commit 8c3e4f491f8d513327c7d20505d33b63066677e4 Author: proitlab Date: Thu Dec 18 16:28:50 2025 +0700 First Commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d2f2892 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,928 @@ +[*] +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/latest/faq/#how-do-i-enable-or-disable-a-rule +ktlint_standard_wrapping = disabled +ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_trailing-comma-on-declaration-site = disabled +ktlint_standard_multiline-expression-wrapping = disabled +ktlint_standard_string-template-indent = disabled +ktlint_standard_spacing-between-declarations-with-annotations = disabled +ktlint_standard_function-signature = disabled +ktlint_standard_annotation = disabled +ktlint_standard_parameter-list-wrapping = disabled +ktlint_standard_indent = disabled +ktlint_standard_blank-line-before-declaration = disabled +ktlint_function_naming_ignore_when_annotated_with = Composable +# Added when upgrading to 1.7.1 +ktlint_standard_function-expression-body = disabled +ktlint_standard_chain-method-continuation = disabled +ktlint_standard_class-signature = disabled +# Added when upgrading to 1.8.0 +ktlint_standard_when-entry-bracing = disabled +ktlint_standard_blank-line-between-when-conditions = disabled +ktlint_standard_mixed-condition-operators = 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 + +[**/generated/**] +generated_code = true +ij_formatter_enabled = false +ktlint = disabled \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0a1fc86 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +screenshots/**/*.png filter=lfs diff=lfs merge=lfs -text +libraries/compound/screenshots/** filter=lfs diff=lfs merge=lfs -text +**/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text +**/docs/images-lfs/*.png filter=lfs diff=lfs merge=lfs -text +libraries/mediaupload/impl/src/test/assets/* filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77acaad --- /dev/null +++ b/.gitignore @@ -0,0 +1,117 @@ +# Copyright (c) 2025 Element Creations Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +# Please see LICENSE files in the repository root for full details. + +# Built application files +*.apk +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Python cache +__pycache__/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/.name +.idea/androidTestResultsUserPreferences.xml +.idea/assetWizardSettings.xml +.idea/AndroidProjectSystem.xml +.idea/compiler.xml +.idea/deploymentTargetDropDown.xml +.idea/deploymentTargetSelector.xml +.idea/deviceManager.xml +.idea/gradle.xml +.idea/jarRepositories.xml +.idea/markdown.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/other.xml +.idea/runConfigurations.xml +.idea/tasks.xml +.idea/workspace.xml +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/copilot +.idea/copilot.* +.idea/inspectionProfiles +# Shelved changes in the IDE +.idea/shelf +.idea/sonarlint + +# .kotlin folder +.kotlin + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +/tmp +.DS_Store + +checkouts/** diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..aa54a9d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "enterprise"] + path = enterprise + url = git@github.com:element-hq/element-android-enterprise.git diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..cdef735 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,124 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/copyright/Element_Enterprise.xml b/.idea/copyright/Element_Enterprise.xml new file mode 100644 index 0000000..b556049 --- /dev/null +++ b/.idea/copyright/Element_Enterprise.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/copyright/Element_FOSS.xml b/.idea/copyright/Element_FOSS.xml new file mode 100644 index 0000000..bfc80c4 --- /dev/null +++ b/.idea/copyright/Element_FOSS.xml @@ -0,0 +1,6 @@ + + + + diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml new file mode 100644 index 0000000..2e68dd3 --- /dev/null +++ b/.idea/copyright/profiles_settings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/dictionaries/shared.xml b/.idea/dictionaries/shared.xml new file mode 100644 index 0000000..6770828 --- /dev/null +++ b/.idea/dictionaries/shared.xml @@ -0,0 +1,27 @@ + + + + agpl + backstack + blurhash + fdroid + ftue + gplay + homeserver + konsist + kover + measurables + onboarding + placeables + posthog + rageshake + securebackup + showkase + snackbar + spdx + swipeable + textfields + tombstoned + + + diff --git a/.idea/icon.png b/.idea/icon.png new file mode 100644 index 0000000..6f78722 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 0000000..3efb2d8 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/scopes/Enterprise.xml b/.idea/scopes/Enterprise.xml new file mode 100644 index 0000000..83599ae --- /dev/null +++ b/.idea/scopes/Enterprise.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/.maestro/README.md b/.maestro/README.md new file mode 100644 index 0000000..c0ee525 --- /dev/null +++ b/.maestro/README.md @@ -0,0 +1,74 @@ +# 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) + + + +* [Run test](#run-test) + * [Output](#output) +* [Write test](#write-test) +* [CI](#ci) +* [iOS](#ios) +* [Future](#future) + + + +## Run test + +From root dir of the project + +*Note: Since Element X does not allow account creation, we have to use an existing account 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 joined. Note that the test will send messages to this room.* + +```shell +maestro test \ + -e MAESTRO_APP_ID=io.element.android.x.debug \ + -e MAESTRO_USERNAME=user1 \ + -e MAESTRO_PASSWORD=123 \ + -e MAESTRO_RECOVERY_KEY=ABC \ + -e MAESTRO_ROOM_NAME="MyRoom" \ + -e MAESTRO_INVITEE1_MXID=user2 \ + -e MAESTRO_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 Element X 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 0000000..ecbde4d --- /dev/null +++ b/.maestro/allTests.yaml @@ -0,0 +1,10 @@ +appId: ${MAESTRO_APP_ID} +androidWebViewHierarchy: devtools +--- +## Check that all env variables required in the whole test suite are declared (to fail faster) +- runScript: ./scripts/checkEnv.js +- 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/scripts/checkEnv.js b/.maestro/scripts/checkEnv.js new file mode 100644 index 0000000..fa61d3c --- /dev/null +++ b/.maestro/scripts/checkEnv.js @@ -0,0 +1,10 @@ +// This array contains all the required environment variable. When adding a variable, add it here also. +// If a variable is missing, an error will occur. + +if (MAESTRO_APP_ID == null) throw "Fatal: missing env variable MAESTRO_APP_ID" +if (MAESTRO_USERNAME == null) throw "Fatal: missing env variable MAESTRO_USERNAME" +if (MAESTRO_PASSWORD == null) throw "Fatal: missing env variable MAESTRO_PASSWORD" +if (MAESTRO_RECOVERY_KEY == null) throw "Fatal: missing env variable MAESTRO_RECOVERY_KEY" +if (MAESTRO_ROOM_NAME == null) throw "Fatal: missing env variable MAESTRO_ROOM_NAME" +if (MAESTRO_INVITEE1_MXID == null) throw "Fatal: missing env variable MAESTRO_INVITEE1_MXID" +if (MAESTRO_INVITEE2_MXID == null) throw "Fatal: missing env variable MAESTRO_INVITEE2_MXID" diff --git a/.maestro/tests/account/changeServer.yaml b/.maestro/tests/account/changeServer.yaml new file mode 100644 index 0000000..b07fa5c --- /dev/null +++ b/.maestro/tests/account/changeServer.yaml @@ -0,0 +1,21 @@ +appId: ${MAESTRO_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 +- extendedWaitUntil: + visible: "element.io" + timeout: 10000 +- tapOn: "element.io" +# Revert to matrix.org +- tapOn: + id: "login-change_server" +- tapOn: "matrix.org" diff --git a/.maestro/tests/account/login.yaml b/.maestro/tests/account/login.yaml new file mode 100644 index 0000000..f3f584e --- /dev/null +++ b/.maestro/tests/account/login.yaml @@ -0,0 +1,47 @@ +appId: ${MAESTRO_APP_ID} +--- +- tapOn: "Sign in manually" +- runFlow: ../assertions/assertLoginDisplayed.yaml +- takeScreenshot: build/maestro/100-SignIn +- runFlow: changeServer.yaml +- runFlow: ../assertions/assertLoginDisplayed.yaml +- tapOn: + id: "login-continue" +## MAS page +## Conditional workflow to pass the Chrome first launch welcome page. +- runFlow: + when: + visible: 'Use without an account' + commands: + - tapOn: "Use without an account" +## For older chrome versions +- runFlow: + when: + visible: 'Accept & continue' + commands: + - tapOn: "Accept & continue" +- runFlow: + when: + visible: 'No thanks' + commands: + - tapOn: "No thanks" +## Working when running Maestro locally, but not on the CI yet. +- extendedWaitUntil: + visible: + id: "form-1" + timeout: 10000 +- tapOn: + id: "form-1" +- inputText: ${MAESTRO_USERNAME} +- pressKey: Enter +- tapOn: + id: "form-3" +- inputText: ${MAESTRO_PASSWORD} +- pressKey: Enter +- tapOn: "Continue" +## Back to native world +- runFlow: ../assertions/assertSessionVerificationDisplayed.yaml +- runFlow: ./verifySession.yaml +- 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 0000000..f27f5da --- /dev/null +++ b/.maestro/tests/account/logout.yaml @@ -0,0 +1,15 @@ +appId: ${MAESTRO_APP_ID} +--- +- tapOn: + id: "home_screen-settings" +- tapOn: "Sign out" +- takeScreenshot: build/maestro/900-SignOutScreen +- back +- tapOn: "Sign out" +# Ensure cancel cancels +- tapOn: + id: "dialog-negative" +- tapOn: "Sign out" +- tapOn: + id: "dialog-positive" +- runFlow: ../assertions/assertInitDisplayed.yaml diff --git a/.maestro/tests/account/verifySession.yaml b/.maestro/tests/account/verifySession.yaml new file mode 100644 index 0000000..a163225 --- /dev/null +++ b/.maestro/tests/account/verifySession.yaml @@ -0,0 +1,13 @@ +appId: ${MAESTRO_APP_ID} +--- +- takeScreenshot: build/maestro/150-Verify +- tapOn: "Enter recovery key" +- tapOn: + id: "verification-recovery_key" +- inputText: ${MAESTRO_RECOVERY_KEY} +- hideKeyboard +- tapOn: "Continue" +- extendedWaitUntil: + visible: "Device verified" + timeout: 30000 +- tapOn: "Continue" diff --git a/.maestro/tests/assertions/assertAnalyticsDisplayed.yaml b/.maestro/tests/assertions/assertAnalyticsDisplayed.yaml new file mode 100644 index 0000000..516dcc8 --- /dev/null +++ b/.maestro/tests/assertions/assertAnalyticsDisplayed.yaml @@ -0,0 +1,5 @@ +appId: ${MAESTRO_APP_ID} +--- +- extendedWaitUntil: + visible: "Help improve Element X dbg" + timeout: 10000 diff --git a/.maestro/tests/assertions/assertHomeDisplayed.yaml b/.maestro/tests/assertions/assertHomeDisplayed.yaml new file mode 100644 index 0000000..c371d3b --- /dev/null +++ b/.maestro/tests/assertions/assertHomeDisplayed.yaml @@ -0,0 +1,5 @@ +appId: ${MAESTRO_APP_ID} +--- +- extendedWaitUntil: + visible: "Chats" + timeout: 10000 diff --git a/.maestro/tests/assertions/assertInitDisplayed.yaml b/.maestro/tests/assertions/assertInitDisplayed.yaml new file mode 100644 index 0000000..6e895d9 --- /dev/null +++ b/.maestro/tests/assertions/assertInitDisplayed.yaml @@ -0,0 +1,5 @@ +appId: ${MAESTRO_APP_ID} +--- +- extendedWaitUntil: + visible: "Be in your element" + timeout: 10000 diff --git a/.maestro/tests/assertions/assertLoginDisplayed.yaml b/.maestro/tests/assertions/assertLoginDisplayed.yaml new file mode 100644 index 0000000..6d8558c --- /dev/null +++ b/.maestro/tests/assertions/assertLoginDisplayed.yaml @@ -0,0 +1,5 @@ +appId: ${MAESTRO_APP_ID} +--- +- extendedWaitUntil: + visible: "Change account provider" + timeout: 10000 diff --git a/.maestro/tests/assertions/assertRoomListSynced.yaml b/.maestro/tests/assertions/assertRoomListSynced.yaml new file mode 100644 index 0000000..0eb1c52 --- /dev/null +++ b/.maestro/tests/assertions/assertRoomListSynced.yaml @@ -0,0 +1,5 @@ +appId: ${MAESTRO_APP_ID} +--- +- extendedWaitUntil: + visible: ${MAESTRO_ROOM_NAME} + timeout: 10000 diff --git a/.maestro/tests/assertions/assertSessionVerificationDisplayed.yaml b/.maestro/tests/assertions/assertSessionVerificationDisplayed.yaml new file mode 100644 index 0000000..f983ced --- /dev/null +++ b/.maestro/tests/assertions/assertSessionVerificationDisplayed.yaml @@ -0,0 +1,5 @@ +appId: ${MAESTRO_APP_ID} +--- +- extendedWaitUntil: + visible: "Confirm your identity" + timeout: 20000 diff --git a/.maestro/tests/init.yaml b/.maestro/tests/init.yaml new file mode 100644 index 0000000..6cb056d --- /dev/null +++ b/.maestro/tests/init.yaml @@ -0,0 +1,7 @@ +appId: ${MAESTRO_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 0000000..7e33fd1 --- /dev/null +++ b/.maestro/tests/roomList/createAndDeleteDM.yaml @@ -0,0 +1,15 @@ +appId: ${MAESTRO_APP_ID} +--- +# Purpose: Test the creation and deletion of a DM room. +- tapOn: "Create a new conversation or room" +- tapOn: "Search for someone" +- inputText: ${MAESTRO_INVITEE1_MXID} +- tapOn: + text: ${MAESTRO_INVITEE1_MXID} + index: 1 +- tapOn: "Send invite" +- takeScreenshot: build/maestro/330-createAndDeleteDM +- tapOn: "maestroelement2" +- scroll +- tapOn: "Leave room" +- tapOn: "Leave" diff --git a/.maestro/tests/roomList/createAndDeleteRoom.yaml b/.maestro/tests/roomList/createAndDeleteRoom.yaml new file mode 100644 index 0000000..a72fb80 --- /dev/null +++ b/.maestro/tests/roomList/createAndDeleteRoom.yaml @@ -0,0 +1,39 @@ +appId: ${MAESTRO_APP_ID} +--- +# Purpose: Test the creation and deletion of a room +- tapOn: "Create a new conversation or room" +- tapOn: "New room" +- tapOn: "e.g. your project name" +- inputText: "aRoomName" +- tapOn: "What is this room about?" +- inputText: "aRoomTopic" +- tapOn: "Create" +- takeScreenshot: build/maestro/320-createAndDeleteRoom +- tapOn: "Search for someone" +- inputText: ${MAESTRO_INVITEE1_MXID} +- tapOn: + text: ${MAESTRO_INVITEE1_MXID} + index: 1 +- tapOn: "Finish" +- tapOn: "aRoomName" +- tapOn: "Invite" +# assert there's 1 member and 1 invitee +- tapOn: "Search for someone" +- inputText: ${MAESTRO_INVITEE2_MXID} +- tapOn: + text: ${MAESTRO_INVITEE2_MXID} + index: 1 +- tapOn: "Invite" +- tapOn: "Back" +- tapOn: "aRoomName" +- scrollUntilVisible: + direction: DOWN + element: + text: "People" +- tapOn: "People" +# assert there's 1 member and 2 invitees +- tapOn: "Back" +- scroll +- scroll +- tapOn: "Leave room" +- tapOn: "Leave" diff --git a/.maestro/tests/roomList/roomContextMenu.yaml b/.maestro/tests/roomList/roomContextMenu.yaml new file mode 100644 index 0000000..160f8a3 --- /dev/null +++ b/.maestro/tests/roomList/roomContextMenu.yaml @@ -0,0 +1,14 @@ +appId: ${MAESTRO_APP_ID} +--- +# Purpose: Test the context menu of a room in the room list +- longPressOn: ${MAESTRO_ROOM_NAME} +- takeScreenshot: build/maestro/310-RoomList-ContextMenu +- tapOn: + text: "Settings" + index: 0 +- tapOn: "Back" +- longPressOn: ${MAESTRO_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 0000000..5cc9e26 --- /dev/null +++ b/.maestro/tests/roomList/roomList.yaml @@ -0,0 +1,8 @@ +appId: ${MAESTRO_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 0000000..09197f0 --- /dev/null +++ b/.maestro/tests/roomList/searchRoomList.yaml @@ -0,0 +1,18 @@ +appId: ${MAESTRO_APP_ID} +--- +- runFlow: ../assertions/assertRoomListSynced.yaml +- tapOn: "search" +- inputText: ${MAESTRO_ROOM_NAME.substring(0, 3)} +- takeScreenshot: build/maestro/400-SearchRoom +- tapOn: ${MAESTRO_ROOM_NAME} +# Back from timeline to search +- back +- extendedWaitUntil: + visible: ${MAESTRO_ROOM_NAME.substring(0, 3)} + timeout: 10000 +# Back to close the keyboard +- back +- waitForAnimationToEnd +# Back to close the home screen +- back +- runFlow: ../assertions/assertHomeDisplayed.yaml diff --git a/.maestro/tests/roomList/timeline/call/call.yaml b/.maestro/tests/roomList/timeline/call/call.yaml new file mode 100644 index 0000000..e390d0a --- /dev/null +++ b/.maestro/tests/roomList/timeline/call/call.yaml @@ -0,0 +1,13 @@ +appId: ${MAESTRO_APP_ID} +--- +- tapOn: "Start a call" +- takeScreenshot: build/maestro/700-Call +- extendedWaitUntil: + visible: "maestroelement" + timeout: 10000 +- takeScreenshot: build/maestro/710-Call +# Hangup +- tapOn: "End call" +- extendedWaitUntil: + visible: "MyRoom" + timeout: 10000 diff --git a/.maestro/tests/roomList/timeline/messages/location.yaml b/.maestro/tests/roomList/timeline/messages/location.yaml new file mode 100644 index 0000000..c9382bd --- /dev/null +++ b/.maestro/tests/roomList/timeline/messages/location.yaml @@ -0,0 +1,7 @@ +appId: ${MAESTRO_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/poll.yaml b/.maestro/tests/roomList/timeline/messages/poll.yaml new file mode 100644 index 0000000..c6fffeb --- /dev/null +++ b/.maestro/tests/roomList/timeline/messages/poll.yaml @@ -0,0 +1,13 @@ +appId: ${MAESTRO_APP_ID} +--- +- takeScreenshot: build/maestro/530-Timeline +- tapOn: "Add attachment" +- tapOn: "Poll" +- tapOn: "What is the poll about?" +- inputText: "I am a poll" +- tapOn: "Option 1" +- inputText: "Answer 1" +- tapOn: "Option 2" +- inputText: "Answer 2" +- tapOn: "Create" +- takeScreenshot: build/maestro/531-Timeline diff --git a/.maestro/tests/roomList/timeline/messages/text.yaml b/.maestro/tests/roomList/timeline/messages/text.yaml new file mode 100644 index 0000000..4e1f4bc --- /dev/null +++ b/.maestro/tests/roomList/timeline/messages/text.yaml @@ -0,0 +1,9 @@ +appId: ${MAESTRO_APP_ID} +--- +- takeScreenshot: build/maestro/510-Timeline +- tapOn: + id: "text_editor" +- inputText: "Hello world!" +- tapOn: "Send message" +- 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 0000000..0ad9231 --- /dev/null +++ b/.maestro/tests/roomList/timeline/timeline.yaml @@ -0,0 +1,14 @@ +appId: ${MAESTRO_APP_ID} +--- +# This is the name of one room +- tapOn: ${MAESTRO_ROOM_NAME} +- takeScreenshot: build/maestro/500-Timeline +- runFlow: messages/text.yaml +- runFlow: messages/location.yaml +- runFlow: messages/poll.yaml + +# Restore once the call flow is fixed +#- runFlow: call/call.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 0000000..15181a4 --- /dev/null +++ b/.maestro/tests/settings/settings.yaml @@ -0,0 +1,46 @@ +appId: ${MAESTRO_APP_ID} +--- +- tapOn: + id: "home_screen-settings" +- assertVisible: "Settings" +- takeScreenshot: build/maestro/600-Settings +- tapOn: + text: "Analytics" +- assertVisible: "Share analytics data" +- back + +- tapOn: + text: "Notifications" +- assertVisible: "Enable notifications on this device" +- back + +- tapOn: + text: "Report a problem" +- assertVisible: "Report a problem" +- back + +- tapOn: + text: "About" +- assertVisible: "Copyright" +- assertVisible: "Acceptable use policy" +- assertVisible: "Privacy policy" +- back + +- tapOn: + text: "Screen lock" +- assertVisible: "Choose PIN" +- hideKeyboard +- back + +- tapOn: + text: "Advanced settings" +- assertVisible: "View source" +- 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 0000000..b3c2de6 --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,17 @@ +A full developer contributors list can be found [here](https://github.com/element-hq/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 0000000..01a83c0 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,3034 @@ +Changes in Element X v25.11.3 +============================= + + + +## What's Changed +### 🙌 Improvements +* Improve rendering notification for multi account by @bmarty in https://github.com/element-hq/element-x-android/pull/5645 +* Change : roles and permissions by @ganfra in https://github.com/element-hq/element-x-android/pull/5685 +* Improve account provider selection during the login flow by @bmarty in https://github.com/element-hq/element-x-android/pull/5692 +* Let notifications use avatar fallback. by @bmarty in https://github.com/element-hq/element-x-android/pull/5721 +* Changes : member list improvements by @ganfra in https://github.com/element-hq/element-x-android/pull/5728 +### 🐛 Bugfixes +* Do not use the bestDescription but the caption for images, when available by @bmarty in https://github.com/element-hq/element-x-android/pull/5684 +* Add the user certificate if any when creating Matrix Client. by @bmarty in https://github.com/element-hq/element-x-android/pull/5686 +* Ensure the form data are not lost when opening the log viewer. by @bmarty in https://github.com/element-hq/element-x-android/pull/5695 +* Fix password flow when using a login link by @bmarty in https://github.com/element-hq/element-x-android/pull/5693 +* Fix layout issue in text composer by @bmarty in https://github.com/element-hq/element-x-android/pull/5710 +* Fix navigation stack overflow when sharing media by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5724 +* Notification robustness by @bmarty in https://github.com/element-hq/element-x-android/pull/5726 +* Send read receipts using the current timeline, not the live timeline by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5731 +* Render Owner in the horizontal list when editing Admins. by @bmarty in https://github.com/element-hq/element-x-android/pull/5736 +* Stop overriding the homeserver when restoring a `Client` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5753 +* Revert "Stop overriding the homeserver when restoring a `Client`" by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5754 +* Try fixing forced dark mode issues on MIUI on Android 10 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5708 +* Fix crash at startup by @bmarty in https://github.com/element-hq/element-x-android/pull/5761 +* Fix null pointer exception on room notification settings. by @bmarty in https://github.com/element-hq/element-x-android/pull/5758 +* Fix crash when viewing Pinned events by @bmarty in https://github.com/element-hq/element-x-android/pull/5764 +* Fix crash when pressing back from the showkase Activity by @bmarty in https://github.com/element-hq/element-x-android/pull/5772 +* Fix navigation issue once incoming share is handled by @bmarty in https://github.com/element-hq/element-x-android/pull/5773 +* Fix crash in work manager by @bmarty in https://github.com/element-hq/element-x-android/pull/5768 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5704 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5747 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5782 +### 🧱 Build +* Module cleanup by @bmarty in https://github.com/element-hq/element-x-android/pull/5722 +* Add `NIGHTLY` env for Sentry by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5779 +### 🚧 In development 🚧 +* Space : prepare Space Settings screen by @ganfra in https://github.com/element-hq/element-x-android/pull/5668 +### Dependency upgrades +* fix(deps): update dependency androidx.core:core-splashscreen to v1.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5687 +* fix(deps): update dependency com.posthog:posthog-android to v3.26.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5696 +* fix(deps): update metro to v0.7.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5697 +* Update dependency org.matrix.rustcomponents:sdk-android to v25.11.11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5716 +* Update plugin ktlint to v14 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5713 +* Update plugin dependencycheck to v12.1.9 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5717 +* Update dependency org.maplibre.gl:android-sdk to v12.1.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5714 +* Update dependency io.sentry:sentry-android to v8.26.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5720 +* Update sqldelight to v2.2.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5730 +* fix(deps): update dependency com.squareup.okhttp3:okhttp-bom to v5.3.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5746 +* fix(deps): update dependency com.google.firebase:firebase-bom to v34.6.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5737 +* fix(deps): update metro to v0.7.6 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5752 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v12.1.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5743 +* Update dependency com.squareup.okhttp3:okhttp-bom to v5.3.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5757 +* fix(deps): update dependency com.pinterest.ktlint:ktlint-cli to v1.8.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5738 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.11.19 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5762 +* fix(deps): update dependencyanalysis to v3.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5776 +### Others +* Extract save change dialog by @bmarty in https://github.com/element-hq/element-x-android/pull/5679 +* Use the dedicated subdomain for the bug report URL by default by @benbz in https://github.com/element-hq/element-x-android/pull/5689 +* Convert `ComposerAlertMolecule` to use alert levels. by @kaylendog in https://github.com/element-hq/element-x-android/pull/5691 +* Improve composer alert molecule by @bmarty in https://github.com/element-hq/element-x-android/pull/5701 +* Code consistency around view event handling by @bmarty in https://github.com/element-hq/element-x-android/pull/5698 +* Update copyright holders by @bmarty in https://github.com/element-hq/element-x-android/pull/5706 +* Fix rendering notifications after receiving redundant push by @SpiritCroc in https://github.com/element-hq/element-x-android/pull/5711 +* Fix push gateway with some push provider (Sunup/autopush) by @p1gp1g in https://github.com/element-hq/element-x-android/pull/5741 +* Use new notification sound in release. by @bmarty in https://github.com/element-hq/element-x-android/pull/5748 +* Fix issue on brand color override by @bmarty in https://github.com/element-hq/element-x-android/pull/5626 +* Add media retention policy by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5749 +* Enable logging OkHttp traffic based on the current log level by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5750 +* Remove unused `slidingSyncProxy` from DB. by @bmarty in https://github.com/element-hq/element-x-android/pull/5755 +* Add some performance metrics for Sentry by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5760 + +## New Contributors +* @benbz made their first contribution in https://github.com/element-hq/element-x-android/pull/5689 +* @kaylendog made their first contribution in https://github.com/element-hq/element-x-android/pull/5691 + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.11.2...v25.11.3 + +Changes in Element X v25.11.2 +============================= + + + +## What's Changed +### ✨ Features +* Enable access to security and privacy by @bmarty in https://github.com/element-hq/element-x-android/pull/5566 +* Add ability to forward a media from the media viewer and the gallery by @bmarty in https://github.com/element-hq/element-x-android/pull/5622 +* Split notifications for messages in threads by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5595 +### 🙌 Improvements +* Enable `SyncNotificationsWithWorkManager` in nightly and debug builds by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5573 +* Confirm exit without saving change in room details edit screen by @bmarty in https://github.com/element-hq/element-x-android/pull/5618 +* Space : add view members entry by @ganfra in https://github.com/element-hq/element-x-android/pull/5619 +* Update notification sound by @bmarty in https://github.com/element-hq/element-x-android/pull/5667 +* Use the new notification sound only on debug and nightly build by @bmarty in https://github.com/element-hq/element-x-android/pull/5673 +* Make sure we know the session verification state before showing the options to verify the session by @bmarty in https://github.com/element-hq/element-x-android/pull/5677 +### 🐛 Bugfixes +* Improve how brand color is applied. by @bmarty in https://github.com/element-hq/element-x-android/pull/5584 +* Improve wellknown retrieval API by @bmarty in https://github.com/element-hq/element-x-android/pull/5587 +* Clearing the room list search clears the search term too by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5603 +* Delete pin code only when the last session is deleted by @bmarty in https://github.com/element-hq/element-x-android/pull/5600 +* Fix issues with WorkManager on Android 12 and below by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5606 +* Fix marking a room as read re-instantiates its timeline by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5628 +* Display only valid emojis in recent emoji list by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5612 +* Fix navigation issue. by @bmarty in https://github.com/element-hq/element-x-android/pull/5666 +* Fix forward events from media viewer from pinned media timeline by @bmarty in https://github.com/element-hq/element-x-android/pull/5669 +* Try fixing 'Timeline Event object has already been destroyed' by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5675 +* Use the SDK Client to check whether a homeserver is compatible by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5664 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5610 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5662 +### 🧱 Build +* Remove `@Inject`, not necessary anymore when class is annotated with `@ContributesBinding` by @bmarty in https://github.com/element-hq/element-x-android/pull/5589 +* Upgrade ktlint to 1.7.1 and ensure Renovate will upgrade the version by @bmarty in https://github.com/element-hq/element-x-android/pull/5638 +* Improve architecture around Nodes by @bmarty in https://github.com/element-hq/element-x-android/pull/5641 +* Move dependencies block out of the android block. by @bmarty in https://github.com/element-hq/element-x-android/pull/5674 +* Always use the handleEvent(s) function the same way. by @bmarty in https://github.com/element-hq/element-x-android/pull/5672 +### Dependency upgrades +* fix(deps): update metro to v0.7.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5576 +* fix(deps): update dependencyanalysis to v3.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5577 +* fix(deps): update dependency io.sentry:sentry-android to v8.24.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5586 +* fix(deps): update dependency androidx.work:work-runtime-ktx to v2.11.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5590 +* fix(deps): update dependency com.posthog:posthog-android to v3.25.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5594 +* fix(deps): update dependency com.google.crypto.tink:tink-android to v1.19.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5572 +* Update plugin sonarqube to v7.0.1.6134 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5605 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.10.28 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5620 +* fix(deps): update dependencyanalysis to v3.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5602 +* fix(deps): update dependency com.github.matrix-org:matrix-analytics-events to v0.29.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5621 +* fix(deps): update dependencyanalysis to v3.4.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5624 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.10.29 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5625 +* fix(deps): update dependency io.sentry:sentry-android to v8.25.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5629 +* fix(deps): update dependencyanalysis to v3.4.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5642 +* fix(deps): update dependency com.squareup.okhttp3:okhttp-bom to v5.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5644 +* chore(deps): update danger/danger-js action to v13.0.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5652 +* fix(deps): update dependency com.google.firebase:firebase-bom to v34.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5643 +* fix(deps): update firebaseappdistribution to v5.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5640 +* fix(deps): update metro to v0.7.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5663 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.10.31 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5657 +* Update GitHub Artifact Actions (major) by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5609 +* Update dependency io.element.android:element-call-embedded to v0.16.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5598 +* Update roborazzi to v1.51.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5676 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.11.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5681 +* fix(deps): update metro to v0.7.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5683 +### Others +* Improve code around Element .well-known configuration by @bmarty in https://github.com/element-hq/element-x-android/pull/5565 +* misc: display offline banner for all LoggedIn screens by @ganfra in https://github.com/element-hq/element-x-android/pull/5574 +* Remove icon preview duplicate by @bmarty in https://github.com/element-hq/element-x-android/pull/5588 +* Remove application navigation state usage in the push module by @bmarty in https://github.com/element-hq/element-x-android/pull/5596 +* Design : update Home TopBar and RoomList Filters by @ganfra in https://github.com/element-hq/element-x-android/pull/5599 +* Add missing tests on the analytic modules by @bmarty in https://github.com/element-hq/element-x-android/pull/5604 +* design(space): let SpaceRoomItemView divider be full width by @ganfra in https://github.com/element-hq/element-x-android/pull/5597 +* Update notification style by @bmarty in https://github.com/element-hq/element-x-android/pull/5607 +* Improve how data is handled for the WorkManager. by @bmarty in https://github.com/element-hq/element-x-android/pull/5592 +* Revert "Make sure declining a call stops observing the ringing call state" by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5615 +* Misc : space flow inject room by @ganfra in https://github.com/element-hq/element-x-android/pull/5614 +* Enable `SyncNotificationsWithWorkManager` by default in release mode apps too by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5646 +* Revert "Update notification sound" by @bmarty in https://github.com/element-hq/element-x-android/pull/5671 +* Introduce new query to count accounts by @bmarty in https://github.com/element-hq/element-x-android/pull/5678 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.11.0...v25.11.2 + +Changes in Element X v25.11.0 +============================= + +Hotfix release. + +Includes https://github.com/element-hq/element-x-android/pull/5615, which fixes an issue that prevented Element Call notifications from being displayed sometimes. + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.10.1...v25.11.0 + +Changes in Element X v25.10.1 +============================= + + + +## What's Changed +### ✨ Features +* Sync notifications using WorkManager by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5545 +### 🙌 Improvements +* Sort feature flags by @bmarty in https://github.com/element-hq/element-x-android/pull/5557 +### 🐛 Bugfixes +* Makes sure images are loaded when cancelling multiaccount flow by @ganfra in https://github.com/element-hq/element-x-android/pull/5502 +* Fix 'test push loop back' notification check by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5541 +* Display 'join anyway' button on room preview when the state can't be loaded by @ShadowRZ in https://github.com/element-hq/element-x-android/pull/5514 +* Fix media viewer not being dismissed with reduced motion enabled by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5555 +* Keep the cursor position in room list search when going back by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5570 +* Make sure declining a call stops observing the ringing call state by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5563 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5515 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5562 +### 🧱 Build +* Do some cleanup on our immutable annotation usage by @bmarty in https://github.com/element-hq/element-x-android/pull/5503 +* `interface TestParameterValuesProvider` is deprecated. by @bmarty in https://github.com/element-hq/element-x-android/pull/5568 +### Dependency upgrades +* fix(deps): update metro to v0.6.9 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5480 +* fix(deps): update dependency org.unifiedpush.android:connector to v3.1.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5443 +* fix(deps): update wysiwyg to v2.40.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5400 +* fix(deps): update dependency io.github.sergio-sastre.composablepreviewscanner:android to v0.7.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5510 +* fix(deps): update camera to v1.5.1 - autoclosed by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5509 +* chore(deps): update plugin dependencycheck to v12.1.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5518 +* chore(deps): update plugin licensee to v1.14.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5477 +* chore(deps): update dependency python to 3.14 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5475 +* fix(deps): update metro to v0.6.10 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5520 +* fix(deps): update dependency org.unifiedpush.android:connector to v3.1.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5519 +* chore(deps): update plugin gms_google_services to v4.4.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5507 +* fix(deps): update dependency com.google.firebase:firebase-bom to v34.4.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5522 +* fix(deps): update dependency com.squareup.okhttp3:okhttp-bom to v5.2.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5524 +* fix(deps): update dependency net.zetetic:sqlcipher-android to v4.11.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5525 +* fix(deps): update dependencyanalysis to v3.1.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5523 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.10.13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5527 +* chore(deps): update plugin dependencycheck to v12.1.8 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5531 +* chore(deps): update rnkdsh/action-upload-diawi action to v1.5.12 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5533 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v12.0.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5548 +* fix(deps): update metro to v0.7.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5554 +* fix(deps): update dependency com.posthog:posthog-android to v3.24.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5564 +* chore(deps): update plugin sonarqube to v7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5535 +### Others +* Import Compound tokens - fixed icons by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5506 +* Replace Uri by String in States that are used in Composable function. by @bmarty in https://github.com/element-hq/element-x-android/pull/5508 +* Let room filters follow the design. by @bmarty in https://github.com/element-hq/element-x-android/pull/5526 +* Allow uploading notification push rules in bug reports by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5538 +* Add number of accounts info in the rageshake data. by @bmarty in https://github.com/element-hq/element-x-android/pull/5532 +* design(space): match figma for Space views by @ganfra in https://github.com/element-hq/element-x-android/pull/5540 +* Extract console message logger and mutualize instance of Json by @bmarty in https://github.com/element-hq/element-x-android/pull/5552 +* Improve colors customization by @bmarty in https://github.com/element-hq/element-x-android/pull/5542 +* Fix test warning by @bmarty in https://github.com/element-hq/element-x-android/pull/5558 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.10.0...v25.10.1 + +Changes in Element X v25.10.0 +============================= + + + +## What's Changed +### ✨ Features +* Use shared recent emoji reactions from account data by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5402 +* Follow permalinks to and from threads by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5414 +* Add support for Spaces by @bmarty in https://github.com/element-hq/element-x-android/pull/5462 +* Add Labs screen for beta testing of public features by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5465 +### 🙌 Improvements +* Update the strings for the device verification flow by @andybalaam in https://github.com/element-hq/element-x-android/pull/5419 +* Set a notification sound by @bmarty in https://github.com/element-hq/element-x-android/pull/5469 +* Improve current push provider test: give info about the distributor. by @bmarty in https://github.com/element-hq/element-x-android/pull/5471 +* Improve AnnouncementService. by @bmarty in https://github.com/element-hq/element-x-android/pull/5482 +### 🐛 Bugfixes +* Improvement and bugfix on incoming verification request screen by @bmarty in https://github.com/element-hq/element-x-android/pull/5426 +* Space : makes sure to use room heroes for avatar by @ganfra in https://github.com/element-hq/element-x-android/pull/5488 +* Filter out direct room in the leave space screen. by @bmarty in https://github.com/element-hq/element-x-android/pull/5498 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5427 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5460 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5486 +### 🧱 Build +* Remove unused dependency on `javax.inject:javax.inject` by @bmarty in https://github.com/element-hq/element-x-android/pull/5445 +* Internalize compound-android by @bmarty in https://github.com/element-hq/element-x-android/pull/5457 +### 🚧 In development 🚧 +* Sdk : use latest apis for space by @ganfra in https://github.com/element-hq/element-x-android/pull/5404 +* Multi accounts - experimental first implementation by @bmarty in https://github.com/element-hq/element-x-android/pull/5285 +* Leave space - UI by @bmarty in https://github.com/element-hq/element-x-android/pull/5354 +* Leave spave: iteration on string value. by @bmarty in https://github.com/element-hq/element-x-android/pull/5425 +* Feature : space list join action by @ganfra in https://github.com/element-hq/element-x-android/pull/5431 +* Room list space invite by @ganfra in https://github.com/element-hq/element-x-android/pull/5449 +* Leave space: use SDK API. by @bmarty in https://github.com/element-hq/element-x-android/pull/5432 +* Space annoucement by @bmarty in https://github.com/element-hq/element-x-android/pull/5451 +* feature(space) : keep space children in the presenter by @ganfra in https://github.com/element-hq/element-x-android/pull/5456 +* Spaces : some tweaks around ui by @ganfra in https://github.com/element-hq/element-x-android/pull/5468 +* Use "BETA" word from Localazy and ensure layout is correct by @bmarty in https://github.com/element-hq/element-x-android/pull/5470 +* Disable avatar cluster for now by @bmarty in https://github.com/element-hq/element-x-android/pull/5492 +### Dependency upgrades +* Update dependency com.posthog:posthog-android to v3.21.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5360 +* Update dependency io.element.android:element-call-embedded to v0.16.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5408 +* Update dependency net.java.dev.jna:jna to v5.18.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5398 +* Update plugin dependencycheck to v12.1.6 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5405 +* Update dependency org.matrix.rustcomponents:sdk-android to v25.9.25 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5412 +* Update dependency androidx.sqlite:sqlite-ktx to v2.6.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5409 +* Update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5317 +* Update metro to v0.6.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5416 +* Update dependency app.cash.molecule:molecule-runtime to v2.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5413 +* Update dependency com.posthog:posthog-android to v3.22.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5415 +* Update metro to v0.6.8 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5422 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.10.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5438 +* fix(deps): update dependency net.java.dev.jna:jna to v5.18.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5437 +* fix(deps): update dependency io.mockk:mockk to v1.14.6 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5441 +* Update gradle/actions action to v5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5444 +* fix(deps): update dependency io.sentry:sentry-android to v8.23.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5442 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v12 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5455 +* fix(deps): update dependency com.posthog:posthog-android to v3.23.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5463 +* fix(deps): update roborazzi to v1.50.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5464 +* fix(deps): update telephoto to v0.18.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5459 +### Others +* Ensure Metro `@AssistedInject` is used. by @bmarty in https://github.com/element-hq/element-x-android/pull/5420 +* Misc : destroy SpaceRoomList by @ganfra in https://github.com/element-hq/element-x-android/pull/5436 +* Remove CurrentSessionIdHolder and inject SessionId instead. by @bmarty in https://github.com/element-hq/element-x-android/pull/5440 +* Only offer to verify if a cross-signed device is available by @uhoreg in https://github.com/element-hq/element-x-android/pull/5433 +* Replace fun by val in MatrixClient by @bmarty in https://github.com/element-hq/element-x-android/pull/5466 +* Space : makes sure to use SpaceRoom.displayName from sdk by @ganfra in https://github.com/element-hq/element-x-android/pull/5476 +* Add preview with all icons in the Showkase browser by @bmarty in https://github.com/element-hq/element-x-android/pull/5485 +* Ensure that we are using Immutable instead of Persistent by @bmarty in https://github.com/element-hq/element-x-android/pull/5490 +* Reduce number of Previews for Avatar. by @bmarty in https://github.com/element-hq/element-x-android/pull/5495 +* Fix error when attempting to verify with recovery key with missing backup key by @uhoreg in https://github.com/element-hq/element-x-android/pull/5314 +* Sync strings by @bmarty in https://github.com/element-hq/element-x-android/pull/5499 +* feature(space): make sure to handle topic properly by @ganfra in https://github.com/element-hq/element-x-android/pull/5493 + +## New Contributors +* @uhoreg made their first contribution in https://github.com/element-hq/element-x-android/pull/5433 + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.09.2...v25.10.0 + +Changes in Element X v25.09.2 +============================= + +## What's Changed +### ✨ Features +* Show progress dialog while we are sending invites in a room by @richvdh in https://github.com/element-hq/element-x-android/pull/5342 +* Call: RTC decline event support by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/5305 +* Add room info to the thread's top app bar by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5374 +### 🙌 Improvements +* Use the new RtcNotification event instead of the now deprecated CallNotify by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/5357 +### 🐛 Bugfixes +* Increase Element Call audio init delay ensuring the right audio device is used by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5315 +* Do not center the dialog title text for dialogs with no icon by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5332 +* Media viewer: release the `ExoPlayers` when the hosting composables are disposed by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5351 +* Make PushData.clientSecret mandatory. by @bmarty in https://github.com/element-hq/element-x-android/pull/5369 +* Cleanup ftue code and ensure verification confirmation is displayed by @bmarty in https://github.com/element-hq/element-x-android/pull/5379 +* Change in clear cache behavior by @bmarty in https://github.com/element-hq/element-x-android/pull/5388 +* fix (room navigation) : fix navigation when leaving room/space by @ganfra in https://github.com/element-hq/element-x-android/pull/5376 +* fix (timeline) : forward pagination regression by @ganfra in https://github.com/element-hq/element-x-android/pull/5389 +* When joining a call, wait for the `content_loaded` action by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5399 +* Ensure the thread summary sender's display name won't wrap to the next line by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5403 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5349 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5385 +### 🧱 Build +* Improve release script and the file Versions.kt by @bmarty in https://github.com/element-hq/element-x-android/pull/5318 +* Dependency: extract the Matrix SDK and add instructions for upgrading the library by @bmarty in https://github.com/element-hq/element-x-android/pull/5363 +* Add test on DefaultSpaceEntryPoint by @bmarty in https://github.com/element-hq/element-x-android/pull/5343 +### 🚧 In development 🚧 +* Space list by @bmarty in https://github.com/element-hq/element-x-android/pull/5320 +* Feature : Join Space (WIP) by @ganfra in https://github.com/element-hq/element-x-android/pull/5378 +### Dependency upgrades +* Update activity to v1.11.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5324 +* Update dependency com.google.truth:truth to v1.4.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5322 +* Update dependency io.sentry:sentry-android to v8.21.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5310 +* Update dependency org.matrix.rustcomponents:sdk-android to v25.9.10 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5323 +* Update dependency androidx.sqlite:sqlite-ktx to v2.6.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5337 +* Update camera to v1.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5336 +* Update dependency com.posthog:posthog-android to v3.21.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5333 +* Update dependency com.google.testparameterinjector:test-parameter-injector to v1.19 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5341 +* Upgrade Rust SDK bindings to v25.09.15 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5353 +* Update dependency org.matrix.rustcomponents:sdk-android to v25.9.16 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5359 +* Update dependency org.matrix.rustcomponents:sdk-android to v25.9.18 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5365 +* Update telephoto to v0.17.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5350 +* Update dependency org.matrix.rustcomponents:sdk-android to v25.9.19 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5377 +* Update dependency com.google.firebase:firebase-bom to v34.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5367 +* Upgrade Element Call embedded dependency to `v0.16.0-rc.4` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5391 +* Update dependencyAnalysis to v3 (major) by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5194 +* Update dependency org.maplibre.gl:android-sdk to v11.13.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5381 +* Update dependency org.matrix.rustcomponents:sdk-android to v25.9.23 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5396 +* Update plugin dependencycheck to v12.1.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5382 +* Update dependency io.sentry:sentry-android to v8.22.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5397 +### Others +* Cleanup nodes by @bmarty in https://github.com/element-hq/element-x-android/pull/5358 +* Complete test on MediaGalleryPresenter by @bmarty in https://github.com/element-hq/element-x-android/pull/5361 +* Remove dead code by @bmarty in https://github.com/element-hq/element-x-android/pull/5306 +* Introduce BugReportFlowNode, and remove NavTarget.ViewLogs from RootFlowNode by @bmarty in https://github.com/element-hq/element-x-android/pull/5370 +* When logging out from Pin code screen, logout from all the sessions. by @bmarty in https://github.com/element-hq/element-x-android/pull/5372 +* Clean MatrixAuthenticationService and SessionStore API by @bmarty in https://github.com/element-hq/element-x-android/pull/5371 +* Add logs to detect duplicates in the room list by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5364 +* Add troubleshoot notification test about blocked users by @bmarty in https://github.com/element-hq/element-x-android/pull/5394 +* Add thread decoration with latest event details by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5355 +* Rework on messages view top bars by @bmarty in https://github.com/element-hq/element-x-android/pull/5401 +* Put developer settings at the end of the view by @p1gp1g in https://github.com/element-hq/element-x-android/pull/5387 + +## New Contributors +* @p1gp1g made their first contribution in https://github.com/element-hq/element-x-android/pull/5387 + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.09.1...v25.09.2 + +Changes in Element X v25.09.1 +============================= + +## What's Changed + +We have migrated our DI libraries from Dagger and Anvil to Metro. If you need more details on the migration steps, please read the [documentation](https://github.com/element-hq/element-x-android/blob/develop/docs/migration_to_metro.md). + +### ✨ Features +* Allow replying to a message with an attachment by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5261 +* Add emoji search to the reaction emoji picker by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5255 +### 🙌 Improvements +* Spelling correction in Update FeatureFlags.kt by @escix in https://github.com/element-hq/element-x-android/pull/5232 +* [a11y] Add content descriptions to room list item indicators by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5236 +* [a11y] Add click action to the message bottom sheet handle by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5228 +### 🐛 Bugfixes +* Reload member list after moderation actions by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5268 +* Restore view log code by @bmarty in https://github.com/element-hq/element-x-android/pull/5294 +* Detect mime type when picking a file by @bmarty in https://github.com/element-hq/element-x-android/pull/5291 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5249 +* Sync Strings - new translations to Korean by @ElementBot in https://github.com/element-hq/element-x-android/pull/5286 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5290 +### 🧱 Build +* Iterate on build chain by @bmarty in https://github.com/element-hq/element-x-android/pull/5272 +* Cleanup our DI solution and add documentation about the migration to Metro by @bmarty in https://github.com/element-hq/element-x-android/pull/5287 +* Revert agp to 8.11 by @bmarty in https://github.com/element-hq/element-x-android/pull/5311 +### 🚧 In development 🚧 +* Space: add content in home screen by @bmarty in https://github.com/element-hq/element-x-android/pull/5273 +* Hide the home navigation bar if the user is not a member of any Space. by @bmarty in https://github.com/element-hq/element-x-android/pull/5292 +### Dependency upgrades +* Update dependency org.maplibre.gl:android-sdk to v11.13.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5239 +* Update dependency com.google.firebase:firebase-bom to v34.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5245 +* Update dependency com.posthog:posthog-android to v3.21.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5238 +* Update dependency org.matrix.rustcomponents:sdk-android to v25.9.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5251 +* Update plugin sonarqube to v6.3.1.5724 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5235 +* Update android.gradle.plugin to v8.12.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5244 +* Update dependency io.element.android:emojibase-bindings to v1.4.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5250 +* Update actions/setup-python action to v6 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5270 +* Update dependency com.posthog:posthog-android to v3.21.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5275 +* Migrate Anvil KSP to Metro by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5253 +* Update actions/github-script action to v8 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5284 +* Update codecov/codecov-action action to v5.5.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5274 +* Update dependency io.sentry:sentry-android to v8.21.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5293 +### Others +* Remove LoginUserStory. by @bmarty in https://github.com/element-hq/element-x-android/pull/5237 +* Update state in runUpdatingState when CancellationException occurs by @jbrenorv in https://github.com/element-hq/element-x-android/pull/5243 +* Refactor: Move InMemorySessionStore to test module by @bmarty in https://github.com/element-hq/element-x-android/pull/5252 +* Enable `largeHeap` option to have a larger max heap size by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5258 +* Set a custom request config for the Client by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5266 +* Set shortcut ID on received notifications to make them appear as a Conversation by @frebib in https://github.com/element-hq/element-x-android/pull/5192 +* Improve management of shortcut ids. by @bmarty in https://github.com/element-hq/element-x-android/pull/5303 + +## New Contributors +* @escix made their first contribution in https://github.com/element-hq/element-x-android/pull/5232 +* @jbrenorv made their first contribution in https://github.com/element-hq/element-x-android/pull/5243 + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.09.0...v25.09.1 + +Changes in Element X v25.09.0 +============================= + +This release is the same as `25.08.4` but it includes performance fixes for the timeline load times, included in the Rust SDK version upgrade and internal changes for Element Call. + +## What's Changed +### 🧱 Build +* Revert "Try following KSP incremental best practices on `anvilcodegen`" by @bmarty in https://github.com/element-hq/element-x-android/pull/5233 +### Dependency upgrades +* Update dependency io.element.android:element-call-embedded to v0.15.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5229 +* Update dependency org.matrix.rustcomponents:sdk-android to v25.8.26 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5230 +* Downgrade sonar scanner gradle plugin to `v6.2.0.5505` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5234 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.08.4...v25.09.0 + +Changes in Element X v25.08.4 +============================= + +## What's Changed +### ✨ Features +* Threads - first iteration by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5165 +* Add shortcut suggestions for rooms, remove then when leaving by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5180 +* Allow replying to any remote message in a thread by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5201 +### 🙌 Improvements +* Create room flow rework by @bmarty in https://github.com/element-hq/element-x-android/pull/5166 +### 🐛 Bugfixes +* Fix bitrate value used for video transcoding by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5183 +* Fix sending videos in Android 11 and lower by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5186 +* Ensure that only one DataStore is active for the same file. by @bmarty in https://github.com/element-hq/element-x-android/pull/5198 +* Handle preference stores corruption by clearing them by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5086 +* Use variable bitrate mode when transcoding to ensure compatibility with old devices by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5223 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5178 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5211 +### 🧱 Build +* Build release with the latest build tools 36.0.0 by @bmarty in https://github.com/element-hq/element-x-android/pull/5173 +* Try following KSP incremental best practices on `anvilcodegen` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5205 +* Split deeplink module and remove setupAnvil from api modules by @bmarty in https://github.com/element-hq/element-x-android/pull/5210 +* Introduce a11y screenshot test by @bmarty in https://github.com/element-hq/element-x-android/pull/5214 +* Custom logo on on boarding screen. by @bmarty in https://github.com/element-hq/element-x-android/pull/5217 +### 🚧 In development 🚧 +* Space UI component by @bmarty in https://github.com/element-hq/element-x-android/pull/5197 +* Add UI components for spaces. by @bmarty in https://github.com/element-hq/element-x-android/pull/5207 +### Dependency upgrades +* Update core to v1.17.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5168 +* Update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5169 +* Update dependency org.matrix.rustcomponents:sdk-android to v25.8.18 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5182 +* Update android.gradle.plugin to v8.12.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5184 +* Update dagger to v2.57.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5193 +* Update actions/setup-java action to v5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5196 +* Update codecov/codecov-action action to v5.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5191 +* Update plugin ktlint to v13.1.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5204 +* Update dependency com.posthog:posthog-android to v3.20.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5206 +* Update dependency org.jsoup:jsoup to v1.21.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5212 +* Update dependency com.posthog:posthog-android to v3.20.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5213 +* Update plugin sonarqube to v6.3.0.5676 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5220 +* Update dependency io.sentry:sentry-android to v8.20.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5216 +* Update dependency org.matrix.rustcomponents:sdk-android to v25.8.25 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5219 +### Others +* Iterate on invite people UI by @bmarty in https://github.com/element-hq/element-x-android/pull/5185 +* AnalyticsOptInStateProvider does not need to have an injected constructor by @bmarty in https://github.com/element-hq/element-x-android/pull/5215 +* Add extra logs for sending media by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5218 +* Rename custom_logo to onboarding_logo by @bmarty in https://github.com/element-hq/element-x-android/pull/5226 +* Add unit test on VideoCompressorHelper by @bmarty in https://github.com/element-hq/element-x-android/pull/5227 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.08.3...v25.08.4 + +Changes in Element X v25.08.3 +============================= + +## What's Changed +### ✨ Features +* Add media file limit size warning and media quality selection by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5131 +### 🐛 Bugfixes +* Fix cursor position in room list search by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5138 +* Fix leaving the room not always dismissing the room screen by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5089 +* Do not automatically initialize `DefaultVideoMetadataExtractor`'s data source by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5157 +* Provide calculated server names when opening a room from another by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5155 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5146 +### 🧱 Build +* Compile and target sdk36 by @bmarty in https://github.com/element-hq/element-x-android/pull/5150 +* Fix Maestro regression when coming back from room to the search screen by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5156 +### Dependency upgrades +* Update android.gradle.plugin to v8.12.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5106 +* Update wysiwyg to v2.39.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5080 +* Update dependency python to 3.13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5144 +* Update rnkdsh/action-upload-diawi action to v1.5.11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5141 +* Update dependency io.github.sergio-sastre.ComposablePreviewScanner:android to v0.7.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5143 +* Update actions/checkout action to v5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5148 +* Update dependency io.sentry:sentry-android to v8.19.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5149 +* Update dependency io.sentry:sentry-android to v8.19.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5158 +* Update dependency androidx.browser:browser to v1.9.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5096 +* Update Compose bom to 2025.07.00 by @bmarty in https://github.com/element-hq/element-x-android/pull/5164 +* Update showkase to v1.0.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5117 +* Update haze to v1.6.10 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5167 +### Others +* Let enterprise build be able to override (or disable) the bug report URL. by @bmarty in https://github.com/element-hq/element-x-android/pull/5139 +* Hide the recovery key while we are entering it by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5147 +* Remove old feature flags by @bmarty in https://github.com/element-hq/element-x-android/pull/5160 +* Move push history entry point from notification settings to developer settings by @bmarty in https://github.com/element-hq/element-x-android/pull/5161 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.08.2...v25.08.3 + +Changes in Element X v25.08.2 +============================= + + + +## What's Changed +### 🐛 Bugfixes +* When mapping an invalid notification event, only drop that one by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5137 +### Dependency upgrades +* Update dependency io.nlopez.compose.rules:detekt to v0.4.27 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5123 +* Update actions/download-artifact action to v5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5122 +* Update dependency net.zetetic:sqlcipher-android to v4.10.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5121 +* Update dependency com.posthog:posthog-android to v3.20.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5133 +* Update dependency com.google.firebase:firebase-bom to v34.1.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5136 +### Others +* [a11y] Open context menu with the keyboard by @bmarty in https://github.com/element-hq/element-x-android/pull/5120 +* Let enterprise build store the logs in a dedicated subfolder by @bmarty in https://github.com/element-hq/element-x-android/pull/5132 +* Redirect FOSS user to Element Pro according to element .well-known file by @bmarty in https://github.com/element-hq/element-x-android/pull/5126 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.08.1...v25.08.2 + +Changes in Element X v25.08.1 +============================= + + + +## What's Changed +### 🙌 Improvements +* Force last owner of a room to pass ownership when leaving by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5094 +### 🐛 Bugfixes +* Reload room member list when active members count changes by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5129 +* Delegate call notifications to Element Call, upgrade SDK and EC embedded by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5119 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5112 +### Dependency upgrades +* Update media3 to v1.8.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5101 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.08.0...v25.08.1 + +Changes in Element X v25.08.0 +============================= + + + +## What's Changed +### 🐛 Bugfixes +* Fix `toPlainText` where `
    ` tags appear by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5044 +* Remove the scaling added in `Player.Listener.onVideoSizeChanged` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5055 +* Make sure we clean up the pre-processed and uploaded media by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5039 +* Calculate video output size taking into account portrait mode by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5068 +* Prevent loop when exiting the attachments preview screen by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5078 +* Prevent crash caused by re-release of wakelock in calls by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5077 +* Make sure we display errors when we create a recovery key and it fails by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5079 +* Fix crash when trying to get active notifications by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5085 +* Adapt 'change roles' screens to the new creator/owner role by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5076 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5021 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5054 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5083 +### 🧱 Build +* Disable Element Call Maestro tests for the time being by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5064 +### 📄 Documentation +* Grammar fixes for docs and comments by @andybalaam in https://github.com/element-hq/element-x-android/pull/5043 +* Note how to switch back to the published SDK after building locally by @andybalaam in https://github.com/element-hq/element-x-android/pull/5042 +### Dependency upgrades +* Update dependency io.mockk:mockk to v1.14.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5037 +* Update dependency androidx.lifecycle:lifecycle-process to v2.9.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5036 +* Update dagger to v2.57 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5038 +* Update haze to v1.6.9 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5045 +* Update dependency io.nlopez.compose.rules:detekt to v0.4.24 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5053 +* Update dependency io.nlopez.compose.rules:detekt to v0.4.25 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5058 +* Update coil to v3.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5063 +* Update dependency io.nlopez.compose.rules:detekt to v0.4.26 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5065 +* Update dependency com.posthog:posthog-android to v3.20.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5067 +* Update dependency com.google.firebase:firebase-bom to v34 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5061 +* Update dependency org.matrix.rustcomponents:sdk-android to v25.7.23 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5073 +* Update dependency com.posthog:posthog-android to v3.20.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5087 +* Update dependency org.matrix.rustcomponents:sdk-android to v25.7.28 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5088 +* Update dependency org.maplibre.gl:android-sdk to v11.13.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5093 +* Update dependency androidx.test:runner to v1.7.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5102 +* Update test.core to v1.7.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5104 +* Update dependency androidx.test.ext:junit to v1.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5103 +* Update dependency io.sentry:sentry-android to v8.18.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5097 +### Others +* Iterate on FloatingActionButton shape and colors. by @bmarty in https://github.com/element-hq/element-x-android/pull/5033 +* [a11y] Improve session verification screens by @bmarty in https://github.com/element-hq/element-x-android/pull/5017 +* misc (room id) : add room id regex pattern to match new versions by @ganfra in https://github.com/element-hq/element-x-android/pull/5040 +* Use lower level APIs to draw the message bubbles by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5056 +* misc (store description) : update store description for fastlane by @ganfra in https://github.com/element-hq/element-x-android/pull/5060 +* [a11y] Improve accessibility on avatar when creating a room. by @bmarty in https://github.com/element-hq/element-x-android/pull/5046 +* Add fallback notifications from UTDs to the push history by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5047 +* feature (media send queue) : enable send queue by default by @ganfra in https://github.com/element-hq/element-x-android/pull/5098 +* misc : re-enable share pos by default by @ganfra in https://github.com/element-hq/element-x-android/pull/5108 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.07.1...v25.08.0 + +Changes in Element X v25.07.1 +============================= + + + +## What's Changed +### 🐛 Bugfixes +* fix ( room list) : rebuild with filteredSummaries to avoid bad state by @ganfra in https://github.com/element-hq/element-x-android/pull/4993 +* Keep video rotation metadata when transcoding by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5008 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4988 +### 🧱 Build +* Update Gradle Wrapper from 8.14.2 to 8.14.3 by @ElementBot in https://github.com/element-hq/element-x-android/pull/4985 +* Stop ignoring dependencies, but instead set `open-pull-requests-limit to 0 by @bmarty in https://github.com/element-hq/element-x-android/pull/5013 +### 📄 Documentation +* Update to the status and clarifications with respect to the legacy app. by @mxandreas in https://github.com/element-hq/element-x-android/pull/5016 +### 🚧 In development 🚧 +* Home navigation bar fixes by @bmarty in https://github.com/element-hq/element-x-android/pull/4990 +* Home screen iteration by @bmarty in https://github.com/element-hq/element-x-android/pull/5003 +### Dependency upgrades +* Update dependency io.element.android:compound-android to v25.7.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/4984 +* Update dependency org.matrix.rustcomponents:sdk-android to v25.7.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/4989 +* Update plugin ktlint to v13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/4992 +* Update dependency org.jetbrains.kotlinx:kotlinx-datetime to v0.7.1-0.6.x-compat by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/4991 +* Update haze to v1.6.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/4987 +* Update dependency com.squareup.okhttp3:okhttp-bom to v5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/4979 +* Update dependency io.sentry:sentry-android to v8.17.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/4998 +* Update dependency com.squareup.okhttp3:okhttp-bom to v5.1.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/4997 +* Update dependency org.maplibre.gl:android-sdk to v11.12.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5001 +* Update dependency com.posthog:posthog-android to v3.19.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5009 +* Update dependency org.maplibre.gl:android-sdk to v11.12.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5006 +* Update android.gradle.plugin to v8.11.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5014 +* Update rnkdsh/action-upload-diawi action to v1.5.10 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5019 +* Update wysiwyg to v2.38.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5025 +* Update haze to v1.6.8 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5026 +* Update dependency org.matrix.rustcomponents:sdk-android to v25.7.15 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5011 +### Others +* Remove bloom effect and replace by linear gradient by @bmarty in https://github.com/element-hq/element-x-android/pull/4926 +* misc (a11y) : mark MainActionButton icon as decorative by @ganfra in https://github.com/element-hq/element-x-android/pull/4996 +* Make `ContentAvoidingLayoutData` an immutable data class by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4999 +* Remove unused composable and cleanup colors by @bmarty in https://github.com/element-hq/element-x-android/pull/5000 +* Add a feature flag to reuse the last `pos` value for initial syncs by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5010 +* [a11y] Fix several issues around accessibility by @bmarty in https://github.com/element-hq/element-x-android/pull/5007 +* Replace video transcoder with Media3 Transformer by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5018 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.07.0...v25.07.1 + +Changes in Element X v25.07.0 +============================= + + + +## What's Changed +### 🙌 Improvements +* Change : handle invalid invite error by @ganfra in https://github.com/element-hq/element-x-android/pull/4909 +* Add ability to zoom on video. by @bmarty in https://github.com/element-hq/element-x-android/pull/4916 +* Change : sync moderation and safety preferences with server by @ganfra in https://github.com/element-hq/element-x-android/pull/4962 +### 🐛 Bugfixes +* Restore `MarkdownEditText.focusSearch` override by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4908 +* Fix duplicate usage of a `modifier` variable in `TextInputBox` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4928 +### 🗣 Translations +* Sync Strings - new translations to Danish by @ElementBot in https://github.com/element-hq/element-x-android/pull/4913 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4983 +### 🧱 Build +* a11y: Add scripts to enable and disable the talkback service by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4906 +* Update min api level to 33 for Element enterprise by @bmarty in https://github.com/element-hq/element-x-android/pull/4960 +### 🚧 In development 🚧 +* Rename module roomlist to home by @bmarty in https://github.com/element-hq/element-x-android/pull/4955 +* Home navigation bar by @bmarty in https://github.com/element-hq/element-x-android/pull/4964 +### Dependency upgrades +* fix(deps): update dependency org.unifiedpush.android:connector to v3.0.10 by @renovate in https://github.com/element-hq/element-x-android/pull/4871 +* fix(deps): update dependency io.sentry:sentry-android to v8.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4892 +* fix(deps): update dependency com.google.crypto.tink:tink-android to v1.18.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4897 +* fix(deps): update wysiwyg to v2.38.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4907 +* fix(deps): update dependency org.robolectric:robolectric to v4.15 by @renovate in https://github.com/element-hq/element-x-android/pull/4901 +* fix(deps): update dependency androidx.sqlite:sqlite-ktx to v2.5.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4898 +* fix(deps): update dependency io.mockk:mockk to v1.14.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4912 +* fix(deps): update dependency org.robolectric:robolectric to v4.15.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4911 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.6.23 by @renovate in https://github.com/element-hq/element-x-android/pull/4917 +* fix(deps): update dependencyanalysis to v2.19.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4932 +* fix(deps): update dependency org.jsoup:jsoup to v1.21.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4914 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.6.25 by @renovate in https://github.com/element-hq/element-x-android/pull/4936 +* fix(deps): update dependency io.sentry:sentry-android to v8.15.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4938 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.11.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4939 +* fix(deps): update dependency com.google.firebase:firebase-bom to v33.16.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4945 +* fix(deps): update dependency io.sentry:sentry-android to v8.16.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4941 +* Update sdk to version 25.7.1 by @bmarty in https://github.com/element-hq/element-x-android/pull/4966 +* Update haze to v1.6.6 by @renovate in https://github.com/element-hq/element-x-android/pull/4968 +* Update dependency com.google.gms:google-services to v4.4.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4946 +* Update android.gradle.plugin to v8.11.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4931 +* Update dependency io.element.android:element-call-embedded to v0.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4969 +* Update dependency org.matrix.rustcomponents:sdk-android to v25.7.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4967 +* Upgrade compose bom to 2025.06.01 by @bmarty in https://github.com/element-hq/element-x-android/pull/4970 +* Update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4918 +* Update dependency io.element.android:element-call-embedded to v0.13.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4977 +* Update dependency org.matrix.rustcomponents:sdk-android to v25.7.3 by @ganfra in https://github.com/element-hq/element-x-android/pull/4976 +### Others +* a11y: Make isTalkbackActive() live. by @bmarty in https://github.com/element-hq/element-x-android/pull/4903 +* a11y: improve accessibility on grouped state events header. by @bmarty in https://github.com/element-hq/element-x-android/pull/4902 +* Room debug info by @bmarty in https://github.com/element-hq/element-x-android/pull/4904 +* [a11y] Improve accessibility of message composer by @bmarty in https://github.com/element-hq/element-x-android/pull/4900 +* refactor: Migrate SQLCipher Android to new API by @ShadowRZ in https://github.com/element-hq/element-x-android/pull/4874 +* Iterate on avatar to be able to render Space avatar. by @bmarty in https://github.com/element-hq/element-x-android/pull/4921 +* Simplify syncing the room list when receiving a push by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4915 +* Add unit test on ChooseAccountProviderState so that the coverage is above 90% by @bmarty in https://github.com/element-hq/element-x-android/pull/4924 +* Iterate on avatar to be able to render Space avatar Part2 by @bmarty in https://github.com/element-hq/element-x-android/pull/4923 +* Introduce SessionEnterpriseService. by @bmarty in https://github.com/element-hq/element-x-android/pull/4925 +* Simplify message composer layout by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4884 +* Display error dialog if Element Call can't be joined by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4919 +* misc : simplify timeline diff logic by @ganfra in https://github.com/element-hq/element-x-android/pull/4930 +* Navigation bar component by @bmarty in https://github.com/element-hq/element-x-android/pull/4940 +* a11y: improve content description of the close buttons by @bmarty in https://github.com/element-hq/element-x-android/pull/4943 +* Element Call: remove top app bar and add it inside the webview instead by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4927 +* Replace the Report a problem button with the app's version on the on boading screen. by @bmarty in https://github.com/element-hq/element-x-android/pull/4944 +* Split RoomListPresenter and introduce HomePresenter by @bmarty in https://github.com/element-hq/element-x-android/pull/4958 +* Add "View avatar" content description to all clickable Avatar that will open the avatar preview. by @bmarty in https://github.com/element-hq/element-x-android/pull/4948 +* [a11y] Ensure that the focus is not lost when the send button state change by @bmarty in https://github.com/element-hq/element-x-android/pull/4975 +* [a11y] add missing heading() qualifier on screen titles and other headers by @bmarty in https://github.com/element-hq/element-x-android/pull/4980 +* misc (tracing) : add new TraceLogPack.Notification by @ganfra in https://github.com/element-hq/element-x-android/pull/4981 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.06.3...v25.07.0 + +Changes in Element X v25.06.3 +============================= + +## What's Changed +### ✨ Features +* Feature : room version upgrade by @ganfra in https://github.com/element-hq/element-x-android/pull/4862 +* Add a developer option for history sharing on invite by @richvdh in https://github.com/element-hq/element-x-android/pull/4821 +### 🙌 Improvements +* Change : add tombstoned room decoration by @ganfra in https://github.com/element-hq/element-x-android/pull/4891 +* Show generic notification when Event cannot be resolved by @bmarty in https://github.com/element-hq/element-x-android/pull/4889 +### 🐛 Bugfixes +* [a11y] Improve screen reader on polls by @bmarty in https://github.com/element-hq/element-x-android/pull/4875 +* fix (event action): allow to edit only if permission to send message by @ganfra in https://github.com/element-hq/element-x-android/pull/4895 +* fix (room upgrade) : room predecessor banner on DM room by @ganfra in https://github.com/element-hq/element-x-android/pull/4896 +* fix (join room) : do not navigate up when join is successful by @ganfra in https://github.com/element-hq/element-x-android/pull/4899 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4842 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4881 +### Dependency upgrades +* chore(deps): update plugin dependencycheck to v12.1.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4856 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.10.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4858 +* fix(deps): update kotlin to v2.1.21-2.0.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4850 +* fix(deps): update dependency app.cash.turbine:turbine to v1.2.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4865 +* Update dependency com.posthog:posthog-android to v3.18.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4873 +* Update dependency org.maplibre.gl:android-sdk to v11.10.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4879 +* fix(deps): update dependency com.posthog:posthog-android to v3.19.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4882 +* fix(deps): update dependency io.sentry:sentry-android to v8.13.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4870 +* fix(deps): update showkase to v1.0.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4878 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.6.18 by @renovate in https://github.com/element-hq/element-x-android/pull/4894 +### Others +* Annotate Composable functions with `@ReadOnlyComposable` where it's possible by @bmarty in https://github.com/element-hq/element-x-android/pull/4859 +* Add documentation on WebViewPipController by @bmarty in https://github.com/element-hq/element-x-android/pull/4861 +* Small cleanup around log tag. by @bmarty in https://github.com/element-hq/element-x-android/pull/4860 +* Another cleanup by @bmarty in https://github.com/element-hq/element-x-android/pull/4869 +* Disable BT audio devices for Element Call on Android < 12 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4876 +* Add a banner to ask the user to disable battery optimization when Event cannot be resolved from Push by @bmarty in https://github.com/element-hq/element-x-android/pull/4845 +* a11y: improve accessibility on rich text editor options. by @bmarty in https://github.com/element-hq/element-x-android/pull/4886 +* A11Y: improve accessibility on event reactions. by @bmarty in https://github.com/element-hq/element-x-android/pull/4877 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.06.2...v25.06.3 + +Changes in Element X v25.06.2 +============================= + +## What's Changed +### 🐛 Bugfixes +* Fix crash when using Element Call on API <= 30 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4847 +* Element Call: add delay before selecting the default audio device by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4854 +* Fix for message composer losing focus in Compose 1.8.0 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4853 +### Dependency upgrades +* chore(deps): update plugin dependencycheck to v12.1.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4840 +* deps (matrix rust sdk) : bump version to 25.06.10 by @ganfra in https://github.com/element-hq/element-x-android/pull/4855 +### Others +* feat: Support matrix: links by @ShadowRZ in https://github.com/element-hq/element-x-android/pull/4839 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.06.1...v25.06.2 + +## What's Changed +### ✨ Features +* Enable support for Android Auto. by @bmarty in https://github.com/element-hq/element-x-android/pull/4818 +* Element Call: Add audio output selector handled by Android by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4663 +### 🙌 Improvements +* Oidc: Fallback to external browser instead of using Webview by @bmarty in https://github.com/element-hq/element-x-android/pull/4808 +* change (room member moderation) : update icon to match figma by @ganfra in https://github.com/element-hq/element-x-android/pull/4837 +### 🐛 Bugfixes +* Fix login flow by @bmarty in https://github.com/element-hq/element-x-android/pull/4813 +* fix: When sending media as files use the `octet-stream` type by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4815 +* fix: Make `Client.findDM` return a `Result` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4816 +* Mark room as fully read when user goes back to the room list. by @bmarty in https://github.com/element-hq/element-x-android/pull/2687 +* fix (identity change) : RoomMemberIdentityStateChange in non encrypted room by @ganfra in https://github.com/element-hq/element-x-android/pull/4824 +* Fix room and user avatar downloaded with a `.bin` extension. by @bmarty in https://github.com/element-hq/element-x-android/pull/4830 +* Log the push resolving failure reason if available by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4835 +### 🧱 Build +* Update Gradle Wrapper from 8.14.1 to 8.14.2 by @ElementBot in https://github.com/element-hq/element-x-android/pull/4831 +### Dependency upgrades +* fix(deps): update dependency androidx.compose:compose-bom to v2025.04.01 by @renovate in https://github.com/element-hq/element-x-android/pull/4631 +* fix(deps): update dependency androidx.compose:compose-bom to v2025.05.01 by @renovate in https://github.com/element-hq/element-x-android/pull/4814 +* fix(deps): update dependency io.sentry:sentry-android to v8.13.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4780 +* fix(deps): update appyx to v1.7.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4672 +* fix(deps): update telephoto to v0.16.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4749 +* fix(deps): update coil to v3.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4712 +* fix(deps): update dependency androidx.webkit:webkit to v1.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4823 +* fix(deps): update dependency com.posthog:posthog-android to v3.17.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4827 +* fix(deps): update dependency io.element.android:element-call-embedded to v0.12.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4832 +* fix(deps): update dependency com.google.firebase:firebase-bom to v33.15.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4833 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.10.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4825 +* fix(deps): update lifecycle to v2.9.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4822 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.6.6 by @renovate in https://github.com/element-hq/element-x-android/pull/4834 +* fix(deps): update dependency io.element.android:opusencoder to v1.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4836 +### Others +* Add `catchingExceptions` method to replace `runCatching` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4797 +* Rename classes overriding classes from the FFI layer. by @bmarty in https://github.com/element-hq/element-x-android/pull/4817 +* Fix coroutine scope by @bmarty in https://github.com/element-hq/element-x-android/pull/4820 +* Add extra logs the 'send call notification' flow by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4819 +* misc (matrix) : use innerClient.subscribeToRoomInfo sdk method by @ganfra in https://github.com/element-hq/element-x-android/pull/4838 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.06.0...v25.06.1 + +Changes in Element X v25.06.0 +============================= + +Rust SDK: https://github.com/matrix-org/matrix-rust-sdk/releases/tag/matrix-sdk-ffi%2F20250603 + +## What's Changed +### ✨ Features +* Add support for login link by @bmarty in https://github.com/element-hq/element-x-android/pull/4752 +### 🙌 Improvements +* On boarding flow: add a screen to select account provider among a fixed list by @bmarty in https://github.com/element-hq/element-x-android/pull/4769 +* Change : RoomMember moderation by @ganfra in https://github.com/element-hq/element-x-android/pull/4779 +### 🐛 Bugfixes +* Fix left room membership change by @ganfra in https://github.com/element-hq/element-x-android/pull/4765 +* fix: exclude more domains from being backed up by the system by @lucasmz-dev in https://github.com/element-hq/element-x-android/pull/4773 +* Make sure HeaderFooterPage contents can be scrolled by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4704 +* Fix mobile link by @bmarty in https://github.com/element-hq/element-x-android/pull/4805 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4775 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4804 +### 🧱 Build +* Maestro: fix MAS and EC breaking the tests by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4762 +* Update Gradle Wrapper from 8.14 to 8.14.1 by @ElementBot in https://github.com/element-hq/element-x-android/pull/4766 +* Stronger lambda error by @bmarty in https://github.com/element-hq/element-x-android/pull/4771 +* Use Localazy's `langAliases` for Indonesian language by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4801 +### Dependency upgrades +* fix(deps): update datastore to v1.1.7 by @renovate in https://github.com/element-hq/element-x-android/pull/4754 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.8 by @renovate in https://github.com/element-hq/element-x-android/pull/4721 +* chore(deps): update plugin ktlint to v12.3.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4767 +* fix(deps): update dependency com.google.firebase:firebase-bom to v33.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4755 +* Update UnifiedPush library by @bmarty in https://github.com/element-hq/element-x-android/pull/4358 +* fix(deps): update sqldelight to v2.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4735 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.5.26 by @renovate in https://github.com/element-hq/element-x-android/pull/4781 +* fix(deps): update dependency com.posthog:posthog-android to v3.15.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4787 +* fix(deps): update dependency com.posthog:posthog-android to v3.16.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4789 +* fix(deps): update dependency io.element.android:element-call-embedded to v0.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4743 +* fix(deps): update dependencyanalysis to v2.18.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4796 +* fix(deps): update android.gradle.plugin to v8.10.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4795 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.5.29 by @renovate in https://github.com/element-hq/element-x-android/pull/4799 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.6.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4810 +### Others +* fix(deps): update media3 to v1.7.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4733 +* fix: Ignore global proxy settings if system thinks there's none by @ShadowRZ in https://github.com/element-hq/element-x-android/pull/4744 +* Add `ActiveRoomHolder` to manage the active room for a session by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4758 +* Notification events resolving and rendering in batches by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4722 +* Hide Element Call entry point if Element Call service is not available. by @bmarty in https://github.com/element-hq/element-x-android/pull/4783 +* Fix dependencies on test by @bmarty in https://github.com/element-hq/element-x-android/pull/4790 +* Update _developer_onboarding.md by @lex-neufeld in https://github.com/element-hq/element-x-android/pull/4570 + +## New Contributors +* @lucasmz-dev made their first contribution in https://github.com/element-hq/element-x-android/pull/4773 +* @lex-neufeld made their first contribution in https://github.com/element-hq/element-x-android/pull/4570 + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.05.4...v25.06.0 + + + +Changes in Element X v25.05.4 +============================= + +Rust SDK: https://github.com/matrix-org/matrix-rust-sdk/releases/tag/matrix-sdk-ffi%2F20250521 + +## What's Changed +### 🙌 Improvements +* Change (report room) : check if server supports the report room api by @ganfra in https://github.com/element-hq/element-x-android/pull/4718 +### 🐛 Bugfixes +* Improve audio focus management by @bmarty in https://github.com/element-hq/element-x-android/pull/4707 +* When transcoding a video fails, send it as a file by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4257 +* Disable mutliple click (parallel or serial) on a room by @bmarty in https://github.com/element-hq/element-x-android/pull/4683 +* Fix generic mime type used when externally sharing several files by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4715 +* Fix issues on JoinedRoom / BaseRoom by @bmarty in https://github.com/element-hq/element-x-android/pull/4724 +* Use the right live timeline instance in `RustRoomFactory` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4745 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4739 +### 🧱 Build +* Ensure the CI is marked as failed when Maestro test is failing by @bmarty in https://github.com/element-hq/element-x-android/pull/4700 +* Trigger pipeline build when a release tag is pushed by @bmarty in https://github.com/element-hq/element-x-android/pull/4741 +* Fix compilation issues. by @bmarty in https://github.com/element-hq/element-x-android/pull/4750 +### 📄 Documentation +* README.md: fix broken link by @richvdh in https://github.com/element-hq/element-x-android/pull/4728 +### Dependency upgrades +* chore(config): migrate renovate config by @renovate in https://github.com/element-hq/element-x-android/pull/4688 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.5.13 by @renovate in https://github.com/element-hq/element-x-android/pull/4716 +* fix(deps): update dependency io.sentry:sentry-android to v8.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4717 +* chore(deps): update plugin sonarqube to v6.2.0.5505 by @renovate in https://github.com/element-hq/element-x-android/pull/4725 +* fix(deps): update dependency com.posthog:posthog-android to v3.15.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4723 +* fix(deps): update dependency com.squareup.retrofit2:retrofit-bom to v2.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4727 +* chore(deps): update codecov/codecov-action action to v5.4.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4730 +* fix(deps): update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4713 +* fix(deps): update dependency com.squareup.retrofit2:retrofit-bom to v3 by @renovate in https://github.com/element-hq/element-x-android/pull/4729 +* fix(deps): update kotlinpoet to v2.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4732 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.5.21 by @renovate in https://github.com/element-hq/element-x-android/pull/4759 +### Others +* Remove event cache feature flag by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4719 +* Check homeserver when login using qr code by @bmarty in https://github.com/element-hq/element-x-android/pull/4708 +* Merge on boarding module to login module by @bmarty in https://github.com/element-hq/element-x-android/pull/4746 +* Allow configuration to provide multiple account providers. by @bmarty in https://github.com/element-hq/element-x-android/pull/4742 +* Reduce API of JoinedRoom, caller must use the Timeline API from liveTimeline instead by @bmarty in https://github.com/element-hq/element-x-android/pull/4731 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.05.3...v25.05.4 + +Changes in Element X v25.05.3 +============================= + +Version 25.05.2 was skipped. + +## What's Changed +### 🐛 Bugfixes +* Disable Continue button when the login field is cleared. by @bmarty in https://github.com/element-hq/element-x-android/pull/4699 +* Revert "fix(deps): update dependency io.element.android:element-call-embedded to v0.10.0", which caused an issue with to-device events in the latest version by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4706 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4703 +### 🧱 Build +* Update Gradle Wrapper from 8.13 to 8.14 by @ElementBot in https://github.com/element-hq/element-x-android/pull/4645 +### Dependency upgrades +* fix(deps): update datastore to v1.1.6 by @renovate in https://github.com/element-hq/element-x-android/pull/4630 +* fix(deps): update lifecycle to v2.9.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4693 +* fix(deps): update dependency androidx.sqlite:sqlite-ktx to v2.5.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4692 +### Others +* Update "Learn more" link by @bmarty in https://github.com/element-hq/element-x-android/pull/4686 +* Keep call notification ringing while a call is present in the room by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4634 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.05.1...v25.05.3 + +Changes in Element X v25.05.1 +============================= + +## What's Changed +### 🐛 Bugfixes +* Fix broken Element Call in 25.05.0 by @bmarty in https://github.com/element-hq/element-x-android/pull/4694 +### Dependency upgrades +* fix(deps): update android.gradle.plugin to v8.10.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4687 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.5.8 by @renovate in https://github.com/element-hq/element-x-android/pull/4696 +### Others +* Let EnterpriseService prevent usage of homeserver by @bmarty in https://github.com/element-hq/element-x-android/pull/4682 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.05.0...v25.05.1 + +Changes in Element X v25.05.0 +============================= + +## What's Changed +### ✨ Features +* Feature : Report room by @ganfra in https://github.com/element-hq/element-x-android/pull/4654 +### 🙌 Improvements +* Render kick and ban reason in the timeline when available by @bmarty in https://github.com/element-hq/element-x-android/pull/4642 +### 🐛 Bugfixes +* Accessibility: improve behavior of list items by @bmarty in https://github.com/element-hq/element-x-android/pull/4626 +* Render caller avatar on Incoming call screen by @bmarty in https://github.com/element-hq/element-x-android/pull/4635 +* Fix `Client.getJoinedRoom` crash when a room doesn't exist locally by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4656 +* Fix wrong member count in join room screen for invitation by @bmarty in https://github.com/element-hq/element-x-android/pull/4651 +* Make sure any `JoinedRustRoom` is destroyed after being used by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4678 +* Fix read receipt behavior when the timeline is opened. by @bmarty in https://github.com/element-hq/element-x-android/pull/4679 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4648 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4677 +### 🧱 Build +* OIDC configuration by @bmarty in https://github.com/element-hq/element-x-android/pull/4623 +* Pin commit sha on GitHub actions by @bmarty in https://github.com/element-hq/element-x-android/pull/4653 +### Dependency upgrades +* fix(deps): update dependency io.sentry:sentry-android to v8.9.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4624 +* fix(deps): update dependency com.posthog:posthog-android to v3.14.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4628 +* fix(deps): update dependency androidx.exifinterface:exifinterface to v1.4.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4632 +* fix(deps): update dependencyanalysis to v2.17.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4638 +* fix(deps): update dependency com.google.firebase:firebase-bom to v33.13.0 - autoclosed by @renovate in https://github.com/element-hq/element-x-android/pull/4637 +* fix(deps): update dependency io.sentry:sentry-android to v8.10.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4644 +* fix(deps): update dependency org.jsoup:jsoup to v1.20.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4655 +* fix(deps): update dependency com.google.accompanist:accompanist-permissions to v0.37.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4649 +* fix(deps): update dependency io.sentry:sentry-android to v8.11.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4660 +* fix(deps): update dependency io.mockk:mockk to v1.14.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4658 +* fix(deps): update dependency io.github.sergio-sastre.composablepreviewscanner:android to v0.6.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4647 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.4.30 by @renovate in https://github.com/element-hq/element-x-android/pull/4665 +* fix(deps): update kotlin to v2.1.20-2.0.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4670 +* fix(deps): update dependency io.sentry:sentry-android to v8.11.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4668 +* fix(deps): update dependency io.element.android:element-call-embedded to v0.10.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4667 +* chore(deps): update rnkdsh/action-upload-diawi action to v1.5.9 by @renovate in https://github.com/element-hq/element-x-android/pull/4674 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.7 by @renovate in https://github.com/element-hq/element-x-android/pull/4673 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.5.6 by @renovate in https://github.com/element-hq/element-x-android/pull/4681 +### Others +* Split `MatrixRoom` into `MatrixRoom` and `JoinedMatrixRoom` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4561 +* Cleanup element call and UI by @bmarty in https://github.com/element-hq/element-x-android/pull/4641 +* Take change of screen_change_server_error_no_sliding_sync_message into account by @bmarty in https://github.com/element-hq/element-x-android/pull/4650 +* Improve the callback uri format and customization. by @bmarty in https://github.com/element-hq/element-x-android/pull/4664 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.04.3...v25.05.0 + +Changes in Element X v25.04.3 +============================= + +### 🙌 Improvements +* Use PreferenceDropdown for appearance by @ganfra in https://github.com/element-hq/element-x-android/pull/4581 +### 🐛 Bugfixes +* Use in-call volume and mode for EC by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4481 +* Send SVG images as files by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4595 +* Fetch the initial ignored user list manually when subscribing by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4598 +* Fix audio output selection for Element Call by @bmarty in https://github.com/element-hq/element-x-android/pull/4602 +* [a11y] Make more items focusable by @bmarty in https://github.com/element-hq/element-x-android/pull/4605 +* Fix ringing calls not stopping when the other user cancels the call by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4613 +* Ensure that pinning an event makes the pinned messages banner appear by @bmarty in https://github.com/element-hq/element-x-android/pull/4606 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4590 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4612 +### 📄 Documentation +* Improve onboarding docs: by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4578 +### Dependency upgrades +* Upgrade Rust bindings to `v25.04.11` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4580 +* fix(deps): update dependency androidx.sqlite:sqlite-ktx to v2.5.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4568 +* fix(deps): update dependency app.cash.molecule:molecule-runtime to v2.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4585 +* fix(deps): update core to v1.16.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4564 +* Upate datastore to 1.1.4 by @bmarty in https://github.com/element-hq/element-x-android/pull/4551 +* fix(deps): update media3 to v1.6.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4592 +* chore(deps): update danger/danger-js action to v13 by @renovate in https://github.com/element-hq/element-x-android/pull/4596 +* fix(deps): update dependency io.element.android:emojibase-bindings to v1.4.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4591 +* fix(deps): update dagger to v2.56.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4603 +* fix(deps): update dependency io.sentry:sentry-android to v8.8.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4557 +* fix(deps): update dependency androidx.compose:compose-bom to v2025.04.00 - autoclosed by @renovate in https://github.com/element-hq/element-x-android/pull/4565 +* fix(deps): update dependency com.posthog:posthog-android to v3.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4616 +* fix(deps): update android.gradle.plugin to v8.9.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4615 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.4.22 by @renovate in https://github.com/element-hq/element-x-android/pull/4622 +### Others +* Improve accessibility of the timeline by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4579 +* Push: improve Push history screen, log and stored data by @bmarty in https://github.com/element-hq/element-x-android/pull/4601 +* Push gateway config by @bmarty in https://github.com/element-hq/element-x-android/pull/4608 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.04.2...v25.04.3 + +Changes in Element X v25.04.2 +============================= + +Security fixes 🔐 +----------------- +- Fix for [GHSA-m5px-pwq3-4p5m](https://github.com/element-hq/element-x-android/security/advisories/GHSA-m5px-pwq3-4p5m) / [CVE-2025-27599](https://www.cve.org/CVERecord?id=CVE-2025-27599) + +Changes in Element X v25.04.1 +============================= + + + +## What's Changed +### ✨ Features +* Introduce PushHistoryService to store data about the received push by @bmarty in https://github.com/element-hq/element-x-android/pull/4573 +### 🙌 Improvements +* change (preferences) : new moderation and safety settings by @ganfra in https://github.com/element-hq/element-x-android/pull/4574 +### 🐛 Bugfixes +* Ensure that we have only one single instance of SeenInviteStore per session by @bmarty in https://github.com/element-hq/element-x-android/pull/4577 +### Dependency upgrades +* fix(deps): update dependencyanalysis to v2.15.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4558 +* fix(deps): update dependency io.mockk:mockk to v1.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4562 +* fix(deps): update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4552 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.6 by @renovate in https://github.com/element-hq/element-x-android/pull/4567 +* fix(deps): update dependencyanalysis to v2.16.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4575 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.04.0...v25.04.1 + +Changes in Element X v25.04.0 +============================= + + + +## What's Changed +### ✨ Features +* Enable Rust trace log packs by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4514 +* Allow using a hardware keyboard to unlock the app using a pin code by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4530 +### 🙌 Improvements +* Change (mention span) : rework and add more cases by @ganfra in https://github.com/element-hq/element-x-android/pull/4476 +* Add kick (remove) confirmation and reason by @bmarty in https://github.com/element-hq/element-x-android/pull/4507 +* Remove the green badge on a pending invite after a first preview by @bmarty in https://github.com/element-hq/element-x-android/pull/4532 +### 🐛 Bugfixes +* Improve touch indicators for the user info UI in the timeline by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4482 +* Limit the text length in the 'in reply to' preview by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4491 +* Timeline header: ensure that the decoration is clickable by @bmarty in https://github.com/element-hq/element-x-android/pull/4495 +* Add video autoplay to media gallery by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4499 +* Add `WakeLock` to dismiss ringing call screen when call is cancelled by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4478 +* Make sure the live timeline is destroyed before clearing a room's cache by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4515 +* Fix bullet points not having leading margin on timeline items by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4536 +* Fix the share location URI by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4544 +* Add a inderminate progress bar when loging out and in Waiting state. by @bmarty in https://github.com/element-hq/element-x-android/pull/4538 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4506 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4543 +### 🧱 Build +* Element config by @bmarty in https://github.com/element-hq/element-x-android/pull/4471 +* Check if Manifest.permission.REQUEST_INSTALL_PACKAGES is in the manifest by @bmarty in https://github.com/element-hq/element-x-android/pull/4490 +* Remove nightly_enterprise.yml. by @bmarty in https://github.com/element-hq/element-x-android/pull/4492 +* Log the packageId which is currently built. by @bmarty in https://github.com/element-hq/element-x-android/pull/4494 +* Use handy buildConfigFieldStr. by @bmarty in https://github.com/element-hq/element-x-android/pull/4501 +* Fix warnings in InMemoryAppPreferencesStore by @bmarty in https://github.com/element-hq/element-x-android/pull/4523 +### Dependency upgrades +* fix(deps): update camera to v1.4.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4483 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.5 by @renovate in https://github.com/element-hq/element-x-android/pull/4487 +* fix(deps): update dependency com.posthog:posthog-android to v3.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4469 +* fix(deps): update dependency androidx.compose:compose-bom to v2025.03.01 by @renovate in https://github.com/element-hq/element-x-android/pull/4484 +* fix(deps): update dependencyanalysis to v2.13.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4493 +* fix(deps): update media3 to v1.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4488 +* fix(deps): update dependency io.element.android:element-call-embedded to v0.9.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4498 +* fix(deps): update dependency com.google.firebase:firebase-bom to v33.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4508 +* fix(deps): update dependency com.posthog:posthog-android to v3.13.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4516 +* fix(deps): update dependency io.sentry:sentry-android to v8.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4509 +* fix(deps): update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4444 +* fix(deps): update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4522 +* fix(deps): update dependencyanalysis to v2.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4527 +* fix(deps): update dependency io.element.android:compound-android to v25.4.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4537 +* chore(deps): update plugin dependencycheck to v12.1.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4540 +* fix(deps): update appyx to v1.7.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4547 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.4.7 by @renovate in https://github.com/element-hq/element-x-android/pull/4548 +### Others +* Update screenshots by @bmarty in https://github.com/element-hq/element-x-android/pull/4497 +* Update store description. by @bmarty in https://github.com/element-hq/element-x-android/pull/4496 +* Improve TextFieldDialog by @bmarty in https://github.com/element-hq/element-x-android/pull/4512 +* Make `RustMatrixClient.close` asynchronous by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4513 +* Replace OutlinedTextField by our TextField by @bmarty in https://github.com/element-hq/element-x-android/pull/4521 +* Remove alias from room invite item by @bmarty in https://github.com/element-hq/element-x-android/pull/4531 +* Remember flows by @bmarty in https://github.com/element-hq/element-x-android/pull/4533 +* Use colors from compound for badges by @bmarty in https://github.com/element-hq/element-x-android/pull/4545 +* Update app icon by @bmarty in https://github.com/element-hq/element-x-android/pull/4534 +* Click on userId / room alias to copy value to clipboard. by @bmarty in https://github.com/element-hq/element-x-android/pull/4549 +* Run the 'prevent blocked' workflow even if PR has conflicts by @robintown in https://github.com/element-hq/element-x-android/pull/4432 +* Update wording for push provider support test. (#4079) by @bmarty in https://github.com/element-hq/element-x-android/pull/4553 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.03.4...v25.04.0 + +Changes in Element X v25.03.4 +============================= + + + +## What's Changed +### 🙌 Improvements +* Change : composer suggestions by @ganfra in https://github.com/element-hq/element-x-android/pull/4485 +### 🧱 Build +* Fix flaky incoming verification tests by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4479 +### Dependency upgrades +* fix(deps): update dagger to v2.56.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4472 +* fix(deps): update dependencyanalysis to v2.13.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4473 +* Upgrade embedded EC version to `v0.9.0-rc.4` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4489 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.03.3...v25.03.4 + +Changes in Element X v25.03.3 +============================= + + + +## What's Changed +### ✨ Features +* Add 'unencrypted room' badges and labels by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4445 +* Use embedded version of Element Call by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4470 +### 🐛 Bugfixes +* Fix 'unverified session' flow displayed when creating account by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4467 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4461 +### 🧱 Build +* Let element enterprise be able to configure id for mapTiler. by @bmarty in https://github.com/element-hq/element-x-android/pull/4446 +### Dependency upgrades +* chore(deps): update rnkdsh/action-upload-diawi action to v1.5.8 by @renovate in https://github.com/element-hq/element-x-android/pull/4457 +* chore(deps): update plugin licensee to v1.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4447 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4450 +* fix(deps): update dependency com.google.firebase:firebase-bom to v33.11.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4448 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.3.24 by @renovate in https://github.com/element-hq/element-x-android/pull/4394 +* fix(deps): update dependencyanalysis to v2.13.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4464 +* chore(deps): update plugin sonarqube to v6.1.0.5360 by @renovate in https://github.com/element-hq/element-x-android/pull/4468 +* fix(deps): update android.gradle.plugin to v8.9.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4465 +### Others +* Sync Strings - tweaks to identity change messages by @andybalaam in https://github.com/element-hq/element-x-android/pull/4454 +* Check link click by @bmarty in https://github.com/element-hq/element-x-android/pull/4463 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.03.2...v25.03.3 + +Changes in Element X v25.03.2 +============================= + + + +## What's Changed +### ✨ Features +* Implement user verification by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4294 +* Add user verification and verification state violation badges by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4392 +* Open txt document inside the application by @bmarty in https://github.com/element-hq/element-x-android/pull/4414 +* Add timeline item prefetching by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4399 +### 🐛 Bugfixes +* fix(read receipt): track read receipts for focused timeline by @ganfra in https://github.com/element-hq/element-x-android/pull/4374 +* Discard timed out verification requests by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4385 +* Ensure the snackbar "No more media to show" is not rendered when opening the media viewer. by @bmarty in https://github.com/element-hq/element-x-android/pull/4397 +* Disable click effect on Stickers by @bmarty in https://github.com/element-hq/element-x-android/pull/4401 +* Ensure that a click on a media open the correct media. by @bmarty in https://github.com/element-hq/element-x-android/pull/4413 +* Display user verification violation icon in DM rooms too by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4423 +* Add a filter to avoid stack overflow when pressing the back button several times. by @bmarty in https://github.com/element-hq/element-x-android/pull/4430 +* Make verification screens scrollable and emoji labels multiline by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4449 +### 🗣 Translations +* Sync Strings - New translations in Basque by @ElementBot in https://github.com/element-hq/element-x-android/pull/4381 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4421 +### 🧱 Build +* More PR checks by @bmarty in https://github.com/element-hq/element-x-android/pull/4384 +* "Core Team" is a team of matrix-org. Use team "Vector Core" instead. by @bmarty in https://github.com/element-hq/element-x-android/pull/4393 +* Fix warnings in tests for push provider modules by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4398 +* Update Gradle Wrapper from 8.12.1 to 8.13 by @ElementBot in https://github.com/element-hq/element-x-android/pull/4308 +* Revert agp to 8.8.1 by @bmarty in https://github.com/element-hq/element-x-android/pull/4451 +### Dependency upgrades +* Update rnkdsh/action-upload-diawi action to v1.5.7 by @renovate in https://github.com/element-hq/element-x-android/pull/4354 +* fix(deps): update dependency com.posthog:posthog-android to v3.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4387 +* fix(deps): update dependencyanalysis to v2.11.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4395 +* fix(deps): update dependency androidx.compose:compose-bom to v2025.03.00 by @renovate in https://github.com/element-hq/element-x-android/pull/4407 +* fix(deps): update dependency androidx.webkit:webkit to v1.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4408 +* fix(deps): update dependency net.java.dev.jna:jna to v5.17.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4419 +* fix(deps): update dependencyanalysis to v2.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4409 +* Add Google Tink dependency, replacing `androidx.security.crypto` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4405 +* fix(deps): update dependency io.sentry:sentry-android to v8.4.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4411 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4427 +* chore(deps): update webfactory/ssh-agent action to v0.9.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4426 +* fix(deps): update android.gradle.plugin to v8.9.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4320 +* Update SDK version to `25.03.13` and fix breaking changes by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4406 +* Update dagger to v2.56 by @renovate in https://github.com/element-hq/element-x-android/pull/4440 +* Update dependency io.sentry:sentry-android to v8.5.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4433 +* Update dependencyAnalysis to v2.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4442 +* Update dependency com.google.crypto.tink:tink-android to v1.17.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4422 +* deps(rust sdk) : update to 25.03.20 and fix api change by @ganfra in https://github.com/element-hq/element-x-android/pull/4452 +### Others +* Migrate some icons to Compound icon by @bmarty in https://github.com/element-hq/element-x-android/pull/4375 +* Long press link to copy URL to clipboard by @ShadowRZ in https://github.com/element-hq/element-x-android/pull/4376 +* Use public icon from Compound by @bmarty in https://github.com/element-hq/element-x-android/pull/4386 +* Be able to correctly render the UI with other colors. by @bmarty in https://github.com/element-hq/element-x-android/pull/4378 +* Let EnterpriseService provides push gateways by @bmarty in https://github.com/element-hq/element-x-android/pull/4400 +* Add feature flag to let the application prints logs to logcat in release builds. by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4402 +* Hide "unencrypted" lock for redacted messages by @Xant3s in https://github.com/element-hq/element-x-android/pull/4410 +* Hide unencrypted lock for redacted msgs by @bmarty in https://github.com/element-hq/element-x-android/pull/4429 +* Clear SDK cache properly by @bmarty in https://github.com/element-hq/element-x-android/pull/4396 + +## New Contributors +* @ShadowRZ made their first contribution in https://github.com/element-hq/element-x-android/pull/4376 +* @Xant3s made their first contribution in https://github.com/element-hq/element-x-android/pull/4410 + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.03.1...v25.03.2 + +Changes in Element X v25.03.1 +============================= + + + +## What's Changed +### ✨ Features +* Enable the Event cache by default. by @bmarty in https://github.com/element-hq/element-x-android/pull/4373 +### 🙌 Improvements +* change(create room) : use history visibility "invited" by @ganfra in https://github.com/element-hq/element-x-android/pull/4335 +* change(room directory) : move the the room directory entry by @ganfra in https://github.com/element-hq/element-x-android/pull/4348 +* [Change] Invited state room preview by @ganfra in https://github.com/element-hq/element-x-android/pull/4353 +* change(left room snackbar) : manage cancel knock and decline invite by @ganfra in https://github.com/element-hq/element-x-android/pull/4360 +### 🐛 Bugfixes +* Restore manual `Client` cleanup on session logout by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4333 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4346 +### 🧱 Build +* Fix typo on job name. by @bmarty in https://github.com/element-hq/element-x-android/pull/4352 +### Dependency upgrades +* chore(deps): update plugin ktlint to v12.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4338 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v11.8.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4340 +* fix(deps): update dependency io.mockk:mockk to v1.13.17 by @renovate in https://github.com/element-hq/element-x-android/pull/4334 +* fix(deps): update kotlin to v2.1.10-1.0.31 by @renovate in https://github.com/element-hq/element-x-android/pull/4337 +* fix(deps): update dependency com.google.firebase:firebase-bom to v33.10.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4339 +* Migrate to coil3 by @bmarty in https://github.com/element-hq/element-x-android/pull/4347 +* fix(deps): update dependency org.jsoup:jsoup to v1.19.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4351 +* deps(rust sdk) : update to 25.03.05 by @ganfra in https://github.com/element-hq/element-x-android/pull/4370 +* Update dependency org.matrix.rustcomponents:sdk-android to v25.3.6 by @renovate in https://github.com/element-hq/element-x-android/pull/4371 +### Others +* Prevent PRs with the X-Blocked label from being merged by @robintown in https://github.com/element-hq/element-x-android/pull/4350 +* Fix some icon colors by @bmarty in https://github.com/element-hq/element-x-android/pull/4365 +* Remove PreferenceText, replace by ListItem. by @bmarty in https://github.com/element-hq/element-x-android/pull/4369 +* Show error screens in group calls by @robintown in https://github.com/element-hq/element-x-android/pull/4297 + +## New Contributors +* @robintown made their first contribution in https://github.com/element-hq/element-x-android/pull/4350 + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.03.0...v25.03.1 + +Changes in Element X v25.03.0 +============================= + + + +## What's Changed +### ✨ Features +* Create `SyncOrchestrator` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4176 +* feature(crypto): verification violation handling and block sending by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/4126 +* Update Matrix Room API and allow media swipe on pinned event only. by @bmarty in https://github.com/element-hq/element-x-android/pull/4274 +* Feature : join room by address by @ganfra in https://github.com/element-hq/element-x-android/pull/4302 +### 🙌 Improvements +* Change : Room Preview by @ganfra in https://github.com/element-hq/element-x-android/pull/4250 +### 🐛 Bugfixes +* SyncOrchestrator: restore the initial sync step by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4242 +* When an emoji is used as the 'initial' for an avatar, use the whole emoji by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4277 +* Try avoiding trailing punctuation inside linkified URLs by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4214 +* Preload account urls by @bmarty in https://github.com/element-hq/element-x-android/pull/4301 +* Fix issues due to multiple ntfy applications with the same name. by @bmarty in https://github.com/element-hq/element-x-android/pull/4312 +* Use `Settings.System.DEFAULT_RINGTONE_URI` for ringing notifications by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4310 +### 🗣 Translations +* Sync Strings - New translations to turkish by @ElementBot in https://github.com/element-hq/element-x-android/pull/4253 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4298 +### 🧱 Build +* Fix nightly reports by @bmarty in https://github.com/element-hq/element-x-android/pull/4235 +* Fix nightly reports - next step by @bmarty in https://github.com/element-hq/element-x-android/pull/4239 +* Prepare application for being configurable by @bmarty in https://github.com/element-hq/element-x-android/pull/4285 +* runQualityChecks task shouldn't fail fast by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4309 +* Get library ComposablePreviewScanner from maven and update to the latest version by @bmarty in https://github.com/element-hq/element-x-android/pull/4327 +### Dependency upgrades +* Update dependency com.posthog:posthog-android to v3.11.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4230 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.78 by @renovate in https://github.com/element-hq/element-x-android/pull/4234 +* Update dependency org.maplibre.gl:android-sdk to v11.8.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4245 +* fix(deps): update dependency org.jetbrains.kotlinx:kotlinx-datetime to v0.6.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4258 +* fix(deps): update dependency io.sentry:sentry-android to v8.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4262 +* fix(deps): update telephoto to v0.15.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4270 +* fix(deps): update dependency com.google.firebase:firebase-bom to v33.9.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4249 +* chore(deps): update danger/danger-js action to v12.3.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4259 +* fix(deps): update android.gradle.plugin to v8.8.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4263 +* chore(deps): update plugin dependencycheck to v12.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4272 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25 by @renovate in https://github.com/element-hq/element-x-android/pull/4273 +* fix(deps): update dependency androidx.compose:compose-bom to v2025.02.00 by @renovate in https://github.com/element-hq/element-x-android/pull/4261 +* fix(deps): update kotlin to v2.1.10-1.0.30 by @renovate in https://github.com/element-hq/element-x-android/pull/4265 +* fix(deps): update dependency io.github.zxing-cpp:android to v2.3.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4282 +* fix(deps): update firebaseappdistribution to v5.1.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4246 +* fix(deps): update dependencyanalysis to v2.8.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4251 +* fix(deps): update dependency com.google.accompanist:accompanist-permissions to v0.37.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4283 +* fix(deps): update dependency com.google.accompanist:accompanist-permissions to v0.37.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4287 +* fix(deps): update dependencyanalysis to v2.10.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4288 +* fix(deps): update dependencyanalysis to v2.10.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4295 +* Upgrade SDK version to 25.02.26 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4305 +* fix(deps): update kotlinpoet to v2.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4304 +* Update compound by @bmarty in https://github.com/element-hq/element-x-android/pull/4319 +* fix(deps): update dependency androidx.constraintlayout:constraintlayout-compose to v1.1.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4324 +* fix(deps): update activity to v1.10.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4321 +* fix(deps): update dependency androidx.exifinterface:exifinterface to v1.4.0 - autoclosed by @renovate in https://github.com/element-hq/element-x-android/pull/4325 +* fix(deps): update dependency androidx.constraintlayout:constraintlayout to v2.2.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4322 +* fix(deps): update dependency io.sentry:sentry-android to v8.3.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4316 +* fix(deps): update dependency com.posthog:posthog-android to v3.11.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4313 +* fix(deps): update dependency com.android.tools:desugar_jdk_libs to v2.1.5 by @renovate in https://github.com/element-hq/element-x-android/pull/4299 +* chore(deps): update plugin detekt to v1.23.8 by @renovate in https://github.com/element-hq/element-x-android/pull/4292 +### Others +* Update incoming call notification content to "📹 Incoming call" by @bmarty in https://github.com/element-hq/element-x-android/pull/4231 +* Display a bottom sheet to let user confirm the DM creation by @bmarty in https://github.com/element-hq/element-x-android/pull/4233 +* Open chat links in regular browser tabs by @cbs228 in https://github.com/element-hq/element-x-android/pull/4198 +* Theme override by @bmarty in https://github.com/element-hq/element-x-android/pull/4226 +* Allow user certificate in production builds. by @bmarty in https://github.com/element-hq/element-x-android/pull/4275 +* Replace Material icons with Compound icons wherever it's possible by @bmarty in https://github.com/element-hq/element-x-android/pull/4323 + +## New Contributors +* @cbs228 made their first contribution in https://github.com/element-hq/element-x-android/pull/4198 + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.02.0...v25.03.0 + +Changes in Element X v25.02.0 (2025-02-04) +========================================== + + + +## What's Changed +### ✨ Features +* Media navigation with swipe gesture by @bmarty in https://github.com/element-hq/element-x-android/pull/4161 +* Add ability to swipe between media when opened from the timeline. by @bmarty in https://github.com/element-hq/element-x-android/pull/4205 +### 🙌 Improvements +* change(design) : use ElementTheme.typography.fontBodyLgMedium by @ganfra in https://github.com/element-hq/element-x-android/pull/4145 +* change(design) : New component Announcement by @ganfra in https://github.com/element-hq/element-x-android/pull/4140 +* update rust sdk 0.2.75 by @ganfra in https://github.com/element-hq/element-x-android/pull/4158 +### 🐛 Bugfixes +* Fix dm avatar rtl by @bmarty in https://github.com/element-hq/element-x-android/pull/4103 +* Unified push gateway resolver improvement by @bmarty in https://github.com/element-hq/element-x-android/pull/4101 +* Close the media preview screen ASAP with sending queue enabled by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4089 +* fix(coroutine) : make sure to switch coroutine context by @ganfra in https://github.com/element-hq/element-x-android/pull/4146 +* Fix snack bar not displayed in MediaViewer by @bmarty in https://github.com/element-hq/element-x-android/pull/4195 +* Let the SDK provide the "network is available information" by @bmarty in https://github.com/element-hq/element-x-android/pull/4215 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4088 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4100 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4114 +* Fix import of en-US translations. by @bmarty in https://github.com/element-hq/element-x-android/pull/4135 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4139 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4172 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4199 +* Sync Strings - new (partial) language: Norwegian by @ElementBot in https://github.com/element-hq/element-x-android/pull/4227 +### 🧱 Build +* Update Gradle Wrapper from 8.11.1 to 8.12 by @ElementBot in https://github.com/element-hq/element-x-android/pull/4085 +* Test using Maestro CLI + emulator instead of Cloud by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4092 +* Make Maestro run for each PR push by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4121 +* Migrate to CalVer like versioning by @bmarty in https://github.com/element-hq/element-x-android/pull/4187 +* Kover: include back :libraries:matrix:impl module. by @bmarty in https://github.com/element-hq/element-x-android/pull/4193 +* Update Gradle Wrapper from 8.12 to 8.12.1 by @ElementBot in https://github.com/element-hq/element-x-android/pull/4196 +* Use secret Sentry DSN value by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4210 +* Use Sentry breadcrumbs instead of logging new events by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4223 +### 🚧 In development 🚧 +* Media Viewer: show snackbar when reaching end of timeline. by @bmarty in https://github.com/element-hq/element-x-android/pull/4201 +* Feature : room settings - security and privacy by @ganfra in https://github.com/element-hq/element-x-android/pull/4212 +### Dependency upgrades +* Update dependency io.mockk:mockk to v1.13.14 by @renovate in https://github.com/element-hq/element-x-android/pull/4083 +* Update dependency net.java.dev.jna:jna to v5.16.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4087 +* Update kotlin to v1.10.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4073 +* Update dagger to v2.54 by @renovate in https://github.com/element-hq/element-x-android/pull/4084 +* Update dependency io.sentry:sentry-android to v7.19.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4090 +* Update dependency com.android.tools:desugar_jdk_libs to v2.1.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4077 +* Update dependency com.posthog:posthog-android to v3.10.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4120 +* Update appyx to v1.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4129 +* Update dagger to v2.55 by @renovate in https://github.com/element-hq/element-x-android/pull/4131 +* Update android.gradle.plugin to v8.8.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4130 +* Update dependency org.maplibre.gl:android-sdk to v11.8.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4132 +* Update dependency io.mockk:mockk to v1.13.16 by @renovate in https://github.com/element-hq/element-x-android/pull/4134 +* Update dependencyAnalysis to v2.7.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4136 +* Update anvil to v0.4.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4144 +* Update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4117 +* Update plugin dependencycheck to v12 by @renovate in https://github.com/element-hq/element-x-android/pull/4137 +* Update dependency io.sentry:sentry-android to v7.20.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4107 +* Update wysiwyg to v2.38.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4104 +* Update dependency androidx.recyclerview:recyclerview to v1.4.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4154 +* Update activity to v1.10.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4152 +* Update firebaseAppDistribution to v5.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4159 +* Update dependency com.google.firebase:firebase-bom to v33.8.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4160 +* Update dependency androidx.compose:compose-bom to v2025 by @renovate in https://github.com/element-hq/element-x-android/pull/4155 +* Update dependency io.sentry:sentry-android to v7.20.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4178 +* Update dependency io.sentry:sentry-android to v8 by @renovate in https://github.com/element-hq/element-x-android/pull/4180 +* Update wysiwyg to v2.38.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4177 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.76 by @renovate in https://github.com/element-hq/element-x-android/pull/4183 +* Update wysiwyg to v2.38.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4186 +* Update dependency com.posthog:posthog-android to v3.11.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4204 +* Update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/4200 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.77 by @renovate in https://github.com/element-hq/element-x-android/pull/4228 +* Update dependency com.posthog:posthog-android to v3.11.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4222 +* Update dependency io.element.android:emojibase-bindings to v1.3.4 by @renovate in https://github.com/element-hq/element-x-android/pull/4213 +* Update dependencyAnalysis to v2.8.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4218 +* Update dependency androidx.compose:compose-bom to v2025.01.01 by @renovate in https://github.com/element-hq/element-x-android/pull/4217 +* Update dependency io.sentry:sentry-android to v8.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4221 +* Update rnkdsh/action-upload-diawi action to v1.5.6 by @renovate in https://github.com/element-hq/element-x-android/pull/4173 +* Update plugin dependencycheck to v12.0.2 by @renovate in https://github.com/element-hq/element-x-android/pull/4170 +### Others +* Improve gallery loading state by @bmarty in https://github.com/element-hq/element-x-android/pull/4080 +* Show more detail about the error when pusher registration fails. by @bmarty in https://github.com/element-hq/element-x-android/pull/4081 +* Update pull request template and CI automation by @bmarty in https://github.com/element-hq/element-x-android/pull/4037 +* Add a log function for handling complex values to the WebView client. by @Half-Shot in https://github.com/element-hq/element-x-android/pull/4098 +* design : CounterAtom by @ganfra in https://github.com/element-hq/element-x-android/pull/4108 +* Change sticker mimetype fallback to image by @surakin in https://github.com/element-hq/element-x-android/pull/4111 +* Dual licensing: AGPL + Element Commercial by @bmarty in https://github.com/element-hq/element-x-android/pull/4118 +* Replace the InfoListOrganism default bg color by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4091 +* Ignore dependency that are not third-party licenses to us. by @bmarty in https://github.com/element-hq/element-x-android/pull/4122 +* misc(send queue) : do not disable send queue when Network is Offline by @ganfra in https://github.com/element-hq/element-x-android/pull/4105 +* Remove or replace unnecessary `BackHandler` calls by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4148 +* Replace our firstIfSingle extension with singleOrNull from the Kotlin library by @bmarty in https://github.com/element-hq/element-x-android/pull/4184 +* Remove log. by @bmarty in https://github.com/element-hq/element-x-android/pull/4203 +* Remove unused types / code. by @bmarty in https://github.com/element-hq/element-x-android/pull/4185 +* Consider that the topic of a room has been removed when it's blank. by @bmarty in https://github.com/element-hq/element-x-android/pull/4209 +* CalVer: use 2 digits for the year and 2 digits for the month. by @bmarty in https://github.com/element-hq/element-x-android/pull/4192 +* Always display encryption badge by @bmarty in https://github.com/element-hq/element-x-android/pull/4219 + +## New Contributors +* @Half-Shot made their first contribution in https://github.com/element-hq/element-x-android/pull/4098 + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v0.7.6...v25.02.0 + +Changes in Element X v0.7.6 (2024-12-20) +======================================== + +## What's Changed +### ✨ Features +* Media gallery UI by @bmarty in https://github.com/element-hq/element-x-android/pull/4010 +* Render audio file in the files list and improve media viewer for audio/voice files by @bmarty in https://github.com/element-hq/element-x-android/pull/4031 +* Media gallery UI update by @bmarty in https://github.com/element-hq/element-x-android/pull/4071 +### 🙌 Improvements +* Support new properties in posthog UTD reports by @richvdh in https://github.com/element-hq/element-x-android/pull/4020 +### 🐛 Bugfixes +* fix(dm) : remove duplicate LaunchedEffect when opening DM by @ganfra in https://github.com/element-hq/element-x-android/pull/4012 +* Always attempt to start the sync when starting the application. by @bmarty in https://github.com/element-hq/element-x-android/pull/4069 +* Fix rendering issue in the toolbar. by @bmarty in https://github.com/element-hq/element-x-android/pull/4075 +* fix(timeline) : dispatch timeline creations trying to avoid ANRs by @ganfra in https://github.com/element-hq/element-x-android/pull/4076 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4007 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4043 +* Add Accept-Language to extra header when opening CustomChromeTab by @bmarty in https://github.com/element-hq/element-x-android/pull/4051 +### 🧱 Build +* Update Gradle Wrapper from 8.10.2 to 8.11.1 by @ElementBot in https://github.com/element-hq/element-x-android/pull/4019 +### 📄 Documentation +* [Doc] Improve instructions for building Rust SDK locally by @richvdh in https://github.com/element-hq/element-x-android/pull/4015 +* Build SDK for the local hardware by @richvdh in https://github.com/element-hq/element-x-android/pull/4021 +### 🚧 In development 🚧 +* feat(knock_requests_list) : implement design by @ganfra in https://github.com/element-hq/element-x-android/pull/3995 +* feat(knock) : Knock Requests Banner UI by @ganfra in https://github.com/element-hq/element-x-android/pull/4005 +* Add a feature flag to be able to enable the event cache by @bmarty in https://github.com/element-hq/element-x-android/pull/4029 +* Improve title and subtitle for empty states in the gallery. by @bmarty in https://github.com/element-hq/element-x-android/pull/4038 +* Inline voice message player in the files gallery. by @bmarty in https://github.com/element-hq/element-x-android/pull/4045 +* Media gallery update by @bmarty in https://github.com/element-hq/element-x-android/pull/4059 +* feat(knock requests) : branch logic for handling knock requests by @ganfra in https://github.com/element-hq/element-x-android/pull/4067 +### Dependency upgrades +* Update dependency io.sentry:sentry-android to v7.18.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3972 +* Update dependency com.google.firebase:firebase-bom to v33.7.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4001 +* Update nschloe/action-cached-lfs-checkout action to v1.2.3 by @renovate in https://github.com/element-hq/element-x-android/pull/4017 +* Update dependency com.posthog:posthog-android to v3.9.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3960 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.70 by @renovate in https://github.com/element-hq/element-x-android/pull/4018 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.71 by @renovate in https://github.com/element-hq/element-x-android/pull/4024 +* Update camera to v1.4.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4022 +* Update dependency org.maplibre.gl:android-sdk to v11.7.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4028 +* Update dependency io.nlopez.compose.rules:detekt to v0.4.22 by @renovate in https://github.com/element-hq/element-x-android/pull/4016 +* Update dependencyAnalysis to v2.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3996 +* Update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/3955 +* Update dependency org.jsoup:jsoup to v1.18.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3951 +* Update dagger to v2.53.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4013 +* Update dependency io.sentry:sentry-android to v7.19.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4030 +* Update dependency org.jetbrains.kotlinx:kover-gradle-plugin to v0.9.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4032 +* Update dependencyAnalysis to v2.6.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4041 +* Update dependency androidx.compose:compose-bom to v2024.12.01 by @renovate in https://github.com/element-hq/element-x-android/pull/4023 +* Update android.gradle.plugin to v8.7.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3982 +* Update dependency com.lemonappdev:konsist to v0.17.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3997 +* Update dependency com.google.accompanist:accompanist-permissions to v0.37.0 by @renovate in https://github.com/element-hq/element-x-android/pull/4035 +* depencies(sdk) : update rust sdk 0.2.72 by @ganfra in https://github.com/element-hq/element-x-android/pull/4060 +* Update dependency org.maplibre.gl:android-sdk to v11.7.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4066 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.73 by @renovate in https://github.com/element-hq/element-x-android/pull/4070 +* Update media3 to v1.5.1 by @renovate in https://github.com/element-hq/element-x-android/pull/4072 +### Others +* Add destructive param to BigIcon.Style.Default to be able to render icons with red tint by @bmarty in https://github.com/element-hq/element-x-android/pull/4004 +* UI: knock avatars by @bmarty in https://github.com/element-hq/element-x-android/pull/4014 +* Implement month separator for the Gallery, and improve date rendering. by @bmarty in https://github.com/element-hq/element-x-android/pull/4026 +* Extract voice message player to its own module by @bmarty in https://github.com/element-hq/element-x-android/pull/4036 +* Add a quick filter on the open source licence screen. by @bmarty in https://github.com/element-hq/element-x-android/pull/4052 +* Make the room filter use normalized strings. by @bmarty in https://github.com/element-hq/element-x-android/pull/4050 +* Add test on DefaultMediaPlayer. by @bmarty in https://github.com/element-hq/element-x-android/pull/4054 +* Fix flaky test by using CompletableDeferred by @bmarty in https://github.com/element-hq/element-x-android/pull/4057 +* feat(crypto): Support for new UtdCause for historical messages by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/4044 +* Update message action list by @bmarty in https://github.com/element-hq/element-x-android/pull/4056 +* Update recovery key UI by @bmarty in https://github.com/element-hq/element-x-android/pull/4065 +* Fix gallery title by @bmarty in https://github.com/element-hq/element-x-android/pull/4078 + +Changes in Element X v0.7.5 (2024-12-06) +======================================== + +## What's Changed +### ✨ Features +* Allow to set caption when uploading file and audio files, and allow adding / edit / remove caption on Event with attachment (also works on local echo) by @bmarty in https://github.com/element-hq/element-x-android/pull/3902 +* Enable all notification actions: quick reply, accept/decline invite, mark as read from notification. by @bmarty in https://github.com/element-hq/element-x-android/pull/3916 +* Video player controller by @bmarty in https://github.com/element-hq/element-x-android/pull/3959 +### 🙌 Improvements +* change : confirm biometric before allowing biometric unlock. by @ganfra in https://github.com/element-hq/element-x-android/pull/3930 +* Hide media preprocessing by @bmarty in https://github.com/element-hq/element-x-android/pull/3943 +* changes: iterate on room create screen by @ganfra in https://github.com/element-hq/element-x-android/pull/3966 +* change : knock message supporting text display number of characters by @ganfra in https://github.com/element-hq/element-x-android/pull/3970 +* feat(design) : update send button background by @ganfra in https://github.com/element-hq/element-x-android/pull/4000 +### 🐛 Bugfixes +* Min size for hidden media by @bmarty in https://github.com/element-hq/element-x-android/pull/3906 +* fix : use RoomMembershipObserver to close room screen when leaving by @ganfra in https://github.com/element-hq/element-x-android/pull/3887 +* fix : protect some usages of client to avoid crashes by @bmarty in https://github.com/element-hq/element-x-android/pull/3886 +* Fix long click not working on pinned events timeline by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3940 +* Element Call: display error dialog only when loading the main URL by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3962 +* Fix navigation issue when entering recovery key after navigating from the banner by @bmarty in https://github.com/element-hq/element-x-android/pull/3961 +* navigation : clear backstack when opening room from outer node by @ganfra in https://github.com/element-hq/element-x-android/pull/3984 +* fix : hide keyboard when TextComposer is removed from composition by @ganfra in https://github.com/element-hq/element-x-android/pull/3985 +* fix(room_preview) : catch all exception instead by @ganfra in https://github.com/element-hq/element-x-android/pull/3989 +* fix(room_detail) : hide room avatar preview by @ganfra in https://github.com/element-hq/element-x-android/pull/3992 +* fix(composer) : use HideKeyboardWhenDisposed only in MessagesView by @ganfra in https://github.com/element-hq/element-x-android/pull/3993 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3936 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3975 +### Dependency upgrades +* Update dependency io.sentry:sentry-android to v7.18.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3891 +* Update plugin sonarqube to v6 - autoclosed by @renovate in https://github.com/element-hq/element-x-android/pull/3895 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.64 by @renovate in https://github.com/element-hq/element-x-android/pull/3907 +* Update dependency com.autonomousapps.dependency-analysis to v2.5.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3909 +* Update dependency org.robolectric:robolectric to v4.14.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3924 +* Update dependency io.element.android:compound-android to v0.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3915 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.65 by @renovate in https://github.com/element-hq/element-x-android/pull/3932 +* Update media3 to v1.5.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3942 +* Update plugin ktlint to v12.1.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3944 +* Update wysiwyg to v2.37.14 by @renovate in https://github.com/element-hq/element-x-android/pull/3948 +* Update mobile-dev-inc/action-maestro-cloud action to v1.9.7 by @renovate in https://github.com/element-hq/element-x-android/pull/3914 +* Update dependency com.lemonappdev:konsist to v0.17.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3947 +* deps : update rust sdk to 0.2.67 and fix breaking changes by @ganfra in https://github.com/element-hq/element-x-android/pull/3957 +* Update dependency com.lemonappdev:konsist to v0.17.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3983 +* Update plugin sonarqube to v6.0.1.5171 by @renovate in https://github.com/element-hq/element-x-android/pull/3958 +* Update dagger to v2.53 by @renovate in https://github.com/element-hq/element-x-android/pull/3986 +* Update dependency com.sigpwned:emoji4j-core to v16 by @renovate in https://github.com/element-hq/element-x-android/pull/3899 +* dependencies : update rust sdk to 0.2.68 by @ganfra in https://github.com/element-hq/element-x-android/pull/3988 +* Update plugin dependencycheck to v11.1.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3994 +* chore(dependencies) : update rust sdk to 0.2.69 by @ganfra in https://github.com/element-hq/element-x-android/pull/3999 +### Others +* Send button iteration by @bmarty in https://github.com/element-hq/element-x-android/pull/3901 +* Fix photo / video name by @bmarty in https://github.com/element-hq/element-x-android/pull/3903 +* Render edited caption. by @bmarty in https://github.com/element-hq/element-x-android/pull/3904 +* Rely on the SDK to decide if a caption is editable or not by @bmarty in https://github.com/element-hq/element-x-android/pull/3917 +* Remove AttachmentsState and use the MessagesNavigator by @bmarty in https://github.com/element-hq/element-x-android/pull/3918 +* Fix element call crash when resuming from notification by @bmarty in https://github.com/element-hq/element-x-android/pull/3926 +* Ensure that the SDK is syncing during an incoming call so that the app can cancel the notification by @bmarty in https://github.com/element-hq/element-x-android/pull/3931 +* Add feature flag to temporary disable sending caption by default in production by @bmarty in https://github.com/element-hq/element-x-android/pull/3953 +* Add timeline action item to copy caption by @bmarty in https://github.com/element-hq/element-x-android/pull/3963 +* Fix wrong name of classes and method by @bmarty in https://github.com/element-hq/element-x-android/pull/3971 +* Rework on media module by @bmarty in https://github.com/element-hq/element-x-android/pull/3967 +* Add warning when adding a caption. by @bmarty in https://github.com/element-hq/element-x-android/pull/3977 +* Do not auto-play videos. by @bmarty in https://github.com/element-hq/element-x-android/pull/3978 +* MediaViewer: iterate on design by @bmarty in https://github.com/element-hq/element-x-android/pull/3979 +* feat(crypto): Support new expected UTD causes UX + Analytics by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/3980 +* increase ringing timeout from 15 seconds to 90 seconds by @fkwp in https://github.com/element-hq/element-x-android/pull/3991 +* MediaViewer: Align title to left and move action bottom to top bar. by @bmarty in https://github.com/element-hq/element-x-android/pull/4003 + +Changes in Element X v0.7.4 (2024-11-20) +======================================== + +## What's Changed +### 🙌 Improvements +* Update the strings for unsupported calls by @bmarty in https://github.com/element-hq/element-x-android/pull/3857 +### 🐛 Bugfixes +* Stop incoming call ringing if answered on another device. by @bmarty in https://github.com/element-hq/element-x-android/pull/3842 +* Use formatted captions for images and video by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3864 +* Fix unified push unregister by @bmarty in https://github.com/element-hq/element-x-android/pull/3877 +* Hide the keyboard when navigating from the chat room screen by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3878 +* Fix long click not working for media timeline items by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3879 +* Instantiate the verification controller ASAP by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3893 +* fix : display security banner for room list empty state by @ganfra in https://github.com/element-hq/element-x-android/pull/3892 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3852 +* Sync Strings - add translations to Finnish by @ElementBot in https://github.com/element-hq/element-x-android/pull/3883 +### 🚧 In development 🚧 +* Create room : improve handling of room address by @ganfra in https://github.com/element-hq/element-x-android/pull/3868 +### Dependency upgrades +* Update anvil to v0.4.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3792 +* Update kotlin to v2.0.21-1.0.27 by @renovate in https://github.com/element-hq/element-x-android/pull/3836 +* Update dependency org.maplibre.gl:android-sdk to v11.6.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3793 +* Update android.gradle.plugin to v8.7.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3785 +* Update lifecycle to v2.8.7 by @renovate in https://github.com/element-hq/element-x-android/pull/3763 +* Update plugin dependencycheck to v11 by @renovate in https://github.com/element-hq/element-x-android/pull/3723 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.61 by @renovate in https://github.com/element-hq/element-x-android/pull/3841 +* Update mobile-dev-inc/action-maestro-cloud action to v1.9.6 by @renovate in https://github.com/element-hq/element-x-android/pull/3846 +* Update dependency com.posthog:posthog-android to v3.9.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3856 +* Update core to v1.15.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3766 +* Update dependency com.android.tools:desugar_jdk_libs to v2.1.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3825 +* Update dependency io.nlopez.compose.rules:detekt to v0.4.18 by @renovate in https://github.com/element-hq/element-x-android/pull/3860 +* Update dependency com.posthog:posthog-android to v3.9.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3861 +* Update dependency io.sentry:sentry-android to v7.17.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3862 +* Update dependency androidx.compose:compose-bom to v2024.11.00 by @renovate in https://github.com/element-hq/element-x-android/pull/3869 +* Update telephoto to v0.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3870 +* Update SDK bindings version to `0.2.62` and fix `SendHandle` usages by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3876 +* Update codecov/codecov-action action to v5 by @renovate in https://github.com/element-hq/element-x-android/pull/3874 +* Update dependency com.google.firebase:firebase-bom to v33.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3880 +* Update kotlin to v2.0.21-1.0.28 by @renovate in https://github.com/element-hq/element-x-android/pull/3881 +* Update dependency org.robolectric:robolectric to v4.14 by @renovate in https://github.com/element-hq/element-x-android/pull/3882 +* Update appyx to v1.5.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3889 +* Update dependency io.nlopez.compose.rules:detekt to v0.4.19 by @renovate in https://github.com/element-hq/element-x-android/pull/3900 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.63 by @renovate in https://github.com/element-hq/element-x-android/pull/3898 +### Others +* Design system : implement new TextField by @ganfra in https://github.com/element-hq/element-x-android/pull/3834 +* Remove :samples:minimal module by @bmarty in https://github.com/element-hq/element-x-android/pull/3871 +* Replace `textPlaceholder` color usages with `textSecondary` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3873 +* Room Preview API changes by @ganfra in https://github.com/element-hq/element-x-android/pull/3875 + +Changes in Element X v0.7.3 (2024-11-08) +======================================== + +## What's Changed +### ✨ Features +* Incoming session verification by @bmarty in https://github.com/element-hq/element-x-android/pull/3733 +* Remove all GPS metadata from images uploaded as media by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3781 +* Send caption with image and video by @bmarty in https://github.com/element-hq/element-x-android/pull/3803 +### 🙌 Improvements +* UI iteration on the encryption settings by @bmarty in https://github.com/element-hq/element-x-android/pull/3750 +* Rotate firebase token in case of error by @bmarty in https://github.com/element-hq/element-x-android/pull/3755 +* Optimize media upload by @bmarty in https://github.com/element-hq/element-x-android/pull/3779 +* Iteration on caption by @bmarty in https://github.com/element-hq/element-x-android/pull/3816 +* Hide join call button when the user is already in the call by @bmarty in https://github.com/element-hq/element-x-android/pull/3815 +* Disable button during the "verifying" step. by @bmarty in https://github.com/element-hq/element-x-android/pull/3832 +### 🐛 Bugfixes +* Fix oversize padding on captioned images/videos by @frebib in https://github.com/element-hq/element-x-android/pull/3732 +* Fix the onboarding flow getting stuck in some cases by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3778 +* bugfix: do not remove logs after sending them by @ganfra in https://github.com/element-hq/element-x-android/pull/3780 +* Use in-memory thumbnail APIs when possible by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3817 +* ElementCall: allow user to switch to another call. by @bmarty in https://github.com/element-hq/element-x-android/pull/3833 +* Do not delete the original file if it's not a temporary file when sending it to a room. by @bmarty in https://github.com/element-hq/element-x-android/pull/3819 +* Fix verification failed issue, simplify verification logic by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3830 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3798 +### 🧱 Build +* Target api 35 by @bmarty in https://github.com/element-hq/element-x-android/pull/3776 +### 🚧 In development 🚧 +* Knocking : update create room flow by @ganfra in https://github.com/element-hq/element-x-android/pull/3804 +### Dependency upgrades +* Update dependency io.nlopez.compose.rules:detekt to v0.4.17 by @renovate in https://github.com/element-hq/element-x-android/pull/3746 +* Update dependency com.posthog:posthog-android to v3.8.3 - autoclosed by @renovate in https://github.com/element-hq/element-x-android/pull/3742 +* Update dependency org.maplibre.gl:android-plugin-annotation-v9 to v3.0.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3702 +* Update dependency com.posthog:posthog-android to v3.9.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3754 +* Update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/3283 +* Update camera to v1.4.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3765 +* Update dependencyAnalysis to v2.4.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3773 +* Update kotlin to v2.0.21-1.0.26 by @renovate in https://github.com/element-hq/element-x-android/pull/3774 +* Update dependency androidx.annotation:annotation-jvm to v1.9.1 - autoclosed by @renovate in https://github.com/element-hq/element-x-android/pull/3762 +* chore(deps): update dependencyanalysis to v2.4.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3791 +* fix(deps): update dependency androidx.compose:compose-bom to v2024.10.01 by @renovate in https://github.com/element-hq/element-x-android/pull/3782 +* Update dependency androidx.constraintlayout:constraintlayout-compose to v1.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3770 +* fix(deps): update dependency androidx.constraintlayout:constraintlayout to v2.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3784 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v0.2.59 by @renovate in https://github.com/element-hq/element-x-android/pull/3809 +* Update mobile-dev-inc/action-maestro-cloud action to v1.9.4 by @renovate in https://github.com/element-hq/element-x-android/pull/3820 +* Update dependency com.otaliastudios:transcoder to v0.11.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3805 +* Update plugin paparazzi to v1.3.5 by @renovate in https://github.com/element-hq/element-x-android/pull/3826 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.60 by @renovate in https://github.com/element-hq/element-x-android/pull/3827 +### Others +* Change wording to "Verify identity" by @bmarty in https://github.com/element-hq/element-x-android/pull/3751 +* Improve FakeMatrixRoom to be able to check all the parameters. by @bmarty in https://github.com/element-hq/element-x-android/pull/3761 +* Editor state fixture and preview improvement by @bmarty in https://github.com/element-hq/element-x-android/pull/3758 +* Enable identity pinning violation notifications unconditionally by @andybalaam in https://github.com/element-hq/element-x-android/pull/3745 +* Enable predictive back gesture by @frebib in https://github.com/element-hq/element-x-android/pull/3797 +* Update project status by @mxandreas in https://github.com/element-hq/element-x-android/pull/3806 +* Remove code duplication - no behavior change. by @bmarty in https://github.com/element-hq/element-x-android/pull/3823 +* Verification UI / UX iteration by @bmarty in https://github.com/element-hq/element-x-android/pull/3829 + +## New Contributors +* @andybalaam made their first contribution in https://github.com/element-hq/element-x-android/pull/3745 +* @mxandreas made their first contribution in https://github.com/element-hq/element-x-android/pull/3806 + +Changes in Element X v0.7.2 (2024-10-29) +======================================== + +## What's Changed +### 🙌 Improvements +* Add setting to compress image and video by @bmarty in https://github.com/element-hq/element-x-android/pull/3744 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3743 +### 🧱 Build +* Release script improvement by @bmarty in https://github.com/element-hq/element-x-android/pull/3741 +### Dependency upgrades +* Update dependency org.maplibre.gl:android-sdk to v11.5.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3720 +* Update dependency io.sentry:sentry-android to v7.16.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3726 +* Update dependencyAnalysis to v2.3.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3740 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.58 by @renovate in https://github.com/element-hq/element-x-android/pull/3749 + +Changes in Element X v0.7.1 (2024-10-25) +======================================== + +## What's Changed +### ✨ Features +* Verified user badge by @bmarty in https://github.com/element-hq/element-x-android/pull/3718 +### 🙌 Improvements +* Add userId in identity change warning banner by @bmarty in https://github.com/element-hq/element-x-android/pull/3686 +* OIDC prompt by @bmarty in https://github.com/element-hq/element-x-android/pull/3694 +* Bump rust-sdk version to rust-sdk 0.2.57 by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/3735 +### 🐛 Bugfixes +* Refresh room summaries when date or time changes in the device by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3683 +* Call: ensure that the microphone is working when the application is backgrounded. by @bmarty in https://github.com/element-hq/element-x-android/pull/3685 +* RTL: ensure sender information are correctly rendered in the timeline by @bmarty in https://github.com/element-hq/element-x-android/pull/3681 +* Improve composer paddings by @bmarty in https://github.com/element-hq/element-x-android/pull/3695 +* UI: fix list item colors by @bmarty in https://github.com/element-hq/element-x-android/pull/3706 +* Small UI iteration on pin feature. by @bmarty in https://github.com/element-hq/element-x-android/pull/3714 +* Use BigIcon and fix colors by @bmarty in https://github.com/element-hq/element-x-android/pull/3719 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3665 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3713 +### 🧱 Build +* Update Gradle Wrapper from 8.10 to 8.10.2 by @ElementBot in https://github.com/element-hq/element-x-android/pull/3663 +* fix: import path broken in module template by @torrybr in https://github.com/element-hq/element-x-android/pull/3710 +### 📄 Documentation +* Update store description by @bmarty in https://github.com/element-hq/element-x-android/pull/3680 +### 🚧 In development 🚧 +* Feature: knock request to join by @ganfra in https://github.com/element-hq/element-x-android/pull/3725 +### Dependency upgrades +* Update anvil to v0.3.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3662 +* Update dependency io.nlopez.compose.rules:detekt to v0.4.16 by @renovate in https://github.com/element-hq/element-x-android/pull/3675 +* Update dependency com.posthog:posthog-android to v3.8.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3674 +* Update dependency io.element.android:compound-android to v0.1.1 - Better support for RTL icons. by @renovate in https://github.com/element-hq/element-x-android/pull/3676 +* Update android.gradle.plugin to v8.7.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3677 +* Update dependency io.sentry:sentry-android to v7.15.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3640 +* Update mobile-dev-inc/action-maestro-cloud action to v1.9.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3641 +* Update plugin licensee to v1.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3687 +* Update dependency app.cash.turbine:turbine to v1.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3696 +* Update activity to v1.9.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3697 +* Update dependency androidx.compose:compose-bom to v2024.10.00 by @renovate in https://github.com/element-hq/element-x-android/pull/3699 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.55 by @renovate in https://github.com/element-hq/element-x-android/pull/3701 +* Update dependencyAnalysis to v2.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3707 +* Update anvil to v0.3.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3711 +* Update dependency androidx.annotation:annotation-jvm to v1.9.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3698 +* Update dependency com.google.firebase:firebase-bom to v33.5.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3716 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.56 by @renovate in https://github.com/element-hq/element-x-android/pull/3715 +* Update dependency com.squareup:kotlinpoet-ksp to v2 by @renovate in https://github.com/element-hq/element-x-android/pull/3722 +* Update dependency org.maplibre.gl:android-sdk-ktx-v7 to v3.0.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3703 +* Dependencies : makes sure to use same version for all kotlinpoet dependencies by @ganfra in https://github.com/element-hq/element-x-android/pull/3727 +* Update dependency com.google.firebase:firebase-bom to v33.5.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3731 +### Others +* No need to launch a coroutine here. by @bmarty in https://github.com/element-hq/element-x-android/pull/3668 +* Fix issue on canInvite refresh. by @bmarty in https://github.com/element-hq/element-x-android/pull/3670 +* AsyncAction confirming with param by @bmarty in https://github.com/element-hq/element-x-android/pull/3667 +* Cleanup tests by @bmarty in https://github.com/element-hq/element-x-android/pull/3672 +* Ensure selectedRoomMember is not null to reduce code indentation. by @bmarty in https://github.com/element-hq/element-x-android/pull/3669 +* Improve preview provider name consistency by @bmarty in https://github.com/element-hq/element-x-android/pull/3673 +* Clarify model for Event with attachment by @bmarty in https://github.com/element-hq/element-x-android/pull/3574 +* Improve room moderation by @bmarty in https://github.com/element-hq/element-x-android/pull/3671 +* Remove duplicated code regarding user (room member and user profile) screens by @bmarty in https://github.com/element-hq/element-x-android/pull/3700 +* Rename some function to avoid name clash by @bmarty in https://github.com/element-hq/element-x-android/pull/3705 +* Fix flaky tests. by @bmarty in https://github.com/element-hq/element-x-android/pull/3717 +* Update accent color for `Checkbox`, `RadioButton` and `Switch` components by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3728 + +Changes in Element X v0.7.0 (2024-10-10) +======================================== + +## What's Changed +### 🙌 Improvements +* Enable Login with QR code in release builds. by @bmarty in https://github.com/element-hq/element-x-android/pull/3646 +* Remove unused `RoomSummary` cache by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3647 +### 🐛 Bugfixes +* Add the `CallWebView` logs to our logging stack by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3637 +### Dependency upgrades +* Update dependency io.element.android:emojibase-bindings to v1.3.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3620 +* fix(deps): update dependency androidx.compose:compose-bom to v2024.09.03 by @renovate in https://github.com/element-hq/element-x-android/pull/3583 +* fix(deps): update dependency io.mockk:mockk to v1.13.13 by @renovate in https://github.com/element-hq/element-x-android/pull/3634 +* chore(deps): update dependencyanalysis to v2.1.4 by @renovate in https://github.com/element-hq/element-x-android/pull/3610 +* fix(deps): update dependency androidx.webkit:webkit to v1.12.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3584 +* fix(deps): update dependency com.posthog:posthog-android to v3.8.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3638 +* Upgrade Kotlin to v2.0 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3594 +### Others +* Rework room summary by @ganfra in https://github.com/element-hq/element-x-android/pull/3631 +* QrCode intro screen: add subtitle and fix button wording #3632 by @bmarty in https://github.com/element-hq/element-x-android/pull/3633 +* Improve avatar rendering by @ganfra in https://github.com/element-hq/element-x-android/pull/3642 +* Add feature flag IdentityPinningViolationNotifications. by @bmarty in https://github.com/element-hq/element-x-android/pull/3648 +* Crypto copy adjustment by @bmarty in https://github.com/element-hq/element-x-android/pull/3649 + + +Changes in Element X v0.6.5 (2024-10-09) +======================================== + +## What's Changed +### ✨ Features +* Add developer setting to hide images in the timeline by @bmarty in https://github.com/element-hq/element-x-android/pull/3592 +* Warn the user when unverified user has changed their identity by @bmarty in https://github.com/element-hq/element-x-android/pull/3621 +### 🙌 Improvements +* Handle no network error when starting Element Call. by @bmarty in https://github.com/element-hq/element-x-android/pull/3527 +### 🐛 Bugfixes +* Fix room settings not treating unencrypted DMs as DMs by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3545 +* Fix crash when aspectRatio is null. by @bmarty in https://github.com/element-hq/element-x-android/pull/3561 +* Don't delete uploaded logs by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3540 +* Don't display security banner for unknown RecoveryState by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3579 +* Fix the logic of the room list banner state by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3615 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3560 +* Sync Strings - import translations to Persian by @ElementBot in https://github.com/element-hq/element-x-android/pull/3612 +### 🧱 Build +* Introduce ModulesConfig by @bmarty in https://github.com/element-hq/element-x-android/pull/3530 +* Centralise the DI code generation logic by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3562 +* Update Gradle impl module template with `setupAnvil()` call by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3563 +* Use Anvil KSP instead of the Square KAPT one by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3564 +* Upgrade the used JDK in the project to v21 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3582 +* Merge unit, screenshot tests and coverage in a single CI call by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3593 +* Disable configuration cache in the CI by default by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3601 +* Fix screenshot recording in CI by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3607 +* Ensure the CI compile and execute all the unit tests. by @bmarty in https://github.com/element-hq/element-x-android/pull/3617 +### Dependency upgrades +* Update dependency androidx.compose:compose-bom to v2024.09.00 by @renovate in https://github.com/element-hq/element-x-android/pull/3399 +* Update dependency androidx.compose:compose-bom to v2024.09.02 by @renovate in https://github.com/element-hq/element-x-android/pull/3544 +* Update dependency io.element.android:compound-android to v0.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3524 +* Update dependency com.google.firebase:firebase-bom to v33.3.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3549 +* Update dependency org.maplibre.gl:android-sdk to v11.5.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3550 +* Update dependency org.maplibre.gl:android-plugin-annotation-v9 to v3.0.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3505 +* Update dependency androidx.webkit:webkit to v1.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3520 +* Update dependency com.posthog:posthog-android to v3.7.5 by @renovate in https://github.com/element-hq/element-x-android/pull/3546 +* Update gradle-update/update-gradle-wrapper-action action to v2 by @renovate in https://github.com/element-hq/element-x-android/pull/3551 +* Update dependency com.lemonappdev:konsist to v0.16.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3371 +* Update android.gradle.plugin to v8.6.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3504 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.49 by @renovate in https://github.com/element-hq/element-x-android/pull/3553 +* Update lifecycle to v2.8.6 by @renovate in https://github.com/element-hq/element-x-android/pull/3398 +* Update dependency com.google.accompanist:accompanist-permissions to v0.36.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3400 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.50 by @renovate in https://github.com/element-hq/element-x-android/pull/3565 +* Update dependency com.google.firebase:firebase-bom to v33.4.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3578 +* Update android.gradle.plugin to v8.7.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3577 +* Update dependency com.posthog:posthog-android to v3.8.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3591 +* dependency: Bump rust sdk to 0.2.51 by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/3602 +* chore(deps): update dependencyanalysis to v2.1.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3559 +* Update wysiwyg to v2.37.13 by @renovate in https://github.com/element-hq/element-x-android/pull/3596 +* fix(deps): update dependency io.nlopez.compose.rules:detekt to v0.4.15 by @renovate in https://github.com/element-hq/element-x-android/pull/3595 +* fix(deps): update dependency com.google.testparameterinjector:test-parameter-injector to v1.18 by @renovate in https://github.com/element-hq/element-x-android/pull/3606 +* fix(deps): update dependency com.squareup:kotlinpoet-ksp to v1.18.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3580 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.52 by @renovate in https://github.com/element-hq/element-x-android/pull/3619 +* SDK 0.2.53 19b9a73ecc3e31d502dbf0c5850bfdfaddf02afe by @bmarty in https://github.com/element-hq/element-x-android/pull/3622 +* Update dependency org.maplibre.gl:android-sdk to v11.5.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3608 +### Others +* rename invisible flag to onlySignedDeviceIsolation flag by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/3542 +* Fix image viewer glitch by @ganfra in https://github.com/element-hq/element-x-android/pull/3537 +* Prefix message sent by the current user by `You` instead of the sender name. by @bmarty in https://github.com/element-hq/element-x-android/pull/3547 +* timeline : remove animateItem by @ganfra in https://github.com/element-hq/element-x-android/pull/3548 +* Fix a couple of build-time warnings in Gradle output by @frebib in https://github.com/element-hq/element-x-android/pull/3349 +* Use MSC2530 filename when loading media by @frebib in https://github.com/element-hq/element-x-android/pull/3567 +* Prevent crash with duplicate room suggestion by @frebib in https://github.com/element-hq/element-x-android/pull/3576 +* Add unit tests on TimelineItemsSubscriber by @bmarty in https://github.com/element-hq/element-x-android/pull/3554 +* Fix tests on develop by @bmarty in https://github.com/element-hq/element-x-android/pull/3585 +* Timeline better jump to behaviours by @ganfra in https://github.com/element-hq/element-x-android/pull/3597 +* Fix building the app using a local SDK. by @bmarty in https://github.com/element-hq/element-x-android/pull/3604 +* crypto: Use OnlySigned isolation flag to setup decryption trust req. by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/3569 +* Fix black-on-black status bars with hidden media by @frebib in https://github.com/element-hq/element-x-android/pull/3611 +* Remove supportSlidingSync boolean. by @bmarty in https://github.com/element-hq/element-x-android/pull/3609 +* Ensure that `Presenter`s do not depend on other presenters. by @bmarty in https://github.com/element-hq/element-x-android/pull/3618 +* Do not render pin violation in clear rooms. by @bmarty in https://github.com/element-hq/element-x-android/pull/3630 + +Changes in Element X v0.6.4 (2024-09-25) +======================================== + +### 🙌 Improvements +* Pinned messages : add pin icon in timeline for pinned events. by @ganfra in https://github.com/element-hq/element-x-android/pull/3500 +* Include inviter in the notification for invitation by @bmarty in https://github.com/element-hq/element-x-android/pull/3503 + +### 🐛 Bugfixes +* Fix crash when session is deleted on another client by @bmarty in https://github.com/element-hq/element-x-android/pull/3515 +* Fix pinned events banner reappearing when loading by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3519 +* Fix various crashes by @bmarty in https://github.com/element-hq/element-x-android/pull/3533 +* Perform the migration, even if the current version is not known. by @bmarty in https://github.com/element-hq/element-x-android/pull/3535 +* timeline : makes sure to emit empty list if initial reset has no item. by @ganfra in https://github.com/element-hq/element-x-android/pull/3538 + +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3513 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3517 + +### Dependency upgrades +* Update dependency io.nlopez.compose.rules:detekt to v0.4.12 by @renovate in https://github.com/element-hq/element-x-android/pull/3436 +* Update dependency com.posthog:posthog-android to v3.7.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3443 +* Update dependency com.otaliastudios:transcoder to v0.11.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3440 +* Update dependency org.maplibre.gl:android-sdk to v11.4.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3408 +* Update dependencyAnalysis to v2.0.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3508 +* Update dependency org.maplibre.gl:android-sdk-ktx-v7 to v3.0.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3507 +* Update dependencyAnalysis to v2.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3526 +* Update dependency net.java.dev.jna:jna to v5.15.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3525 +* Update dependency androidx.startup:startup-runtime to v1.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3516 +* dependencies : update rust sdk to 0.2.48 by @ganfra in https://github.com/element-hq/element-x-android/pull/3532 + +### Others +* Change ElementBot mail to android@element.io by @bmarty in https://github.com/element-hq/element-x-android/pull/3497 +* Test RustMatrixClient and other classes in the matrix module by @bmarty in https://github.com/element-hq/element-x-android/pull/3501 +* Pinned messages analytics by @ganfra in https://github.com/element-hq/element-x-android/pull/3523 +* Remove ability to configure default log level by @bmarty in https://github.com/element-hq/element-x-android/pull/3531 + +Changes in Element X v0.6.3 (2024-09-19) +======================================== + +## What's Changed +### 🙌 Improvements +* Iterate send failure verification by @ganfra in https://github.com/element-hq/element-x-android/pull/3485 +### 🐛 Bugfixes +* Make sure the logout action doesn't cause a crash by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3480 +* Distinguish between roomId and roomAlias. by @bmarty in https://github.com/element-hq/element-x-android/pull/3486 +* Fix sliding sync proxy login not working after native SS failure by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3489 +### Dependency upgrades +* SDK 0.2.47 by @ganfra in https://github.com/element-hq/element-x-android/pull/3490 +### Others +* Add tests on AccountDeactivationView by @bmarty in https://github.com/element-hq/element-x-android/pull/3481 +* Cleanup and fixtures for SDK classes. by @bmarty in https://github.com/element-hq/element-x-android/pull/3488 +* Timeline related improvements by @ganfra in https://github.com/element-hq/element-x-android/pull/3487 +* Room list : debounce subscribe to visible rooms. by @ganfra in https://github.com/element-hq/element-x-android/pull/3491 +* Improve code coverage metrics by @bmarty in https://github.com/element-hq/element-x-android/pull/3450 + +### ✨ Features +* Account deactivation. by @bmarty in https://github.com/element-hq/element-x-android/pull/3479 + +Changes in Element X v0.6.1 (2024-09-17) +======================================== + +### ✨ Features +* Add forced logout flow when the proxy is no longer available by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3458 +* Temporary account creation using Element Web. by @bmarty in https://github.com/element-hq/element-x-android/pull/3467 + +### 🙌 Improvements +* Feature/valere/invisible crypto feature flag by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/3451 +* Require acknowledgement to send to a verified user if their identity changed or if a device is unverified. by @ganfra in https://github.com/element-hq/element-x-android/pull/3461 +* Update pinned message actions by @ganfra in https://github.com/element-hq/element-x-android/pull/3438 + +### 🐛 Bugfixes +* Fix events blinking at the beginning of DM by @bmarty in https://github.com/element-hq/element-x-android/pull/3449 +* Fix not being able to decline an invite from the room list by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3466 + +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3464 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3469 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3476 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3477 + +### Others +* Upgrade Rust sdk to 0.2.45 by @bmarty in https://github.com/element-hq/element-x-android/pull/3472 +* SDK 0.2.46 by @bmarty in https://github.com/element-hq/element-x-android/pull/3475 + +Changes in Element X v0.6.0 (2024-09-12) +======================================== + +### 🙌 Improvements +* Enables pinned messages feature by default. by @ganfra in https://github.com/element-hq/element-x-android/pull/3439 +* Pinned messages list : hide reactions by @ganfra in https://github.com/element-hq/element-x-android/pull/3430 + +### 🐛 Bugfixes +* Feature/fga/pinned messages fix timeline provider by @ganfra in https://github.com/element-hq/element-x-android/pull/3432 + +### Dependency upgrades +* Update activity to v1.9.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3397 +* Update peter-evans/create-pull-request action to v7 by @renovate in https://github.com/element-hq/element-x-android/pull/3383 +* Rust sdk upgrade to 0.2.43 by @bmarty in https://github.com/element-hq/element-x-android/pull/3446 + +### Others +* DeviceId and cleanup. by @bmarty in https://github.com/element-hq/element-x-android/pull/3442 +* Update application store assets by @bmarty in https://github.com/element-hq/element-x-android/pull/3441 + +Changes in Element X v0.5.3 (2024-09-10) +======================================== + +### ✨ Features +* Add banner for optional migration to simplified sliding sync by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3429 + +### 🙌 Improvements +* Timeline : remove the encrypted history banner by @ganfra in https://github.com/element-hq/element-x-android/pull/3410 + +### 🐛 Bugfixes +* Fix new logins with Simplified SS using the proxy by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3417 +* Ensure Call is not hang up when user is asked to grant system permissions by @bmarty in https://github.com/element-hq/element-x-android/pull/3419 +* Wait for a room with joined state in `/sync` after creating it by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3421 +* [Bugfix] : fix self verification flow by @ganfra in https://github.com/element-hq/element-x-android/pull/3426 + +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3425 + +### 🚧 In development 🚧 +* [Feature] Pinned messages list by @ganfra in https://github.com/element-hq/element-x-android/pull/3392 +* Pinned messages banner : adjust indicator to match design. by @ganfra in https://github.com/element-hq/element-x-android/pull/3415 + +### Dependency upgrades +* Update plugin dependencycheck to v10.0.4 by @renovate in https://github.com/element-hq/element-x-android/pull/3372 +* Update plugin detekt to v1.23.7 by @renovate in https://github.com/element-hq/element-x-android/pull/3424 + +### Others +* Delete old log files by @bmarty in https://github.com/element-hq/element-x-android/pull/3413 +* Recovery key formatting and wording iteration by @bmarty in https://github.com/element-hq/element-x-android/pull/3409 +* Change license to AGPL by @bmarty in https://github.com/element-hq/element-x-android/pull/3422 +* Remove Wait list screen by @bmarty in https://github.com/element-hq/element-x-android/pull/3428 + +Changes in Element X v0.5.2 (2024-09-05) +========================================= + +### 🙌 Improvements +* [Identity reset] Remove instruction to reset identity on another client. by @bmarty in https://github.com/element-hq/element-x-android/pull/3355 +* Redact message on displayed notification by @bmarty in https://github.com/element-hq/element-x-android/pull/3320 +* Add a way to sign out when the user is asked to verify the session. by @bmarty in https://github.com/element-hq/element-x-android/pull/3359 +* Add banner entry point to set up recovery by @bmarty in https://github.com/element-hq/element-x-android/pull/3360 +* Replace OSS licenses plugin with Licensee and some manually done UI. by @bmarty in https://github.com/element-hq/element-x-android/pull/3381 + +### 🐛 Bugfixes +* Small fixes around logging out. by @bmarty in https://github.com/element-hq/element-x-android/pull/3356 +* Ensure starting PinUnlockActivity does not crash the application. by @bmarty in https://github.com/element-hq/element-x-android/pull/3369 +* Use the right colors for `@room` mention pills by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3376 +* Fix avatar sometimes not loading by @bmarty in https://github.com/element-hq/element-x-android/pull/3366 +* Make pinned events required state in SlidingSync by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3385 +* Make sure to save the tokens the Client might return when its session is restored by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3378 +* Fix Element Call closing automatically on API 34 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3402 + +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3373 + +### 🧱 Build +* Try adding a memory limit for the kotlin compiler by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3377 + +### Dependency upgrades +* Update dependency com.google.testparameterinjector:test-parameter-injector to v1.17 by @renovate in https://github.com/element-hq/element-x-android/pull/3357 +* Update dependencyAnalysis to v2.0.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3362 +* Update android.gradle.plugin to v8.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3363 +* Update dependency io.nlopez.compose.rules:detekt to v0.4.11 by @renovate in https://github.com/element-hq/element-x-android/pull/3364 +* Update dependency com.posthog:posthog-android to v3.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3358 +* Update mobile-dev-inc/action-maestro-cloud action to v1.9.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3367 +* Update dependency com.posthog:posthog-android to v3.6.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3368 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.41 by @renovate in https://github.com/element-hq/element-x-android/pull/3384 +* Rust sdk : update to 0.2.42 by @ganfra in https://github.com/element-hq/element-x-android/pull/3393 +* Update dependency com.android.tools:desugar_jdk_libs to v2.1.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3350 +* Update dependency com.sigpwned:emoji4j-core to v15.1.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3396 + +### Others +* Release : use a different concurrency group for enterprise build by @ganfra in https://github.com/element-hq/element-x-android/pull/3351 +* Provide distinct cache directory to the Rust SDK. by @bmarty in https://github.com/element-hq/element-x-android/pull/3370 +* Remove the migration screen by @bmarty in https://github.com/element-hq/element-x-android/pull/3389 +* Unified push endpoint: do not fallback to default endpoint in case of failure and add troubleshoot test. by @bmarty in https://github.com/element-hq/element-x-android/pull/3388 +* Skip device verification screen when creating a new account using OIDC by @bmarty in https://github.com/element-hq/element-x-android/pull/3395 +* Big emoji-only messages by @frebib in https://github.com/element-hq/element-x-android/pull/3295 + +Changes in Element X v0.5.1 (2024-08-28) +========================================= + +### ✨ Features +* Add simplified sliding sync toggle to developer options by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3222 +* Feature: identity reset by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3298 +* Timeline UI | MessageShield Support by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/3240 +* Suggestion for room alias (disabled for now) by @bmarty in https://github.com/element-hq/element-x-android/pull/3322 +* Allow `PictureInPicture` mode for Element Call. by @bmarty in https://github.com/element-hq/element-x-android/pull/3345 + +### 🙌 Improvements +* Join Room : allow to join by alias (and getPreview) by @ganfra in https://github.com/element-hq/element-x-android/pull/3241 +* [Feature] Pinned message : render m.room.pinned events in timeline by @ganfra in https://github.com/element-hq/element-x-android/pull/3276 +* Enable sync on push feature flag to partially sync when notifications arrive by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3290 +* Improve the text for mentions and replies in notifications by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3328 +* Use new functions exposed by Element Call about PiP by @bmarty in https://github.com/element-hq/element-x-android/pull/3334 + +### 🐛 Bugfixes +* Ensure sessionPath is not reused for different homeserver. Fixes not loading media issue. by @bmarty in https://github.com/element-hq/element-x-android/pull/3299 +* Fix reset identity with password stuck in loading state. by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3317 + +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3252 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3267 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3297 +* Sync Strings - New language: Dutch. by @ElementBot in https://github.com/element-hq/element-x-android/pull/3308 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3339 + +### 🧱 Build +* Update sonarcloud project key by @guillaumevillemont in https://github.com/element-hq/element-x-android/pull/3264 +* Fix `build_rust_sdk.sh` script to work on linux by @erikjohnston in https://github.com/element-hq/element-x-android/pull/3291 +* Fix proguard config for nightly and release builds by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3294 +* Gradle update action: Use JDK 17 and skip early in forks. by @bmarty in https://github.com/element-hq/element-x-android/pull/3311 +* Gradle update action: add label and use other token. by @bmarty in https://github.com/element-hq/element-x-android/pull/3313 +* Update Gradle Wrapper from 8.9 to 8.10 by @ElementBot in https://github.com/element-hq/element-x-android/pull/3314 + +### 🚧 In development 🚧 +* WIP Pinned events : add feature flag and pin/unpin actions by @ganfra in https://github.com/element-hq/element-x-android/pull/3255 +* WIP Pinned events : start creating the banner ui, no logic. by @ganfra in https://github.com/element-hq/element-x-android/pull/3259 +* WIP Pinned events : banner logic by @ganfra in https://github.com/element-hq/element-x-android/pull/3275 + +### Dependency upgrades +* Update dependency org.maplibre.gl:android-sdk to v11.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3244 +* Update activity to v1.9.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3242 +* Update media3 to v1.4.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3247 +* Update dependency androidx.annotation:annotation-jvm to v1.8.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3243 +* Update dependencyAnalysis to v1.33.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3250 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.35 by @renovate in https://github.com/element-hq/element-x-android/pull/3249 +* Update dependency io.sentry:sentry-android to v7.12.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3246 +* Update dependency io.nlopez.compose.rules:detekt to v0.4.8 by @renovate in https://github.com/element-hq/element-x-android/pull/3254 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.36 by @renovate in https://github.com/element-hq/element-x-android/pull/3269 +* Update wysiwyg to v2.37.8 by @renovate in https://github.com/element-hq/element-x-android/pull/3263 +* Update dependency io.sentry:sentry-android to v7.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3258 +* Update dependency io.nlopez.compose.rules:detekt to v0.4.9 by @renovate in https://github.com/element-hq/element-x-android/pull/3277 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.38 by @renovate in https://github.com/element-hq/element-x-android/pull/3280 +* Update dependency androidx.annotation:annotation-jvm to v1.8.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3282 +* Update kotlin by @renovate in https://github.com/element-hq/element-x-android/pull/2990 +* Update dependency io.nlopez.compose.rules:detekt to v0.4.10 by @renovate in https://github.com/element-hq/element-x-android/pull/3281 +* Update dependency com.posthog:posthog-android to v3.5.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3287 +* Update wysiwyg to v2.37.8 by @renovate in https://github.com/element-hq/element-x-android/pull/3284 +* Update the SDK bindings to `v0.2.39` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3288 +* Update gradle/actions action to v4 by @renovate in https://github.com/element-hq/element-x-android/pull/3265 +* Update android.gradle.plugin to v8.5.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3004 +* Update dependency io.sentry:sentry-android to v7.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3285 +* Update dependency io.sentry:sentry-android to v7.14.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3302 +* Update dependency androidx.test:runner to v1.6.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3304 +* Update dependency com.otaliastudios:transcoder to v0.11.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3306 +* Update lifecycle to v2.8.0 by @renovate in https://github.com/element-hq/element-x-android/pull/2848 +* Update lifecycle to v2.8.4 by @renovate in https://github.com/element-hq/element-x-android/pull/3315 +* Update dagger to v2.52 by @renovate in https://github.com/element-hq/element-x-android/pull/3270 +* Update telephoto to v0.13.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3325 +* Update dependency androidx.compose:compose-bom to v2024.08.00 by @renovate in https://github.com/element-hq/element-x-android/pull/3323 +* Update dependency com.google.firebase:firebase-bom to v33.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3331 +* Update dependency com.posthog:posthog-android to v3.5.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3340 +* Update dependency com.android.tools:desugar_jdk_libs to v2.1.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3341 +* Update dependencyAnalysis to v2 (major) by @renovate in https://github.com/element-hq/element-x-android/pull/3346 +* Update dependency org.maplibre.gl:android-sdk to v11.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3347 +* Update media3 to v1.4.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3344 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.40 by @renovate in https://github.com/element-hq/element-x-android/pull/3343 + +### Others +* Feature/fga/push subscribe to room by @ganfra in https://github.com/element-hq/element-x-android/pull/3257 +* Feature/fga/start sync on push by @ganfra in https://github.com/element-hq/element-x-android/pull/3260 +* Cleanup and add unit test for DefaultPinnedMessagesBannerFormatter by @bmarty in https://github.com/element-hq/element-x-android/pull/3307 +* Add test on function name which may start or end with spaces by @bmarty in https://github.com/element-hq/element-x-android/pull/3318 +* Fix broken direct room member for rooms with old users that left by @networkException in https://github.com/element-hq/element-x-android/pull/3324 +* Add unit test on MatrixRoom extension by @bmarty in https://github.com/element-hq/element-x-android/pull/3327 +* Fix login navigation getting stuck when the app was compiled with no-op analytics provider by @SpiritCroc in https://github.com/element-hq/element-x-android/pull/3337 + +Changes in Element X v0.5.0 (2024-07-24) +========================================= + +### 🙌 Improvements +* Add icon for "Mark as read" and "Mark as unread" actions. by @bmarty in https://github.com/element-hq/element-x-android/pull/3144 +* Add support for Picture In Picture for Element Call by @bmarty in https://github.com/element-hq/element-x-android/pull/3159 +* Set pin grace period to 2 minutes by @bmarty in https://github.com/element-hq/element-x-android/pull/3172 +* Unify the way we decide whether a room is a DM or a group room by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3100 +* Subscribe to `RoomListItems` in the visible range by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3169 +* Improve pip and add feature flag. by @bmarty in https://github.com/element-hq/element-x-android/pull/3199 +* Open Source licenses: add color for links. by @bmarty in https://github.com/element-hq/element-x-android/pull/3215 +* Cancel ringing call notification on call cancellation by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3047 + +### 🐛 Bugfixes +* Fix `MainActionButton` layout for long texts by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3158 +* Always follow the desired theme for Pin, Incoming Call and Element Call screens by @bmarty in https://github.com/element-hq/element-x-android/pull/3165 +* Fix empty screen issue after clearing the cache by @bmarty in https://github.com/element-hq/element-x-android/pull/3163 +* Restore intentional mentions in the markdown/plain text editor by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3193 +* Fix crash in the room list after a forced log out in background by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3180 +* Clear existing notification when a room is marked as read by @bmarty in https://github.com/element-hq/element-x-android/pull/3203 +* Fix crash when Pin code screen is displayed by @bmarty in https://github.com/element-hq/element-x-android/pull/3205 +* Fix pillification not working for non formatted message bodies by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3201 +* Update grammar on Matrix Ids to be more spec compliant and render error instead of infinite loading in room member list screen by @bmarty in https://github.com/element-hq/element-x-android/pull/3206 +* Reduce the risk of text truncation in buttons. by @bmarty in https://github.com/element-hq/element-x-android/pull/3209 +* Ensure that the manual dark theme is rendering correctly regarding -night resource and keyboard by @bmarty in https://github.com/element-hq/element-x-android/pull/3216 +* Fix rendering issue of SunsetPage in dark mode by @bmarty in https://github.com/element-hq/element-x-android/pull/3217 +* Fix linkification not working for `Spanned` strings in text messages by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3233 +* Edit : fallback to room.edit when timeline item is not found. by @ganfra in https://github.com/element-hq/element-x-android/pull/3239 + +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3156 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3192 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3232 + +### 🧱 Build +* Remove Showkase processor not found warning from Danger by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3148 +* Set targetSDK to 34 by @bmarty in https://github.com/element-hq/element-x-android/pull/3149 +* Add a local copy of `inplace-fix.py` and `fix-pg-map-id.py` by @bmarty in https://github.com/element-hq/element-x-android/pull/3167 +* Only add private SSH keys and clone submodules in the original repo by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3225 +* Fix CI for forks by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3226 + +### Dependency upgrades +* Update dependency io.element.android:compound-android to v0.0.7 by @renovate in https://github.com/element-hq/element-x-android/pull/3143 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.31 by @renovate in https://github.com/element-hq/element-x-android/pull/3145 +* Update dependency com.squareup:kotlinpoet to v1.18.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3150 +* Update dependency org.robolectric:robolectric to v4.13 by @renovate in https://github.com/element-hq/element-x-android/pull/3157 +* Update plugin dependencycheck to v10.0.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3154 +* Update wysiwyg to v2.37.5 by @renovate in https://github.com/element-hq/element-x-android/pull/3162 +* Update plugin sonarqube to v5.1.0.4882 by @renovate in https://github.com/element-hq/element-x-android/pull/3139 +* Update dependency org.jsoup:jsoup to v1.18.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3171 +* Update dependency com.google.firebase:firebase-bom to v33.1.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3178 +* Update telephoto to v0.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3191 +* Update dependency com.google.truth:truth to v1.4.4 by @renovate in https://github.com/element-hq/element-x-android/pull/3187 +* Update dependency com.squareup:kotlinpoet to v1.18.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3194 +* Update dependency io.mockk:mockk to v1.13.12 by @renovate in https://github.com/element-hq/element-x-android/pull/3198 +* Update dependency io.sentry:sentry-android to v7.12.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3200 +* Update plugin dependencycheck to v10.0.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3204 +* Update dependency gradle to v8.9 by @renovate in https://github.com/element-hq/element-x-android/pull/3177 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.32 by @renovate in https://github.com/element-hq/element-x-android/pull/3202 +* Update coil to v2.7.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3210 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.33 by @renovate in https://github.com/element-hq/element-x-android/pull/3220 +* Update wysiwyg to v2.37.7 by @renovate in https://github.com/element-hq/element-x-android/pull/3218 +* Update telephoto to v0.12.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3230 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.34 by @renovate in https://github.com/element-hq/element-x-android/pull/3237 + +### Others +* Reduce delay when selecting room list filters by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3160 +* Add `--alignment-preserved true` when signing APK for F-Droid. by @bmarty in https://github.com/element-hq/element-x-android/pull/3161 +* Ensure that all callback plugins are invoked. by @bmarty in https://github.com/element-hq/element-x-android/pull/3146 +* Add generated screen to show open source licenses in Gplay variant by @bmarty in https://github.com/element-hq/element-x-android/pull/3207 +* Performance : improve time to open a room. by @ganfra in https://github.com/element-hq/element-x-android/pull/3186 +* Add logging to help debug forced logout issues by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3208 +* Use the right filename for log files so they're sorted in rageshakes by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3219 +* Compose : add immutability to some Reaction classes by @ganfra in https://github.com/element-hq/element-x-android/pull/3224 +* Fix stickers display text on room summary by @surakin in https://github.com/element-hq/element-x-android/pull/3221 +* Rework FakeMatrixRoom so that it contains only lambdas. by @bmarty in https://github.com/element-hq/element-x-android/pull/3229 + +Changes in Element X v0.4.16 (2024-07-05) +========================================= + +### ✨ Features +* Avatar cluster for DM by @bmarty in https://github.com/element-hq/element-x-android/pull/3069 +* Feature : Draft support by @ganfra in https://github.com/element-hq/element-x-android/pull/3099 +* Timeline : re-enable edition of local echo by @ganfra in https://github.com/element-hq/element-x-android/pull/3126 +* Draft : add volatile storage when moving to edit mode. by @ganfra in https://github.com/element-hq/element-x-android/pull/3132 + +### 🙌 Improvements +* Give locale and theme to Element Call by @bmarty in https://github.com/element-hq/element-x-android/pull/3118 +* Let the SDK retrieve and parse Element well known content by @bmarty in https://github.com/element-hq/element-x-android/pull/3127 + +### 🐛 Bugfixes +* Let role and permissions screens works for invited room members too. by @bmarty in https://github.com/element-hq/element-x-android/pull/3081 +* Fix image rendering after clear cache by @bmarty in https://github.com/element-hq/element-x-android/pull/3082 +* Replace the 'answer' PendingIntent in ringing call notifications by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3093 +* Use IO dispatcher for cleanup in bug reporter by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3092 +* Fix `@room` mentions crashing in debug builds by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3107 +* Auth : fix restore session when there is no network. by @ganfra in https://github.com/element-hq/element-x-android/pull/3109 +* Alert for incoming call even if notifications are disabled - WAITING FOR FINAL PRODUCT DECISION by @bmarty in https://github.com/element-hq/element-x-android/pull/3053 +* Fix incorrect 'device verified' screen when app was opened with no network connection by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3110 +* Draft : also clear draft when composer is blank by @ganfra in https://github.com/element-hq/element-x-android/pull/3115 +* Timeline : fix text item not refreshed when content change by @ganfra in https://github.com/element-hq/element-x-android/pull/3123 +* FFs can now be toggled in release builds too by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3101 +* Fix crash when getting the system ringtone for ringing calls by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3131 +* Bugfix : avoid potential NPE on verification service. by @ganfra in https://github.com/element-hq/element-x-android/pull/3140 + +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3114 +* Sync Strings - Add Greek translations by @ElementBot in https://github.com/element-hq/element-x-android/pull/3133 + +### 🧱 Build +* Let GitHub generates the release notes by @bmarty in https://github.com/element-hq/element-x-android/pull/3105 +* Fix F-Droid reproducible build. by @bmarty in https://github.com/element-hq/element-x-android/pull/3106 +* Element enterprise (EE) foundations by @bmarty in https://github.com/element-hq/element-x-android/pull/3025 +* Fix Element Enterprise nightly build and publication using App Distribution by @bmarty in https://github.com/element-hq/element-x-android/pull/3130 +* Improve screenshot testing with ComposablePreviewScanner by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3125 + +### Dependency upgrades +* Update dependency com.posthog:posthog-android to v3.4.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3060 +* Update danger/danger-js action to v12.3.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3059 +* Update dependency com.freeletics.flowredux:compose to v1.2.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3067 +* Update dependency com.google.firebase:firebase-bom to v33.1.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3062 +* Update dependency androidx.test.ext:junit to v1.2.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3088 +* Update test.core to v1.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3090 +* Remove dependencies androidx.test.espresso:espresso-core and androidx.appcompat:appcompat by @renovate in https://github.com/element-hq/element-x-android/pull/3087 +* Update wysiwyg to v2.37.4 by @renovate in https://github.com/element-hq/element-x-android/pull/3094 +* Update dependency androidx.test:runner to v1.6.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3089 +* Update test.core to v1.6.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3104 +* Update dependency androidx.test:runner to v1.6.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3103 +* Update dependency androidx.test.ext:junit to v1.2.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3102 +* Update dependency com.google.truth:truth to v1.4.3 by @renovate in https://github.com/element-hq/element-x-android/pull/3108 +* Update dependency com.posthog:posthog-android to v3.4.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3111 +* Update dependency io.nlopez.compose.rules:detekt to v0.4.5 by @renovate in https://github.com/element-hq/element-x-android/pull/3116 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.29 by @renovate in https://github.com/element-hq/element-x-android/pull/3119 +* Update plugin dependencycheck to v10 by @renovate in https://github.com/element-hq/element-x-android/pull/3128 +* Update plugin dependencycheck to v10.0.1 by @renovate in https://github.com/element-hq/element-x-android/pull/3129 +* Update dependency io.sentry:sentry-android to v7.11.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3122 +* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.30 by @renovate in https://github.com/element-hq/element-x-android/pull/3138 + +### Others +* Feature/fga/sending queue iteration by @ganfra in https://github.com/element-hq/element-x-android/pull/3054 +* Use full date format for day dividers in timeline by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3057 +* Let Dms use other member color. by @bmarty in https://github.com/element-hq/element-x-android/pull/3058 +* Resolve display names in mentions in real time by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3051 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3077 +* Improve the way we cut the bubble layout to give space for the sender Avatar by @bmarty in https://github.com/element-hq/element-x-android/pull/3080 +* Upgrade build tools and fix `pg-map-id` for F-Droid by @bmarty in https://github.com/element-hq/element-x-android/pull/3084 +* Improve room filtering behavior. by @bmarty in https://github.com/element-hq/element-x-android/pull/3083 +* Adapt our code to the new authentication APIs in the Rust SDK by @jmartinesp in https://github.com/element-hq/element-x-android/pull/3068 +* Add temporary icon for Element Enterprise by @bmarty in https://github.com/element-hq/element-x-android/pull/3134 +* Improve click behavior on room timeline title by @bmarty in https://github.com/element-hq/element-x-android/pull/3064 + +Changes in Element X v0.4.15 (2024-06-19) +========================================= + +Features ✨ +---------- + - Ringing call notifications and full screen ringing screen for DMs when the device is locked. ([#2894](https://github.com/element-hq/element-x-android/issues/2894)) + +Bugfixes 🐛 +---------- + - Improve UX on notification setting changes. ([#1647](https://github.com/element-hq/element-x-android/issues/1647)) + - Fix tracing configuration in debug and nightlies: + - Debug will now write the logs to disk too. + - Nightly will be able to customise tracing filters. + - Improved the configure tracing and bug report screens. ([#3016](https://github.com/element-hq/element-x-android/issues/3016)) + +Other changes +------------- + - Allow cancelling jump to event in timeline. ([#2876](https://github.com/element-hq/element-x-android/issues/2876)) + - Make Element Call widget URL configurable ([#3009](https://github.com/element-hq/element-x-android/issues/3009)) + - Enable hidden access to developer options in release mode apps. ([#3020](https://github.com/element-hq/element-x-android/issues/3020)) + - Improve how active calls work by also taking into account external url calls and waiting for the sync process to start before sending the `m.call.notify` event. ([#3029](https://github.com/element-hq/element-x-android/issues/3029)) + + +Changes in Element X v0.4.14 (2024-06-07) +========================================= + +Features ✨ +---------- + - Add support for incoming share (text or files) from other apps ([#1980](https://github.com/element-hq/element-x-android/issues/1980)) + +Bugfixes 🐛 +---------- + - Render selected/deselected room list filters on top ([#2809](https://github.com/element-hq/element-x-android/issues/2809)) + - Set auto captilization, multiline and autocompletion flags for the markdown EditText. ([#2896](https://github.com/element-hq/element-x-android/issues/2896)) + - Restore Markdown text input contents when returning to the room screen. ([#2898](https://github.com/element-hq/element-x-android/issues/2898)) + - Fixed sending rich content from android keyboards on the markdown text input ([#2917](https://github.com/element-hq/element-x-android/issues/2917)) + - Fix crash when restoring the selection values in the plain text editor. ([#2959](https://github.com/element-hq/element-x-android/issues/2959)) + +Other changes +------------- + - BugReporting | Add public device keys to rageshakes ([#2893](https://github.com/element-hq/element-x-android/issues/2893)) + - Move push provider setting to the "Notifications" screen and display it only when several push provider are available. ([#2912](https://github.com/element-hq/element-x-android/issues/2912)) + - Simplify notifications by removing the custom persistence layer. + - Bump minSdk to 24 (Android 7). ([#2924](https://github.com/element-hq/element-x-android/issues/2924)) + - Add a feature flag ShowBlockedUsersDetails, disabled by default to render display name and avatar of blocked users in the blocked users list. ([#2930](https://github.com/element-hq/element-x-android/issues/2930)) + - Be more specific with the widget permissions ([#2932](https://github.com/element-hq/element-x-android/issues/2932)) + - Analytics | Add support for SuperProperties ([#2953](https://github.com/element-hq/element-x-android/issues/2953)) + - Track when the user starts a room call and when they enable formatting options on the message composer ([#2969](https://github.com/element-hq/element-x-android/issues/2969)) + + +Changes in Element X v0.4.13 (2024-05-22) +========================================= + +Features ✨ +---------- + - Add plain text editor based on Markdown input. ([#2840](https://github.com/element-hq/element-x-android/issues/2840)) + +Bugfixes 🐛 +---------- + - Use members display names for their membership state events. ([#2286](https://github.com/element-hq/element-x-android/issues/2286)) + - Make sure explicit links in messages take priority over links found by linkification (urls, emails, phone numbers, etc.) ([#2291](https://github.com/element-hq/element-x-android/issues/2291)) + - Fix modal contents overlapping screen lock pin. ([#2692](https://github.com/element-hq/element-x-android/issues/2692)) + - Fix a crash when trying to create an `EncryptedFile` in Android 6. ([#2846](https://github.com/element-hq/element-x-android/issues/2846)) + - Session falsely displayed as 'verified' with no internet connection. ([#2884](https://github.com/element-hq/element-x-android/issues/2884)) + +Other changes +------------- + - Allow configuring push notification provider ([#2340](https://github.com/element-hq/element-x-android/issues/2340)) + - UX cleanup: reorder text composer actions to prioritise camera ones. ([#2803](https://github.com/element-hq/element-x-android/issues/2803)) + - Translation added into Portuguese and Simplified Chinese ([#2834](https://github.com/element-hq/element-x-android/issues/2834)) + - Use via parameters when joining a room from permalink. ([#2843](https://github.com/element-hq/element-x-android/issues/2843)) + + +Changes in Element X v0.4.12 (2024-05-13) +========================================= + +Features ✨ +---------- +- Add support for expected decryption errors due to membership (UX and analytics). ([#2754](https://github.com/element-hq/element-x-android/issues/2754)) +- Handle permalink navigation to Events. ([#2759](https://github.com/element-hq/element-x-android/issues/2759)) +- Pretty-print event JSON in debug viewer ([#2771](https://github.com/element-hq/element-x-android/issues/2771)) +- Add support for external permalinks. ([#2776](https://github.com/element-hq/element-x-android/issues/2776)) +- Enable support for Android per-app language preferences ([#2795](https://github.com/element-hq/element-x-android/issues/2795)) + +Bugfixes 🐛 +---------- +- Fix session verification being asked again for already verified users. ([#2718](https://github.com/element-hq/element-x-android/issues/2718)) +- Instead of displaying 'create new recovery key' on the session verification screen when there is no other session active, display it always under the 'enter recovery key' screen. ([#2740](https://github.com/element-hq/element-x-android/issues/2740)) +- Adjust the typography used in the selected user component so a user's display name fits better. ([#2760](https://github.com/element-hq/element-x-android/issues/2760)) +- User display name overflows in timeline messages when it's way too long. ([#2761](https://github.com/element-hq/element-x-android/issues/2761)) +- Ensure the application open the room when a notification is clicked. ([#2778](https://github.com/element-hq/element-x-android/issues/2778)) +- Enforce mandatory session verification only for new logins. ([#2810](https://github.com/element-hq/element-x-android/issues/2810)) +- Make log less verbose, make sure we upload as many log files as possible before reaching the request size limit of the bug reporting service, discard older logs if they don't fit. ([#2825](https://github.com/element-hq/element-x-android/issues/2825)) +- Remove 'Join' button in room directory search results. ([#2827](https://github.com/element-hq/element-x-android/issues/2827)) +- Add missing `app_id` and `Version` properties to bug reports. ([#2829](https://github.com/element-hq/element-x-android/issues/2829)) + +Other changes +------------- +- RoomMember screen: fallback to userProfile data, if the member is not a user of the room. ([#2721](https://github.com/element-hq/element-x-android/issues/2721)) +- Migrate application data. ([#2749](https://github.com/element-hq/element-x-android/issues/2749)) +- Let the SDK manage the file log cleanup, and keep one week of log. ([#2758](https://github.com/element-hq/element-x-android/issues/2758)) +- UX cleanup: reorder options in the main settings screen. ([#2801](https://github.com/element-hq/element-x-android/issues/2801)) +- Analytics: Add support to report current session verification and recovery state ([#2806](https://github.com/element-hq/element-x-android/issues/2806)) +- UX cleanup: room details screen, add new CTA buttons for Invite and Call actions. ([#2814](https://github.com/element-hq/element-x-android/issues/2814)) +- UX cleanup: user profile. Move send DM to a call to action button, add 'Call' CTA too. ([#2818](https://github.com/element-hq/element-x-android/issues/2818)) +- Add room badges to room details screen. ([#2822](https://github.com/element-hq/element-x-android/issues/2822)) + +Security +------------- +- Bump the Rust SDK to `v0.2.18` to remediate [CVE-2024-34353 / GHSA-9ggc-845v-gcgv](https://github.com/matrix-org/matrix-rust-sdk/security/advisories/GHSA-9ggc-845v-gcgv). + +Changes in Element X v0.4.10 (2024-04-17) +========================================= + +Matrix Rust SDK 0.2.14 + +Features ✨ +---------- +- Rework room navigation to handle unknown room and prepare work on permalink. ([#2695](https://github.com/element-hq/element-x-android/issues/2695)) + +Other changes +------------- +- Encrypt new session data with a passphrase ([#2703](https://github.com/element-hq/element-x-android/issues/2703)) +- Use sdk API to build permalinks ([#2708](https://github.com/element-hq/element-x-android/issues/2708)) +- Parse permalink using parseMatrixEntityFrom from the SDK ([#2709](https://github.com/element-hq/element-x-android/issues/2709)) +- Fix compile for forks that use the `noop` analytics module ([#2698](https://github.com/element-hq/element-x-android/issues/2698)) + + +Changes in Element X v0.4.9 (2024-04-12) +======================================== + +- Synchronize Localazy Strings. + +Security +---------- +- Fix crash while processing a room message containing a malformed pill. + +Changes in Element X v0.4.8 (2024-04-10) +======================================== + +Features ✨ +---------- +- Move session recovery to the login flow. ([#2579](https://github.com/element-hq/element-x-android/issues/2579)) +- Move session verification to the after login flow and make it mandatory. ([#2580](https://github.com/element-hq/element-x-android/issues/2580)) +- Add a notification troubleshoot screen ([#2601](https://github.com/element-hq/element-x-android/issues/2601)) +- Add action to copy permalink ([#2650](https://github.com/element-hq/element-x-android/issues/2650)) + +Bugfixes 🐛 +---------- +- Fix analytics issue around room considered as space by mistake. ([#2612](https://github.com/element-hq/element-x-android/issues/2612)) +- Fix crash observed when going back to the room list. ([#2619](https://github.com/element-hq/element-x-android/issues/2619)) +- Hide Event org.matrix.msc3401.call.member on the timeline. ([#2625](https://github.com/element-hq/element-x-android/issues/2625)) +- Fall back to name-based generated avatars when image avatars don't load. ([#2667](https://github.com/element-hq/element-x-android/issues/2667)) + +Other changes +------------- +- Improve UI for notification permission screen in onboarding. ([#2581](https://github.com/element-hq/element-x-android/issues/2581)) +- Categorise members by role in change roles screen. ([#2593](https://github.com/element-hq/element-x-android/issues/2593)) +- Make completed poll more clearly visible ([#2608](https://github.com/element-hq/element-x-android/issues/2608)) +- Show users from last visited DM as suggestion when starting a Chat or when creating a Room. ([#2634](https://github.com/element-hq/element-x-android/issues/2634)) +- Enable room moderation feature. ([#2678](https://github.com/element-hq/element-x-android/issues/2678)) +- Improve analytics opt-in screen UI. ([#2684](https://github.com/element-hq/element-x-android/issues/2684)) + + +Changes in Element X v0.4.7 (2024-03-26) +======================================== + +Features ✨ +---------- +- Enable the feature "RoomList filters". ([#2603](https://github.com/element-hq/element-x-android/issues/2603)) +- Enable the feature "Mark as unread" ([#2261](https://github.com/element-hq/element-x-android/issues/2261)) +- Implement MSC2530 (Body field as media caption) ([#2521](https://github.com/element-hq/element-x-android/issues/2521)) + +Bugfixes 🐛 +---------- +- Use user avatar from cache if available. ([#2488](https://github.com/element-hq/element-x-android/issues/2488)) +- Update member list after changing member roles and when the room member list is opened. ([#2590](https://github.com/element-hq/element-x-android/issues/2590)) + +Other changes +------------- +- Compound: add `BigIcon`, `BigCheckmark` and `PageTitle` components. ([#2574](https://github.com/element-hq/element-x-android/issues/2574)) +- Remove Welcome screen from the FTUE. ([#2584](https://github.com/element-hq/element-x-android/issues/2584)) + + +Changes in Element X v0.4.6 (2024-03-15) +======================================== + +Features ✨ +---------- +- Admins can now change user roles in rooms. ([#2257](https://github.com/element-hq/element-x-android/issues/2257)) +- Room member moderation: remove, ban and unban users from a room. ([#2258](https://github.com/element-hq/element-x-android/issues/2258)) +- Change a room's permissions power levels. ([#2259](https://github.com/element-hq/element-x-android/issues/2259)) +- Add state timeline events and notifications for legacy call invites. ([#2485](https://github.com/element-hq/element-x-android/issues/2485)) + +Bugfixes 🐛 +---------- +- Added empty state to banned member list. ([#+add-empty-state-to-banned-members-list](https://github.com/element-hq/element-x-android/issues/+add-empty-state-to-banned-members-list)) +- Prevent sending empty messages. ([#995](https://github.com/element-hq/element-x-android/issues/995)) +- Use the display name only once in display name change events. The user should be referenced by `userId` instead. ([#2125](https://github.com/element-hq/element-x-android/issues/2125)) +- Hide blocked users list when there are no blocked users. ([#2198](https://github.com/element-hq/element-x-android/issues/2198)) +- Fix timeline not showing sender info when room is marked as direct but not a 1:1 room. ([#2530](https://github.com/element-hq/element-x-android/issues/2530)) + +Other changes +------------- +- Add `local_time`, `utc_time` and `sdk_sha` params to bug reports so they're easier to investigate. ([#+add-time-and-sdk-sha-params-to-bugreports](https://github.com/element-hq/element-x-android/issues/+add-time-and-sdk-sha-params-to-bugreports)) +- Improve room member list loading times, increase chunk size ([#2322](https://github.com/element-hq/element-x-android/issues/2322)) +- Improve room member list loading UX. ([#2452](https://github.com/element-hq/element-x-android/issues/2452)) +- Remove the special log level for the Rust SDK read receipts. ([#2511](https://github.com/element-hq/element-x-android/issues/2511)) +- Track UTD errors. ([#2544](https://github.com/element-hq/element-x-android/issues/2544)) + + +Changes in Element X v0.4.5 (2024-02-28) +======================================== + +Features ✨ +---------- +- Mark a room or dm as favourite. ([#2208](https://github.com/element-hq/element-x-android/issues/2208)) +- Add moderation to rooms: + - Sort member in room member list by powerlevel, display their roles. + - Display banner users in room member list for users with enough power level to ban/unban. ([#2256](https://github.com/element-hq/element-x-android/issues/2256)) +- MediaViewer : introduce fullscreen and flick to dismiss behavior. ([#2390](https://github.com/element-hq/element-x-android/issues/2390)) +- Allow user-installed certificates to be used by the HTTP client ([#2992](https://github.com/element-hq/element-x-android/issues/2992)) + +Bugfixes 🐛 +---------- +- Do not display empty room list state before the loading one when we still don't have any items ([#+do-not-display-empty-state-before-loading-roomlist](https://github.com/element-hq/element-x-android/issues/+do-not-display-empty-state-before-loading-roomlist)) +- Improve how Talkback works with the timeline. Sadly, it's still not 100% working, but there is some issue with the `LazyColumn` using `reverseLayout` that only Google can fix. ([#+improve-accessibility-in-timeline](https://github.com/element-hq/element-x-android/issues/+improve-accessibility-in-timeline)) +- Add ability to enter a recovery key to verify the session. Also fixes some refresh issues with the verification session state. ([#2421](https://github.com/element-hq/element-x-android/issues/2421)) + +Other changes +------------- +- Provide the current system proxy setting to the Rust SDK. ([#2420](https://github.com/element-hq/element-x-android/issues/2420)) + + +Changes in Element X v0.4.4 (2024-02-15) +======================================== + +Bugfixes 🐛 +---------- + +- Fix decryption of previous messages after session verification not working. + +Changes in Element X v0.4.3 (2024-02-14) +======================================== + +Features ✨ +---------- +- Change "Read receipts" advanced setting used to send private Read Receipt to "Share presence" settings. When disabled, private Read Receipts will be sent, and no typing notification will be sent. Also Read Receipts and typing notifications will not be rendered in the timeline. ([#2241](https://github.com/element-hq/element-x-android/issues/2241)) +- Render typing notifications. ([#2242](https://github.com/element-hq/element-x-android/issues/2242)) +- Manually mark a room as unread. ([#2261](https://github.com/element-hq/element-x-android/issues/2261)) +- Add empty state to the room list. ([#2330](https://github.com/element-hq/element-x-android/issues/2330)) +- Allow joining unencrypted video calls in non encrypted rooms. ([#2333](https://github.com/element-hq/element-x-android/issues/2333)) + +Bugfixes 🐛 +---------- +- Fix crash after unregistering UnifiedPush distributor ([#2304](https://github.com/element-hq/element-x-android/issues/2304)) +- Add missing device id to settings screen. ([#2316](https://github.com/element-hq/element-x-android/issues/2316)) +- Open the keyboard (and keep it opened) when creating a poll. ([#2329](https://github.com/element-hq/element-x-android/issues/2329)) +- Fix message forwarding after SDK API change related to Timeline intitialization. + +Other changes +------------- +- Adjusted the login flow buttons so the continue button is always at the same height ([#825](https://github.com/element-hq/element-x-android/issues/825)) +- Move migration screen to within the room list ([#2310](https://github.com/element-hq/element-x-android/issues/2310)) +- Render correctly in reply to data when Event cannot be decrypted or has been redacted ([#2318](https://github.com/element-hq/element-x-android/issues/2318)) +- Remove Compose Foundation version pinning workaround. This was done to avoid a bug introduced in the default foundation version used by the material3 library, but that has already been fixed. +- Remove `FilterHiddenStateEventsProcessor`, as this is already handled by the Rust SDK. +- Remove session preferences on user log out. + +Breaking changes 🚨 +------------------- +- Update Compound icons in the project. Since the icon prefix changed to `ic_compound_` and the `CompoundIcons` helper now contains the vector icons as composable functions. + +Changes in Element X v0.4.2 (2024-01-31) +======================================== + +Matrix SDK 🦀 v0.1.95 + +Features ✨ +---------- +- Add 'send private read receipts' option in advanced settings ([#2204](https://github.com/element-hq/element-x-android/issues/2204)) +- Send typing notification ([#2240](https://github.com/element-hq/element-x-android/issues/2240)). Disabling the sending of typing notification and rendering typing notification will come soon. + +Bugfixes 🐛 +---------- +- Make the room settings screen update automatically when new room info (name, avatar, topic) is available. ([#921](https://github.com/element-hq/element-x-android/issues/921)) +- Update timeline items' read receipts when the room members info is loaded. ([#2176](https://github.com/element-hq/element-x-android/issues/2176)) +- Edited text message bubbles should resize when edited ([#2260](https://github.com/element-hq/element-x-android/issues/2260)) +- Ensure login and password exclude `\n` ([#2263](https://github.com/element-hq/element-x-android/issues/2263)) +- Room list Ensure the indicators stay grey if the global setting is set to mention only and a regular message is received. ([#2282](https://github.com/element-hq/element-x-android/issues/2282)) + +Other changes +------------- +- Add a special logging configuration for nightlies so we can get more detailed info for existing issues. ([#+add-special-tracing-configuration-for-nightlies](https://github.com/element-hq/element-x-android/issues/+add-special-tracing-configuration-for-nightlies)) +- Try mitigating unexpected logouts by making getting/storing session data use a Mutex for synchronization. + Also added some more logs so we can understand exactly where it's failing. ([#+try-mitigating-unexpected-logouts](https://github.com/element-hq/element-x-android/issues/+try-mitigating-unexpected-logouts)) +- Upgrade Material3 Compose to `1.2.0-beta02`. + There is also a constraint on a transitive Compose Foundation dependency version (1.6.0-beta02) that fixes the timeline scrolling issue. ([#0-beta02](https://github.com/element-hq/element-x-android/issues/0-beta02)) +- Disambiguate display name in the timeline. ([#2215](https://github.com/element-hq/element-x-android/issues/2215)) +- Disambiguate display name in notifications ([#2224](https://github.com/element-hq/element-x-android/issues/2224)) +- Remove room creation, self-join of room creator and 'this is the beginning of X' timeline items for DMs. ([#2217](https://github.com/element-hq/element-x-android/issues/2217)) +- Encrypt databases used by the Rust SDK on Nightly and Debug builds. ([#2219](https://github.com/element-hq/element-x-android/issues/2219)) +- Fallback to UnifiedPush (if available) if the PlayServices are not installed on the device. ([#2248](https://github.com/element-hq/element-x-android/issues/2248)) +- Add "Report a problem" button to the onboarding screen ([#2275](https://github.com/element-hq/element-x-android/issues/2275)) +- Add in app logs viewer to the "Report a problem" screen. ([#2276](https://github.com/element-hq/element-x-android/issues/2276)) + + +Changes in Element X v0.4.1 (2024-01-17) +======================================== + +Features ✨ +---------- +- Render m.sticker events ([#1949](https://github.com/element-hq/element-x-android/issues/1949)) +- Add support for sending images from the keyboard ([#1977](https://github.com/element-hq/element-x-android/issues/1977)) +- Added support for MSC4027 (render custom images in reactions) ([#2159](https://github.com/element-hq/element-x-android/issues/2159)) + +Bugfixes 🐛 +---------- +- Fix crash sending image with latest Posthog because of an usage of an internal Android method. ([#+crash-sending-image-with-latest-posthog](https://github.com/element-hq/element-x-android/issues/+crash-sending-image-with-latest-posthog)) +- Make sure the media viewer tries the main url first (if not empty) then the thumbnail url and then not open if both are missing instead of failing with an error dialog ([#1949](https://github.com/element-hq/element-x-android/issues/1949)) +- Fix room transition animation happens twice. ([#2084](https://github.com/element-hq/element-x-android/issues/2084)) +- Disable ability to send reaction if the user does not have the permission to. ([#2093](https://github.com/element-hq/element-x-android/issues/2093)) +- Trim whitespace at the end of messages to ensure we render the right content. ([#2099](https://github.com/element-hq/element-x-android/issues/2099)) +- Fix crashes in room list when the last message for a room was an extremely long one (several thousands of characters) with no line breaks. ([#2105](https://github.com/element-hq/element-x-android/issues/2105)) +- Disable rasterisation of Vector XMLs, which was causing crashes on API 23. ([#2124](https://github.com/element-hq/element-x-android/issues/2124)) +- Use `SubomposeLayout` for `ContentAvoidingLayout` to prevent wrong measurements in the layout process, leading to cut-off text messages in the timeline. ([#2155](https://github.com/element-hq/element-x-android/issues/2155)) +- Improve rendering of voice messages in the timeline in large displays ([#2156](https://github.com/element-hq/element-x-android/issues/2156)) +- Fix no indication that user list is loading when inviting to room. ([#2172](https://github.com/element-hq/element-x-android/issues/2172)) +- Hide keyboard when tapping on a message in the timeline. ([#2182](https://github.com/element-hq/element-x-android/issues/2182)) +- Mention selector gets stuck when quickly deleting the prompt. ([#2192](https://github.com/element-hq/element-x-android/issues/2192)) +- Hide verbose state events from the timeline ([#2216](https://github.com/element-hq/element-x-android/issues/2216)) + +Other changes +------------- +- Only apply `com.autonomousapps.dependency-analysis` plugin in those modules that need it. ([#+only-apply-dependency-analysis-plugin-where-needed](https://github.com/element-hq/element-x-android/issues/+only-apply-dependency-analysis-plugin-where-needed)) +- Migrate to Kover 0.7.X ([#1782](https://github.com/element-hq/element-x-android/issues/1782)) +- Remove extra logout screen. ([#2072](https://github.com/element-hq/element-x-android/issues/2072)) +- Handle `MembershipChange.NONE` rendering in the timeline. ([#2102](https://github.com/element-hq/element-x-android/issues/2102)) +- Remove extra previews for timestamp view with 'document' case ([#2127](https://github.com/element-hq/element-x-android/issues/2127)) +- Bump AGP version to 8.2.0 ([#2142](https://github.com/element-hq/element-x-android/issues/2142)) +- Replace 'leave room' text with 'leave conversation' for DMs. ([#2218](https://github.com/element-hq/element-x-android/issues/2218)) + + +Changes in Element X v0.4.0 (2023-12-22) +======================================== + +Features ✨ +---------- +- Use the RTE library `TextView` to render text events in the timeline. Add support for mention pills - with no interaction yet. ([#1433](https://github.com/element-hq/element-x-android/issues/1433)) +- Tapping on a user mention pill opens their profile. ([#1448](https://github.com/element-hq/element-x-android/issues/1448)) +- Display different notifications for mentions. ([#1451](https://github.com/element-hq/element-x-android/issues/1451)) +- Reply to a poll ([#1848](https://github.com/element-hq/element-x-android/issues/1848)) +- Add plain text representation of messages ([#1850](https://github.com/element-hq/element-x-android/issues/1850)) +- Allow polls to be edited when they have not been voted on ([#1869](https://github.com/element-hq/element-x-android/issues/1869)) +- Scroll to end of timeline when sending a new message. ([#1877](https://github.com/element-hq/element-x-android/issues/1877)) +- Confirm back navigation when editing a poll only if the poll was changed ([#1886](https://github.com/element-hq/element-x-android/issues/1886)) +- Add option to delete a poll while editing the poll ([#1895](https://github.com/element-hq/element-x-android/issues/1895)) +- Open room member avatar when you click on it inside the member details screen. ([#1907](https://github.com/element-hq/element-x-android/issues/1907)) +- Poll history of a room is now accessible from the room details screen. ([#2014](https://github.com/element-hq/element-x-android/issues/2014)) +- Always close the invite list screen when there is no more invite. ([#2022](https://github.com/element-hq/element-x-android/issues/2022)) + +Bugfixes 🐛 +---------- +- Fix see room in the room list after leaving it. ([#1006](https://github.com/element-hq/element-x-android/issues/1006)) +- Adjust mention pills font weight and horizontal padding ([#1449](https://github.com/element-hq/element-x-android/issues/1449)) +- Font size in 'All Chats' header was changing mid-animation. ([#1572](https://github.com/element-hq/element-x-android/issues/1572)) +- Accessibility: do not read initial used for avatar out loud. ([#1864](https://github.com/element-hq/element-x-android/issues/1864)) +- Use the right avatar for DMs in DM rooms ([#1912](https://github.com/element-hq/element-x-android/issues/1912)) +- Fix scaling of timeline images: don't crop, don't set min/max aspect ratio values. ([#1940](https://github.com/element-hq/element-x-android/issues/1940)) +- Fix rendering of user name with vertical text by clipping the text. ([#1950](https://github.com/element-hq/element-x-android/issues/1950)) +- Do not render `roomId` if the room has no canonical alias. ([#1970](https://github.com/element-hq/element-x-android/issues/1970)) +- Fix avatar not displayed in notification when the app is not in background ([#1991](https://github.com/element-hq/element-x-android/issues/1991)) +- Fix wording in room invite members view: `Send` -> `Invite`. ([#2037](https://github.com/element-hq/element-x-android/issues/2037)) +- Timestamp positioning was broken, specially for edited messages. ([#2060](https://github.com/element-hq/element-x-android/issues/2060)) +- Emojis in custom reaction bottom sheet are too tiny. ([#2066](https://github.com/element-hq/element-x-android/issues/2066)) +- Set a default power level to join calls. Also, create new rooms taking this power level into account. + +Other changes +------------- +- Add a warning for 'mentions and keywords only' notification option if your homeserver does not support it ([#1749](https://github.com/element-hq/element-x-android/issues/1749)) +- Remove `:libraries:theme` module, extract theme and tokens to [Compound Android](https://github.com/element-hq/compound-android). ([#1833](https://github.com/element-hq/element-x-android/issues/1833)) +- Update poll icons from Compound ([#1849](https://github.com/element-hq/element-x-android/issues/1849)) +- Add ability to see the room avatar in the media viewer. ([#1918](https://github.com/element-hq/element-x-android/issues/1918)) +- RoomList: introduce incremental loading to improve performances. ([#1920](https://github.com/element-hq/element-x-android/issues/1920)) +- Add toggle in the notification settings to disable notifications for room invites. ([#1944](https://github.com/element-hq/element-x-android/issues/1944)) +- Update rendering of Emojis displayed during verification. ([#1965](https://github.com/element-hq/element-x-android/issues/1965)) +- Hide sender info in direct rooms ([#1979](https://github.com/element-hq/element-x-android/issues/1979)) +- Render images in Notification ([#1991](https://github.com/element-hq/element-x-android/issues/1991)) +- Only process content.json from Localazy. ([#2031](https://github.com/element-hq/element-x-android/issues/2031)) +- Always show user avatar in message action sheet ([#2032](https://github.com/element-hq/element-x-android/issues/2032)) +- Hide room list dropdown menu. ([#2062](https://github.com/element-hq/element-x-android/issues/2062)) +- Enable Chat backup, Mentions and Read Receipt in release. ([#2087](https://github.com/element-hq/element-x-android/issues/2087)) +- Make most code used in Compose from `:libraries:matrix` and derived classes Immutable or Stable. + +Changes in Element X v0.3.2 (2023-11-22) +======================================== + +Features ✨ +---------- +- Add ongoing call indicator to rooms lists items. ([#1158](https://github.com/element-hq/element-x-android/issues/1158)) +- Add support for typing mentions in the message composer. ([#1453](https://github.com/element-hq/element-x-android/issues/1453)) +- Add intentional mentions to messages. This needs to be enabled in developer options since it's disabled by default. ([#1591](https://github.com/element-hq/element-x-android/issues/1591)) +- Update voice message recording behaviour. Instead of holding the record button, users can now tap the record button to start recording and tap again to stop recording. ([#1784](https://github.com/element-hq/element-x-android/issues/1784)) + +Bugfixes 🐛 +---------- +- Always ensure media temp dir exists ([#1790](https://github.com/element-hq/element-x-android/issues/1790)) + +Other changes +------------- +- Update icons and move away from `PreferenceText` components. ([#1718](https://github.com/element-hq/element-x-android/issues/1718)) +- Add item "This is the beginning of..." at the beginning of the timeline. ([#1801](https://github.com/element-hq/element-x-android/issues/1801)) +- LockScreen : rework LoggedInFlowNode and back management when locked. ([#1806](https://github.com/element-hq/element-x-android/issues/1806)) +- Suppress usage of removeTimeline method. ([#1824](https://github.com/element-hq/element-x-android/issues/1824)) +- Remove Element Call feature flag, it's now always enabled. +- Reverted the EC base URL to `https://call.element.io`. +- Moved the option to override this URL to developer settings from advanced settings. + + +Changes in Element X v0.3.1 (2023-11-09) +======================================== + +Features ✨ +---------- +- Chat backup is still under a feature flag, but when enabled, user can enter their recovery key (it's also possible to input a passphrase) to unlock the encrypted room history. ([#1770](https://github.com/element-hq/element-x-android/pull/1770)) + +Bugfixes 🐛 +---------- +- Improve confusing text in the 'ready to start verification' screen. ([#879](https://github.com/element-hq/element-x-android/issues/879)) +- Message composer wasn't resized when selecting a several lines message to reply to, then a single line one. ([#1560](https://github.com/element-hq/element-x-android/issues/1560)) + +Other changes +------------- +- PIN: Set lock grace period to 0. ([#1732](https://github.com/element-hq/element-x-android/issues/1732)) + + +Changes in Element X v0.3.0 (2023-10-31) +======================================== + +Features ✨ +---------- +- Element Call: change the 'join call' button in a chat room when there's an active call. ([#1158](https://github.com/element-hq/element-x-android/issues/1158)) +- Mentions: add mentions suggestion view in RTE ([#1452](https://github.com/element-hq/element-x-android/issues/1452)) +- Record and send voice messages ([#1596](https://github.com/element-hq/element-x-android/issues/1596)) +- Enable voice messages for all users ([#1669](https://github.com/element-hq/element-x-android/issues/1669)) +- Receive and play a voice message ([#2084](https://github.com/element-hq/element-x-android/issues/2084)) +- Enable Element Call integration in rooms by default, fix several issues when creating or joining calls. + +Bugfixes 🐛 +---------- +- Group fallback notification to avoid having plenty of them displayed. ([#994](https://github.com/element-hq/element-x-android/issues/994)) +- Hide keyboard when exiting the chat room screen. ([#1375](https://github.com/element-hq/element-x-android/issues/1375)) +- Always register the pusher when application starts ([#1481](https://github.com/element-hq/element-x-android/issues/1481)) +- Ensure screen does not turn off when playing a video ([#1519](https://github.com/element-hq/element-x-android/issues/1519)) +- Fix issue where text is cleared when cancelling a reply ([#1617](https://github.com/element-hq/element-x-android/issues/1617)) + +Other changes +------------- +- Remove usage of blocking methods. ([#1563](https://github.com/element-hq/element-x-android/issues/1563)) + + +Changes in Element X v0.2.4 (2023-10-12) +======================================== + +Features ✨ +---------- +- [Rich text editor] Add full screen mode ([#1447](https://github.com/element-hq/element-x-android/issues/1447)) +- Improve rendering of m.emote. ([#1497](https://github.com/element-hq/element-x-android/issues/1497)) +- Improve deleted session behavior. ([#1520](https://github.com/element-hq/element-x-android/issues/1520)) + +Bugfixes 🐛 +---------- +- WebP images can't be sent as media. ([#1483](https://github.com/element-hq/element-x-android/issues/1483)) +- Fix back button not working in bottom sheets. ([#1517](https://github.com/element-hq/element-x-android/issues/1517)) +- Render body of unknown msgtype in the timeline and in the room list ([#1539](https://github.com/element-hq/element-x-android/issues/1539)) + +Other changes +------------- +- Room : makes subscribeToSync/unsubscribeFromSync suspendable. ([#1457](https://github.com/element-hq/element-x-android/issues/1457)) +- Add some Konsist tests. ([#1526](https://github.com/element-hq/element-x-android/issues/1526)) + + +Changes in Element X v0.2.3 (2023-09-27) +======================================== + +Features ✨ +---------- +- Handle installation of Apks from the media viewer. ([#1432](https://github.com/element-hq/element-x-android/pull/1432)) +- Integrate SDK 0.1.58 ([#1437](https://github.com/element-hq/element-x-android/pull/1437)) + +Other changes +------------- +- Element call: add custom parameters to Element Call urls. ([#1434](https://github.com/element-hq/element-x-android/issues/1434)) + + +Changes in Element X v0.2.2 (2023-09-21) +======================================== + +Bugfixes 🐛 +---------- +- Add animation when rendering the timeline to avoid glitches. ([#1323](https://github.com/element-hq/element-x-android/issues/1323)) +- Fix crash when trying to take a photo or record a video. ([#1395](https://github.com/element-hq/element-x-android/issues/1395)) + + +Changes in Element X v0.2.1 (2023-09-20) +======================================== + +Features ✨ +---------- +- Bump Rust SDK to `v0.1.56` +- [Rich text editor] Add link support to rich text editor ([#1309](https://github.com/element-hq/element-x-android/issues/1309)) +- Let the SDK figure the best scheme given an homeserver URL (thus allowing HTTP homeservers) ([#1382](https://github.com/element-hq/element-x-android/issues/1382)) + +Bugfixes 🐛 +---------- +- Fix ANR on RoomList when notification settings change. ([#1370](https://github.com/element-hq/element-x-android/issues/1370)) + +Other changes +------------- +- Element Call: support scheme `io.element.call` ([#1377](https://github.com/element-hq/element-x-android/issues/1377)) +- [DI] Rework how dagger components are created and provided. ([#1378](https://github.com/element-hq/element-x-android/issues/1378)) +- Remove usage of async-uniffi as it leads to a deadlocks and memory leaks. ([#1381](https://github.com/element-hq/element-x-android/issues/1381)) + + +Changes in Element X v0.2.0 (2023-09-18) +======================================== + +Features ✨ +---------- +- Bump Rust SDK to `v0.1.54` +- Add a "Mute" shortcut icon and a "Notifications" section in the room details screen ([#506](https://github.com/element-hq/element-x-android/issues/506)) +- Add a notification permission screen to the initial flow. ([#897](https://github.com/element-hq/element-x-android/issues/897)) +- Integrate Element Call into EX by embedding a call in a WebView. ([#1300](https://github.com/element-hq/element-x-android/issues/1300)) +- Implement Bloom effect modifier. ([#1217](https://github.com/element-hq/element-x-android/issues/1217)) +- Set color on display name and default avatar in the timeline. ([#1224](https://github.com/element-hq/element-x-android/issues/1224)) +- Display a thread decorator in timeline so we know when a message is coming from a thread. ([#1236](https://github.com/element-hq/element-x-android/issues/1236)) +- [Rich text editor] Integrate rich text editor library. Note that markdown is now not supported and further formatting support will be introduced through the rich text editor. ([#1172](https://github.com/element-hq/element-x-android/issues/1172)) +- [Rich text editor] Add formatting menu (accessible via the '+' button) ([#1261](https://github.com/element-hq/element-x-android/issues/1261)) +- [Rich text editor] Add feature flag for rich text editor. Markdown support can now be enabled by disabling the rich text editor. ([#1289](https://github.com/element-hq/element-x-android/issues/1289)) +- [Rich text editor] Update design ([#1332](https://github.com/element-hq/element-x-android/issues/1332)) + +Bugfixes 🐛 +---------- +- Make links in room topic clickable ([#612](https://github.com/element-hq/element-x-android/issues/612)) +- Reply action: harmonize conditions in bottom sheet and swipe to reply. ([#1173](https://github.com/element-hq/element-x-android/issues/1173)) +- Fix system bar color after login on light theme. ([#1222](https://github.com/element-hq/element-x-android/issues/1222)) +- Fix long click on simple formatted messages ([#1232](https://github.com/element-hq/element-x-android/issues/1232)) +- Enable polls in release build. ([#1241](https://github.com/element-hq/element-x-android/issues/1241)) +- Fix top padding in room list when app is opened in offline mode. ([#1297](https://github.com/element-hq/element-x-android/issues/1297)) +- [Rich text editor] Fix 'text formatting' option only partially visible ([#1335](https://github.com/element-hq/element-x-android/issues/1335)) +- [Rich text editor] Ensure keyboard opens for reply and text formatting modes ([#1337](https://github.com/element-hq/element-x-android/issues/1337)) +- [Rich text editor] Fix placeholder spilling onto multiple lines ([#1347](https://github.com/element-hq/element-x-android/issues/1347)) + +Other changes +------------- +- Add a sub-screen "Notifications" in the existing application Settings ([#510](https://github.com/element-hq/element-x-android/issues/510)) +- Exclude some groups related to analytics to be included. ([#1191](https://github.com/element-hq/element-x-android/issues/1191)) +- Use the new SyncIndicator API. ([#1244](https://github.com/element-hq/element-x-android/issues/1244)) +- Improve RoomSummary mapping by using RoomInfo. ([#1251](https://github.com/element-hq/element-x-android/issues/1251)) +- Ensure Posthog data are sent to "https://posthog.element.io" ([#1269](https://github.com/element-hq/element-x-android/issues/1269)) +- New app icon, with monochrome support. ([#1363](https://github.com/element-hq/element-x-android/issues/1363)) + + +Changes in Element X v0.1.6 (2023-09-04) +======================================== + +Features ✨ +---------- +- Enable the Polls feature. Allows to create, view, vote and end polls. ([#1196](https://github.com/element-hq/element-x-android/issues/1196)) +- Create poll. ([#1143](https://github.com/element-hq/element-x-android/issues/1143)) + +Bugfixes 🐛 +---------- +- Ensure notification for Event from encrypted room get decrypted content. ([#1178](https://github.com/element-hq/element-x-android/issues/1178)) +- Make sure Snackbars are only displayed once. ([#928](https://github.com/element-hq/element-x-android/issues/928)) +- Fix the orientation of sent images. ([#1135](https://github.com/element-hq/element-x-android/issues/1135)) +- Bug reporter crashes when 'send logs' is disabled. ([#1168](https://github.com/element-hq/element-x-android/issues/1168)) +- Add missing link to the terms on the analytics setting screen. ([#1177](https://github.com/element-hq/element-x-android/issues/1177)) +- Re-enable `SyncService.withEncryptionSync` to improve decryption of notifications. ([#1198](https://github.com/element-hq/element-x-android/issues/1198)) +- Crash with `aspectRatio` modifier when `Float.NaN` was used as input. ([#1995](https://github.com/element-hq/element-x-android/issues/1995)) + +Other changes +------------- +- Remove unnecessary year in copyright mention. ([#1187](https://github.com/element-hq/element-x-android/issues/1187)) + + +Changes in Element X v0.1.5 (2023-08-28) +======================================== + +Bugfixes 🐛 +---------- +- Fix crash when opening any room. ([#1160](https://github.com/element-hq/element-x-android/issues/1160)) + + +Changes in Element X v0.1.4 (2023-08-28) +======================================== + +Features ✨ +---------- +- Allow cancelling media upload ([#769](https://github.com/element-hq/element-x-android/issues/769)) +- Enable OIDC support. ([#1127](https://github.com/element-hq/element-x-android/issues/1127)) +- Add a "Setting up account" screen, displayed the first time the user logs in to the app (per account). ([#1149](https://github.com/element-hq/element-x-android/issues/1149)) + +Bugfixes 🐛 +---------- +- Videos sent from the app were cropped in some cases. ([#862](https://github.com/element-hq/element-x-android/issues/862)) +- Timeline: sender names are now displayed in one single line. ([#1033](https://github.com/element-hq/element-x-android/issues/1033)) +- Fix `TextButtons` being displayed in black. ([#1077](https://github.com/element-hq/element-x-android/issues/1077)) +- Linkify links in HTML contents. ([#1079](https://github.com/element-hq/element-x-android/issues/1079)) +- Fix bug reporter failing after not finding some log files. ([#1082](https://github.com/element-hq/element-x-android/issues/1082)) +- Fix rendering of inline elements in list items. ([#1090](https://github.com/element-hq/element-x-android/issues/1090)) +- Fix crash RuntimeException "No matching key found for the ciphertext in the stream" ([#1101](https://github.com/element-hq/element-x-android/issues/1101)) +- Make links in messages clickable again. ([#1111](https://github.com/element-hq/element-x-android/issues/1111)) +- When event has no id, just cancel parsing the latest room message for a room. ([#1125](https://github.com/element-hq/element-x-android/issues/1125)) +- Only display verification prompt after initial sync is done. ([#1131](https://github.com/element-hq/element-x-android/issues/1131)) + +In development 🚧 +---------------- +- [Poll] Add feature flag in developer options ([#1064](https://github.com/element-hq/element-x-android/issues/1064)) +- [Polls] Improve UI and render ended state ([#1113](https://github.com/element-hq/element-x-android/issues/1113)) + +Other changes +------------- +- Compound: add `ListItem` and `ListSectionHeader` components. ([#990](https://github.com/element-hq/element-x-android/issues/990)) +- Migrate `object` to `data object` in sealed interface / class #1135 ([#1135](https://github.com/element-hq/element-x-android/issues/1135)) + + +Changes in Element X v0.1.2 (2023-08-16) +======================================== + +Bugfixes 🐛 +---------- +- Filter push notifications using push rules. ([#640](https://github.com/element-hq/element-x-android/issues/640)) +- Use `for` instead of `forEach` in `DefaultDiffCacheInvalidator` to improve performance. ([#1035](https://github.com/element-hq/element-x-android/issues/1035)) + +In development 🚧 +---------------- +- [Poll] Render start event in the timeline ([#1031](https://github.com/element-hq/element-x-android/issues/1031)) + +Other changes +------------- +- Add Button component based on Compound designs ([#1021](https://github.com/element-hq/element-x-android/issues/1021)) +- Compound: implement dialogs. ([#1043](https://github.com/element-hq/element-x-android/issues/1043)) +- Compound: customise `IconButton` component. ([#1049](https://github.com/element-hq/element-x-android/issues/1049)) +- Compound: implement `DropdownMenu` customisations. ([#1050](https://github.com/element-hq/element-x-android/issues/1050)) +- Compound: implement Snackbar component. ([#1054](https://github.com/element-hq/element-x-android/issues/1054)) + + +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 0000000..40e2416 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @element-hq/element-x-android-reviewers diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4e0c9b9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,195 @@ +# Contributing to Element X Android + + + +* [Developer onboarding](#developer-onboarding) +* [Contributing code to Matrix](#contributing-code-to-matrix) +* [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) + * [Element X Android Gallery](#element-x-android-gallery) +* [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) + * [detekt](#detekt) + * [ktlint](#ktlint) + * [knit](#knit) + * [lint](#lint) + * [Unit tests](#unit-tests) + * [konsist](#konsist) + * [Tests](#tests) + * [Accessibility](#accessibility) + * [Jetpack Compose](#jetpack-compose) + * [Authors](#authors) +* [Thanks](#thanks) + + + +## Developer onboarding + +For a detailed overview of the project, see [Developer Onboarding](./docs/_developer_onboarding.md). + +## Contributing code to Matrix + +If instead of contributing to the Element X Android project, you want to contribute to Synapse, the homeserver implementation, please read the [Synapse contribution guide](https://element-hq.github.io/synapse/latest/development/contributing_guide.html). + +Element X Android support can be found in this room: [![Element X Android Matrix room #element-x-android:matrix.org](https://img.shields.io/matrix/element-x-android:matrix.org.svg?label=%23element-x-android:matrix.org&logo=matrix&server_fqdn=matrix.org)](https://matrix.to/#/#element-x-android:matrix.org). + +The rest of the document contains specific rules for Matrix Android projects. + +## 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`. + +## Strings + +The strings of the project are managed externally using [https://localazy.com](https://localazy.com) and shared with Element X 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 + +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 Element X (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 information can be found [in this README.md](./tools/localazy/README.md). + +Once a language is sufficiently translated, it will be added to the app. The core team will decide when a language is sufficiently translated. + +### Element X Android Gallery + +Once added to Localazy, translations can be checked screen per screen using our tool Element X Android Gallery, available at https://element-hq.github.io/element-x-android/. + +Localazy syncs occur every Monday and the screenshots on this page are generated every Tuesday, so you'll have to wait to see your change appearing on Element X Android Gallery. + +## 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 + +The release notes are generated from the pull request titles and labels. If possible, the title must describe best what will be the user facing change. + +You will also need to add a label starting by `PR-` to you Pull Request to help categorize the release note. The label should be added by the PR author, but can be added by the reviewer if the submitter does not have right to add label. Also note that the label can be added after the PR has been merged, as soon as the release is not done yet. + +### Code quality + +Make sure the following commands execute without any error: + +
    +./tools/quality/check.sh
    +
    + +Some separate commands can also be run, see below. + +#### detekt + +
    +./gradlew detekt
    +
    + +#### ktlint + +
    +./gradlew ktlintCheck --continue
    +
    + +Note that you can run + +
    +./gradlew ktlintFormat
    +
    + +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 +
    +./gradlew knit
    +
    + +and commit the changes. + +The CI will check that markdown files are up to date by running + +
    +./gradlew knitCheck
    +
    + +#### lint + +
    +./gradlew lint
    +
    + +### Unit tests + +Make sure the following commands execute without any error: + +
    +./gradlew test
    +
    + +#### konsist + +[konsist](https://github.com/LemonAppDev/konsist) is setup in the project to check that the architecture and the naming rules are followed. Konsist tests are classical Unit tests. + +### Tests + +Element X is currently supported on Android Marshmallow (API 23+): please test your change on an Android device (or Android emulator) running with API 23. 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 an internal function annotated with `@PreviewsDayNight`, with a name suffixed by `Preview`, and having `ElementPreview` as the root composable. + +Example: +```kotlin +@PreviewsDayNight +@Composable +internal fun PinIconPreview() = ElementPreview { + PinIcon() +} +``` + +This will allow to preview the composable in both light and dark mode in Android Studio. This will also automatically add UI tests. The GitHub action [Record screenshots](https://github.com/element-hq/element-x-android/actions/workflows/recordScreenshots.yml) has to be run to record the new screenshots. The PR reviewer can trigger this for you if you're not part of the core team. + +### 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 0000000..a432dd0 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem 'danger' diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/LICENSE-COMMERCIAL b/LICENSE-COMMERCIAL new file mode 100644 index 0000000..39041ce --- /dev/null +++ b/LICENSE-COMMERCIAL @@ -0,0 +1,6 @@ +Licensees holding a valid commercial license with Element may use this +software in accordance with the terms contained in a written agreement +between you and Element. + +To purchase a commercial license please contact our sales team at +licensing@element.io diff --git a/README.md b/README.md new file mode 100644 index 0000000..5406b15 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +[![Latest build](https://github.com/element-hq/element-x-android/actions/workflows/build.yml/badge.svg?query=branch%3Adevelop)](https://github.com/element-hq/element-x-android/actions/workflows/build.yml?query=branch%3Adevelop) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=element-x-android&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=element-x-android) +[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=element-x-android&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=element-x-android) +[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=element-x-android&metric=bugs)](https://sonarcloud.io/summary/new_code?id=element-x-android) +[![codecov](https://codecov.io/github/element-hq/element-x-android/branch/develop/graph/badge.svg?token=ecwvia7amV)](https://codecov.io/github/element-hq/element-x-android) +[![Element X Android Matrix room #element-x-android:matrix.org](https://img.shields.io/matrix/element-x-android:matrix.org.svg?label=%23element-x-android:matrix.org&logo=matrix&server_fqdn=matrix.org)](https://matrix.to/#/#element-x-android:matrix.org) +[![Localazy](https://img.shields.io/endpoint?url=https%3A%2F%2Fconnect.localazy.com%2Fstatus%2Felement%2Fdata%3Fcontent%3Dall%26title%3Dlocalazy%26logo%3Dtrue)](https://localazy.com/p/element) + +# Element X Android + +Element X Android is the next-generation [Matrix](https://matrix.org/) client provided by [Element](https://element.io/). + +Compared to the previous-generation [Element Classic](https://github.com/element-hq/element-android), the application is a total rewrite, using the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) underneath and targeting devices running Android 7+. The UI layer is written using [Jetpack Compose](https://developer.android.com/jetpack/compose), and the navigation is managed using [Appyx](https://github.com/bumble-tech/appyx). + +[Get it on Google Play](https://play.google.com/store/apps/details?id=io.element.android.x)[Get it on F-Droid](https://f-droid.org/packages/io.element.android.x) + +## Table of contents + + + +* [Screenshots](#screenshots) +* [Translations](#translations) +* [Rust SDK](#rust-sdk) +* [Status](#status) +* [Minimum SDK version](#minimum-sdk-version) +* [Contributing](#contributing) +* [Build instructions](#build-instructions) +* [Support](#support) +* [Copyright and License](#copyright-and-license) + + + +## Screenshots + +Here are some screenshots of the application: + + + +||||| +|-|-|-|-| +||||| + +## Translations + +Element X Android supports many languages. You can help us to translate the app in your language by joining our [Localazy project](https://localazy.com/p/element). You can also help us to improve the existing translations. + +Note that for now, we keep control on the French and German translations. + +Translations can be checked screen per screen using our tool Element X Android Gallery, available at https://element-hq.github.io/element-x-android/. Note that this page is updated every Tuesday. + +More instructions about translating the application can be found at [CONTRIBUTING.md](CONTRIBUTING.md#strings). + +## Rust SDK + +Element X 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 actively developed and supported. New users are recommended to use Element X instead of the previous-generation app. + +## Minimum SDK version + +Element X Android requires a minimum SDK version of 24 (Android 7.0, Nougat). We aim to support devices running Android 7.0 and above, which covers a wide range of devices still in use today. + +Element Android Enterprise requires a minimum SDK version of 33 (Android 13, Tiramisu). For Element Enterprise, we support only devices that still receive security updates, which means devices running Android 13 and above. Android does not have a documented support policy, but some information can be found at [https://endoflife.date/android](https://endoflife.date/android). + +## Contributing + +Want to get actively involved in the project? You're more than welcome! A good way to start is to check the issues that are labelled with the [good first issue](https://github.com/element-hq/element-x-android/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) label. Let us know by commenting the issue that you're starting working on it. + +But first make sure to read our [contribution guide](CONTRIBUTING.md) first. + +You can also come chat with the community in the Matrix [room](https://matrix.to/#/#element-x-android:matrix.org) dedicated to the project. + +## Build instructions + +Just clone the project and open it in Android Studio. Make sure to select the +`app` configuration when building (as we also have sample apps in the project). + +To build against a local copy of the Rust SDK, see the [Developer +onboarding](docs/_developer_onboarding.md#building-the-sdk-locally) instructions. + +## Support + +When you are experiencing an issue on Element X Android, please first search in [GitHub issues](https://github.com/element-hq/element-x-android/issues) +and then in [#element-x-android:matrix.org](https://matrix.to/#/#element-x-android:matrix.org). +If after your research you still have a question, ask at [#element-x-android:matrix.org](https://matrix.to/#/#element-x-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 from the application settings. This is especially recommended when you encounter a crash. + +## Copyright and License + +Copyright (c) 2025 Element Creations Ltd. +Copyright (c) 2022 - 2025 New Vector Ltd. + +This software is dual licensed by Element Creations Ltd (Element). It can be used either: + +(1) for free under the terms of the GNU Affero General Public License (as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version); OR + +(2) under the terms of a paid-for Element Commercial License agreement between you and Element (the terms of which may vary depending on what you and Element have agreed to). + +Unless required by applicable law or agreed to in writing, software distributed under the Licenses is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the Licenses for the specific language governing permissions and limitations under the Licenses. diff --git a/annotations/.gitignore b/annotations/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/annotations/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/annotations/build.gradle.kts b/annotations/build.gradle.kts new file mode 100644 index 0000000..33e3cbe --- /dev/null +++ b/annotations/build.gradle.kts @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + alias(libs.plugins.kotlin.jvm) + id("com.android.lint") +} diff --git a/annotations/src/main/kotlin/io/element/android/annotations/ContributesNode.kt b/annotations/src/main/kotlin/io/element/android/annotations/ContributesNode.kt new file mode 100644 index 0000000..632bdc3 --- /dev/null +++ b/annotations/src/main/kotlin/io/element/android/annotations/ContributesNode.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.annotations + +import kotlin.reflect.KClass + +/** + * Adds Node to the specified component graph. + * Equivalent to the following declaration: + * + * @BindingContainer + * @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/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..2464155 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,363 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:Suppress("UnstableApiUsage") + +import com.android.build.api.variant.FilterConfiguration.FilterType.ABI +import com.android.build.gradle.internal.tasks.factory.dependsOn +import com.android.build.gradle.tasks.GenerateBuildConfig +import com.google.firebase.appdistribution.gradle.firebaseAppDistribution +import config.BuildTimeConfig +import extension.AssetCopyTask +import extension.GitBranchNameValueSource +import extension.GitRevisionValueSource +import extension.allEnterpriseImpl +import extension.allFeaturesImpl +import extension.allLibrariesImpl +import extension.allServicesImpl +import extension.buildConfigFieldStr +import extension.koverDependencies +import extension.locales +import extension.setupDependencyInjection +import extension.setupKover +import extension.testCommonDependencies +import java.util.Locale + +plugins { + id("io.element.android-compose-application") + alias(libs.plugins.kotlin.android) + // When using precompiled plugins, we need to apply the firebase plugin like this + id(libs.plugins.firebaseAppDistribution.get().pluginId) + alias(libs.plugins.knit) + id("kotlin-parcelize") + alias(libs.plugins.licensee) + alias(libs.plugins.kotlin.serialization) + // To be able to update the firebase.xml files, uncomment and build the project + // alias(libs.plugins.gms.google.services) +} + +setupKover() + +android { + namespace = "io.element.android.x" + + defaultConfig { + applicationId = BuildTimeConfig.APPLICATION_ID + targetSdk = Versions.TARGET_SDK + versionCode = Versions.VERSION_CODE + versionName = Versions.VERSION_NAME + + // 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 { + val buildingAppBundle = gradle.startParameter.taskNames.any { it.contains("bundle") } + + // Enables building multiple APKs per ABI. This should be disabled when building an AAB. + isEnable = !buildingAppBundle + + // 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() + + if (!buildingAppBundle) { + // 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 + } + } + } + + androidResources { + localeFilters += locales + } + } + + signingConfigs { + getByName("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? + } + } + + val baseAppName = BuildTimeConfig.APPLICATION_NAME + val buildType = if (isEnterpriseBuild) "Enterprise" else "FOSS" + logger.warnInBox("Building ${defaultConfig.applicationId} ($baseAppName) [$buildType]") + + buildTypes { + val oidcRedirectSchemeBase = BuildTimeConfig.METADATA_HOST_REVERSED ?: "io.element.android" + getByName("debug") { + resValue("string", "app_name", "$baseAppName dbg") + resValue( + "string", + "login_redirect_scheme", + "$oidcRedirectSchemeBase.debug", + ) + applicationIdSuffix = ".debug" + signingConfig = signingConfigs.getByName("debug") + } + + getByName("release") { + resValue("string", "app_name", baseAppName) + resValue( + "string", + "login_redirect_scheme", + oidcRedirectSchemeBase, + ) + signingConfig = signingConfigs.getByName("debug") + + optimization { + enable = true + keepRules { + files.add(File(projectDir, "proguard-rules.pro")) + files.add(getDefaultProguardFile("proguard-android-optimize.txt")) + } + } + } + + register("nightly") { + val release = getByName("release") + initWith(release) + applicationIdSuffix = ".nightly" + versionNameSuffix = "-nightly" + resValue("string", "app_name", "$baseAppName nightly") + resValue( + "string", + "login_redirect_scheme", + "$oidcRedirectSchemeBase.nightly", + ) + matchingFallbacks += listOf("release") + signingConfig = signingConfigs.getByName("nightly") + + 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/gplay/nightly/app-gplay-universal-nightly.apk" + // artifactType = "AAB" + // artifactPath = "$rootDir/app/build/outputs/bundle/nightly/app-nightly.aab" + releaseNotesFile = "tools/release/ReleaseNotesNightly.md" + groups = if (isEnterpriseBuild) { + "enterprise-testers" + } else { + "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 = if (isEnterpriseBuild) { + "1:912726360885:android:3f7e1fe644d99d5a00427c" + } else { + "1:912726360885:android:e17435e0beb0303000427c" + } + } + } + } + + buildFeatures { + buildConfig = true + } + flavorDimensions += "store" + productFlavors { + create("gplay") { + dimension = "store" + isDefault = true + buildConfigFieldStr("SHORT_FLAVOR_DESCRIPTION", "G") + buildConfigFieldStr("FLAVOR_DESCRIPTION", "GooglePlay") + } + create("fdroid") { + dimension = "store" + buildConfigFieldStr("SHORT_FLAVOR_DESCRIPTION", "F") + buildConfigFieldStr("FLAVOR_DESCRIPTION", "FDroid") + } + } + + packaging { + resources.pickFirsts += setOf( + "META-INF/versions/9/OSGI-INF/MANIFEST.MF", + ) + } +} + +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.orNull ?: 0) * 10 + abiCode) + } + } + + val reportingExtension: ReportingExtension = project.extensions.getByType(ReportingExtension::class.java) + configureLicensesTasks(reportingExtension) +} + +// Knit +apply { + plugin("kotlinx-knit") +} + +knit { + files = fileTree(project.rootDir) { + include( + "**/*.md", + "**/*.kt", + "*/*.kts", + ) + exclude( + "**/build/**", + "*/.gradle/**", + "**/CHANGES.md", + ) + } +} + +setupDependencyInjection() + +dependencies { + allLibrariesImpl() + allServicesImpl() + if (isEnterpriseBuild) { + allEnterpriseImpl(project) + implementation(projects.appicon.enterprise) + } else { + implementation(projects.features.enterprise.implFoss) + implementation(projects.appicon.element) + } + allFeaturesImpl(project) + implementation(projects.features.migration.api) + implementation(projects.appnav) + implementation(projects.appconfig) + implementation(projects.libraries.uiStrings) + implementation(projects.services.analytics.compose) + + if (ModulesConfig.pushProvidersConfig.includeFirebase) { + "gplayImplementation"(projects.libraries.pushproviders.firebase) + } + if (ModulesConfig.pushProvidersConfig.includeUnifiedPush) { + implementation(projects.libraries.pushproviders.unifiedpush) + } + + 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.matrix.emojibase.bindings) + + testCommonDependencies(libs) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.services.toolbox.test) + + koverDependencies() +} + +tasks.withType().configureEach { + outputs.upToDateWhen { false } + val gitRevision = providers.of(GitRevisionValueSource::class.java) {}.get() + val gitBranchName = providers.of(GitBranchNameValueSource::class.java) {}.get() + android.defaultConfig.buildConfigFieldStr("GIT_REVISION", gitRevision) + android.defaultConfig.buildConfigFieldStr("GIT_BRANCH_NAME", gitBranchName) +} + +licensee { + allow("Apache-2.0") + allow("MIT") + allow("BSD-2-Clause") + allow("BSD-3-Clause") + allow("EPL-1.0") + allowUrl("https://opensource.org/licenses/MIT") + allowUrl("https://developer.android.com/studio/terms.html") + allowUrl("https://www.zetetic.net/sqlcipher/license/") + allowUrl("https://jsoup.org/license") + allowUrl("https://asm.ow2.io/license.html") + allowUrl("https://www.gnu.org/licenses/agpl-3.0.txt") + allowUrl("https://github.com/mhssn95/compose-color-picker/blob/main/LICENSE") + ignoreDependencies("com.github.matrix-org", "matrix-analytics-events") + // Ignore dependency that are not third-party licenses to us. + ignoreDependencies(groupId = "io.element.android") +} + +fun Project.configureLicensesTasks(reportingExtension: ReportingExtension) { + androidComponents { + onVariants { variant -> + val capitalizedVariantName = variant.name.replaceFirstChar { + if (it.isLowerCase()) { + it.titlecase(Locale.getDefault()) + } else { + it.toString() + } + } + val artifactsFile = reportingExtension.baseDirectory.file("licensee/android$capitalizedVariantName/artifacts.json") + + val copyArtifactsTask = + project.tasks.register("copy${capitalizedVariantName}LicenseeReportToAssets") { + inputFile.set(artifactsFile) + targetFileName.set("licensee-artifacts.json") + } + variant.sources.assets?.addGeneratedSourceDirectory( + copyArtifactsTask, + AssetCopyTask::outputDirectory, + ) + copyArtifactsTask.dependsOn("licenseeAndroid$capitalizedVariantName") + } + } +} + +configurations.all { + resolutionStrategy { + dependencySubstitution { + val tink = libs.google.tink.get() + substitute(module("com.google.crypto.tink:tink")).using(module("${tink.group}:${tink.name}:${tink.version}")) + } + } +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..9610942 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,72 @@ +# 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.** { *; } + +# TagSoup, coming from the RTE library +-keep class org.ccil.cowan.tagsoup.** { *; } + +# 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.** + +# Needed for Posthog +-keepclassmembers class android.view.JavaViewSpy { + static int windowAttachCount(android.view.View); +} + + +# Keep LogSessionId class and related classes (https://github.com/androidx/media/issues/2535) +-keep class android.media.metrics.LogSessionId { *; } +-keep class android.media.metrics.** { *; } + +# Keep Media3 classes that use reflection (https://github.com/androidx/media/issues/2535) +-keep class androidx.media3.** { *; } +-dontwarn android.media.metrics.** + +# New rules after AGP 8.13.1 upgrade +-dontwarn androidx.window.extensions.WindowExtensions +-dontwarn androidx.window.extensions.WindowExtensionsProvider +-dontwarn androidx.window.extensions.area.ExtensionWindowAreaPresentation +-dontwarn androidx.window.extensions.layout.DisplayFeature +-dontwarn androidx.window.extensions.layout.FoldingFeature +-dontwarn androidx.window.extensions.layout.WindowLayoutComponent +-dontwarn androidx.window.extensions.layout.WindowLayoutInfo +-dontwarn androidx.window.sidecar.SidecarDeviceState +-dontwarn androidx.window.sidecar.SidecarDisplayFeature +-dontwarn androidx.window.sidecar.SidecarInterface$SidecarCallback +-dontwarn androidx.window.sidecar.SidecarInterface +-dontwarn androidx.window.sidecar.SidecarProvider +-dontwarn androidx.window.sidecar.SidecarWindowLayoutInfo + +# Also needed after AGP 8.13.1 upgrade, it seems like proguard is now more aggressive on removing unused code +-keep class org.matrix.rustcomponents.sdk.** { *;} +-keep class uniffi.** { *;} +-keep class io.element.android.x.di.** { *; } +-keepnames class io.element.android.x.** diff --git a/app/signature/debug.keystore b/app/signature/debug.keystore new file mode 100644 index 0000000..4a15fc9 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 0000000..a0e9ba4 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 0000000..628c428 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 0000000..e29ff82 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.x + +import android.app.Application +import androidx.startup.AppInitializer +import androidx.work.Configuration +import dev.zacsweers.metro.createGraphFactory +import io.element.android.libraries.di.DependencyInjectionGraphOwner +import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory +import io.element.android.x.di.AppGraph +import io.element.android.x.info.logApplicationInfo +import io.element.android.x.initializer.CacheCleanerInitializer +import io.element.android.x.initializer.CrashInitializer +import io.element.android.x.initializer.PlatformInitializer + +class ElementXApplication : Application(), DependencyInjectionGraphOwner, Configuration.Provider { + override val graph: AppGraph = createGraphFactory().create(this) + + override val workManagerConfiguration: Configuration = Configuration.Builder() + .setWorkerFactory(MetroWorkerFactory(graph.workerProviders)) + .build() + + override fun onCreate() { + super.onCreate() + AppInitializer.getInstance(this).apply { + initializeComponent(CrashInitializer::class.java) + initializeComponent(PlatformInitializer::class.java) + initializeComponent(CacheCleanerInitializer::class.java) + } + + logApplicationInfo(this) + } +} 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 0000000..162a55c --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/MainActivity.kt @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.x + +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.bumble.appyx.core.integration.NodeHost +import com.bumble.appyx.core.integrationpoint.NodeActivity +import com.bumble.appyx.core.plugin.NodeReadyObserver +import io.element.android.compound.colors.SemanticColorsLightDark +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.lockscreen.api.LockScreenEntryPoint +import io.element.android.features.lockscreen.api.LockScreenLockState +import io.element.android.features.lockscreen.api.LockScreenService +import io.element.android.features.lockscreen.api.handleSecureFlag +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.designsystem.theme.ElementThemeApp +import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher +import io.element.android.services.analytics.compose.LocalAnalyticsService +import io.element.android.x.di.AppBindings +import io.element.android.x.intent.SafeUriHandler +import kotlinx.coroutines.launch +import timber.log.Timber + +private val loggerTag = LoggerTag("MainActivity") + +class MainActivity : NodeActivity() { + 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() + setupLockManagement(appBindings.lockScreenService(), appBindings.lockScreenEntryPoint()) + enableEdgeToEdge() + setContent { + MainContent(appBindings) + } + } + + @Composable + private fun MainContent(appBindings: AppBindings) { + val migrationState = appBindings.migrationEntryPoint().present() + val colors by remember { + appBindings.enterpriseService().semanticColorsFlow(sessionId = null) + }.collectAsState(SemanticColorsLightDark.default) + ElementThemeApp( + appPreferencesStore = appBindings.preferencesStore(), + compoundLight = colors.light, + compoundDark = colors.dark, + buildMeta = appBindings.buildMeta() + ) { + CompositionLocalProvider( + LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(), + LocalUriHandler provides SafeUriHandler(this), + LocalAnalyticsService provides appBindings.analyticsService(), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(ElementTheme.colors.bgCanvasDefault), + ) { + if (migrationState.migrationAction.isSuccess()) { + MainNodeHost() + } else { + appBindings.migrationEntryPoint().Render( + state = migrationState, + modifier = Modifier, + ) + } + } + } + } + } + + @Composable + private fun MainNodeHost() { + NodeHost(integrationPoint = appyxV1IntegrationPoint) { + MainNode( + it, + plugins = listOf( + object : NodeReadyObserver { + override fun init(node: MainNode) { + Timber.tag(loggerTag.value).w("onMainNodeInit") + mainNode = node + mainNode.handleIntent(intent) + } + } + ), + context = applicationContext + ) + } + } + + private fun setupLockManagement( + lockScreenService: LockScreenService, + lockScreenEntryPoint: LockScreenEntryPoint + ) { + lockScreenService.handleSecureFlag(this) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + lockScreenService.lockState.collect { state -> + if (state == LockScreenLockState.Locked) { + startActivity(lockScreenEntryPoint.pinUnlockIntent(this@MainActivity)) + } + } + } + } + } + + /** + * Called when: + * - the launcher icon is clicked (if the app is already running); + * - a notification is clicked. + * - a deep link have been 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 0000000..2004db8 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/MainNode.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.x + +import android.content.Context +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.RootFlowNode +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.DependencyInjectionGraphOwner +import io.element.android.libraries.di.annotations.ApplicationContext +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize + +class MainNode( + buildContext: BuildContext, + plugins: List, + @ApplicationContext context: Context, +) : ParentNode( + navModel = PermanentNavModel( + navTargets = setOf(RootNavTarget), + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +), + DependencyInjectionGraphOwner { + override val graph = (context as DependencyInjectionGraphOwner).graph + + override fun resolve(navTarget: RootNavTarget, buildContext: BuildContext): Node { + return createNode(buildContext = buildContext) + } + + @Composable + override fun View(modifier: Modifier) { + Children(navModel = navModel) + } + + fun handleIntent(intent: Intent) { + lifecycleScope.launch { + waitForChildAttached().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 0000000..6e157f6 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.x.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.api.MigrationEntryPoint +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.lockscreen.api.LockScreenEntryPoint +import io.element.android.features.lockscreen.api.LockScreenService +import io.element.android.features.rageshake.api.reporter.BugReporter +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.matrix.api.platform.InitPlatformService +import io.element.android.libraries.matrix.api.tracing.TracingService +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.services.analytics.api.AnalyticsService + +@ContributesTo(AppScope::class) +interface AppBindings { + fun snackbarDispatcher(): SnackbarDispatcher + + fun tracingService(): TracingService + + fun platformService(): InitPlatformService + + fun bugReporter(): BugReporter + + fun lockScreenService(): LockScreenService + + fun preferencesStore(): AppPreferencesStore + + fun migrationEntryPoint(): MigrationEntryPoint + + fun lockScreenEntryPoint(): LockScreenEntryPoint + + fun analyticsService(): AnalyticsService + + fun enterpriseService(): EnterpriseService + + fun featureFlagService(): FeatureFlagService + + fun buildMeta(): BuildMeta +} diff --git a/app/src/main/kotlin/io/element/android/x/di/AppGraph.kt b/app/src/main/kotlin/io/element/android/x/di/AppGraph.kt new file mode 100644 index 0000000..7b5ba11 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/di/AppGraph.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.x.di + +import android.content.Context +import androidx.work.ListenableWorker +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.DependencyGraph +import dev.zacsweers.metro.Multibinds +import dev.zacsweers.metro.Provides +import io.element.android.libraries.architecture.NodeFactoriesBindings +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory +import kotlin.reflect.KClass + +@DependencyGraph(AppScope::class) +interface AppGraph : NodeFactoriesBindings { + val sessionGraphFactory: SessionGraph.Factory + + @Multibinds + val workerProviders: + Map, MetroWorkerFactory.WorkerInstanceFactory<*>> + + @DependencyGraph.Factory + interface Factory { + fun create( + @ApplicationContext @Provides + context: Context + ): AppGraph + } +} 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 0000000..87d0ece --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.x.di + +import android.content.Context +import android.content.SharedPreferences +import android.content.res.Resources +import androidx.preference.PreferenceManager +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn +import io.element.android.appconfig.ApplicationConfig +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.libraries.androidutils.system.getVersionCodeFromManifest +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.snackbar.SnackbarDispatcher +import io.element.android.libraries.di.BaseDirectory +import io.element.android.libraries.di.CacheDirectory +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.recentemojis.api.EmojibaseProvider +import io.element.android.libraries.recentemojis.impl.DefaultEmojibaseProvider +import io.element.android.x.BuildConfig +import io.element.android.x.R +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.plus +import java.io.File + +@BindingContainer +@ContributesTo(AppScope::class) +object AppModule { + @Provides + @BaseDirectory + fun providesBaseDirectory(@ApplicationContext context: Context): File { + return File(context.filesDir, "sessions") + } + + @Provides + @CacheDirectory + fun providesCacheDirectory(@ApplicationContext context: Context): File { + return context.cacheDir + } + + @Provides + fun providesResources(@ApplicationContext context: Context): Resources { + return context.resources + } + + @Provides + @AppCoroutineScope + @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, + enterpriseService: EnterpriseService, + ): BuildMeta { + val applicationName = ApplicationConfig.APPLICATION_NAME.takeIf { it.isNotEmpty() } ?: context.getString(R.string.app_name) + return BuildMeta( + isDebuggable = BuildConfig.DEBUG, + buildType = buildType, + applicationName = applicationName, + productionApplicationName = if (enterpriseService.isEnterpriseBuild) applicationName else ApplicationConfig.PRODUCTION_APPLICATION_NAME, + desktopApplicationName = if (enterpriseService.isEnterpriseBuild) applicationName else ApplicationConfig.DESKTOP_APPLICATION_NAME, + applicationId = BuildConfig.APPLICATION_ID, + isEnterpriseBuild = enterpriseService.isEnterpriseBuild, + // TODO EAx Config.LOW_PRIVACY_LOG_ENABLE, + lowPrivacyLoggingEnabled = false, + versionName = BuildConfig.VERSION_NAME, + versionCode = context.getVersionCodeFromManifest(), + gitRevision = BuildConfig.GIT_REVISION, + gitBranchName = BuildConfig.GIT_BRANCH_NAME, + flavorDescription = BuildConfig.FLAVOR_DESCRIPTION, + flavorShortDescription = BuildConfig.SHORT_FLAVOR_DESCRIPTION, + ) + } + + @Provides + @SingleIn(AppScope::class) + fun providesSharedPreferences(@ApplicationContext context: Context): SharedPreferences { + return PreferenceManager.getDefaultSharedPreferences(context) + } + + @Provides + @SingleIn(AppScope::class) + fun providesCoroutineDispatchers(): CoroutineDispatchers { + return CoroutineDispatchers.Default + } + + @Provides + @SingleIn(AppScope::class) + fun provideSnackbarDispatcher(): SnackbarDispatcher { + return SnackbarDispatcher() + } + + @Provides + @SingleIn(AppScope::class) + fun providesEmojibaseProvider(@ApplicationContext context: Context): EmojibaseProvider { + return DefaultEmojibaseProvider(context) + } +} diff --git a/app/src/main/kotlin/io/element/android/x/di/DefaultRoomGraphFactory.kt b/app/src/main/kotlin/io/element/android/x/di/DefaultRoomGraphFactory.kt new file mode 100644 index 0000000..bc5c853 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/di/DefaultRoomGraphFactory.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.x.di + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.appnav.di.RoomGraphFactory +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.room.JoinedRoom + +@ContributesBinding(SessionScope::class) +class DefaultRoomGraphFactory( + private val sessionGraph: SessionGraph, +) : RoomGraphFactory { + override fun create(room: JoinedRoom): Any { + return sessionGraph.roomGraphFactory + .create(room, room) + } +} diff --git a/app/src/main/kotlin/io/element/android/x/di/DefaultSessionGraphFactory.kt b/app/src/main/kotlin/io/element/android/x/di/DefaultSessionGraphFactory.kt new file mode 100644 index 0000000..9631713 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/di/DefaultSessionGraphFactory.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.x.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.appnav.di.SessionGraphFactory +import io.element.android.libraries.matrix.api.MatrixClient + +@ContributesBinding(AppScope::class) +class DefaultSessionGraphFactory( + private val appGraph: AppGraph +) : SessionGraphFactory { + override fun create(client: MatrixClient): Any { + return appGraph.sessionGraphFactory.create(client) + } +} diff --git a/app/src/main/kotlin/io/element/android/x/di/RoomGraph.kt b/app/src/main/kotlin/io/element/android/x/di/RoomGraph.kt new file mode 100644 index 0000000..6588335 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/di/RoomGraph.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.x.di + +import dev.zacsweers.metro.GraphExtension +import dev.zacsweers.metro.Provides +import io.element.android.appnav.di.TimelineBindings +import io.element.android.libraries.architecture.NodeFactoriesBindings +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.JoinedRoom + +@GraphExtension(RoomScope::class) +interface RoomGraph : NodeFactoriesBindings, TimelineBindings { + @GraphExtension.Factory + interface Factory { + fun create( + @Provides joinedRoom: JoinedRoom, + @Provides baseRoom: BaseRoom + ): RoomGraph + } +} diff --git a/app/src/main/kotlin/io/element/android/x/di/SessionGraph.kt b/app/src/main/kotlin/io/element/android/x/di/SessionGraph.kt new file mode 100644 index 0000000..e53d0eb --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/di/SessionGraph.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.x.di + +import dev.zacsweers.metro.GraphExtension +import dev.zacsweers.metro.Provides +import io.element.android.libraries.architecture.NodeFactoriesBindings +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient + +@GraphExtension(SessionScope::class) +interface SessionGraph : NodeFactoriesBindings { + val roomGraphFactory: RoomGraph.Factory + + @GraphExtension.Factory + interface Factory { + fun create(@Provides matrixClient: MatrixClient): SessionGraph + } +} 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 0000000..2b66fef --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/info/Logs.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.x.info + +import android.content.Context +import io.element.android.libraries.androidutils.system.getVersionCodeFromManifest +import io.element.android.x.BuildConfig +import timber.log.Timber +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +fun logApplicationInfo(context: Context) { + val appVersion = buildString { + append(BuildConfig.VERSION_NAME) + append(" (") + append(context.getVersionCodeFromManifest()) + append(") - ") + append(BuildConfig.BUILD_TYPE) + append(" / ") + append(BuildConfig.FLAVOR) + } + // 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(" Git SHA: ${BuildConfig.GIT_REVISION}") + 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/CacheCleanerInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/CacheCleanerInitializer.kt new file mode 100644 index 0000000..3e187bc --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/initializer/CacheCleanerInitializer.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.x.initializer + +import android.content.Context +import androidx.startup.Initializer +import io.element.android.features.cachecleaner.impl.CacheCleanerBindings +import io.element.android.libraries.architecture.bindings + +class CacheCleanerInitializer : Initializer { + override fun create(context: Context) { + context.bindings().cacheCleaner().clearCache() + } + + override fun dependencies(): List>> = emptyList() +} 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 0000000..3caff8a --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/initializer/CrashInitializer.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.x.initializer + +import android.content.Context +import androidx.startup.Initializer +import io.element.android.features.rageshake.impl.crash.VectorUncaughtExceptionHandler +import io.element.android.features.rageshake.impl.di.RageshakeBindings +import io.element.android.libraries.architecture.bindings + +class CrashInitializer : Initializer { + override fun create(context: Context) { + VectorUncaughtExceptionHandler( + context.bindings().preferencesCrashDataStore(), + ).activate() + } + + override fun dependencies(): List>> = emptyList() +} diff --git a/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt new file mode 100644 index 0000000..0eea512 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.x.initializer + +import android.content.Context +import android.system.Os +import androidx.startup.Initializer +import io.element.android.features.rageshake.api.logs.createWriteToFilesConfiguration +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.tracing.TracingConfiguration +import io.element.android.x.di.AppBindings +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import timber.log.Timber + +private const val ELEMENT_X_TARGET = "elementx" + +class PlatformInitializer : Initializer { + override fun create(context: Context) { + val appBindings = context.bindings() + val tracingService = appBindings.tracingService() + val platformService = appBindings.platformService() + val bugReporter = appBindings.bugReporter() + Timber.plant(tracingService.createTimberTree(ELEMENT_X_TARGET)) + val preferencesStore = appBindings.preferencesStore() + val featureFlagService = appBindings.featureFlagService() + val logLevel = runBlocking { preferencesStore.getTracingLogLevelFlow().first() } + val tracingConfiguration = TracingConfiguration( + writesToLogcat = runBlocking { featureFlagService.isFeatureEnabled(FeatureFlags.PrintLogsToLogcat) }, + writesToFilesConfiguration = bugReporter.createWriteToFilesConfiguration(), + logLevel = logLevel, + extraTargets = listOf(ELEMENT_X_TARGET), + traceLogPacks = runBlocking { preferencesStore.getTracingLogPacksFlow().first() }, + ) + bugReporter.setCurrentTracingLogLevel(logLevel.name) + platformService.init(tracingConfiguration) + // Also set env variable for rust back trace + Os.setenv("RUST_BACKTRACE", "1", true) + } + + override fun dependencies(): List>> = mutableListOf() +} diff --git a/app/src/main/kotlin/io/element/android/x/intent/DefaultIntentProvider.kt b/app/src/main/kotlin/io/element/android/x/intent/DefaultIntentProvider.kt new file mode 100644 index 0000000..0453fdf --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/intent/DefaultIntentProvider.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.x.intent + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.net.toUri +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.deeplink.api.DeepLinkCreator +import io.element.android.libraries.di.annotations.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.x.MainActivity + +@ContributesBinding(AppScope::class) +class DefaultIntentProvider( + @ApplicationContext private val context: Context, + private val deepLinkCreator: DeepLinkCreator, +) : IntentProvider { + override fun getViewRoomIntent( + sessionId: SessionId, + roomId: RoomId?, + threadId: ThreadId?, + eventId: EventId?, + extras: Bundle?, + ): Intent { + return Intent(context, MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = deepLinkCreator.create(sessionId, roomId, threadId, eventId).toUri() + extras?.let(::putExtras) + } + } +} 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 0000000..1e9af37 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/intent/SafeUriHandler.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/kotlin/io/element/android/x/oidc/DefaultOidcRedirectUrlProvider.kt b/app/src/main/kotlin/io/element/android/x/oidc/DefaultOidcRedirectUrlProvider.kt new file mode 100644 index 0000000..ad4f9a4 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/oidc/DefaultOidcRedirectUrlProvider.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.x.oidc + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.matrix.api.auth.OidcRedirectUrlProvider +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.x.R + +@ContributesBinding(AppScope::class) +class DefaultOidcRedirectUrlProvider( + private val stringProvider: StringProvider, +) : OidcRedirectUrlProvider { + override fun provide() = buildString { + append(stringProvider.getString(R.string.login_redirect_scheme)) + append(":/") + } +} diff --git a/app/src/main/res/drawable/transparent.xml b/app/src/main/res/drawable/transparent.xml new file mode 100644 index 0000000..248ccbc --- /dev/null +++ b/app/src/main/res/drawable/transparent.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties new file mode 100644 index 0000000..8cbf8ea --- /dev/null +++ b/app/src/main/res/resources.properties @@ -0,0 +1,7 @@ +# Copyright (c) 2025 Element Creations Ltd. +# Copyright 2024 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +# Please see LICENSE files in the repository root for full details. + +unqualifiedResLocale=en 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 0000000..af159a0 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 0000000..c466692 --- /dev/null +++ b/app/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,10 @@ + + + + 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 0000000..06db4ae --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,17 @@ + + + + + + + + + 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 0000000..77b05d3 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + 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 0000000..ac74590 --- /dev/null +++ b/app/src/main/res/xml/file_providers.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml new file mode 100644 index 0000000..a92d7b2 --- /dev/null +++ b/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..96add8d --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + localhost + 127.0.0.1 + + 10.0.2.2 + + onion + + + + home.arpa + local + test + + home + lan + localdomain + + diff --git a/app/src/test/kotlin/io/element/android/x/intent/DefaultIntentProviderTest.kt b/app/src/test/kotlin/io/element/android/x/intent/DefaultIntentProviderTest.kt new file mode 100644 index 0000000..4598a74 --- /dev/null +++ b/app/src/test/kotlin/io/element/android/x/intent/DefaultIntentProviderTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:Suppress("SameParameterValue") + +package io.element.android.x.intent + +import android.content.Context +import android.content.Intent +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.deeplink.api.DeepLinkCreator +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.matrix.test.A_THREAD_ID +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.x.MainActivity +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class DefaultIntentProviderTest { + @Test + fun `test getViewRoomIntent with data`() { + val deepLinkCreator = lambdaRecorder { _, _, _, _ -> "deepLinkCreatorResult" } + val sut = createDefaultIntentProvider( + deepLinkCreator = { sessionId, roomId, threadId, eventId -> deepLinkCreator.invoke(sessionId, roomId, threadId, eventId) }, + ) + val result = sut.getViewRoomIntent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + threadId = A_THREAD_ID, + eventId = AN_EVENT_ID, + ) + result.commonAssertions() + assertThat(result.data.toString()).isEqualTo("deepLinkCreatorResult") + deepLinkCreator.assertions().isCalledOnce().with( + value(A_SESSION_ID), + value(A_ROOM_ID), + value(A_THREAD_ID), + value(AN_EVENT_ID), + ) + } + + private fun createDefaultIntentProvider( + deepLinkCreator: DeepLinkCreator = DeepLinkCreator { _, _, _, _ -> "" }, + ): DefaultIntentProvider { + return DefaultIntentProvider( + context = RuntimeEnvironment.getApplication() as Context, + deepLinkCreator = deepLinkCreator, + ) + } + + private fun Intent.commonAssertions() { + assertThat(action).isEqualTo(Intent.ACTION_VIEW) + assertThat(component?.className).isEqualTo(MainActivity::class.java.name) + } +} diff --git a/app/src/test/kotlin/io/element/android/x/oidc/DefaultOidcRedirectUrlProviderTest.kt b/app/src/test/kotlin/io/element/android/x/oidc/DefaultOidcRedirectUrlProviderTest.kt new file mode 100644 index 0000000..1856735 --- /dev/null +++ b/app/src/test/kotlin/io/element/android/x/oidc/DefaultOidcRedirectUrlProviderTest.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.x.oidc + +import com.google.common.truth.Truth.assertThat +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.x.R +import org.junit.Test + +class DefaultOidcRedirectUrlProviderTest { + @Test + fun `test provide`() { + val stringProvider = FakeStringProvider( + defaultResult = "str" + ) + val sut = DefaultOidcRedirectUrlProvider( + stringProvider = stringProvider, + ) + val result = sut.provide() + assertThat(result).isEqualTo("str:/") + assertThat(stringProvider.lastResIdParam).isEqualTo(R.string.login_redirect_scheme) + } +} diff --git a/appconfig/build.gradle.kts b/appconfig/build.gradle.kts new file mode 100644 index 0000000..45496ac --- /dev/null +++ b/appconfig/build.gradle.kts @@ -0,0 +1,53 @@ +import config.BuildTimeConfig +import extension.buildConfigFieldStr + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.appconfig" + + buildFeatures { + buildConfig = true + } + + defaultConfig { + buildConfigFieldStr( + name = "URL_POLICY", + value = if (isEnterpriseBuild) { + BuildTimeConfig.URL_POLICY ?: "" + } else { + "https://element.io/cookie-policy" + }, + ) + buildConfigFieldStr( + name = "BUG_REPORT_URL", + value = if (isEnterpriseBuild) { + BuildTimeConfig.BUG_REPORT_URL ?: "" + } else { + "https://rageshakes.element.io/api/submit" + }, + ) + buildConfigFieldStr( + name = "BUG_REPORT_APP_NAME", + value = if (isEnterpriseBuild) { + BuildTimeConfig.BUG_REPORT_APP_NAME ?: "" + } else { + "element-x-android" + }, + ) + } +} + +dependencies { + implementation(libs.androidx.annotationjvm) + implementation(projects.libraries.matrix.api) +} diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/AnalyticsConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/AnalyticsConfig.kt new file mode 100644 index 0000000..e32e482 --- /dev/null +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/AnalyticsConfig.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appconfig + +object AnalyticsConfig { + const val POLICY_LINK = BuildConfig.URL_POLICY +} diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/ApplicationConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/ApplicationConfig.kt new file mode 100644 index 0000000..e1ae689 --- /dev/null +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/ApplicationConfig.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appconfig + +object ApplicationConfig { + /** + * Application name used in the UI for string. If empty, the value is taken from the resources `R.string.app_name`. + * Note that this value is not used for the launcher icon. + * For Element, the value is empty, and so read from `R.string.app_name`, which depends on the build variant: + * - "Element X" for release builds; + * - "Element X dbg" for debug builds; + * - "Element X nightly" for nightly builds. + */ + const val APPLICATION_NAME: String = "" + + /** + * Used in the strings to reference the Element client. + * Cannot be empty. + * For Element, the value is "Element". + */ + const val PRODUCTION_APPLICATION_NAME: String = "Element" + + /** + * Used in the strings to reference the Element Desktop client, for instance Element Web. + * Cannot be empty. + * For Element, the value is "Element". We use the same name for desktop and mobile for now. + */ + const val DESKTOP_APPLICATION_NAME: String = "Element" +} diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.kt new file mode 100644 index 0000000..7432a69 --- /dev/null +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appconfig + +object AuthenticationConfig { + const val MATRIX_ORG_URL = "https://matrix.org" + + /** + * URL with some docs that explain what's sliding sync and how to add it to your home server. + */ + const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md" + + /** + * Force a sliding sync proxy url, if not null, the proxy url in the .well-known file will be ignored. + */ + val SLIDING_SYNC_PROXY_URL: String? = null +} diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt new file mode 100644 index 0000000..ea4c264 --- /dev/null +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appconfig + +object ElementCallConfig { + /** + * The default duration of a ringing call in seconds before it's automatically dismissed. + */ + const val RINGING_CALL_DURATION_SECONDS = 90 +} diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt new file mode 100644 index 0000000..c0c1521 --- /dev/null +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appconfig + +object LearnMoreConfig { + const val ENCRYPTION_URL: String = "https://element.io/help#encryption" + const val DEVICE_VERIFICATION_URL: String = "https://element.io/help#encryption-device-verification" + const val SECURE_BACKUP_URL: String = "https://element.io/help#encryption5" + const val IDENTITY_CHANGE_URL: String = "https://element.io/help#encryption18" +} diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/LockScreenConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/LockScreenConfig.kt new file mode 100644 index 0000000..f2ef0bc --- /dev/null +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/LockScreenConfig.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appconfig + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +object LockScreenConfig { + /** Whether the PIN is mandatory or not. */ + const val IS_PIN_MANDATORY: Boolean = false + + /** Set of forbidden PIN codes. */ + val FORBIDDEN_PIN_CODES: Set = setOf("0000", "1234") + + /** The size of the PIN. */ + const val PIN_SIZE: Int = 4 + + /** Number of attempts before the user is logged out. */ + const val MAX_PIN_CODE_ATTEMPTS_BEFORE_LOGOUT: Int = 3 + + /** Time period before locking the app once backgrounded. */ + val GRACE_PERIOD: Duration = 2.minutes + + /** Authentication with strong methods (fingerprint, some face/iris unlock implementations) is supported. */ + const val IS_STRONG_BIOMETRICS_ENABLED: Boolean = true + + /** Authentication with weak methods (most face/iris unlock implementations) is supported. */ + const val IS_WEAK_BIOMETRICS_ENABLED: Boolean = true +} diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/MatrixConfiguration.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/MatrixConfiguration.kt new file mode 100644 index 0000000..76b96bf --- /dev/null +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/MatrixConfiguration.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appconfig + +object MatrixConfiguration { + const val MATRIX_TO_PERMALINK_BASE_URL: String = "https://matrix.to/#/" + val clientPermalinkBaseUrl: String? = null +} diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/MessageComposerConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/MessageComposerConfig.kt new file mode 100644 index 0000000..f3893f0 --- /dev/null +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/MessageComposerConfig.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appconfig + +object MessageComposerConfig { + /** + * Enable the rich text editing in the composer. + */ + const val ENABLE_RICH_TEXT_EDITING = true +} diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt new file mode 100644 index 0000000..cac2f8a --- /dev/null +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appconfig + +import androidx.annotation.ColorInt +import androidx.core.graphics.toColorInt + +object NotificationConfig { + /** + * If set to true, the notification will have a "Mark as read" action. + */ + const val SHOW_MARK_AS_READ_ACTION = true + + /** + * If set to true, the notification for invitation will have two actions to accept or decline the invite. + */ + const val SHOW_ACCEPT_AND_DECLINE_INVITE_ACTIONS = true + + /** + * If set to true, the notification will have a "Quick reply" action, allow to compose and send a message to the room. + */ + const val SHOW_QUICK_REPLY_ACTION = true + + @ColorInt + val NOTIFICATION_ACCENT_COLOR: Int = "#FF0DBD8B".toColorInt() +} diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/OnBoardingConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/OnBoardingConfig.kt new file mode 100644 index 0000000..c59f5d1 --- /dev/null +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/OnBoardingConfig.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appconfig + +object OnBoardingConfig { + /** Whether the user can create an account using the app. */ + const val CAN_CREATE_ACCOUNT = true +} diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/PushConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/PushConfig.kt new file mode 100644 index 0000000..d36fb74 --- /dev/null +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/PushConfig.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appconfig + +object PushConfig { + /** + * Note: pusher_app_id cannot exceed 64 chars. + */ + const val PUSHER_APP_ID: String = "im.vector.app.android" +} diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt new file mode 100644 index 0000000..1f6609e --- /dev/null +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appconfig + +object RageshakeConfig { + /** + * The URL to submit bug reports to. + */ + const val BUG_REPORT_URL = BuildConfig.BUG_REPORT_URL + + /** + * As per https://github.com/matrix-org/rageshake: + * Identifier for the application (eg 'riot-web'). + * Should correspond to a mapping configured in the configuration file for github issue reporting to work. + */ + const val BUG_REPORT_APP_NAME = BuildConfig.BUG_REPORT_APP_NAME + + /** + * The maximum size of the upload request. Default value is just below CloudFlare's max request size. + */ + const val MAX_LOG_UPLOAD_SIZE = 50 * 1024 * 1024L +} diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/RoomListConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/RoomListConfig.kt new file mode 100644 index 0000000..7369f13 --- /dev/null +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/RoomListConfig.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appconfig + +object RoomListConfig { + const val SHOW_INVITE_MENU_ITEM = false + const val SHOW_REPORT_PROBLEM_MENU_ITEM = false + + const val HAS_DROP_DOWN_MENU = SHOW_INVITE_MENU_ITEM || SHOW_REPORT_PROBLEM_MENU_ITEM +} diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt new file mode 100644 index 0000000..539b678 --- /dev/null +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appconfig + +import io.element.android.libraries.matrix.api.room.StateEventType + +object TimelineConfig { + const val MAX_READ_RECEIPT_TO_DISPLAY = 3 + + /** + * Event types that will be filtered out from the timeline (i.e. not displayed). + */ + val excludedEvents = listOf( + StateEventType.CALL_MEMBER, + StateEventType.ROOM_ALIASES, + StateEventType.ROOM_CANONICAL_ALIAS, + StateEventType.ROOM_GUEST_ACCESS, + StateEventType.ROOM_HISTORY_VISIBILITY, + StateEventType.ROOM_JOIN_RULES, + StateEventType.ROOM_POWER_LEVELS, + StateEventType.ROOM_SERVER_ACL, + StateEventType.ROOM_TOMBSTONE, + StateEventType.SPACE_CHILD, + StateEventType.SPACE_PARENT, + StateEventType.POLICY_RULE_ROOM, + StateEventType.POLICY_RULE_SERVER, + StateEventType.POLICY_RULE_USER, + ) +} diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/VoiceMessageConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/VoiceMessageConfig.kt new file mode 100644 index 0000000..b77b2dc --- /dev/null +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/VoiceMessageConfig.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appconfig + +import kotlin.time.Duration.Companion.minutes + +object VoiceMessageConfig { + val maxVoiceMessageDuration = 30.minutes +} diff --git a/appicon/element/build.gradle.kts b/appicon/element/build.gradle.kts new file mode 100644 index 0000000..23c21d4 --- /dev/null +++ b/appicon/element/build.gradle.kts @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.appicon.element" + + buildTypes { + register("nightly") + } +} diff --git a/appicon/element/src/debug/res/drawable/ic_launcher_background.xml b/appicon/element/src/debug/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d414479 --- /dev/null +++ b/appicon/element/src/debug/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + diff --git a/appicon/element/src/main/ic_launcher-playstore.png b/appicon/element/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..325bf57 Binary files /dev/null and b/appicon/element/src/main/ic_launcher-playstore.png differ diff --git a/appicon/element/src/main/kotlin/io/element/android/appicon/element/IconPreview.kt b/appicon/element/src/main/kotlin/io/element/android/appicon/element/IconPreview.kt new file mode 100644 index 0000000..bffcdae --- /dev/null +++ b/appicon/element/src/main/kotlin/io/element/android/appicon/element/IconPreview.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appicon.element + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview + +@Preview +@Composable +internal fun IconPreview() { + Box { + Image( + modifier = Modifier.matchParentSize(), + painter = painterResource(id = R.drawable.ic_launcher_background), + contentDescription = null, + ) + Image( + painter = painterResource(id = R.mipmap.ic_launcher_foreground), + contentDescription = null, + ) + } +} + +@Preview +@Composable +internal fun RoundIconPreview() { + Box(modifier = Modifier.clip(shape = CircleShape)) { + Image( + modifier = Modifier.matchParentSize(), + painter = painterResource(id = R.drawable.ic_launcher_background), + contentDescription = null, + ) + Image( + painter = painterResource(id = R.mipmap.ic_launcher_foreground), + contentDescription = null, + ) + } +} + +@Preview +@Composable +internal fun MonochromeIconPreview() { + Box( + modifier = Modifier + .background(Color(0xFF2F3133)), + ) { + Image( + painter = painterResource(id = R.mipmap.ic_launcher_monochrome), + colorFilter = ColorFilter.tint(Color(0xFFC3E0F6)), + contentDescription = null + ) + } +} diff --git a/appicon/element/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/appicon/element/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..9de193b --- /dev/null +++ b/appicon/element/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/appicon/element/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/appicon/element/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..9de193b --- /dev/null +++ b/appicon/element/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..2ae0da8 Binary files /dev/null and b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..e40370b Binary files /dev/null and b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..bcc8059 Binary files /dev/null and b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp differ diff --git a/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..8ad6b74 Binary files /dev/null and b/appicon/element/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..d4e1b90 Binary files /dev/null and b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..ac2361f Binary files /dev/null and b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..62e0fb8 Binary files /dev/null and b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp differ diff --git a/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..3cd52b2 Binary files /dev/null and b/appicon/element/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..527b238 Binary files /dev/null and b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..f8c5c5f Binary files /dev/null and b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..d7c1ffc Binary files /dev/null and b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp differ diff --git a/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1c98f35 Binary files /dev/null and b/appicon/element/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..ed524b8 Binary files /dev/null and b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..bb401bc Binary files /dev/null and b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..4f5c924 Binary files /dev/null and b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp differ diff --git a/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..a6b0547 Binary files /dev/null and b/appicon/element/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..359e392 Binary files /dev/null and b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..f0f9a63 Binary files /dev/null and b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..c3490dc Binary files /dev/null and b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp differ diff --git a/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..3612579 Binary files /dev/null and b/appicon/element/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/appicon/element/src/nightly/res/drawable/ic_launcher_background.xml b/appicon/element/src/nightly/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..dc27f37 --- /dev/null +++ b/appicon/element/src/nightly/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + diff --git a/appicon/element/src/release/res/drawable/ic_launcher_background.xml b/appicon/element/src/release/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..1cbabac --- /dev/null +++ b/appicon/element/src/release/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + diff --git a/appicon/enterprise/build.gradle.kts b/appicon/enterprise/build.gradle.kts new file mode 100644 index 0000000..bc5cdea --- /dev/null +++ b/appicon/enterprise/build.gradle.kts @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.appicon.enterprise" + + buildTypes { + register("nightly") + } +} diff --git a/appicon/enterprise/src/debug/res/drawable/ic_launcher_background_enterprise.xml b/appicon/enterprise/src/debug/res/drawable/ic_launcher_background_enterprise.xml new file mode 100644 index 0000000..d414479 --- /dev/null +++ b/appicon/enterprise/src/debug/res/drawable/ic_launcher_background_enterprise.xml @@ -0,0 +1,10 @@ + + + diff --git a/appicon/enterprise/src/main/kotlin/io/element/android/appicon/enterprise/IconPreview.kt b/appicon/enterprise/src/main/kotlin/io/element/android/appicon/enterprise/IconPreview.kt new file mode 100644 index 0000000..70e0201 --- /dev/null +++ b/appicon/enterprise/src/main/kotlin/io/element/android/appicon/enterprise/IconPreview.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appicon.enterprise + +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview + +@Preview +@Composable +internal fun IconPreview() { + Box { + Image(painter = painterResource(id = R.mipmap.ic_launcher_background_enterprise), contentDescription = null) + Image( + modifier = Modifier.align(Alignment.Center), + painter = painterResource(id = R.mipmap.ic_launcher_foreground_enterprise), + contentDescription = null, + ) + } +} + +@Preview +@Composable +internal fun RoundIconPreview() { + Box(modifier = Modifier.clip(shape = CircleShape)) { + Image(painter = painterResource(id = R.mipmap.ic_launcher_background_enterprise), contentDescription = null) + Image( + modifier = Modifier.align(Alignment.Center), + painter = painterResource(id = R.mipmap.ic_launcher_foreground_enterprise), + contentDescription = null, + ) + } +} diff --git a/appicon/enterprise/src/main/res/mipmap-anydpi/ic_launcher.xml b/appicon/enterprise/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..38ef234 --- /dev/null +++ b/appicon/enterprise/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/appicon/enterprise/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/appicon/enterprise/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..0fd0f91 --- /dev/null +++ b/appicon/enterprise/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/appicon/enterprise/src/main/res/mipmap-hdpi/ic_launcher_background_enterprise.webp b/appicon/enterprise/src/main/res/mipmap-hdpi/ic_launcher_background_enterprise.webp new file mode 100644 index 0000000..45f7756 Binary files /dev/null and b/appicon/enterprise/src/main/res/mipmap-hdpi/ic_launcher_background_enterprise.webp differ diff --git a/appicon/enterprise/src/main/res/mipmap-mdpi/ic_launcher_background_enterprise.webp b/appicon/enterprise/src/main/res/mipmap-mdpi/ic_launcher_background_enterprise.webp new file mode 100644 index 0000000..937dbf1 Binary files /dev/null and b/appicon/enterprise/src/main/res/mipmap-mdpi/ic_launcher_background_enterprise.webp differ diff --git a/appicon/enterprise/src/main/res/mipmap-xhdpi/ic_launcher_background_enterprise.webp b/appicon/enterprise/src/main/res/mipmap-xhdpi/ic_launcher_background_enterprise.webp new file mode 100644 index 0000000..f73e1d5 Binary files /dev/null and b/appicon/enterprise/src/main/res/mipmap-xhdpi/ic_launcher_background_enterprise.webp differ diff --git a/appicon/enterprise/src/main/res/mipmap-xxhdpi/ic_launcher_background_enterprise.webp b/appicon/enterprise/src/main/res/mipmap-xxhdpi/ic_launcher_background_enterprise.webp new file mode 100644 index 0000000..65027bb Binary files /dev/null and b/appicon/enterprise/src/main/res/mipmap-xxhdpi/ic_launcher_background_enterprise.webp differ diff --git a/appicon/enterprise/src/main/res/mipmap-xxhdpi/ic_launcher_foreground_enterprise.webp b/appicon/enterprise/src/main/res/mipmap-xxhdpi/ic_launcher_foreground_enterprise.webp new file mode 100644 index 0000000..90b491a Binary files /dev/null and b/appicon/enterprise/src/main/res/mipmap-xxhdpi/ic_launcher_foreground_enterprise.webp differ diff --git a/appicon/enterprise/src/main/res/mipmap-xxxhdpi/ic_launcher_background_enterprise.webp b/appicon/enterprise/src/main/res/mipmap-xxxhdpi/ic_launcher_background_enterprise.webp new file mode 100644 index 0000000..7ae770c Binary files /dev/null and b/appicon/enterprise/src/main/res/mipmap-xxxhdpi/ic_launcher_background_enterprise.webp differ diff --git a/appicon/enterprise/src/nightly/res/drawable/ic_launcher_background_enterprise.xml b/appicon/enterprise/src/nightly/res/drawable/ic_launcher_background_enterprise.xml new file mode 100644 index 0000000..dc27f37 --- /dev/null +++ b/appicon/enterprise/src/nightly/res/drawable/ic_launcher_background_enterprise.xml @@ -0,0 +1,10 @@ + + + diff --git a/appicon/enterprise/src/release/res/drawable/ic_launcher_background_enterprise.xml b/appicon/enterprise/src/release/res/drawable/ic_launcher_background_enterprise.xml new file mode 100644 index 0000000..161c685 --- /dev/null +++ b/appicon/enterprise/src/release/res/drawable/ic_launcher_background_enterprise.xml @@ -0,0 +1,2 @@ + diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts new file mode 100644 index 0000000..aa0bc04 --- /dev/null +++ b/appnav/build.gradle.kts @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:Suppress("UnstableApiUsage") + +import extension.allFeaturesApi +import extension.setupDependencyInjection +import extension.testCommonDependencies + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.appnav" +} + +setupDependencyInjection() + +dependencies { + allFeaturesApi(project) + + implementation(projects.libraries.core) + implementation(projects.libraries.accountselect.api) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.deeplink.api) + implementation(projects.libraries.featureflag.api) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.oidc.api) + implementation(projects.libraries.preferences.api) + implementation(projects.libraries.push.api) + implementation(projects.libraries.pushproviders.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.matrixmedia.api) + implementation(projects.libraries.uiCommon) + implementation(projects.libraries.uiStrings) + implementation(projects.features.login.api) + + implementation(libs.coil) + + implementation(projects.features.announcement.api) + implementation(projects.features.ftue.api) + implementation(projects.features.share.api) + + implementation(projects.services.apperror.impl) + implementation(projects.services.appnavstate.api) + implementation(projects.services.analytics.api) + + testCommonDependencies(libs) + testImplementation(projects.features.login.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.oidc.test) + testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.push.test) + testImplementation(projects.libraries.pushproviders.test) + testImplementation(projects.features.forward.test) + testImplementation(projects.features.networkmonitor.test) + testImplementation(projects.features.rageshake.test) + testImplementation(projects.services.appnavstate.impl) + testImplementation(projects.services.appnavstate.test) + testImplementation(projects.services.analytics.test) + testImplementation(projects.services.toolbox.test) +} 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 0000000..1185016 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/BackstackExt.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 BackStack.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 BackStack.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/LoggedInAppScopeFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt new file mode 100644 index 0000000..0dbcb0f --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(DelicateCoilApi::class) + +package io.element.android.appnav + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import coil3.SingletonImageLoader +import coil3.annotation.DelicateCoilApi +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.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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.appnav.di.SessionGraphFactory +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.DependencyInjectionGraphOwner +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import kotlinx.parcelize.Parcelize + +/** + * `LoggedInAppScopeFlowNode` is a Node responsible to set up the Session graph. + * [io.element.android.libraries.di.SessionScope]. It has only one child: [LoggedInFlowNode]. + * This allow to inject objects with SessionScope in the constructor of [LoggedInFlowNode]. + */ +@ContributesNode(AppScope::class) +@AssistedInject +class LoggedInAppScopeFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + sessionGraphFactory: SessionGraphFactory, + private val imageLoaderHolder: ImageLoaderHolder, +) : ParentNode( + navModel = PermanentNavModel( + navTargets = setOf(NavTarget), + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +), DependencyInjectionGraphOwner { + interface Callback : Plugin { + fun navigateToBugReport() + fun navigateToAddAccount() + } + + private val callback: Callback = callback() + + @Parcelize + object NavTarget : Parcelable + + data class Inputs( + val matrixClient: MatrixClient + ) : NodeInputs + + private val inputs: Inputs = inputs() + override val graph = sessionGraphFactory.create(inputs.matrixClient) + + override fun onBuilt() { + super.onBuilt() + lifecycle.subscribe( + onResume = { + SingletonImageLoader.setUnsafe(imageLoaderHolder.get(inputs.matrixClient)) + }, + ) + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + val callback = object : LoggedInFlowNode.Callback { + override fun navigateToBugReport() { + callback.navigateToBugReport() + } + + override fun navigateToAddAccount() { + callback.navigateToAddAccount() + } + } + return createNode(buildContext, listOf(callback)) + } + + suspend fun attachSession(): LoggedInFlowNode = waitForChildAttached() + + @Composable + override fun View(modifier: Modifier) { + Children( + navModel = navModel, + modifier = modifier, + ) + } +} 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 0000000..cee2296 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +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.ui.strings.CommonStrings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@Inject +class LoggedInEventProcessor( + private val snackbarDispatcher: SnackbarDispatcher, + private val roomMembershipObserver: RoomMembershipObserver, +) { + private var observingJob: Job? = null + + fun observeEvents(coroutineScope: CoroutineScope) { + observingJob = roomMembershipObserver.updates + .filter { !it.isUserInRoom } + .distinctUntilChanged() + .onEach { roomMemberShipUpdate -> + when (roomMemberShipUpdate.change) { + MembershipChange.LEFT -> { + displayMessage( + if (roomMemberShipUpdate.isSpace) { + CommonStrings.common_current_user_left_space + } else { + CommonStrings.common_current_user_left_room + } + ) + } + MembershipChange.INVITATION_REJECTED -> displayMessage(CommonStrings.common_current_user_rejected_invite) + MembershipChange.KNOCK_RETRACTED -> displayMessage(CommonStrings.common_current_user_canceled_knock) + else -> Unit + } + } + .launchIn(coroutineScope) + } + + fun stopObserving() { + observingJob?.cancel() + observingJob = null + } + + private 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 0000000..38dc39e --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -0,0 +1,633 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav + +import android.content.Intent +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.runtime.remember +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import com.bumble.appyx.core.composable.PermanentChild +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.NavElements +import com.bumble.appyx.core.navigation.NavKey +import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel +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.BackStack.State.ACTIVE +import com.bumble.appyx.navmodel.backstack.BackStack.State.CREATED +import com.bumble.appyx.navmodel.backstack.BackStack.State.STASHED +import com.bumble.appyx.navmodel.backstack.BackStackElement +import com.bumble.appyx.navmodel.backstack.BackStackElements +import com.bumble.appyx.navmodel.backstack.operation.BackStackOperation +import com.bumble.appyx.navmodel.backstack.operation.Push +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.appnav.loggedin.LoggedInNode +import io.element.android.appnav.loggedin.MediaPreviewConfigMigration +import io.element.android.appnav.loggedin.SendQueues +import io.element.android.appnav.room.RoomFlowNode +import io.element.android.appnav.room.RoomNavigationTarget +import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode +import io.element.android.compound.colors.SemanticColorsLightDark +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.api.SessionEnterpriseService +import io.element.android.features.ftue.api.FtueEntryPoint +import io.element.android.features.ftue.api.state.FtueService +import io.element.android.features.ftue.api.state.FtueState +import io.element.android.features.home.api.HomeEntryPoint +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer +import io.element.android.features.preferences.api.PreferencesEntryPoint +import io.element.android.features.roomdirectory.api.RoomDescription +import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint +import io.element.android.features.securebackup.api.SecureBackupEntryPoint +import io.element.android.features.share.api.ShareEntryPoint +import io.element.android.features.startchat.api.StartChatEntryPoint +import io.element.android.features.userprofile.api.UserProfileEntryPoint +import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.architecture.waitForChildAttached +import io.element.android.libraries.architecture.waitForNavTargetAttached +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.designsystem.theme.ElementThemeApp +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.annotations.SessionCoroutineScope +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.MAIN_SPACE +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener +import io.element.android.libraries.matrix.api.verification.VerificationRequest +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService +import io.element.android.libraries.ui.common.nodes.emptyNode +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.watchers.AnalyticsRoomListStateWatcher +import io.element.android.services.appnavstate.api.AppNavigationStateService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.parcelize.Parcelize +import timber.log.Timber +import java.time.Duration +import java.time.Instant +import java.util.Optional +import java.util.UUID +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toKotlinDuration +import im.vector.app.features.analytics.plan.JoinedRoom as JoinedRoomAnalyticsEvent + +@ContributesNode(SessionScope::class) +@AssistedInject +class LoggedInFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val homeEntryPoint: HomeEntryPoint, + private val preferencesEntryPoint: PreferencesEntryPoint, + private val startChatEntryPoint: StartChatEntryPoint, + private val appNavigationStateService: AppNavigationStateService, + private val secureBackupEntryPoint: SecureBackupEntryPoint, + private val userProfileEntryPoint: UserProfileEntryPoint, + private val ftueEntryPoint: FtueEntryPoint, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, + private val ftueService: FtueService, + private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint, + private val shareEntryPoint: ShareEntryPoint, + private val matrixClient: MatrixClient, + private val sendingQueue: SendQueues, + private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint, + private val mediaPreviewConfigMigration: MediaPreviewConfigMigration, + private val sessionEnterpriseService: SessionEnterpriseService, + private val networkMonitor: NetworkMonitor, + private val notificationConversationService: NotificationConversationService, + private val syncService: SyncService, + private val enterpriseService: EnterpriseService, + private val appPreferencesStore: AppPreferencesStore, + private val buildMeta: BuildMeta, + snackbarDispatcher: SnackbarDispatcher, + private val analyticsService: AnalyticsService, + private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Placeholder, + savedStateMap = buildContext.savedStateMap, + ), + permanentNavModel = PermanentNavModel( + navTargets = setOf(NavTarget.LoggedInPermanent), + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +) { + interface Callback : Plugin { + fun navigateToBugReport() + fun navigateToAddAccount() + } + + private val callback: Callback = callback() + private val loggedInFlowProcessor = LoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + roomMembershipObserver = matrixClient.roomMembershipObserver, + ) + + private val verificationListener = object : SessionVerificationServiceListener { + override fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) { + // Without this launch the rendering and actual state of this Appyx node's children gets out of sync, resulting in a crash. + // This might be because this method is called back from Rust in a background thread. + lifecycleScope.launch { + val receivedAt = Instant.now() + + // Wait until the app is in foreground to display the incoming verification request + appNavigationStateService.appNavigationState.first { it.isInForeground } + + // TODO there should also be a timeout for > 10 minutes elapsed since the request was created, but the SDK doesn't expose that info yet + val now = Instant.now() + val elapsedTimeSinceReceived = Duration.between(receivedAt, now).toKotlinDuration() + + // Discard the incoming verification request if it has timed out + if (elapsedTimeSinceReceived > 2.minutes) { + Timber.w("Incoming verification request ${verificationRequest.details.flowId} discarded due to timeout.") + return@launch + } + + // Wait for the RoomList UI to be ready so the incoming verification screen can be displayed on top of it + // Otherwise, the RoomList UI may be incorrectly displayed on top + withTimeout(5.seconds) { + backstack.elements.first { elements -> + elements.any { it.key.navTarget == NavTarget.Home } + } + } + + backstack.singleTop(NavTarget.IncomingVerificationRequest(verificationRequest)) + } + } + } + + override fun onBuilt() { + super.onBuilt() + lifecycleScope.launch { + sessionEnterpriseService.init() + } + lifecycle.subscribe( + onCreate = { + analyticsRoomListStateWatcher.start() + appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId) + // TODO We do not support Space yet, so directly navigate to main space + appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE) + loggedInFlowProcessor.observeEvents(sessionCoroutineScope) + matrixClient.sessionVerificationService.setListener(verificationListener) + mediaPreviewConfigMigration() + + sessionCoroutineScope.launch { + // Wait for the network to be connected before pre-fetching the max file upload size + networkMonitor.connectivity.first { networkStatus -> networkStatus == NetworkStatus.Connected } + matrixClient.getMaxFileUploadSize() + } + + analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.FirstRoomsDisplayed) + + ftueService.state + .onEach { ftueState -> + when (ftueState) { + is FtueState.Unknown -> Unit // Nothing to do + is FtueState.Incomplete -> backstack.safeRoot(NavTarget.Ftue) + is FtueState.Complete -> backstack.safeRoot(NavTarget.Home) + } + } + .launchIn(lifecycleScope) + }, + onResume = { + lifecycleScope.launch { + val availableRoomIds = matrixClient.getJoinedRoomIds().getOrNull() ?: return@launch + notificationConversationService.onAvailableRoomsChanged(sessionId = matrixClient.sessionId, roomIds = availableRoomIds) + } + }, + onDestroy = { + appNavigationStateService.onLeavingSpace(id) + appNavigationStateService.onLeavingSession(id) + loggedInFlowProcessor.stopObserving() + matrixClient.sessionVerificationService.setListener(null) + analyticsRoomListStateWatcher.stop() + } + ) + setupSendingQueue() + } + + private fun setupSendingQueue() { + sendingQueue.launchIn(lifecycleScope) + } + + sealed interface NavTarget : Parcelable { + @Parcelize + data object Placeholder : NavTarget + + @Parcelize + data object LoggedInPermanent : NavTarget + + @Parcelize + data object Home : NavTarget + + @Parcelize + data class Room( + val roomIdOrAlias: RoomIdOrAlias, + val serverNames: List = emptyList(), + val trigger: JoinedRoomAnalyticsEvent.Trigger? = null, + val roomDescription: RoomDescription? = null, + val initialElement: RoomNavigationTarget = RoomNavigationTarget.Root(), + val targetId: UUID = UUID.randomUUID(), + ) : NavTarget + + @Parcelize + data class UserProfile( + val userId: UserId, + ) : NavTarget + + @Parcelize + data class Settings( + val initialElement: PreferencesEntryPoint.InitialTarget = PreferencesEntryPoint.InitialTarget.Root + ) : NavTarget + + @Parcelize + data object CreateRoom : NavTarget + + @Parcelize + data class SecureBackup( + val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root + ) : NavTarget + + @Parcelize + data object Ftue : NavTarget + + @Parcelize + data object RoomDirectory : NavTarget + + @Parcelize + data class IncomingShare(val intent: Intent) : NavTarget + + @Parcelize + data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Placeholder -> emptyNode(buildContext) + NavTarget.LoggedInPermanent -> { + val callback = object : LoggedInNode.Callback { + override fun navigateToNotificationTroubleshoot() { + backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationTroubleshoot)) + } + } + createNode(buildContext, listOf(callback)) + } + NavTarget.Home -> { + val callback = object : HomeEntryPoint.Callback { + override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) { + backstack.push( + NavTarget.Room( + roomIdOrAlias = roomId.toRoomIdOrAlias(), + initialElement = RoomNavigationTarget.Root(joinedRoom = joinedRoom) + ) + ) + } + + override fun navigateToSettings() { + backstack.push(NavTarget.Settings()) + } + + override fun navigateToCreateRoom() { + backstack.push(NavTarget.CreateRoom) + } + + override fun navigateToSetUpRecovery() { + backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.Root)) + } + + override fun navigateToEnterRecoveryKey() { + backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey)) + } + + override fun navigateToRoomSettings(roomId: RoomId) { + backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Details)) + } + + override fun navigateToBugReport() { + callback.navigateToBugReport() + } + } + homeEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + callback = callback, + ) + } + is NavTarget.Room -> { + val joinedRoomCallback = object : JoinedRoomLoadedFlowNode.Callback { + override fun navigateToRoom(roomId: RoomId, serverNames: List) { + backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), serverNames)) + } + + override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) { + when (data) { + is PermalinkData.UserLink -> { + // Should not happen (handled by MessagesNode) + Timber.e("User link clicked: ${data.userId}.") + } + is PermalinkData.RoomLink -> { + val target = NavTarget.Room( + roomIdOrAlias = data.roomIdOrAlias, + serverNames = data.viaParameters, + trigger = JoinedRoomAnalyticsEvent.Trigger.Timeline, + initialElement = RoomNavigationTarget.Root(data.eventId), + ) + if (pushToBackstack) { + backstack.push(target) + } else { + backstack.replace(target) + } + } + is PermalinkData.FallbackLink, + is PermalinkData.RoomEmailInviteLink -> { + // Should not happen (handled by MessagesNode) + } + } + } + + override fun navigateToGlobalNotificationSettings() { + backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings)) + } + } + val inputs = RoomFlowNode.Inputs( + roomIdOrAlias = navTarget.roomIdOrAlias, + roomDescription = Optional.ofNullable(navTarget.roomDescription), + serverNames = navTarget.serverNames, + trigger = Optional.ofNullable(navTarget.trigger), + initialElement = navTarget.initialElement + ) + createNode(buildContext, plugins = listOf(inputs, joinedRoomCallback)) + } + is NavTarget.UserProfile -> { + val callback = object : UserProfileEntryPoint.Callback { + override fun navigateToRoom(roomId: RoomId) { + backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias())) + } + } + userProfileEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = UserProfileEntryPoint.Params(userId = navTarget.userId), + callback = callback, + ) + } + is NavTarget.Settings -> { + val callback = object : PreferencesEntryPoint.Callback { + override fun navigateToAddAccount() { + callback.navigateToAddAccount() + } + + override fun navigateToBugReport() { + callback.navigateToBugReport() + } + + override fun navigateToSecureBackup() { + backstack.push(NavTarget.SecureBackup()) + } + + override fun navigateToRoomNotificationSettings(roomId: RoomId) { + backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.NotificationSettings)) + } + + override fun navigateToEvent(roomId: RoomId, eventId: EventId) { + backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Root(eventId))) + } + } + val inputs = PreferencesEntryPoint.Params(navTarget.initialElement) + preferencesEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = inputs, + callback = callback, + ) + } + NavTarget.CreateRoom -> { + val callback = object : StartChatEntryPoint.Callback { + override fun onRoomCreated(roomIdOrAlias: RoomIdOrAlias, serverNames: List) { + backstack.replace(NavTarget.Room(roomIdOrAlias = roomIdOrAlias, serverNames = serverNames)) + } + + override fun navigateToRoomDirectory() { + backstack.push(NavTarget.RoomDirectory) + } + } + + startChatEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + callback = callback, + ) + } + is NavTarget.SecureBackup -> { + secureBackupEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement), + callback = object : SecureBackupEntryPoint.Callback { + override fun onDone() { + backstack.pop() + } + }, + ) + } + NavTarget.Ftue -> { + ftueEntryPoint.createNode(this, buildContext) + } + NavTarget.RoomDirectory -> { + roomDirectoryEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + callback = object : RoomDirectoryEntryPoint.Callback { + override fun navigateToRoom(roomDescription: RoomDescription) { + backstack.push( + NavTarget.Room( + roomIdOrAlias = roomDescription.roomId.toRoomIdOrAlias(), + roomDescription = roomDescription, + trigger = JoinedRoomAnalyticsEvent.Trigger.RoomDirectory, + ) + ) + } + }, + ) + } + is NavTarget.IncomingShare -> { + shareEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = ShareEntryPoint.Params(intent = navTarget.intent), + callback = object : ShareEntryPoint.Callback { + override fun onDone(roomIds: List) { + backstack.pop() + roomIds.singleOrNull()?.let { roomId -> + backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias())) + } + } + }, + ) + } + is NavTarget.IncomingVerificationRequest -> { + incomingVerificationEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = IncomingVerificationEntryPoint.Params(navTarget.data), + callback = object : IncomingVerificationEntryPoint.Callback { + override fun onDone() { + backstack.pop() + } + }, + ) + } + } + } + + suspend fun attachRoom( + roomIdOrAlias: RoomIdOrAlias, + serverNames: List = emptyList(), + trigger: JoinedRoomAnalyticsEvent.Trigger? = null, + eventId: EventId? = null, + clearBackstack: Boolean, + ): RoomFlowNode { + waitForNavTargetAttached { navTarget -> + navTarget is NavTarget.Home + } + attachChild { + val roomNavTarget = NavTarget.Room( + roomIdOrAlias = roomIdOrAlias, + serverNames = serverNames, + trigger = trigger, + initialElement = RoomNavigationTarget.Root(eventId = eventId) + ) + backstack.accept(AttachRoomOperation(roomNavTarget, clearBackstack)) + } + + // If we don't do this check, we might be returning while a previous node with the same type is still displayed + // This means we may attach some new nodes to that one, which will be quickly replaced by the one instantiated above + return waitForChildAttached { + it is NavTarget.Room && + it.roomIdOrAlias == roomIdOrAlias && + it.initialElement is RoomNavigationTarget.Root && + it.initialElement.eventId == eventId + } + } + + suspend fun attachUser(userId: UserId) { + waitForNavTargetAttached { navTarget -> + navTarget is NavTarget.Home + } + attachChild { + backstack.push( + NavTarget.UserProfile( + userId = userId, + ) + ) + } + } + + internal suspend fun attachIncomingShare(intent: Intent) { + waitForNavTargetAttached { navTarget -> + navTarget is NavTarget.Home + } + attachChild { + backstack.push( + NavTarget.IncomingShare(intent) + ) + } + } + + @Composable + override fun View(modifier: Modifier) { + val colors by remember { + enterpriseService.semanticColorsFlow(sessionId = matrixClient.sessionId) + }.collectAsState(SemanticColorsLightDark.default) + ElementThemeApp( + appPreferencesStore = appPreferencesStore, + compoundLight = colors.light, + compoundDark = colors.dark, + buildMeta = buildMeta, + ) { + val isOnline by syncService.isOnline.collectAsState() + ConnectivityIndicatorContainer( + isOnline = isOnline, + modifier = modifier, + ) { contentModifier -> + Box(modifier = contentModifier) { + val ftueState by ftueService.state.collectAsState() + BackstackView() + if (ftueState is FtueState.Complete) { + PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent) + } + } + } + } + } +} + +@Parcelize +private class AttachRoomOperation( + val roomTarget: LoggedInFlowNode.NavTarget.Room, + val clearBackstack: Boolean, +) : BackStackOperation { + override fun isApplicable(elements: NavElements) = true + + override fun invoke(elements: BackStackElements): BackStackElements { + return if (clearBackstack) { + // Makes sure the room list target is alone in the backstack and stashed + elements.mapNotNull { element -> + if (element.key.navTarget == LoggedInFlowNode.NavTarget.Home) { + element.transitionTo(STASHED, this) + } else { + null + } + } + BackStackElement( + key = NavKey(roomTarget), + fromState = CREATED, + targetState = ACTIVE, + operation = this + ) + } else { + Push(roomTarget).invoke(elements) + } + } +} 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 0000000..080f3c1 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(DelicateCoilApi::class) + +package io.element.android.appnav + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import coil3.SingletonImageLoader +import coil3.annotation.DelicateCoilApi +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.login.api.LoginEntryPoint +import io.element.android.features.login.api.LoginParams +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices +import io.element.android.libraries.designsystem.utils.ScreenOrientation +import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher +import kotlinx.parcelize.Parcelize + +@ContributesNode(AppScope::class) +@AssistedInject +class NotLoggedInFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val loginEntryPoint: LoginEntryPoint, + private val imageLoaderHolder: ImageLoaderHolder, + private val analyticsColdStartWatcher: AnalyticsColdStartWatcher, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap + ), + buildContext = buildContext, + plugins = plugins, +) { + data class Params( + val loginParams: LoginParams?, + ) : NodeInputs + + interface Callback : Plugin { + fun navigateToBugReport() + fun onDone() + } + + private val callback: Callback = callback() + private val inputs = inputs() + + override fun onBuilt() { + super.onBuilt() + analyticsColdStartWatcher.whenLoggingIn() + lifecycle.subscribe( + onResume = { + SingletonImageLoader.setUnsafe(imageLoaderHolder.get()) + }, + ) + } + + sealed interface NavTarget : Parcelable { + @Parcelize + data object Root : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Root -> { + val callback = object : LoginEntryPoint.Callback { + override fun navigateToBugReport() { + callback.navigateToBugReport() + } + + override fun onDone() { + callback.onDone() + } + } + loginEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = LoginEntryPoint.Params( + accountProvider = inputs.loginParams?.accountProvider, + loginHint = inputs.loginParams?.loginHint, + ), + callback = callback, + ) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + // The login flow doesn't support landscape mode on mobile devices yet + ForceOrientationInMobileDevices(orientation = ScreenOrientation.PORTRAIT) + + BackstackView() + } +} 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 0000000..4687946 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -0,0 +1,478 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav + +import android.content.Intent +import android.os.Parcelable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +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.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 com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader +import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.annotations.ContributesNode +import io.element.android.appnav.di.MatrixSessionCache +import io.element.android.appnav.intent.IntentResolver +import io.element.android.appnav.intent.ResolvedIntent +import io.element.android.appnav.room.RoomFlowNode +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.announcement.api.AnnouncementService +import io.element.android.features.login.api.LoginParams +import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl +import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint +import io.element.android.features.signedout.api.SignedOutEntryPoint +import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.appyx.rememberDelegateTransitionHandler +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.architecture.waitForChildAttached +import io.element.android.libraries.core.uri.ensureProtocol +import io.element.android.libraries.deeplink.api.DeeplinkData +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +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.core.ThreadId +import io.element.android.libraries.matrix.api.core.asEventId +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.oidc.api.OidcAction +import io.element.android.libraries.oidc.api.OidcActionFlow +import io.element.android.libraries.sessionstorage.api.LoggedInState +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.ui.common.nodes.emptyNode +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher +import io.element.android.services.appnavstate.api.ROOM_OPENED_FROM_NOTIFICATION +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import timber.log.Timber + +@ContributesNode(AppScope::class) +@AssistedInject +class RootFlowNode( + @Assisted val buildContext: BuildContext, + @Assisted plugins: List, + private val sessionStore: SessionStore, + private val accountProviderAccessControl: AccountProviderAccessControl, + private val navStateFlowFactory: RootNavStateFlowFactory, + private val matrixSessionCache: MatrixSessionCache, + private val presenter: RootPresenter, + private val bugReportEntryPoint: BugReportEntryPoint, + private val signedOutEntryPoint: SignedOutEntryPoint, + private val accountSelectEntryPoint: AccountSelectEntryPoint, + private val intentResolver: IntentResolver, + private val oidcActionFlow: OidcActionFlow, + private val featureFlagService: FeatureFlagService, + private val announcementService: AnnouncementService, + private val analyticsService: AnalyticsService, + private val analyticsColdStartWatcher: AnalyticsColdStartWatcher, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.SplashScreen, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +) { + override fun onBuilt() { + analyticsColdStartWatcher.start() + matrixSessionCache.restoreWithSavedState(buildContext.savedStateMap) + super.onBuilt() + observeNavState() + } + + override fun onSaveInstanceState(state: MutableSavedStateMap) { + super.onSaveInstanceState(state) + matrixSessionCache.saveIntoSavedState(state) + navStateFlowFactory.saveIntoSavedState(state) + } + + private fun observeNavState() { + navStateFlowFactory.create(buildContext.savedStateMap).distinctUntilChanged().onEach { navState -> + Timber.v("navState=$navState") + when (navState.loggedInState) { + is LoggedInState.LoggedIn -> { + if (navState.loggedInState.isTokenValid) { + tryToRestoreLatestSession( + onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) }, + onFailure = { switchToNotLoggedInFlow(null) } + ) + } else { + switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId)) + } + } + LoggedInState.NotLoggedIn -> { + switchToNotLoggedInFlow(null) + } + } + }.launchIn(lifecycleScope) + } + + private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) { + backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId)) + } + + private fun switchToNotLoggedInFlow(params: LoginParams?) { + matrixSessionCache.removeAll() + backstack.safeRoot(NavTarget.NotLoggedInFlow(params)) + } + + private fun switchToSignedOutFlow(sessionId: SessionId) { + backstack.safeRoot(NavTarget.SignedOutFlow(sessionId)) + } + + private suspend fun restoreSessionIfNeeded( + sessionId: SessionId, + onFailure: () -> Unit, + onSuccess: (SessionId) -> Unit, + ) { + matrixSessionCache.getOrRestore(sessionId).onSuccess { + Timber.v("Succeed to restore session $sessionId") + onSuccess(sessionId) + }.onFailure { + Timber.e(it, "Failed to restore session $sessionId") + onFailure() + } + } + + private suspend fun tryToRestoreLatestSession( + onSuccess: (SessionId) -> Unit, onFailure: () -> Unit + ) { + val latestSessionId = sessionStore.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, + ) { + val backstackSlider = rememberBackstackSlider( + transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) }, + ) + val backstackFader = rememberBackstackFader( + transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) }, + ) + val transitionHandler = rememberDelegateTransitionHandler { navTarget -> + when (navTarget) { + is NavTarget.SplashScreen, + is NavTarget.LoggedInFlow -> backstackFader + else -> backstackSlider + } + } + BackstackView(transitionHandler = transitionHandler) + announcementService.Render(Modifier) + } + } + + sealed interface NavTarget : Parcelable { + @Parcelize data object SplashScreen : NavTarget + + @Parcelize data class AccountSelect( + val currentSessionId: SessionId, + val intent: Intent?, + val permalinkData: PermalinkData?, + ) : NavTarget + + @Parcelize data class NotLoggedInFlow( + val params: LoginParams? + ) : NavTarget + + @Parcelize data class LoggedInFlow( + val sessionId: SessionId, val navId: Int + ) : NavTarget + + @Parcelize data class SignedOutFlow( + val sessionId: SessionId + ) : NavTarget + + @Parcelize data object BugReport : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.LoggedInFlow -> { + val matrixClient = matrixSessionCache.getOrNull(navTarget.sessionId) + ?: return emptyNode(buildContext).also { + Timber.w("Couldn't find any session, go through SplashScreen") + } + val inputs = LoggedInAppScopeFlowNode.Inputs(matrixClient) + val callback = object : LoggedInAppScopeFlowNode.Callback { + override fun navigateToBugReport() { + backstack.push(NavTarget.BugReport) + } + + override fun navigateToAddAccount() { + backstack.push(NavTarget.NotLoggedInFlow(null)) + } + } + createNode(buildContext, plugins = listOf(inputs, callback)) + } + is NavTarget.NotLoggedInFlow -> { + val callback = object : NotLoggedInFlowNode.Callback { + override fun navigateToBugReport() { + backstack.push(NavTarget.BugReport) + } + + override fun onDone() { + backstack.pop() + } + } + val params = NotLoggedInFlowNode.Params( + loginParams = navTarget.params, + ) + createNode(buildContext, plugins = listOf(params, callback)) + } + is NavTarget.SignedOutFlow -> { + signedOutEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = SignedOutEntryPoint.Params( + sessionId = navTarget.sessionId, + ), + ) + } + NavTarget.SplashScreen -> emptyNode(buildContext) + NavTarget.BugReport -> { + val callback = object : BugReportEntryPoint.Callback { + override fun onDone() { + backstack.pop() + } + } + bugReportEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + callback = callback, + ) + } + is NavTarget.AccountSelect -> { + val callback: AccountSelectEntryPoint.Callback = object : AccountSelectEntryPoint.Callback { + override fun onAccountSelected(sessionId: SessionId) { + lifecycleScope.launch { + if (sessionId == navTarget.currentSessionId) { + // Ensure that the account selection Node is removed from the backstack + // Do not pop when the account is changed to avoid a UI flicker. + backstack.pop() + } + attachSession(sessionId).apply { + if (navTarget.intent != null) { + attachIncomingShare(navTarget.intent) + } else if (navTarget.permalinkData != null) { + attachPermalinkData(navTarget.permalinkData) + } + } + } + } + + override fun onCancel() { + backstack.pop() + } + } + accountSelectEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + callback = callback, + ) + } + } + } + + suspend fun handleIntent(intent: Intent) { + val resolvedIntent = intentResolver.resolve(intent) ?: return + when (resolvedIntent) { + is ResolvedIntent.Navigation -> { + val openingRoomFromNotification = intent.getBooleanExtra(ROOM_OPENED_FROM_NOTIFICATION, false) + if (openingRoomFromNotification && resolvedIntent.deeplinkData is DeeplinkData.Room) { + analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.NotificationTapOpensTimeline) + } + navigateTo(resolvedIntent.deeplinkData) + } + is ResolvedIntent.Login -> onLoginLink(resolvedIntent.params) + is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction) + is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData) + is ResolvedIntent.IncomingShare -> onIncomingShare(resolvedIntent.intent) + } + } + + private suspend fun onLoginLink(params: LoginParams) { + if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) { + // Is there a session already? + val sessions = sessionStore.getAllSessions() + if (sessions.isNotEmpty()) { + if (featureFlagService.isFeatureEnabled(FeatureFlags.MultiAccount)) { + val loginHintMatrixId = params.loginHint?.removePrefix("mxid:") + val existingAccount = sessions.find { it.userId == loginHintMatrixId } + if (existingAccount != null) { + // We have an existing account matching the login hint, ensure this is the current session + sessionStore.setLatestSession(existingAccount.userId) + } else { + val latestSessionId = sessions.maxBy { it.lastUsageIndex }.userId + attachSession(SessionId(latestSessionId)) + backstack.push(NavTarget.NotLoggedInFlow(params)) + } + } else { + Timber.w("Login link ignored, multi account is disabled") + } + } else { + switchToNotLoggedInFlow(params) + } + } else { + Timber.w("Login link ignored, we are not allowed to connect to the homeserver") + } + } + + private suspend fun onIncomingShare(intent: Intent) { + // Is there a session already? + val latestSessionId = sessionStore.getLatestSessionId() + if (latestSessionId == null) { + // No session, open login + switchToNotLoggedInFlow(null) + } else { + // wait for the current session to be restored + val loggedInFlowNode = attachSession(latestSessionId) + if (sessionStore.numberOfSessions() > 1) { + // Several accounts, let the user choose which one to use + backstack.push( + NavTarget.AccountSelect( + currentSessionId = latestSessionId, + intent = intent, + permalinkData = null, + ) + ) + } else { + // Only one account, directly attach the incoming share node. + loggedInFlowNode.attachIncomingShare(intent) + } + } + } + + private suspend fun navigateTo(permalinkData: PermalinkData) { + Timber.d("Navigating to $permalinkData") + // Is there a session already? + val latestSessionId = sessionStore.getLatestSessionId() + if (latestSessionId == null) { + // No session, open login + switchToNotLoggedInFlow(null) + } else { + // wait for the current session to be restored + val loggedInFlowNode = attachSession(latestSessionId) + when (permalinkData) { + is PermalinkData.FallbackLink -> Unit + is PermalinkData.RoomEmailInviteLink -> Unit + else -> { + if (sessionStore.numberOfSessions() > 1) { + // Several accounts, let the user choose which one to use + backstack.push( + NavTarget.AccountSelect( + currentSessionId = latestSessionId, + intent = null, + permalinkData = permalinkData, + ) + ) + } else { + // Only one account, directly attach the room or the user node. + loggedInFlowNode.attachPermalinkData(permalinkData) + } + } + } + } + } + + private suspend fun LoggedInFlowNode.attachPermalinkData(permalinkData: PermalinkData) { + when (permalinkData) { + is PermalinkData.FallbackLink -> Unit + is PermalinkData.RoomEmailInviteLink -> Unit + is PermalinkData.RoomLink -> { + // If there is a thread id, focus on it in the main timeline + val focusedEventId = if (permalinkData.threadId != null) { + permalinkData.threadId?.asEventId() + } else { + permalinkData.eventId + } + attachRoom( + roomIdOrAlias = permalinkData.roomIdOrAlias, + trigger = JoinedRoom.Trigger.MobilePermalink, + serverNames = permalinkData.viaParameters, + eventId = focusedEventId, + clearBackstack = true + ).maybeAttachThread(permalinkData.threadId, permalinkData.eventId) + } + is PermalinkData.UserLink -> { + attachUser(permalinkData.userId) + } + } + } + + private suspend fun RoomFlowNode.maybeAttachThread(threadId: ThreadId?, focusedEventId: EventId?) { + if (threadId != null) { + attachThread(threadId, focusedEventId) + } + } + + private suspend fun navigateTo(deeplinkData: DeeplinkData) { + Timber.d("Navigating to $deeplinkData") + attachSession(deeplinkData.sessionId).let { loggedInFlowNode -> + when (deeplinkData) { + is DeeplinkData.Root -> Unit // The room list will always be shown, observing FtueState + is DeeplinkData.Room -> { + loggedInFlowNode.attachRoom( + roomIdOrAlias = deeplinkData.roomId.toRoomIdOrAlias(), + eventId = if (deeplinkData.threadId != null) deeplinkData.threadId?.asEventId() else deeplinkData.eventId, + clearBackstack = true, + ).maybeAttachThread(deeplinkData.threadId, deeplinkData.eventId) + } + } + } + } + + private fun onOidcAction(oidcAction: OidcAction) { + oidcActionFlow.post(oidcAction) + } + + private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode { + // Ensure that the session is the latest one + sessionStore.setLatestSession(sessionId.value) + return waitForChildAttached { navTarget -> + navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId + }.attachSession() + } +} + +private suspend fun SessionStore.getLatestSessionId() = getLatestSession()?.userId?.let(::SessionId) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt new file mode 100644 index 0000000..4af64cb --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.di + +import androidx.annotation.VisibleForTesting +import com.bumble.appyx.core.state.MutableSavedStateMap +import com.bumble.appyx.core.state.SavedStateMap +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.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 + +private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHolder.SaveInstanceKey" + +/** + * In-memory cache for logged in Matrix sessions. + * + * This component contains both the [MatrixClient] and the [SyncOrchestrator] for each session. + */ +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class MatrixSessionCache( + private val authenticationService: MatrixAuthenticationService, + private val syncOrchestratorFactory: SyncOrchestrator.Factory, +) : MatrixClientProvider { + private val sessionIdsToMatrixSession = ConcurrentHashMap() + private val restoreMutex = Mutex() + + init { + authenticationService.listenToNewMatrixClients { matrixClient -> + onNewMatrixClient(matrixClient) + } + } + + fun removeAll() { + sessionIdsToMatrixSession.clear() + } + + fun remove(sessionId: SessionId) { + sessionIdsToMatrixSession.remove(sessionId) + } + + override fun getOrNull(sessionId: SessionId): MatrixClient? { + return sessionIdsToMatrixSession[sessionId]?.matrixClient + } + + override suspend fun getOrRestore(sessionId: SessionId): Result { + return restoreMutex.withLock { + when (val cached = getOrNull(sessionId)) { + null -> restore(sessionId) + else -> Result.success(cached) + } + } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun getSyncOrchestrator(sessionId: SessionId): SyncOrchestrator? { + return sessionIdsToMatrixSession[sessionId]?.syncOrchestrator + } + + @Suppress("UNCHECKED_CAST") + fun restoreWithSavedState(state: SavedStateMap?) { + Timber.d("Restore state") + if (state == null || sessionIdsToMatrixSession.isNotEmpty()) { + Timber.w("Restore with non-empty map") + return + } + val sessionIds = state[SAVE_INSTANCE_KEY] as? Array + 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 -> + getOrRestore(sessionId) + } + } + } + + fun saveIntoSavedState(state: MutableSavedStateMap) { + val sessionKeys = sessionIdsToMatrixSession.keys.toTypedArray() + Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}") + state[SAVE_INSTANCE_KEY] = sessionKeys + } + + private suspend fun restore(sessionId: SessionId): Result { + Timber.d("Restore matrix session: $sessionId") + return authenticationService.restoreSession(sessionId) + .onSuccess { matrixClient -> + onNewMatrixClient(matrixClient) + } + .onFailure { + Timber.e(it, "Fail to restore session") + } + } + + private fun onNewMatrixClient(matrixClient: MatrixClient) { + val syncOrchestrator = syncOrchestratorFactory.create( + syncService = matrixClient.syncService, + sessionCoroutineScope = matrixClient.sessionCoroutineScope, + ) + sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession( + matrixClient = matrixClient, + syncOrchestrator = syncOrchestrator, + ) + syncOrchestrator.start() + } +} + +private data class InMemoryMatrixSession( + val matrixClient: MatrixClient, + val syncOrchestrator: SyncOrchestrator, +) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/RoomGraphFactory.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/RoomGraphFactory.kt new file mode 100644 index 0000000..2d5244c --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/RoomGraphFactory.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.di + +import io.element.android.libraries.matrix.api.room.JoinedRoom + +fun interface RoomGraphFactory { + fun create(room: JoinedRoom): Any +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/SessionGraphFactory.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/SessionGraphFactory.kt new file mode 100644 index 0000000..bc39604 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/SessionGraphFactory.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.di + +import io.element.android.libraries.matrix.api.MatrixClient + +interface SessionGraphFactory { + fun create(client: MatrixClient): Any +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt new file mode 100644 index 0000000..53ce50a --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.di + +import androidx.annotation.VisibleForTesting +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.coroutine.childScope +import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.recordTransaction +import io.element.android.services.appnavstate.api.AppForegroundStateService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@AssistedInject +class SyncOrchestrator( + @Assisted private val syncService: SyncService, + @Assisted sessionCoroutineScope: CoroutineScope, + private val appForegroundStateService: AppForegroundStateService, + private val networkMonitor: NetworkMonitor, + dispatchers: CoroutineDispatchers, + private val analyticsService: AnalyticsService, +) { + @AssistedFactory + interface Factory { + fun create( + syncService: SyncService, + sessionCoroutineScope: CoroutineScope, + ): SyncOrchestrator + } + + private val tag = "SyncOrchestrator" + + private val coroutineScope = sessionCoroutineScope.childScope(dispatchers.io, tag) + + private val started = AtomicBoolean(false) + + /** + * Starting observing the app state and network state to start/stop the sync service. + * + * Before observing the state, a first attempt at starting the sync service will happen if it's not already running. + */ + fun start() { + if (!started.compareAndSet(false, true)) { + Timber.tag(tag).d("already started, exiting early") + return + } + + coroutineScope.launch { + // Perform an initial sync if the sync service is not running, to check whether the homeserver is accessible + // Otherwise, if the device is offline the sync service will never start and the SyncState will be Idle, not Offline + Timber.tag(tag).d("performing initial sync attempt") + analyticsService.recordTransaction("First sync", "syncService.startSync()") { transaction -> + syncService.startSync() + + // Wait until the sync service is not idle, either it will be running or in error/offline state + val firstState = syncService.syncState.first { it != SyncState.Idle } + transaction.setData("first_sync_state", firstState.name) + } + + observeStates() + } + } + + @OptIn(FlowPreview::class) + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun observeStates() = coroutineScope.launch { + Timber.tag(tag).d("start observing the app and network state") + + val isAppActiveFlow = combine( + appForegroundStateService.isInForeground, + appForegroundStateService.isInCall, + appForegroundStateService.isSyncingNotificationEvent, + appForegroundStateService.hasRingingCall, + ) { isInForeground, isInCall, isSyncingNotificationEvent, hasRingingCall -> + isInForeground || isInCall || isSyncingNotificationEvent || hasRingingCall + } + + combine( + // small debounce to avoid spamming startSync when the state is changing quickly in case of error. + syncService.syncState.debounce(100.milliseconds), + networkMonitor.connectivity, + isAppActiveFlow, + ) { syncState, networkState, isAppActive -> + val isNetworkAvailable = networkState == NetworkStatus.Connected + + Timber.tag(tag).d("isAppActive=$isAppActive, isNetworkAvailable=$isNetworkAvailable") + if (syncState == SyncState.Running && !isAppActive) { + SyncStateAction.StopSync + } else if (syncState == SyncState.Idle && isAppActive && isNetworkAvailable) { + SyncStateAction.StartSync + } else { + SyncStateAction.NoOp + } + } + .distinctUntilChanged() + .debounce { action -> + // Don't stop the sync immediately, wait a bit to avoid starting/stopping the sync too often + if (action == SyncStateAction.StopSync) 3.seconds else 0.seconds + } + .onCompletion { + Timber.tag(tag).d("has been stopped") + } + .collect { action -> + when (action) { + SyncStateAction.StartSync -> { + syncService.startSync() + } + SyncStateAction.StopSync -> { + syncService.stopSync() + } + SyncStateAction.NoOp -> Unit + } + } + } +} + +private enum class SyncStateAction { + StartSync, + StopSync, + NoOp, +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt new file mode 100644 index 0000000..cb78760 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.di + +import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider +import io.element.android.libraries.matrix.api.timeline.TimelineProvider + +interface TimelineBindings { + val timelineProvider: TimelineProvider + val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider +} 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 0000000..3e26130 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.intent + +import android.content.Intent +import dev.zacsweers.metro.Inject +import io.element.android.features.login.api.LoginIntentResolver +import io.element.android.features.login.api.LoginParams +import io.element.android.libraries.deeplink.api.DeeplinkData +import io.element.android.libraries.deeplink.api.DeeplinkParser +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.oidc.api.OidcAction +import io.element.android.libraries.oidc.api.OidcIntentResolver +import timber.log.Timber + +sealed interface ResolvedIntent { + data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent + data class Oidc(val oidcAction: OidcAction) : ResolvedIntent + data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent + data class Login(val params: LoginParams) : ResolvedIntent + data class IncomingShare(val intent: Intent) : ResolvedIntent +} + +@Inject +class IntentResolver( + private val deeplinkParser: DeeplinkParser, + private val loginIntentResolver: LoginIntentResolver, + private val oidcIntentResolver: OidcIntentResolver, + private val permalinkParser: PermalinkParser, +) { + fun resolve(intent: Intent): ResolvedIntent? { + if (intent.canBeIgnored()) return null + + // Coming from a notification? + val deepLinkData = deeplinkParser.getFromIntent(intent) + if (deepLinkData != null) return ResolvedIntent.Navigation(deepLinkData) + + // Coming during login using Oidc? + val oidcAction = oidcIntentResolver.resolve(intent) + if (oidcAction != null) return ResolvedIntent.Oidc(oidcAction) + + val actionViewData = intent + .takeIf { it.action == Intent.ACTION_VIEW } + ?.dataString + + // Mobile configuration link clicked? (mobile.element.io) + val mobileLoginData = actionViewData + ?.let { loginIntentResolver.parse(it) } + if (mobileLoginData != null) return ResolvedIntent.Login(mobileLoginData) + + // External link clicked? (matrix.to, element.io, etc.) + val permalinkData = actionViewData + ?.let { permalinkParser.parse(it) } + ?.takeIf { it !is PermalinkData.FallbackLink } + if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData) + + if (intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) { + return ResolvedIntent.IncomingShare(intent) + } + + // Unknown intent + Timber.w("Unknown intent") + return null + } +} + +private fun Intent.canBeIgnored(): Boolean { + return action == Intent.ACTION_MAIN && + categories?.contains(Intent.CATEGORY_LAUNCHER) == true +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/AnalyticsVerificationStateExt.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/AnalyticsVerificationStateExt.kt new file mode 100644 index 0000000..714645d --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/AnalyticsVerificationStateExt.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.loggedin + +import im.vector.app.features.analytics.plan.CryptoSessionStateChange +import im.vector.app.features.analytics.plan.UserProperties +import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus + +fun SessionVerifiedStatus.toAnalyticsUserPropertyValue(): UserProperties.VerificationState? { + return when (this) { + // we don't need to report transient states + SessionVerifiedStatus.Unknown -> null + SessionVerifiedStatus.NotVerified -> UserProperties.VerificationState.NotVerified + SessionVerifiedStatus.Verified -> UserProperties.VerificationState.Verified + } +} + +fun RecoveryState.toAnalyticsUserPropertyValue(): UserProperties.RecoveryState? { + return when (this) { + RecoveryState.ENABLED -> UserProperties.RecoveryState.Enabled + RecoveryState.DISABLED -> UserProperties.RecoveryState.Disabled + RecoveryState.INCOMPLETE -> UserProperties.RecoveryState.Incomplete + // we don't need to report transient states + else -> null + } +} +fun SessionVerifiedStatus.toAnalyticsStateChangeValue(): CryptoSessionStateChange.VerificationState? { + return when (this) { + // we don't need to report transient states + SessionVerifiedStatus.Unknown -> null + SessionVerifiedStatus.NotVerified -> CryptoSessionStateChange.VerificationState.NotVerified + SessionVerifiedStatus.Verified -> CryptoSessionStateChange.VerificationState.Verified + } +} + +fun RecoveryState.toAnalyticsStateChangeValue(): CryptoSessionStateChange.RecoveryState? { + return when (this) { + RecoveryState.ENABLED -> CryptoSessionStateChange.RecoveryState.Enabled + RecoveryState.DISABLED -> CryptoSessionStateChange.RecoveryState.Disabled + RecoveryState.INCOMPLETE -> CryptoSessionStateChange.RecoveryState.Incomplete + // we don't need to report transient states + else -> 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 0000000..d2a9c92 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInEvents.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.loggedin + +sealed interface LoggedInEvents { + data class CloseErrorDialog(val doNotShowAgain: Boolean) : LoggedInEvents + data object CheckSlidingSyncProxyAvailability : LoggedInEvents + data object LogoutAndMigrateToNativeSlidingSync : 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 0000000..c49ca42 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInNode.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class LoggedInNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val loggedInPresenter: LoggedInPresenter, +) : Node( + buildContext = buildContext, + plugins = plugins +) { + interface Callback : Plugin { + fun navigateToNotificationTroubleshoot() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val loggedInState = loggedInPresenter.present() + LoggedInView( + state = loggedInState, + navigateToNotificationTroubleshoot = callback::navigateToNotificationTroubleshoot, + 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 0000000..184766e --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.loggedin + +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.setValue +import dev.zacsweers.metro.Inject +import im.vector.app.features.analytics.plan.CryptoSessionStateChange +import im.vector.app.features.analytics.plan.UserProperties +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.api.oidc.AccountManagementAction +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion +import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.api.PusherRegistrationFailure +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber + +private val pusherTag = LoggerTag("Pusher", LoggerTag.PushLoggerTag) + +@Inject +class LoggedInPresenter( + private val matrixClient: MatrixClient, + private val syncService: SyncService, + private val pushService: PushService, + private val sessionVerificationService: SessionVerificationService, + private val analyticsService: AnalyticsService, + private val encryptionService: EncryptionService, + private val buildMeta: BuildMeta, +) : Presenter { + @Composable + override fun present(): LoggedInState { + val coroutineScope = rememberCoroutineScope() + val ignoreRegistrationError by remember { + pushService.ignoreRegistrationError(matrixClient.sessionId) + }.collectAsState(initial = false) + val pusherRegistrationState = remember>> { mutableStateOf(AsyncData.Uninitialized) } + LaunchedEffect(Unit) { preloadAccountManagementUrl() } + LaunchedEffect(Unit) { + sessionVerificationService.sessionVerifiedStatus + .onEach { sessionVerifiedStatus -> + when (sessionVerifiedStatus) { + SessionVerifiedStatus.Unknown -> Unit + SessionVerifiedStatus.Verified -> { + Timber.tag(pusherTag.value).d("Ensure pusher is registered") + pushService.ensurePusherIsRegistered(matrixClient).fold( + onSuccess = { + Timber.tag(pusherTag.value).d("Pusher registered") + pusherRegistrationState.value = AsyncData.Success(Unit) + }, + onFailure = { + Timber.tag(pusherTag.value).e(it, "Failed to register pusher") + pusherRegistrationState.value = AsyncData.Failure(it) + }, + ) + } + SessionVerifiedStatus.NotVerified -> { + pusherRegistrationState.value = AsyncData.Failure(PusherRegistrationFailure.AccountNotVerified()) + } + } + } + .launchIn(this) + } + val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState() + val isOnline by syncService.isOnline.collectAsState() + val showSyncSpinner by remember { + derivedStateOf { + isOnline && syncIndicator == RoomListService.SyncIndicator.Show + } + } + var forceNativeSlidingSyncMigration by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + combine( + sessionVerificationService.sessionVerifiedStatus, + encryptionService.recoveryStateStateFlow + ) { verificationState, recoveryState -> + reportCryptoStatusToAnalytics(verificationState, recoveryState) + }.launchIn(this) + } + + fun handleEvent(event: LoggedInEvents) { + when (event) { + is LoggedInEvents.CloseErrorDialog -> { + pusherRegistrationState.value = AsyncData.Uninitialized + if (event.doNotShowAgain) { + coroutineScope.launch { + pushService.setIgnoreRegistrationError(matrixClient.sessionId, true) + } + } + } + LoggedInEvents.CheckSlidingSyncProxyAvailability -> coroutineScope.launch { + forceNativeSlidingSyncMigration = matrixClient.needsForcedNativeSlidingSyncMigration().getOrDefault(false) + } + LoggedInEvents.LogoutAndMigrateToNativeSlidingSync -> coroutineScope.launch { + // Force the logout since Native Sliding Sync is already enforced by the SDK + matrixClient.logout(userInitiated = true, ignoreSdkError = true) + } + } + } + + return LoggedInState( + showSyncSpinner = showSyncSpinner, + pusherRegistrationState = pusherRegistrationState.value, + ignoreRegistrationError = ignoreRegistrationError, + forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration, + appName = buildMeta.applicationName, + eventSink = ::handleEvent, + ) + } + + // Force the user to log out if they were using the proxy sliding sync as it's no longer supported by the SDK + private suspend fun MatrixClient.needsForcedNativeSlidingSyncMigration(): Result = runCatchingExceptions { + val currentSlidingSyncVersion = currentSlidingSyncVersion().getOrThrow() + currentSlidingSyncVersion == SlidingSyncVersion.Proxy + } + + private fun reportCryptoStatusToAnalytics(verificationState: SessionVerifiedStatus, recoveryState: RecoveryState) { + // Update first the user property, to store the current status for that posthog user + val userVerificationState = verificationState.toAnalyticsUserPropertyValue() + val userRecoveryState = recoveryState.toAnalyticsUserPropertyValue() + if (userRecoveryState != null && userVerificationState != null) { + // we want to report when both value are known (if one is unknown we wait until we have them both) + analyticsService.updateUserProperties( + UserProperties( + verificationState = userVerificationState, + recoveryState = userRecoveryState + ) + ) + } + + // Also report when there is a change in the state, to be able to track the changes + val changeVerificationState = verificationState.toAnalyticsStateChangeValue() + val changeRecoveryState = recoveryState.toAnalyticsStateChangeValue() + if (changeVerificationState != null && changeRecoveryState != null) { + analyticsService.capture(CryptoSessionStateChange(changeRecoveryState, changeVerificationState)) + } + } + + private fun CoroutineScope.preloadAccountManagementUrl() = launch { + matrixClient.getAccountManagementUrl(AccountManagementAction.Profile) + matrixClient.getAccountManagementUrl(AccountManagementAction.SessionsList) + } +} 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 0000000..b066f9f --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.loggedin + +import io.element.android.libraries.architecture.AsyncData + +data class LoggedInState( + val showSyncSpinner: Boolean, + val pusherRegistrationState: AsyncData, + val ignoreRegistrationError: Boolean, + val forceNativeSlidingSyncMigration: Boolean, + val appName: String, + 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 0000000..b2f5407 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.loggedin + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.push.api.PusherRegistrationFailure + +open class LoggedInStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aLoggedInState(), + aLoggedInState(showSyncSpinner = true), + aLoggedInState(pusherRegistrationState = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable())), + aLoggedInState(forceNativeSlidingSyncMigration = true), + ) +} + +fun aLoggedInState( + showSyncSpinner: Boolean = false, + pusherRegistrationState: AsyncData = AsyncData.Uninitialized, + forceNativeSlidingSyncMigration: Boolean = false, + appName: String = "Element X", +) = LoggedInState( + showSyncSpinner = showSyncSpinner, + pusherRegistrationState = pusherRegistrationState, + ignoreRegistrationError = false, + forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration, + appName = appName, + 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 0000000..62d8de8 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.loggedin + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +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.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.lifecycle.Lifecycle +import io.element.android.appnav.R +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogWithDoNotShowAgain +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent +import io.element.android.libraries.matrix.api.exception.isNetworkError +import io.element.android.libraries.push.api.PusherRegistrationFailure +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun LoggedInView( + state: LoggedInState, + navigateToNotificationTroubleshoot: () -> Unit, + modifier: Modifier = Modifier +) { + OnLifecycleEvent { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + state.eventSink(LoggedInEvents.CheckSlidingSyncProxyAvailability) + } + } + Box( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + ) { + SyncStateView( + modifier = Modifier.align(Alignment.TopCenter), + isVisible = state.showSyncSpinner, + ) + } + when (state.pusherRegistrationState) { + is AsyncData.Uninitialized, + is AsyncData.Loading, + is AsyncData.Success -> Unit + is AsyncData.Failure -> { + state.pusherRegistrationState.errorOrNull() + ?.takeIf { !state.ignoreRegistrationError } + ?.getReason() + ?.let { reason -> + ErrorDialogWithDoNotShowAgain( + content = stringResource(id = CommonStrings.common_error_registering_pusher_android, reason), + cancelText = stringResource(id = CommonStrings.common_settings), + onDismiss = { + state.eventSink(LoggedInEvents.CloseErrorDialog(it)) + }, + onCancel = { + state.eventSink(LoggedInEvents.CloseErrorDialog(false)) + navigateToNotificationTroubleshoot() + } + ) + } + } + } + + // Set the force migration dialog here so it's always displayed over every screen + if (state.forceNativeSlidingSyncMigration) { + ForceNativeSlidingSyncMigrationDialog( + appName = state.appName, + onSubmit = { + state.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync) + } + ) + } +} + +private fun Throwable.getReason(): String? { + return when (this) { + is PusherRegistrationFailure.RegistrationFailure -> { + if (isRegisteringAgain && clientException.isNetworkError()) { + // When registering again, ignore network error + null + } else { + clientException.message ?: "Unknown error" + } + } + is PusherRegistrationFailure.AccountNotVerified -> null + is PusherRegistrationFailure.NoDistributorsAvailable -> "No distributors available" + is PusherRegistrationFailure.NoProvidersAvailable -> "No providers available" + else -> "Other error: $message" + } +} + +@Composable +private fun ForceNativeSlidingSyncMigrationDialog( + appName: String, + onSubmit: () -> Unit, +) { + ErrorDialog( + title = null, + content = stringResource(R.string.banner_migrate_to_native_sliding_sync_app_force_logout_title, appName), + submitText = stringResource(R.string.banner_migrate_to_native_sliding_sync_action), + onSubmit = onSubmit, + canDismiss = false, + ) +} + +@PreviewsDayNight +@Composable +internal fun LoggedInViewPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = ElementPreview { + LoggedInView( + state = state, + navigateToNotificationTroubleshoot = {}, + ) +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigration.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigration.kt new file mode 100644 index 0000000..6b5803a --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigration.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.loggedin + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.media.MediaPreviewService +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * This migration is temporary, will be safe to remove after some time. + * The goal is to set the server config if it's not set, and remove the local data. + */ +@Inject +class MediaPreviewConfigMigration( + private val mediaPreviewService: MediaPreviewService, + private val appPreferencesStore: AppPreferencesStore, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, +) { + @Suppress("DEPRECATION") + operator fun invoke() = sessionCoroutineScope.launch { + val hideInviteAvatars = appPreferencesStore.getHideInviteAvatarsFlow().first() + val mediaPreviewValue = appPreferencesStore.getTimelineMediaPreviewValueFlow().first() + if (hideInviteAvatars == null && mediaPreviewValue == null) { + // No local data, abort. + return@launch + } + mediaPreviewService + .fetchMediaPreviewConfig() + .onSuccess { config -> + if (config != null) { + appPreferencesStore.setHideInviteAvatars(null) + appPreferencesStore.setTimelineMediaPreviewValue(null) + } else { + if (hideInviteAvatars != null) { + mediaPreviewService.setHideInviteAvatars(hideInviteAvatars) + appPreferencesStore.setHideInviteAvatars(null) + } + if (mediaPreviewValue != null) { + mediaPreviewService.setMediaPreviewValue(mediaPreviewValue) + appPreferencesStore.setTimelineMediaPreviewValue(null) + } + } + } + .onFailure { + Timber.e(it, "Couldn't perform migration, failed to fetch media preview config.") + } + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SendQueues.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SendQueues.kt new file mode 100644 index 0000000..3f01f83 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SendQueues.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.loggedin + +import androidx.annotation.VisibleForTesting +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +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.FlowPreview +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import timber.log.Timber + +@VisibleForTesting +const val SEND_QUEUES_RETRY_DELAY_MILLIS = 500L + +@SingleIn(SessionScope::class) +@Inject +class SendQueues( + private val matrixClient: MatrixClient, + private val syncService: SyncService, +) { + /** + * Launches the send queues retry mechanism in the given [coroutineScope]. + * Makes sure to re-enable all send queues when the network status is [NetworkStatus.Connected]. + */ + @OptIn(FlowPreview::class) + fun launchIn(coroutineScope: CoroutineScope) { + combine( + syncService.syncState, + matrixClient.sendQueueDisabledFlow(), + ) { syncState, _ -> syncState } + .debounce(SEND_QUEUES_RETRY_DELAY_MILLIS) + .onEach { syncState -> + Timber.tag("SendQueues").d("Sync state changed: $syncState") + if (syncState == SyncState.Running) { + Timber.tag("SendQueues").d("Enabling send queues again") + matrixClient.setAllSendQueuesEnabled(enabled = true) + } + } + .launchIn(coroutineScope) + } +} 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 0000000..7c5f196 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SyncStateView.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.async.AsyncIndicator +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun SyncStateView( + isVisible: Boolean, + modifier: Modifier = Modifier +) { + AnimatedVisibility( + visible = isVisible, + modifier = modifier, + enter = fadeIn(spring(stiffness = 500F)), + exit = fadeOut(spring(stiffness = 500F)), + ) { + AsyncIndicator.Loading( + text = stringResource(id = CommonStrings.common_syncing), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun SyncStateViewPreview() = ElementPreview { + // Add a box to see the shadow + Box(modifier = Modifier.padding(24.dp)) { + SyncStateView( + isVisible = true + ) + } +} 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 0000000..1bf3516 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.room + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.transition.JumpToEndTransitionHandler +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.active +import com.bumble.appyx.navmodel.backstack.operation.newRoot +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.appnav.room.joined.JoinedRoomFlowNode +import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode +import io.element.android.appnav.room.joined.LoadingRoomNodeView +import io.element.android.features.joinroom.api.JoinRoomEntryPoint +import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint +import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint.Params +import io.element.android.features.roomdirectory.api.RoomDescription +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +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.core.coroutine.withPreviousValue +import io.element.android.libraries.di.SessionScope +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.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias +import io.element.android.libraries.matrix.ui.room.LoadingRoomState +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadJoinedRoomFlow +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationTapOpensTimeline +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import timber.log.Timber +import java.util.Optional +import kotlin.jvm.optionals.getOrNull +import im.vector.app.features.analytics.plan.JoinedRoom as JoinedRoomAnalyticsEvent +import io.element.android.libraries.matrix.api.room.JoinedRoom as JoinedRoomInstance + +@ContributesNode(SessionScope::class) +@AssistedInject +class RoomFlowNode( + @Assisted val buildContext: BuildContext, + @Assisted plugins: List, + private val client: MatrixClient, + private val joinRoomEntryPoint: JoinRoomEntryPoint, + private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint, + private val membershipObserver: RoomMembershipObserver, + private val analyticsService: AnalyticsService, +) : BaseFlowNode( + backstack = BackStack( + initialElement = run { + val joinedRoom = (plugins.filterIsInstance().first().initialElement as? RoomNavigationTarget.Root)?.joinedRoom + if (joinedRoom != null) { + NavTarget.JoinedRoom(joinedRoom) + } else { + NavTarget.Loading + } + }, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +) { + data class Inputs( + val roomIdOrAlias: RoomIdOrAlias, + val roomDescription: Optional, + val serverNames: List, + val trigger: Optional, + val initialElement: RoomNavigationTarget, + ) : NodeInputs + + private val inputs: Inputs = inputs() + + sealed interface NavTarget : Parcelable { + @Parcelize + data object Loading : NavTarget + + @Parcelize + data class Resolving(val roomAlias: RoomAlias) : NavTarget + + @Parcelize + data class JoinRoom( + val roomId: RoomId, + val serverNames: List, + val trigger: JoinedRoomAnalyticsEvent.Trigger, + ) : NavTarget + + @Parcelize + data class JoinedRoom( + val roomId: RoomId, + @IgnoredOnParcel val joinedRoom: JoinedRoomInstance? = null, + ) : NavTarget { + constructor(joinedRoom: JoinedRoomInstance) : this(joinedRoom.roomId, joinedRoom) + } + } + + override fun onBuilt() { + super.onBuilt() + val parentTransaction = analyticsService.getLongRunningTransaction(NotificationTapOpensTimeline) + val openRoomTransaction = analyticsService.startLongRunningTransaction(OpenRoom, parentTransaction) + analyticsService.startLongRunningTransaction(LoadJoinedRoomFlow, openRoomTransaction) + resolveRoomId() + } + + private fun resolveRoomId() { + lifecycleScope.launch { + when (val i = inputs.roomIdOrAlias) { + is RoomIdOrAlias.Alias -> { + backstack.newRoot(NavTarget.Resolving(i.roomAlias)) + } + is RoomIdOrAlias.Id -> { + subscribeToRoomInfoFlow(i.roomId, inputs.serverNames) + } + } + } + } + + private fun subscribeToRoomInfoFlow(roomId: RoomId, serverNames: List) { + val joinedRoom = (inputs.initialElement as? RoomNavigationTarget.Root)?.joinedRoom + val roomInfoFlow = joinedRoom?.roomInfoFlow?.map { Optional.of(it) } + ?: client.getRoomInfoFlow(roomId) + + // This observes the local membership changes for the room + val membershipUpdateFlow = membershipObserver.updates + .filter { it.roomId == roomId } + .distinctUntilChanged() + // We add a replay so we can check the last local membership update + .shareIn(lifecycleScope, started = SharingStarted.Eagerly, replay = 1) + + val currentMembershipFlow = roomInfoFlow + .map { it.getOrNull()?.currentUserMembership } + .distinctUntilChanged() + .withPreviousValue() + currentMembershipFlow.onEach { (previousMembership, membership) -> + Timber.d("Room membership: $membership") + if (membership == CurrentUserMembership.JOINED) { + val currentNavTarget = backstack.active?.key?.navTarget + if (currentNavTarget is NavTarget.JoinedRoom && currentNavTarget.roomId == roomId) { + Timber.d("Already in JoinedRoom $roomId, do nothing") + return@onEach + } + backstack.newRoot(NavTarget.JoinedRoom(roomId)) + } else { + val leavingFromCurrentDevice = + membership == CurrentUserMembership.LEFT && + previousMembership == CurrentUserMembership.JOINED && + membershipUpdateFlow.replayCache.lastOrNull()?.isUserInRoom == false + + if (leavingFromCurrentDevice) { + navigateUp() + } else { + backstack.newRoot( + NavTarget.JoinRoom( + roomId = roomId, + serverNames = serverNames, + trigger = inputs.trigger.getOrNull() ?: JoinedRoomAnalyticsEvent.Trigger.Invite, + ) + ) + } + } + }.launchIn(lifecycleScope) + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.Loading -> loadingNode(buildContext) + is NavTarget.Resolving -> { + val callback = object : RoomAliasResolverEntryPoint.Callback { + override fun onAliasResolved(data: ResolvedRoomAlias) { + subscribeToRoomInfoFlow( + roomId = data.roomId, + serverNames = data.servers, + ) + } + } + val params = Params(navTarget.roomAlias) + roomAliasResolverEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = params, + callback = callback, + ) + } + is NavTarget.JoinRoom -> { + val inputs = JoinRoomEntryPoint.Inputs( + roomId = navTarget.roomId, + roomIdOrAlias = inputs.roomIdOrAlias, + roomDescription = inputs.roomDescription, + serverNames = navTarget.serverNames, + trigger = navTarget.trigger, + ) + joinRoomEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + inputs = inputs, + ) + } + is NavTarget.JoinedRoom -> { + val roomFlowNodeCallback = plugins() + val inputs = JoinedRoomFlowNode.Inputs( + roomId = navTarget.roomId, + initialElement = inputs.initialElement, + joinedRoom = navTarget.joinedRoom, + ) + createNode(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback) + } + } + } + + suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) { + waitForChildAttached() + .attachThread(threadId, focusedEventId) + } + + private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier -> + LoadingRoomNodeView( + state = LoadingRoomState.Loading, + onBackClick = { navigateUp() }, + modifier = modifier, + ) + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView(transitionHandler = JumpToEndTransitionHandler()) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomNavigationTarget.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomNavigationTarget.kt new file mode 100644 index 0000000..aac916a --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomNavigationTarget.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.room + +import android.os.Parcelable +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize + +sealed interface RoomNavigationTarget : Parcelable { + @Parcelize + data class Root( + val eventId: EventId? = null, + @IgnoredOnParcel val joinedRoom: JoinedRoom? = null, + ) : RoomNavigationTarget + + @Parcelize + data object Details : RoomNavigationTarget + + @Parcelize + data object NotificationSettings : RoomNavigationTarget +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt new file mode 100644 index 0000000..504bdfe --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.appnav.room.joined + +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.modality.BuildContext +import com.bumble.appyx.core.navigation.transition.JumpToEndTransitionHandler +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.appnav.room.RoomNavigationTarget +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +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.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.ui.room.LoadingRoomState +import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory +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) +@AssistedInject +class JoinedRoomFlowNode( + @Assisted val buildContext: BuildContext, + @Assisted plugins: List, + loadingRoomStateFlowFactory: LoadingRoomStateFlowFactory, +) : + BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Loading, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins + ) { + data class Inputs( + val roomId: RoomId, + val joinedRoom: JoinedRoom?, + val initialElement: RoomNavigationTarget, + ) : NodeInputs + + private val inputs: Inputs = inputs() + private val loadingRoomStateStateFlow = loadingRoomStateFlowFactory.create(lifecycleScope, inputs.roomId, inputs.joinedRoom) + + sealed interface NavTarget : Parcelable { + @Parcelize + data object Loading : NavTarget + + @Parcelize + data 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 roomFlowNodeCallback = plugins() + val awaitRoomState = loadingRoomStateStateFlow.value + if (awaitRoomState is LoadingRoomState.Loaded) { + val inputs = JoinedRoomLoadedFlowNode.Inputs( + room = awaitRoomState.room, + initialElement = inputs.initialElement + ) + createNode(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback) + } else { + loadingNode(buildContext, this::navigateUp) + } + } + NavTarget.Loading -> { + loadingNode(buildContext, this::navigateUp) + } + } + } + + private fun loadingNode(buildContext: BuildContext, onBackClick: () -> Unit) = node(buildContext) { modifier -> + val loadingRoomState by loadingRoomStateStateFlow.collectAsState() + LoadingRoomNodeView( + state = loadingRoomState, + onBackClick = onBackClick, + modifier = modifier + ) + } + + suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) { + waitForChildAttached() + .attachThread(threadId, focusedEventId) + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView( + transitionHandler = JumpToEndTransitionHandler(), + ) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt new file mode 100644 index 0000000..5a6ef91 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.room.joined + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +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.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.appnav.di.RoomGraphFactory +import io.element.android.appnav.di.TimelineBindings +import io.element.android.appnav.room.RoomNavigationTarget +import io.element.android.features.forward.api.ForwardEntryPoint +import io.element.android.features.messages.api.MessagesEntryPoint +import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint +import io.element.android.features.space.api.SpaceEntryPoint +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.architecture.waitForChildAttached +import io.element.android.libraries.di.DependencyInjectionGraphOwner +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.annotations.SessionCoroutineScope +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.core.ThreadId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadJoinedRoomFlow +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadMessagesUi +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.finishLongRunningTransaction +import io.element.android.services.appnavstate.api.ActiveRoomsHolder +import io.element.android.services.appnavstate.api.AppNavigationStateService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import timber.log.Timber + +@ContributesNode(SessionScope::class) +@AssistedInject +class JoinedRoomLoadedFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val messagesEntryPoint: MessagesEntryPoint, + private val roomDetailsEntryPoint: RoomDetailsEntryPoint, + private val spaceEntryPoint: SpaceEntryPoint, + private val forwardEntryPoint: ForwardEntryPoint, + private val appNavigationStateService: AppNavigationStateService, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, + private val matrixClient: MatrixClient, + private val activeRoomsHolder: ActiveRoomsHolder, + private val analyticsService: AnalyticsService, + roomGraphFactory: RoomGraphFactory, +) : BaseFlowNode( + backstack = BackStack( + initialElement = initialElement(plugins), + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +), DependencyInjectionGraphOwner { + interface Callback : Plugin { + fun navigateToRoom(roomId: RoomId, serverNames: List) + fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) + fun navigateToGlobalNotificationSettings() + } + + data class Inputs( + val room: JoinedRoom, + val initialElement: RoomNavigationTarget, + ) : NodeInputs + + private val inputs: Inputs = inputs() + private val callback: Callback = callback() + override val graph = roomGraphFactory.create(inputs.room) + + init { + lifecycle.subscribe( + onCreate = { + val parent = analyticsService.getLongRunningTransaction(OpenRoom) + analyticsService.startLongRunningTransaction(LoadMessagesUi, parent) + Timber.v("OnCreate => ${inputs.room.roomId}") + appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId) + activeRoomsHolder.addRoom(inputs.room) + fetchRoomMembers() + trackVisitedRoom() + }, + onResume = { + analyticsService.finishLongRunningTransaction(LoadJoinedRoomFlow) + sessionCoroutineScope.launch { + inputs.room.subscribeToSync() + } + }, + onDestroy = { + Timber.v("OnDestroy") + activeRoomsHolder.removeRoom(inputs.room.sessionId, inputs.room.roomId) + inputs.room.destroy() + appNavigationStateService.onLeavingRoom(id) + } + ) + } + + private fun trackVisitedRoom() = lifecycleScope.launch { + matrixClient.trackRecentlyVisitedRoom(inputs.room.roomId) + } + + private fun fetchRoomMembers() = lifecycleScope.launch { + inputs.room.updateMembers() + } + + private fun createRoomDetailsNode(buildContext: BuildContext, initialTarget: RoomDetailsEntryPoint.InitialTarget): Node { + val callback = object : RoomDetailsEntryPoint.Callback { + override fun navigateToGlobalNotificationSettings() { + callback.navigateToGlobalNotificationSettings() + } + + override fun navigateToRoom(roomId: RoomId, serverNames: List) { + callback.navigateToRoom(roomId, serverNames) + } + + override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) { + callback.handlePermalinkClick(data, pushToBackstack) + } + + override fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean) { + backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents)) + } + } + return roomDetailsEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = RoomDetailsEntryPoint.Params(initialTarget), + callback = callback, + ) + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.Messages -> { + createMessagesNode(buildContext, navTarget) + } + NavTarget.RoomDetails -> { + createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomDetails) + } + is NavTarget.RoomMemberDetails -> { + createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(navTarget.userId)) + } + NavTarget.RoomNotificationSettings -> { + createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomNotificationSettings) + } + NavTarget.RoomMemberList -> { + createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomMemberList) + } + NavTarget.Space -> { + createSpaceNode(buildContext) + } + is NavTarget.ForwardEvent -> { + val timelineProvider = if (navTarget.fromPinnedEvents) { + (graph as TimelineBindings).pinnedEventsTimelineProvider + } else { + (graph as TimelineBindings).timelineProvider + } + val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider) + val callback = object : ForwardEntryPoint.Callback { + override fun onDone(roomIds: List) { + backstack.pop() + roomIds.singleOrNull()?.let { roomId -> + callback.navigateToRoom(roomId, emptyList()) + } + } + } + forwardEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = params, + callback = callback, + ) + } + } + } + + private fun createSpaceNode(buildContext: BuildContext): Node { + val callback = object : SpaceEntryPoint.Callback { + override fun navigateToRoom(roomId: RoomId, viaParameters: List) { + callback.navigateToRoom(roomId, viaParameters) + } + + override fun navigateToRoomMemberList() { + backstack.push(NavTarget.RoomMemberList) + } + } + return spaceEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + inputs = SpaceEntryPoint.Inputs(roomId = inputs.room.roomId), + callback = callback, + ) + } + + private fun createMessagesNode( + buildContext: BuildContext, + navTarget: NavTarget.Messages, + ): Node { + val callback = object : MessagesEntryPoint.Callback { + override fun navigateToRoomDetails() { + backstack.push(NavTarget.RoomDetails) + } + + override fun navigateToRoomMemberDetails(userId: UserId) { + backstack.push(NavTarget.RoomMemberDetails(userId)) + } + + override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) { + callback.handlePermalinkClick(data, pushToBackstack) + } + + override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) { + backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents)) + } + + override fun navigateToRoom(roomId: RoomId) { + callback.navigateToRoom(roomId, emptyList()) + } + } + val params = MessagesEntryPoint.Params( + MessagesEntryPoint.InitialTarget.Messages(navTarget.focusedEventId) + ) + return messagesEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = params, + callback = callback, + ) + } + + sealed interface NavTarget : Parcelable { + @Parcelize + data object Space : NavTarget + + @Parcelize + data class Messages( + val focusedEventId: EventId? = null, + ) : NavTarget + + @Parcelize + data object RoomDetails : NavTarget + + @Parcelize + data object RoomMemberList : NavTarget + + @Parcelize + data class RoomMemberDetails(val userId: UserId) : NavTarget + + @Parcelize + data class ForwardEvent(val eventId: EventId, val fromPinnedEvents: Boolean) : NavTarget + + @Parcelize + data object RoomNotificationSettings : NavTarget + } + + suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) { + val messageNode = waitForChildAttached { navTarget -> + navTarget is NavTarget.Messages + } + (messageNode as? MessagesEntryPoint.NodeProxy)?.attachThread(threadId, focusedEventId) + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView() + } +} + +private fun initialElement(plugins: List): JoinedRoomLoadedFlowNode.NavTarget { + val input = plugins.filterIsInstance().single() + return when (input.initialElement) { + is RoomNavigationTarget.Root -> { + if (input.room.roomInfoFlow.value.isSpace) { + JoinedRoomLoadedFlowNode.NavTarget.Space + } else { + JoinedRoomLoadedFlowNode.NavTarget.Messages(input.initialElement.eventId) + } + } + RoomNavigationTarget.Details -> JoinedRoomLoadedFlowNode.NavTarget.RoomDetails + RoomNavigationTarget.NotificationSettings -> JoinedRoomLoadedFlowNode.NavTarget.RoomNotificationSettings + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt new file mode 100644 index 0000000..a596052 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.room.joined + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.utils.DelayedVisibility +import io.element.android.libraries.matrix.ui.room.LoadingRoomState +import io.element.android.libraries.matrix.ui.room.LoadingRoomStateProvider +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun LoadingRoomNodeView( + state: LoadingRoomState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + LoadingRoomTopBar(onBackClick) + }, + 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 { + DelayedVisibility { + CircularProgressIndicator() + } + } + } + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LoadingRoomTopBar( + onBackClick: () -> Unit, +) { + TopAppBar( + navigationIcon = { + BackButton(onClick = onBackClick) + }, + title = { + }, + ) +} + +@PreviewsDayNight +@Composable +internal fun LoadingRoomNodeViewPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) = ElementPreview { + LoadingRoomNodeView( + state = state, + onBackClick = {} + ) +} 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 0000000..9015872 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavState.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.root + +import io.element.android.libraries.sessionstorage.api.LoggedInState + +/** + * [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, + /** + * LoggedInState. + */ + val loggedInState: LoggedInState, +) 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 0000000..9a91e97 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.root + +import com.bumble.appyx.core.state.MutableSavedStateMap +import com.bumble.appyx.core.state.SavedStateMap +import dev.zacsweers.metro.Inject +import io.element.android.appnav.di.MatrixSessionCache +import io.element.android.features.preferences.api.CacheService +import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory +import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onEach + +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. + */ +@Inject +class RootNavStateFlowFactory( + private val sessionStore: SessionStore, + private val cacheService: CacheService, + private val matrixSessionCache: MatrixSessionCache, + private val imageLoaderHolder: ImageLoaderHolder, + private val sessionPreferencesStoreFactory: SessionPreferencesStoreFactory, +) { + private var currentCacheIndex = 0 + + fun create(savedStateMap: SavedStateMap?): Flow { + return combine( + cacheIndexFlow(savedStateMap), + sessionStore.loggedInStateFlow(), + ) { cacheIndex, loggedInState -> + RootNavState( + cacheIndex = cacheIndex, + loggedInState = loggedInState, + ) + } + } + + 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 { + val initialCacheIndex = savedStateMap.getCacheIndexOrDefault() + return cacheService.clearedCacheEventFlow + .onEach { sessionId -> + matrixSessionCache.remove(sessionId) + // Ensure image loader will be recreated with the new MatrixClient + imageLoaderHolder.remove(sessionId) + // Also remove cached value for SessionPreferencesStore + sessionPreferencesStoreFactory.remove(sessionId) + } + .toIndexFlow(initialCacheIndex) + .onEach { cacheIndex -> + currentCacheIndex = cacheIndex + } + } + + /** + * @return a flow of integer that increments the value by one each time a new element is emitted upstream. + */ + private fun Flow.toIndexFlow(initialValue: Int): Flow = 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 0000000..75704c4 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.root + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import dev.zacsweers.metro.Inject +import im.vector.app.features.analytics.plan.SuperProperties +import io.element.android.features.rageshake.api.crash.CrashDetectionState +import io.element.android.features.rageshake.api.detection.RageshakeDetectionState +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.SdkMetadata +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.apperror.api.AppErrorStateService + +@Inject +class RootPresenter( + private val crashDetectionPresenter: Presenter, + private val rageshakeDetectionPresenter: Presenter, + private val appErrorStateService: AppErrorStateService, + private val analyticsService: AnalyticsService, + private val sdkMetadata: SdkMetadata, +) : Presenter { + @Composable + override fun present(): RootState { + val rageshakeDetectionState = rageshakeDetectionPresenter.present() + val crashDetectionState = crashDetectionPresenter.present() + val appErrorState by appErrorStateService.appErrorStateFlow.collectAsState() + + LaunchedEffect(Unit) { + analyticsService.updateSuperProperties( + SuperProperties( + cryptoSDK = SuperProperties.CryptoSDK.Rust, + appPlatform = SuperProperties.AppPlatform.EXA, + cryptoSDKVersion = sdkMetadata.sdkGitSha, + ) + ) + } + + 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 0000000..0d7f362 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootState.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.root + +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 + +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 0000000..26db205 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootStateProvider.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 { + override val values: Sequence + 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 0000000..2bc76d7 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootView.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.services.apperror.impl.AppErrorView + +@Composable +fun RootView( + state: RootState, + onOpenBugReport: () -> Unit, + modifier: Modifier = Modifier, + 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, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun RootViewPreview(@PreviewParameter(RootStateProvider::class) rootState: RootState) = ElementPreview { + RootView( + state = rootState, + onOpenBugReport = {}, + ) { + Text("Children") + } +} diff --git a/appnav/src/main/res/values-be/translations.xml b/appnav/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..f6699c0 --- /dev/null +++ b/appnav/src/main/res/values-be/translations.xml @@ -0,0 +1,5 @@ + + + "Выйсці і абнавіць" + "Ваш хатні сервер больш не падтрымлівае стары пратакол. Калі ласка, выйдзіце і ўвайдзіце зноў, каб працягнуць выкарыстанне праграмы." + diff --git a/appnav/src/main/res/values-cs/translations.xml b/appnav/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..3652bda --- /dev/null +++ b/appnav/src/main/res/values-cs/translations.xml @@ -0,0 +1,6 @@ + + + "Odhlásit se a upgradovat" + "%1$s již nepodporuje starý protokol. Odhlaste se a znovu přihlaste, abyste mohli pokračovat v používání aplikace." + "Váš domovský server již nepodporuje starý protokol. Chcete-li pokračovat v používání aplikace, odhlaste se a znovu se přihlaste." + diff --git a/appnav/src/main/res/values-cy/translations.xml b/appnav/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..06d53cd --- /dev/null +++ b/appnav/src/main/res/values-cy/translations.xml @@ -0,0 +1,6 @@ + + + "Allgofnodi ac Uwchraddio" + "Nid yw %1$s bellach yn cefnogi\'r hen brotocol. Allgofnodwch a mewngofnodi\'n ôl i barhau i ddefnyddio\'r ap." + "Nid yw eich gweinydd cartref yn cefnogi\'r hen brotocol mwyach. Allgofnodwch a mewngofnodi yn ôl i barhau i ddefnyddio\'r ap." + diff --git a/appnav/src/main/res/values-da/translations.xml b/appnav/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..ef5025e --- /dev/null +++ b/appnav/src/main/res/values-da/translations.xml @@ -0,0 +1,6 @@ + + + "Log ud og opgradér" + "%1$s understøtter ikke længere den gamle protokol. Log ud og log ind igen for at fortsætte med at bruge appen." + "Din hjemmeserver understøtter ikke længere den gamle protokol. Log ud og log ind igen for at fortsætte med at bruge appen." + diff --git a/appnav/src/main/res/values-de/translations.xml b/appnav/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..13d085e --- /dev/null +++ b/appnav/src/main/res/values-de/translations.xml @@ -0,0 +1,6 @@ + + + "Abmelden und aktualisieren" + "%1$s unterstützt das alte Protokoll nicht mehr. Bitte melde dich ab und wieder an, um die App weiter nutzen zu können." + "Dein Homeserver unterstützt das alte Protokoll nicht mehr. Bitte logge dich aus und melde dich wieder an, um die App weiter zu nutzen." + diff --git a/appnav/src/main/res/values-el/translations.xml b/appnav/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..03848d7 --- /dev/null +++ b/appnav/src/main/res/values-el/translations.xml @@ -0,0 +1,6 @@ + + + "Αποσύνδεση & Αναβάθμιση" + "Το %1$s δεν υποστηρίζει πλέον το παλιό πρωτόκολλο. Αποσυνδεθείτε και συνδεθείτε ξανά για να συνεχίσετε να χρησιμοποιείτε την εφαρμογή." + "Ο οικιακός διακομιστής σου δεν υποστηρίζει πλέον το παλιό πρωτόκολλο. Αποσυνδέσου και συνδέσου ξανά για να συνεχίσεις να χρησιμοποιείς την εφαρμογή." + diff --git a/appnav/src/main/res/values-es/translations.xml b/appnav/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..20f1555 --- /dev/null +++ b/appnav/src/main/res/values-es/translations.xml @@ -0,0 +1,6 @@ + + + "Cerrar sesión y actualizar" + "%1$s ya no es compatible con el antiguo protocolo. Cierra sesión y vuelve a iniciarla para seguir usando la aplicación." + "Tu servidor base ya no es compatible con el protocolo anterior. Cierra sesión y vuelve a iniciarla para seguir usando la aplicación." + diff --git a/appnav/src/main/res/values-et/translations.xml b/appnav/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..b71640a --- /dev/null +++ b/appnav/src/main/res/values-et/translations.xml @@ -0,0 +1,6 @@ + + + "Logi välja ja uuenda" + "%1$s enam ei toeta vana protokolli. Kui soovid rakendust edasi kasutada, siis logi korraks temast välja ning seejärel tagasi." + "Sinu koduserver enam ei toeta vana protokolli. Jätkamaks rakenduse kasutamist palun logi välja ning seejärel tagasi." + diff --git a/appnav/src/main/res/values-eu/translations.xml b/appnav/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..909ade1 --- /dev/null +++ b/appnav/src/main/res/values-eu/translations.xml @@ -0,0 +1,6 @@ + + + "Amaitu saioa eta bertsio-berritu" + "%1$s(e)k ez da bateragarria lehengo protokoloarekin. Amaitu saioa eta hasi berriro aplikazioa erabiltzen jarraitzeko." + "Zure zerbitzaria ez da bateragarria protokolo zaharrarekin. Amaitu saioa eta hasi berriro aplikazioa erabiltzen jarraitzeko." + diff --git a/appnav/src/main/res/values-fa/translations.xml b/appnav/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..16bb7fd --- /dev/null +++ b/appnav/src/main/res/values-fa/translations.xml @@ -0,0 +1,5 @@ + + + "خروج و ارتقا" + "‏%1$s دیگر از شیوه‌نامهٔ قدیمی پشتیبانی نمی‌کند. لطفاً برای ادامهٔ استفاده از کاره، خارج شده و دوباره وارد شوید." + diff --git a/appnav/src/main/res/values-fi/translations.xml b/appnav/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..14605ea --- /dev/null +++ b/appnav/src/main/res/values-fi/translations.xml @@ -0,0 +1,6 @@ + + + "Kirjaudu Ulos & Päivitä" + "%1$s ei enää tue vanhaa protokollaa. Kirjaudu ulos ja takaisin sisään jatkaaksesi sovelluksen käyttöä." + "Kotipalvelimesi ei enää tue vanhaa protokollaa. Kirjaudu ulos ja takaisin sisään jatkaaksesi sovelluksen käyttöä." + diff --git a/appnav/src/main/res/values-fr/translations.xml b/appnav/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..e3885f8 --- /dev/null +++ b/appnav/src/main/res/values-fr/translations.xml @@ -0,0 +1,6 @@ + + + "Déconnecter et mettre à niveau" + "%1$s ne prend plus en charge l’ancien protocole. Veuillez vous déconnecter puis vous reconnecter pour continuer à utiliser l’application." + "Votre serveur d’accueil ne prend plus en charge l’ancien protocole. Veuillez vous déconnecter puis vous reconnecter pour continuer à utiliser l’application." + diff --git a/appnav/src/main/res/values-hu/translations.xml b/appnav/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..a4dca1c --- /dev/null +++ b/appnav/src/main/res/values-hu/translations.xml @@ -0,0 +1,6 @@ + + + "Kijelentkezés és frissítés" + "%1$s már nem támogatja a régi protokollt. Kérjük, jelentkezzen ki és jelentkezzen be újra az alkalmazás használatának folytatásához." + "A Matrix-kiszolgáló már nem támogatja a régi protokollt. Az alkalmazás további használatához jelentkezzen ki és be." + diff --git a/appnav/src/main/res/values-in/translations.xml b/appnav/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..e4f445d --- /dev/null +++ b/appnav/src/main/res/values-in/translations.xml @@ -0,0 +1,6 @@ + + + "Keluar & Tingkatkan" + "%1$s tidak lagi mendukung protokol lama. Silakan keluar dan masuk kembali untuk terus menggunakan aplikasi." + "Homeserver Anda tidak lagi mendukung protokol lama. Silakan keluar dan masuk kembali untuk terus menggunakan aplikasi." + diff --git a/appnav/src/main/res/values-it/translations.xml b/appnav/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..725604e --- /dev/null +++ b/appnav/src/main/res/values-it/translations.xml @@ -0,0 +1,6 @@ + + + "Esci e aggiorna" + "%1$s non supporta più il vecchio protocollo. Esci e accedi nuovamente per continuare a utilizzare l\'app." + "Il tuo homeserver non supporta più il vecchio protocollo. Esci e rientra per continuare a usare l\'app." + diff --git a/appnav/src/main/res/values-ko/translations.xml b/appnav/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..a29e74f --- /dev/null +++ b/appnav/src/main/res/values-ko/translations.xml @@ -0,0 +1,6 @@ + + + "로그아웃 및 업그레이드" + "%1$s 더 이상 이전 프로토콜을 지원하지 않습니다. 계속 사용하려면 로그아웃 후 다시 로그인해 주세요." + "귀하의 홈서버는 더 이상 이전 프로토콜을 지원하지 않습니다. 앱을 계속 사용하려면 로그아웃한 후 다시 로그인하세요." + diff --git a/appnav/src/main/res/values-nb/translations.xml b/appnav/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..8270499 --- /dev/null +++ b/appnav/src/main/res/values-nb/translations.xml @@ -0,0 +1,6 @@ + + + "Logg ut og oppgrader" + "%1$s støtter ikke lenger den gamle protokollen. Logg ut og logg inn igjen for å fortsette å bruke appen." + "Hjemmeserveren din støtter ikke lenger den gamle protokollen. Vennligst logg ut og inn igjen for å fortsette å bruke appen." + diff --git a/appnav/src/main/res/values-nl/translations.xml b/appnav/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..e38f958 --- /dev/null +++ b/appnav/src/main/res/values-nl/translations.xml @@ -0,0 +1,5 @@ + + + "Uitloggen & Upgraden" + "Je homeserver ondersteunt het oude protocol niet meer. Log uit en log opnieuw in om de app te blijven gebruiken." + diff --git a/appnav/src/main/res/values-pl/translations.xml b/appnav/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..68de66a --- /dev/null +++ b/appnav/src/main/res/values-pl/translations.xml @@ -0,0 +1,6 @@ + + + "Wyloguj się i zaktualizuj" + "%1$s już nie wspiera starego protokołu. Zaloguj się ponownie, aby dalej korzystać z aplikacji." + "Twój serwer domowy już nie wspiera starego protokołu. Zaloguj się ponownie, aby kontynuować korzystanie z aplikacji." + diff --git a/appnav/src/main/res/values-pt-rBR/translations.xml b/appnav/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..90d27d3 --- /dev/null +++ b/appnav/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,6 @@ + + + "Sair e atualizar" + "%1$s não tem mais suporte ao protocolo antigo. Saia da sua conta e entre novamente para continuar utilizando o aplicativo." + "Seu servidor-casa não é mais compatível com o protocolo antigo. Saia da sua conta e entre novamente para continuar usando o aplicativo." + diff --git a/appnav/src/main/res/values-pt/translations.xml b/appnav/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..2d84d29 --- /dev/null +++ b/appnav/src/main/res/values-pt/translations.xml @@ -0,0 +1,6 @@ + + + "Sair & Atualizar" + "%1$s já não suporta o protocolo antigo. Termina a sessão e volta a iniciar sessão para continuares a utilizar a aplicação." + "O teu servidor já não permite o protocolo antigo. Termine sessão e volte a iniciá-la para continuar a utilizar a aplicação." + diff --git a/appnav/src/main/res/values-ro/translations.xml b/appnav/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..626902b --- /dev/null +++ b/appnav/src/main/res/values-ro/translations.xml @@ -0,0 +1,6 @@ + + + "Deconectați-vă și faceți upgrade" + "%1$s nu mai acceptă vechiul protocol. Vă rugăm să vă deconectați și să vă reconectați pentru a continua utilizarea aplicației." + "Serverul dvs. de acasă nu mai acceptă vechiul protocol. Vă rugăm să vă deconectați și să vă conectați din nou pentru a continua să utilizați aplicația." + diff --git a/appnav/src/main/res/values-ru/translations.xml b/appnav/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..45f00a7 --- /dev/null +++ b/appnav/src/main/res/values-ru/translations.xml @@ -0,0 +1,6 @@ + + + "Выйти и обновить" + "%1$s больше не поддерживает старый протокол. Пожалуйста, выйдите из системы и войдите снова, чтобы продолжить использование приложения." + "Ваш домашний сервер больше не поддерживает старый протокол. Пожалуйста, выйдите и войдите в свою учётную запись снова, чтобы продолжить использование приложения." + diff --git a/appnav/src/main/res/values-sk/translations.xml b/appnav/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..a75c2fd --- /dev/null +++ b/appnav/src/main/res/values-sk/translations.xml @@ -0,0 +1,6 @@ + + + "Odhlásiť sa a aktualizovať" + "%1$s už nepodporuje starý protokol. Odhláste sa a znova prihláste, aby ste mohli pokračovať v používaní aplikácie." + "Váš domovský server už nepodporuje starý protokol. Ak chcete pokračovať v používaní aplikácie, odhláste sa a znova sa prihláste." + diff --git a/appnav/src/main/res/values-sv/translations.xml b/appnav/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..408e020 --- /dev/null +++ b/appnav/src/main/res/values-sv/translations.xml @@ -0,0 +1,6 @@ + + + "Logga ut och uppgradera" + "%1$s stöder inte längre det gamla protokollet. Logga ut och logga in igen för att fortsätta använda appen." + "Din hemserver stöder inte längre det gamla protokollet. Logga ut och logga in igen för att fortsätta använda appen." + diff --git a/appnav/src/main/res/values-tr/translations.xml b/appnav/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..443bed5 --- /dev/null +++ b/appnav/src/main/res/values-tr/translations.xml @@ -0,0 +1,7 @@ + + + "Çıkış Yap ve Yükselt" + "%1$s artık eski protokolü destekleniyor. Uygulamayı kullanmaya devam etmek için lütfen çıkış yapın ve tekrar giriş yapın +" + "Ana sunucunuz artık eski protokolü desteklemiyor. Lütfen oturumu kapatın ve uygulamayı kullanmaya devam etmek için tekrar oturum açın." + diff --git a/appnav/src/main/res/values-uk/translations.xml b/appnav/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..b65ab1a --- /dev/null +++ b/appnav/src/main/res/values-uk/translations.xml @@ -0,0 +1,6 @@ + + + "Вийти та оновити" + "%1$s більше не підтримує старий протокол. Вийдіть і знов увійдіть, щоб продовжити користуватися застосунком." + "Ваш домашній сервер більше не підтримує старий протокол. Будь ласка, вийдіть і увійдіть знову, щоб продовжити використання програми." + diff --git a/appnav/src/main/res/values-ur/translations.xml b/appnav/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..80d1840 --- /dev/null +++ b/appnav/src/main/res/values-ur/translations.xml @@ -0,0 +1,5 @@ + + + "لاگ آؤٹ اور اپ گریڈ کریں" + "آپ کا homeserver اب پرانے پروٹوکول کو سپورٹ نہیں کرتا ہے۔ براہ کرم لاگ آؤٹ کریں اور ایپ کا استعمال جاری رکھنے کے لیے دوبارہ لاگ ان کریں۔" + diff --git a/appnav/src/main/res/values-uz/translations.xml b/appnav/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..a76aaae --- /dev/null +++ b/appnav/src/main/res/values-uz/translations.xml @@ -0,0 +1,6 @@ + + + "Tizmdan chiqish va yangilash" + "%1$s endi eski protokolni qoʻllab-quvvatlamaydi. Iltimos, ilovadan foydalanishni davom ettirish uchun tizimdan chiqing va qayta kiring." + "Sizning uy serveringiz endi eski protokolni qoʻllab-quvvatlamaydi. Iltimos, ilovadan foydalanishni davom ettirish uchun tizimdan qayta chiqib-kiring." + diff --git a/appnav/src/main/res/values-zh-rTW/translations.xml b/appnav/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..ea69a33 --- /dev/null +++ b/appnav/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,6 @@ + + + "登出並升級" + "%1$s 不再支援舊版通訊協定。請登出並重新登入以繼續使用應用程式。" + "您的家伺服器不再支援舊協定。請登出並重新登入以繼續使用應用程式。" + diff --git a/appnav/src/main/res/values-zh/translations.xml b/appnav/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..4064711 --- /dev/null +++ b/appnav/src/main/res/values-zh/translations.xml @@ -0,0 +1,6 @@ + + + "登出并升级" + "%1$s 不再支持旧协议。请注销并重新登录以继续使用该应用程序。" + "您的服务器不再支持旧协议。请登出并重新登录以继续使用此应用。" + diff --git a/appnav/src/main/res/values/localazy.xml b/appnav/src/main/res/values/localazy.xml new file mode 100644 index 0000000..c018fbf --- /dev/null +++ b/appnav/src/main/res/values/localazy.xml @@ -0,0 +1,6 @@ + + + "Log Out & Upgrade" + "%1$s no longer supports the old protocol. Please log out and log back in to continue using the app." + "Your homeserver no longer supports the old protocol. Please log out and log back in to continue using the app." + diff --git a/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt new file mode 100644 index 0000000..6cd7df0 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.assertThat +import io.element.android.appnav.di.RoomGraphFactory +import io.element.android.appnav.room.RoomNavigationTarget +import io.element.android.appnav.room.joined.FakeJoinedRoomLoadedFlowNodeCallback +import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode +import io.element.android.features.forward.api.ForwardEntryPoint +import io.element.android.features.forward.test.FakeForwardEntryPoint +import io.element.android.features.messages.api.MessagesEntryPoint +import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint +import io.element.android.features.space.api.SpaceEntryPoint +import io.element.android.libraries.architecture.childNode +import io.element.android.libraries.matrix.api.room.JoinedRoom +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.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.services.appnavstate.api.ActiveRoomsHolder +import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder +import io.element.android.services.appnavstate.test.FakeAppNavigationStateService +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class JoinedRoomLoadedFlowNodeTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private class FakeMessagesEntryPoint : MessagesEntryPoint { + var nodeId: String? = null + var parameters: MessagesEntryPoint.Params? = null + var callback: MessagesEntryPoint.Callback? = null + + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: MessagesEntryPoint.Params, + callback: MessagesEntryPoint.Callback, + ): Node { + parameters = params + this.callback = callback + return node(buildContext) {}.also { + nodeId = it.id + } + } + } + + private class FakeRoomGraphFactory : RoomGraphFactory { + override fun create(room: JoinedRoom): Any { + return Unit + } + } + + private class FakeRoomDetailsEntryPoint : RoomDetailsEntryPoint { + var nodeId: String? = null + + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: RoomDetailsEntryPoint.Params, + callback: RoomDetailsEntryPoint.Callback, + ) = node(buildContext) {}.also { + nodeId = it.id + } + } + + private class FakeSpaceEntryPoint : SpaceEntryPoint { + var nodeId: String? = null + + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + inputs: SpaceEntryPoint.Inputs, + callback: SpaceEntryPoint.Callback, + ) = node(buildContext) {}.also { + nodeId = it.id + } + } + + private fun TestScope.createJoinedRoomLoadedFlowNode( + plugins: List, + messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(), + roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(), + spaceEntryPoint: SpaceEntryPoint = FakeSpaceEntryPoint(), + forwardEntryPoint: ForwardEntryPoint = FakeForwardEntryPoint(), + activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(), + matrixClient: FakeMatrixClient = FakeMatrixClient(), + ) = JoinedRoomLoadedFlowNode( + buildContext = BuildContext.root(savedStateMap = null), + plugins = plugins, + messagesEntryPoint = messagesEntryPoint, + roomDetailsEntryPoint = roomDetailsEntryPoint, + spaceEntryPoint = spaceEntryPoint, + forwardEntryPoint = forwardEntryPoint, + appNavigationStateService = FakeAppNavigationStateService(), + sessionCoroutineScope = backgroundScope, + roomGraphFactory = FakeRoomGraphFactory(), + matrixClient = matrixClient, + activeRoomsHolder = activeRoomsHolder, + analyticsService = FakeAnalyticsService(), + ) + + @Test + fun `given a room flow node when initialized then it loads messages entry point if room is not space`() = runTest { + // GIVEN + val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {}, initialRoomInfo = aRoomInfo(isSpace = false))) + val fakeMessagesEntryPoint = FakeMessagesEntryPoint() + val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Root()) + val roomFlowNode = createJoinedRoomLoadedFlowNode( + plugins = listOf(inputs, FakeJoinedRoomLoadedFlowNodeCallback()), + messagesEntryPoint = fakeMessagesEntryPoint, + ) + // WHEN + val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() + + // THEN + assertThat(roomFlowNode.backstack.activeElement).isEqualTo(JoinedRoomLoadedFlowNode.NavTarget.Messages()) + roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(), Lifecycle.State.CREATED) + val messagesNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.Messages())!! + assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId) + } + + @Test + fun `given a room flow node when initialized then it loads space entry point if room is space`() = runTest { + // GIVEN + val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {}, initialRoomInfo = aRoomInfo(isSpace = true))) + val spaceEntryPoint = FakeSpaceEntryPoint() + val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Root()) + val roomFlowNode = createJoinedRoomLoadedFlowNode( + plugins = listOf(inputs, FakeJoinedRoomLoadedFlowNodeCallback()), + spaceEntryPoint = spaceEntryPoint, + ) + // WHEN + val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() + + // THEN + assertThat(roomFlowNode.backstack.activeElement).isEqualTo(JoinedRoomLoadedFlowNode.NavTarget.Space) + roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Space, Lifecycle.State.CREATED) + val spaceNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.Space)!! + assertThat(spaceNode.id).isEqualTo(spaceEntryPoint.nodeId) + } + + @Test + fun `given a room flow node when callback on room details is triggered then it loads room details entry point`() = runTest { + // GIVEN + val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {})) + val fakeMessagesEntryPoint = FakeMessagesEntryPoint() + val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint() + val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Root()) + val roomFlowNode = createJoinedRoomLoadedFlowNode( + plugins = listOf(inputs, FakeJoinedRoomLoadedFlowNodeCallback()), + messagesEntryPoint = fakeMessagesEntryPoint, + roomDetailsEntryPoint = fakeRoomDetailsEntryPoint, + ) + val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() + // WHEN + fakeMessagesEntryPoint.callback?.navigateToRoomDetails() + // THEN + roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails, Lifecycle.State.CREATED) + val roomDetailsNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails)!! + assertThat(roomDetailsNode.id).isEqualTo(fakeRoomDetailsEntryPoint.nodeId) + } + + @Test + fun `the ActiveRoomsHolder will be updated with the loaded room on create`() = runTest { + // GIVEN + val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {})) + val fakeMessagesEntryPoint = FakeMessagesEntryPoint() + val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint() + val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Root()) + val activeRoomsHolder = DefaultActiveRoomsHolder() + val roomFlowNode = createJoinedRoomLoadedFlowNode( + plugins = listOf(inputs, FakeJoinedRoomLoadedFlowNodeCallback()), + messagesEntryPoint = fakeMessagesEntryPoint, + roomDetailsEntryPoint = fakeRoomDetailsEntryPoint, + activeRoomsHolder = activeRoomsHolder, + ) + + assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNull() + val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() + // WHEN + roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(null), Lifecycle.State.CREATED) + // THEN + assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNotNull() + } + + @Test + fun `the ActiveRoomsHolder will be removed on destroy`() = runTest { + // GIVEN + val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {})) + val fakeMessagesEntryPoint = FakeMessagesEntryPoint() + val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint() + val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Root()) + val activeRoomsHolder = DefaultActiveRoomsHolder().apply { + addRoom(room) + } + val roomFlowNode = createJoinedRoomLoadedFlowNode( + plugins = listOf(inputs, FakeJoinedRoomLoadedFlowNodeCallback()), + messagesEntryPoint = fakeMessagesEntryPoint, + roomDetailsEntryPoint = fakeRoomDetailsEntryPoint, + activeRoomsHolder = activeRoomsHolder, + ) + val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() + roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(null), Lifecycle.State.CREATED) + assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNotNull() + // WHEN + roomFlowNode.updateLifecycleState(Lifecycle.State.DESTROYED) + // THEN + roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(null), Lifecycle.State.DESTROYED) + assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNull() + } +} 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 0000000..73d5513 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav + +import app.cash.molecule.RecompositionMode +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.api.crash.aCrashDetectionState +import io.element.android.features.rageshake.api.detection.aRageshakeDetectionState +import io.element.android.libraries.matrix.test.FakeSdkMetadata +import io.element.android.services.analytics.test.FakeAnalyticsService +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 io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class RootPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createRootPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.crashDetectionState.crashDetected).isFalse() + } + } + + @Test + fun `present - passes app error state`() = runTest { + val presenter = createRootPresenter( + appErrorService = DefaultAppErrorStateService( + stringProvider = FakeStringProvider(), + ).apply { + showError("Bad news", "Something bad happened") + } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + 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 createRootPresenter( + appErrorService: AppErrorStateService = DefaultAppErrorStateService( + stringProvider = FakeStringProvider(), + ), + ): RootPresenter { + return RootPresenter( + crashDetectionPresenter = { aCrashDetectionState() }, + rageshakeDetectionPresenter = { aRageshakeDetectionState() }, + appErrorStateService = appErrorService, + analyticsService = FakeAnalyticsService(), + sdkMetadata = FakeSdkMetadata("sha") + ) + } +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt new file mode 100644 index 0000000..7309ca6 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/SyncOrchestratorTest.kt @@ -0,0 +1,396 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav + +import io.element.android.appnav.di.SyncOrchestrator +import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.features.networkmonitor.test.FakeNetworkMonitor +import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.libraries.matrix.test.sync.FakeSyncService +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.services.appnavstate.test.FakeAppForegroundStateService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class SyncOrchestratorTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `when the sync wasn't running before, an initial sync will take place, even with no network`() = runTest { + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply { + startSyncLambda = startSyncRecorder + } + val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected) + val syncOrchestrator = createSyncOrchestrator( + syncService = syncService, + networkMonitor = networkMonitor, + ) + + // We start observing with an initial sync + syncOrchestrator.start() + + // Advance the time just enough to make sure the initial sync has run + advanceTimeBy(1.milliseconds) + startSyncRecorder.assertions().isCalledOnce() + } + + @Test + fun `when the sync wasn't running before, an initial sync will take place`() = runTest { + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply { + startSyncLambda = startSyncRecorder + } + val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected) + val syncOrchestrator = createSyncOrchestrator( + syncService = syncService, + networkMonitor = networkMonitor, + ) + + // We start observing with an initial sync + syncOrchestrator.start() + + // Advance the time just enough to make sure the initial sync has run + advanceTimeBy(1.milliseconds) + startSyncRecorder.assertions().isCalledOnce() + + // If we wait for a while, the sync will not be started again by the observer since it's already running + advanceTimeBy(10.seconds) + startSyncRecorder.assertions().isCalledOnce() + } + + @Test + fun `when the app goes to background and the sync was running, it will be stopped after a delay`() = runTest { + val stopSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Running).apply { + stopSyncLambda = stopSyncRecorder + } + val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected) + val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true) + val syncOrchestrator = createSyncOrchestrator( + syncService = syncService, + networkMonitor = networkMonitor, + appForegroundStateService = appForegroundStateService, + ) + + // We start observing + syncOrchestrator.observeStates() + + // Advance the time to make sure the orchestrator has had time to start processing the inputs + advanceTimeBy(100.milliseconds) + + // Stop sync was never called + stopSyncRecorder.assertions().isNeverCalled() + + // Now we send the app to background + appForegroundStateService.isInForeground.value = false + + // Stop sync will be called after some delay + stopSyncRecorder.assertions().isNeverCalled() + advanceTimeBy(10.seconds) + stopSyncRecorder.assertions().isCalledOnce() + } + + @Test + fun `when the app state changes several times in a short while, stop sync is only called once`() = runTest { + val stopSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Running).apply { + stopSyncLambda = stopSyncRecorder + } + val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected) + val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true) + val syncOrchestrator = createSyncOrchestrator( + syncService = syncService, + networkMonitor = networkMonitor, + appForegroundStateService = appForegroundStateService, + ) + + // We start observing + syncOrchestrator.observeStates() + + // Advance the time to make sure the orchestrator has had time to start processing the inputs + advanceTimeBy(100.milliseconds) + + // Stop sync was never called + stopSyncRecorder.assertions().isNeverCalled() + + // Now we send the app to background + appForegroundStateService.isInForeground.value = false + + // Ensure the stop action wasn't called yet + stopSyncRecorder.assertions().isNeverCalled() + advanceTimeBy(1.seconds) + appForegroundStateService.isInForeground.value = true + advanceTimeBy(1.seconds) + + // Ensure the stop action wasn't called yet either, since we didn't give it enough time to emit after the expected delay + stopSyncRecorder.assertions().isNeverCalled() + + // Now change it again and wait for enough time + appForegroundStateService.isInForeground.value = false + advanceTimeBy(4.seconds) + + // And confirm it's now called + stopSyncRecorder.assertions().isCalledOnce() + } + + @Test + fun `when the app was in background and we receive a notification, a sync will be started then stopped`() = runTest { + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val stopSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply { + startSyncLambda = startSyncRecorder + stopSyncLambda = stopSyncRecorder + } + val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected) + val appForegroundStateService = FakeAppForegroundStateService( + initialForegroundValue = false, + initialIsSyncingNotificationEventValue = false, + ) + val syncOrchestrator = createSyncOrchestrator( + syncService = syncService, + networkMonitor = networkMonitor, + appForegroundStateService = appForegroundStateService, + ) + + // We start observing + syncOrchestrator.observeStates() + + // Advance the time to make sure the orchestrator has had time to start processing the inputs + advanceTimeBy(100.milliseconds) + + // Start sync was never called + startSyncRecorder.assertions().isNeverCalled() + + // Now we receive a notification and need to sync + appForegroundStateService.updateIsSyncingNotificationEvent(true) + + // Start sync will be called shortly after + advanceTimeBy(1.milliseconds) + startSyncRecorder.assertions().isCalledOnce() + + // If the sync is running and we mark the notification sync as no longer necessary, the sync stops after a delay + syncService.emitSyncState(SyncState.Running) + appForegroundStateService.updateIsSyncingNotificationEvent(false) + + advanceTimeBy(10.seconds) + stopSyncRecorder.assertions().isCalledOnce() + } + + @Test + fun `when the app was in background and we join a call, a sync will be started`() = runTest { + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val stopSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply { + startSyncLambda = startSyncRecorder + stopSyncLambda = stopSyncRecorder + } + val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected) + val appForegroundStateService = FakeAppForegroundStateService( + initialForegroundValue = false, + initialIsSyncingNotificationEventValue = false, + ) + val syncOrchestrator = createSyncOrchestrator( + syncService = syncService, + networkMonitor = networkMonitor, + appForegroundStateService = appForegroundStateService, + ) + + // We start observing + syncOrchestrator.observeStates() + + // Advance the time to make sure the orchestrator has had time to start processing the inputs + advanceTimeBy(100.milliseconds) + + // Start sync was never called + startSyncRecorder.assertions().isNeverCalled() + + // Now we join a call + appForegroundStateService.updateIsInCallState(true) + + // Start sync will be called shortly after + advanceTimeBy(1.milliseconds) + startSyncRecorder.assertions().isCalledOnce() + + // If the sync is running and we mark the in-call state as false, the sync stops after a delay + syncService.emitSyncState(SyncState.Running) + appForegroundStateService.updateIsInCallState(false) + + advanceTimeBy(10.seconds) + stopSyncRecorder.assertions().isCalledOnce() + } + + @Test + fun `when the app was in background and we have an incoming ringing call, a sync will be started`() = runTest { + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val stopSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply { + startSyncLambda = startSyncRecorder + stopSyncLambda = stopSyncRecorder + } + val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected) + val appForegroundStateService = FakeAppForegroundStateService( + initialForegroundValue = false, + initialIsSyncingNotificationEventValue = false, + initialHasRingingCall = false, + ) + val syncOrchestrator = createSyncOrchestrator( + syncService = syncService, + networkMonitor = networkMonitor, + appForegroundStateService = appForegroundStateService, + ) + + // We start observing + syncOrchestrator.observeStates() + + // Advance the time to make sure the orchestrator has had time to start processing the inputs + advanceTimeBy(100.milliseconds) + + // Start sync was never called + startSyncRecorder.assertions().isNeverCalled() + + // Now we receive a ringing call + appForegroundStateService.updateHasRingingCall(true) + + // Start sync will be called shortly after + advanceTimeBy(1.milliseconds) + startSyncRecorder.assertions().isCalledOnce() + + // If the sync is running and the ringing call notification is now over, the sync stops after a delay + syncService.emitSyncState(SyncState.Running) + appForegroundStateService.updateHasRingingCall(false) + + advanceTimeBy(10.seconds) + stopSyncRecorder.assertions().isCalledOnce() + } + + @Test + fun `when the app is in foreground, we sync for a notification and a call is ongoing, the sync will only stop when all conditions are false`() = runTest { + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val stopSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Running).apply { + startSyncLambda = startSyncRecorder + stopSyncLambda = stopSyncRecorder + } + val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected) + val appForegroundStateService = FakeAppForegroundStateService( + initialForegroundValue = true, + initialIsSyncingNotificationEventValue = true, + initialIsInCallValue = true, + ) + val syncOrchestrator = createSyncOrchestrator( + syncService = syncService, + networkMonitor = networkMonitor, + appForegroundStateService = appForegroundStateService, + ) + + // We start observing + syncOrchestrator.observeStates() + + // Advance the time to make sure the orchestrator has had time to start processing the inputs + advanceTimeBy(100.milliseconds) + + // Start sync was never called + startSyncRecorder.assertions().isNeverCalled() + + // We send the app to background, it's still syncing + appForegroundStateService.givenIsInForeground(false) + advanceTimeBy(10.seconds) + stopSyncRecorder.assertions().isNeverCalled() + + // We stop the notification sync, it's still syncing + appForegroundStateService.updateIsSyncingNotificationEvent(false) + advanceTimeBy(10.seconds) + stopSyncRecorder.assertions().isNeverCalled() + + // We set the in-call state to false, now it stops syncing after a delay + appForegroundStateService.updateIsInCallState(false) + advanceTimeBy(10.seconds) + stopSyncRecorder.assertions().isCalledOnce() + } + + @Test + fun `if the sync was running, it's set to be stopped but something triggers a sync again, the sync is not stopped`() = runTest { + val stopSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Running).apply { + stopSyncLambda = stopSyncRecorder + } + val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected) + val appForegroundStateService = FakeAppForegroundStateService( + initialForegroundValue = true, + initialIsSyncingNotificationEventValue = false, + initialIsInCallValue = false, + ) + val syncOrchestrator = createSyncOrchestrator( + syncService = syncService, + networkMonitor = networkMonitor, + appForegroundStateService = appForegroundStateService, + ) + + // We start observing + syncOrchestrator.observeStates() + + // Advance the time to make sure the orchestrator has had time to start processing the inputs + advanceTimeBy(100.milliseconds) + + // This will set the sync to stop + appForegroundStateService.givenIsInForeground(false) + + // But if we reset it quickly before the stop sync takes place, the sync is not stopped + advanceTimeBy(2.seconds) + appForegroundStateService.givenIsInForeground(true) + + advanceTimeBy(10.seconds) + stopSyncRecorder.assertions().isNeverCalled() + } + + @Test + fun `when network is offline, sync service should not start`() = runTest { + val startSyncRecorder = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply { + startSyncLambda = startSyncRecorder + } + val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected) + val syncOrchestrator = createSyncOrchestrator( + syncService = syncService, + networkMonitor = networkMonitor, + ) + + // We start observing + syncOrchestrator.observeStates() + + // This should still not trigger a sync, since there is no network + advanceTimeBy(10.seconds) + startSyncRecorder.assertions().isNeverCalled() + } + + private fun TestScope.createSyncOrchestrator( + syncService: FakeSyncService = FakeSyncService(), + networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), + appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(), + ) = SyncOrchestrator( + syncService = syncService, + sessionCoroutineScope = backgroundScope, + networkMonitor = networkMonitor, + appForegroundStateService = appForegroundStateService, + dispatchers = testCoroutineDispatchers(), + analyticsService = FakeAnalyticsService(), + ) +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt new file mode 100644 index 0000000..56c20f7 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.di + +import com.bumble.appyx.core.state.MutableSavedStateMapImpl +import com.google.common.truth.Truth.assertThat +import io.element.android.features.networkmonitor.test.FakeNetworkMonitor +import io.element.android.libraries.matrix.api.sync.SyncService +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.auth.FakeMatrixAuthenticationService +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.services.appnavstate.test.FakeAppForegroundStateService +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class MatrixSessionCacheTest { + @Test + fun `test getOrNull`() = runTest { + val fakeAuthenticationService = FakeMatrixAuthenticationService() + val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory()) + assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull() + } + + @Test + fun `test getSyncOrchestratorOrNull`() = runTest { + val fakeAuthenticationService = FakeMatrixAuthenticationService() + val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory()) + + // With no matrix client there is no sync orchestrator + assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull() + assertThat(matrixSessionCache.getSyncOrchestrator(A_SESSION_ID)).isNull() + + // But as soon as we receive a client, we can get the sync orchestrator + val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope) + fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) + assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient) + assertThat(matrixSessionCache.getSyncOrchestrator(A_SESSION_ID)).isNotNull() + } + + @Test + fun `test getOrRestore`() = runTest { + val fakeAuthenticationService = FakeMatrixAuthenticationService() + val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory()) + val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope) + fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) + assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull() + assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient) + // Do it again to hit the cache + assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient) + assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient) + } + + @Test + fun `test remove`() = runTest { + val fakeAuthenticationService = FakeMatrixAuthenticationService() + val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory()) + val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope) + fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) + assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient) + assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient) + // Remove + matrixSessionCache.remove(A_SESSION_ID) + assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull() + } + + @Test + fun `test remove all`() = runTest { + val fakeAuthenticationService = FakeMatrixAuthenticationService() + val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory()) + val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope) + fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) + assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient) + assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient) + // Remove all + matrixSessionCache.removeAll() + assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull() + } + + @Test + fun `test save and restore`() = runTest { + val fakeAuthenticationService = FakeMatrixAuthenticationService() + val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory()) + val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope) + fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) + matrixSessionCache.getOrRestore(A_SESSION_ID) + val savedStateMap = MutableSavedStateMapImpl { true } + matrixSessionCache.saveIntoSavedState(savedStateMap) + assertThat(savedStateMap.size).isEqualTo(1) + // Test Restore with non-empty map + matrixSessionCache.restoreWithSavedState(savedStateMap) + // Empty the map + matrixSessionCache.removeAll() + assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull() + // Restore again + matrixSessionCache.restoreWithSavedState(savedStateMap) + assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient) + } + + @Test + fun `test AuthenticationService listenToNewMatrixClients emits a Client value and we save it`() = runTest { + val fakeAuthenticationService = FakeMatrixAuthenticationService() + val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory()) + assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull() + + fakeAuthenticationService.givenMatrixClient(FakeMatrixClient(sessionId = A_SESSION_ID, sessionCoroutineScope = backgroundScope)) + val loginSucceeded = fakeAuthenticationService.login("user", "pass") + + assertThat(loginSucceeded.isSuccess).isTrue() + assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNotNull() + } + + private fun TestScope.createSyncOrchestratorFactory() = object : SyncOrchestrator.Factory { + override fun create( + syncService: SyncService, + sessionCoroutineScope: CoroutineScope, + ): SyncOrchestrator { + return SyncOrchestrator( + syncService = syncService, + sessionCoroutineScope = sessionCoroutineScope, + appForegroundStateService = FakeAppForegroundStateService(), + networkMonitor = FakeNetworkMonitor(), + dispatchers = testCoroutineDispatchers(), + analyticsService = FakeAnalyticsService(), + ) + } + } +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt new file mode 100644 index 0000000..bf67360 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt @@ -0,0 +1,313 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.intent + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import androidx.core.net.toUri +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.api.LoginParams +import io.element.android.features.login.test.FakeLoginIntentResolver +import io.element.android.libraries.deeplink.api.DeeplinkData +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.permalink.PermalinkData +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_THREAD_ID +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.oidc.api.OidcAction +import io.element.android.libraries.oidc.test.FakeOidcIntentResolver +import io.element.android.tests.testutils.lambda.lambdaError +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class IntentResolverTest { + @Test + fun `resolve launcher intent should return null`() { + val sut = createIntentResolver() + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_MAIN + addCategory(Intent.CATEGORY_LAUNCHER) + } + val result = sut.resolve(intent) + assertThat(result).isNull() + } + + @Test + fun `test resolve navigation intent root`() { + val sut = createIntentResolver( + deeplinkParserResult = DeeplinkData.Root(A_SESSION_ID) + ) + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_VIEW + } + val result = sut.resolve(intent) + assertThat(result).isEqualTo( + ResolvedIntent.Navigation( + deeplinkData = DeeplinkData.Root( + sessionId = A_SESSION_ID, + ) + ) + ) + } + + @Test + fun `test resolve navigation intent room`() { + val sut = createIntentResolver( + deeplinkParserResult = DeeplinkData.Room( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + threadId = null, + eventId = null, + ) + ) + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_VIEW + } + val result = sut.resolve(intent) + assertThat(result).isEqualTo( + ResolvedIntent.Navigation( + deeplinkData = DeeplinkData.Room( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + threadId = null, + eventId = null, + ) + ) + ) + } + + @Test + fun `test resolve navigation intent thread`() { + val sut = createIntentResolver( + deeplinkParserResult = DeeplinkData.Room( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + threadId = A_THREAD_ID, + eventId = null, + ) + ) + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_VIEW + } + val result = sut.resolve(intent) + assertThat(result).isEqualTo( + ResolvedIntent.Navigation( + deeplinkData = DeeplinkData.Room( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + threadId = A_THREAD_ID, + eventId = null, + ) + ) + ) + } + + @Test + fun `test resolve navigation intent event`() { + val sut = createIntentResolver( + deeplinkParserResult = DeeplinkData.Room( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + threadId = null, + eventId = AN_EVENT_ID, + ) + ) + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_VIEW + } + val result = sut.resolve(intent) + assertThat(result).isEqualTo( + ResolvedIntent.Navigation( + deeplinkData = DeeplinkData.Room( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + threadId = null, + eventId = AN_EVENT_ID, + ) + ) + ) + } + + @Test + fun `test resolve navigation intent thread and event`() { + val sut = createIntentResolver( + deeplinkParserResult = DeeplinkData.Room( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + threadId = A_THREAD_ID, + eventId = AN_EVENT_ID, + ) + ) + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_VIEW + } + val result = sut.resolve(intent) + assertThat(result).isEqualTo( + ResolvedIntent.Navigation( + deeplinkData = DeeplinkData.Room( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + threadId = A_THREAD_ID, + eventId = AN_EVENT_ID, + ) + ) + ) + } + + @Test + fun `test resolve oidc`() { + val sut = createIntentResolver( + oidcIntentResolverResult = { OidcAction.GoBack() }, + ) + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_VIEW + data = "io.element.android:/?error=access_denied&state=IFF1UETGye2ZA8pO".toUri() + } + val result = sut.resolve(intent) + assertThat(result).isEqualTo( + ResolvedIntent.Oidc( + oidcAction = OidcAction.GoBack() + ) + ) + } + + @Test + fun `test resolve external permalink`() { + val permalinkData = PermalinkData.UserLink( + userId = UserId("@alice:matrix.org") + ) + val sut = createIntentResolver( + loginIntentResolverResult = { null }, + permalinkParserResult = { permalinkData }, + oidcIntentResolverResult = { null }, + ) + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_VIEW + data = "https://matrix.to/#/@alice:matrix.org".toUri() + } + val result = sut.resolve(intent) + assertThat(result).isEqualTo( + ResolvedIntent.Permalink( + permalinkData = permalinkData + ) + ) + } + + @Test + fun `test resolve external permalink, FallbackLink should be ignored`() { + val sut = createIntentResolver( + permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) }, + loginIntentResolverResult = { null }, + oidcIntentResolverResult = { null }, + ) + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_VIEW + data = "https://matrix.to/#/@alice:matrix.org".toUri() + } + val result = sut.resolve(intent) + assertThat(result).isNull() + } + + @Test + fun `test resolve external permalink, invalid action`() { + val permalinkData = PermalinkData.UserLink( + userId = UserId("@alice:matrix.org") + ) + val sut = createIntentResolver( + permalinkParserResult = { permalinkData }, + oidcIntentResolverResult = { null }, + ) + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_BATTERY_LOW + data = "https://matrix.to/invalid".toUri() + } + val result = sut.resolve(intent) + assertThat(result).isNull() + } + + @Test + fun `test incoming share simple`() { + val sut = createIntentResolver( + oidcIntentResolverResult = { null }, + ) + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_SEND + } + val result = sut.resolve(intent) + assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(intent = intent)) + } + + @Test + fun `test incoming share multiple`() { + val sut = createIntentResolver( + oidcIntentResolverResult = { null }, + ) + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_SEND_MULTIPLE + } + val result = sut.resolve(intent) + assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(intent = intent)) + } + + @Test + fun `test resolve invalid`() { + val sut = createIntentResolver( + permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) }, + loginIntentResolverResult = { null }, + oidcIntentResolverResult = { null }, + ) + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_VIEW + data = "io.element:/invalid".toUri() + } + val result = sut.resolve(intent) + assertThat(result).isNull() + } + + @Test + fun `test resolve login param`() { + val aLoginParams = LoginParams("accountProvider", null) + val sut = createIntentResolver( + loginIntentResolverResult = { aLoginParams }, + oidcIntentResolverResult = { null }, + ) + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_VIEW + data = "".toUri() + } + val result = sut.resolve(intent) + assertThat(result).isEqualTo(ResolvedIntent.Login(aLoginParams)) + } + + private fun createIntentResolver( + deeplinkParserResult: DeeplinkData? = null, + permalinkParserResult: (String) -> PermalinkData = { lambdaError() }, + loginIntentResolverResult: (String) -> LoginParams? = { lambdaError() }, + oidcIntentResolverResult: (Intent) -> OidcAction? = { lambdaError() }, + ): IntentResolver { + return IntentResolver( + deeplinkParser = { deeplinkParserResult }, + loginIntentResolver = FakeLoginIntentResolver( + parseResult = loginIntentResolverResult, + ), + oidcIntentResolver = FakeOidcIntentResolver( + resolveResult = oidcIntentResolverResult, + ), + permalinkParser = FakePermalinkParser( + result = permalinkParserResult + ), + ) + } +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/AnalyticsVerificationStateMappingTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/AnalyticsVerificationStateMappingTest.kt new file mode 100644 index 0000000..50b9df5 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/AnalyticsVerificationStateMappingTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.loggedin + +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.CryptoSessionStateChange +import im.vector.app.features.analytics.plan.UserProperties +import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class AnalyticsVerificationStateMappingTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `Test verification Mappings`() = runTest { + assertThat(SessionVerifiedStatus.Verified.toAnalyticsUserPropertyValue()) + .isEqualTo(UserProperties.VerificationState.Verified) + assertThat(SessionVerifiedStatus.NotVerified.toAnalyticsUserPropertyValue()) + .isEqualTo(UserProperties.VerificationState.NotVerified) + + assertThat(SessionVerifiedStatus.Verified.toAnalyticsStateChangeValue()) + .isEqualTo(CryptoSessionStateChange.VerificationState.Verified) + assertThat(SessionVerifiedStatus.NotVerified.toAnalyticsStateChangeValue()) + .isEqualTo(CryptoSessionStateChange.VerificationState.NotVerified) + } + + @Test + fun `Test recovery state Mappings`() = runTest { + assertThat(RecoveryState.UNKNOWN.toAnalyticsUserPropertyValue()) + .isNull() + assertThat(RecoveryState.WAITING_FOR_SYNC.toAnalyticsUserPropertyValue()) + .isNull() + assertThat(RecoveryState.INCOMPLETE.toAnalyticsUserPropertyValue()) + .isEqualTo(UserProperties.RecoveryState.Incomplete) + assertThat(RecoveryState.ENABLED.toAnalyticsUserPropertyValue()) + .isEqualTo(UserProperties.RecoveryState.Enabled) + assertThat(RecoveryState.DISABLED.toAnalyticsUserPropertyValue()) + .isEqualTo(UserProperties.RecoveryState.Disabled) + + assertThat(RecoveryState.UNKNOWN.toAnalyticsStateChangeValue()) + .isNull() + assertThat(RecoveryState.WAITING_FOR_SYNC.toAnalyticsStateChangeValue()) + .isNull() + assertThat(RecoveryState.INCOMPLETE.toAnalyticsStateChangeValue()) + .isEqualTo(CryptoSessionStateChange.RecoveryState.Incomplete) + assertThat(RecoveryState.ENABLED.toAnalyticsStateChangeValue()) + .isEqualTo(CryptoSessionStateChange.RecoveryState.Enabled) + assertThat(RecoveryState.DISABLED.toAnalyticsStateChangeValue()) + .isEqualTo(CryptoSessionStateChange.RecoveryState.Disabled) + } +} 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 0000000..849dfa8 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt @@ -0,0 +1,348 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.appnav.loggedin + +import app.cash.turbine.ReceiveTurbine +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.CryptoSessionStateChange +import im.vector.app.features.analytics.plan.UserProperties +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.api.oidc.AccountManagementAction +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion +import io.element.android.libraries.matrix.api.sync.SyncState +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_EXCEPTION +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.core.aBuildMeta +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.libraries.matrix.test.sync.FakeSyncService +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.api.PusherRegistrationFailure +import io.element.android.libraries.push.test.FakePushService +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.libraries.pushproviders.test.FakePushProvider +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class LoggedInPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + createLoggedInPresenter().test { + val initialState = awaitItem() + assertThat(initialState.showSyncSpinner).isFalse() + assertThat(initialState.pusherRegistrationState.isUninitialized()).isTrue() + assertThat(initialState.ignoreRegistrationError).isFalse() + } + } + + @Test + fun `present - ensure that account urls are preloaded`() = runTest { + val accountManagementUrlResult = lambdaRecorder> { Result.success("aUrl") } + val matrixClient = FakeMatrixClient( + accountManagementUrlResult = accountManagementUrlResult, + ) + createLoggedInPresenter( + matrixClient = matrixClient, + ).test { + awaitItem() + advanceUntilIdle() + accountManagementUrlResult.assertions().isCalledExactly(2) + .withSequence( + listOf(value(AccountManagementAction.Profile)), + listOf(value(AccountManagementAction.SessionsList)), + ) + } + } + + @Test + fun `present - show sync spinner`() = runTest { + val roomListService = FakeRoomListService() + createLoggedInPresenter( + syncState = SyncState.Running, + matrixClient = FakeMatrixClient(roomListService = roomListService), + ).test { + val initialState = awaitItem() + assertThat(initialState.showSyncSpinner).isFalse() + roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show) + consumeItemsUntilPredicate { it.showSyncSpinner } + roomListService.postSyncIndicator(RoomListService.SyncIndicator.Hide) + consumeItemsUntilPredicate { !it.showSyncSpinner } + } + } + + @Test + fun `present - report crypto status analytics`() = runTest { + val analyticsService = FakeAnalyticsService() + val roomListService = FakeRoomListService() + val verificationService = FakeSessionVerificationService() + val encryptionService = FakeEncryptionService() + val buildMeta = aBuildMeta() + LoggedInPresenter( + matrixClient = FakeMatrixClient( + roomListService = roomListService, + encryptionService = encryptionService, + ), + syncService = FakeSyncService(initialSyncState = SyncState.Running), + pushService = FakePushService( + ensurePusherIsRegisteredResult = { Result.success(Unit) }, + ), + sessionVerificationService = verificationService, + analyticsService = analyticsService, + encryptionService = encryptionService, + buildMeta = buildMeta, + ).test { + encryptionService.emitRecoveryState(RecoveryState.UNKNOWN) + encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE) + verificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified) + skipItems(2) + assertThat(analyticsService.capturedEvents.size).isEqualTo(1) + assertThat(analyticsService.capturedEvents[0]).isInstanceOf(CryptoSessionStateChange::class.java) + assertThat(analyticsService.capturedUserProperties.size).isEqualTo(1) + assertThat(analyticsService.capturedUserProperties[0].recoveryState).isEqualTo(UserProperties.RecoveryState.Incomplete) + assertThat(analyticsService.capturedUserProperties[0].verificationState).isEqualTo(UserProperties.VerificationState.Verified) + // ensure a sync status change does not trigger a new capture + roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show) + skipItems(1) + assertThat(analyticsService.capturedEvents.size).isEqualTo(1) + } + } + + @Test + fun `present - ensure default pusher is not registered if session is not verified`() = runTest { + val lambda = lambdaRecorder> { + Result.success(Unit) + } + val pushService = createFakePushService(ensurePusherIsRegisteredResult = lambda) + val verificationService = FakeSessionVerificationService( + initialSessionVerifiedStatus = SessionVerifiedStatus.NotVerified + ) + createLoggedInPresenter( + pushService = pushService, + sessionVerificationService = verificationService, + ).test { + val finalState = awaitFirstItem() + assertThat(finalState.pusherRegistrationState.errorOrNull()) + .isInstanceOf(PusherRegistrationFailure.AccountNotVerified::class.java) + lambda.assertions().isNeverCalled() + } + } + + @Test + fun `present - ensure default pusher is registered with default provider`() = runTest { + val lambda = lambdaRecorder> { Result.success(Unit) } + val sessionVerificationService = FakeSessionVerificationService( + initialSessionVerifiedStatus = SessionVerifiedStatus.Verified + ) + val pushService = createFakePushService( + ensurePusherIsRegisteredResult = lambda, + ) + createLoggedInPresenter( + pushService = pushService, + sessionVerificationService = sessionVerificationService, + matrixClient = FakeMatrixClient( + accountManagementUrlResult = { Result.success(null) }, + ), + ).test { + val finalState = awaitFirstItem() + assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue() + lambda.assertions() + .isCalledOnce() + } + } + + @Test + fun `present - ensure default pusher is registered with default provider - fail to register`() = runTest { + val lambda = lambdaRecorder> { Result.failure(AN_EXCEPTION) } + val sessionVerificationService = FakeSessionVerificationService( + initialSessionVerifiedStatus = SessionVerifiedStatus.Verified + ) + val pushService = createFakePushService( + ensurePusherIsRegisteredResult = lambda, + ) + createLoggedInPresenter( + pushService = pushService, + sessionVerificationService = sessionVerificationService, + matrixClient = FakeMatrixClient( + accountManagementUrlResult = { Result.success(null) }, + ), + ).test { + val finalState = awaitFirstItem() + assertThat(finalState.pusherRegistrationState.isFailure()).isTrue() + lambda.assertions() + .isCalledOnce() + // Reset the error and do not show again + finalState.eventSink(LoggedInEvents.CloseErrorDialog(doNotShowAgain = false)) + val lastState = awaitItem() + assertThat(lastState.pusherRegistrationState.isUninitialized()).isTrue() + assertThat(lastState.ignoreRegistrationError).isFalse() + } + } + + @Test + fun `present - ensure default pusher is registered with default provider - fail to register - do not show again`() = runTest { + val lambda = lambdaRecorder> { Result.failure(AN_EXCEPTION) } + val setIgnoreRegistrationErrorLambda = lambdaRecorder { _, _ -> } + val sessionVerificationService = FakeSessionVerificationService( + initialSessionVerifiedStatus = SessionVerifiedStatus.Verified + ) + val pushService = createFakePushService( + ensurePusherIsRegisteredResult = lambda, + setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda, + ) + createLoggedInPresenter( + pushService = pushService, + sessionVerificationService = sessionVerificationService, + matrixClient = FakeMatrixClient( + accountManagementUrlResult = { Result.success(null) }, + ), + ).test { + val finalState = awaitFirstItem() + assertThat(finalState.pusherRegistrationState.isFailure()).isTrue() + lambda.assertions() + .isCalledOnce() + // Reset the error and do not show again + finalState.eventSink(LoggedInEvents.CloseErrorDialog(doNotShowAgain = true)) + skipItems(1) + setIgnoreRegistrationErrorLambda.assertions() + .isCalledOnce() + .with( + // SessionId + value(A_SESSION_ID), + // Ignore + value(true), + ) + val lastState = awaitItem() + assertThat(lastState.pusherRegistrationState.isUninitialized()).isTrue() + assertThat(lastState.ignoreRegistrationError).isTrue() + } + } + + private fun createFakePushService( + pushProvider0: PushProvider? = FakePushProvider( + index = 0, + name = "aFakePushProvider0", + distributors = listOf(Distributor("aDistributorValue0", "aDistributorName0")), + currentDistributor = { null }, + ), + pushProvider1: PushProvider? = FakePushProvider( + index = 1, + name = "aFakePushProvider1", + distributors = listOf(Distributor("aDistributorValue1", "aDistributorName1")), + currentDistributor = { null }, + ), + ensurePusherIsRegisteredResult: () -> Result = { + Result.success(Unit) + }, + selectPushProviderLambda: (SessionId, PushProvider) -> Unit = { _, _ -> lambdaError() }, + currentPushProvider: (SessionId) -> PushProvider? = { null }, + setIgnoreRegistrationErrorLambda: (SessionId, Boolean) -> Unit = { _, _ -> lambdaError() }, + ): PushService { + return FakePushService( + availablePushProviders = listOfNotNull(pushProvider0, pushProvider1), + ensurePusherIsRegisteredResult = ensurePusherIsRegisteredResult, + currentPushProvider = currentPushProvider, + selectPushProviderLambda = selectPushProviderLambda, + setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda, + ) + } + + @Test + fun `present - CheckSlidingSyncProxyAvailability forces the sliding sync migration under the right circumstances`() = runTest { + // The migration will be forced if the user is not using the native sliding sync + val matrixClient = FakeMatrixClient( + currentSlidingSyncVersionLambda = { Result.success(SlidingSyncVersion.Proxy) }, + ) + createLoggedInPresenter( + matrixClient = matrixClient, + ).test { + val initialState = awaitItem() + assertThat(initialState.forceNativeSlidingSyncMigration).isFalse() + initialState.eventSink(LoggedInEvents.CheckSlidingSyncProxyAvailability) + assertThat(awaitItem().forceNativeSlidingSyncMigration).isTrue() + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - LogoutAndMigrateToNativeSlidingSync logs out the user`() = runTest { + val logoutLambda = lambdaRecorder { userInitiated, ignoreSdkError -> + assertThat(userInitiated).isTrue() + assertThat(ignoreSdkError).isTrue() + } + val matrixClient = FakeMatrixClient( + accountManagementUrlResult = { Result.success(null) }, + ).apply { + this.logoutLambda = logoutLambda + } + createLoggedInPresenter( + matrixClient = matrixClient, + ).test { + val initialState = awaitItem() + + initialState.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync) + + advanceUntilIdle() + + assertThat(logoutLambda.assertions().isCalledOnce()) + } + } + + private suspend fun ReceiveTurbine.awaitFirstItem(): T { + skipItems(1) + return awaitItem() + } + + private fun createLoggedInPresenter( + syncState: SyncState = SyncState.Running, + analyticsService: AnalyticsService = FakeAnalyticsService(), + sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(), + encryptionService: EncryptionService = FakeEncryptionService(), + pushService: PushService = FakePushService(), + matrixClient: MatrixClient = FakeMatrixClient( + accountManagementUrlResult = { Result.success(null) }, + ), + buildMeta: BuildMeta = aBuildMeta(), + ): LoggedInPresenter { + return LoggedInPresenter( + matrixClient = matrixClient, + syncService = FakeSyncService(initialSyncState = syncState), + pushService = pushService, + sessionVerificationService = sessionVerificationService, + analyticsService = analyticsService, + encryptionService = encryptionService, + buildMeta = buildMeta, + ) + } +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigrationTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigrationTest.kt new file mode 100644 index 0000000..8e081bd --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/MediaPreviewConfigMigrationTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:Suppress("DEPRECATION") + +package io.element.android.appnav.loggedin + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.media.MediaPreviewConfig +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.matrix.test.media.FakeMediaPreviewService +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class MediaPreviewConfigMigrationTest { + @Test + fun `when no local data exists, migration does nothing`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore() + val mediaPreviewService = FakeMediaPreviewService( + fetchMediaPreviewConfigResult = { Result.success(null) } + ) + val migration = createMigration(appPreferencesStore, mediaPreviewService) + + migration().join() + + // Verify no calls were made to set server config + // since there's nothing to migrate + } + + @Test + fun `when local data exists and server has config, clears local data`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore().apply { + setHideInviteAvatars(true) + setTimelineMediaPreviewValue(MediaPreviewValue.Private) + } + val serverConfig = MediaPreviewConfig( + hideInviteAvatar = false, + mediaPreviewValue = MediaPreviewValue.On + ) + val mediaPreviewService = FakeMediaPreviewService( + fetchMediaPreviewConfigResult = { Result.success(serverConfig) } + ) + val migration = createMigration(appPreferencesStore, mediaPreviewService) + + migration().join() + + // Verify local data was cleared + assertThat(appPreferencesStore.getHideInviteAvatarsFlow().first()).isNull() + assertThat(appPreferencesStore.getTimelineMediaPreviewValueFlow().first()).isNull() + } + + @Test + fun `when local hideInviteAvatars exists and server has no config, migrates to server`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore().apply { + setHideInviteAvatars(true) + } + var setHideInviteAvatarsValue: Boolean? = null + val mediaPreviewService = FakeMediaPreviewService( + fetchMediaPreviewConfigResult = { Result.success(null) }, + setHideInviteAvatarsResult = { value -> + setHideInviteAvatarsValue = value + Result.success(Unit) + } + ) + val migration = createMigration(appPreferencesStore, mediaPreviewService) + + migration().join() + + // Verify server was updated with local value + assertThat(setHideInviteAvatarsValue).isTrue() + // Verify local data was cleared + assertThat(appPreferencesStore.getHideInviteAvatarsFlow().first()).isNull() + } + + @Test + fun `when local mediaPreviewValue exists and server has no config, migrates to server`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore().apply { + setTimelineMediaPreviewValue(MediaPreviewValue.Private) + } + var setMediaPreviewValue: MediaPreviewValue? = null + val mediaPreviewService = FakeMediaPreviewService( + fetchMediaPreviewConfigResult = { Result.success(null) }, + setMediaPreviewValueResult = { value -> + setMediaPreviewValue = value + Result.success(Unit) + } + ) + val migration = createMigration(appPreferencesStore, mediaPreviewService) + + migration().join() + + // Verify server was updated with local value + assertThat(setMediaPreviewValue).isEqualTo(MediaPreviewValue.Private) + // Verify local data was cleared + assertThat(appPreferencesStore.getTimelineMediaPreviewValueFlow().first()).isNull() + } + + @Test + fun `when both local values exist and server has no config, migrates both to server`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore().apply { + setHideInviteAvatars(true) + setTimelineMediaPreviewValue(MediaPreviewValue.Off) + } + var setHideInviteAvatarsValue: Boolean? = null + var setMediaPreviewValue: MediaPreviewValue? = null + val mediaPreviewService = FakeMediaPreviewService( + fetchMediaPreviewConfigResult = { Result.success(null) }, + setHideInviteAvatarsResult = { value -> + setHideInviteAvatarsValue = value + Result.success(Unit) + }, + setMediaPreviewValueResult = { value -> + setMediaPreviewValue = value + Result.success(Unit) + } + ) + val migration = createMigration(appPreferencesStore, mediaPreviewService) + + migration().join() + + // Verify server was updated with both local values + assertThat(setHideInviteAvatarsValue).isTrue() + assertThat(setMediaPreviewValue).isEqualTo(MediaPreviewValue.Off) + // Verify local data was cleared + assertThat(appPreferencesStore.getHideInviteAvatarsFlow().first()).isNull() + assertThat(appPreferencesStore.getTimelineMediaPreviewValueFlow().first()).isNull() + } + + @Test + fun `when fetch config fails, migration does nothing`() = runTest { + val appPreferencesStore = InMemoryAppPreferencesStore().apply { + setHideInviteAvatars(true) + setTimelineMediaPreviewValue(MediaPreviewValue.Private) + } + val mediaPreviewService = FakeMediaPreviewService( + fetchMediaPreviewConfigResult = { Result.failure(Exception("Network error")) } + ) + val migration = createMigration(appPreferencesStore, mediaPreviewService) + + migration().join() + + // Verify local data was not cleared since migration failed + assertThat(appPreferencesStore.getHideInviteAvatarsFlow().first()).isTrue() + assertThat(appPreferencesStore.getTimelineMediaPreviewValueFlow().first()).isEqualTo(MediaPreviewValue.Private) + } + + private fun TestScope.createMigration( + appPreferencesStore: InMemoryAppPreferencesStore, + mediaPreviewService: FakeMediaPreviewService + ) = MediaPreviewConfigMigration( + mediaPreviewService = mediaPreviewService, + appPreferencesStore = appPreferencesStore, + sessionCoroutineScope = this + ) +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/SendQueuesTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/SendQueuesTest.kt new file mode 100644 index 0000000..3c5d8dc --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/SendQueuesTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.loggedin + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.sync.FakeSyncService +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SendQueuesTest { + private val matrixClient = FakeMatrixClient() + private val syncService = FakeSyncService(initialSyncState = SyncState.Running) + private val sut = SendQueues(matrixClient, syncService) + + @Test + fun `test network status online and sending queue failed`() = runTest { + val sendQueueDisabledFlow = MutableSharedFlow(replay = 1) + val setAllSendQueuesEnabledLambda = lambdaRecorder { _: Boolean -> } + matrixClient.sendQueueDisabledFlow = sendQueueDisabledFlow + matrixClient.setAllSendQueuesEnabledLambda = setAllSendQueuesEnabledLambda + val setRoomSendQueueEnabledLambda = lambdaRecorder { _: Boolean -> } + val room = FakeJoinedRoom( + setSendQueueEnabledResult = setRoomSendQueueEnabledLambda + ) + matrixClient.givenGetRoomResult(room.roomId, room) + sut.launchIn(backgroundScope) + + sendQueueDisabledFlow.emit(room.roomId) + advanceTimeBy(SEND_QUEUES_RETRY_DELAY_MILLIS) + runCurrent() + + assert(setAllSendQueuesEnabledLambda) + .isCalledOnce() + .with(value(true)) + + assert(setRoomSendQueueEnabledLambda).isNeverCalled() + } + + @Test + fun `test sync state offline and sending queue failed`() = runTest { + val sendQueueDisabledFlow = MutableSharedFlow(replay = 1) + + val setAllSendQueuesEnabledLambda = lambdaRecorder { _: Boolean -> } + matrixClient.sendQueueDisabledFlow = sendQueueDisabledFlow + matrixClient.setAllSendQueuesEnabledLambda = setAllSendQueuesEnabledLambda + syncService.emitSyncState(SyncState.Offline) + val setRoomSendQueueEnabledLambda = lambdaRecorder { _: Boolean -> } + val room = FakeJoinedRoom( + setSendQueueEnabledResult = setRoomSendQueueEnabledLambda + ) + matrixClient.givenGetRoomResult(room.roomId, room) + + sut.launchIn(backgroundScope) + + sendQueueDisabledFlow.emit(room.roomId) + advanceTimeBy(SEND_QUEUES_RETRY_DELAY_MILLIS) + runCurrent() + + assert(setAllSendQueuesEnabledLambda).isNeverCalled() + assert(setRoomSendQueueEnabledLambda).isNeverCalled() + } +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingBaseRoomStateFlowFactoryTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingBaseRoomStateFlowFactoryTest.kt new file mode 100644 index 0000000..14128ac --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingBaseRoomStateFlowFactoryTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.room + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.roomlist.RoomList +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.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.libraries.matrix.ui.room.LoadingRoomState +import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LoadingBaseRoomStateFlowFactoryTest { + @Test + fun `flow should emit only Loaded when we already pass a JoinedRoom`() = runTest { + val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(sessionId = A_SESSION_ID, roomId = A_ROOM_ID)) + val matrixClient = FakeMatrixClient(A_SESSION_ID) + val flowFactory = LoadingRoomStateFlowFactory(matrixClient) + flowFactory + .create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = room) + .test { + assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room)) + ensureAllEventsConsumed() + } + } + + @Test + fun `flow should emit Loading and then Loaded when there is a room in cache`() = runTest { + val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(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(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = null) + .test { + assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading) + 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 = FakeJoinedRoom(baseRoom = FakeBaseRoom(sessionId = A_SESSION_ID, roomId = A_ROOM_ID)) + val roomListService = FakeRoomListService() + val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService) + val flowFactory = LoadingRoomStateFlowFactory(matrixClient) + flowFactory + .create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = null) + .test { + assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading) + matrixClient.givenGetRoomResult(A_ROOM_ID, room) + roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) + 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 roomListService = FakeRoomListService() + val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService) + val flowFactory = LoadingRoomStateFlowFactory(matrixClient) + flowFactory + .create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = null) + .test { + assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading) + roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) + assertThat(awaitItem()).isEqualTo(LoadingRoomState.Error) + } + } +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/room/joined/FakeJoinedRoomLoadedFlowNodeCallback.kt b/appnav/src/test/kotlin/io/element/android/appnav/room/joined/FakeJoinedRoomLoadedFlowNodeCallback.kt new file mode 100644 index 0000000..40778ae --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/room/joined/FakeJoinedRoomLoadedFlowNodeCallback.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.appnav.room.joined + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeJoinedRoomLoadedFlowNodeCallback : JoinedRoomLoadedFlowNode.Callback { + override fun navigateToRoom(roomId: RoomId, serverNames: List) = lambdaError() + override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError() + override fun navigateToGlobalNotificationSettings() = lambdaError() +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..19aaf78 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,237 @@ +import org.gradle.accessors.dm.LibrariesForLibs + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + id("io.element.android-root") + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.dependencycheck) apply false + alias(libs.plugins.roborazzi) apply false + alias(libs.plugins.dependencyanalysis) + alias(libs.plugins.detekt) + alias(libs.plugins.ktlint) + alias(libs.plugins.dependencygraph) + alias(libs.plugins.sonarqube) +} + +tasks.register("clean").configure { + delete(rootProject.layout.buildDirectory) +} + +private val ktLintVersion = the().versions.ktlint.get() + +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.from(files("$rootDir/tools/detekt/detekt.yml")) + } + dependencies { + detektPlugins("io.nlopez.compose.rules:detekt:0.4.28") + detektPlugins(project(":tests:detekt-rules")) + } + + tasks.withType().configureEach { + exclude("io/element/android/tests/konsist/failures/**") + } + + // KtLint + apply { + plugin("org.jlleitschuh.gradle.ktlint") + } + + // See https://github.com/JLLeitschuh/ktlint-gradle#configuration + configure { + version = ktLintVersion + android = true + ignoreFailures = false + enableExperimentalRules = true + // display the corresponding rule + verbose = true + reporters { + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN) + // To have XML report for Danger + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE) + } + val generatedPath = "${layout.buildDirectory.asFile.get()}/generated/" + filter { + exclude { element -> element.file.path.contains(generatedPath) } + exclude("io/element/android/tests/konsist/failures/**") + } + } + // Dependency check + apply { + plugin("org.owasp.dependencycheck") + } + + tasks.withType { + compilerOptions { + // Warnings are potential errors, so stop ignoring them + // This is disabled by default, but the CI will enforce this. + // You can override by passing `-PallWarningsAsErrors=true` in the command line + // Or add a line with "allWarningsAsErrors=true" in your ~/.gradle/gradle.properties file + allWarningsAsErrors = project.properties["allWarningsAsErrors"] == "true" + + // Uncomment to suppress Compose Kotlin compiler compatibility warning +// freeCompilerArgs.addAll(listOf("-P", "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true")) + + // Fix compilation warning for annotations + // See https://youtrack.jetbrains.com/issue/KT-73255/Change-defaulting-rule-for-annotations for more details + freeCompilerArgs.add("-Xannotation-default-target=first-only") + // Opt-in to context receivers + freeCompilerArgs.add("-Xcontext-parameters") + } + } +} + +// See https://github.com/autonomousapps/dependency-analysis-android-gradle-plugin/wiki/Customizing-plugin-behavior +dependencyAnalysis { + issues { + all { + onUnusedDependencies { + exclude("com.jakewharton.timber:timber") + } + onUnusedAnnotationProcessors {} + onRedundantPlugins {} + onIncorrectConfiguration {} + } + } +} + +// To run a sonar analysis: +// Run './gradlew sonar -Dsonar.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=element-x-android +sonar { + properties { + property("sonar.projectName", "element-x-android") + property("sonar.projectKey", "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/element-hq/element-x-android/") + property("sonar.links.ci", "https://github.com/element-hq/element-x-android/actions") + property("sonar.links.scm", "https://github.com/element-hq/element-x-android/") + property("sonar.links.issue", "https://github.com/element-hq/element-x-android/issues") + property("sonar.organization", "element-hq") + 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 { + tasks.withType { + 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 = "2g" + // Record all the languages? + if (project.hasProperty("allLanguagesNoEnglish")) { + // Do not record English language + exclude("ui/*.class") + } else if (project.hasProperty("allLanguages").not()) { + // Do not record other languages + exclude("translations/*.class") + } + } else { + // Disable screenshot tests by default + exclude("ui/*.class") + exclude("translations/*.class") + } + } +} + +// Register quality check tasks. +tasks.register("runQualityChecks") { + dependsOn(":tests:konsist:testDebugUnitTest") + dependsOn(":app:lintGplayDebug") + project.subprojects { + tasks.findByPath("$path:lintDebug")?.let { dependsOn(it) } + tasks.findByName("detekt")?.let { dependsOn(it) } + tasks.findByName("ktlintCheck")?.let { dependsOn(it) } + // tasks.findByName("buildHealth")?.let { dependsOn(it) } + } + dependsOn(":app:knitCheck") + + // Make sure all checks run even if some fail + gradle.startParameter.isContinueOnFailure = true +} + +// 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) +} + +// Make sure to delete old snapshot before recording new ones +subprojects { + val screenshotsDir = File("${project.projectDir}/screenshots") + val removeOldScreenshotsTask = tasks.register("removeOldScreenshots") { + onlyIf { screenshotsDir.exists() } + doFirst { + println("Delete previous screenshots located at $screenshotsDir\n") + screenshotsDir.deleteRecursively() + } + } + tasks.findByName("recordRoborazzi")?.dependsOn(removeOldScreenshotsTask) + tasks.findByName("recordRoborazziDebug")?.dependsOn(removeOldScreenshotsTask) + tasks.findByName("recordRoborazziRelease")?.dependsOn(removeOldScreenshotsTask) +} + +subprojects { + tasks.withType().configureEach { + compilerOptions { + if (project.findProperty("composeCompilerReports") == "true") { + freeCompilerArgs.addAll( + listOf( + "-P", + "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + + "${project.layout.buildDirectory.asFile.get().absolutePath}/compose_compiler" + ) + ) + } + if (project.findProperty("composeCompilerMetrics") == "true") { + freeCompilerArgs.addAll( + listOf( + "-P", + "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + + "${project.layout.buildDirectory.asFile.get().absolutePath}/compose_compiler" + ) + ) + } + } + } +} diff --git a/codegen/.gitignore b/codegen/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/codegen/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/codegen/build.gradle.kts b/codegen/build.gradle.kts new file mode 100644 index 0000000..5c71c17 --- /dev/null +++ b/codegen/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + alias(libs.plugins.kotlin.jvm) +} + +dependencies { + implementation(projects.annotations) + implementation(libs.metro.runtime) + implementation(libs.kotlin.compiler) + implementation(libs.kotlinpoet) + implementation(libs.ksp.plugin) + implementation(libs.kotlinpoet.ksp) +} diff --git a/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt b/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt new file mode 100644 index 0000000..7ddf80b --- /dev/null +++ b/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.codegen + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.getConstructors +import com.google.devtools.ksp.isAnnotationPresent +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.validate +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 com.squareup.kotlinpoet.ksp.toTypeName +import com.squareup.kotlinpoet.ksp.writeTo +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.IntoMap +import dev.zacsweers.metro.Origin +import io.element.android.annotations.ContributesNode +import org.jetbrains.kotlin.name.FqName + +class ContributesNodeProcessor( + private val logger: KSPLogger, + private val codeGenerator: CodeGenerator, + private val config: Config, +) : SymbolProcessor { + data class Config( + val enableLogging: Boolean = false, + ) + + override fun process(resolver: Resolver): List { + val annotatedSymbols = resolver.getSymbolsWithAnnotation(ContributesNode::class.qualifiedName!!) + .filterIsInstance() + + val (validSymbols, invalidSymbols) = annotatedSymbols.partition { it.validate() } + + if (validSymbols.isEmpty()) return invalidSymbols + + for (ksClass in validSymbols) { + if (config.enableLogging) { + logger.warn("Processing ${ksClass.qualifiedName?.asString()}") + } + generateModule(ksClass) + generateFactory(ksClass) + } + + return invalidSymbols + } + + private fun generateModule(ksClass: KSClassDeclaration) { + val annotation = ksClass.annotations.find { it.shortName.asString() == "ContributesNode" }!! + val scope = annotation.arguments.find { it.name?.asString() == "scope" }!!.value as KSType + val modulePackage = ksClass.packageName.asString() + val moduleClassName = "${ksClass.simpleName.asString()}_Module" + val nodeClassName = ClassName.bestGuess(ksClass.qualifiedName!!.asString()) + val content = FileSpec.builder( + packageName = modulePackage, + fileName = moduleClassName, + ) + .addType( + TypeSpec.interfaceBuilder(moduleClassName) + .addAnnotation(AnnotationSpec.builder(Origin::class).addMember(CLASS_PLACEHOLDER, nodeClassName).build()) + .addAnnotation(BindingContainer::class) + .addAnnotation(AnnotationSpec.builder(ContributesTo::class).addMember(CLASS_PLACEHOLDER, scope.toTypeName()).build()) + .addFunction( + FunSpec.builder("bind${ksClass.simpleName.asString()}Factory") + .addModifiers(KModifier.ABSTRACT) + .addParameter("factory", ClassName(modulePackage, "${ksClass.simpleName.asString()}_AssistedFactory")) + .returns(ClassName.bestGuess(assistedNodeFactoryFqName.asString()).parameterizedBy(STAR)) + .addAnnotation(Binds::class) + .addAnnotation(IntoMap::class) + .addAnnotation( + AnnotationSpec.Companion.builder(ClassName.bestGuess(nodeKeyFqName.asString())).addMember( + CLASS_PLACEHOLDER, + ClassName.bestGuess(ksClass.qualifiedName!!.asString()) + ).build() + ) + .build(), + ) + .build(), + ) + .build() + + content.writeTo( + codeGenerator = codeGenerator, + dependencies = Dependencies( + aggregating = false, + ksClass.containingFile!! + ), + ) + } + + @OptIn(KspExperimental::class) + private fun generateFactory(ksClass: KSClassDeclaration) { + val generatedPackage = ksClass.packageName.asString() + val assistedFactoryClassName = "${ksClass.simpleName.asString()}_AssistedFactory" + val constructor = ksClass.getConstructors().first { it.parameters.isNotEmpty() } + val assistedParameters = constructor.parameters.filter { it.isAnnotationPresent(Assisted::class) } + if (assistedParameters.size != 2) { + error( + "${ksClass.qualifiedName?.asString()} must have a constructor with 2 @Assisted parameters. Found: ${assistedParameters.size}", + ) + } + val contextAssistedParam = assistedParameters[0] + if (contextAssistedParam.name?.asString() != "buildContext") { + error( + "${ksClass.qualifiedName?.asString()} @Assisted parameter must be named buildContext", + ) + } + val pluginsAssistedParam = assistedParameters[1] + if (pluginsAssistedParam.name?.asString() != "plugins") { + error( + "${ksClass.qualifiedName?.asString()} @Assisted parameter must be named plugins", + ) + } + + val nodeClassName = ClassName.bestGuess(ksClass.qualifiedName!!.asString()) + val buildContextClassName = contextAssistedParam.type.toTypeName() + val pluginsClassName = pluginsAssistedParam.type.toTypeName() + val content = FileSpec.builder(generatedPackage, assistedFactoryClassName) + .addType( + TypeSpec.interfaceBuilder(assistedFactoryClassName) + .addSuperinterface(ClassName.bestGuess(assistedNodeFactoryFqName.asString()).parameterizedBy(nodeClassName)) + .addAnnotation(AnnotationSpec.builder(Origin::class).addMember("%T::class", nodeClassName).build()) + .addAnnotation(AssistedFactory::class) + .addFunction( + FunSpec.builder("create") + .addModifiers(KModifier.OVERRIDE, KModifier.ABSTRACT) + .addParameter("buildContext", buildContextClassName) + .addParameter("plugins", pluginsClassName) + .returns(nodeClassName) + .build(), + ) + .build(), + ) + .build() + + content.writeTo( + codeGenerator = codeGenerator, + dependencies = Dependencies( + aggregating = false, + ksClass.containingFile!! + ), + ) + } + + companion object { + private const val CLASS_PLACEHOLDER = "%T::class" + private val assistedNodeFactoryFqName = FqName("io.element.android.libraries.architecture.AssistedNodeFactory") + private val nodeKeyFqName = FqName("io.element.android.libraries.architecture.NodeKey") + } +} diff --git a/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessorProvider.kt b/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessorProvider.kt new file mode 100644 index 0000000..8412fbf --- /dev/null +++ b/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessorProvider.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.codegen + +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider + +class ContributesNodeProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { + val enableLogging = environment.options["enableLogging"]?.toBoolean() == true + return ContributesNodeProcessor( + logger = environment.logger, + codeGenerator = environment.codeGenerator, + config = ContributesNodeProcessor.Config(enableLogging = enableLogging), + ) + } +} diff --git a/codegen/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/codegen/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 0000000..2d18fdf --- /dev/null +++ b/codegen/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +io.element.android.codegen.ContributesNodeProcessorProvider diff --git a/docs/_developer_onboarding.md b/docs/_developer_onboarding.md new file mode 100644 index 0000000..6fb5229 --- /dev/null +++ b/docs/_developer_onboarding.md @@ -0,0 +1,433 @@ +# Developer on boarding + + + +* [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) + * [Building the SDK locally](#building-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) + + + +## Introduction + +This doc is a quick introduction about the project and its architecture. + +Its 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. + +#### Building the SDK locally + +If you want to make changes to the SDK or test them before integrating it with your codebase, you can build the SDK locally too. + +Prerequisites: +* Install the Android NDK (Native Development Kit). To do this from within + Android Studio: + 1. **Tools > SDK Manager** + 2. Click the **SDK Tools** tab. + 3. Select the **NDK (Side by side)** checkbox + 4. Click **OK**. + 5. Click **OK**. + 6. When the installation is complete, click **Finish**. +* Install `cargo-ndk`: + ``` + cargo install cargo-ndk + ``` +* Install the Android Rust toolchain for your machine's hardware: + ``` + rustup target add aarch64-linux-android x86_64-linux-android + ``` +* Depending on the location of the Android SDK, you may need to set + `ANDROID_HOME`: + ``` + export ANDROID_HOME=$HOME/android/sdk + ``` + +You can then build the Rust SDK by running the script +[`tools/sdk/build_rust_sdk.sh`](../tools/sdk/build_rust_sdk.sh) and just answering +the questions. + +This will prompt you for the path to the Rust SDK, then build it and +`matrix-rust-components-kotlin`, eventually producing an aar file at +`./libraries/rustsdk/matrix-rust-sdk.aar`, which will be picked up +automatically by the Element X Android build. + +Troubleshooting: + - You may need to set `ANDROID_NDK_HOME` e.g `export ANDROID_NDK_HOME=~/Library/Android/sdk/ndk`. + - If you get the error `thread 'main' panicked at 'called `Option::unwrap()` on a `None` value', .cargo/registry/src/index.crates.io-6f17d22bba15001f/cargo-ndk-2.11.0/src/cli.rs:345:18` try updating your Cargo NDK version. In this case, 2.11.0 is too old so `cargo install cargo-ndk` to install a newer version. + - If you get the error `Unsupported class file major version `, try changing your JVM version by setting + `JAVA_HOME` and, if building via Android Studio, "File | Settings | Build, Execution, Deployment | Build Tools | Gradle | Gradle JDK". + +You can switch back to using the published version of the SDK by deleting `libraries/rustsdk/matrix-rust-sdk.aar`. + +### 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: + + + +```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! +- Dependency injection: [Metro](https://zacsweers.github.io/metro/latest/) +- 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 DI graph if any, see for instance `LoggedInAppScopeFlowNode`. +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. From repository root, run `./tools/templates/generate_templates.sh` to generate the template zip file +3. Import file templates in AS : + - Navigate to File/Manage IDE Settings/Import Settings + - Pick the `tmp/file_templates.zip` files + - Click on OK +4. 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 is implemented, but Unified Push is not yet fully implemented on the project, so this is not possible to choose this push provider in the app at the moment. + +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/element-hq/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 app [build.gradle.kts](../app/build.gradle.kts) file. + +To compute the code coverage, run: + +```bash +./gradlew :app:koverHtmlReport +``` + +and open the Html report: [../app/build/reports/kover/html/index.html](../app/build/reports/kover/html/index.html) + +To ensure that the code coverage threshold are OK, you can run + +```bash +./gradlew :app:koverVerify +``` + +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](../app/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 code generation. Adding `--no-build-cache` to the `gradlew` command line can help to fix compilation issue. + +## 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 0000000..3113b94 --- /dev/null +++ b/docs/analytics.md @@ -0,0 +1,17 @@ +# Analytics in Element + + + +* [Sentry](#sentry) + + + +## Sentry + +To make Sentry analytics and bug reporting work, you need to provide a Sentry DSN in the `local.properties` file, or set the `ELEMENT_ANDROID_SENTRY_DSN` environment variable. + +The format used to add the DSN to your `local.properties` file is the following: + +```properties +services.analyticsproviders.sentry.dsn=https://your-sentry-dsn/project-id +``` diff --git a/docs/continuous_integration.md b/docs/continuous_integration.md new file mode 100644 index 0000000..85a869c --- /dev/null +++ b/docs/continuous_integration.md @@ -0,0 +1,69 @@ +# Continuous integration strategy + + + +* [Introduction](#introduction) +* [CI tools](#ci-tools) +* [Rules](#rules) +* [What is the CI checking](#what-is-the-ci-checking) +* [What is the CI reporting](#what-is-the-ci-reporting) +* [Current choices](#current-choices) + * [R8 task](#r8-task) + * [Android test (connected test)](#android-test-connected-test) + + + +## Introduction + +This document gives some information about how we take advantage of the continuous integration (CI). + +## CI tools + +We use GitHub Actions to configure and perform the CI. + +## Rules + +We want: + +1. The CI to detect as soon as possible any issue in the code +2. The CI to be fast - it's run on all the Pull Requests, and developers do not like to wait too long +3. The CI to be reliable - it should not fail randomly +4. The CI to generate artifacts which can be used by the team and the community +5. The CI to generate useful logs and reports, not too verbose, not too short +6. The developer to be able to run the CI locally - to help with this we have [a script](../tools/check/check_code_quality.sh) the can be run locally and which does more checks that just building and deploying the app. +7. The CI to be used as a common environment for the team: generate the screenshots image for the screenshot test, build the release build (unsigned) +8. The CI to run repeated tasks, like building the nightly builds, integrating data from external tools (translations, etc.) +9. The CI to upgrade our dependencies (Renovate) +10. The CI to do some issue triaging + +## What is the CI checking + +The CI checks that: + +1. The code is compiling, without any warnings, for all the app build types and variants +2. The tests are passing +3. The code quality is good (detekt, ktlint, lint) +4. The code is running and smoke tests are passing (maestro) +5. The PullRequest itself is good (with danger) +6. Files that must be added with git-lfs are added with git-lfs + +## What is the CI reporting + +The CI reports: + +1. Code coverage reports +2. Sonar reports + +## Current choices + +### R8 task + +The CI does not run R8 because it's too slow, and it breaks rule 2. + +The drawback is that the nightly build can fail, as well as the release build. + +Since the nightly build is failing, the team can detect the failure quite fast and react to it. + +### Android test (connected test) + +We limit the number of connected tests (tests under folder `androidTest`), because it often break rule 2 and 3. diff --git a/docs/danger.md b/docs/danger.md new file mode 100644 index 0000000..9adb382 --- /dev/null +++ b/docs/danger.md @@ -0,0 +1,105 @@ +## Danger + + + +* [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) + + + +## 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 correct title and a label to categorize the release note +- PR does not modify frozen classes +- 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 --dangerfile=./tools/danger/dangerfile.js +``` + +For instance: + +```shell +bundle exec danger pr https://github.com/element-hq/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 --dangerfile=./tools/danger/dangerfile.js +bundle exec danger-kotlin pr --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/element-hq/element-x-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/debug_proxying.md b/docs/debug_proxying.md new file mode 100644 index 0000000..a28163c --- /dev/null +++ b/docs/debug_proxying.md @@ -0,0 +1,22 @@ +# Setup a debug mitm proxy to inspect all the app's network traffic + +1) Install mitmproxy: `brew install mitmproxy`. + 1) Launch `mitmweb` from a terminal. It will pop up mitmproxy's web interface in a web browser. +1) Configure Android Emulator. + 1) Launch your android emulator. + 1) Open its settings page and go to Settings -> Proxy (nb this tab isn't visible when running the emu inside the Android Studio window, you need to set it so it runs in its own window). + 1) Disable "Use Android Studio HTTP proxy settings" and pick "Manual proxy configuration". + 1) Set `127.0.0.1` as "Host name" and `8080` as "Port number". + 1) Click "Apply" and verify that "Proxy status" is "Success" and close the settings window. + Screenshot 2023-10-04 at 14 48 47 +1) Install the mitmproxy CA cert (this is needed to see traffic from java/kotlin code, it's not needed for traffic coming from native code e.g. the matrix-rust-sdk). + 1) Open the emulator Chrome browser app + 1) Go to the url `mitm.it` + 1) Follow the instructions to install the CA cert on Android devices. + Screenshot 2023-10-04 at 14 51 27 +1) Slightly modify the Element X app source code. + 1) Go to the `RustMatrixClientFactory.create()` method. + 1) Add `.disableSslVerification()` in the `ClientBuilder` method chain. +1) Build and run the Element X app. +1) Enjoy, you will see all the traffic in mitmproxy's web interface. + Screenshot 2023-10-04 at 14 50 03 diff --git a/docs/deeplink.md b/docs/deeplink.md new file mode 100644 index 0000000..1350b2f --- /dev/null +++ b/docs/deeplink.md @@ -0,0 +1,71 @@ +# Element X Android deeplink + + + +* [Introduction](#introduction) + * [Asset Links](#asset-links) + * [Supported links](#supported-links) +* [Developer tools](#developer-tools) + + + + +## Introduction + +Element X Android supports deep linking to specific screens in the application. This document explains how to use deep links in Element X Android. + +### Asset Links + +The asset links file is available at https://element.io/.well-known/assetlinks.json + +### Supported links + +Element Call link: +> https://call.element.io/Example + +Link to a user: +> https://app.element.io/#/user/@alice:matrix.org + +Link to a room by id or alias: +> https://app.element.io/#/room/!roomid:matrix.org +> https://app.element.io/#/room/#element-x-android:matrix.org + +Link to a room with a specific event: +> https://app.element.io/#/room/!roomid:matrix.org/$eventid + +Note that it will also work with other domain such as: +> https://mobile.element.io +> https://develop.element.io +> https://staging.element.io + +## Developer tools + +Using an Android 12 or higher emulator + +Ensure links verification is enabled +```bash +adb shell am compat enable 175408749 io.element.android.x.debug +``` + +Reset link verifications for the given package id +```bash +adb shell pm set-app-links --package io.element.android.x.debug 0 all +``` + +Force the package id links to be verified +```bash +adb shell pm verify-app-links --re-verify io.element.android.x.debug +``` + +Print the link verification of the package id +```bash +adb shell pm get-app-links io.element.android.x.debug +``` + +``` + io.element.android.x.debug: + ID: e2ece472-c266-4bf0-829c-be79959a6270 + Signatures: [B0:B0:51:DC:56:5C:81:2F:E1:7F:6F:3E:94:5B:4D:79:04:71:23:AB:0D:A6:12:86:76:9E:B2:94:91:97:13:0E] + Domain verification state: + *.element.io: 1024 +``` diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..bdcaba1 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,161 @@ +# Element Android design + + + +* [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) + + + +**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 [`element-hq/compound-design-tokens#assets/android`](https://github.com/element-hq/compound-design-tokens/tree/main/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-lfs/screen_1_dark.png b/docs/images-lfs/screen_1_dark.png new file mode 100644 index 0000000..8bdcd59 --- /dev/null +++ b/docs/images-lfs/screen_1_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4515f7589c422197a82672cdc3e64814ccca9a9a022b806facee44dd67d51ff2 +size 1116864 diff --git a/docs/images-lfs/screen_1_light.png b/docs/images-lfs/screen_1_light.png new file mode 100644 index 0000000..8eba38a --- /dev/null +++ b/docs/images-lfs/screen_1_light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2afb667a8b679f4395c28407b014f074e8b74745f6ecdd6ad699a6650cee2bc7 +size 771160 diff --git a/docs/images-lfs/screen_2_dark.png b/docs/images-lfs/screen_2_dark.png new file mode 100644 index 0000000..9a01029 --- /dev/null +++ b/docs/images-lfs/screen_2_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:910f9ab58a197a16b1295cd6b65d406ebfff1298c9c128a326edf1ec834d7fa7 +size 332936 diff --git a/docs/images-lfs/screen_2_light.png b/docs/images-lfs/screen_2_light.png new file mode 100644 index 0000000..1dd3106 --- /dev/null +++ b/docs/images-lfs/screen_2_light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7b80b9124c5c04c9db3824b68ab35883d41d05918abd415f7565f87d2713cfa +size 338455 diff --git a/docs/images-lfs/screen_3_dark.png b/docs/images-lfs/screen_3_dark.png new file mode 100644 index 0000000..ccc1733 --- /dev/null +++ b/docs/images-lfs/screen_3_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dadff79ae955ab1da5c0a0567960fc567526ad0d8d2ea418d8adacf3a7a400cc +size 243201 diff --git a/docs/images-lfs/screen_3_light.png b/docs/images-lfs/screen_3_light.png new file mode 100644 index 0000000..2116f1d --- /dev/null +++ b/docs/images-lfs/screen_3_light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e2f328b3e8bf2ebe4abc4c130e28f6fc2351f5ac339be0819e5fddb657f4a560 +size 246731 diff --git a/docs/images-lfs/screen_4_dark.png b/docs/images-lfs/screen_4_dark.png new file mode 100644 index 0000000..5bd122a --- /dev/null +++ b/docs/images-lfs/screen_4_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:519fb54f0070833b2a4671e1f0fc7b2ec60930d69b592b8a760f52a73dc4fe38 +size 132247 diff --git a/docs/images-lfs/screen_4_light.png b/docs/images-lfs/screen_4_light.png new file mode 100644 index 0000000..ee82f3b --- /dev/null +++ b/docs/images-lfs/screen_4_light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f683a228d7168c75d6f42c353c1a0e169cb22cb8fa6696e9e3ffeb80854b2441 +size 131610 diff --git a/docs/images/module_graph.png b/docs/images/module_graph.png new file mode 100644 index 0000000..3be0256 Binary files /dev/null and b/docs/images/module_graph.png differ diff --git a/docs/install_from_github_release.md b/docs/install_from_github_release.md new file mode 100644 index 0000000..5be1c0c --- /dev/null +++ b/docs/install_from_github_release.md @@ -0,0 +1,94 @@ +# Installing Element X Android from a Github Release + +This document explains how to install Element X Android from a Github Release. + + + +* [Installing the universal APK](#installing-the-universal-apk) + * [Instructions](#instructions) + * [Steps](#steps) + * [I already have the application on my phone](#i-already-have-the-application-on-my-phone) +* [Installing from the App Bundle](#installing-from-the-app-bundle) + * [Requirements](#requirements) + * [Steps](#steps) + * [I already have the application on my phone](#i-already-have-the-application-on-my-phone) + + + +## Installing the universal APK + +### Instructions + +The easiest way to install the application from a GitHub release is to use the universal APK which is attached to the release. This APK is compatible with all Android devices, but it is not optimized for any of them. So it may not be as fast as it could be on your device, and it may not be as small as it could be. + +Alternatively, you can generate an APK that is optimized for your device. This is explained in the next section. + +### Steps + +- Open the GitHub release that you want to install from using the Web browser of your phone. +- Download the APK +- Open the APK file from the download notification, or from the file manager +- Follow the steps to install the application + +### I already have the application on my phone + +If the application was already installed on your phone, there are several cases: + +- it was installed from the PlayStore, you can install the universal APK as long as the version is more recent. The existing data should not be lost. +- it was installed from a previous GitHub release, this is like an application upgrade. +- it was installed from a more recent GitHub release, or from the PlayStore with a later version, you will have to uninstall it first. + +## Installing from the App Bundle + +### Requirements + +The Github release will contain an Android App Bundle (with `aab` extension) file, unlike in the Element Android project where releases directly provide the APKs. So there are some steps to perform to generate and sign App Bundle APKs. An APK suitable for the targeted device will then be generated. + +The easiest way to do that is to use the debug signature that is shared between the developers and stored in the Element X Android project. So we recommend to clone the project first, to be able to use the debug signature it contains. But note that you can use any other signature. You don't need to install Android Studio, you will only need a shell terminal. + +You can clone the project by running: +```bash +git clone git@github.com:element-hq/element-x-android.git +``` +or +```bash +git clone https://github.com/element-hq/element-x-android.git +``` + +You will also need to install [bundletool](https://developer.android.com/studio/command-line/bundletool). On MacOS, you can run the following command: + +```bash +brew install bundletool +``` + +### Steps + +1. Open the GitHub release that you want to install from https://github.com/element-hq/element-x-android/releases +2. Download the asset `app-release-signed.aab` +3. Navigate to the folder where you cloned the project and run the following command: +```bash +bundletool build-apks --bundle= --output=./tmp/elementx.apks \ + --ks=./app/signature/debug.keystore --ks-pass=pass:android --ks-key-alias=androiddebugkey --key-pass=pass:android \ + --overwrite +``` +For instance: +```bash +bundletool build-apks --bundle=./tmp/Element/0.1.5/app-release-signed.aab --output=./tmp/elementx.apks \ + --ks=./app/signature/debug.keystore --ks-pass=pass:android --ks-key-alias=androiddebugkey --key-pass=pass:android \ + --overwrite +``` +4. Run an Android emulator, or connect a real device to your computer +5. Install the APKs on the device: +```bash +bundletool install-apks --apks=./tmp/elementx.apks +``` + +That's it, the application should be installed on your device, you can start it from the launcher icon. + +### I already have the application on my phone + +If the application was already installed on your phone, there are several cases: + +- it was installed from the PlayStore, you will have to uninstall it first because the signature will not match. +- it was installed from a previous GitHub release, this is like an application upgrade, so no need to uninstall the existing app. +- it was installed from a more recent GitHub release, you will have to uninstall it first. diff --git a/docs/installing_from_ci.md b/docs/installing_from_ci.md new file mode 100644 index 0000000..634ee90 --- /dev/null +++ b/docs/installing_from_ci.md @@ -0,0 +1,49 @@ +## Installing from CI + + + + * [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) + + + +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 0000000..dbd3ce2 --- /dev/null +++ b/docs/integration_tests.md @@ -0,0 +1,131 @@ +# Integration tests + + + +* [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-localhost8080") + * [virtualenv command fails](#virtualenv-command-fails) + + + +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 0000000..789d455 --- /dev/null +++ b/docs/maps.md @@ -0,0 +1,47 @@ +# Use of maps + + + +* [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) + + + +## 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 +``` + +Optionally you can also place your custom MapTyler style ids for light and dark maps +in the `local.properties` with the keys `services.maptiler.lightMapId` and +`services.maptiler.darkMapId`. If you don't specify these, the default MapTiler "basic-v2" +styles will be used. + +## 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. +If you've added custom styles also set the `ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID` +and `ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID` environment variables accordingly. + +## Using other map sources or MapTiler styles + +If you wish to use an alternative map provider, you can provide your own implementations of +`TileServerStyleUriBuilder` and `StaticMapUrlBuilder` in +`features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/`. diff --git a/docs/migration_to_metro.md b/docs/migration_to_metro.md new file mode 100644 index 0000000..23c5db9 --- /dev/null +++ b/docs/migration_to_metro.md @@ -0,0 +1,15 @@ +# Migration to Metro + +The dependency injection library is now [Metro](https://zacsweers.github.io/metro/latest/). It replaces both Dagger and Anvil. + +Migration of the current Element X code has been performed in https://github.com/element-hq/element-x-android/pull/5253. + +To migrate other existing code you will need to: + +- replace `setupAnvil()` with `setupDependencyInjection()` in your `build.gradle.kts` files +- replace the Dagger and Anvil imports with Metro ones +- move the `@Inject` apply to the constructor to the class itself (only applicable if there is only one primary constructor +- replace `@AssistedInject` with `@Inject` +- replace `@Module` with `@BindingContainer` + +This should help to migrate your existing code. diff --git a/docs/nightly_build.md b/docs/nightly_build.md new file mode 100644 index 0000000..3a3345d --- /dev/null +++ b/docs/nightly_build.md @@ -0,0 +1,48 @@ +# Nightly builds + + + +* [Configuration](#configuration) +* [How to register to get nightly build](#how-to-register-to-get-nightly-build) +* [Build nightly manually](#build-nightly-manually) + + + +## 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 Element X 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 Element X 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 +./gradlew assembleGplayNightly appDistributionUploadGplayNightly +``` + +Then you can reset the change on the codebase. diff --git a/docs/notifications.md b/docs/notifications.md new file mode 100644 index 0000000..5f67f88 --- /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 + + + +* [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) + + + + +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 its 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 0000000..23709b6 --- /dev/null +++ b/docs/oidc.md @@ -0,0 +1,51 @@ +This file contains some rough notes about Oidc implementation, with some examples of actual data. + +[ios implementation](https://github.com/element-hq/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/element-hq/oidc-playground + +Metadata iOS: (from https://github.com/element-hq/element-x-ios/blob/5f9d07377cebc4f21d9668b1a25f6e3bb22f64a1/ElementX/Sources/Services/Authentication/AuthenticationServiceProxy.swift#L28) + +clientName: InfoPlistReader.main.bundleDisplayName, +redirectUri: "io.element.android:/", +clientUri: "https://element.io", +tosUri: "https://element.io/user-terms-of-service", +policyUri: "https://element.io/privacy" + + +Android: +clientName = "Element", +redirectUri = "io.element.android:/", +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 + + +Test server: +synapse-oidc.lab.element.dev diff --git a/docs/pull_request.md b/docs/pull_request.md new file mode 100644 index 0000000..97314b2 --- /dev/null +++ b/docs/pull_request.md @@ -0,0 +1,290 @@ +# Pull requests + + + +* [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) + + + +## 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/element-hq/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/element-hq/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 commit authors must have signed the CLA. Please open https://cla-assistant.io/element-hq/element-x-android to agree to the CLA. + +### 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 0000000..044b136 --- /dev/null +++ b/docs/screenshot_testing.md @@ -0,0 +1,67 @@ +# Screenshot testing + + + +* [Overview](#overview) +* [Setup](#setup) +* [Recording](#recording) +* [Verifying](#verifying) +* [Contributing](#contributing) + + + +## Overview + +- Screenshot tests are tests which record the content of a rendered screen and verify subsequent runs to check if the screen renders differently. +- Element X uses [Paparazzi](https://github.com/cashapp/paparazzi) to render, record and verify Composables. All internal/public Composable Preview will be used for screenshot tests, thanks to the usage of [ComposablePreviewScanner](https://github.com/sergio-sastre/ComposablePreviewScanner). +- 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 + +Recording of screenshots is done by triggering the GitHub action [Record screenshots](https://github.com/element-hq/element-x-android/actions/workflows/recordScreenshots.yml), to avoid differences of generated binary files (png images) depending on developers' environment. + +So basically, you will create a branch, do some commits with your work on it, then push your branch, trigger the GitHub action to record the screenshots (only if you think preview may have changed), and finally create a pull request. The GitHub action will record the screenshots and commit the changes to the branch. + +You can still record the screenshots locally, but please do not commit the changes. + +To record the screenshot locally, run the following command: + +```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/build/paparazzi/failures`. 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 0000000..78dd519 --- /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/changelogs/202502000.txt b/fastlane/metadata/android/en-US/changelogs/202502000.txt new file mode 100644 index 0000000..cb9da2e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202502000.txt @@ -0,0 +1,2 @@ +Main changes in this version: swipe between media. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202503000.txt b/fastlane/metadata/android/en-US/changelogs/202503000.txt new file mode 100644 index 0000000..9f4d072 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202503000.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202503010.txt b/fastlane/metadata/android/en-US/changelogs/202503010.txt new file mode 100644 index 0000000..8e44d1e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202503010.txt @@ -0,0 +1,2 @@ +Main changes in this version: Event cache / Join room by address. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202503020.txt b/fastlane/metadata/android/en-US/changelogs/202503020.txt new file mode 100644 index 0000000..8955ade --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202503020.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202503030.txt b/fastlane/metadata/android/en-US/changelogs/202503030.txt new file mode 100644 index 0000000..f7a79e0 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202503030.txt @@ -0,0 +1,2 @@ +Main changes in this version: improvements to the event cache, Element Call now uses an embedded implementation, several bugfixes. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202503040.txt b/fastlane/metadata/android/en-US/changelogs/202503040.txt new file mode 100644 index 0000000..21f4248 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202503040.txt @@ -0,0 +1,2 @@ +Main changes in this version: added a fix for Element Call not being able to report issues. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202504000.txt b/fastlane/metadata/android/en-US/changelogs/202504000.txt new file mode 100644 index 0000000..8955ade --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202504000.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202504010.txt b/fastlane/metadata/android/en-US/changelogs/202504010.txt new file mode 100644 index 0000000..8955ade --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202504010.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202504020.txt b/fastlane/metadata/android/en-US/changelogs/202504020.txt new file mode 100644 index 0000000..6657847 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202504020.txt @@ -0,0 +1,2 @@ +Main changes in this version: security fix. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202504030.txt b/fastlane/metadata/android/en-US/changelogs/202504030.txt new file mode 100644 index 0000000..a4b397f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202504030.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/202505000.txt b/fastlane/metadata/android/en-US/changelogs/202505000.txt new file mode 100644 index 0000000..8955ade --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202505000.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202505010.txt b/fastlane/metadata/android/en-US/changelogs/202505010.txt new file mode 100644 index 0000000..4712e9f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202505010.txt @@ -0,0 +1,2 @@ +Main changes in this version: fix Element Call not working. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202505030.txt b/fastlane/metadata/android/en-US/changelogs/202505030.txt new file mode 100644 index 0000000..f9194d1 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202505030.txt @@ -0,0 +1,2 @@ +Main changes in this version: fix issue with Element Call and to-device events. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202505040.txt b/fastlane/metadata/android/en-US/changelogs/202505040.txt new file mode 100644 index 0000000..8955ade --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202505040.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202506000.txt b/fastlane/metadata/android/en-US/changelogs/202506000.txt new file mode 100644 index 0000000..a4b397f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202506000.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/202506010.txt b/fastlane/metadata/android/en-US/changelogs/202506010.txt new file mode 100644 index 0000000..a51bde4 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202506010.txt @@ -0,0 +1,2 @@ +Main changes in this version: fix audio devices and volume selection in Element Call, improves moderation features. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202506020.txt b/fastlane/metadata/android/en-US/changelogs/202506020.txt new file mode 100644 index 0000000..8955ade --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202506020.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202506030.txt b/fastlane/metadata/android/en-US/changelogs/202506030.txt new file mode 100644 index 0000000..f52df1a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202506030.txt @@ -0,0 +1,2 @@ +Main changes in this version: add support for tombstoned rooms, improve notification reliability. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/202507000.txt b/fastlane/metadata/android/en-US/changelogs/202507000.txt new file mode 100644 index 0000000..3abd90d --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202507000.txt @@ -0,0 +1,2 @@ +Main changes in this version: improve accessibility. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202507010.txt b/fastlane/metadata/android/en-US/changelogs/202507010.txt new file mode 100644 index 0000000..42a042f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202507010.txt @@ -0,0 +1,2 @@ +Main changes in this version: improvements and bug fixes. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/202508000.txt b/fastlane/metadata/android/en-US/changelogs/202508000.txt new file mode 100644 index 0000000..46897eb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202508000.txt @@ -0,0 +1,2 @@ +Main changes in this version: start support for room v12, bug fixes and general improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202508010.txt b/fastlane/metadata/android/en-US/changelogs/202508010.txt new file mode 100644 index 0000000..e96d596 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202508010.txt @@ -0,0 +1,4 @@ +Main changes in this version: +- Fixes for the room v12 changes. +- Several bug fixes, centered on media and calls. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202508020.txt b/fastlane/metadata/android/en-US/changelogs/202508020.txt new file mode 100644 index 0000000..4da3e65 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202508020.txt @@ -0,0 +1,3 @@ +Main changes in this version: +- Fix a bug with notifications being incorrectly dropped. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202508030.txt b/fastlane/metadata/android/en-US/changelogs/202508030.txt new file mode 100644 index 0000000..8955ade --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202508030.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202508040.txt b/fastlane/metadata/android/en-US/changelogs/202508040.txt new file mode 100644 index 0000000..ac9a4fb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202508040.txt @@ -0,0 +1,2 @@ +Main changes in this version: you can now create shortcuts to your recent conversations, several bug fixes related to media processing and downloading. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/202509000.txt b/fastlane/metadata/android/en-US/changelogs/202509000.txt new file mode 100644 index 0000000..9756225 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202509000.txt @@ -0,0 +1,2 @@ +Main changes in this version: improved timeline loading times, you can now create shortcuts to your recent conversations, several bug fixes related to media processing and downloading. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/202509010.txt b/fastlane/metadata/android/en-US/changelogs/202509010.txt new file mode 100644 index 0000000..8955ade --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202509010.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202509020.txt b/fastlane/metadata/android/en-US/changelogs/202509020.txt new file mode 100644 index 0000000..8955ade --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202509020.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202510000.txt b/fastlane/metadata/android/en-US/changelogs/202510000.txt new file mode 100644 index 0000000..e30ec57 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202510000.txt @@ -0,0 +1,2 @@ +Main changes in this version: Spaces! +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202510010.txt b/fastlane/metadata/android/en-US/changelogs/202510010.txt new file mode 100644 index 0000000..3c916fe --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202510010.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes around notifications and UX improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202511000.txt b/fastlane/metadata/android/en-US/changelogs/202511000.txt new file mode 100644 index 0000000..8afd746 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202511000.txt @@ -0,0 +1,2 @@ +Main changes in this version: fixes an issue that prevented Element Call notifications from being displayed sometimes. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202511020.txt b/fastlane/metadata/android/en-US/changelogs/202511020.txt new file mode 100644 index 0000000..a4b397f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202511020.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/202511030.txt b/fastlane/metadata/android/en-US/changelogs/202511030.txt new file mode 100644 index 0000000..a4b397f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202511030.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/202512000.txt b/fastlane/metadata/android/en-US/changelogs/202512000.txt new file mode 100644 index 0000000..e287435 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202512000.txt @@ -0,0 +1,6 @@ +Main changes in this version: +- Improve the room security and privacy screens. +- Better room list sorting. +- Fixed crashes when recording long voice messages. +- Improved the UX when opening a room from the room list. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/40001020.txt b/fastlane/metadata/android/en-US/changelogs/40001020.txt new file mode 100644 index 0000000..54b9834 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40001020.txt @@ -0,0 +1,2 @@ +First release of Element X 🚀! +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40001040.txt b/fastlane/metadata/android/en-US/changelogs/40001040.txt new file mode 100644 index 0000000..8b8ede3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40001040.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and add OIDC support. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40001050.txt b/fastlane/metadata/android/en-US/changelogs/40001050.txt new file mode 100644 index 0000000..8b8ede3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40001050.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and add OIDC support. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40001060.txt b/fastlane/metadata/android/en-US/changelogs/40001060.txt new file mode 100644 index 0000000..b2d056f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40001060.txt @@ -0,0 +1,2 @@ +Main changes in this version: bugfixes. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40002000.txt b/fastlane/metadata/android/en-US/changelogs/40002000.txt new file mode 100644 index 0000000..19e1bab --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40002000.txt @@ -0,0 +1,2 @@ +Main changes in this version: Element Call, design update, bugfixes +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40002010.txt b/fastlane/metadata/android/en-US/changelogs/40002010.txt new file mode 100644 index 0000000..19e1bab --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40002010.txt @@ -0,0 +1,2 @@ +Main changes in this version: Element Call, design update, bugfixes +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40002020.txt b/fastlane/metadata/android/en-US/changelogs/40002020.txt new file mode 100644 index 0000000..f9c0486 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40002020.txt @@ -0,0 +1,2 @@ +Main changes in this version: bugfixes +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40002030.txt b/fastlane/metadata/android/en-US/changelogs/40002030.txt new file mode 100644 index 0000000..b2d056f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40002030.txt @@ -0,0 +1,2 @@ +Main changes in this version: bugfixes. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40002040.txt b/fastlane/metadata/android/en-US/changelogs/40002040.txt new file mode 100644 index 0000000..b2d056f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40002040.txt @@ -0,0 +1,2 @@ +Main changes in this version: bugfixes. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40003000.txt b/fastlane/metadata/android/en-US/changelogs/40003000.txt new file mode 100644 index 0000000..fb55ecb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40003000.txt @@ -0,0 +1,2 @@ +Main changes in this version: Element Call, voice message. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40003010.txt b/fastlane/metadata/android/en-US/changelogs/40003010.txt new file mode 100644 index 0000000..cccc747 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40003010.txt @@ -0,0 +1,2 @@ +Main changes in this version: Mainly bug fixes. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40003020.txt b/fastlane/metadata/android/en-US/changelogs/40003020.txt new file mode 100644 index 0000000..610f99e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40003020.txt @@ -0,0 +1,7 @@ +Main changes in this version: +- Element Call is now enabled by default. +- There is an 'ongoing call' indicator in the room list. +- Adding mentions to a message is now possible, but it's disabled by default as it's a work in progress. They can be enabled in the developer options. +- Voice messages behavior changed: there is no need to keep pressing to record, to start recording a message just tap on the mic button, then tap again to stop recording. +- Added a marker in the timeline to indicate the starting point of the room messages. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40004000.txt b/fastlane/metadata/android/en-US/changelogs/40004000.txt new file mode 100644 index 0000000..811c504 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40004000.txt @@ -0,0 +1,13 @@ +Main changes in this version: + +- New timeline messages rendering. +- Added support for user mentions. +- Enabled chat backup so you can restore previous message history. +- Added read receipts. +- Several improvements to polls, including poll history. +- Several UI/UX improvements. +- Set a default power level to join room calls. +- Added an option to disable notifications for invites. +- Fixed a bug with the text composer and suggestions on Android 14. + +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40004010.txt b/fastlane/metadata/android/en-US/changelogs/40004010.txt new file mode 100644 index 0000000..cccc747 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40004010.txt @@ -0,0 +1,2 @@ +Main changes in this version: Mainly bug fixes. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40004020.txt b/fastlane/metadata/android/en-US/changelogs/40004020.txt new file mode 100644 index 0000000..b55bf0b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40004020.txt @@ -0,0 +1,2 @@ +Main changes in this version: be able to send a problem from the first screen, and add an internal log viewer. Be able to send private read receipt, send typing notification, improve performance. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40004030.txt b/fastlane/metadata/android/en-US/changelogs/40004030.txt new file mode 100644 index 0000000..3135dcf --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40004030.txt @@ -0,0 +1,10 @@ +Main changes in this version: +- Added share presence toggle. +- Render typing notifications. +- Manually mark a room as unread. +- Add an empty state to the room list. +- Allow joining unencrypted video calls in non encrypted rooms. + +And several other bugfixes. + +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40004040.txt b/fastlane/metadata/android/en-US/changelogs/40004040.txt new file mode 100644 index 0000000..22293ab --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40004040.txt @@ -0,0 +1,4 @@ +Main changes in this version: +- Fix decryption of previous messages after session verification not working. + +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40004050.txt b/fastlane/metadata/android/en-US/changelogs/40004050.txt new file mode 100644 index 0000000..cc12859 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40004050.txt @@ -0,0 +1,2 @@ +Main changes in this version: Moderation to rooms, mark room as favourite. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40004060.txt b/fastlane/metadata/android/en-US/changelogs/40004060.txt new file mode 100644 index 0000000..303b8eb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40004060.txt @@ -0,0 +1,2 @@ +Main changes in this version: room and user moderation. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40004070.txt b/fastlane/metadata/android/en-US/changelogs/40004070.txt new file mode 100644 index 0000000..7ce359b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40004070.txt @@ -0,0 +1,2 @@ +Main changes in this version: Enable the feature "RoomList filters" and "Mark as unread". +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40004080.txt b/fastlane/metadata/android/en-US/changelogs/40004080.txt new file mode 100644 index 0000000..06f69e5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40004080.txt @@ -0,0 +1,2 @@ +Main changes in this version: Enable room moderation feature. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40004090.txt b/fastlane/metadata/android/en-US/changelogs/40004090.txt new file mode 100644 index 0000000..06f69e5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40004090.txt @@ -0,0 +1,2 @@ +Main changes in this version: Enable room moderation feature. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40004100.txt b/fastlane/metadata/android/en-US/changelogs/40004100.txt new file mode 100644 index 0000000..1618e5e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40004100.txt @@ -0,0 +1,2 @@ +Main changes in this version: Prepare navigation with permalink. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40004120.txt b/fastlane/metadata/android/en-US/changelogs/40004120.txt new file mode 100644 index 0000000..6ecb5ad --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40004120.txt @@ -0,0 +1,10 @@ +Main changes in this version: + +- Added support for opening matrix URLs inside the app and navigating to replied to messages. +- Added per-app language support for Android 13+. +- Session verification is no longer mandatory for already logged in users. +- Better log handling. +- Fixed CVE-2024-34353 / GHSA-9ggc-845v-gcgv. +- UX improvements. + +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/40004130.txt b/fastlane/metadata/android/en-US/changelogs/40004130.txt new file mode 100644 index 0000000..a00be64 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40004130.txt @@ -0,0 +1,2 @@ +Main changes in this version: Add plain text editor based on Markdown input. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40004140.txt b/fastlane/metadata/android/en-US/changelogs/40004140.txt new file mode 100644 index 0000000..b4b4c00 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40004140.txt @@ -0,0 +1,2 @@ +Main changes in this version: Add support for incoming share (text or files) from other apps. Bug fixes. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40004150.txt b/fastlane/metadata/android/en-US/changelogs/40004150.txt new file mode 100644 index 0000000..0dce189 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40004150.txt @@ -0,0 +1,2 @@ +Main changes in this version: Ringing call notifications. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40004160.txt b/fastlane/metadata/android/en-US/changelogs/40004160.txt new file mode 100644 index 0000000..a79e38a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40004160.txt @@ -0,0 +1,2 @@ +Main changes in this version: Composer draft support and bug fixes. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/40005000.txt b/fastlane/metadata/android/en-US/changelogs/40005000.txt new file mode 100644 index 0000000..dd8c30a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40005000.txt @@ -0,0 +1,2 @@ +Main changes in this version: mostly bug fixes and performance improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/40005010.txt b/fastlane/metadata/android/en-US/changelogs/40005010.txt new file mode 100644 index 0000000..3c6c019 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40005010.txt @@ -0,0 +1,2 @@ +Main changes in this version: Element Call improvements and bug fixes. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/40005020.txt b/fastlane/metadata/android/en-US/changelogs/40005020.txt new file mode 100644 index 0000000..2508042 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40005020.txt @@ -0,0 +1,2 @@ +Main changes in this version: mainly bug fixes. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/40005030.txt b/fastlane/metadata/android/en-US/changelogs/40005030.txt new file mode 100644 index 0000000..cbdff7b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40005030.txt @@ -0,0 +1,2 @@ +Main changes in this version: mainly bug fixes. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40006000.txt b/fastlane/metadata/android/en-US/changelogs/40006000.txt new file mode 100644 index 0000000..0574894 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40006000.txt @@ -0,0 +1,2 @@ +Element X is the new generation of Element for professional and personal use on mobile. It’s the fastest Matrix client with a seamless & intuitive user interface. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40006010.txt b/fastlane/metadata/android/en-US/changelogs/40006010.txt new file mode 100644 index 0000000..0574894 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40006010.txt @@ -0,0 +1,2 @@ +Element X is the new generation of Element for professional and personal use on mobile. It’s the fastest Matrix client with a seamless & intuitive user interface. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40006020.txt b/fastlane/metadata/android/en-US/changelogs/40006020.txt new file mode 100644 index 0000000..0574894 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40006020.txt @@ -0,0 +1,2 @@ +Element X is the new generation of Element for professional and personal use on mobile. It’s the fastest Matrix client with a seamless & intuitive user interface. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40006030.txt b/fastlane/metadata/android/en-US/changelogs/40006030.txt new file mode 100644 index 0000000..0574894 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40006030.txt @@ -0,0 +1,2 @@ +Element X is the new generation of Element for professional and personal use on mobile. It’s the fastest Matrix client with a seamless & intuitive user interface. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40006040.txt b/fastlane/metadata/android/en-US/changelogs/40006040.txt new file mode 100644 index 0000000..2508042 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40006040.txt @@ -0,0 +1,2 @@ +Main changes in this version: mainly bug fixes. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/40006050.txt b/fastlane/metadata/android/en-US/changelogs/40006050.txt new file mode 100644 index 0000000..0e38b17 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40006050.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and performance improvement. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40007000.txt b/fastlane/metadata/android/en-US/changelogs/40007000.txt new file mode 100644 index 0000000..0e38b17 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40007000.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and performance improvement. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40007010.txt b/fastlane/metadata/android/en-US/changelogs/40007010.txt new file mode 100644 index 0000000..0e38b17 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40007010.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and performance improvement. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/40007020.txt b/fastlane/metadata/android/en-US/changelogs/40007020.txt new file mode 100644 index 0000000..4271b64 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40007020.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/40007030.txt b/fastlane/metadata/android/en-US/changelogs/40007030.txt new file mode 100644 index 0000000..120548b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40007030.txt @@ -0,0 +1,2 @@ +Main changes in this version: TODO. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/40007040.txt b/fastlane/metadata/android/en-US/changelogs/40007040.txt new file mode 100644 index 0000000..4271b64 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40007040.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/40007050.txt b/fastlane/metadata/android/en-US/changelogs/40007050.txt new file mode 100644 index 0000000..a4b397f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40007050.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/40007060.txt b/fastlane/metadata/android/en-US/changelogs/40007060.txt new file mode 100644 index 0000000..1bc0b2f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40007060.txt @@ -0,0 +1,2 @@ +Main changes in this version: media browser and bug fixes. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file 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 0000000..862c235 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,29 @@ +Freedom to communicate on your own terms + +For individuals and communities - private communication between family, friends, hobby groups, clubs, etc. + +Element X gives you fast, secure and private instant messaging and video calls built on Matrix, the open standard for real-time communication. This is a free and open-source app maintained at https://github.com/element-hq/element-x-android. + +Stay in touch with friends, family and communities with: + • Real time messaging & video calls + • Public rooms for open group communication + • Private rooms for closed group communication + • Rich messaging features: emoji reactions, replies, polls, pinned messages and more. + • Video calling while browsing messages. + • Interoperability with other Matrix-based apps such as FluffyChat, Cinny and many more. + +Privacy-first +Unlike some other messengers from Big Tech companies, we don’t mine your data or monitor your communications. + +Own your conversations +Choose where to host your data - from any public server (the largest free server is matrix.org, but there are plenty of others to choose from) to creating your own personal server and hosting it on your own domain. This ability to choose a server is a large part of what differentiates us from other real time communication apps. However you host, you have ownership; it’s your data. You’re not the product. You’re in control. + +Communicate in real time, all the time +Use Element everywhere. Stay in touch wherever you are with fully synchronised message history across all your devices, including on the web at https://app.element.io + +Element X is our next-generation app +If you’re using the previous-generation Element Classic app, it’s time to try Element X! It’s faster, easier to use, and more powerful than the classic app. It’s better in every way and we’re adding new features all the time. + +The application requires the android.permission.REQUEST_INSTALL_PACKAGES permission to enable the installation of applications received as attachments, ensuring seamless and convenient access to new software within the app. + +The application requires the USE_FULL_SCREEN_INTENT permission to ensure our users can effectively receive call notifications even when their devices are locked. 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 0000000..eb6cabd 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 0000000..325bf57 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 0000000..3214245 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 0000000..652d91f 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 0000000..e91fb3c 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 0000000..9e9f87e Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png new file mode 100644 index 0000000..b317b7d Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.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 0000000..6a18cdc --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Sovereign. Seamless. On Matrix \ 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 0000000..92577b2 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +Element X - Secure Chat & Call \ 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 0000000..5c7f0b2 --- /dev/null +++ b/features/analytics/api/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +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) +} 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 0000000..5d5c1ba --- /dev/null +++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/AnalyticsEntryPoint.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.analytics.api + +import io.element.android.libraries.architecture.SimpleFeatureEntryPoint + +fun 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 0000000..4181f52 --- /dev/null +++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/AnalyticsOptInEvents.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/preferences/AnalyticsPreferencesState.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesState.kt new file mode 100644 index 0000000..20a1804 --- /dev/null +++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesState.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 policyUrl: String, + 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 0000000..02e07a8 --- /dev/null +++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesStateProvider.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.analytics.api.preferences + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class AnalyticsPreferencesStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aAnalyticsPreferencesState().copy(isEnabled = true), + aAnalyticsPreferencesState().copy(isEnabled = true, policyUrl = ""), + ) +} + +fun aAnalyticsPreferencesState( + applicationName: String = "Element X", + isEnabled: Boolean = false, + policyUrl: String = "https://element.io", +) = AnalyticsPreferencesState( + applicationName = applicationName, + isEnabled = isEnabled, + policyUrl = policyUrl, + 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 0000000..e91c770 --- /dev/null +++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.analytics.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.PreviewParameter +import io.element.android.features.analytics.api.AnalyticsOptInEvents +import io.element.android.features.analytics.api.R +import io.element.android.libraries.designsystem.components.LINK_TAG +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListSupportingText +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun AnalyticsPreferencesView( + state: AnalyticsPreferencesState, + modifier: Modifier = Modifier, +) { + fun onEnabledChanged(isEnabled: Boolean) { + state.eventSink(AnalyticsOptInEvents.EnableAnalytics(isEnabled = isEnabled)) + } + + val supportingText = stringResource( + id = R.string.screen_analytics_settings_help_us_improve, + state.applicationName + ) + Column(modifier) { + ListItem( + headlineContent = { + Text(stringResource(id = R.string.screen_analytics_settings_share_data)) + }, + supportingContent = { + Text(supportingText) + }, + leadingContent = null, + trailingContent = ListItemContent.Switch( + checked = state.isEnabled, + ), + onClick = { + onEnabledChanged(!state.isEnabled) + } + ) + if (state.policyUrl.isNotEmpty()) { + val linkText = buildAnnotatedStringWithStyledPart( + R.string.screen_analytics_settings_read_terms, + R.string.screen_analytics_settings_read_terms_content_link, + tagAndLink = LINK_TAG to state.policyUrl, + ) + ListSupportingText(annotatedString = linkText) + } + } +} + +@PreviewsDayNight +@Composable +internal fun AnalyticsPreferencesViewPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) = + ElementPreview { + AnalyticsPreferencesView( + state = state, + ) + } diff --git a/features/analytics/api/src/main/res/values-be/translations.xml b/features/analytics/api/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..73a8b54 --- /dev/null +++ b/features/analytics/api/src/main/res/values-be/translations.xml @@ -0,0 +1,7 @@ + + + "Даваць ананімныя дадзеныя аб выкарыстанні, каб дапамагчы нам выявіць праблемы." + "Вы можаце азнаёміцца з усімі нашымі ўмовамі %1$s." + "тут" + "Дзяліцеся дадзенымі аналітыкі" + diff --git a/features/analytics/api/src/main/res/values-bg/translations.xml b/features/analytics/api/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..050febd --- /dev/null +++ b/features/analytics/api/src/main/res/values-bg/translations.xml @@ -0,0 +1,7 @@ + + + "Споделяне на анонимни данни за използване, за да ни помогнете да идентифицираме проблеми." + "Можете да прочетете всички наши условия %1$s." + "тук" + "Споделяне на статистически данни" + diff --git a/features/analytics/api/src/main/res/values-cs/translations.xml b/features/analytics/api/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..52a1ec3 --- /dev/null +++ b/features/analytics/api/src/main/res/values-cs/translations.xml @@ -0,0 +1,7 @@ + + + "Sdílejte anonymní údaje o používání, které nám pomohou identifikovat problémy." + "Můžete si přečíst všechny naše podmínky %1$s." + "zde" + "Sdílet analytická data" + diff --git a/features/analytics/api/src/main/res/values-cy/translations.xml b/features/analytics/api/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..308d8a0 --- /dev/null +++ b/features/analytics/api/src/main/res/values-cy/translations.xml @@ -0,0 +1,7 @@ + + + "Rhannu data defnydd dienw i\'n helpu i nodi problemau." + "Gallwch ddarllen ein holl amodau %1$s." + "yma" + "Rhannu data dadansoddeg" + diff --git a/features/analytics/api/src/main/res/values-da/translations.xml b/features/analytics/api/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..e302bae --- /dev/null +++ b/features/analytics/api/src/main/res/values-da/translations.xml @@ -0,0 +1,7 @@ + + + "Del anonyme brugsdata for at hjælpe os med at identificere problemer." + "Du kan læse alle vores vilkår %1$s." + "her" + "Del analysedata" + diff --git a/features/analytics/api/src/main/res/values-de/translations.xml b/features/analytics/api/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..bf3584e --- /dev/null +++ b/features/analytics/api/src/main/res/values-de/translations.xml @@ -0,0 +1,7 @@ + + + "Teile anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen." + "Weitere Informationen findest du %1$s." + "hier" + "Analysedaten teilen" + diff --git a/features/analytics/api/src/main/res/values-el/translations.xml b/features/analytics/api/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..b97de25 --- /dev/null +++ b/features/analytics/api/src/main/res/values-el/translations.xml @@ -0,0 +1,7 @@ + + + "Μοιράσου ανώνυμα δεδομένα χρήσης για να μάς βοηθήσεις να εντοπίσουμε προβλήματα." + "Μπορείς να διαβάσεις όλους τους όρους μας %1$s." + "εδώ" + "Κοινή χρήση δεδομένων αναλυτικών στοιχείων" + diff --git a/features/analytics/api/src/main/res/values-es/translations.xml b/features/analytics/api/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..9d99b00 --- /dev/null +++ b/features/analytics/api/src/main/res/values-es/translations.xml @@ -0,0 +1,7 @@ + + + "Compartir datos de uso anónimos para ayudarnos a identificar problemas." + "Puedes leer todos nuestros términos %1$s." + "aquí" + "Compartir datos analíticos" + diff --git a/features/analytics/api/src/main/res/values-et/translations.xml b/features/analytics/api/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..66da1fe --- /dev/null +++ b/features/analytics/api/src/main/res/values-et/translations.xml @@ -0,0 +1,7 @@ + + + "Võimalike rakenduse vigade leidmiseks palun jaga anonüümset kasutusteavet." + "Sa võid lugeda meie kasutustingimusi %1$s" + "siin" + "Jaga andmeid rakenduse kasutuse kohta" + diff --git a/features/analytics/api/src/main/res/values-eu/translations.xml b/features/analytics/api/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..52e18f3 --- /dev/null +++ b/features/analytics/api/src/main/res/values-eu/translations.xml @@ -0,0 +1,7 @@ + + + "Partekatu erabilerari buruzko datu anonimoak arazoak identifikatzen laguntzeko." + "Gure baldintza guztiak irakur ditzakezu %1$s." + "hemen" + "Partekatu analisi-datuak" + diff --git a/features/analytics/api/src/main/res/values-fa/translations.xml b/features/analytics/api/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..62a0e1c --- /dev/null +++ b/features/analytics/api/src/main/res/values-fa/translations.xml @@ -0,0 +1,7 @@ + + + "داده های استفاده ناشناس را به اشتراک بگذارید تا به ما در شناسایی مشکلات کمک کند." + "شما می‌توانید تمام شرایط ما را بخوانید%1$s ." + "این‌جا" + "هم رسانی داده‌های تحلیلی" + diff --git a/features/analytics/api/src/main/res/values-fi/translations.xml b/features/analytics/api/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..446538f --- /dev/null +++ b/features/analytics/api/src/main/res/values-fi/translations.xml @@ -0,0 +1,7 @@ + + + "Jaa anonyymejä käyttötietoja auttaaksesi meitä tunnistamaan ongelmat." + "Voit lukea kaikki ehtomme %1$s." + "täällä" + "Jaa analytiikkatietoja" + diff --git a/features/analytics/api/src/main/res/values-fr/translations.xml b/features/analytics/api/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..931dfdc --- /dev/null +++ b/features/analytics/api/src/main/res/values-fr/translations.xml @@ -0,0 +1,7 @@ + + + "Partagez des données d’utilisation anonymes pour nous aider à identifier les problèmes." + "Vous pouvez lire toutes nos conditions %1$s." + "ici" + "Partagez des données de statistiques d’utilisation" + diff --git a/features/analytics/api/src/main/res/values-hu/translations.xml b/features/analytics/api/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..84c43f2 --- /dev/null +++ b/features/analytics/api/src/main/res/values-hu/translations.xml @@ -0,0 +1,7 @@ + + + "Anonim használati adatok megosztása a problémák azonosítása érdekében." + "%1$s olvashatja el a feltételeinket." + "Itt" + "Elemzési adatok megosztása" + diff --git a/features/analytics/api/src/main/res/values-in/translations.xml b/features/analytics/api/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..7b93054 --- /dev/null +++ b/features/analytics/api/src/main/res/values-in/translations.xml @@ -0,0 +1,7 @@ + + + "Bagikan data penggunaan anonim untuk membantu kami mengidentifikasi masalah." + "Anda dapat membaca semua persyaratan kami %1$s." + "di sini" + "Bagikan data analitik" + diff --git a/features/analytics/api/src/main/res/values-it/translations.xml b/features/analytics/api/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..794dfc5 --- /dev/null +++ b/features/analytics/api/src/main/res/values-it/translations.xml @@ -0,0 +1,7 @@ + + + "Condividi dati di utilizzo anonimi per aiutarci a identificare problemi." + "Puoi leggere tutti i nostri termini %1$s." + "qui" + "Condividi statistiche" + diff --git a/features/analytics/api/src/main/res/values-ka/translations.xml b/features/analytics/api/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..fa12b9c --- /dev/null +++ b/features/analytics/api/src/main/res/values-ka/translations.xml @@ -0,0 +1,7 @@ + + + "გააზიარეთ ანონიმური გამოყენების მონაცემები, რათა დაგვეხმაროთ პრობლემების იდენტიფიცირებაში." + "შეგიძლიათ, წაიკითხოთ ჩვენი ყველა პირობა %1$s." + "აქ" + "გააზიარეთ ანალიტიკური მონაცემები" + diff --git a/features/analytics/api/src/main/res/values-ko/translations.xml b/features/analytics/api/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..809fc08 --- /dev/null +++ b/features/analytics/api/src/main/res/values-ko/translations.xml @@ -0,0 +1,7 @@ + + + "익명화된 사용 데이터를 공유하여 문제점을 파악하는 데 도움을 주십시오." + "모든 이용 약관은 %1$s 에서 확인하실 수 있습니다." + "여기" + "분석 데이터 공유" + diff --git a/features/analytics/api/src/main/res/values-lt/translations.xml b/features/analytics/api/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..08dd155 --- /dev/null +++ b/features/analytics/api/src/main/res/values-lt/translations.xml @@ -0,0 +1,7 @@ + + + "Dalinkitės anoniminiais naudojimo duomenimis ir padėkite mums nustatyti problemas." + "Galite perskaityti visas mūsų sąlygas %1$s." + "čia" + "Dalytis analitiniais duomenimis" + diff --git a/features/analytics/api/src/main/res/values-nb/translations.xml b/features/analytics/api/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..ea1f595 --- /dev/null +++ b/features/analytics/api/src/main/res/values-nb/translations.xml @@ -0,0 +1,7 @@ + + + "Del anonyme bruksdata for å hjelpe oss med å identifisere problemer." + "Du kan lese alle vilkårene våre på %1$s." + "her" + "Del analysedata" + diff --git a/features/analytics/api/src/main/res/values-nl/translations.xml b/features/analytics/api/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..996fe84 --- /dev/null +++ b/features/analytics/api/src/main/res/values-nl/translations.xml @@ -0,0 +1,7 @@ + + + "Deel anonieme gebruiksgegevens om ons te helpen problemen te identificeren." + "Je kunt al onze voorwaarden %1$s lezen." + "hier" + "Gebruiksgegevens delen" + diff --git a/features/analytics/api/src/main/res/values-pl/translations.xml b/features/analytics/api/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..a334758 --- /dev/null +++ b/features/analytics/api/src/main/res/values-pl/translations.xml @@ -0,0 +1,7 @@ + + + "Udostępniaj anonimowe dane użytkowania, aby pomóc nam identyfikować problemy." + "Przeczytaj nasze warunki użytkowania %1$s." + "tutaj" + "Udostępniaj dane analityczne" + diff --git a/features/analytics/api/src/main/res/values-pt-rBR/translations.xml b/features/analytics/api/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..01fe907 --- /dev/null +++ b/features/analytics/api/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,7 @@ + + + "Compartilhe dados de uso anônimos para nos ajudar a identificar problemas." + "Você pode ler todos os nossos termos %1$s." + "aqui" + "Compartilhar dados analíticos" + diff --git a/features/analytics/api/src/main/res/values-pt/translations.xml b/features/analytics/api/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..434df0b --- /dev/null +++ b/features/analytics/api/src/main/res/values-pt/translations.xml @@ -0,0 +1,7 @@ + + + "Partilhe dados de utilização anónimos para nos ajudar a identificar problemas." + "Podes ler todos os nossos termos %1$s." + "aqui" + "Partilhar dados de utilização" + diff --git a/features/analytics/api/src/main/res/values-ro/translations.xml b/features/analytics/api/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..04e0c00 --- /dev/null +++ b/features/analytics/api/src/main/res/values-ro/translations.xml @@ -0,0 +1,7 @@ + + + "Distribuiți date anonime de utilizare pentru a ne ajuta să identificăm probleme." + "Puteți citi toate condițiile noastre %1$s." + "aici" + "Partajați datele analitice" + diff --git a/features/analytics/api/src/main/res/values-ru/translations.xml b/features/analytics/api/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..b97c069 --- /dev/null +++ b/features/analytics/api/src/main/res/values-ru/translations.xml @@ -0,0 +1,7 @@ + + + "Предоставьте разработчикам анонимные данные об использовании, чтобы помочь им выявлять проблемы эффективнее." + "Вы можете ознакомиться со всеми нашими условиями %1$s." + "здесь" + "Отправлять аналитические данные" + diff --git a/features/analytics/api/src/main/res/values-sk/translations.xml b/features/analytics/api/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..a37964e --- /dev/null +++ b/features/analytics/api/src/main/res/values-sk/translations.xml @@ -0,0 +1,7 @@ + + + "Zdieľajte anonymné údaje o používaní, aby sme mohli identifikovať problémy." + "Môžete si prečítať všetky naše podmienky %1$s." + "tu" + "Zdieľať analytické údaje" + diff --git a/features/analytics/api/src/main/res/values-sv/translations.xml b/features/analytics/api/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..0956a9e --- /dev/null +++ b/features/analytics/api/src/main/res/values-sv/translations.xml @@ -0,0 +1,7 @@ + + + "Dela anonyma användningsdata för att hjälpa oss att identifiera problem." + "Du kan läsa alla våra villkor %1$s." + "här" + "Dela analysdata" + diff --git a/features/analytics/api/src/main/res/values-tr/translations.xml b/features/analytics/api/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..7229872 --- /dev/null +++ b/features/analytics/api/src/main/res/values-tr/translations.xml @@ -0,0 +1,7 @@ + + + "Sorunları tanımlamamıza yardımcı olmak için anonim kullanım verilerini paylaşın." + "Tüm şartlarımızı okuyabilirsiniz %1$s." + "burada" + "Analitik verileri paylaşın" + diff --git a/features/analytics/api/src/main/res/values-uk/translations.xml b/features/analytics/api/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..bcd812a --- /dev/null +++ b/features/analytics/api/src/main/res/values-uk/translations.xml @@ -0,0 +1,7 @@ + + + "Ділитися анонімними даними про використання, щоб допомогати нам виявляти проблеми." + "Ви можете прочитати всі наші умови %1$s." + "тут" + "Поділитися аналітичними даними" + diff --git a/features/analytics/api/src/main/res/values-ur/translations.xml b/features/analytics/api/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..237bb30 --- /dev/null +++ b/features/analytics/api/src/main/res/values-ur/translations.xml @@ -0,0 +1,7 @@ + + + "مسائل کی نشاندہی کرنے میں ہماری مدد کے لیے گمنام استعمال کے بیانات کا اشتراک کریں۔" + "آپ ہماری تمام شرائط پڑھ سکتے ہیں %1$s۔" + "یہاں" + "تجزیاتی بیانات کا اشتراک کریں" + diff --git a/features/analytics/api/src/main/res/values-uz/translations.xml b/features/analytics/api/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..787a1b0 --- /dev/null +++ b/features/analytics/api/src/main/res/values-uz/translations.xml @@ -0,0 +1,7 @@ + + + "Muammolarni aniqlashda yordam berish uchun anonim foydalanish maʼlumotlarini baham koʻring." + "Siz bizning barcha shartlarimizni o\'qishingiz mumkin%1$s." + "Bu yerga" + "Analitik ma\'lumotlarni ulashish" + diff --git a/features/analytics/api/src/main/res/values-zh-rTW/translations.xml b/features/analytics/api/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..2a3df10 --- /dev/null +++ b/features/analytics/api/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,7 @@ + + + "提供匿名的使用數據以協助我們釐清問題。" + "您可以到%1$s閱讀我們的條款。" + "這裡" + "提供分析數據" + diff --git a/features/analytics/api/src/main/res/values-zh/translations.xml b/features/analytics/api/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..e5f9fcc --- /dev/null +++ b/features/analytics/api/src/main/res/values-zh/translations.xml @@ -0,0 +1,7 @@ + + + "共享匿名使用数据以帮助我们排查问题。" + "您可以阅读我们的所有条款 %1$s。" + "此处" + "共享分析数据" + diff --git a/features/analytics/api/src/main/res/values/localazy.xml b/features/analytics/api/src/main/res/values/localazy.xml new file mode 100644 index 0000000..20dcba7 --- /dev/null +++ b/features/analytics/api/src/main/res/values/localazy.xml @@ -0,0 +1,7 @@ + + + "Share anonymous usage data to help us identify issues." + "You can read all our terms %1$s." + "here" + "Share analytics data" + diff --git a/features/analytics/impl/build.gradle.kts b/features/analytics/impl/build.gradle.kts new file mode 100644 index 0000000..cdb172f --- /dev/null +++ b/features/analytics/impl/build.gradle.kts @@ -0,0 +1,38 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.analytics.impl" +} + +setupDependencyInjection() + +dependencies { + 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(projects.appconfig) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.browser) + + testCommonDependencies(libs) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.services.analytics.test) +} 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 0000000..545f306 --- /dev/null +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInNode.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.analytics.impl + +import android.app.Activity +import androidx.activity.compose.LocalActivity +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.appconfig.AnalyticsConfig +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab + +@ContributesNode(AppScope::class) +@AssistedInject +class AnalyticsOptInNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: AnalyticsOptInPresenter, +) : Node(buildContext, plugins = plugins) { + private fun onClickTerms(activity: Activity, darkTheme: Boolean) { + activity.openUrlInChromeCustomTab(null, darkTheme, AnalyticsConfig.POLICY_LINK) + } + + @Composable + override fun View(modifier: Modifier) { + val activity = requireNotNull(LocalActivity.current) + val isDark = ElementTheme.isLightTheme.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 0000000..d7bdb85 --- /dev/null +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenter.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.analytics.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import dev.zacsweers.metro.Inject +import io.element.android.appconfig.AnalyticsConfig +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 + +@Inject +class AnalyticsOptInPresenter( + private val buildMeta: BuildMeta, + private val analyticsService: AnalyticsService, +) : Presenter { + @Composable + override fun present(): AnalyticsOptInState { + val localCoroutineScope = rememberCoroutineScope() + + fun handleEvent(event: AnalyticsOptInEvents) { + when (event) { + is AnalyticsOptInEvents.EnableAnalytics -> localCoroutineScope.setIsEnabled(event.isEnabled) + } + localCoroutineScope.launch { + analyticsService.setDidAskUserConsent() + } + } + + return AnalyticsOptInState( + applicationName = buildMeta.applicationName, + hasPolicyLink = AnalyticsConfig.POLICY_LINK.isNotEmpty(), + eventSink = ::handleEvent, + ) + } + + 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 0000000..b2ebd37 --- /dev/null +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInState.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.analytics.impl + +import io.element.android.features.analytics.api.AnalyticsOptInEvents + +data class AnalyticsOptInState( + val applicationName: String, + val hasPolicyLink: Boolean, + 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 0000000..30a396c --- /dev/null +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInStateProvider.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.analytics.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class AnalyticsOptInStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aAnalyticsOptInState(), + aAnalyticsOptInState(hasPolicyLink = false), + ) +} + +fun aAnalyticsOptInState( + hasPolicyLink: Boolean = true, +) = AnalyticsOptInState( + applicationName = "Element X", + hasPolicyLink = hasPolicyLink, + 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 0000000..4fc9ce5 --- /dev/null +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.analytics.impl + +import androidx.activity.compose.BackHandler +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.systemBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.Modifier +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.appconfig.AnalyticsConfig +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +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.organisms.InfoListItem +import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism +import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage +import io.element.android.libraries.designsystem.background.OnboardingBackground +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.ClickableLinkText +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.ButtonSize +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun AnalyticsOptInView( + state: AnalyticsOptInState, + onClickTerms: () -> Unit, + modifier: Modifier = Modifier, +) { + val eventSink = state.eventSink + + fun onAcceptTerms() { + eventSink(AnalyticsOptInEvents.EnableAnalytics(true)) + } + + fun onDeclineTerms() { + eventSink(AnalyticsOptInEvents.EnableAnalytics(false)) + } + + BackHandler(onBack = ::onDeclineTerms) + HeaderFooterPage( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + .imePadding(), + background = { OnboardingBackground() }, + header = { AnalyticsOptInHeader(state, onClickTerms) }, + content = { AnalyticsOptInContent() }, + footer = { + AnalyticsOptInFooter( + onAcceptTerms = ::onAcceptTerms, + onDeclineTerms = ::onDeclineTerms, + ) + } + ) +} + +private const val LINK_TAG = "link" + +@Composable +private fun AnalyticsOptInHeader( + state: AnalyticsOptInState, + onClickTerms: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + IconTitleSubtitleMolecule( + modifier = Modifier.padding(top = 60.dp, bottom = 28.dp), + title = stringResource(id = R.string.screen_analytics_prompt_title, state.applicationName), + subTitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve), + iconStyle = BigIcon.Style.Default(CompoundIcons.Chart()) + ) + if (state.hasPolicyLink) { + val 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, + tagAndLink = LINK_TAG to AnalyticsConfig.POLICY_LINK, + ) + ClickableLinkText( + annotatedString = text, + onClick = { onClickTerms() }, + modifier = Modifier + .padding(8.dp), + style = ElementTheme.typography.fontBodyMdRegular + .copy( + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + ) + } + } +} + +@Composable +private fun AnalyticsOptInContent() { + 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), + iconVector = CompoundIcons.CheckCircle(), + ), + InfoListItem( + message = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing), + iconVector = CompoundIcons.CheckCircle(), + ), + InfoListItem( + message = stringResource(id = R.string.screen_analytics_prompt_settings), + iconVector = CompoundIcons.CheckCircle(), + ), + ), + textStyle = ElementTheme.typography.fontBodyLgMedium, + iconTint = ElementTheme.colors.iconSuccessPrimary, + ) + } +} + +@Composable +private fun AnalyticsOptInFooter( + onAcceptTerms: () -> Unit, + onDeclineTerms: () -> Unit, +) { + ButtonColumnMolecule { + Button( + text = stringResource(id = CommonStrings.action_ok), + onClick = onAcceptTerms, + modifier = Modifier.fillMaxWidth(), + ) + TextButton( + text = stringResource(id = CommonStrings.action_not_now), + size = ButtonSize.Medium, + onClick = onDeclineTerms, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun AnalyticsOptInViewPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreview { + 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 0000000..fb85387 --- /dev/null +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/DefaultAnalyticsEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.analytics.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.analytics.api.AnalyticsEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultAnalyticsEntryPoint : AnalyticsEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext): Node { + return parentNode.createNode(buildContext) + } +} diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/di/AnalyticsModule.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/di/AnalyticsModule.kt new file mode 100644 index 0000000..32a3d58 --- /dev/null +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/di/AnalyticsModule.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.analytics.impl.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesState +import io.element.android.features.analytics.impl.preferences.AnalyticsPreferencesPresenter +import io.element.android.libraries.architecture.Presenter + +@ContributesTo(AppScope::class) +@BindingContainer +interface AnalyticsModule { + @Binds + fun bindAnalyticsPreferencesPresenter(presenter: AnalyticsPreferencesPresenter): Presenter +} diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenter.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenter.kt new file mode 100644 index 0000000..110cb98 --- /dev/null +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenter.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.analytics.impl.preferences + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.rememberCoroutineScope +import dev.zacsweers.metro.Inject +import io.element.android.appconfig.AnalyticsConfig +import io.element.android.features.analytics.api.AnalyticsOptInEvents +import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesState +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 + +@Inject +class AnalyticsPreferencesPresenter( + private val analyticsService: AnalyticsService, + private val buildMeta: BuildMeta, +) : Presenter { + @Composable + override fun present(): AnalyticsPreferencesState { + val localCoroutineScope = rememberCoroutineScope() + val isEnabled = analyticsService.userConsentFlow.collectAsState(initial = false) + + fun handleEvent(event: AnalyticsOptInEvents) { + when (event) { + is AnalyticsOptInEvents.EnableAnalytics -> localCoroutineScope.setIsEnabled(event.isEnabled) + } + } + + return AnalyticsPreferencesState( + applicationName = buildMeta.applicationName, + isEnabled = isEnabled.value, + policyUrl = AnalyticsConfig.POLICY_LINK, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.setIsEnabled(enabled: Boolean) = launch { + analyticsService.setUserConsent(enabled) + } +} diff --git a/features/analytics/impl/src/main/res/values-be/translations.xml b/features/analytics/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..6d32278 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,10 @@ + + + "Мы не будзем запісваць або прафіляваць любыя асабістыя даныя" + "Даваць ананімныя дадзеныя аб выкарыстанні, каб дапамагчы нам выявіць праблемы." + "Вы можаце азнаёміцца з усімі нашымі ўмовамі %1$s." + "тут" + "Вы можаце адключыць гэта ў любы час" + "Мы не будзем перадаваць вашыя дадзеныя трэцім асобам" + "Дапамажыце палепшыць %1$s" + diff --git a/features/analytics/impl/src/main/res/values-bg/translations.xml b/features/analytics/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..13a69aa --- /dev/null +++ b/features/analytics/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,10 @@ + + + "Няма да записваме или профилираме лични данни" + "Споделяне на анонимни данни за използване, за да ни помогнете да идентифицираме проблеми." + "Можете да прочетете всички наши условия %1$s." + "тук" + "Можете да изключите това по всяко време" + "Няма да споделяме данни ви с трети страни" + "Помогнете за подобряването на %1$s" + 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 0000000..b75b359 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,10 @@ + + + "Nezaznamenáváme ani neprofilujeme žádné údaje o účtu" + "Sdílejte anonymní údaje o používání, které nám pomohou identifikovat problémy." + "Můžete si přečíst všechny naše podmínky %1$s." + "zde" + "Tuto funkci můžete kdykoli vypnout" + "Nesdílíme informace s třetími stranami" + "Pomozte vylepšit %1$s" + diff --git a/features/analytics/impl/src/main/res/values-cy/translations.xml b/features/analytics/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..1a53e36 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,10 @@ + + + "Fyddwn ni ddim yn cofnodi nac yn proffilio unrhyw ddata personol" + "Rhannu data defnydd dienw i\'n helpu i nodi problemau." + "Gallwch ddarllen ein holl amodau %1$s." + "yma" + "Gallwch ddiffodd hwn unrhyw bryd" + "Fyddwn ni ddim yn rhannu eich data gyda thrydydd parti" + "Helpwch i wella %1$s" + diff --git a/features/analytics/impl/src/main/res/values-da/translations.xml b/features/analytics/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..971a12a --- /dev/null +++ b/features/analytics/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,10 @@ + + + "Vi vil ikke registrere eller profilere nogen personlige data" + "Del anonyme brugsdata for at hjælpe os med at identificere problemer." + "Du kan læse alle vores vilkår %1$s." + "her" + "Du kan slå dette fra når som helst" + "Vi deler ikke dine data med tredjeparter" + "Hjælp med at forbedre %1$s" + 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 0000000..9fd4dbf --- /dev/null +++ b/features/analytics/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,10 @@ + + + "Wir speichern oder profilieren keine personenbezogenen Daten." + "Teile anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen." + "Weitere Informationen findest du %1$s." + "hier" + "Du kannst diese Funktion jederzeit deaktivieren" + "Wir geben deine Daten nicht an Dritte weiter" + "Hilf uns %1$s zu verbessern" + diff --git a/features/analytics/impl/src/main/res/values-el/translations.xml b/features/analytics/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..253836a --- /dev/null +++ b/features/analytics/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,10 @@ + + + "Δεν θα καταγράψουμε ούτε θα δημιουργήσουμε προφίλ προσωπικών δεδομένων" + "Μοιράσου ανώνυμα δεδομένα χρήσης για να μάς βοηθήσεις να εντοπίσουμε προβλήματα." + "Μπορείς να διαβάσεις όλους τους όρους μας %1$s." + "εδώ" + "Μπορείς να το απενεργοποιήσεις ανά πάσα στιγμή" + "Δεν θα μοιραστούμε τα δεδομένα σου με τρίτους" + "Βοήθησε στη βελτίωση του %1$s" + diff --git a/features/analytics/impl/src/main/res/values-es/translations.xml b/features/analytics/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..93cf2e1 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,10 @@ + + + "No registraremos o perfilaremos ningún dato personal" + "Compartir datos de uso anónimos para ayudarnos a identificar problemas." + "Puedes leer todos nuestros términos %1$s." + "aquí" + "Puedes desactivarlo en cualquier momento" + "No compartiremos tus datos con terceros" + "Ayuda a mejorar %1$s" + diff --git a/features/analytics/impl/src/main/res/values-et/translations.xml b/features/analytics/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..4af3853 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,10 @@ + + + "Me ei salvesta ega profileeri sinu isiklikke andmeid" + "Võimalike rakenduse vigade leidmiseks palun jaga anonüümset kasutusteavet." + "Sa võid lugeda meie kasutustingimusi %1$s" + "siin" + "Selle valiku saad igal ajal välja lülitada" + "Me ei jaga sinu andmeid kolmandate osapooltega" + "Aita parandada rakendust %1$s" + diff --git a/features/analytics/impl/src/main/res/values-eu/translations.xml b/features/analytics/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..a0083af --- /dev/null +++ b/features/analytics/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,9 @@ + + + "Partekatu erabilerari buruzko datu anonimoak arazoak identifikatzen laguntzeko." + "Gure baldintza guztiak irakur ditzakezu %1$s." + "hemen" + "Edozein unetan desaktibatu dezakezu" + "Ez ditugu zure datuak hirugarrenekin partekatuko" + "Lagundu %1$s hobetzen" + diff --git a/features/analytics/impl/src/main/res/values-fa/translations.xml b/features/analytics/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..45ac8c1 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,10 @@ + + + "ما هیچ گونه اطلاعات شخصی را ضبط یا نمایه‌سازی نمی‌کنیم" + "داده های استفاده ناشناس را به اشتراک بگذارید تا به ما در شناسایی مشکلات کمک کند." + "شما می‌توانید تمام شرایط ما را بخوانید%1$s ." + "این‌جا" + "می‌توانید در هر زمان خاموشش کنید" + "داده‌هایتان را با سوم‌شخص‌ها هم‌نمی‌رسانیم" + "کمک به بهبود %1$s" + diff --git a/features/analytics/impl/src/main/res/values-fi/translations.xml b/features/analytics/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..26fad90 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,10 @@ + + + "Emme tallenna tai profiloi henkilötietoja" + "Jaa anonyymejä käyttötietoja auttaaksesi meitä tunnistamaan ongelmat." + "Voit lukea kaikki ehtomme %1$s." + "täällä" + "Voit poistaa tämän käytöstä milloin tahansa" + "Emme jaa tietojasi kolmansien osapuolien kanssa" + "Auta parantamaan %1$s -sovellusta" + 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 0000000..e18657c --- /dev/null +++ b/features/analytics/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,10 @@ + + + "Nous n’enregistrerons ni ne profilerons aucune donnée personnelle" + "Partagez des données d’utilisation anonymes pour nous aider à identifier les problèmes." + "Vous pouvez lire toutes nos conditions %1$s." + "ici" + "Vous pouvez le désactiver à tout moment" + "Nous ne partagerons pas vos données avec des tiers" + "Aidez à améliorer %1$s" + diff --git a/features/analytics/impl/src/main/res/values-hu/translations.xml b/features/analytics/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..dea908f --- /dev/null +++ b/features/analytics/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,10 @@ + + + "Nem rögzítünk vagy profilozunk személyes adatokat" + "Anonim használati adatok megosztása a problémák azonosítása érdekében." + "%1$s olvashatja el a feltételeinket." + "Itt" + "Ezt bármikor kikapcsolhatja" + "Adatait nem osztjuk meg harmadik felekkel" + "Segítsen az %1$s fejlesztésében" + diff --git a/features/analytics/impl/src/main/res/values-in/translations.xml b/features/analytics/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..655b0c6 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,10 @@ + + + "Kami tidak akan merekam atau memprofil data pribadi apa pun" + "Bagikan data penggunaan anonim untuk membantu kami mengidentifikasi masalah." + "Anda dapat membaca semua persyaratan kami %1$s." + "di sini" + "Anda dapat mematikan ini kapan saja" + "Kami tidak akan membagikan data Anda dengan pihak ketiga" + "Bantu sempurnakan %1$s" + diff --git a/features/analytics/impl/src/main/res/values-it/translations.xml b/features/analytics/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..6e030d5 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,10 @@ + + + "Non registreremo né profileremo alcun dato personale" + "Condividi dati di utilizzo anonimi per aiutarci a identificare problemi." + "Puoi leggere tutti i nostri termini %1$s." + "qui" + "Puoi disattivarlo in qualsiasi momento" + "Non condivideremo i tuoi dati con terze parti" + "Aiutaci a migliorare %1$s" + diff --git a/features/analytics/impl/src/main/res/values-ka/translations.xml b/features/analytics/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..fb561f4 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,10 @@ + + + "ჩვენ არ ჩავწერთ და არ დავაფიქსირებთ პერსონალურ მონაცემებს" + "გააზიარეთ ანონიმური გამოყენების მონაცემები, რათა დაგვეხმაროთ პრობლემების იდენტიფიცირებაში." + "შეგიძლიათ, წაიკითხოთ ჩვენი ყველა პირობა %1$s." + "აქ" + "ამის გამორთვა ნებისმიერ დროს შეგიძლიათ" + "თქვენს მონაცემებს მესამე პირს არ გადავცემთ" + "დაგვეხმარეთ, გავაუმჯობესოთ %1$s" + diff --git a/features/analytics/impl/src/main/res/values-ko/translations.xml b/features/analytics/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..80dca72 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,10 @@ + + + "개인 데이터는 기록하거나 프로파일링하지 않습니다." + "익명화된 사용 데이터를 공유하여 문제점을 파악하는 데 도움을 주십시오." + "모든 이용 약관은 %1$s 에서 확인하실 수 있습니다." + "여기" + "이 기능을 언제든지 비활성화할 수 있습니다." + "우리는 귀하의 데이터를 제3자와 공유하지 않습니다." + "%1$s 개선하기" + diff --git a/features/analytics/impl/src/main/res/values-lt/translations.xml b/features/analytics/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..11f2cbc --- /dev/null +++ b/features/analytics/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,10 @@ + + + "Mes nekaupsime ir neprofiliuosime jokių asmens duomenų" + "Dalinkitės anoniminiais naudojimo duomenimis ir padėkite mums nustatyti problemas." + "Galite perskaityti visas mūsų sąlygas %1$s." + "čia" + "Tai galite bet kada išjungti" + "Mes nesidalinsime Jūsų duomenimis su trečiosiomis šalimis" + "Padėkite pagerinti %1$s" + diff --git a/features/analytics/impl/src/main/res/values-nb/translations.xml b/features/analytics/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..6225fdf --- /dev/null +++ b/features/analytics/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,10 @@ + + + "Vi vil ikke registrere eller profilere noen personlige data" + "Del anonyme bruksdata for å hjelpe oss med å identifisere problemer." + "Du kan lese alle vilkårene våre på %1$s." + "her" + "Du kan slå av dette når som helst" + "Vi deler ikke dataene dine med tredjeparter" + "Hjelp til å forbedre %1$s" + diff --git a/features/analytics/impl/src/main/res/values-nl/translations.xml b/features/analytics/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..dc45280 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,10 @@ + + + "We zullen geen persoonlijke gegevens registreren of er een profiel van maken" + "Deel anonieme gebruiksgegevens om ons te helpen problemen te identificeren." + "Je kunt al onze voorwaarden %1$s lezen." + "hier" + "Je kunt dit op elk moment uitschakelen" + "We delen je gegevens niet met derden" + "Help %1$s te verbeteren" + diff --git a/features/analytics/impl/src/main/res/values-pl/translations.xml b/features/analytics/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..61da840 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,10 @@ + + + "Nie będziemy rejestrować ani profilować żadnych danych osobistych" + "Udostępniaj anonimowe dane użytkowania, aby pomóc nam identyfikować problemy." + "Przeczytaj nasze warunki użytkowania %1$s." + "tutaj" + "Możesz to wyłączyć w dowolnym momencie" + "Nie będziemy udostępniać Twoich danych stronom trzecim" + "Pomóż nam ulepszyć %1$s" + diff --git a/features/analytics/impl/src/main/res/values-pt-rBR/translations.xml b/features/analytics/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..c9a53b8 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,10 @@ + + + "Não iremos gravar ou personificar qualquer dado pessoal" + "Compartilhe dados de uso anônimos para nos ajudar a identificar problemas." + "Você pode ler todos os nossos termos %1$s." + "aqui" + "Você pode desativar isso a qualquer momento" + "Não compartilharemos seus dados com terceiros" + "Ajude a melhorar o %1$s" + diff --git a/features/analytics/impl/src/main/res/values-pt/translations.xml b/features/analytics/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..d98f020 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,10 @@ + + + "Não recolheremos ou analisaremos quaisquer dados pessoais" + "Partilhe dados de utilização anónimos para nos ajudar a identificar problemas." + "Podes ler todos os nossos termos %1$s." + "aqui" + "Pode desactivar a qualquer momento" + "Não partilharemos os teus dados com terceiros" + "Ajude a melhorar a %1$s" + 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 0000000..f9fd53a --- /dev/null +++ b/features/analytics/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,10 @@ + + + "Nu vom înregistra și nu vom face profiluri cu privire la datele personale." + "Distribuiți date anonime de utilizare pentru a ne ajuta să identificăm probleme." + "Puteți citi toate condițiile noastre %1$s." + "aici" + "Puteți dezactiva această opțiune oricând din setări" + "Nu vom partaja datele dvs. cu terțe părți" + "Ajutați la îmbunătățirea %1$s" + diff --git a/features/analytics/impl/src/main/res/values-ru/translations.xml b/features/analytics/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..4212f5f --- /dev/null +++ b/features/analytics/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,10 @@ + + + "Мы не будем записывать или профилировать какие-либо персональные данные" + "Предоставьте разработчикам анонимные данные об использовании, чтобы помочь им выявлять проблемы эффективнее." + "Вы можете ознакомиться со всеми нашими условиями %1$s." + "здесь" + "Вы можете отключить эту функцию в любое время" + "Мы не будем передавать ваши данные третьим лицам" + "Помогите улучшить %1$s" + 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 0000000..d16c34d --- /dev/null +++ b/features/analytics/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,10 @@ + + + "Nezaznamenávame ani neprofilujeme žiadne osobné údaje" + "Zdieľajte anonymné údaje o používaní, aby sme mohli identifikovať problémy." + "Môžete si prečítať všetky naše podmienky %1$s." + "tu" + "Môžete to kedykoľvek vypnúť" + "Vaše údaje nebudeme zdieľať s tretími stranami" + "Pomôžte zlepšiť %1$s" + diff --git a/features/analytics/impl/src/main/res/values-sv/translations.xml b/features/analytics/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..6555e3f --- /dev/null +++ b/features/analytics/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,10 @@ + + + "Vi kommer inte att registrera eller profilera några personuppgifter" + "Dela anonyma användningsdata för att hjälpa oss att identifiera problem." + "Du kan läsa alla våra villkor %1$s." + "här" + "Du kan stänga av detta när som helst" + "Vi delar inte dina uppgifter med tredje part" + "Hjälp till att förbättra %1$s" + diff --git a/features/analytics/impl/src/main/res/values-tr/translations.xml b/features/analytics/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..238e53b --- /dev/null +++ b/features/analytics/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,10 @@ + + + "Hiçbir kişisel veriyi kaydetmeyeceğiz veya profillemeyeceğiz" + "Sorunları tanımlamamıza yardımcı olmak için anonim kullanım verilerini paylaşın." + "Tüm şartlarımızı okuyabilirsiniz %1$s." + "burada" + "Bu özelliği istediğiniz zaman kapatabilirsiniz" + "Verilerinizi üçüncü taraflarla paylaşmayacağız" + "%1$s geliştirilmesine yardımcı olun" + diff --git a/features/analytics/impl/src/main/res/values-uk/translations.xml b/features/analytics/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..c40f29a --- /dev/null +++ b/features/analytics/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,10 @@ + + + "Ми не записуватимемо та не профілюватимемо жодні персональні дані" + "Ділитися анонімними даними про використання, щоб допомогати нам виявляти проблеми." + "Ви можете прочитати всі наші умови %1$s." + "тут" + "Ви можете вимкнути цю функцію в будь-який час" + "Ми не передаватимемо ваші дані третім особам" + "Допоможіть вдосконалити %1$s" + diff --git a/features/analytics/impl/src/main/res/values-ur/translations.xml b/features/analytics/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..4b95033 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,10 @@ + + + "ہم کسی بھی ذاتی ڈیٹا کو ثبت یا پروفائل نہیں کریں گے" + "مسائل کی نشاندہی کرنے میں ہماری مدد کے لیے گمنام استعمال کے بیانات کا اشتراک کریں۔" + "آپ ہماری تمام شرائط پڑھ سکتے ہیں %1$s۔" + "یہاں" + "آپ اسے کسی بھی وقت بند کر سکتے ہیں" + "ہم آپکے بیانات کا فریق ثالث کے ساتھ اشتراک نہیں کریں گے" + "%1$s کو بہتر بنانے میں مدد کریں" + diff --git a/features/analytics/impl/src/main/res/values-uz/translations.xml b/features/analytics/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..daa1080 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,10 @@ + + + "Biz hech qanday shaxsiy ma\'lumotlarni yozmaymiz yoki profilga kiritmaymiz" + "Muammolarni aniqlashda yordam berish uchun anonim foydalanish maʼlumotlarini baham koʻring." + "Siz bizning barcha shartlarimizni o\'qishingiz mumkin%1$s." + "Bu yerga" + "Buni istalgan vaqtda oʻchirib qoʻyishingiz mumkin" + "Biz sizning ma\'lumotlaringizni uchinchi tomonlar bilan baham ko\'rmaymiz" + "Yaxshilashga yordam bering%1$s" + diff --git a/features/analytics/impl/src/main/res/values-zh-rTW/translations.xml b/features/analytics/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..ef7a3c7 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,10 @@ + + + "我們不會紀錄或剖繪您的個人資料" + "提供匿名的使用數據以協助我們釐清問題。" + "您可以到%1$s閱讀我們的條款。" + "這裡" + "您可以在任何時候關閉它" + "我們不會和第三方分享您的資料" + "讓 %1$s 變得更好" + diff --git a/features/analytics/impl/src/main/res/values-zh/translations.xml b/features/analytics/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..d186506 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,10 @@ + + + "我们不会记录或分析任何个人数据" + "共享匿名使用数据以帮助我们排查问题。" + "您可以阅读我们的所有条款 %1$s。" + "此处" + "可以随时关闭此功能" + "我们不会与第三方共享您的数据" + "帮助改进 %1$s" + 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 0000000..a496bdd --- /dev/null +++ b/features/analytics/impl/src/main/res/values/localazy.xml @@ -0,0 +1,10 @@ + + + "We won\'t record or profile any personal data" + "Share anonymous usage data to help us identify issues." + "You can read all our terms %1$s." + "here" + "You can turn this off anytime" + "We won\'t share your data with third parties" + "Help improve %1$s" + 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 0000000..6521c7f --- /dev/null +++ b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInPresenterTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.analytics.impl + +import app.cash.molecule.RecompositionMode +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.libraries.matrix.test.core.aBuildMeta +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class AnalyticsOptInPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - enable`() = runTest { + val analyticsService = FakeAnalyticsService(isEnabled = false) + val presenter = AnalyticsOptInPresenter( + aBuildMeta(), + analyticsService + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse() + initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true)) + assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue() + assertThat(analyticsService.userConsentFlow.first()).isTrue() + } + } + + @Test + fun `present - not now`() = runTest { + val analyticsService = FakeAnalyticsService(isEnabled = false) + val presenter = AnalyticsOptInPresenter( + aBuildMeta(), + analyticsService + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse() + initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false)) + assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue() + assertThat(analyticsService.userConsentFlow.first()).isFalse() + } + } +} diff --git a/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/DefaultAnalyticsEntryPointTest.kt b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/DefaultAnalyticsEntryPointTest.kt new file mode 100644 index 0000000..0340bd3 --- /dev/null +++ b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/DefaultAnalyticsEntryPointTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.analytics.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultAnalyticsEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `test node creation`() { + val entryPoint = DefaultAnalyticsEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + AnalyticsOptInNode( + buildContext = buildContext, + plugins = plugins, + AnalyticsOptInPresenter( + buildMeta = aBuildMeta(), + analyticsService = FakeAnalyticsService() + ) + ) + } + val result = entryPoint.createNode(parentNode, BuildContext.root(null)) + assertThat(result).isInstanceOf(AnalyticsOptInNode::class.java) + } +} 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 0000000..174935a --- /dev/null +++ b/features/analytics/impl/src/test/kotlin/io/element/android/features/analytics/impl/preferences/AnalyticsPreferencesPresenterTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.analytics.impl.preferences + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.appconfig.AnalyticsConfig +import io.element.android.features.analytics.api.AnalyticsOptInEvents +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class AnalyticsPreferencesPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state available`() = runTest { + val presenter = AnalyticsPreferencesPresenter( + FakeAnalyticsService(isEnabled = true, didAskUserConsent = true), + aBuildMeta() + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isEnabled).isTrue() + assertThat(initialState.policyUrl).isEqualTo(AnalyticsConfig.POLICY_LINK) + } + } + + @Test + fun `present - initial state not available`() = runTest { + val presenter = AnalyticsPreferencesPresenter( + FakeAnalyticsService(isEnabled = false, didAskUserConsent = false), + aBuildMeta() + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.isEnabled).isFalse() + } + } + + @Test + fun `present - enable and disable`() = runTest { + val presenter = AnalyticsPreferencesPresenter( + FakeAnalyticsService(isEnabled = true, didAskUserConsent = true), + aBuildMeta() + ) + moleculeFlow(RecompositionMode.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/announcement/api/build.gradle.kts b/features/announcement/api/build.gradle.kts new file mode 100644 index 0000000..0c2f5bb --- /dev/null +++ b/features/announcement/api/build.gradle.kts @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.announcement.api" +} diff --git a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt new file mode 100644 index 0000000..0bf3565 --- /dev/null +++ b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.announcement.api + +enum class Announcement { + Space, + NewNotificationSound, +} diff --git a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementService.kt b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementService.kt new file mode 100644 index 0000000..42c66aa --- /dev/null +++ b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementService.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.announcement.api + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.Flow + +interface AnnouncementService { + suspend fun showAnnouncement(announcement: Announcement) + + suspend fun onAnnouncementDismissed(announcement: Announcement) + + fun announcementsToShowFlow(): Flow> + + /** + * Use this composable to render the announcement UI in Fullscreen. + */ + @Composable + fun Render( + modifier: Modifier, + ) +} diff --git a/features/announcement/impl/build.gradle.kts b/features/announcement/impl/build.gradle.kts new file mode 100644 index 0000000..443e343 --- /dev/null +++ b/features/announcement/impl/build.gradle.kts @@ -0,0 +1,38 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.announcement.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.preferences.api) + implementation(projects.libraries.uiStrings) + api(projects.features.announcement.api) + implementation(libs.androidx.datastore.preferences) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) +} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt new file mode 100644 index 0000000..508f1e4 --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenter.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.announcement.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import dev.zacsweers.metro.Inject +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.impl.store.AnnouncementStatus +import io.element.android.features.announcement.impl.store.AnnouncementStore +import io.element.android.libraries.architecture.Presenter +import kotlinx.coroutines.flow.map + +@Inject +class AnnouncementPresenter( + private val announcementStore: AnnouncementStore, +) : Presenter { + @Composable + override fun present(): AnnouncementState { + val showSpaceAnnouncement by remember { + announcementStore.announcementStatusFlow(Announcement.Space).map { + it == AnnouncementStatus.Show + } + }.collectAsState(false) + return AnnouncementState( + showSpaceAnnouncement = showSpaceAnnouncement, + ) + } +} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementState.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementState.kt new file mode 100644 index 0000000..e762dd6 --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/AnnouncementState.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.announcement.impl + +data class AnnouncementState( + val showSpaceAnnouncement: Boolean, +) + +fun anAnnouncementState( + showSpaceAnnouncement: Boolean = false, +) = AnnouncementState( + showSpaceAnnouncement = showSpaceAnnouncement, +) diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt new file mode 100644 index 0000000..0e5c301 --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.announcement.impl + +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.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService +import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementState +import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementView +import io.element.android.features.announcement.impl.store.AnnouncementStatus +import io.element.android.features.announcement.impl.store.AnnouncementStore +import io.element.android.libraries.architecture.Presenter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first + +@ContributesBinding(AppScope::class) +class DefaultAnnouncementService( + private val announcementStore: AnnouncementStore, + private val announcementPresenter: Presenter, + private val spaceAnnouncementPresenter: Presenter, +) : AnnouncementService { + override suspend fun showAnnouncement(announcement: Announcement) { + when (announcement) { + Announcement.Space -> showSpaceAnnouncement() + Announcement.NewNotificationSound -> { + announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) + } + } + } + + override suspend fun onAnnouncementDismissed(announcement: Announcement) { + announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Shown) + } + + override fun announcementsToShowFlow(): Flow> { + return combine( + announcementStore.announcementStatusFlow(Announcement.Space), + announcementStore.announcementStatusFlow(Announcement.NewNotificationSound), + ) { spaceAnnouncementStatus, newNotificationSoundStatus -> + buildList { + if (spaceAnnouncementStatus == AnnouncementStatus.Show) { + add(Announcement.Space) + } + if (newNotificationSoundStatus == AnnouncementStatus.Show) { + add(Announcement.NewNotificationSound) + } + } + } + } + + private suspend fun showSpaceAnnouncement() { + val currentValue = announcementStore.announcementStatusFlow(Announcement.Space).first() + if (currentValue == AnnouncementStatus.NeverShown) { + announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Show) + } + } + + @Composable + override fun Render(modifier: Modifier) { + val announcementState = announcementPresenter.present() + Box(modifier = modifier.fillMaxSize()) { + AnimatedVisibility( + visible = announcementState.showSpaceAnnouncement, + enter = fadeIn(), + exit = fadeOut(), + ) { + val spaceAnnouncementState = spaceAnnouncementPresenter.present() + SpaceAnnouncementView( + state = spaceAnnouncementState, + ) + } + } + } +} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/di/AnnouncementModule.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/di/AnnouncementModule.kt new file mode 100644 index 0000000..4cfc073 --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/di/AnnouncementModule.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.announcement.impl.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.announcement.impl.AnnouncementPresenter +import io.element.android.features.announcement.impl.AnnouncementState +import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementPresenter +import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementState +import io.element.android.libraries.architecture.Presenter + +@ContributesTo(AppScope::class) +@BindingContainer +interface AnnouncementModule { + @Binds + fun bindAnnouncementPresenter(presenter: AnnouncementPresenter): Presenter + + @Binds + fun bindSpaceAnnouncementPresenter(presenter: SpaceAnnouncementPresenter): Presenter +} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementEvents.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementEvents.kt new file mode 100644 index 0000000..3b968d0 --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementEvents.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.announcement.impl.spaces + +sealed interface SpaceAnnouncementEvents { + data object Continue : SpaceAnnouncementEvents +} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenter.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenter.kt new file mode 100644 index 0000000..7c4bc7b --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenter.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.announcement.impl.spaces + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import dev.zacsweers.metro.Inject +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.impl.store.AnnouncementStatus +import io.element.android.features.announcement.impl.store.AnnouncementStore +import io.element.android.libraries.architecture.Presenter +import kotlinx.coroutines.launch + +@Inject +class SpaceAnnouncementPresenter( + private val announcementStore: AnnouncementStore, +) : Presenter { + @Composable + override fun present(): SpaceAnnouncementState { + val localCoroutineScope = rememberCoroutineScope() + + fun handleEvent(event: SpaceAnnouncementEvents) { + when (event) { + SpaceAnnouncementEvents.Continue -> localCoroutineScope.launch { + announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Shown) + } + } + } + + return SpaceAnnouncementState( + eventSink = ::handleEvent, + ) + } +} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementState.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementState.kt new file mode 100644 index 0000000..9407fad --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementState.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.announcement.impl.spaces + +data class SpaceAnnouncementState( + val eventSink: (SpaceAnnouncementEvents) -> Unit +) diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementStateProvider.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementStateProvider.kt new file mode 100644 index 0000000..27f48cc --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementStateProvider.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.announcement.impl.spaces + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class SpaceAnnouncementStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aSpaceAnnouncementState(), + ) +} + +fun aSpaceAnnouncementState( + eventSink: (SpaceAnnouncementEvents) -> Unit = {}, +) = SpaceAnnouncementState( + eventSink = eventSink, +) diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt new file mode 100644 index 0000000..e0759bc --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.announcement.impl.spaces + +import androidx.activity.compose.BackHandler +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.padding +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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.announcement.impl.R +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.organisms.InfoListItem +import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism +import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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 +import kotlinx.collections.immutable.persistentListOf + +/** + * Ref: https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=4593-40181 + */ +@Composable +fun SpaceAnnouncementView( + state: SpaceAnnouncementState, + modifier: Modifier = Modifier, +) { + val eventSink = state.eventSink + + fun onContinue() { + eventSink(SpaceAnnouncementEvents.Continue) + } + + BackHandler(onBack = ::onContinue) + HeaderFooterPage( + modifier = modifier, + isScrollable = true, + contentPadding = PaddingValues(top = 24.dp, start = 16.dp, end = 16.dp, bottom = 24.dp), + header = { + SpaceAnnouncementHeader() + }, + content = { + SpaceAnnouncementContent( + modifier = Modifier.padding(horizontal = 8.dp), + ) + }, + footer = { + SpaceAnnouncementFooter( + onContinue = ::onContinue, + ) + } + ) +} + +@Composable +private fun SpaceAnnouncementHeader( + modifier: Modifier = Modifier, +) { + IconTitleSubtitleMolecule( + modifier = modifier.padding(top = 16.dp, bottom = 16.dp), + title = stringResource(id = R.string.screen_space_announcement_title), + showBetaLabel = true, + subTitle = stringResource(id = R.string.screen_space_announcement_subtitle), + iconStyle = BigIcon.Style.Default( + vectorIcon = CompoundIcons.WorkspaceSolid(), + usePrimaryTint = true, + ), + ) +} + +@Composable +private fun SpaceAnnouncementContent( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + ) { + InfoListOrganism( + modifier = Modifier.fillMaxWidth(), + items = persistentListOf( + InfoListItem( + message = stringResource(id = R.string.screen_space_announcement_item1), + iconVector = CompoundIcons.VisibilityOn(), + ), + InfoListItem( + message = stringResource(id = R.string.screen_space_announcement_item2), + iconVector = CompoundIcons.Email(), + ), + InfoListItem( + message = stringResource(id = R.string.screen_space_announcement_item3), + iconVector = CompoundIcons.Search(), + ), + InfoListItem( + message = stringResource(id = R.string.screen_space_announcement_item4), + iconVector = CompoundIcons.Explore(), + ), + InfoListItem( + message = stringResource(id = R.string.screen_space_announcement_item5), + iconVector = CompoundIcons.Leave(), + ), + ), + textStyle = ElementTheme.typography.fontBodyLgMedium, + iconTint = ElementTheme.colors.iconSecondary, + iconSize = 24.dp + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + text = stringResource(id = R.string.screen_space_announcement_notice), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun SpaceAnnouncementFooter( + onContinue: () -> Unit, +) { + ButtonColumnMolecule( + modifier = Modifier.padding(bottom = 8.dp) + ) { + Button( + text = stringResource(id = CommonStrings.action_continue), + onClick = onContinue, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun SpaceAnnouncementViewPreview(@PreviewParameter(SpaceAnnouncementStateProvider::class) state: SpaceAnnouncementState) = ElementPreview { + SpaceAnnouncementView( + state = state, + ) +} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStatus.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStatus.kt new file mode 100644 index 0000000..5f3dc7d --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStatus.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.announcement.impl.store + +enum class AnnouncementStatus { + NeverShown, + Show, + Shown, +} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStore.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStore.kt new file mode 100644 index 0000000..c818e90 --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/AnnouncementStore.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.announcement.impl.store + +import io.element.android.features.announcement.api.Announcement +import kotlinx.coroutines.flow.Flow + +interface AnnouncementStore { + suspend fun setAnnouncementStatus( + announcement: Announcement, + status: AnnouncementStatus, + ) + + fun announcementStatusFlow( + announcement: Announcement, + ): Flow + + suspend fun reset() +} diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt new file mode 100644 index 0000000..ad166e4 --- /dev/null +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.announcement.impl.store + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.announcement.api.Announcement +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement") +private val newNotificationSoundKey = intPreferencesKey("newNotificationSound") + +@ContributesBinding(AppScope::class) +class DefaultAnnouncementStore( + preferenceDataStoreFactory: PreferenceDataStoreFactory, +) : AnnouncementStore { + private val store = preferenceDataStoreFactory.create("elementx_announcement") + + override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) { + val key = announcement.toKey() + store.edit { prefs -> + prefs[key] = status.ordinal + } + } + + override fun announcementStatusFlow(announcement: Announcement): Flow { + val key = announcement.toKey() + // For NewNotificationSound, a migration will set it to Show on application upgrade (see AppMigration08) + val defaultStatus = when (announcement) { + Announcement.Space -> AnnouncementStatus.NeverShown + Announcement.NewNotificationSound -> AnnouncementStatus.Shown + } + return store.data.map { prefs -> + val ordinal = prefs[key] ?: defaultStatus.ordinal + AnnouncementStatus.entries.getOrElse(ordinal) { defaultStatus } + } + } + + override suspend fun reset() { + store.edit { it.clear() } + } +} + +private fun Announcement.toKey() = when (this) { + Announcement.Space -> spaceAnnouncementKey + Announcement.NewNotificationSound -> newNotificationSoundKey +} diff --git a/features/announcement/impl/src/main/res/values-bg/translations.xml b/features/announcement/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..853cf5f --- /dev/null +++ b/features/announcement/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,4 @@ + + + "Присъединете се към обществени пространства" + diff --git a/features/announcement/impl/src/main/res/values-cs/translations.xml b/features/announcement/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..cf7ead1 --- /dev/null +++ b/features/announcement/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,11 @@ + + + "Zobrazit prostory, které jste vytvořili nebo ke kterým jste se připojili" + "Přijmout nebo odmítnout pozvánky do prostorů" + "Objevte všechny místnosti, do kterých můžete vstoupit ve svých prostorech" + "Připojit se k veřejným prostorům" + "Opustit všechny prostory, ke kterým jste se připojili" + "Filtrování, vytváření a správa prostorů bude brzy k dispozici." + "Vítejte v beta verzi prostorů! S touto první verzí můžete:" + "Představujeme prostory" + diff --git a/features/announcement/impl/src/main/res/values-da/translations.xml b/features/announcement/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..d07871b --- /dev/null +++ b/features/announcement/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,11 @@ + + + "Se grupper, du har oprettet eller tilmeldt dig" + "Acceptere eller afvise invitationer til grupper" + "Finde alle rum, du kan deltage i, i dine grupper" + "Deltage i offentlige grupper" + "Forlade de grupper, du har tilsluttet dig" + "Filtrering, oprettelse og administration af grupper kommer snart." + "Velkommen til betaversionen af Grupper! Med denne første version kan du:" + "Introduktion til Grupper" + diff --git a/features/announcement/impl/src/main/res/values-de/translations.xml b/features/announcement/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..11f5f3a --- /dev/null +++ b/features/announcement/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,11 @@ + + + "Von dir erstellte oder beigetretene Spaces anzeigen" + "Einladungen zu Spaces annehmen oder ablehnen" + "Chats innerhalb deiner Spaces entdecken, um ihnen beizutreten" + "Öffentlichen Spaces beitreten" + "Spaces verlassen, bei denen du Mitglied bist" + "Das Filtern, Erstellen und Verwalten von Spaces ist bald verfügbar." + "Willkommen bei der Beta-Version von Spaces! Mit dieser ersten Version kannst du:" + "Einführung in Spaces" + diff --git a/features/announcement/impl/src/main/res/values-et/translations.xml b/features/announcement/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..ee2ba9c --- /dev/null +++ b/features/announcement/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,11 @@ + + + "Vaadata kogukondi, mille oled loonud või millega oled liitunud" + "Nõustuda kutsetega liitumiseks kogukonnaga või sellest keelduda" + "Uurida neis kogukondades leiduvaid jututube ning nendega liituda" + "Liituda avalike kogukondadega" + "Lahkuda kogukonnast, millega oled liitunud" + "Kogukondade filtreerimine, loomine ja haldamine lisandub peagi" + "Tere tulemast kasutama kogukondade beetaversiooni! Selles esimeses versioonis saad sa:" + "Võtame kasutusele kogukonnad" + diff --git a/features/announcement/impl/src/main/res/values-fa/translations.xml b/features/announcement/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..2e8902a --- /dev/null +++ b/features/announcement/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,11 @@ + + + "دیدن فضاهایی که ساخته یا پیوسته‌اید" + "پذیرش یا رد دعوت‌ها به فضاها" + "کشف تمامی اتاق‌هایی که می‌توانید در فضاهایتان بپیوندید" + "پیوستن به فضاهای عمومی" + "ترک هر فضایی که پیوسته‌اید" + "پالایش، ایجاد و مدیریت کردن فضاها به زودی." + "به نگارش آزمایشی فضاها خوش آمدید! در این نگارش می‌توانید:" + "معرّفی فضاها" + diff --git a/features/announcement/impl/src/main/res/values-fi/translations.xml b/features/announcement/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..8e76744 --- /dev/null +++ b/features/announcement/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,11 @@ + + + "Nähdä luomasi tai liittymäsi tilat" + "Hyväksyä tai hylätä kutsuja tiloihin" + "Löytää kaikki huoneet, joihin voit liittyä tiloissasi" + "Liittyä julkisiin tiloihin" + "Poistua mistä tahansa tilasta, johon olet liittynyt" + "Tilojen suodatus, luominen ja hallinta on tulossa pian." + "Tervetuloa tilojen beetaversioon! Tämän ensimmäisen version avulla voit:" + "Esittelyssä tilat" + diff --git a/features/announcement/impl/src/main/res/values-fr/translations.xml b/features/announcement/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..7e042c6 --- /dev/null +++ b/features/announcement/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,11 @@ + + + "Voir les espaces que vous avez créés ou rejoints" + "Accepter ou refuser les invitations aux espaces" + "Découvrir les salons que vous pouvez joindre depuis vos espaces" + "Rejoindre les espaces publics" + "Quitter les espaces dont vous êtes membre." + "Le filtrage, la création et la gestion des espaces seront bientôt disponibles." + "Bienvenue dans la version bêta des espaces! Avec cette première version, vous pourrez :" + "Ajout des espaces" + diff --git a/features/announcement/impl/src/main/res/values-hu/translations.xml b/features/announcement/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..b09f704 --- /dev/null +++ b/features/announcement/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,11 @@ + + + "Az Ön által létrehozott vagy csatlakozott térek megtekintése" + "A meghívások elfogadására vagy elutasítására a terekhez" + "Szobák felfedezése a terekben, amelyekhez csatlakozhat" + "Csatlakozás nyilvános terekhez" + "Terek elhagyása" + "Terek szűrése, készítése és kezelése hamarosan érkezik." + "Üdvözöljük a tér béta verziójában! Ezzel az első verzióval a következőket teheti:" + "Bemutatkoznak a terek" + diff --git a/features/announcement/impl/src/main/res/values-it/translations.xml b/features/announcement/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..584ddcd --- /dev/null +++ b/features/announcement/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,11 @@ + + + "Visualizza gli spazi che hai creato o a cui partecipi" + "Accetta o rifiuta gli inviti agli spazi" + "Scopri tutte le stanze a cui puoi partecipare nei tuoi spazi" + "Unisciti agli spazi pubblici" + "Lascia tutti gli spazi a cui ti sei unito" + "A breve saranno disponibili le funzionalità di filtraggio, creazione e gestione degli spazi." + "Benvenuti alla versione beta degli Spazi! Con questa prima versione potrete:" + "Ti presentiamo gli Spazi" + diff --git a/features/announcement/impl/src/main/res/values-nb/translations.xml b/features/announcement/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..553ff9f --- /dev/null +++ b/features/announcement/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,11 @@ + + + "Se områder du har opprettet eller blitt med i" + "Godta eller avslå invitasjoner til områder" + "Oppdag alle rom du kan bli med i i dine områder" + "Bli med i offentlige områder" + "Forlat områder du har blitt med i" + "Oppretting, filtrering og administrasjon av områder kommer snart." + "Velkommen til betaversjonen av Områder! Med denne første versjonen kan du:" + "Vi introduserer Områder" + diff --git a/features/announcement/impl/src/main/res/values-pl/translations.xml b/features/announcement/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..4308bdd --- /dev/null +++ b/features/announcement/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,11 @@ + + + "Wyświetlić przestrzenie, które stworzyłeś lub do których dołączyłeś" + "Akceptować lub odrzucać zaproszenia" + "Odkrywać wszystkie pokoje, do których możesz dołączyć w swoich przestrzeniach" + "Dołączać do przestrzeni publicznych" + "Opuszczać jakąkolwiek przestrzeń, do której dołączyłeś" + "Filtrowanie, tworzenie i zarządzanie przestrzeniami pojawi się wkrótce." + "Witamy w wersji beta przestrzeni! W tej wersji możesz:" + "Przedstawiamy przestrzenie" + diff --git a/features/announcement/impl/src/main/res/values-pt-rBR/translations.xml b/features/announcement/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..32a9bf8 --- /dev/null +++ b/features/announcement/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,11 @@ + + + "Visualizar espaços que criou ou entrou" + "Aceitar ou recusar convites aos espaços" + "Descobrir quaisquer salas que você pode entrar nos espaços" + "Entrar espaços públicos" + "Sair de quaisquer espaços que entrou" + "Filtrar, criar, e gerenciar espaços virão em breve." + "Boas-vindas à versão beta dos Espaços! Com essa primeira versão, você pode:" + "Apresentando Espaços" + diff --git a/features/announcement/impl/src/main/res/values-ro/translations.xml b/features/announcement/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..48fa06f --- /dev/null +++ b/features/announcement/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,11 @@ + + + "Vizualizați spațiile pe care le-ați creat sau la care v-ați alăturat" + "Acceptați sau refuzați invitațiile la spații" + "Descoperiți toate camerele la care vă puteți alătura în spațiile dumneavoastră." + "Alăturați-vă spațiilor publice" + "Părăsiți spațiile la care v-ați alăturat." + "Crearea și gestionarea spațiilor vor fi disponibile în curând." + "Bun venit la versiunea beta a Spațiilor! Cu această primă versiune puteți:" + "Vă prezentăm Spații" + diff --git a/features/announcement/impl/src/main/res/values-ru/translations.xml b/features/announcement/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..7ff445b --- /dev/null +++ b/features/announcement/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,11 @@ + + + "Просмотр пространств, которые вы создали или к которым присоединились" + "Принимать или отклонять приглашения в пространства" + "Откройте для себя все комнаты, к которым вы можете присоединиться в своих пространствах." + "Присоединиться к публичному пространству" + "Покинуть все пространства, к которым вы присоединились" + "Работа с пространствами скоро станет доступна" + "Добро пожаловать в бета-версию Spaces! В этой первой версии вы сможете:" + "Представляем пространства" + diff --git a/features/announcement/impl/src/main/res/values-sk/translations.xml b/features/announcement/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..0b30549 --- /dev/null +++ b/features/announcement/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,11 @@ + + + "Zobraziť priestory, ktoré ste vytvorili alebo ku ktorým ste sa pripojili" + "Prijímať alebo odmietať pozvánky do priestorov" + "Objaviť všetky miestnosti, do ktorých sa môžete pripojiť vo svojich priestoroch" + "Pripojiť sa k verejnému priestoru" + "Opustiť akékoľvek priestory, ku ktorým ste sa pridali" + "Filtrovanie, vytváranie a správa priestorov bude čoskoro k dispozícii." + "Vitajte v beta verzii priestorov! S touto prvou verziou môžete:" + "Predstavujeme priestory" + diff --git a/features/announcement/impl/src/main/res/values-zh-rTW/translations.xml b/features/announcement/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..a5b8275 --- /dev/null +++ b/features/announcement/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,11 @@ + + + "檢視您建立或加入的空間" + "接受或拒絕空間邀請" + "探索空間內您可以加入的任何聊天室" + "加入公開空間" + "離開任何您已加入的空間" + "篩選、建立與管理空間功能即將推出。" + "歡迎使用空間的測試版!此初始版本可讓您:" + "介紹空間" + diff --git a/features/announcement/impl/src/main/res/values-zh/translations.xml b/features/announcement/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..e01e63b --- /dev/null +++ b/features/announcement/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,11 @@ + + + "查看您创建或加入的空间" + "接受或拒绝空间邀请" + "发现您可以加入空间的所有房间" + "加入公共空间" + "离开你加入的所有空间" + "筛选、创建及管理空间功能即将上线。" + "欢迎使用 Spaces 测试版!使用首个版本,您可以:" + "Spaces 简介" + diff --git a/features/announcement/impl/src/main/res/values/localazy.xml b/features/announcement/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..5e7b8a6 --- /dev/null +++ b/features/announcement/impl/src/main/res/values/localazy.xml @@ -0,0 +1,11 @@ + + + "View spaces you\'ve created or joined" + "Accept or decline invites to spaces" + "Discover any rooms you can join in your spaces" + "Join public spaces" + "Leave any spaces you’ve joined" + "Filtering, creating and managing spaces is coming soon." + "Welcome to the beta version of Spaces! With this first version you can:" + "Introducing Spaces" + diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenterTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenterTest.kt new file mode 100644 index 0000000..18deb8b --- /dev/null +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/AnnouncementPresenterTest.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.announcement.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.impl.store.AnnouncementStatus +import io.element.android.features.announcement.impl.store.AnnouncementStore +import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AnnouncementPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createAnnouncementPresenter() + presenter.test { + val state = awaitItem() + assertThat(state.showSpaceAnnouncement).isFalse() + } + } + + @Test + fun `present - showSpaceAnnouncement value depends on the value in the store`() = runTest { + val store = InMemoryAnnouncementStore() + val presenter = createAnnouncementPresenter( + announcementStore = store, + ) + presenter.test { + val state = awaitItem() + assertThat(state.showSpaceAnnouncement).isFalse() + store.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Show) + val updatedState = awaitItem() + assertThat(updatedState.showSpaceAnnouncement).isTrue() + store.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Shown) + val finalState = awaitItem() + assertThat(finalState.showSpaceAnnouncement).isFalse() + } + } +} + +private fun createAnnouncementPresenter( + announcementStore: AnnouncementStore = InMemoryAnnouncementStore(), +) = AnnouncementPresenter( + announcementStore = announcementStore, +) diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt new file mode 100644 index 0000000..e166191 --- /dev/null +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.announcement.impl + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.impl.spaces.SpaceAnnouncementState +import io.element.android.features.announcement.impl.spaces.aSpaceAnnouncementState +import io.element.android.features.announcement.impl.store.AnnouncementStatus +import io.element.android.features.announcement.impl.store.AnnouncementStore +import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore +import io.element.android.libraries.architecture.Presenter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultAnnouncementServiceTest { + @Test + fun `when showing Space announcement, space announcement is set to show only if it was never shown`() = runTest { + val announcementStore = InMemoryAnnouncementStore() + val sut = createDefaultAnnouncementService( + announcementStore = announcementStore, + ) + assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.NeverShown) + sut.showAnnouncement(Announcement.Space) + assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.Show) + // Simulate user close the announcement + sut.onAnnouncementDismissed(Announcement.Space) + // Entering again the space tab should not change the value + sut.showAnnouncement(Announcement.Space) + assertThat(announcementStore.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.Shown) + } + + @Test + fun `when showing NewNotificationSound announcement, announcement is set to show even if it was already shown`() = runTest { + val announcementStore = InMemoryAnnouncementStore() + val sut = createDefaultAnnouncementService( + announcementStore = announcementStore, + ) + assertThat(announcementStore.announcementStatusFlow(Announcement.NewNotificationSound).first()).isEqualTo(AnnouncementStatus.NeverShown) + sut.showAnnouncement(Announcement.NewNotificationSound) + assertThat(announcementStore.announcementStatusFlow(Announcement.NewNotificationSound).first()).isEqualTo(AnnouncementStatus.Show) + // Simulate user close the announcement + sut.onAnnouncementDismissed(Announcement.NewNotificationSound) + // Calling again showAnnouncement should set it back to Show + sut.showAnnouncement(Announcement.NewNotificationSound) + assertThat(announcementStore.announcementStatusFlow(Announcement.NewNotificationSound).first()).isEqualTo(AnnouncementStatus.Show) + } + + @Test + fun `test announcementsToShowFlow`() = runTest { + val announcementStore = InMemoryAnnouncementStore() + val sut = createDefaultAnnouncementService( + announcementStore = announcementStore, + ) + sut.announcementsToShowFlow().test { + assertThat(awaitItem()).isEmpty() + announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Show) + assertThat(awaitItem()).containsExactly(Announcement.Space) + announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) + assertThat(awaitItem()).containsExactly(Announcement.Space, Announcement.NewNotificationSound) + announcementStore.setAnnouncementStatus(Announcement.Space, AnnouncementStatus.Shown) + assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound) + announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown) + assertThat(awaitItem()).isEmpty() + } + } + + private fun createDefaultAnnouncementService( + announcementStore: AnnouncementStore = InMemoryAnnouncementStore(), + announcementPresenter: Presenter = Presenter { anAnnouncementState() }, + spaceAnnouncementPresenter: Presenter = Presenter { aSpaceAnnouncementState() }, + ) = DefaultAnnouncementService( + announcementStore = announcementStore, + announcementPresenter = announcementPresenter, + spaceAnnouncementPresenter = spaceAnnouncementPresenter, + ) +} diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenterTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenterTest.kt new file mode 100644 index 0000000..672f677 --- /dev/null +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementPresenterTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.announcement.impl.spaces + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.impl.store.AnnouncementStatus +import io.element.android.features.announcement.impl.store.AnnouncementStore +import io.element.android.features.announcement.impl.store.InMemoryAnnouncementStore +import io.element.android.tests.testutils.test +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SpaceAnnouncementPresenterTest { + @Test + fun `present - when user continues, the store is updated`() = runTest { + val store = InMemoryAnnouncementStore() + val presenter = createSpaceAnnouncementPresenter( + announcementStore = store, + ) + presenter.test { + assertThat(store.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.NeverShown) + val state = awaitItem() + state.eventSink(SpaceAnnouncementEvents.Continue) + assertThat(store.announcementStatusFlow(Announcement.Space).first()).isEqualTo(AnnouncementStatus.Shown) + } + } +} + +private fun createSpaceAnnouncementPresenter( + announcementStore: AnnouncementStore = InMemoryAnnouncementStore(), +) = SpaceAnnouncementPresenter( + announcementStore = announcementStore, +) diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementViewTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementViewTest.kt new file mode 100644 index 0000000..ad3d83f --- /dev/null +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementViewTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.announcement.impl.spaces + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SpaceAnnouncementViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back sends a SpaceAnnouncementEvents`() { + val eventsRecorder = EventsRecorder() + rule.setSpaceAnnouncementView( + aSpaceAnnouncementState( + eventSink = eventsRecorder, + ), + ) + rule.pressBackKey() + eventsRecorder.assertSingle(SpaceAnnouncementEvents.Continue) + } + + @Test + fun `clicking on Continue sends a SpaceAnnouncementEvents`() { + val eventsRecorder = EventsRecorder() + rule.setSpaceAnnouncementView( + aSpaceAnnouncementState( + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_continue) + eventsRecorder.assertSingle(SpaceAnnouncementEvents.Continue) + } +} + +private fun AndroidComposeTestRule.setSpaceAnnouncementView( + state: SpaceAnnouncementState, +) { + setContent { + SpaceAnnouncementView( + state = state, + ) + } +} diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt new file mode 100644 index 0000000..ab3e851 --- /dev/null +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.announcement.impl.store + +import io.element.android.features.announcement.api.Announcement +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class InMemoryAnnouncementStore( + initialSpaceAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, + initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, +) : AnnouncementStore { + private val spaceAnnouncement = MutableStateFlow(initialSpaceAnnouncementStatus) + private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus) + + override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) { + announcement.toMutableStateFlow().value = status + } + + override fun announcementStatusFlow(announcement: Announcement): Flow { + return announcement.toMutableStateFlow().asStateFlow() + } + + override suspend fun reset() { + spaceAnnouncement.value = AnnouncementStatus.NeverShown + newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown + } + + private fun Announcement.toMutableStateFlow() = when (this) { + Announcement.Space -> spaceAnnouncement + Announcement.NewNotificationSound -> newNotificationSoundAnnouncement + } +} diff --git a/features/announcement/test/build.gradle.kts b/features/announcement/test/build.gradle.kts new file mode 100644 index 0000000..d9e9251 --- /dev/null +++ b/features/announcement/test/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.announcement.test" +} + +dependencies { + implementation(projects.features.announcement.api) + implementation(libs.coroutines.core) + implementation(projects.tests.testutils) +} diff --git a/features/announcement/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeAnnouncementService.kt b/features/announcement/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeAnnouncementService.kt new file mode 100644 index 0000000..fefb61d --- /dev/null +++ b/features/announcement/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeAnnouncementService.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.test.logs + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeAnnouncementService( + initialAnnouncementsToShowFlowValue: List = emptyList(), + val showAnnouncementResult: (Announcement) -> Unit = { lambdaError() }, + val onAnnouncementDismissedResult: (Announcement) -> Unit = { lambdaError() }, + val renderResult: (Modifier) -> Unit = { lambdaError() }, +) : AnnouncementService { + private val announcementsToShowFlowValue = MutableStateFlow(initialAnnouncementsToShowFlowValue) + + override suspend fun showAnnouncement(announcement: Announcement) { + showAnnouncementResult(announcement) + } + + override suspend fun onAnnouncementDismissed(announcement: Announcement) { + onAnnouncementDismissedResult(announcement) + } + + override fun announcementsToShowFlow(): Flow> { + return announcementsToShowFlowValue.asStateFlow() + } + + fun emitAnnouncementsToShow(value: List) { + announcementsToShowFlowValue.value = value + } + + @Composable + override fun Render(modifier: Modifier) { + renderResult(modifier) + } +} diff --git a/features/cachecleaner/api/build.gradle.kts b/features/cachecleaner/api/build.gradle.kts new file mode 100644 index 0000000..7051072 --- /dev/null +++ b/features/cachecleaner/api/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.cachecleaner.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(libs.androidx.startup) +} diff --git a/features/cachecleaner/api/src/main/kotlin/io/element/android/features/cachecleaner/api/CacheCleaner.kt b/features/cachecleaner/api/src/main/kotlin/io/element/android/features/cachecleaner/api/CacheCleaner.kt new file mode 100644 index 0000000..b5a5396 --- /dev/null +++ b/features/cachecleaner/api/src/main/kotlin/io/element/android/features/cachecleaner/api/CacheCleaner.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.cachecleaner.api + +interface CacheCleaner { + /** + * Clear the cache subdirs holding temporarily decrypted content (such as media and voice messages). + * + * Will fail silently in case of errors while deleting the files. + */ + fun clearCache() +} diff --git a/features/cachecleaner/impl/build.gradle.kts b/features/cachecleaner/impl/build.gradle.kts new file mode 100644 index 0000000..3321e2a --- /dev/null +++ b/features/cachecleaner/impl/build.gradle.kts @@ -0,0 +1,28 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.cachecleaner.impl" +} + +setupDependencyInjection() + +dependencies { + api(projects.features.cachecleaner.api) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + + testCommonDependencies(libs) +} diff --git a/features/cachecleaner/impl/src/main/kotlin/io/element/android/features/cachecleaner/impl/CacheCleanerBindings.kt b/features/cachecleaner/impl/src/main/kotlin/io/element/android/features/cachecleaner/impl/CacheCleanerBindings.kt new file mode 100644 index 0000000..2137b7b --- /dev/null +++ b/features/cachecleaner/impl/src/main/kotlin/io/element/android/features/cachecleaner/impl/CacheCleanerBindings.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.cachecleaner.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.cachecleaner.api.CacheCleaner + +@ContributesTo(AppScope::class) +interface CacheCleanerBindings { + fun cacheCleaner(): CacheCleaner +} diff --git a/features/cachecleaner/impl/src/main/kotlin/io/element/android/features/cachecleaner/impl/DefaultCacheCleaner.kt b/features/cachecleaner/impl/src/main/kotlin/io/element/android/features/cachecleaner/impl/DefaultCacheCleaner.kt new file mode 100644 index 0000000..4435141 --- /dev/null +++ b/features/cachecleaner/impl/src/main/kotlin/io/element/android/features/cachecleaner/impl/DefaultCacheCleaner.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.cachecleaner.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.cachecleaner.api.CacheCleaner +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.CacheDirectory +import io.element.android.libraries.di.annotations.AppCoroutineScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber +import java.io.File + +/** + * Default implementation of [CacheCleaner]. + */ +@ContributesBinding(AppScope::class) +class DefaultCacheCleaner( + @AppCoroutineScope + private val coroutineScope: CoroutineScope, + private val dispatchers: CoroutineDispatchers, + @CacheDirectory private val cacheDir: File, +) : CacheCleaner { + companion object { + val SUBDIRS_TO_CLEANUP = listOf("temp/media", "temp/voice") + } + + override fun clearCache() { + coroutineScope.launch(dispatchers.io) { + runCatchingExceptions { + SUBDIRS_TO_CLEANUP.forEach { + File(cacheDir.path, it).apply { + if (exists()) { + if (!deleteRecursively()) error("Failed to delete recursively cache directory $this") + } + if (!mkdirs()) error("Failed to create cache directory $this") + } + } + }.onFailure { + Timber.e(it, "Failed to clear cache") + } + } + } +} diff --git a/features/cachecleaner/impl/src/test/kotlin/io/element/android/features/cachecleaner/impl/DefaultCacheCleanerTest.kt b/features/cachecleaner/impl/src/test/kotlin/io/element/android/features/cachecleaner/impl/DefaultCacheCleanerTest.kt new file mode 100644 index 0000000..6094f1e --- /dev/null +++ b/features/cachecleaner/impl/src/test/kotlin/io/element/android/features/cachecleaner/impl/DefaultCacheCleanerTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.cachecleaner.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class DefaultCacheCleanerTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `calling clearCache actually removes file in the SUBDIRS_TO_CLEANUP list`() = runTest { + // Create temp subdirs and fill with 2 files each + DefaultCacheCleaner.SUBDIRS_TO_CLEANUP.forEach { + File(temporaryFolder.root, it).apply { + mkdirs() + File(this, "temp1").createNewFile() + File(this, "temp2").createNewFile() + } + } + + // Clear cache + aCacheCleaner().clearCache() + + // Check the files are gone but the sub dirs are not. + DefaultCacheCleaner.SUBDIRS_TO_CLEANUP.forEach { + File(temporaryFolder.root, it).apply { + assertThat(exists()).isTrue() + assertThat(isDirectory).isTrue() + assertThat(listFiles()).isEmpty() + } + } + } + + @Test + fun `clear cache fails silently`() = runTest { + // Set cache dir as unreadable, unwritable and unexecutable so that the deletion fails. + check(temporaryFolder.root.setReadable(false)) + check(temporaryFolder.root.setWritable(false)) + check(temporaryFolder.root.setExecutable(false)) + + aCacheCleaner().clearCache() + } + + private fun TestScope.aCacheCleaner() = DefaultCacheCleaner( + coroutineScope = this, + dispatchers = this.testCoroutineDispatchers(true), + cacheDir = temporaryFolder.root, + ) +} diff --git a/features/call/api/build.gradle.kts b/features/call/api/build.gradle.kts new file mode 100644 index 0000000..1480a31 --- /dev/null +++ b/features/call/api/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.call.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.core) + implementation(projects.libraries.matrix.api) +} diff --git a/features/call/api/src/main/kotlin/io/element/android/features/call/api/CallType.kt b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CallType.kt new file mode 100644 index 0000000..5beb9f7 --- /dev/null +++ b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CallType.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.api + +import android.os.Parcelable +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.parcelize.Parcelize + +sealed interface CallType : NodeInputs, Parcelable { + @Parcelize + data class ExternalUrl(val url: String) : CallType { + override fun toString(): String { + return "ExternalUrl" + } + } + + @Parcelize + data class RoomCall( + val sessionId: SessionId, + val roomId: RoomId, + ) : CallType { + override fun toString(): String { + return "RoomCall(sessionId=$sessionId, roomId=$roomId)" + } + } +} diff --git a/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCall.kt b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCall.kt new file mode 100644 index 0000000..a6932a1 --- /dev/null +++ b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCall.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.api + +import io.element.android.libraries.matrix.api.core.RoomId + +/** + * Value for the local current call. + */ +sealed interface CurrentCall { + data object None : CurrentCall + + data class RoomCall( + val roomId: RoomId, + ) : CurrentCall + + data class ExternalUrl( + val url: String, + ) : CurrentCall +} diff --git a/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallService.kt b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallService.kt new file mode 100644 index 0000000..2572059 --- /dev/null +++ b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallService.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.api + +import kotlinx.coroutines.flow.StateFlow + +interface CurrentCallService { + /** + * The current call state flow, which will be updated when the active call changes. + * This value reflect the local state of the call. It is not updated if the user answers + * a call from another session. + */ + val currentCall: StateFlow +} diff --git a/features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt b/features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt new file mode 100644 index 0000000..caa557f --- /dev/null +++ b/features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.api + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId + +/** + * Entry point for the call feature. + */ +interface ElementCallEntryPoint { + /** + * Start a call of the given type. + * @param callType The type of call to start. + */ + fun startCall(callType: CallType) + + /** + * Handle an incoming call. + * @param callType The type of call. + * @param eventId The event id of the event that started the call. + * @param senderId The user id of the sender of the event that started the call. + * @param roomName The name of the room the call is in. + * @param senderName The name of the sender of the event that started the call. + * @param avatarUrl The avatar url of the room or DM. + * @param timestamp The timestamp of the event that started the call. + * @param expirationTimestamp The timestamp at which the call should stop ringing. + * @param notificationChannelId The id of the notification channel to use for the call notification. + * @param textContent The text content of the notification. If null the default content from the system will be used. + */ + suspend fun handleIncomingCall( + callType: CallType.RoomCall, + eventId: EventId, + senderId: UserId, + roomName: String?, + senderName: String?, + avatarUrl: String?, + timestamp: Long, + expirationTimestamp: Long, + notificationChannelId: String, + textContent: String?, + ) +} diff --git a/features/call/impl/build.gradle.kts b/features/call/impl/build.gradle.kts new file mode 100644 index 0000000..e77c09e --- /dev/null +++ b/features/call/impl/build.gradle.kts @@ -0,0 +1,103 @@ +import extension.buildConfigFieldStr +import extension.readLocalProperty +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "io.element.android.features.call.impl" + + buildFeatures { + buildConfig = true + } + + testOptions { + unitTests.isIncludeAndroidResources = true + } + + defaultConfig { + buildConfigFieldStr( + name = "SENTRY_DSN", + value = System.getenv("ELEMENT_CALL_SENTRY_DSN") + ?: readLocalProperty("features.call.sentry.dsn") + ?: "" + ) + buildConfigFieldStr( + name = "POSTHOG_USER_ID", + value = System.getenv("ELEMENT_CALL_POSTHOG_USER_ID") + ?: readLocalProperty("features.call.posthog.userid") + ?: "" + ) + buildConfigFieldStr( + name = "POSTHOG_API_HOST", + value = System.getenv("ELEMENT_CALL_POSTHOG_API_HOST") + ?: readLocalProperty("features.call.posthog.api.host") + ?: "" + ) + buildConfigFieldStr( + name = "POSTHOG_API_KEY", + value = System.getenv("ELEMENT_CALL_POSTHOG_API_KEY") + ?: readLocalProperty("features.call.posthog.api.key") + ?: "" + ) + buildConfigFieldStr( + name = "RAGESHAKE_URL", + value = System.getenv("ELEMENT_CALL_RAGESHAKE_URL") + ?: readLocalProperty("features.call.regeshake.url") + ?: "" + ) + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.appconfig) + implementation(projects.features.enterprise.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.audio.api) + implementation(projects.libraries.core) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.featureflag.api) + implementation(projects.libraries.matrix.impl) + implementation(projects.libraries.matrixmedia.api) + implementation(projects.libraries.network) + implementation(projects.libraries.preferences.api) + implementation(projects.libraries.push.api) + implementation(projects.libraries.uiStrings) + implementation(projects.services.analytics.api) + implementation(projects.services.appnavstate.api) + implementation(projects.services.toolbox.api) + implementation(libs.androidx.webkit) + implementation(libs.coil.compose) + implementation(libs.network.retrofit) + implementation(libs.serialization.json) + implementation(libs.element.call.embedded) + api(projects.features.call.api) + + testCommonDependencies(libs, true) + testImplementation(projects.features.call.test) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.matrixmedia.test) + testImplementation(projects.libraries.push.test) + testImplementation(projects.services.analytics.test) + testImplementation(projects.services.appnavstate.impl) + testImplementation(projects.services.appnavstate.test) + testImplementation(projects.services.toolbox.test) +} diff --git a/features/call/impl/src/main/AndroidManifest.xml b/features/call/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..daf1a91 --- /dev/null +++ b/features/call/impl/src/main/AndroidManifest.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt new file mode 100644 index 0000000..9ed479f --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl + +import android.content.Context +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.call.api.CallType +import io.element.android.features.call.api.ElementCallEntryPoint +import io.element.android.features.call.impl.notifications.CallNotificationData +import io.element.android.features.call.impl.utils.ActiveCallManager +import io.element.android.features.call.impl.utils.IntentProvider +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId + +@ContributesBinding(AppScope::class) +class DefaultElementCallEntryPoint( + @ApplicationContext private val context: Context, + private val activeCallManager: ActiveCallManager, +) : ElementCallEntryPoint { + companion object { + const val EXTRA_CALL_TYPE = "EXTRA_CALL_TYPE" + const val REQUEST_CODE = 2255 + } + + override fun startCall(callType: CallType) { + context.startActivity(IntentProvider.createIntent(context, callType)) + } + + override suspend fun handleIncomingCall( + callType: CallType.RoomCall, + eventId: EventId, + senderId: UserId, + roomName: String?, + senderName: String?, + avatarUrl: String?, + timestamp: Long, + expirationTimestamp: Long, + notificationChannelId: String, + textContent: String?, + ) { + val incomingCallNotificationData = CallNotificationData( + sessionId = callType.sessionId, + roomId = callType.roomId, + eventId = eventId, + senderId = senderId, + roomName = roomName, + senderName = senderName, + avatarUrl = avatarUrl, + timestamp = timestamp, + expirationTimestamp = expirationTimestamp, + notificationChannelId = notificationChannelId, + textContent = textContent, + ) + activeCallManager.registerIncomingCall(notificationData = incomingCallNotificationData) + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/data/WidgetMessage.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/data/WidgetMessage.kt new file mode 100644 index 0000000..421ac9d --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/data/WidgetMessage.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class WidgetMessage( + @SerialName("api") val direction: Direction, + @SerialName("widgetId") val widgetId: String, + @SerialName("requestId") val requestId: String, + @SerialName("action") val action: Action, + @SerialName("data") val data: JsonElement? = null, +) { + @Serializable + enum class Direction { + @SerialName("fromWidget") + FromWidget, + + @SerialName("toWidget") + ToWidget + } + + @Serializable + enum class Action { + @SerialName("io.element.join") + Join, + + @SerialName("im.vector.hangup") + HangUp, + + @SerialName("io.element.close") + Close, + + @SerialName("send_event") + SendEvent, + + @SerialName("content_loaded") + ContentLoaded, + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/di/CallBindings.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/di/CallBindings.kt new file mode 100644 index 0000000..66efe82 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/di/CallBindings.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver +import io.element.android.features.call.impl.ui.ElementCallActivity +import io.element.android.features.call.impl.ui.IncomingCallActivity + +@ContributesTo(AppScope::class) +interface CallBindings { + fun inject(callActivity: ElementCallActivity) + fun inject(callActivity: IncomingCallActivity) + fun inject(declineCallBroadcastReceiver: DeclineCallBroadcastReceiver) +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/CallNotificationData.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/CallNotificationData.kt new file mode 100644 index 0000000..dcc434e --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/CallNotificationData.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.notifications + +import android.os.Parcelable +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.UserId +import kotlinx.parcelize.Parcelize + +@Parcelize +data class CallNotificationData( + val sessionId: SessionId, + val roomId: RoomId, + val eventId: EventId, + val senderId: UserId, + val roomName: String?, + val senderName: String?, + val avatarUrl: String?, + val notificationChannelId: String, + val timestamp: Long, + val textContent: String?, + // Expiration timestamp in millis since epoch + val expirationTimestamp: Long, +) : Parcelable diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt new file mode 100644 index 0000000..e7d270e --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +package io.element.android.features.call.impl.notifications + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.media.AudioManager +import android.provider.Settings +import androidx.core.app.NotificationCompat +import androidx.core.app.PendingIntentCompat +import androidx.core.app.Person +import dev.zacsweers.metro.Inject +import io.element.android.appconfig.ElementCallConfig +import io.element.android.features.call.api.CallType +import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver +import io.element.android.features.call.impl.ui.IncomingCallActivity +import io.element.android.features.call.impl.utils.IntentProvider +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.CommonDrawables +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.MatrixClientProvider +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.UserId +import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader +import kotlin.time.Duration.Companion.seconds + +/** + * Creates a notification for a ringing call. + */ +@Inject +class RingingCallNotificationCreator( + @ApplicationContext private val context: Context, + private val matrixClientProvider: MatrixClientProvider, + private val imageLoaderHolder: ImageLoaderHolder, + private val notificationBitmapLoader: NotificationBitmapLoader, +) { + companion object { + /** + * Request code for the decline action. + */ + const val DECLINE_REQUEST_CODE = 1 + + /** + * Request code for the full screen intent. + */ + const val FULL_SCREEN_INTENT_REQUEST_CODE = 2 + } + + suspend fun createNotification( + sessionId: SessionId, + roomId: RoomId, + eventId: EventId, + senderId: UserId, + roomName: String?, + senderDisplayName: String, + roomAvatarUrl: String?, + notificationChannelId: String, + timestamp: Long, + expirationTimestamp: Long, + textContent: String?, + ): Notification? { + val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null + val imageLoader = imageLoaderHolder.get(matrixClient) + val userIcon = notificationBitmapLoader.getUserIcon( + avatarData = AvatarData( + id = roomId.value, + name = roomName, + url = roomAvatarUrl, + size = AvatarSize.RoomDetailsHeader, + ), + imageLoader = imageLoader, + ) + + val caller = Person.Builder() + .setName(senderDisplayName) + .setIcon(userIcon) + .setImportant(true) + .build() + + val answerIntent = IntentProvider.getPendingIntent(context, CallType.RoomCall(sessionId, roomId)) + val notificationData = CallNotificationData( + sessionId = sessionId, + roomId = roomId, + eventId = eventId, + senderId = senderId, + roomName = roomName, + senderName = senderDisplayName, + avatarUrl = roomAvatarUrl, + notificationChannelId = notificationChannelId, + timestamp = timestamp, + textContent = textContent, + expirationTimestamp = expirationTimestamp, + ) + + val declineIntent = PendingIntentCompat.getBroadcast( + context, + DECLINE_REQUEST_CODE, + Intent(context, DeclineCallBroadcastReceiver::class.java).apply { + putExtra(DeclineCallBroadcastReceiver.EXTRA_NOTIFICATION_DATA, notificationData) + }, + PendingIntent.FLAG_CANCEL_CURRENT, + false, + )!! + + val fullScreenIntent = PendingIntentCompat.getActivity( + context, + FULL_SCREEN_INTENT_REQUEST_CODE, + Intent(context, IncomingCallActivity::class.java).apply { + putExtra(IncomingCallActivity.EXTRA_NOTIFICATION_DATA, notificationData) + }, + PendingIntent.FLAG_CANCEL_CURRENT, + false + ) + + return NotificationCompat.Builder(context, notificationChannelId) + .setSmallIcon(CommonDrawables.ic_notification) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setStyle(NotificationCompat.CallStyle.forIncomingCall(caller, declineIntent, answerIntent).setIsVideo(true)) + .addPerson(caller) + .setAutoCancel(true) + .setWhen(timestamp) + .setOngoing(true) + .setShowWhen(false) + // If textContent is null, the content text is set by the style (will be "Incoming call") + .setContentText(textContent) + .setSound(Settings.System.DEFAULT_RINGTONE_URI, AudioManager.STREAM_RING) + .setTimeoutAfter(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds.inWholeMilliseconds) + .setContentIntent(answerIntent) + .setDeleteIntent(declineIntent) + .setFullScreenIntent(fullScreenIntent, true) + .build() + .apply { + flags = flags.or(Notification.FLAG_INSISTENT) + } + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvents.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvents.kt new file mode 100644 index 0000000..9522d44 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvents.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.pip + +import io.element.android.features.call.impl.utils.PipController + +sealed interface PictureInPictureEvents { + data class SetPipController(val pipController: PipController) : PictureInPictureEvents + data object EnterPictureInPicture : PictureInPictureEvents + data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvents +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt new file mode 100644 index 0000000..5125b46 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.pip + +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 dev.zacsweers.metro.Inject +import io.element.android.features.call.impl.utils.PipController +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.log.logger.LoggerTag +import kotlinx.coroutines.launch +import timber.log.Timber + +private val loggerTag = LoggerTag("PiP") + +@Inject +class PictureInPicturePresenter( + pipSupportProvider: PipSupportProvider, +) : Presenter { + private val isPipSupported = pipSupportProvider.isPipSupported() + private var pipView: PipView? = null + + @Composable + override fun present(): PictureInPictureState { + val coroutineScope = rememberCoroutineScope() + var isInPictureInPicture by remember { mutableStateOf(false) } + var pipController by remember { mutableStateOf(null) } + + fun handleEvent(event: PictureInPictureEvents) { + when (event) { + is PictureInPictureEvents.SetPipController -> { + pipController = event.pipController + } + PictureInPictureEvents.EnterPictureInPicture -> { + coroutineScope.launch { + switchToPip(pipController) + } + } + is PictureInPictureEvents.OnPictureInPictureModeChanged -> { + Timber.tag(loggerTag.value).d("onPictureInPictureModeChanged: ${event.isInPip}") + isInPictureInPicture = event.isInPip + if (event.isInPip) { + pipController?.enterPip() + } else { + pipController?.exitPip() + } + } + } + } + + return PictureInPictureState( + supportPip = isPipSupported, + isInPictureInPicture = isInPictureInPicture, + eventSink = ::handleEvent, + ) + } + + fun setPipView(pipView: PipView?) { + if (isPipSupported) { + Timber.tag(loggerTag.value).d("Setting PiP params") + this.pipView = pipView + pipView?.setPipParams() + } else { + Timber.tag(loggerTag.value).d("setPipView: PiP is not supported") + } + } + + /** + * Enters Picture-in-Picture mode, if allowed by Element Call. + */ + private suspend fun switchToPip(pipController: PipController?) { + if (isPipSupported) { + if (pipController == null) { + Timber.tag(loggerTag.value).w("webPipApi is not available") + } + if (pipController == null || pipController.canEnterPip()) { + Timber.tag(loggerTag.value).d("Switch to PiP mode") + pipView?.enterPipMode() + ?.also { Timber.tag(loggerTag.value).d("Switch to PiP mode result: $it") } + } else { + Timber.tag(loggerTag.value).w("Cannot enter PiP mode, hangup the call") + pipView?.hangUp() + } + } + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureState.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureState.kt new file mode 100644 index 0000000..b1fef4f --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureState.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.pip + +data class PictureInPictureState( + val supportPip: Boolean, + val isInPictureInPicture: Boolean, + val eventSink: (PictureInPictureEvents) -> Unit, +) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureStateProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureStateProvider.kt new file mode 100644 index 0000000..6324820 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureStateProvider.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.pip + +fun aPictureInPictureState( + supportPip: Boolean = false, + isInPictureInPicture: Boolean = false, + eventSink: (PictureInPictureEvents) -> Unit = {}, +): PictureInPictureState { + return PictureInPictureState( + supportPip = supportPip, + isInPictureInPicture = isInPictureInPicture, + eventSink = eventSink, + ) +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipSupportProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipSupportProvider.kt new file mode 100644 index 0000000..54109f0 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipSupportProvider.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.pip + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.di.annotations.ApplicationContext + +interface PipSupportProvider { + @ChecksSdkIntAtLeast(Build.VERSION_CODES.O) + fun isPipSupported(): Boolean +} + +@ContributesBinding(AppScope::class) +class DefaultPipSupportProvider( + @ApplicationContext private val context: Context, +) : PipSupportProvider { + override fun isPipSupported(): Boolean { + val isSupportedByTheOs = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + context.packageManager?.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE).orFalse() + return isSupportedByTheOs + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt new file mode 100644 index 0000000..90f74a3 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.pip + +interface PipView { + fun setPipParams() + fun enterPipMode(): Boolean + fun hangUp() +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt new file mode 100644 index 0000000..d2cbb01 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.content.IntentCompat +import dev.zacsweers.metro.Inject +import io.element.android.features.call.api.CallType +import io.element.android.features.call.impl.di.CallBindings +import io.element.android.features.call.impl.notifications.CallNotificationData +import io.element.android.features.call.impl.utils.ActiveCallManager +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.di.annotations.AppCoroutineScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Broadcast receiver to decline the incoming call. + */ +class DeclineCallBroadcastReceiver : BroadcastReceiver() { + companion object { + const val EXTRA_NOTIFICATION_DATA = "EXTRA_NOTIFICATION_DATA" + } + @Inject + lateinit var activeCallManager: ActiveCallManager + + @AppCoroutineScope + @Inject lateinit var appCoroutineScope: CoroutineScope + + override fun onReceive(context: Context, intent: Intent?) { + val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) } + ?: return + context.bindings().inject(this) + appCoroutineScope.launch { + activeCallManager.hungUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) + } + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/services/CallForegroundService.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/services/CallForegroundService.kt new file mode 100644 index 0000000..89c9fdb --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/services/CallForegroundService.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.services + +import android.Manifest +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import io.element.android.features.call.impl.R +import io.element.android.features.call.impl.ui.ElementCallActivity +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.designsystem.utils.CommonDrawables +import io.element.android.libraries.push.api.notifications.ForegroundServiceType +import io.element.android.libraries.push.api.notifications.NotificationIdProvider +import timber.log.Timber + +/** + * A foreground service that shows a notification for an ongoing call while the UI is in background. + */ +class CallForegroundService : Service() { + companion object { + fun start(context: Context) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { + val intent = Intent(context, CallForegroundService::class.java) + ContextCompat.startForegroundService(context, intent) + } else { + Timber.w("Microphone permission is not granted, cannot start the call foreground service") + } + } + + fun stop(context: Context) { + val intent = Intent(context, CallForegroundService::class.java) + context.stopService(intent) + } + } + + private lateinit var notificationManagerCompat: NotificationManagerCompat + + override fun onCreate() { + super.onCreate() + + notificationManagerCompat = NotificationManagerCompat.from(this) + + val foregroundServiceChannel = NotificationChannelCompat.Builder( + "call_foreground_service_channel", + NotificationManagerCompat.IMPORTANCE_LOW, + ).setName( + getString(R.string.call_foreground_service_channel_title_android).ifEmpty { "Ongoing call" } + ).build() + notificationManagerCompat.createNotificationChannel(foregroundServiceChannel) + + val callActivityIntent = Intent(this, ElementCallActivity::class.java) + val pendingIntent = PendingIntentCompat.getActivity(this, 0, callActivityIntent, 0, false) + val notification = NotificationCompat.Builder(this, foregroundServiceChannel.id) + .setSmallIcon(CommonDrawables.ic_notification) + .setContentTitle(getString(R.string.call_foreground_service_title_android)) + .setContentText(getString(R.string.call_foreground_service_message_android)) + .setContentIntent(pendingIntent) + .build() + val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.ONGOING_CALL) + val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } else { + 0 + } + runCatchingExceptions { + ServiceCompat.startForeground(this, notificationId, notification, serviceType) + }.onFailure { + Timber.e(it, "Failed to start ongoing call foreground service") + } + } + + override fun onDestroy() { + super.onDestroy() + + stopForeground(STOP_FOREGROUND_REMOVE) + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenEvents.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenEvents.kt new file mode 100644 index 0000000..8fbbce8 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenEvents.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.ui + +import io.element.android.features.call.impl.utils.WidgetMessageInterceptor + +sealed interface CallScreenEvents { + data object Hangup : CallScreenEvents + data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvents + data class OnWebViewError(val description: String?) : CallScreenEvents +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt new file mode 100644 index 0000000..7fbcf1b --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.ui + +import androidx.compose.runtime.Composable +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.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.call.api.CallType +import io.element.android.features.call.impl.data.WidgetMessage +import io.element.android.features.call.impl.utils.ActiveCallManager +import io.element.android.features.call.impl.utils.CallWidgetProvider +import io.element.android.features.call.impl.utils.WidgetMessageInterceptor +import io.element.android.features.call.impl.utils.WidgetMessageSerializer +import io.element.android.libraries.architecture.AsyncData +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.di.annotations.AppCoroutineScope +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver +import io.element.android.libraries.network.useragent.UserAgentProvider +import io.element.android.services.analytics.api.ScreenTracker +import io.element.android.services.appnavstate.api.AppForegroundStateService +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.UUID +import kotlin.time.Duration.Companion.seconds + +@AssistedInject +class CallScreenPresenter( + @Assisted private val callType: CallType, + @Assisted private val navigator: CallScreenNavigator, + private val callWidgetProvider: CallWidgetProvider, + userAgentProvider: UserAgentProvider, + private val clock: SystemClock, + private val dispatchers: CoroutineDispatchers, + private val matrixClientsProvider: MatrixClientProvider, + private val screenTracker: ScreenTracker, + private val activeCallManager: ActiveCallManager, + private val languageTagProvider: LanguageTagProvider, + private val appForegroundStateService: AppForegroundStateService, + @AppCoroutineScope + private val appCoroutineScope: CoroutineScope, + private val widgetMessageSerializer: WidgetMessageSerializer, +) : Presenter { + @AssistedFactory + interface Factory { + fun create(callType: CallType, navigator: CallScreenNavigator): CallScreenPresenter + } + + private val isInWidgetMode = callType is CallType.RoomCall + private val userAgent = userAgentProvider.provide() + + @Composable + override fun present(): CallScreenState { + val coroutineScope = rememberCoroutineScope() + val urlState = remember { mutableStateOf>(AsyncData.Uninitialized) } + val callWidgetDriver = remember { mutableStateOf(null) } + val messageInterceptor = remember { mutableStateOf(null) } + var isWidgetLoaded by rememberSaveable { mutableStateOf(false) } + var ignoreWebViewError by rememberSaveable { mutableStateOf(false) } + var webViewError by remember { mutableStateOf(null) } + val languageTag = languageTagProvider.provideLanguageTag() + val theme = if (ElementTheme.isLightTheme) "light" else "dark" + + DisposableEffect(Unit) { + coroutineScope.launch { + // Sets the call as joined + activeCallManager.joinedCall(callType) + fetchRoomCallUrl( + inputs = callType, + urlState = urlState, + callWidgetDriver = callWidgetDriver, + languageTag = languageTag, + theme = theme, + ) + } + onDispose { + appCoroutineScope.launch { activeCallManager.hungUpCall(callType) } + } + } + + when (callType) { + is CallType.ExternalUrl -> { + // No analytics yet for external calls + } + is CallType.RoomCall -> { + screenTracker.TrackScreen(screen = MobileScreen.ScreenName.RoomCall) + } + } + + HandleMatrixClientSyncState() + + callWidgetDriver.value?.let { driver -> + LaunchedEffect(Unit) { + driver.incomingMessages + .onEach { + // Relay message to the WebView + messageInterceptor.value?.sendMessage(it) + } + .launchIn(this) + + driver.run() + } + } + + messageInterceptor.value?.let { interceptor -> + LaunchedEffect(Unit) { + interceptor.interceptedMessages + .onEach { + // We are receiving messages from the WebView, consider that the application is loaded + ignoreWebViewError = true + // Relay message to Widget Driver + callWidgetDriver.value?.send(it) + + val parsedMessage = parseMessage(it) + if (parsedMessage?.direction == WidgetMessage.Direction.FromWidget) { + if (parsedMessage.action == WidgetMessage.Action.Close) { + close(callWidgetDriver.value, navigator) + } else if (parsedMessage.action == WidgetMessage.Action.ContentLoaded) { + isWidgetLoaded = true + } + } + } + .launchIn(this) + } + + LaunchedEffect(Unit) { + // Wait for the call to be joined, if it takes too long, we display an error + delay(10.seconds) + + if (!isWidgetLoaded) { + Timber.w("The call took too long to load. Displaying an error before exiting.") + + // This will display a simple 'Sorry, an error occurred' dialog and force the user to exit the call + webViewError = "" + } + } + } + + fun handleEvent(event: CallScreenEvents) { + when (event) { + is CallScreenEvents.Hangup -> { + val widgetId = callWidgetDriver.value?.id + val interceptor = messageInterceptor.value + if (widgetId != null && interceptor != null && isWidgetLoaded) { + // If the call was joined, we need to hang up first. Then the UI will be dismissed automatically. + sendHangupMessage(widgetId, interceptor) + isWidgetLoaded = false + + coroutineScope.launch { + // Wait for a couple of seconds to receive the hangup message + // If we don't get it in time, we close the screen anyway + delay(2.seconds) + close(callWidgetDriver.value, navigator) + } + } else { + coroutineScope.launch { + close(callWidgetDriver.value, navigator) + } + } + } + is CallScreenEvents.SetupMessageChannels -> { + messageInterceptor.value = event.widgetMessageInterceptor + } + is CallScreenEvents.OnWebViewError -> { + if (!ignoreWebViewError) { + webViewError = event.description.orEmpty() + } + // Else ignore the error, give a chance the Element Call to recover by itself. + } + } + } + + return CallScreenState( + urlState = urlState.value, + webViewError = webViewError, + userAgent = userAgent, + isCallActive = isWidgetLoaded, + isInWidgetMode = isInWidgetMode, + eventSink = ::handleEvent, + ) + } + + private suspend fun fetchRoomCallUrl( + inputs: CallType, + urlState: MutableState>, + callWidgetDriver: MutableState, + languageTag: String?, + theme: String?, + ) { + urlState.runCatchingUpdatingState { + when (inputs) { + is CallType.ExternalUrl -> { + inputs.url + } + is CallType.RoomCall -> { + val result = callWidgetProvider.getWidget( + sessionId = inputs.sessionId, + roomId = inputs.roomId, + clientId = UUID.randomUUID().toString(), + languageTag = languageTag, + theme = theme, + ).getOrThrow() + callWidgetDriver.value = result.driver + Timber.d("Call widget driver initialized for sessionId: ${inputs.sessionId}, roomId: ${inputs.roomId}") + result.url + } + } + } + } + + @Composable + private fun HandleMatrixClientSyncState() { + val coroutineScope = rememberCoroutineScope() + DisposableEffect(Unit) { + val roomCallType = callType as? CallType.RoomCall ?: return@DisposableEffect onDispose {} + val client = matrixClientsProvider.getOrNull(roomCallType.sessionId) ?: return@DisposableEffect onDispose { + Timber.w("No MatrixClient found for sessionId, can't send call notification: ${roomCallType.sessionId}") + } + coroutineScope.launch { + Timber.d("Observing sync state in-call for sessionId: ${roomCallType.sessionId}") + client.syncService.syncState + .collect { state -> + if (state != SyncState.Running) { + appForegroundStateService.updateIsInCallState(true) + } + } + } + onDispose { + Timber.d("Stopped observing sync state in-call for sessionId: ${roomCallType.sessionId}") + // Make sure we mark the call as ended in the app state + appForegroundStateService.updateIsInCallState(false) + } + } + } + + private fun parseMessage(message: String): WidgetMessage? { + return widgetMessageSerializer.deserialize(message).getOrNull() + } + + private fun sendHangupMessage(widgetId: String, messageInterceptor: WidgetMessageInterceptor) { + val message = WidgetMessage( + direction = WidgetMessage.Direction.ToWidget, + widgetId = widgetId, + requestId = "widgetapi-${clock.epochMillis()}", + action = WidgetMessage.Action.HangUp, + data = null, + ) + messageInterceptor.sendMessage(widgetMessageSerializer.serialize(message)) + } + + private fun CoroutineScope.close(widgetDriver: MatrixWidgetDriver?, navigator: CallScreenNavigator) = launch(dispatchers.io) { + navigator.close() + widgetDriver?.close() + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt new file mode 100644 index 0000000..c07594a --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.ui + +import io.element.android.libraries.architecture.AsyncData + +data class CallScreenState( + val urlState: AsyncData, + val webViewError: String?, + val userAgent: String, + val isCallActive: Boolean, + val isInWidgetMode: Boolean, + val eventSink: (CallScreenEvents) -> Unit, +) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt new file mode 100644 index 0000000..3e72f96 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.ui + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncData + +open class CallScreenStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aCallScreenState(), + aCallScreenState(urlState = AsyncData.Loading()), + aCallScreenState(urlState = AsyncData.Failure(Exception("An error occurred"))), + aCallScreenState(webViewError = "Error details from WebView"), + ) +} + +internal fun aCallScreenState( + urlState: AsyncData = AsyncData.Success("https://call.element.io/some-actual-call?with=parameters"), + webViewError: String? = null, + userAgent: String = "", + isCallActive: Boolean = true, + isInWidgetMode: Boolean = false, + eventSink: (CallScreenEvents) -> Unit = {}, +): CallScreenState { + return CallScreenState( + urlState = urlState, + webViewError = webViewError, + userAgent = userAgent, + isCallActive = isCallActive, + isInWidgetMode = isInWidgetMode, + eventSink = eventSink, + ) +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt new file mode 100644 index 0000000..f8657a9 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt @@ -0,0 +1,277 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.ui + +import android.annotation.SuppressLint +import android.view.ViewGroup +import android.webkit.ConsoleMessage +import android.webkit.JavascriptInterface +import android.webkit.PermissionRequest +import android.webkit.WebChromeClient +import android.webkit.WebView +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +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 androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.viewinterop.AndroidView +import io.element.android.features.call.impl.R +import io.element.android.features.call.impl.pip.PictureInPictureEvents +import io.element.android.features.call.impl.pip.PictureInPictureState +import io.element.android.features.call.impl.pip.aPictureInPictureState +import io.element.android.features.call.impl.utils.InvalidAudioDeviceReason +import io.element.android.features.call.impl.utils.WebViewAudioManager +import io.element.android.features.call.impl.utils.WebViewPipController +import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings +import timber.log.Timber + +typealias RequestPermissionCallback = (Array) -> Unit + +interface CallScreenNavigator { + fun close() +} + +@Composable +internal fun CallScreenView( + state: CallScreenState, + pipState: PictureInPictureState, + onConsoleMessage: (ConsoleMessage) -> Unit, + requestPermissions: (Array, RequestPermissionCallback) -> Unit, + modifier: Modifier = Modifier, +) { + fun handleBack() { + if (pipState.supportPip) { + pipState.eventSink.invoke(PictureInPictureEvents.EnterPictureInPicture) + } else { + state.eventSink(CallScreenEvents.Hangup) + } + } + + Scaffold( + modifier = modifier, + ) { padding -> + BackHandler { + handleBack() + } + if (state.webViewError != null) { + ErrorDialog( + content = buildString { + append(stringResource(CommonStrings.error_unknown)) + state.webViewError.takeIf { it.isNotEmpty() }?.let { append("\n\n").append(it) } + }, + onSubmit = { state.eventSink(CallScreenEvents.Hangup) }, + ) + } else { + var webViewAudioManager by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() + + var invalidAudioDeviceReason by remember { mutableStateOf(null) } + invalidAudioDeviceReason?.let { + InvalidAudioDeviceDialog(invalidAudioDeviceReason = it) { + invalidAudioDeviceReason = null + } + } + + CallWebView( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + .fillMaxSize(), + url = state.urlState, + userAgent = state.userAgent, + onPermissionsRequest = { request -> + val androidPermissions = mapWebkitPermissions(request.resources) + val callback: RequestPermissionCallback = { request.grant(it) } + requestPermissions(androidPermissions.toTypedArray(), callback) + }, + onConsoleMessage = onConsoleMessage, + onCreateWebView = { webView -> + webView.addBackHandler(onBackPressed = ::handleBack) + val interceptor = WebViewWidgetMessageInterceptor( + webView = webView, + onUrlLoaded = { url -> + webView.evaluateJavascript("controls.onBackButtonPressed = () => { backHandler.onBackPressed() }", null) + if (webViewAudioManager?.isInCallMode?.get() == false) { + Timber.d("URL $url is loaded, starting in-call audio mode") + webViewAudioManager?.onCallStarted() + } else { + Timber.d("Can't start in-call audio mode since the app is already in it.") + } + }, + onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) }, + ) + webViewAudioManager = WebViewAudioManager( + webView = webView, + coroutineScope = coroutineScope, + onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it }, + ) + state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor)) + val pipController = WebViewPipController(webView) + pipState.eventSink(PictureInPictureEvents.SetPipController(pipController)) + }, + onDestroyWebView = { + // Reset audio mode + webViewAudioManager?.onCallStopped() + } + ) + when (state.urlState) { + AsyncData.Uninitialized, + is AsyncData.Loading -> + ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait)) + is AsyncData.Failure -> { + Timber.e(state.urlState.error, "WebView failed to load URL: ${state.urlState.error.message}") + ErrorDialog( + content = state.urlState.error.message.orEmpty(), + onSubmit = { state.eventSink(CallScreenEvents.Hangup) }, + ) + } + is AsyncData.Success -> Unit + } + } + } +} + +@Composable +private fun InvalidAudioDeviceDialog( + invalidAudioDeviceReason: InvalidAudioDeviceReason, + onDismiss: () -> Unit, +) { + ErrorDialog( + content = when (invalidAudioDeviceReason) { + InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED -> { + stringResource(R.string.call_invalid_audio_device_bluetooth_devices_disabled) + } + }, + onSubmit = onDismiss, + ) +} + +@Composable +private fun CallWebView( + url: AsyncData, + userAgent: String, + onPermissionsRequest: (PermissionRequest) -> Unit, + onConsoleMessage: (ConsoleMessage) -> Unit, + onCreateWebView: (WebView) -> Unit, + onDestroyWebView: (WebView) -> Unit, + modifier: Modifier = Modifier, +) { + if (LocalInspectionMode.current) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Text("WebView - can't be previewed") + } + } else { + AndroidView( + modifier = modifier, + factory = { context -> + WebView(context).apply { + onCreateWebView(this) + setup( + userAgent = userAgent, + onPermissionsRequested = onPermissionsRequest, + onConsoleMessage = onConsoleMessage, + ) + } + }, + update = { webView -> + if (url is AsyncData.Success && webView.url != url.data) { + webView.loadUrl(url.data) + } + }, + onRelease = { webView -> + onDestroyWebView(webView) + webView.destroy() + } + ) + } +} + +@SuppressLint("SetJavaScriptEnabled") +private fun WebView.setup( + userAgent: String, + onPermissionsRequested: (PermissionRequest) -> Unit, + onConsoleMessage: (ConsoleMessage) -> Unit, +) { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + + with(settings) { + javaScriptEnabled = true + allowContentAccess = true + allowFileAccess = true + domStorageEnabled = true + mediaPlaybackRequiresUserGesture = false + @Suppress("DEPRECATION") + databaseEnabled = true + loadsImagesAutomatically = true + userAgentString = userAgent + } + + webChromeClient = object : WebChromeClient() { + override fun onPermissionRequest(request: PermissionRequest) { + onPermissionsRequested(request) + } + + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + onConsoleMessage(consoleMessage) + return true + } + } +} + +private fun WebView.addBackHandler(onBackPressed: () -> Unit) { + addJavascriptInterface( + object { + @Suppress("unused") + @JavascriptInterface + fun onBackPressed() = onBackPressed() + }, + "backHandler" + ) +} + +@PreviewsDayNight +@Composable +internal fun CallScreenViewPreview( + @PreviewParameter(CallScreenStateProvider::class) state: CallScreenState, +) = ElementPreview { + CallScreenView( + state = state, + pipState = aPictureInPictureState(), + requestPermissions = { _, _ -> }, + onConsoleMessage = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun InvalidAudioDeviceDialogPreview() = ElementPreview { + InvalidAudioDeviceDialog(invalidAudioDeviceReason = InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) {} +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallTypeExtension.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallTypeExtension.kt new file mode 100644 index 0000000..0c18c3e --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallTypeExtension.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.ui + +import io.element.android.features.call.api.CallType +import io.element.android.libraries.matrix.api.core.SessionId + +fun CallType.getSessionId(): SessionId? { + return when (this) { + is CallType.ExternalUrl -> null + is CallType.RoomCall -> sessionId + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt new file mode 100644 index 0000000..bf4f836 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt @@ -0,0 +1,299 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.ui + +import android.Manifest +import android.app.PictureInPictureParams +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.util.Rational +import android.view.WindowManager +import android.webkit.PermissionRequest +import androidx.activity.compose.setContent +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.rememberUpdatedState +import androidx.core.app.PictureInPictureModeChangedInfo +import androidx.core.content.IntentCompat +import androidx.core.util.Consumer +import androidx.lifecycle.Lifecycle +import dev.zacsweers.metro.Inject +import io.element.android.compound.colors.SemanticColorsLightDark +import io.element.android.features.call.api.CallType +import io.element.android.features.call.api.CallType.ExternalUrl +import io.element.android.features.call.impl.DefaultElementCallEntryPoint +import io.element.android.features.call.impl.di.CallBindings +import io.element.android.features.call.impl.pip.PictureInPictureEvents +import io.element.android.features.call.impl.pip.PictureInPicturePresenter +import io.element.android.features.call.impl.pip.PictureInPictureState +import io.element.android.features.call.impl.pip.PipView +import io.element.android.features.call.impl.services.CallForegroundService +import io.element.android.features.call.impl.utils.CallIntentDataParser +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.libraries.androidutils.browser.ConsoleMessageLogger +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.audio.api.AudioFocus +import io.element.android.libraries.audio.api.AudioFocusRequester +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.designsystem.theme.ElementThemeApp +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import timber.log.Timber + +private val loggerTag = LoggerTag("ElementCallActivity") + +class ElementCallActivity : + AppCompatActivity(), + CallScreenNavigator, + PipView { + @Inject lateinit var callIntentDataParser: CallIntentDataParser + @Inject lateinit var presenterFactory: CallScreenPresenter.Factory + @Inject lateinit var appPreferencesStore: AppPreferencesStore + @Inject lateinit var enterpriseService: EnterpriseService + @Inject lateinit var pictureInPicturePresenter: PictureInPicturePresenter + @Inject lateinit var buildMeta: BuildMeta + @Inject lateinit var audioFocus: AudioFocus + @Inject lateinit var consoleMessageLogger: ConsoleMessageLogger + + private lateinit var presenter: Presenter + + private var requestPermissionCallback: RequestPermissionCallback? = null + + private val requestPermissionsLauncher = registerPermissionResultLauncher() + + private val webViewTarget = mutableStateOf(null) + + private var eventSink: ((CallScreenEvents) -> Unit)? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + bindings().inject(this) + + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + } else { + @Suppress("DEPRECATION") + window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED) + } + + setCallType(intent) + // If presenter is not created at this point, it means we have no call to display, the Activity is finishing, so return early + if (!::presenter.isInitialized) { + return + } + + pictureInPicturePresenter.setPipView(this) + + Timber.d("Created ElementCallActivity with call type: ${webViewTarget.value}") + + setContent { + val pipState = pictureInPicturePresenter.present() + ListenToAndroidEvents(pipState) + val colors by remember(webViewTarget.value?.getSessionId()) { + enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.getSessionId()) + }.collectAsState(SemanticColorsLightDark.default) + ElementThemeApp( + appPreferencesStore = appPreferencesStore, + compoundLight = colors.light, + compoundDark = colors.dark, + buildMeta = buildMeta, + ) { + val state = presenter.present() + eventSink = state.eventSink + LaunchedEffect(state.isCallActive, state.isInWidgetMode) { + // Note when not in WidgetMode, isCallActive will never be true, so consider the call is active + if (state.isCallActive || !state.isInWidgetMode) { + setCallIsActive() + } + } + CallScreenView( + state = state, + pipState = pipState, + onConsoleMessage = { + consoleMessageLogger.log("ElementCall", it) + }, + requestPermissions = { permissions, callback -> + requestPermissionCallback = callback + requestPermissionsLauncher.launch(permissions) + } + ) + } + } + } + + private fun setCallIsActive() { + audioFocus.requestAudioFocus( + requester = AudioFocusRequester.ElementCall, + onFocusLost = { + // If the audio focus is lost, we do not stop the call. + Timber.tag(loggerTag.value).w("Audio focus lost") + } + ) + CallForegroundService.start(this) + } + + @Composable + private fun ListenToAndroidEvents(pipState: PictureInPictureState) { + val pipEventSink by rememberUpdatedState(pipState.eventSink) + DisposableEffect(Unit) { + val listener = Runnable { + if (requestPermissionCallback != null) { + Timber.tag(loggerTag.value).w("Ignoring onUserLeaveHint event because user is asked to grant permissions") + } else { + pipEventSink(PictureInPictureEvents.EnterPictureInPicture) + } + } + addOnUserLeaveHintListener(listener) + onDispose { + removeOnUserLeaveHintListener(listener) + } + } + DisposableEffect(Unit) { + val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo -> + pipEventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(isInPictureInPictureMode)) + if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + Timber.tag(loggerTag.value).d("Exiting PiP mode: Hangup the call") + eventSink?.invoke(CallScreenEvents.Hangup) + } + } + addOnPictureInPictureModeChangedListener(onPictureInPictureModeChangedListener) + onDispose { + removeOnPictureInPictureModeChangedListener(onPictureInPictureModeChangedListener) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setCallType(intent) + } + + override fun onDestroy() { + super.onDestroy() + audioFocus.releaseAudioFocus() + CallForegroundService.stop(this) + pictureInPicturePresenter.setPipView(null) + } + + override fun finish() { + // Also remove the task from recents + finishAndRemoveTask() + } + + override fun close() { + finish() + } + + private fun setCallType(intent: Intent?) { + val callType = intent?.let { + IntentCompat.getParcelableExtra(intent, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java) + ?: intent.dataString?.let(::parseUrl)?.let(::ExternalUrl) + } + val currentCallType = webViewTarget.value + if (currentCallType == null) { + if (callType == null) { + Timber.tag(loggerTag.value).d("Re-opened the activity but we have no url to load or a cached one, finish the activity") + finish() + } else { + Timber.tag(loggerTag.value).d("Set the call type and create the presenter") + webViewTarget.value = callType + presenter = presenterFactory.create(callType, this) + } + } else { + if (callType == null) { + Timber.tag(loggerTag.value).d("Coming back from notification, do nothing") + } else if (callType != currentCallType) { + Timber.tag(loggerTag.value).d("User starts another call, restart the Activity") + setIntent(intent) + recreate() + } else { + // Starting the same call again, should not happen, the UI is preventing this. But maybe when using external links. + Timber.tag(loggerTag.value).d("Starting the same call again, do nothing") + } + } + } + + private fun parseUrl(url: String?): String? = callIntentDataParser.parse(url) + + private fun registerPermissionResultLauncher(): ActivityResultLauncher> { + return registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + val callback = requestPermissionCallback ?: return@registerForActivityResult + val permissionsToGrant = mutableListOf() + permissions.forEach { (permission, granted) -> + if (granted) { + val webKitPermission = when (permission) { + Manifest.permission.CAMERA -> PermissionRequest.RESOURCE_VIDEO_CAPTURE + Manifest.permission.RECORD_AUDIO -> PermissionRequest.RESOURCE_AUDIO_CAPTURE + else -> return@forEach + } + permissionsToGrant.add(webKitPermission) + } + } + callback(permissionsToGrant.toTypedArray()) + requestPermissionCallback = null + } + } + + @RequiresApi(Build.VERSION_CODES.O) + override fun setPipParams() { + setPictureInPictureParams(getPictureInPictureParams()) + } + + @RequiresApi(Build.VERSION_CODES.O) + override fun enterPipMode(): Boolean { + return if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + enterPictureInPictureMode(getPictureInPictureParams()) + } else { + false + } + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun getPictureInPictureParams(): PictureInPictureParams { + return PictureInPictureParams.Builder() + // Portrait for calls seems more appropriate + .setAspectRatio(Rational(3, 5)) + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setAutoEnterEnabled(true) + } + } + .build() + } + + override fun hangUp() { + eventSink?.invoke(CallScreenEvents.Hangup) + } +} + +internal fun mapWebkitPermissions(permissions: Array): List { + return permissions.mapNotNull { permission -> + when (permission) { + PermissionRequest.RESOURCE_AUDIO_CAPTURE -> Manifest.permission.RECORD_AUDIO + PermissionRequest.RESOURCE_VIDEO_CAPTURE -> Manifest.permission.CAMERA + else -> null + } + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt new file mode 100644 index 0000000..714360a --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.ui + +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.core.content.IntentCompat +import androidx.lifecycle.lifecycleScope +import dev.zacsweers.metro.Inject +import io.element.android.compound.colors.SemanticColorsLightDark +import io.element.android.features.call.api.CallType +import io.element.android.features.call.api.ElementCallEntryPoint +import io.element.android.features.call.impl.di.CallBindings +import io.element.android.features.call.impl.notifications.CallNotificationData +import io.element.android.features.call.impl.utils.ActiveCallManager +import io.element.android.features.call.impl.utils.CallState +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.designsystem.theme.ElementThemeApp +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +/** + * Activity that's displayed as a full screen intent when an incoming call is received. + */ +class IncomingCallActivity : AppCompatActivity() { + companion object { + /** + * Extra key for the notification data. + */ + const val EXTRA_NOTIFICATION_DATA = "EXTRA_NOTIFICATION_DATA" + } + + @Inject + lateinit var elementCallEntryPoint: ElementCallEntryPoint + + @Inject + lateinit var activeCallManager: ActiveCallManager + + @Inject + lateinit var appPreferencesStore: AppPreferencesStore + + @Inject + lateinit var enterpriseService: EnterpriseService + + @Inject + lateinit var buildMeta: BuildMeta + + @AppCoroutineScope + @Inject lateinit var appCoroutineScope: CoroutineScope + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + bindings().inject(this) + + // Set flags so it can be displayed in the lock screen + @Suppress("DEPRECATION") + window.addFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or + WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON or + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + ) + + val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) } + if (notificationData != null) { + setContent { + val colors by remember { + enterpriseService.semanticColorsFlow(sessionId = notificationData.sessionId) + }.collectAsState(SemanticColorsLightDark.default) + ElementThemeApp( + appPreferencesStore = appPreferencesStore, + compoundLight = colors.light, + compoundDark = colors.dark, + buildMeta = buildMeta, + ) { + IncomingCallScreen( + notificationData = notificationData, + onAnswer = ::onAnswer, + onCancel = ::onCancel, + ) + } + } + } else { + // No data, finish the activity + finish() + return + } + + activeCallManager.activeCall + .filter { it?.callState !is CallState.Ringing } + .onEach { finish() } + .launchIn(lifecycleScope) + } + + private fun onAnswer(notificationData: CallNotificationData) { + elementCallEntryPoint.startCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) + } + + private fun onCancel() { + val activeCall = activeCallManager.activeCall.value ?: return + appCoroutineScope.launch { + activeCallManager.hungUpCall(callType = activeCall.callType) + } + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt new file mode 100644 index 0000000..682c4ce --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallScreen.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.ui + +import androidx.compose.foundation.border +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.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.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.IconButtonDefaults +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.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.call.impl.R +import io.element.android.features.call.impl.notifications.CallNotificationData +import io.element.android.libraries.designsystem.background.OnboardingBackground +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.avatar.AvatarType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.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.UserId +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun IncomingCallScreen( + notificationData: CallNotificationData, + onAnswer: (CallNotificationData) -> Unit, + onCancel: () -> Unit, +) { + OnboardingBackground() + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Bottom + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, top = 124.dp) + .weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Avatar( + avatarData = AvatarData( + id = notificationData.senderId.value, + name = notificationData.senderName, + url = notificationData.avatarUrl, + size = AvatarSize.IncomingCall, + ), + avatarType = AvatarType.User, + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = notificationData.senderName ?: notificationData.senderId.value, + style = ElementTheme.typography.fontHeadingMdBold, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.screen_incoming_call_subtitle_android), + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, bottom = 64.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + ActionButton( + size = 64.dp, + onClick = { onAnswer(notificationData) }, + icon = CompoundIcons.VoiceCallSolid(), + title = stringResource(CommonStrings.action_accept), + backgroundColor = ElementTheme.colors.iconSuccessPrimary, + borderColor = ElementTheme.colors.borderSuccessSubtle + ) + + ActionButton( + size = 64.dp, + onClick = onCancel, + icon = CompoundIcons.EndCall(), + title = stringResource(CommonStrings.action_reject), + backgroundColor = ElementTheme.colors.iconCriticalPrimary, + borderColor = ElementTheme.colors.borderCriticalSubtle + ) + } + } +} + +@Composable +private fun ActionButton( + size: Dp, + onClick: () -> Unit, + icon: ImageVector, + title: String, + backgroundColor: Color, + borderColor: Color, + contentDescription: String? = title, + borderSize: Dp = 1.33.dp, +) { + Column( + modifier = Modifier.width(120.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + FilledIconButton( + modifier = Modifier + .size(size + borderSize) + .border(borderSize, borderColor, CircleShape), + onClick = onClick, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = backgroundColor, + contentColor = Color.White, + ) + ) { + Icon( + modifier = Modifier.size(32.dp), + imageVector = icon, + contentDescription = contentDescription + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = title, + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun IncomingCallScreenPreview() = ElementPreview { + IncomingCallScreen( + notificationData = CallNotificationData( + sessionId = SessionId("@alice:matrix.org"), + roomId = RoomId("!1234:matrix.org"), + eventId = EventId("\$asdadadsad:matrix.org"), + senderId = UserId("@bob:matrix.org"), + roomName = "A room", + senderName = "Bob", + avatarUrl = null, + notificationChannelId = "incoming_call", + timestamp = 0L, + textContent = null, + expirationTimestamp = 1000L, + ), + onAnswer = {}, + onCancel = {}, + ) +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/LanguageTagProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/LanguageTagProvider.kt new file mode 100644 index 0000000..c9abd7b --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/LanguageTagProvider.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalConfiguration +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding + +interface LanguageTagProvider { + @Composable + fun provideLanguageTag(): String? +} + +@ContributesBinding(AppScope::class) +class DefaultLanguageTagProvider : LanguageTagProvider { + @Composable + override fun provideLanguageTag(): String? { + return LocalConfiguration.current.locales.get(0)?.toLanguageTag() + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt new file mode 100644 index 0000000..91fcc25 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt @@ -0,0 +1,400 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.os.PowerManager +import androidx.annotation.VisibleForTesting +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.getSystemService +import coil3.SingletonImageLoader +import coil3.annotation.DelicateCoilApi +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.appconfig.ElementCallConfig +import io.element.android.features.call.api.CallType +import io.element.android.features.call.api.CurrentCall +import io.element.android.features.call.impl.notifications.CallNotificationData +import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.push.api.notifications.ForegroundServiceType +import io.element.android.libraries.push.api.notifications.NotificationIdProvider +import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler +import io.element.android.services.appnavstate.api.AppForegroundStateService +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import kotlin.math.min + +/** + * Manages the active call state. + */ +interface ActiveCallManager { + /** + * The active call state flow, which will be updated when the active call changes. + */ + val activeCall: StateFlow + + /** + * Registers an incoming call if there isn't an existing active call and posts a [CallState.Ringing] notification. + * @param notificationData The data for the incoming call notification. + */ + suspend fun registerIncomingCall(notificationData: CallNotificationData) + + /** + * Called when the active call has been hung up. It will remove any existing UI and the active call. + * @param callType The type of call that the user hung up, either an external url one or a room one. + */ + suspend fun hungUpCall(callType: CallType) + + /** + * Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall]. + * + * @param callType The type of call that the user joined, either an external url one or a room one. + */ + suspend fun joinedCall(callType: CallType) +} + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultActiveCallManager( + @ApplicationContext context: Context, + @AppCoroutineScope + private val coroutineScope: CoroutineScope, + private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler, + private val ringingCallNotificationCreator: RingingCallNotificationCreator, + private val notificationManagerCompat: NotificationManagerCompat, + private val matrixClientProvider: MatrixClientProvider, + private val defaultCurrentCallService: DefaultCurrentCallService, + private val appForegroundStateService: AppForegroundStateService, + private val imageLoaderHolder: ImageLoaderHolder, + private val systemClock: SystemClock, +) : ActiveCallManager { + private val tag = "ActiveCallManager" + private var timedOutCallJob: Job? = null + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal val activeWakeLock: PowerManager.WakeLock? = context.getSystemService() + ?.takeIf { it.isWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK) } + ?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "${context.packageName}:IncomingCallWakeLock") + + override val activeCall = MutableStateFlow(null) + + private val mutex = Mutex() + + init { + observeRingingCall() + observeCurrentCall() + } + + override suspend fun registerIncomingCall(notificationData: CallNotificationData) { + mutex.withLock { + val ringDuration = + min( + notificationData.expirationTimestamp - systemClock.epochMillis(), + ElementCallConfig.RINGING_CALL_DURATION_SECONDS * 1000L + ) + + if (ringDuration < 0) { + // Should already have stopped ringing, ignore. + Timber.tag(tag).d("Received timed-out incoming ringing call for room id: ${notificationData.roomId}, cancel ringing") + return + } + + appForegroundStateService.updateHasRingingCall(true) + Timber.tag(tag).d("Received incoming call for room id: ${notificationData.roomId}, ringDuration(ms): $ringDuration") + if (activeCall.value != null) { + displayMissedCallNotification(notificationData) + Timber.tag(tag).w("Already have an active call, ignoring incoming call: $notificationData") + return + } + activeCall.value = ActiveCall( + callType = CallType.RoomCall( + sessionId = notificationData.sessionId, + roomId = notificationData.roomId, + ), + callState = CallState.Ringing(notificationData), + ) + + timedOutCallJob = coroutineScope.launch { + setUpCoil(notificationData.sessionId) + showIncomingCallNotification(notificationData) + + // Wait for the ringing call to time out + delay(timeMillis = ringDuration) + incomingCallTimedOut(displayMissedCallNotification = true) + } + + // Acquire a wake lock to keep the device awake during the incoming call, so we can process the room info data + if (activeWakeLock?.isHeld == false) { + Timber.tag(tag).d("Acquiring partial wakelock") + activeWakeLock.acquire(ringDuration) + } + } + } + + @OptIn(DelicateCoilApi::class) + private suspend fun setUpCoil(sessionId: SessionId) { + val matrixClient = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return + // Ensure that the image loader is set, else the IncomingCallActivity will not be able to render the caller avatar + SingletonImageLoader.setUnsafe(imageLoaderHolder.get(matrixClient)) + } + + /** + * Called when the incoming call timed out. It will remove the active call and remove any associated UI, adding a 'missed call' notification. + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + suspend fun incomingCallTimedOut(displayMissedCallNotification: Boolean) = mutex.withLock { + Timber.tag(tag).d("Incoming call timed out") + + val previousActiveCall = activeCall.value ?: return + val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return + activeCall.value = null + if (activeWakeLock?.isHeld == true) { + Timber.tag(tag).d("Releasing partial wakelock after timeout") + activeWakeLock.release() + } + + cancelIncomingCallNotification() + + if (displayMissedCallNotification) { + displayMissedCallNotification(notificationData) + } + } + + override suspend fun hungUpCall(callType: CallType) = mutex.withLock { + Timber.tag(tag).d("Hung up call: $callType") + val currentActiveCall = activeCall.value ?: run { + Timber.tag(tag).w("No active call, ignoring hang up") + return + } + if (currentActiveCall.callType != callType) { + Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring") + return + } + if (currentActiveCall.callState is CallState.Ringing) { + // Decline the call + val notificationData = currentActiveCall.callState.notificationData + matrixClientProvider.getOrRestore(notificationData.sessionId).getOrNull() + ?.getRoom(notificationData.roomId) + ?.declineCall(notificationData.eventId) + } + + cancelIncomingCallNotification() + if (activeWakeLock?.isHeld == true) { + Timber.tag(tag).d("Releasing partial wakelock after hang up") + activeWakeLock.release() + } + timedOutCallJob?.cancel() + activeCall.value = null + } + + override suspend fun joinedCall(callType: CallType) = mutex.withLock { + Timber.tag(tag).d("Joined call: $callType") + + cancelIncomingCallNotification() + if (activeWakeLock?.isHeld == true) { + Timber.tag(tag).d("Releasing partial wakelock after joining call") + activeWakeLock.release() + } + timedOutCallJob?.cancel() + + activeCall.value = ActiveCall( + callType = callType, + callState = CallState.InCall, + ) + } + + @SuppressLint("MissingPermission") + private suspend fun showIncomingCallNotification(notificationData: CallNotificationData) { + Timber.tag(tag).d("Displaying ringing call notification") + val notification = ringingCallNotificationCreator.createNotification( + sessionId = notificationData.sessionId, + roomId = notificationData.roomId, + eventId = notificationData.eventId, + senderId = notificationData.senderId, + roomName = notificationData.roomName, + senderDisplayName = notificationData.senderName ?: notificationData.senderId.value, + roomAvatarUrl = notificationData.avatarUrl, + notificationChannelId = notificationData.notificationChannelId, + timestamp = notificationData.timestamp, + textContent = notificationData.textContent, + expirationTimestamp = notificationData.expirationTimestamp, + ) ?: return + runCatchingExceptions { + notificationManagerCompat.notify( + NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL), + notification, + ) + }.onFailure { + Timber.e(it, "Failed to publish notification for incoming call") + } + } + + private fun cancelIncomingCallNotification() { + appForegroundStateService.updateHasRingingCall(false) + Timber.tag(tag).d("Ringing call notification cancelled") + notificationManagerCompat.cancel(NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL)) + } + + private fun displayMissedCallNotification(notificationData: CallNotificationData) { + Timber.tag(tag).d("Displaying missed call notification") + coroutineScope.launch { + onMissedCallNotificationHandler.addMissedCallNotification( + sessionId = notificationData.sessionId, + roomId = notificationData.roomId, + eventId = notificationData.eventId, + ) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun observeRingingCall() { + activeCall + .filterNotNull() + .filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall } + .flatMapLatest { activeCall -> + val callType = activeCall.callType as CallType.RoomCall + val ringingInfo = activeCall.callState as CallState.Ringing + val client = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull() ?: run { + Timber.tag(tag).d("Couldn't find session for incoming call: $activeCall") + return@flatMapLatest flowOf() + } + val room = client.getRoom(callType.roomId) ?: run { + Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall") + return@flatMapLatest flowOf() + } + + Timber.tag(tag).d("Found room for ringing call: ${room.roomId}") + + // If we have declined from another phone we want to stop ringing. + room.subscribeToCallDecline(ringingInfo.notificationData.eventId) + .filter { decliner -> + Timber.tag(tag).d("Call: $activeCall was declined by $decliner") + // only want to listen if the call was declined from another of my sessions, + // (we are ringing for an incoming call in a DM) + decliner == client.sessionId + } + } + .onEach { decliner -> + Timber.tag(tag).d("Call: $activeCall was declined by user from another session") + // Remove the active call and cancel the notification + activeCall.value = null + if (activeWakeLock?.isHeld == true) { + Timber.tag(tag).d("Releasing partial wakelock after call declined from another session") + activeWakeLock.release() + } + cancelIncomingCallNotification() + } + .launchIn(coroutineScope) + // This will observe ringing calls and ensure they're terminated if the room call is cancelled or if the user + // has joined the call from another session. + activeCall + .filterNotNull() + .filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall } + .flatMapLatest { activeCall -> + val callType = activeCall.callType as CallType.RoomCall + // Get a flow of updated `hasRoomCall` and `activeRoomCallParticipants` values for the room + val room = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull()?.getRoom(callType.roomId) ?: run { + Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall") + return@flatMapLatest flowOf() + } + room.roomInfoFlow.map { + Timber.tag(tag).d("Has room call status changed for ringing call: ${it.hasRoomCall}") + it.hasRoomCall to (callType.sessionId in it.activeRoomCallParticipants) + } + } + // We only want to check if the room active call status changes + .distinctUntilChanged() + // Skip the first one, we're not interested in it (if the check below passes, it had to be active anyway) + .drop(1) + .onEach { (roomHasActiveCall, userIsInTheCall) -> + if (!roomHasActiveCall) { + // The call was cancelled + timedOutCallJob?.cancel() + incomingCallTimedOut(displayMissedCallNotification = true) + } else if (userIsInTheCall) { + // The user joined the call from another session + timedOutCallJob?.cancel() + incomingCallTimedOut(displayMissedCallNotification = false) + } + } + .launchIn(coroutineScope) + } + + private fun observeCurrentCall() { + activeCall + .onEach { value -> + if (value == null) { + defaultCurrentCallService.onCallEnded() + } else { + when (value.callState) { + is CallState.Ringing -> { + // Nothing to do + } + is CallState.InCall -> { + when (val callType = value.callType) { + is CallType.ExternalUrl -> defaultCurrentCallService.onCallStarted(CurrentCall.ExternalUrl(callType.url)) + is CallType.RoomCall -> defaultCurrentCallService.onCallStarted(CurrentCall.RoomCall(callType.roomId)) + } + } + } + } + } + .launchIn(coroutineScope) + } +} + +/** + * Represents an active call. + */ +data class ActiveCall( + val callType: CallType, + val callState: CallState, +) + +/** + * Represents the state of an active call. + */ +sealed interface CallState { + /** + * The call is in a ringing state. + * @param notificationData The data for the incoming call notification. + */ + data class Ringing(val notificationData: CallNotificationData) : CallState + + /** + * The call is in an in-call state. + */ + data object InCall : CallState +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallIntentDataParser.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallIntentDataParser.kt new file mode 100644 index 0000000..f5433c1 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallIntentDataParser.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.utils + +import android.net.Uri +import androidx.core.net.toUri +import dev.zacsweers.metro.Inject + +@Inject +class CallIntentDataParser { + private val validHttpSchemes = sequenceOf("https") + private val knownHosts = sequenceOf( + "call.element.io", + ) + + fun parse(data: String?): String? { + val parsedUrl = data?.toUri() ?: return null + val scheme = parsedUrl.scheme + return when { + scheme in validHttpSchemes -> parsedUrl + scheme == "element" && parsedUrl.host == "call" -> { + parsedUrl.getUrlParameter() + } + scheme == "io.element.call" && parsedUrl.host == null -> { + parsedUrl.getUrlParameter() + } + // This should never be possible, but we still need to take into account the possibility + else -> null + } + ?.takeIf { it.host in knownHosts } + ?.withCustomParameters() + } + + private fun Uri.getUrlParameter(): Uri? { + return getQueryParameter("url") + ?.let { urlParameter -> + urlParameter.toUri().takeIf { uri -> + uri.scheme in validHttpSchemes && !uri.host.isNullOrBlank() + } + } + } +} + +/** + * Ensure the uri has the following parameters and value in the fragment: + * - appPrompt=false + * - confineToRoom=true + * to ensure that the rendering will bo correct on the embedded Webview. + */ +private fun Uri.withCustomParameters(): String { + val builder = buildUpon() + // Remove the existing query parameters + builder.clearQuery() + queryParameterNames.forEach { + if (it == APP_PROMPT_PARAMETER || it == CONFINE_TO_ROOM_PARAMETER) return@forEach + builder.appendQueryParameter(it, getQueryParameter(it)) + } + // Remove the existing fragment parameters, and build the new fragment + val currentFragment = fragment ?: "" + // Reset the current fragment + builder.fragment("") + val queryFragmentPosition = currentFragment.lastIndexOf("?") + val newFragment = if (queryFragmentPosition == -1) { + // No existing query, build it. + "$currentFragment?$APP_PROMPT_PARAMETER=false&$CONFINE_TO_ROOM_PARAMETER=true" + } else { + buildString { + append(currentFragment.substring(0, queryFragmentPosition + 1)) + val queryFragment = currentFragment.substring(queryFragmentPosition + 1) + // Replace the existing parameters + val newQueryFragment = queryFragment + .replace("$APP_PROMPT_PARAMETER=true", "$APP_PROMPT_PARAMETER=false") + .replace("$CONFINE_TO_ROOM_PARAMETER=false", "$CONFINE_TO_ROOM_PARAMETER=true") + append(newQueryFragment) + // Ensure the parameters are there + if (!newQueryFragment.contains("$APP_PROMPT_PARAMETER=false")) { + if (newQueryFragment.isNotEmpty()) { + append("&") + } + append("$APP_PROMPT_PARAMETER=false") + } + if (!newQueryFragment.contains("$CONFINE_TO_ROOM_PARAMETER=true")) { + append("&$CONFINE_TO_ROOM_PARAMETER=true") + } + } + } + // We do not want to encode the Fragment part, so append it manually + return builder.build().toString() + "#" + newFragment +} + +private const val APP_PROMPT_PARAMETER = "appPrompt" +private const val CONFINE_TO_ROOM_PARAMETER = "confineToRoom" diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallWidgetProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallWidgetProvider.kt new file mode 100644 index 0000000..6ce73bc --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallWidgetProvider.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.utils + +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.widget.MatrixWidgetDriver + +interface CallWidgetProvider { + suspend fun getWidget( + sessionId: SessionId, + roomId: RoomId, + clientId: String, + languageTag: String?, + theme: String?, + ): Result + + data class GetWidgetResult( + val driver: MatrixWidgetDriver, + val url: String, + ) +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallAnalyticCredentialsProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallAnalyticCredentialsProvider.kt new file mode 100644 index 0000000..6ba075b --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallAnalyticCredentialsProvider.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.utils + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.call.impl.BuildConfig +import io.element.android.libraries.matrix.api.widget.CallAnalyticCredentialsProvider + +@ContributesBinding(AppScope::class) +class DefaultCallAnalyticCredentialsProvider : CallAnalyticCredentialsProvider { + override val posthogUserId: String? = BuildConfig.POSTHOG_USER_ID.takeIf { it.isNotBlank() } + override val posthogApiHost: String? = BuildConfig.POSTHOG_API_HOST.takeIf { it.isNotBlank() } + override val posthogApiKey: String? = BuildConfig.POSTHOG_API_KEY.takeIf { it.isNotBlank() } + override val rageshakeSubmitUrl: String? = BuildConfig.RAGESHAKE_URL.takeIf { it.isNotBlank() } + override val sentryDsn: String? = BuildConfig.SENTRY_DSN.takeIf { it.isNotBlank() } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt new file mode 100644 index 0000000..1728a0c --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.utils + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.MatrixClientProvider +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.room.isDm +import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.services.appnavstate.api.ActiveRoomsHolder +import kotlinx.coroutines.flow.firstOrNull + +private const val EMBEDDED_CALL_WIDGET_BASE_URL = "https://appassets.androidplatform.net/element-call/index.html" + +@ContributesBinding(AppScope::class) +class DefaultCallWidgetProvider( + private val matrixClientsProvider: MatrixClientProvider, + private val appPreferencesStore: AppPreferencesStore, + private val callWidgetSettingsProvider: CallWidgetSettingsProvider, + private val activeRoomsHolder: ActiveRoomsHolder, +) : CallWidgetProvider { + override suspend fun getWidget( + sessionId: SessionId, + roomId: RoomId, + clientId: String, + languageTag: String?, + theme: String?, + ): Result = runCatchingExceptions { + val matrixClient = matrixClientsProvider.getOrRestore(sessionId).getOrThrow() + val room = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId) + ?: matrixClient.getJoinedRoom(roomId) + ?: error("Room not found") + + val customBaseUrl = appPreferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull() + val baseUrl = customBaseUrl ?: EMBEDDED_CALL_WIDGET_BASE_URL + + val roomInfo = room.info() + val isEncrypted = roomInfo.isEncrypted ?: room.getUpdatedIsEncrypted().getOrThrow() + val widgetSettings = callWidgetSettingsProvider.provide( + baseUrl = baseUrl, + encrypted = isEncrypted, + direct = room.isDm(), + hasActiveCall = roomInfo.hasRoomCall, + ) + val callUrl = room.generateWidgetWebViewUrl( + widgetSettings = widgetSettings, + clientId = clientId, + languageTag = languageTag, + theme = theme, + ).getOrThrow() + + val driver = room.getWidgetDriver(widgetSettings).getOrThrow() + + CallWidgetProvider.GetWidgetResult( + driver = driver, + url = callUrl, + ) + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCurrentCallService.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCurrentCallService.kt new file mode 100644 index 0000000..1c8c0b3 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCurrentCallService.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.utils + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.features.call.api.CurrentCall +import io.element.android.features.call.api.CurrentCallService +import kotlinx.coroutines.flow.MutableStateFlow + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultCurrentCallService : CurrentCallService { + override val currentCall = MutableStateFlow(CurrentCall.None) + + fun onCallStarted(call: CurrentCall) { + currentCall.value = call + } + + fun onCallEnded() { + currentCall.value = CurrentCall.None + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/IntentProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/IntentProvider.kt new file mode 100644 index 0000000..0f74ba8 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/IntentProvider.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.utils + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.PendingIntentCompat +import io.element.android.features.call.api.CallType +import io.element.android.features.call.impl.DefaultElementCallEntryPoint +import io.element.android.features.call.impl.ui.ElementCallActivity + +internal object IntentProvider { + fun createIntent(context: Context, callType: CallType): Intent = Intent(context, ElementCallActivity::class.java).apply { + putExtra(DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, callType) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION) + } + + fun getPendingIntent(context: Context, callType: CallType): PendingIntent { + return PendingIntentCompat.getActivity( + context, + DefaultElementCallEntryPoint.REQUEST_CODE, + createIntent(context, callType), + PendingIntent.FLAG_CANCEL_CURRENT, + false + )!! + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt new file mode 100644 index 0000000..b259816 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.utils + +interface PipController { + suspend fun canEnterPip(): Boolean + fun enterPip() + fun exitPip() +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt new file mode 100644 index 0000000..d4811d6 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt @@ -0,0 +1,521 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.utils + +import android.content.Context +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build +import android.os.PowerManager +import android.webkit.JavascriptInterface +import android.webkit.WebView +import androidx.annotation.RequiresApi +import androidx.core.content.getSystemService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.json.Json +import timber.log.Timber +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.time.Duration.Companion.seconds + +/** + * This class manages the audio devices for a WebView. + * + * It listens for audio device changes and updates the WebView with the available devices. + * It also handles the selection of the audio device by the user in the WebView and the default audio device based on the device type. + * + * See also: [Element Call controls docs.](https://github.com/element-hq/element-call/blob/livekit/docs/controls.md#audio-devices) + */ +class WebViewAudioManager( + private val webView: WebView, + private val coroutineScope: CoroutineScope, + private val onInvalidAudioDeviceAdded: (InvalidAudioDeviceReason) -> Unit, +) { + private val json by lazy { + Json { + encodeDefaults = true + explicitNulls = false + } + } + + /** + * Whether to disable bluetooth audio devices. This must be done on Android versions lower than Android 12, + * since the WebView approach breaks when using the legacy Bluetooth audio APIs. + */ + private val disableBluetoothAudioDevices = Build.VERSION.SDK_INT < Build.VERSION_CODES.S + + /** + * This flag indicates whether the WebView audio is enabled or not. By default, it is enabled. + */ + private val isWebViewAudioEnabled = AtomicBoolean(true) + + /** + * The list of device types that are considered as communication devices, sorted by likelihood of it being used for communication. + */ + private val wantedDeviceTypes = listOf( + // Paired bluetooth device with microphone + AudioDeviceInfo.TYPE_BLUETOOTH_SCO, + // USB devices which can play or record audio + AudioDeviceInfo.TYPE_USB_HEADSET, + AudioDeviceInfo.TYPE_USB_DEVICE, + AudioDeviceInfo.TYPE_USB_ACCESSORY, + // Wired audio devices + AudioDeviceInfo.TYPE_WIRED_HEADSET, + AudioDeviceInfo.TYPE_WIRED_HEADPHONES, + // The built-in speaker of the device + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, + // The built-in earpiece of the device + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, + ) + + private val audioManager = webView.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + /** + * This wake lock is used to turn off the screen when the proximity sensor is triggered during a call, + * if the selected audio device is the built-in earpiece. + */ + private val proximitySensorWakeLock by lazy { + webView.context.getSystemService() + ?.takeIf { it.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK) } + ?.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "${webView.context.packageName}:ProximitySensorCallWakeLock") + } + + /** + * Used to ensure that only one coroutine can access the proximity sensor wake lock at a time, preventing re-acquiring or re-releasing it. + */ + private val proximitySensorMutex = Mutex() + + /** + * This listener tracks the current communication device and updates the WebView when it changes. + */ + @get:RequiresApi(Build.VERSION_CODES.S) + private val commsDeviceChangedListener by lazy { + AudioManager.OnCommunicationDeviceChangedListener { device -> + if (device != null && device.id == expectedNewCommunicationDeviceId) { + expectedNewCommunicationDeviceId = null + Timber.d("Audio device changed, type: ${device.type}") + updateSelectedAudioDeviceInWebView(device.id.toString()) + } else if (device != null && device.id != expectedNewCommunicationDeviceId) { + // We were expecting a device change but it didn't happen, so we should retry + val expectedDeviceId = expectedNewCommunicationDeviceId + if (expectedDeviceId != null) { + // Remove the expected id so we only retry once + expectedNewCommunicationDeviceId = null + audioManager.selectAudioDevice(expectedDeviceId.toString()) + } + } else { + Timber.d("Audio device cleared") + expectedNewCommunicationDeviceId = null + audioManager.selectAudioDevice(null) + } + } + } + + /** + * This callback is used to listen for audio device changes coming from the OS. + */ + private val audioDeviceCallback = object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array?) { + val validNewDevices = addedDevices.orEmpty().filter { it.type in wantedDeviceTypes && it.isSink } + if (validNewDevices.isEmpty()) return + + // We need to calculate the available devices ourselves, since calling `listAudioDevices` will return an outdated list + val audioDevices = (listAudioDevices() + validNewDevices).distinctBy { it.id } + setAvailableAudioDevices(audioDevices.map(SerializableAudioDevice::fromAudioDeviceInfo)) + // This should automatically switch to a new device if it has a higher priority than the current one + selectDefaultAudioDevice(audioDevices) + } + + override fun onAudioDevicesRemoved(removedDevices: Array?) { + // Update the available devices + setAvailableAudioDevices() + + // Unless the removed device is the current one, we don't need to do anything else + val removedCurrentDevice = removedDevices.orEmpty().any { it.id == currentDeviceId } + if (!removedCurrentDevice) return + + val previousDevice = previousSelectedDevice + if (previousDevice != null) { + previousSelectedDevice = null + // If we have a previous device, we should select it again + audioManager.selectAudioDevice(previousDevice.id.toString()) + } else { + // If we don't have a previous device, we should select the default one + selectDefaultAudioDevice() + } + } + } + + /** + * The currently used audio device id. + */ + private var currentDeviceId: Int? = null + + /** + * When a new audio device is selected but not yet set as the communication device by the OS, this id is used to check if the device is the expected one. + */ + private var expectedNewCommunicationDeviceId: Int? = null + + /** + * Previously selected device, used to restore the selection when the selected device is removed. + */ + private var previousSelectedDevice: AudioDeviceInfo? = null + + private var hasRegisteredCallbacks = false + + /** + * Marks if the WebView audio is in call mode or not. + */ + val isInCallMode = AtomicBoolean(false) + + init { + // Apparently, registering the javascript interface takes a while, so registering and immediately using it doesn't work + // We register it ahead of time to avoid this issue + registerWebViewDeviceSelectedCallback() + } + + /** + * Call this method when the call starts to enable in-call audio mode. + * + * It'll set the audio mode to [AudioManager.MODE_IN_COMMUNICATION] if possible, register the audio device callback and set the available audio devices. + */ + fun onCallStarted() { + if (!isInCallMode.compareAndSet(false, true)) { + Timber.w("Audio: tried to enable webview in-call audio mode while already in it") + return + } + + Timber.d("Audio: enabling webview in-call audio mode") + + audioManager.mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Set 'voice call' mode so volume keys actually control the call volume + AudioManager.MODE_IN_COMMUNICATION + } else { + // Workaround for Android 12 and lower, otherwise changing the audio device doesn't work + AudioManager.MODE_NORMAL + } + + setWebViewAndroidNativeBridge() + } + + /** + * Call this method when the call stops to disable in-call audio mode. + * + * It's the counterpart of [onCallStarted], and should be called as a pair with it once the call has ended. + */ + fun onCallStopped() { + if (!isInCallMode.compareAndSet(true, false)) { + Timber.w("Audio: tried to disable webview in-call audio mode while already disabled") + return + } + + coroutineScope.launch { + proximitySensorMutex.withLock { + if (proximitySensorWakeLock?.isHeld == true) { + proximitySensorWakeLock?.release() + } + } + } + + audioManager.mode = AudioManager.MODE_NORMAL + + if (!hasRegisteredCallbacks) { + Timber.w("Audio: tried to disable webview in-call audio mode without registering callbacks") + return + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + audioManager.clearCommunicationDevice() + audioManager.removeOnCommunicationDeviceChangedListener(commsDeviceChangedListener) + } + + audioManager.unregisterAudioDeviceCallback(audioDeviceCallback) + } + + /** + * Registers the WebView audio device selected callback. + * + * This should be called when the WebView is created to ensure that the callback is set before any audio device selection is made. + */ + private fun registerWebViewDeviceSelectedCallback() { + val webViewAudioDeviceSelectedCallback = AndroidWebViewAudioBridge( + onAudioDeviceSelected = { selectedDeviceId -> + previousSelectedDevice = listAudioDevices().find { it.id.toString() == selectedDeviceId } + audioManager.selectAudioDevice(selectedDeviceId) + }, + onAudioPlaybackStarted = { + coroutineScope.launch(Dispatchers.Main) { + // Even with the callback, it seems like starting the audio takes a bit on the webview side, + // so we add an extra delay here to make sure it's ready + delay(2.seconds) + + // Calling this ahead of time makes the default audio device to not use the right audio stream + setAvailableAudioDevices() + + // Registering the audio devices changed callback will also set the default audio device + audioManager.registerAudioDeviceCallback(audioDeviceCallback, null) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + audioManager.addOnCommunicationDeviceChangedListener(Executors.newSingleThreadExecutor(), commsDeviceChangedListener) + } + + hasRegisteredCallbacks = true + } + } + ) + Timber.d("Setting androidNativeBridge javascript interface in webview") + webView.addJavascriptInterface(webViewAudioDeviceSelectedCallback, "androidNativeBridge") + } + + /** + * Assigns the callback in the WebView to be called when the user selects an audio device. + * + * It should be called with some delay after [registerWebViewDeviceSelectedCallback]. + */ + private fun setWebViewAndroidNativeBridge() { + Timber.d("Adding callback in controls.onAudioPlaybackStarted") + webView.evaluateJavascript("controls.onAudioPlaybackStarted = () => { androidNativeBridge.onTrackReady(); };", null) + Timber.d("Adding callback in controls.onOutputDeviceSelect") + webView.evaluateJavascript("controls.onOutputDeviceSelect = (id) => { androidNativeBridge.setOutputDevice(id); };", null) + } + + /** + * Returns the list of available audio devices. + * + * On Android 11 ([Build.VERSION_CODES.R]) and lower, it returns the list of output devices as a fallback. + */ + private fun listAudioDevices(): List { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + audioManager.availableCommunicationDevices + } else { + val rawAudioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + rawAudioDevices.filter { it.type in wantedDeviceTypes && it.isSink } + } + } + + /** + * Sets the available audio devices in the WebView. + * + * @param devices The list of audio devices to set. If not provided, it will use the current list of audio devices. + */ + private fun setAvailableAudioDevices( + devices: List = listAudioDevices().map(SerializableAudioDevice::fromAudioDeviceInfo), + ) { + Timber.d("Updating available audio devices") + val deviceList = json.encodeToString(devices) + webView.evaluateJavascript("controls.setAvailableOutputDevices($deviceList);", { + Timber.d("Audio: setAvailableOutputDevices result: $it") + }) + } + + /** + * Selects the default audio device based on the available devices. + * + * @param availableDevices The list of available audio devices to select from. If not provided, it will use the current list of audio devices. + */ + private fun selectDefaultAudioDevice(availableDevices: List = listAudioDevices()) { + val selectedDevice = availableDevices + .minByOrNull { + wantedDeviceTypes.indexOf(it.type).let { index -> + // If the device type is not in the wantedDeviceTypes list, we give it a low priority + if (index == -1) Int.MAX_VALUE else index + } + } + + expectedNewCommunicationDeviceId = selectedDevice?.id + audioManager.selectAudioDevice(selectedDevice) + + selectedDevice?.let { + updateSelectedAudioDeviceInWebView(it.id.toString()) + } ?: run { + Timber.w("Audio: unable to select default audio device") + } + } + + /** + * Updates the WebView's UI to reflect the selected audio device. + * + * @param deviceId The id of the selected audio device. + */ + private fun updateSelectedAudioDeviceInWebView(deviceId: String) { + coroutineScope.launch(Dispatchers.Main) { + webView.evaluateJavascript("controls.setOutputDevice('$deviceId');", null) + } + } + + /** + * Selects the audio device on the OS based on the provided device id. + * + * It will select the device only if it is available in the list of audio devices. + * + * @param device The id of the audio device to select. + */ + private fun AudioManager.selectAudioDevice(device: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val audioDevice = availableCommunicationDevices.find { it.id.toString() == device } + selectAudioDevice(audioDevice) + } else { + val rawAudioDevices = getDevices(AudioManager.GET_DEVICES_OUTPUTS) + val audioDevice = rawAudioDevices.find { it.id.toString() == device } + selectAudioDevice(audioDevice) + } + } + + /** + * Selects the audio device on the OS based on the provided device info. + * + * @param device The info of the audio device to select, or none to clear the selected device. + */ + @Suppress("DEPRECATION") + private fun AudioManager.selectAudioDevice(device: AudioDeviceInfo?) { + currentDeviceId = device?.id + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (device != null) { + Timber.d("Setting communication device: ${device.id} - ${deviceName(device.type, device.productName.toString())}") + setCommunicationDevice(device) + } else { + audioManager.clearCommunicationDevice() + } + } else { + // On Android 11 and lower, we don't have the concept of communication devices + // We have to call the right methods based on the device type + if (device != null) { + if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO && disableBluetoothAudioDevices) { + Timber.w("Bluetooth audio devices are disabled on this Android version") + setAudioEnabled(false) + onInvalidAudioDeviceAdded(InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) + return + } + setAudioEnabled(true) + isSpeakerphoneOn = device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + isBluetoothScoOn = device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO + } else { + isSpeakerphoneOn = false + isBluetoothScoOn = false + } + } + + expectedNewCommunicationDeviceId = null + + coroutineScope.launch { + proximitySensorMutex.withLock { + @Suppress("WakeLock", "WakeLockTimeout") + if (device?.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE && proximitySensorWakeLock?.isHeld == false) { + // If the device is the built-in earpiece, we need to acquire the proximity sensor wake lock + proximitySensorWakeLock?.acquire() + } else if (proximitySensorWakeLock?.isHeld == true) { + // If the device is no longer the earpiece, we need to release the wake lock + proximitySensorWakeLock?.release() + } + } + } + } + + /** + * Sets whether the audio is enabled for Element Call in the WebView. + * It will only perform the change if the audio state has changed. + */ + private fun setAudioEnabled(enabled: Boolean) { + coroutineScope.launch(Dispatchers.Main) { + Timber.d("Setting audio enabled in Element Call: $enabled") + if (isWebViewAudioEnabled.getAndSet(enabled) != enabled) { + webView.evaluateJavascript("controls.setAudioEnabled($enabled);", null) + } + } + } +} + +/** + * This class is used to handle the audio device selection in the WebView. + * It listens for the audio device selection event and calls the callback with the selected device ID. + */ +private class AndroidWebViewAudioBridge( + private val onAudioDeviceSelected: (String) -> Unit, + private val onAudioPlaybackStarted: () -> Unit, +) { + @JavascriptInterface + fun setOutputDevice(id: String) { + Timber.d("Audio device selected in webview, id: $id") + onAudioDeviceSelected(id) + } + + @JavascriptInterface + fun onTrackReady() { + // This method can be used to notify the WebView that the audio track is ready + // It can be used to start playing audio or to update the UI + Timber.d("Audio track is ready") + + onAudioPlaybackStarted() + } +} + +private fun deviceName(type: Int, name: String): String { + // TODO maybe translate these? + val typePart = when (type) { + AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth" + AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB accessory" + AudioDeviceInfo.TYPE_USB_DEVICE -> "USB device" + AudioDeviceInfo.TYPE_USB_HEADSET -> "USB headset" + AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired headset" + AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "Wired headphones" + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Built-in speaker" + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "Built-in earpiece" + else -> "Unknown" + } + return if (isBuiltIn(type)) { + typePart + } else { + "$typePart - $name" + } +} + +private fun isBuiltIn(type: Int): Boolean = when (type) { + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, + AudioDeviceInfo.TYPE_BUILTIN_MIC, + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE -> true + else -> false +} + +enum class InvalidAudioDeviceReason { + BT_AUDIO_DEVICE_DISABLED, +} + +/** + * This class is used to serialize the audio device information to JSON. + */ +@Suppress("unused") +@Serializable +internal data class SerializableAudioDevice( + val id: String, + val name: String, + @Transient val type: Int = 0, + // These have to be part of the constructor for the JSON serializer to pick them up + val isEarpiece: Boolean = type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, + val isSpeaker: Boolean = type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, + val isExternalHeadset: Boolean = type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO, +) { + companion object { + fun fromAudioDeviceInfo(audioDeviceInfo: AudioDeviceInfo): SerializableAudioDevice { + return SerializableAudioDevice( + id = audioDeviceInfo.id.toString(), + name = deviceName(type = audioDeviceInfo.type, name = audioDeviceInfo.productName.toString()), + type = audioDeviceInfo.type, + ) + } + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt new file mode 100644 index 0000000..12f8dfd --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.utils + +import android.webkit.WebView +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +/** + * Documentation about the `controls` command can be found here: + * https://github.com/element-hq/element-call/blob/livekit/docs/controls.md#picture-in-picture + */ +class WebViewPipController( + private val webView: WebView, +) : PipController { + override suspend fun canEnterPip(): Boolean { + return suspendCoroutine { continuation -> + webView.evaluateJavascript("controls.canEnterPip()") { result -> + // Note if the method is not available, it will return "null" + continuation.resume(result == "true" || result == "null") + } + } + } + + override fun enterPip() { + webView.evaluateJavascript("controls.enablePip()", null) + } + + override fun exitPip() { + webView.evaluateJavascript("controls.disablePip()", null) + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt new file mode 100644 index 0000000..f7ab2c5 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.utils + +import android.graphics.Bitmap +import android.net.http.SslError +import android.webkit.JavascriptInterface +import android.webkit.SslErrorHandler +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.core.net.toUri +import androidx.webkit.WebViewAssetLoader +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import io.element.android.features.call.impl.BuildConfig +import kotlinx.coroutines.flow.MutableSharedFlow +import timber.log.Timber + +class WebViewWidgetMessageInterceptor( + private val webView: WebView, + private val onUrlLoaded: (String) -> Unit, + private val onError: (String?) -> Unit, +) : WidgetMessageInterceptor { + companion object { + // We call both the WebMessageListener and the JavascriptInterface objects in JS with this + // 'listenerName' so they can both receive the data from the WebView when + // `${LISTENER_NAME}.postMessage(...)` is called + const val LISTENER_NAME = "elementX" + } + + // It's important to have extra capacity here to make sure we don't drop any messages + override val interceptedMessages = MutableSharedFlow(extraBufferCapacity = 10) + + init { + val assetLoader = WebViewAssetLoader.Builder() + .addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(webView.context)) + .build() + + webView.webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + + // Due to https://github.com/element-hq/element-x-android/issues/4097 + // we need to supply a logging implementation that correctly includes + // objects in log lines. + view.evaluateJavascript( + """ + function logFn(consoleLogFn, ...args) { + consoleLogFn( + args.map( + a => typeof a === "string" ? a : JSON.stringify(a) + ).join(' ') + ); + }; + globalThis.console.debug = logFn.bind(null, console.debug); + globalThis.console.log = logFn.bind(null, console.log); + globalThis.console.info = logFn.bind(null, console.info); + globalThis.console.warn = logFn.bind(null, console.warn); + globalThis.console.error = logFn.bind(null, console.error); + """.trimIndent(), + null + ) + + // We inject this JS code when the page starts loading to attach a message listener to the window. + // This listener will receive both messages: + // - EC widget API -> Element X (message.data.api == "fromWidget") + // - Element X -> EC widget API (message.data.api == "toWidget"), we should ignore these + view.evaluateJavascript( + """ + window.addEventListener('message', function(event) { + let message = {data: event.data, origin: event.origin} + if (message.data.response && message.data.api == "toWidget" + || !message.data.response && message.data.api == "fromWidget") { + let json = JSON.stringify(event.data) + ${"console.log('message sent: ' + json);".takeIf { BuildConfig.DEBUG }} + $LISTENER_NAME.postMessage(json); + } else { + ${"console.log('message received (ignored): ' + JSON.stringify(event.data));".takeIf { BuildConfig.DEBUG }} + } + }); + """.trimIndent(), + null + ) + } + + override fun onPageFinished(view: WebView, url: String) { + onUrlLoaded(url) + } + + override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { + // No network for instance, transmit the error + Timber.e("onReceivedError error: ${error?.errorCode} ${error?.description}") + + // Only propagate the error if it happens while loading the current page + if (view?.url == request?.url.toString()) { + onError(error?.description.toString()) + } + + super.onReceivedError(view, request, error) + } + + override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, errorResponse: WebResourceResponse?) { + Timber.e("onReceivedHttpError error: ${errorResponse?.statusCode} ${errorResponse?.reasonPhrase}") + + // Only propagate the error if it happens while loading the current page + if (view?.url == request?.url.toString()) { + onError(errorResponse?.statusCode.toString()) + } + + super.onReceivedHttpError(view, request, errorResponse) + } + + override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) { + Timber.e("onReceivedSslError error: ${error?.primaryError}") + + // Only propagate the error if it happens while loading the current page + if (view?.url == error?.url.toString()) { + onError(error?.toString()) + } + + super.onReceivedSslError(view, handler, error) + } + + override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest): WebResourceResponse? { + return assetLoader.shouldInterceptRequest(request.url) + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun shouldInterceptRequest(view: WebView?, url: String): WebResourceResponse? { + return assetLoader.shouldInterceptRequest(url.toUri()) + } + } + + // Create a WebMessageListener, which will receive messages from the WebView and reply to them + val webMessageListener = WebViewCompat.WebMessageListener { _, message, _, _, _ -> + onMessageReceived(message.data) + } + + // Use WebMessageListener if supported, otherwise use JavascriptInterface + if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + WebViewCompat.addWebMessageListener( + webView, + LISTENER_NAME, + setOf("*"), + webMessageListener + ) + } else { + webView.addJavascriptInterface(object { + @JavascriptInterface + fun postMessage(json: String?) { + onMessageReceived(json) + } + }, LISTENER_NAME) + } + } + + override fun sendMessage(message: String) { + webView.evaluateJavascript("postMessage($message, '*')", null) + } + + private fun onMessageReceived(json: String?) { + // Here is where we would handle the messages from the WebView, passing them to the Rust SDK + json?.let { interceptedMessages.tryEmit(it) } + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WidgetMessageInterceptor.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WidgetMessageInterceptor.kt new file mode 100644 index 0000000..ea158c5 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WidgetMessageInterceptor.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.utils + +import kotlinx.coroutines.flow.Flow + +interface WidgetMessageInterceptor { + val interceptedMessages: Flow + fun sendMessage(message: String) +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WidgetMessageSerializer.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WidgetMessageSerializer.kt new file mode 100644 index 0000000..9a489c6 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WidgetMessageSerializer.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.utils + +import dev.zacsweers.metro.Inject +import io.element.android.features.call.impl.data.WidgetMessage +import io.element.android.libraries.androidutils.json.JsonProvider +import io.element.android.libraries.core.extensions.runCatchingExceptions + +@Inject +class WidgetMessageSerializer( + private val json: JsonProvider, +) { + fun deserialize(message: String): Result { + return runCatchingExceptions { json().decodeFromString(WidgetMessage.serializer(), message) } + } + + fun serialize(message: WidgetMessage): String { + return json().encodeToString(WidgetMessage.serializer(), message) + } +} diff --git a/features/call/impl/src/main/res/values-be/translations.xml b/features/call/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..ccedff1 --- /dev/null +++ b/features/call/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,7 @@ + + + "Бягучы званок" + "Націсніце, каб вярнуцца да званку" + "☎️ Ідзе званок" + "Уваходны званок Element Call" + diff --git a/features/call/impl/src/main/res/values-cs/translations.xml b/features/call/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..5d458dc --- /dev/null +++ b/features/call/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,8 @@ + + + "Probíhající hovor" + "Klepněte pro návrat k hovoru" + "☎️ Probíhá hovor" + "Element Call nepodporuje používání Bluetooth zvukových zařízení v této verzi systému Android. Vyberte jiné zvukové zařízení." + "Příchozí Element Call" + diff --git a/features/call/impl/src/main/res/values-cy/translations.xml b/features/call/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..cd36fed --- /dev/null +++ b/features/call/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,8 @@ + + + "Galwad cyfredol" + "Tapio i ddychwelyd i\'r alwad" + "☎️ Galwad ar y gweill" + "Nid yw Element Call yn cefnogi defnyddio dyfeisiau sain Bluetooth yn y fersiwn Android hon. Dewiswch ddyfais sain wahanol." + "Galwad Element" + diff --git a/features/call/impl/src/main/res/values-da/translations.xml b/features/call/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..9bb8c30 --- /dev/null +++ b/features/call/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,8 @@ + + + "Igangværende opkald" + "Tryk for at vende tilbage til opkaldet" + "☎️ Opkald i gang" + "Element Call understøtter desværre ikke brug af Bluetooth-lydenheder i denne Android-version. Vælg venligst en anden lydenhed." + "Indgående Element opkald" + diff --git a/features/call/impl/src/main/res/values-de/translations.xml b/features/call/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..8fabd16 --- /dev/null +++ b/features/call/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,8 @@ + + + "Laufender Anruf" + "Tippen, um zum Anruf zurückzukehren" + "☎️ Anruf läuft" + "Element Call unterstützt in dieser Android-Version keine Bluetooth Geräte. Bitte wähle ein anderes Audiogerät." + "Eingehender Element Call" + diff --git a/features/call/impl/src/main/res/values-el/translations.xml b/features/call/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..fa80c68 --- /dev/null +++ b/features/call/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,8 @@ + + + "Συνεχής κλήση" + "Πάτα για να επιστρέψεις στην κλήση" + "☎️ Κλήση σε εξέλιξη" + "Το Element Call δεν υποστηρίζει τη χρήση συσκευών ήχου Bluetooth σε αυτήν την έκδοση Android. Επέλεξε μια διαφορετική συσκευή ήχου." + "Εισερχόμενη κλήση Element" + diff --git a/features/call/impl/src/main/res/values-es/translations.xml b/features/call/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..b0c3092 --- /dev/null +++ b/features/call/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,7 @@ + + + "Llamada en curso" + "Pulsa para regresar a la llamada" + "☎️ Llamada en curso" + "Llamada de Element Call entrante" + diff --git a/features/call/impl/src/main/res/values-et/translations.xml b/features/call/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..16b72b8 --- /dev/null +++ b/features/call/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,8 @@ + + + "Pooleliolev kõne" + "Kõne juurde naasmiseks klõpsa" + "☎️ Kõne on pooleli" + "Element Call ei võimalda selles Androidi versioonis Bluetoothi heliseadmete kasutamist. Palun vali mõni muu heliseade." + "Sissetulev Element Calli kõne" + diff --git a/features/call/impl/src/main/res/values-eu/translations.xml b/features/call/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..9985c56 --- /dev/null +++ b/features/call/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,7 @@ + + + "Abian den deia" + "Sakatu deira itzultzeko." + "☎️ Deia abian" + "Element deia jasotzen" + diff --git a/features/call/impl/src/main/res/values-fa/translations.xml b/features/call/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..c58ebfd --- /dev/null +++ b/features/call/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,7 @@ + + + "تماس خروجی" + "زدن برای بازگشت به تماس" + "تماس در جریان ☎️" + "تماس المنتی ورودی" + diff --git a/features/call/impl/src/main/res/values-fi/translations.xml b/features/call/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..16be852 --- /dev/null +++ b/features/call/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,8 @@ + + + "Käynnissä oleva puhelu" + "Palaa puheluun napauttamalla" + "☎️ Puhelu käynnissä" + "Element Call ei tue Bluetooth-äänilaitteiden käyttöä tässä Android-versiossa. Valitse toinen äänilaite." + "Saapuva Element Call -puhelu" + diff --git a/features/call/impl/src/main/res/values-fr/translations.xml b/features/call/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..bab8f63 --- /dev/null +++ b/features/call/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,8 @@ + + + "Appel en cours" + "Cliquez pour retourner à l’appel." + "☎️ Appel en cours" + "Element Call ne prend pas en charge l’utilisation d’accessoires Bluetooth dans cette version d’Android. Sélectionnez une autre sortie audio." + "Appel Element entrant" + diff --git a/features/call/impl/src/main/res/values-hu/translations.xml b/features/call/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..62d359c --- /dev/null +++ b/features/call/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,8 @@ + + + "Folyamatban lévő hívás" + "Koppintson a híváshoz való visszatéréshez" + "☎️ Hívás folyamatban" + "Az Element Call nem támogatja a Bluetooth hangeszközök használatát ebben az Android-verzióban. Válasszon másik hangeszközt." + "Bejövő Element hívás" + diff --git a/features/call/impl/src/main/res/values-in/translations.xml b/features/call/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..7da23d6 --- /dev/null +++ b/features/call/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,8 @@ + + + "Panggilan berlangsung" + "Ketuk untuk kembali ke panggilan" + "☎️ Panggilan sedang berlangsung" + "Element Call tidak mendukung penggunaan perangkat audio Bluetooth di versi Android ini. Silakan pilih perangkat audio yang berbeda." + "Element Call Masuk" + diff --git a/features/call/impl/src/main/res/values-it/translations.xml b/features/call/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..086fc98 --- /dev/null +++ b/features/call/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,8 @@ + + + "Chiamata in corso" + "Tocca per tornare alla chiamata" + "☎️ Chiamata in corso" + "Element Call non supporta l\'uso di dispositivi audio Bluetooth in questa versione di Android. Seleziona un dispositivo audio diverso." + "Chiamata Element Call in arrivo" + diff --git a/features/call/impl/src/main/res/values-ka/translations.xml b/features/call/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..755bb24 --- /dev/null +++ b/features/call/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,6 @@ + + + "მიმდინარე ზარი" + "დააწკაპუნეთ ზარში დასაბრუნებლად" + "☎️ ზარი მიმდინარეობს" + diff --git a/features/call/impl/src/main/res/values-ko/translations.xml b/features/call/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..7900c57 --- /dev/null +++ b/features/call/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,8 @@ + + + "수신 중인 통화" + "탭해서 통화로 돌아가기" + "☎️ 통화 진행 중" + "Element Call은 이 Android 버전에서 Bluetooth 오디오 장치 사용을 지원하지 않습니다. 다른 오디오 장치를 선택하세요." + "Element 전화 수신" + diff --git a/features/call/impl/src/main/res/values-nb/translations.xml b/features/call/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..b93abc6 --- /dev/null +++ b/features/call/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,8 @@ + + + "Pågående samtale" + "Trykk for å gå tilbake til samtalen" + "☎️ Samtale pågår" + "Element Call støtter ikke bruk av Bluetooth-lydenheter i denne Android-versjonen. Velg en annen lydenhet." + "Innkommende Element-anrop" + diff --git a/features/call/impl/src/main/res/values-nl/translations.xml b/features/call/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..d1b0e64 --- /dev/null +++ b/features/call/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,7 @@ + + + "Actieve oproep" + "Tik om terug te gaan naar het gesprek" + "☎️ In gesprek" + "Inkomende Element-oproep" + diff --git a/features/call/impl/src/main/res/values-pl/translations.xml b/features/call/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..d297899 --- /dev/null +++ b/features/call/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,8 @@ + + + "Połączenie w trakcie" + "Stuknij, aby wrócić do rozmowy" + "☎️ Rozmowa w toku" + "Element Call nie obsługuje korzystania z urządzeń audio Bluetooth w tej wersji Androida. Wybierz inne urządzenie audio." + "Przychodzące połączenie Element" + diff --git a/features/call/impl/src/main/res/values-pt-rBR/translations.xml b/features/call/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..b089584 --- /dev/null +++ b/features/call/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,8 @@ + + + "Chamada em andamento" + "Toque para retornar à chamada" + "☎️ Chamada em andamento" + "O Element Call não tem suporte a dispositivos de áudio Bluetooth nesta versão do Android. Por favor, selecione um dispositivo de áudio diferente." + "Chamada do Element recebida" + diff --git a/features/call/impl/src/main/res/values-pt/translations.xml b/features/call/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..639726b --- /dev/null +++ b/features/call/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,8 @@ + + + "Chamada em curso" + "Toca para voltar à chamada" + "☎️ Chamada em curso" + "As chamadas do Element não permitem o uso de dispositivos de áudio Bluetooth nesta versão do Android. Por favor, seleciona outro dispositivo." + "A receber chamada da Element" + diff --git a/features/call/impl/src/main/res/values-ro/translations.xml b/features/call/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..7d619d5 --- /dev/null +++ b/features/call/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,8 @@ + + + "Apel în curs" + "Atingeți pentru a reveni la apel." + "☎️ Apel în curs" + "Element Call nu permite utilizarea dispozitivelor audio Bluetooth în această versiune Android. Vă rugăm să selectați un alt dispozitiv audio." + "Primiți un apel Element Call" + diff --git a/features/call/impl/src/main/res/values-ru/translations.xml b/features/call/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..e7de6d3 --- /dev/null +++ b/features/call/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,8 @@ + + + "Текущий вызов" + "Коснитесь, чтобы вернуться к вызову" + "☎️ Идёт вызов" + "Функция Element Call не поддерживает использование аудиоустройств Bluetooth в данной версии Android. Пожалуйста, выберите другое аудиоустройство." + "Входящий вызов Element" + diff --git a/features/call/impl/src/main/res/values-sk/translations.xml b/features/call/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..4e5b7db --- /dev/null +++ b/features/call/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,8 @@ + + + "Prebiehajúci hovor" + "Ťuknutím sa vrátite k hovoru" + "☎️ Prebieha hovor" + "Element Call nepodporuje používanie zvukových zariadení Bluetooth v tejto verzii systému Android. Vyberte iné zvukové zariadenie." + "Prichádzajúci hovor Element Call" + diff --git a/features/call/impl/src/main/res/values-sv/translations.xml b/features/call/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..930b5ee --- /dev/null +++ b/features/call/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,8 @@ + + + "Pågående samtal" + "Tryck för att återgå till samtalet" + "☎️ Samtal pågår" + "Element Call stöder inte användning av Bluetooth-ljudenheter i den här Android-versionen. Välj en annan ljudenhet." + "Inkommande Element Call" + diff --git a/features/call/impl/src/main/res/values-tr/translations.xml b/features/call/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..37607db --- /dev/null +++ b/features/call/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,7 @@ + + + "Devam eden çağrı" + "Aramaya geri dönmek için dokunun" + "☎️ Çağrı devam ediyor" + "Gelen Element Call" + diff --git a/features/call/impl/src/main/res/values-uk/translations.xml b/features/call/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..f03df23 --- /dev/null +++ b/features/call/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,8 @@ + + + "Поточний виклик" + "Торкніться, щоб повернутися до виклику" + "☎️ Триває виклик" + "Element Call не підтримує використання аудіопристроїв Bluetooth у цій версії Android. Виберіть інший аудіопристрій." + "Вхідний виклик Element" + diff --git a/features/call/impl/src/main/res/values-ur/translations.xml b/features/call/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..d0cafe9 --- /dev/null +++ b/features/call/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,7 @@ + + + "جاری مکالمہ" + "مکالمہ پر واپس جانے کے لیے تھپتھپائیں" + "☎️ مکالمہ جاری ہے" + "ورودی ایلیمنٹ کال" + diff --git a/features/call/impl/src/main/res/values-uz/translations.xml b/features/call/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..ceab664 --- /dev/null +++ b/features/call/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,8 @@ + + + "Davom etayotgan qo\'ng\'iroq" + "Qo\'ng\'iroqqa qaytish uchun bosing" + "☎️ Qoʻngʻiroq davom etmoqda" + "Element Call ushbu Android versiyasida Bluetooth audio qurilmalaridan foydalanishni qoʻllab-quvvatlamaydi. Iltimos, boshqa audio qurilmani tanlang." + "Kiruvchi element qoʻngʻirogʻi" + diff --git a/features/call/impl/src/main/res/values-zh-rTW/translations.xml b/features/call/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..d0e400d --- /dev/null +++ b/features/call/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,8 @@ + + + "進行中的通話" + "點擊以返回到通話頁面" + "☎️ 通話中" + "Element Call 不支援在此 Android 版本中使用藍牙音訊裝置。請選取其他音訊裝置。" + "Element 來電" + diff --git a/features/call/impl/src/main/res/values-zh/translations.xml b/features/call/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..6192568 --- /dev/null +++ b/features/call/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,8 @@ + + + "通话进行中" + "点按即可返回通话" + "☎️ 通话中" + "Element Call 不支持在此 Android 版本中使用蓝牙音频设备。请选择其他音频设备。" + "Element 来电" + diff --git a/features/call/impl/src/main/res/values/do_not_translate.xml b/features/call/impl/src/main/res/values/do_not_translate.xml new file mode 100644 index 0000000..deb835b --- /dev/null +++ b/features/call/impl/src/main/res/values/do_not_translate.xml @@ -0,0 +1,10 @@ + + + Element Call + diff --git a/features/call/impl/src/main/res/values/localazy.xml b/features/call/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..5ab2caa --- /dev/null +++ b/features/call/impl/src/main/res/values/localazy.xml @@ -0,0 +1,8 @@ + + + "Ongoing call" + "Tap to return to the call" + "☎️ Call in progress" + "Element Call does not support using Bluetooth audio devices in this Android version. Please select a different audio device." + "Incoming Element Call" + diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt new file mode 100644 index 0000000..bfc6565 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call + +import android.content.Intent +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.features.call.api.CallType +import io.element.android.features.call.impl.DefaultElementCallEntryPoint +import io.element.android.features.call.impl.notifications.CallNotificationData +import io.element.android.features.call.impl.ui.ElementCallActivity +import io.element.android.features.call.utils.FakeActiveCallManager +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_USER_ID_2 +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import kotlin.time.Duration.Companion.seconds + +@RunWith(RobolectricTestRunner::class) +class DefaultElementCallEntryPointTest { + @Test + fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest { + val entryPoint = createEntryPoint() + entryPoint.startCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID)) + + val expectedIntent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, ElementCallActivity::class.java) + val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity + assertThat(intent.component).isEqualTo(expectedIntent.component) + assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() = runTest { + val registerIncomingCallLambda = lambdaRecorder {} + val activeCallManager = FakeActiveCallManager(registerIncomingCallResult = registerIncomingCallLambda) + val entryPoint = createEntryPoint(activeCallManager = activeCallManager) + + entryPoint.handleIncomingCall( + callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), + eventId = AN_EVENT_ID, + senderId = A_USER_ID_2, + roomName = "roomName", + senderName = "senderName", + avatarUrl = "avatarUrl", + timestamp = 0, + expirationTimestamp = 0, + notificationChannelId = "notificationChannelId", + textContent = "textContent", + ) + + advanceTimeBy(1.seconds) + + registerIncomingCallLambda.assertions().isCalledOnce() + } + + private fun TestScope.createEntryPoint( + activeCallManager: FakeActiveCallManager = FakeActiveCallManager(), + ) = DefaultElementCallEntryPoint( + context = InstrumentationRegistry.getInstrumentation().targetContext, + activeCallManager = activeCallManager, + ) +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt new file mode 100644 index 0000000..e356358 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call + +import android.Manifest +import android.webkit.PermissionRequest +import com.google.common.truth.Truth.assertThat +import io.element.android.features.call.impl.ui.mapWebkitPermissions +import org.junit.Test + +class MapWebkitPermissionsTest { + @Test + fun `given Webkit's RESOURCE_AUDIO_CAPTURE returns Android's RECORD_AUDIO permission`() { + val permission = mapWebkitPermissions(arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) + assertThat(permission).isEqualTo(listOf(Manifest.permission.RECORD_AUDIO)) + } + + @Test + fun `given Webkit's RESOURCE_VIDEO_CAPTURE returns Android's CAMERA permission`() { + val permission = mapWebkitPermissions(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) + assertThat(permission).isEqualTo(listOf(Manifest.permission.CAMERA)) + } + + @Test + fun `given any other permission, it returns nothing`() { + val permission = mapWebkitPermissions(arrayOf(PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID)) + assertThat(permission).isEmpty() + } +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt new file mode 100644 index 0000000..5153f79 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.pip + +import io.element.android.features.call.impl.utils.PipController +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePipController( + private val canEnterPipResult: () -> Boolean = { lambdaError() }, + private val enterPipResult: () -> Unit = { lambdaError() }, + private val exitPipResult: () -> Unit = { lambdaError() }, +) : PipController { + override suspend fun canEnterPip(): Boolean = canEnterPipResult() + + override fun enterPip() = enterPipResult() + + override fun exitPip() = exitPipResult() +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipSupportProvider.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipSupportProvider.kt new file mode 100644 index 0000000..c17e312 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipSupportProvider.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.pip + +class FakePipSupportProvider( + private val isPipSupported: Boolean +) : PipSupportProvider { + override fun isPipSupported() = isPipSupported +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt new file mode 100644 index 0000000..55e9431 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.pip + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePipView( + private val setPipParamsResult: () -> Unit = { lambdaError() }, + private val enterPipModeResult: () -> Boolean = { lambdaError() }, + private val handUpResult: () -> Unit = { lambdaError() } +) : PipView { + override fun setPipParams() = setPipParamsResult() + override fun enterPipMode(): Boolean = enterPipModeResult() + override fun hangUp() = handUpResult() +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt new file mode 100644 index 0000000..c087fa3 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.impl.pip + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PictureInPicturePresenterTest { + @Test + fun `when pip is not supported, the state value supportPip is false`() = runTest { + val presenter = createPictureInPicturePresenter(supportPip = false) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.supportPip).isFalse() + } + presenter.setPipView(null) + } + + @Test + fun `when pip is supported, the state value supportPip is true`() = runTest { + val presenter = createPictureInPicturePresenter( + supportPip = true, + pipView = FakePipView(setPipParamsResult = { }), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.supportPip).isTrue() + } + } + + @Test + fun `when entering pip is supported, the state value isInPictureInPicture is true`() = runTest { + val enterPipModeResult = lambdaRecorder { true } + val presenter = createPictureInPicturePresenter( + supportPip = true, + pipView = FakePipView( + setPipParamsResult = { }, + enterPipModeResult = enterPipModeResult, + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.isInPictureInPicture).isFalse() + initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture) + enterPipModeResult.assertions().isCalledOnce() + initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(true)) + val pipState = awaitItem() + assertThat(pipState.isInPictureInPicture).isTrue() + // User stops pip + initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(false)) + val finalState = awaitItem() + assertThat(finalState.isInPictureInPicture).isFalse() + } + } + + @Test + fun `with webPipApi, when entering pip is supported, but web deny it, the call is finished`() = runTest { + val handUpResult = lambdaRecorder { } + val presenter = createPictureInPicturePresenter( + supportPip = true, + pipView = FakePipView( + setPipParamsResult = { }, + handUpResult = handUpResult + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(PictureInPictureEvents.SetPipController(FakePipController(canEnterPipResult = { false }))) + initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture) + handUpResult.assertions().isCalledOnce() + } + } + + @Test + fun `with webPipApi, when entering pip is supported, and web allows it, the state value isInPictureInPicture is true`() = runTest { + val enterPipModeResult = lambdaRecorder { true } + val enterPipResult = lambdaRecorder { } + val exitPipResult = lambdaRecorder { } + val presenter = createPictureInPicturePresenter( + supportPip = true, + pipView = FakePipView( + setPipParamsResult = { }, + enterPipModeResult = enterPipModeResult + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink( + PictureInPictureEvents.SetPipController( + FakePipController( + canEnterPipResult = { true }, + enterPipResult = enterPipResult, + exitPipResult = exitPipResult, + ) + ) + ) + initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture) + enterPipModeResult.assertions().isCalledOnce() + enterPipResult.assertions().isNeverCalled() + initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(true)) + val pipState = awaitItem() + assertThat(pipState.isInPictureInPicture).isTrue() + enterPipResult.assertions().isCalledOnce() + // User stops pip + exitPipResult.assertions().isNeverCalled() + initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(false)) + val finalState = awaitItem() + assertThat(finalState.isInPictureInPicture).isFalse() + exitPipResult.assertions().isCalledOnce() + } + } + + private fun createPictureInPicturePresenter( + supportPip: Boolean = true, + pipView: PipView? = FakePipView() + ): PictureInPicturePresenter { + return PictureInPicturePresenter( + pipSupportProvider = FakePipSupportProvider(supportPip), + ).apply { + setPipView(pipView) + } + } +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt new file mode 100644 index 0000000..28e2747 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.notifications + +import androidx.core.graphics.drawable.IconCompat +import androidx.test.platform.app.InstrumentationRegistry +import coil3.ImageLoader +import com.google.common.truth.Truth.assertThat +import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator +import io.element.android.libraries.designsystem.components.avatar.AvatarData +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_USER_ID_2 +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class RingingCallNotificationCreatorTest { + @Test + fun `createNotification - with no associated MatrixClient does nothing`() = runTest { + val notificationCreator = createRingingCallNotificationCreator( + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(IllegalStateException("No client found")) }) + ) + + val result = notificationCreator.createTestNotification() + + assertThat(result).isNull() + } + + @Test + fun `createNotification - creates a valid notification`() = runTest { + val notificationCreator = createRingingCallNotificationCreator( + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }) + ) + + val result = notificationCreator.createTestNotification() + + assertThat(result).isNotNull() + } + + @Test + fun `createNotification - tries to load the avatar URL`() = runTest { + val getUserIconLambda = lambdaRecorder { _, _ -> null } + val notificationCreator = createRingingCallNotificationCreator( + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }), + notificationBitmapLoader = FakeNotificationBitmapLoader(getUserIconResult = getUserIconLambda) + ) + + notificationCreator.createTestNotification() + + getUserIconLambda.assertions().isCalledOnce() + } + + private suspend fun RingingCallNotificationCreator.createTestNotification() = createNotification( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + senderId = A_USER_ID_2, + roomName = "Room", + senderDisplayName = "Johnnie Murphy", + roomAvatarUrl = "https://example.com/avatar.jpg", + notificationChannelId = "channelId", + timestamp = 0L, + expirationTimestamp = 20L, + textContent = "textContent", + ) + + private fun createRingingCallNotificationCreator( + matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), + imageLoaderHolder: FakeImageLoaderHolder = FakeImageLoaderHolder(), + notificationBitmapLoader: FakeNotificationBitmapLoader = FakeNotificationBitmapLoader(), + ) = RingingCallNotificationCreator( + context = InstrumentationRegistry.getInstrumentation().targetContext, + matrixClientProvider = matrixClientProvider, + imageLoaderHolder = imageLoaderHolder, + notificationBitmapLoader = notificationBitmapLoader, + ) +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt new file mode 100644 index 0000000..09aaaf8 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt @@ -0,0 +1,419 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.ui + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.features.call.api.CallType +import io.element.android.features.call.impl.ui.CallScreenEvents +import io.element.android.features.call.impl.ui.CallScreenNavigator +import io.element.android.features.call.impl.ui.CallScreenPresenter +import io.element.android.features.call.impl.utils.WidgetMessageSerializer +import io.element.android.features.call.utils.FakeActiveCallManager +import io.element.android.features.call.utils.FakeCallWidgetProvider +import io.element.android.features.call.utils.FakeWidgetMessageInterceptor +import io.element.android.libraries.androidutils.json.DefaultJsonProvider +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.sync.SyncState +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.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.sync.FakeSyncService +import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver +import io.element.android.libraries.network.useragent.UserAgentProvider +import io.element.android.services.analytics.api.ScreenTracker +import io.element.android.services.analytics.test.FakeScreenTracker +import io.element.android.services.appnavstate.test.FakeAppForegroundStateService +import io.element.android.services.toolbox.api.systemclock.SystemClock +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class CallScreenPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - with CallType ExternalUrl just loads the URL and sets the call as active`() = runTest { + val analyticsLambda = lambdaRecorder {} + val joinedCallLambda = lambdaRecorder {} + val presenter = createCallScreenPresenter( + callType = CallType.ExternalUrl("https://call.element.io"), + screenTracker = FakeScreenTracker(analyticsLambda), + activeCallManager = FakeActiveCallManager(joinedCallResult = joinedCallLambda), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Wait until the URL is loaded + advanceTimeBy(1.seconds) + skipItems(2) + val initialState = awaitItem() + assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io")) + assertThat(initialState.webViewError).isNull() + assertThat(initialState.isInWidgetMode).isFalse() + assertThat(initialState.isCallActive).isFalse() + analyticsLambda.assertions().isNeverCalled() + joinedCallLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - with CallType RoomCall sets call as active, loads URL and runs WidgetDriver`() = runTest { + val widgetDriver = FakeMatrixWidgetDriver() + val widgetProvider = FakeCallWidgetProvider(widgetDriver) + val analyticsLambda = lambdaRecorder {} + val joinedCallLambda = lambdaRecorder {} + val presenter = createCallScreenPresenter( + callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), + widgetDriver = widgetDriver, + widgetProvider = widgetProvider, + screenTracker = FakeScreenTracker(analyticsLambda), + activeCallManager = FakeActiveCallManager(joinedCallResult = joinedCallLambda), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Wait until the URL is loaded + advanceTimeBy(1.seconds) + skipItems(1) + + joinedCallLambda.assertions().isCalledOnce() + val initialState = awaitItem() + assertThat(initialState.urlState).isInstanceOf(AsyncData.Loading::class.java) + assertThat(initialState.isCallActive).isFalse() + assertThat(initialState.isInWidgetMode).isTrue() + assertThat(widgetProvider.getWidgetCalled).isTrue() + assertThat(widgetDriver.runCalledCount).isEqualTo(1) + analyticsLambda.assertions().isCalledOnce().with(value(MobileScreen.ScreenName.RoomCall)) + + // Wait until the WidgetDriver is loaded + skipItems(1) + + assertThat(awaitItem().urlState).isInstanceOf(AsyncData.Success::class.java) + } + } + + @Test + fun `present - set message interceptor, send and receive messages`() = runTest { + val widgetDriver = FakeMatrixWidgetDriver() + val presenter = createCallScreenPresenter( + callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), + widgetDriver = widgetDriver, + screenTracker = FakeScreenTracker {}, + ) + val messageInterceptor = FakeWidgetMessageInterceptor() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Give it time to load the URL and WidgetDriver + advanceTimeBy(1.seconds) + + val initialState = awaitItem() + initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) + + // And incoming message from the Widget Driver is passed to the WebView + widgetDriver.givenIncomingMessage("A message") + assertThat(messageInterceptor.sentMessages).containsExactly("A message") + + // And incoming message from the WebView is passed to the Widget Driver + messageInterceptor.givenInterceptedMessage("A reply") + assertThat(widgetDriver.sentMessages).containsExactly("A reply") + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - hang up event closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) { + val navigator = FakeCallScreenNavigator() + val widgetDriver = FakeMatrixWidgetDriver() + val presenter = createCallScreenPresenter( + callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), + widgetDriver = widgetDriver, + navigator = navigator, + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + screenTracker = FakeScreenTracker {}, + ) + val messageInterceptor = FakeWidgetMessageInterceptor() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + // Give it time to load the URL and WidgetDriver + advanceTimeBy(1.seconds) + + initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) + + initialState.eventSink(CallScreenEvents.Hangup) + + // Let background coroutines run and the widget drive be received + runCurrent() + + assertThat(navigator.closeCalled).isTrue() + assertThat(widgetDriver.closeCalledCount).isEqualTo(1) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - a received close message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) { + val navigator = FakeCallScreenNavigator() + val widgetDriver = FakeMatrixWidgetDriver() + val presenter = createCallScreenPresenter( + callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), + widgetDriver = widgetDriver, + navigator = navigator, + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + screenTracker = FakeScreenTracker {}, + ) + val messageInterceptor = FakeWidgetMessageInterceptor() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + // Give it time to load the URL and WidgetDriver + advanceTimeBy(1.seconds) + + initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) + + messageInterceptor.givenInterceptedMessage("""{"action":"io.element.close","api":"fromWidget","widgetId":"1","requestId":"1"}""") + + // Let background coroutines run + advanceTimeBy(1.seconds) + runCurrent() + + assertThat(navigator.closeCalled).isTrue() + assertThat(widgetDriver.closeCalledCount).isEqualTo(1) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - a received 'content loaded' action makes the call to be active`() = runTest { + val navigator = FakeCallScreenNavigator() + val widgetDriver = FakeMatrixWidgetDriver() + val presenter = createCallScreenPresenter( + callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), + widgetDriver = widgetDriver, + navigator = navigator, + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + screenTracker = FakeScreenTracker {}, + ) + val messageInterceptor = FakeWidgetMessageInterceptor() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Give it time to load the URL and WidgetDriver + advanceTimeBy(1.seconds) + skipItems(2) + val initialState = awaitItem() + assertThat(initialState.isCallActive).isFalse() + initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) + messageInterceptor.givenInterceptedMessage( + """ + { + "action":"content_loaded", + "api":"fromWidget", + "widgetId":"1", + "requestId":"1" + } + """.trimIndent() + ) + skipItems(2) + val finalState = awaitItem() + assertThat(finalState.isCallActive).isTrue() + } + } + + @Test + fun `present - if in room mode and no join action is received an error is displayed`() = runTest { + val navigator = FakeCallScreenNavigator() + val widgetDriver = FakeMatrixWidgetDriver() + val presenter = createCallScreenPresenter( + callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), + widgetDriver = widgetDriver, + navigator = navigator, + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + screenTracker = FakeScreenTracker {}, + ) + val messageInterceptor = FakeWidgetMessageInterceptor() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Give it time to load the URL and WidgetDriver + advanceTimeBy(1.seconds) + skipItems(2) + val initialState = awaitItem() + assertThat(initialState.isCallActive).isFalse() + initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) + skipItems(2) + + // Wait for the timeout to trigger + advanceTimeBy(10.seconds) + + val finalState = awaitItem() + assertThat(finalState.isCallActive).isFalse() + // The error dialog that will force the user to leave the call is displayed + assertThat(finalState.webViewError).isNotNull() + assertThat(finalState.webViewError).isEmpty() + } + } + + @Test + fun `present - automatically sets the isInCall state when starting the call and disposing the screen`() = runTest { + val navigator = FakeCallScreenNavigator() + val widgetDriver = FakeMatrixWidgetDriver() + val startSyncLambda = lambdaRecorder> { Result.success(Unit) } + val syncService = FakeSyncService(SyncState.Idle).apply { + this.startSyncLambda = startSyncLambda + } + val matrixClient = FakeMatrixClient(syncService = syncService) + val appForegroundStateService = FakeAppForegroundStateService() + val presenter = createCallScreenPresenter( + callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), + widgetDriver = widgetDriver, + navigator = navigator, + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }), + screenTracker = FakeScreenTracker {}, + appForegroundStateService = appForegroundStateService, + ) + val hasRun = Mutex(true) + val job = launch { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.collect { + hasRun.unlock() + } + } + + appForegroundStateService.isInCall.test { + // The initial isInCall state will always be false + assertThat(awaitItem()).isFalse() + + // Wait until the call starts + hasRun.lock() + + // Then it'll be true once the call is active + assertThat(awaitItem()).isTrue() + + // If we dispose the screen + job.cancelAndJoin() + + // The isInCall state is now false + assertThat(awaitItem()).isFalse() + + // And there are no more events + ensureAllEventsConsumed() + } + } + + @Test + fun `present - error from WebView are updating the state`() = runTest { + val presenter = createCallScreenPresenter( + callType = CallType.ExternalUrl("https://call.element.io"), + activeCallManager = FakeActiveCallManager(), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Wait until the URL is loaded + advanceTimeBy(1.seconds) + skipItems(2) + val initialState = awaitItem() + initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error")) + val finalState = awaitItem() + assertThat(finalState.webViewError).isEqualTo("A Webview error") + } + } + + @Test + fun `present - error from WebView are ignored if Element Call is loaded`() = runTest { + val presenter = createCallScreenPresenter( + callType = CallType.ExternalUrl("https://call.element.io"), + activeCallManager = FakeActiveCallManager(), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Wait until the URL is loaded + skipItems(1) + val initialState = awaitItem() + + val messageInterceptor = FakeWidgetMessageInterceptor() + initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) + // Emit a message + messageInterceptor.givenInterceptedMessage("A message") + // WebView emits an error, but it will be ignored + initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error")) + val finalState = awaitItem() + assertThat(finalState.webViewError).isNull() + + cancelAndIgnoreRemainingEvents() + } + } + + private fun TestScope.createCallScreenPresenter( + callType: CallType, + navigator: CallScreenNavigator = FakeCallScreenNavigator(), + widgetDriver: FakeMatrixWidgetDriver = FakeMatrixWidgetDriver(), + widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver), + dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), + matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), + activeCallManager: FakeActiveCallManager = FakeActiveCallManager(), + screenTracker: ScreenTracker = FakeScreenTracker(), + appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(), + ): CallScreenPresenter { + val userAgentProvider = object : UserAgentProvider { + override fun provide(): String { + return "Test" + } + } + val clock = SystemClock { 0 } + return CallScreenPresenter( + callType = callType, + navigator = navigator, + callWidgetProvider = widgetProvider, + userAgentProvider = userAgentProvider, + clock = clock, + dispatchers = dispatchers, + matrixClientsProvider = matrixClientsProvider, + activeCallManager = activeCallManager, + screenTracker = screenTracker, + languageTagProvider = FakeLanguageTagProvider("en-US"), + appForegroundStateService = appForegroundStateService, + appCoroutineScope = backgroundScope, + widgetMessageSerializer = WidgetMessageSerializer(DefaultJsonProvider()), + ) + } +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallTypeTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallTypeTest.kt new file mode 100644 index 0000000..0c91b21 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallTypeTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.ui + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.call.api.CallType +import io.element.android.features.call.impl.ui.getSessionId +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import org.junit.Test + +class CallTypeTest { + @Test + fun `getSessionId returns null for ExternalUrl`() { + assertThat(CallType.ExternalUrl("aURL").getSessionId()).isNull() + } + + @Test + fun `getSessionId returns the sessionId for RoomCall`() { + assertThat( + CallType.RoomCall( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + ).getSessionId() + ).isEqualTo(A_SESSION_ID) + } + + @Test + fun `ExternalUrl stringification does not contain the URL`() { + assertThat(CallType.ExternalUrl("aURL").toString()).isEqualTo("ExternalUrl") + } + + @Test + fun `RoomCall stringification does not contain the URL`() { + assertThat(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID).toString()) + .isEqualTo("RoomCall(sessionId=$A_SESSION_ID, roomId=$A_ROOM_ID)") + } +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt new file mode 100644 index 0000000..431b746 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.ui + +import io.element.android.features.call.impl.ui.CallScreenNavigator + +class FakeCallScreenNavigator : CallScreenNavigator { + var closeCalled = false + private set + + override fun close() { + closeCalled = true + } +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeLanguageTagProvider.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeLanguageTagProvider.kt new file mode 100644 index 0000000..f688428 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeLanguageTagProvider.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.ui + +import androidx.compose.runtime.Composable +import io.element.android.features.call.impl.ui.LanguageTagProvider + +class FakeLanguageTagProvider(private val languageTag: String?) : LanguageTagProvider { + @Composable + override fun provideLanguageTag() = languageTag +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt new file mode 100644 index 0000000..43f7f93 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.utils + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.call.impl.utils.CallIntentDataParser +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.net.URLEncoder + +@RunWith(RobolectricTestRunner::class) +class CallIntentDataParserTest { + private val callIntentDataParser = CallIntentDataParser() + + @Test + fun `a null data returns null`() { + val url: String? = null + assertThat(callIntentDataParser.parse(url)).isNull() + } + + @Test + fun `empty data returns null`() { + doTest("", null) + } + + @Test + fun `invalid data returns null`() { + doTest("!", null) + } + + @Test + fun `data with no scheme returns null`() { + doTest("test", null) + } + + @Test + fun `Element Call http urls returns null`() { + doTest("http://call.element.io", null) + doTest("http://call.element.io/some-actual-call?with=parameters", null) + } + + @Test + fun `Element Call urls with unknown host returns null`() { + // Check valid host first, should not return null + doTest("https://call.element.io", "https://call.element.io#?appPrompt=false&confineToRoom=true") + // Unknown host should return null + doTest("https://unknown.io", null) + doTest("https://call.unknown.io", null) + doTest("https://call.element.com", null) + doTest("https://call.element.io.tld", null) + } + + @Test + fun `Element Call urls will be returned as is`() { + doTest( + url = "https://call.element.io", + expectedResult = "https://call.element.io#?$EXTRA_PARAMS" + ) + } + + @Test + fun `Element Call url with url param gets url extracted`() { + doTest( + url = VALID_CALL_URL_WITH_PARAM, + expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS" + ) + } + + @Test + fun `HTTP and HTTPS urls that don't come from EC return null`() { + doTest("http://app.element.io", null) + doTest("https://app.element.io", null) + doTest("http://", null) + doTest("https://", null) + } + + @Test + fun `Element Call url with no url returns null`() { + val embeddedUrl = VALID_CALL_URL_WITH_PARAM + val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8") + val url = "io.element.call:/?no_url=$encodedUrl" + assertThat(callIntentDataParser.parse(url)).isNull() + } + + @Test + fun `element scheme with no call host returns null`() { + val embeddedUrl = VALID_CALL_URL_WITH_PARAM + val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8") + val url = "element://no-call?url=$encodedUrl" + assertThat(callIntentDataParser.parse(url)).isNull() + } + + @Test + fun `element scheme with no data returns null`() { + val url = "element://call?url=" + assertThat(callIntentDataParser.parse(url)).isNull() + } + + @Test + fun `Element Call url with no data returns null`() { + val url = "io.element.call:/?url=" + assertThat(callIntentDataParser.parse(url)).isNull() + } + + @Test + fun `element invalid scheme returns null`() { + val embeddedUrl = VALID_CALL_URL_WITH_PARAM + val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8") + val url = "bad.scheme:/?url=$encodedUrl" + assertThat(callIntentDataParser.parse(url)).isNull() + } + + @Test + fun `Element Call url with url extra param appPrompt gets url extracted`() { + doTest( + url = "$VALID_CALL_URL_WITH_PARAM&appPrompt=true", + expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS" + ) + } + + @Test + fun `Element Call url with url extra param in fragment appPrompt gets url extracted`() { + doTest( + url = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=true", + expectedResult = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=false&confineToRoom=true" + ) + } + + @Test + fun `Element Call url with url extra param in fragment appPrompt and other gets url extracted`() { + doTest( + url = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=true&otherParam=maybe", + expectedResult = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=false&otherParam=maybe&confineToRoom=true" + ) + } + + @Test + fun `Element Call url with url extra param confineToRoom gets url extracted`() { + doTest( + url = "$VALID_CALL_URL_WITH_PARAM&confineToRoom=false", + expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS" + ) + } + + @Test + fun `Element Call url with url extra param in fragment confineToRoom gets url extracted`() { + doTest( + url = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=false", + expectedResult = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=true&appPrompt=false" + ) + } + + @Test + fun `Element Call url with url extra param in fragment confineToRoom and more gets url extracted`() { + doTest( + url = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=false&otherParam=maybe", + expectedResult = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=true&otherParam=maybe&appPrompt=false" + ) + } + + @Test + fun `Element Call url with url fragment gets url extracted`() { + doTest( + url = "$VALID_CALL_URL_WITH_PARAM#fragment", + expectedResult = "$VALID_CALL_URL_WITH_PARAM#fragment?$EXTRA_PARAMS" + ) + } + + @Test + fun `Element Call url with url fragment with params gets url extracted`() { + doTest( + url = "$VALID_CALL_URL_WITH_PARAM#fragment?otherParam=maybe", + expectedResult = "$VALID_CALL_URL_WITH_PARAM#fragment?otherParam=maybe&$EXTRA_PARAMS" + ) + } + + @Test + fun `Element Call url with url fragment with other params gets url extracted`() { + doTest( + url = "$VALID_CALL_URL_WITH_PARAM#?otherParam=maybe", + expectedResult = "$VALID_CALL_URL_WITH_PARAM#?otherParam=maybe&$EXTRA_PARAMS" + ) + } + + @Test + fun `Element Call url with empty fragment`() { + doTest( + url = "$VALID_CALL_URL_WITH_PARAM#", + expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS" + ) + } + + @Test + fun `Element Call url with empty fragment query`() { + doTest( + url = "$VALID_CALL_URL_WITH_PARAM#?", + expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS" + ) + } + + private fun doTest(url: String, expectedResult: String?) { + // Test direct parsing + assertThat(callIntentDataParser.parse(url)).isEqualTo(expectedResult) + + // Test embedded url, scheme 1 + val encodedUrl = URLEncoder.encode(url, "utf-8") + val urlScheme1 = "element://call?url=$encodedUrl" + assertThat(callIntentDataParser.parse(urlScheme1)).isEqualTo(expectedResult) + + // Test embedded url, scheme 2 + val urlScheme2 = "io.element.call:/?url=$encodedUrl" + assertThat(callIntentDataParser.parse(urlScheme2)).isEqualTo(expectedResult) + } + + companion object { + const val VALID_CALL_URL_WITH_PARAM = "https://call.element.io/some-actual-call?with=parameters" + const val EXTRA_PARAMS = "appPrompt=false&confineToRoom=true" + } +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt new file mode 100644 index 0000000..df14b4b --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt @@ -0,0 +1,480 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.utils + +import android.os.PowerManager +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.getSystemService +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.features.call.api.CallType +import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator +import io.element.android.features.call.impl.utils.ActiveCall +import io.element.android.features.call.impl.utils.CallState +import io.element.android.features.call.impl.utils.DefaultActiveCallManager +import io.element.android.features.call.impl.utils.DefaultCurrentCallService +import io.element.android.features.call.test.aCallNotificationData +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.room.JoinedRoom +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.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.push.api.notifications.ForegroundServiceType +import io.element.android.libraries.push.api.notifications.NotificationIdProvider +import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler +import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader +import io.element.android.services.appnavstate.test.FakeAppForegroundStateService +import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.plantTestTimber +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +class DefaultActiveCallManagerTest { + private val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL) + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `registerIncomingCall - sets the incoming call as active`() = runTest { + setupShadowPowerManager() + val notificationManagerCompat = mockk(relaxed = true) + val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat) + + assertThat(manager.activeWakeLock?.isHeld).isFalse() + assertThat(manager.activeCall.value).isNull() + + val callNotificationData = aCallNotificationData() + manager.registerIncomingCall(callNotificationData) + + assertThat(manager.activeCall.value).isEqualTo( + ActiveCall( + callType = CallType.RoomCall( + sessionId = callNotificationData.sessionId, + roomId = callNotificationData.roomId, + ), + callState = CallState.Ringing(callNotificationData) + ) + ) + + runCurrent() + + assertThat(manager.activeWakeLock?.isHeld).isTrue() + verify { notificationManagerCompat.notify(notificationId, any()) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `registerIncomingCall - when there is an already active call adds missed call notification`() = runTest { + val addMissedCallNotificationLambda = lambdaRecorder { _, _, _ -> } + val onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda) + val manager = createActiveCallManager( + onMissedCallNotificationHandler = onMissedCallNotificationHandler, + ) + + // Register existing call + val callNotificationData = aCallNotificationData() + manager.registerIncomingCall(callNotificationData) + val activeCall = manager.activeCall.value + + // Now add a new call + manager.registerIncomingCall(aCallNotificationData(roomId = A_ROOM_ID_2)) + + assertThat(manager.activeCall.value).isEqualTo(activeCall) + assertThat((manager.activeCall.value?.callType as? CallType.RoomCall)?.roomId).isNotEqualTo(A_ROOM_ID_2) + + advanceTimeBy(1) + + addMissedCallNotificationLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID), value(A_ROOM_ID_2), value(AN_EVENT_ID)) + } + + @Test + fun `incomingCallTimedOut - when there isn't an active call does nothing`() = runTest { + val addMissedCallNotificationLambda = lambdaRecorder { _, _, _ -> } + val manager = createActiveCallManager( + onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda) + ) + + manager.incomingCallTimedOut(displayMissedCallNotification = true) + + addMissedCallNotificationLambda.assertions().isNeverCalled() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `incomingCallTimedOut - when there is an active call removes it and adds a missed call notification`() = runTest { + setupShadowPowerManager() + val notificationManagerCompat = mockk(relaxed = true) + val addMissedCallNotificationLambda = lambdaRecorder { _, _, _ -> } + val manager = createActiveCallManager( + onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda), + notificationManagerCompat = notificationManagerCompat, + ) + + manager.registerIncomingCall(aCallNotificationData()) + assertThat(manager.activeCall.value).isNotNull() + assertThat(manager.activeWakeLock?.isHeld).isTrue() + + manager.incomingCallTimedOut(displayMissedCallNotification = true) + advanceTimeBy(1) + + assertThat(manager.activeCall.value).isNull() + assertThat(manager.activeWakeLock?.isHeld).isFalse() + addMissedCallNotificationLambda.assertions().isCalledOnce() + verify { notificationManagerCompat.cancel(notificationId) } + } + + @Test + fun `hungUpCall - removes existing call if the CallType matches`() = runTest { + setupShadowPowerManager() + val notificationManagerCompat = mockk(relaxed = true) + val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat) + + val notificationData = aCallNotificationData() + manager.registerIncomingCall(notificationData) + assertThat(manager.activeCall.value).isNotNull() + assertThat(manager.activeWakeLock?.isHeld).isTrue() + + manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) + assertThat(manager.activeCall.value).isNull() + assertThat(manager.activeWakeLock?.isHeld).isFalse() + + verify { notificationManagerCompat.cancel(notificationId) } + } + + @Test + fun `Decline event - Hangup on a ringing call should send a decline event`() = runTest { + setupShadowPowerManager() + val notificationManagerCompat = mockk(relaxed = true) + + val room = mockk(relaxed = true) + + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) }) + + val manager = createActiveCallManager( + matrixClientProvider = clientProvider, + notificationManagerCompat = notificationManagerCompat + ) + + val notificationData = aCallNotificationData(roomId = A_ROOM_ID) + manager.registerIncomingCall(notificationData) + + manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) + + coVerify { + room.declineCall(notificationEventId = notificationData.eventId) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `Decline event - Declining from another session should stop ringing`() = runTest { + setupShadowPowerManager() + val notificationManagerCompat = mockk(relaxed = true) + + val room = FakeJoinedRoom() + + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) }) + + val manager = createActiveCallManager( + matrixClientProvider = clientProvider, + notificationManagerCompat = notificationManagerCompat + ) + + val notificationData = aCallNotificationData(roomId = A_ROOM_ID) + manager.registerIncomingCall(notificationData) + + runCurrent() + + // Simulate declined from other session + room.baseRoom.givenDecliner(matrixClient.sessionId, notificationData.eventId) + + runCurrent() + + assertThat(manager.activeCall.value).isNull() + assertThat(manager.activeWakeLock?.isHeld).isFalse() + + verify { notificationManagerCompat.cancel(notificationId) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `Decline event - Should ignore decline for other notification events`() = runTest { + plantTestTimber() + setupShadowPowerManager() + val notificationManagerCompat = mockk(relaxed = true) + + val room = FakeJoinedRoom() + + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) }) + + val manager = createActiveCallManager( + matrixClientProvider = clientProvider, + notificationManagerCompat = notificationManagerCompat + ) + + val notificationData = aCallNotificationData(roomId = A_ROOM_ID) + manager.registerIncomingCall(notificationData) + + runCurrent() + + // Simulate declined for another notification event + room.baseRoom.givenDecliner(matrixClient.sessionId, AN_EVENT_ID_2) + + runCurrent() + + assertThat(manager.activeCall.value).isNotNull() + assertThat(manager.activeWakeLock?.isHeld).isTrue() + + verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) } + } + + @Test + fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest { + setupShadowPowerManager() + val notificationManagerCompat = mockk(relaxed = true) + val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat) + + manager.registerIncomingCall(aCallNotificationData()) + assertThat(manager.activeCall.value).isNotNull() + assertThat(manager.activeWakeLock?.isHeld).isTrue() + + manager.hungUpCall(CallType.ExternalUrl("https://example.com")) + assertThat(manager.activeCall.value).isNotNull() + assertThat(manager.activeWakeLock?.isHeld).isTrue() + + verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `joinedCall - register an ongoing call and tries sending the call notify event`() = runTest { + val notificationManagerCompat = mockk(relaxed = true) + val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat) + assertThat(manager.activeCall.value).isNull() + + manager.joinedCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID)) + assertThat(manager.activeCall.value).isEqualTo( + ActiveCall( + callType = CallType.RoomCall( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + ), + callState = CallState.InCall, + ) + ) + + runCurrent() + + verify { notificationManagerCompat.cancel(notificationId) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `observeRingingCalls - will cancel the active ringing call if the call is cancelled`() = runTest { + val room = FakeBaseRoom().apply { + givenRoomInfo(aRoomInfo()) + } + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }) + val manager = createActiveCallManager(matrixClientProvider = matrixClientProvider) + + manager.registerIncomingCall(aCallNotificationData()) + + // Call is active (the other user join the call) + room.givenRoomInfo(aRoomInfo(hasRoomCall = true)) + advanceTimeBy(1) + // Call is cancelled (the other user left the call) + room.givenRoomInfo(aRoomInfo(hasRoomCall = false)) + advanceTimeBy(1) + + assertThat(manager.activeCall.value).isNull() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `observeRingingCalls - will do nothing if either the session or the room are not found`() = runTest { + val room = FakeBaseRoom().apply { + givenRoomInfo(aRoomInfo()) + } + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(IllegalStateException("Matrix client not found")) }) + val manager = createActiveCallManager(matrixClientProvider = matrixClientProvider) + + // No matrix client + + manager.registerIncomingCall(aCallNotificationData()) + + room.givenRoomInfo(aRoomInfo(hasRoomCall = true)) + advanceTimeBy(1) + room.givenRoomInfo(aRoomInfo(hasRoomCall = false)) + advanceTimeBy(1) + + // The call should still be active + assertThat(manager.activeCall.value).isNotNull() + + // No room + client.givenGetRoomResult(A_ROOM_ID, null) + matrixClientProvider.getClient = { Result.success(client) } + + manager.registerIncomingCall(aCallNotificationData()) + + room.givenRoomInfo(aRoomInfo(hasRoomCall = true)) + advanceTimeBy(1) + room.givenRoomInfo(aRoomInfo(hasRoomCall = false)) + advanceTimeBy(1) + + // The call should still be active + assertThat(manager.activeCall.value).isNotNull() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `IncomingCall - rings no longer than expiration time`() = runTest { + setupShadowPowerManager() + val notificationManagerCompat = mockk(relaxed = true) + val clock = FakeSystemClock() + val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat, systemClock = clock) + + assertThat(manager.activeWakeLock?.isHeld).isFalse() + assertThat(manager.activeCall.value).isNull() + + val eventTimestamp = A_FAKE_TIMESTAMP + // The call should not ring more than 30 seconds after the initial event was sent + val expirationTimestamp = eventTimestamp + 30_000 + + val callNotificationData = aCallNotificationData( + timestamp = eventTimestamp, + expirationTimestamp = expirationTimestamp, + ) + + // suppose it took 10s to be notified + clock.epochMillisResult = eventTimestamp + 10_000 + manager.registerIncomingCall(callNotificationData) + + assertThat(manager.activeCall.value).isEqualTo( + ActiveCall( + callType = CallType.RoomCall( + sessionId = callNotificationData.sessionId, + roomId = callNotificationData.roomId, + ), + callState = CallState.Ringing(callNotificationData) + ) + ) + + runCurrent() + + assertThat(manager.activeWakeLock?.isHeld).isTrue() + verify { notificationManagerCompat.notify(notificationId, any()) } + + // advance by 21s it should have stopped ringing + advanceTimeBy(21_000) + runCurrent() + + verify { notificationManagerCompat.cancel(any()) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `IncomingCall - ignore expired ring lifetime`() = runTest { + setupShadowPowerManager() + val notificationManagerCompat = mockk(relaxed = true) + val clock = FakeSystemClock() + val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat, systemClock = clock) + + assertThat(manager.activeWakeLock?.isHeld).isFalse() + assertThat(manager.activeCall.value).isNull() + + val eventTimestamp = A_FAKE_TIMESTAMP + // The call should not ring more than 30 seconds after the initial event was sent + val expirationTimestamp = eventTimestamp + 30_000 + + val callNotificationData = aCallNotificationData( + timestamp = eventTimestamp, + expirationTimestamp = expirationTimestamp, + ) + + // suppose it took 35s to be notified + clock.epochMillisResult = eventTimestamp + 35_000 + manager.registerIncomingCall(callNotificationData) + + assertThat(manager.activeCall.value).isNull() + + runCurrent() + + assertThat(manager.activeWakeLock?.isHeld).isFalse() + verify(exactly = 0) { notificationManagerCompat.notify(notificationId, any()) } + } + + private fun setupShadowPowerManager() { + shadowOf(InstrumentationRegistry.getInstrumentation().targetContext.getSystemService()).apply { + setIsWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK, true) + } + } + + private fun TestScope.createActiveCallManager( + matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), + onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(), + notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true), + systemClock: FakeSystemClock = FakeSystemClock(), + ) = DefaultActiveCallManager( + context = InstrumentationRegistry.getInstrumentation().targetContext, + coroutineScope = backgroundScope, + onMissedCallNotificationHandler = onMissedCallNotificationHandler, + ringingCallNotificationCreator = RingingCallNotificationCreator( + context = InstrumentationRegistry.getInstrumentation().targetContext, + matrixClientProvider = matrixClientProvider, + imageLoaderHolder = FakeImageLoaderHolder(), + notificationBitmapLoader = FakeNotificationBitmapLoader(), + ), + notificationManagerCompat = notificationManagerCompat, + matrixClientProvider = matrixClientProvider, + defaultCurrentCallService = DefaultCurrentCallService(), + appForegroundStateService = FakeAppForegroundStateService(), + imageLoaderHolder = FakeImageLoaderHolder(), + systemClock = systemClock, + ) +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt new file mode 100644 index 0000000..95d5398 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.utils + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.call.impl.utils.DefaultCallWidgetProvider +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider +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.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.widget.FakeCallWidgetSettingsProvider +import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore +import io.element.android.services.appnavstate.api.ActiveRoomsHolder +import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultCallWidgetProviderTest { + @Test + fun `getWidget - fails if the session does not exist`() = runTest { + val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.failure(Exception("Session not found")) }) + assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue() + } + + @Test + fun `getWidget - fails if the room does not exist`() = runTest { + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, null) + } + val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) }) + assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue() + } + + @Test + fun `getWidget - fails if it can't generate the URL for the widget`() = runTest { + val room = FakeJoinedRoom( + generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.failure(Exception("Can't generate URL for widget")) } + ) + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) }) + assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue() + } + + @Test + fun `getWidget - fails if it can't get the widget driver`() = runTest { + val room = FakeJoinedRoom( + generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.success("url") }, + getWidgetDriverResult = { Result.failure(Exception("Can't get a widget driver")) } + ) + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) }) + assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue() + } + + @Test + fun `getWidget - returns a widget driver when all steps are successful`() = runTest { + val room = FakeJoinedRoom( + generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.success("url") }, + getWidgetDriverResult = { Result.success(FakeMatrixWidgetDriver()) }, + ) + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) }) + assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").getOrNull()).isNotNull() + } + + @Test + fun `getWidget - reuses the active room if possible`() = runTest { + val client = FakeMatrixClient().apply { + // No room from the client + givenGetRoomResult(A_ROOM_ID, null) + } + val activeRoomsHolder = DefaultActiveRoomsHolder().apply { + // A current active room with the same room id + addRoom( + FakeJoinedRoom( + baseRoom = FakeBaseRoom(roomId = A_ROOM_ID), + generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.success("url") }, + getWidgetDriverResult = { Result.success(FakeMatrixWidgetDriver()) }, + ) + ) + } + val provider = createProvider( + matrixClientProvider = FakeMatrixClientProvider { Result.success(client) }, + activeRoomsHolder = activeRoomsHolder + ) + assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isSuccess).isTrue() + } + + @Test + fun `getWidget - will use a custom base url if it exists`() = runTest { + val room = FakeJoinedRoom( + generateWidgetWebViewUrlResult = { _, _, _, _ -> Result.success("url") }, + getWidgetDriverResult = { Result.success(FakeMatrixWidgetDriver()) }, + ) + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val preferencesStore = InMemoryAppPreferencesStore().apply { + setCustomElementCallBaseUrl("https://custom.element.io") + } + val settingsProvider = FakeCallWidgetSettingsProvider() + val provider = createProvider( + matrixClientProvider = FakeMatrixClientProvider { Result.success(client) }, + callWidgetSettingsProvider = settingsProvider, + appPreferencesStore = preferencesStore, + ) + provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme") + + assertThat(settingsProvider.providedBaseUrls).containsExactly("https://custom.element.io") + } + + private fun createProvider( + matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(), + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), + callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider(), + activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(), + ) = DefaultCallWidgetProvider( + matrixClientsProvider = matrixClientProvider, + appPreferencesStore = appPreferencesStore, + callWidgetSettingsProvider = callWidgetSettingsProvider, + activeRoomsHolder = activeRoomsHolder, + ) +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt new file mode 100644 index 0000000..74bd1c3 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.utils + +import io.element.android.features.call.api.CallType +import io.element.android.features.call.impl.notifications.CallNotificationData +import io.element.android.features.call.impl.utils.ActiveCall +import io.element.android.features.call.impl.utils.ActiveCallManager +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeActiveCallManager( + var registerIncomingCallResult: (CallNotificationData) -> Unit = {}, + var hungUpCallResult: (CallType) -> Unit = {}, + var joinedCallResult: (CallType) -> Unit = {}, +) : ActiveCallManager { + override val activeCall = MutableStateFlow(null) + + override suspend fun registerIncomingCall(notificationData: CallNotificationData) = simulateLongTask { + registerIncomingCallResult(notificationData) + } + + override suspend fun hungUpCall(callType: CallType) = simulateLongTask { + hungUpCallResult(callType) + } + + override suspend fun joinedCall(callType: CallType) = simulateLongTask { + joinedCallResult(callType) + } + + fun setActiveCall(value: ActiveCall?) { + this.activeCall.value = value + } +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt new file mode 100644 index 0000000..11e6d9e --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.utils + +import io.element.android.features.call.impl.utils.CallWidgetProvider +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver + +class FakeCallWidgetProvider( + private val widgetDriver: FakeMatrixWidgetDriver = FakeMatrixWidgetDriver(), + private val url: String = "https://call.element.io", +) : CallWidgetProvider { + var getWidgetCalled = false + private set + + override suspend fun getWidget( + sessionId: SessionId, + roomId: RoomId, + clientId: String, + languageTag: String?, + theme: String? + ): Result { + getWidgetCalled = true + return Result.success( + CallWidgetProvider.GetWidgetResult( + driver = widgetDriver, + url = url, + ) + ) + } +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt new file mode 100644 index 0000000..6b68b15 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.utils + +import io.element.android.features.call.impl.utils.WidgetMessageInterceptor +import kotlinx.coroutines.flow.MutableSharedFlow + +class FakeWidgetMessageInterceptor : WidgetMessageInterceptor { + val sentMessages = mutableListOf() + + override val interceptedMessages = MutableSharedFlow(extraBufferCapacity = 1) + + override fun sendMessage(message: String) { + sentMessages += message + } + + fun givenInterceptedMessage(message: String) { + interceptedMessages.tryEmit(message) + } +} diff --git a/features/call/test/build.gradle.kts b/features/call/test/build.gradle.kts new file mode 100644 index 0000000..76fbf99 --- /dev/null +++ b/features/call/test/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.call.test" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.core) + + api(projects.features.call.api) + implementation(projects.features.call.impl) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrix.test) + implementation(projects.tests.testutils) +} diff --git a/features/call/test/src/main/kotlin/io/element/android/features/call/test/CallNotificationData.kt b/features/call/test/src/main/kotlin/io/element/android/features/call/test/CallNotificationData.kt new file mode 100644 index 0000000..2c7d191 --- /dev/null +++ b/features/call/test/src/main/kotlin/io/element/android/features/call/test/CallNotificationData.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.test + +import io.element.android.features.call.impl.notifications.CallNotificationData +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.UserId +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_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.A_USER_NAME + +fun aCallNotificationData( + sessionId: SessionId = A_SESSION_ID, + roomId: RoomId = A_ROOM_ID, + eventId: EventId = AN_EVENT_ID, + senderId: UserId = A_USER_ID_2, + roomName: String = A_ROOM_NAME, + senderName: String? = A_USER_NAME, + avatarUrl: String? = AN_AVATAR_URL, + notificationChannelId: String = "channel_id", + timestamp: Long = 0L, + expirationTimestamp: Long = 30_000L, + textContent: String? = null, +): CallNotificationData = CallNotificationData( + sessionId = sessionId, + roomId = roomId, + eventId = eventId, + senderId = senderId, + roomName = roomName, + senderName = senderName, + avatarUrl = avatarUrl, + notificationChannelId = notificationChannelId, + timestamp = timestamp, + expirationTimestamp = expirationTimestamp, + textContent = textContent, +) diff --git a/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeCurrentCallService.kt b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeCurrentCallService.kt new file mode 100644 index 0000000..45f5277 --- /dev/null +++ b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeCurrentCallService.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.test + +import io.element.android.features.call.api.CurrentCall +import io.element.android.features.call.api.CurrentCallService +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeCurrentCallService( + override val currentCall: MutableStateFlow = MutableStateFlow(CurrentCall.None), +) : CurrentCallService diff --git a/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt new file mode 100644 index 0000000..fdf3ca5 --- /dev/null +++ b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.call.test + +import io.element.android.features.call.api.CallType +import io.element.android.features.call.api.ElementCallEntryPoint +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeElementCallEntryPoint( + var startCallResult: (CallType) -> Unit = { lambdaError() }, + var handleIncomingCallResult: ( + CallType.RoomCall, + EventId, + UserId, + String?, + String?, + String?, + String, + String?, + ) -> Unit = { _, _, _, _, _, _, _, _ -> lambdaError() } +) : ElementCallEntryPoint { + override fun startCall(callType: CallType) { + startCallResult(callType) + } + + override suspend fun handleIncomingCall( + callType: CallType.RoomCall, + eventId: EventId, + senderId: UserId, + roomName: String?, + senderName: String?, + avatarUrl: String?, + timestamp: Long, + expirationTimestamp: Long, + notificationChannelId: String, + textContent: String?, + ) { + handleIncomingCallResult( + callType, + eventId, + senderId, + roomName, + senderName, + avatarUrl, + notificationChannelId, + textContent, + ) + } +} diff --git a/features/createroom/api/build.gradle.kts b/features/createroom/api/build.gradle.kts new file mode 100644 index 0000000..b4d7d2a --- /dev/null +++ b/features/createroom/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +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 0000000..1c6a9f0 --- /dev/null +++ b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 createNode( + parentNode: Node, + buildContext: BuildContext, + callback: Callback, + ): Node + + interface Callback : Plugin { + fun onRoomCreated(roomId: RoomId) + } +} diff --git a/features/createroom/impl/build.gradle.kts b/features/createroom/impl/build.gradle.kts new file mode 100644 index 0000000..1adaac1 --- /dev/null +++ b/features/createroom/impl/build.gradle.kts @@ -0,0 +1,57 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.createroom.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + 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.api) + implementation(projects.libraries.mediapickers.api) + implementation(projects.libraries.mediaupload.api) + implementation(projects.libraries.permissions.api) + implementation(projects.libraries.usersearch.impl) + implementation(projects.services.analytics.api) + implementation(libs.coil.compose) + implementation(projects.libraries.featureflag.api) + implementation(projects.features.invitepeople.api) + api(projects.features.createroom.api) + + testCommonDependencies(libs, true) + testImplementation(projects.services.analytics.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.mediapickers.test) + testImplementation(projects.libraries.mediaupload.test) + testImplementation(projects.libraries.permissions.test) + testImplementation(projects.libraries.usersearch.test) + testImplementation(projects.features.startchat.test) + testImplementation(projects.libraries.featureflag.test) +} 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 0000000..7fea6fc --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.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.replace +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.createroom.api.CreateRoomEntryPoint +import io.element.android.features.createroom.impl.addpeople.AddPeopleNode +import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.callback +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) +@AssistedInject +class CreateRoomFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.ConfigureRoom, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +) { + private val callback: CreateRoomEntryPoint.Callback = callback() + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.ConfigureRoom -> { + val callback = object : ConfigureRoomNode.Callback { + override fun onCreateRoomSuccess(roomId: RoomId) { + backstack.replace(NavTarget.AddPeople(roomId)) + } + } + createNode(buildContext, plugins = listOf(callback)) + } + is NavTarget.AddPeople -> { + val inputs = AddPeopleNode.Inputs(navTarget.roomId) + val callback: AddPeopleNode.Callback = object : AddPeopleNode.Callback { + override fun onFinish() { + callback.onRoomCreated(navTarget.roomId) + } + } + createNode(buildContext, plugins = listOf(inputs, callback)) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView() + } + + sealed interface NavTarget : Parcelable { + @Parcelize + data object ConfigureRoom : NavTarget + + @Parcelize + data class AddPeople(val roomId: RoomId) : NavTarget + } +} 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 0000000..2261d29 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.createroom.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.createroom.api.CreateRoomEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope + +@ContributesBinding(SessionScope::class) +class DefaultCreateRoomEntryPoint : CreateRoomEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: CreateRoomEntryPoint.Callback, + ): Node { + return parentNode.createNode(buildContext, listOf(callback)) + } +} 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 0000000..2e5c16e --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleNode.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.invitepeople.api.InvitePeoplePresenter +import io.element.android.features.invitepeople.api.InvitePeopleRenderer +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId + +@ContributesNode(SessionScope::class) +@AssistedInject +class AddPeopleNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + invitePeoplePresenterFactory: InvitePeoplePresenter.Factory, + private val invitePeopleRenderer: InvitePeopleRenderer, +) : Node(buildContext, plugins = plugins) { + data class Inputs( + val roomId: RoomId, + ) : NodeInputs + + interface Callback : Plugin { + fun onFinish() + } + + private val callback: Callback = callback() + private val roomId = inputs().roomId + private val invitePeoplePresenter = invitePeoplePresenterFactory.create( + joinedRoom = null, + roomId = roomId, + ) + + @Composable + override fun View(modifier: Modifier) { + val state = invitePeoplePresenter.present() + AddPeopleView( + state = state, + onFinish = callback::onFinish, + ) { + invitePeopleRenderer.Render(state, Modifier) + } + } +} 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 0000000..0a7309a --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.createroom.impl.addpeople + +import androidx.compose.foundation.layout.PaddingValues +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.createroom.impl.R +import io.element.android.features.invitepeople.api.InvitePeopleEvents +import io.element.android.features.invitepeople.api.InvitePeopleState +import io.element.android.features.invitepeople.api.InvitePeopleStateProvider +import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun AddPeopleView( + state: InvitePeopleState, + onFinish: () -> Unit, + modifier: Modifier = Modifier, + invitePeopleView: @Composable () -> Unit, +) { + HeaderFooterPage( + modifier = modifier, + contentPadding = PaddingValues(0.dp), + topBar = { + AddPeopleTopBar(onSkipClick = onFinish) + }, + footer = { + Button( + text = stringResource(CommonStrings.action_finish), + onClick = { + state.eventSink(InvitePeopleEvents.SendInvites) + onFinish() + }, + enabled = state.canInvite, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) + }, + content = invitePeopleView + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AddPeopleTopBar( + onSkipClick: () -> Unit, +) { + TopAppBar( + titleStr = stringResource(R.string.screen_create_room_add_people_title), + actions = { + TextButton( + text = stringResource(CommonStrings.action_skip), + onClick = onSkipClick, + ) + } + ) +} + +@PreviewsDayNight +@Composable +internal fun AddPeopleViewPreview(@PreviewParameter(InvitePeopleStateProvider::class) state: InvitePeopleState) = ElementPreview { + AddPeopleView( + state = state, + invitePeopleView = {}, + onFinish = {}, + ) +} 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 0000000..fed8404 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.createroom.impl.configureroom + +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 RoomVisibilityChanged(val visibilityItem: RoomVisibilityItem) : ConfigureRoomEvents + data class RoomAccessChanged(val roomAccess: RoomAccessItem) : ConfigureRoomEvents + data class RoomAddressChanged(val roomAddress: String) : ConfigureRoomEvents + data object CreateRoom : ConfigureRoomEvents + data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents + data 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 0000000..43ceee3 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +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) +@AssistedInject +class ConfigureRoomNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: ConfigureRoomPresenter, + private val analyticsService: AnalyticsService, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onCreateRoomSuccess(roomId: RoomId) + } + + init { + lifecycle.subscribe( + onResume = { + analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.CreateRoom)) + } + ) + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + ConfigureRoomView( + state = state, + modifier = modifier, + onBackClick = this::navigateUp, + onCreateRoomSuccess = callback::onCreateRoomSuccess, + ) + } +} 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 0000000..c5d68e1 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.createroom.impl.configureroom + +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.core.net.toUri +import dev.zacsweers.metro.Inject +import im.vector.app.features.analytics.plan.CreatedRoom +import io.element.android.libraries.architecture.AsyncAction +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.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +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.room.alias.RoomAliasHelper +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility +import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity +import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidityEffect +import io.element.android.libraries.mediapickers.api.PickerProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber +import kotlin.jvm.optionals.getOrDefault + +@Inject +class ConfigureRoomPresenter( + private val dataStore: CreateRoomConfigStore, + private val matrixClient: MatrixClient, + private val mediaPickerProvider: PickerProvider, + private val mediaPreProcessor: MediaPreProcessor, + private val analyticsService: AnalyticsService, + permissionsPresenterFactory: PermissionsPresenter.Factory, + private val featureFlagService: FeatureFlagService, + private val roomAliasHelper: RoomAliasHelper, + private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, +) : Presenter { + private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA) + private var pendingPermissionRequest = false + + @Composable + override fun present(): ConfigureRoomState { + val cameraPermissionState = cameraPermissionPresenter.present() + val createRoomConfig by dataStore.getCreateRoomConfigFlow().collectAsState(CreateRoomConfig()) + val homeserverName = remember { matrixClient.userIdServerName() } + val isKnockFeatureEnabled by remember { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock) + }.collectAsState(initial = false) + val roomAddressValidity = remember { + mutableStateOf(RoomAddressValidity.Unknown) + } + + 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.avatarUri) { + derivedStateOf { + listOfNotNull( + AvatarAction.TakePhoto, + AvatarAction.ChoosePhoto, + AvatarAction.Remove.takeIf { createRoomConfig.avatarUri != null }, + ).toImmutableList() + } + } + + LaunchedEffect(cameraPermissionState.permissionGranted) { + if (cameraPermissionState.permissionGranted && pendingPermissionRequest) { + pendingPermissionRequest = false + cameraPhotoPicker.launch() + } + } + + RoomAddressValidityEffect( + client = matrixClient, + roomAliasHelper = roomAliasHelper, + newRoomAddress = createRoomConfig.roomVisibility.roomAddress().getOrDefault(""), + knownRoomAddress = null, + ) { newRoomAddressValidity -> + roomAddressValidity.value = newRoomAddressValidity + } + + val localCoroutineScope = rememberCoroutineScope() + val createRoomAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + + fun createRoom(config: CreateRoomConfig) { + createRoomAction.value = AsyncAction.Uninitialized + localCoroutineScope.createRoom(config, createRoomAction) + } + + fun handleEvent(event: ConfigureRoomEvents) { + when (event) { + is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name) + is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic) + is ConfigureRoomEvents.RoomVisibilityChanged -> dataStore.setRoomVisibility(event.visibilityItem) + is ConfigureRoomEvents.RoomAccessChanged -> dataStore.setRoomAccess(event.roomAccess) + is ConfigureRoomEvents.RoomAddressChanged -> dataStore.setRoomAddress(event.roomAddress) + is ConfigureRoomEvents.CreateRoom -> createRoom(createRoomConfig) + is ConfigureRoomEvents.HandleAvatarAction -> { + when (event.action) { + AvatarAction.ChoosePhoto -> galleryImagePicker.launch() + AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) { + cameraPhotoPicker.launch() + } else { + pendingPermissionRequest = true + cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) + } + AvatarAction.Remove -> dataStore.setAvatarUri(uri = null) + } + } + + ConfigureRoomEvents.CancelCreateRoom -> createRoomAction.value = AsyncAction.Uninitialized + } + } + + return ConfigureRoomState( + isKnockFeatureEnabled = isKnockFeatureEnabled, + config = createRoomConfig, + avatarActions = avatarActions, + createRoomAction = createRoomAction.value, + cameraPermissionState = cameraPermissionState, + homeserverName = homeserverName, + roomAddressValidity = roomAddressValidity.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.createRoom( + config: CreateRoomConfig, + createRoomAction: MutableState> + ) = launch { + suspend { + val avatarUrl = config.avatarUri?.let { uploadAvatar(it.toUri()) } + val params = if (config.roomVisibility is RoomVisibilityState.Public) { + CreateRoomParameters( + name = config.roomName, + topic = config.topic, + isEncrypted = false, + isDirect = false, + visibility = RoomVisibility.Public, + joinRuleOverride = config.roomVisibility.roomAccess.toJoinRule(), + preset = RoomPreset.PUBLIC_CHAT, + invite = config.invites.map { it.userId }, + avatar = avatarUrl, + roomAliasName = config.roomVisibility.roomAddress() + ) + } else { + CreateRoomParameters( + name = config.roomName, + topic = config.topic, + isEncrypted = config.roomVisibility is RoomVisibilityState.Private, + isDirect = false, + visibility = RoomVisibility.Private, + historyVisibilityOverride = RoomHistoryVisibility.Invited, + preset = RoomPreset.PRIVATE_CHAT, + invite = config.invites.map { it.userId }, + avatar = avatarUrl, + ) + } + matrixClient.createRoom(params) + .onFailure { failure -> + Timber.e(failure, "Failed to create room") + } + .onSuccess { + dataStore.clearCachedData() + analyticsService.capture(CreatedRoom(isDM = false)) + } + .getOrThrow() + }.runCatchingUpdatingState(createRoomAction) + } + + private suspend fun uploadAvatar(avatarUri: Uri): String { + val preprocessed = mediaPreProcessor.process( + uri = avatarUri, + mimeType = MimeTypes.Jpeg, + deleteOriginal = false, + mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), + ).getOrThrow() + val byteArray = preprocessed.file.readBytes() + return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray).getOrThrow() + } +} 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 0000000..127aa20 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.createroom.impl.configureroom + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity +import io.element.android.libraries.permissions.api.PermissionsState +import kotlinx.collections.immutable.ImmutableList + +data class ConfigureRoomState( + val isKnockFeatureEnabled: Boolean, + val config: CreateRoomConfig, + val avatarActions: ImmutableList, + val createRoomAction: AsyncAction, + val cameraPermissionState: PermissionsState, + val roomAddressValidity: RoomAddressValidity, + val homeserverName: String, + val eventSink: (ConfigureRoomEvents) -> Unit +) { + val isValid: Boolean = config.roomName?.isNotEmpty() == true && + (config.roomVisibility is RoomVisibilityState.Private || roomAddressValidity == RoomAddressValidity.Valid) +} 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 0000000..7f760b4 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.createroom.impl.configureroom + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity +import io.element.android.libraries.permissions.api.PermissionsState +import io.element.android.libraries.permissions.api.aPermissionsState +import kotlinx.collections.immutable.toImmutableList + +open class ConfigureRoomStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aConfigureRoomState(), + aConfigureRoomState( + isKnockFeatureEnabled = false, + 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 = aMatrixUserList().toImmutableList(), + roomVisibility = RoomVisibilityState.Public( + roomAddress = RoomAddress.AutoFilled("Room-101"), + roomAccess = RoomAccess.Knocking, + ), + ), + ), + aConfigureRoomState( + 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 = aMatrixUserList().toImmutableList(), + roomVisibility = RoomVisibilityState.Public( + roomAddress = RoomAddress.AutoFilled("Room-101"), + roomAccess = RoomAccess.Knocking, + ), + ), + ), + aConfigureRoomState( + 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", + roomVisibility = RoomVisibilityState.Public( + roomAddress = RoomAddress.AutoFilled("Room-101"), + roomAccess = RoomAccess.Knocking, + ), + ), + roomAddressValidity = RoomAddressValidity.NotAvailable, + ), + aConfigureRoomState( + 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", + roomVisibility = RoomVisibilityState.Public( + roomAddress = RoomAddress.AutoFilled("Room-101"), + roomAccess = RoomAccess.Knocking, + ), + ), + roomAddressValidity = RoomAddressValidity.InvalidSymbols, + ), + aConfigureRoomState( + 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", + roomVisibility = RoomVisibilityState.Public( + roomAddress = RoomAddress.AutoFilled("Room-101"), + roomAccess = RoomAccess.Knocking, + ), + ), + roomAddressValidity = RoomAddressValidity.Valid, + ), + ) +} + +fun aConfigureRoomState( + config: CreateRoomConfig = CreateRoomConfig(), + isKnockFeatureEnabled: Boolean = true, + avatarActions: List = emptyList(), + createRoomAction: AsyncAction = AsyncAction.Uninitialized, + cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false), + homeserverName: String = "matrix.org", + roomAddressValidity: RoomAddressValidity = RoomAddressValidity.Valid, + eventSink: (ConfigureRoomEvents) -> Unit = { }, +) = ConfigureRoomState( + config = config, + isKnockFeatureEnabled = isKnockFeatureEnabled, + avatarActions = avatarActions.toImmutableList(), + createRoomAction = createRoomAction, + cameraPermissionState = cameraPermissionState, + homeserverName = homeserverName, + roomAddressValidity = roomAddressValidity, + eventSink = 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 0000000..6715a7c --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.createroom.impl.configureroom + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.createroom.impl.R +import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage +import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom +import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight +import io.element.android.libraries.designsystem.theme.components.ListItem +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.TextField +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.UnsavedAvatar +import io.element.android.libraries.matrix.ui.room.address.RoomAddressField +import io.element.android.libraries.permissions.api.PermissionsView +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun ConfigureRoomView( + state: ConfigureRoomState, + onBackClick: () -> Unit, + onCreateRoomSuccess: (RoomId) -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + val isAvatarActionsSheetVisible = remember { mutableStateOf(false) } + + fun onAvatarClick() { + focusManager.clearFocus() + isAvatarActionsSheetVisible.value = true + } + + Scaffold( + modifier = modifier.clearFocusOnTap(focusManager), + topBar = { + ConfigureRoomToolbar( + isNextActionEnabled = state.isValid, + onBackClick = onBackClick, + onNextClick = { + focusManager.clearFocus() + state.eventSink(ConfigureRoomEvents.CreateRoom) + }, + ) + } + ) { 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 = ::onAvatarClick, + onChangeRoomName = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) }, + ) + RoomTopic( + modifier = Modifier.padding(horizontal = 16.dp), + topic = state.config.topic.orEmpty(), + onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) }, + ) + RoomVisibilityOptions( + selected = when (state.config.roomVisibility) { + is RoomVisibilityState.Private -> RoomVisibilityItem.Private + is RoomVisibilityState.Public -> RoomVisibilityItem.Public + }, + onOptionClick = { + focusManager.clearFocus() + state.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(it)) + }, + ) + if (state.config.roomVisibility is RoomVisibilityState.Public && state.isKnockFeatureEnabled) { + RoomAccessOptions( + selected = when (state.config.roomVisibility.roomAccess) { + RoomAccess.Anyone -> RoomAccessItem.Anyone + RoomAccess.Knocking -> RoomAccessItem.AskToJoin + }, + onOptionClick = { + focusManager.clearFocus() + state.eventSink(ConfigureRoomEvents.RoomAccessChanged(it)) + }, + ) + RoomAddressField( + modifier = Modifier.padding(horizontal = 16.dp), + address = state.config.roomVisibility.roomAddress.value, + homeserverName = state.homeserverName, + addressValidity = state.roomAddressValidity, + onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) }, + label = stringResource(R.string.screen_create_room_room_address_section_title), + supportingText = stringResource(R.string.screen_create_room_room_address_section_footer), + ) + Spacer(Modifier) + } + } + } + + AvatarActionBottomSheet( + actions = state.avatarActions, + isVisible = isAvatarActionsSheetVisible.value, + onDismiss = { isAvatarActionsSheetVisible.value = false }, + onSelectAction = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) } + ) + + AsyncActionView( + async = state.createRoomAction, + progressDialog = { + AsyncActionViewDefaults.ProgressDialog( + progressText = stringResource(CommonStrings.common_creating_room), + ) + }, + onSuccess = { onCreateRoomSuccess(it) }, + errorMessage = { stringResource(R.string.screen_create_room_error_creating_room) }, + onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom) }, + onErrorDismiss = { state.eventSink(ConfigureRoomEvents.CancelCreateRoom) }, + ) + + PermissionsView( + state = state.cameraPermissionState, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConfigureRoomToolbar( + isNextActionEnabled: Boolean, + onBackClick: () -> Unit, + onNextClick: () -> Unit, +) { + TopAppBar( + titleStr = stringResource(R.string.screen_create_room_title), + navigationIcon = { BackButton(onClick = onBackClick) }, + actions = { + TextButton( + text = stringResource(CommonStrings.action_create), + enabled = isNextActionEnabled, + onClick = onNextClick, + ) + } + ) +} + +@Composable +private fun RoomNameWithAvatar( + avatarUri: String?, + roomName: String, + onAvatarClick: () -> Unit, + onChangeRoomName: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val a11yAvatar = stringResource(CommonStrings.a11y_room_avatar) + UnsavedAvatar( + avatarUri = avatarUri, + avatarSize = AvatarSize.EditRoomDetails, + avatarType = AvatarType.Room(), + modifier = Modifier + .clickable( + onClick = onAvatarClick, + onClickLabel = stringResource(CommonStrings.action_open_context_menu), + ) + .clearAndSetSemantics { + contentDescription = a11yAvatar + }, + ) + + TextField( + label = stringResource(R.string.screen_create_room_room_name_label), + value = roomName, + placeholder = stringResource(CommonStrings.common_room_name_placeholder), + singleLine = true, + onValueChange = onChangeRoomName, + ) + } +} + +@Composable +private fun RoomTopic( + topic: String, + onTopicChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + TextField( + modifier = modifier, + label = stringResource(R.string.screen_create_room_topic_label), + value = topic, + onValueChange = onTopicChange, + maxLines = 3, + supportingText = stringResource(CommonStrings.common_topic_placeholder), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + ), + ) +} + +@Composable +private fun ConfigureRoomOptions( + title: String, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier.selectableGroup() + ) { + Text( + text = title, + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + modifier = Modifier.padding(horizontal = 16.dp), + ) + content() + } +} + +@Composable +private fun RoomVisibilityOptions( + selected: RoomVisibilityItem, + onOptionClick: (RoomVisibilityItem) -> Unit, + modifier: Modifier = Modifier, +) { + ConfigureRoomOptions( + title = stringResource(R.string.screen_create_room_room_visibility_section_title), + modifier = modifier, + ) { + RoomVisibilityItem.entries.forEach { item -> + val isSelected = item == selected + ListItem( + leadingContent = ListItemContent.Custom { + RoundedIconAtom( + size = RoundedIconAtomSize.Big, + resourceId = item.icon, + tint = if (isSelected) ElementTheme.colors.iconPrimary else ElementTheme.colors.iconSecondary, + ) + }, + headlineContent = { Text(text = stringResource(item.title)) }, + supportingContent = { Text(text = stringResource(item.description)) }, + trailingContent = ListItemContent.RadioButton(selected = isSelected), + onClick = { onOptionClick(item) }, + ) + } + } +} + +@Composable +private fun RoomAccessOptions( + selected: RoomAccessItem, + onOptionClick: (RoomAccessItem) -> Unit, + modifier: Modifier = Modifier, +) { + ConfigureRoomOptions( + title = stringResource(R.string.screen_create_room_room_access_section_header), + modifier = modifier, + ) { + RoomAccessItem.entries.forEach { item -> + ListItem( + headlineContent = { Text(text = stringResource(item.title)) }, + supportingContent = { Text(text = stringResource(item.description)) }, + trailingContent = ListItemContent.RadioButton(selected = item == selected), + onClick = { onOptionClick(item) }, + ) + } + } +} + +@PreviewWithLargeHeight +@Composable +internal fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = + ElementPreviewLight { ContentToPreview(state) } + +@PreviewWithLargeHeight +@Composable +internal fun ConfigureRoomViewDarkPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = + ElementPreviewDark { ContentToPreview(state) } + +@ExcludeFromCoverage +@Composable +private fun ContentToPreview(state: ConfigureRoomState) { + ConfigureRoomView( + state = state, + onBackClick = {}, + onCreateRoomSuccess = {}, + ) +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt new file mode 100644 index 0000000..b9d05a1 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.createroom.impl.configureroom + +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: String? = null, + val invites: ImmutableList = persistentListOf(), + val roomVisibility: RoomVisibilityState = RoomVisibilityState.Private, +) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt new file mode 100644 index 0000000..5e8637d --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.createroom.impl.configureroom + +import android.net.Uri +import dev.zacsweers.metro.Inject +import io.element.android.libraries.androidutils.file.safeDelete +import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.getAndUpdate +import java.io.File + +@Inject +class CreateRoomConfigStore( + private val roomAliasHelper: RoomAliasHelper, +) { + private val createRoomConfigFlow: MutableStateFlow = MutableStateFlow(CreateRoomConfig()) + + private var cachedAvatarUri: Uri? = null + set(value) { + field?.path?.let { File(it) }?.safeDelete() + field = value + } + + fun getCreateRoomConfigFlow(): StateFlow = createRoomConfigFlow + + fun setRoomName(roomName: String) { + createRoomConfigFlow.getAndUpdate { config -> + val newVisibility = when (config.roomVisibility) { + is RoomVisibilityState.Public -> { + val roomAddress = config.roomVisibility.roomAddress + if (roomAddress is RoomAddress.AutoFilled || roomName.isEmpty()) { + val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(roomName) + config.roomVisibility.copy( + roomAddress = RoomAddress.AutoFilled(roomAliasName), + ) + } else { + config.roomVisibility + } + } + else -> config.roomVisibility + } + config.copy( + roomName = roomName.takeIf { it.isNotEmpty() }, + roomVisibility = newVisibility, + ) + } + } + + fun setTopic(topic: String) { + createRoomConfigFlow.getAndUpdate { config -> + config.copy(topic = topic.takeIf { it.isNotEmpty() }) + } + } + + fun setAvatarUri(uri: Uri?, cached: Boolean = false) { + cachedAvatarUri = uri.takeIf { cached } + createRoomConfigFlow.getAndUpdate { config -> + config.copy(avatarUri = uri?.toString()) + } + } + + fun setRoomVisibility(visibility: RoomVisibilityItem) { + createRoomConfigFlow.getAndUpdate { config -> + config.copy( + roomVisibility = when (visibility) { + RoomVisibilityItem.Private -> RoomVisibilityState.Private + RoomVisibilityItem.Public -> { + val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(config.roomName.orEmpty()) + RoomVisibilityState.Public( + roomAddress = RoomAddress.AutoFilled(roomAliasName), + roomAccess = RoomAccess.Anyone, + ) + } + } + ) + } + } + + fun setRoomAddress(address: String) { + createRoomConfigFlow.getAndUpdate { config -> + config.copy( + roomVisibility = when (config.roomVisibility) { + is RoomVisibilityState.Public -> { + val sanitizedAddress = address.lowercase() + config.roomVisibility.copy(roomAddress = RoomAddress.Edited(sanitizedAddress)) + } + else -> config.roomVisibility + } + ) + } + } + + fun setRoomAccess(access: RoomAccessItem) { + createRoomConfigFlow.getAndUpdate { config -> + config.copy( + roomVisibility = when (config.roomVisibility) { + is RoomVisibilityState.Public -> { + when (access) { + RoomAccessItem.Anyone -> config.roomVisibility.copy(roomAccess = RoomAccess.Anyone) + RoomAccessItem.AskToJoin -> config.roomVisibility.copy(roomAccess = RoomAccess.Knocking) + } + } + else -> config.roomVisibility + } + ) + } + } + + fun clearCachedData() { + cachedAvatarUri = null + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccess.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccess.kt new file mode 100644 index 0000000..9d8167c --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccess.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.createroom.impl.configureroom + +import io.element.android.libraries.matrix.api.room.join.JoinRule + +enum class RoomAccess { + Anyone, + Knocking +} + +fun RoomAccess.toJoinRule(): JoinRule? { + return when (this) { + RoomAccess.Anyone -> null + RoomAccess.Knocking -> JoinRule.Knock + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt new file mode 100644 index 0000000..2d37be9 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.createroom.impl.configureroom + +import androidx.annotation.StringRes +import io.element.android.features.createroom.impl.R + +enum class RoomAccessItem( + @StringRes val title: Int, + @StringRes val description: Int +) { + Anyone( + title = R.string.screen_create_room_room_access_section_anyone_option_title, + description = R.string.screen_create_room_room_access_section_anyone_option_description, + ), + AskToJoin( + title = R.string.screen_create_room_room_access_section_knocking_option_title, + description = R.string.screen_create_room_room_access_section_knocking_option_description, + ), +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAddress.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAddress.kt new file mode 100644 index 0000000..a8e4e39 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAddress.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.createroom.impl.configureroom + +sealed class RoomAddress(open val value: String) { + data class AutoFilled(override val value: String) : RoomAddress(value) + data class Edited(override val value: String) : RoomAddress(value) +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt new file mode 100644 index 0000000..b92dee4 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.createroom.impl.configureroom + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import io.element.android.features.createroom.impl.R +import io.element.android.libraries.designsystem.icons.CompoundDrawables + +enum class RoomVisibilityItem( + @DrawableRes val icon: Int, + @StringRes val title: Int, + @StringRes val description: Int +) { + Private( + icon = CompoundDrawables.ic_compound_lock, + title = R.string.screen_create_room_private_option_title, + description = R.string.screen_create_room_private_option_description, + ), + Public( + icon = CompoundDrawables.ic_compound_public, + title = R.string.screen_create_room_public_option_title, + description = R.string.screen_create_room_public_option_description, + ) +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityState.kt new file mode 100644 index 0000000..82af5a5 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityState.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.createroom.impl.configureroom + +import java.util.Optional + +sealed interface RoomVisibilityState { + data object Private : RoomVisibilityState + + data class Public( + val roomAddress: RoomAddress, + val roomAccess: RoomAccess, + ) : RoomVisibilityState + + fun roomAddress(): Optional { + return when (this) { + is Private -> Optional.empty() + is Public -> Optional.of(roomAddress.value) + } + } +} diff --git a/features/createroom/impl/src/main/res/values-be/translations.xml b/features/createroom/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..f5d6a23 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,17 @@ + + + "Новы пакой" + "Запрасіць карыстальнікаў" + "Пры стварэнні пакоя адбылася памылка" + "Толькі запрошаныя людзі могуць атрымаць доступ да гэтага пакоя. Усе паведамленні абаронены end-to-end шыфраваннем." + "Прыватны пакой" + "Любы можа знайсці гэты пакой. +Вы можаце змяніць гэта ў любы час у наладах пакоя." + "Публічны пакой" + "Хто заўгодна" + "Доступ у пакой" + "Папрасіце далучыцца" + "Назва пакоя" + "Стварыце пакой" + "Тэма (неабавязкова)" + diff --git a/features/createroom/impl/src/main/res/values-bg/translations.xml b/features/createroom/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..249058b --- /dev/null +++ b/features/createroom/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,18 @@ + + + "Нова стая" + "Поканване на хора" + "Възникна грешка при създаването на стаята" + "Само поканени хора имат достъп до тази стая. Всички съобщения са шифровани от край до край." + "Частна стая" + "Всеки може да намери тази стая. +Можете да промените това по всяко време в настройките на стаята." + "Общодостъпна стая" + "Всеки може да се присъедини към тази стая" + "Всеки" + "За да бъде тази стая видима в директорията на общодостъпните стаи, ще ви е необходим адрес на стаята." + "Име на стаята" + "Видимост на стаята" + "Създаване на стая" + "Тема за разговор (незадължително)" + 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 0000000..e19cfbc --- /dev/null +++ b/features/createroom/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,22 @@ + + + "Nová místnost" + "Pozvat přátele" + "Při vytváření místnosti došlo k chybě" + "Do této místnosti mají přístup pouze pozvaní lidé. Všechny zprávy jsou koncově šifrovány." + "Soukromá místnost" + "Tuto místnost může najít kdokoli. +To můžete kdykoli změnit v nastavení místnosti." + "Veřejná místnost" + "Do této místnosti může vstoupit kdokoli" + "Kdokoliv" + "Přístup do místnosti" + "Kdokoli může požádat o vstup do místnosti, ale správce nebo moderátor bude muset žádost přijmout" + "Požádat o připojení" + "Aby byla tato místnost viditelná v adresáři veřejných místností, budete potřebovat adresu místnosti." + "Adresa místnosti" + "Název místnosti" + "Viditelnost místnosti" + "Vytvořit místnost" + "Téma (nepovinné)" + diff --git a/features/createroom/impl/src/main/res/values-cy/translations.xml b/features/createroom/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..5216801 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,22 @@ + + + "Ystafell newydd" + "Gwahodd pobl" + "Bu gwall wrth greu\'r ystafell" + "Dim ond pobl wahoddwyd all gael mynediad i\'r ystafell hon. Mae pob neges wedi\'i hamgryptio o\'r dechrau i\'r diwedd." + "Ystafell breifat" + "Gall unrhyw un ddod o hyd i\'r ystafell hon. +Gallwch newid hyn unrhyw bryd yng ngosodiadau ystafell." + "Ystafell gyhoeddus" + "Gall unrhyw un ymuno â\'r ystafell hon" + "Unrhyw un" + "Mynediad i\'r Ystafell" + "Gall unrhyw un ofyn am gael ymuno â\'r ystafell ond bydd rhaid i weinyddwr neu gymedrolwr dderbyn y cais" + "Gofyn i gael ymuno" + "Er mwyn i\'r ystafell hon fod yn weladwy yn y cyfeiriadur ystafelloedd cyhoeddus, bydd angen cyfeiriad ystafell arnoch." + "Cyfeiriad yr ystafell" + "Enw\'r ystafell" + "Gwelededd yr ystafell" + "Creu ystafell" + "Pwnc (dewisol)" + diff --git a/features/createroom/impl/src/main/res/values-da/translations.xml b/features/createroom/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..422aae0 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,21 @@ + + + "Nyt rum" + "Invitér andre" + "Der opstod en fejl ved oprettelsen af rummet" + "Kun inviterede personer kan få adgang til dette rum. Alle meddelelser er ende-til-ende krypteret." + "Privat rum" + "Alle kan finde dette rum. +Du kan ændre dette når som helst i rummets indstillinger." + "Offentligt rum" + "Alle kan deltage i dette rum" + "Enhver" + "Adgang til rummet" + "Alle kan bede om at deltage i rummet, men en administrator eller en moderator skal acceptere anmodningen" + "Spørg om at deltage" + "Hvis dette rum skal være synligt i det offentlige register, skal du bruge en rum-adresse." + "Navn på rum" + "Rummets synlighed" + "Opret et rum" + "Emne (valgfrit)" + 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 0000000..9c48001 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,22 @@ + + + "Neuer Chat" + "Nutzer einladen" + "Beim Erstellen des Chats ist ein Fehler aufgetreten" + "Nur eingeladene Personen haben Zutritt zu diesem Chat. Alle Nachrichten sind Ende-zu-Ende verschlüsselt." + "Privater Chat" + "Jeder kann diesen Chat finden. +Du kannst dies jederzeit in den Einstellungen des Chats ändern." + "Öffentlicher Chat" + "Jeder darf diesem Chat beitreten" + "Jeder" + "Chat Zugang" + "Jeder kann den Beitritt zum Chat erbitten, aber ein Admin oder Moderator muss die Anfrage akzeptieren." + "Beitritt beantragen" + "Du benötigst eine Chat-Adresse, damit dieser Chat im öffentlichen Verzeichnis sichtbar ist." + "Chatroom Adresse" + "Chat-Name" + " Sichtbarkeit des Chats" + "Chat erstellen" + "Thema (optional)" + diff --git a/features/createroom/impl/src/main/res/values-el/translations.xml b/features/createroom/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..37ccd49 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,22 @@ + + + "Νέα αίθουσα" + "Πρόσκληση ατόμων" + "Προέκυψε σφάλμα κατά τη δημιουργία της αίθουσας" + "Μόνο τα άτομα που έχουν προσκληθεί μπορούν να έχουν πρόσβαση σε αυτή την αίθουσα. Όλα τα μηνύματα είναι κρυπτογραφημένα από άκρο σε άκρο." + "Ιδιωτική αίθουσα" + "Ο καθένας μπορεί να βρει αυτή την αίθουσα. +Αυτό μπορείτε να το αλλάξετε ανά πάσα στιγμή στις ρυθμίσεις της αίθουσας." + "Δημόσια αίθουσα" + "Οποιοσδήποτε μπορεί να συμμετάσχει σε αυτή την αίθουσα" + "Οποιοσδήποτε" + "Πρόσβαση στην Αίθουσα" + "Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στην αίθουσα, αλλά ένας διαχειριστής ή ένας συντονιστής θα πρέπει να αποδεχτεί το αίτημα" + "Αίτημα συμμετοχής" + "Για να είναι ορατή αυτή η αίθουσα στον δημόσιο κατάλογο αιθουσών, θα χρειαστείτε μια διεύθυνση αίθουσας." + "Διεύθυνση δωματίου" + "Όνομα αίθουσας" + "Ορατότητα αίθουσας" + "Δημιουργία αίθουσας" + "Θέμα (προαιρετικό)" + 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 0000000..6480697 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,21 @@ + + + "Nueva sala" + "Invitar personas" + "Se ha producido un error al crear la sala" + "Solo las personas invitadas pueden acceder a esta sala. Todos los mensajes están cifrados de extremo a extremo." + "Sala privada" + "Cualquiera puede encontrar esta sala. +Puedes cambiar esto en cualquier momento en los ajustes de la sala." + "Sala pública" + "Cualquiera puede unirse a esta sala" + "Cualquiera" + "Acceso a la sala" + "Cualquiera puede solicitar unirse a la sala, pero un administrador o un moderador tendrá que aceptar la solicitud" + "Solicitud para unirse" + "Para que esta sala sea visible en el directorio de salas públicas, necesitarás una dirección de sala." + "Nombre de la sala" + "Visibilidad de la sala" + "Crear una sala" + "Tema (opcional)" + diff --git a/features/createroom/impl/src/main/res/values-et/translations.xml b/features/createroom/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..6a1d9dc --- /dev/null +++ b/features/createroom/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,22 @@ + + + "Uus jututuba" + "Kutsu osalejaid" + "Jututoa loomisel tekkis viga" + "Ligipääs siia jututuppa on vaid kutse alusel. Kõik sõnumid siin jututoas on läbivalt krüptitud." + "Privaatne jututuba" + "Kõik saavad seda jututuba leida. +Sa võid seda jututoa seadistustest alati muuta." + "Avalik jututuba" + "Kõik võivad selle jututoaga liituda" + "Kõik" + "Ligipääs jututoale" + "Kõik võivad paluda selle jututoaga liitumist, kuid peakasutaja või moderaator peavad selle kinnitama" + "Küsi võimalust liitumiseks" + "Selleks, et see jututuba oleks nähtav jututubade avalikus kataloogis, sa vajad jututoa aadressi." + "Jututoa aadress" + "Jututoa nimi" + "Jututoa nähtavus" + "Loo jututuba" + "Teema (kui soovid lisada)" + diff --git a/features/createroom/impl/src/main/res/values-eu/translations.xml b/features/createroom/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..537aa49 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,19 @@ + + + "Gela berria" + "Gonbidatu jendea" + "Errorea gertatu da gela sortzean" + "Gonbidatutako jendea soilik sar daiteke gelara. Mezu guztiak daude ertzetik ertzera zifratuta." + "Gela pribatua" + "Edonork aurki dezake gela hau. +Gelaren ezarpenetan aldatu dezakezu hobespena." + "Gela publikoa" + "Edonor sar daiteke gela honetara" + "Edonork" + "Gelarako sarbidea" + "Gelaren helbidea" + "Gelaren izena" + "Gelaren ikusgarritasuna" + "Sortu gela" + "Mintzagaia (aukerakoa)" + diff --git a/features/createroom/impl/src/main/res/values-fa/translations.xml b/features/createroom/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..09869c7 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,20 @@ + + + "اتاق جدید" + "دعوت افراد" + "هنگام ایجاد اتاق خطایی رخ داد" + "تنها افراد دعوت شده می‌توانند به این اتاق دسترسی داشته باشند. همهٔ پیام‌ها رمزنگاری سرتاسری شده‌اند." + "اتاق خصوصی" + "هرکسی می‌تواند اتاق را بیابد. +می‌توانید بعداً در تظیمات اتاق عوضش کنید." + "اتاق عمومی" + "هرکسی می‌تواند به این اتاق بپیوندد" + "هرکسی" + "دسترسی اتاق" + "درخواست دعوت" + "نشانی اتاق" + "نام اتاق" + "نمایانی اتاق" + "ایجاد اتاق" + "موضوع (اختیاری)" + diff --git a/features/createroom/impl/src/main/res/values-fi/translations.xml b/features/createroom/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..df541d3 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,22 @@ + + + "Uusi huone" + "Kutsu henkilöitä" + "Huoneen luomisessa tapahtui virhe" + "Vain kutsutut henkilöt pääsevät tähän huoneeseen. Kaikki viestit ovat päästä päähän salattuja." + "Yksityinen huone" + "Kuka tahansa voi löytää tämän huoneen. +Voit muuttaa tämän milloin tahansa huoneen asetuksista." + "Julkinen huone" + "Kuka tahansa voi liittyä tähän huoneeseen" + "Kuka tahansa" + "Huoneeseen Pääsy" + "Kuka tahansa voi pyytää saada liittyä huoneeseen, mutta ylläpitäjän tai valvojan on hyväksyttävä pyyntö" + "Pyydä liittymistä" + "Jotta tämä huone näkyisi julkisessa huonehakemistossa, tarvitset huoneen osoitteen." + "Huoneen osoite" + "Huoneen nimi" + "Huoneen näkyvyys" + "Luo huone" + "Aihe (valinnainen)" + 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 0000000..afbdc91 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,22 @@ + + + "Nouveau salon" + "Inviter des amis" + "Une erreur s’est produite lors de la création du salon" + "Seules les personnes invitées peuvent accéder à ce salon. Tous les messages sont chiffrés de bout en bout." + "Salon privé" + "N’importe qui peut trouver ce salon. +Vous pouvez modifier cela à tout moment dans les paramètres du salon." + "Salon public" + "Tout le monde peut rejoindre ce salon" + "Tout le monde" + "Accès au salon" + "Tout le monde peut demander à rejoindre le salon, mais un administrateur ou un modérateur devra accepter la demande" + "Demander à rejoindre" + "Pour que ce salon soit visible dans le répertoire des salons publics, vous aurez besoin d’une adresse de salon." + "Adresse du salon" + "Nom du salon" + "Visibilité du salon" + "Créer un salon" + "Sujet (facultatif)" + diff --git a/features/createroom/impl/src/main/res/values-hu/translations.xml b/features/createroom/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..24f7198 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,22 @@ + + + "Új szoba" + "Ismerősök meghívása" + "Hiba történt a szoba létrehozásakor" + "Csak a meghívottak léphetnek be ebbe a szobába. Az összes üzenet végpontok közti titkosítással van védve." + "Privát szoba" + "Bárki megtalálhatja ezt a szobát. +Ezt bármikor módosíthatja a szobabeállításokban." + "Nyilvános szoba" + "Bárki csatlakozhat ehhez a szobához" + "Bárki" + "Szobahozzáférés" + "Bárki kérheti, hogy csatlakozzon a szobához, de egy adminisztrátornak vagy moderátornak el kell fogadnia a kérést" + "Csatlakozás kérése" + "Ahhoz, hogy ez a szoba látható legyen a nyilvános szobák címtárában, meg kell adnia a szoba címét." + "Szoba címe" + "Szoba neve" + "Szoba láthatósága" + "Szoba létrehozása" + "Téma (nem kötelező)" + diff --git a/features/createroom/impl/src/main/res/values-in/translations.xml b/features/createroom/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..219f621 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,22 @@ + + + "Ruangan baru" + "Undang orang-orang" + "Terjadi kesalahan saat membuat ruangan" + "Hanya orang-orang yang diundang dapat mengakses ruangan ini. Semua pesan terenkripsi secara ujung ke ujung." + "Ruangan pribadi" + "Siapa pun dapat mencari ruangan ini. +Anda dapat mengubah ini kapan pun dalam pengaturan ruangan." + "Ruangan publik" + "Siapa pun dapat bergabung dengan ruangan ini" + "Siapa pun" + "Akses Ruangan" + "Siapa pun dapat meminta untuk bergabung dengan ruangan tetapi administrator atau moderator harus menerima permintaan tersebut" + "Minta untuk bergabung" + "Supaya ruangan ini terlihat di direktori ruangan publik, Anda memerlukan alamat ruangan." + "Alamat ruangan" + "Nama ruangan" + "Keterlihatan ruangan" + "Buat ruangan" + "Topik (opsional)" + 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 0000000..741b88b --- /dev/null +++ b/features/createroom/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,22 @@ + + + "Nuova stanza" + "Invita persone" + "Si è verificato un errore durante la creazione della stanza" + "Solo le persone invitate possono accedere a questa stanza. Tutti i messaggi sono cifrati end-to-end." + "Stanza privata" + "Chiunque può trovare questa stanza. +Puoi modificarlo in qualsiasi momento nelle impostazioni della stanza." + "Stanza pubblica" + "Chiunque può entrare in questa stanza" + "Chiunque" + "Accesso alla stanza" + "Chiunque può chiedere di entrare nella stanza, ma un amministratore o un moderatore dovrà accettare la richiesta" + "Chiedi di entrare" + "Affinché questa stanza sia visibile nell\'elenco delle stanze pubbliche, è necessario un indirizzo della stanza." + "Indirizzo della stanza" + "Nome stanza" + "Visibilità della stanza" + "Crea una stanza" + "Argomento (facoltativo)" + diff --git a/features/createroom/impl/src/main/res/values-ka/translations.xml b/features/createroom/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..20c7af4 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,13 @@ + + + "ახალი ოთახი" + "ხალხის მოწვევა" + "ოთახის შექმნისას შეცდომა მოხდა" + "ამ ოთახში შეტყობინებები დაშიფრულია. შემდგომ დაშიფვრის გამორთვა შეუძლებელია." + "კერძო ოთახი" + "ყველას ამ ოთახის მოძებნა შეუძლია. +თქვენ ნებისმიერ დროს შეგიძლიათ ამის შეცვლა ოთახის პარამეტრებში." + "ოთახის სახელი" + "ოთახის შექმნა" + "თემა (სურვილისამებრ)" + diff --git a/features/createroom/impl/src/main/res/values-ko/translations.xml b/features/createroom/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..3dfdc46 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,21 @@ + + + "새 방" + "사람 초대하기" + "방을 생성하던 중 오류가 발생했어요" + "초대받은 사람만 이 방에 액세스할 수 있습니다. 모든 메시지는 종단 간 암호화됩니다." + "비공개 방" + "누구나 이 방을 찾을 수 있습니다. +방 설정에서 언제든지 변경할 수 있습니다." + "공개 방" + "누구나 이 방에 참여할 수 있습니다." + "누구나" + "방 액세스" + "누구나 방에 참여 요청을 할 수 있지만, 관리자나 운영자가 요청을 수락해야 합니다." + "참가 요청" + "이 방이 공개 방 디렉토리에 표시되려면 방 주소가 필요합니다." + "방 이름" + "방 표시 여부" + "방 만들기" + "주제 (선택)" + diff --git a/features/createroom/impl/src/main/res/values-lt/translations.xml b/features/createroom/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..2fb7b3e --- /dev/null +++ b/features/createroom/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,13 @@ + + + "Naujas kambarys" + "Pakviesti žmonių" + "Kuriant kambarį įvyko klaida" + "Į šį kambarį gali patekti tik pakviesti žmonės. Visi pranešimai yra užšifruoti nuo pradžios iki galo." + "Privatus kambarys" + "Bet kas gali rasti šį kambarį. +Tai galite bet kada pakeisti kambario nustatymuose." + "Kambario pavadinimas" + "Kurti kambarį" + "Tema (nebūtina)" + diff --git a/features/createroom/impl/src/main/res/values-nb/translations.xml b/features/createroom/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..9a15981 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,21 @@ + + + "Nytt rom" + "Inviter folk" + "Det oppsto en feil under opprettelsen av rommet" + "Bare inviterte personer har tilgang til dette rommet. Alle meldinger er ende-til-ende-kryptert." + "Privat rom" + "Alle kan finne dette rommet. +Du kan endre dette når som helst i rominnstillingene." + "Offentlig rom" + "Alle kan bli med i dette rommet" + "Alle" + "Tilgang til rom" + "Alle kan be om å få bli med i rommet, men en administrator eller moderator må godta forespørselen" + "Be om å bli med" + "For at dette rommet skal være synlig i den offentlige romkatalogen, trenger du en romadresse." + "Romnavn" + "Romsynlighet" + "Opprett et rom" + "Emne (valgfritt)" + diff --git a/features/createroom/impl/src/main/res/values-nl/translations.xml b/features/createroom/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..5142148 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,19 @@ + + + "Nieuwe kamer" + "Mensen uitnodigen" + "Er is een fout opgetreden bij het aanmaken van de kamer" + "Alleen uitgenodigde personen hebben toegang tot deze kamer. Alle berichten zijn end-to-end versleuteld." + "Privé kamer" + "Iedereen kan deze kamer vinden. +Je kunt dit op elk gewenst moment wijzigen in de kamerinstellingen." + "Openbare kamer" + "Iedereen kan toetreden tot deze kamer" + "Iedereen" + "Toegang tot de kamer" + "Iedereen kan vragen om toe te treden tot de kamer, maar een beheerder of moderator moet het verzoek accepteren" + "Vraag om toe te treden" + "Naam van de kamer" + "Creëer een kamer" + "Onderwerp (optioneel)" + diff --git a/features/createroom/impl/src/main/res/values-pl/translations.xml b/features/createroom/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..446644b --- /dev/null +++ b/features/createroom/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,22 @@ + + + "Nowy pokój" + "Zaproś znajomych" + "Wystąpił błąd w trakcie tworzenia pokoju" + "Tylko zaproszone osoby mogą dołączyć do tego pokoju. Wszystkie wiadomości są szyfrowane end-to-end." + "Pokój prywatny" + "Każdy może znaleźć ten pokój. +Możesz to zmienić w ustawieniach pokoju." + "Pokój publiczny" + "Każdy może dołączyć do tego pokoju" + "Wszyscy" + "Dostęp do pokoju" + "Każdy może poprosić o dołączenie do pokoju, ale administrator lub moderator będzie musiał zatwierdzić prośbę" + "Poproś o dołączenie" + "Aby ten pokój był widoczny w katalogu pomieszczeń publicznych, będziesz potrzebował adres pokoju." + "Adres pokoju" + "Nazwa pokoju" + "Widoczność pomieszczenia" + "Utwórz pokój" + "Temat (opcjonalnie)" + diff --git a/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml b/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..399c9fe --- /dev/null +++ b/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,22 @@ + + + "Nova sala" + "Convidar pessoas" + "Ocorreu um erro ao criar a sala" + "Apenas as pessoas convidadas podem entrar nesta sala. Todas as mensagens são criptografadas de ponta a ponta." + "Sala privada" + "Qualquer um pode encontrar esta sala. +Você pode mudar isso a qualquer momento nas configurações da sala." + "Sala pública" + "Qualquer pessoa pode entrar nesta sala" + "Qualquer pessoa" + "Acesso à sala" + "Qualquer pessoa pode pedir para entrar na sala, mas um administrador ou moderador terá de aceitar a solicitação" + "Pedir para entrar" + "Para que esta sala fique visível no diretório público de salas, você precisará de um endereço de sala." + "Endereço da sala" + "Nome da sala" + "Visibilidade da sala" + "Criar uma sala" + "Tópico (opcional)" + diff --git a/features/createroom/impl/src/main/res/values-pt/translations.xml b/features/createroom/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..1524914 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,22 @@ + + + "Nova sala" + "Convidar pessoas" + "Ocorreu um erro ao criar a sala" + "Apenas as pessoas convidadas podem aceder a esta sala. Todas as mensagens são cifradas ponta-a-ponta." + "Sala privada" + "Qualquer um pode encontrar esta sala. +Pode alterar esta opção nas definições da sala." + "Sala pública" + "Qualquer pessoa pode entrar nesta sala" + "Qualquer pessoa" + "Acesso à sala" + "Qualquer pessoa pode pedir para entrar na sala, mas um administrador ou um moderador terá de aceitar o pedido" + "Pedir para participar" + "Para que esta sala seja visível no diretório público de salas, precisas de um endereço de sala." + "Endereço da sala" + "Nome da sala" + "Visibilidade da sala" + "Criar uma sala" + "Descrição (opcional)" + 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 0000000..b9fe78b --- /dev/null +++ b/features/createroom/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,22 @@ + + + "Cameră nouă" + "Invitați prieteni" + "A apărut o eroare la crearea camerei" + "Doar persoanele invitate pot accesa această cameră. Toate mesajele sunt criptate end-to-end." + "Cameră privată" + "Oricine poate găsi această cameră. +Puteți modifica acest lucru oricând în setări." + "Cameră publică" + "Oricine se poate alătura acestei camere" + "Oricine" + "Acces la cameră" + "Oricine poate cere să se alăture camerei, dar un administrator sau un moderator va trebui să accepte cererea" + "Cereți să vă alăturați" + "Pentru ca această cameră să fie vizibilă în directorul de camere publice, veți avea nevoie de o adresă de cameră." + "Adresa camerei" + "Numele camerei" + "Vizibilitatea camerei" + "Creați o cameră" + "Subiect (opțional)" + diff --git a/features/createroom/impl/src/main/res/values-ru/translations.xml b/features/createroom/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..e871673 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,22 @@ + + + "Создать новую комнату" + "Пригласить в комнату" + "Произошла ошибка при создании комнаты" + "Доступ в эту комнату имеют только приглашенные пользователи. Все сообщения защищены сквозным шифрованием." + "Частная комната" + "Любой желающий может найти эту комнату. +Вы можете изменить это в любое время в настройках комнаты." + "Общедоступная комната" + "Любой желающий может присоединиться к этой комнате" + "Любой" + "Доступ в комнату" + "Любой желающий может подать заявку на присоединение к комнате, но администратор или модератор должен будет принять запрос." + "Попросить присоединиться" + "Чтобы эта комната была видна в каталоге общедоступных, вам необходим ее адрес" + "Адрес комнаты" + "Название комнаты" + "Видимость комнаты" + "Создать комнату" + "Тема (необязательно)" + 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 0000000..7b6d89b --- /dev/null +++ b/features/createroom/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,22 @@ + + + "Nová miestnosť" + "Pozvať ľudí" + "Pri vytváraní miestnosti došlo k chybe" + "Do tejto miestnosti majú prístup iba pozvaní ľudia. Všetky správy sú end-to-end šifrované." + "Súkromná miestnosť" + "Túto miestnosť môže nájsť ktokoľvek. +Môžete to kedykoľvek zmeniť v nastaveniach miestnosti." + "Verejná miestnosť" + "Do tejto miestnosti sa môže pripojiť ktokoľvek" + "Ktokoľvek" + "Prístup do miestnosti" + "Ktokoľvek môže požiadať o pripojenie sa k miestnosti, ale administrátor alebo moderátor bude musieť žiadosť schváliť" + "Požiadať o pripojenie" + "Aby bola táto miestnosť viditeľná v adresári verejných miestností, budete potrebovať adresu miestnosti." + "Adresa miestnosti" + "Názov miestnosti" + "Viditeľnosť miestnosti" + "Vytvoriť miestnosť" + "Téma (voliteľné)" + diff --git a/features/createroom/impl/src/main/res/values-sv/translations.xml b/features/createroom/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..8cd01eb --- /dev/null +++ b/features/createroom/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,22 @@ + + + "Nytt rum" + "Bjud in personer" + "Ett fel uppstod när rummet skapades" + "Endast inbjudna personer har tillgång till detta rum. Alla meddelanden är totalsträckskrypterade." + "Privat rum" + "Vem som helst kan hitta det här rummet. +Du kan ändra detta när som helst i rumsinställningarna." + "Offentligt rum" + "Vem som helst kan gå med i det här rummet" + "Vem som helst" + "Rumsåtkomst" + "Vem som helst kan be om att gå med i rummet men en administratör eller en moderator måste acceptera begäran" + "Be om att gå med" + "För att detta rum ska vara synligt i den allmänna rumskatalogen behöver du en rumsadress." + "Rumsadress" + "Rumsnamn" + "Rumssynlighet" + "Skapa ett rum" + "Ämne (valfritt)" + diff --git a/features/createroom/impl/src/main/res/values-tr/translations.xml b/features/createroom/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..d97139c --- /dev/null +++ b/features/createroom/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,22 @@ + + + "Yeni oda" + "İnsanları davet et" + "Oda oluşturulurken bir hata oluştu" + "Bu odaya yalnızca davet edilen kişiler erişebilir. Tüm mesajlar uçtan uca şifrelenir." + "Özel oda" + "Bu odayı herkes bulabilir. +Bunu istediğiniz zaman oda ayarlarından değiştirebilirsiniz." + "Herkese açık oda" + "Bu odaya herkes katılabilir" + "Herkes" + "Oda Erişimi" + "Herkes odaya katılmayı isteyebilir ancak bir yönetici veya moderatörün isteği kabul etmesi gerekecektir" + "Katılmak için sor" + "Bu odanın genel oda dizininde görünür olması için bir oda adresine ihtiyacınız olacaktır." + "Oda adresi" + "Oda adı" + "Oda görünürlüğü" + "Bir oda oluştur" + "Konu (isteğe bağlı)" + diff --git a/features/createroom/impl/src/main/res/values-uk/translations.xml b/features/createroom/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..047b4dd --- /dev/null +++ b/features/createroom/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,22 @@ + + + "Нова кімната" + "Запросити людей" + "Під час створення кімнати сталася помилка" + "Лише запрошені люди мають доступ до цієї кімнати. Усі повідомлення захищені наскрізним шифруванням." + "Приватна кімната (тільки за запрошенням)" + "Будь-хто може знайти цю кімнату. +Ви можете змінити це в будь-який час у налаштуваннях кімнати." + "Загальнодоступна кімната" + "Будь-хто може приєднатися до цієї кімнати" + "Кожний" + "Доступ до кімнати" + "Будь-хто може попросити приєднатися до кімнати, але адміністратор або модератор повинен буде прийняти запит" + "Запросити приєднатися" + "Щоб цю кімнату було видно в каталозі загальнодоступних кімнат, вам знадобиться її адреса." + "Адреса кімнати" + "Назва кімнати" + "Видимість кімнати" + "Створити кімнату" + "Тема (необов\'язково)" + diff --git a/features/createroom/impl/src/main/res/values-ur/translations.xml b/features/createroom/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..b689920 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,14 @@ + + + "نیا کمرہ" + "لوگوں کو مدعو کریں" + "کمرہ تخلیق کرتے ہوئے ایک نقص واقع ہوا" + "صرف مدعو لوگ ہی اس کمرے تک رسائی حاصل کر سکتے ہیں۔ تمام پیغامات آخر تا آخر مرموز کردہ ہیں۔" + "نجی کمرہ" + "کوئی بھی یہ کمرہ ڈھونڈ سکتا ہے۔ +آپ اسے کمرے کی ترتیبات میں کسی بھی وقت تبدیل کرسکتے ہیں۔" + "عوامی کمرہ" + "کمرے کا نام" + "ایک کمرہ بنائیں" + "موضوع (اختیاری)" + diff --git a/features/createroom/impl/src/main/res/values-uz/translations.xml b/features/createroom/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..34062f9 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,21 @@ + + + "Yangi xona" + "Odamlarni taklif qiling" + "Xonani yaratishda xatolik yuz berdi" + "Faqat taklif etilgan shaxslargina bu xonaga kira oladi. Barcha xabarlar boshdan-oxirigacha shifrlanadi." + "Shaxsiy xona" + "Bu xonani har kim topishi mumkin. +Buni xona sozlamalaridan istalgan vaqtda oʻzgartirishingiz mumkin." + "Jamoat xonasi" + "Bu xonaga istalgan kishi qo‘shilishi mumkin" + "Har kim" + "Xonaga kirish" + "Xonaga qo‘shilishni istalgan kishi so‘rashi mumkin, lekin administrator yoki moderator so‘rovni qabul qilishi kerak" + "Qo‘shilishni so‘rang" + "Ushbu xona ommaviy xonalar ro‘yxatida ko‘rinishi uchun sizga xona manzili kerak bo‘ladi." + "Xona nomi" + "Xonaning ko‘rinishi" + "Xonani yaratish" + "Mavzu (ixtiyoriy)" + diff --git a/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml b/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..476f9cf --- /dev/null +++ b/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,22 @@ + + + "建立聊天室" + "邀請夥伴" + "建立聊天室時發生錯誤" + "僅被邀請的人才能存取此聊天室。所有訊息均會端到端加密。" + "私密聊天室" + "任何人都可以找到此聊天室。 +您隨時都可以在聊天室設定中變更此設定。" + "公開的聊天室" + "任何人都可以加入此聊天室" + "任何人" + "聊天室存取權" + "任何人都可以要求加入聊天室,但管理員或版主必須接受該請求" + "要求加入" + "為了讓此聊天室在公開聊天室目錄中可見,您需要聊天室地址。" + "聊天室地址" + "聊天室名稱" + "聊天室能見度" + "建立聊天室" + "主題(非必填)" + diff --git a/features/createroom/impl/src/main/res/values-zh/translations.xml b/features/createroom/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..d20e348 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,22 @@ + + + "新聊天室" + "邀请朋友" + "创建聊天室时出错" + "只有受邀用户才能访问此聊天室。所有消息均经过端到端加密。" + "私有聊天室" + "任何人都能找到此聊天室。 +你可以随时在聊天室设置中更改。" + "公共聊天室" + "任何人都可以加入此房间" + "任何人" + "房间访问权限" + "任何人都可以请求加入房间,但必须由管理员或审核人接受" + "请求加入" + "要使该房间在公开房间目录中可见,您需要一个房间地址。" + "房间地址" + "聊天室名称" + "房间可见性" + "创建聊天室" + "主题(可选)" + 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 0000000..fa9a1cb --- /dev/null +++ b/features/createroom/impl/src/main/res/values/localazy.xml @@ -0,0 +1,22 @@ + + + "New room" + "Invite people" + "An error occurred when creating the room" + "Only people invited can access this room. All messages are end-to-end encrypted." + "Private room" + "Anyone can find this room. +You can change this anytime in room settings." + "Public room" + "Anyone can join this room" + "Anyone" + "Room Access" + "Anyone can ask to join the room but an administrator or a moderator will have to accept the request" + "Ask to join" + "In order for this room to be visible in the public room directory, you will need a room address." + "Room address" + "Room name" + "Room visibility" + "Create a room" + "Topic (optional)" + diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPointTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPointTest.kt new file mode 100644 index 0000000..35b6637 --- /dev/null +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPointTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.createroom.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.features.createroom.api.CreateRoomEntryPoint +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultCreateRoomEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultCreateRoomEntryPoint() + + val parentNode = TestParentNode.create { buildContext, plugins -> + CreateRoomFlowNode( + buildContext = buildContext, + plugins = plugins, + ) + } + val callback = object : CreateRoomEntryPoint.Callback { + override fun onRoomCreated(roomId: RoomId) = lambdaError() + } + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + callback = callback, + ) + assertThat(result.plugins).contains(callback) + } +} diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/startchat/impl/configureroom/ConfigureRoomPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/startchat/impl/configureroom/ConfigureRoomPresenterTest.kt new file mode 100644 index 0000000..c8a6c2b --- /dev/null +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/startchat/impl/configureroom/ConfigureRoomPresenterTest.kt @@ -0,0 +1,416 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.configureroom + +import android.net.Uri +import androidx.core.net.toUri +import app.cash.turbine.TurbineTestContext +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.CreatedRoom +import io.element.android.features.createroom.impl.configureroom.ConfigureRoomEvents +import io.element.android.features.createroom.impl.configureroom.ConfigureRoomPresenter +import io.element.android.features.createroom.impl.configureroom.ConfigureRoomState +import io.element.android.features.createroom.impl.configureroom.CreateRoomConfig +import io.element.android.features.createroom.impl.configureroom.CreateRoomConfigStore +import io.element.android.features.createroom.impl.configureroom.RoomAccess +import io.element.android.features.createroom.impl.configureroom.RoomAddress +import io.element.android.features.createroom.impl.configureroom.RoomVisibilityItem +import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +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.alias.ResolvedRoomAlias +import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper +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_MESSAGE +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.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity +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.MediaUploadInfo +import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.util.Optional + +private const val AN_URI_FROM_CAMERA = "content://uri_from_camera" +private const val AN_URI_FROM_CAMERA_2 = "content://uri_from_camera_2" +private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery" + +@RunWith(RobolectricTestRunner::class) +class ConfigureRoomPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Before + fun setup() { + mockkStatic(File::readBytes) + every { any().readBytes() } returns byteArrayOf() + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `present - initial state`() = runTest { + val presenter = createConfigureRoomPresenter() + presenter.test { + val initialState = initialState() + 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.roomVisibility).isEqualTo(RoomVisibilityState.Private) + assertThat(initialState.createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(initialState.homeserverName).isEqualTo("matrix.org") + } + } + + @Test + fun `present - create room button is enabled only if the required fields are completed`() = runTest { + val presenter = createConfigureRoomPresenter() + presenter.test { + val initialState = initialState() + var config = initialState.config + assertThat(initialState.isValid).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.isValid).isTrue() + + // Clear room name + newState.eventSink(ConfigureRoomEvents.RoomNameChanged("")) + newState = awaitItem() + config = config.copy(roomName = null) + assertThat(newState.config).isEqualTo(config) + assertThat(newState.isValid).isFalse() + } + } + + @Test + fun `present - state is updated when fields are changed`() = runTest { + val pickerProvider = FakePickerProvider() + val permissionsPresenter = FakePermissionsPresenter() + val roomAliasHelper = FakeRoomAliasHelper() + val presenter = createConfigureRoomPresenter( + dataStore = CreateRoomConfigStore(roomAliasHelper), + pickerProvider = pickerProvider, + permissionsPresenter = permissionsPresenter, + ) + presenter.test { + val initialState = initialState() + var expectedConfig = CreateRoomConfig() + assertThat(initialState.config).isEqualTo(expectedConfig) + // Room name + initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME)) + var 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 + pickerProvider.givenResult(null) + // From gallery + val uriFromGallery = AN_URI_FROM_GALLERY + pickerProvider.givenResult(uriFromGallery.toUri()) + newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + newState = awaitItem() + expectedConfig = expectedConfig.copy(avatarUri = uriFromGallery) + assertThat(newState.config).isEqualTo(expectedConfig) + // From camera + val uriFromCamera = AN_URI_FROM_CAMERA + pickerProvider.givenResult(uriFromCamera.toUri()) + assertThat(newState.cameraPermissionState.permissionGranted).isFalse() + newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto)) + newState = awaitItem() + assertThat(newState.cameraPermissionState.showDialog).isTrue() + permissionsPresenter.setPermissionGranted() + newState = awaitItem() + assertThat(newState.cameraPermissionState.permissionGranted).isTrue() + newState = awaitItem() + expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera) + assertThat(newState.config).isEqualTo(expectedConfig) + // Do it again, no permission is requested + val uriFromCamera2 = AN_URI_FROM_CAMERA_2 + pickerProvider.givenResult(uriFromCamera2.toUri()) + newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto)) + newState = awaitItem() + expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera2) + 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.RoomVisibilityChanged(RoomVisibilityItem.Public)) + newState = awaitItem() + expectedConfig = expectedConfig.copy( + roomVisibility = RoomVisibilityState.Public( + roomAddress = RoomAddress.AutoFilled(roomAliasHelper.roomAliasNameFromRoomDisplayName(expectedConfig.roomName ?: "")), + roomAccess = RoomAccess.Anyone, + ) + ) + assertThat(newState.config).isEqualTo(expectedConfig) + } + } + + @Test + fun `present - trigger create room action`() = runTest { + val matrixClient = createMatrixClient() + val presenter = createConfigureRoomPresenter( + matrixClient = matrixClient + ) + presenter.test { + val initialState = initialState() + val createRoomResult = Result.success(RoomId("!createRoomResult:domain")) + + matrixClient.givenCreateRoomResult(createRoomResult) + + initialState.eventSink(ConfigureRoomEvents.CreateRoom) + assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) + val stateAfterCreateRoom = awaitItem() + assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Success::class.java) + assertThat(stateAfterCreateRoom.createRoomAction.dataOrNull()).isEqualTo(createRoomResult.getOrNull()) + } + } + + @Test + fun `present - record analytics when creating room`() = runTest { + val matrixClient = createMatrixClient() + val analyticsService = FakeAnalyticsService() + val presenter = createConfigureRoomPresenter( + matrixClient = matrixClient, + analyticsService = analyticsService + ) + presenter.test { + val initialState = initialState() + val createRoomResult = Result.success(RoomId("!createRoomResult:domain")) + + matrixClient.givenCreateRoomResult(createRoomResult) + + initialState.eventSink(ConfigureRoomEvents.CreateRoom) + skipItems(2) + + val analyticsEvent = analyticsService.capturedEvents.filterIsInstance().firstOrNull() + assertThat(analyticsEvent).isNotNull() + assertThat(analyticsEvent?.isDM).isFalse() + } + } + + @Test + fun `present - trigger create room with upload error and retry`() = runTest { + val matrixClient = createMatrixClient() + val analyticsService = FakeAnalyticsService() + val mediaPreProcessor = FakeMediaPreProcessor() + val dataStore = CreateRoomConfigStore(FakeRoomAliasHelper()) + val presenter = createConfigureRoomPresenter( + dataStore = dataStore, + mediaPreProcessor = mediaPreProcessor, + matrixClient = matrixClient, + analyticsService = analyticsService + ) + presenter.test { + val initialState = initialState() + dataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY)) + skipItems(1) + mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk()))) + matrixClient.givenUploadMediaResult(Result.failure(AN_EXCEPTION)) + + initialState.eventSink(ConfigureRoomEvents.CreateRoom) + assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) + val stateAfterCreateRoom = awaitItem() + assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java) + assertThat(analyticsService.capturedEvents.filterIsInstance()).isEmpty() + + matrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL)) + stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom) + assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) + assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Success::class.java) + } + } + + @Test + fun `present - trigger retry and cancel actions`() = runTest { + val fakeMatrixClient = createMatrixClient() + val presenter = createConfigureRoomPresenter( + matrixClient = fakeMatrixClient + ) + presenter.test { + val initialState = initialState() + val createRoomResult = Result.failure(AN_EXCEPTION) + + fakeMatrixClient.givenCreateRoomResult(createRoomResult) + + // Create + initialState.eventSink(ConfigureRoomEvents.CreateRoom) + assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) + val stateAfterCreateRoom = awaitItem() + assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java) + assertThat((stateAfterCreateRoom.createRoomAction as? AsyncAction.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull()) + + // Retry + stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom) + assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) + val stateAfterRetry = awaitItem() + assertThat(stateAfterRetry.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java) + assertThat((stateAfterRetry.createRoomAction as? AsyncAction.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull()) + + // Cancel + stateAfterRetry.eventSink(ConfigureRoomEvents.CancelCreateRoom) + assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - address is invalid when format is invalid`() = runTest { + val aliasHelper = FakeRoomAliasHelper( + isRoomAliasValidLambda = { false } + ) + val presenter = createConfigureRoomPresenter( + roomAliasHelper = aliasHelper + ) + presenter.test { + val initialState = initialState() + initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public)) + skipItems(1) + initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("invalid address")) + skipItems(1) + advanceUntilIdle() + awaitItem().also { state -> + assertThat(state.roomAddressValidity).isEqualTo(RoomAddressValidity.InvalidSymbols) + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - address is not available when alias is not available`() = runTest { + val fakeMatrixClient = createMatrixClient(isAliasAvailable = false) + val presenter = createConfigureRoomPresenter( + matrixClient = fakeMatrixClient, + ) + presenter.test { + val initialState = initialState() + initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public)) + skipItems(1) + initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address")) + skipItems(1) + advanceUntilIdle() + awaitItem().also { state -> + assertThat(state.roomAddressValidity).isEqualTo(RoomAddressValidity.NotAvailable) + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - address is valid when alias is available and format is valid`() = runTest { + val fakeMatrixClient = createMatrixClient(isAliasAvailable = true) + val presenter = createConfigureRoomPresenter( + matrixClient = fakeMatrixClient, + ) + presenter.test { + val initialState = initialState() + initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public)) + skipItems(1) + initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address")) + skipItems(1) + advanceUntilIdle() + awaitItem().also { state -> + assertThat(state.roomAddressValidity).isEqualTo(RoomAddressValidity.Valid) + } + } + } + + private suspend fun TurbineTestContext.initialState(): ConfigureRoomState { + skipItems(1) + return awaitItem() + } + + private fun createMatrixClient(isAliasAvailable: Boolean = true) = FakeMatrixClient( + userIdServerNameLambda = { "matrix.org" }, + resolveRoomAliasResult = { + val resolvedRoomAlias = if (isAliasAvailable) { + Optional.empty() + } else { + Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList())) + } + Result.success(resolvedRoomAlias) + } + ) + + private fun createConfigureRoomPresenter( + roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper(), + dataStore: CreateRoomConfigStore = CreateRoomConfigStore(roomAliasHelper), + matrixClient: MatrixClient = createMatrixClient(), + pickerProvider: PickerProvider = FakePickerProvider(), + mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(), + analyticsService: AnalyticsService = FakeAnalyticsService(), + permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), + isKnockFeatureEnabled: Boolean = true, + mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), + ) = ConfigureRoomPresenter( + dataStore = dataStore, + matrixClient = matrixClient, + mediaPickerProvider = pickerProvider, + mediaPreProcessor = mediaPreProcessor, + analyticsService = analyticsService, + permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter), + roomAliasHelper = roomAliasHelper, + featureFlagService = FakeFeatureFlagService( + mapOf(FeatureFlags.Knock.key to isKnockFeatureEnabled) + ), + mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, + ) +} diff --git a/features/createroom/test/build.gradle.kts b/features/createroom/test/build.gradle.kts new file mode 100644 index 0000000..98aeac8 --- /dev/null +++ b/features/createroom/test/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.createroom.test" +} + +dependencies { + implementation(projects.features.createroom.api) + implementation(projects.libraries.architecture) + implementation(projects.tests.testutils) +} diff --git a/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/api/FakeCreateRoomEntryPoint.kt b/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/api/FakeCreateRoomEntryPoint.kt new file mode 100644 index 0000000..2beaecf --- /dev/null +++ b/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/api/FakeCreateRoomEntryPoint.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.createroom.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeCreateRoomEntryPoint : CreateRoomEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: CreateRoomEntryPoint.Callback, + ): Node = lambdaError() +} diff --git a/features/deactivation/api/build.gradle.kts b/features/deactivation/api/build.gradle.kts new file mode 100644 index 0000000..64a9f25 --- /dev/null +++ b/features/deactivation/api/build.gradle.kts @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.deactivation.api" +} + +dependencies { + implementation(projects.libraries.architecture) +} diff --git a/features/deactivation/api/src/main/kotlin/io/element/android/features/deactivation/api/AccountDeactivationEntryPoint.kt b/features/deactivation/api/src/main/kotlin/io/element/android/features/deactivation/api/AccountDeactivationEntryPoint.kt new file mode 100644 index 0000000..6694d1e --- /dev/null +++ b/features/deactivation/api/src/main/kotlin/io/element/android/features/deactivation/api/AccountDeactivationEntryPoint.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.deactivation.api + +import io.element.android.libraries.architecture.SimpleFeatureEntryPoint + +interface AccountDeactivationEntryPoint : SimpleFeatureEntryPoint diff --git a/features/deactivation/impl/build.gradle.kts b/features/deactivation/impl/build.gradle.kts new file mode 100644 index 0000000..bca1440 --- /dev/null +++ b/features/deactivation/impl/build.gradle.kts @@ -0,0 +1,41 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.deactivation.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.libraries.androidutils) + 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) + api(projects.features.deactivation.api) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) +} diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationEvents.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationEvents.kt new file mode 100644 index 0000000..5ceb97b --- /dev/null +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationEvents.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +sealed interface AccountDeactivationEvents { + data class SetEraseData(val eraseData: Boolean) : AccountDeactivationEvents + data class SetPassword(val password: String) : AccountDeactivationEvents + data class DeactivateAccount(val isRetry: Boolean) : AccountDeactivationEvents + data object CloseDialogs : AccountDeactivationEvents +} diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationNode.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationNode.kt new file mode 100644 index 0000000..44be7b5 --- /dev/null +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationNode.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class AccountDeactivationNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: AccountDeactivationPresenter, +) : Node(buildContext, plugins = plugins) { + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + AccountDeactivationView( + state = state, + onBackClick = ::navigateUp, + modifier = modifier, + ) + } +} diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenter.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenter.kt new file mode 100644 index 0000000..eaeacd0 --- /dev/null +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenter.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.matrix.api.MatrixClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Inject +class AccountDeactivationPresenter( + private val matrixClient: MatrixClient, +) : Presenter { + @Composable + override fun present(): AccountDeactivationState { + val localCoroutineScope = rememberCoroutineScope() + val action: MutableState> = remember { + mutableStateOf(AsyncAction.Uninitialized) + } + + val formState = remember { mutableStateOf(DeactivateFormState.Default) } + + fun handleEvent(event: AccountDeactivationEvents) { + when (event) { + is AccountDeactivationEvents.SetEraseData -> { + updateFormState(formState) { + copy(eraseData = event.eraseData) + } + } + is AccountDeactivationEvents.SetPassword -> { + updateFormState(formState) { + copy(password = event.password) + } + } + is AccountDeactivationEvents.DeactivateAccount -> + if (action.value.isConfirming() || event.isRetry) { + localCoroutineScope.deactivateAccount( + formState = formState.value, + action + ) + } else { + action.value = AsyncAction.ConfirmingNoParams + } + AccountDeactivationEvents.CloseDialogs -> { + action.value = AsyncAction.Uninitialized + } + } + } + + return AccountDeactivationState( + deactivateFormState = formState.value, + accountDeactivationAction = action.value, + eventSink = ::handleEvent, + ) + } + + private fun updateFormState(formState: MutableState, updateLambda: DeactivateFormState.() -> DeactivateFormState) { + formState.value = updateLambda(formState.value) + } + + private fun CoroutineScope.deactivateAccount( + formState: DeactivateFormState, + action: MutableState>, + ) = launch { + suspend { + matrixClient.deactivateAccount( + password = formState.password, + eraseData = formState.eraseData, + ).getOrThrow() + }.runCatchingUpdatingState(action) + } +} diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationState.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationState.kt new file mode 100644 index 0000000..ca9751e --- /dev/null +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationState.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +import android.os.Parcelable +import io.element.android.libraries.architecture.AsyncAction +import kotlinx.parcelize.Parcelize + +data class AccountDeactivationState( + val deactivateFormState: DeactivateFormState, + val accountDeactivationAction: AsyncAction, + val eventSink: (AccountDeactivationEvents) -> Unit, +) { + val submitEnabled: Boolean + get() = accountDeactivationAction is AsyncAction.Uninitialized && + deactivateFormState.password.isNotEmpty() +} + +@Parcelize +data class DeactivateFormState( + val eraseData: Boolean, + val password: String +) : Parcelable { + companion object { + val Default = DeactivateFormState(false, "") + } +} diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStateProvider.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStateProvider.kt new file mode 100644 index 0000000..5c832a4 --- /dev/null +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStateProvider.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction + +open class AccountDeactivationStateProvider : PreviewParameterProvider { + private val filledForm = aDeactivateFormState(eraseData = true, password = "password") + override val values: Sequence + get() = sequenceOf( + anAccountDeactivationState(), + anAccountDeactivationState( + deactivateFormState = filledForm + ), + anAccountDeactivationState( + deactivateFormState = filledForm, + accountDeactivationAction = AsyncAction.ConfirmingNoParams, + ), + anAccountDeactivationState( + deactivateFormState = filledForm, + accountDeactivationAction = AsyncAction.Loading + ), + anAccountDeactivationState( + deactivateFormState = filledForm, + accountDeactivationAction = AsyncAction.Failure(Exception("Failed to deactivate account")) + ), + ) +} + +internal fun aDeactivateFormState( + eraseData: Boolean = false, + password: String = "", +) = DeactivateFormState( + eraseData = eraseData, + password = password, +) + +internal fun anAccountDeactivationState( + deactivateFormState: DeactivateFormState = aDeactivateFormState(), + accountDeactivationAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (AccountDeactivationEvents) -> Unit = {}, +) = AccountDeactivationState( + deactivateFormState = deactivateFormState, + accountDeactivationAction = accountDeactivationAction, + eventSink = eventSink, +) diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt new file mode 100644 index 0000000..c0d625a --- /dev/null +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt @@ -0,0 +1,321 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +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.ColumnScope +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.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.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.Modifier +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.deactivation.impl.R +import io.element.android.features.logout.impl.ui.AccountDeactivationActionDialog +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem +import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.form.textFieldState +import io.element.android.libraries.designsystem.components.list.SwitchListItem +import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextField +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.persistentListOf + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountDeactivationView( + state: AccountDeactivationState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val eventSink = state.eventSink + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + navigationIcon = { + BackButton(onClick = onBackClick) + }, + titleStr = stringResource(R.string.screen_deactivate_account_title), + ) + }, + ) { padding -> + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .imePadding() + .padding(padding) + .consumeWindowInsets(padding) + .verticalScroll(state = scrollState) + .padding(vertical = 16.dp, horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Content( + state = state, + onSubmitClick = { + eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) + } + ) + Spacer(modifier = Modifier.height(32.dp)) + Buttons( + state = state, + onSubmitClick = { + eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) + } + ) + } + } + AccountDeactivationActionDialog( + state.accountDeactivationAction, + onConfirmClick = { + eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) + }, + onRetryClick = { + eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = true)) + }, + onDismissDialog = { + eventSink(AccountDeactivationEvents.CloseDialogs) + }, + ) +} + +@Composable +private fun ColumnScope.Buttons( + state: AccountDeactivationState, + onSubmitClick: () -> Unit, +) { + val logoutAction = state.accountDeactivationAction + Button( + text = stringResource(CommonStrings.action_deactivate), + showProgress = logoutAction is AsyncAction.Loading, + destructive = true, + enabled = state.submitEnabled, + modifier = Modifier.fillMaxWidth(), + onClick = onSubmitClick, + ) +} + +@Composable +private fun Content( + state: AccountDeactivationState, + onSubmitClick: () -> Unit, +) { + val isLoading by remember(state.deactivateFormState) { + derivedStateOf { + state.accountDeactivationAction is AsyncAction.Loading + } + } + val eraseData = state.deactivateFormState.eraseData + var passwordFieldState by textFieldState(stateValue = state.deactivateFormState.password) + + val focusManager = LocalFocusManager.current + val eventSink = state.eventSink + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = buildAnnotatedStringWithStyledPart( + R.string.screen_deactivate_account_description, + R.string.screen_deactivate_account_description_bold_part, + color = ElementTheme.colors.textSecondary, + bold = true, + underline = false, + ), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + InfoListOrganism( + items = persistentListOf( + InfoListItem( + message = buildAnnotatedStringWithStyledPart( + R.string.screen_deactivate_account_list_item_1, + R.string.screen_deactivate_account_list_item_1_bold_part, + color = ElementTheme.colors.textSecondary, + bold = true, + underline = false, + ), + iconComposable = { + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.Close(), + contentDescription = null, + tint = ElementTheme.colors.iconCriticalPrimary, + ) + }, + ), + InfoListItem( + message = stringResource(R.string.screen_deactivate_account_list_item_2), + iconComposable = { + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.Close(), + contentDescription = null, + tint = ElementTheme.colors.iconCriticalPrimary, + ) + }, + ), + InfoListItem( + message = stringResource(R.string.screen_deactivate_account_list_item_3), + iconComposable = { + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.Close(), + contentDescription = null, + tint = ElementTheme.colors.iconCriticalPrimary, + ) + }, + ), + InfoListItem( + message = stringResource(R.string.screen_deactivate_account_list_item_4), + iconComposable = { + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.Check(), + contentDescription = null, + tint = ElementTheme.colors.iconSuccessPrimary, + ) + }, + ), + ), + textStyle = ElementTheme.typography.fontBodyMdRegular, + textColor = ElementTheme.colors.textSecondary, + iconTint = ElementTheme.colors.iconSuccessPrimary, + backgroundColor = Color.Transparent, + ) + + Column { + SwitchListItem( + headline = stringResource(R.string.screen_deactivate_account_delete_all_messages), + value = eraseData, + onChange = { + eventSink(AccountDeactivationEvents.SetEraseData(it)) + }, + enabled = !isLoading, + ) + Text( + modifier = Modifier.padding(start = 16.dp), + text = stringResource(R.string.screen_deactivate_account_delete_all_messages_notice), + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + ) { + var passwordVisible by remember { mutableStateOf(false) } + if (isLoading) { + // Ensure password is hidden when user submits the form + passwordVisible = false + } + TextField( + value = passwordFieldState, + label = stringResource(CommonStrings.action_confirm_password), + readOnly = isLoading, + modifier = Modifier + .fillMaxWidth() + .onTabOrEnterKeyFocusNext(focusManager) + .testTag(TestTags.loginPassword) + .semantics { + contentType = ContentType.Password + }, + onValueChange = { + val sanitized = it.sanitize() + passwordFieldState = sanitized + eventSink(AccountDeactivationEvents.SetPassword(sanitized)) + }, + placeholder = stringResource(CommonStrings.common_password), + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + val image = + if (passwordVisible) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff() + val description = + if (passwordVisible) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password) + + Box(modifier = Modifier.clickable { passwordVisible = !passwordVisible }) { + Icon(imageVector = image, description) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { onSubmitClick() } + ), + singleLine = true, + ) + } + } +} + +/** + * Ensure that the string does not contain any new line characters, which can happen when pasting values. + */ +private fun String.sanitize(): String { + return replace("\n", "") +} + +@PreviewsDayNight +@Composable +internal fun AccountDeactivationViewPreview( + @PreviewParameter(AccountDeactivationStateProvider::class) state: AccountDeactivationState, +) = ElementPreview { + AccountDeactivationView( + state, + onBackClick = {}, + ) +} diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPoint.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPoint.kt new file mode 100644 index 0000000..c93d519 --- /dev/null +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.deactivation.api.AccountDeactivationEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultAccountDeactivationEntryPoint : AccountDeactivationEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext): Node { + return parentNode.createNode(buildContext) + } +} diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationActionDialog.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationActionDialog.kt new file mode 100644 index 0000000..84037d7 --- /dev/null +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationActionDialog.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.dialogs.RetryDialog +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun AccountDeactivationActionDialog( + state: AsyncAction, + onConfirmClick: () -> Unit, + onRetryClick: () -> Unit, + onDismissDialog: () -> Unit, +) { + when (state) { + AsyncAction.Uninitialized -> + Unit + is AsyncAction.Confirming -> + AccountDeactivationConfirmationDialog( + onSubmitClick = onConfirmClick, + onDismiss = onDismissDialog + ) + is AsyncAction.Loading -> + ProgressDialog(text = stringResource(CommonStrings.common_please_wait)) + is AsyncAction.Failure -> + RetryDialog( + title = stringResource(id = CommonStrings.dialog_title_error), + content = stringResource(id = CommonStrings.error_unknown), + onRetry = onRetryClick, + onDismiss = onDismissDialog, + ) + is AsyncAction.Success -> Unit + } +} diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationConfirmationDialog.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationConfirmationDialog.kt new file mode 100644 index 0000000..905112a --- /dev/null +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationConfirmationDialog.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.features.deactivation.impl.R +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun AccountDeactivationConfirmationDialog( + onSubmitClick: () -> Unit, + onDismiss: () -> Unit, +) { + ConfirmationDialog( + title = stringResource(id = R.string.screen_deactivate_account_title), + content = stringResource(R.string.screen_deactivate_account_confirmation_dialog_content), + submitText = stringResource(id = CommonStrings.action_deactivate), + onSubmitClick = onSubmitClick, + onDismiss = onDismiss, + destructiveSubmit = true, + ) +} diff --git a/features/deactivation/impl/src/main/res/values-be/translations.xml b/features/deactivation/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..f950e4e --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,11 @@ + + + "Калі ласка, пацвердзіце, што вы хочаце дэактываваць свой уліковы запіс. Гэта дзеянне нельга адмяніць." + "Выдаліць усе мае паведамленні" + "Увага: будучыя карыстальнікі могуць бачыць няпоўныя размовы." + "незваротны" + "Назаўсёды адключыць" + "Выдаліць вас з усіх чатаў." + "Выдаліце інфармацыю аб сваім уліковым запісе з нашага сервера ідэнтыфікацыі." + "Дэактываваць уліковы запіс" + diff --git a/features/deactivation/impl/src/main/res/values-bg/translations.xml b/features/deactivation/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..34ad5b4 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,5 @@ + + + "Моля, потвърдете, че искате да деактивирате акаунта си. Това действие не може да бъде отменено." + "Деактивиране на акаунта" + diff --git a/features/deactivation/impl/src/main/res/values-cs/translations.xml b/features/deactivation/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..e0f4fd1 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,14 @@ + + + "Potvrďte prosím, že chcete svůj účet deaktivovat. Tuto akci nelze vrátit zpět." + "Smazat všechny mé zprávy" + "Upozornění: Budoucí uživatelé mohou vidět neúplné konverzace." + "Deaktivace vašeho účtu je %1$s, což způsobí:" + "nezvratná" + "%1$s váš účet (nemůžete se znovu přihlásit a vaše ID nelze znovu použít)." + "Trvale zakázat" + "Odebere vás ze všech chatovacích místností." + "Odstraní informace o vašem účtu z našeho serveru identit." + "Vaše zprávy budou stále viditelné registrovaným uživatelům, ale nebudou dostupné novým ani neregistrovaným uživatelům, pokud se rozhodnete je smazat." + "Deaktivovat účet" + diff --git a/features/deactivation/impl/src/main/res/values-cy/translations.xml b/features/deactivation/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..e49073c --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,14 @@ + + + "Cadarnhewch eich bod am gau\'r cyfrif. Does dom modd dadwneud y weithred hon." + "Dileu fy holl negeseuon" + "Rhybudd: Mae\'n bosibl y bydd defnyddwyr y dyfodol yn gweld sgyrsiau anghyflawn." + "Bydd cau eich cyfrif yn %1$s yn:" + "dim modd ei adfer" + "%1$s eich cyfrif (does dim modd i chi fewngofnodi eto, ac nid oes modd ailddefnyddio\'ch dull adnabod)." + "Analluogi\'n barhaol" + "Eich tynnu o bob ystafell sgwrsio." + "Dileu manylion eich cyfrif o\'n gweinydd hunaniaeth." + "Bydd eich negeseuon yn dal i fod yn weladwy i ddefnyddwyr cofrestredig ond fyddan nhw ddim ar gael i ddefnyddwyr newydd neu anghofrestredig os byddwch yn dewis eu dileu." + "Cau cyfrif" + diff --git a/features/deactivation/impl/src/main/res/values-da/translations.xml b/features/deactivation/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..c6dcb17 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,14 @@ + + + "Bekræft venligst, at du vil deaktivere din konto. Denne handling kan ikke fortrydes." + "Slet alle mine beskeder" + "Advarsel: Fremtidige brugere kan muligvis se ufuldstændige samtaler." + "Deaktivering af din konto er %1$s, det vil:" + "irreversibel" + "%1$s din konto (du kan ikke logge ind igen, og dit ID kan ikke genbruges)." + "Permanent deaktivere" + "Fjerne dig fra alle samtaler" + "Slette dine kontooplysninger fra vores identitetsserver." + "Dine beskeder vil stadig være synlige for registrerede brugere, men vil ikke være tilgængelige for nye eller uregistrerede brugere, hvis du vælger at slette dem." + "Deaktiver konto" + diff --git a/features/deactivation/impl/src/main/res/values-de/translations.xml b/features/deactivation/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..1aec749 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,14 @@ + + + "Bitte bestätige, dass du dein Konto deaktivieren möchtest. Dies kann nicht rückgängig gemacht werden." + "Lösche alle meine Nachrichten" + "Warnung: Künftigen Nutzern werden möglicherweise unvollständige Konversationen angezeigt." + "Dein Konto zu deaktivieren ist %1$s. Folgendes wird passieren:" + "irreversibel" + "%1$s dein Konto (du kannst dich nicht erneut anmelden und deine ID kann nicht wiederverwendet werden)." + "Dauerhaft deaktivieren" + "Du wirst aus allen Chats entfernt." + "Lösche deine Kontoinformationen von unserem Identitätsserver." + "Deine Nachrichten werden für bereits registrierte Nutzer weiterhin sichtbar sein. Für neue oder unregistrierte Nutzer sind sie nicht verfügbar, wenn du sie löschen solltest." + "Nutzerkonto deaktivieren" + diff --git a/features/deactivation/impl/src/main/res/values-el/translations.xml b/features/deactivation/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..ac645f3 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,14 @@ + + + "Παρακαλώ επιβεβαίωσε ότι θες να απενεργοποιήσεις τον λογαριασμό σου. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί." + "Διαγραφή όλων των μηνυμάτων μου" + "Προειδοποίηση: Οι μελλοντικοί χρήστες ενδέχεται να βλέπουν ελλιπείς συνομιλίες." + "Η απενεργοποίηση του λογαριασμού σας είναι %1$s, θα:" + "μη αναστρέψιμο" + "%1$s τον λογαριασμό σου (δεν μπορείς να συνδεθείς ξανά και το αναγνωριστικό σου δεν μπορεί να επαναχρησιμοποιηθεί)." + "Μόνιμη απενεργοποίηση" + "Αποχώρησή σας από όλες τις αίθουσες συνομιλίας." + "Διαγράψει τα στοιχεία του λογαριασμού σου από τον διακομιστή ταυτότητάς μας." + "Τα μηνύματά σου θα εξακολουθούν να είναι ορατά στους εγγεγραμμένους χρήστες, αλλά δεν θα είναι διαθέσιμα σε νέους ή μη εγγεγραμμένους χρήστες εάν επιλέξεις να τα διαγράψεις." + "Απενεργοποίηση λογαριασμού" + diff --git a/features/deactivation/impl/src/main/res/values-es/translations.xml b/features/deactivation/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..cd0757b --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,14 @@ + + + "Confirma que quieres desactivar tu cuenta. Esta acción no se puede deshacer." + "Borrar todos mis mensajes" + "Advertencia: Futuros usuarios pueden ver conversaciones incompletas." + "Desactivar tu cuenta es %1$s:" + "irreversible" + "%1$s tu cuenta (no podrás volver a iniciar sesión y tu ID no se podrá volver a utilizar)." + "Inhabilitará permanentemente" + "Te eliminará de todas las salas de chat." + "Eliminará la información de tu cuenta de nuestro servidor de identidad." + "Tus mensajes seguirán siendo visibles para los usuarios registrados, pero no estarán disponibles para los usuarios nuevos o no registrados si decides eliminarlos." + "Desactivar cuenta" + diff --git a/features/deactivation/impl/src/main/res/values-et/translations.xml b/features/deactivation/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..95695fe --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,14 @@ + + + "Palun kinnita uuesti, et soovid eemaldada oma konto kasutusest" + "Kustuta kõik minu sõnumid" + "Hoiatus: tulevased kasutajad võivad näha poolikuid vestlusi." + "Sinu konto kasutusest eemaldamine on %1$s ja sellega:" + "pöördumatu" + "Sinu kasutajakonto %1$s (sa ei saa enam sellega võrku logida ning kasutajatunnust ei saa enam pruukida)." + "jäädavalt eemaldatakse kasutusest" + "Sind logitakse välja kõikidest jututubadest." + "Kustutatakse sinu andmed meie isikutuvastusserverist." + "Sinu sõnumid on jätkuvalt nähtavad registreeritud kasutajatele, kuid kui otsustad sõnumid kustutada, siis nad nad pole nähtavad uutele ja registreerimata kasutajatele." + "Eemalda konto kasutusest" + diff --git a/features/deactivation/impl/src/main/res/values-eu/translations.xml b/features/deactivation/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..3df431c --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,12 @@ + + + "Baieztatu zure kontua desaktibatu nahi duzula. Ekintza hau ezin da desegin." + "Ezabatu nire mezu guztiak" + "Kontuaren desaktibazioa %1$s, honakoa eragingo du:" + "ezin da desegin" + "Ezgaitu betiko" + "Kendu zure burua txat gela guztietatik." + "Ezabatu zure kontuaren informazioa gure identitate-zerbitzaritik." + "Zure mezuak erregistratutako erabiltzaileentzat ikusgai egongo dira oraindik, baina ezabatzen badituzu, ez dira eskuragarri egongo erabiltzaile berri edo erregistratu gabeentzat." + "Desaktibatu kontua" + diff --git a/features/deactivation/impl/src/main/res/values-fa/translations.xml b/features/deactivation/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..5c4eb00 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,9 @@ + + + "حذف همهٔ پیام‌هایم" + "بازگشت‌ناپذیر" + "از کار انداختن دایمی" + "برداشتنتان از همهٔ اتاق‌های گپ." + "حذف اطّلاعات حسابتان از کارساز هویت." + "غیرفعّال‌سازی حساب" + diff --git a/features/deactivation/impl/src/main/res/values-fi/translations.xml b/features/deactivation/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..df2543b --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,14 @@ + + + "Vahvista, että haluat deaktivoida tilisi. Tätä ei voi perua." + "Poista kaikki viestini" + "Varoitus: Tulevaisuudessa muut voivat nähdä puutteellisia keskusteluja." + "Tilisi deaktivointia %1$s. Jos teet sen:" + "ei voi peruuttaa" + "Tilisi %1$s (et voi kirjautua takaisin sisään, eikä tunnustasi voi käyttää uudelleen)." + "poistetaan käytöstä pysyvästi" + "Sinut poistetaan kaikista keskusteluhuoneista." + "Tilitietosi poistetaan identiteettipalvelimeltamme." + "Viestisi näkyvät edelleen rekisteröityneille käyttäjille, mutta ne eivät ole uusien tai rekisteröimättömien käyttäjien saatavilla, jos päätät poistaa ne." + "Deaktivoi tili" + diff --git a/features/deactivation/impl/src/main/res/values-fr/translations.xml b/features/deactivation/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..675ac1e --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,14 @@ + + + "Veuillez confirmer que vous souhaitez désactiver votre compte. Cette action ne peut pas être annulée." + "Supprimer tous mes messages" + "Attention : les futurs utilisateurs pourraient voir des conversations incomplètes." + "La désactivation de votre compte est %1$s, cela va :" + "irréversible" + "%1$s votre compte (vous ne pourrez plus vous reconnecter et votre identifiant ne pourra pas être réutilisé)." + "Désactiver définitivement" + "Vous retirer de tous les salons et toutes les discussions." + "Supprimer les informations de votre compte du serveur d’identité." + "Rendre vos messages invisibles aux futurs membres des salons si vous choisissez de les supprimer. Vos messages seront toujours visibles pour les utilisateurs qui les ont déjà récupérés." + "Désactiver le compte" + diff --git a/features/deactivation/impl/src/main/res/values-hu/translations.xml b/features/deactivation/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..3d3722b --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,14 @@ + + + "Erősítse meg, hogy deaktiválja a fiókját. Ez a művelet nem vonható vissza." + "Összes saját üzenet törlése" + "Figyelmeztetés: A jövőbeli felhasználók hiányos beszélgetéseket láthatnak." + "A fiók deaktiválása %1$s, a következőket okozza:" + "visszafordíthatatlan" + "%1$s a fiókját (nem fog tudni újra bejelentkezni, és az azonosítója nem használható újra)." + "Véglegesen letiltja" + "Eltávolításra kerül az összes csevegőszobából." + "Törlésre kerülnek a fiókadatai az azonosítási kiszolgálónkról." + "Üzenetei továbbra is láthatóak maradnak a regisztrált felhasználók számára, de nem lesznek elérhetőek az új vagy nem regisztrált felhasználók számára, ha úgy dönt, hogy törli őket." + "Fiók deaktiválása" + diff --git a/features/deactivation/impl/src/main/res/values-in/translations.xml b/features/deactivation/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..e255ad3 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,14 @@ + + + "Harap konfirmasi bahwa Anda ingin menonaktifkan akun Anda. Tindakan ini tidak dapat diurungkan." + "Hapus semua pesan saya" + "Peringatan: Pengguna masa depan mungkin melihat percakapan yang tidak lengkap." + "Penonaktifan akun Anda %1$s, ini akan:" + "tidak dapat diurungkan" + "%1$s akun Anda (Anda tidak dapat masuk kembali, dan ID Anda tidak dapat digunakan kembali)." + "Nonaktifkan secara permanen" + "Mengeluarkan Anda dari semua ruangan obrolan." + "Hapus informasi akun Anda dari server identitas kami." + "Pesan Anda akan tetap terlihat oleh pengguna terdaftar tetapi tidak akan tersedia bagi pengguna baru atau tidak terdaftar jika Anda memilih untuk menghapusnya." + "Nonaktifkan akun" + diff --git a/features/deactivation/impl/src/main/res/values-it/translations.xml b/features/deactivation/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..3fbc9d5 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,14 @@ + + + "Conferma di voler disattivare il tuo account. Questa azione è irreversibile." + "Elimina tutti i miei messaggi" + "Attenzione: gli utenti futuri potrebbero vedere conversazioni incomplete." + "La disattivazione del tuo account è %1$s , quindi:" + "irreversibile" + "%1$s il tuo account (non puoi riaccedere e il tuo ID non può essere riutilizzato)." + "Disattiva permanentemente" + "Ti rimuove da tutte le stanze di chat." + "Elimina le informazioni del tuo account dal nostro server di identità." + "I tuoi messaggi saranno ancora visibili agli utenti registrati, ma non saranno disponibili per gli utenti nuovi o non registrati se decidi di eliminarli." + "Disattiva account" + diff --git a/features/deactivation/impl/src/main/res/values-ko/translations.xml b/features/deactivation/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..6b7953a --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,14 @@ + + + "계정을 비활성화하시겠습니까? 이 작업은 되돌릴 수 없습니다." + "모든 내 메시지 삭제" + "경고: 향후 사용자는 불완전한 대화 내용을 볼 수 있습니다." + "계정을 비활성화하는 것은 %1$s 이며, 다음과 같은 조치를 취합니다:" + "불가역적" + "%1$s 귀하의 계정 (로그인할 수 없으며, 귀하의 ID는 재사용할 수 없습니다)." + "영구적으로 비활성화" + "모든 채팅방에서 자신을 제거하세요." + "당사의 신원 서버에서 귀하의 계정 정보를 삭제하세요." + "메시지는 등록된 사용자에게는 계속 표시되지만, 삭제하면 신규 또는 미등록 사용자는 볼 수 없게 됩니다." + "계정 비활성화" + diff --git a/features/deactivation/impl/src/main/res/values-nb/translations.xml b/features/deactivation/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..cdff7f8 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,14 @@ + + + "Bekreft at du vil deaktivere kontoen din. Denne handlingen kan ikke angres." + "Slett alle meldingene mine" + "Advarsel: Fremtidige brukere vil kunne se ufullstendige samtaler." + "Deaktivering av kontoen din er %1$s , det vil:" + "irreversibel" + "%1$s kontoen din (du kan ikke logge på igjen, og ID-en din kan ikke brukes på nytt)." + "Deaktiver permanent" + "Fjern deg fra alle chatterom." + "Slett kontoinformasjonen din fra vår identitetsserver." + "Meldingene dine vil fortsatt være synlige for registrerte brukere, men vil ikke være tilgjengelige for nye eller uregistrerte brukere hvis du velger å slette dem." + "Deaktiver kontoen" + diff --git a/features/deactivation/impl/src/main/res/values-nl/translations.xml b/features/deactivation/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..e03ffa5 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,14 @@ + + + "Bevestig dat je je account wilt sluiten. Deze actie kan niet ongedaan worden gemaakt." + "Verwijder al mijn berichten" + "Waarschuwing: Toekomstige gebruikers kunnen onvolledige gesprekken te zien krijgen." + "Je account sluiten is %1$s, het zal:" + "onomkeerbaar" + "Je account %1$s (je kunt niet opnieuw inloggen en je ID kan niet opnieuw worden gebruikt)" + "permanent uitschakelen" + "Je verwijderen uit alle chatkamers." + "Je accountgegevens verwijderen van onze identiteitsserver." + "Je berichten zijn nog steeds zichtbaar voor geregistreerde gebruikers, maar niet beschikbaar voor nieuwe of niet-geregistreerde gebruikers als je ervoor kiest ze te verwijderen." + "Account sluiten" + diff --git a/features/deactivation/impl/src/main/res/values-pl/translations.xml b/features/deactivation/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..bddb6a9 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,14 @@ + + + "Potwierdź dezaktywacje konta. Tej akcji nie można cofnąć." + "Usuń wszystkie moje wiadomości" + "Ostrzeżenie: Przyszli użytkownicy mogą zobaczyć niekompletne rozmowy." + "Dezaktywacja konta jest %1$s, zostanie:" + "nieodwracalna" + "%1$s twoje konto (nie będziesz mógł się zalogować, a twoje ID przepadnie)." + "Permanentnie wyłączy" + "Usunie Ciebie ze wszystkich pokoi rozmów." + "Usunięte wszystkie dane konta z naszego serwera tożsamości." + "Twoje wiadomości wciąż będą widoczne dla zarejestrowanych użytkowników, ale nie będą dostępne dla nowych lub niezarejestrowanych użytkowników, jeśli je usuniesz." + "Dezaktywuj konto" + diff --git a/features/deactivation/impl/src/main/res/values-pt-rBR/translations.xml b/features/deactivation/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..7000a65 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,14 @@ + + + "Confirme que você deseja desativar sua conta. Essa ação não pode ser desfeita." + "Apagar todas as minhas mensagens" + "Alerta: Usuários futuros podem ver conversas incompletas." + "Desativar sua conta é %1$s, isso irá:" + "irreversível" + "%1$s (você não poderá entrar novamente, e seu ID não poderá ser reutilizado)." + "Desativar a sua conta permanentemente" + "Te remover de todas as salas de conversa." + "Apague as informações da sua conta do nosso servidor de identidade." + "Suas mensagens ainda estarão visíveis para os usuários registrados, mas não estarão disponíveis para usuários novos ou não registrados se você optar por apagá-las." + "Desativar conta" + diff --git a/features/deactivation/impl/src/main/res/values-pt/translations.xml b/features/deactivation/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..0a8c618 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,14 @@ + + + "Confirma que pretendes desativar a tua conta. Esta ação não pode ser desfeita." + "Eliminar todas as minhas mensagens" + "Aviso: futuros usuários podem ver conversas incompletas." + "A desativação da sua conta é %1$s, irá:" + "irreversível" + "%1$s sua conta (não pode voltar a iniciar sessão e o seu ID não pode ser reutilizado)." + "Desativar permanentemente" + "Removê-lo de todas as salas de chat." + "Exclua as informações da sua conta do nosso servidor de identidade." + "As tuas mensagens continuarão a ser visíveis para os utilizadores registados, mas não estarão disponíveis para os utilizadores novos ou não registados se optares por as apagar." + "Desativar conta" + diff --git a/features/deactivation/impl/src/main/res/values-ro/translations.xml b/features/deactivation/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..acd4c07 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,14 @@ + + + "Vă rugăm să confirmați că doriți să vă dezactivați contul. Această acțiune nu poate fi anulată." + "Ștergeți toate mesajele mele" + "Avertisment: este posibil ca viitorii utilizatori să vadă conversații incomplete." + "Dezactivarea contului dumneavoastră este %1$s, acesta va:" + "ireversibilă" + "%1$s contul dumneavoastră (nu vă puteți conecta din nou, iar ID-ul dvs. nu poate fi reutilizat)." + "Dezactivați permanent" + "Îndepărta din toate camerele de chat." + "Șterge informațiile contului dumneavoastră de pe serverul nostru de identitate." + "Mesajele dumneavoastră vor fi în continuare vizibile pentru utilizatorii înregistrați, dar nu vor fi disponibile pentru utilizatorii noi sau neînregistrați dacă alegeți să le ștergeți." + "Dezactivați contul" + diff --git a/features/deactivation/impl/src/main/res/values-ru/translations.xml b/features/deactivation/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..ccd38f2 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,14 @@ + + + "Вы уверены, что хотите отключить свою учётную запись? Данное действие не может быть отменено." + "Удалить все мои сообщения" + "Предупреждение: будущие пользователи могут увидеть незавершенные разговоры." + "Отключение вашей учетной записи %1$s и означает следующее:" + "необратимо" + "Ваша учётная запись будет %1$s (вы не сможете войти в неё снова, и ваш ID не может быть использован повторно)." + "отключена навсегда" + "Вы будете удалены из всех чатов." + "Данные вашей учётной записи будут удалены с нашего сервера идентификации." + "Ваши сообщения по-прежнему будут видны зарегистрированным пользователям, но не будут доступны новым или незарегистрированным пользователям, если вы решите удалить их." + "Отключить учётную запись" + diff --git a/features/deactivation/impl/src/main/res/values-sk/translations.xml b/features/deactivation/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..42470ce --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,14 @@ + + + "Prosím potvrďte, že chcete deaktivovať svoj účet. Túto akciu nie je možné vrátiť späť." + "Vymazať všetky moje správy" + "Upozornenie: Budúcim používateľom sa môžu zobraziť neúplné konverzácie." + "Deaktivácia vášho účtu znamená %1$s, že:" + "nezvratný" + "%1$s váš účet (nebudete sa môcť znova prihlásiť a vaše ID nebude možné znova použiť)." + "Natrvalo zakázať" + "Odstrániť vás zo všetkých miestností." + "Odstrániť informácie o vašom účte z nášho servera totožností." + "Vaše správy budú stále viditeľné pre registrovaných používateľov, ale nebudú dostupné pre nových alebo neregistrovaných používateľov, ak sa ich rozhodnete odstrániť." + "Deaktivovať účet" + diff --git a/features/deactivation/impl/src/main/res/values-sv/translations.xml b/features/deactivation/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..03fb92a --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,14 @@ + + + "Vänligen bekräfta att du vill avaktivera ditt konto. Denna åtgärd kan inte ångras." + "Radera alla mina meddelanden" + "Varning: Framtida användare kan se ofullständiga konversationer." + "Att inaktivera ditt konto är %1$s, det kommer att:" + "oåterkallelig" + "%1$s ditt konto (du kan inte logga in igen och ditt ID kan inte återanvändas)." + "Permanent avaktivera" + "Ta bort dig från alla chattrum." + "Radera din kontoinformation från vår identitetsserver." + "Dina meddelanden kommer fortfarande att vara synliga för registrerade användare men kommer inte att vara tillgängliga för nya eller oregistrerade användare om du väljer att radera dem." + "Inaktivera konto" + diff --git a/features/deactivation/impl/src/main/res/values-tr/translations.xml b/features/deactivation/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..cc61b80 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,14 @@ + + + "Lütfen hesabınızı devre dışı bırakmak istediğinizi onaylayın. Bu işlem geri alınamaz." + "Tüm mesajlarımı sil" + "Uyarı: Gelecekteki kullanıcılar eksik konuşmalar görebilir." + "Hesabınızı devre dışı bırakmak %1$s, şunları yapacaktır:" + "geri alınamaz" + "%1$s (tekrar giriş yapamazsınız ve kimliğiniz yeniden kullanılamaz)." + "Kalıcı olarak devre dışı bırak" + "Sizi tüm sohbet odalarından çıkarmak." + "Hesap bilgileriniz kimlik sunucumuzdan silinecek." + "Mesajlarınız kayıtlı kullanıcılar tarafından görülmeye devam eder, ancak silmeyi seçerseniz yeni veya kayıtlı olmayan kullanıcılar tarafından görüntülenemeyecek." + "Hesabı devre dışı bırak" + diff --git a/features/deactivation/impl/src/main/res/values-uk/translations.xml b/features/deactivation/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..04b32df --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,14 @@ + + + "Будь ласка, підтвердіть, що ви хочете деактивувати свій обліковий запис. Ця дія не може бути скасована." + "Видалити всі мої повідомлення" + "Попередження: майбутні користувачі можуть бачити неповні розмови." + "Деактивація вашого облікового запису%1$s , це буде:" + "незворотні" + "%1$sваш обліковий запис (ви не можете знову увійти, а ваш ідентифікатор не може бути використаний повторно)." + "Назавжди відключити" + "Видалити вас з усіх чатів." + "Видаліть інформацію свого облікового запису з нашого сервера ідентифікації." + "Ваші повідомлення залишатимуться видимими для зареєстрованих користувачів, але недоступними для нових або незареєстрованих користувачів, якщо ви вирішите їх видалити." + "Деактивувати обліковий запис" + diff --git a/features/deactivation/impl/src/main/res/values-ur/translations.xml b/features/deactivation/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..297b29c --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,14 @@ + + + "براہ کرم تصدیق کریں کہ آپ اپنا اکاؤنٹ غیر فعال کرنا چاہتے ہیں۔ اس کارروائی کو کالعدم نہیں کیا جا سکتا۔" + "میرے تمام پیغامات ڈیلیٹ کریں۔" + "انتباہ: مستقبل کے صارفین نامکمل گفتگو دیکھ سکتے ہیں۔" + "اپنے اکاؤنٹ کو غیر فعال کرنا %1$s ہے، یہ کرے گا:" + "ناقابل واپسی" + "‏%1$s آپ کا اکاؤنٹ (آپ دوبارہ لاگ ان نہیں ہو سکتے، اور آپ کی ID کو دوبارہ استعمال نہیں کیا جا سکتا)۔" + "مستقل طور پر غیر فعال کریں" + "آپ کو تمام چیت رومز سے ہٹا دے گا۔" + "ہمارے شناختی سرور سے اپنے اکاؤنٹ کی معلومات کو حذف کریں۔" + "آپ کے پیغامات اب بھی رجسٹرڈ صارفین کو نظر آئیں گے لیکن اگر آپ انہیں حذف کرنے کا انتخاب کرتے ہیں تو نئے یا غیر رجسٹرڈ صارفین کے لیے دستیاب نہیں ہوں گے۔" + "اکاؤنٹ کو غیر فعال کریں" + diff --git a/features/deactivation/impl/src/main/res/values-uz/translations.xml b/features/deactivation/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..19a70bb --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,14 @@ + + + "Iltimos, hisobingizni o‘chirishni xohlayotganingizni tasdiqlang. Bu amalni qaytarib bo‘lmaydi." + "Barcha xabarlarimni o‘chirib tashlang" + "Ogohlantirish: Kelgusi foydalanuvchilar chala suhbatlarni ko‘rishi mumkin." + "Hisobingiz %1$s faolsizlantirilmoqda, u quyidagilarni bajaradi:" + "qaytarilmas" + "%1$s hisobingiz (qaytadan kirolmaysiz va ID qayta ishlatilmaydi)." + "Butunlay faolsizlantirish" + "Sizni barcha chat xonalaridan olib tashlash." + "Hisobingiz haqidagi axborotni identifikatsiya serverimizdan o‘chirib tashlang." + "Xabarlaringiz ro‘yxatdan o‘tgan foydalanuvchilarga ko‘rinadi, lekin ularni o‘chirishni tanlasangiz, yangi yoki ro‘yxatdan o‘tmagan foydalanuvchilarga ko‘rinmaydi." + "Hisobni faolsizlantirish" + diff --git a/features/deactivation/impl/src/main/res/values-zh-rTW/translations.xml b/features/deactivation/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..a099a67 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,14 @@ + + + "請確認您想要停用您的帳號。此動作無法還原。" + "刪除我所有的訊息" + "警告:未來的使用者可能會看到不完整的對話。" + "停用您的帳號為 %1$s,它將:" + "不可逆" + "%1$s 您的帳號(您將無法重新登入,也無法重用您的 ID)。" + "永久停用" + "將您從所有聊天室移除。" + "從我們的身份伺服器將您的帳號資訊刪除。" + "註冊使用者仍可看到您的訊息,但如果您選擇刪除,新使用者與未註冊的使用者將看不到它們。" + "停用帳號" + diff --git a/features/deactivation/impl/src/main/res/values-zh/translations.xml b/features/deactivation/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..ca24375 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,14 @@ + + + "请确认您要停用您的账户。此操作无法撤消。" + "删除我的所有消息" + "警告:未来的用户可能会看到不完整的对话。" + "停用您的帐户是%1$s,它将:" + "不可逆转的" + "%1$s您的账户(您无法登录回来,并且您的ID无法重复使用)。" + "永久禁用" + "将您从所有聊天房间中移除。" + "从我们的身份服务器中删除您的账户信息。" + "注册用户仍可看到您的消息,但如果您选择删除它们,新用户或未注册用户将无法看到您的消息。" + "停用账户" + diff --git a/features/deactivation/impl/src/main/res/values/localazy.xml b/features/deactivation/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..0380cf1 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values/localazy.xml @@ -0,0 +1,14 @@ + + + "Please confirm that you want to deactivate your account. This action cannot be undone." + "Delete all my messages" + "Warning: Future users may see incomplete conversations." + "Deactivating your account is %1$s, it will:" + "irreversible" + "%1$s your account (you can\'t log back in, and your ID can\'t be reused)." + "Permanently disable" + "Remove you from all chat rooms." + "Delete your account information from our identity server." + "Your messages will still be visible to registered users but won’t be available to new or unregistered users if you choose to delete them." + "Deactivate account" + diff --git a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenterTest.kt b/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenterTest.kt new file mode 100644 index 0000000..ee7f8e4 --- /dev/null +++ b/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenterTest.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class AccountDeactivationPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.accountDeactivationAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(initialState.deactivateFormState).isEqualTo(DeactivateFormState.Default) + } + } + + @Test + fun `present - form update`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.deactivateFormState).isEqualTo(DeactivateFormState.Default) + initialState.eventSink(AccountDeactivationEvents.SetEraseData(true)) + val updatedState = awaitItem() + assertThat(updatedState.deactivateFormState).isEqualTo(DeactivateFormState.Default.copy(eraseData = true)) + assertThat(updatedState.submitEnabled).isFalse() + updatedState.eventSink(AccountDeactivationEvents.SetPassword("password")) + val updatedState2 = awaitItem() + assertThat(updatedState2.deactivateFormState).isEqualTo(DeactivateFormState(password = "password", eraseData = true)) + assertThat(updatedState2.submitEnabled).isTrue() + } + } + + @Test + fun `present - submit`() = runTest { + val recorder = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val matrixClient = FakeMatrixClient( + deactivateAccountResult = recorder + ) + val presenter = createPresenter(matrixClient) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(AccountDeactivationEvents.SetPassword("password")) + skipItems(1) + initialState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) + val updatedState = awaitItem() + assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.ConfirmingNoParams) + updatedState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) + val updatedState2 = awaitItem() + assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading) + val finalState = awaitItem() + assertThat(finalState.accountDeactivationAction).isEqualTo(AsyncAction.Success(Unit)) + recorder.assertions().isCalledOnce().with(value("password"), value(false)) + } + } + + @Test + fun `present - submit with error and retry`() = runTest { + val recorder = lambdaRecorder> { _, _ -> + Result.failure(AN_EXCEPTION) + } + val matrixClient = FakeMatrixClient( + deactivateAccountResult = recorder + ) + val presenter = createPresenter(matrixClient) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(AccountDeactivationEvents.SetPassword("password")) + initialState.eventSink(AccountDeactivationEvents.SetEraseData(true)) + skipItems(2) + initialState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) + val updatedState = awaitItem() + assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.ConfirmingNoParams) + updatedState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) + val updatedState2 = awaitItem() + assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading) + val finalState = awaitItem() + assertThat(finalState.accountDeactivationAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) + recorder.assertions().isCalledOnce().with(value("password"), value(true)) + // Retry + finalState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = true)) + val finalState2 = awaitItem() + assertThat(finalState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading) + assertThat(awaitItem().accountDeactivationAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) + } + } + + @Test + fun `present - submit with error and cancel`() = runTest { + val recorder = lambdaRecorder> { _, _ -> + Result.failure(AN_EXCEPTION) + } + val matrixClient = FakeMatrixClient( + deactivateAccountResult = recorder + ) + val presenter = createPresenter(matrixClient) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(AccountDeactivationEvents.SetPassword("password")) + initialState.eventSink(AccountDeactivationEvents.SetEraseData(true)) + skipItems(2) + initialState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) + val updatedState = awaitItem() + assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.ConfirmingNoParams) + updatedState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) + val updatedState2 = awaitItem() + assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading) + val finalState = awaitItem() + assertThat(finalState.accountDeactivationAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) + recorder.assertions().isCalledOnce().with(value("password"), value(true)) + // Cancel + finalState.eventSink(AccountDeactivationEvents.CloseDialogs) + val finalState2 = awaitItem() + assertThat(finalState2.accountDeactivationAction).isEqualTo(AsyncAction.Uninitialized) + } + } +} + +internal fun createPresenter( + matrixClient: MatrixClient = FakeMatrixClient(), +) = AccountDeactivationPresenter( + matrixClient = matrixClient, +) diff --git a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationViewTest.kt b/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationViewTest.kt new file mode 100644 index 0000000..eff479d --- /dev/null +++ b/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationViewTest.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.deactivation.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_PASSWORD +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.pressTag +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class AccountDeactivationViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setAccountDeactivationView( + state = anAccountDeactivationState(eventSink = eventsRecorder), + onBackClick = it, + ) + rule.pressBack() + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on Deactivate emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setAccountDeactivationView( + state = anAccountDeactivationState( + deactivateFormState = aDeactivateFormState( + password = A_PASSWORD, + ), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_deactivate) + eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(false)) + } + + @Test + fun `clicking on Deactivate on the confirmation dialog emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setAccountDeactivationView( + state = anAccountDeactivationState( + deactivateFormState = aDeactivateFormState( + password = A_PASSWORD, + ), + accountDeactivationAction = AsyncAction.ConfirmingNoParams, + eventSink = eventsRecorder, + ), + ) + rule.pressTag(TestTags.dialogPositive.value) + eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(false)) + } + + @Test + fun `clicking on retry on the confirmation dialog emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setAccountDeactivationView( + state = anAccountDeactivationState( + deactivateFormState = aDeactivateFormState( + password = A_PASSWORD, + ), + accountDeactivationAction = AsyncAction.Failure(AN_EXCEPTION), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_retry) + eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(true)) + } + + @Test + fun `switching on the erase all switch emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setAccountDeactivationView( + state = anAccountDeactivationState( + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_deactivate_account_delete_all_messages) + eventsRecorder.assertSingle(AccountDeactivationEvents.SetEraseData(true)) + } + + @Test + fun `switching off the erase all switch emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setAccountDeactivationView( + state = anAccountDeactivationState( + deactivateFormState = aDeactivateFormState( + eraseData = true, + ), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_deactivate_account_delete_all_messages) + eventsRecorder.assertSingle(AccountDeactivationEvents.SetEraseData(false)) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `typing text in the password field emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setAccountDeactivationView( + state = anAccountDeactivationState( + deactivateFormState = aDeactivateFormState( + password = A_PASSWORD, + ), + eventSink = eventsRecorder, + ), + ) + rule.onNodeWithTag(TestTags.loginPassword.value).performTextInput("A") + eventsRecorder.assertSingle(AccountDeactivationEvents.SetPassword("A$A_PASSWORD")) + } +} + +private fun AndroidComposeTestRule.setAccountDeactivationView( + state: AccountDeactivationState, + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + AccountDeactivationView( + state = state, + onBackClick = onBackClick, + ) + } +} diff --git a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPointTest.kt b/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPointTest.kt new file mode 100644 index 0000000..a9fdf3f --- /dev/null +++ b/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPointTest.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultAccountDeactivationEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultAccountDeactivationEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + AccountDeactivationNode( + buildContext = buildContext, + plugins = plugins, + presenter = createPresenter(), + ) + } + val result = entryPoint.createNode(parentNode, BuildContext.root(null)) + assertThat(result).isInstanceOf(AccountDeactivationNode::class.java) + } +} diff --git a/features/deactivation/test/build.gradle.kts b/features/deactivation/test/build.gradle.kts new file mode 100644 index 0000000..e1050d7 --- /dev/null +++ b/features/deactivation/test/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.deactivation.test" +} + +dependencies { + implementation(projects.features.deactivation.api) + implementation(projects.libraries.architecture) + implementation(projects.tests.testutils) +} diff --git a/features/deactivation/test/src/main/kotlin/io/element/android/features/deactivation/test/FakeAccountDeactivationEntryPoint.kt b/features/deactivation/test/src/main/kotlin/io/element/android/features/deactivation/test/FakeAccountDeactivationEntryPoint.kt new file mode 100644 index 0000000..ada2483 --- /dev/null +++ b/features/deactivation/test/src/main/kotlin/io/element/android/features/deactivation/test/FakeAccountDeactivationEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.deactivation.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.deactivation.api.AccountDeactivationEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeAccountDeactivationEntryPoint : AccountDeactivationEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + ): Node { + lambdaError() + } +} diff --git a/features/enterprise/api/build.gradle.kts b/features/enterprise/api/build.gradle.kts new file mode 100644 index 0000000..1c541c5 --- /dev/null +++ b/features/enterprise/api/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.enterprise.api" +} + +dependencies { + implementation(projects.libraries.compound) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/BugReportUrl.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/BugReportUrl.kt new file mode 100644 index 0000000..86ba110 --- /dev/null +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/BugReportUrl.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.enterprise.api + +sealed interface BugReportUrl { + data object UseDefault : BugReportUrl + data object Disabled : BugReportUrl + data class Custom( + val url: String, + ) : BugReportUrl +} diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt new file mode 100644 index 0000000..ecf1ba6 --- /dev/null +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.enterprise.api + +import androidx.compose.ui.graphics.Color +import io.element.android.compound.colors.SemanticColorsLightDark +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.flow.Flow + +interface EnterpriseService { + val isEnterpriseBuild: Boolean + suspend fun isEnterpriseUser(sessionId: SessionId): Boolean + fun defaultHomeserverList(): List + suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean + + /** + * Override the brand color. + * @param sessionId the session to override the brand color for, or null to set the brand color to use when there is no session. + * @param brandColor the color in hex format (#RRGGBBAA or #RRGGBB), or null to reset to default. + */ + suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) + + fun brandColorsFlow(sessionId: SessionId?): Flow + + fun semanticColorsFlow(sessionId: SessionId?): Flow + + fun firebasePushGateway(): String? + fun unifiedPushDefaultPushGateway(): String? + + fun bugReportUrlFlow(sessionId: SessionId?): Flow + + companion object { + const val ANY_ACCOUNT_PROVIDER = "*" + } +} + +fun EnterpriseService.canConnectToAnyHomeserver(): Boolean { + return defaultHomeserverList().let { + it.isEmpty() || it.contains(EnterpriseService.ANY_ACCOUNT_PROVIDER) + } +} diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt new file mode 100644 index 0000000..6bd6c78 --- /dev/null +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.enterprise.api + +interface SessionEnterpriseService { + suspend fun isElementCallAvailable(): Boolean + + suspend fun init() +} diff --git a/features/enterprise/impl-foss/build.gradle.kts b/features/enterprise/impl-foss/build.gradle.kts new file mode 100644 index 0000000..f0c63f5 --- /dev/null +++ b/features/enterprise/impl-foss/build.gradle.kts @@ -0,0 +1,29 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.enterprise.impl" +} + +setupDependencyInjection() + +dependencies { + implementation(projects.libraries.compound) + api(projects.features.enterprise.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + + testCommonDependencies(libs) + testImplementation(projects.libraries.matrix.test) +} diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt new file mode 100644 index 0000000..b154d78 --- /dev/null +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.enterprise.impl + +import androidx.compose.ui.graphics.Color +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.compound.colors.SemanticColorsLightDark +import io.element.android.features.enterprise.api.BugReportUrl +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +@ContributesBinding(AppScope::class) +class DefaultEnterpriseService : EnterpriseService { + override val isEnterpriseBuild = false + + override suspend fun isEnterpriseUser(sessionId: SessionId) = false + + override fun defaultHomeserverList(): List = emptyList() + override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true + + override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = Unit + + override fun brandColorsFlow(sessionId: SessionId?): Flow { + return flowOf(null) + } + + override fun semanticColorsFlow(sessionId: SessionId?): Flow { + return flowOf(SemanticColorsLightDark.default) + } + + override fun firebasePushGateway(): String? = null + override fun unifiedPushDefaultPushGateway(): String? = null + + override fun bugReportUrlFlow(sessionId: SessionId?): Flow { + return flowOf(BugReportUrl.UseDefault) + } +} diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt new file mode 100644 index 0000000..3441063 --- /dev/null +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.enterprise.impl + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.enterprise.api.SessionEnterpriseService +import io.element.android.libraries.di.SessionScope + +@ContributesBinding(SessionScope::class) +class DefaultSessionEnterpriseService : SessionEnterpriseService { + override suspend fun init() = Unit + override suspend fun isElementCallAvailable(): Boolean = true +} diff --git a/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt b/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt new file mode 100644 index 0000000..ff95fd8 --- /dev/null +++ b/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.enterprise.impl + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.compound.colors.SemanticColorsLightDark +import io.element.android.features.enterprise.api.BugReportUrl +import io.element.android.libraries.matrix.test.A_HOMESERVER_URL +import io.element.android.libraries.matrix.test.A_SESSION_ID +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultEnterpriseServiceTest { + @Test + fun `isEnterpriseBuild is false`() { + val defaultEnterpriseService = DefaultEnterpriseService() + assertThat(defaultEnterpriseService.isEnterpriseBuild).isFalse() + } + + @Test + fun `defaultHomeserverList should return empty list`() { + val defaultEnterpriseService = DefaultEnterpriseService() + assertThat(defaultEnterpriseService.defaultHomeserverList()).isEmpty() + } + + @Test + fun `isAllowedToConnectToHomeserver is true for all homeserver urls`() = runTest { + val defaultEnterpriseService = DefaultEnterpriseService() + assertThat(defaultEnterpriseService.isAllowedToConnectToHomeserver(A_HOMESERVER_URL)).isTrue() + } + + @Test + fun `isEnterpriseUser always return false`() = runTest { + val defaultEnterpriseService = DefaultEnterpriseService() + assertThat(defaultEnterpriseService.isEnterpriseUser(A_SESSION_ID)).isFalse() + } + + @Test + fun `semanticColorsFlow always emits the same value`() = runTest { + val defaultEnterpriseService = DefaultEnterpriseService() + defaultEnterpriseService.semanticColorsFlow(null).test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(SemanticColorsLightDark.default) + awaitComplete() + } + } + + @Test + fun `brandColorsFlow always emits null`() = runTest { + val defaultEnterpriseService = DefaultEnterpriseService() + defaultEnterpriseService.brandColorsFlow(null).test { + val initialState = awaitItem() + assertThat(initialState).isNull() + awaitComplete() + } + } + + @Test + fun `semanticColorsFlow always emits the same value for a session`() = runTest { + val defaultEnterpriseService = DefaultEnterpriseService() + defaultEnterpriseService.semanticColorsFlow(A_SESSION_ID).test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(SemanticColorsLightDark.default) + awaitComplete() + } + } + + @Test + fun `overrideBrandColor has no effect`() = runTest { + val defaultEnterpriseService = DefaultEnterpriseService() + defaultEnterpriseService.overrideBrandColor(A_SESSION_ID, "aColor") + } + + @Test + fun `firebasePushGateway returns null`() = runTest { + val defaultEnterpriseService = DefaultEnterpriseService() + assertThat(defaultEnterpriseService.firebasePushGateway()).isNull() + } + + @Test + fun `unifiedPushDefaultPushGateway returns null`() = runTest { + val defaultEnterpriseService = DefaultEnterpriseService() + assertThat(defaultEnterpriseService.unifiedPushDefaultPushGateway()).isNull() + } + + @Test + fun `bugReportUrlFlow only emits UseDefault`() = runTest { + val defaultEnterpriseService = DefaultEnterpriseService() + defaultEnterpriseService.bugReportUrlFlow(A_SESSION_ID).test { + assertThat(awaitItem()).isEqualTo(BugReportUrl.UseDefault) + awaitComplete() + } + } +} diff --git a/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseServiceTest.kt b/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseServiceTest.kt new file mode 100644 index 0000000..391878a --- /dev/null +++ b/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseServiceTest.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.enterprise.impl + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultSessionEnterpriseServiceTest { + @Test + fun `isElementCallAvailable is always true`() = runTest { + val service = DefaultSessionEnterpriseService() + assertThat(service.isElementCallAvailable()).isTrue() + } +} diff --git a/features/enterprise/test/build.gradle.kts b/features/enterprise/test/build.gradle.kts new file mode 100644 index 0000000..542e737 --- /dev/null +++ b/features/enterprise/test/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.enterprise.test" +} + +dependencies { + api(projects.features.enterprise.api) + implementation(projects.libraries.compound) + implementation(projects.libraries.matrix.api) + implementation(projects.tests.testutils) +} diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt new file mode 100644 index 0000000..b2c3862 --- /dev/null +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.enterprise.test + +import androidx.compose.ui.graphics.Color +import io.element.android.compound.colors.SemanticColorsLightDark +import io.element.android.features.enterprise.api.BugReportUrl +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeEnterpriseService( + override val isEnterpriseBuild: Boolean = false, + private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() }, + private val defaultHomeserverListResult: () -> List = { emptyList() }, + private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() }, + initialSemanticColors: SemanticColorsLightDark = SemanticColorsLightDark.default, + initialBrandColor: Color? = null, + private val overrideBrandColorResult: (SessionId?, String?) -> Unit = { _, _ -> lambdaError() }, + private val firebasePushGatewayResult: () -> String? = { lambdaError() }, + private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() }, +) : EnterpriseService { + private val brandColorState = MutableStateFlow(initialBrandColor) + private val semanticColorsState = MutableStateFlow(initialSemanticColors) + + override suspend fun isEnterpriseUser(sessionId: SessionId): Boolean = simulateLongTask { + isEnterpriseUserResult(sessionId) + } + + override fun defaultHomeserverList(): List { + return defaultHomeserverListResult() + } + + override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean = simulateLongTask { + isAllowedToConnectToHomeserverResult(homeserverUrl) + } + + override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = simulateLongTask { + overrideBrandColorResult(sessionId, brandColor) + } + + override fun brandColorsFlow(sessionId: SessionId?): Flow { + return brandColorState.asStateFlow() + } + + override fun semanticColorsFlow(sessionId: SessionId?): Flow { + return semanticColorsState.asStateFlow() + } + + override fun firebasePushGateway(): String? { + return firebasePushGatewayResult() + } + + override fun unifiedPushDefaultPushGateway(): String? { + return unifiedPushDefaultPushGatewayResult() + } + + val bugReportUrlMutableFlow = MutableStateFlow(BugReportUrl.UseDefault) + override fun bugReportUrlFlow(sessionId: SessionId?): Flow { + return bugReportUrlMutableFlow.asStateFlow() + } +} diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt new file mode 100644 index 0000000..3914c60 --- /dev/null +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.enterprise.test + +import io.element.android.features.enterprise.api.SessionEnterpriseService +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask + +class FakeSessionEnterpriseService( + private val isElementCallAvailableResult: () -> Boolean = { lambdaError() }, +) : SessionEnterpriseService { + override suspend fun init() { + } + + override suspend fun isElementCallAvailable(): Boolean = simulateLongTask { + isElementCallAvailableResult() + } +} diff --git a/features/forward/api/build.gradle.kts b/features/forward/api/build.gradle.kts new file mode 100644 index 0000000..1c5f47a --- /dev/null +++ b/features/forward/api/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.forward.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/forward/api/src/main/kotlin/io/element/android/features/forward/api/ForwardEntryPoint.kt b/features/forward/api/src/main/kotlin/io/element/android/features/forward/api/ForwardEntryPoint.kt new file mode 100644 index 0000000..95b8c43 --- /dev/null +++ b/features/forward/api/src/main/kotlin/io/element/android/features/forward/api/ForwardEntryPoint.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.forward.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.architecture.NodeInputs +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.timeline.TimelineProvider + +interface ForwardEntryPoint : FeatureEntryPoint { + interface Callback : Plugin { + fun onDone(roomIds: List) + } + + data class Params( + val eventId: EventId, + val timelineProvider: TimelineProvider, + ) : NodeInputs + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + callback: Callback, + ): Node +} diff --git a/features/forward/impl/build.gradle.kts b/features/forward/impl/build.gradle.kts new file mode 100644 index 0000000..5d692c8 --- /dev/null +++ b/features/forward/impl/build.gradle.kts @@ -0,0 +1,41 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.forward.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + api(projects.features.forward.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.roomselect.api) + implementation(projects.libraries.uiStrings) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.roomselect.test) + testImplementation(projects.libraries.testtags) +} diff --git a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPoint.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPoint.kt new file mode 100644 index 0000000..97256c6 --- /dev/null +++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPoint.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.forward.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.forward.api.ForwardEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope + +@ContributesBinding(SessionScope::class) +class DefaultForwardEntryPoint : ForwardEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: ForwardEntryPoint.Params, + callback: ForwardEntryPoint.Callback, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf( + ForwardMessagesNode.Inputs( + eventId = params.eventId, + timelineProvider = params.timelineProvider, + ), + callback, + ) + ) + } +} diff --git a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesEvents.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesEvents.kt new file mode 100644 index 0000000..0161a5d --- /dev/null +++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesEvents.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.forward.impl + +sealed interface ForwardMessagesEvents { + data object ClearError : ForwardMessagesEvents +} diff --git a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt new file mode 100644 index 0000000..eb085d1 --- /dev/null +++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.forward.impl + +import android.os.Parcelable +import androidx.compose.foundation.layout.Box +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.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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.forward.api.ForwardEntryPoint +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope +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.timeline.TimelineProvider +import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint +import io.element.android.libraries.roomselect.api.RoomSelectMode +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +@AssistedInject +class ForwardMessagesNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: ForwardMessagesPresenter.Factory, + private val roomSelectEntryPoint: RoomSelectEntryPoint, +) : ParentNode( + navModel = PermanentNavModel( + navTargets = setOf(NavTarget), + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + @Parcelize + object NavTarget : Parcelable + + data class Inputs( + val eventId: EventId, + val timelineProvider: TimelineProvider, + ) : NodeInputs + + private val inputs = inputs() + private val callback: ForwardEntryPoint.Callback = callback() + private val presenter = presenterFactory.create(inputs.eventId.value, inputs.timelineProvider) + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + val callback = object : RoomSelectEntryPoint.Callback { + override fun onRoomSelected(roomIds: List) { + presenter.onRoomSelected(roomIds) + } + + override fun onCancel() { + callback.onDone(emptyList()) + } + } + + return roomSelectEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = RoomSelectEntryPoint.Params(mode = RoomSelectMode.Forward), + callback = callback, + ) + } + + @Composable + override fun View(modifier: Modifier) { + Box(modifier = modifier) { + // Will render to room select screen + Children( + navModel = navModel, + ) + + val state = presenter.present() + ForwardMessagesView( + state = state, + onForwardSuccess = callback::onDone, + ) + } + } +} diff --git a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenter.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenter.kt new file mode 100644 index 0000000..1145f29 --- /dev/null +++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenter.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.forward.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.di.annotations.SessionCoroutineScope +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.timeline.TimelineProvider +import io.element.android.libraries.matrix.api.timeline.getActiveTimeline +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +@AssistedInject +class ForwardMessagesPresenter( + @Assisted eventId: String, + @Assisted private val timelineProvider: TimelineProvider, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, +) : Presenter { + private val eventId: EventId = EventId(eventId) + + @AssistedFactory + fun interface Factory { + fun create(eventId: String, timelineProvider: TimelineProvider): ForwardMessagesPresenter + } + + private val forwardingActionState: MutableState>> = mutableStateOf(AsyncAction.Uninitialized) + + fun onRoomSelected(roomIds: List) { + sessionCoroutineScope.forwardEvent(eventId, roomIds) + } + + @Composable + override fun present(): ForwardMessagesState { + fun handleEvent(event: ForwardMessagesEvents) { + when (event) { + ForwardMessagesEvents.ClearError -> forwardingActionState.value = AsyncAction.Uninitialized + } + } + + return ForwardMessagesState( + forwardAction = forwardingActionState.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.forwardEvent( + eventId: EventId, + roomIds: List, + ) = launch { + suspend { + timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds) + .onFailure { + Timber.e(it, "Error while forwarding event") + } + .getOrThrow() + roomIds + }.runCatchingUpdatingState(forwardingActionState) + } +} diff --git a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesState.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesState.kt new file mode 100644 index 0000000..b1e46ad --- /dev/null +++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesState.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.forward.impl + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId + +data class ForwardMessagesState( + val forwardAction: AsyncAction>, + val eventSink: (ForwardMessagesEvents) -> Unit +) diff --git a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesStateProvider.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesStateProvider.kt new file mode 100644 index 0000000..bbb5b38 --- /dev/null +++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesStateProvider.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.forward.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId + +open class ForwardMessagesStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aForwardMessagesState(), + aForwardMessagesState( + forwardAction = AsyncAction.Loading, + ), + aForwardMessagesState( + forwardAction = AsyncAction.Success( + listOf(RoomId("!room2:domain")), + ) + ), + aForwardMessagesState( + forwardAction = AsyncAction.Failure(RuntimeException("error")), + ), + ) +} + +fun aForwardMessagesState( + forwardAction: AsyncAction> = AsyncAction.Uninitialized, + eventSink: (ForwardMessagesEvents) -> Unit = {} +) = ForwardMessagesState( + forwardAction = forwardAction, + eventSink = eventSink +) diff --git a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesView.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesView.kt new file mode 100644 index 0000000..8065054 --- /dev/null +++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesView.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.forward.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun ForwardMessagesView( + state: ForwardMessagesState, + onForwardSuccess: (List) -> Unit, +) { + AsyncActionView( + async = state.forwardAction, + onSuccess = { + onForwardSuccess(it) + }, + errorMessage = { + stringResource(id = CommonStrings.error_unknown) + }, + onErrorDismiss = { + state.eventSink(ForwardMessagesEvents.ClearError) + }, + ) +} + +@PreviewsDayNight +@Composable +internal fun ForwardMessagesViewPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) = ElementPreview { + ForwardMessagesView( + state = state, + onForwardSuccess = {} + ) +} diff --git a/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPointTest.kt b/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPointTest.kt new file mode 100644 index 0000000..9ee932a --- /dev/null +++ b/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPointTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.forward.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.features.forward.api.ForwardEntryPoint +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.timeline.FakeTimelineProvider +import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DefaultForwardEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() = runTest { + val entryPoint = DefaultForwardEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + ForwardMessagesNode( + buildContext = buildContext, + plugins = plugins, + presenterFactory = { _, _ -> createForwardMessagesPresenter() }, + roomSelectEntryPoint = FakeRoomSelectEntryPoint(), + ) + } + val callback = object : ForwardEntryPoint.Callback { + override fun onDone(roomIds: List) = lambdaError() + } + val params = ForwardEntryPoint.Params( + eventId = AN_EVENT_ID, + timelineProvider = FakeTimelineProvider(), + ) + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + params = params, + callback = callback, + ) + assertThat(result).isInstanceOf(ForwardMessagesNode::class.java) + assertThat(result.plugins).contains( + ForwardMessagesNode.Inputs( + eventId = params.eventId, + timelineProvider = params.timelineProvider, + ) + ) + assertThat(result.plugins).contains(callback) + } +} diff --git a/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenterTest.kt b/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenterTest.kt new file mode 100644 index 0000000..5e2f74c --- /dev/null +++ b/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenterTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.forward.impl + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class ForwardMessagesPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createForwardMessagesPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.forwardAction.isUninitialized()).isTrue() + } + } + + @Test + fun `present - forward successful`() = runTest { + val forwardEventLambda = lambdaRecorder { _: EventId, _: List -> + Result.success(Unit) + } + val timeline = FakeTimeline().apply { + this.forwardEventLambda = forwardEventLambda + } + val room = FakeJoinedRoom(liveTimeline = timeline) + val presenter = createForwardMessagesPresenter(fakeRoom = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val summary = aRoomSummary() + presenter.onRoomSelected(listOf(summary.roomId)) + val forwardingState = awaitItem() + assertThat(forwardingState.forwardAction.isLoading()).isTrue() + val successfulForwardState = awaitItem() + assertThat(successfulForwardState.forwardAction).isEqualTo(AsyncAction.Success(listOf(summary.roomId))) + forwardEventLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - select a room and forward failed, then clear`() = runTest { + val forwardEventLambda = lambdaRecorder { _: EventId, _: List -> + Result.failure(IllegalStateException("error")) + } + val timeline = FakeTimeline().apply { + this.forwardEventLambda = forwardEventLambda + } + val room = FakeJoinedRoom(liveTimeline = timeline) + val presenter = createForwardMessagesPresenter(fakeRoom = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val summary = aRoomSummary() + presenter.onRoomSelected(listOf(summary.roomId)) + skipItems(1) + val failedForwardState = awaitItem() + assertThat(failedForwardState.forwardAction.isFailure()).isTrue() + // Then clear error + failedForwardState.eventSink(ForwardMessagesEvents.ClearError) + assertThat(awaitItem().forwardAction.isUninitialized()).isTrue() + forwardEventLambda.assertions().isCalledOnce() + } + } +} + +fun TestScope.createForwardMessagesPresenter( + eventId: EventId = AN_EVENT_ID, + fakeRoom: FakeJoinedRoom = FakeJoinedRoom(), +) = ForwardMessagesPresenter( + eventId = eventId.value, + timelineProvider = LiveTimelineProvider(fakeRoom), + sessionCoroutineScope = this, +) diff --git a/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/ForwardMessagesViewTest.kt b/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/ForwardMessagesViewTest.kt new file mode 100644 index 0000000..f1e9bd8 --- /dev/null +++ b/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/ForwardMessagesViewTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.forward.impl + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.testtags.TestTags +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.pressTag +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ForwardMessagesViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `cancel error emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setForwardMessagesView( + aForwardMessagesState( + forwardAction = AsyncAction.Failure(AN_EXCEPTION), + eventSink = eventsRecorder + ), + ) + rule.pressTag(TestTags.dialogPositive.value) + eventsRecorder.assertSingle(ForwardMessagesEvents.ClearError) + } + + @Test + fun `success invokes onForwardSuccess`() { + val data = listOf(A_ROOM_ID) + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnceWithParam?>(data) { callback -> + rule.setForwardMessagesView( + aForwardMessagesState( + forwardAction = AsyncAction.Success(data), + eventSink = eventsRecorder + ), + onForwardSuccess = callback, + ) + } + } +} + +private fun AndroidComposeTestRule.setForwardMessagesView( + state: ForwardMessagesState, + onForwardSuccess: (List) -> Unit = EnsureNeverCalledWithParam(), +) { + setContent { + ForwardMessagesView( + state = state, + onForwardSuccess = onForwardSuccess, + ) + } +} diff --git a/features/forward/test/build.gradle.kts b/features/forward/test/build.gradle.kts new file mode 100644 index 0000000..bf9db05 --- /dev/null +++ b/features/forward/test/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.forward.test" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.features.forward.api) + implementation(projects.tests.testutils) +} diff --git a/features/forward/test/src/main/kotlin/io/element/android/features/forward/test/FakeForwardEntryPoint.kt b/features/forward/test/src/main/kotlin/io/element/android/features/forward/test/FakeForwardEntryPoint.kt new file mode 100644 index 0000000..9306ebc --- /dev/null +++ b/features/forward/test/src/main/kotlin/io/element/android/features/forward/test/FakeForwardEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.forward.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.forward.api.ForwardEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeForwardEntryPoint : ForwardEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: ForwardEntryPoint.Params, + callback: ForwardEntryPoint.Callback, + ): Node = lambdaError() +} diff --git a/features/ftue/api/build.gradle.kts b/features/ftue/api/build.gradle.kts new file mode 100644 index 0000000..4f1d8b1 --- /dev/null +++ b/features/ftue/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..fe5ffc3 --- /dev/null +++ b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/FtueEntryPoint.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.api + +import io.element.android.libraries.architecture.SimpleFeatureEntryPoint + +interface FtueEntryPoint : SimpleFeatureEntryPoint diff --git a/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueService.kt b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueService.kt new file mode 100644 index 0000000..1bc585f --- /dev/null +++ b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueService.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.api.state + +import kotlinx.coroutines.flow.StateFlow + +/** + * Service to manage the First Time User Experience state (aka Onboarding). + */ +interface FtueService { + /** The current state of the FTUE. */ + val state: StateFlow +} + +/** The state of the FTUE. */ +sealed interface FtueState { + /** The FTUE state is unknown, nothing to do for now. */ + data object Unknown : FtueState + + /** The FTUE state is incomplete. The FTUE flow should be displayed. */ + data object Incomplete : FtueState + + /** The FTUE state is complete. The FTUE flow should not be displayed anymore. */ + data object Complete : FtueState +} diff --git a/features/ftue/impl/build.gradle.kts b/features/ftue/impl/build.gradle.kts new file mode 100644 index 0000000..49b3baa --- /dev/null +++ b/features/ftue/impl/build.gradle.kts @@ -0,0 +1,60 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.ftue.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + api(projects.features.ftue.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.preferences.api) + implementation(projects.libraries.uiCommon) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.testtags) + implementation(projects.features.analytics.api) + implementation(projects.features.logout.api) + implementation(projects.features.securebackup.api) + implementation(projects.features.verifysession.api) + implementation(projects.services.analytics.api) + implementation(projects.features.lockscreen.api) + implementation(projects.libraries.permissions.api) + implementation(projects.libraries.permissions.noop) + implementation(projects.services.toolbox.api) + implementation(projects.appconfig) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.services.analytics.test) + testImplementation(projects.services.analytics.noop) + testImplementation(projects.libraries.permissions.test) + testImplementation(projects.libraries.preferences.test) + testImplementation(projects.features.lockscreen.test) + testImplementation(projects.services.toolbox.test) +} 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 0000000..812910e --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.ftue.api.FtueEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultFtueEntryPoint : FtueEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext): Node { + return parentNode.createNode(buildContext) + } +} 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 0000000..8d66258 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.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.replace +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.analytics.api.AnalyticsEntryPoint +import io.element.android.features.ftue.impl.notifications.NotificationsOptInNode +import io.element.android.features.ftue.impl.sessionverification.FtueSessionVerificationFlowNode +import io.element.android.features.ftue.impl.state.DefaultFtueService +import io.element.android.features.ftue.impl.state.FtueStep +import io.element.android.features.ftue.impl.state.InternalFtueState +import io.element.android.features.lockscreen.api.LockScreenEntryPoint +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.ui.common.nodes.emptyNode +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +@AssistedInject +class FtueFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val defaultFtueService: DefaultFtueService, + private val analyticsEntryPoint: AnalyticsEntryPoint, + private val lockScreenEntryPoint: LockScreenEntryPoint, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Placeholder, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + sealed interface NavTarget : Parcelable { + @Parcelize + data object Placeholder : NavTarget + + @Parcelize + data object SessionVerification : NavTarget + + @Parcelize + data object NotificationsOptIn : NavTarget + + @Parcelize + data object AnalyticsOptIn : NavTarget + + @Parcelize + data object LockScreenSetup : NavTarget + } + + override fun onBuilt() { + super.onBuilt() + defaultFtueService.ftueStepStateFlow + .filterIsInstance(InternalFtueState.Incomplete::class) + .onEach { + showStep(it.nextStep) + } + .launchIn(lifecycleScope) + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Placeholder -> { + emptyNode(buildContext) + } + is NavTarget.SessionVerification -> { + val callback = object : FtueSessionVerificationFlowNode.Callback { + override fun onDone() { + defaultFtueService.onUserCompletedSessionVerification() + } + } + createNode(buildContext, listOf(callback)) + } + NavTarget.NotificationsOptIn -> { + val callback = object : NotificationsOptInNode.Callback { + override fun onNotificationsOptInFinished() { + defaultFtueService.updateFtueStep() + } + } + createNode(buildContext, listOf(callback)) + } + NavTarget.AnalyticsOptIn -> { + analyticsEntryPoint.createNode(this, buildContext) + } + NavTarget.LockScreenSetup -> { + val callback = object : LockScreenEntryPoint.Callback { + override fun onSetupDone() { + defaultFtueService.updateFtueStep() + } + } + lockScreenEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + navTarget = LockScreenEntryPoint.Target.Setup, + callback = callback, + ) + } + } + } + + private fun showStep(ftueStep: FtueStep) { + when (ftueStep) { + FtueStep.WaitingForInitialState -> { + backstack.newRoot(NavTarget.Placeholder) + } + FtueStep.SessionVerification -> { + backstack.newRoot(NavTarget.SessionVerification) + } + FtueStep.NotificationsOptIn -> { + backstack.newRoot(NavTarget.NotificationsOptIn) + } + FtueStep.AnalyticsOptIn -> { + backstack.replace(NavTarget.AnalyticsOptIn) + } + FtueStep.LockscreenSetup -> { + backstack.newRoot(NavTarget.LockScreenSetup) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView() + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/di/FtueModule.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/di/FtueModule.kt new file mode 100644 index 0000000..52f7a43 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/di/FtueModule.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl.di + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.ftue.impl.sessionverification.choosemode.ChooseSelfVerificationModePresenter +import io.element.android.features.ftue.impl.sessionverification.choosemode.ChooseSelfVerificationModeState +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.SessionScope + +@ContributesTo(SessionScope::class) +@BindingContainer +interface FtueModule { + @Binds + fun bindChooseSelfVerificationMethodPresenter(presenter: ChooseSelfVerificationModePresenter): Presenter +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInEvents.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInEvents.kt new file mode 100644 index 0000000..e0bd5ac --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl.notifications + +sealed interface NotificationsOptInEvents { + data object ContinueClicked : NotificationsOptInEvents + data object NotNowClicked : NotificationsOptInEvents +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInNode.kt new file mode 100644 index 0000000..52ad70b --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInNode.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl.notifications + +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs + +@ContributesNode(AppScope::class) +@AssistedInject +class NotificationsOptInNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: NotificationsOptInPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + interface Callback : NodeInputs { + fun onNotificationsOptInFinished() + } + + private val callback = inputs() + + private val presenter: NotificationsOptInPresenter = presenterFactory.create(callback) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + NotificationsOptInView( + state = state, + onBack = { callback.onNotificationsOptInFinished() }, + modifier = modifier + ) + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt new file mode 100644 index 0000000..344d83c --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl.notifications + +import android.Manifest +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.permissions.api.PermissionStateProvider +import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@AssistedInject +class NotificationsOptInPresenter( + permissionsPresenterFactory: PermissionsPresenter.Factory, + @Assisted private val callback: NotificationsOptInNode.Callback, + @AppCoroutineScope + private val appCoroutineScope: CoroutineScope, + private val permissionStateProvider: PermissionStateProvider, + private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, +) : Presenter { + @AssistedFactory + interface Factory { + fun create(callback: NotificationsOptInNode.Callback): NotificationsOptInPresenter + } + + private val postNotificationPermissionsPresenter: PermissionsPresenter = + // Ask for POST_NOTIFICATION PERMISSION on Android 13+ + if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { + permissionsPresenterFactory.create(Manifest.permission.POST_NOTIFICATIONS) + } else { + NoopPermissionsPresenter() + } + + @Composable + override fun present(): NotificationsOptInState { + val notificationsPermissionsState = postNotificationPermissionsPresenter.present() + + fun handleEvent(event: NotificationsOptInEvents) { + when (event) { + NotificationsOptInEvents.ContinueClicked -> { + if (notificationsPermissionsState.permissionGranted) { + callback.onNotificationsOptInFinished() + } else { + notificationsPermissionsState.eventSink(PermissionsEvents.RequestPermissions) + } + } + NotificationsOptInEvents.NotNowClicked -> { + if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { + appCoroutineScope.setPermissionDenied() + } + callback.onNotificationsOptInFinished() + } + } + } + + LaunchedEffect(notificationsPermissionsState) { + if (notificationsPermissionsState.permissionGranted || + notificationsPermissionsState.permissionAlreadyDenied) { + callback.onNotificationsOptInFinished() + } + } + + return NotificationsOptInState( + notificationsPermissionState = notificationsPermissionsState, + eventSink = ::handleEvent, + ) + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private fun CoroutineScope.setPermissionDenied() = launch { + permissionStateProvider.setPermissionDenied(Manifest.permission.POST_NOTIFICATIONS, true) + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInState.kt new file mode 100644 index 0000000..d12cec0 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInState.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl.notifications + +import io.element.android.libraries.permissions.api.PermissionsState + +data class NotificationsOptInState( + val notificationsPermissionState: PermissionsState, + val eventSink: (NotificationsOptInEvents) -> Unit +) diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInStateProvider.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInStateProvider.kt new file mode 100644 index 0000000..1b2e5d2 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInStateProvider.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl.notifications + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.permissions.api.aPermissionsState + +open class NotificationsOptInStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aNotificationsOptInState(), + // Add other states here + ) +} + +fun aNotificationsOptInState() = NotificationsOptInState( + notificationsPermissionState = aPermissionsState(showDialog = false), + eventSink = {} +) diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInView.kt new file mode 100644 index 0000000..6955d8e --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInView.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl.notifications + +import androidx.activity.compose.BackHandler +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.Row +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.statusBarsPadding +import androidx.compose.foundation.shape.CircleShape +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.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.ftue.impl.R +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.background.OnboardingBackground +import io.element.android.libraries.designsystem.components.BigIcon +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.avatar.AvatarType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun NotificationsOptInView( + state: NotificationsOptInState, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = onBack) + + HeaderFooterPage( + modifier = modifier + .statusBarsPadding() + .fillMaxSize(), + background = { OnboardingBackground() }, + header = { NotificationsOptInHeader(modifier = Modifier.padding(top = 60.dp, bottom = 28.dp)) }, + footer = { NotificationsOptInFooter(state) }, + ) { + NotificationsOptInContent() + } +} + +@Composable +private fun NotificationsOptInHeader( + modifier: Modifier = Modifier, +) { + IconTitleSubtitleMolecule( + modifier = modifier, + title = stringResource(R.string.screen_notification_optin_title), + subTitle = stringResource(R.string.screen_notification_optin_subtitle), + iconStyle = BigIcon.Style.Default(CompoundIcons.NotificationsSolid()), + ) +} + +@Composable +private fun NotificationsOptInFooter(state: NotificationsOptInState) { + ButtonColumnMolecule { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(CommonStrings.action_ok), + onClick = { + state.eventSink(NotificationsOptInEvents.ContinueClicked) + } + ) + TextButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(CommonStrings.action_not_now), + onClick = { + state.eventSink(NotificationsOptInEvents.NotNowClicked) + } + ) + } +} + +@Composable +private fun NotificationsOptInContent() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + verticalArrangement = Arrangement.spacedBy( + 16.dp, + alignment = Alignment.CenterVertically + ) + ) { + NotificationRow( + avatarLetter = "M", + avatarColorsId = "5", + firstRowPercent = 1f, + secondRowPercent = 0.4f + ) + + NotificationRow( + avatarLetter = "A", + avatarColorsId = "1", + firstRowPercent = 1f, + secondRowPercent = 1f + ) + + NotificationRow( + avatarLetter = "T", + avatarColorsId = "4", + firstRowPercent = 0.65f, + secondRowPercent = 0f + ) + } + } +} + +@Composable +private fun NotificationRow( + avatarLetter: String, + avatarColorsId: String, + firstRowPercent: Float, + secondRowPercent: Float, +) { + Surface( + color = ElementTheme.colors.bgCanvasDisabled, + shape = RoundedCornerShape(14.dp), + shadowElevation = 2.dp, + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Avatar( + avatarData = AvatarData(id = avatarColorsId, name = avatarLetter, size = AvatarSize.NotificationsOptIn), + avatarType = AvatarType.User, + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Box( + modifier = Modifier + .clip(CircleShape) + .fillMaxWidth(firstRowPercent) + .height(10.dp) + .background(ElementTheme.colors.borderInteractiveSecondary) + ) + if (secondRowPercent > 0f) { + Box( + modifier = Modifier + .clip(CircleShape) + .fillMaxWidth(secondRowPercent) + .height(10.dp) + .background(ElementTheme.colors.borderInteractiveSecondary) + ) + } + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun NotificationsOptInViewPreview( + @PreviewParameter(NotificationsOptInStateProvider::class) state: NotificationsOptInState +) { + ElementPreview { + NotificationsOptInView( + onBack = {}, + state = state, + ) + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/FtueSessionVerificationFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/FtueSessionVerificationFlowNode.kt new file mode 100644 index 0000000..085240d --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/FtueSessionVerificationFlowNode.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl.sessionverification + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +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.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.appconfig.LearnMoreConfig +import io.element.android.features.ftue.impl.sessionverification.choosemode.ChooseSelfVerificationModeNode +import io.element.android.features.securebackup.api.SecureBackupEntryPoint +import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.designsystem.utils.OpenUrlInTabView +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.verification.VerificationRequest +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +@AssistedInject +class FtueSessionVerificationFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val outgoingVerificationEntryPoint: OutgoingVerificationEntryPoint, + private val secureBackupEntryPoint: SecureBackupEntryPoint, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + sealed interface NavTarget : Parcelable { + @Parcelize + data object Root : NavTarget + + @Parcelize + data object UseAnotherDevice : NavTarget + + @Parcelize + data object EnterRecoveryKey : NavTarget + + @Parcelize + data object ResetIdentity : NavTarget + } + + interface Callback : Plugin { + fun onDone() + } + + private val callback: Callback = callback() + + private val secureBackupEntryPointCallback = object : SecureBackupEntryPoint.Callback { + override fun onDone() { + lifecycleScope.launch { + // Move to the completed state view in the verification flow + backstack.newRoot(NavTarget.UseAnotherDevice) + } + } + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.Root -> { + val callback = object : ChooseSelfVerificationModeNode.Callback { + override fun navigateToUseAnotherDevice() { + backstack.push(NavTarget.UseAnotherDevice) + } + + override fun navigateToUseRecoveryKey() { + backstack.push(NavTarget.EnterRecoveryKey) + } + + override fun navigateToResetKey() { + backstack.push(NavTarget.ResetIdentity) + } + + override fun navigateToLearnMoreAboutEncryption() { + learnMoreUrl.value = LearnMoreConfig.DEVICE_VERIFICATION_URL + } + } + createNode(buildContext, plugins = listOf(callback)) + } + is NavTarget.UseAnotherDevice -> { + outgoingVerificationEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = OutgoingVerificationEntryPoint.Params( + showDeviceVerifiedScreen = true, + verificationRequest = VerificationRequest.Outgoing.CurrentSession, + ), + callback = object : OutgoingVerificationEntryPoint.Callback { + override fun onDone() { + callback.onDone() + } + + override fun onBack() { + backstack.pop() + } + + override fun navigateToLearnMoreAboutEncryption() { + // Note that this callback is never called. The "Learn more" link is not displayed + // for the self session interactive verification. + } + } + ) + } + is NavTarget.EnterRecoveryKey -> { + secureBackupEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey), + callback = secureBackupEntryPointCallback + ) + } + is NavTarget.ResetIdentity -> { + secureBackupEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.ResetIdentity), + callback = object : SecureBackupEntryPoint.Callback { + override fun onDone() { + callback.onDone() + } + }, + ) + } + } + } + + private val learnMoreUrl = mutableStateOf(null) + + @Composable + override fun View(modifier: Modifier) { + BackstackView() + + OpenUrlInTabView(learnMoreUrl) + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeEvent.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeEvent.kt new file mode 100644 index 0000000..de7e176 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeEvent.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl.sessionverification.choosemode + +sealed interface ChooseSelfVerificationModeEvent { + data object SignOut : ChooseSelfVerificationModeEvent +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeNode.kt new file mode 100644 index 0000000..e78eef7 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeNode.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl.sessionverification.choosemode + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.logout.api.direct.DirectLogoutView +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class ChooseSelfVerificationModeNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: Presenter, + private val directLogoutView: DirectLogoutView, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun navigateToUseAnotherDevice() + fun navigateToUseRecoveryKey() + fun navigateToResetKey() + fun navigateToLearnMoreAboutEncryption() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + + ChooseSelfVerificationModeView( + state = state, + onUseAnotherDevice = callback::navigateToUseAnotherDevice, + onUseRecoveryKey = callback::navigateToUseRecoveryKey, + onResetKey = callback::navigateToResetKey, + onLearnMore = callback::navigateToLearnMoreAboutEncryption, + modifier = modifier, + ) + + directLogoutView.Render(state = state.directLogoutState) + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt new file mode 100644 index 0000000..ace890b --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl.sessionverification.choosemode + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import dev.zacsweers.metro.Inject +import io.element.android.features.logout.api.direct.DirectLogoutEvents +import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.mapState +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.encryption.RecoveryState + +@Inject +class ChooseSelfVerificationModePresenter( + private val encryptionService: EncryptionService, + private val directLogoutPresenter: Presenter, +) : Presenter { + @Composable + override fun present(): ChooseSelfVerificationModeState { + val hasDevicesToVerifyAgainst by encryptionService.hasDevicesToVerifyAgainst.collectAsState() + val canEnterRecoveryKey by encryptionService.recoveryStateStateFlow + .mapState { recoveryState -> + when (recoveryState) { + RecoveryState.WAITING_FOR_SYNC, + RecoveryState.UNKNOWN -> AsyncData.Loading() + RecoveryState.INCOMPLETE -> AsyncData.Success(true) + RecoveryState.ENABLED, + RecoveryState.DISABLED -> AsyncData.Success(false) + } + } + .collectAsState() + val buttonsState by remember { + derivedStateOf { + val canUseAnotherDevice = hasDevicesToVerifyAgainst.dataOrNull() + val canEnterRecoveryKey = canEnterRecoveryKey.dataOrNull() + if (canUseAnotherDevice == null || canEnterRecoveryKey == null) { + AsyncData.Loading() + } else { + AsyncData.Success( + ChooseSelfVerificationModeState.ButtonsState( + canUseAnotherDevice = canUseAnotherDevice, + canEnterRecoveryKey = canEnterRecoveryKey, + ) + ) + } + } + } + + val directLogoutState = directLogoutPresenter.present() + + fun handleEvent(event: ChooseSelfVerificationModeEvent) { + when (event) { + ChooseSelfVerificationModeEvent.SignOut -> directLogoutState.eventSink(DirectLogoutEvents.Logout(ignoreSdkError = false)) + } + } + + return ChooseSelfVerificationModeState( + buttonsState = buttonsState, + directLogoutState = directLogoutState, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt new file mode 100644 index 0000000..4e27043 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl.sessionverification.choosemode + +import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.libraries.architecture.AsyncData + +data class ChooseSelfVerificationModeState( + val buttonsState: AsyncData, + val directLogoutState: DirectLogoutState, + val eventSink: (ChooseSelfVerificationModeEvent) -> Unit, +) { + data class ButtonsState( + val canUseAnotherDevice: Boolean, + val canEnterRecoveryKey: Boolean, + ) +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStateProvider.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStateProvider.kt new file mode 100644 index 0000000..676c772 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStateProvider.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl.sessionverification.choosemode + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.libraries.architecture.AsyncData + +class ChooseSelfVerificationModeStateProvider : + PreviewParameterProvider { + override val values = sequenceOf( + aChooseSelfVerificationModeState( + buttonsState = AsyncData.Success( + aButtonsState(canUseAnotherDevice = false, canEnterRecoveryKey = true), + ), + ), + aChooseSelfVerificationModeState( + buttonsState = AsyncData.Success( + aButtonsState(canUseAnotherDevice = false, canEnterRecoveryKey = false), + ), + ), + aChooseSelfVerificationModeState( + buttonsState = AsyncData.Success( + aButtonsState(canUseAnotherDevice = true, canEnterRecoveryKey = true), + ), + ), + aChooseSelfVerificationModeState( + buttonsState = AsyncData.Success( + aButtonsState(canUseAnotherDevice = true, canEnterRecoveryKey = false), + ), + ), + aChooseSelfVerificationModeState( + buttonsState = AsyncData.Loading(), + ), + ) +} + +fun aChooseSelfVerificationModeState( + buttonsState: AsyncData = AsyncData.Success(aButtonsState()), +) = ChooseSelfVerificationModeState( + buttonsState = buttonsState, + directLogoutState = aDirectLogoutState(), + eventSink = {}, +) + +fun aButtonsState( + canUseAnotherDevice: Boolean = true, + canEnterRecoveryKey: Boolean = true, +) = ChooseSelfVerificationModeState.ButtonsState( + canUseAnotherDevice = canUseAnotherDevice, + canEnterRecoveryKey = canEnterRecoveryKey, +) diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt new file mode 100644 index 0000000..e4e922f --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl.sessionverification.choosemode + +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.ftue.impl.R +import io.element.android.libraries.architecture.AsyncData +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.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChooseSelfVerificationModeView( + state: ChooseSelfVerificationModeState, + onUseAnotherDevice: () -> Unit, + onUseRecoveryKey: () -> Unit, + onResetKey: () -> Unit, + onLearnMore: () -> Unit, + modifier: Modifier = Modifier +) { + val activity = LocalActivity.current + BackHandler { + activity?.finish() + } + HeaderFooterPage( + modifier = modifier, + topBar = { + TopAppBar( + title = {}, + actions = { + TextButton( + text = stringResource(CommonStrings.action_signout), + onClick = { state.eventSink(ChooseSelfVerificationModeEvent.SignOut) } + ) + } + ) + }, + header = { + IconTitleSubtitleMolecule( + modifier = Modifier.padding(bottom = 16.dp), + iconStyle = BigIcon.Style.Default(CompoundIcons.LockSolid()), + title = stringResource(id = R.string.screen_identity_confirmation_title), + subTitle = stringResource(id = R.string.screen_identity_confirmation_subtitle) + ) + }, + footer = { + ChooseSelfVerificationModeButtons( + state = state, + onUseAnotherDevice = onUseAnotherDevice, + onUseRecoveryKey = onUseRecoveryKey, + onResetKey = onResetKey, + ) + } + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Text( + modifier = Modifier + .clickable(onClick = onLearnMore) + .padding(vertical = 4.dp, horizontal = 16.dp), + text = stringResource(CommonStrings.action_learn_more), + style = ElementTheme.typography.fontBodyLgMedium + ) + } + } +} + +@Composable +private fun ChooseSelfVerificationModeButtons( + state: ChooseSelfVerificationModeState, + onUseAnotherDevice: () -> Unit, + onUseRecoveryKey: () -> Unit, + onResetKey: () -> Unit, +) { + ButtonColumnMolecule( + modifier = Modifier.padding(bottom = 16.dp) + ) { + when (state.buttonsState) { + AsyncData.Uninitialized, + is AsyncData.Failure, + is AsyncData.Loading -> { + Button( + modifier = Modifier.fillMaxWidth(), + enabled = false, + showProgress = true, + text = stringResource(CommonStrings.common_loading), + onClick = {}, + ) + } + is AsyncData.Success -> { + if (state.buttonsState.data.canUseAnotherDevice) { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.screen_identity_use_another_device), + onClick = onUseAnotherDevice, + ) + } + if (state.buttonsState.data.canEnterRecoveryKey) { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.screen_session_verification_enter_recovery_key), + onClick = onUseRecoveryKey, + ) + } + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.screen_identity_confirmation_cannot_confirm), + onClick = onResetKey, + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun ChooseSelfVerificationModeViewPreview( + @PreviewParameter(ChooseSelfVerificationModeStateProvider::class) state: ChooseSelfVerificationModeState +) = ElementPreview { + ChooseSelfVerificationModeView( + state = state, + onUseAnotherDevice = {}, + onUseRecoveryKey = {}, + onResetKey = {}, + onLearnMore = {}, + ) +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt new file mode 100644 index 0000000..78bb5f5 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl.state + +import android.Manifest +import android.os.Build +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.features.ftue.api.state.FtueService +import io.element.android.features.ftue.api.state.FtueState +import io.element.android.features.lockscreen.api.LockScreenService +import io.element.android.libraries.core.coroutine.mapState +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus +import io.element.android.libraries.permissions.api.PermissionStateProvider +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +@ContributesBinding(SessionScope::class) +@SingleIn(SessionScope::class) +class DefaultFtueService( + private val sdkVersionProvider: BuildVersionSdkIntProvider, + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, + private val analyticsService: AnalyticsService, + private val permissionStateProvider: PermissionStateProvider, + private val lockScreenService: LockScreenService, + private val sessionVerificationService: SessionVerificationService, + private val sessionPreferencesStore: SessionPreferencesStore, +) : FtueService { + private val userNeedsToConfirmSessionVerificationSuccess = MutableStateFlow(false) + + val ftueStepStateFlow = MutableStateFlow(InternalFtueState.Unknown) + + override val state = ftueStepStateFlow + .mapState { + when (it) { + is InternalFtueState.Unknown -> FtueState.Unknown + is InternalFtueState.Incomplete -> FtueState.Incomplete + is InternalFtueState.Complete -> FtueState.Complete + } + } + + init { + combine( + sessionVerificationService.sessionVerifiedStatus.onEach { sessionVerifiedStatus -> + if (sessionVerifiedStatus == SessionVerifiedStatus.NotVerified) { + // Ensure we wait for the user to confirm the session verified screen before going further + userNeedsToConfirmSessionVerificationSuccess.value = true + } + }, + userNeedsToConfirmSessionVerificationSuccess, + analyticsService.didAskUserConsentFlow.distinctUntilChanged(), + ) { + updateFtueStep() + } + .launchIn(sessionCoroutineScope) + } + + fun updateFtueStep() = sessionCoroutineScope.launch { + val step = getNextStep(null) + ftueStepStateFlow.value = when (step) { + null -> InternalFtueState.Complete + else -> InternalFtueState.Incomplete(step) + } + } + + private suspend fun getNextStep(completedStep: FtueStep? = null): FtueStep? = + when (completedStep) { + null -> if (!isSessionVerificationStateReady()) { + FtueStep.WaitingForInitialState + } else { + getNextStep(FtueStep.WaitingForInitialState) + } + FtueStep.WaitingForInitialState -> if (isSessionNotVerified() || userNeedsToConfirmSessionVerificationSuccess.value) { + FtueStep.SessionVerification + } else { + getNextStep(FtueStep.SessionVerification) + } + FtueStep.SessionVerification -> if (shouldAskNotificationPermissions()) { + FtueStep.NotificationsOptIn + } else { + getNextStep(FtueStep.NotificationsOptIn) + } + FtueStep.NotificationsOptIn -> if (shouldDisplayLockscreenSetup()) { + FtueStep.LockscreenSetup + } else { + getNextStep(FtueStep.LockscreenSetup) + } + FtueStep.LockscreenSetup -> if (needsAnalyticsOptIn()) { + FtueStep.AnalyticsOptIn + } else { + getNextStep(FtueStep.AnalyticsOptIn) + } + FtueStep.AnalyticsOptIn -> null + } + + private fun isSessionVerificationStateReady(): Boolean { + return sessionVerificationService.sessionVerifiedStatus.value != SessionVerifiedStatus.Unknown + } + + private suspend fun isSessionNotVerified(): Boolean { + return sessionVerificationService.sessionVerifiedStatus.value == SessionVerifiedStatus.NotVerified && !canSkipVerification() + } + + private suspend fun canSkipVerification(): Boolean { + return sessionPreferencesStore.isSessionVerificationSkipped().first() + } + + private suspend fun needsAnalyticsOptIn(): Boolean { + return analyticsService.didAskUserConsentFlow.first().not() + } + + private suspend fun shouldAskNotificationPermissions(): Boolean { + return if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { + val permission = Manifest.permission.POST_NOTIFICATIONS + val isPermissionDenied = permissionStateProvider.isPermissionDenied(permission).first() + val isPermissionGranted = permissionStateProvider.isPermissionGranted(permission) + !isPermissionGranted && !isPermissionDenied + } else { + false + } + } + + private suspend fun shouldDisplayLockscreenSetup(): Boolean { + return lockScreenService.isSetupRequired().first() + } + + fun onUserCompletedSessionVerification() { + userNeedsToConfirmSessionVerificationSuccess.value = false + } +} + +sealed interface FtueStep { + data object WaitingForInitialState : FtueStep + data object SessionVerification : FtueStep + data object NotificationsOptIn : FtueStep + data object AnalyticsOptIn : FtueStep + data object LockscreenSetup : FtueStep +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/InternalFtueState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/InternalFtueState.kt new file mode 100644 index 0000000..b352a43 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/InternalFtueState.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl.state + +sealed interface InternalFtueState { + data object Unknown : InternalFtueState + + data class Incomplete( + val nextStep: FtueStep, + ) : InternalFtueState + + data object Complete : InternalFtueState +} diff --git a/features/ftue/impl/src/main/res/values-be/translations.xml b/features/ftue/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..949410c --- /dev/null +++ b/features/ftue/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,16 @@ + + + "Не можаце пацвердзіць?" + "Стварыць новы ключ аднаўлення" + "Пацвердзіце гэтую прыладу, каб наладзіць бяспечны абмен паведамленнямі." + "Пацвердзіце, што гэта вы" + "Выкарыстоўвайце іншую прыладу" + "Выкарыстоўваць ключ аднаўлення" + "Цяпер вы можаце бяспечна чытаць і адпраўляць паведамленні, і ўсе, з кім вы маеце зносіны ў чаце, таксама могуць давяраць гэтай прыладзе." + "Прылада праверана" + "Выкарыстоўвайце іншую прыладу" + "Чаканне на іншай прыладзе…" + "Вы можаце змяніць налады пазней." + "Дазвольце апавяшчэнні і ніколі не прапускайце іх" + "Увядзіце ключ аднаўлення" + diff --git a/features/ftue/impl/src/main/res/values-bg/translations.xml b/features/ftue/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..0b7bd3d --- /dev/null +++ b/features/ftue/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,13 @@ + + + "Не можете да потвърдите?" + "Потвърдете това устройство, за да настроите защитени съобщения." + "Потвърдете самоличността си" + "Използване на друго устройство" + "Използване на ключ за възстановяване" + "Устройството е потвърдено" + "Използване на друго устройство" + "Можете да промените настройките си по-късно." + "Разрешете известията и никога не пропускайте съобщение" + "Въвеждане на ключ за възстановяване" + diff --git a/features/ftue/impl/src/main/res/values-cs/translations.xml b/features/ftue/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..6190f04 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,16 @@ + + + "Nemůžete potvrdit?" + "Vytvoření nového klíče pro obnovení" + "Ověřte toto zařízení a nastavte zabezpečené zasílání zpráv." + "Potvrďte, že jste to vy" + "Použít jiné zařízení" + "Použít klíč pro obnovení" + "Nyní můžete bezpečně číst nebo odesílat zprávy, a kdokoli, s kým chatujete, může tomuto zařízení důvěřovat." + "Zařízení ověřeno" + "Použít jiné zařízení" + "Čekání na jiném zařízení…" + "Nastavení můžete později změnit." + "Povolte oznámení a nezmeškejte žádnou zprávu" + "Zadejte klíč pro obnovení" + diff --git a/features/ftue/impl/src/main/res/values-cy/translations.xml b/features/ftue/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..c9ed3c0 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,16 @@ + + + "Methu cadarnhau?" + "Crëwch allwedd adfer newydd" + "Dilyswch y ddyfais hon er mwyn gosod negeseuon diogel." + "Cadarnhewch eich hunaniaeth" + "Defnyddiwch ddyfais arall" + "Defnyddiwch allwedd adfer" + "Nawr gallwch chi ddarllen neu anfon negeseuon yn ddiogel, a gall unrhyw un rydych chi\'n sgwrsio â nhw ymddiried yn y ddyfais hon hefyd." + "Dyfais wedi\'i dilysu" + "Defnyddiwch ddyfais arall" + "Yn aros ar ddyfais arall…" + "Gallwch newid eich gosodiadau yn nes ymlaen." + "Caniatáu hysbysiadau a pheidio byth â cholli neges" + "Rhowch eich allwedd adfer" + diff --git a/features/ftue/impl/src/main/res/values-da/translations.xml b/features/ftue/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..abbf00f --- /dev/null +++ b/features/ftue/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,16 @@ + + + "Kan ikke bekræfte?" + "Opret en ny gendannelsesnøgle" + "Verificér denne enhed for at konfigurere sikre meddelelser." + "Bekræft din identitet" + "Brug en anden enhed" + "Brug gendannelsesnøgle" + "Nu kan du læse eller sende beskeder sikkert, og enhver du samtaler med kan også stole på denne enhed." + "Enhed verificeret" + "Brug en anden enhed" + "Venter på en anden enhed…" + "Du kan ændre dine indstillinger senere." + "Tillad notifikationer, og gå aldrig glip af en besked" + "Indtast gendannelsesnøgle" + diff --git a/features/ftue/impl/src/main/res/values-de/translations.xml b/features/ftue/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..6d902ab --- /dev/null +++ b/features/ftue/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,16 @@ + + + "Bestätigung unmöglich?" + "Erstelle einen neuen Wiederherstellungsschlüssel" + "Verifiziere dieses Gerät, um sichere Chats einzurichten." + "Bestätige deine Identität" + "Ein anderes Gerät verwenden" + "Wiederherstellungsschlüssel verwenden" + "Du kannst jetzt verschlüsselte Nachrichten lesen und versenden. Dein Chatpartner vertraut nun diesem Gerät." + "Gerät verifiziert" + "Ein anderes Gerät verwenden" + "Bitte warten bis das andere Gerät bereit ist." + "Du kannst deine Einstellungen später ändern." + "Erlaube Benachrichtigungen und verpasse keine Nachricht" + "Wiederherstellungsschlüssel eingeben" + diff --git a/features/ftue/impl/src/main/res/values-el/translations.xml b/features/ftue/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..f9029a7 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,16 @@ + + + "Δεν μπορείς να επιβεβαιώσεις;" + "Δημιουργία νέου κλειδιού ανάκτησης" + "Επαλήθευσε αυτήν τη συσκευή για να ρυθμίσεις την ασφαλή επικοινωνία." + "Επιβεβαίωσε ότι είσαι εσύ" + "Χρήση άλλης συσκευής" + "Χρήση κλειδιού ανάκτησης" + "Τώρα μπορείς να διαβάζεις ή να στέλνεις μηνύματα με ασφάλεια και επίσης μπορεί να εμπιστευτεί αυτήν τη συσκευή οποιοσδήποτε με τον οποίο συνομιλείς." + "Επαληθευμένη συσκευή" + "Χρήση άλλης συσκευής" + "Αναμονή σε άλλη συσκευή…" + "Μπορείς να αλλάξεις τις ρυθμίσεις σου αργότερα." + "Επέτρεψε τις ειδοποιήσεις και μην χάσεις ούτε ένα μήνυμα" + "Εισαγωγή κλειδιού ανάκτησης" + diff --git a/features/ftue/impl/src/main/res/values-es/translations.xml b/features/ftue/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..111821f --- /dev/null +++ b/features/ftue/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,16 @@ + + + "¿No puedes confirmar?" + "Crear una nueva clave de recuperación" + "Verifica este dispositivo para configurar la mensajería segura." + "Confirma que eres tú" + "Usar otro dispositivo" + "Usar clave de recuperación" + "Ahora puedes leer o enviar mensajes de forma segura y cualquier persona con la que chatees también puede confiar en este dispositivo." + "Dispositivo verificado" + "Usar otro dispositivo" + "Esperando en otro dispositivo…" + "Puedes cambiar la configuración más tarde." + "Activa las notificaciones y nunca te pierdas un mensaje" + "Introduce la clave de recuperación" + diff --git a/features/ftue/impl/src/main/res/values-et/translations.xml b/features/ftue/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..c1898c3 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,16 @@ + + + "Kas kinnitamine pole võimalik?" + "Loo uus taastevõti" + "Krüptitud sõnumivahetuse tagamiseks verifitseeri see seade." + "Kinnita, et see oled sina" + "Kasuta teist seadet" + "Kasuta taastevõtit" + "Nüüd saad saata või lugeda sõnumeid turvaliselt ning kõik sinu vestluspartnerid võivad usaldada seda seadet." + "Seade on verifitseeritud" + "Kasuta teist seadet" + "Ootame teise seadme järgi…" + "Sa võid seadistusi hiljem alati muuta." + "Luba teavitused ja kunagi ei jää sul sõnumid märkamata" + "Sisesta taastevõti" + diff --git a/features/ftue/impl/src/main/res/values-eu/translations.xml b/features/ftue/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..f512a62 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,16 @@ + + + "Ezin duzu baieztatu?" + "Sortu berreskuratze-gako berria" + "Egiaztatu gailua mezularitza segurua konfiguratzeko." + "Berretsi zure identitatea" + "Erabili beste gailu bat" + "Erabili berreskuratze-gakoa" + "Orain mezuak modu seguruan irakurri edo bidal ditzakezu, eta txateatzen duzun edonor ere fida daiteke gailu honetaz." + "Gailua egiaztatu da" + "Erabili beste gailu bat" + "Beste gailuaren zain…" + "Geroago alda ditzakezu ezarpenak." + "Baimendu jakinarazpenak eta ez galdu inoiz mezurik" + "Sartu berreskuratze-gakoa" + diff --git a/features/ftue/impl/src/main/res/values-fa/translations.xml b/features/ftue/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..8c2b5ae --- /dev/null +++ b/features/ftue/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,16 @@ + + + "نمی‌توانید تأیید کنید؟" + "ایجاد کلید بازیابی جدید" + "تأیید این افزاره برای برپایی پیام‌رسانی امن." + "تأیید هویتتان" + "استفاده از افزاره‌ای دیگر" + "استفاده از کلید بازیابی" + "اکنون می‌توانید پیام‌ها را به صورت امن فرستاده و بگیرید و هرکسی که با او گپ می‌زنید نیز می‌تواند به این افزاره اعتماد کند." + "افزاره تأیید شده" + "استفاده از افزاره‌ای دیگر" + "منتظر افزارهٔ دیگر…" + "می‌توانید بعداً تنظیماتتان را تغییر دهید." + "اجازه به آگاهی‌ها و از دست ندادن پیام‌ها" + "ورود کلید بازیابی" + diff --git a/features/ftue/impl/src/main/res/values-fi/translations.xml b/features/ftue/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..cc3ee45 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,16 @@ + + + "Etkö voi vahvistaa?" + "Luo uusi palautusavain" + "Vahvista tämä laite suojattua viestintää varten." + "Vahvista identiteettisi" + "Käytä toista laitetta" + "Käytä palautusavainta" + "Nyt voit lukea ja lähettää viestejä turvallisesti, ja kaikki, joiden kanssa keskustelet, voivat myös luottaa tähän laitteeseen." + "Laite vahvistettu" + "Käytä toista laitetta" + "Odotetaan toista laitetta…" + "Voit muuttaa asetuksia myöhemmin." + "Salli ilmoitukset ja älä koskaan missaa viestejä" + "Syötä palautusavain" + diff --git a/features/ftue/impl/src/main/res/values-fr/translations.xml b/features/ftue/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..43bc6e0 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,16 @@ + + + "Confirmation impossible ?" + "Créer une nouvelle clé de récupération" + "Vérifier cette session pour configurer votre messagerie sécurisée." + "Confirmez votre identité" + "Utiliser une autre session" + "Utiliser la clé de récupération" + "Vous pouvez désormais lire ou envoyer des messages en toute sécurité, et toute personne avec qui vous discutez peut également faire confiance à cette session." + "Session vérifiée" + "Utiliser une autre session" + "En attente d’une autre session…" + "Vous pourrez modifier vos paramètres ultérieurement." + "Autorisez les notifications et ne manquez aucun message" + "Utiliser la clé de récupération" + diff --git a/features/ftue/impl/src/main/res/values-hu/translations.xml b/features/ftue/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..90a2667 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,16 @@ + + + "Nem tudja megerősíteni?" + "Új helyreállítási kulcs létrehozása" + "A biztonságos üzenetkezelés beállításához ellenőrizze ezt az eszközt." + "Erősítse meg, hogy Ön az" + "Másik eszköz használata" + "Helyreállítási kulcs használata" + "Mostantól biztonságosan olvashat vagy küldhet üzeneteket, és bármelyik csevegőpartnere megbízhat ebben az eszközben." + "Eszköz ellenőrizve" + "Másik eszköz használata" + "Várakozás a másik eszközre…" + "A beállításokat később is módosíthatja." + "Értesítések engedélyezése, hogy soha ne maradjon le egyetlen üzenetről sem" + "Adja meg a helyreállítási kulcsot" + diff --git a/features/ftue/impl/src/main/res/values-in/translations.xml b/features/ftue/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..3b6f878 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,16 @@ + + + "Tidak dapat mengonfirmasi?" + "Buat kunci pemulihan baru" + "Verifikasi perangkat ini untuk menyiapkan perpesanan aman." + "Konfirmasi bahwa ini Anda" + "Gunakan perangkat lain" + "Gunakan kunci pemulihan" + "Sekarang Anda dapat membaca atau mengirim pesan dengan aman, dan siapa pun yang mengobrol dengan Anda juga dapat mempercayai perangkat ini." + "Perangkat terverifikasi" + "Gunakan perangkat lain" + "Menunggu di perangkat lain…" + "Anda dapat mengubah pengaturan Anda nanti." + "Izinkan pemberitahuan dan jangan pernah melewatkan pesan" + "Masukkan kunci pemulihan" + diff --git a/features/ftue/impl/src/main/res/values-it/translations.xml b/features/ftue/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..c8c4766 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,16 @@ + + + "Non puoi confermare?" + "Crea una nuova chiave di recupero" + "Verifica questo dispositivo per segnare i tuoi messaggi come sicuri." + "Conferma la tua identità" + "Usa un altro dispositivo" + "Usa la chiave di recupero" + "Ora puoi leggere o inviare messaggi in tutta sicurezza e anche chi chatta con te può fidarsi di questo dispositivo." + "Dispositivo verificato" + "Usa un altro dispositivo" + "In attesa sull\'altro dispositivo…" + "Potrai modificare le tue impostazioni in seguito." + "Consenti le notifiche e non perdere mai un messaggio" + "Inserisci la chiave di recupero" + diff --git a/features/ftue/impl/src/main/res/values-ka/translations.xml b/features/ftue/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..7118eaf --- /dev/null +++ b/features/ftue/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,12 @@ + + + "ახალი აღდგენის გასაღების შექმნა" + "დაადასტურეთ ეს მოწყობილობა უსაფრთხო მიმოწერისათვის." + "დაამტკიცეთ თქვენი პიროვნება" + "ახლა თქვენ შეძლებთ შეტყობინებების წაკითხვას ან გაგზავნას უსაფრთხოდ, სხვა მომხმარებლებსაც შეუძლიათ ამ მოწყობილობას ენდონ." + "მოწყობილობა დადასტურებულია" + "ველოდებით სხვა მოწყობილობას…" + "თქვენ შეგიძლიათ შეცვალოთ თქვენი პარამეტრები მოგვიანებით." + "ყველა შეტყობინებაზე შეტყობინებების მიღება" + "შეიყვანეთ აღდგენის გასაღები" + diff --git a/features/ftue/impl/src/main/res/values-ko/translations.xml b/features/ftue/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..cb7c9e3 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,16 @@ + + + "확인할 수 없나요?" + "새로운 복구 키 만들기" + "보안 메시징을 설정하려면 이 장치를 확인하세요." + "본인 확인" + "다른 기기 사용" + "복구 키 사용" + "이제 메시지를 안전하게 읽거나 보낼 수 있으며, 채팅 상대도 이 기기를 신뢰할 수 있습니다." + "기기 검증됨" + "다른 기기 사용" + "다른 기기에서 대기 중…" + "나중에 설정을 변경할 수 있습니다." + "알림을 허용하고 메시지를 놓치지 마세요." + "복구 키를 입력하세요" + diff --git a/features/ftue/impl/src/main/res/values-nb/translations.xml b/features/ftue/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..aa0f4a1 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,16 @@ + + + "Kan du ikke bekrefte?" + "Opprett en ny gjenopprettingsnøkkel" + "Verifiser denne enheten for å sette opp sikker meldingsutveksling." + "Bekreft identiteten din" + "Bruk en annen enhet" + "Bruk gjenopprettingsnøkkel" + "Nå kan du lese eller sende meldinger på en sikker måte, og alle du chatter med kan også stole på denne enheten." + "Enhet verifisert" + "Bruk en annen enhet" + "Venter på en annen enhet…" + "Du kan endre innstillingene dine senere." + "Tillat varslinger og gå aldri glipp av en melding" + "Skriv inn gjenopprettingsnøkkel" + diff --git a/features/ftue/impl/src/main/res/values-nl/translations.xml b/features/ftue/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..ca49c62 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,16 @@ + + + "Kan ik dit niet bevestigen?" + "Maak een nieuwe herstelsleutel" + "Verifieer dit apparaat om beveiligde berichten in te stellen." + "Bevestig dat jij het bent" + "Gebruik een ander apparaat" + "Gebruik de herstelsleutel" + "Nu kun je veilig berichten lezen of verzenden, en iedereen met wie je chat kan dit apparaat ook vertrouwen." + "Apparaat geverifieerd" + "Gebruik een ander apparaat" + "Wachten op ander apparaat…" + "Je kunt je instellingen later wijzigen." + "Sta meldingen toe en mis nooit meer een bericht" + "Voer herstelsleutel in" + diff --git a/features/ftue/impl/src/main/res/values-pl/translations.xml b/features/ftue/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..c348c9b --- /dev/null +++ b/features/ftue/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,16 @@ + + + "Nie możesz potwierdzić?" + "Utwórz nowy klucz przywracania" + "Zweryfikuj to urządzenie, aby skonfigurować bezpieczne przesyłanie wiadomości." + "Potwierdź, że to Ty" + "Użyj innego urządzenia" + "Użyj klucza przywracania" + "Teraz możesz bezpiecznie czytać i wysyłać wiadomości, każdy z kim czatujesz również może ufać temu urządzeniu." + "Urządzenie zweryfikowane" + "Użyj innego urządzenia" + "Oczekiwanie na inne urządzenie…" + "Możesz zmienić ustawienia później." + "Zezwól na powiadomienia i nie przegap żadnej wiadomości" + "Wprowadź klucz przywracania" + diff --git a/features/ftue/impl/src/main/res/values-pt-rBR/translations.xml b/features/ftue/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..8629bbf --- /dev/null +++ b/features/ftue/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,16 @@ + + + "Não consegue confirmar?" + "Criar uma nova chave de recuperação" + "Verifique este dispositivo para configurar as mensagens seguras." + "Confirme sua identidade" + "Usar outro dispositivo" + "Usar chave de recuperação" + "Agora você pode ler ou enviar mensagens com segurança, e qualquer pessoa com quem você conversa também pode confiar neste dispositivo." + "Dispositivo verificado" + "Usar outro dispositivo" + "Aguardando o outro dispositivo…" + "Você pode alterar suas configurações mais tarde." + "Permita as notificações e nunca perca uma mensagem" + "Digitar chave de recuperação" + diff --git a/features/ftue/impl/src/main/res/values-pt/translations.xml b/features/ftue/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..c05eca2 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,16 @@ + + + "Não é possível confirmar?" + "Criar uma nova chave de recuperação" + "Verifica este dispositivo para configurar o envio seguro de mensagens." + "Confirma que és tu" + "Utilizar outro dispositivo" + "Utilizar chave de recuperação" + "Agora podes ler ou enviar mensagens de forma segura, e qualquer pessoa com quem converses também pode confiar neste dispositivo." + "Dispositivo verificado" + "Utilizar outro dispositivo" + "A aguardar por outros dispositivos…" + "Podes alterar as tuas definições mais tarde." + "Permite as notificações e nunca percas uma mensagem" + "Insere a chave de recuperação" + diff --git a/features/ftue/impl/src/main/res/values-ro/translations.xml b/features/ftue/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..b58414a --- /dev/null +++ b/features/ftue/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,16 @@ + + + "Nu puteți confirma?" + "Creați o nouă cheie de recuperare" + "Verificați acest dispozitiv pentru a configura mesagerie securizată." + "Confirmați că sunteți dumneavoastră" + "Utilizați un alt dispozitiv" + "Utilizați cheia de recuperare" + "Acum puteți citi sau trimite mesaje în siguranță, iar oricine cu care conversați poate avea încredere în acest dispozitiv." + "Dispozitiv verificat" + "Utilizați un alt dispozitiv" + "Se așteaptă celălalt dispozitiv…" + "Puteți modifica setările mai târziu." + "Permiteți notificările și nu pierdeți niciodată un mesaj" + "Introduceți cheia de recuperare" + diff --git a/features/ftue/impl/src/main/res/values-ru/translations.xml b/features/ftue/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..75a927e --- /dev/null +++ b/features/ftue/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,16 @@ + + + "Не можете подтвердить?" + "Создайте новый ключ восстановления" + "Подтвердите это устройство, чтобы настроить безопасный обмен сообщениями." + "Подтвердите, что это вы" + "Использовать другое устройство" + "Используйте recovery key" + "Теперь вы можете безопасно читать и отправлять сообщения, и все, с кем вы общаетесь в чате, также могут доверять этому устройству." + "Устройство проверено" + "Использовать другое устройство" + "Ожидание на другом устройстве…" + "Вы можете изменить настройки позже." + "Разрешите отправку уведомлений и ни одно сообщение не будет пропущено" + "Введите ключ восстановления" + diff --git a/features/ftue/impl/src/main/res/values-sk/translations.xml b/features/ftue/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..ca2cc3e --- /dev/null +++ b/features/ftue/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,16 @@ + + + "Nemôžete potvrdiť?" + "Vytvoriť nový kľúč na obnovenie" + "Ak chcete nastaviť zabezpečené správy, overte toto zariadenie." + "Potvrďte, že ste to vy" + "Použite iné zariadenie" + "Použiť kľúč na obnovenie" + "Teraz môžete bezpečne čítať alebo odosielať správy a tomuto zariadeniu môže dôverovať aj ktokoľvek, s kým konverzujete." + "Zariadenie overené" + "Použite iné zariadenie" + "Čaká sa na druhom zariadení…" + "Svoje nastavenia môžete neskôr zmeniť." + "Povoľte oznámenia a nikdy nezmeškajte žiadnu správu" + "Zadajte kľúč na obnovenie" + diff --git a/features/ftue/impl/src/main/res/values-sv/translations.xml b/features/ftue/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..0bca53c --- /dev/null +++ b/features/ftue/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,16 @@ + + + "Kan du inte bekräfta?" + "Skapa en ny återställningsnyckel" + "Verifiera den här enheten för att konfigurera säkra meddelanden." + "Bekräfta att det är du" + "Använd en annan enhet" + "Använd återställningsnyckel" + "Nu kan du läsa eller skicka meddelanden säkert, och alla du chattar med kan också lita på den här enheten." + "Enhet verifierad" + "Använd en annan enhet" + "Väntar på annan enhet …" + "Du kan ändra dina inställningar senare." + "Tillåt aviseringar och missa aldrig ett meddelande" + "Ange återställningsnyckel" + diff --git a/features/ftue/impl/src/main/res/values-tr/translations.xml b/features/ftue/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..dbdfe55 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,16 @@ + + + "Onaylayamıyor musunuz?" + "Yeni bir kurtarma anahtarı oluştur" + "Güvenli mesajlaşmayı ayarlamak için bu cihazı doğrulayın." + "Kimliğinizi doğrulayın" + "Başka bir cihaz kullan" + "Kurtarma anahtarı kullan" + "Artık mesajları güvenli bir şekilde okuyabilir veya gönderebilirsiniz ve sohbet ettiğiniz herkes de bu cihaza güvenebilir." + "Cihaz doğrulandı" + "Başka bir cihaz kullan" + "Diğer cihazda bekleniyor…" + "Ayarlarınızı daha sonra değiştirebilirsiniz." + "Bildirimlere izin verin ve hiçbir mesajı kaçırmayın" + "Kurtarma anahtarını girin" + diff --git a/features/ftue/impl/src/main/res/values-uk/translations.xml b/features/ftue/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..14ee57a --- /dev/null +++ b/features/ftue/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,16 @@ + + + "Не можете підтвердити?" + "Створити новий ключ відновлення" + "Верифікуйте цей пристрій, щоб налаштувати безпечний обмін повідомленнями." + "Підтвердьте, що це ви" + "Використовуйте інший пристрій" + "Використовуйте ключ відновлення" + "Тепер ви можете безпечно читати або надсилати повідомлення, і кожен, з ким ви спілкуєтесь, також може довіряти цьому пристрою." + "Пристрій перевірено" + "Використовуйте інший пристрій" + "Чекає на інше пристрій…" + "Ви можете змінити свої налаштування пізніше." + "Дозволити сповіщення і ніколи не пропускати повідомлення" + "Введіть ключ відновлення" + diff --git a/features/ftue/impl/src/main/res/values-ur/translations.xml b/features/ftue/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..402140e --- /dev/null +++ b/features/ftue/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,16 @@ + + + "تصدیق نہیں کر سکتے؟" + "ایک نئی بازیابی کلید تخلیق کریں" + "محفوظ پیغام رسانی ترتیب دینے کیلئے اس آلے کی توثیق کریں۔" + "اپنی شناخت کی تصدیق کریں" + "دوسرا آلہ استعمال کریں" + "بازیابی کلید استعمال کریں" + "اب آپ محفوظ طریقے سے پیغامات پڑھ یا بھیج سکتے ہیں، اور جسکے ساتھ آپ گفتگو کرتے ہیں وہ بھی اس آلہ پر بھروسہ کر سکتا ہے۔" + "آلہ توثیق شدہ" + "دوسرا آلہ استعمال کریں" + "دوسرے آلہ پر منتظر…" + "آپ بعد میں اپنی ترتیبات تبدیل کر سکتے ہیں۔" + "اطلاعات کی اجازت دیں اور کبھی بھی کسی پیغام سے محروم نہ ہوں۔" + "بازیابی کلید درج کریں" + diff --git a/features/ftue/impl/src/main/res/values-uz/translations.xml b/features/ftue/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..9ed2a2b --- /dev/null +++ b/features/ftue/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,16 @@ + + + "Tasdiqlay olmayapsizmi?" + "Yangi tiklash kalitini yarating" + "Xavfsiz xabarlashuvni sozlash uchun ushbu qurilmani tasdiqlang." + "Shaxsingizni tasdiqlang" + "Boshqa qurilmadan foydalanish" + "Qayta tiklash kalitidan foydalaning" + "Endi xabarlarni xavfsiz tarzda o‘qish yoki yuborish imkoniyatiga egasiz, shuningdek, siz bilan muloqot qilayotgan har qanday kishi ham bu qurilmaga ishonch bildirishi mumkin." + "Qurilma tasdiqlandi" + "Boshqa qurilmadan foydalanish" + "Boshqa qurilmada kutilmoqda…" + "Sozlamalaringizni keyinroq o\'zgartirishingiz mumkin." + "Bildirishnomalarga ruxsat bering va hech qachon xabarni o\'tkazib yubormang" + "Tiklash kalitini kiriting" + diff --git a/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml b/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..93ef039 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,16 @@ + + + "無法確認?" + "建立新的復原金鑰" + "驗證這部裝置以設定安全通訊。" + "確認這是你本人" + "使用另一部裝置" + "使用復原金鑰" + "您可以安全地讀取和發送訊息了,與您聊天的人也可以信任這部裝置。" + "裝置已驗證" + "使用另一部裝置" + "正在等待其他裝置…" + "您稍後仍可變更設定。" + "允許通知,永遠不會錯誤任何訊息" + "輸入復原金鑰" + diff --git a/features/ftue/impl/src/main/res/values-zh/translations.xml b/features/ftue/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..1c72ad8 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,16 @@ + + + "无法确认?" + "创建新的恢复密钥" + "验证此设备以开始安全地收发消息。" + "确认这是你" + "使用其他设备" + "使用恢复密钥" + "现在,您可以安全地阅读或发送消息,与您聊天的人也会信任此设备。" + "设备已验证" + "使用其他设备" + "正在等待其他设备……" + "您可以稍后更改设置。" + "允许通知,绝不错过任何消息" + "输入恢复密钥" + 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 0000000..91cf96d --- /dev/null +++ b/features/ftue/impl/src/main/res/values/localazy.xml @@ -0,0 +1,16 @@ + + + "Can\'t confirm?" + "Create a new recovery key" + "Verify this device to set up secure messaging." + "Confirm your identity" + "Use another device" + "Use recovery key" + "Now you can read or send messages securely, and anyone you chat with can also trust this device." + "Device verified" + "Use another device" + "Waiting on other device…" + "You can change your settings later." + "Allow notifications and never miss a message" + "Enter recovery key" + diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPointTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPointTest.kt new file mode 100644 index 0000000..bbab0fc --- /dev/null +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPointTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.features.lockscreen.test.FakeLockScreenEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DefaultFtueEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() = runTest { + val entryPoint = DefaultFtueEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + FtueFlowNode( + buildContext = buildContext, + plugins = plugins, + analyticsEntryPoint = { _, _ -> lambdaError() }, + defaultFtueService = createDefaultFtueService(), + lockScreenEntryPoint = FakeLockScreenEntryPoint(), + ) + } + val result = entryPoint.createNode(parentNode, BuildContext.root(null)) + assertThat(result).isInstanceOf(FtueFlowNode::class.java) + } +} diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt new file mode 100644 index 0000000..dee8c7c --- /dev/null +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl + +import android.os.Build +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.ftue.api.state.FtueState +import io.element.android.features.ftue.impl.state.DefaultFtueService +import io.element.android.features.ftue.impl.state.FtueStep +import io.element.android.features.ftue.impl.state.InternalFtueState +import io.element.android.features.lockscreen.api.LockScreenService +import io.element.android.features.lockscreen.test.FakeLockScreenService +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.verification.FakeSessionVerificationService +import io.element.android.libraries.permissions.api.PermissionStateProvider +import io.element.android.libraries.permissions.test.FakePermissionStateProvider +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.noop.NoopAnalyticsService +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultFtueServiceTest { + @Test + fun `given any check being false and session verification state being loaded, FtueState is Incomplete`() = runTest { + val sessionVerificationService = FakeSessionVerificationService().apply { + emitVerifiedStatus(SessionVerifiedStatus.Unknown) + } + val service = createDefaultFtueService( + sessionVerificationService = sessionVerificationService, + ) + + service.state.test { + // Verification state is unknown, we don't display the flow yet + assertThat(awaitItem()).isEqualTo(FtueState.Unknown) + + // Verification state is known, we should display the flow if any check is false + sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified) + assertThat(awaitItem()).isEqualTo(FtueState.Incomplete) + } + } + + @Test + fun `given all checks being true, FtueState is Complete`() = runTest { + val analyticsService = FakeAnalyticsService() + val sessionVerificationService = FakeSessionVerificationService() + val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true) + val lockScreenService = FakeLockScreenService() + val service = createDefaultFtueService( + sessionVerificationService = sessionVerificationService, + analyticsService = analyticsService, + permissionStateProvider = permissionStateProvider, + lockScreenService = lockScreenService, + ) + + sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified) + analyticsService.setDidAskUserConsent() + permissionStateProvider.setPermissionGranted() + lockScreenService.setIsPinSetup(true) + service.updateFtueStep() + service.state.test { + assertThat(awaitItem()).isEqualTo(FtueState.Unknown) + assertThat(awaitItem()).isEqualTo(FtueState.Complete) + } + } + + @Test + fun `given all checks being true with no analytics, FtueState is Complete`() = runTest { + val analyticsService = NoopAnalyticsService() + val sessionVerificationService = FakeSessionVerificationService() + val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true) + val lockScreenService = FakeLockScreenService() + val service = createDefaultFtueService( + sessionVerificationService = sessionVerificationService, + analyticsService = analyticsService, + permissionStateProvider = permissionStateProvider, + lockScreenService = lockScreenService, + ) + + sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified) + permissionStateProvider.setPermissionGranted() + lockScreenService.setIsPinSetup(true) + service.updateFtueStep() + service.state.test { + assertThat(awaitItem()).isEqualTo(FtueState.Unknown) + assertThat(awaitItem()).isEqualTo(FtueState.Complete) + } + } + + @Test + fun `traverse flow`() = runTest { + val sessionVerificationService = FakeSessionVerificationService().apply { + emitVerifiedStatus(SessionVerifiedStatus.NotVerified) + } + val analyticsService = FakeAnalyticsService() + val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false) + val lockScreenService = FakeLockScreenService() + val service = createDefaultFtueService( + sessionVerificationService = sessionVerificationService, + analyticsService = analyticsService, + permissionStateProvider = permissionStateProvider, + lockScreenService = lockScreenService, + ) + + service.ftueStepStateFlow.test { + assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown) + // Session verification + assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.SessionVerification)) + sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified) + // User completes verification + service.onUserCompletedSessionVerification() + // Notifications opt in + assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.NotificationsOptIn)) + permissionStateProvider.setPermissionGranted() + // Simulate event from NotificationsOptInNode.Callback.onNotificationsOptInFinished + service.updateFtueStep() + // Entering PIN code + assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.LockscreenSetup)) + lockScreenService.setIsPinSetup(true) + // Simulate event from LockScreenEntryPoint.Callback.onSetupDone() + service.updateFtueStep() + // Analytics opt in + assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.AnalyticsOptIn)) + analyticsService.setDidAskUserConsent() + // Final step + assertThat(awaitItem()).isEqualTo(InternalFtueState.Complete) + } + } + + @Test + fun `if a check for a step is true, start from the next one`() = runTest { + val sessionVerificationService = FakeSessionVerificationService() + val analyticsService = FakeAnalyticsService() + val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false) + val lockScreenService = FakeLockScreenService() + val service = createDefaultFtueService( + sessionVerificationService = sessionVerificationService, + analyticsService = analyticsService, + permissionStateProvider = permissionStateProvider, + lockScreenService = lockScreenService, + ) + + // Skip first 3 steps + sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified) + permissionStateProvider.setPermissionGranted() + lockScreenService.setIsPinSetup(true) + + service.ftueStepStateFlow.test { + assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown) + // Analytics opt in + assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.AnalyticsOptIn)) + analyticsService.setDidAskUserConsent() + assertThat(awaitItem()).isEqualTo(InternalFtueState.Complete) + } + } + + @Test + fun `if version is older than 13 we don't display the notification opt in screen`() = runTest { + val sessionVerificationService = FakeSessionVerificationService() + val analyticsService = FakeAnalyticsService() + val lockScreenService = FakeLockScreenService() + + val service = createDefaultFtueService( + sdkIntVersion = Build.VERSION_CODES.M, + sessionVerificationService = sessionVerificationService, + analyticsService = analyticsService, + lockScreenService = lockScreenService, + ) + + sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified) + lockScreenService.setIsPinSetup(true) + + service.ftueStepStateFlow.test { + assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown) + // Analytics opt in + assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.AnalyticsOptIn)) + analyticsService.setDidAskUserConsent() + assertThat(awaitItem()).isEqualTo(InternalFtueState.Complete) + } + } +} + +internal fun TestScope.createDefaultFtueService( + sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(), + analyticsService: AnalyticsService = FakeAnalyticsService(), + permissionStateProvider: PermissionStateProvider = FakePermissionStateProvider(permissionGranted = false), + lockScreenService: LockScreenService = FakeLockScreenService(), + sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(), + // First version where notification permission is required + sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU, +) = DefaultFtueService( + sessionCoroutineScope = backgroundScope, + sessionVerificationService = sessionVerificationService, + sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion), + analyticsService = analyticsService, + permissionStateProvider = permissionStateProvider, + lockScreenService = lockScreenService, + sessionPreferencesStore = sessionPreferencesStore, +) diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenterTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenterTest.kt new file mode 100644 index 0000000..32d68dd --- /dev/null +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenterTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl.notifications + +import android.os.Build +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.permissions.api.PermissionStateProvider +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionStateProvider +import io.element.android.libraries.permissions.test.FakePermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class NotificationsOptInPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private var isFinished = false + + @Test + fun `initial state`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.notificationsPermissionState.showDialog).isFalse() + } + } + + @Test + fun `show dialog on continue clicked`() = runTest { + val permissionPresenter = FakePermissionsPresenter() + val presenter = createPresenter(permissionPresenter) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(NotificationsOptInEvents.ContinueClicked) + assertThat(awaitItem().notificationsPermissionState.showDialog).isTrue() + } + } + + @Test + fun `finish flow on continue clicked with permission already granted`() = runTest { + val permissionPresenter = FakePermissionsPresenter().apply { + setPermissionGranted() + } + val presenter = createPresenter(permissionPresenter) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(NotificationsOptInEvents.ContinueClicked) + assertThat(isFinished).isTrue() + } + } + + @Test + fun `finish flow on not now clicked`() = runTest { + val permissionPresenter = FakePermissionsPresenter() + val presenter = createPresenter( + permissionsPresenter = permissionPresenter, + sdkIntVersion = Build.VERSION_CODES.M + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(NotificationsOptInEvents.NotNowClicked) + assertThat(isFinished).isTrue() + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `set permission denied on not now clicked in API 33`() = runTest(StandardTestDispatcher()) { + val permissionPresenter = FakePermissionsPresenter() + val permissionStateProvider = FakePermissionStateProvider() + val presenter = createPresenter( + permissionsPresenter = permissionPresenter, + permissionStateProvider = permissionStateProvider, + sdkIntVersion = Build.VERSION_CODES.TIRAMISU + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(NotificationsOptInEvents.NotNowClicked) + + // Allow background coroutines to run + runCurrent() + + val isPermissionDenied = runBlocking { + permissionStateProvider.isPermissionDenied("notifications").first() + } + assertThat(isPermissionDenied).isTrue() + } + } + + private fun TestScope.createPresenter( + permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), + permissionStateProvider: PermissionStateProvider = FakePermissionStateProvider(), + sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU, + ) = NotificationsOptInPresenter( + permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter), + callback = object : NotificationsOptInNode.Callback { + override fun onNotificationsOptInFinished() { + isFinished = true + } + }, + appCoroutineScope = this, + permissionStateProvider = permissionStateProvider, + buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion), + ) +} diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt new file mode 100644 index 0000000..c95c455 --- /dev/null +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl.sessionverification.choosemode + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.logout.api.direct.DirectLogoutEvents +import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ChooseSessionVerificationModePresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + presenter.test { + awaitItem().run { + assertThat(buttonsState.isLoading()).isTrue() + assertThat(directLogoutState.logoutAction.isUninitialized()).isTrue() + } + } + } + + @Test + fun `present - state is relayed from EncryptionService, order 1`() = runTest { + val encryptionService = FakeEncryptionService() + val presenter = createPresenter(encryptionService = encryptionService) + presenter.test { + assertThat(awaitItem().buttonsState.isLoading()).isTrue() + // Has device to verify against + encryptionService.emitHasDevicesToVerifyAgainst(AsyncData.Success(false)) + // Can enter recovery key + encryptionService.emitRecoveryState(RecoveryState.DISABLED) + assertThat(awaitItem().buttonsState.dataOrNull()).isEqualTo( + ChooseSelfVerificationModeState.ButtonsState( + canUseAnotherDevice = false, + canEnterRecoveryKey = false, + ) + ) + } + } + + @Test + fun `present - state is relayed from EncryptionService, order 2`() = runTest { + val encryptionService = FakeEncryptionService() + val presenter = createPresenter(encryptionService = encryptionService) + presenter.test { + assertThat(awaitItem().buttonsState.isLoading()).isTrue() + // Can enter recovery key + encryptionService.emitRecoveryState(RecoveryState.DISABLED) + // Has device to verify against + encryptionService.emitHasDevicesToVerifyAgainst(AsyncData.Success(false)) + assertThat(awaitItem().buttonsState.dataOrNull()).isEqualTo( + ChooseSelfVerificationModeState.ButtonsState( + canUseAnotherDevice = false, + canEnterRecoveryKey = false, + ) + ) + } + } + + @Test + fun `present - can use another device`() = runTest { + val encryptionService = FakeEncryptionService() + val presenter = createPresenter(encryptionService = encryptionService) + presenter.test { + assertThat(awaitItem().buttonsState.isLoading()).isTrue() + // Can enter recovery key + encryptionService.emitRecoveryState(RecoveryState.DISABLED) + // Has device to verify against + encryptionService.emitHasDevicesToVerifyAgainst(AsyncData.Success(true)) + assertThat(awaitItem().buttonsState.dataOrNull()).isEqualTo( + ChooseSelfVerificationModeState.ButtonsState( + canUseAnotherDevice = true, + canEnterRecoveryKey = false, + ) + ) + } + } + + @Test + fun `present - can enter recovery key`() = runTest { + val encryptionService = FakeEncryptionService() + val presenter = createPresenter(encryptionService = encryptionService) + presenter.test { + assertThat(awaitItem().buttonsState.isLoading()).isTrue() + // Can enter recovery key + encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE) + // Has device to verify against + encryptionService.emitHasDevicesToVerifyAgainst(AsyncData.Success(false)) + assertThat(awaitItem().buttonsState.dataOrNull()).isEqualTo( + ChooseSelfVerificationModeState.ButtonsState( + canUseAnotherDevice = false, + canEnterRecoveryKey = true, + ) + ) + } + } + + @Test + fun `sing out action triggers a direct logout`() = runTest { + val logoutEventRecorder = lambdaRecorder {} + val logoutPresenter = Presenter { + aDirectLogoutState(eventSink = logoutEventRecorder) + } + val presenter = createPresenter(directLogoutPresenter = logoutPresenter) + presenter.test { + val initial = awaitItem() + initial.eventSink(ChooseSelfVerificationModeEvent.SignOut) + logoutEventRecorder.assertions().isCalledOnce() + .with(value(DirectLogoutEvents.Logout(ignoreSdkError = false))) + } + } + + private fun createPresenter( + encryptionService: FakeEncryptionService = FakeEncryptionService(), + directLogoutPresenter: Presenter = Presenter { aDirectLogoutState() } + ) = ChooseSelfVerificationModePresenter( + encryptionService = encryptionService, + directLogoutPresenter = directLogoutPresenter, + ) +} diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt new file mode 100644 index 0000000..f201cb6 --- /dev/null +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.impl.sessionverification.choosemode + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.ftue.impl.R +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class ChooseSessionVerificationModeViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on learn more invokes the expected callback`() { + ensureCalledOnce { callback -> + rule.setChooseSelfVerificationModeView( + aChooseSelfVerificationModeState(), + onLearnMoreClick = callback, + ) + rule.clickOn(CommonStrings.action_learn_more) + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on use another device calls the callback`() { + ensureCalledOnce { callback -> + rule.setChooseSelfVerificationModeView( + aChooseSelfVerificationModeState(AsyncData.Success(aButtonsState(canUseAnotherDevice = true))), + onUseAnotherDevice = callback, + ) + rule.clickOn(R.string.screen_identity_use_another_device) + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on enter recovery key calls the callback`() { + ensureCalledOnce { callback -> + rule.setChooseSelfVerificationModeView( + aChooseSelfVerificationModeState(AsyncData.Success(aButtonsState(canEnterRecoveryKey = true))), + onEnterRecoveryKey = callback, + ) + rule.clickOn(R.string.screen_session_verification_enter_recovery_key) + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on cannot confirm calls the reset keys callback`() { + ensureCalledOnce { callback -> + rule.setChooseSelfVerificationModeView( + aChooseSelfVerificationModeState(), + onResetKey = callback, + ) + rule.clickOn(R.string.screen_identity_confirmation_cannot_confirm) + } + } + + private fun AndroidComposeTestRule.setChooseSelfVerificationModeView( + state: ChooseSelfVerificationModeState, + onLearnMoreClick: () -> Unit = EnsureNeverCalled(), + onUseAnotherDevice: () -> Unit = EnsureNeverCalled(), + onResetKey: () -> Unit = EnsureNeverCalled(), + onEnterRecoveryKey: () -> Unit = EnsureNeverCalled(), + ) { + setContent { + ChooseSelfVerificationModeView( + state = state, + onLearnMore = onLearnMoreClick, + onUseAnotherDevice = onUseAnotherDevice, + onResetKey = onResetKey, + onUseRecoveryKey = onEnterRecoveryKey, + ) + } + } +} diff --git a/features/ftue/test/build.gradle.kts b/features/ftue/test/build.gradle.kts new file mode 100644 index 0000000..4c7dc57 --- /dev/null +++ b/features/ftue/test/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.ftue.test" +} + +dependencies { + implementation(projects.features.ftue.api) + implementation(projects.tests.testutils) +} diff --git a/features/ftue/test/src/main/kotlin/io/element/android/features/ftue/test/FakeFtueService.kt b/features/ftue/test/src/main/kotlin/io/element/android/features/ftue/test/FakeFtueService.kt new file mode 100644 index 0000000..963f67c --- /dev/null +++ b/features/ftue/test/src/main/kotlin/io/element/android/features/ftue/test/FakeFtueService.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.ftue.test + +import io.element.android.features.ftue.api.state.FtueService +import io.element.android.features.ftue.api.state.FtueState +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeFtueService : FtueService { + override val state: MutableStateFlow = MutableStateFlow(FtueState.Unknown) + + suspend fun emitState(newState: FtueState) { + state.emit(newState) + } +} diff --git a/features/home/api/build.gradle.kts b/features/home/api/build.gradle.kts new file mode 100644 index 0000000..2e72565 --- /dev/null +++ b/features/home/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.home.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt new file mode 100644 index 0000000..71ee093 --- /dev/null +++ b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.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.room.JoinedRoom + +interface HomeEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: Callback, + ): Node + + interface Callback : Plugin { + fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) + fun navigateToCreateRoom() + fun navigateToSettings() + fun navigateToSetUpRecovery() + fun navigateToEnterRecoveryKey() + fun navigateToRoomSettings(roomId: RoomId) + fun navigateToBugReport() + } +} diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts new file mode 100644 index 0000000..b36ee6a --- /dev/null +++ b/features/home/impl/build.gradle.kts @@ -0,0 +1,81 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.home.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.appconfig) + implementation(projects.libraries.core) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.featureflag.api) + 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.indicator.api) + implementation(projects.libraries.deeplink.api) + implementation(projects.libraries.fullscreenintent.api) + implementation(projects.libraries.permissions.api) + implementation(projects.libraries.permissions.noop) + implementation(projects.libraries.preferences.api) + implementation(projects.libraries.push.api) + implementation(projects.features.announcement.api) + implementation(projects.features.invite.api) + implementation(projects.features.networkmonitor.api) + implementation(projects.features.logout.api) + implementation(projects.features.leaveroom.api) + implementation(projects.features.rageshake.api) + implementation(projects.services.analytics.api) + implementation(libs.androidx.datastore.preferences) + implementation(libs.haze) + implementation(libs.haze.materials) + implementation(projects.features.reportroom.api) + implementation(projects.features.rolesandpermissions.api) + implementation(projects.libraries.previewutils) + api(projects.features.home.api) + + testCommonDependencies(libs, true) + testImplementation(projects.features.announcement.test) + testImplementation(projects.features.invite.test) + testImplementation(projects.features.logout.test) + testImplementation(projects.features.networkmonitor.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.libraries.dateformatter.test) + testImplementation(projects.libraries.eventformatter.test) + testImplementation(projects.libraries.indicator.test) + testImplementation(projects.libraries.permissions.noop) + testImplementation(projects.libraries.permissions.test) + testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.sessionStorage.test) + testImplementation(projects.libraries.push.test) + testImplementation(projects.services.analytics.test) + testImplementation(projects.services.toolbox.test) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt new file mode 100644 index 0000000..b29dc78 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.sessionstorage.api.SessionData +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +class CurrentUserWithNeighborsBuilder { + /** + * Build a list of [MatrixUser] containing the current user. If there are other sessions, the list + * will contain 3 users, with the current user in the middle. + * If there is only one other session, the list will contain twice the other user, to allow cycling. + */ + fun build( + matrixUser: MatrixUser, + sessions: List, + ): ImmutableList { + // Sort by position to always have the same order (not depending on last account usage) + return sessions.sortedBy { it.position } + .map { + if (it.userId == matrixUser.userId.value) { + // Always use the freshest profile for the current user + matrixUser + } else { + // Use the data from the DB + MatrixUser( + userId = UserId(it.userId), + displayName = it.userDisplayName, + avatarUrl = it.userAvatarUrl, + ) + } + } + .let { sessionList -> + // If the list has one item, there is no other session, return the list + when (sessionList.size) { + // Can happen when the user signs out (?) + 0 -> listOf(matrixUser) + 1 -> sessionList + else -> { + // Create a list with extra item at the start and end if necessary to have the current user in the middle + // If the list is [A, B, C, D] and the current user is A we want to return [D, A, B] + // If the current user is B, we want to return [A, B, C] + // If the current user is C, we want to return [B, C, D] + // If the current user is D, we want to return [C, D, A] + // Special case: if there are only two users, we want to return [B, A, B] or [A, B, A] to allows cycling + // between the two users. + val currentUserIndex = sessionList.indexOfFirst { it.userId == matrixUser.userId } + when (currentUserIndex) { + // This can happen when the user signs out. + // In this case, just return a singleton list with the current user. + -1 -> listOf(matrixUser) + 0 -> listOf(sessionList.last()) + sessionList.take(2) + sessionList.lastIndex -> sessionList.takeLast(2) + sessionList.first() + else -> sessionList.slice(currentUserIndex - 1..currentUserIndex + 1) + } + } + } + } + .toImmutableList() + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPoint.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPoint.kt new file mode 100644 index 0000000..8da31b6 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPoint.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.home.api.HomeEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultHomeEntryPoint : HomeEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: HomeEntryPoint.Callback, + ): Node { + return parentNode.createNode(buildContext, listOf(callback)) + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvents.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvents.kt new file mode 100644 index 0000000..db9dafb --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvents.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl + +import io.element.android.libraries.matrix.api.core.SessionId + +sealed interface HomeEvents { + data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvents + data class SwitchToAccount(val sessionId: SessionId) : HomeEvents +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt new file mode 100644 index 0000000..d9f87e2 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl + +import android.app.Activity +import android.os.Parcelable +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +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.node.node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.annotations.ContributesNode +import io.element.android.features.home.api.HomeEntryPoint +import io.element.android.features.home.impl.components.RoomListMenuAction +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.features.home.impl.roomlist.RoomListEvents +import io.element.android.features.invite.api.InviteData +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView +import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint +import io.element.android.features.leaveroom.api.LeaveRoomRenderer +import io.element.android.features.logout.api.direct.DirectLogoutView +import io.element.android.features.reportroom.api.ReportRoomEntryPoint +import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint +import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.appyx.launchMolecule +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.deeplink.api.usecase.InviteFriendsUseCase +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.utils.DelayedVisibility +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize +import timber.log.Timber +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.milliseconds + +@ContributesNode(SessionScope::class) +@AssistedInject +class HomeFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val matrixClient: MatrixClient, + private val presenter: HomePresenter, + private val inviteFriendsUseCase: InviteFriendsUseCase, + private val analyticsService: AnalyticsService, + private val acceptDeclineInviteView: AcceptDeclineInviteView, + private val directLogoutView: DirectLogoutView, + private val reportRoomEntryPoint: ReportRoomEntryPoint, + private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint, + private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint, + private val leaveRoomRenderer: LeaveRoomRenderer, + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +) { + private val callback: HomeEntryPoint.Callback = callback() + private val stateFlow = launchMolecule { presenter.present() } + + override fun onBuilt() { + super.onBuilt() + lifecycle.subscribe( + onResume = { + analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.Home)) + } + ) + whenChildAttached { + commonLifecycle: Lifecycle, + changeRoomMemberRolesNode: ChangeRoomMemberRolesEntryPoint.NodeProxy, + -> + commonLifecycle.coroutineScope.launch { + val isNewOwnerSelected = changeRoomMemberRolesNode.waitForCompletion() + withContext(NonCancellable) { + backstack.pop() + if (isNewOwnerSelected) { + onNewOwnersSelected(changeRoomMemberRolesNode.roomId) + } + } + } + } + } + + sealed interface NavTarget : Parcelable { + @Parcelize + data object Root : NavTarget + + @Parcelize + data class ReportRoom(val roomId: RoomId) : NavTarget + + @Parcelize + data class DeclineInviteAndBlockUser(val inviteData: InviteData) : NavTarget + + @Parcelize + data class SelectNewOwnersWhenLeavingRoom(val roomId: RoomId) : NavTarget + } + + private fun navigateToReportRoom(roomId: RoomId) { + backstack.push(NavTarget.ReportRoom(roomId)) + } + + private fun navigateToDeclineInviteAndBlockUser(roomSummary: RoomListRoomSummary) { + backstack.push(NavTarget.DeclineInviteAndBlockUser(roomSummary.toInviteData())) + } + + private fun onMenuActionClick(activity: Activity, roomListMenuAction: RoomListMenuAction) { + when (roomListMenuAction) { + RoomListMenuAction.InviteFriends -> { + inviteFriendsUseCase.execute(activity) + } + RoomListMenuAction.ReportBug -> { + callback.navigateToBugReport() + } + } + } + + private fun navigateToSelectNewOwnersWhenLeavingRoom(roomId: RoomId) { + backstack.push(NavTarget.SelectNewOwnersWhenLeavingRoom(roomId)) + } + + private fun onNewOwnersSelected(roomId: RoomId) { + stateFlow.value.roomListState.eventSink(RoomListEvents.LeaveRoom(roomId, needsConfirmation = false)) + } + + private fun rootNode(buildContext: BuildContext): Node { + return node(buildContext) { modifier -> + val state by stateFlow.collectAsState() + val activity = requireNotNull(LocalActivity.current) + + val loadingJoinedRoomJob = remember { mutableStateOf>(AsyncData.Uninitialized) } + if (loadingJoinedRoomJob.value.isLoading()) { + DelayedVisibility(duration = 400.milliseconds) { + ProgressDialog( + onDismissRequest = { + loadingJoinedRoomJob.value.dataOrNull()?.cancel() + loadingJoinedRoomJob.value = AsyncData.Uninitialized + } + ) + } + } + + fun navigateToRoom( + roomId: RoomId, + ) { + if (!loadingJoinedRoomJob.value.isUninitialized()) { + Timber.w("Already loading a room, ignoring navigateToRoom for $roomId") + return + } + + val job = sessionCoroutineScope.launch { + runCatchingExceptions { + matrixClient.getJoinedRoom(roomId) + }.fold( + onSuccess = { joinedRoom -> + if (isActive) { + callback.navigateToRoom(roomId, joinedRoom) + loadingJoinedRoomJob.value = AsyncData.Success(coroutineContext.job) + // Wait a bit before resetting the state to avoid allowing to open several rooms + delay(200.milliseconds) + loadingJoinedRoomJob.value = AsyncData.Uninitialized + } + }, + onFailure = { + // If the operation wasn't cancelled, navigate without the room, using the room id + if (it !is CancellationException) { + callback.navigateToRoom(roomId, null) + } + loadingJoinedRoomJob.value = AsyncData.Failure(error = it, prevData = coroutineContext.job) + // Wait a bit before resetting the state to avoid allowing to open several rooms + delay(200.milliseconds) + loadingJoinedRoomJob.value = AsyncData.Uninitialized + } + ) + } + loadingJoinedRoomJob.value = AsyncData.Loading(job) + } + + HomeView( + homeState = state, + onRoomClick = ::navigateToRoom, + onSettingsClick = callback::navigateToSettings, + onStartChatClick = callback::navigateToCreateRoom, + onSetUpRecoveryClick = callback::navigateToSetUpRecovery, + onConfirmRecoveryKeyClick = callback::navigateToEnterRecoveryKey, + onRoomSettingsClick = callback::navigateToRoomSettings, + onMenuActionClick = { onMenuActionClick(activity, it) }, + onReportRoomClick = ::navigateToReportRoom, + onDeclineInviteAndBlockUser = ::navigateToDeclineInviteAndBlockUser, + modifier = modifier, + acceptDeclineInviteView = { + acceptDeclineInviteView.Render( + state = state.roomListState.acceptDeclineInviteState, + onAcceptInviteSuccess = ::navigateToRoom, + onDeclineInviteSuccess = { }, + modifier = Modifier + ) + }, + leaveRoomView = { + leaveRoomRenderer.Render( + state = state.roomListState.leaveRoomState, + onSelectNewOwners = ::navigateToSelectNewOwnersWhenLeavingRoom, + modifier = Modifier + ) + } + ) + directLogoutView.Render(state.directLogoutState) + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView() + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.ReportRoom -> { + reportRoomEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + roomId = navTarget.roomId, + ) + } + is NavTarget.DeclineInviteAndBlockUser -> { + declineInviteAndBlockUserEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + inviteData = navTarget.inviteData, + ) + } + is NavTarget.SelectNewOwnersWhenLeavingRoom -> { + val room = runBlocking { matrixClient.getJoinedRoom(navTarget.roomId) } ?: error("Room ${navTarget.roomId} not found") + changeRoomMemberRolesEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + room = room, + listType = ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving, + ) + } + NavTarget.Root -> rootNode(buildContext) + } + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeNavigationBarItem.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeNavigationBarItem.kt new file mode 100644 index 0000000..6a4a5e1 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeNavigationBarItem.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import io.element.android.compound.tokens.generated.CompoundIcons + +enum class HomeNavigationBarItem( + @StringRes + val labelRes: Int, +) { + Chats( + labelRes = R.string.screen_home_tab_chats + ), + Spaces( + labelRes = R.string.screen_home_tab_spaces + ); + + @Composable + fun icon( + isSelected: Boolean, + ) = when (this) { + Chats -> if (isSelected) CompoundIcons.ChatSolid() else CompoundIcons.Chat() + Spaces -> if (isSelected) CompoundIcons.WorkspaceSolid() else CompoundIcons.Workspace() + } + + companion object { + fun from(index: Int): HomeNavigationBarItem { + return entries.getOrElse(index) { Chats } + } + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt new file mode 100644 index 0000000..e53d208 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService +import io.element.android.features.home.impl.roomlist.RoomListState +import io.element.android.features.home.impl.spaces.HomeSpacesState +import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.features.rageshake.api.RageshakeFeatureAvailability +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.indicator.api.IndicatorService +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +@Inject +class HomePresenter( + private val client: MatrixClient, + private val syncService: SyncService, + private val snackbarDispatcher: SnackbarDispatcher, + private val indicatorService: IndicatorService, + private val roomListPresenter: Presenter, + private val homeSpacesPresenter: Presenter, + private val logoutPresenter: Presenter, + private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, + private val featureFlagService: FeatureFlagService, + private val sessionStore: SessionStore, + private val announcementService: AnnouncementService, +) : Presenter { + private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder() + + @Composable + override fun present(): HomeState { + val coroutineState = rememberCoroutineScope() + val matrixUser by client.userProfile.collectAsState() + val currentUserAndNeighbors by remember { + combine( + client.userProfile, + sessionStore.sessionsFlow(), + currentUserWithNeighborsBuilder::build, + ) + }.collectAsState(initial = persistentListOf(matrixUser)) + val isOnline by syncService.isOnline.collectAsState() + val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false) + val roomListState = roomListPresenter.present() + val homeSpacesState = homeSpacesPresenter.present() + val isSpaceFeatureEnabled by remember { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.Space) + }.collectAsState(initial = false) + var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) } + val currentHomeNavigationBarItem by remember { + derivedStateOf { + HomeNavigationBarItem.from(currentHomeNavigationBarItemOrdinal) + } + } + LaunchedEffect(Unit) { + // Force a refresh of the profile + client.getUserProfile() + } + // Avatar indicator + val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator() + val directLogoutState = logoutPresenter.present() + + fun handleEvent(event: HomeEvents) { + when (event) { + is HomeEvents.SelectHomeNavigationBarItem -> coroutineState.launch { + if (event.item == HomeNavigationBarItem.Spaces) { + announcementService.showAnnouncement(Announcement.Space) + } + currentHomeNavigationBarItemOrdinal = event.item.ordinal + } + is HomeEvents.SwitchToAccount -> coroutineState.launch { + sessionStore.setLatestSession(event.sessionId.value) + } + } + } + + LaunchedEffect(homeSpacesState.spaceRooms.isEmpty()) { + // If the last space is left, ensure that the Chat view is rendered. + if (homeSpacesState.spaceRooms.isEmpty()) { + currentHomeNavigationBarItemOrdinal = HomeNavigationBarItem.Chats.ordinal + } + } + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + return HomeState( + currentUserAndNeighbors = currentUserAndNeighbors, + showAvatarIndicator = showAvatarIndicator, + hasNetworkConnection = isOnline, + currentHomeNavigationBarItem = currentHomeNavigationBarItem, + roomListState = roomListState, + homeSpacesState = homeSpacesState, + snackbarMessage = snackbarMessage, + canReportBug = canReportBug, + directLogoutState = directLogoutState, + isSpaceFeatureEnabled = isSpaceFeatureEnabled, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt new file mode 100644 index 0000000..90667a8 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl + +import io.element.android.features.home.impl.roomlist.RoomListState +import io.element.android.features.home.impl.spaces.HomeSpacesState +import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList + +data class HomeState( + /** + * The current user of this session, in case of multiple accounts, will contains 3 items, with the + * current user in the middle. + */ + val currentUserAndNeighbors: ImmutableList, + val showAvatarIndicator: Boolean, + val hasNetworkConnection: Boolean, + val currentHomeNavigationBarItem: HomeNavigationBarItem, + val roomListState: RoomListState, + val homeSpacesState: HomeSpacesState, + val snackbarMessage: SnackbarMessage?, + val canReportBug: Boolean, + val directLogoutState: DirectLogoutState, + val isSpaceFeatureEnabled: Boolean, + val eventSink: (HomeEvents) -> Unit, +) { + val displayActions = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats + val displayRoomListFilters = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats && roomListState.displayFilters + val showNavigationBar = isSpaceFeatureEnabled && homeSpacesState.spaceRooms.isNotEmpty() +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt new file mode 100644 index 0000000..43010b1 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.home.impl.roomlist.RoomListState +import io.element.android.features.home.impl.roomlist.RoomListStateProvider +import io.element.android.features.home.impl.roomlist.aRoomListState +import io.element.android.features.home.impl.roomlist.aRoomsContentState +import io.element.android.features.home.impl.roomlist.generateRoomListRoomSummaryList +import io.element.android.features.home.impl.spaces.HomeSpacesState +import io.element.android.features.home.impl.spaces.aHomeSpacesState +import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +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.toImmutableList + +open class HomeStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aHomeState(), + aHomeState(hasNetworkConnection = false), + aHomeState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)), + aHomeState( + isSpaceFeatureEnabled = true, + roomListState = aRoomListState( + // Add more rooms to see the blur effect under the NavigationBar + contentState = aRoomsContentState( + summaries = generateRoomListRoomSummaryList(), + ) + ), + // For the bottom nav bar to be visible in the preview, the user must be member of at least one space + homeSpacesState = aHomeSpacesState(), + ), + aHomeState( + isSpaceFeatureEnabled = true, + currentHomeNavigationBarItem = HomeNavigationBarItem.Spaces, + ), + ) + RoomListStateProvider().values.map { + aHomeState(roomListState = it) + } +} + +internal fun aHomeState( + matrixUser: MatrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"), + currentUserAndNeighbors: List = listOf(matrixUser), + showAvatarIndicator: Boolean = false, + hasNetworkConnection: Boolean = true, + snackbarMessage: SnackbarMessage? = null, + currentHomeNavigationBarItem: HomeNavigationBarItem = HomeNavigationBarItem.Chats, + roomListState: RoomListState = aRoomListState(), + homeSpacesState: HomeSpacesState = aHomeSpacesState(), + canReportBug: Boolean = true, + isSpaceFeatureEnabled: Boolean = false, + directLogoutState: DirectLogoutState = aDirectLogoutState(), + eventSink: (HomeEvents) -> Unit = {} +) = HomeState( + currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(), + showAvatarIndicator = showAvatarIndicator, + hasNetworkConnection = hasNetworkConnection, + snackbarMessage = snackbarMessage, + canReportBug = canReportBug, + directLogoutState = directLogoutState, + currentHomeNavigationBarItem = currentHomeNavigationBarItem, + roomListState = roomListState, + homeSpacesState = homeSpacesState, + isSpaceFeatureEnabled = isSpaceFeatureEnabled, + eventSink = eventSink, +) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt new file mode 100644 index 0000000..42e4f72 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -0,0 +1,361 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalHazeMaterialsApi::class) + +package io.element.android.features.home.impl + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalLayoutDirection +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 dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.HazeMaterials +import dev.chrisbanes.haze.rememberHazeState +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.home.impl.components.HomeTopBar +import io.element.android.features.home.impl.components.RoomListContentView +import io.element.android.features.home.impl.components.RoomListMenuAction +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.features.home.impl.roomlist.RoomListContextMenu +import io.element.android.features.home.impl.roomlist.RoomListDeclineInviteMenu +import io.element.android.features.home.impl.roomlist.RoomListEvents +import io.element.android.features.home.impl.roomlist.RoomListState +import io.element.android.features.home.impl.search.RoomListSearchView +import io.element.android.features.home.impl.spaces.HomeSpacesView +import io.element.android.libraries.androidutils.throttler.FirstThrottler +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.NavigationBar +import io.element.android.libraries.designsystem.theme.components.NavigationBarIcon +import io.element.android.libraries.designsystem.theme.components.NavigationBarItem +import io.element.android.libraries.designsystem.theme.components.NavigationBarText +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost +import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.launch + +@Composable +fun HomeView( + homeState: HomeState, + onRoomClick: (RoomId) -> Unit, + onSettingsClick: () -> Unit, + onSetUpRecoveryClick: () -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, + onStartChatClick: () -> Unit, + onRoomSettingsClick: (roomId: RoomId) -> Unit, + onMenuActionClick: (RoomListMenuAction) -> Unit, + onReportRoomClick: (roomId: RoomId) -> Unit, + onDeclineInviteAndBlockUser: (roomSummary: RoomListRoomSummary) -> Unit, + acceptDeclineInviteView: @Composable () -> Unit, + modifier: Modifier = Modifier, + leaveRoomView: @Composable () -> Unit, +) { + val state: RoomListState = homeState.roomListState + val coroutineScope = rememberCoroutineScope() + val firstThrottler = remember { FirstThrottler(300, coroutineScope) } + Box(modifier) { + if (state.contextMenu is RoomListState.ContextMenu.Shown) { + RoomListContextMenu( + contextMenu = state.contextMenu, + canReportRoom = state.canReportRoom, + eventSink = state.eventSink, + onRoomSettingsClick = onRoomSettingsClick, + onReportRoomClick = onReportRoomClick, + ) + } + if (state.declineInviteMenu is RoomListState.DeclineInviteMenu.Shown) { + RoomListDeclineInviteMenu( + menu = state.declineInviteMenu, + canReportRoom = state.canReportRoom, + eventSink = state.eventSink, + onDeclineAndBlockClick = onDeclineInviteAndBlockUser, + ) + } + + leaveRoomView() + + HomeScaffold( + state = homeState, + onSetUpRecoveryClick = onSetUpRecoveryClick, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) }, + onOpenSettings = { if (firstThrottler.canHandle()) onSettingsClick() }, + onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() }, + onMenuActionClick = onMenuActionClick, + ) + // This overlaid view will only be visible when state.displaySearchResults is true + RoomListSearchView( + state = state.searchState, + eventSink = state.eventSink, + hideInvitesAvatars = state.hideInvitesAvatars, + onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) }, + modifier = Modifier + .fillMaxSize() + .background(ElementTheme.colors.bgCanvasDefault) + ) + acceptDeclineInviteView() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun HomeScaffold( + state: HomeState, + onSetUpRecoveryClick: () -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, + onRoomClick: (RoomId) -> Unit, + onOpenSettings: () -> Unit, + onStartChatClick: () -> Unit, + onMenuActionClick: (RoomListMenuAction) -> Unit, + modifier: Modifier = Modifier, +) { + fun onRoomClick(room: RoomListRoomSummary) { + onRoomClick(room.roomId) + } + + val appBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(appBarState) + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) + val roomListState: RoomListState = state.roomListState + + BackHandler( + enabled = state.currentHomeNavigationBarItem != HomeNavigationBarItem.Chats, + ) { + state.eventSink(HomeEvents.SelectHomeNavigationBarItem(HomeNavigationBarItem.Chats)) + } + + val hazeState = rememberHazeState() + val roomsLazyListState = rememberLazyListState() + val spacesLazyListState = rememberLazyListState() + + Scaffold( + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + HomeTopBar( + title = stringResource(state.currentHomeNavigationBarItem.labelRes), + currentUserAndNeighbors = state.currentUserAndNeighbors, + showAvatarIndicator = state.showAvatarIndicator, + areSearchResultsDisplayed = roomListState.searchState.isSearchActive, + onToggleSearch = { roomListState.eventSink(RoomListEvents.ToggleSearchResults) }, + onMenuActionClick = onMenuActionClick, + onOpenSettings = onOpenSettings, + onAccountSwitch = { + state.eventSink(HomeEvents.SwitchToAccount(it)) + }, + scrollBehavior = scrollBehavior, + displayMenuItems = state.displayActions, + displayFilters = state.displayRoomListFilters, + filtersState = roomListState.filtersState, + canReportBug = state.canReportBug, + modifier = if (state.isSpaceFeatureEnabled) { + Modifier.hazeEffect( + state = hazeState, + style = HazeMaterials.thick(), + ) + } else { + Modifier.background(ElementTheme.colors.bgCanvasDefault) + } + ) + }, + bottomBar = { + if (state.showNavigationBar) { + val coroutineScope = rememberCoroutineScope() + HomeBottomBar( + currentHomeNavigationBarItem = state.currentHomeNavigationBarItem, + onItemClick = { item -> + // scroll to top if selecting the same item + if (item == state.currentHomeNavigationBarItem) { + val lazyListStateTarget = when (item) { + HomeNavigationBarItem.Chats -> roomsLazyListState + HomeNavigationBarItem.Spaces -> spacesLazyListState + } + coroutineScope.launch { + if (lazyListStateTarget.firstVisibleItemIndex > 10) { + lazyListStateTarget.scrollToItem(10) + } + // Also reset the scrollBehavior height offset as it's not triggered by programmatic scrolls + scrollBehavior.state.heightOffset = 0f + lazyListStateTarget.animateScrollToItem(0) + } + } else { + state.eventSink(HomeEvents.SelectHomeNavigationBarItem(item)) + } + }, + modifier = Modifier.hazeEffect( + state = hazeState, + style = HazeMaterials.thick(), + ) + ) + } + }, + content = { padding -> + when (state.currentHomeNavigationBarItem) { + HomeNavigationBarItem.Chats -> { + RoomListContentView( + contentState = roomListState.contentState, + filtersState = roomListState.filtersState, + lazyListState = roomsLazyListState, + hideInvitesAvatars = roomListState.hideInvitesAvatars, + eventSink = roomListState.eventSink, + onSetUpRecoveryClick = onSetUpRecoveryClick, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + onRoomClick = ::onRoomClick, + onCreateRoomClick = onStartChatClick, + contentPadding = PaddingValues( + // FAB height is 56dp, bottom padding is 16dp, we add 8dp as extra margin -> 56+16+8 = 80, + // and include provided bottom padding + // Disable contentPadding due to navigation issue using the keyboard + // See https://issuetracker.google.com/issues/436432313 + bottom = 80.dp, + // bottom = 80.dp + padding.calculateBottomPadding(), + // top = padding.calculateTopPadding() + ), + modifier = Modifier + .padding( + PaddingValues( + start = padding.calculateStartPadding(LocalLayoutDirection.current), + end = padding.calculateEndPadding(LocalLayoutDirection.current), + // Remove these two lines once https://issuetracker.google.com/issues/436432313 has been fixed + bottom = padding.calculateBottomPadding(), + top = padding.calculateTopPadding() + ) + ) + .consumeWindowInsets(padding) + .hazeSource(state = hazeState) + ) + } + HomeNavigationBarItem.Spaces -> { + HomeSpacesView( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding) + .hazeSource(state = hazeState), + state = state.homeSpacesState, + lazyListState = spacesLazyListState, + onSpaceClick = { spaceId -> + onRoomClick(spaceId) + } + ) + } + } + }, + floatingActionButton = { + if (state.displayActions) { + FloatingActionButton( + onClick = onStartChatClick, + ) { + Icon( + imageVector = CompoundIcons.Plus(), + contentDescription = stringResource(id = R.string.screen_roomlist_a11y_create_message), + ) + } + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) +} + +@Composable +private fun HomeBottomBar( + currentHomeNavigationBarItem: HomeNavigationBarItem, + onItemClick: (HomeNavigationBarItem) -> Unit, + modifier: Modifier = Modifier, +) { + NavigationBar( + containerColor = Color.Transparent, + modifier = modifier + ) { + HomeNavigationBarItem.entries.forEach { item -> + val isSelected = currentHomeNavigationBarItem == item + NavigationBarItem( + selected = isSelected, + onClick = { + onItemClick(item) + }, + icon = { + NavigationBarIcon( + imageVector = item.icon(isSelected), + ) + }, + label = { + NavigationBarText( + text = stringResource(item.labelRes), + ) + } + ) + } + } +} + +internal fun RoomListRoomSummary.contentType() = displayType.ordinal + +@PreviewsDayNight +@Composable +internal fun HomeViewPreview(@PreviewParameter(HomeStateProvider::class) state: HomeState) = ElementPreview { + HomeView( + homeState = state, + onRoomClick = {}, + onSettingsClick = {}, + onSetUpRecoveryClick = {}, + onConfirmRecoveryKeyClick = {}, + onStartChatClick = {}, + onRoomSettingsClick = {}, + onReportRoomClick = {}, + onMenuActionClick = {}, + onDeclineInviteAndBlockUser = {}, + acceptDeclineInviteView = {}, + leaveRoomView = {} + ) +} + +@Preview +@Composable +internal fun HomeViewA11yPreview() = ElementPreview { + HomeView( + homeState = aHomeState(), + onRoomClick = {}, + onSettingsClick = {}, + onSetUpRecoveryClick = {}, + onConfirmRecoveryKeyClick = {}, + onStartChatClick = {}, + onRoomSettingsClick = {}, + onReportRoomClick = {}, + onMenuActionClick = {}, + onDeclineInviteAndBlockUser = {}, + acceptDeclineInviteView = {}, + leaveRoomView = {} + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BannerPadding.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BannerPadding.kt new file mode 100644 index 0000000..cc3227f --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BannerPadding.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Common padding for RoomList banners. + */ +internal fun Modifier.roomListBannerPadding() = padding(horizontal = 16.dp, vertical = 8.dp) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt new file mode 100644 index 0000000..c6a9480 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.features.home.impl.R +import io.element.android.libraries.designsystem.components.Announcement +import io.element.android.libraries.designsystem.components.AnnouncementType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.push.api.battery.BatteryOptimizationEvents +import io.element.android.libraries.push.api.battery.BatteryOptimizationState +import io.element.android.libraries.push.api.battery.aBatteryOptimizationState + +@Composable +internal fun BatteryOptimizationBanner( + state: BatteryOptimizationState, + modifier: Modifier = Modifier, +) { + Announcement( + modifier = modifier.roomListBannerPadding(), + title = stringResource(R.string.banner_battery_optimization_title_android), + description = stringResource(R.string.banner_battery_optimization_content_android), + type = AnnouncementType.Actionable( + actionText = stringResource(R.string.banner_battery_optimization_submit_android), + onActionClick = { state.eventSink(BatteryOptimizationEvents.RequestDisableOptimizations) }, + onDismissClick = { state.eventSink(BatteryOptimizationEvents.Dismiss) }, + ), + ) +} + +@PreviewsDayNight +@Composable +internal fun BatteryOptimizationBannerPreview() = ElementPreview { + BatteryOptimizationBanner( + state = aBatteryOptimizationState(), + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/ConfirmRecoveryKeyBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/ConfirmRecoveryKeyBanner.kt new file mode 100644 index 0000000..c0f8353 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/ConfirmRecoveryKeyBanner.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.features.home.impl.R +import io.element.android.libraries.designsystem.components.Announcement +import io.element.android.libraries.designsystem.components.AnnouncementType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun ConfirmRecoveryKeyBanner( + onContinueClick: () -> Unit, + onDismissClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Announcement( + modifier = modifier.roomListBannerPadding(), + title = stringResource(R.string.confirm_recovery_key_banner_title), + description = stringResource(R.string.confirm_recovery_key_banner_message), + type = AnnouncementType.Actionable( + actionText = stringResource(CommonStrings.action_continue), + onActionClick = onContinueClick, + onDismissClick = onDismissClick, + ), + ) +} + +@PreviewsDayNight +@Composable +internal fun ConfirmRecoveryKeyBannerPreview() = ElementPreview { + ConfirmRecoveryKeyBanner( + onContinueClick = {}, + onDismissClick = {}, + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt new file mode 100644 index 0000000..92a5d8d --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.features.home.impl.R +import io.element.android.libraries.designsystem.components.Announcement +import io.element.android.libraries.designsystem.components.AnnouncementType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents +import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState +import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun FullScreenIntentPermissionBanner( + state: FullScreenIntentPermissionsState, + modifier: Modifier = Modifier +) { + Announcement( + title = stringResource(R.string.full_screen_intent_banner_title), + description = stringResource(R.string.full_screen_intent_banner_message), + type = AnnouncementType.Actionable( + actionText = stringResource(CommonStrings.action_continue), + onDismissClick = { state.eventSink(FullScreenIntentPermissionsEvents.Dismiss) }, + onActionClick = { state.eventSink(FullScreenIntentPermissionsEvents.OpenSettings) }, + ), + modifier = modifier.roomListBannerPadding(), + ) +} + +@PreviewsDayNight +@Composable +internal fun FullScreenIntentPermissionBannerPreview() { + ElementPreview { + FullScreenIntentPermissionBanner(aFullScreenIntentPermissionsState()) + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt new file mode 100644 index 0000000..093b91f --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt @@ -0,0 +1,332 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.pager.VerticalPager +import androidx.compose.foundation.pager.rememberPagerState +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.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.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +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.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import io.element.android.appconfig.RoomListConfig +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.home.impl.R +import io.element.android.features.home.impl.filters.RoomListFiltersState +import io.element.android.features.home.impl.filters.RoomListFiltersView +import io.element.android.features.home.impl.filters.aRoomListFiltersState +import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom +import io.element.android.libraries.designsystem.components.TopAppBarScrollBehaviorLayout +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.modifiers.backgroundVerticalGradient +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +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.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.ui.components.aMatrixUserList +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.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeTopBar( + title: String, + currentUserAndNeighbors: ImmutableList, + showAvatarIndicator: Boolean, + areSearchResultsDisplayed: Boolean, + onToggleSearch: () -> Unit, + onMenuActionClick: (RoomListMenuAction) -> Unit, + onOpenSettings: () -> Unit, + onAccountSwitch: (SessionId) -> Unit, + scrollBehavior: TopAppBarScrollBehavior, + displayMenuItems: Boolean, + canReportBug: Boolean, + displayFilters: Boolean, + filtersState: RoomListFiltersState, + modifier: Modifier = Modifier, +) { + Column(modifier) { + TopAppBar( + modifier = Modifier + .backgroundVerticalGradient( + isVisible = !areSearchResultsDisplayed, + ) + .statusBarsPadding(), + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent, + ), + title = { + Text( + modifier = Modifier.semantics { + heading() + }, + style = ElementTheme.typography.aliasScreenTitle, + text = title, + ) + }, + navigationIcon = { + NavigationIcon( + currentUserAndNeighbors = currentUserAndNeighbors, + showAvatarIndicator = showAvatarIndicator, + onAccountSwitch = onAccountSwitch, + onClick = onOpenSettings, + ) + }, + actions = { + if (displayMenuItems) { + IconButton( + onClick = onToggleSearch, + ) { + Icon( + imageVector = CompoundIcons.Search(), + contentDescription = stringResource(CommonStrings.action_search), + ) + } + if (RoomListConfig.HAS_DROP_DOWN_MENU) { + var showMenu by remember { mutableStateOf(false) } + IconButton( + onClick = { showMenu = !showMenu } + ) { + Icon( + imageVector = CompoundIcons.OverflowVertical(), + contentDescription = null, + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + if (RoomListConfig.SHOW_INVITE_MENU_ITEM) { + DropdownMenuItem( + onClick = { + showMenu = false + onMenuActionClick(RoomListMenuAction.InviteFriends) + }, + text = { Text(stringResource(id = CommonStrings.action_invite)) }, + leadingIcon = { + Icon( + imageVector = CompoundIcons.ShareAndroid(), + tint = ElementTheme.colors.iconSecondary, + contentDescription = null, + ) + } + ) + } + if (RoomListConfig.SHOW_REPORT_PROBLEM_MENU_ITEM && canReportBug) { + DropdownMenuItem( + onClick = { + showMenu = false + onMenuActionClick(RoomListMenuAction.ReportBug) + }, + text = { Text(stringResource(id = CommonStrings.common_report_a_problem)) }, + leadingIcon = { + Icon( + imageVector = CompoundIcons.ChatProblem(), + tint = ElementTheme.colors.iconSecondary, + contentDescription = null, + ) + } + ) + } + } + } + } + }, + // We want a 16dp left padding for the navigationIcon : + // 4dp from default TopAppBarHorizontalPadding + // 8dp from AccountIcon default padding (because of IconButton) + // 4dp extra padding using left insets + windowInsets = WindowInsets(left = 4.dp), + ) + if (displayFilters) { + TopAppBarScrollBehaviorLayout(scrollBehavior = scrollBehavior) { + RoomListFiltersView( + state = filtersState, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + } + } +} + +@Composable +private fun NavigationIcon( + currentUserAndNeighbors: ImmutableList, + showAvatarIndicator: Boolean, + onAccountSwitch: (SessionId) -> Unit, + onClick: () -> Unit, +) { + if (currentUserAndNeighbors.size == 1) { + AccountIcon( + matrixUser = currentUserAndNeighbors.single(), + isCurrentAccount = true, + showAvatarIndicator = showAvatarIndicator, + onClick = onClick, + ) + } else { + // Render a vertical pager + val pagerState = rememberPagerState(initialPage = 1) { currentUserAndNeighbors.size } + // Listen to page changes and switch account if needed + val latestOnAccountSwitch by rememberUpdatedState(onAccountSwitch) + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage }.collect { page -> + latestOnAccountSwitch(SessionId(currentUserAndNeighbors[page].userId.value)) + } + } + VerticalPager( + state = pagerState, + modifier = Modifier.height(48.dp), + ) { page -> + AccountIcon( + matrixUser = currentUserAndNeighbors[page], + isCurrentAccount = page == 1, + showAvatarIndicator = page == 1 && showAvatarIndicator, + onClick = if (page == 1) { + onClick + } else { + {} + }, + ) + } + } +} + +@Composable +private fun AccountIcon( + matrixUser: MatrixUser, + isCurrentAccount: Boolean, + showAvatarIndicator: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier + IconButton( + modifier = modifier.then(testTag), + onClick = onClick, + ) { + Box { + val avatarData by remember(matrixUser) { + derivedStateOf { + matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar) + } + } + Avatar( + avatarData = avatarData, + avatarType = AvatarType.User, + contentDescription = if (isCurrentAccount) stringResource(CommonStrings.common_settings) else null, + ) + if (showAvatarIndicator) { + RedIndicatorAtom( + modifier = Modifier.align(Alignment.TopEnd) + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@PreviewsDayNight +@Composable +internal fun HomeTopBarPreview() = ElementPreview { + HomeTopBar( + title = stringResource(R.string.screen_roomlist_main_space_title), + currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")), + showAvatarIndicator = false, + areSearchResultsDisplayed = false, + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()), + onOpenSettings = {}, + onAccountSwitch = {}, + onToggleSearch = {}, + displayMenuItems = true, + canReportBug = true, + displayFilters = true, + filtersState = aRoomListFiltersState(), + onMenuActionClick = {}, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@PreviewsDayNight +@Composable +internal fun HomeTopBarWithIndicatorPreview() = ElementPreview { + HomeTopBar( + title = stringResource(R.string.screen_roomlist_main_space_title), + currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")), + showAvatarIndicator = true, + areSearchResultsDisplayed = false, + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()), + onOpenSettings = {}, + onAccountSwitch = {}, + onToggleSearch = {}, + displayMenuItems = true, + canReportBug = true, + displayFilters = true, + filtersState = aRoomListFiltersState(), + onMenuActionClick = {}, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@PreviewsDayNight +@Composable +internal fun HomeTopBarMultiAccountPreview() = ElementPreview { + HomeTopBar( + title = stringResource(R.string.screen_roomlist_main_space_title), + currentUserAndNeighbors = aMatrixUserList().take(3).toImmutableList(), + showAvatarIndicator = false, + areSearchResultsDisplayed = false, + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()), + onOpenSettings = {}, + onAccountSwitch = {}, + onToggleSearch = {}, + displayMenuItems = true, + canReportBug = true, + displayFilters = true, + filtersState = aRoomListFiltersState(), + onMenuActionClick = {}, + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/NewNotificationSoundBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/NewNotificationSoundBanner.kt new file mode 100644 index 0000000..c1c5ac0 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/NewNotificationSoundBanner.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.features.home.impl.R +import io.element.android.libraries.designsystem.components.Announcement +import io.element.android.libraries.designsystem.components.AnnouncementType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun NewNotificationSoundBanner( + onDismissClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Announcement( + modifier = modifier.roomListBannerPadding(), + title = stringResource(R.string.banner_new_sound_title), + description = stringResource(R.string.banner_new_sound_message), + type = AnnouncementType.Actionable( + actionText = stringResource(CommonStrings.action_ok), + onActionClick = onDismissClick, + onDismissClick = onDismissClick, + ), + ) +} + +@PreviewsDayNight +@Composable +internal fun NewNotificationSoundBannerPreview() = ElementPreview { + NewNotificationSoundBanner( + onDismissClick = {}, + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt new file mode 100644 index 0000000..364fe59 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt @@ -0,0 +1,355 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.components + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +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.remember +import androidx.compose.runtime.rememberUpdatedState +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.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.home.impl.R +import io.element.android.features.home.impl.contentType +import io.element.android.features.home.impl.filters.RoomListFilter +import io.element.android.features.home.impl.filters.RoomListFiltersEmptyStateResources +import io.element.android.features.home.impl.filters.RoomListFiltersState +import io.element.android.features.home.impl.filters.aRoomListFiltersState +import io.element.android.features.home.impl.filters.selection.FilterSelectionState +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.features.home.impl.model.RoomSummaryDisplayType +import io.element.android.features.home.impl.roomlist.RoomListContentState +import io.element.android.features.home.impl.roomlist.RoomListContentStateProvider +import io.element.android.features.home.impl.roomlist.RoomListEvents +import io.element.android.features.home.impl.roomlist.SecurityBannerState +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun RoomListContentView( + contentState: RoomListContentState, + filtersState: RoomListFiltersState, + lazyListState: LazyListState, + hideInvitesAvatars: Boolean, + eventSink: (RoomListEvents) -> Unit, + onSetUpRecoveryClick: () -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, + onRoomClick: (RoomListRoomSummary) -> Unit, + onCreateRoomClick: () -> Unit, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + when (contentState) { + is RoomListContentState.Skeleton -> { + SkeletonView( + modifier = modifier, + count = contentState.count, + contentPadding = contentPadding, + ) + } + is RoomListContentState.Empty -> { + EmptyView( + modifier = modifier.padding(contentPadding), + state = contentState, + eventSink = eventSink, + onSetUpRecoveryClick = onSetUpRecoveryClick, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + onCreateRoomClick = onCreateRoomClick, + ) + } + is RoomListContentState.Rooms -> { + RoomsView( + modifier = modifier, + state = contentState, + hideInvitesAvatars = hideInvitesAvatars, + filtersState = filtersState, + eventSink = eventSink, + onSetUpRecoveryClick = onSetUpRecoveryClick, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + onRoomClick = onRoomClick, + lazyListState = lazyListState, + contentPadding = contentPadding, + ) + } + } +} + +@Composable +private fun SkeletonView( + count: Int, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier, + contentPadding = contentPadding, + ) { + repeat(count) { index -> + item { + RoomSummaryPlaceholderRow() + if (index != count - 1) { + HorizontalDivider() + } + } + } + } +} + +@Composable +private fun EmptyView( + state: RoomListContentState.Empty, + eventSink: (RoomListEvents) -> Unit, + onSetUpRecoveryClick: () -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, + onCreateRoomClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier.fillMaxSize()) { + EmptyScaffold( + title = R.string.screen_roomlist_empty_title, + subtitle = R.string.screen_roomlist_empty_message, + action = { + Button( + text = stringResource(CommonStrings.action_start_chat), + leadingIcon = IconSource.Vector(CompoundIcons.Compose()), + onClick = onCreateRoomClick, + ) + }, + modifier = Modifier.align(Alignment.Center), + ) + Box { + when (state.securityBannerState) { + SecurityBannerState.SetUpRecovery -> { + SetUpRecoveryKeyBanner( + onContinueClick = onSetUpRecoveryClick, + onDismissClick = { eventSink(RoomListEvents.DismissBanner) }, + ) + } + SecurityBannerState.RecoveryKeyConfirmation -> { + ConfirmRecoveryKeyBanner( + onContinueClick = onConfirmRecoveryKeyClick, + onDismissClick = { eventSink(RoomListEvents.DismissBanner) }, + ) + } + SecurityBannerState.None -> Unit + } + } + } +} + +@Composable +private fun RoomsView( + state: RoomListContentState.Rooms, + hideInvitesAvatars: Boolean, + filtersState: RoomListFiltersState, + eventSink: (RoomListEvents) -> Unit, + onSetUpRecoveryClick: () -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, + onRoomClick: (RoomListRoomSummary) -> Unit, + contentPadding: PaddingValues, + lazyListState: LazyListState, + modifier: Modifier = Modifier, +) { + if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) { + EmptyViewForFilterStates( + selectedFilters = filtersState.selectedFilters(), + modifier = modifier.fillMaxSize() + ) + } else { + RoomsViewList( + state = state, + hideInvitesAvatars = hideInvitesAvatars, + eventSink = eventSink, + onSetUpRecoveryClick = onSetUpRecoveryClick, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + onRoomClick = onRoomClick, + contentPadding = contentPadding, + lazyListState = lazyListState, + modifier = modifier.fillMaxSize(), + ) + } +} + +@Composable +private fun RoomsViewList( + state: RoomListContentState.Rooms, + hideInvitesAvatars: Boolean, + eventSink: (RoomListEvents) -> Unit, + onSetUpRecoveryClick: () -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, + onRoomClick: (RoomListRoomSummary) -> Unit, + contentPadding: PaddingValues, + lazyListState: LazyListState, + modifier: Modifier = Modifier, +) { + 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 updatedEventSink by rememberUpdatedState(newValue = eventSink) + LaunchedEffect(visibleRange) { + updatedEventSink(RoomListEvents.UpdateVisibleRange(visibleRange)) + } + LazyColumn( + state = lazyListState, + modifier = modifier, + contentPadding = contentPadding, + ) { + when (state.securityBannerState) { + SecurityBannerState.SetUpRecovery -> { + item { + SetUpRecoveryKeyBanner( + onContinueClick = onSetUpRecoveryClick, + onDismissClick = { updatedEventSink(RoomListEvents.DismissBanner) }, + ) + } + } + SecurityBannerState.RecoveryKeyConfirmation -> { + item { + ConfirmRecoveryKeyBanner( + onContinueClick = onConfirmRecoveryKeyClick, + onDismissClick = { updatedEventSink(RoomListEvents.DismissBanner) }, + ) + } + } + SecurityBannerState.None -> if (state.fullScreenIntentPermissionsState.shouldDisplayBanner) { + item { + FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState) + } + } else if (state.batteryOptimizationState.shouldDisplayBanner) { + item { + BatteryOptimizationBanner(state = state.batteryOptimizationState) + } + } else if (state.showNewNotificationSoundBanner) { + item { + NewNotificationSoundBanner( + onDismissClick = { updatedEventSink(RoomListEvents.DismissNewNotificationSoundBanner) }, + ) + } + } + } + + // Note: do not use a key for the LazyColumn, or the scroll will not behave as expected if a room + // is moved to the top of the list. + itemsIndexed( + items = state.summaries, + contentType = { _, room -> room.contentType() }, + ) { index, room -> + RoomSummaryRow( + room = room, + hideInviteAvatars = hideInvitesAvatars, + isInviteSeen = room.displayType == RoomSummaryDisplayType.INVITE && + state.seenRoomInvites.contains(room.roomId), + onClick = onRoomClick, + eventSink = eventSink, + ) + if (index != state.summaries.lastIndex) { + HorizontalDivider() + } + } + } +} + +@Composable +private fun EmptyViewForFilterStates( + selectedFilters: ImmutableList, + modifier: Modifier = Modifier, +) { + val emptyStateResources = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters) ?: return + EmptyScaffold( + title = emptyStateResources.title, + subtitle = emptyStateResources.subtitle, + modifier = modifier, + ) +} + +@Composable +private fun EmptyScaffold( + @StringRes title: Int, + @StringRes subtitle: Int, + modifier: Modifier = Modifier, + action: @Composable (ColumnScope.() -> Unit)? = null, +) { + Column( + modifier = modifier.padding(horizontal = 60.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(title), + style = ElementTheme.typography.fontHeadingMdBold, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(subtitle), + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(32.dp)) + action?.invoke(this) + } +} + +@PreviewsDayNight +@Composable +internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStateProvider::class) state: RoomListContentState) = ElementPreview { + RoomListContentView( + contentState = state, + filtersState = aRoomListFiltersState( + filterSelectionStates = RoomListFilter.entries.map { + FilterSelectionState( + filter = it, + isSelected = true + ) + } + ), + hideInvitesAvatars = false, + eventSink = {}, + onSetUpRecoveryClick = {}, + onConfirmRecoveryKeyClick = {}, + onRoomClick = {}, + onCreateRoomClick = {}, + lazyListState = rememberLazyListState(), + contentPadding = PaddingValues(0.dp), + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListMenuAction.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListMenuAction.kt new file mode 100644 index 0000000..2cfc589 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListMenuAction.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.components + +enum class RoomListMenuAction { + InviteFriends, + ReportBug +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryPlaceholderRow.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryPlaceholderRow.kt new file mode 100644 index 0000000..466abb7 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryPlaceholderRow.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.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.unit.dp +import io.element.android.compound.theme.ElementTheme +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.placeholderBackground + +/** + * 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) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun RoomSummaryPlaceholderRowPreview() = ElementPreview { + RoomSummaryPlaceholderRow() +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt new file mode 100644 index 0000000..da0f47b --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt @@ -0,0 +1,445 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.components + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ripple +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.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.home.impl.R +import io.element.android.features.home.impl.model.LatestEvent +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.features.home.impl.model.RoomListRoomSummaryProvider +import io.element.android.features.home.impl.model.RoomSummaryDisplayType +import io.element.android.features.home.impl.roomlist.RoomListEvents +import io.element.android.libraries.core.extensions.orEmpty +import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom +import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.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.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.ui.components.InviteSenderView +import io.element.android.libraries.matrix.ui.model.InviteSender +import io.element.android.libraries.ui.strings.CommonStrings +import timber.log.Timber + +internal val minHeight = 84.dp + +@Composable +internal fun RoomSummaryRow( + room: RoomListRoomSummary, + hideInviteAvatars: Boolean, + isInviteSeen: Boolean, + onClick: (RoomListRoomSummary) -> Unit, + eventSink: (RoomListEvents) -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + when (room.displayType) { + RoomSummaryDisplayType.PLACEHOLDER -> { + RoomSummaryPlaceholderRow() + } + RoomSummaryDisplayType.INVITE -> { + RoomSummaryScaffoldRow( + room = room, + hideAvatarImage = hideInviteAvatars, + onClick = onClick, + onLongClick = { + Timber.d("Long click on invite room") + }, + ) { + InviteNameAndIndicatorRow(name = room.name, isInviteSeen = isInviteSeen) + InviteSubtitle(isDm = room.isDm, inviteSender = room.inviteSender) + if (!room.isDm && room.inviteSender != null) { + Spacer(modifier = Modifier.height(4.dp)) + InviteSenderView( + modifier = Modifier.fillMaxWidth(), + inviteSender = room.inviteSender, + hideAvatarImage = hideInviteAvatars + ) + } + Spacer(modifier = Modifier.height(12.dp)) + InviteButtonsRowMolecule( + onAcceptClick = { + eventSink(RoomListEvents.AcceptInvite(room)) + }, + onDeclineClick = { + eventSink(RoomListEvents.ShowDeclineInviteMenu(room)) + } + ) + } + } + RoomSummaryDisplayType.ROOM -> { + RoomSummaryScaffoldRow( + room = room, + onClick = onClick, + onLongClick = { + eventSink(RoomListEvents.ShowContextMenu(room)) + }, + ) { + NameAndTimestampRow( + name = room.name, + latestEvent = room.latestEvent, + timestamp = room.timestamp, + isHighlighted = room.isHighlighted + ) + MessagePreviewAndIndicatorRow(room = room) + } + } + RoomSummaryDisplayType.KNOCKED -> { + RoomSummaryScaffoldRow( + room = room, + onClick = onClick, + onLongClick = { + Timber.d("Long click on knocked room") + }, + ) { + NameAndTimestampRow( + name = room.name, + latestEvent = room.latestEvent, + timestamp = null, + isHighlighted = room.isHighlighted + ) + if (room.canonicalAlias != null) { + Text( + text = room.canonicalAlias.value, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + Spacer(modifier = Modifier.height(4.dp)) + } + Text( + text = stringResource(id = R.string.screen_roomlist_knock_event_sent_description), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } + } + } +} + +@Composable +private fun RoomSummaryScaffoldRow( + room: RoomListRoomSummary, + onClick: (RoomListRoomSummary) -> Unit, + onLongClick: (RoomListRoomSummary) -> Unit, + modifier: Modifier = Modifier, + hideAvatarImage: Boolean = false, + content: @Composable ColumnScope.() -> Unit +) { + val clickModifier = Modifier + .combinedClickable( + onClick = { onClick(room) }, + onLongClick = { onLongClick(room) }, + onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), + indication = ripple(), + interactionSource = remember { MutableInteractionSource() } + ) + .onKeyboardContextMenuAction { onLongClick(room) } + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = minHeight) + .then(clickModifier) + .padding(horizontal = 16.dp, vertical = 11.dp) + .height(IntrinsicSize.Min), + ) { + Avatar( + avatarData = room.avatarData, + avatarType = if (room.isSpace) { + AvatarType.Space(isTombstoned = room.isTombstoned) + } else { + AvatarType.Room( + heroes = room.heroes, + isTombstoned = room.isTombstoned, + ) + }, + hideImage = hideAvatarImage, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + content = content, + ) + } +} + +@Composable +private fun NameAndTimestampRow( + name: String?, + latestEvent: LatestEvent, + timestamp: String?, + isHighlighted: Boolean, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = spacedBy(16.dp) + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + ) { + // Name + Text( + style = ElementTheme.typography.fontBodyLgMedium, + text = name ?: stringResource(id = CommonStrings.common_no_room_name), + fontStyle = FontStyle.Italic.takeIf { name == null }, + color = ElementTheme.colors.roomListRoomName, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + // Picto + when (latestEvent) { + is LatestEvent.Sending -> { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + modifier = Modifier.size(16.dp), + imageVector = CompoundIcons.Time(), + contentDescription = null, + tint = ElementTheme.colors.iconTertiary, + ) + } + is LatestEvent.Error -> { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + modifier = Modifier.size(16.dp), + imageVector = CompoundIcons.ErrorSolid(), + contentDescription = null, + tint = ElementTheme.colors.iconCriticalPrimary, + ) + } + else -> Unit + } + } + // Timestamp + Text( + text = timestamp ?: "", + style = ElementTheme.typography.fontBodySmMedium, + color = if (isHighlighted) { + ElementTheme.colors.unreadIndicator + } else { + ElementTheme.colors.roomListRoomMessageDate + }, + ) + } +} + +@Composable +private fun InviteSubtitle( + isDm: Boolean, + inviteSender: InviteSender?, + modifier: Modifier = Modifier +) { + val subtitle = if (isDm) { + inviteSender?.userId?.value + } else { + null + } + if (subtitle != null) { + Text( + text = subtitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.roomListRoomMessage, + modifier = modifier, + ) + } +} + +@Composable +private fun MessagePreviewAndIndicatorRow( + room: RoomListRoomSummary, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = spacedBy(28.dp) + ) { + if (room.isTombstoned) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.screen_roomlist_tombstoned_room_description), + color = ElementTheme.colors.roomListRoomMessage, + style = ElementTheme.typography.fontBodyMdRegular, + minLines = 2, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + if (room.latestEvent is LatestEvent.Error) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(CommonStrings.common_message_failed_to_send), + color = ElementTheme.colors.textCriticalPrimary, + style = ElementTheme.typography.fontBodyMdRegular, + minLines = 2, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + val messagePreview = room.latestEvent.content() + val annotatedMessagePreview = messagePreview as? AnnotatedString ?: AnnotatedString(text = messagePreview.orEmpty().toString()) + Text( + modifier = Modifier.weight(1f), + text = annotatedMessagePreview, + color = ElementTheme.colors.roomListRoomMessage, + style = ElementTheme.typography.fontBodyMdRegular, + minLines = 2, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + + // Call and unread + Row( + modifier = Modifier + .height(16.dp) + // Used to force this line to be read aloud earlier than the latest event when using Talkback + .zIndex(-1f), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary + if (room.hasRoomCall) { + OnGoingCallIcon( + color = tint, + ) + } + if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) { + NotificationOffIndicatorAtom() + } else if (room.numberOfUnreadMentions > 0) { + MentionIndicatorAtom() + } + if (room.hasNewContent) { + val contentDescription = stringResource(CommonStrings.a11y_notifications_new_messages) + UnreadIndicatorAtom( + color = tint, + contentDescription = contentDescription, + ) + } + } + } +} + +@Composable +private fun InviteNameAndIndicatorRow( + name: String?, + isInviteSeen: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + style = ElementTheme.typography.fontBodyLgMedium, + text = name ?: stringResource(id = CommonStrings.common_no_room_name), + fontStyle = FontStyle.Italic.takeIf { name == null }, + color = ElementTheme.colors.roomListRoomName, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (!isInviteSeen) { + UnreadIndicatorAtom( + color = ElementTheme.colors.unreadIndicator + ) + } + } +} + +@Composable +private fun OnGoingCallIcon( + color: Color, +) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = CompoundIcons.VideoCallSolid(), + contentDescription = stringResource(CommonStrings.a11y_notifications_ongoing_call), + tint = color, + ) +} + +@Composable +private fun NotificationOffIndicatorAtom() { + Icon( + modifier = Modifier.size(16.dp), + contentDescription = stringResource(CommonStrings.a11y_notifications_muted), + imageVector = CompoundIcons.NotificationsOffSolid(), + tint = ElementTheme.colors.iconQuaternary, + ) +} + +@Composable +private fun MentionIndicatorAtom() { + Icon( + modifier = Modifier.size(16.dp), + contentDescription = stringResource(CommonStrings.a11y_notifications_new_mentions), + imageVector = CompoundIcons.Mention(), + tint = ElementTheme.colors.unreadIndicator, + ) +} + +@PreviewsDayNight +@Composable +internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryProvider::class) data: RoomListRoomSummary) = ElementPreview { + RoomSummaryRow( + room = data, + hideInviteAvatars = false, + // Set isInviteSeen to true for the preview when the room has name "Bob" + isInviteSeen = data.name == "Bob", + onClick = {}, + eventSink = {}, + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/SetUpRecoveryKeyBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/SetUpRecoveryKeyBanner.kt new file mode 100644 index 0000000..0d3bf87 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/SetUpRecoveryKeyBanner.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.features.home.impl.R +import io.element.android.libraries.designsystem.components.Announcement +import io.element.android.libraries.designsystem.components.AnnouncementType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +@Composable +internal fun SetUpRecoveryKeyBanner( + onContinueClick: () -> Unit, + onDismissClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Announcement( + modifier = modifier.roomListBannerPadding(), + title = stringResource(R.string.banner_set_up_recovery_title), + description = stringResource(R.string.banner_set_up_recovery_content), + type = AnnouncementType.Actionable( + actionText = stringResource(R.string.banner_set_up_recovery_submit), + onActionClick = onContinueClick, + onDismissClick = onDismissClick, + ), + ) +} + +@PreviewsDayNight +@Composable +internal fun SetUpRecoveryKeyBannerPreview() = ElementPreview { + SetUpRecoveryKeyBanner( + onContinueClick = {}, + onDismissClick = {}, + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt new file mode 100644 index 0000000..ef5d1ff --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.datasource + +import dev.zacsweers.metro.Inject +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.libraries.androidutils.diff.DiffCacheUpdater +import io.element.android.libraries.androidutils.diff.MutableListDiffCache +import io.element.android.libraries.androidutils.system.DateTimeObserver +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import timber.log.Timber +import kotlin.time.Duration.Companion.seconds + +@Inject +class RoomListDataSource( + private val roomListService: RoomListService, + private val roomListRoomSummaryFactory: RoomListRoomSummaryFactory, + private val coroutineDispatchers: CoroutineDispatchers, + private val notificationSettingsService: NotificationSettingsService, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, + private val dateTimeObserver: DateTimeObserver, +) { + init { + observeNotificationSettings() + observeDateTimeChanges() + } + + private val _allRooms = MutableSharedFlow>(replay = 1) + + private val lock = Mutex() + private val diffCache = MutableListDiffCache() + private val diffCacheUpdater = DiffCacheUpdater(diffCache = diffCache, detectMoves = true) { old, new -> + old?.roomId == new?.roomId + } + + val allRooms: Flow> = _allRooms + + val loadingState = roomListService.allRooms.loadingState + + fun launchIn(coroutineScope: CoroutineScope) { + roomListService + .allRooms + .filteredSummaries + .onEach { roomSummaries -> + replaceWith(roomSummaries) + } + .launchIn(coroutineScope) + } + + suspend fun subscribeToVisibleRooms(roomIds: List) { + roomListService.subscribeToVisibleRooms(roomIds) + } + + @OptIn(FlowPreview::class) + private fun observeNotificationSettings() { + notificationSettingsService.notificationSettingsChangeFlow + .debounce(0.5.seconds) + .onEach { + roomListService.allRooms.rebuildSummaries() + } + .launchIn(sessionCoroutineScope) + } + + private fun observeDateTimeChanges() { + dateTimeObserver.changes + .onEach { event -> + when (event) { + is DateTimeObserver.Event.TimeZoneChanged -> rebuildAllRoomSummaries() + is DateTimeObserver.Event.DateChanged -> rebuildAllRoomSummaries() + } + } + .launchIn(sessionCoroutineScope) + } + + private suspend fun replaceWith(roomSummaries: List) = withContext(coroutineDispatchers.computation) { + lock.withLock { + diffCacheUpdater.updateWith(roomSummaries) + buildAndEmitAllRooms(roomSummaries) + } + } + + private suspend fun buildAndEmitAllRooms(roomSummaries: List, useCache: Boolean = true) { + // Used to detect duplicates in the room list summaries - see comment below + data class CacheResult(val index: Int, val fromCache: Boolean) + val cachingResults = mutableMapOf>() + + val roomListRoomSummaries = diffCache.indices().mapNotNull { index -> + if (useCache) { + diffCache.get(index)?.let { cachedItem -> + // Add the cached item to the caching results + val pairs = cachingResults.getOrDefault(cachedItem.roomId, mutableListOf()) + pairs.add(CacheResult(index, fromCache = true)) + cachingResults[cachedItem.roomId] = pairs + cachedItem + } ?: run { + roomSummaries.getOrNull(index)?.roomId?.let { + // Add the non-cached item to the caching results + val pairs = cachingResults.getOrDefault(it, mutableListOf()) + pairs.add(CacheResult(index, fromCache = false)) + cachingResults[it] = pairs + } + buildAndCacheItem(roomSummaries, index) + } + } else { + roomSummaries.getOrNull(index)?.roomId?.let { + // Add the non-cached item to the caching results + val pairs = cachingResults.getOrDefault(it, mutableListOf()) + pairs.add(CacheResult(index, fromCache = false)) + cachingResults[it] = pairs + } + buildAndCacheItem(roomSummaries, index) + } + } + + // TODO remove once https://github.com/element-hq/element-x-android/issues/5031 has been confirmed as fixed + val duplicates = cachingResults.filter { (_, operations) -> operations.size > 1 } + if (duplicates.isNotEmpty()) { + Timber.e("Found duplicates in room summaries after an UI update: $duplicates. This could be a race condition/caching issue of some kind") + } + + _allRooms.emit(roomListRoomSummaries.toImmutableList()) + } + + private fun buildAndCacheItem(roomSummaries: List, index: Int): RoomListRoomSummary? { + val roomListSummary = roomSummaries.getOrNull(index)?.let { roomListRoomSummaryFactory.create(it) } + diffCache[index] = roomListSummary + return roomListSummary + } + + private suspend fun rebuildAllRoomSummaries() { + lock.withLock { + roomListService.allRooms.filteredSummaries.replayCache.firstOrNull()?.let { roomSummaries -> + buildAndEmitAllRooms(roomSummaries, useCache = false) + } + } + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt new file mode 100644 index 0000000..0200f49 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.datasource + +import dev.zacsweers.metro.Inject +import io.element.android.features.home.impl.model.LatestEvent +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.features.home.impl.model.RoomSummaryDisplayType +import io.element.android.libraries.core.extensions.orEmpty +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.roomlist.LatestEventValue +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.matrix.ui.model.toInviteSender +import kotlinx.collections.immutable.toImmutableList + +@Inject +class RoomListRoomSummaryFactory( + private val dateFormatter: DateFormatter, + private val roomLatestEventFormatter: RoomLatestEventFormatter, +) { + fun create(roomSummary: RoomSummary): RoomListRoomSummary { + val roomInfo = roomSummary.info + val avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomListItem) + return RoomListRoomSummary( + id = roomSummary.roomId.value, + roomId = roomSummary.roomId, + name = roomInfo.name, + numberOfUnreadMessages = roomInfo.numUnreadMessages, + numberOfUnreadMentions = roomInfo.numUnreadMentions, + numberOfUnreadNotifications = roomInfo.numUnreadNotifications, + isMarkedUnread = roomInfo.isMarkedUnread, + timestamp = dateFormatter.format( + timestamp = roomSummary.latestEventTimestamp, + mode = DateFormatterMode.TimeOrDate, + useRelative = true, + ), + latestEvent = computeLatestEvent(roomSummary.latestEvent, roomInfo.isDm), + avatarData = avatarData, + userDefinedNotificationMode = roomInfo.userDefinedNotificationMode, + hasRoomCall = roomInfo.hasRoomCall, + isDirect = roomInfo.isDirect, + isFavorite = roomInfo.isFavorite, + inviteSender = roomInfo.inviter?.toInviteSender(), + isDm = roomInfo.isDm, + canonicalAlias = roomInfo.canonicalAlias, + displayType = when (roomInfo.currentUserMembership) { + CurrentUserMembership.INVITED -> { + RoomSummaryDisplayType.INVITE + } + CurrentUserMembership.KNOCKED -> { + RoomSummaryDisplayType.KNOCKED + } + else -> { + RoomSummaryDisplayType.ROOM + } + }, + heroes = roomInfo.heroes.map { user -> + user.getAvatarData(size = AvatarSize.RoomListItem) + }.toImmutableList(), + isTombstoned = roomInfo.successorRoom != null, + isSpace = roomInfo.isSpace, + ) + } + + private fun computeLatestEvent(latestEvent: LatestEventValue, dm: Boolean): LatestEvent { + return when (latestEvent) { + is LatestEventValue.None -> { + LatestEvent.None + } + is LatestEventValue.Local -> { + if (latestEvent.isSending) { + val content = roomLatestEventFormatter.format(latestEvent, dm).orEmpty() + LatestEvent.Sending( + content = content, + ) + } else { + LatestEvent.Error + } + } + is LatestEventValue.Remote -> { + val content = roomLatestEventFormatter.format(latestEvent, dm).orEmpty() + LatestEvent.Synced( + content = content, + ) + } + } + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/HomeSpacesModule.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/HomeSpacesModule.kt new file mode 100644 index 0000000..5e22123 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/HomeSpacesModule.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.di + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.home.impl.spaces.HomeSpacesPresenter +import io.element.android.features.home.impl.spaces.HomeSpacesState +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.SessionScope + +@BindingContainer +@ContributesTo(SessionScope::class) +interface HomeSpacesModule { + @Binds + fun bindHomeSpacesPresenter(presenter: HomeSpacesPresenter): Presenter +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt new file mode 100644 index 0000000..ea80c1b --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/di/RoomListModule.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.di + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.home.impl.filters.RoomListFiltersPresenter +import io.element.android.features.home.impl.filters.RoomListFiltersState +import io.element.android.features.home.impl.roomlist.RoomListPresenter +import io.element.android.features.home.impl.roomlist.RoomListState +import io.element.android.features.home.impl.search.RoomListSearchPresenter +import io.element.android.features.home.impl.search.RoomListSearchState +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.SessionScope + +@ContributesTo(SessionScope::class) +@BindingContainer +interface RoomListModule { + @Binds + fun bindRoomListPresenter(presenter: RoomListPresenter): Presenter + + @Binds + fun bindSearchPresenter(presenter: RoomListSearchPresenter): Presenter + + @Binds + fun bindFiltersPresenter(presenter: RoomListFiltersPresenter): Presenter +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFilter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFilter.kt new file mode 100644 index 0000000..1f627ec --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFilter.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.filters + +import io.element.android.features.home.impl.R + +/** + * Enum class representing the different filters that can be applied to the room list. + * Order is important, it'll be used as initial order in the UI. + */ +enum class RoomListFilter(val stringResource: Int) { + Unread(R.string.screen_roomlist_filter_unreads), + People(R.string.screen_roomlist_filter_people), + Rooms(R.string.screen_roomlist_filter_rooms), + Favourites(R.string.screen_roomlist_filter_favourites), + Invites(R.string.screen_roomlist_filter_invites); + + val incompatibleFilters: Set + get() = when (this) { + Rooms -> setOf(People, Invites) + People -> setOf(Rooms, Invites) + Unread -> setOf(Invites) + Favourites -> setOf(Invites) + Invites -> setOf(Rooms, People, Unread, Favourites) + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEmptyStateResources.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEmptyStateResources.kt new file mode 100644 index 0000000..7381ac3 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEmptyStateResources.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.filters + +import androidx.annotation.StringRes +import io.element.android.features.home.impl.R + +/** + * Holds the resources for the empty state when filters are applied to the room list. + * @param title the title of the empty state + * @param subtitle the subtitle of the empty state + */ +data class RoomListFiltersEmptyStateResources( + @StringRes val title: Int, + @StringRes val subtitle: Int, +) { + companion object { + /** + * Create a [RoomListFiltersEmptyStateResources] from a list of selected filters. + */ + fun fromSelectedFilters(selectedFilters: List): RoomListFiltersEmptyStateResources? { + return when { + selectedFilters.isEmpty() -> null + selectedFilters.size == 1 -> { + when (selectedFilters.first()) { + RoomListFilter.Unread -> RoomListFiltersEmptyStateResources( + title = R.string.screen_roomlist_filter_unreads_empty_state_title, + subtitle = R.string.screen_roomlist_filter_mixed_empty_state_subtitle + ) + RoomListFilter.People -> RoomListFiltersEmptyStateResources( + title = R.string.screen_roomlist_filter_people_empty_state_title, + subtitle = R.string.screen_roomlist_filter_mixed_empty_state_subtitle + ) + RoomListFilter.Rooms -> RoomListFiltersEmptyStateResources( + title = R.string.screen_roomlist_filter_rooms_empty_state_title, + subtitle = R.string.screen_roomlist_filter_mixed_empty_state_subtitle + ) + RoomListFilter.Favourites -> RoomListFiltersEmptyStateResources( + title = R.string.screen_roomlist_filter_favourites_empty_state_title, + subtitle = R.string.screen_roomlist_filter_favourites_empty_state_subtitle + ) + RoomListFilter.Invites -> RoomListFiltersEmptyStateResources( + title = R.string.screen_roomlist_filter_invites_empty_state_title, + subtitle = R.string.screen_roomlist_filter_mixed_empty_state_subtitle + ) + } + } + else -> RoomListFiltersEmptyStateResources( + title = R.string.screen_roomlist_filter_mixed_empty_state_title, + subtitle = R.string.screen_roomlist_filter_mixed_empty_state_subtitle + ) + } + } + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEvents.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEvents.kt new file mode 100644 index 0000000..8b1906d --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.filters + +sealed interface RoomListFiltersEvents { + data class ToggleFilter(val filter: RoomListFilter) : RoomListFiltersEvents + data object ClearSelectedFilters : RoomListFiltersEvents +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenter.kt new file mode 100644 index 0000000..4b20c69 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenter.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.filters + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import dev.zacsweers.metro.Inject +import io.element.android.features.home.impl.filters.selection.FilterSelectionStrategy +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.map +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter + +@Inject +class RoomListFiltersPresenter( + private val roomListService: RoomListService, + private val filterSelectionStrategy: FilterSelectionStrategy, +) : Presenter { + private val initialFilters = filterSelectionStrategy.filterSelectionStates.value.toImmutableList() + + @Composable + override fun present(): RoomListFiltersState { + fun handleEvent(event: RoomListFiltersEvents) { + when (event) { + RoomListFiltersEvents.ClearSelectedFilters -> { + filterSelectionStrategy.clear() + } + is RoomListFiltersEvents.ToggleFilter -> { + filterSelectionStrategy.toggle(event.filter) + } + } + } + + val filters by produceState(initialValue = initialFilters) { + filterSelectionStrategy.filterSelectionStates + .map { filters -> + value = filters.toImmutableList() + filters.mapNotNull { filterState -> + if (!filterState.isSelected) { + return@mapNotNull null + } + when (filterState.filter) { + RoomListFilter.Rooms -> MatrixRoomListFilter.Category.Group + RoomListFilter.People -> MatrixRoomListFilter.Category.People + RoomListFilter.Unread -> MatrixRoomListFilter.Unread + RoomListFilter.Favourites -> MatrixRoomListFilter.Favorite + RoomListFilter.Invites -> MatrixRoomListFilter.Invite + } + } + } + .collect { filters -> + val result = MatrixRoomListFilter.All(filters) + roomListService.allRooms.updateFilter(result) + } + } + + return RoomListFiltersState( + filterSelectionStates = filters, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersState.kt new file mode 100644 index 0000000..104a99c --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersState.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.filters + +import io.element.android.features.home.impl.filters.selection.FilterSelectionState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +data class RoomListFiltersState( + val filterSelectionStates: ImmutableList, + val eventSink: (RoomListFiltersEvents) -> Unit, +) { + val hasAnyFilterSelected = filterSelectionStates.any { it.isSelected } + + fun selectedFilters(): ImmutableList { + return filterSelectionStates + .filter { it.isSelected } + .map { it.filter } + .toImmutableList() + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersStateProvider.kt new file mode 100644 index 0000000..7d73727 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersStateProvider.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.filters + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.home.impl.filters.selection.FilterSelectionState +import kotlinx.collections.immutable.toImmutableList + +class RoomListFiltersStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aRoomListFiltersState(), + aRoomListFiltersState( + filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) } + ), + ) +} + +fun aRoomListFiltersState( + filterSelectionStates: List = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = false) }, + eventSink: (RoomListFiltersEvents) -> Unit = {}, +) = RoomListFiltersState( + filterSelectionStates = filterSelectionStates.toImmutableList(), + eventSink = eventSink, +) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt new file mode 100644 index 0000000..588da6b --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.filters + +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.clickable +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.home.impl.R +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag + +/** + * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2191-606 + */ +@Composable +fun RoomListFiltersView( + state: RoomListFiltersState, + modifier: Modifier = Modifier +) { + fun onClearFiltersClick() { + state.eventSink(RoomListFiltersEvents.ClearSelectedFilters) + } + + fun onToggleFilter(filter: RoomListFilter) { + state.eventSink(RoomListFiltersEvents.ToggleFilter(filter)) + } + + var scrollToStart by remember { mutableIntStateOf(0) } + val lazyListState = rememberLazyListState() + LaunchedEffect(scrollToStart) { + // Scroll until the first item start to be displayed + // Since all items have different size, there is no way to compute the amount of + // pixel to scroll to go directly to the start of the row. + // But IRL it should only happen for one item. + while (lazyListState.firstVisibleItemIndex > 0) { + lazyListState.animateScrollBy( + value = -(lazyListState.firstVisibleItemScrollOffset + 1f), + animationSpec = spring( + stiffness = Spring.StiffnessMediumLow, + ) + ) + } + // Then scroll to the start of the list, a bit faster, to fully reveal the first + // item, which can be the close button to reset filter, or the first item + // if the user has scroll a bit before clicking on the close button. + lazyListState.animateScrollBy( + value = -lazyListState.firstVisibleItemScrollOffset.toFloat(), + animationSpec = spring( + stiffness = Spring.StiffnessMedium, + ) + ) + } + val previousFilters = remember { mutableStateOf(listOf()) } + LazyRow( + contentPadding = PaddingValues(start = 8.dp, end = 16.dp), + modifier = modifier.fillMaxWidth(), + state = lazyListState, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + item("clear_filters") { + if (state.hasAnyFilterSelected) { + RoomListClearFiltersButton( + modifier = Modifier + .padding(start = 8.dp) + .testTag(TestTags.homeScreenClearFilters), + onClick = { + previousFilters.value = state.selectedFilters() + onClearFiltersClick() + // When clearing filter, we want to ensure that the list + // of filters is scrolled to the start. + scrollToStart++ + } + ) + } + } + state.filterSelectionStates.forEachIndexed { i, filterWithSelection -> + item(filterWithSelection.filter) { + val zIndex = (if (previousFilters.value.contains(filterWithSelection.filter)) state.filterSelectionStates.size else 0) - i.toFloat() + RoomListFilterView( + modifier = Modifier + .animateItem() + .zIndex(zIndex), + roomListFilter = filterWithSelection.filter, + selected = filterWithSelection.isSelected, + onClick = { + previousFilters.value = state.selectedFilters() + onToggleFilter(it) + // When selecting a filter, we want to scroll to the start of the list + if (filterWithSelection.isSelected.not()) { + scrollToStart++ + } + }, + ) + } + } + } +} + +@Composable +private fun RoomListClearFiltersButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .clip(CircleShape) + .background(ElementTheme.colors.bgActionPrimaryRest) + .clickable(onClick = onClick) + .padding(4.dp) + ) { + Icon( + modifier = Modifier + .align(Alignment.Center) + .size(16.dp), + imageVector = CompoundIcons.Close(), + tint = ElementTheme.colors.iconOnSolidPrimary, + contentDescription = stringResource(id = R.string.screen_roomlist_clear_filters), + ) + } +} + +@Composable +private fun RoomListFilterView( + roomListFilter: RoomListFilter, + selected: Boolean, + onClick: (RoomListFilter) -> Unit, + modifier: Modifier = Modifier +) { + val background = animateColorAsState( + targetValue = if (selected) ElementTheme.colors.bgActionPrimaryRest else ElementTheme.colors.bgCanvasDefault, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "chip background colour", + ) + val textColour = animateColorAsState( + targetValue = if (selected) ElementTheme.colors.textOnSolidPrimary else ElementTheme.colors.textPrimary, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "chip text colour", + ) + val borderColour = animateColorAsState( + targetValue = if (selected) Color.Transparent else ElementTheme.colors.borderInteractiveSecondary, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "chip border colour", + ) + + FilterChip( + selected = selected, + onClick = { onClick(roomListFilter) }, + modifier = modifier.height(32.dp), + shape = CircleShape, + colors = FilterChipDefaults.filterChipColors( + containerColor = background.value, + selectedContainerColor = background.value, + labelColor = textColour.value, + selectedLabelColor = textColour.value, + ), + label = { + Text( + text = stringResource(id = roomListFilter.stringResource), + style = ElementTheme.typography.fontBodyMdRegular, + ) + }, + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = selected, + borderColor = borderColour.value, + ), + ) +} + +@PreviewsDayNight +@Composable +internal fun RoomListFiltersViewPreview(@PreviewParameter(RoomListFiltersStateProvider::class) state: RoomListFiltersState) = ElementPreview { + RoomListFiltersView( + modifier = Modifier.padding(vertical = 4.dp), + state = state, + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt new file mode 100644 index 0000000..877e934 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.filters.selection + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.home.impl.filters.RoomListFilter +import io.element.android.libraries.di.SessionScope +import kotlinx.coroutines.flow.MutableStateFlow + +@ContributesBinding(SessionScope::class) +class DefaultFilterSelectionStrategy : FilterSelectionStrategy { + private val selectedFilters = LinkedHashSet() + + override val filterSelectionStates = MutableStateFlow(buildFilters()) + + override fun select(filter: RoomListFilter) { + selectedFilters.add(filter) + filterSelectionStates.value = buildFilters() + } + + override fun deselect(filter: RoomListFilter) { + selectedFilters.remove(filter) + filterSelectionStates.value = buildFilters() + } + + override fun isSelected(filter: RoomListFilter): Boolean { + return selectedFilters.contains(filter) + } + + override fun clear() { + selectedFilters.clear() + filterSelectionStates.value = buildFilters() + } + + private fun buildFilters(): Set { + val selectedFilterStates = selectedFilters.map { + FilterSelectionState( + filter = it, + isSelected = true + ) + } + val unselectedFilters = RoomListFilter.entries - selectedFilters - selectedFilters.flatMap { it.incompatibleFilters }.toSet() + val unselectedFilterStates = unselectedFilters.map { + FilterSelectionState( + filter = it, + isSelected = false + ) + } + return (selectedFilterStates + unselectedFilterStates).toSet() + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/FilterSelectionState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/FilterSelectionState.kt new file mode 100644 index 0000000..3a13688 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/FilterSelectionState.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.filters.selection + +import io.element.android.features.home.impl.filters.RoomListFilter + +data class FilterSelectionState( + val filter: RoomListFilter, + val isSelected: Boolean, +) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/FilterSelectionStrategy.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/FilterSelectionStrategy.kt new file mode 100644 index 0000000..f0877b5 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/FilterSelectionStrategy.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.filters.selection + +import io.element.android.features.home.impl.filters.RoomListFilter +import kotlinx.coroutines.flow.StateFlow + +interface FilterSelectionStrategy { + val filterSelectionStates: StateFlow> + + fun select(filter: RoomListFilter) + fun deselect(filter: RoomListFilter) + fun isSelected(filter: RoomListFilter): Boolean + fun clear() + + fun toggle(filter: RoomListFilter) { + if (isSelected(filter)) { + deselect(filter) + } else { + select(filter) + } + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/LatestEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/LatestEvent.kt new file mode 100644 index 0000000..d6ddcf2 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/LatestEvent.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.model + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface LatestEvent { + data object None : LatestEvent + + data class Synced( + val content: CharSequence?, + ) : LatestEvent + + data class Sending( + val content: CharSequence?, + ) : LatestEvent + + data object Error : LatestEvent + + fun content(): CharSequence? { + return when (this) { + is None -> null + is Synced -> content + is Sending -> content + is Error -> null + } + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummary.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummary.kt new file mode 100644 index 0000000..a59e444 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummary.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.model + +import androidx.compose.runtime.Immutable +import io.element.android.features.invite.api.InviteData +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.ui.model.InviteSender +import kotlinx.collections.immutable.ImmutableList + +@Immutable +data class RoomListRoomSummary( + val id: String, + val displayType: RoomSummaryDisplayType, + val roomId: RoomId, + val name: String?, + val canonicalAlias: RoomAlias?, + val numberOfUnreadMessages: Long, + val numberOfUnreadMentions: Long, + val numberOfUnreadNotifications: Long, + val isMarkedUnread: Boolean, + val timestamp: String?, + val latestEvent: LatestEvent, + val avatarData: AvatarData, + val userDefinedNotificationMode: RoomNotificationMode?, + val hasRoomCall: Boolean, + val isDirect: Boolean, + val isDm: Boolean, + val isFavorite: Boolean, + val inviteSender: InviteSender?, + val isTombstoned: Boolean, + val heroes: ImmutableList, + val isSpace: Boolean, +) { + val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE && + (numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) || + isMarkedUnread + + val hasNewContent = numberOfUnreadMessages > 0 || + numberOfUnreadMentions > 0 || + numberOfUnreadNotifications > 0 || + isMarkedUnread + + fun toInviteData() = InviteData( + roomId = roomId, + roomName = name ?: roomId.value, + isDm = isDm, + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryProvider.kt new file mode 100644 index 0000000..400decf --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryProvider.kt @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.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.RoomAlias +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.RoomNotificationMode +import io.element.android.libraries.matrix.ui.model.InviteSender +import kotlinx.collections.immutable.toImmutableList + +open class RoomListRoomSummaryProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + listOf( + aRoomListRoomSummary(displayType = RoomSummaryDisplayType.PLACEHOLDER), + aRoomListRoomSummary(), + aRoomListRoomSummary(name = null), + aRoomListRoomSummary(latestEvent = LatestEvent.None), + aRoomListRoomSummary( + name = "A very long room name that should be truncated", + latestEvent = LatestEvent.Synced( + "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", + numberOfUnreadMessages = 1, + ), + ), + listOf(false, true).map { hasCall -> + listOf( + RoomNotificationMode.ALL_MESSAGES, + RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, + RoomNotificationMode.MUTE, + ).map { roomNotificationMode -> + listOf( + aRoomListRoomSummary( + name = roomNotificationMode.name, + latestEvent = LatestEvent.Synced("No activity" + if (hasCall) ", call" else ""), + notificationMode = roomNotificationMode, + numberOfUnreadMessages = 0, + numberOfUnreadMentions = 0, + hasRoomCall = hasCall, + ), + aRoomListRoomSummary( + name = roomNotificationMode.name, + latestEvent = LatestEvent.Synced("New messages" + if (hasCall) ", call" else ""), + notificationMode = roomNotificationMode, + numberOfUnreadMessages = 1, + numberOfUnreadMentions = 0, + hasRoomCall = hasCall, + ), + aRoomListRoomSummary( + name = roomNotificationMode.name, + latestEvent = LatestEvent.Synced("New messages, mentions" + if (hasCall) ", call" else ""), + notificationMode = roomNotificationMode, + numberOfUnreadMessages = 1, + numberOfUnreadMentions = 1, + hasRoomCall = hasCall, + ), + aRoomListRoomSummary( + name = roomNotificationMode.name, + latestEvent = LatestEvent.Synced("New mentions" + if (hasCall) ", call" else ""), + notificationMode = roomNotificationMode, + numberOfUnreadMessages = 0, + numberOfUnreadMentions = 1, + hasRoomCall = hasCall, + ), + ) + }.flatten() + }.flatten(), + listOf( + aRoomListRoomSummary( + displayType = RoomSummaryDisplayType.INVITE, + inviteSender = anInviteSender( + userId = UserId("@alice:matrix.org"), + displayName = "Alice", + ), + canonicalAlias = RoomAlias("#alias:matrix.org"), + ), + aRoomListRoomSummary( + name = "Bob", + displayType = RoomSummaryDisplayType.INVITE, + inviteSender = anInviteSender( + userId = UserId("@bob:matrix.org"), + displayName = "Bob", + ), + isDm = true, + ), + aRoomListRoomSummary( + name = null, + displayType = RoomSummaryDisplayType.INVITE, + inviteSender = anInviteSender( + userId = UserId("@bob:matrix.org"), + displayName = "Bob", + ), + ), + aRoomListRoomSummary( + name = "A space invite", + displayType = RoomSummaryDisplayType.INVITE, + inviteSender = anInviteSender( + userId = UserId("@bob:matrix.org"), + displayName = "Bob", + ), + isSpace = true + ), + aRoomListRoomSummary( + name = "A knocked room", + displayType = RoomSummaryDisplayType.KNOCKED, + ), + aRoomListRoomSummary( + name = "A knocked room with alias", + canonicalAlias = RoomAlias("#knockable:matrix.org"), + displayType = RoomSummaryDisplayType.KNOCKED, + ), + aRoomListRoomSummary( + name = "A tombstoned room", + displayType = RoomSummaryDisplayType.ROOM, + isTombstoned = true, + ) + ), + listOf( + aRoomListRoomSummary(latestEvent = LatestEvent.Sending("A sending message")), + aRoomListRoomSummary(latestEvent = LatestEvent.Error), + ) + ).flatten() +} + +internal fun anInviteSender( + userId: UserId = UserId("@bob:domain"), + displayName: String = "Bob", + avatarData: AvatarData = AvatarData(userId.value, displayName, size = AvatarSize.InviteSender), +) = InviteSender( + userId = userId, + displayName = displayName, + avatarData = avatarData, + membershipChangeReason = null, +) + +internal fun aRoomListRoomSummary( + id: String = "!roomId:domain", + name: String? = "Room name", + numberOfUnreadMessages: Long = 0, + numberOfUnreadMentions: Long = 0, + numberOfUnreadNotifications: Long = 0, + isMarkedUnread: Boolean = false, + latestEvent: LatestEvent = LatestEvent.Synced("Last message"), + timestamp: String? = latestEvent.takeIf { it !is LatestEvent.None }?.let { "88:88" }, + notificationMode: RoomNotificationMode? = null, + hasRoomCall: Boolean = false, + avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem), + isDirect: Boolean = false, + isDm: Boolean = false, + isFavorite: Boolean = false, + inviteSender: InviteSender? = null, + displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM, + canonicalAlias: RoomAlias? = null, + heroes: List = emptyList(), + isTombstoned: Boolean = false, + isSpace: Boolean = false, +) = RoomListRoomSummary( + id = id, + roomId = RoomId(id), + name = name, + numberOfUnreadMessages = numberOfUnreadMessages, + numberOfUnreadMentions = numberOfUnreadMentions, + numberOfUnreadNotifications = numberOfUnreadNotifications, + isMarkedUnread = isMarkedUnread, + timestamp = timestamp, + latestEvent = latestEvent, + avatarData = avatarData, + userDefinedNotificationMode = notificationMode, + hasRoomCall = hasRoomCall, + isDirect = isDirect, + isDm = isDm, + isFavorite = isFavorite, + inviteSender = inviteSender, + displayType = displayType, + canonicalAlias = canonicalAlias, + heroes = heroes.toImmutableList(), + isTombstoned = isTombstoned, + isSpace = isSpace +) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomSummaryDisplayType.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomSummaryDisplayType.kt new file mode 100644 index 0000000..a27a83d --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomSummaryDisplayType.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.model + +/** + * Represents the type of display for a room list item. + */ +enum class RoomSummaryDisplayType { + PLACEHOLDER, + ROOM, + INVITE, + KNOCKED, +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStateProvider.kt new file mode 100644 index 0000000..c79e79b --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStateProvider.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.roomlist + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState +import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.push.api.battery.BatteryOptimizationState +import io.element.android.libraries.push.api.battery.aBatteryOptimizationState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableSet + +open class RoomListContentStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aRoomsContentState(), + aRoomsContentState(summaries = persistentListOf()), + aSkeletonContentState(), + anEmptyContentState(), + anEmptyContentState(securityBannerState = SecurityBannerState.SetUpRecovery), + aRoomsContentState( + showNewNotificationSoundBanner = true, + ), + ) +} + +internal fun aRoomsContentState( + securityBannerState: SecurityBannerState = SecurityBannerState.None, + showNewNotificationSoundBanner: Boolean = false, + summaries: ImmutableList = aRoomListRoomSummaryList(), + fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(), + batteryOptimizationState: BatteryOptimizationState = aBatteryOptimizationState(), + seenRoomInvites: Set = emptySet(), +) = RoomListContentState.Rooms( + securityBannerState = securityBannerState, + showNewNotificationSoundBanner = showNewNotificationSoundBanner, + fullScreenIntentPermissionsState = fullScreenIntentPermissionsState, + batteryOptimizationState = batteryOptimizationState, + summaries = summaries, + seenRoomInvites = seenRoomInvites.toImmutableSet(), +) + +internal fun aSkeletonContentState() = RoomListContentState.Skeleton(16) + +internal fun anEmptyContentState( + securityBannerState: SecurityBannerState = SecurityBannerState.None, +) = RoomListContentState.Empty( + securityBannerState = securityBannerState, +) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt new file mode 100644 index 0000000..5c7ef15 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.roomlist + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +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.text.font.FontStyle +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.home.impl.R +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +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.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RoomListContextMenu( + contextMenu: RoomListState.ContextMenu.Shown, + canReportRoom: Boolean, + eventSink: (RoomListEvents.ContextMenuEvents) -> Unit, + onRoomSettingsClick: (roomId: RoomId) -> Unit, + onReportRoomClick: (roomId: RoomId) -> Unit +) { + ModalBottomSheet( + onDismissRequest = { eventSink(RoomListEvents.HideContextMenu) }, + ) { + RoomListModalBottomSheetContent( + contextMenu = contextMenu, + canReportRoom = canReportRoom, + onRoomMarkReadClick = { + eventSink(RoomListEvents.HideContextMenu) + eventSink(RoomListEvents.MarkAsRead(contextMenu.roomId)) + }, + onRoomMarkUnreadClick = { + eventSink(RoomListEvents.HideContextMenu) + eventSink(RoomListEvents.MarkAsUnread(contextMenu.roomId)) + }, + onRoomSettingsClick = { + eventSink(RoomListEvents.HideContextMenu) + onRoomSettingsClick(contextMenu.roomId) + }, + onLeaveRoomClick = { + eventSink(RoomListEvents.HideContextMenu) + eventSink(RoomListEvents.LeaveRoom(contextMenu.roomId, needsConfirmation = true)) + }, + onFavoriteChange = { isFavorite -> + eventSink(RoomListEvents.SetRoomIsFavorite(contextMenu.roomId, isFavorite)) + }, + onClearCacheRoomClick = { + eventSink(RoomListEvents.HideContextMenu) + eventSink(RoomListEvents.ClearCacheOfRoom(contextMenu.roomId)) + }, + onReportRoomClick = { + eventSink(RoomListEvents.HideContextMenu) + onReportRoomClick(contextMenu.roomId) + }, + ) + } +} + +@Composable +private fun RoomListModalBottomSheetContent( + contextMenu: RoomListState.ContextMenu.Shown, + canReportRoom: Boolean, + onRoomSettingsClick: () -> Unit, + onLeaveRoomClick: () -> Unit, + onFavoriteChange: (isFavorite: Boolean) -> Unit, + onRoomMarkReadClick: () -> Unit, + onRoomMarkUnreadClick: () -> Unit, + onClearCacheRoomClick: () -> Unit, + onReportRoomClick: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + ListItem( + headlineContent = { + Text( + text = contextMenu.roomName ?: stringResource(id = CommonStrings.common_no_room_name), + style = ElementTheme.typography.fontBodyLgMedium, + fontStyle = FontStyle.Italic.takeIf { contextMenu.roomName == null } + ) + } + ) + if (contextMenu.hasNewContent) { + ListItem( + headlineContent = { + Text( + text = stringResource(id = R.string.screen_roomlist_mark_as_read), + style = MaterialTheme.typography.bodyLarge, + ) + }, + onClick = onRoomMarkReadClick, + leadingContent = ListItemContent.Icon( + iconSource = IconSource.Vector(CompoundIcons.MarkAsRead()) + ), + style = ListItemStyle.Primary, + ) + } else { + ListItem( + headlineContent = { + Text( + text = stringResource(id = R.string.screen_roomlist_mark_as_unread), + style = MaterialTheme.typography.bodyLarge, + ) + }, + onClick = onRoomMarkUnreadClick, + leadingContent = ListItemContent.Icon( + iconSource = IconSource.Vector(CompoundIcons.MarkAsUnread()) + ), + style = ListItemStyle.Primary, + ) + } + ListItem( + headlineContent = { + Text( + text = stringResource(id = CommonStrings.common_favourite), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = ListItemContent.Icon( + iconSource = IconSource.Vector( + CompoundIcons.Favourite(), + ) + ), + trailingContent = ListItemContent.Switch( + checked = contextMenu.isFavorite, + ), + onClick = { + onFavoriteChange(!contextMenu.isFavorite) + }, + style = ListItemStyle.Primary, + ) + ListItem( + headlineContent = { + Text( + text = stringResource(id = CommonStrings.common_settings), + style = MaterialTheme.typography.bodyLarge, + ) + }, + modifier = Modifier.clickable { onRoomSettingsClick() }, + leadingContent = ListItemContent.Icon( + iconSource = IconSource.Vector( + CompoundIcons.Settings(), + ) + ), + style = ListItemStyle.Primary, + ) + if (canReportRoom) { + ListItem( + headlineContent = { + Text(text = stringResource(CommonStrings.action_report_room)) + }, + modifier = Modifier.clickable { onReportRoomClick() }, + leadingContent = ListItemContent.Icon( + iconSource = IconSource.Vector( + CompoundIcons.ChatProblem(), + ) + ), + style = ListItemStyle.Destructive, + ) + } + ListItem( + headlineContent = { + Text(text = stringResource(CommonStrings.action_leave_room)) + }, + modifier = Modifier.clickable { onLeaveRoomClick() }, + leadingContent = ListItemContent.Icon( + iconSource = IconSource.Vector( + CompoundIcons.Leave(), + ) + ), + style = ListItemStyle.Destructive, + ) + if (contextMenu.displayClearRoomCacheAction) { + ListItem( + headlineContent = { + Text(text = "Clear cache for this room") + }, + modifier = Modifier.clickable { onClearCacheRoomClick() }, + leadingContent = ListItemContent.Icon( + iconSource = IconSource.Vector(CompoundIcons.Delete()) + ), + style = ListItemStyle.Primary, + ) + } + } +} + +// 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. +@PreviewsDayNight +@Composable +internal fun RoomListModalBottomSheetContentPreview( + @PreviewParameter(RoomListStateContextMenuShownProvider::class) contextMenu: RoomListState.ContextMenu.Shown +) = ElementPreview { + RoomListModalBottomSheetContent( + contextMenu = contextMenu, + canReportRoom = true, + onRoomMarkReadClick = {}, + onRoomMarkUnreadClick = {}, + onRoomSettingsClick = {}, + onLeaveRoomClick = {}, + onFavoriteChange = {}, + onClearCacheRoomClick = {}, + onReportRoomClick = {}, + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenu.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenu.kt new file mode 100644 index 0000000..4c91e14 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenu.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.roomlist + +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.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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.home.impl.R +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +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 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RoomListDeclineInviteMenu( + menu: RoomListState.DeclineInviteMenu.Shown, + canReportRoom: Boolean, + onDeclineAndBlockClick: (RoomListRoomSummary) -> Unit, + eventSink: (RoomListEvents) -> Unit, +) { + ModalBottomSheet( + onDismissRequest = { eventSink(RoomListEvents.HideDeclineInviteMenu) }, + ) { + RoomListDeclineInviteMenuContent( + roomName = menu.roomSummary.name ?: menu.roomSummary.roomId.value, + onDeclineClick = { + eventSink(RoomListEvents.HideDeclineInviteMenu) + eventSink(RoomListEvents.DeclineInvite(menu.roomSummary, false)) + }, + onDeclineAndBlockClick = { + eventSink(RoomListEvents.HideDeclineInviteMenu) + if (canReportRoom) { + onDeclineAndBlockClick(menu.roomSummary) + } else { + eventSink(RoomListEvents.DeclineInvite(menu.roomSummary, true)) + } + }, + onCancelClick = { + eventSink(RoomListEvents.HideDeclineInviteMenu) + } + ) + } +} + +@Composable +private fun RoomListDeclineInviteMenuContent( + roomName: String, + onDeclineClick: () -> Unit, + onDeclineAndBlockClick: () -> Unit, + onCancelClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(all = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.screen_invites_decline_chat_title), + style = ElementTheme.typography.fontHeadingSmMedium, + color = ElementTheme.colors.textPrimary, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.screen_invites_decline_chat_message, roomName), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(22.dp)) + Button( + text = stringResource(CommonStrings.action_decline), + modifier = Modifier.fillMaxWidth(), + onClick = onDeclineClick, + ) + Spacer(Modifier.height(16.dp)) + OutlinedButton( + text = stringResource(CommonStrings.action_decline_and_block), + modifier = Modifier.fillMaxWidth(), + destructive = true, + onClick = onDeclineAndBlockClick + ) + Spacer(Modifier.height(16.dp)) + TextButton( + text = stringResource(CommonStrings.action_cancel), + modifier = Modifier.fillMaxWidth(), + onClick = onCancelClick + ) + } +} + +// 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. +@PreviewsDayNight +@Composable +internal fun RoomListDeclineInviteMenuContentPreview() = ElementPreview { + RoomListDeclineInviteMenuContent( + roomName = "Room name", + onCancelClick = {}, + onDeclineClick = {}, + onDeclineAndBlockClick = {}, + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvents.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvents.kt new file mode 100644 index 0000000..3695322 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvents.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.roomlist + +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.libraries.matrix.api.core.RoomId + +sealed interface RoomListEvents { + data class UpdateVisibleRange(val range: IntRange) : RoomListEvents + data object DismissRequestVerificationPrompt : RoomListEvents + data object DismissBanner : RoomListEvents + data object DismissNewNotificationSoundBanner : RoomListEvents + data object ToggleSearchResults : RoomListEvents + data class ShowContextMenu(val roomSummary: RoomListRoomSummary) : RoomListEvents + + data class AcceptInvite(val roomSummary: RoomListRoomSummary) : RoomListEvents + data class DeclineInvite(val roomSummary: RoomListRoomSummary, val blockUser: Boolean) : RoomListEvents + data class ShowDeclineInviteMenu(val roomSummary: RoomListRoomSummary) : RoomListEvents + data object HideDeclineInviteMenu : RoomListEvents + + sealed interface ContextMenuEvents : RoomListEvents + data object HideContextMenu : ContextMenuEvents + data class LeaveRoom(val roomId: RoomId, val needsConfirmation: Boolean) : ContextMenuEvents + data class MarkAsRead(val roomId: RoomId) : ContextMenuEvents + data class MarkAsUnread(val roomId: RoomId) : ContextMenuEvents + data class SetRoomIsFavorite(val roomId: RoomId, val isFavorite: Boolean) : ContextMenuEvents + data class ClearCacheOfRoom(val roomId: RoomId) : ContextMenuEvents +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt new file mode 100644 index 0000000..5fc07ab --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -0,0 +1,344 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.roomlist + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +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.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import dev.zacsweers.metro.Inject +import im.vector.app.features.analytics.plan.Interaction +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService +import io.element.android.features.home.impl.datasource.RoomListDataSource +import io.element.android.features.home.impl.filters.RoomListFiltersState +import io.element.android.features.home.impl.search.RoomListSearchEvents +import io.element.android.features.home.impl.search.RoomListSearchState +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.AcceptInvite +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.DeclineInvite +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.api.roomlist.RoomList +import io.element.android.libraries.matrix.api.timeline.ReceiptType +import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.push.api.battery.BatteryOptimizationState +import io.element.android.libraries.push.api.notifications.NotificationCleaner +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.launch + +private const val EXTENDED_RANGE_SIZE = 40 +private const val SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS = 300L + +@Inject +class RoomListPresenter( + private val client: MatrixClient, + private val leaveRoomPresenter: Presenter, + private val roomListDataSource: RoomListDataSource, + private val filtersPresenter: Presenter, + private val searchPresenter: Presenter, + private val sessionPreferencesStore: SessionPreferencesStore, + private val analyticsService: AnalyticsService, + private val acceptDeclineInvitePresenter: Presenter, + private val fullScreenIntentPermissionsPresenter: Presenter, + private val batteryOptimizationPresenter: Presenter, + private val notificationCleaner: NotificationCleaner, + private val appPreferencesStore: AppPreferencesStore, + private val seenInvitesStore: SeenInvitesStore, + private val announcementService: AnnouncementService, + private val coldStartWatcher: AnalyticsColdStartWatcher, +) : Presenter { + private val encryptionService = client.encryptionService + + @Composable + override fun present(): RoomListState { + val coroutineScope = rememberCoroutineScope() + val leaveRoomState = leaveRoomPresenter.present() + val filtersState = filtersPresenter.present() + val searchState = searchPresenter.present() + val acceptDeclineInviteState = acceptDeclineInvitePresenter.present() + + LaunchedEffect(Unit) { + roomListDataSource.launchIn(this) + } + + var securityBannerDismissed by rememberSaveable { mutableStateOf(false) } + val showNewNotificationSoundBanner by remember { + announcementService.announcementsToShowFlow().map { announcements -> + announcements.contains(Announcement.NewNotificationSound) + } + }.collectAsState(false) + + // Avatar indicator + val hideInvitesAvatar by client.rememberHideInvitesAvatar() + + val contextMenu = remember { mutableStateOf(RoomListState.ContextMenu.Hidden) } + val declineInviteMenu = remember { mutableStateOf(RoomListState.DeclineInviteMenu.Hidden) } + + fun handleEvent(event: RoomListEvents) { + when (event) { + is RoomListEvents.UpdateVisibleRange -> coroutineScope.launch { + updateVisibleRange(event.range) + } + RoomListEvents.DismissRequestVerificationPrompt -> securityBannerDismissed = true + RoomListEvents.DismissBanner -> securityBannerDismissed = true + RoomListEvents.DismissNewNotificationSoundBanner -> coroutineScope.launch { + announcementService.onAnnouncementDismissed(Announcement.NewNotificationSound) + } + RoomListEvents.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvents.ToggleSearchVisibility) + is RoomListEvents.ShowContextMenu -> { + coroutineScope.showContextMenu(event, contextMenu) + } + is RoomListEvents.HideContextMenu -> { + contextMenu.value = RoomListState.ContextMenu.Hidden + } + is RoomListEvents.LeaveRoom -> { + leaveRoomState.eventSink(LeaveRoomEvent.LeaveRoom(event.roomId, needsConfirmation = event.needsConfirmation)) + } + is RoomListEvents.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event.roomId, event.isFavorite) + is RoomListEvents.MarkAsRead -> coroutineScope.markAsRead(event.roomId) + is RoomListEvents.MarkAsUnread -> coroutineScope.markAsUnread(event.roomId) + is RoomListEvents.AcceptInvite -> { + acceptDeclineInviteState.eventSink( + AcceptInvite(event.roomSummary.toInviteData()) + ) + } + is RoomListEvents.DeclineInvite -> { + acceptDeclineInviteState.eventSink( + DeclineInvite(event.roomSummary.toInviteData(), blockUser = event.blockUser, shouldConfirm = false) + ) + } + is RoomListEvents.ShowDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Shown(event.roomSummary) + RoomListEvents.HideDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Hidden + is RoomListEvents.ClearCacheOfRoom -> coroutineScope.clearCacheOfRoom(event.roomId) + } + } + + val contentState = roomListContentState( + securityBannerDismissed, + showNewNotificationSoundBanner, + ) + + val canReportRoom by produceState(false) { value = client.canReportRoom() } + + return RoomListState( + contextMenu = contextMenu.value, + declineInviteMenu = declineInviteMenu.value, + leaveRoomState = leaveRoomState, + filtersState = filtersState, + searchState = searchState, + contentState = contentState, + acceptDeclineInviteState = acceptDeclineInviteState, + hideInvitesAvatars = hideInvitesAvatar, + canReportRoom = canReportRoom, + eventSink = ::handleEvent, + ) + } + + @Composable + private fun rememberSecurityBannerState( + securityBannerDismissed: Boolean, + ): State { + val currentSecurityBannerDismissed by rememberUpdatedState(securityBannerDismissed) + val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() + return remember { + derivedStateOf { + calculateBannerState( + securityBannerDismissed = currentSecurityBannerDismissed, + recoveryState = recoveryState, + ) + } + } + } + + private fun calculateBannerState( + securityBannerDismissed: Boolean, + recoveryState: RecoveryState, + ): SecurityBannerState { + if (securityBannerDismissed) { + return SecurityBannerState.None + } + + when (recoveryState) { + RecoveryState.DISABLED -> return SecurityBannerState.SetUpRecovery + RecoveryState.INCOMPLETE -> return SecurityBannerState.RecoveryKeyConfirmation + RecoveryState.UNKNOWN, + RecoveryState.WAITING_FOR_SYNC, + RecoveryState.ENABLED -> Unit + } + + return SecurityBannerState.None + } + + @Composable + private fun roomListContentState( + securityBannerDismissed: Boolean, + showNewNotificationSoundBanner: Boolean, + ): RoomListContentState { + val roomSummaries by produceState(initialValue = AsyncData.Loading()) { + roomListDataSource.allRooms.collect { value = AsyncData.Success(it) } + } + val loadingState by roomListDataSource.loadingState.collectAsState() + val showEmpty by remember { + derivedStateOf { + (loadingState as? RoomList.LoadingState.Loaded)?.numberOfRooms == 0 + } + } + val showSkeleton by remember { + derivedStateOf { + loadingState == RoomList.LoadingState.NotLoaded || roomSummaries is AsyncData.Loading + } + } + val seenRoomInvites by remember { seenInvitesStore.seenRoomIds() }.collectAsState(emptySet()) + val securityBannerState by rememberSecurityBannerState(securityBannerDismissed) + return when { + showEmpty -> RoomListContentState.Empty( + securityBannerState = securityBannerState, + ) + showSkeleton -> RoomListContentState.Skeleton(count = 16) + else -> { + coldStartWatcher.onRoomListVisible() + + RoomListContentState.Rooms( + securityBannerState = securityBannerState, + showNewNotificationSoundBanner = showNewNotificationSoundBanner, + fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(), + batteryOptimizationState = batteryOptimizationPresenter.present(), + summaries = roomSummaries.dataOrNull().orEmpty().toImmutableList(), + seenRoomInvites = seenRoomInvites.toImmutableSet(), + ) + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun CoroutineScope.showContextMenu(event: RoomListEvents.ShowContextMenu, contextMenuState: MutableState) = launch { + val initialState = RoomListState.ContextMenu.Shown( + roomId = event.roomSummary.roomId, + roomName = event.roomSummary.name, + isDm = event.roomSummary.isDm, + isFavorite = event.roomSummary.isFavorite, + hasNewContent = event.roomSummary.hasNewContent, + displayClearRoomCacheAction = appPreferencesStore.isDeveloperModeEnabledFlow().first(), + ) + contextMenuState.value = initialState + + client.getRoom(event.roomSummary.roomId)?.use { room -> + + val isShowingContextMenuFlow = snapshotFlow { contextMenuState.value is RoomListState.ContextMenu.Shown } + .distinctUntilChanged() + + val isFavoriteFlow = room.roomInfoFlow + .map { it.isFavorite } + .distinctUntilChanged() + + isFavoriteFlow + .onEach { isFavorite -> + contextMenuState.value = initialState.copy(isFavorite = isFavorite) + } + .flatMapLatest { isShowingContextMenuFlow } + .takeWhile { isShowingContextMenu -> isShowingContextMenu } + .collect() + } + } + + private fun CoroutineScope.setRoomIsFavorite(roomId: RoomId, isFavorite: Boolean) = launch { + client.getRoom(roomId)?.use { room -> + room.setIsFavorite(isFavorite) + .onSuccess { + analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuFavouriteToggle) + } + } + } + + private fun CoroutineScope.markAsRead(roomId: RoomId) = launch { + notificationCleaner.clearMessagesForRoom(client.sessionId, roomId) + client.getRoom(roomId)?.use { room -> + room.setUnreadFlag(isUnread = false) + val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) { + ReceiptType.READ + } else { + ReceiptType.READ_PRIVATE + } + room.markAsRead(receiptType) + .onSuccess { + analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle) + } + } + } + + private fun CoroutineScope.markAsUnread(roomId: RoomId) = launch { + client.getRoom(roomId)?.use { room -> + room.setUnreadFlag(isUnread = true) + .onSuccess { + analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle) + } + } + } + + private fun CoroutineScope.clearCacheOfRoom(roomId: RoomId) = launch { + client.getRoom(roomId)?.use { room -> + room.clearEventCacheStorage() + } + } + + private var currentUpdateVisibleRangeJob: Job? = null + private fun CoroutineScope.updateVisibleRange(range: IntRange) { + currentUpdateVisibleRangeJob?.cancel() + currentUpdateVisibleRangeJob = launch { + // Debounce the subscription to avoid subscribing to too many rooms + delay(SUBSCRIBE_TO_VISIBLE_ROOMS_DEBOUNCE_IN_MILLIS) + + if (range.isEmpty()) return@launch + val currentRoomList = roomListDataSource.allRooms.first() + // Use extended range to 'prefetch' the next rooms info + val midExtendedRangeSize = EXTENDED_RANGE_SIZE / 2 + val extendedRange = range.first until range.last + midExtendedRangeSize + val roomIds = extendedRange.mapNotNull { index -> + currentRoomList.getOrNull(index)?.roomId + } + roomListDataSource.subscribeToVisibleRooms(roomIds) + } + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt new file mode 100644 index 0000000..2b5ca03 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.roomlist + +import androidx.compose.runtime.Immutable +import io.element.android.features.home.impl.filters.RoomListFiltersState +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.features.home.impl.search.RoomListSearchState +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState +import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.push.api.battery.BatteryOptimizationState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet + +data class RoomListState( + val contextMenu: ContextMenu, + val declineInviteMenu: DeclineInviteMenu, + val leaveRoomState: LeaveRoomState, + val filtersState: RoomListFiltersState, + val searchState: RoomListSearchState, + val contentState: RoomListContentState, + val acceptDeclineInviteState: AcceptDeclineInviteState, + val hideInvitesAvatars: Boolean, + val canReportRoom: Boolean, + val eventSink: (RoomListEvents) -> Unit, +) { + val displayFilters = contentState is RoomListContentState.Rooms + + sealed interface ContextMenu { + data object Hidden : ContextMenu + data class Shown( + val roomId: RoomId, + val roomName: String?, + val isDm: Boolean, + val isFavorite: Boolean, + val hasNewContent: Boolean, + val displayClearRoomCacheAction: Boolean, + ) : ContextMenu + } + + sealed interface DeclineInviteMenu { + data object Hidden : DeclineInviteMenu + data class Shown(val roomSummary: RoomListRoomSummary) : DeclineInviteMenu + } +} + +enum class SecurityBannerState { + None, + SetUpRecovery, + RecoveryKeyConfirmation, +} + +@Immutable +sealed interface RoomListContentState { + data class Skeleton(val count: Int) : RoomListContentState + data class Empty( + val securityBannerState: SecurityBannerState, + ) : RoomListContentState + + data class Rooms( + val securityBannerState: SecurityBannerState, + val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState, + val batteryOptimizationState: BatteryOptimizationState, + val showNewNotificationSoundBanner: Boolean, + val summaries: ImmutableList, + val seenRoomInvites: ImmutableSet, + ) : RoomListContentState +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateContextMenuShownProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateContextMenuShownProvider.kt new file mode 100644 index 0000000..3f24996 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateContextMenuShownProvider.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.roomlist + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.RoomId + +open class RoomListStateContextMenuShownProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aContextMenuShown(hasNewContent = true), + aContextMenuShown(isDm = true), + aContextMenuShown(roomName = null) + ) +} + +internal fun aContextMenuShown( + roomName: String? = "aRoom", + isDm: Boolean = false, + hasNewContent: Boolean = false, + isFavorite: Boolean = false, +) = RoomListState.ContextMenu.Shown( + roomId = RoomId("!aRoom:aDomain"), + roomName = roomName, + isDm = isDm, + hasNewContent = hasNewContent, + isFavorite = isFavorite, + displayClearRoomCacheAction = false, +) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt new file mode 100644 index 0000000..188f446 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.roomlist + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.home.impl.filters.RoomListFiltersState +import io.element.android.features.home.impl.filters.aRoomListFiltersState +import io.element.android.features.home.impl.model.LatestEvent +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.features.home.impl.model.RoomSummaryDisplayType +import io.element.android.features.home.impl.model.aRoomListRoomSummary +import io.element.android.features.home.impl.model.anInviteSender +import io.element.android.features.home.impl.search.RoomListSearchState +import io.element.android.features.home.impl.search.aRoomListSearchState +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState +import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.push.api.battery.aBatteryOptimizationState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +open class RoomListStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aRoomListState(), + aRoomListState(contextMenu = aContextMenuShown(roomName = null)), + aRoomListState(contextMenu = aContextMenuShown(roomName = "A nice room name")), + aRoomListState(contextMenu = aContextMenuShown(isFavorite = true)), + aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)), + aRoomListState(contentState = anEmptyContentState()), + aRoomListState(contentState = aSkeletonContentState()), + aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")), + aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery)), + aRoomListState(contentState = aRoomsContentState(batteryOptimizationState = aBatteryOptimizationState(shouldDisplayBanner = true))), + aRoomListState(contentState = anEmptyContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)), + ) +} + +internal fun aRoomListState( + contextMenu: RoomListState.ContextMenu = RoomListState.ContextMenu.Hidden, + declineInviteMenu: RoomListState.DeclineInviteMenu = RoomListState.DeclineInviteMenu.Hidden, + leaveRoomState: LeaveRoomState = aLeaveRoomState(), + searchState: RoomListSearchState = aRoomListSearchState(), + filtersState: RoomListFiltersState = aRoomListFiltersState(), + contentState: RoomListContentState = aRoomsContentState(), + acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(), + hideInvitesAvatars: Boolean = false, + canReportRoom: Boolean = true, + eventSink: (RoomListEvents) -> Unit = {} +) = RoomListState( + contextMenu = contextMenu, + declineInviteMenu = declineInviteMenu, + leaveRoomState = leaveRoomState, + filtersState = filtersState, + searchState = searchState, + contentState = contentState, + acceptDeclineInviteState = acceptDeclineInviteState, + hideInvitesAvatars = hideInvitesAvatars, + canReportRoom = canReportRoom, + eventSink = eventSink, +) + +internal fun aLeaveRoomState( + eventSink: (LeaveRoomEvent) -> Unit = {} +) = object : LeaveRoomState { + override val eventSink: (LeaveRoomEvent) -> Unit = eventSink +} + +internal fun aRoomListRoomSummaryList(): ImmutableList { + return persistentListOf( + aRoomListRoomSummary( + name = "Room Invited", + avatarData = AvatarData("!roomId", "Room with Alice and Bob", size = AvatarSize.RoomListItem), + id = "!roomId:domain", + inviteSender = anInviteSender(), + displayType = RoomSummaryDisplayType.INVITE, + ), + aRoomListRoomSummary( + name = "Room", + numberOfUnreadMessages = 1, + timestamp = "14:18", + latestEvent = LatestEvent.Synced("A very very very very long message which suites on two lines"), + avatarData = AvatarData("!id", "R", size = AvatarSize.RoomListItem), + id = "!roomId:domain", + ), + aRoomListRoomSummary( + name = "Room#2", + numberOfUnreadMessages = 0, + timestamp = "14:16", + latestEvent = LatestEvent.Synced("A short message"), + avatarData = AvatarData("!id", "Z", size = AvatarSize.RoomListItem), + id = "!roomId2:domain", + ), + aRoomListRoomSummary( + id = "!roomId3:domain", + displayType = RoomSummaryDisplayType.PLACEHOLDER, + ), + aRoomListRoomSummary( + id = "!roomId4:domain", + displayType = RoomSummaryDisplayType.PLACEHOLDER, + ), + ) +} + +internal fun generateRoomListRoomSummaryList( + numberOfRooms: Int = 10, +): ImmutableList { + return List(numberOfRooms) { index -> + aRoomListRoomSummary( + name = "Room#$index", + numberOfUnreadMessages = 0, + timestamp = "14:16", + latestEvent = LatestEvent.Synced("A message"), + avatarData = AvatarData("!id$index", "${(65 + index % 26).toChar()}", size = AvatarSize.RoomListItem), + id = "!roomId$index:domain", + ) + }.toImmutableList() +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt new file mode 100644 index 0000000..44f53a2 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search + +import dev.zacsweers.metro.Inject +import io.element.android.features.home.impl.datasource.RoomListRoomSummaryFactory +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.roomlist.RoomList +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +private const val PAGE_SIZE = 30 + +@Inject +class RoomListSearchDataSource( + roomListService: RoomListService, + coroutineDispatchers: CoroutineDispatchers, + private val roomSummaryFactory: RoomListRoomSummaryFactory, +) { + private val roomList = roomListService.createRoomList( + pageSize = PAGE_SIZE, + initialFilter = RoomListFilter.None, + source = RoomList.Source.All, + ) + + val roomSummaries: Flow> = roomList.filteredSummaries + .map { roomSummaries -> + roomSummaries + .map(roomSummaryFactory::create) + .toImmutableList() + } + .flowOn(coroutineDispatchers.computation) + + suspend fun setIsActive(isActive: Boolean) = coroutineScope { + if (isActive) { + roomList.loadAllIncrementally(this) + } else { + roomList.reset() + } + } + + suspend fun setSearchQuery(searchQuery: String) = coroutineScope { + val filter = if (searchQuery.isBlank()) { + RoomListFilter.None + } else { + RoomListFilter.NormalizedMatchRoomName(searchQuery) + } + roomList.updateFilter(filter) + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt new file mode 100644 index 0000000..20222c1 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search + +sealed interface RoomListSearchEvents { + data object ToggleSearchVisibility : RoomListSearchEvents + data class QueryChanged(val query: String) : RoomListSearchEvents + data object ClearQuery : RoomListSearchEvents +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt new file mode 100644 index 0000000..ad06b12 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search + +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 dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import kotlinx.collections.immutable.persistentListOf + +@Inject +class RoomListSearchPresenter( + private val dataSource: RoomListSearchDataSource, +) : Presenter { + @Composable + override fun present(): RoomListSearchState { + // Do not use rememberSaveable so that search is not active when the user navigates back to the screen + var isSearchActive by remember { + mutableStateOf(false) + } + var searchQuery by remember { + mutableStateOf("") + } + + LaunchedEffect(isSearchActive) { + dataSource.setIsActive(isSearchActive) + } + + LaunchedEffect(searchQuery) { + dataSource.setSearchQuery(searchQuery) + } + + fun handleEvent(event: RoomListSearchEvents) { + when (event) { + RoomListSearchEvents.ClearQuery -> { + searchQuery = "" + } + is RoomListSearchEvents.QueryChanged -> { + searchQuery = event.query + } + RoomListSearchEvents.ToggleSearchVisibility -> { + isSearchActive = !isSearchActive + searchQuery = "" + } + } + } + + val searchResults by dataSource.roomSummaries.collectAsState(initial = persistentListOf()) + + return RoomListSearchState( + isSearchActive = isSearchActive, + query = searchQuery, + results = searchResults, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt new file mode 100644 index 0000000..92e8c1f --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search + +import io.element.android.features.home.impl.model.RoomListRoomSummary +import kotlinx.collections.immutable.ImmutableList + +data class RoomListSearchState( + val isSearchActive: Boolean, + val query: String, + val results: ImmutableList, + val eventSink: (RoomListSearchEvents) -> Unit +) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt new file mode 100644 index 0000000..a5015a5 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.features.home.impl.roomlist.aRoomListRoomSummaryList +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +class RoomListSearchStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aRoomListSearchState(), + aRoomListSearchState( + isSearchActive = true, + query = "Test", + results = aRoomListRoomSummaryList() + ), + ) +} + +fun aRoomListSearchState( + isSearchActive: Boolean = false, + query: String = "", + results: ImmutableList = persistentListOf(), + eventSink: (RoomListSearchEvents) -> Unit = { }, +) = RoomListSearchState( + isSearchActive = isSearchActive, + query = query, + results = results, + eventSink = eventSink, +) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt new file mode 100644 index 0000000..58d6ba7 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search + +import androidx.activity.compose.BackHandler +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.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.material3.ExperimentalMaterial3Api +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +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.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.home.impl.components.RoomSummaryRow +import io.element.android.features.home.impl.contentType +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.features.home.impl.roomlist.RoomListEvents +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.FilledTextField +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.matrix.api.core.RoomId +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun RoomListSearchView( + state: RoomListSearchState, + hideInvitesAvatars: Boolean, + eventSink: (RoomListEvents) -> Unit, + onRoomClick: (RoomId) -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(enabled = state.isSearchActive) { + state.eventSink(RoomListSearchEvents.ToggleSearchVisibility) + } + + AnimatedVisibility( + visible = state.isSearchActive, + enter = fadeIn(), + exit = fadeOut(), + ) { + Column(modifier = modifier) { + RoomListSearchContent( + state = state, + hideInvitesAvatars = hideInvitesAvatars, + onRoomClick = onRoomClick, + eventSink = eventSink, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RoomListSearchContent( + state: RoomListSearchState, + hideInvitesAvatars: Boolean, + eventSink: (RoomListEvents) -> Unit, + onRoomClick: (RoomId) -> Unit, +) { + val borderColor = MaterialTheme.colorScheme.tertiary + val strokeWidth = 1.dp + fun onBackButtonClick() { + state.eventSink(RoomListSearchEvents.ToggleSearchVisibility) + } + + fun onRoomClick(room: RoomListRoomSummary) { + onRoomClick(room.roomId) + } + Scaffold( + 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 = ::onBackButtonClick) }, + title = { + // TODO replace `state.query` with TextFieldState when it's available for M3 TextField + // The stateSaver will keep the selection state when returning to this UI + var value by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue(state.query)) + } + + val focusRequester = remember { FocusRequester() } + FilledTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + value = value, + singleLine = true, + onValueChange = { + value = it + state.eventSink(RoomListSearchEvents.QueryChanged(it.text)) + }, + 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 (value.text.isNotEmpty()) { + IconButton(onClick = { + state.eventSink(RoomListSearchEvents.ClearQuery) + // Clear local state too + value = value.copy(text = "") + }) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_cancel) + ) + } + } + } + ) + + LaunchedEffect(Unit) { + if (!focusRequester.restoreFocusedChild()) { + focusRequester.requestFocus() + } + focusRequester.saveFocusedChild() + } + }, + ) + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + ) { + LazyColumn( + modifier = Modifier.weight(1f), + ) { + items( + items = state.results, + contentType = { room -> room.contentType() }, + ) { room -> + RoomSummaryRow( + room = room, + hideInviteAvatars = hideInvitesAvatars, + // TODO + isInviteSeen = false, + onClick = ::onRoomClick, + eventSink = eventSink, + ) + } + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun RoomListSearchContentPreview(@PreviewParameter(RoomListSearchStateProvider::class) state: RoomListSearchState) = ElementPreview { + RoomListSearchContent( + state = state, + hideInvitesAvatars = false, + onRoomClick = {}, + eventSink = {}, + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesEvents.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesEvents.kt new file mode 100644 index 0000000..8829041 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesEvents.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.spaces + +sealed interface HomeSpacesEvents diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt new file mode 100644 index 0000000..a890a61 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.spaces + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import dev.zacsweers.metro.Inject +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.flow.map + +@Inject +class HomeSpacesPresenter( + private val client: MatrixClient, + private val seenInvitesStore: SeenInvitesStore, +) : Presenter { + @Composable + override fun present(): HomeSpacesState { + val hideInvitesAvatar by client.rememberHideInvitesAvatar() + val spaceRooms by remember { + client.spaceService.spaceRoomsFlow.map { it.toImmutableList() } + }.collectAsState(persistentListOf()) + + val seenSpaceInvites by remember { + seenInvitesStore.seenRoomIds().map { it.toImmutableSet() } + }.collectAsState(persistentSetOf()) + + fun handleEvent(event: HomeSpacesEvents) { + // when (event) { } + } + + return HomeSpacesState( + space = CurrentSpace.Root, + spaceRooms = spaceRooms, + seenSpaceInvites = seenSpaceInvites, + hideInvitesAvatar = hideInvitesAvatar, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt new file mode 100644 index 0000000..7dcb370 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.spaces + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet + +data class HomeSpacesState( + val space: CurrentSpace, + val spaceRooms: ImmutableList, + val seenSpaceInvites: ImmutableSet, + val hideInvitesAvatar: Boolean, + val eventSink: (HomeSpacesEvents) -> Unit, +) + +sealed interface CurrentSpace { + object Root : CurrentSpace + data class Space(val spaceRoom: SpaceRoom) : CurrentSpace +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt new file mode 100644 index 0000000..8c03cff --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.spaces + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.previewutils.room.aSpaceRoom +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet + +open class HomeSpacesStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aHomeSpacesState( + spaceRooms = SpaceRoomProvider().values.toList(), + seenSpaceInvites = setOf( + RoomId("!spaceId3:example.com"), + ), + ), + aHomeSpacesState( + space = CurrentSpace.Space( + spaceRoom = aSpaceRoom(roomId = RoomId("!mySpace:example.com")) + ), + spaceRooms = aListOfSpaceRooms(), + ), + ) +} + +internal fun aHomeSpacesState( + space: CurrentSpace = CurrentSpace.Root, + spaceRooms: List = aListOfSpaceRooms(), + seenSpaceInvites: Set = emptySet(), + hideInvitesAvatar: Boolean = false, + eventSink: (HomeSpacesEvents) -> Unit = {}, +) = HomeSpacesState( + space = space, + spaceRooms = spaceRooms.toImmutableList(), + seenSpaceInvites = seenSpaceInvites.toImmutableSet(), + hideInvitesAvatar = hideInvitesAvatar, + eventSink = eventSink, +) + +fun aListOfSpaceRooms(): List { + return listOf( + aSpaceRoom(roomId = RoomId("!spaceId0:example.com")), + aSpaceRoom(roomId = RoomId("!spaceId1:example.com")), + aSpaceRoom(roomId = RoomId("!spaceId2:example.com")), + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt new file mode 100644 index 0000000..2505cf8 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.spaces + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.ui.components.SpaceHeaderRootView +import io.element.android.libraries.matrix.ui.components.SpaceHeaderView +import io.element.android.libraries.matrix.ui.components.SpaceRoomItemView +import io.element.android.libraries.matrix.ui.model.getAvatarData +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun HomeSpacesView( + state: HomeSpacesState, + lazyListState: LazyListState, + onSpaceClick: (RoomId) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier, + state = lazyListState + ) { + val space = state.space + when (space) { + CurrentSpace.Root -> { + item { + SpaceHeaderRootView(numberOfSpaces = state.spaceRooms.size) + } + } + is CurrentSpace.Space -> item { + SpaceHeaderView( + avatarData = space.spaceRoom.getAvatarData(AvatarSize.SpaceHeader), + name = space.spaceRoom.displayName, + topic = space.spaceRoom.topic, + visibility = space.spaceRoom.visibility, + heroes = space.spaceRoom.heroes.toImmutableList(), + numberOfMembers = space.spaceRoom.numJoinedMembers, + ) + } + } + item { + HorizontalDivider() + } + itemsIndexed( + items = state.spaceRooms, + key = { _, spaceRoom -> spaceRoom.roomId } + ) { index, spaceRoom -> + val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED + SpaceRoomItemView( + spaceRoom = spaceRoom, + showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites, + hideAvatars = isInvitation && state.hideInvitesAvatar, + onClick = { + onSpaceClick(spaceRoom.roomId) + }, + onLongClick = { + // TODO + }, + ) + if (index != state.spaceRooms.lastIndex) { + HorizontalDivider() + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun HomeSpacesViewPreview( + @PreviewParameter(HomeSpacesStateProvider::class) state: HomeSpacesState, +) = ElementPreview { + HomeSpacesView( + state = state, + lazyListState = rememberLazyListState(), + onSpaceClick = {}, + modifier = Modifier, + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomProvider.kt new file mode 100644 index 0000000..4a77e45 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/SpaceRoomProvider.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.spaces + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.previewutils.room.aSpaceRoom + +class SpaceRoomProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + aSpaceRoom(), + aSpaceRoom( + numJoinedMembers = 5, + childrenCount = 10, + worldReadable = true, + roomId = RoomId("!spaceId0:example.com"), + ), + aSpaceRoom( + numJoinedMembers = 5, + childrenCount = 10, + worldReadable = true, + avatarUrl = "anUrl", + roomId = RoomId("!spaceId1:example.com"), + ), + aSpaceRoom( + numJoinedMembers = 5, + childrenCount = 10, + worldReadable = true, + avatarUrl = "anUrl", + roomId = RoomId("!spaceId2:example.com"), + state = CurrentUserMembership.INVITED, + ), + ) +} diff --git a/features/home/impl/src/main/res/values-be/translations.xml b/features/home/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..d6cd4a1 --- /dev/null +++ b/features/home/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,43 @@ + + + "Стварыце новы ключ аднаўлення, які можна выкарыстоўваць для аднаўлення зашыфраванай гісторыі паведамленняў у выпадку страты доступу да вашых прылад." + "Наладзьце аднаўленне" + "Наладзіць аднаўленне" + "Пацвердзіце свой ключ аднаўлення, каб захаваць доступ да сховішча ключоў і гісторыі паведамленняў." + "Ваша сховішча ключоў не сінхранізавана" + "Каб не прапусціць важны званок, зменіце налады, каб дазволіць поўнаэкранныя апавяшчэнні, калі тэлефон заблакіраваны." + "Палепшыце якасць званкоў" + "Усе чаты" + "Вы ўпэўненыя, што хочаце адхіліць запрашэнне ў %1$s?" + "Адхіліць запрашэнне" + "Вы ўпэўненыя, што хочаце адмовіцца ад прыватных зносін з %1$s?" + "Адхіліць чат" + "Няма запрашэнняў" + "%1$s (%2$s) запрасіў(-ла) вас" + "Гэта аднаразовы працэс, дзякуем за чаканне." + "Налада ўліковага запісу." + "Стварыце новую размову або пакой" + "Пачніце з паведамлення каму-небудзь." + "Пакуль няма чатаў." + "Абранае" + "Дадаць чат у абранае можна ў наладах чата. +На дадзены момант вы можаце прыбраць фільтры, каб убачыць іншыя вашыя чаты." + "У вас пакуль няма абраных чатаў" + "Запрашэнні" + "У вас няма непрынятых запрашэнняў." + "Нізкі прыярытэт" + "Вы можаце прыбраць фільтры, каб убачыць іншыя вашыя чаты." + "У вас няма чатаў для гэтай катэгорыі" + "Людзі" + "У вас пакуль няма асабістых паведамленняў" + "Пакоі" + "Вас пакуль няма ў ніводным пакоі" + "Непрачытаныя" + "Віншуем! +У вас няма непрачытаных паведамленняў!" + "Усе чаты" + "Пазначыць як прачытанае" + "Пазначыць як непрачытанае" + "Здаецца, вы карыстаецеся новай прыладай. Праверце з дапамогай іншай прылады, каб атрымаць доступ да зашыфраваных паведамленняў." + "Пацвердзіце, што гэта вы" + diff --git a/features/home/impl/src/main/res/values-bg/translations.xml b/features/home/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..44a0bd5 --- /dev/null +++ b/features/home/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,39 @@ + + + "Потвърдете ключа си за възстановяване, за да запазите достъп до хранилището за ключове и историята на съобщенията си." + "Въведете ключа си за възстановяване" + "Хранилището ви за ключове не е синхронизирано" + "Всички чатове" + "Пространства" + "Сигурни ли сте, че искате да отхвърлите поканата за присъединяване в %1$s?" + "Отказване на покана" + "Сигурни ли сте, че искате да откажете този личен чат с %1$s?" + "Отказване на чат" + "Няма покани" + "%1$s (%2$s) ви покани" + "Това е еднократен процес, благодаря, че изчакахте." + "Настройване на вашия акаунт." + "Създаване на нов разговор или стая" + "Започнете, като изпратите съобщение на някого." + "Все още няма чатове." + "Любими" + "Можете да добавите чат към фаворизираните си в настройките на чата. +Засега можете да премахнете избора на филтрите, за да видите другите си чатове." + "Все още нямате фаворизирани чатове" + "Покани" + "Нямате чакащи покани." + "Нисък приоритет" + "Можете да премахнете избора на филтрите, за да видите другите си чатове" + "Хора" + "Все още нямате директни съобщения" + "Стаи" + "Все още не сте в никоя стая" + "Непрочетени" + "Поздравления! +Нямате непрочетени съобщения!" + "Всички чатове" + "Отбелязване като прочетено" + "Отбелязване като непрочетено" + "Изглежда, че използвате ново устройство. Потвърдете с друго устройство за достъп до вашите шифровани съобщения." + "Потвърдете, че сте вие" + diff --git a/features/home/impl/src/main/res/values-cs/translations.xml b/features/home/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..1eab814 --- /dev/null +++ b/features/home/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,55 @@ + + + "Zakažte optimalizaci baterie pro tuto aplikaci, abyste měli jistotu, že budou přijata všechna oznámení." + "Zakázat optimalizaci" + "Nepřicházejí vám oznámení?" + "Váš zvuk oznámení byl aktualizován – je jasnější, rychlejší a méně rušivý." + "Aktualizovali jsme vaše zvuky" + "Vygenerujte nový klíč pro obnovení, který lze použít k obnovení historie šifrovaných zpráv v případě, že ztratíte přístup ke svým zařízením." + "Nastavení obnovy" + "Nastavení obnovy" + "Potvrďte klíč pro obnovení, abyste zachovali přístup k úložišti klíčů a historii zpráv." + "Zadejte klíč pro obnovení" + "Zapomněli jste klíč pro obnovení?" + "Vaše úložiště klíčů není synchronizováno" + "Abyste nikdy nezmeškali důležitý hovor, změňte nastavení tak, abyste povolili oznámení na celé obrazovce, když je telefon uzamčen." + "Vylepšete si zážitek z volání" + "Všechny chaty" + "Prostory" + "Opravdu chcete odmítnout pozvánku do %1$s?" + "Odmítnout pozvání" + "Opravdu chcete odmítnout tuto soukromou konverzaci s %1$s?" + "Odmítnout chat" + "Žádné pozvánky" + "%1$s (%2$s) vás pozval(a)" + "Jedná se o jednorázový proces, prosíme o strpení." + "Nastavení vašeho účtu" + "Vytvořte novou konverzaci nebo místnost" + "Vymazat filtry" + "Začněte tím, že někomu pošnete zprávu." + "Zatím žádné konverzace." + "Oblíbené" + "V nastavení chatu můžete přidat chat k oblíbeným. +Prozatím můžete zrušit výběr filtrů, abyste viděli své další chaty" + "Zatím nemáte oblíbené chaty" + "Pozvánky" + "Nemáte žádné nevyřízené pozvánky." + "Nízká priorita" + "Zatím nemáte žádné chaty s nízkou prioritou" + "Můžete zrušit výběr filtrů, abyste viděli své další chaty" + "Nemáte chaty pro tento výběr" + "Lidé" + "Zatím nemáte žádné přímé zprávy" + "Místnosti" + "Ještě nejste v žádné místnosti" + "Nepřečtené" + "Gratulujeme! +Nemáte žádné nepřečtené zprávy!" + "Žádost o vstup odeslána" + "Všechny chaty" + "Označit jako přečtené" + "Označit jako nepřečtené" + "Tato místnost byla aktualizována" + "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." + "Ověřte, že jste to vy" + diff --git a/features/home/impl/src/main/res/values-cy/translations.xml b/features/home/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..c841795 --- /dev/null +++ b/features/home/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,53 @@ + + + "Analluogwch optimeiddio batri ar gyfer yr ap hwn, er mwyn sicrhau bod pob hysbysiad yn cael ei dderbyn." + "Analluogi optimeiddio" + "Hysbysiadau ddim yn cyrraedd?" + "Adferwch eich hunaniaeth cryptograffig a hanes negeseuon gydag allwedd adfer os ydych wedi colli eich holl ddyfeisiau presennol." + "Gosod adfer" + "Gosodwch adferiad i ddiogelu eich cyfrif" + "Cadarnhewch eich allwedd adfer i gynnal mynediad i\'ch storfa allweddi a\'ch hanes negeseuon." + "Rhowch eich allwedd adfer" + "Wedi anghofio\'ch allwedd adfer?" + "Dyw eich allwedd storfa heb ei gydweddu" + "Er mwyn sicrhau fyddwch chi ddim yn colli galwad bwysig, newidiwch eich gosodiadau i ganiatáu hysbysiadau sgrin lawn pan fydd eich ffôn wedi\'i gloi." + "Gwella profiad eich galwadau" + "Sgyrsiau" + "Gofodau" + "Ydych chi\'n siŵr eich bod am wrthod y gwahoddiad i ymuno â %1$s?" + "Gwrthod y gwahoddiad" + "Ydych chi\'n siŵr eich bod am wrthod y sgwrs breifat hon gyda %1$s?" + "Gwrthod sgwrs" + "Dim Gwahoddiadau" + "Mae %1$s (%2$s) wedi eich gwahodd" + "Mae hon yn broses un tro, diolch am aros." + "Creu eich cyfrif." + "Crëwch sgwrs neu ystafell newydd" + "Clirio\'r hidlau" + "Cychwynnwch arni trwy anfon neges at rywun." + "Dim sgyrsiau eto." + "Ffefrynnau" + "Gallwch ychwanegu sgwrs at eich ffefrynnau yn y gosodiadau sgwrsio. +Am y tro, gallwch ddad-ddewis hidlwyr er mwyn gweld eich sgyrsiau eraill" + "Does gennych chi ddim hoff sgyrsiau eto" + "Gwahoddiadau" + "Does gennych chi ddim gwahoddiadau yn aros." + "Blaenoriaeth Isel" + "Does gennych chi ddim sgyrsiau blaenoriaeth isel eto" + "Gallwch ddad-ddewis hidlwyr er mwyn gweld eich sgyrsiau eraill" + "Does gennych chi ddim sgyrsiau ar gyfer y dewis hwn" + "Pobl" + "Does gennych chi ddim unrhyw DMs eto" + "Ystafelloedd" + "Dydych chi ddim mewn unrhyw ystafell eto" + "Heb ei ddarllen" + "Llongyfarchiadau! +Does gennych chi ddim negeseuon heb eu darllen!" + "Anfonwyd y cais i ymuno" + "Sgyrsiau" + "Marcio fel wedi\'i ddarllen" + "Marcio fel heb ei ddarllen" + "Mae\'r ystafell hon wedi\'i huwchraddio" + "Mae\'n debyg eich bod chi\'n defnyddio dyfais newydd. Dilyswch gyda dyfais arall i gael mynediad at eich negeseuon wedi\'u hamgryptio." + "Gwiriwch mai chi sydd yna" + diff --git a/features/home/impl/src/main/res/values-da/translations.xml b/features/home/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..b076359 --- /dev/null +++ b/features/home/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,55 @@ + + + "Deaktiver batterioptimering for denne app for at sikre, at alle notifikationer dukker op." + "Deaktivér optimering" + "Modtager du ikke notifikationer?" + "Dit notifikationsping er blevet opdateret – tydeligere, hurtigere og mindre forstyrrende." + "Vi har opdateret dine lyde" + "Gendan din kryptografiske identitet og meddelelseshistorik med en gendannelsesnøgle, hvis du har mistet alle dine eksisterende enheder." + "Opsæt gendannelse" + "Konfigurer gendannelse for at beskytte din konto" + "Bekræft din gendannelsesnøgle for at bevare adgangen til nøglelager og meddelelseshistorik." + "Indtast din gendannelsesnøgle" + "Har du glemt din gendannelsesnøgle?" + "Dit nøglelager er ikke synkroniseret" + "For at sikre, at du aldrig går glip af et vigtigt opkald, skal du ændre dine indstillinger til at tillade underretninger i fuld skærm, når din telefon er låst." + "Gør din opkaldsoplevelse bedre" + "Samtaler" + "Grupper" + "Er du sikker på, at du vil afvise invitationen til at deltage i %1$s?" + "Afvis invitation" + "Er du sikker på, at du vil afvise denne private samtale med %1$s?" + "Afvis samtale" + "Ingen invitationer" + "%1$s(%2$s ) inviterede dig" + "Dette er en engangsproces, tak for din tålmodighed." + "Sætter din konto op." + "Opret en ny samtale eller et nyt rum" + "Ryd filtre" + "Kom i gang ved at sende en besked til nogen." + "Ingen samtaler endnu." + "Favoritter" + "Du kan tilføje en samtale til dine favoritter i samtaleindstillingerne. +For nu kan du fravælge filtre for at se dine andre samtaler" + "Du har endnu ingen foretrukne samtaler" + "Invitationer" + "Du har ingen afventende invitationer." + "Lav prioritet" + "Du har endnu ingen chats med lav prioritet" + "Du kan fravælge filtre for at se dine andre samtaler" + "Du har ingen samtaler til dette valg" + "Brugere" + "Du har ingen DM\'er endnu" + "Rum" + "Du er ikke i noget rum endnu" + "Ulæste" + "Tillykke! +Du har ingen ulæste beskeder!" + "Anmodning om at deltage sendt" + "Samtaler" + "Marker som læst" + "Marker som ulæst" + "Dette rum er blevet opgraderet" + "Det ser ud til, at du bruger en ny enhed. Bekræft med en anden enhed for at få adgang til dine krypterede meddelelser." + "Bekræft, at det er dig" + diff --git a/features/home/impl/src/main/res/values-de/translations.xml b/features/home/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..2502df5 --- /dev/null +++ b/features/home/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,55 @@ + + + "Deaktiviere die Batterieoptimierung für diese App, um sicherzustellen, dass alle Benachrichtigungen empfangen werden." + "Optimierung deaktivieren" + "Kommen die Benachrichtigungen nicht an?" + "Dein Benachrichtigungs-Ping wurde aktualisiert – klarer, schneller und weniger störend." + "Wir haben deine Sounds aktualisiert" + "Stelle Deine kryptographische Identität und Deinen Nachrichtenverlauf mit Hilfe eines Wiederherstellungsschlüssels wieder her, falls du alle deine Geräte verloren haben solltest" + "Wiederherstellung einrichten" + "Wiederherstellung einrichten" + "Bestätige deinen Wiederherstellungsschlüssel, um weiterhin auf deinen Schlüsselspeicher und den Nachrichtenverlauf zugreifen zu können." + "Gib deinen Wiederherstellungsschlüssel ein" + "Hast du deinen Wiederherstellungsschlüssel vergessen?" + "Dein Schlüsselspeicher ist nicht synchronisiert" + "Damit du keinen wichtigen Anruf verpasst, ändere bitte deine Einstellungen so, dass du bei gesperrtem Telefon Benachrichtigungen im Vollbildmodus erhältst." + "Verbessere dein Anruferlebnis" + "Chats" + "Spaces" + "Möchtest du die Einladung zum Betreten von %1$s wirklich ablehnen?" + "Einladung ablehnen" + "Bist du sicher, dass du diese Direktnachricht von %1$s ablehnen möchtest?" + "Einladung ablehnen" + "Keine Einladungen" + "%1$s (%2$s) hat dich eingeladen" + "Dies ist ein einmaliger Vorgang, danke fürs Warten." + "Dein Konto wird eingerichtet." + "Einen neuen Chat erstellen" + "Filter zurücksetzen" + "Schick einfach jemandem eine Nachricht, um loszulegen." + "Noch keine Chats." + "Favoriten" + "In den Chat Einstellungen kannst du einen Chat als Favorit markieren. +Deaktiviere den entsprechenden Filter, um deine anderen Chats zu sehen" + "Du hast noch keine Chats als Favorit markiert" + "Einladungen" + "Du hast keine ausstehenden Einladungen." + "Niedrige Priorität" + "Du hast noch keine Chats mit niedriger Priorität." + "Wähle Filter ab, um Chats zu sehen." + "Für diese Auswahl hast du keinen Chat." + "Personen" + "Du hast noch keine Direktnachrichten" + "Gruppen" + "Du bist noch in keinem Chat" + "Ungelesen" + "Glückwunsch! +Du hast keine ungelesenen Nachrichten!" + "Beitrittsanfrage geschickt" + "Chats" + "Als gelesen markieren" + "Als ungelesen markieren" + "Die Chat-Version wurde aktualisiert" + "Es sieht aus, als würdest du ein neues Gerät verwenden. Verifiziere es mit einem anderen Gerät, damit du auf deine verschlüsselten Nachrichten zugreifen kannst." + "Verifiziere deine Identität" + diff --git a/features/home/impl/src/main/res/values-el/translations.xml b/features/home/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..df49284 --- /dev/null +++ b/features/home/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,50 @@ + + + "Απενεργοποίησε τη βελτιστοποίηση μπαταρίας για αυτήν την εφαρμογή, για να βεβαιωθείς ότι λαμβάνονται όλες οι ειδοποιήσεις." + "Απενεργοποίηση βελτιστοποίησης" + "Δεν φτάνουν οι ειδοποιήσεις;" + "Δημιούργησε ένα νέο κλειδί ανάκτησης που μπορεί να χρησιμοποιηθεί για την επαναφορά του ιστορικού των κρυπτογραφημένων μηνυμάτων σου σε περίπτωση που χάσεις την πρόσβαση στις συσκευές σου." + "Ρύθμιση ανάκτησης" + "Ρύθμιση ανάκτησης" + "Επιβεβαίωσε το κλειδί ανάκτησης για να διατηρήσεις την πρόσβαση στο χώρο αποθήκευσης κλειδιών και στο ιστορικό μηνυμάτων." + "Εισήγαγε το κλειδί ανάκτησης" + "Ξέχασες το κλειδί ανάκτησης;" + "Ο χώρος αποθήκευσης κλειδιών σου δεν είναι συγχρονισμένος" + "Για να διασφαλίσετε ότι δεν θα χάσετε ποτέ μια σημαντική κλήση, αλλάξτε τις ρυθμίσεις σας ώστε να επιτρέπονται οι ειδοποιήσεις πλήρους οθόνης όταν το τηλέφωνό σας είναι κλειδωμένο." + "Βελτίωσε την εμπειρία κλήσεων" + "Συνομιλίες" + "Σίγουρα θες να απορρίψεις την πρόσκληση συμμετοχής στο %1$s;" + "Απόρριψη πρόσκλησης" + "Σίγουρα θες να απορρίψεις την ιδιωτική συνομιλία με τον χρήστη %1$s;" + "Απόρριψη συνομιλίας" + "Χωρίς προσκλήσεις" + "%1$s (%2$s) σέ προσκάλεσε" + "Αυτή είναι μια εφάπαξ διαδικασία, ευχαριστώ που περίμενες." + "Ρύθμιση του λογαριασμού σου." + "Δημιουργία νέας συνομιλίας ή αίθουσας" + "Ξεκίνησε στέλνοντας μηνύματα σε κάποιον." + "Δεν υπάρχουν συνομιλίες ακόμα." + "Αγαπημένα" + "Μπορείς να προσθέσεις μια συνομιλία στα αγαπημένα σου στις ρυθμίσεις συνομιλίας. +Προς το παρόν, μπορείς να καταργήσεις την επιλογή φίλτρων για να δεις τις άλλες συνομιλίες σου" + "Δεν έχεις ακόμα αγαπημένες συνομιλίες" + "Προσκλήσεις" + "Δεν έχεις εκκρεμείς προσκλήσεις." + "Χαμηλής Προτεραιότητας" + "Μπορείς να καταργήσεις την επιλογή φίλτρων για να δεις τις άλλες συνομιλίες σου" + "Δεν έχεις συνομιλίες για αυτήν την επιλογή" + "Άτομα" + "Δεν έχεις ακόμα ΠΜ" + "Αίθουσες" + "Δεν είστε ακόμα σε κάποια αίθουσα" + "Μη αναγνωσμένα" + "Συγχαρητήρια! +Δεν έχεις μη αναγνωσμένα μηνύματα!" + "Το αίτημα συμμετοχής στάλθηκε" + "Συνομιλίες" + "Επισήμανση ως αναγνωσμένου" + "Επισήμανση ως μη αναγνωσμένου" + "Αυτή η αίθουσα έχει αναβαθμιστεί" + "Φαίνεται ότι χρησιμοποιείς μια νέα συσκευή. Επαλήθευσε με άλλη συσκευή για πρόσβαση στα κρυπτογραφημένα σου μηνύματα." + "Επαλήθευσε ότι είσαι εσύ" + diff --git a/features/home/impl/src/main/res/values-en-rUS/translations.xml b/features/home/impl/src/main/res/values-en-rUS/translations.xml new file mode 100644 index 0000000..fe1100a --- /dev/null +++ b/features/home/impl/src/main/res/values-en-rUS/translations.xml @@ -0,0 +1,9 @@ + + + "Disable battery optimization for this app, to make sure all notifications are received." + "Disable optimization" + "Favorites" + "You can add a chat to your favorites in the chat settings. +For now, you can deselect filters in order to see your other chats" + "You don’t have favorite chats yet" + diff --git a/features/home/impl/src/main/res/values-es/translations.xml b/features/home/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..77e52fc --- /dev/null +++ b/features/home/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,46 @@ + + + "Recupera tu identidad criptográfica y tu historial de mensajes con una clave de recuperación si has perdido todos tus dispositivos actuales." + "Configurar la recuperación" + "Configura la recuperación para proteger tu cuenta" + "Confirma tu clave de recuperación para mantener el acceso a tu almacén de claves y al historial de mensajes." + "Introduce tu clave de recuperación" + "¿Olvidaste tu clave de recuperación?" + "Tu almacén de claves no está sincronizado" + "Para asegurarte de que nunca te pierdas una llamada importante, modifica tus ajustes para permitir notificaciones a pantalla completa cuando el teléfono esté bloqueado." + "Mejora tu experiencia de llamada" + "Chats" + "¿Estás seguro de que quieres rechazar la invitación a unirte a %1$s?" + "Rechazar la invitación" + "¿Estás seguro de que quieres rechazar este chat privado con %1$s?" + "Rechazar el chat" + "Sin invitaciones" + "%1$s (%2$s) te invitó" + "Este proceso solo se hace una vez, gracias por esperar." + "Configura tu cuenta" + "Crear una nueva conversación o sala" + "Empieza enviando un mensaje a alguien." + "Aún no hay chats." + "Favoritos" + "Puedes añadir un chat a tus favoritos en la configuración del chat. +Por ahora, puedes deseleccionar los filtros para ver tus otros chats" + "Aún no tienes chats favoritos" + "Invitaciones" + "No tienes ninguna invitación pendiente." + "Prioridad baja" + "Puedes deseleccionar filtros para ver tus otros chats." + "No tienes chats para esta selección" + "Personas" + "Todavía no tienes ningún mensaje directo" + "Salas" + "Todavía no estás en ninguna sala" + "No leídos" + "¡Felicidades! +¡No tienes ningún mensaje sin leer!" + "Solicitud de unión enviada" + "Chats" + "Marcar como leído" + "Marcar como no leído" + "Parece que estás usando un nuevo dispositivo. Verifica que eres tú para acceder a tus mensajes cifrados." + "Verifica que eres tú" + diff --git a/features/home/impl/src/main/res/values-et/translations.xml b/features/home/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..d5b6fa0 --- /dev/null +++ b/features/home/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,55 @@ + + + "Kui tahad olla kindel, et näed õigel ajal kõiki teavitusi, siis palun lülita akukasutuse optimeerimine välja." + "Lülita akukasutuse optimeerimine välja" + "Sa ei näe kõiki teavitusi?" + "Sinu nutiseadme teavituste heli on uuenenud - see on nüüd selgem, kiirem ja vähem häiriv." + "Oleme sinu helisid värskendanud" + "Loo uus taastevõti, mida saad kasutada oma krüptitud sõnumite ajaloo taastamisel olukorras, kus kaotad ligipääsu oma seadmetele." + "Seadista andmete taastamine" + "Seadista taastamine" + "Säilitamaks ligipääsu vestluste ja krüptovõtmete varukoopiale, palun sisesta kinnituseks oma taastevõti." + "Sisesta oma taastevõti" + "Kas unustasid oma taastevõtme?" + "Sinu võtmehoidla pole sünkroonis" + "Selleks, et sul ainsamgi tähtis kõne ei jääks märkamata, siis palun muuda oma nutiseadme seadistusi nii, et lukustusvaates oleksid täisekraani mõõtu teavitused." + "Sinu tõhusad telefonikõned" + "Vestlused" + "Kogukonnad" + "Kas sa oled kindel, et soovid keelduda liitumiskutsest: %1$s?" + "Lükka kutse tagasi" + "Kas sa oled kindel, et soovid keelduda privaatsest vestlusest kasutajaga %1$s?" + "Keeldu vestlusest" + "Kutseid pole" + "%1$s (%2$s) saatis sulle kutse" + "Tänud, et ootad - seda toimingut on vaja teha vaid üks kord." + "Seadistame sinu kasutajakontot." + "Loo uus vestlus või jututuba" + "Tühjenda filtrid" + "Alustamiseks saada kellelegi sõnum." + "Veel pole vestlusi." + "Lemmikud" + "Vestluse seadistusest saad ta määrata lemmikuks. +Aga seni… oma teiste vestluste nägemiseks pead eemaldama filtrid" + "Sul veel pole lemmikvestlusi" + "Kutsed" + "Sul pole ootel kutseid." + "Vähetähtis" + "Sul pole veel ühtegi olulist vestlust" + "Oma teiste vestluste nägemiseks sa pead filtrid eemaldama" + "Selle valiku jaoks sul veel pole vestlusi" + "Inimesed" + "Sul pole veel otsevestlusi" + "Jututoad" + "Sa veel ei osale mitte üheski jututoas" + "Lugemata" + "Õnnitleme! +Sul pole ühtegi lugemata sõnumit!" + "Liitumispalve on saadetud" + "Vestlused" + "Märgi loetuks" + "Märgi mitteloetuks" + "See jututuba on uuendatud" + "Tundub, et kasutad uut seadet. Oma krüptitud sõnumite lugemiseks verifitseeri ta mõne muu oma seadmega." + "Verifitseeri, et see oled sina" + diff --git a/features/home/impl/src/main/res/values-eu/translations.xml b/features/home/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..b1eaf77 --- /dev/null +++ b/features/home/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,46 @@ + + + "Desgaitu bateriaren optimizazioa aplikazio honentzat, ziurtatzeko jakinarazpen guztiak jasoko direla." + "Desgaitu optimizazioa" + "Jakinarazpenak ez dira iristen?" + "Konfiguratu berreskurapena" + "Sartu zure berreskuratze-gakoa" + "Berreskuratze-gakoa ahaztu al duzu?" + "Dei garrantzitsurik galduko ez duzula ziurtatzeko, aldatu ezarpenak telefonoa blokeatuta dagoenean pantaila osoko jakinarazpenak baimentzeko." + "Hobetu deien esperientzia" + "Txatak" + "Ziur %1$s(e)ra batzeko gonbidapena baztertu nahi duzula?" + "Baztertu gonbidapena" + "Ziur %1$s(r)en txat pribatua baztertu nahi duzula?" + "Baztertu txata" + "Ez dago gonbidapenik" + "%1$s(e)k (%2$s) gonbidatu zaitu" + "Behin egin beharreko prozesua da; eskerrik asko itxaroteagatik." + "Zure kontua konfiguratzen." + "Sortu elkarrizketa edo gela berria" + "Garbitu iragazkiak" + "Hasi norbaiti mezuak bidaltzen." + "Oraindik ez dago txatik." + "Gogokoak" + "Txatak gogokoetara gehi dezakezu txaten ezarpenetan. +Oraingoz, iragazkiak desautatu ditzakezu zure gainerako txatak ikusteko" + "Oraindik ez duzu gogoko txatik" + "Gonbidapenak" + "Ez duzu gonbidapenik zain." + "Lehentasun baxua" + "Iragazkiak desautatu ditzakezu gainerako txatak ikusteko" + "Ez duzu hautaketa betetzen duen txatik" + "Jendea" + "Oraindik ez duzu Mezu Pribaturik" + "Gelak" + "Oraindik ez zaude inolako gelatan" + "Irakurri gabeak" + "Bejondeizula! +Ez duzu irakurri gabeko mezurik!" + "Sartzeko eskaera bidali da" + "Txatak" + "Markatu irakurritzat" + "Markatu irakurri gabetzat" + "Gailu berri bat erabiltzen ari zarela dirudi. Egiaztatu beste gailu batekin enkriptatutako mezuak atzitzeko." + "Egiaztatu zu zarela" + diff --git a/features/home/impl/src/main/res/values-fa/translations.xml b/features/home/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..aec5030 --- /dev/null +++ b/features/home/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,48 @@ + + + "از کار انداختن بهینه‌سازی باتری برای این کاره برای اطمینان از گرفتن همهٔ آگاهی‌ها." + "از کار انداختن بهینه سازی" + "آگاهی‌ها نمی‌رسند؟" + "بازگردانی تاریخچهٔ پیام‌ها و هویت رمزنگاشته‌تان با کلید بازیابی در صورت از دست دادن همهٔ افزاره‌های موجودتان." + "برپایی بازیابی" + "برپایی بازیابی" + "کلید بازیابی خود را تأیید کنید تا دسترسی به حافظه کلیدها و تاریخچه پیام‌هایتان حفظ شود ." + "ورود کلید بازیابیتان" + "ذخیره‌ساز کلیدتان از هم‌گام بودن در آمده" + "بهبود تجریهٔ تماستان" + "گپ‌ها" + "فضاها" + "مطمئنید که می‌خواهید دعوت پیوستن به %1$s را رد کنید؟" + "رد دعوت" + "مطمئنید که می‌خواهید این گپ خصوصی با %1$s را رد کنید؟" + "رد گپ" + "بدون دعوت" + "%1$s (%2$s) دعوتتان کرد" + "فرایندی یک باره است. ممنون از شکیباییتان." + "برپایی حسابتان." + "ایجاد اتاق یا گفت‌وگویی جدید" + "پاک کردن پالایه‌ها" + "آغاز با پیام دادن به کسی." + "هنوز گپی وجود ندارد." + "علاقه‌مندی‌ها" + "هنوز هیچ گپ مورد علاقه‌ای ندارید" + "دعوت‌ها" + "هیچ دعوت منتظری ندارید." + "اولویت کم" + "می توانید پالایه‌ها را برای دیدن دیگر گپ‌هایتان بردارید" + "هیچ گپی برای این گزینش ندارید" + "افراد" + "هنوز هیچ پیام مستقیمی ندارید" + "اتاق‌ها" + "هنوز در هیچ اتاقی نیستید" + "نخوانده‌ها" + "تبریک! +هیچ پیام نخوانده‌ای ندارید!" + "درخواست پیوستن فرستاده شد" + "گپ‌ها" + "علامت‌گذاری به عنوان خوانده شده" + "نشان به ناخوانده" + "این اتاق ارتقا یافته" + "گویا از افزاره‌ای جدید استفاده می‌کنید. تأیید با افزاره‌ای دیگر برای دسترسی به پیام‌های رمزنگاری شده‌تان." + "تأیید کنید که خودتانید" + diff --git a/features/home/impl/src/main/res/values-fi/translations.xml b/features/home/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..fa7150b --- /dev/null +++ b/features/home/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,55 @@ + + + "Ota tämän sovelluksen akunkäytön optimointi pois käytöstä varmistaaksesi, että kaikki ilmoitukset tulevat perille." + "Ota optimointi pois käytöstä" + "Eikö ilmoitukset tule perille?" + "Ilmoitusääni on päivitetty — selkeämpi, nopeampi ja vähemmän häiritsevä." + "Olemme päivittäneet äänesi" + "Palauta kryptografinen identiteettisi ja viestihistoriasi palautusavaimella, mikäli menetät pääsyn kaikkiin laitteisiisi." + "Ota palautus käyttöön" + "Ota palautus käyttöön tilisi suojaamiseksi" + "Vahvista palautusavaimesi, jotta pääset edelleen käyttämään avainten säilytystä ja viestihistoriaa." + "Syötä palautusavaimesi" + "Unohditko palautusavaimesi?" + "Avainten säilytys ei ole synkronoitu" + "Salli koko näytön ilmoitukset, kun laite on lukittu, jos et halua koskaan missata tärkeää puhelua." + "Paranna puhelukokemustasi" + "Keskustelut" + "Tilat" + "Haluatko varmasti hylätä kutsun liittyä %1$s -huoneeseen?" + "Hylkää kutsu" + "Haluatko varmasti hylätä kutsun yksityiseen keskusteluun käyttäjän %1$s kanssa?" + "Hylkää keskustelu" + "Ei kutsuja" + "%1$s (%2$s) kutsui sinut" + "Tämä on kertaluonteinen prosessi, kiitos odottamisesta." + "Tiliä määritetään." + "Luo uusi keskustelu tai huone" + "Tyhjennä suodattimet" + "Aloita lähettämällä viesti jollekin." + "Sinulla ei ole vielä keskusteluja." + "Suosikit" + "Voit lisätä keskustelun suosikkeihisi keskustelun asetuksissa. +Toistaiseksi voit poistaa suodattimien valinnan, jotta näet muut keskustelut." + "Sinulla ei ole vielä suosikkikeskusteluja" + "Kutsut" + "Sinulla ei ole yhtään odottavaa kutsua." + "Matala prioriteetti" + "Sinulla ei ole vielä yhtään matalan prioriteetin keskustelua" + "Voit poistaa suodattimien valinnan nähdäksesi muut keskustelusi." + "Sinulla ei ole sopivia keskusteluja tähän valintaan" + "Ihmiset" + "Sinulla ei ole vielä yhtään yksityisviestiä" + "Huoneet" + "Et ole vielä missään huoneessa" + "Lukemattomat" + "Onnittelut! +Sinulla ei ole lukemattomia viestejä!" + "Liittymispyyntö lähetetty" + "Keskustelut" + "Merkitse luetuksi" + "Merkitse lukemattomaksi" + "Tämä huone on päivitetty" + "Vaikuttaisi siltä, että käytät uutta laitetta. Vahvista toisella laitteella nähdäksesi salatut viestit." + "Vahvista, että se olet sinä" + diff --git a/features/home/impl/src/main/res/values-fr/translations.xml b/features/home/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..d54e8fb --- /dev/null +++ b/features/home/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,55 @@ + + + "Désactivez l’optimisation de la batterie pour cette application afin de vous assurer que toutes les notifications sont reçues." + "Désactiver l’optimisation" + "Ils vous manque des notifications?" + "Le son des notifications a été modifié: plus clair, plus court et moins perturbateur." + "Nous avons rafraîchi les sons" + "Générez une nouvelle clé de récupération qui peut être utilisée pour restaurer l’historique de vos messages chiffrés au cas où vous perdriez l’accès à vos appareils." + "Configurer la sauvegarde" + "Configurer la récupération" + "Confirmez votre clé de récupération pour conserver l’accès à votre stockage de clés et à l’historique des messages." + "Saisissez votre clé de récupération" + "Clé de récupération oubliée ?" + "Le stockage de vos clés n’est pas synchronisé" + "Afin de ne jamais manquer un appel important, veuillez modifier vos paramètres pour autoriser les notifications en plein écran lorsque votre appareil est verrouillé." + "Améliorez votre expérience d’appel" + "Conversations" + "Espaces" + "Êtes-vous sûr de vouloir décliner l’invitation à rejoindre %1$s ?" + "Refuser l’invitation" + "Êtes-vous sûr de vouloir refuser cette discussion privée avec %1$s ?" + "Refuser l’invitation" + "Aucune invitation" + "%1$s (%2$s) vous a invité(e)" + "Il s’agit d’une opération ponctuelle, merci d’attendre quelques instants." + "Configuration de votre compte." + "Créer une nouvelle discussion ou un nouveau salon" + "Supprimer les filtres" + "Commencez par envoyer un message à quelqu’un." + "Aucune discussion pour le moment." + "Favoris" + "Vous pouvez ajouter une discussion aux favoris depuis les paramètres de la discussion. +En attendant, vous pouvez désélectionner des filtres pour voir vos autres salons." + "Vous n’avez pas encore de discussions favorites" + "Invitations" + "Vous n’avez aucune invitation en attente." + "Priorité basse" + "Vous n’avez pas encore de salon à priorité basse" + "Veuillez désélectionner des filtres pour voir vos discussions" + "Vous n’avez pas de discussions pour cette sélection" + "Personnes" + "Vous n’avez pas encore de discussions" + "Salons" + "Vous n’êtes membre d’aucun salon" + "Non-lus" + "Félicitations ! +Vous n’avez plus de messages non-lus !" + "Demande de rejoindre le salon envoyée" + "Conversations" + "Marquer comme lu" + "Marquer comme non lu" + "Ce salon a été mis à niveau." + "Il semblerait que vous utilisiez un nouvel appareil. Vérifiez la session avec un autre de vos appareils pour accéder à vos messages chiffrés." + "Vérifier que c’est bien vous" + diff --git a/features/home/impl/src/main/res/values-hu/translations.xml b/features/home/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..cca9279 --- /dev/null +++ b/features/home/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,55 @@ + + + "Kapcsolja ki az alkalmazás akkumulátoroptimalizálását, hogy biztosan megkapja az összes értesítést." + "Optimalizálás letiltása" + "Nem érkeznek meg az értesítések?" + "Értesítési hangja frissült – tisztább, gyorsabb és kevésbé zavaró lett." + "Frissítettük a hangokat" + "Hozzon létre egy új helyreállítási kulcsot, amellyel visszaállíthatja a titkosított üzenetek előzményeit, ha elveszíti az eszközökhöz való hozzáférést." + "Helyreállítás beállítása" + "Helyreállítás beállítása a fiókja védelméhez" + "Erősítse meg a helyreállítási kulcsát, hogy továbbra is hozzáférjen a kulcstárolójához és az üzenetelőzményekhez." + "Adja meg a helyreállítási kulcsot" + "Elfelejtette a helyreállítási kulcsot?" + "A kulcstároló nincs szinkronizálva" + "Hogy sose maradjon le egyetlen fontos hívásról sem, a beállításokban engedélyezze a teljes képernyős értesítéseket, amikor a telefon zárolva van." + "Fokozza a hívásélményét" + "Összes csevegés" + "Terek" + "Biztos, hogy elutasítja a meghívást, hogy csatlakozzon ehhez: %1$s?" + "Meghívás elutasítása" + "Biztos, hogy elutasítja ezt a privát csevegést vele: %1$s?" + "Csevegés elutasítása" + "Nincsenek meghívások" + "%1$s (%2$s) meghívta" + "Ez egy egyszeri folyamat, köszönjük a türelmét." + "A fiók beállítása." + "Új beszélgetés vagy szoba létrehozása" + "Szűrők törlése" + "Kezdje azzal, hogy üzenetet küld valakinek." + "Még nincsenek csevegések." + "Kedvencek" + "A csevegési beállításokban csevegéseket adhat hozzá a kedvencekhez. +Egyelőre törölheti a szűrőket a többi csevegés megtekintéséhez." + "Még nincsenek kedvenc csevegései" + "Meghívások" + "Nincsenek függőben lévő meghívásai." + "Alacsony prioritás" + "Még nincsenek alacsony prioritású csevegései" + "Kikapcsolhatja a szűrőket a többi csevegés megtekintéséhez" + "Ehhez a kiválasztáshoz nem tartoznak csevegések" + "Emberek" + "Még nincsenek privát üzenetei" + "Szobák" + "Még nincs egy szobában sem" + "Olvasatlan" + "Gratulálunk! +Nincs olvasatlan üzenete!" + "Csatlakozási kérés elküldve" + "Összes csevegés" + "Megjelölés olvasottként" + "Megjelölés olvasatlanként" + "A szoba verzióját frissítették" + "Úgy tűnik, hogy új eszközt használ. Ellenőrizze egy másik eszközzel, hogy a továbbiakban elérje a titkosított üzeneteket." + "Ellenőrizze, hogy Ön az" + diff --git a/features/home/impl/src/main/res/values-in/translations.xml b/features/home/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..924b019 --- /dev/null +++ b/features/home/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,51 @@ + + + "Nonaktifkan pengoptimalan baterai untuk aplikasi ini, untuk memastikan semua notifikasi diterima." + "Nonaktifkan optimasi" + "Notifikasi tidak masuk?" + "Buat kunci pemulihan baru yang dapat digunakan untuk memulihkan riwayat pesan terenkripsi Anda jika Anda kehilangan akses ke perangkat Anda." + "Siapkan pemulihan" + "Siapkan pemulihan" + "Konfirmasikan kunci pemulihan Anda untuk mempertahankan akses ke penyimpanan kunci dan riwayat pesan Anda." + "Masukkan kunci pemulihan Anda" + "Lupa kunci pemulihan Anda?" + "Penyimpanan kunci Anda tidak sinkron" + "Untuk memastikan Anda tidak melewatkan panggilan penting, silakan ubah pengaturan Anda untuk memperbolehkan notifikasi layar penuh ketika ponsel Anda terkunci." + "Tingkatkan pengalaman panggilan Anda" + "Semua Obrolan" + "Apakah Anda yakin ingin menolak undangan untuk bergabung ke %1$s?" + "Tolak undangan" + "Apakah Anda yakin ingin menolak obrolan pribadi dengan %1$s?" + "Tolak obrolan" + "Tidak ada undangan" + "%1$s (%2$s) mengundang Anda" + "Ini adalah proses satu kali, terima kasih telah menunggu." + "Menyiapkan akun Anda." + "Buat percakapan atau ruangan baru" + "Hapus filter" + "Mulailah dengan mengirim pesan kepada seseorang." + "Belum ada obrolan." + "Favorit" + "Anda dapat menambahkan percakapan ke favorit Anda dalam pengaturan percakapan. +Untuk sementara, Anda dapat membatalkan pilihan saringan untuk melihat percakapan Anda yang lain" + "Anda belum memiliki percakapan favorit" + "Undangan" + "Anda tidak memiliki undangan yang tertunda." + "Prioritas Rendah" + "Anda dapat membatalkan pilihan saringan untuk melihat percakapan Anda yang lain" + "Anda tidak memiliki percakapan untuk pemilihan ini" + "Orang" + "Anda belum memiliki percakapan langsung" + "Ruangan" + "Anda belum berada dalam ruangan" + "Belum dibaca" + "Selamat! +Anda tidak memiliki pesan yang belum dibaca!" + "Permintaan untuk bergabung dikirim" + "Semua Obrolan" + "Tandai sebagai dibaca" + "Tandai sebagai belum dibaca" + "Ruangan ini telah ditingkatkan" + "Sepertinya Anda menggunakan perangkat baru. Verifikasi dengan perangkat lain untuk mengakses pesan terenkripsi Anda selanjutnya." + "Verifikasi bahwa ini Anda" + diff --git a/features/home/impl/src/main/res/values-it/translations.xml b/features/home/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..932545c --- /dev/null +++ b/features/home/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,55 @@ + + + "Disabilita l\'ottimizzazione della batteria per questa app, per assicurarti che tutte le notifiche vengano ricevute." + "Disabilita l\'ottimizzazione" + "Le notifiche non arrivano?" + "Il ping delle notifiche è stato aggiornato: ora è più chiaro, più rapido e meno fastidioso." + "Abbiamo rinnovato i tuoi suoni" + "Recupera la tua identità crittografica e la cronologia dei messaggi con una chiave di recupero se hai perso tutti i tuoi dispositivi." + "Configura il recupero" + "Configura il ripristino" + "Conferma la chiave di recupero per mantenere l\'accesso all\'archiviazione delle chiavi e alla cronologia dei messaggi." + "Inserisci la tua chiave di recupero" + "Hai dimenticato la chiave di recupero?" + "L\'archiviazione delle chiavi non è sincronizzata" + "Per non perdere mai una chiamata importante, modifica le impostazioni per consentire le notifiche a schermo intero quando il telefono è bloccato." + "Migliora la tua esperienza di chiamata" + "Tutte le conversazioni" + "Spazi" + "Vuoi davvero rifiutare l\'invito ad entrare in %1$s?" + "Rifiuta l\'invito" + "Vuoi davvero rifiutare questa conversazione privata con %1$s?" + "Rifiuta l\'invito alla conversazione" + "Nessun invito" + "%1$s (%2$s) ti ha invitato" + "Si tratta di una procedura che si effettua una sola volta, grazie per l\'attesa." + "Configurazione del tuo account." + "Crea una nuova conversazione o stanza" + "Elimina filtri" + "Inizia inviando un messaggio a qualcuno." + "Ancora nessuna conversazione." + "Preferiti" + "Puoi aggiungere una conversazione ai tuoi preferiti nelle impostazioni della stessa. +Per il momento, puoi deselezionare i filtri per vedere le altre conversazioni." + "Non hai ancora conversazioni preferite" + "Inviti" + "Non hai nessun invito in sospeso." + "Bassa priorità" + "Non hai ancora conversazioni a bassa priorità" + "Puoi deselezionare i filtri per vedere le altre conversazioni." + "Non hai conversazioni per questa selezione" + "Persone" + "Non hai ancora nessuna conversazione diretta" + "Stanze" + "Non sei ancora in nessuna stanza" + "Non letti" + "Congratulazioni! +Non hai messaggi non letti!" + "Richiesta di accesso inviata" + "Tutte le conversazioni" + "Segna come letto" + "Segna come non letto" + "Questa stanza è stata aggiornata" + "Sembra che tu stia usando un nuovo dispositivo. Verificati con un altro dispositivo per accedere ai tuoi messaggi cifrati." + "Verifica che sei tu" + diff --git a/features/home/impl/src/main/res/values-ka/translations.xml b/features/home/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..1aa5b2d --- /dev/null +++ b/features/home/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,38 @@ + + + "აღდგენის დაყენება" + "დაადასტურეთ თქვენი აღდგენის გასაღები რათა გქონდეთ წვდომა გასაღებების დამგროვებელთან და შეტყობინებების ისტორიასთან." + "თქვენი გასაღების დამგროვებელი არაა სინქრონიზებული" + "ჩატები" + "დარწმუნებული ხართ, რომ გსურთ, უარი თქვათ მოწვევაზე %1$s-ში?" + "მოწვევაზე უარის თქმა" + "დარწმუნებული ხართ, რომ გსურთ, უარი თქვათ ჩატზე %1$s-თან?" + "ჩატზე უარის თქვა" + "მოწვევები არ არის" + "%1$s (%2$s) მოგიწვიათ" + "ეს არის ერთჯერადი პროცესი, მადლობა ლოდინისთვის." + "თქვენი ანგარიშის კონფიგურაცია" + "ახალი საუბრისა ან ოთახის შექმნა" + "დაიწყეთ ვინმესთვის შეტყობინების გაგზავნით." + "არც ერთი ჩატი ჯერ არაა." + "რჩეულები" + "თქვენ შეგიძლიათ ოთახის რჩეულებში დამატება ოთახების პარამეტრებში. +ახლა კი შეგიძლიათ ფილტრების მოხსნა სხვა ოთახების გამოსაჩენად" + "თქვენ ჯერ არ გაქვთ რჩეული ჩატები" + "მოწვევები" + "დაბალი პრიორიტეტი" + "თქვენ შეგიძლიათ წაშალოთ ფილტრები სხვა ჩეთების გამოსაჩენად" + "თქვენ არ გაქვთ ოთახები ამ არჩევნისთვის" + "ხალხი" + "თქვენ ჯერ არ გაქვთ პირადი შეტყობინებები" + "ოთახები" + "თქვენ ჯერ არც ერთ ოთახში არ ხართ" + "წაუკითხავი" + "გილოცავთ! +თქვენ არ გაქვთ წაუკითხავი შეტყობინებები!" + "ჩატები" + "წაკითხულად მონიშვნა" + "წაუკითხავად მონიშვნა" + "როგორც ჩანს, ახალ მოწყობილობას იყენებთ. დაადასტურეთ სხვა მოწყობილობით თქვენს დაშიფრულ შეტყობინებებზე წვდომისთვის." + "დაადასტურეთ, რომ ეს თქვენ ხართ" + diff --git a/features/home/impl/src/main/res/values-ko/translations.xml b/features/home/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..de823b9 --- /dev/null +++ b/features/home/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,53 @@ + + + "이 앱의 배터리 최적화를 비활성화하여 모든 알림이 정상적으로 수신되도록 합니다." + "최적화 비활성화" + "알림이 도착하지 않나요?" + "기존의 모든 기기를 분실한 경우 복구 키를 사용하여 암호화된 ID 및 메시지 기록을 복구할 수 있습니다." + "복구 설정" + "계정을 보호하기 위해 복구를 설정하세요" + "키 저장소 및 메시지 기록에 대한 액세스를 유지하려면 복구 키를 확인하세요." + "복구 키를 입력하세요" + "복구 키를 잊으셨나요?" + "귀하의 키 저장소가 동기화되지 않았습니다" + "중요한 전화를 놓치지 않으려면 휴대폰이 잠겨 있을 때 전체 화면 알림을 허용하도록 설정을 변경하세요." + "통화 경험을 향상시키세요" + "채팅" + "스페이스" + "정말로 %1$s 에 참가하지 않고 초대를 거절하시겠어요?" + "초대 거절" + "%1$s 와의 비공개 채팅을 정말 거부하시겠습니까?" + "채팅 거절" + "초대 없음" + "%1$s (%2$s) 당신을 초대했습니다" + "이 과정은 한 번만 진행됩니다, 기다려 주셔서 감사합니다." + "계정 설정하기" + "새로운 대화 또는 방 만들기" + "필터 지우기" + "누군가에게 메시지를 보내어 시작해 보세요." + "아직 채팅이 없습니다." + "즐겨찾기" + "채팅 설정에서 채팅을 즐겨찾기에 추가할 수 있습니다. +현재는 다른 채팅을 보려면 필터를 선택 해제해야 합니다." + "아직 즐겨찾는 채팅이 없습니다." + "초대" + "보류 중인 초대가 없습니다." + "낮은 우선순위" + "아직 낮은 우선순위 채팅이 없습니다." + "다른 채팅을 보려면 필터 선택을 해제하세요." + "이 선택 항목에 대한 채팅이 없습니다." + "사람" + "아직 DM이 없습니다." + "방" + "아직 어떤 방에도 있지 않습니다." + "읽지 않은 항목" + "축하합니다! +읽지 않은 메시지가 없습니다!" + "가입 요청이 전송되었습니다" + "채팅" + "읽음으로 표시" + "읽지 않음으로 표시" + "이 방이 업그레이드되었습니다" + "새 장치를 사용 중인 것 같습니다. 다른 디바이스로 인증하여 암호화된 메시지에 액세스하세요." + "본인인지 확인하세요" + diff --git a/features/home/impl/src/main/res/values-lt/translations.xml b/features/home/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..03771f7 --- /dev/null +++ b/features/home/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,16 @@ + + + "Pokalbiai" + "Ar tikrai norite atmesti kvietimą prisijungti prie %1$s?" + "Atmesti kvietimą" + "Ar tikrai norite atmesti šį privatų pokalbį su %1$s ?" + "Atmesti pokalbį" + "Jokių kvietimų" + "%1$s(%2$s) pakvietė Jus" + "Sukurti naują pokalbį arba kambarį" + "Kvietimai" + "Žmonės" + "Pokalbiai" + "Panašu, kad naudojate naują įrenginį. Patvirtinkite naudodami kitą įrenginį, kad galėtumėte pasiekti savo šifruotas žinutes." + "Patvirtinkite, kad tai Jūs" + diff --git a/features/home/impl/src/main/res/values-nb/translations.xml b/features/home/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..4221644 --- /dev/null +++ b/features/home/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,55 @@ + + + "Deaktiver batterioptimalisering for denne appen for å sikre at alle varsler mottas." + "Deaktiver optimalisering" + "Kommer ikke varslene frem?" + "Varslingssignalet ditt er oppdatert – tydeligere, raskere og mindre forstyrrende." + "Vi har oppdatert lydene dine" + "Gjenopprett din kryptografiske identitet og meldingshistorikk med en gjenopprettingsnøkkel hvis du har mistet alle dine brukte enheter." + "Konfigurer gjenoppretting" + "Konfigurer gjenoppretting for å beskytte kontoen din" + "Verifiser gjenopprettingsnøkkelen for å opprettholde tilgangen til nøkkellageret og meldingshistorikken." + "Skriv inn gjenopprettingsnøkkelen din" + "Har du glemt din gjenopprettingsnøkkel?" + "Nøkkellagringen din er ikke synkronisert" + "For å sikre at du aldri går glipp av en viktig samtale, må du endre innstillingene dine for å tillate fullskjermvarsler når telefonen er låst." + "Forbedre samtaleopplevelsen din" + "Chatter" + "Områder" + "Er du sikker på at du vil takke nei til invitasjonen til å bli med i %1$s?" + "Avvis invitasjon" + "Er du sikker på at du vil avslå denne private chatten med %1$s?" + "Avslå chat" + "Ingen invitasjoner" + "%1$s(%2$s) inviterte deg" + "Dette er en engangsprosess, takk for at du venter." + "Setter opp kontoen din." + "Opprett en ny samtale eller et nytt rom" + "Fjern filtre" + "Kom i gang med å sende meldinger til noen." + "Ingen chatter ennå." + "Favoritter" + "Du kan legge til en chat blant favorittene dine i chat-innstillingene. +Inntil videre kan du velge bort filtre for å se de andre chattene dine" + "Du har ikke favorittchatter ennå" + "Invitasjoner" + "Du har ingen ventende invitasjoner." + "Lav prioritet" + "Du har ingen lavprioriterte chatter ennå" + "Du kan velge bort filtre for å se de andre chattene dine" + "Du har ikke chatter for dette utvalget" + "Personer" + "Du har ingen DM-er ennå" + "Rom" + "Du er ikke i noe rom ennå" + "Uleste" + "Gratulerer! +Du har ingen uleste meldinger!" + "Forespørsel om å bli med sendt" + "Chatter" + "Marker som lest" + "Merk som ulest" + "Dette rommet har blitt oppgradert" + "Det ser ut til at du bruker en ny enhet. Bekreft med en annen enhet for å få tilgang til de krypterte meldingene dine." + "Bekreft at det er deg" + diff --git a/features/home/impl/src/main/res/values-nl/translations.xml b/features/home/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..b6df46e --- /dev/null +++ b/features/home/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,45 @@ + + + "Optimalisatie uitschakelen" + "Herstel je cryptografische identiteit en berichtengeschiedenis met een herstelsleutel voor als je al je bestaande apparaten kwijt bent." + "Herstelmogelijkheid instellen" + "Herstel instellen om je account te beschermen" + "Bevestig je herstelsleutel om toegang te houden tot je sleutelopslag en berichtengeschiedenis." + "Je sleutelopslag is niet gesynchroniseerd" + "Pas je instellingen aan om meldingen op het volledige scherm toe te staan wanneer de telefoon is vergrendeld. Zo mis je nooit een belangrijk gesprek." + "Verbeter je gesprekservaring" + "Chats" + "Weet je zeker dat je de uitnodiging om toe te treden tot %1$s wilt weigeren?" + "Uitnodiging weigeren" + "Weet je zeker dat je deze privéchat met %1$s wilt weigeren?" + "Chat weigeren" + "Geen uitnodigingen" + "%1$s (%2$s) heeft je uitgenodigd" + "Dit is een eenmalig proces, bedankt voor het wachten." + "Je account instellen." + "Begin een nieuw gesprek of maak een nieuwe kamer" + "Ga aan de slag door iemand een bericht te sturen." + "Nog geen chats." + "Favorieten" + "Je kunt een chat toevoegen aan je favorieten in de chatinstellingen. +Voor nu kun je filters deselecteren om je andere chats te zien" + "Je hebt nog geen favoriete chats" + "Uitnodigingen" + "Je hebt geen openstaande uitnodigingen." + "Lage prioriteit" + "Je kunt filters deselecteren om je andere chats te zien" + "Je hebt geen chats voor deze selectie" + "Personen" + "Je hebt nog geen directe chats" + "Kamers" + "Je zit nog niet in een kamer" + "Ongelezen" + "Gefeliciteerd! +Je hebt geen ongelezen berichten!" + "Verzoek om toe te treden verzonden" + "Chats" + "Markeren als gelezen" + "Markeren als ongelezen" + "Het lijkt erop dat je een nieuw apparaat gebruikt. Verifieer met een ander apparaat om toegang te krijgen tot je versleutelde berichten." + "Verifieer dat jij het bent" + diff --git a/features/home/impl/src/main/res/values-pl/translations.xml b/features/home/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..73e0b5e --- /dev/null +++ b/features/home/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,55 @@ + + + "Wyłącz optymalizację baterii dla tej aplikacji, aby upewnić się, że wszystkie powiadomienia są odbierane." + "Wyłącz optymalizację" + "Powiadomienia nie dochodzą?" + "Sygnał powiadomień został zaktualizowany — jest wyraźniejszy, szybszy i mniej uciążliwy." + "Odświeżyliśmy Twoje dźwięki" + "Wygeneruj nowy klucz przywracania, którego można użyć do przywrócenia historii wiadomości szyfrowanych w przypadku utraty dostępu do swoich urządzeń." + "Skonfiguruj przywracanie" + "Skonfiguruj przywracanie" + "Potwierdź klucz przywracania, aby zachować dostęp do magazynu kluczy i historii wiadomości." + "Wprowadź klucz przywracania" + "Zapomniałeś klucza przywracania?" + "Magazyn kluczy nie jest zsynchronizowany" + "Upewnij się, że nie pominiesz żadnego połączenia. Zmień swoje ustawienia i zezwól na powiadomienia na blokadzie ekranu." + "Popraw jakość swoich rozmów" + "Wszystkie czaty" + "Przestrzenie" + "Czy na pewno chcesz odrzucić zaproszenie dołączenia do %1$s?" + "Odrzuć zaproszenie" + "Czy na pewno chcesz odrzucić rozmowę prywatną z %1$s?" + "Odrzuć czat" + "Brak zaproszeń" + "%1$s (%2$s) zaprosił Cię" + "Jest to jednorazowy proces, dziękujemy za czekanie." + "Konfigurowanie Twojego konta." + "Utwórz nową rozmowę lub pokój" + "Wyczyść filtry" + "Wyślij komuś wiadomość, aby rozpocząć." + "Brak czatów." + "Ulubione" + "Możesz dodać czat do ulubionych w ustawieniach czatu. +Na razie możesz wyczyścić filtry, aby zobaczyć pozostałe czaty" + "Nie masz jeszcze ulubionych czatów" + "Zaproszenia" + "Nie masz żadnych oczekujących zaproszeń." + "Niski priorytet" + "Nie masz jeszcze żadnych czatów o niskim priorytecie" + "Wyczyść filtry, aby zobaczyć pozostałe czaty" + "Brak czatów dla podanych kryteriów" + "Osoby" + "Nie masz jeszcze żadnych PW" + "Pokoje" + "Nie jesteś jeszcze w żadnym pokoju" + "Nieprzeczytane" + "Gratulacje! +Nie masz żadnych nieprzeczytanych wiadomości!" + "Wysłano prośbę o dołączenie" + "Wszystkie czaty" + "Oznacz jako przeczytane" + "Oznacz jako nieprzeczytane" + "Ten pokój został ulepszony" + "Wygląda na to, że używasz nowego urządzenia. Zweryfikuj się innym urządzeniem, aby uzyskać dostęp do zaszyfrowanych wiadomości." + "Potwierdź, że to Ty" + diff --git a/features/home/impl/src/main/res/values-pt-rBR/translations.xml b/features/home/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..10f5cf9 --- /dev/null +++ b/features/home/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,55 @@ + + + "Desative a otimização de bateria para este app, para que tenha certeza que todas as notificações sejam recebidas." + "Desativar otimização" + "As notificações não chegam?" + "O seu ping de notificação foi atualizado—mais suave, mais rápido, e menos disruptivo." + "Recarregamos seus sons" + "Recupere sua identidade criptográfica e o histórico de mensagens com uma chave de recuperação caso você perda todos os dispositivos existentes." + "Configurar a recuperação" + "Configure a recuperação para proteger sua conta" + "Confirme sua chave de recuperação para manter o acesso ao seu armazenamento de chaves e histórico de mensagens." + "Digite sua chave de recuperação" + "Esqueceu sua chave de recuperação?" + "Seu armazenamento de chaves está fora de sincronia" + "Para garantir que você nunca perca uma chamada importante, por favor altere as suas configurações para permitir notificações em tela cheia enquanto o seu celular estiver bloqueado." + "Melhore a sua experiência de chamadas" + "Conversas" + "Espaços" + "Tem certeza de que deseja recusar o convite para entrar em %1$s?" + "Recusar convite" + "Tem certeza de que deseja recusar esse conversa privada com %1$s?" + "Recusar chat" + "Não há convites" + "%1$s(%2$s) convidou você" + "Este é um processo único, obrigado por esperar." + "Configurando sua conta." + "Criar uma nova conversa ou sala" + "Limpar filtros" + "Comece enviando uma mensagem para alguém." + "Ainda não há conversas." + "Favoritos" + "Você pode adicionar uma conversa aos seus favoritos nas configurações da conversa. +Por enquanto, você pode desmarcar os filtros para ver suas outras conversas" + "Você não tem nenhuma conversa favorita ainda" + "Convites" + "Você não tem nenhum convite pendente." + "Baixa prioridade" + "Você ainda não tem nenhuma conversa de baixa prioridade" + "Você pode desmarcar filtros para ver suas outras conversas" + "Você não tem conversas para esta seleção" + "Pessoas" + "Você não tem nenhuma conversa privada ainda" + "Salas" + "Você não está em nenhuma sala ainda" + "Não lidas" + "Parabéns! +Você não tem nenhuma mensagem não lida!" + "Pedido de entrada enviado" + "Conversas" + "Marcar como lida" + "Marcar como não lida" + "Esta sala foi atualizada" + "Parece que você está usando um novo dispositivo. Verifique com outro dispositivo para acessar suas mensagens criptografadas." + "Verifique se é você" + diff --git a/features/home/impl/src/main/res/values-pt/translations.xml b/features/home/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..6c9f5a6 --- /dev/null +++ b/features/home/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,53 @@ + + + "Desativa as otimizações de bateria para esta aplicação, de modo a garantir que todas as notificações chegam." + "Desativar otimizações" + "As notificações não chegam?" + "Recupera a tua identidade criptográfica e o histórico de mensagens com uma chave de recuperação se tiveres perdido todos os teus dispositivos existentes." + "Configurar recuperação" + "Configurar a recuperação" + "Confirma a tua chave de recuperação para manteres o acesso ao teu armazenamento de chaves e ao histórico de mensagens." + "Introduz a tua chave de recuperação" + "Esqueceste-te da tua chave de recuperação?" + "O teu armazenamento de chaves não está sincronizado" + "Para garantir que nunca perdes uma chamada importante, altera as configurações para permitir notificações em ecrã inteiro quando o telemóvel está bloqueado." + "Melhora a tua experiência de chamada" + "Conversas" + "Espaços" + "Tens a certeza que queres rejeitar o convite para entra em %1$s?" + "Rejeitar convite" + "Tens a certeza que queres rejeitar esta conversa privada com %1$s?" + "Rejeitar conversa" + "Sem convites" + "%1$s (%2$s) convidou-te" + "Este processo só acontece uma única vez, obrigado por esperares." + "A configurar a tua conta…" + "Criar uma nova conversa ou sala" + "Limpar filtros" + "Começa por enviar uma mensagem a alguém." + "Ainda não tens conversas." + "Favoritas" + "Podes adicionar uma conversa às tuas favoritas nas suas configurações. +Por enquanto, podes anular a seleção dos filtros para veres as tuas outras conversas" + "Ainda não tens nenhuma conversa favorita" + "Convites" + "Não tens nenhum convite pendente." + "Prioridade baixa" + "Ainda não tens conversas de prioridade baixa" + "Podes anular a seleção dos filtros para veres as tuas outras conversas" + "Não tens nenhuma conversa selecionada" + "Pessoas" + "Ainda não tens nenhuma MD (mensagem direta)" + "Salas" + "Ainda não estás em nenhuma sala" + "Por ler" + "Parabéns! +Não tens nenhuma mensagem por ler!" + "Pedido de adesão enviado" + "Conversas" + "Marcar como lida" + "Marcar como não lida" + "Esta sala foi atualizada" + "Parece que estás a utilizar um novo dispositivo. Verifica-o com um outro para poderes aceder às tuas mensagens cifradas." + "Verifica que és tu" + diff --git a/features/home/impl/src/main/res/values-ro/translations.xml b/features/home/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..e4a80b4 --- /dev/null +++ b/features/home/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,55 @@ + + + "Dezactivați optimizarea bateriei pentru această aplicație, pentru a vă asigura că toate notificările sunt primite." + "Dezactivați optimizarea" + "Nu primiți notificări?" + "Sunetul pentru notificări a fost actualizat — mai clar, mai rapid și mai puțin perturbatoar." + "Am reîmprospătat sunetele" + "Recuperați-vă identitatea criptografică și mesajele anterioare cu o cheie de recuperare dacă ați pierdut toate dispozitivele existente." + "Configurați recuperarea" + "Configurați recuperarea pentru a vă proteja contul" + "Backup-ul pentru chat nu este sincronizat. Trebuie să confirmați cheia de recuperare pentru a menține accesul la backup." + "Introduceți cheia de recuperare" + "Ați uitat cheia de recuperare?" + "Backup-ul nu este sincronizat" + "Pentru a vă asigura că nu pierdeți niciodată un apel important, vă rugăm să modificați setările pentru a permite notificări fullscreen atunci când telefonul este blocat." + "Îmbunătățiți-vă experiența in timpul unui apel" + "Toate conversatiile" + "Spații" + "Sigur doriți să refuzați alăturarea la %1$s?" + "Refuzați invitația" + "Sigur doriți să refuzați conversațiile cu %1$s?" + "Refuzați conversația" + "Nicio invitație" + "%1$s (%2$s) v-a invitat." + "Acesta este un proces care se desfășoară o singură dată, vă mulțumim pentru așteptare." + "Contul dumneavoastră se configurează" + "Creați o conversație sau o cameră nouă" + "Ștergeți filtrele" + "Începeți prin a trimite mesaje cuiva." + "Nu există încă discuții." + "Favorite" + "Puteți adăuga un chat la preferințele dvs. în setările de chat. +Deocamdată, puteți deselecta filtrele pentru a vedea celelalte chat-uri" + "Încă nu aveți conversații preferate" + "Invitații" + "Nu aveți invitații în așteptare." + "Prioritate scăzută" + "Nu aveți încă niciun chat cu prioritate scăzută." + "Puteți deselecta filtrele pentru a vedea celelalte chat-uri" + "Nu aveți chat-uri pentru această selecție" + "Persoane" + "Încă nu aveți DM-uri" + "Camere" + "Nu sunteți încă în nicio cameră" + "Necitite" + "Felicitari! +Nu aveți mesaje necitite!" + "Cererea de alăturare a fost trimisă" + "Toate conversatiile" + "Marcați ca citită" + "Marcați ca necitită" + "Această cameră a fost modernizată." + "Se pare că folosiți un dispozitiv nou. Verificați-vă identitatea cu un alt dispozitiv pentru a accesa mesajele dumneavoastră criptate." + "Verificați că sunteți dumneavoastră" + diff --git a/features/home/impl/src/main/res/values-ru/translations.xml b/features/home/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..a500778 --- /dev/null +++ b/features/home/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,55 @@ + + + "Выключите оптимизацию расхода батареи, чтобы убедиться, что все уведомления будут поступать." + "Выключить оптимизацию" + "Уведомления не поступают?" + "Ваши уведомления были обновлены — теперь они понятнее, быстрее и менее отвлекающие." + "Мы обновили ваши звуки" + "Создайте новый ключ восстановления, который можно использовать для восстановления зашифрованной истории сообщений в случае потери доступа к своим устройствам." + "Настроить восстановление" + "Для защиты вашего аккаунта рекомендуется настроить восстановление" + "Подтвердите ключ восстановления, чтобы сохранить доступ к хранилищу ключей и истории сообщений." + "Введите ключ восстановления" + "Забыли ключ восстановления?" + "Хранилище ключей не синхронизировано" + "Чтобы больше не пропускать важные звонки, разрешите приложению показывать полноэкранные уведомления на заблокированном экране телефона." + "Улучшите качество звонков" + "Все чаты" + "Пространства" + "Вы уверены, что хотите отклонить приглашение в %1$s?" + "Отклонить приглашение" + "Вы уверены, что хотите отказаться от личного общения с %1$s?" + "Отклонить чат" + "Нет приглашений" + "%1$s (%2$s) пригласил вас" + "Это одноразовый процесс, спасибо, что подождали." + "Настройка учетной записи." + "Создайте новую беседу или комнату" + "Очистить фильтры" + "Начните переписку с отправки сообщения." + "Пока нет доступных чатов." + "Избранное" + "Добавить чат в избранное можно в настройках чата. +На данный момент вы можете убрать фильтры, чтобы увидеть другие ваши чаты." + "У вас пока нет избранных чатов" + "Приглашения" + "У вас нет отложенных приглашений." + "Низкий приоритет" + "У вас пока нет чатов с низким приоритетом." + "Вы можете убрать фильтры, чтобы увидеть другие ваши чаты." + "У вас нет чатов для этой подборки" + "Пользователи" + "У вас пока нет личных сообщений" + "Комнаты" + "Вас пока нет ни в одной комнате" + "Непрочитанные" + "Поздравляем! +Все сообщения прочитаны!" + "Запрос на присоединение отправлен" + "Все чаты" + "Пометить как прочитанное" + "Отметить как непрочитанное" + "Эта комната была обновлена" + "Похоже, вы используете новое устройство. Чтобы получить доступ к зашифрованным сообщениям пройдите подтверждение с другим устройством." + "Подтвердите, что это вы" + diff --git a/features/home/impl/src/main/res/values-sk/translations.xml b/features/home/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..275a482 --- /dev/null +++ b/features/home/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,55 @@ + + + "Vypnite optimalizáciu batérie pre túto aplikáciu, aby ste sa uistili, že sú prijaté všetky upozornenia." + "Zakázať optimalizáciu" + "Oznámenia neprichádzajú?" + "Vaše oznámenia boli aktualizované – sú prehľadnejšie, rýchlejšie a menej rušivé." + "Obnovili sme vaše zvuky" + "Vytvorte nový kľúč na obnovenie, ktorý môžete použiť na obnovenie vašej histórie šifrovaných správ v prípade straty prístupu k vašim zariadeniam." + "Nastaviť obnovenie" + "Nastaviť obnovenie" + "Potvrďte svoj kľúč na obnovenie, aby ste zachovali prístup k úložisku kľúčov a histórii správ." + "Zadajte kľúč na obnovenie" + "Zabudli ste svoj kľúč na obnovenie?" + "Vaše úložisko kľúčov nie je synchronizované" + "Aby ste už nikdy nezmeškali dôležitý hovor, zmeňte svoje nastavenia a povoľte upozornenia na celú obrazovku, keď je váš telefón uzamknutý." + "Vylepšite svoj zážitok z hovoru" + "Všetky konverzácie" + "Priestory" + "Naozaj chcete odmietnuť pozvánku na pripojenie do %1$s?" + "Odmietnuť pozvanie" + "Naozaj chcete odmietnuť túto súkromnú konverzáciu s %1$s?" + "Odmietnuť konverzáciu" + "Žiadne pozvánky" + "%1$s (%2$s) vás pozval/a" + "Ide o jednorazový proces, ďakujeme za trpezlivosť." + "Nastavenie vášho účtu." + "Vytvorte novú konverzáciu alebo miestnosť" + "Vyčistiť filtre" + "Začnite tým, že niekomu pošlete správu." + "Zatiaľ žiadne konverzácie." + "Obľúbené" + "Môžete pridať konverzáciu medzi obľúbené v nastaveniach konverzácie. +Zatiaľ môžete zrušiť výber filtrov, aby ste videli ostatné konverzácie" + "Zatiaľ nemáte obľúbené konverzácie" + "Pozvánky" + "Nemáte žiadne čakajúce pozvánky." + "Nízka priorita" + "Zatiaľ nemáte žiadne konverzácie s nízkou prioritou." + "Môžete zrušiť výber filtrov, aby ste videli svoje ostatné konverzácie" + "Nemáte konverzácie pre tento výber" + "Ľudia" + "Zatiaľ nemáte žiadne priame správy" + "Miestnosti" + "Zatiaľ ešte nie ste v žiadnej miestnosti" + "Neprečítané" + "Gratulujeme! +Nemáte žiadne neprečítané správy!" + "Žiadosť o pripojenie bola odoslaná" + "Všetky konverzácie" + "Označiť ako prečítané" + "Označiť ako neprečítané" + "Táto miestnosť bola aktualizovaná" + "Vyzerá to tak, že používate nové zariadenie. Overte svoj prístup k zašifrovaným správam pomocou vášho druhého zariadenia." + "Overte, že ste to vy" + diff --git a/features/home/impl/src/main/res/values-sv/translations.xml b/features/home/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..d92b351 --- /dev/null +++ b/features/home/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,53 @@ + + + "Inaktivera batterioptimering för den här appen för att säkerställa att alla aviseringar tas emot." + "Inaktivera optimering" + "Aviseringar kommer inte fram?" + "Skapa en ny återställningsnyckel som kan användas för att återställa din krypterade meddelandehistorik om du förlorar åtkomst till dina enheter." + "Ställ in återställning" + "Ställ in återställning" + "Bekräfta din återställningsnyckel för att behålla åtkomsten till din nyckellagring och meddelandehistorik." + "Ange din återställningsnyckel" + "Glömt din återställningsnyckel?" + "Din nyckellagring är inte synkroniserad" + "För att säkerställa att du aldrig missar ett viktigt samtal, ändra dina inställningar för att tillåta helskärmsmeddelanden när telefonen är låst." + "Förbättra din samtalsupplevelse" + "Alla chattar" + "Utrymmen" + "Är du säker på att du vill tacka nej till inbjudan att gå med%1$s?" + "Avböj inbjudan" + "Är du säker på att du vill avböja denna privata chatt med %1$s?" + "Avböj chatt" + "Inga inbjudningar" + "%1$s (%2$s) bjöd in dig" + "Detta är en engångsprocess, tack för att du väntar." + "Konfigurerar ditt konto" + "Skapa en ny konversation eller ett nytt rum" + "Rensa filter" + "Kom igång genom att skicka meddelanden till någon." + "Inga chattar än." + "Favoriter" + "Du kan lägga till en chatt till dina favoriter i chattinställningarna. +För tillfället kan du avmarkera filter för att se dina andra chattar" + "Du har inga favoritchattar än" + "Inbjudningar" + "Du har inga väntande inbjudningar." + "Låg prioritet" + "Du har inga lågprioriterade chattar ännu" + "Du kan avmarkera filter för att se dina andra chattar" + "Du har inga chattar för det här valet" + "Personer" + "Du har inga DM:er än" + "Rum" + "Du är inte i något rum än" + "Olästa" + "Grattis! +Du har inga olästa meddelanden!" + "Begäran om att gå med skickad" + "Alla chattar" + "Markera som läst" + "Markera som oläst" + "Det här rummet har uppgraderats" + "Det verkar som om du använder en ny enhet. Verifiera med en annan enhet för att komma åt dina krypterade meddelanden." + "Verifiera att det är du" + diff --git a/features/home/impl/src/main/res/values-tr/translations.xml b/features/home/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..1dda320 --- /dev/null +++ b/features/home/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,46 @@ + + + "Mevcut tüm cihazlarınızı kaybettiyseniz şifreleme kimliğinizi ve mesaj geçmişinizi bir kurtarma anahtarıyla kurtarın." + "Kurtarmayı ayarlayın" + "Hesabınızı korumak için kurtarmayı ayarlayın" + "Anahtar depolama alanınıza ve mesaj geçmişinize erişimi sürdürmek için kurtarma anahtarınızı onaylayın." + "Kurtarma anahtarınızı girin" + "Kurtarma anahtarınızı mı unuttunuz?" + "Anahtar depolama alanınız senkronize değil" + "Önemli bir aramayı asla kaçırmamak için, telefonunuz kilitliyken tam ekran bildirimlere izin vermek üzere ayarlarınızı değiştirin." + "Arama deneyiminizi geliştirin" + "Sohbetler" + "%1$s katılma davetini reddetmek istediğinizden emin misiniz?" + "Daveti reddet" + "%1$s ile bu özel sohbeti reddetmek istediğinizden emin misiniz?" + "Sohbeti reddet" + "Davet Yok" + "%1$s (%2$s) sizi davet etti" + "Bu tek seferlik bir işlemdir, beklediğiniz için teşekkürler." + "Hesabınızı ayarlanıyor." + "Yeni bir sohbet veya oda oluşturun" + "Birine mesaj göndererek başla." + "Henüz sohbet yok." + "Favoriler" + "Sohbet ayarlarından bir sohbeti favorilerinize ekleyebilirsiniz. +Şimdilik, diğer sohbetlerinizi görmek için filtrelerin seçimini kaldırabilirsiniz" + "Henüz favori sohbetleriniz yok" + "Davetiyeler" + "Bekleyen davetiniz yok." + "Düşük Öncelikli" + "Diğer sohbetlerinizi görmek için filtrelerin seçimini kaldırabilirsiniz" + "Bu seçim için sohbetiniz yok" + "Kişiler" + "Henüz hiç DM\'niz yok" + "Odalar" + "Henüz herhangi bir odada değilsiniz" + "Okunmamış" + "Tebrikler! +Okunmamış mesajınız yok!" + "Katılma isteği gönderildi" + "Sohbetler" + "Okundu olarak işaretle" + "Okunmamış olarak işaretle" + "Görünüşe göre yeni bir cihaz kullanıyorsunuz. Şifrelenmiş mesajlarınıza erişmek için başka bir cihazla doğrulayın." + "Siz olduğunuzu doğrulayın" + diff --git a/features/home/impl/src/main/res/values-uk/translations.xml b/features/home/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..f41f077 --- /dev/null +++ b/features/home/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,53 @@ + + + "Вимкніть оптимізацію акумулятора для цього застосунку, щоб надходили всі сповіщення." + "Вимкнути оптимізацію" + "Не надходять сповіщення?" + "Відновіть свою криптографічну ідентичність та історію повідомлень за допомогою ключа відновлення, якщо ви втратили всі наявні пристрої." + "Налаштувати відновлення" + "Налаштуйте відновлення для захисту свого облікового запису" + "Підтвердіть свій ключ відновлення, щоб мати доступ до сховища ключів та історії повідомлень." + "Введіть ключ відновлення" + "Забули ключ відновлення?" + "Ваше сховище ключів не синхронізовано" + "Щоб ніколи не пропустити важливий виклик, змініть налаштування, щоб увімкнути повноекранні сповіщення, коли телефон заблоковано." + "Покращуйте досвід дзвінків" + "Бесіди" + "Простори" + "Ви впевнені, що хочете відхилити запрошення приєднатися до %1$s?" + "Відхилити запрошення" + "Ви дійсно хочете відмовитися від приватної бесіди з %1$s?" + "Відхилити бесіду" + "Немає запрошень" + "%1$s (%2$s) запрошує вас" + "Це одноразовий процес, дякую за очікування." + "Налаштування облікового запису." + "Створити нову розмову або кімнату" + "Очистити фільтри" + "Почніть з обміну повідомленнями з кимось." + "Ще немає бесід." + "Обране" + "Ви можете додати бесіду до обраних у налаштуваннях бесіди. +Наразі ви можете зняти фільтри, щоб побачити інші ваші бесіди" + "Ви ще не маєте обраних бесід" + "Запрошення" + "У вас немає запрошень, що очікують на розгляд." + "Низький пріоритет" + "У вас ще немає неважливих бесід" + "Ви можете зняти фільтри, щоб побачити інші ваші бесіди" + "Ви не маєте бесід для цієї категорії" + "Люди" + "Ви ще не маєте жодної особистої бесіди" + "Кімнати" + "Ви ще не учасник жодної кімнати" + "Непрочитані" + "Вітаємо! +У вас немає непрочитаних повідомлень!" + "Запит на приєднання надіслано" + "Бесіди" + "Позначити прочитаним" + "Позначити непрочитаним" + "Цю кімнату оновлено" + "Схоже, ви використовуєте новий пристрій. Щоб отримати доступ до зашифрованих повідомлень, підтвердьте особу за допомогою іншого пристрою." + "Підтвердьте, що це ви" + diff --git a/features/home/impl/src/main/res/values-ur/translations.xml b/features/home/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..78166c3 --- /dev/null +++ b/features/home/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,43 @@ + + + "اگر آپ اپنے تمام موجودہ آلات کھو چکے ہیں تو ایک recovery key کے ذریعہ اپنی کرپٹوگرافک شناخت اور پیغام کی سرگزشت کو دوبارہ حاصل کریں۔" + "بازیابی مرتب کریں" + "اپنے اکاؤنٹ کی حفاظت کے لیے ریکوری طے کریں" + "اپنے کلید کے ذخیرہ اور پیغام کی سرگزشت تک رسائی کو برقرار رکھنے کیلئے اپنی بازیابی کلید کی تصدیق کریں۔" + "آپ کا کلید کا ذخیرہ غیر ہم وقت ساز ہے۔" + "اس بات کو یقینی بنانے کے لیے کہ آپ کبھی بھی اہم مکالمہ سے محروم نہ ہوں، براہ کرم اپنی ترتیبات تبدیل کریں تاکہ آپ کا ہاتف مقفل ہونے پر مکمل پردۂ نمائش اطلاعات کی اجازت دی جا سکے۔" + "اپنے مکالمتی تجربے کو احسن کریں" + "گفتگوئیں" + "کیا آپکو یقین ہے کہ آپ %1$s میں شامل ہونے کی درخواست مسترد کرنا چاہتے ہیں؟" + "دعوت مسترد کریں" + "کیا آپکو یقین ہے کہ آپ %1$s کیساتھ نجی گفتگو مسترد کرنا چاہتے ہیں؟" + "گفتگو مسترد کریں" + "کوئی دعوت نامے نہیں" + "%1$s (%2$s) نے آپ کو مدعو کیا" + "یہ ایک بار کا عمل ہے، انتظار کرنے کا شکریہ۔" + "آپکا کھاتہ مرتب کر رہا ہے" + "ایک نئی گفتگو یا کمرہ تخلیق کریں" + "کسی کو پیغام بھیج کر شروع کریں۔" + "ابھی تک کوئی گفتگوئیں نہیں ہیں۔" + "پسندیدگان" + "آپ گفتگو کی ترتیبات میں اپنے پسندیدہ میں گفتگو شامل کر سکتے ہیں۔ + ابھی کے لیے، آپ اپنی دوسری گفتگوئیں دیکھنے کے لیے مرشحات کو غیر منتخب کر سکتے ہیں۔" + "آپ کے پاس ابھی تک پسندیدہ گفتگوئیں نہیں ہیں" + "دعوت نامے" + "آپ کے پاس کوئی زیر التوا دعوتیں نہیں ہیں۔" + "کم ترجیحی" + "آپ اپنی دیگر گفتگئہں دیکھنے کیلئے مرشحات کو غیر منتخب کرسکتے ہیں" + "آپ کے پاس اس انتخاب کے لیے گفتگو ئیں نہیں ہیں۔" + "لوگ" + "آپ کے پاس ابھی تک کوئی براہ راست پیغامات نہیں ہے۔" + "کمرے" + "آپ ابھی تک کسی کمرے میں نہیں ہیں" + "غیر مقروءہ" + "مبارک ہو! +آپ کے پاس کوئی غیر مقروءہ پیغامات نہیں!" + "گفتگوئیں" + "بطور مقروءہ نشانزد کریں" + "بطور غیر مقروءہ نشانزد کریں" + "ایسا لگتا ہے کہ آپ ایک نیا آلہ استعمال کر رہے ہیں۔ اپنے مرموزکردہ پیغامات تک رسائی کیلئے کسی دوسرے آلے سے توثیق کریں۔" + "تصدیق کریں کہ آپ ہی ہیں" + diff --git a/features/home/impl/src/main/res/values-uz/translations.xml b/features/home/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..2de9b1b --- /dev/null +++ b/features/home/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,53 @@ + + + "Ushbu ilova uchun quvvatni optimallashtirishni oʻchirib qoʻying, barcha xabarnomalar qabul qilinganligiga ishonch hosil qilish uchun." + "Optimallashtirishni o\'chiring" + "Bildirishnoma kelmayaptimi?" + "Mavjud barcha qurilmalarni yoʻqotgan boʻlsangiz, kriptografik kimligingizni va xabarlar tarixini qayta tiklovchi kalit bilan saqlab qoʻying." + "Qayta tiklashni sozlang" + "Hisobingizni himoya qilish uchun tiklashni sozlang" + "Kalit saqlash joyingiz va xabarlar tarixingizga kirishni saqlab qolish uchun tiklash kalitingizni tasdiqlang." + "Qayta tiklash kalitingizni kiriting" + "Tiklash kalitini unutdingizmi?" + "Kalit saqlash joyi sinxronlashmagan" + "Muhim qoʻngʻiroqlarni oʻtkazib yubormasligingiz uchun telefoningiz qulflangan holatida toʻliq ekranli bildirishnomalarni ko‘rsatishga ruxsat beradigan qilib sozlamalaringizni oʻzgartiring." + "Qoʻngʻiroq tajribangizni yaxshilang" + "Suhbatlar" + "Bo‘shliqlar" + "Haqiqatan ham qo\'shilish taklifini rad qilmoqchimisiz%1$s ?" + "Taklifni rad etish" + "Haqiqatan ham bu shaxsiy chatni rad qilmoqchimisiz%1$s ?" + "Chatni rad etish" + "Takliflar yo\'q" + "%1$s(%2$s ) sizni taklif qildi" + "Bu bir martalik jarayon, kutganingiz uchun rahmat." + "Hisobingiz sozlanmoqda." + "Yangi suhbat yoki xona yarating" + "Filtrlarni tozalash" + "Kimgadir xabar yuborishdan boshlang." + "Hozircha chatlar yo‘q." + "Sevimlilar" + "Siz chat sozlamalarida suhbatni sevimlilar ro‘yxatiga qo‘shishingiz mumkin. +Hozircha, boshqa suhbatlaringizni ko‘rish uchun filtrlarni bekor qilishingiz mumkin." + "Sizda hali sevimli chatlar yo‘q" + "Takliflar" + "Sizda hech qanday kutilayotgan takliflar yoʻq." + "Past darajali" + "Sizda hali past ustuvor chatlar yoʻq" + "Boshqa suhbatlaringizni koʻrish uchun filtrlarni bekor qilishingiz mumkin" + "Sizda bu tanlov uchun chatlar yo‘q" + "Odamlar" + "Sizda hali hech qanday shaxsiy xabarlar yo‘q" + "Xonalar" + "Hali hech qaysi xonada emassiz" + "Oʻqilmaganlar" + "Tabriklaymiz! +Sizda oʻqilmagan xabarlar yoʻq!" + "Qo‘shilish so‘rovi yuborildi" + "Suhbatlar" + "Oʻqilgan deb belgilash" + "Oʻqilmagan deb belgilash" + "Bu xona yangilandi" + "Siz yangi qurilmadan foydalanayotganga o‘xshaysiz. Shifrlangan xabarlaringizga kirish uchun boshqa qurilma bilan tasdiqlang." + "Siz ekanligingizni tasdiqlang" + diff --git a/features/home/impl/src/main/res/values-zh-rTW/translations.xml b/features/home/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..20ee59d --- /dev/null +++ b/features/home/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,55 @@ + + + "停用此應用程式的電池最佳化,才能確保收到所有通知。" + "停用最佳化" + "沒收到通知?" + "您的通知提示音已更新,更清晰、更快、更不易分心。" + "我們已更新您的音效設定" + "若您遺失了所有現有裝置,則請使用復原金鑰以救援您的密碼學身份與訊息歷史紀錄。" + "設定復原" + "設定備援以保護您的帳號" + "確認您的復原金鑰以維持對金鑰儲存空間與訊息歷史紀錄的存取權。" + "輸入您的復原金鑰" + "忘記了您的復原金鑰?" + "您的金鑰儲存空間並未同步" + "為確保您永遠不會錯過重要通話,請變更設定以允許在手機鎖定時允許全螢幕通知。" + "提升您的通話體驗" + "所有聊天室" + "空間" + "您確定您想要拒絕加入 %1$s 的邀請嗎?" + "拒絕邀請" + "您確定您要拒絕此與 %1$s 的私人聊天嗎?" + "拒絕聊天" + "沒有邀請" + "%1$s(%2$s)邀請您" + "這是一次性的程序,感謝您耐心等候。" + "正在設定您的帳號。" + "建立新的對話或聊天室" + "清除篩選條件" + "從向某人傳送訊息開始。" + "尚無聊天室。" + "我的最愛" + "您可以在聊天設定中將聊天新增至收藏。 +目前,您可以取消選取篩選條件以檢視其他聊天" + "您尚無收藏聊天" + "邀請" + "您沒有任何擱置中的邀請。" + "低優先度" + "您尚無任何低優先程度聊天" + "您可以取消選取篩選條件以檢視其他聊天" + "您並無此選擇的聊天" + "夥伴" + "您尚無任何私人訊息" + "聊天室" + "您尚未進入任何聊天室" + "未讀" + "恭喜! +您沒有任何未讀的訊息!" + "已傳送加入請求" + "所有聊天室" + "標為已讀" + "標為未讀" + "此聊天室已升級" + "您似乎正在使用新的裝置。請使用另一個裝置進行驗證,以存取您的加密訊息。" + "驗證這是您本人" + diff --git a/features/home/impl/src/main/res/values-zh/translations.xml b/features/home/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..748159d --- /dev/null +++ b/features/home/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,55 @@ + + + "请关闭本应用的电池优化设置,确保不错过任何消息通知。" + "禁用优化" + "通知未送达?" + "您的通知提示音已升级 - 更清晰、更快速、干扰更少。" + "我们已更新您的声音" + "生成新的恢复密钥,该密钥可用于在您无法访问设备时恢复加密的消息历史记录。" + "设置恢复" + "设置恢复" + "确认恢复密钥,以保持对密钥存储和消息历史的访问。" + "输入恢复密钥" + "忘记了恢复密钥?" + "你的密钥存储已不同步" + "为确保您不会错过重要来电,请更改设置以允许锁屏时的全屏通知。" + "提升通话体验" + "全部聊天" + "空间" + "您确定要拒绝加入 %1$s 的邀请吗?" + "拒绝邀请" + "您确定要拒绝与 %1$s 开始私聊吗?" + "拒绝聊天" + "没有邀请" + "%1$s (%2$s)邀请了你" + "这是一个一次性的过程,感谢您的等待。" + "设置您的账户。" + "创建新的对话或聊天室" + "清除筛选条件" + "通过向某人发送消息来开始。" + "还没有聊天。" + "收藏夹" + "可以在聊天设置里将聊天添加到收藏夹中。 +现在,可以取消选择过滤器以查看其他对话。" + "您未收藏任何聊天" + "邀请" + "没有待处理的邀请。" + "低优先级" + "您还没有任何低优先级聊天" + "您可以取消选择过滤器以查看其他对话" + "您没有关于此选项的聊天" + "用户" + "目前您还没有私信" + "聊天室" + "您尚未进入任何聊天室" + "未读" + "恭喜! +没有任何未读消息!" + "加入请求已发送" + "全部聊天" + "标记为已读" + "标记为未读" + "此房间已升级" + "您似乎正在使用新设备。使用另一台设备进行验证以访问您的加密消息。" + "验证是你本人" + diff --git a/features/home/impl/src/main/res/values/localazy.xml b/features/home/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..80a263a --- /dev/null +++ b/features/home/impl/src/main/res/values/localazy.xml @@ -0,0 +1,55 @@ + + + "Disable battery optimisation for this app, to make sure all notifications are received." + "Disable optimisation" + "Notifications not arriving?" + "Your notification ping has been updated—clearer, quicker, and less disruptive." + "We’ve refreshed your sounds" + "Recover your cryptographic identity and message history with a recovery key if you have lost all your existing devices." + "Set up recovery" + "Set up recovery to protect your account" + "Confirm your recovery key to maintain access to your key storage and message history." + "Enter your recovery key" + "Forgot your recovery key?" + "Your key storage is out of sync" + "To ensure you never miss an important call, please change your settings to allow full-screen notifications when your phone is locked." + "Enhance your call experience" + "Chats" + "Spaces" + "Are you sure you want to decline the invitation to join %1$s?" + "Decline invite" + "Are you sure you want to decline this private chat with %1$s?" + "Decline chat" + "No Invites" + "%1$s (%2$s) invited you" + "This is a one time process, thanks for waiting." + "Setting up your account." + "Create a new conversation or room" + "Clear filters" + "Get started by messaging someone." + "No chats yet." + "Favourites" + "You can add a chat to your favourites in the chat settings. +For now, you can deselect filters in order to see your other chats" + "You don’t have favourite chats yet" + "Invites" + "You don\'t have any pending invites." + "Low Priority" + "You don’t have any low priority chats yet" + "You can deselect filters in order to see your other chats" + "You don’t have chats for this selection" + "People" + "You don’t have any DMs yet" + "Rooms" + "You’re not in any room yet" + "Unreads" + "Congrats! +You don’t have any unread messages!" + "Request to join sent" + "Chats" + "Mark as read" + "Mark as unread" + "This room has been upgraded" + "Looks like you’re using a new device. Verify with another device to access your encrypted messages." + "Verify it’s you" + diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt new file mode 100644 index 0000000..13c9585 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl + +import com.google.common.truth.Truth.assertThat +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_ID_2 +import io.element.android.libraries.matrix.test.A_USER_ID_3 +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.libraries.sessionstorage.test.aSessionData +import org.junit.Test + +class CurrentUserWithNeighborsBuilderTest { + @Test + fun `build on empty list returns current user`() { + val sut = CurrentUserWithNeighborsBuilder() + val matrixUser = aMatrixUser() + val list = listOf() + val result = sut.build(matrixUser, list) + assertThat(result).containsExactly(matrixUser) + } + + @Test + fun `ensure that account are sorted by position`() { + val sut = CurrentUserWithNeighborsBuilder() + val matrixUser = aMatrixUser(id = A_USER_ID.value) + val list = listOf( + aSessionData( + sessionId = A_USER_ID.value, + position = 3, + ), + aSessionData( + sessionId = A_USER_ID_2.value, + position = 2, + ), + aSessionData( + sessionId = A_USER_ID_3.value, + position = 1, + ), + ) + val result = sut.build(matrixUser, list) + assertThat(result.map { it.userId }).containsExactly( + A_USER_ID_3, + A_USER_ID_2, + A_USER_ID, + ) + } + + @Test + fun `if current user is not found, return a singleton with current user`() { + val sut = CurrentUserWithNeighborsBuilder() + val matrixUser = aMatrixUser(id = A_USER_ID.value) + val list = listOf( + aSessionData( + sessionId = A_USER_ID_2.value, + ), + aSessionData( + sessionId = A_USER_ID_3.value, + ), + ) + val result = sut.build(matrixUser, list) + assertThat(result.map { it.userId }).containsExactly( + A_USER_ID, + ) + } + + @Test + fun `one account, will return a singleton`() { + val sut = CurrentUserWithNeighborsBuilder() + val matrixUser = aMatrixUser(id = A_USER_ID.value) + val list = listOf( + aSessionData( + sessionId = A_USER_ID.value, + ), + ) + val result = sut.build(matrixUser, list) + assertThat(result.map { it.userId }).containsExactly( + A_USER_ID, + ) + } + + @Test + fun `two accounts, first is current, will return 3 items`() { + val sut = CurrentUserWithNeighborsBuilder() + val matrixUser = aMatrixUser(id = A_USER_ID.value) + val list = listOf( + aSessionData( + sessionId = A_USER_ID.value, + ), + aSessionData( + sessionId = A_USER_ID_2.value, + ), + ) + val result = sut.build(matrixUser, list) + assertThat(result.map { it.userId }).containsExactly( + A_USER_ID_2, + A_USER_ID, + A_USER_ID_2, + ) + } + + @Test + fun `two accounts, second is current, will return 3 items`() { + val sut = CurrentUserWithNeighborsBuilder() + val matrixUser = aMatrixUser(id = A_USER_ID_2.value) + val list = listOf( + aSessionData( + sessionId = A_USER_ID.value, + ), + aSessionData( + sessionId = A_USER_ID_2.value, + ), + ) + val result = sut.build(matrixUser, list) + assertThat(result.map { it.userId }).containsExactly( + A_USER_ID, + A_USER_ID_2, + A_USER_ID, + ) + } + + @Test + fun `three accounts, first is current, will return last current and next`() { + val sut = CurrentUserWithNeighborsBuilder() + val matrixUser = aMatrixUser(id = A_USER_ID.value) + val list = listOf( + aSessionData( + sessionId = A_USER_ID.value, + ), + aSessionData( + sessionId = A_USER_ID_2.value, + ), + aSessionData( + sessionId = A_USER_ID_3.value, + ), + ) + val result = sut.build(matrixUser, list) + assertThat(result.map { it.userId }).containsExactly( + A_USER_ID_3, + A_USER_ID, + A_USER_ID_2, + ) + } + + @Test + fun `three accounts, second is current, will return first current and last`() { + val sut = CurrentUserWithNeighborsBuilder() + val matrixUser = aMatrixUser(id = A_USER_ID_2.value) + val list = listOf( + aSessionData( + sessionId = A_USER_ID.value, + ), + aSessionData( + sessionId = A_USER_ID_2.value, + ), + aSessionData( + sessionId = A_USER_ID_3.value, + ), + ) + val result = sut.build(matrixUser, list) + assertThat(result.map { it.userId }).containsExactly( + A_USER_ID, + A_USER_ID_2, + A_USER_ID_3, + ) + } + + @Test + fun `three accounts, current is last, will return middle, current and first`() { + val sut = CurrentUserWithNeighborsBuilder() + val matrixUser = aMatrixUser(id = A_USER_ID_3.value) + val list = listOf( + aSessionData( + sessionId = A_USER_ID_2.value, + ), + aSessionData( + sessionId = A_USER_ID_3.value, + ), + aSessionData( + sessionId = A_USER_ID.value, + ), + ) + val result = sut.build(matrixUser, list) + assertThat(result.map { it.userId }).containsExactly( + A_USER_ID, + A_USER_ID_2, + A_USER_ID_3, + ) + } + + @Test + fun `one account, will return data from matrix user and not from db`() { + val sut = CurrentUserWithNeighborsBuilder() + val matrixUser = aMatrixUser( + id = A_USER_ID.value, + displayName = "Bob", + avatarUrl = "avatarUrl", + ) + val list = listOf( + aSessionData( + sessionId = A_USER_ID.value, + userDisplayName = "Outdated Bob", + userAvatarUrl = "outdatedAvatarUrl", + ), + ) + val result = sut.build(matrixUser, list) + assertThat(result).containsExactly( + MatrixUser( + userId = A_USER_ID, + displayName = "Bob", + avatarUrl = "avatarUrl", + ) + ) + } +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt new file mode 100644 index 0000000..9778556 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.features.home.api.HomeEntryPoint +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DefaultHomeEntryPointTest { + @Test + fun `test node builder`() = runTest { + val entryPoint = DefaultHomeEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + HomeFlowNode( + buildContext = buildContext, + plugins = plugins, + matrixClient = FakeMatrixClient(), + presenter = createHomePresenter(), + inviteFriendsUseCase = { lambdaError() }, + analyticsService = FakeAnalyticsService(), + acceptDeclineInviteView = { _, _, _, _ -> lambdaError() }, + directLogoutView = { _ -> lambdaError() }, + reportRoomEntryPoint = { _, _, _ -> lambdaError() }, + declineInviteAndBlockUserEntryPoint = { _, _, _ -> lambdaError() }, + changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() }, + leaveRoomRenderer = { _, _, _ -> lambdaError() }, + sessionCoroutineScope = backgroundScope, + ) + } + val callback = object : HomeEntryPoint.Callback { + override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) = lambdaError() + override fun navigateToCreateRoom() = lambdaError() + override fun navigateToSettings() = lambdaError() + override fun navigateToSetUpRecovery() = lambdaError() + override fun navigateToEnterRecoveryKey() = lambdaError() + override fun navigateToRoomSettings(roomId: RoomId) = lambdaError() + override fun navigateToBugReport() = lambdaError() + } + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + callback = callback, + ) + assertThat(result).isInstanceOf(HomeFlowNode::class.java) + assertThat(result.plugins).contains(callback) + } +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/FakeDateTimeObserver.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/FakeDateTimeObserver.kt new file mode 100644 index 0000000..3e88ff7 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/FakeDateTimeObserver.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl + +import io.element.android.libraries.androidutils.system.DateTimeObserver +import kotlinx.coroutines.flow.MutableSharedFlow + +class FakeDateTimeObserver : DateTimeObserver { + override val changes = MutableSharedFlow(extraBufferCapacity = 1) + + fun given(event: DateTimeObserver.Event) { + changes.tryEmit(event) + } +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt new file mode 100644 index 0000000..0ae3ea1 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService +import io.element.android.features.home.impl.roomlist.aRoomListState +import io.element.android.features.home.impl.spaces.HomeSpacesState +import io.element.android.features.home.impl.spaces.aHomeSpacesState +import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.features.rageshake.api.RageshakeFeatureAvailability +import io.element.android.features.rageshake.test.logs.FakeAnnouncementService +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.utils.snackbar.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.indicator.api.IndicatorService +import io.element.android.libraries.indicator.test.FakeIndicatorService +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.sync.SyncService +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.AN_EXCEPTION +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.core.aBuildMeta +import io.element.android.libraries.matrix.test.sync.FakeSyncService +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.tests.testutils.MutablePresenter +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class HomePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private val isSpaceEnabled = FeatureFlags.Space.defaultValue(aBuildMeta()) + + @Test + fun `present - should start with no user and then load user with success`() = runTest { + val matrixClient = FakeMatrixClient( + userDisplayName = null, + userAvatarUrl = null, + ) + matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.success(MatrixUser(matrixClient.sessionId, A_USER_NAME, AN_AVATAR_URL))) + val presenter = createHomePresenter( + client = matrixClient, + rageshakeFeatureAvailability = { flowOf(false) }, + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = matrixClient.sessionId.value, + userDisplayName = null, + userAvatarUrl = null, + ) + ), + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + if (isSpaceEnabled) skipItems(1) + val initialState = awaitItem() + assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo( + MatrixUser(A_USER_ID, null, null) + ) + assertThat(initialState.canReportBug).isFalse() + skipItems(1) + val withUserState = awaitItem() + assertThat(withUserState.currentUserAndNeighbors.first()).isEqualTo( + MatrixUser(A_USER_ID, A_USER_NAME, AN_AVATAR_URL) + ) + assertThat(withUserState.showAvatarIndicator).isFalse() + assertThat(withUserState.isSpaceFeatureEnabled).isEqualTo(isSpaceEnabled) + assertThat(withUserState.showNavigationBar).isEqualTo(isSpaceEnabled) + } + } + + @Test + fun `present - can report bug`() = runTest { + val presenter = createHomePresenter( + rageshakeFeatureAvailability = { flowOf(true) }, + sessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.canReportBug).isFalse() + val finalState = awaitItem() + assertThat(finalState.canReportBug).isTrue() + } + } + + @Test + fun `present - space feature enabled`() = runTest { + val presenter = createHomePresenter( + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.Space.key to true), + ), + sessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), + ) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isSpaceFeatureEnabled).isTrue() + } + } + + @Test + fun `present - show avatar indicator`() = runTest { + val indicatorService = FakeIndicatorService() + val presenter = createHomePresenter( + indicatorService = indicatorService, + sessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + if (isSpaceEnabled) skipItems(1) + val initialState = awaitItem() + assertThat(initialState.showAvatarIndicator).isFalse() + indicatorService.setShowRoomListTopBarIndicator(true) + val finalState = awaitItem() + assertThat(finalState.showAvatarIndicator).isTrue() + } + } + + @Test + fun `present - should start with no user and then load user with error`() = runTest { + val matrixClient = FakeMatrixClient( + userDisplayName = null, + userAvatarUrl = null, + ) + matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.failure(AN_EXCEPTION)) + val presenter = createHomePresenter( + client = matrixClient, + sessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + if (isSpaceEnabled) skipItems(1) + val initialState = awaitItem() + assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(MatrixUser(matrixClient.sessionId)) + // No new state is coming + } + } + + @Test + fun `present - NavigationBar change`() = runTest { + val showAnnouncementResult = lambdaRecorder { } + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), + announcementService = FakeAnnouncementService( + showAnnouncementResult = showAnnouncementResult, + ) + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + if (isSpaceEnabled) skipItems(1) + val initialState = awaitItem() + assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats) + initialState.eventSink(HomeEvents.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces)) + val finalState = awaitItem() + assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces) + showAnnouncementResult.assertions().isCalledOnce() + .with(value(Announcement.Space)) + } + } + + @Test + fun `present - NavigationBar is hidden when the last space is left`() = runTest { + val homeSpacesPresenter = MutablePresenter(aHomeSpacesState()) + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.Space.key to true), + ), + homeSpacesPresenter = homeSpacesPresenter, + announcementService = FakeAnnouncementService( + showAnnouncementResult = {}, + ) + ) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats) + assertThat(initialState.showNavigationBar).isTrue() + // User navigate to Spaces + initialState.eventSink(HomeEvents.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces)) + val spaceState = awaitItem() + assertThat(spaceState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces) + // The last space is left + homeSpacesPresenter.updateState(aHomeSpacesState(spaceRooms = emptyList())) + skipItems(1) + val finalState = awaitItem() + // We are back to Chats + assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats) + assertThat(finalState.showNavigationBar).isFalse() + } + } +} + +internal fun createHomePresenter( + client: MatrixClient = FakeMatrixClient(), + syncService: SyncService = FakeSyncService(), + snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), + rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(false) }, + indicatorService: IndicatorService = FakeIndicatorService(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), + homeSpacesPresenter: Presenter = Presenter { aHomeSpacesState() }, + sessionStore: SessionStore = InMemorySessionStore(), + announcementService: AnnouncementService = FakeAnnouncementService(), +) = HomePresenter( + client = client, + syncService = syncService, + snackbarDispatcher = snackbarDispatcher, + indicatorService = indicatorService, + logoutPresenter = { aDirectLogoutState() }, + roomListPresenter = { aRoomListState() }, + homeSpacesPresenter = homeSpacesPresenter, + rageshakeFeatureAvailability = rageshakeFeatureAvailability, + featureFlagService = featureFlagService, + sessionStore = sessionStore, + announcementService = announcementService, +) diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt new file mode 100644 index 0000000..35c30af --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.datasource + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.home.impl.FakeDateTimeObserver +import io.element.android.libraries.androidutils.system.DateTimeObserver +import io.element.android.libraries.dateformatter.test.FakeDateFormatter +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.time.Instant + +class RoomListDataSourceTest { + @Test + fun `when DateTimeObserver gets a date change, the room summaries are refreshed`() = runTest { + val roomListService = FakeRoomListService().apply { + postState(RoomListService.State.Running) + postAllRooms(listOf(aRoomSummary())) + } + val dateTimeObserver = FakeDateTimeObserver() + var dateFormatterResult = "Today" + val dateFormatter = FakeDateFormatter({ _, _, _ -> dateFormatterResult }) + val roomListDataSource = createRoomListDataSource( + roomListService = roomListService, + roomListRoomSummaryFactory = aRoomListRoomSummaryFactory( + dateFormatter = dateFormatter, + ), + dateTimeObserver = dateTimeObserver, + ) + + roomListDataSource.allRooms.test { + // Observe room list items changes + roomListDataSource.launchIn(backgroundScope) + // Get the initial room list + val initialRoomList = awaitItem() + assertThat(initialRoomList).isNotEmpty() + assertThat(initialRoomList.first().timestamp).isEqualTo("Today") + dateFormatterResult = "Yesterday" + // Trigger a date change + dateTimeObserver.given(DateTimeObserver.Event.DateChanged(Instant.MIN, Instant.now())) + // Check there is a new list and it's not the same as the previous one + val newRoomList = awaitItem() + assertThat(newRoomList).isNotSameInstanceAs(initialRoomList) + assertThat(newRoomList.first().timestamp).isEqualTo("Yesterday") + } + } + + @Test + fun `when DateTimeObserver gets a time zone change, the room summaries are refreshed`() = runTest { + val roomListService = FakeRoomListService().apply { + postState(RoomListService.State.Running) + postAllRooms(listOf(aRoomSummary())) + } + val dateTimeObserver = FakeDateTimeObserver() + var dateFormatterResult = "Today" + val dateFormatter = FakeDateFormatter({ _, _, _ -> dateFormatterResult }) + val roomListDataSource = createRoomListDataSource( + roomListService = roomListService, + roomListRoomSummaryFactory = aRoomListRoomSummaryFactory( + dateFormatter = dateFormatter, + ), + dateTimeObserver = dateTimeObserver, + ) + roomListDataSource.allRooms.test { + // Observe room list items changes + roomListDataSource.launchIn(backgroundScope) + // Get the initial room list + val initialRoomList = awaitItem() + assertThat(initialRoomList).isNotEmpty() + assertThat(initialRoomList.first().timestamp).isEqualTo("Today") + dateFormatterResult = "Yesterday" + // Trigger a timezone change + dateTimeObserver.given(DateTimeObserver.Event.TimeZoneChanged) + // Check there is a new list and it's not the same as the previous one + val newRoomList = awaitItem() + assertThat(newRoomList).isNotSameInstanceAs(initialRoomList) + assertThat(newRoomList.first().timestamp).isEqualTo("Yesterday") + } + } + + private fun TestScope.createRoomListDataSource( + roomListService: FakeRoomListService = FakeRoomListService(), + roomListRoomSummaryFactory: RoomListRoomSummaryFactory = aRoomListRoomSummaryFactory(), + notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), + dateTimeObserver: FakeDateTimeObserver = FakeDateTimeObserver(), + ) = RoomListDataSource( + roomListService = roomListService, + roomListRoomSummaryFactory = roomListRoomSummaryFactory, + coroutineDispatchers = testCoroutineDispatchers(), + notificationSettingsService = notificationSettingsService, + sessionCoroutineScope = backgroundScope, + dateTimeObserver = dateTimeObserver, + ) +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactoryTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactoryTest.kt new file mode 100644 index 0000000..5f30791 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactoryTest.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.datasource + +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.test.FakeDateFormatter +import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter +import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter + +fun aRoomListRoomSummaryFactory( + dateFormatter: DateFormatter = FakeDateFormatter { _, _, _ -> "Today" }, + roomLatestEventFormatter: RoomLatestEventFormatter = FakeRoomLatestEventFormatter(), +) = RoomListRoomSummaryFactory( + dateFormatter = dateFormatter, + roomLatestEventFormatter = roomLatestEventFormatter, +) diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEmptyStateResourcesTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEmptyStateResourcesTest.kt new file mode 100644 index 0000000..250f43e --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersEmptyStateResourcesTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.filters + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.home.impl.R +import org.junit.Test + +class RoomListFiltersEmptyStateResourcesTest { + @Test + fun `fromSelectedFilters should return null when selectedFilters is empty`() { + val selectedFilters = emptyList() + val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters) + assertThat(result).isNull() + } + + @Test + fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only unread filter`() { + val selectedFilters = listOf(RoomListFilter.Unread) + val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters) + assertThat(result).isNotNull() + assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_unreads_empty_state_title) + assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle) + } + + @Test + fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only people filter`() { + val selectedFilters = listOf(RoomListFilter.People) + val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters) + assertThat(result).isNotNull() + assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_people_empty_state_title) + assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle) + } + + @Test + fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only rooms filter`() { + val selectedFilters = listOf(RoomListFilter.Rooms) + val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters) + assertThat(result).isNotNull() + assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_rooms_empty_state_title) + assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle) + } + + @Test + fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only favourites filter`() { + val selectedFilters = listOf(RoomListFilter.Favourites) + val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters) + assertThat(result).isNotNull() + assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_favourites_empty_state_title) + assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_favourites_empty_state_subtitle) + } + + @Test + fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only invites filter`() { + val selectedFilters = listOf(RoomListFilter.Invites) + val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters) + assertThat(result).isNotNull() + assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_invites_empty_state_title) + assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle) + } + + @Test + fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has multiple filters`() { + val selectedFilters = listOf(RoomListFilter.Unread, RoomListFilter.People, RoomListFilter.Rooms, RoomListFilter.Favourites) + val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters) + assertThat(result).isNotNull() + assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_title) + assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle) + } +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt new file mode 100644 index 0000000..df1bc18 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.filters + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.home.impl.filters.selection.DefaultFilterSelectionStrategy +import io.element.android.features.home.impl.filters.selection.FilterSelectionState +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.tests.testutils.awaitLastSequentialItem +import kotlinx.coroutines.test.runTest +import org.junit.Test +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter + +class RoomListFiltersPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createRoomListFiltersPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().let { state -> + assertThat(state.hasAnyFilterSelected).isFalse() + assertThat(state.filterSelectionStates).containsExactly( + filterSelectionState(RoomListFilter.Unread, false), + filterSelectionState(RoomListFilter.People, false), + filterSelectionState(RoomListFilter.Rooms, false), + filterSelectionState(RoomListFilter.Favourites, false), + filterSelectionState(RoomListFilter.Invites, false), + ) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - toggle rooms filter`() = runTest { + val roomListService = FakeRoomListService() + val presenter = createRoomListFiltersPresenter(roomListService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms)) + awaitLastSequentialItem().let { state -> + + assertThat(state.hasAnyFilterSelected).isTrue() + assertThat(state.filterSelectionStates).containsExactly( + filterSelectionState(RoomListFilter.Rooms, true), + filterSelectionState(RoomListFilter.Unread, false), + filterSelectionState(RoomListFilter.Favourites, false), + ).inOrder() + + assertThat(state.selectedFilters()).containsExactly( + RoomListFilter.Rooms, + ) + val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All + assertThat(roomListCurrentFilter.filters).containsExactly( + MatrixRoomListFilter.Category.Group, + ) + state.eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms)) + } + awaitLastSequentialItem().let { state -> + assertThat(state.hasAnyFilterSelected).isFalse() + assertThat(state.filterSelectionStates).containsExactly( + filterSelectionState(RoomListFilter.Unread, false), + filterSelectionState(RoomListFilter.People, false), + filterSelectionState(RoomListFilter.Rooms, false), + filterSelectionState(RoomListFilter.Favourites, false), + filterSelectionState(RoomListFilter.Invites, false), + ).inOrder() + assertThat(state.selectedFilters()).isEmpty() + val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All + assertThat(roomListCurrentFilter.filters).isEmpty() + } + } + } + + @Test + fun `present - clear filters event`() = runTest { + val roomListService = FakeRoomListService() + val presenter = createRoomListFiltersPresenter(roomListService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink.invoke(RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms)) + awaitLastSequentialItem().let { state -> + assertThat(state.hasAnyFilterSelected).isTrue() + state.eventSink.invoke(RoomListFiltersEvents.ClearSelectedFilters) + } + awaitLastSequentialItem().let { state -> + assertThat(state.hasAnyFilterSelected).isFalse() + } + } + } +} + +private fun filterSelectionState(filter: RoomListFilter, selected: Boolean) = FilterSelectionState( + filter = filter, + isSelected = selected, +) + +private fun createRoomListFiltersPresenter( + roomListService: RoomListService = FakeRoomListService(), +): RoomListFiltersPresenter { + return RoomListFiltersPresenter( + roomListService = roomListService, + filterSelectionStrategy = DefaultFilterSelectionStrategy(), + ) +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersViewTest.kt new file mode 100644 index 0000000..9e99220 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersViewTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.filters + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.home.impl.R +import io.element.android.features.home.impl.filters.selection.FilterSelectionState +import io.element.android.libraries.testtags.TestTags +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.pressTag +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RoomListFiltersViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on filters generates expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setContent { + RoomListFiltersView( + state = aRoomListFiltersState(eventSink = eventsRecorder), + ) + } + rule.clickOn(R.string.screen_roomlist_filter_rooms) + eventsRecorder.assertList( + listOf( + RoomListFiltersEvents.ToggleFilter(RoomListFilter.Rooms), + ) + ) + } + + @Test + fun `clicking on clear filters generates expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setContent { + RoomListFiltersView( + state = aRoomListFiltersState( + filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) }, + eventSink = eventsRecorder + ), + ) + } + rule.pressTag(TestTags.homeScreenClearFilters.value) + eventsRecorder.assertList( + listOf( + RoomListFiltersEvents.ClearSelectedFilters, + ) + ) + } +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/model/RoomListBaseRoomSummaryTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/model/RoomListBaseRoomSummaryTest.kt new file mode 100644 index 0000000..28e7051 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/model/RoomListBaseRoomSummaryTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.model + +import com.google.common.truth.Truth.assertThat +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.room.RoomNotificationMode +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_NAME +import kotlinx.collections.immutable.toImmutableList +import org.junit.Test + +class RoomListBaseRoomSummaryTest { + @Test + fun `test default value`() { + val sut = createRoomListRoomSummary( + isMarkedUnread = false, + ) + assertThat(sut.isHighlighted).isFalse() + assertThat(sut.hasNewContent).isFalse() + } + + @Test + fun `test muted room`() { + val sut = createRoomListRoomSummary( + userDefinedNotificationMode = RoomNotificationMode.MUTE, + ) + assertThat(sut.isHighlighted).isFalse() + assertThat(sut.hasNewContent).isFalse() + } + + @Test + fun `test muted room isMarkedUnread set to true`() { + val sut = createRoomListRoomSummary( + isMarkedUnread = true, + userDefinedNotificationMode = RoomNotificationMode.MUTE, + ) + assertThat(sut.isHighlighted).isTrue() + assertThat(sut.hasNewContent).isTrue() + } + + @Test + fun `test muted room with unread message`() { + val sut = createRoomListRoomSummary( + numberOfUnreadNotifications = 1, + userDefinedNotificationMode = RoomNotificationMode.MUTE, + ) + assertThat(sut.isHighlighted).isFalse() + assertThat(sut.hasNewContent).isTrue() + } + + @Test + fun `test isMarkedUnread set to true`() { + val sut = createRoomListRoomSummary( + isMarkedUnread = true, + ) + assertThat(sut.isHighlighted).isTrue() + assertThat(sut.hasNewContent).isTrue() + } + + @Test + fun `when display type is invite then isHighlighted and hasNewContent are false`() { + val sut = createRoomListRoomSummary( + displayType = RoomSummaryDisplayType.INVITE, + ) + assertThat(sut.isHighlighted).isFalse() + assertThat(sut.hasNewContent).isFalse() + } +} + +internal fun createRoomListRoomSummary( + numberOfUnreadMentions: Long = 0, + numberOfUnreadMessages: Long = 0, + numberOfUnreadNotifications: Long = 0, + isMarkedUnread: Boolean = false, + userDefinedNotificationMode: RoomNotificationMode? = null, + isFavorite: Boolean = false, + displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM, + heroes: List = emptyList(), + timestamp: String? = null, + isTombstoned: Boolean = false, + isSpace: Boolean = false, +) = RoomListRoomSummary( + id = A_ROOM_ID.value, + roomId = A_ROOM_ID, + name = A_ROOM_NAME, + numberOfUnreadMentions = numberOfUnreadMentions, + numberOfUnreadMessages = numberOfUnreadMessages, + numberOfUnreadNotifications = numberOfUnreadNotifications, + isMarkedUnread = isMarkedUnread, + timestamp = timestamp, + latestEvent = LatestEvent.Synced(""), + avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME, size = AvatarSize.RoomListItem), + displayType = displayType, + userDefinedNotificationMode = userDefinedNotificationMode, + hasRoomCall = false, + isDirect = false, + isFavorite = isFavorite, + canonicalAlias = null, + inviteSender = null, + isDm = false, + heroes = heroes.toImmutableList(), + isTombstoned = isTombstoned, + isSpace = isSpace +) diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenuTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenuTest.kt new file mode 100644 index 0000000..b692d49 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenuTest.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.roomlist + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.home.impl.R +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureCalledOnceWithParam +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.setSafeContent +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RoomListContextMenuTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on Mark as read generates expected Events`() { + val eventsRecorder = EventsRecorder() + val contextMenu = aContextMenuShown(hasNewContent = true) + rule.setRoomListContextMenu( + contextMenu = contextMenu, + eventSink = eventsRecorder, + ) + rule.clickOn(R.string.screen_roomlist_mark_as_read) + eventsRecorder.assertList( + listOf( + RoomListEvents.HideContextMenu, + RoomListEvents.MarkAsRead(contextMenu.roomId), + ) + ) + } + + @Test + fun `clicking on Mark as unread generates expected Events`() { + val eventsRecorder = EventsRecorder() + val contextMenu = aContextMenuShown(hasNewContent = false) + rule.setRoomListContextMenu( + contextMenu = contextMenu, + eventSink = eventsRecorder, + ) + rule.clickOn(R.string.screen_roomlist_mark_as_unread) + eventsRecorder.assertList( + listOf( + RoomListEvents.HideContextMenu, + RoomListEvents.MarkAsUnread(contextMenu.roomId), + ) + ) + } + + @Test + fun `clicking on Leave room generates expected Events`() { + val eventsRecorder = EventsRecorder() + val contextMenu = aContextMenuShown(isDm = false) + rule.setRoomListContextMenu( + contextMenu = contextMenu, + eventSink = eventsRecorder, + ) + rule.clickOn(CommonStrings.action_leave_room) + eventsRecorder.assertList( + listOf( + RoomListEvents.HideContextMenu, + RoomListEvents.LeaveRoom(contextMenu.roomId, needsConfirmation = true), + ) + ) + } + + @Test + fun `clicking on Report room invokes the expected callback and generates expected Event`() { + val eventsRecorder = EventsRecorder() + val contextMenu = aContextMenuShown() + val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit) + rule.setRoomListContextMenu( + contextMenu = contextMenu, + canReportRoom = true, + eventSink = eventsRecorder, + onRoomSettingsClick = EnsureNeverCalledWithParam(), + onReportRoomClick = callback, + ) + rule.clickOn(CommonStrings.action_report_room) + eventsRecorder.assertSingle(RoomListEvents.HideContextMenu) + callback.assertSuccess() + } + + @Test + fun `clicking on Settings invokes the expected callback and generates expected Event`() { + val eventsRecorder = EventsRecorder() + val contextMenu = aContextMenuShown() + val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit) + rule.setRoomListContextMenu( + contextMenu = contextMenu, + eventSink = eventsRecorder, + onRoomSettingsClick = callback, + ) + rule.clickOn(CommonStrings.common_settings) + eventsRecorder.assertSingle(RoomListEvents.HideContextMenu) + callback.assertSuccess() + } + + @Test + fun `clicking on Favourites generates expected Event`() { + val eventsRecorder = EventsRecorder() + val contextMenu = aContextMenuShown(isDm = false, isFavorite = false) + val callback = EnsureNeverCalledWithParam() + rule.setRoomListContextMenu( + contextMenu = contextMenu, + eventSink = eventsRecorder, + onRoomSettingsClick = callback, + ) + rule.clickOn(CommonStrings.common_favourite) + eventsRecorder.assertList( + listOf( + RoomListEvents.SetRoomIsFavorite(contextMenu.roomId, true), + ) + ) + } + + private fun AndroidComposeTestRule<*, *>.setRoomListContextMenu( + contextMenu: RoomListState.ContextMenu.Shown, + canReportRoom: Boolean = false, + eventSink: (RoomListEvents) -> Unit, + onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), + onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), + ) { + setSafeContent { + RoomListContextMenu( + contextMenu = contextMenu, + canReportRoom = canReportRoom, + onRoomSettingsClick = onRoomSettingsClick, + onReportRoomClick = onReportRoomClick, + eventSink = eventSink, + ) + } + } +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenuTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenuTest.kt new file mode 100644 index 0000000..0803993 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenuTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.roomlist + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.home.impl.model.aRoomListRoomSummary +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureCalledOnceWithParam +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.setSafeContent +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RoomListDeclineInviteMenuTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on decline emits the expected Events`() { + val eventsRecorder = EventsRecorder() + val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary()) + rule.setSafeContent { + RoomListDeclineInviteMenu( + menu = menu, + canReportRoom = false, + onDeclineAndBlockClick = EnsureNeverCalledWithParam(), + eventSink = eventsRecorder, + ) + } + rule.clickOn(CommonStrings.action_decline) + eventsRecorder.assertList( + listOf( + RoomListEvents.HideDeclineInviteMenu, + RoomListEvents.DeclineInvite(menu.roomSummary, blockUser = false), + ) + ) + } + + @Test + fun `clicking on decline and block when canReportRoom=true, it emits the expected Events and callback`() { + val eventsRecorder = EventsRecorder() + val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary()) + rule.setSafeContent { + RoomListDeclineInviteMenu( + menu = menu, + canReportRoom = true, + onDeclineAndBlockClick = EnsureCalledOnceWithParam(menu.roomSummary, Unit), + eventSink = eventsRecorder, + ) + } + rule.clickOn(CommonStrings.action_decline_and_block) + val expectedEvents = listOf(RoomListEvents.HideDeclineInviteMenu) + eventsRecorder.assertList(expectedEvents) + } + + @Test + fun `clicking on decline and block when canReportRoom=false, it emits the expected Events`() { + val eventsRecorder = EventsRecorder() + val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary()) + rule.setSafeContent { + RoomListDeclineInviteMenu( + menu = menu, + canReportRoom = false, + onDeclineAndBlockClick = EnsureNeverCalledWithParam(), + eventSink = eventsRecorder, + ) + } + rule.clickOn(CommonStrings.action_decline_and_block) + val expectedEvents = listOf( + RoomListEvents.HideDeclineInviteMenu, + RoomListEvents.DeclineInvite(menu.roomSummary, blockUser = true), + ) + eventsRecorder.assertList(expectedEvents) + } + + @Test + fun `clicking on cancel emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary()) + rule.setSafeContent { + RoomListDeclineInviteMenu( + menu = menu, + canReportRoom = false, + onDeclineAndBlockClick = EnsureNeverCalledWithParam(), + eventSink = eventsRecorder, + ) + } + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertList(listOf(RoomListEvents.HideDeclineInviteMenu)) + } +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt new file mode 100644 index 0000000..acc68db --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt @@ -0,0 +1,679 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.roomlist + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.Interaction +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService +import io.element.android.features.home.impl.FakeDateTimeObserver +import io.element.android.features.home.impl.datasource.RoomListDataSource +import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory +import io.element.android.features.home.impl.filters.RoomListFiltersState +import io.element.android.features.home.impl.filters.aRoomListFiltersState +import io.element.android.features.home.impl.model.createRoomListRoomSummary +import io.element.android.features.home.impl.search.RoomListSearchEvents +import io.element.android.features.home.impl.search.RoomListSearchState +import io.element.android.features.home.impl.search.aRoomListSearchState +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState +import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState +import io.element.android.features.invite.test.InMemorySeenInvitesStore +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.features.rageshake.test.logs.FakeAnnouncementService +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.test.FakeDateFormatter +import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter +import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter +import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState +import io.element.android.libraries.matrix.api.MatrixClient +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.encryption.RecoveryState +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.roomlist.RoomList +import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.libraries.matrix.api.timeline.ReceiptType +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_ID_3 +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.encryption.FakeEncryptionService +import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.libraries.matrix.test.sync.FakeSyncService +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore +import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.libraries.push.api.battery.aBatteryOptimizationState +import io.element.android.libraries.push.api.notifications.NotificationCleaner +import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +class RoomListPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - load 1 room with success`() = runTest { + val roomListService = FakeRoomListService() + val matrixClient = FakeMatrixClient( + roomListService = roomListService + ) + val presenter = createRoomListPresenter( + client = matrixClient, + seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)), + ) + presenter.test { + val initialState = consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Skeleton }.last() + assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Skeleton::class.java) + roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) + roomListService.postAllRooms( + listOf( + aRoomSummary( + numUnreadMentions = 1, + numUnreadMessages = 2, + ) + ) + ) + val withRoomsState = + consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Rooms && state.contentAsRooms().summaries.isNotEmpty() }.last() + assertThat(withRoomsState.contentAsRooms().summaries).hasSize(1) + assertThat(withRoomsState.contentAsRooms().summaries.first()).isEqualTo( + createRoomListRoomSummary( + numberOfUnreadMentions = 1, + numberOfUnreadMessages = 2, + timestamp = "0 TimeOrDate true", + ) + ) + assertThat(withRoomsState.contentAsRooms().seenRoomInvites).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - handle DismissRequestVerificationPrompt`() = runTest { + val roomListService = FakeRoomListService().apply { + postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) + } + val encryptionService = FakeEncryptionService().apply { + emitRecoveryState(RecoveryState.INCOMPLETE) + } + val syncService = FakeSyncService(initialSyncState = SyncState.Running) + val presenter = createRoomListPresenter( + client = FakeMatrixClient(roomListService = roomListService, encryptionService = encryptionService, syncService = syncService), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val eventWithContentAsRooms = consumeItemsUntilPredicate { + it.contentState is RoomListContentState.Rooms + }.last() + val eventSink = eventWithContentAsRooms.eventSink + assertThat(eventWithContentAsRooms.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation) + eventSink(RoomListEvents.DismissRequestVerificationPrompt) + assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None) + } + } + + @Test + fun `present - handle DismissRecoveryKeyPrompt`() = runTest { + val encryptionService = FakeEncryptionService().apply { + recoveryStateStateFlow.emit(RecoveryState.DISABLED) + } + val roomListService = FakeRoomListService().apply { + postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) + } + val matrixClient = FakeMatrixClient( + roomListService = roomListService, + encryptionService = encryptionService, + sessionVerificationService = FakeSessionVerificationService().apply { + emitNeedsSessionVerification(false) + }, + syncService = FakeSyncService(initialSyncState = SyncState.Running), + ) + val presenter = createRoomListPresenter( + client = matrixClient, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = consumeItemsUntilPredicate { + it.contentState is RoomListContentState.Rooms + }.last() + assertThat(initialState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SetUpRecovery) + encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE) + val nextState = awaitItem() + assertThat(nextState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation) + // Also check other states + encryptionService.emitRecoveryState(RecoveryState.DISABLED) + assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SetUpRecovery) + encryptionService.emitRecoveryState(RecoveryState.WAITING_FOR_SYNC) + assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None) + encryptionService.emitRecoveryState(RecoveryState.DISABLED) + assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SetUpRecovery) + encryptionService.emitRecoveryState(RecoveryState.ENABLED) + assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None) + encryptionService.emitRecoveryState(RecoveryState.DISABLED) + assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SetUpRecovery) + nextState.eventSink(RoomListEvents.DismissBanner) + val finalState = awaitItem() + assertThat(finalState.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None) + } + } + + @Test + fun `present - show context menu`() = runTest { + val room = FakeBaseRoom() + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val presenter = createRoomListPresenter(client = client) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val summary = createRoomListRoomSummary() + initialState.eventSink(RoomListEvents.ShowContextMenu(summary)) + + awaitItem().also { state -> + assertThat(state.contextMenu) + .isEqualTo( + RoomListState.ContextMenu.Shown( + roomId = summary.roomId, + roomName = summary.name, + isDm = false, + isFavorite = false, + hasNewContent = false, + displayClearRoomCacheAction = false, + ) + ) + } + + room.givenRoomInfo( + aRoomInfo(isFavorite = true) + ) + awaitItem().also { state -> + assertThat(state.contextMenu) + .isEqualTo( + RoomListState.ContextMenu.Shown( + roomId = summary.roomId, + roomName = summary.name, + isDm = false, + isFavorite = true, + hasNewContent = false, + displayClearRoomCacheAction = false, + ) + ) + } + } + } + + @Test + fun `present - show context menu with view source on`() = runTest { + val presenter = createRoomListPresenter( + appPreferencesStore = InMemoryAppPreferencesStore( + isDeveloperModeEnabled = true, + ) + ) + presenter.test { + val initialState = awaitItem() + val summary = createRoomListRoomSummary() + initialState.eventSink(RoomListEvents.ShowContextMenu(summary)) + awaitItem().also { state -> + assertThat(state.contextMenu) + .isEqualTo( + RoomListState.ContextMenu.Shown( + roomId = summary.roomId, + roomName = summary.name, + isDm = false, + isFavorite = false, + // true here. + hasNewContent = false, + displayClearRoomCacheAction = true, + ) + ) + } + } + } + + @Test + fun `present - hide context menu`() = runTest { + val room = FakeBaseRoom() + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val presenter = createRoomListPresenter(client = client) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val summary = createRoomListRoomSummary() + initialState.eventSink(RoomListEvents.ShowContextMenu(summary)) + + val shownState = awaitItem() + assertThat(shownState.contextMenu) + .isEqualTo( + RoomListState.ContextMenu.Shown( + roomId = summary.roomId, + roomName = summary.name, + isDm = false, + isFavorite = false, + hasNewContent = false, + displayClearRoomCacheAction = false, + ) + ) + + shownState.eventSink(RoomListEvents.HideContextMenu) + + val hiddenState = awaitItem() + assertThat(hiddenState.contextMenu).isEqualTo(RoomListState.ContextMenu.Hidden) + } + } + + @Test + fun `present - leave room calls into leave room presenter`() = runTest { + val leaveRoomEventsRecorder = EventsRecorder() + val presenter = createRoomListPresenter( + leaveRoomState = aLeaveRoomState(eventSink = leaveRoomEventsRecorder), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RoomListEvents.LeaveRoom(A_ROOM_ID, needsConfirmation = true)) + leaveRoomEventsRecorder.assertSingle(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - toggle search menu`() = runTest { + val eventRecorder = EventsRecorder() + val searchPresenter: Presenter = Presenter { + aRoomListSearchState( + eventSink = eventRecorder + ) + } + val presenter = createRoomListPresenter( + searchPresenter = searchPresenter, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + eventRecorder.assertEmpty() + initialState.eventSink(RoomListEvents.ToggleSearchResults) + eventRecorder.assertSingle( + RoomListSearchEvents.ToggleSearchVisibility + ) + initialState.eventSink(RoomListEvents.ToggleSearchResults) + eventRecorder.assertList( + listOf( + RoomListSearchEvents.ToggleSearchVisibility, + RoomListSearchEvents.ToggleSearchVisibility + ) + ) + } + } + + @Test + fun `present - change in notification settings updates the summary for decorations`() = runTest { + val userDefinedMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + val notificationSettingsService = FakeNotificationSettingsService() + val roomListService = FakeRoomListService() + roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) + roomListService.postAllRooms(listOf(aRoomSummary(userDefinedNotificationMode = userDefinedMode))) + val matrixClient = FakeMatrixClient( + roomListService = roomListService, + notificationSettingsService = notificationSettingsService + ) + val presenter = createRoomListPresenter(client = matrixClient) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + notificationSettingsService.setRoomNotificationMode(A_ROOM_ID, userDefinedMode) + val updatedState = consumeItemsUntilPredicate { state -> + (state.contentState as? RoomListContentState.Rooms)?.summaries.orEmpty().any { summary -> + summary.id == A_ROOM_ID.value && summary.userDefinedNotificationMode == userDefinedMode + } + }.last() + + val room = updatedState.contentAsRooms().summaries.find { it.id == A_ROOM_ID.value } + assertThat(room?.userDefinedNotificationMode).isEqualTo(userDefinedMode) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - when set is favorite event is emitted, then the action is called`() = runTest { + val setIsFavoriteResult = lambdaRecorder { _: Boolean -> Result.success(Unit) } + val room = FakeBaseRoom( + setIsFavoriteResult = setIsFavoriteResult + ) + val analyticsService = FakeAnalyticsService() + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val presenter = createRoomListPresenter(client = client, analyticsService = analyticsService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RoomListEvents.SetRoomIsFavorite(A_ROOM_ID, true)) + setIsFavoriteResult.assertions().isCalledOnce().with(value(true)) + initialState.eventSink(RoomListEvents.SetRoomIsFavorite(A_ROOM_ID, false)) + setIsFavoriteResult.assertions().isCalledExactly(2) + .withSequence( + listOf(value(true)), + listOf(value(false)), + ) + assertThat(analyticsService.capturedEvents).containsExactly( + Interaction(name = Interaction.Name.MobileRoomListRoomContextMenuFavouriteToggle), + Interaction(name = Interaction.Name.MobileRoomListRoomContextMenuFavouriteToggle) + ) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - when room service returns no room, then contentState is Empty`() = runTest { + val roomListService = FakeRoomListService() + roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(0)) + val matrixClient = FakeMatrixClient( + roomListService = roomListService, + ) + val presenter = createRoomListPresenter( + client = matrixClient, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + assertThat(awaitItem().contentState).isInstanceOf(RoomListContentState.Empty::class.java) + } + } + + @Test + fun `present - check that the room is marked as read with correct RR and as unread`() = runTest { + val markAsReadResult = lambdaRecorder> { Result.success(Unit) } + val markAsReadResult3 = lambdaRecorder> { Result.success(Unit) } + val room = FakeBaseRoom( + markAsReadResult = markAsReadResult, + ) + val room2 = FakeBaseRoom( + roomId = A_ROOM_ID_2, + ) + val room3 = FakeBaseRoom( + roomId = A_ROOM_ID_3, + markAsReadResult = markAsReadResult3, + ) + val allRooms = setOf(room, room2, room3) + val sessionPreferencesStore = InMemorySessionPreferencesStore() + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + givenGetRoomResult(A_ROOM_ID_2, room2) + givenGetRoomResult(A_ROOM_ID_3, room3) + } + val analyticsService = FakeAnalyticsService() + val clearMessagesForRoomLambda = lambdaRecorder { _, _ -> } + val notificationCleaner = FakeNotificationCleaner( + clearMessagesForRoomLambda = clearMessagesForRoomLambda, + ) + val presenter = createRoomListPresenter( + client = matrixClient, + sessionPreferencesStore = sessionPreferencesStore, + analyticsService = analyticsService, + notificationCleaner = notificationCleaner, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + allRooms.forEach { + assertThat(it.setUnreadFlagCalls).isEmpty() + } + initialState.eventSink.invoke(RoomListEvents.MarkAsRead(A_ROOM_ID)) + markAsReadResult.assertions().isCalledOnce().with(value(ReceiptType.READ)) + assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false)) + clearMessagesForRoomLambda.assertions().isCalledOnce() + .with(value(A_SESSION_ID), value(A_ROOM_ID)) + initialState.eventSink.invoke(RoomListEvents.MarkAsUnread(A_ROOM_ID_2)) + assertThat(room2.setUnreadFlagCalls).isEqualTo(listOf(true)) + // Test again with private read receipts + sessionPreferencesStore.setSendPublicReadReceipts(false) + initialState.eventSink.invoke(RoomListEvents.MarkAsRead(A_ROOM_ID_3)) + markAsReadResult3.assertions().isCalledOnce().with(value(ReceiptType.READ_PRIVATE)) + assertThat(room3.setUnreadFlagCalls).isEqualTo(listOf(false)) + clearMessagesForRoomLambda.assertions().isCalledExactly(2) + .withSequence( + listOf(value(A_SESSION_ID), value(A_ROOM_ID)), + listOf(value(A_SESSION_ID), value(A_ROOM_ID_3)), + ) + assertThat(analyticsService.capturedEvents).containsExactly( + Interaction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle), + Interaction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle), + Interaction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle), + ) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - when a room is invited then accept and decline events are sent to acceptDeclinePresenter`() = runTest { + val eventSinkRecorder = lambdaRecorder { _: AcceptDeclineInviteEvents -> } + val acceptDeclinePresenter = Presenter { + anAcceptDeclineInviteState(eventSink = eventSinkRecorder) + } + val roomListService = FakeRoomListService() + val matrixClient = FakeMatrixClient( + roomListService = roomListService, + ) + val roomSummary = aRoomSummary( + currentUserMembership = CurrentUserMembership.INVITED, + inviter = aRoomMember(), + ) + roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) + roomListService.postAllRooms(listOf(roomSummary)) + val presenter = createRoomListPresenter( + client = matrixClient, + acceptDeclineInvitePresenter = acceptDeclinePresenter + ) + presenter.test { + val state = consumeItemsUntilPredicate { + it.contentState is RoomListContentState.Rooms + }.last() + + val roomListRoomSummary = state.contentAsRooms().summaries.first { + it.id == roomSummary.roomId.value + } + + state.eventSink(RoomListEvents.AcceptInvite(roomListRoomSummary)) + state.eventSink(RoomListEvents.DeclineInvite(roomListRoomSummary, blockUser = false)) + + val inviteData = roomListRoomSummary.toInviteData() + assert(eventSinkRecorder) + .isCalledExactly(2) + .withSequence( + listOf(value(AcceptDeclineInviteEvents.AcceptInvite(inviteData))), + listOf(value(AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = false))), + ) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - UpdateVisibleRange will cancel the previous subscription if called too soon`() = runTest { + val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List -> } + val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda) + val matrixClient = FakeMatrixClient( + roomListService = roomListService, + ) + val roomSummary = aRoomSummary( + currentUserMembership = CurrentUserMembership.INVITED + ) + roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) + roomListService.postAllRooms(listOf(roomSummary)) + val presenter = createRoomListPresenter( + client = matrixClient, + ) + presenter.test { + val state = consumeItemsUntilPredicate { + it.contentState is RoomListContentState.Rooms + }.last() + + state.eventSink(RoomListEvents.UpdateVisibleRange(IntRange(0, 10))) + // If called again, it will cancel the current one, which should not result in a test failure + state.eventSink(RoomListEvents.UpdateVisibleRange(IntRange(0, 11))) + advanceTimeBy(1.seconds) + subscribeToVisibleRoomsLambda.assertions().isCalledOnce() + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - UpdateVisibleRange subscribes to rooms in visible range`() = runTest { + val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List -> } + val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda) + val matrixClient = FakeMatrixClient( + roomListService = roomListService, + ) + val roomSummary = aRoomSummary( + currentUserMembership = CurrentUserMembership.INVITED + ) + roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) + roomListService.postAllRooms(listOf(roomSummary)) + val presenter = createRoomListPresenter( + client = matrixClient, + ) + presenter.test { + val state = consumeItemsUntilPredicate { + it.contentState is RoomListContentState.Rooms + }.last() + + state.eventSink(RoomListEvents.UpdateVisibleRange(IntRange(0, 10))) + advanceTimeBy(1.seconds) + subscribeToVisibleRoomsLambda.assertions().isCalledOnce() + + // If called again, it will subscribe to the next items + state.eventSink(RoomListEvents.UpdateVisibleRange(IntRange(0, 11))) + advanceTimeBy(1.seconds) + subscribeToVisibleRoomsLambda.assertions().isCalledExactly(2) + } + } + + @Test + fun `present - notification sound banner`() = runTest { + val subscribeToVisibleRoomsLambda = lambdaRecorder { _: List -> } + val roomListService = FakeRoomListService(subscribeToVisibleRoomsLambda = subscribeToVisibleRoomsLambda) + val matrixClient = FakeMatrixClient( + roomListService = roomListService, + ) + val roomSummary = aRoomSummary( + currentUserMembership = CurrentUserMembership.INVITED + ) + roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) + roomListService.postAllRooms(listOf(roomSummary)) + val onAnnouncementDismissedResult = lambdaRecorder { } + val announcementService = FakeAnnouncementService( + onAnnouncementDismissedResult = onAnnouncementDismissedResult, + ) + val presenter = createRoomListPresenter( + client = matrixClient, + announcementService = announcementService, + ) + presenter.test { + assertThat(announcementService.announcementsToShowFlow().first()).isEmpty() + skipItems(1) + val state = awaitItem() + assertThat(state.contentAsRooms().showNewNotificationSoundBanner).isFalse() + announcementService.emitAnnouncementsToShow(listOf(Announcement.NewNotificationSound)) + assertThat(awaitItem().contentAsRooms().showNewNotificationSoundBanner).isTrue() + state.eventSink(RoomListEvents.DismissNewNotificationSoundBanner) + onAnnouncementDismissedResult.assertions().isCalledOnce() + .with(value(Announcement.NewNotificationSound)) + // Simulate service updating the value + announcementService.emitAnnouncementsToShow(emptyList()) + assertThat(awaitItem().contentAsRooms().showNewNotificationSoundBanner).isFalse() + } + } + + private fun TestScope.createRoomListPresenter( + client: MatrixClient = FakeMatrixClient(), + leaveRoomState: LeaveRoomState = aLeaveRoomState(), + dateFormatter: DateFormatter = FakeDateFormatter(), + roomLatestEventFormatter: RoomLatestEventFormatter = FakeRoomLatestEventFormatter(), + sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(), + analyticsService: AnalyticsService = FakeAnalyticsService(), + filtersPresenter: Presenter = Presenter { aRoomListFiltersState() }, + searchPresenter: Presenter = Presenter { aRoomListSearchState() }, + acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() }, + notificationCleaner: NotificationCleaner = FakeNotificationCleaner(), + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), + seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), + announcementService: AnnouncementService = FakeAnnouncementService(), + ) = RoomListPresenter( + client = client, + leaveRoomPresenter = { leaveRoomState }, + roomListDataSource = RoomListDataSource( + roomListService = client.roomListService, + roomListRoomSummaryFactory = aRoomListRoomSummaryFactory( + dateFormatter = dateFormatter, + roomLatestEventFormatter = roomLatestEventFormatter, + ), + coroutineDispatchers = testCoroutineDispatchers(), + notificationSettingsService = client.notificationSettingsService, + sessionCoroutineScope = backgroundScope, + dateTimeObserver = FakeDateTimeObserver(), + ), + searchPresenter = searchPresenter, + sessionPreferencesStore = sessionPreferencesStore, + filtersPresenter = filtersPresenter, + analyticsService = analyticsService, + acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, + fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() }, + batteryOptimizationPresenter = { aBatteryOptimizationState() }, + notificationCleaner = notificationCleaner, + appPreferencesStore = appPreferencesStore, + seenInvitesStore = seenInvitesStore, + announcementService = announcementService, + coldStartWatcher = FakeAnalyticsColdStartWatcher(), + ) +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt new file mode 100644 index 0000000..2f158ec --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.roomlist + +internal fun RoomListState.contentAsRooms() = contentState as RoomListContentState.Rooms diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt new file mode 100644 index 0000000..bb82d51 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.roomlist + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.home.impl.HomeView +import io.element.android.features.home.impl.R +import io.element.android.features.home.impl.aHomeState +import io.element.android.features.home.impl.components.RoomListMenuAction +import io.element.android.features.home.impl.model.RoomListRoomSummary +import io.element.android.features.home.impl.model.RoomSummaryDisplayType +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.setSafeContent +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class RoomListViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Config(qualifiers = "h1024dp") + @Test + fun `displaying the view automatically sends a couple of UpdateVisibleRangeEvents`() { + val eventsRecorder = EventsRecorder() + rule.setRoomListView( + state = aRoomListState( + contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation), + eventSink = eventsRecorder, + ) + ) + + eventsRecorder.assertList( + listOf( + RoomListEvents.UpdateVisibleRange(IntRange.EMPTY), + RoomListEvents.UpdateVisibleRange(0..5), + ) + ) + } + + @Test + fun `clicking on close recovery key banner emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomListView( + state = aRoomListState( + contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation), + eventSink = eventsRecorder, + ) + ) + + // Remove automatic initial events + eventsRecorder.clear() + + val close = rule.activity.getString(CommonStrings.action_close) + rule.onNodeWithContentDescription(close).performClick() + eventsRecorder.assertSingle(RoomListEvents.DismissBanner) + } + + @Test + fun `clicking on close setup key banner emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomListView( + state = aRoomListState( + contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery), + eventSink = eventsRecorder, + ) + ) + + // Remove automatic initial events + eventsRecorder.clear() + + val close = rule.activity.getString(CommonStrings.action_close) + rule.onNodeWithContentDescription(close).performClick() + eventsRecorder.assertSingle(RoomListEvents.DismissBanner) + } + + @Test + fun `clicking on continue recovery key banner invokes the expected callback`() { + val eventsRecorder = EventsRecorder() + ensureCalledOnce { callback -> + rule.setRoomListView( + state = aRoomListState( + contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation), + eventSink = eventsRecorder, + ), + onConfirmRecoveryKeyClick = callback, + ) + + // Remove automatic initial events + eventsRecorder.clear() + + rule.clickOn(CommonStrings.action_continue) + + eventsRecorder.assertEmpty() + } + } + + @Test + fun `clicking on continue setup key banner invokes the expected callback`() { + val eventsRecorder = EventsRecorder() + ensureCalledOnce { callback -> + rule.setRoomListView( + state = aRoomListState( + contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery), + eventSink = eventsRecorder, + ), + onSetUpRecoveryClick = callback, + ) + // Remove automatic initial events + eventsRecorder.clear() + rule.clickOn(R.string.banner_set_up_recovery_submit) + eventsRecorder.assertEmpty() + } + } + + @Test + fun `clicking on start chat when the session has no room invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setRoomListView( + state = aRoomListState( + eventSink = eventsRecorder, + contentState = anEmptyContentState(), + ), + onCreateRoomClick = callback, + ) + rule.clickOn(CommonStrings.action_start_chat) + } + } + + @Test + fun `clicking on a room invokes the expected callback`() { + val eventsRecorder = EventsRecorder() + val state = aRoomListState( + eventSink = eventsRecorder, + ) + val room0 = state.contentAsRooms().summaries.first { + it.displayType == RoomSummaryDisplayType.ROOM + } + ensureCalledOnceWithParam(room0.roomId) { callback -> + rule.setRoomListView( + state = state, + onRoomClick = callback, + ) + + // Remove automatic initial events + eventsRecorder.clear() + + rule.onNodeWithText(room0.latestEvent.content().toString()).performClick() + } + + eventsRecorder.assertEmpty() + } + + @Test + fun `clicking on a room twice invokes the expected callback only once`() { + val eventsRecorder = EventsRecorder() + val state = aRoomListState( + eventSink = eventsRecorder, + ) + val room0 = state.contentAsRooms().summaries.first { + it.displayType == RoomSummaryDisplayType.ROOM + } + ensureCalledOnceWithParam(room0.roomId) { callback -> + rule.setRoomListView( + state = state, + onRoomClick = callback, + ) + // Remove automatic initial events + eventsRecorder.clear() + rule.onNodeWithText(room0.latestEvent.content().toString()) + .performClick() + .performClick() + } + eventsRecorder.assertEmpty() + } + + @Test + fun `long clicking on a room emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val state = aRoomListState( + eventSink = eventsRecorder, + ) + val room0 = state.contentAsRooms().summaries.first { + it.displayType == RoomSummaryDisplayType.ROOM + } + rule.setRoomListView( + state = state, + ) + // Remove automatic initial events + eventsRecorder.clear() + + rule.onNodeWithText(room0.latestEvent.content().toString()).performTouchInput { longClick() } + eventsRecorder.assertSingle(RoomListEvents.ShowContextMenu(room0)) + } + + @Test + fun `clicking on a room setting invokes the expected callback and emits expected Event`() { + val eventsRecorder = EventsRecorder() + val state = aRoomListState( + contextMenu = aContextMenuShown(), + eventSink = eventsRecorder, + ) + val room0 = (state.contextMenu as RoomListState.ContextMenu.Shown).roomId + ensureCalledOnceWithParam(room0) { callback -> + rule.setRoomListView( + state = state, + onRoomSettingsClick = callback, + ) + + // Remove automatic initial events + eventsRecorder.clear() + + rule.clickOn(CommonStrings.common_settings) + } + + eventsRecorder.assertSingle(RoomListEvents.HideContextMenu) + } + + @Test + fun `clicking on accept and decline invite emits the expected Events`() { + val eventsRecorder = EventsRecorder() + val state = aRoomListState( + eventSink = eventsRecorder, + ) + val invitedRoom = state.contentAsRooms().summaries.first { + it.displayType == RoomSummaryDisplayType.INVITE + } + rule.setRoomListView(state = state) + + // Remove automatic initial events + eventsRecorder.clear() + + rule.clickOn(CommonStrings.action_accept) + rule.clickOn(CommonStrings.action_decline) + eventsRecorder.assertList( + listOf( + RoomListEvents.AcceptInvite(invitedRoom), + RoomListEvents.ShowDeclineInviteMenu(invitedRoom), + ) + ) + } +} + +private fun AndroidComposeTestRule.setRoomListView( + state: RoomListState, + onRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), + onSettingsClick: () -> Unit = EnsureNeverCalled(), + onSetUpRecoveryClick: () -> Unit = EnsureNeverCalled(), + onConfirmRecoveryKeyClick: () -> Unit = EnsureNeverCalled(), + onCreateRoomClick: () -> Unit = EnsureNeverCalled(), + onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), + onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(), + onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), + onDeclineInviteAndBlockUser: (RoomListRoomSummary) -> Unit = EnsureNeverCalledWithParam(), +) { + setSafeContent { + HomeView( + homeState = aHomeState(roomListState = state), + onRoomClick = onRoomClick, + onSettingsClick = onSettingsClick, + onSetUpRecoveryClick = onSetUpRecoveryClick, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + onStartChatClick = onCreateRoomClick, + onRoomSettingsClick = onRoomSettingsClick, + onMenuActionClick = onMenuActionClick, + onDeclineInviteAndBlockUser = onDeclineInviteAndBlockUser, + onReportRoomClick = onReportRoomClick, + acceptDeclineInviteView = {}, + leaveRoomView = {}, + ) + } +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt new file mode 100644 index 0000000..dee0601 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.search + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory +import io.element.android.libraries.dateformatter.test.FakeDateFormatter +import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RoomListSearchPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createRoomListSearchPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().let { state -> + assertThat(state.isSearchActive).isFalse() + assertThat(state.query).isEmpty() + assertThat(state.results).isEmpty() + } + } + } + + @Test + fun `present - toggle search visibility`() = runTest { + val presenter = createRoomListSearchPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().let { state -> + assertThat(state.isSearchActive).isFalse() + state.eventSink(RoomListSearchEvents.ToggleSearchVisibility) + } + awaitItem().let { state -> + assertThat(state.isSearchActive).isTrue() + state.eventSink(RoomListSearchEvents.ToggleSearchVisibility) + } + awaitItem().let { state -> + assertThat(state.isSearchActive).isFalse() + } + } + } + + @Test + fun `present - query search changes`() = runTest { + val roomListService = FakeRoomListService() + val presenter = createRoomListSearchPresenter(roomListService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().let { state -> + assertThat( + roomListService.allRooms.currentFilter.value + ).isEqualTo( + RoomListFilter.None + ) + state.eventSink(RoomListSearchEvents.QueryChanged("Search")) + } + awaitItem().let { state -> + assertThat(state.query).isEqualTo("Search") + assertThat( + roomListService.allRooms.currentFilter.value + ).isEqualTo( + RoomListFilter.NormalizedMatchRoomName("Search") + ) + state.eventSink(RoomListSearchEvents.ClearQuery) + } + awaitItem().let { state -> + assertThat(state.query).isEmpty() + assertThat( + roomListService.allRooms.currentFilter.value + ).isEqualTo( + RoomListFilter.None + ) + } + } + } + + @Test + fun `present - room list changes`() = runTest { + val roomListService = FakeRoomListService() + val presenter = createRoomListSearchPresenter(roomListService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().let { state -> + assertThat(state.results).isEmpty() + } + roomListService.postAllRooms( + listOf(aRoomSummary()) + ) + awaitItem().let { state -> + assertThat(state.results).hasSize(1) + } + roomListService.postAllRooms(emptyList()) + awaitItem().let { state -> + assertThat(state.results).isEmpty() + } + } + } +} + +fun TestScope.createRoomListSearchPresenter( + roomListService: RoomListService = FakeRoomListService(), +): RoomListSearchPresenter { + return RoomListSearchPresenter( + dataSource = RoomListSearchDataSource( + roomListService = roomListService, + roomSummaryFactory = aRoomListRoomSummaryFactory( + dateFormatter = FakeDateFormatter(), + roomLatestEventFormatter = FakeRoomLatestEventFormatter(), + ), + coroutineDispatchers = testCoroutineDispatchers(), + ), + ) +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenterTest.kt new file mode 100644 index 0000000..c760883 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenterTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.spaces + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.features.invite.test.InMemorySeenInvitesStore +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class HomeSpacesPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + presenter.test { + val state = awaitItem() + assertThat(state.space).isEqualTo(CurrentSpace.Root) + assertThat(state.spaceRooms).isEmpty() + assertThat(state.hideInvitesAvatar).isFalse() + assertThat(state.seenSpaceInvites).isEmpty() + } + } + + private fun createPresenter( + client: MatrixClient = FakeMatrixClient(), + seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), + ) = HomeSpacesPresenter( + client = client, + seenInvitesStore = seenInvitesStore, + ) +} diff --git a/features/invite/api/build.gradle.kts b/features/invite/api/build.gradle.kts new file mode 100644 index 0000000..084ad10 --- /dev/null +++ b/features/invite/api/build.gradle.kts @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.invite.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.services.analytics.api) +} diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/InviteData.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/InviteData.kt new file mode 100644 index 0000000..696e02a --- /dev/null +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/InviteData.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.api + +import android.os.Parcelable +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import kotlinx.parcelize.Parcelize + +@Parcelize +data class InviteData( + val roomId: RoomId, + val roomName: String, + val isDm: Boolean, +) : Parcelable + +fun RoomPreviewInfo.toInviteData(): InviteData { + return InviteData( + roomId = roomId, + roomName = name ?: roomId.value, + isDm = false, + ) +} + +fun RoomInfo.toInviteData(): InviteData { + return InviteData( + roomId = id, + roomName = name ?: id.value, + isDm = isDm, + ) +} + +fun SpaceRoom.toInviteData(): InviteData { + return InviteData( + roomId = roomId, + roomName = displayName, + isDm = false, + ) +} diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt new file mode 100644 index 0000000..046303c --- /dev/null +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.api + +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.flow.Flow + +interface SeenInvitesStore { + /** + * Returns a flow of seen room IDs of invitation. + */ + fun seenRoomIds(): Flow> + + /** + * Mark the invitation as seen. + * Call this when the invitation details are shown to the user. + * @param roomId the room ID of the invitation to mark as seen. + */ + suspend fun markAsSeen(roomId: RoomId) + + /** + * Mark the invitation as unseen. + * Call this when the invitation has been accepted or declined. + * @param roomId the room ID of the invitation to mark as unseen. + */ + suspend fun markAsUnSeen(roomId: RoomId) + + /** + * Delete the store. + */ + suspend fun clear() +} diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteEvents.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteEvents.kt new file mode 100644 index 0000000..2caaaca --- /dev/null +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteEvents.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.api.acceptdecline + +import io.element.android.features.invite.api.InviteData + +interface AcceptDeclineInviteEvents { + data class AcceptInvite(val invite: InviteData) : AcceptDeclineInviteEvents + data class DeclineInvite(val invite: InviteData, val blockUser: Boolean, val shouldConfirm: Boolean) : AcceptDeclineInviteEvents +} diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteState.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteState.kt new file mode 100644 index 0000000..3bfd043 --- /dev/null +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteState.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.api.acceptdecline + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId + +data class AcceptDeclineInviteState( + val acceptAction: AsyncAction, + val declineAction: AsyncAction, + val eventSink: (AcceptDeclineInviteEvents) -> Unit, +) diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteStateProvider.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteStateProvider.kt new file mode 100644 index 0000000..18acc11 --- /dev/null +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteStateProvider.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.api.acceptdecline + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId + +fun anAcceptDeclineInviteState( + acceptAction: AsyncAction = AsyncAction.Uninitialized, + declineAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (AcceptDeclineInviteEvents) -> Unit = {}, +) = AcceptDeclineInviteState( + acceptAction = acceptAction, + declineAction = declineAction, + eventSink = eventSink, +) diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteView.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteView.kt new file mode 100644 index 0000000..b6eec03 --- /dev/null +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteView.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.api.acceptdecline + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.libraries.matrix.api.core.RoomId + +fun interface AcceptDeclineInviteView { + @Composable + fun Render( + state: AcceptDeclineInviteState, + onAcceptInviteSuccess: (RoomId) -> Unit, + onDeclineInviteSuccess: (RoomId) -> Unit, + modifier: Modifier, + ) +} diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/ConfirmingDeclineInvite.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/ConfirmingDeclineInvite.kt new file mode 100644 index 0000000..0ebf675 --- /dev/null +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/ConfirmingDeclineInvite.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.api.acceptdecline + +import io.element.android.features.invite.api.InviteData +import io.element.android.libraries.architecture.AsyncAction + +data class ConfirmingDeclineInvite(val inviteData: InviteData, val blockUser: Boolean) : AsyncAction.Confirming diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt new file mode 100644 index 0000000..4174d99 --- /dev/null +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/declineandblock/DeclineInviteAndBlockEntryPoint.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.api.declineandblock + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.invite.api.InviteData +import io.element.android.libraries.architecture.FeatureEntryPoint + +fun interface DeclineInviteAndBlockEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + inviteData: InviteData, + ): Node +} diff --git a/features/invite/impl/build.gradle.kts b/features/invite/impl/build.gradle.kts new file mode 100644 index 0000000..80b9846 --- /dev/null +++ b/features/invite/impl/build.gradle.kts @@ -0,0 +1,46 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.invite.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + api(projects.features.invite.api) + implementation(libs.androidx.datastore.preferences) + 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.uiStrings) + implementation(projects.services.analytics.api) + implementation(projects.libraries.push.api) + + testCommonDependencies(libs, true) + testImplementation(projects.features.invite.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.push.test) + testImplementation(projects.services.analytics.test) +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/AcceptInvite.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/AcceptInvite.kt new file mode 100644 index 0000000..217c5e4 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/AcceptInvite.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl + +import dev.zacsweers.metro.ContributesBinding +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.libraries.core.extensions.mapFailure +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.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.exception.ClientException +import io.element.android.libraries.matrix.api.exception.ErrorKind +import io.element.android.libraries.matrix.api.room.join.JoinRoom +import io.element.android.libraries.push.api.notifications.NotificationCleaner + +interface AcceptInvite { + suspend operator fun invoke(roomId: RoomId): Result + + sealed class Failures : Exception() { + data object InvalidInvite : Failures() + } +} + +@ContributesBinding(SessionScope::class) +class DefaultAcceptInvite( + private val client: MatrixClient, + private val joinRoom: JoinRoom, + private val notificationCleaner: NotificationCleaner, + private val seenInvitesStore: SeenInvitesStore, +) : AcceptInvite { + override suspend fun invoke(roomId: RoomId): Result { + return joinRoom( + roomIdOrAlias = roomId.toRoomIdOrAlias(), + serverNames = emptyList(), + trigger = JoinedRoom.Trigger.Invite, + ).onSuccess { + notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId) + seenInvitesStore.markAsUnSeen(roomId) + }.mapFailure { + if (it is ClientException.MatrixApi) { + when (it.kind) { + ErrorKind.Unknown -> AcceptInvite.Failures.InvalidInvite + else -> it + } + } else { + it + } + }.map { roomId } + } +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DeclineInvite.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DeclineInvite.kt new file mode 100644 index 0000000..ca3ad28 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DeclineInvite.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.invite.api.SeenInvitesStore +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.push.api.notifications.NotificationCleaner + +interface DeclineInvite { + suspend operator fun invoke( + roomId: RoomId, + blockUser: Boolean, + reportRoom: Boolean, + reportReason: String? + ): Result + + sealed class Exception : kotlin.Exception() { + data object RoomNotFound : Exception() + data object DeclineInviteFailed : Exception() + data object ReportRoomFailed : Exception() + data object BlockUserFailed : Exception() + } +} + +@ContributesBinding(SessionScope::class) +class DefaultDeclineInvite( + private val client: MatrixClient, + private val notificationCleaner: NotificationCleaner, + private val seenInvitesStore: SeenInvitesStore, +) : DeclineInvite { + override suspend fun invoke( + roomId: RoomId, + blockUser: Boolean, + reportRoom: Boolean, + reportReason: String? + ): Result { + val room = client.getRoom(roomId) ?: return Result.failure(DeclineInvite.Exception.RoomNotFound) + room.use { + room.leave() + .onFailure { return Result.failure(DeclineInvite.Exception.DeclineInviteFailed) } + .onSuccess { + notificationCleaner.clearMembershipNotificationForRoom( + sessionId = client.sessionId, + roomId = roomId + ) + seenInvitesStore.markAsUnSeen(roomId) + } + + if (blockUser) { + val userIdToBlock = room.info().inviter?.userId + if (userIdToBlock != null) { + client + .ignoreUser(userIdToBlock) + .onFailure { return Result.failure(DeclineInvite.Exception.BlockUserFailed) } + } + } + if (reportRoom) { + room + .reportRoom(reportReason) + .onFailure { return Result.failure(DeclineInvite.Exception.ReportRoomFailed) } + } + } + return Result.success(roomId) + } +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt new file mode 100644 index 0000000..9e36b68 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStore.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl + +import android.content.Context +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStoreFile +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.libraries.androidutils.file.safeDelete +import io.element.android.libraries.androidutils.hash.hash +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.sessionstorage.api.observer.SessionListener +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val seenInvitesKey = stringSetPreferencesKey("seenInvites") + +class DefaultSeenInvitesStore( + context: Context, + sessionId: SessionId, + sessionCoroutineScope: CoroutineScope, + sessionObserver: SessionObserver, +) : SeenInvitesStore { + init { + sessionObserver.addListener(object : SessionListener { + override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) { + if (sessionId.value == userId) { + clear() + } + } + }) + } + + private val dataStoreFile = sessionId.value.hash().take(16).let { hashedUserId -> + context.preferencesDataStoreFile("session_${hashedUserId}_seen-invites") + } + + private val store = PreferenceDataStoreFactory.create( + scope = sessionCoroutineScope, + migrations = emptyList(), + ) { + dataStoreFile + } + + override fun seenRoomIds(): Flow> = + store.data.map { prefs -> + prefs[seenInvitesKey] + .orEmpty() + .map { RoomId(it) } + .toSet() + } + + override suspend fun markAsSeen(roomId: RoomId) { + store.edit { prefs -> + prefs[seenInvitesKey] = prefs[seenInvitesKey].orEmpty() + roomId.value + } + } + + override suspend fun markAsUnSeen(roomId: RoomId) { + store.edit { prefs -> + prefs[seenInvitesKey] = prefs[seenInvitesKey].orEmpty() - roomId.value + } + } + + override suspend fun clear() { + dataStoreFile.safeDelete() + } +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStoreFactory.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStoreFactory.kt new file mode 100644 index 0000000..b1a46f8 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/DefaultSeenInvitesStoreFactory.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl + +import android.content.Context +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import kotlinx.coroutines.CoroutineScope +import java.util.concurrent.ConcurrentHashMap + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultSeenInvitesStoreFactory( + @ApplicationContext private val context: Context, + private val sessionObserver: SessionObserver, +) : SeenInvitesStoreFactory { + // We can have only one class accessing a single data store, so keep a cache of them. + private val cache = ConcurrentHashMap() + + override fun getOrCreate( + sessionId: SessionId, + sessionCoroutineScope: CoroutineScope, + ): SeenInvitesStore { + return cache.getOrPut(sessionId) { + DefaultSeenInvitesStore( + context = context, + sessionId = sessionId, + sessionCoroutineScope = sessionCoroutineScope, + sessionObserver = sessionObserver, + ) + } + } +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/SeenInvitesStoreFactory.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/SeenInvitesStoreFactory.kt new file mode 100644 index 0000000..5b5ef54 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/SeenInvitesStoreFactory.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl + +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.CoroutineScope + +interface SeenInvitesStoreFactory { + fun getOrCreate( + sessionId: SessionId, + sessionCoroutineScope: CoroutineScope, + ): SeenInvitesStore +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInvitePresenter.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInvitePresenter.kt new file mode 100644 index 0000000..9be255f --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInvitePresenter.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl.acceptdecline + +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 dev.zacsweers.metro.Inject +import io.element.android.features.invite.api.InviteData +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState +import io.element.android.features.invite.api.acceptdecline.ConfirmingDeclineInvite +import io.element.android.features.invite.impl.AcceptInvite +import io.element.android.features.invite.impl.DeclineInvite +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Inject +class AcceptDeclineInvitePresenter( + private val acceptInvite: AcceptInvite, + private val declineInvite: DeclineInvite, +) : Presenter { + @Composable + override fun present(): AcceptDeclineInviteState { + val localCoroutineScope = rememberCoroutineScope() + val acceptedAction: MutableState> = + remember { mutableStateOf(AsyncAction.Uninitialized) } + val declinedAction: MutableState> = + remember { mutableStateOf(AsyncAction.Uninitialized) } + + fun handleEvent(event: AcceptDeclineInviteEvents) { + when (event) { + is AcceptDeclineInviteEvents.AcceptInvite -> { + localCoroutineScope.acceptInvite(event.invite.roomId, acceptedAction) + } + + is AcceptDeclineInviteEvents.DeclineInvite -> { + val inviteData = event.invite + if (event.shouldConfirm) { + declinedAction.value = ConfirmingDeclineInvite(inviteData, event.blockUser) + } else { + localCoroutineScope.declineInvite( + inviteData = inviteData, + blockUser = event.blockUser, + declinedAction = declinedAction, + ) + } + } + is InternalAcceptDeclineInviteEvents.ClearAcceptActionState -> { + acceptedAction.value = AsyncAction.Uninitialized + } + + is InternalAcceptDeclineInviteEvents.ClearDeclineActionState -> { + declinedAction.value = AsyncAction.Uninitialized + } + } + } + + return AcceptDeclineInviteState( + acceptAction = acceptedAction.value, + declineAction = declinedAction.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.acceptInvite( + roomId: RoomId, + acceptedAction: MutableState>, + ) = launch { + acceptedAction.runUpdatingState { + acceptInvite(roomId) + } + } + + private fun CoroutineScope.declineInvite( + inviteData: InviteData, + blockUser: Boolean, + declinedAction: MutableState>, + ) = launch { + declinedAction.runUpdatingState { + declineInvite( + roomId = inviteData.roomId, + blockUser = blockUser, + reportRoom = false, + reportReason = null + ) + } + } +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteStateProvider.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteStateProvider.kt new file mode 100644 index 0000000..3f8bf93 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteStateProvider.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl.acceptdecline + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.invite.api.InviteData +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState +import io.element.android.features.invite.api.acceptdecline.ConfirmingDeclineInvite +import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState +import io.element.android.features.invite.impl.AcceptInvite +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId + +open class AcceptDeclineInviteStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anAcceptDeclineInviteState(), + anAcceptDeclineInviteState( + declineAction = ConfirmingDeclineInvite( + InviteData( + roomId = RoomId("!room:matrix.org"), + isDm = true, + roomName = "Alice" + ), + blockUser = false, + ), + ), + anAcceptDeclineInviteState( + declineAction = ConfirmingDeclineInvite( + InviteData( + roomId = RoomId("!room:matrix.org"), + isDm = true, + roomName = "Alice" + ), + blockUser = true, + ), + ), + anAcceptDeclineInviteState( + acceptAction = AsyncAction.Failure(RuntimeException("Error while accepting invite")), + ), + anAcceptDeclineInviteState( + acceptAction = AsyncAction.Failure(AcceptInvite.Failures.InvalidInvite), + ), + anAcceptDeclineInviteState( + declineAction = AsyncAction.Failure(RuntimeException("Error while declining invite")), + ), + ) +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteView.kt new file mode 100644 index 0000000..da035f0 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteView.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl.acceptdecline + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.invite.api.InviteData +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState +import io.element.android.features.invite.api.acceptdecline.ConfirmingDeclineInvite +import io.element.android.features.invite.impl.AcceptInvite +import io.element.android.features.invite.impl.R +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun AcceptDeclineInviteView( + state: AcceptDeclineInviteState, + onAcceptInviteSuccess: (RoomId) -> Unit, + onDeclineInviteSuccess: (RoomId) -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + AsyncActionView( + async = state.acceptAction, + onSuccess = { roomId -> + state.eventSink(InternalAcceptDeclineInviteEvents.ClearAcceptActionState) + onAcceptInviteSuccess(roomId) + }, + onErrorDismiss = { + state.eventSink(InternalAcceptDeclineInviteEvents.ClearAcceptActionState) + }, + errorTitle = { + stringResource(CommonStrings.common_something_went_wrong) + }, + errorMessage = { error -> + if (error is AcceptInvite.Failures.InvalidInvite) { + stringResource(CommonStrings.error_invalid_invite) + } else { + stringResource(CommonStrings.error_network_or_server_issue) + } + } + ) + AsyncActionView( + async = state.declineAction, + onSuccess = { roomId -> + state.eventSink(InternalAcceptDeclineInviteEvents.ClearDeclineActionState) + onDeclineInviteSuccess(roomId) + }, + onErrorDismiss = { + state.eventSink(InternalAcceptDeclineInviteEvents.ClearDeclineActionState) + }, + errorTitle = { + stringResource(CommonStrings.common_something_went_wrong) + }, + errorMessage = { + stringResource(CommonStrings.error_network_or_server_issue) + }, + confirmationDialog = { confirming -> + // Note: confirming will always be of type ConfirmingDeclineInvite. + if (confirming is ConfirmingDeclineInvite) { + DeclineConfirmationDialog( + invite = confirming.inviteData, + blockUser = confirming.blockUser, + onConfirmClick = { + state.eventSink( + AcceptDeclineInviteEvents.DeclineInvite( + confirming.inviteData, + blockUser = confirming.blockUser, + shouldConfirm = false + ) + ) + }, + onDismissClick = { + state.eventSink(InternalAcceptDeclineInviteEvents.ClearDeclineActionState) + } + ) + } + } + ) + } +} + +@Composable +private fun DeclineConfirmationDialog( + invite: InviteData, + blockUser: Boolean, + onConfirmClick: () -> Unit, + onDismissClick: () -> Unit, + modifier: Modifier = Modifier +) { + ConfirmationDialog( + modifier = modifier, + content = stringResource(R.string.screen_invites_decline_chat_message, invite.roomName), + title = if (blockUser) { + stringResource(R.string.screen_join_room_decline_and_block_alert_title) + } else { + stringResource(R.string.screen_invites_decline_chat_title) + }, + submitText = if (blockUser) { + stringResource(R.string.screen_join_room_decline_and_block_alert_confirmation) + } else { + stringResource(CommonStrings.action_decline) + }, + cancelText = stringResource(CommonStrings.action_cancel), + onSubmitClick = onConfirmClick, + onDismiss = onDismissClick, + ) +} + +@PreviewsDayNight +@Composable +internal fun AcceptDeclineInviteViewPreview(@PreviewParameter(AcceptDeclineInviteStateProvider::class) state: AcceptDeclineInviteState) = + ElementPreview { + AcceptDeclineInviteView( + state = state, + onAcceptInviteSuccess = {}, + onDeclineInviteSuccess = {}, + ) + } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/DefaultAcceptDeclineInviteView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/DefaultAcceptDeclineInviteView.kt new file mode 100644 index 0000000..f819de6 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/DefaultAcceptDeclineInviteView.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl.acceptdecline + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId + +@ContributesBinding(SessionScope::class) +class DefaultAcceptDeclineInviteView : AcceptDeclineInviteView { + @Composable + override fun Render( + state: AcceptDeclineInviteState, + onAcceptInviteSuccess: (RoomId) -> Unit, + onDeclineInviteSuccess: (RoomId) -> Unit, + modifier: Modifier, + ) { + AcceptDeclineInviteView( + state = state, + onAcceptInviteSuccess = onAcceptInviteSuccess, + onDeclineInviteSuccess = onDeclineInviteSuccess, + modifier = modifier + ) + } +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/InternalAcceptDeclineInviteEvents.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/InternalAcceptDeclineInviteEvents.kt new file mode 100644 index 0000000..765c705 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/InternalAcceptDeclineInviteEvents.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl.acceptdecline + +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents + +sealed interface InternalAcceptDeclineInviteEvents : AcceptDeclineInviteEvents { + data object ClearAcceptActionState : InternalAcceptDeclineInviteEvents + data object ClearDeclineActionState : InternalAcceptDeclineInviteEvents +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockEvents.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockEvents.kt new file mode 100644 index 0000000..1fc160e --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockEvents.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl.declineandblock + +sealed interface DeclineAndBlockEvents { + data class UpdateReportReason(val reason: String) : DeclineAndBlockEvents + data object ToggleReportRoom : DeclineAndBlockEvents + data object ToggleBlockUser : DeclineAndBlockEvents + data object Decline : DeclineAndBlockEvents + data object ClearDeclineAction : DeclineAndBlockEvents +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt new file mode 100644 index 0000000..2fe5c23 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockNode.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl.declineandblock + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.invite.api.InviteData +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class DeclineAndBlockNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: DeclineAndBlockPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + data class Inputs(val inviteData: InviteData) : NodeInputs + + private val inviteData = inputs().inviteData + private val presenter = presenterFactory.create(inviteData) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + DeclineAndBlockView( + state = state, + onBackClick = ::navigateUp, + modifier = modifier + ) + } +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockPresenter.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockPresenter.kt new file mode 100644 index 0000000..af2a10c --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockPresenter.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl.declineandblock + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.invite.api.InviteData +import io.element.android.features.invite.impl.DeclineInvite +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@AssistedInject +class DeclineAndBlockPresenter( + @Assisted private val inviteData: InviteData, + private val declineInvite: DeclineInvite, + private val snackbarDispatcher: SnackbarDispatcher, +) : Presenter { + @AssistedFactory + fun interface Factory { + fun create(inviteData: InviteData): DeclineAndBlockPresenter + } + + @Composable + override fun present(): DeclineAndBlockState { + var reportReason by rememberSaveable { mutableStateOf("") } + var blockUser by rememberSaveable { mutableStateOf(true) } + var reportRoom by rememberSaveable { mutableStateOf(false) } + val declineAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + + val coroutineScope = rememberCoroutineScope() + + fun handleEvent(event: DeclineAndBlockEvents) { + when (event) { + DeclineAndBlockEvents.ClearDeclineAction -> declineAction.value = AsyncAction.Uninitialized + DeclineAndBlockEvents.Decline -> coroutineScope.decline(reportReason, blockUser, reportRoom, declineAction) + DeclineAndBlockEvents.ToggleBlockUser -> blockUser = !blockUser + DeclineAndBlockEvents.ToggleReportRoom -> reportRoom = !reportRoom + is DeclineAndBlockEvents.UpdateReportReason -> reportReason = event.reason + } + } + + return DeclineAndBlockState( + reportRoom = reportRoom, + reportReason = reportReason, + blockUser = blockUser, + declineAction = declineAction.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.decline( + reason: String, + blockUser: Boolean, + reportRoom: Boolean, + action: MutableState> + ) = launch { + action.value = AsyncAction.Loading + declineInvite( + roomId = inviteData.roomId, + blockUser = blockUser, + reportRoom = reportRoom, + reportReason = reason + ).onSuccess { + action.value = AsyncAction.Success(Unit) + }.onFailure { error -> + if (error is DeclineInvite.Exception.DeclineInviteFailed) { + action.value = AsyncAction.Failure(error) + } else { + action.value = AsyncAction.Uninitialized + snackbarDispatcher.post(SnackbarMessage(CommonStrings.error_unknown)) + } + } + } +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockState.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockState.kt new file mode 100644 index 0000000..7dbbbfd --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockState.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl.declineandblock + +import io.element.android.libraries.architecture.AsyncAction + +data class DeclineAndBlockState( + val reportRoom: Boolean, + val reportReason: String, + val blockUser: Boolean, + val declineAction: AsyncAction, + val eventSink: (DeclineAndBlockEvents) -> Unit +) { + val canDecline = blockUser || reportRoom && reportReason.isNotEmpty() +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockStateProvider.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockStateProvider.kt new file mode 100644 index 0000000..2e78214 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockStateProvider.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl.declineandblock + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction + +open class DeclineAndBlockStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aDeclineAndBlockState(), + aDeclineAndBlockState( + reportRoom = true, + reportReason = "Inappropriate content", + ), + aDeclineAndBlockState( + blockUser = true, + ), + aDeclineAndBlockState( + declineAction = AsyncAction.Loading, + ), + aDeclineAndBlockState( + declineAction = AsyncAction.Failure(Exception("Failed to decline")), + ), + ) +} + +fun aDeclineAndBlockState( + reportRoom: Boolean = false, + reportReason: String = "", + blockUser: Boolean = false, + declineAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (DeclineAndBlockEvents) -> Unit = {}, +) = DeclineAndBlockState( + reportRoom = reportRoom, + reportReason = reportReason, + blockUser = blockUser, + declineAction = declineAction, + eventSink = eventSink, +) diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt new file mode 100644 index 0000000..1028a4c --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl.declineandblock + +import androidx.compose.foundation.layout.Column +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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.invite.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ListItem +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.TextField +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeclineAndBlockView( + state: DeclineAndBlockState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + + val isDeclining = state.declineAction is AsyncAction.Loading + AsyncActionView( + async = state.declineAction, + onSuccess = { onBackClick() }, + errorMessage = { stringResource(CommonStrings.error_unknown) }, + onRetry = { state.eventSink(DeclineAndBlockEvents.Decline) }, + onErrorDismiss = { state.eventSink(DeclineAndBlockEvents.ClearDeclineAction) } + ) + + Scaffold( + topBar = { + TopAppBar( + titleStr = stringResource(R.string.screen_decline_and_block_title), + navigationIcon = { + BackButton(onClick = onBackClick) + } + ) + }, + modifier = modifier + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + .imePadding() + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(vertical = 16.dp) + ) { + ListItem( + modifier = Modifier.padding(end = 8.dp), + headlineContent = { + Text(text = stringResource(R.string.screen_decline_and_block_block_user_option_title)) + }, + supportingContent = { + Text(text = stringResource(R.string.screen_decline_and_block_block_user_option_description)) + }, + onClick = { + state.eventSink(DeclineAndBlockEvents.ToggleBlockUser) + }, + trailingContent = ListItemContent.Switch(checked = state.blockUser) + ) + + Spacer(modifier = Modifier.height(24.dp)) + ListItem( + modifier = Modifier.padding(end = 8.dp), + headlineContent = { + Text(text = stringResource(CommonStrings.action_report_room)) + }, + supportingContent = { + Text(text = stringResource(R.string.screen_decline_and_block_report_user_option_description)) + }, + onClick = { + state.eventSink(DeclineAndBlockEvents.ToggleReportRoom) + }, + trailingContent = ListItemContent.Switch(checked = state.reportRoom) + ) + + if (state.reportRoom) { + Spacer(modifier = Modifier.height(24.dp)) + TextField( + value = state.reportReason, + onValueChange = { state.eventSink(DeclineAndBlockEvents.UpdateReportReason(it)) }, + placeholder = stringResource(R.string.screen_decline_and_block_report_user_reason_placeholder), + minLines = 3, + enabled = !isDeclining, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .heightIn(min = 90.dp), + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + Button( + text = stringResource(CommonStrings.action_decline), + destructive = true, + showProgress = isDeclining, + enabled = !isDeclining && state.canDecline, + onClick = { + focusManager.clearFocus(force = true) + state.eventSink(DeclineAndBlockEvents.Decline) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun DeclineAndBlockViewPreview( + @PreviewParameter(DeclineAndBlockStateProvider::class) state: DeclineAndBlockState +) = ElementPreview { + DeclineAndBlockView( + state = state, + onBackClick = {}, + ) +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt new file mode 100644 index 0000000..d6e93fb --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPoint.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl.declineandblock + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.invite.api.InviteData +import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultDeclineAndBlockEntryPoint : DeclineInviteAndBlockEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + inviteData: InviteData, + ): Node { + val inputs = DeclineAndBlockNode.Inputs(inviteData) + return parentNode.createNode(buildContext, plugins = listOf(inputs)) + } +} diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/di/InviteModule.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/di/InviteModule.kt new file mode 100644 index 0000000..f78c035 --- /dev/null +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/di/InviteModule.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl.di + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState +import io.element.android.features.invite.impl.SeenInvitesStoreFactory +import io.element.android.features.invite.impl.acceptdecline.AcceptDeclineInvitePresenter +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient + +@ContributesTo(SessionScope::class) +@BindingContainer +interface InviteModule { + @Binds + fun bindAcceptDeclinePresenter(presenter: AcceptDeclineInvitePresenter): Presenter + + companion object { + @Provides + fun providesSeenInvitesStore( + factory: SeenInvitesStoreFactory, + matrixClient: MatrixClient, + ): SeenInvitesStore { + return factory.getOrCreate( + matrixClient.sessionId, + matrixClient.sessionCoroutineScope, + ) + } + } +} diff --git a/features/invite/impl/src/main/res/values-be/translations.xml b/features/invite/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..fca38b7 --- /dev/null +++ b/features/invite/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,10 @@ + + + "Заблакіраваць карыстальніка" + "Вы ўпэўненыя, што хочаце адхіліць запрашэнне ў %1$s?" + "Адхіліць запрашэнне" + "Вы ўпэўненыя, што хочаце адмовіцца ад прыватных зносін з %1$s?" + "Адхіліць чат" + "Няма запрашэнняў" + "%1$s (%2$s) запрасіў(-ла) вас" + diff --git a/features/invite/impl/src/main/res/values-bg/translations.xml b/features/invite/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..d4c19ec --- /dev/null +++ b/features/invite/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,12 @@ + + + "Блокиране на потребителя" + "Отхвърляне и блокиране" + "Сигурни ли сте, че искате да отхвърлите поканата за присъединяване в %1$s?" + "Отказване на покана" + "Сигурни ли сте, че искате да откажете този личен чат с %1$s?" + "Отказване на чат" + "Няма покани" + "%1$s (%2$s) ви покани" + "Отхвърляне и блокиране" + diff --git a/features/invite/impl/src/main/res/values-cs/translations.xml b/features/invite/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..28da3dc --- /dev/null +++ b/features/invite/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,18 @@ + + + "Od tohoto uživatele neuvidíte žádné zprávy ani pozvánky do místnosti" + "Zablokovat uživatele" + "Nahlaste tuto místnost svému poskytovateli účtu." + "Popište důvod…" + "Odmítnout a zablokovat" + "Opravdu chcete odmítnout pozvánku do %1$s?" + "Odmítnout pozvání" + "Opravdu chcete odmítnout tuto soukromou konverzaci s %1$s?" + "Odmítnout chat" + "Žádné pozvánky" + "%1$s (%2$s) vás pozval(a)" + "Ano, odmítnout a zablokovat" + "Opravdu chcete odmítnout pozvánku do této místnosti? Tím také zabráníte tomu, aby vás %1$s kontaktoval(a) nebo pozval(a) do místností." + "Odmítnout pozvání a zablokovat" + "Odmítnout a zablokovat" + diff --git a/features/invite/impl/src/main/res/values-cy/translations.xml b/features/invite/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..e32337a --- /dev/null +++ b/features/invite/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,18 @@ + + + "Fyddwch chi ddim yn gweld unrhyw negeseuon neu wahoddiadau ystafell gan y defnyddiwr hwn" + "Rhwystro defnyddiwr" + "Adrodd am yr ystafell hon i ddarparwr eich cyfrif." + "Disgrifiwch y rheswm…" + "Gwrthod a rhwystro" + "Ydych chi\'n siŵr eich bod am wrthod y gwahoddiad i ymuno â %1$s?" + "Gwrthod y gwahoddiad" + "Ydych chi\'n siŵr eich bod am wrthod y sgwrs breifat hon gyda %1$s?" + "Gwrthod sgwrs" + "Dim Gwahoddiadau" + "Mae %1$s (%2$s) wedi eich gwahodd" + "Iawn, gwrthod a rhwystro" + "Ydych chi\'n siŵr eich bod am wrthod y gwahoddiad i ymuno â\'r ystafell hon? Bydd hyn hefyd yn atal %1$s rhag cysylltu â chi neu eich gwahodd i ystafelloedd." + "Gwrthod gwahoddiad a rhwystro" + "Gwrthod a rhwystro" + diff --git a/features/invite/impl/src/main/res/values-da/translations.xml b/features/invite/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..6093a43 --- /dev/null +++ b/features/invite/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,18 @@ + + + "Du vil ikke se nogen beskeder eller rum-invitationer fra denne bruger" + "Bloker bruger" + "Anmeld dette rum til din kontoudbyder." + "Beskriv årsagen til anmeldelsen…" + "Afvis og blokér" + "Er du sikker på, at du vil afvise invitationen til at deltage i %1$s?" + "Afvis invitation" + "Er du sikker på, at du vil afvise denne private samtale med %1$s?" + "Afvis samtale" + "Ingen invitationer" + "%1$s(%2$s ) inviterede dig" + "Ja, afvis og blokér" + "Er du sikker på, at du vil afvise invitationen til at deltage i dette rum? Dette forhindrer også %1$s i at kontakte dig eller invitere dig til andre rum." + "Afvis invitation og blokér" + "Afvis og blokér" + diff --git a/features/invite/impl/src/main/res/values-de/translations.xml b/features/invite/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..7b6ca2e --- /dev/null +++ b/features/invite/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,18 @@ + + + "Du wirst keine Nachrichten oder Chat-Einladungen von diesem Nutzer sehen." + "Nutzer blockieren" + "Melde diesen Chat deinem Konto-Anbieter." + "Beschreibe den Grund für die Meldung…" + "Ablehnen und blockieren" + "Möchtest du die Einladung zum Betreten von %1$s wirklich ablehnen?" + "Einladung ablehnen" + "Bist du sicher, dass du diese Direktnachricht von %1$s ablehnen möchtest?" + "Einladung ablehnen" + "Keine Einladungen" + "%1$s (%2$s) hat dich eingeladen" + "Ja, ablehnen & blockieren" + "Bist du sicher, dass du die Einladung zu diesem Chat ablehnen möchtest? Dadurch wird auch jede weitere Kontaktaufnahme oder Chat Einladung von %1$s blockiert." + "Einladung ablehnen & Nutzer blockieren" + "Ablehnen und blockieren" + diff --git a/features/invite/impl/src/main/res/values-el/translations.xml b/features/invite/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..96f10b2 --- /dev/null +++ b/features/invite/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,18 @@ + + + "Δεν θα βλέπετε μηνύματα ή προσκλήσεις αίθουσας από αυτόν τον χρήστη" + "Αποκλεισμός χρήστη" + "Αναφέρετε αυτή την αίθουσα στον πάροχο του λογαριασμού σας." + "Περιγράψτε τον λόγο αναφοράς…" + "Απόρριψη και αποκλεισμός" + "Σίγουρα θες να απορρίψεις την πρόσκληση συμμετοχής στο %1$s;" + "Απόρριψη πρόσκλησης" + "Σίγουρα θες να απορρίψεις την ιδιωτική συνομιλία με τον χρήστη %1$s;" + "Απόρριψη συνομιλίας" + "Χωρίς προσκλήσεις" + "%1$s (%2$s) σέ προσκάλεσε" + "Ναι, απόρριψη και αποκλεισμός" + "Είστε βέβαιοι ότι θέλετε να απορρίψετε την πρόσκληση συμμετοχής σε αυτήν την αίθουσα; Αυτό θα εμποδίσει επίσης τον χρήστη %1$s να επικοινωνήσει μαζί σας ή να σας προσκαλέσει σε αίθουσες." + "Απόρριψη πρόσκλησης και αποκλεισμός" + "Απόρριψη και αποκλεισμός" + diff --git a/features/invite/impl/src/main/res/values-es/translations.xml b/features/invite/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..a88f9bc --- /dev/null +++ b/features/invite/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,18 @@ + + + "No verás ningún mensaje ni invitación a sala que provenga de este usuario" + "Bloquear usuario" + "Denunciar esta sala a tu proveedor de cuentas." + "Describe el motivo de la denuncia…" + "Rechazar y bloquear" + "¿Estás seguro de que quieres rechazar la invitación a unirte a %1$s?" + "Rechazar la invitación" + "¿Estás seguro de que quieres rechazar este chat privado con %1$s?" + "Rechazar el chat" + "Sin invitaciones" + "%1$s (%2$s) te invitó" + "Sí, rechazar y bloquear" + "¿Estás seguro de que deseas rechazar la invitación para unirte a esta sala? Esto también impedirá que %1$s pueda contactar contigo o invitarte a salas." + "Rechazar invitación y bloquear" + "Rechazar y bloquear" + diff --git a/features/invite/impl/src/main/res/values-et/translations.xml b/features/invite/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..1bc1e93 --- /dev/null +++ b/features/invite/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,18 @@ + + + "Sa ei näe enam selle kasutaja saadetud sõnumeid ja jututubade kutseid" + "Blokeeri kasutaja" + "Teata sellest jututoast oma teenusepakkujale." + "Kirjelda põhjust…" + "Keeldu ja blokeeri" + "Kas sa oled kindel, et soovid keelduda liitumiskutsest: %1$s?" + "Lükka kutse tagasi" + "Kas sa oled kindel, et soovid keelduda privaatsest vestlusest kasutajaga %1$s?" + "Keeldu vestlusest" + "Kutseid pole" + "%1$s (%2$s) saatis sulle kutse" + "Jah, keeldu ja blokeeri" + "Kas sa oled kindel, et soovid keelduda kutsest sellesse jututuppa? Samaga kaob kasutajal %1$s võimalus sinuga suhelda ja saata sulle jututubade kutseid." + "Keeldu kutsest ja blokeeri" + "Keeldu ja blokeeri" + diff --git a/features/invite/impl/src/main/res/values-eu/translations.xml b/features/invite/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..dfc8310 --- /dev/null +++ b/features/invite/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,13 @@ + + + "Blokeatu erabiltzailea" + "Baztertu eta blokeatu" + "Ziur %1$s(e)ra batzeko gonbidapena baztertu nahi duzula?" + "Baztertu gonbidapena" + "Ziur %1$s(r)en txat pribatua baztertu nahi duzula?" + "Baztertu txata" + "Ez dago gonbidapenik" + "%1$s(e)k (%2$s) gonbidatu zaitu" + "Eman gonbidapenari ezetza eta blokeatu" + "Baztertu eta blokeatu" + diff --git a/features/invite/impl/src/main/res/values-fa/translations.xml b/features/invite/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..9cbad94 --- /dev/null +++ b/features/invite/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,15 @@ + + + "انسداد کاربر" + "شرح دلیل گزارش…" + "رد و انسداد" + "مطمئنید که می‌خواهید دعوت پیوستن به %1$s را رد کنید؟" + "رد دعوت" + "مطمئنید که می‌خواهید این گپ خصوصی با %1$s را رد کنید؟" + "رد گپ" + "بدون دعوت" + "%1$s (%2$s) دعوتتان کرد" + "بله. رد و انسداد" + "رد دعوت و انسداد" + "رد و انسداد" + diff --git a/features/invite/impl/src/main/res/values-fi/translations.xml b/features/invite/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..215e2ee --- /dev/null +++ b/features/invite/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,18 @@ + + + "Et tule näkemään viestejä tai kutsuja tältä käyttäjältä" + "Estä käyttäjä" + "Ilmoita tästä huoneesta palveluntarjoajallesi." + "Kuvaile syytä…" + "Hylkää ja estä" + "Haluatko varmasti hylätä kutsun liittyä %1$s -huoneeseen?" + "Hylkää kutsu" + "Haluatko varmasti hylätä kutsun yksityiseen keskusteluun käyttäjän %1$s kanssa?" + "Hylkää keskustelu" + "Ei kutsuja" + "%1$s (%2$s) kutsui sinut" + "Kyllä, hylkää ja estä" + "Oletko varma, että haluat kieltäytyä kutsusta liittyä tähän huoneeseen? Tämä estää myös käyttäjää %1$s ottamasta sinuun yhteyttä tai kutsumasta sinua huoneisiin." + "Hylkää kutsu ja estä" + "Hylkää ja estä" + diff --git a/features/invite/impl/src/main/res/values-fr/translations.xml b/features/invite/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..1775da0 --- /dev/null +++ b/features/invite/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,18 @@ + + + "Vous ne verrez aucun messages ou invitation à un salon de la part de cet utilisateur" + "Bloquer l’utilisateur" + "Signalez ce salon à votre fournisseur de compte." + "Décrivez la raison du signalement…" + "Refuser et bloquer" + "Êtes-vous sûr de vouloir décliner l’invitation à rejoindre %1$s ?" + "Refuser l’invitation" + "Êtes-vous sûr de vouloir refuser cette discussion privée avec %1$s ?" + "Refuser l’invitation" + "Aucune invitation" + "%1$s (%2$s) vous a invité(e)" + "Oui, refuser et bloquer" + "Êtes-vous sûr de vouloir refuser l’invitation à rejoindre ce salon ? Cela empêchera également %1$s de vous contacter ou de vous inviter dans les salons." + "Refuser l’invitation et bloquer" + "Refuser et bloquer" + diff --git a/features/invite/impl/src/main/res/values-hu/translations.xml b/features/invite/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..e75d296 --- /dev/null +++ b/features/invite/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,18 @@ + + + "Ettől a felhasználótól nem fog többé üzeneteket vagy meghívásokat látni." + "Felhasználó letiltása" + "A szoba jelentése a fiókszolgáltatójának." + "Írja le az okot…" + "Elutasítás és letiltás" + "Biztos, hogy elutasítja a meghívást, hogy csatlakozzon ehhez: %1$s?" + "Meghívás elutasítása" + "Biztos, hogy elutasítja ezt a privát csevegést vele: %1$s?" + "Csevegés elutasítása" + "Nincsenek meghívások" + "%1$s (%2$s) meghívta" + "Igen, elutasítás és blokkolás" + "Biztos, hogy elutasítja a meghívást, hogy csatlakozzon ehhez a szobához? Ez azt is megakadályozza, hogy %1$s kapcsolatba lépjen Önnel, vagy szobákba hívja." + "Meghívó elutasítása és blokkolás" + "Elutasítás és letiltás" + diff --git a/features/invite/impl/src/main/res/values-in/translations.xml b/features/invite/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..6f8ab15 --- /dev/null +++ b/features/invite/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,18 @@ + + + "Anda tidak akan melihat pesan atau undangan ruangan dari pengguna ini" + "Blokir pengguna" + "Laporkan ruangan ini ke penyedia akun Anda." + "Jelaskan alasan untuk melaporkan…" + "Tolak dan blokir" + "Apakah Anda yakin ingin menolak undangan untuk bergabung ke %1$s?" + "Tolak undangan" + "Apakah Anda yakin ingin menolak obrolan pribadi dengan %1$s?" + "Tolak obrolan" + "Tidak ada undangan" + "%1$s (%2$s) mengundang Anda" + "Ya, tolak & blokir" + "Apakah Anda yakin ingin menolak undangan untuk bergabung dengan ruangan ini? Ini juga akan mencegah %1$s menghubungi Anda atau mengundang Anda ke ruangan." + "Tolak undangan & blokir" + "Tolak dan blokir" + diff --git a/features/invite/impl/src/main/res/values-it/translations.xml b/features/invite/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..d88183f --- /dev/null +++ b/features/invite/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,18 @@ + + + "Non vedrai alcun messaggio o invito ad una stanza da parte di questo utente" + "Blocca utente" + "Segnala questa stanza al fornitore del tuo account." + "Descrivi il motivo della segnalazione…" + "Rifiuta e blocca" + "Vuoi davvero rifiutare l\'invito ad entrare in %1$s?" + "Rifiuta l\'invito" + "Vuoi davvero rifiutare questa conversazione privata con %1$s?" + "Rifiuta l\'invito alla conversazione" + "Nessun invito" + "%1$s (%2$s) ti ha invitato" + "Sì, rifiuta e blocca" + "Sei sicuro di voler rifiutare l\'invito a entrare in questa stanza? Ciò impedirà a %1$s di contattarti o invitarti nuovamente in una stanza." + "Rifiuta invito e blocca" + "Rifiuta e blocca" + diff --git a/features/invite/impl/src/main/res/values-ka/translations.xml b/features/invite/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..7b60e8f --- /dev/null +++ b/features/invite/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,10 @@ + + + "მომხმარებლის დაბლოკვა" + "დარწმუნებული ხართ, რომ გსურთ, უარი თქვათ მოწვევაზე %1$s-ში?" + "მოწვევაზე უარის თქმა" + "დარწმუნებული ხართ, რომ გსურთ, უარი თქვათ ჩატზე %1$s-თან?" + "ჩატზე უარის თქვა" + "მოწვევები არ არის" + "%1$s (%2$s) მოგიწვიათ" + diff --git a/features/invite/impl/src/main/res/values-ko/translations.xml b/features/invite/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..169d5ab --- /dev/null +++ b/features/invite/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,18 @@ + + + "이 사용자로부터 메시지나 방 초대장이 표시되지 않습니다." + "사용자 차단하기" + "이 room 계정 제공자에게 신고하세요." + "신고 사유를 설명하세요…" + "거부 및 차단" + "정말로 %1$s 에 참가하지 않고 초대를 거절하시겠어요?" + "초대 거절" + "%1$s 와의 비공개 채팅을 정말 거부하시겠습니까?" + "채팅 거절" + "초대 없음" + "%1$s (%2$s) 당신을 초대했습니다" + "예, 거부 및 차단" + "이 방에 대한 초대 거부를 정말로 확인하시겠습니까? 이 경우 %1$s 에서 귀하에게 연락하거나 방에 초대할 수 없게 됩니다." + "초대 거부 및 차단" + "거부 및 차단" + diff --git a/features/invite/impl/src/main/res/values-lt/translations.xml b/features/invite/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..375aab7 --- /dev/null +++ b/features/invite/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,10 @@ + + + "Blokuoti vartotoją" + "Ar tikrai norite atmesti kvietimą prisijungti prie %1$s?" + "Atmesti kvietimą" + "Ar tikrai norite atmesti šį privatų pokalbį su %1$s ?" + "Atmesti pokalbį" + "Jokių kvietimų" + "%1$s(%2$s) pakvietė Jus" + diff --git a/features/invite/impl/src/main/res/values-nb/translations.xml b/features/invite/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..31091a1 --- /dev/null +++ b/features/invite/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,18 @@ + + + "Du vil ikke se noen meldinger eller rominvitasjoner fra denne brukeren" + "Blokker bruker" + "Rapporter dette rommet til din kontoleverandør." + "Beskriv årsaken…" + "Avslå og blokker" + "Er du sikker på at du vil takke nei til invitasjonen til å bli med i %1$s?" + "Avvis invitasjon" + "Er du sikker på at du vil avslå denne private chatten med %1$s?" + "Avslå chat" + "Ingen invitasjoner" + "%1$s(%2$s) inviterte deg" + "Ja, avslå og blokker" + "Er du sikker på at du vil avslå invitasjonen til å bli med i dette rommet? Dette vil også forhindre %1$s fra å kontakte deg eller invitere deg til rom." + "Avslå invitasjon og blokker" + "Avslå og blokker" + diff --git a/features/invite/impl/src/main/res/values-nl/translations.xml b/features/invite/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..de0f690 --- /dev/null +++ b/features/invite/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,12 @@ + + + "Gebruiker blokkeren" + "Weigeren en blokkeren" + "Weet je zeker dat je de uitnodiging om toe te treden tot %1$s wilt weigeren?" + "Uitnodiging weigeren" + "Weet je zeker dat je deze privéchat met %1$s wilt weigeren?" + "Chat weigeren" + "Geen uitnodigingen" + "%1$s (%2$s) heeft je uitgenodigd" + "Weigeren en blokkeren" + diff --git a/features/invite/impl/src/main/res/values-pl/translations.xml b/features/invite/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..b20ee32 --- /dev/null +++ b/features/invite/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,18 @@ + + + "Nie zobaczysz żadnych wiadomości ani zaproszeń od tego użytkownika" + "Zablokuj użytkownika" + "Zgłoś pokój dostawcy swojego konta." + "Opisz powód…" + "Odrzuć i zablokuj" + "Czy na pewno chcesz odrzucić zaproszenie dołączenia do %1$s?" + "Odrzuć zaproszenie" + "Czy na pewno chcesz odrzucić rozmowę prywatną z %1$s?" + "Odrzuć czat" + "Brak zaproszeń" + "%1$s (%2$s) zaprosił Cię" + "Tak, odrzuć i zablokuj" + "Czy na pewno chcesz odrzucić zaproszenie dołączenia do tego pokoju? %1$s nie będzie mógł się również z Tobą skontaktować, ani zaprosić Cię do pokoju." + "Odrzuć zaproszenie i zablokuj" + "Odrzuć i zablokuj" + diff --git a/features/invite/impl/src/main/res/values-pt-rBR/translations.xml b/features/invite/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..bf0ee63 --- /dev/null +++ b/features/invite/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,18 @@ + + + "Você não verá nenhuma mensagem ou convite de sala deste usuário" + "Bloquear usuário" + "Denuncie esta sala ao fornecedor da sua conta." + "Descreva o motivo para denunciar…" + "Recusar e bloquear" + "Tem certeza de que deseja recusar o convite para entrar em %1$s?" + "Recusar convite" + "Tem certeza de que deseja recusar esse conversa privada com %1$s?" + "Recusar chat" + "Não há convites" + "%1$s(%2$s) convidou você" + "Sim, recusar e bloquear" + "Tem certeza de que quer recusar o convite para entrar nesta sala? Isso também impedirá que %1$s entre em contato com você ou o convide para salas." + "Recusar convite e bloquear" + "Recusar e bloquear" + diff --git a/features/invite/impl/src/main/res/values-pt/translations.xml b/features/invite/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..f00ce61 --- /dev/null +++ b/features/invite/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,18 @@ + + + "Não vais ver quaisquer mensagens ou convites para sala deste utilizador" + "Bloquear utilizador" + "Denunciar esta sala ao fornecedor da tua conta." + "Descreve a razão para denunciar…" + "Recusar e bloquear" + "Tens a certeza que queres rejeitar o convite para entra em %1$s?" + "Rejeitar convite" + "Tens a certeza que queres rejeitar esta conversa privada com %1$s?" + "Rejeitar conversa" + "Sem convites" + "%1$s (%2$s) convidou-te" + "Sim, recusar & bloquear" + "Tens a certeza de que queres recusar o convite para entrar nesta sala? Isto também evitará que %1$s te contacte ou te convide para salas." + "Recusar convite & bloquear" + "Recusar e bloquear" + diff --git a/features/invite/impl/src/main/res/values-ro/translations.xml b/features/invite/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..747efbe --- /dev/null +++ b/features/invite/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,18 @@ + + + "Nu veți vedea niciun mesaj sau invitație de la acest utilizator." + "Blocați utilizatorul" + "Raportați această cameră furnizorului contului dumneavoastră" + "Descrieți motivul raportării…" + "Refuzați și blocați" + "Sigur doriți să refuzați alăturarea la %1$s?" + "Refuzați invitația" + "Sigur doriți să refuzați conversațiile cu %1$s?" + "Refuzați conversația" + "Nicio invitație" + "%1$s (%2$s) v-a invitat." + "Da, refuzați și blocați" + "Sunteți sigur că doriți să refuzați invitația de a vă alătura acestei camere? Acest lucru va împiedica, de asemenea, %1$s să vă contacteze sau să vă invite în camere." + "Refuzați invitația și blocați" + "Refuzați și blocați" + diff --git a/features/invite/impl/src/main/res/values-ru/translations.xml b/features/invite/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..90d467e --- /dev/null +++ b/features/invite/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,18 @@ + + + "Вы не увидите сообщений или приглашений в комнату от этого пользователя" + "Заблокировать пользователя" + "Сообщите об этой комнате своему поставщику учетной записи." + "Опишите причину жалобы…" + "Отклонить и заблокировать" + "Вы уверены, что хотите отклонить приглашение в %1$s?" + "Отклонить приглашение" + "Вы уверены, что хотите отказаться от личного общения с %1$s?" + "Отклонить чат" + "Нет приглашений" + "%1$s (%2$s) пригласил вас" + "Да, отклонить и заблокировать" + "Вы действительно хотите отклонить приглашение в эту комнате? Это также предотвратит %1$s возможность связываться с вами или приглашать вас в комнаты." + "Отклонить приглашение и заблокировать" + "Отклонить и заблокировать" + diff --git a/features/invite/impl/src/main/res/values-sk/translations.xml b/features/invite/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..826aa02 --- /dev/null +++ b/features/invite/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,18 @@ + + + "Od tohto používateľa sa vám nezobrazia žiadne správy ani pozvánky do miestnosti" + "Zablokovať používateľa" + "Nahlásiť túto miestnosť poskytovateľovi účtu." + "Popíšte dôvod…" + "Odmietnuť a zablokovať" + "Naozaj chcete odmietnuť pozvánku na pripojenie do %1$s?" + "Odmietnuť pozvanie" + "Naozaj chcete odmietnuť túto súkromnú konverzáciu s %1$s?" + "Odmietnuť konverzáciu" + "Žiadne pozvánky" + "%1$s (%2$s) vás pozval/a" + "Áno, odmietnuť a zablokovať" + "Ste si istí, že chcete odmietnuť pozvanie na vstup do tejto miestnosti? To tiež zabráni tomu, aby vás %1$s kontaktoval/a alebo vás pozval/a do miestností." + "Odmietnuť pozvánku a zablokovať" + "Odmietnuť a zablokovať" + diff --git a/features/invite/impl/src/main/res/values-sv/translations.xml b/features/invite/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..f447831 --- /dev/null +++ b/features/invite/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,18 @@ + + + "Du kommer inte att se några meddelanden eller rumsinbjudningar från den här användaren" + "Blockera användare" + "Rapportera det här rummet till din kontoleverantör." + "Beskriv anledningen …" + "Avvisa och blockera" + "Är du säker på att du vill tacka nej till inbjudan att gå med%1$s?" + "Avböj inbjudan" + "Är du säker på att du vill avböja denna privata chatt med %1$s?" + "Avböj chatt" + "Inga inbjudningar" + "%1$s (%2$s) bjöd in dig" + "Ja, avvisa och blockera" + "Är du säker på att du vill avvisa inbjudan att gå med i det här rummet? Detta kommer också att hindra %1$s från att kontakta dig eller bjuda in dig till rum." + "Avvisa inbjudan och blockera" + "Avvisa och blockera" + diff --git a/features/invite/impl/src/main/res/values-tr/translations.xml b/features/invite/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..26ed1cb --- /dev/null +++ b/features/invite/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,13 @@ + + + "Kullanıcıyı engelle" + "%1$s katılma davetini reddetmek istediğinizden emin misiniz?" + "Daveti reddet" + "%1$s ile bu özel sohbeti reddetmek istediğinizden emin misiniz?" + "Sohbeti reddet" + "Davet Yok" + "%1$s (%2$s) sizi davet etti" + "Evet, reddet ve engelle" + "Bu odaya katılma davetini reddetmek istediğinizden emin misiniz? Bu aynı zamanda %1$s sizinle iletişim kurmasını veya sizi odalara davet etmesini de engeller." + "Daveti reddet ve engelle" + diff --git a/features/invite/impl/src/main/res/values-uk/translations.xml b/features/invite/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..8752f57 --- /dev/null +++ b/features/invite/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,18 @@ + + + "Ви не бачитимете повідомлень або запрошень у кімнату від цього користувача" + "Заблокувати користувача" + "Поскаржитися на цю кімнату постачальнику облікового запису." + "Опишіть причину…" + "Відхилити та заблокувати" + "Ви впевнені, що хочете відхилити запрошення приєднатися до %1$s?" + "Відхилити запрошення" + "Ви дійсно хочете відмовитися від приватної бесіди з %1$s?" + "Відхилити бесіду" + "Немає запрошень" + "%1$s (%2$s) запрошує вас" + "Так, відхилити та заблокувати" + "Ви впевнені, що хочете відхилити запрошення приєднатися до цієї кімнати? Це також завадить %1$s зв\'язатися з вами або запрошувати вас в кімнати." + "Відхилити запрошення та заблокувати" + "Відхилити та заблокувати" + diff --git a/features/invite/impl/src/main/res/values-ur/translations.xml b/features/invite/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..42bb36e --- /dev/null +++ b/features/invite/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,10 @@ + + + "صارف کو مسدود کریں" + "کیا آپکو یقین ہے کہ آپ %1$s میں شامل ہونے کی درخواست مسترد کرنا چاہتے ہیں؟" + "دعوت مسترد کریں" + "کیا آپکو یقین ہے کہ آپ %1$s کیساتھ نجی گفتگو مسترد کرنا چاہتے ہیں؟" + "گفتگو مسترد کریں" + "کوئی دعوت نامے نہیں" + "%1$s (%2$s) نے آپ کو مدعو کیا" + diff --git a/features/invite/impl/src/main/res/values-uz/translations.xml b/features/invite/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..ab20b58 --- /dev/null +++ b/features/invite/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,18 @@ + + + "Siz bu foydalanuvchidan hech qanday xabar yoki xonaga taklif ko‘rmaysiz" + "Foydalanuvchini bloklash" + "Bu xona haqida hisobingiz provayderiga xabar bering." + "Xabar berish sababini tushuntiring…" + "Rad etish va bloklash" + "Haqiqatan ham qo\'shilish taklifini rad qilmoqchimisiz%1$s ?" + "Taklifni rad etish" + "Haqiqatan ham bu shaxsiy chatni rad qilmoqchimisiz%1$s ?" + "Chatni rad etish" + "Takliflar yo\'q" + "%1$s(%2$s ) sizni taklif qildi" + "Ha, rad etish va bloklash" + "Ushbu xonaga qo‘shilish taklifini rad etishga ishonchingiz komilmi? Bu %1$sning siz bilan bog‘lanishiga yoki sizni xonalarga taklif qilishiga ham to‘sqinlik qiladi." + "Taklifni rad etish va bloklash" + "Rad etish va bloklash" + diff --git a/features/invite/impl/src/main/res/values-zh-rTW/translations.xml b/features/invite/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..5b75fcd --- /dev/null +++ b/features/invite/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,18 @@ + + + "您將不會看到來自此使用者的任何訊息或聊天室邀請" + "封鎖使用者" + "向您的帳號提供者回報此聊天室。" + "說明回報的原因……" + "拒絕並封鎖" + "您確定您想要拒絕加入 %1$s 的邀請嗎?" + "拒絕邀請" + "您確定您要拒絕此與 %1$s 的私人聊天嗎?" + "拒絕聊天" + "沒有邀請" + "%1$s(%2$s)邀請您" + "是的,拒絕並封鎖" + "您確定要拒絕加入此聊天室的邀請嗎?這也會防止 %1$s 聯絡您或邀請您加入聊天室。" + "拒絕邀請並封鎖" + "拒絕並封鎖" + diff --git a/features/invite/impl/src/main/res/values-zh/translations.xml b/features/invite/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..e7cf39a --- /dev/null +++ b/features/invite/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,18 @@ + + + "您不会看到来自该用户的任何信息或房间邀请" + "封禁用户" + "向您的帐户提供商举报此房间。" + "描述举报的原因…" + "拒绝并屏蔽" + "您确定要拒绝加入 %1$s 的邀请吗?" + "拒绝邀请" + "您确定要拒绝与 %1$s 开始私聊吗?" + "拒绝聊天" + "没有邀请" + "%1$s (%2$s)邀请了你" + "是的,拒绝并屏蔽" + "您确定要拒绝加入此房间的邀请吗?这也将阻止%1$s 与您联系或邀请您加入房间。" + "拒绝邀请并屏蔽" + "拒绝并屏蔽" + diff --git a/features/invite/impl/src/main/res/values/localazy.xml b/features/invite/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..4256025 --- /dev/null +++ b/features/invite/impl/src/main/res/values/localazy.xml @@ -0,0 +1,18 @@ + + + "You will not see any messages or room invites from this user" + "Block user" + "Report this room to your account provider." + "Describe the reason to report…" + "Decline and block" + "Are you sure you want to decline the invitation to join %1$s?" + "Decline invite" + "Are you sure you want to decline this private chat with %1$s?" + "Decline chat" + "No Invites" + "%1$s (%2$s) invited you" + "Yes, decline & block" + "Are you sure you want to decline the invite to join this room? This will also prevent %1$s from contacting you or inviting you to rooms." + "Decline invite & block" + "Decline and block" + diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/DefaultAcceptInviteTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/DefaultAcceptInviteTest.kt new file mode 100644 index 0000000..b1a0c30 --- /dev/null +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/DefaultAcceptInviteTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl + +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.features.invite.test.InMemorySeenInvitesStore +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +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.join.FakeJoinRoom +import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultAcceptInviteTest { + private val roomId = A_ROOM_ID + private val client = FakeMatrixClient() + private val seenInvitesStore = InMemorySeenInvitesStore(initialRoomIds = setOf(roomId)) + + private val clearMembershipNotificationForRoomLambda = + lambdaRecorder { _, _ -> } + private val notificationCleaner = + FakeNotificationCleaner(clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda) + + @Test + fun `accept invite success scenario`() = runTest { + val joinRoomLambda = + lambdaRecorder, JoinedRoom.Trigger, Result> { _, _, _ -> + Result.success(Unit) + } + + val acceptInvite = DefaultAcceptInvite( + client = client, + notificationCleaner = notificationCleaner, + joinRoom = FakeJoinRoom(lambda = joinRoomLambda), + seenInvitesStore = seenInvitesStore + ) + + val result = acceptInvite(roomId) + + assertThat(result.isSuccess).isTrue() + + assert(joinRoomLambda) + .isCalledOnce() + .with(value(roomId.toRoomIdOrAlias()), any(), any()) + + assert(clearMembershipNotificationForRoomLambda) + .isCalledOnce() + .with(value(client.sessionId), value(roomId)) + + assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty() + } + + @Test + fun `accept invite failure scenario`() = runTest { + val joinRoomLambda = + lambdaRecorder, JoinedRoom.Trigger, Result> { _, _, _ -> + Result.failure(RuntimeException("Join room failed")) + } + + val acceptInvite = DefaultAcceptInvite( + client = client, + notificationCleaner = notificationCleaner, + joinRoom = FakeJoinRoom(lambda = joinRoomLambda), + seenInvitesStore = seenInvitesStore + ) + + val result = acceptInvite(roomId) + + assertThat(result.isFailure).isTrue() + + assert(joinRoomLambda) + .isCalledOnce() + .with(value(roomId.toRoomIdOrAlias()), any(), any()) + + assert(clearMembershipNotificationForRoomLambda).isNeverCalled() + + assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(roomId) + } +} diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/DefaultDeclineInviteTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/DefaultDeclineInviteTest.kt new file mode 100644 index 0000000..40621f4 --- /dev/null +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/DefaultDeclineInviteTest.kt @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.invite.test.InMemorySeenInvitesStore +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.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultDeclineInviteTest { + private val roomId = A_ROOM_ID + private val inviter = aRoomMember() + private val seenInvitesStore = InMemorySeenInvitesStore(initialRoomIds = setOf(roomId)) + private val clearMembershipNotificationForRoomLambda = + lambdaRecorder { _, _ -> } + private val notificationCleaner = + FakeNotificationCleaner(clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda) + + private val successLeaveRoomLambda = lambdaRecorder> { Result.success(Unit) } + private val successIgnoreUserLambda = + lambdaRecorder> { _ -> Result.success(Unit) } + private val successReportRoomLambda = + lambdaRecorder> { _ -> Result.success(Unit) } + + private val failureLeaveRoomLambda = + lambdaRecorder> { Result.failure(Exception("Leave room error")) } + private val failureIgnoreUserLambda = + lambdaRecorder> { _ -> Result.failure(Exception("Ignore user error")) } + private val failureReportRoomLambda = + lambdaRecorder> { _ -> Result.failure(Exception("Report room error")) } + + @Test + fun `decline invite, block=false, report=false, all success`() = runTest { + val room = FakeBaseRoom( + roomId = roomId, + leaveRoomLambda = successLeaveRoomLambda, + reportRoomResult = successReportRoomLambda + ) + val client = FakeMatrixClient(ignoreUserResult = successIgnoreUserLambda).apply { + givenGetRoomResult(roomId, room) + } + + val declineInvite = DefaultDeclineInvite( + client = client, + notificationCleaner = notificationCleaner, + seenInvitesStore = seenInvitesStore + ) + + val result = + declineInvite(roomId, blockUser = false, reportRoom = false, reportReason = null) + + assertThat(result.isSuccess).isTrue() + + assert(clearMembershipNotificationForRoomLambda) + .isCalledOnce() + .with(value(client.sessionId), value(roomId)) + + assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty() + } + + @Test + fun `decline invite, block=true, report=true, all success`() = runTest { + val room = FakeBaseRoom( + roomId = roomId, + leaveRoomLambda = successLeaveRoomLambda, + reportRoomResult = successReportRoomLambda, + initialRoomInfo = aRoomInfo(inviter = inviter) + ) + val client = FakeMatrixClient(ignoreUserResult = successIgnoreUserLambda).apply { + givenGetRoomResult(roomId, room) + } + val declineInvite = DefaultDeclineInvite( + client = client, + notificationCleaner = notificationCleaner, + seenInvitesStore = seenInvitesStore + ) + val result = declineInvite(roomId, blockUser = true, reportRoom = true, reportReason = null) + + assertThat(result.isSuccess).isTrue() + + assert(clearMembershipNotificationForRoomLambda) + .isCalledOnce() + .with(value(client.sessionId), value(roomId)) + + assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty() + } + + @Test + fun `decline invite, block=true, report=true, decline invite failed`() = runTest { + val room = FakeBaseRoom( + roomId = roomId, + leaveRoomLambda = failureLeaveRoomLambda, + reportRoomResult = successReportRoomLambda + ) + val client = FakeMatrixClient(ignoreUserResult = successIgnoreUserLambda).apply { + givenGetRoomResult(roomId, room) + } + val declineInvite = DefaultDeclineInvite( + client = client, + notificationCleaner = notificationCleaner, + seenInvitesStore = seenInvitesStore + ) + val result = declineInvite(roomId, blockUser = true, reportRoom = true, reportReason = null) + + assertThat(result.exceptionOrNull()).isEqualTo(DeclineInvite.Exception.DeclineInviteFailed) + + assert(clearMembershipNotificationForRoomLambda) + .isNeverCalled() + + assertThat(seenInvitesStore.seenRoomIds().first()).isNotEmpty() + } + + @Test + fun `decline invite, block=true, report=true, ignore user failed`() = runTest { + val room = FakeBaseRoom( + roomId = roomId, + leaveRoomLambda = successLeaveRoomLambda, + reportRoomResult = successReportRoomLambda, + initialRoomInfo = aRoomInfo(inviter = inviter) + ) + val client = FakeMatrixClient(ignoreUserResult = failureIgnoreUserLambda).apply { + givenGetRoomResult(roomId, room) + } + val declineInvite = DefaultDeclineInvite( + client = client, + notificationCleaner = notificationCleaner, + seenInvitesStore = seenInvitesStore + ) + val result = declineInvite(roomId, blockUser = true, reportRoom = true, reportReason = null) + + assertThat(result.exceptionOrNull()).isEqualTo(DeclineInvite.Exception.BlockUserFailed) + + assert(clearMembershipNotificationForRoomLambda).isCalledOnce() + assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty() + } + + @Test + fun `decline invite, block=true, report=true, report room failed`() = runTest { + val room = FakeBaseRoom( + roomId = roomId, + leaveRoomLambda = successLeaveRoomLambda, + reportRoomResult = failureReportRoomLambda, + initialRoomInfo = aRoomInfo(inviter = inviter) + ) + val client = FakeMatrixClient(ignoreUserResult = successIgnoreUserLambda).apply { + givenGetRoomResult(roomId, room) + } + val declineInvite = DefaultDeclineInvite( + client = client, + notificationCleaner = notificationCleaner, + seenInvitesStore = seenInvitesStore + ) + val result = declineInvite(roomId, blockUser = true, reportRoom = true, reportReason = null) + + assertThat(result.exceptionOrNull()).isEqualTo(DeclineInvite.Exception.ReportRoomFailed) + + assert(clearMembershipNotificationForRoomLambda).isCalledOnce() + assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty() + } +} diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInvitePresenterTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInvitePresenterTest.kt new file mode 100644 index 0000000..ba08029 --- /dev/null +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInvitePresenterTest.kt @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl.acceptdecline + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.invite.api.InviteData +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents +import io.element.android.features.invite.api.acceptdecline.ConfirmingDeclineInvite +import io.element.android.features.invite.impl.AcceptInvite +import io.element.android.features.invite.impl.DeclineInvite +import io.element.android.features.invite.impl.fake.FakeAcceptInvite +import io.element.android.features.invite.impl.fake.FakeDeclineInvite +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class AcceptDeclineInvitePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createAcceptDeclineInvitePresenter() + presenter.test { + awaitItem().also { state -> + assertThat(state.acceptAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + } + } + } + + @Test + fun `present - declining invite cancel flow`() = runTest { + val presenter = createAcceptDeclineInvitePresenter() + presenter.test { + val inviteData = anInviteData() + awaitItem().also { state -> + state.eventSink( + AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = true) + ) + } + awaitItem().also { state -> + assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData, false)) + state.eventSink( + InternalAcceptDeclineInviteEvents.ClearDeclineActionState + ) + } + awaitItem().also { state -> + assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + } + } + } + + @Test + fun `present - declining invite error flow`() = runTest { + val declineInviteFailure = lambdaRecorder> { _, _, _, _ -> + Result.failure(DeclineInvite.Exception.DeclineInviteFailed) + } + val presenter = createAcceptDeclineInvitePresenter( + declineInvite = FakeDeclineInvite(lambda = declineInviteFailure) + ) + presenter.test { + val inviteData = anInviteData() + awaitItem().also { state -> + state.eventSink( + AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = true) + ) + } + awaitItem().also { state -> + assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData, false)) + state.eventSink( + AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = false) + ) + } + assertThat(awaitItem().declineAction.isLoading()).isTrue() + awaitItem().also { state -> + assertThat(state.declineAction).isInstanceOf(AsyncAction.Failure::class.java) + state.eventSink( + InternalAcceptDeclineInviteEvents.ClearDeclineActionState + ) + } + awaitItem().also { state -> + assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + } + cancelAndConsumeRemainingEvents() + } + assert(declineInviteFailure) + .isCalledOnce() + .with(value(A_ROOM_ID), value(false), value(false), value(null)) + } + + @Test + fun `present - declining invite success flow`() = runTest { + val declineInviteSuccess = lambdaRecorder> { roomId, _, _, _ -> Result.success(roomId) } + val presenter = createAcceptDeclineInvitePresenter( + declineInvite = FakeDeclineInvite(lambda = declineInviteSuccess) + ) + presenter.test { + val inviteData = anInviteData() + awaitItem().also { state -> + state.eventSink( + AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = true) + ) + } + awaitItem().also { state -> + assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData, blockUser = false)) + state.eventSink( + AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = false) + ) + } + assertThat(awaitItem().declineAction.isLoading()).isTrue() + awaitItem().also { state -> + assertThat(state.declineAction).isInstanceOf(AsyncAction.Success::class.java) + } + cancelAndConsumeRemainingEvents() + } + assert(declineInviteSuccess) + .isCalledOnce() + .with(value(A_ROOM_ID), value(false), value(false), value(null)) + } + + @Test + fun `present - accepting invite error flow`() = runTest { + val acceptInviteFailure = lambdaRecorder> { roomId: RoomId -> + Result.failure(RuntimeException("Failed to accept invite")) + } + val presenter = createAcceptDeclineInvitePresenter( + acceptInvite = FakeAcceptInvite(lambda = acceptInviteFailure), + ) + presenter.test { + val inviteData = anInviteData() + awaitItem().also { state -> + state.eventSink( + AcceptDeclineInviteEvents.AcceptInvite(inviteData) + ) + } + awaitItem().also { state -> + assertThat(state.acceptAction).isEqualTo(AsyncAction.Loading) + } + awaitItem().also { state -> + assertThat(state.acceptAction).isInstanceOf(AsyncAction.Failure::class.java) + state.eventSink( + InternalAcceptDeclineInviteEvents.ClearAcceptActionState + ) + } + awaitItem().also { state -> + assertThat(state.acceptAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + } + cancelAndConsumeRemainingEvents() + } + assert(acceptInviteFailure) + .isCalledOnce() + .with(value(A_ROOM_ID)) + } + + @Test + fun `present - accepting invite success flow`() = runTest { + val acceptInviteSuccess = lambdaRecorder> { roomId: RoomId -> Result.success(roomId) } + val presenter = createAcceptDeclineInvitePresenter( + acceptInvite = FakeAcceptInvite(lambda = acceptInviteSuccess) + ) + presenter.test { + val inviteData = anInviteData() + awaitItem().also { state -> + state.eventSink( + AcceptDeclineInviteEvents.AcceptInvite(inviteData) + ) + } + awaitItem().also { state -> + assertThat(state.acceptAction).isEqualTo(AsyncAction.Loading) + } + awaitItem().also { state -> + assertThat(state.acceptAction).isInstanceOf(AsyncAction.Success::class.java) + } + cancelAndConsumeRemainingEvents() + } + acceptInviteSuccess.assertions() + .isCalledOnce() + .with(value(A_ROOM_ID)) + } + + private fun anInviteData( + roomId: RoomId = A_ROOM_ID, + name: String = A_ROOM_NAME, + isDm: Boolean = false, + ): InviteData { + return InviteData( + roomId = roomId, + roomName = name, + isDm = isDm, + ) + } + + private fun createAcceptDeclineInvitePresenter( + acceptInvite: AcceptInvite = FakeAcceptInvite(), + declineInvite: DeclineInvite = FakeDeclineInvite(), + ): AcceptDeclineInvitePresenter { + return AcceptDeclineInvitePresenter( + acceptInvite = acceptInvite, + declineInvite = declineInvite, + ) + } +} diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockPresenterTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockPresenterTest.kt new file mode 100644 index 0000000..67c2d55 --- /dev/null +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockPresenterTest.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl.declineandblock + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.invite.api.InviteData +import io.element.android.features.invite.impl.DeclineInvite +import io.element.android.features.invite.impl.fake.FakeDeclineInvite +import io.element.android.features.invite.test.anInviteData +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DeclineAndBlockPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createDeclineAndBlockPresenter() + presenter.test { + awaitItem().also { state -> + assertThat(state.blockUser).isTrue() + assertThat(state.reportRoom).isFalse() + assertThat(state.reportReason).isEmpty() + assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.canDecline).isTrue() + } + } + } + + @Test + fun `present - update form values`() = runTest { + val presenter = createDeclineAndBlockPresenter() + presenter.test { + awaitItem().also { state -> + assertThat(state.reportRoom).isFalse() + assertThat(state.blockUser).isTrue() + assertThat(state.reportReason).isEmpty() + assertThat(state.canDecline).isTrue() + state.eventSink(DeclineAndBlockEvents.ToggleBlockUser) + } + awaitItem().also { state -> + assertThat(state.reportRoom).isFalse() + assertThat(state.blockUser).isFalse() + assertThat(state.reportReason).isEmpty() + assertThat(state.canDecline).isFalse() + state.eventSink(DeclineAndBlockEvents.ToggleReportRoom) + } + awaitItem().also { state -> + assertThat(state.reportRoom).isTrue() + assertThat(state.blockUser).isFalse() + assertThat(state.reportReason).isEmpty() + assertThat(state.canDecline).isFalse() + state.eventSink(DeclineAndBlockEvents.UpdateReportReason("Spam")) + } + awaitItem().also { state -> + assertThat(state.reportRoom).isTrue() + assertThat(state.blockUser).isFalse() + assertThat(state.reportReason).isEqualTo("Spam") + assertThat(state.canDecline).isTrue() + } + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun `present - declining invite success flow`() = runTest { + val declineInviteSuccess = lambdaRecorder> { roomId, _, _, _ -> Result.success(roomId) } + val presenter = createDeclineAndBlockPresenter( + declineInvite = FakeDeclineInvite(lambda = declineInviteSuccess) + ) + presenter.test { + awaitItem().also { state -> + state.eventSink(DeclineAndBlockEvents.Decline) + } + assertThat(awaitItem().declineAction.isLoading()).isTrue() + awaitItem().also { state -> + assertThat(state.declineAction).isInstanceOf(AsyncAction.Success::class.java) + } + cancelAndConsumeRemainingEvents() + } + assert(declineInviteSuccess) + .isCalledOnce() + .with(value(A_ROOM_ID), value(true), value(false), value("")) + } + + @Test + fun `present - declining invite error flow`() = runTest { + val declineInviteFailure = lambdaRecorder> { _, _, _, _ -> + Result.failure(DeclineInvite.Exception.DeclineInviteFailed) + } + val presenter = createDeclineAndBlockPresenter( + declineInvite = FakeDeclineInvite(lambda = declineInviteFailure) + ) + presenter.test { + awaitItem().also { state -> + state.eventSink(DeclineAndBlockEvents.Decline) + } + assertThat(awaitItem().declineAction.isLoading()).isTrue() + awaitItem().also { state -> + assertThat(state.declineAction).isInstanceOf(AsyncAction.Failure::class.java) + state.eventSink(DeclineAndBlockEvents.ClearDeclineAction) + } + awaitItem().also { state -> + assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + } + cancelAndConsumeRemainingEvents() + } + assert(declineInviteFailure) + .isCalledOnce() + .with(value(A_ROOM_ID), value(true), value(false), value("")) + } + + @Test + fun `present - block user error flow`() = runTest { + val declineInviteFailure = lambdaRecorder> { _, _, _, _ -> + Result.failure(DeclineInvite.Exception.BlockUserFailed) + } + val presenter = createDeclineAndBlockPresenter( + declineInvite = FakeDeclineInvite(lambda = declineInviteFailure) + ) + presenter.test { + awaitItem().also { state -> + state.eventSink(DeclineAndBlockEvents.Decline) + } + assertThat(awaitItem().declineAction.isLoading()).isTrue() + awaitItem().also { state -> + assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + } + cancelAndConsumeRemainingEvents() + } + assert(declineInviteFailure) + .isCalledOnce() + .with(value(A_ROOM_ID), value(true), value(false), value("")) + } +} + +internal fun createDeclineAndBlockPresenter( + inviteData: InviteData = anInviteData(), + declineInvite: DeclineInvite = FakeDeclineInvite(), + snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), +): DeclineAndBlockPresenter { + return DeclineAndBlockPresenter( + inviteData = inviteData, + declineInvite = declineInvite, + snackbarDispatcher = snackbarDispatcher, + ) +} diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt new file mode 100644 index 0000000..299fec8 --- /dev/null +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl.declineandblock + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.invite.impl.R +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DeclineAndBlockViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invoke the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setDeclineAndBlockView( + aDeclineAndBlockState( + eventSink = eventsRecorder, + ), + onBackClick = it + ) + rule.pressBack() + } + } + + @Test + fun `clicking on decline when enabled emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setDeclineAndBlockView( + aDeclineAndBlockState( + blockUser = true, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_decline) + eventsRecorder.assertSingle(DeclineAndBlockEvents.Decline) + } + + @Test + fun `clicking on decline when disabled does not emit event`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setDeclineAndBlockView( + aDeclineAndBlockState( + blockUser = false, + reportRoom = false, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_decline) + eventsRecorder.assertEmpty() + } + + @Test + fun `clicking on block option emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setDeclineAndBlockView( + aDeclineAndBlockState( + blockUser = true, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_decline_and_block_block_user_option_title) + eventsRecorder.assertSingle(DeclineAndBlockEvents.ToggleBlockUser) + } + + @Test + fun `clicking on report room option emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setDeclineAndBlockView( + aDeclineAndBlockState( + reportRoom = true, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_report_room) + eventsRecorder.assertSingle(DeclineAndBlockEvents.ToggleReportRoom) + } + + @Test + fun `typing text in the reason field emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setDeclineAndBlockView( + aDeclineAndBlockState( + reportRoom = true, + reportReason = "", + eventSink = eventsRecorder, + ), + ) + rule.onNodeWithText("").performTextInput("Spam!") + eventsRecorder.assertSingle(DeclineAndBlockEvents.UpdateReportReason("Spam!")) + } +} + +private fun AndroidComposeTestRule.setDeclineAndBlockView( + state: DeclineAndBlockState, + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + DeclineAndBlockView( + state = state, + onBackClick = onBackClick, + ) + } +} diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt new file mode 100644 index 0000000..13bb13d --- /dev/null +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DefaultDeclineAndBlockEntryPointTest.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl.declineandblock + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.features.invite.test.anInviteData +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultDeclineAndBlockEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultDeclineAndBlockEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + DeclineAndBlockNode( + buildContext = buildContext, + plugins = plugins, + presenterFactory = { inviteData -> createDeclineAndBlockPresenter() } + ) + } + val inviteData = anInviteData() + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + inviteData = inviteData + ) + assertThat(result).isInstanceOf(DeclineAndBlockNode::class.java) + assertThat(result.plugins).contains(DeclineAndBlockNode.Inputs(inviteData)) + } +} diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/fake/FakeAcceptInvite.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/fake/FakeAcceptInvite.kt new file mode 100644 index 0000000..de210de --- /dev/null +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/fake/FakeAcceptInvite.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl.fake + +import io.element.android.features.invite.impl.AcceptInvite +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask + +class FakeAcceptInvite( + private val lambda: (RoomId) -> Result = { lambdaError() }, +) : AcceptInvite { + override suspend fun invoke(roomId: RoomId) = simulateLongTask { + lambda(roomId) + } +} diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/fake/FakeDeclineInvite.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/fake/FakeDeclineInvite.kt new file mode 100644 index 0000000..cb7af78 --- /dev/null +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/fake/FakeDeclineInvite.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.impl.fake + +import io.element.android.features.invite.impl.DeclineInvite +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask + +class FakeDeclineInvite( + private val lambda: (RoomId, Boolean, Boolean, String?) -> Result = { _, _, _, _ -> lambdaError() }, +) : DeclineInvite { + override suspend fun invoke(roomId: RoomId, blockUser: Boolean, reportRoom: Boolean, reportReason: String?): Result = simulateLongTask { + lambda(roomId, blockUser, reportRoom, reportReason) + } +} diff --git a/features/invite/test/build.gradle.kts b/features/invite/test/build.gradle.kts new file mode 100644 index 0000000..2df267f --- /dev/null +++ b/features/invite/test/build.gradle.kts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.invite.test" +} + +dependencies { + implementation(libs.coroutines.core) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrix.test) + implementation(projects.tests.testutils) + api(projects.features.invite.api) +} diff --git a/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/InMemorySeenInvitesStore.kt b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/InMemorySeenInvitesStore.kt new file mode 100644 index 0000000..709a4a0 --- /dev/null +++ b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/InMemorySeenInvitesStore.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.test + +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class InMemorySeenInvitesStore( + initialRoomIds: Set = emptySet(), +) : SeenInvitesStore { + private val roomIds = MutableStateFlow(initialRoomIds) + + override fun seenRoomIds(): Flow> = roomIds + + override suspend fun markAsSeen(roomId: RoomId) { + roomIds.value += roomId + } + + override suspend fun markAsUnSeen(roomId: RoomId) { + roomIds.value -= roomId + } + + override suspend fun clear() { + roomIds.value = emptySet() + } +} diff --git a/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/InviteData.kt b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/InviteData.kt new file mode 100644 index 0000000..84810f2 --- /dev/null +++ b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/InviteData.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.test + +import io.element.android.features.invite.api.InviteData +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_NAME + +fun anInviteData( + roomId: RoomId = A_ROOM_ID, + roomName: String = A_ROOM_NAME, + isDm: Boolean = false, +) = InviteData( + roomId = roomId, + roomName = roomName, + isDm = isDm, +) diff --git a/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt new file mode 100644 index 0000000..52d4d17 --- /dev/null +++ b/features/invite/test/src/main/kotlin/io/element/android/features/invite/test/declineandblock/FakeDeclineInviteAndBlockEntryPoint.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invite.test.declineandblock + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.invite.api.InviteData +import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeDeclineInviteAndBlockEntryPoint : DeclineInviteAndBlockEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + inviteData: InviteData, + ): Node { + lambdaError() + } +} diff --git a/features/invitepeople/api/build.gradle.kts b/features/invitepeople/api/build.gradle.kts new file mode 100644 index 0000000..9fba484 --- /dev/null +++ b/features/invitepeople/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.invitepeople.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleEvents.kt b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleEvents.kt new file mode 100644 index 0000000..264aafd --- /dev/null +++ b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invitepeople.api + +interface InvitePeopleEvents { + data object SendInvites : InvitePeopleEvents + data object CloseSearch : InvitePeopleEvents +} diff --git a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeoplePresenter.kt b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeoplePresenter.kt new file mode 100644 index 0000000..0be0798 --- /dev/null +++ b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeoplePresenter.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invitepeople.api + +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.JoinedRoom + +interface InvitePeoplePresenter : Presenter { + interface Factory { + fun create( + joinedRoom: JoinedRoom?, + roomId: RoomId, + ): InvitePeoplePresenter + } +} diff --git a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleRenderer.kt b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleRenderer.kt new file mode 100644 index 0000000..7bad81a --- /dev/null +++ b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleRenderer.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invitepeople.api + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +interface InvitePeopleRenderer { + @Composable + fun Render( + state: InvitePeopleState, + modifier: Modifier, + ) +} diff --git a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleState.kt b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleState.kt new file mode 100644 index 0000000..9d342d1 --- /dev/null +++ b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleState.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invitepeople.api + +import io.element.android.libraries.architecture.AsyncAction + +interface InvitePeopleState { + val canInvite: Boolean + val isSearchActive: Boolean + val sendInvitesAction: AsyncAction + val eventSink: (InvitePeopleEvents) -> Unit +} diff --git a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleStateProvider.kt b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleStateProvider.kt new file mode 100644 index 0000000..ce30bcc --- /dev/null +++ b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleStateProvider.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invitepeople.api + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction + +class InvitePeopleStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aPreviewInvitePeopleState(), + aPreviewInvitePeopleState(canInvite = true), + aPreviewInvitePeopleState(isSearchActive = true), + aPreviewInvitePeopleState(sendInvitesAction = AsyncAction.Loading), + ) +} + +private data class PreviewInvitePeopleState( + override val canInvite: Boolean, + override val isSearchActive: Boolean, + override val sendInvitesAction: AsyncAction, + override val eventSink: (InvitePeopleEvents) -> Unit, +) : InvitePeopleState + +private fun aPreviewInvitePeopleState( + canInvite: Boolean = false, + isSearchActive: Boolean = false, + sendInvitesAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (InvitePeopleEvents) -> Unit = {}, +) = PreviewInvitePeopleState( + canInvite = canInvite, + isSearchActive = isSearchActive, + sendInvitesAction = sendInvitesAction, + eventSink = eventSink +) diff --git a/features/invitepeople/impl/build.gradle.kts b/features/invitepeople/impl/build.gradle.kts new file mode 100644 index 0000000..e202540 --- /dev/null +++ b/features/invitepeople/impl/build.gradle.kts @@ -0,0 +1,46 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.invitepeople.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + 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.usersearch.impl) + implementation(libs.coil.compose) + implementation(projects.services.apperror.api) + api(projects.features.invitepeople.api) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.usersearch.test) + testImplementation(projects.services.apperror.test) +} diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleEvents.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleEvents.kt new file mode 100644 index 0000000..b0c8994 --- /dev/null +++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleEvents.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invitepeople.impl + +import io.element.android.features.invitepeople.api.InvitePeopleEvents +import io.element.android.libraries.matrix.api.user.MatrixUser + +sealed interface DefaultInvitePeopleEvents : InvitePeopleEvents { + data class ToggleUser(val user: MatrixUser) : DefaultInvitePeopleEvents + data class UpdateSearchQuery(val query: String) : DefaultInvitePeopleEvents + data class OnSearchActiveChanged(val active: Boolean) : DefaultInvitePeopleEvents +} diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenter.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenter.kt new file mode 100644 index 0000000..5ac3bc2 --- /dev/null +++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenter.kt @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invitepeople.impl + +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.invitepeople.api.InvitePeopleEvents +import io.element.android.features.invitepeople.api.InvitePeoplePresenter +import io.element.android.features.invitepeople.api.InvitePeopleState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.map +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.annotations.SessionCoroutineScope +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.JoinedRoom +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.filterMembers +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.libraries.usersearch.api.UserRepository +import io.element.android.services.apperror.api.AppErrorStateService +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@AssistedInject +class DefaultInvitePeoplePresenter( + @Assisted private val joinedRoom: JoinedRoom?, + @Assisted private val roomId: RoomId, + private val userRepository: UserRepository, + private val coroutineDispatchers: CoroutineDispatchers, + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, + private val appErrorStateService: AppErrorStateService, + private val matrixClient: MatrixClient, +) : InvitePeoplePresenter { + @AssistedFactory + @ContributesBinding(SessionScope::class) + interface Factory : InvitePeoplePresenter.Factory { + override fun create(joinedRoom: JoinedRoom?, roomId: RoomId): DefaultInvitePeoplePresenter + } + + @Composable + override fun present(): InvitePeopleState { + val roomMembers = remember { mutableStateOf>>(AsyncData.Loading()) } + val selectedUsers = remember { mutableStateOf>(persistentListOf()) } + val searchResults = remember { mutableStateOf>>(SearchBarResultState.Initial()) } + var searchQuery by rememberSaveable { mutableStateOf("") } + var searchActive by rememberSaveable { mutableStateOf(false) } + val showSearchLoader = rememberSaveable { mutableStateOf(false) } + val sendInvitesAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + + val room by produceState(if (joinedRoom != null) AsyncData.Success(joinedRoom) else AsyncData.Loading()) { + if (joinedRoom == null) { + val result = matrixClient.getJoinedRoom(roomId) + value = if (result == null) { + AsyncData.Failure(Exception("Room not found")) + } else { + AsyncData.Success(result) + } + } + } + + LaunchedEffect(room.isSuccess()) { + room.dataOrNull()?.let { + fetchMembers(it, roomMembers) + } + } + LaunchedEffect(searchQuery, roomMembers) { + performSearch( + searchResults = searchResults, + roomMembers = roomMembers, + selectedUsers = selectedUsers, + showSearchLoader = showSearchLoader, + searchQuery = searchQuery + ) + } + + fun handleEvent(event: InvitePeopleEvents) { + when (event) { + is DefaultInvitePeopleEvents.OnSearchActiveChanged -> { + searchActive = event.active + searchQuery = "" + } + + is DefaultInvitePeopleEvents.UpdateSearchQuery -> { + searchQuery = event.query + } + + is DefaultInvitePeopleEvents.ToggleUser -> { + selectedUsers.toggleUser(event.user) + searchResults.toggleUser(event.user) + } + is InvitePeopleEvents.SendInvites -> { + room.dataOrNull()?.let { + sessionCoroutineScope.sendInvites(it, selectedUsers.value, sendInvitesAction) + } + } + is InvitePeopleEvents.CloseSearch -> { + searchActive = false + searchQuery = "" + } + } + } + + return DefaultInvitePeopleState( + room = room.map { }, + canInvite = selectedUsers.value.isNotEmpty() && !sendInvitesAction.value.isLoading(), + selectedUsers = selectedUsers.value, + searchQuery = searchQuery, + isSearchActive = searchActive, + searchResults = searchResults.value, + showSearchLoader = showSearchLoader.value, + sendInvitesAction = sendInvitesAction.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.sendInvites( + room: JoinedRoom, + selectedUsers: List, + sendInvitesAction: MutableState>, + ) = launch { + sendInvitesAction.runUpdatingState { + val anyInviteFailed = selectedUsers + .map { room.inviteUserById(it.userId) } + .any { it.isFailure } + + if (anyInviteFailed) { + appErrorStateService.showError( + titleRes = CommonStrings.common_unable_to_invite_title, + bodyRes = CommonStrings.common_unable_to_invite_message, + ) + } + + Result.success(Unit) + } + } + + @JvmName("toggleUserInSelectedUsers") + private fun MutableState>.toggleUser(user: MatrixUser) { + value = if (value.contains(user)) { + value.filterNot { it.userId == user.userId } + } else { + value + user + }.toImmutableList() + } + + @JvmName("toggleUserInSearchResults") + private fun MutableState>>.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>>, + roomMembers: MutableState>>, + selectedUsers: MutableState>, + showSearchLoader: MutableState, + searchQuery: String, + ) = withContext(coroutineDispatchers.io) { + searchResults.value = SearchBarResultState.Initial() + showSearchLoader.value = false + val joinedMembers = roomMembers.value.dataOrNull().orEmpty() + + userRepository.search(searchQuery).onEach { state -> + showSearchLoader.value = state.isSearching + searchResults.value = when { + state.results.isEmpty() && state.isSearching -> SearchBarResultState.Initial() + state.results.isEmpty() && !state.isSearching -> SearchBarResultState.NoResultsFound() + else -> SearchBarResultState.Results(state.results.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), + isAlreadyJoined = isJoined, + isAlreadyInvited = isInvited, + isUnresolved = result.isUnresolved, + ) + }.toImmutableList()) + } + }.launchIn(this) + } + + private suspend fun fetchMembers( + room: JoinedRoom, + roomMembers: MutableState>> + ) { + suspend { + room.filterMembers("", coroutineDispatchers.io).toImmutableList() + }.runCatchingUpdatingState(roomMembers) + } +} diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleRenderer.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleRenderer.kt new file mode 100644 index 0000000..59f25f8 --- /dev/null +++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleRenderer.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invitepeople.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.invitepeople.api.InvitePeopleRenderer +import io.element.android.features.invitepeople.api.InvitePeopleState +import io.element.android.libraries.di.SessionScope + +@ContributesBinding(SessionScope::class) +class DefaultInvitePeopleRenderer : InvitePeopleRenderer { + @Composable + override fun Render(state: InvitePeopleState, modifier: Modifier) { + if (state is DefaultInvitePeopleState) { + InvitePeopleView( + state = state, + modifier = modifier + ) + } else { + error("Unsupported state type: ${state::javaClass}") + } + } +} diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleState.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleState.kt new file mode 100644 index 0000000..917915e --- /dev/null +++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invitepeople.impl + +import io.element.android.features.invitepeople.api.InvitePeopleEvents +import io.element.android.features.invitepeople.api.InvitePeopleState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList + +data class DefaultInvitePeopleState( + val room: AsyncData, + override val canInvite: Boolean, + val searchQuery: String, + val showSearchLoader: Boolean, + val searchResults: SearchBarResultState>, + val selectedUsers: ImmutableList, + override val isSearchActive: Boolean, + override val sendInvitesAction: AsyncAction, + override val eventSink: (InvitePeopleEvents) -> Unit +) : InvitePeopleState diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStateProvider.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStateProvider.kt new file mode 100644 index 0000000..2f28db8 --- /dev/null +++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStateProvider.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invitepeople.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +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.aMatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +internal class DefaultInvitePeopleStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aDefaultInvitePeopleState(), + aDefaultInvitePeopleState(canInvite = true, selectedUsers = aMatrixUserList().toImmutableList()), + aDefaultInvitePeopleState(isSearchActive = true, searchQuery = "some query"), + aDefaultInvitePeopleState(isSearchActive = true, searchQuery = "some query", selectedUsers = aMatrixUserList().toImmutableList()), + aDefaultInvitePeopleState(isSearchActive = true, searchQuery = "some query", searchResults = SearchBarResultState.NoResultsFound()), + aDefaultInvitePeopleState( + isSearchActive = true, + canInvite = true, + searchQuery = "some query", + selectedUsers = persistentListOf( + aMatrixUser("@carol:server.org", "Carol") + ), + searchResults = SearchBarResultState.Results( + persistentListOf( + anInvitableUser(aMatrixUser("@alice:server.org")), + anInvitableUser(aMatrixUser("@bob:server.org", "Bob")), + anInvitableUser(aMatrixUser("@carol:server.org", "Carol"), isSelected = true), + anInvitableUser(aMatrixUser("@eve:server.org", "Eve"), isSelected = true, isAlreadyJoined = true), + anInvitableUser(aMatrixUser("@justin:server.org", "Justin"), isSelected = true, isAlreadyInvited = true), + ) + ) + ), + aDefaultInvitePeopleState( + isSearchActive = true, + canInvite = true, + searchQuery = "@alice:server.org", + selectedUsers = persistentListOf( + aMatrixUser("@carol:server.org", "Carol") + ), + searchResults = SearchBarResultState.Results( + persistentListOf( + anInvitableUser(aMatrixUser("@alice:server.org"), isUnresolved = true), + anInvitableUser(aMatrixUser("@bob:server.org", "Bob")), + ) + ) + ), + aDefaultInvitePeopleState( + isSearchActive = true, + canInvite = true, + searchQuery = "@alice:server.org", + searchResults = SearchBarResultState.Results( + persistentListOf( + anInvitableUser(aMatrixUser("@alice:server.org"), isUnresolved = true), + ) + ), + showSearchLoader = true, + ), + aDefaultInvitePeopleState(room = AsyncData.Failure(Exception("Room not found"))), + aDefaultInvitePeopleState( + canInvite = false, + selectedUsers = aMatrixUserList().toImmutableList(), + sendInvitesAction = AsyncAction.Loading, + ), + ) +} + +private fun anInvitableUser( + matrixUser: MatrixUser, + isSelected: Boolean = false, + isAlreadyJoined: Boolean = false, + isAlreadyInvited: Boolean = false, + isUnresolved: Boolean = false, +) = InvitableUser( + matrixUser = matrixUser, + isSelected = isSelected, + isAlreadyJoined = isAlreadyJoined, + isAlreadyInvited = isAlreadyInvited, + isUnresolved = isUnresolved, +) + +private fun aDefaultInvitePeopleState( + room: AsyncData = AsyncData.Success(Unit), + canInvite: Boolean = false, + searchQuery: String = "", + searchResults: SearchBarResultState> = SearchBarResultState.Initial(), + selectedUsers: ImmutableList = persistentListOf(), + isSearchActive: Boolean = false, + showSearchLoader: Boolean = false, + sendInvitesAction: AsyncAction = AsyncAction.Uninitialized, +): DefaultInvitePeopleState { + return DefaultInvitePeopleState( + room = room, + canInvite = canInvite, + searchQuery = searchQuery, + searchResults = searchResults, + selectedUsers = selectedUsers, + isSearchActive = isSearchActive, + showSearchLoader = showSearchLoader, + sendInvitesAction = sendInvitesAction, + eventSink = {}, + ) +} diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitableUser.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitableUser.kt new file mode 100644 index 0000000..d2fd3f5 --- /dev/null +++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitableUser.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invitepeople.impl + +import io.element.android.libraries.matrix.api.user.MatrixUser + +data class InvitableUser( + val matrixUser: MatrixUser, + val isSelected: Boolean, + val isAlreadyJoined: Boolean, + val isAlreadyInvited: Boolean, + val isUnresolved: Boolean, +) diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitePeopleView.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitePeopleView.kt new file mode 100644 index 0000000..a8e056c --- /dev/null +++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitePeopleView.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invitepeople.impl + +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.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.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.compound.theme.ElementTheme +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.async.AsyncFailure +import io.element.android.libraries.designsystem.components.async.AsyncLoading +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.CheckableUserRow +import io.element.android.libraries.matrix.ui.components.CheckableUserRowData +import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList +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 +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun InvitePeopleView( + state: DefaultInvitePeopleState, + modifier: Modifier = Modifier, +) { + when (state.room) { + is AsyncData.Failure -> InvitePeopleViewError(state.room.error, modifier) + AsyncData.Uninitialized, + is AsyncData.Loading, + is AsyncData.Success -> InvitePeopleContentView(state, modifier) + } +} + +@Composable +private fun InvitePeopleViewError( + error: Throwable, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + AsyncFailure( + throwable = error, + onRetry = null, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } +} + +@Composable +private fun InvitePeopleContentView( + state: DefaultInvitePeopleState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + InvitePeopleSearchBar( + modifier = Modifier.fillMaxWidth(), + query = state.searchQuery, + showLoader = state.showSearchLoader, + selectedUsers = state.selectedUsers, + state = state.searchResults, + active = state.isSearchActive, + onActiveChange = { + state.eventSink( + DefaultInvitePeopleEvents.OnSearchActiveChanged( + it + ) + ) + }, + onTextChange = { state.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery(it)) }, + onToggleUser = { state.eventSink(DefaultInvitePeopleEvents.ToggleUser(it)) }, + ) + + if (!state.isSearchActive) { + SelectedUsersRowList( + modifier = Modifier.fillMaxWidth(), + selectedUsers = state.selectedUsers, + autoScroll = true, + onUserRemove = { state.eventSink(DefaultInvitePeopleEvents.ToggleUser(it)) }, + contentPadding = PaddingValues(16.dp), + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun InvitePeopleSearchBar( + query: String, + state: SearchBarResultState>, + showLoader: Boolean, + selectedUsers: ImmutableList, + active: Boolean, + onActiveChange: (Boolean) -> Unit, + onTextChange: (String) -> Unit, + onToggleUser: (MatrixUser) -> Unit, + modifier: Modifier = Modifier, + placeHolderTitle: String = stringResource(CommonStrings.common_search_for_someone), +) { + SearchBar( + query = query, + onQueryChange = onTextChange, + active = active, + onActiveChange = onActiveChange, + modifier = modifier, + placeHolderTitle = placeHolderTitle, + contentPrefix = { + if (selectedUsers.isNotEmpty()) { + SelectedUsersRowList( + modifier = Modifier.fillMaxWidth(), + selectedUsers = selectedUsers, + autoScroll = true, + onUserRemove = onToggleUser, + contentPadding = PaddingValues(16.dp), + ) + } + }, + showBackButton = false, + resultState = state, + contentSuffix = { + if (showLoader) { + AsyncLoading() + } + }, + 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 -> + val invitedOrJoined = invitableUser.isAlreadyInvited || invitableUser.isAlreadyJoined + val isUnresolved = invitableUser.isUnresolved && !invitedOrJoined + val enabled = isUnresolved || !invitedOrJoined + val data = if (isUnresolved) { + CheckableUserRowData.Unresolved( + avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.UserListItem), + id = invitableUser.matrixUser.userId.value, + ) + } else { + CheckableUserRowData.Resolved( + 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_invite_users_already_a_member) + invitableUser.isAlreadyInvited -> stringResource(R.string.screen_invite_users_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 + } + ) + } + CheckableUserRow( + checked = invitableUser.isSelected || invitedOrJoined, + enabled = enabled, + data = data, + onCheckedChange = { onToggleUser(invitableUser.matrixUser) }, + modifier = Modifier.fillMaxWidth() + ) + } + } + }, + ) +} + +@PreviewsDayNight +@Composable +internal fun InvitePeopleViewPreview(@PreviewParameter(DefaultInvitePeopleStateProvider::class) state: DefaultInvitePeopleState) = + ElementPreview { + InvitePeopleView(state = state) + } diff --git a/features/invitepeople/impl/src/main/res/values-be/translations.xml b/features/invitepeople/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..7125d8b --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,5 @@ + + + "Ужо ўдзельнік" + "Ужо запрасілі" + diff --git a/features/invitepeople/impl/src/main/res/values-bg/translations.xml b/features/invitepeople/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..f585980 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,5 @@ + + + "Вече е член" + "Вече е бил поканен" + diff --git a/features/invitepeople/impl/src/main/res/values-cs/translations.xml b/features/invitepeople/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..fa5b3aa --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,5 @@ + + + "Již členem" + "Již pozván(a)" + diff --git a/features/invitepeople/impl/src/main/res/values-cy/translations.xml b/features/invitepeople/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..c58df3c --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,5 @@ + + + "Eisoes yn aelod" + "Wedi gwahodd yn barod" + diff --git a/features/invitepeople/impl/src/main/res/values-da/translations.xml b/features/invitepeople/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..fbb1814 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,5 @@ + + + "Allerede medlem" + "Allerede inviteret" + diff --git a/features/invitepeople/impl/src/main/res/values-de/translations.xml b/features/invitepeople/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..182d9f2 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,5 @@ + + + "Bereits Mitglied" + "Bereits eingeladen" + diff --git a/features/invitepeople/impl/src/main/res/values-el/translations.xml b/features/invitepeople/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..3e3c1fd --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,5 @@ + + + "Ήδη μέλος" + "Ήδη προσκεκλημένος" + diff --git a/features/invitepeople/impl/src/main/res/values-es/translations.xml b/features/invitepeople/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..e62bb21 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,5 @@ + + + "Ya eres miembro" + "Ya estás invitado" + diff --git a/features/invitepeople/impl/src/main/res/values-et/translations.xml b/features/invitepeople/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..44484d2 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,5 @@ + + + "Sa juba oled jututoa liige" + "Sa juba oled kutse saanud" + diff --git a/features/invitepeople/impl/src/main/res/values-eu/translations.xml b/features/invitepeople/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..a6aedd8 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,5 @@ + + + "Kidea da dagoeneko" + "Lehendik ere gonbidatuta" + diff --git a/features/invitepeople/impl/src/main/res/values-fa/translations.xml b/features/invitepeople/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..544f01b --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,5 @@ + + + "از پیش عضو است" + "از پیش دعوت شده" + diff --git a/features/invitepeople/impl/src/main/res/values-fi/translations.xml b/features/invitepeople/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..e347919 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,5 @@ + + + "On jo jäsen" + "On jo kutsuttu" + diff --git a/features/invitepeople/impl/src/main/res/values-fr/translations.xml b/features/invitepeople/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..dcc16f5 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,5 @@ + + + "Déjà membre" + "Déjà invité(e)" + diff --git a/features/invitepeople/impl/src/main/res/values-hu/translations.xml b/features/invitepeople/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..16f35b0 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,5 @@ + + + "Már tag" + "Már meghívták" + diff --git a/features/invitepeople/impl/src/main/res/values-in/translations.xml b/features/invitepeople/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..e112033 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,5 @@ + + + "Sudah menjadi anggota" + "Sudah diundang" + diff --git a/features/invitepeople/impl/src/main/res/values-it/translations.xml b/features/invitepeople/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..979e42d --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,5 @@ + + + "Già membro" + "Già invitato" + diff --git a/features/invitepeople/impl/src/main/res/values-ka/translations.xml b/features/invitepeople/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..3a34c2e --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,5 @@ + + + "უკვე წევრია" + "უკვე მოწვეულია" + diff --git a/features/invitepeople/impl/src/main/res/values-ko/translations.xml b/features/invitepeople/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..19214c5 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,5 @@ + + + "이미 회원" + "이미 초대됨" + diff --git a/features/invitepeople/impl/src/main/res/values-lt/translations.xml b/features/invitepeople/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..59c1529 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,5 @@ + + + "Jau narys" + "Jau pakviestas" + diff --git a/features/invitepeople/impl/src/main/res/values-nb/translations.xml b/features/invitepeople/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..617b927 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,5 @@ + + + "Allerede medlem" + "Allerede invitert" + diff --git a/features/invitepeople/impl/src/main/res/values-nl/translations.xml b/features/invitepeople/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..b978dcd --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,5 @@ + + + "Reeds lid" + "Reeds uitgenodigd" + diff --git a/features/invitepeople/impl/src/main/res/values-pl/translations.xml b/features/invitepeople/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..bfd537b --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,5 @@ + + + "Jest już członkiem" + "Już zaproszony" + diff --git a/features/invitepeople/impl/src/main/res/values-pt-rBR/translations.xml b/features/invitepeople/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..7ad0498 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,5 @@ + + + "Já é membro" + "Já foi convidado" + diff --git a/features/invitepeople/impl/src/main/res/values-pt/translations.xml b/features/invitepeople/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..a953d11 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,5 @@ + + + "Já é participante" + "Já foi convidado" + diff --git a/features/invitepeople/impl/src/main/res/values-ro/translations.xml b/features/invitepeople/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..f03be4b --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,5 @@ + + + "Deja membru" + "Deja invitat" + diff --git a/features/invitepeople/impl/src/main/res/values-ru/translations.xml b/features/invitepeople/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..0ae8eb7 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,5 @@ + + + "Уже зарегистрирован" + "Уже приглашены" + diff --git a/features/invitepeople/impl/src/main/res/values-sk/translations.xml b/features/invitepeople/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..68e34b0 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,5 @@ + + + "Už ste členom" + "Už ste pozvaní" + diff --git a/features/invitepeople/impl/src/main/res/values-sv/translations.xml b/features/invitepeople/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..fd670c9 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,5 @@ + + + "Redan medlem" + "Redan inbjuden" + diff --git a/features/invitepeople/impl/src/main/res/values-tr/translations.xml b/features/invitepeople/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..427a7f3 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,5 @@ + + + "Zaten üye" + "Zaten davet edildi" + diff --git a/features/invitepeople/impl/src/main/res/values-uk/translations.xml b/features/invitepeople/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..da3ac9f --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,5 @@ + + + "Уже учасник" + "Уже запрошені" + diff --git a/features/invitepeople/impl/src/main/res/values-ur/translations.xml b/features/invitepeople/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..06e8cee --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,5 @@ + + + "پہلے سے ہی رکن" + "پہلے سے مدعو شدہ" + diff --git a/features/invitepeople/impl/src/main/res/values-uz/translations.xml b/features/invitepeople/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..445a62d --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,5 @@ + + + "Allaqachon a\'zo" + "Allaqachon taklif qilingan" + diff --git a/features/invitepeople/impl/src/main/res/values-zh-rTW/translations.xml b/features/invitepeople/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..c8117ec --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,5 @@ + + + "已是成員" + "已邀請" + diff --git a/features/invitepeople/impl/src/main/res/values-zh/translations.xml b/features/invitepeople/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..b1e0e95 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,5 @@ + + + "已经是成员" + "已邀请" + diff --git a/features/invitepeople/impl/src/main/res/values/localazy.xml b/features/invitepeople/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..d89ae92 --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values/localazy.xml @@ -0,0 +1,5 @@ + + + "Already a member" + "Already invited" + diff --git a/features/invitepeople/impl/src/test/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenterTest.kt b/features/invitepeople/impl/src/test/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenterTest.kt new file mode 100644 index 0000000..388baf1 --- /dev/null +++ b/features/invitepeople/impl/src/test/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenterTest.kt @@ -0,0 +1,574 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invitepeople.impl + +import app.cash.turbine.ReceiveTurbine +import com.google.common.truth.Truth.assertThat +import io.element.android.features.invitepeople.api.InvitePeopleEvents +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +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.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.user.MatrixUser +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_USER_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.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.matrix.test.room.aRoomMemberList +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.libraries.usersearch.api.UserRepository +import io.element.android.libraries.usersearch.api.UserSearchResult +import io.element.android.libraries.usersearch.api.UserSearchResultState +import io.element.android.libraries.usersearch.test.FakeUserRepository +import io.element.android.services.apperror.api.AppErrorStateService +import io.element.android.services.apperror.test.FakeAppErrorStateService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +internal class DefaultInvitePeoplePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state has no results and no search`() = runTest { + val presenter = createDefaultInvitePeoplePresenter() + presenter.test { + val initialState = awaitItemAsDefault() + assertThat(initialState.room).isEqualTo(AsyncData.Success(Unit)) + assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::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 = createDefaultInvitePeoplePresenter() + presenter.test { + val initialState = awaitItem() + skipItems(1) + + initialState.eventSink(DefaultInvitePeopleEvents.OnSearchActiveChanged(true)) + + val resultState = awaitItem() + assertThat(resultState.isSearchActive).isTrue() + resultState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query")) + assertThat(awaitItemAsDefault().searchQuery).isEqualTo("some query") + resultState.eventSink(InvitePeopleEvents.CloseSearch) + skipItems(1) + awaitItemAsDefault().also { + assertThat(it.isSearchActive).isFalse() + assertThat(it.searchQuery).isEmpty() + } + } + } + + @Test + fun `present - performs search and handles empty result list`() = runTest { + val repository = FakeUserRepository() + val presenter = createDefaultInvitePeoplePresenter( + userRepository = repository, + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query")) + assertThat(repository.providedQuery).isEqualTo("some query") + repository.emitState(UserSearchResultState(results = emptyList(), isSearching = true)) + skipItems(3) + awaitItemAsDefault().also { state -> + assertThat(state.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java) + assertThat(state.showSearchLoader).isTrue() + } + repository.emitState(results = emptyList(), isSearching = false) + awaitItemAsDefault().also { state -> + assertThat(state.searchResults).isInstanceOf(SearchBarResultState.NoResultsFound::class.java) + assertThat(state.showSearchLoader).isFalse() + } + } + } + + @Test + fun `present - performs search and handles user results`() = runTest { + val repository = FakeUserRepository() + val presenter = createDefaultInvitePeoplePresenter( + userRepository = repository, + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + ) + presenter.test { + val initialState = awaitItem() + skipItems(1) + + initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query")) + skipItems(1) + + assertThat(repository.providedQuery).isEqualTo("some query") + repository.emitStateWithUsers(users = aMatrixUserList()) + skipItems(1) + + val resultState = awaitItemAsDefault() + 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) + // All users are joined or invited + if (users[index].isAlreadyInvited) { + assertThat(users[index].isAlreadyJoined).isFalse() + } else { + assertThat(users[index].isAlreadyJoined).isTrue() + } + 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 = createDefaultInvitePeoplePresenter( + userRepository = repository, + roomMembersState = RoomMembersState.Ready( + persistentListOf( + aRoomMember( + userId = joinedUser.userId, + membership = RoomMembershipState.JOIN + ), + aRoomMember( + userId = invitedUser.userId, + membership = RoomMembershipState.INVITE + ), + ) + ), + coroutineDispatchers = coroutineDispatchers, + ) + presenter.test { + val initialState = awaitItem() + skipItems(1) + + initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query")) + skipItems(1) + + assertThat(repository.providedQuery).isEqualTo("some query") + repository.emitStateWithUsers(users = aMatrixUserList()) + skipItems(1) + + val resultState = awaitItemAsDefault() + 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 = createDefaultInvitePeoplePresenter( + userRepository = repository, + roomMembersState = + RoomMembersState.Ready( + persistentListOf( + aRoomMember( + userId = joinedUser.userId, + membership = RoomMembershipState.JOIN + ), + aRoomMember( + userId = invitedUser.userId, + membership = RoomMembershipState.INVITE + ), + ) + ), + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + ) + + presenter.test { + val initialState = awaitItem() + skipItems(1) + + initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query")) + skipItems(1) + + assertThat(repository.providedQuery).isEqualTo("some query") + + val unresolvedUser = + UserSearchResult(aMatrixUser(id = A_USER_ID.value), isUnresolved = true) + repository.emitState(listOf(unresolvedUser) + aMatrixUserList().map { + UserSearchResult( + it + ) + }) + skipItems(1) + + val resultState = awaitItemAsDefault() + 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 = createDefaultInvitePeoplePresenter( + userRepository = repository, + coroutineDispatchers = testCoroutineDispatchers() + ) + presenter.test { + val initialState = awaitItem() + skipItems(1) + + // When we toggle a user not in the list, they are added + initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(aMatrixUser())) + assertThat(awaitItemAsDefault().selectedUsers).containsExactly(aMatrixUser()) + + // Toggling a different user also adds them + initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(aMatrixUser(id = A_USER_ID_2.value))) + assertThat(awaitItemAsDefault().selectedUsers).containsExactly( + aMatrixUser(), + aMatrixUser(id = A_USER_ID_2.value) + ) + + // Toggling the first user removes them + initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(aMatrixUser())) + assertThat(awaitItemAsDefault().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 = createDefaultInvitePeoplePresenter( + userRepository = repository, + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + ) + presenter.test { + val initialState = awaitItem() + skipItems(1) + + val selectedUser = aMatrixUser() + + initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(selectedUser)) + + initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query")) + skipItems(1) + + assertThat(repository.providedQuery).isEqualTo("some query") + repository.emitStateWithUsers(users = aMatrixUserList() + selectedUser) + skipItems(2) + + val resultState = awaitItemAsDefault() + 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 = createDefaultInvitePeoplePresenter( + userRepository = repository, + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + ) + presenter.test { + val initialState = awaitItem() + skipItems(1) + + val selectedUser = aMatrixUser() + + // Given a query is made + initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query")) + skipItems(1) + + assertThat(repository.providedQuery).isEqualTo("some query") + repository.emitStateWithUsers(users = aMatrixUserList() + selectedUser) + skipItems(1) + awaitItemAsDefault().also { state -> + // selectedUser is not selected + assertThat(state.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) + val users = state.searchResults.users() + val shouldNotBeSelectedUser = users.find { it.matrixUser == selectedUser } + assertThat(shouldNotBeSelectedUser).isNotNull() + assertThat(shouldNotBeSelectedUser?.isSelected).isFalse() + } + + // And then a user is toggled + initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(selectedUser)) + skipItems(1) + val resultState = awaitItemAsDefault() + + // 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() + } + } + + @Test + fun `present - toggling a user and send invite success`() = runTest { + val repository = FakeUserRepository() + val inviteUserResult = lambdaRecorder> { userId: UserId -> + Result.success(Unit) + } + val presenter = createDefaultInvitePeoplePresenter( + userRepository = repository, + inviteUserResult = inviteUserResult, + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + ) + presenter.test { + val initialState = awaitItem() + skipItems(1) + val selectedUser = aMatrixUser() + repository.emitStateWithUsers(users = aMatrixUserList() + selectedUser) + skipItems(1) + // And then a user is toggled + initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(selectedUser)) + skipItems(1) + val resultState = awaitItemAsDefault() + // The results are updated... + assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) + // Send invites + initialState.eventSink(InvitePeopleEvents.SendInvites) + + // Can't invite in the loading state + awaitItem().run { + assertThat(sendInvitesAction.isLoading()).isTrue() + assertThat(canInvite).isFalse() + } + + delay(1_000) + inviteUserResult.assertions().isCalledOnce().with( + value(selectedUser.userId) + ) + + // Can invite again once the action is finished + awaitItem().run { + assertThat(sendInvitesAction.isReady()).isTrue() + assertThat(canInvite).isTrue() + } + } + } + + @Test + fun `present - toggling a user and send invite error`() = runTest { + val repository = FakeUserRepository() + val inviteUserResult = lambdaRecorder> { _: UserId -> + Result.failure(AN_EXCEPTION) + } + val showErrorResResult = lambdaRecorder { _, _ -> } + val presenter = createDefaultInvitePeoplePresenter( + userRepository = repository, + inviteUserResult = inviteUserResult, + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + appErrorStateService = FakeAppErrorStateService( + showErrorResResult = showErrorResResult, + ) + ) + presenter.test { + val initialState = awaitItem() + skipItems(1) + val selectedUser = aMatrixUser() + repository.emitStateWithUsers(users = aMatrixUserList() + selectedUser) + skipItems(1) + // And then a user is toggled + initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(selectedUser)) + skipItems(1) + val resultState = awaitItemAsDefault() + // The results are updated... + assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) + // Send invites + initialState.eventSink(InvitePeopleEvents.SendInvites) + + // Can't invite in the loading state + awaitItem().run { + assertThat(sendInvitesAction.isLoading()).isTrue() + assertThat(canInvite).isFalse() + } + + delay(1_000) + inviteUserResult.assertions().isCalledOnce().with( + value(selectedUser.userId) + ) + showErrorResResult.assertions() + .isCalledOnce() + .with( + value(CommonStrings.common_unable_to_invite_title), + value(CommonStrings.common_unable_to_invite_message) + ) + + // Can invite again once the action is finished + awaitItem().run { + assertThat(sendInvitesAction.isReady()).isTrue() + assertThat(canInvite).isTrue() + } + } + } + + @Test + fun `present - when joinedRoom is not provided, it is retrieved on the MatrixClient`() = runTest { + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, FakeJoinedRoom()) + } + val presenter = createDefaultInvitePeoplePresenter( + joinedRoom = null, + roomId = A_ROOM_ID, + matrixClient = matrixClient, + ) + presenter.test { + val initialState = awaitItemAsDefault() + assertThat(initialState.room.isLoading()).isTrue() + val finalState = awaitItemAsDefault() + assertThat(finalState.room).isEqualTo(AsyncData.Success(Unit)) + } + } + + @Test + fun `present - when joinedRoom is not provided, it is retrieved on the MatrixClient - error case`() = runTest { + val matrixClient = FakeMatrixClient() + val presenter = createDefaultInvitePeoplePresenter( + joinedRoom = null, + roomId = A_ROOM_ID, + matrixClient = matrixClient, + ) + presenter.test { + val initialState = awaitItemAsDefault() + assertThat(initialState.room.isLoading()).isTrue() + val finalState = awaitItemAsDefault() + assertThat(finalState.room.errorOrNull()?.message).isEqualTo("Room not found") + } + } + + private suspend fun FakeUserRepository.emitStateWithUsers( + users: List, + isSearching: Boolean = false + ) { + emitState( + results = users.map { UserSearchResult(it) }, + isSearching = isSearching, + ) + } + + private suspend fun FakeUserRepository.emitState( + results: List, + isSearching: Boolean = false + ) { + val state = UserSearchResultState( + results = results, + isSearching = isSearching + ) + emitState(state) + } + + private fun SearchBarResultState>.users() = + (this as? SearchBarResultState.Results>)?.results.orEmpty() +} + +private suspend fun ReceiveTurbine.awaitItemAsDefault(): DefaultInvitePeopleState { + return awaitItem() as DefaultInvitePeopleState +} + +fun TestScope.createDefaultInvitePeoplePresenter( + roomMembersState: RoomMembersState = RoomMembersState.Ready(aRoomMemberList()), + inviteUserResult: (UserId) -> Result = { lambdaError() }, + joinedRoom: JoinedRoom? = FakeJoinedRoom( + inviteUserResult = inviteUserResult, + ).apply { + givenRoomMembersState(roomMembersState) + }, + roomId: RoomId = A_ROOM_ID, + userRepository: UserRepository = FakeUserRepository(), + coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), + appErrorStateService: AppErrorStateService = FakeAppErrorStateService(), + matrixClient: MatrixClient = FakeMatrixClient(), +): DefaultInvitePeoplePresenter { + return DefaultInvitePeoplePresenter( + joinedRoom = joinedRoom, + roomId = roomId, + userRepository = userRepository, + coroutineDispatchers = coroutineDispatchers, + sessionCoroutineScope = backgroundScope, + appErrorStateService = appErrorStateService, + matrixClient = matrixClient, + ) +} diff --git a/features/joinroom/api/build.gradle.kts b/features/joinroom/api/build.gradle.kts new file mode 100644 index 0000000..6ba5f9c --- /dev/null +++ b/features/joinroom/api/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.joinroom.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.features.roomdirectory.api) + implementation(projects.services.analytics.api) +} diff --git a/features/joinroom/api/src/main/kotlin/io/element/android/features/joinroom/api/JoinRoomEntryPoint.kt b/features/joinroom/api/src/main/kotlin/io/element/android/features/joinroom/api/JoinRoomEntryPoint.kt new file mode 100644 index 0000000..4d61f27 --- /dev/null +++ b/features/joinroom/api/src/main/kotlin/io/element/android/features/joinroom/api/JoinRoomEntryPoint.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.joinroom.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.features.roomdirectory.api.RoomDescription +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import java.util.Optional + +interface JoinRoomEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + inputs: Inputs, + ): Node + + data class Inputs( + val roomId: RoomId, + val roomIdOrAlias: RoomIdOrAlias, + val roomDescription: Optional, + val serverNames: List, + val trigger: JoinedRoom.Trigger, + ) : NodeInputs +} diff --git a/features/joinroom/impl/build.gradle.kts b/features/joinroom/impl/build.gradle.kts new file mode 100644 index 0000000..71acdad --- /dev/null +++ b/features/joinroom/impl/build.gradle.kts @@ -0,0 +1,48 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.joinroom.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + api(projects.features.joinroom.api) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(projects.features.invite.api) + implementation(projects.features.roomdirectory.api) + implementation(projects.services.analytics.api) + implementation(projects.libraries.preferences.api) + implementation(projects.appconfig) + + testCommonDependencies(libs, true) + testImplementation(projects.features.invite.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.previewutils) +} diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/DefaultJoinRoomEntryPoint.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/DefaultJoinRoomEntryPoint.kt new file mode 100644 index 0000000..2343860 --- /dev/null +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/DefaultJoinRoomEntryPoint.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.joinroom.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.joinroom.api.JoinRoomEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultJoinRoomEntryPoint : JoinRoomEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + inputs: JoinRoomEntryPoint.Inputs, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf(inputs) + ) + } +} diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomEvents.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomEvents.kt new file mode 100644 index 0000000..51245dd --- /dev/null +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomEvents.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.joinroom.impl + +import io.element.android.features.invite.api.InviteData + +sealed interface JoinRoomEvents { + data object RetryFetchingContent : JoinRoomEvents + data object DismissErrorAndHideContent : JoinRoomEvents + data object JoinRoom : JoinRoomEvents + data object KnockRoom : JoinRoomEvents + data object ForgetRoom : JoinRoomEvents + data class CancelKnock(val requiresConfirmation: Boolean) : JoinRoomEvents + data class UpdateKnockMessage(val message: String) : JoinRoomEvents + data object ClearActionStates : JoinRoomEvents + data class AcceptInvite(val inviteData: InviteData) : JoinRoomEvents + data class DeclineInvite(val inviteData: InviteData, val blockUser: Boolean) : JoinRoomEvents +} diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt new file mode 100644 index 0000000..93f8f53 --- /dev/null +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomFlowNode.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.joinroom.impl + +import android.os.Parcelable +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.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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.invite.api.InviteData +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView +import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint +import io.element.android.features.joinroom.api.JoinRoomEntryPoint +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +@AssistedInject +class JoinRoomFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: JoinRoomPresenter.Factory, + private val acceptDeclineInviteView: AcceptDeclineInviteView, + private val declineAndBlockEntryPoint: DeclineInviteAndBlockEntryPoint +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +) { + private val inputs: JoinRoomEntryPoint.Inputs = inputs() + private val presenter = presenterFactory.create( + inputs.roomId, + inputs.roomIdOrAlias, + inputs.roomDescription, + inputs.serverNames, + inputs.trigger, + ) + + sealed interface NavTarget : Parcelable { + @Parcelize + data object Root : NavTarget + + @Parcelize + data class DeclineInviteAndBlockUser(val inviteData: InviteData) : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.DeclineInviteAndBlockUser -> declineAndBlockEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + inviteData = navTarget.inviteData, + ) + NavTarget.Root -> rootNode(buildContext) + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView(modifier) + } + + private fun rootNode(buildContext: BuildContext): Node { + return node(buildContext) { modifier -> + val state = presenter.present() + JoinRoomView( + state = state, + onBackClick = ::navigateUp, + onJoinSuccess = {}, + onForgetSuccess = ::navigateUp, + onCancelKnockSuccess = {}, + onKnockSuccess = {}, + onDeclineInviteAndBlockUser = { + backstack.push( + NavTarget.DeclineInviteAndBlockUser(it) + ) + }, + modifier = modifier + ) + acceptDeclineInviteView.Render( + state = state.acceptDeclineInviteState, + onAcceptInviteSuccess = {}, + onDeclineInviteSuccess = {}, + modifier = Modifier + ) + } + } +} diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt new file mode 100644 index 0000000..49c5ffb --- /dev/null +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt @@ -0,0 +1,385 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.joinroom.impl + +import androidx.annotation.VisibleForTesting +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.mutableIntStateOf +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.features.invite.api.InviteData +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState +import io.element.android.features.invite.api.toInviteData +import io.element.android.features.joinroom.impl.di.CancelKnockRoom +import io.element.android.features.joinroom.impl.di.ForgetRoom +import io.element.android.features.joinroom.impl.di.KnockRoom +import io.element.android.features.roomdirectory.api.RoomDescription +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingState +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.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.RoomMembershipDetails +import io.element.android.libraries.matrix.api.room.RoomType +import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.room.join.JoinRoom +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.ui.model.toInviteSender +import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.util.Optional +import kotlin.jvm.optionals.getOrNull + +@AssistedInject +class JoinRoomPresenter( + @Assisted private val roomId: RoomId, + @Assisted private val roomIdOrAlias: RoomIdOrAlias, + @Assisted private val roomDescription: Optional, + @Assisted private val serverNames: List, + @Assisted private val trigger: JoinedRoom.Trigger, + private val matrixClient: MatrixClient, + private val joinRoom: JoinRoom, + private val knockRoom: KnockRoom, + private val cancelKnockRoom: CancelKnockRoom, + private val forgetRoom: ForgetRoom, + private val acceptDeclineInvitePresenter: Presenter, + private val buildMeta: BuildMeta, + private val seenInvitesStore: SeenInvitesStore, +) : Presenter { + fun interface Factory { + fun create( + roomId: RoomId, + roomIdOrAlias: RoomIdOrAlias, + roomDescription: Optional, + serverNames: List, + trigger: JoinedRoom.Trigger, + ): JoinRoomPresenter + } + + private val spaceList = matrixClient.spaceService.spaceRoomList(roomId) + + @Composable + override fun present(): JoinRoomState { + val coroutineScope = rememberCoroutineScope() + var retryCount by remember { mutableIntStateOf(0) } + val roomInfo by remember { + matrixClient.getRoomInfoFlow(roomId) + }.collectAsState(initial = Optional.empty()) + val spaceRoom by spaceList.currentSpaceFlow.collectAsState() + val joinAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + val knockAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + val cancelKnockAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + val forgetRoomAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + var knockMessage by rememberSaveable { mutableStateOf("") } + var isDismissingContent by remember { mutableStateOf(false) } + val hideInviteAvatars by matrixClient.rememberHideInvitesAvatar() + val canReportRoom by produceState(false) { value = matrixClient.canReportRoom() } + + var contentState by remember { + mutableStateOf(ContentState.Loading) + } + LaunchedEffect(roomInfo, retryCount, isDismissingContent, spaceRoom) { + when { + isDismissingContent -> contentState = ContentState.Dismissing + roomInfo.isPresent -> { + val notJoinedRoom = matrixClient.getRoomPreview(roomIdOrAlias, serverNames).getOrNull() + val membershipDetails = notJoinedRoom?.membershipDetails()?.getOrNull() + val joinedMembersCountOverride = notJoinedRoom?.previewInfo?.numberOfJoinedMembers + contentState = roomInfo.get().toContentState( + joinedMembersCountOverride = joinedMembersCountOverride, + membershipDetails = membershipDetails, + childrenCount = spaceRoom.getOrNull()?.childrenCount, + ) + } + spaceRoom.isPresent -> { + val spaceRoom = spaceRoom.get() + // Only use this state when space is not locally known + contentState = if (spaceRoom.state != null) { + ContentState.Loading + } else { + spaceRoom.toContentState() + } + } + roomDescription.isPresent -> { + contentState = roomDescription.get().toContentState() + } + else -> { + contentState = ContentState.Loading + val result = matrixClient.getRoomPreview(roomIdOrAlias, serverNames) + contentState = result.fold( + onSuccess = { preview -> + val membershipDetails = preview.membershipDetails().getOrNull() + preview.previewInfo.toContentState(membershipDetails) + }, + onFailure = { throwable -> + ContentState.UnknownRoom + } + ) + } + } + } + val acceptDeclineInviteState = acceptDeclineInvitePresenter.present() + + LaunchedEffect(contentState) { + contentState.markRoomInviteAsSeen() + } + + fun handleEvent(event: JoinRoomEvents) { + when (event) { + JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction) + is JoinRoomEvents.KnockRoom -> coroutineScope.knockRoom(knockAction, knockMessage) + is JoinRoomEvents.AcceptInvite -> { + acceptDeclineInviteState.eventSink( + AcceptDeclineInviteEvents.AcceptInvite(event.inviteData) + ) + } + is JoinRoomEvents.DeclineInvite -> { + acceptDeclineInviteState.eventSink( + AcceptDeclineInviteEvents.DeclineInvite(invite = event.inviteData, blockUser = event.blockUser, shouldConfirm = true) + ) + } + is JoinRoomEvents.CancelKnock -> coroutineScope.cancelKnockRoom(event.requiresConfirmation, cancelKnockAction) + JoinRoomEvents.RetryFetchingContent -> { + retryCount++ + } + JoinRoomEvents.ClearActionStates -> { + knockAction.value = AsyncAction.Uninitialized + joinAction.value = AsyncAction.Uninitialized + cancelKnockAction.value = AsyncAction.Uninitialized + forgetRoomAction.value = AsyncAction.Uninitialized + } + is JoinRoomEvents.UpdateKnockMessage -> { + knockMessage = event.message.take(MAX_KNOCK_MESSAGE_LENGTH) + } + JoinRoomEvents.DismissErrorAndHideContent -> { + isDismissingContent = true + } + JoinRoomEvents.ForgetRoom -> coroutineScope.forgetRoom(forgetRoomAction) + } + } + + return JoinRoomState( + roomIdOrAlias = roomIdOrAlias, + contentState = contentState, + acceptDeclineInviteState = acceptDeclineInviteState, + joinAction = joinAction.value, + knockAction = knockAction.value, + forgetAction = forgetRoomAction.value, + cancelKnockAction = cancelKnockAction.value, + applicationName = buildMeta.applicationName, + knockMessage = knockMessage, + hideInviteAvatars = hideInviteAvatars, + canReportRoom = canReportRoom, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.joinRoom(joinAction: MutableState>) = launch { + joinAction.runUpdatingState { + joinRoom.invoke( + roomIdOrAlias = roomIdOrAlias, + serverNames = serverNames, + trigger = trigger + ) + } + } + + private fun CoroutineScope.knockRoom(knockAction: MutableState>, message: String) = launch { + knockAction.runUpdatingState { + knockRoom(roomIdOrAlias, message, serverNames) + } + } + + private fun CoroutineScope.cancelKnockRoom(requiresConfirmation: Boolean, cancelKnockAction: MutableState>) = launch { + if (requiresConfirmation) { + cancelKnockAction.value = AsyncAction.ConfirmingNoParams + } else { + cancelKnockAction.runUpdatingState { + cancelKnockRoom(roomId) + } + } + } + + private fun CoroutineScope.forgetRoom(forgetAction: MutableState>) = launch { + forgetAction.runUpdatingState { + forgetRoom.invoke(roomId) + } + } + + private suspend fun ContentState.markRoomInviteAsSeen() { + if ((this as? ContentState.Loaded)?.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited != null) { + seenInvitesStore.markAsSeen(roomId) + } + } +} + +private fun RoomPreviewInfo.toContentState(membershipDetails: RoomMembershipDetails?): ContentState { + return ContentState.Loaded( + roomId = roomId, + name = name, + topic = topic, + alias = canonicalAlias, + numberOfMembers = numberOfJoinedMembers, + roomAvatarUrl = avatarUrl, + joinAuthorisationStatus = computeJoinAuthorisationStatus( + membership, + membershipDetails, + joinRule, + { toInviteData() } + ), + joinRule = joinRule, + details = when (roomType) { + is RoomType.Other, + RoomType.Room -> LoadedDetails.Room( + isDm = false, + ) + RoomType.Space -> LoadedDetails.Space( + childrenCount = 0, + heroes = persistentListOf(), + ) + } + ) +} + +private fun SpaceRoom.toContentState(): ContentState { + return ContentState.Loaded( + roomId = roomId, + name = displayName, + topic = topic, + alias = canonicalAlias, + numberOfMembers = numJoinedMembers.toLong(), + roomAvatarUrl = avatarUrl, + joinAuthorisationStatus = computeJoinAuthorisationStatus( + membership = state, + membershipDetails = null, + joinRule = joinRule, + inviteData = { toInviteData() } + ), + joinRule = joinRule, + details = LoadedDetails.Space( + childrenCount = childrenCount, + heroes = heroes.toImmutableList(), + ) + ) +} + +@VisibleForTesting +internal fun RoomDescription.toContentState(): ContentState { + return ContentState.Loaded( + roomId = roomId, + name = name, + topic = topic, + alias = alias, + numberOfMembers = numberOfMembers, + roomAvatarUrl = avatarUrl, + joinAuthorisationStatus = when (joinRule) { + RoomDescription.JoinRule.KNOCK -> JoinAuthorisationStatus.CanKnock + RoomDescription.JoinRule.PUBLIC -> JoinAuthorisationStatus.CanJoin + else -> JoinAuthorisationStatus.Unknown + }, + joinRule = when (joinRule) { + RoomDescription.JoinRule.KNOCK -> JoinRule.Knock + RoomDescription.JoinRule.PUBLIC -> JoinRule.Public + RoomDescription.JoinRule.RESTRICTED -> JoinRule.Restricted(persistentListOf()) + RoomDescription.JoinRule.KNOCK_RESTRICTED -> JoinRule.KnockRestricted(persistentListOf()) + RoomDescription.JoinRule.INVITE -> JoinRule.Invite + RoomDescription.JoinRule.UNKNOWN -> null + }, + details = LoadedDetails.Room(isDm = false) + ) +} + +@VisibleForTesting +internal fun RoomInfo.toContentState( + joinedMembersCountOverride: Long?, + membershipDetails: RoomMembershipDetails?, + childrenCount: Int?, +): ContentState { + return ContentState.Loaded( + roomId = id, + name = name, + topic = topic, + alias = canonicalAlias, + numberOfMembers = joinedMembersCountOverride ?: joinedMembersCount, + roomAvatarUrl = avatarUrl, + joinAuthorisationStatus = computeJoinAuthorisationStatus( + membership = currentUserMembership, + membershipDetails = membershipDetails, + joinRule = joinRule, + inviteData = { toInviteData() } + ), + joinRule = joinRule, + details = if (isSpace) { + LoadedDetails.Space( + childrenCount = childrenCount ?: 0, + heroes = heroes, + ) + } else { + LoadedDetails.Room( + isDm = isDm, + ) + }, + ) +} + +private fun computeJoinAuthorisationStatus( + membership: CurrentUserMembership?, + membershipDetails: RoomMembershipDetails?, + joinRule: JoinRule?, + inviteData: () -> InviteData, +): JoinAuthorisationStatus { + return when (membership) { + CurrentUserMembership.INVITED -> { + JoinAuthorisationStatus.IsInvited( + inviteData = inviteData(), + inviteSender = membershipDetails?.senderMember?.toInviteSender() + ) + } + CurrentUserMembership.BANNED -> JoinAuthorisationStatus.IsBanned( + membershipDetails?.senderMember?.toInviteSender(), + membershipDetails?.membershipChangeReason + ) + CurrentUserMembership.KNOCKED -> JoinAuthorisationStatus.IsKnocked + else -> joinRule.toJoinAuthorisationStatus() + } +} + +private fun JoinRule?.toJoinAuthorisationStatus(): JoinAuthorisationStatus { + return when (this) { + JoinRule.Knock, + is JoinRule.KnockRestricted -> JoinAuthorisationStatus.CanKnock + JoinRule.Invite, + JoinRule.Private -> JoinAuthorisationStatus.NeedInvite + is JoinRule.Restricted -> JoinAuthorisationStatus.Restricted + JoinRule.Public -> JoinAuthorisationStatus.CanJoin + else -> JoinAuthorisationStatus.Unknown + } +} diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt new file mode 100644 index 0000000..e109bb1 --- /dev/null +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.joinroom.impl + +import androidx.compose.runtime.Immutable +import io.element.android.features.invite.api.InviteData +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState +import io.element.android.libraries.architecture.AsyncAction +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.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.room.join.JoinRoom +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.model.InviteSender +import kotlinx.collections.immutable.ImmutableList + +internal const val MAX_KNOCK_MESSAGE_LENGTH = 500 + +data class JoinRoomState( + val roomIdOrAlias: RoomIdOrAlias, + val contentState: ContentState, + val acceptDeclineInviteState: AcceptDeclineInviteState, + val joinAction: AsyncAction, + val knockAction: AsyncAction, + val forgetAction: AsyncAction, + val cancelKnockAction: AsyncAction, + private val applicationName: String, + val knockMessage: String, + val hideInviteAvatars: Boolean, + val canReportRoom: Boolean, + val eventSink: (JoinRoomEvents) -> Unit +) { + val isJoinActionUnauthorized = joinAction is AsyncAction.Failure && joinAction.error is JoinRoom.Failures.UnauthorizedJoin + val joinAuthorisationStatus = when (contentState) { + is ContentState.Loaded -> { + when { + isJoinActionUnauthorized -> { + JoinAuthorisationStatus.Unauthorized + } + else -> { + contentState.joinAuthorisationStatus + } + } + } + is ContentState.UnknownRoom -> { + if (isJoinActionUnauthorized) { + JoinAuthorisationStatus.Unauthorized + } else { + JoinAuthorisationStatus.Unknown + } + } + else -> JoinAuthorisationStatus.None + } + + val hideAvatarsImages = hideInviteAvatars && joinAuthorisationStatus is JoinAuthorisationStatus.IsInvited +} + +@Immutable +sealed interface ContentState { + data object Dismissing : ContentState + data object Loading : ContentState + data class Failure(val error: Throwable) : ContentState + data object UnknownRoom : ContentState + data class Loaded( + val roomId: RoomId, + val name: String?, + val topic: String?, + val alias: RoomAlias?, + val numberOfMembers: Long?, + val roomAvatarUrl: String?, + val joinAuthorisationStatus: JoinAuthorisationStatus, + val joinRule: JoinRule?, + val details: LoadedDetails, + ) : ContentState { + val showMemberCount = numberOfMembers != null + val isSpace = details is LoadedDetails.Space + + fun avatarData(size: AvatarSize): AvatarData { + return AvatarData( + id = roomId.value, + name = name, + url = roomAvatarUrl, + size = size, + ) + } + } +} + +@Immutable +sealed interface LoadedDetails { + data class Room( + val isDm: Boolean, + ) : LoadedDetails + + data class Space( + val childrenCount: Int, + val heroes: ImmutableList, + ) : LoadedDetails +} + +sealed interface JoinAuthorisationStatus { + data object None : JoinAuthorisationStatus + data class IsInvited(val inviteData: InviteData, val inviteSender: InviteSender?) : JoinAuthorisationStatus + data class IsBanned(val banSender: InviteSender?, val reason: String?) : JoinAuthorisationStatus + data object IsKnocked : JoinAuthorisationStatus + data object CanKnock : JoinAuthorisationStatus + data object CanJoin : JoinAuthorisationStatus + data object NeedInvite : JoinAuthorisationStatus + data object Restricted : JoinAuthorisationStatus + data object Unknown : JoinAuthorisationStatus + data object Unauthorized : JoinAuthorisationStatus +} diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt new file mode 100644 index 0000000..7e51423 --- /dev/null +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt @@ -0,0 +1,246 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.joinroom.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.invite.api.InviteData +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState +import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState +import io.element.android.libraries.architecture.AsyncAction +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.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.exception.ClientException +import io.element.android.libraries.matrix.api.room.join.JoinRoom +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.model.InviteSender +import kotlinx.collections.immutable.toImmutableList + +open class JoinRoomStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aJoinRoomState( + contentState = ContentState.Loading + ), + aJoinRoomState( + contentState = ContentState.UnknownRoom + ), + aJoinRoomState( + contentState = aLoadedContentState( + name = null, + alias = null, + topic = null, + ) + ), + aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin) + ), + aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin), + joinAction = AsyncAction.Failure(JoinRoom.Failures.UnauthorizedJoin) + ), + aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin), + joinAction = AsyncAction.Failure(ClientException.Generic("Something went wrong", null)) + ), + aJoinRoomState( + contentState = aLoadedContentState( + joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited( + inviteData = anInviteData(), + inviteSender = null, + ) + ) + ), + aJoinRoomState( + contentState = aLoadedContentState( + numberOfMembers = 123, + joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited( + inviteData = anInviteData(), + inviteSender = anInviteSender(), + ), + ) + ), + aJoinRoomState( + contentState = aFailureContentState() + ), + aJoinRoomState( + contentState = aLoadedContentState( + roomId = RoomId("!aSpaceId:domain"), + name = "A space", + alias = null, + topic = "This is the topic of a space", + details = aLoadedDetailsSpace( + childrenCount = 42, + ), + ) + ), + aJoinRoomState( + contentState = aLoadedContentState( + name = "A DM", + details = aLoadedDetailsRoom( + isDm = true, + ), + ) + ), + aJoinRoomState( + contentState = aLoadedContentState( + joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock, + topic = "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 commodo consequat duis aute irure dolor in reprehenderit in" + + " voluptate velit esse cillum dolore eu fugiat nulla pariatur excepteur sint occaecat cupidatat" + + " non proident sunt in culpa qui officia deserunt mollit anim id est laborum", + numberOfMembers = 888, + ) + ), + aJoinRoomState( + knockMessage = "Let me in please!", + contentState = aLoadedContentState( + joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock, + topic = "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 commodo consequat duis aute irure dolor in reprehenderit in" + + " voluptate velit esse cillum dolore eu fugiat nulla pariatur excepteur sint occaecat cupidatat" + + " non proident sunt in culpa qui officia deserunt mollit anim id est laborum", + numberOfMembers = 888, + ) + ), + aJoinRoomState( + contentState = aLoadedContentState( + name = "A knocked Room", + joinAuthorisationStatus = JoinAuthorisationStatus.IsKnocked + ) + ), + aJoinRoomState( + contentState = aLoadedContentState( + name = "A private room", + joinAuthorisationStatus = JoinAuthorisationStatus.NeedInvite + ) + ), + aJoinRoomState( + contentState = aLoadedContentState( + name = "A banned room", + joinAuthorisationStatus = JoinAuthorisationStatus.IsBanned( + banSender = InviteSender( + userId = UserId("@alice:domain"), + displayName = "Alice", + avatarData = AvatarData("alice", "Alice", size = AvatarSize.InviteSender), + membershipChangeReason = "spamming" + ), + reason = "spamming", + ), + ) + ), + aJoinRoomState( + contentState = aLoadedContentState( + name = "A restricted room", + joinAuthorisationStatus = JoinAuthorisationStatus.Restricted, + ) + ), + ) +} + +fun aFailureContentState(): ContentState { + return ContentState.Failure( + error = Exception("Error"), + ) +} + +fun aLoadedContentState( + roomId: RoomId = A_ROOM_ID, + name: String? = "Element X android", + alias: RoomAlias? = RoomAlias("#exa:matrix.org"), + topic: String? = "Element X is a secure, private and decentralized messenger.", + numberOfMembers: Long? = null, + roomAvatarUrl: String? = null, + joinAuthorisationStatus: JoinAuthorisationStatus = JoinAuthorisationStatus.Unknown, + joinRule: JoinRule? = null, + details: LoadedDetails = aLoadedDetailsRoom(isDm = false), +) = ContentState.Loaded( + roomId = roomId, + name = name, + alias = alias, + topic = topic, + numberOfMembers = numberOfMembers, + roomAvatarUrl = roomAvatarUrl, + joinAuthorisationStatus = joinAuthorisationStatus, + joinRule = joinRule, + details = details, +) + +fun aLoadedDetailsRoom( + isDm: Boolean = false, +) = LoadedDetails.Room( + isDm = isDm +) + +fun aLoadedDetailsSpace( + childrenCount: Int = 0, + heroes: List = emptyList(), +) = LoadedDetails.Space( + childrenCount = childrenCount, + heroes = heroes.toImmutableList() +) + +fun aJoinRoomState( + roomIdOrAlias: RoomIdOrAlias = A_ROOM_ALIAS.toRoomIdOrAlias(), + contentState: ContentState = aLoadedContentState(), + acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(), + joinAction: AsyncAction = AsyncAction.Uninitialized, + knockAction: AsyncAction = AsyncAction.Uninitialized, + forgetAction: AsyncAction = AsyncAction.Uninitialized, + cancelKnockAction: AsyncAction = AsyncAction.Uninitialized, + knockMessage: String = "", + hideInviteAvatars: Boolean = false, + canReportRoom: Boolean = true, + eventSink: (JoinRoomEvents) -> Unit = {} +) = JoinRoomState( + roomIdOrAlias = roomIdOrAlias, + contentState = contentState, + acceptDeclineInviteState = acceptDeclineInviteState, + joinAction = joinAction, + knockAction = knockAction, + cancelKnockAction = cancelKnockAction, + forgetAction = forgetAction, + applicationName = "AppName", + knockMessage = knockMessage, + hideInviteAvatars = hideInviteAvatars, + canReportRoom = canReportRoom, + eventSink = eventSink +) + +internal fun anInviteSender( + userId: UserId = UserId("@bob:domain"), + displayName: String = "Bob", + avatarData: AvatarData = AvatarData(userId.value, displayName, size = AvatarSize.InviteSender), + membershipChangeReason: String? = null, +) = InviteSender( + userId = userId, + displayName = displayName, + avatarData = avatarData, + membershipChangeReason = membershipChangeReason, +) + +internal fun anInviteData( + roomId: RoomId = A_ROOM_ID, + roomName: String = "Room name", + isDm: Boolean = false, +) = InviteData( + roomId = roomId, + roomName = roomName, + isDm = isDm, +) + +private val A_ROOM_ID = RoomId("!exa:matrix.org") +private val A_ROOM_ALIAS = RoomAlias("#exa:matrix.org") diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt new file mode 100644 index 0000000..8992b92 --- /dev/null +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt @@ -0,0 +1,658 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.joinroom.impl + +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.PaddingValues +import androidx.compose.foundation.layout.Row +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +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.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.invite.api.InviteData +import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom +import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom +import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewSubtitleAtom +import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom +import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule +import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.atomic.molecules.MembersCountMolecule +import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism +import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage +import io.element.android.libraries.designsystem.components.Announcement +import io.element.android.libraries.designsystem.components.AnnouncementType +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.button.SuperButton +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.components.dialogs.RetryDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ButtonSize +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.IconSource +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +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.TextField +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.theme.placeholderBackground +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility +import io.element.android.libraries.matrix.ui.components.SpaceInfoRow +import io.element.android.libraries.matrix.ui.components.SpaceMembersView +import io.element.android.libraries.matrix.ui.model.InviteSender +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun JoinRoomView( + state: JoinRoomState, + onBackClick: () -> Unit, + onJoinSuccess: () -> Unit, + onKnockSuccess: () -> Unit, + onForgetSuccess: () -> Unit, + onCancelKnockSuccess: () -> Unit, + onDeclineInviteAndBlockUser: (InviteData) -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + ) { + HeaderFooterPage( + containerColor = Color.Transparent, + contentPadding = PaddingValues( + horizontal = 16.dp, + vertical = 24.dp + ), + topBar = { + JoinRoomTopBar( + contentState = state.contentState, + hideAvatarImage = state.hideAvatarsImages, + onBackClick = onBackClick, + ) + }, + content = { + JoinRoomContent( + roomIdOrAlias = state.roomIdOrAlias, + contentState = state.contentState, + knockMessage = state.knockMessage, + hideAvatarsImages = state.hideAvatarsImages, + onKnockMessageUpdate = { state.eventSink(JoinRoomEvents.UpdateKnockMessage(it)) }, + ) + }, + footer = { + JoinRoomFooter( + joinAuthorisationStatus = state.joinAuthorisationStatus, + onAcceptInvite = { inviteData -> + state.eventSink(JoinRoomEvents.AcceptInvite(inviteData)) + }, + onDeclineInvite = { inviteData, blockUser -> + if (state.canReportRoom && blockUser) { + onDeclineInviteAndBlockUser(inviteData) + } else { + state.eventSink(JoinRoomEvents.DeclineInvite(inviteData, blockUser = blockUser)) + } + }, + onJoinRoom = { + state.eventSink(JoinRoomEvents.JoinRoom) + }, + onKnockRoom = { + state.eventSink(JoinRoomEvents.KnockRoom) + }, + onCancelKnock = { + state.eventSink(JoinRoomEvents.CancelKnock(requiresConfirmation = true)) + }, + onForgetRoom = { + state.eventSink(JoinRoomEvents.ForgetRoom) + }, + onGoBack = onBackClick, + ) + } + ) + } + if (state.contentState is ContentState.Failure) { + RetryDialog( + title = stringResource(R.string.screen_join_room_loading_alert_title), + content = stringResource(CommonStrings.error_network_or_server_issue), + onRetry = { state.eventSink(JoinRoomEvents.RetryFetchingContent) }, + onDismiss = { + state.eventSink(JoinRoomEvents.DismissErrorAndHideContent) + onBackClick() + } + ) + } + // This particular error is shown directly in the footer + if (!state.isJoinActionUnauthorized) { + AsyncActionView( + async = state.joinAction, + errorTitle = { stringResource(CommonStrings.common_something_went_wrong) }, + errorMessage = { stringResource(CommonStrings.error_network_or_server_issue) }, + onSuccess = { onJoinSuccess() }, + onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearActionStates) }, + ) + } + AsyncActionView( + async = state.knockAction, + errorTitle = { stringResource(CommonStrings.common_something_went_wrong) }, + errorMessage = { stringResource(CommonStrings.error_network_or_server_issue) }, + onSuccess = { onKnockSuccess() }, + onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearActionStates) }, + ) + AsyncActionView( + async = state.forgetAction, + errorTitle = { stringResource(CommonStrings.common_something_went_wrong) }, + errorMessage = { stringResource(CommonStrings.error_network_or_server_issue) }, + onSuccess = { onForgetSuccess() }, + onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearActionStates) }, + ) + AsyncActionView( + async = state.cancelKnockAction, + onSuccess = { onCancelKnockSuccess() }, + onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearActionStates) }, + errorTitle = { stringResource(CommonStrings.common_something_went_wrong) }, + errorMessage = { stringResource(CommonStrings.error_network_or_server_issue) }, + confirmationDialog = { + ConfirmationDialog( + content = stringResource(R.string.screen_join_room_cancel_knock_alert_description), + title = stringResource(R.string.screen_join_room_cancel_knock_alert_title), + submitText = stringResource(R.string.screen_join_room_cancel_knock_alert_confirmation), + cancelText = stringResource(CommonStrings.action_no), + onSubmitClick = { state.eventSink(JoinRoomEvents.CancelKnock(requiresConfirmation = false)) }, + onDismiss = { state.eventSink(JoinRoomEvents.ClearActionStates) }, + ) + }, + ) +} + +@Composable +private fun JoinRoomFooter( + joinAuthorisationStatus: JoinAuthorisationStatus, + onAcceptInvite: (InviteData) -> Unit, + onDeclineInvite: (InviteData, Boolean) -> Unit, + onJoinRoom: () -> Unit, + onKnockRoom: () -> Unit, + onCancelKnock: () -> Unit, + onForgetRoom: () -> Unit, + onGoBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) { + when (joinAuthorisationStatus) { + is JoinAuthorisationStatus.IsInvited -> { + Column { + ButtonRowMolecule(horizontalArrangement = Arrangement.spacedBy(20.dp)) { + OutlinedButton( + text = stringResource(CommonStrings.action_decline), + onClick = { onDeclineInvite(joinAuthorisationStatus.inviteData, false) }, + modifier = Modifier.weight(1f), + size = ButtonSize.LargeLowPadding, + leadingIcon = IconSource.Vector(CompoundIcons.Close()) + ) + Button( + text = stringResource(CommonStrings.action_accept), + onClick = { onAcceptInvite(joinAuthorisationStatus.inviteData) }, + modifier = Modifier.weight(1f), + size = ButtonSize.LargeLowPadding, + leadingIcon = IconSource.Vector(CompoundIcons.Check()) + ) + } + Spacer(modifier = Modifier.height(24.dp)) + TextButton( + text = stringResource(R.string.screen_join_room_decline_and_block_button_title), + onClick = { onDeclineInvite(joinAuthorisationStatus.inviteData, true) }, + modifier = Modifier.fillMaxWidth(), + destructive = true + ) + } + } + JoinAuthorisationStatus.CanJoin -> { + SuperButton( + onClick = onJoinRoom, + modifier = Modifier.fillMaxWidth(), + buttonSize = ButtonSize.Large, + ) { + Text( + text = stringResource(R.string.screen_join_room_join_action), + ) + } + } + JoinAuthorisationStatus.CanKnock -> { + SuperButton( + onClick = onKnockRoom, + modifier = Modifier.fillMaxWidth(), + buttonSize = ButtonSize.Large, + ) { + Text( + text = stringResource(R.string.screen_join_room_knock_action), + ) + } + } + JoinAuthorisationStatus.IsKnocked -> { + OutlinedButton( + text = stringResource(R.string.screen_join_room_cancel_knock_action), + onClick = onCancelKnock, + modifier = Modifier.fillMaxWidth(), + size = ButtonSize.Large, + ) + } + JoinAuthorisationStatus.NeedInvite -> { + Announcement( + title = stringResource(R.string.screen_join_room_invite_required_message), + description = null, + type = AnnouncementType.Informative(isCritical = false), + ) + } + is JoinAuthorisationStatus.IsBanned -> JoinBannedFooter(joinAuthorisationStatus, onForgetRoom) + JoinAuthorisationStatus.Unknown -> JoinRestrictedFooter(onJoinRoom) + JoinAuthorisationStatus.Restricted -> JoinRestrictedFooter(onJoinRoom) + JoinAuthorisationStatus.Unauthorized -> JoinUnauthorizedFooter(onGoBack) + JoinAuthorisationStatus.None -> Unit + } + } +} + +@Composable +private fun JoinUnauthorizedFooter( + onOkClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Announcement( + title = stringResource(R.string.screen_join_room_fail_message), + description = stringResource(R.string.screen_join_room_fail_reason), + type = AnnouncementType.Informative(isCritical = true), + ) + Spacer(Modifier.height(24.dp)) + Button( + text = stringResource(CommonStrings.action_ok), + onClick = onOkClick, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun JoinBannedFooter( + status: JoinAuthorisationStatus.IsBanned, + onForgetRoom: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + val banReason = status.reason?.let { + stringResource(R.string.screen_join_room_ban_reason, it.removeSuffix(".")) + } + val title = if (status.banSender != null) { + stringResource(R.string.screen_join_room_ban_by_message, status.banSender.displayName) + } else { + stringResource(R.string.screen_join_room_ban_message) + } + Announcement( + title = title, + description = banReason, + type = AnnouncementType.Informative(isCritical = true), + ) + Spacer(Modifier.height(24.dp)) + Button( + text = stringResource(R.string.screen_join_room_forget_action), + onClick = onForgetRoom, + modifier = Modifier.fillMaxWidth(), + size = ButtonSize.Large, + ) + } +} + +@Composable +private fun JoinRestrictedFooter( + onJoinRoom: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Announcement( + title = stringResource(R.string.screen_join_room_join_restricted_message), + description = null, + type = AnnouncementType.Informative(), + ) + Spacer(Modifier.height(24.dp)) + SuperButton( + onClick = onJoinRoom, + modifier = Modifier.fillMaxWidth(), + buttonSize = ButtonSize.Large, + ) { + Text( + text = stringResource(R.string.screen_join_room_join_action), + ) + } + } +} + +@Composable +private fun JoinRoomContent( + roomIdOrAlias: RoomIdOrAlias, + contentState: ContentState, + knockMessage: String, + hideAvatarsImages: Boolean, + onKnockMessageUpdate: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + when (contentState) { + is ContentState.Loaded -> { + when (contentState.joinAuthorisationStatus) { + is JoinAuthorisationStatus.IsKnocked -> { + IsKnockedLoadedContent() + } + else -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + DefaultLoadedContent( + contentState = contentState, + hideAvatarImage = hideAvatarsImages, + ) + when (contentState.joinAuthorisationStatus) { + is JoinAuthorisationStatus.IsInvited -> { + val inviteSender = contentState.joinAuthorisationStatus.inviteSender + if (inviteSender != null) { + Spacer(Modifier.height(16.dp)) + InvitedByView(inviteSender, hideAvatarsImages) + } + } + is JoinAuthorisationStatus.CanKnock -> { + Spacer(modifier = Modifier.height(24.dp)) + val supportingText = if (knockMessage.isNotEmpty()) { + "${knockMessage.length}/$MAX_KNOCK_MESSAGE_LENGTH" + } else { + stringResource(R.string.screen_join_room_knock_message_description) + } + TextField( + value = knockMessage, + onValueChange = onKnockMessageUpdate, + maxLines = 3, + minLines = 3, + modifier = Modifier.fillMaxWidth(), + supportingText = supportingText + ) + } + else -> Unit + } + } + } + } + } + is ContentState.UnknownRoom -> UnknownRoomContent() + is ContentState.Loading -> IncompleteContent(roomIdOrAlias, isLoading = true) + is ContentState.Dismissing -> IncompleteContent(roomIdOrAlias, isLoading = false) + is ContentState.Failure -> IncompleteContent(roomIdOrAlias, isLoading = false) + } + } +} + +@Composable +private fun InvitedByView( + sender: InviteSender, + hideAvatarImage: Boolean, + modifier: Modifier = Modifier +) { + Column( + modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.screen_join_room_invited_by), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary + ) + Spacer(Modifier.height(8.dp)) + Avatar( + avatarData = sender.avatarData, + avatarType = AvatarType.User, + hideImage = hideAvatarImage, + forcedAvatarSize = AvatarSize.RoomPreviewInviter.dp + ) + Spacer(Modifier.height(8.dp)) + Text( + text = sender.displayName, + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textPrimary + ) + Spacer(Modifier.height(4.dp)) + Text( + text = sender.userId.value, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary + ) + } +} + +@Composable +private fun UnknownRoomContent( + modifier: Modifier = Modifier +) { + RoomPreviewOrganism( + modifier = modifier, + avatar = { + Box( + modifier = Modifier + .size(AvatarSize.RoomPreviewHeader.dp) + .background( + color = ElementTheme.colors.placeholderBackground, + shape = CircleShape + ) + ) { + Icon( + modifier = Modifier.align(Alignment.Center), + tint = ElementTheme.colors.iconPrimary, + imageVector = CompoundIcons.VisibilityOff(), + contentDescription = null, + ) + } + }, + title = { + RoomPreviewTitleAtom(stringResource(R.string.screen_join_room_title_no_preview)) + }, + subtitle = { + }, + ) +} + +@Composable +private fun IncompleteContent( + roomIdOrAlias: RoomIdOrAlias, + isLoading: Boolean, + modifier: Modifier = Modifier +) { + RoomPreviewOrganism( + modifier = modifier, + avatar = { + PlaceholderAtom(width = AvatarSize.RoomPreviewHeader.dp, height = AvatarSize.RoomPreviewHeader.dp) + }, + title = { + when (roomIdOrAlias) { + is RoomIdOrAlias.Alias -> { + RoomPreviewSubtitleAtom(roomIdOrAlias.identifier) + } + is RoomIdOrAlias.Id -> { + PlaceholderAtom(width = 200.dp, height = 22.dp) + } + } + }, + subtitle = { + if (isLoading) { + Spacer(Modifier.height(8.dp)) + CircularProgressIndicator() + } + }, + ) +} + +@Composable +private fun IsKnockedLoadedContent(modifier: Modifier = Modifier) { + IconTitleSubtitleMolecule( + modifier = modifier.padding(horizontal = 8.dp), + iconStyle = BigIcon.Style.SuccessSolid, + title = stringResource(R.string.screen_join_room_knock_sent_title), + subTitle = stringResource(R.string.screen_join_room_knock_sent_description), + ) +} + +@Composable +private fun DefaultLoadedContent( + contentState: ContentState.Loaded, + hideAvatarImage: Boolean, + modifier: Modifier = Modifier, +) { + RoomPreviewOrganism( + modifier = modifier, + avatar = { + Avatar( + contentState.avatarData(AvatarSize.RoomPreviewHeader), + hideImage = hideAvatarImage, + avatarType = if (contentState.isSpace) AvatarType.Space() else AvatarType.Room(), + ) + }, + title = { + if (contentState.name != null) { + RoomPreviewTitleAtom(title = contentState.name) + } else { + RoomPreviewTitleAtom( + title = stringResource(id = CommonStrings.common_no_room_name), + fontStyle = FontStyle.Italic + ) + } + }, + subtitle = { + when { + contentState.details is LoadedDetails.Space -> { + SpaceInfoRow(visibility = SpaceRoomVisibility.fromJoinRule(contentState.joinRule)) + } + contentState.alias != null -> { + RoomPreviewSubtitleAtom(contentState.alias.value) + } + } + }, + description = { + RoomPreviewDescriptionAtom( + contentState.topic ?: "", + maxLines = if (contentState.joinAuthorisationStatus is JoinAuthorisationStatus.CanJoin) Int.MAX_VALUE else 2 + ) + }, + memberCount = { + if (contentState.showMemberCount) { + val membersCount = contentState.numberOfMembers?.toInt() ?: 0 + if (contentState.isSpace) { + SpaceMembersView(persistentListOf(), membersCount) + } else { + MembersCountMolecule(memberCount = membersCount) + } + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun JoinRoomTopBar( + contentState: ContentState, + hideAvatarImage: Boolean, + onBackClick: () -> Unit, +) { + TopAppBar( + navigationIcon = { + BackButton(onClick = onBackClick) + }, + title = { + if (contentState is ContentState.Loaded && contentState.joinAuthorisationStatus is JoinAuthorisationStatus.IsKnocked) { + val roundedCornerShape = RoundedCornerShape(8.dp) + val titleModifier = Modifier + .clip(roundedCornerShape) + if (contentState.name != null) { + Row( + modifier = titleModifier, + verticalAlignment = Alignment.CenterVertically + ) { + Avatar( + avatarData = contentState.avatarData(AvatarSize.TimelineRoom), + hideImage = hideAvatarImage, + avatarType = AvatarType.Room(), + ) + Text( + modifier = Modifier + .padding(horizontal = 8.dp) + .semantics { + heading() + }, + text = contentState.name, + style = ElementTheme.typography.fontBodyLgMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } else { + IconTitlePlaceholdersRowMolecule( + iconSize = AvatarSize.TimelineRoom.dp, + modifier = titleModifier + ) + } + } + }, + ) +} + +@PreviewsDayNight +@Composable +internal fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStateProvider::class) state: JoinRoomState) = ElementPreview { + JoinRoomView( + state = state, + onBackClick = { }, + onJoinSuccess = { }, + onKnockSuccess = { }, + onForgetSuccess = { }, + onCancelKnockSuccess = { }, + onDeclineInviteAndBlockUser = { }, + ) +} diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/CancelKnockRoom.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/CancelKnockRoom.kt new file mode 100644 index 0000000..11614dc --- /dev/null +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/CancelKnockRoom.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.joinroom.impl.di + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId + +interface CancelKnockRoom { + suspend operator fun invoke(roomId: RoomId): Result +} + +@ContributesBinding(SessionScope::class) +class DefaultCancelKnockRoom(private val client: MatrixClient) : CancelKnockRoom { + override suspend fun invoke(roomId: RoomId): Result { + return client + .getRoom(roomId) + ?.use { it.leave() } + ?: Result.failure(IllegalStateException("No pending room found")) + } +} diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/ForgetRoom.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/ForgetRoom.kt new file mode 100644 index 0000000..c93ea68 --- /dev/null +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/ForgetRoom.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.joinroom.impl.di + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId + +interface ForgetRoom { + suspend operator fun invoke(roomId: RoomId): Result +} + +@ContributesBinding(SessionScope::class) +class DefaultForgetRoom(private val client: MatrixClient) : ForgetRoom { + override suspend fun invoke(roomId: RoomId): Result { + return client.getRoom(roomId)?.use { it.forget() } + ?: Result.failure(IllegalStateException("Room not found")) + } +} diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt new file mode 100644 index 0000000..3304169 --- /dev/null +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.joinroom.impl.di + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState +import io.element.android.features.joinroom.impl.JoinRoomPresenter +import io.element.android.features.roomdirectory.api.RoomDescription +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta +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.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.room.join.JoinRoom +import java.util.Optional + +@BindingContainer +@ContributesTo(SessionScope::class) +object JoinRoomModule { + @Provides + fun providesJoinRoomPresenterFactory( + client: MatrixClient, + joinRoom: JoinRoom, + knockRoom: KnockRoom, + cancelKnockRoom: CancelKnockRoom, + forgetRoom: ForgetRoom, + acceptDeclineInvitePresenter: Presenter, + buildMeta: BuildMeta, + seenInvitesStore: SeenInvitesStore, + ): JoinRoomPresenter.Factory { + return object : JoinRoomPresenter.Factory { + override fun create( + roomId: RoomId, + roomIdOrAlias: RoomIdOrAlias, + roomDescription: Optional, + serverNames: List, + trigger: JoinedRoom.Trigger, + ): JoinRoomPresenter { + return JoinRoomPresenter( + roomId = roomId, + roomIdOrAlias = roomIdOrAlias, + roomDescription = roomDescription, + serverNames = serverNames, + trigger = trigger, + matrixClient = client, + joinRoom = joinRoom, + knockRoom = knockRoom, + forgetRoom = forgetRoom, + cancelKnockRoom = cancelKnockRoom, + acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, + buildMeta = buildMeta, + seenInvitesStore = seenInvitesStore, + ) + } + } + } +} diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/KnockRoom.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/KnockRoom.kt new file mode 100644 index 0000000..1e34fdd --- /dev/null +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/KnockRoom.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.joinroom.impl.di + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias + +interface KnockRoom { + suspend operator fun invoke( + roomIdOrAlias: RoomIdOrAlias, + message: String, + serverNames: List, + ): Result +} + +@ContributesBinding(SessionScope::class) +class DefaultKnockRoom(private val client: MatrixClient) : KnockRoom { + override suspend fun invoke( + roomIdOrAlias: RoomIdOrAlias, + message: String, + serverNames: List + ): Result { + return client + .knockRoom(roomIdOrAlias, message, serverNames) + .map { } + } +} diff --git a/features/joinroom/impl/src/main/res/values-be/translations.xml b/features/joinroom/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..1986a76 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,11 @@ + + + "Далучыцца" + "Націсніце, каб далучыцца" + "%1$s пакуль не падтрымлівае прасторы. Вы можаце атрымаць доступ да прастор праз вэб-старонку." + "Прасторы пакуль не падтрымліваюцца" + "Націсніце кнопку ніжэй, і адміністратар пакоя атрымае апавяшчэнне. Вы зможаце далучыцца да размовы пасля зацвярджэння." + "Вы павінны быць удзельнікам гэтага пакоя каб прагледзець гісторыю паведамленняў." + "Вы хочаце далучыцца да гэтага пакоя?" + "Перадпрагляд недаступны" + diff --git a/features/joinroom/impl/src/main/res/values-bg/translations.xml b/features/joinroom/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..b395310 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,5 @@ + + + "Отхвърляне и блокиране" + "Присъединяване" + diff --git a/features/joinroom/impl/src/main/res/values-cs/translations.xml b/features/joinroom/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..8540333 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,34 @@ + + + "Byli jste vykázáni uživatelem %1$s." + "Byl vám zakázán vstup" + "Důvod: %1$s." + "Zrušit žádost" + "Ano, zrušit" + "Opravdu chcete zrušit svou žádost o vstup do této místnosti?" + "Zrušit žádost o vstup" + "Ano, odmítnout a zablokovat" + "Opravdu chcete odmítnout pozvánku do této místnosti? Tím také zabráníte tomu, aby vás %1$s kontaktoval(a) nebo pozval(a) do místností." + "Odmítnout pozvání a zablokovat" + "Odmítnout a zablokovat" + "Vstup se nezdařil" + "Buď musíte být pozváni ke vstupu, nebo mohou existovat omezení přístupu." + "Zapomenout" + "Pro vstup potřebujete pozvánku" + "Pozván(a)" + "Vstoupit" + "Abyste se mohli připojit, musíte být pozváni nebo být členem některého prostoru." + "Zaklepejte a připojte se" + "Povolené znaky %1$d z %2$d" + "Zpráva (nepovinné)" + "Pokud bude váš požadavek přijat, obdržíte pozvánku na vstup do místnosti." + "Žádost o vstup odeslána" + "Náhled místnosti se nám nepodařilo zobrazit. To může být způsobeno problémy se sítí nebo serverem." + "Náhled této místnosti jsme nemohli zobrazit" + "%1$s zatím nepodporuje prostory. Prostory můžete používat na webu." + "Prostory zatím nejsou podporovány" + "Klikněte na tlačítko níže a správce místnosti bude informován. Po schválení se budete moci připojit ke konverzaci." + "Pro zobrazení historie zpráv musíte být členem této místnosti." + "Chcete se připojit k této místnosti?" + "Náhled není k dispozici" + diff --git a/features/joinroom/impl/src/main/res/values-cy/translations.xml b/features/joinroom/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..8b5e3c2 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,34 @@ + + + "Cawsoch eich gwahardd o\'r ystafell hon gan %1$s." + "Cawsoch eich gwahardd o\'r ystafell hon" + "Rheswm: %1$s." + "Diddymu cais" + "Iawn, diddymu" + "Ydych chi\'n siŵr eich bod am ddiddymu\'ch cais i ymuno â\'r ystafell hon?" + "Diddymu cais i ymuno" + "Iawn, gwrthod a rhwystro" + "Ydych chi\'n siŵr eich bod am wrthod y gwahoddiad i ymuno â\'r ystafell hon? Bydd hyn hefyd yn atal %1$s rhag cysylltu â chi neu eich gwahodd i ystafelloedd." + "Gwrthod gwahoddiad a rhwystro" + "Gwrthod a rhwystro" + "Methodd yr ymuno â\'r ystafell." + "Mae\'r ystafell hon naill ai drwy wahoddiad yn unig neu efallai y bydd cyfyngiadau ar fynediad ar lefel y gofod." + "Anghofiwch yr ystafell hon" + "Mae angen gwahoddiad arnoch chi er mwyn ymuno â\'r ystafell hon" + "Gwahoddwyd gan" + "Ymuno" + "Efallai y bydd angen i chi gael eich gwahodd neu fod yn aelod o ofod er mwyn ymuno." + "Anfon cais i ymuno" + "Nodau a ganiateir %1$d o %2$d" + "Neges (dewisol)" + "Byddwch yn derbyn gwahoddiad i ymuno â\'r ystafell os caiff eich cais ei dderbyn." + "Anfonwyd y cais i ymuno" + "Doedd dim modd dangos rhagolwg yr ystafell. Gall hyn fod oherwydd problemau rhwydwaith neu weinydd." + "Doedd dim modd dangos rhagolwg yr ystafell hon" + "Nid yw %1$s yn cefnogi gofodau eto. Gallwch gael mynediad i ofodau ar y we." + "Nid yw gofodau\'n cael eu cefnogi eto" + "Cliciwch ar y botwm isod a bydd gweinyddwr ystafell yn cael ei hysbysu. Byddwch yn gallu ymuno â\'r sgwrs ar ôl ei chymeradwyo." + "Rhaid i chi fod yn aelod o\'r ystafell hon i weld hanes y neges." + "Eisiau ymuno â\'r ystafell hon?" + "Nid yw rhagolwg ar gael" + diff --git a/features/joinroom/impl/src/main/res/values-da/translations.xml b/features/joinroom/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..12613b5 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,34 @@ + + + "Du blev spærret af %1$s." + "Du blev spærret" + "Årsag: %1$s." + "Annuller anmodning" + "Ja, annullér" + "Er du sikker på, at du vil annullere din anmodning om at deltage i dette rum?" + "Annullér anmodning om at deltage" + "Ja, afvis og blokér" + "Er du sikker på, at du vil afvise invitationen til at deltage i dette rum? Dette forhindrer også %1$s i at kontakte dig eller invitere dig til andre rum." + "Afvis invitation og blokér" + "Afvis og blokér" + "Deltagelse fejlede." + "Du skal enten inviteres til at deltage, eller der kan være adgangsbegrænsninger." + "Glem" + "Du har brug for en invitation for at deltage" + "Inviteret af" + "Deltag" + "Du skal muligvis være inviteret eller være medlem af en gruppe for at deltage." + "Send anmodning om at deltage" + "Tilladte tegn %1$d af %2$d" + "Besked (valgfrit)" + "Du vil modtage en invitation til at deltage i rummet, hvis din anmodning accepteres." + "Anmodning om at deltage sendt" + "Vi kunne ikke forhåndsvise rummet. Dette kan skyldes netværks- eller serverproblemer." + "Vi kunne ikke forhåndsvise rummet" + "%1$s understøtter ikke grupper endnu. Du kan få adgang til grupper på nettet." + "Grupper er ikke understøttet endnu" + "Klik på knappen nedenfor, og en rumadministrator vil blive underrettet. Du kan deltage i samtalen, når din anmodning er godkendt." + "Du skal være medlem af dette rum for at kunne se meddelelseshistorikken." + "Vil du deltage i dette rum?" + "Forhåndsvisning er ikke tilgængelig" + diff --git a/features/joinroom/impl/src/main/res/values-de/translations.xml b/features/joinroom/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..c6d97d6 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,34 @@ + + + "Du wurdest von %1$s gesperrt." + "Du wurdest gesperrt" + "Grund:%1$s." + "Anfrage abbrechen" + "Ja, abbrechen" + "Willst du wirklich deine Anfrage zum Beitritt zu diesem Chat abbrechen?" + "Beitrittsanfrage abbrechen" + "Ja, ablehnen & blockieren" + "Bist du sicher, dass du die Einladung zu diesem Chat ablehnen möchtest? Dadurch wird auch jede weitere Kontaktaufnahme oder Chat Einladung von %1$s blockiert." + "Einladung ablehnen & Nutzer blockieren" + "Ablehnen und blockieren" + "Beitritt fehlgeschlagen" + "Du musst entweder eingeladen werden, um beizutreten, oder es gibt möglicherweise Zugriffsbeschränkungen." + "Vergessen" + "Du benötigst eine Einladung, um beizutreten" + "Eingeladen von" + "Beitreten" + "Möglicherweise musst du eingeladen werden oder ein Mitglied eines Spaces sein, um beitreten zu können." + "Anklopfen" + "%1$d von %2$d erlaubte Zeichen" + "Nachricht (optional)" + "Sollte deine Anfrage akzeptiert werden, erhältst du eine Einladung, dem Chat beizutreten." + "Beitrittsanfrage geschickt" + "Wir konnten die Chat Vorschau nicht anzeigen. Dies kann an Netzwerk- oder Serverproblemen liegen." + "Wir konnten diese Chat-Vorschau nicht anzeigen" + "%1$s unterstützt noch keine Spaces. Du kannst auf Spaces im Web zugreifen." + "Spaces werden noch nicht unterstützt" + "Klopfe an um einen Admin zu benachrichtigen. Nach der Freigabe kannst du dich an der Unterhaltung beteiligen." + "Du musst Mitglied in diesem Chat sein, um den Nachrichtenverlauf zu sehen." + "Willst du diesem Chat beitreten?" + "Vorschau nicht verfügbar" + diff --git a/features/joinroom/impl/src/main/res/values-el/translations.xml b/features/joinroom/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..b12bbb9 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,32 @@ + + + "Αποκλειστήκατε από αυτή την αίθουσα από το χρήστη %1$s." + "Σας απαγορεύτηκε η είσοδος σε αυτή την αίθουσα" + "Αιτία: %1$s." + "Ακύρωση αιτήματος" + "Ναι, ακύρωση" + "Είστε βέβαιοι ότι θέλετε να ακυρώσετε το αίτημά σας να συμμετάσχετε σε αυτή την αίθουσα;" + "Ακύρωση αίτησης συμμετοχής" + "Ναι, απόρριψη και αποκλεισμός" + "Είστε βέβαιοι ότι θέλετε να απορρίψετε την πρόσκληση συμμετοχής σε αυτήν την αίθουσα; Αυτό θα εμποδίσει επίσης τον χρήστη %1$s να επικοινωνήσει μαζί σας ή να σας προσκαλέσει σε αίθουσες." + "Απόρριψη πρόσκλησης και αποκλεισμός" + "Απόρριψη και αποκλεισμός" + "Η συμμετοχή στην αίθουσα απέτυχε." + "Αυτή η αίθουσα είναι είτε μόνο για προσκεκλημένους είτε ενδέχεται να υπάρχουν περιορισμοί πρόσβασης σε επίπεδο χώρου." + "Ξεχάστε αυτή την αίθουσα" + "Χρειάζεστε πρόσκληση για να συμμετάσχετε σε αυτή την αίθουσα" + "Συμμετοχή" + "Ενδέχεται να χρειαστεί να προσκληθείτε ή να είστε μέλος ενός χώρου για να συμμετάσχετε." + "Χτύπα για συμμετοχή" + "Μήνυμα (προαιρετικό)" + "Θα λάβετε πρόσκληση για να συμμετάσχετε στην αίθουσα, εάν το αίτημά σας γίνει αποδεκτό." + "Το αίτημα συμμετοχής στάλθηκε" + "Δεν μπορέσαμε να εμφανίσουμε την προεπισκόπηση της αίθουσας. Αυτό μπορεί να οφείλεται σε προβλήματα δικτύου ή διακομιστή." + "Δεν μπορέσαμε να εμφανίσουμε αυτή την προεπισκόπηση αίθουσας" + "Το %1$s δεν υποστηρίζει ακόμα χώρους. Μπορείς να έχεις πρόσβαση σε χώρους στον ιστό." + "Οι Χώροι δεν υποστηρίζονται ακόμα" + "Κάντε κλικ στο παρακάτω κουμπί και θα ειδοποιηθεί ένας διαχειριστής της αίθουσας. Μόλις εγκριθείτε, θα μπορείτε να συμμετάσχετε στη συζήτηση." + "Πρέπει να είστε μέλος αυτής της αίθουσας για να δείτε το ιστορικό των μηνυμάτων." + "Θέλετε να συμμετάσχετε σε αυτή την αίθουσα;" + "Η προεπισκόπηση δεν είναι διαθέσιμη" + diff --git a/features/joinroom/impl/src/main/res/values-es/translations.xml b/features/joinroom/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..1f905bb --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,32 @@ + + + "Has sido vetado de esta sala por %1$s." + "Has sido vetado de esta sala" + "Motivo: %1$s." + "Cancelar solicitud" + "Sí, cancelar" + "¿Estás seguro de que deseas cancelar tu solicitud de unión a esta sala?" + "Cancelar solicitud de unión" + "Sí, rechazar y bloquear" + "¿Estás seguro de que deseas rechazar la invitación para unirte a esta sala? Esto también impedirá que %1$s pueda contactar contigo o invitarte a salas." + "Rechazar invitación y bloquear" + "Rechazar y bloquear" + "No se pudo unir a la sala." + "O bien solo se puede acceder a esta sala con invitación, o puede que haya restricciones de acceso a nivel de espacio." + "Olvidar esta sala" + "Necesitas una invitación para unirte a esta sala" + "Unirse" + "Es posible que necesites ser invitado o ser miembro de un espacio para poder unirte." + "Enviar solicitud de unión" + "Mensaje (opcional)" + "Recibirás una invitación para unirte a la sala si se acepta tu solicitud." + "Solicitud de unión enviada" + "No hemos podido mostrar la vista previa de la sala. Esto puede deberse a problemas de red o del servidor." + "No hemos podido mostrar la vista previa de esta sala" + "%1$s aún no admite los espacios. Puedes acceder a los espacios en la web." + "Todavía no se admiten los espacios" + "Haz clic en el botón que aparece a continuación y se notificará a un administrador de la sala. Podrás unirte a la conversación una vez que hayas sido aprobado." + "Debes ser miembro de esta sala para ver el historial de mensajes." + "¿Quieres unirte a esta sala?" + "La vista previa no está disponible" + diff --git a/features/joinroom/impl/src/main/res/values-et/translations.xml b/features/joinroom/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..572e499 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,34 @@ + + + "%1$s keelas sinu ligipääsu siia jututuppa." + "Sinul on ligipääsukeeld siia jututuppa" + "Põhjus: %1$s." + "Tühista liitumispalve" + "Jah, tühista" + "Kas sa oled kindel, et soovid tühistada oma palve jututoaga liitumiseks?" + "Tühista liitumispalve" + "Jah, keeldu ja blokeeri" + "Kas sa oled kindel, et soovid keelduda kutsest sellesse jututuppa? Samaga kaob kasutajal %1$s võimalus sinuga suhelda ja saata sulle jututubade kutseid." + "Keeldu kutsest ja blokeeri" + "Keeldu ja blokeeri" + "Jututoaga liitumine ei õnnestunud" + "Ligipääs siia on võimalik vaid kutse alusel või siin kehtivad ligipääsupiirangud." + "Unusta see jututuba" + "Selle jututoaga liitumiseks vajad sa kutset" + "Kutsuja" + "Liitu" + "Selle jututoaga liitumiseks sa vajad kutset või pead juba olema kogukonna liige." + "Liitumiseks koputa jututoa uksele" + "Lubatud tähemärke: %1$d / %2$d" + "Selgitus (kui soovid lisada)" + "Kui sinu liitumispalvega ollakse nõus, siis saad kutse jututoaga liitumiseks." + "Liitumispalve on saadetud" + "Me ei saanud jututoa eelvaadet näidata. See võib olla põhjustatud võrguühenduse või serveri vigadest." + "Meil ei õnnestunud selle jututoa eelvaadet kuvada" + "%1$s veel ei toeta kogukondadega liitumise ja kasutamise võimalust. Vajadusel saad seda teha veebiliidese vahendusel." + "Kogukonnad pole veel toetatud" + "Klõpsi allolevat nuppu ja jututoa haldaja saab asjakohase teate. Sa saad liituda, kui haldaja sinu soovi heaks kiidab." + "Sõnumite ajaloo vaatamiseks pead olema selle jututoa liige." + "Kas sa soovid selle jututoaga liituda?" + "Eelvaade pole saadaval" + diff --git a/features/joinroom/impl/src/main/res/values-eu/translations.xml b/features/joinroom/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..2fe6a76 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,20 @@ + + + "Arrazoia: %1$s." + "Utzi eskaera bertan behera" + "Bai, utzi bertan behera" + "Eman gonbidapenari ezetza eta blokeatu" + "Baztertu eta blokeatu" + "Gelara sartzeak huts egin du." + "Ahaztu gela hau" + "Elkartu" + "Bidali batzeko eskaera" + "Mezua (aukerakoa)" + "Sartzeko eskaera bidali da" + "%1$s ez da oraindik guneekin bateragarria. Webgunean sar zaitezke guneetara." + "Oraindik ez da guneekin bateragarria" + "Klikatu beheko botoia eta gelako administratzaileari jakinaraziko zaio. Elkarrizketara batu ahal izango zara onartutakoan." + "Gela honetako kide izan behar zara mezuen historia ikusteko." + "Gela honetan sartu nahi?" + "Aurrebista ez dago erabilgarri" + diff --git a/features/joinroom/impl/src/main/res/values-fa/translations.xml b/features/joinroom/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..3f9fc99 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,27 @@ + + + "به دست %1$s از این اتاق محروم شدید." + "از این اتاق محروم شدید" + "دلیل: %1$s." + "لغو درخواست" + "بله. لغو" + "لغو درخواست پیوستن" + "بله. رد و انسداد" + "رد دعوت و انسداد" + "رد و انسداد" + "پیوستن شکست خورد" + "فراموشی این اتاق" + "برای پیوستن به این اتاق نیاز به دعوت دارید" + "دعوت شده از سوی" + "پیوستن" + "برای پیوستن به فضا باید دعوت شده باشید." + "در زدن برای پیوستن" + "پیام (اختیاری)" + "درخواست پیوستن فرستاده شد" + "%1$s هنوز از فضاها پشتیبانی نمی‌کند. می‌توانید روی وب به فضاها دسترسی داشته باشید." + "فضاها هنوز پشتیبانی نمی‌شوند" + "زدن روی این دکمه برای آگاه شدن مدیر اتاق. پس از تأیید می‌توانید به گفت‌وگو بپیوندید." + "برای دیدن تاریخچهٔ پیام باید عضو این اتاق باشید." + "می‌خواهید به اتاق بپیوندید؟" + "پیش‌نمایش موجود نیست" + diff --git a/features/joinroom/impl/src/main/res/values-fi/translations.xml b/features/joinroom/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..38ba043 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,34 @@ + + + "%1$s antoi sinulle porttikiellon." + "Sinulle on annettu porttikielto" + "Syy: %1$s." + "Peruuta pyyntö" + "Kyllä, peruuta" + "Haluatko varmasti peruuttaa pyyntösi liittyä tähän huoneeseen?" + "Peruuta liittymispyyntö" + "Kyllä, hylkää ja estä" + "Oletko varma, että haluat kieltäytyä kutsusta liittyä tähän huoneeseen? Tämä estää myös käyttäjää %1$s ottamasta sinuun yhteyttä tai kutsumasta sinua huoneisiin." + "Hylkää kutsu ja estä" + "Hylkää ja estä" + "Liittyminen epäonnistui" + "Sinun on joko saatava kutsu liittyäksesi tai pääsyyn voi olla rajoituksia." + "Unohda" + "Tarvitset kutsun liittyäksesi" + "Kutsuja" + "Liity" + "Saatat tarvita kutsun tai olla tilan jäsen, jotta voit liittyä." + "Lähetä liittymispyyntö" + "%1$d merkkiä käytetty, %2$d merkkiä sallittu" + "Viesti (valinnainen)" + "Saat kutsun liittyä huoneeseen, jos pyyntösi hyväksytään." + "Liittymispyyntö lähetetty" + "Emme voineet näyttää huoneen esikatselua. Tämä voi johtua verkko- tai palvelinongelmista." + "Emme voineet näyttää tämän huoneen esikatselua" + "%1$s ei tue vielä tiloja. Voit käyttää tiloja selainversiolla." + "Tiloja ei vielä tueta" + "Paina alla olevaa nappia ja huoneen ylläpitäjä saa ilmoituksen. Voit liittyä keskusteluun kun pyyntösi on hyväksytty." + "Sinun on oltava tämän huoneen jäsen, jotta voit nähdä viestihistorian." + "Haluatko liittyä tähän huoneeseen?" + "Esikatselu ei ole saatavilla" + diff --git a/features/joinroom/impl/src/main/res/values-fr/translations.xml b/features/joinroom/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..7ad0317 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,34 @@ + + + "Vous avez été banni(e) par %1$s." + "Vous avez été banni(e)" + "Motif: %1$s." + "Annuler la demande" + "Oui, annuler" + "Êtes-vous sûr de vouloir annuler votre demande d’accès à ce salon ?" + "Annuler la demande d’adhésion" + "Oui, refuser et bloquer" + "Êtes-vous sûr de vouloir refuser l’invitation à rejoindre ce salon ? Cela empêchera également %1$s de vous contacter ou de vous inviter dans les salons." + "Refuser l’invitation et bloquer" + "Refuser et bloquer" + "L’opération a échoué." + "Soit vous devez être invité(e) pour rejoindre, soit il peut y avoir des restrictions d’accès." + "Oublier" + "Vous avez besoin d’une invitation pour pouvoir rejoindre" + "Invité(e) par" + "Rejoindre" + "Il est possible que vous deviez être invité ou être membre d’un Espace pour pouvoir rejoindre le salon." + "Demander à joindre" + "Caractères autorisés %1$d sur %2$d" + "Message (facultatif)" + "Vous recevrez une invitation à rejoindre le salon si votre demande est acceptée." + "Demande de rejoindre le salon envoyée" + "Impossible d’afficher l’aperçu du salon. Cela peut être dû à des problèmes de réseau ou de serveur." + "Impossible d’afficher l’aperçu de ce salon" + "Les Espaces ne sont pas encore pris en charge par %1$s. Vous pouvez voir les Espaces sur le Web." + "Les Espaces ne sont pas encore pris en charge" + "Cliquez ci-dessous et un administrateur sera prévenu. Une fois votre demande approuvée, pour pourrez rejoindre la discussion." + "Vous devez être un membre du salon pour pouvoir lire l’historique des messages." + "Vous souhaitez rejoindre ce salon ?" + "La prévisualisation n’est pas disponible" + diff --git a/features/joinroom/impl/src/main/res/values-hu/translations.xml b/features/joinroom/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..facb3ed --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,34 @@ + + + "%1$s kitiltotta a szobából." + "Kitiltották" + "Ok: %1$s." + "Kérés visszavonása" + "Igen, visszavonás" + "Biztos, hogy visszavonja a szobához való csatlakozási kérését?" + "Csatlakozási kérés visszavonása" + "Igen, elutasítás és blokkolás" + "Biztos, hogy elutasítja a meghívást, hogy csatlakozzon ehhez a szobához? Ez azt is megakadályozza, hogy %1$s kapcsolatba lépjen Önnel, vagy szobákba hívja." + "Meghívó elutasítása és blokkolás" + "Elutasítás és letiltás" + "A csatlakozás sikertelen" + "Csatlakozáshoz meghívóra van szükség, vagy lehet, hogy korlátozva van a hozzáférés." + "Elfelejt" + "A csatlakozáshoz meghívóra van szükség." + "Meghívta:" + "Csatlakozás" + "A csatlakozáshoz meghívásra vagy tértagságra lehet szüksége." + "Kopogtasson a csatlakozáshoz" + "Engedélyezett karakterek: %1$d / %2$d" + "Üzenet (nem kötelező)" + "Ha a kérését elfogadják, meghívót kap a szobához való csatlakozáshoz." + "Csatlakozási kérés elküldve" + "Nem tudtuk megjeleníteni a szoba előnézetét. Ennek az oka hálózati vagy kiszolgálóprobléma is lehet." + "Nem tudtuk megjeleníteni a szoba előnézetét" + "Az %1$s még nem támogatja a tereket. A tereket a weben érheti el." + "A terek még nem támogatottak" + "Kattintson az alábbi gombra, és a szoba adminisztrátora értesítést kap. A jóváhagyást követően csatlakozhat a beszélgetéshez." + "Az üzenetelőzmények megtekintéséhez a szoba tagjának kell lennie." + "Csatlakozna ehhez a szobához?" + "Az előnézet nem érhető el" + diff --git a/features/joinroom/impl/src/main/res/values-in/translations.xml b/features/joinroom/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..59e3aba --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,32 @@ + + + "Anda dicekal dari ruangan ini oleh %1$s." + "Anda dicekal dari ruangan ini" + "Alasan: %1$s." + "Batalkan permintaan" + "Ya, batalkan" + "Apakah Anda yakin ingin membatalkan permintaan Anda untuk bergabung dengan ruangan ini?" + "Batalkan permintaan untuk bergabung" + "Ya, tolak & blokir" + "Apakah Anda yakin ingin menolak undangan untuk bergabung dengan ruangan ini? Ini juga akan mencegah %1$s menghubungi Anda atau mengundang Anda ke ruangan." + "Tolak undangan & blokir" + "Tolak dan blokir" + "Bergabung dalam ruangan gagal." + "Ruangan ini hanya untuk undangan atau mungkin ada pembatasan akses pada tingkat space." + "Lupakan ruangan ini" + "Anda memerlukan undangan untuk bergabung dalam ruangan ini" + "Gabung" + "Anda mungkin perlu diundang atau menjadi anggota space untuk bergabung." + "Ketuk untuk bergabung" + "Pesan (opsional)" + "Anda akan menerima undangan untuk bergabung dengan ruangan jika permintaan Anda diterima." + "Permintaan untuk bergabung dikirim" + "Kami tidak dapat menampilkan pratinjau ruangan. Ini mungkin karena masalah jaringan atau server." + "Kami tidak dapat menampilkan pratinjau ruangan ini" + "%1$s belum mendukung space. Anda dapat mengakses space di web." + "Space belum didukung" + "Klik tombol di bawah ini dan administrator kamar akan diberi tahu. Anda akan dapat bergabung dengan percakapan setelah disetujui." + "Anda harus menjadi anggota ruangan ini untuk melihat riwayat pesan." + "Ingin bergabung dengan ruangan ini?" + "Pratinjau tidak tersedia" + diff --git a/features/joinroom/impl/src/main/res/values-it/translations.xml b/features/joinroom/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..756d6ed --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,34 @@ + + + "Sei stato bannato da %1$s ." + "Sei stato bannato" + "Motivo: %1$s" + "Cancella richiesta" + "Sì, annulla" + "Sei sicuro di voler annullare la tua richiesta di accesso a questa stanza?" + "Annulla la richiesta di accesso" + "Sì, rifiuta e blocca" + "Sei sicuro di voler rifiutare l\'invito a entrare in questa stanza? Ciò impedirà a %1$s di contattarti o invitarti nuovamente in una stanza." + "Rifiuta invito e blocca" + "Rifiuta e blocca" + "Partecipazione non riuscita" + "Devi essere invitato per partecipare o potrebbero esserci delle restrizioni di accesso." + "Dimentica" + "Per partecipare è necessario un invito" + "Invitato da" + "Entra" + "Potrebbe essere necessario essere invitati o essere membro di uno spazio per partecipare." + "Bussa per partecipare" + "Caratteri consentiti: %1$d di %2$d" + "Messaggio (opzionale)" + "Riceverai un invito a entrare nella stanza se la tua richiesta viene accettata." + "Richiesta di accesso inviata" + "Non è stato possibile visualizzare l\'anteprima della stanza. Ciò può essere dovuto a problemi di rete o del server." + "Non è stato possibile visualizzare l\'anteprima di questa stanza" + "%1$s non supporta ancora gli spazi. Puoi accedere agli spazi sul web." + "Gli spazi non sono ancora supportati" + "Clicca sul pulsante qui sotto e un amministratore della stanza riceverà una notifica. Potrai partecipare alla conversazione una volta approvato." + "Per visualizzare la cronologia dei messaggi devi essere un membro di questa stanza." + "Vuoi entrare in questa stanza?" + "L\'anteprima non è disponibile" + diff --git a/features/joinroom/impl/src/main/res/values-ka/translations.xml b/features/joinroom/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..038b807 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,4 @@ + + + "გაწევრიანება" + diff --git a/features/joinroom/impl/src/main/res/values-ko/translations.xml b/features/joinroom/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..80225ac --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,33 @@ + + + "%1$s 에 의해 이 방에서 퇴장당했습니다." + "당신은 이 방에서 차단되었습니다" + "이유: %1$s." + "요청 취소" + "네, 취소합니다" + "이 방에 대한 가입 요청을 정말로 취소하시겠습니까?" + "가입 요청 취소" + "예, 거부 및 차단" + "이 방에 대한 초대 거부를 정말로 확인하시겠습니까? 이 경우 %1$s 에서 귀하에게 연락하거나 방에 초대할 수 없게 됩니다." + "초대 거부 및 차단" + "거부 및 차단" + "방에 참여하는데 실패했습니다." + "이 방은 초대 전용이거나 스페이스 수준에서 액세스 제한이 있을 수 있습니다." + "이 방 지우기" + "이 방에 참여하려면 초대장이 필요합니다." + "참가하기" + "참여하려면 초대 또는 스페이스의 회원이어야 할 수 있습니다." + "가입 요청 보내기" + "%2$d의 %1$d 문자가 허용됨" + "메시지 (선택 사항)" + "요청이 승인되면 방에 참여하기 위한 초대장이 발송됩니다." + "가입 요청이 전송되었습니다" + "방 미리보기를 표시할 수 없습니다. 네트워크 또는 서버 문제 때문일 수 있습니다." + "이 방 미리보기를 표시할 수 없습니다." + "%1$s 아직 스페이스를 지원하지 않습니다. 웹에서 스페이스에 접속할 수 있습니다." + "아직 스페이스가 지원되지 않습니다." + "아래 버튼을 클릭하면 방 관리자에게 알림이 전송됩니다. 승인이 완료되면 대화에 참여할 수 있습니다." + "이 방의 회원이어야만 메시지 기록을 볼 수 있습니다." + "이 방에 참여하고 싶으신가요?" + "미리보기 기능은 제공되지 않습니다." + diff --git a/features/joinroom/impl/src/main/res/values-nb/translations.xml b/features/joinroom/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..5c4873b --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,34 @@ + + + "Du ble utestengt av %1$s." + "Du ble utestengt" + "Årsak: %1$s." + "Avbryt forespørsel" + "Ja, avbryt" + "Er du sikker på at du vil kansellere forespørselen din om å bli med i dette rommet?" + "Avbryt forespørsel om å bli med" + "Ja, avslå og blokker" + "Er du sikker på at du vil avslå invitasjonen til å bli med i dette rommet? Dette vil også forhindre %1$s fra å kontakte deg eller invitere deg til rom." + "Avslå invitasjon og blokker" + "Avslå og blokker" + "Kunne ikke bli med" + "Du må enten bli invitert til å bli med, eller så kan det være begrensninger på tilgangen." + "Glem" + "Du trenger en invitasjon for å bli med" + "Invitert av" + "Bli med" + "Du må kanskje bli invitert eller være medlem av et område for å bli med." + "Send forespørsel om å bli med" + "Tillatte tegn %1$d av %2$d" + "Melding (valgfritt)" + "Du vil motta en invitasjon til å bli med i rommet hvis forespørselen din blir akseptert." + "Forespørsel om å bli med sendt" + "Vi kunne ikke vise forhåndsvisningen av rommet. Dette kan skyldes nettverks- eller serverproblemer." + "Vi kunne ikke vise forhåndsvisning av dette rommet" + "%1$s støtter ikke områder ennå. Du kan få tilgang til områder på nett." + "Områder støttes ikke ennå" + "Klikk på knappen nedenfor, så vil en romadministrator bli varslet. Du vil kunne delta i samtalen når den er godkjent." + "Du må være medlem av dette rommet for å se meldingshistorikken." + "Vil du bli med i dette rommet?" + "Forhåndsvisning er ikke tilgjengelig" + diff --git a/features/joinroom/impl/src/main/res/values-nl/translations.xml b/features/joinroom/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..36d480b --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,18 @@ + + + "Reden: %1$s." + "Verzoek annuleren" + "Weigeren en blokkeren" + "Deze kamer vergeten" + "Deelnemen" + "Klop om deel te nemen" + "Bericht (optioneel)" + "Je ontvangt een uitnodiging om deel te nemen aan de kamer als je aanvraag wordt geaccepteerd." + "Verzoek om toe te treden verzonden" + "%1$s ondersteunt nog geen spaces. Je kunt spaces benaderen via de webbrowser." + "Spaces worden nog niet ondersteund" + "Klik op de knop hieronder en een kamerbeheerder wordt op de hoogte gebracht. Na goedkeuring kun je deelnemen aan het gesprek." + "Je moet lid zijn van deze kamer om de berichtgeschiedenis te bekijken." + "Wil je tot deze kamer toetreden?" + "Voorbeeld is niet beschikbaar" + diff --git a/features/joinroom/impl/src/main/res/values-pl/translations.xml b/features/joinroom/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..fcac55d --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,34 @@ + + + "Zostałeś zbanowany przez %1$s ." + "Zostałeś zbanowany" + "Powód: %1$s." + "Anuluj prośbę" + "Tak, anuluj" + "Czy na pewno chcesz anulować prośbę o dołączenie do tego pokoju?" + "Anuluj prośbę o dołączenie" + "Tak, odrzuć i zablokuj" + "Czy na pewno chcesz odrzucić zaproszenie dołączenia do tego pokoju? %1$s nie będzie mógł się również z Tobą skontaktować, ani zaprosić Cię do pokoju." + "Odrzuć zaproszenie i zablokuj" + "Odrzuć i zablokuj" + "Nie udało się dołączyć do pokoju" + "Ten pokój wymaga zaproszenia lub dołączanie zostało ograniczone." + "Zapomnij" + "Aby dołączyć, potrzebujesz zaproszenia" + "Zaproszony przez" + "Dołącz" + "Aby dołączyć, musisz uzyskać zaproszenie lub być członkiem danej przestrzeni." + "Wyślij prośbę o dołączenie" + "Dozwolone znaki %1$d z %2$d" + "Wiadomość (opcjonalne)" + "Otrzymasz zaproszenie dołączenia do pokoju, jeśli prośba zostanie zaakceptowana." + "Wysłano prośbę o dołączenie" + "Nie udało się wyświetlić podglądu pokoju. Może to być spowodowane problemami z siecią lub serwerem." + "Nie udało nam się wyświetlić podglądu tego pokoju" + "%1$s jeszcze nie obsługuje przestrzeni. Uzyskaj dostęp do przestrzeni w wersji web." + "Przestrzenie nie są jeszcze obsługiwane" + "Kliknij przycisk poniżej, aby powiadomić administratora pokoju. Po zatwierdzeniu będziesz mógł dołączyć do rozmowy." + "Musisz być członkiem tego pokoju, aby wyświetlić historię wiadomości." + "Chcesz dołączyć do tego pokoju?" + "Podgląd nie jest dostępny" + diff --git a/features/joinroom/impl/src/main/res/values-pt-rBR/translations.xml b/features/joinroom/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..2475329 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,34 @@ + + + "Você foi banido por %1$s." + "Você foi banido" + "Motivo: %1$s." + "Cancelar pedido" + "Sim, cancelar" + "Tem a certeza de que pretende cancelar o seu pedido de adesão a esta sala?" + "Cancelar pedido de entrada" + "Sim, recusar e bloquear" + "Tem certeza de que quer recusar o convite para entrar nesta sala? Isso também impedirá que %1$s entre em contato com você ou o convide para salas." + "Recusar convite e bloquear" + "Recusar e bloquear" + "Falha ao entrar" + "Você precisa ser convidado ou pode haver restrições ao acesso." + "Esquecer" + "Você precisa de um convite para entrar" + "Convidado por" + "Entrar" + "Talvez você precise ser convidado ou ser membro de um espaço para participar." + "Enviar solicitação para entrar" + "%1$d de %2$d caráteres permitidos" + "Mensagem (opcional)" + "Você receberá um convite para entrar nesta sala se seu pedido for aceito." + "Pedido de entrada enviado" + "Não foi possível exibir a pré-visualização da sala. Isso pode ser devido a problemas de rede ou do servidor." + "Não foi possível exibir a pré-visualização desta sala" + "%1$s não suporta espaços ainda. Você pode acessar os espaços na web." + "Ainda não há suporte aos espaços" + "Clique no botão abaixo e um administrador da sala será notificado. Você poderá participar da conversa assim que for aprovado." + "Você deve ser um membro desta sala para visualizar o histórico de mensagens." + "Quer entrar nesta sala?" + "A pré-visualização não está disponível" + diff --git a/features/joinroom/impl/src/main/res/values-pt/translations.xml b/features/joinroom/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..5a20d62 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,34 @@ + + + "Foste banido(a) por %1$s." + "Foste banido(a)" + "Razão: %1$s." + "Cancelar pedido" + "Sim, cancelar" + "Tens a certeza de que queres cancelar o teu pedido de entrada nesta sala?" + "Cancela o pedido de adesão" + "Sim, recusar & bloquear" + "Tens a certeza de que queres recusar o convite para entrar nesta sala? Isto também evitará que %1$s te contacte ou te convide para salas." + "Recusar convite & bloquear" + "Recusar e bloquear" + "Falha ao entrar" + "A entrada pode estar limitada a convites ou pode haver uma outra limitação de acesso." + "Esquecer" + "Precisas de um convite para entrares" + "Convidado por" + "Entrar" + "Podes ter que ser convidado ou pertenceres a um espaço para poderes entrar." + "Bater à porta" + "%1$d de %2$d caracteres permitidos" + "Mensagem (opcional)" + "Irás receber um convite para participar na sala se o pedido for aceite." + "Pedido de adesão enviado" + "Não conseguimos exibir a pré-visualização da sala. Isso pode ser devido a problemas de rede ou servidor." + "Não foi possível exibir a pré-visualização desta sala" + "A %1$s ainda não funciona com espaços. Podes usá-los na aplicação web." + "Os espaços ainda não estão implementados" + "Carrega no botão abaixo para notificar um administrador da sala. Poderás entrar quando te aprovarem." + "Apenas os participantes podem ver o histórico de mensagens." + "Queres entrar nesta sala?" + "Pré-visualização indisponível" + diff --git a/features/joinroom/impl/src/main/res/values-ro/translations.xml b/features/joinroom/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..8326ce0 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,34 @@ + + + "Ați fost exclus din această cameră de către %1$s." + "Ați fost exclus din această cameră." + "Motiv: %1$s." + "Anulați cererea" + "Da, anulați" + "Sunteți sigur că doriți să anulați cererea de a vă alătura acestei camere?" + "Anulați cererea de alăturare" + "Da, refuzați și blocați" + "Sunteți sigur că doriți să refuzați invitația de a vă alătura acestei camere? Acest lucru va împiedica, de asemenea, %1$s să vă contacteze sau să vă invite în camere." + "Refuzați invitația și blocați" + "Refuzați și blocați" + "Alăturarea la cameră a eșuat." + "Această cameră este fie accesibilă numai pe bază de invitație, fie exista restricții de acces la nivel de spațiu." + "Uitați această cameră" + "Aveți nevoie de o invitație pentru a vă alătura acestei camere." + "Invitat de" + "Alăturați-vă" + "Este posibil să fie necesar să fiți invitat sau să fiți membru al unui spațiu pentru a vă alătura." + "Trimiteți o cerere de alăturare" + "Caractere permise %1$d din %2$d" + "Mesaj (opțional)" + "Veți primi o invitație de a vă alătura camerei dacă cererea dumneavoastră este acceptată." + "Cererea de alăturare a fost trimisă" + "Nu am putut afișa previzualizarea camerei. Este posibil ca acest lucru să se datoreze unor probleme de rețea sau de server." + "Nu am putut afișa previzualizarea acestei camere." + "%1$s nu suporta încă spații. Puteți accesa spațiile pe web." + "Spațiile nu sunt încă suportate" + "Faceți clic pe butonul de mai jos și un administrator de cameră va fi notificat. Veți putea să vă alăturați conversației odată aprobată." + "Trebuie să fiți membru al acestei camere pentru a vizualiza mesajele anterioare." + "Doriți să vă alăturați acestei camere?" + "Previzualizare indisponibilă" + diff --git a/features/joinroom/impl/src/main/res/values-ru/translations.xml b/features/joinroom/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..207bc4b --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,34 @@ + + + "Вы были заблокированы в комнате %1$s." + "Вы были заблокированы в комнате" + "Причина: %1$s." + "Отменить запрос" + "Да, отменить" + "Вы действительно хотите отменить заявку на вступление в эту комнату?" + "Отменить запрос на присоединение" + "Да, отклонить и заблокировать" + "Вы действительно хотите отклонить приглашение в эту комнате? Это также предотвратит %1$s возможность связываться с вами или приглашать вас в комнаты." + "Отклонить приглашение и заблокировать" + "Отклонить и заблокировать" + "Не удалось присоединиться к комнате." + "Доступ к комнате ограничен. Возможно вам нужно приглашение в комнату" + "Забыть эту комнату" + "Вам необходимо приглашение для того, чтобы присоединиться к этой комнате" + "Приглашен" + "Присоединиться" + "Чтобы присоединиться, вам необходимо приглашение или быть участником сообщества." + "Отправить запрос на присоединение" + "Разрешенные символы %1$d %2$d" + "Сообщение (опционально)" + "Вы получите приглашение присоединиться к комнате, как только ваш запрос будет принят." + "Запрос на присоединение отправлен" + "Не удалось отобразить предварительный просмотр комнаты. Это может быть связано с проблемами сети или сервера." + "Мы не смогли отобразить предварительный просмотр этой комнаты" + "%1$s еще не поддерживает пространства. Вы можете получить к ним доступ в веб-версии." + "Пространства пока не поддерживаются" + "Нажмите кнопку ниже и администратор комнаты получит уведомление. После одобрения вы сможете присоединиться к обсуждению." + "Вы должны быть участником этой комнаты, чтобы просмотреть историю сообщений." + "Хотите присоединиться к этой комнате?" + "Предварительный просмотр недоступен" + diff --git a/features/joinroom/impl/src/main/res/values-sk/translations.xml b/features/joinroom/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..b6af52c --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,34 @@ + + + "Používateľ %1$s vám zakázal prístup." + "Bol vám zakázaný vstup" + "Dôvod: %1$s." + "Zrušiť žiadosť" + "Áno, zrušiť" + "Ste si istí, že chcete zrušiť svoju žiadosť o vstup do tejto miestnosti?" + "Zrušiť žiadosť o pripojenie" + "Áno, odmietnuť a zablokovať" + "Ste si istí, že chcete odmietnuť pozvanie na vstup do tejto miestnosti? To tiež zabráni tomu, aby vás %1$s kontaktoval/a alebo vás pozval/a do miestností." + "Odmietnuť pozvánku a zablokovať" + "Odmietnuť a zablokovať" + "Vstup sa nepodaril" + "Buď musíte byť pozvaní pripojiť sa, alebo môžu existovať obmedzenia prístupu." + "Zabudnúť" + "Potrebujete pozvanie, aby ste sa mohli pripojiť" + "Pozvaný/á používateľom" + "Pripojiť sa" + "Možno budete musieť byť pozvaní alebo byť členom priestoru, aby ste sa mohli pripojiť." + "Zaklopaním sa pripojíte" + "Povolené znaky %1$d z %2$d" + "Správa (voliteľné)" + "Ak bude vaša žiadosť prijatá, dostanete pozvánku na vstup do miestnosti." + "Žiadosť o pripojenie bola odoslaná" + "Nepodarilo sa zobraziť ukážku miestnosti. Môže to byť spôsobené problémami so sieťou alebo serverom." + "Ukážku tejto miestnosti sa nepodarilo zobraziť" + "%1$s zatiaľ nepodporuje priestory. K priestorom môžete pristupovať na webe." + "Priestory zatiaľ nie sú podporované" + "Kliknite na tlačidlo nižšie a správca miestnosti bude informovaný. Po schválení sa budete môcť pripojiť ku konverzácii." + "Ak chcete zobraziť históriu správ, musíte byť členom tejto miestnosti." + "Chcete sa pripojiť do tejto miestnosti?" + "Náhľad nie je k dispozícii" + diff --git a/features/joinroom/impl/src/main/res/values-sv/translations.xml b/features/joinroom/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..11af0da --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,33 @@ + + + "Du bannades från det här rummet av %1$s." + "Du bannades från det här rummet" + "Anledning: %1$s." + "Avbryt begäran" + "Ja, avbryt" + "Är du säker på att du vill avbryta din begäran om att gå med i det här rummet?" + "Avbryt begäran om att gå med" + "Ja, avvisa och blockera" + "Är du säker på att du vill avvisa inbjudan att gå med i det här rummet? Detta kommer också att hindra %1$s från att kontakta dig eller bjuda in dig till rum." + "Avvisa inbjudan och blockera" + "Avvisa och blockera" + "Misslyckades att gå med i rummet." + "Detta rum är antingen endast för inbjudna eller så kan det finnas begränsningar för åtkomst på utrymmesnivå." + "Glöm det här rummet" + "Du behöver en inbjudan för att gå med i detta rum" + "Gå med" + "Du kan behöva bli inbjuden eller vara medlem i ett utrymme för att gå med." + "Knacka för att gå med" + "Tillåtna tecken %1$d av %2$d" + "Meddelande (valfritt)" + "Du kommer att få en inbjudan att gå med i rummet om din begäran accepteras." + "Begäran om att gå med skickad" + "Vi kunde inte visa förhandsgranskningen av rummet. Detta kan bero på nätverks- eller serverproblem." + "Vi kunde inte visa förhandsgranskningen av rummet" + "%1$s stöder inte utrymmen än. Du kan komma åt utrymmen på webben." + "Utrymmen stöds inte ännu" + "Klicka på knappen nedan så kommer en rumsadministratör att meddelas. Du kommer att kunna gå med i konversationen när den har godkänts." + "Du måste vara medlem i det här rummet för att se meddelandehistoriken." + "Vill du gå med i det här rummet?" + "Förhandsgranskning är inte tillgänglig" + diff --git a/features/joinroom/impl/src/main/res/values-tr/translations.xml b/features/joinroom/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..abd08d3 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,31 @@ + + + "%1$s tarafından bu odadan yasaklandınız." + "Bu odadan yasaklandın" + "Neden: %1$s." + "İsteği iptal et" + "Evet, iptal et" + "Bu odaya katılma isteğinizi iptal etmek istediğinizden emin misiniz?" + "Katılma isteğini iptal et" + "Evet, reddet ve engelle" + "Bu odaya katılma davetini reddetmek istediğinizden emin misiniz? Bu aynı zamanda %1$s sizinle iletişim kurmasını veya sizi odalara davet etmesini de engeller." + "Daveti reddet ve engelle" + "Odaya katılım başarısız oldu." + "Bu odaya yalnızca davetle girilebilir veya alan düzeyinde erişim kısıtlamaları olabilir." + "Bu odayı unut" + "Bu odaya katılmak için bir davete ihtiyacınız var" + "Katıl" + "Katılmak için davet edilmeniz veya bir alana üye olmanız gerekebilir." + "Katılma isteği gönder" + "Mesaj (isteğe bağlı)" + "Talebiniz kabul edilirse odaya katılmanız için bir davet alacaksınız." + "Katılma isteği gönderildi" + "Oda önizlemesini görüntüleyemedik. Bunun nedeni ağ veya sunucu sorunları olabilir." + "Bu oda önizlemesini görüntüleyemedik" + "%1$s henüz alanları desteklemiyor. Alanlara web üzerinden erişebilirsiniz." + "Alanlar henüz desteklenmiyor" + "Aşağıdaki düğmeyi tıkladığınızda bir oda yöneticisi bilgilendirilecektir. Onaylandıktan sonra görüşmeye katılabilirsiniz." + "Mesaj geçmişini görüntülemek için bu odaya üye olmanız gerekmektedir." + "Bu odaya katılmak ister misiniz?" + "Önizleme mevcut değil" + diff --git a/features/joinroom/impl/src/main/res/values-uk/translations.xml b/features/joinroom/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..90d5b01 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,33 @@ + + + "%1$s забороняє вам відвідувати цю кімнату." + "Вам заборонили відвідувати цю кімнату" + "Причина: %1$s." + "Скасувати запит" + "Так, скасувати" + "Ви впевнені, що бажаєте скасувати свій запит на приєднання до цієї кімнати?" + "Скасувати запит на приєднання" + "Так, відхилити та заблокувати" + "Ви впевнені, що хочете відхилити запрошення приєднатися до цієї кімнати? Це також завадить %1$s зв\'язатися з вами або запрошувати вас в кімнати." + "Відхилити запрошення та заблокувати" + "Відхилити та заблокувати" + "Не вдалося приєднатися до кімнати." + "Ця кімната доступна лише за запрошенням або на рівні простору можуть бути обмеження доступу." + "Забути цю кімнату" + "Вам потрібне запрошення, щоб приєднатися до цієї кімнати" + "Доєднатися" + "Можливо, вам знадобиться отримати запрошення або стати учасником простору, щоб приєднатися." + "Постукати, щоб приєднатися" + "Дозволені символи %1$d з %2$d" + "Повідомлення (необов\'язково)" + "Ви отримаєте запрошення приєднатися до кімнати, якщо ваш запит буде прийнятий." + "Запит на приєднання надіслано" + "Ми не змогли показати попередній перегляд кімнати. Це може бути пов\'язано з проблемами мережі або сервера." + "Ми не можемо показати попередній перегляд цієї кімнати" + "%1$s ще не підтримує простори. Ви можете отримати доступ до них у вебверсії." + "Простори поки що не підтримуються" + "Натисніть кнопку нижче, і адміністратор кімнати отримає сповіщення. Ви зможете приєднатися до розмови після схвалення." + "Ви мусите бути учасником цієї кімнати, щоб переглядати історію повідомлень." + "Хочете приєднатися до цієї кімнати?" + "Попередній перегляд недоступний" + diff --git a/features/joinroom/impl/src/main/res/values-ur/translations.xml b/features/joinroom/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..4bb7030 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,11 @@ + + + "شامل ہوں" + "شامل ہونے کی درخواست بھیجیں" + "%1$s ابھی تک خالی جگہوں کی حمایت نہیں کرتا۔ آپ جال پر خالی جگہوں تک رسائی حاصل کرسکتے ہیں۔" + "ابھی تک جگہیں تعاون یافتہ نہیں" + "نیچے دیئے گئے کلید پر دبائیں اور کمرے کے منتظم کو مطلع کیا جائے گا۔ منظور ہونے کے بعد آپ گفتگو میں شامل ہو سکیں گے۔" + "پیغام کی سرگزشت دیکھنے کے لیے آپ کا اس کمرے کا رکن ہونا ضروری ہے۔" + "اس کمرے میں شامل ہونا چاہتے ہیں؟" + "پیش منظر دستیاب نہیں ہے" + diff --git a/features/joinroom/impl/src/main/res/values-uz/translations.xml b/features/joinroom/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..de8b10f --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,33 @@ + + + "Siz %1$s tomonidan ushbu xonadan ban qilingansiz." + "Siz bu xonadan chetlashtirilgansiz" + "Sababi: %1$s ." + "So‘rovni bekor qilish" + "Ha, bekor qiling" + "Bu xonaga qo‘shilish so‘rovingizni bekor qilishni xohlayotganingizga ishonchingiz komilmi?" + "Qo‘shilish so‘rovini bekor qilish" + "Ha, rad etish va bloklash" + "Ushbu xonaga qo‘shilish taklifini rad etishga ishonchingiz komilmi? Bu %1$sning siz bilan bog‘lanishiga yoki sizni xonalarga taklif qilishiga ham to‘sqinlik qiladi." + "Taklifni rad etish va bloklash" + "Rad etish va bloklash" + "Xonaga qo‘shilish amalga oshmadi" + "Bu xona faqat taklif etilganlar uchun yoki bu maydonga kirish huquqi cheklangan bo‘lishi mumkin." + "Bu xonani esdan chiqarish" + "Bu xonaga kirish uchun taklifnoma kerak" + "Qo\'shilish" + "Qo‘shilish uchun sizga taklif kerak yoki siz maydonga a’zo bo‘lishingiz kerak." + "Qoʻshilish soʻrovini yuborish" + "Ruxsat etilgan belgilar: %1$d / %2$d" + "Xabar (ixtiyoriy)" + "Agar so‘rovingiz qabul qilinsa, xonaga qo‘shilish taklifini olasiz." + "Qo‘shilish so‘rovi yuborildi" + "Xona ko‘rinishini namoyish eta olmadik. Bu tarmoq yoki server muammolari tufayli yuz bergan bo‘lishi mumkin." + "Biz bu xonani oldindan ko‘rishni ko‘rsata olmadik " + "%1$s hali maydon xizmatini qoʻllab-quvvatlamaydi. maydonga veb-sayt orqali kirishingiz mumkin." + "Maydonlar hali qoʻllab-quvvatlanmaydi" + "Quyidagi tugmani bosing va xona administratoriga xabar beriladi. Ruxsat berilgandan soʻng suhbatga qoʻshilishingiz mumkin boʻladi." + "Xabarlar tarixini koʻrish uchun siz ushbu xonaning aʼzosi boʻlishingiz shart." + "Bu xonaga qoʻshilishni xohlaysizmi?" + "Oldindan koʻrish imkoni yoʻq" + diff --git a/features/joinroom/impl/src/main/res/values-zh-rTW/translations.xml b/features/joinroom/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..00887ad --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,34 @@ + + + "您被 %1$s 禁止。" + "您被禁止了" + "理由:%1$s。" + "取消請求" + "是的,取消" + "您確定您想要取消加入此聊天室的請求嗎?" + "取消加入請求" + "是的,拒絕並封鎖" + "您確定要拒絕加入此聊天室的邀請嗎?這也會防止 %1$s 聯絡您或邀請您加入聊天室。" + "拒絕邀請並封鎖" + "拒絕並封鎖" + "加入失敗。" + "您必須獲得邀請才能加入,或者可能存在存取限制。" + "忘記" + "您需要獲得邀請才能加入" + "邀請者" + "加入" + "您可能需要被邀請成為空間的成員才能加入。" + "傳送加入請求" + "允許的字元 %1$d 中的 %2$d" + "訊息(選擇性)" + "若接受了您的請求,您將會收到加入聊天是的邀請。" + "已傳送加入請求" + "我們無法顯示聊天室預覽。這可能是因為網路或伺服器問題所致。" + "我們無法顯示此聊天室的預覽" + "%1$s 尚未支援空間。您可以在網頁上存取空間。" + "尚未支援空間" + "點選下方按鈕將會通知聊天試管里員。一旦核准,您就可以加入對話。" + "您必須是此聊天室的成員才能檢視訊息歷史紀錄。" + "想要加入此聊天室?" + "無法使用預覽" + diff --git a/features/joinroom/impl/src/main/res/values-zh/translations.xml b/features/joinroom/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..7bea362 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,34 @@ + + + "您已被禁止访问%1$s。" + "你已被禁止访问" + "理由:%1$s。" + "取消请求" + "是的,取消" + "您确定要取消加入此房间的请求吗?" + "取消加入申请" + "是的,拒绝并屏蔽" + "您确定要拒绝加入此房间的邀请吗?这也将阻止%1$s 与您联系或邀请您加入房间。" + "拒绝邀请并屏蔽" + "拒绝并屏蔽" + "加入失败" + "您需要被邀请加入,否则可能会受到访问限制。" + "忘记" + "您需要邀请才能加入" + "受邀于" + "加入" + "您可能需要受到邀请或成为某个空间的成员才能加入。" + "加入聊天室" + "允许的字符数量 %2$d中的%1$d" + "消息(可选)" + "如果您的请求被接受,您将收到加入房间的邀请。" + "加入请求已发送" + "无法显示房间预览。这可能是由于网络或服务器问题造成的。" + "无法显示此房间预览" + "%1$s 尚不支持空间。您可以通过 Web 端访问空间" + "空间尚不支持" + "点击下面的按钮,系统将通知聊天室管理员。获得批准后将能够加入对话。" + "只有聊天室成员才能查看消息历史记录。" + "想加入这个聊天室吗?" + "预览不可用" + diff --git a/features/joinroom/impl/src/main/res/values/localazy.xml b/features/joinroom/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..0e1787f --- /dev/null +++ b/features/joinroom/impl/src/main/res/values/localazy.xml @@ -0,0 +1,34 @@ + + + "You were banned by %1$s." + "You were banned" + "Reason: %1$s." + "Cancel request" + "Yes, cancel" + "Are you sure that you want to cancel your request to join this room?" + "Cancel request to join" + "Yes, decline & block" + "Are you sure you want to decline the invite to join this room? This will also prevent %1$s from contacting you or inviting you to rooms." + "Decline invite & block" + "Decline and block" + "Joining failed" + "You either need to be invited to join or there might be restrictions to access." + "Forget" + "You need an invite in order to join" + "Invited by" + "Join" + "You may need to be invited or be a member of a space in order to join." + "Send request to join" + "Allowed characters %1$d of %2$d" + "Message (optional)" + "You will receive an invite to join the room if your request is accepted." + "Request to join sent" + "We could not display the room preview. This may be due to network or server issues." + "We couldn’t display this room preview" + "%1$s does not support spaces yet. You can access spaces on web." + "Spaces are not supported yet" + "Click the button below and a room administrator will be notified. You’ll be able to join the conversation once approved." + "You must be a member of this room to view the message history." + "Want to join this room?" + "Preview is not available" + diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/DefaultJoinRoomEntryPointTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/DefaultJoinRoomEntryPointTest.kt new file mode 100644 index 0000000..3f44650 --- /dev/null +++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/DefaultJoinRoomEntryPointTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.joinroom.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.features.invite.test.declineandblock.FakeDeclineInviteAndBlockEntryPoint +import io.element.android.features.joinroom.api.JoinRoomEntryPoint +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test +import java.util.Optional + +class DefaultJoinRoomEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultJoinRoomEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + JoinRoomFlowNode( + buildContext = buildContext, + plugins = plugins, + presenterFactory = { _, _, _, _, _ -> createJoinRoomPresenter() }, + acceptDeclineInviteView = { _, _, _, _ -> lambdaError() }, + declineAndBlockEntryPoint = FakeDeclineInviteAndBlockEntryPoint(), + ) + } + val inputs = JoinRoomEntryPoint.Inputs( + roomId = A_ROOM_ID, + roomIdOrAlias = A_ROOM_ID.toRoomIdOrAlias(), + roomDescription = Optional.ofNullable(null), + serverNames = emptyList(), + trigger = JoinedRoom.Trigger.RoomDirectory, + ) + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + inputs = inputs, + ) + assertThat(result).isInstanceOf(JoinRoomFlowNode::class.java) + assertThat(result.plugins).contains(inputs) + } +} diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/FakeCancelKnockRoom.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/FakeCancelKnockRoom.kt new file mode 100644 index 0000000..8badc1b --- /dev/null +++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/FakeCancelKnockRoom.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.joinroom.impl + +import io.element.android.features.joinroom.impl.di.CancelKnockRoom +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.tests.testutils.simulateLongTask + +class FakeCancelKnockRoom( + var lambda: (RoomId) -> Result = { Result.success(Unit) } +) : CancelKnockRoom { + override suspend fun invoke(roomId: RoomId) = simulateLongTask { + lambda(roomId) + } +} diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/FakeForgetRoom.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/FakeForgetRoom.kt new file mode 100644 index 0000000..b7e0fc5 --- /dev/null +++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/FakeForgetRoom.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.joinroom.impl + +import io.element.android.features.joinroom.impl.di.ForgetRoom +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.tests.testutils.simulateLongTask + +class FakeForgetRoom( + var lambda: (RoomId) -> Result = { Result.success(Unit) } +) : ForgetRoom { + override suspend fun invoke(roomId: RoomId) = simulateLongTask { + lambda(roomId) + } +} diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/FakeKnockRoom.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/FakeKnockRoom.kt new file mode 100644 index 0000000..d80312f --- /dev/null +++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/FakeKnockRoom.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.joinroom.impl + +import io.element.android.features.joinroom.impl.di.KnockRoom +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.tests.testutils.simulateLongTask + +class FakeKnockRoom( + var lambda: (RoomIdOrAlias, String, List) -> Result = { _, _, _ -> Result.success(Unit) } +) : KnockRoom { + override suspend fun invoke(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List): Result = simulateLongTask { + lambda(roomIdOrAlias, message, serverNames) + } +} diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt new file mode 100644 index 0000000..aec50d1 --- /dev/null +++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt @@ -0,0 +1,1285 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.joinroom.impl + +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.features.invite.api.InviteData +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState +import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState +import io.element.android.features.invite.api.toInviteData +import io.element.android.features.invite.test.InMemorySeenInvitesStore +import io.element.android.features.joinroom.impl.di.CancelKnockRoom +import io.element.android.features.joinroom.impl.di.ForgetRoom +import io.element.android.features.joinroom.impl.di.KnockRoom +import io.element.android.features.roomdirectory.api.RoomDescription +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta +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.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.exception.ClientException +import io.element.android.libraries.matrix.api.exception.ErrorKind +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomMembershipDetails +import io.element.android.libraries.matrix.api.room.join.JoinRoom +import io.element.android.libraries.matrix.api.room.join.JoinRule +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_SERVER_LIST +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.FakeMatrixClient +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.matrix.test.room.aRoomPreview +import io.element.android.libraries.matrix.test.room.aRoomPreviewInfo +import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom +import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList +import io.element.android.libraries.matrix.test.spaces.FakeSpaceService +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.model.InviteSender +import io.element.android.libraries.matrix.ui.model.toInviteSender +import io.element.android.libraries.previewutils.room.aSpaceRoom +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import java.util.Optional + +@Suppress("LargeClass") +class JoinRoomPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createJoinRoomPresenter() + presenter.test { + awaitItem().also { state -> + assertThat(state.contentState).isEqualTo(ContentState.Loading) + assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState()) + assertThat(state.cancelKnockAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(state.knockAction).isEqualTo(AsyncAction.Uninitialized) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun `present - when room is joined then content state is filled with his data`() = runTest { + val roomInfo = aRoomInfo() + val matrixClient = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ).apply { + getRoomInfoFlowLambda = { _ -> + flowOf(Optional.of(roomInfo)) + } + } + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + val contentState = state.contentState as ContentState.Loaded + assertThat(contentState.roomId).isEqualTo(A_ROOM_ID) + assertThat(contentState.name).isEqualTo(roomInfo.name) + assertThat(contentState.topic).isEqualTo(roomInfo.topic) + assertThat(contentState.alias).isEqualTo(roomInfo.canonicalAlias) + assertThat(contentState.numberOfMembers).isEqualTo(roomInfo.joinedMembersCount) + assertThat(contentState.details).isEqualTo(aLoadedDetailsRoom(isDm = roomInfo.isDirect)) + assertThat(contentState.roomAvatarUrl).isEqualTo(roomInfo.avatarUrl) + } + } + } + + @Test + fun `present - when room is invited then join authorization is equal to invited`() = runTest { + val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.INVITED) + val matrixClient = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ).apply { + getRoomInfoFlowLambda = { _ -> + flowOf(Optional.of(roomInfo)) + } + } + val seenInvitesStore = InMemorySeenInvitesStore() + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient, + seenInvitesStore = seenInvitesStore, + ) + val inviteData = roomInfo.toInviteData() + assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty() + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(inviteData, null)) + } + // Check that the roomId is stored in the seen invites store + assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(roomInfo.id) + } + } + + @Test + fun `present - when room is invited then join authorization is equal to invited, an inviter is provided`() = runTest { + val inviter = aRoomMember(userId = A_USER_ID_2, displayName = "Bob") + val expectedInviteSender = inviter.toInviteSender() + val roomInfo = aRoomInfo( + currentUserMembership = CurrentUserMembership.INVITED, + joinedMembersCount = 5, + inviter = inviter, + ) + val inviteData = roomInfo.toInviteData() + val matrixClient = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> + Result.success( + aRoomPreview( + info = aRoomPreviewInfo( + numberOfJoinedMembers = 5, + ), + roomMembershipDetails = { + Result.success(aRoomMembershipDetails()) + }, + ) + ) + }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ).apply { + getRoomInfoFlowLambda = { _ -> + flowOf(Optional.of(roomInfo)) + } + } + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(inviteData, expectedInviteSender)) + assertThat((state.contentState as ContentState.Loaded).numberOfMembers).isEqualTo(5) + } + } + } + + @Test + fun `present - when space is invited then join authorization is equal to invited, an inviter is provided`() = runTest { + val inviter = aRoomMember(userId = A_USER_ID_2, displayName = "Bob") + val expectedInviteSender = inviter.toInviteSender() + val spaceHero = aMatrixUser() + val roomInfo = aRoomInfo( + isSpace = true, + currentUserMembership = CurrentUserMembership.INVITED, + joinedMembersCount = 5, + inviter = inviter, + heroes = listOf(spaceHero), + ) + val inviteData = roomInfo.toInviteData() + val matrixClient = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> + Result.success( + aRoomPreview( + info = aRoomPreviewInfo( + numberOfJoinedMembers = 5, + ), + roomMembershipDetails = { + Result.success(aRoomMembershipDetails()) + }, + ) + ) + }, + spaceService = FakeSpaceService( + spaceRoomListResult = { + FakeSpaceRoomList( + initialSpaceFlowValue = aSpaceRoom( + childrenCount = 3, + ) + ) + }, + ), + ).apply { + getRoomInfoFlowLambda = { _ -> + flowOf(Optional.of(roomInfo)) + } + } + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(inviteData, expectedInviteSender)) + assertThat((state.contentState as ContentState.Loaded).numberOfMembers).isEqualTo(5) + // Space details are provided + assertThat(state.contentState.details).isEqualTo( + LoadedDetails.Space( + childrenCount = 3, + heroes = persistentListOf(spaceHero), + ) + ) + } + } + } + + @Test + fun `present - space is invited - no room info`() = runTest { + val spaceHero = aMatrixUser() + val spaceRoom = aSpaceRoom( + childrenCount = 3, + heroes = listOf(spaceHero), + ) + val matrixClient = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> + Result.failure(Exception("Error")) + }, + spaceService = FakeSpaceService( + spaceRoomListResult = { + FakeSpaceRoomList( + initialSpaceFlowValue = spaceRoom, + ) + }, + ), + ).apply { + getRoomInfoFlowLambda = { _ -> + flowOf(Optional.ofNullable(null)) + } + } + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + // Space details are provided + assertThat((state.contentState as ContentState.Loaded).details).isEqualTo( + LoadedDetails.Space( + childrenCount = 3, + heroes = persistentListOf(spaceHero), + ) + ) + } + } + } + + @Test + fun `present - space is invited - no room info - space room state set`() = runTest { + val spaceRoom = aSpaceRoom( + state = CurrentUserMembership.INVITED, + ) + val matrixClient = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> + Result.failure(Exception("Error")) + }, + spaceService = FakeSpaceService( + spaceRoomListResult = { + FakeSpaceRoomList( + initialSpaceFlowValue = spaceRoom, + ) + }, + ), + ).apply { + getRoomInfoFlowLambda = { _ -> + flowOf(Optional.ofNullable(null)) + } + } + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient + ) + presenter.test { + awaitItem().also { state -> + // Space details are provided + assertThat(state.contentState).isInstanceOf(ContentState.Loading::class.java) + } + } + } + + @Test + fun `present - when room is invited read the number of member from the room preview`() = runTest { + val roomInfo = aRoomInfo( + currentUserMembership = CurrentUserMembership.INVITED, + // It seems that the SDK does not provide this value. + joinedMembersCount = 0, + ) + val matrixClient = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> + Result.success( + aRoomPreview( + info = aRoomPreviewInfo( + numberOfJoinedMembers = 10, + ), + roomMembershipDetails = { + Result.success(aRoomMembershipDetails()) + }, + ) + ) + }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ).apply { + getRoomInfoFlowLambda = { _ -> + flowOf(Optional.of(roomInfo)) + } + } + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat((state.contentState as ContentState.Loaded).numberOfMembers).isEqualTo(10) + } + } + } + + @Test + fun `present - when room is invited then accept and decline events are sent to acceptDeclinePresenter`() = runTest { + val eventSinkRecorder = lambdaRecorder { _: AcceptDeclineInviteEvents -> } + val acceptDeclinePresenter = Presenter { + anAcceptDeclineInviteState(eventSink = eventSinkRecorder) + } + val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.INVITED) + val matrixClient = FakeMatrixClient( + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ).apply { + getRoomInfoFlowLambda = { _ -> + flowOf(Optional.of(roomInfo)) + } + } + val inviteData = roomInfo.toInviteData() + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient, + acceptDeclineInvitePresenter = acceptDeclinePresenter + ) + presenter.test { + skipItems(1) + + awaitItem().also { state -> + state.eventSink(JoinRoomEvents.AcceptInvite(inviteData)) + state.eventSink(JoinRoomEvents.DeclineInvite(inviteData, false)) + + assert(eventSinkRecorder) + .isCalledExactly(2) + .withSequence( + listOf(value(AcceptDeclineInviteEvents.AcceptInvite(inviteData))), + listOf(value(AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = true))), + ) + } + } + } + + @Test + fun `present - when room is joined with success, all the parameters are provided`() = runTest { + val aTrigger = JoinedRoom.Trigger.MobilePermalink + val joinRoomLambda = lambdaRecorder { _: RoomIdOrAlias, _: List, _: JoinedRoom.Trigger -> + Result.success(Unit) + } + val matrixClient = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ) + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient, + trigger = aTrigger, + serverNames = A_SERVER_LIST, + joinRoomLambda = joinRoomLambda, + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + state.eventSink(JoinRoomEvents.JoinRoom) + } + awaitItem().also { state -> + assertThat(state.joinAction).isEqualTo(AsyncAction.Loading) + } + awaitItem().also { state -> + assertThat(state.joinAction).isEqualTo(AsyncAction.Success(Unit)) + } + joinRoomLambda.assertions() + .isCalledOnce() + .with(value(A_ROOM_ID.toRoomIdOrAlias()), value(A_SERVER_LIST), value(aTrigger)) + } + } + + @Test + fun `present - when room is joined with error, it is possible to clear the error`() = runTest { + val matrixClient = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ) + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient, + joinRoomLambda = { _, _, _ -> + Result.failure(AN_EXCEPTION) + }, + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + state.eventSink(JoinRoomEvents.JoinRoom) + } + awaitItem().also { state -> + assertThat(state.joinAction).isEqualTo(AsyncAction.Loading) + } + awaitItem().also { state -> + assertThat(state.joinAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) + state.eventSink(JoinRoomEvents.ClearActionStates) + } + awaitItem().also { state -> + assertThat(state.joinAction).isEqualTo(AsyncAction.Uninitialized) + } + } + } + + @Test + fun `present - when room is joined with unauthorized error, then the authorisation status is unauthorized`() = runTest { + val roomDescription = aRoomDescription() + val presenter = createJoinRoomPresenter( + roomDescription = Optional.of(roomDescription), + joinRoomLambda = { _, _, _ -> + Result.failure(JoinRoom.Failures.UnauthorizedJoin) + }, + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + state.eventSink(JoinRoomEvents.JoinRoom) + } + awaitItem().also { state -> + assertThat(state.joinAction).isEqualTo(AsyncAction.Loading) + } + awaitItem().also { state -> + assertThat(state.joinAction).isEqualTo(AsyncAction.Failure(JoinRoom.Failures.UnauthorizedJoin)) + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unauthorized) + } + } + } + + @Test + fun `present - when room is banned, then join authorization is equal to IsBanned`() = runTest { + val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.BANNED, joinRule = JoinRule.Public) + val matrixClient = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> + Result.success( + aRoomPreview( + info = aRoomPreviewInfo( + roomId = A_ROOM_ID, + joinRule = JoinRule.Public, + currentUserMembership = CurrentUserMembership.BANNED, + ), + roomMembershipDetails = { + Result.success(aRoomMembershipDetails()) + } + ) + ) + }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ).apply { + getRoomInfoFlowLambda = { _ -> + flowOf(Optional.of(roomInfo)) + } + } + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient + ) + presenter.test { + // Skip initial state + skipItems(1) + + // Advance until the room info is loaded and the presenter recomposes. The room preview info still needs to be loaded async. + skipItems(1) + + // Now we should have the room info + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isInstanceOf(JoinAuthorisationStatus.IsBanned::class.java) + } + } + } + + @Test + fun `present - when room is left and public then join authorization is equal to canJoin`() = runTest { + val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.LEFT, joinRule = JoinRule.Public) + val matrixClient = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ).apply { + getRoomInfoFlowLambda = { _ -> + flowOf(Optional.of(roomInfo)) + } + } + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.CanJoin) + } + } + } + + @Test + fun `present - when room is left and join rule null then join authorization is equal to Unknown`() = runTest { + val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.LEFT, joinRule = null) + val matrixClient = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ).apply { + getRoomInfoFlowLambda = { _ -> + flowOf(Optional.of(roomInfo)) + } + } + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unknown) + } + } + } + + @Test + fun `present - when room description is provided and room is not found then content state is filled with data`() = runTest { + val roomDescription = aRoomDescription() + val presenter = createJoinRoomPresenter( + roomDescription = Optional.of(roomDescription) + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + val contentState = state.contentState as ContentState.Loaded + assertThat(contentState.roomId).isEqualTo(A_ROOM_ID) + assertThat(contentState.name).isEqualTo(roomDescription.name) + assertThat(contentState.topic).isEqualTo(roomDescription.topic) + assertThat(contentState.alias).isEqualTo(roomDescription.alias) + assertThat(contentState.numberOfMembers).isEqualTo(roomDescription.numberOfMembers) + assertThat(contentState.details).isEqualTo(aLoadedDetailsRoom(isDm = false)) + assertThat(contentState.roomAvatarUrl).isEqualTo(roomDescription.avatarUrl) + } + } + } + + @Test + fun `present - when room description join rule is Knock then join authorization is equal to canKnock`() = runTest { + val roomDescription = aRoomDescription(joinRule = RoomDescription.JoinRule.KNOCK) + val presenter = createJoinRoomPresenter( + roomDescription = Optional.of(roomDescription) + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.CanKnock) + } + } + } + + @Test + fun `present - when room description join rule is Public then join authorization is equal to canJoin`() = runTest { + val roomDescription = aRoomDescription(joinRule = RoomDescription.JoinRule.PUBLIC) + val presenter = createJoinRoomPresenter( + roomDescription = Optional.of(roomDescription) + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.CanJoin) + } + } + } + + @Test + fun `present - when room description join rule is Unknown then join authorization is equal to unknown`() = runTest { + val roomDescription = aRoomDescription(joinRule = RoomDescription.JoinRule.UNKNOWN) + val presenter = createJoinRoomPresenter( + roomDescription = Optional.of(roomDescription) + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unknown) + } + } + } + + @Test + fun `present - when room preview join rule is Private then join authorization is equal to NeedInvite`() = runTest { + val roomDescription = aRoomDescription(joinRule = RoomDescription.JoinRule.UNKNOWN) + val presenter = createJoinRoomPresenter( + roomDescription = Optional.of(roomDescription) + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unknown) + } + } + } + + @Test + fun `present - emit knock room event`() = runTest { + val knockMessage = "Knock message" + val knockRoomSuccess = lambdaRecorder { _: RoomIdOrAlias, _: String, _: List -> + Result.success(Unit) + } + val knockRoomFailure = lambdaRecorder { roomIdOrAlias: RoomIdOrAlias, _: String, _: List -> + Result.failure(RuntimeException("Failed to knock room $roomIdOrAlias")) + } + val fakeKnockRoom = FakeKnockRoom(knockRoomSuccess) + val matrixClient = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ) + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient, + knockRoom = fakeKnockRoom, + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + state.eventSink(JoinRoomEvents.UpdateKnockMessage(knockMessage)) + } + awaitItem().also { state -> + state.eventSink(JoinRoomEvents.KnockRoom) + } + + assertThat(awaitItem().knockAction).isEqualTo(AsyncAction.Loading) + awaitItem().also { state -> + assertThat(state.knockAction).isEqualTo(AsyncAction.Success(Unit)) + fakeKnockRoom.lambda = knockRoomFailure + state.eventSink(JoinRoomEvents.KnockRoom) + } + + assertThat(awaitItem().knockAction).isEqualTo(AsyncAction.Loading) + awaitItem().also { state -> + assertThat(state.knockAction).isInstanceOf(AsyncAction.Failure::class.java) + } + } + assert(knockRoomSuccess) + .isCalledOnce() + .with(value(A_ROOM_ID.toRoomIdOrAlias()), value(knockMessage), any()) + assert(knockRoomFailure) + .isCalledOnce() + .with(value(A_ROOM_ID.toRoomIdOrAlias()), value(knockMessage), any()) + } + + @Test + fun `present - emit cancel knock room event`() = runTest { + val cancelKnockRoomSuccess = lambdaRecorder { _: RoomId -> + Result.success(Unit) + } + val cancelKnockRoomFailure = lambdaRecorder { roomId: RoomId -> + Result.failure(RuntimeException("Failed to knock room $roomId")) + } + val cancelKnockRoom = FakeCancelKnockRoom(cancelKnockRoomSuccess) + val matrixClient = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ) + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient, + cancelKnockRoom = cancelKnockRoom, + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + state.eventSink(JoinRoomEvents.CancelKnock(true)) + } + awaitItem().also { state -> + assertThat(state.cancelKnockAction).isEqualTo(AsyncAction.ConfirmingNoParams) + state.eventSink(JoinRoomEvents.CancelKnock(false)) + } + assertThat(awaitItem().cancelKnockAction).isEqualTo(AsyncAction.Loading) + awaitItem().also { state -> + assertThat(state.cancelKnockAction).isEqualTo(AsyncAction.Success(Unit)) + cancelKnockRoom.lambda = cancelKnockRoomFailure + state.eventSink(JoinRoomEvents.CancelKnock(false)) + } + assertThat(awaitItem().cancelKnockAction).isEqualTo(AsyncAction.Loading) + awaitItem().also { state -> + assertThat(state.cancelKnockAction).isInstanceOf(AsyncAction.Failure::class.java) + } + } + assert(cancelKnockRoomFailure) + .isCalledOnce() + .with(value(A_ROOM_ID)) + assert(cancelKnockRoomSuccess) + .isCalledOnce() + .with(value(A_ROOM_ID)) + } + + @Test + fun `present - emit forget room event`() = runTest { + val forgetRoomSuccess = lambdaRecorder { _: RoomId -> + Result.success(Unit) + } + val forgetRoomFailure = lambdaRecorder { _: RoomId -> + Result.failure(RuntimeException("Failed to forget room")) + } + val fakeForgetRoom = FakeForgetRoom(forgetRoomSuccess) + val matrixClient = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> Result.failure(AN_EXCEPTION) }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ) + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient, + forgetRoom = fakeForgetRoom, + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + state.eventSink(JoinRoomEvents.ForgetRoom) + } + + assertThat(awaitItem().forgetAction).isEqualTo(AsyncAction.Loading) + awaitItem().also { state -> + assertThat(state.forgetAction).isEqualTo(AsyncAction.Success(Unit)) + fakeForgetRoom.lambda = forgetRoomFailure + state.eventSink(JoinRoomEvents.ForgetRoom) + } + + assertThat(awaitItem().forgetAction).isEqualTo(AsyncAction.Loading) + awaitItem().also { state -> + assertThat(state.forgetAction).isInstanceOf(AsyncAction.Failure::class.java) + } + } + assert(forgetRoomFailure) + .isCalledOnce() + .with(value(A_ROOM_ID)) + assert(forgetRoomSuccess) + .isCalledOnce() + .with(value(A_ROOM_ID)) + } + + @Test + fun `present - when room is not known RoomPreview is loaded - membership null`() = runTest { + val client = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> + Result.success( + aRoomPreview( + info = aRoomPreviewInfo( + roomId = A_ROOM_ID, + canonicalAlias = RoomAlias("#alias:matrix.org"), + name = "Room name", + topic = "Room topic", + avatarUrl = "avatarUrl", + numberOfJoinedMembers = 2, + isSpace = false, + isHistoryWorldReadable = false, + joinRule = JoinRule.Public, + currentUserMembership = null, + ), + roomMembershipDetails = { + Result.success(aRoomMembershipDetails()) + }, + ) + ) + }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ) + val presenter = createJoinRoomPresenter( + matrixClient = client + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.contentState).isEqualTo( + ContentState.Loaded( + roomId = A_ROOM_ID, + name = "Room name", + topic = "Room topic", + alias = RoomAlias("#alias:matrix.org"), + numberOfMembers = 2, + roomAvatarUrl = "avatarUrl", + joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin, + joinRule = JoinRule.Public, + details = aLoadedDetailsRoom(isDm = false), + ) + ) + } + } + } + + @Test + fun `present - when room is not known RoomPreview is loaded - membership INVITED`() = runTest { + val client = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> + Result.success( + aRoomPreview( + info = aRoomPreviewInfo( + roomId = A_ROOM_ID, + canonicalAlias = RoomAlias("#alias:matrix.org"), + name = "Room name", + topic = "Room topic", + avatarUrl = "avatarUrl", + numberOfJoinedMembers = 2, + isSpace = false, + isHistoryWorldReadable = false, + joinRule = JoinRule.Public, + currentUserMembership = CurrentUserMembership.INVITED, + ), + roomMembershipDetails = { + Result.success(aRoomMembershipDetails()) + } + ) + ) + }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ) + val presenter = createJoinRoomPresenter( + matrixClient = client + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.contentState).isEqualTo( + ContentState.Loaded( + roomId = A_ROOM_ID, + name = "Room name", + topic = "Room topic", + alias = RoomAlias("#alias:matrix.org"), + numberOfMembers = 2, + roomAvatarUrl = "avatarUrl", + joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited( + inviteData = InviteData( + roomId = A_ROOM_ID, + roomName = "Room name", + isDm = false, + ), + inviteSender = InviteSender( + userId = A_USER_ID_2, + displayName = "Bob", + avatarData = AvatarData( + id = A_USER_ID_2.value, + name = "Bob", + size = AvatarSize.InviteSender, + ), + membershipChangeReason = null, + ), + ), + joinRule = JoinRule.Public, + details = aLoadedDetailsRoom(isDm = false), + ) + ) + } + } + } + + @Test + fun `present - when room is not known RoomPreview is loaded - membership BANNED`() = runTest { + val client = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> + Result.success( + aRoomPreview( + info = aRoomPreviewInfo( + roomId = A_ROOM_ID, + canonicalAlias = RoomAlias("#alias:matrix.org"), + name = null, + topic = "Room topic", + avatarUrl = "avatarUrl", + numberOfJoinedMembers = 2, + isSpace = false, + isHistoryWorldReadable = false, + joinRule = JoinRule.Public, + currentUserMembership = CurrentUserMembership.BANNED, + ), + roomMembershipDetails = { + Result.success( + aRoomMembershipDetails(), + ) + } + ) + ) + }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ) + val presenter = createJoinRoomPresenter( + matrixClient = client + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.contentState).isEqualTo( + ContentState.Loaded( + roomId = A_ROOM_ID, + name = null, + topic = "Room topic", + alias = RoomAlias("#alias:matrix.org"), + numberOfMembers = 2, + roomAvatarUrl = "avatarUrl", + joinAuthorisationStatus = JoinAuthorisationStatus.IsBanned( + banSender = InviteSender( + userId = A_USER_ID_2, + displayName = "Bob", + avatarData = AvatarData( + id = A_USER_ID_2.value, + name = "Bob", + size = AvatarSize.InviteSender, + ), + membershipChangeReason = null, + ), + reason = null, + ), + joinRule = JoinRule.Public, + details = aLoadedDetailsRoom(isDm = false), + ) + ) + } + } + } + + @Test + fun `present - when room is not known RoomPreview is loaded - membership KNOCKED`() = runTest { + val client = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> + Result.success( + aRoomPreview( + info = aRoomPreviewInfo( + roomId = A_ROOM_ID, + canonicalAlias = RoomAlias("#alias:matrix.org"), + name = "Room name", + topic = "Room topic", + avatarUrl = "avatarUrl", + numberOfJoinedMembers = 2, + isSpace = false, + isHistoryWorldReadable = false, + joinRule = JoinRule.Public, + currentUserMembership = CurrentUserMembership.KNOCKED, + ), + roomMembershipDetails = { + Result.success(aRoomMembershipDetails()) + } + ) + ) + }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ) + val presenter = createJoinRoomPresenter( + matrixClient = client + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.contentState).isEqualTo( + ContentState.Loaded( + roomId = A_ROOM_ID, + name = "Room name", + topic = "Room topic", + alias = RoomAlias("#alias:matrix.org"), + numberOfMembers = 2, + roomAvatarUrl = "avatarUrl", + joinAuthorisationStatus = JoinAuthorisationStatus.IsKnocked, + joinRule = JoinRule.Public, + details = aLoadedDetailsRoom(isDm = false), + ) + ) + } + } + } + + @Test + fun `present - when room is not known RoomPreview is loaded as Private`() = runTest { + val client = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> + Result.success( + aRoomPreview( + info = aRoomPreviewInfo(joinRule = JoinRule.Private), + roomMembershipDetails = { + Result.success(aRoomMembershipDetails()) + }, + ) + ) + }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ) + val presenter = createJoinRoomPresenter( + matrixClient = client + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.NeedInvite) + } + } + } + + @Test + fun `present - when room is not known RoomPreview is loaded as Custom`() = runTest { + val client = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> + Result.success( + aRoomPreview( + info = aRoomPreviewInfo(joinRule = JoinRule.Custom("custom")), + roomMembershipDetails = { + Result.success(aRoomMembershipDetails()) + }, + ) + ) + }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ) + val presenter = createJoinRoomPresenter( + matrixClient = client + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unknown) + } + } + } + + @Test + fun `present - when room is not known RoomPreview is loaded as Invite`() = runTest { + val client = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> + Result.success( + aRoomPreview( + info = aRoomPreviewInfo(joinRule = JoinRule.Invite), + roomMembershipDetails = { + Result.success(aRoomMembershipDetails()) + }, + ) + ) + }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ) + val presenter = createJoinRoomPresenter( + matrixClient = client + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.NeedInvite) + } + } + } + + @Test + fun `present - when room is not known RoomPreview is loaded as KnockRestricted`() = runTest { + val client = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> + Result.success( + aRoomPreview( + info = aRoomPreviewInfo( + joinRule = JoinRule.KnockRestricted(persistentListOf()) + ), + roomMembershipDetails = { + Result.success(aRoomMembershipDetails()) + } + ) + ) + }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ) + val presenter = createJoinRoomPresenter( + matrixClient = client + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.CanKnock) + } + } + } + + @Test + fun `present - when room is not known RoomPreview is loaded as Restricted`() = runTest { + val client = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> + Result.success( + aRoomPreview( + info = aRoomPreviewInfo(joinRule = JoinRule.Restricted(persistentListOf())), + roomMembershipDetails = { + Result.success(aRoomMembershipDetails()) + }, + ) + ) + }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ) + val presenter = createJoinRoomPresenter( + matrixClient = client + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Restricted) + } + } + } + + @Test + fun `present - when room is not known RoomPreview is loaded with error`() = runTest { + val client = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> + Result.failure(AN_EXCEPTION) + }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ) + val presenter = createJoinRoomPresenter( + matrixClient = client + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.contentState).isEqualTo( + ContentState.UnknownRoom + ) + } + } + } + + @Test + fun `present - when room is not known RoomPreview is loaded with error Forbidden`() = runTest { + val client = FakeMatrixClient( + getNotJoinedRoomResult = { _, _ -> + Result.failure(ClientException.MatrixApi(ErrorKind.Forbidden, "403", "Forbidden", null)) + }, + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ) + val presenter = createJoinRoomPresenter( + matrixClient = client + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.contentState).isEqualTo(ContentState.UnknownRoom) + } + } + } + + private fun aRoomDescription( + roomId: RoomId = A_ROOM_ID, + name: String? = A_ROOM_NAME, + topic: String? = "A room about something", + alias: RoomAlias? = RoomAlias("#alias:matrix.org"), + avatarUrl: String? = null, + joinRule: RoomDescription.JoinRule = RoomDescription.JoinRule.UNKNOWN, + numberOfMembers: Long = 2L + ): RoomDescription { + return RoomDescription( + roomId = roomId, + name = name, + topic = topic, + alias = alias, + avatarUrl = avatarUrl, + joinRule = joinRule, + numberOfMembers = numberOfMembers + ) + } +} + +internal fun createJoinRoomPresenter( + roomId: RoomId = A_ROOM_ID, + roomDescription: Optional = Optional.empty(), + serverNames: List = emptyList(), + trigger: JoinedRoom.Trigger = JoinedRoom.Trigger.Invite, + matrixClient: MatrixClient = FakeMatrixClient( + spaceService = FakeSpaceService( + spaceRoomListResult = { FakeSpaceRoomList() }, + ), + ), + joinRoomLambda: (RoomIdOrAlias, List, JoinedRoom.Trigger) -> Result = { _, _, _ -> + Result.success(Unit) + }, + knockRoom: KnockRoom = FakeKnockRoom(), + cancelKnockRoom: CancelKnockRoom = FakeCancelKnockRoom(), + forgetRoom: ForgetRoom = FakeForgetRoom(), + buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"), + acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() }, + seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), +): JoinRoomPresenter { + return JoinRoomPresenter( + roomId = roomId, + roomIdOrAlias = roomId.toRoomIdOrAlias(), + roomDescription = roomDescription, + serverNames = serverNames, + trigger = trigger, + matrixClient = matrixClient, + joinRoom = FakeJoinRoom(joinRoomLambda), + knockRoom = knockRoom, + cancelKnockRoom = cancelKnockRoom, + forgetRoom = forgetRoom, + buildMeta = buildMeta, + acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, + seenInvitesStore = seenInvitesStore, + ) +} + +private fun aRoomMembershipDetails() = RoomMembershipDetails( + currentUserMember = aRoomMember(userId = A_USER_ID, displayName = "Alice"), + senderMember = aRoomMember(userId = A_USER_ID_2, displayName = "Bob"), +) diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt new file mode 100644 index 0000000..0a3b1ca --- /dev/null +++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.joinroom.impl + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.invite.api.InviteData +import io.element.android.features.invite.test.anInviteData +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.room.join.JoinRoom +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.matrix.ui.model.toInviteSender +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class JoinRoomViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invoke the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setJoinRoomView( + aJoinRoomState( + eventSink = eventsRecorder, + ), + onBackClick = it + ) + rule.pressBack() + } + } + + @Test + fun `clicking on Join room on CanJoin room emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setJoinRoomView( + aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_join_room_join_action) + eventsRecorder.assertSingle(JoinRoomEvents.JoinRoom) + } + + @Test + fun `clicking on Knock room on CanKnock room emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setJoinRoomView( + aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock), + knockMessage = "Knock knock", + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_join_room_knock_action) + eventsRecorder.assertSingle(JoinRoomEvents.KnockRoom) + } + + @Test + fun `clicking on closing Knock error emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setJoinRoomView( + aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock), + knockAction = AsyncAction.Failure(Exception("Error")), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_ok) + eventsRecorder.assertSingle(JoinRoomEvents.ClearActionStates) + } + + @Test + fun `clicking on cancel knock request emit the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setJoinRoomView( + aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsKnocked), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_join_room_cancel_knock_action) + eventsRecorder.assertSingle(JoinRoomEvents.CancelKnock(true)) + } + + @Test + fun `clicking on closing Cancel Knock error emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setJoinRoomView( + aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsKnocked), + cancelKnockAction = AsyncAction.Failure(Exception("Error")), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_ok) + eventsRecorder.assertSingle(JoinRoomEvents.ClearActionStates) + } + + @Test + fun `clicking on closing Join error emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setJoinRoomView( + aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock), + joinAction = AsyncAction.Failure(Exception("Error")), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_ok) + eventsRecorder.assertSingle(JoinRoomEvents.ClearActionStates) + } + + @Test + fun `when joining room is successful, the expected callback is invoked`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setJoinRoomView( + aJoinRoomState( + joinAction = AsyncAction.Success(Unit), + eventSink = eventsRecorder, + ), + onJoinSuccess = it + ) + } + } + + @Test + fun `clicking on Accept when JoinAuthorisationStatus is IsInvited emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val inviteData = anInviteData() + rule.setJoinRoomView( + aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(inviteData, null)), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_accept) + eventsRecorder.assertSingle(JoinRoomEvents.AcceptInvite(inviteData)) + } + + @Test + fun `clicking on Decline when JoinAuthorisationStatus is IsInvited emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val inviteData = anInviteData() + rule.setJoinRoomView( + aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(inviteData, null)), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_decline) + eventsRecorder.assertSingle(JoinRoomEvents.DeclineInvite(inviteData, false)) + } + + @Test + fun `clicking on Decline and block when JoinAuthorisationStatus is IsInvited and can report room, the expected callback is invoked`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val inviteData = anInviteData() + val joinRoomState = aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(inviteData, aRoomMember().toInviteSender())), + canReportRoom = true, + eventSink = eventsRecorder, + ) + ensureCalledOnceWithParam(inviteData) { + rule.setJoinRoomView( + state = joinRoomState, + onDeclineInviteAndBlockUser = it, + ) + rule.clickOn(R.string.screen_join_room_decline_and_block_button_title) + } + } + + @Test + fun `clicking on Decline and block when JoinAuthorisationStatus is IsInvited and cant report room, emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val inviteData = anInviteData() + val joinRoomState = aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(inviteData, aRoomMember().toInviteSender())), + canReportRoom = false, + eventSink = eventsRecorder, + ) + rule.setJoinRoomView(state = joinRoomState) + rule.clickOn(R.string.screen_join_room_decline_and_block_button_title) + eventsRecorder.assertSingle(JoinRoomEvents.DeclineInvite(inviteData, true)) + } + + @Test + fun `clicking on Retry when an error occurs emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setJoinRoomView( + aJoinRoomState( + contentState = aFailureContentState(), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_retry) + eventsRecorder.assertSingle(JoinRoomEvents.RetryFetchingContent) + } + + @Test + fun `clicking on ok when user is unauthorized the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setJoinRoomView( + aJoinRoomState( + contentState = aLoadedContentState(), + joinAction = AsyncAction.Failure(JoinRoom.Failures.UnauthorizedJoin), + eventSink = eventsRecorder, + ), + onBackClick = it + ) + rule.clickOn(CommonStrings.action_ok) + } + } + + @Test + fun `clicking on forget when user is banned invokes the expected callback`() { + val eventsRecorder = EventsRecorder() + rule.setJoinRoomView( + aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsBanned(null, null)), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_join_room_forget_action) + eventsRecorder.assertSingle(JoinRoomEvents.ForgetRoom) + } +} + +private fun AndroidComposeTestRule.setJoinRoomView( + state: JoinRoomState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onJoinSuccess: () -> Unit = EnsureNeverCalled(), + onKnockSuccess: () -> Unit = EnsureNeverCalled(), + onCancelKnockSuccess: () -> Unit = EnsureNeverCalled(), + onForgetSuccess: () -> Unit = EnsureNeverCalled(), + onDeclineInviteAndBlockUser: (InviteData) -> Unit = EnsureNeverCalledWithParam(), +) { + setContent { + JoinRoomView( + state = state, + onBackClick = onBackClick, + onJoinSuccess = onJoinSuccess, + onKnockSuccess = onKnockSuccess, + onForgetSuccess = onForgetSuccess, + onCancelKnockSuccess = onCancelKnockSuccess, + onDeclineInviteAndBlockUser = onDeclineInviteAndBlockUser, + ) + } +} diff --git a/features/knockrequests/api/build.gradle.kts b/features/knockrequests/api/build.gradle.kts new file mode 100644 index 0000000..25f419a --- /dev/null +++ b/features/knockrequests/api/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.knockrequests.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/banner/KnockRequestsBannerRenderer.kt b/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/banner/KnockRequestsBannerRenderer.kt new file mode 100644 index 0000000..34061db --- /dev/null +++ b/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/banner/KnockRequestsBannerRenderer.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.api.banner + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +interface KnockRequestsBannerRenderer { + @Composable + fun View(modifier: Modifier, onViewRequestsClick: () -> Unit) +} diff --git a/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/list/KnockRequestsListEntryPoint.kt b/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/list/KnockRequestsListEntryPoint.kt new file mode 100644 index 0000000..9975911 --- /dev/null +++ b/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/list/KnockRequestsListEntryPoint.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.api.list + +import io.element.android.libraries.architecture.SimpleFeatureEntryPoint + +interface KnockRequestsListEntryPoint : SimpleFeatureEntryPoint diff --git a/features/knockrequests/impl/build.gradle.kts b/features/knockrequests/impl/build.gradle.kts new file mode 100644 index 0000000..6f03047 --- /dev/null +++ b/features/knockrequests/impl/build.gradle.kts @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +import extension.setupDependencyInjection +import extension.testCommonDependencies + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.knockrequests.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + api(projects.features.knockrequests.api) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.featureflag.api) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.featureflag.test) +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/DefaultKnockRequestsBannerRenderer.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/DefaultKnockRequestsBannerRenderer.kt new file mode 100644 index 0000000..32e551a --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/DefaultKnockRequestsBannerRenderer.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.banner + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.knockrequests.api.banner.KnockRequestsBannerRenderer +import io.element.android.libraries.di.RoomScope + +@ContributesBinding(RoomScope::class) +class DefaultKnockRequestsBannerRenderer( + private val presenter: KnockRequestsBannerPresenter, +) : KnockRequestsBannerRenderer { + @Composable + override fun View(modifier: Modifier, onViewRequestsClick: () -> Unit) { + val state = presenter.present() + KnockRequestsBannerView( + state = state, + onViewRequestsClick = onViewRequestsClick, + ) + } +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerEvents.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerEvents.kt new file mode 100644 index 0000000..740982b --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.banner + +sealed interface KnockRequestsBannerEvents { + data object AcceptSingleRequest : KnockRequestsBannerEvents + data object Dismiss : KnockRequestsBannerEvents +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt new file mode 100644 index 0000000..641efff --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.banner + +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 dev.zacsweers.metro.Inject +import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable +import io.element.android.features.knockrequests.impl.data.KnockRequestsService +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.mapState +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private const val ACCEPT_ERROR_DISPLAY_DURATION = 1500L + +@Inject +class KnockRequestsBannerPresenter( + private val knockRequestsService: KnockRequestsService, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, +) : Presenter { + @Composable + override fun present(): KnockRequestsBannerState { + val knockRequests by remember { + knockRequestsService.knockRequestsFlow.mapState { knockRequests -> + knockRequests.dataOrNull().orEmpty() + .filter { !it.isSeen } + .toImmutableList() + } + }.collectAsState() + + val permissions by knockRequestsService.permissionsFlow.collectAsState() + val showAcceptError = remember { mutableStateOf(false) } + + val shouldShowBanner by remember { + derivedStateOf { + permissions.canHandle && knockRequests.isNotEmpty() + } + } + + fun handleEvent(event: KnockRequestsBannerEvents) { + when (event) { + is KnockRequestsBannerEvents.AcceptSingleRequest -> { + sessionCoroutineScope.acceptSingleKnockRequest( + knockRequests = knockRequests, + displayAcceptError = showAcceptError, + ) + } + is KnockRequestsBannerEvents.Dismiss -> { + sessionCoroutineScope.launch { + knockRequestsService.markAllKnockRequestsAsSeen() + } + } + } + } + + return KnockRequestsBannerState( + knockRequests = knockRequests, + displayAcceptError = showAcceptError.value, + canAccept = permissions.canAccept, + isVisible = shouldShowBanner, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.acceptSingleKnockRequest( + knockRequests: List, + displayAcceptError: MutableState, + ) = launch { + val knockRequest = knockRequests.singleOrNull() + if (knockRequest != null) { + knockRequestsService.acceptKnockRequest(knockRequest, optimistic = true) + .onFailure { + displayAcceptError.value = true + delay(ACCEPT_ERROR_DISPLAY_DURATION) + displayAcceptError.value = false + } + } + } +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerState.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerState.kt new file mode 100644 index 0000000..e9291b5 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerState.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.banner + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import io.element.android.features.knockrequests.impl.R +import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable +import kotlinx.collections.immutable.ImmutableList + +data class KnockRequestsBannerState( + val isVisible: Boolean, + val knockRequests: ImmutableList, + val displayAcceptError: Boolean, + val canAccept: Boolean, + val eventSink: (KnockRequestsBannerEvents) -> Unit, +) { + val subtitle = knockRequests.singleOrNull()?.userId?.value + val reason = knockRequests.singleOrNull()?.reason + + @Composable + fun formattedTitle(): String { + return when (knockRequests.size) { + 0 -> "" + 1 -> stringResource(R.string.screen_room_single_knock_request_title, knockRequests.first().getBestName()) + else -> { + val firstRequest = knockRequests.first() + val otherRequestsCount = knockRequests.size - 1 + pluralStringResource( + id = R.plurals.screen_room_multiple_knock_requests_title, + count = otherRequestsCount, + firstRequest.getBestName(), + otherRequestsCount + ) + } + } + } +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt new file mode 100644 index 0000000..67f1aaa --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.banner + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable +import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable +import kotlinx.collections.immutable.toImmutableList + +class KnockRequestsBannerStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aKnockRequestsBannerState(), + aKnockRequestsBannerState( + knockRequests = listOf( + aKnockRequestPresentable( + reason = "A very long reason that should probably be truncated, " + + "but could be also expanded so you can see it over the lines, wow," + + "very amazing reason, I know, right, I'm so good at writing reasons." + ) + ) + ), + aKnockRequestsBannerState( + knockRequests = listOf( + aKnockRequestPresentable(), + aKnockRequestPresentable(displayName = "Alice") + ) + ), + aKnockRequestsBannerState( + knockRequests = listOf( + aKnockRequestPresentable(), + aKnockRequestPresentable(displayName = "Alice"), + aKnockRequestPresentable(displayName = "Bob"), + aKnockRequestPresentable(displayName = "Charlie") + ) + ), + aKnockRequestsBannerState( + canAccept = false + ), + aKnockRequestsBannerState( + displayAcceptError = true + ), + aKnockRequestsBannerState( + knockRequests = listOf( + aKnockRequestPresentable( + displayName = "A_very_long_display_name_so_that_the_text_can_be_displayed_on_multiple_lines" + ) + ) + ), + ) +} + +fun aKnockRequestsBannerState( + knockRequests: List = listOf(aKnockRequestPresentable()), + displayAcceptError: Boolean = false, + canAccept: Boolean = true, + isVisible: Boolean = true, + eventSink: (KnockRequestsBannerEvents) -> Unit = {} +) = KnockRequestsBannerState( + knockRequests = knockRequests.toImmutableList(), + displayAcceptError = displayAcceptError, + canAccept = canAccept, + isVisible = isVisible, + eventSink = eventSink, +) diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerView.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerView.kt new file mode 100644 index 0000000..431bd14 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerView.kt @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.banner + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +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.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.knockrequests.impl.R +import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable +import io.element.android.libraries.designsystem.components.async.AsyncIndicator +import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost +import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarRow +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ButtonSize +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.Surface +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +private const val MAX_AVATAR_COUNT = 3 + +@Composable +fun KnockRequestsBannerView( + state: KnockRequestsBannerState, + onViewRequestsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + AnimatedVisibility( + visible = state.isVisible, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Surface( + shape = MaterialTheme.shapes.small, + color = ElementTheme.colors.bgCanvasDefaultLevel1, + shadowElevation = 24.dp, + modifier = Modifier.padding(16.dp), + ) { + KnockRequestsBannerContent( + state = state, + onViewRequestsClick = onViewRequestsClick, + ) + } + } + KnockRequestsAcceptErrorView(displayError = state.displayAcceptError) + } +} + +@Composable +private fun KnockRequestsAcceptErrorView( + displayError: Boolean, + modifier: Modifier = Modifier, +) { + val asyncIndicatorState = rememberAsyncIndicatorState() + AsyncIndicatorHost(modifier = modifier.statusBarsPadding(), state = asyncIndicatorState) + LaunchedEffect(displayError) { + if (displayError) { + asyncIndicatorState.enqueue { + AsyncIndicator.Custom(text = stringResource(CommonStrings.error_unknown)) + } + } else { + asyncIndicatorState.clear() + } + } +} + +@Composable +private fun KnockRequestsBannerContent( + state: KnockRequestsBannerState, + onViewRequestsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + fun onDismissClick() { + state.eventSink(KnockRequestsBannerEvents.Dismiss) + } + + fun onAcceptClick() { + state.eventSink(KnockRequestsBannerEvents.AcceptSingleRequest) + } + + Column( + modifier + .fillMaxWidth() + .padding(all = 16.dp) + ) { + Row { + KnockRequestAvatarView( + state.knockRequests, + modifier = Modifier.padding(top = 2.dp), + ) + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = state.formattedTitle(), + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Start, + ) + if (state.subtitle != null) { + Text( + text = state.subtitle, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Start, + ) + } + } + Spacer(modifier = Modifier.width(4.dp)) + Icon( + modifier = Modifier.clickable(onClick = ::onDismissClick), + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_close) + ) + } + val reason = state.reason + if (!reason.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = state.reason, + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyMdRegular, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (state.knockRequests.size > 1) { + Button( + text = stringResource(R.string.screen_room_multiple_knock_requests_view_all_button_title), + onClick = onViewRequestsClick, + size = ButtonSize.MediumLowPadding, + modifier = Modifier.weight(1f), + ) + } else { + OutlinedButton( + text = stringResource(R.string.screen_room_single_knock_request_view_button_title), + onClick = onViewRequestsClick, + size = ButtonSize.MediumLowPadding, + modifier = Modifier.weight(1f), + ) + if (state.canAccept) { + Button( + text = stringResource(R.string.screen_room_single_knock_request_accept_button_title), + onClick = ::onAcceptClick, + size = ButtonSize.MediumLowPadding, + modifier = Modifier.weight(1f), + ) + } + } + } + } +} + +@Composable +private fun KnockRequestAvatarView( + knockRequests: ImmutableList, + modifier: Modifier = Modifier, +) { + Box(modifier) { + when (knockRequests.size) { + 0 -> Unit + 1 -> Avatar( + avatarData = knockRequests.first().getAvatarData(AvatarSize.KnockRequestBanner), + avatarType = AvatarType.User, + ) + else -> KnockRequestAvatarListView(knockRequests) + } + } +} + +@Composable +private fun KnockRequestAvatarListView( + knockRequests: ImmutableList, + modifier: Modifier = Modifier, +) { + val avatars = knockRequests + .take(MAX_AVATAR_COUNT) + .map { knockRequest -> + knockRequest.getAvatarData(AvatarSize.KnockRequestBanner) + } + .toImmutableList() + AvatarRow( + avatarDataList = avatars, + avatarType = AvatarType.User, + modifier = modifier, + ) +} + +@Composable +@PreviewsDayNight +internal fun KnockRequestsBannerViewPreview(@PreviewParameter(KnockRequestsBannerStateProvider::class) state: KnockRequestsBannerState) = ElementPreview { + KnockRequestsBannerView( + state = state, + onViewRequestsClick = {}, + ) +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestFixture.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestFixture.kt new file mode 100644 index 0000000..17fd0ec --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestFixture.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.data + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId + +fun aKnockRequestPresentable( + eventId: EventId = EventId("\$eventId"), + userId: UserId = UserId("@jacob_ross:example.com"), + displayName: String? = "Jacob Ross", + avatarUrl: String? = null, + reason: String? = "Hi, I would like to get access to this room please.", + formattedDate: String? = "20 Nov 2024", +) = object : KnockRequestPresentable { + override val eventId: EventId = eventId + override val userId: UserId = userId + override val displayName: String? = displayName + override val avatarUrl: String? = avatarUrl + override val reason: String? = reason + override val formattedDate: String? = formattedDate +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPermissions.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPermissions.kt new file mode 100644 index 0000000..2ca4d4d --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPermissions.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.data + +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.powerlevels.canBan +import io.element.android.libraries.matrix.api.room.powerlevels.canInvite +import io.element.android.libraries.matrix.api.room.powerlevels.canKick +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +data class KnockRequestPermissions( + val canAccept: Boolean, + val canDecline: Boolean, + val canBan: Boolean, +) { + val canHandle = canAccept || canDecline || canBan +} + +fun JoinedRoom.knockRequestPermissionsFlow(): Flow { + return syncUpdateFlow.map { + val canAccept = canInvite().getOrDefault(false) + val canDecline = canKick().getOrDefault(false) + val canBan = canBan().getOrDefault(false) + KnockRequestPermissions(canAccept, canDecline, canBan) + } +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPresentable.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPresentable.kt new file mode 100644 index 0000000..dc6aeb8 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPresentable.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.data + +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.EventId +import io.element.android.libraries.matrix.api.core.UserId + +@Immutable +interface KnockRequestPresentable { + val eventId: EventId + val userId: UserId + val displayName: String? + val avatarUrl: String? + val reason: String? + val formattedDate: String? + + fun getAvatarData(size: AvatarSize) = AvatarData( + id = userId.value, + name = displayName, + url = avatarUrl, + size = size, + ) + + fun getBestName(): String { + return displayName?.takeIf { it.isNotEmpty() } ?: userId.value + } +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestWrapper.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestWrapper.kt new file mode 100644 index 0000000..15af1b2 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestWrapper.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.data + +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.knock.KnockRequest + +class KnockRequestWrapper( + private val inner: KnockRequest, + dateFormatter: (Long?) -> String? = { null } +) : KnockRequestPresentable { + override val eventId: EventId = inner.eventId + override val userId: UserId = inner.userId + override val displayName: String? = inner.displayName + override val avatarUrl: String? = inner.avatarUrl + override val reason: String? = inner.reason?.trim() + override val formattedDate: String? = dateFormatter(inner.timestamp) + + val isSeen: Boolean = inner.isSeen + + suspend fun accept(): Result = inner.accept() + + suspend fun decline(reason: String?): Result = inner.decline(reason) + + suspend fun declineAndBan(reason: String?): Result = inner.declineAndBan(reason) + + suspend fun markAsSeen(): Result = inner.markAsSeen() +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsException.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsException.kt new file mode 100644 index 0000000..059b128 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsException.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.data + +sealed class KnockRequestsException : Exception() { + data object AcceptAllPartiallyFailed : KnockRequestsException() + data object KnockRequestNotFound : KnockRequestsException() +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt new file mode 100644 index 0000000..eeba54f --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.data + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.room.JoinedRoom + +@BindingContainer +@ContributesTo(RoomScope::class) +object KnockRequestsModule { + @Provides + @SingleIn(RoomScope::class) + fun knockRequestsService(room: JoinedRoom, featureFlagService: FeatureFlagService): KnockRequestsService { + return KnockRequestsService( + knockRequestsFlow = room.knockRequestsFlow, + permissionsFlow = room.knockRequestPermissionsFlow(), + isKnockFeatureEnabledFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock), + coroutineScope = room.roomCoroutineScope + ) + } +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt new file mode 100644 index 0000000..04c2f7b --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.data + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.knock.KnockRequest +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.supervisorScope + +class KnockRequestsService( + knockRequestsFlow: Flow>, + permissionsFlow: Flow, + isKnockFeatureEnabledFlow: Flow, + coroutineScope: CoroutineScope, +) { + // Keep track of the knock requests that have been handled, so we don't have to wait for sync to remove them. + private val handledKnockRequestIds = MutableStateFlow>(emptySet()) + + val knockRequestsFlow = combine( + isKnockFeatureEnabledFlow, + knockRequestsFlow, + handledKnockRequestIds, + ) { isKnockEnabled, knockRequests, handledKnockIds -> + if (!isKnockEnabled) { + AsyncData.Success(persistentListOf()) + } else { + val presentableKnockRequests = knockRequests + .filter { it.eventId !in handledKnockIds } + .map { inner -> KnockRequestWrapper(inner) } + .toImmutableList() + AsyncData.Success(presentableKnockRequests) + } + }.stateIn(coroutineScope, SharingStarted.Lazily, AsyncData.Loading()) + + val permissionsFlow = permissionsFlow.stateIn( + scope = coroutineScope, + started = SharingStarted.Lazily, + initialValue = KnockRequestPermissions(canAccept = false, canDecline = false, canBan = false) + ) + + private fun knockRequestsList() = knockRequestsFlow.value.dataOrNull().orEmpty() + + private fun getKnockRequestById(eventId: EventId): KnockRequestWrapper? { + return knockRequestsList().find { it.eventId == eventId } + } + + /** + * Accept a knock request. + * @param knockRequest The knock request to accept. + * @param optimistic If true, the request will be marked as handled before the server responds. + */ + suspend fun acceptKnockRequest(knockRequest: KnockRequestPresentable, optimistic: Boolean = false): Result { + val wrapped = getKnockRequestById(knockRequest.eventId) ?: return knockRequestNotFoundResult() + return handleKnockRequest(wrapped, optimistic) { accept() } + } + + /** + * Decline a knock request. + * @param knockRequest The knock request to decline. + * @param optimistic If true, the request will be marked as handled before the server responds. + */ + suspend fun declineKnockRequest(knockRequest: KnockRequestPresentable, optimistic: Boolean = false): Result { + val wrapped = getKnockRequestById(knockRequest.eventId) ?: return knockRequestNotFoundResult() + return handleKnockRequest(wrapped, optimistic) { decline(null) } + } + + /** + * Decline a knock request by banning the user. + * @param knockRequest The knock request to decline. + * @param optimistic If true, the request will be marked as handled before the server responds. + */ + suspend fun declineAndBanKnockRequest(knockRequest: KnockRequestPresentable, optimistic: Boolean = false): Result { + val wrapped = getKnockRequestById(knockRequest.eventId) ?: return knockRequestNotFoundResult() + return handleKnockRequest(wrapped, optimistic) { declineAndBan(null) } + } + + /** + * Accept all currently known knock requests. + * @param optimistic If true, the requests will be marked as handled before the server responds. + */ + suspend fun acceptAllKnockRequests(optimistic: Boolean = false): Result = supervisorScope { + val results = knockRequestsList() + .map { knockRequest -> + async { + acceptKnockRequest(knockRequest, optimistic = optimistic) + } + } + .awaitAll() + if (results.all { it.isSuccess }) { + Result.success(Unit) + } else { + Result.failure(KnockRequestsException.AcceptAllPartiallyFailed) + } + } + + /** + * Mark all currently known knock requests as seen. + */ + suspend fun markAllKnockRequestsAsSeen() = supervisorScope { + knockRequestsList() + .map { knockRequest -> + async { knockRequest.markAsSeen() } + } + .awaitAll() + } + + private suspend fun handleKnockRequest( + knockRequest: KnockRequestWrapper, + optimistic: Boolean, + action: suspend (KnockRequestWrapper.() -> Result) + ): Result { + if (optimistic) { + handledKnockRequestIds.getAndUpdate { it + knockRequest.eventId } + } + return action(knockRequest) + .onFailure { + if (optimistic) { + handledKnockRequestIds.getAndUpdate { it - knockRequest.eventId } + } + } + .onSuccess { + if (!optimistic) { + handledKnockRequestIds.getAndUpdate { it + knockRequest.eventId } + } + } + } +} + +private fun knockRequestNotFoundResult() = Result.failure(KnockRequestsException.KnockRequestNotFound) diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/DefaultKnockRequestsListEntryPoint.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/DefaultKnockRequestsListEntryPoint.kt new file mode 100644 index 0000000..74a8b71 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/DefaultKnockRequestsListEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.list + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultKnockRequestsListEntryPoint : KnockRequestsListEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext): Node { + return parentNode.createNode(buildContext) + } +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListEvents.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListEvents.kt new file mode 100644 index 0000000..f1350b1 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListEvents.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.list + +import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable + +sealed interface KnockRequestsListEvents { + data class Accept(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents + data class Decline(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents + data class DeclineAndBan(val knockRequest: KnockRequestPresentable) : KnockRequestsListEvents + data object AcceptAll : KnockRequestsListEvents + data object ResetCurrentAction : KnockRequestsListEvents + data object RetryCurrentAction : KnockRequestsListEvents + data object ConfirmCurrentAction : KnockRequestsListEvents +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListNode.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListNode.kt new file mode 100644 index 0000000..f3c8b06 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListNode.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.list + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.di.RoomScope + +@ContributesNode(RoomScope::class) +@AssistedInject +class KnockRequestsListNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: KnockRequestsListPresenter, +) : Node(buildContext, plugins = plugins) { + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + KnockRequestsListView( + state = state, + onBackClick = ::navigateUp, + modifier = modifier + ) + } +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenter.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenter.kt new file mode 100644 index 0000000..a56dc40 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenter.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.list + +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 dev.zacsweers.metro.Inject +import io.element.android.features.knockrequests.impl.data.KnockRequestsService +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Inject +class KnockRequestsListPresenter( + private val knockRequestsService: KnockRequestsService, +) : Presenter { + @Composable + override fun present(): KnockRequestsListState { + val asyncAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + var currentAction by remember { mutableStateOf(KnockRequestsAction.None) } + + val permissions by knockRequestsService.permissionsFlow.collectAsState() + val knockRequests by knockRequestsService.knockRequestsFlow.collectAsState() + + val coroutineScope = rememberCoroutineScope() + + fun handleEvent(event: KnockRequestsListEvents) { + when (event) { + KnockRequestsListEvents.AcceptAll -> { + currentAction = KnockRequestsAction.AcceptAll + } + is KnockRequestsListEvents.Accept -> { + currentAction = KnockRequestsAction.Accept(event.knockRequest) + } + is KnockRequestsListEvents.Decline -> { + currentAction = KnockRequestsAction.Decline(event.knockRequest) + } + is KnockRequestsListEvents.DeclineAndBan -> { + currentAction = KnockRequestsAction.DeclineAndBan(event.knockRequest) + } + KnockRequestsListEvents.ResetCurrentAction -> { + asyncAction.value = AsyncAction.Uninitialized + currentAction = KnockRequestsAction.None + } + KnockRequestsListEvents.RetryCurrentAction -> { + coroutineScope.executeAction(currentAction, asyncAction, isActionConfirmed = true) + } + KnockRequestsListEvents.ConfirmCurrentAction -> { + coroutineScope.executeAction(currentAction, asyncAction, isActionConfirmed = true) + } + } + } + LaunchedEffect(currentAction) { + executeAction(currentAction, asyncAction, isActionConfirmed = false) + } + + return KnockRequestsListState( + knockRequests = knockRequests, + currentAction = currentAction, + permissions = permissions, + asyncAction = asyncAction.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.executeAction( + currentAction: KnockRequestsAction, + asyncAction: MutableState>, + isActionConfirmed: Boolean, + ) = launch { + when (currentAction) { + is KnockRequestsAction.Accept -> { + runUpdatingState(asyncAction) { + knockRequestsService.acceptKnockRequest(currentAction.knockRequest) + } + } + is KnockRequestsAction.Decline -> { + if (isActionConfirmed) { + runUpdatingState(asyncAction) { + knockRequestsService.declineKnockRequest(currentAction.knockRequest) + } + } else { + asyncAction.value = AsyncAction.ConfirmingNoParams + } + } + is KnockRequestsAction.DeclineAndBan -> { + if (isActionConfirmed) { + runUpdatingState(asyncAction) { + knockRequestsService.declineAndBanKnockRequest(currentAction.knockRequest) + } + } else { + asyncAction.value = AsyncAction.ConfirmingNoParams + } + } + is KnockRequestsAction.AcceptAll -> { + if (isActionConfirmed) { + runUpdatingState(asyncAction) { + knockRequestsService.acceptAllKnockRequests() + } + } else { + asyncAction.value = AsyncAction.ConfirmingNoParams + } + } + KnockRequestsAction.None -> Unit + } + } +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt new file mode 100644 index 0000000..a1bb90c --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.list + +import androidx.compose.runtime.Immutable +import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions +import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import kotlinx.collections.immutable.ImmutableList + +data class KnockRequestsListState( + val knockRequests: AsyncData>, + val currentAction: KnockRequestsAction, + val asyncAction: AsyncAction, + val permissions: KnockRequestPermissions, + val eventSink: (KnockRequestsListEvents) -> Unit, +) { + val canAcceptAll = permissions.canAccept && knockRequests is AsyncData.Success && knockRequests.data.size > 1 +} + +@Immutable +sealed interface KnockRequestsAction { + data object None : KnockRequestsAction + data class Accept(val knockRequest: KnockRequestPresentable) : KnockRequestsAction + data class Decline(val knockRequest: KnockRequestPresentable) : KnockRequestsAction + data class DeclineAndBan(val knockRequest: KnockRequestPresentable) : KnockRequestsAction + data object AcceptAll : KnockRequestsAction +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt new file mode 100644 index 0000000..2c4c92a --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.list + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions +import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable +import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +open class KnockRequestsListStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aKnockRequestsListState( + knockRequests = AsyncData.Loading(), + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf() + ), + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf( + aKnockRequestPresentable() + ) + ), + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf( + aKnockRequestPresentable( + reason = "A very long reason that should probably be truncated, " + + "but could be also expanded so you can see it over the lines, wow," + + "very amazing reason, I know, right, I'm so good at writing reasons." + ) + ) + ), + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf( + aKnockRequestPresentable(), + aKnockRequestPresentable( + userId = UserId("@user:example.com"), + displayName = null, + avatarUrl = null, + reason = null, + ) + ) + ), + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf( + aKnockRequestPresentable() + ) + ), + currentAction = KnockRequestsAction.AcceptAll, + asyncAction = AsyncAction.ConfirmingNoParams, + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf( + aKnockRequestPresentable() + ) + ), + currentAction = KnockRequestsAction.AcceptAll, + asyncAction = AsyncAction.Loading, + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf( + aKnockRequestPresentable() + ) + ), + permissions = KnockRequestPermissions( + canAccept = false, + canDecline = true, + canBan = true, + ), + currentAction = KnockRequestsAction.AcceptAll, + asyncAction = AsyncAction.Failure(RuntimeException("Failed to accept all")), + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf( + aKnockRequestPresentable() + ) + ), + permissions = KnockRequestPermissions( + canAccept = true, + canDecline = false, + canBan = true, + ), + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf( + aKnockRequestPresentable() + ) + ), + permissions = KnockRequestPermissions( + canAccept = false, + canDecline = false, + canBan = true, + ), + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf( + aKnockRequestPresentable() + ) + ), + permissions = KnockRequestPermissions( + canAccept = true, + canDecline = true, + canBan = false, + ), + ), + ) +} + +fun aKnockRequestsListState( + knockRequests: AsyncData> = AsyncData.Success(persistentListOf()), + currentAction: KnockRequestsAction = KnockRequestsAction.None, + asyncAction: AsyncAction = AsyncAction.Uninitialized, + permissions: KnockRequestPermissions = KnockRequestPermissions( + canAccept = true, + canDecline = true, + canBan = true, + ), + eventSink: (KnockRequestsListEvents) -> Unit = {}, +) = KnockRequestsListState( + knockRequests = knockRequests, + currentAction = currentAction, + asyncAction = asyncAction, + permissions = permissions, + eventSink = eventSink, +) diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt new file mode 100644 index 0000000..1ccc074 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt @@ -0,0 +1,496 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.list + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.spacedBy +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.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.width +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.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.draw.clipToBounds +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.knockrequests.impl.R +import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.toDp +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ButtonSize +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +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.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.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun KnockRequestsListView( + state: KnockRequestsListState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + KnockRequestsListTopBar(onBackClick = onBackClick) + }, + content = { padding -> + KnockRequestsListContent( + state = state, + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding), + ) + } + ) +} + +@Composable +private fun KnockRequestsListContent( + state: KnockRequestsListState, + modifier: Modifier = Modifier, +) { + fun onAcceptClick(knockRequest: KnockRequestPresentable) { + state.eventSink(KnockRequestsListEvents.Accept(knockRequest)) + } + + fun onDeclineClick(knockRequest: KnockRequestPresentable) { + state.eventSink(KnockRequestsListEvents.Decline(knockRequest)) + } + + fun onBanClick(knockRequest: KnockRequestPresentable) { + state.eventSink(KnockRequestsListEvents.DeclineAndBan(knockRequest)) + } + + var bottomPaddingInPixels by remember { mutableIntStateOf(0) } + + Box(modifier.fillMaxSize()) { + when (state.knockRequests) { + is AsyncData.Success -> { + val knockRequests = state.knockRequests.data + if (knockRequests.isEmpty()) { + KnockRequestsEmptyList() + } else { + KnockRequestsList( + knockRequests = knockRequests, + canAccept = state.permissions.canAccept, + canDecline = state.permissions.canDecline, + canBan = state.permissions.canBan, + onAcceptClick = ::onAcceptClick, + onDeclineClick = ::onDeclineClick, + onBanClick = ::onBanClick, + contentPadding = PaddingValues(bottom = bottomPaddingInPixels.toDp()), + ) + } + } + is AsyncData.Loading -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = spacedBy(16.dp), + modifier = Modifier.align(Alignment.Center) + ) { + CircularProgressIndicator(color = ElementTheme.colors.iconPrimary) + Text( + text = stringResource(R.string.screen_knock_requests_list_initial_loading_title), + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textPrimary, + ) + } + } + else -> Unit + } + KnockRequestsActionsView( + currentAction = state.currentAction, + asyncAction = state.asyncAction, + onConfirm = { + state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction) + }, + onRetry = { + state.eventSink(KnockRequestsListEvents.RetryCurrentAction) + }, + onDismiss = { + state.eventSink(KnockRequestsListEvents.ResetCurrentAction) + }, + ) + if (state.canAcceptAll) { + KnockRequestsAcceptAll( + onClick = { + state.eventSink(KnockRequestsListEvents.AcceptAll) + }, + onHeightChange = { height -> + bottomPaddingInPixels = height + }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + } +} + +@Composable +private fun KnockRequestsActionsView( + currentAction: KnockRequestsAction, + asyncAction: AsyncAction, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier) { + AsyncActionView( + async = asyncAction, + onSuccess = { onDismiss() }, + onErrorDismiss = onDismiss, + confirmationDialog = { + KnockRequestActionConfirmation( + currentAction = currentAction, + onSubmit = onConfirm, + onDismiss = onDismiss, + ) + }, + progressDialog = { + KnockRequestActionProgress(target = currentAction) + }, + errorMessage = { + when (currentAction) { + is KnockRequestsAction.Accept -> stringResource(R.string.screen_knock_requests_list_accept_failed_alert_description) + is KnockRequestsAction.Decline -> stringResource(R.string.screen_knock_requests_list_decline_failed_alert_description) + is KnockRequestsAction.DeclineAndBan -> stringResource(R.string.screen_knock_requests_list_decline_failed_alert_description) + KnockRequestsAction.AcceptAll -> stringResource(R.string.screen_knock_requests_list_accept_all_failed_alert_description) + else -> "" + } + }, + onRetry = onRetry, + ) + } +} + +@Composable +private fun KnockRequestActionConfirmation( + currentAction: KnockRequestsAction, + onSubmit: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val (title, content, submitText) = when (currentAction) { + KnockRequestsAction.AcceptAll -> Triple( + stringResource(R.string.screen_knock_requests_list_accept_all_alert_title), + stringResource(R.string.screen_knock_requests_list_accept_all_alert_description), + stringResource(R.string.screen_knock_requests_list_accept_all_alert_confirm_button_title), + ) + is KnockRequestsAction.Decline -> Triple( + stringResource(R.string.screen_knock_requests_list_decline_alert_title), + stringResource(R.string.screen_knock_requests_list_decline_alert_description, currentAction.knockRequest.getBestName()), + stringResource(R.string.screen_knock_requests_list_decline_alert_confirm_button_title), + ) + is KnockRequestsAction.DeclineAndBan -> Triple( + stringResource(R.string.screen_knock_requests_list_ban_alert_title), + stringResource(R.string.screen_knock_requests_list_ban_alert_description, currentAction.knockRequest.getBestName()), + stringResource(R.string.screen_knock_requests_list_ban_alert_confirm_button_title), + ) + else -> return + } + ConfirmationDialog( + title = title, + content = content, + submitText = submitText, + onSubmitClick = onSubmit, + onDismiss = onDismiss, + modifier = modifier, + ) +} + +@Composable +private fun KnockRequestActionProgress( + target: KnockRequestsAction, + modifier: Modifier = Modifier, +) { + val progressText = when (target) { + is KnockRequestsAction.Accept -> stringResource(R.string.screen_knock_requests_list_accept_loading_title) + is KnockRequestsAction.Decline -> stringResource(R.string.screen_knock_requests_list_decline_loading_title) + is KnockRequestsAction.DeclineAndBan -> stringResource(R.string.screen_knock_requests_list_ban_loading_title) + KnockRequestsAction.AcceptAll -> stringResource(R.string.screen_knock_requests_list_accept_all_loading_title) + else -> return + } + ProgressDialog( + text = progressText, + modifier = modifier, + ) +} + +@Composable +private fun KnockRequestsList( + knockRequests: ImmutableList, + canAccept: Boolean, + canDecline: Boolean, + canBan: Boolean, + onAcceptClick: (KnockRequestPresentable) -> Unit, + onDeclineClick: (KnockRequestPresentable) -> Unit, + onBanClick: (KnockRequestPresentable) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = contentPadding, + ) { + itemsIndexed(knockRequests) { index, knockRequest -> + KnockRequestItem( + knockRequest = knockRequest, + onAcceptClick = onAcceptClick, + canBan = canBan, + canDecline = canDecline, + canAccept = canAccept, + onDeclineClick = onDeclineClick, + onBanClick = onBanClick, + ) + if (index != knockRequests.size - 1) { + HorizontalDivider() + } + } + } +} + +@Composable +private fun KnockRequestItem( + knockRequest: KnockRequestPresentable, + canAccept: Boolean, + canDecline: Boolean, + canBan: Boolean, + onAcceptClick: (KnockRequestPresentable) -> Unit, + onDeclineClick: (KnockRequestPresentable) -> Unit, + onBanClick: (KnockRequestPresentable) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Avatar( + avatarData = knockRequest.getAvatarData(AvatarSize.KnockRequestItem), + avatarType = AvatarType.User, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + // Name and date + Row { + Text( + modifier = Modifier + .clipToBounds() + .weight(1f), + text = knockRequest.getBestName(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyLgMedium, + ) + val formattedDate = knockRequest.formattedDate + if (!formattedDate.isNullOrEmpty()) { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = formattedDate, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmRegular, + ) + } + } + // UserId + if (!knockRequest.displayName.isNullOrEmpty()) { + Text( + text = knockRequest.userId.value, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } + // Reason + val reason = knockRequest.reason + if (!reason.isNullOrBlank()) { + Spacer(modifier = Modifier.height(12.dp)) + var isExpanded by rememberSaveable(knockRequest.userId) { mutableStateOf(false) } + var isExpandable by rememberSaveable(knockRequest.userId) { mutableStateOf(false) } + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier + .animateContentSize() + .clickable(enabled = isExpandable) { isExpanded = !isExpanded } + ) { + Text( + text = reason, + style = ElementTheme.typography.fontBodyMdRegular, + maxLines = if (isExpanded) Int.MAX_VALUE else 3, + onTextLayout = { result -> + if (!isExpanded && result.hasVisualOverflow) { + isExpandable = true + } + }, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Box(modifier = Modifier.size(24.dp)) { + if (isExpandable) { + Icon( + imageVector = if (isExpanded) CompoundIcons.ChevronUp() else CompoundIcons.ChevronDown(), + contentDescription = null, + tint = ElementTheme.colors.iconTertiary, + ) + } + } + } + } + // Actions + if (canDecline || canAccept) { + Spacer(modifier = Modifier.height(12.dp)) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) { + if (canDecline) { + OutlinedButton( + text = stringResource(CommonStrings.action_decline), + onClick = { + onDeclineClick(knockRequest) + }, + size = ButtonSize.MediumLowPadding, + modifier = Modifier.weight(1f), + ) + } + if (canAccept) { + Button( + text = stringResource(CommonStrings.action_accept), + onClick = { + onAcceptClick(knockRequest) + }, + size = ButtonSize.MediumLowPadding, + modifier = Modifier.weight(1f), + ) + } + } + if (canBan) { + Spacer(modifier = Modifier.height(12.dp)) + TextButton( + text = stringResource(R.string.screen_knock_requests_list_decline_and_ban_action_title), + onClick = { + onBanClick(knockRequest) + }, + destructive = true, + size = ButtonSize.Small, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +@Composable +private fun KnockRequestsAcceptAll( + onClick: () -> Unit, + onHeightChange: (Int) -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .shadow(elevation = 24.dp, spotColor = Color.Transparent) + .background(color = ElementTheme.colors.bgCanvasDefault) + .padding(vertical = 12.dp, horizontal = 16.dp) + .onSizeChanged { onHeightChange(it.height) } + ) { + OutlinedButton( + text = stringResource(R.string.screen_knock_requests_list_accept_all_button_title), + onClick = onClick, + size = ButtonSize.Medium, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun KnockRequestsEmptyList( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.padding( + horizontal = 32.dp, + vertical = 48.dp, + ), + contentAlignment = Alignment.Center, + ) { + IconTitleSubtitleMolecule( + title = stringResource(R.string.screen_knock_requests_list_empty_state_title), + subTitle = stringResource(R.string.screen_knock_requests_list_empty_state_description), + iconStyle = BigIcon.Style.Default(CompoundIcons.AskToJoin()), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun KnockRequestsListTopBar(onBackClick: () -> Unit) { + TopAppBar( + titleStr = stringResource(R.string.screen_knock_requests_list_title), + navigationIcon = { BackButton(onClick = onBackClick) }, + ) +} + +@PreviewsDayNight +@Composable +internal fun KnockRequestsListViewPreview( + @PreviewParameter(KnockRequestsListStateProvider::class) state: KnockRequestsListState +) = ElementPreview { + KnockRequestsListView( + state = state, + onBackClick = {}, + ) +} diff --git a/features/knockrequests/impl/src/main/res/values-be/translations.xml b/features/knockrequests/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..e6f08bc --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,4 @@ + + + "Прыняць" + diff --git a/features/knockrequests/impl/src/main/res/values-bg/translations.xml b/features/knockrequests/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..108ffb1 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,4 @@ + + + "Приемане" + diff --git a/features/knockrequests/impl/src/main/res/values-cs/translations.xml b/features/knockrequests/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..c6aa055 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,37 @@ + + + "Ano, přijmout všechny" + "Opravdu chcete přijmout všechny žádosti o vstup?" + "Přijmout všechny požadavky" + "Přijmout vše" + "Nemohli jsme přijmout všechny žádosti. Chcete to zkusit znovu?" + "Nepodařilo se přijmout všechny žádosti" + "Přijímání všech žádostí o vstup" + "Tuto žádost jsme nemohli přijmout. Chcete to zkusit znovu?" + "Žádost se nepodařilo přijmout" + "Přijímání žádosti o vstup" + "Ano, odmítnout a vykázat" + "Opravdu chcete odmítnout a vykázat %1$s? Tento uživatel nebude moci znovu požádat o vstup do této místnosti." + "Odmítnout a zakázat vstup" + "Odmítání vstupu a vykázání" + "Ano, odmítnout" + "Opravdu chcete odmítnout %1$s žádost o vstup do této místnosti?" + "Odmítnout vstup" + "Odmítnout a vykázat" + "Tuto žádost jsme nemohli odmítnout. Chcete to zkusit znovu?" + "Žádost se nepodařilo odmítnout" + "Odmítání žádosti o vstup" + "Když někdo požádá o vstup do místnosti, uvidíte jeho žádost zde." + "Žádná čekající žádost o vstup" + "Načítání žádostí o vstup…" + "Žádosti o vstup" + + "%1$s +%2$d další chce vstoupit do této místnosti" + "%1$s +%2$d další chtějí vstoupit do této místnosti" + "%1$s +%2$d dalších chce vstoupit do této místnosti" + + "Zobrazit vše" + "Přijmout" + "%1$s chce vstoupit do této místnosti" + "Zobrazit" + diff --git a/features/knockrequests/impl/src/main/res/values-cy/translations.xml b/features/knockrequests/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..8257ae8 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,40 @@ + + + "Iawn, derbyn y cyfan" + "Ydych chi\'n siŵr eich bod am dderbyn pob cais i ymuno?" + "Derbyn pob cais" + "Derbyn y cyfan" + "Doedd dim modd derbyn pob cais. Hoffech chi roi cynnig arall arni?" + "Wedi methu derbyn pob cais" + "Yn derbyn pob cais i ymuno" + "Doedd dim modd derbyn y cais hwn. Hoffech chi roi cynnig arall arni?" + "Wedi methu â derbyn y cais" + "Yn derbyn cais i ymuno" + "Iawn, gwrthod a gwahardd" + "Ydych chi\'n siŵr eich bod am wrthod a gwahardd %1$s? Bydd y defnyddiwr hwn ddim yn gallu gofyn am fynediad i ymuno â\'r ystafell hon eto." + "Gwrthod a gwahardd mynediad" + "Yn gwrthod a gwahardd mynediad" + "Iawn, gwrthod" + "Ydych chi\'n siŵr eich bod am wrthod %1$s cais i ymuno â\'r ystafell hon?" + "Gwrthod mynediad" + "Gwrthod a gwahardd" + "Doedd dim modd i ni wrthod y cais hwn. Hoffech chi roi cynnig arall arni?" + "Wedi methu â gwrthod y cais" + "Yn gwrthod cais i ymuno" + "Pan fydd rhywun yn gofyn am gael ymuno â\'r ystafell, byddwch yn gallu gweld eu cais yma." + "Dim cais i ymuno yn disgwyl" + "Yn llwytho ceisiadau i ymuno…" + "Ceisiadau i ymuno" + + "Dyw %1$s na +%2$d arall eisiau ymuno â\'r ystafell hon" + "Mae %1$s +%2$d arall eisiau ymuno â\'r ystafell hon" + "Mae %1$s +%2$d arall eisiau ymuno â\'r ystafell hon" + "Mae %1$s +%2$d arall eisiau ymuno â\'r ystafell hon" + "Mae %1$s +%2$d arall eisiau ymuno â\'r ystafell hon" + "Mae %1$s +%2$d arall eisiau ymuno â\'r ystafell hon" + + "Gweld y cyfan" + "Derbyn" + "Mae %1$s eisiau ymuno â\'r ystafell hon" + "Golwg" + diff --git a/features/knockrequests/impl/src/main/res/values-da/translations.xml b/features/knockrequests/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..795cda6 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,36 @@ + + + "Ja, acceptér alle" + "Er du sikker på, at du vil acceptere alle anmodninger om at deltage?" + "Acceptér alle anmodninger" + "Acceptér alle" + "Vi kunne ikke acceptere alle anmodninger. Vil du prøve igen?" + "Kunne ikke acceptere alle anmodninger" + "Accepterer alle anmodninger om at deltage" + "Vi kunne ikke acceptere denne anmodning. Vil du prøve igen?" + "Kunne ikke acceptere anmodningen" + "Accepterer anmodning om at deltage" + "Ja, afvis og spær" + "Er du sikker på, at du vil afvise og spærre %1$s? Denne bruger vil ikke kunne anmode om adgang til at deltage i dette rum igen." + "Afvis og spær for deres adgang til rummet" + "Afviser og spærrer for adgang" + "Ja, afvis" + "Er du sikker på, at du vil afvise %1$ss anmodning om at deltage i dette rum?" + "Afvis adgang" + "Afvis og spær" + "Vi kunne ikke afvise denne anmodning. Vil du prøve igen?" + "Anmodningen kunne ikke afvises" + "Afviser anmodning om at deltage" + "Når nogen beder om at deltage i rummet, kan du se deres anmodning her." + "Ingen ventende anmodning om at deltage" + "Indlæser anmodninger om at deltage…" + "Anmodninger om at deltage" + + "%1$s + %2$d anden ønsker at deltage i dette rum" + "%1$s + %2$d andre ønsker at deltage i dette rum" + + "Se alle" + "Accepter" + "%1$s ønsker at deltage i dette rum" + "Vis" + diff --git a/features/knockrequests/impl/src/main/res/values-de/translations.xml b/features/knockrequests/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..33e9e1b --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,36 @@ + + + "Ja, akzeptiere alle" + "Bist du sicher, dass du alle Beitrittsanfragen akzeptieren möchtest?" + "Akzeptiere alle Beitrittsanfragen" + "Alle akzeptieren" + "Wir konnten nicht alle Beitrittsanfragen annehmen. Möchtest du es noch mal versuchen?" + "Es konnten nicht alle Beitrittsanfragen akzeptiert werden" + "Alle Beitrittsanfragen werden angenommen" + "Wir konnten diese Beitrittsanfrage nicht annehmen. Möchtest du es noch mal versuchen?" + "Die Beitrittsanfrage konnte nicht akzeptiert werden" + "Beitrittsanfrage annehmen" + "Ja, ablehnen und sperren" + "Bist du sicher, dass du %1$s ablehnen und sperren möchtest? Dieser Nutzer kann dann keinen erneuten Beitritt zu diesem Chat anfragen." + "Ablehnen und Zugriff sperren" + "Ablehnung und Sperrung des Zugriffs" + "Ja, ablehnen" + "Bist du sicher, dass du die Beitrittsanfrage von %1$s zu diesem Chat ablehnen möchtest?" + "Zugriff ablehnen" + "Ablehnen und sperren" + "Wir konnten diese Beitrittsanfrage nicht ablehnen. Möchtest du es noch mal versuchen?" + "Beitrittsanfrage konnte nicht abgelehnt werden" + "Ablehnung der Beitrittsanfrage" + "Sollte jemand um Beitritt zum Chat bitten, kannst du die Anfrage hier sehen." + "Keine ausstehende Beitrittsanfrage" + "Beitrittsanfragen werden geladen …" + "Beitrittsanfragen" + + "%1$s +%2$d weiterer möchten diesem Chat beitreten" + "%1$s +%2$d weitere möchten diesem Chat beitreten" + + "Alles ansehen" + "Akzeptieren" + "%1$s möchte diesem Chat beitreten" + "Ansicht" + diff --git a/features/knockrequests/impl/src/main/res/values-el/translations.xml b/features/knockrequests/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..5c8bec1 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,35 @@ + + + "Ναι, αποδοχή όλων" + "Σίγουρα θες να αποδεχτείς όλα τα αιτήματα συμμετοχής;" + "Αποδοχή όλων των αιτημάτων" + "Αποδοχή όλων" + "Δεν μπορέσαμε να δεχτούμε όλα τα αιτήματα. Θες να προσπαθήσεις ξανά;" + "Αποτυχία αποδοχής όλων των αιτημάτων" + "Αποδοχή όλων των αιτημάτων συμμετοχής" + "Δεν μπορέσαμε να δεχτούμε αυτό το αίτημα. Θα θέλατε να προσπαθήσετε ξανά;" + "Αποτυχία αποδοχής αιτήματος" + "Γίνεται αποδοχή αιτήματος συμμετοχής" + "Ναι, απόρριψη και αποκλεισμός" + "Είστε σίγουροι ότι θέλετε να απορρίψετε και να αποκλείσετε το χρήστη %1$s; Αυτός ο χρήστης δεν θα μπορέσει να ζητήσει ξανά πρόσβαση για να συμμετάσχει σε αυτή την αίθουσα." + "Απόρριψη και αποκλεισμός πρόσβασης" + "Γίνεται απόρριψη και αποκλεισμός πρόσβασης" + "Ναι, απόρριψη" + "Είστε σίγουροι ότι θέλετε να απορρίψετε το αίτημα του %1$s να συμμετάσχετε σε αυτήν την αίθουσα;" + "Απόρριψη πρόσβασης" + "Απόρριψη και αποκλεισμός" + "Δεν μπορέσαμε να απορρίψουμε αυτό το αίτημα. Θα θέλατε να προσπαθήσετε ξανά;" + "Απέτυχε η απόρριψη του αιτήματος" + "Γίνεται απόρριψη αιτήματος συμμετοχής" + "Όταν κάποιος θα ζητήσει να συμμετάσχει στην αίθουσα, θα μπορείτε να δείτε το αίτημά του εδώ." + "Δεν υπάρχει εκκρεμές αίτημα συμμετοχής" + "Φόρτωση αιτημάτων συμμετοχής…" + "Αιτήματα συμμετοχής" + + "%1$s +%2$d άλλοι θέλουν να συμμετάσχουν σε αυτή την αίθουσα" + "%1$s +%2$d άλλοι θέλουν να συμμετάσχουν σε αυτή την αίθουσα" + + "Προβολή όλων" + "Αποδοχή" + "%1$s θέλει να συμμετάσχει σε αυτή την αίθουσα" + diff --git a/features/knockrequests/impl/src/main/res/values-es/translations.xml b/features/knockrequests/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..8f9ab72 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,35 @@ + + + "Sí, aceptar todas" + "¿Estás seguro de que quieres aceptar todas las solicitudes de unión?" + "Aceptar todas las solicitudes" + "Aceptar todas" + "No pudimos aceptar todas las solicitudes. ¿Quieres volver a intentarlo?" + "No se pudieron aceptar todas las solicitudes" + "Aceptando todas las solicitudes de unión" + "No pudimos aceptar esta solicitud. ¿Quieres volver a intentarlo?" + "No se pudo aceptar la solicitud" + "Aceptando solicitud de unión" + "Sí, rechazar y vetar" + "¿Estás seguro de que quieres rechazar y vetar a %1$s? Este usuario no podrá volver a solicitar acceso para unirse a esta sala." + "Rechazar y vetar el acceso" + "Rechazando y vetando acceso" + "Sí, rechazar" + "¿Estás seguro de que deseas rechazar la solicitud de %1$s para unirse a esta sala?" + "Rechazar el acceso" + "Rechazar y vetar" + "No pudimos rechazar esta solicitud. ¿Quieres volver a intentarlo?" + "No se pudo rechazar la solicitud" + "Rechazando solicitud de unión" + "Cuando alguien pida unirse a la sala, podrás ver su solicitud aquí." + "No hay ninguna solicitud de unión pendiente" + "Cargando solicitudes de unión…" + "Solicitudes de unión" + + "%1$s y %2$d más quieren unirse a esta sala" + "%1$s y otros %2$d más quieren unirse a esta sala" + + "Ver todo" + "Aceptar" + "%1$s quiere unirse a esta sala" + diff --git a/features/knockrequests/impl/src/main/res/values-et/translations.xml b/features/knockrequests/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..8ecaa7c --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,36 @@ + + + "Jah, võta kõik vastu" + "Kas sa oled kindel, et soovid kõik vastu liitumist soovinud võtta?" + "Võta kõik vastu" + "Nõustu kõigiga" + "Kõikide päringutega nõustumine polnud võimalik. Kas sa sooviksid uuesti proovida?" + "Kõikide liitumispalvetega nõustumine ei õnnestunud" + "Nõustume kõikide liitumispalvetega" + "Selle liitumispalvega nõustumine ei õnnestunud. Kas sa sooviksid uuesti proovida?" + "Liitumispalvega nõustumine ei õnnestunud" + "Nõustume liitumispalvega" + "Jah, keeldu liitumisest ning keela ligipääs" + "Kas sa oled kindel, et soovid kasutajale %1$s keelata ligipääsu siia jututuppa ning seada talle suhtluskeelu? Seetõttu ta ei saa ka enam hiljem liitumispalvet saata." + "Keeldu liitumisest ja keela ligipääs" + "Keeldume liitumispalvest ja seame suhtluskeelu" + "Jah, keeldu" + "Kas sa oled kindel, et soovid kasutajale %1$s keelata ligipääsu siia jututuppa?" + "Keela ligipääs" + "Keeldu ja määra suhtluskeeld" + "Selle liitumispalvest tagasilükkamine ei õnnestunud. Kas sa sooviksid uuesti proovida?" + "Liitumispalvest tagasilükkamine ei õnnestunud" + "Lükkame liitumispalve tagasi" + "Kui keegi soovib jututoaga liituda, siis need päringud on kuvatud siin." + "Pole ühtegi liitumispalvet" + "Laadime liitumispalveid…" + "Liitumispalved" + + "%1$s + veel %2$d kasutaja soovivad selle jututoaga liituda" + "%1$s + veel %2$d kasutajat soovivad selle jututoaga liituda" + + "Vaata kõiki" + "Nõustu" + "%1$s soovib selle jututoaga liituda" + "Vaata" + diff --git a/features/knockrequests/impl/src/main/res/values-eu/translations.xml b/features/knockrequests/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..5f45850 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,19 @@ + + + "Bai, onartu guztiak" + "Onartu eskaera guztiak" + "Onartu guztiak" + "Bai, ukatu eta ezarri debekua" + "Bai, ukatu" + "Ukatu sarbidea" + "Ukatu eta ezarri debekua" + "Sartzeko eskaerak" + + "%1$s + beste %2$d gelara batu nahi dute" + "%1$s + beste %2$d gelara batu nahi dute" + + "Ikusi guztiak" + "Onartu" + "%1$s(e)k gela honetara sartu nahi du" + "Ikusi" + diff --git a/features/knockrequests/impl/src/main/res/values-fa/translations.xml b/features/knockrequests/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..5b931e4 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,23 @@ + + + "بله. پذیرش همه" + "پذیرش همهٔ درخواست‌ها" + "پذیرش همه" + "شکست در پذیرش همهٔ درخواست‌ها" + "پذیرفتن همهٔ درخواست‌های پیوستن" + "شکست در پذیرش درخواست" + "پذیرفتن درخواست پیوستن" + "بله. رد و انسداد" + "رد و تحریم دسترسی" + "رد کردن و تحریم دسترسی" + "بله. رد شود" + "رد کردن دسترسی" + "رد و تحریم" + "شکتس در رد درخواست" + "در کردن درخواست پیوستن" + "درخواست‌های پیوستن" + "دیدن همه" + "پذیرش" + "%1$s می‌خواهد به این اتاق بپیوندد" + "نما" + diff --git a/features/knockrequests/impl/src/main/res/values-fi/translations.xml b/features/knockrequests/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..d192e71 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,36 @@ + + + "Kyllä, hyväksy kaikki" + "Haluatko varmasti hyväksyä kaikki liittymispyynnöt?" + "Hyväksy kaikki pyynnöt" + "Hyväksy kaikki" + "Emme voineet hyväksyä kaikkia pyyntöjä. Haluaisitko yrittää uudelleen?" + "Kaikkien pyyntöjen hyväksyminen epäonnistui" + "Hyväksytään kaikkia liittymispyyntöjä" + "Emme voineet hyväksyä tätä pyyntöä. Haluaisitko yrittää uudelleen?" + "Pyynnön hyväksyminen epäonnistui" + "Hyväksytään liittymispyyntöä" + "Kyllä, hylkää ja anna porttikielto" + "Haluatko varmasti hylätä käyttäjän %1$s pyynnön liittyä huoneeseen ja antaa hänelle porttikiellon? Hän ei voi enää pyytää lupaa liittyä tähän huoneeseen." + "Hylkää ja anna porttikielto" + "Hylätään pyyntöä ja annetaan porttikieltoa" + "Kyllä, hylkää" + "Haluatko varmasti hylätä käyttäjän %1$s pyynnön liittyä tähän huoneeseen?" + "Hylkää pyyntö" + "Hylkää ja anna porttikielto" + "Emme voineet hylätä tätä pyyntöä. Haluaisitko yrittää uudelleen?" + "Pyynnön hylkääminen epäonnistui" + "Hylätään liittymispyyntöä" + "Kun joku pyytää liittyä huoneeseen, näet hänen pyyntönsä täällä." + "Ei odottavia liittymispyyntöjä" + "Ladataan liittymispyyntöjä…" + "Liittymispyynnöt" + + "%1$s +%2$d muu haluavat liittyä tähän huoneeseen" + "%1$s +%2$d muuta haluavat liittyä tähän huoneeseen" + + "Näytä kaikki" + "Hyväksy" + "%1$s haluaa liittyä tähän huoneeseen" + "Näytä" + diff --git a/features/knockrequests/impl/src/main/res/values-fr/translations.xml b/features/knockrequests/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..85da9d9 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,36 @@ + + + "Oui, tout accepter" + "Êtes-vous sûr de vouloir accepter toutes les demandes pour rejoindre le salon ?" + "Tout accepter" + "Tout accepter" + "Toutes les demandes n’ont pas pu être acceptées. Voulez-vous réessayer ?" + "Toutes les demandes n’ont pas été acceptées" + "Accepter toutes les demandes à rejoindre" + "La demande n’a pas pu être acceptée. Voulez-vous réessayer ?" + "Impossible d’accepter la demande" + "Accepter la demande à rejoindre" + "Oui, rejeter et bannir" + "Êtes-vous sûr de vouloir rejeter la demande et bannir %1$s ? Cet utilisateur ne pourra pas demander à nouveau à rejoindre ce salon." + "Refuser et interdire l’accès" + "En cours de traitement…" + "Oui, refuser" + "Êtes-vous sûr de vouloir refuser la demande de %1$s à rejoindre le salon ?" + "Refuser l’accès" + "Refuser et bannir" + "Nous n’avons pas pu refuser cette demande. Voulez-vous réessayer ?" + "Echec" + "Traitement en cours…" + "Lorsque quelqu’un demandera à rejoindre le salon, vous pourrez voir sa demande ici." + "Personne ne demande à rejoindre le salon" + "Chargement…" + "Demandes en attente" + + "%1$s et %2$d autre personne souhaitent rejoindre ce salon" + "%1$s et %2$d autres personnes souhaitent rejoindre ce salon" + + "Tout afficher" + "Accepter" + "%1$s souhaite rejoindre ce salon" + "Voir" + diff --git a/features/knockrequests/impl/src/main/res/values-hu/translations.xml b/features/knockrequests/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..c01121a --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,36 @@ + + + "Igen, az összes elfogadása" + "Biztos, hogy elfogadja az összes csatlakozási kérelmet?" + "Minden kérés elfogadása" + "Összes elfogadása" + "Nem sikerült az összes kérés fogadása. Újra megpróbálja?" + "Nem sikerült az összes kérés elfogadása" + "Összes csatlakozási kérés elfogadása" + "Nem sikerült elfogadni a kérést. Megpróbálja újra?" + "Nem sikerült elfogadni a kérést" + "Csatlakozási kérés elfogadása" + "Igen, elutasítás és kitiltás" + "Biztos, hogy elutasítja %1$s kérését és ki is tiltja? Többé nem fogja tudni azt kérni, hogy csatlakozhasson ehhez a szobához." + "A hozzáférés elutasítása és kitiltás" + "A hozzáférés megtagadása és kitiltás" + "Igen, elutasítás" + "Biztos, hogy elutasítja %1$s kérését, hogy csatlakozzon a szobához?" + "Hozzáférés elutasítása" + "Elutasítás és kitiltás" + "Nem sikerült elutasítani a kérést. Megpróbálja újra?" + "Nem sikerült elutasítani a kérést" + "Csatlakozási kérés elutasítása" + "Ha valaki csatlakozni kíván a szobához, itt láthatja a kérését." + "Nincs függőben lévő csatlakozási kérelem" + "Csatlakozási kérések betöltése…" + "Csatlakozási kérelem" + + "%1$s és még %2$d felhasználó szeretne csatlakozni ehhez a szobához" + "%1$s és még %2$d felhasználó szeretne csatlakozni ehhez a szobához" + + "Összes megtekintése" + "Elfogadás" + "%1$s szeretne csatlakozni ehhez a szobához" + "Megtekintés" + diff --git a/features/knockrequests/impl/src/main/res/values-in/translations.xml b/features/knockrequests/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..0848008 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,35 @@ + + + "Ya, terima semua" + "Apakah Anda yakin ingin menerima semua permintaan untuk bergabung?" + "Terima semua permintaan" + "Terima semua" + "Kami tidak dapat menerima semua permintaan. Apakah Anda ingin mencoba lagi?" + "Gagal menerima semua permintaan" + "Menerima semua permintaan untuk bergabung" + "Kami tidak dapat menerima permintaan ini. Apakah Anda ingin mencoba lagi?" + "Gagal menerima permintaan" + "Menerima permintaan untuk bergabung" + "Ya, tolak dan cekal" + "Apakah Anda yakin ingin menolak dan mencekal %1$s? Pengguna ini tidak akan dapat meminta akses untuk bergabung ke ruangan ini lagi." + "Tolak dan cekal akses" + "Menolak dan mencekal akses" + "Ya, tolak" + "Apakah Anda yakin ingin menolak permintaan %1$s untuk bergabung ke ruangan ini?" + "Tolak akses" + "Tolak dan cekal" + "Kami tidak dapat menolak permintaan ini. Apakah Anda ingin mencoba lagi?" + "Gagal menolak permintaan" + "Menolak permintaan untuk bergabung" + "Ketika seseorang akan meminta untuk bergabung dengan ruangan, Anda akan dapat melihat permintaan mereka di sini." + "Tidak ada permintaan yang tertunda untuk bergabung" + "Memuat permintaan untuk bergabung…" + "Permintaan untuk bergabung" + + "%1$s +%2$d lainnya ingin bergabung ke ruangan ini" + + "Lihat semua" + "Terima" + "%1$s ingin bergabung ke ruangan ini" + "Lihat" + diff --git a/features/knockrequests/impl/src/main/res/values-it/translations.xml b/features/knockrequests/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..1c95fe7 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,36 @@ + + + "Sì, accetta tutte" + "Sei sicuro di voler accettare tutte le richieste di accesso?" + "Accetta tutte le richieste" + "Accetta tutte" + "Non siamo riusciti ad accettare tutte le richieste. Vuoi riprovare?" + "Non è stato possibile accettare tutte le richieste" + "Accettazione di tutte le richieste di ingresso" + "Non è stato possibile accettare questa richiesta. Vuoi riprovare?" + "Impossibile accettare la richiesta" + "Accettazione della richiesta di ingresso" + "Sì, rifiuta e blocca" + "Sei sicuro di voler rifiutare e bloccare %1$s? Questo utente non potrà richiedere nuovamente l\'accesso per entrare in questa stanza." + "Rifiuta e blocca l\'accesso" + "Rifiuto e divieto di accesso" + "Sì, rifiuta" + "Sei sicuro di voler rifiutare la richiesta di %1$s ad entrare in a questa stanza?" + "Rifiuta l\'accesso" + "Rifiuta e blocca" + "Non è stato possibile rifiutare questa richiesta. Vuoi riprovare?" + "Non è stato possibile rifiutare la richiesta" + "Rifiuto della richiesta di ingresso" + "Quando qualcuno ti chiederà di entrare nella stanza, potrai vedere la sua richiesta qui." + "Nessuna richiesta di accesso in sospeso" + "Caricamento richieste di partecipazione…" + "Richieste di accesso" + + "%1$s +%2$d vogliono entrare in questa stanza" + "%1$s +%2$d vogliono entrare in questa stanza" + + "Visualizza tutte" + "Accetta" + "%1$s vuole entrare in questa stanza" + "Visualizza" + diff --git a/features/knockrequests/impl/src/main/res/values-ka/translations.xml b/features/knockrequests/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..ce6628b --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,4 @@ + + + "მიღება" + diff --git a/features/knockrequests/impl/src/main/res/values-ko/translations.xml b/features/knockrequests/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..ef97ab4 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,35 @@ + + + "네, 모두 수락합니다" + "모든 가입 요청을 정말로 수락하시겠습니까?" + "모든 요청 수락" + "모두 수락" + "모든 요청을 처리할 수 없습니다. 다시 시도하시겠습니까?" + "모든 요청을 수락하지 못했습니다." + "모든 가입 요청 수락" + "이 요청을 수락할 수 없습니다. 다시 시도하시겠습니까?" + "요청을 수락하지 못했습니다" + "가입 요청 수락" + "네, 거절하고 차단합니다" + "%1$s 을 거부하고 차단하시겠습니까? 이 사용자는 이 방에 다시 참여하기 위해 액세스를 요청할 수 없습니다." + "접근 거부 및 차단" + "접근 거부 및 차단" + "네, 거절합니다" + "%1$s 의 이 방에 대한 요청을 정말 거부하시겠습니까?" + "접근 거부" + "거부 및 차단" + "이 요청을 거부할 수 없습니다. 다시 시도하시겠습니까?" + "요청 거부에 실패했습니다" + "가입 요청 거부" + "누군가가 방에 참여 요청을 한다면, 여기에서 그 요청을 볼 수 있습니다." + "보류 중인 가입 요청이 없습니다." + "가입 요청을 로딩 중…" + "참여 요청" + + "%1$s +%2$d 명이 이 방에 참여하고 싶어합니다." + + "모두 보기" + "수락" + "%1$s 이 방에 참여하고 싶습니다." + "보기" + diff --git a/features/knockrequests/impl/src/main/res/values-lt/translations.xml b/features/knockrequests/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..df25b7a --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,4 @@ + + + "Priimti" + diff --git a/features/knockrequests/impl/src/main/res/values-nb/translations.xml b/features/knockrequests/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..1616a4b --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,36 @@ + + + "Ja, godta alle" + "Er du sikker på at du vil godta alle forespørsler om å bli med?" + "Godta alle forespørsler" + "Godta alle" + "Vi kunne ikke godta alle forespørsler. Vil du prøve igjen?" + "Kunne ikke godta alle forespørsler" + "Godtar alle forespørsler om å bli med" + "Vi kunne ikke godta denne forespørselen. Vil du prøve igjen?" + "Kunne ikke godta forespørselen" + "Godtar forespørsel om å bli med" + "Ja, avslå og utesteng" + "Er du sikker på at du vil avvise og utestenge %1$s? Denne brukeren vil ikke kunne be om tilgang til dette rommet igjen." + "Avslå og forby tilgang" + "Avslår og forbyr tilgang" + "Ja, avslå" + "Er du sikker på at du vil avslå %1$ss forespørsel om å bli med i dette rommet?" + "Avslå tilgang" + "Avslå og forby" + "Vi kunne ikke avslå denne forespørselen. Vil du prøve igjen?" + "Kunne ikke avslå forespørselen" + "Avslår forespørsel om å bli med" + "Når noen ber om å bli med i rommet, vil du kunne se forespørselen deres her." + "Ingen ventende forespørsel om å bli med" + "Laster inn forespørsler om å bli med…" + "Forespørsler om å bli med" + + "%1$s +%2$d andre ønsker å bli med i dette rommet" + "%1$s +%2$d andre ønsker å bli med i dette rommet" + + "Vis alle" + "Godta" + "%1$s ønsker å bli med i dette rommet" + "Vis" + diff --git a/features/knockrequests/impl/src/main/res/values-nl/translations.xml b/features/knockrequests/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..a78d622 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,5 @@ + + + "Accepteren" + "Bekijken" + diff --git a/features/knockrequests/impl/src/main/res/values-pl/translations.xml b/features/knockrequests/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..d043caa --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,37 @@ + + + "Tak, akceptuj wszystkie" + "Czy na pewno chcesz zaakceptować wszystkie prośby o dołączenie?" + "Akceptuj wszystkie prośby" + "Akceptuj wszystkie" + "Nie udało się zaakceptować wszystkich próśb. Czy chcesz spróbować ponownie?" + "Nie udało się zaakceptować wszystkich próśb" + "Akceptowanie wszystkich próśb o dołączenie" + "Nie udało się zaakceptować prośby. Czy chcesz spróbować ponownie?" + "Nie udało się zaakceptować prośby" + "Akceptuję prośbę o dołączenie" + "Tak, odrzuć i zbanuj" + "Czy na pewno chcesz odrzucić i zbanować %1$s? Użytkownik nie będzie mógł ponownie poprosić o dostęp do tego pokoju." + "Odrzuć i zbanuj dostęp" + "Odrzucanie i banowanie dostępu" + "Tak, odrzuć" + "Czy na pewno chcesz odrzucić %1$s prośbę o dołączenie do tego pokoju?" + "Odrzuć dostęp" + "Odrzuć i zbanuj" + "Nie udało się odrzucić prośby. Czy chcesz spróbować ponownie?" + "Nie udało się odrzucić prośby" + "Odrzucanie prośby o dołączenie" + "Kiedy ktoś poprosi o dołączenie do pokoju, będziesz mógł zobaczyć jego prośbę tutaj." + "Brak oczekujących próśb o dołączenie" + "Wczytywanie próśb o dołączenie…" + "Prośby o dołączenie" + + "%1$s +%2$d inny chce dołączyć do pokoju" + "%1$s +%2$d innych chce dołączyć do pokoju" + "%1$s +%2$d innych chce dołączyć do pokoju" + + "Wyświetl wszystkie" + "Akceptuj" + "%1$s chce dołączyć do tego pokoju" + "Wyświetl" + diff --git a/features/knockrequests/impl/src/main/res/values-pt-rBR/translations.xml b/features/knockrequests/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..d6fcebb --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,36 @@ + + + "Sim, aceitar todos" + "Tem certeza de que deseja aceitar todos os pedidos de entrada?" + "Aceitar todos os pedidos" + "Aceitar todos" + "Não pudemos aceitar todas as solicitações. Você gostaria de tentar novamente?" + "Falha ao aceitar todas as solicitações" + "Aceitando todas as solicitações de entrada" + "Não pudemos aceitar essa solicitação. Você gostaria de tentar novamente?" + "Falha ao aceitar a solicitação" + "Aceitando solicitação de entrada" + "Sim, recusar e banir" + "Você tem certeza de que deseja recusar e banir %1$s? Este usuário não poderá solicitar acesso para entrar nesta sala novamente." + "Recusar e proibir o acesso" + "Recusando e proibindo o acesso" + "Sim, recusar" + "Você tem certeza de que deseja recusar a solicitação de %1$s para entrar nesta sala?" + "Recusar acesso" + "Recusar e banir" + "Não foi possível recusar esta solicitação. Você gostaria de tentar novamente?" + "Falha ao recusar a solicitação" + "Recusando a solicitação de entrada" + "Quando alguém pedir para entrar na sala, você poderá ver o pedido aqui." + "Nenhum pedido de entrada pendente" + "Carregando solicitações de entrada…" + "Pedidos de entrada" + + "%1$s + outro %2$d desejam entrar nesta sala" + "%1$s + outros %2$d desejam entrar nesta sala" + + "Ver tudo" + "Aceitar" + "%1$s quer entrar nesta sala" + "Visualizar" + diff --git a/features/knockrequests/impl/src/main/res/values-pt/translations.xml b/features/knockrequests/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..255c9d5 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,36 @@ + + + "Sim, aceitar todos" + "Tens a certeza de que queres aceitar todos os pedidos de entrada?" + "Aceitar todos os pedidos" + "Aceitar todos" + "Não foi possível aceitar todos os pedidos. Queres tentar novamente?" + "Falha ao aceitar todos os pedidos" + "A aceitar todos os pedidos de entrada" + "Não foi possível aceitar este pedido. Queres tentar novamente?" + "Falha ao aceitar pedido" + "A aceitar pedido de entrada" + "Sim, recusar e proibir" + "Tens a certeza de que queres recusar e banir %1$s? Este utilizador não poderá voltar a pedir para entrar nesta sala." + "Recusar e banir" + "A rejeitar pedido e a banir o utilizador" + "Sim, rejeitar" + "Tens a certeza que queres recusar o pedido de entrada de %1$s?" + "Rejeitar entrada" + "Recusar e banir" + "Não foi possível rejeitar este pedido. Queres tentar novamente?" + "Falha ao rejeitar pedido" + "A rejeitar pedido de entrada" + "Quando alguém pedir para entrar na sala, irás poder rever o pedido aqui." + "Sem pedidos de entrada" + "A carregar pedidos de entrada…" + "Pedidos de entrada" + + "%1$s +%2$d outro querem entrar nesta sala" + "%1$s +%2$d outros querem entrar nesta sala" + + "Ver todos" + "Aceitar" + "%1$s quer entrar nesta sala" + "Ver" + diff --git a/features/knockrequests/impl/src/main/res/values-ro/translations.xml b/features/knockrequests/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..06bfbc9 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,37 @@ + + + "Da, acceptati tot" + "Sunteți sigur că doriți să acceptați toate cererile de alăturare?" + "Acceptați toate cererile" + "Acceptați tot" + "Nu am putut accepta toate cererile. Doriți să încercați din nou?" + "Nu s-au putut accepta toate cererile" + "Se acceptă toate cererile de alăturare" + "Nu am putut accepta această cerere. Doriți să încercați din nou?" + "Nu s-a putut accepta cererea" + "Se acceptă cererea de alăturare" + "Da, refuzați și interziceți" + "Sunteți sigur că doriți să refuzați și să interziceți accesul lui %1$s? Acest utilizator nu va mai putea cere accesul pentru a se alătura acestei camere." + "Refuzați și interziceți accesul" + "Se refuză și interzice accesul" + "Da, refuzați" + "Sunteți sigur că doriți să refuzați cererea %1$s de a vă alătura acestei camere?" + "Refuzați accesul" + "Refuzați și interziceți" + "Nu am putut refuza această cerere. Doriți să încercați din nou?" + "Cererea nu a putut fi respinsă" + "Se refuza cererea de alăturare" + "Când cineva va cere să se alăture camerei, veți putea vedea cererea aici." + "Nu există cereri de alăturare în așteptare" + "Se încarcă cererile de alăturare…" + "Cereri de alăturare" + + "%1$s +%2$d utilizator doresc să se alăture acestei camere" + "%1$s +%2$d utilizatori doresc să se alăture acestei camere" + "%1$s +%2$d utilizatori doresc să se alăture acestei camere" + + "Vizualizați tot" + "Acceptați" + "%1$s dorește să se alăture acestei camere" + "Vizualizați" + diff --git a/features/knockrequests/impl/src/main/res/values-ru/translations.xml b/features/knockrequests/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..8080d27 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,37 @@ + + + "Да, принять все" + "Вы действительно хотите принять все заявки на присоединение?" + "Принять все запросы" + "Принять всё" + "Мы не смогли принять все запросы. Хотите попробовать еще раз?" + "Не удалось принять все запросы" + "Принять все заявки на присоединение" + "Мы не смогли принять этот запрос. Хотите попробовать еще раз?" + "Не удалось принять запрос" + "Принятие заявки на присоединение" + "Да, отклонить и запретить" + "Вы уверены, что хотите отклонить и запретить %1$s? Этот пользователь больше не сможет запросить доступ к этой комнате." + "Отклонить и запретить доступ" + "Отклонение и запрет доступа" + "Да, отклонить" + "Вы уверены, что хотите отклонить %1$s запрос на присоединение к этой комнате?" + "Отклонить доступ" + "Отклонить и запретить" + "Мы не смогли отклонить этот запрос. Хотите попробовать еще раз?" + "Не удалось отклонить запрос" + "Отклонение заявки на присоединение" + "Вы сможете увидеть запрос, когда кто-то попросит присоединиться к комнате." + "Нет ожидающих запросов на присоединение" + "Загрузка запросов на присоединение…" + "Запросы на вступление" + + "%1$s +%2$d хочет присоединиться к этой комнате" + "%1$s +%2$d хотят присоединиться к этой комнате" + "%1$s +%2$d хотят присоединиться к этой комнате" + + "Показать все" + "Разрешить" + "%1$s хочет присоединиться к этой комнате" + "Просмотр" + diff --git a/features/knockrequests/impl/src/main/res/values-sk/translations.xml b/features/knockrequests/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..12cc86d --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,37 @@ + + + "Áno, prijať všetky" + "Ste si istí, že chcete prijať všetky žiadosti o pripojenie?" + "Prijať všetky žiadosti" + "Prijať všetky" + "Nepodarilo sa prijať všetky žiadosti. Chceli by ste to skúsiť znova?" + "Nepodarilo sa prijať všetky žiadosti" + "Prijímanie všetkých žiadostí o pripojenie" + "Túto žiadosť sme nemohli prijať. Chceli by ste to skúsiť znova?" + "Žiadosť sa nepodarilo prijať" + "Prijíma sa žiadosť o pripojenie" + "Áno, odmietnuť a zakázať" + "Ste si istí, že chcete odmietnuť a zakázať používateľa %1$s? Tento používateľ nebude môcť znova požiadať o prístup k tejto miestnosti." + "Odmietnuť a zakázať prístup" + "Odmietnutie a zákaz prístupu" + "Áno, odmietnuť" + "Ste si istí, že chcete odmietnuť používateľovi %1$s žiadosť o vstup do tejto miestnosti?" + "Odmietnuť prístup" + "Odmietnuť a zakázať" + "Túto žiadosť sa nepodarilo odmietnuť. Chceli by ste to skúsiť znova?" + "Nepodarilo sa odmietnuť žiadosť" + "Odmietnutie žiadosti o prihlásenie" + "Keď niekto požiada, aby sa pripojil k miestnosti, jeho žiadosť si môžete pozrieť tu." + "Žiadna čakajúca žiadosť o pripojenie" + "Načítavajú sa žiadosti o pripojenie…" + "Žiadosti o vstup" + + "%1$s +%2$d ďalší chcú vstúpiť do tejto miestnosti" + "%1$s +%2$d ďalší chcú vstúpiť do tejto miestnosti" + "%1$s +%2$d ďalších chce vstúpiť do tejto miestnosti" + + "Zobraziť všetko" + "Prijať" + "%1$s chce vstúpiť do tejto miestnosti" + "Zobraziť" + diff --git a/features/knockrequests/impl/src/main/res/values-sv/translations.xml b/features/knockrequests/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..1b51a8e --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,36 @@ + + + "Ja, acceptera alla" + "Är du säker på att du vill acceptera alla förfrågningar om att gå med?" + "Acceptera alla förfrågningar" + "Acceptera alla" + "Vi kunde inte acceptera alla förfrågningar. Vill du försöka igen?" + "Misslyckades att acceptera alla förfrågningar" + "Accepterar alla förfrågningar om att gå med" + "Vi kunde inte acceptera denna begäran. Vill du försöka igen?" + "Misslyckades att acceptera förfrågan" + "Accepterar begäran om att gå med" + "Ja, avslå och förbjud" + "Är du säker på att du vill avvisa och förbjuda%1$s? Den här användaren kommer inte att kunna begära åtkomst för att gå med i det här rummet igen." + "Avvisa och förbjud åtkomst" + "Avvisar och bannar åtkomst" + "Ja, avböj" + "Är du säker på att du vill avslå %1$s begäran om att gå med i det här rummet?" + "Avvisa åtkomst" + "Avvisa och förbjud" + "Vi kunde inte avslå denna begäran. Vill du försöka igen?" + "Misslyckades att avvisa begäran" + "Avvisa begäran om att gå med" + "När någon begär om att gå med i rummet, kan du se deras förfrågan här." + "Ingen väntande begäran om att gå med" + "Laddar förfrågningar om att gå med …" + "Begäran om att gå med" + + "%1$s+ %2$d annan vill gå med i detta rum" + "%1$s+ %2$d andra vill gå med i detta rum" + + "Visa alla" + "Godkänn" + "%1$s vill gå med i det här rummet" + "Visa" + diff --git a/features/knockrequests/impl/src/main/res/values-tr/translations.xml b/features/knockrequests/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..445d2aa --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,35 @@ + + + "Evet, tümünü kabul et" + "Tüm katılma isteklerini kabul etmek istediğinizden emin misiniz?" + "Tüm istekleri kabul et" + "Tümünü kabul et" + "Tüm istekleri kabul edemedik. Tekrar denemek ister misiniz?" + "Tüm istekler kabul edilemedi" + "Tüm katılım istekleri kabul ediliyor" + "Bu isteği kabul edemedik. Tekrar denemek ister misiniz?" + "İstek kabul edilemedi" + "Katılma isteği kabul ediliyor" + "Evet, reddet ve yasakla" + "%1$s reddetmek ve yasaklamak istediğinizden emin misiniz? Bu kullanıcı, bu odaya tekrar katılmak için erişim isteğinde bulunamaz." + "Reddet ve erişimi yasakla" + "Reddediliyor ve erişim yasaklanıyor" + "Evet, reddet" + "Bu odaya katılma isteğini reddetmek istediğinizden emin misiniz %1$s?" + "Erişimi reddet" + "Reddet ve yasakla" + "Bu isteği reddedemedik. Tekrar denemek ister misiniz?" + "İsteği reddetme başarısız oldu" + "Katılma isteği reddediliyor" + "Birisi odaya katılmak istediğinde, isteklerini burada görebileceksiniz." + "Bekleyen katılım isteği yok" + "Katılma istekleri yükleniyor…" + "Katılma istekleri" + + "%1$s +%2$d kişi daha bu odaya katılmak istiyor" + "%1$s +%2$d kişi daha bu odaya katılmak istiyor" + + "Tümünü görüntüle" + "Kabul et" + "%1$s bu odaya katılmak istiyor" + diff --git a/features/knockrequests/impl/src/main/res/values-uk/translations.xml b/features/knockrequests/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..11a9b7f --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,37 @@ + + + "Так, прийняти всі" + "Ви впевнені, що хочете прийняти всі запити на приєднання?" + "Прийняти всі запити" + "Прийняти всі" + "Ми не змогли прийняти всі запити. Бажаєте спробувати ще раз?" + "Не вдалося прийняти всі запити" + "Прийняття всіх запитів на приєднання" + "Ми не змогли прийняти цей запит. Бажаєте спробувати ще раз?" + "Не вдалося прийняти запит" + "Прийняття запиту на приєднання" + "Так, відхилити та заблокувати" + "Ви впевнені, що хочете відхилити та заборонити %1$s? Цей користувач не зможе знову запитувати про доступ до цієї кімнати." + "Відмова та заборона доступу" + "Відмова та заборона доступу" + "Так, відхилити" + "Ви впевнені, що хочете відхилити запит %1$s на приєднання до цієї кімнати?" + "Відмовити в доступі" + "Відхилити та заблокувати" + "Ми не змогли відхилити цей запит. Бажаєте спробувати ще раз?" + "Не вдалося відхилити запит" + "Відхилення запиту на приєднання" + "Коли хтось попросить приєднатися до кімнати, ви зможете побачити їхній запит тут." + "Немає нерозглянутих запитів на приєднання" + "Завантаження запитів на приєднання…" + "Запити на приєднання" + + "%1$s +%2$d інший хочуть приєднатися до цієї кімнати" + "%1$s +%2$d інші хочуть приєднатися до цієї кімнати" + "%1$s +%2$d інших хочуть приєднатися до цієї кімнати" + + "Переглянути все" + "Прийняти" + "%1$s хоче приєднатися до цієї кімнати" + "Переглянути" + diff --git a/features/knockrequests/impl/src/main/res/values-ur/translations.xml b/features/knockrequests/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..fba633f --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,4 @@ + + + "قبول کریں" + diff --git a/features/knockrequests/impl/src/main/res/values-uz/translations.xml b/features/knockrequests/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..24d8fb3 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,36 @@ + + + "Ha, hammasini qabul qiling" + "Barcha qo‘shilish so‘rovlarini qabul qilishga ishonchingiz komilmi?" + "Barcha so‘rovlarni qabul qilish" + "Hammasini qabul qiling" + "Biz barcha so‘rovlarni qabul qila olmadik. Qayta urinib koʻrmoqchimisiz?" + "Barcha so‘rovlar qabul qilinmadi" + "Qo‘shilish so‘rovi qabul qilinmoqda" + "Biz bu so‘rovni qabul qila olmadik. Yana bir bor urinib ko‘rishni xohlaysizmi?" + "So‘rovni qabul qilib bo‘lmadi" + "Qo‘shilish so‘rovi qabul qilinmoqda" + "Ha, rad eting va taqiqlang" + "Siz %1$sʼni rad etib, taqiqlashni xohlayotganingizga ishonchingiz komilmi? Bu foydalanuvchi ushbu xonaga qayta kirish uchun ruxsat so‘ray olmaydi." + "Rad etish va kirishni taqiqlash" + "Kirishni rad etish va taqiqlash" + "Ha, rad etish" + "%1$sning bu xonaga qo‘shilish so‘rovini rad etasizmi?" + "Kirishni rad etish" + "Rad etish va taqiqlash" + "Biz bu iltimosni rad etolmasdik. Yana bir bor urinib ko‘rishni xohlaysizmi?" + "So‘rovni rad etib bo‘lmadi" + "Qo‘shilish so‘rovi rad etilayapti" + "Kimdir xonaga qo‘shilishni so‘raganda, uning iltimosini shu yerda ko‘rishingiz mumkin." + "Qo‘shilish so‘rovi kutilmayapti" + "Qo‘shilish uchun so‘rovlar yuklanmoqda…" + "Qo‘shilish uchun so‘rovlar" + + "%1$s + %2$d kishi bu xonaga qo‘shilmoqchi" + "%1$s + %2$d kishi bu xonaga qo‘shilmoqchi" + + "Hammasini ko\'rish" + "Qabul qiling" + "%1$s bu xonaga qo‘shilmoqchi" + "Ko\'rish" + diff --git a/features/knockrequests/impl/src/main/res/values-zh-rTW/translations.xml b/features/knockrequests/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..dae4f90 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,35 @@ + + + "是的,全部接受" + "您確定要接受所有加入請求嗎?" + "接受所有請求" + "全部接受" + "我們無法接受所有請求。您想要再試一次嗎?" + "無法接受所有請求" + "接受所有加入請求" + "我們無法接受此請求。您想要再試一次嗎?" + "無法接受請求" + "接受加入請求" + "是的,拒絕並封鎖" + "您確定要拒絕並封鎖 %1$s 嗎?此使用者將無法再次申請加入此聊天室。" + "拒絕並禁止存取" + "拒絕並封鎖存取權" + "是的,拒絕" + "您確定您要拒絕 %1$s 加入此聊天室的請求嗎?" + "拒絕存取" + "拒絕並封鎖" + "我們無法拒絕此請求。您想要再試一次嗎?" + "拒絕請求失敗" + "拒絕加入請求" + "當有人要求加入聊天室時,您可以在這裡看到他們的請求。" + "沒有待處理的加入請求" + "正在載入加入請求……" + "請求加入" + + "%1$s 與 %2$d 個其他人想要加入此聊天室" + + "檢視全部" + "接受" + "%1$s 想要加入此聊天室" + "檢視" + diff --git a/features/knockrequests/impl/src/main/res/values-zh/translations.xml b/features/knockrequests/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..f568ee1 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,35 @@ + + + "是的,全部接受" + "您确定要接受所有加入请求吗?" + "接受所有请求" + "全部接受" + "我们无法接受所有请求。是否要再试一次?" + "无法接受所有请求" + "接受所有加入请求" + "我们无法接受此请求。是否要再试一次?" + "无法接受请求" + "接受加入请求" + "是的,拒绝并禁止" + "您确定要拒绝并禁止吗%1$s?该用户将无法再次请求加入该房间。" + "拒绝并禁止访问" + "拒绝并禁止访问" + "是的,拒绝" + "您确定要拒绝 %1$s 加入此房间的请求吗?" + "拒绝访问" + "拒绝和禁止" + "我们无法拒绝此请求。是否要再试一次?" + "拒绝请求失败" + "拒绝加入请求" + "当有人请求加入房间时,您将能够在这里看到他们的请求。" + "没有待处理的加入请求" + "正在加载加入请求…" + "申请加入" + + "%1$s+ %2$d 其他人想加入这个房间" + + "查看全部" + "接受" + "%1$s想加入这个房间" + "查看" + diff --git a/features/knockrequests/impl/src/main/res/values/localazy.xml b/features/knockrequests/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..454bb8e --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values/localazy.xml @@ -0,0 +1,36 @@ + + + "Yes, accept all" + "Are you sure you want to accept all requests to join?" + "Accept all requests" + "Accept all" + "We couldn’t accept all requests. Would you like to try again?" + "Failed to accept all requests" + "Accepting all requests to join" + "We couldn’t accept this request. Would you like to try again?" + "Failed to accept request" + "Accepting request to join" + "Yes, decline and ban" + "Are you sure you want to decline and ban %1$s? This user won’t be able to request access to join this room again." + "Decline and ban from accessing" + "Declining and banning access" + "Yes, decline" + "Are you sure you want to decline %1$s request to join this room?" + "Decline access" + "Decline and ban" + "We couldn’t decline this request. Would you like to try again?" + "Failed to decline request" + "Declining request to join" + "When somebody will ask to join the room, you’ll be able to see their request here." + "No pending request to join" + "Loading requests to join…" + "Requests to join" + + "%1$s +%2$d other want to join this room" + "%1$s +%2$d others want to join this room" + + "View all" + "Accept" + "%1$s wants to join this room" + "View" + diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt new file mode 100644 index 0000000..9eaa51c --- /dev/null +++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.banner + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions +import io.element.android.features.knockrequests.impl.data.KnockRequestsService +import io.element.android.libraries.matrix.api.room.knock.KnockRequest +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_ID_3 +import io.element.android.libraries.matrix.test.room.knock.FakeKnockRequest +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) class KnockRequestsBannerPresenterTest { + @Test + fun `present - when feature is disabled then the banner should be hidden`() = runTest { + val knockRequests = flowOf(listOf(FakeKnockRequest())) + val presenter = createKnockRequestsBannerPresenter(isFeatureEnabled = false, knockRequestsFlow = knockRequests) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.isVisible).isFalse() + } + } + } + + @Test + fun `present - when empty knock request list then the banner should be hidden`() = runTest { + val knockRequests = flowOf(emptyList()) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.isVisible).isFalse() + } + } + } + + @Test + fun `present - when no permission to manage knock requests then the banner should be hidden`() = runTest { + val presenter = createKnockRequestsBannerPresenter(canAcceptKnockRequests = false) + presenter.test { + awaitItem().also { state -> + assertThat(state.isVisible).isFalse() + } + } + } + + @Test + fun `present - when everything is setup to manage knocks with data, then the banner should be visible`() = runTest { + val knockRequests = flowOf( + listOf( + FakeKnockRequest( + reason = "A reason", + ) + ) + ) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.isVisible).isTrue() + assertThat(state.knockRequests).hasSize(1) + assertThat(state.canAccept).isTrue() + assertThat(state.reason).isEqualTo("A reason") + } + } + } + + @Test + fun `present - when multiple knock requests, the banner should not have reason nor subtitle`() = runTest { + val knockRequests = flowOf( + listOf( + FakeKnockRequest( + displayName = "Alice", + ), + FakeKnockRequest( + displayName = "Bob", + ), + FakeKnockRequest( + displayName = "Charlie", + ), + ) + ) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.isVisible).isTrue() + assertThat(state.knockRequests).hasSize(3) + assertThat(state.reason).isNull() + assertThat(state.subtitle).isNull() + } + } + } + + @Test + fun `present - when there are some seen knock requests, then the banner should filtered them`() = runTest { + val knockRequests = flowOf( + listOf( + FakeKnockRequest( + displayName = "Alice", + isSeen = true, + userId = A_USER_ID + ), + FakeKnockRequest( + displayName = "Bob", + isSeen = true, + userId = A_USER_ID_2 + ), + FakeKnockRequest( + isSeen = false, + displayName = "Charlie", + reason = "A reason", + userId = A_USER_ID_3 + ), + ) + ) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.isVisible).isTrue() + // Only Charlie should be displayed + assertThat(state.knockRequests).hasSize(1) + assertThat(state.reason).isEqualTo("A reason") + assertThat(state.subtitle).isEqualTo(A_USER_ID_3.value) + } + } + } + + @Test + fun `present - given AcceptSingleRequest event with failure, then the banner should hide and reappear and error should appear and disappear`() = runTest { + val acceptLambda = lambdaRecorder> { Result.failure(Exception()) } + val knockRequest = FakeKnockRequest( + displayName = "Alice", + reason = "A reason", + acceptLambda = acceptLambda + ) + val knockRequests = flowOf(listOf(knockRequest)) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(2) + awaitItem().also { state -> + state.eventSink(KnockRequestsBannerEvents.AcceptSingleRequest) + } + awaitItem().also { state -> + assertThat(state.isVisible).isFalse() + assertThat(state.displayAcceptError).isFalse() + } + awaitItem().also { state -> + assertThat(state.isVisible).isFalse() + assertThat(state.displayAcceptError).isTrue() + } + awaitItem().also { state -> + assertThat(state.isVisible).isTrue() + assertThat(state.displayAcceptError).isTrue() + } + awaitItem().also { state -> + assertThat(state.isVisible).isTrue() + assertThat(state.displayAcceptError).isFalse() + } + assert(acceptLambda).isCalledOnce() + } + } + + @Test + fun `present - given an AcceptSingleRequest event with success, then banner should be dismissed`() = runTest { + val acceptLambda = lambdaRecorder> { Result.success(Unit) } + val knockRequest = FakeKnockRequest( + displayName = "Alice", + reason = "A reason", + acceptLambda = acceptLambda + ) + val knockRequests = flowOf(listOf(knockRequest)) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.knockRequests).hasSize(1) + state.eventSink(KnockRequestsBannerEvents.AcceptSingleRequest) + } + awaitItem().also { state -> + assertThat(state.isVisible).isFalse() + } + advanceUntilIdle() + assert(acceptLambda).isCalledOnce() + } + } + + @Test + fun `present - given a Dismiss event, then knock requests should be marked as seen`() = runTest { + val markAsSeenLambda = lambdaRecorder> { Result.success(Unit) } + val knockRequests = flowOf( + listOf( + FakeKnockRequest(markAsSeenLambda = markAsSeenLambda), + FakeKnockRequest(markAsSeenLambda = markAsSeenLambda), + FakeKnockRequest(markAsSeenLambda = markAsSeenLambda), + ) + ) + val presenter = createKnockRequestsBannerPresenter(knockRequestsFlow = knockRequests) + presenter.test { + skipItems(2) + awaitItem().also { state -> + state.eventSink(KnockRequestsBannerEvents.Dismiss) + } + advanceUntilIdle() + assert(markAsSeenLambda).isCalledExactly(3) + } + } +} + +private fun TestScope.createKnockRequestsBannerPresenter( + knockRequestsFlow: Flow> = flowOf(emptyList()), + canAcceptKnockRequests: Boolean = true, + isFeatureEnabled: Boolean = true, +): KnockRequestsBannerPresenter { + val knockRequestsService = KnockRequestsService( + knockRequestsFlow = knockRequestsFlow, + coroutineScope = backgroundScope, + isKnockFeatureEnabledFlow = flowOf(isFeatureEnabled), + permissionsFlow = flowOf(KnockRequestPermissions(canAcceptKnockRequests, canAcceptKnockRequests, canAcceptKnockRequests)), + ) + return KnockRequestsBannerPresenter( + knockRequestsService = knockRequestsService, + sessionCoroutineScope = this, + ) +} diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerViewTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerViewTest.kt new file mode 100644 index 0000000..a9fea09 --- /dev/null +++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerViewTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.banner + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.knockrequests.impl.R +import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class KnockRequestsBannerViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on view on single request invoke the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setKnockRequestsBannerView( + state = aKnockRequestsBannerState( + eventSink = eventsRecorder, + ), + onViewRequestsClick = it + ) + rule.clickOn(R.string.screen_room_single_knock_request_view_button_title) + } + } + + @Test + fun `clicking on view all when multiple requests invoke the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setKnockRequestsBannerView( + state = aKnockRequestsBannerState( + knockRequests = listOf( + aKnockRequestPresentable(displayName = "Alice"), + aKnockRequestPresentable(displayName = "Bob"), + aKnockRequestPresentable(displayName = "Charlie") + ), + eventSink = eventsRecorder, + ), + onViewRequestsClick = it + ) + rule.clickOn(R.string.screen_room_multiple_knock_requests_view_all_button_title) + } + } + + @Test + fun `clicking on accept on a single request emit the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setKnockRequestsBannerView( + state = aKnockRequestsBannerState( + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_accept) + eventsRecorder.assertSingle(KnockRequestsBannerEvents.AcceptSingleRequest) + } + + @Test + fun `clicking on dismiss emit the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setKnockRequestsBannerView( + state = aKnockRequestsBannerState( + eventSink = eventsRecorder, + ), + ) + val close = rule.activity.getString(CommonStrings.action_close) + rule.onNodeWithContentDescription(close).performClick() + eventsRecorder.assertSingle(KnockRequestsBannerEvents.Dismiss) + } +} + +private fun AndroidComposeTestRule.setKnockRequestsBannerView( + state: KnockRequestsBannerState, + onViewRequestsClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + KnockRequestsBannerView( + state = state, + onViewRequestsClick = onViewRequestsClick, + ) + } +} diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/DefaultKnockRequestsListEntryPointTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/DefaultKnockRequestsListEntryPointTest.kt new file mode 100644 index 0000000..2ef357c --- /dev/null +++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/DefaultKnockRequestsListEntryPointTest.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.list + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.tests.testutils.node.TestParentNode +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DefaultKnockRequestsListEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `test node builder`() = runTest { + val entryPoint = DefaultKnockRequestsListEntryPoint() + + val parentNode = TestParentNode.create { buildContext, plugins -> + KnockRequestsListNode( + buildContext = buildContext, + plugins = plugins, + presenter = createKnockRequestsListPresenter(), + ) + } + val result = entryPoint.createNode(parentNode, BuildContext.root(null)) + assertThat(result).isInstanceOf(KnockRequestsListNode::class.java) + } +} diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt new file mode 100644 index 0000000..b0d0b68 --- /dev/null +++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.knockrequests.impl.list + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions +import io.element.android.features.knockrequests.impl.data.KnockRequestsService +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.room.knock.KnockRequest +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.room.knock.FakeKnockRequest +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class KnockRequestsListPresenterTest { + @Test + fun `present - initial states should be emitted`() = runTest { + val presenter = createKnockRequestsListPresenter() + presenter.test { + awaitItem().also { state -> + assertThat(state.knockRequests).isInstanceOf(AsyncData.Loading::class.java) + assertThat(state.permissions.canAccept).isFalse() + assertThat(state.permissions.canDecline).isFalse() + assertThat(state.permissions.canBan).isFalse() + } + awaitItem().also { state -> + assertThat(state.knockRequests).isInstanceOf(AsyncData.Loading::class.java) + assertThat(state.permissions.canAccept).isTrue() + assertThat(state.permissions.canDecline).isTrue() + assertThat(state.permissions.canBan).isTrue() + } + awaitItem().also { state -> + assertThat(state.knockRequests).isInstanceOf(AsyncData.Success::class.java) + assertThat(state.knockRequests.dataOrNull()).isEmpty() + } + } + } + + @Test + fun `present - accept success scenario`() = runTest { + val acceptLambda = lambdaRecorder> { Result.success(Unit) } + val knockRequest = FakeKnockRequest(acceptLambda = acceptLambda) + val knockRequests = flowOf(listOf(knockRequest)) + val presenter = createKnockRequestsListPresenter( + knockRequestsFlow = knockRequests + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + state.eventSink(KnockRequestsListEvents.Accept(knockRequestPresentable)) + } + skipItems(1) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.Accept(knockRequestPresentable)) + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Success::class.java) + state.eventSink(KnockRequestsListEvents.ResetCurrentAction) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.None) + assertThat(state.knockRequests.dataOrNull().orEmpty()).isEmpty() + } + assert(acceptLambda).isCalledOnce() + } + } + + @Test + fun `present - accept failure scenario`() = runTest { + val acceptLambda = lambdaRecorder> { Result.failure(Exception()) } + val knockRequest = FakeKnockRequest(acceptLambda = acceptLambda) + val knockRequests = flowOf(listOf(knockRequest)) + val presenter = createKnockRequestsListPresenter( + knockRequestsFlow = knockRequests + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + state.eventSink(KnockRequestsListEvents.Accept(knockRequestPresentable)) + } + skipItems(1) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.Accept(knockRequestPresentable)) + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Failure::class.java) + state.eventSink(KnockRequestsListEvents.RetryCurrentAction) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Failure::class.java) + state.eventSink(KnockRequestsListEvents.ResetCurrentAction) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.None) + assertThat(state.knockRequests.dataOrNull()).hasSize(1) + } + assert(acceptLambda).isCalledExactly(2) + } + } + + @Test + fun `present - decline success scenario`() = runTest { + val declineLambda = lambdaRecorder> { Result.success(Unit) } + val knockRequest = FakeKnockRequest(declineLambda = declineLambda) + val knockRequests = flowOf(listOf(knockRequest)) + val presenter = createKnockRequestsListPresenter( + knockRequestsFlow = knockRequests + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + state.eventSink(KnockRequestsListEvents.Decline(knockRequestPresentable)) + } + skipItems(1) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.Decline(knockRequestPresentable)) + assertThat(state.asyncAction).isInstanceOf(AsyncAction.ConfirmingNoParams::class.java) + state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Success::class.java) + state.eventSink(KnockRequestsListEvents.ResetCurrentAction) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.None) + assertThat(state.knockRequests.dataOrNull().orEmpty()).isEmpty() + } + } + assert(declineLambda).isCalledOnce() + } + + @Test + fun `present - decline and ban success scenario`() = runTest { + val declineAndBanLambda = lambdaRecorder> { Result.success(Unit) } + val knockRequest = FakeKnockRequest(declineAndBanLambda = declineAndBanLambda) + val knockRequests = flowOf(listOf(knockRequest)) + val presenter = createKnockRequestsListPresenter( + knockRequestsFlow = knockRequests + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + state.eventSink(KnockRequestsListEvents.DeclineAndBan(knockRequestPresentable)) + } + skipItems(1) + awaitItem().also { state -> + val knockRequestPresentable = state.knockRequests.dataOrNull()?.first()!! + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.DeclineAndBan(knockRequestPresentable)) + assertThat(state.asyncAction).isInstanceOf(AsyncAction.ConfirmingNoParams::class.java) + state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Success::class.java) + state.eventSink(KnockRequestsListEvents.ResetCurrentAction) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.None) + assertThat(state.knockRequests.dataOrNull().orEmpty()).isEmpty() + } + } + assert(declineAndBanLambda).isCalledOnce() + } + + @Test + fun `present - accept all success scenario`() = runTest { + val acceptLambda = lambdaRecorder> { Result.success(Unit) } + val knockRequests = flowOf( + listOf( + FakeKnockRequest(eventId = AN_EVENT_ID, acceptLambda = acceptLambda), + FakeKnockRequest(eventId = AN_EVENT_ID_2, acceptLambda = acceptLambda), + ) + ) + val presenter = createKnockRequestsListPresenter( + knockRequestsFlow = knockRequests + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.canAcceptAll).isTrue() + state.eventSink(KnockRequestsListEvents.AcceptAll) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.AcceptAll) + assertThat(state.asyncAction).isInstanceOf(AsyncAction.ConfirmingNoParams::class.java) + state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Success::class.java) + state.eventSink(KnockRequestsListEvents.ResetCurrentAction) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.None) + assertThat(state.knockRequests.dataOrNull().orEmpty()).isEmpty() + } + } + assert(acceptLambda).isCalledExactly(2) + } + + @Test + fun `present - accept all partial success scenario`() = runTest { + val acceptSuccessLambda = lambdaRecorder> { Result.success(Unit) } + val acceptFailureLambda = lambdaRecorder> { Result.failure(Exception()) } + val knockRequests = flowOf( + listOf( + FakeKnockRequest(eventId = AN_EVENT_ID, acceptLambda = acceptSuccessLambda), + FakeKnockRequest(eventId = AN_EVENT_ID_2, acceptLambda = acceptFailureLambda), + ) + ) + val presenter = createKnockRequestsListPresenter( + knockRequestsFlow = knockRequests + ) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.canAcceptAll).isTrue() + state.eventSink(KnockRequestsListEvents.AcceptAll) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.AcceptAll) + assertThat(state.asyncAction).isInstanceOf(AsyncAction.ConfirmingNoParams::class.java) + state.eventSink(KnockRequestsListEvents.ConfirmCurrentAction) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Failure::class.java) + state.eventSink(KnockRequestsListEvents.ResetCurrentAction) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.asyncAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.currentAction).isEqualTo(KnockRequestsAction.None) + assertThat(state.knockRequests.dataOrNull()).hasSize(1) + } + } + assert(acceptFailureLambda).isCalledOnce() + assert(acceptSuccessLambda).isCalledOnce() + } +} + +internal fun TestScope.createKnockRequestsListPresenter( + canAccept: Boolean = true, + canDecline: Boolean = true, + canBan: Boolean = true, + knockRequestsFlow: Flow> = flowOf(emptyList()) +): KnockRequestsListPresenter { + val knockRequestsService = KnockRequestsService( + knockRequestsFlow = knockRequestsFlow, + coroutineScope = backgroundScope, + isKnockFeatureEnabledFlow = flowOf(true), + permissionsFlow = flowOf(KnockRequestPermissions(canAccept, canDecline, canBan)), + ) + return KnockRequestsListPresenter(knockRequestsService = knockRequestsService) +} diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListViewTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListViewTest.kt new file mode 100644 index 0000000..188dcc7 --- /dev/null +++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListViewTest.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.list + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.knockrequests.impl.R +import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import kotlinx.collections.immutable.persistentListOf +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class KnockRequestsListViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invoke the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setKnockRequestsListView( + aKnockRequestsListState( + eventSink = eventsRecorder, + ), + onBackClick = it + ) + rule.pressBack() + } + } + + @Test + fun `clicking on accept emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequest = aKnockRequestPresentable() + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(persistentListOf(knockRequest)), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_accept) + eventsRecorder.assertSingle(KnockRequestsListEvents.Accept(knockRequest)) + } + + @Test + fun `clicking on decline emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequest = aKnockRequestPresentable() + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(persistentListOf(knockRequest)), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_decline) + eventsRecorder.assertSingle(KnockRequestsListEvents.Decline(knockRequest)) + } + + @Test + fun `clicking on decline and ban emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequest = aKnockRequestPresentable() + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(persistentListOf(knockRequest)), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_knock_requests_list_decline_and_ban_action_title) + eventsRecorder.assertSingle(KnockRequestsListEvents.DeclineAndBan(knockRequest)) + } + + @Test + fun `clicking on accept all emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable()) + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(knockRequests), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_knock_requests_list_accept_all_button_title) + eventsRecorder.assertSingle(KnockRequestsListEvents.AcceptAll) + } + + @Test + fun `retry on async view retry emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable()) + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(knockRequests), + asyncAction = AsyncAction.Failure(RuntimeException("Failed to accept all")), + currentAction = KnockRequestsAction.AcceptAll, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_retry) + eventsRecorder.assertSingle(KnockRequestsListEvents.RetryCurrentAction) + } + + @Test + fun `canceling async view emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable()) + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(knockRequests), + asyncAction = AsyncAction.Failure(RuntimeException("Failed to accept all")), + currentAction = KnockRequestsAction.AcceptAll, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(KnockRequestsListEvents.ResetCurrentAction) + } + + @Test + fun `confirming async view emit the expected event`() { + val eventsRecorder = EventsRecorder() + val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable()) + rule.setKnockRequestsListView( + aKnockRequestsListState( + knockRequests = AsyncData.Success(knockRequests), + asyncAction = AsyncAction.ConfirmingNoParams, + currentAction = KnockRequestsAction.AcceptAll, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_knock_requests_list_accept_all_alert_confirm_button_title) + eventsRecorder.assertSingle(KnockRequestsListEvents.ConfirmCurrentAction) + } +} + +private fun AndroidComposeTestRule.setKnockRequestsListView( + state: KnockRequestsListState, + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + KnockRequestsListView( + state = state, + onBackClick = onBackClick, + ) + } +} diff --git a/features/knockrequests/test/build.gradle.kts b/features/knockrequests/test/build.gradle.kts new file mode 100644 index 0000000..dc3407d --- /dev/null +++ b/features/knockrequests/test/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.knockrequests.test" +} + +dependencies { + implementation(projects.features.knockrequests.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.tests.testutils) +} diff --git a/features/knockrequests/test/src/main/kotlin/io/element/android/features/knockrequests/test/FakeKnockRequestsListEntryPoint.kt b/features/knockrequests/test/src/main/kotlin/io/element/android/features/knockrequests/test/FakeKnockRequestsListEntryPoint.kt new file mode 100644 index 0000000..95fc36d --- /dev/null +++ b/features/knockrequests/test/src/main/kotlin/io/element/android/features/knockrequests/test/FakeKnockRequestsListEntryPoint.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.knockrequests.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeKnockRequestsListEntryPoint : KnockRequestsListEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + ): Node = lambdaError() +} diff --git a/features/leaveroom/api/build.gradle.kts b/features/leaveroom/api/build.gradle.kts new file mode 100644 index 0000000..b548081 --- /dev/null +++ b/features/leaveroom/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.leaveroom.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} 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 0000000..6d228ee --- /dev/null +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.leaveroom.api + +import io.element.android.libraries.matrix.api.core.RoomId + +interface LeaveRoomEvent { + data class LeaveRoom(val roomId: RoomId, val needsConfirmation: Boolean) : LeaveRoomEvent +} diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomRenderer.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomRenderer.kt new file mode 100644 index 0000000..852193d --- /dev/null +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomRenderer.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.leaveroom.api + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.libraries.matrix.api.core.RoomId + +fun interface LeaveRoomRenderer { + @Composable + fun Render( + state: LeaveRoomState, + onSelectNewOwners: (RoomId) -> Unit, + modifier: Modifier, + ) +} 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 0000000..92a5916 --- /dev/null +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.leaveroom.api + +import androidx.compose.runtime.Immutable + +@Immutable +interface LeaveRoomState { + val eventSink: (LeaveRoomEvent) -> Unit +} diff --git a/features/leaveroom/api/src/main/res/values-be/translations.xml b/features/leaveroom/api/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..aae2572 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-be/translations.xml @@ -0,0 +1,7 @@ + + + "Вы ўпэўнены, што хочаце пакінуць гэту размову? Гэта размова не з\'яўляецца публічнай, і вы не зможаце далучыцца зноў без запрашэння." + "Вы ўпэўнены, што хочаце пакінуць гэты пакой? Вы тут адзіны карыстальнік. Калі вы выйдзеце, ніхто не зможа далучыцца ў будучыні, у тым ліку і вы." + "Вы ўпэўнены, што хочаце пакінуць гэты пакой? Гэты пакой не агульнадаступны, і вы не зможаце далучыцца да яго зноў без запрашэння." + "Вы ўпэўнены, што хочаце пакінуць пакой?" + diff --git a/features/leaveroom/api/src/main/res/values-bg/translations.xml b/features/leaveroom/api/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..8bb8863 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-bg/translations.xml @@ -0,0 +1,6 @@ + + + "Сигурни ли сте, че искате да напуснете тази стая? Вие сте единственият човек тук. Ако напуснете, никой няма да може да се присъедини в бъдеще, включително и вие." + "Сигурни ли сте, че искате да напуснете тази стая? Тази стая не е общодостъпна и няма да можете да се присъедините отново без покана." + "Сигурни ли сте, че искате да напуснете стаята?" + diff --git a/features/leaveroom/api/src/main/res/values-cs/translations.xml b/features/leaveroom/api/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..3853553 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-cs/translations.xml @@ -0,0 +1,9 @@ + + + "Opravdu chcete opustit tuto konverzaci? Tato konverzace není veřejná a bez pozvánky se k ní nebudete moci znovu připojit." + "Opravdu chcete opustit tuto místnost? Jste tu jediná osoba. Pokud odejdete, nikdo se v budoucnu nebude moci připojit, včetně vás." + "Opravdu chcete opustit tuto místnost? Tato místnost není veřejná a bez pozvánky se nebudete moci znovu připojit." + "Vyberte vlastníky" + "Jste jediným vlastníkem této místnost. Než místnost opustíte, musíte vlastnictví převést na někoho jiného." + "Opravdu chcete opustit místnost?" + diff --git a/features/leaveroom/api/src/main/res/values-cy/translations.xml b/features/leaveroom/api/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..ea02d4d --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-cy/translations.xml @@ -0,0 +1,10 @@ + + + "Ydych chi\'n siŵr eich bod am adael y sgwrs hon? Dyw\'r sgwrs hon ddim yn gyhoeddus a fyddwch chi ddim yn gallu ailymuno heb wahoddiad." + "Ydych chi\'n siŵr eich bod am adael yr ystafell hon? Chi yw\'r unig berson yma. Os byddwch yn gadael, fydd neb yn gallu ymuno yn y dyfodol, gan gynnwys chi." + "Ydych chi\'n siŵr eich bod am adael yr ystafell hon? Dyw\'r ystafell hon ddim yn gyhoeddus a fyddwch chi ddim yn gallu ailymuno heb wahoddiad." + "Dewiswch Berchnogion" + "Chi yw unig berchennog yr ystafell hon. Mae angen i chi drosglwyddo perchnogaeth i rywun arall cyn i chi adael yr room." + "Trosglwyddo perchnogaeth" + "Ydych chi\'n siŵr eich bod am adael yr ystafell?" + diff --git a/features/leaveroom/api/src/main/res/values-da/translations.xml b/features/leaveroom/api/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..af49087 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-da/translations.xml @@ -0,0 +1,10 @@ + + + "Er du sikker på, at du vil forlade denne samtale? Denne samtale er ikke offentlig, og du kan ikke deltage igen uden en invitation." + "Er du sikker på, at du vil forlade dette rum? Du er den eneste person her. Hvis du går, vil ingen kunne tilslutte sig det i fremtiden, heller ikke dig." + "Er du sikker på, at du vil forlade dette rum? Rummet er ikke offentligt, så du vil ikke kunne deltage igen uden en invitation." + "Vælg ejere" + "Du er den eneste ejer af dette rum. Du skal overføre ejerskabet til en anden, før du forlader rummet." + "Overdrag ejerskab" + "Er du sikker på, at du ønsker at forlade rummet?" + diff --git a/features/leaveroom/api/src/main/res/values-de/translations.xml b/features/leaveroom/api/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..2a0326d --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-de/translations.xml @@ -0,0 +1,10 @@ + + + "Bist du sicher, dass du diese Unterhaltung verlassen willst? Diese Unterhaltung ist nicht öffentlich und du kannst ihr ohne Einladung nicht wieder beitreten." + "Bist du sicher, dass du diesen Chat verlassen möchtest? Du bist die einzige Person hier. Wenn du gehst, kann in Zukunft niemand mehr eintreten, auch du nicht." + "Bist du sicher, dass du diesen Chat verlassen möchtest? Dieser Chat ist nicht öffentlich und du kannst ihm ohne Einladung nicht erneut beitreten." + "Wähle Eigentümer" + "Du bist der einzige Eigentümer dieses Chats. Du musst die Eigentumsrechte an jemand anderen übertragen, bevor du den Chat verlässt." + "Eigentumsrechte übertragen" + "Bist du sicher, dass du den Chat verlassen willst?" + diff --git a/features/leaveroom/api/src/main/res/values-el/translations.xml b/features/leaveroom/api/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..b6ea1a6 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-el/translations.xml @@ -0,0 +1,7 @@ + + + "Είσαι σίγουρος ότι θέλεις να αποχωρήσεις από αυτή τη συζήτηση; Αυτή η συνομιλία δεν είναι δημόσια και δεν θα μπορείς να συμμετάσχεις ξανά χωρίς πρόσκληση." + "Είστε σίγουροι ότι θέλετε να αποχωρήσετε από αυτή την αίθουσα; Είστε το μόνο άτομο εδώ. Αν αποχωρήσετε, κανείς δεν θα μπορεί να ενταχθεί στο μέλλον, ούτε εσείς." + "Είστε σίγουροι ότι θέλετε να αποχωρήσετε από αυτήν την αίθουσα; Αυτή η αίθουσα δεν είναι δημόσια και δεν θα μπορέσετε να επανενταχτείτε χωρίς πρόσκληση." + "Είστε σίγουροι ότι θέλετε να αποχωρήσετε από την αίθουσα;" + diff --git a/features/leaveroom/api/src/main/res/values-es/translations.xml b/features/leaveroom/api/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..81b169e --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-es/translations.xml @@ -0,0 +1,7 @@ + + + "¿Estás seguro de que quieres salir de esta conversación? Esta conversación no es pública y no podrás volver a unirte sin una invitación." + "¿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ú." + "¿Seguro que quieres salir de esta sala? Esta sala no es pública y no podrás volver a entrar sin una invitación." + "¿Seguro que quieres salir de la sala?" + diff --git a/features/leaveroom/api/src/main/res/values-et/translations.xml b/features/leaveroom/api/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..78fff92 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-et/translations.xml @@ -0,0 +1,10 @@ + + + "Kas sa oled kindel, et soovid sellest vestlusest lahkuda? See vestlus pole avalik ja uuesti liitumiseks vajad kutset." + "Kas sa oled kindel, et soovid sellest jututoast lahkuda? Sa oled siin viimane osaleja ja peale sinu lahkumist ei saa keegi enam liituda, isegi sina mitte." + "Kas sa oled kindel, et soovid sellest jututoast lahkuda? See jututuba pole avalik ja uuesti liitumiseks vajad kutset." + "Vali omanikud" + "Sa oled selle jututoa ainus omanik. Enne jututoast lahkumist pead omandi üle andma kellelegi teisele." + "Anna omand üle" + "Kas sa oled kindel, et soovid sellest jututoast lahkuda?" + diff --git a/features/leaveroom/api/src/main/res/values-eu/translations.xml b/features/leaveroom/api/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..16fee54 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-eu/translations.xml @@ -0,0 +1,9 @@ + + + "Ziur elkarrizketa utzi nahi duzula? Ez da publikoa eta ezingo zara berriro batu gonbidapenik gabe." + "Ziur gelatik irten nahi duzula? Dagoen pertsona bakarra zara. Ateratzen bazara ezingo da inor batu etorkizunean, ezta zeu ere." + "Ziur gelatik irten nahi duzula? Gela hau ez da publikoa eta ezingo zara berriro batu gonbidapenik gabe." + "Aukeratu jabeak" + "Eskualdatu jabetza" + "Ziur gelatik atera nahi duzula?" + diff --git a/features/leaveroom/api/src/main/res/values-fa/translations.xml b/features/leaveroom/api/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..1670708 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-fa/translations.xml @@ -0,0 +1,8 @@ + + + "مطمئنید که می‌خواهید این اتاق را ترک کنید؟ تنها فرد این‌جا هستید. در صورت ترک، هیچ‌کسی از جمله خودتان در آینده نخواهد توانست به آن بپیوندد." + "مطمئنید که می‌خواهید این اتاق را ترک کنید؟ این اتاق عمومی نبوده قادر نخواهید بود بدون دعوت دوباره بپیوندید." + "گزینش مالکان" + "انتقال مالکیت" + "مطمئنید که می‌خواهید این اتاق را ترک کنید؟" + diff --git a/features/leaveroom/api/src/main/res/values-fi/translations.xml b/features/leaveroom/api/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..a97696c --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-fi/translations.xml @@ -0,0 +1,10 @@ + + + "Haluatko varmasti poistua keskustelusta? Tämä keskustelu ei ole julkinen ja et voi liittyä takaisin ilman kutsua." + "Haluatko varmasti poistua huoneesta? Olet huoneen ainoa jäsen. Jos poistut, kukaan ei voi liittyä takaisin, et edes sinä." + "Haluatko varmasti poistua huoneesta? Tämä huone ei ole julkinen ja et voi liittyä takaisin ilman kutsua." + "Valitse omistajat" + "Olet tämän huoneen ainoa omistaja. Sinun on siirrettävä omistajuus jollekulle toiselle ennen kuin poistut huoneesta." + "Siirrä omistajuus" + "Haluatko varmasti poistua huoneesta?" + diff --git a/features/leaveroom/api/src/main/res/values-fr/translations.xml b/features/leaveroom/api/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..5f75b7a --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-fr/translations.xml @@ -0,0 +1,10 @@ + + + "Êtes-vous sûr de vouloir quitter cette discussion ? Vous ne pourrez pas la rejoindre à nouveau sans y être invité." + "Êtes-vous sûr de vouloir quitter ce salon ? Vous êtes la seule personne ici. Si vous partez, personne ne pourra rejoindre le salon à l’avenir, y compris vous." + "Êtes-vous sûr de vouloir quitter ce salon ? Ce salon n’est pas public et vous ne pourrez pas le rejoindre sans invitation." + "Choisissez les propriétaires" + "Vous êtes le seul propriétaire de ce salon. Vous devez en transférer la propriété à quelqu’un d’autre avant de le quitter." + "Transférer la propriété" + "Êtes-vous sûr de vouloir quitter le salon ?" + diff --git a/features/leaveroom/api/src/main/res/values-hu/translations.xml b/features/leaveroom/api/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..90eae5c --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-hu/translations.xml @@ -0,0 +1,10 @@ + + + "Biztos, hogy elhagyja ezt a beszélgetést? Ez a beszélgetés nem nyilvános, és meghívás nélkül nem fog tudni visszacsatlakozni." + "Biztos, hogy elhagyja ezt a szobát? Ön az egyedüli ember itt. Ha kilép, akkor senki sem fog tudni csatlakozni a jövőben, Önt is beleértve." + "Biztos, hogy elhagyja ezt a szobát? Ez a szoba nem nyilvános, és meghívó nélkül nem fog tudni újra belépni." + "Tulajdonosok kiválasztása" + "Ön az egyetlen tulajdonosa ennek a szobának. Mielőtt elhagyja a szobát, át kell adnia a tulajdonjogot valaki másnak." + "Tulajdonjog átruházása" + "Biztos, hogy elhagyja a szobát?" + diff --git a/features/leaveroom/api/src/main/res/values-in/translations.xml b/features/leaveroom/api/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..bafad3f --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-in/translations.xml @@ -0,0 +1,7 @@ + + + "Apakah Anda yakin ingin keluar dari percakapan ini? Percakapan ini tidak umum dan Anda tidak akan dapat bergabung lagi tanpa undangan." + "Apakah Anda yakin ingin meninggalkan ruangan ini? Anda adalah orang satu-satunya di sini. Jika Anda pergi, tidak akan ada yang bisa bergabung di masa depan, termasuk Anda." + "Apakah Anda yakin ingin meninggalkan ruangan ini? Ruangan ini tidak umum dan Anda tidak akan dapat bergabung kembali tanpa undangan." + "Apakah Anda yakin ingin meninggalkan ruangan?" + diff --git a/features/leaveroom/api/src/main/res/values-it/translations.xml b/features/leaveroom/api/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..3eb2761 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-it/translations.xml @@ -0,0 +1,10 @@ + + + "Vuoi davvero abbandonare questa conversazione? La conversazione non è pubblica e non potrai rientrare senza un invito." + "Sei sicuro di voler lasciare questa stanza? Sei l\'unica persona presente. Se esci, nessuno potrà unirsi in futuro, te compreso." + "Sei sicuro di voler lasciare questa stanza? Questa stanza non è pubblica e non potrai rientrare senza un invito." + "Scegli i proprietari" + "Sei l\'unico proprietario di questa stanza. Devi trasferire la proprietà a qualcun altro prima di lasciare la stanza." + "Trasferisci proprietà" + "Sei sicuro di voler lasciare la stanza?" + diff --git a/features/leaveroom/api/src/main/res/values-ka/translations.xml b/features/leaveroom/api/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..f039365 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-ka/translations.xml @@ -0,0 +1,7 @@ + + + "დარწმუნებული ხართ ამ საუბრის დატოვებაში? იგი საჯარო არაა და მოწვევის გარეშე ხელახლა ვერ შეურთდებით." + "დარწმუნებული ბრძანდებით, რომ ამ ოთახის დატოვება გსურთ? თქვენ აქ მარტო ხართ და ჩატის დატოვებისას აქ თქვენს ჩათვლით ვერავინ ვერ გაწევრიანდება." + "დარწმუნებული ბრძანდებით, რომ ამ ოთახის დატოვება გსურთ? ეს ოთახი არ არის საჯარო და მოწვევის გარეშე ვერ შეძლებთ ხელახლა გაწევრიანებას." + "დარწმუნებული ბრძანდებით, რომ ოთახის დატოვება გსურთ?" + diff --git a/features/leaveroom/api/src/main/res/values-ko/translations.xml b/features/leaveroom/api/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..71f5bbe --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-ko/translations.xml @@ -0,0 +1,10 @@ + + + "이 대화를 나가시겠습니까? 이 대화는 공개되지 않았으므로 초대 없이는 다시 참여할 수 없습니다." + "정말로 이 방을 떠나시겠어요? 이 방에서 유일하게 남은 사용자입니다. 나간 이후부터는 당신을 포함해서 아무도 다시 참여할 수 없어요." + "정말로 이 방을 떠나시겠어요? 이 방은 공개가 아니기 때문에 초대 없이는 다시 참여할 수 없습니다." + "소유자 선택" + "이 방의 유일한 소유자는 귀하입니다. 방을 떠나기 전에 다른 사람에게 소유권을 양도해야 합니다." + "소유권 이전" + "정말로 이 방을 떠나시겠어요?" + diff --git a/features/leaveroom/api/src/main/res/values-lt/translations.xml b/features/leaveroom/api/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..c204c28 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-lt/translations.xml @@ -0,0 +1,6 @@ + + + "Ar tikrai norite išeiti iš šio kambario? Jūs esate vienintelis žmogus jame. Jei išeisite, niekas negalės prisijungti ateityje, įskaitant Jus." + "Ar tikrai norite išeiti iš šio kambario? Šis kambarys nėra viešas ir negalėsite vėl prisijungti be kvietimo." + "Ar tikrai norite išeiti iš kambario?" + diff --git a/features/leaveroom/api/src/main/res/values-nb/translations.xml b/features/leaveroom/api/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..7286ca9 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-nb/translations.xml @@ -0,0 +1,10 @@ + + + "Er du sikker på at du vil forlate denne samtalen? Denne samtalen er ikke offentlig, og du vil ikke kunne bli med igjen uten en invitasjon." + "Er du sikker på at du vil forlate dette rommet? Du er den eneste personen her. Hvis du forlater, vil ingen kunne bli med i fremtiden, inkludert deg." + "Er du sikker på at du vil forlate dette rommet? Dette rommet er ikke offentlig, og du vil ikke kunne bli med igjen uten en invitasjon." + "Velg eiere" + "Du er den eneste eieren av dette rommet. Du må overføre eierskapet til noen andre før du forlater rommet." + "Overfør eierskap" + "Er du sikker på at du vil forlate rommet?" + diff --git a/features/leaveroom/api/src/main/res/values-nl/translations.xml b/features/leaveroom/api/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..bef0aff --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-nl/translations.xml @@ -0,0 +1,7 @@ + + + "Weet je zeker dat je dit gesprek wilt verlaten? Dit gesprek is niet openbaar en je kunt niet opnieuw deelnemen zonder een uitnodiging." + "Weet je zeker dat je deze kamer wilt verlaten? Je bent de enige persoon hier. Als je weggaat, kan er in de toekomst niemand meer toetreden, ook jij niet." + "Weet je zeker dat je deze kamer wilt verlaten? Deze kamer is niet openbaar en je kunt niet opnieuw deelnemen zonder een uitnodiging." + "Weet je zeker dat je de kamer wilt verlaten?" + diff --git a/features/leaveroom/api/src/main/res/values-pl/translations.xml b/features/leaveroom/api/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..440b806 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-pl/translations.xml @@ -0,0 +1,10 @@ + + + "Czy na pewno chcesz opuścić tę konwersację? Konwersacja nie jest publiczna i nie będziesz mógł dołączyć ponownie bez zaproszenia." + "Jesteś pewien, że chcesz opuścić ten pokój? Jesteś tu jedyną osobą. Jeśli wyjdziesz, nikt nie będzie mógł dołączyć, w tym Ty." + "Czy na pewno chcesz opuścić ten pokój? Ten pokój nie jest publiczny i nie będziesz mógł do niego wrócić bez zaproszenia." + "Wybierz właścicieli" + "Jesteś jedynym właścicielem tego pokoju. Musisz przenieść własność na kogoś innego, zanim go opuścisz." + "Przenieś własność" + "Jesteś pewien, że chcesz wyjść z tego pokoju?" + diff --git a/features/leaveroom/api/src/main/res/values-pt-rBR/translations.xml b/features/leaveroom/api/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..178e43b --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,10 @@ + + + "Tem certeza de que deseja sair dessa conversa? Essa conversa não é pública e você não poderá entrar novamente sem um convite." + "Tem certeza de que deseja sair desta sala? Você é a única pessoa aqui. Se você sair, ninguém poderá entrar no futuro, até mesmo você." + "Tem certeza de que deseja sair desta sala? Esta sala não é pública e você não poderá entrar novamente sem um convite." + "Escolher proprietários" + "Você é o único proprietário desta sala. Você precisa transferir a posse para outra pessoa antes de sair da sala." + "Transferir posse" + "Tem certeza de que deseja sair da sala?" + diff --git a/features/leaveroom/api/src/main/res/values-pt/translations.xml b/features/leaveroom/api/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..4832cd9 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-pt/translations.xml @@ -0,0 +1,10 @@ + + + "Tens a certeza que queres sair desta conversa? Não é pública, logo não poderás voltar a participar sem um convite." + "Tens a certeza que queres sair desta sala? És o único participante. Se saíres, ninguém mais poderá entrar, incluindo tu." + "Tens a certeza que queres sair desta sala? Atenta que não é pública e portanto não poderás voltar a entrar sem um novo convite." + "Escolher donos" + "És o único dono/dona desta sala. Se quiseres sair, terás que a transferir para alguém primeiro." + "Transferir posse" + "Tens a certeza que queres sair da sala?" + diff --git a/features/leaveroom/api/src/main/res/values-ro/translations.xml b/features/leaveroom/api/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..8f50414 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-ro/translations.xml @@ -0,0 +1,10 @@ + + + "Sunteți sigur că doriți să părăsiți această conversație? Această conversație nu este publică și nu veți putea reveni fără o invitație." + "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." + "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." + "Alegeți proprietari" + "Sunteți singurul proprietar al acestei camere. Trebuie să transferați proprietatea către o altă persoană înainte de a părăsi camera." + "Transferați proprietatea" + "Sunteți sigur că vreți să părăsiți camera?" + diff --git a/features/leaveroom/api/src/main/res/values-ru/translations.xml b/features/leaveroom/api/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..907b729 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-ru/translations.xml @@ -0,0 +1,10 @@ + + + "Вы уверены, что хотите покинуть беседу? Эта беседа не является общедоступной, и Вы не сможете присоединиться к ней без приглашения." + "Вы уверены, что хотите покинуть эту комнату? Вы здесь единственный человек. Если вы уйдете, никто не сможет присоединиться в будущем, включая вас." + "Вы уверены, что хотите покинуть эту комнату? Эта комната не является общедоступной, и Вы не сможете присоединиться к ней без приглашения." + "Назначить владельцев" + "Вы единственный владелец этой комнаты. Перед тем, как её покинуть, необходимо передать владение кому-нибудь другому." + "Передача владения" + "Вы уверены, что хотите покинуть комнату?" + diff --git a/features/leaveroom/api/src/main/res/values-sk/translations.xml b/features/leaveroom/api/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..c9eb9c4 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-sk/translations.xml @@ -0,0 +1,10 @@ + + + "Ste si istí, že chcete opustiť konverzáciu?" + "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." + "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ť." + "Vybrať vlastníkov" + "Ste jediným vlastníkom tejto miestnosti. Pred opustením miestnosti musíte previesť vlastníctvo na niekoho iného." + "Preniesť vlastníctvo" + "Ste si istí, že chcete opustiť miestnosť?" + diff --git a/features/leaveroom/api/src/main/res/values-sv/translations.xml b/features/leaveroom/api/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..2d26a6e --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-sv/translations.xml @@ -0,0 +1,10 @@ + + + "Är du säker på att du vill lämna den här konversationen? Den här konversationen är inte offentlig och du kommer inte att kunna gå med igen utan en inbjudan." + "Är du säker på att du vill lämna det här rummet? Du är den enda personen här. Om du lämnar kommer ingen att kunna gå med i framtiden, inklusive du." + "Är du säker på att du vill lämna det här rummet? Detta rum är inte offentligt och du kommer inte att kunna gå med igen utan en inbjudan." + "Välj ägare" + "Du är den enda ägaren av det här rummet. Du måste överföra ägarskapet till någon annan innan du lämnar rummet." + "Överför ägarskap" + "Är du säker på att du vill lämna rummet?" + diff --git a/features/leaveroom/api/src/main/res/values-tr/translations.xml b/features/leaveroom/api/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..39ba596 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-tr/translations.xml @@ -0,0 +1,7 @@ + + + "Bu sohbetten ayrılmak istediğinizden emin misiniz? Bu sohbet herkese açık değil ve davet olmadan tekrar katılamayacaksınız." + "Bu odadan ayrılmak istediğinizden emin misiniz? Burada tek kişi sizsiniz. Ayrılırsanız, siz de dahil olmak üzere gelecekte kimse katılamayacak." + "Bu odadan ayrılmak istediğinizden emin misiniz? Bu oda herkese açık değildir ve davet olmadan tekrar katılamazsınız." + "Odadan çıkmak istediğinden emin misin?" + diff --git a/features/leaveroom/api/src/main/res/values-uk/translations.xml b/features/leaveroom/api/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..0c7c92b --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-uk/translations.xml @@ -0,0 +1,10 @@ + + + "Ви впевнені, що хочете залишити цю розмову? Ця розмова не загальнодоступна, і ви не зможете знову приєднатися без запрошення." + "Ви впевнені, що хочете вийти з цієї кімнати? Ви тут єдина людина. Якщо ви вийдете, ніхто в майбутньому не зможе приєднатися, у тому числі й ви." + "Ви впевнені, що хочете вийти з цієї кімнати? Ця кімната не загальнодоступна, і ви не зможете повернутися до неї без запрошення." + "Оберіть власників" + "Ви є єдиним власником цієї кімнати. Перед тим як вийти з кімнати, вам потрібно передати право власності іншій особі." + "Передати право власності" + "Ви впевнені, що хочете вийти з кімнати?" + diff --git a/features/leaveroom/api/src/main/res/values-ur/translations.xml b/features/leaveroom/api/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..5a6cfea --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-ur/translations.xml @@ -0,0 +1,7 @@ + + + "کیا آپ واقعی اس گفتگو کو چھوڑنا چاہتے ہیں؟ یہ گفتگو عوامی نہیں ہے اور آپ دعوت نامے کے بغیر دوبارہ شامل نہیں ہو سکیں گے۔" + "کیا آپ واقعی یہ کمرہ چھوڑنا چاہتے ہیں؟ آپ یہاں واحد شخص ہیں۔ اگر آپ چھوڑتے ہیں، تو مستقبل میں کوئی بھی شامل نہیں ہو سکے گا، آپ سمیت۔" + "کیا آپ واقعی یہ کمرہ چھوڑنا چاہتے ہیں؟ یہ کمرہ عوامی نہیں اور آپ دعوت نامے کے بغیر پھر شامل نہیں ہو پائیں گے۔" + "کیا آپ واقعی کمرہ چھوڑنا چاہتے ہیں؟" + diff --git a/features/leaveroom/api/src/main/res/values-uz/translations.xml b/features/leaveroom/api/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..e04bbf4 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-uz/translations.xml @@ -0,0 +1,10 @@ + + + "Bu suhbatni tark etmoqchi ekanligingizga ishonchingiz komilmi? Bu suhbat hammaga ochiq emas va siz taklifsiz qayta qo‘shila olmaysiz." + "Bu xonani tark etmoqchi ekanligingizga ishonchingiz komilmi? Siz bu yerda yagona odamsiz. Agar siz tark etsangiz, kelajakda hech kim qo\'shila olmaydi, jumladan siz ham." + "Bu xonani tark etmoqchi ekanligingizga ishonchingiz komilmi? Bu xona ochiq emas va siz taklifsiz qayta qo‘shila olmaysiz." + "Egalarni tanlang" + "Siz bu xonaning yagona egasisiz. Xonadan chiqishdan oldin egalikni boshqaga topshirishingiz kerak." + "Egalikni topshirish" + "Xonani tark etmoqchi ekanligingizga ishonchingiz komilmi?" + diff --git a/features/leaveroom/api/src/main/res/values-zh-rTW/translations.xml b/features/leaveroom/api/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..96e3b57 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,10 @@ + + + "您確定要離開對話嗎?此對話不是公開的,如果沒有收到邀請,您無法重新加入。" + "您確定要離開聊天室嗎?這裡只有您一個人。如果您離開了,包含您在內的所有人都無法再進入此聊天室。" + "您確定要離開聊天室嗎?此聊天室不是公開的,如果沒有收到邀請,您無法重新加入。" + "選擇擁有者" + "您是此聊天室的唯一擁有者。離開聊天室前,您必須將所有權移轉給其他人。" + "轉移所有權" + "您確定要離開聊天室嗎?" + diff --git a/features/leaveroom/api/src/main/res/values-zh/translations.xml b/features/leaveroom/api/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..6b7f175 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-zh/translations.xml @@ -0,0 +1,10 @@ + + + "您确定要离开此对话吗?此对话不公开,未经邀请您将无法重新加入。" + "确定要离开此聊天室吗?此处只有你一个人。如果离开此聊天室,包括你在内的所有人都将无法进入。" + "确定要离开此聊天室吗?此聊天室不公开,没有邀请你将无法重新加入。" + "选择所有者" + "您是本房间的唯一所有者。离开房间前,您需要将所有权转移给他人。" + "转让所有权" + "确定要离开聊天室吗?" + diff --git a/features/leaveroom/api/src/main/res/values/localazy.xml b/features/leaveroom/api/src/main/res/values/localazy.xml new file mode 100644 index 0000000..5f9ab3a --- /dev/null +++ b/features/leaveroom/api/src/main/res/values/localazy.xml @@ -0,0 +1,10 @@ + + + "Are you sure that you want to leave this conversation? This conversation is not public and you won\'t be able to rejoin without an invite." + "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." + "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." + "Choose owners" + "You\'re the only owner of this room. You need to transfer ownership to someone else before you leave the room." + "Transfer ownership" + "Are you sure that you want to leave the room?" + diff --git a/features/leaveroom/impl/build.gradle.kts b/features/leaveroom/impl/build.gradle.kts new file mode 100644 index 0000000..cc07e66 --- /dev/null +++ b/features/leaveroom/impl/build.gradle.kts @@ -0,0 +1,35 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.leaveroom.impl" +} + +setupDependencyInjection() + +dependencies { + api(projects.features.leaveroom.api) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.push.api) + + testCommonDependencies(libs) + testImplementation(libs.coroutines.core) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.push.test) +} diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomEvent.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomEvent.kt new file mode 100644 index 0000000..37ec70e --- /dev/null +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomEvent.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.leaveroom.impl + +import io.element.android.features.leaveroom.api.LeaveRoomEvent + +sealed interface InternalLeaveRoomEvent : LeaveRoomEvent { + data object ResetState : InternalLeaveRoomEvent +} diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomRenderer.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomRenderer.kt new file mode 100644 index 0000000..c9335bb --- /dev/null +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomRenderer.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.leaveroom.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.leaveroom.api.LeaveRoomRenderer +import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId + +@ContributesBinding(SessionScope::class) +class InternalLeaveRoomRenderer : LeaveRoomRenderer { + @Composable + override fun Render(state: LeaveRoomState, onSelectNewOwners: (RoomId) -> Unit, modifier: Modifier) { + if (state is InternalLeaveRoomState) { + LeaveRoomView(state, onSelectNewOwners) + } else { + error("Unsupported state type ${state.javaClass}") + } + } +} diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomState.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomState.kt new file mode 100644 index 0000000..68607f2 --- /dev/null +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.leaveroom.impl + +import androidx.compose.runtime.Immutable +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId + +data class InternalLeaveRoomState( + val leaveAction: AsyncAction, + override val eventSink: (LeaveRoomEvent) -> Unit +) : LeaveRoomState + +@Immutable +sealed interface Confirmation : AsyncAction.Confirming { + data class Dm(val roomId: RoomId) : Confirmation + data class Generic(val roomId: RoomId) : Confirmation + data class PrivateRoom(val roomId: RoomId) : Confirmation + data class LastUserInRoom(val roomId: RoomId) : Confirmation + data class LastOwnerInRoom(val roomId: RoomId) : Confirmation +} diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomStateProvider.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomStateProvider.kt new file mode 100644 index 0000000..769015f --- /dev/null +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/InternalLeaveRoomStateProvider.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.leaveroom.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId + +class InternalLeaveRoomStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aLeaveRoomState(), + aLeaveRoomState( + leaveAction = Confirmation.Generic(roomId = A_ROOM_ID), + ), + aLeaveRoomState( + leaveAction = Confirmation.PrivateRoom(roomId = A_ROOM_ID), + ), + aLeaveRoomState( + leaveAction = Confirmation.LastUserInRoom(roomId = A_ROOM_ID), + ), + aLeaveRoomState( + leaveAction = Confirmation.Dm(roomId = A_ROOM_ID), + ), + aLeaveRoomState( + leaveAction = Confirmation.LastOwnerInRoom(roomId = A_ROOM_ID), + ), + aLeaveRoomState( + leaveAction = AsyncAction.Loading, + ), + aLeaveRoomState( + leaveAction = AsyncAction.Failure(RuntimeException("Something went wrong")), + ), + ) +} + +private val A_ROOM_ID = RoomId("!aRoomId:aDomain") + +fun aLeaveRoomState( + leaveAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (LeaveRoomEvent) -> Unit = {}, +) = InternalLeaveRoomState( + leaveAction = leaveAction, + eventSink = eventSink, +) diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt new file mode 100644 index 0000000..c371d42 --- /dev/null +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Inject +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.libraries.architecture.AsyncAction +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.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.room.powerlevels.usersWithRole +import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import timber.log.Timber + +@Inject +class LeaveRoomPresenter( + private val client: MatrixClient, + private val dispatchers: CoroutineDispatchers, + private val notificationConversationService: NotificationConversationService, +) : Presenter { + @Composable + override fun present(): LeaveRoomState { + val scope = rememberCoroutineScope() + val leaveAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + return InternalLeaveRoomState( + leaveAction = leaveAction.value, + ) { event -> + when (event) { + is LeaveRoomEvent.LeaveRoom -> + if (event.needsConfirmation) { + scope.showLeaveRoomAlert(roomId = event.roomId, leaveAction = leaveAction) + } else { + scope.leaveRoom(roomId = event.roomId, leaveAction = leaveAction) + } + InternalLeaveRoomEvent.ResetState -> leaveAction.value = AsyncAction.Uninitialized + } + } + } + + private fun CoroutineScope.showLeaveRoomAlert( + roomId: RoomId, + leaveAction: MutableState>, + ) = launch(dispatchers.io) { + client.getRoom(roomId)?.use { room -> + val roomInfo = room.roomInfoFlow.first() + leaveAction.value = when { + roomInfo.isDm -> Confirmation.Dm(roomId) + room.isLastOwner() && roomInfo.joinedMembersCount > 1L -> Confirmation.LastOwnerInRoom(roomId) + // If unknown, assume the room is private + roomInfo.isPublic == null || roomInfo.isPublic == false -> Confirmation.PrivateRoom(roomId) + roomInfo.joinedMembersCount == 1L -> Confirmation.LastUserInRoom(roomId) + else -> Confirmation.Generic(roomId) + } + } + } + + private fun CoroutineScope.leaveRoom( + roomId: RoomId, + leaveAction: MutableState>, + ) = launch(dispatchers.io) { + leaveAction.runCatchingUpdatingState { + client.getRoom(roomId)!!.use { room -> + room + .leave() + .onSuccess { notificationConversationService.onLeftRoom(client.sessionId, roomId) } + .onFailure { Timber.e(it, "Error while leaving room ${room.roomId}") } + .getOrThrow() + } + } + } + + private suspend fun BaseRoom.isLastOwner(): Boolean { + if (roomInfoFlow.value.isDm) { + // DMs are not owned by the user, so we can return false + return false + } else { + val hasPrivilegedCreatorRole = roomInfoFlow.value.privilegedCreatorRole + if (!hasPrivilegedCreatorRole) return false + + val creators = usersWithRole(RoomMember.Role.Owner(isCreator = true)).first() + val superAdmins = usersWithRole(RoomMember.Role.Owner(isCreator = false)).first() + val owners = creators + superAdmins + return owners.size == 1 && owners.first().userId == sessionId + } + } +} diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomView.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomView.kt new file mode 100644 index 0000000..131801b --- /dev/null +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomView.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.leaveroom.impl + +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.R +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.ui.strings.CommonStrings + +@Suppress("LambdaParameterEventTrailing") +@Composable +fun LeaveRoomView( + state: InternalLeaveRoomState, + onSelectNewOwners: (RoomId) -> Unit, +) { + AsyncActionView( + state.leaveAction, + onSuccess = { + state.eventSink(InternalLeaveRoomEvent.ResetState) + }, + onErrorDismiss = { + state.eventSink(InternalLeaveRoomEvent.ResetState) + }, + confirmationDialog = { confirmation -> + if (confirmation is Confirmation) { + LeaveRoomConfirmationDialog( + confirmation = confirmation, + eventSink = state.eventSink, + onSelectNewOwners = onSelectNewOwners, + ) + } + }, + errorTitle = { stringResource(CommonStrings.common_something_went_wrong) }, + errorMessage = { stringResource(CommonStrings.error_network_or_server_issue) }, + progressDialog = { LeaveRoomProgressDialog() }, + ) +} + +@Composable +private fun LeaveRoomConfirmationDialog( + confirmation: Confirmation, + eventSink: (LeaveRoomEvent) -> Unit, + onSelectNewOwners: (RoomId) -> Unit, +) { + val defaultOnSubmitClick = { roomId: RoomId -> { eventSink(LeaveRoomEvent.LeaveRoom(roomId, needsConfirmation = false)) } } + val defaultDismissAction = { eventSink(InternalLeaveRoomEvent.ResetState) } + when (confirmation) { + is Confirmation.Dm -> LeaveRoomConfirmationDialog( + text = stringResource(R.string.leave_room_alert_private_subtitle), + isDm = false, + onSubmitClick = defaultOnSubmitClick(confirmation.roomId), + onDismiss = defaultDismissAction, + ) + + is Confirmation.PrivateRoom -> LeaveRoomConfirmationDialog( + text = stringResource(R.string.leave_room_alert_private_subtitle), + isDm = false, + onSubmitClick = defaultOnSubmitClick(confirmation.roomId), + onDismiss = defaultDismissAction, + ) + + is Confirmation.LastUserInRoom -> LeaveRoomConfirmationDialog( + text = stringResource(R.string.leave_room_alert_empty_subtitle), + isDm = false, + onSubmitClick = defaultOnSubmitClick(confirmation.roomId), + onDismiss = defaultDismissAction, + ) + + is Confirmation.LastOwnerInRoom -> LeaveRoomConfirmationDialog( + title = stringResource(R.string.leave_room_alert_select_new_owner_title), + text = stringResource(R.string.leave_room_alert_select_new_owner_subtitle), + isDm = false, + submitText = stringResource(R.string.leave_room_alert_select_new_owner_action), + destructiveSubmit = true, + onSubmitClick = { + onSelectNewOwners(confirmation.roomId) + eventSink(InternalLeaveRoomEvent.ResetState) + }, + onDismiss = defaultDismissAction, + ) + + is Confirmation.Generic -> LeaveRoomConfirmationDialog( + text = stringResource(R.string.leave_room_alert_subtitle), + isDm = false, + onSubmitClick = defaultOnSubmitClick(confirmation.roomId), + onDismiss = defaultDismissAction, + ) + } +} + +@Composable +private fun LeaveRoomConfirmationDialog( + isDm: Boolean, + text: String, + onSubmitClick: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + title: String = stringResource(if (isDm) CommonStrings.action_leave_conversation else CommonStrings.action_leave_room), + submitText: String = stringResource(CommonStrings.action_leave), + destructiveSubmit: Boolean = false, +) { + ConfirmationDialog( + title = title, + content = text, + submitText = submitText, + onSubmitClick = onSubmitClick, + onDismiss = onDismiss, + destructiveSubmit = destructiveSubmit, + modifier = modifier, + ) +} + +@Composable +private fun LeaveRoomProgressDialog(modifier: Modifier = Modifier) { + ProgressDialog( + text = stringResource(CommonStrings.common_leaving_room), + modifier = modifier, + ) +} + +@PreviewsDayNight +@Composable +internal fun LeaveRoomViewPreview( + @PreviewParameter(InternalLeaveRoomStateProvider::class) state: InternalLeaveRoomState +) = ElementPreview { + Box( + modifier = Modifier.size(300.dp, 300.dp), + propagateMinConstraints = true, + ) { + LeaveRoomView(state = state, onSelectNewOwners = {}) + } +} diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/di/LeaveRoomModule.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/di/LeaveRoomModule.kt new file mode 100644 index 0000000..db5b141 --- /dev/null +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/di/LeaveRoomModule.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.leaveroom.impl.di + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.features.leaveroom.impl.LeaveRoomPresenter +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.SessionScope + +@ContributesTo(SessionScope::class) +@BindingContainer +interface LeaveRoomModule { + @Binds + fun bindLeaveRoomPresenter(presenter: LeaveRoomPresenter): Presenter +} diff --git a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt new file mode 100644 index 0000000..59d2c1c --- /dev/null +++ b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.leaveroom.impl + +import app.cash.molecule.RecompositionMode +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.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.MatrixClient +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.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class LeaveBaseRoomPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state hides all dialogs`() = runTest { + createLeaveRoomPresenter() + .stateFlow() + .test { + val initialState = awaitItem() + assertThat(initialState.leaveAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - show generic confirmation`() = runTest { + val presenter = createLeaveRoomPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeBaseRoom().apply { + givenRoomInfo(aRoomInfo(isDirect = false, isPublic = true, joinedMembersCount = 10)) + } + ) + } + ) + presenter.stateFlow().test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true)) + val confirmationState = awaitItem() + assertThat(confirmationState.leaveAction).isEqualTo(Confirmation.Generic(A_ROOM_ID)) + } + } + + @Test + fun `present - show private room confirmation`() = runTest { + val presenter = createLeaveRoomPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeBaseRoom().apply { + givenRoomInfo(aRoomInfo(isPublic = false)) + }, + ) + } + ) + presenter.stateFlow().test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true)) + val confirmationState = awaitItem() + assertThat(confirmationState.leaveAction).isEqualTo(Confirmation.PrivateRoom(A_ROOM_ID)) + } + } + + @Test + fun `present - show last user in room confirmation`() = runTest { + val presenter = createLeaveRoomPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeBaseRoom().apply { + givenRoomInfo(aRoomInfo(joinedMembersCount = 1)) + }, + ) + } + ) + presenter.stateFlow().test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true)) + val confirmationState = awaitItem() + assertThat(confirmationState.leaveAction).isEqualTo(Confirmation.LastUserInRoom(A_ROOM_ID)) + } + } + + @Test + fun `present - show DM confirmation`() = runTest { + val presenter = createLeaveRoomPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeBaseRoom().apply { + givenRoomInfo(aRoomInfo(isDirect = true, activeMembersCount = 2)) + }, + ) + } + ) + presenter.stateFlow().test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true)) + val confirmationState = awaitItem() + assertThat(confirmationState.leaveAction).isEqualTo(Confirmation.Dm(A_ROOM_ID)) + } + } + + @Test + fun `present - leaving a room leaves the room`() = runTest { + val leaveRoomLambda = lambdaRecorder> { Result.success(Unit) } + val presenter = createLeaveRoomPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeBaseRoom( + leaveRoomLambda = leaveRoomLambda + ), + ) + }, + ) + presenter.stateFlow().test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = false)) + advanceUntilIdle() + cancelAndIgnoreRemainingEvents() + assert(leaveRoomLambda) + .isCalledOnce() + .withNoParameter() + } + } + + @Test + fun `present - show error if leave room fails`() = runTest { + val presenter = createLeaveRoomPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeBaseRoom( + leaveRoomLambda = { Result.failure(RuntimeException("Blimey!")) } + ), + ) + } + ) + presenter.stateFlow().test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = false)) + val progressState = awaitItem() + assertThat(progressState.leaveAction).isEqualTo(AsyncAction.Loading) + val errorState = awaitItem() + assertThat(errorState.leaveAction).isInstanceOf(AsyncAction.Failure::class.java) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - reset state after error`() = runTest { + val presenter = createLeaveRoomPresenter( + client = FakeMatrixClient().apply { + givenGetRoomResult( + roomId = A_ROOM_ID, + result = FakeBaseRoom( + leaveRoomLambda = { Result.failure(RuntimeException("Blimey!")) } + ), + ) + } + ) + presenter.stateFlow().test { + val initialState = awaitItem() + initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = false)) + skipItems(1) // Skip show progress state + val errorState = awaitItem() + assertThat(errorState.leaveAction).isInstanceOf(AsyncAction.Failure::class.java) + errorState.eventSink(InternalLeaveRoomEvent.ResetState) + val hiddenErrorState = awaitItem() + assertThat(hiddenErrorState.leaveAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + private fun LeaveRoomPresenter.stateFlow(): Flow { + return moleculeFlow(RecompositionMode.Immediate) { + present() + }.filterIsInstance(InternalLeaveRoomState::class) + } +} + +private fun TestScope.createLeaveRoomPresenter( + client: MatrixClient = FakeMatrixClient(), +): LeaveRoomPresenter = LeaveRoomPresenter( + client = client, + dispatchers = testCoroutineDispatchers(false), + notificationConversationService = FakeNotificationConversationService(), +) diff --git a/features/licenses/api/build.gradle.kts b/features/licenses/api/build.gradle.kts new file mode 100644 index 0000000..fcbf81f --- /dev/null +++ b/features/licenses/api/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.licenses.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) +} diff --git a/features/licenses/api/src/main/kotlin/io/element/android/features/licenses/api/OpenSourceLicensesEntryPoint.kt b/features/licenses/api/src/main/kotlin/io/element/android/features/licenses/api/OpenSourceLicensesEntryPoint.kt new file mode 100644 index 0000000..835af35 --- /dev/null +++ b/features/licenses/api/src/main/kotlin/io/element/android/features/licenses/api/OpenSourceLicensesEntryPoint.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.licenses.api + +import io.element.android.libraries.architecture.SimpleFeatureEntryPoint + +interface OpenSourceLicensesEntryPoint : SimpleFeatureEntryPoint diff --git a/features/licenses/impl/build.gradle.kts b/features/licenses/impl/build.gradle.kts new file mode 100644 index 0000000..52f21ac --- /dev/null +++ b/features/licenses/impl/build.gradle.kts @@ -0,0 +1,35 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "io.element.android.features.licenses.impl" +} + +setupDependencyInjection() + +dependencies { + implementation(libs.serialization.json) + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.core) + implementation(projects.libraries.uiStrings) + api(projects.features.licenses.api) + + testCommonDependencies(libs) + testImplementation(libs.coroutines.core) + testImplementation(projects.libraries.matrix.test) +} diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/DefaultOpenSourcesLicensesEntryPoint.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/DefaultOpenSourcesLicensesEntryPoint.kt new file mode 100644 index 0000000..671510e --- /dev/null +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/DefaultOpenSourcesLicensesEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.licenses.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.licenses.api.OpenSourceLicensesEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultOpenSourcesLicensesEntryPoint : OpenSourceLicensesEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext): Node { + return parentNode.createNode(buildContext) + } +} diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/DependenciesFlowNode.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/DependenciesFlowNode.kt new file mode 100644 index 0000000..34e734a --- /dev/null +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/DependenciesFlowNode.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.licenses.impl + +import android.os.Parcelable +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.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.push +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.licenses.impl.details.DependenciesDetailsNode +import io.element.android.features.licenses.impl.list.DependencyLicensesListNode +import io.element.android.features.licenses.impl.model.DependencyLicenseItem +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.createNode +import kotlinx.parcelize.Parcelize + +@ContributesNode(AppScope::class) +@AssistedInject +class DependenciesFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.LicensesList, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + sealed interface NavTarget : Parcelable { + @Parcelize + data object LicensesList : NavTarget + + @Parcelize + data class LicenseDetails(val license: DependencyLicenseItem) : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.LicensesList -> { + val callback = object : DependencyLicensesListNode.Callback { + override fun navigateToLicense(license: DependencyLicenseItem) { + backstack.push(NavTarget.LicenseDetails(license)) + } + } + createNode(buildContext, listOf(callback)) + } + is NavTarget.LicenseDetails -> { + createNode(buildContext, listOf(DependenciesDetailsNode.Inputs(navTarget.license))) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView(modifier) + } +} diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/LicensesProvider.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/LicensesProvider.kt new file mode 100644 index 0000000..7ae6fa7 --- /dev/null +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/LicensesProvider.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.licenses.impl + +import android.content.Context +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.licenses.impl.model.DependencyLicenseItem +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.annotations.ApplicationContext +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream + +interface LicensesProvider { + suspend fun provides(): List +} + +@ContributesBinding(AppScope::class) +class AssetLicensesProvider( + @ApplicationContext private val context: Context, + private val dispatchers: CoroutineDispatchers, +) : LicensesProvider { + @OptIn(ExperimentalSerializationApi::class) + override suspend fun provides(): List { + return withContext(dispatchers.io) { + context.assets.open("licensee-artifacts.json").use { inputStream -> + val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + json.decodeFromStream>(inputStream) + .sortedBy { it.safeName.lowercase() } + } + } + } +} diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/details/DependenciesDetailsNode.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/details/DependenciesDetailsNode.kt new file mode 100644 index 0000000..83e7ae2 --- /dev/null +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/details/DependenciesDetailsNode.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.licenses.impl.details + +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.licenses.impl.model.DependencyLicenseItem +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs + +@ContributesNode(AppScope::class) +@AssistedInject +class DependenciesDetailsNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : Node( + buildContext = buildContext, + plugins = plugins +) { + data class Inputs( + val licenseItem: DependencyLicenseItem, + ) : NodeInputs + + private val licenseItem = inputs().licenseItem + + @Composable + override fun View(modifier: Modifier) { + DependenciesDetailsView( + modifier = modifier, + licenseItem = licenseItem, + onBack = ::navigateUp + ) + } +} diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/details/DependenciesDetailsView.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/details/DependenciesDetailsView.kt new file mode 100644 index 0000000..f463a6b --- /dev/null +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/details/DependenciesDetailsView.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.licenses.impl.details + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import io.element.android.features.licenses.impl.list.aDependencyLicenseItem +import io.element.android.features.licenses.impl.model.DependencyLicenseItem +import io.element.android.libraries.designsystem.components.ClickableLinkText +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.TopAppBar + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DependenciesDetailsView( + licenseItem: DependencyLicenseItem, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + titleStr = licenseItem.safeName, + navigationIcon = { BackButton(onClick = onBack) }, + ) + }, + ) { contentPadding -> + LazyColumn( + modifier = Modifier.padding(contentPadding), + ) { + val licenses = licenseItem.licenses.orEmpty() + + licenseItem.unknownLicenses.orEmpty() + items(licenses) { license -> + val text = buildString { + if (license.name != null) { + append(license.name) + append("\n") + append("\n") + } + if (license.url != null) { + append(license.url) + } + } + ListItem( + headlineContent = { + ClickableLinkText( + text = text, + interactionSource = remember { MutableInteractionSource() }, + ) + } + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun DependenciesDetailsViewPreview() = ElementPreview { + DependenciesDetailsView( + licenseItem = aDependencyLicenseItem(), + onBack = {} + ) +} diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListEvent.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListEvent.kt new file mode 100644 index 0000000..bf53b05 --- /dev/null +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListEvent.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.licenses.impl.list + +sealed interface DependencyLicensesListEvent { + data class SetFilter(val filter: String) : DependencyLicensesListEvent +} diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListNode.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListNode.kt new file mode 100644 index 0000000..424dad4 --- /dev/null +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListNode.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.licenses.impl.list + +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.licenses.impl.model.DependencyLicenseItem +import io.element.android.libraries.architecture.callback + +@ContributesNode(AppScope::class) +@AssistedInject +class DependencyLicensesListNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: DependencyLicensesListPresenter, +) : Node( + buildContext = buildContext, + plugins = plugins +) { + interface Callback : Plugin { + fun navigateToLicense(license: DependencyLicenseItem) + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + DependencyLicensesListView( + state = state, + onBackClick = ::navigateUp, + onOpenLicense = callback::navigateToLicense, + ) + } +} diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListPresenter.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListPresenter.kt new file mode 100644 index 0000000..fe69739 --- /dev/null +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListPresenter.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.licenses.impl.list + +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 dev.zacsweers.metro.Inject +import io.element.android.features.licenses.impl.LicensesProvider +import io.element.android.features.licenses.impl.model.DependencyLicenseItem +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.extensions.runCatchingExceptions +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Inject +class DependencyLicensesListPresenter( + private val licensesProvider: LicensesProvider, +) : Presenter { + @Composable + override fun present(): DependencyLicensesListState { + var licenses by remember { + mutableStateOf>>(AsyncData.Loading()) + } + var filteredLicenses by remember { + mutableStateOf>>(AsyncData.Loading()) + } + var filter by remember { mutableStateOf("") } + LaunchedEffect(Unit) { + runCatchingExceptions { + licenses = AsyncData.Success(licensesProvider.provides().toImmutableList()) + }.onFailure { + licenses = AsyncData.Failure(it) + } + } + LaunchedEffect(filter, licenses.dataOrNull()) { + val data = licenses.dataOrNull() + val safeFilter = filter.trim() + if (data != null && safeFilter.isNotEmpty()) { + filteredLicenses = AsyncData.Success(data.filter { + it.safeName.contains(safeFilter, ignoreCase = true) || + it.groupId.contains(safeFilter, ignoreCase = true) || + it.artifactId.contains(safeFilter, ignoreCase = true) + }.toImmutableList()) + } else { + filteredLicenses = licenses + } + } + + fun handleEvent(event: DependencyLicensesListEvent) { + when (event) { + is DependencyLicensesListEvent.SetFilter -> { + filter = event.filter + } + } + } + + return DependencyLicensesListState( + licenses = filteredLicenses, + filter = filter, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListState.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListState.kt new file mode 100644 index 0000000..d3aeede --- /dev/null +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListState.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.licenses.impl.list + +import io.element.android.features.licenses.impl.model.DependencyLicenseItem +import io.element.android.libraries.architecture.AsyncData +import kotlinx.collections.immutable.ImmutableList + +data class DependencyLicensesListState( + val licenses: AsyncData>, + val filter: String, + val eventSink: (DependencyLicensesListEvent) -> Unit, +) diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListStateProvider.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListStateProvider.kt new file mode 100644 index 0000000..6a74cc0 --- /dev/null +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListStateProvider.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.licenses.impl.list + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.licenses.impl.model.DependencyLicenseItem +import io.element.android.features.licenses.impl.model.License +import io.element.android.libraries.architecture.AsyncData +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +open class DependencyLicensesListStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aDependencyLicensesListState( + licenses = AsyncData.Loading() + ), + aDependencyLicensesListState( + licenses = AsyncData.Failure(Exception("Failed to load licenses")) + ), + aDependencyLicensesListState( + licenses = AsyncData.Success( + persistentListOf( + aDependencyLicenseItem(), + aDependencyLicenseItem(name = null), + ) + ) + ), + aDependencyLicensesListState( + licenses = AsyncData.Success( + persistentListOf( + aDependencyLicenseItem(), + aDependencyLicenseItem(name = null), + ) + ), + filter = "a filter", + ), + ) +} + +private fun aDependencyLicensesListState( + licenses: AsyncData>, + filter: String = "", +): DependencyLicensesListState { + return DependencyLicensesListState( + licenses = licenses, + filter = filter, + eventSink = {}, + ) +} + +internal fun aDependencyLicenseItem( + name: String? = "A dependency", +) = DependencyLicenseItem( + groupId = "org.some.group", + artifactId = "a-dependency", + version = "1.0.0", + name = name, + licenses = listOf( + License( + identifier = "Apache 2.0", + name = "Apache 2.0", + url = "https://www.apache.org/licenses/LICENSE-2.0" + ) + ), + unknownLicenses = listOf(), + scm = null, +) diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt new file mode 100644 index 0000000..f60c28e --- /dev/null +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.licenses.impl.list + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.licenses.impl.model.DependencyLicenseItem +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.ListItem +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.TextField +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DependencyLicensesListView( + state: DependencyLicensesListState, + onBackClick: () -> Unit, + onOpenLicense: (DependencyLicenseItem) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + titleStr = stringResource(CommonStrings.common_open_source_licenses), + navigationIcon = { BackButton(onClick = onBackClick) }, + ) + }, + ) { contentPadding -> + Column( + modifier = Modifier + .padding(contentPadding) + .padding(horizontal = 16.dp) + ) { + if (state.licenses.isSuccess()) { + // Search field + TextField( + value = state.filter, + onValueChange = { state.eventSink(DependencyLicensesListEvent.SetFilter(it)) }, + leadingIcon = { + Icon( + imageVector = CompoundIcons.Search(), + contentDescription = null, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + } + LazyColumn { + when (state.licenses) { + is AsyncData.Failure -> item { + Text( + text = stringResource(CommonStrings.common_error), + modifier = Modifier.padding(16.dp) + ) + } + AsyncData.Uninitialized, + is AsyncData.Loading -> item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 64.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + } + is AsyncData.Success -> items(state.licenses.data) { license -> + ListItem( + headlineContent = { Text(license.safeName) }, + supportingContent = { + Text( + buildString { + append(license.groupId) + append(":") + append(license.artifactId) + append(":") + append(license.version) + } + ) + }, + onClick = { + onOpenLicense(license) + } + ) + } + } + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun DependencyLicensesListViewPreview( + @PreviewParameter(DependencyLicensesListStateProvider::class) state: DependencyLicensesListState +) = ElementPreview { + DependencyLicensesListView( + state = state, + onBackClick = {}, + onOpenLicense = {}, + ) +} diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/model/DependencyLicenseItem.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/model/DependencyLicenseItem.kt new file mode 100644 index 0000000..d26d062 --- /dev/null +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/model/DependencyLicenseItem.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.licenses.impl.model + +import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@Parcelize +data class DependencyLicenseItem( + val groupId: String, + val artifactId: String, + val version: String, + @SerialName("spdxLicenses") + val licenses: List?, + val unknownLicenses: List?, + val name: String?, + val scm: Scm?, +) : Parcelable { + @IgnoredOnParcel + val safeName = name?.takeIf { name -> name != "null" } ?: "$groupId:$artifactId" +} + +@Serializable +@Parcelize +data class License( + val identifier: String?, + val name: String?, + val url: String?, +) : Parcelable + +@Serializable +@Parcelize +data class Scm( + val url: String, +) : Parcelable diff --git a/features/licenses/impl/src/test/kotlin/io/element/android/features/licenses/impl/DefaultOpenSourcesLicensesEntryPointTest.kt b/features/licenses/impl/src/test/kotlin/io/element/android/features/licenses/impl/DefaultOpenSourcesLicensesEntryPointTest.kt new file mode 100644 index 0000000..4b6e3d9 --- /dev/null +++ b/features/licenses/impl/src/test/kotlin/io/element/android/features/licenses/impl/DefaultOpenSourcesLicensesEntryPointTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.licenses.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultOpenSourcesLicensesEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultOpenSourcesLicensesEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + DependenciesFlowNode( + buildContext = buildContext, + plugins = plugins, + ) + } + val result = entryPoint.createNode(parentNode, BuildContext.root(null)) + assertThat(result).isInstanceOf(DependenciesFlowNode::class.java) + } +} diff --git a/features/licenses/impl/src/test/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListPresenterTest.kt b/features/licenses/impl/src/test/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListPresenterTest.kt new file mode 100644 index 0000000..abc827d --- /dev/null +++ b/features/licenses/impl/src/test/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListPresenterTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.licenses.impl.list + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.licenses.impl.model.DependencyLicenseItem +import io.element.android.libraries.architecture.AsyncData +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DependencyLicensesListPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state, no licenses`() = runTest { + val presenter = createPresenter { emptyList() } + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.licenses).isInstanceOf(AsyncData.Loading::class.java) + val finalState = awaitItem() + assertThat(finalState.licenses.isSuccess()).isTrue() + assertThat(finalState.licenses.dataOrNull()).isEmpty() + assertThat(finalState.filter).isEqualTo("") + } + } + + @Test + fun `present - initial state, one license`() = runTest { + val anItem = aDependencyLicenseItem() + val presenter = createPresenter { + listOf(anItem) + } + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.licenses).isInstanceOf(AsyncData.Loading::class.java) + val finalState = awaitItem() + assertThat(finalState.licenses.isSuccess()).isTrue() + assertThat(finalState.licenses.dataOrNull()!!.size).isEqualTo(1) + assertThat(finalState.licenses.dataOrNull()!!.get(0)).isEqualTo(anItem) + } + } + + @Test + fun `present - initial state, one license, set filter`() = runTest { + val anItem = aDependencyLicenseItem() + val presenter = createPresenter { + listOf(anItem) + } + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.licenses).isInstanceOf(AsyncData.Loading::class.java) + val loadedState = awaitItem() + assertThat(loadedState.licenses.isSuccess()).isTrue() + assertThat(loadedState.licenses.dataOrNull()!!.size).isEqualTo(1) + loadedState.eventSink(DependencyLicensesListEvent.SetFilter("dep")) + awaitItem().let { state -> + assertThat(state.licenses.dataOrNull()!!.size).isEqualTo(1) + assertThat(state.filter).isEqualTo("dep") + } + loadedState.eventSink(DependencyLicensesListEvent.SetFilter("bleh")) + skipItems(1) + awaitItem().let { state -> + assertThat(state.licenses.dataOrNull()!!.size).isEqualTo(0) + assertThat(state.filter).isEqualTo("bleh") + } + loadedState.eventSink(DependencyLicensesListEvent.SetFilter("")) + skipItems(1) + awaitItem().let { state -> + assertThat(state.licenses.dataOrNull()!!.size).isEqualTo(1) + assertThat(state.filter).isEqualTo("") + } + } + } + + private fun createPresenter( + provideResult: () -> List + ) = DependencyLicensesListPresenter( + licensesProvider = FakeLicensesProvider(provideResult), + ) +} diff --git a/features/licenses/impl/src/test/kotlin/io/element/android/features/licenses/impl/list/FakeLicensesProvider.kt b/features/licenses/impl/src/test/kotlin/io/element/android/features/licenses/impl/list/FakeLicensesProvider.kt new file mode 100644 index 0000000..ebbad0e --- /dev/null +++ b/features/licenses/impl/src/test/kotlin/io/element/android/features/licenses/impl/list/FakeLicensesProvider.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.licenses.impl.list + +import io.element.android.features.licenses.impl.LicensesProvider +import io.element.android.features.licenses.impl.model.DependencyLicenseItem +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeLicensesProvider( + private val provideResult: () -> List = { lambdaError() } +) : LicensesProvider { + override suspend fun provides(): List { + return provideResult() + } +} diff --git a/features/licenses/test/build.gradle.kts b/features/licenses/test/build.gradle.kts new file mode 100644 index 0000000..d9a2870 --- /dev/null +++ b/features/licenses/test/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.licenses.test" +} + +dependencies { + implementation(projects.features.licenses.api) + implementation(projects.libraries.architecture) + implementation(projects.tests.testutils) +} diff --git a/features/licenses/test/src/main/kotlin/io/element/android/features/licenses/test/FakeOpenSourceLicensesEntryPoint.kt b/features/licenses/test/src/main/kotlin/io/element/android/features/licenses/test/FakeOpenSourceLicensesEntryPoint.kt new file mode 100644 index 0000000..366aacb --- /dev/null +++ b/features/licenses/test/src/main/kotlin/io/element/android/features/licenses/test/FakeOpenSourceLicensesEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.licenses.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.licenses.api.OpenSourceLicensesEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeOpenSourceLicensesEntryPoint : OpenSourceLicensesEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + ): Node { + lambdaError() + } +} diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts new file mode 100644 index 0000000..ab85e37 --- /dev/null +++ b/features/location/api/build.gradle.kts @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +import config.BuildTimeConfig +import extension.buildConfigFieldStr +import extension.readLocalProperty +import extension.testCommonDependencies + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.location.api" + + buildFeatures { + buildConfig = true + } + + defaultConfig { + buildConfigFieldStr( + name = "MAPTILER_BASE_URL", + value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL ?: "https://api.maptiler.com/maps" + ) + buildConfigFieldStr( + name = "MAPTILER_API_KEY", + value = if (isEnterpriseBuild) { + BuildTimeConfig.SERVICES_MAPTILER_APIKEY + } else { + System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY") + ?: readLocalProperty("services.maptiler.apikey") + } + ?: "" + ) + buildConfigFieldStr( + name = "MAPTILER_LIGHT_MAP_ID", + value = if (isEnterpriseBuild) { + BuildTimeConfig.SERVICES_MAPTILER_LIGHT_MAPID + } else { + System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID") + ?: readLocalProperty("services.maptiler.lightMapId") + } + // fall back to maptiler's default light map. + ?: "basic-v2" + ) + buildConfigFieldStr( + name = "MAPTILER_DARK_MAP_ID", + value = if (isEnterpriseBuild) { + BuildTimeConfig.SERVICES_MAPTILER_DARK_MAPID + } else { + System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID") + ?: readLocalProperty("services.maptiler.darkMapId") + } + // fall back to maptiler's default dark map. + ?: "basic-v2-dark" + ) + } +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.core) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.uiStrings) + implementation(libs.coil.compose) + + testCommonDependencies(libs) +} 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 0000000..7593867 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.api + +import android.annotation.SuppressLint +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +private const val GEO_URI_REGEX = """geo:(?-?\d+(?:\.\d+)?),(?-?\d+(?:\.\d+)?)(?:;u=(?\d+(?:\.\d+)?))?""" + +@SuppressLint("NewApi") +@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/LocationService.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocationService.kt new file mode 100644 index 0000000..22049c9 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocationService.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.api + +interface LocationService { + fun isServiceAvailable(): Boolean +} 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 0000000..48816b2 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/SendLocationEntryPoint.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.matrix.api.timeline.Timeline + +/** + * The "Send location" screen. + * + * Allows a user to share a location message within a room. + */ +interface SendLocationEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + timelineMode: Timeline.Mode, + ): Node +} 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 0000000..03693a7 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationEntryPoint.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..c58cadd --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.platform.LocalDensity +import androidx.compose.ui.unit.dp +import coil3.Extras +import coil3.compose.AsyncImagePainter +import coil3.compose.rememberAsyncImagePainter +import coil3.request.ImageRequest +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.location.api.internal.StaticMapPlaceholder +import io.element.android.features.location.api.internal.StaticMapUrlBuilder +import io.element.android.features.location.api.internal.centerBottomEdge +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.utils.CommonDrawables + +/** + * 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 { mutableIntStateOf(0) } + val builder = remember { StaticMapUrlBuilder() } + 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(context) + .data( + builder.build( + lat = lat, + lon = lon, + zoom = zoom, + darkMode = darkMode, + width = constraints.maxWidth, + height = constraints.maxHeight, + density = LocalDensity.current.density, + ) + ) + .size(width = constraints.maxWidth, height = constraints.maxHeight) + .apply { + extras.set(Extras.Key("retry_hash"), retryHash).build() + } + .build() + } + ) + + val collectedState = painter.state.collectAsState() + if (collectedState.value 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 = CommonDrawables.pin, + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.centerBottomEdge(this), + ) + } else { + StaticMapPlaceholder( + showProgress = collectedState.value.isLoading(), + canReload = builder.isServiceAvailable(), + contentDescription = contentDescription, + width = maxWidth, + height = maxHeight, + onLoadMapClick = { retryHash++ } + ) + } + } +} + +private fun AsyncImagePainter.State.isLoading(): Boolean { + return this is AsyncImagePainter.State.Empty || + this is AsyncImagePainter.State.Loading +} + +@PreviewsDayNight +@Composable +internal 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/MapTilerStaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt new file mode 100644 index 0000000..839cda0 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.api.internal + +import io.element.android.features.location.api.BuildConfig +import kotlin.math.roundToInt + +/** + * Builds an URL for MapTiler's Static Maps API. + * + * https://docs.maptiler.com/cloud/api/static-maps/ + */ +internal class MapTilerStaticMapUrlBuilder( + private val baseUrl: String, + private val apiKey: String, + private val lightMapId: String, + private val darkMapId: String, +) : StaticMapUrlBuilder { + constructor() : this( + baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"), + apiKey = BuildConfig.MAPTILER_API_KEY, + lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID, + darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID, + ) + + override fun build( + lat: Double, + lon: Double, + zoom: Double, + darkMode: Boolean, + width: Int, + height: Int, + density: Float + ): String { + val mapId = if (darkMode) darkMapId else lightMapId + val finalZoom = zoom.coerceIn(zoomRange) + + // Request @2x density for xhdpi and above (xhdpi == 320dpi == 2x density). + val is2x = density >= 2 + + // Scale requested width/height according to the reported display density. + val (finalWidth, finalHeight) = coerceWidthAndHeight( + width = (width / density).roundToInt(), + height = (height / density).roundToInt(), + is2x = is2x, + ) + + val scale = if (is2x) "@2x" else "" + + // Since Maptiler doesn't support arbitrary dpi scaling, we stick to 2x sized + // images even on displays with density higher than 2x, thereby yielding an + // image smaller than the available space in pixels. + // The resulting image will have to be scaled to fit the available space in order + // to keep the perceived content size constant at the expense of sharpness. + return "$baseUrl/$mapId/static/$lon,$lat,$finalZoom/${finalWidth}x${finalHeight}$scale.webp?key=$apiKey&attribution=bottomleft" + } + + override fun isServiceAvailable() = apiKey.isNotEmpty() +} + +private fun coerceWidthAndHeight(width: Int, height: Int, is2x: Boolean): Pair { + if (width <= 0 || height <= 0) { + // This effectively yields an URL which asks for a 0x0 image which will result in an HTTP error, + // but it's better than e.g. asking for a 1x1 image which would be unreadable and increase usage costs. + return 0 to 0 + } + val aspectRatio = width.toDouble() / height.toDouble() + val range = if (is2x) widthHeightRange2x else widthHeightRange + return if (width >= height) { + width.coerceIn(range).let { coercedWidth -> + coercedWidth to (coercedWidth / aspectRatio).roundToInt() + } + } else { + height.coerceIn(range).let { coercedHeight -> + (coercedHeight * aspectRatio).roundToInt() to coercedHeight + } + } +} + +private val widthHeightRange = 1..2048 // API will error if outside 1-2048 range @1x. +private val widthHeightRange2x = 1..1024 // API will error if outside 1-1024 range @2x. +private val zoomRange = 0.0..22.0 // API will error if outside 0-22 range. diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt new file mode 100644 index 0000000..d119dc7 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:JvmName("TileServerStyleUriBuilderKt") + +package io.element.android.features.location.api.internal + +import io.element.android.features.location.api.BuildConfig + +internal class MapTilerTileServerStyleUriBuilder( + private val baseUrl: String, + private val apiKey: String, + private val lightMapId: String, + private val darkMapId: String, +) : TileServerStyleUriBuilder { + constructor() : this( + baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"), + apiKey = BuildConfig.MAPTILER_API_KEY, + lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID, + darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID, + ) + + override fun build(darkMode: Boolean): String { + val mapId = if (darkMode) darkMapId else lightMapId + return "$baseUrl/$mapId/style.json?key=$apiKey" + } +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/ModifierCenterBottomEdge.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/ModifierCenterBottomEdge.kt new file mode 100644 index 0000000..48c78ef --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/ModifierCenterBottomEdge.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.api.internal + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset + +/** + * Horizontally aligns the content to the center of the space. + * Vertically aligns the bottom edge of the content to the center of the space. + */ +fun Modifier.centerBottomEdge(scope: BoxScope): Modifier = this.then( + with(scope) { + Modifier.align { size, space, _ -> + IntOffset( + x = (space.width - size.width) / 2, + y = space.height / 2 - size.height, + ) + } + } +) 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 0000000..81b80c8 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.api.internal + +import androidx.compose.foundation.Image +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.padding +import androidx.compose.foundation.layout.size +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.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.location.api.R +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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, + canReload: Boolean, + contentDescription: String?, + width: Dp, + height: Dp, + onLoadMapClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(width = width, height = height) + .then(if (showProgress) Modifier else Modifier.clickable(onClick = onLoadMapClick)) + ) { + Image( + painter = painterResource(id = R.drawable.blurred_map), + contentDescription = contentDescription, + contentScale = ContentScale.FillBounds, + modifier = Modifier.size(width = width, height = height) + ) + if (showProgress) { + CircularProgressIndicator() + } else if (canReload) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = CompoundIcons.Restart(), + contentDescription = null + ) + Text(text = stringResource(id = CommonStrings.action_static_map_load)) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun StaticMapPlaceholderPreview() = ElementPreview { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + listOf( + true to false, + false to true, + false to false, + ).forEach { (showProgress, canReload) -> + StaticMapPlaceholder( + showProgress = showProgress, + canReload = canReload, + contentDescription = null, + width = 400.dp, + height = 200.dp, + onLoadMapClick = {}, + ) + } + } +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt new file mode 100644 index 0000000..6636976 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.api.internal + +/** + * Builds an URL for a 3rd party service provider static maps API. + */ +interface StaticMapUrlBuilder { + fun build( + lat: Double, + lon: Double, + zoom: Double, + darkMode: Boolean, + width: Int, + height: Int, + density: Float, + ): String + + fun isServiceAvailable(): Boolean +} + +fun StaticMapUrlBuilder(): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder() diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt new file mode 100644 index 0000000..17b620e --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.api.internal + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import io.element.android.compound.theme.ElementTheme + +/** + * Builds a style URI for a MapLibre compatible tile server. + * + * Used for rendering dynamic maps. + */ +interface TileServerStyleUriBuilder { + fun build( + darkMode: Boolean, + ): String +} + +fun TileServerStyleUriBuilder(): TileServerStyleUriBuilder = MapTilerTileServerStyleUriBuilder() + +/** + * Provides and remembers a style URI for a MapLibre compatible tile server. + * + * Used for rendering dynamic maps. + */ +@Composable +fun rememberTileStyleUrl(): String { + val darkMode = !ElementTheme.isLightTheme + return remember(darkMode) { + TileServerStyleUriBuilder().build(darkMode) + } +} 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 0000000..7e90d56 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 0000000..365cf96 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 0000000..d1ad401 --- /dev/null +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/LocationKtTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt new file mode 100644 index 0000000..65c0acd --- /dev/null +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.api.internal + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class MapTilerStaticMapUrlBuilderTest { + private val builder = MapTilerStaticMapUrlBuilder( + baseUrl = "https://base.url", + apiKey = "anApiKey", + lightMapId = "aLightMapId", + darkMapId = "aDarkMapId", + ) + + @Test + fun `isServiceAvailable returns true if api key is not empty`() { + assertThat(builder.isServiceAvailable()).isTrue() + } + + @Test + fun `isServiceAvailable returns false if api key is empty`() { + val builderWithoutKey = MapTilerStaticMapUrlBuilder( + baseUrl = "https://base.url", + apiKey = "", + lightMapId = "aLightMapId", + darkMapId = "aDarkMapId", + ) + assertThat(builderWithoutKey.isServiceAvailable()).isFalse() + } + + @Test + fun `static map 1x density`() { + assertThat( + builder.build( + lat = 1.23, + lon = -4.56, + zoom = 7.8, + darkMode = false, + width = 800, + height = 600, + density = 1f, + ) + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft") + } + + @Test + fun `static map 1,5x density`() { + assertThat( + builder.build( + lat = 1.23, + lon = -4.56, + zoom = 7.8, + darkMode = false, + width = 1200, + height = 900, + density = 1.5f, + ) + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft") + } + + @Test + fun `static map 2x density`() { + assertThat( + builder.build( + lat = 1.23, + lon = -4.56, + zoom = 7.8, + darkMode = false, + width = 1600, + height = 1200, + density = 2f, + ) + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft") + } + + @Test + fun `static map 3x density`() { + assertThat( + builder.build( + lat = 1.23, + lon = -4.56, + zoom = 7.8, + darkMode = false, + width = 2400, + height = 1800, + density = 3f, + ) + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft") + } + + @Test + fun `too big image is coerced keeping aspect ratio`() { + assertThat( + builder.build( + lat = 1.23, + lon = -4.56, + zoom = 7.8, + darkMode = false, + width = 4096, + height = 2048, + density = 1f, + ) + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/2048x1024.webp?key=anApiKey&attribution=bottomleft") + + assertThat( + builder.build( + lat = 1.23, + lon = -4.56, + zoom = 7.8, + darkMode = false, + width = 2048, + height = 4096, + density = 1f, + ) + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x2048.webp?key=anApiKey&attribution=bottomleft") + + assertThat( + builder.build( + lat = 1.23, + lon = -4.56, + zoom = 7.8, + darkMode = false, + width = 4096, + height = 2048, + density = 2f, + ) + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x512@2x.webp?key=anApiKey&attribution=bottomleft") + + assertThat( + builder.build( + lat = 1.23, + lon = -4.56, + zoom = 7.8, + darkMode = false, + width = 2048, + height = 4096, + density = 2f, + ) + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/512x1024@2x.webp?key=anApiKey&attribution=bottomleft") + + assertThat( + builder.build( + lat = 1.23, + lon = -4.56, + zoom = 7.8, + darkMode = false, + width = Int.MAX_VALUE, + height = Int.MAX_VALUE, + density = 2f, + ) + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x1024@2x.webp?key=anApiKey&attribution=bottomleft") + } + + @Test + fun `too small image is coerced to 0x0`() { + assertThat( + builder.build( + lat = 1.23, + lon = -4.56, + zoom = 7.8, + darkMode = false, + width = 0, + height = 0, + density = 1f, + ) + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft") + + assertThat( + builder.build( + lat = 1.23, + lon = -4.56, + zoom = 7.8, + darkMode = false, + width = 0, + height = 0, + density = 2f, + ) + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0@2x.webp?key=anApiKey&attribution=bottomleft") + + assertThat( + builder.build( + lat = 1.23, + lon = -4.56, + zoom = 7.8, + darkMode = false, + width = Int.MIN_VALUE, + height = Int.MIN_VALUE, + density = 1f, + ) + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft") + } +} diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt new file mode 100644 index 0000000..d5c6521 --- /dev/null +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.api.internal + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class MapTilerTileServerStyleUriBuilderTest { + private val builder = MapTilerTileServerStyleUriBuilder( + baseUrl = "https://base.url", + apiKey = "anApiKey", + lightMapId = "aLightMapId", + darkMapId = "aDarkMapId", + ) + + @Test + fun `light map uri`() { + assertThat( + builder.build(darkMode = false) + ).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey") + } + + @Test + fun `dark map uri`() { + assertThat( + builder.build(darkMode = true) + ).isEqualTo("https://base.url/aDarkMapId/style.json?key=anApiKey") + } +} diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts new file mode 100644 index 0000000..bd41c7e --- /dev/null +++ b/features/location/impl/build.gradle.kts @@ -0,0 +1,48 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.location.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +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) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.testtags) + testImplementation(projects.services.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 0000000..ae728c0 --- /dev/null +++ b/features/location/impl/src/main/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/DefaultLocationService.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/DefaultLocationService.kt new file mode 100644 index 0000000..9f2c3fa --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/DefaultLocationService.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.location.api.BuildConfig +import io.element.android.features.location.api.LocationService + +@ContributesBinding(AppScope::class) +class DefaultLocationService : LocationService { + override fun isServiceAvailable(): Boolean { + return BuildConfig.MAPTILER_API_KEY.isNotEmpty() + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt new file mode 100644 index 0000000..d01f3e7 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.common + +import android.Manifest +import android.view.Gravity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color +import io.element.android.compound.theme.ElementTheme +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 org.maplibre.android.camera.CameraPosition +import org.maplibre.android.geometry.LatLng + +/** + * 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, + backgroundTintColor = Color.White, + foregroundTintColor = Color.Black, + backgroundStaleTintColor = Color.White, + foregroundStaleTintColor = Color.Black, + accuracyColor = Color.Black, + pulseEnabled = true, + pulseColor = Color.Black, + ) + + 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/common/PermissionDeniedDialog.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionDeniedDialog.kt new file mode 100644 index 0000000..6817f57 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionDeniedDialog.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.common + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun PermissionDeniedDialog( + onContinue: () -> Unit, + onDismiss: () -> Unit, + appName: String, +) { + ConfirmationDialog( + content = stringResource(CommonStrings.error_missing_location_auth_android, appName), + onSubmitClick = 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/common/PermissionRationaleDialog.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionRationaleDialog.kt new file mode 100644 index 0000000..7aef07e --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionRationaleDialog.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.common + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun PermissionRationaleDialog( + onContinue: () -> Unit, + onDismiss: () -> Unit, + appName: String, +) { + ConfirmationDialog( + content = stringResource(CommonStrings.error_missing_location_rationale_android, appName), + onSubmitClick = 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/common/actions/AndroidLocationActions.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt new file mode 100644 index 0000000..0284c25 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.common.actions + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.annotation.VisibleForTesting +import androidx.core.net.toUri +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.location.api.Location +import io.element.android.libraries.androidutils.system.openAppSettingsPage +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.annotations.ApplicationContext +import timber.log.Timber +import java.util.Locale + +@ContributesBinding(AppScope::class) +class AndroidLocationActions( + @ApplicationContext private val context: Context +) : LocationActions { + override fun share(location: Location, label: String?) { + runCatchingExceptions { + val uri = buildUrl(location, label).toUri() + 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() + } +} + +// Ref: https://developer.android.com/guide/components/intents-common#ViewMap +@VisibleForTesting +internal fun buildUrl( + location: Location, + label: String?, + urlEncoder: (String) -> String = Uri::encode +): String { + // This is needed so the coordinates are formatted with a dot as decimal separator + val locale = Locale.ENGLISH + return "geo:0,0?q=%.6f,%.6f (%s)".format(locale, location.lat, location.lon, urlEncoder(label.orEmpty())) +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/LocationActions.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/LocationActions.kt new file mode 100644 index 0000000..cd9efbd --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/LocationActions.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.common.actions + +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/common/permissions/DefaultPermissionsPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/DefaultPermissionsPresenter.kt new file mode 100644 index 0000000..1aa2e12 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/DefaultPermissionsPresenter.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.common.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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesBinding + +@Suppress("unused") +@AssistedInject +class DefaultPermissionsPresenter( + @Assisted private val permissions: List +) : PermissionsPresenter { + @AssistedFactory + @ContributesBinding(AppScope::class) + interface Factory : PermissionsPresenter.Factory { + override fun create(permissions: List): DefaultPermissionsPresenter + } + + @OptIn(ExperimentalPermissionsApi::class) + @Composable + override fun present(): PermissionsState { + val multiplePermissionsState = rememberMultiplePermissionsState(permissions = permissions) + + fun handleEvent(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 = ::handleEvent, + ) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsEvents.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsEvents.kt new file mode 100644 index 0000000..a4b5ef1 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsEvents.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.common.permissions + +sealed interface PermissionsEvents { + data object RequestPermissions : PermissionsEvents +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsPresenter.kt new file mode 100644 index 0000000..e706226 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsPresenter.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.common.permissions + +import io.element.android.libraries.architecture.Presenter + +interface PermissionsPresenter : Presenter { + fun interface Factory { + fun create(permissions: List): PermissionsPresenter + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsState.kt new file mode 100644 index 0000000..9119128 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsState.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.common.permissions + +data class PermissionsState( + val permissions: Permissions, + val shouldShowRationale: Boolean, + val eventSink: (PermissionsEvents) -> Unit, +) { + sealed interface Permissions { + data object AllGranted : Permissions + data object SomeGranted : Permissions + data 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/common/ui/LocationFloatingActionButton.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationFloatingActionButton.kt new file mode 100644 index 0000000..7735443 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationFloatingActionButton.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.common.ui + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.theme.components.FloatingActionButton +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * Ref: See design in https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=3426-141111 + */ +@Composable +internal fun LocationFloatingActionButton( + isMapCenteredOnUser: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + FloatingActionButton( + shape = FloatingActionButtonDefaults.smallShape, + containerColor = ElementTheme.colors.bgCanvasDefault, + contentColor = ElementTheme.colors.iconPrimary, + onClick = onClick, + modifier = modifier + // Note: design is 40dp, but min is 48 for accessibility. + .size(48.dp), + ) { + val iconImage = if (isMapCenteredOnUser) { + CompoundIcons.LocationNavigatorCentred() + } else { + CompoundIcons.LocationNavigator() + } + Icon( + imageVector = iconImage, + contentDescription = stringResource(CommonStrings.a11y_move_the_map_to_my_location), + ) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPoint.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPoint.kt new file mode 100644 index 0000000..56399f7 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPoint.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.send + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.location.api.SendLocationEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.matrix.api.timeline.Timeline + +@ContributesBinding(AppScope::class) +class DefaultSendLocationEntryPoint : SendLocationEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + timelineMode: Timeline.Mode, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf(SendLocationNode.Inputs(timelineMode)) + ) + } +} 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 0000000..0d266ee --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEvents.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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, + ) + } + + data object SwitchToMyLocationMode : SendLocationEvents + data object SwitchToPinLocationMode : SendLocationEvents + data object DismissDialog : SendLocationEvents + data object RequestPermissions : SendLocationEvents + data 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 0000000..2184b52 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationNode.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.annotations.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.timeline.Timeline +import io.element.android.services.analytics.api.AnalyticsService + +@ContributesNode(RoomScope::class) +@AssistedInject +class SendLocationNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: SendLocationPresenter.Factory, + analyticsService: AnalyticsService, +) : Node(buildContext, plugins = plugins) { + data class Inputs( + val timelineMode: Timeline.Mode, + ) : NodeInputs + + private val presenter = presenterFactory.create(inputs().timelineMode) + + 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 0000000..b753820 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.Composer +import io.element.android.features.location.impl.common.MapDefaults +import io.element.android.features.location.impl.common.actions.LocationActions +import io.element.android.features.location.impl.common.permissions.PermissionsEvents +import io.element.android.features.location.impl.common.permissions.PermissionsPresenter +import io.element.android.features.location.impl.common.permissions.PermissionsState +import io.element.android.features.messages.api.MessageComposerContext +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.extensions.flatMap +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.room.CreateTimelineParams +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.launch + +@AssistedInject +class SendLocationPresenter( + permissionsPresenterFactory: PermissionsPresenter.Factory, + private val room: JoinedRoom, + @Assisted private val timelineMode: Timeline.Mode, + private val analyticsService: AnalyticsService, + private val messageComposerContext: MessageComposerContext, + private val locationActions: LocationActions, + private val buildMeta: BuildMeta, +) : Presenter { + @AssistedFactory + fun interface Factory { + fun create(timelineMode: Timeline.Mode): SendLocationPresenter + } + + 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 handleEvent(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 = ::handleEvent, + ) + } + + private suspend fun sendLocation( + event: SendLocationEvents.SendLocation, + mode: SendLocationState.Mode, + ) { + val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply + val inReplyToEventId = replyMode?.eventId + when (mode) { + SendLocationState.Mode.PinLocation -> { + val geoUri = event.cameraPosition.toGeoUri() + getTimeline().flatMap { + it.sendLocation( + body = generateBody(geoUri), + geoUri = geoUri, + description = null, + zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), + assetType = AssetType.PIN, + inReplyToEventId = inReplyToEventId, + ) + } + analyticsService.capture( + Composer( + inThread = messageComposerContext.composerMode.inThread, + isEditing = messageComposerContext.composerMode.isEditing, + isReply = messageComposerContext.composerMode.isReply, + messageType = Composer.MessageType.LocationPin, + ) + ) + } + SendLocationState.Mode.SenderLocation -> { + val geoUri = event.toGeoUri() + getTimeline().flatMap { + it.sendLocation( + body = generateBody(geoUri), + geoUri = geoUri, + description = null, + zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), + assetType = AssetType.SENDER, + inReplyToEventId = inReplyToEventId, + ) + } + analyticsService.capture( + Composer( + inThread = messageComposerContext.composerMode.inThread, + isEditing = messageComposerContext.composerMode.isEditing, + isReply = messageComposerContext.composerMode.isReply, + messageType = Composer.MessageType.LocationUser, + ) + ) + } + } + } + + private suspend fun getTimeline(): Result { + return when (timelineMode) { + is Timeline.Mode.Thread -> room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId)) + else -> Result.success(room.liveTimeline) + } + } +} + +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): String = "Location was shared at $uri" 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 0000000..4ca84c4 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationState.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.send + +data class SendLocationState( + val permissionDialog: Dialog, + val mode: Mode, + val hasLocationPermission: Boolean, + val appName: String, + val eventSink: (SendLocationEvents) -> Unit, +) { + sealed interface Mode { + data object SenderLocation : Mode + data object PinLocation : Mode + } + + sealed interface Dialog { + data object None : Dialog + data object PermissionRationale : Dialog + data 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 0000000..238201c --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationStateProvider.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.send + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +private const val APP_NAME = "ApplicationName" + +class SendLocationStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aSendLocationState( + permissionDialog = SendLocationState.Dialog.None, + mode = SendLocationState.Mode.PinLocation, + hasLocationPermission = false, + ), + aSendLocationState( + permissionDialog = SendLocationState.Dialog.PermissionDenied, + mode = SendLocationState.Mode.PinLocation, + hasLocationPermission = false, + ), + aSendLocationState( + permissionDialog = SendLocationState.Dialog.PermissionRationale, + mode = SendLocationState.Mode.PinLocation, + hasLocationPermission = false, + ), + aSendLocationState( + permissionDialog = SendLocationState.Dialog.None, + mode = SendLocationState.Mode.PinLocation, + hasLocationPermission = true, + ), + aSendLocationState( + permissionDialog = SendLocationState.Dialog.None, + mode = SendLocationState.Mode.SenderLocation, + hasLocationPermission = true, + ), + ) +} + +private fun aSendLocationState( + permissionDialog: SendLocationState.Dialog, + mode: SendLocationState.Mode, + hasLocationPermission: Boolean, +): SendLocationState { + return SendLocationState( + permissionDialog = permissionDialog, + mode = mode, + hasLocationPermission = hasLocationPermission, + appName = APP_NAME, + eventSink = {} + ) +} 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 0000000..452a4aa --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.send + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +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.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.dp +import io.element.android.features.location.api.Location +import io.element.android.features.location.api.internal.centerBottomEdge +import io.element.android.features.location.api.internal.rememberTileStyleUrl +import io.element.android.features.location.impl.R +import io.element.android.features.location.impl.common.MapDefaults +import io.element.android.features.location.impl.common.PermissionDeniedDialog +import io.element.android.features.location.impl.common.PermissionRationaleDialog +import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold +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.designsystem.utils.CommonDrawables +import io.element.android.libraries.maplibre.compose.CameraMode +import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason +import io.element.android.libraries.maplibre.compose.MapLibreMap +import io.element.android.libraries.maplibre.compose.rememberCameraPositionState +import io.element.android.libraries.ui.strings.CommonStrings +import org.maplibre.android.camera.CameraPosition + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SendLocationView( + state: SendLocationState, + navigateUp: () -> Unit, + modifier: Modifier = Modifier, +) { + 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, + ), + location = cameraPositionState.location?.let { + Location( + lat = it.latitude, + lon = it.longitude, + accuracy = it.accuracy, + ) + } + ) + ) + navigateUp() + }, + leadingContent = { + Icon( + resourceId = R.drawable.pin_small, + contentDescription = null, + tint = Color.Unspecified, + ) + }, + ) + Spacer(modifier = Modifier.height(16.dp + navBarPadding)) + }, + modifier = modifier, + scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded), + ), + sheetDragHandle = {}, + sheetSwipeEnabled = false, + topBar = { + TopAppBar( + titleStr = stringResource(CommonStrings.screen_share_location_title), + navigationIcon = { + BackButton(onClick = navigateUp) + }, + ) + }, + ) { + Box( + modifier = Modifier + .padding(it) + .consumeWindowInsets(it), + contentAlignment = Alignment.Center + ) { + MapLibreMap( + styleUri = rememberTileStyleUrl(), + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + uiSettings = MapDefaults.uiSettings, + symbolManagerSettings = MapDefaults.symbolManagerSettings, + locationSettings = MapDefaults.locationSettings.copy( + locationEnabled = state.hasLocationPermission, + ), + ) + Icon( + resourceId = CommonDrawables.pin, + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.centerBottomEdge(this), + ) + LocationFloatingActionButton( + isMapCenteredOnUser = state.mode == SendLocationState.Mode.SenderLocation, + onClick = { state.eventSink(SendLocationEvents.SwitchToMyLocationMode) }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 18.dp, bottom = 72.dp + navBarPadding), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun SendLocationViewPreview( + @PreviewParameter(SendLocationStateProvider::class) state: SendLocationState +) = ElementPreview { + SendLocationView( + state = state, + navigateUp = {}, + ) +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPoint.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPoint.kt new file mode 100644 index 0000000..7d1f5f1 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPoint.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.show + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.location.api.ShowLocationEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultShowLocationEntryPoint : ShowLocationEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + inputs: ShowLocationEntryPoint.Inputs, + ): Node { + return parentNode.createNode(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 0000000..12f368f --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvents.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.show + +sealed interface ShowLocationEvents { + data object Share : ShowLocationEvents + data class TrackMyLocation(val enabled: Boolean) : ShowLocationEvents + data object DismissDialog : ShowLocationEvents + data object RequestPermissions : ShowLocationEvents + data object OpenAppSettings : 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 0000000..86d7741 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.annotations.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) +@AssistedInject +class ShowLocationNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: ShowLocationPresenter.Factory, + analyticsService: AnalyticsService, +) : 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, + onBackClick = ::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 0000000..3dcccef --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.show + +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.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.location.api.Location +import io.element.android.features.location.impl.common.MapDefaults +import io.element.android.features.location.impl.common.actions.LocationActions +import io.element.android.features.location.impl.common.permissions.PermissionsEvents +import io.element.android.features.location.impl.common.permissions.PermissionsPresenter +import io.element.android.features.location.impl.common.permissions.PermissionsState +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta + +@AssistedInject +class ShowLocationPresenter( + @Assisted private val location: Location, + @Assisted private val description: String?, + permissionsPresenterFactory: PermissionsPresenter.Factory, + private val locationActions: LocationActions, + private val buildMeta: BuildMeta, +) : Presenter { + @AssistedFactory + fun 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) } + val appName by remember { derivedStateOf { buildMeta.applicationName } } + var permissionDialog: ShowLocationState.Dialog by remember { + mutableStateOf(ShowLocationState.Dialog.None) + } + + LaunchedEffect(permissionsState.permissions) { + if (permissionsState.isAnyGranted) { + permissionDialog = ShowLocationState.Dialog.None + } + } + + fun handleEvent(event: ShowLocationEvents) { + when (event) { + ShowLocationEvents.Share -> locationActions.share(location, description) + is ShowLocationEvents.TrackMyLocation -> { + if (event.enabled) { + when { + permissionsState.isAnyGranted -> isTrackMyLocation = true + permissionsState.shouldShowRationale -> permissionDialog = ShowLocationState.Dialog.PermissionRationale + else -> permissionDialog = ShowLocationState.Dialog.PermissionDenied + } + } else { + isTrackMyLocation = false + } + } + ShowLocationEvents.DismissDialog -> permissionDialog = ShowLocationState.Dialog.None + ShowLocationEvents.OpenAppSettings -> { + locationActions.openSettings() + permissionDialog = ShowLocationState.Dialog.None + } + ShowLocationEvents.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) + } + } + + return ShowLocationState( + permissionDialog = permissionDialog, + location = location, + description = description, + hasLocationPermission = permissionsState.isAnyGranted, + isTrackMyLocation = isTrackMyLocation, + appName = appName, + eventSink = ::handleEvent, + ) + } +} 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 0000000..96635d6 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.show + +import io.element.android.features.location.api.Location + +data class ShowLocationState( + val permissionDialog: Dialog, + val location: Location, + val description: String?, + val hasLocationPermission: Boolean, + val isTrackMyLocation: Boolean, + val appName: String, + val eventSink: (ShowLocationEvents) -> Unit, +) { + sealed interface Dialog { + data object None : Dialog + data object PermissionRationale : Dialog + data object PermissionDenied : Dialog + } +} 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 0000000..7d03a1e --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.show + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.location.api.Location + +private const val APP_NAME = "ApplicationName" + +class ShowLocationStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aShowLocationState(), + aShowLocationState( + permissionDialog = ShowLocationState.Dialog.PermissionDenied, + ), + aShowLocationState( + permissionDialog = ShowLocationState.Dialog.PermissionRationale, + ), + aShowLocationState( + hasLocationPermission = true, + ), + aShowLocationState( + hasLocationPermission = true, + isTrackMyLocation = true, + ), + aShowLocationState( + description = "My favourite place!", + ), + aShowLocationState( + description = "For some reason I decided to to write a small essay that wraps at just two lines!", + ), + aShowLocationState( + 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!", + ), + ) +} + +fun aShowLocationState( + permissionDialog: ShowLocationState.Dialog = ShowLocationState.Dialog.None, + location: Location = Location(1.23, 2.34, 4f), + description: String? = null, + hasLocationPermission: Boolean = false, + isTrackMyLocation: Boolean = false, + appName: String = APP_NAME, + eventSink: (ShowLocationEvents) -> Unit = {}, +) = ShowLocationState( + permissionDialog = permissionDialog, + location = location, + description = description, + hasLocationPermission = hasLocationPermission, + isTrackMyLocation = isTrackMyLocation, + appName = appName, + eventSink = 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 0000000..6bc9e27 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.show + +import androidx.compose.foundation.layout.Column +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.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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.compound.tokens.generated.TypographyTokens +import io.element.android.features.location.api.internal.rememberTileStyleUrl +import io.element.android.features.location.impl.common.MapDefaults +import io.element.android.features.location.impl.common.PermissionDeniedDialog +import io.element.android.features.location.impl.common.PermissionRationaleDialog +import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.designsystem.utils.CommonDrawables +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.MapLibreMap +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.ui.strings.CommonStrings +import kotlinx.collections.immutable.toImmutableMap +import org.maplibre.android.camera.CameraPosition +import org.maplibre.android.geometry.LatLng + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShowLocationView( + state: ShowLocationState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + when (state.permissionDialog) { + ShowLocationState.Dialog.None -> Unit + ShowLocationState.Dialog.PermissionDenied -> PermissionDeniedDialog( + onContinue = { state.eventSink(ShowLocationEvents.OpenAppSettings) }, + onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) }, + appName = state.appName, + ) + ShowLocationState.Dialog.PermissionRationale -> PermissionRationaleDialog( + onContinue = { state.eventSink(ShowLocationEvents.RequestPermissions) }, + onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) }, + appName = state.appName, + ) + } + + 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( + titleStr = stringResource(CommonStrings.screen_view_location_title), + navigationIcon = { + BackButton( + onClick = onBackClick, + ) + }, + actions = { + IconButton( + onClick = { state.eventSink(ShowLocationEvents.Share) } + ) { + Icon( + imageVector = CompoundIcons.ShareAndroid(), + contentDescription = stringResource(CommonStrings.action_share), + ) + } + } + ) + }, + floatingActionButton = { + LocationFloatingActionButton( + isMapCenteredOnUser = state.isTrackMyLocation, + onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) }, + ) + }, + ) { 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), + ) + } + + MapLibreMap( + styleUri = rememberTileStyleUrl(), + modifier = Modifier.fillMaxSize(), + images = mapOf(PIN_ID to CommonDrawables.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, + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun ShowLocationViewPreview(@PreviewParameter(ShowLocationStateProvider::class) state: ShowLocationState) = ElementPreview { + ShowLocationView( + state = state, + onBackClick = {}, + ) +} + +private const val PIN_ID = "pin" diff --git a/features/location/impl/src/main/res/drawable-night/pin_small.xml b/features/location/impl/src/main/res/drawable-night/pin_small.xml new file mode 100644 index 0000000..2e8a54b --- /dev/null +++ b/features/location/impl/src/main/res/drawable-night/pin_small.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/features/location/impl/src/main/res/drawable/pin_small.xml b/features/location/impl/src/main/res/drawable/pin_small.xml new file mode 100644 index 0000000..0e277a1 --- /dev/null +++ b/features/location/impl/src/main/res/drawable/pin_small.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt new file mode 100644 index 0000000..b0e8997 --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.location.api.BuildConfig +import org.junit.Test + +class DefaultLocationServiceTest { + @Test + fun `isServiceAvailable should return value depending on BuildConfig MAPTILER_API_KEY`() { + val locationService = DefaultLocationService() + assertThat(locationService.isServiceAvailable()).isEqualTo( + BuildConfig.MAPTILER_API_KEY.isNotEmpty() + ) + } +} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/PermissionsStateFactory.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/PermissionsStateFactory.kt new file mode 100644 index 0000000..a435244 --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/PermissionsStateFactory.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl + +import io.element.android.features.location.impl.common.permissions.PermissionsState + +fun aPermissionsState( + permissions: PermissionsState.Permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale: Boolean = false, +): PermissionsState { + return PermissionsState( + permissions = permissions, + shouldShowRationale = shouldShowRationale, + eventSink = {}, + ) +} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActionsTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActionsTest.kt new file mode 100644 index 0000000..f82635e --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActionsTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.common.actions + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.location.api.Location +import org.junit.Test +import java.net.URLEncoder +import java.util.Locale + +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) + } + + @Test + fun `buildUrl - URL encodes coordinates in locale with comma decimal separator`() { + val location = Location( + lat = 1.000001, + lon = 2.000001, + accuracy = 0f + ) + // Set a locale with comma as decimal separator + @Suppress("DEPRECATION") + Locale.setDefault(Locale.Category.FORMAT, Locale("pt", "BR")) + + 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/common/actions/FakeLocationActions.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/FakeLocationActions.kt new file mode 100644 index 0000000..94dc972 --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/FakeLocationActions.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.common.actions + +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/common/permissions/FakePermissionsPresenter.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/FakePermissionsPresenter.kt new file mode 100644 index 0000000..94d909a --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/FakePermissionsPresenter.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.common.permissions + +import androidx.compose.runtime.Composable + +class FakePermissionsPresenter : PermissionsPresenter { + val events = mutableListOf() + + private fun handleEvent(event: PermissionsEvents) { + events += event + } + + private var state = PermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + 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/DefaultSendLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPointTest.kt new file mode 100644 index 0000000..a90afd5 --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPointTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.send + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.features.location.impl.common.actions.FakeLocationActions +import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter +import io.element.android.features.messages.test.FakeMessageComposerContext +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultSendLocationEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultSendLocationEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + SendLocationNode( + buildContext = buildContext, + plugins = plugins, + presenterFactory = { timelineMode: Timeline.Mode -> + SendLocationPresenter( + permissionsPresenterFactory = { FakePermissionsPresenter() }, + room = FakeJoinedRoom(), + timelineMode = timelineMode, + analyticsService = FakeAnalyticsService(), + messageComposerContext = FakeMessageComposerContext(), + locationActions = FakeLocationActions(), + buildMeta = aBuildMeta(), + ) + }, + analyticsService = FakeAnalyticsService(), + ) + } + val timelineMode = Timeline.Mode.Live + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + timelineMode = timelineMode, + ) + assertThat(result).isInstanceOf(SendLocationNode::class.java) + assertThat(result.plugins).contains(SendLocationNode.Inputs(timelineMode)) + } +} 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 0000000..23ab384 --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt @@ -0,0 +1,496 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.send + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.Composer +import io.element.android.features.location.api.Location +import io.element.android.features.location.impl.aPermissionsState +import io.element.android.features.location.impl.common.actions.FakeLocationActions +import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter +import io.element.android.features.location.impl.common.permissions.PermissionsEvents +import io.element.android.features.location.impl.common.permissions.PermissionsPresenter +import io.element.android.features.location.impl.common.permissions.PermissionsState +import io.element.android.features.messages.test.FakeMessageComposerContext +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class SendLocationPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private val fakePermissionsPresenter = FakePermissionsPresenter() + private val fakeAnalyticsService = FakeAnalyticsService() + private val fakeMessageComposerContext = FakeMessageComposerContext() + private val fakeLocationActions = FakeLocationActions() + private val fakeBuildMeta = aBuildMeta(applicationName = "app name") + + private fun createSendLocationPresenter( + joinedRoom: JoinedRoom = FakeJoinedRoom(), + ): SendLocationPresenter = SendLocationPresenter( + permissionsPresenterFactory = object : PermissionsPresenter.Factory { + override fun create(permissions: List): PermissionsPresenter = fakePermissionsPresenter + }, + room = joinedRoom, + timelineMode = Timeline.Mode.Live, + analyticsService = fakeAnalyticsService, + messageComposerContext = fakeMessageComposerContext, + locationActions = fakeLocationActions, + buildMeta = fakeBuildMeta, + ) + + @Test + fun `initial state with permissions granted`() = runTest { + val sendLocationPresenter = createSendLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + moleculeFlow(RecompositionMode.Immediate) { + sendLocationPresenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) + assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.SenderLocation) + assertThat(initialState.hasLocationPermission).isTrue() + + // Swipe the map to switch mode + initialState.eventSink(SendLocationEvents.SwitchToPinLocationMode) + val myLocationState = awaitItem() + assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) + assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(myLocationState.hasLocationPermission).isTrue() + } + } + + @Test + fun `initial state with permissions partially granted`() = runTest { + val sendLocationPresenter = createSendLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.SomeGranted, + shouldShowRationale = false, + ) + ) + + moleculeFlow(RecompositionMode.Immediate) { + sendLocationPresenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) + assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.SenderLocation) + assertThat(initialState.hasLocationPermission).isTrue() + + // Swipe the map to switch mode + initialState.eventSink(SendLocationEvents.SwitchToPinLocationMode) + val myLocationState = awaitItem() + assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) + assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(myLocationState.hasLocationPermission).isTrue() + } + } + + @Test + fun `initial state with permissions denied`() = runTest { + val sendLocationPresenter = createSendLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + ) + ) + + moleculeFlow(RecompositionMode.Immediate) { + sendLocationPresenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) + assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(initialState.hasLocationPermission).isFalse() + + // Click on the button to switch mode + initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) + val myLocationState = awaitItem() + assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionDenied) + assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(myLocationState.hasLocationPermission).isFalse() + } + } + + @Test + fun `initial state with permissions denied once`() = runTest { + val sendLocationPresenter = createSendLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = true, + ) + ) + + moleculeFlow(RecompositionMode.Immediate) { + sendLocationPresenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) + assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(initialState.hasLocationPermission).isFalse() + + // Click on the button to switch mode + initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) + val myLocationState = awaitItem() + assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale) + assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(myLocationState.hasLocationPermission).isFalse() + } + } + + @Test + fun `rationale dialog dismiss`() = runTest { + val sendLocationPresenter = createSendLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = true, + ) + ) + + moleculeFlow(RecompositionMode.Immediate) { + sendLocationPresenter.present() + }.test { + // Skip initial state + val initialState = awaitItem() + + // Click on the button to switch mode + initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) + val myLocationState = awaitItem() + assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale) + assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(myLocationState.hasLocationPermission).isFalse() + + // Dismiss the dialog + myLocationState.eventSink(SendLocationEvents.DismissDialog) + val dialogDismissedState = awaitItem() + assertThat(dialogDismissedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) + assertThat(dialogDismissedState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(dialogDismissedState.hasLocationPermission).isFalse() + } + } + + @Test + fun `rationale dialog continue`() = runTest { + val sendLocationPresenter = createSendLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = true, + ) + ) + + moleculeFlow(RecompositionMode.Immediate) { + sendLocationPresenter.present() + }.test { + // Skip initial state + val initialState = awaitItem() + + // Click on the button to switch mode + initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) + val myLocationState = awaitItem() + assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale) + assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(myLocationState.hasLocationPermission).isFalse() + + // Continue the dialog sends permission request to the permissions presenter + myLocationState.eventSink(SendLocationEvents.RequestPermissions) + assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) + } + } + + @Test + fun `permission denied dialog dismiss`() = runTest { + val sendLocationPresenter = createSendLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + ) + ) + + moleculeFlow(RecompositionMode.Immediate) { + sendLocationPresenter.present() + }.test { + // Skip initial state + val initialState = awaitItem() + + // Click on the button to switch mode + initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) + val myLocationState = awaitItem() + assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionDenied) + assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(myLocationState.hasLocationPermission).isFalse() + + // Dismiss the dialog + myLocationState.eventSink(SendLocationEvents.DismissDialog) + val dialogDismissedState = awaitItem() + assertThat(dialogDismissedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) + assertThat(dialogDismissedState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(dialogDismissedState.hasLocationPermission).isFalse() + } + } + + @Test + fun `share sender location`() = runTest { + val sendLocationResult = lambdaRecorder> { _, _, _, _, _, _ -> + Result.success(Unit) + } + val joinedRoom = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendLocationLambda = sendLocationResult + }, + ) + val sendLocationPresenter = createSendLocationPresenter(joinedRoom) + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + moleculeFlow(RecompositionMode.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 + + sendLocationResult.assertions().isCalledOnce() + .with( + value("Location was shared at geo:3.0,4.0;u=5.0"), + value("geo:3.0,4.0;u=5.0"), + value(null), + value(15), + value(AssetType.SENDER), + value(null), + ) + + assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1) + assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo( + Composer( + inThread = false, + isEditing = false, + isReply = false, + messageType = Composer.MessageType.LocationUser, + ) + ) + } + } + + @Test + fun `share pin location`() = runTest { + val sendLocationResult = lambdaRecorder> { _, _, _, _, _, _ -> + Result.success(Unit) + } + val joinedRoom = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendLocationLambda = sendLocationResult + }, + ) + val sendLocationPresenter = createSendLocationPresenter(joinedRoom) + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + ) + ) + + moleculeFlow(RecompositionMode.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 + + sendLocationResult.assertions().isCalledOnce() + .with( + value("Location was shared at geo:0.0,1.0"), + value("geo:0.0,1.0"), + value(null), + value(15), + value(AssetType.PIN), + value(null), + ) + + assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1) + assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo( + Composer( + inThread = false, + isEditing = false, + isReply = false, + messageType = Composer.MessageType.LocationPin, + ) + ) + } + } + + @Test + fun `composer context passes through analytics`() = runTest { + val sendLocationResult = lambdaRecorder> { _, _, _, _, _, _ -> + Result.success(Unit) + } + val joinedRoom = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendLocationLambda = sendLocationResult + }, + ) + val sendLocationPresenter = createSendLocationPresenter(joinedRoom) + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + ) + ) + fakeMessageComposerContext.apply { + composerMode = MessageComposerMode.Edit( + eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(), + content = "" + ) + } + + moleculeFlow(RecompositionMode.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 + + assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1) + assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo( + Composer( + inThread = false, + isEditing = true, + isReply = false, + messageType = Composer.MessageType.LocationPin, + ) + ) + } + } + + @Test + fun `open settings activity`() = runTest { + val sendLocationPresenter = createSendLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + ) + ) + fakeMessageComposerContext.apply { + composerMode = MessageComposerMode.Edit( + eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(), + content = "" + ) + } + + moleculeFlow(RecompositionMode.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() + + assertThat(settingsOpenedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) + assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1) + } + } + + @Test + fun `application name is in state`() = runTest { + val sendLocationPresenter = createSendLocationPresenter() + moleculeFlow(RecompositionMode.Immediate) { + sendLocationPresenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.appName).isEqualTo("app name") + } + } +} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt new file mode 100644 index 0000000..a49b887 --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.show + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.features.location.api.Location +import io.element.android.features.location.api.ShowLocationEntryPoint +import io.element.android.features.location.impl.common.actions.FakeLocationActions +import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultShowLocationEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultShowLocationEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + ShowLocationNode( + buildContext = buildContext, + plugins = plugins, + presenterFactory = { location: Location, description: String? -> + ShowLocationPresenter( + permissionsPresenterFactory = { FakePermissionsPresenter() }, + locationActions = FakeLocationActions(), + buildMeta = aBuildMeta(), + location = location, + description = description, + ) + }, + analyticsService = FakeAnalyticsService(), + ) + } + val inputs = ShowLocationEntryPoint.Inputs( + location = Location(37.4219983, -122.084, 10f), + description = "My location", + ) + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + inputs = inputs, + ) + assertThat(result).isInstanceOf(ShowLocationNode::class.java) + assertThat(result.plugins).contains(inputs) + } +} 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 0000000..df22863 --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -0,0 +1,288 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.show + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.location.api.Location +import io.element.android.features.location.impl.aPermissionsState +import io.element.android.features.location.impl.common.actions.FakeLocationActions +import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter +import io.element.android.features.location.impl.common.permissions.PermissionsEvents +import io.element.android.features.location.impl.common.permissions.PermissionsPresenter +import io.element.android.features.location.impl.common.permissions.PermissionsState +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class ShowLocationPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private val fakePermissionsPresenter = FakePermissionsPresenter() + private val fakeLocationActions = FakeLocationActions() + private val fakeBuildMeta = aBuildMeta(applicationName = "app name") + private val location = Location(1.23, 4.56, 7.8f) + private val presenter = ShowLocationPresenter( + permissionsPresenterFactory = object : PermissionsPresenter.Factory { + override fun create(permissions: List): PermissionsPresenter = fakePermissionsPresenter + }, + locationActions = fakeLocationActions, + buildMeta = fakeBuildMeta, + location = location, + description = A_DESCRIPTION, + ) + + @Test + fun `emits initial state with no location permission`() = runTest { + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + ) + ) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.location).isEqualTo(location) + assertThat(initialState.description).isEqualTo(A_DESCRIPTION) + assertThat(initialState.hasLocationPermission).isFalse() + assertThat(initialState.isTrackMyLocation).isFalse() + } + } + + @Test + fun `emits initial state location permission denied once`() = runTest { + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = true, + ) + ) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.location).isEqualTo(location) + assertThat(initialState.description).isEqualTo(A_DESCRIPTION) + assertThat(initialState.hasLocationPermission).isFalse() + assertThat(initialState.isTrackMyLocation).isFalse() + } + } + + @Test + fun `emits initial state with location permission`() = runTest { + fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.location).isEqualTo(location) + assertThat(initialState.description).isEqualTo(A_DESCRIPTION) + assertThat(initialState.hasLocationPermission).isTrue() + assertThat(initialState.isTrackMyLocation).isFalse() + } + } + + @Test + fun `emits initial state with partial location permission`() = runTest { + fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted)) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.location).isEqualTo(location) + assertThat(initialState.description).isEqualTo(A_DESCRIPTION) + assertThat(initialState.hasLocationPermission).isTrue() + assertThat(initialState.isTrackMyLocation).isFalse() + } + } + + @Test + fun `uses action to share location`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(ShowLocationEvents.Share) + + assertThat(fakeLocationActions.sharedLocation).isEqualTo(location) + assertThat(fakeLocationActions.sharedLabel).isEqualTo(A_DESCRIPTION) + } + } + + @Test + fun `centers on user location`() = runTest { + fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.hasLocationPermission).isTrue() + assertThat(initialState.isTrackMyLocation).isFalse() + + initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) + val trackMyLocationState = awaitItem() + + delay(1) + + assertThat(trackMyLocationState.hasLocationPermission).isTrue() + assertThat(trackMyLocationState.isTrackMyLocation).isTrue() + + // Swipe the map to switch mode + initialState.eventSink(ShowLocationEvents.TrackMyLocation(false)) + val trackLocationDisabledState = awaitItem() + assertThat(trackLocationDisabledState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None) + assertThat(trackLocationDisabledState.isTrackMyLocation).isFalse() + assertThat(trackLocationDisabledState.hasLocationPermission).isTrue() + } + } + + @Test + fun `rationale dialog dismiss`() = runTest { + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = true, + ) + ) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip initial state + val initialState = awaitItem() + + // Click on the button to switch mode + initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) + val trackLocationState = awaitItem() + assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionRationale) + assertThat(trackLocationState.isTrackMyLocation).isFalse() + assertThat(trackLocationState.hasLocationPermission).isFalse() + + // Dismiss the dialog + initialState.eventSink(ShowLocationEvents.DismissDialog) + val dialogDismissedState = awaitItem() + assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None) + assertThat(dialogDismissedState.isTrackMyLocation).isFalse() + assertThat(dialogDismissedState.hasLocationPermission).isFalse() + } + } + + @Test + fun `rationale dialog continue`() = runTest { + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = true, + ) + ) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip initial state + val initialState = awaitItem() + + // Click on the button to switch mode + initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) + val trackLocationState = awaitItem() + assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionRationale) + assertThat(trackLocationState.isTrackMyLocation).isFalse() + assertThat(trackLocationState.hasLocationPermission).isFalse() + + // Continue the dialog sends permission request to the permissions presenter + trackLocationState.eventSink(ShowLocationEvents.RequestPermissions) + assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) + } + } + + @Test + fun `permission denied dialog dismiss`() = runTest { + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + ) + ) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip initial state + val initialState = awaitItem() + + // Click on the button to switch mode + initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) + val trackLocationState = awaitItem() + assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionDenied) + assertThat(trackLocationState.isTrackMyLocation).isFalse() + assertThat(trackLocationState.hasLocationPermission).isFalse() + + // Dismiss the dialog + initialState.eventSink(ShowLocationEvents.DismissDialog) + val dialogDismissedState = awaitItem() + assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None) + assertThat(dialogDismissedState.isTrackMyLocation).isFalse() + assertThat(dialogDismissedState.hasLocationPermission).isFalse() + } + } + + @Test + fun `open settings activity`() = runTest { + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + ) + ) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip initial state + val initialState = awaitItem() + + initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) + val dialogShownState = awaitItem() + + // Open settings + dialogShownState.eventSink(ShowLocationEvents.OpenAppSettings) + val settingsOpenedState = awaitItem() + + assertThat(settingsOpenedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None) + assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1) + } + } + + @Test + fun `application name is in state`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.appName).isEqualTo("app name") + } + } + + companion object { + private const val A_DESCRIPTION = "My happy place" + } +} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt new file mode 100644 index 0000000..2245360 --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.show + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ShowLocationViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `test back action`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setShowLocationView( + state = aShowLocationState( + eventSink = eventsRecorder + ), + onBackClick = callback, + ) + rule.pressBack() + } + } + + @Test + fun `test share action`() { + val eventsRecorder = EventsRecorder() + rule.setShowLocationView( + aShowLocationState( + eventSink = eventsRecorder + ), + onBackClick = EnsureNeverCalled(), + ) + val shareContentDescription = rule.activity.getString(CommonStrings.action_share) + rule.onNodeWithContentDescription(shareContentDescription).performClick() + eventsRecorder.assertSingle(ShowLocationEvents.Share) + } + + @Test + fun `test fab click`() { + val eventsRecorder = EventsRecorder() + rule.setShowLocationView( + aShowLocationState( + eventSink = eventsRecorder + ), + onBackClick = EnsureNeverCalled(), + ) + rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick() + eventsRecorder.assertSingle(ShowLocationEvents.TrackMyLocation(true)) + } + + @Test + fun `when permission denied is displayed user can open the settings`() { + val eventsRecorder = EventsRecorder() + rule.setShowLocationView( + aShowLocationState( + permissionDialog = ShowLocationState.Dialog.PermissionDenied, + eventSink = eventsRecorder + ), + onBackClick = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_continue) + eventsRecorder.assertSingle(ShowLocationEvents.OpenAppSettings) + } + + @Test + fun `when permission denied is displayed user can close the dialog`() { + val eventsRecorder = EventsRecorder() + rule.setShowLocationView( + aShowLocationState( + permissionDialog = ShowLocationState.Dialog.PermissionDenied, + eventSink = eventsRecorder + ), + onBackClick = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog) + } + + @Test + fun `when permission rationale is displayed user can request permissions`() { + val eventsRecorder = EventsRecorder() + rule.setShowLocationView( + aShowLocationState( + permissionDialog = ShowLocationState.Dialog.PermissionRationale, + eventSink = eventsRecorder + ), + onBackClick = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_continue) + eventsRecorder.assertSingle(ShowLocationEvents.RequestPermissions) + } + + @Test + fun `when permission rationale is displayed user can close the dialog`() { + val eventsRecorder = EventsRecorder() + rule.setShowLocationView( + aShowLocationState( + permissionDialog = ShowLocationState.Dialog.PermissionRationale, + eventSink = eventsRecorder + ), + onBackClick = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog) + } +} + +private fun AndroidComposeTestRule.setShowLocationView( + state: ShowLocationState, + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + // Simulate a LocalInspectionMode for MapLibreMap + CompositionLocalProvider(LocalInspectionMode provides true) { + ShowLocationView( + state = state, + onBackClick = onBackClick, + ) + } + } +} diff --git a/features/location/test/build.gradle.kts b/features/location/test/build.gradle.kts new file mode 100644 index 0000000..f84e8ba --- /dev/null +++ b/features/location/test/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.location.test" +} + +dependencies { + api(projects.features.location.api) + implementation(projects.libraries.matrix.api) + implementation(libs.appyx.core) + implementation(projects.tests.testutils) +} diff --git a/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeLocationService.kt b/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeLocationService.kt new file mode 100644 index 0000000..f961a81 --- /dev/null +++ b/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeLocationService.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.test + +import io.element.android.features.location.api.LocationService + +class FakeLocationService( + private val isServiceAvailable: Boolean = false, +) : LocationService { + override fun isServiceAvailable() = isServiceAvailable +} diff --git a/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeSendLocationEntryPoint.kt b/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeSendLocationEntryPoint.kt new file mode 100644 index 0000000..2a1741e --- /dev/null +++ b/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeSendLocationEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.location.api.SendLocationEntryPoint +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeSendLocationEntryPoint : SendLocationEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + timelineMode: Timeline.Mode, + ): Node = lambdaError() +} diff --git a/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeShowLocationEntryPoint.kt b/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeShowLocationEntryPoint.kt new file mode 100644 index 0000000..b6a9409 --- /dev/null +++ b/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeShowLocationEntryPoint.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.location.api.ShowLocationEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeShowLocationEntryPoint : ShowLocationEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + inputs: ShowLocationEntryPoint.Inputs, + ): Node = lambdaError() +} diff --git a/features/lockscreen/api/build.gradle.kts b/features/lockscreen/api/build.gradle.kts new file mode 100644 index 0000000..a09c1a4 --- /dev/null +++ b/features/lockscreen/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.lockscreen.api" +} + +dependencies { + implementation(projects.libraries.architecture) +} diff --git a/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenEntryPoint.kt b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenEntryPoint.kt new file mode 100644 index 0000000..aa891ad --- /dev/null +++ b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenEntryPoint.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.api + +import android.content.Context +import android.content.Intent +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 LockScreenEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + navTarget: Target, + callback: Callback, + ): Node + + fun pinUnlockIntent(context: Context): Intent + + interface Callback : Plugin { + fun onSetupDone() + } + + enum class Target { + Settings, + Setup, + } +} diff --git a/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenLockState.kt b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenLockState.kt new file mode 100644 index 0000000..1a63ebc --- /dev/null +++ b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenLockState.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.api + +sealed interface LockScreenLockState { + data object Unlocked : LockScreenLockState + data object Locked : LockScreenLockState +} diff --git a/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenService.kt b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenService.kt new file mode 100644 index 0000000..bd644e6 --- /dev/null +++ b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenService.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.api + +import android.os.Build +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +interface LockScreenService { + /** + * The current lock state of the app. + */ + val lockState: StateFlow + + /** + * Check if setting up the lock screen is required. + * @return true if the lock screen is mandatory and not setup yet, false otherwise. + */ + fun isSetupRequired(): Flow + + /** + * Check if pin is setup. + * @return true if the pin is setup, false otherwise. + */ + fun isPinSetup(): Flow +} + +/** + * Makes sure the secure flag is set on the activity if the pin is setup. + * @param activity the activity to set the flag on. + */ +fun LockScreenService.handleSecureFlag(activity: ComponentActivity) { + isPinSetup() + .onEach { isPinSetup -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity.setRecentsScreenshotEnabled(!isPinSetup) + } else { + if (isPinSetup) { + activity.window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE + ) + } else { + activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + } + .launchIn(activity.lifecycleScope) +} diff --git a/features/lockscreen/impl/build.gradle.kts b/features/lockscreen/impl/build.gradle.kts new file mode 100644 index 0000000..63aec59 --- /dev/null +++ b/features/lockscreen/impl/build.gradle.kts @@ -0,0 +1,58 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.lockscreen.impl" + + testOptions { + unitTests.isIncludeAndroidResources = true + } +} + +setupDependencyInjection() + +dependencies { + api(projects.features.lockscreen.api) + implementation(projects.appconfig) + implementation(projects.features.enterprise.api) + 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.featureflag.api) + implementation(projects.libraries.cryptography.api) + implementation(projects.libraries.preferences.api) + implementation(projects.libraries.testtags) + implementation(projects.libraries.uiUtils) + implementation(projects.features.logout.api) + implementation(projects.libraries.uiCommon) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.sessionStorage.api) + implementation(projects.services.appnavstate.api) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.biometric) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.cryptography.test) + testImplementation(projects.libraries.cryptography.impl) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.libraries.sessionStorage.test) + testImplementation(projects.services.appnavstate.test) + testImplementation(projects.features.logout.test) +} diff --git a/features/lockscreen/impl/src/main/AndroidManifest.xml b/features/lockscreen/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..13b1262 --- /dev/null +++ b/features/lockscreen/impl/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPoint.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPoint.kt new file mode 100644 index 0000000..ec8fdc6 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPoint.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl + +import android.content.Context +import android.content.Intent +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.lockscreen.api.LockScreenEntryPoint +import io.element.android.features.lockscreen.impl.unlock.activity.PinUnlockActivity +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultLockScreenEntryPoint : LockScreenEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + navTarget: LockScreenEntryPoint.Target, + callback: LockScreenEntryPoint.Callback, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf( + LockScreenFlowNode.Inputs( + when (navTarget) { + LockScreenEntryPoint.Target.Setup -> LockScreenFlowNode.NavTarget.Setup + LockScreenEntryPoint.Target.Settings -> LockScreenFlowNode.NavTarget.Settings + } + ), + callback, + ) + ) + } + + override fun pinUnlockIntent(context: Context): Intent { + return PinUnlockActivity.newIntent(context) + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenService.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenService.kt new file mode 100644 index 0000000..7ac42fe --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenService.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.features.lockscreen.api.LockScreenLockState +import io.element.android.features.lockscreen.api.LockScreenService +import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager +import io.element.android.features.lockscreen.impl.biometric.DefaultBiometricUnlockCallback +import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback +import io.element.android.features.lockscreen.impl.pin.PinCodeManager +import io.element.android.features.lockscreen.impl.storage.LockScreenStore +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.sessionstorage.api.observer.SessionListener +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import io.element.android.services.appnavstate.api.AppForegroundStateService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlin.time.Duration + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultLockScreenService( + private val lockScreenConfig: LockScreenConfig, + private val lockScreenStore: LockScreenStore, + private val pinCodeManager: PinCodeManager, + @AppCoroutineScope + private val coroutineScope: CoroutineScope, + private val sessionObserver: SessionObserver, + private val appForegroundStateService: AppForegroundStateService, + biometricAuthenticatorManager: BiometricAuthenticatorManager, +) : LockScreenService { + private val _lockState = MutableStateFlow(LockScreenLockState.Unlocked) + override val lockState: StateFlow = _lockState + + private var lockJob: Job? = null + + init { + pinCodeManager.addCallback(object : DefaultPinCodeManagerCallback() { + override fun onPinCodeVerified() { + _lockState.value = LockScreenLockState.Unlocked + } + + override fun onPinCodeRemoved() { + _lockState.value = LockScreenLockState.Unlocked + } + }) + biometricAuthenticatorManager.addCallback(object : DefaultBiometricUnlockCallback() { + override fun onBiometricAuthenticationSuccess() { + _lockState.value = LockScreenLockState.Unlocked + coroutineScope.launch { + lockScreenStore.resetCounter() + } + } + }) + coroutineScope.lockIfNeeded() + observeAppForegroundState() + observeSessionsState() + } + + /** + * Makes sure to delete the pin code when the last session is deleted. + */ + private fun observeSessionsState() { + sessionObserver.addListener(object : SessionListener { + override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) { + if (wasLastSession) { + pinCodeManager.deletePinCode() + } + } + }) + } + + /** + * Makes sure to lock the app if it goes in background for a certain amount of time. + */ + private fun observeAppForegroundState() { + coroutineScope.launch { + appForegroundStateService.startObservingForeground() + appForegroundStateService.isInForeground.collect { isInForeground -> + if (isInForeground) { + lockJob?.cancel() + } else { + lockJob = lockIfNeeded(gracePeriod = lockScreenConfig.gracePeriod) + } + } + } + } + + override fun isPinSetup(): Flow { + return pinCodeManager.hasPinCode() + } + + override fun isSetupRequired(): Flow { + return isPinSetup().map { isPinSetup -> + !isPinSetup && lockScreenConfig.isPinMandatory + } + } + + private fun CoroutineScope.lockIfNeeded(gracePeriod: Duration = Duration.ZERO) = launch { + if (isPinSetup().first()) { + delay(gracePeriod) + _lockState.value = LockScreenLockState.Locked + } + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenConfig.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenConfig.kt new file mode 100644 index 0000000..c246390 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenConfig.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import kotlin.time.Duration +import io.element.android.appconfig.LockScreenConfig as AppConfigLockScreenConfig + +data class LockScreenConfig( + val isPinMandatory: Boolean, + val forbiddenPinCodes: Set, + val pinSize: Int, + val maxPinCodeAttemptsBeforeLogout: Int, + val gracePeriod: Duration, + val isStrongBiometricsEnabled: Boolean, + val isWeakBiometricsEnabled: Boolean, +) + +@ContributesTo(AppScope::class) +@BindingContainer +object LockScreenConfigModule { + @Provides + fun providesLockScreenConfig(): LockScreenConfig = LockScreenConfig( + isPinMandatory = AppConfigLockScreenConfig.IS_PIN_MANDATORY, + forbiddenPinCodes = AppConfigLockScreenConfig.FORBIDDEN_PIN_CODES, + pinSize = AppConfigLockScreenConfig.PIN_SIZE, + maxPinCodeAttemptsBeforeLogout = AppConfigLockScreenConfig.MAX_PIN_CODE_ATTEMPTS_BEFORE_LOGOUT, + gracePeriod = AppConfigLockScreenConfig.GRACE_PERIOD, + isStrongBiometricsEnabled = AppConfigLockScreenConfig.IS_STRONG_BIOMETRICS_ENABLED, + isWeakBiometricsEnabled = AppConfigLockScreenConfig.IS_WEAK_BIOMETRICS_ENABLED, + ) +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenFlowNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenFlowNode.kt new file mode 100644 index 0000000..2a84ddc --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenFlowNode.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl + +import android.os.Parcelable +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 com.bumble.appyx.navmodel.backstack.BackStack +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.lockscreen.api.LockScreenEntryPoint +import io.element.android.features.lockscreen.impl.settings.LockScreenSettingsFlowNode +import io.element.android.features.lockscreen.impl.setup.LockScreenSetupFlowNode +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +@AssistedInject +class LockScreenFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : BaseFlowNode( + backstack = BackStack( + initialElement = plugins.filterIsInstance().first().initialNavTarget, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + data class Inputs( + val initialNavTarget: NavTarget, + ) : NodeInputs + + sealed interface NavTarget : Parcelable { + @Parcelize + data object Setup : NavTarget + + @Parcelize + data object Settings : NavTarget + } + + private class OnSetupDoneCallback(private val plugins: List) : LockScreenSetupFlowNode.Callback { + override fun onSetupDone() { + plugins.forEach { + it.onSetupDone() + } + } + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Setup -> { + val callback = OnSetupDoneCallback(plugins()) + createNode(buildContext, plugins = listOf(callback)) + } + NavTarget.Settings -> { + createNode(buildContext) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView() + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt new file mode 100644 index 0000000..a96c713 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.biometric + +import android.security.keystore.KeyPermanentlyInvalidatedException +import androidx.biometric.BiometricPrompt +import androidx.biometric.BiometricPrompt.CryptoObject +import androidx.biometric.BiometricPrompt.PromptInfo +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.cryptography.api.EncryptionDecryptionService +import io.element.android.libraries.cryptography.api.SecretKeyRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import timber.log.Timber +import java.security.InvalidKeyException +import javax.crypto.Cipher + +interface BiometricAuthenticator { + interface Callback { + fun onBiometricSetupError() + fun onBiometricAuthenticationSuccess() + fun onBiometricAuthenticationFailed(error: Exception?) + } + + sealed interface AuthenticationResult { + data object Success : AuthenticationResult + data class Failure(val error: Exception? = null) : AuthenticationResult + } + + val isActive: Boolean + fun setup() + suspend fun authenticate(): AuthenticationResult +} + +class NoopBiometricAuthentication : BiometricAuthenticator { + override val isActive: Boolean = false + override fun setup() = Unit + override suspend fun authenticate() = BiometricAuthenticator.AuthenticationResult.Failure() +} + +class DefaultBiometricAuthentication( + private val activity: FragmentActivity, + private val promptInfo: PromptInfo, + private val secretKeyRepository: SecretKeyRepository, + private val encryptionDecryptionService: EncryptionDecryptionService, + private val keyAlias: String, + private val callbacks: List +) : BiometricAuthenticator { + override val isActive: Boolean = true + + private var cryptoObject: CryptoObject? = null + + override fun setup() { + try { + val secretKey = ensureKey() + val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey) + cryptoObject = CryptoObject(cipher) + } catch (e: InvalidKeyException) { + callbacks.forEach { it.onBiometricSetupError() } + Timber.e(e, "Invalid biometric key") + } + } + + override suspend fun authenticate(): BiometricAuthenticator.AuthenticationResult { + val cryptoObject = cryptoObject ?: return BiometricAuthenticator.AuthenticationResult.Failure() + + val deferredAuthenticationResult = CompletableDeferred() + val executor = ContextCompat.getMainExecutor(activity.baseContext) + val callback = AuthenticationCallback(callbacks, deferredAuthenticationResult) + val prompt = BiometricPrompt(activity, executor, callback) + prompt.authenticate(promptInfo, cryptoObject) + return try { + deferredAuthenticationResult.await() + } catch (cancellation: CancellationException) { + prompt.cancelAuthentication() + BiometricAuthenticator.AuthenticationResult.Failure(cancellation) + } + } + + @Throws(KeyPermanentlyInvalidatedException::class) + private fun ensureKey() = secretKeyRepository.getOrCreateKey(keyAlias, true).also { + encryptionDecryptionService.createEncryptionCipher(it) + } +} + +private class AuthenticationCallback( + private val callbacks: List, + private val deferredAuthenticationResult: CompletableDeferred, +) : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + val biometricUnlockError = BiometricUnlockError(errorCode, errString.toString()) + callbacks.forEach { it.onBiometricAuthenticationFailed(biometricUnlockError) } + deferredAuthenticationResult.complete(BiometricAuthenticator.AuthenticationResult.Failure(biometricUnlockError)) + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + callbacks.forEach { it.onBiometricAuthenticationFailed(null) } + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + if (result.cryptoObject?.cipher.isValid()) { + callbacks.forEach { it.onBiometricAuthenticationSuccess() } + deferredAuthenticationResult.complete(BiometricAuthenticator.AuthenticationResult.Success) + } else { + val error = IllegalStateException("Invalid cipher") + callbacks.forEach { it.onBiometricAuthenticationFailed(error) } + deferredAuthenticationResult.complete(BiometricAuthenticator.AuthenticationResult.Failure()) + } + } + + private fun Cipher?.isValid(): Boolean { + if (this == null) return false + return runCatchingExceptions { + doFinal("biometric_challenge".toByteArray()) + }.isSuccess + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticatorManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticatorManager.kt new file mode 100644 index 0000000..9917845 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticatorManager.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.biometric + +import androidx.compose.runtime.Composable + +interface BiometricAuthenticatorManager { + /** + * If the device is secured for example with a pin, pattern or password. + */ + val isDeviceSecured: Boolean + + /** + * If the device has biometric hardware and if the user has enrolled at least one biometric. + */ + val hasAvailableAuthenticator: Boolean + + fun addCallback(callback: BiometricAuthenticator.Callback) + fun removeCallback(callback: BiometricAuthenticator.Callback) + + /** + * Remember a biometric authenticator ready for unlocking the app. + */ + @Composable + fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator + + /** + * Remember a biometric authenticator ready for confirmation. + */ + @Composable + fun rememberConfirmBiometricAuthenticator(): BiometricAuthenticator +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricUnlockError.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricUnlockError.kt new file mode 100644 index 0000000..941256f --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricUnlockError.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.biometric + +import androidx.biometric.BiometricPrompt + +/** + * Wrapper for [BiometricPrompt.AuthenticationCallback] errors. + */ +class BiometricUnlockError(val code: Int, message: String) : Exception(message) { + /** + * This error disables Biometric authentication, either temporarily or permanently. + */ + val isAuthDisabledError: Boolean get() = code in LOCKOUT_ERROR_CODES + + /** + * This error permanently disables Biometric authentication. + */ + val isAuthPermanentlyDisabledError: Boolean get() = code == BiometricPrompt.ERROR_LOCKOUT_PERMANENT + + companion object { + private val LOCKOUT_ERROR_CODES = arrayOf(BiometricPrompt.ERROR_LOCKOUT, BiometricPrompt.ERROR_LOCKOUT_PERMANENT) + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt new file mode 100644 index 0000000..8bb044f --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.biometric + +import android.app.KeyguardManager +import android.content.Context +import android.content.ContextWrapper +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.core.content.getSystemService +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.compose.LocalLifecycleOwner +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.features.lockscreen.impl.LockScreenConfig +import io.element.android.features.lockscreen.impl.R +import io.element.android.features.lockscreen.impl.storage.LockScreenStore +import io.element.android.libraries.cryptography.api.EncryptionDecryptionService +import io.element.android.libraries.cryptography.api.SecretKeyRepository +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.util.concurrent.CopyOnWriteArrayList + +private const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_BIOMETRIC" + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class DefaultBiometricAuthenticatorManager( + @ApplicationContext private val context: Context, + private val lockScreenStore: LockScreenStore, + private val lockScreenConfig: LockScreenConfig, + private val encryptionDecryptionService: EncryptionDecryptionService, + private val secretKeyRepository: SecretKeyRepository, + @AppCoroutineScope + private val coroutineScope: CoroutineScope, +) : BiometricAuthenticatorManager { + private val callbacks = CopyOnWriteArrayList() + private val biometricManager = BiometricManager.from(context) + private val keyguardManager: KeyguardManager = context.getSystemService()!! + + /** + * Returns true if a weak biometric method (i.e.: some face or iris unlock implementations) can be used. + */ + private val canUseWeakBiometricAuth: Boolean + get() = lockScreenConfig.isWeakBiometricsEnabled && + biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS + + /** + * Returns true if a strong biometric method (i.e.: fingerprint, some face or iris unlock implementations) can be used. + */ + private val canUseStrongBiometricAuth: Boolean + get() = lockScreenConfig.isStrongBiometricsEnabled && + biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS + + /** + * Returns true if any biometric method (weak or strong) can be used. + */ + override val hasAvailableAuthenticator: Boolean + get() = canUseWeakBiometricAuth || canUseStrongBiometricAuth + + override val isDeviceSecured: Boolean + get() = keyguardManager.isDeviceSecure + + private val internalCallback = object : DefaultBiometricUnlockCallback() { + override fun onBiometricSetupError() { + coroutineScope.launch { + lockScreenStore.setIsBiometricUnlockAllowed(false) + secretKeyRepository.deleteKey(SECRET_KEY_ALIAS) + } + } + } + + @Composable + override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator { + val isBiometricAllowed by remember { + lockScreenStore.isBiometricUnlockAllowed() + }.collectAsState(initial = false) + val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState() + val isAvailable by remember(lifecycleState) { + derivedStateOf { isBiometricAllowed && hasAvailableAuthenticator } + } + val promptTitle = stringResource(id = R.string.screen_app_lock_biometric_unlock_title_android) + val promptNegative = stringResource(id = R.string.screen_app_lock_use_pin_android) + return rememberBiometricAuthenticator( + isAvailable = isAvailable, + promptTitle = promptTitle, + promptNegative = promptNegative, + ) + } + + @Composable + override fun rememberConfirmBiometricAuthenticator(): BiometricAuthenticator { + val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState() + val isAvailable by remember(lifecycleState) { + derivedStateOf { hasAvailableAuthenticator } + } + val promptTitle = stringResource(id = R.string.screen_app_lock_confirm_biometric_authentication_android) + val promptNegative = stringResource(id = CommonStrings.action_cancel) + return rememberBiometricAuthenticator( + isAvailable = isAvailable, + promptTitle = promptTitle, + promptNegative = promptNegative, + ) + } + + @Composable + private fun rememberBiometricAuthenticator( + isAvailable: Boolean, + promptTitle: String, + promptNegative: String, + ): BiometricAuthenticator { + val activity = LocalContext.current.findFragmentActivity() + return remember(isAvailable) { + if (isAvailable && activity != null) { + val authenticators = when { + canUseStrongBiometricAuth -> BiometricManager.Authenticators.BIOMETRIC_STRONG + canUseWeakBiometricAuth -> BiometricManager.Authenticators.BIOMETRIC_WEAK + else -> 0 + } + val promptInfo = BiometricPrompt.PromptInfo.Builder().apply { + setTitle(promptTitle) + setNegativeButtonText(promptNegative) + setAllowedAuthenticators(authenticators) + }.build() + DefaultBiometricAuthentication( + activity = activity, + promptInfo = promptInfo, + secretKeyRepository = secretKeyRepository, + encryptionDecryptionService = encryptionDecryptionService, + keyAlias = SECRET_KEY_ALIAS, + callbacks = callbacks + internalCallback + ) + } else { + NoopBiometricAuthentication() + } + } + } + + override fun addCallback(callback: BiometricAuthenticator.Callback) { + callbacks.add(callback) + } + + override fun removeCallback(callback: BiometricAuthenticator.Callback) { + callbacks.remove(callback) + } + + private fun Context.findFragmentActivity(): FragmentActivity? = when (this) { + is FragmentActivity -> this + is ContextWrapper -> baseContext.findFragmentActivity() + else -> null + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricUnlockCallback.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricUnlockCallback.kt new file mode 100644 index 0000000..9a7a0ab --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricUnlockCallback.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.biometric + +open class DefaultBiometricUnlockCallback : BiometricAuthenticator.Callback { + override fun onBiometricSetupError() = Unit + override fun onBiometricAuthenticationSuccess() = Unit + override fun onBiometricAuthenticationFailed(error: Exception?) = Unit +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt new file mode 100644 index 0000000..439c6ae --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.lockscreen.impl.pin.model.PinDigit +import io.element.android.features.lockscreen.impl.pin.model.PinEntry +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.pinDigitBg + +@Composable +fun PinEntryTextField( + pinEntry: PinEntry, + isSecured: Boolean, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + BasicTextField( + modifier = modifier, + value = pinEntry.toText(), + onValueChange = { + onValueChange(it) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + decorationBox = { + PinEntryRow(pinEntry = pinEntry, isSecured = isSecured) + } + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun PinEntryRow( + pinEntry: PinEntry, + isSecured: Boolean, +) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (digit in pinEntry.digits) { + PinDigitView(digit = digit, isSecured = isSecured) + } + } +} + +@Composable +private fun PinDigitView( + digit: PinDigit, + isSecured: Boolean, +) { + val shape = RoundedCornerShape(8.dp) + val appearanceModifier = when (digit) { + PinDigit.Empty -> { + Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape) + } + is PinDigit.Filled -> { + Modifier.background(ElementTheme.colors.pinDigitBg, shape) + } + } + Box( + modifier = Modifier + .size(48.dp) + .then(appearanceModifier), + contentAlignment = Alignment.Center, + ) { + if (digit is PinDigit.Filled) { + val text = if (isSecured) { + "•" + } else { + digit.value.toString() + } + Text( + text = text, + style = ElementTheme.typography.fontHeadingMdBold + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun PinEntryTextFieldPreview() { + ElementPreview { + val pinEntry = PinEntry.createEmpty(4).fillWith("12") + Column { + PinEntryTextField( + pinEntry = pinEntry, + isSecured = true, + onValueChange = {}, + ) + Spacer(modifier = Modifier.size(16.dp)) + PinEntryTextField( + pinEntry = pinEntry, + isSecured = false, + onValueChange = {}, + ) + } + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManager.kt new file mode 100644 index 0000000..1ebf00d --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManager.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.pin + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.features.lockscreen.impl.storage.LockScreenStore +import io.element.android.libraries.cryptography.api.EncryptionDecryptionService +import io.element.android.libraries.cryptography.api.EncryptionResult +import io.element.android.libraries.cryptography.api.SecretKeyRepository +import kotlinx.coroutines.flow.Flow +import java.util.concurrent.CopyOnWriteArrayList + +private const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_PIN_CODE" + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class DefaultPinCodeManager( + private val secretKeyRepository: SecretKeyRepository, + private val encryptionDecryptionService: EncryptionDecryptionService, + private val lockScreenStore: LockScreenStore, +) : PinCodeManager { + private val callbacks = CopyOnWriteArrayList() + + override fun addCallback(callback: PinCodeManager.Callback) { + callbacks.add(callback) + } + + override fun removeCallback(callback: PinCodeManager.Callback) { + callbacks.remove(callback) + } + + override fun hasPinCode(): Flow { + return lockScreenStore.hasPinCode() + } + + override suspend fun getPinCodeSize(): Int { + val encryptedPinCode = lockScreenStore.getEncryptedCode() ?: return 0 + val secretKey = secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, false) + val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode)) + return decryptedPinCode.size + } + + override suspend fun createPinCode(pinCode: String) { + val secretKey = secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, false) + val encryptedPinCode = encryptionDecryptionService.encrypt(secretKey, pinCode.toByteArray()).toBase64() + lockScreenStore.saveEncryptedPinCode(encryptedPinCode) + callbacks.forEach { it.onPinCodeCreated() } + } + + override suspend fun verifyPinCode(pinCode: String): Boolean { + val encryptedPinCode = lockScreenStore.getEncryptedCode() ?: return false + return try { + val secretKey = secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, false) + val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode)) + val pinCodeToCheck = pinCode.toByteArray() + decryptedPinCode.contentEquals(pinCodeToCheck).also { isPinCodeCorrect -> + if (isPinCodeCorrect) { + lockScreenStore.resetCounter() + callbacks.forEach { callback -> + callback.onPinCodeVerified() + } + } else { + lockScreenStore.onWrongPin() + } + } + } catch (failure: Throwable) { + false + } + } + + override suspend fun deletePinCode() { + lockScreenStore.deleteEncryptedPinCode() + lockScreenStore.resetCounter() + callbacks.forEach { it.onPinCodeRemoved() } + } + + override suspend fun getRemainingPinCodeAttemptsNumber(): Int { + return lockScreenStore.getRemainingPinCodeAttemptsNumber() + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManagerCallback.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManagerCallback.kt new file mode 100644 index 0000000..c9c8b7b --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManagerCallback.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.pin + +open class DefaultPinCodeManagerCallback : PinCodeManager.Callback { + override fun onPinCodeVerified() = Unit + + override fun onPinCodeCreated() = Unit + + override fun onPinCodeRemoved() = Unit +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/PinCodeManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/PinCodeManager.kt new file mode 100644 index 0000000..9282f3e --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/PinCodeManager.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.pin + +import kotlinx.coroutines.flow.Flow + +/** + * This interface is the main interface to manage the pin code. + * Implementation should take care of encrypting the pin code and storing it. + */ +interface PinCodeManager { + /** + * Callbacks for pin code management events. + */ + interface Callback { + /** + * Called when the pin code is verified. + */ + fun onPinCodeVerified() + + /** + * Called when the pin code is created. + */ + fun onPinCodeCreated() + + /** + * Called when the pin code is removed. + */ + fun onPinCodeRemoved() + } + + /** + * Register a callback to be notified of pin code management events. + */ + fun addCallback(callback: Callback) + + /** + * Unregister callback to be notified of pin code management events. + */ + fun removeCallback(callback: Callback) + + /** + * @return true if a pin code is available. + */ + fun hasPinCode(): Flow + + /** + * @return the size of the saved pin code. + */ + suspend fun getPinCodeSize(): Int + + /** + * Creates a new encrypted pin code. + * @param pinCode the clear pin code to create + */ + suspend fun createPinCode(pinCode: String) + + /** + * @return true if the pin code is correct. + */ + suspend fun verifyPinCode(pinCode: String): Boolean + + /** + * Deletes the previously created pin code. + */ + suspend fun deletePinCode() + + /** + * @return the number of remaining attempts before the pin code is blocked. + */ + suspend fun getRemainingPinCodeAttemptsNumber(): Int +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinDigit.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinDigit.kt new file mode 100644 index 0000000..64200d0 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinDigit.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.pin.model + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface PinDigit { + data object Empty : PinDigit + data class Filled(val value: Char) : PinDigit + + fun toText(): String { + return when (this) { + is Empty -> "" + is Filled -> value.toString() + } + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntry.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntry.kt new file mode 100644 index 0000000..f9cb2af --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntry.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.pin.model + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +data class PinEntry( + val digits: ImmutableList, +) { + companion object { + fun createEmpty(size: Int): PinEntry { + val digits = List(size) { PinDigit.Empty } + return PinEntry( + digits = digits.toImmutableList() + ) + } + } + + val size = digits.size + + /** + * Fill the first digits with the given text. + * Can't be more than the size of the PinEntry + * Keep the Empty digits at the end + * @return the new PinEntry + */ + fun fillWith(text: String): PinEntry { + val newDigits = MutableList(size) { PinDigit.Empty } + text.forEachIndexed { index, char -> + if (index < size && char.isDigit()) { + newDigits[index] = PinDigit.Filled(char) + } + } + return copy(digits = newDigits.toImmutableList()) + } + + fun deleteLast(): PinEntry { + if (isEmpty()) return this + val newDigits = digits.toMutableList() + newDigits.indexOfLast { it is PinDigit.Filled }.also { lastFilled -> + newDigits[lastFilled] = PinDigit.Empty + } + return copy(digits = newDigits.toImmutableList()) + } + + fun addDigit(digit: Char): PinEntry { + if (isComplete()) return this + val newDigits = digits.toMutableList() + newDigits.indexOfFirst { it is PinDigit.Empty }.also { firstEmpty -> + newDigits[firstEmpty] = PinDigit.Filled(digit) + } + return copy(digits = newDigits.toImmutableList()) + } + + fun clear(): PinEntry { + return createEmpty(size) + } + + fun isComplete(): Boolean { + return digits.all { it is PinDigit.Filled } + } + + fun isEmpty(): Boolean { + return digits.all { it is PinDigit.Empty } + } + + fun toText(): String { + return digits.joinToString("") { + it.toText() + } + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsEvents.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsEvents.kt new file mode 100644 index 0000000..2d62427 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsEvents.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.settings + +sealed interface LockScreenSettingsEvents { + data object OnRemovePin : LockScreenSettingsEvents + data object ConfirmRemovePin : LockScreenSettingsEvents + data object CancelRemovePin : LockScreenSettingsEvents + data object ToggleBiometricAllowed : LockScreenSettingsEvents +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt new file mode 100644 index 0000000..0bd2672 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.settings + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +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.newRoot +import com.bumble.appyx.navmodel.backstack.operation.push +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback +import io.element.android.features.lockscreen.impl.pin.PinCodeManager +import io.element.android.features.lockscreen.impl.setup.pin.SetupPinNode +import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.ui.common.nodes.emptyNode +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +@AssistedInject +class LockScreenSettingsFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val pinCodeManager: PinCodeManager, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Loading, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + sealed interface NavTarget : Parcelable { + @Parcelize + data object Loading : NavTarget + + @Parcelize + data object Unlock : NavTarget + + @Parcelize + data object SetupPin : NavTarget + + @Parcelize + data object Settings : NavTarget + } + + private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() { + override fun onPinCodeRemoved() { + navigateUp() + } + + override fun onPinCodeCreated() { + backstack.newRoot(NavTarget.Settings) + } + } + + override fun onBuilt() { + super.onBuilt() + lifecycleScope.launch { + val hasPinCode = pinCodeManager.hasPinCode().first() + if (hasPinCode) { + backstack.newRoot(NavTarget.Unlock) + } else { + backstack.newRoot(NavTarget.SetupPin) + } + } + lifecycle.subscribe( + onCreate = { + pinCodeManager.addCallback(pinCodeManagerCallback) + }, + onDestroy = { + pinCodeManager.removeCallback(pinCodeManagerCallback) + } + ) + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Loading -> { + emptyNode(buildContext) + } + NavTarget.Unlock -> { + val callback = object : PinUnlockNode.Callback { + override fun onUnlock() { + backstack.newRoot(NavTarget.Settings) + } + } + createNode(buildContext, plugins = listOf(callback)) + } + NavTarget.SetupPin -> { + createNode(buildContext) + } + NavTarget.Settings -> { + val callback = object : LockScreenSettingsNode.Callback { + override fun navigateToSetupPin() { + backstack.push(NavTarget.SetupPin) + } + } + createNode(buildContext, plugins = listOf(callback)) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView() + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsNode.kt new file mode 100644 index 0000000..e66bc13 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsNode.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.settings + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class LockScreenSettingsNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: LockScreenSettingsPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun navigateToSetupPin() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + LockScreenSettingsView( + state = state, + onBackClick = this::navigateUp, + onChangePinClick = callback::navigateToSetupPin, + modifier = modifier, + ) + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt new file mode 100644 index 0000000..589794b --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.settings + +import androidx.compose.runtime.Composable +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.setValue +import dev.zacsweers.metro.Inject +import io.element.android.features.lockscreen.impl.LockScreenConfig +import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator +import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager +import io.element.android.features.lockscreen.impl.pin.PinCodeManager +import io.element.android.features.lockscreen.impl.storage.LockScreenStore +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.annotations.AppCoroutineScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Inject +class LockScreenSettingsPresenter( + private val lockScreenConfig: LockScreenConfig, + private val pinCodeManager: PinCodeManager, + private val lockScreenStore: LockScreenStore, + private val biometricAuthenticatorManager: BiometricAuthenticatorManager, + @AppCoroutineScope + private val coroutineScope: CoroutineScope, +) : Presenter { + @Composable + override fun present(): LockScreenSettingsState { + val showRemovePinOption by produceState(initialValue = false) { + pinCodeManager.hasPinCode().collect { hasPinCode -> + value = !lockScreenConfig.isPinMandatory && hasPinCode + } + } + val isBiometricEnabled by remember { + lockScreenStore.isBiometricUnlockAllowed() + }.collectAsState(initial = false) + var showRemovePinConfirmation by remember { + mutableStateOf(false) + } + + val biometricUnlock = biometricAuthenticatorManager.rememberConfirmBiometricAuthenticator() + + fun handleEvent(event: LockScreenSettingsEvents) { + when (event) { + LockScreenSettingsEvents.CancelRemovePin -> showRemovePinConfirmation = false + LockScreenSettingsEvents.ConfirmRemovePin -> { + coroutineScope.launch { + if (showRemovePinConfirmation) { + showRemovePinConfirmation = false + pinCodeManager.deletePinCode() + } + } + } + LockScreenSettingsEvents.OnRemovePin -> showRemovePinConfirmation = true + LockScreenSettingsEvents.ToggleBiometricAllowed -> { + coroutineScope.launch { + if (!isBiometricEnabled) { + biometricUnlock.setup() + if (biometricUnlock.authenticate() == BiometricAuthenticator.AuthenticationResult.Success) { + lockScreenStore.setIsBiometricUnlockAllowed(true) + } + } else { + lockScreenStore.setIsBiometricUnlockAllowed(false) + } + } + } + } + } + + return LockScreenSettingsState( + showRemovePinOption = showRemovePinOption, + isBiometricEnabled = isBiometricEnabled, + showRemovePinConfirmation = showRemovePinConfirmation, + showToggleBiometric = biometricAuthenticatorManager.isDeviceSecured, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsState.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsState.kt new file mode 100644 index 0000000..a69d633 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsState.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.settings + +data class LockScreenSettingsState( + val showRemovePinOption: Boolean, + val isBiometricEnabled: Boolean, + val showRemovePinConfirmation: Boolean, + val showToggleBiometric: Boolean, + val eventSink: (LockScreenSettingsEvents) -> Unit +) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsStateProvider.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsStateProvider.kt new file mode 100644 index 0000000..43f20c1 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsStateProvider.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.settings + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class LockScreenSettingsStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aLockScreenSettingsState(), + aLockScreenSettingsState(isLockMandatory = true), + aLockScreenSettingsState(showRemovePinConfirmation = true), + ) +} + +fun aLockScreenSettingsState( + isLockMandatory: Boolean = false, + isBiometricEnabled: Boolean = false, + showRemovePinConfirmation: Boolean = false, + showToggleBiometric: Boolean = true, +) = LockScreenSettingsState( + showRemovePinOption = isLockMandatory, + isBiometricEnabled = isBiometricEnabled, + showRemovePinConfirmation = showRemovePinConfirmation, + showToggleBiometric = showToggleBiometric, + eventSink = {} +) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt new file mode 100644 index 0000000..fe5f20d --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.settings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.lockscreen.impl.R +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.components.preferences.PreferenceDivider +import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun LockScreenSettingsView( + state: LockScreenSettingsState, + onChangePinClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + PreferencePage( + title = stringResource(id = io.element.android.libraries.ui.strings.R.string.common_screen_lock), + onBackClick = onBackClick, + modifier = modifier + ) { + PreferenceCategory(showTopDivider = false) { + ListItem( + headlineContent = { + Text(stringResource(id = R.string.screen_app_lock_settings_change_pin)) + }, + onClick = onChangePinClick, + ) + PreferenceDivider() + if (state.showRemovePinOption) { + ListItem( + headlineContent = { + Text(stringResource(id = R.string.screen_app_lock_settings_remove_pin)) + }, + style = ListItemStyle.Destructive, + onClick = { + state.eventSink(LockScreenSettingsEvents.OnRemovePin) + } + ) + } + if (state.showToggleBiometric) { + PreferenceDivider() + PreferenceSwitch( + title = stringResource(id = R.string.screen_app_lock_settings_enable_biometric_unlock), + isChecked = state.isBiometricEnabled, + onCheckedChange = { + state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed) + } + ) + } + } + } + if (state.showRemovePinConfirmation) { + ConfirmationDialog( + title = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_title), + content = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_message), + onSubmitClick = { + state.eventSink(LockScreenSettingsEvents.ConfirmRemovePin) + }, + onDismiss = { + state.eventSink(LockScreenSettingsEvents.CancelRemovePin) + } + ) + } +} + +@PreviewsDayNight +@Composable +internal fun LockScreenSettingsViewPreview( + @PreviewParameter(LockScreenSettingsStateProvider::class) state: LockScreenSettingsState, +) { + ElementPreview { + LockScreenSettingsView( + state = state, + onChangePinClick = {}, + onBackClick = {}, + ) + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/LockScreenSetupFlowNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/LockScreenSetupFlowNode.kt new file mode 100644 index 0000000..6f47395 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/LockScreenSetupFlowNode.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.setup + +import android.os.Parcelable +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.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.newRoot +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager +import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback +import io.element.android.features.lockscreen.impl.pin.PinCodeManager +import io.element.android.features.lockscreen.impl.setup.biometric.SetupBiometricNode +import io.element.android.features.lockscreen.impl.setup.pin.SetupPinNode +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +@AssistedInject +class LockScreenSetupFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val pinCodeManager: PinCodeManager, + val biometricAuthenticatorManager: BiometricAuthenticatorManager, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Pin, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + interface Callback : Plugin { + fun onSetupDone() + } + + private val callback: Callback = callback() + + sealed interface NavTarget : Parcelable { + @Parcelize + data object Pin : NavTarget + + @Parcelize + data object Biometric : NavTarget + } + + private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() { + override fun onPinCodeCreated() { + if (biometricAuthenticatorManager.hasAvailableAuthenticator) { + backstack.newRoot(NavTarget.Biometric) + } else { + callback.onSetupDone() + } + } + } + + init { + lifecycle.subscribe( + onCreate = { + pinCodeManager.addCallback(pinCodeManagerCallback) + }, + onDestroy = { + pinCodeManager.removeCallback(pinCodeManagerCallback) + } + ) + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Pin -> { + createNode(buildContext) + } + NavTarget.Biometric -> { + val callback = object : SetupBiometricNode.Callback { + override fun onBiometricSetupDone() { + callback.onSetupDone() + } + } + createNode(buildContext, plugins = listOf(callback)) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView() + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricEvents.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricEvents.kt new file mode 100644 index 0000000..ab8b186 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.setup.biometric + +sealed interface SetupBiometricEvents { + data object AllowBiometric : SetupBiometricEvents + data object UsePin : SetupBiometricEvents +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricNode.kt new file mode 100644 index 0000000..c74b9cd --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricNode.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.setup.biometric + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class SetupBiometricNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: SetupBiometricPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onBiometricSetupDone() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + LaunchedEffect(state.isBiometricSetupDone) { + if (state.isBiometricSetupDone) { + callback.onBiometricSetupDone() + } + } + SetupBiometricView( + state = state, + modifier = modifier + ) + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenter.kt new file mode 100644 index 0000000..3af2a28 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenter.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.setup.biometric + +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 dev.zacsweers.metro.Inject +import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator +import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager +import io.element.android.features.lockscreen.impl.storage.LockScreenStore +import io.element.android.libraries.architecture.Presenter +import kotlinx.coroutines.launch + +@Inject +class SetupBiometricPresenter( + private val lockScreenStore: LockScreenStore, + private val biometricAuthenticatorManager: BiometricAuthenticatorManager, +) : Presenter { + @Composable + override fun present(): SetupBiometricState { + var isBiometricSetupDone by remember { + mutableStateOf(false) + } + + val coroutineScope = rememberCoroutineScope() + val biometricUnlock = biometricAuthenticatorManager.rememberConfirmBiometricAuthenticator() + + fun handleEvent(event: SetupBiometricEvents) { + when (event) { + SetupBiometricEvents.AllowBiometric -> coroutineScope.launch { + biometricUnlock.setup() + if (biometricUnlock.authenticate() == BiometricAuthenticator.AuthenticationResult.Success) { + lockScreenStore.setIsBiometricUnlockAllowed(true) + isBiometricSetupDone = true + } + } + SetupBiometricEvents.UsePin -> coroutineScope.launch { + lockScreenStore.setIsBiometricUnlockAllowed(false) + isBiometricSetupDone = true + } + } + } + + return SetupBiometricState( + isBiometricSetupDone = isBiometricSetupDone, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricState.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricState.kt new file mode 100644 index 0000000..2843c02 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricState.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.setup.biometric + +data class SetupBiometricState( + val isBiometricSetupDone: Boolean, + val eventSink: (SetupBiometricEvents) -> Unit +) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricStateProvider.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricStateProvider.kt new file mode 100644 index 0000000..d725d2d --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricStateProvider.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.setup.biometric + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class SetupBiometricStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aSetupBiometricState(), + ) +} + +fun aSetupBiometricState( + isBiometricSetupDone: Boolean = false, +) = SetupBiometricState( + isBiometricSetupDone = isBiometricSetupDone, + eventSink = {} +) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt new file mode 100644 index 0000000..35b1ec7 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.setup.biometric + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Fingerprint +import androidx.compose.runtime.Composable +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.lockscreen.impl.R +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.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.TextButton + +@Composable +fun SetupBiometricView( + state: SetupBiometricState, + modifier: Modifier = Modifier, +) { + BackHandler { + state.eventSink(SetupBiometricEvents.UsePin) + } + HeaderFooterPage( + modifier = modifier.padding(top = 80.dp), + header = { + SetupBiometricHeader() + }, + footer = { + SetupBiometricFooter( + onAllowClick = { state.eventSink(SetupBiometricEvents.AllowBiometric) }, + onSkipClick = { state.eventSink(SetupBiometricEvents.UsePin) } + ) + }, + ) +} + +@Composable +private fun SetupBiometricHeader() { + val biometricAuth = stringResource(id = R.string.screen_app_lock_biometric_authentication) + IconTitleSubtitleMolecule( + iconStyle = BigIcon.Style.Default(Icons.Default.Fingerprint), + title = stringResource(id = R.string.screen_app_lock_settings_enable_biometric_unlock), + subTitle = stringResource(id = R.string.screen_app_lock_setup_biometric_unlock_subtitle, biometricAuth), + ) +} + +@Composable +private fun SetupBiometricFooter( + onAllowClick: () -> Unit, + onSkipClick: () -> Unit, +) { + ButtonColumnMolecule { + val biometricAuth = stringResource(id = R.string.screen_app_lock_biometric_authentication) + Button( + text = stringResource(id = R.string.screen_app_lock_setup_biometric_unlock_allow_title, biometricAuth), + onClick = onAllowClick + ) + TextButton( + text = stringResource(id = R.string.screen_app_lock_setup_biometric_unlock_skip), + onClick = onSkipClick + ) + } +} + +@Composable +@PreviewsDayNight +internal fun SetupBiometricViewPreview(@PreviewParameter(SetupBiometricStateProvider::class) state: SetupBiometricState) { + ElementPreview { + SetupBiometricView( + state = state, + ) + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinEvents.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinEvents.kt new file mode 100644 index 0000000..276a94b --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.setup.pin + +sealed interface SetupPinEvents { + data class OnPinEntryChanged(val entryAsText: String, val fromConfirmationStep: Boolean) : SetupPinEvents + data object ClearFailure : SetupPinEvents +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinNode.kt new file mode 100644 index 0000000..2f86ca5 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinNode.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.setup.pin + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class SetupPinNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: SetupPinPresenter, +) : Node(buildContext, plugins = plugins) { + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + SetupPinView( + state = state, + onBackClick = this::navigateUp, + modifier = modifier + ) + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenter.kt new file mode 100644 index 0000000..ac5b5bd --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenter.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.setup.pin + +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 dev.zacsweers.metro.Inject +import io.element.android.features.lockscreen.impl.LockScreenConfig +import io.element.android.features.lockscreen.impl.pin.PinCodeManager +import io.element.android.features.lockscreen.impl.pin.model.PinEntry +import io.element.android.features.lockscreen.impl.setup.pin.validation.PinValidator +import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta +import kotlinx.coroutines.delay + +/** + * Some time for the ui to refresh before showing confirmation step. + */ +private const val DELAY_BEFORE_CONFIRMATION_STEP_IN_MILLIS = 100L + +@Inject +class SetupPinPresenter( + private val lockScreenConfig: LockScreenConfig, + private val pinValidator: PinValidator, + private val buildMeta: BuildMeta, + private val pinCodeManager: PinCodeManager, +) : Presenter { + @Composable + override fun present(): SetupPinState { + var choosePinEntry by remember { + mutableStateOf(PinEntry.createEmpty(lockScreenConfig.pinSize)) + } + var confirmPinEntry by remember { + mutableStateOf(PinEntry.createEmpty(lockScreenConfig.pinSize)) + } + var isConfirmationStep by remember { + mutableStateOf(false) + } + var setupPinFailure by remember { + mutableStateOf(null) + } + LaunchedEffect(choosePinEntry) { + if (choosePinEntry.isComplete()) { + when (val pinValidationResult = pinValidator.isPinValid(choosePinEntry)) { + is PinValidator.Result.Invalid -> { + setupPinFailure = pinValidationResult.failure + } + PinValidator.Result.Valid -> { + delay(DELAY_BEFORE_CONFIRMATION_STEP_IN_MILLIS) + isConfirmationStep = true + } + } + } + } + + LaunchedEffect(confirmPinEntry) { + if (confirmPinEntry.isComplete()) { + if (confirmPinEntry == choosePinEntry) { + pinCodeManager.createPinCode(confirmPinEntry.toText()) + } else { + setupPinFailure = SetupPinFailure.PinsDoNotMatch + } + } + } + + fun handleEvent(event: SetupPinEvents) { + when (event) { + is SetupPinEvents.OnPinEntryChanged -> { + // Use the fromConfirmationStep flag from ui to avoid race condition. + if (event.fromConfirmationStep) { + confirmPinEntry = confirmPinEntry.fillWith(event.entryAsText) + } else { + choosePinEntry = choosePinEntry.fillWith(event.entryAsText) + } + } + SetupPinEvents.ClearFailure -> { + when (setupPinFailure) { + is SetupPinFailure.PinsDoNotMatch -> { + choosePinEntry = choosePinEntry.clear() + confirmPinEntry = confirmPinEntry.clear() + } + is SetupPinFailure.ForbiddenPin -> { + choosePinEntry = choosePinEntry.clear() + } + null -> Unit + } + isConfirmationStep = false + setupPinFailure = null + } + } + } + + return SetupPinState( + choosePinEntry = choosePinEntry, + confirmPinEntry = confirmPinEntry, + isConfirmationStep = isConfirmationStep, + setupPinFailure = setupPinFailure, + appName = buildMeta.applicationName, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinState.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinState.kt new file mode 100644 index 0000000..2d5124d --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinState.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.setup.pin + +import io.element.android.features.lockscreen.impl.pin.model.PinEntry +import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure + +data class SetupPinState( + val choosePinEntry: PinEntry, + val confirmPinEntry: PinEntry, + val isConfirmationStep: Boolean, + val setupPinFailure: SetupPinFailure?, + val appName: String, + val eventSink: (SetupPinEvents) -> Unit +) { + val activePinEntry = if (isConfirmationStep) { + confirmPinEntry + } else { + choosePinEntry + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinStateProvider.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinStateProvider.kt new file mode 100644 index 0000000..f50643e --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinStateProvider.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.setup.pin + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.lockscreen.impl.pin.model.PinEntry +import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure + +open class SetupPinStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aSetupPinState(), + aSetupPinState( + choosePinEntry = PinEntry.createEmpty(4).fillWith("12") + ), + aSetupPinState( + choosePinEntry = PinEntry.createEmpty(4).fillWith("1789"), + isConfirmationStep = true, + ), + aSetupPinState( + choosePinEntry = PinEntry.createEmpty(4).fillWith("1789"), + confirmPinEntry = PinEntry.createEmpty(4).fillWith("1788"), + isConfirmationStep = true, + creationFailure = SetupPinFailure.PinsDoNotMatch + ), + aSetupPinState( + choosePinEntry = PinEntry.createEmpty(4).fillWith("1111"), + creationFailure = SetupPinFailure.ForbiddenPin + ), + ) +} + +fun aSetupPinState( + choosePinEntry: PinEntry = PinEntry.createEmpty(4), + confirmPinEntry: PinEntry = PinEntry.createEmpty(4), + isConfirmationStep: Boolean = false, + creationFailure: SetupPinFailure? = null, +) = SetupPinState( + choosePinEntry = choosePinEntry, + confirmPinEntry = confirmPinEntry, + isConfirmationStep = isConfirmationStep, + setupPinFailure = creationFailure, + appName = "Element", + eventSink = {} +) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt new file mode 100644 index 0000000..5f2320d --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.features.lockscreen.impl.setup.pin + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +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.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.lockscreen.impl.R +import io.element.android.features.lockscreen.impl.components.PinEntryTextField +import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.TopAppBar + +@Composable +fun SetupPinView( + state: SetupPinState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + navigationIcon = { + BackButton(onClick = onBackClick) + }, + title = {} + ) + }, + content = { padding -> + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .imePadding() + .padding(padding) + .consumeWindowInsets(padding) + .verticalScroll(state = scrollState) + .padding(vertical = 16.dp, horizontal = 20.dp), + ) { + SetupPinHeader(state.isConfirmationStep, state.appName) + SetupPinContent(state) + } + } + ) +} + +@Composable +private fun SetupPinHeader( + isValidationStep: Boolean, + appName: String, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + IconTitleSubtitleMolecule( + title = if (isValidationStep) { + stringResource(id = R.string.screen_app_lock_setup_confirm_pin) + } else { + stringResource(id = R.string.screen_app_lock_setup_choose_pin) + }, + subTitle = stringResource(id = R.string.screen_app_lock_setup_pin_context, appName), + iconStyle = BigIcon.Style.Default(CompoundIcons.LockSolid()), + ) + } +} + +@Composable +private fun SetupPinContent( + state: SetupPinState, +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + PinEntryTextField( + pinEntry = state.activePinEntry, + isSecured = true, + onValueChange = { entry -> + state.eventSink(SetupPinEvents.OnPinEntryChanged(entry, state.isConfirmationStep)) + }, + modifier = Modifier + .focusRequester(focusRequester) + .padding(top = 36.dp) + .fillMaxWidth() + ) + if (state.setupPinFailure != null) { + ErrorDialog( + title = state.setupPinFailure.title(), + content = state.setupPinFailure.content(), + onSubmit = { + state.eventSink(SetupPinEvents.ClearFailure) + } + ) + } +} + +@Composable +@ReadOnlyComposable +private fun SetupPinFailure.content(): String { + return when (this) { + SetupPinFailure.ForbiddenPin -> stringResource(id = R.string.screen_app_lock_setup_pin_forbidden_dialog_content) + SetupPinFailure.PinsDoNotMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_content) + } +} + +@Composable +@ReadOnlyComposable +private fun SetupPinFailure.title(): String { + return when (this) { + SetupPinFailure.ForbiddenPin -> stringResource(id = R.string.screen_app_lock_setup_pin_forbidden_dialog_title) + SetupPinFailure.PinsDoNotMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_title) + } +} + +@Composable +@PreviewsDayNight +internal fun SetupPinViewPreview(@PreviewParameter(SetupPinStateProvider::class) state: SetupPinState) { + ElementPreview { + SetupPinView( + state = state, + onBackClick = {}, + ) + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/validation/PinValidator.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/validation/PinValidator.kt new file mode 100644 index 0000000..c84d892 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/validation/PinValidator.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.setup.pin.validation + +import dev.zacsweers.metro.Inject +import io.element.android.features.lockscreen.impl.LockScreenConfig +import io.element.android.features.lockscreen.impl.pin.model.PinEntry + +@Inject +class PinValidator(private val lockScreenConfig: LockScreenConfig) { + sealed interface Result { + data object Valid : Result + data class Invalid(val failure: SetupPinFailure) : Result + } + + fun isPinValid(pinEntry: PinEntry): Result { + val pinAsText = pinEntry.toText() + val isForbidden = lockScreenConfig.forbiddenPinCodes.any { it == pinAsText } + return if (isForbidden) { + Result.Invalid(SetupPinFailure.ForbiddenPin) + } else { + Result.Valid + } + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/validation/SetupPinFailure.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/validation/SetupPinFailure.kt new file mode 100644 index 0000000..94e4aa5 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/validation/SetupPinFailure.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.setup.pin.validation + +sealed interface SetupPinFailure { + data object ForbiddenPin : SetupPinFailure + data object PinsDoNotMatch : SetupPinFailure +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/EncryptedPinCodeStorage.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/EncryptedPinCodeStorage.kt new file mode 100644 index 0000000..c455881 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/EncryptedPinCodeStorage.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.storage + +import kotlinx.coroutines.flow.Flow + +/** + * Should be implemented by any class that provides access to the encrypted PIN code. + * All methods are suspending in case there are async IO operations involved. + */ +interface EncryptedPinCodeStorage { + /** + * Returns the encrypted PIN code. + */ + suspend fun getEncryptedCode(): String? + + /** + * Saves the encrypted PIN code to some persistable storage. + */ + suspend fun saveEncryptedPinCode(pinCode: String) + + /** + * Deletes the PIN code from some persistable storage. + */ + suspend fun deleteEncryptedPinCode() + + /** + * Returns whether the PIN code is stored or not. + */ + fun hasPinCode(): Flow +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/LockScreenStore.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/LockScreenStore.kt new file mode 100644 index 0000000..d48a30e --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/LockScreenStore.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.storage + +import kotlinx.coroutines.flow.Flow + +interface LockScreenStore : EncryptedPinCodeStorage { + /** + * Returns the remaining PIN code attempts. When this reaches 0 the PIN code access won't be available for some time. + */ + suspend fun getRemainingPinCodeAttemptsNumber(): Int + + /** + * Should decrement the number of remaining PIN code attempts. + */ + suspend fun onWrongPin() + + /** + * Resets the counter of attempts for PIN code and biometric access. + */ + suspend fun resetCounter() + + /** + * Returns whether the biometric unlock is allowed or not. + */ + fun isBiometricUnlockAllowed(): Flow + + /** + * Sets whether the biometric unlock is allowed or not. + */ + suspend fun setIsBiometricUnlockAllowed(isAllowed: Boolean) +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/PreferencesLockScreenStore.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/PreferencesLockScreenStore.kt new file mode 100644 index 0000000..6b99d90 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/PreferencesLockScreenStore.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.storage + +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.lockscreen.impl.LockScreenConfig +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +@ContributesBinding(AppScope::class) +class PreferencesLockScreenStore( + preferenceDataStoreFactory: PreferenceDataStoreFactory, + private val lockScreenConfig: LockScreenConfig, +) : LockScreenStore { + private val dataStore = preferenceDataStoreFactory.create("pin_code_store") + + private val pinCodeKey = stringPreferencesKey("encoded_pin_code") + private val remainingAttemptsKey = intPreferencesKey("remaining_pin_code_attempts") + private val biometricUnlockKey = booleanPreferencesKey("biometric_unlock_enabled") + + override suspend fun getRemainingPinCodeAttemptsNumber(): Int { + return dataStore.data.map { preferences -> + preferences.getRemainingPinCodeAttemptsNumber() + }.first() + } + + override suspend fun onWrongPin() { + dataStore.edit { preferences -> + val current = preferences.getRemainingPinCodeAttemptsNumber() + val remaining = (current - 1).coerceAtLeast(0) + preferences[remainingAttemptsKey] = remaining + } + } + + override suspend fun resetCounter() { + dataStore.edit { preferences -> + preferences[remainingAttemptsKey] = lockScreenConfig.maxPinCodeAttemptsBeforeLogout + } + } + + override suspend fun getEncryptedCode(): String? { + return dataStore.data.map { preferences -> + preferences[pinCodeKey] + }.first() + } + + override suspend fun saveEncryptedPinCode(pinCode: String) { + dataStore.edit { preferences -> + preferences[pinCodeKey] = pinCode + } + } + + override suspend fun deleteEncryptedPinCode() { + dataStore.edit { preferences -> + preferences.remove(pinCodeKey) + } + } + + override fun hasPinCode(): Flow { + return dataStore.data.map { preferences -> + preferences[pinCodeKey] != null + } + } + + override fun isBiometricUnlockAllowed(): Flow { + return dataStore.data.map { preferences -> + preferences[biometricUnlockKey] ?: false + } + } + + override suspend fun setIsBiometricUnlockAllowed(isAllowed: Boolean) { + dataStore.edit { preferences -> + preferences[biometricUnlockKey] = isAllowed + } + } + + private fun Preferences.getRemainingPinCodeAttemptsNumber() = this[remainingAttemptsKey] ?: lockScreenConfig.maxPinCodeAttemptsBeforeLogout +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockEvents.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockEvents.kt new file mode 100644 index 0000000..bd90438 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockEvents.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.unlock + +import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel + +sealed interface PinUnlockEvents { + data class OnPinKeypadPressed(val pinKeypadModel: PinKeypadModel) : PinUnlockEvents + data class OnPinEntryChanged(val entryAsText: String) : PinUnlockEvents + data object OnForgetPin : PinUnlockEvents + data object ClearSignOutPrompt : PinUnlockEvents + data object SignOut : PinUnlockEvents + data object OnUseBiometric : PinUnlockEvents + data object ClearBiometricError : PinUnlockEvents +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockHelper.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockHelper.kt new file mode 100644 index 0000000..2a47581 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockHelper.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.unlock + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import dev.zacsweers.metro.Inject +import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager +import io.element.android.features.lockscreen.impl.biometric.DefaultBiometricUnlockCallback +import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback +import io.element.android.features.lockscreen.impl.pin.PinCodeManager + +@Inject +class PinUnlockHelper( + private val biometricAuthenticatorManager: BiometricAuthenticatorManager, + private val pinCodeManager: PinCodeManager +) { + @Composable + fun OnUnlockEffect(onUnlock: () -> Unit) { + val latestOnUnlock by rememberUpdatedState(onUnlock) + DisposableEffect(Unit) { + val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() { + override fun onBiometricAuthenticationSuccess() { + latestOnUnlock() + } + } + val pinCodeVerifiedCallback = object : DefaultPinCodeManagerCallback() { + override fun onPinCodeVerified() { + latestOnUnlock() + } + } + biometricAuthenticatorManager.addCallback(biometricUnlockCallback) + pinCodeManager.addCallback(pinCodeVerifiedCallback) + onDispose { + biometricAuthenticatorManager.removeCallback(biometricUnlockCallback) + pinCodeManager.removeCallback(pinCodeVerifiedCallback) + } + } + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt new file mode 100644 index 0000000..459e165 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.unlock + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class PinUnlockNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: PinUnlockPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onUnlock() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + LaunchedEffect(state.isUnlocked) { + if (state.isUnlocked) { + callback.onUnlock() + } + } + PinUnlockView( + state = state, + // UnlockNode is only used for in-app unlock, so we can safely set isInAppUnlock to true. + // It's set to false in PinUnlockActivity. + isInAppUnlock = true, + modifier = modifier + ) + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt new file mode 100644 index 0000000..5429320 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.unlock + +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 dev.zacsweers.metro.Inject +import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator +import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager +import io.element.android.features.lockscreen.impl.pin.PinCodeManager +import io.element.android.features.lockscreen.impl.pin.model.PinEntry +import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel +import io.element.android.features.logout.api.LogoutUseCase +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +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.di.annotations.AppCoroutineScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Inject +class PinUnlockPresenter( + private val pinCodeManager: PinCodeManager, + private val biometricAuthenticatorManager: BiometricAuthenticatorManager, + private val logoutUseCase: LogoutUseCase, + @AppCoroutineScope + private val coroutineScope: CoroutineScope, + private val pinUnlockHelper: PinUnlockHelper, +) : Presenter { + @Composable + override fun present(): PinUnlockState { + val pinEntryState = remember { + mutableStateOf>(AsyncData.Uninitialized) + } + val pinEntry by pinEntryState + var remainingAttempts by remember { + mutableStateOf>(AsyncData.Uninitialized) + } + var showWrongPinTitle by rememberSaveable { + mutableStateOf(false) + } + var showSignOutPrompt by rememberSaveable { + mutableStateOf(false) + } + val signOutAction = remember { + mutableStateOf>(AsyncAction.Uninitialized) + } + var biometricUnlockResult by remember { + mutableStateOf(null) + } + val isUnlocked = remember { + mutableStateOf(false) + } + val biometricUnlock = biometricAuthenticatorManager.rememberUnlockBiometricAuthenticator() + LaunchedEffect(Unit) { + suspend { + val pinCodeSize = pinCodeManager.getPinCodeSize() + PinEntry.createEmpty(pinCodeSize) + }.runCatchingUpdatingState(pinEntryState) + } + LaunchedEffect(biometricUnlock) { + biometricUnlock.setup() + biometricUnlock.authenticate() + } + + LaunchedEffect(pinEntry) { + if (pinEntry.isComplete()) { + val isVerified = pinCodeManager.verifyPinCode(pinEntry.toText()) + if (!isVerified) { + pinEntryState.value = pinEntry.clear() + showWrongPinTitle = true + } + } + val remainingAttemptsNumber = pinCodeManager.getRemainingPinCodeAttemptsNumber() + remainingAttempts = AsyncData.Success(remainingAttemptsNumber) + if (remainingAttemptsNumber == 0) { + showSignOutPrompt = true + } + } + pinUnlockHelper.OnUnlockEffect { + isUnlocked.value = true + } + + fun handleEvent(event: PinUnlockEvents) { + when (event) { + is PinUnlockEvents.OnPinKeypadPressed -> { + pinEntryState.value = pinEntry.process(event.pinKeypadModel) + } + PinUnlockEvents.OnForgetPin -> showSignOutPrompt = true + PinUnlockEvents.ClearSignOutPrompt -> showSignOutPrompt = false + PinUnlockEvents.SignOut -> { + if (showSignOutPrompt) { + showSignOutPrompt = false + coroutineScope.signOut(signOutAction) + } + } + PinUnlockEvents.OnUseBiometric -> { + coroutineScope.launch { + biometricUnlockResult = biometricUnlock.authenticate() + } + } + PinUnlockEvents.ClearBiometricError -> { + biometricUnlockResult = null + } + is PinUnlockEvents.OnPinEntryChanged -> { + pinEntryState.value = pinEntry.process(event.entryAsText) + } + } + } + return PinUnlockState( + pinEntry = pinEntry, + showWrongPinTitle = showWrongPinTitle, + remainingAttempts = remainingAttempts, + showSignOutPrompt = showSignOutPrompt, + signOutAction = signOutAction.value, + showBiometricUnlock = biometricUnlock.isActive, + biometricUnlockResult = biometricUnlockResult, + isUnlocked = isUnlocked.value, + eventSink = ::handleEvent, + ) + } + + private fun AsyncData.isComplete(): Boolean { + return dataOrNull()?.isComplete().orFalse() + } + + private fun AsyncData.toText(): String { + return dataOrNull()?.toText() ?: "" + } + + private fun AsyncData.clear(): AsyncData { + return when (this) { + is AsyncData.Success -> AsyncData.Success(data.clear()) + else -> this + } + } + + private fun AsyncData.process(pinKeypadModel: PinKeypadModel): AsyncData { + return when (this) { + is AsyncData.Success -> { + val pinEntry = when (pinKeypadModel) { + PinKeypadModel.Back -> data.deleteLast() + is PinKeypadModel.Number -> data.addDigit(pinKeypadModel.number) + PinKeypadModel.Empty -> data + } + AsyncData.Success(pinEntry) + } + else -> this + } + } + + private fun AsyncData.process(pinEntryAsText: String): AsyncData { + return when (this) { + is AsyncData.Success -> { + val pinEntry = data.fillWith(pinEntryAsText) + AsyncData.Success(pinEntry) + } + else -> this + } + } + + private fun CoroutineScope.signOut(signOutAction: MutableState>) = launch { + suspend { + logoutUseCase.logoutAll(ignoreSdkError = true) + }.runCatchingUpdatingState(signOutAction) + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt new file mode 100644 index 0000000..2bbcbe3 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.unlock + +import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator +import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockError +import io.element.android.features.lockscreen.impl.pin.model.PinEntry +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData + +data class PinUnlockState( + val pinEntry: AsyncData, + val showWrongPinTitle: Boolean, + val remainingAttempts: AsyncData, + val showSignOutPrompt: Boolean, + val signOutAction: AsyncAction, + val showBiometricUnlock: Boolean, + val isUnlocked: Boolean, + val biometricUnlockResult: BiometricAuthenticator.AuthenticationResult?, + val eventSink: (PinUnlockEvents) -> Unit +) { + val isSignOutPromptCancellable = when (remainingAttempts) { + is AsyncData.Success -> remainingAttempts.data > 0 + else -> true + } + + val biometricUnlockErrorMessage = when { + biometricUnlockResult is BiometricAuthenticator.AuthenticationResult.Failure && + biometricUnlockResult.error is BiometricUnlockError && + biometricUnlockResult.error.isAuthDisabledError -> { + biometricUnlockResult.error.message + } + else -> null + } + val showBiometricUnlockError = biometricUnlockErrorMessage != null +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt new file mode 100644 index 0000000..2beb8ba --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.unlock + +import androidx.biometric.BiometricPrompt +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator +import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockError +import io.element.android.features.lockscreen.impl.pin.model.PinEntry +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData + +open class PinUnlockStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aPinUnlockState(), + aPinUnlockState(pinEntry = PinEntry.createEmpty(4).fillWith("12")), + aPinUnlockState(showWrongPinTitle = true), + aPinUnlockState(showSignOutPrompt = true), + aPinUnlockState(showBiometricUnlock = false), + aPinUnlockState(showSignOutPrompt = true, remainingAttempts = AsyncData.Success(0)), + aPinUnlockState(signOutAction = AsyncAction.Loading), + aPinUnlockState( + biometricUnlockResult = BiometricAuthenticator.AuthenticationResult.Failure( + BiometricUnlockError(BiometricPrompt.ERROR_LOCKOUT, "Biometric auth disabled") + ) + ), + ) +} + +fun aPinUnlockState( + pinEntry: PinEntry = PinEntry.createEmpty(4), + remainingAttempts: AsyncData = AsyncData.Success(3), + showWrongPinTitle: Boolean = false, + showSignOutPrompt: Boolean = false, + showBiometricUnlock: Boolean = true, + biometricUnlockResult: BiometricAuthenticator.AuthenticationResult? = null, + isUnlocked: Boolean = false, + signOutAction: AsyncAction = AsyncAction.Uninitialized, +) = PinUnlockState( + pinEntry = AsyncData.Success(pinEntry), + showWrongPinTitle = showWrongPinTitle, + remainingAttempts = remainingAttempts, + showSignOutPrompt = showSignOutPrompt, + showBiometricUnlock = showBiometricUnlock, + signOutAction = signOutAction, + biometricUnlockResult = biometricUnlockResult, + isUnlocked = isUnlocked, + eventSink = {} +) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt new file mode 100644 index 0000000..659f8c2 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt @@ -0,0 +1,377 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.unlock + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.BoxWithConstraintsScope +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.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.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.shape.CircleShape +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.pluralStringResource +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.lifecycle.Lifecycle +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.lockscreen.impl.R +import io.element.android.features.lockscreen.impl.components.PinEntryTextField +import io.element.android.features.lockscreen.impl.pin.model.PinDigit +import io.element.android.features.lockscreen.impl.pin.model.PinEntry +import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypad +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.BigIcon +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun PinUnlockView( + state: PinUnlockState, + isInAppUnlock: Boolean, + modifier: Modifier = Modifier, +) { + OnLifecycleEvent { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(PinUnlockEvents.OnUseBiometric) + else -> Unit + } + } + Surface(modifier) { + PinUnlockPage(state = state, isInAppUnlock = isInAppUnlock) + if (state.showSignOutPrompt) { + SignOutPrompt( + isCancellable = state.isSignOutPromptCancellable, + onSignOut = { state.eventSink(PinUnlockEvents.SignOut) }, + onDismiss = { state.eventSink(PinUnlockEvents.ClearSignOutPrompt) }, + ) + } + when (state.signOutAction) { + AsyncAction.Loading -> { + ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content)) + } + is AsyncAction.Success, + is AsyncAction.Confirming, + is AsyncAction.Failure, + AsyncAction.Uninitialized -> Unit + } + + if (state.showBiometricUnlockError) { + ErrorDialog( + content = state.biometricUnlockErrorMessage ?: "", + onSubmit = { state.eventSink(PinUnlockEvents.ClearBiometricError) } + ) + } + } +} + +@Composable +private fun PinUnlockPage( + state: PinUnlockState, + isInAppUnlock: Boolean, +) { + BoxWithConstraints { + val commonModifier = Modifier + .fillMaxSize() + .systemBarsPadding() + .imePadding() + .padding(all = 20.dp) + + val header = @Composable { + PinUnlockHeader( + state = state, + isInAppUnlock = isInAppUnlock, + modifier = Modifier.padding(top = 60.dp) + ) + } + val footer = @Composable { + PinUnlockFooter( + modifier = Modifier.padding(top = 24.dp), + showBiometricUnlock = state.showBiometricUnlock, + onUseBiometric = { + state.eventSink(PinUnlockEvents.OnUseBiometric) + }, + onForgotPin = { + state.eventSink(PinUnlockEvents.OnForgetPin) + }, + ) + } + val content = @Composable { constraints: BoxWithConstraintsScope -> + if (isInAppUnlock) { + val pinEntry = state.pinEntry.dataOrNull() + if (pinEntry != null) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + PinEntryTextField( + pinEntry = pinEntry, + isSecured = true, + onValueChange = { + state.eventSink(PinUnlockEvents.OnPinEntryChanged(it)) + }, + modifier = Modifier + .focusRequester(focusRequester) + .fillMaxWidth() + ) + } + } else { + PinKeypad( + onClick = { + state.eventSink(PinUnlockEvents.OnPinKeypadPressed(it)) + }, + maxWidth = constraints.maxWidth, + maxHeight = constraints.maxHeight, + horizontalAlignment = Alignment.CenterHorizontally, + ) + } + } + if (maxHeight < 600.dp) { + PinUnlockCompactView( + header = header, + footer = footer, + content = content, + modifier = commonModifier, + ) + } else { + PinUnlockExpandedView( + header = header, + footer = footer, + content = content, + modifier = commonModifier, + ) + } + } +} + +@Composable +private fun SignOutPrompt( + isCancellable: Boolean, + onSignOut: () -> Unit, + onDismiss: () -> Unit, +) { + if (isCancellable) { + ConfirmationDialog( + title = stringResource(id = R.string.screen_app_lock_signout_alert_title), + content = stringResource(id = R.string.screen_app_lock_signout_alert_message), + onSubmitClick = onSignOut, + onDismiss = onDismiss, + ) + } else { + ErrorDialog( + title = stringResource(id = R.string.screen_app_lock_signout_alert_title), + content = stringResource(id = R.string.screen_app_lock_signout_alert_message), + onSubmit = onSignOut, + ) + } +} + +@Composable +private fun PinUnlockCompactView( + header: @Composable () -> Unit, + footer: @Composable () -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxWithConstraintsScope.() -> Unit, +) { + Row(modifier = modifier) { + Column(Modifier.weight(1f)) { + header() + Spacer(modifier = Modifier.height(24.dp)) + footer() + } + BoxWithConstraints( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + contentAlignment = Alignment.Center, + ) { + content() + } + } +} + +@Composable +private fun PinUnlockExpandedView( + header: @Composable () -> Unit, + footer: @Composable () -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxWithConstraintsScope.() -> Unit, +) { + Column( + modifier = modifier, + ) { + header() + BoxWithConstraints( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(top = 40.dp), + ) { + content() + } + footer() + } +} + +@Composable +private fun PinDotsRow( + pinEntry: PinEntry, +) { + Row( + horizontalArrangement = spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + for (digit in pinEntry.digits) { + PinDot(isFilled = digit is PinDigit.Filled) + } + } +} + +@Composable +private fun PinDot( + isFilled: Boolean, +) { + val backgroundColor = if (isFilled) { + ElementTheme.colors.iconPrimary + } else { + ElementTheme.colors.bgSubtlePrimary + } + Box( + modifier = Modifier + .size(14.dp) + .background(backgroundColor, CircleShape) + ) +} + +@Composable +private fun PinUnlockHeader( + state: PinUnlockState, + isInAppUnlock: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (isInAppUnlock) { + BigIcon(style = BigIcon.Style.Default(CompoundIcons.LockSolid())) + } else { + Icon( + modifier = Modifier + .size(32.dp), + tint = ElementTheme.colors.iconPrimary, + imageVector = CompoundIcons.LockSolid(), + contentDescription = null, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(id = CommonStrings.common_enter_your_pin), + modifier = Modifier + .fillMaxWidth(), + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontHeadingMdBold, + color = ElementTheme.colors.textPrimary, + ) + Spacer(Modifier.height(8.dp)) + val remainingAttempts = state.remainingAttempts.dataOrNull() + val subtitle = if (remainingAttempts != null) { + if (state.showWrongPinTitle) { + pluralStringResource(id = R.plurals.screen_app_lock_subtitle_wrong_pin, count = remainingAttempts, remainingAttempts) + } else { + pluralStringResource(id = R.plurals.screen_app_lock_subtitle, count = remainingAttempts, remainingAttempts) + } + } else { + "" + } + val subtitleColor = if (state.showWrongPinTitle) { + ElementTheme.colors.textCriticalPrimary + } else { + ElementTheme.colors.textSecondary + } + Text( + text = subtitle, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodyMdRegular, + color = subtitleColor, + ) + if (!isInAppUnlock && state.pinEntry is AsyncData.Success) { + Spacer(Modifier.height(24.dp)) + PinDotsRow(state.pinEntry.data) + } + } +} + +@Composable +private fun PinUnlockFooter( + showBiometricUnlock: Boolean, + onUseBiometric: () -> Unit, + onForgotPin: () -> Unit, + modifier: Modifier = Modifier, +) { + Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) { + if (showBiometricUnlock) { + TextButton(text = stringResource(id = R.string.screen_app_lock_use_biometric_android), onClick = onUseBiometric) + } + TextButton(text = stringResource(id = R.string.screen_app_lock_forgot_pin), onClick = onForgotPin) + } +} + +@Composable +@PreviewsDayNight +internal fun PinUnlockViewInAppPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) { + ElementPreview { + PinUnlockView( + state = state, + isInAppUnlock = true, + ) + } +} + +@Composable +@PreviewsDayNight +internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) { + ElementPreview { + PinUnlockView( + state = state, + isInAppUnlock = false, + ) + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt new file mode 100644 index 0000000..6209c19 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.unlock.activity + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.lifecycleScope +import dev.zacsweers.metro.Inject +import io.element.android.compound.colors.SemanticColorsLightDark +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.lockscreen.api.LockScreenLockState +import io.element.android.features.lockscreen.api.LockScreenService +import io.element.android.features.lockscreen.impl.unlock.PinUnlockPresenter +import io.element.android.features.lockscreen.impl.unlock.PinUnlockView +import io.element.android.features.lockscreen.impl.unlock.di.PinUnlockBindings +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.designsystem.theme.ElementThemeApp +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import kotlinx.coroutines.launch + +class PinUnlockActivity : AppCompatActivity() { + internal companion object { + fun newIntent(context: Context): Intent { + return Intent(context, PinUnlockActivity::class.java) + } + } + + @Inject lateinit var presenter: PinUnlockPresenter + @Inject lateinit var lockScreenService: LockScreenService + @Inject lateinit var appPreferencesStore: AppPreferencesStore + @Inject lateinit var enterpriseService: EnterpriseService + @Inject lateinit var buildMeta: BuildMeta + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + bindings().inject(this) + setContent { + val colors by remember { + enterpriseService.semanticColorsFlow(sessionId = null) + }.collectAsState(SemanticColorsLightDark.default) + ElementThemeApp( + appPreferencesStore = appPreferencesStore, + compoundLight = colors.light, + compoundDark = colors.dark, + buildMeta = buildMeta, + ) { + val state = presenter.present() + PinUnlockView( + state = state, + isInAppUnlock = false, + ) + } + } + lifecycleScope.launch { + lockScreenService.lockState.collect { state -> + if (state == LockScreenLockState.Unlocked) { + finish() + } + } + } + val onBackPressedCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + moveTaskToBack(true) + } + } + onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/di/PinUnlockBindings.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/di/PinUnlockBindings.kt new file mode 100644 index 0000000..c8b65af --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/di/PinUnlockBindings.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.unlock.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.lockscreen.impl.unlock.activity.PinUnlockActivity + +@ContributesTo(AppScope::class) +interface PinUnlockBindings { + fun inject(activity: PinUnlockActivity) +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt new file mode 100644 index 0000000..2131853 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.unlock.keypad + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +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.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Backspace +import androidx.compose.material3.Text +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.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.coerceIn +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.times +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.toSp +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.libraries.ui.utils.time.digit +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +private val spaceBetweenPinKey = 16.dp +private val minSizePinKey = 16.dp +private val maxSizePinKey = 80.dp + +@Composable +fun PinKeypad( + onClick: (PinKeypadModel) -> Unit, + maxWidth: Dp, + maxHeight: Dp, + modifier: Modifier = Modifier, + verticalAlignment: Alignment.Vertical = Alignment.Top, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, +) { + val pinKeyMaxWidth = ((maxWidth - 2 * spaceBetweenPinKey) / 3).coerceIn(minSizePinKey, maxSizePinKey) + val pinKeyMaxHeight = ((maxHeight - 3 * spaceBetweenPinKey) / 4).coerceIn(minSizePinKey, maxSizePinKey) + val pinKeySize = if (pinKeyMaxWidth < pinKeyMaxHeight) pinKeyMaxWidth else pinKeyMaxHeight + + val horizontalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterHorizontally) + val verticalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterVertically) + Column( + modifier = modifier.onKeyEvent { event -> + if (event.type == KeyEventType.KeyUp) { + val digitChar = event.digit + if (digitChar != null) { + onClick(PinKeypadModel.Number(digitChar)) + true + } else if (event.key == Key.Backspace) { + onClick(PinKeypadModel.Back) + true + } else { + false + } + } else { + false + } + }, + verticalArrangement = verticalArrangement, + horizontalAlignment = horizontalAlignment, + ) { + PinKeypadRow( + pinKeySize = pinKeySize, + verticalAlignment = verticalAlignment, + horizontalArrangement = horizontalArrangement, + models = persistentListOf(PinKeypadModel.Number('1'), PinKeypadModel.Number('2'), PinKeypadModel.Number('3')), + onClick = onClick, + ) + PinKeypadRow( + pinKeySize = pinKeySize, + verticalAlignment = verticalAlignment, + horizontalArrangement = horizontalArrangement, + models = persistentListOf(PinKeypadModel.Number('4'), PinKeypadModel.Number('5'), PinKeypadModel.Number('6')), + onClick = onClick, + ) + PinKeypadRow( + pinKeySize = pinKeySize, + verticalAlignment = verticalAlignment, + horizontalArrangement = horizontalArrangement, + models = persistentListOf(PinKeypadModel.Number('7'), PinKeypadModel.Number('8'), PinKeypadModel.Number('9')), + onClick = onClick, + ) + PinKeypadRow( + pinKeySize = pinKeySize, + verticalAlignment = verticalAlignment, + horizontalArrangement = horizontalArrangement, + models = persistentListOf(PinKeypadModel.Empty, PinKeypadModel.Number('0'), PinKeypadModel.Back), + onClick = onClick, + ) + } +} + +@Composable +private fun PinKeypadRow( + models: ImmutableList, + onClick: (PinKeypadModel) -> Unit, + pinKeySize: Dp, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + verticalAlignment: Alignment.Vertical = Alignment.Top, +) { + Row( + horizontalArrangement = horizontalArrangement, + verticalAlignment = verticalAlignment, + modifier = Modifier.fillMaxWidth(), + ) { + val commonModifier = Modifier.size(pinKeySize) + for (model in models) { + when (model) { + is PinKeypadModel.Empty -> { + Spacer(modifier = commonModifier) + } + is PinKeypadModel.Back -> { + PinKeypadBackButton( + modifier = commonModifier, + onClick = { onClick(model) }, + ) + } + is PinKeypadModel.Number -> { + PinKeypadDigitButton( + size = pinKeySize, + modifier = commonModifier, + digit = model.number.toString(), + onClick = { onClick(model) }, + ) + } + } + } + } +} + +@Composable +private fun PinKeypadButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .clip(CircleShape) + .background(color = ElementTheme.colors.bgSubtlePrimary) + .clickable(onClick = onClick), + content = content + ) +} + +@Composable +private fun PinKeypadDigitButton( + digit: String, + size: Dp, + onClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + PinKeypadButton( + modifier = modifier, + onClick = { onClick(digit) } + ) { + val fontSize = size.toSp() / 2 + val originalFont = ElementTheme.typography.fontHeadingXlBold + val ratio = fontSize.value / originalFont.fontSize.value + val lineHeight = originalFont.lineHeight * ratio + Text( + text = digit, + color = ElementTheme.colors.textPrimary, + style = originalFont.copy(fontSize = fontSize, lineHeight = lineHeight, letterSpacing = 0.sp), + ) + } +} + +@Composable +private fun PinKeypadBackButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + PinKeypadButton( + modifier = modifier, + onClick = onClick, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Backspace, + contentDescription = stringResource(CommonStrings.a11y_delete), + ) + } +} + +@Composable +@PreviewsDayNight +internal fun PinKeypadPreview() { + ElementPreview { + BoxWithConstraints { + PinKeypad( + maxWidth = maxWidth, + maxHeight = maxHeight, + onClick = {} + ) + } + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadModel.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadModel.kt new file mode 100644 index 0000000..36c7839 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadModel.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.unlock.keypad + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface PinKeypadModel { + data object Empty : PinKeypadModel + data object Back : PinKeypadModel + data class Number(val number: Char) : PinKeypadModel +} diff --git a/features/lockscreen/impl/src/main/res/values-be/translations.xml b/features/lockscreen/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..7667034 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,39 @@ + + + "біяметрычная аўтэнтыфікацыя" + "біяметрычная разблакіроўка" + "Разблакіроўка з дапамогай біяметрыі" + "Забыліся PIN-код?" + "Змяніць PIN-код" + "Дазволіць біяметрычную разблакіроўку" + "Выдаліць PIN-код" + "Вы ўпэўнены, што хочаце выдаліць PIN-код?" + "Выдаліць PIN-код?" + "Дазволіць %1$s" + "Я хацеў бы выкарыстоўваць PIN-код" + "Эканомце час і выкарыстоўвайце %1$s для разблакіроўкі праграмы" + "Выберыце PIN-код" + "Пацвярджэнне PIN-кода" + "Заблакіруйце %1$s, каб павялічыць бяспеку вашых чатаў. + +Абярыце што-небудзь незабыўнае. Калі вы забудзецеся гэты PIN-код, вы выйдзеце з праграмы." + "Вы не можаце выбраць гэты PIN-код з меркаванняў бяспекі" + "Выберыце іншы PIN-код" + "Увядзіце адзін і той жа PIN двойчы" + "PIN-коды не супадаюць" + "Каб працягнуць, вам неабходна паўторна ўвайсці ў сістэму і стварыць новы PIN-код" + "Вы выходзіце з сістэмы" + + "У вас %1$d спроба разблакіроўкі" + "У вас %1$d спробы разблакіроўкі" + "У вас %1$d спроб разблакіроўкі" + + + "Няправільны PIN-код. У вас застаўся %1$d шанец" + "Няправільны PIN-код. У вас засталася %1$d шанцы" + "Няправільны PIN-код. У вас засталася %1$d шанцаў" + + "Выкарыстоўваць біяметрыю" + "Выкарыстоўваць PIN-код" + "Выхад…" + diff --git a/features/lockscreen/impl/src/main/res/values-bg/translations.xml b/features/lockscreen/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..7bd2895 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,35 @@ + + + "биометрично удостоверяване" + "биометрично отключване" + "Отключване с биометрия" + "Потвърдете биометричните данни" + "Забравихте PIN?" + "Промяна на PIN кода" + "Разрешаване на биометрично отключване" + "Премахване на PIN" + "Сигурни ли сте, че искате да премахнете PIN?" + "Премахване на PIN?" + "Разрешаване на %1$s" + "Предпочитам да използвам PIN" + "Избор на PIN" + "Потвърждаване на PIN" + "Заключете %1$s, за да добавите допълнителна сигурност към вашите чатове. + +Изберете нещо запомнящо се. Ако забравите този PIN, ще бъдете излезли от приложението." + "Не можете да изберете това за ваш PIN код от съображения за сигурност" + "Избор на различен PIN" + "Моля, въведете един и същ PIN два пъти" + "PINs не съвпадат" + + "Имате %1$d опит да отключите" + "Имате %1$d опита да отключите" + + + "Грешен PIN. Имате още %1$d шанс" + "Грешен PIN. Имате още %1$d шанса" + + "Използване на биометрия" + "Използване на PIN" + "Излизане…" + diff --git a/features/lockscreen/impl/src/main/res/values-cs/translations.xml b/features/lockscreen/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..fce1142 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,40 @@ + + + "Biometrické ověřování" + "biometrické odemknutí" + "Odemkněte pomocí biometrie" + "Potvrďte biometrické údaje" + "Zapomněli jste PIN?" + "Změnit PIN kód" + "Povolit biometrické odemykání" + "Odstranit PIN" + "Opravdu chcete odstranit PIN?" + "Odstranit PIN?" + "Povolit %1$s" + "Raději bych použil PIN" + "Ušetřete si čas a použijte pokaždé %1$s pro odemknutí aplikace" + "Zvolte PIN" + "Potvrďte PIN" + "Zamkněte %1$s pro zvýšení bezpečnosti vašich konverzací. + +Vyberte si něco zapamatovatelného. Pokud tento kód PIN zapomenete, budete z aplikace odhlášeni." + "Z bezpečnostních důvodů si toto nemůžete zvolit jako svůj PIN kód" + "Zvolte jiný PIN" + "Zadejte stejný PIN dvakrát" + "PIN kódy se neshodují." + "Abyste mohli pokračovat, budete se muset znovu přihlásit a vytvořit nový PIN" + "Jste odhlášeni" + + "Máte %1$d pokus pro odemknutí" + "Máte %1$d pokusy pro odemknutí" + "Máte %1$d pokusů pro odemknutí" + + + "Špatný PIN. Máte %1$d další pokus" + "Špatný PIN. Máte %1$d další pokusy" + "Špatný PIN. Máte %1$d dalších pokusů" + + "Použijte biometrické údaje" + "Použít PIN" + "Odhlašování…" + diff --git a/features/lockscreen/impl/src/main/res/values-cy/translations.xml b/features/lockscreen/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..d82bc28 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,46 @@ + + + "dilysu biometreg" + "datgloi biometreg" + "Datgloi gyda biometreg" + "Cadarnhau biometreg" + "Wedi anghofio\'ch PIN?" + "Newid cod PIN" + "Caniatáu datgloi biometreg" + "Dileu PIN" + "Ydych chi\'n siŵr eich bod am ddileu\'r PIN?" + "Tynnu\'r PIN?" + "Caniatáu %1$s" + "Byddai\'n well gen i ddefnyddio PIN" + "Arbedwch beth amser i chi\'ch hun a defnyddiwch %1$s i ddatgloi\'r ap bob tro" + "Dewiswch PIN" + "Cadarnhau eich PIN" + "Clowch %1$s i ychwanegu diogelwch ychwanegol i\'ch sgyrsiau. + +Dewiswch rywbeth cofiadwy. Os byddwch chi\'n anghofio\'r PIN hwn, byddwch chi\'n cael eich allgofnodi o\'r ap." + "Does dim mod dewis hwn fel eich cod PIN am resymau diogelwch" + "Dewiswch PIN gwahanol" + "Rhowch yr un PIN ddwywaith" + "Nid yw\'r PINau\'n cyfateb" + "Bydd angen i chi ail-fewngofnodi a chreu PIN newydd i barhau" + "Rydych chi\'n cael eich allgofnodi" + + "Does gennych %1$d ceisiadau i ddatgloi" + "Mae gennych %1$d cais i ddatgloi" + "Mae gennych %1$d gais i ddatgloi" + "Mae gennych %1$d chais i ddatgloi" + "Mae gennych %1$d chais i ddatgloi" + "Mae gennych %1$d cais i ddatgloi" + + + "PIN anghywir. Does gennych %1$d cais arall" + "PIN anghywir. Mae gennych %1$d cais arall" + "PIN anghywir. Mae gennych %1$d gais arall" + "PIN anghywir. Mae gennych %1$d chais arall" + "PIN anghywir. Mae gennych %1$d chais arall" + "PIN anghywir. Mae gennych %1$d cais arall" + + "Defnyddio biometreg" + "Defnyddio PIN" + "Yn allgofnodi…" + diff --git a/features/lockscreen/impl/src/main/res/values-da/translations.xml b/features/lockscreen/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..ffbd657 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,38 @@ + + + "biometrisk godkendelse" + "biometrisk oplåsning" + "Lås op med biometri" + "Bekræft biometri" + "Glemt PIN-kode?" + "Skift PIN-kode" + "Tillad biometrisk oplåsning" + "Fjern PIN-koden" + "Er du sikker på, at du vil fjerne PIN-koden?" + "Fjern PIN-koden?" + "Tillad %1$s" + "Jeg foretrækker at bruge PIN-kode" + "Spar dig selv lidt tid og brug den %1$s til at låse appen op hver gang" + "Vælg PIN-kode" + "Bekræft PIN-kode" + "Lås %1$s for at tilføje ekstra sikkerhed til dine samtaler. + +Vælg noget mindeværdigt. Hvis du glemmer denne pinkode, bliver du logget ud af appen." + "Du kan ikke vælge dette som din PIN-kode af sikkerhedsmæssige årsager" + "Vælg en anden PIN-kode" + "Indtast venligst den samme PIN-kode to gange" + "PIN-koderne stemmer ikke overens" + "Du vil være nødt til at logge ind igen og oprette en ny PIN-kode for at fortsætte." + "Du bliver logget ud" + + "Du har %1$d forsøg på at låse op" + "Du har %1$d forsøg på at låse op" + + + "Forkert PIN-kode. Du har %1$d chance mere" + "Forkert PIN-kode. Du har %1$d flere chancer" + + "Brug biometri" + "Brug PIN-kode" + "Logger ud…" + diff --git a/features/lockscreen/impl/src/main/res/values-de/translations.xml b/features/lockscreen/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..dd74818 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,38 @@ + + + "biometrische Authentifizierung" + "biometrisches Entsperren" + "Mit Biometrie entsperren" + "Biometrische Daten bestätigen" + "PIN vergessen?" + "PIN-Code ändern" + "Biometrisches Entsperren zulassen" + "Pin entfernen" + "Bist du sicher, dass du die PIN entfernen willst?" + "PIN entfernen?" + "%1$s zulassen" + "Ich möchte diese PIN verwenden." + "Spare dir etwas Zeit und benutze %1$s, um die App zu entsperren" + "PIN wählen" + "PIN bestätigen" + "Sperre %1$s um deine Chats zusätzlich abzusichern. + +Wähle eine einprägsame PIN. Wenn du sie vergisst, wirst du aus der App abgemeldet." + "Aus Sicherheitsgründen kann dieser PIN-Code nicht verwendet werden." + "Bitte eine andere PIN verwenden." + "Bitte gib die gleiche PIN wie zuvor ein." + "Die PINs stimmen nicht überein" + "Um fortzufahren, musst du dich erneut anmelden und eine neue PIN erstellen" + "Du wirst abgemeldet" + + "Du hast %1$d Versuch, um zu entsperren" + "Du hast %1$d Versuche, um zu entsperren" + + + "Falsche PIN. Du hast %1$d weiteren Versuch" + "Falsche PIN. Du hast %1$d weitere Versuche" + + "Biometrie verwenden" + "PIN verwenden" + "Abmelden…" + diff --git a/features/lockscreen/impl/src/main/res/values-el/translations.xml b/features/lockscreen/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..d36cbd4 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,38 @@ + + + "βιομετρική ταυτοποίηση" + "βιομετρικό ξεκλείδωμα" + "Ξεκλείδωμα με βιομετρικά στοιχεία" + "Επιβεβαίωσε τον βιομετρικό έλεγχο ταυτότητας" + "Ξέχασες το PIN;" + "Αλλαγή κωδικού PIN" + "Να επιτρέπεται το βιομετρικό ξεκλείδωμα" + "Αφαίρεση PIN" + "Θες σίγουρα να καταργήσεις το PIN;" + "Κατάργηση PIN;" + "Επέτρεψε %1$s" + "Θα προτιμούσα να χρησιμοποιήσω PIN" + "Εξοικονόμησε χρόνο και χρησιμοποίησε %1$s για να ξεκλειδώσεις την εφαρμογή κάθε φορά" + "Επέλεξε PIN" + "Επιβεβαίωση PIN" + "Κλειδώστε το %1$s για να προσθέσετε επιπλέον ασφάλεια στις συνομιλίες σας. + +Επιλέξτε κάτι που θα θυμάστε εύκολα. Εάν ξεχάσετε αυτό το PIN, θα αποσυνδεθείτε από την εφαρμογή." + "Δεν μπορείς να το επιλέξεις ως κωδικό PIN για λόγους ασφαλείας" + "Επέλεξε διαφορετικό PIN" + "Παρακαλώ εισήγαγε το ίδιο PIN δύο φορές" + "Τα PIN δεν ταιριάζουν" + "Θα χρειαστεί να συνδεθείς ξανά και να δημιουργήσεις ένα νέο PIN για να προχωρήσεις" + "Έχεις αποσυνδεθεί" + + "Έχετε %1$d προσπάθεια να ξεκλειδώσετε" + "Έχετε %1$d προσπάθειες να ξεκλειδώσετε" + + + "Λάθος PIN. Έχεις %1$d ακόμη ευκαιρία" + "Λάθος PIN. Έχεις %1$d ακόμη ευκαιρίες" + + "Χρήση βιομετρικών" + "Χρήση PIN" + "Αποσύνδεση…" + diff --git a/features/lockscreen/impl/src/main/res/values-es/translations.xml b/features/lockscreen/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..c0e34a3 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,38 @@ + + + "autenticación biométrica" + "desbloqueo biométrico" + "Desbloquear con biométrico" + "Confirmar datos biométricos" + "¿Olvidaste el PIN?" + "Cambiar código PIN" + "Permitir desbloqueo biométrico" + "Eliminar PIN" + "¿Estás seguro de que quieres eliminar el PIN?" + "¿Eliminar el PIN?" + "Permitir %1$s" + "Prefiero usar el PIN" + "Ahorra algo de tiempo y usa %1$s para desbloquear la aplicación cada vez" + "Elegir PIN" + "Confirmar PIN" + "Añade un bloqueo a %1$s para añadir seguridad adicional a tus chats. + +Elige algo que puedas recordar. Si olvidas este PIN, se cerrará la sesión de la aplicación." + "No puedes usar este código PIN por motivos de seguridad" + "Elige un PIN diferente" + "Por favor ingresa el mismo PIN dos veces" + "Los PINs no coinciden" + "Tendrás que volver a iniciar sesión y crear un nuevo PIN para continuar" + "Se está cerrando tu sesión" + + "Tienes %1$d intento de desbloqueo" + "Tienes %1$d intentos de desbloqueo" + + + "PIN incorrecto. Tienes %1$d oportunidad más" + "PIN incorrecto. Tienes %1$d oportunidades más" + + "Usar desbloqueo biométrico" + "Usar PIN" + "Cerrando sesión…" + diff --git a/features/lockscreen/impl/src/main/res/values-et/translations.xml b/features/lockscreen/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..4449479 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,38 @@ + + + "biomeetrilist autentimist" + "biomeetrilist lukustuse eemaldamist" + "Eemalda lukustus biomeetrilise tuvastuse abil" + "Kinnita biomeetriline tuvastus" + "Kas unustasid PIN-koodi?" + "Muuda PIN-koodi" + "Kasuta lukustuse eemaldamiseks biomeetrilist tuvastust" + "Eemalda PIN-kood" + "Kas sa oled kindel, et soovid eemaldada PIN-koodi?" + "Kas eemaldame PIN-koodi?" + "Kasuta %1$s" + "Pigem kasutan PIN-koodi" + "Säästa aega ja kasuta alati %1$s rakenduse lukustuse eemaldamiseks" + "Vali PIN-kood" + "Korda PIN-koodi" + "Lisamaks oma %1$s rakenduse vestlustele turvalisust ja privaatsust, lukusta oma nutiseade. + +Vali midagi, mis hästi meelde jääb. Kui unustad selle PIN-koodi, siis turvakaalutlustel logitakse sind rakendusest välja." + "Turvakaalutlustel sa ei saa sellist PIN-koodi kasutada" + "Kasuta mõnda teist PIN-koodi" + "Palun sisesta sama PIN-kood kaks korda" + "PIN-koodid ei klapi omavahel" + "Jätkamaks pead uuesti sisse logima ja looma uue PIN-koodi" + "Sa oled logimas välja" + + "Sul on lukustuse eemaldamiseks jäänud %1$d katse" + "Sul on lukustuse eemaldamiseks jäänud %1$d katset" + + + "Vale PIN-kood. Saad proovida veel %1$d korra" + "Vale PIN-kood. Saad proovida veel %1$d korda" + + "Kasuta biomeetriat" + "Kasuta PIN-koodi" + "Logime välja…" + diff --git a/features/lockscreen/impl/src/main/res/values-eu/translations.xml b/features/lockscreen/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..74c5e79 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,33 @@ + + + "autentifikazio biometrikoa" + "desblokeo biometrikoa" + "Desblokeatu biometria bidez" + "PINa ahaztu duzu?" + "Aldatu PIN kodea" + "Baimendu desblokeo biometrikoa" + "Kendu PIN kodea" + "Ziur PINa kendu nahi duzula?" + "PINa kendu?" + "Baimendu %1$s" + "Nahiago dut PINa erabili" + "Aukeratu PINa" + "Berretsi PINa" + "Segurtasun arrazoiak direla eta, ezin duzu hau aukeratu PIN kode gisa" + "Aukeratu PIN ezberdin bat" + "Sartu birritan PIN bera" + "PINak ez datoz bat" + "Saioa berriro hasi eta PIN berri bat sortu beharko duzu aurrera jarraitzeko" + "Saioa amaitzen ari zara" + + "Saiakera %1$d duzu desblokeatzeko" + "%1$d saiakera dituzu desblokeatzeko" + + + "PIN okerra. Aukera %1$d gehiago duzu" + "Pin okerra. %1$d aukera gehiago dituzu." + + "Erabili biometria" + "Erabili PINa" + "Saioa amaitzen…" + diff --git a/features/lockscreen/impl/src/main/res/values-fa/translations.xml b/features/lockscreen/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..56dc91e --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,38 @@ + + + "هویت‌سنجی زیستی" + "قفل‌گشایی زیست‌سنجی" + "قفل‌گشایی با زیست‌سنجی" + "تأیید زیست‌سنجی" + "فراموشی پین؟" + "تغییر کد پین" + "احازه به قفل گشایی زیست‌سنجی" + "برداشتن پین" + "مطمئنید که می‌خواهید پین را بردارید؟" + "برداشتن پین؟" + "اجازه به %1$s" + "ترجیح می‌دهم از پین استفاده کنم" + "زمانتان را ذخیره کرده و از %1$s برای قفل‌گشایی هربارهٔ کاره استفاده کنید" + "گزینش پین" + "تأیید پین" + "قفل کردن %1$s برای افزودن امنیت بیشتر به گفتگوهایتان. + +چیزی به یاد ماندنی انتخاب کنید. اگر این پین را فراموش کنید، از برنامه خارج خواهید شد." + "به دلیل امنیتی نمی‌توانید این پین را برگزینید" + "گزینشی پینی متفاوت" + "لطفاً یک پین را دو بار وارد کنید" + "پین‌ها مطابق نیستند" + "برای ادامه باید دوباره وارد شده و پینی جدید ایجاد کنید" + "دارید خارج می‌شوید" + + "شما %1$d تلاش برای باز کردن قفل دارید" + "شما %1$d تلاش برای باز کردن قفل دارید" + + + "پین اشتباه است. شما %1$d شانس دیگر دارید" + "پین اشتباه است. شما %1$d شانس دیگر دارید" + + "استفاده از زیست‌سنجی" + "استفاده از پین" + "خارج شدن…" + diff --git a/features/lockscreen/impl/src/main/res/values-fi/translations.xml b/features/lockscreen/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..02df752 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,38 @@ + + + "biometrinen tunnistus" + "biometrinen tunnistus" + "Avaa biometrisellä" + "Vahvista biometrinen tunniste" + "Unohtuiko PIN-koodi?" + "Vaihda PIN-koodi" + "Salli biometrinen tunnistus" + "Poista PIN-koodi" + "Haluatko varmasti poistaa PIN-koodin?" + "Poistetaanko PIN-koodi?" + "Salli %1$s" + "Käytän mieluummin PIN-koodia" + "Säästä aikaa ja ota käyttöön %1$s" + "Valitse PIN-koodi" + "Vahvista PIN-koodi" + "Lukitse %1$s -sovellus lisätäksesi turvaa keskusteluihisi. + +Valitse PIN-koodi, jonka muistat. Jos unohdat sen, joudut kirjautumaan ulos." + "Et voi valita tätä PIN-koodia turvallisuussyistä" + "Valitse toinen PIN-koodi" + "Anna sama PIN-koodi kahdesti" + "PIN-koodit eivät täsmää" + "Sinun on kirjauduttava sisään uudelleen ja luotava uusi PIN-koodi jatkaaksesi" + "Sinut kirjataan ulos" + + "Sinulla on %1$d yritys" + "Sinulla on %1$d yritystä" + + + "Väärä PIN-koodi. Sinulla on %1$d yritys jäljellä" + "Väärä PIN-koodi. Sinulla on %1$d yritystä jäljellä" + + "Käytä biometristä" + "Käytä PIN-koodia" + "Kirjaudutaan ulos…" + diff --git a/features/lockscreen/impl/src/main/res/values-fr/translations.xml b/features/lockscreen/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..8596647 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,38 @@ + + + "l’authentification biométrique" + "déverrouillage biométrique" + "Déverrouiller avec la biométrie" + "Confirmer la biométrie" + "Code PIN oublié ?" + "Modifier le code PIN" + "Autoriser le déverrouillage biométrique" + "Supprimer le code PIN" + "Êtes-vous certain de vouloir supprimer le code PIN ?" + "Supprimer le code PIN ?" + "Autoriser %1$s" + "Je préfère utiliser le code PIN" + "Gagnez du temps en utilisant %1$s pour déverrouiller l’application à chaque fois." + "Choisissez un code PIN" + "Confirmer le code PIN" + "Verrouillez %1$s pour ajouter une sécurité supplémentaire à vos discussions. + +Choisissez un code facile à retenir. Si vous oubliez le code PIN, vous serez déconnecté." + "Vous ne pouvez pas choisir ce code PIN pour des raisons de sécurité" + "Choisissez un code PIN différent" + "Veuillez saisir le même code PIN deux fois" + "Les codes PIN ne correspondent pas" + "Pour continuer, vous devrez vous connecter à nouveau et créer un nouveau code PIN." + "Vous êtes en train de vous déconnecter" + + "Il reste %1$d tentative pour déverrouiller" + "Il reste %1$d tentatives pour déverrouiller" + + + "Code PIN incorrect. Il reste %1$d tentative" + "Code PIN incorrect. Il reste %1$d tentatives" + + "Utiliser la biométrie" + "Utiliser le code PIN" + "Déconnexion…" + diff --git a/features/lockscreen/impl/src/main/res/values-hu/translations.xml b/features/lockscreen/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..3a65239 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,38 @@ + + + "biometrikus hitelesítés" + "biometrikus feloldás" + "Feloldás biometrikus adatokkal" + "Biometrikus megerősítés" + "Elfelejtette a PIN-kódot?" + "PIN-kód módosítása" + "Biometrikus feloldás engedélyezése" + "PIN-kód eltávolítása" + "Biztos, hogy eltávolítja a PIN-kódot?" + "PIN-kód eltávolítása?" + "A %1$s engedélyezése" + "Inkább PIN-kód használata" + "Spóroljon meg némi időt, és használja a %1$st az alkalmazás feloldásához" + "PIN-kód kiválasztása" + "PIN-kód megerősítése" + "Az %1$s zárolása a csevegései nagyobb biztonsága érdekében. + +Válasszon valami megjegyezhetőt. Ha elfelejti a PIN-kódot, akkor ki lesz jelentkeztetve az alkalmazásból." + "Ezt biztonsági okokból nem választhatja PIN-kódként" + "Válasszon egy másik PIN-kódot" + "Adja meg a PIN-kódját kétszer" + "A PIN-kódok nem egyeznek" + "A folytatáshoz újra be kell jelentkeznie, és létre kell hoznia egy új PIN-kódot" + "Kijelentkeztetésre kerül" + + "%1$d próbálkozása van a feloldáshoz" + "%1$d próbálkozása van a feloldáshoz" + + + "Hibás PIN-kód. Még %1$d próbálkozási lehetősége maradt." + "Hibás PIN-kód. Még %1$d próbálkozási lehetősége maradt." + + "Biometrikus adatok használata" + "PIN-kód használata" + "Kijelentkezés…" + diff --git a/features/lockscreen/impl/src/main/res/values-in/translations.xml b/features/lockscreen/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..0396f56 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,36 @@ + + + "autentikasi biometrik" + "pembukaan biometrik" + "Buka kunci dengan biometrik" + "Konfirmasi biometrik" + "Lupa PIN?" + "Ubah kode PIN" + "Perbolehkan pembukaan biometrik" + "Hapus PIN" + "Apakah Anda yakin ingin menghapus PIN?" + "Hapus PIN?" + "Perbolehkan %1$s" + "Saya lebih suka menggunakan PIN" + "Hemat waktu Anda dan gunakan %1$s untuk membuka kunci aplikasi setiap kalinya" + "Pilih PIN" + "Konfirmasi PIN" + "Kunci %1$s untuk menambahkan keamanan tambahan pada percakapan Anda. + +Pilih sesuatu yang mudah untuk diingat. Jika Anda lupa PIN ini, Anda akan dikeluarkan dari aplikasi." + "Anda tidak dapat memilih PIN ini demi keamanan" + "Pilih PIN yang lain" + "Silakan masukkan PIN yang sama dua kali" + "PIN tidak cocok" + "Anda harus masuk ulang dan membuat PIN baru untuk melanjutkan" + "Anda sedang dikeluarkan" + + "Anda memiliki %1$d percobaan lagi untuk membuka kunci" + + + "PIN salah. Anda memiliki %1$d percobaan lagi" + + "Gunakan biometrik" + "Gunakan PIN" + "Mengeluarkan dari akun…" + diff --git a/features/lockscreen/impl/src/main/res/values-it/translations.xml b/features/lockscreen/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..514d246 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,38 @@ + + + "autenticazione biometrica" + "sblocco con biometria" + "Sblocca con biometria" + "Conferma la biometria" + "PIN dimenticato?" + "Modifica il codice PIN" + "Consenti lo sblocco biometrico" + "Rimuovi PIN" + "Vuoi davvero rimuovere il PIN?" + "Rimuovere il PIN?" + "Consenti %1$s" + "Preferisco usare il PIN" + "Risparmia un po\' di tempo e usa %1$s per sbloccare l\'app ogni volta" + "Scegli il PIN" + "Conferma il PIN" + "Blocca %1$s per aggiungere una sicurezza extra alle tue conversazioni. + +Scegli un PIN facile da ricordare. Se lo dimentichi, verrai disconnesso dall’app" + "Non puoi scegliere questo codice PIN per motivi di sicurezza" + "Scegli un PIN diverso" + "Inserisci lo stesso PIN due volte" + "I PIN non corrispondono" + "Dovrai effettuare nuovamente l\'accesso e creare un nuovo PIN per procedere" + "Stai per essere disconnesso" + + "Hai %1$d tentativo di sblocco" + "Hai %1$d tentativi di sblocco" + + + "PIN sbagliato. Hai %1$d altro tentativo" + "PIN sbagliato. Hai altri %1$d tentativi" + + "Usa la biometria" + "Usa il PIN" + "Disconnessione in corso…" + diff --git a/features/lockscreen/impl/src/main/res/values-ka/translations.xml b/features/lockscreen/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..022d4ee --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,37 @@ + + + "ბიომეტრიული ავტორიზაცია" + "ბიომეტრიული განბლოკვა" + "განბლოკვა ბიომეტრიით" + "დაგავიწყდათ PIN?" + "PIN კოდის შეცვლა" + "ბიომეტრიული განბლოკვის დაშვება" + "პინ კოდის წაშლა" + "დარწმუნებული ხართ, რომ გსურთ PIN-ის წაშლა?" + "გსურთ PIN-ის წაშლა?" + "%1$s დაშვება" + "მირჩევნია PIN-ის გამოყენება" + "დაზოგეთ დრო და გამოიყენეთ %1$s აპლიკაციის განსაბლოკად." + "აირჩიეთ PIN" + "დაადასტურეთ PIN" + "თქვენი ჩატების დამატებითი უსაფრთხოებისათვის დაბლოკეთ %1$s. + +აირჩიეთ რაიმე ისეთი, რაც დაგამახსოვრდებათ. თუ დაგავიწყდებათ ეს PIN, აპლიკაციიდან გამოხვალთ." + "თქვენ არ შეგიძლიათ აირჩიოთ ეს PIN კოდი უსაფრთხოების მიზეზების გამო" + "აირჩიეთ სხვა PIN" + "გთხოვთ შეიყვანოთ იგივე PIN ორჯერ" + "PIN-ები არ ემთხვევა" + "გასაგრძელებლად საჭიროა ხელახლა შესვლა და ახალი PIN-ის შექმნა" + "თქვენ ახლა გადიხართ…" + + "თქვენ გაქვთ %1$d მცდელობა განსაბლოკად" + "თქვენ გაქვთ %1$d მცდელობა განსაბლოკად" + + + "არასწორი PIN. თქვენ %1$d შანსი დაგრჩათ" + "არასწორი PIN. თქვენ %1$d შანსი დაგრჩათ" + + "გამოიყენეთ ბიომეტრია" + "გამოიყენეთ PIN" + "გასვლა…" + diff --git a/features/lockscreen/impl/src/main/res/values-ko/translations.xml b/features/lockscreen/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..b959841 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,36 @@ + + + "생체 인식 인증" + "생체 인식 잠금 해제" + "생체 인증으로 잠금 해제" + "생체 인식 확인" + "PIN을 잊으셨나요?" + "PIN 코드 변경" + "생체 인식 잠금 해제 허용" + "PIN 제거" + "PIN을 제거하시겠습니까?" + "PIN을 제거하시겠습니까?" + "%1$s 허용" + "나는 PIN을 사용하고 싶습니다" + "시간을 절약하려면 %1$s 를 사용하여 앱을 매번 잠금 해제하세요." + "PIN을 선택하세요" + "PIN 확인" + "%1$s 를 잠그면 채팅에 추가 보안이 적용됩니다. + +기억하기 쉬운 것을 선택하세요. 이 PIN을 잊어버리면 앱에서 로그아웃됩니다." + "보안상의 이유로 이 코드를 PIN 코드로 선택할 수 없습니다." + "다른 PIN을 선택하세요" + "PIN을 두 번 입력하세요." + "PIN이 일치하지 않습니다" + "계속하려면 다시 로그인하고 새로운 PIN을 생성해야 합니다" + "로그아웃 중입니다" + + "당신은 %1$d 회 잠금 해제 시도를 가지고 있습니다" + + + "PIN이 잘못되었습니다. %1$d 번 남았습니다" + + "생체 인증 사용" + "PIN 사용" + "로그아웃 중…" + diff --git a/features/lockscreen/impl/src/main/res/values-lt/translations.xml b/features/lockscreen/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..b6b5eaa --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,4 @@ + + + "Atsijungiama…" + diff --git a/features/lockscreen/impl/src/main/res/values-nb/translations.xml b/features/lockscreen/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..89ca8c0 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,38 @@ + + + "biometrisk autentisering" + "biometrisk opplåsing" + "Lås opp med biometri" + "Bekreft biometri" + "Glemt PIN-koden?" + "Endre PIN-kode" + "Tillat biometrisk opplåsing" + "Fjern PIN-kode" + "Er du sikker på at du vil fjerne PIN-koden?" + "Fjerne PIN-kode?" + "Tillat %1$s" + "Jeg vil heller bruke PIN-kode" + "Spar deg selv litt tid og bruk%1$s for å låse opp appen hver gang" + "Velg PIN-kode" + "Bekreft PIN-kode" + "Lås %1$s for å legge til ekstra sikkerhet til chattene dine. + +Velg noe som du husker. Hvis du glemmer denne PIN-koden, blir du logget ut av appen." + "Du kan ikke velge dette som PIN-kode av sikkerhetsmessige årsaker" + "Velg en annen PIN-kode" + "Skriv inn samme PIN-kode to ganger" + "PIN-kodene samsvarer ikke" + "Du må logge inn på nytt og opprette en ny PIN-kode for å fortsette" + "Du blir logget av" + + "Du har %1$d forsøk på å låse opp" + "Du har %1$d forsøk på å låse opp" + + + "Feil PIN-kode. Du har %1$d forsøk igjen" + "Feil PIN-kode. Du har %1$d forsøk igjen" + + "Bruk biometri" + "Bruk PIN-kode" + "Logger ut…" + diff --git a/features/lockscreen/impl/src/main/res/values-nl/translations.xml b/features/lockscreen/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..419d6d5 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,37 @@ + + + "biometrische authenticatie" + "biometrische ontgrendeling" + "Ontgrendelen met biometrie" + "Pincode vergeten?" + "Pincode wijzigen" + "Biometrische ontgrendeling toestaan" + "Pincode verwijderen" + "Weet je zeker dat je de pincode wilt verwijderen?" + "Pincode verwijderen?" + "%1$s toestaan" + "Ik gebruik liever een pincode" + "Bespaar jezelf tijd en gebruik %1$s om de app elke keer te ontgrendelen" + "Kies je pincode" + "Bevestig pincode" + "Vergrendel %1$s om je chats extra te beveiligen. + +Kies iets dat je kunt onthouden. Als je deze pincode vergeet, word je uitgelogd bij de app." + "Vanwege veiligheidsredenen kun je dit niet als je pincode kiezen" + "Kies een andere pincode" + "Voer dezelfde pincode twee keer in" + "Pincodes komen niet overeen" + "Je moet opnieuw inloggen en een nieuwe pincode aanmaken om verder te gaan" + "Je wordt uitgelogd" + + "Je hebt %1$d poging om te ontgrendelen" + "Je hebt %1$d pogingen om te ontgrendelen" + + + "Verkeerde pincode. Je hebt nog %1$d kans" + "Verkeerde pincode. Je hebt nog %1$d kansen" + + "Biometrie gebruiken" + "Pincode gebruiken" + "Uitloggen…" + diff --git a/features/lockscreen/impl/src/main/res/values-pl/translations.xml b/features/lockscreen/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..5d61ecb --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,40 @@ + + + "uwierzytelnienie biometryczne" + "odblokowanie biometryczne" + "Odblokuj za pomocą biometrii" + "Potwierdź biometrię" + "Nie pamiętasz kodu PIN?" + "Zmień kod PIN" + "Zezwól na uwierzytelnienie biometryczne" + "Usuń PIN" + "Czy na pewno chcesz usunąć PIN?" + "Usunąć PIN?" + "Zezwól na %1$s" + "Wolę korzystać z kodu PIN" + "Zaoszczędź sobie trochę czasu i korzystaj z %1$s do odblokowywania aplikacji" + "Wybierz PIN" + "Potwierdź PIN" + "Zablokuj %1$s, aby zwiększyć bezpieczeństwo swoich czatów. + +Wybierz coś łatwego do zapamiętania. Jeśli zapomnisz ten PIN, zostaniesz wylogowany z aplikacji." + "Nie możesz wybrać tego PIN\'u ze względów bezpieczeństwa" + "Wybierz inny kod PIN" + "Wprowadź ten sam kod PIN dwa razy" + "PIN\'y nie pasują do siebie" + "Aby kontynuować, zaloguj się ponownie i utwórz nowy kod PIN" + "Trwa wylogowywanie" + + "Masz %1$d próbę, żeby odblokować" + "Masz %1$d próby, żeby odblokować" + "Masz %1$d prób, żeby odblokować" + + + "Błędny PIN. Pozostała %1$d próba" + "Błędny PIN. Pozostały %1$d próby" + "Błędny PIN. Pozostało %1$d prób" + + "Użyj biometrii" + "Użyj kodu PIN" + "Wylogowywanie…" + diff --git a/features/lockscreen/impl/src/main/res/values-pt-rBR/translations.xml b/features/lockscreen/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..f2f67dc --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,38 @@ + + + "autenticação biométrica" + "desbloqueio biométrico" + "Desbloquear com biometria" + "Confirmar biometria" + "Esqueceu o PIN?" + "Alterar código de PIN" + "Permitir desbloqueio biométrico" + "Remover PIN" + "Tem certeza de que quer remover o PIN?" + "Remover PIN?" + "Permitir %1$s" + "Prefiro usar o PIN" + "Poupe tempo e use %1$s para desbloquear o aplicativo todas as vezes" + "Escolher PIN" + "Confirmar PIN" + "Bloqueie o %1$s para adicionar uma segurança extra às suas conversas. + +Escolha algo memorável. Se você esquecer este PIN, você será desconectado do app." + "Você não pode escolher este PIN por razões de segurança" + "Escolha um PIN diferente" + "Por favor, digite o mesmo PIN duas vezes" + "Os PINs não correspondem" + "Você terá que entrar novamente e criar um PIN novo para continuar" + "Você está sendo desconectado" + + "Você tem %1$d tentativa de desbloqueio" + "Você tem %1$d tentativas de desbloqueio" + + + "PIN incorreto. Você tem mais %1$d chance" + "PIN incorreto. Você tem mais %1$d chances" + + "Usar biometria" + "Usar PIN" + "Saindo…" + diff --git a/features/lockscreen/impl/src/main/res/values-pt/translations.xml b/features/lockscreen/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..a6b2516 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,38 @@ + + + "autenticação biométrica" + "desbloqueio biométrico" + "Desbloquear com biometria" + "Confirmar com biometria" + "Esqueceste-te do PIN?" + "Altera o código PIN" + "Permitir o desbloqueio biométrico" + "Remover PIN" + "Tens a certeza que queres remover o PIN?" + "Remover o PIN?" + "Permitir %1$s" + "Prefiro usar o PIN" + "Poupa tempo e utiliza %1$s para desbloquear a aplicação" + "Escolher PIN" + "Confirmar PIN" + "Bloqueia a %1$s para dar mais segurança às tuas conversas. + +Escolhe algo memorável. Se te esqueceres deste PIN, a tua sessão será terminada." + "Não podes escolher este código PIN por razões de segurança" + "Escolhe um PIN diferente" + "Insere o mesmo PIN duas vezes" + "Os PINs não coincidem" + "Terás de voltar a iniciar sessão e criar um novo PIN para continuar" + "Estás a terminar a sessão" + + "Tens %1$d tentativa de desbloqueio" + "Tens %1$d tentativas de desbloqueio" + + + "PIN incorreto. Tens mais %1$d tentativa" + "PIN incorreto. Tens mais %1$d tentativas" + + "Utilizar biometria" + "Utilizar PIN" + "A terminar sessão…" + diff --git a/features/lockscreen/impl/src/main/res/values-ro/translations.xml b/features/lockscreen/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..d40bfba --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,40 @@ + + + "autentificare biometrică" + "deblocare biometrică" + "Deblocați cu biometrice" + "Confirmați datele biometrice" + "Ați uitat codul PIN?" + "Schimbați codul PIN" + "Permite deblocarea biometrică" + "Ștergeți codul PIN" + "Sunteți sigur că doriți să ștergeți codul PIN?" + "Ștergeți codul PIN?" + "Permiteți %1$s" + "Prefer să folosesc un cod PIN" + "Economisiți timp și utilizați %1$s pentru a debloca aplicația de fiecare dată." + "Alegeți codul PIN" + "Confirmare PIN" + "Blocați %1$s pentru a adăuga un plus de securitate la conversațiile dvs. + +Alegeți ceva memorabil. Dacă uitați acest PIN, veți fi deconectat din aplicație." + "Nu puteți alege acest cod PIN din motive de securitate" + "Alegeți un alt cod PIN" + "Vă rugăm să introduceți același cod PIN de două ori" + "Codurile PIN nu corespund" + "Va trebui să vă reconectați și să creați un cod PIN nou pentru a continua" + "Sunteți deconectat" + + "Aveți %1$d încercare de deblocare" + "Aveți %1$d încercări de deblocare" + "Aveți %1$d încercări de deblocare" + + + "PIN greșit. Mai aveți %1$d sansa" + "PIN greșit. Mai aveți %1$d sanse" + "PIN greșit. Mai aveți %1$d sanse" + + "Utilizați biometrice" + "Utilizați codul PIN" + "Deconectare în curs…" + diff --git a/features/lockscreen/impl/src/main/res/values-ru/translations.xml b/features/lockscreen/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..3879c46 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,40 @@ + + + "биометрическая идентификация" + "биометрическая разблокировка" + "Разблокировать с помощью биометрии" + "Подтвердить биометрические данные" + "Забыли PIN-код?" + "Изменить PIN-код" + "Разрешить разблокировку по биометрии" + "Удалить PIN-код" + "Вы действительно хотите удалить PIN-код?" + "Удалить PIN-код?" + "Разрешить %1$s" + "Я бы предпочел использовать PIN-код" + "Сэкономьте время и используйте %1$s для разблокировки приложения" + "Выберите PIN-код" + "Подтвердите PIN-код" + "Заблокируйте %1$s, чтобы повысить безопасность ваших чатов. + +Введите что-нибудь незабываемое. Если вы забудете этот PIN-код, вы выйдете из приложения." + "Из соображений безопасности вы не можешь выбрать это в качестве PIN-кода" + "Выберите другой PIN-код" + "Повторите PIN-код" + "PIN-коды не совпадают" + "Чтобы продолжить, вам необходимо повторно войти в систему и создать новый PIN-код" + "Выполняется выход из системы" + + "У вас осталась %1$d попытка на разблокировку" + "У вас остались %1$d попытки на разблокировку" + "У вас осталось %1$d попыток на разблокировку" + + + "Неверный PIN-код. У вас осталась %1$d попытка" + "Неверный PIN-код. У вас остались %1$d попытки" + "Неверный PIN-код. У вас осталось %1$d попыток" + + "Использовать биометрию" + "Использовать PIN-код" + "Выполняется выход…" + diff --git a/features/lockscreen/impl/src/main/res/values-sk/translations.xml b/features/lockscreen/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..0cfb2e8 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,40 @@ + + + "biometrické overenie" + "biometrické odomknutie" + "Odomknúť pomocou biometrie" + "Potvrdiť biometrické údaje" + "Zabudli ste PIN?" + "Zmeniť PIN kód" + "Povoliť biometrické odomknutie" + "Odstrániť PIN" + "Ste si istí, že chcete odstrániť PIN?" + "Odstrániť PIN?" + "Povoliť %1$s" + "Radšej použijem PIN" + "Ušetrite si čas a použite zakaždým %1$s na odomknutie aplikácie" + "Vyberte PIN" + "Potvrdiť PIN" + "Uzamknite %1$s, aby ste zvýšili bezpečnosť svojich konverzácií. + +Vyberte si niečo zapamätateľné. Ak tento kód PIN zabudnete, budete z aplikácie odhlásení." + "Z bezpečnostných dôvodov si nemôžete toto zvoliť ako svoj PIN kód." + "Vyberte iný PIN" + "Zadajte prosím ten istý PIN dvakrát" + "PIN kódy sa nezhodujú" + "Ak chcete pokračovať, musíte sa znovu prihlásiť a vytvoriť nový PIN kód." + "Prebieha odhlasovanie" + + "Máte %1$d pokus na odomknutie" + "Máte %1$d pokusy na odomknutie" + "Máte %1$d pokusov na odomknutie" + + + "Nesprávny PIN kód. Máte ešte %1$d pokus" + "Nesprávny PIN kód. Máte ešte %1$d pokusy" + "Nesprávny PIN kód. Máte ešte %1$d pokusov" + + "Použiť biometrické údaje" + "Použiť PIN" + "Prebieha odhlasovanie…" + diff --git a/features/lockscreen/impl/src/main/res/values-sv/translations.xml b/features/lockscreen/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..a559e4c --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,38 @@ + + + "biometrisk autentisering" + "biometrisk upplåsning" + "Lås upp med biometri" + "Bekräfta biometriskt" + "Glömt PIN-kod?" + "Byt PIN-kod" + "Tillåt biometrisk upplåsning" + "Ta bort PIN-kod" + "Är du säker på att du vill ta bort PIN-koden?" + "Ta bort PIN-koden?" + "Tillåt %1$s" + "Jag vill hellre använda PIN-kod" + "Bespara dig själv lite tid och använd %1$s för att låsa upp appen varje gång" + "Välj PIN-kod" + "Bekräfta PIN-kod" + "Lås %1$s för att lägga till extra säkerhet i dina chattar. + +Välj något minnesvärt. Om du glömmer den här PIN-koden loggas du ut från appen." + "Du kan inte välja detta som din PIN-kod av säkerhetsskäl" + "Välj en annan PIN-kod" + "Ange samma PIN-kod två gånger" + "PIN-koder matchar inte" + "Du måste logga in igen och skapa en ny PIN-kod för att fortsätta" + "Du blir utloggad" + + "Du har %1$d försök att låsa upp" + "Du har %1$d försök att låsa upp" + + + "Fel PIN-kod. Du har %1$d försök kvar" + "Fel PIN-kod. Du har %1$d försök kvar" + + "Använd biometri" + "Använd PIN-kod" + "Loggar ut …" + diff --git a/features/lockscreen/impl/src/main/res/values-tr/translations.xml b/features/lockscreen/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..5a9b94a --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,38 @@ + + + "biyometrik kimlik doğrulama" + "biyometrik kilit açma" + "Biyometrik ile kilit aç" + "Biyometrik doğrulama" + "PIN\'i unuttum" + "PIN kodunu değiştir" + "Biyometrik kilit açmaya izin ver" + "PIN kodunu kaldır" + "PIN\'i kaldırmak istediğinizden emin misiniz?" + "PIN\'i kaldır?" + "İzin ver %1$s" + "PIN kullanmayı tercih ederim" + "%1$s kullanarak oturum açarken kendinize zaman kazandırın" + "PIN Seç" + "PIN\'i onayla" + "Sohbetlerinize ekstra güvenlik eklemek için %1$s kilitleyin. + +Hatırlanabilir bir şey seçin. Bu PIN\'i unutursanız, uygulamadan çıkış yaparsınız." + "Güvenlik nedeniyle bunu PIN kodunuz olarak seçemezsiniz" + "Farklı bir PIN seçin" + "Lütfen aynı PIN\'i iki kez girin" + "PIN\'ler eşleşmiyor" + "Devam etmek için yeniden oturum açmanız ve yeni bir PIN oluşturmanız gerekir" + "Oturumunuz kapatılıyor" + + "Kilidi açmak için %1$d deneme hakkınız var" + "Kilidi açmak için %1$d deneme hakkınız var" + + + "Yanlış PIN. %1$d kere daha şansınız var" + "Yanlış PIN. %1$d kere daha şansınız var" + + "Biyometrik kullan" + "PIN kullan" + "Oturum kapatılıyor…" + diff --git a/features/lockscreen/impl/src/main/res/values-uk/translations.xml b/features/lockscreen/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..5c19889 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,40 @@ + + + "біометрична автентифікація" + "біометричне розблокування" + "Розблокувати за допомогою біометрії" + "Підтвердити біометрію" + "Забули PIN-код?" + "Змінити PIN-код" + "Дозволити біометричне розблокування" + "Вилучити PIN-код" + "Ви впевнені, що хочете видалити PIN-код?" + "Видалити PIN-код?" + "Дозволити %1$s" + "Мені краще використати PIN-код" + "Заощаджуйте час і використовуйте %1$s для розблокування застосунку щоразу" + "Виберіть PIN-код" + "Підтвердити PIN-код" + "Заблокуйте %1$s, щоб додати додаткову безпеку вашим чатам. + +Виберіть щось, що запам\'ятовується. Але якщо ви забудете PIN-код, ви вийдете з застосунку." + "Ви не можете вибрати його своїм PIN-кодом з міркувань безпеки" + "Виберіть інший PIN-код" + "Будь ласка, введіть один і той самий PIN-код двічі" + "PIN-коди не збігаються" + "Щоб продовжити, вам потрібно повторно ввійти та створити новий PIN-код" + "Ви виходите з системи" + + "Ви маєте %1$d спробу" + "Ви маєте %1$d спроби" + "Ви маєте %1$d спроб" + + + "Хибний PIN-код. Ви маєте ще %1$d шанс" + "Хибний PIN-код. Ви маєте ще %1$d шанси" + "Хибний PIN-код. Ви маєте ще %1$d шансів" + + "Використати біометрію" + "Використати PIN-код" + "Вихід…" + diff --git a/features/lockscreen/impl/src/main/res/values-ur/translations.xml b/features/lockscreen/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..85629f7 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,37 @@ + + + "زیست سنجی تصدیق" + "زیست سنجی فتحِ قفل" + "زیست سنجی کے ساتھ فتح قفل کریں" + "‏PIN بھول گئے؟" + "PIN رمز بدلیں" + "زیست سنجی فتحِ قفل کی اجازت دیں" + "PIN ہٹائیں" + "کیا آپ کو یقین ہے کہ آپ PIN ہٹانا چاہتے ہیں؟" + "PIN ہٹائیں؟" + "%1$s کی اجازت دیں" + "میں اس کے بجائے PIN استعمال کروں گا" + "اپنے آپ کو کچھ وقت بچائیں اور ہر بار اطلاقیے کو غیر مقفل کرنے کے لئے %1$s کا استعمال کریں۔" + "PIN چنیں" + "PIN کی تصدیق کریں" + "اپنی گفتگوہا میں اضافی سلامتی شامل کرنے کیلئے %1$s مقفل کریں۔ + +کوئی یادگار چیز چنیں۔ اگر آپ اس PIN کو بھول گئے، آپ طلاقیے سے خارج ہوجائیں گے۔" + "حفاظتی وجوہات کی بنا پر آپ اسے اپنے PIN رمز کے طور پر منتخب نہیں کر سکتے" + "ایک مختلف PIN چنیں" + "برائے مہربانی ایک ہی PIN دو بار درج کریں۔" + "PINs مماثل نہیں ہیں" + "آگے بڑھنے کیلئے آپکو دوبارہ داخل ہونے اور ایک نیا PIN بنانے کی ضرورت ہوگی۔" + "آپکو خارج کیا جا رہا ہے" + + "آپکے پاس %1$d غیر مقفل کرنے کی کوشش ہے" + "آپکے پاس %1$d غیر مقفل کرنے کی کوششیں ہیں" + + + "غلط PIN۔ آپ کے پاس %1$d مزید موقع ہے" + "غلط PIN۔ آپ کے پاس %1$d مزید موقعے ہیں" + + "زیست سنجی استعمال کریں" + "PIN استعمال کریں" + "خارج ہورہاہے…" + diff --git a/features/lockscreen/impl/src/main/res/values-uz/translations.xml b/features/lockscreen/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..8cd0a57 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,38 @@ + + + "biometrik autentifikatsiya" + "biometrik qulf ochish" + "Biometrik bilan qulfni oching" + "Biometrikni tasdiqlang" + "PIN kodni unutdingizmi?" + "PIN kodni o\'zgartirish" + "Biometrik qulfni ochishga ruxsat bering" + "PIN-kodni olib tashlang" + "Haqiqatan ham PIN kodni olib tashlamoqchimisiz?" + "PIN kod olib tashlansinmi?" + "Ruxsat berish %1$s" + "Men PIN kod ishlatishni maʼqul koʻraman" + "Oʻzingizga vaqt tejang va har safar ilovani ochish uchun %1$s dan foydalaning" + "PIN kodni tanlang" + "PIN kodni tasdiqlang" + "Qulflash %1$s suhbatlaringizga qoʻshimcha xavfsizlik qoʻshish uchun. + +Esda qoladigan biror narsani tanlang. Agar ushbu PIN kodni unutib qolsangiz, dasturdan chiqib ketasiz." + "Xavfsizlik sabablari bilan buni PIN kodingiz sifatida tanlay olmaysiz" + "Boshqa PIN kod tanlang" + "Iltimos, bir xil PIN kodni ikkita marta kiriting" + "PIN kodlar bir-biriga mos kelmadi" + "Davom etish uchun qayta kirishingiz va yangi PIN yaratishingiz kerak boʻladi." + "Siz tizimdan chiqmoqdasiz" + + "Sizda %1$d ta ochishga urinish mavjud" + "Sizda %1$d ta ochishga urinish mavjud" + + + "Notoʻgʻri PIN. Sizda yana %1$d ta imkoniyat bor" + "Notoʻgʻri PIN. Sizda yana %1$d ta imkoniyat bor" + + "Biometrikdan foydalaning" + "PIN koddan foydalaning" + "Chiqish…" + diff --git a/features/lockscreen/impl/src/main/res/values-zh-rTW/translations.xml b/features/lockscreen/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..799db8f --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,36 @@ + + + "生物辨識認證" + "生物辨識解鎖" + "使用生物辨識解鎖" + "確認生物辨識" + "忘記 PIN 碼?" + "變更 PIN 碼" + "允許生物辨識解鎖" + "移除 PIN 碼" + "您確定要移除 PIN 碼嗎?" + "移除 PIN 碼" + "允許 %1$s" + "我想使用 PIN 碼" + "為自己節省一些時間,使用 %1$s 來解鎖應用程式" + "選擇 PIN 碼" + "確認 PIN 碼" + "將 %1$s 上鎖,為你的聊天室添加一層防護。 + +請選擇好記憶的數字。如果忘記 PIN 碼,您會被登出。" + "基於安全性的考量,您選的 PIN 碼無法使用" + "選擇不一樣的 PIN 碼" + "請輸入相同的 PIN 碼兩次" + "PIN 碼不一樣" + "您需要重新登入並建立新的 PIN 碼才能繼續" + "您即將登出" + + "您有 %1$d 次解鎖的機會" + + + "PIN 碼錯誤。您還有 %1$d 次機會" + + "使用生物辨識" + "使用 PIN 碼" + "正在登出…" + diff --git a/features/lockscreen/impl/src/main/res/values-zh/translations.xml b/features/lockscreen/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..d3633e4 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,36 @@ + + + "生物识别认证" + "生物识别解锁" + "使用生物识别解锁" + "确认生物特征" + "忘记 PIN 码?" + "更改 PIN 码" + "允许生物识别解锁" + "移除 PIN 码" + "您确定要删除 PIN 码吗?" + "移除 PIN 码?" + "允许 %1$s" + "我宁愿使用 PIN 码" + "节省时间,用 %1$s 来解锁应用程序" + "选择 PIN 码" + "确认 PIN 码" + "锁定 %1$s 以为聊天增加安全性。 + +选择好记的 PIN 码。如果忘掉了这个 PIN 码,就不得不登出应用。" + "出于安全原因,您不能选择这个 PIN 码" + "选择不同的 PIN 码" + "请输入两次相同的 PIN 码" + "PIN 码不匹配" + "您需要重新登录并创建新的 PIN 才能继续" + "您正在登出" + + "还剩 %1$d 次解锁机会" + + + "PIN 码错误。还剩 %1$d 次机会" + + "使用生物识别" + "使用 PIN 码" + "正在登出…" + diff --git a/features/lockscreen/impl/src/main/res/values/localazy.xml b/features/lockscreen/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..8d6d298 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values/localazy.xml @@ -0,0 +1,38 @@ + + + "biometric authentication" + "biometric unlock" + "Unlock with biometric" + "Confirm biometric" + "Forgot PIN?" + "Change PIN code" + "Allow biometric unlock" + "Remove PIN" + "Are you sure you want to remove PIN?" + "Remove PIN?" + "Allow %1$s" + "I’d rather use PIN" + "Save yourself some time and use %1$s to unlock the app each time" + "Choose PIN" + "Confirm PIN" + "Lock %1$s to add extra security to your chats. + +Choose something memorable. If you forget this PIN, you will be logged out of the app." + "You cannot choose this as your PIN code for security reasons" + "Choose a different PIN" + "Please enter the same PIN twice" + "PINs don\'t match" + "You’ll need to re-login and create a new PIN to proceed" + "You are being signed out" + + "You have %1$d attempt to unlock" + "You have %1$d attempts to unlock" + + + "Wrong PIN. You have %1$d more chance" + "Wrong PIN. You have %1$d more chances" + + "Use biometric" + "Use PIN" + "Signing out…" + diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPointIntentTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPointIntentTest.kt new file mode 100644 index 0000000..e992237 --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPointIntentTest.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.features.lockscreen.impl.unlock.activity.PinUnlockActivity +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DefaultLockScreenEntryPointIntentTest { + @Test + fun `test pin unlock intent`() { + val entryPoint = DefaultLockScreenEntryPoint() + val result = entryPoint.pinUnlockIntent(InstrumentationRegistry.getInstrumentation().context) + assertThat(result.component?.className).isEqualTo(PinUnlockActivity::class.qualifiedName) + } +} diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPointTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPointTest.kt new file mode 100644 index 0000000..533a7be --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPointTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.features.lockscreen.api.LockScreenEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultLockScreenEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder Setup`() { + val entryPoint = DefaultLockScreenEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + LockScreenFlowNode( + buildContext = buildContext, + plugins = plugins, + ) + } + val callback = object : LockScreenEntryPoint.Callback { + override fun onSetupDone() = lambdaError() + } + val navTarget = LockScreenEntryPoint.Target.Setup + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + navTarget = navTarget, + callback = callback, + ) + assertThat(result).isInstanceOf(LockScreenFlowNode::class.java) + assertThat(result.plugins).contains(LockScreenFlowNode.Inputs(LockScreenFlowNode.NavTarget.Setup)) + assertThat(result.plugins).contains(callback) + } + + @Test + fun `test node builder Settings`() { + val entryPoint = DefaultLockScreenEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + LockScreenFlowNode( + buildContext = buildContext, + plugins = plugins, + ) + } + val callback = object : LockScreenEntryPoint.Callback { + override fun onSetupDone() = lambdaError() + } + val navTarget = LockScreenEntryPoint.Target.Settings + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + navTarget = navTarget, + callback = callback, + ) + assertThat(result).isInstanceOf(LockScreenFlowNode::class.java) + assertThat(result.plugins).contains(LockScreenFlowNode.Inputs(LockScreenFlowNode.NavTarget.Settings)) + assertThat(result.plugins).contains(callback) + } +} diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenServiceTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenServiceTest.kt new file mode 100644 index 0000000..9082f20 --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenServiceTest.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager +import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager +import io.element.android.features.lockscreen.impl.fixtures.aLockScreenConfig +import io.element.android.features.lockscreen.impl.pin.PinCodeManager +import io.element.android.features.lockscreen.impl.pin.createDefaultPinCodeManager +import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore +import io.element.android.features.lockscreen.impl.storage.LockScreenStore +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver +import io.element.android.services.appnavstate.api.AppForegroundStateService +import io.element.android.services.appnavstate.test.FakeAppForegroundStateService +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultLockScreenServiceTest { + @Test + fun `when the pin is not mandatory and no pin is configured isSetupRequired emits false`() = runTest { + val sut = createDefaultLockScreenService( + lockScreenConfig = aLockScreenConfig(isPinMandatory = false) + ) + sut.isSetupRequired().test { + assertThat(awaitItem()).isFalse() + } + } + + @Test + fun `when the pin is mandatory, isSetupRequired emits true`() = runTest { + val lockScreenStore = InMemoryLockScreenStore() + val sut = createDefaultLockScreenService( + lockScreenConfig = aLockScreenConfig(isPinMandatory = true), + lockScreenStore = lockScreenStore, + ) + sut.isSetupRequired().test { + assertThat(awaitItem()).isTrue() + // When the user configures the pin code, the setup is not required anymore + lockScreenStore.saveEncryptedPinCode("encryptedCode") + assertThat(awaitItem()).isFalse() + // Users deletes the pin code + lockScreenStore.deleteEncryptedPinCode() + assertThat(awaitItem()).isTrue() + } + } + + @Test + fun `when the last session is deleted, the pin code is removed`() = runTest { + val sessionObserver = FakeSessionObserver() + val lockScreenStore = InMemoryLockScreenStore() + val sut = createDefaultLockScreenService( + lockScreenConfig = aLockScreenConfig(isPinMandatory = true), + lockScreenStore = lockScreenStore, + sessionObserver = sessionObserver, + ) + sut.isPinSetup().test { + assertThat(awaitItem()).isFalse() + // When the user configure the pin code, the setup is not required anymore + lockScreenStore.saveEncryptedPinCode("encryptedCode") + assertThat(awaitItem()).isTrue() + sessionObserver.onSessionDeleted("userId", wasLastSession = false) + expectNoEvents() + sessionObserver.onSessionDeleted("userId", wasLastSession = true) + assertThat(awaitItem()).isFalse() + } + } +} + +private fun TestScope.createDefaultLockScreenService( + lockScreenConfig: LockScreenConfig = aLockScreenConfig(), + lockScreenStore: LockScreenStore = InMemoryLockScreenStore(), + pinCodeManager: PinCodeManager = createDefaultPinCodeManager( + lockScreenStore = lockScreenStore, + ), + sessionObserver: SessionObserver = FakeSessionObserver(), + appForegroundStateService: AppForegroundStateService = FakeAppForegroundStateService(), + biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(), +) = DefaultLockScreenService( + lockScreenConfig = lockScreenConfig, + lockScreenStore = lockScreenStore, + pinCodeManager = pinCodeManager, + coroutineScope = backgroundScope, + sessionObserver = sessionObserver, + appForegroundStateService = appForegroundStateService, + biometricAuthenticatorManager = biometricAuthenticatorManager, +) diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticator.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticator.kt new file mode 100644 index 0000000..073bdc7 --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticator.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.biometric + +class FakeBiometricAuthenticator( + override val isActive: Boolean = false, + private val authenticateLambda: suspend () -> BiometricAuthenticator.AuthenticationResult = { BiometricAuthenticator.AuthenticationResult.Success }, +) : BiometricAuthenticator { + override fun setup() = Unit + override suspend fun authenticate() = authenticateLambda() +} diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticatorManager.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticatorManager.kt new file mode 100644 index 0000000..9e9b892 --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticatorManager.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.biometric + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +class FakeBiometricAuthenticatorManager( + override var isDeviceSecured: Boolean = true, + override var hasAvailableAuthenticator: Boolean = false, + private val createBiometricAuthenticator: () -> BiometricAuthenticator = { FakeBiometricAuthenticator() }, +) : BiometricAuthenticatorManager { + override fun addCallback(callback: BiometricAuthenticator.Callback) { + // no-op + } + + override fun removeCallback(callback: BiometricAuthenticator.Callback) { + // no-op + } + + @Composable + override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator { + return remember { + createBiometricAuthenticator() + } + } + + @Composable + override fun rememberConfirmBiometricAuthenticator(): BiometricAuthenticator { + return remember { + createBiometricAuthenticator() + } + } +} diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/fixtures/LockScreenConfig.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/fixtures/LockScreenConfig.kt new file mode 100644 index 0000000..23fccb6 --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/fixtures/LockScreenConfig.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.fixtures + +import io.element.android.features.lockscreen.impl.LockScreenConfig +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +internal fun aLockScreenConfig( + isPinMandatory: Boolean = false, + forbiddenPinCodes: Set = emptySet(), + pinSize: Int = 4, + maxPinCodeAttemptsBeforeLogout: Int = 3, + gracePeriod: Duration = 3.seconds, + isStrongBiometricsEnabled: Boolean = true, + isWeakBiometricsEnabled: Boolean = true, +): LockScreenConfig { + return LockScreenConfig( + isPinMandatory = isPinMandatory, + forbiddenPinCodes = forbiddenPinCodes, + pinSize = pinSize, + maxPinCodeAttemptsBeforeLogout = maxPinCodeAttemptsBeforeLogout, + gracePeriod = gracePeriod, + isStrongBiometricsEnabled = isStrongBiometricsEnabled, + isWeakBiometricsEnabled = isWeakBiometricsEnabled, + ) +} diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/fixtures/PinCodeManager.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/fixtures/PinCodeManager.kt new file mode 100644 index 0000000..89bd760 --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/fixtures/PinCodeManager.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.fixtures + +import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManager +import io.element.android.features.lockscreen.impl.pin.PinCodeManager +import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore +import io.element.android.features.lockscreen.impl.storage.LockScreenStore +import io.element.android.libraries.cryptography.api.EncryptionDecryptionService +import io.element.android.libraries.cryptography.impl.AESEncryptionDecryptionService +import io.element.android.libraries.cryptography.test.SimpleSecretKeyRepository + +internal fun aPinCodeManager( + lockScreenStore: LockScreenStore = InMemoryLockScreenStore(), + secretKeyRepository: SimpleSecretKeyRepository = SimpleSecretKeyRepository(), + encryptionDecryptionService: EncryptionDecryptionService = AESEncryptionDecryptionService(), +): PinCodeManager { + return DefaultPinCodeManager(secretKeyRepository, encryptionDecryptionService, lockScreenStore) +} diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManagerTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManagerTest.kt new file mode 100644 index 0000000..dbc9c18 --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManagerTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.pin + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore +import io.element.android.features.lockscreen.impl.storage.LockScreenStore +import io.element.android.libraries.cryptography.api.EncryptionDecryptionService +import io.element.android.libraries.cryptography.api.SecretKeyRepository +import io.element.android.libraries.cryptography.impl.AESEncryptionDecryptionService +import io.element.android.libraries.cryptography.test.SimpleSecretKeyRepository +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultPinCodeManagerTest { + @Test + fun `given a pin code when create and delete assert no pin code left`() = runTest { + val pinCodeManager = createDefaultPinCodeManager() + pinCodeManager.hasPinCode().test { + assertThat(awaitItem()).isFalse() + pinCodeManager.createPinCode("1234") + assertThat(awaitItem()).isTrue() + pinCodeManager.deletePinCode() + assertThat(awaitItem()).isFalse() + } + } + + @Test + fun `given a pin code when create and verify with the same pin succeed`() = runTest { + val pinCodeManager = createDefaultPinCodeManager() + val pinCode = "1234" + pinCodeManager.createPinCode(pinCode) + assertThat(pinCodeManager.verifyPinCode(pinCode)).isTrue() + } + + @Test + fun `given a pin code when create and verify with a different pin fails`() = runTest { + val pinCodeManager = createDefaultPinCodeManager() + pinCodeManager.createPinCode("1234") + assertThat(pinCodeManager.verifyPinCode("1235")).isFalse() + } +} + +fun createDefaultPinCodeManager( + lockScreenStore: LockScreenStore = InMemoryLockScreenStore(), + secretKeyRepository: SecretKeyRepository = SimpleSecretKeyRepository(), + encryptionDecryptionService: EncryptionDecryptionService = AESEncryptionDecryptionService(), +) = DefaultPinCodeManager( + lockScreenStore = lockScreenStore, + secretKeyRepository = secretKeyRepository, + encryptionDecryptionService = encryptionDecryptionService, +) diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntryAssertions.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntryAssertions.kt new file mode 100644 index 0000000..9a863a0 --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntryAssertions.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.pin.model + +import com.google.common.truth.Truth.assertThat + +fun PinEntry.assertText(text: String) { + assertThat(toText()).isEqualTo(text) +} + +fun PinEntry.assertEmpty() { + val isEmpty = digits.all { it is PinDigit.Empty } + assertThat(isEmpty).isTrue() +} diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntryTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntryTest.kt new file mode 100644 index 0000000..8c2e5ba --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntryTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.pin.model + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class PinEntryTest { + @Test + fun `when using fillWith with empty string ensure pin is empty`() { + val pinEntry = PinEntry.createEmpty(4) + val newPinEntry = pinEntry.fillWith("") + assertThat(newPinEntry.isEmpty()).isTrue() + } + + @Test + fun `when using fillWith with bigger string than size ensure pin is complete`() { + val pinEntry = PinEntry.createEmpty(4) + val newPinEntry = pinEntry.fillWith("12345") + assertThat(newPinEntry.isComplete()).isTrue() + newPinEntry.assertText("1234") + } + + @Test + fun `when using fillWith with non digit string ensure pin is filtering`() { + val pinEntry = PinEntry.createEmpty(4) + val newPinEntry = pinEntry.fillWith("12aa") + newPinEntry.assertText("12") + } + + @Test + fun `when using clear ensure pin is empty`() { + val pinEntry = PinEntry.createEmpty(4) + val newPinEntry = pinEntry.clear() + assertThat(newPinEntry.isEmpty()).isTrue() + assertThat(newPinEntry.isComplete()).isFalse() + newPinEntry.assertText("") + } + + @Test + fun `when using deleteLast ensure pin correct`() { + val pinEntry = PinEntry.createEmpty(4) + val newPinEntry = pinEntry.fillWith("1234").deleteLast() + newPinEntry.assertText("123") + } + + @Test + fun `when using deleteLast with empty pin ensure pin is empty`() { + val pinEntry = PinEntry.createEmpty(4) + val newPinEntry = pinEntry.deleteLast() + assertThat(newPinEntry.isEmpty()).isTrue() + } + + @Test + fun `when using addDigit with complete pin ensure pin is complete`() { + val pinEntry = PinEntry.createEmpty(4) + val newPinEntry = pinEntry + .addDigit('1') + .addDigit('2') + .addDigit('3') + .addDigit('4') + .addDigit('5') + assertThat(newPinEntry.isComplete()).isTrue() + newPinEntry.assertText("1234") + } +} diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/storage/InMemoryLockScreenStore.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/storage/InMemoryLockScreenStore.kt new file mode 100644 index 0000000..61acf71 --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/storage/InMemoryLockScreenStore.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.pin.storage + +import io.element.android.features.lockscreen.impl.storage.LockScreenStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +private const val DEFAULT_REMAINING_ATTEMPTS = 3 + +class InMemoryLockScreenStore : LockScreenStore { + private val hasPinCode = MutableStateFlow(false) + private var pinCode: String? = null + set(value) { + field = value + hasPinCode.value = value != null + } + private var remainingAttempts: Int = DEFAULT_REMAINING_ATTEMPTS + private var isBiometricUnlockAllowed = MutableStateFlow(false) + + override suspend fun getRemainingPinCodeAttemptsNumber(): Int { + return remainingAttempts + } + + override suspend fun onWrongPin() { + remainingAttempts-- + } + + override suspend fun resetCounter() { + remainingAttempts = DEFAULT_REMAINING_ATTEMPTS + } + + override suspend fun getEncryptedCode(): String? { + return pinCode + } + + override suspend fun saveEncryptedPinCode(pinCode: String) { + this.pinCode = pinCode + } + + override suspend fun deleteEncryptedPinCode() { + pinCode = null + } + + override fun hasPinCode(): Flow { + return hasPinCode + } + + override fun isBiometricUnlockAllowed(): Flow { + return isBiometricUnlockAllowed + } + + override suspend fun setIsBiometricUnlockAllowed(isAllowed: Boolean) { + isBiometricUnlockAllowed.value = isAllowed + } +} diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt new file mode 100644 index 0000000..ef3e94f --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.settings + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.lockscreen.impl.LockScreenConfig +import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator +import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager +import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticator +import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager +import io.element.android.features.lockscreen.impl.fixtures.aLockScreenConfig +import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager +import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore +import io.element.android.features.lockscreen.impl.storage.LockScreenStore +import io.element.android.tests.testutils.awaitLastSequentialItem +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LockScreenSettingsPresenterTest { + @Test + fun `present - remove pin option is hidden when mandatory`() = runTest { + val presenter = createLockScreenSettingsPresenter(lockScreenConfig = aLockScreenConfig(isPinMandatory = true)) + presenter.test { + awaitItem().also { state -> + assertThat(state.showRemovePinOption).isFalse() + } + } + } + + @Test + fun `present - remove pin flow`() = runTest { + val presenter = createLockScreenSettingsPresenter() + presenter.test { + consumeItemsUntilPredicate { state -> + state.showRemovePinOption + }.last().also { state -> + state.eventSink(LockScreenSettingsEvents.OnRemovePin) + } + awaitLastSequentialItem().also { state -> + assertThat(state.showRemovePinConfirmation).isTrue() + state.eventSink(LockScreenSettingsEvents.CancelRemovePin) + } + awaitLastSequentialItem().also { state -> + assertThat(state.showRemovePinConfirmation).isFalse() + state.eventSink(LockScreenSettingsEvents.OnRemovePin) + } + awaitLastSequentialItem().also { state -> + assertThat(state.showRemovePinConfirmation).isTrue() + state.eventSink(LockScreenSettingsEvents.ConfirmRemovePin) + } + consumeItemsUntilPredicate { + it.showRemovePinOption.not() + }.last().also { state -> + assertThat(state.showRemovePinConfirmation).isFalse() + assertThat(state.showRemovePinOption).isFalse() + } + } + } + + @Test + fun `present - show toggle biometric if device is secured`() = runTest { + val fakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager( + isDeviceSecured = true, + ) + val presenter = createLockScreenSettingsPresenter( + biometricAuthenticatorManager = fakeBiometricAuthenticatorManager + ) + presenter.test { + skipItems(1) + assertThat(awaitItem().showToggleBiometric).isTrue() + } + } + + @Test + fun `present - enable biometric unlock success`() = runTest { + val fakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager( + createBiometricAuthenticator = { + FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Success }) + } + ) + val presenter = createLockScreenSettingsPresenter( + biometricAuthenticatorManager = fakeBiometricAuthenticatorManager + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed) + } + awaitItem().also { state -> + assertThat(state.isBiometricEnabled).isTrue() + } + } + } + + @Test + fun `present - enable biometric unlock failure`() = runTest { + val fakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager( + createBiometricAuthenticator = { + FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Failure() }) + } + ) + val presenter = createLockScreenSettingsPresenter( + biometricAuthenticatorManager = fakeBiometricAuthenticatorManager + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed) + } + } + } + + @Test + fun `present - disable biometric unlock`() = runTest { + val fakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager( + createBiometricAuthenticator = { + FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Failure() }) + } + ) + val lockScreenStore = InMemoryLockScreenStore() + val presenter = createLockScreenSettingsPresenter( + lockScreenStore = lockScreenStore, + biometricAuthenticatorManager = fakeBiometricAuthenticatorManager + ) + lockScreenStore.setIsBiometricUnlockAllowed(true) + + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.isBiometricEnabled).isTrue() + state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed) + } + awaitItem().also { state -> + assertThat(state.isBiometricEnabled).isFalse() + } + } + } + + private suspend fun TestScope.createLockScreenSettingsPresenter( + lockScreenConfig: LockScreenConfig = aLockScreenConfig(), + biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(), + lockScreenStore: LockScreenStore = InMemoryLockScreenStore(), + ): LockScreenSettingsPresenter { + val pinCodeManager = aPinCodeManager(lockScreenStore = lockScreenStore).apply { + createPinCode("1234") + } + return LockScreenSettingsPresenter( + lockScreenStore = lockScreenStore, + pinCodeManager = pinCodeManager, + coroutineScope = this, + lockScreenConfig = lockScreenConfig, + biometricAuthenticatorManager = biometricAuthenticatorManager, + ) + } +} diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenterTest.kt new file mode 100644 index 0000000..3f87c1d --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenterTest.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.setup.biometric + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator +import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager +import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticator +import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager +import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore +import io.element.android.features.lockscreen.impl.storage.LockScreenStore +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SetupBiometricPresenterTest { + @Test + fun `present - allow flow with biometric authentication success`() = runTest { + val lockScreenStore = InMemoryLockScreenStore() + val fakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(createBiometricAuthenticator = { + FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Success }) + }) + val presenter = createSetupBiometricPresenter(lockScreenStore, fakeBiometricAuthenticatorManager) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().also { state -> + assertThat(state.isBiometricSetupDone).isFalse() + state.eventSink(SetupBiometricEvents.AllowBiometric) + } + awaitItem().also { state -> + assertThat(state.isBiometricSetupDone).isTrue() + } + } + assertThat(lockScreenStore.isBiometricUnlockAllowed().first()).isTrue() + } + + @Test + fun `present - allow flow with biometric authentication failure`() = runTest { + val lockScreenStore = InMemoryLockScreenStore() + val fakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(createBiometricAuthenticator = { + FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Failure() }) + }) + val presenter = createSetupBiometricPresenter(lockScreenStore, fakeBiometricAuthenticatorManager) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().also { state -> + assertThat(state.isBiometricSetupDone).isFalse() + state.eventSink(SetupBiometricEvents.AllowBiometric) + } + } + assertThat(lockScreenStore.isBiometricUnlockAllowed().first()).isFalse() + } + + @Test + fun `present - skip flow`() = runTest { + val lockScreenStore = InMemoryLockScreenStore() + val presenter = createSetupBiometricPresenter(lockScreenStore) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().also { state -> + assertThat(state.isBiometricSetupDone).isFalse() + state.eventSink(SetupBiometricEvents.UsePin) + } + awaitItem().also { state -> + assertThat(state.isBiometricSetupDone).isTrue() + } + } + assertThat(lockScreenStore.isBiometricUnlockAllowed().first()).isFalse() + } + + private fun createSetupBiometricPresenter( + lockScreenStore: LockScreenStore = InMemoryLockScreenStore(), + biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(), + ): SetupBiometricPresenter { + return SetupBiometricPresenter( + lockScreenStore = lockScreenStore, + biometricAuthenticatorManager = biometricAuthenticatorManager + ) + } +} diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenterTest.kt new file mode 100644 index 0000000..6a1d32e --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenterTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.setup.pin + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.lockscreen.impl.LockScreenConfig +import io.element.android.features.lockscreen.impl.fixtures.aLockScreenConfig +import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager +import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback +import io.element.android.features.lockscreen.impl.pin.PinCodeManager +import io.element.android.features.lockscreen.impl.pin.model.assertEmpty +import io.element.android.features.lockscreen.impl.pin.model.assertText +import io.element.android.features.lockscreen.impl.setup.pin.validation.PinValidator +import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.tests.testutils.awaitLastSequentialItem +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SetupPinPresenterTest { + private val forbiddenPin = "1234" + private val halfCompletePin = "12" + private val completePin = "1235" + private val mismatchedPin = "1236" + + @Test + fun `present - complete flow`() = runTest { + val pinCodeCreated = CompletableDeferred() + val callback = object : DefaultPinCodeManagerCallback() { + override fun onPinCodeCreated() { + pinCodeCreated.complete(Unit) + } + } + val presenter = createSetupPinPresenter(callback) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().also { state -> + state.choosePinEntry.assertEmpty() + state.confirmPinEntry.assertEmpty() + assertThat(state.setupPinFailure).isNull() + assertThat(state.isConfirmationStep).isFalse() + state.onPinEntryChanged(halfCompletePin) + } + awaitItem().also { state -> + state.choosePinEntry.assertText(halfCompletePin) + state.confirmPinEntry.assertEmpty() + assertThat(state.setupPinFailure).isNull() + assertThat(state.isConfirmationStep).isFalse() + state.onPinEntryChanged(forbiddenPin) + } + awaitLastSequentialItem().also { state -> + state.choosePinEntry.assertText(forbiddenPin) + assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.ForbiddenPin) + state.eventSink(SetupPinEvents.ClearFailure) + } + awaitLastSequentialItem().also { state -> + state.choosePinEntry.assertEmpty() + assertThat(state.setupPinFailure).isNull() + state.onPinEntryChanged(completePin) + } + consumeItemsUntilPredicate { + it.isConfirmationStep + }.last().also { state -> + state.choosePinEntry.assertText(completePin) + state.confirmPinEntry.assertEmpty() + assertThat(state.isConfirmationStep).isTrue() + state.onPinEntryChanged(mismatchedPin) + } + awaitLastSequentialItem().also { state -> + state.choosePinEntry.assertText(completePin) + state.confirmPinEntry.assertText(mismatchedPin) + assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.PinsDoNotMatch) + state.eventSink(SetupPinEvents.ClearFailure) + } + awaitLastSequentialItem().also { state -> + state.choosePinEntry.assertEmpty() + state.confirmPinEntry.assertEmpty() + assertThat(state.isConfirmationStep).isFalse() + assertThat(state.setupPinFailure).isNull() + state.onPinEntryChanged(completePin) + } + consumeItemsUntilPredicate { + it.isConfirmationStep + }.last().also { state -> + state.choosePinEntry.assertText(completePin) + state.confirmPinEntry.assertEmpty() + assertThat(state.isConfirmationStep).isTrue() + state.onPinEntryChanged(completePin) + } + awaitItem().also { state -> + state.choosePinEntry.assertText(completePin) + state.confirmPinEntry.assertText(completePin) + } + pinCodeCreated.await() + } + } + + private fun SetupPinState.onPinEntryChanged(pinEntry: String) { + eventSink(SetupPinEvents.OnPinEntryChanged(pinEntry, isConfirmationStep)) + } + + private fun createSetupPinPresenter( + callback: PinCodeManager.Callback, + lockScreenConfig: LockScreenConfig = aLockScreenConfig( + forbiddenPinCodes = setOf(forbiddenPin) + ), + ): SetupPinPresenter { + val pinCodeManager = aPinCodeManager() + pinCodeManager.addCallback(callback) + return SetupPinPresenter( + lockScreenConfig = lockScreenConfig, + pinValidator = PinValidator(lockScreenConfig), + buildMeta = aBuildMeta(), + pinCodeManager = pinCodeManager + ) + } +} diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt new file mode 100644 index 0000000..f5bfb11 --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.unlock + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager +import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager +import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager +import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback +import io.element.android.features.lockscreen.impl.pin.PinCodeManager +import io.element.android.features.lockscreen.impl.pin.model.PinEntry +import io.element.android.features.lockscreen.impl.pin.model.assertText +import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel +import io.element.android.features.logout.test.FakeLogoutUseCase +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PinUnlockPresenterTest { + private val halfCompletePin = "12" + private val completePin = "1235" + + @Test + fun `present - success verify flow`() = runTest { + val presenter = createPinUnlockPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().also { state -> + assertThat(state.pinEntry).isInstanceOf(AsyncData.Uninitialized::class.java) + assertThat(state.showWrongPinTitle).isFalse() + assertThat(state.showSignOutPrompt).isFalse() + assertThat(state.isUnlocked).isFalse() + assertThat(state.signOutAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(state.remainingAttempts).isInstanceOf(AsyncData.Uninitialized::class.java) + } + awaitItem().also { state -> + assertThat(state.pinEntry).isInstanceOf(AsyncData.Success::class.java) + assertThat(state.remainingAttempts).isInstanceOf(AsyncData.Success::class.java) + state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('1'))) + state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('2'))) + } + skipItems(1) + awaitItem().also { state -> + state.pinEntry.assertText(halfCompletePin) + state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3'))) + state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Back)) + state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Empty)) + state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3'))) + state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('5'))) + } + skipItems(4) + awaitItem().also { state -> + state.pinEntry.assertText(completePin) + assertThat(state.isUnlocked).isTrue() + } + } + } + + @Test + fun `present - failure verify flow`() = runTest { + val presenter = createPinUnlockPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem().also { state -> + assertThat(state.pinEntry).isInstanceOf(AsyncData.Success::class.java) + assertThat(state.remainingAttempts).isInstanceOf(AsyncData.Success::class.java) + } + val numberOfAttempts = initialState.remainingAttempts.dataOrNull() ?: 0 + repeat(numberOfAttempts) { + initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('1'))) + initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('2'))) + initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3'))) + initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('4'))) + } + skipItems(4 * numberOfAttempts + 2) + awaitItem().also { state -> + assertThat(state.remainingAttempts.dataOrNull()).isEqualTo(0) + assertThat(state.showSignOutPrompt).isTrue() + assertThat(state.isSignOutPromptCancellable).isFalse() + } + } + } + + @Test + fun `present - forgot pin flow`() = runTest { + val signOutLambda = lambdaRecorder {} + val signOut = FakeLogoutUseCase(signOutLambda) + val presenter = createPinUnlockPresenter(logoutUseCase = signOut) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.pinEntry).isInstanceOf(AsyncData.Success::class.java) + assertThat(state.remainingAttempts).isInstanceOf(AsyncData.Success::class.java) + state.eventSink(PinUnlockEvents.OnForgetPin) + } + awaitItem().also { state -> + assertThat(state.showSignOutPrompt).isTrue() + assertThat(state.isSignOutPromptCancellable).isTrue() + state.eventSink(PinUnlockEvents.ClearSignOutPrompt) + } + awaitItem().also { state -> + assertThat(state.showSignOutPrompt).isFalse() + state.eventSink(PinUnlockEvents.OnForgetPin) + } + awaitItem().also { state -> + assertThat(state.showSignOutPrompt).isTrue() + state.eventSink(PinUnlockEvents.SignOut) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.signOutAction).isInstanceOf(AsyncAction.Success::class.java) + } + assert(signOutLambda).isCalledOnce() + } + } + + private fun AsyncData.assertText(text: String) { + dataOrNull()?.assertText(text) + } + + private suspend fun TestScope.createPinUnlockPresenter( + biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(), + callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(), + logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = { "" }), + ): PinUnlockPresenter { + val pinCodeManager = aPinCodeManager().apply { + addCallback(callback) + createPinCode(completePin) + } + return PinUnlockPresenter( + pinCodeManager = pinCodeManager, + biometricAuthenticatorManager = biometricAuthenticatorManager, + logoutUseCase = logoutUseCase, + coroutineScope = this, + pinUnlockHelper = PinUnlockHelper(biometricAuthenticatorManager, pinCodeManager), + ) + } +} diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateTest.kt new file mode 100644 index 0000000..34244b3 --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.unlock + +import androidx.biometric.BiometricPrompt +import com.google.common.truth.Truth.assertThat +import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator +import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockError +import io.element.android.libraries.architecture.AsyncData +import org.junit.Test + +class PinUnlockStateTest { + @Test + fun `isSignOutPromptCancellable should have expected values`() { + assertThat(aPinUnlockState(remainingAttempts = AsyncData.Uninitialized).isSignOutPromptCancellable).isTrue() + assertThat(aPinUnlockState(remainingAttempts = AsyncData.Success(1)).isSignOutPromptCancellable).isTrue() + assertThat(aPinUnlockState(remainingAttempts = AsyncData.Success(0)).isSignOutPromptCancellable).isFalse() + } + + @Test + fun `biometricUnlockErrorMessage and showBiometricUnlockError should have expected values`() { + listOf( + null, + BiometricAuthenticator.AuthenticationResult.Failure(), + BiometricAuthenticator.AuthenticationResult.Success, + ).forEach { biometricUnlockResult -> + aPinUnlockState( + biometricUnlockResult = biometricUnlockResult, + ).let { + assertThat(it.biometricUnlockErrorMessage).isNull() + assertThat(it.showBiometricUnlockError).isFalse() + } + } + listOf( + BiometricPrompt.ERROR_HW_UNAVAILABLE, + BiometricPrompt.ERROR_UNABLE_TO_PROCESS, + BiometricPrompt.ERROR_TIMEOUT, + BiometricPrompt.ERROR_NO_SPACE, + BiometricPrompt.ERROR_CANCELED, + BiometricPrompt.ERROR_VENDOR, + BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_NO_BIOMETRICS, + BiometricPrompt.ERROR_HW_NOT_PRESENT, + BiometricPrompt.ERROR_NEGATIVE_BUTTON, + BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL, + BiometricPrompt.ERROR_SECURITY_UPDATE_REQUIRED, + ).forEach { code -> + aPinUnlockState( + biometricUnlockResult = BiometricAuthenticator.AuthenticationResult.Failure( + error = BiometricUnlockError(code, "Error message") + ), + ).let { + assertThat(it.biometricUnlockErrorMessage).isNull() + assertThat(it.showBiometricUnlockError).isFalse() + } + } + listOf( + BiometricPrompt.ERROR_LOCKOUT, + BiometricPrompt.ERROR_LOCKOUT_PERMANENT, + ).forEach { code -> + aPinUnlockState( + biometricUnlockResult = BiometricAuthenticator.AuthenticationResult.Failure( + error = BiometricUnlockError(code, "Error message") + ), + ).let { + assertThat(it.biometricUnlockErrorMessage).isEqualTo("Error message") + assertThat(it.showBiometricUnlockError).isTrue() + } + } + } +} diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt new file mode 100644 index 0000000..1ecb79b --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.impl.unlock.keypad + +import android.view.KeyEvent +import androidx.activity.ComponentActivity +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isRoot +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.requestFocus +import androidx.compose.ui.unit.dp +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PinKeypadTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on a number emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setPinKeyPad(onClick = eventsRecorder) + rule.onNode(hasText("1")).performClick() + eventsRecorder.assertSingle(PinKeypadModel.Number('1')) + } + + @Test + fun `clicking on the delete previous character button emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setPinKeyPad(onClick = eventsRecorder) + rule.onNode(hasContentDescription(rule.activity.getString(CommonStrings.a11y_delete))).performClick() + eventsRecorder.assertSingle(PinKeypadModel.Back) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun `typing using the hardware keyboard emits the expected events`() { + val eventsRecorder = EventsRecorder() + rule.setPinKeyPad(onClick = eventsRecorder) + rule.onNodeWithText("1").requestFocus() + rule.onAllNodes(isRoot())[0].performKeyInput { + val keys = listOf( + Key.A, + Key.NumPad1, + Key.NumPad2, + Key.NumPad3, + Key.NumPad4, + Key.NumPad5, + Key.NumPad6, + Key.NumPad7, + Key.NumPad8, + Key.NumPad9, + Key.NumPad0, + Key(KeyEvent.KEYCODE_1), + Key(KeyEvent.KEYCODE_2), + Key(KeyEvent.KEYCODE_3), + Key(KeyEvent.KEYCODE_4), + Key(KeyEvent.KEYCODE_5), + Key(KeyEvent.KEYCODE_6), + Key(KeyEvent.KEYCODE_7), + Key(KeyEvent.KEYCODE_8), + Key(KeyEvent.KEYCODE_9), + Key(KeyEvent.KEYCODE_0), + Key.Backspace, + ) + for (key in keys) { + pressKey(key) + } + } + eventsRecorder.assertList( + listOf( + // Note that the first key is not a number, but a letter so it's ignored as input + // Then we have the numpad keys + PinKeypadModel.Number('1'), + PinKeypadModel.Number('2'), + PinKeypadModel.Number('3'), + PinKeypadModel.Number('4'), + PinKeypadModel.Number('5'), + PinKeypadModel.Number('6'), + PinKeypadModel.Number('7'), + PinKeypadModel.Number('8'), + PinKeypadModel.Number('9'), + PinKeypadModel.Number('0'), + // And the normal keys from the number row in the keyboard + PinKeypadModel.Number('1'), + PinKeypadModel.Number('2'), + PinKeypadModel.Number('3'), + PinKeypadModel.Number('4'), + PinKeypadModel.Number('5'), + PinKeypadModel.Number('6'), + PinKeypadModel.Number('7'), + PinKeypadModel.Number('8'), + PinKeypadModel.Number('9'), + PinKeypadModel.Number('0'), + PinKeypadModel.Back, + ) + ) + } + + private fun AndroidComposeTestRule.setPinKeyPad( + onClick: (PinKeypadModel) -> Unit = EnsureNeverCalledWithParam(), + ) { + setContent { + PinKeypad( + onClick = onClick, + maxWidth = 1000.dp, + maxHeight = 1000.dp, + ) + } + } +} diff --git a/features/lockscreen/test/build.gradle.kts b/features/lockscreen/test/build.gradle.kts new file mode 100644 index 0000000..5c314db --- /dev/null +++ b/features/lockscreen/test/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.lockscreen.test" +} + +dependencies { + api(projects.features.lockscreen.api) + implementation(libs.coroutines.core) + implementation(projects.libraries.architecture) + implementation(projects.tests.testutils) +} diff --git a/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeLockScreenEntryPoint.kt b/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeLockScreenEntryPoint.kt new file mode 100644 index 0000000..1bfcbaf --- /dev/null +++ b/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeLockScreenEntryPoint.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.test + +import android.content.Context +import android.content.Intent +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.lockscreen.api.LockScreenEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeLockScreenEntryPoint : LockScreenEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + navTarget: LockScreenEntryPoint.Target, + callback: LockScreenEntryPoint.Callback, + ): Node = lambdaError() + + override fun pinUnlockIntent(context: Context): Intent = lambdaError() +} diff --git a/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeLockScreenService.kt b/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeLockScreenService.kt new file mode 100644 index 0000000..a75b3b1 --- /dev/null +++ b/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeLockScreenService.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.lockscreen.test + +import io.element.android.features.lockscreen.api.LockScreenLockState +import io.element.android.features.lockscreen.api.LockScreenService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map + +class FakeLockScreenService : LockScreenService { + private var isPinSetup = MutableStateFlow(false) + private val _lockState: MutableStateFlow = MutableStateFlow(LockScreenLockState.Locked) + override val lockState: StateFlow = _lockState + + override fun isSetupRequired(): Flow { + return isPinSetup.map { !it } + } + + fun setIsPinSetup(isPinSetup: Boolean) { + this.isPinSetup.value = isPinSetup + } + + override fun isPinSetup(): Flow { + return isPinSetup + } + + fun setLockState(lockState: LockScreenLockState) { + _lockState.value = lockState + } +} diff --git a/features/login/api/build.gradle.kts b/features/login/api/build.gradle.kts new file mode 100644 index 0000000..2a4e3ec --- /dev/null +++ b/features/login/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") + id("kotlin-parcelize") +} + +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 0000000..830a63b --- /dev/null +++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.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 LoginEntryPoint : FeatureEntryPoint { + data class Params( + val accountProvider: String?, + val loginHint: String?, + ) + + interface Callback : Plugin { + fun navigateToBugReport() + fun onDone() + } + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + callback: Callback, + ): Node +} diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginIntentResolver.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginIntentResolver.kt new file mode 100644 index 0000000..79cfc35 --- /dev/null +++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginIntentResolver.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.api + +interface LoginIntentResolver { + fun parse(uriString: String): LoginParams? +} diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginParams.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginParams.kt new file mode 100644 index 0000000..2a83a06 --- /dev/null +++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginParams.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.api + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Parameters to start the login flow, when the application is opened + * from a mobile.element.io link. + */ +@Parcelize +data class LoginParams( + val accountProvider: String, + val loginHint: String? +) : Parcelable diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/accesscontrol/AccountProviderAccessControl.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/accesscontrol/AccountProviderAccessControl.kt new file mode 100644 index 0000000..fc804c1 --- /dev/null +++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/accesscontrol/AccountProviderAccessControl.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.api.accesscontrol + +interface AccountProviderAccessControl { + suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String): Boolean +} diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts new file mode 100644 index 0000000..071408b --- /dev/null +++ b/features/login/impl/build.gradle.kts @@ -0,0 +1,63 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "io.element.android.features.login.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.appconfig) + implementation(projects.features.enterprise.api) + implementation(projects.features.rageshake.api) + implementation(projects.libraries.core) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.featureflag.api) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.testtags) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.permissions.api) + implementation(projects.libraries.sessionStorage.api) + implementation(projects.libraries.qrcode) + implementation(projects.libraries.oidc.api) + implementation(projects.libraries.uiUtils) + implementation(projects.libraries.wellknown.api) + implementation(libs.androidx.browser) + implementation(libs.androidx.webkit) + implementation(libs.serialization.json) + api(projects.features.login.api) + + testCommonDependencies(libs, true) + testImplementation(projects.features.login.test) + testImplementation(projects.features.enterprise.test) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.oidc.test) + testImplementation(projects.libraries.permissions.test) + testImplementation(projects.libraries.sessionStorage.test) + testImplementation(projects.libraries.wellknown.test) +} diff --git a/features/login/impl/src/main/AndroidManifest.xml b/features/login/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..453cf05 --- /dev/null +++ b/features/login/impl/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + 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 0000000..1f0fe44 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPoint.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.login.api.LoginEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultLoginEntryPoint : LoginEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: LoginEntryPoint.Params, + callback: LoginEntryPoint.Callback, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf( + LoginFlowNode.Params( + accountProvider = params.accountProvider, + loginHint = params.loginHint, + ), + callback, + ) + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginIntentResolver.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginIntentResolver.kt new file mode 100644 index 0000000..26f0f17 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginIntentResolver.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl + +import androidx.core.net.toUri +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.login.api.LoginIntentResolver +import io.element.android.features.login.api.LoginParams + +@ContributesBinding(AppScope::class) +class DefaultLoginIntentResolver : LoginIntentResolver { + override fun parse(uriString: String): LoginParams? { + val uri = uriString.toUri() + if (uri.host != "mobile.element.io") return null + if (uri.path.orEmpty().startsWith("/element").not()) return null + val accountProvider = uri.getQueryParameter("account_provider") ?: return null + val loginHint = uri.getQueryParameter("login_hint") + return LoginParams( + accountProvider = accountProvider, + loginHint = loginHint, + ) + } +} 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 0000000..a19bb12 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl + +import android.app.Activity +import android.os.Parcelable +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +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 com.bumble.appyx.navmodel.backstack.operation.singleTop +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.login.api.LoginEntryPoint +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode +import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode +import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderNode +import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode +import io.element.android.features.login.impl.screens.createaccount.CreateAccountNode +import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode +import io.element.android.features.login.impl.screens.onboarding.OnBoardingNode +import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode +import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.matrix.api.auth.OidcDetails +import io.element.android.libraries.oidc.api.OidcAction +import io.element.android.libraries.oidc.api.OidcActionFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize + +@ContributesNode(AppScope::class) +@AssistedInject +class LoginFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val accountProviderDataSource: AccountProviderDataSource, + private val oidcActionFlow: OidcActionFlow, + @AppCoroutineScope + private val appCoroutineScope: CoroutineScope, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.OnBoarding, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + data class Params( + val accountProvider: String?, + val loginHint: String?, + ) : NodeInputs + + private val callback: LoginEntryPoint.Callback = callback() + private var activity: Activity? = null + private var darkTheme: Boolean = false + + private var externalAppStarted = false + + override fun onBuilt() { + super.onBuilt() + lifecycle.subscribe( + onResume = { + if (externalAppStarted) { + externalAppStarted = false + // Workaround to detect that the Custom Chrome Tab has been closed + // If there is no coming OidcAction (that would end this Node), + // consider that the user has cancelled the login + // by pressing back or by closing the Custom Chrome Tab. + lifecycleScope.launch { + delay(5000) + oidcActionFlow.post(OidcAction.GoBack(toUnblock = true)) + } + } + } + ) + } + + sealed interface NavTarget : Parcelable { + @Parcelize + data object OnBoarding : NavTarget + + @Parcelize + data object QrCode : NavTarget + + @Parcelize + data class ConfirmAccountProvider( + val isAccountCreation: Boolean, + ) : NavTarget + + @Parcelize + data object ChooseAccountProvider : NavTarget + + @Parcelize + data object ChangeAccountProvider : NavTarget + + @Parcelize + data object SearchAccountProvider : NavTarget + + @Parcelize + data object LoginPassword : NavTarget + + @Parcelize + data class CreateAccount(val url: String) : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.OnBoarding -> { + val callback = object : OnBoardingNode.Callback { + override fun navigateToSignUpFlow() { + backstack.push( + NavTarget.ConfirmAccountProvider(isAccountCreation = true) + ) + } + + override fun navigateToSignInFlow(mustChooseAccountProvider: Boolean) { + backstack.push( + if (mustChooseAccountProvider) { + NavTarget.ChooseAccountProvider + } else { + NavTarget.ConfirmAccountProvider(isAccountCreation = false) + } + ) + } + + override fun navigateToQrCode() { + backstack.push(NavTarget.QrCode) + } + + override fun navigateToBugReport() { + callback.navigateToBugReport() + } + + override fun navigateToOidc(oidcDetails: OidcDetails) { + navigateToMas(oidcDetails) + } + + override fun navigateToCreateAccount(url: String) { + backstack.push(NavTarget.CreateAccount(url)) + } + + override fun navigateToLoginPassword() { + backstack.push(NavTarget.LoginPassword) + } + + override fun onDone() { + callback.onDone() + } + } + val params = inputs() + val inputs = OnBoardingNode.Params( + accountProvider = params.accountProvider, + loginHint = params.loginHint, + ) + createNode(buildContext, listOf(callback, inputs)) + } + NavTarget.ChooseAccountProvider -> { + val callback = object : ChooseAccountProviderNode.Callback { + override fun navigateToOidc(oidcDetails: OidcDetails) { + navigateToMas(oidcDetails) + } + + override fun navigateToCreateAccount(url: String) { + backstack.push(NavTarget.CreateAccount(url)) + } + + override fun navigateToLoginPassword() { + backstack.push(NavTarget.LoginPassword) + } + } + createNode(buildContext, listOf(callback)) + } + NavTarget.QrCode -> { + createNode(buildContext) + } + is NavTarget.ConfirmAccountProvider -> { + val inputs = ConfirmAccountProviderNode.Inputs( + isAccountCreation = navTarget.isAccountCreation, + ) + val callback = object : ConfirmAccountProviderNode.Callback { + override fun navigateToOidc(oidcDetails: OidcDetails) { + navigateToMas(oidcDetails) + } + + override fun navigateToCreateAccount(url: String) { + backstack.push(NavTarget.CreateAccount(url)) + } + + override fun navigateToLoginPassword() { + backstack.push(NavTarget.LoginPassword) + } + + override fun navigateToChangeAccountProvider() { + backstack.push(NavTarget.ChangeAccountProvider) + } + } + createNode(buildContext, plugins = listOf(inputs, callback)) + } + NavTarget.ChangeAccountProvider -> { + val callback = object : ChangeAccountProviderNode.Callback { + override fun onDone() { + // Go back to the Account Provider screen + val confirmAccountProvider = backstack.elements.value.firstOrNull { + it.key.navTarget is NavTarget.ConfirmAccountProvider + }?.key?.navTarget ?: NavTarget.ConfirmAccountProvider(isAccountCreation = false) + backstack.singleTop(confirmAccountProvider) + } + + override fun navigateToSearchAccountProvider() { + backstack.push(NavTarget.SearchAccountProvider) + } + } + + createNode(buildContext, plugins = listOf(callback)) + } + NavTarget.SearchAccountProvider -> { + val callback = object : SearchAccountProviderNode.Callback { + override fun onDone() { + // Go back to the Account Provider screen + val confirmAccountProvider = backstack.elements.value.firstOrNull { + it.key.navTarget is NavTarget.ConfirmAccountProvider + }?.key?.navTarget ?: NavTarget.ConfirmAccountProvider(isAccountCreation = false) + backstack.singleTop(confirmAccountProvider) + } + } + + createNode(buildContext, plugins = listOf(callback)) + } + NavTarget.LoginPassword -> { + createNode(buildContext) + } + is NavTarget.CreateAccount -> { + val inputs = CreateAccountNode.Inputs( + url = navTarget.url, + ) + createNode(buildContext, listOf(inputs)) + } + } + } + + private fun navigateToMas(oidcDetails: OidcDetails) { + activity?.let { + externalAppStarted = true + it.openUrlInChromeCustomTab(null, darkTheme, oidcDetails.url) + } + } + + @Composable + override fun View(modifier: Modifier) { + activity = requireNotNull(LocalActivity.current) + darkTheme = !ElementTheme.isLightTheme + DisposableEffect(Unit) { + onDispose { + activity = null + appCoroutineScope.launch { + accountProviderDataSource.reset() + } + } + } + BackstackView() + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt new file mode 100644 index 0000000..db56047 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.accesscontrol + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl +import io.element.android.features.login.impl.changeserver.AccountProviderAccessException +import io.element.android.libraries.core.uri.ensureProtocol +import io.element.android.libraries.wellknown.api.WellknownRetriever + +@ContributesBinding(AppScope::class) +class DefaultAccountProviderAccessControl( + private val enterpriseService: EnterpriseService, + private val wellknownRetriever: WellknownRetriever, +) : AccountProviderAccessControl { + override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try { + assertIsAllowedToConnectToAccountProvider( + title = accountProviderUrl, + accountProviderUrl = accountProviderUrl, + ) + true + } catch (_: AccountProviderAccessException) { + false + } + + @Throws(AccountProviderAccessException::class) + suspend fun assertIsAllowedToConnectToAccountProvider( + title: String, + accountProviderUrl: String, + ) { + if (enterpriseService.isEnterpriseBuild.not()) { + // Ensure that Element Pro is not required for this account provider + val wellKnown = wellknownRetriever.getElementWellKnown( + baseUrl = accountProviderUrl.ensureProtocol(), + ).dataOrNull() + if (wellKnown?.enforceElementPro == true) { + throw AccountProviderAccessException.NeedElementProException( + unauthorisedAccountProviderTitle = title, + applicationId = ELEMENT_PRO_APPLICATION_ID, + ) + } + } + if (enterpriseService.isAllowedToConnectToHomeserver(accountProviderUrl).not()) { + throw AccountProviderAccessException.UnauthorizedAccountProviderException( + unauthorisedAccountProviderTitle = title, + authorisedAccountProviderTitles = enterpriseService.defaultHomeserverList(), + ) + } + } + + companion object { + const val ELEMENT_PRO_APPLICATION_ID = "io.element.enterprise" + } +} 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 0000000..7ca467d --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProvider.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.accountprovider + +data class AccountProvider( + val url: String, + val title: String = url.removePrefix("https://").removePrefix("http://"), + val subtitle: String? = null, + val isPublic: Boolean = false, + val isMatrixOrg: 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 0000000..931959d --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSource.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.accountprovider + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.element.android.appconfig.AuthenticationConfig +import io.element.android.features.enterprise.api.EnterpriseService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +@SingleIn(AppScope::class) +@Inject +class AccountProviderDataSource( + enterpriseService: EnterpriseService, +) { + private val defaultAccountProvider = createAccountProvider( + url = enterpriseService.defaultHomeserverList() + .firstOrNull { it != EnterpriseService.ANY_ACCOUNT_PROVIDER } + ?: AuthenticationConfig.MATRIX_ORG_URL + ) + + private val accountProvider: MutableStateFlow = MutableStateFlow(defaultAccountProvider) + + val flow: StateFlow = accountProvider.asStateFlow() + + suspend fun reset() { + accountProvider.emit(defaultAccountProvider) + } + + suspend fun setUrl(url: String) { + setAccountProvider(createAccountProvider(url)) + } + + suspend fun setAccountProvider(data: AccountProvider) { + accountProvider.emit(data) + } + + private fun createAccountProvider(url: String): AccountProvider { + return AccountProvider( + url = url, + subtitle = null, + isPublic = url == AuthenticationConfig.MATRIX_ORG_URL, + isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt new file mode 100644 index 0000000..fd5c7b8 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderOtherView.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Text + +/** + * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817 + */ +@Composable +fun AccountProviderOtherView( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() } + ) { + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 44.dp) + .padding(vertical = 4.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RoundedIconAtom( + size = RoundedIconAtomSize.Medium, + imageVector = CompoundIcons.Search(), + tint = ElementTheme.colors.iconPrimary, + ) + Text( + modifier = Modifier + .padding(start = 16.dp) + .weight(1f), + text = stringResource(R.string.screen_change_account_provider_other), + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun AccountProviderOtherViewPreview() = ElementPreview { + AccountProviderOtherView( + onClick = { }, + ) +} 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 0000000..7459392 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.accountprovider + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.appconfig.AuthenticationConfig + +open class AccountProviderProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anAccountProvider(), + anAccountProvider().copy(subtitle = null), + anAccountProvider().copy(subtitle = null, title = "invalid"), + anAccountProvider().copy(subtitle = null, title = "Other", isPublic = false, isMatrixOrg = false), + // Add other state here + ) +} + +fun anAccountProvider( + url: String = AuthenticationConfig.MATRIX_ORG_URL, + subtitle: String? = "Matrix.org is an open network for secure, decentralized communication.", + isPublic: Boolean = true, + isMatrixOrg: Boolean = true, +) = AccountProvider( + url = url, + subtitle = subtitle, + isPublic = isPublic, + isMatrixOrg = isMatrixOrg, +) 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 0000000..5130bf1 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text + +/** + * https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817 + */ +@Composable +fun AccountProviderView( + item: AccountProvider, + onClick: () -> Unit, + modifier: Modifier = Modifier, + selected: Boolean = false, +) { + Column( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() } + ) { + HorizontalDivider() + 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 = CompoundIcons.Host(), + tint = ElementTheme.colors.iconPrimary, + ) + } + Text( + modifier = Modifier + .padding(start = 16.dp) + .weight(1f), + text = item.title, + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + ) + if (item.isPublic) { + Icon( + modifier = Modifier + .padding(start = 10.dp) + .size(16.dp), + imageVector = CompoundIcons.Public(), + contentDescription = null, + tint = ElementTheme.colors.iconSecondary, + ) + } + if (selected) { + Icon( + modifier = Modifier + .padding(start = 10.dp), + imageVector = CompoundIcons.Check(), + contentDescription = null, + tint = ElementTheme.colors.iconAccentPrimary, + ) + } + } + 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 = ElementTheme.colors.textSecondary, + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun AccountProviderViewPreview(@PreviewParameter(AccountProviderProvider::class) item: AccountProvider) = ElementPreview { + AccountProviderView( + item = item, + onClick = { } + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/AccountProviderAccessException.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/AccountProviderAccessException.kt new file mode 100644 index 0000000..88ec3bf --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/AccountProviderAccessException.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.changeserver + +sealed class AccountProviderAccessException : Exception() { + data class NeedElementProException( + val unauthorisedAccountProviderTitle: String, + val applicationId: String, + ) : AccountProviderAccessException() + + data class UnauthorizedAccountProviderException( + val unauthorisedAccountProviderTitle: String, + val authorisedAccountProviderTitles: List, + ) : AccountProviderAccessException() +} 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 0000000..27e6128 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerEvents.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 + data 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 0000000..4a4fb3c --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenter.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Inject +import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl +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.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Inject +class ChangeServerPresenter( + private val authenticationService: MatrixAuthenticationService, + private val accountProviderDataSource: AccountProviderDataSource, + private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl, +) : Presenter { + @Composable + override fun present(): ChangeServerState { + val localCoroutineScope = rememberCoroutineScope() + + val changeServerAction: MutableState> = remember { + mutableStateOf(AsyncData.Uninitialized) + } + + fun handleEvent(event: ChangeServerEvents) { + when (event) { + is ChangeServerEvents.ChangeServer -> localCoroutineScope.changeServer(event.accountProvider, changeServerAction) + ChangeServerEvents.ClearError -> changeServerAction.value = AsyncData.Uninitialized + } + } + + return ChangeServerState( + changeServerAction = changeServerAction.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.changeServer( + data: AccountProvider, + changeServerAction: MutableState>, + ) = launch { + suspend { + defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider( + title = data.title, + accountProviderUrl = data.url, + ) + val details = authenticationService.setHomeserver(data.url).getOrThrow() + if (!details.isSupported) { + throw ChangeServerError.UnsupportedServer + } + // Homeserver is valid, remember user choice + accountProviderDataSource.setAccountProvider(data) + }.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 0000000..eea3ad7 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerState.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.changeserver + +import io.element.android.libraries.architecture.AsyncData + +data class ChangeServerState( + val changeServerAction: AsyncData, + 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 0000000..1a94bb5 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerStateProvider.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.changeserver + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.login.impl.error.ChangeServerError +import io.element.android.libraries.architecture.AsyncData + +open class ChangeServerStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aChangeServerState(), + aChangeServerState(changeServerAction = AsyncData.Failure(ChangeServerError.Error(null))), + aChangeServerState(changeServerAction = AsyncData.Failure(ChangeServerError.SlidingSyncAlert)), + aChangeServerState( + changeServerAction = AsyncData.Failure( + ChangeServerError.UnauthorizedAccountProvider( + unauthorisedAccountProviderTitle = "example.com", + authorisedAccountProviderTitles = listOf("element.io", "element.org"), + ) + ) + ), + aChangeServerState( + changeServerAction = AsyncData.Failure( + ChangeServerError.NeedElementPro( + unauthorisedAccountProviderTitle = "example.com", + applicationId = "applicationId", + ), + ) + ), + aChangeServerState( + changeServerAction = AsyncData.Failure( + ChangeServerError.UnsupportedServer + ) + ), + ) +} + +fun aChangeServerState( + changeServerAction: AsyncData = AsyncData.Uninitialized, +) = ChangeServerState( + changeServerAction = changeServerAction, + 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 0000000..d6b9c17 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.changeserver + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +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.androidutils.system.openGooglePlay +import io.element.android.libraries.architecture.AsyncData +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.LocalBuildMeta +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun ChangeServerView( + state: ChangeServerState, + onLearnMoreClick: () -> Unit, + onSuccess: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val eventSink = state.eventSink + when (state.changeServerAction) { + is AsyncData.Failure -> { + when (val error = state.changeServerAction.error as? ChangeServerError) { + ChangeServerError.InvalidServer -> + ErrorDialog( + modifier = modifier, + content = stringResource(R.string.screen_change_server_error_invalid_homeserver), + onSubmit = { + eventSink.invoke(ChangeServerEvents.ClearError) + } + ) + ChangeServerError.UnsupportedServer -> + ErrorDialog( + modifier = modifier, + content = stringResource(R.string.screen_login_error_unsupported_authentication), + onSubmit = { + eventSink.invoke(ChangeServerEvents.ClearError) + } + ) + is ChangeServerError.Error -> { + ErrorDialog( + modifier = modifier, + content = error.messageStr ?: stringResource(CommonStrings.error_unknown), + onSubmit = { + eventSink.invoke(ChangeServerEvents.ClearError) + } + ) + } + is ChangeServerError.SlidingSyncAlert -> { + SlidingSyncNotSupportedDialog( + modifier = modifier, + onLearnMoreClick = { + onLearnMoreClick() + eventSink.invoke(ChangeServerEvents.ClearError) + }, + onDismiss = { + eventSink.invoke(ChangeServerEvents.ClearError) + } + ) + } + is ChangeServerError.NeedElementPro -> { + ConfirmationDialog( + modifier = modifier, + title = stringResource(R.string.screen_change_server_error_element_pro_required_title), + content = stringResource( + R.string.screen_change_server_error_element_pro_required_message, + error.unauthorisedAccountProviderTitle, + ), + submitText = stringResource(R.string.screen_change_server_error_element_pro_required_action_android), + onSubmitClick = { + context.openGooglePlay(error.applicationId) + eventSink.invoke(ChangeServerEvents.ClearError) + }, + onDismiss = { + eventSink.invoke(ChangeServerEvents.ClearError) + }, + ) + } + is ChangeServerError.UnauthorizedAccountProvider -> { + ErrorDialog( + modifier = modifier, + content = stringResource( + id = R.string.screen_change_server_error_unauthorized_homeserver, + LocalBuildMeta.current.applicationName, + error.unauthorisedAccountProviderTitle, + ), + onSubmit = { + eventSink.invoke(ChangeServerEvents.ClearError) + } + ) + } + null -> Unit + } + } + is AsyncData.Loading -> ProgressDialog() + is AsyncData.Success -> { + val latestOnSuccess by rememberUpdatedState(onSuccess) + LaunchedEffect(state.changeServerAction) { + latestOnSuccess() + } + } + AsyncData.Uninitialized -> Unit + } +} + +@PreviewsDayNight +@Composable +internal fun ChangeServerViewPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) = ElementPreview { + ChangeServerView( + state = state, + onLearnMoreClick = {}, + onSuccess = {}, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt new file mode 100644 index 0000000..4523e6f --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.login.impl.changeserver.ChangeServerPresenter +import io.element.android.features.login.impl.changeserver.ChangeServerState +import io.element.android.libraries.architecture.Presenter + +@ContributesTo(AppScope::class) +@BindingContainer +interface LoginModule { + @Binds + fun bindChangeServerPresenter(presenter: ChangeServerPresenter): Presenter +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/QrCodeLoginBindings.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/QrCodeLoginBindings.kt new file mode 100644 index 0000000..050dc0c --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/QrCodeLoginBindings.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.di + +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.login.impl.qrcode.QrCodeLoginManager + +@ContributesTo(QrCodeLoginScope::class) +interface QrCodeLoginBindings { + fun qrCodeLoginManager(): QrCodeLoginManager +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/QrCodeLoginGraph.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/QrCodeLoginGraph.kt new file mode 100644 index 0000000..6500820 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/QrCodeLoginGraph.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.GraphExtension +import io.element.android.libraries.architecture.NodeFactoriesBindings + +@GraphExtension(QrCodeLoginScope::class) +interface QrCodeLoginGraph : NodeFactoriesBindings { + @ContributesTo(AppScope::class) + @GraphExtension.Factory + interface Factory { + fun create(): QrCodeLoginGraph + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/QrCodeLoginScope.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/QrCodeLoginScope.kt new file mode 100644 index 0000000..a3538c8 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/QrCodeLoginScope.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.di + +abstract class QrCodeLoginScope private constructor() 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 0000000..60ffd2a --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/dialogs/SlidingSyncNotSupportedDialog.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.LocalBuildMeta +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun SlidingSyncNotSupportedDialog( + onLearnMoreClick: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + ConfirmationDialog( + modifier = modifier, + onDismiss = onDismiss, + submitText = stringResource(CommonStrings.action_learn_more), + onSubmitClick = onLearnMoreClick, + onCancelClick = onDismiss, + title = stringResource(CommonStrings.dialog_title_error), + content = stringResource( + id = R.string.screen_change_server_error_no_sliding_sync_message, + LocalBuildMeta.current.applicationName, + ), + ) +} + +@PreviewsDayNight +@Composable +internal fun SlidingSyncNotSupportedDialogPreview() = ElementPreview { + SlidingSyncNotSupportedDialog( + onLearnMoreClick = {}, + onDismiss = {}, + ) +} 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 0000000..2f4af14 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerError.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.error + +import io.element.android.features.login.impl.changeserver.AccountProviderAccessException +import io.element.android.libraries.matrix.api.auth.AuthenticationException + +sealed class ChangeServerError : Exception() { + data class Error( + val messageStr: String? = null, + ) : ChangeServerError() + + data class NeedElementPro( + val unauthorisedAccountProviderTitle: String, + val applicationId: String, + ) : ChangeServerError() + + data class UnauthorizedAccountProvider( + val unauthorisedAccountProviderTitle: String, + val authorisedAccountProviderTitles: List, + ) : ChangeServerError() + + data object SlidingSyncAlert : ChangeServerError() + data object InvalidServer : ChangeServerError() + data object UnsupportedServer : ChangeServerError() + + companion object { + fun from(error: Throwable): ChangeServerError = when (error) { + is ChangeServerError -> error + is AuthenticationException -> { + when (error) { + is AuthenticationException.SlidingSyncVersion -> SlidingSyncAlert + is AuthenticationException.InvalidServerName, + is AuthenticationException.ServerUnreachable -> InvalidServer + // AccountAlreadyLoggedIn error should not happen at this point + is AuthenticationException.AccountAlreadyLoggedIn -> Error(messageStr = error.message) + is AuthenticationException.Generic -> Error(messageStr = error.message) + is AuthenticationException.Oidc -> Error(messageStr = error.message) + } + } + is AccountProviderAccessException.NeedElementProException -> NeedElementPro( + unauthorisedAccountProviderTitle = error.unauthorisedAccountProviderTitle, + applicationId = error.applicationId, + ) + is AccountProviderAccessException.UnauthorizedAccountProviderException -> UnauthorizedAccountProvider( + unauthorisedAccountProviderTitle = error.unauthorisedAccountProviderTitle, + authorisedAccountProviderTitles = error.authorisedAccountProviderTitles, + ) + else -> Error(messageStr = error.message) + } + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerErrorProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerErrorProvider.kt new file mode 100644 index 0000000..59fb42f --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerErrorProvider.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.error + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +class ChangeServerErrorProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + ChangeServerError.InvalidServer, + ChangeServerError.Error( + messageStr = "An error description", + ), + ChangeServerError.NeedElementPro( + unauthorisedAccountProviderTitle = "element.io", + applicationId = "io.element.enterprise", + ), + ChangeServerError.UnauthorizedAccountProvider( + unauthorisedAccountProviderTitle = "element.io", + authorisedAccountProviderTitles = listOf("provider.org", "provider.io"), + ), + ChangeServerError.SlidingSyncAlert, + ChangeServerError.UnsupportedServer, + ) +} 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 0000000..66ca0d9 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ErrorFormatter.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/login/LoginHelper.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt new file mode 100644 index 0000000..a62919e --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.login + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import dev.zacsweers.metro.Inject +import io.element.android.features.login.impl.error.ChangeServerError +import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderPresenter +import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderPresenter +import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported +import io.element.android.features.login.impl.screens.onboarding.OnBoardingPresenter +import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.auth.OidcPrompt +import io.element.android.libraries.oidc.api.OidcAction +import io.element.android.libraries.oidc.api.OidcActionFlow + +/** + * This class is responsible for managing the login flow, including handling OIDC actions and + * submitting login requests. + * It's a helper to avoid code duplication. It is used by [OnBoardingPresenter], [ConfirmAccountProviderPresenter] + * and [ChooseAccountProviderPresenter]. + */ +@Inject +class LoginHelper( + private val oidcActionFlow: OidcActionFlow, + private val authenticationService: MatrixAuthenticationService, + private val webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever, +) { + private val loginModeState: MutableState> = mutableStateOf(AsyncData.Uninitialized) + + @Composable + fun collectLoginMode(): State> { + LaunchedEffect(Unit) { + oidcActionFlow.collect { oidcAction -> + if (oidcAction != null) { + onOidcAction(oidcAction) + } + } + } + return loginModeState + } + + fun clearError() { + loginModeState.value = AsyncData.Uninitialized + } + + suspend fun submit( + isAccountCreation: Boolean, + homeserverUrl: String, + loginHint: String?, + ) { + suspend { + authenticationService.setHomeserver(homeserverUrl).map { matrixHomeServerDetails -> + if (matrixHomeServerDetails.supportsOidcLogin) { + // Retrieve the details right now + val oidcPrompt = if (isAccountCreation) OidcPrompt.Create else OidcPrompt.Login + LoginMode.Oidc( + authenticationService.getOidcUrl(prompt = oidcPrompt, loginHint = loginHint).getOrThrow() + ) + } else if (isAccountCreation) { + val url = webClientUrlForAuthenticationRetriever.retrieve(homeserverUrl) + LoginMode.AccountCreation(url) + } else if (matrixHomeServerDetails.supportsPasswordLogin) { + LoginMode.PasswordLogin + } else { + error("Unsupported login flow") + } + }.getOrThrow() + }.runCatchingUpdatingState( + state = loginModeState, + errorTransform = { + when (it) { + is AccountCreationNotSupported -> it + else -> ChangeServerError.from(it) + } + } + ) + } + + private suspend fun onOidcAction(oidcAction: OidcAction) { + if (oidcAction is OidcAction.GoBack && oidcAction.toUnblock && loginModeState.value !is AsyncData.Loading) { + // Ignore GoBack action if the current state is not Loading. This GoBack action is coming from LoginFlowNode. + // This can happen if there is an error, for instance attempt to login again on the same account. + return + } + loginModeState.value = AsyncData.Loading() + when (oidcAction) { + is OidcAction.GoBack -> { + authenticationService.cancelOidcLogin() + .onSuccess { + loginModeState.value = AsyncData.Uninitialized + } + .onFailure { failure -> + loginModeState.value = AsyncData.Failure(failure) + } + } + is OidcAction.Success -> { + authenticationService.loginWithOidc(oidcAction.url) + .onFailure { failure -> + loginModeState.value = AsyncData.Failure(failure) + } + } + } + oidcActionFlow.reset() + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginMode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginMode.kt new file mode 100644 index 0000000..08e604e --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginMode.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.login + +import io.element.android.libraries.matrix.api.auth.OidcDetails + +sealed interface LoginMode { + data object PasswordLogin : LoginMode + data class Oidc(val oidcDetails: OidcDetails) : LoginMode + data class AccountCreation(val url: String) : LoginMode +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt new file mode 100644 index 0000000..f88e34b --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.login + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +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.features.login.impl.screens.createaccount.AccountCreationNotSupported +import io.element.android.libraries.androidutils.system.openGooglePlay +import io.element.android.libraries.architecture.AsyncData +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.LocalBuildMeta +import io.element.android.libraries.matrix.api.auth.AuthenticationException +import io.element.android.libraries.matrix.api.auth.OidcDetails +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun LoginModeView( + loginMode: AsyncData, + onClearError: () -> Unit, + onLearnMoreClick: () -> Unit, + onOidcDetails: (OidcDetails) -> Unit, + onNeedLoginPassword: () -> Unit, + onCreateAccountContinue: (url: String) -> Unit +) { + val context = LocalContext.current + when (loginMode) { + is AsyncData.Failure -> { + when (val error = loginMode.error) { + is ChangeServerError -> { + when (error) { + ChangeServerError.InvalidServer -> + ErrorDialog( + content = stringResource(R.string.screen_change_server_error_invalid_homeserver), + onSubmit = onClearError, + ) + is ChangeServerError.UnsupportedServer -> { + ErrorDialog( + content = stringResource(R.string.screen_login_error_unsupported_authentication), + onSubmit = onClearError, + ) + } + is ChangeServerError.Error -> { + ErrorDialog( + content = error.messageStr ?: stringResource(CommonStrings.error_unknown), + onSubmit = onClearError, + ) + } + is ChangeServerError.SlidingSyncAlert -> { + SlidingSyncNotSupportedDialog( + onLearnMoreClick = { + onLearnMoreClick() + onClearError() + }, + onDismiss = onClearError, + ) + } + is ChangeServerError.NeedElementPro -> { + ConfirmationDialog( + title = stringResource(R.string.screen_change_server_error_element_pro_required_title), + content = stringResource( + R.string.screen_change_server_error_element_pro_required_message, + error.unauthorisedAccountProviderTitle, + ), + submitText = stringResource(R.string.screen_change_server_error_element_pro_required_action_android), + onSubmitClick = { + context.openGooglePlay(error.applicationId) + onClearError() + }, + onDismiss = onClearError, + ) + } + is ChangeServerError.UnauthorizedAccountProvider -> { + ErrorDialog( + content = stringResource( + id = R.string.screen_change_server_error_unauthorized_homeserver, + LocalBuildMeta.current.applicationName, + error.unauthorisedAccountProviderTitle, + ), + onSubmit = onClearError, + ) + } + } + } + is AccountCreationNotSupported -> { + ErrorDialog( + content = stringResource(CommonStrings.error_account_creation_not_possible), + onSubmit = onClearError, + ) + } + is AuthenticationException.AccountAlreadyLoggedIn -> { + ErrorDialog( + content = stringResource(CommonStrings.error_account_already_logged_in, error.userId), + onSubmit = onClearError, + ) + } + else -> { + ErrorDialog( + content = stringResource(CommonStrings.error_unknown), + onSubmit = onClearError, + ) + } + } + } + is AsyncData.Loading -> Unit // The Continue button shows the loading state + is AsyncData.Success -> { + when (val loginModeData = loginMode.data) { + is LoginMode.Oidc -> onOidcDetails(loginModeData.oidcDetails) + LoginMode.PasswordLogin -> onNeedLoginPassword() + is LoginMode.AccountCreation -> onCreateAccountContinue(loginModeData.url) + } + // Also clear the data, to let the next screen be able to go back + onClearError() + } + AsyncData.Uninitialized -> Unit + } +} + +@PreviewsDayNight +@Composable +internal fun LoginModeViewPreview(@PreviewParameter(LoginModeViewErrorProvider::class) error: Throwable) { + ElementPreview { + LoginModeView( + loginMode = AsyncData.Failure(error), + onClearError = {}, + onLearnMoreClick = {}, + onOidcDetails = {}, + onNeedLoginPassword = {}, + onCreateAccountContinue = {} + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeViewErrorProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeViewErrorProvider.kt new file mode 100644 index 0000000..513d0a8 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeViewErrorProvider.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.login + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.login.impl.error.ChangeServerErrorProvider +import io.element.android.libraries.matrix.api.auth.AuthenticationException + +class LoginModeViewErrorProvider : PreviewParameterProvider { + override val values: Sequence + get() = ChangeServerErrorProvider().values + + AuthenticationException.AccountAlreadyLoggedIn("@alice:matrix.org") +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/DefaultQrCodeLoginManager.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/DefaultQrCodeLoginManager.kt new file mode 100644 index 0000000..a25d810 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/DefaultQrCodeLoginManager.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.qrcode + +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.features.login.impl.di.QrCodeLoginScope +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +@SingleIn(QrCodeLoginScope::class) +@ContributesBinding(QrCodeLoginScope::class) +class DefaultQrCodeLoginManager( + private val authenticationService: MatrixAuthenticationService, +) : QrCodeLoginManager { + private val _currentLoginStep = MutableStateFlow(QrCodeLoginStep.Uninitialized) + override val currentLoginStep: StateFlow = _currentLoginStep + + override suspend fun authenticate(qrCodeLoginData: MatrixQrCodeLoginData): Result { + reset() + + return authenticationService.loginWithQrCode(qrCodeLoginData) { step -> + _currentLoginStep.value = step + }.onFailure { throwable -> + if (throwable is QrLoginException) { + _currentLoginStep.value = QrCodeLoginStep.Failed(throwable) + } + } + } + + override fun reset() { + _currentLoginStep.value = QrCodeLoginStep.Uninitialized + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNode.kt new file mode 100644 index 0000000..7dad957 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNode.kt @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.qrcode + +import android.os.Parcelable +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +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.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import com.bumble.appyx.navmodel.backstack.operation.replace +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.login.impl.di.QrCodeLoginBindings +import io.element.android.features.login.impl.di.QrCodeLoginGraph +import io.element.android.features.login.impl.screens.qrcode.confirmation.QrCodeConfirmationNode +import io.element.android.features.login.impl.screens.qrcode.confirmation.QrCodeConfirmationStep +import io.element.android.features.login.impl.screens.qrcode.error.QrCodeErrorNode +import io.element.android.features.login.impl.screens.qrcode.intro.QrCodeIntroNode +import io.element.android.features.login.impl.screens.qrcode.scan.QrCodeScanNode +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.DependencyInjectionGraphOwner +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import timber.log.Timber + +@ContributesNode(AppScope::class) +@AssistedInject +class QrCodeLoginFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + qrCodeLoginGraphFactory: QrCodeLoginGraph.Factory, + private val coroutineDispatchers: CoroutineDispatchers, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Initial, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +), DependencyInjectionGraphOwner { + private var authenticationJob: Job? = null + + override val graph = qrCodeLoginGraphFactory.create() + private val qrCodeLoginManager by lazy { bindings().qrCodeLoginManager() } + + sealed interface NavTarget : Parcelable { + @Parcelize + data object Initial : NavTarget + + @Parcelize + data object QrCodeScan : NavTarget + + @Parcelize + data class QrCodeConfirmation(val step: QrCodeConfirmationStep) : NavTarget + + @Parcelize + data class Error(val errorType: QrCodeErrorScreenType) : NavTarget + } + + override fun onBuilt() { + super.onBuilt() + + observeLoginStep() + } + + fun isLoginInProgress(): Boolean { + return authenticationJob?.isActive == true + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun observeLoginStep() { + lifecycleScope.launch { + qrCodeLoginManager.currentLoginStep + .collect { step -> + when (step) { + is QrCodeLoginStep.EstablishingSecureChannel -> { + backstack.replace(NavTarget.QrCodeConfirmation(QrCodeConfirmationStep.DisplayCheckCode(step.checkCode))) + } + is QrCodeLoginStep.WaitingForToken -> { + backstack.replace(NavTarget.QrCodeConfirmation(QrCodeConfirmationStep.DisplayVerificationCode(step.userCode))) + } + is QrCodeLoginStep.Failed -> { + when (val error = step.error) { + is QrLoginException.OtherDeviceNotSignedIn -> { + // Do nothing here, it'll be handled in the scan QR screen + } + is QrLoginException.Cancelled -> { + backstack.replace(NavTarget.Error(QrCodeErrorScreenType.Cancelled)) + } + is QrLoginException.Expired -> { + backstack.replace(NavTarget.Error(QrCodeErrorScreenType.Expired)) + } + is QrLoginException.Declined -> { + backstack.replace(NavTarget.Error(QrCodeErrorScreenType.Declined)) + } + is QrLoginException.ConnectionInsecure -> { + backstack.replace(NavTarget.Error(QrCodeErrorScreenType.InsecureChannelDetected)) + } + is QrLoginException.LinkingNotSupported -> { + backstack.replace(NavTarget.Error(QrCodeErrorScreenType.ProtocolNotSupported)) + } + is QrLoginException.SlidingSyncNotAvailable -> { + backstack.replace(NavTarget.Error(QrCodeErrorScreenType.SlidingSyncNotAvailable)) + } + is QrLoginException.OidcMetadataInvalid -> { + Timber.e(error, "OIDC metadata is invalid") + backstack.replace(NavTarget.Error(QrCodeErrorScreenType.UnknownError)) + } + else -> { + Timber.e(error, "Unknown error found") + backstack.replace(NavTarget.Error(QrCodeErrorScreenType.UnknownError)) + } + } + } + else -> Unit + } + } + } + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.Initial -> { + val callback = object : QrCodeIntroNode.Callback { + override fun cancel() { + navigateUp() + } + + override fun navigateToQrCodeScan() { + backstack.push(NavTarget.QrCodeScan) + } + } + createNode(buildContext, plugins = listOf(callback)) + } + is NavTarget.QrCodeScan -> { + val callback = object : QrCodeScanNode.Callback { + override fun handleScannedCode(qrCodeLoginData: MatrixQrCodeLoginData) { + lifecycleScope.startAuthentication(qrCodeLoginData) + } + + override fun cancel() { + backstack.pop() + } + } + createNode(buildContext, plugins = listOf(callback)) + } + is NavTarget.QrCodeConfirmation -> { + val callback = object : QrCodeConfirmationNode.Callback { + override fun onCancel() = reset() + } + createNode(buildContext, plugins = listOf(navTarget.step, callback)) + } + is NavTarget.Error -> { + val callback = object : QrCodeErrorNode.Callback { + override fun onRetry() = reset() + } + createNode(buildContext, plugins = listOf(navTarget.errorType, callback)) + } + } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun reset() { + authenticationJob?.cancel() + authenticationJob = null + qrCodeLoginManager.reset() + backstack.newRoot(NavTarget.Initial) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun CoroutineScope.startAuthentication(qrCodeLoginData: MatrixQrCodeLoginData) { + authenticationJob = launch(coroutineDispatchers.main) { + qrCodeLoginManager.authenticate(qrCodeLoginData) + .onSuccess { + authenticationJob = null + } + .onFailure { throwable -> + Timber.e(throwable, "QR code authentication failed") + authenticationJob = null + } + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView() + } +} + +@Immutable +sealed interface QrCodeErrorScreenType : NodeInputs, Parcelable { + @Parcelize + data object Cancelled : QrCodeErrorScreenType + + @Parcelize + data object Expired : QrCodeErrorScreenType + + @Parcelize + data object InsecureChannelDetected : QrCodeErrorScreenType + + @Parcelize + data object Declined : QrCodeErrorScreenType + + @Parcelize + data object ProtocolNotSupported : QrCodeErrorScreenType + + @Parcelize + data object SlidingSyncNotAvailable : QrCodeErrorScreenType + + @Parcelize + data object UnknownError : QrCodeErrorScreenType +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginManager.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginManager.kt new file mode 100644 index 0000000..5f75403 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginManager.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.qrcode + +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.flow.StateFlow + +/** + * Helper to handle the QR code login flow after the QR code data has been provided. + */ +interface QrCodeLoginManager { + /** + * The current QR code login step. + */ + val currentLoginStep: StateFlow + + /** + * Authenticate using the provided [qrCodeLoginData]. + * @param qrCodeLoginData the QR code login data from the scanned QR code. + * @return the logged in [SessionId] if the authentication was successful or a failure result. + */ + suspend fun authenticate(qrCodeLoginData: MatrixQrCodeLoginData): Result + + fun reset() +} 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 0000000..d9994de --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverData.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.resolver + +data class HomeserverData( + // The computed homeserver url, for which a wellknown file has been retrieved, or just a valid Url + val homeserverUrl: String, +) 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 0000000..1c4ef8c --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/resolver/HomeserverResolver.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.resolver + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.coroutine.parallelMap +import io.element.android.libraries.core.uri.ensureProtocol +import io.element.android.libraries.core.uri.isValidUrl +import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.util.Collections + +/** + * Resolve homeserver base on search terms. + */ +@Inject +class HomeserverResolver( + private val dispatchers: CoroutineDispatchers, + private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker, +) { + fun resolve(userInput: String): Flow> = 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()) + // Run all the requests in parallel + withContext(dispatchers.io) { + list.parallelMap { url -> + val isValid = homeServerLoginCompatibilityChecker.check(url) + .onFailure { Timber.w(it, "Failed to check compatibility with homeserver $url") } + .getOrNull() + ?: return@parallelMap + + // Emit the list as soon as possible + if (isValid) { + currentList.add(HomeserverData(homeserverUrl = url)) + withContext(flowContext) { + emit(currentList.toList()) + } + } + } + } + // If list is empty, and candidateBase is a valid an URL, do not block the user. + // A unsupported homeserver / homeserver not found error will be displayed if the user continues + if (currentList.isEmpty() && candidateBase.isValidUrl()) { + emit(listOf(HomeserverData(homeserverUrl = candidateBase))) + } + } + + private fun getUrlCandidates(data: String): List { + 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/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 0000000..018a25d --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.login.impl.util.openLearnMorePage +import io.element.android.libraries.architecture.callback + +@ContributesNode(AppScope::class) +@AssistedInject +class ChangeAccountProviderNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: ChangeAccountProviderPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onDone() + fun navigateToSearchAccountProvider() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val context = LocalContext.current + ChangeAccountProviderView( + state = state, + modifier = modifier, + onBackClick = ::navigateUp, + onLearnMoreClick = { openLearnMorePage(context) }, + onSuccess = callback::onDone, + onOtherProviderClick = callback::navigateToSearchAccountProvider, + ) + } +} 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 0000000..4160535 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.changeaccountprovider + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import dev.zacsweers.metro.Inject +import io.element.android.appconfig.AuthenticationConfig +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.api.canConnectToAnyHomeserver +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.changeserver.ChangeServerState +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.uri.ensureProtocol +import kotlinx.collections.immutable.toImmutableList + +@Inject +class ChangeAccountProviderPresenter( + private val changeServerPresenter: Presenter, + private val enterpriseService: EnterpriseService, +) : Presenter { + @Composable + override fun present(): ChangeAccountProviderState { + val staticAccountProviderList = remember { + enterpriseService.defaultHomeserverList() + .filter { it != EnterpriseService.ANY_ACCOUNT_PROVIDER } + .map { it.ensureProtocol() } + .ifEmpty { listOf(AuthenticationConfig.MATRIX_ORG_URL) } + .map { url -> + AccountProvider( + url = url, + subtitle = null, + isPublic = url == AuthenticationConfig.MATRIX_ORG_URL, + isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL, + ) + } + .toImmutableList() + } + + val canSearchForAccountProviders = remember { + enterpriseService.canConnectToAnyHomeserver() + } + + val changeServerState = changeServerPresenter.present() + return ChangeAccountProviderState( + accountProviders = staticAccountProviderList, + canSearchForAccountProviders = canSearchForAccountProviders, + 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 0000000..3068a1c --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderState.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 +import kotlinx.collections.immutable.ImmutableList + +data class ChangeAccountProviderState( + val accountProviders: ImmutableList, + val canSearchForAccountProviders: Boolean, + 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 0000000..5cec196 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderStateProvider.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.changeaccountprovider + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.accountprovider.anAccountProvider +import io.element.android.features.login.impl.changeserver.ChangeServerState +import io.element.android.features.login.impl.changeserver.aChangeServerState +import kotlinx.collections.immutable.toImmutableList + +open class ChangeAccountProviderStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aChangeAccountProviderState(), + aChangeAccountProviderState(canSearchForAccountProviders = false), + // Add other state here + ) +} + +fun aChangeAccountProviderState( + accountProviders: List = listOf( + anAccountProvider() + ), + canSearchForAccountProviders: Boolean = true, + changeServerState: ChangeServerState = aChangeServerState(), +) = ChangeAccountProviderState( + accountProviders = accountProviders.toImmutableList(), + canSearchForAccountProviders = canSearchForAccountProviders, + changeServerState = changeServerState, +) 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 0000000..4f6c87a --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::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.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.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +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.compound.tokens.generated.CompoundIcons +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.accountprovider.AccountProviderOtherView +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.BigIcon +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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, + onBackClick: () -> Unit, + onLearnMoreClick: () -> Unit, + onSuccess: () -> Unit, + onOtherProviderClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { BackButton(onClick = onBackClick) } + ) + } + ) { 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), + iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()), + 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 + if (state.canSearchForAccountProviders) { + AccountProviderOtherView( + onClick = onOtherProviderClick + ) + } + Spacer(Modifier.height(32.dp)) + } + ChangeServerView( + state = state.changeServerState, + onLearnMoreClick = onLearnMoreClick, + onSuccess = onSuccess, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun ChangeAccountProviderViewPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) = ElementPreview { + ChangeAccountProviderView( + state = state, + onBackClick = { }, + onLearnMoreClick = { }, + onSuccess = { }, + onOtherProviderClick = { }, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderEvents.kt new file mode 100644 index 0000000..f60cc3e --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderEvents.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.chooseaccountprovider + +import io.element.android.features.login.impl.accountprovider.AccountProvider + +sealed interface ChooseAccountProviderEvents { + data class SelectAccountProvider(val accountProvider: AccountProvider) : ChooseAccountProviderEvents + data object Continue : ChooseAccountProviderEvents + data object ClearError : ChooseAccountProviderEvents +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderNode.kt new file mode 100644 index 0000000..5dc6ebb --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderNode.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.chooseaccountprovider + +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.login.impl.util.openLearnMorePage +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.matrix.api.auth.OidcDetails + +@ContributesNode(AppScope::class) +@AssistedInject +class ChooseAccountProviderNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: ChooseAccountProviderPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun navigateToLoginPassword() + fun navigateToOidc(oidcDetails: OidcDetails) + fun navigateToCreateAccount(url: String) + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val context = LocalContext.current + ChooseAccountProviderView( + state = state, + modifier = modifier, + onBackClick = ::navigateUp, + onOidcDetails = callback::navigateToOidc, + onNeedLoginPassword = callback::navigateToLoginPassword, + onLearnMoreClick = { openLearnMorePage(context) }, + onCreateAccountContinue = callback::navigateToCreateAccount, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt new file mode 100644 index 0000000..87010a4 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenter.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.chooseaccountprovider + +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 dev.zacsweers.metro.Inject +import io.element.android.appconfig.AuthenticationConfig +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.login.LoginHelper +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.uri.ensureProtocol +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch + +@Inject +class ChooseAccountProviderPresenter( + private val enterpriseService: EnterpriseService, + private val loginHelper: LoginHelper, +) : Presenter { + @Composable + override fun present(): ChooseAccountProviderState { + val localCoroutineScope = rememberCoroutineScope() + val loginMode by loginHelper.collectLoginMode() + + var selectedAccountProvider: AccountProvider? by remember { mutableStateOf(null) } + + fun handleEvent(event: ChooseAccountProviderEvents) { + when (event) { + ChooseAccountProviderEvents.Continue -> localCoroutineScope.launch { + selectedAccountProvider?.let { + loginHelper.submit( + isAccountCreation = false, + homeserverUrl = it.url, + loginHint = null, + ) + } + } + is ChooseAccountProviderEvents.SelectAccountProvider -> { + // Ensure that the user do not change the server during processing + if (loginMode is AsyncData.Uninitialized) { + selectedAccountProvider = event.accountProvider + } + } + ChooseAccountProviderEvents.ClearError -> loginHelper.clearError() + } + } + + val staticAccountProviderList = remember { + // The list cannot contains ANY_ACCOUNT_PROVIDER ("*") and cannot be empty at this point + enterpriseService.defaultHomeserverList() + .map { it.ensureProtocol() } + .map { url -> + AccountProvider( + url = url, + subtitle = null, + isPublic = url == AuthenticationConfig.MATRIX_ORG_URL, + isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL, + ) + } + .toImmutableList() + } + + return ChooseAccountProviderState( + accountProviders = staticAccountProviderList, + selectedAccountProvider = selectedAccountProvider, + loginMode = loginMode, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderState.kt new file mode 100644 index 0000000..e34fbcc --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderState.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.chooseaccountprovider + +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.login.LoginMode +import io.element.android.libraries.architecture.AsyncData +import kotlinx.collections.immutable.ImmutableList + +data class ChooseAccountProviderState( + val accountProviders: ImmutableList, + val selectedAccountProvider: AccountProvider?, + val loginMode: AsyncData, + val eventSink: (ChooseAccountProviderEvents) -> Unit, +) { + val submitEnabled: Boolean + get() = selectedAccountProvider != null && (loginMode is AsyncData.Uninitialized || loginMode is AsyncData.Loading) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStateProvider.kt new file mode 100644 index 0000000..93bd665 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStateProvider.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.chooseaccountprovider + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.accountprovider.anAccountProvider +import io.element.android.features.login.impl.login.LoginMode +import io.element.android.libraries.architecture.AsyncData +import kotlinx.collections.immutable.toImmutableList + +open class ChooseAccountProviderStateProvider : PreviewParameterProvider { + private val server1 = anAccountProvider( + url = "https://server1.io", + subtitle = null, + isPublic = false, + isMatrixOrg = false, + ) + private val server2 = anAccountProvider( + url = "https://server2.io", + subtitle = null, + isPublic = false, + isMatrixOrg = false, + ) + private val server3 = anAccountProvider( + url = "https://server3.io", + subtitle = null, + isPublic = false, + isMatrixOrg = false, + ) + override val values: Sequence + get() = sequenceOf( + aChooseAccountProviderState( + accountProviders = listOf( + server1, + server2, + server3, + ) + ), + aChooseAccountProviderState( + accountProviders = listOf( + server1, + server2, + server3, + ), + selectedAccountProvider = server2, + ), + aChooseAccountProviderState( + accountProviders = listOf( + server1, + server2, + server3, + ), + selectedAccountProvider = server2, + loginMode = AsyncData.Loading(), + ), + // Add other state here + ) +} + +fun aChooseAccountProviderState( + accountProviders: List = listOf( + anAccountProvider() + ), + selectedAccountProvider: AccountProvider? = null, + loginMode: AsyncData = AsyncData.Uninitialized, + eventSink: (ChooseAccountProviderEvents) -> Unit = {}, +) = ChooseAccountProviderState( + accountProviders = accountProviders.toImmutableList(), + selectedAccountProvider = selectedAccountProvider, + loginMode = loginMode, + eventSink = eventSink, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt new file mode 100644 index 0000000..cdb8030 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.features.login.impl.screens.chooseaccountprovider + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.accountprovider.AccountProviderView +import io.element.android.features.login.impl.login.LoginModeView +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.auth.OidcDetails +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun ChooseAccountProviderView( + state: ChooseAccountProviderState, + onBackClick: () -> Unit, + onOidcDetails: (OidcDetails) -> Unit, + onNeedLoginPassword: () -> Unit, + onLearnMoreClick: () -> Unit, + onCreateAccountContinue: (url: String) -> Unit, + modifier: Modifier = Modifier, +) { + val isLoading by remember(state.loginMode) { + derivedStateOf { + state.loginMode is AsyncData.Loading + } + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { BackButton(onClick = onBackClick) } + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .imePadding() + .padding(padding) + .consumeWindowInsets(padding) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(state = rememberScrollState()) + ) { + IconTitleSubtitleMolecule( + modifier = Modifier.padding(top = 16.dp, bottom = 32.dp, start = 16.dp, end = 16.dp), + iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()), + title = stringResource(id = R.string.screen_server_confirmation_title_picker_mode), + subTitle = null, + ) + + 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, + selected = item == state.selectedAccountProvider, + onClick = { + state.eventSink(ChooseAccountProviderEvents.SelectAccountProvider(item)) + } + ) + } + Spacer(Modifier.height(32.dp)) + // Flexible spacing to keep the submit button at the bottom + Spacer(modifier = Modifier.weight(1f)) + Button( + text = stringResource(id = CommonStrings.action_continue), + showProgress = isLoading, + onClick = { + state.eventSink(ChooseAccountProviderEvents.Continue) + }, + enabled = state.submitEnabled || isLoading, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(48.dp)) + } + LoginModeView( + loginMode = state.loginMode, + onClearError = { + state.eventSink(ChooseAccountProviderEvents.ClearError) + }, + onLearnMoreClick = onLearnMoreClick, + onOidcDetails = onOidcDetails, + onNeedLoginPassword = onNeedLoginPassword, + onCreateAccountContinue = onCreateAccountContinue, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun ChooseAccountProviderViewPreview(@PreviewParameter(ChooseAccountProviderStateProvider::class) state: ChooseAccountProviderState) = ElementPreview { + ChooseAccountProviderView( + state = state, + onBackClick = { }, + onLearnMoreClick = { }, + onOidcDetails = { }, + onNeedLoginPassword = { }, + onCreateAccountContinue = { }, + ) +} 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 0000000..d66606b --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.confirmaccountprovider + +sealed interface ConfirmAccountProviderEvents { + data object Continue : ConfirmAccountProviderEvents + data 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 0000000..e3643af --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.login.impl.util.openLearnMorePage +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.matrix.api.auth.OidcDetails + +@ContributesNode(AppScope::class) +@AssistedInject +class ConfirmAccountProviderNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + 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 navigateToLoginPassword() + fun navigateToOidc(oidcDetails: OidcDetails) + fun navigateToCreateAccount(url: String) + fun navigateToChangeAccountProvider() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val context = LocalContext.current + ConfirmAccountProviderView( + state = state, + modifier = modifier, + onOidcDetails = callback::navigateToOidc, + onNeedLoginPassword = callback::navigateToLoginPassword, + onCreateAccountContinue = callback::navigateToCreateAccount, + onChange = callback::navigateToChangeAccountProvider, + onLearnMoreClick = { 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 0000000..c38da7b --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.confirmaccountprovider + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.login.LoginHelper +import io.element.android.libraries.architecture.Presenter +import kotlinx.coroutines.launch + +@AssistedInject +class ConfirmAccountProviderPresenter( + @Assisted private val params: Params, + private val accountProviderDataSource: AccountProviderDataSource, + private val loginHelper: LoginHelper, +) : Presenter { + 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 loginMode by loginHelper.collectLoginMode() + + fun handleEvent(event: ConfirmAccountProviderEvents) { + when (event) { + ConfirmAccountProviderEvents.Continue -> localCoroutineScope.launch { + loginHelper.submit( + isAccountCreation = params.isAccountCreation, + homeserverUrl = accountProvider.url, + loginHint = null, + ) + } + ConfirmAccountProviderEvents.ClearError -> loginHelper.clearError() + } + } + + return ConfirmAccountProviderState( + accountProvider = accountProvider, + isAccountCreation = params.isAccountCreation, + loginMode = loginMode, + eventSink = ::handleEvent, + ) + } +} 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 0000000..b29b610 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderState.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.confirmaccountprovider + +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.login.LoginMode +import io.element.android.libraries.architecture.AsyncData + +data class ConfirmAccountProviderState( + val accountProvider: AccountProvider, + val isAccountCreation: Boolean, + val loginMode: AsyncData, + val eventSink: (ConfirmAccountProviderEvents) -> Unit +) { + val submitEnabled: Boolean get() = accountProvider.url.isNotEmpty() && (loginMode is AsyncData.Uninitialized || loginMode is AsyncData.Loading) +} 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 0000000..f3a48a8 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStateProvider.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.confirmaccountprovider + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.accountprovider.anAccountProvider +import io.element.android.features.login.impl.login.LoginMode +import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported +import io.element.android.libraries.architecture.AsyncData + +open class ConfirmAccountProviderStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aConfirmAccountProviderState(), + aConfirmAccountProviderState( + isAccountCreation = true, + ), + aConfirmAccountProviderState( + isAccountCreation = true, + loginMode = AsyncData.Failure(AccountCreationNotSupported()) + ), + ) +} + +private fun aConfirmAccountProviderState( + accountProvider: AccountProvider = anAccountProvider(), + isAccountCreation: Boolean = false, + loginMode: AsyncData = AsyncData.Uninitialized, + eventSink: (ConfirmAccountProviderEvents) -> Unit = {}, +) = ConfirmAccountProviderState( + accountProvider = accountProvider, + isAccountCreation = isAccountCreation, + loginMode = loginMode, + eventSink = 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 0000000..a175ab5 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.confirmaccountprovider + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.login.LoginModeView +import io.element.android.libraries.architecture.AsyncData +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.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +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 +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun ConfirmAccountProviderView( + state: ConfirmAccountProviderState, + onOidcDetails: (OidcDetails) -> Unit, + onNeedLoginPassword: () -> Unit, + onLearnMoreClick: () -> Unit, + onCreateAccountContinue: (url: String) -> Unit, + onChange: () -> Unit, + modifier: Modifier = Modifier, +) { + val isLoading by remember(state.loginMode) { + derivedStateOf { + state.loginMode is AsyncData.Loading + } + } + val eventSink = state.eventSink + + HeaderFooterPage( + modifier = modifier, + header = { + IconTitleSubtitleMolecule( + modifier = Modifier.padding(top = 60.dp), + iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), + 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 { + Button( + text = stringResource(id = CommonStrings.action_continue), + showProgress = isLoading, + onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) }, + enabled = state.submitEnabled || isLoading, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.loginContinue) + ) + TextButton( + text = stringResource(id = R.string.screen_account_provider_change), + onClick = onChange, + enabled = !isLoading, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.loginChangeServer) + ) + } + } + ) { + LoginModeView( + loginMode = state.loginMode, + onClearError = { + eventSink(ConfirmAccountProviderEvents.ClearError) + }, + onLearnMoreClick = onLearnMoreClick, + onOidcDetails = onOidcDetails, + onNeedLoginPassword = onNeedLoginPassword, + onCreateAccountContinue = onCreateAccountContinue, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun ConfirmAccountProviderViewPreview( + @PreviewParameter(ConfirmAccountProviderStateProvider::class) state: ConfirmAccountProviderState +) = ElementPreview { + ConfirmAccountProviderView( + state = state, + onOidcDetails = {}, + onNeedLoginPassword = {}, + onCreateAccountContinue = {}, + onLearnMoreClick = {}, + onChange = {}, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/AccountCreationNotSupported.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/AccountCreationNotSupported.kt new file mode 100644 index 0000000..f91eea2 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/AccountCreationNotSupported.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.createaccount + +class AccountCreationNotSupported : Exception() diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountEvents.kt new file mode 100644 index 0000000..a515573 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.createaccount + +sealed interface CreateAccountEvents { + data class SetPageProgress(val progress: Int) : CreateAccountEvents + data class OnMessageReceived(val message: String) : CreateAccountEvents +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountNode.kt new file mode 100644 index 0000000..e1972e5 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountNode.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.createaccount + +import android.app.Activity +import androidx.activity.compose.LocalActivity +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs + +@ContributesNode(AppScope::class) +@AssistedInject +class CreateAccountNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: CreateAccountPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + data class Inputs( + val url: String, + ) : NodeInputs + + private val presenter = presenterFactory.create(inputs().url) + + private fun onOpenExternalUrl(activity: Activity, darkTheme: Boolean, url: String) { + activity.openUrlInChromeCustomTab(null, darkTheme, url) + } + + @Composable + override fun View(modifier: Modifier) { + val activity = requireNotNull(LocalActivity.current) + val isDark = ElementTheme.isLightTheme.not() + val state = presenter.present() + CreateAccountView( + state = state, + modifier = modifier, + onBackClick = ::navigateUp, + onOpenExternalUrl = { + onOpenExternalUrl(activity, isDark, it) + }, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt new file mode 100644 index 0000000..f7a23df --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.createaccount + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.core.extensions.flatMap +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.core.meta.BuildMeta +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.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration.Companion.seconds + +@AssistedInject +class CreateAccountPresenter( + @Assisted private val url: String, + private val authenticationService: MatrixAuthenticationService, + private val clientProvider: MatrixClientProvider, + private val messageParser: MessageParser, + private val buildMeta: BuildMeta, +) : Presenter { + @AssistedFactory + interface Factory { + fun create(url: String): CreateAccountPresenter + } + + @Composable + override fun present(): CreateAccountState { + val coroutineScope = rememberCoroutineScope() + val pageProgress: MutableState = remember { mutableIntStateOf(0) } + val createAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + + fun handleEvent(event: CreateAccountEvents) { + when (event) { + is CreateAccountEvents.SetPageProgress -> { + pageProgress.value = event.progress + } + is CreateAccountEvents.OnMessageReceived -> { + // Ignore unexpected message + if (event.message.contains("isTrusted")) return + coroutineScope.importSession(event.message, createAction) + } + } + } + + return CreateAccountState( + url = url, + pageProgress = pageProgress.value, + isDebugBuild = buildMeta.isDebuggable, + createAction = createAction.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.importSession(message: String, loggedInState: MutableState>) = launch { + loggedInState.value = AsyncAction.Loading + runCatchingExceptions { + messageParser.parse(message) + }.flatMap { externalSession -> + authenticationService.importCreatedSession(externalSession) + }.onSuccess { sessionId -> + tryOrNull { + // Wait until the session is verified + val client = clientProvider.getOrRestore(sessionId).getOrThrow() + val sessionVerificationService = client.sessionVerificationService + withTimeout(10.seconds) { sessionVerificationService.sessionVerifiedStatus.first { it.isVerified() } } + } + loggedInState.value = AsyncAction.Success(sessionId) + }.onFailure { failure -> + loggedInState.value = AsyncAction.Failure(failure) + } + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountState.kt new file mode 100644 index 0000000..de7efe5 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountState.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.createaccount + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.SessionId + +data class CreateAccountState( + val url: String, + val pageProgress: Int, + val createAction: AsyncAction, + val isDebugBuild: Boolean, + val eventSink: (CreateAccountEvents) -> Unit +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountStateProvider.kt new file mode 100644 index 0000000..976ceae --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountStateProvider.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.createaccount + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.SessionId + +open class CreateAccountStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aCreateAccountState(), + aCreateAccountState(pageProgress = 33), + aCreateAccountState(createAction = AsyncAction.Loading), + aCreateAccountState(createAction = AsyncAction.Failure(RuntimeException("Failed to create account"))), + ) +} + +private fun aCreateAccountState( + pageProgress: Int = 100, + createAction: AsyncAction = AsyncAction.Uninitialized, +) = CreateAccountState( + url = "https://example.com", + isDebugBuild = true, + pageProgress = pageProgress, + createAction = createAction, + eventSink = {} +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountView.kt new file mode 100644 index 0000000..0511886 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountView.kt @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.createaccount + +import android.annotation.SuppressLint +import android.view.ViewGroup +import android.webkit.JsResult +import android.webkit.WebChromeClient +import android.webkit.WebView +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.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.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.login.impl.R +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator +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.progressIndicatorTrackColor +import timber.log.Timber + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateAccountView( + state: CreateAccountState, + onBackClick: () -> Unit, + onOpenExternalUrl: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + titleStr = stringResource(R.string.screen_create_account_title), + navigationIcon = { + BackButton(onClick = onBackClick) + }, + ) + } + ) { contentPadding -> + Box( + modifier = Modifier + .padding(contentPadding) + .consumeWindowInsets(contentPadding) + .fillMaxSize() + ) { + CreateAccountWebView( + modifier = Modifier + .fillMaxSize(), + state = state, + onWebViewCreate = { webView -> + WebViewMessageInterceptor( + webView, + state.isDebugBuild, + onOpenExternalUrl = onOpenExternalUrl, + onMessage = { + state.eventSink(CreateAccountEvents.OnMessageReceived(it)) + }, + ) + } + ) + AnimatedVisibility( + visible = state.pageProgress != 100, + // Disable enter animation + enter = fadeIn(initialAlpha = 1f), + exit = fadeOut(), + ) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(2.dp), + progress = { state.pageProgress / 100f }, + trackColor = ElementTheme.colors.progressIndicatorTrackColor, + ) + } + } + } + + AsyncActionView( + async = state.createAction, + onSuccess = {}, + onErrorDismiss = onBackClick, + onRetry = null + ) +} + +@Composable +private fun CreateAccountWebView( + state: CreateAccountState, + onWebViewCreate: (WebView) -> Unit, + modifier: Modifier = Modifier, +) { + if (LocalInspectionMode.current) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Text("WebView - can't be previewed") + } + } else { + AndroidView( + modifier = modifier, + factory = { context -> + WebView(context).apply { + onWebViewCreate(this) + setup(state) + } + }, + update = { webView -> + if (webView.url != state.url) { + webView.loadUrl(state.url) + } + }, + onRelease = { webView -> + webView.destroy() + } + ) + } +} + +@SuppressLint("SetJavaScriptEnabled") +private fun WebView.setup(state: CreateAccountState) { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + with(settings) { + javaScriptEnabled = true + domStorageEnabled = true + } + + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + super.onProgressChanged(view, newProgress) + state.eventSink(CreateAccountEvents.SetPageProgress(newProgress)) + } + + override fun onJsBeforeUnload(view: WebView?, url: String?, message: String?, result: JsResult?): Boolean { + Timber.w("onJsBeforeUnload, cancelling the dialog, we will open external links in a Custom Chrome Tab") + result?.confirm() + return true + } + } +} + +@PreviewsDayNight +@Composable +internal fun CreateAccountViewPreview(@PreviewParameter(CreateAccountStateProvider::class) state: CreateAccountState) = ElementPreview { + CreateAccountView( + state = state, + onBackClick = {}, + onOpenExternalUrl = {}, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MessageParser.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MessageParser.kt new file mode 100644 index 0000000..d795437 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MessageParser.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.createaccount + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.libraries.androidutils.json.JsonProvider +import io.element.android.libraries.matrix.api.auth.external.ExternalSession + +interface MessageParser { + /** + * Parse the message and return the ExternalSession object, or + * throw an exception if the message is invalid. + */ + fun parse(message: String): ExternalSession +} + +@ContributesBinding(AppScope::class) +class DefaultMessageParser( + private val accountProviderDataSource: AccountProviderDataSource, + private val json: JsonProvider, +) : MessageParser { + override fun parse(message: String): ExternalSession { + val response = json().decodeFromString(MobileRegistrationResponse.serializer(), message) + val userId = response.userId ?: error("No user ID in response") + val homeServer = response.homeServer ?: accountProviderDataSource.flow.value.url + val accessToken = response.accessToken ?: error("No access token in response") + val deviceId = response.deviceId ?: error("No device ID in response") + return ExternalSession( + userId = userId, + homeserverUrl = homeServer, + accessToken = accessToken, + deviceId = deviceId, + refreshToken = null, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MobileRegistrationResponse.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MobileRegistrationResponse.kt new file mode 100644 index 0000000..5e78b1b --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MobileRegistrationResponse.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.createaccount + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * For ref: + * https://github.com/element-hq/matrix-react-sdk/pull/42/files#diff-2bbba5a742004fd4e924a639ded444279f66f7ad890cb669fbc91ac6b8638c64R56 + */ +@Serializable +data class MobileRegistrationResponse( + @SerialName("user_id") + val userId: String? = null, + @SerialName("home_server") + val homeServer: String? = null, + @SerialName("access_token") + val accessToken: String? = null, + @SerialName("device_id") + val deviceId: String? = null, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/WebViewMessageInterceptor.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/WebViewMessageInterceptor.kt new file mode 100644 index 0000000..20d5035 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/WebViewMessageInterceptor.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.createaccount + +import android.graphics.Bitmap +import android.webkit.JavascriptInterface +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature + +class WebViewMessageInterceptor( + webView: WebView, + private val debugLog: Boolean, + private val onOpenExternalUrl: (String) -> Unit, + private val onMessage: (String) -> Unit, +) { + companion object { + // We call both the WebMessageListener and the JavascriptInterface objects in JS with this + // 'listenerName' so they can both receive the data from the WebView when + // `${LISTENER_NAME}.postMessage(...)` is called + const val LISTENER_NAME = "elementX" + } + + init { + webView.webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + + // We inject this JS code when the page starts loading to attach a message listener to the window. + view?.evaluateJavascript( + """ + window.addEventListener( + "mobileregistrationresponse", + (event) => { + let json = JSON.stringify(event.detail) + ${"console.log('message sent: ' + json);".takeIf { debugLog }} + $LISTENER_NAME.postMessage(json); + }, + false, + ); + """.trimIndent(), + null + ) + } + + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + request ?: return false + // Load the URL in a Chrome Custom Tab, and return true to cancel the load + onOpenExternalUrl(request.url.toString()) + return true + } + } + + // Use WebMessageListener if supported, otherwise use JavascriptInterface + if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + // Create a WebMessageListener, which will receive messages from the WebView and reply to them + val webMessageListener = WebViewCompat.WebMessageListener { _, message, _, _, _ -> + onMessageReceived(message.data) + } + WebViewCompat.addWebMessageListener( + webView, + LISTENER_NAME, + setOf("*"), + webMessageListener + ) + } else { + webView.addJavascriptInterface( + object { + @JavascriptInterface + fun postMessage(json: String?) { + onMessageReceived(json) + } + }, + LISTENER_NAME, + ) + } + } + + private fun onMessageReceived(json: String?) { + // Here is where we would handle the messages from the WebView, passing them to the listener + json?.let { onMessage(it) } + } +} 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 0000000..25a003b --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordEvents.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 + data object Submit : LoginPasswordEvents + data 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 0000000..c6ce161 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode + +@ContributesNode(AppScope::class) +@AssistedInject +class LoginPasswordNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: LoginPasswordPresenter, +) : Node(buildContext, plugins = plugins) { + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + LoginPasswordView( + state = state, + modifier = modifier, + onBackClick = ::navigateUp, + ) + } +} 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 0000000..b1ddc6e --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenter.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Inject +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.libraries.architecture.AsyncData +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 + +@Inject +class LoginPasswordPresenter( + private val authenticationService: MatrixAuthenticationService, + private val accountProviderDataSource: AccountProviderDataSource, +) : Presenter { + @Composable + override fun present(): LoginPasswordState { + val localCoroutineScope = rememberCoroutineScope() + val loginAction: MutableState> = remember { + mutableStateOf(AsyncData.Uninitialized) + } + + val formState = rememberSaveable { + mutableStateOf(LoginFormState.Default) + } + val accountProvider by accountProviderDataSource.flow.collectAsState() + + fun handleEvent(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 = AsyncData.Uninitialized + } + } + + return LoginPasswordState( + accountProvider = accountProvider, + formState = formState.value, + loginAction = loginAction.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.submit(formState: LoginFormState, loggedInState: MutableState>) = launch { + loggedInState.value = AsyncData.Loading() + authenticationService.login(formState.login.trim(), formState.password) + .onSuccess { sessionId -> + loggedInState.value = AsyncData.Success(sessionId) + } + .onFailure { failure -> + loggedInState.value = AsyncData.Failure(failure) + } + } + + private fun updateFormState(formState: MutableState, 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 0000000..d8adc73 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordState.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.AsyncData +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.parcelize.Parcelize + +data class LoginPasswordState( + val accountProvider: AccountProvider, + val formState: LoginFormState, + val loginAction: AsyncData, + val eventSink: (LoginPasswordEvents) -> Unit +) { + val submitEnabled: Boolean + get() = loginAction !is AsyncData.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 0000000..2183790 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordStateProvider.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.loginpassword + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.accountprovider.anAccountProvider +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.SessionId + +open class LoginPasswordStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aLoginPasswordState(), + // Loading + aLoginPasswordState(loginAction = AsyncData.Loading()), + // Error + aLoginPasswordState(loginAction = AsyncData.Failure(Exception("An error occurred"))), + ) +} + +fun aLoginPasswordState( + accountProvider: AccountProvider = anAccountProvider(), + formState: LoginFormState = LoginFormState.Default, + loginAction: AsyncData = AsyncData.Uninitialized, + eventSink: (LoginPasswordEvents) -> Unit = {}, +) = LoginPasswordState( + accountProvider = accountProvider, + formState = formState, + loginAction = loginAction, + eventSink = eventSink, +) + +fun aLoginFormState( + login: String = "", + password: String = "", +) = LoginFormState( + login = login, + password = password, +) 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 0000000..d3641ea --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt @@ -0,0 +1,298 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.loginpassword + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.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.Modifier +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalAutofillManager +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.error.loginError +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon +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.form.textFieldState +import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.Scaffold +import io.element.android.libraries.designsystem.theme.components.TextField +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoginPasswordView( + state: LoginPasswordState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val autofillManager = LocalAutofillManager.current + + BackHandler { + autofillManager?.cancel() + onBackClick() + } + + val isLoading by remember(state.loginAction) { + derivedStateOf { + state.loginAction is AsyncData.Loading + } + } + val focusManager = LocalFocusManager.current + + fun submit() { + // Clear focus to prevent keyboard issues with textfields + focusManager.clearFocus(force = true) + + autofillManager?.commit() + + state.eventSink(LoginPasswordEvents.Submit) + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + BackButton(onClick = { + autofillManager?.cancel() + onBackClick() + }) + }, + ) + } + ) { padding -> + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxSize() + .imePadding() + .padding(padding) + .consumeWindowInsets(padding) + .verticalScroll(state = scrollState) + .padding(start = 20.dp, end = 20.dp, bottom = 20.dp), + ) { + // Title + IconTitleSubtitleMolecule( + modifier = Modifier.padding(top = 20.dp, start = 16.dp, end = 16.dp), + iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), + 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 + Box( + modifier = Modifier + .padding(horizontal = 16.dp) + ) { + ButtonColumnMolecule { + Button( + text = stringResource(CommonStrings.action_continue), + showProgress = isLoading, + onClick = ::submit, + enabled = state.submitEnabled || isLoading, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.loginContinue) + ) + Spacer(modifier = Modifier.height(48.dp)) + } + } + + if (state.loginAction is AsyncData.Failure) { + LoginErrorDialog(error = state.loginAction.error, onDismiss = { + state.eventSink(LoginPasswordEvents.ClearError) + }) + } + } + } +} + +@Composable +private fun LoginForm( + state: LoginPasswordState, + isLoading: Boolean, + onSubmit: () -> Unit, +) { + 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 { + TextField( + label = stringResource(R.string.screen_login_form_header), + value = loginFieldState, + enabled = !isLoading, + modifier = Modifier + .fillMaxWidth() + .onTabOrEnterKeyFocusNext(focusManager) + .testTag(TestTags.loginEmailUsername) + .semantics { + contentType = ContentType.Username + }, + placeholder = stringResource(CommonStrings.common_username), + onValueChange = { + val sanitized = it.sanitize() + loginFieldState = sanitized + eventSink(LoginPasswordEvents.SetLogin(sanitized)) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions(onNext = { + focusManager.moveFocus(FocusDirection.Down) + }), + singleLine = true, + trailingIcon = if (loginFieldState.isNotEmpty()) { + { + Box(Modifier.clickable { + loginFieldState = "" + eventSink(LoginPasswordEvents.SetLogin("")) + }) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_clear), + tint = ElementTheme.colors.iconSecondary + ) + } + } + } else { + null + }, + ) + var passwordVisible by remember { mutableStateOf(false) } + if (state.loginAction is AsyncData.Loading) { + // Ensure password is hidden when user submits the form + passwordVisible = false + } + Spacer(Modifier.height(20.dp)) + TextField( + value = passwordFieldState, + enabled = !isLoading, + modifier = Modifier + .fillMaxWidth() + .onTabOrEnterKeyFocusNext(focusManager) + .testTag(TestTags.loginPassword) + .semantics { + contentType = ContentType.Password + }, + onValueChange = { + val sanitized = it.sanitize() + passwordFieldState = sanitized + eventSink(LoginPasswordEvents.SetPassword(sanitized)) + }, + placeholder = stringResource(CommonStrings.common_password), + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + val image = + if (passwordVisible) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff() + val description = + if (passwordVisible) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password) + Box(Modifier.clickable { passwordVisible = !passwordVisible }) { + Icon( + imageVector = image, + contentDescription = description, + ) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { onSubmit() } + ), + singleLine = true, + ) + } +} + +/** + * Ensure that the string does not contain any new line characters, which can happen when pasting values. + */ +private fun String.sanitize(): String { + return replace("\n", "") +} + +@Composable +private fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) { + ErrorDialog( + title = stringResource(id = CommonStrings.dialog_title_error), + content = stringResource(loginError(error)), + onSubmit = onDismiss + ) +} + +@PreviewsDayNight +@Composable +internal fun LoginPasswordViewPreview(@PreviewParameter(LoginPasswordStateProvider::class) state: LoginPasswordState) = ElementPreview { + LoginPasswordView( + state = state, + onBackClick = {}, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingEvents.kt new file mode 100644 index 0000000..6101b21 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingEvents.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.onboarding + +sealed interface OnBoardingEvents { + data class OnSignIn( + val defaultAccountProvider: String + ) : OnBoardingEvents + + data object OnVersionClick : OnBoardingEvents + data object ClearError : OnBoardingEvents +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingLogoResIdProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingLogoResIdProvider.kt new file mode 100644 index 0000000..a1a0266 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingLogoResIdProvider.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.onboarding + +import android.annotation.SuppressLint +import android.content.Context +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.annotations.ApplicationContext + +fun interface OnBoardingLogoResIdProvider { + fun get(): Int? +} + +@ContributesBinding(AppScope::class) +class DefaultOnBoardingLogoResIdProvider( + @ApplicationContext private val context: Context, +) : OnBoardingLogoResIdProvider { + @SuppressLint("DiscouragedApi") + override fun get(): Int? { + val resId = context.resources + .getIdentifier("onboarding_logo", "drawable", context.packageName) + .takeIf { it != 0 } + return resId + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt new file mode 100644 index 0000000..1ded677 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.onboarding + +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.login.impl.util.openLearnMorePage +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.matrix.api.auth.OidcDetails + +@ContributesNode(AppScope::class) +@AssistedInject +class OnBoardingNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: OnBoardingPresenter.Factory, +) : Node( + buildContext = buildContext, + plugins = plugins +) { + interface Callback : Plugin { + fun navigateToSignUpFlow() + fun navigateToSignInFlow(mustChooseAccountProvider: Boolean) + fun navigateToQrCode() + fun navigateToBugReport() + fun navigateToLoginPassword() + fun navigateToOidc(oidcDetails: OidcDetails) + fun navigateToCreateAccount(url: String) + fun onDone() + } + + data class Params( + val accountProvider: String?, + val loginHint: String?, + ) : NodeInputs + + private val callback: Callback = callback() + private val params = inputs() + + private val presenter = presenterFactory.create( + params = params, + ) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val context = LocalContext.current + OnBoardingView( + state = state, + modifier = modifier, + onSignIn = callback::navigateToSignInFlow, + onCreateAccount = callback::navigateToSignUpFlow, + onSignInWithQrCode = callback::navigateToQrCode, + onReportProblem = callback::navigateToBugReport, + onOidcDetails = callback::navigateToOidc, + onNeedLoginPassword = callback::navigateToLoginPassword, + onLearnMoreClick = { openLearnMorePage(context) }, + onCreateAccountContinue = callback::navigateToCreateAccount, + onBackClick = callback::onDone, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt new file mode 100644 index 0000000..4d83c45 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.onboarding + +import androidx.compose.runtime.Composable +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.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.appconfig.OnBoardingConfig +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.api.canConnectToAnyHomeserver +import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.login.LoginHelper +import io.element.android.features.rageshake.api.RageshakeFeatureAvailability +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.ui.utils.MultipleTapToUnlock +import kotlinx.coroutines.launch + +@AssistedInject +class OnBoardingPresenter( + @Assisted private val params: OnBoardingNode.Params, + private val buildMeta: BuildMeta, + private val enterpriseService: EnterpriseService, + private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl, + private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, + private val loginHelper: LoginHelper, + private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider, + private val sessionStore: SessionStore, + private val accountProviderDataSource: AccountProviderDataSource, +) : Presenter { + @AssistedFactory + interface Factory { + fun create( + params: OnBoardingNode.Params, + ): OnBoardingPresenter + } + + private val multipleTapToUnlock = MultipleTapToUnlock() + + @Composable + override fun present(): OnBoardingState { + val localCoroutineScope = rememberCoroutineScope() + val forcedAccountProvider = remember { + // If defaultHomeserverList() returns a singleton list, this is the default account provider. + // In this case, the user can sign in using this homeserver, or use QrCode login + enterpriseService.defaultHomeserverList().singleOrNull() + } + val canConnectToAnyHomeserver = remember { + enterpriseService.canConnectToAnyHomeserver() + } + val mustChooseAccountProvider = remember { + !canConnectToAnyHomeserver && enterpriseService.defaultHomeserverList().size > 1 + } + val linkAccountProvider by produceState(initialValue = null) { + // Account provider from the link, if allowed by the enterprise service + value = params.accountProvider?.takeIf { + try { + defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(it, it) + true + } catch (_: Exception) { + false + } + } + } + val defaultAccountProvider = remember(linkAccountProvider) { + // If there is a forced account provider, this is the default account provider + // Else use the account provider passed in the params if any and if allowed + forcedAccountProvider ?: linkAccountProvider + } + val canLoginWithQrCode by produceState(initialValue = false, linkAccountProvider) { + value = linkAccountProvider == null + } + val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false) + var showReportBug by rememberSaveable { mutableStateOf(false) } + val onBoardingLogoResId = remember { + onBoardingLogoResIdProvider.get() + } + val isAddingAccount by produceState(initialValue = false) { + // We are adding an account if there is at least one session already stored + value = sessionStore.numberOfSessions() > 0 + } + + val loginMode by loginHelper.collectLoginMode() + + fun handleEvent(event: OnBoardingEvents) { + when (event) { + is OnBoardingEvents.OnSignIn -> localCoroutineScope.launch { + // Ensure that the current account provider is set + accountProviderDataSource.setUrl(event.defaultAccountProvider) + loginHelper.submit( + isAccountCreation = false, + homeserverUrl = event.defaultAccountProvider, + loginHint = params.loginHint?.takeIf { forcedAccountProvider == null }, + ) + } + OnBoardingEvents.ClearError -> loginHelper.clearError() + OnBoardingEvents.OnVersionClick -> { + if (canReportBug) { + if (multipleTapToUnlock.unlock(localCoroutineScope)) { + showReportBug = true + } + } + } + } + } + + return OnBoardingState( + isAddingAccount = isAddingAccount, + productionApplicationName = buildMeta.productionApplicationName, + defaultAccountProvider = defaultAccountProvider, + mustChooseAccountProvider = mustChooseAccountProvider, + canLoginWithQrCode = canLoginWithQrCode, + canCreateAccount = defaultAccountProvider == null && canConnectToAnyHomeserver && OnBoardingConfig.CAN_CREATE_ACCOUNT, + canReportBug = canReportBug && showReportBug, + loginMode = loginMode, + version = buildMeta.versionName, + onBoardingLogoResId = onBoardingLogoResId, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt new file mode 100644 index 0000000..db6c357 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.onboarding + +import androidx.annotation.DrawableRes +import io.element.android.features.login.impl.login.LoginMode +import io.element.android.libraries.architecture.AsyncData + +data class OnBoardingState( + val isAddingAccount: Boolean, + val productionApplicationName: String, + val defaultAccountProvider: String?, + val mustChooseAccountProvider: Boolean, + val canLoginWithQrCode: Boolean, + val canCreateAccount: Boolean, + val canReportBug: Boolean, + val version: String, + @DrawableRes + val onBoardingLogoResId: Int?, + val loginMode: AsyncData, + val eventSink: (OnBoardingEvents) -> Unit, +) { + val submitEnabled: Boolean + get() = defaultAccountProvider != null && (loginMode is AsyncData.Uninitialized || loginMode is AsyncData.Loading) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt new file mode 100644 index 0000000..d7db27c --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.onboarding + +import androidx.annotation.DrawableRes +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.login.impl.login.LoginMode +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.R + +open class OnBoardingStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anOnBoardingState(), + anOnBoardingState(canLoginWithQrCode = true), + anOnBoardingState(canCreateAccount = true), + anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true), + anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true, canReportBug = true), + anOnBoardingState(defaultAccountProvider = "element.io", canCreateAccount = false, canReportBug = true), + anOnBoardingState(customLogoResId = R.drawable.sample_background), + anOnBoardingState( + isAddingAccount = true, + canLoginWithQrCode = true, + canCreateAccount = true, + ), + ) +} + +fun anOnBoardingState( + isAddingAccount: Boolean = false, + productionApplicationName: String = "Element", + defaultAccountProvider: String? = null, + mustChooseAccountProvider: Boolean = false, + canLoginWithQrCode: Boolean = false, + canCreateAccount: Boolean = false, + canReportBug: Boolean = false, + version: String = "1.0.0", + @DrawableRes + customLogoResId: Int? = null, + loginMode: AsyncData = AsyncData.Uninitialized, + eventSink: (OnBoardingEvents) -> Unit = {}, +) = OnBoardingState( + isAddingAccount = isAddingAccount, + productionApplicationName = productionApplicationName, + defaultAccountProvider = defaultAccountProvider, + mustChooseAccountProvider = mustChooseAccountProvider, + canLoginWithQrCode = canLoginWithQrCode, + canCreateAccount = canCreateAccount, + canReportBug = canReportBug, + version = version, + loginMode = loginMode, + onBoardingLogoResId = customLogoResId, + eventSink = eventSink, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt new file mode 100644 index 0000000..977c6de --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt @@ -0,0 +1,325 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.onboarding + +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.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.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.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.login.LoginModeView +import io.element.android.libraries.architecture.AsyncData +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.FlowStepPage +import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.IconSource +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 +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, + onBackClick: () -> Unit, + onSignInWithQrCode: () -> Unit, + onSignIn: (mustChooseAccountProvider: Boolean) -> Unit, + onCreateAccount: () -> Unit, + onOidcDetails: (OidcDetails) -> Unit, + onNeedLoginPassword: () -> Unit, + onLearnMoreClick: () -> Unit, + onCreateAccountContinue: (url: String) -> Unit, + onReportProblem: () -> Unit, + modifier: Modifier = Modifier, +) { + val loginView = @Composable { + LoginModeView( + loginMode = state.loginMode, + onClearError = { + state.eventSink(OnBoardingEvents.ClearError) + }, + onLearnMoreClick = onLearnMoreClick, + onOidcDetails = onOidcDetails, + onNeedLoginPassword = onNeedLoginPassword, + onCreateAccountContinue = onCreateAccountContinue, + ) + } + val buttons = @Composable { + OnBoardingButtons( + state = state, + onSignInWithQrCode = onSignInWithQrCode, + onSignIn = onSignIn, + onCreateAccount = onCreateAccount, + onReportProblem = onReportProblem, + ) + } + + if (state.isAddingAccount) { + AddOtherAccountScaffold( + modifier = modifier, + loginView = loginView, + buttons = buttons, + onBackClick = onBackClick, + ) + } else { + AddFirstAccountScaffold( + modifier = modifier, + state = state, + loginView = loginView, + buttons = buttons, + ) + } +} + +@Composable +private fun AddFirstAccountScaffold( + state: OnBoardingState, + loginView: @Composable () -> Unit, + buttons: @Composable () -> Unit, + modifier: Modifier = Modifier, +) { + OnBoardingPage( + modifier = modifier, + renderBackground = state.onBoardingLogoResId == null, + content = { + if (state.onBoardingLogoResId != null) { + OnBoardingLogo( + onBoardingLogoResId = state.onBoardingLogoResId, + ) + } else { + OnBoardingContent(state = state) + } + loginView() + }, + footer = { + buttons() + } + ) +} + +@Composable +private fun AddOtherAccountScaffold( + loginView: @Composable () -> Unit, + buttons: @Composable () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + FlowStepPage( + modifier = modifier, + title = stringResource(CommonStrings.common_add_account), + iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()), + buttons = { buttons() }, + content = loginView, + onBackClick = onBackClick, + ) +} + +@Composable +private fun OnBoardingContent(state: OnBoardingState) { + 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.colors.textPrimary, + style = ElementTheme.typography.fontHeadingLgBold, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(id = R.string.screen_onboarding_welcome_message, state.productionApplicationName), + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = 17.sp), + textAlign = TextAlign.Center + ) + } + } + } +} + +@Composable +private fun OnBoardingLogo( + onBoardingLogoResId: Int, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(id = onBoardingLogoResId), + contentDescription = null + ) + } +} + +@Composable +private fun OnBoardingButtons( + state: OnBoardingState, + onSignInWithQrCode: () -> Unit, + onSignIn: (mustChooseAccountProvider: Boolean) -> Unit, + onCreateAccount: () -> Unit, + onReportProblem: () -> Unit, +) { + val isLoading by remember(state.loginMode) { + derivedStateOf { + state.loginMode is AsyncData.Loading + } + } + + ButtonColumnMolecule { + val signInButtonStringRes = if (state.canLoginWithQrCode || state.canCreateAccount) { + R.string.screen_onboarding_sign_in_manually + } else { + CommonStrings.action_continue + } + if (state.canLoginWithQrCode) { + Button( + text = stringResource(id = R.string.screen_onboarding_sign_in_with_qr_code), + leadingIcon = IconSource.Vector(CompoundIcons.QrCode()), + onClick = onSignInWithQrCode, + modifier = Modifier.fillMaxWidth() + ) + } + val defaultAccountProvider = state.defaultAccountProvider + if (defaultAccountProvider == null) { + Button( + text = stringResource(id = signInButtonStringRes), + onClick = { + onSignIn(state.mustChooseAccountProvider) + }, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.onBoardingSignIn) + ) + } else { + Button( + text = stringResource(id = R.string.screen_onboarding_sign_in_to, defaultAccountProvider), + showProgress = isLoading, + onClick = { + state.eventSink(OnBoardingEvents.OnSignIn(defaultAccountProvider)) + }, + enabled = state.submitEnabled || isLoading, + modifier = Modifier + .fillMaxWidth() + ) + } + if (state.canCreateAccount) { + TextButton( + text = stringResource(id = R.string.screen_onboarding_sign_up), + onClick = onCreateAccount, + modifier = Modifier + .fillMaxWidth() + ) + } + if (state.isAddingAccount.not()) { + if (state.canReportBug) { + // Add a report problem text button. Use a Text since we need a special theme here. + Text( + modifier = Modifier + .clickable(onClick = onReportProblem) + .padding(16.dp), + text = stringResource(id = CommonStrings.common_report_a_problem), + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } else { + Text( + modifier = Modifier + .clickable { + state.eventSink(OnBoardingEvents.OnVersionClick) + } + .padding(16.dp), + text = stringResource(id = R.string.screen_onboarding_app_version, state.version), + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun OnBoardingViewPreview( + @PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState +) = ElementPreview { + OnBoardingView( + state = state, + onBackClick = {}, + onSignInWithQrCode = {}, + onSignIn = {}, + onCreateAccount = {}, + onReportProblem = {}, + onOidcDetails = {}, + onNeedLoginPassword = {}, + onLearnMoreClick = {}, + onCreateAccountContinue = {}, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationNode.kt new file mode 100644 index 0000000..b3b5ee8 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationNode.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.confirmation + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.login.impl.di.QrCodeLoginScope +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs + +@ContributesNode(QrCodeLoginScope::class) +@AssistedInject +class QrCodeConfirmationNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : Node(buildContext = buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onCancel() + } + + private val callback: Callback = callback() + private val step = inputs() + + @Composable + override fun View(modifier: Modifier) { + QrCodeConfirmationView( + step = step, + onCancel = callback::onCancel, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationStep.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationStep.kt new file mode 100644 index 0000000..6aacebe --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationStep.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.confirmation + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import io.element.android.libraries.architecture.NodeInputs +import kotlinx.parcelize.Parcelize + +@Immutable +sealed interface QrCodeConfirmationStep : NodeInputs, Parcelable { + @Parcelize + data class DisplayCheckCode(val code: String) : QrCodeConfirmationStep + + @Parcelize + data class DisplayVerificationCode(val code: String) : QrCodeConfirmationStep +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationStepProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationStepProvider.kt new file mode 100644 index 0000000..17864f6 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationStepProvider.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.confirmation + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +class QrCodeConfirmationStepProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + QrCodeConfirmationStep.DisplayCheckCode("12"), + QrCodeConfirmationStep.DisplayVerificationCode("123456"), + QrCodeConfirmationStep.DisplayVerificationCode("123456789"), + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationView.kt new file mode 100644 index 0000000..8de93bc --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationView.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.confirmation + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.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.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.login.impl.R +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun QrCodeConfirmationView( + step: QrCodeConfirmationStep, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = onCancel) + + val icon = when (step) { + is QrCodeConfirmationStep.DisplayCheckCode -> CompoundIcons.Computer() + is QrCodeConfirmationStep.DisplayVerificationCode -> CompoundIcons.LockSolid() + } + val title = when (step) { + is QrCodeConfirmationStep.DisplayCheckCode -> stringResource(R.string.screen_qr_code_login_device_code_title) + is QrCodeConfirmationStep.DisplayVerificationCode -> stringResource(R.string.screen_qr_code_login_verify_code_title) + } + val subtitle = when (step) { + is QrCodeConfirmationStep.DisplayCheckCode -> stringResource(R.string.screen_qr_code_login_device_code_subtitle) + is QrCodeConfirmationStep.DisplayVerificationCode -> stringResource(R.string.screen_qr_code_login_verify_code_subtitle) + } + FlowStepPage( + modifier = modifier, + iconStyle = BigIcon.Style.Default(icon), + title = title, + subTitle = subtitle, + content = { Content(step = step) }, + buttons = { Buttons(onCancel = onCancel) } + ) +} + +@Composable +private fun Content(step: QrCodeConfirmationStep) { + Column( + modifier = Modifier.padding(top = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (step) { + is QrCodeConfirmationStep.DisplayCheckCode -> { + Digits(code = step.code) + Spacer(modifier = Modifier.height(32.dp)) + WaitingForOtherDevice() + } + is QrCodeConfirmationStep.DisplayVerificationCode -> { + Digits(code = step.code) + Spacer(modifier = Modifier.height(32.dp)) + WaitingForOtherDevice() + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun Digits(code: String) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + code.forEach { + Text( + modifier = Modifier + .padding(horizontal = 6.dp, vertical = 4.dp) + .clip(RoundedCornerShape(4.dp)) + .background(ElementTheme.colors.bgActionSecondaryPressed) + .padding(horizontal = 16.dp, vertical = 17.dp), + text = it.toString() + ) + } + } +} + +@Composable +private fun WaitingForOtherDevice() { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + CircularProgressIndicator( + modifier = Modifier + .size(20.dp) + .padding(2.dp), + strokeWidth = 2.dp, + ) + Text( + text = stringResource(R.string.screen_qr_code_login_verify_code_loading), + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun Buttons( + onCancel: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(CommonStrings.action_cancel), + onClick = onCancel + ) + } +} + +@PreviewsDayNight +@Composable +internal fun QrCodeConfirmationViewPreview(@PreviewParameter(QrCodeConfirmationStepProvider::class) step: QrCodeConfirmationStep) { + ElementPreview { + QrCodeConfirmationView( + step = step, + onCancel = {}, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorNode.kt new file mode 100644 index 0000000..4dc1e48 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorNode.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.error + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.login.impl.di.QrCodeLoginScope +import io.element.android.features.login.impl.qrcode.QrCodeErrorScreenType +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.core.meta.BuildMeta + +@ContributesNode(QrCodeLoginScope::class) +@AssistedInject +class QrCodeErrorNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val buildMeta: BuildMeta, +) : Node(buildContext = buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onRetry() + } + + private val callback: Callback = callback() + private val qrCodeErrorScreenType = inputs() + + @Composable + override fun View(modifier: Modifier) { + QrCodeErrorView( + modifier = modifier, + errorScreenType = qrCodeErrorScreenType, + appName = buildMeta.productionApplicationName, + onRetry = callback::onRetry, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorView.kt new file mode 100644 index 0000000..d2ec6ce --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorView.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.error + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.qrcode.QrCodeErrorScreenType +import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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 +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun QrCodeErrorView( + errorScreenType: QrCodeErrorScreenType, + appName: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = onRetry) + FlowStepPage( + modifier = modifier, + iconStyle = BigIcon.Style.AlertSolid, + title = titleText(errorScreenType, appName), + subTitle = subtitleText(errorScreenType, appName), + content = { Content(errorScreenType) }, + buttons = { Buttons(onRetry) }, + ) +} + +@Composable +private fun titleText(errorScreenType: QrCodeErrorScreenType, appName: String) = when (errorScreenType) { + QrCodeErrorScreenType.Cancelled -> stringResource(R.string.screen_qr_code_login_error_cancelled_title) + QrCodeErrorScreenType.Declined -> stringResource(R.string.screen_qr_code_login_error_declined_title) + QrCodeErrorScreenType.Expired -> stringResource(R.string.screen_qr_code_login_error_expired_title) + QrCodeErrorScreenType.ProtocolNotSupported -> stringResource(R.string.screen_qr_code_login_error_linking_not_suported_title) + QrCodeErrorScreenType.InsecureChannelDetected -> stringResource(id = R.string.screen_qr_code_login_connection_note_secure_state_title) + QrCodeErrorScreenType.SlidingSyncNotAvailable -> stringResource(id = R.string.screen_qr_code_login_error_sliding_sync_not_supported_title, appName) + is QrCodeErrorScreenType.UnknownError -> stringResource(CommonStrings.common_something_went_wrong) +} + +@Composable +private fun subtitleText(errorScreenType: QrCodeErrorScreenType, appName: String) = when (errorScreenType) { + QrCodeErrorScreenType.Cancelled -> stringResource(R.string.screen_qr_code_login_error_cancelled_subtitle) + QrCodeErrorScreenType.Declined -> stringResource(R.string.screen_qr_code_login_error_declined_subtitle) + QrCodeErrorScreenType.Expired -> stringResource(R.string.screen_qr_code_login_error_expired_subtitle) + QrCodeErrorScreenType.ProtocolNotSupported -> stringResource(R.string.screen_qr_code_login_error_linking_not_suported_subtitle, appName) + QrCodeErrorScreenType.InsecureChannelDetected -> stringResource(id = R.string.screen_qr_code_login_connection_note_secure_state_description) + QrCodeErrorScreenType.SlidingSyncNotAvailable -> stringResource(id = R.string.screen_qr_code_login_error_sliding_sync_not_supported_subtitle, appName) + is QrCodeErrorScreenType.UnknownError -> stringResource(R.string.screen_qr_code_login_unknown_error_description) +} + +@Composable +private fun ColumnScope.InsecureChannelDetectedError() { + Text( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + text = stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_header), + style = ElementTheme.typography.fontBodyLgMedium, + textAlign = TextAlign.Center, + ) + NumberedListOrganism( + modifier = Modifier.fillMaxSize(), + items = persistentListOf( + AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_1)), + AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_2)), + AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_3)), + ) + ) +} + +@Composable +private fun Content(errorScreenType: QrCodeErrorScreenType) { + when (errorScreenType) { + QrCodeErrorScreenType.InsecureChannelDetected -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + InsecureChannelDetectedError() + } + } + else -> Unit + } +} + +@Composable +private fun Buttons(onRetry: () -> Unit) { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.screen_qr_code_login_start_over_button), + onClick = onRetry + ) +} + +@PreviewsDayNight +@Composable +internal fun QrCodeErrorViewPreview(@PreviewParameter(QrCodeErrorScreenTypeProvider::class) errorScreenType: QrCodeErrorScreenType) { + ElementPreview { + QrCodeErrorView( + errorScreenType = errorScreenType, + appName = "Element X", + onRetry = {} + ) + } +} + +class QrCodeErrorScreenTypeProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + QrCodeErrorScreenType.Cancelled, + QrCodeErrorScreenType.Declined, + QrCodeErrorScreenType.Expired, + QrCodeErrorScreenType.ProtocolNotSupported, + QrCodeErrorScreenType.InsecureChannelDetected, + QrCodeErrorScreenType.SlidingSyncNotAvailable, + QrCodeErrorScreenType.UnknownError + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroEvents.kt new file mode 100644 index 0000000..11abd63 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroEvents.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.intro + +sealed interface QrCodeIntroEvents { + data object Continue : QrCodeIntroEvents +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroNode.kt new file mode 100644 index 0000000..5ff5f09 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroNode.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.intro + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.login.impl.di.QrCodeLoginScope +import io.element.android.libraries.architecture.callback + +@ContributesNode(QrCodeLoginScope::class) +@AssistedInject +class QrCodeIntroNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: QrCodeIntroPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun cancel() + fun navigateToQrCodeScan() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + QrCodeIntroView( + state = state, + onBackClick = callback::cancel, + onContinue = callback::navigateToQrCodeScan, + modifier = modifier + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenter.kt new file mode 100644 index 0000000..4da6448 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenter.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.intro + +import android.Manifest +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 dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsPresenter + +@Inject +class QrCodeIntroPresenter( + private val buildMeta: BuildMeta, + permissionsPresenterFactory: PermissionsPresenter.Factory, +) : Presenter { + private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) + private var pendingPermissionRequest by mutableStateOf(false) + + @Composable + override fun present(): QrCodeIntroState { + val cameraPermissionState = cameraPermissionPresenter.present() + var canContinue by remember { mutableStateOf(false) } + LaunchedEffect(cameraPermissionState.permissionGranted) { + if (cameraPermissionState.permissionGranted && pendingPermissionRequest) { + pendingPermissionRequest = false + canContinue = true + } + } + + fun handleEvent(event: QrCodeIntroEvents) { + when (event) { + QrCodeIntroEvents.Continue -> if (cameraPermissionState.permissionGranted) { + canContinue = true + } else { + pendingPermissionRequest = true + cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) + } + } + } + + return QrCodeIntroState( + appName = buildMeta.applicationName, + desktopAppName = buildMeta.desktopApplicationName, + cameraPermissionState = cameraPermissionState, + canContinue = canContinue, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroState.kt new file mode 100644 index 0000000..d20a3f5 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroState.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.intro + +import io.element.android.libraries.permissions.api.PermissionsState + +data class QrCodeIntroState( + val appName: String, + val desktopAppName: String, + val cameraPermissionState: PermissionsState, + val canContinue: Boolean, + val eventSink: (QrCodeIntroEvents) -> Unit +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroStateProvider.kt new file mode 100644 index 0000000..22c7fd6 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroStateProvider.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.intro + +import android.Manifest +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.permissions.api.PermissionsState +import io.element.android.libraries.permissions.api.aPermissionsState + +open class QrCodeIntroStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aQrCodeIntroState(), + aQrCodeIntroState(cameraPermissionState = aPermissionsState(showDialog = true, permission = Manifest.permission.CAMERA)), + // Add other state here + ) +} + +fun aQrCodeIntroState( + appName: String = "AppName", + desktopAppName: String = "Element", + cameraPermissionState: PermissionsState = aPermissionsState( + showDialog = false, + permission = Manifest.permission.CAMERA, + ), + canContinue: Boolean = false, + eventSink: (QrCodeIntroEvents) -> Unit = {}, +) = QrCodeIntroState( + appName = appName, + desktopAppName = desktopAppName, + cameraPermissionState = cameraPermissionState, + canContinue = canContinue, + eventSink = eventSink +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroView.kt new file mode 100644 index 0000000..767dc49 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroView.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.intro + +import androidx.compose.foundation.layout.ColumnScope +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.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.login.impl.R +import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.utils.annotatedTextWithBold +import io.element.android.libraries.permissions.api.PermissionsView +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun QrCodeIntroView( + state: QrCodeIntroState, + onBackClick: () -> Unit, + onContinue: () -> Unit, + modifier: Modifier = Modifier, +) { + val latestOnContinue by rememberUpdatedState(onContinue) + LaunchedEffect(state.canContinue) { + if (state.canContinue) { + latestOnContinue() + } + } + FlowStepPage( + modifier = modifier, + onBackClick = onBackClick, + iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()), + title = stringResource(id = R.string.screen_qr_code_login_initial_state_title, state.desktopAppName), + subTitle = stringResource(id = R.string.screen_qr_code_login_initial_state_subtitle), + content = { Content(state = state) }, + buttons = { Buttons(state = state) } + ) + + PermissionsView( + title = stringResource(R.string.screen_qr_code_login_no_camera_permission_state_title), + content = stringResource(R.string.screen_qr_code_login_no_camera_permission_state_description, state.appName), + icon = { Icon(imageVector = CompoundIcons.TakePhotoSolid(), contentDescription = null) }, + state = state.cameraPermissionState, + ) +} + +@Composable +private fun Content(state: QrCodeIntroState) { + NumberedListOrganism( + modifier = Modifier.padding(top = 50.dp, start = 20.dp, end = 20.dp), + items = persistentListOf( + AnnotatedString(stringResource(R.string.screen_qr_code_login_initial_state_item_1, state.desktopAppName)), + AnnotatedString(stringResource(R.string.screen_qr_code_login_initial_state_item_2)), + annotatedTextWithBold( + text = stringResource( + id = R.string.screen_qr_code_login_initial_state_item_3, + stringResource(R.string.screen_qr_code_login_initial_state_item_3_action), + ), + boldText = stringResource(R.string.screen_qr_code_login_initial_state_item_3_action) + ), + AnnotatedString(stringResource(R.string.screen_qr_code_login_initial_state_item_4)), + ), + ) +} + +@Composable +private fun ColumnScope.Buttons( + state: QrCodeIntroState, +) { + Button( + text = stringResource(id = R.string.screen_qr_code_login_initial_state_button_title), + modifier = Modifier.fillMaxWidth(), + onClick = { + state.eventSink.invoke(QrCodeIntroEvents.Continue) + } + ) +} + +@PreviewsDayNight +@Composable +internal fun QrCodeIntroViewPreview(@PreviewParameter(QrCodeIntroStateProvider::class) state: QrCodeIntroState) = ElementPreview { + QrCodeIntroView( + state = state, + onBackClick = {}, + onContinue = {}, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanEvents.kt new file mode 100644 index 0000000..f5804ac --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.scan + +sealed interface QrCodeScanEvents { + data class QrCodeScanned(val code: ByteArray) : QrCodeScanEvents + data object TryAgain : QrCodeScanEvents +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanNode.kt new file mode 100644 index 0000000..987221e --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanNode.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.scan + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.login.impl.di.QrCodeLoginScope +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData + +@ContributesNode(QrCodeLoginScope::class) +@AssistedInject +class QrCodeScanNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: QrCodeScanPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun handleScannedCode(qrCodeLoginData: MatrixQrCodeLoginData) + fun cancel() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + QrCodeScanView( + state = state, + onQrCodeDataReady = callback::handleScannedCode, + onBackClick = callback::cancel, + modifier = modifier + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanPresenter.kt new file mode 100644 index 0000000..2f93d5b --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanPresenter.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.scan + +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.setValue +import dev.zacsweers.metro.Inject +import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl +import io.element.android.features.login.impl.qrcode.QrCodeLoginManager +import io.element.android.libraries.architecture.AsyncAction +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.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginDataFactory +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean + +@Inject +class QrCodeScanPresenter( + private val qrCodeLoginDataFactory: MatrixQrCodeLoginDataFactory, + private val qrCodeLoginManager: QrCodeLoginManager, + private val coroutineDispatchers: CoroutineDispatchers, + private val defaultAccountProviderAccessControl: DefaultAccountProviderAccessControl, +) : Presenter { + private var isScanning by mutableStateOf(true) + + private val isProcessingCode = AtomicBoolean(false) + + @Composable + override fun present(): QrCodeScanState { + val coroutineScope = rememberCoroutineScope() + val authenticationAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + + ObserveQRCodeLoginFailures { + authenticationAction.value = AsyncAction.Failure(it) + } + + fun handleEvent(event: QrCodeScanEvents) { + when (event) { + QrCodeScanEvents.TryAgain -> { + isScanning = true + authenticationAction.value = AsyncAction.Uninitialized + } + is QrCodeScanEvents.QrCodeScanned -> { + isScanning = false + coroutineScope.getQrCodeData(authenticationAction, event.code) + } + } + } + + return QrCodeScanState( + isScanning = isScanning, + authenticationAction = authenticationAction.value, + eventSink = ::handleEvent, + ) + } + + @Composable + private fun ObserveQRCodeLoginFailures(onQrCodeLoginError: (QrLoginException) -> Unit) { + LaunchedEffect(onQrCodeLoginError) { + qrCodeLoginManager.currentLoginStep + .onEach { state -> + if (state is QrCodeLoginStep.Failed) { + onQrCodeLoginError(state.error) + // The error was handled here, reset the login state + qrCodeLoginManager.reset() + } + } + .launchIn(this) + } + } + + private fun CoroutineScope.getQrCodeData(codeScannedAction: MutableState>, code: ByteArray) { + if (codeScannedAction.value.isSuccess() || isProcessingCode.compareAndSet(true, true)) return + + launch(coroutineDispatchers.computation) { + suspend { + val data = qrCodeLoginDataFactory.parseQrCodeData(code).onFailure { + Timber.e(it, "Error parsing QR code data") + }.getOrThrow() + val serverName = data.serverName() + if (serverName != null) { + defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider( + title = serverName, + accountProviderUrl = serverName, + ) + } + data + }.runCatchingUpdatingState(codeScannedAction) + }.invokeOnCompletion { + isProcessingCode.set(false) + } + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanState.kt new file mode 100644 index 0000000..0e64bfc --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanState.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.scan + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData + +data class QrCodeScanState( + val isScanning: Boolean, + val authenticationAction: AsyncAction, + val eventSink: (QrCodeScanEvents) -> Unit +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanStateProvider.kt new file mode 100644 index 0000000..0d467ef --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanStateProvider.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.scan + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.login.impl.changeserver.AccountProviderAccessException +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException + +open class QrCodeScanStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aQrCodeScanState(), + aQrCodeScanState(isScanning = false, authenticationAction = AsyncAction.Loading), + aQrCodeScanState(isScanning = false, authenticationAction = AsyncAction.Failure(Exception("Error"))), + aQrCodeScanState(isScanning = false, authenticationAction = AsyncAction.Failure(QrLoginException.OtherDeviceNotSignedIn)), + aQrCodeScanState( + isScanning = false, + authenticationAction = AsyncAction.Failure( + AccountProviderAccessException.UnauthorizedAccountProviderException( + unauthorisedAccountProviderTitle = "example.com", + authorisedAccountProviderTitles = listOf("element.io", "element.org"), + ) + ) + ), + aQrCodeScanState( + isScanning = false, + authenticationAction = AsyncAction.Failure( + AccountProviderAccessException.NeedElementProException( + unauthorisedAccountProviderTitle = "example.com", + applicationId = "applicationId" + ) + ) + ), + // Add other state here + ) +} + +fun aQrCodeScanState( + isScanning: Boolean = true, + authenticationAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (QrCodeScanEvents) -> Unit = {}, +) = QrCodeScanState( + isScanning = isScanning, + authenticationAction = authenticationAction, + eventSink = eventSink +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt new file mode 100644 index 0000000..4f444b1 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.scan + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +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.progressSemantics +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +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.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.changeserver.AccountProviderAccessException +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.modifiers.cornerBorder +import io.element.android.libraries.designsystem.modifiers.squareSize +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException +import io.element.android.libraries.qrcode.QrCodeCameraView + +@Composable +fun QrCodeScanView( + state: QrCodeScanState, + onBackClick: () -> Unit, + onQrCodeDataReady: (MatrixQrCodeLoginData) -> Unit, + modifier: Modifier = Modifier, +) { + val updatedOnQrCodeDataReady by rememberUpdatedState(onQrCodeDataReady) + // QR code data parsed successfully, notify the parent node + if (state.authenticationAction is AsyncAction.Success) { + LaunchedEffect(state.authenticationAction, updatedOnQrCodeDataReady) { + updatedOnQrCodeDataReady(state.authenticationAction.data) + } + } + + FlowStepPage( + modifier = modifier, + onBackClick = onBackClick, + iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()), + title = stringResource(R.string.screen_qr_code_login_scanning_state_title), + content = { Content(state = state) }, + buttons = { Buttons(state = state) } + ) +} + +@Composable +private fun Content( + state: QrCodeScanState, +) { + BoxWithConstraints( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + val modifier = if (constraints.maxWidth > constraints.maxHeight) { + Modifier.fillMaxHeight() + } else { + Modifier.fillMaxWidth() + }.then( + Modifier + .padding(start = 20.dp, end = 20.dp, top = 50.dp, bottom = 32.dp) + .squareSize() + .cornerBorder( + strokeWidth = 4.dp, + color = ElementTheme.colors.textPrimary, + cornerSizeDp = 42.dp, + ) + ) + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + QrCodeCameraView( + modifier = Modifier.fillMaxSize(), + onScanQrCode = { state.eventSink.invoke(QrCodeScanEvents.QrCodeScanned(it)) }, + renderPreview = state.isScanning, + ) + } + } +} + +@Composable +private fun ColumnScope.Buttons( + state: QrCodeScanState, +) { + Column(Modifier.heightIn(min = 130.dp)) { + when (state.authenticationAction) { + is AsyncAction.Failure -> { + Button( + text = stringResource(id = R.string.screen_qr_code_login_invalid_scan_state_retry_button), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + onClick = { + state.eventSink.invoke(QrCodeScanEvents.TryAgain) + } + ) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + val error = state.authenticationAction.error + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = CompoundIcons.ErrorSolid(), + tint = ElementTheme.colors.iconCriticalPrimary, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = when (error) { + is AccountProviderAccessException.NeedElementProException -> { + stringResource(R.string.screen_change_server_error_element_pro_required_title) + } + is AccountProviderAccessException.UnauthorizedAccountProviderException -> { + stringResource( + id = R.string.screen_change_server_error_unauthorized_homeserver_title, + error.unauthorisedAccountProviderTitle, + ) + } + is QrLoginException.OtherDeviceNotSignedIn -> { + stringResource(R.string.screen_qr_code_login_device_not_signed_in_scan_state_subtitle) + } + else -> stringResource(R.string.screen_qr_code_login_invalid_scan_state_subtitle) + }, + textAlign = TextAlign.Center, + color = ElementTheme.colors.textCriticalPrimary, + style = ElementTheme.typography.fontBodySmMedium, + ) + } + Text( + text = when (error) { + is AccountProviderAccessException.NeedElementProException -> { + stringResource( + R.string.screen_change_server_error_element_pro_required_message, + error.unauthorisedAccountProviderTitle, + ) + } + is AccountProviderAccessException.UnauthorizedAccountProviderException -> { + stringResource( + id = R.string.screen_change_server_error_unauthorized_homeserver_content, + error.authorisedAccountProviderTitles.joinToString(), + ) + } + is QrLoginException.OtherDeviceNotSignedIn -> { + stringResource(R.string.screen_qr_code_login_device_not_signed_in_scan_state_description) + } + else -> stringResource(R.string.screen_qr_code_login_invalid_scan_state_description) + }, + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } + AsyncAction.Loading, is AsyncAction.Success -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(20.dp), + strokeWidth = 2.dp + ) + Text( + text = stringResource(R.string.screen_qr_code_login_connecting_subtitle), + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } + AsyncAction.Uninitialized, + is AsyncAction.Confirming -> Unit + } + } +} + +@PreviewsDayNight +@Composable +internal fun QrCodeScanViewPreview(@PreviewParameter(QrCodeScanStateProvider::class) state: QrCodeScanState) = ElementPreview { + QrCodeScanView( + state = state, + onQrCodeDataReady = {}, + onBackClick = {}, + ) +} 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 0000000..8816de3 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderEvents.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..ddbcc4c --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.login.impl.util.openLearnMorePage +import io.element.android.libraries.architecture.callback + +@ContributesNode(AppScope::class) +@AssistedInject +class SearchAccountProviderNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: SearchAccountProviderPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onDone() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val context = LocalContext.current + SearchAccountProviderView( + state = state, + modifier = modifier, + onBackClick = ::navigateUp, + onLearnMoreClick = { openLearnMorePage(context) }, + onSuccess = callback::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 0000000..2efe450 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenter.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Inject +import io.element.android.features.login.impl.changeserver.ChangeServerState +import io.element.android.features.login.impl.resolver.HomeserverData +import io.element.android.features.login.impl.resolver.HomeserverResolver +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Inject +class SearchAccountProviderPresenter( + private val homeserverResolver: HomeserverResolver, + private val changeServerPresenter: Presenter, +) : Presenter { + @Composable + override fun present(): SearchAccountProviderState { + var userInput by rememberSaveable { + mutableStateOf("") + } + val changeServerState = changeServerPresenter.present() + + val data: MutableState>> = remember { + mutableStateOf(AsyncData.Uninitialized) + } + + LaunchedEffect(userInput) { + onUserInput(userInput, data) + } + + fun handleEvent(event: SearchAccountProviderEvents) { + when (event) { + is SearchAccountProviderEvents.UserInput -> { + userInput = event.input + } + } + } + + return SearchAccountProviderState( + userInput = userInput, + userInputResult = data.value, + changeServerState = changeServerState, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.onUserInput(userInput: String, data: MutableState>>) = launch { + data.value = AsyncData.Uninitialized + // Debounce + delay(500) + data.value = AsyncData.Loading() + homeserverResolver.resolve(userInput).collect { + data.value = AsyncData.Success(it) + } + if (data.value !is AsyncData.Success) { + data.value = AsyncData.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 0000000..dc02a99 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderState.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.AsyncData + +data class SearchAccountProviderState( + val userInput: String, + val userInputResult: AsyncData>, + 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 0000000..2a8512c --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.searchaccountprovider + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.appconfig.AuthenticationConfig +import io.element.android.features.login.impl.changeserver.aChangeServerState +import io.element.android.features.login.impl.resolver.HomeserverData +import io.element.android.libraries.architecture.AsyncData + +open class SearchAccountProviderStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aSearchAccountProviderState(), + aSearchAccountProviderState(userInputResult = AsyncData.Success(aHomeserverDataList())), + // Add other state here + ) +} + +fun aSearchAccountProviderState( + userInput: String = "", + userInputResult: AsyncData> = AsyncData.Uninitialized, +) = SearchAccountProviderState( + userInput = userInput, + userInputResult = userInputResult, + changeServerState = aChangeServerState(), + eventSink = {} +) + +fun aHomeserverDataList(): List { + return listOf( + aHomeserverData(homeserverUrl = AuthenticationConfig.MATRIX_ORG_URL), + aHomeserverData(homeserverUrl = "https://no.sliding.sync"), + aHomeserverData(homeserverUrl = "https://invalid"), + ) +} + +fun aHomeserverData( + homeserverUrl: String = AuthenticationConfig.MATRIX_ORG_URL, +): HomeserverData { + return HomeserverData(homeserverUrl = homeserverUrl) +} 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 0000000..55c2c28 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.features.login.impl.screens.searchaccountprovider + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.appconfig.AuthenticationConfig +import io.element.android.compound.tokens.generated.CompoundIcons +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.AsyncData +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.form.textFieldState +import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.TextField +import io.element.android.libraries.designsystem.theme.components.TopAppBar +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, + onBackClick: () -> Unit, + onLearnMoreClick: () -> Unit, + onSuccess: () -> Unit, + modifier: Modifier = Modifier, +) { + val eventSink = state.eventSink + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { BackButton(onClick = onBackClick) } + ) + } + ) { 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), + iconStyle = BigIcon.Style.Default(CompoundIcons.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 + TextField( + 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()) { + { + Box(Modifier.clickable { + userInputState = "" + eventSink(SearchAccountProviderEvents.UserInput("")) + }) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_clear) + ) + } + } + } else { + null + }, + supportingText = stringResource(id = R.string.screen_account_provider_form_notice), + ) + } + + when (state.userInputResult) { + is AsyncData.Failure -> { + // Ignore errors (let the user type more chars) + } + is AsyncData.Loading -> { + item { + Box( + modifier = Modifier + .fillMaxSize() + ) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + } + } + is AsyncData.Success -> { + items(state.userInputResult.data) { homeserverData -> + val item = homeserverData.toAccountProvider() + AccountProviderView( + item = item, + onClick = { + state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(item)) + } + ) + } + } + AsyncData.Uninitialized -> Unit + } + item { + Spacer(Modifier.height(32.dp)) + } + } + ChangeServerView( + state = state.changeServerState, + onLearnMoreClick = onLearnMoreClick, + onSuccess = onSuccess, + ) + } + } +} + +@Composable +private fun HomeserverData.toAccountProvider(): AccountProvider { + val isMatrixOrg = homeserverUrl == AuthenticationConfig.MATRIX_ORG_URL + return AccountProvider( + url = homeserverUrl, + subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null, + // There is no need to know for other servers right now + isPublic = isMatrixOrg, + isMatrixOrg = isMatrixOrg, + ) +} + +@PreviewsDayNight +@Composable +internal fun SearchAccountProviderViewPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) = ElementPreview { + SearchAccountProviderView( + state = state, + onBackClick = {}, + onLearnMoreClick = {}, + onSuccess = {}, + ) +} 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 0000000..06fbf12 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.util + +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import io.element.android.appconfig.AuthenticationConfig +import io.element.android.libraries.core.data.tryOrNull + +fun openLearnMorePage(context: Context) { + val intent = Intent(Intent.ACTION_VIEW, AuthenticationConfig.SLIDING_SYNC_READ_MORE_URL.toUri()) + tryOrNull { context.startActivity(intent) } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/web/WebClientUrlForAuthenticationRetriever.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/web/WebClientUrlForAuthenticationRetriever.kt new file mode 100644 index 0000000..df08cf4 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/web/WebClientUrlForAuthenticationRetriever.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.web + +import androidx.core.net.toUri +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.appconfig.AuthenticationConfig +import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported +import io.element.android.libraries.wellknown.api.WellknownRetriever +import timber.log.Timber + +interface WebClientUrlForAuthenticationRetriever { + suspend fun retrieve(homeServerUrl: String): String +} + +@ContributesBinding(AppScope::class) +class DefaultWebClientUrlForAuthenticationRetriever( + private val wellknownRetriever: WellknownRetriever, +) : WebClientUrlForAuthenticationRetriever { + override suspend fun retrieve(homeServerUrl: String): String { + if (homeServerUrl != AuthenticationConfig.MATRIX_ORG_URL) { + Timber.w("Temporary account creation flow is only supported on matrix.org") + throw AccountCreationNotSupported() + } + val wellknown = wellknownRetriever.getElementWellKnown(homeServerUrl).dataOrNull() + ?: throw AccountCreationNotSupported() + val registrationHelperUrl = wellknown.registrationHelperUrl + return if (registrationHelperUrl != null) { + registrationHelperUrl.toUri() + .buildUpon() + .appendQueryParameter("hs_url", homeServerUrl) + .build() + .toString() + } else { + throw AccountCreationNotSupported() + } + } +} 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 0000000..dbc788a --- /dev/null +++ b/features/login/impl/src/main/res/drawable/ic_matrix.xml @@ -0,0 +1,12 @@ + + + + diff --git a/features/login/impl/src/main/res/raw/keep.xml b/features/login/impl/src/main/res/raw/keep.xml new file mode 100644 index 0000000..b013056 --- /dev/null +++ b/features/login/impl/src/main/res/raw/keep.xml @@ -0,0 +1,13 @@ + + + diff --git a/features/login/impl/src/main/res/values-be/translations.xml b/features/login/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..307ccf7 --- /dev/null +++ b/features/login/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,88 @@ + + + "Змяніць правайдара ўліковага запісу" + "Адрас хатняга сервера" + "Увядзіце пошукавы запыт або адрас дамена." + "Пошук кампаніі, супольнасці або прыватнага сервера." + "Знайдзіце правайдара ўліковага запісу" + "Тут будуць захоўвацца вашыя размовы - сапраўды гэтак жа, як вы выкарыстоўваеце паштовага правайдара для захоўвання сваіх лістоў." + "Вы збіраецеся ўвайсці ў %s" + "Тут будуць захоўвацца вашыя размовы - сапраўды гэтак жа, як вы выкарыстоўваеце паштовага правайдара для захоўвання сваіх лістоў." + "Вы збіраецеся стварыць уліковы запіс на %s" + "Matrix.org - гэта вялікі бясплатны сервер у агульнадаступнай сетцы Matrix для бяспечнай дэцэнтралізаванай сувязі, якім кіруе фонд Matrix.org." + "Іншае" + "Выкарыстоўвайце іншага правайдара ўліковых запісаў, напрыклад, уласны прыватны сервер або працоўны ўліковы запіс." + "Змяніць правайдара ўліковага запісу" + "Нам не ўдалося звязацца з гэтым хатнім серверам. Упэўніцеся, што вы правільна ўвялі URL-адрас хатняга сервера. Калі URL-адрас пазначаны правільна, звярніцеся да адміністратара хатняга сервера за дадатковай дапамогай." + "Sliding sync недаступны з-за праблемы ў вядомым файле: +%1$s" + "URL хатняга сервера" + "Які адрас вашага сервера?" + "Выберыце свой сервер" + "Стварыць уліковы запіс" + "Гэты ўліковы запіс быў дэактываваны." + "Няправільнае імя карыстальніка і/або пароль" + "Гэта несапраўдны ідэнтыфікатар карыстальніка. Чаканы фармат: ‘@user:homeserver.org’" + "Гэты сервер настроены на выкарыстанне маркераў абнаўлення. Яны не падтрымліваюцца пры ўваходзе на аснове пароля." + "Выбраны хатні сервер не падтрымлівае пароль або ўваход у OIDC. Калі ласка, звярніцеся да адміністратара або абярыце іншы хатні сервер." + "Увядзіце свае даныя" + "Matrix - гэта адкрытая сетка для бяспечнай, дэцэнтралізаванай сувязі." + "Сардэчна запрашаем!" + "Увайсці ў %1$s" + "Увайсці ўручную" + "Увайсці ў %1$s" + "Увайсці з QR-кодам" + "Стварыць уліковы запіс" + "Сардэчна запрашаем у самы хуткі %1$s. Перавага ў хуткасці і прастаце." + "Сардэчна запрашаем у %1$s. Зараджаны, для хуткасці і прастаты." + "Будзьце ў сваім element" + "Ўсталяванне бяспечнага злучэння" + "Не атрымалася ўсталяваць бяспечнае злучэнне з новай прыладай. Існуючыя прылады па-ранейшаму ў бяспецы, і вам не трэба турбавацца пра іх." + "Што зараз?" + "Паспрабуйце зноў увайсці ў сістэму з дапамогай QR-кода, калі гэта была сеткавая праблема" + "Калі вы сутыкнуліся з той жа праблемай, паспрабуйце іншую сетку Wi-Fi або скарыстайцеся мабільнымі дадзенымі замест Wi-Fi." + "Калі гэта не дапамагло, увайдзіце ўручную" + "Злучэнне небяспечнае" + "Вам будзе прапанавана ўвесці дзве лічбы, паказаныя на гэтай прыладзе." + "Увядзіце наступны нумар на іншай прыладзе." + "Увайдзіце ў сістэму на іншай прыладзе і паспрабуйце яшчэ раз, або выкарыстоўвайце іншую прыладу на якой ужо выкананы ўваход." + "Другая прылада не ўвайшла ў сістэму" + "Уваход быў адменены на іншай прыладзе." + "Запыт на ўваход скасаваны" + "Уваход на іншай прыладзе быў адхілены." + "Уваход адхілены" + "Тэрмін уваходу скончыўся. Калі ласка, паспрабуйце яшчэ раз." + "Уваход у сістэму не быў завершаны своечасова" + "Ваша іншая прылада не падтрымлівае ўваход у %s з дапамогай QR-кода. + +Паспрабуйце ўвайсці ў сістэму ўручную або адсканіруйце QR-код з дапамогай іншай прылады." + "QR-код не падтрымліваецца" + "Ваш правайдар уліковага запісу не падтрымлівае %1$s." + "%1$s не падтрымліваецца" + "Гатовы да сканіравання" + "Адкрыйце %1$s на настольнай прыладзе" + "Націсніце на свой аватар" + "Выберыце %1$s" + "“Звязаць новую прыладу”" + "Адсканіруйце QR-код з дапамогай гэтай прылады" + "Даступна толькі ў тым выпадку, калі ваш правайдар уліковага запісу гэта падтрымлівае." + "Адкрыйце %1$s на іншай прыладзе, каб атрымаць QR-код" + "Выкарыстоўвайце QR-код, паказаны на іншай прыладзе." + "Паўтарыць спробу" + "Няправільны QR-код" + "Перайсці ў налады камеры" + "Каб працягнуць, вам неабходна дазволіць %1$s выкарыстоўваць камеру вашай прылады." + "Дазвольце доступ да камеры для сканіравання QR-кода" + "Сканіраваць QR-код" + "Пачаць спачатку" + "Адбылася нечаканая памылка. Калі ласка, паспрабуйце яшчэ раз." + "У чаканні іншай прылады" + "Ваш правайдэр уліковага запісу можа запытаць наступны код для праверкі ўваходу." + "Ваш код спраўджання" + "Змяніць правайдара ўліковага запісу" + "Прыватны сервер для супрацоўнікаў Element." + "Matrix - гэта адкрытая сетка для бяспечнай, дэцэнтралізаванай сувязі." + "Тут будуць захоўвацца вашыя размовы - сапраўды гэтак жа, як вы выкарыстоўваеце паштовага правайдара для захоўвання сваіх лістоў." + "Вы збіраецеся ўвайсці ў %1$s" + "Вы збіраецеся стварыць уліковы запіс на %1$s" + diff --git a/features/login/impl/src/main/res/values-bg/translations.xml b/features/login/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..6ccc7c9 --- /dev/null +++ b/features/login/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,44 @@ + + + "Промяна на доставчика на акаунт" + "Адрес на сървъра" + "Въведете термин за търсене или адрес на домейн." + "Потърсете компания, общност или частен сървър." + "Намерете доставчик на акаунт" + "Това е мястото, където ще живеят вашите разговори — точно както бихте използвали имейл доставчик, за да съхранявате вашите имейли." + "На път сте да влезете в %s" + "Това е мястото, където ще живеят вашите разговори — точно както бихте използвали имейл доставчик, за да съхранявате вашите имейли." + "На път сте да създадете акаунт в %s" + "Matrix.org е голям, безплатен сървър в публичната мрежа на Matrix за сигурна, децентрализирана комуникация, управляван от фондация Matrix.org." + "Друг" + "Използвайте друг доставчик на акаунт, като например собствен частен сървър или работен акаунт." + "Промяна на доставчика на акаунт" + "Не можахме да достигнем този сървър. Моля, проверете дали сте въвели правилно URL адреса на сървъра. Ако URL адресът е правилен, свържете се с администратора на вашия сървър за допълнителна помощ." + "URL адрес на сървъра" + "Какъв е адресът на вашия сървър?" + "Изберете своя сървър" + "Създаване на акаунт" + "Този акаунт бе деактивиран." + "Неправилно потребителско име и/или парола" + "Това не е валиден потребителски идентификатор. Очакван формат: ‘@user:homeserver.org’" + "Избраният сървър не поддържа влизане с парола или OIDC. Моля, свържете се с вашия администратор или изберете друг сървър." + "Въведете своите данни" + "Matrix е отворена мрежа за сигурна, децентрализирана комуникация." + "Добре дошли отново!" + "Влизане в %1$s" + "Влизане ръчно" + "Влизане в %1$s" + "Влизане с QR код" + "Създаване на акаунт" + "Добре дошли в най-бързия %1$s досега. Супер зареден за скорост и простота." + "Добре дошли в %1$s. Супер зареден за скорост и простота." + "Бъдете в стихията си" + "Повторен опит" + "Вашият код за потвърждение" + "Промяна на доставчика на акаунт" + "Частен сървър за служителите на Element." + "Matrix е отворена мрежа за сигурна, децентрализирана комуникация." + "Това е мястото, където ще живеят вашите разговори — точно както бихте използвали имейл доставчик, за да съхранявате вашите имейли." + "На път сте да влезете в %1$s" + "На път сте да създадете акаунт в %1$s" + 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 0000000..6ffd9a8 --- /dev/null +++ b/features/login/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,98 @@ + + + "Změnit poskytovatele účtu" + "Adresa domovského serveru" + "Zadejte hledaný výraz nebo adresu domény." + "Vyhledejte společnost, komunitu nebo soukromý server." + "Najít poskytovatele účtu" + "Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily." + "Chystáte se přihlásit do %s" + "Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily." + "Chystáte se vytvořit účet na %s" + "Matrix.org je velký bezplatný server ve veřejné síti Matrix pro bezpečnou decentralizovanou komunikaci, který provozuje nadace Matrix.org." + "Jiný" + "Použijte jiného poskytovatele účtu, například vlastní soukromý server nebo pracovní účet." + "Změnit poskytovatele účtu" + "Google Play" + "Na %1$s je vyžadována aplikace Element Pro. Stáhněte si ji prosím z obchodu." + "Vyžadován Element Pro" + "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." + "Server není k dispozici kvůli problému se souborem well-known: +%1$s" + "Vybraný poskytovatel účtu nepodporuje klouzavou synchronizaci. Pro použití %1$s je nutná aktualizace serveru." + "Uživateli %1$s není dovoleno se připojit do %2$s." + "Tato aplikace byla nakonfigurována tak, aby umožňovala: %1$s." + "Poskytovatel účtu %1$s není povolen." + "Adresa URL domovského serveru" + "Zadejte adresu domény." + "Jaká je adresa vašeho serveru?" + "Vyberte váš server" + "Vytvořit účet" + "Tento účet byl deaktivován." + "Nesprávné uživatelské jméno nebo heslo" + "Toto není platný identifikátor uživatele. Očekávaný formát: \'@user:homeserver.org\'" + "Tento server je nakonfigurován tak, aby používal obnovovací tokeny. Ty nejsou podporovány při použití přihlašovacích údajů založených na hesle." + "Vybraný domovský server nepodporuje přihlášení pomocí hesla nebo OIDC. Kontaktujte prosím svého správce nebo vyberte jiný domovský server." + "Zadejte své údaje" + "Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci." + "Vítejte zpět!" + "Přihlaste se k %1$s" + "Verze %1$s" + "Ruční přihlášení" + "Přihlaste se k %1$s" + "Přihlásit se pomocí QR kódu" + "Vytvořit účet" + "Vítejte v dosud nejrychlejším %1$su. Vylepšený pro rychlost a jednoduchost." + "Vítejte v %1$su. Vylepšený, pro rychlost a jednoduchost." + "Buďte ve svém živlu" + "Navazování zabezpečeného spojení" + "K novému zařízení se nepodařilo navázat bezpečné připojení. Vaše stávající zařízení jsou stále v bezpečí a nemusíte se o ně obávat." + "Co teď?" + "Zkuste se znovu přihlásit pomocí QR kódu v případě, že se jednalo o problém se sítí" + "Pokud narazíte na stejný problém, zkuste jinou síť wifi nebo použijte mobilní data místo wifi" + "Pokud to nefunguje, přihlaste se ručně" + "Připojení není zabezpečené" + "Budete požádáni o zadání dvou níže uvedených číslic." + "Zadejte níže uvedené číslo na svém dalším zařízení" + "Přihlaste se k druhému zařízení a zkuste to znovu, nebo použijte jiné zařízení, které už je přihlášené." + "Druhé zařízení není přihlášeno" + "Přihlášení bylo na druhém zařízení zrušeno." + "Žádost o přihlášení zrušena" + "Přihlášení bylo na druhém zařízení odmítnuto." + "Přihlášení odmítnuto" + "Platnost přihlášení vypršela. Zkuste to prosím znovu." + "Přihlášení nebylo dokončeno včas" + "Vaše druhé zařízení nepodporuje přihlášení k %su pomocí QR kódu. + +Zkuste se přihlásit ručně nebo naskenujte QR kód pomocí jiného zařízení." + "QR kód není podporován" + "Váš poskytovatel účtu nepodporuje %1$s." + "%1$s není podporováno" + "Připraveno ke skenování" + "Otevřete %1$s na stolním počítači" + "Klikněte na svůj avatar" + "Vybrat %1$s" + "\"Připojit nové zařízení\"" + "Naskenujte QR kód pomocí tohoto zařízení" + "Dostupné pouze v případě, že to váš poskytovatel účtu podporuje." + "Otevřete %1$s na jiném zařízení pro získání QR kódu" + "Použijte QR kód zobrazený na druhém zařízení." + "Zkusit znovu" + "Špatný QR kód" + "Přejděte na nastavení fotoaparátu" + "Abyste mohli pokračovat, musíte aplikaci %1$s udělit povolení k použití kamery vašeho zařízení." + "Povolte přístup k fotoaparátu a naskenujte QR kód" + "Naskenujte QR kód" + "Začít znovu" + "Vyskytla se neočekávaná chyba. Prosím zkuste to znovu." + "Čekání na vaše další zařízení" + "Váš poskytovatel účtu může požádat o následující kód pro ověření přihlášení." + "Váš ověřovací kód" + "Změnit poskytovatele účtu" + "Soukromý server pro zaměstnance Elementu." + "Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci." + "Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily." + "Chystáte se přihlásit do služby %1$s" + "Vyberte poskytovatele účtu" + "Chystáte se vytvořit účet na %1$s" + diff --git a/features/login/impl/src/main/res/values-cy/translations.xml b/features/login/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..b8988a9 --- /dev/null +++ b/features/login/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,98 @@ + + + "Newid darparwr cyfrif" + "Cyfeiriad y gweinydd cartref" + "Rhowch derm chwilio neu gyfeiriad parth." + "Chwiliwch am gwmni, gweinyddwr cymunedol neu weinydd preifat." + "Canfod darparwr cyfrif" + "Dyma lle bydd eich sgyrsiau\'n byw - yn union fel y byddech chi\'n defnyddio darparwr e-bost i gadw\'ch e-byst." + "Rydych ar fin mewngofnodi i %s" + "Dyma lle bydd eich sgyrsiau\'n byw - yn union fel y byddech chi\'n defnyddio darparwr e-bost i gadw\'ch e-byst." + "Rydych chi ar fin creu cyfrif ar %s" + "Mae Matrix.org yn weinydd mawr, rhad ac am ddim ar y rhwydwaith Matrix cyhoeddus ar gyfer cyfathrebu diogel, datganoledig, sy\'n cael ei redeg gan y Matrix.org Foundation." + "Arall" + "Defnyddiwch ddarparwr cyfrif gwahanol, fel eich gweinydd preifat eich hun neu gyfrif gwaith." + "Newid darparwr cyfrif" + "Google Play" + "Mae angen yr ap Element Pro ar %1$s. Llwythwch ef o\'r siop." + "Mae angen Element Pro" + "Doedd dim modd i ni gyrraedd y gweinydd cartref hwn. Gwiriwch eich bod wedi rhoi URL y gweinydd cartref yn gywir. Os yw\'r URL yn gywir, cysylltwch â gweinyddwr eich gweinydd cartref am ragor o help." + "Dyw cydweddu llithrig ddim ar gael oherwydd problem yn y ffeil .well-known: +%1$s" + "Dyw\'r darparwr cyfrif hwn ddim yn cefnogi cydweddu llithro. Mae angen uwchraddio\'r gweinydd i ddefnyddio %1$s." + "Does dim caniatâd i %1$s gysylltu â %2$s." + "Mae\'r ap hwn wedi\'i ffurfweddu i ganiatáu: %1$s." + "Dyw darparwr cyfrif %1$s dddim yn cael ei ganiatáu." + "URL y Gweinydd Cartref" + "Rhowch gyfeiriad parth." + "Beth yw cyfeiriad eich gweinydd?" + "Dewiswch eich gweinydd" + "Creu cyfrif" + "Mae\'r cyfrif hwn wedi\'i gau." + "Enw defnyddiwr a/neu gyfrinair anghywir" + "Nid yw hwn yn ddynodwr defnyddiwr dilys. Fformat disgwyliedig: ‘@user:homeserver.org’" + "Mae\'r gweinydd hwn wedi\'i ffurfweddu i ddefnyddio tocynnau adnewyddu. Nid yw\'r rhain yn cael eu cefnogi wrth ddefnyddio mewngofnodi ar sail cyfrinair." + "Nid yw\'r gweinydd cartref ddewiswyd yn cefnogi cyfrinair na mewngofnodi OIDC. Cysylltwch â\'ch gweinyddwr neu dewis gweinydd cartref arall." + "Rhowch eich manylion" + "Mae Matrix yn rhwydwaith agored ar gyfer cyfathrebu diogel, datganoledig." + "Croeso nôl!" + "Mewngofnodi i %1$s" + "Fersiwn %1$s" + "Mewngofnodwch â llaw" + "Mewngofnodi i %1$s" + "Mewngofnodwch gyda chod QR" + "Creu cyfrif" + "Croeso i\'r %1$s cyflymaf erioed. Yn nodedig am gyflymder a symlrwydd." + "Croeso i %1$s. Yn nodedig ar gyfer cyflymder a symlrwydd." + "Byddwch yn eich elfen" + "Yn creu cysylltiad diogel" + "Nid oedd modd gwneud cysylltiad diogel â\'r ddyfais newydd. Mae eich dyfeisiau presennol yn dal yn ddiogel a does dim angen i chi boeni amdanyn nhw." + "Beth nawr?" + "Ceisiwch fewngofnodi eto gyda chod QR rhag ofn bod hyn yn broblem rhwydwaith" + "Os ydych chi\'n dod ar draws yr un broblem, rhowch gynnig ar rwydwaith wifi gwahanol neu defnyddiwch eich data symudol yn lle wifi" + "Os nad yw hynny\'n gweithio, mewngofnodwch â llaw" + "Nid yw\'r cysylltiad yn ddiogel" + "Bydd gofyn i chi nodi\'r ddau ddigid sy\'n cael eu dangos ar y ddyfais hon." + "Rhowch y rhif isod ar eich dyfais arall" + "Mewngofnodwch i\'ch dyfais arall ac yna ceisio eto, neu defnyddio ddyfais arall sydd eisoes wedi\'fewngofnodi." + "Dyw\'r ddyfais arall heb ei mewngofnodi" + "Cafodd y mewngofnodi ei ddiddymu ar y ddyfais arall." + "Cais mewngofnodi wedi\'i ddiddymu" + "Cafodd y mewngofnodi ar y ddyfais arall ei wrthod." + "Gwrthodwyd y mewngofnodi" + "Mewngofnodi wedi dod i ben. Ceisiwch eto." + "Heb gwblhau\'r mewngofnodi mewn pryd" + "Nid yw eich dyfais arall yn cefnogi mewngofnodi i %s gyda chod QR. + +Ceisiwch fewngofnodi â llaw, neu sganiwch y cod QR gyda dyfais arall." + "Nid yw\'r cod QR yn cael ei gefnogi" + "Nid yw darparwr eich cyfrif yn cefnogi %1$s." + "%1$s heb ei gefnogi" + "Yn barod i sganio" + "Agor %1$s ar ddyfais bwrdd gwaith" + "Cliciwch ar eich afatar" + "Dewiswch %1$s" + "“Cysylltu dyfais newydd”" + "Sganiwch y cod QR gyda\'r ddyfais hon" + "Dim ond ar gael os yw darparwr eich cyfrif yn ei gefnogi." + "Agorwch %1$s ar ddyfais arall i gael y cod QR" + "Defnyddiwch y cod QR sy\'n cael ei ddangos ar y ddyfais arall." + "Ceisiwch eto" + "Cod QR anghywir" + "Mynd i osodiadau camera" + "Mae angen i chi roi caniatâd i %1$s ddefnyddio camera eich dyfais er mwyn parhau." + "Caniatáu mynediad camera i sganio\'r cod QR" + "Sganiwch y cod QR" + "Cychwyn eto" + "Digwyddodd gwall annisgwyl. Ceisiwch eto." + "Yn aros am eich dyfais arall" + "Mae\'n bosibl y bydd darparwr eich cyfrif yn gofyn am y cod canlynol i ddilysu\'r mewngofnodi." + "Eich cod dilysu" + "Newid darparwr cyfrif" + "Gweinydd preifat ar gyfer gweithwyr Element." + "Mae Matrix yn rhwydwaith agored ar gyfer cyfathrebu diogel, datganoledig." + "Dyma lle bydd eich sgyrsiau\'n byw - yn union fel y byddech chi\'n defnyddio darparwr e-bost i gadw\'ch e-byst." + "Rydych ar fin mewngofnodi i %1$s" + "Dewiswch ddarparwr cyfrif" + "Rydych chi ar fin creu cyfrif ar %1$s" + diff --git a/features/login/impl/src/main/res/values-da/translations.xml b/features/login/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..cffa9c7 --- /dev/null +++ b/features/login/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,98 @@ + + + "Skift kontoudbyder" + "Hjemmeserverens adresse" + "Indtast et søgeudtryk eller en domæneadresse." + "Søg efter en virksomhed, et fællesskab eller en privat server." + "Find en kontoudbyder" + "Det er her, dine samtaler vil bo - ligesom du ville bruge en e-mail-udbyder til at opbevare dine e-mails." + "Du er ved at logge ind på %s" + "Det er her, dine samtaler vil bo - ligesom du ville bruge en e-mail-udbyder til at opbevare dine e-mails." + "Du er ved at oprette en konto på %s" + "Matrix.org er en stor, gratis server på det offentlige Matrix-netværk til sikker, decentraliseret kommunikation, drevet af Matrix.org Foundation." + "Anden" + "Brug en anden kontoudbyder, f.eks. din egen private server eller en arbejdskonto." + "Skift kontoudbyder" + "Google Play" + "Element Pro-appen er påkrævet på %1$s Download den venligst fra din app store." + "Element Pro kræves" + "Vi kunne ikke nå denne hjemmeserver. Kontroller, at du har indtastet hjemmeserverens URL korrekt. Hvis URL-adressen er korrekt, skal du kontakte administratoren på din hjemmeserver for at få yderligere hjælp." + "Serveren er ikke tilgængelig på grund af et problem i .well-known-filen: +%1$s" + "Den valgte kontoudbyder understøtter ikke sliding sync. En opgradering af serveren er nødvendig for at bruge %1$s." + "%1$s har ikke tilladelse til at oprette forbindelse til %2$s." + "Denne app er konfigureret til at tillade: %1$s." + "Kontoudbyder %1$s er ikke tilladt." + "URL-adressen på din hjemmeserver" + "Indtast en domæneadresse." + "Hvad er adressen på din server?" + "Vælg din server" + "Opret konto" + "Denne konto er blevet deaktiveret." + "Forkert brugernavn og/eller adgangskode" + "Dette er ikke en gyldig brugeridentifikation. Forventet format: \'@bruger:hjemmeserver.org\'" + "Denne server er konfigureret til at bruge opdateringstokens. Disse understøttes ikke, når du bruger adgangskodebaseret login." + "Den valgte hjemmeserver understøtter ikke adgangskode eller OIDC-login. Kontakt venligst din administrator eller vælg en anden hjemmeserver." + "Indtast dine oplysninger" + "Matrix er et åbent netværk for sikker, decentraliseret kommunikation." + "Velkommen tilbage!" + "Log ind på %1$s" + "Version %1$s" + "Log ind manuelt" + "Log ind på %1$s" + "Log ind med QR-kode" + "Opret konto" + "Velkommen til den hurtigste %1$s nogensinde. Supercharged til hastighed og enkelhed." + "Velkommen til %1$s. Ladet med hastighed og enkelhed." + "Vær i dit rette Element" + "Etablerer en sikker forbindelse" + "Der kunne ikke oprettes en sikker forbindelse til den nye enhed. Dine eksisterende enheder er stadig sikre, og du behøver ikke bekymre dig om dem." + "Hvad nu?" + "Prøv at logge ind igen med en QR-kode, hvis dette skyldtes et netværksproblem" + "Hvis du støder på det samme problem, kan du prøve et andet wifi-netværk eller bruge dine mobildata i stedet for wifi" + "Hvis det ikke virker, skal du logge ind manuelt" + "Forbindelsen er ikke sikker" + "Du bliver bedt om at indtaste de to cifre, der vises på denne enhed." + "Indtast nummeret herunder på din anden enhed" + "Log på din anden enhed, og prøv derefter igen, eller brug en anden enhed, der allerede er logget på." + "Den anden enhed er ikke logget ind" + "Login blev annulleret på den anden enhed." + "Anmodning om login annulleret" + "Login blev afvist på den anden enhed." + "Login afvist" + "Login er udløbet. Prøv venligst igen." + "Login blev ikke afsluttet i tide" + "Din anden enhed understøtter ikke at logge ind på %s med en QR-kode. + +Prøv at logge ind manuelt, eller scan QR-koden med en anden enhed." + "QR-kode understøttes ikke" + "Din kontoudbyder understøtter ikke %1$s." + "%1$s understøttes ikke" + "Klar til at scanne" + "Åbn %1$s på en stationær enhed" + "Klik på din avatar" + "Vælg %1$s" + "\"Tilknyt ny enhed\"" + "Scan QR-koden med denne enhed" + "Kun tilgængeligt, hvis din kontoudbyder understøtter det." + "Åbn %1$s på en anden enhed for at få QR-koden" + "Brug den QR-kode, der bliver vist på den anden enhed." + "Prøv igen" + "Forkert QR-kode" + "Gå til kameraindstillinger" + "Du skal give tilladelse til at %1$s kan benytte enhedens kamera, for at fortsætte." + "Tillad kameraadgang for at scanne QR-koden" + "Scan QR-koden" + "Begynd forfra" + "Der opstod en uventet fejl. Prøv venligst igen." + "Venter på din anden enhed" + "Din kontoudbyder kan bede om følgende kode for at verificere login\'et." + "Din bekræftelseskode" + "Skift kontoudbyder" + "En privat server til Element-medarbejdere." + "Matrix er et åbent netværk for sikker, decentraliseret kommunikation." + "Det er her, dine samtaler vil bo - ligesom du ville bruge en e-mail-udbyder til at opbevare dine e-mails." + "Du er ved at logge ind på %1$s" + "Vælg din kontoudbyder" + "Du er ved at oprette en konto på %1$s" + 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 0000000..cb3459f --- /dev/null +++ b/features/login/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,98 @@ + + + "Kontoanbieter wechseln" + "Homeserver-Adresse" + "Gib einen Suchbegriff oder eine Domainadresse ein." + "Suche nach einem Unternehmen, einer Community oder einem privaten Server." + "Kontoanbieter finden" + "Hier werden deine Gespräche gespeichert - so wie du deine E-Mails bei einem E-Mail-Anbieter aufbewahren würden." + "Du bist dabei, dich bei %s anzumelden" + "Hier werden deine Gespräche gespeichert - so wie du deine E-Mails bei einem E-Mail-Anbieter aufbewahren würden." + "Du bist dabei, ein Konto bei %s zu erstellen" + "Matrix.org ist ein großer, kostenloser Server im öffentlichen Matrix-Netzwerk für eine sichere, dezentralisierte Kommunikation, der von der Matrix.org Foundation betrieben wird." + "Sonstige" + "Verwende einen anderen Kontoanbieter, z. B. deinen eigenen privaten Server oder ein Geschäftskonto." + "Kontoanbieter wechseln" + "Google Play" + "Auf %1$s ist die Element Pro App erforderlich. Bitte lade diese aus dem Store." + "Element Pro erforderlich" + "Wir konnten diesen Homeserver nicht erreichen. Bitte überprüfe, ob du die Homeserver-URL korrekt eingegeben hast. Wenn die URL korrekt ist, wende dich an deinen Homeserver-Administrator, um weitere Hilfe zu erhalten." + "Der Server ist aufgrund eines Problems in der \".well-known\" Datei nicht verfügbar: +%1$s" + "Der gewählte Kontoanbieter unterstützt Sliding-Sync nicht. Für die Verwendung von %1$s ist eine Aktualisierung des Servers erforderlich." + "%1$s darf keine Verbindung zu %2$s herstellen." + "Die App wurde so konfiguriert, dass sie %1$s zulässt." + "Kontoanbieter %1$s ist nicht zulässig." + "Homeserver-URL" + "Gib eine Domain-Adresse ein." + "Wie lautet die Adresse deines Servers?" + "Wähle deinen Server aus" + "Konto erstellen" + "Dieses Konto wurde deaktiviert." + "Falscher Nutzername und/oder Passwort" + "Dies ist keine gültige Nutzerkennung. Erwartetes Format: \'@nutzer:homeserver.org\'" + "Dieser Server ist so konfiguriert, dass er Refresh-Tokens verwendet. Diese werden für die passwortbasierte Anmeldung nicht unterstützt." + "Der ausgewählte Homeserver unterstützt weder den Login per Passwort noch per OIDC. Bitte kontaktiere deinen Administrator oder wähle einen anderen Homeserver." + "Gib deine Daten ein" + "Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation." + "Willkommen zurück!" + "Anmelden bei %1$s" + "Version %1$s" + "Manuell anmelden" + "Anmelden bei %1$s" + "Mit QR-Code anmelden" + "Konto erstellen" + "Willkommen beim schnellsten %1$s aller Zeiten. Optimiert für Geschwindigkeit und Einfachheit." + "Willkommen zu %1$s. Aufgeladen, für Geschwindigkeit und Einfachheit." + "Sei in Deinem Element" + "Sichere Verbindung aufbauen" + "Es konnte keine sichere Verbindung zu dem neuen Gerät hergestellt werden." + "Und jetzt?" + "Versuche, dich erneut mit einem QR-Code anzumelden, falls dies ein Netzwerkproblem war." + "Wenn das Problem bestehen bleibt, versuche es mit einem anderen WLAN-Netzwerk oder verwende deine mobilen Daten statt WLAN." + "Wenn das nicht funktioniert, melde dich manuell an" + "Die Verbindung ist nicht sicher" + "Du wirst aufgefordert, die beiden unten abgebildeten Ziffern einzugeben." + "Trage die unten angezeigte Zahl auf einem anderen Device ein" + "Melde dich auf deinem anderen Gerät an und versuche es dann noch einmal oder verwende ein anderes Gerät, das bereits angemeldet ist." + "Anderes Gerät ist nicht angemeldet" + "Die Anmeldung wurde auf dem anderen Gerät abgebrochen." + "Anmeldeanfrage abgebrochen" + "Die Anmeldung auf dem anderen Gerät wurde abgelehnt." + "Anmelden abgelehnt" + "Die Anmeldung ist abgelaufen. Bitte versuche es erneut." + "Die Anmeldung wurde nicht rechtzeitig abgeschlossen" + "Dein anderes Gerät unterstützt die Anmeldung bei %s mit einem QR-Code nicht. + +Versuche, dich manuell anzumelden, oder scanne den QR-Code mit einem anderen Gerät." + "QR-Code wird nicht unterstützt" + "Dein Kontoanbieter unterstützt %1$s nicht." + "%1$s wird nicht unterstützt" + "Bereit zum Scannen" + "%1$s auf einem Desktop-Gerät öffnen" + "Klick auf deinen Avatar" + "Wähle %1$s" + "\"Neues Gerät verknüpfen\"" + "Scanne den QR-Code mit diesem Gerät" + "Nur verfügbar falls dein Kontoanbieter dies unterstützt." + "Öffne %1$s auf einem anderen Gerät, um den QR-Code zu erhalten" + "Verwende den QR-Code, der auf dem anderen Gerät angezeigt wird." + "Erneut versuchen" + "Falscher QR-Code" + "Gehe zu den Kameraeinstellungen" + "Du musst %1$s die Berechtigung erteilen, die Kamera deines Geräts zu verwenden, um fortzufahren." + "Erlaube Zugriff auf die Kamera zum Scannen des QR-Codes" + "QR-Code scannen" + "Neu beginnen" + "Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut." + "Warten auf dein anderes Gerät" + "Dein Konto-Provider kann nach dem folgenden Code fragen, um die Anmeldung zu bestätigen." + "Dein Verifizierungscode" + "Kontoanbieter wechseln" + "Ein privater Server für die Mitarbeiter von Element." + "Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation." + "Hier werden deine Gespräche gespeichert - so wie du deine E-Mails bei einem E-Mail-Anbieter aufbewahren würden." + "Du bist dabei, dich bei %1$s anzumelden" + "Kontoanbieter auswählen" + "Du bist dabei, auf %1$s ein Konto zu erstellen" + diff --git a/features/login/impl/src/main/res/values-el/translations.xml b/features/login/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..047cd3b --- /dev/null +++ b/features/login/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,94 @@ + + + "Αλλαγή παρόχου λογαριασμού" + "Διεύθυνση οικιακού διακομιστή" + "Εισήγαγε έναν όρο αναζήτησης ή μια διεύθυνση τομέα." + "Αναζήτησε μια εταιρεία, κοινότητα ή ιδιωτικό διακομιστή." + "Βρες έναν πάροχο λογαριασμού" + "Εδώ θα ζουν οι συνομιλίες σου - όπως θα χρησιμοποιούσες έναν πάροχο email για να διατηρήσεις τα email σου." + "Πρόκειται να συνδεθείς στο %s" + "Εδώ θα ζουν οι συνομιλίες σου - όπως θα χρησιμοποιούσες έναν πάροχο email για να διατηρήσεις τα email σου." + "Πρόκειται να δημιουργήσεις έναν λογαριασμό στο %s" + "Το Matrix.org είναι ένας μεγάλος, δωρεάν διακομιστής στο δημόσιο δίκτυο Matrix για ασφαλή, αποκεντρωμένη επικοινωνία, που διευθύνεται από το Ίδρυμα Matrix.org." + "Άλλο" + "Χρησιμοποίησε διαφορετικό πάροχο λογαριασμού, όπως τον δικό σου ιδιωτικό διακομιστή ή έναν εργασιακό λογαριασμό." + "Αλλαγή παρόχου λογαριασμού" + "Δεν μπορούσαμε να επικοινωνήσουμε με αυτόν τον οικιακό διακομιστή. Βεβαιώσου ότι έχεις εισαγάγει σωστά τη διεύθυνση URL του αρχικού διακομιστή. Εάν η διεύθυνση URL είναι σωστή, επικοινώνησε με τον διαχειριστή του κεντρικού διακομιστή για περαιτέρω βοήθεια." + "Ο διακομιστής δεν είναι διαθέσιμος λόγω προβλήματος στο αρχείο .well-known: +%1$s" + "Ο επιλεγμένος πάροχος λογαριασμού δεν υποστηρίζει sliding sync. Απαιτείται αναβάθμιση στο διακομιστή για χρήση του %1$s." + "%1$s δεν επιτρέπεται να συνδεθεί με %2$s." + "Αυτή η εφαρμογή έχει ρυθμιστεί ώστε να επιτρέπει: %1$s." + "Ο πάροχος %1$s λογαριασμού δεν επιτρέπεται." + "URL οικιακού διακομιστή" + "Εισήγαγε μια διεύθυνση τομέα." + "Ποια είναι η διεύθυνση του διακομιστή σου;" + "Επέλεξε το διακομιστή σου" + "Δημιουργία λογαριασμού" + "Αυτός ο λογαριασμός έχει απενεργοποιηθεί." + "Λανθασμένο όνομα χρήστη ή κωδικός πρόσβασης" + "Αυτό δεν είναι έγκυρο αναγνωριστικό χρήστη. Αναμενόμενη μορφή: \'@χρήστης:homeserver.org\'" + "Αυτός ο διακομιστής έχει ρυθμιστεί ώστε να χρησιμοποιεί διακριτικά ανανέωσης. Αυτά δεν υποστηρίζονται όταν χρησιμοποιείς σύνδεση μέσω κωδικού πρόσβασης." + "Ο επιλεγμένος οικιακός διακομιστής δεν υποστηρίζει κωδικό πρόσβασης ή σύνδεση OIDC. Επικοινωνήστε με τον διαχειριστή σου ή επέλεξε άλλο οικιακό διακομιστή." + "Εισήγαγε τα στοιχεία σου" + "Το Matrix είναι ένα ανοιχτό δίκτυο για ασφαλή, αποκεντρωμένη επικοινωνία." + "Καλωσόρισες ξανά!" + "Συνδέσου στο %1$s" + "Σύνδεση χειροκίνητα" + "Συνδέσου στο %1$s" + "Συνδέσου με κωδικό QR" + "Δημιουργία λογαριασμού" + "Καλώς ήλθατε στο γρηγορότερο %1$s όλων των εποχών. Υπερτροφοδοτούμενο με ταχύτητα και απλότητα." + "Καλώς ήρθες στο %1$s. Υπερφορτισμένο, για ταχύτητα και απλότητα." + "Μείνε στο element σου" + "Εγκαθίδρυση ασφαλούς σύνδεσης" + "Δεν ήταν δυνατή η πραγματοποίηση ασφαλούς σύνδεσης στη νέα συσκευή. Οι υπάρχουσες συσκευές σας εξακολουθούν να είναι ασφαλείς και δεν χρειάζεται να ανησυχείς για αυτές." + "Τί είναι πάλι;" + "Δοκίμασε να συνδεθείς ξανά με έναν κωδικό QR σε περίπτωση που ήταν πρόβλημα του δικτύου" + "Εάν αντιμετωπίσεις το ίδιο πρόβλημα, δοκίμασε ένα διαφορετικό δίκτυο wifi ή χρησιμοποίησε τα δεδομένα του κινητού σου αντί για wifi" + "Εάν δεν λειτουργήσει, συνδέσου χειροκίνητα" + "Η σύνδεση δεν είναι ασφαλής" + "Θα σου ζητηθεί να εισάγεις τα δύο ψηφία που εμφανίζονται σε αυτήν τη συσκευή." + "Εισήγαγε τον παρακάτω αριθμό στην άλλη συσκευή σου" + "Συνδέσου στην άλλη συσκευή σου και στη συνέχεια, δοκίμασε ξανά ή χρησιμοποίησε άλλη συσκευή που είναι ήδη συνδεδεμένη." + "Η άλλη συσκευή δεν έχει συνδεθεί" + "Η σύνδεση ακυρώθηκε στην άλλη συσκευή." + "Το αίτημα σύνδεσης ακυρώθηκε" + "Η σύνδεση απορρίφθηκε στην άλλη συσκευή." + "Η σύνδεση απορρίφθηκε" + "Η είσοδος έληξε. Παρακαλώ προσπάθησε ξανά." + "Η σύνδεση δεν ολοκληρώθηκε εγκαίρως" + "Η άλλη σου συσκευή δεν υποστηρίζει σύνδεση στο %s με κωδικό QR. + +Δοκίμασε να συνδεθείς χειροκίνητα ή σάρωσε τον κωδικό QR με άλλη συσκευή." + "Ο κωδικός QR δεν υποστηρίζεται" + "Ο πάροχος λογαριασμού σου δεν υποστηρίζει το %1$s." + "Το %1$s δεν υποστηρίζεται" + "Έτοιμο για σάρωση" + "Άνοιγμα %1$s σε υπολογιστή" + "Κάνε κλικ στο avatar σου" + "Επιλογή %1$s" + "«Σύνδεση νέας συσκευής»" + "Σάρωσε τον κωδικό QR με αυτήν τη συσκευή" + "Διατίθεται μόνο εάν ο πάροχος του λογαριασμού σου το υποστηρίζει." + "Άνοιγμα %1$s σε άλλη συσκευή για να λήψη κωδικού QR" + "Χρησιμοποίησε τον κωδικό QR που εμφανίζεται στην άλλη συσκευή." + "Προσπάθησε ξανά" + "Λάθος κωδικός QR" + "Μετάβαση στις ρυθμίσεις κάμερας" + "Πρέπει να δώσεις άδεια για %1$s για να χρησιμοποιήσεις την κάμερα της συσκευής σου και να συνεχίσεις." + "Επέτρεψε την πρόσβαση της κάμερας για σάρωση του κωδικού QR" + "Σάρωση κωδικού QR" + "Ξανά από την αρχή" + "Παρουσιάστηκε ένα απροσδόκητο σφάλμα. Παρακαλώ προσπάθησε ξανά." + "Αναμονή για την άλλη σου συσκευή" + "Ο πάροχος λογαριασμού σου μπορεί να ζητήσει τον ακόλουθο κωδικό για να επαληθεύσει τη σύνδεση." + "Ο κωδικός επαλήθευσής σου" + "Αλλαγή παρόχου λογαριασμού" + "Ένας ιδιωτικός διακομιστής για υπαλλήλους του Element." + "Το Matrix είναι ένα ανοιχτό δίκτυο για ασφαλή, αποκεντρωμένη επικοινωνία." + "Εδώ θα ζουν οι συνομιλίες σου - όπως θα χρησιμοποιούσες έναν πάροχο email για να διατηρήσεις τα email σου." + "Πρόκειται να συνδεθείς στο %1$s" + "Επέλεξε πάροχο λογαριασμού" + "Πρόκειται να δημιουργήσεις έναν λογαριασμό στο %1$s" + diff --git a/features/login/impl/src/main/res/values-en-rUS/translations.xml b/features/login/impl/src/main/res/values-en-rUS/translations.xml new file mode 100644 index 0000000..74e6a80 --- /dev/null +++ b/features/login/impl/src/main/res/values-en-rUS/translations.xml @@ -0,0 +1,7 @@ + + + "Matrix.org is a large, free server on the public Matrix network for secure, decentralized communication, run by the Matrix.org Foundation." + "Matrix is an open network for secure, decentralized communication." + "If you encounter the same problem, try a different Wi-Fi network or use your mobile data instead of Wi-Fi" + "Matrix is an open network for secure, decentralized communication." + 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 0000000..d4841e2 --- /dev/null +++ b/features/login/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,94 @@ + + + "Cambiar proveedor de cuentas" + "Dirección del servidor base" + "Introduce un término de búsqueda o una dirección de dominio." + "Busca una empresa, comunidad o servidor privado." + "Encontrar un proveedor de cuentas" + "Aquí es donde se alojarán tus conversaciones — justo como utilizarías un proveedor de correo electrónico para guardar tus correos electrónicos." + "Estás a punto de iniciar sesión en %s" + "Aquí es donde se alojarán tus conversaciones — justo como utilizarías un proveedor de correo electrónico para guardar tus correos electrónicos." + "Estás a punto de crear una cuenta en %s" + "Matrix.org es un servidor grande y gratuito en la red pública Matrix para una comunicación segura y descentralizada, administrado por la Fundación Matrix.org." + "Otro" + "Usa un proveedor de cuentas diferente, como tu propio servidor privado o una cuenta de trabajo." + "Cambiar proveedor de cuentas" + "No hemos podido acceder a este servidor base. Comprueba que has introducido correctamente la dirección. Si es correcta, ponte en contacto con el administrador de tu servidor base para obtener más ayuda." + "El servidor no está disponible debido a un problema en el archivo .well-known: +%1$s" + "El proveedor de cuentas seleccionado no admite sliding sync. Es necesario actualizar el servidor para usar %1$s." + "%1$s no está autorizado para conectarse a %2$s." + "Esta aplicación se ha configurado para permitir: %1$s." + "No se permite el proveedor de cuentas %1$s." + "URL del servidor base" + "Introduce una dirección de dominio." + "¿Cuál es la dirección de tu servidor?" + "Selecciona tu servidor" + "Crear cuenta" + "Esta cuenta ha sido desactivada." + "Usuario y/o contraseña incorrectos" + "Este no es un id de usuario válido. Formato esperado: \'@user:homeserver.org\'" + "Este servidor está configurado para utilizar tokens de actualización. Estos no son compatibles cuando se utiliza el inicio de sesión basado en contraseña." + "El servidor base seleccionado no admite el inicio de sesión usando contraseña ni OIDC. Ponte en contacto con tu administrador o elige otro servidor base." + "Introduce tus datos" + "Matrix es una red abierta para una comunicación segura y descentralizada." + "¡Hola de nuevo!" + "Iniciar sesión en %1$s" + "Iniciar sesión manualmente" + "Iniciar sesión en %1$s" + "Iniciar sesión con un código QR" + "Crear cuenta" + "Bienvenido al %1$s más rápido de todos los tiempos. Diseñado para la velocidad y la simplicidad." + "Bienvenido a %1$s. Vitaminado, para mayor rapidez y sencillez." + "Siéntete en tu Elemento" + "Estableciendo una conexión segura" + "No se pudo establecer una conexión segura con el nuevo dispositivo. Tus dispositivos actuales siguen siendo seguros y no tienes que preocuparte por ellos." + "¿Y ahora qué?" + "Intenta iniciar sesión de nuevo con un código QR en caso de que se trate de un problema de red" + "Si te encuentras con el mismo problema, prueba con una red wifi diferente o usa tus datos móviles en lugar de wifi" + "Si eso no funciona, inicia sesión manualmente" + "La conexión no es segura" + "Se te pedirá que introduzcas los dos dígitos mostrados en este dispositivo." + "Introduce el número que aparece a continuación en tu otro dispositivo" + "Inicia sesión en tu otro dispositivo e inténtalo de nuevo, o usa otro dispositivo en el que ya hayas iniciado sesión." + "El otro dispositivo no tiene iniciada la sesión" + "El inicio de sesión se canceló en el otro dispositivo." + "Solicitud de inicio de sesión cancelada" + "El inicio de sesión se rechazó en el otro dispositivo." + "Inicio de sesión rechazado" + "El inicio de sesión ha caducado. Inténtalo de nuevo." + "El inicio de sesión no se completó a tiempo" + "Tu otro dispositivo no admite el inicio de sesión en %s con un código QR. + +Intenta iniciar sesión manualmente o escanea el código QR con otro dispositivo." + "Código QR no admitido" + "Tu proveedor de cuentas no es compatible con %1$s." + "%1$s no admitido" + "Listo para escanear" + "Abre %1$s en un dispositivo de escritorio" + "Haz clic en tu avatar" + "Selecciona %1$s" + "«Vincular un dispositivo nuevo»" + "Escanea el código QR con este dispositivo" + "Sólo disponible si tu proveedor de cuentas lo admite." + "Abre %1$s en otro dispositivo para obtener el código QR" + "Usa el código QR que se muestra en el otro dispositivo." + "Intentar de nuevo" + "Código QR incorrecto" + "Ir a los ajustes de la cámara" + "Tienes que dar permiso a %1$s para que utilice la cámara de tu dispositivo y así poder continuar." + "Permite el acceso a la cámara para escanear el código QR" + "Escanea el código QR" + "Empezar de nuevo" + "Se ha producido un error inesperado. Vuelve a intentarlo." + "A la espera de tu otro dispositivo" + "Puede que tu proveedor de cuentas te pida el siguiente código para verificar el inicio de sesión." + "Tu código de verificación" + "Cambiar proveedor de cuentas" + "Un servidor privado para los empleados de Element." + "Matrix es una red abierta para una comunicación segura y descentralizada." + "Aquí es donde se alojarán tus conversaciones — justo como utilizarías un proveedor de correo electrónico para guardar tus correos electrónicos." + "Estás a punto de iniciar sesión en %1$s" + "Elegir proveedor de cuentas" + "Estás a punto de crear una cuenta en %1$s" + diff --git a/features/login/impl/src/main/res/values-et/translations.xml b/features/login/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..3e3e6ad --- /dev/null +++ b/features/login/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,98 @@ + + + "Muuda teenusepakkujat" + "Koduserveri aadress" + "Sisesta otsingusõna või domeeni nimi." + "Otsi äriühingut, kogukonda või võrgus leiduvat Matrixi serverit." + "Leia teenusepakkuja" + "See on koht, kus sinu vestlused elavad – just nagu kasutaksid oma e-kirjade säilitamiseks e-postiteenuse pakkujat." + "Sa oled sisse logimas %s teenusesse" + "See on koht, kus sinu vestlused elavad – just nagu kasutaksid oma e-kirjade säilitamiseks e-postiteenuse pakkujat." + "Sa oled loomas kasutajakontot %s teenuses" + "Matrix.org on suur ja tasuta koduserver Matrixi võrgus, mis on mõeldud turvalise ja hajutatud suhtluse jaoks. Selle serveri halduse eest vastutab Matrix.org Foundation." + "Muu teenusepakkuja" + "Kasuta erinevat teenusepakkujat, milleks võib olla ka sinu oma server või töökoha hallatav server." + "Muuda teenusepakkujat" + "Google Play" + "%1$s koduserver eeldab Element Pro rakenduse kasutamist. Palun laadi ta alla rakendustepoest." + "Vajalik on Element Pro" + "Me ei suutnud luuaühendust selle koduserveriga. Palun kontrolli, kas koduserveri aadress on õige. Kui aadress on õige, siis täiendavat teavet oskab sulle anda koduserveri haldaja." + "Server pole saadaval vea tõttu well-known failis: +%1$s" + "Valitud teenusepakkuja ei toeta „sliding sync“ režiimi. Rakenduse %1$s kasutamiseks on vaja serverit uuendada." + "%1$s ei saa kasutada %2$s koduserverit." + "See rakendus on seadistatud järgneva koduserveri kasutamiseks: %1$s." + "%1$s teenusepakkuja pole lubatud." + "Koduserveri url" + "Sisesta domeeni aadress." + "Mis on sinu koduserveri aadress?" + "Vali oma server" + "Loo kasutajakonto" + "Konto on kasutusest eemaldatud." + "Vigane kasutajanimi ja/või salasõna" + "See ei ole korrektne kasutajanimi. Õige vorming on: „@kasutaja:koduserver.ee“" + "See server on seadistatud kasutama tunnusloa põhist sisselogimist. Salasõnaga sisselogimisel see võimalus aga ei ole toetatud." + "Valitud koduserver ei toeta salasõna ega OIDC-põhist sisselogimist. Lisateavet saad koduserveri haldajalt, aga sa võid ka valida mõne teise serveri." + "Sisesta oma andmed" + "Matrix on avatud võrk turvalise ja hajutatud suhtluse jaoks." + "Tere tulemast tagasi!" + "Logi sisse serverisse %1$s" + "Versioon %1$s" + "Logi sisse käsitsi" + "Logi sisse serverisse %1$s" + "Logi sisse QR-koodi alusel" + "Loo kasutajakonto" + "Läbi aegade kiireim ja mugavaim %1$s." + "Tere tulemast kasutama kiiret ja lihtsat suhtlusrakendust %1$s." + "Ole oma elemendis" + "Loome turvalist ühendust" + "Turvalise ühenduse loomine uue seadmega ei õnnestunud. Sinu olemasolevad seadmed on jätkuvalt turvatud ja sa ei pea nende pärast muretsema." + "Mida järgmiseks teeme?" + "Kui see juhtumisi oli võrguühenduse viga, siis proovi uuesti QR-koodiga sisse logida" + "Kui sama probleem kordub, siis kasuta mõnda muud WiFi- või mobiilset andmedsideühendust" + "Kui see ka ei aita, siis logi sisse käsitsi" + "Ühendus pole turvaline" + "Sul palutakse sisestada kaks selles seadmes kuvatud numbrit." + "Sisesta see number oma teises seadmes" + "Logi sisse oma teise seadmesse ja proovi siis uuesti või kasuta mõnda muud oma seadet, mis on juba sisse logitud." + "Teine seade pole sisselogitud" + "Sisselogimine katkestati teises seadmes." + "Sisselogimispäring on tühistatud" + "Sisselogimisest on teises seadmes keeldutud." + "Sisselogimisest on keeldutud" + "Sisselogimine aegus. Palun proovi uuesti." + "Sisselogimine jäi etteantud aja jooksul tegemata" + "Sinu teine seade ei toeta %s sisselogimist QR-koodiga. + +Proovi käsitsi sisselogimist või skaneeri QR-koodi mõne muu seadmega." + "QR-kood pole toetatud" + "Sinu teenusepakkuja ei toeta rakendust %1$s." + "%1$s pole toetatud" + "Skaneerimiseks valmis" + "Ava %1$s töölauarakenduses" + "Klõpsi oma tunnuspilti" + "Vali %1$s" + "„Seo uus seade“" + "Skaneeri QR-koodi selle seadmega" + "See funktsionaalsus on sadaval vaid siis, kui sinu teenusepakkuja seda toetab." + "QR-koodi saamiseks ava %1$s oma teises seadmes" + "Kasuta teises seadmes näidatavat QR-koodi" + "Proovi uuesti" + "Vale QR-kood" + "Ava kaamera seadistused" + "Jätkamiseks pead lubama, et %1$s saab kasutada sinu nutiseadme kaamerat" + "QR-koodi lugemiseks luba kaamerat kasutada" + "Skaneeri QR-koodi" + "Alusta uuesti" + "Tekkis ootamatu viga. Palun proovi uuesti." + "Ootame sinu teise seadme järgi" + "Sinu teenusepakkuja võib sisselogimisel eeldada selle verifitseerimiskoodi kasutamist." + "Sinu verifitseerimiskood" + "Muuda teenusepakkujat" + "Privaatne server Elemendi töötajate jaoks." + "Matrix on avatud võrk turvalise ja hajutatud suhtluse jaoks." + "See on koht, kus sinu vestlused elavad – just nagu kasutaksid oma e-kirjade säilitamiseks e-postiteenuse pakkujat." + "Sa oled sisselogimas koduserverisse %1$s" + "Vali teenusepakkuja" + "Sa oled loomas kasutajakontot koduserveris %1$s" + diff --git a/features/login/impl/src/main/res/values-eu/translations.xml b/features/login/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..355e635 --- /dev/null +++ b/features/login/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,80 @@ + + + "Aldatu kontu-hornitzailea" + "Zerbitzariaren helbidea" + "Sartu bilaketa-kontsulta edo domeinu-helbidea." + "Bilatu enpresa, komunitate edo zerbitzari pribatu bat." + "Bilatu kontu-hornitzaile bat" + "%s(e)n saioa hastear zaude" + "%s(e)n kontua sortzear zaude" + "Beste bat" + "Erabili beste kontu-hornitzaile bat, hala nola zure zerbitzari pribatua edo laneko kontu bat." + "Aldatu kontu-hornitzailea" + "Google Play" + "Element Pro behar da" + "Hautatutako kontu-hornitzailea ez da bateragarria Sliding Sync-ekin. Beharrezkoa da zerbitzaria bertsio-berritzea %1$s erabiltzeko." + "Ez da %1$s kontu-hornitzailea onartzen." + "Zerbitzariaren URLa" + "Sartu domeinu-helbide bat." + "Zein da zure zerbitzariaren helbidea?" + "Hautatu zure zerbitzaria" + "Sortu kontua" + "Kontu hau desaktibatuta dago." + "Erabiltzaile-izena edo/eta pasahitza okerrak" + "Hautatutako zerbitzaria ez da bateragarria pasahitz edo OIDC saio-hasierarekin. Jarri harremanetan administratzailearekin edo aukeratu beste zerbitzari bat." + "Sartu zure datuak" + "Matrix komunikazio seguru eta deszentralizaturako sare irekia da." + "Ongi etorri!" + "Hasi saioa %1$s(e)n" + "%1$s bertsioa" + "Hasi saioa eskuz" + "Hasi saioa %1$s(e)n" + "Hasi saioa QR kodearekin" + "Sortu kontua" + "Ongi etorri inoizko %1$s azkarrenera. Abiaduraz eta sinpletasunaz gainkargatua." + "Ongi etorri %1$s-ra. Abiaduraz eta sinpletasunez gainezka." + "Egon zure saltsan" + "Konexio segurua ezartzen" + "Ezin izan da konexio segururik ezarri gailu berriarekin. Lehendik dauden gailuak seguru daude oraindik ere eta ez duzu haietaz kezkatu beharrik." + "Orain zer?" + "Saiatu berriro QR kodearekin saioa hasten sare-arazo bat izan bada" + "Horrek ez badu funtzionatzen, hasi saioa eskuz" + "Konexioa ez da segurua" + "Gailu honetan agertzen diren bi digituak sartzeko eskatuko zaizu." + "Sartu beheko zenbakia beste gailuan" + "Hasi saioa beste gailu batean eta saiatu berriro, edo erabili saioa hasita duen beste gailuren bat." + "Saioa hasteko eskaera bertan behera utzi da beste gailuan" + "Saioa hasteko eskaera bertan behera utzi da" + "Saioa hasteari uko egin zaio beste dispositiboan." + "Saio-hasiera ukatu da" + "Saio-hasiera iraungi da. Saiatu berriro." + "Saio-hasiera ez da garaiz gauzatu." + "Beste gailua ez da bateragarria QR kodeak erabiliz %s(e)n saioa hastearekin. + +Saiatu saioa eskuz hasten, edo eskaneatu QR kodea beste gailu batean." + "QR kodea ez da bateragarria" + "Zure kontu-hornitzailea ez da %1$s-ekin bateragarria." + "%1$s ez da bateragarria" + "Eskaneatzeko prest" + "Klikatu zure abatarrean" + "Hautatu %1$s" + "\"Lotu gailu berria\"" + "Eskaneatu QR kodea gailu honekin" + "Erabili beste gailuan agertzen den QR kodea." + "Saiatu berriro" + "QR kode okerra" + "Joan kameraren ezarpenetara" + "Baimendu kameraren sarbidea QR kodea eskaneatzeko" + "Eskaneatu QR kodea" + "Hasi berriro" + "Ustekabeko errore bat gertatu da. Saiatu berriro." + "Beste gailuaren zain" + "Zure kontu-hornitzaileak hurrengo kodea eska diezazuke saio-hasiera egiaztatzeko." + "Egiaztapen-kodea" + "Aldatu kontu-hornitzailea" + "Elementeko langileentzako zerbitzari pribatua." + "Matrix komunikazio seguru eta deszentralizaturako sare irekia da." + "%1$s(e)n saioa hastear zaude" + "Aukeratu kontu-hornitzailea" + "%1$s(e)n kontua sortzear zaude" + diff --git a/features/login/impl/src/main/res/values-fa/translations.xml b/features/login/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..ef7062a --- /dev/null +++ b/features/login/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,85 @@ + + + "تغییر فراهم کنندهٔ حساب" + "نشانی کارساز خانگی" + "ورود عبارت جست‌وجو یا نشانی دامنه." + "جست‌وجو برای شرکت، اجتماع یا کارسازی خصوصی." + "یافتن فراهم کنندهٔ حساب" + "جایی که گفت‌وگوهایتان خواهند زیست — درست مثل استفاده‌تان از فراهم کنندهٔ رایانامه‌ای برای نگه داشتن رایانامه‌هایتان." + "دارید وارد %s می‌شوید" + "جایی که گفت‌وگوهایتان خواهند زیست — درست مثل استفاده‌تان از فراهم کنندهٔ رایانامه‌ای برای نگه داشتن رایانامه‌هایتان." + "دارید حسابی روی %s می‌سازید" + "ماتریکس‌دات‌اورگ کارسازی بزرگ و آزاد روی شبکهٔ ماتریکس عمومی برای ارتباطات نامتمرکز و امن است که به دست بنیاد ماتریکس‌دات‌اورگ اداره می‌شود." + "دیگر" + "استفاده از فراهم کنندهٔ حسابی دیگر چون کارساز خصوصی خوتان یا حسابی کاری." + "تغییر فراهم کنندهٔ حساب" + "پلی گپگل" + "ما نتوانستیم به این کارساز خانگی برسیم. لطفاً بررسی کنید که URL کارساز اصلی را به درستی وارد کرده اید. اگر URL صحیح است، برای کمک بیشتر با مدیر کارساز خانگی خود تماس بگیرید." + "نشانی کارساز خانگی" + "ورود نشانی دامنه." + "نشانی کارسازتان چیست؟" + "کارسازتان را برگزینید" + "ایجاد حساب" + "این حساب از کار افتاده است." + "نام کاربری یا گذرواژه نامعتبر است" + "این یک شناسه کاربری معتبر نیست. قالب صحیح: ‪«@user:homeserver.or" + "کارساز اصلی انتخاب شده از رمز عبور یا ورود OIDC پشتیبانی نمی کند. لطفا با مدیر خود تماس بگیرید یا یک کارساز خانگی دیگر را انتخاب کنید." + "جزییاتتان را وارد کنید" + "ماتریکس شبکه‌ای بار برای ارتباطات نامتمرکز و امن است." + "خوش برگشتید!" + "ورود به %1$s" + "نگارش %1$s" + "ورود دستی" + "ورود به %1$s" + "ورود با کد QR" + "ایجاد حساب" + "به سریع‌ترین %1$s خوش آمدید. بازطرّاحی شده برای سرعت و سادگی." + "به %1$s خوش آمدید. بازطرّاحی شده برای سرعت و سادگی." + "در المنتتان باشید" + "برقرار کدن اتّصالی امن" + "نتوانست اتّصالی امن به افزارهٔ جدید بسازد. افزاره‌های موجودتان هنوز امنند و نیازی نیست نگرانشان باشید." + "اکنون چه؟" + "ورود دستی در صورت کار نکردنش" + "اتّصال ناامن" + "از شما خواسته خواهد شد که دو رقم نشان داده روی این افزاره را وارد کنید." + "شمارهٔ زیر را روی افزارهٔ دیگرتان وارد کنید" + "به افزارهٔ دیگرتان وارد شده و دوباره تلاش کنید یا از افزارهٔ دیگری که از پیش وارد شده استفاده کنید." + "افزارهٔ دیگر وارد نشده" + "ورود روی افزارهٔ دیگر لغو شد." + "درخواست ورد لغو شد" + "ورود به دست افزارهٔ دیگر رد شد." + "ورود رد شد" + "ورود منقضی شد. لطفاً دوباره تلاش کنید." + "ورود در زمان معیّن کامل نشد" + "افزارهٔ دیگرتان از ورود به %s با کد پاس پشتیبانی نمی‌کند. + +آزمودن ورود دستی یا پویش کد پاس با افزاره‌ای دیگر." + "کد پاس پشتیبانی نمی‌شود" + "فراهم کنندهٔ حسابتان از %1$s پشتیبانی نمی‌کند." + "%1$s پشتیبانی نمی‌شود" + "آمادهٔ پویش" + "گشودن %1$s در افزارهٔ میزکار" + "زدن روی چهرکتان" + "گزینش %1$s" + "«پیوند افزارهٔ جدید»" + "پویش کد پاس با این افزاره" + "گشودن %1$s روی افزاره‌ای دیگر برای گرفتن کد پاس" + "استفاده از کد پاس نشان داده روی افزارهٔ دیگر." + "تلاش دوباره" + "کد پاس اشتباه" + "رفتن به تنظیمات دوربین" + "برای ادامه باید اجازهٔ استفادهٔ %1$s از دوربین افزاره‌تان را بدهید." + "اجازهٔ دسترسی دوربین برای پویش کد پاس" + "پویش کد پاس" + "آغاز از نو" + "خطایی غیرمنتظره رخ داد. لطفاً دوباره تلاش کنید." + "منتظر افزارهٔ دیگرتان" + "ممکن است فراهم کنندهٔ حسابتان کد زیر را برای تأیید ورود بخواهد." + "کد تأییدتان" + "تغییر فراهم کنندهٔ حساب" + "کارساز خصوصی برای کارمندان المنت." + "ماتریکس شبکه‌ای بار برای ارتباطات نامتمرکز و امن است." + "جایی که گفت‌وگوهایتان خواهند زیست — درست مثل استفاده‌تان از فراهم کنندهٔ رایانامه‌ای برای نگه داشتن رایانامه‌هایتان." + "دارید به %1$s وارد می‌شوید" + "دارید روی %1$s حساب می‌سازید" + diff --git a/features/login/impl/src/main/res/values-fi/translations.xml b/features/login/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..e16f66b --- /dev/null +++ b/features/login/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,98 @@ + + + "Vaihda palveluntarjoajaa" + "Kotipalvelimen osoite" + "Kirjoita hakutermi tai osoite." + "Hae yritystä, yhteisöä tai yksityistä palvelinta." + "Etsi palveluntarjoajaa" + "Keskustelusi asuvat täällä — aivan kuten aivan kuten käyttäisit sähköpostipalveluntarjoajaa sähköpostiesi säilyttämiseen." + "Olet kirjautumassa sisään %s-palvelimelle" + "Keskustelusi asuvat täällä — aivan kuten aivan kuten käyttäisit sähköpostipalveluntarjoajaa sähköpostiesi säilyttämiseen." + "Olet luomassa tiliä %s-palvelimelle" + "Matrix.org on suuri, ilmainen palvelin julkisessa Matrix-verkossa turvalliseen, hajautettuun viestintään, jota ylläpitää Matrix.org-säätiö." + "Muu" + "Käytä toista palveluntarjoajaa, kuten omaa yksityistä palvelintasi tai työpaikkaasi." + "Vaihda palveluntarjoajaa" + "Google Play" + "Element Pro -sovellus on pakollinen %1$s -palvelimella. Lataa se sovelluskaupasta." + "Element Pro vaaditaan" + "Kotipalvelimeen ei saatu yhteyttä. Varmista, että olet syöttänyt osoitteen oikein. Jos osoite on oikein, ota yhteyttä palvelimesi ylläpitäjään." + "Palvelin ei ole saatavilla .well-known tiedostossa olevan ongelman vuoksi: +%1$s" + "Valitsemasi palveluntarjoaja ei tue sliding syncia. Palvelimen päivitys tarvitaan %1$s -sovelluksen käyttämiseen." + "%1$s ei saa yhdistää %2$s -palvelimeen." + "Tämä sovellus on määritetty sallimaan: %1$s." + "Palveluntarjoaja %1$s ei ole sallittu." + "Kotipalvelimen osoite" + "Anna verkkotunnuksen osoite." + "Mikä on palvelimesi osoite?" + "Valitse palvelimesi" + "Luo tili" + "Tämä tili on deaktivoitu." + "Väärä käyttäjänimi ja/tai salasana" + "Tämä ei ole kelvollinen käyttäjätunnus. Odotettu muoto: \'@käyttäjä:kotipalvelin.fi\'" + "Tämä palvelin on määritetty käyttämään refresh tokeneja. Näitä ei tueta salasanapohjaisen kirjautumisen kanssa." + "Valitsemasi kotipalvelin ei tue salasana- tai OIDC-kirjautumista. Ota yhteyttä palvelimesi ylläpitäjään tai valitse toinen kotipalvelin." + "Syötä tietosi" + "Matrix on avoin verkko turvallista, hajautettua viestintää varten." + "Tervetuloa takaisin!" + "Kirjaudu sisään %1$s -palvelimelle" + "Versio %1$s" + "Kirjaudu sisään manuaalisesti" + "Kirjaudu sisään %1$s -palvelimelle" + "Kirjaudu sisään QR-koodilla" + "Luo tili" + "Tervetuloa kaikkien aikojen nopeimpaan %1$s -sovellukseen. Ahdettu nopeudella ja yksinkertaisuudella." + "Tervetuloa %1$s -sovellukseen. Ahdettu nopeudella ja yksinkertaisuudella." + "Ole elementissäsi" + "Muodostetaan turvallista yhteyttä" + "Turvallista yhteyttä uuteen laitteeseen ei voitu muodostaa. Olemassa olevat laitteesi ovat edelleen turvassa, eikä sinun tarvitse huolehtia niistä." + "Mitä nyt?" + "Yritä kirjautua sisään uudelleen QR-koodilla, jos kyseessä oli verkko-ongelma" + "Jos kohtaat saman ongelman, kokeile toista wifi-verkkoa tai käytä mobiilidataa wifi-yhteyden sijaan" + "Jos tämä ei auta, kirjaudu sisään manuaalisesti" + "Yhteys ei ole turvallinen" + "Sinua pyydetään antamaan tässä laitteessa näkyvät kaksi numeroa." + "Kirjoita alla oleva numero toisella laitteellasi" + "Kirjaudu sisään toisella laitteellasi ja yritä sitten uudelleen tai käytä toista laitetta, joka on jo kirjautunut sisään." + "Toinen laitteesi ei ole kirjautuneena" + "Kirjautuminen peruutettiin toisella laitteella." + "Kirjautumispyyntö peruutettu" + "Kirjautuminen hylättiin toisella laitteella." + "Kirjautuminen hylätty" + "Kirjautuminen vanhentui. Yritä uudelleen." + "Kirjautumista ei suoritettu ajoissa" + "Toinen laitteesi ei tue kirjautumista %s -sovellukseen QR-koodilla. + +Yritä kirjautua sisään manuaalisesti tai skannaa QR-koodi toisella laitteella." + "QR-koodia ei tueta" + "Palveluntarjoajasi ei tue %1$s -sovellusta" + "%1$s -sovellusta ei tueta" + "Valmis skannaamaan" + "Avaa %1$s tietokoneella" + "Napsauta avatariasi" + "Valitse %1$s" + "“Yhdistä uusi laite”" + "Skannaa QR-koodi tällä laitteella" + "Saatavilla vain, jos palveluntarjoajasi tukee sitä." + "Avaa %1$s toisella laitteella saadaksesi QR-koodin" + "Käytä toisessa laitteessa näkyvää QR-koodia." + "Yritä uudelleen" + "Väärä QR-koodi" + "Siirry kameran asetuksiin" + "Jatkaaksesi sinun on annettava lupa %1$s -sovellukselle käyttää laitteesi kameraa." + "Salli lupa kameraan QR-koodin skannaamiseksi" + "Skannaa QR-koodi" + "Aloita alusta" + "Tapahtui odottamaton virhe. Yritä uudelleen." + "Odotetaan toista laitettasi" + "Palveluntarjoajasi saattaa kysyä seuraavaa koodia kirjautumisen vahvistamiseksi." + "Vahvistuskoodisi" + "Vaihda palveluntarjoajaa" + "Yksityinen palvelin Elementin työntekijöille." + "Matrix on avoin verkko turvallista, hajautettua viestintää varten." + "Keskustelusi asuvat täällä — aivan kuten aivan kuten käyttäisit sähköpostipalveluntarjoajaa sähköpostiesi säilyttämiseen." + "Olet kirjautumassa sisään %1$s-palvelimelle" + "Valitse palveluntarjoaja" + "Olet luomassa tiliä %1$s-palvelimelle" + 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 0000000..946fec8 --- /dev/null +++ b/features/login/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,96 @@ + + + "Changer de fournisseur de compte" + "Adresse du serveur d’accueil" + "Entrez un terme de recherche ou une adresse de domaine." + "Recherchez une entreprise, une communauté ou un serveur privé." + "Trouver un fournisseur de comptes" + "C’est ici que vos conversations seront enregistrées, comme vous le feriez avec un fournisseur de messagerie pour conserver vos e-mails." + "Vous êtes sur le point de vous connecter à %s" + "C’est ici que vos conversations seront enregistrées, comme vous le feriez avec un fournisseur de messagerie pour conserver vos e-mails." + "Vous êtes sur le point de créer un compte sur %s" + "Matrix.org est un grand serveur gratuit sur le réseau public Matrix pour une communication sécurisée et décentralisée, géré par la Fondation Matrix.org." + "Autres" + "Utilisez un autre fournisseur de compte, tel que votre propre serveur privé ou un serveur professionnel." + "Changer de fournisseur de compte" + "Google Play" + "L’application Element Pro est requise sur %1$s. Veuillez la télécharger depuis le store." + "Element Pro est requis" + "Nous n’avons pas pu atteindre ce serveur d’accueil. Vérifiez que vous avez correctement saisi l’URL du serveur d’accueil. Si l’URL est correcte, contactez l’administrateur de votre serveur d’accueil pour obtenir de l’aide." + "Ce fournisseur de compte n’est pas disponible en raison d’un problème dans le fichier .well-known: +%1$s" + "Le fournisseur de compte sélectionné ne prend pas en charge le sliding sync. Une mise à jour du serveur est nécessaire pour pouvoir utiliser %1$s." + "%1$s n’est pas autorisé à se connecter à %2$s." + "Cette application a été configurée pour autoriser: %1$s." + "Le fournisseur de compte %1$s n’est pas autorisé." + "URL du serveur d’accueil" + "Saisissez une adresse de domaine." + "Quelle est l’adresse de votre serveur ?" + "Choisissez votre serveur" + "Créer un compte" + "Ce compte a été désactivé." + "Nom d’utilisateur et/ou mot de passe incorrects" + "Il ne s’agit pas d’un identifiant utilisateur valide. Format attendu : « @user:homeserver.org »" + "Ce serveur est configuré pour utiliser des tokens d’actualisation. Ils ne sont pas pris en charge lors de l’utilisation d’une connexion basée sur un mot de passe." + "Le serveur d’accueil sélectionné ne prend pas en charge le mot de passe ou la connexion OIDC. Contactez votre administrateur ou choisissez un autre serveur d’accueil." + "Saisissez vos identifiants" + "Matrix est un réseau ouvert pour une communication sécurisée et décentralisée." + "Content de vous revoir !" + "Connectez-vous à %1$s" + "Version %1$s" + "Se connecter manuellement" + "Connectez-vous à %1$s" + "Se connecter avec un QR code" + "Créer un compte" + "Bienvenue dans l’application %1$s la plus rapide de tous les temps. Boosté pour plus de rapidité et de simplicité." + "Bienvenue sur %1$s. Boosté, pour plus de rapidité et de simplicité." + "Soyez dans votre Element" + "Établissement d’une connexion sécurisée" + "Aucune connexion sécurisée n’a pu être établie avec la nouvelle session. Vos sessions existantes sont toujours en sécurité et vous n’avez pas à vous en soucier." + "Et maintenant ?" + "Essayez de vous connecter à nouveau à l’aide du code QR au cas où il s’agirait d’un problème réseau" + "Si vous rencontrez le même problème, essayez un autre réseau wifi ou utilisez vos données mobiles au lieu du wifi" + "Si cela ne fonctionne pas, connectez-vous manuellement" + "La connexion n’est pas sécurisée" + "Il vous sera demandé de saisir les deux chiffres affichés sur cet appareil." + "Saisissez le nombre ci-dessous sur votre autre appareil" + "Connectez-vous à votre autre appareil, puis réessayez, ou utilisez un autre appareil déjà connecté." + "Autre appareil non connecté" + "La connexion a été annulée sur l’autre appareil." + "Demande de connexion annulée" + "La connexion a été refusée sur l’autre appareil." + "Connexion refusée" + "Connexion expirée. Veuillez essayer à nouveau." + "La connexion a pris trop de temps." + "Votre autre appareil ne supporte pas la connexion à %s avec un code QR. Essayer de vous connecter manuellement, ou scanner le code QR avec un autre appareil." + "Code QR non supporté" + "Votre fournisseur de compte ne supporte pas %1$s." + "%1$s n’est pas supporté" + "Prêt à scanner" + "Ouvrez %1$s sur un ordinateur" + "Cliquez sur votre image de profil" + "Choisissez %1$s" + "“Associer une nouvelle session”" + "Scanner le code QR avec cet appareil" + "Disponible uniquement si votre fournisseur de compte le supporte." + "Ouvrez %1$s sur un autre appareil pour obtenir le QR code" + "Scannez le QR code affiché sur l’autre appareil." + "Essayer à nouveau" + "QR code erroné" + "Accéder aux paramètres de l’appareil photo" + "Vous devez autoriser %1$s à utiliser la camera de votre appareil pour continuer." + "Autoriser l’usage de la caméra pour scanner le code QR" + "Scannez le QR code" + "Recommencer" + "Une erreur inattendue s’est produite. Veuillez réessayer." + "En attente de votre autre session" + "Votre fournisseur de compte peut vous demander le code suivant pour vérifier la connexion." + "Votre code de vérification" + "Changer de fournisseur de compte" + "Un serveur privé pour les employés d’Element." + "Matrix est un réseau ouvert pour une communication sécurisée et décentralisée." + "C’est ici que vos conversations seront enregistrées, comme vous le feriez avec un fournisseur de messagerie pour conserver vos e-mails." + "Vous êtes sur le point de vous connecter à %1$s" + "Choisissez un fournisseur de compte" + "Vous êtes sur le point de créer un compte sur %1$s" + diff --git a/features/login/impl/src/main/res/values-hu/translations.xml b/features/login/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..fd05e19 --- /dev/null +++ b/features/login/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,98 @@ + + + "Fiókszolgáltató módosítása" + "Matrix-kiszolgáló webcíme" + "Adjon meg egy keresési kifejezést vagy egy tartománycímet." + "Keresés egy cégre, közösségre vagy privát kiszolgálóra." + "Fiókszolgáltató keresése" + "Itt lesznek a beszélgetései – ahogyan egy e-mail-szolgáltatást is használna a levelei kezeléséhez." + "Hamarosan bejelentkezik ide: %s" + "Itt lesznek a beszélgetései – ahogyan egy e-mail-szolgáltatást is használna a levelei kezeléséhez." + "Hamarosan létrehoz egy fiókot itt: %s" + "A Matrix.org egy nagy, ingyenes kiszolgáló a nyilvános Matrix-hálózaton, a biztonságos, decentralizált kommunikáció érdekében, amelyet a Matrix.org Alapítvány üzemeltet." + "Egyéb" + "Másik fiókszolgáltató, például a saját privát kiszolgáló vagy egy munkahelyi fiók használata." + "Fiókszolgáltató módosítása" + "Google Play" + "Az Element Pro alkalmazás szükséges a következőn: %1$s. Töltse le az áruházból." + "Element Pro szükséges" + "Nem sikerült elérni ezt a Matrix-kiszolgálót. Ellenőrizze, hogy helyesen adta-e meg a Matrix-kiszolgáló webcímét. Ha a webcím helyes, akkor további segítségért lépjen kapcsolatba a Matrix-kiszolgáló adminisztrátorával." + "A kiszolgáló a well-known fájl problémája miatt nem érhető el: +%1$s" + "A kiválasztott fiókszolgáltató nem támogatja a csúszóablakos szinkronizálást. Az %1$s használatához kiszolgálófrissítés szükséges." + "%1$s nem csatlakozhat ide: %2$s." + "Ezt az alkalmazást úgy konfigurálták, hogy engedélyezi ezt: %1$s." + "A(z) %1$s fiókszolgáltató nem engedélyezett." + "Matrix-kiszolgáló webcíme" + "Adjon meg egy domaincímet." + "Mi a kiszolgálója címe?" + "Válassza ki a kiszolgálóját" + "Fiók létrehozása" + "Ez a fiók deaktiválva lett." + "Helytelen felhasználónév vagy jelszó" + "Ez nem érvényes felhasználóazonosító. A várt formátum: „@user:homeserver.org”" + "Ez a kiszolgáló frissítési tokenek használatára van beállítva. Ezek jelszó alapú bejelentkezés esetén nem támogatottak." + "A kiválasztott Matrix-kiszolgáló nem támogatja a jelszavas vagy OIDC-alapú bejelentkezést. Lépjen kapcsolatba a kiszolgáló adminisztrátorával, vagy válasszon másik Matrix-kiszolgálót." + "Adja meg adatait" + "A Matrix egy nyitott hálózat a biztonságos, decentralizált kommunikációhoz." + "Örülünk, hogy visszatért!" + "Bejelentkezés ide: %1$s" + "Verzió: %1$s" + "Kézi bejelentkezés" + "Bejelentkezés ide: %1$s" + "Bejelentkezés QR-kóddal" + "Fiók létrehozása" + "Üdvözöljük a valaha volt leggyorsabb %1$sben. Felturbózva, a sebesség és az egyszerűség érdekében." + "Üdvözli az %1$s. Felturbózva, a sebesség és az egyszerűség jegyében." + "Legyen elemében" + "Biztonságos kapcsolat létesítése" + "Nem sikerült biztonságos kapcsolatot létesíteni az új eszközzel. A meglévő eszközei továbbra is biztonságban vannak, és nem kell aggódnia miattuk." + "Most mi lesz?" + "Próbáljon meg újra bejelentkezni egy QR-kóddal, ha ez hálózati probléma volt." + "Ha ugyanezzel a problémával találkozik, próbálkozzon másik Wi-Fi-hálózattal, vagy a Wi-Fi helyett használja a mobil-adatkapcsolatát" + "Ha ez nem működik, jelentkezzen be kézileg" + "A kapcsolat nem biztonságos" + "A rendszer kérni fogja, hogy adja meg az alábbi két számjegyet az eszközén." + "Adja meg az alábbi számot a másik eszközén" + "Jelentkezzen be másik eszközére, majd próbálkozzon újra, vagy használjon egy másik, már bejelentkezett eszközt." + "Más eszköz nincs bejelentkezve" + "A bejelentkezést megszakították a másik eszközön." + "Bejelentkezési kérés törölve" + "A bejelentkezést elutasították a másik eszközön." + "A bejelentkezés elutasítva" + "A bejelentkezés lejárt. Próbálja újra." + "A bejelentkezés nem fejeződött be időben" + "A másik eszköz nem támogatja QR-kóddal történő bejelentkezést az %sbe. + +Próbáljon meg kézileg bejelentkezni, vagy olvassa be a QR-kódot egy másik eszközzel." + "A QR-kód nem támogatott" + "A fiókszolgáltatója nem támogatja az %1$s-et." + "Az %1$s nem támogatott" + "Készen áll a beolvasásra" + "Nyissa meg az %1$set egy asztali eszközön" + "Kattintson a profilképére" + "Válassza ezt: %1$s" + "„Új eszköz összekapcsolása”" + "Olvassa be a QR-kódot ezzel az eszközzel" + "Csak akkor érhető el, ha a fiókszolgáltató támogatja." + "Nyissa meg az %1$set egy másik eszközön a QR-kód lekéréséhez." + "Használja a másik eszközön látható QR-kódot." + "Próbálja újra" + "Hibás QR-kód" + "Ugrás a kamerabeállításokhoz" + "A folytatáshoz engedélyeznie kell, hogy az %1$s használhassa az eszköz kameráját." + "Engedélyezze a kamera elérését a QR-kód beolvasásához" + "Olvassa be a QR-kódot" + "Újrakezdés" + "Váratlan hiba történt. Próbálja meg újra." + "Várakozás a másik eszközre" + "A fiókszolgáltatója kérheti a következő kódot a bejelentkezése ellenőrzéséhez." + "Az Ön ellenőrzőkódja" + "Fiókszolgáltató módosítása" + "Egy privát kiszolgáló az Element alkalmazottai számára." + "A Matrix egy nyitott hálózat a biztonságos, decentralizált kommunikációhoz." + "Itt lesznek a beszélgetései – ahogyan egy e-mail-szolgáltatást is használna a levelei kezeléséhez." + "Hamarosan bejelentkezik ebbe: %1$s" + "Válassza ki a fiókszolgáltatót" + "Hamarosan létrehoz egy fiókot ezen: %1$s" + diff --git a/features/login/impl/src/main/res/values-in/translations.xml b/features/login/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..63d703f --- /dev/null +++ b/features/login/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,95 @@ + + + "Ubah penyedia akun" + "Alamat homeserver" + "Masukkan istilah pencarian atau alamat domain." + "Cari perusahaan, komunitas, atau server pribadi." + "Cari penyedia akun" + "Di sinilah percakapan Anda akan berlangsung — sama seperti Anda menggunakan penyedia surel untuk menyimpan surel Anda." + "Anda akan masuk ke %s" + "Di sinilah percakapan Anda akan berlangsung — sama seperti Anda menggunakan penyedia surel untuk menyimpan surel Anda." + "Anda akan membuat akun di %s" + "Matrix.org adalah server besar dan gratis di jaringan Matrix publik untuk komunikasi yang aman dan terdesentralisasi, disediakan oleh Yayasan Matrix.org." + "Lainnya" + "Gunakan penyedia akun yang berbeda, seperti server pribadi Anda sendiri atau akun kerja." + "Ubah penyedia akun" + "Kami tidak dapat menjangkau server ini. Periksa apakah Anda telah memasukkan URL homeserver dengan benar. Jika URL sudah benar, hubungi administrator homeserver Anda untuk bantuan lebih lanjut." + "Server tidak tersedia karena ada masalah dalam berkas .well-known: +%1$s" + "Penyedia akun yang dipilih tidak mendukung sinkronisasi geser. Peningkatan server diperlukan untuk digunakan %1$s." + "%1$s tidak diizinkan untuk terhubung ke %2$s." + "Aplikasi ini telah diatur untuk mengizinkan: %1$s." + "Penyedia akun %1$s tidak diizinkan." + "URL Homeserver" + "Masukkan alamat domain." + "Apa alamat server Anda?" + "Pilih server Anda" + "Buat akun" + "Akun ini telah dinonaktifkan." + "Nama pengguna dan/atau kata sandi salah" + "Ini bukan pengenal pengguna yang valid. Format yang diharapkan: \'@pengguna:homeserver.org\'" + "Server ini diatur untuk menggunakan token penyegaran. Ini tidak didukung ketika menggunakan log masuk berbasis kata sandi." + "Homeserver yang dipilih tidak mendukung log masuk kata sandi atau OIDC. Silakan hubungi admin Anda atau pilih homeserver yang lain." + "Masukkan detail Anda" + "Matrix adalah jaringan terbuka untuk komunikasi yang aman dan terdesentralisasi." + "Selamat datang kembali!" + "Masuk ke %1$s" + "Versi %1$s" + "Masuk secara manual" + "Masuk ke %1$s" + "Masuk dengan kode QR" + "Buat akun" + "Selamat datang di %1$s tercepat yang pernah ada. Berdaya besar untuk kecepatan dan kesederhanaan." + "Selamat datang di %1$s. Berdaya penuh, untuk kecepatan dan kesederhanaan." + "Berada di elemen Anda" + "Membuat koneksi aman" + "Koneksi aman tidak dapat dibuat ke perangkat baru. Perangkat Anda yang ada masih aman dan Anda tidak perlu khawatir tentang mereka." + "Apa sekarang?" + "Coba masuk lagi dengan kode QR jika ini adalah masalah jaringan" + "Jika Anda mengalami masalah yang sama, coba jaringan Wi-Fi yang berbeda atau gunakan data seluler Anda daripada Wi-Fi" + "Jika tidak berhasil, masuk secara manual" + "Koneksi tidak aman" + "Anda akan diminta untuk memasukkan dua digit yang ditunjukkan di perangkat ini." + "Masukkan nomor bawah di perangkat Anda yang lain" + "Masuk ke perangkat lain lalu coba lagi, atau gunakan perangkat lain yang sudah masuk." + "Perangkat lain tidak masuk" + "Proses masuk dibatalkan di perangkat lain." + "Permintaan masuk dibatalkan" + "Proses masuk ditolak di perangkat lain." + "Proses masuk ditolak" + "Masa masuk kedaluwarsa. Silakan coba lagi." + "Proses masuk tidak selesai tepat waktu" + "Perangkat Anda yang lain tidak mendukung masuk ke %s dengan kode QR. + +Coba masuk secara manual, atau pindai kode QR dengan perangkat lain." + "Kode QR tidak didukung" + "Penyedia akun Anda tidak mendukung %1$s." + "%1$s tidak didukung" + "Siap untuk memindai" + "Buka %1$s di perangkat desktop" + "Klik pada avatar Anda" + "Pilih %1$s" + "“Tautkan perangkat baru”" + "Pindai kode QR dengan perangkat ini" + "Hanya tersedia jika penyedia akun Anda mendukungnya." + "Buka %1$s di perangkat lain untuk mendapatkan kode QR" + "Gunakan kode QR yang ditampilkan di perangkat lain." + "Coba lagi" + "Kode QR salah" + "Pergi ke pengaturan kamera" + "Anda perlu memberikan izin ke %1$s untuk menggunakan kamera perangkat Anda untuk melanjutkan." + "Izinkan akses kamera untuk memindai kode QR" + "Pindai kode QR" + "Mulai dari awal" + "Terjadi kesalahan tak terduga. Silakan coba lagi." + "Menunggu perangkat Anda yang lain" + "Penyedia akun Anda mungkin meminta kode berikut untuk memverifikasi proses masuk." + "Kode verifikasi Anda" + "Ubah penyedia akun" + "Server pribadi untuk karyawan Element." + "Matrix adalah jaringan terbuka untuk komunikasi yang aman dan terdesentralisasi." + "Di sinilah percakapan Anda akan berlangsung — sama seperti Anda menggunakan penyedia surel untuk menyimpan surel Anda." + "Anda akan masuk ke %1$s" + "Pilih penyedia akun" + "Anda akan membuat akun di %1$s" + 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 0000000..310aa22 --- /dev/null +++ b/features/login/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,98 @@ + + + "Cambia fornitore dell\'account" + "Indirizzo dell\'homeserver" + "Inserisci un termine di ricerca o un indirizzo di dominio." + "Cerca un\'azienda, una comunità o un server privato." + "Trova un fornitore di account" + "Qui è dove vivranno le tue conversazioni — proprio come useresti un fornitore di posta elettronica per conservare le tue email." + "Stai per accedere a %s" + "Qui è dove vivranno le tue conversazioni — proprio come useresti un fornitore di posta elettronica per conservare le tue email." + "Stai per creare un account su %s" + "Matrix.org è un grande server gratuito nella rete pubblica Matrix per una comunicazione sicura e decentralizzata, gestito da Matrix.org Foundation." + "Altro" + "Utilizza un provider di account diverso, ad esempio il tuo server privato o un account di lavoro." + "Cambia fornitore dell\'account" + "Google Play" + "L\'app Element Pro è necessaria su %1$s. Scaricala dallo store." + "Element Pro è richiesto" + "Non siamo riusciti a raggiungere questo homeserver. Verifica di aver inserito correttamente l\'URL. Se l\'URL è corretto, contatta l\'amministratore del homeserver per ulteriore assistenza." + "Il server non è disponibile per un problema nel file well-known: +%1$s" + "Il fornitore di account selezionato non supporta la Sliding sync. Per utilizzare %1$s è necessario un aggiornamento del server." + "%1$s non è autorizzato a connettersi a %2$s." + "Questa app è stata configurata per consentire: %1$s." + "Fornitore dell\'account %1$s non consentito." + "URL dell\'homeserver" + "Inserisci un indirizzo di dominio." + "Qual è l\'indirizzo del tuo server?" + "Seleziona il tuo server" + "Crea account" + "Questo account è stato disattivato." + "Nome utente e/o password errati" + "Questo non è un identità utente valida. il formato atteso é: \'@user:homeserver.org\'" + "Questo server è configurato per usare i token di aggiornamento. Non sono supportati quando si usa l\'accesso basato su password." + "L\'homeserver selezionato non supporta la password o l\'accesso OIDC. Contatta il tuo amministratore o scegli un altro homeserver." + "Inserisci i tuoi dati" + "Matrix è una rete aperta per comunicazioni sicure e decentralizzate." + "Bentornato!" + "Accedi a %1$s" + "Versione %1$s" + "Accedi manualmente" + "Accedi a %1$s" + "Accedi con codice QR" + "Crea account" + "Benvenuti nell\'%1$s più veloce di sempre. Potenziato per velocità e semplicità." + "Benvenuto su %1$s. Potenziato in velocità e semplicità." + "Sii nel tuo elemento" + "Stabilendo la connessione" + "Non è stato possibile stabilire una connessione sicura con il nuovo dispositivo. I tuoi dispositivi esistenti sono ancora al sicuro e non devi preoccuparti di loro." + "E adesso?" + "Prova ad accedere di nuovo con un codice QR nel caso si sia verificato un problema di rete." + "Se riscontri lo stesso problema, prova con un altra rete wifi o usa i dati mobili al posto del wifi." + "Se il problema persiste, accedi manualmente" + "La connessione non è sicura" + "Ti verrà chiesto di inserire le due cifre mostrate su questo dispositivo." + "Inserisci il numero qui sotto sull\'altro dispositivo" + "Accedi all\'altro dispositivo e riprova oppure usa un altro dispositivo che ha già eseguito l\'accesso." + "Altro dispositivo non connesso" + "L\'accesso è stato annullato sull\'altro dispositivo." + "Richiesta di accesso annullata" + "L\'accesso è stato rifiutato sull\'altro dispositivo." + "Accesso rifiutato" + "L\'accesso è scaduto. Riprova." + "L\'accesso non è stato completato in tempo" + "L\'altro dispositivo non supporta l\'accesso a %s con un codice QR. + +Prova ad accedere manualmente o scansiona il codice QR con un altro dispositivo." + "Codice QR non supportato" + "Il tuo fornitore di account non supporta %1$s." + "%1$s non supportato" + "Pronto per la scansione" + "Apri %1$s su un dispositivo desktop" + "Clicca sul tuo avatar" + "Seleziona %1$s" + "\"Collega un nuovo dispositivo\"" + "Scansiona il codice QR con questo dispositivo" + "Disponibile solo se il provider del tuo account lo supporta." + "Apri %1$s su un altro dispositivo per ottenere il codice QR" + "Usa il codice QR mostrato sull\'altro dispositivo." + "Riprova" + "Codice QR sbagliato" + "Vai alle impostazioni della fotocamera" + "Per continuare, è necessario fornire l\'autorizzazione a %1$s per utilizzare la fotocamera del dispositivo." + "Consenti l\'accesso alla fotocamera per la scansione del codice QR" + "Scansiona il codice QR" + "Ricomincia" + "Si è verificato un errore inatteso. Riprova." + "In attesa dell\'altro dispositivo" + "Il fornitore dell\'account potrebbe richiedere il seguente codice per verificare l\'accesso." + "Il tuo codice di verifica" + "Cambia fornitore dell\'account" + "Un server privato per i dipendenti di Element." + "Matrix è una rete aperta per comunicazioni sicure e decentralizzate." + "Qui è dove vivranno le tue conversazioni — proprio come useresti un fornitore di posta elettronica per conservare le tue email." + "Stai per accedere a %1$s" + "Scegli il fornitore dell\'account" + "Stai per creare un account su %1$s" + diff --git a/features/login/impl/src/main/res/values-ka/translations.xml b/features/login/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..54e277e --- /dev/null +++ b/features/login/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,45 @@ + + + "შეცვალეთ ანგარიშის მომწოდებელი" + "სახლის სერვერის მისამართი" + "შეიყვანეთ საძიებო სიტყვა ან დომენის მისამართი." + "მოძებნეთ კომპანია, საზოგადოება ან კერძო სერვერი." + "ანგარიშის მომწოდებლის მოძებნა" + "აქ იქნება თქვენი საუბრები - ისევე, როგორც თქვენ ელ. ფოსტაში ინახება თქვენი ელ.წერილები." + "თქვენ აპირებთ შესვლას %s-ში" + "აქ იქნება თქვენი საუბრები - ისევე, როგორც თქვენ ელ. ფოსტაში ინახება თქვენი ელ.წერილები." + "თქვენ აპირებთ ანგარიშის შექმნას %s-ში" + "Matrix.org არის დიდი, უფასო სერვერი საჯარო Matrix ქსელში უსაფრთხო, დეცენტრალიზებული კომუნიკაციისთვის, რომელსაც მართავს Matrix.org ფონდი." + "სხვა" + "გამოიყენეთ სხვა ანგარიშის პროვაიდერი, როგორიცაა თქვენი პირადი სერვერი ან სამუშაო ანგარიში." + "შეცვალეთ ანგარიშის მომწოდებელი" + "ჩვენ ვერ მივაღწიეთ ამ სახლის სერვერს. გთხოვთ, შეამოწმოთ, რომ სწორად შეიყვანეთ სახლის სერვერის URL. თუ URL სწორია, დაუკავშირდით თქვენი სახლის სერვერის ადმინისტრატორს დამატებითი დახმარებისთვის." + "Sliding sync არ არის ხელმისაწვდომი well-known ფაილში პრობლემის გამო: %1$s" + "სახლის სერვერის URL" + "რა არის თქვენი სერვერის მისამართი?" + "აირჩიეთ თქვენი სერვერი" + "ანგარიშის შექმნა" + "ეს ანგარიში დეაქტივირებულია." + "არასწორი მომხმარებლის სახელი და/ან პაროლი" + "მოცემული მომხმარებლის იდენტიფიკატორი არასწორია. დასაშვები ფორმატი: ‘@user:homeserver.org’" + "ეს სერვერი კონფიგურირებულია განახლების გასაღებების გამოსაყენებლად. პაროლზე დაფუძნებული შეცვლისას ისინი მხარდაჭერილი არაა." + "მოცემული სახლის სერვერი მხარს არ უჭერს პაროლით ან OIDC-ით შესვლას. გთხოვთ, დაუკავშირდეთ თქვენს ადმინისტრატორს ან აარჩიეთ სხვა სახლის სერვერი." + "შეიყვანეთ თქვენი დეტალები" + "Matrix არის ღია ქსელი უსაფრთხო, დეცენტრალიზებული კომუნიკაციისთვის." + "კეთილი იყოს თქვენი მობრძანება!" + "შესვლა %1$s-ში" + "ხელით შესვლა" + "შესვლა %1$s-ში" + "შესვლა QR კოდით" + "ანგარიშის შექმნა" + "კეთილი იყოს თქვენი მობრძანება უსწრაფეს %1$s-ში. დამუხტულია სიჩქარისა და სიმარტივისათვის." + "კეთილი იყოს თქვენი მობრძანება %1$s-ში! დამუხტული სიჩქარისა და სიმარტივისთვის." + "იყავი შენს element-ში" + "ხელახლა ცდა" + "შეცვალეთ ანგარიშის მომწოდებელი" + "კერძო სერვერი Element-ის თანამშრომლებისთვის." + "Matrix არის ღია ქსელი უსაფრთხო, დეცენტრალიზებული კომუნიკაციისთვის." + "აქ იქნება თქვენი საუბრები - ისევე, როგორც თქვენ ელ. ფოსტაში ინახება თქვენი ელ.წერილები." + "თქვენ აპირებთ შესვლას %1$s-ში" + "თქვენ აპირებთ ანგარიშის შექმნას %1$s-ში" + diff --git a/features/login/impl/src/main/res/values-ko/translations.xml b/features/login/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..7c099f1 --- /dev/null +++ b/features/login/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,98 @@ + + + "계정 제공자 변경" + "홈서버 주소" + "검색어 또는 도메인 주소를 입력하세요." + "회사, 커뮤니티, 또는 개인 서버를 검색하세요." + "계정 제공자 찾기" + "이곳이 귀하의 대화 공간입니다 — 이메일 제공업체를 사용해 이메일을 관리하는 것처럼 말이죠." + "%s에 로그인합니다" + "이곳이 귀하의 대화 공간입니다 — 이메일 제공업체를 사용해 이메일을 관리하는 것처럼 말이죠." + "%s 에서 계정을 생성하려고 합니다." + "Matrix.org는 Matrix.org 재단이 운영하는, 안전하고 분산된 통신을 위한 공개 Matrix 네트워크의 대규모 무료 서버입니다." + "기타" + "다른 계정 제공업체를 사용하세요. 예를 들어 자체 사설 서버나 업무용 계정 등을 사용할 수 있습니다." + "계정 제공자 변경" + "구글 플레이" + "%1$s 에는 Element Pro 앱이 필요합니다. 스토어에서 다운로드하시기 바랍니다." + "Element Pro가 필요합니다" + "이 홈 서버에 연결할 수 없습니다. 홈 서버 URL을 올바르게 입력했는지 확인하십시오. URL이 올바른 경우 홈 서버 관리자에게 추가 지원을 요청하십시오." + "서버가 .well-known 파일의 문제로 인해 사용할 수 없습니다: +%1$s" + "선택한 계정 제공업체는 sliding sync를 지원하지 않습니다. %1$s를 사용하려면 서버를 업그레이드 해야 합니다." + "%1$s는 %2$s에 연결이 허용되지 않습니다." + "이 앱은 다음을 허용하도록 구성되었습니다: %1$s." + "계정 제공자 %1$s 는 허용되지 않습니다." + "홈서버 URL" + "도메인 주소를 입력하세요." + "서버의 주소는 무엇인가요?" + "서버 선택" + "계정 만들기" + "계정이 비활성화되었습니다." + "잘못된 아이디/비밀번호" + "이 사용자 ID는 유효하지 않습니다. 예상 형식: ‘@user:homeserver.org’" + "이 서버는 새로 고침 토큰을 사용하도록 구성되어 있습니다. 비밀번호 기반 로그인을 사용하는 경우 이 기능은 지원되지 않습니다." + "선택한 홈 서버는 password 또는 OIDC 로그인을 지원하지 않습니다. 관리자에게 문의하거나 다른 홈 서버를 선택하세요." + "귀하의 세부 정보를 입력하십시오" + "Matrix 는 안전하고 분산된 커뮤니케이션을 위한 개방형 네트워크입니다." + "다시 돌아온 걸 환영합니다!" + "%1$s 에 로그인합니다" + "버전 %1$s" + "수동으로 로그인" + "%1$s 에 로그인합니다" + "QR 코드로 로그인" + "계정 만들기" + "%1$s 에 오신 것을 환영합니다. 속도와 단순성을 극대화한 가장 빠른 버전입니다." + "%1$s 에 오신 것을 환영합니다. 속도와 단순성을 위해 최적화된 앱입니다." + "당신의 엘리먼트에 있어" + "안전한 연결 설정" + "새 장치에 안전하게 연결할 수 없습니다. 기존 장치는 여전히 안전하므로 걱정할 필요가 없습니다." + "이제 어떻게 해야 할까?" + "네트워크 문제로 인해 로그인에 실패한 경우 QR 코드로 다시 로그인해 보세요." + "동일한 문제를 겪으신 경우 다른 Wi-Fi 네트워크를 사용해 보거나 Wi-Fi 대신 모바일 데이터를 사용해 보세요." + "만약 작동하지 않는 경우, 수동으로 로그인하세요." + "연결이 안전하지 않습니다" + "이 장치에 표시된 두 자리 숫자를 입력하라는 메시지가 표시됩니다." + "다른 device 에 아래 번호를 입력하세요" + "다른 장치에 로그인한 다음 다시 시도하거나, 이미 로그인되어 있는 다른 장치를 사용하세요." + "로그인하지 않은 다른 장치" + "다른 기기에서 로그인이 취소되었습니다." + "로그인 요청이 취소되었습니다" + "다른 기기에서 로그인이 거부되었습니다." + "로그인 거부됨" + "로그인이 만료되었습니다. 다시 시도해 주세요." + "로그인 시간이 초과되었습니다." + "다른 기기에서는 QR 코드로 %s 에 로그인할 수 없습니다. + +수동으로 로그인하거나 다른 기기로 QR 코드를 스캔해 보세요." + "QR 코드는 지원되지 않습니다" + "귀하의 계정 제공자는 지원하지 않습니다 %1$s ." + "%1$s 지원되지 않습니다" + "스캔 준비 완료" + "데스크톱 장치에서 %1$s 을 엽니다." + "아바타를 클릭하세요" + "선택 %1$s" + "“새로운 기기 연결”" + "이 기기로 QR 코드를 스캔하세요." + "해당 기능은 계정 제공업체가 지원하는 경우에만 사용할 수 있습니다." + "다른 기기에서 %1$s 을 열어 QR 코드를 가져오세요." + "다른 기기에 표시된 QR 코드를 사용하세요." + "다시 시도하기" + "잘못된 QR 코드" + "카메라 설정으로 이동" + "계속하려면 %1$s 가 기기의 카메라를 사용할 수 있도록 권한을 부여해야 합니다." + "카메라 액세스를 허용하여 QR 코드를 스캔하세요" + "QR 코드를 스캔하세요" + "다시 시작하다" + "예기치 않은 오류가 발생했습니다. 다시 시도해 주세요." + "다른 기기를 기다리고 있습니다" + "귀하의 계정 제공자는 로그인을 확인하기 위해 다음 코드를 요청할 수 있습니다." + "귀하의 인증 코드" + "계정 제공자 변경" + "Element 직원을 위한 전용 서버." + "Matrix 는 안전하고 분산된 커뮤니케이션을 위한 개방형 네트워크입니다." + "이곳이 귀하의 대화 공간입니다 — 이메일 제공업체를 사용해 이메일을 관리하는 것처럼 말이죠." + "당신은 %1$s 에 로그인하려 합니다" + "계정 제공자를 선택하세요" + "%1$s 에서 계정을 생성하려고 합니다." + diff --git a/features/login/impl/src/main/res/values-lt/translations.xml b/features/login/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..a8c68c7 --- /dev/null +++ b/features/login/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,39 @@ + + + "Keisti paskyros teikėją" + "Ieškokite bendrovės, bendruomenės arba privataus serverio." + "Rasti paskyros teikėją" + "Čia bus saugomi Jūsų pokalbiai - panašiai kaip el. pašto paslaugų teikėjas saugo Jūsų el. laiškus." + "Ketinate prisijungti prie %s" + "Čia bus saugomi Jūsų pokalbiai - panašiai kaip el. pašto paslaugų teikėjas saugo Jūsų el. laiškus." + "Ketinate sukurti paskyrą teikėjoje %s" + "Kita" + "Naudokite skirtingą paskyros teikėją, pavyzdžiui, savo privatų serverį arba darbo paskyrą." + "Keisti paskyros teikėją" + "Nepavyko pasiekti šio serverio. Patikrinkite, ar teisingai įvedėte serverio URL. Jei URL yra teisingas, susisiekite su serverio administracija dėl tolimesnės pagalbos." + "Serverio URL" + "Koks yra Jūsų serverio adresas?" + "Kurti paskyrą" + "Ši paskyra buvo išjungta." + "Neteisingas vartotojo vardas ir (arba) slaptažodis" + "Tai nėra tinkamas vartotojo vardas. Reikalingas formatas: \'@vartotojas:serveris.org\'" + "Pasirinktas serveris nepalaiko slaptažodžio ar OIDC prisijungimo. Susisiekite su serverio administracija arba pasirinkite kitą serverį." + "Įveskite savo duomenis" + "Matrix yra atviras tinklas, skirtas saugiam, decentralizuotam bendravimui." + "Sveiki sugrįžę!" + "Prisijungti prie %1$s" + "%1$s versija" + "Prisijungti rankiniu būdu" + "Prisijungti prie %1$s" + "Prisijungti su QR kodu" + "Kurti paskyrą" + "Sveiki atvykę į sparčiausią „%1$s“ kada nors. Pagerintas spartai ir paprastumui." + "Sveiki atvykę į „%1$s“. Pagerintas spartai ir paprastumui." + "Būkite savo elemente" + "Keisti paskyros teikėją" + "Privatus serveris “Element” darbuotojams." + "Matrix yra atviras tinklas, skirtas saugiam, decentralizuotam bendravimui." + "Čia bus saugomi Jūsų pokalbiai - panašiai kaip el. pašto paslaugų teikėjas saugo Jūsų el. laiškus." + "Jūs ruošiatės prisijungti prie %1$s" + "Jūs ruošiatės susikurti paskyrą %1$s" + diff --git a/features/login/impl/src/main/res/values-nb/translations.xml b/features/login/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..10f554a --- /dev/null +++ b/features/login/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,98 @@ + + + "Bytt kontotilbyder" + "Hjemmeserveradresse" + "Skriv inn et søkeord eller en domeneadresse." + "Søk etter et selskap, fellesskap eller privat server." + "Finn en kontoleverandør" + "Det er her samtalene dine vil ligge - akkurat som du ville brukt en e-postleverandør til å oppbevare e-postene dine." + "Du er i ferd med å logge inn på %s" + "Det er her samtalene dine vil ligge - akkurat som du ville brukt en e-postleverandør til å oppbevare e-postene dine." + "Du er i ferd med å opprette en konto på %s" + "Matrix.org er en stor, gratis server på det offentlige Matrix-nettverket for sikker, desentralisert kommunikasjon, drevet av Matrix.org Foundation." + "Annet" + "Bruk en annen kontotilbyder, for eksempel din egen private server eller en arbeidskonto." + "Bytt kontotilbyder" + "Google Play" + "Element Pro-appen er nødvendig på %1$s. Last den ned fra butikken." + "Element Pro kreves" + "Vi kunne ikke nå denne hjemmeserveren. Kontroller at du har skrevet inn hjemmeserverens URL riktig. Hvis URL-en er riktig, kontakt administratoren for hjemmeserveren din for å få mer hjelp." + "Serveren er ikke tilgjengelig på grunn av et problem i den velkjente filen: +%1$s" + "Den valgte kontoleverandøren støtter ikke sliding sync. En oppgradering av serveren er nødvendig for å bruke %1$s." + "%1$s har ikke lov til å koble seg til %2$s." + "Denne appen er konfigurert til å tillate: %1$s." + "Kontoleverandør %1$s er ikke tillatt." + "URL til hjemmeserver" + "Skriv inn en domeneadresse." + "Hva er adressen til serveren din?" + "Velg din server" + "Opprett konto" + "Denne kontoen er deaktivert." + "Feil brukernavn og/eller passord" + "Dette er ikke en gyldig brukeridentifikator. Forventet format: \'@bruker:homeserver.org\'" + "Denne serveren er konfigurert til å bruke oppdateringstokener. Disse støttes ikke når du bruker passordbasert pålogging." + "Den valgte hjemmeserveren støtter ikke passord eller OIDC-pålogging. Ta kontakt med administratoren din eller velg en annen hjemmeserver." + "Skriv inn opplysningene dine" + "Matrix er et åpent nettverk for sikker, desentralisert kommunikasjon." + "Velkommen tilbake!" + "Logg inn på %1$s" + "Versjon %1$s" + "Logg på manuelt" + "Logg inn på %1$s" + "Logg inn med QR-kode" + "Opprett konto" + "Velkommen til den raskeste %1$s noensinne. Superladet for hastighet og enkelhet." + "Velkommen til %1$s. Supercharged, for hastighet og enkelhet." + "Vær i ditt rette element" + "Etablere en sikker forbindelse" + "En sikker tilkobling kunne ikke opprettes til den nye enheten. Dine eksisterende enheter er fortsatt trygge, og du trenger ikke å bekymre deg for dem." + "Hva nå?" + "Prøv å logge på igjen med en QR-kode i tilfelle dette var et nettverksproblem" + "Hvis du støter på det samme problemet, kan du prøve et annet wifi-nettverk eller bruke mobildata i stedet for wifi" + "Hvis det ikke fungerer, kan du logge på manuelt" + "Forbindelsen er ikke sikker" + "Du blir bedt om å skrive inn de to sifrene som vises på denne enheten." + "Skriv inn nummeret nedenfor på den andre enheten" + "Logg på den andre enheten din og prøv igjen, eller bruk en annen enhet som allerede er pålogget." + "Annen enhet er ikke pålogget" + "Påloggingen ble kansellert på den andre enheten." + "Påloggingsforespørsel kansellert" + "Påloggingen ble avvist på den andre enheten." + "Pålogging avslått" + "Påloggingen er utløpt. Vennligst prøv igjen." + "Påloggingen ble ikke fullført i tide" + "Den andre enheten din støtter ikke pålogging på %s med en QR-kode. + +Prøv å logge på manuelt, eller skann QR-koden med en annen enhet." + "QR-kode støttes ikke" + "Kontotilbyderen din støtter ikke %1$s." + "%1$s støttes ikke" + "Klar til å skanne" + "Åpne %1$s på en datamaskin" + "Klikk på avataren din" + "Velg %1$s" + "«Koble til ny enhet»" + "Skann QR-koden med denne enheten" + "Bare tilgjengelig hvis kontotilbyderen din støtter det." + "Åpne %1$s på en annen enhet for å få QR-koden" + "Bruk QR-koden som vises på den andre enheten." + "Prøv igjen" + "Feil QR-kode" + "Gå til kamerainnstillinger" + "Du må gi tillatelse til at %1$s kan bruke enhetens kamera for å fortsette." + "Tillat kameratilgang for å skanne QR-koden" + "Skann QR-koden" + "Begynn på nytt" + "Det oppstod en uventet feil. Prøv igjen." + "Venter på den andre enheten din" + "Kontotilbyderen din kan be om følgende kode for å bekrefte påloggingen." + "Bekreftelseskoden din" + "Bytt kontotilbyder" + "En privat server for Element-ansatte." + "Matrix er et åpent nettverk for sikker, desentralisert kommunikasjon." + "Det er her samtalene dine vil ligge - akkurat som du ville brukt en e-postleverandør til å oppbevare e-postene dine." + "Du er i ferd med å logge inn på %1$s" + "Velg kontoleverandør" + "Du er i ferd med å opprette en konto på %1$s" + diff --git a/features/login/impl/src/main/res/values-nl/translations.xml b/features/login/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..8a7f695 --- /dev/null +++ b/features/login/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,88 @@ + + + "Wijzig accountprovider" + "Homeserver-adres" + "Voer een zoekterm of een domeinnaam in." + "Zoek naar een bedrijf, community of privéserver." + "Vind een accountprovider" + "Dit is waar je gesprekken zullen worden bewaard — net zoals je een e-mailprovider zou gebruiken om je e-mails te bewaren." + "Je staat op het punt om je aan te melden bij %s" + "Dit is waar je gesprekken zullen worden bewaard — net zoals je een e-mailprovider zou gebruiken om je e-mails te bewaren." + "Je staat op het punt een account aan te maken op %s" + "Matrix.org is een grote, gratis server op het openbare Matrix-netwerk voor veilige, gedecentraliseerde communicatie, beheerd door de Matrix.org Foundation." + "Anders" + "Gebruik een andere accountprovider, zoals je eigen privéserver of een zakelijke account." + "Wijzig accountprovider" + "We konden deze homeserver niet bereiken. Controleer of je de homeserver-URL juist hebt ingevoerd. Als de URL juist is, neem dan contact op met de beheerder van je homeserver voor verdere hulp." + "Server is niet beschikbaar vanwege een probleem in het well-known bestand: +%1$s" + "Homeserver-URL" + "Wat is het adres van je server?" + "Selecteer je server" + "Account aanmaken" + "Dit account is gesloten." + "Onjuiste gebruikersnaam en/of wachtwoord" + "Dit is geen geldige gebruikers-ID. Verwacht formaat: \'@user:homeserver.org\'" + "Deze server is geconfigureerd om verversingstokens te gebruiken. Deze worden niet ondersteund bij inloggen met een wachtwoord." + "De geselecteerde homeserver ondersteunt geen wachtwoord of OIDC aanmelding. Neem contact op met je beheerder of kies een andere homeserver." + "Vul je gegevens in" + "Matrix is een open netwerk voor veilige, gedecentraliseerde communicatie." + "Welkom terug!" + "Inloggen bij %1$s" + "Handmatig inloggen" + "Inloggen bij %1$s" + "Inloggen met QR-code" + "Account aanmaken" + "Welkom bij de snelste %1$s ooit. Supercharged, voor snelheid en eenvoud." + "Welkom bij %1$s. Supercharged, voor snelheid en eenvoud." + "Wees in je element" + "Een beveiligde verbinding tot stand brengen" + "Er kon geen beveiligde verbinding worden gemaakt met het nieuwe apparaat. Je bestaande apparaten zijn nog steeds veilig en je hoeft je daarover geen zorgen te maken." + "Wat nu?" + "Probeer opnieuw in te loggen met een QR-code voor het geval dit een netwerkprobleem was" + "Als je hetzelfde probleem ondervindt, probeer dan een ander wifi-netwerk of gebruik je mobiele data in plaats van wifi." + "Als dat niet werkt, log dan handmatig in" + "Verbinding niet veilig" + "Daar word je gevraagd om de twee cijfers in te voeren die op dit apparaat worden weergegeven." + "Voer het onderstaande nummer in op je andere apparaat" + "Log in op een ander apparaat en probeer opnieuw, of gebruik een ander apparaat dat al is ingelogd." + "Ander apparaat is niet ingelogd" + "De aanmelding is geannuleerd op het andere apparaat." + "Login verzoek geannuleerd" + "De aanmelding is geweigerd op het andere apparaat." + "Aanmelden geweigerd" + "Aanmelden is verlopen. Probeer het opnieuw." + "De aanmelding was niet op tijd voltooid" + "Jouw andere apparaat ondersteunt geen inloggen op %s met een QR code. + +Probeer handmatig in te loggen, of scan de QR code met een ander apparaat." + "QR-code wordt niet ondersteund" + "Je accountprovider ondersteunt geen %1$s." + "%1$s wordt niet ondersteund" + "Klaar om te scannen" + "Open %1$s op een desktopapparaat" + "Klik op je afbeelding" + "Selecteer %1$s" + "“Nieuw apparaat koppelen”" + "Scan de QR-code met dit apparaat" + "Alleen beschikbaar als je accountprovider dit ondersteunt." + "Open %1$s op een ander apparaat om de QR-code te krijgen" + "Gebruik de QR-code die op het andere apparaat wordt weergegeven." + "Probeer het opnieuw" + "Verkeerde QR-code" + "Ga naar camera-instellingen" + "Je moet %1$s toestemming geven om de camera van je apparaat te gebruiken om verder te gaan." + "Cameratoegang toestaan om de QR-code te scannen" + "Scan de QR-code" + "Opnieuw beginnen" + "Er is een onverwachte fout opgetreden. Probeer het opnieuw." + "Aan het wachten op je andere apparaat" + "Je accountprovider kan om de volgende code vragen om de aanmelding te verifiëren." + "Je verificatiecode" + "Wijzig accountprovider" + "Een privéserver voor medewerkers van Element." + "Matrix is een open netwerk voor veilige, gedecentraliseerde communicatie." + "Dit is waar je gesprekken zullen worden bewaard — net zoals je een e-mailprovider zou gebruiken om je e-mails te bewaren." + "Je staat op het punt je aan te melden bij %1$s" + "Je staat op het punt een account aan te maken op %1$s" + diff --git a/features/login/impl/src/main/res/values-pl/translations.xml b/features/login/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..0c7810a --- /dev/null +++ b/features/login/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,98 @@ + + + "Zmień dostawcę konta" + "Adres serwera domowego" + "Wprowadź wyszukiwane hasło lub adres domeny." + "Szukaj serwera firmowego, społeczności lub prywatnego." + "Znajdź dostawcę konta" + "Tutaj będą przechowywane Twoje konwersacje - w podobnej formie jak wiadomości widnieją na skrzynce e-mail." + "Zamierzasz zalogować się do %s" + "Tutaj będą przechowywane Twoje konwersacje - w podobnej formie jak wiadomości widnieją na skrzynce e-mail." + "Zamierzasz założyć konto na %s" + "Matrix.org jest ogromnym i darmowym serwerem na publicznej sieci Matrix zapewniający bezpieczną i zdecentralizowaną komunikację zarządzaną przez Fundację Matrix.org." + "Inne" + "Użyj innego dostawcy konta, takiego jak własny serwer lub konta służbowego." + "Zmień dostawcę konta" + "Google Play" + "Wymagana jest aplikacja Element Pro na %1$s. Znajdziesz ją w sklepie z aplikacjami." + "Wymagany jest Element Pro" + "Nie mogliśmy połączyć się z tym serwerem domowym. Sprawdź, czy adres URL serwera został wprowadzony poprawnie. Jeśli adres URL jest poprawny, skontaktuj się z administratorem serwera w celu uzyskania dalszej pomocy." + "Serwer nie jest dostępny z powodu problemu pliku .well-known: +%1$s" + "Wybrany dostawca konta nie wspiera synchronizacji przesuwnej. Wymagana jest aktualizacja serwera %1$s." + "%1$s nie posiada zezwolenia na dołączenie do %2$s." + "Aplikacja została skonfigurowana tak, aby zezwalała na: %1$s." + "Dostawca konta %1$s jest niedozwolony." + "URL serwera domowego" + "Wprowadź adres domeny." + "Jaki jest adres Twojego serwera?" + "Wybierz swój serwer" + "Utwórz konto" + "To konto zostało dezaktywowane." + "Nieprawidłowa nazwa użytkownika i/lub hasło" + "To nie jest prawidłowy identyfikator użytkownika. Oczekiwany format: \'@user:homeserver.org\'" + "Ten serwer został skonfigurowany do korzystania z tokenów odświeżania. Nie są one obsługiwane, gdy korzystasz z hasła." + "Wybrany serwer domowy nie obsługuje uwierzytelniania hasłem, ani OIDC. Skontaktuj się z jego administratorem lub wybierz inny serwer domowy." + "Wprowadź swoje dane" + "Matrix to otwarta sieć do bezpiecznej i zdecentralizowanej komunikacji." + "Witaj ponownie!" + "Zaloguj się do %1$s" + "Wersja %1$s" + "Zaloguj się ręcznie" + "Zaloguj się do %1$s" + "Zaloguj się za pomocą kodu QR" + "Utwórz konto" + "Witamy w %1$s. Szybszy i prostszy niż kiedykolwiek." + "Witamy w %1$s. Doładowany, dla szybkości i prostoty." + "Be in your element" + "Nawiązanie bezpiecznego połączenia" + "Nie udało się nawiązać bezpiecznego połączenia z nowym urządzeniem. Twoje istniejące urządzenia są nadal bezpieczne i nie musisz się o nie martwić." + "Co teraz?" + "Spróbuj zalogować się ponownie za pomocą kodu QR, jeśli byłby to problem z siecią" + "Jeśli napotkasz ten sam problem, użyj innej sieci Wi-FI lub danych mobilnych" + "Jeśli to nie zadziała, zaloguj się ręcznie" + "Połączenie nie jest bezpieczne" + "Zostaniesz poproszony o wprowadzenie dwóch cyfr widocznych na tym urządzeniu." + "Wprowadź numer poniżej na innym urządzeniu" + "Zaloguj się na drugie urządzenie lub użyj tego, które jest już zalogowane, a następnie spróbuj ponownie." + "Drugie urządzenie nie jest zalogowane" + "Logowanie zostało anulowane na drugim urządzeniu." + "Prośba o logowanie została anulowana" + "Logowanie zostało odrzucone na drugim urządzeniu." + "Logowanie odrzucone" + "Logowanie wygasło. Spróbuj ponownie." + "Logowanie nie zostało ukończone na czas" + "Twoje drugie urządzenie nie wspiera logowania się do %s za pomocą kodu QR. + +Spróbuj zalogować się ręcznie lub zeskanuj kod QR na innym urządzeniu." + "Kod QR nie jest wspierany" + "Twój dostawca konta nie obsługuje %1$s." + "%1$s nie jest wspierany" + "Gotowy do skanowania" + "Otwórz %1$s na urządzeniu stacjonarnym" + "Kliknij na swój awatar" + "Wybierz %1$s" + "“Powiąż nowe urządzenie”" + "Zeskanuj kod QR za pomocą tego urządzenia" + "Dostępne tylko wtedy, gdy Twój dostawca konta obsługuje tę funkcję." + "Otwórz %1$s na innym urządzeniu, aby uzyskać kod QR" + "Użyj kodu QR widocznego na drugim urządzeniu." + "Spróbuj ponownie" + "Błędny kod QR" + "Przejdź do ustawień aparatu" + "Musisz przyznać uprawnienia %1$s do korzystania z kamery, aby kontynuować." + "Zezwól na dostęp do kamery, aby zeskanować kod QR" + "Skanuj kod QR" + "Zacznij od nowa" + "Wystąpił nieoczekiwany błąd. Spróbuj ponownie." + "Oczekiwanie na drugie urządzenie" + "Twój dostawca konta może poprosić o podany kod, aby zweryfikować logowanie." + "Twój kod weryfikacyjny" + "Zmień dostawcę konta" + "Serwer prywatny dla pracowników Element." + "Matrix to otwarta sieć do bezpiecznej i zdecentralizowanej komunikacji." + "Tutaj będą przechowywane Twoje konwersacje - w podobnej formie jak wiadomości widnieją na skrzynce e-mail." + "Zamierzasz się zalogować do %1$s" + "Wybierz dostawcę konta" + "Zamierzasz utworzyć konto na %1$s" + diff --git a/features/login/impl/src/main/res/values-pt-rBR/translations.xml b/features/login/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..ce67264 --- /dev/null +++ b/features/login/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,98 @@ + + + "Alterar provedor da conta" + "Endereço do servidor-casa" + "Digite um termo de pesquisa ou o endereço de um domínio." + "Procure uma empresa, comunidade ou servidor privado." + "Encontre um provedor de contas" + "Aqui é onde suas conversas vão ficar — assim como você usa um provedor de e-mail para guardar seus e-mails." + "Você está prestes a entrar em %s" + "Aqui é onde suas conversas vão ficar — assim como você usa um provedor de e-mail para guardar seus e-mails." + "Você está prestes a criar uma conta em %s" + "O Matrix.org é um grande servidor gratuito na rede pública Matrix para comunicação segura e descentralizada, administrado pela Fundação Matrix.org." + "Outro" + "Use um provedor de conta diferente, como seu próprio servidor privado ou uma conta corporativa." + "Alterar provedor da conta" + "Google Play" + "O app Element Pro é necessário no %1$s. Por favor, baixe-o da loja." + "Element Pro necessário" + "Não conseguimos acessar esse servidor. Verifique se você digitou a URL do servidor corretamente. Se a URL estiver correta, entre em contato com o administrador do seu servidor-casa para obter mais ajuda." + "O servidor não está disponível devido à um problema no arquivo .well-known: +%1$s" + "O provedor de conta selecionado não é compatível com a sliding sync. É necessária uma atualização do servidor para que você possa usar o %1$s." + "O %1$s não tem permissão para se conectar a %2$s." + "Este app foi configurado para permitir: %1$s." + "O provedor de conta %1$s não é permitido." + "URL do servidor" + "Digite o endereço de um domínio." + "Qual é o endereço do seu servidor?" + "Selecione seu servidor" + "Criar conta" + "Essa conta foi desativada." + "Nome de usuário e/ou senha incorretos" + "Esse não é um identificador de usuário válido. Formato esperado: \'@usuário:servidor.org\'" + "Este servidor está configurado para usar tokens recarregados. Não há suporte a eles ao entrar por uma senha." + "O servidor selecionado não suporta a entrada por senha ou OIDC. Entre em contato com o administrador ou escolha outro servidor." + "Digite seus dados" + "A Matrix é uma rede aberta para comunicação segura e descentralizada." + "Boas-vindas novamente!" + "Entrar em %1$s" + "Versão %1$s" + "Entrar manualmente" + "Entrar em %1$s" + "Entrar com código QR" + "Criar conta" + "Boas-vindas ao %1$s mais rápido de todos os tempos. Turbinado para velocidade e simplicidade." + "Bem-vindo ao %1$s. Turbinado, para velocidade e simplicidade" + "Esteja no seu elemento" + "Estabelecendo uma conexão segura" + "Não foi possível estabelecer uma conexão segura com o novo dispositivo. Seus dispositivos existentes ainda estão seguros e você não precisa se preocupar com eles." + "E agora?" + "Tente entrar novamente com um código QR caso seja um problema de rede" + "Se o problema persistir, tente uma rede Wi-Fi diferente ou use seus dados móveis em vez de Wi-Fi" + "Se isso não funcionar, entre manualmente" + "Conexão insegura" + "Você será solicitado a inserir os dois dígitos mostrados neste dispositivo." + "Digite o número abaixo no seu outro dispositivo" + "Entre no seu outro dispositivo e tente novamente, ou use outro dispositivo que já esteja conectado." + "Outro dispositivo não conectado" + "A entrada foi cancelada no outro dispositivo." + "Solicitação de entrada foi cancelada" + "A entrada foi recusada no outro dispositivo." + "Entrada recusada" + "O processo de entrada expirou. Tente novamente." + "A entrada não foi concluída a tempo" + "Seu outro dispositivo não tem suporte a entrar no %s com um código QR. + +Tente entrar manualmente ou ler o código QR com outro dispositivo." + "Código QR não suportado" + "Seu provedor de conta não tem suporte ao %1$s." + "%1$s não suportado" + "Pronto para ler" + "Abra o %1$s em um computador" + "Clique no seu avatar" + "Selecione %1$s" + "\"Vincular novo dispositivo\"" + "Leia o código QR com este dispositivo" + "Disponível somente se o provedor da sua conta ter suporte." + "Abra o %1$s em outro dispositivo para obter o código QR" + "Use o código QR exibido no outro dispositivo." + "Tente novamente" + "Código QR errado" + "Ir para as configurações da câmera" + "Você deve permitir que o %1$s use a câmera do seu dispositivo para continuar." + "Permita o acesso à câmera para ler o código QR" + "Leia o código QR" + "Começar de novo" + "Ocorreu um erro inesperado. Tente novamente." + "Aguardando seu outro dispositivo" + "Seu provedor de conta pode solicitar o seguinte código para verificar a entrada." + "Seu código de verificação" + "Alterar provedor da conta" + "Um servidor privado para funcionários do Element." + "A Matrix é uma rede aberta para comunicação segura e descentralizada." + "Aqui é onde suas conversas vão ficar — assim como você usa um provedor de e-mail para guardar seus e-mails." + "Você está prestes a entrar em %1$s" + "Escolher um provedor de conta" + "Você está prestes a criar uma conta em %1$s" + diff --git a/features/login/impl/src/main/res/values-pt/translations.xml b/features/login/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..c2aa0d5 --- /dev/null +++ b/features/login/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,98 @@ + + + "Alterar operador de conta" + "Endereço do servidor" + "Insira um termo para pesquisa ou um endereço." + "Pesquisar por uma empresa, comunidade ou servidor privado." + "Encontrar um operador de conta" + "É aqui que as tuas conversas vão ficar — tal como num serviço de e-mail." + "Irás iniciar sessão em %s" + "É aqui que as tuas conversas vão ficar — tal como num serviço de e-mail." + "Irás criar uma conta em %s" + "O Matrix.org é um servidor grande e gratuito na rede pública Matrix para comunicação segura e descentralizada, gerido pela Fundação Matrix.org." + "Outro" + "Utiliza um operador de conta diferente, como o teu próprio servidor privado ou uma conta de trabalho." + "Alterar operador de conta" + "Google Play" + "%1$s requer a Element Pro. Por favor, descarrega-a da app store." + "Element Pro necessária" + "Não foi possível comunicar com este servidor. Por favor, verifica se introduziste o seu URL corretamente. Se sim, contacta o administrador para obteres mais ajuda." + "O servidor não está disponível devido a um problema no ficheiro \".well-known\": +%1$s" + "O operador de conta selecionado não suporta sincronização deslizante (sliding sync). É necessária uma atualização do servidor para poder usar a %1$s." + "%1$s não se pode ligar a %2$s." + "Esta aplicação foi configurada para permitir: %1$s." + "Operador de conta %1$s não permitido." + "URL do servidor" + "Introduz um domínio" + "Qual é o endereço do teu servidor?" + "Seleciona o teu servidor" + "Criar conta" + "Esta conta foi desativada." + "Nome de utilizador ou senha incorretos" + "Identificador de utilizador inválido. Formato esperado: ‘@utilizador:servidor.org’" + "Este servidor está configurado para utilizar \"tokens\" de atualização. Estes não são suportados quando utilizas o início de sessão por senha." + "O servidor selecionado não suporta início de sessão por senha nem por OIDC. Por favor, contacta o teu administrador ou escolhe outro servidor." + "Insere o teus detalhes" + "A Matrix é uma rede aberta de comunicação descentralizada e segura." + "Bem-vindo(a) de volta!" + "Iniciar sessão em %1$s" + "Versão %1$s" + "Iniciar sessão manualmente" + "Iniciar sessão em %1$s" + "Iniciar sessão com código QR" + "Criar conta" + "Bem-vindo(a) à %1$s mais rápida de sempre. Super rápida e simples." + "Bem-vindo(a) à %1$s. Revitalizado, rápido e simples." + "A liberdade do teu elemento" + "A estabelecer uma ligação segura" + "Não foi possível estabelecer uma ligação segura com o novo dispositivo. Os teus outros dispositivos continuam seguros, não precisas de te preocupar com eles." + "E agora?" + "Tenta iniciar sessão novamente com um código QR, caso se trate de um problema de rede" + "Se tiveres o mesmo problema, experimenta uma rede Wi-Fi diferente ou utiliza os teus dados móveis." + "Se isso não funcionar, inicia sessão manualmente" + "Ligação insegura" + "Ser-te-á pedido que insiras os dois dígitos indicados neste dispositivo." + "Insere o número abaixo no teu dispositivo" + "Inicia a sessão no teu outro dispositivo e tenta novamente, ou utiliza outro dispositivo que já tenha a sessão iniciada." + "O outro dispositivo não tem a sessão iniciada" + "O início de sessão foi cancelado no outro dispositivo." + "Pedido de início de sessão cancelado" + "O início de sessão foi rejeitado no outro dispositivo." + "Início de sessão rejeitado" + "O início de sessão expirou. Por favor, tenta novamente." + "O início de sessão não foi concluído a tempo" + "O teu outro dispositivo não suporta o início de sessão na %s com um código QR. + +Tenta iniciar a sessão manualmente ou digitaliza o código QR com outro dispositivo." + "Código QR não suportado" + "O teu operador de conta não suporta %1$s." + "%1$s não suportado" + "Pronto para ler" + "Abre a %1$s num computador" + "Carrega no teu avatar" + "Seleciona %1$s" + "“Ligar novo dispositivo”" + "Lê o código QR com este dispositivo" + "Disponível apenas se o teu operador de conta o permitir." + "Abre a %1$s noutro dispositivo para obteres o código QR" + "Lê o código QR apresentado no outro dispositivo." + "Tentar novamente" + "Código QR inválido" + "Ir para as configurações da câmara" + "Para continuar, tens que dar permissão à %1$s para aceder à câmara do teu dispositivo." + "Permitir o acesso à câmara para ler o código QR" + "Ler o código QR" + "Começar de novo" + "Ocorreu um erro inesperado. Tenta novamente." + "À espera do teu outro dispositivo" + "O teu fornecedor de conta pode pedir o seguinte código para verificar o início de sessão." + "O teu código de verificação" + "Alterar operador de conta" + "Um servidor privado para funcionários da Element." + "A Matrix é uma rede aberta de comunicação descentralizada e segura." + "É aqui que as tuas conversas vão ficar — tal como num serviço de e-mail." + "Irás iniciar sessão em %1$s" + "Escolher operador de conta" + "Irás criar uma conta em %1$s" + 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 0000000..6dddc4d --- /dev/null +++ b/features/login/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,98 @@ + + + "Schimbați furnizorul contului" + "Adresa Homeserver-ului" + "Introduceţi un termen de căutare sau o adresă de domeniu." + "Căutați o companie, o comunitate sau un server privat." + "Găsiți un furnizor de cont" + "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." + "Sunteți pe cale să vă conectați la %s" + "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." + "Sunteți pe cale să creați un cont pe %s" + "Matrix.org este un server mare și gratuit din rețeaua publică Matrix pentru comunicații sigure și descentralizate, administrat de Fundația Matrix.org." + "Altul" + "Utilizați un alt furnizor de cont, cum ar fi propriul server privat sau un cont de serviciu." + "Schimbați furnizorul contului" + "Google Play" + "Aplicația Element Pro este necesară pe %1$s. Descărcați-o din magazin." + "Este necesar Element Pro" + "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." + "Serverul nu este disponibil din cauza unei probleme în fișierul well-known: +%1$s" + "Furnizorul de cont selectat nu acceptă Sliding sync. Pentru a utiliza funcția „ %1$s ”, este necesară o actualizare a serverului." + "%1$s nu are voie să se conecteze la %2$s." + "Această aplicație a fost configurată pentru a permite: %1$s." + "Furnizorul de cont %1$s nu este permis." + "Adresa URL a homeserver-ului" + "Introduceți o adresă de domeniu." + "Care este adresa serverului dumneavoastră?" + "Selectați serverul dumneavoastra" + "Creați un cont" + "Acest cont a fost dezactivat." + "Utilizator și/sau parolă incorecte" + "Acesta nu este un identificator de utilizator valid. Format așteptat: „@user:homeserver.org”" + "Acest server este configurat pentru a utiliza token-uri de reîmprospătare. Acestea nu sunt acceptate atunci când utilizați autentificare bazată pe parolă." + "Homeserver-ul selectat nu acceptă autentificarea prin parola sau OIDC. Te rugăm să contactezi administratorul sau să alegi un alt homeserver." + "Introduceți detaliile" + "Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată." + "Bine ați revenit!" + "Conectați-vă la %1$s" + "Versiunea %1$s" + "Conectați-vă manual" + "Conectați-vă la %1$s" + "Conectați-vă cu un cod QR" + "Creați un cont" + "Bine ați venit la cel mai rapid %1$s din toate timpurile. Supraalimentat pentru viteză și simplitate." + "Bun venit în %1$s. Supraalimentat, pentru viteză și simplitate." + "Fii în Elementul tău" + "Se stabilește o conexiune securizată" + "Nu a putut fi făcută o conexiune sigură la noul dispozitiv. Dispozitivele existente sunt încă în siguranță și nu trebuie să vă faceți griji cu privire la ele." + "Și acum?" + "Încercați să vă conectați din nou cu un cod QR în cazul în care a fost o problemă de rețea." + "Dacă întâmpinați aceeași problemă, încercați o altă rețea Wi-Fi sau utilizați datele mobile în loc de Wi-Fi." + "Dacă nu funcționează, conectați-vă manual" + "Conexiunea nu este sigură" + "Vi se va cere să introduceți cele două cifre afișate pe acest dispozitiv." + "Introduceți numărul de mai jos pe celălalt dispozitiv" + "Autentificați-vă pe celălalt dispozitiv și apoi încercați din nou sau utilizați un alt dispozitiv care este deja conectat." + "Celălalt dispozitiv nu este conectat" + "Autentificarea a fost anulată de pe celălalt dispozitiv." + "Cererea de autentificare a fost anulată" + "Autentificarea a fost refuzată pe celălalt dispozitiv." + "Autentificarea a fost refuzată" + "Autentificarea a expirat. Vă rugăm să încercați din nou." + "Autentificarea nu a fost finalizată la timp" + "Celălalt dispozitiv nu acceptă autentificarea la %s cu un cod QR. + +Încercați să vă autentificați manual sau să scanați codul QR cu un alt dispozitiv." + "Formatul codului QR nu este acceptat." + "Furnizorul dumneavoastră de cont nu acceptă %1$s." + "%1$s nu este acceptat" + "Gata de scanare" + "Deschideți %1$s pe un dispozitiv desktop" + "Faceți clic pe avatarul dumneavoastră" + "Selectați %1$s" + "„Conectați un dispozitiv nou”" + "Scanați codul QR cu acest dispozitiv" + "Disponibil numai dacă furnizorul dumneavoastră de cont îl acceptă." + "Deschideți %1$s pe un alt dispozitiv pentru a obține codul QR" + "Utilizați codul QR afișat pe celălalt dispozitiv." + "Încercați din nou" + "Cod QR greșit" + "Mergeți la setările camerei" + "Trebuie să acordați permisiunea ca %1$s să folosească camera dispozitivului pentru a continua." + "Permiteți accesul la cameră pentru a scana codul QR" + "Scanați codul QR" + "Începeți din nou" + "A apărut o eroare neașteptată. Vă rugăm să încercați din nou." + "În așteptarea celuilalt dispozitiv" + "Furnizorul dumneavoastră de cont poate cere următorul cod pentru a verifica conectarea." + "Codul dumneavoastră de verificare" + "Schimbați furnizorul contului" + "Un server privat pentru angajații Element." + "Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată." + "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." + "Sunteți pe cale să vă conectați la %1$s" + "Alegeți furnizorul de cont" + "Sunteți pe cale să creați un cont pe %1$s" + diff --git a/features/login/impl/src/main/res/values-ru/translations.xml b/features/login/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..8ce5813 --- /dev/null +++ b/features/login/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,98 @@ + + + "Сменить поставщика учетной записи" + "Адрес домашнего сервера" + "Введите поисковый запрос или адрес домена." + "Поиск компании, сообщества или частного сервера." + "Поиск сервера учетной записи" + "Здесь будут храниться ваши разговоры — точно так же, как если бы вы использовали почтового провайдера для хранения своих писем." + "Вы собираетесь войти в %s" + "Здесь будут храниться ваши разговоры — точно так же, как если бы вы использовали почтового провайдера для хранения своих писем." + "Вы собираетесь создать учетную запись на %s" + "Matrix.org — это большой бесплатный сервер в общедоступной сети Matrix для безопасной децентрализованной связи, управляемый Matrix.org Foundation." + "Другое" + "Используйте другого поставщика учетных записей, например, собственный частный сервер или рабочую учетную запись." + "Сменить поставщика учетной записи" + "Google Play" + "Требуется приложение Element Pro для %1$s. Пожалуйста, загрузите его из магазина." + "Требуется Element Pro" + "Нам не удалось связаться с этим домашним сервером. Убедитесь, что вы правильно ввели URL-адрес домашнего сервера. Если URL-адрес указан правильно, обратитесь к администратору домашнего сервера за дополнительной помощью." + "Сервер недоступен из-за проблемы в файле .well-known: +%1$s" + "Выбранный провайдер аккаунтов не поддерживает Sliding sync. Для использования %1$s необходимо обновление сервера." + "%1$s отказано в подключении к %2$s." + "Это приложение настроено таким образом, чтобы разрешить: %1$s." + "Поставщик учетной записи %1$s не разрешен." + "URL-адрес домашнего сервера" + "Введите адрес домена." + "Какой адрес у вашего сервера?" + "Выберите свой сервер" + "Создать учетную запись" + "Данная учётная запись была отключена." + "Неверное имя пользователя и/или пароль" + "Это не корректный идентификатор пользователя. Ожидаемый формат: \'@user:homeserver.org\'" + "Этот сервер настроен на использование токенов обновления. Они не поддерживаются при использовании входа на основе пароля." + "Выбранный домашний сервер не поддерживает пароль или логин OIDC. Пожалуйста, свяжитесь с администратором или выберите другой домашний сервер." + "Введите свои данные" + "Matrix — это открытая сеть для безопасной децентрализованной связи." + "Рады видеть вас снова!" + "Войти в %1$s" + "Версия %1$s" + "Войти вручную" + "Войти в %1$s" + "Войти QR-кодом" + "Создать учетную запись" + "Добро пожаловать в самый быстрый клиент %1$s. Ориентирован на скорость и простоту." + "Добро пожаловать в %1$s. Ориентирован на скорость и простоту." + "Чувствуйте себя как дома с Element" + "Установление безопасного соединения" + "Не удалось установить безопасное соединение с новым устройством. Существующие устройства по-прежнему в безопасности, и вам не нужно беспокоиться о них." + "Что теперь?" + "Попробуйте снова войти в систему с помощью QR-кода, если это была проблема с соединением" + "Если вы столкнулись с той же проблемой, попробуйте сменить точку доступа Wi-Fi или используйте мобильные данные" + "Если это не помогло, войдите вручную" + "Соединение не защищено" + "Вам нужно будет ввести две цифры, показанные на этом устройстве." + "Введите показанный номер на своем другом устройстве" + "Войдите на другое устройство и повторите попытку или используйте другое устройство, на котором уже выполнен вход." + "На другом устройстве вход не выполнен." + "Вход на другом устройстве был отменен." + "Запрос на вход отменен" + "Вход в систему был отклонен на другом устройстве." + "Вход отклонен" + "Срок действия входа истек. Пожалуйста, попробуйте еще раз." + "Вход в систему не был выполнен вовремя" + "Другое устройство не поддерживает вход в %s с помощью QR-кода. + +Попробуйте войти вручную или отсканируйте QR-код на другом устройстве." + "QR-код не поддерживается" + "Поставщик учетной записи не поддерживает %1$s." + "%1$s не поддерживается" + "Готово к сканированию" + "Откройте %1$s на компьютере" + "Нажмите на свое изображение" + "Выберите %1$s" + "\"Привязать новое устройство\"" + "Отсканируйте QR-код с помощью этого устройства" + "Доступно только в том случае, если ваш поставщик учетной записи поддерживает это." + "Откройте %1$s на другом устройстве, чтобы получить QR-код" + "Используйте QR-код, показанный на другом устройстве." + "Повторить попытку" + "Неверный QR-код" + "Перейдите в настройки камеры" + "Чтобы продолжить, вам необходимо разрешить %1$s использовать камеру вашего устройства." + "Разрешите доступ к камере для сканирования QR-кода" + "Сканировать QR-код" + "Начать заново" + "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз." + "В ожидании другого устройства" + "Поставщик учетной записи может запросить следующий код для подтверждения входа." + "Ваш код подтверждения" + "Сменить поставщика учетной записи" + "Частный сервер для сотрудников Element." + "Matrix — это открытая сеть для безопасной децентрализованной связи." + "Здесь будут храниться ваши разговоры — точно так же, как если бы вы использовали почтового провайдера для хранения своих писем." + "Вы собираетесь войти в %1$s" + "Выберите поставщика учетной записи" + "Вы собираетесь создать учетную запись на %1$s" + 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 0000000..6a8acf1 --- /dev/null +++ b/features/login/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,98 @@ + + + "Zmeniť poskytovateľa účtu" + "Adresa domovského servera" + "Zadajte hľadaný výraz alebo adresu domény." + "Vyhľadať spoločnosť, komunitu alebo súkromný server." + "Nájsť poskytovateľa účtu" + "Tu budú žiť vaše konverzácie - podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov." + "Chystáte sa prihlásiť do %s" + "Tu budú žiť vaše konverzácie - podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov." + "Chystáte sa vytvoriť účet na %s" + "Matrix.org je veľký bezplatný server vo verejnej sieti Matrix na bezpečnú, decentralizovanú komunikáciu, ktorý prevádzkuje nadácia Matrix.org." + "Iný" + "Použite iného poskytovateľa účtu, ako napríklad vlastný súkromný server alebo pracovný účet." + "Zmeniť poskytovateľa účtu" + "Google Play" + "Aplikácia Element Pro je potrebná na %1$s Stiahnite si ju z obchodu." + "Vyžaduje sa Element Pro" + "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." + "Server nie je k dispozícii kvôli problému v známom súbore: +%1$s" + "Vybraný poskytovateľ účtu nepodporuje kĺzavú synchronizáciu. Na používanie aplikácie %1$s je potrebná aktualizácia servera," + "%1$s nemá dovolené pripojiť sa k %2$s." + "Táto aplikácia bola nastavená tak, aby povoľovala: %1$s." + "Poskytovateľ účtu %1$s nie je povolený." + "Adresa URL domovského servera" + "Zadajte adresu domény." + "Aká je adresa vášho servera?" + "Vyberte svoj server" + "Vytvoriť účet" + "Tento účet bol deaktivovaný." + "Nesprávne používateľské meno a/alebo heslo" + "Toto nie je platný identifikátor používateľa. Očakávaný formát: \'@pouzivatel:homeserver.sk\'" + "Tento server je nakonfigurovaný tak, aby používal obnovovacie tokeny. Pri prihlasovaní na základe hesla nie sú podporované." + "Vybraný domovský server nepodporuje prihlásenie pomocou hesla alebo OIDC. Obráťte sa na správcu alebo vyberte iný domovský server." + "Zadajte svoje údaje" + "Matrix je otvorená sieť pre bezpečnú a decentralizovanú komunikáciu." + "Vitajte späť!" + "Prihlásiť sa do %1$s" + "Verzia %1$s" + "Prihlásiť sa manuálne" + "Prihlásiť sa do %1$s" + "Prihlásiť sa pomocou QR kódu" + "Vytvoriť účet" + "Vitajte v najrýchlejšom %1$s vôbec. Nadupaný pre rýchlosť a jednoduchosť." + "Vitajte v %1$s. Nadupaný, pre rýchlosť a jednoduchosť." + "Buďte vo svojom elemente" + "Nadväzovanie bezpečného spojenia" + "K novému zariadeniu sa nepodarilo vytvoriť bezpečné pripojenie. Vaše existujúce zariadenia sú stále v bezpečí a nemusíte sa o ne obávať." + "Čo teraz?" + "Skúste sa znova prihlásiť pomocou QR kódu v prípade, že ide o problém so sieťou" + "Ak narazíte na rovnaký problém, vyskúšajte inú sieť Wi-Fi alebo namiesto siete Wi-Fi použite mobilné dáta" + "Ak to nefunguje, prihláste sa manuálne" + "Pripojenie nie je bezpečené" + "Budete požiadaní o zadanie dvoch číslic zobrazených na tomto zariadení." + "Zadajte nižšie uvedené číslo na vašom druhom zariadení" + "Prihláste sa do svojho druhého zariadenia a skúste to znova alebo použite iné zariadenie, ktoré už je prihlásené." + "Druhé zariadenie nie je prihlásené" + "Prihlásenie bolo zrušené na druhom zariadení." + "Žiadosť o prihlásenie bola zrušená" + "Prihlásenie bolo zamietnuté na druhom zariadení." + "Prihlásenie bolo odmietnuté" + "Platnosť prihlásenia vypršala. Skúste to prosím znova." + "Prihlásenie nebolo včas dokončené" + "Vaše druhé zariadenie nepodporuje prihlásenie do aplikácie %s pomocou QR kódu. + +Skúste sa prihlásiť manuálne alebo naskenujte QR kód pomocou iného zariadenia." + "QR kód nie je podporovaný" + "Poskytovateľ vášho účtu nepodporuje %1$s." + "%1$s nie je podporovaný" + "Pripravené na skenovanie" + "Otvorte %1$s na stolnom zariadení" + "Kliknite na svoj obrázok" + "Vyberte %1$s" + "„Prepojiť nové zariadenie“" + "Naskenujte QR kód pomocou tohto zariadenia" + "Dostupné iba v prípade, že to podporuje váš poskytovateľ účtu." + "Ak chcete získať QR kód, otvorte %1$s na inom zariadení" + "Použite QR kód zobrazený na druhom zariadení." + "Skúste to znova" + "Nesprávny QR kód" + "Prejsť na nastavenia fotoaparátu" + "Ak chcete pokračovať, musíte udeliť povolenie aplikácii %1$s používať fotoaparát vášho zariadenia." + "Povoľte prístup k fotoaparátu na naskenovanie QR kódu" + "Naskenovať QR kód" + "Začať odznova" + "Vyskytla sa neočakávaná chyba. Prosím, skúste to znova." + "Čaká sa na vaše druhé zariadenie" + "Váš poskytovateľ účtu môže požiadať o nasledujúci kód na overenie prihlásenia." + "Váš overovací kód" + "Zmeniť poskytovateľa účtu" + "Súkromný server pre zamestnancov spoločnosti Element." + "Matrix je otvorená sieť pre bezpečnú a decentralizovanú komunikáciu." + "Tu budú žiť vaše konverzácie - podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov." + "Chystáte sa prihlásiť do %1$s" + "Vyberte poskytovateľa účtu" + "Chystáte sa vytvoriť účet na %1$s" + diff --git a/features/login/impl/src/main/res/values-sv/translations.xml b/features/login/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..33fb76b --- /dev/null +++ b/features/login/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,98 @@ + + + "Byt kontoleverantör" + "Hemserveradress" + "Ange ett sökord eller en domänadress." + "Sök efter ett företag, en gemenskap eller en privat server." + "Hitta en kontoleverantör" + "Det är här dina konversationer kommer att sparas - precis som du skulle använda en e-postleverantör för att spara dina e-brev." + "Du är på väg att logga in på %s" + "Det är här dina konversationer kommer att sparas - precis som du skulle använda en e-postleverantör för att spara dina e-brev." + "Du är på väg att skapa ett konto på %s" + "Matrix.org är en stor gratisserver på det offentliga Matrix-nätverket för säker, decentraliserad kommunikation, som drivs av Matrix.org Foundation." + "Annan" + "Använd en annan kontoleverantör, till exempel din egen privata server eller ett jobbkonto." + "Byt kontoleverantör" + "Google Play" + "Element Pro-appen krävs på %1$s. Ladda ner den från butiken." + "Element Pro krävs" + "Vi kunde inte nå den här hemservern. Kontrollera att du har angett hemserverns URL korrekt. Om URL:en är korrekt kontaktar du administratören för hemservern för ytterligare hjälp." + "Sliding Sync är inte tillgängligt på grund av ett problem i .well-known-filen: +%1$s" + "Den valda kontoleverantören stöder inte sliding sync. En uppgradering till servern behövs för att använda %1$s." + "%1$s får inte ansluta till %2$s." + "Den här appen har konfigurerats för att tillåta: %1$s." + "Kontoleverantör %1$s är inte tillåten." + "Hemserverns URL" + "Ange en domänadress." + "Vad är adressen till din server?" + "Välj din server" + "Skapa konto" + "Detta konto har avaktiverats." + "Felaktigt användarnamn och/eller lösenord" + "Detta är inte en giltig användaridentifierare. Förväntat format: \'@användare:hemserver.org\'" + "Den här servern är konfigurerad för att använda uppdateringstokens. Dessa stöds inte när du använder lösenordsbaserad inloggning." + "Den valda hemservern stöder inte lösenord eller OIDC-inloggning. Kontakta administratören eller välj en annan hemserver." + "Ange dina uppgifter" + "Matrix är ett öppet nätverk för säker, decentraliserad kommunikation." + "Välkommen tillbaka!" + "Logga in på %1$s" + "Version %1$s" + "Logga in manuellt" + "Logga in på %1$s" + "Logga in med QR-kod" + "Skapa konto" + "Välkommen till den snabbaste %1$s någonsin. Superladdad för snabbhet och enkelhet." + "Välkommen till %1$s. Superladdad, för snabbhet och enkelhet." + "Var i ditt rätta element" + "Upprättar en säker anslutning" + "En säker anslutning kunde inte göras till den nya enheten. Dina befintliga enheter är fortfarande säkra och du behöver inte oroa dig för dem." + "Nu då?" + "Pröva att logga in igen med en QR-kod ifall detta skulle vara ett nätverksproblem" + "Om du stöter på samma problem, prova ett annat wifi-nätverk eller använd din mobildata istället för wifi" + "Om det inte fungerar, logga in manuellt" + "Anslutningen är inte säker" + "Du kommer att bli ombedd att ange de två siffrorna som visas på den här enheten." + "Ange numret nedan på din andra enhet" + "Logga in på din andra enhet och försök sedan igen, eller använd en annan enhet som redan är inloggad." + "Den andra enheten är inte inloggad" + "Inloggningen avbröts på den andra enheten." + "Inloggningsförfrågan avbröts" + "Inloggningen avvisades på den andra enheten." + "Inloggning avvisad" + "Inloggningen har löpt ut. Vänligen försök igen." + "Inloggningen slutfördes inte i tid" + "Din andra enhet stöder inte inloggning i %s med en QR-kod. + +Prova att logga in manuellt eller skanna QR-koden med en annan enhet." + "QR-kod stöds inte" + "Din kontoleverantör stöder inte %1$s." + "%1$s stöds inte" + "Redo att skanna" + "Öppna %1$s på en skrivbordsenhet" + "Klicka på din avatar" + "Välj %1$s" + "”Länka ny enhet”" + "Skanna QR-koden med den här enheten" + "Endast tillgängligt om din kontoleverantör stöder det." + "Öppna %1$s på en annan enhet för att få QR-koden" + "Använd QR-koden som visas på den andra enheten." + "Försök igen" + "Fel QR-kod" + "Gå till kamerainställningar" + "Du måste ge tillstånd för %1$s att använda enhetens kamera för att kunna fortsätta." + "Tillåt kameraåtkomst för att skanna QR-koden" + "Skanna QR-koden" + "Börja om" + "Ett oväntat fel inträffade. Vänligen försök igen." + "Väntar på din andra enhet" + "Din kontoleverantör kan be om följande kod för att verifiera inloggningen." + "Din verifieringskod" + "Byt kontoleverantör" + "En privat server för Element-anställda." + "Matrix är ett öppet nätverk för säker, decentraliserad kommunikation." + "Det är här dina konversationer kommer att sparas - precis som du skulle använda en e-postleverantör för att spara dina e-brev." + "Du är på väg att logga in på %1$s" + "Välj kontoleverantör" + "Du är på väg att skapa ett konto på %1$s" + diff --git a/features/login/impl/src/main/res/values-tr/translations.xml b/features/login/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..6d2bbee --- /dev/null +++ b/features/login/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,88 @@ + + + "Hesap sağlayıcısını değiştir" + "Ana sunucu adresi" + "Bir arama terimi veya alan adı adresi girin." + "Bir şirket, topluluk veya özel sunucu arayın." + "Bir hesap sağlayıcısı bulun" + "Konuşmalarınızın saklanacağı yer burasıdır - tıpkı e-postalarınızı saklamak için bir e-posta sağlayıcısı kullandığınız gibi." + "Oturum açmak üzeresiniz %s" + "Konuşmalarınızın saklanacağı yer burasıdır - tıpkı e-postalarınızı saklamak için bir e-posta sağlayıcısı kullandığınız gibi." + "%s üzerinde bir hesap oluşturmak üzeresiniz" + "Matrix.org, Matrix.org Vakfı tarafından yönetilen, güvenli, merkezi olmayan iletişim için halka açık Matrix ağında büyük, ücretsiz bir sunucudur." + "Diğer" + "Kendi özel sunucunuz veya iş hesabınız gibi farklı bir hesap sağlayıcı kullanın." + "Hesap sağlayıcısını değiştir" + "Bu ana sunucuya ulaşamadık. Lütfen ana sunucu URL\'sini doğru girip girmediğinizi kontrol edin. URL doğruysa, daha fazla yardım için ana sunucu yöneticinize başvurun." + "Well-known dosyasında bir sorun nedeniyle sunucu kullanılamıyor: +%1$s" + "Ana sunucu URL\'si" + "Sunucunuzun adresi nedir?" + "Sunucunuzu seçin" + "Hesap oluştur" + "Hesap devre dışı bırakıldı." + "Yanlış kullanıcı adı ve/veya şifre" + "Bu geçerli bir kullanıcı tanımlayıcısı değil. Kullanılması gereken biçim: \'@user:homeserver.org\'" + "Bu sunucu, yenileme belirteçlerini kullanacak şekilde yapılandırılmıştır. Parola tabanlı oturum açma kullanılırken bunlar desteklenmez." + "Seçilen ana sunucu parola veya OIDC oturum açmayı desteklemiyor. Lütfen yöneticinizle iletişime geçin veya başka bir ana sunucu seçin." + "Bilgilerinizi girin" + "Matrix, güvenli, merkezi olmayan iletişim için açık bir ağdır." + "Tekrar hoş geldiniz!" + "%1$s adresinde oturum aç" + "Manuel olarak oturum aç" + "%1$s adresinde oturum aç" + "QR kodu ile giriş yap" + "Hesap oluştur" + "Şimdiye kadarki en hızlı %1$s hoş geldiniz. Hız ve basitlik için güçlendirildi." + "%1$s\'e hoş geldiniz. Hız ve basitlik için süper şarjlı." + "Kendi elementinizde olun" + "Güvenli bir bağlantı kuruluyor" + "Yeni cihaza güvenli bir bağlantı kurulamadı. Mevcut cihazlarınız hala güvende ve onlar için endişelenmenize gerek yok." + "Şimdi ne olacak?" + "Bunun bir ağ sorunu olması ihtimaline karşı bir QR koduyla tekrar oturum açmayı deneyin" + "Aynı sorunla karşılaşırsanız, farklı bir wifi ağı deneyin veya wifi yerine mobil verinizi kullanın" + "Bu işe yaramazsa, manuel olarak oturum açın" + "Bağlantı güvenli değil" + "Bu cihazda gösterilen iki haneyi girmeniz istenecektir." + "Aşağıdaki numarayı diğer cihazınıza girin" + "Diğer cihazınızda oturum açın ve ardından tekrar deneyin veya zaten oturum açmış başka bir cihaz kullanın." + "Diğer cihaz oturum açmamış" + "Oturum açma işlemi diğer cihazda iptal edildi." + "Oturum açma isteği iptal edildi" + "Diğer cihazda oturum açma işlemi reddedildi." + "Oturum açma reddedildi" + "Oturum açma süresi doldu. Lütfen tekrar deneyin." + "Oturum açma işlemi zamanında tamamlanmadı" + "Diğer cihazınız %s QR koduyla oturum açmayı desteklemiyor. + +Manuel olarak oturum açmayı deneyin veya QR kodunu başka bir cihazla tarayın." + "QR kodu desteklenmiyor" + "Hesap sağlayıcınız %1$s desteklemiyor." + "%1$s desteklenmiyor" + "Taramaya hazır" + "Bir masaüstü cihazda %1$s açın" + "Avatarınıza tıklayın" + "Seç %1$s" + "\"Yeni cihaz bağla\"" + "QR kodunu bu cihazla tarayın" + "Yalnızca hesap sağlayıcınız destekliyorsa kullanılabilir." + "QR kodunu almak için %1$s uygulamasını başka bir cihazda açın" + "Diğer cihazda gösterilen QR kodunu kullan." + "Tekrar deneyin" + "Yanlış QR kodu" + "Kamera ayarlarına git" + "Devam etmek için %1$s cihazınızın kamerasını kullanmasına izin vermeniz gerekir." + "QR kodunu taramak için kamera erişimine izin verin" + "QR kodunu tara" + "Yeniden Başla" + "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin." + "Diğer cihazınız bekleniyor" + "Hesap sağlayıcınız, oturum açmayı doğrulamak için aşağıdaki kodu isteyebilir." + "Doğrulama kodunuz" + "Hesap sağlayıcısını değiştir" + "Element çalışanları için özel bir sunucu." + "Matrix, güvenli, merkezi olmayan iletişim için açık bir ağdır." + "Konuşmalarınızın saklanacağı yer burasıdır - tıpkı e-postalarınızı saklamak için bir e-posta sağlayıcısı kullandığınız gibi." + "%1$s sunucusunda oturum açmak üzeresiniz" + "%1$s sunucusunda bir hesap oluşturmak üzeresiniz" + diff --git a/features/login/impl/src/main/res/values-uk/translations.xml b/features/login/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..697c21a --- /dev/null +++ b/features/login/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,98 @@ + + + "Змінити провайдера облікового запису" + "Адреса домашнього сервера" + "Уведіть пошуковий термін або адресу домену." + "Пошук компанії, спільноти або приватного сервера." + "Знайти провайдера облікового запису" + "Тут розміщуватимуться ваші розмови — так само як у поштовій скриньці для зберігання своїх електронних листів." + "Ви збираєтесь увійти в %s" + "Тут розміщуватимуться ваші розмови — так само як у поштовій скриньці для зберігання своїх електронних листів." + "Ви збираєтеся створити обліковий запис на %s" + "Matrix.org — це великий безплатний сервер у загальнодоступній мережі Matrix для безпечного децентралізованого спілкування, яким керує Matrix.org Foundation." + "Інше" + "Використати іншого провайдера облікових записів, наприклад, власний приватний сервер або робочий обліковий запис." + "Змінити провайдера облікового запису" + "Google Play" + "Необхідно встановити застосунок Element Pro на %1$s. Завантажте його з магазину." + "Потрібен Element Pro" + "Не вдалося під\'єднатися до цього домашнього сервера. Перевірте правильність введеної URL-адреси домашнього сервера. Якщо URL-адреса правильна, зверніться по додаткову допомогу до адміністратора домашнього сервера." + "Сервер недоступний через помилку у файлі well-known: +%1$s" + "Вибраний постачальник облікових записів не підтримує sliding sync. Щоб користуватися %1$s необхідно оновити сервер." + "%1$s не дозволено під\'єднуватися до %2$s." + "Цей застосунок налаштовано так, щоб дозволити: %1$s." + "Постачальник облікового запису %1$s не дозволений." + "URL-адреса домашнього сервера" + "Введіть адресу домену." + "Яка адреса вашого сервера?" + "Виберіть свій сервер" + "Створити обліковий запис" + "Цей обліковий запис було деактивовано." + "Неправильне ім\'я користувача та/або пароль" + "Це недійсний ідентифікатор користувача. Очікуваний формат: \'@user:homeserver.org\'" + "Цей сервер налаштований на використання оновлюваних токенів. Вони не підтримуються, якщо використовується вхід за допомогою основі пароля." + "Обраний домашній сервер не підтримує вхід за допомогою пароля або OIDC. Зверніться до адміністратора або виберіть інший домашній сервер." + "Введіть свої дані" + "Matrix — це відкрита мережа для безпечної, децентралізованої комунікації." + "З поверненням!" + "Увійти в %1$s" + "Версія %1$s" + "Увійти вручну" + "Увійти в %1$s" + "Увійти за допомогою QR-коду" + "Створити обліковий запис" + "Ласкаво просимо до найшвидшого %1$s. Заряджений для швидкості та простоти." + "Ласкаво просимо до %1$s. Заряджений, для швидкості та простоти." + "Будьте у своєму element" + "Встановлення безпечного з\'єднання" + "Не вдалося встановити безпечне з\'єднання з новим пристроєм. Ваші наявні пристрої досі в безпеці, і вам не потрібно про них турбуватися." + "Що тепер?" + "Спробуйте увійти ще раз за допомогою QR-коду, якщо це була проблема з мережею" + "Якщо ви зіткнулися з тією ж проблемою, спробуйте іншу мережу Wi-Fi або використовуйте мобільний інтернет замість Wi-Fi" + "Якщо це не спрацює, увійдіть вручну" + "З\'єднання не безпечне" + "Вас попросять ввести дві цифри, показані на цьому пристрої." + "Введіть номер нижче на іншому пристрої" + "Увійдіть на іншому пристрої та спробуйте ще раз або скористайтеся іншим пристроєм, на якому ви вже ввійшли." + "Вхід на іншому пристрої не виконано" + "Вхід було скасовано на іншому пристрої." + "Запит на вхід скасовано" + "Вхід був відхилений на іншому пристрої." + "Вхід відхилено" + "Термін входу сплив. Будь ласка, спробуйте ще раз." + "Вхід не було завершено вчасно" + "Ваш інший пристрій не підтримує вхід у %s за допомогою QR-коду. + +Спробуйте ввійти вручну або відскануйте QR-код за допомогою іншого пристрою." + "QR-код не підтримується" + "Постачальник вашого облікового запису не підтримує %1$s." + "%1$s не підтримується" + "Готовий до сканування" + "Відкрийте %1$s на комп\'ютері" + "Натисніть на свою аватарку" + "Виберіть %1$s" + "“Під\'єднати новий пристрій”" + "Зіскануйте QR-код цим пристроєм" + "Доступно лише в тому випадку, якщо ваш постачальник облікового запису підтримує цю функцію." + "Відкрийте %1$s на іншому пристрої, щоб отримати QR-код" + "Використовуйте QR-код, показаний на іншому пристрої." + "Спробуйте ще раз" + "Неправильний QR-код" + "Перейти до налаштувань камери" + "Вам потрібно дати дозвіл %1$s на використання камери вашого пристрою, щоб продовжити." + "Надайте доступ до камери, щоб сканувати QR-код" + "Зіскануйте QR-код" + "Почати спочатку" + "Сталася несподівана помилка. Будь ласка, спробуйте ще раз." + "Чекаємо на ваш інший пристрій" + "Постачальник облікового запису може попросити вас ввести код нижче для підтвердження входу." + "Ваш код підтвердження" + "Змінити провайдера облікового запису" + "Приватний сервер для співробітників Element." + "Matrix — це відкрита мережа для безпечної, децентралізованої комунікації." + "Тут розміщуватимуться ваші розмови — так само як у поштовій скриньці для зберігання своїх електронних листів." + "Ви збираєтесь увійти в %1$s" + "Вибір постачальника облікового запису" + "Ви збираєтеся створити обліковий запис на %1$s" + diff --git a/features/login/impl/src/main/res/values-ur/translations.xml b/features/login/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..334d5b6 --- /dev/null +++ b/features/login/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,87 @@ + + + "اکاؤنٹ فراہم کنندہ بدلیں" + "منزلی خادم پتہ" + "تلاش کی اصطلاح یا عنوانِ مجال درج کریں۔" + "کسی شرکت، برادری، یا نجی خادم کیلئے تلاش کریں۔" + "ایک کھاتہ فراہم کنندہ ڈھونڈیں" + "یہ وہ جگہ ہے جہاں آپ کی گفتگوئیں زندہ رہیں گی — بالکل اسی طرح جیسے آپ اپنی برقی خطوط رکھنے کے لیے برقی ڈاک فراہم کنندہ کا استعمال کرتے ہوں گے۔" + "آپ %s میں داخل ہونے والے ہیں" + "یہ وہ جگہ ہے جہاں آپ کی گفتگوئیں زندہ رہیں گی — بالکل اسی طرح جیسے آپ اپنی برقی خطوط رکھنے کے لیے برقی ڈاک فراہم کنندہ کا استعمال کرتے ہوں گے۔" + "آپ %s پر ایک کھاتہ تخلیق کرنے والے ہیں" + "Matrix.org Matrix.org فاؤنڈیشن کے ذریعہ محفوظ، غیر مرکزی مواصلات کے لئے عوامی میٹرکس شبکہ پر ایک بڑا، مفت خادم ہے۔" + "دیگر" + "ایک مختلف کھاتہ فراہم کنندہ استعمال کریں، جیسے آپ کا اپنا نجی خادم یا کام کا کھاتہ۔" + "اکاؤنٹ فراہم کنندہ بدلیں" + "ہم اس منزلی خادم تک نہیں پہنچ سکے۔ برائے مہربانی پڑتال کریں کہ آپ نے ہوم سرور کا عنوان صحیح طریقے سے درج کیا ہے۔ اگر عنوان درست ہے تو مزید مدد کے لیے اپنے منزلی خادم کے منتظم سے رابطہ کریں۔" + "مشہور فائل میں کسی مسئلے کی وجہ سے سلائیڈنگ سنک دستیاب نہیں ہے: +%1$s" + "منزلی خادم عنوان" + "آپکے خادم کا پتہ کیا ہے؟" + "اپنا خادم منتخب کریں" + "کھاتہ تخلیق کریں" + "یہ کھاتہ غیر فعال کر دیا گیا ہے۔" + "غلط صارف نام اور/یا لفظ عبور" + "یہ صالح صارف شناسه نہیں ہے۔ متوقع شکل: @صارف:منزلی خادم" + "یہ خادم تازگی کی رموزِ ممیز استعمال کرنے کے لئے تشکیل دیا گیا ہے۔ لفظ عبور پر مبنی دخول استعمال کرتے ہوئے ان کی حمایت نہیں کی جاتی۔" + "منتخب منزلی خادم کلمۂ عبوری یا OIDC دخول کا تعاون نہیں کرتا۔ برائے مہربانی اپنے منتظم سے رابطہ کریں یا کوئی اور منزلی خادم چنیں۔" + "اپنی تفصیلات درج کریں" + "میٹرکس محفوظ، غیر مرکزی مواصلت کے لئے ایک کھلا شبکہ ہے۔" + "واپس خوش آمدید!" + "%1$s میں داخل ہوں" + "دستی طور پر داخل ہوں" + "%1$s میں داخل ہوں" + "کیو آر (QR) رمز کیساتھ داخل ہوں" + "کھاتہ تخلیق کریں" + "اب تک کی تیز ترین %1$s میں خوش آمدید۔ رفتار اور سادگی کے لئے مشحون" + "%1$s پر خوش آمدید۔ شحن فائق شدہ، رفتار اور سادگی کیلئے۔" + "اپنے عنصر میں رہیں" + "محفوظ اتصال قائم کر رہا ہے" + "نئے آلے سے محفوظ اتصال نہیں بنایا جا سکا۔ آپ کے موجودہ آلات اب بھی محفوظ ہیں اور آپ کو ان کے بارے میں فکر کرنے کی ضرورت نہیں ہے۔" + "اب کیا؟" + "اگر یہ شبکہ کا مسئلہ تھا تو کیو آر رمز کے ساتھ دوبارہ داخل ہونے کی کوشش کریں۔" + "اگر آپ کو بھی یہی مسئلہ درپیش ہو، تو کوئی دوسرا وائی فائی شبکہ آزمائیں یا وائی فائی کے بجائے اپنے محمول بیانات استعمال کریں۔" + "اگر یہ کام نہ کرے، تو دستی طور پر داخل ہوں" + "اتصال محفوظ نہیں" + "آپ سے اس آلے پر دکھائے گئے دو ہندسوں کو درج کرنے کو کہا جائے گا۔" + "اپنے دوسرے آلے پر درج ذیل نمبر درج کریں" + "اپنے دوسرے آلے میں داخل ہوں اور پھر دوبارہ کوشش کریں، یا کوئی دوسرا آلہ استعمال کریں جو پہلے سے دخول شدہ ہے۔" + "دوسرا آلہ دخول شدہ نہیں" + "دوسرے آلے پر دخول منسوخ کر دیا گیا تھا۔" + "دخول کی درخواست منسوخ" + "دوسرے آلہ پر دخول کو مسترد کر دیا گیا تھا۔" + "دخول مسترد کیا گیا" + "دخول کی میعاد ختم۔ برائے مہربانی دوبارہ کوشش کریں۔" + "دخول وقت پر مکمل نہیں ہوا تھا" + "آپ کا دوسرا آلہ کیو آر رمز کے ساتھ %s میں دخول کا تعاون نہیں کرتا۔ + +دستی طور پر داخل ہونے کی کوشش کریں ، یا کسی دوسرے آلے سے کیو آر رمز مسح ضوئی کریں۔" + "کر رمز غیر تعاون یافتہ" + "آپ کا کھاتہ فراہم کنندہ %1$s کا تعاون نہیں کرتا۔" + "%1$s تعاون یافتہ نہیں" + "مسح ضوئی کیلئے تیار" + "برمیز آلے پر %1$s کھولیں" + "اپنے اوتار پر دبائیں" + "%1$s منتخب کریں" + "”نیا آلہ مربوط کریں“" + "اس آلے کے ساتھ کیو آر رمز مسح ضوئی کریں" + "کیو آر رمز حاصل کرنے کے لئے کسی دوسرے آلے پر %1$s کھولیں" + "دوسرے آلے پر دکھایا گیا کیو آر رمز استعمال کریں۔" + "دوبارہ کوشش کریں" + "غلط کیو آر رمز" + "تصویرگر کی ترتیبات پر جائیں" + "جاری رکھنے کے لیے آپ %1$s کو اپنے آلے کا تصویرگر استعمال کرنے کی اجازت دینے کی ضرورت ہے۔" + "کیو آر رمز کو مسح ضوئی کرنے کے لئے تصویرگر تک رسائی کی اجازت دیں" + "کیو آر رمز مسح ضوئی کریں" + "از سر نو شروع کریں" + "ایک غیر متوقع نقص واقع ہوا۔ برائے مہربانی دوبارہ کوشش کریں۔" + "آپکے دوسرے آلے کا منتظر" + "آپ کا کھاتہ فراہم کنندہ دخول کی توثیق کے لیے درج ذیل رمز کا مطالبہ کر سکتا ہے۔" + "آپکا توثیقی رمز" + "اکاؤنٹ فراہم کنندہ بدلیں" + "ایلیمنٹ کے ملازمین کیلئے ایک نجی خادم۔" + "میٹرکس محفوظ، غیر مرکزی مواصلت کے لئے ایک کھلا شبکہ ہے۔" + "یہ وہ جگہ ہے جہاں آپ کی گفتگوئیں زندہ رہیں گی — بالکل اسی طرح جیسے آپ اپنی برقی خطوط رکھنے کے لیے برقی ڈاک فراہم کنندہ کا استعمال کرتے ہوں گے۔" + "آپ %1$s میں داخل ہونے والے ہیں" + "آپ %1$s پر ایک کھاتہ بنانے والے ہیں" + diff --git a/features/login/impl/src/main/res/values-uz/translations.xml b/features/login/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..9c6c297 --- /dev/null +++ b/features/login/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,97 @@ + + + "Hisob provayderini o\'zgartiring" + "Uy server manzili" + "Qidiruv so\'zini yoki domen manzilini kiriting." + "Kompaniya, jamoa yoki shaxsiy serverni qidiring." + "Hisob provayderini toping" + "Bu sizning suhbatlaringiz yashaydigan joy - xuddi siz elektron pochta xabarlaringizni saqlash uchun elektron pochta provayderidan foydalanganingiz kabi." + "Siz %sga kirmoqchisiz" + "Bu sizning suhbatlaringiz yashaydigan joy - xuddi siz elektron pochta xabarlaringizni saqlash uchun elektron pochta provayderidan foydalanganingiz kabi." + "Siz %sda hisob yaratmoqchisiz" + "Matrix.org - bu Matrix.org Jamg\'armasi tomonidan boshqariladigan xavfsiz, markazlashtirilmagan aloqa uchun ommaviy Matrix tarmog\'idagi katta, bepul server." + "Boshqa" + "Shaxsiy serveringiz yoki ishchi hisob qaydnomangiz kabi boshqa hisob provayderidan foydalaning." + "Hisob provayderini o\'zgartiring" + "Google Play" + "%1$s da Element Pro ilovasi talab qilinadi. Iltimos, do‘kondan yuklab oling." + "Element Pro talab qilinadi" + "Bu uy serveriga kira olmadik. Iltimos, uy serverining URL manzilini to\'ri kiritganingizni tekshiring. Agar URL toʻgʻri boʻlsa, qoʻshimcha yordam olish uchun uy serveri administratoriga murojaat qiling." + ".well-known faylidagi muammo tufayli server mavjud emas: %1$s" + "Tanlangan hisob provayderi siljitish sinxronizatsiyasini qo‘llab-quvvatlamaydi. %1$s ishlatish uchun serverni yangilash zarur." + "%1$s uchun %2$s bilan ulanishga ruxsat berilmagan." + "Bu ilova quyidagilarga ruxsat berish uchun sozlangan: %1$s ." + "Hisob provayderi %1$s ga ruxsat berilmagan." + "Uy serverining URL manzili" + "Domen manzilini kiriting." + "Serveringizning manzili nima?" + "Serveringizni tanlang" + "Hisob yaratish" + "Bu hisob o‘chirilgan." + "Notog\'ri foydalanuvchi nomi va/yoki parol" + "Bu haqiqiy foydalanuvchi identifikatori emas. Kutilayotgan format: \'@user:homeserver.org\'" + "Ushbu server yangilash tokenlaridan foydalanishga moslashtirilgan. Parolga asoslangan tizimga kirishda bunday tokenlar qoʻllab-quvvatlanmaydi." + "Tanlangan uy serveri parol yoki OIDC loginni qo\'lab-quvvatlamaydi. Iltimos, administratoringizga murojaat qiling yoki boshqa uy serverini tanlang." + "Tafsilotlaringizni kiriting" + "Matrix xavfsiz, markazlashmagan aloqa uchun ochiq tarmoqdir." + "Qaytib kelganingizdan xursandmiz!" + "Kirish%1$s" + "%1$s versiya" + "Qo\'lda tizimga kiring" + "Kirish%1$s" + "QR kod bilan tizimga kiring" + "Hisob yaratish" + "Eng tezkor %1$sga xush kelibsiz. Tezlik va oddylik uchun super zaryadlangan." + "%1$sga Xush kelibsiz. Tezlik va oddylik uchun o\'ta zaryadlangan." + "Elementingizda bo\'ling" + "Xavfsiz aloqa oʻrnatish" + "Yangi qurilmaga xavfsiz ulanish amalga oshirilmadi. Mavjud qurilmalaringiz hali ham xavfsiz va ular haqida qaygʻurishingiz shart emas." + "Endi nima?" + "Agar bu tarmoq muammosi boʻlsa, QR kod bilan qayta kiring" + "Xuddi shu muammoga duch kelsangiz, boshqa wifi tarmogʻini sinang yoki wifi oʻrniga mobil internetdan foydalaning" + "Agar bunisi ishlamasa, oddiy usulda kiring" + "Ulanish xavfsiz emas" + "Sizdan ushbu qurilmada koʻrsatilgan ikkita raqamni kiritish soʻraladi." + "Narigi qurilmada quyidagi raqamni kiriting" + "Boshqa qurilmangizga kiring va qayta urining yoki allaqachon kirilgan boshqa qurilmadan foydalaning." + "Boshqa qurilma tizimga kirmagan" + "Boshqa qurilmadan hisobga kirish bekor qilindi." + "Tizimga kirish soʻrovi bekor qilindi" + "Boshqa qurilmadan hisobga kirish bekor qilindi." + "Tizimga kirish rad etildi" + "Kirish muddati tugagan. Iltimos, qayta urinib koʻring." + "Kirish oʻz vaqtida tugallanmagan" + "Boshqa qurilmangiz %s hisobiga QR kod orqali kirishni qoʻllab-quvvatlamaydi. + +Oddiy usulda kiring yoki boshqa qurilma bilan QR kodni skanerlang." + "QR kod qoʻllab-quvvatlanmaydi" + "Hisob provayderingiz %1$s bilan ishlamaydi." + "%1$s qoʻllab-quvvatlanmaydi" + "Skanerlashga tayyor" + "%1$sʼni kompyuterda oching" + "Avataringizni bosing" + "%1$sʼni tanlang" + "\"Yangi qurilmani bogʻlash\"" + "Bu qurilma bilan QR kodni skanerlang" + "Faqatgina hisob provayderi tomonidan qo‘llab-quvvatlansa mavjud bo‘ladi." + "QR-kodni olish uchun %1$sʼni boshqa qurilmada oching" + "Narigi qurilmada koʻrsatilgan QR koddan foydalaning." + "Qayta urinib ko\'ring" + "QR kod notoʻgʻri" + "Kamera sozlamalarini ochish" + "Davom etish uchun %1$s qurilmangiz kamerasidan foydalanishiga ruxsat berishingiz kerak." + "QR kodni skanerlash uchun kameraga ruxsat bering" + "QR kodni skanerlash" + "Qaytadan boshlang" + "Kutilmagan xatolik yuz berdi. Qayta urining." + "Boshqa qurilmangiz kutilmoqda" + "Hisob provayderingiz hisobga kirishni tasdiqlash uchun quyidagi kodni soʻrashi mumkin." + "Tasdiqlash kodingiz" + "Hisob provayderini o\'zgartiring" + "Element xodimlari uchun shaxsiy server." + "Matrix xavfsiz, markazlashmagan aloqa uchun ochiq tarmoqdir." + "Bu sizning suhbatlaringiz yashaydigan joy - xuddi siz elektron pochta xabarlaringizni saqlash uchun elektron pochta provayderidan foydalanganingiz kabi." + "Siz tizimga kirmoqchisiz%1$s" + "Hisob provayderini tanlang" + "Hisob yaratmoqchisiz%1$s" + diff --git a/features/login/impl/src/main/res/values-zh-rTW/translations.xml b/features/login/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..1b0b94d --- /dev/null +++ b/features/login/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,98 @@ + + + "更改帳號提供者" + "家伺服器位址" + "輸入關鍵字或網域名稱。" + "搜尋公司、社群、私有伺服器。" + "尋找帳號提供者" + "您的所有對話將保存於此,就如同您的電子郵件供應商會保存您的電子郵件一樣。" + "您即將登入 %s" + "您的所有對話將保存於此,就如同您的電子郵件供應商會保存您的電子郵件一樣。" + "您即將在 %s 建立帳號" + "Matrix.org 由 Matrix.org 基金會營運,是用於安全、去中心化通訊的公共 Matrix 網路上的大型免費伺服器。" + "其他" + "使用不同的帳戶提供者,例如您自己的伺服器或工作帳號。" + "更改帳號提供者" + "Google Play" + "%1$s 需要 Element Pro 應用程式。請從商店下載。" + "需要 Element Pro" + "我們無法連線至此家伺服器。請檢查您是否已正確輸入家伺服器 URL。若 URL 正確,請聯絡您家伺服器的管理員以取得進一步協助。" + "因為 well-known 檔案的問題,伺服器不可用: +%1$s" + "選定的帳號提供者不支援 sliding sync。必須升級伺服器才能使用 %1$s。" + "不允許 %1$s 連線至 %2$s。" + "應用程式已設定為允許:%1$s。" + "不允許帳號提供者 %1$s。" + "家伺服器 URL" + "輸入網域位址。" + "您的伺服器地址?" + "選擇您的伺服器" + "建立帳號" + "這個帳號已被停用。" + "不正確的使用者名稱或密碼" + "此非有效的使用者識別字串。預期的格式:‘@user:homeserver.org’" + "此伺服器已設定為使用重新整理權杖。使用以密碼為基礎的登入方式時,不支援這些功能。" + "選定的家伺服器不支援密碼或 OIDC 登入。請聯絡您的管理員或選擇其他家伺服器。" + "輸入您的詳細資料" + "Matrix 是一個開放網路,為了安全且去中心化的通訊而生。" + "歡迎回來!" + "登入 %1$s" + "版本 %1$s" + "手動登入" + "登入 %1$s" + "使用 QR code 登入" + "建立帳號" + "歡迎使用有史以來最快的 %1$s。速度超快,操作簡便。" + "歡迎使用 %1$s。速度超快且簡單。" + "Be in your element" + "建立安全連線" + "無法與新裝置建立安全連線。您現有的裝置仍然安全,您不必擔心它們。" + "現在怎麼辦?" + "嘗試再次使用 QR code 登入以確認不是網路問題" + "如果遇到相同的問題,請嘗試使用其他 wifi 網路或您的行動數據" + "若無法運作,請手動登入" + "連線不安全" + "系統會要求您輸入此裝置上顯示的兩位數字。" + "在您的其他裝置上輸入以下數字" + "登入您的其他裝置,然後再試一次,或使用其他已登入的裝置。" + "其他裝置未登入" + "已在其他裝置上取消登入。" + "已取消登入請求" + "其他裝置拒絕登入。" + "已拒絕登入" + "登入已過期。請再試一次。" + "未及時完成登入" + "您的其他裝置不支援使用 QR cpde 登入 %s。 + +嘗試手動登入,或是使用其他裝置掃描 QR code。" + "不支援 QR code" + "您的帳號提供者不支援 %1$s。" + "不支援 %1$s" + "準備掃描" + "在桌面裝置上開啟 %1$s" + "點選您的大頭照" + "選取 %1$s" + "「連結新裝置」" + "使用此裝置掃描 QR code" + "僅在您的帳號提供者支援時才可用。" + "在其他裝置上開啟 %1$s 以取得 QR code" + "使用其他裝置上顯示的 QR code。" + "再試一次" + "錯誤的 QR code" + "前往相機設定" + "您必須授予 %1$s 權限以使用裝置相機才能繼續。" + "允許相機權限以掃描 QR code" + "掃描 QR code" + "重新開始" + "發生意外錯誤。請再試一次。" + "等待您的其他裝置" + "您的帳號提供者可能會要求以下代碼以驗證登入。" + "您的驗證碼" + "更改帳號提供者" + "供 Element 員工使用的私人伺服器。" + "Matrix 是一個開放網路,為了安全且去中心化的通訊而生。" + "您的所有對話將保存於此,就如同您的電子郵件供應商會保存您的電子郵件一樣。" + "您即將登入 %1$s" + "選擇帳號提供者" + "您即將在 %1$s 建立帳號" + diff --git a/features/login/impl/src/main/res/values-zh/translations.xml b/features/login/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..de7ea8b --- /dev/null +++ b/features/login/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,98 @@ + + + "更改账户提供方" + "服务器地址" + "输入搜索词或域名地址。" + "搜索公司、社区或私人服务器。" + "寻找账户提供方" + "这是您的对话将存在的地方,就像您使用电子邮件提供方来保存电子邮件一样。" + "您即将登录 %s" + "这是您的对话将存在的地方,就像您使用电子邮件提供方来保存电子邮件一样。" + "您即将在 %s 上创建一个帐户" + "Matrix.org 由 Matrix.org 基金会运营,是用于安全、去中心化的通信的公共 Matrix 网络上的大型免费服务器。" + "其他" + "使用其他账户提供商,例如您自己的私人服务器或工作账户。" + "更改账户提供方" + "Google Play" + "%1$s 需要 Element Pro 应用。请从应用商店下载。" + "需要 Element Pro 版" + "我们无法访问此服务器。请检查您输入的服务器网址是否正确。如果 URL 正确,请联系您的服务器管理员寻求进一步帮助。" + "由于 .well-known 文件中存在问题,服务器不可用: +%1$s" + "所选账户提供商不支持跨屏同步。需要升级服务器才能使用%1$s。" + "%1$s不允许连接到%2$s。" + "本应用已配置为允许访问:%1$s 。" + "账户提供商%1$s 不被允许。" + "服务器网址" + "输入域名地址。" + "您的服务器地址是什么?" + "选择服务器" + "创建账户" + "该账户已被停用。" + "错误的用户名和/或密码" + "这不是合法的用户 ID。期望格式:‘@user:homeserver.org’。" + "此服务器使用刷新令牌。使用密码登录时不支持这些功能。" + "该服务器不支持密码登录和 OIDC 第三方账户登录。请联系服务器管理员,或选择别的服务器。" + "输入您的详细信息" + "Matrix 是一个用于安全、去中心化通信的开放网络。" + "欢迎回来!" + "登录到 %1$s" + "版本%1$s" + "手动登录" + "登录到 %1$s" + "使用二维码登录" + "创建账户" + "欢迎使用 %1$s,快而简约的消息应用。" + "欢迎使用 %1$s,速度与简洁的极致。" + "融入您的 Element" + "建立安全连接" + "无法与新设备建立安全连接。您现有的设备仍然安全,无需担心。" + "现在怎么办?" + "如果这是网络问题,请尝试使用二维码再次登录" + "如果遇到同样的问题,请尝试使用不同的 WiFi 网络或使用移动数据代替 WiFi" + "如果不起作用,请手动登录" + "连接不安全" + "您会被要求输入此设备上显示的两位数。" + "在您的其他设备上输入下面的数字" + "在其他设备登录后重试,或使用另一个已登录的设备。" + "其他设备未登录" + "登录被另一台设备取消" + "登录请求已取消" + "其它设备未接受请求" + "登录被拒绝" + "登录已过期. 请重试." + "登录未及时完成" + "另一个设备不支持使用二维码登录 %s. + +尝试手动或使用另一个设备扫描二维码." + "不支持二维码" + "账户提供方不支持 %1$s." + "不支持 %1$s." + "准备进行扫描" + "在桌面设备上打开 %1$s" + "点击你的头像" + "选择 %1$s" + "「连接新设备」" + "使用此设备扫描二维码" + "仅在您的账户提供方支持时才可用。" + "在另一台设备上打开 %1$s 以获取二维码" + "使用其他设备上显示的二维码。" + "再试一次" + "二维码错误" + "转到摄像头设置" + "您需要授予 %1$s 使用设备摄像头的权限才能继续。" + "允许摄像头权限以扫描 QR 码" + "扫描二维码" + "重新开始" + "发生了意外错误。请再试一次。" + "等着您的其他设备" + "您的账户提供方可能会要求您提供以下代码来验证登录。" + "您的验证码" + "更改账户提供方" + "专为 Element 员工提供的私人服务器。" + "Matrix 是一个用于安全、去中心化通信的开放网络。" + "这是您的对话将存在的地方,就像您使用电子邮件提供方来保存电子邮件一样。" + "即将登录 %1$s" + "选择账户提供商" + "即将在 %1$s 上创建一个账户" + 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 0000000..9b23555 --- /dev/null +++ b/features/login/impl/src/main/res/values/localazy.xml @@ -0,0 +1,98 @@ + + + "Change account provider" + "Homeserver address" + "Enter a search term or a domain address." + "Search for a company, community, or private server." + "Find an account provider" + "This is where your conversations will live — just like you would use an email provider to keep your emails." + "You’re about to sign in to %s" + "This is where your conversations will live — just like you would use an email provider to keep your emails." + "You’re about to create an account on %s" + "Matrix.org is a large, free server on the public Matrix network for secure, decentralised communication, run by the Matrix.org Foundation." + "Other" + "Use a different account provider, such as your own private server or a work account." + "Change account provider" + "Google Play" + "The Element Pro app is required on %1$s. Please download it from the store." + "Element Pro required" + "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." + "Server isn\'t available due to an issue in the .well-known file: +%1$s" + "The selected account provider does not support sliding sync. An upgrade to the server is needed to use %1$s." + "%1$s is not allowed to connect to %2$s." + "This app has been configured to allow: %1$s." + "Account provider %1$s not allowed." + "Homeserver URL" + "Enter a domain address." + "What is the address of your server?" + "Select your server" + "Create account" + "This account has been deactivated." + "Incorrect username and/or password" + "This is not a valid user identifier. Expected format: ‘@user:homeserver.org’" + "This server is configured to use refresh tokens. These aren\'t supported when using password based login." + "The selected homeserver doesn\'t support password or OIDC login. Please contact your admin or choose another homeserver." + "Enter your details" + "Matrix is an open network for secure, decentralised communication." + "Welcome back!" + "Sign in to %1$s" + "Version %1$s" + "Sign in manually" + "Sign in to %1$s" + "Sign in with QR code" + "Create account" + "Welcome to the fastest %1$s ever. Supercharged for speed and simplicity." + "Welcome to %1$s. Supercharged, for speed and simplicity." + "Be in your element" + "Establishing a secure connection" + "A secure connection could not be made to the new device. Your existing devices are still safe and you don\'t need to worry about them." + "What now?" + "Try signing in again with a QR code in case this was a network problem" + "If you encounter the same problem, try a different wifi network or use your mobile data instead of wifi" + "If that doesn’t work, sign in manually" + "Connection not secure" + "You’ll be asked to enter the two digits shown on this device." + "Enter the number below on your other device" + "Sign in to your other device and then try again, or use another device that’s already signed in." + "Other device not signed in" + "The sign in was cancelled on the other device." + "Sign in request cancelled" + "The sign in was declined on the other device." + "Sign in declined" + "Sign in expired. Please try again." + "The sign in was not completed in time" + "Your other device does not support signing in to %s with a QR code. + +Try signing in manually, or scan the QR code with another device." + "QR code not supported" + "Your account provider does not support %1$s." + "%1$s not supported" + "Ready to scan" + "Open %1$s on a desktop device" + "Click on your avatar" + "Select %1$s" + "“Link new device”" + "Scan the QR code with this device" + "Only available if your account provider supports it." + "Open %1$s on another device to get the QR code" + "Use the QR code shown on the other device." + "Try again" + "Wrong QR code" + "Go to camera settings" + "You need to give permission for %1$s to use your device’s camera in order to continue." + "Allow camera access to scan the QR code" + "Scan the QR code" + "Start over" + "An unexpected error occurred. Please try again." + "Waiting for your other device" + "Your account provider may ask for the following code to verify the sign in." + "Your verification code" + "Change account provider" + "A private server for Element employees." + "Matrix is an open network for secure, decentralised communication." + "This is where your conversations will live — just like you would use an email provider to keep your emails." + "You’re about to sign in to %1$s" + "Choose account provider" + "You’re about to create an account on %1$s" + diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt new file mode 100644 index 0000000..953693b --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.features.login.api.LoginEntryPoint +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DefaultLoginEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() = runTest { + val entryPoint = DefaultLoginEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + LoginFlowNode( + buildContext = buildContext, + plugins = plugins, + accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + oidcActionFlow = FakeOidcActionFlow(), + appCoroutineScope = backgroundScope, + ) + } + val callback = object : LoginEntryPoint.Callback { + override fun navigateToBugReport() = lambdaError() + override fun onDone() = lambdaError() + } + val params = LoginEntryPoint.Params( + accountProvider = "ac", + loginHint = "lh", + ) + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + params = params, + callback = callback, + ) + assertThat(result).isInstanceOf(LoginFlowNode::class.java) + assertThat(result.plugins).contains(LoginFlowNode.Params(params.accountProvider, params.loginHint)) + assertThat(result.plugins).contains(callback) + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginIntentResolverTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginIntentResolverTest.kt new file mode 100644 index 0000000..21b3cca --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginIntentResolverTest.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.api.LoginParams +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DefaultLoginIntentResolverTest { + @Test + fun `nominal case`() { + val sut = DefaultLoginIntentResolver() + val uriString = "https://mobile.element.io/element/?account_provider=example.org&login_hint=mxid:@alice:example.org" + assertThat(sut.parse(uriString)).isEqualTo( + LoginParams( + accountProvider = "example.org", + loginHint = "mxid:@alice:example.org", + ) + ) + } + + @Test + fun `extra unknown param`() { + val sut = DefaultLoginIntentResolver() + val uriString = "https://mobile.element.io/element/?account_provider=example.org&login_hint=mxid:@alice:example.org&extra=uknown" + assertThat(sut.parse(uriString)).isEqualTo( + LoginParams( + accountProvider = "example.org", + loginHint = "mxid:@alice:example.org", + ) + ) + } + + @Test + fun `no account provider`() { + val sut = DefaultLoginIntentResolver() + val uriString = "https://mobile.element.io/element/?login_hint=mxid:@alice:example.org" + assertThat(sut.parse(uriString)).isNull() + } + + @Test + fun `no path`() { + val sut = DefaultLoginIntentResolver() + val uriString = "https://mobile.element.io?account_provider=example.org&login_hint=mxid:@alice:example.org" + assertThat(sut.parse(uriString)).isNull() + } + + @Test + fun `wrong path`() { + val sut = DefaultLoginIntentResolver() + val uriString = "https://mobile.element.io/wrong?account_provider=example.org&login_hint=mxid:@alice:example.org" + assertThat(sut.parse(uriString)).isNull() + } + + @Test + fun `wrong host`() { + val sut = DefaultLoginIntentResolver() + val uriString = "https://wrong.element.io/element/?account_provider=example.org&login_hint=mxid:@alice:example.org" + assertThat(sut.parse(uriString)).isNull() + } + + @Test + fun `no login_hint param`() { + val sut = DefaultLoginIntentResolver() + val uriString = "https://mobile.element.io/element/?account_provider=example.org" + assertThat(sut.parse(uriString)).isEqualTo( + LoginParams( + accountProvider = "example.org", + loginHint = null, + ) + ) + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt new file mode 100644 index 0000000..e3cf1c0 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.accesscontrol + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.features.login.impl.changeserver.AccountProviderAccessException +import io.element.android.features.wellknown.test.FakeWellknownRetriever +import io.element.android.features.wellknown.test.anElementWellKnown +import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER +import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2 +import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_URL +import io.element.android.libraries.wellknown.api.ElementWellKnown +import io.element.android.libraries.wellknown.api.WellknownRetrieverResult +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertThrows +import org.junit.Test + +class DefaultAccountProviderAccessControlTest { + @Test + fun `foss build should not allow using account provider that enforce enterprise build`() { + val accessControl = createDefaultAccountProviderAccessControl( + isEnterpriseBuild = false, + isAllowedToConnectToHomeserver = true, + elementWellKnown = anElementWellKnown( + enforceElementPro = true, + ), + ) + accessControl.expectNeedElementProException() + } + + @Test + fun `foss build should not allow using account provider that enforce enterprise build taking precedence over authorization`() { + val accessControl = createDefaultAccountProviderAccessControl( + isEnterpriseBuild = false, + // false here. + isAllowedToConnectToHomeserver = false, + elementWellKnown = anElementWellKnown( + enforceElementPro = true, + ), + ) + accessControl.expectNeedElementProException() + } + + @Test + fun `foss build should allow using account provider that does not enforce enterprise build`() = runTest { + val accessControl = createDefaultAccountProviderAccessControl( + isEnterpriseBuild = false, + isAllowedToConnectToHomeserver = true, + elementWellKnown = anElementWellKnown( + enforceElementPro = false, + ), + ) + accessControl.expectAllowed() + } + + @Test + fun `foss build should allow using account provider twith missing key in wellknown`() = runTest { + val accessControl = createDefaultAccountProviderAccessControl( + isEnterpriseBuild = false, + isAllowedToConnectToHomeserver = true, + elementWellKnown = anElementWellKnown( + enforceElementPro = null, + ), + ) + accessControl.expectAllowed() + } + + @Test + fun `foss build should allow using account provider twith missing wellknown`() = runTest { + val accessControl = createDefaultAccountProviderAccessControl( + isEnterpriseBuild = false, + isAllowedToConnectToHomeserver = true, + elementWellKnown = null, + ) + accessControl.expectAllowed() + } + + @Test + fun `foss build should not allow using account provider that do not enforce enterprise build but is not allowed`() { + val accessControl = createDefaultAccountProviderAccessControl( + isEnterpriseBuild = false, + isAllowedToConnectToHomeserver = false, + allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2), + elementWellKnown = anElementWellKnown( + enforceElementPro = false, + ), + ) + accessControl.expectUnauthorizedAccountProviderException() + } + + @Test + fun `enterprise build should allow using account provider that enforce enterprise build`() = runTest { + val accessControl = createDefaultAccountProviderAccessControl( + isEnterpriseBuild = true, + isAllowedToConnectToHomeserver = true, + elementWellKnown = anElementWellKnown( + enforceElementPro = true, + ), + ) + accessControl.expectAllowed() + } + + @Test + fun `enterprise build should allow using account provider that do not enforce enterprise build`() = runTest { + val accessControl = createDefaultAccountProviderAccessControl( + isEnterpriseBuild = true, + isAllowedToConnectToHomeserver = true, + elementWellKnown = anElementWellKnown( + enforceElementPro = false, + ), + ) + accessControl.expectAllowed() + } + + @Test + fun `enterprise build should not allow using account provider that enforce enterprise build but is not allowed`() = runTest { + val accessControl = createDefaultAccountProviderAccessControl( + isEnterpriseBuild = true, + isAllowedToConnectToHomeserver = false, + allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2), + elementWellKnown = anElementWellKnown( + enforceElementPro = true, + ), + ) + accessControl.expectUnauthorizedAccountProviderException() + } + + @Test + fun `enterprise build should not allow using account provider that do not enforce enterprise build but is not allowed`() = runTest { + val accessControl = createDefaultAccountProviderAccessControl( + isEnterpriseBuild = true, + isAllowedToConnectToHomeserver = false, + allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2), + elementWellKnown = anElementWellKnown( + enforceElementPro = false, + ), + ) + accessControl.expectUnauthorizedAccountProviderException() + } + + private fun createDefaultAccountProviderAccessControl( + isEnterpriseBuild: Boolean = false, + isAllowedToConnectToHomeserver: Boolean = false, + allowedAccountProviders: List = emptyList(), + elementWellKnown: ElementWellKnown? = null, + ) = DefaultAccountProviderAccessControl( + enterpriseService = FakeEnterpriseService( + isEnterpriseBuild = isEnterpriseBuild, + isAllowedToConnectToHomeserverResult = { isAllowedToConnectToHomeserver }, + defaultHomeserverListResult = { allowedAccountProviders }, + ), + wellknownRetriever = FakeWellknownRetriever( + getElementWellKnownResult = { + if (elementWellKnown == null) { + WellknownRetrieverResult.NotFound + } else { + WellknownRetrieverResult.Success(elementWellKnown) + } + }, + ), + ) + + private fun DefaultAccountProviderAccessControl.expectNeedElementProException() { + val exception = assertThrows(AccountProviderAccessException.NeedElementProException::class.java) { + runTest { + assertIsAllowedToConnectToAccountProvider( + title = AN_ACCOUNT_PROVIDER, + accountProviderUrl = AN_ACCOUNT_PROVIDER_URL, + ) + } + } + assertThat(exception.unauthorisedAccountProviderTitle).isEqualTo(AN_ACCOUNT_PROVIDER) + assertThat(exception.applicationId).isEqualTo("io.element.enterprise") + runTest { + assertThat( + isAllowedToConnectToAccountProvider( + accountProviderUrl = AN_ACCOUNT_PROVIDER_URL, + ) + ).isFalse() + } + } + + private fun DefaultAccountProviderAccessControl.expectUnauthorizedAccountProviderException() { + val exception = assertThrows(AccountProviderAccessException.UnauthorizedAccountProviderException::class.java) { + runTest { + assertIsAllowedToConnectToAccountProvider( + title = AN_ACCOUNT_PROVIDER, + accountProviderUrl = AN_ACCOUNT_PROVIDER_URL, + ) + } + } + assertThat(exception.unauthorisedAccountProviderTitle).isEqualTo(AN_ACCOUNT_PROVIDER) + assertThat(exception.authorisedAccountProviderTitles).containsExactly(AN_ACCOUNT_PROVIDER_2) + runTest { + assertThat( + isAllowedToConnectToAccountProvider( + accountProviderUrl = AN_ACCOUNT_PROVIDER_URL, + ) + ).isFalse() + } + } + + private suspend fun DefaultAccountProviderAccessControl.expectAllowed() { + // If no exception is thrown, the test passes + assertIsAllowedToConnectToAccountProvider( + title = AN_ACCOUNT_PROVIDER, + accountProviderUrl = AN_ACCOUNT_PROVIDER_URL, + ) + runTest { + assertThat( + isAllowedToConnectToAccountProvider( + accountProviderUrl = AN_ACCOUNT_PROVIDER_URL, + ) + ).isTrue() + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt new file mode 100644 index 0000000..f86df13 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderDataSourceTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.accountprovider + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.appconfig.AuthenticationConfig +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class AccountProviderDataSourceTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val sut = AccountProviderDataSource(FakeEnterpriseService()) + sut.flow.test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo( + AccountProvider( + url = AuthenticationConfig.MATRIX_ORG_URL, + title = "matrix.org", + subtitle = null, + isPublic = true, + isMatrixOrg = true, + ) + ) + } + } + + @Test + fun `present - initial state - matrix org`() = runTest { + val sut = AccountProviderDataSource( + FakeEnterpriseService( + defaultHomeserverListResult = { listOf(AuthenticationConfig.MATRIX_ORG_URL) } + ) + ) + sut.flow.test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo( + AccountProvider( + url = AuthenticationConfig.MATRIX_ORG_URL, + title = "matrix.org", + subtitle = null, + isPublic = true, + isMatrixOrg = true, + ) + ) + } + } + + @Test + fun `present - ensure that default homeserver is not star char`() = runTest { + val sut = AccountProviderDataSource( + FakeEnterpriseService( + defaultHomeserverListResult = { listOf(EnterpriseService.ANY_ACCOUNT_PROVIDER, AuthenticationConfig.MATRIX_ORG_URL) } + ) + ) + sut.flow.test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo( + AccountProvider( + url = AuthenticationConfig.MATRIX_ORG_URL, + title = "matrix.org", + subtitle = null, + isPublic = true, + isMatrixOrg = true, + ) + ) + } + } + + @Test + fun `present - user change and reset`() = runTest { + val sut = AccountProviderDataSource(FakeEnterpriseService()) + sut.flow.test { + val initialState = awaitItem() + assertThat(initialState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + sut.setAccountProvider(AccountProvider(url = "https://example.com")) + val changedState = awaitItem() + assertThat(changedState).isEqualTo( + AccountProvider( + url = "https://example.com", + title = "example.com", + subtitle = null, + isPublic = false, + isMatrixOrg = false, + ) + ) + sut.reset() + val resetState = awaitItem() + assertThat(resetState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + } + } + + @Test + fun `present - set url and reset`() = runTest { + val sut = AccountProviderDataSource(FakeEnterpriseService()) + sut.flow.test { + val initialState = awaitItem() + assertThat(initialState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + sut.setUrl(url = "https://example.com") + val changedState = awaitItem() + assertThat(changedState).isEqualTo( + AccountProvider( + url = "https://example.com", + title = "example.com", + subtitle = null, + isPublic = false, + isMatrixOrg = false, + ) + ) + sut.reset() + val resetState = awaitItem() + assertThat(resetState.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + } + } +} 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 0000000..1fb5d37 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.changeserver + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl +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.features.wellknown.test.FakeWellknownRetriever +import io.element.android.features.wellknown.test.anElementWellKnown +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.uri.ensureProtocol +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_HOMESERVER_URL +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails +import io.element.android.libraries.wellknown.api.ElementWellKnown +import io.element.android.libraries.wellknown.api.WellknownRetriever +import io.element.android.libraries.wellknown.api.WellknownRetrieverResult +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class ChangeServerPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + createPresenter().test { + val initialState = awaitItem() + assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) + } + } + + @Test + fun `present - change server ok`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.success(aMatrixHomeServerDetails(supportsOidcLogin = true)) + }, + ) + createPresenter( + authenticationService = authenticationService, + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { true }, + ), + ).test { + val initialState = awaitItem() + assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) + initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(url = A_HOMESERVER_URL))) + val loadingState = awaitItem() + assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java) + val successState = awaitItem() + assertThat(successState.changeServerAction).isEqualTo(AsyncData.Success(Unit)) + } + } + + @Test + fun `present - change server error`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.failure(AN_EXCEPTION) + }, + ) + createPresenter( + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { true }, + ), + authenticationService = authenticationService, + ).test { + val initialState = awaitItem() + assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) + initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(url = A_HOMESERVER_URL))) + val loadingState = awaitItem() + assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java) + val failureState = awaitItem() + assertThat(failureState.changeServerAction).isInstanceOf(AsyncData.Failure::class.java) + // Clear error + failureState.eventSink.invoke(ChangeServerEvents.ClearError) + val finalState = awaitItem() + assertThat(finalState.changeServerAction).isEqualTo(AsyncData.Uninitialized) + } + } + + @Test + fun `present - change server unsupported server`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.success(aMatrixHomeServerDetails()) + }, + ) + createPresenter( + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { true }, + ), + authenticationService = authenticationService, + ).test { + val initialState = awaitItem() + assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) + initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(url = A_HOMESERVER_URL))) + val loadingState = awaitItem() + assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java) + val failureState = awaitItem() + assertThat(failureState.changeServerAction).isInstanceOf(AsyncData.Failure::class.java) + assertThat(failureState.changeServerAction.errorOrNull()).isEqualTo( + ChangeServerError.UnsupportedServer + ) + } + } + + @Test + fun `present - change server not allowed error`() = runTest { + val isAllowedToConnectToHomeserverResult = lambdaRecorder { false } + createPresenter( + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = isAllowedToConnectToHomeserverResult, + defaultHomeserverListResult = { listOf("element.io") }, + ), + ).test { + val initialState = awaitItem() + assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) + val anAccountProvider = AccountProvider(url = A_HOMESERVER_URL) + initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(anAccountProvider)) + val loadingState = awaitItem() + assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java) + val failureState = awaitItem() + assertThat( + (failureState.changeServerAction.errorOrNull() as ChangeServerError.UnauthorizedAccountProvider).unauthorisedAccountProviderTitle + ).isEqualTo(anAccountProvider.title) + assertThat( + (failureState.changeServerAction.errorOrNull() as ChangeServerError.UnauthorizedAccountProvider).authorisedAccountProviderTitles + ).containsExactly("element.io") + isAllowedToConnectToHomeserverResult.assertions() + .isCalledOnce() + .with(value(A_HOMESERVER_URL)) + } + } + + @Test + fun `present - change server element pro required error`() = runTest { + val getElementWellKnownResult = lambdaRecorder> { + WellknownRetrieverResult.Success( + anElementWellKnown( + enforceElementPro = true, + ) + ) + } + createPresenter( + wellknownRetriever = FakeWellknownRetriever( + getElementWellKnownResult = getElementWellKnownResult, + ), + ).test { + val initialState = awaitItem() + assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) + val anAccountProvider = AccountProvider(url = A_HOMESERVER_URL) + initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(anAccountProvider)) + val loadingState = awaitItem() + assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java) + val failureState = awaitItem() + assertThat( + (failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).unauthorisedAccountProviderTitle + ).isEqualTo(anAccountProvider.title) + assertThat( + (failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).applicationId + ).isEqualTo("io.element.enterprise") + getElementWellKnownResult.assertions() + .isCalledOnce() + .with(value(A_HOMESERVER_URL.ensureProtocol())) + } + } + + private fun createPresenter( + authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(), + accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + enterpriseService: EnterpriseService = FakeEnterpriseService(), + wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(), + ) = ChangeServerPresenter( + authenticationService = authenticationService, + accountProviderDataSource = accountProviderDataSource, + defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( + enterpriseService = enterpriseService, + wellknownRetriever = wellknownRetriever, + ), + ) +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/di/FakeQrCodeLoginGraph.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/di/FakeQrCodeLoginGraph.kt new file mode 100644 index 0000000..155d4f6 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/di/FakeQrCodeLoginGraph.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.di + +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.features.login.impl.qrcode.QrCodeLoginFlowNode +import io.element.android.features.login.impl.qrcode.QrCodeLoginManager +import io.element.android.libraries.architecture.AssistedNodeFactory +import kotlin.reflect.KClass + +internal class FakeQrCodeLoginGraph( + private val qrCodeLoginManager: QrCodeLoginManager, +) : QrCodeLoginGraph, QrCodeLoginBindings { + override fun nodeFactories(): Map, AssistedNodeFactory<*>> { + return mapOf( + QrCodeLoginFlowNode::class to object : AssistedNodeFactory { + override fun create(buildContext: BuildContext, plugins: List): QrCodeLoginFlowNode { + error("This factory should not be called in tests") + } + } + ) + } + + override fun qrCodeLoginManager(): QrCodeLoginManager = qrCodeLoginManager + + internal class Builder( + private val qrCodeLoginManager: QrCodeLoginManager, + ) : QrCodeLoginGraph.Factory { + override fun create(): QrCodeLoginGraph { + return FakeQrCodeLoginGraph(qrCodeLoginManager) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/error/ErrorFormatterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/error/ErrorFormatterTest.kt new file mode 100644 index 0000000..dd504f9 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/error/ErrorFormatterTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 ErrorFormatterTest { + // region loginError + @Test + fun `loginError - invalid unknown error returns unknown error message`() { + val error = RuntimeException("Some unknown error") + assertThat(loginError(error)).isEqualTo(CommonStrings.error_unknown) + } + + @Test + fun `loginError - invalid auth error returns unknown error message`() { + val error = AuthenticationException.SlidingSyncVersion("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/qrcode/DefaultQrCodeLoginManagerTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/DefaultQrCodeLoginManagerTest.kt new file mode 100644 index 0000000..166e47f --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/DefaultQrCodeLoginManagerTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.qrcode + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultQrCodeLoginManagerTest { + @Test + fun `authenticate - returns success if the login succeeded`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + loginWithQrCodeResult = { _, _ -> Result.success(A_SESSION_ID) } + ) + val manager = DefaultQrCodeLoginManager(authenticationService) + val result = manager.authenticate(FakeMatrixQrCodeLoginData()) + + assertThat(result.isSuccess).isTrue() + assertThat(result.getOrNull()).isEqualTo(A_SESSION_ID) + } + + @Test + fun `authenticate - returns failure if the login failed`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + loginWithQrCodeResult = { _, _ -> Result.failure(IllegalStateException("Auth failed")) } + ) + val manager = DefaultQrCodeLoginManager(authenticationService) + val result = manager.authenticate(FakeMatrixQrCodeLoginData()) + + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isNotNull() + } + + @Test + fun `authenticate - emits the auth steps`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + loginWithQrCodeResult = { _, progressListener -> + progressListener(QrCodeLoginStep.EstablishingSecureChannel("00")) + progressListener(QrCodeLoginStep.Starting) + progressListener(QrCodeLoginStep.WaitingForToken("000000")) + progressListener(QrCodeLoginStep.Finished) + Result.success(A_SESSION_ID) + } + ) + val manager = DefaultQrCodeLoginManager(authenticationService) + manager.currentLoginStep.test { + manager.authenticate(FakeMatrixQrCodeLoginData()) + + assertThat(awaitItem()).isEqualTo(QrCodeLoginStep.Uninitialized) + assertThat(awaitItem()).isEqualTo(QrCodeLoginStep.EstablishingSecureChannel("00")) + assertThat(awaitItem()).isEqualTo(QrCodeLoginStep.Starting) + assertThat(awaitItem()).isEqualTo(QrCodeLoginStep.WaitingForToken("000000")) + assertThat(awaitItem()).isEqualTo(QrCodeLoginStep.Finished) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/FakeQrCodeLoginManager.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/FakeQrCodeLoginManager.kt new file mode 100644 index 0000000..976c313 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/FakeQrCodeLoginManager.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.qrcode + +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeQrCodeLoginManager( + var authenticateResult: (MatrixQrCodeLoginData) -> Result = + lambdaRecorder> { Result.success(A_SESSION_ID) }, + var resetAction: () -> Unit = lambdaRecorder { }, +) : QrCodeLoginManager { + override val currentLoginStep: MutableStateFlow = + MutableStateFlow(QrCodeLoginStep.Uninitialized) + + override suspend fun authenticate(qrCodeLoginData: MatrixQrCodeLoginData): Result { + return authenticateResult(qrCodeLoginData) + } + + override fun reset() { + resetAction() + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNodeTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNodeTest.kt new file mode 100644 index 0000000..ee99d11 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNodeTest.kt @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.qrcode + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bumble.appyx.core.modality.AncestryInfo +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.impl.di.FakeQrCodeLoginGraph +import io.element.android.features.login.impl.screens.qrcode.confirmation.QrCodeConfirmationStep +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class QrCodeLoginFlowNodeTest { + @Test + fun `backstack changes when confirmation steps are received`() = runTest { + val qrCodeLoginManager = FakeQrCodeLoginManager() + val flowNode = createLoginFlowNode(qrCodeLoginManager = qrCodeLoginManager) + flowNode.observeLoginStep() + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Initial) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.EstablishingSecureChannel("12") + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.QrCodeConfirmation(QrCodeConfirmationStep.DisplayCheckCode("12"))) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.WaitingForToken("123456") + assertThat(flowNode.currentNavTarget()) + .isEqualTo(QrCodeLoginFlowNode.NavTarget.QrCodeConfirmation(QrCodeConfirmationStep.DisplayVerificationCode("123456"))) + } + + @Test + fun `backstack changes when failure step is received`() = runTest { + val qrCodeLoginManager = FakeQrCodeLoginManager() + val flowNode = createLoginFlowNode(qrCodeLoginManager = qrCodeLoginManager) + flowNode.observeLoginStep() + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Initial) + + // Only case when this doesn't happen, since it's handled by the already displayed UI + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.OtherDeviceNotSignedIn) + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Initial) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.Expired) + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.Expired)) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.Declined) + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.Declined)) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.Cancelled) + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.Cancelled)) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.SlidingSyncNotAvailable) + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.SlidingSyncNotAvailable)) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.LinkingNotSupported) + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.ProtocolNotSupported)) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.ConnectionInsecure) + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.InsecureChannelDetected)) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.OidcMetadataInvalid) + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.UnknownError)) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.Unknown) + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.UnknownError)) + } + + @Test + fun `backstack doesn't change when other steps are received`() = runTest { + val qrCodeLoginManager = FakeQrCodeLoginManager() + val flowNode = createLoginFlowNode(qrCodeLoginManager = qrCodeLoginManager) + flowNode.observeLoginStep() + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Initial) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Starting + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Initial) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Finished + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Initial) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `startAuthentication - success`() = runTest { + val fakeAuthenticationService = FakeMatrixAuthenticationService( + loginWithQrCodeResult = { _, progress -> + progress(QrCodeLoginStep.Finished) + Result.success(A_SESSION_ID) + } + ) + // Test with a real manager to ensure the flow is correctly done + val qrCodeLoginManager = DefaultQrCodeLoginManager(fakeAuthenticationService) + val flowNode = createLoginFlowNode( + qrCodeLoginManager = qrCodeLoginManager, + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + ) + + flowNode.run { startAuthentication(FakeMatrixQrCodeLoginData()) } + assertThat(flowNode.isLoginInProgress()).isTrue() + + advanceUntilIdle() + + assertThat(qrCodeLoginManager.currentLoginStep.value).isEqualTo(QrCodeLoginStep.Finished) + assertThat(flowNode.isLoginInProgress()).isFalse() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `startAuthentication - failure is correctly handled`() = runTest { + val fakeAuthenticationService = FakeMatrixAuthenticationService( + loginWithQrCodeResult = { _, progress -> + progress(QrCodeLoginStep.Failed(QrLoginException.Unknown)) + Result.failure(IllegalStateException("Failed")) + } + ) + // Test with a real manager to ensure the flow is correctly done + val qrCodeLoginManager = DefaultQrCodeLoginManager(fakeAuthenticationService) + val flowNode = createLoginFlowNode( + qrCodeLoginManager = qrCodeLoginManager, + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + ) + + flowNode.run { startAuthentication(FakeMatrixQrCodeLoginData()) } + assertThat(flowNode.isLoginInProgress()).isTrue() + + advanceUntilIdle() + + assertThat(qrCodeLoginManager.currentLoginStep.value).isEqualTo(QrCodeLoginStep.Failed(QrLoginException.Unknown)) + assertThat(flowNode.isLoginInProgress()).isFalse() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `startAuthentication - then reset`() = runTest { + val fakeAuthenticationService = FakeMatrixAuthenticationService( + loginWithQrCodeResult = { _, progress -> + progress(QrCodeLoginStep.Finished) + Result.success(A_SESSION_ID) + } + ) + // Test with a real manager to ensure the flow is correctly done + val qrCodeLoginManager = DefaultQrCodeLoginManager(fakeAuthenticationService) + val flowNode = createLoginFlowNode( + qrCodeLoginManager = qrCodeLoginManager, + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + ) + + flowNode.run { startAuthentication(FakeMatrixQrCodeLoginData()) } + assertThat(flowNode.isLoginInProgress()).isTrue() + flowNode.reset() + + advanceUntilIdle() + + assertThat(qrCodeLoginManager.currentLoginStep.value).isEqualTo(QrCodeLoginStep.Uninitialized) + assertThat(flowNode.isLoginInProgress()).isFalse() + } + + private fun TestScope.createLoginFlowNode( + qrCodeLoginManager: QrCodeLoginManager = FakeQrCodeLoginManager(), + coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers() + ): QrCodeLoginFlowNode { + val buildContext = BuildContext( + ancestryInfo = AncestryInfo.Root, + savedStateMap = null, + customisations = NodeCustomisationDirectoryImpl() + ) + return QrCodeLoginFlowNode( + buildContext = buildContext, + plugins = emptyList(), + qrCodeLoginGraphFactory = FakeQrCodeLoginGraph.Builder(qrCodeLoginManager), + coroutineDispatchers = coroutineDispatchers, + ) + } + + private fun QrCodeLoginFlowNode.currentNavTarget() = backstack.elements.value.last().key.navTarget +} 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 0000000..7d81f05 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.changeaccountprovider + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.changeserver.aChangeServerState +import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER +import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2 +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class ChangeAccountProviderPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = ChangeAccountProviderPresenter( + changeServerPresenter = { aChangeServerState() }, + enterpriseService = FakeEnterpriseService( + defaultHomeserverListResult = { emptyList() } + ), + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.accountProviders).isEqualTo( + listOf( + AccountProvider( + url = "https://matrix.org", + title = "matrix.org", + subtitle = null, + isPublic = true, + isMatrixOrg = true, + ) + ) + ) + assertThat(initialState.canSearchForAccountProviders).isTrue() + } + } + + @Test + fun `present - fixed list of account providers`() = runTest { + val presenter = ChangeAccountProviderPresenter( + changeServerPresenter = { aChangeServerState() }, + enterpriseService = FakeEnterpriseService( + defaultHomeserverListResult = { + listOf(AN_ACCOUNT_PROVIDER, AN_ACCOUNT_PROVIDER_2) + } + ), + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.accountProviders).isEqualTo( + listOf( + AccountProvider( + url = "https://matrix.org", + title = "matrix.org", + subtitle = null, + isPublic = true, + isMatrixOrg = true, + ), + AccountProvider( + url = "https://element.io", + title = "element.io", + subtitle = null, + isPublic = false, + isMatrixOrg = false, + ) + ) + ) + assertThat(initialState.canSearchForAccountProviders).isFalse() + } + } + + @Test + fun `present - opened list of account providers`() = runTest { + val presenter = ChangeAccountProviderPresenter( + changeServerPresenter = { aChangeServerState() }, + enterpriseService = FakeEnterpriseService( + defaultHomeserverListResult = { + listOf(AN_ACCOUNT_PROVIDER, EnterpriseService.ANY_ACCOUNT_PROVIDER) + } + ), + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.accountProviders).isEqualTo( + listOf( + AccountProvider( + url = "https://matrix.org", + title = "matrix.org", + subtitle = null, + isPublic = true, + isMatrixOrg = true, + ) + ) + ) + assertThat(initialState.canSearchForAccountProviders).isTrue() + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenterTest.kt new file mode 100644 index 0000000..7461a7d --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderPresenterTest.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.chooseaccountprovider + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.features.login.impl.accountprovider.AccountProvider +import io.element.android.features.login.impl.login.LoginHelper +import io.element.android.features.login.impl.screens.onboarding.createLoginHelper +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.uri.ensureProtocol +import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2 +import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_3 +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class ChooseAccountProviderPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + companion object { + private const val ACCOUNT_PROVIDER_FROM_CONFIG_1 = AN_ACCOUNT_PROVIDER_2 + private const val ACCOUNT_PROVIDER_FROM_CONFIG_2 = AN_ACCOUNT_PROVIDER_3 + val accountProvider1 = AccountProvider( + url = ACCOUNT_PROVIDER_FROM_CONFIG_1.ensureProtocol(), + subtitle = null, + isPublic = false, + isMatrixOrg = false, + ) + val accountProvider2 = AccountProvider( + url = ACCOUNT_PROVIDER_FROM_CONFIG_2.ensureProtocol(), + subtitle = null, + isPublic = false, + isMatrixOrg = false, + ) + } + + @Test + fun `present - ensure initial conditions`() { + assertThat( + setOf( + ACCOUNT_PROVIDER_FROM_CONFIG_1, + ACCOUNT_PROVIDER_FROM_CONFIG_2, + ).size + ).isEqualTo(2) + } + + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter( + enterpriseService = FakeEnterpriseService( + defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) }, + ), + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.accountProviders).containsExactly( + accountProvider1, + accountProvider2, + ) + assertThat(initialState.selectedAccountProvider).isNull() + } + } + + @Test + fun `present - Continue when no account provider is selected has no effect`() = runTest { + val authenticationService = FakeMatrixAuthenticationService() + val presenter = createPresenter( + enterpriseService = FakeEnterpriseService( + defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) }, + ), + loginHelper = createLoginHelper( + authenticationService = authenticationService, + ), + ) + presenter.test { + awaitItem().also { + assertThat(it.selectedAccountProvider).isNull() + it.eventSink(ChooseAccountProviderEvents.Continue) + expectNoEvents() + } + } + } + + @Test + fun `present - select account provider and continue - error then clear error`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.failure(AN_EXCEPTION) + }, + ) + val presenter = createPresenter( + enterpriseService = FakeEnterpriseService( + defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) }, + ), + loginHelper = createLoginHelper( + authenticationService = authenticationService, + ), + ) + presenter.test { + awaitItem().also { + assertThat(it.selectedAccountProvider).isNull() + it.eventSink(ChooseAccountProviderEvents.SelectAccountProvider(accountProvider1)) + } + awaitItem().also { + assertThat(it.selectedAccountProvider).isEqualTo(accountProvider1) + it.eventSink(ChooseAccountProviderEvents.Continue) + skipItems(1) // Loading + + // Check an error was returned + val submittedState = awaitItem() + assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Failure::class.java) + + // Assert the error is then cleared + submittedState.eventSink(ChooseAccountProviderEvents.ClearError) + val clearedState = awaitItem() + assertThat(clearedState.loginMode).isEqualTo(AsyncData.Uninitialized) + } + } + } + + @Test + fun `present - default account provider - select account provider during login has no effect`() = runTest { + val authenticationService = FakeMatrixAuthenticationService() + val presenter = createPresenter( + enterpriseService = FakeEnterpriseService( + defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG_1, ACCOUNT_PROVIDER_FROM_CONFIG_2) }, + ), + loginHelper = createLoginHelper( + authenticationService = authenticationService, + ), + ) + presenter.test { + awaitItem().also { + assertThat(it.selectedAccountProvider).isNull() + it.eventSink(ChooseAccountProviderEvents.SelectAccountProvider(accountProvider1)) + } + awaitItem().also { + assertThat(it.selectedAccountProvider).isEqualTo(accountProvider1) + it.eventSink(ChooseAccountProviderEvents.Continue) + } + awaitItem().also { + assertThat(it.loginMode.isLoading()).isTrue() + it.eventSink(ChooseAccountProviderEvents.SelectAccountProvider(accountProvider2)) + } + expectNoEvents() + } + } +} + +private fun createPresenter( + enterpriseService: EnterpriseService = FakeEnterpriseService(), + loginHelper: LoginHelper = createLoginHelper(), +) = ChooseAccountProviderPresenter( + enterpriseService = enterpriseService, + loginHelper = loginHelper, +) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStateTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStateTest.kt new file mode 100644 index 0000000..ba1eff3 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderStateTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.chooseaccountprovider + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.impl.accountprovider.anAccountProvider +import io.element.android.features.login.impl.login.LoginMode +import io.element.android.libraries.architecture.AsyncData +import org.junit.Test + +class ChooseAccountProviderStateTest { + @Test + fun `submitEnabled returns false when there is no selectedAccountProvider`() { + val sut = aChooseAccountProviderState( + selectedAccountProvider = null, + ) + assertThat(sut.submitEnabled).isFalse() + } + + @Test + fun `submitEnabled returns true when there is a selectedAccountProvider`() { + val sut = aChooseAccountProviderState( + selectedAccountProvider = anAccountProvider(), + ) + assertThat(sut.submitEnabled).isTrue() + } + + @Test + fun `submitEnabled returns false when there is a selectedAccountProvider but there is an error`() { + val sut = aChooseAccountProviderState( + selectedAccountProvider = anAccountProvider(), + loginMode = AsyncData.Failure(Throwable("Error")), + ) + assertThat(sut.submitEnabled).isFalse() + } + + @Test + fun `submitEnabled returns false when there is a selectedAccountProvider but the result is successful`() { + val sut = aChooseAccountProviderState( + selectedAccountProvider = anAccountProvider(), + loginMode = AsyncData.Success(LoginMode.PasswordLogin), + ) + assertThat(sut.submitEnabled).isFalse() + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt new file mode 100644 index 0000000..f7ff5d3 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.chooseaccountprovider + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.login.impl.accountprovider.anAccountProvider +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.auth.OidcDetails +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class ChooseAccountProviderViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invokes the expected callback`() { + val eventSink = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setChooseAccountProviderView( + state = aChooseAccountProviderState( + eventSink = eventSink, + ), + onBackClick = it, + ) + rule.pressBack() + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `selecting an account provider emits the the expected event`() { + val eventSink = EventsRecorder() + rule.setChooseAccountProviderView( + state = aChooseAccountProviderState( + accountProviders = listOf( + ChooseAccountProviderPresenterTest.accountProvider1, + ChooseAccountProviderPresenterTest.accountProvider2, + ), + selectedAccountProvider = anAccountProvider(), + eventSink = eventSink, + ), + ) + rule.onNodeWithText(ChooseAccountProviderPresenterTest.accountProvider1.title).performClick() + eventSink.assertSingle(ChooseAccountProviderEvents.SelectAccountProvider(ChooseAccountProviderPresenterTest.accountProvider1)) + } + + @Test + fun `when error is displayed - closing the dialog emits the expected event`() { + val eventSink = EventsRecorder() + rule.setChooseAccountProviderView( + state = aChooseAccountProviderState( + loginMode = AsyncData.Failure(AN_EXCEPTION), + eventSink = eventSink, + ), + ) + rule.clickOn(CommonStrings.action_ok) + eventSink.assertSingle(ChooseAccountProviderEvents.ClearError) + } + + private fun AndroidComposeTestRule.setChooseAccountProviderView( + state: ChooseAccountProviderState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onOidcDetails: (OidcDetails) -> Unit = EnsureNeverCalledWithParam(), + onNeedLoginPassword: () -> Unit = EnsureNeverCalled(), + onLearnMoreClick: () -> Unit = EnsureNeverCalled(), + onCreateAccountContinue: (url: String) -> Unit = EnsureNeverCalledWithParam(), + ) { + setContent { + ChooseAccountProviderView( + state = state, + onBackClick = onBackClick, + onOidcDetails = onOidcDetails, + onNeedLoginPassword = onNeedLoginPassword, + onLearnMoreClick = onLearnMoreClick, + onCreateAccountContinue = onCreateAccountContinue, + ) + } + } +} 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 0000000..6372841 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -0,0 +1,394 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.confirmaccountprovider + +import com.google.common.truth.Truth.assertThat +import io.element.android.appconfig.AuthenticationConfig +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.login.LoginMode +import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported +import io.element.android.features.login.impl.screens.onboarding.createLoginHelper +import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever +import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails +import io.element.android.libraries.oidc.api.OidcAction +import io.element.android.libraries.oidc.api.OidcActionFlow +import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class ConfirmAccountProviderPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial test`() = runTest { + val presenter = createConfirmAccountProviderPresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.isAccountCreation).isFalse() + assertThat(initialState.submitEnabled).isTrue() + assertThat(initialState.accountProvider.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + assertThat(initialState.loginMode).isEqualTo(AsyncData.Uninitialized) + } + } + + @Test + fun `present - continue password login`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true)) + }, + ) + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + val loadingState = awaitItem() + assertThat(loadingState.submitEnabled).isTrue() + assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java) + val successState = awaitItem() + assertThat(successState.submitEnabled).isFalse() + assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java) + assertThat(successState.loginMode.dataOrNull()).isEqualTo(LoginMode.PasswordLogin) + } + } + + @Test + fun `present - continue oidc`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.success(aMatrixHomeServerDetails(supportsOidcLogin = true)) + }, + ) + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + val loadingState = awaitItem() + assertThat(loadingState.submitEnabled).isTrue() + assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java) + val successState = awaitItem() + assertThat(successState.submitEnabled).isFalse() + assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java) + assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java) + } + } + + @Test + fun `present - oidc - cancel with failure`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.success(aMatrixHomeServerDetails(supportsOidcLogin = true)) + }, + ) + val defaultOidcActionFlow = FakeOidcActionFlow() + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + defaultOidcActionFlow = defaultOidcActionFlow, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + val loadingState = awaitItem() + assertThat(loadingState.submitEnabled).isTrue() + assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java) + val successState = awaitItem() + assertThat(successState.submitEnabled).isFalse() + assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java) + assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java) + authenticationService.givenOidcCancelError(AN_EXCEPTION) + defaultOidcActionFlow.post(OidcAction.GoBack()) + val cancelFailureState = awaitItem() + assertThat(cancelFailureState.loginMode).isInstanceOf(AsyncData.Failure::class.java) + } + } + + @Test + fun `present - oidc - cancel with success`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.success(aMatrixHomeServerDetails(supportsOidcLogin = true)) + }, + ) + val defaultOidcActionFlow = FakeOidcActionFlow() + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + defaultOidcActionFlow = defaultOidcActionFlow, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + val loadingState = awaitItem() + assertThat(loadingState.submitEnabled).isTrue() + assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java) + val successState = awaitItem() + assertThat(successState.submitEnabled).isFalse() + assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java) + assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java) + defaultOidcActionFlow.post(OidcAction.GoBack()) + val cancelFinalState = awaitItem() + assertThat(cancelFinalState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java) + } + } + + @Test + fun `present - oidc - cancel to unblock`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.success(aMatrixHomeServerDetails(supportsOidcLogin = true)) + }, + ) + val defaultOidcActionFlow = FakeOidcActionFlow() + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + defaultOidcActionFlow = defaultOidcActionFlow, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + val loadingState = awaitItem() + assertThat(loadingState.submitEnabled).isTrue() + assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java) + defaultOidcActionFlow.post(OidcAction.GoBack(toUnblock = true)) + val cancelFinalState = awaitItem() + assertThat(cancelFinalState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java) + } + } + + @Test + fun `present - oidc - success with failure`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.success(aMatrixHomeServerDetails(supportsOidcLogin = true)) + }, + ) + val defaultOidcActionFlow = FakeOidcActionFlow() + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + defaultOidcActionFlow = defaultOidcActionFlow, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + val loadingState = awaitItem() + assertThat(loadingState.submitEnabled).isTrue() + assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java) + val successState = awaitItem() + assertThat(successState.submitEnabled).isFalse() + assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java) + assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java) + authenticationService.givenLoginError(AN_EXCEPTION) + defaultOidcActionFlow.post(OidcAction.Success("aUrl")) + val cancelLoadingState = awaitItem() + assertThat(cancelLoadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java) + val cancelFailureState = awaitItem() + assertThat(cancelFailureState.loginMode).isInstanceOf(AsyncData.Failure::class.java) + } + } + + @Test + fun `present - oidc - success with success`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.success(aMatrixHomeServerDetails(supportsOidcLogin = true)) + }, + ) + val defaultOidcActionFlow = FakeOidcActionFlow() + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + defaultOidcActionFlow = defaultOidcActionFlow, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + val loadingState = awaitItem() + assertThat(loadingState.submitEnabled).isTrue() + assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java) + val successState = awaitItem() + assertThat(successState.submitEnabled).isFalse() + assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java) + assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java) + defaultOidcActionFlow.post(OidcAction.Success("aUrl")) + val successSuccessState = awaitItem() + assertThat(successSuccessState.loginMode).isInstanceOf(AsyncData.Loading::class.java) + } + } + + @Test + fun `present - submit fails`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.failure(AN_EXCEPTION) + }, + ) + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ConfirmAccountProviderEvents.Continue) + skipItems(1) // Loading + val failureState = awaitItem() + assertThat(failureState.submitEnabled).isFalse() + assertThat(failureState.loginMode).isInstanceOf(AsyncData.Failure::class.java) + } + } + + @Test + fun `present - clear error`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.failure(AN_EXCEPTION) + }, + ) + val presenter = createConfirmAccountProviderPresenter( + matrixAuthenticationService = authenticationService, + ) + presenter.test { + val initialState = awaitItem() + + // Submit will return an error + initialState.eventSink(ConfirmAccountProviderEvents.Continue) + + skipItems(1) // Loading + + // Check an error was returned + val submittedState = awaitItem() + assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Failure::class.java) + + // Assert the error is then cleared + submittedState.eventSink(ConfirmAccountProviderEvents.ClearError) + val clearedState = awaitItem() + assertThat(clearedState.loginMode).isEqualTo(AsyncData.Uninitialized) + } + } + + @Test + fun `present - confirm account creation without oidc and without url generates an error`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.success(aMatrixHomeServerDetails()) + }, + ) + val presenter = createConfirmAccountProviderPresenter( + params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true), + matrixAuthenticationService = authenticationService, + webClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever { + throw AccountCreationNotSupported() + }, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.Continue) + skipItems(1) // Loading + // Check an error was returned + val submittedState = awaitItem() + assertThat(submittedState.loginMode.errorOrNull()).isInstanceOf(AccountCreationNotSupported::class.java) + // Assert the error is then cleared + submittedState.eventSink(ConfirmAccountProviderEvents.ClearError) + val clearedState = awaitItem() + assertThat(clearedState.loginMode).isEqualTo(AsyncData.Uninitialized) + } + } + + @Test + fun `present - confirm account creation with oidc is successful`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.success(aMatrixHomeServerDetails(supportsOidcLogin = true)) + }, + ) + val presenter = createConfirmAccountProviderPresenter( + params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true), + matrixAuthenticationService = authenticationService, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.Continue) + skipItems(1) // Loading + val submittedState = awaitItem() + assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Success::class.java) + assertThat(submittedState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java) + } + } + + @Test + fun `present - confirm account creation with oidc and url continues with oidc`() = runTest { + val aUrl = "aUrl" + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.success(aMatrixHomeServerDetails(supportsOidcLogin = true)) + }, + ) + val presenter = createConfirmAccountProviderPresenter( + params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true), + matrixAuthenticationService = authenticationService, + webClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever { aUrl }, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.Continue) + skipItems(1) // Loading + val submittedState = awaitItem() + assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Success::class.java) + assertThat(submittedState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java) + } + } + + @Test + fun `present - confirm account creation without oidc and with url continuing with url`() = runTest { + val aUrl = "aUrl" + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.success(aMatrixHomeServerDetails()) + }, + ) + val presenter = createConfirmAccountProviderPresenter( + params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true), + matrixAuthenticationService = authenticationService, + webClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever { aUrl }, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ConfirmAccountProviderEvents.Continue) + skipItems(1) // Loading + val submittedState = awaitItem() + assertThat(submittedState.loginMode.dataOrNull()).isEqualTo(LoginMode.AccountCreation(aUrl)) + } + } + + private fun createConfirmAccountProviderPresenter( + params: ConfirmAccountProviderPresenter.Params = ConfirmAccountProviderPresenter.Params(isAccountCreation = false), + accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), + defaultOidcActionFlow: OidcActionFlow = FakeOidcActionFlow(), + webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(), + ) = ConfirmAccountProviderPresenter( + params = params, + accountProviderDataSource = accountProviderDataSource, + loginHelper = createLoginHelper( + authenticationService = matrixAuthenticationService, + oidcActionFlow = defaultOidcActionFlow, + webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever, + ), + ) +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt new file mode 100644 index 0000000..a9d3c02 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.createaccount + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.auth.external.ExternalSession +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus +import io.element.android.libraries.matrix.test.AN_EXCEPTION +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.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class CreateAccountPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.url).isEqualTo("aUrl") + assertThat(initialState.pageProgress).isEqualTo(0) + assertThat(initialState.createAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(initialState.isDebugBuild).isTrue() + } + } + + @Test + fun `present - set up progress update the state`() = runTest { + val presenter = createPresenter() + presenter.test { + val initialState = awaitItem() + initialState.eventSink(CreateAccountEvents.SetPageProgress(33)) + assertThat(awaitItem().pageProgress).isEqualTo(33) + } + } + + @Test + fun `present - receiving a message not able to be parsed change the state to error`() = runTest { + val presenter = createPresenter( + messageParser = FakeMessageParser { error("An error") } + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(CreateAccountEvents.OnMessageReceived("")) + assertThat(awaitItem().createAction).isInstanceOf(AsyncAction.Failure::class.java) + } + } + + @Test + fun `present - receiving a message containing isTrusted is ignored`() = runTest { + val presenter = createPresenter() + presenter.test { + val initialState = awaitItem() + initialState.eventSink(CreateAccountEvents.OnMessageReceived("isTrusted")) + } + } + + @Test + fun `present - receiving a message able to be parsed change the state to success`() = runTest { + val lambda = lambdaRecorder { _ -> anExternalSession() } + val sessionVerificationService = FakeSessionVerificationService() + val client = FakeMatrixClient(sessionVerificationService = sessionVerificationService) + val clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }) + val presenter = createPresenter( + authenticationService = FakeMatrixAuthenticationService( + importCreatedSessionLambda = { Result.success(A_SESSION_ID) } + ), + messageParser = FakeMessageParser(lambda), + clientProvider = clientProvider, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(CreateAccountEvents.OnMessageReceived("aMessage")) + assertThat(awaitItem().createAction.isLoading()).isTrue() + sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified) + assertThat(awaitItem().createAction.dataOrNull()).isEqualTo(A_SESSION_ID) + } + lambda.assertions().isCalledOnce().with(value("aMessage")) + } + + @Test + fun `present - receiving a message able to be parsed but error in importing change the state to error`() = runTest { + val presenter = createPresenter( + authenticationService = FakeMatrixAuthenticationService( + importCreatedSessionLambda = { Result.failure(AN_EXCEPTION) } + ), + messageParser = FakeMessageParser { anExternalSession() } + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(CreateAccountEvents.OnMessageReceived("")) + assertThat(awaitItem().createAction.isLoading()).isTrue() + assertThat(awaitItem().createAction.errorOrNull()).isNotNull() + } + } + + private fun createPresenter( + url: String = "aUrl", + authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), + messageParser: MessageParser = FakeMessageParser(), + buildMeta: BuildMeta = aBuildMeta(), + clientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), + ) = CreateAccountPresenter( + url = url, + authenticationService = authenticationService, + messageParser = messageParser, + buildMeta = buildMeta, + clientProvider = clientProvider, + ) +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/DefaultMessageParserTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/DefaultMessageParserTest.kt new file mode 100644 index 0000000..680fda2 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/DefaultMessageParserTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.createaccount + +import com.google.common.truth.Truth.assertThat +import io.element.android.appconfig.AuthenticationConfig +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.libraries.androidutils.json.DefaultJsonProvider +import io.element.android.libraries.matrix.api.auth.external.ExternalSession +import kotlinx.serialization.SerializationException +import org.junit.Assert.assertThrows +import org.junit.Test + +class DefaultMessageParserTest { + private val validMessage = """ + { + "user_id": "user_id", + "home_server": "home_server", + "access_token": "access_token", + "device_id": "device_id" + } + """.trimIndent() + + @Test + fun `DefaultMessageParser is able to parse correct message`() { + val sut = createDefaultMessageParser() + assertThat(sut.parse(validMessage)).isEqualTo( + anExternalSession( + homeserverUrl = "home_server", + ) + ) + } + + @Test + fun `DefaultMessageParser should throw Exception in case of error`() { + val sut = createDefaultMessageParser() + // kotlinx.serialization.json.internal.JsonDecodingException + assertThrows(SerializationException::class.java) { sut.parse("invalid json") } + // missing userId + assertThrows(IllegalStateException::class.java) { sut.parse(validMessage.replace(""""user_id": "user_id",""", "")) } + // missing accessToken + assertThrows(IllegalStateException::class.java) { sut.parse(validMessage.replace(""""access_token": "access_token",""", "")) } + // missing deviceId + assertThrows(IllegalStateException::class.java) { + sut.parse( + validMessage + .replace(""""access_token": "access_token",""", """"access_token": "access_token"""") + .replace(""""device_id": "device_id"""", "") + ) + } + } + + @Test + fun `DefaultMessageParser should be successful even is homeserver url is missing`() { + val sut = createDefaultMessageParser() + // missing homeServer + assertThat(sut.parse(validMessage.replace(""""home_server": "home_server",""", ""))).isEqualTo( + anExternalSession( + homeserverUrl = AuthenticationConfig.MATRIX_ORG_URL, + ) + ) + } + + private fun createDefaultMessageParser(): DefaultMessageParser { + return DefaultMessageParser( + accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + json = DefaultJsonProvider(), + ) + } +} + +internal fun anExternalSession( + homeserverUrl: String = "home_server", +) = ExternalSession( + userId = "user_id", + homeserverUrl = homeserverUrl, + accessToken = "access_token", + deviceId = "device_id", + refreshToken = null, +) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/FakeMessageParser.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/FakeMessageParser.kt new file mode 100644 index 0000000..62d6dd7 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/FakeMessageParser.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.createaccount + +import io.element.android.libraries.matrix.api.auth.external.ExternalSession +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeMessageParser( + private val parseResult: (String) -> ExternalSession = { lambdaError() } +) : MessageParser { + override fun parse(message: String): ExternalSession { + return parseResult(message) + } +} 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 0000000..9209918 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.loginpassword + +import com.google.common.truth.Truth.assertThat +import io.element.android.appconfig.AuthenticationConfig +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.AN_EXCEPTION +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_USER_NAME +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class LoginPasswordPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + createLoginPasswordPresenter().test { + val initialState = awaitItem() + assertThat(initialState.accountProvider.url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + assertThat(initialState.formState).isEqualTo(LoginFormState.Default) + assertThat(initialState.loginAction).isEqualTo(AsyncData.Uninitialized) + assertThat(initialState.submitEnabled).isFalse() + } + } + + @Test + fun `present - enter login and password`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.success(aMatrixHomeServerDetails()) + }, + ) + createLoginPasswordPresenter( + authenticationService = authenticationService, + ).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 = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.success(aMatrixHomeServerDetails()) + }, + ) + createLoginPasswordPresenter( + authenticationService = authenticationService, + ).test { + 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(AsyncData.Loading::class.java) + val loggedInState = awaitItem() + assertThat(loggedInState.loginAction).isEqualTo(AsyncData.Success(A_SESSION_ID)) + } + } + + @Test + fun `present - submit with error`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.success(aMatrixHomeServerDetails()) + }, + ) + createLoginPasswordPresenter( + authenticationService = authenticationService, + ).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(AN_EXCEPTION) + loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit) + val submitState = awaitItem() + assertThat(submitState.loginAction).isInstanceOf(AsyncData.Loading::class.java) + val loggedInState = awaitItem() + assertThat(loggedInState.loginAction).isEqualTo(AsyncData.Failure(AN_EXCEPTION)) + } + } + + @Test + fun `present - clear error`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.success(aMatrixHomeServerDetails()) + }, + ) + createLoginPasswordPresenter( + authenticationService = authenticationService, + ).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(AN_EXCEPTION) + loginAndPasswordState.eventSink.invoke(LoginPasswordEvents.Submit) + val submitState = awaitItem() + assertThat(submitState.loginAction).isInstanceOf(AsyncData.Loading::class.java) + val loggedInState = awaitItem() + // Check an error was returned + assertThat(loggedInState.loginAction).isEqualTo(AsyncData.Failure(AN_EXCEPTION)) + // Assert the error is then cleared + loggedInState.eventSink(LoginPasswordEvents.ClearError) + val clearedState = awaitItem() + assertThat(clearedState.loginAction).isEqualTo(AsyncData.Uninitialized) + } + } + + private fun createLoginPasswordPresenter( + authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(), + accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), + ): LoginPasswordPresenter = LoginPasswordPresenter( + authenticationService = authenticationService, + accountProviderDataSource = accountProviderDataSource, + ) +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt new file mode 100644 index 0000000..26da50d --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.loginpassword + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.matrix.test.A_PASSWORD +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class LoginPasswordViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invoke back callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setLoginPasswordView( + aLoginPasswordState( + eventSink = eventsRecorder + ), + onBackClick = callback, + ) + rule.pressBack() + } + } + + @Test + fun `changing login invokes the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setLoginPasswordView( + aLoginPasswordState( + eventSink = eventsRecorder, + ), + ) + val userNameHint = rule.activity.getString(CommonStrings.common_username) + rule.onNodeWithText(userNameHint).performTextInput(A_USER_NAME) + eventsRecorder.assertSingle( + LoginPasswordEvents.SetLogin(A_USER_NAME) + ) + } + + @Test + fun `changing login removes new lines the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setLoginPasswordView( + aLoginPasswordState( + eventSink = eventsRecorder, + ), + ) + val userNameHint = rule.activity.getString(CommonStrings.common_username) + rule.onNodeWithText(userNameHint).performTextInput("a\nb") + eventsRecorder.assertSingle( + LoginPasswordEvents.SetLogin("ab") + ) + } + + @Test + fun `clearing login invokes the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setLoginPasswordView( + aLoginPasswordState( + formState = aLoginFormState(A_USER_NAME), + eventSink = eventsRecorder, + ), + ) + val a11yClear = rule.activity.getString(CommonStrings.action_clear) + rule.onNodeWithContentDescription(a11yClear).performClick() + eventsRecorder.assertSingle( + LoginPasswordEvents.SetLogin("") + ) + } + + @Test + fun `changing password invokes the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setLoginPasswordView( + aLoginPasswordState( + eventSink = eventsRecorder, + ), + ) + val userNameHint = rule.activity.getString(CommonStrings.common_password) + rule.onNodeWithText(userNameHint).performTextInput(A_PASSWORD) + eventsRecorder.assertSingle( + LoginPasswordEvents.SetPassword(A_PASSWORD) + ) + } + + @Test + fun `reveal password makes the password visible`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setLoginPasswordView( + aLoginPasswordState( + formState = aLoginFormState(password = A_PASSWORD), + eventSink = eventsRecorder, + ), + ) + rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText("••••••••")) + // Show password + val a11yShowPassword = rule.activity.getString(CommonStrings.a11y_show_password) + rule.onNodeWithContentDescription(a11yShowPassword).performClick() + rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText(A_PASSWORD)) + // Hide password + val a11yHidePassword = rule.activity.getString(CommonStrings.a11y_hide_password) + rule.onNodeWithContentDescription(a11yHidePassword).performClick() + rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText("••••••••")) + } + + @Test + fun `when login is empty, continue button is not enabled`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setLoginPasswordView( + aLoginPasswordState( + formState = aLoginFormState(password = A_PASSWORD), + eventSink = eventsRecorder, + ), + ) + val continueStr = rule.activity.getString(CommonStrings.action_continue) + rule.onNodeWithText(continueStr).assertIsNotEnabled() + } + + @Test + fun `when password is empty, continue button is not enabled`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setLoginPasswordView( + aLoginPasswordState( + formState = aLoginFormState(login = A_USER_NAME), + eventSink = eventsRecorder, + ), + ) + val continueStr = rule.activity.getString(CommonStrings.action_continue) + rule.onNodeWithText(continueStr).assertIsNotEnabled() + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on Continue sends expected event`() { + val eventsRecorder = EventsRecorder() + rule.setLoginPasswordView( + aLoginPasswordState( + formState = aLoginFormState(login = A_USER_NAME, password = A_PASSWORD), + eventSink = eventsRecorder, + ), + ) + val continueStr = rule.activity.getString(CommonStrings.action_continue) + rule.onNodeWithText(continueStr).assertIsEnabled() + rule.clickOn(CommonStrings.action_continue) + eventsRecorder.assertSingle( + LoginPasswordEvents.Submit + ) + } +} + +private fun AndroidComposeTestRule.setLoginPasswordView( + state: LoginPasswordState, + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + LoginPasswordView( + state = state, + onBackClick = onBackClick, + ) + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/DefaultOnBoardingLogoResIdProviderTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/DefaultOnBoardingLogoResIdProviderTest.kt new file mode 100644 index 0000000..6e67925 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/DefaultOnBoardingLogoResIdProviderTest.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.onboarding + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DefaultOnBoardingLogoResIdProviderTest { + @Test + fun `when onboarding_logo resource does not exist, get() returns null`() { + val context = InstrumentationRegistry.getInstrumentation().context + val sut = DefaultOnBoardingLogoResIdProvider(context) + val result = sut.get() + assertThat(result).isNull() + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt new file mode 100644 index 0000000..1d43499 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt @@ -0,0 +1,296 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.onboarding + +import com.google.common.truth.Truth.assertThat +import io.element.android.appconfig.AuthenticationConfig +import io.element.android.appconfig.OnBoardingConfig +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl +import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource +import io.element.android.features.login.impl.login.LoginHelper +import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever +import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever +import io.element.android.features.wellknown.test.FakeWellknownRetriever +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER +import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2 +import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_3 +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_HOMESERVER_URL +import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2 +import io.element.android.libraries.matrix.test.A_LOGIN_HINT +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.oidc.api.OidcActionFlow +import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.libraries.wellknown.api.WellknownRetriever +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class OnBoardingPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + companion object { + private const val ACCOUNT_PROVIDER_FROM_LINK = AN_ACCOUNT_PROVIDER + private const val ACCOUNT_PROVIDER_FROM_CONFIG = AN_ACCOUNT_PROVIDER_2 + private const val ACCOUNT_PROVIDER_FROM_CONFIG_2 = AN_ACCOUNT_PROVIDER_3 + } + + @Test + fun `present - ensure initial conditions`() { + assertThat( + setOf( + ACCOUNT_PROVIDER_FROM_LINK, + ACCOUNT_PROVIDER_FROM_CONFIG, + ACCOUNT_PROVIDER_FROM_CONFIG_2, + ).size + ).isEqualTo(3) + } + + @Test + fun `present - initial state`() = runTest { + val buildMeta = aBuildMeta( + applicationName = "A", + productionApplicationName = "B", + desktopApplicationName = "C", + ) + val presenter = createPresenter( + buildMeta = buildMeta, + enterpriseService = FakeEnterpriseService( + defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) }, + ), + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.defaultAccountProvider).isNull() + assertThat(initialState.canLoginWithQrCode).isFalse() + assertThat(initialState.productionApplicationName).isEqualTo("B") + assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT) + assertThat(initialState.canReportBug).isFalse() + assertThat(initialState.isAddingAccount).isFalse() + assertThat(awaitItem().canLoginWithQrCode).isTrue() + } + } + + @Test + fun `present - initial state adding account`() = runTest { + val presenter = createPresenter( + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData() + ) + ) + ) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isAddingAccount).isTrue() + } + } + + @Test + fun `present - on boarding logo`() = runTest { + val presenter = createPresenter( + onBoardingLogoResIdProvider = OnBoardingLogoResIdProvider { 42 }, + ) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.onBoardingLogoResId).isEqualTo(42) + } + } + + @Test + fun `present - clicking on version 7 times has no effect if rageshake not available`() = runTest { + val presenter = createPresenter( + rageshakeFeatureAvailability = { flowOf(false) }, + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.canReportBug).isFalse() + repeat(7) { + state.eventSink(OnBoardingEvents.OnVersionClick) + } + } + expectNoEvents() + } + } + + @Test + fun `present - clicking on version 7 times will reveal the report a problem button`() = runTest { + val presenter = createPresenter() + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.canReportBug).isFalse() + repeat(7) { + state.eventSink(OnBoardingEvents.OnVersionClick) + } + } + assertThat(awaitItem().canReportBug).isTrue() + } + } + + @Test + fun `present - opening the app using link with allowed account provider, and the app does not force account provider`() = runTest { + val presenter = createPresenter( + params = OnBoardingNode.Params( + accountProvider = ACCOUNT_PROVIDER_FROM_LINK, + loginHint = null, + ), + enterpriseService = FakeEnterpriseService( + defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) }, + isAllowedToConnectToHomeserverResult = { true }, + ), + ) + presenter.test { + skipItems(3) + awaitItem().also { + assertThat(it.defaultAccountProvider).isEqualTo(ACCOUNT_PROVIDER_FROM_LINK) + assertThat(it.canLoginWithQrCode).isFalse() + assertThat(it.canCreateAccount).isFalse() + } + } + } + + @Test + fun `present - opening the app using link with not allowed account provider, and the app does not force account provider`() = runTest { + val presenter = createPresenter( + params = OnBoardingNode.Params( + accountProvider = ACCOUNT_PROVIDER_FROM_LINK, + loginHint = null, + ), + enterpriseService = FakeEnterpriseService( + defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, ACCOUNT_PROVIDER_FROM_CONFIG_2) }, + isAllowedToConnectToHomeserverResult = { false }, + ), + ) + presenter.test { + skipItems(1) + awaitItem().also { + assertThat(it.defaultAccountProvider).isNull() + assertThat(it.canLoginWithQrCode).isTrue() + assertThat(it.canCreateAccount).isFalse() + } + } + } + + @Test + fun `present - opening the app using link, and the app forces account provider`() = runTest { + val presenter = createPresenter( + params = OnBoardingNode.Params( + accountProvider = ACCOUNT_PROVIDER_FROM_LINK, + loginHint = null, + ), + enterpriseService = FakeEnterpriseService( + defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG) }, + ) + ) + presenter.test { + skipItems(1) + awaitItem().also { + assertThat(it.defaultAccountProvider).isEqualTo(ACCOUNT_PROVIDER_FROM_CONFIG) + assertThat(it.canLoginWithQrCode).isTrue() + assertThat(it.canCreateAccount).isFalse() + } + } + } + + @Test + fun `present - default account provider - login and clear error`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + setHomeserverResult = { + Result.failure(AN_EXCEPTION) + }, + ) + val accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()) + val presenter = createPresenter( + params = OnBoardingNode.Params( + accountProvider = A_HOMESERVER_URL, + loginHint = A_LOGIN_HINT, + ), + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { true }, + ), + loginHelper = createLoginHelper( + authenticationService = authenticationService, + ), + accountProviderDataSource = accountProviderDataSource, + ) + presenter.test { + skipItems(3) + awaitItem().also { + assertThat(it.defaultAccountProvider).isEqualTo(A_HOMESERVER_URL) + assertThat(accountProviderDataSource.flow.first().url).isEqualTo(AuthenticationConfig.MATRIX_ORG_URL) + it.eventSink(OnBoardingEvents.OnSignIn(A_HOMESERVER_URL_2)) + skipItems(1) // Loading + // Account data source has been updated + assertThat(accountProviderDataSource.flow.first().url).isEqualTo(A_HOMESERVER_URL_2) + // Check an error was returned + val submittedState = awaitItem() + assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Failure::class.java) + + // Assert the error is then cleared + submittedState.eventSink(OnBoardingEvents.ClearError) + val clearedState = awaitItem() + assertThat(clearedState.loginMode).isEqualTo(AsyncData.Uninitialized) + } + } + } +} + +private fun createPresenter( + params: OnBoardingNode.Params = OnBoardingNode.Params(null, null), + buildMeta: BuildMeta = aBuildMeta(), + enterpriseService: EnterpriseService = FakeEnterpriseService(), + wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(), + rageshakeFeatureAvailability: () -> Flow = { flowOf(true) }, + loginHelper: LoginHelper = createLoginHelper(), + onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider = OnBoardingLogoResIdProvider { null }, + sessionStore: SessionStore = InMemorySessionStore(), + accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()), +) = OnBoardingPresenter( + params = params, + buildMeta = buildMeta, + enterpriseService = enterpriseService, + defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( + enterpriseService = enterpriseService, + wellknownRetriever = wellknownRetriever, + ), + rageshakeFeatureAvailability = rageshakeFeatureAvailability, + loginHelper = loginHelper, + onBoardingLogoResIdProvider = onBoardingLogoResIdProvider, + sessionStore = sessionStore, + accountProviderDataSource = accountProviderDataSource, +) + +fun createLoginHelper( + oidcActionFlow: OidcActionFlow = FakeOidcActionFlow(), + authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), + webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(), +): LoginHelper = LoginHelper( + oidcActionFlow = oidcActionFlow, + authenticationService = authenticationService, + webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever, +) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt new file mode 100644 index 0000000..c8dcd97 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt @@ -0,0 +1,280 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.onboarding + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.login.LoginMode +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.auth.OidcDetails +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class OnboardingViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `when can create account - clicking on create account calls the expected callback`() { + val eventSink = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setOnboardingView( + state = anOnBoardingState( + canCreateAccount = true, + eventSink = eventSink, + ), + onCreateAccount = callback, + ) + rule.clickOn(R.string.screen_onboarding_sign_up) + } + } + + @Test + fun `when can go back - clicking on back calls the expected callback`() { + val eventSink = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setOnboardingView( + state = anOnBoardingState( + isAddingAccount = true, + eventSink = eventSink, + ), + onBackClick = callback, + ) + rule.pressBack() + } + } + + @Test + fun `when can login with QR code - clicking on sign in with QR code calls the expected callback`() { + val eventSink = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setOnboardingView( + state = anOnBoardingState( + canLoginWithQrCode = true, + eventSink = eventSink, + ), + onSignInWithQrCode = callback, + ) + rule.clickOn(R.string.screen_onboarding_sign_in_with_qr_code) + } + } + + @Test + fun `when can login with QR code - clicking on sign in manually calls the expected callback - can search account provider`() { + `when can login with QR code - clicking on sign in manually calls the expected callback`( + mustChooseAccountProvider = false, + ) + } + + @Test + fun `when can login with QR code - clicking on sign in manually calls the expected callback - cannot search account provider`() { + `when can login with QR code - clicking on sign in manually calls the expected callback`( + mustChooseAccountProvider = true, + ) + } + + private fun `when can login with QR code - clicking on sign in manually calls the expected callback`( + mustChooseAccountProvider: Boolean, + ) { + val eventSink = EventsRecorder(expectEvents = false) + ensureCalledOnceWithParam(mustChooseAccountProvider) { callback -> + rule.setOnboardingView( + state = anOnBoardingState( + canLoginWithQrCode = true, + mustChooseAccountProvider = mustChooseAccountProvider, + eventSink = eventSink, + ), + onSignIn = callback, + ) + rule.clickOn(R.string.screen_onboarding_sign_in_manually) + } + } + + @Test + fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback - can search account provider`() { + `when cannot login with QR code or create account - clicking on continue calls the sign in callback`( + mustChooseAccountProvider = false, + ) + } + + @Test + fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback - cannot search account provider`() { + `when cannot login with QR code or create account - clicking on continue calls the sign in callback`( + mustChooseAccountProvider = true, + ) + } + + private fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback`( + mustChooseAccountProvider: Boolean, + ) { + val eventSink = EventsRecorder(expectEvents = false) + ensureCalledOnceWithParam(mustChooseAccountProvider) { callback -> + rule.setOnboardingView( + state = anOnBoardingState( + canLoginWithQrCode = false, + canCreateAccount = false, + mustChooseAccountProvider = mustChooseAccountProvider, + eventSink = eventSink, + ), + onSignIn = callback, + ) + rule.clickOn(CommonStrings.action_continue) + } + } + + @Test + fun `when sign in to pre defined account provider - clicking on button emits the expected event`() { + val eventSink = EventsRecorder() + rule.setOnboardingView( + state = anOnBoardingState( + defaultAccountProvider = "element.io", + eventSink = eventSink, + ), + ) + val buttonText = rule.activity.getString(R.string.screen_onboarding_sign_in_to, "element.io") + rule.onNodeWithText(buttonText).performClick() + eventSink.assertSingle(OnBoardingEvents.OnSignIn("element.io")) + } + + @Test + fun `when error is displayed - closing the dialog emits the expected event`() { + val eventSink = EventsRecorder() + rule.setOnboardingView( + state = anOnBoardingState( + defaultAccountProvider = "element.io", + loginMode = AsyncData.Failure(AN_EXCEPTION), + eventSink = eventSink, + ), + ) + rule.clickOn(CommonStrings.action_ok) + eventSink.assertSingle(OnBoardingEvents.ClearError) + } + + @Test + fun `clicking on report a problem calls the sign in callback`() { + val eventSink = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setOnboardingView( + state = anOnBoardingState( + canReportBug = true, + eventSink = eventSink, + ), + onReportProblem = callback, + ) + val text = rule.activity.getString(CommonStrings.common_report_a_problem) + rule.onNodeWithText(text).assertExists() + rule.clickOn(CommonStrings.common_report_a_problem) + } + } + + @Test + fun `cannot report a problem when the feature is disabled`() { + val eventSink = EventsRecorder(expectEvents = false) + rule.setOnboardingView( + state = anOnBoardingState( + canReportBug = false, + eventSink = eventSink, + ), + ) + val text = rule.activity.getString(CommonStrings.common_report_a_problem) + rule.onNodeWithText(text).assertDoesNotExist() + } + + @Test + fun `when success PasswordLogin - the expected callback is invoked and the event is received`() { + val eventSink = EventsRecorder() + ensureCalledOnce { callback -> + rule.setOnboardingView( + state = anOnBoardingState( + loginMode = AsyncData.Success(LoginMode.PasswordLogin), + eventSink = eventSink, + ), + onNeedLoginPassword = callback, + ) + } + eventSink.assertSingle(OnBoardingEvents.ClearError) + } + + @Test + fun `when success Oidc - the expected callback is invoked and the event is received`() { + val eventSink = EventsRecorder() + val oidcDetails = OidcDetails("aUrl") + ensureCalledOnceWithParam(oidcDetails) { callback -> + rule.setOnboardingView( + state = anOnBoardingState( + loginMode = AsyncData.Success(LoginMode.Oidc(oidcDetails)), + eventSink = eventSink, + ), + onOidcDetails = callback, + ) + } + eventSink.assertSingle(OnBoardingEvents.ClearError) + } + + @Test + fun `when success AccountCreation - the expected callback is invoked and the event is received`() { + val eventSink = EventsRecorder() + val oidcDetails = OidcDetails("aUrl") + ensureCalledOnceWithParam(oidcDetails.url) { callback -> + rule.setOnboardingView( + state = anOnBoardingState( + loginMode = AsyncData.Success(LoginMode.AccountCreation("aUrl")), + eventSink = eventSink, + ), + onCreateAccountContinue = callback, + ) + } + eventSink.assertSingle(OnBoardingEvents.ClearError) + } + + private fun AndroidComposeTestRule.setOnboardingView( + state: OnBoardingState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onSignInWithQrCode: () -> Unit = EnsureNeverCalled(), + onSignIn: (Boolean) -> Unit = EnsureNeverCalledWithParam(), + onCreateAccount: () -> Unit = EnsureNeverCalled(), + onReportProblem: () -> Unit = EnsureNeverCalled(), + onOidcDetails: (OidcDetails) -> Unit = EnsureNeverCalledWithParam(), + onNeedLoginPassword: () -> Unit = EnsureNeverCalled(), + onLearnMoreClick: () -> Unit = EnsureNeverCalled(), + onCreateAccountContinue: (url: String) -> Unit = EnsureNeverCalledWithParam(), + ) { + setContent { + OnBoardingView( + state = state, + onBackClick = onBackClick, + onSignInWithQrCode = onSignInWithQrCode, + onSignIn = onSignIn, + onCreateAccount = onCreateAccount, + onReportProblem = onReportProblem, + onOidcDetails = onOidcDetails, + onNeedLoginPassword = onNeedLoginPassword, + onLearnMoreClick = onLearnMoreClick, + onCreateAccountContinue = onCreateAccountContinue, + ) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationViewTest.kt new file mode 100644 index 0000000..a0469a6 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationViewTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.confirmation + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class QrCodeConfirmationViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `on back pressed - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setQrCodeConfirmationView( + step = QrCodeConfirmationStep.DisplayCheckCode("12"), + onCancel = callback + ) + rule.pressBackKey() + } + } + + @Test + fun `on Cancel button clicked - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setQrCodeConfirmationView( + step = QrCodeConfirmationStep.DisplayVerificationCode("123456"), + onCancel = callback + ) + rule.clickOn(CommonStrings.action_cancel) + } + } + + private fun AndroidComposeTestRule.setQrCodeConfirmationView( + step: QrCodeConfirmationStep, + onCancel: () -> Unit + ) { + setContent { + QrCodeConfirmationView( + step = step, + onCancel = onCancel + ) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorViewTest.kt new file mode 100644 index 0000000..c7b6a5e --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorViewTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.error + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.qrcode.QrCodeErrorScreenType +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class QrCodeErrorViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `on back pressed - calls the onRetry callback`() { + ensureCalledOnce { callback -> + rule.setQrCodeErrorView( + onRetry = callback + ) + rule.pressBackKey() + } + } + + @Test + fun `on try again button clicked - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setQrCodeErrorView( + onRetry = callback + ) + rule.clickOn(R.string.screen_qr_code_login_start_over_button) + } + } + + private fun AndroidComposeTestRule.setQrCodeErrorView( + onRetry: () -> Unit, + errorScreenType: QrCodeErrorScreenType = QrCodeErrorScreenType.UnknownError, + appName: String = "Element X", + ) { + setContent { + QrCodeErrorView( + errorScreenType = errorScreenType, + appName = appName, + onRetry = onRetry + ) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenterTest.kt new file mode 100644 index 0000000..aabcf12 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenterTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.intro + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.permissions.test.FakePermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class QrCodeIntroPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createQrCodeIntroPresenter() + presenter.test { + awaitItem().run { + assertThat(appName).isEqualTo("AppName") + assertThat(desktopAppName).isEqualTo("DesktopAppName") + assertThat(cameraPermissionState.permission).isEqualTo("android.permission.POST_NOTIFICATIONS") + assertThat(canContinue).isFalse() + } + } + } + + @Test + fun `present - Continue with camera permissions can continue`() = runTest { + val permissionsPresenter = FakePermissionsPresenter().apply { setPermissionGranted() } + val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter) + val presenter = createQrCodeIntroPresenter(permissionsPresenterFactory = permissionsPresenterFactory) + presenter.test { + awaitItem().eventSink(QrCodeIntroEvents.Continue) + assertThat(awaitItem().canContinue).isTrue() + } + } + + @Test + fun `present - Continue with unknown camera permissions opens permission dialog`() = runTest { + val permissionsPresenter = FakePermissionsPresenter() + val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter) + val presenter = createQrCodeIntroPresenter(permissionsPresenterFactory = permissionsPresenterFactory) + presenter.test { + awaitItem().eventSink(QrCodeIntroEvents.Continue) + assertThat(awaitItem().cameraPermissionState.showDialog).isTrue() + } + } + + private fun createQrCodeIntroPresenter( + buildMeta: BuildMeta = aBuildMeta( + applicationName = "AppName", + desktopApplicationName = "DesktopAppName", + ), + permissionsPresenterFactory: FakePermissionsPresenterFactory = FakePermissionsPresenterFactory(), + ): QrCodeIntroPresenter { + return QrCodeIntroPresenter( + buildMeta = buildMeta, + permissionsPresenterFactory = permissionsPresenterFactory, + ) + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroViewTest.kt new file mode 100644 index 0000000..cec67e5 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroViewTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.intro + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.login.impl.R +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class QrCodeIntroViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `on back pressed - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setQrCodeIntroView( + state = aQrCodeIntroState(), + onBackClicked = callback + ) + rule.pressBackKey() + } + } + + @Test + fun `on back button clicked - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setQrCodeIntroView( + state = aQrCodeIntroState(), + onBackClicked = callback + ) + rule.pressBack() + } + } + + @Test + fun `when can continue - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setQrCodeIntroView( + state = aQrCodeIntroState(canContinue = true), + onContinue = callback + ) + } + } + + @Test + fun `on submit button clicked - emits the Continue event`() { + val eventRecorder = EventsRecorder() + rule.setQrCodeIntroView( + state = aQrCodeIntroState(eventSink = eventRecorder), + ) + rule.clickOn(R.string.screen_qr_code_login_initial_state_button_title) + eventRecorder.assertSingle(QrCodeIntroEvents.Continue) + } + + private fun AndroidComposeTestRule.setQrCodeIntroView( + state: QrCodeIntroState, + onBackClicked: () -> Unit = EnsureNeverCalled(), + onContinue: () -> Unit = EnsureNeverCalled(), + ) { + setContent { + QrCodeIntroView( + state = state, + onBackClick = onBackClicked, + onContinue = onContinue, + ) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanPresenterTest.kt new file mode 100644 index 0000000..65a0713 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanPresenterTest.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.scan + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl +import io.element.android.features.login.impl.changeserver.AccountProviderAccessException +import io.element.android.features.login.impl.qrcode.FakeQrCodeLoginManager +import io.element.android.features.wellknown.test.FakeWellknownRetriever +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException +import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData +import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginDataFactory +import io.element.android.libraries.wellknown.api.WellknownRetriever +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class QrCodeScanPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createQrCodeScanPresenter() + presenter.test { + awaitItem().run { + assertThat(isScanning).isTrue() + assertThat(authenticationAction.isUninitialized()).isTrue() + } + } + } + + @Test + fun `present - scanned QR code successfully`() = runTest { + val qrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory( + parseQrCodeLoginDataResult = { + Result.success( + FakeMatrixQrCodeLoginData( + serverNameResult = { "example.com" } + ) + ) + } + ) + val presenter = createQrCodeScanPresenter( + qrCodeLoginDataFactory = qrCodeLoginDataFactory, + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { true }, + ) + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(QrCodeScanEvents.QrCodeScanned(byteArrayOf())) + assertThat(awaitItem().isScanning).isFalse() + assertThat(awaitItem().authenticationAction.isLoading()).isTrue() + assertThat(awaitItem().authenticationAction.isSuccess()).isTrue() + } + } + + @Test + fun `present - scanned QR code successfully, but homeserver not allowed`() = runTest { + val qrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory( + parseQrCodeLoginDataResult = { + Result.success( + FakeMatrixQrCodeLoginData( + serverNameResult = { "example.com" } + ) + ) + } + ) + val presenter = createQrCodeScanPresenter( + qrCodeLoginDataFactory = qrCodeLoginDataFactory, + enterpriseService = FakeEnterpriseService( + isAllowedToConnectToHomeserverResult = { false }, + defaultHomeserverListResult = { listOf("element.io") }, + ) + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(QrCodeScanEvents.QrCodeScanned(byteArrayOf())) + assertThat(awaitItem().isScanning).isFalse() + assertThat(awaitItem().authenticationAction.isLoading()).isTrue() + awaitItem().also { state -> + assertThat( + (state.authenticationAction + .errorOrNull() as AccountProviderAccessException.UnauthorizedAccountProviderException).unauthorisedAccountProviderTitle + ) + .isEqualTo("example.com") + assertThat( + (state.authenticationAction + .errorOrNull() as AccountProviderAccessException.UnauthorizedAccountProviderException).authorisedAccountProviderTitles + ) + .containsExactly("element.io") + } + } + } + + @Test + fun `present - scanned QR code failed and can be retried`() = runTest { + val qrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory( + parseQrCodeLoginDataResult = { Result.failure(Exception("Failed to parse QR code")) } + ) + val presenter = createQrCodeScanPresenter(qrCodeLoginDataFactory = qrCodeLoginDataFactory) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(QrCodeScanEvents.QrCodeScanned(byteArrayOf())) + assertThat(awaitItem().isScanning).isFalse() + assertThat(awaitItem().authenticationAction.isLoading()).isTrue() + + val errorState = awaitItem() + assertThat(errorState.authenticationAction.isFailure()).isTrue() + + errorState.eventSink(QrCodeScanEvents.TryAgain) + assertThat(awaitItem().isScanning).isTrue() + assertThat(awaitItem().authenticationAction.isUninitialized()).isTrue() + } + } + + @Test + fun `present - login failed with so we display the error and recover from it`() = runTest { + val qrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory() + val qrCodeLoginManager = FakeQrCodeLoginManager() + val resetAction = lambdaRecorder { + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Uninitialized + } + qrCodeLoginManager.resetAction = resetAction + val presenter = createQrCodeScanPresenter(qrCodeLoginDataFactory = qrCodeLoginDataFactory, qrCodeLoginManager = qrCodeLoginManager) + presenter.test { + // Skip initial item + skipItems(1) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.OtherDeviceNotSignedIn) + + val errorState = awaitItem() + // The state for this screen is failure + assertThat(errorState.authenticationAction.isFailure()).isTrue() + // However, the QrCodeLoginManager is reset + resetAction.assertions().isCalledOnce() + assertThat(qrCodeLoginManager.currentLoginStep.value).isEqualTo(QrCodeLoginStep.Uninitialized) + } + } + + private fun TestScope.createQrCodeScanPresenter( + qrCodeLoginDataFactory: FakeMatrixQrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory(), + coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), + qrCodeLoginManager: FakeQrCodeLoginManager = FakeQrCodeLoginManager(), + enterpriseService: EnterpriseService = FakeEnterpriseService(), + wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(), + ) = QrCodeScanPresenter( + qrCodeLoginDataFactory = qrCodeLoginDataFactory, + qrCodeLoginManager = qrCodeLoginManager, + coroutineDispatchers = coroutineDispatchers, + defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( + enterpriseService = enterpriseService, + wellknownRetriever = wellknownRetriever, + ), + ) +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanViewTest.kt new file mode 100644 index 0000000..454dc79 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanViewTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.qrcode.scan + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class QrCodeScanViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `on back pressed - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setQrCodeScanView( + state = aQrCodeScanState(), + onBackClick = callback + ) + rule.pressBackKey() + } + } + + @Test + fun `on QR code data ready - calls the expected callback`() { + val data = FakeMatrixQrCodeLoginData() + ensureCalledOnceWithParam(data) { callback -> + rule.setQrCodeScanView( + state = aQrCodeScanState(authenticationAction = AsyncAction.Success(data)), + onQrCodeDataReady = callback + ) + } + } + + private fun AndroidComposeTestRule.setQrCodeScanView( + state: QrCodeScanState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onQrCodeDataReady: (MatrixQrCodeLoginData) -> Unit = EnsureNeverCalledWithParam(), + ) { + setContent { + QrCodeScanView( + state = state, + onBackClick = onBackClick, + onQrCodeDataReady = onQrCodeDataReady + ) + } + } +} 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 0000000..87afb77 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.searchaccountprovider + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.impl.changeserver.aChangeServerState +import io.element.android.features.login.impl.resolver.HomeserverResolver +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class SearchAccountProviderPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(true) }) + val presenter = SearchAccountProviderPresenter( + homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker), + changeServerPresenter = { aChangeServerState() } + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.userInput).isEmpty() + assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) + } + } + + @Test + fun `present - error while checking login compatibility`() = runTest { + val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.failure(IllegalStateException("Oops")) }) + val presenter = SearchAccountProviderPresenter( + homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker), + changeServerPresenter = { aChangeServerState() } + ) + presenter.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(AsyncData.Uninitialized) + assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java) + assertThat(awaitItem().userInputResult).isEqualTo( + AsyncData.Success( + listOf( + aHomeserverData(homeserverUrl = "https://test.org") + ) + ) + ) + } + } + + @Test + fun `present - enter text no result`() = runTest { + val fakeWellknownRetriever = FakeHomeServerLoginCompatibilityChecker(checkResult = { Result.success(false) }) + val presenter = SearchAccountProviderPresenter( + homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeWellknownRetriever), + changeServerPresenter = { aChangeServerState() } + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test")) + val withInputState = awaitItem() + assertThat(withInputState.userInput).isEqualTo("test") + assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) + assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java) + assertThat(awaitItem().userInputResult).isEqualTo( + AsyncData.Success( + listOf( + aHomeserverData(homeserverUrl = "https://test"), + ) + ) + ) + } + } + + @Test + fun `present - enter text one result with wellknown`() = runTest { + val checkResult = lambdaRecorder> { + when (it) { + "https://test.org" -> Result.success(false) + "https://test.com" -> Result.success(false) + "https://test.io" -> Result.success(true) + "https://test" -> Result.success(false) + else -> error("should not happen") + } + } + val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult) + val presenter = SearchAccountProviderPresenter( + homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker), + changeServerPresenter = { aChangeServerState() } + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test")) + val withInputState = awaitItem() + assertThat(withInputState.userInput).isEqualTo("test") + assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) + assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java) + assertThat(awaitItem().userInputResult).isEqualTo( + AsyncData.Success( + listOf( + aHomeserverData(homeserverUrl = "https://test.io") + ) + ) + ) + checkResult.assertions().isCalledExactly(4) + .withSequence( + listOf(value("https://test.org")), + listOf(value("https://test.com")), + listOf(value("https://test.io")), + listOf(value("https://test")), + ) + } + } + + @Test + fun `present - enter text two results with wellknown`() = runTest { + val checkResult = lambdaRecorder> { + when (it) { + "https://test.org" -> Result.success(true) + "https://test.com" -> Result.success(false) + "https://test.io" -> Result.success(true) + "https://test" -> Result.success(false) + else -> error("should not happen") + } + } + val fakeLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult) + val presenter = SearchAccountProviderPresenter( + homeserverResolver = HomeserverResolver(testCoroutineDispatchers(), fakeLoginCompatibilityChecker), + changeServerPresenter = { aChangeServerState() } + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink.invoke(SearchAccountProviderEvents.UserInput("test")) + val withInputState = awaitItem() + assertThat(withInputState.userInput).isEqualTo("test") + assertThat(initialState.userInputResult).isEqualTo(AsyncData.Uninitialized) + assertThat(awaitItem().userInputResult).isInstanceOf(AsyncData.Loading::class.java) + assertThat(awaitItem().userInputResult).isEqualTo( + AsyncData.Success( + listOf( + aHomeserverData(homeserverUrl = "https://test.org"), + ) + ) + ) + assertThat(awaitItem().userInputResult).isEqualTo( + AsyncData.Success( + listOf( + aHomeserverData(homeserverUrl = "https://test.org"), + aHomeserverData(homeserverUrl = "https://test.io"), + ) + ) + ) + checkResult.assertions().isCalledExactly(4) + .withSequence( + listOf(value("https://test.org")), + listOf(value("https://test.com")), + listOf(value("https://test.io")), + listOf(value("https://test")), + ) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/web/FakeWebClientUrlForAuthenticationRetriever.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/web/FakeWebClientUrlForAuthenticationRetriever.kt new file mode 100644 index 0000000..f52af0a --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/web/FakeWebClientUrlForAuthenticationRetriever.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.web + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeWebClientUrlForAuthenticationRetriever( + private val retrieveLambda: suspend (homeServerUrl: String) -> String = { lambdaError() } +) : WebClientUrlForAuthenticationRetriever { + override suspend fun retrieve(homeServerUrl: String): String { + return retrieveLambda(homeServerUrl) + } +} diff --git a/features/login/test/build.gradle.kts b/features/login/test/build.gradle.kts new file mode 100644 index 0000000..7073648 --- /dev/null +++ b/features/login/test/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.login.test" +} + +dependencies { + implementation(projects.features.login.api) + implementation(projects.tests.testutils) +} diff --git a/features/login/test/src/main/kotlin/io/element/android/features/login/test/FakeLoginIntentResolver.kt b/features/login/test/src/main/kotlin/io/element/android/features/login/test/FakeLoginIntentResolver.kt new file mode 100644 index 0000000..7912a36 --- /dev/null +++ b/features/login/test/src/main/kotlin/io/element/android/features/login/test/FakeLoginIntentResolver.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.test + +import io.element.android.features.login.api.LoginIntentResolver +import io.element.android.features.login.api.LoginParams +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeLoginIntentResolver( + private val parseResult: (String) -> LoginParams? = { lambdaError() } +) : LoginIntentResolver { + override fun parse(uriString: String): LoginParams? { + return parseResult(uriString) + } +} diff --git a/features/logout/api/build.gradle.kts b/features/logout/api/build.gradle.kts new file mode 100644 index 0000000..4453d37 --- /dev/null +++ b/features/logout/api/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.logout.api" +} + +dependencies { + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) +} diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutEntryPoint.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutEntryPoint.kt new file mode 100644 index 0000000..df8477b --- /dev/null +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutEntryPoint.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.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 LogoutEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: Callback, + ): Node + + interface Callback : Plugin { + fun navigateToSecureBackup() + } +} diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutUseCase.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutUseCase.kt new file mode 100644 index 0000000..f3091b0 --- /dev/null +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutUseCase.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.api + +/** + * Used to trigger a log out of the current user(s) from any part of the app. + */ +interface LogoutUseCase { + /** + * Log out the current user(s) and then perform any needed cleanup tasks. + * @param ignoreSdkError if true, the SDK error will be ignored and the user will be logged out anyway. + */ + suspend fun logoutAll(ignoreSdkError: Boolean) +} diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutEvents.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutEvents.kt new file mode 100644 index 0000000..cc16dfd --- /dev/null +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.api.direct + +sealed interface DirectLogoutEvents { + data class Logout(val ignoreSdkError: Boolean) : DirectLogoutEvents + data object CloseDialogs : DirectLogoutEvents +} diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutState.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutState.kt new file mode 100644 index 0000000..31e0fdc --- /dev/null +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutState.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.api.direct + +import io.element.android.libraries.architecture.AsyncAction + +data class DirectLogoutState( + val canDoDirectSignOut: Boolean, + val logoutAction: AsyncAction, + val eventSink: (DirectLogoutEvents) -> Unit, +) diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutStateProvider.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutStateProvider.kt new file mode 100644 index 0000000..feaf21e --- /dev/null +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutStateProvider.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.api.direct + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction + +open class DirectLogoutStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aDirectLogoutState(), + aDirectLogoutState(logoutAction = AsyncAction.ConfirmingNoParams), + aDirectLogoutState(logoutAction = AsyncAction.Loading), + aDirectLogoutState(logoutAction = AsyncAction.Failure(Exception("Error"))), + aDirectLogoutState(logoutAction = AsyncAction.Success(Unit)), + ) +} + +fun aDirectLogoutState( + canDoDirectSignOut: Boolean = true, + logoutAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (DirectLogoutEvents) -> Unit = {}, +) = DirectLogoutState( + canDoDirectSignOut = canDoDirectSignOut, + logoutAction = logoutAction, + eventSink = eventSink, +) diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutView.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutView.kt new file mode 100644 index 0000000..7841b85 --- /dev/null +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutView.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.api.direct + +import androidx.compose.runtime.Composable + +fun interface DirectLogoutView { + @Composable + fun Render(state: DirectLogoutState) +} diff --git a/features/logout/impl/build.gradle.kts b/features/logout/impl/build.gradle.kts new file mode 100644 index 0000000..8de7718 --- /dev/null +++ b/features/logout/impl/build.gradle.kts @@ -0,0 +1,46 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.logout.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.featureflag.api) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.testtags) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.dateformatter.api) + implementation(projects.libraries.workmanager.api) + api(projects.features.logout.api) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.libraries.sessionStorage.test) + testImplementation(projects.libraries.workmanager.test) +} diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutEntryPoint.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutEntryPoint.kt new file mode 100644 index 0000000..d730c1d --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutEntryPoint.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.logout.api.LogoutEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultLogoutEntryPoint : LogoutEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: LogoutEntryPoint.Callback, + ): Node { + return parentNode.createNode(buildContext, listOf(callback)) + } +} diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCase.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCase.kt new file mode 100644 index 0000000..df139c1 --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCase.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.logout.api.LogoutUseCase +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.sessionstorage.api.SessionStore +import timber.log.Timber + +@ContributesBinding(AppScope::class) +class DefaultLogoutUseCase( + private val sessionStore: SessionStore, + private val matrixClientProvider: MatrixClientProvider, +) : LogoutUseCase { + override suspend fun logoutAll(ignoreSdkError: Boolean) { + sessionStore.getAllSessions() + .map { sessionData -> + SessionId(sessionData.userId) + } + .forEach { sessionId -> + Timber.d("Logging out sessionId: $sessionId") + matrixClientProvider.getOrRestore(sessionId).fold( + onSuccess = { client -> + client.logout(userInitiated = true, ignoreSdkError = ignoreSdkError) + }, + onFailure = { error -> + Timber.e(error, "Failed to get or restore MatrixClient for sessionId: $sessionId") + } + ) + } + } +} diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutEvents.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutEvents.kt new file mode 100644 index 0000000..0822444 --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +sealed interface LogoutEvents { + data class Logout(val ignoreSdkError: Boolean) : LogoutEvents + data object CloseDialogs : LogoutEvents +} diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt new file mode 100644 index 0000000..1e9b984 --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.logout.api.LogoutEntryPoint +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class LogoutNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: LogoutPresenter, +) : Node(buildContext, plugins = plugins) { + private val callback: LogoutEntryPoint.Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + LogoutView( + state = state, + onChangeRecoveryKeyClick = callback::navigateToSecureBackup, + onBackClick = ::navigateUp, + modifier = modifier, + ) + } +} diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt new file mode 100644 index 0000000..8176ed3 --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.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 dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.core.bool.orTrue +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.encryption.BackupState +import io.element.android.libraries.matrix.api.encryption.BackupUploadState +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.workmanager.api.WorkManagerScheduler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Inject +class LogoutPresenter( + private val matrixClient: MatrixClient, + private val encryptionService: EncryptionService, + private val workManagerScheduler: WorkManagerScheduler, +) : Presenter { + @Composable + override fun present(): LogoutState { + val localCoroutineScope = rememberCoroutineScope() + val logoutAction: MutableState> = remember { + mutableStateOf(AsyncAction.Uninitialized) + } + + val backupUploadState: BackupUploadState by remember { + encryptionService.waitForBackupUploadSteadyState() + } + .collectAsState(initial = BackupUploadState.Unknown) + + var waitingForALongTime by remember { mutableStateOf(false) } + LaunchedEffect(backupUploadState) { + if (backupUploadState is BackupUploadState.Waiting) { + delay(2_000) + waitingForALongTime = true + } else { + waitingForALongTime = false + } + } + + val isLastDevice by encryptionService.isLastDevice.collectAsState() + val backupState by encryptionService.backupStateStateFlow.collectAsState() + val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() + + val doesBackupExistOnServerAction: MutableState> = remember { + mutableStateOf(AsyncData.Uninitialized) + } + + LaunchedEffect(backupState) { + if (backupState == BackupState.UNKNOWN) { + getKeyBackupStatus(doesBackupExistOnServerAction) + } + } + + fun handleEvent(event: LogoutEvents) { + when (event) { + is LogoutEvents.Logout -> { + if (logoutAction.value.isConfirming() || event.ignoreSdkError) { + localCoroutineScope.logout(logoutAction, event.ignoreSdkError) + } else { + logoutAction.value = AsyncAction.ConfirmingNoParams + } + } + LogoutEvents.CloseDialogs -> { + logoutAction.value = AsyncAction.Uninitialized + } + } + } + + return LogoutState( + isLastDevice = isLastDevice, + backupState = backupState, + doesBackupExistOnServer = doesBackupExistOnServerAction.value.dataOrNull().orTrue(), + recoveryState = recoveryState, + backupUploadState = backupUploadState, + waitingForALongTime = waitingForALongTime, + logoutAction = logoutAction.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.getKeyBackupStatus(action: MutableState>) = launch { + suspend { + encryptionService.doesBackupExistOnServer().getOrThrow() + }.runCatchingUpdatingState(action) + } + + private fun CoroutineScope.logout( + logoutAction: MutableState>, + ignoreSdkError: Boolean, + ) = launch { + suspend { + // Cancel any pending work (e.g. notification sync) + workManagerScheduler.cancel(matrixClient.sessionId) + + matrixClient.logout(userInitiated = true, ignoreSdkError) + }.runCatchingUpdatingState(logoutAction) + } +} diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt new file mode 100644 index 0000000..9240edc --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.encryption.BackupState +import io.element.android.libraries.matrix.api.encryption.BackupUploadState +import io.element.android.libraries.matrix.api.encryption.RecoveryState + +data class LogoutState( + val isLastDevice: Boolean, + val backupState: BackupState, + val doesBackupExistOnServer: Boolean, + val recoveryState: RecoveryState, + val backupUploadState: BackupUploadState, + val waitingForALongTime: Boolean, + val logoutAction: AsyncAction, + val eventSink: (LogoutEvents) -> Unit, +) diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt new file mode 100644 index 0000000..a3b5986 --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.encryption.BackupState +import io.element.android.libraries.matrix.api.encryption.BackupUploadState +import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.api.encryption.SteadyStateException + +open class LogoutStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aLogoutState(), + aLogoutState(isLastDevice = true), + aLogoutState(isLastDevice = false, backupUploadState = BackupUploadState.Uploading(66, 200)), + aLogoutState(isLastDevice = true, backupUploadState = BackupUploadState.Done), + aLogoutState(logoutAction = AsyncAction.ConfirmingNoParams), + aLogoutState(logoutAction = AsyncAction.Loading), + aLogoutState(logoutAction = AsyncAction.Failure(Exception("Failed to logout"))), + aLogoutState(backupUploadState = BackupUploadState.SteadyException(SteadyStateException.Connection("No network"))), + // Last session no recovery + aLogoutState(isLastDevice = true, recoveryState = RecoveryState.DISABLED), + // Last session no backup + aLogoutState(isLastDevice = true, backupState = BackupState.UNKNOWN, doesBackupExistOnServer = false), + aLogoutState( + isLastDevice = false, + backupUploadState = BackupUploadState.Waiting, + ), + aLogoutState( + isLastDevice = false, + backupUploadState = BackupUploadState.Waiting, + waitingForALongTime = true, + ), + ) +} + +fun aLogoutState( + isLastDevice: Boolean = false, + backupState: BackupState = BackupState.ENABLED, + doesBackupExistOnServer: Boolean = true, + recoveryState: RecoveryState = RecoveryState.ENABLED, + backupUploadState: BackupUploadState = BackupUploadState.Unknown, + waitingForALongTime: Boolean = false, + logoutAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (LogoutEvents) -> Unit = {}, +) = LogoutState( + isLastDevice = isLastDevice, + backupState = backupState, + doesBackupExistOnServer = doesBackupExistOnServer, + recoveryState = recoveryState, + backupUploadState = backupUploadState, + waitingForALongTime = waitingForALongTime, + logoutAction = logoutAction, + eventSink = eventSink, +) diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt new file mode 100644 index 0000000..9700e54 --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +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.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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.logout.impl.tools.isBackingUp +import io.element.android.features.logout.impl.ui.LogoutActionDialog +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor +import io.element.android.libraries.matrix.api.encryption.BackupState +import io.element.android.libraries.matrix.api.encryption.BackupUploadState +import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.api.encryption.SteadyStateException +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun LogoutView( + state: LogoutState, + onChangeRecoveryKeyClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val eventSink = state.eventSink + + FlowStepPage( + onBackClick = onBackClick, + title = title(state), + subTitle = subtitle(state), + iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()), + modifier = modifier, + buttons = { + Buttons( + state = state, + onChangeRecoveryKeyClick = onChangeRecoveryKeyClick, + onLogoutClick = { + eventSink(LogoutEvents.Logout(ignoreSdkError = false)) + } + ) + }, + ) { + Content(state) + } + + LogoutActionDialog( + state.logoutAction, + onConfirmClick = { + eventSink(LogoutEvents.Logout(ignoreSdkError = false)) + }, + onForceLogoutClick = { + eventSink(LogoutEvents.Logout(ignoreSdkError = true)) + }, + onDismissDialog = { + eventSink(LogoutEvents.CloseDialogs) + }, + ) +} + +@Composable +private fun title(state: LogoutState): String { + return when { + state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_title) + state.isLastDevice -> { + if (state.recoveryState != RecoveryState.ENABLED) { + stringResource(id = R.string.screen_signout_recovery_disabled_title) + } else if (state.backupState == BackupState.UNKNOWN && state.doesBackupExistOnServer.not()) { + stringResource(id = R.string.screen_signout_key_backup_disabled_title) + } else { + stringResource(id = R.string.screen_signout_save_recovery_key_title) + } + } + else -> stringResource(CommonStrings.action_signout) + } +} + +@Composable +private fun subtitle(state: LogoutState): String? { + return when { + (state.backupUploadState as? BackupUploadState.SteadyException)?.exception is SteadyStateException.Connection -> + stringResource(id = R.string.screen_signout_key_backup_offline_subtitle) + state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_subtitle) + state.isLastDevice -> stringResource(id = R.string.screen_signout_key_backup_disabled_subtitle) + else -> null + } +} + +@Composable +private fun ColumnScope.Buttons( + state: LogoutState, + onLogoutClick: () -> Unit, + onChangeRecoveryKeyClick: () -> Unit, +) { + val logoutAction = state.logoutAction + if (state.isLastDevice) { + OutlinedButton( + text = stringResource(id = CommonStrings.common_settings), + modifier = Modifier.fillMaxWidth(), + onClick = onChangeRecoveryKeyClick, + ) + } + val signOutSubmitRes = when { + logoutAction is AsyncAction.Loading -> R.string.screen_signout_in_progress_dialog_content + state.backupUploadState.isBackingUp() -> CommonStrings.action_signout_anyway + else -> CommonStrings.action_signout + } + Button( + text = stringResource(id = signOutSubmitRes), + showProgress = logoutAction is AsyncAction.Loading, + destructive = true, + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.signOut), + onClick = onLogoutClick, + ) +} + +@Composable +private fun Content( + state: LogoutState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(top = 60.dp, start = 20.dp, end = 20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + when (state.backupUploadState) { + is BackupUploadState.Uploading -> { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + progress = { state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat() }, + trackColor = ElementTheme.colors.progressIndicatorTrackColor, + ) + Text( + modifier = Modifier.align(Alignment.End), + text = "${state.backupUploadState.backedUpCount} / ${state.backupUploadState.totalCount}", + style = ElementTheme.typography.fontBodySmRegular, + ) + } + BackupUploadState.Waiting -> { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + trackColor = ElementTheme.colors.progressIndicatorTrackColor, + ) + if (state.waitingForALongTime) { + Text( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = stringResource(CommonStrings.common_please_check_internet_connection), + style = ElementTheme.typography.fontBodySmRegular, + ) + } + } + else -> Unit + } + } +} + +@PreviewsDayNight +@Composable +internal fun LogoutViewPreview( + @PreviewParameter(LogoutStateProvider::class) state: LogoutState, +) = ElementPreview { + LogoutView( + state, + onChangeRecoveryKeyClick = {}, + onBackClick = {}, + ) +} diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/di/LogoutModule.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/di/LogoutModule.kt new file mode 100644 index 0000000..9b38fde --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/di/LogoutModule.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl.di + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.features.logout.impl.direct.DirectLogoutPresenter +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.SessionScope + +@ContributesTo(SessionScope::class) +@BindingContainer +interface LogoutModule { + @Binds + fun bindDirectLogoutPresenter(presenter: DirectLogoutPresenter): Presenter +} diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt new file mode 100644 index 0000000..7fb483a --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl.direct + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewParameter +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.logout.api.direct.DirectLogoutEvents +import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.features.logout.api.direct.DirectLogoutStateProvider +import io.element.android.features.logout.api.direct.DirectLogoutView +import io.element.android.features.logout.impl.ui.LogoutActionDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.di.SessionScope + +@ContributesBinding(SessionScope::class) +class DefaultDirectLogoutView : DirectLogoutView { + @Composable + override fun Render(state: DirectLogoutState) { + val eventSink = state.eventSink + LogoutActionDialog( + state.logoutAction, + onConfirmClick = { + eventSink(DirectLogoutEvents.Logout(ignoreSdkError = false)) + }, + onForceLogoutClick = { + eventSink(DirectLogoutEvents.Logout(ignoreSdkError = true)) + }, + onDismissDialog = { + eventSink(DirectLogoutEvents.CloseDialogs) + }, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun DefaultDirectLogoutViewPreview( + @PreviewParameter(DirectLogoutStateProvider::class) state: DirectLogoutState, +) = ElementPreview { + DefaultDirectLogoutView().Render(state = state) +} diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenter.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenter.kt new file mode 100644 index 0000000..8d8cc91 --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenter.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl.direct + +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 dev.zacsweers.metro.Inject +import io.element.android.features.logout.api.direct.DirectLogoutEvents +import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.features.logout.impl.tools.isBackingUp +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.encryption.BackupUploadState +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Inject +class DirectLogoutPresenter( + private val matrixClient: MatrixClient, + private val encryptionService: EncryptionService, +) : Presenter { + @Composable + override fun present(): DirectLogoutState { + val localCoroutineScope = rememberCoroutineScope() + + val logoutAction: MutableState> = remember { + mutableStateOf(AsyncAction.Uninitialized) + } + + val backupUploadState: BackupUploadState by remember { + encryptionService.waitForBackupUploadSteadyState() + } + .collectAsState(initial = BackupUploadState.Unknown) + + val isLastDevice by encryptionService.isLastDevice.collectAsState() + + fun handleEvent(event: DirectLogoutEvents) { + when (event) { + is DirectLogoutEvents.Logout -> { + if (logoutAction.value.isConfirming() || event.ignoreSdkError) { + localCoroutineScope.logout(logoutAction, event.ignoreSdkError) + } else { + logoutAction.value = AsyncAction.ConfirmingNoParams + } + } + DirectLogoutEvents.CloseDialogs -> { + logoutAction.value = AsyncAction.Uninitialized + } + } + } + + return DirectLogoutState( + canDoDirectSignOut = !isLastDevice && + !backupUploadState.isBackingUp(), + logoutAction = logoutAction.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.logout( + logoutAction: MutableState>, + ignoreSdkError: Boolean, + ) = launch { + suspend { + matrixClient.logout(userInitiated = true, ignoreSdkError) + }.runCatchingUpdatingState(logoutAction) + } +} diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/tools/Extensions.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/tools/Extensions.kt new file mode 100644 index 0000000..3721417 --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/tools/Extensions.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl.tools + +import io.element.android.libraries.matrix.api.encryption.BackupUploadState +import io.element.android.libraries.matrix.api.encryption.SteadyStateException + +internal fun BackupUploadState.isBackingUp(): Boolean { + return when (this) { + BackupUploadState.Waiting, + is BackupUploadState.Uploading -> true + // The backup is in progress, but there have been a network issue, so we have to warn the user. + is BackupUploadState.SteadyException -> exception is SteadyStateException.Connection + BackupUploadState.Unknown, + BackupUploadState.Done, + BackupUploadState.Error -> false + } +} diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt new file mode 100644 index 0000000..0222a99 --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.features.logout.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.dialogs.RetryDialog +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun LogoutActionDialog( + state: AsyncAction, + onConfirmClick: () -> Unit, + onForceLogoutClick: () -> Unit, + onDismissDialog: () -> Unit, +) { + when (state) { + AsyncAction.Uninitialized -> + Unit + is AsyncAction.Confirming -> + LogoutConfirmationDialog( + onSubmitClick = onConfirmClick, + onDismiss = onDismissDialog + ) + is AsyncAction.Loading -> + ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content)) + is AsyncAction.Failure -> + RetryDialog( + title = stringResource(id = CommonStrings.dialog_title_error), + content = stringResource(id = CommonStrings.error_unknown), + retryText = stringResource(id = CommonStrings.action_signout_anyway), + onRetry = onForceLogoutClick, + onDismiss = onDismissDialog, + ) + is AsyncAction.Success -> Unit + } +} diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutConfirmationDialog.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutConfirmationDialog.kt new file mode 100644 index 0000000..d19a287 --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutConfirmationDialog.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.features.logout.impl.R +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun LogoutConfirmationDialog( + onSubmitClick: () -> Unit, + onDismiss: () -> Unit, +) { + ConfirmationDialog( + title = stringResource(id = CommonStrings.action_signout), + content = stringResource(id = R.string.screen_signout_confirmation_dialog_content), + submitText = stringResource(id = CommonStrings.action_signout), + onSubmitClick = onSubmitClick, + onDismiss = onDismiss, + ) +} diff --git a/features/logout/impl/src/main/res/values-be/translations.xml b/features/logout/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..bc005e2 --- /dev/null +++ b/features/logout/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,18 @@ + + + "Вы ўпэўнены, што хочаце выйсці?" + "Выйсці" + "Выйсці" + "Выхад…" + "Вы збіраецеся выйсці з апошняга сеанса. Калі вы выйдзеце з сістэмы зараз, вы страціце доступ да зашыфраваных паведамленняў." + "Вы адключылі рэзервовае капіраванне" + "Вашы ключы ўсё яшчэ захоўваліся, калі вы выйшлі з сеткі. Паўторна падключыцеся, каб можна было стварыць рэзервовую копію вашых ключоў перад выхадам." + "Вашы ключы ўсё яшчэ ствараюцца" + "Калі ласка, дачакайцеся завяршэння працэсу, перш чым выходзіць з сістэмы." + "Вашы ключы ўсё яшчэ ствараюцца" + "Выйсці" + "Вы збіраецеся выйсці з апошняга сеанса. Калі вы выйдзеце з сістэмы зараз, вы страціце доступ да зашыфраваных паведамленняў." + "Аднаўленне не наладжана" + "Вы збіраецеся выйсці з апошняга сеанса. Калі вы выйдзеце з сістэмы зараз, вы страціце доступ да зашыфраваных паведамленняў." + "Вы захавалі свой ключ аднаўлення?" + diff --git a/features/logout/impl/src/main/res/values-bg/translations.xml b/features/logout/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..422cabb --- /dev/null +++ b/features/logout/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,8 @@ + + + "Сигурни ли сте, че искате да излезете?" + "Изход" + "Изход" + "Излизане…" + "Изход" + diff --git a/features/logout/impl/src/main/res/values-cs/translations.xml b/features/logout/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..e2c2a68 --- /dev/null +++ b/features/logout/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,18 @@ + + + "Opravdu se chcete odhlásit?" + "Odhlásit se" + "Odhlásit se" + "Odhlašování…" + "Chystáte se odhlásit z poslední relace. Pokud se nyní odhlásíte, ztratíte přístup ke svým šifrovaným zprávám." + "Vypnuli jste zálohování" + "Když jste přešli do režimu offline, vaše klíče se ještě stále zálohovaly. Znovu se připojte, aby bylo možné před odhlášením zálohovat vaše klíče." + "Vaše klíče jsou stále zálohovány" + "Před odhlášením prosím počkejte na dokončení." + "Vaše klíče jsou stále zálohovány" + "Odhlásit se" + "Chystáte se odhlásit z poslední relace. Pokud se nyní odhlásíte, ztratíte přístup ke svým šifrovaným zprávám." + "Obnovení není nastaveno" + "Chystáte se odhlásit z poslední relace. Pokud se nyní odhlásíte, můžete ztratit přístup k šifrovaným zprávám." + "Uložili jste si klíč pro obnovení?" + diff --git a/features/logout/impl/src/main/res/values-cy/translations.xml b/features/logout/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..d476bb6 --- /dev/null +++ b/features/logout/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,18 @@ + + + "Ydych chi\'n siŵr eich bod am allgofnodi?" + "Allgofnodi" + "Allgofnodi" + "Yn allgofnodi…" + "Rydych chi ar fin allgofnodi o\'ch sesiwn ddiwethaf. Os byddwch yn allgofnodi nawr, byddwch yn colli mynediad i\'ch negeseuon wedi\'u hamgryptio." + "Rydych chi wedi diffodd copïo wrth gefn" + "Roedd eich allweddi yn dal i gael eu copïo wrth gefn pan aethoch all-lein. Ailgysylltwch fel bod modd gwneud copi wrth gefn o\'ch allweddi cyn allgofnodi." + "Mae eich allweddi yn dal i gael eu copïo wrth gefn" + "Arhoswch iddo gael ei gwblhau cyn allgofnodi." + "Mae eich allweddi yn dal i gael eu copïo wrth gefn" + "Allgofnodi" + "Rydych chi ar fin allgofnodi o\'ch sesiwn ddiwethaf. Os byddwch yn allgofnodi nawr, byddwch yn colli mynediad i\'ch negeseuon wedi\'u hamgryptio." + "Adfer heb ei osod" + "Rydych chi ar fin allgofnodi o\'ch sesiwn ddiwethaf. Os ydych chi\'n allgofnodi nawr, efallai y byddwch chi\'n colli mynediad i\'ch negeseuon wedi\'u hamgryptio." + "Ydych chi wedi cadw\'ch allwedd adfer?" + diff --git a/features/logout/impl/src/main/res/values-da/translations.xml b/features/logout/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..bbf6a49 --- /dev/null +++ b/features/logout/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,18 @@ + + + "Er du sikker på, at du vil logge ud?" + "Log ud" + "Log ud" + "Logger ud…" + "Du er ved at logge ud af din sidste session. Hvis du logger ud nu, mister du adgangen til dine krypterede meddelelser." + "Du har slået sikkerhedskopiering fra" + "Dine nøgler blev stadig sikkerhedskopieret, da du gik offline. Opret forbindelse igen, så dine nøgler kan sikkerhedskopieres, før du logger ud." + "Dine nøgler bliver stadig sikkerhedskopieret" + "Vent på, at dette er fuldført, før du logger ud." + "Dine nøgler bliver stadig sikkerhedskopieret" + "Log ud" + "Du er ved at logge ud af din sidste session. Hvis du logger af nu, mister du adgangen til dine krypterede meddelelser." + "Gendannelse er ikke konfigureret" + "Du er ved at logge ud af din sidste session. Hvis du logger af nu, kan du miste adgangen til dine krypterede meddelelser." + "Har du gemt din gendannelsesnøgle?" + diff --git a/features/logout/impl/src/main/res/values-de/translations.xml b/features/logout/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..8ebea45 --- /dev/null +++ b/features/logout/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,18 @@ + + + "Möchtest du dich wirklich abmelden?" + "Abmelden" + "Abmelden" + "Abmelden…" + "Du bist dabei, dich von deiner letzten Sitzung abzumelden. Wenn dich jetzt abmeldest, verlierst du den Zugriff auf deine verschlüsselten Nachrichten." + "Du hast das Backup deaktiviert" + "Das Backup deiner Schlüssel lief noch, als du offline gegangen bist. Verbinde dich erneut, damit deine Schlüssel vor dem Abmelden gesichert werden können." + "Deine Schlüssel werden noch gesichert" + "Bitte warte, bis dieser Vorgang abgeschlossen ist, bevor du dich abmeldest." + "Deine Schlüssel werden noch gesichert" + "Abmelden" + "Du bist dabei, dich von deiner letzten Sitzung abzumelden. Wenn du dich jetzt abmeldest, verlierst du den Zugriff auf deine verschlüsselten Nachrichten." + "Wiederherstellung nicht eingerichtet" + "Du bist dabei, dich von deiner letzten Sitzung abzumelden. Wenn du dich jetzt abmeldest, verlierst du möglicherweise den Zugriff auf deine verschlüsselten Nachrichten." + "Hast du deinen Wiederherstellungsschlüssel gespeichert?" + diff --git a/features/logout/impl/src/main/res/values-el/translations.xml b/features/logout/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..21cbd34 --- /dev/null +++ b/features/logout/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,18 @@ + + + "Σίγουρα θες να αποσυνδεθείς;" + "Αποσύνδεση" + "Αποσύνδεση" + "Αποσύνδεση…" + "Πρόκειται να αποσυνδεθείς από την τελευταία σου συνεδρία. Εάν αποσυνδεθείς τώρα, θα χάσεις την πρόσβαση στα κρυπτογραφημένα μηνύματά σου." + "Έχεις απενεργοποιήσει τη δημιουργία αντιγράφων ασφαλείας" + "Εξακολουθούσε να δημιουργείται αντίγραφο ασφαλείας των κλειδιών σου όταν βρέθηκες εκτός σύνδεσης. Επανασυνδέσου, ώστε να είναι δυνατή η δημιουργία αντιγράφων ασφαλείας των κλειδιών σου πριν αποσυνδεθείς." + "Εξακολουθούν να δημιουργούνται αντίγραφα ασφαλείας των κλειδιών σου" + "Περίμενε να ολοκληρωθεί πριν αποσυνδεθείς." + "Εξακολουθούν να δημιουργούνται αντίγραφα ασφαλείας των κλειδιών σου" + "Αποσύνδεση" + "Πρόκειται να αποσυνδεθείς από την τελευταία σου συνεδρία. Εάν αποσυνδεθείς τώρα, θα χάσεις την πρόσβαση στα κρυπτογραφημένα μηνύματά σου." + "Η ανάκτηση δεν έχει ρυθμιστεί" + "Πρόκειται να αποσυνδεθείς από την τελευταία σας συνεδρία. Εάν αποσυνδεθείς τώρα, ενδέχεται να χάσεις την πρόσβαση στα κρυπτογραφημένα μηνύματά σου." + "Έχεις αποθηκεύσει το κλειδί ανάκτησης;" + diff --git a/features/logout/impl/src/main/res/values-es/translations.xml b/features/logout/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..94d0ec5 --- /dev/null +++ b/features/logout/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,18 @@ + + + "¿Estás seguro de que quieres cerrar sesión?" + "Cerrar sesión" + "Cerrar sesión" + "Cerrando sesión…" + "Estás a punto de cerrar tu última sesión. Si cierras sesión ahora, perderás el acceso a tus mensajes cifrados." + "Has desactivado la copia de seguridad" + "Se estaba haciendo una copia de seguridad de tus claves cuando te desconectaste. Vuelve a conectarte para que se haga una copia de seguridad de tus claves antes de desconectarte." + "Se sigue guardando una copia de seguridad de tus claves" + "Espera a que se complete antes de cerrar sesión." + "Se sigue guardando una copia de seguridad de tus claves" + "Cerrar sesión" + "Estás a punto de cerrar tu última sesión. Si cierras sesión ahora, perderás el acceso a tus mensajes cifrados." + "La recuperación no está configurada" + "Estás a punto de cerrar tu última sesión. Si cierras la sesión ahora, podrías perder el acceso a tus mensajes cifrados." + "¿Has guardado tu clave de recuperación?" + diff --git a/features/logout/impl/src/main/res/values-et/translations.xml b/features/logout/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..5cfe6da --- /dev/null +++ b/features/logout/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,18 @@ + + + "Kas sa oled kindel, et soovid välja logida?" + "Logi välja" + "Logi välja" + "Logime välja…" + "Oled oma viimasest seansist välja logimas. Kui logid nüüd välja, kaotad ligipääsu oma krüptitud sõnumitele." + "Sa oled varukoopiate tegemise välja lülitanud" + "Kui su võrguühendus katkes, siis sinu krüptovõtmed oli parasjagu varundamisel. Loo võrguühendus uuesti, oota kuni krüptovõtmete varundamine lõppeb ja alles siis logi rakendusest välja." + "Sinu krüptovõtmed on veel varundamisel" + "Enne väljalogimist palun oota, et pooleliolev toiming lõppeb." + "Sinu krüptovõtmed on veel varundamisel" + "Logi välja" + "Sa oled logimas välja oma viimasest sessioonist. Kui teed seda nüüd, siis kaotad ligipääsu oma krüptitud sõnumitele." + "Andmete taastamine on seadistamata" + "Sa oled logimas välja oma viimasest sessioonist. Kui teed seda nüüd, siis ilmselt kaotad ligipääsu oma krüptitud sõnumitele." + "Kas sa oled oma taastevõtme talletanud?" + diff --git a/features/logout/impl/src/main/res/values-eu/translations.xml b/features/logout/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..9d25ef2 --- /dev/null +++ b/features/logout/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,12 @@ + + + "Ziur saioa amaitu nahi duzula?" + "Amaitu saioa" + "Amaitu saioa" + "Saioa amaitzen…" + "Babeskopia desaktibatu duzu" + "Itxaron eragiketa amaitu arte saioa amaitu baino lehen." + "Amaitu saioa" + "Berreskuratzea ez da konfiguratu" + "Gorde al duzu berreskuratze-gakoa?" + diff --git a/features/logout/impl/src/main/res/values-fa/translations.xml b/features/logout/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..8dfaad8 --- /dev/null +++ b/features/logout/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,18 @@ + + + "مطمئنید که می‌خواهید از حسابتان خارج شوید؟" + "خروج" + "خروج" + "خارج شدن…" + "دارید از واپسین نشستتان خارج می‌شوید. اگر اکنون خارج شوید پیام‌های رمزنگاشته‌تان را از دست خواهید داد." + "پشتیبان را خاموش کرده‌اید" + "در هنگامی که آفلاین شدید، کلیدهای شما هنوز در حال پشتیبان‌گیری بودند. دوباره متصل شوید ، تا قبل از خروج از کلیدهایتان نسخه پشتیبان‌ گرفته شود." + "کلیدهایتان هنوز در حال پشتیبان گیریند" + "لطفاً پیش از خروج منتظر پایانش شوید." + "کلیدهایتان هنوز در حال پشتیبان گیریند" + "خروج" + "شما در آستانه خروج از آخرین جلسه خود هستید. اگر اکنون از سیستم خارج شوید، دسترسی به پیام های رمزگذاری شده تان را از دست خواهید داد." + "بازگردانی برپا نشده" + "دارید از واپسین نشستتان خارج می‌شوید. اگر اکنون خارج شوید ممکن است پیام‌های رمزنگاشته‌تان را از دست بدهید." + "کلید بازیابیتان را ذخیره کرده‌اید؟" + diff --git a/features/logout/impl/src/main/res/values-fi/translations.xml b/features/logout/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..05ec46d --- /dev/null +++ b/features/logout/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,18 @@ + + + "Haluatko varmasti kirjautua ulos?" + "Kirjaudu ulos" + "Kirjaudu ulos" + "Kirjaudutaan ulos…" + "Olet kirjautumassa ulos viimeisestä istunnostasi. Jos kirjaudut ulos nyt, menetät pääsyn salattuihin viesteihisi." + "Olet poistanut varmuuskopioinnin käytöstä" + "Avaimiasi varmuuskopioitiin vielä, kun menit offline-tilaan. Muodosta yhteys uudelleen, jotta avaimesi voidaan varmuuskopioida ennen uloskirjautumista." + "Avaimiasi varmuuskopioidaan vielä" + "Odota, että tämä on valmis ennen uloskirjautumista." + "Avaimiasi varmuuskopioidaan vielä" + "Kirjaudu ulos" + "Olet kirjautumassa ulos viimeisestä istunnostasi. Jos kirjaudut ulos nyt, menetät pääsyn salattuihin viesteihisi." + "Palautus ei ole käytössä" + "Olet kirjautumassa ulos viimeisestä istunnostasi. Jos kirjaudut ulos nyt, saatat menettää pääsyn salattuihin viesteihisi." + "Oletko tallentanut palautusavaimesi?" + diff --git a/features/logout/impl/src/main/res/values-fr/translations.xml b/features/logout/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..5e8f9d4 --- /dev/null +++ b/features/logout/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,18 @@ + + + "Êtes-vous sûr de vouloir vous déconnecter ?" + "Se déconnecter" + "Se déconnecter" + "Déconnexion…" + "Vous êtes en train de vous déconnecter de votre dernière session. Si vous vous déconnectez maintenant, vous perdrez l’accès à l’historique de vos discussions chiffrées." + "Vous avez désactivé la sauvegarde" + "Vos clés étaient en cours de sauvegarde lorsque vous avez perdu la connexion au réseau. Il faudrait rétablir cette connexion afin de pouvoir terminer la sauvegarde avant de vous déconnecter." + "Vos clés sont en cours de sauvegarde" + "Veuillez attendre que cela se termine avant de vous déconnecter." + "Vos clés sont en cours de sauvegarde" + "Se déconnecter" + "Vous êtes sur le point de vous déconnecter de votre dernier appareil. Si vous le faites maintenant, vous perdrez l’accès à l’historique de vos messages." + "La récupération n’est pas configurée." + "Vous êtes sur le point de vous déconnecter de votre dernière session. Si vous le faites maintenant, vous perdrez l’accès à l’historique de vos discussions chiffrées." + "Avez-vous sauvegardé votre clé de récupération ?" + diff --git a/features/logout/impl/src/main/res/values-hu/translations.xml b/features/logout/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..2cf2b89 --- /dev/null +++ b/features/logout/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,18 @@ + + + "Biztos, hogy kijelentkezik?" + "Kijelentkezés" + "Kijelentkezés" + "Kijelentkezés…" + "Arra készül, hogy kijelentkezzen az utolsó munkamenetéből is. Ha most kijelentkezik, akkor elveszti a hozzáférését a titkosított üzeneteihez." + "Kikapcsolta a biztonsági mentést" + "A kulcsai mentése során bontotta a kapcsolatot. Kapcsolódjon újra, hogy a kulcsai továbbra is mentésre kerüljenek mielőtt kijelentkezik." + "A kulcsai mentése még folyamatban van" + "Kijelentkezés előtt várja meg a befejezését." + "A kulcsai mentése még folyamatban van" + "Kijelentkezés" + "Arra készül, hogy kijelentkezzen az utolsó munkamenetéből is. Ha most kijelentkezik, akkor elveszti a hozzáférését a titkosított üzeneteihez." + "A helyreállítás nincs beállítva" + "Arra készül, hogy kijelentkezzen az utolsó munkamenetéből is. Ha most kijelentkezik, akkor elveszítheti a hozzáférését a titkosított üzeneteihez." + "Mentette a helyreállítási kulcsát?" + diff --git a/features/logout/impl/src/main/res/values-in/translations.xml b/features/logout/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..dabf835 --- /dev/null +++ b/features/logout/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,18 @@ + + + "Apakah Anda yakin ingin keluar dari akun?" + "Keluar dari akun" + "Keluar dari akun" + "Mengeluarkan dari akun…" + "Anda akan keluar dari sesi terakhir Anda. Jika Anda keluar sekarang, Anda akan kehilangan akses ke pesan terenkripsi Anda." + "Anda telah menonaktifkan pencadangan" + "Kunci Anda masih dicadangkan saat Anda luring. Sambungkan kembali sehingga kunci Anda dapat dicadangkan sebelum keluar." + "Kunci Anda masih dicadangkan" + "Mohon tunggu hingga proses ini selesai sebelum keluar." + "Kunci Anda masih dicadangkan" + "Keluar dari akun" + "Anda akan keluar dari sesi Anda yang terakhir. Jika Anda keluar sekarang, Anda akan kehilangan akses ke pesan terenkripsi Anda." + "Pemulihan belum disiapkan" + "Anda akan keluar dari sesi terakhir Anda. Jika Anda keluar sekarang, Anda mungkin kehilangan akses ke pesan terenkripsi Anda." + "Apakah Anda sudah menyimpan kunci pemulihan Anda?" + diff --git a/features/logout/impl/src/main/res/values-it/translations.xml b/features/logout/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..47d6bcf --- /dev/null +++ b/features/logout/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,18 @@ + + + "Sei sicuro di voler uscire?" + "Disconnetti" + "Disconnetti" + "Disconnessione in corso…" + "Stai per disconnettere la tua ultima sessione. Se esci ora, perderai l\'accesso ai tuoi messaggi cifrati." + "Hai disattivato il backup" + "Il backup delle chiavi era ancora in corso quando sei andato offline. Riconnettiti per eseguire il backup delle chiavi prima di uscire." + "Il backup delle chiavi è ancora in corso" + "Attendi il completamento dell\'operazione prima di uscire." + "Il backup delle chiavi è ancora in corso" + "Disconnetti" + "Stai per disconnettere la tua ultima sessione. Se esci ora, perderai l\'accesso ai tuoi messaggi cifrati." + "Recupero non impostato" + "Stai per disconnettere la tua ultima sessione. Se esci ora, potresti perdere l\'accesso ai tuoi messaggi cifrati." + "Hai salvato la chiave di recupero?" + diff --git a/features/logout/impl/src/main/res/values-ka/translations.xml b/features/logout/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..adf15b8 --- /dev/null +++ b/features/logout/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,18 @@ + + + "დარწმუნებული ხართ, რომ გსურთ გამოსვლა?" + "გამოსვლა" + "გამოსვლა" + "გასვლა…" + "თქვენ აპირებთ გასვლას თქვენი ბოლო სესიიდან. თუ ახლა გამოხვალთ, დაკარგავთ წვდომას თქვენს დაშიფრულ შეტყობინებებზე." + "თქვენ გამორთეთ სარეზერვო ასლი" + "თქვენი გასაღებების სარეზერვო ასლის შექმნა მიმდინარეობდა იმ დროს, როდესაც გამოხვედით. დაკავშირდით ისევ ისე, რომ სარეზერვო ასლი შეიქმნას ანგარიშიდან გამოსვლის გარეშე." + "თქვენი გასაღებების სარეზერვო ასლი ჯერ კიდევ შექმნის პროცესშია" + "გთხოვთ დაელოდეთ ამის დასრულებას სისტემიდან გამოსვლამდე." + "თქვენი გასაღებების სარეზერვო ასლი ჯერ კიდევ შექმნის პროცესშია" + "გამოსვლა" + "თქვენ აპირებთ გასვლას თქვენი ბოლო სესიიდან. თუ ახლა გამოხვალთ, დაკარგავთ წვდომას თქვენს დაშიფრულ შეტყობინებებზე." + "აღდგენა არ არის დაყენებული" + "თქვენ აპირებთ გასვლას თქვენი ბოლო სესიიდან. თუ ახლა გამოხვალთ, შესაძლოა დაკარგოთ წვდომა თქვენს დაშიფრულ შეტყობინებებზე." + "შეინახეთ თქვენი აღდგენის გასაღები?" + diff --git a/features/logout/impl/src/main/res/values-ko/translations.xml b/features/logout/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..7f6a2f0 --- /dev/null +++ b/features/logout/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,18 @@ + + + "정말 로그아웃하시겠습니까?" + "로그아웃" + "로그아웃" + "로그아웃 중…" + "마지막 세션에서 로그아웃하려고 합니다. 지금 로그아웃하면 암호화된 메시지에 액세스할 수 없게 됩니다." + "백업이 꺼져 있습니다." + "오프라인으로 전환했을 때 키가 아직 백업 중이었습니다. 로그아웃하기 전에 키를 백업할 수 있도록 다시 연결하세요." + "귀하의 키는 아직 백업 중입니다." + "로그아웃하기 전에 이 과정이 완료될 때까지 기다려 주시기 바랍니다." + "귀하의 키는 아직 백업 중입니다." + "로그아웃" + "마지막 세션에서 로그아웃할 것입니다. 지금 로그아웃하면 암호화된 메시지에 액세스할 수 없게 됩니다." + "복구가 설정되지 않았습니다" + "마지막 세션에서 로그아웃하려고 합니다. 지금 로그아웃하면 암호화된 메시지에 액세스할 수 없게 될 수 있습니다." + "복구 키를 저장하셨습니까?" + diff --git a/features/logout/impl/src/main/res/values-lt/translations.xml b/features/logout/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..3faee6b --- /dev/null +++ b/features/logout/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,8 @@ + + + "Ar tikrai norite atsijungti?" + "Atsijungti" + "Atsijungti" + "Atsijungiama…" + "Atsijungti" + diff --git a/features/logout/impl/src/main/res/values-nb/translations.xml b/features/logout/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..0e4a735 --- /dev/null +++ b/features/logout/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,18 @@ + + + "Er du sikker på at du vil logge ut?" + "Logg ut" + "Logg ut" + "Logger ut…" + "Du er i ferd med å logge av din siste sesjon. Hvis du logger av nå, mister du tilgangen til de krypterte meldingene dine." + "Du har slått av sikkerhetskopiering" + "Nøklene dine ble fortsatt sikkerhetskopiert da du koblet fra. Koble til igjen, slik at nøklene dine kan sikkerhetskopieres før du logger av." + "Nøklene dine blir fortsatt sikkerhetskopiert" + "Vent til dette er fullført før du logger av." + "Nøklene dine blir fortsatt sikkerhetskopiert" + "Logg ut" + "Du er i ferd med å logge ut av din siste sesjon. Hvis du logger ut nå, mister du tilgangen til de krypterte meldingene dine." + "Gjenoppretting ikke konfigurert" + "Du er i ferd med å logge av din siste sesjon. Hvis du logger av nå, kan du miste tilgangen til de krypterte meldingene dine." + "Har du lagret gjenopprettingsnøkkelen din?" + diff --git a/features/logout/impl/src/main/res/values-nl/translations.xml b/features/logout/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..174102a --- /dev/null +++ b/features/logout/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,18 @@ + + + "Weet je zeker dat je je wilt uitloggen?" + "Uitloggen" + "Uitloggen" + "Uitloggen…" + "Je staat op het punt uit te loggen bij je laatste sessie. Als je je nu uitlogt, verlies je de toegang tot je versleutelde berichten." + "Je hebt de back-up uitgeschakeld" + "De backup van je sleutels was nog bezig toen je offline ging. Maak opnieuw verbinding zodat er een back-up van je sleutels kan worden gemaakt voordat je uitlogt." + "De backup van je sleutels is nog bezig" + "Wacht tot dit voltooid is voordat je uitlogt." + "De backup van je sleutels is nog bezig" + "Uitloggen" + "Je staat op het punt uit te loggen bij je laatste sessie. Als je je nu uitlogt, verlies je de toegang tot je versleutelde berichten." + "Herstelmogelijkheid niet ingesteld" + "Je staat op het punt uit te loggen bij je laatste sessie. Als je je nu uitlogt, kan het dat je de toegang tot je versleutelde berichten verliest." + "Heb je je herstelsleutel opgeslagen?" + diff --git a/features/logout/impl/src/main/res/values-pl/translations.xml b/features/logout/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..46a5c2d --- /dev/null +++ b/features/logout/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,18 @@ + + + "Czy na pewno chcesz się wylogować?" + "Wyloguj" + "Wyloguj" + "Wylogowywanie…" + "Zamierzasz wylogować się ze swojej ostatniej sesji. Jeśli wylogujesz się teraz, stracisz dostęp do swoich wiadomości szyfrowanych." + "Wyłączyłeś backup" + "Twoje klucze były nadal archiwizowane po przejściu w tryb offline. Połącz się ponownie, aby zapisać w chmurze przed wylogowaniem." + "Twoje klucze są nadal archiwizowane" + "Zanim się wylogujesz, poczekaj na zakończenie operacji." + "Twoje klucze są nadal archiwizowane" + "Wyloguj" + "Zamierzasz wylogować się ze swojej ostatniej sesji. Jeśli wylogujesz się teraz, stracisz dostęp do swoich wiadomości szyfrowanych." + "Nie ustawiono przywracania" + "Zamierzasz wylogować się ze swojej ostatniej sesji. Jeśli wylogujesz się teraz, stracisz dostęp do swoich wiadomości szyfrowanych." + "Czy zapisałeś swój klucz przywracania?" + diff --git a/features/logout/impl/src/main/res/values-pt-rBR/translations.xml b/features/logout/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..b12a2aa --- /dev/null +++ b/features/logout/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,18 @@ + + + "Você tem certeza que deseja sair?" + "Sair" + "Sair" + "Saindo…" + "Você está prestes a sair da sua última sessão. Se você sair agora, perderá o acesso às suas mensagens criptografadas." + "Você desativou o backup" + "O backup das suas chaves ainda estava sendo feito quando você ficou off-line. Reconecte-se para que você possa fazer o backup de suas chaves antes de sair." + "O backup das suas chaves ainda está em andamento" + "Aguarde até que isso seja concluído antes de sair." + "O backup das suas chaves ainda está em andamento" + "Sair" + "Você está prestes a sair da sua última sessão. Se você sair agora, perderá o acesso às suas mensagens criptografadas." + "A recuperação não está configurada" + "Você está prestes a sair da sua última sessão. Se você sair agora, poderá perder o acesso às suas mensagens criptografadas." + "Você salvou sua chave de recuperação?" + diff --git a/features/logout/impl/src/main/res/values-pt/translations.xml b/features/logout/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..b8a7161 --- /dev/null +++ b/features/logout/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,18 @@ + + + "Tens a certeza que queres terminar a sessão?" + "Terminar sessão" + "Terminar sessão" + "A terminar sessão…" + "Estás prestes a terminar a tua última sessão. Se continuares, perderás o acesso às tuas mensagens cifradas." + "Desativaste a cópia de segurança" + "As tuas chaves ainda estavam a ser guardadas quando ficaste desligado. Volta a ligar-te para que as tuas chaves possam ser guardadas antes de encerrares a sessão." + "As tuas chaves ainda estão a ser guardadas" + "Por favor, aguarda a conclusão desta operação antes de terminares a sessão." + "As tuas chaves ainda estão a ser guardadas" + "Terminar sessão" + "Estás prestes a terminar a tua última sessão. Se continuares, perderás o acesso às tuas mensagens cifradas." + "Recuperação não configurada" + "Estás prestes a terminar a tua última sessão. Se continuares, poderás perder o acesso às tuas mensagens cifradas." + "Guardaste a tua chave de recuperação?" + diff --git a/features/logout/impl/src/main/res/values-ro/translations.xml b/features/logout/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..7124188 --- /dev/null +++ b/features/logout/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,18 @@ + + + "Sunteți sigur că vreți să vă deconectați?" + "Deconectați-vă" + "Deconectați-vă" + "Deconectare în curs…" + "Sunteți pe cale să vă deconectați de la ultima sesiune. Dacă vă deconectați acum, veți pierde accesul la mesajele criptate." + "Ați dezactivat backup-ul" + "Cheile dumneavoastră erau încă în curs de backup atunci când ați fost deconectat. Reconectați-vă pentru ca cheile dumneavoastră să poată fi salvate înainte de a vă deconecta." + "Cheile dumneavoastră sunt încă în curs de backup" + "Vă rugăm să așteptați până la finalizarea acestui proces înainte de a vă deconecta." + "Cheile dumneavoastră sunt încă în curs de backup" + "Deconectați-vă" + "Sunteți pe cale să vă deconectați de la ultima sesiune. Dacă vă deconectați acum, veți pierde accesul la mesajele criptate." + "Recuperarea nu este configurată" + "Sunteți pe cale să vă deconectați de la ultima sesiune. Dacă vă deconectați acum, este posibil să pierdeți accesul la mesajele criptate." + "Ați salvat cheia de recuperare?" + diff --git a/features/logout/impl/src/main/res/values-ru/translations.xml b/features/logout/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..9cd07e2 --- /dev/null +++ b/features/logout/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,18 @@ + + + "Вы уверены, что вы хотите выйти?" + "Выйти" + "Выйти" + "Выполняется выход…" + "Вы собираетесь выйти из последнего сеанса. Если вы выйдете из системы сейчас, вы потеряете доступ к зашифрованным сообщениям." + "Вы отключили резервное копирование" + "Когда вы перешли в автономный режим, резервное копирование ваших ключей продолжалось. Повторно подключитесь, чтобы перед выходом из системы можно было создать резервную копию ключей." + "Резервное копирование ключей все еще продолжается" + "Пожалуйста, дождитесь завершения процесса, прежде чем выходить из системы." + "Резервное копирование ключей все еще продолжается" + "Выйти" + "Вы собираетесь выйти из последнего сеанса. Если вы выйдете из системы сейчас, вы потеряете доступ к зашифрованным сообщениям." + "Восстановление не настроено" + "Вы собираетесь выйти из последнего сеанса. Если вы выйдете из системы сейчас, вы можете потерять доступ к зашифрованным сообщениям." + "Вы сохранили ключ восстановления?" + diff --git a/features/logout/impl/src/main/res/values-sk/translations.xml b/features/logout/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..69ca196 --- /dev/null +++ b/features/logout/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,18 @@ + + + "Ste si istí, že sa chcete odhlásiť?" + "Odhlásiť sa" + "Odhlásiť sa" + "Prebieha odhlasovanie…" + "Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, stratíte prístup k svojim šifrovaným správam." + "Vypli ste zálohovanie" + "Keď ste sa odpojili od internetu, vaše kľúče sa ešte stále zálohovali. Pripojte sa znova k internetu, aby sa vaše kľúče mohli zálohovať pred odhlásením." + "Vaše kľúče sa ešte stále zálohujú" + "Pred odhlásením počkajte, kým sa to dokončí." + "Vaše kľúče sa ešte stále zálohujú" + "Odhlásiť sa" + "Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, stratíte prístup k svojim šifrovaným správam." + "Obnovenie nie je nastavené" + "Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, môžete stratiť prístup k svojim šifrovaným správam." + "Uložili ste kľúč na obnovenie?" + diff --git a/features/logout/impl/src/main/res/values-sv/translations.xml b/features/logout/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..fdf0e51 --- /dev/null +++ b/features/logout/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,18 @@ + + + "Är du säker på att du vill logga ut?" + "Logga ut" + "Logga ut" + "Loggar ut …" + "Du är på väg att logga ut ur din senaste session. Om du loggar ut nu kommer du att förlora åtkomsten till dina krypterade meddelanden." + "Du har stängt av säkerhetskopiering" + "Dina nycklar säkerhetskopierades fortfarande när du gick offline. Anslut igen så att dina nycklar kan säkerhetskopieras innan du loggar ut." + "Dina nycklar säkerhetskopieras fortfarande" + "Vänta tills detta är klart innan du loggar ut." + "Dina nycklar säkerhetskopieras fortfarande" + "Logga ut" + "Du är på väg att logga ut ur din sista session. Om du loggar ut nu förlorar du åtkomsten till dina krypterade meddelanden." + "Återställning inte inställd" + "Du är på väg att logga ut från din senaste session. Om du loggar ut nu kan du förlora åtkomsten till dina krypterade meddelanden." + "Har du sparat din återställningsnyckel?" + diff --git a/features/logout/impl/src/main/res/values-tr/translations.xml b/features/logout/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..6e1ac9c --- /dev/null +++ b/features/logout/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,18 @@ + + + "Çıkış yapmak istediğinize emin misiniz?" + "Oturumu kapat" + "Oturumu kapat" + "Oturum kapatılıyor…" + "Son oturumunuzdan çıkmak üzeresiniz. Şimdi çıkış yaparsanız, şifrelenmiş mesajlarınıza erişiminizi kaybedersiniz." + "Yedeklemeyi kapattınız" + "Çevrimdışı olduğunuzda anahtarlarınız hala yedekleniyordu. Oturumu kapatmadan önce anahtarlarınızın yedeklenebilmesi için yeniden bağlanın." + "Anahtarlarınız hala yedekleniyor" + "Lütfen oturumu kapatmadan önce bunun tamamlanmasını bekleyin." + "Anahtarlarınız hala yedekleniyor" + "Oturumu kapat" + "Son oturumunuzdan çıkmak üzeresiniz. Şimdi çıkış yaparsanız şifrelenmiş mesajlarınıza erişiminizi kaybedersiniz." + "Kurtarma ayarlanmadı" + "Son oturumunuzdan çıkmak üzeresiniz. Şimdi oturumu kapatırsanız şifrelenmiş mesajlarınıza erişiminizi kaybedebilirsiniz." + "Kurtarma anahtarınızı kaydettiniz mi?" + diff --git a/features/logout/impl/src/main/res/values-uk/translations.xml b/features/logout/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..7e23189 --- /dev/null +++ b/features/logout/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,18 @@ + + + "Ви впевнені, що бажаєте вийти?" + "Вийти" + "Вийти" + "Вихід…" + "Ви збираєтеся вийти зі свого останнього сеансу. Якщо ви вийдете зараз, ви втратите доступ до своїх зашифрованих повідомлень." + "Ви вимкнули резервне копіювання" + "Коли ви вийшли з мережі, резервна копія ваших ключів все ще створювалася. Повторно під\'єднайтеся, щоб зберегти резервну копію ключів перед виходом." + "Резервне копіювання ваших ключів ще триває" + "Дочекайтеся завершення процесу, перш ніж вийти." + "Резервне копіювання ваших ключів ще триває" + "Вийти" + "Ви збираєтеся вийти зі свого останнього сеансу. Якщо ви вийдете зараз, ви втратите доступ до своїх зашифрованих повідомлень." + "Відновлення не налаштовано" + "Ви збираєтеся вийти зі свого останнього сеансу. Якщо вийти зараз, ви можете втратити доступ до зашифрованих повідомлень." + "Ви зберегли ключ відновлення?" + diff --git a/features/logout/impl/src/main/res/values-ur/translations.xml b/features/logout/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..37c8c57 --- /dev/null +++ b/features/logout/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,18 @@ + + + "کیا آپ واقعی خارج ہونا چاہتے ہیں؟" + "خارج ہوں" + "خارج ہوں" + "خارج ہورہاہے…" + "آپ اپنے آخری جلسے سے خارج ہونے والے ہیں۔ اگر آپ ابھی خارج ہوجاتے ہیں تو آپ اپنے مرموز کردہ پیغامات تک رسائی سے محروم ہو جائیں گے۔" + "آپنے پشتارہ بند کردیا ہے" + "جب آپ پرے خط تھے تب بھی آپ کی کلیدوں کا پشتارہ کیا جا رہا تھا۔ دوبارہ جڑیں تاکہ خارج ہونے سے پہلے آپ کی کلیدوں کا پشتارہ کیا جا سکے۔" + "آپکی کلیدوں کا ابھی بھی پشتارہ کیا جا رہا ہے۔" + "برائے مہربانی خارج ہونے سے پہلے اسکے مکمل ہونے کا انتظار کریں" + "آپکی کلیدوں کا ابھی بھی پشتارہ کیا جا رہا ہے۔" + "خارج ہوں" + "آپ اپنے آخری جلسے سے خارج ہونے والے ہیں۔ اگر آپ ابھی خارج ہوجاتے ہیں تو آپ اپنے مرموز کردہ پیغامات تک رسائی سے محروم ہو جائیں گے۔" + "بازیابی غیر مرتب" + "آپ اپنے آخری جلسے سے خارج ہونے والے ہیں۔ اگر آپ ابھی خارج ہوجاتے ہیں تو ہوسکتا ہے کہ آپ اپنے مرموز کردہ پیغامات تک رسائی سے محروم ہو جائیں گے۔" + "کیا آپ نے اپنی بازیابی کی کلید محفوظ کی ہے؟" + diff --git a/features/logout/impl/src/main/res/values-uz/translations.xml b/features/logout/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..9cdfc3d --- /dev/null +++ b/features/logout/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,18 @@ + + + "Haqiqatan ham tizimdan chiqmoqchimisiz?" + "Tizimdan chiqish" + "Tizimdan chiqish" + "Chiqish…" + "Siz oxirgi sessiyangizdan chiqmoqdasiz. Agar hozir chiqib ketsangiz, shifrlangan xabarlaringizga kira olmaysiz." + "Siz zaxira nusxasini oʻchirdingiz" + "Siz oflayn bo‘lganingizda ham kalitlaringiz zaxiralanish jarayonida edi. Tizimdan chiqishdan oldin kalitlaringizning to‘liq zaxiralanishini ta’minlash uchun qayta ulanishingiz zarur." + "Kalitlaringiz hamon zaxiralanmoqda" + "Tizimdan chiqishdan oldin bu jarayon tugashini kuting." + "Kalitlaringiz hamon zaxiralanmoqda" + "Tizimdan chiqish" + "Siz oxirgi sessiyangizdan chiqmoqdasiz. Agar hozir chiqib ketsangiz, shifrlangan xabarlaringizga kira olmaysiz." + "Qayta tiklash sozlanmagan" + "Siz oxirgi sessiyangizdan chiqmoqdasiz. Agar hozir chiqib ketsangiz, shifrlangan xabarlaringizga kira olmay qolishingiz mumkin." + "Zaxira kalitingizni saqladingizmi?" + diff --git a/features/logout/impl/src/main/res/values-zh-rTW/translations.xml b/features/logout/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..a86def3 --- /dev/null +++ b/features/logout/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,18 @@ + + + "您確定要登出嗎?" + "登出" + "登出" + "正在登出…" + "您將要登出上一次作業階段。若您現在登出,將會失去對加密訊息的存取權。" + "您已關閉備份" + "當您離線時,您的金鑰仍在備份中。請重新連線才能在您登出前備份金鑰。" + "您的金鑰仍在備份中" + "請等待此動作完成後再登出。" + "您的金鑰仍在備份中" + "登出" + "您將要登出上一次作業階段。若您現在登出,將會失去對加密訊息的存取權。" + "未設定復原金鑰" + "您將要登出上一次作業階段。若您現在登出,將會失去對加密訊息的存取權。" + "您儲存復原金鑰了嗎?" + diff --git a/features/logout/impl/src/main/res/values-zh/translations.xml b/features/logout/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..0a8ec07 --- /dev/null +++ b/features/logout/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,18 @@ + + + "确定要登出吗?" + "登出" + "登出" + "正在登出…" + "即将登出最后一个会话。如果现在登出,将无法访问加密的消息。" + "您已关闭备份" + "当你离线时,密钥仍在备份中。重新连接以便在登出之前备份密钥。" + "您的密钥仍在备份中" + "请等待此操作完成后再登出。" + "您的密钥仍在备份中" + "登出" + "即将登出最后一个会话。如果现在登出,将无法访问加密的消息。" + "未设置恢复" + "即将登出最后一个会话。如果现在登出,将无法访问加密的消息。" + "您保存了恢复密钥吗?" + diff --git a/features/logout/impl/src/main/res/values/localazy.xml b/features/logout/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..bf13f1d --- /dev/null +++ b/features/logout/impl/src/main/res/values/localazy.xml @@ -0,0 +1,18 @@ + + + "Are you sure you want to sign out?" + "Sign out" + "Sign out" + "Signing out…" + "You are about to sign out of your last session. If you sign out now, you will lose access to your encrypted messages." + "You have turned off backup" + "Your keys were still being backed up when you went offline. Reconnect so that your keys can be backed up before signing out." + "Your keys are still being backed up" + "Please wait for this to complete before signing out." + "Your keys are still being backed up" + "Sign out" + "You are about to sign out of your last session. If you sign out now, you\'ll lose access to your encrypted messages." + "Recovery not set up" + "You are about to sign out of your last session. If you sign out now, you might lose access to your encrypted messages." + "Have you saved your recovery key?" + diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultLogoutEntryPointTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultLogoutEntryPointTest.kt new file mode 100644 index 0000000..8f2490b --- /dev/null +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultLogoutEntryPointTest.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.features.logout.api.LogoutEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultLogoutEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultLogoutEntryPoint() + + val parentNode = TestParentNode.create { buildContext, plugins -> + LogoutNode( + buildContext = buildContext, + plugins = plugins, + presenter = createLogoutPresenter(), + ) + } + val callback = object : LogoutEntryPoint.Callback { + override fun navigateToSecureBackup() = lambdaError() + } + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + callback = callback, + ) + assertThat(result).isInstanceOf(LogoutNode::class.java) + assertThat(result.plugins).contains(callback) + } +} diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCaseTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCaseTest.kt new file mode 100644 index 0000000..e6707a2 --- /dev/null +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultLogoutUseCaseTest.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.logout.impl + +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.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultLogoutUseCaseTest { + @Test + fun `test logout from one session`() = runTest { + val logoutLambda1 = lambdaRecorder { _, _ -> } + val client1 = FakeMatrixClient(A_USER_ID).apply { + logoutLambda = logoutLambda1 + } + val sut = DefaultLogoutUseCase( + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_USER_ID.value), + ) + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { sessionId -> + when (sessionId) { + A_USER_ID -> Result.success(client1) + else -> error("Unexpected sessionId") + } + } + ), + ) + sut.logoutAll(ignoreSdkError = true) + logoutLambda1.assertions().isCalledOnce().with(value(true), value(true)) + } + + @Test + fun `test logout from several sessions`() = runTest { + val logoutLambda1 = lambdaRecorder { _, _ -> } + val logoutLambda2 = lambdaRecorder { _, _ -> } + val client1 = FakeMatrixClient(A_USER_ID).apply { + logoutLambda = logoutLambda1 + } + val client2 = FakeMatrixClient(A_USER_ID_2).apply { + logoutLambda = logoutLambda2 + } + val sut = DefaultLogoutUseCase( + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_USER_ID.value), + aSessionData(sessionId = A_USER_ID_2.value), + ) + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { sessionId -> + when (sessionId) { + A_USER_ID -> Result.success(client1) + A_USER_ID_2 -> Result.success(client2) + else -> error("Unexpected sessionId") + } + } + ), + ) + sut.logoutAll(ignoreSdkError = true) + logoutLambda1.assertions().isCalledOnce().with(value(true), value(true)) + logoutLambda2.assertions().isCalledOnce().with(value(true), value(true)) + } + + @Test + fun `test logout session not found is ignored`() = runTest { + val sut = DefaultLogoutUseCase( + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_USER_ID.value), + ) + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { sessionId -> + when (sessionId) { + A_USER_ID -> Result.failure(Exception("Session not found")) + else -> error("Unexpected sessionId") + } + } + ), + ) + sut.logoutAll(ignoreSdkError = true) + // No error + } + + @Test + fun `test logout no sessions`() = runTest { + val sut = DefaultLogoutUseCase( + sessionStore = InMemorySessionStore( + initialList = emptyList() + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { sessionId -> + when (sessionId) { + else -> error("Unexpected sessionId") + } + } + ), + ) + sut.logoutAll(ignoreSdkError = true) + // No error + } +} diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt new file mode 100644 index 0000000..236b013 --- /dev/null +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt @@ -0,0 +1,246 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +import app.cash.molecule.RecompositionMode +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.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.encryption.BackupState +import io.element.android.libraries.matrix.api.encryption.BackupUploadState +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class LogoutPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createLogoutPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.isLastDevice).isFalse() + assertThat(initialState.backupState).isEqualTo(BackupState.UNKNOWN) + assertThat(initialState.doesBackupExistOnServer).isTrue() + assertThat(initialState.recoveryState).isEqualTo(RecoveryState.UNKNOWN) + assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown) + assertThat(initialState.waitingForALongTime).isFalse() + assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - initial state - last session`() = runTest { + val presenter = createLogoutPresenter( + encryptionService = FakeEncryptionService().apply { + emitIsLastDevice(true) + } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(2) + val initialState = awaitItem() + assertThat(initialState.isLastDevice).isTrue() + assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown) + assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - initial state - waiting a long time`() = runTest { + val encryptionService = FakeEncryptionService() + encryptionService.givenWaitForBackupUploadSteadyStateFlow( + flow { + emit(BackupUploadState.Waiting) + delay(3_000) + } + ) + val presenter = createLogoutPresenter( + encryptionService = encryptionService + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.waitingForALongTime).isFalse() + assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown) + val waitingState = awaitItem() + assertThat(waitingState.backupUploadState).isEqualTo(BackupUploadState.Waiting) + assertThat(initialState.waitingForALongTime).isFalse() + skipItems(1) + val waitingALongTimeState = awaitItem() + assertThat(waitingALongTimeState.backupUploadState).isEqualTo(BackupUploadState.Waiting) + assertThat(waitingALongTimeState.waitingForALongTime).isTrue() + } + } + + @Test + fun `present - initial state - backing up`() = runTest { + val encryptionService = FakeEncryptionService() + encryptionService.givenWaitForBackupUploadSteadyStateFlow( + flow { + emit(BackupUploadState.Waiting) + delay(1) + emit(BackupUploadState.Uploading(backedUpCount = 1, totalCount = 2)) + delay(1) + emit(BackupUploadState.Done) + } + ) + val presenter = createLogoutPresenter( + encryptionService = encryptionService + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.isLastDevice).isFalse() + assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown) + assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) + val waitingState = awaitItem() + assertThat(waitingState.backupUploadState).isEqualTo(BackupUploadState.Waiting) + skipItems(1) + val uploadingState = awaitItem() + assertThat(uploadingState.backupUploadState).isEqualTo(BackupUploadState.Uploading(backedUpCount = 1, totalCount = 2)) + val doneState = awaitItem() + assertThat(doneState.backupUploadState).isEqualTo(BackupUploadState.Done) + } + } + + @Test + fun `present - logout then cancel`() = runTest { + val presenter = createLogoutPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) + val confirmationState = awaitItem() + assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.ConfirmingNoParams) + initialState.eventSink.invoke(LogoutEvents.CloseDialogs) + val finalState = awaitItem() + assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - logout then confirm`() = runTest { + val cancelWorkManagerJobsLambda = lambdaRecorder {} + val workManagerScheduler = FakeWorkManagerScheduler(cancelLambda = cancelWorkManagerJobsLambda) + val presenter = createLogoutPresenter(workManagerScheduler = workManagerScheduler) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) + val confirmationState = awaitItem() + assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.ConfirmingNoParams) + confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) + val loadingState = awaitItem() + assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) + val successState = awaitItem() + assertThat(successState.logoutAction).isInstanceOf(AsyncAction.Success::class.java) + + cancelWorkManagerJobsLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - logout with error then cancel`() = runTest { + val matrixClient = FakeMatrixClient().apply { + logoutLambda = { _, _ -> + throw AN_EXCEPTION + } + } + val presenter = createLogoutPresenter( + matrixClient, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) + val confirmationState = awaitItem() + assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.ConfirmingNoParams) + confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) + val loadingState = awaitItem() + assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) + val errorState = awaitItem() + assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) + errorState.eventSink.invoke(LogoutEvents.CloseDialogs) + val finalState = awaitItem() + assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - logout with error then force`() = runTest { + val matrixClient = FakeMatrixClient().apply { + logoutLambda = { ignoreSdkError, _ -> + if (!ignoreSdkError) { + throw AN_EXCEPTION + } + } + } + val presenter = createLogoutPresenter( + matrixClient, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) + val confirmationState = awaitItem() + assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.ConfirmingNoParams) + confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) + val loadingState = awaitItem() + assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) + val errorState = awaitItem() + assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) + errorState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = true)) + val loadingState2 = awaitItem() + assertThat(loadingState2.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) + val successState = awaitItem() + assertThat(successState.logoutAction).isInstanceOf(AsyncAction.Success::class.java) + } + } + + private suspend fun ReceiveTurbine.awaitFirstItem(): T { + skipItems(2) + return awaitItem() + } +} + +internal fun createLogoutPresenter( + matrixClient: MatrixClient = FakeMatrixClient(), + encryptionService: EncryptionService = FakeEncryptionService(), + workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(cancelLambda = {}), +): LogoutPresenter = LogoutPresenter( + matrixClient = matrixClient, + encryptionService = encryptionService, + workManagerScheduler = workManagerScheduler, +) diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt new file mode 100644 index 0000000..84ca038 --- /dev/null +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.pressTag +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LogoutViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on logout sends a LogoutEvents`() { + val eventsRecorder = EventsRecorder() + rule.setLogoutView( + aLogoutState( + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.action_signout) + eventsRecorder.assertSingle(LogoutEvents.Logout(false)) + } + + @Test + fun `confirming logout sends a LogoutEvents`() { + val eventsRecorder = EventsRecorder() + rule.setLogoutView( + aLogoutState( + logoutAction = AsyncAction.ConfirmingNoParams, + eventSink = eventsRecorder + ), + ) + rule.pressTag(TestTags.dialogPositive.value) + eventsRecorder.assertSingle(LogoutEvents.Logout(false)) + } + + @Test + fun `clicking on back invoke back callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setLogoutView( + aLogoutState( + eventSink = eventsRecorder + ), + onBackClick = callback, + ) + rule.pressBack() + } + } + + @Test + fun `clicking on confirm after error sends a LogoutEvents`() { + val eventsRecorder = EventsRecorder() + rule.setLogoutView( + aLogoutState( + logoutAction = AsyncAction.Failure(Exception("Failed to logout")), + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.action_signout_anyway) + eventsRecorder.assertSingle(LogoutEvents.Logout(true)) + } + + @Test + fun `clicking on cancel after error sends a LogoutEvents`() { + val eventsRecorder = EventsRecorder() + rule.setLogoutView( + aLogoutState( + logoutAction = AsyncAction.Failure(Exception("Failed to logout")), + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(LogoutEvents.CloseDialogs) + } + + @Test + fun `last session setting button invoke onChangeRecoveryKeyClicked`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setLogoutView( + aLogoutState( + isLastDevice = true, + eventSink = eventsRecorder + ), + onChangeRecoveryKeyClick = callback, + ) + rule.clickOn(CommonStrings.common_settings) + } + } +} + +private fun AndroidComposeTestRule.setLogoutView( + state: LogoutState, + onChangeRecoveryKeyClick: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + LogoutView( + state = state, + onChangeRecoveryKeyClick = onChangeRecoveryKeyClick, + onBackClick = onBackClick, + ) + } +} diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutViewTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutViewTest.kt new file mode 100644 index 0000000..8eae534 --- /dev/null +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutViewTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl.direct + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.logout.api.direct.DirectLogoutEvents +import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.pressBackKey +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DefaultDirectLogoutViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on confirm logout sends expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setDefaultDirectLogoutView( + state = aDirectLogoutState( + logoutAction = AsyncAction.ConfirmingNoParams, + eventSink = eventsRecorder, + ) + ) + rule.clickOn(CommonStrings.action_signout) + eventsRecorder.assertSingle(DirectLogoutEvents.Logout(false)) + } + + @Test + fun `clicking on cancel logout sends expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setDefaultDirectLogoutView( + state = aDirectLogoutState( + logoutAction = AsyncAction.ConfirmingNoParams, + eventSink = eventsRecorder, + ) + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(DirectLogoutEvents.CloseDialogs) + } + + @Ignore("Pressing back key should dismiss the dialog, and so generate the expected event, but it's not the case.") + @Test + fun `clicking on back invoke back callback`() { + val eventsRecorder = EventsRecorder() + rule.setDefaultDirectLogoutView( + state = aDirectLogoutState( + logoutAction = AsyncAction.ConfirmingNoParams, + eventSink = eventsRecorder, + ) + ) + rule.pressBackKey() + eventsRecorder.assertSingle(DirectLogoutEvents.CloseDialogs) + } + + @Test + fun `clicking on confirm after error sends expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setDefaultDirectLogoutView( + state = aDirectLogoutState( + logoutAction = AsyncAction.Failure(Exception("Error")), + eventSink = eventsRecorder, + ) + ) + rule.clickOn(CommonStrings.action_signout_anyway) + eventsRecorder.assertSingle(DirectLogoutEvents.Logout(true)) + } + + @Test + fun `clicking on cancel after error sends expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setDefaultDirectLogoutView( + state = aDirectLogoutState( + logoutAction = AsyncAction.Failure(Exception("Error")), + eventSink = eventsRecorder, + ) + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(DirectLogoutEvents.CloseDialogs) + } +} + +private fun AndroidComposeTestRule.setDefaultDirectLogoutView( + state: DirectLogoutState, +) { + setContent { + DefaultDirectLogoutView().Render(state) + } +} diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenterTest.kt new file mode 100644 index 0000000..835ed4e --- /dev/null +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenterTest.kt @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.impl.direct + +import app.cash.molecule.RecompositionMode +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.logout.api.direct.DirectLogoutEvents +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.encryption.BackupUploadState +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DirectLogoutPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createDirectLogoutPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.canDoDirectSignOut).isTrue() + assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - initial state - last session`() = runTest { + val presenter = createDirectLogoutPresenter( + encryptionService = FakeEncryptionService().apply { + emitIsLastDevice(true) + } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.canDoDirectSignOut).isFalse() + assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - initial state - backing up`() = runTest { + val encryptionService = FakeEncryptionService() + encryptionService.givenWaitForBackupUploadSteadyStateFlow( + flow { + emit(BackupUploadState.Waiting) + } + ) + val presenter = createDirectLogoutPresenter( + encryptionService = encryptionService + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitFirstItem() + assertThat(initialState.canDoDirectSignOut).isFalse() + assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - logout then cancel`() = runTest { + val presenter = createDirectLogoutPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) + val confirmationState = awaitItem() + assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.ConfirmingNoParams) + initialState.eventSink.invoke(DirectLogoutEvents.CloseDialogs) + val finalState = awaitItem() + assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - logout then confirm`() = runTest { + val presenter = createDirectLogoutPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) + val confirmationState = awaitItem() + assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.ConfirmingNoParams) + confirmationState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) + val loadingState = awaitItem() + assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) + val successState = awaitItem() + assertThat(successState.logoutAction).isInstanceOf(AsyncAction.Success::class.java) + } + } + + @Test + fun `present - logout with error then cancel`() = runTest { + val matrixClient = FakeMatrixClient().apply { + logoutLambda = { _, _ -> + throw AN_EXCEPTION + } + } + val presenter = createDirectLogoutPresenter( + matrixClient, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) + val confirmationState = awaitItem() + assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.ConfirmingNoParams) + confirmationState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) + val loadingState = awaitItem() + assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) + val errorState = awaitItem() + assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) + errorState.eventSink.invoke(DirectLogoutEvents.CloseDialogs) + val finalState = awaitItem() + assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - logout with error then force`() = runTest { + val matrixClient = FakeMatrixClient().apply { + logoutLambda = { ignoreSdkError, _ -> + if (!ignoreSdkError) { + throw AN_EXCEPTION + } + } + } + val presenter = createDirectLogoutPresenter( + matrixClient, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) + val confirmationState = awaitItem() + assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.ConfirmingNoParams) + confirmationState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) + val loadingState = awaitItem() + assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) + val errorState = awaitItem() + assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) + errorState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = true)) + val loadingState2 = awaitItem() + assertThat(loadingState2.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) + val successState = awaitItem() + assertThat(successState.logoutAction).isInstanceOf(AsyncAction.Success::class.java) + } + } + + private suspend fun ReceiveTurbine.awaitFirstItem(): T { + return awaitItem() + } + + private fun createDirectLogoutPresenter( + matrixClient: MatrixClient = FakeMatrixClient(), + encryptionService: EncryptionService = FakeEncryptionService(), + ): DirectLogoutPresenter = DirectLogoutPresenter( + matrixClient = matrixClient, + encryptionService = encryptionService, + ) +} diff --git a/features/logout/test/build.gradle.kts b/features/logout/test/build.gradle.kts new file mode 100644 index 0000000..f61ed10 --- /dev/null +++ b/features/logout/test/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.logout.test" +} + +dependencies { + implementation(libs.coroutines.core) + implementation(projects.libraries.architecture) + implementation(projects.tests.testutils) + api(projects.features.logout.api) +} diff --git a/features/logout/test/src/main/kotlin/io/element/android/features/logout/test/FakeLogoutEntryPoint.kt b/features/logout/test/src/main/kotlin/io/element/android/features/logout/test/FakeLogoutEntryPoint.kt new file mode 100644 index 0000000..c5c4172 --- /dev/null +++ b/features/logout/test/src/main/kotlin/io/element/android/features/logout/test/FakeLogoutEntryPoint.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.logout.api.LogoutEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeLogoutEntryPoint : LogoutEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: LogoutEntryPoint.Callback, + ): Node = lambdaError() +} diff --git a/features/logout/test/src/main/kotlin/io/element/android/features/logout/test/FakeLogoutUseCase.kt b/features/logout/test/src/main/kotlin/io/element/android/features/logout/test/FakeLogoutUseCase.kt new file mode 100644 index 0000000..9b11172 --- /dev/null +++ b/features/logout/test/src/main/kotlin/io/element/android/features/logout/test/FakeLogoutUseCase.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.logout.test + +import io.element.android.features.logout.api.LogoutUseCase +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask + +class FakeLogoutUseCase( + var logoutLambda: (Boolean) -> Unit = { lambdaError() } +) : LogoutUseCase { + override suspend fun logoutAll(ignoreSdkError: Boolean) = simulateLongTask { + logoutLambda(ignoreSdkError) + } +} diff --git a/features/messages/api/.gitignore b/features/messages/api/.gitignore new file mode 100644 index 0000000..42afabf --- /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 0000000..029ef62 --- /dev/null +++ b/features/messages/api/build.gradle.kts @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.messages.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.mediaviewer.api) + implementation(projects.libraries.preferences.api) + api(projects.libraries.textcomposer.impl) +} 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 0000000..534af8a --- /dev/null +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.api + +import io.element.android.libraries.textcomposer.model.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 0000000..a23e337 --- /dev/null +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.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.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import kotlinx.parcelize.Parcelize + +interface MessagesEntryPoint : FeatureEntryPoint { + sealed interface InitialTarget : Parcelable { + @Parcelize + data class Messages( + val focusedEventId: EventId?, + ) : InitialTarget + + @Parcelize + data object PinnedMessages : InitialTarget + } + + interface Callback : Plugin { + fun navigateToRoomDetails() + fun navigateToRoomMemberDetails(userId: UserId) + fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) + fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) + fun navigateToRoom(roomId: RoomId) + } + + data class Params(val initialTarget: InitialTarget) : NodeInputs + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + callback: Callback, + ): Node + + interface NodeProxy { + suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) + } +} diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/pinned/PinnedEventsTimelineProvider.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/pinned/PinnedEventsTimelineProvider.kt new file mode 100644 index 0000000..849b974 --- /dev/null +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/pinned/PinnedEventsTimelineProvider.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.api.pinned + +import io.element.android.libraries.matrix.api.timeline.TimelineProvider + +interface PinnedEventsTimelineProvider : TimelineProvider diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt new file mode 100644 index 0000000..f44c996 --- /dev/null +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.api.timeline + +import androidx.compose.runtime.Composable +import io.element.android.wysiwyg.utils.HtmlConverter + +interface HtmlConverterProvider { + @Composable + fun Update() + + fun provide(): HtmlConverter +} diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerEvent.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerEvent.kt new file mode 100644 index 0000000..ef5fe93 --- /dev/null +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerEvent.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.api.timeline.voicemessages.composer + +import androidx.lifecycle.Lifecycle +import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent +import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent + +sealed interface VoiceMessageComposerEvent { + data class RecorderEvent( + val recorderEvent: VoiceMessageRecorderEvent + ) : VoiceMessageComposerEvent + data class PlayerEvent( + val playerEvent: VoiceMessagePlayerEvent, + ) : VoiceMessageComposerEvent + data object SendVoiceMessage : VoiceMessageComposerEvent + data object DeleteVoiceMessage : VoiceMessageComposerEvent + data object AcceptPermissionRationale : VoiceMessageComposerEvent + data object DismissPermissionsRationale : VoiceMessageComposerEvent + data class LifecycleEvent(val event: Lifecycle.Event) : VoiceMessageComposerEvent + data object DismissSendFailureDialog : VoiceMessageComposerEvent +} diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerPresenter.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerPresenter.kt new file mode 100644 index 0000000..848f9fe --- /dev/null +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerPresenter.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.api.timeline.voicemessages.composer + +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.timeline.Timeline + +fun interface VoiceMessageComposerPresenter : Presenter { + interface Factory { + fun create(timelineMode: Timeline.Mode): VoiceMessageComposerPresenter + } +} diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerState.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerState.kt new file mode 100644 index 0000000..f324bb7 --- /dev/null +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerState.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.api.timeline.voicemessages.composer + +import androidx.compose.runtime.Stable +import io.element.android.libraries.textcomposer.model.VoiceMessageState + +@Stable +data class VoiceMessageComposerState( + val voiceMessageState: VoiceMessageState, + val showPermissionRationaleDialog: Boolean, + val showSendFailureDialog: Boolean, + val keepScreenOn: Boolean, + val eventSink: (VoiceMessageComposerEvent) -> Unit, +) diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStateProvider.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStateProvider.kt new file mode 100644 index 0000000..7b264a3 --- /dev/null +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerStateProvider.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.api.timeline.voicemessages.composer + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.designsystem.components.media.WaveFormSamples +import io.element.android.libraries.textcomposer.model.VoiceMessageState +import kotlin.time.Duration.Companion.seconds + +open class VoiceMessageComposerStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(duration = 61.seconds, levels = WaveFormSamples.allRangeWaveForm)), + ) +} + +fun aVoiceMessageComposerState( + voiceMessageState: VoiceMessageState = VoiceMessageState.Idle, + keepScreenOn: Boolean = false, + showPermissionRationaleDialog: Boolean = false, + showSendFailureDialog: Boolean = false, +) = VoiceMessageComposerState( + voiceMessageState = voiceMessageState, + showPermissionRationaleDialog = showPermissionRationaleDialog, + showSendFailureDialog = showSendFailureDialog, + keepScreenOn = keepScreenOn, + eventSink = {}, +) + +fun aVoiceMessagePreviewState() = VoiceMessageState.Preview( + isSending = false, + isPlaying = false, + showCursor = false, + playbackProgress = 0f, + time = 10.seconds, + waveform = WaveFormSamples.realisticWaveForm, +) diff --git a/features/messages/impl/.gitignore b/features/messages/impl/.gitignore new file mode 100644 index 0000000..42afabf --- /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 0000000..ad6562a --- /dev/null +++ b/features/messages/impl/build.gradle.kts @@ -0,0 +1,105 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.messages.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + api(projects.features.messages.api) + implementation(projects.appconfig) + implementation(projects.features.call.api) + implementation(projects.features.enterprise.api) + implementation(projects.features.forward.api) + implementation(projects.features.location.api) + implementation(projects.features.poll.api) + implementation(projects.features.roomcall.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.matrixmedia.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.textcomposer.impl) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.dateformatter.api) + implementation(projects.libraries.eventformatter.api) + implementation(projects.libraries.mediapickers.api) + implementation(projects.libraries.mediaviewer.api) + implementation(projects.libraries.featureflag.api) + implementation(projects.libraries.mediaupload.api) + implementation(projects.libraries.permissions.api) + implementation(projects.libraries.preferences.api) + implementation(projects.libraries.recentemojis.api) + implementation(projects.libraries.roomselect.api) + implementation(projects.libraries.voiceplayer.api) + implementation(projects.libraries.voicerecorder.api) + implementation(projects.libraries.mediaplayer.api) + implementation(projects.libraries.push.api) + implementation(projects.libraries.uiUtils) + implementation(projects.libraries.testtags) + implementation(projects.features.networkmonitor.api) + implementation(projects.services.analytics.compose) + implementation(projects.services.appnavstate.api) + implementation(projects.services.toolbox.api) + implementation(libs.coil.compose) + implementation(libs.datetime) + implementation(libs.jsoup) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.constraintlayout.compose) + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.ui) + implementation(libs.sigpwned.emoji4j) + implementation(libs.vanniktech.blurhash) + implementation(libs.telephoto.zoomableimage) + implementation(libs.matrix.emojibase.bindings) + implementation(projects.features.knockrequests.api) + implementation(projects.features.roommembermoderation.api) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.dateformatter.test) + testImplementation(projects.libraries.push.test) + testImplementation(projects.features.call.test) + testImplementation(projects.features.forward.test) + testImplementation(projects.features.knockrequests.test) + testImplementation(projects.features.location.test) + testImplementation(projects.features.networkmonitor.test) + testImplementation(projects.features.messages.test) + testImplementation(projects.services.analytics.test) + testImplementation(projects.services.toolbox.test) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.libraries.mediaupload.impl) + testImplementation(projects.libraries.mediaupload.test) + testImplementation(projects.libraries.mediapickers.test) + testImplementation(projects.libraries.permissions.test) + testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.voicerecorder.test) + testImplementation(projects.libraries.mediaplayer.test) + testImplementation(projects.libraries.mediaviewer.test) + testImplementation(projects.libraries.testtags) + testImplementation(projects.features.poll.test) + testImplementation(projects.libraries.eventformatter.test) + testImplementation(projects.libraries.recentemojis.test) +} diff --git a/features/messages/impl/consumer-rules.pro b/features/messages/impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/features/messages/impl/src/main/AndroidManifest.xml b/features/messages/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ed135ae --- /dev/null +++ b/features/messages/impl/src/main/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + 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 0000000..0293a40 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPoint.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.messages.api.MessagesEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope + +@ContributesBinding(SessionScope::class) +class DefaultMessagesEntryPoint : MessagesEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: MessagesEntryPoint.Params, + callback: MessagesEntryPoint.Callback, + ): Node { + return parentNode.createNode(buildContext, listOf(params, callback)) + } +} + +internal fun MessagesEntryPoint.InitialTarget.toNavTarget() = when (this) { + is MessagesEntryPoint.InitialTarget.Messages -> MessagesFlowNode.NavTarget.Messages(focusedEventId) + MessagesEntryPoint.InitialTarget.PinnedMessages -> MessagesFlowNode.NavTarget.PinnedMessagesList +} 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 0000000..2419d76 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.api.user.MatrixUser + +sealed interface MessagesEvents { + data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents + data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvents + data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvents + data class OnUserClicked(val user: MatrixUser) : MessagesEvents + data object Dismiss : MessagesEvents + data object MarkAsFullyReadAndExit : 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 0000000..f0b88e1 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -0,0 +1,634 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +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.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.Interaction +import io.element.android.annotations.ContributesNode +import io.element.android.features.call.api.CallType +import io.element.android.features.call.api.ElementCallEntryPoint +import io.element.android.features.forward.api.ForwardEntryPoint +import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint +import io.element.android.features.location.api.Location +import io.element.android.features.location.api.LocationService +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.pinned.DefaultPinnedEventsTimelineProvider +import io.element.android.features.messages.impl.pinned.list.PinnedMessagesListNode +import io.element.android.features.messages.impl.report.ReportMessageNode +import io.element.android.features.messages.impl.threads.ThreadedMessagesNode +import io.element.android.features.messages.impl.timeline.TimelineController +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.TimelineItemEventContentWithAttachment +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.features.messages.impl.timeline.model.event.TimelineItemVoiceContent +import io.element.android.features.messages.impl.timeline.model.event.duration +import io.element.android.features.poll.api.create.CreatePollEntryPoint +import io.element.android.features.poll.api.create.CreatePollMode +import io.element.android.libraries.architecture.BackstackWithOverlayBox +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.architecture.overlay.Overlay +import io.element.android.libraries.architecture.overlay.operation.hide +import io.element.android.libraries.architecture.overlay.operation.show +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode +import io.element.android.libraries.dateformatter.api.toHumanReadableDuration +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.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.alias.matches +import io.element.android.libraries.matrix.api.room.joinedRoomMembers +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache +import io.element.android.libraries.matrix.ui.messages.RoomNamesCache +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint +import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater +import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme +import io.element.android.libraries.textcomposer.mentions.MentionSpanUpdater +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize +import kotlin.time.Duration.Companion.milliseconds + +@ContributesNode(RoomScope::class) +@AssistedInject +class MessagesFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val roomListService: RoomListService, + private val sessionId: SessionId, + private val sendLocationEntryPoint: SendLocationEntryPoint, + private val showLocationEntryPoint: ShowLocationEntryPoint, + private val createPollEntryPoint: CreatePollEntryPoint, + private val elementCallEntryPoint: ElementCallEntryPoint, + private val mediaViewerEntryPoint: MediaViewerEntryPoint, + private val forwardEntryPoint: ForwardEntryPoint, + private val analyticsService: AnalyticsService, + private val locationService: LocationService, + private val room: BaseRoom, + private val roomMemberProfilesCache: RoomMemberProfilesCache, + private val roomNamesCache: RoomNamesCache, + private val mentionSpanUpdater: MentionSpanUpdater, + private val mentionSpanTheme: MentionSpanTheme, + private val pinnedEventsTimelineProvider: DefaultPinnedEventsTimelineProvider, + private val timelineController: TimelineController, + private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint, + private val dateFormatter: DateFormatter, + private val coroutineDispatchers: CoroutineDispatchers, +) : BaseFlowNode( + backstack = BackStack( + initialElement = plugins.filterIsInstance().first().initialTarget.toNavTarget(), + savedStateMap = buildContext.savedStateMap, + ), + overlay = Overlay( + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +), MessagesEntryPoint.NodeProxy { + sealed interface NavTarget : Parcelable { + @Parcelize + data class Messages(val focusedEventId: EventId?) : NavTarget + + @Parcelize + data class MediaViewer( + val mode: MediaViewerEntryPoint.MediaViewerMode, + val eventId: EventId?, + val mediaInfo: MediaInfo, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + ) : NavTarget + + @Parcelize + data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachment: Attachment, val inReplyToEventId: EventId?) : 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, + val fromPinnedEvents: Boolean, + ) : NavTarget + + @Parcelize + data class ReportMessage(val eventId: EventId, val senderId: UserId) : NavTarget + + @Parcelize + data class SendLocation(val timelineMode: Timeline.Mode) : NavTarget + + @Parcelize + data class CreatePoll(val timelineMode: Timeline.Mode) : NavTarget + + @Parcelize + data class EditPoll(val timelineMode: Timeline.Mode, val eventId: EventId) : NavTarget + + @Parcelize + data object PinnedMessagesList : NavTarget + + @Parcelize + data object KnockRequestsList : NavTarget + + @Parcelize + data class Thread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget + } + + private val callback: MessagesEntryPoint.Callback = callback() + + override fun onBuilt() { + super.onBuilt() + lifecycle.subscribe( + onDestroy = { + timelineController.close() + } + ) + setupCacheUpdaters() + + pinnedEventsTimelineProvider.launchIn(lifecycleScope) + } + + private fun setupCacheUpdaters() { + room.membersStateFlow + .onEach { membersState -> + withContext(coroutineDispatchers.computation) { + roomMemberProfilesCache.replace(membersState.joinedRoomMembers()) + } + } + .launchIn(lifecycleScope) + + roomListService + .allRooms + .summaries + .onEach { + withContext(coroutineDispatchers.computation) { + roomNamesCache.replace(it) + } + } + .launchIn(lifecycleScope) + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.Messages -> { + val callback = object : MessagesNode.Callback { + override fun navigateToRoomDetails() { + callback.navigateToRoomDetails() + } + + override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean { + return processEventClick( + timelineMode = timelineMode, + event = event, + ) + } + + override fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) { + backstack.push( + NavTarget.AttachmentPreview( + attachment = attachments.first(), + timelineMode = Timeline.Mode.Live, + inReplyToEventId = inReplyToEventId, + ) + ) + } + + override fun navigateToRoomMemberDetails(userId: UserId) { + callback.navigateToRoomMemberDetails(userId) + } + + override fun handlePermalinkClick(data: PermalinkData) { + callback.handlePermalinkClick(data, pushToBackstack = true) + } + + override fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { + backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) + } + + override fun forwardEvent(eventId: EventId) { + backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents = false)) + } + + override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { + backstack.push(NavTarget.ReportMessage(eventId, senderId)) + } + + override fun navigateToSendLocation() { + backstack.push(NavTarget.SendLocation(Timeline.Mode.Live)) + } + + override fun navigateToCreatePoll() { + backstack.push(NavTarget.CreatePoll(Timeline.Mode.Live)) + } + + override fun navigateToEditPoll(eventId: EventId) { + backstack.push(NavTarget.EditPoll(Timeline.Mode.Live, eventId)) + } + + override fun navigateToRoomCall(roomId: RoomId) { + val callType = CallType.RoomCall( + sessionId = sessionId, + roomId = roomId, + ) + analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton) + elementCallEntryPoint.startCall(callType) + } + + override fun navigateToPinnedMessagesList() { + backstack.push(NavTarget.PinnedMessagesList) + } + + override fun navigateToKnockRequestsList() { + backstack.push(NavTarget.KnockRequestsList) + } + + override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { + backstack.push(NavTarget.Thread(threadRootId, focusedEventId)) + } + } + val inputs = MessagesNode.Inputs(focusedEventId = navTarget.focusedEventId) + createNode(buildContext, listOf(callback, inputs)) + } + is NavTarget.MediaViewer -> { + val params = MediaViewerEntryPoint.Params( + mode = navTarget.mode, + eventId = navTarget.eventId, + mediaInfo = navTarget.mediaInfo, + mediaSource = navTarget.mediaSource, + thumbnailSource = navTarget.thumbnailSource, + canShowInfo = true, + ) + val callback = object : MediaViewerEntryPoint.Callback { + override fun onDone() { + overlay.hide() + } + + override fun viewInTimeline(eventId: EventId) { + this@MessagesFlowNode.viewInTimeline(eventId) + } + + override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) { + // Need to go to the parent because of the overlay + callback.forwardEvent(eventId, fromPinnedEvents) + } + } + mediaViewerEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = params, + callback = callback + ) + } + is NavTarget.AttachmentPreview -> { + val inputs = AttachmentsPreviewNode.Inputs( + attachment = navTarget.attachment, + timelineMode = navTarget.timelineMode, + inReplyToEventId = navTarget.inReplyToEventId, + ) + createNode(buildContext, listOf(inputs)) + } + is NavTarget.LocationViewer -> { + val inputs = ShowLocationEntryPoint.Inputs(navTarget.location, navTarget.description) + showLocationEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + inputs = inputs, + ) + } + is NavTarget.EventDebugInfo -> { + val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo) + createNode(buildContext, listOf(inputs)) + } + is NavTarget.ForwardEvent -> { + val timelineProvider = if (navTarget.fromPinnedEvents) { + pinnedEventsTimelineProvider + } else { + timelineController + } + val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider) + val callback = object : ForwardEntryPoint.Callback { + override fun onDone(roomIds: List) { + backstack.pop() + roomIds.singleOrNull()?.let { roomId -> + callback.navigateToRoom(roomId) + } + } + } + forwardEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = params, + callback = callback, + ) + } + is NavTarget.ReportMessage -> { + val inputs = ReportMessageNode.Inputs(navTarget.eventId, navTarget.senderId) + createNode(buildContext, listOf(inputs)) + } + is NavTarget.SendLocation -> { + sendLocationEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + timelineMode = navTarget.timelineMode, + ) + } + is NavTarget.CreatePoll -> { + createPollEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = CreatePollEntryPoint.Params( + timelineMode = navTarget.timelineMode, + mode = CreatePollMode.NewPoll + ), + ) + } + is NavTarget.EditPoll -> { + createPollEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = CreatePollEntryPoint.Params( + timelineMode = navTarget.timelineMode, + mode = CreatePollMode.EditPoll(eventId = navTarget.eventId) + ), + ) + } + NavTarget.PinnedMessagesList -> { + val callback = object : PinnedMessagesListNode.Callback { + override fun handleEventClick(event: TimelineItem.Event) { + processEventClick( + timelineMode = Timeline.Mode.PinnedEvents, + event = event, + ) + } + + override fun navigateToRoomMemberDetails(userId: UserId) { + callback.navigateToRoomMemberDetails(userId) + } + + override fun viewInTimeline(eventId: EventId) { + this@MessagesFlowNode.viewInTimeline(eventId) + } + + override fun handlePermalinkClick(data: PermalinkData.RoomLink) { + callback.handlePermalinkClick(data, pushToBackstack = !room.matches(data.roomIdOrAlias)) + } + + override fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { + backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) + } + + override fun handleForwardEventClick(eventId: EventId) { + backstack.push(NavTarget.ForwardEvent(eventId = eventId, fromPinnedEvents = true)) + } + } + createNode(buildContext, plugins = listOf(callback)) + } + NavTarget.KnockRequestsList -> { + knockRequestsListEntryPoint.createNode(this, buildContext) + } + is NavTarget.Thread -> { + val inputs = ThreadedMessagesNode.Inputs( + threadRootEventId = navTarget.threadRootId, + focusedEventId = navTarget.focusedEventId, + ) + val callback = object : ThreadedMessagesNode.Callback { + override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean { + return processEventClick( + timelineMode = timelineMode, + event = event, + ) + } + + override fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) { + backstack.push( + NavTarget.AttachmentPreview( + attachment = attachments.first(), + timelineMode = Timeline.Mode.Thread(navTarget.threadRootId), + inReplyToEventId = inReplyToEventId, + ) + ) + } + + override fun navigateToRoomMemberDetails(userId: UserId) { + callback.navigateToRoomMemberDetails(userId) + } + + override fun handlePermalinkClick(data: PermalinkData) { + callback.handlePermalinkClick(data, pushToBackstack = true) + } + + override fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { + backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) + } + + override fun handleForwardEventClick(eventId: EventId) { + backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents = false)) + } + + override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { + backstack.push(NavTarget.ReportMessage(eventId, senderId)) + } + + override fun navigateToSendLocation() { + backstack.push(NavTarget.SendLocation(Timeline.Mode.Thread(navTarget.threadRootId))) + } + + override fun navigateToCreatePoll() { + backstack.push(NavTarget.CreatePoll(Timeline.Mode.Thread(navTarget.threadRootId))) + } + + override fun navigateToEditPoll(eventId: EventId) { + backstack.push(NavTarget.EditPoll(Timeline.Mode.Thread(navTarget.threadRootId), eventId)) + } + + override fun navigateToRoomCall(roomId: RoomId) { + val callType = CallType.RoomCall( + sessionId = sessionId, + roomId = roomId, + ) + analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton) + elementCallEntryPoint.startCall(callType) + } + + override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { + backstack.push(NavTarget.Thread(threadRootId, focusedEventId)) + } + } + createNode(buildContext, listOf(inputs, callback)) + } + } + } + + private fun viewInTimeline(eventId: EventId) { + val permalinkData = PermalinkData.RoomLink( + roomIdOrAlias = room.roomId.toRoomIdOrAlias(), + eventId = eventId, + ) + callback.handlePermalinkClick(permalinkData, pushToBackstack = false) + } + + private fun processEventClick( + timelineMode: Timeline.Mode, + event: TimelineItem.Event, + ): Boolean { + val navTarget = when (event.content) { + is TimelineItemImageContent -> { + buildMediaViewerNavTarget( + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode), + event = event, + content = event.content, + mediaSource = event.content.mediaSource, + thumbnailSource = event.content.thumbnailSource, + ) + } + is TimelineItemVideoContent -> { + buildMediaViewerNavTarget( + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode), + event = event, + content = event.content, + mediaSource = event.content.mediaSource, + thumbnailSource = event.content.thumbnailSource, + ) + } + is TimelineItemFileContent -> { + buildMediaViewerNavTarget( + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode), + event = event, + content = event.content, + mediaSource = event.content.mediaSource, + thumbnailSource = event.content.thumbnailSource, + ) + } + is TimelineItemAudioContent -> { + buildMediaViewerNavTarget( + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode), + event = event, + content = event.content, + mediaSource = event.content.mediaSource, + thumbnailSource = null, + ) + } + is TimelineItemLocationContent -> { + NavTarget.LocationViewer( + location = event.content.location, + description = event.content.description, + ).takeIf { locationService.isServiceAvailable() } + } + else -> null + } + return when (navTarget) { + is NavTarget.MediaViewer -> { + overlay.show(navTarget) + true + } + is NavTarget.LocationViewer -> { + backstack.push(navTarget) + true + } + else -> false + } + } + + private fun buildMediaViewerNavTarget( + mode: MediaViewerEntryPoint.MediaViewerMode, + event: TimelineItem.Event, + content: TimelineItemEventContentWithAttachment, + mediaSource: MediaSource, + thumbnailSource: MediaSource?, + ): NavTarget { + return NavTarget.MediaViewer( + mode = mode, + eventId = event.eventId, + mediaInfo = MediaInfo( + filename = content.filename, + fileSize = content.fileSize, + caption = content.caption, + mimeType = content.mimeType, + formattedFileSize = content.formattedFileSize, + fileExtension = content.fileExtension, + senderId = event.senderId, + senderName = event.safeSenderName, + senderAvatar = event.senderAvatar.url, + dateSent = dateFormatter.format( + event.sentTimeMillis, + mode = DateFormatterMode.Day, + ), + dateSentFull = dateFormatter.format( + timestamp = event.sentTimeMillis, + mode = DateFormatterMode.Full, + ), + waveform = (content as? TimelineItemVoiceContent)?.waveform, + duration = content.duration()?.toHumanReadableDuration(), + ), + mediaSource = mediaSource, + thumbnailSource = thumbnailSource, + ) + } + + override suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) { + // Wait until we have the UI for the main timeline attached + waitForChildAttached() + // Give some time for the items in the main timeline to be received, otherwise loading the focused thread root id won't work + // (look at TimelineItemIndexer and firstProcessLatch for more info) + delay(10.milliseconds) + // Then push the new threads screen on top + backstack.push(NavTarget.Thread(threadId, focusedEventId)) + } + + @Composable + override fun View(modifier: Modifier) { + mentionSpanTheme.updateStyles() + CompositionLocalProvider( + LocalMentionSpanUpdater provides mentionSpanUpdater + ) { + BackstackWithOverlayBox(modifier) + } + } +} 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 0000000..2ec5c0b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl + +import io.element.android.features.messages.impl.attachments.Attachment +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.ThreadId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import kotlinx.collections.immutable.ImmutableList + +interface MessagesNavigator { + fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) + fun forwardEvent(eventId: EventId) + fun navigateToReportMessage(eventId: EventId, senderId: UserId) + fun navigateToEditPoll(eventId: EventId) + fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) + fun navigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List) + fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) + fun close() +} 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 0000000..0692a98 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -0,0 +1,314 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl + +import android.app.Activity +import android.content.Context +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.lifecycle.Lifecycle +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.knockrequests.api.banner.KnockRequestsBannerRenderer +import io.element.android.features.messages.impl.actionlist.ActionListPresenter +import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor +import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvent +import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter +import io.element.android.features.messages.impl.timeline.TimelineController +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.di.LocalTimelineItemPresenterFactories +import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.roommembermoderation.api.ModerationAction +import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents +import io.element.android.features.roommembermoderation.api.RoomMemberModerationRenderer +import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab +import io.element.android.libraries.androidutils.system.openUrlInExternalApp +import io.element.android.libraries.androidutils.system.toast +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom +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.ThreadId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.alias.matches +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.mediaplayer.api.MediaPlayer +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadMessagesUi +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.finishLongRunningTransaction +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@ContributesNode(RoomScope::class) +@AssistedInject +class MessagesNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + @ApplicationContext private val context: Context, + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, + private val room: JoinedRoom, + private val analyticsService: AnalyticsService, + messageComposerPresenterFactory: MessageComposerPresenter.Factory, + timelinePresenterFactory: TimelinePresenter.Factory, + presenterFactory: MessagesPresenter.Factory, + actionListPresenterFactory: ActionListPresenter.Factory, + private val timelineItemPresenterFactories: TimelineItemPresenterFactories, + private val mediaPlayer: MediaPlayer, + private val permalinkParser: PermalinkParser, + private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer, + private val roomMemberModerationRenderer: RoomMemberModerationRenderer, +) : Node(buildContext, plugins = plugins), MessagesNavigator { + data class Inputs( + val focusedEventId: EventId?, + ) : NodeInputs + + private val inputs = inputs() + private val callback: Callback = callback() + + private val timelineController = TimelineController(room, room.liveTimeline) + private val presenter = presenterFactory.create( + navigator = this, + composerPresenter = messageComposerPresenterFactory.create(timelineController, this), + timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this), + actionListPresenter = actionListPresenterFactory.create( + postProcessor = TimelineItemActionPostProcessor.Default, + timelineMode = timelineController.mainTimelineMode(), + ), + timelineController = timelineController, + ) + + interface Callback : Plugin { + fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean + fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) + fun navigateToRoomMemberDetails(userId: UserId) + fun handlePermalinkClick(data: PermalinkData) + fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) + fun forwardEvent(eventId: EventId) + fun navigateToReportMessage(eventId: EventId, senderId: UserId) + fun navigateToSendLocation() + fun navigateToCreatePoll() + fun navigateToEditPoll(eventId: EventId) + fun navigateToRoomCall(roomId: RoomId) + fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) + fun navigateToRoomDetails() + fun navigateToPinnedMessagesList() + fun navigateToKnockRequestsList() + } + + override fun onBuilt() { + super.onBuilt() + lifecycle.subscribe( + onCreate = { + sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) } + }, + onResume = { + analyticsService.finishLongRunningTransaction(LoadMessagesUi) + }, + onDestroy = { + mediaPlayer.close() + } + ) + } + + private fun onLinkClick( + activity: Activity, + darkTheme: Boolean, + url: String, + eventSink: (TimelineEvents) -> Unit, + customTab: Boolean + ) { + when (val permalink = permalinkParser.parse(url)) { + is PermalinkData.UserLink -> { + // Open the room member profile, it will fallback to + // the user profile if the user is not in the room + callback.navigateToRoomMemberDetails(permalink.userId) + } + is PermalinkData.RoomLink -> { + handleRoomLinkClick(permalink, eventSink) + } + is PermalinkData.FallbackLink -> { + if (customTab) { + activity.openUrlInChromeCustomTab(null, darkTheme, url) + } else { + activity.openUrlInExternalApp(url) + } + } + is PermalinkData.RoomEmailInviteLink -> { + activity.openUrlInChromeCustomTab(null, darkTheme, url) + } + } + } + + private fun handleRoomLinkClick( + roomLink: PermalinkData.RoomLink, + eventSink: (TimelineEvents) -> Unit, + ) { + if (room.matches(roomLink.roomIdOrAlias)) { + val eventId = roomLink.eventId + if (eventId != null) { + eventSink(TimelineEvents.FocusOnEvent(eventId)) + } else { + // Click on the same room, ignore + displaySameRoomToast() + } + } else { + callback.handlePermalinkClick(roomLink) + } + } + + override fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { + callback.navigateToEventDebugInfo(eventId, debugInfo) + } + + override fun forwardEvent(eventId: EventId) { + callback.forwardEvent(eventId) + } + + override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { + callback.navigateToReportMessage(eventId, senderId) + } + + override fun navigateToEditPoll(eventId: EventId) { + callback.navigateToEditPoll(eventId) + } + + override fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) { + callback.navigateToPreviewAttachments(attachments, inReplyToEventId) + } + + override fun navigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List) { + if (roomId == room.roomId) { + displaySameRoomToast() + } else { + val permalinkData = PermalinkData.RoomLink(roomId.toRoomIdOrAlias(), eventId, viaParameters = serverNames.toImmutableList()) + callback.handlePermalinkClick(permalinkData) + } + } + + override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { + callback.navigateToThread(threadRootId, focusedEventId) + } + + private fun displaySameRoomToast() { + context.toast(CommonStrings.screen_room_permalink_same_room_android) + } + + override fun close() = navigateUp() + + @Composable + override fun View(modifier: Modifier) { + val activity = requireNotNull(LocalActivity.current) + val isDark = ElementTheme.isLightTheme.not() + CompositionLocalProvider( + LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, + ) { + val state = presenter.present() + + BackHandler { + state.eventSink(MessagesEvents.MarkAsFullyReadAndExit) + } + + OnLifecycleEvent { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> state.composerState.eventSink(MessageComposerEvent.SaveDraft) + else -> Unit + } + } + MessagesView( + state = state, + onBackClick = { state.eventSink(MessagesEvents.MarkAsFullyReadAndExit) }, + onRoomDetailsClick = callback::navigateToRoomDetails, + onEventContentClick = { isLive, event -> + if (isLive) { + callback.handleEventClick(timelineController.mainTimelineMode(), event) + } else { + val detachedTimelineMode = timelineController.detachedTimelineMode() + if (detachedTimelineMode != null) { + callback.handleEventClick(detachedTimelineMode, event) + } else { + false + } + } + }, + onUserDataClick = callback::navigateToRoomMemberDetails, + onLinkClick = { url, customTab -> + onLinkClick( + activity = activity, + darkTheme = isDark, + url = url, + eventSink = state.timelineState.eventSink, + customTab = customTab, + ) + }, + onSendLocationClick = callback::navigateToSendLocation, + onCreatePollClick = callback::navigateToCreatePoll, + onJoinCallClick = { callback.navigateToRoomCall(room.roomId) }, + onViewAllPinnedMessagesClick = callback::navigateToPinnedMessagesList, + modifier = modifier, + knockRequestsBannerView = { + knockRequestsBannerRenderer.View( + modifier = Modifier, + onViewRequestsClick = callback::navigateToKnockRequestsList, + ) + }, + ) + roomMemberModerationRenderer.Render( + state = state.roomMemberModerationState, + onSelectAction = { action, target -> + when (action) { + is ModerationAction.DisplayProfile -> callback.navigateToRoomMemberDetails(target.userId) + else -> state.roomMemberModerationState.eventSink(RoomMemberModerationEvents.ProcessAction(action, target)) + } + }, + modifier = Modifier, + ) + + var focusedEventId by rememberSaveable { + mutableStateOf(inputs.focusedEventId) + } + LaunchedEffect(focusedEventId) { + if (focusedEventId != null) { + state.timelineState.eventSink(TimelineEvents.FocusOnEvent(focusedEventId!!)) + focusedEventId = null + } + } + } + } +} 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 0000000..e912722 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -0,0 +1,586 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.State +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 androidx.lifecycle.compose.LifecycleResumeEffect +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.PinUnpinAction +import io.element.android.appconfig.MessageComposerConfig +import io.element.android.features.messages.api.timeline.HtmlConverterProvider +import io.element.android.features.messages.impl.actionlist.ActionListEvents +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.crypto.identity.IdentityChangeState +import io.element.android.features.messages.impl.link.LinkState +import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvent +import io.element.android.features.messages.impl.messagecomposer.MessageComposerState +import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState +import io.element.android.features.messages.impl.timeline.MarkAsFullyRead +import io.element.android.features.messages.impl.timeline.TimelineController +import io.element.android.features.messages.impl.timeline.TimelineEvents +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.reactionsummary.ReactionSummaryState +import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent +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.protection.TimelineProtectionState +import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter +import io.element.android.features.roomcall.api.RoomCallState +import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents +import io.element.android.features.roommembermoderation.api.RoomMemberModerationState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.flatMap +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.core.meta.BuildMeta +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.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.core.toThreadId +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin +import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther +import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn +import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.ui.messages.reply.map +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.matrix.ui.room.getDirectRoomMember +import io.element.android.libraries.recentemojis.api.AddRecentEmoji +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean + +@AssistedInject +class MessagesPresenter( + @Assisted private val navigator: MessagesNavigator, + private val room: JoinedRoom, + @Assisted private val composerPresenter: Presenter, + voiceMessageComposerPresenterFactory: DefaultVoiceMessageComposerPresenter.Factory, + @Assisted private val timelinePresenter: Presenter, + private val timelineProtectionPresenter: Presenter, + private val identityChangeStatePresenter: Presenter, + private val linkPresenter: Presenter, + @Assisted private val actionListPresenter: Presenter, + private val customReactionPresenter: Presenter, + private val reactionSummaryPresenter: Presenter, + private val readReceiptBottomSheetPresenter: Presenter, + private val pinnedMessagesBannerPresenter: Presenter, + private val roomCallStatePresenter: Presenter, + private val roomMemberModerationPresenter: Presenter, + private val snackbarDispatcher: SnackbarDispatcher, + private val dispatchers: CoroutineDispatchers, + private val clipboardHelper: ClipboardHelper, + private val htmlConverterProvider: HtmlConverterProvider, + private val buildMeta: BuildMeta, + @Assisted private val timelineController: TimelineController, + private val permalinkParser: PermalinkParser, + private val analyticsService: AnalyticsService, + private val encryptionService: EncryptionService, + private val featureFlagService: FeatureFlagService, + private val addRecentEmoji: AddRecentEmoji, + private val markAsFullyRead: MarkAsFullyRead, + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, +) : Presenter { + @AssistedFactory + interface Factory { + fun create( + navigator: MessagesNavigator, + composerPresenter: Presenter, + timelinePresenter: Presenter, + actionListPresenter: Presenter, + timelineController: TimelineController, + ): MessagesPresenter + } + + private val voiceMessageComposerPresenter = voiceMessageComposerPresenterFactory.create( + timelineMode = timelineController.mainTimelineMode() + ) + + private val markingAsReadAndExiting = AtomicBoolean(false) + + @Composable + override fun present(): MessagesState { + htmlConverterProvider.Update() + + val coroutineScope = rememberCoroutineScope() + val roomInfo by room.roomInfoFlow.collectAsState() + val localCoroutineScope = rememberCoroutineScope() + val composerState = composerPresenter.present() + val voiceMessageComposerState = voiceMessageComposerPresenter.present() + val timelineState = timelinePresenter.present() + val timelineProtectionState = timelineProtectionPresenter.present() + val identityChangeState = identityChangeStatePresenter.present() + val actionListState = actionListPresenter.present() + val linkState = linkPresenter.present() + val customReactionState = customReactionPresenter.present() + val reactionSummaryState = reactionSummaryPresenter.present() + val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present() + val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present() + val roomCallState = roomCallStatePresenter.present() + val roomMemberModerationState = roomMemberModerationPresenter.present() + + val userEventPermissions by userEventPermissions(roomInfo) + + val roomAvatar by remember { + derivedStateOf { roomInfo.avatarData() } + } + val heroes by remember { + derivedStateOf { roomInfo.heroes().toImmutableList() } + } + + var hasDismissedInviteDialog by rememberSaveable { + mutableStateOf(false) + } + LaunchedEffect(Unit) { + // Remove the unread flag on entering but don't send read receipts + // as those will be handled by the timeline. + withContext(dispatchers.io) { + room.setUnreadFlag(isUnread = false) + + // If for some reason the encryption state is unknown, fetch it + if (roomInfo.isEncrypted == null) { + room.getUpdatedIsEncrypted() + } + } + } + + val inviteProgress = remember { mutableStateOf>(AsyncData.Uninitialized) } + var showReinvitePrompt by remember { mutableStateOf(false) } + val composerHasFocus by remember { derivedStateOf { composerState.textEditorState.hasFocus() } } + LaunchedEffect(hasDismissedInviteDialog, composerHasFocus, roomInfo) { + withContext(dispatchers.io) { + showReinvitePrompt = !hasDismissedInviteDialog && composerHasFocus && roomInfo.isDm && roomInfo.activeMembersCount == 1L + } + } + + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + + var dmUserVerificationState by remember { mutableStateOf(null) } + + val membersState by room.membersStateFlow.collectAsState() + val dmRoomMember by room.getDirectRoomMember(membersState) + val roomMemberIdentityStateChanges = identityChangeState.roomMemberIdentityStateChanges + + LifecycleResumeEffect(dmRoomMember, roomInfo.isEncrypted) { + if (roomInfo.isEncrypted == true) { + val dmRoomMemberId = dmRoomMember?.userId + localCoroutineScope.launch { + dmRoomMemberId?.let { userId -> + dmUserVerificationState = roomMemberIdentityStateChanges.find { it.identityRoomMember.userId == userId }?.identityState + ?: encryptionService.getUserIdentity(userId).getOrNull() + } + } + } + onPauseOrDispose {} + } + + fun handleEvent(event: MessagesEvents) { + when (event) { + is MessagesEvents.HandleAction -> { + localCoroutineScope.handleTimelineAction( + action = event.action, + targetEvent = event.event, + composerState = composerState, + enableTextFormatting = composerState.showTextFormatting, + timelineState = timelineState, + timelineProtectionState = timelineProtectionState, + ) + } + is MessagesEvents.ToggleReaction -> { + localCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId) + } + is MessagesEvents.InviteDialogDismissed -> { + hasDismissedInviteDialog = true + + if (event.action == InviteDialogAction.Invite) { + localCoroutineScope.reinviteOtherUser(inviteProgress) + } + } + is MessagesEvents.Dismiss -> actionListState.eventSink(ActionListEvents.Clear) + is MessagesEvents.OnUserClicked -> { + roomMemberModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.user)) + } + is MessagesEvents.MarkAsFullyReadAndExit -> coroutineScope.launch { + if (!markingAsReadAndExiting.getAndSet(true)) { + val latestEventId = room.liveTimeline.getLatestEventId().getOrElse { + Timber.w(it, "Failed to get latest event id to mark as fully read") + navigator.close() + return@launch + } + latestEventId?.let { eventId -> + sessionCoroutineScope.launch { + markAsFullyRead(room.roomId, eventId) + } + } + navigator.close() + markingAsReadAndExiting.set(false) + } + } + } + } + + return MessagesState( + roomId = room.roomId, + roomName = roomInfo.name, + roomAvatar = roomAvatar, + heroes = heroes, + userEventPermissions = userEventPermissions, + composerState = composerState, + voiceMessageComposerState = voiceMessageComposerState, + timelineState = timelineState, + timelineProtectionState = timelineProtectionState, + identityChangeState = identityChangeState, + linkState = linkState, + actionListState = actionListState, + customReactionState = customReactionState, + reactionSummaryState = reactionSummaryState, + readReceiptBottomSheetState = readReceiptBottomSheetState, + snackbarMessage = snackbarMessage, + inviteProgress = inviteProgress.value, + showReinvitePrompt = showReinvitePrompt, + enableTextFormatting = MessageComposerConfig.ENABLE_RICH_TEXT_EDITING, + roomCallState = roomCallState, + appName = buildMeta.applicationName, + pinnedMessagesBannerState = pinnedMessagesBannerState, + dmUserVerificationState = dmUserVerificationState, + roomMemberModerationState = roomMemberModerationState, + successorRoom = roomInfo.successorRoom, + eventSink = ::handleEvent, + ) + } + + @Composable + private fun userEventPermissions(roomInfo: RoomInfo): State { + val key = if (roomInfo.privilegedCreatorRole && roomInfo.creators.contains(room.sessionId)) { + Long.MAX_VALUE + } else { + roomInfo.roomPowerLevels?.hashCode() ?: 0L + } + return produceState(UserEventPermissions.DEFAULT, key1 = key) { + value = UserEventPermissions( + canSendMessage = room.canSendMessage(type = MessageEventType.RoomMessage).getOrElse { true }, + canSendReaction = room.canSendMessage(type = MessageEventType.Reaction).getOrElse { true }, + canRedactOwn = room.canRedactOwn().getOrElse { false }, + canRedactOther = room.canRedactOther().getOrElse { false }, + canPinUnpin = room.canPinUnpin().getOrElse { false }, + ) + } + } + + private fun RoomInfo.avatarData(): AvatarData { + return AvatarData( + id = id.value, + name = name, + url = avatarUrl, + size = AvatarSize.TimelineRoom + ) + } + + private fun RoomInfo.heroes(): List { + return heroes.map { user -> + user.getAvatarData(size = AvatarSize.TimelineRoom) + } + } + + private fun CoroutineScope.handleTimelineAction( + action: TimelineItemAction, + targetEvent: TimelineItem.Event, + composerState: MessageComposerState, + timelineProtectionState: TimelineProtectionState, + enableTextFormatting: Boolean, + timelineState: TimelineState, + ) = launch { + when (action) { + TimelineItemAction.CopyText -> handleCopyContents(targetEvent) + TimelineItemAction.CopyCaption -> handleCopyCaption(targetEvent) + TimelineItemAction.CopyLink -> handleCopyLink(targetEvent) + TimelineItemAction.Redact -> handleActionRedact(targetEvent) + TimelineItemAction.Edit, + TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting) + TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState) + TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState) + TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent) + TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState, timelineProtectionState) + TimelineItemAction.ReplyInThread -> { + val displayThreads = featureFlagService.isFeatureEnabled(FeatureFlags.Threads) + if (displayThreads) { + // Get either the thread id this event is in, or the event id if it's not in a thread so we can start one + val threadId = when (targetEvent.threadInfo) { + is TimelineItemThreadInfo.ThreadResponse -> targetEvent.threadInfo.threadRootId + is TimelineItemThreadInfo.ThreadRoot, null -> targetEvent.eventId?.toThreadId() + } ?: return@launch + navigator.navigateToThread(threadId, null) + } else { + handleActionReply(targetEvent, composerState, timelineProtectionState) + } + } + TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent) + TimelineItemAction.Forward -> handleForwardAction(targetEvent) + TimelineItemAction.ReportContent -> handleReportAction(targetEvent) + TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState) + TimelineItemAction.Pin -> handlePinAction(targetEvent) + TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) + TimelineItemAction.ViewInTimeline -> Unit + } + } + + private suspend fun handleRemoveCaption(targetEvent: TimelineItem.Event) { + timelineController.invokeOnCurrentTimeline { + editCaption( + eventOrTransactionId = targetEvent.eventOrTransactionId, + caption = null, + formattedCaption = null, + ) + } + } + + private suspend fun handlePinAction(targetEvent: TimelineItem.Event) { + if (targetEvent.eventId == null) return + analyticsService.capture( + PinUnpinAction( + from = PinUnpinAction.From.Timeline, + kind = PinUnpinAction.Kind.Pin, + ) + ) + timelineController.invokeOnCurrentTimeline { + pinEvent(targetEvent.eventId) + .onFailure { + Timber.e(it, "Failed to pin event ${targetEvent.eventId}") + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error)) + } + } + } + + private suspend fun handleUnpinAction(targetEvent: TimelineItem.Event) { + if (targetEvent.eventId == null) return + analyticsService.capture( + PinUnpinAction( + from = PinUnpinAction.From.Timeline, + kind = PinUnpinAction.Kind.Unpin, + ) + ) + timelineController.invokeOnCurrentTimeline { + unpinEvent(targetEvent.eventId) + .onFailure { + Timber.e(it, "Failed to unpin event ${targetEvent.eventId}") + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error)) + } + } + } + + private fun CoroutineScope.toggleReaction( + emoji: String, + eventOrTransactionId: EventOrTransactionId, + ) = launch(dispatchers.io) { + timelineController.invokeOnCurrentTimeline { + toggleReaction(emoji, eventOrTransactionId) + .flatMap { added -> if (added) addRecentEmoji(emoji) else Result.success(Unit) } + .onFailure { Timber.e(it) } + } + } + + private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState>) = launch(dispatchers.io) { + inviteProgress.value = AsyncData.Loading() + runCatchingExceptions { + val memberList = when (val memberState = room.membersStateFlow.value) { + is RoomMembersState.Ready -> memberState.roomMembers + is RoomMembersState.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() + }.fold( + onSuccess = { + inviteProgress.value = AsyncData.Success(Unit) + }, + onFailure = { + inviteProgress.value = AsyncData.Failure(it) + } + ) + } + + private suspend fun handleActionRedact(event: TimelineItem.Event) { + timelineController.invokeOnCurrentTimeline { + redactEvent(eventOrTransactionId = event.eventOrTransactionId, reason = null) + .onFailure { Timber.e(it) } + } + } + + private fun handleActionEdit( + targetEvent: TimelineItem.Event, + composerState: MessageComposerState, + enableTextFormatting: Boolean, + ) { + when (targetEvent.content) { + is TimelineItemPollContent -> { + if (targetEvent.eventId == null) return + navigator.navigateToEditPoll(targetEvent.eventId) + } + else -> { + val composerMode = MessageComposerMode.Edit( + targetEvent.eventOrTransactionId, + (targetEvent.content as? TimelineItemTextBasedContent)?.let { + if (enableTextFormatting) { + it.htmlBody ?: it.body + } else { + it.body + } + }.orEmpty(), + ) + composerState.eventSink( + MessageComposerEvent.SetMode(composerMode) + ) + } + } + } + + private suspend fun handleActionAddCaption( + targetEvent: TimelineItem.Event, + composerState: MessageComposerState, + ) { + val composerMode = MessageComposerMode.EditCaption( + eventOrTransactionId = targetEvent.eventOrTransactionId, + content = "", + ) + composerState.eventSink( + MessageComposerEvent.SetMode(composerMode) + ) + } + + private suspend fun handleActionEditCaption( + targetEvent: TimelineItem.Event, + composerState: MessageComposerState, + ) { + val composerMode = MessageComposerMode.EditCaption( + eventOrTransactionId = targetEvent.eventOrTransactionId, + content = (targetEvent.content as? TimelineItemEventContentWithAttachment)?.caption.orEmpty(), + ) + composerState.eventSink( + MessageComposerEvent.SetMode(composerMode) + ) + } + + private suspend fun handleActionReply( + targetEvent: TimelineItem.Event, + composerState: MessageComposerState, + timelineProtectionState: TimelineProtectionState, + ) { + if (targetEvent.eventId == null) return + timelineController.invokeOnCurrentTimeline { + val replyToDetails = loadReplyDetails(targetEvent.eventId).map(permalinkParser) + val composerMode = MessageComposerMode.Reply( + replyToDetails = replyToDetails, + hideImage = timelineProtectionState.hideMediaContent(targetEvent.eventId), + ) + composerState.eventSink( + MessageComposerEvent.SetMode(composerMode) + ) + } + } + + private fun handleShowDebugInfoAction(event: TimelineItem.Event) { + navigator.navigateToEventDebugInfo(event.eventId, event.debugInfo) + } + + private fun handleForwardAction(event: TimelineItem.Event) { + if (event.eventId == null) return + navigator.forwardEvent(event.eventId) + } + + private fun handleReportAction(event: TimelineItem.Event) { + if (event.eventId == null) return + navigator.navigateToReportMessage(event.eventId, event.senderId) + } + + private fun handleEndPollAction( + event: TimelineItem.Event, + timelineState: TimelineState, + ) { + event.eventId?.let { timelineState.eventSink(TimelineEvents.EndPoll(it)) } + } + + private suspend fun handleCopyLink(event: TimelineItem.Event) { + event.eventId ?: return + room.getPermalinkFor(event.eventId).fold( + onSuccess = { permalink -> + clipboardHelper.copyPlainText(permalink) + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_link_copied_to_clipboard)) + }, + onFailure = { + Timber.e(it, "Failed to get permalink for event ${event.eventId}") + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error)) + } + ) + } + + private 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_timeline_message_copied)) + } + } + + private fun handleCopyCaption(event: TimelineItem.Event) { + val content = (event.content as? TimelineItemEventContentWithAttachment)?.caption ?: return + clipboardHelper.copyPlainText(content) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + } + } +} 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 0000000..9faf2f6 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl + +import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState +import io.element.android.features.messages.impl.actionlist.ActionListState +import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState +import io.element.android.features.messages.impl.link.LinkState +import io.element.android.features.messages.impl.messagecomposer.MessageComposerState +import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState +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.reactionsummary.ReactionSummaryState +import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.roomcall.api.RoomCallState +import io.element.android.features.roommembermoderation.api.RoomMemberModerationState +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom +import kotlinx.collections.immutable.ImmutableList + +data class MessagesState( + val roomId: RoomId, + val roomName: String?, + val roomAvatar: AvatarData, + val heroes: ImmutableList, + val userEventPermissions: UserEventPermissions, + val composerState: MessageComposerState, + val voiceMessageComposerState: VoiceMessageComposerState, + val timelineState: TimelineState, + val timelineProtectionState: TimelineProtectionState, + val identityChangeState: IdentityChangeState, + val linkState: LinkState, + val actionListState: ActionListState, + val customReactionState: CustomReactionState, + val reactionSummaryState: ReactionSummaryState, + val readReceiptBottomSheetState: ReadReceiptBottomSheetState, + val snackbarMessage: SnackbarMessage?, + val inviteProgress: AsyncData, + val showReinvitePrompt: Boolean, + val enableTextFormatting: Boolean, + val roomCallState: RoomCallState, + val appName: String, + val pinnedMessagesBannerState: PinnedMessagesBannerState, + val dmUserVerificationState: IdentityState?, + val roomMemberModerationState: RoomMemberModerationState, + val successorRoom: SuccessorRoom?, + val eventSink: (MessagesEvents) -> Unit +) { + val isTombstoned = successorRoom != null +} 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 0000000..3a077e6 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState +import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState +import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessagePreviewState +import io.element.android.features.messages.impl.actionlist.ActionListState +import io.element.android.features.messages.impl.actionlist.anActionListState +import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState +import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState +import io.element.android.features.messages.impl.link.LinkState +import io.element.android.features.messages.impl.link.aLinkState +import io.element.android.features.messages.impl.messagecomposer.MessageComposerState +import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState +import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState +import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState +import io.element.android.features.messages.impl.timeline.TimelineState +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.CustomReactionEvents +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState +import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents +import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState +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.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState +import io.element.android.features.roomcall.api.RoomCallState +import io.element.android.features.roomcall.api.aStandByCallState +import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents +import io.element.android.features.roommembermoderation.api.RoomMemberModerationState +import io.element.android.libraries.architecture.AsyncData +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.ThreadId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.textcomposer.model.aTextEditorStateRich +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf + +open class MessagesStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aMessagesState(), + aMessagesState(composerState = aMessageComposerState(showAttachmentSourcePicker = true)), + aMessagesState(userEventPermissions = aUserEventPermissions(canSendMessage = false)), + aMessagesState(showReinvitePrompt = true), + aMessagesState(composerState = aMessageComposerState(showTextFormatting = true)), + aMessagesState( + voiceMessageComposerState = aVoiceMessageComposerState(showPermissionRationaleDialog = true), + ), + aMessagesState( + voiceMessageComposerState = aVoiceMessageComposerState( + voiceMessageState = aVoiceMessagePreviewState(), + showSendFailureDialog = true + ), + ), + aMessagesState( + pinnedMessagesBannerState = aLoadedPinnedMessagesBannerState( + knownPinnedMessagesCount = 4, + currentPinnedMessageIndex = 0, + ), + ), + aMessagesState(successorRoom = SuccessorRoom(RoomId("!id:domain"), null)), + aMessagesState( + timelineState = aTimelineState( + timelineMode = Timeline.Mode.Thread(threadRootId = ThreadId("\$a-thread-id")), + timelineItems = aTimelineItemList(aTimelineItemTextContent()), + ) + ), + ) +} + +fun aMessagesState( + roomName: String? = "Room name", + roomAvatar: AvatarData = AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom), + userEventPermissions: UserEventPermissions = aUserEventPermissions(), + composerState: MessageComposerState = aMessageComposerState( + textEditorState = aTextEditorStateRich(initialText = "Hello", initialFocus = true), + isFullScreen = false, + mode = MessageComposerMode.Normal, + ), + voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(), + timelineState: TimelineState = aTimelineState( + timelineItems = aTimelineItemList(aTimelineItemTextContent()), + // Render a focused event for an event with sender information displayed + focusedEventIndex = 2, + ), + timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(), + identityChangeState: IdentityChangeState = anIdentityChangeState(), + linkState: LinkState = aLinkState(), + readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(), + actionListState: ActionListState = anActionListState(), + customReactionState: CustomReactionState = aCustomReactionState(), + reactionSummaryState: ReactionSummaryState = aReactionSummaryState(), + showReinvitePrompt: Boolean = false, + roomCallState: RoomCallState = aStandByCallState(), + pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(), + dmUserVerificationState: IdentityState? = null, + roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(), + successorRoom: SuccessorRoom? = null, + eventSink: (MessagesEvents) -> Unit = {}, +) = MessagesState( + roomId = RoomId("!id:domain"), + roomName = roomName, + roomAvatar = roomAvatar, + heroes = persistentListOf(), + userEventPermissions = userEventPermissions, + composerState = composerState, + voiceMessageComposerState = voiceMessageComposerState, + timelineProtectionState = timelineProtectionState, + identityChangeState = identityChangeState, + linkState = linkState, + timelineState = timelineState, + readReceiptBottomSheetState = readReceiptBottomSheetState, + actionListState = actionListState, + customReactionState = customReactionState, + reactionSummaryState = reactionSummaryState, + snackbarMessage = null, + inviteProgress = AsyncData.Uninitialized, + showReinvitePrompt = showReinvitePrompt, + enableTextFormatting = true, + roomCallState = roomCallState, + appName = "Element", + pinnedMessagesBannerState = pinnedMessagesBannerState, + dmUserVerificationState = dmUserVerificationState, + roomMemberModerationState = roomMemberModerationState, + successorRoom = successorRoom, + eventSink = eventSink, +) + +fun aRoomMemberModerationState( + canKick: Boolean = false, + canBan: Boolean = false, +) = object : RoomMemberModerationState { + override val canKick: Boolean = canKick + override val canBan: Boolean = canBan + override val eventSink: (RoomMemberModerationEvents) -> Unit = {} +} + +fun aUserEventPermissions( + canRedactOwn: Boolean = false, + canRedactOther: Boolean = false, + canSendMessage: Boolean = true, + canSendReaction: Boolean = true, + canPinUnpin: Boolean = false, +) = UserEventPermissions( + canRedactOwn = canRedactOwn, + canRedactOther = canRedactOther, + canSendMessage = canSendMessage, + canSendReaction = canSendReaction, + canPinUnpin = canPinUnpin, +) + +fun aReactionSummaryState( + target: ReactionSummaryState.Summary? = null, + eventSink: (ReactionSummaryEvents) -> Unit = {} +) = ReactionSummaryState( + target = target, + eventSink = eventSink, +) + +fun aCustomReactionState( + target: CustomReactionState.Target = CustomReactionState.Target.None, + recentEmojis: ImmutableList = persistentListOf(), + eventSink: (CustomReactionEvents) -> Unit = {}, +) = CustomReactionState( + target = target, + recentEmojis = recentEmojis, + selectedEmoji = persistentSetOf(), + eventSink = eventSink, +) + +fun aReadReceiptBottomSheetState( + selectedEvent: TimelineItem.Event? = null, + eventSink: (ReadReceiptBottomSheetEvents) -> Unit = {}, +) = ReadReceiptBottomSheetState( + selectedEvent = selectedEvent, + eventSink = 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 0000000..03b0460 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -0,0 +1,552 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +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.Row +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.heightIn +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.systemBarsPadding +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.draw.shadow +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent +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.crypto.identity.IdentityChangeStateView +import io.element.android.features.messages.impl.link.LinkEvents +import io.element.android.features.messages.impl.link.LinkView +import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet +import io.element.android.features.messages.impl.messagecomposer.DisabledComposerView +import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvent +import io.element.android.features.messages.impl.messagecomposer.MessageComposerView +import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsPickerView +import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState +import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView +import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerViewDefaults +import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS +import io.element.android.features.messages.impl.timeline.TimelineEvents +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.reactionsummary.ReactionSummaryEvents +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView +import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet +import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.topbars.MessagesViewTopBar +import io.element.android.features.messages.impl.topbars.ThreadTopBar +import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog +import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog +import io.element.android.libraries.androidutils.ui.hideKeyboard +import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule +import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayout +import io.element.android.libraries.designsystem.components.ExpandableBottomSheetLayoutState +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.components.rememberExpandableBottomSheetLayoutState +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.toAnnotatedString +import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.HideKeyboardWhenDisposed +import io.element.android.libraries.designsystem.utils.KeepScreenOn +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost +import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState +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.encryption.identity.IdentityState +import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.textcomposer.model.TextEditorState +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.wysiwyg.link.Link +import timber.log.Timber +import kotlin.time.Duration.Companion.milliseconds + +@Composable +fun MessagesView( + state: MessagesState, + onBackClick: () -> Unit, + onRoomDetailsClick: () -> Unit, + onEventContentClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean, + onUserDataClick: (UserId) -> Unit, + onLinkClick: (String, Boolean) -> Unit, + onSendLocationClick: () -> Unit, + onCreatePollClick: () -> Unit, + onJoinCallClick: () -> Unit, + onViewAllPinnedMessagesClick: () -> Unit, + modifier: Modifier = Modifier, + forceJumpToBottomVisibility: Boolean = false, + knockRequestsBannerView: @Composable () -> Unit, +) { + OnLifecycleEvent { _, event -> + state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.LifecycleEvent(event)) + } + + KeepScreenOn(state.voiceMessageComposerState.keepScreenOn) + + HideKeyboardWhenDisposed() + + 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 + + fun hidingKeyboard(block: () -> Unit) { + localView.hideKeyboard() + block() + } + + fun onContentClick(event: TimelineItem.Event) { + Timber.v("onMessageClick= ${event.id}") + val hideKeyboard = onEventContentClick(state.timelineState.isLive, event) + if (hideKeyboard) { + localView.hideKeyboard() + } + } + + fun onMessageLongClick(event: TimelineItem.Event) { + Timber.v("OnMessageLongClicked= ${event.id}") + hidingKeyboard { + state.actionListState.eventSink( + ActionListEvents.ComputeForMessage( + event = event, + userEventPermissions = state.userEventPermissions, + ) + ) + } + } + + fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) { + state.eventSink(MessagesEvents.HandleAction(action, event)) + } + + fun onEmojiReactionClick(emoji: String, event: TimelineItem.Event) { + state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventOrTransactionId)) + } + + fun onEmojiReactionLongClick(emoji: String, event: TimelineItem.Event) { + if (event.eventId == null) return + state.reactionSummaryState.eventSink(ReactionSummaryEvents.ShowReactionSummary(event.eventId, event.reactionsState.reactions, emoji)) + } + + fun onMoreReactionsClick(event: TimelineItem.Event) { + state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event)) + } + + val expandableState = rememberExpandableBottomSheetLayoutState() + ExpandableBottomSheetLayout( + modifier = modifier + .fillMaxSize() + .imePadding() + .systemBarsPadding(), + content = { + Scaffold( + contentWindowInsets = WindowInsets.statusBars, + topBar = { + if (state.timelineState.timelineMode is Timeline.Mode.Thread) { + ThreadTopBar( + roomName = state.roomName, + roomAvatarData = state.roomAvatar, + heroes = state.heroes, + isTombstoned = state.isTombstoned, + onBackClick = onBackClick, + ) + } else { + MessagesViewTopBar( + roomName = state.roomName, + roomAvatar = state.roomAvatar, + isTombstoned = state.isTombstoned, + heroes = state.heroes, + roomCallState = state.roomCallState, + dmUserIdentityState = state.dmUserVerificationState, + onBackClick = { hidingKeyboard { onBackClick() } }, + onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } }, + onJoinCallClick = onJoinCallClick, + ) + } + }, + content = { padding -> + Box( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + ) { + MessagesViewContent( + state = state, + onContentClick = ::onContentClick, + onMessageLongClick = ::onMessageLongClick, + onUserDataClick = { + hidingKeyboard { + state.eventSink(MessagesEvents.OnUserClicked(it)) + } + }, + onLinkClick = { link, customTab -> + if (customTab) { + onLinkClick(link.url, true) + // Do not check those links, they are internal link only + } else { + state.linkState.eventSink(LinkEvents.OnLinkClick(link)) + } + }, + onReactionClick = ::onEmojiReactionClick, + onReactionLongClick = ::onEmojiReactionLongClick, + onMoreReactionsClick = ::onMoreReactionsClick, + onReadReceiptClick = { event -> + state.readReceiptBottomSheetState.eventSink(ReadReceiptBottomSheetEvents.EventSelected(event)) + }, + onSendLocationClick = onSendLocationClick, + onCreatePollClick = onCreatePollClick, + onSwipeToReply = { targetEvent -> + state.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, targetEvent)) + }, + forceJumpToBottomVisibility = forceJumpToBottomVisibility, + onJoinCallClick = onJoinCallClick, + onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick, + knockRequestsBannerView = knockRequestsBannerView, + ) + + SuggestionsPickerView( + modifier = Modifier + .shadow(10.dp) + .background(ElementTheme.colors.bgCanvasDefault) + .align(Alignment.BottomStart) + .heightIn(max = 230.dp), + roomId = state.roomId, + roomName = state.roomName, + roomAvatarData = state.roomAvatar, + suggestions = state.composerState.suggestions, + onSelectSuggestion = { + state.composerState.eventSink(MessageComposerEvent.InsertSuggestion(it)) + } + ) + } + }, + snackbarHost = { + SnackbarHost( + snackbarHostState, + modifier = Modifier.navigationBarsPadding() + ) + }, + ) + }, + bottomSheetContent = { + MessagesViewComposerBottomSheetContents( + state = state, + onLinkClick = { url, customTab -> onLinkClick(url, customTab) }, + onRoomSuccessorClick = { roomId -> + state.timelineState.eventSink(TimelineEvents.NavigateToPredecessorOrSuccessorRoom(roomId = roomId)) + }, + ) + }, + sheetDragHandle = @Composable { toggleAction -> + if (state.composerState.showTextFormatting) { + val expandA11yLabel = stringResource(CommonStrings.a11y_expand_message_text_field) + val collapseA11yLabel = stringResource(CommonStrings.a11y_collapse_message_text_field) + BottomSheetDragHandle( + modifier = Modifier.semantics { + role = Role.Button + // Accessibility action to toggle the bottom sheet state + val label = when (expandableState.position) { + ExpandableBottomSheetLayoutState.Position.COLLAPSED, ExpandableBottomSheetLayoutState.Position.DRAGGING -> expandA11yLabel + ExpandableBottomSheetLayoutState.Position.EXPANDED -> collapseA11yLabel + } + onClick(label) { + toggleAction() + true + } + } + ) + } else { + LaunchedEffect(Unit) { + // Ensure that the bottom sheet is collapsed + if (expandableState.position == ExpandableBottomSheetLayoutState.Position.EXPANDED) { + toggleAction() + } + } + } + }, + isSwipeGestureEnabled = state.composerState.showTextFormatting, + state = expandableState, + sheetShape = if (state.composerState.showTextFormatting || state.composerState.suggestions.isNotEmpty()) { + MaterialTheme.shapes.large + } else { + RectangleShape + }, + maxBottomSheetContentHeight = 360.dp, + ) + + ActionListView( + state = state.actionListState, + onSelectAction = ::onActionSelected, + onCustomReactionClick = { event -> + state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event)) + }, + onEmojiReactionClick = ::onEmojiReactionClick, + onVerifiedUserSendFailureClick = { event -> + state.timelineState.eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event)) + }, + ) + + CustomReactionBottomSheet( + state = state.customReactionState, + onSelectEmoji = { uniqueId, emoji -> + state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, uniqueId)) + } + ) + + ReactionSummaryView(state = state.reactionSummaryState) + ReadReceiptBottomSheet( + state = state.readReceiptBottomSheetState, + onUserDataClick = onUserDataClick, + ) + ReinviteDialog(state = state) + LinkView( + onLinkValid = { link -> + onLinkClick(link.url, false) + }, + state = state.linkState, + ) +} + +@Composable +private 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), + onSubmitClick = { state.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) }, + onDismiss = { state.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Cancel)) } + ) + } +} + +@Composable +private fun MessagesViewContent( + state: MessagesState, + onContentClick: (TimelineItem.Event) -> Unit, + onUserDataClick: (MatrixUser) -> Unit, + onLinkClick: (Link, Boolean) -> Unit, + onReactionClick: (key: String, TimelineItem.Event) -> Unit, + onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, + onMoreReactionsClick: (TimelineItem.Event) -> Unit, + onReadReceiptClick: (TimelineItem.Event) -> Unit, + onMessageLongClick: (TimelineItem.Event) -> Unit, + onSendLocationClick: () -> Unit, + onCreatePollClick: () -> Unit, + onJoinCallClick: () -> Unit, + onViewAllPinnedMessagesClick: () -> Unit, + forceJumpToBottomVisibility: Boolean, + onSwipeToReply: (TimelineItem.Event) -> Unit, + modifier: Modifier = Modifier, + knockRequestsBannerView: @Composable () -> Unit, +) { + Box( + modifier = modifier + .fillMaxSize() + .navigationBarsPadding() + .imePadding(), + ) { + AttachmentsBottomSheet( + state = state.composerState, + onSendLocationClick = onSendLocationClick, + onCreatePollClick = onCreatePollClick, + enableTextFormatting = state.enableTextFormatting, + ) + + if (state.voiceMessageComposerState.showPermissionRationaleDialog) { + VoiceMessagePermissionRationaleDialog( + onContinue = { + state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.AcceptPermissionRationale) + }, + onDismiss = { + state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.DismissPermissionsRationale) + }, + appName = state.appName + ) + } + if (state.voiceMessageComposerState.showSendFailureDialog) { + VoiceMessageSendingFailedDialog( + onDismiss = { state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvent.DismissSendFailureDialog) }, + ) + } + + Box { + val scrollBehavior = PinnedMessagesBannerViewDefaults.rememberScrollBehavior( + pinnedMessagesCount = (state.pinnedMessagesBannerState as? PinnedMessagesBannerState.Visible)?.pinnedMessagesCount() ?: 0, + ) + TimelineView( + state = state.timelineState, + timelineProtectionState = state.timelineProtectionState, + onUserDataClick = onUserDataClick, + onLinkClick = { link -> onLinkClick(link, false) }, + onContentClick = onContentClick, + onMessageLongClick = onMessageLongClick, + onSwipeToReply = onSwipeToReply, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, + onReadReceiptClick = onReadReceiptClick, + forceJumpToBottomVisibility = forceJumpToBottomVisibility, + onJoinCallClick = onJoinCallClick, + nestedScrollConnection = scrollBehavior.nestedScrollConnection, + ) + + if (state.timelineState.timelineMode !is Timeline.Mode.Thread) { + AnimatedVisibility( + visible = state.pinnedMessagesBannerState is PinnedMessagesBannerState.Visible && scrollBehavior.isVisible, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + fun focusOnPinnedEvent(eventId: EventId) { + state.timelineState.eventSink( + TimelineEvents.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds) + ) + } + PinnedMessagesBannerView( + state = state.pinnedMessagesBannerState, + onClick = ::focusOnPinnedEvent, + onViewAllClick = onViewAllPinnedMessagesClick, + ) + } + knockRequestsBannerView() + } + } + } +} + +@Composable +private fun MessagesViewComposerBottomSheetContents( + state: MessagesState, + onRoomSuccessorClick: (RoomId) -> Unit, + onLinkClick: (String, Boolean) -> Unit, +) { + when { + state.successorRoom != null -> { + SuccessorRoomBanner(roomSuccessor = state.successorRoom, onRoomSuccessorClick = onRoomSuccessorClick) + } + state.userEventPermissions.canSendMessage -> { + Column(modifier = Modifier.fillMaxWidth()) { + // Do not show the identity change if user is composing a Rich message or is seeing suggestion(s). + if (state.composerState.suggestions.isEmpty() && + state.composerState.textEditorState is TextEditorState.Markdown) { + IdentityChangeStateView( + state = state.identityChangeState, + onLinkClick = onLinkClick, + ) + } + val verificationViolation = state.identityChangeState.roomMemberIdentityStateChanges.firstOrNull { + it.identityState == IdentityState.VerificationViolation + } + if (verificationViolation != null) { + DisabledComposerView(modifier = Modifier.fillMaxWidth()) + } else { + MessageComposerView( + state = state.composerState, + voiceMessageState = state.voiceMessageComposerState, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + else -> { + CantSendMessageBanner() + } + } +} + +@Composable +private fun CantSendMessageBanner() { + Row( + modifier = Modifier + .fillMaxWidth() + .background(ElementTheme.colors.bgSubtleSecondary) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(id = R.string.screen_room_timeline_no_permission_to_post), + color = ElementTheme.colors.textSecondary, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + fontStyle = FontStyle.Italic, + ) + } +} + +@Composable +private fun SuccessorRoomBanner( + roomSuccessor: SuccessorRoom, + onRoomSuccessorClick: (RoomId) -> Unit, + modifier: Modifier = Modifier, +) { + ComposerAlertMolecule( + avatar = null, + content = stringResource(R.string.screen_room_timeline_tombstoned_room_message).toAnnotatedString(), + onSubmitClick = { onRoomSuccessorClick(roomSuccessor.roomId) }, + modifier = modifier, + submitText = stringResource(R.string.screen_room_timeline_tombstoned_room_action) + ) +} + +@PreviewsDayNight +@Composable +internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) = ElementPreview { + MessagesView( + state = state, + onBackClick = {}, + onRoomDetailsClick = {}, + onEventContentClick = { _, _ -> false }, + onUserDataClick = {}, + onLinkClick = { _, _ -> }, + onSendLocationClick = {}, + onCreatePollClick = {}, + onJoinCallClick = {}, + onViewAllPinnedMessagesClick = { }, + forceJumpToBottomVisibility = true, + knockRequestsBannerView = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/UserEventPermissions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/UserEventPermissions.kt new file mode 100644 index 0000000..f7d2219 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/UserEventPermissions.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl + +/** + * Represents the permissions a user has in a room. + * It's dependent of the user's power level in the room. + */ +data class UserEventPermissions( + val canRedactOwn: Boolean, + val canRedactOther: Boolean, + val canSendMessage: Boolean, + val canSendReaction: Boolean, + val canPinUnpin: Boolean, +) { + companion object { + val DEFAULT = UserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = false + ) + } +} 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 0000000..415a80a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.actionlist + +import io.element.android.features.messages.impl.UserEventPermissions +import io.element.android.features.messages.impl.timeline.model.TimelineItem + +sealed interface ActionListEvents { + data object Clear : ActionListEvents + data class ComputeForMessage( + val event: TimelineItem.Event, + val userEventPermissions: UserEventPermissions, + ) : 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 0000000..3616640 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.actionlist + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.messages.impl.UserEventPermissions +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionComparator +import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent +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.features.messages.impl.timeline.model.event.canBeForwarded +import io.element.android.features.messages.impl.timeline.model.event.canReact +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.recentemojis.api.GetRecentEmojis +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +interface ActionListPresenter : Presenter { + interface Factory { + fun create( + postProcessor: TimelineItemActionPostProcessor, + timelineMode: Timeline.Mode, + ): ActionListPresenter + } +} + +@AssistedInject +class DefaultActionListPresenter( + @Assisted + private val postProcessor: TimelineItemActionPostProcessor, + @Assisted + private val timelineMode: Timeline.Mode, + private val appPreferencesStore: AppPreferencesStore, + private val room: BaseRoom, + private val userSendFailureFactory: VerifiedUserSendFailureFactory, + private val dateFormatter: DateFormatter, + private val featureFlagService: FeatureFlagService, + private val getRecentEmojis: GetRecentEmojis, +) : ActionListPresenter { + @AssistedFactory + @ContributesBinding(RoomScope::class) + interface Factory : ActionListPresenter.Factory { + override fun create( + postProcessor: TimelineItemActionPostProcessor, + timelineMode: Timeline.Mode, + ): DefaultActionListPresenter + } + + private val comparator = TimelineItemActionComparator() + + private val suggestedEmojis = persistentListOf("👍️", "👎️", "🔥", "❤️", "👏") + + @Composable + override fun present(): ActionListState { + val localCoroutineScope = rememberCoroutineScope() + + val target: MutableState = remember { + mutableStateOf(ActionListState.Target.None) + } + + val isDeveloperModeEnabled by remember { + appPreferencesStore.isDeveloperModeEnabledFlow() + }.collectAsState(initial = false) + val pinnedEventIds by remember { + room.roomInfoFlow.map { it.pinnedEventIds } + }.collectAsState(initial = persistentListOf()) + + val isThreadsEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false) + + fun handleEvent(event: ActionListEvents) { + when (event) { + ActionListEvents.Clear -> target.value = ActionListState.Target.None + is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage( + timelineItem = event.event, + usersEventPermissions = event.userEventPermissions, + isDeveloperModeEnabled = isDeveloperModeEnabled, + pinnedEventIds = pinnedEventIds, + target = target, + isThreadsEnabled = isThreadsEnabled.value, + ) + } + } + + return ActionListState( + target = target.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.computeForMessage( + timelineItem: TimelineItem.Event, + usersEventPermissions: UserEventPermissions, + isDeveloperModeEnabled: Boolean, + pinnedEventIds: ImmutableList, + target: MutableState, + isThreadsEnabled: Boolean, + ) = launch { + target.value = ActionListState.Target.Loading(timelineItem) + + val actions = buildActions( + timelineItem = timelineItem, + usersEventPermissions = usersEventPermissions, + isDeveloperModeEnabled = isDeveloperModeEnabled, + isEventPinned = pinnedEventIds.contains(timelineItem.eventId), + isThreadsEnabled = isThreadsEnabled, + ) + + val verifiedUserSendFailure = userSendFailureFactory.create(timelineItem.localSendState) + val displayEmojiReactions = usersEventPermissions.canSendReaction && timelineItem.content.canReact() + + if (actions.isNotEmpty() || displayEmojiReactions || verifiedUserSendFailure != VerifiedUserSendFailure.None) { + val recentEmojis = getRecentEmojis().getOrNull()?.toImmutableList() ?: persistentListOf() + target.value = ActionListState.Target.Success( + event = timelineItem, + sentTimeFull = dateFormatter.format( + timelineItem.sentTimeMillis, + DateFormatterMode.Full, + useRelative = true, + ), + displayEmojiReactions = displayEmojiReactions, + verifiedUserSendFailure = verifiedUserSendFailure, + actions = actions.toImmutableList(), + // Merge suggested and recent emojis, removing duplicates and returning at most 100 + recentEmojis = (suggestedEmojis + recentEmojis).distinct() + .take(100) + .toImmutableList() + ) + } else { + target.value = ActionListState.Target.None + } + } + + private fun buildActions( + timelineItem: TimelineItem.Event, + usersEventPermissions: UserEventPermissions, + isDeveloperModeEnabled: Boolean, + isEventPinned: Boolean, + isThreadsEnabled: Boolean, + ): List { + val canRedact = timelineItem.isMine && usersEventPermissions.canRedactOwn || !timelineItem.isMine && usersEventPermissions.canRedactOther + return buildSet { + if (timelineItem.canBeRepliedTo && usersEventPermissions.canSendMessage) { + if (isThreadsEnabled && timelineMode !is Timeline.Mode.Thread && timelineItem.isRemote) { + // If threads are enabled, we can reply in thread if the item is remote + add(TimelineItemAction.ReplyInThread) + add(TimelineItemAction.Reply) + } else { + if (!isThreadsEnabled && timelineItem.threadInfo is TimelineItemThreadInfo.ThreadResponse) { + // If threads are not enabled, we can reply in a thread if the item is already in the thread + add(TimelineItemAction.ReplyInThread) + } else { + // Otherwise, we can only reply in the room + add(TimelineItemAction.Reply) + } + } + } + if (timelineItem.isRemote && timelineItem.content.canBeForwarded()) { + add(TimelineItemAction.Forward) + } + if (timelineItem.isEditable && usersEventPermissions.canSendMessage) { + if (timelineItem.content is TimelineItemEventContentWithAttachment) { + // Caption + if (timelineItem.content.caption == null) { + add(TimelineItemAction.AddCaption) + } else { + add(TimelineItemAction.EditCaption) + add(TimelineItemAction.RemoveCaption) + } + } else if (timelineItem.content is TimelineItemPollContent) { + add(TimelineItemAction.EditPoll) + } else { + add(TimelineItemAction.Edit) + } + } + if (canRedact && timelineItem.content is TimelineItemPollContent && !timelineItem.content.isEnded) { + add(TimelineItemAction.EndPoll) + } + val canPinUnpin = usersEventPermissions.canPinUnpin && timelineItem.isRemote + if (canPinUnpin) { + if (isEventPinned) { + add(TimelineItemAction.Unpin) + } else { + add(TimelineItemAction.Pin) + } + } + if (timelineItem.content.canBeCopied()) { + add(TimelineItemAction.CopyText) + } else if ((timelineItem.content as? TimelineItemEventContentWithAttachment)?.caption.isNullOrBlank().not()) { + add(TimelineItemAction.CopyCaption) + } + if (timelineItem.isRemote) { + add(TimelineItemAction.CopyLink) + } + if (isDeveloperModeEnabled) { + add(TimelineItemAction.ViewSource) + } + if (!timelineItem.isMine) { + add(TimelineItemAction.ReportContent) + } + if (canRedact) { + add(TimelineItemAction.Redact) + } + } + .postFilter(timelineItem.content) + .sortedWith(comparator) + .let(postProcessor::process) + } +} + +/** + * Post filter the actions based on the content of the event. + */ +private fun Iterable.postFilter(content: TimelineItemEventContent): Iterable { + return filter { action -> + when (content) { + is TimelineItemRtcNotificationContent, + is TimelineItemLegacyCallInviteContent, + is TimelineItemStateContent -> action == TimelineItemAction.ViewSource + is TimelineItemRedactedContent -> { + action == TimelineItemAction.ViewSource || action == TimelineItemAction.Unpin + } + else -> true + } + } +} 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 0000000..c0554aa --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListState.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.crypto.sendfailure.VerifiedUserSendFailure +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import kotlinx.collections.immutable.ImmutableList + +data class ActionListState( + val target: Target, + val eventSink: (ActionListEvents) -> Unit, +) { + @Immutable + sealed interface Target { + data object None : Target + data class Loading(val event: TimelineItem.Event) : Target + data class Success( + val event: TimelineItem.Event, + val sentTimeFull: String, + val displayEmojiReactions: Boolean, + val recentEmojis: ImmutableList, + val verifiedUserSendFailure: VerifiedUserSendFailure, + val actions: ImmutableList, + ) : 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 0000000..e57e5bd --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.actionlist.model.TimelineItemActionComparator +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.anUnsignedDeviceSendFailure +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.aTimelineItemAudioContent +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.aTimelineItemPollContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +open class ActionListStateProvider : PreviewParameterProvider { + private val suggestedEmojis = persistentListOf("👍️", "👎️", "🔥", "❤️", "👏") + + override val values: Sequence + get() { + val reactionsState = aTimelineItemReactions(1, isHighlighted = true) + return sequenceOf( + anActionListState(), + anActionListState().copy(target = ActionListState.Target.Loading(aTimelineItemEvent())), + anActionListState( + target = ActionListState.Target.Success( + event = aTimelineItemEvent( + timelineItemReactions = reactionsState + ), + sentTimeFull = "January 1, 1970 at 12:00 AM", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = aTimelineItemActionList(), + recentEmojis = suggestedEmojis, + ) + ), + anActionListState( + target = ActionListState.Target.Success( + event = aTimelineItemEvent( + content = aTimelineItemImageContent(), + displayNameAmbiguous = true, + timelineItemReactions = reactionsState, + ), + sentTimeFull = "January 1, 1970 at 12:00 AM", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = aTimelineItemActionList( + copyAction = TimelineItemAction.CopyCaption, + ), + recentEmojis = suggestedEmojis, + ) + ), + anActionListState( + target = ActionListState.Target.Success( + event = aTimelineItemEvent( + content = aTimelineItemVideoContent(), + timelineItemReactions = reactionsState + ), + sentTimeFull = "January 1, 1970 at 12:00 AM", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = aTimelineItemActionList( + copyAction = TimelineItemAction.CopyCaption, + ), + recentEmojis = suggestedEmojis, + ) + ), + anActionListState( + target = ActionListState.Target.Success( + event = aTimelineItemEvent( + content = aTimelineItemFileContent(), + timelineItemReactions = reactionsState + ), + sentTimeFull = "January 1, 1970 at 12:00 AM", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = aTimelineItemActionList( + copyAction = null, + ), + recentEmojis = suggestedEmojis, + ) + ), + anActionListState( + target = ActionListState.Target.Success( + event = aTimelineItemEvent( + content = aTimelineItemAudioContent(), + timelineItemReactions = reactionsState + ), + sentTimeFull = "January 1, 1970 at 12:00 AM", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = aTimelineItemActionList( + copyAction = TimelineItemAction.CopyCaption, + ), + recentEmojis = suggestedEmojis, + ) + ), + anActionListState( + target = ActionListState.Target.Success( + event = aTimelineItemEvent( + content = aTimelineItemVoiceContent(caption = null), + timelineItemReactions = reactionsState + ), + sentTimeFull = "January 1, 1970 at 12:00 AM", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = aTimelineItemActionList( + copyAction = null, + ), + recentEmojis = suggestedEmojis, + ) + ), + anActionListState( + target = ActionListState.Target.Success( + event = aTimelineItemEvent( + content = aTimelineItemLocationContent(), + timelineItemReactions = reactionsState + ), + sentTimeFull = "January 1, 1970 at 12:00 AM", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = aTimelineItemActionList(), + recentEmojis = suggestedEmojis, + ) + ), + anActionListState( + target = ActionListState.Target.Success( + event = aTimelineItemEvent( + content = aTimelineItemLocationContent(), + timelineItemReactions = reactionsState + ), + sentTimeFull = "January 1, 1970 at 12:00 AM", + displayEmojiReactions = false, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = aTimelineItemActionList(), + recentEmojis = suggestedEmojis, + ), + ), + anActionListState( + target = ActionListState.Target.Success( + event = aTimelineItemEvent( + content = aTimelineItemPollContent(), + timelineItemReactions = reactionsState + ), + sentTimeFull = "January 1, 1970 at 12:00 AM", + displayEmojiReactions = false, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = aTimelineItemPollActionList(), + recentEmojis = suggestedEmojis, + ), + ), + anActionListState( + target = ActionListState.Target.Success( + event = aTimelineItemEvent( + timelineItemReactions = reactionsState, + messageShield = MessageShield.UnknownDevice(isCritical = true) + ), + sentTimeFull = "January 1, 1970 at 12:00 AM", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = aTimelineItemActionList(), + recentEmojis = suggestedEmojis, + ) + ), + anActionListState( + target = ActionListState.Target.Success( + event = aTimelineItemEvent(), + sentTimeFull = "January 1, 1970 at 12:00 AM", + displayEmojiReactions = true, + verifiedUserSendFailure = anUnsignedDeviceSendFailure(), + actions = aTimelineItemActionList(), + recentEmojis = suggestedEmojis, + ) + ), + ) + } +} + +fun anActionListState( + target: ActionListState.Target = ActionListState.Target.None, + eventSink: (ActionListEvents) -> Unit = {}, +) = ActionListState( + target = target, + eventSink = eventSink +) + +fun aTimelineItemActionList( + copyAction: TimelineItemAction? = TimelineItemAction.CopyText +): ImmutableList { + return setOfNotNull( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + copyAction, + TimelineItemAction.CopyLink, + TimelineItemAction.Edit, + TimelineItemAction.Redact, + TimelineItemAction.ReportContent, + TimelineItemAction.ViewSource, + ) + .sortedWith(TimelineItemActionComparator()) + .toImmutableList() +} + +fun aTimelineItemPollActionList(): ImmutableList { + return setOf( + TimelineItemAction.EndPoll, + TimelineItemAction.EditPoll, + TimelineItemAction.Reply, + TimelineItemAction.Pin, + TimelineItemAction.CopyLink, + TimelineItemAction.Redact, + ) + .sortedWith(TimelineItemActionComparator()) + .toImmutableList() +} 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 0000000..704f79a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -0,0 +1,508 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.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.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +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.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.traversalIndex +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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.ChangedIdentity +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.None +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure.UnsignedDevice +import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction +import io.element.android.features.messages.impl.timeline.components.MessageShieldView +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.TimelineItemLegacyCallInviteContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent +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.timeline.model.event.TimelineItemVoiceContent +import io.element.android.features.messages.impl.utils.messagesummary.DefaultMessageSummaryFormatter +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.toSp +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.hide +import io.element.android.libraries.matrix.ui.messages.sender.SenderName +import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ActionListView( + state: ActionListState, + onSelectAction: (action: TimelineItemAction, TimelineItem.Event) -> Unit, + onEmojiReactionClick: (String, TimelineItem.Event) -> Unit, + onCustomReactionClick: (TimelineItem.Event) -> Unit, + onVerifiedUserSendFailureClick: (TimelineItem.Event) -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() + val targetItem = (state.target as? ActionListState.Target.Success)?.event + + fun onItemActionClick( + itemAction: TimelineItemAction + ) { + if (targetItem == null) return + sheetState.hide(coroutineScope) { + state.eventSink(ActionListEvents.Clear) + onSelectAction(itemAction, targetItem) + } + } + + fun onEmojiReactionClick(emoji: String) { + if (targetItem == null) return + sheetState.hide(coroutineScope) { + state.eventSink(ActionListEvents.Clear) + onEmojiReactionClick(emoji, targetItem) + } + } + + fun onCustomReactionClick() { + if (targetItem == null) return + sheetState.hide(coroutineScope) { + state.eventSink(ActionListEvents.Clear) + onCustomReactionClick(targetItem) + } + } + + fun onDismiss() { + state.eventSink(ActionListEvents.Clear) + } + + fun onVerifiedUserSendFailureClick() { + if (targetItem == null) return + sheetState.hide(coroutineScope) { + state.eventSink(ActionListEvents.Clear) + onVerifiedUserSendFailureClick(targetItem) + } + } + + if (targetItem != null) { + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = ::onDismiss, + modifier = modifier, + ) { + ActionListViewContent( + state = state, + onActionClick = ::onItemActionClick, + onEmojiReactionClick = ::onEmojiReactionClick, + onCustomReactionClick = ::onCustomReactionClick, + onVerifiedUserSendFailureClick = ::onVerifiedUserSendFailureClick, + modifier = Modifier + .navigationBarsPadding() + .imePadding() + ) + } + } +} + +@Composable +private fun ActionListViewContent( + state: ActionListState, + onActionClick: (TimelineItemAction) -> Unit, + onEmojiReactionClick: (String) -> Unit, + onCustomReactionClick: () -> Unit, + onVerifiedUserSendFailureClick: () -> 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, + sentTimeFull = target.sentTimeFull, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clearAndSetSemantics {}, + ) + if (target.event.messageShield != null) { + MessageShieldView( + shield = target.event.messageShield, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) + } else { + Spacer(modifier = Modifier.height(14.dp)) + } + HorizontalDivider() + } + } + if (target.verifiedUserSendFailure != None) { + item { + VerifiedUserSendFailureView( + sendFailure = target.verifiedUserSendFailure, + modifier = Modifier.fillMaxWidth(), + onClick = onVerifiedUserSendFailureClick + ) + HorizontalDivider() + } + } + if (target.displayEmojiReactions) { + item { + EmojiReactionsRow( + recentEmojis = target.recentEmojis, + highlightedEmojis = target.event.reactionsState.highlightedKeys, + onEmojiReactionClick = onEmojiReactionClick, + onCustomReactionClick = onCustomReactionClick, + modifier = Modifier.fillMaxWidth(), + ) + HorizontalDivider() + } + } + items( + items = actions, + ) { action -> + ListItem( + modifier = Modifier.clickable { + onActionClick(action) + }, + headlineContent = { + Text(text = stringResource(id = action.titleRes)) + }, + leadingContent = ListItemContent.Icon(IconSource.Resource(action.icon)), + style = when { + action.destructive -> ListItemStyle.Destructive + else -> ListItemStyle.Primary + } + ) + } + } + } + } +} + +@Suppress("MultipleEmitters") // False positive +@Composable +private fun MessageSummary( + event: TimelineItem.Event, + sentTimeFull: String, + modifier: Modifier = Modifier, +) { + val content: @Composable () -> Unit + val icon: @Composable () -> Unit = { + Avatar( + avatarData = event.senderAvatar.copy(size = AvatarSize.MessageActionSender), + avatarType = AvatarType.User, + ) + } + val contentStyle = ElementTheme.typography.fontBodyMdRegular.copy(color = ElementTheme.colors.textSecondary) + + @Composable + fun ContentForBody(body: String) { + Text(body, style = contentStyle, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + + val context = LocalContext.current + val formatter = remember(context) { DefaultMessageSummaryFormatter(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 -> { + content = { ContentForBody(stringResource(CommonStrings.common_shared_location)) } + } + is TimelineItemImageContent -> { + content = { ContentForBody(event.content.bestDescription) } + } + is TimelineItemStickerContent -> { + content = { ContentForBody(event.content.bestDescription) } + } + is TimelineItemVideoContent -> { + content = { ContentForBody(event.content.bestDescription) } + } + is TimelineItemFileContent -> { + content = { ContentForBody(event.content.bestDescription) } + } + is TimelineItemAudioContent -> { + content = { ContentForBody(event.content.bestDescription) } + } + is TimelineItemVoiceContent -> { + content = { ContentForBody(textContent) } + } + is TimelineItemPollContent -> { + content = { ContentForBody(textContent) } + } + is TimelineItemLegacyCallInviteContent -> { + content = { ContentForBody(textContent) } + } + is TimelineItemRtcNotificationContent -> { + content = { ContentForBody(stringResource(CommonStrings.common_call_started)) } + } + } + Row(modifier = modifier) { + icon() + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Row { + SenderName( + modifier = Modifier.weight(1f), + senderId = event.senderId, + senderProfile = event.senderProfile, + senderNameMode = SenderNameMode.ActionList, + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = sentTimeFull, + style = ElementTheme.typography.fontBodyXsRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.End, + ) + } + content() + } + } +} + +private val emojiRippleRadius = 24.dp + +@Composable +private fun EmojiReactionsRow( + recentEmojis: ImmutableList, + highlightedEmojis: ImmutableList, + onEmojiReactionClick: (String) -> Unit, + onCustomReactionClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.padding(end = 16.dp, top = 16.dp, bottom = 16.dp), + ) { + val backgroundColor = ElementTheme.colors.bgCanvasDefault + + LazyRow( + modifier = Modifier + .weight(1f, fill = true) + .drawWithContent { + val gradientWidth = 24.dp.toPx() + val width = size.width + drawContent() + + drawRect( + brush = Brush.horizontalGradient( + 0.0f to Color.Transparent, + 1.0f to backgroundColor, + startX = width - gradientWidth, + endX = width, + ), + topLeft = Offset(width - gradientWidth, 0f), + size = Size(gradientWidth, size.height) + ) + }, + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(recentEmojis) { emoji -> + val isHighlighted = highlightedEmojis.contains(emoji) + EmojiButton( + modifier = Modifier + // Make it appear after the more useful actions for the accessibility service + .semantics { + traversalIndex = 1f + }, + emoji = emoji, + isHighlighted = isHighlighted, + onClick = onEmojiReactionClick + ) + } + } + + Box( + modifier = Modifier.padding(end = 10.dp).requiredSize(48.dp), + contentAlignment = Alignment.CenterEnd, + ) { + Icon( + imageVector = CompoundIcons.ReactionAdd(), + contentDescription = stringResource(id = CommonStrings.a11y_react_with_other_emojis), + tint = ElementTheme.colors.iconSecondary, + modifier = Modifier + .size(24.dp) + .clickable( + enabled = true, + onClick = onCustomReactionClick, + indication = ripple(bounded = false, radius = emojiRippleRadius), + interactionSource = remember { MutableInteractionSource() } + ) + // Make it appear after the more useful actions for the accessibility service + .semantics { + traversalIndex = 1f + } + ) + } + } +} + +@Composable +private fun VerifiedUserSendFailureView( + sendFailure: VerifiedUserSendFailure, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + @Composable + @ReadOnlyComposable + fun VerifiedUserSendFailure.headline(): String { + return when (this) { + is None -> "" + is UnsignedDevice.FromOther -> stringResource(CommonStrings.screen_timeline_item_menu_send_failure_unsigned_device, userDisplayName) + is UnsignedDevice.FromYou -> stringResource(CommonStrings.screen_timeline_item_menu_send_failure_you_unsigned_device) + is ChangedIdentity -> stringResource(CommonStrings.screen_timeline_item_menu_send_failure_changed_identity, userDisplayName) + } + } + + ListItem( + modifier = modifier + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 8.dp), + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ErrorSolid())), + trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChevronRight())), + headlineContent = { + Text( + text = sendFailure.headline(), + style = ElementTheme.typography.fontBodySmMedium, + ) + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + leadingIconColor = ElementTheme.colors.iconCriticalPrimary, + trailingIconColor = ElementTheme.colors.iconPrimary, + headlineColor = ElementTheme.colors.textCriticalPrimary, + ), + ) +} + +@Composable +private fun EmojiButton( + emoji: String, + isHighlighted: Boolean, + onClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val backgroundColor = if (isHighlighted) { + ElementTheme.colors.bgActionPrimaryRest + } else { + Color.Transparent + } + val a11yClickLabel = a11yReactionAction( + emoji = emoji, + userAlreadyReacted = isHighlighted, + ) + Box( + modifier = modifier + .size(48.dp) + .background(backgroundColor, CircleShape) + .clickable( + onClickLabel = a11yClickLabel, + onClick = { onClick(emoji) }, + indication = ripple(bounded = false, radius = emojiRippleRadius), + interactionSource = remember { MutableInteractionSource() } + ), + contentAlignment = Alignment.Center + ) { + Text( + emoji, + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = 24.dp.toSp(), color = Color.White), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun ActionListViewContentPreview( + @PreviewParameter(ActionListStateProvider::class) state: ActionListState +) = ElementPreview { + ActionListViewContent( + state = state, + onActionClick = {}, + onEmojiReactionClick = {}, + onCustomReactionClick = {}, + onVerifiedUserSendFailureClick = {}, + ) +} 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 0000000..25e75be --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.actionlist.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.ui.strings.CommonStrings + +enum class TimelineItemAction( + @StringRes val titleRes: Int, + @DrawableRes val icon: Int, + val destructive: Boolean = false +) { + ViewInTimeline(CommonStrings.action_view_in_timeline, CompoundDrawables.ic_compound_visibility_on), + Forward(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward), + CopyText(CommonStrings.action_copy_text, CompoundDrawables.ic_compound_copy), + CopyCaption(CommonStrings.action_copy_caption, CompoundDrawables.ic_compound_copy), + CopyLink(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link), + Redact(CommonStrings.action_remove, CompoundDrawables.ic_compound_delete, destructive = true), + Reply(CommonStrings.action_reply, CompoundDrawables.ic_compound_reply), + ReplyInThread(CommonStrings.action_reply_in_thread, CompoundDrawables.ic_compound_reply), + Edit(CommonStrings.action_edit, CompoundDrawables.ic_compound_edit), + EditPoll(CommonStrings.action_edit_poll, CompoundDrawables.ic_compound_edit), + EditCaption(CommonStrings.action_edit_caption, CompoundDrawables.ic_compound_edit), + AddCaption(CommonStrings.action_add_caption, CompoundDrawables.ic_compound_edit), + RemoveCaption(CommonStrings.action_remove_caption, CompoundDrawables.ic_compound_close, destructive = true), + ViewSource(CommonStrings.action_view_source, CompoundDrawables.ic_compound_code), + ReportContent(CommonStrings.action_report_content, CompoundDrawables.ic_compound_chat_problem, destructive = true), + EndPoll(CommonStrings.action_end_poll, CompoundDrawables.ic_compound_polls_end), + Pin(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin), + Unpin(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin), +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt new file mode 100644 index 0000000..0a0d9b1 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.actionlist.model + +import androidx.annotation.VisibleForTesting + +class TimelineItemActionComparator : Comparator { + // See order in https://www.figma.com/design/ux3tYoZV9WghC7hHT9Fhk0/Compound-iOS-Components?node-id=2946-2392 + @VisibleForTesting + val orderedList = listOf( + TimelineItemAction.EndPoll, + TimelineItemAction.ViewInTimeline, + TimelineItemAction.Reply, + TimelineItemAction.ReplyInThread, + TimelineItemAction.Forward, + TimelineItemAction.Edit, + TimelineItemAction.EditPoll, + TimelineItemAction.AddCaption, + TimelineItemAction.EditCaption, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.Unpin, + TimelineItemAction.CopyText, + TimelineItemAction.CopyCaption, + TimelineItemAction.RemoveCaption, + TimelineItemAction.ViewSource, + TimelineItemAction.ReportContent, + TimelineItemAction.Redact, + ) + + override fun compare(o1: TimelineItemAction, o2: TimelineItemAction): Int { + val index1 = orderedList.indexOf(o1) + val index2 = orderedList.indexOf(o2) + return index1.compareTo(index2) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionPostProcessor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionPostProcessor.kt new file mode 100644 index 0000000..421a68f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionPostProcessor.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.actionlist.model + +fun interface TimelineItemActionPostProcessor { + fun process(actions: List): List + + object Default : TimelineItemActionPostProcessor { + override fun process(actions: List): List { + return actions + } + } +} 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 0000000..d989b34 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/Attachment.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import kotlinx.parcelize.Parcelize + +@Immutable +sealed interface Attachment : Parcelable { + @Parcelize + data class Media(val localMedia: LocalMedia) : 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 0000000..d8e29de --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvents.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments.preview + +sealed interface AttachmentsPreviewEvents { + data object SendAttachment : AttachmentsPreviewEvents + data object CancelAndDismiss : AttachmentsPreviewEvents + data object CancelAndClearSendState : 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 0000000..451398d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewNode.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments.preview + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.compound.colors.SemanticColorsLightDark +import io.element.android.compound.theme.ForcedDarkElementTheme +import io.element.android.features.enterprise.api.EnterpriseService +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.di.RoomScope +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.timeline.Timeline +import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer + +@ContributesNode(RoomScope::class) +@AssistedInject +class AttachmentsPreviewNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: AttachmentsPreviewPresenter.Factory, + private val localMediaRenderer: LocalMediaRenderer, + private val sessionId: SessionId, + private val enterpriseService: EnterpriseService, +) : Node(buildContext, plugins = plugins) { + data class Inputs( + val attachment: Attachment, + val timelineMode: Timeline.Mode, + val inReplyToEventId: EventId?, + ) : NodeInputs + + private val inputs: Inputs = inputs() + + private val onDoneListener = OnDoneListener { + navigateUp() + } + + private val presenter = presenterFactory.create( + attachment = inputs.attachment, + timelineMode = inputs.timelineMode, + onDoneListener = onDoneListener, + inReplyToEventId = inputs.inReplyToEventId, + ) + + @Composable + override fun View(modifier: Modifier) { + val colors by remember { + enterpriseService.semanticColorsFlow(sessionId = sessionId) + }.collectAsState(SemanticColorsLightDark.default) + ForcedDarkElementTheme( + colors = colors, + ) { + val state = presenter.present() + AttachmentsPreviewView( + state = state, + localMediaRenderer = localMediaRenderer, + 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 0000000..d7e0332 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -0,0 +1,342 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments.preview + +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.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter +import io.element.android.libraries.androidutils.file.TemporaryUriDeleter +import io.element.android.libraries.androidutils.file.safeDelete +import io.element.android.libraries.androidutils.hash.hash +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.coroutine.firstInstanceOf +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig +import io.element.android.libraries.mediaupload.api.MediaSenderFactory +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.mediaupload.api.allFiles +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.textcomposer.model.TextEditorState +import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import timber.log.Timber + +@AssistedInject +class AttachmentsPreviewPresenter( + @Assisted private val attachment: Attachment, + @Assisted private val onDoneListener: OnDoneListener, + @Assisted private val timelineMode: Timeline.Mode, + @Assisted private val inReplyToEventId: EventId?, + mediaSenderFactory: MediaSenderFactory, + private val permalinkBuilder: PermalinkBuilder, + private val temporaryUriDeleter: TemporaryUriDeleter, + private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory, + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, + private val dispatchers: CoroutineDispatchers, +) : Presenter { + @AssistedFactory + interface Factory { + fun create( + attachment: Attachment, + timelineMode: Timeline.Mode, + onDoneListener: OnDoneListener, + inReplyToEventId: EventId?, + ): AttachmentsPreviewPresenter + } + + private val mediaSender = mediaSenderFactory.create(timelineMode) + + @Composable + override fun present(): AttachmentsPreviewState { + val coroutineScope = rememberCoroutineScope() + + val sendActionState = remember { + mutableStateOf(SendActionState.Idle) + } + + val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false) + val textEditorState by rememberUpdatedState( + TextEditorState.Markdown(markdownTextEditorState, isRoomEncrypted = null) + ) + + val ongoingSendAttachmentJob = remember { mutableStateOf(null) } + + var preprocessMediaJob by remember { mutableStateOf(null) } + + val mediaAttachment = attachment as Attachment.Media + val mediaOptimizationSelectorPresenter = remember { + mediaOptimizationSelectorPresenterFactory.create(mediaAttachment.localMedia) + } + val mediaOptimizationSelectorState = mediaOptimizationSelectorPresenter.present() + + val observableSendState = snapshotFlow { sendActionState.value } + + var displayFileTooLargeError by remember { mutableStateOf(false) } + + LaunchedEffect(mediaOptimizationSelectorState.displayMediaSelectorViews) { + // If the media optimization selector is not displayed, we can pre-process the media + // to prepare it for sending. This is done to avoid blocking the UI thread when the + // user clicks on the send button. + if (mediaOptimizationSelectorState.displayMediaSelectorViews == false) { + val mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true, + videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD, + ) + preprocessMediaJob = preProcessAttachment( + attachment = attachment, + mediaOptimizationConfig = mediaOptimizationConfig, + displayProgress = false, + sendActionState = sendActionState, + ) + } + } + + val maxUploadSize = mediaOptimizationSelectorState.maxUploadSize.dataOrNull() + LaunchedEffect(maxUploadSize) { + // Check file upload size if the media won't be processed for upload + val isImageFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeImage() + val isVideoFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo() + if (maxUploadSize != null && !(isImageFile || isVideoFile)) { + // If file size is not known, we're permissive and allow sending. The SDK will cancel the upload if needed. + val fileSize = mediaAttachment.localMedia.info.fileSize ?: 0L + if (maxUploadSize < fileSize) { + displayFileTooLargeError = true + } + } + } + + val videoSizeEstimations = mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull() + LaunchedEffect(videoSizeEstimations) { + if (videoSizeEstimations != null) { + // Check if the video size estimations are too large for the max upload size + displayFileTooLargeError = videoSizeEstimations.none { it.canUpload } + } + } + + fun handleEvent(event: AttachmentsPreviewEvents) { + when (event) { + is AttachmentsPreviewEvents.SendAttachment -> { + ongoingSendAttachmentJob.value = coroutineScope.launch { + // If the media optimization selector is displayed, we need to wait for the user to select the options + // before we can pre-process the media. + if (mediaOptimizationSelectorState.displayMediaSelectorViews == true) { + val config = MediaOptimizationConfig( + compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true, + videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD, + ) + preprocessMediaJob = preProcessAttachment( + attachment = attachment, + mediaOptimizationConfig = config, + displayProgress = true, + sendActionState = sendActionState, + ) + } + + // If the processing was hidden before, make it visible now + if (sendActionState.value is SendActionState.Sending.Processing) { + sendActionState.value = SendActionState.Sending.Processing(displayProgress = true) + } + + // Wait until the media is ready to be uploaded + val mediaUploadInfo = observableSendState.firstInstanceOf().mediaInfo + + // Pre-processing is done, send the attachment + val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) + .takeIf { it.isNotEmpty() } + + // If we're supposed to send the media as a background job, we can dismiss this screen already + if (coroutineContext.isActive) { + onDoneListener() + } + + // Send the media using the session coroutine scope so it doesn't matter if this screen or the chat one are closed + sessionCoroutineScope.launch(dispatchers.io) { + sendPreProcessedMedia( + mediaUploadInfo = mediaUploadInfo, + caption = caption, + sendActionState = sendActionState, + dismissAfterSend = false, + inReplyToEventId = inReplyToEventId, + ) + + // Clean up the pre-processed media after it's been sent + mediaSender.cleanUp() + } + } + } + AttachmentsPreviewEvents.CancelAndDismiss -> { + displayFileTooLargeError = false + + // Cancel media preprocessing and sending + preprocessMediaJob?.cancel() + // If we couldn't send the pre-processed media, remove it + mediaSender.cleanUp() + ongoingSendAttachmentJob.value?.cancel() + + // Dismiss the screen + dismiss( + attachment, + sendActionState, + ) + } + AttachmentsPreviewEvents.CancelAndClearSendState -> { + // Cancel media sending + ongoingSendAttachmentJob.value?.let { + it.cancel() + ongoingSendAttachmentJob.value = null + } + + val mediaUploadInfo = sendActionState.value.mediaUploadInfo() + sendActionState.value = if (mediaUploadInfo != null) { + SendActionState.Sending.ReadyToUpload(mediaUploadInfo) + } else { + SendActionState.Idle + } + } + } + } + + return AttachmentsPreviewState( + attachment = attachment, + sendActionState = sendActionState.value, + textEditorState = textEditorState, + mediaOptimizationSelectorState = mediaOptimizationSelectorState, + displayFileTooLargeError = displayFileTooLargeError, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.preProcessAttachment( + attachment: Attachment, + mediaOptimizationConfig: MediaOptimizationConfig, + displayProgress: Boolean, + sendActionState: MutableState, + ) = launch(dispatchers.io) { + when (attachment) { + is Attachment.Media -> { + preProcessMedia( + mediaAttachment = attachment, + mediaOptimizationConfig = mediaOptimizationConfig, + displayProgress = displayProgress, + sendActionState = sendActionState, + ) + } + } + } + + private suspend fun preProcessMedia( + mediaAttachment: Attachment.Media, + mediaOptimizationConfig: MediaOptimizationConfig, + displayProgress: Boolean, + sendActionState: MutableState, + ) { + sendActionState.value = SendActionState.Sending.Processing(displayProgress = displayProgress) + mediaSender.preProcessMedia( + uri = mediaAttachment.localMedia.uri, + mimeType = mediaAttachment.localMedia.info.mimeType, + mediaOptimizationConfig = mediaOptimizationConfig, + ).fold( + onSuccess = { mediaUploadInfo -> + Timber.d("Media ${mediaUploadInfo.file.path.orEmpty().hash()} finished processing, it's now ready to upload") + sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfo) + }, + onFailure = { + Timber.e(it, "Failed to pre-process media") + if (it is CancellationException) { + throw it + } else { + sendActionState.value = SendActionState.Failure(it, null) + } + } + ) + } + + private fun dismiss( + attachment: Attachment, + sendActionState: MutableState, + ) { + // Delete the temporary file + when (attachment) { + is Attachment.Media -> { + temporaryUriDeleter.delete(attachment.localMedia.uri) + sendActionState.value.mediaUploadInfo()?.let { data -> + cleanUp(data) + } + } + } + // Reset the sendActionState to ensure that dialog is closed before the screen + sendActionState.value = SendActionState.Done + onDoneListener() + } + + private fun cleanUp( + mediaUploadInfo: MediaUploadInfo, + ) { + mediaUploadInfo.allFiles().forEach { file -> + file.safeDelete() + } + } + + private suspend fun sendPreProcessedMedia( + mediaUploadInfo: MediaUploadInfo, + caption: String?, + sendActionState: MutableState, + dismissAfterSend: Boolean, + inReplyToEventId: EventId?, + ) = runCatchingExceptions { + sendActionState.value = SendActionState.Sending.Uploading(mediaUploadInfo) + mediaSender.sendPreProcessedMedia( + mediaUploadInfo = mediaUploadInfo, + caption = caption, + formattedCaption = null, + inReplyToEventId = inReplyToEventId, + ).getOrThrow() + }.fold( + onSuccess = { + cleanUp(mediaUploadInfo) + // Reset the sendActionState to ensure that dialog is closed before the screen + sendActionState.value = SendActionState.Done + + if (dismissAfterSend) { + onDoneListener() + } + }, + onFailure = { error -> + Timber.e(error, "Failed to send attachment") + if (error is CancellationException) { + throw error + } else { + sendActionState.value = SendActionState.Failure(error, mediaUploadInfo) + } + } + ) +} 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 0000000..42d01a4 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments.preview + +import androidx.compose.runtime.Immutable +import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.textcomposer.model.TextEditorState + +data class AttachmentsPreviewState( + val attachment: Attachment, + val sendActionState: SendActionState, + val textEditorState: TextEditorState, + val mediaOptimizationSelectorState: MediaOptimizationSelectorState, + val displayFileTooLargeError: Boolean, + val eventSink: (AttachmentsPreviewEvents) -> Unit +) + +@Immutable +sealed interface SendActionState { + data object Idle : SendActionState + + @Immutable + sealed interface Sending : SendActionState { + data class Processing(val displayProgress: Boolean) : Sending + data class ReadyToUpload(val mediaInfo: MediaUploadInfo) : Sending + data class Uploading(val mediaUploadInfo: MediaUploadInfo) : Sending + } + + data class Failure(val error: Throwable, val mediaUploadInfo: MediaUploadInfo?) : SendActionState + data object Done : SendActionState + + fun mediaUploadInfo(): MediaUploadInfo? = when (this) { + is Sending.ReadyToUpload -> mediaInfo + is Sending.Uploading -> mediaUploadInfo + is Failure -> mediaUploadInfo + else -> null + } +} 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 0000000..6823aea --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.attachments.video.MediaOptimizationSelectorState +import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo +import io.element.android.libraries.mediaviewer.api.anImageMediaInfo +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.textcomposer.model.TextEditorState +import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import java.io.File + +open class AttachmentsPreviewStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anAttachmentsPreviewState(), + anAttachmentsPreviewState( + sendActionState = SendActionState.Sending.Processing(displayProgress = false), + textEditorState = aTextEditorStateMarkdown( + initialText = "This is a caption!" + ) + ), + anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing(displayProgress = true)), + anAttachmentsPreviewState(sendActionState = SendActionState.Sending.ReadyToUpload(aMediaUploadInfo())), + anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(aMediaUploadInfo())), + anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), aMediaUploadInfo())), + anAttachmentsPreviewState(displayFileTooLargeError = true), + anAttachmentsPreviewState( + mediaInfo = aVideoMediaInfo(), + mediaOptimizationSelectorState = aMediaOptimisationSelectorState( + selectedVideoPreset = VideoCompressionPreset.STANDARD, + videoSizeEstimations = aVideoSizeEstimationList(), + ) + ), + anAttachmentsPreviewState( + mediaInfo = aVideoMediaInfo(), + mediaOptimizationSelectorState = aMediaOptimisationSelectorState( + videoSizeEstimations = aVideoSizeEstimationList(), + displayVideoPresetSelectorDialog = true, + ) + ), + ) +} + +fun anAttachmentsPreviewState( + mediaInfo: MediaInfo = anImageMediaInfo(), + textEditorState: TextEditorState = aTextEditorStateMarkdown(), + sendActionState: SendActionState = SendActionState.Idle, + mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(), + displayFileTooLargeError: Boolean = false, +) = AttachmentsPreviewState( + attachment = Attachment.Media( + localMedia = LocalMedia("file://path".toUri(), mediaInfo), + ), + sendActionState = sendActionState, + textEditorState = textEditorState, + mediaOptimizationSelectorState = mediaOptimizationSelectorState, + displayFileTooLargeError = displayFileTooLargeError, + eventSink = {} +) + +fun aMediaUploadInfo( + filePath: String = "file://path", + thumbnailFilePath: String? = null, +) = MediaUploadInfo.Image( + file = File(filePath), + imageInfo = ImageInfo( + height = 100, + width = 100, + mimetype = MimeTypes.Jpeg, + size = 1000, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ), + thumbnailFile = thumbnailFilePath?.let { File(it) }, +) + +fun aMediaOptimisationSelectorState( + maxUploadSize: Long = 100, + videoSizeEstimations: AsyncData> = AsyncData.Success(persistentListOf()), + isImageOptimizationEnabled: Boolean = true, + selectedVideoPreset: VideoCompressionPreset = VideoCompressionPreset.STANDARD, + displayMediaSelectorViews: Boolean = true, + displayVideoPresetSelectorDialog: Boolean = false, +) = MediaOptimizationSelectorState( + maxUploadSize = AsyncData.Success(maxUploadSize), + videoSizeEstimations = videoSizeEstimations, + isImageOptimizationEnabled = isImageOptimizationEnabled, + selectedVideoPreset = selectedVideoPreset, + displayMediaSelectorViews = displayMediaSelectorViews, + displayVideoPresetSelectorDialog = displayVideoPresetSelectorDialog, + eventSink = {}, +) + +internal fun aVideoSizeEstimationList(): AsyncData> = AsyncData.Success( + persistentListOf( + VideoUploadEstimation( + preset = VideoCompressionPreset.HIGH, + sizeInBytes = 8_200_000L, + canUpload = false, + ), + VideoUploadEstimation( + preset = VideoCompressionPreset.STANDARD, + sizeInBytes = 4_200_000L, + canUpload = true, + ), + ) +) 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 0000000..7c9ffda --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt @@ -0,0 +1,444 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments.preview + +import androidx.activity.compose.BackHandler +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.IntrinsicSize +import androidx.compose.foundation.layout.Row +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.material3.ExperimentalMaterial3Api +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.res.painterResource +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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.R +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.attachments.video.MediaOptimizationSelectorEvent +import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState +import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.ProgressDialogType +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.AlertDialog +import io.element.android.libraries.designsystem.components.dialogs.ListDialog +import io.element.android.libraries.designsystem.components.dialogs.RetryDialog +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.modifiers.niceClickable +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Switch +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.CommonDrawables +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.textcomposer.TextComposer +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.textcomposer.model.VoiceMessageState +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.libraries.ui.utils.formatter.rememberFileSizeFormatter +import io.element.android.wysiwyg.display.TextDisplay +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AttachmentsPreviewView( + state: AttachmentsPreviewState, + localMediaRenderer: LocalMediaRenderer, + modifier: Modifier = Modifier, +) { + fun postSendAttachment() { + state.eventSink(AttachmentsPreviewEvents.SendAttachment) + } + + fun postCancel() { + state.eventSink(AttachmentsPreviewEvents.CancelAndDismiss) + } + + fun postClearSendState() { + state.eventSink(AttachmentsPreviewEvents.CancelAndClearSendState) + } + + BackHandler(enabled = state.sendActionState !is SendActionState.Sending.Uploading && state.sendActionState !is SendActionState.Done) { + postCancel() + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + navigationIcon = { + BackButton( + imageVector = CompoundIcons.Close(), + onClick = ::postCancel, + ) + }, + title = {}, + ) + } + ) { paddingValues -> + AttachmentPreviewContent( + modifier = Modifier.padding(paddingValues), + state = state, + localMediaRenderer = localMediaRenderer, + onSendClick = ::postSendAttachment, + ) + } + AttachmentSendStateView( + sendActionState = state.sendActionState, + onDismissClick = ::postClearSendState, + onRetryClick = ::postSendAttachment + ) +} + +@Composable +private fun AttachmentSendStateView( + sendActionState: SendActionState, + onDismissClick: () -> Unit, + onRetryClick: () -> Unit +) { + when (sendActionState) { + is SendActionState.Sending.Processing -> { + if (sendActionState.displayProgress) { + ProgressDialog( + type = ProgressDialogType.Indeterminate, + text = stringResource(CommonStrings.common_preparing), + showCancelButton = true, + onDismissRequest = onDismissClick, + ) + } + } + is SendActionState.Sending.Uploading -> { + ProgressDialog( + type = ProgressDialogType.Indeterminate, + text = stringResource(id = CommonStrings.common_sending), + showCancelButton = true, + onDismissRequest = onDismissClick, + ) + } + is SendActionState.Failure -> { + RetryDialog( + content = stringResource(sendAttachmentError(sendActionState.error)), + onDismiss = onDismissClick, + onRetry = onRetryClick + ) + } + else -> Unit + } +} + +@Composable +private fun AttachmentPreviewContent( + state: AttachmentsPreviewState, + localMediaRenderer: LocalMediaRenderer, + onSendClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .navigationBarsPadding(), + ) { + Box( + modifier = Modifier + .weight(1f), + contentAlignment = Alignment.Center + ) { + when (val attachment = state.attachment) { + is Attachment.Media -> { + localMediaRenderer.Render(attachment.localMedia) + } + } + } + val mimeType = (state.attachment as? Attachment.Media)?.localMedia?.info?.mimeType + if (mimeType?.isMimeTypeImage() == true) { + ImageOptimizationSelector(state.mediaOptimizationSelectorState) + } else if (mimeType?.isMimeTypeVideo() == true) { + VideoPresetSelector(state = state.mediaOptimizationSelectorState) + } + + val sizeFormatter = rememberFileSizeFormatter() + if (state.displayFileTooLargeError) { + val maxFileUploadSize = state.mediaOptimizationSelectorState.maxUploadSize.dataOrNull() + if (maxFileUploadSize != null) { + val content = stringResource(CommonStrings.dialog_file_too_large_to_upload_subtitle, sizeFormatter.format(maxFileUploadSize, true)) + AlertDialog( + title = stringResource(CommonStrings.dialog_file_too_large_to_upload_title), + content = content, + onDismiss = { state.eventSink(AttachmentsPreviewEvents.CancelAndDismiss) }, + ) + } + } + + AttachmentsPreviewBottomActions( + state = state, + onSendClick = onSendClick, + modifier = Modifier + .fillMaxWidth() + .background(ElementTheme.colors.bgCanvasDefault) + .height(IntrinsicSize.Min) + .imePadding(), + ) + } +} + +@Composable +private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) { + if (state.displayMediaSelectorViews == true) { + Row( + modifier = Modifier.fillMaxWidth() + .niceClickable { + state.isImageOptimizationEnabled?.let { value -> + state.eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(!value)) + } + } + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + Text( + modifier = Modifier.weight(1f).align(Alignment.CenterVertically), + text = stringResource(R.string.screen_media_upload_preview_optimize_image_quality_title), + style = ElementTheme.materialTypography.bodyLarge, + ) + Switch( + modifier = Modifier.height(32.dp), + checked = state.isImageOptimizationEnabled.orFalse(), + onCheckedChange = { value -> state.eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(value)) }, + ) + } + } +} + +@Composable +private fun VideoPresetSelector( + state: MediaOptimizationSelectorState, +) { + val videoPresets = state.videoSizeEstimations.dataOrNull() + var selectedPreset by remember(state.selectedVideoPreset) { mutableStateOf(state.selectedVideoPreset) } + + val displayDialog = state.displayVideoPresetSelectorDialog + + val sizeFormatter = rememberFileSizeFormatter() + + if (state.displayMediaSelectorViews == true && videoPresets != null && state.selectedVideoPreset != null) { + Column( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp) + .niceClickable { state.eventSink(MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog) } + ) { + val estimation = videoPresets.find { it.preset == selectedPreset } + val estimationMb = estimation?.sizeInBytes?.let { sizeFormatter.format(it, true) } + val title = buildString { + append(state.selectedVideoPreset.title()) + if (estimationMb != null) { + append(" ($estimationMb)") + } + } + Text(text = title, style = ElementTheme.typography.fontBodyLgMedium) + Text( + text = stringResource(R.string.screen_media_upload_preview_change_video_quality_prompt), + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textSecondary, + ) + } + } + + if (displayDialog) { + VideoQualitySelectorDialog( + selectedPreset = selectedPreset ?: VideoCompressionPreset.STANDARD, + videoSizeEstimations = videoPresets ?: persistentListOf(), + maxFileUploadSize = state.maxUploadSize.dataOrNull(), + onSubmit = { preset -> + selectedPreset = preset + state.eventSink(MediaOptimizationSelectorEvent.SelectVideoPreset(preset)) + }, + onDismiss = { state.eventSink(MediaOptimizationSelectorEvent.DismissVideoPresetSelectorDialog) } + ) + } +} + +@Composable +private fun VideoQualitySelectorDialog( + selectedPreset: VideoCompressionPreset, + videoSizeEstimations: ImmutableList, + maxFileUploadSize: Long?, + onSubmit: (VideoCompressionPreset) -> Unit, + onDismiss: () -> Unit, +) { + val sizeFormatter = rememberFileSizeFormatter() + + var localSelectedPreset by remember(selectedPreset) { mutableStateOf(selectedPreset) } + val subtitlePartNoFileSize = stringResource(CommonStrings.dialog_video_quality_selector_subtitle_no_file_size) + val subtitlePartWithFileSize = stringResource(CommonStrings.dialog_video_quality_selector_subtitle_file_size) + val subtitle = remember(maxFileUploadSize) { + buildString { + append(subtitlePartNoFileSize) + if (maxFileUploadSize != null) { + append(String.format(subtitlePartWithFileSize, sizeFormatter.format(maxFileUploadSize, true))) + } + } + } + ListDialog( + title = stringResource(CommonStrings.dialog_video_quality_selector_title), + subtitle = subtitle, + onSubmit = { onSubmit(localSelectedPreset) }, + onDismissRequest = onDismiss, + applyPaddingToContents = false, + ) { + for (videoEstimation in videoSizeEstimations) { + val preset = videoEstimation.preset + val isSelected = preset == localSelectedPreset + item( + key = preset, + contentType = preset, + ) { + val estimationMb = sizeFormatter.format(videoEstimation.sizeInBytes, true) + val title = "${preset.title()} ($estimationMb)" + ListItem( + headlineContent = { + Text( + text = title, + style = ElementTheme.typography.fontBodyLgMedium, + ) + }, + supportingContent = { + Text( + text = preset.subtitle(), + style = ElementTheme.materialTypography.bodyMedium, + color = ElementTheme.colors.textSecondary, + ) + }, + leadingContent = ListItemContent.RadioButton( + selected = isSelected, + ), + onClick = { + localSelectedPreset = preset + }, + enabled = videoEstimation.canUpload, + ) + } + } + } +} + +@Composable +private fun AttachmentsPreviewBottomActions( + state: AttachmentsPreviewState, + onSendClick: () -> Unit, + modifier: Modifier = Modifier +) { + TextComposer( + modifier = modifier, + state = state.textEditorState, + voiceMessageState = VoiceMessageState.Idle, + composerMode = MessageComposerMode.Attachment, + onRequestFocus = {}, + onSendMessage = onSendClick, + showTextFormatting = false, + onResetComposerMode = {}, + onAddAttachment = {}, + onDismissTextFormatting = {}, + onVoiceRecorderEvent = {}, + onVoicePlayerEvent = {}, + onSendVoiceMessage = {}, + onDeleteVoiceMessage = {}, + onReceiveSuggestion = {}, + resolveMentionDisplay = { _, _ -> TextDisplay.Plain }, + resolveAtRoomMentionDisplay = { TextDisplay.Plain }, + onError = {}, + onTyping = {}, + onSelectRichContent = {}, + ) +} + +// Only preview in dark, dark theme is forced on the Node. +@Preview +@Composable +internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark { + AttachmentsPreviewView( + state = state, + localMediaRenderer = object : LocalMediaRenderer { + @Composable + override fun Render(localMedia: LocalMedia) { + Image( + painter = painterResource(id = CommonDrawables.sample_background), + modifier = Modifier.fillMaxSize(), + contentDescription = null, + ) + } + } + ) +} + +@PreviewsDayNight +@Composable +internal fun VideoQualitySelectorDialogPreview() { + ElementPreview { + VideoQualitySelectorDialog( + selectedPreset = VideoCompressionPreset.STANDARD, + videoSizeEstimations = persistentListOf( + VideoUploadEstimation(VideoCompressionPreset.HIGH, 2_000_000, canUpload = false), + VideoUploadEstimation(VideoCompressionPreset.STANDARD, 1_000_000, canUpload = true), + VideoUploadEstimation(VideoCompressionPreset.LOW, 500_000, canUpload = true) + ), + maxFileUploadSize = 1_500_000, + onSubmit = {}, + onDismiss = {}, + ) + } +} + +@Composable +fun VideoCompressionPreset.title(): String { + return stringResource( + when (this) { + VideoCompressionPreset.STANDARD -> CommonStrings.common_video_quality_standard + VideoCompressionPreset.HIGH -> CommonStrings.common_video_quality_high + VideoCompressionPreset.LOW -> CommonStrings.common_video_quality_low + } + ) +} + +@Composable +fun VideoCompressionPreset.subtitle(): String { + return stringResource( + when (this) { + VideoCompressionPreset.STANDARD -> CommonStrings.common_video_quality_standard_description + VideoCompressionPreset.HIGH -> CommonStrings.common_video_quality_high_description + VideoCompressionPreset.LOW -> CommonStrings.common_video_quality_low_description + } + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/OnDoneListener.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/OnDoneListener.kt new file mode 100644 index 0000000..948370f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/OnDoneListener.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments.preview + +fun interface OnDoneListener { + operator fun invoke() +} 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 0000000..89b14fe --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/error/ErrorFormatter.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments.preview.error + +import io.element.android.features.messages.impl.R +import io.element.android.libraries.mediaupload.api.MediaPreProcessor + +fun sendAttachmentError( + throwable: Throwable +): Int { + return if (throwable is MediaPreProcessor.Failure) { + R.string.screen_media_upload_preview_error_failed_processing + } else { + R.string.screen_media_upload_preview_error_failed_sending + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt new file mode 100644 index 0000000..d0716ab --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments.video + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.mediaupload.api.MaxUploadSizeProvider +import io.element.android.libraries.mediaupload.api.compressorHelper +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.first +import timber.log.Timber +import kotlin.math.roundToLong + +@AssistedInject +class DefaultMediaOptimizationSelectorPresenter( + @Assisted private val localMedia: LocalMedia, + private val maxUploadSizeProvider: MaxUploadSizeProvider, + private val sessionPreferencesStore: SessionPreferencesStore, + private val featureFlagService: FeatureFlagService, + mediaExtractorFactory: VideoMetadataExtractor.Factory, +) : MediaOptimizationSelectorPresenter { + @ContributesBinding(SessionScope::class) + @AssistedFactory + interface Factory : MediaOptimizationSelectorPresenter.Factory { + override fun create( + localMedia: LocalMedia, + ): DefaultMediaOptimizationSelectorPresenter + } + + private val mediaExtractor = mediaExtractorFactory.create(localMedia.uri) + + @Composable + override fun present(): MediaOptimizationSelectorState { + val displayMediaSelectorViews by produceState(null) { + value = featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality) + } + + var displayVideoPresetSelectorDialog by remember { mutableStateOf(false) } + + val maxUploadSize by produceState(AsyncData.Loading()) { + maxUploadSizeProvider.getMaxUploadSize().fold( + onSuccess = { value = AsyncData.Success(it) }, + onFailure = { + Timber.e(it, "Failed to retrieve max upload size for video optimization selector") + value = AsyncData.Success((100 * 1024 * 1024).toLong()) // Default to 100 MB if we can't retrieve the max upload size + } + ) + } + + val mediaMimeType = localMedia.info.mimeType + + val videoSizeEstimations by produceState>>( + initialValue = AsyncData.Loading(), + key1 = maxUploadSize, + ) { + if (maxUploadSize !is AsyncData.Success) { + return@produceState + } + + if (!mediaMimeType.isMimeTypeVideo()) { + value = AsyncData.Uninitialized + return@produceState + } + + val (videoDimensions, duration) = mediaExtractor.use { + val size = it.getSize() + .getOrElse { exception -> + value = AsyncData.Failure(exception) + return@produceState + } + + val duration = it.getDuration() + .getOrElse { exception -> + value = AsyncData.Failure(exception) + return@produceState + } + size to duration + } + + val sizeEstimations = VideoCompressionPreset.entries + .map { preset -> + val bitRateAsBytes = preset.compressorHelper().calculateOptimalBitrate(videoDimensions, 30) / 8f + val durationInSeconds = duration.inWholeSeconds.toFloat() + val calculatedSize = (bitRateAsBytes * durationInSeconds * 1.1f).roundToLong() // Adding 10% overhead for safety + VideoUploadEstimation( + preset = preset, + sizeInBytes = calculatedSize, + canUpload = calculatedSize <= (maxUploadSize as AsyncData.Success).data + ) + } + .toImmutableList() + .also { sizes -> + Timber.d(sizes.joinToString("\n") { "Calculated size for ${it.preset}: ${it.sizeInBytes} MB. Max upload size: $maxUploadSize" }) + } + + value = AsyncData.Success(sizeEstimations) + } + + var selectedImageOptimization by remember { mutableStateOf>(AsyncData.Loading()) } + var selectedVideoOptimizationPreset by remember { mutableStateOf>(AsyncData.Loading()) } + + LaunchedEffect(videoSizeEstimations.dataOrNull()) { + selectedImageOptimization = AsyncData.Success(sessionPreferencesStore.doesOptimizeImages().first()) + // Find the best video preset based on the default preset and the video size estimations + // Since the estimation for the current preset may be way too large to upload, we check the ones that provide lower file sizes + selectedVideoOptimizationPreset = findBestVideoPreset( + defaultVideoPreset = sessionPreferencesStore.getVideoCompressionPreset().first(), + videoSizeEstimations = videoSizeEstimations, + ) + } + + fun handleEvent(event: MediaOptimizationSelectorEvent) { + when (event) { + is MediaOptimizationSelectorEvent.SelectImageOptimization -> { + selectedImageOptimization = AsyncData.Success(event.enabled) + } + is MediaOptimizationSelectorEvent.SelectVideoPreset -> { + val estimations = videoSizeEstimations.dataOrNull() + if (estimations != null) { + val preset = estimations.find { it.preset == event.preset } + if (preset == null) { + Timber.e("Selected video preset ${event.preset} is not available in the estimations") + return + } + if (!preset.canUpload) { + Timber.w("Selected video preset ${event.preset} exceeds max upload size") + return + } + } else { + Timber.e("Video size estimations are not available") + return + } + selectedVideoOptimizationPreset = AsyncData.Success(event.preset) + displayVideoPresetSelectorDialog = false + } + is MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog -> { + displayVideoPresetSelectorDialog = true + } + is MediaOptimizationSelectorEvent.DismissVideoPresetSelectorDialog -> { + displayVideoPresetSelectorDialog = false + } + } + } + + return MediaOptimizationSelectorState( + maxUploadSize = maxUploadSize, + videoSizeEstimations = videoSizeEstimations, + isImageOptimizationEnabled = selectedImageOptimization.dataOrNull(), + selectedVideoPreset = selectedVideoOptimizationPreset.dataOrNull(), + displayMediaSelectorViews = displayMediaSelectorViews, + displayVideoPresetSelectorDialog = displayVideoPresetSelectorDialog, + eventSink = ::handleEvent, + ) + } + + private fun findBestVideoPreset( + defaultVideoPreset: VideoCompressionPreset, + videoSizeEstimations: AsyncData>, + ): AsyncData { + val estimations = videoSizeEstimations.dataOrNull() ?: return AsyncData.Loading() + // This will find the best video preset that can be used to produce a video that can be uploaded + val bestEstimation = estimations.find { it.preset.ordinal >= defaultVideoPreset.ordinal && it.canUpload }?.preset + return if (bestEstimation != null) { + AsyncData.Success(bestEstimation) + } else { + AsyncData.Failure( + IllegalStateException("No suitable video preset found for default preset: $defaultVideoPreset") + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorEvent.kt new file mode 100644 index 0000000..ec1c3b8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorEvent.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments.video + +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset + +sealed interface MediaOptimizationSelectorEvent { + data class SelectImageOptimization(val enabled: Boolean) : MediaOptimizationSelectorEvent + data class SelectVideoPreset(val preset: VideoCompressionPreset) : MediaOptimizationSelectorEvent + data object OpenVideoPresetSelectorDialog : MediaOptimizationSelectorEvent + data object DismissVideoPresetSelectorDialog : MediaOptimizationSelectorEvent +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorPresenter.kt new file mode 100644 index 0000000..80cdfd9 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorPresenter.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments.video + +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.mediaviewer.api.local.LocalMedia + +fun interface MediaOptimizationSelectorPresenter : Presenter { + interface Factory { + fun create( + localMedia: LocalMedia, + ): MediaOptimizationSelectorPresenter + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorState.kt new file mode 100644 index 0000000..29e51d3 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/MediaOptimizationSelectorState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments.video + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import kotlinx.collections.immutable.ImmutableList + +data class MediaOptimizationSelectorState( + val maxUploadSize: AsyncData, + val videoSizeEstimations: AsyncData>, + val isImageOptimizationEnabled: Boolean?, + val selectedVideoPreset: VideoCompressionPreset?, + val displayMediaSelectorViews: Boolean?, + val displayVideoPresetSelectorDialog: Boolean, + val eventSink: (MediaOptimizationSelectorEvent) -> Unit +) + +data class VideoUploadEstimation( + val preset: VideoCompressionPreset, + val sizeInBytes: Long, + val canUpload: Boolean, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/VideoMetadataExtractor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/VideoMetadataExtractor.kt new file mode 100644 index 0000000..a6945b5 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/VideoMetadataExtractor.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments.video + +import android.content.Context +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.util.Size +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.annotations.ApplicationContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +interface VideoMetadataExtractor : AutoCloseable { + fun getSize(): Result + fun getDuration(): Result + interface Factory { + fun create(uri: Uri): VideoMetadataExtractor + } +} + +@ContributesBinding(AppScope::class) +@AssistedInject +class DefaultVideoMetadataExtractor( + @ApplicationContext private val context: Context, + @Assisted private val uri: Uri, +) : VideoMetadataExtractor { + @ContributesBinding(AppScope::class) + @AssistedFactory + interface Factory : VideoMetadataExtractor.Factory { + override fun create(uri: Uri): DefaultVideoMetadataExtractor + } + + // Don't use `by lazy` so we can catch any exceptions thrown during initialization + private val mediaMetadataRetriever = lazy { + MediaMetadataRetriever().apply { + setDataSource(context, uri) + } + } + + override fun getSize(): Result = runCatchingExceptions { + val width = mediaMetadataRetriever.value.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() + val height = mediaMetadataRetriever.value.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() + + @Suppress("ComplexCondition") + if (width != null && width > 0 && height != null && height > 0) { + Size(width, height) + } else { + error("Could not retrieve video size from metadata for $uri") + } + } + + override fun getDuration(): Result = runCatchingExceptions { + mediaMetadataRetriever.value.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() + ?.takeIf { it > 0L } + ?.milliseconds + ?: error("Could not retrieve video duration from metadata") + } + + override fun close() { + if (mediaMetadataRetriever.isInitialized()) { + mediaMetadataRetriever.value.release() + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeEvent.kt new file mode 100644 index 0000000..d11f5f9 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeEvent.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import io.element.android.libraries.matrix.api.core.UserId + +sealed interface IdentityChangeEvent { + data class PinIdentity(val userId: UserId) : IdentityChangeEvent + data class WithdrawVerification(val userId: UserId) : IdentityChangeEvent +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeState.kt new file mode 100644 index 0000000..25344bf --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeState.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import io.element.android.libraries.matrix.ui.room.RoomMemberIdentityStateChange +import kotlinx.collections.immutable.ImmutableList + +data class IdentityChangeState( + val roomMemberIdentityStateChanges: ImmutableList, + val eventSink: (IdentityChangeEvent) -> Unit, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt new file mode 100644 index 0000000..dcf9056 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenter.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.rememberCoroutineScope +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +@Inject +class IdentityChangeStatePresenter( + private val room: JoinedRoom, + private val encryptionService: EncryptionService, +) : Presenter { + @Composable + override fun present(): IdentityChangeState { + val coroutineScope = rememberCoroutineScope() + val roomMemberIdentityStateChange by produceState(persistentListOf()) { + room.roomMemberIdentityStateChange(waitForEncryption = true).collect { value = it } + } + + fun handleEvent(event: IdentityChangeEvent) { + when (event) { + is IdentityChangeEvent.WithdrawVerification -> { + coroutineScope.withdrawVerification(event.userId) + } + is IdentityChangeEvent.PinIdentity -> { + coroutineScope.pinUserIdentity(event.userId) + } + } + } + + return IdentityChangeState( + roomMemberIdentityStateChanges = roomMemberIdentityStateChange, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.pinUserIdentity(userId: UserId) = launch { + encryptionService.pinUserIdentity(userId) + .onFailure { + Timber.e(it, "Failed to pin identity for user $userId") + } + } + + private fun CoroutineScope.withdrawVerification(userId: UserId) = launch { + encryptionService.withdrawVerification(userId) + .onFailure { + Timber.e(it, "Failed to withdraw verification for user $userId") + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateProvider.kt new file mode 100644 index 0000000..47d1947 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateProvider.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +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.UserId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import io.element.android.libraries.matrix.ui.room.IdentityRoomMember +import io.element.android.libraries.matrix.ui.room.RoomMemberIdentityStateChange +import kotlinx.collections.immutable.toImmutableList + +class IdentityChangeStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anIdentityChangeState(), + anIdentityChangeState( + roomMemberIdentityStateChanges = listOf( + aRoomMemberIdentityStateChange( + identityRoomMember = anIdentityRoomMember(), + identityState = IdentityState.PinViolation, + ), + ), + ), + anIdentityChangeState( + roomMemberIdentityStateChanges = listOf( + aRoomMemberIdentityStateChange( + identityRoomMember = anIdentityRoomMember(displayNameOrDefault = "Alice"), + identityState = IdentityState.VerificationViolation, + ), + ), + ), + ) +} + +internal fun aRoomMemberIdentityStateChange( + identityRoomMember: IdentityRoomMember = anIdentityRoomMember(), + identityState: IdentityState = IdentityState.PinViolation, +) = RoomMemberIdentityStateChange( + identityRoomMember = identityRoomMember, + identityState = identityState, +) + +internal fun anIdentityChangeState( + roomMemberIdentityStateChanges: List = emptyList(), + eventSink: (IdentityChangeEvent) -> Unit = {}, +) = IdentityChangeState( + roomMemberIdentityStateChanges = roomMemberIdentityStateChanges.toImmutableList(), + eventSink = eventSink, +) + +internal fun anIdentityRoomMember( + userId: UserId = UserId("@alice:example.com"), + displayNameOrDefault: String = userId.extractedDisplayName, + avatarData: AvatarData = AvatarData( + id = userId.value, + name = null, + url = null, + size = AvatarSize.ComposerAlert, + ), +) = IdentityRoomMember( + userId = userId, + displayNameOrDefault = displayNameOrDefault, + avatarData = avatarData, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt new file mode 100644 index 0000000..9235273 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateView.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.appconfig.LearnMoreConfig +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertLevel +import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import io.element.android.libraries.matrix.api.encryption.identity.isAViolation +import io.element.android.libraries.matrix.ui.room.RoomMemberIdentityStateChange +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun IdentityChangeStateView( + state: IdentityChangeState, + onLinkClick: (String, Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + // Pick the first identity change that is a violation + val identityChangeViolation = state.roomMemberIdentityStateChanges.firstOrNull { + it.identityState.isAViolation() + } + when (identityChangeViolation?.identityState) { + IdentityState.PinViolation -> ViolationAlert( + identityChangeViolation = identityChangeViolation, + onLinkClick = onLinkClick, + textId = CommonStrings.crypto_identity_change_pin_violation_new, + isCritical = false, + submitTextId = CommonStrings.action_dismiss, + onSubmitClick = { state.eventSink(IdentityChangeEvent.PinIdentity(identityChangeViolation.identityRoomMember.userId)) }, + modifier = modifier, + ) + IdentityState.VerificationViolation -> ViolationAlert( + identityChangeViolation = identityChangeViolation, + onLinkClick = onLinkClick, + textId = CommonStrings.crypto_identity_change_verification_violation_new, + isCritical = true, + submitTextId = CommonStrings.crypto_identity_change_withdraw_verification_action, + onSubmitClick = { state.eventSink(IdentityChangeEvent.WithdrawVerification(identityChangeViolation.identityRoomMember.userId)) }, + modifier = modifier, + ) + else -> Unit + } +} + +@Composable +private fun ViolationAlert( + identityChangeViolation: RoomMemberIdentityStateChange, + onLinkClick: (String, Boolean) -> Unit, + @StringRes textId: Int, + isCritical: Boolean, + @StringRes submitTextId: Int, + onSubmitClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ComposerAlertMolecule( + modifier = modifier, + avatar = identityChangeViolation.identityRoomMember.avatarData, + content = buildAnnotatedString { + val learnMoreStr = stringResource(CommonStrings.action_learn_more) + val displayName = identityChangeViolation.identityRoomMember.displayNameOrDefault + val userIdStr = stringResource( + CommonStrings.crypto_identity_change_pin_violation_new_user_id, + identityChangeViolation.identityRoomMember.userId, + ) + val fullText = stringResource(textId, displayName, userIdStr, learnMoreStr) + append(fullText) + val userIdStartIndex = fullText.indexOf(userIdStr) + addStyle( + style = SpanStyle( + fontWeight = FontWeight.Bold, + ), + start = userIdStartIndex, + end = userIdStartIndex + userIdStr.length, + ) + val learnMoreStartIndex = fullText.lastIndexOf(learnMoreStr) + addStyle( + style = SpanStyle( + textDecoration = TextDecoration.Underline, + fontWeight = FontWeight.Bold, + color = ElementTheme.colors.textPrimary + ), + start = learnMoreStartIndex, + end = learnMoreStartIndex + learnMoreStr.length, + ) + addLink( + url = LinkAnnotation.Url( + url = LearnMoreConfig.IDENTITY_CHANGE_URL, + linkInteractionListener = { + onLinkClick(LearnMoreConfig.IDENTITY_CHANGE_URL, true) + } + ), + start = learnMoreStartIndex, + end = learnMoreStartIndex + learnMoreStr.length, + ) + }, + submitText = stringResource(submitTextId), + onSubmitClick = onSubmitClick, + level = if (isCritical) ComposerAlertLevel.Critical else ComposerAlertLevel.Default, + ) +} + +@PreviewsDayNight +@Composable +internal fun IdentityChangeStateViewPreview( + @PreviewParameter(IdentityChangeStateProvider::class) state: IdentityChangeState, +) = ElementPreview { + IdentityChangeStateView( + state = state, + onLinkClick = { _, _ -> }, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt new file mode 100644 index 0000000..b434656 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.messages.impl.MessagesView +import io.element.android.features.messages.impl.aMessagesState +import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown + +@PreviewsDayNight +@Composable +internal fun MessagesViewWithIdentityChangePreview( + @PreviewParameter(IdentityChangeStateProvider::class) identityChangeState: IdentityChangeState +) = ElementPreview { + MessagesView( + state = aMessagesState( + composerState = aMessageComposerState( + textEditorState = aTextEditorStateMarkdown( + initialText = "", + initialFocus = false, + ) + ), + identityChangeState = identityChangeState, + ), + onBackClick = {}, + onRoomDetailsClick = {}, + onEventContentClick = { _, _ -> false }, + onUserDataClick = {}, + onLinkClick = { _, _ -> }, + onSendLocationClick = {}, + onCreatePollClick = {}, + onJoinCallClick = {}, + onViewAllPinnedMessagesClick = {}, + knockRequestsBannerView = {} + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/VerifiedUserSendFailure.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/VerifiedUserSendFailure.kt new file mode 100644 index 0000000..0204617 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/VerifiedUserSendFailure.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.sendfailure + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface VerifiedUserSendFailure { + data object None : VerifiedUserSendFailure + + sealed interface UnsignedDevice : VerifiedUserSendFailure { + data object FromYou : UnsignedDevice + data class FromOther(val userDisplayName: String) : UnsignedDevice + } + + data class ChangedIdentity( + val userDisplayName: String, + ) : VerifiedUserSendFailure +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/VerifiedUserSendFailureFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/VerifiedUserSendFailureFactory.kt new file mode 100644 index 0000000..65184d9 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/VerifiedUserSendFailureFactory.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.sendfailure + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState + +@Inject +class VerifiedUserSendFailureFactory( + private val room: BaseRoom, +) { + suspend fun create( + sendState: LocalEventSendState?, + ): VerifiedUserSendFailure { + return when (sendState) { + is LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice -> { + val userId = sendState.devices.keys.firstOrNull() + if (userId == null) { + VerifiedUserSendFailure.None + } else { + if (userId == room.sessionId) { + VerifiedUserSendFailure.UnsignedDevice.FromYou + } else { + val displayName = room.userDisplayName(userId).getOrNull() ?: userId.value + VerifiedUserSendFailure.UnsignedDevice.FromOther(displayName) + } + } + } + is LocalEventSendState.Failed.VerifiedUserChangedIdentity -> { + val userId = sendState.users.firstOrNull() + if (userId == null) { + VerifiedUserSendFailure.None + } else { + val displayName = room.userDisplayName(userId).getOrNull() ?: userId.value + VerifiedUserSendFailure.ChangedIdentity(displayName) + } + } + else -> VerifiedUserSendFailure.None + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureEvents.kt new file mode 100644 index 0000000..242dc58 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureEvents.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.sendfailure.resolve + +import io.element.android.features.messages.impl.timeline.model.TimelineItem + +sealed interface ResolveVerifiedUserSendFailureEvents { + data class ComputeForMessage( + val messageEvent: TimelineItem.Event, + ) : ResolveVerifiedUserSendFailureEvents + + data object ResolveAndResend : ResolveVerifiedUserSendFailureEvents + data object Retry : ResolveVerifiedUserSendFailureEvents + data object Dismiss : ResolveVerifiedUserSendFailureEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailurePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailurePresenter.kt new file mode 100644 index 0000000..720e676 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailurePresenter.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.sendfailure.resolve + +import androidx.compose.runtime.Composable +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 dev.zacsweers.metro.Inject +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import kotlinx.coroutines.launch + +@Inject +class ResolveVerifiedUserSendFailurePresenter( + private val room: JoinedRoom, + private val verifiedUserSendFailureFactory: VerifiedUserSendFailureFactory, +) : Presenter { + @Composable + override fun present(): ResolveVerifiedUserSendFailureState { + var resolver by remember { + mutableStateOf(null) + } + val verifiedUserSendFailure by produceState(VerifiedUserSendFailure.None, resolver?.currentSendFailure?.value) { + val currentSendFailure = resolver?.currentSendFailure?.value + value = verifiedUserSendFailureFactory.create(currentSendFailure) + } + + val resolveAction = remember { + mutableStateOf>(AsyncAction.Uninitialized) + } + val retryAction = remember { + mutableStateOf>(AsyncAction.Uninitialized) + } + val coroutineScope = rememberCoroutineScope() + + fun handleEvent(event: ResolveVerifiedUserSendFailureEvents) { + when (event) { + is ResolveVerifiedUserSendFailureEvents.ComputeForMessage -> { + val sendState = event.messageEvent.localSendState as? LocalEventSendState.Failed.VerifiedUser + val transactionId = event.messageEvent.transactionId + val sendHandle = event.messageEvent.sendhandle + resolver = if (sendState != null && transactionId != null && sendHandle != null) { + VerifiedUserSendFailureResolver( + room = room, + transactionId = transactionId, + sendHandle = sendHandle, + iterator = VerifiedUserSendFailureIterator.from(sendState) + ) + } else { + null + } + } + ResolveVerifiedUserSendFailureEvents.Dismiss -> { + resolver = null + } + ResolveVerifiedUserSendFailureEvents.Retry -> { + coroutineScope.launch { + resolver?.run { + runUpdatingState(retryAction) { + resend() + } + } + } + } + ResolveVerifiedUserSendFailureEvents.ResolveAndResend -> { + coroutineScope.launch { + resolver?.run { + runUpdatingState(resolveAction) { + resolveAndResend() + } + } + } + } + } + } + + return ResolveVerifiedUserSendFailureState( + verifiedUserSendFailure = verifiedUserSendFailure, + resolveAction = resolveAction.value, + retryAction = retryAction.value, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureState.kt new file mode 100644 index 0000000..dfe9ac3 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureState.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.sendfailure.resolve + +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure +import io.element.android.libraries.architecture.AsyncAction + +data class ResolveVerifiedUserSendFailureState( + val verifiedUserSendFailure: VerifiedUserSendFailure, + val resolveAction: AsyncAction, + val retryAction: AsyncAction, + val eventSink: (ResolveVerifiedUserSendFailureEvents) -> Unit +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureStateProvider.kt new file mode 100644 index 0000000..251f0e6 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureStateProvider.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.sendfailure.resolve + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure +import io.element.android.libraries.architecture.AsyncAction + +open class ResolveVerifiedUserSendFailureStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aResolveVerifiedUserSendFailureState(), + aResolveVerifiedUserSendFailureState( + verifiedUserSendFailure = anUnsignedDeviceSendFailure() + ), + aResolveVerifiedUserSendFailureState( + verifiedUserSendFailure = aChangedIdentitySendFailure() + ) + ) +} + +fun aResolveVerifiedUserSendFailureState( + verifiedUserSendFailure: VerifiedUserSendFailure = VerifiedUserSendFailure.None, + resolveAction: AsyncAction = AsyncAction.Uninitialized, + retryAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (ResolveVerifiedUserSendFailureEvents) -> Unit = {} +) = ResolveVerifiedUserSendFailureState( + verifiedUserSendFailure = verifiedUserSendFailure, + resolveAction = resolveAction, + retryAction = retryAction, + eventSink = eventSink +) + +fun anUnsignedDeviceSendFailure(userDisplayName: String = "Alice") = VerifiedUserSendFailure.UnsignedDevice.FromOther( + userDisplayName = userDisplayName, +) + +fun aChangedIdentitySendFailure(userDisplayName: String = "Alice") = VerifiedUserSendFailure.ChangedIdentity( + userDisplayName = userDisplayName, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureView.kt new file mode 100644 index 0000000..3f881e7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureView.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.sendfailure.resolve + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState +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.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.messages.impl.crypto.sendfailure.VerifiedUserSendFailure +import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ResolveVerifiedUserSendFailureView( + state: ResolveVerifiedUserSendFailureState, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var showSheet by remember { mutableStateOf(false) } + + fun dismiss() { + state.eventSink(ResolveVerifiedUserSendFailureEvents.Dismiss) + } + + fun onRetryClick() { + state.eventSink(ResolveVerifiedUserSendFailureEvents.Retry) + } + + fun onResolveAndResendClick() { + state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend) + } + + LaunchedEffect(state.verifiedUserSendFailure) { + if (state.verifiedUserSendFailure is VerifiedUserSendFailure.None) { + sheetState.hide() + showSheet = false + } else { + showSheet = true + } + } + + Box(modifier = modifier) { + if (showSheet) { + ModalBottomSheet( + modifier = Modifier + .systemBarsPadding() + .navigationBarsPadding(), + sheetState = sheetState, + onDismissRequest = ::dismiss, + ) { + IconTitleSubtitleMolecule( + modifier = Modifier.padding(24.dp), + title = state.verifiedUserSendFailure.title(), + subTitle = state.verifiedUserSendFailure.subtitle(), + iconStyle = BigIcon.Style.AlertSolid, + ) + ButtonColumnMolecule( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + ) { + Button( + modifier = Modifier.fillMaxWidth(), + text = state.verifiedUserSendFailure.resolveAction(), + showProgress = state.resolveAction.isLoading(), + onClick = ::onResolveAndResendClick + ) + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = CommonStrings.action_retry), + showProgress = state.retryAction.isLoading(), + onClick = ::onRetryClick + ) + TextButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = CommonStrings.action_cancel_for_now), + onClick = ::dismiss, + ) + } + } + } + } +} + +@Composable +private fun VerifiedUserSendFailure.title(): String { + return when (this) { + is VerifiedUserSendFailure.UnsignedDevice.FromOther -> stringResource( + id = CommonStrings.screen_resolve_send_failure_unsigned_device_title, + userDisplayName + ) + VerifiedUserSendFailure.UnsignedDevice.FromYou -> stringResource(id = CommonStrings.screen_resolve_send_failure_you_unsigned_device_title) + is VerifiedUserSendFailure.ChangedIdentity -> stringResource( + id = CommonStrings.screen_resolve_send_failure_changed_identity_title, + userDisplayName + ) + VerifiedUserSendFailure.None -> "" + } +} + +@Composable +private fun VerifiedUserSendFailure.subtitle(): String { + return when (this) { + is VerifiedUserSendFailure.UnsignedDevice.FromOther -> stringResource( + id = CommonStrings.screen_resolve_send_failure_unsigned_device_subtitle, + userDisplayName, + userDisplayName, + ) + VerifiedUserSendFailure.UnsignedDevice.FromYou -> stringResource(id = CommonStrings.screen_resolve_send_failure_you_unsigned_device_subtitle) + is VerifiedUserSendFailure.ChangedIdentity -> stringResource( + id = CommonStrings.screen_resolve_send_failure_changed_identity_subtitle, + userDisplayName + ) + VerifiedUserSendFailure.None -> "" + } +} + +@Composable +private fun VerifiedUserSendFailure.resolveAction(): String { + return when (this) { + is VerifiedUserSendFailure.UnsignedDevice -> stringResource(id = CommonStrings.screen_resolve_send_failure_unsigned_device_primary_button_title) + is VerifiedUserSendFailure.ChangedIdentity -> stringResource(id = CommonStrings.screen_resolve_send_failure_changed_identity_primary_button_title) + VerifiedUserSendFailure.None -> "" + } +} + +@PreviewsDayNight +@Composable +internal fun ResolveVerifiedUserSendFailureViewPreview( + @PreviewParameter(ResolveVerifiedUserSendFailureStateProvider::class) state: ResolveVerifiedUserSendFailureState +) = ElementPreview { + ResolveVerifiedUserSendFailureView(state) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/VerifiedUserSendFailureIterator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/VerifiedUserSendFailureIterator.kt new file mode 100644 index 0000000..db82606 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/VerifiedUserSendFailureIterator.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.sendfailure.resolve + +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import timber.log.Timber + +/** + * Iterator for [LocalEventSendState.Failed.VerifiedUser] + * Allow to iterate through the internal state of the failure. + * This is useful to allow solving the failure step by step (e.g. for each user). + */ +interface VerifiedUserSendFailureIterator : Iterator { + companion object { + fun from(failure: LocalEventSendState.Failed.VerifiedUser): VerifiedUserSendFailureIterator { + return when (failure) { + is LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice -> UnsignedDeviceSendFailureIterator(failure) + is LocalEventSendState.Failed.VerifiedUserChangedIdentity -> ChangedIdentitySendFailureIterator(failure) + } + } + } +} + +class UnsignedDeviceSendFailureIterator( + failure: LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice +) : VerifiedUserSendFailureIterator { + private val iterator = failure.devices.iterator() + + init { + if (!hasNext()) { + Timber.w("Got $failure without any devices, shouldn't happen.") + } + } + + override fun hasNext(): Boolean { + return iterator.hasNext() + } + + override fun next(): LocalEventSendState.Failed.VerifiedUser { + val (userId, deviceIds) = iterator.next() + return LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice( + mapOf(userId to deviceIds) + ) + } +} + +class ChangedIdentitySendFailureIterator( + failure: LocalEventSendState.Failed.VerifiedUserChangedIdentity +) : VerifiedUserSendFailureIterator { + private val iterator = failure.users.iterator() + + init { + if (!hasNext()) { + Timber.w("Got $failure without any users, shouldn't happen.") + } + } + + override fun hasNext(): Boolean { + return iterator.hasNext() + } + + override fun next(): LocalEventSendState.Failed.VerifiedUser { + val userId = iterator.next() + return LocalEventSendState.Failed.VerifiedUserChangedIdentity( + listOf(userId) + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/VerifiedUserSendFailureResolver.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/VerifiedUserSendFailureResolver.kt new file mode 100644 index 0000000..c669606 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/VerifiedUserSendFailureResolver.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.sendfailure.resolve + +import androidx.compose.runtime.mutableStateOf +import io.element.android.libraries.matrix.api.core.SendHandle +import io.element.android.libraries.matrix.api.core.TransactionId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import timber.log.Timber + +/** + * This class is responsible for resolving and resending a failed message sent to a verified user. + * It also allow to resend the message without resolving the failure, for example if the user has in the meantime verified their device again. + * It's using the [VerifiedUserSendFailureIterator] to iterate over the different failures (ie. the different users concerned by the failure). + * This way, the user can resolve and resend the message for each user concerned, one by one. + */ +class VerifiedUserSendFailureResolver( + private val room: JoinedRoom, + private val transactionId: TransactionId, + private val sendHandle: SendHandle, + private val iterator: VerifiedUserSendFailureIterator, +) { + val currentSendFailure = mutableStateOf(null) + + init { + if (iterator.hasNext()) { + currentSendFailure.value = iterator.next() + } + } + + suspend fun resend(): Result { + return sendHandle.retry() + .onSuccess { + Timber.d("Succeed to resend message with transactionId: $transactionId") + currentSendFailure.value = null + } + .onFailure { + Timber.e(it, "Failed to resend message with transactionId: $transactionId") + } + } + + suspend fun resolveAndResend(): Result { + return when (val failure = currentSendFailure.value) { + is LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice -> { + room.ignoreDeviceTrustAndResend(failure.devices, sendHandle) + } + is LocalEventSendState.Failed.VerifiedUserChangedIdentity -> { + room.withdrawVerificationAndResend(failure.users, sendHandle) + } + else -> { + Result.failure(IllegalStateException("Unknown send failure type")) + } + }.onSuccess { + Timber.d("Succeed to resolve and resend message with transactionId: $transactionId") + if (iterator.hasNext()) { + val failure = iterator.next() + currentSendFailure.value = failure + } else { + currentSendFailure.value = null + Timber.d("No more failure to resolve for transactionId: $transactionId") + } + }.onFailure { + Timber.e(it, "Failed to resolve and resend message with transactionId: $transactionId") + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt new file mode 100644 index 0000000..a345e09 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.di + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState +import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState +import io.element.android.features.messages.impl.link.LinkPresenter +import io.element.android.features.messages.impl.link.LinkState +import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerPresenter +import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState +import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetPresenter +import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionPresenter +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.messages.impl.typing.TypingNotificationPresenter +import io.element.android.features.messages.impl.typing.TypingNotificationState +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.RoomScope + +@ContributesTo(RoomScope::class) +@BindingContainer +interface MessagesBindsModule { + @Binds + fun bindPinnedMessagesBannerPresenter(presenter: PinnedMessagesBannerPresenter): Presenter + + @Binds + fun bindResolveVerifiedUserSendFailurePresenter(presenter: ResolveVerifiedUserSendFailurePresenter): Presenter + + @Binds + fun bindTypingNotificationPresenter(presenter: TypingNotificationPresenter): Presenter + + @Binds + fun bindTimelineProtectionPresenter(presenter: TimelineProtectionPresenter): Presenter + + @Binds + fun bindLinkPresenter(presenter: LinkPresenter): Presenter + + @Binds + fun bindCustomReactionPresenter(presenter: CustomReactionPresenter): Presenter + + @Binds + fun bindReactionSummaryPresenter(presenter: ReactionSummaryPresenter): Presenter + + @Binds + fun bindReadReceiptBottomSheetPresenter(presenter: ReadReceiptBottomSheetPresenter): Presenter + + @Binds + fun bindIdentityChangeStatePresenter(presenter: IdentityChangeStatePresenter): Presenter +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesProvidesModule.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesProvidesModule.kt new file mode 100644 index 0000000..856a14c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesProvidesModule.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.di + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import io.element.android.features.messages.impl.timeline.di.LiveTimeline +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.Timeline + +@ContributesTo(RoomScope::class) +@BindingContainer +object MessagesProvidesModule { + @Provides + @LiveTimeline + fun provideLiveTimeline(joinedRoom: JoinedRoom): Timeline = joinedRoom.liveTimeline +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/ComposerDraftService.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/ComposerDraftService.kt new file mode 100644 index 0000000..765fa69 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/ComposerDraftService.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.draft + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.room.draft.ComposerDraft + +interface ComposerDraftService { + suspend fun loadDraft(roomId: RoomId, threadRoot: ThreadId?, isVolatile: Boolean): ComposerDraft? + suspend fun updateDraft(roomId: RoomId, threadRoot: ThreadId?, draft: ComposerDraft?, isVolatile: Boolean) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/ComposerDraftStore.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/ComposerDraftStore.kt new file mode 100644 index 0000000..0e8d707 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/ComposerDraftStore.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.draft + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.room.draft.ComposerDraft + +interface ComposerDraftStore { + suspend fun loadDraft(roomId: RoomId, threadRoot: ThreadId?): ComposerDraft? + suspend fun updateDraft(roomId: RoomId, threadRoot: ThreadId?, draft: ComposerDraft?) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/DefaultComposerDraftService.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/DefaultComposerDraftService.kt new file mode 100644 index 0000000..5ac81a3 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/DefaultComposerDraftService.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.draft + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.room.draft.ComposerDraft + +@ContributesBinding(RoomScope::class) +class DefaultComposerDraftService( + private val volatileComposerDraftStore: VolatileComposerDraftStore, + private val matrixComposerDraftStore: MatrixComposerDraftStore, +) : ComposerDraftService { + override suspend fun loadDraft(roomId: RoomId, threadRoot: ThreadId?, isVolatile: Boolean): ComposerDraft? { + return getStore(isVolatile).loadDraft(roomId, threadRoot) + } + + override suspend fun updateDraft(roomId: RoomId, threadRoot: ThreadId?, draft: ComposerDraft?, isVolatile: Boolean) { + getStore(isVolatile).updateDraft(roomId, threadRoot, draft) + } + + private fun getStore(isVolatile: Boolean): ComposerDraftStore { + return if (isVolatile) { + volatileComposerDraftStore + } else { + matrixComposerDraftStore + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/MatrixComposerDraftStore.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/MatrixComposerDraftStore.kt new file mode 100644 index 0000000..e105851 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/MatrixComposerDraftStore.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.draft + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.room.draft.ComposerDraft +import timber.log.Timber + +/** + * A draft store that persists drafts in the room state. + * It can be used to store drafts that should be persisted across app restarts. + */ +@Inject +class MatrixComposerDraftStore( + private val client: MatrixClient, +) : ComposerDraftStore { + override suspend fun loadDraft(roomId: RoomId, threadRoot: ThreadId?): ComposerDraft? { + return client.getRoom(roomId)?.use { room -> + room.loadComposerDraft(threadRoot) + .onFailure { + Timber.e(it, "Failed to load composer draft for room $roomId") + } + .onSuccess { draft -> + room.clearComposerDraft(threadRoot) + Timber.d("Loaded composer draft for room $roomId : $draft") + } + .getOrNull() + } + } + + override suspend fun updateDraft(roomId: RoomId, threadRoot: ThreadId?, draft: ComposerDraft?) { + client.getRoom(roomId)?.use { room -> + val updateDraftResult = if (draft == null) { + room.clearComposerDraft(threadRoot) + } else { + room.saveComposerDraft(draft, threadRoot) + } + updateDraftResult + .onFailure { + Timber.e(it, "Failed to update composer draft for room $roomId") + } + .onSuccess { + Timber.d("Updated composer draft for room $roomId") + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/VolatileComposerDraftStore.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/VolatileComposerDraftStore.kt new file mode 100644 index 0000000..0dd6e33 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/draft/VolatileComposerDraftStore.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.draft + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.room.draft.ComposerDraft + +/** + * A volatile draft store that keeps drafts in memory only. + * It can be used to store drafts that should not be persisted across app restarts. + * Currently it's used to store draft message when moving to edit mode. + */ +@Inject +class VolatileComposerDraftStore : ComposerDraftStore { + private val drafts: MutableMap = mutableMapOf() + + override suspend fun loadDraft(roomId: RoomId, threadRoot: ThreadId?): ComposerDraft? { + val key = threadRoot?.value ?: roomId.value + // Remove the draft from the map when it is loaded + return drafts.remove(key) + } + + override suspend fun updateDraft(roomId: RoomId, threadRoot: ThreadId?, draft: ComposerDraft?) { + val key = threadRoot?.value ?: roomId.value + if (draft == null) { + drafts.remove(key) + } else { + drafts[key] = draft + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/ConfirmingLinkClick.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/ConfirmingLinkClick.kt new file mode 100644 index 0000000..88106db --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/ConfirmingLinkClick.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.wysiwyg.link.Link + +data class ConfirmingLinkClick( + val link: Link, +) : AsyncAction.Confirming diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkChecker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkChecker.kt new file mode 100644 index 0000000..1a8456b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkChecker.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.core.extensions.containsRtLOverride +import io.element.android.wysiwyg.link.Link +import java.net.URI + +interface LinkChecker { + fun isSafe(link: Link): Boolean +} + +@ContributesBinding(AppScope::class) +class DefaultLinkChecker : LinkChecker { + override fun isSafe(link: Link): Boolean { + return if (link.url.containsRtLOverride()) { + false + } else { + val textUrl = tryOrNull { URI(link.text).toURL() } + val urlUrl = tryOrNull { URI(link.url).toURL() } + if (textUrl == null || urlUrl == null) { + // The text is not a Url, or the url is not valid + true + } else { + // the hosts must match + textUrl.host == urlUrl.host + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkEvents.kt new file mode 100644 index 0000000..ce817bf --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkEvents.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import io.element.android.wysiwyg.link.Link + +sealed interface LinkEvents { + data class OnLinkClick(val link: Link) : LinkEvents + data object Confirm : LinkEvents + data object Cancel : LinkEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkPresenter.kt new file mode 100644 index 0000000..b688665 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkPresenter.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.wysiwyg.link.Link + +@Inject +class LinkPresenter( + private val linkChecker: LinkChecker, +) : Presenter { + @Composable + override fun present(): LinkState { + val linkClick: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + + fun handleEvent(event: LinkEvents) { + when (event) { + is LinkEvents.OnLinkClick -> { + linkClick.value = AsyncAction.Loading + val result = linkChecker.isSafe(event.link) + if (result) { + linkClick.value = AsyncAction.Success(event.link) + } else { + // Confirm first + linkClick.value = ConfirmingLinkClick(event.link) + } + } + LinkEvents.Confirm -> { + linkClick.value = (linkClick.value as? ConfirmingLinkClick) + ?.let { AsyncAction.Success(it.link) } + ?: AsyncAction.Uninitialized + } + LinkEvents.Cancel -> { + linkClick.value = AsyncAction.Uninitialized + } + } + } + return LinkState( + linkClick = linkClick.value, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkState.kt new file mode 100644 index 0000000..c06d23e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkState.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.wysiwyg.link.Link + +data class LinkState( + val linkClick: AsyncAction, + val eventSink: (LinkEvents) -> Unit, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkStateProvider.kt new file mode 100644 index 0000000..8388cbe --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkStateProvider.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.wysiwyg.link.Link + +open class LinkStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aLinkState(), + aLinkState( + linkClick = ConfirmingLinkClick( + Link( + url = "https://evil.io", + text = "https://element.io" + ), + ), + ), + ) +} + +fun aLinkState( + linkClick: AsyncAction = AsyncAction.Uninitialized, + eventSink: (LinkEvents) -> Unit = {}, +) = LinkState( + linkClick = linkClick, + eventSink = eventSink, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkView.kt new file mode 100644 index 0000000..1a7558d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/link/LinkView.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.core.extensions.ensureEndsLeftToRight +import io.element.android.libraries.core.extensions.filterDirectionOverrides +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.wysiwyg.link.Link + +@Composable +fun LinkView( + state: LinkState, + onLinkValid: (Link) -> Unit, + modifier: Modifier = Modifier, +) { + when (state.linkClick) { + AsyncAction.Uninitialized, + AsyncAction.Loading, + is AsyncAction.Failure -> Unit + is AsyncAction.Confirming -> { + if (state.linkClick is ConfirmingLinkClick) { + ConfirmationDialog( + modifier = modifier, + title = stringResource(CommonStrings.dialog_confirm_link_title), + content = stringResource( + CommonStrings.dialog_confirm_link_message, + state.linkClick.link.text.ensureEndsLeftToRight(), + state.linkClick.link.url.filterDirectionOverrides(), + ), + submitText = stringResource(CommonStrings.action_continue), + onSubmitClick = { + state.eventSink(LinkEvents.Confirm) + }, + onDismiss = { + state.eventSink(LinkEvents.Cancel) + }, + ) + } + } + is AsyncAction.Success -> { + val latestOnLinkValid by rememberUpdatedState(onLinkValid) + LaunchedEffect(state.linkClick.data) { + latestOnLinkValid(state.linkClick.data) + state.eventSink(LinkEvents.Cancel) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun LinkViewPreview(@PreviewParameter(LinkStateProvider::class) state: LinkState) = ElementPreview { + LinkView( + state = state, + onLinkValid = {}, + ) +} 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 0000000..1fdb61f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState +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 io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.R +import io.element.android.libraries.androidutils.ui.hideKeyboard +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +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, + onSendLocationClick: () -> Unit, + onCreatePollClick: () -> Unit, + enableTextFormatting: Boolean, + modifier: Modifier = Modifier, +) { + val localView = LocalView.current + var isVisible by rememberSaveable { mutableStateOf(state.showAttachmentSourcePicker) } + + BackHandler(enabled = isVisible) { + isVisible = false + } + + LaunchedEffect(state.showAttachmentSourcePicker) { + isVisible = 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() + true + } else { + false + } + } + // Send 'DismissAttachmentMenu' event when the bottomsheet was just hidden + LaunchedEffect(isVisible) { + if (!isVisible) { + state.eventSink(MessageComposerEvent.DismissAttachmentMenu) + } + } + + if (isVisible) { + ModalBottomSheet( + modifier = modifier, + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ), + onDismissRequest = { isVisible = false } + ) { + AttachmentSourcePickerMenu( + state = state, + enableTextFormatting = enableTextFormatting, + onSendLocationClick = onSendLocationClick, + onCreatePollClick = onCreatePollClick, + ) + } + } +} + +@Composable +private fun AttachmentSourcePickerMenu( + state: MessageComposerState, + onSendLocationClick: () -> Unit, + onCreatePollClick: () -> Unit, + enableTextFormatting: Boolean, +) { + Column( + modifier = Modifier + .navigationBarsPadding() + .imePadding() + ) { + ListItem( + modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.PhotoFromCamera) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.TakePhoto())), + headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) }, + style = ListItemStyle.Primary, + ) + ListItem( + modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.VideoFromCamera) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.VideoCall())), + headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) }, + style = ListItemStyle.Primary, + ) + ListItem( + modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.FromGallery) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())), + headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) }, + style = ListItemStyle.Primary, + ) + ListItem( + modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.FromFiles) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Attachment())), + headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_files)) }, + style = ListItemStyle.Primary, + ) + if (state.canShareLocation) { + ListItem( + modifier = Modifier.clickable { + state.eventSink(MessageComposerEvent.PickAttachmentSource.Location) + onSendLocationClick() + }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())), + headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_location)) }, + style = ListItemStyle.Primary, + ) + } + ListItem( + modifier = Modifier.clickable { + state.eventSink(MessageComposerEvent.PickAttachmentSource.Poll) + onCreatePollClick() + }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Polls())), + headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_poll)) }, + style = ListItemStyle.Primary, + ) + if (enableTextFormatting) { + ListItem( + modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.ToggleTextFormatting(enabled = true)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.TextFormatting())), + headlineContent = { Text(stringResource(R.string.screen_room_attachment_text_formatting)) }, + style = ListItemStyle.Primary, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun AttachmentSourcePickerMenuPreview() = ElementPreview { + AttachmentSourcePickerMenu( + state = aMessageComposerState( + canShareLocation = true, + ), + onSendLocationClick = {}, + onCreatePollClick = {}, + enableTextFormatting = true, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultMessageComposerContext.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultMessageComposerContext.kt new file mode 100644 index 0000000..495731b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultMessageComposerContext.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.messagecomposer + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.features.messages.api.MessageComposerContext +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.textcomposer.model.MessageComposerMode + +@SingleIn(RoomScope::class) +@ContributesBinding(RoomScope::class) +class DefaultMessageComposerContext : 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/DisabledComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/DisabledComposerView.kt new file mode 100644 index 0000000..c642904 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/DisabledComposerView.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.messagecomposer + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +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.requiredHeightIn +import androidx.compose.foundation.layout.size +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.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.IconColorButton +import io.element.android.libraries.designsystem.theme.components.IconColorButtonStyle + +@Composable +internal fun DisabledComposerView( + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .padding(3.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + IconColorButton( + onClick = {}, + imageVector = CompoundIcons.Plus(), + contentDescription = null, + iconColorButtonStyle = IconColorButtonStyle.Disabled, + ) + + val bgColor = ElementTheme.colors.bgCanvasDisabled + val borderColor = ElementTheme.colors.borderDisabled + + Box( + modifier = Modifier + .clip(RoundedCornerShape(21.dp)) + .border(0.5.dp, borderColor, RoundedCornerShape(21.dp)) + .background(color = bgColor) + .size(42.dp) + .requiredHeightIn(min = 42.dp) + .weight(1f), + ) + + Spacer(modifier = Modifier.width(8.dp)) + IconButton( + modifier = Modifier + .padding(start = 2.dp) + .size(48.dp), + enabled = false, + onClick = {}, + ) { + Icon( + modifier = Modifier.size(30.dp), + imageVector = CompoundIcons.SendSolid(), + contentDescription = "", + tint = ElementTheme.colors.iconQuaternary + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun DisabledComposerViewPreview() = ElementPreview { + Column { + DisabledComposerView( + modifier = Modifier.height(IntrinsicSize.Min), + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvent.kt new file mode 100644 index 0000000..ae82c60 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvent.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.messagecomposer + +import android.net.Uri +import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.textcomposer.model.Suggestion + +sealed interface MessageComposerEvent { + data object ToggleFullScreenState : MessageComposerEvent + data object SendMessage : MessageComposerEvent + data class SendUri(val uri: Uri) : MessageComposerEvent + data object CloseSpecialMode : MessageComposerEvent + data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvent + data object AddAttachment : MessageComposerEvent + data object DismissAttachmentMenu : MessageComposerEvent + sealed interface PickAttachmentSource : MessageComposerEvent { + data object FromGallery : PickAttachmentSource + data object FromFiles : PickAttachmentSource + data object PhotoFromCamera : PickAttachmentSource + data object VideoFromCamera : PickAttachmentSource + data object Location : PickAttachmentSource + data object Poll : PickAttachmentSource + } + + data class ToggleTextFormatting(val enabled: Boolean) : MessageComposerEvent + data class Error(val error: Throwable) : MessageComposerEvent + data class TypingNotice(val isTyping: Boolean) : MessageComposerEvent + data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvent + data class InsertSuggestion(val resolvedSuggestion: ResolvedSuggestion) : MessageComposerEvent + data object SaveDraft : MessageComposerEvent +} 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 0000000..276499d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -0,0 +1,766 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.messagecomposer + +import android.Manifest +import android.annotation.SuppressLint +import android.net.Uri +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.Composer +import im.vector.app.features.analytics.plan.Interaction +import io.element.android.features.location.api.LocationService +import io.element.android.features.messages.impl.MessagesNavigator +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.draft.ComposerDraftService +import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestionsDataSource +import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor +import io.element.android.features.messages.impl.timeline.TimelineController +import io.element.android.features.messages.impl.utils.TextPillificationHelper +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.di.annotations.SessionCoroutineScope +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.permalink.PermalinkBuilder +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.room.IntentionalMention +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.draft.ComposerDraft +import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType +import io.element.android.libraries.matrix.api.room.getDirectRoomMember +import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.timeline.TimelineException +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails +import io.element.android.libraries.matrix.ui.messages.reply.map +import io.element.android.libraries.mediapickers.api.PickerProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.api.MediaSenderFactory +import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory +import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion +import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState +import io.element.android.libraries.textcomposer.model.Message +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.TextEditorState +import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction +import io.element.android.wysiwyg.compose.RichTextEditorState +import io.element.android.wysiwyg.display.TextDisplay +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import timber.log.Timber +import kotlin.time.Duration.Companion.seconds +import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes + +@AssistedInject +class MessageComposerPresenter( + @Assisted private val navigator: MessagesNavigator, + @Assisted private val timelineController: TimelineController, + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, + private val room: JoinedRoom, + private val mediaPickerProvider: PickerProvider, + private val sessionPreferencesStore: SessionPreferencesStore, + private val localMediaFactory: LocalMediaFactory, + mediaSenderFactory: MediaSenderFactory, + private val snackbarDispatcher: SnackbarDispatcher, + private val analyticsService: AnalyticsService, + private val locationService: LocationService, + private val messageComposerContext: DefaultMessageComposerContext, + private val richTextEditorStateFactory: RichTextEditorStateFactory, + private val roomAliasSuggestionsDataSource: RoomAliasSuggestionsDataSource, + private val permalinkParser: PermalinkParser, + private val permalinkBuilder: PermalinkBuilder, + permissionsPresenterFactory: PermissionsPresenter.Factory, + private val draftService: ComposerDraftService, + private val mentionSpanProvider: MentionSpanProvider, + private val pillificationHelper: TextPillificationHelper, + private val suggestionsProcessor: SuggestionsProcessor, + private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, + private val notificationConversationService: NotificationConversationService, +) : Presenter { + @AssistedFactory + interface Factory { + fun create(timelineController: TimelineController, navigator: MessagesNavigator): MessageComposerPresenter + } + + private val mediaSender = mediaSenderFactory.create(timelineMode = timelineController.mainTimelineMode()) + + private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) + private var pendingEvent: MessageComposerEvent? = null + private val suggestionSearchTrigger = MutableStateFlow(null) + + // Used to disable some UI related elements in tests + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var isTesting: Boolean = false + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var showTextFormatting: Boolean by mutableStateOf(false) + + @SuppressLint("UnsafeOptInUsageError") + @Composable + override fun present(): MessageComposerState { + val localCoroutineScope = rememberCoroutineScope() + + val roomInfo by room.roomInfoFlow.collectAsState() + + val richTextEditorState = richTextEditorStateFactory.remember() + if (isTesting) { + richTextEditorState.isReadyToProcessActions = true + } + val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false) + + val cameraPermissionState = cameraPermissionPresenter.present() + + val canShareLocation = remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + canShareLocation.value = locationService.isServiceAvailable() + } + + val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType -> + handlePickedMedia(uri, mimeType) + } + val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType -> + handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream) + } + val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> + handlePickedMedia(uri, MimeTypes.Jpeg) + } + val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri -> + handlePickedMedia(uri, MimeTypes.Mp4) + } + val isFullScreen = rememberSaveable { + mutableStateOf(false) + } + var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) } + + val sendTypingNotifications by remember { + sessionPreferencesStore.isSendTypingNotificationsEnabled() + }.collectAsState(initial = true) + + LaunchedEffect(cameraPermissionState.permissionGranted) { + if (cameraPermissionState.permissionGranted) { + when (pendingEvent) { + is MessageComposerEvent.PickAttachmentSource.PhotoFromCamera -> cameraPhotoPicker.launch() + is MessageComposerEvent.PickAttachmentSource.VideoFromCamera -> cameraVideoPicker.launch() + else -> Unit + } + pendingEvent = null + } + } + + val suggestions = remember { mutableStateListOf() } + ResolveSuggestionsEffect(suggestions) + + DisposableEffect(Unit) { + // Declare that the user is not typing anymore when the composer is disposed + onDispose { + sessionCoroutineScope.launch { + if (sendTypingNotifications) { + room.typingNotice(false) + } + } + } + } + + val textEditorState by rememberUpdatedState( + if (showTextFormatting) { + TextEditorState.Rich(richTextEditorState, roomInfo.isEncrypted == true) + } else { + TextEditorState.Markdown(markdownTextEditorState, roomInfo.isEncrypted == true) + } + ) + + LaunchedEffect(Unit) { + val draft = draftService.loadDraft( + roomId = room.roomId, + // TODO support threads in composer + threadRoot = null, + isVolatile = false + ) + if (draft != null) { + applyDraft(draft, markdownTextEditorState, richTextEditorState) + } + } + + fun handleEvent(event: MessageComposerEvent) { + when (event) { + MessageComposerEvent.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value + MessageComposerEvent.CloseSpecialMode -> { + if (messageComposerContext.composerMode.isEditing) { + localCoroutineScope.launch { + resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = true) + } + } else { + messageComposerContext.composerMode = MessageComposerMode.Normal + } + } + is MessageComposerEvent.SendMessage -> { + sessionCoroutineScope.sendMessage( + markdownTextEditorState = markdownTextEditorState, + richTextEditorState = richTextEditorState, + ) + } + is MessageComposerEvent.SendUri -> { + val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId + sessionCoroutineScope.sendAttachment( + attachment = Attachment.Media( + localMedia = localMediaFactory.createFromUri( + uri = event.uri, + mimeType = null, + name = null, + formattedFileSize = null + ), + ), + inReplyToEventId = inReplyToEventId, + ) + + // Reset composer since the attachment has been sent + messageComposerContext.composerMode = MessageComposerMode.Normal + } + is MessageComposerEvent.SetMode -> { + localCoroutineScope.setMode(event.composerMode, markdownTextEditorState, richTextEditorState) + } + MessageComposerEvent.AddAttachment -> localCoroutineScope.launch { + showAttachmentSourcePicker = true + } + MessageComposerEvent.DismissAttachmentMenu -> showAttachmentSourcePicker = false + MessageComposerEvent.PickAttachmentSource.FromGallery -> localCoroutineScope.launch { + showAttachmentSourcePicker = false + galleryMediaPicker.launch() + } + MessageComposerEvent.PickAttachmentSource.FromFiles -> localCoroutineScope.launch { + showAttachmentSourcePicker = false + filesPicker.launch() + } + MessageComposerEvent.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launch { + showAttachmentSourcePicker = false + if (cameraPermissionState.permissionGranted) { + cameraPhotoPicker.launch() + } else { + pendingEvent = event + cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) + } + } + MessageComposerEvent.PickAttachmentSource.VideoFromCamera -> localCoroutineScope.launch { + showAttachmentSourcePicker = false + if (cameraPermissionState.permissionGranted) { + cameraVideoPicker.launch() + } else { + pendingEvent = event + cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) + } + } + MessageComposerEvent.PickAttachmentSource.Location -> { + showAttachmentSourcePicker = false + // Navigation to the location picker screen is done at the view layer + } + MessageComposerEvent.PickAttachmentSource.Poll -> { + showAttachmentSourcePicker = false + // Navigation to the create poll screen is done at the view layer + } + is MessageComposerEvent.ToggleTextFormatting -> { + showAttachmentSourcePicker = false + localCoroutineScope.toggleTextFormatting(event.enabled, markdownTextEditorState, richTextEditorState) + } + is MessageComposerEvent.Error -> { + analyticsService.trackError(event.error) + } + is MessageComposerEvent.TypingNotice -> { + if (sendTypingNotifications) { + localCoroutineScope.launch { + room.typingNotice(event.isTyping) + } + } + } + is MessageComposerEvent.SuggestionReceived -> { + suggestionSearchTrigger.value = event.suggestion + } + is MessageComposerEvent.InsertSuggestion -> { + localCoroutineScope.launch { + if (showTextFormatting) { + when (val suggestion = event.resolvedSuggestion) { + is ResolvedSuggestion.AtRoom -> { + richTextEditorState.insertAtRoomMentionAtSuggestion() + } + is ResolvedSuggestion.Member -> { + val text = suggestion.roomMember.userId.value + val link = permalinkBuilder.permalinkForUser(suggestion.roomMember.userId).getOrNull() ?: return@launch + richTextEditorState.insertMentionAtSuggestion(text = text, link = link) + } + is ResolvedSuggestion.Alias -> { + val text = suggestion.roomAlias.value + val link = permalinkBuilder.permalinkForRoomAlias(suggestion.roomAlias).getOrNull() ?: return@launch + richTextEditorState.insertMentionAtSuggestion(text = text, link = link) + } + } + } else if (markdownTextEditorState.currentSuggestion != null) { + markdownTextEditorState.insertSuggestion( + resolvedSuggestion = event.resolvedSuggestion, + mentionSpanProvider = mentionSpanProvider, + ) + suggestionSearchTrigger.value = null + } + } + } + MessageComposerEvent.SaveDraft -> { + val draft = createDraftFromState(markdownTextEditorState, richTextEditorState) + sessionCoroutineScope.updateDraft(draft, isVolatile = false) + } + } + } + + val resolveMentionDisplay = remember { + { text: String, url: String -> + val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url) + if (mentionSpan != null) { + TextDisplay.Custom(mentionSpan) + } else { + TextDisplay.Plain + } + } + } + + val resolveAtRoomMentionDisplay = remember { + { + val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan() + TextDisplay.Custom(mentionSpan) + } + } + + return MessageComposerState( + textEditorState = textEditorState, + isFullScreen = isFullScreen.value, + mode = messageComposerContext.composerMode, + showAttachmentSourcePicker = showAttachmentSourcePicker, + showTextFormatting = showTextFormatting, + canShareLocation = canShareLocation.value, + suggestions = suggestions.toImmutableList(), + resolveMentionDisplay = resolveMentionDisplay, + resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay, + eventSink = ::handleEvent, + ) + } + + @OptIn(FlowPreview::class) + @Composable + private fun ResolveSuggestionsEffect( + suggestions: SnapshotStateList, + ) { + LaunchedEffect(Unit) { + val currentUserId = room.sessionId + + suspend fun canSendRoomMention(): Boolean { + val userCanSendAtRoom = room.canUserTriggerRoomNotification(currentUserId).getOrDefault(false) + return !room.isDm() && userCanSendAtRoom + } + + // This will trigger a search immediately when `@` is typed + val mentionStartTrigger = suggestionSearchTrigger.filter { it?.text.isNullOrEmpty() } + // This will start a search when the user changes the text after the `@` with a debounce to prevent too much wasted work + val mentionCompletionTrigger = suggestionSearchTrigger.debounce(0.3.seconds).filter { !it?.text.isNullOrEmpty() } + + val mentionTriggerFlow = merge(mentionStartTrigger, mentionCompletionTrigger) + + val roomAliasSuggestionsFlow = roomAliasSuggestionsDataSource + .getAllRoomAliasSuggestions() + .stateIn(this, SharingStarted.Lazily, emptyList()) + + combine(mentionTriggerFlow, room.membersStateFlow, roomAliasSuggestionsFlow) { suggestion, roomMembersState, roomAliasSuggestions -> + val result = suggestionsProcessor.process( + suggestion = suggestion, + roomMembersState = roomMembersState, + roomAliasSuggestions = roomAliasSuggestions, + currentUserId = currentUserId, + canSendRoomMention = ::canSendRoomMention, + ) + suggestions.clear() + suggestions.addAll(result) + } + .collect() + } + } + + private fun CoroutineScope.sendMessage( + markdownTextEditorState: MarkdownTextEditorState, + richTextEditorState: RichTextEditorState, + ) = launch { + val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = true) + val capturedMode = messageComposerContext.composerMode + // Reset composer right away + resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit) + when (capturedMode) { + is MessageComposerMode.Attachment, + is MessageComposerMode.Normal -> timelineController.invokeOnCurrentTimeline { + sendMessage( + body = message.markdown, + htmlBody = message.html, + intentionalMentions = message.intentionalMentions + ) + } + is MessageComposerMode.Edit -> { + timelineController.invokeOnCurrentTimeline { + // First try to edit the message in the current timeline + editMessage(capturedMode.eventOrTransactionId, message.markdown, message.html, message.intentionalMentions) + .onFailure { cause -> + val eventId = capturedMode.eventOrTransactionId.eventId + if (cause is TimelineException.EventNotFound && eventId != null) { + // if the event is not found in the timeline, try to edit the message directly + room.editMessage(eventId, message.markdown, message.html, message.intentionalMentions) + } + } + } + } + is MessageComposerMode.EditCaption -> { + timelineController.invokeOnCurrentTimeline { + editCaption( + capturedMode.eventOrTransactionId, + caption = message.markdown, + formattedCaption = message.html + ) + } + } + is MessageComposerMode.Reply -> { + timelineController.invokeOnCurrentTimeline { + with(capturedMode) { + replyMessage( + body = message.markdown, + htmlBody = message.html, + intentionalMentions = message.intentionalMentions, + repliedToEventId = eventId, + ) + } + } + } + } + + val roomInfo = room.info() + val roomMembers = room.membersStateFlow.value + + notificationConversationService.onSendMessage( + sessionId = room.sessionId, + roomId = roomInfo.id, + roomName = roomInfo.name ?: roomInfo.id.value, + roomIsDirect = roomInfo.isDm, + roomAvatarUrl = roomInfo.avatarUrl ?: roomMembers.getDirectRoomMember(roomInfo = roomInfo, sessionId = room.sessionId)?.avatarUrl, + ) + + analyticsService.capture( + Composer( + inThread = capturedMode.inThread, + isEditing = capturedMode.isEditing, + isReply = capturedMode.isReply, + // Set proper type when we'll be sending other types of messages. + messageType = Composer.MessageType.Text, + ) + ) + } + + private fun CoroutineScope.sendAttachment( + attachment: Attachment, + inReplyToEventId: EventId?, + ) = when (attachment) { + is Attachment.Media -> { + launch { + sendMedia( + uri = attachment.localMedia.uri, + mimeType = attachment.localMedia.info.mimeType, + inReplyToEventId = inReplyToEventId, + ) + } + } + } + + private fun handlePickedMedia( + uri: Uri?, + mimeType: String? = null, + ) { + uri ?: return + val localMedia = localMediaFactory.createFromUri( + uri = uri, + mimeType = mimeType, + name = null, + formattedFileSize = null + ) + val mediaAttachment = Attachment.Media(localMedia) + val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId + navigator.navigateToPreviewAttachments(persistentListOf(mediaAttachment), inReplyToEventId) + + // Reset composer since the attachment will be sent in a separate flow + messageComposerContext.composerMode = MessageComposerMode.Normal + } + + private suspend fun sendMedia( + uri: Uri, + mimeType: String, + inReplyToEventId: EventId?, + ) = runCatchingExceptions { + mediaSender.sendMedia( + uri = uri, + mimeType = mimeType, + mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), + inReplyToEventId = inReplyToEventId, + ).getOrThrow() + } + .onFailure { cause -> + Timber.e(cause, "Failed to send attachment") + if (cause is CancellationException) { + throw cause + } else { + val snackbarMessage = SnackbarMessage(sendAttachmentError(cause)) + snackbarDispatcher.post(snackbarMessage) + } + } + + private fun CoroutineScope.updateDraft( + draft: ComposerDraft?, + isVolatile: Boolean, + ) = launch { + draftService.updateDraft( + roomId = room.roomId, + draft = draft, + isVolatile = isVolatile, + // TODO support threads in composer + threadRoot = null, + ) + } + + private suspend fun applyDraft( + draft: ComposerDraft, + markdownTextEditorState: MarkdownTextEditorState, + richTextEditorState: RichTextEditorState, + ) { + val htmlText = draft.htmlText + val markdownText = draft.plainText + if (htmlText != null) { + showTextFormatting = true + setText(htmlText, markdownTextEditorState, richTextEditorState, requestFocus = true) + } else { + showTextFormatting = false + setText(markdownText, markdownTextEditorState, richTextEditorState, requestFocus = true) + } + when (val draftType = draft.draftType) { + ComposerDraftType.NewMessage -> messageComposerContext.composerMode = MessageComposerMode.Normal + is ComposerDraftType.Edit -> messageComposerContext.composerMode = MessageComposerMode.Edit( + eventOrTransactionId = draftType.eventId.toEventOrTransactionId(), + content = htmlText ?: markdownText + ) + is ComposerDraftType.Reply -> { + messageComposerContext.composerMode = MessageComposerMode.Reply( + replyToDetails = InReplyToDetails.Loading(draftType.eventId), + // I guess it's fine to always render the image when restoring a draft + hideImage = false + ) + timelineController.invokeOnCurrentTimeline { + val replyToDetails = loadReplyDetails(draftType.eventId).map(permalinkParser) + messageComposerContext.composerMode = MessageComposerMode.Reply( + replyToDetails = replyToDetails, + // I guess it's fine to always render the image when restoring a draft + hideImage = false + ) + } + } + } + } + + private fun createDraftFromState( + markdownTextEditorState: MarkdownTextEditorState, + richTextEditorState: RichTextEditorState, + ): ComposerDraft? { + val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = false) + val draftType = when (val mode = messageComposerContext.composerMode) { + is MessageComposerMode.Attachment, + is MessageComposerMode.Normal -> ComposerDraftType.NewMessage + is MessageComposerMode.Edit -> { + mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) } + } + is MessageComposerMode.Reply -> ComposerDraftType.Reply(mode.eventId) + is MessageComposerMode.EditCaption -> { + // TODO Need a new type to save caption in the SDK + null + } + } + return if (draftType == null || message.markdown.isBlank()) { + null + } else { + ComposerDraft( + draftType = draftType, + htmlText = message.html, + plainText = message.markdown, + ) + } + } + + private fun currentComposerMessage( + markdownTextEditorState: MarkdownTextEditorState, + richTextEditorState: RichTextEditorState, + withMentions: Boolean, + ): Message { + return if (showTextFormatting) { + val html = richTextEditorState.messageHtml + val markdown = richTextEditorState.messageMarkdown + val mentions = richTextEditorState.mentionsState + .takeIf { withMentions } + ?.let { state -> + buildList { + if (state.hasAtRoomMention) { + add(IntentionalMention.Room) + } + for (userId in state.userIds) { + add(IntentionalMention.User(UserId(userId))) + } + } + } + .orEmpty() + Message(html = html, markdown = markdown, intentionalMentions = mentions) + } else { + val markdown = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) + val mentions = if (withMentions) { + markdownTextEditorState.getMentions() + } else { + emptyList() + } + Message(html = null, markdown = markdown, intentionalMentions = mentions) + } + } + + private fun CoroutineScope.toggleTextFormatting( + enabled: Boolean, + markdownTextEditorState: MarkdownTextEditorState, + richTextEditorState: RichTextEditorState + ) = launch { + showTextFormatting = enabled + if (showTextFormatting) { + val markdown = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) + richTextEditorState.setMarkdown(markdown) + richTextEditorState.requestFocus() + analyticsService.captureInteraction(Interaction.Name.MobileRoomComposerFormattingEnabled) + } else { + val markdown = richTextEditorState.messageMarkdown + val markdownWithMentions = pillificationHelper.pillify(markdown, false) + markdownTextEditorState.text.update(markdownWithMentions, true) + // Give some time for the focus of the previous editor to be cleared + delay(100) + markdownTextEditorState.requestFocusAction() + } + } + + private fun CoroutineScope.setMode( + newComposerMode: MessageComposerMode, + markdownTextEditorState: MarkdownTextEditorState, + richTextEditorState: RichTextEditorState, + ) = launch { + val currentComposerMode = messageComposerContext.composerMode + when (newComposerMode) { + is MessageComposerMode.Edit -> { + if (currentComposerMode.isEditing.not()) { + val draft = createDraftFromState(markdownTextEditorState, richTextEditorState) + updateDraft(draft, isVolatile = true).join() + } + setText(newComposerMode.content, markdownTextEditorState, richTextEditorState) + } + is MessageComposerMode.EditCaption -> { + if (currentComposerMode.isEditing.not()) { + val draft = createDraftFromState(markdownTextEditorState, richTextEditorState) + updateDraft(draft, isVolatile = true).join() + } + setText(newComposerMode.content, markdownTextEditorState, richTextEditorState) + } + else -> { + // When coming from edit, just clear the composer as it'd be weird to reset a volatile draft in this scenario. + if (currentComposerMode.isEditing) { + setText("", markdownTextEditorState, richTextEditorState) + } + } + } + messageComposerContext.composerMode = newComposerMode + } + + private suspend fun resetComposer( + markdownTextEditorState: MarkdownTextEditorState, + richTextEditorState: RichTextEditorState, + fromEdit: Boolean, + ) { + // Use the volatile draft only when coming from edit mode otherwise. + val draft = draftService.loadDraft( + roomId = room.roomId, + // TODO support threads in composer + threadRoot = null, + isVolatile = true + ).takeIf { fromEdit } + if (draft != null) { + applyDraft(draft, markdownTextEditorState, richTextEditorState) + } else { + setText("", markdownTextEditorState, richTextEditorState) + messageComposerContext.composerMode = MessageComposerMode.Normal + } + } + + private suspend fun setText( + content: String, + markdownTextEditorState: MarkdownTextEditorState, + richTextEditorState: RichTextEditorState, + requestFocus: Boolean = false, + ) { + if (showTextFormatting) { + richTextEditorState.setHtml(content) + if (requestFocus) { + richTextEditorState.requestFocus() + } + } else { + if (content.isEmpty()) { + markdownTextEditorState.selection = IntRange.EMPTY + } + val pillifiedContent = pillificationHelper.pillify(content, false) + markdownTextEditorState.text.update(pillifiedContent, true) + if (requestFocus) { + markdownTextEditorState.requestFocusAction() + } + } + } +} 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 0000000..424e8c0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.messagecomposer + +import androidx.compose.runtime.Stable +import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.textcomposer.model.TextEditorState +import io.element.android.wysiwyg.display.TextDisplay +import kotlinx.collections.immutable.ImmutableList + +@Stable +data class MessageComposerState( + val textEditorState: TextEditorState, + val isFullScreen: Boolean, + val mode: MessageComposerMode, + val showAttachmentSourcePicker: Boolean, + val showTextFormatting: Boolean, + val canShareLocation: Boolean, + val suggestions: ImmutableList, + val resolveMentionDisplay: (String, String) -> TextDisplay, + val resolveAtRoomMentionDisplay: () -> TextDisplay, + val eventSink: (MessageComposerEvent) -> Unit, +) 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 0000000..a06bf30 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.messagecomposer + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.textcomposer.model.TextEditorState +import io.element.android.libraries.textcomposer.model.aTextEditorStateRich +import io.element.android.wysiwyg.display.TextDisplay +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +open class MessageComposerStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aMessageComposerState(), + ) +} + +fun aMessageComposerState( + textEditorState: TextEditorState = aTextEditorStateRich(), + isFullScreen: Boolean = false, + mode: MessageComposerMode = MessageComposerMode.Normal, + showTextFormatting: Boolean = false, + showAttachmentSourcePicker: Boolean = false, + canShareLocation: Boolean = true, + suggestions: ImmutableList = persistentListOf(), + eventSink: (MessageComposerEvent) -> Unit = {}, +) = MessageComposerState( + textEditorState = textEditorState, + isFullScreen = isFullScreen, + mode = mode, + showTextFormatting = showTextFormatting, + showAttachmentSourcePicker = showAttachmentSourcePicker, + canShareLocation = canShareLocation, + suggestions = suggestions, + resolveMentionDisplay = { _, _ -> TextDisplay.Plain }, + resolveAtRoomMentionDisplay = { TextDisplay.Plain }, + eventSink = 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 0000000..4b346e0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.messagecomposer + +import android.net.Uri +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent +import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState +import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerStateProvider +import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessageComposerState +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.textcomposer.TextComposer +import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent +import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent +import kotlinx.coroutines.launch + +@Composable +internal fun MessageComposerView( + state: MessageComposerState, + voiceMessageState: VoiceMessageComposerState, + modifier: Modifier = Modifier, +) { + val view = LocalView.current + fun sendMessage() { + state.eventSink(MessageComposerEvent.SendMessage) + } + + fun sendUri(uri: Uri) { + state.eventSink(MessageComposerEvent.SendUri(uri)) + } + + fun onAddAttachment() { + state.eventSink(MessageComposerEvent.AddAttachment) + } + + fun onCloseSpecialMode() { + state.eventSink(MessageComposerEvent.CloseSpecialMode) + } + + fun onDismissTextFormatting() { + view.clearFocus() + state.eventSink(MessageComposerEvent.ToggleTextFormatting(enabled = false)) + } + + fun onSuggestionReceived(suggestion: Suggestion?) { + state.eventSink(MessageComposerEvent.SuggestionReceived(suggestion)) + } + + fun onError(error: Throwable) { + state.eventSink(MessageComposerEvent.Error(error)) + } + + fun onTyping(typing: Boolean) { + state.eventSink(MessageComposerEvent.TypingNotice(typing)) + } + + val coroutineScope = rememberCoroutineScope() + fun onRequestFocus() { + coroutineScope.launch { + state.textEditorState.requestFocus() + } + } + + val onVoiceRecorderEvent = { press: VoiceMessageRecorderEvent -> + voiceMessageState.eventSink(VoiceMessageComposerEvent.RecorderEvent(press)) + } + + val onSendVoiceMessage = { + voiceMessageState.eventSink(VoiceMessageComposerEvent.SendVoiceMessage) + } + + val onDeleteVoiceMessage = { + voiceMessageState.eventSink(VoiceMessageComposerEvent.DeleteVoiceMessage) + } + + val onVoicePlayerEvent = { event: VoiceMessagePlayerEvent -> + voiceMessageState.eventSink(VoiceMessageComposerEvent.PlayerEvent(event)) + } + + TextComposer( + modifier = modifier, + state = state.textEditorState, + voiceMessageState = voiceMessageState.voiceMessageState, + onRequestFocus = ::onRequestFocus, + onSendMessage = ::sendMessage, + composerMode = state.mode, + showTextFormatting = state.showTextFormatting, + onResetComposerMode = ::onCloseSpecialMode, + onAddAttachment = ::onAddAttachment, + onDismissTextFormatting = ::onDismissTextFormatting, + onVoiceRecorderEvent = onVoiceRecorderEvent, + onVoicePlayerEvent = onVoicePlayerEvent, + onSendVoiceMessage = onSendVoiceMessage, + onDeleteVoiceMessage = onDeleteVoiceMessage, + onReceiveSuggestion = ::onSuggestionReceived, + resolveMentionDisplay = state.resolveMentionDisplay, + resolveAtRoomMentionDisplay = state.resolveAtRoomMentionDisplay, + onError = ::onError, + onTyping = ::onTyping, + onSelectRichContent = ::sendUri, + ) +} + +@PreviewsDayNight +@Composable +internal fun MessageComposerViewPreview( + @PreviewParameter(MessageComposerStateProvider::class) state: MessageComposerState, +) = ElementPreview { + Column { + MessageComposerView( + modifier = Modifier.height(IntrinsicSize.Min), + state = state, + voiceMessageState = aVoiceMessageComposerState(), + ) + MessageComposerView( + modifier = Modifier.height(200.dp), + state = state, + voiceMessageState = aVoiceMessageComposerState(), + ) + DisabledComposerView() + } +} + +@PreviewsDayNight +@Composable +internal fun MessageComposerViewVoicePreview( + @PreviewParameter(VoiceMessageComposerStateProvider::class) state: VoiceMessageComposerState, +) = ElementPreview { + Column { + MessageComposerView( + modifier = Modifier.height(IntrinsicSize.Min), + state = aMessageComposerState(), + voiceMessageState = state, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt new file mode 100644 index 0000000..79fc6e7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.messagecomposer + +import androidx.compose.runtime.Composable +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.wysiwyg.compose.RichTextEditorState +import io.element.android.wysiwyg.compose.rememberRichTextEditorState + +interface RichTextEditorStateFactory { + @Composable + fun remember(): RichTextEditorState +} + +@ContributesBinding(AppScope::class) +class DefaultRichTextEditorStateFactory : RichTextEditorStateFactory { + @Composable + override fun remember(): RichTextEditorState { + return rememberRichTextEditorState() + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/RoomAliasSuggestionsDataSource.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/RoomAliasSuggestionsDataSource.kt new file mode 100644 index 0000000..d1ba363 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/RoomAliasSuggestionsDataSource.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.messagecomposer.suggestions + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +data class RoomAliasSuggestion( + val roomAlias: RoomAlias, + val roomId: RoomId, + val roomName: String?, + val roomAvatarUrl: String?, +) + +interface RoomAliasSuggestionsDataSource { + fun getAllRoomAliasSuggestions(): Flow> +} + +@ContributesBinding(SessionScope::class) +class DefaultRoomAliasSuggestionsDataSource( + private val roomListService: RoomListService, +) : RoomAliasSuggestionsDataSource { + override fun getAllRoomAliasSuggestions(): Flow> { + return roomListService + .allRooms + .summaries + .map { roomSummaries -> + roomSummaries + .mapNotNull { roomSummary -> + roomSummary.info.canonicalAlias?.let { roomAlias -> + RoomAliasSuggestion( + roomAlias = roomAlias, + roomId = roomSummary.roomId, + roomName = roomSummary.info.name, + roomAvatarUrl = roomSummary.info.avatarUrl, + ) + } + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt new file mode 100644 index 0000000..e9e38e1 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.messagecomposer.suggestions + +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.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +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.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.messages.impl.R +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.avatar.AvatarType +import io.element.android.libraries.designsystem.components.avatar.anAvatarData +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.RoomAlias +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.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun SuggestionsPickerView( + roomId: RoomId, + roomName: String?, + roomAvatarData: AvatarData, + suggestions: ImmutableList, + onSelectSuggestion: (ResolvedSuggestion) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxWidth(), + ) { + items( + suggestions, + key = { suggestion -> + when (suggestion) { + is ResolvedSuggestion.AtRoom -> "@room" + is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value + is ResolvedSuggestion.Alias -> suggestion.roomId.value + } + } + ) { + Column(modifier = Modifier.fillParentMaxWidth()) { + SuggestionItemView( + suggestion = it, + roomId = roomId.value, + roomName = roomName, + roomAvatar = roomAvatarData, + onSelectSuggestion = onSelectSuggestion, + modifier = Modifier.fillMaxWidth() + ) + HorizontalDivider(modifier = Modifier.fillMaxWidth()) + } + } + } +} + +@Composable +private fun SuggestionItemView( + suggestion: ResolvedSuggestion, + roomId: String, + roomName: String?, + roomAvatar: AvatarData?, + onSelectSuggestion: (ResolvedSuggestion) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.clickable { onSelectSuggestion(suggestion) }, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + val avatarSize = AvatarSize.Suggestion + val avatarData = when (suggestion) { + is ResolvedSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize) + is ResolvedSuggestion.Member -> suggestion.roomMember.getAvatarData(avatarSize) + is ResolvedSuggestion.Alias -> suggestion.getAvatarData(avatarSize) + } + val avatarType = when (suggestion) { + is ResolvedSuggestion.Alias -> AvatarType.Room() + ResolvedSuggestion.AtRoom, + is ResolvedSuggestion.Member -> AvatarType.User + } + val title = when (suggestion) { + is ResolvedSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title) + is ResolvedSuggestion.Member -> suggestion.roomMember.displayName + is ResolvedSuggestion.Alias -> suggestion.roomName + } + val subtitle = when (suggestion) { + is ResolvedSuggestion.AtRoom -> "@room" + is ResolvedSuggestion.Member -> suggestion.roomMember.userId.value + is ResolvedSuggestion.Alias -> suggestion.roomAlias.value + } + Avatar( + avatarData = avatarData, + avatarType = avatarType, + modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp), + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(end = 16.dp, top = 8.dp, bottom = 8.dp) + .align(Alignment.CenterVertically), + ) { + title?.let { + Text( + text = it, + style = ElementTheme.typography.fontBodyLgRegular, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = subtitle, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun SuggestionsPickerViewPreview() { + ElementPreview { + val roomMember = RoomMember( + userId = UserId("@alice:server.org"), + displayName = null, + avatarUrl = null, + membership = RoomMembershipState.JOIN, + isNameAmbiguous = false, + powerLevel = 0L, + isIgnored = false, + role = RoomMember.Role.User, + membershipChangeReason = null, + ) + val anAlias = remember { RoomAlias("#room:domain.org") } + SuggestionsPickerView( + roomId = RoomId("!room:matrix.org"), + roomName = "Room", + roomAvatarData = anAvatarData(), + suggestions = persistentListOf( + ResolvedSuggestion.AtRoom, + ResolvedSuggestion.Member(roomMember), + ResolvedSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")), + ResolvedSuggestion.Alias( + roomAlias = anAlias, + roomId = RoomId("!room:matrix.org"), + roomName = "My room", + roomAvatarUrl = null, + ) + ), + onSelectSuggestion = {} + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt new file mode 100644 index 0000000..789a027 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.messagecomposer.suggestions + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.core.data.filterUpTo +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.RoomMembersState +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.room.roomMembers +import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion +import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.SuggestionType + +/** + * This class is responsible for processing suggestions when `@`, `/` or `#` are type in the composer. + */ +@Inject +class SuggestionsProcessor { + /** + * Process the suggestion. + * @param suggestion The current suggestion input + * @param roomMembersState The room members state, it contains the current users in the room + * @param roomAliasSuggestions The available room alias suggestions + * @param currentUserId The current user id + * @param canSendRoomMention Should return true if the current user can send room mentions + * @return The list of suggestions to display + */ + suspend fun process( + suggestion: Suggestion?, + roomMembersState: RoomMembersState, + roomAliasSuggestions: List, + currentUserId: UserId, + canSendRoomMention: suspend () -> Boolean, + ): List { + suggestion ?: return emptyList() + return when (suggestion.type) { + SuggestionType.Mention -> { + // Replace suggestions + val members = roomMembersState.roomMembers() + val matchingMembers = getMemberSuggestions( + query = suggestion.text, + roomMembers = members, + currentUserId = currentUserId, + canSendRoomMention = canSendRoomMention() + ) + matchingMembers + } + SuggestionType.Room -> { + roomAliasSuggestions + .filter { roomAliasSuggestion -> + // Filter by either room alias or room name (if available) + roomAliasSuggestion.roomAlias.value.contains(suggestion.text, ignoreCase = true) || + roomAliasSuggestion.roomName?.contains(suggestion.text, ignoreCase = true) == true + } + .map { + ResolvedSuggestion.Alias( + roomAlias = it.roomAlias, + roomId = it.roomId, + roomName = it.roomName, + roomAvatarUrl = it.roomAvatarUrl, + ) + } + } + SuggestionType.Command, + SuggestionType.Emoji, + is SuggestionType.Custom -> { + // Clear suggestions + emptyList() + } + } + } + + private fun getMemberSuggestions( + query: String, + roomMembers: List?, + currentUserId: UserId, + canSendRoomMention: Boolean, + ): List { + return if (roomMembers.isNullOrEmpty()) { + emptyList() + } else { + fun isJoinedMemberAndNotSelf(member: RoomMember): Boolean { + return member.membership == RoomMembershipState.JOIN && currentUserId != member.userId + } + + fun memberMatchesQuery(member: RoomMember, query: String): Boolean { + return member.userId.value.contains(query, ignoreCase = true) || + member.displayName?.contains(query, ignoreCase = true) == true + } + + val matchingMembers = roomMembers + // Search only in joined members, up to MAX_BATCH_ITEMS, exclude the current user + .filterUpTo(MAX_BATCH_ITEMS) { member -> + isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query) + } + .map(ResolvedSuggestion::Member) + + if ("room".contains(query) && canSendRoomMention) { + listOf(ResolvedSuggestion.AtRoom) + matchingMembers + } else { + matchingMembers + } + } + } + + companion object { + // We don't want to retrieve thousands of members + private const val MAX_BATCH_ITEMS = 100 + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/DefaultPinnedEventsTimelineProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/DefaultPinnedEventsTimelineProvider.kt new file mode 100644 index 0000000..ee31677 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/DefaultPinnedEventsTimelineProvider.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned + +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.coroutine.mapState +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.room.CreateTimelineParams +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.timeline.Timeline +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext + +@SingleIn(RoomScope::class) +@ContributesBinding(RoomScope::class) +class DefaultPinnedEventsTimelineProvider( + private val room: JoinedRoom, + private val syncService: SyncService, + private val dispatchers: CoroutineDispatchers, +) : PinnedEventsTimelineProvider { + private val _timelineStateFlow: MutableStateFlow> = + MutableStateFlow(AsyncData.Uninitialized) + + override fun activeTimelineFlow(): StateFlow { + return _timelineStateFlow + .mapState { value -> + value.dataOrNull() + } + } + + val timelineStateFlow = _timelineStateFlow + + fun launchIn(scope: CoroutineScope) { + _timelineStateFlow.subscriptionCount + .map { count -> count > 0 } + .distinctUntilChanged() + .onEach { isActive -> + if (isActive) { + onActive() + } else { + onInactive() + } + } + .launchIn(scope) + .invokeOnCompletion { timelineStateFlow.value.dataOrNull()?.close() } + } + + private suspend fun onActive() = coroutineScope { + syncService.syncState.onEach { + // do not use syncState here as data can be loaded from cache, it's just to trigger retry if needed + loadTimelineIfNeeded() + } + .launchIn(this) + } + + private suspend fun onInactive() { + resetTimeline() + } + + private suspend fun resetTimeline() { + invokeOnTimeline { + close() + } + _timelineStateFlow.emit(AsyncData.Uninitialized) + } + + suspend fun invokeOnTimeline(action: suspend Timeline.() -> Unit) { + when (val asyncTimeline = timelineStateFlow.value) { + is AsyncData.Success -> action(asyncTimeline.data) + else -> Unit + } + } + + private suspend fun loadTimelineIfNeeded() { + when (timelineStateFlow.value) { + is AsyncData.Uninitialized, is AsyncData.Failure -> { + timelineStateFlow.emit(AsyncData.Loading()) + withContext(dispatchers.io) { + room.createTimeline(CreateTimelineParams.PinnedOnly) + } + .fold( + { timelineStateFlow.emit(AsyncData.Success(it)) }, + { timelineStateFlow.emit(AsyncData.Failure(it)) } + ) + } + else -> Unit + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerEvents.kt new file mode 100644 index 0000000..d492640 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerEvents.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.banner + +sealed interface PinnedMessagesBannerEvents { + data object MoveToNextPinned : PinnedMessagesBannerEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItem.kt new file mode 100644 index 0000000..7e7904e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItem.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.banner + +import androidx.compose.ui.text.AnnotatedString +import io.element.android.libraries.matrix.api.core.EventId + +data class PinnedMessagesBannerItem( + val eventId: EventId, + val formatted: AnnotatedString, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItemFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItemFactory.kt new file mode 100644 index 0000000..d1d53b3 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItemFactory.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.banner + +import androidx.compose.ui.text.AnnotatedString +import dev.zacsweers.metro.Inject +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.eventformatter.api.PinnedMessagesBannerFormatter +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import kotlinx.coroutines.withContext + +@Inject +class PinnedMessagesBannerItemFactory( + private val coroutineDispatchers: CoroutineDispatchers, + private val formatter: PinnedMessagesBannerFormatter, +) { + suspend fun create(timelineItem: MatrixTimelineItem): PinnedMessagesBannerItem? = withContext(coroutineDispatchers.computation) { + when (timelineItem) { + is MatrixTimelineItem.Event -> { + val eventId = timelineItem.eventId ?: return@withContext null + val formatted = formatter.format(timelineItem.event) + PinnedMessagesBannerItem( + eventId = eventId, + formatted = if (formatted is AnnotatedString) { + formatted + } else { + AnnotatedString(formatted.toString()) + }, + ) + } + else -> null + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt new file mode 100644 index 0000000..eada4b0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenter.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.banner + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.room.BaseRoom +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach + +@Inject +class PinnedMessagesBannerPresenter( + private val room: BaseRoom, + private val itemFactory: PinnedMessagesBannerItemFactory, + private val pinnedEventsTimelineProvider: DefaultPinnedEventsTimelineProvider, +) : Presenter { + private val pinnedItems = mutableStateOf>>(AsyncData.Uninitialized) + + @Composable + override fun present(): PinnedMessagesBannerState { + val expectedPinnedMessagesCount by remember { + room.roomInfoFlow.map { roomInfo -> roomInfo.pinnedEventIds.size } + }.collectAsState(initial = 0) + + var currentPinnedMessageIndex by rememberSaveable { mutableIntStateOf(-1) } + + PinnedMessagesBannerItemsEffect( + onItemsChange = { newItems -> + val pinnedMessageCount = newItems.dataOrNull().orEmpty().size + if (currentPinnedMessageIndex >= pinnedMessageCount || currentPinnedMessageIndex < 0) { + currentPinnedMessageIndex = pinnedMessageCount - 1 + } + pinnedItems.value = newItems + }, + ) + + fun handleEvent(event: PinnedMessagesBannerEvents) { + when (event) { + is PinnedMessagesBannerEvents.MoveToNextPinned -> { + val loadedCount = pinnedItems.value.dataOrNull().orEmpty().size + currentPinnedMessageIndex = (currentPinnedMessageIndex - 1).mod(loadedCount) + } + } + } + + return pinnedMessagesBannerState( + expectedPinnedMessagesCount = expectedPinnedMessagesCount, + pinnedItems = pinnedItems.value, + currentPinnedMessageIndex = currentPinnedMessageIndex, + eventSink = ::handleEvent, + ) + } + + @Composable + private fun pinnedMessagesBannerState( + expectedPinnedMessagesCount: Int, + pinnedItems: AsyncData>, + currentPinnedMessageIndex: Int, + eventSink: (PinnedMessagesBannerEvents) -> Unit + ): PinnedMessagesBannerState { + return when (pinnedItems) { + is AsyncData.Failure, is AsyncData.Uninitialized -> PinnedMessagesBannerState.Hidden + is AsyncData.Loading -> { + if (expectedPinnedMessagesCount == 0) { + PinnedMessagesBannerState.Hidden + } else { + PinnedMessagesBannerState.Loading(expectedPinnedMessagesCount = expectedPinnedMessagesCount) + } + } + is AsyncData.Success -> { + val currentPinnedMessage = pinnedItems.data.getOrNull(currentPinnedMessageIndex) + if (currentPinnedMessage == null) { + PinnedMessagesBannerState.Hidden + } else { + PinnedMessagesBannerState.Loaded( + loadedPinnedMessagesCount = pinnedItems.data.size, + currentPinnedMessageIndex = currentPinnedMessageIndex, + currentPinnedMessage = currentPinnedMessage, + eventSink = eventSink + ) + } + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Composable + private fun PinnedMessagesBannerItemsEffect( + onItemsChange: (AsyncData>) -> Unit, + ) { + val updatedOnItemsChange by rememberUpdatedState(onItemsChange) + LaunchedEffect(Unit) { + pinnedEventsTimelineProvider.timelineStateFlow + .flatMapLatest { asyncTimeline -> + when (asyncTimeline) { + AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized) + is AsyncData.Failure -> flowOf(AsyncData.Failure(asyncTimeline.error)) + is AsyncData.Loading -> flowOf(AsyncData.Loading()) + is AsyncData.Success -> { + asyncTimeline.data.timelineItems + .map { timelineItems -> + val pinnedItems = timelineItems.mapNotNull { timelineItem -> + itemFactory.create(timelineItem) + }.toImmutableList() + + AsyncData.Success(pinnedItems) + } + } + } + } + .onEach { newItems -> + updatedOnItemsChange(newItems) + } + .launchIn(this) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerState.kt new file mode 100644 index 0000000..0ed4337 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerState.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.banner + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.designsystem.text.toAnnotatedString +import io.element.android.libraries.ui.strings.CommonStrings + +@Immutable +sealed interface PinnedMessagesBannerState { + data object Hidden : PinnedMessagesBannerState + @Immutable + sealed interface Visible : PinnedMessagesBannerState { + fun pinnedMessagesCount() = when (this) { + is Loading -> expectedPinnedMessagesCount + is Loaded -> loadedPinnedMessagesCount + } + + fun currentPinnedMessageIndex() = when (this) { + is Loading -> expectedPinnedMessagesCount - 1 + is Loaded -> currentPinnedMessageIndex + } + + @Composable + fun formattedMessage() = when (this) { + is Loading -> stringResource(id = CommonStrings.screen_room_pinned_banner_loading_description).toAnnotatedString() + is Loaded -> currentPinnedMessage.formatted + } + } + + data class Loading(val expectedPinnedMessagesCount: Int) : Visible + data class Loaded( + val currentPinnedMessage: PinnedMessagesBannerItem, + val currentPinnedMessageIndex: Int, + val loadedPinnedMessagesCount: Int, + val eventSink: (PinnedMessagesBannerEvents) -> Unit + ) : Visible +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerStateProvider.kt new file mode 100644 index 0000000..ad7713e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerStateProvider.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.banner + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.EventId +import kotlin.random.Random + +internal class PinnedMessagesBannerStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aHiddenPinnedMessagesBannerState(), + aLoadingPinnedMessagesBannerState(knownPinnedMessagesCount = 1), + aLoadingPinnedMessagesBannerState(knownPinnedMessagesCount = 5), + aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 1, currentPinnedMessageIndex = 0), + aLoadedPinnedMessagesBannerState( + knownPinnedMessagesCount = 2, + currentPinnedMessageIndex = 0, + message = "This is a pinned long message to check the wrapping behavior", + ), + aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 3, currentPinnedMessageIndex = 0), + aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 5, currentPinnedMessageIndex = 0), + aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 5, currentPinnedMessageIndex = 1), + aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 5, currentPinnedMessageIndex = 2), + aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 5, currentPinnedMessageIndex = 3), + aLoadedPinnedMessagesBannerState(knownPinnedMessagesCount = 5, currentPinnedMessageIndex = 4), + ) +} + +internal fun aHiddenPinnedMessagesBannerState() = PinnedMessagesBannerState.Hidden + +internal fun aLoadingPinnedMessagesBannerState( + knownPinnedMessagesCount: Int = 4 +) = PinnedMessagesBannerState.Loading( + expectedPinnedMessagesCount = knownPinnedMessagesCount +) + +internal fun aLoadedPinnedMessagesBannerState( + currentPinnedMessageIndex: Int = 0, + knownPinnedMessagesCount: Int = 1, + message: String = "This is a pinned message", + currentPinnedMessage: PinnedMessagesBannerItem = PinnedMessagesBannerItem( + eventId = EventId("\$" + Random.nextInt().toString()), + formatted = AnnotatedString(message) + ), + eventSink: (PinnedMessagesBannerEvents) -> Unit = {} +) = PinnedMessagesBannerState.Loaded( + currentPinnedMessage = currentPinnedMessage, + currentPinnedMessageIndex = currentPinnedMessageIndex, + loadedPinnedMessagesCount = knownPinnedMessagesCount, + eventSink = eventSink +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerView.kt new file mode 100644 index 0000000..ee44f7b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerView.kt @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.banner + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement.spacedBy +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.heightIn +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.runtime.Stable +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.drawBehind +import androidx.compose.ui.draw.shadow +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.NestedScrollSource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import im.vector.app.features.analytics.plan.Interaction +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.pinnedMessageBannerBorder +import io.element.android.libraries.designsystem.theme.pinnedMessageBannerIndicator +import io.element.android.libraries.designsystem.utils.annotatedTextWithBold +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.analytics.compose.LocalAnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction + +@Composable +fun PinnedMessagesBannerView( + state: PinnedMessagesBannerState, + onClick: (EventId) -> Unit, + onViewAllClick: () -> Unit, + modifier: Modifier = Modifier, +) { + when (state) { + PinnedMessagesBannerState.Hidden -> Unit + is PinnedMessagesBannerState.Visible -> { + PinnedMessagesBannerRow( + state = state, + onClick = onClick, + onViewAllClick = onViewAllClick, + modifier = modifier, + ) + } + } +} + +@Composable +private fun PinnedMessagesBannerRow( + state: PinnedMessagesBannerState.Visible, + onClick: (EventId) -> Unit, + onViewAllClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val analyticsService = LocalAnalyticsService.current + val borderColor = ElementTheme.colors.pinnedMessageBannerBorder + Row( + modifier = modifier + .background(color = ElementTheme.colors.bgCanvasDefault) + .fillMaxWidth() + .drawBorder(borderColor) + .heightIn(min = 64.dp) + .clickable { + if (state is PinnedMessagesBannerState.Loaded) { + analyticsService.captureInteraction(Interaction.Name.PinnedMessageBannerClick) + onClick(state.currentPinnedMessage.eventId) + state.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned) + } + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = Modifier.width(26.dp)) + PinIndicators( + pinIndex = state.currentPinnedMessageIndex(), + pinsCount = state.pinnedMessagesCount(), + ) + Icon( + imageVector = CompoundIcons.PinSolid(), + contentDescription = null, + tint = ElementTheme.colors.iconSecondary, + modifier = Modifier + .padding(horizontal = 10.dp) + .size(20.dp) + ) + PinnedMessageItem( + index = state.currentPinnedMessageIndex(), + totalCount = state.pinnedMessagesCount(), + message = state.formattedMessage(), + modifier = Modifier.weight(1f) + ) + ViewAllButton( + state = state, + onViewAllClick = { + onViewAllClick() + analyticsService.captureInteraction(Interaction.Name.PinnedMessageBannerViewAllButton) + }, + ) + } +} + +@Composable +private fun ViewAllButton( + state: PinnedMessagesBannerState, + onViewAllClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val text = if (state is PinnedMessagesBannerState.Loaded) { + stringResource(id = CommonStrings.screen_room_pinned_banner_view_all_button_title) + } else { + "" + } + TextButton( + text = text, + showProgress = state is PinnedMessagesBannerState.Loading, + onClick = onViewAllClick, + modifier = modifier, + ) +} + +private fun Modifier.drawBorder(borderColor: Color): Modifier { + return this + .drawBehind { + val strokeWidth = 0.5.dp.toPx() + val y = size.height - strokeWidth / 2 + drawLine( + borderColor, + Offset(0f, y), + Offset(size.width, y), + strokeWidth + ) + drawLine( + borderColor, + Offset(0f, 0f), + Offset(size.width, 0f), + strokeWidth + ) + } + .shadow(elevation = 5.dp, spotColor = Color.Transparent) +} + +@Composable +private fun PinIndicators( + pinIndex: Int, + pinsCount: Int, + modifier: Modifier = Modifier, +) { + val indicatorHeight = remember(pinsCount) { + when (pinsCount) { + 0 -> 0 + 1 -> 32 + 2 -> 18 + else -> 11 + } + } + val activeIndex = remember(pinIndex) { + pinIndex % 3 + } + val shownIndicators = remember(pinsCount, pinIndex) { + if (pinsCount <= 3) { + pinsCount + } else { + val isLastPage = pinIndex >= pinsCount - pinsCount % 3 + if (isLastPage) { + pinsCount % 3 + } else { + 3 + } + } + } + val indicatorsCount = pinsCount.coerceAtMost(3) + + Column( + modifier = modifier, + verticalArrangement = spacedBy(2.dp) + ) { + for (index in 0 until indicatorsCount) { + Box( + modifier = Modifier + .width(2.dp) + .height(indicatorHeight.dp) + .background( + color = if (index == activeIndex) { + ElementTheme.colors.iconAccentPrimary + } else if (index < shownIndicators) { + ElementTheme.colors.pinnedMessageBannerIndicator + } else { + Color.Transparent + } + ), + ) + } + } +} + +@Composable +private fun PinnedMessageItem( + index: Int, + totalCount: Int, + message: AnnotatedString?, + modifier: Modifier = Modifier, +) { + val countMessage = stringResource(id = CommonStrings.screen_room_pinned_banner_indicator, index + 1, totalCount) + val fullCountMessage = stringResource(id = CommonStrings.screen_room_pinned_banner_indicator_description, countMessage) + Column(modifier = modifier) { + AnimatedVisibility(totalCount > 1) { + Text( + text = annotatedTextWithBold( + text = fullCountMessage, + boldText = countMessage, + ), + style = ElementTheme.typography.fontBodySmMedium, + color = ElementTheme.colors.textActionAccent, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (message != null) { + Text( + text = message, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textPrimary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + } +} + +@Stable +internal interface PinnedMessagesBannerViewScrollBehavior { + val isVisible: Boolean + val nestedScrollConnection: NestedScrollConnection +} + +internal object PinnedMessagesBannerViewDefaults { + @Composable + fun rememberScrollBehavior(pinnedMessagesCount: Int): PinnedMessagesBannerViewScrollBehavior = remember(pinnedMessagesCount) { + ExitOnScrollBehavior() + } +} + +private class ExitOnScrollBehavior : PinnedMessagesBannerViewScrollBehavior { + override var isVisible by mutableStateOf(true) + override val nestedScrollConnection: NestedScrollConnection = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (available.y < -1) { + isVisible = true + } + if (available.y > 1) { + isVisible = false + } + return Offset.Zero + } + } +} + +@PreviewsDayNight +@Composable +internal fun PinnedMessagesBannerViewPreview(@PreviewParameter(PinnedMessagesBannerStateProvider::class) state: PinnedMessagesBannerState) = ElementPreview { + PinnedMessagesBannerView( + state = state, + onClick = {}, + onViewAllClick = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvents.kt new file mode 100644 index 0000000..4a3dcf6 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListEvents.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.list + +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.timeline.model.TimelineItem + +sealed interface PinnedMessagesListEvents { + data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : PinnedMessagesListEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNavigator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNavigator.kt new file mode 100644 index 0000000..a3728cb --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNavigator.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.list + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo + +interface PinnedMessagesListNavigator { + fun viewInTimeline(eventId: EventId) + fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) + fun forwardEvent(eventId: EventId) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt new file mode 100644 index 0000000..57af770 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.list + +import android.content.Context +import android.view.HapticFeedbackConstants +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.messages.impl.actionlist.ActionListPresenter +import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories +import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.androidutils.system.copyToClipboard +import io.element.android.libraries.androidutils.system.openUrlInExternalApp +import io.element.android.libraries.architecture.callback +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.permalink.PermalinkData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.ui.strings.CommonStrings + +@ContributesNode(RoomScope::class) +@AssistedInject +class PinnedMessagesListNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: PinnedMessagesListPresenter.Factory, + actionListPresenterFactory: ActionListPresenter.Factory, + private val timelineItemPresenterFactories: TimelineItemPresenterFactories, + private val permalinkParser: PermalinkParser, +) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator { + interface Callback : Plugin { + fun handleEventClick(event: TimelineItem.Event) + fun navigateToRoomMemberDetails(userId: UserId) + fun viewInTimeline(eventId: EventId) + fun handlePermalinkClick(data: PermalinkData.RoomLink) + fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) + fun handleForwardEventClick(eventId: EventId) + } + + private val callback: Callback = callback() + private val presenter = presenterFactory.create( + navigator = this, + actionListPresenter = actionListPresenterFactory.create( + postProcessor = PinnedMessagesListTimelineActionPostProcessor(), + timelineMode = Timeline.Mode.PinnedEvents, + ) + ) + + private fun onLinkClick(context: Context, url: String) { + when (val permalink = permalinkParser.parse(url)) { + is PermalinkData.UserLink -> { + // Open the room member profile, it will fallback to + // the user profile if the user is not in the room + callback.navigateToRoomMemberDetails(permalink.userId) + } + is PermalinkData.RoomLink -> { + callback.handlePermalinkClick(permalink) + } + is PermalinkData.FallbackLink, + is PermalinkData.RoomEmailInviteLink -> { + context.openUrlInExternalApp(url) + } + } + } + + override fun viewInTimeline(eventId: EventId) { + callback.viewInTimeline(eventId) + } + + override fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { + callback.navigateToEventDebugInfo(eventId, debugInfo) + } + + override fun forwardEvent(eventId: EventId) { + callback.handleForwardEventClick(eventId) + } + + @Composable + override fun View(modifier: Modifier) { + CompositionLocalProvider( + LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, + ) { + val context = LocalContext.current + val view = LocalView.current + val state = presenter.present() + PinnedMessagesListView( + state = state, + onBackClick = ::navigateUp, + onEventClick = callback::handleEventClick, + onUserDataClick = { callback.navigateToRoomMemberDetails(it.userId) }, + onLinkClick = { link -> onLinkClick(context, link.url) }, + onLinkLongClick = { + view.performHapticFeedback( + HapticFeedbackConstants.LONG_PRESS + ) + context.copyToClipboard( + it.url, + context.getString(CommonStrings.common_copied_to_clipboard) + ) + }, + modifier = modifier + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt new file mode 100644 index 0000000..cdc1f85 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.list + +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.rememberUpdatedState +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.Interaction +import im.vector.app.features.analytics.plan.PinUnpinAction +import io.element.android.features.messages.api.timeline.HtmlConverterProvider +import io.element.android.features.messages.impl.UserEventPermissions +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.link.LinkState +import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider +import io.element.android.features.messages.impl.timeline.TimelineRoomInfo +import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory +import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.messages.impl.typing.TypingNotificationState +import io.element.android.features.roomcall.api.aStandByCallState +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin +import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther +import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn +import io.element.android.libraries.matrix.api.room.roomMembers +import io.element.android.libraries.matrix.ui.room.isDmAsState +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber + +@AssistedInject +class PinnedMessagesListPresenter( + @Assisted private val navigator: PinnedMessagesListNavigator, + private val room: JoinedRoom, + timelineItemsFactoryCreator: TimelineItemsFactory.Creator, + private val timelineProvider: DefaultPinnedEventsTimelineProvider, + private val timelineProtectionPresenter: Presenter, + private val linkPresenter: Presenter, + private val snackbarDispatcher: SnackbarDispatcher, + @Assisted private val actionListPresenter: Presenter, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, + private val analyticsService: AnalyticsService, + private val featureFlagService: FeatureFlagService, + private val htmlConverterProvider: HtmlConverterProvider, +) : Presenter { + @AssistedFactory + interface Factory { + fun create( + navigator: PinnedMessagesListNavigator, + actionListPresenter: Presenter, + ): PinnedMessagesListPresenter + } + + private val timelineItemsFactory: TimelineItemsFactory = timelineItemsFactoryCreator.create( + config = TimelineItemsFactoryConfig( + computeReadReceipts = false, + computeReactions = false, + ) + ) + + @Composable + override fun present(): PinnedMessagesListState { + htmlConverterProvider.Update() + val isDm by room.isDmAsState() + + val timelineRoomInfo = remember(isDm) { + TimelineRoomInfo( + isDm = isDm, + name = room.info().name, + // We don't need to compute those values + userHasPermissionToSendMessage = false, + userHasPermissionToSendReaction = false, + // We do not care about the call state here. + roomCallState = aStandByCallState(), + // don't compute this value or the pin icon will be shown + pinnedEventIds = persistentListOf(), + typingNotificationState = TypingNotificationState( + renderTypingNotifications = false, + typingMembers = persistentListOf(), + reserveSpace = false, + ), + predecessorRoom = room.predecessorRoom(), + ) + } + val timelineProtectionState = timelineProtectionPresenter.present() + val linkState = linkPresenter.present() + val syncUpdateFlow = room.syncUpdateFlow.collectAsState() + val userEventPermissions by userEventPermissions(syncUpdateFlow.value) + + val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false) + + var pinnedMessageItems by remember { + mutableStateOf>>(AsyncData.Uninitialized) + } + PinnedMessagesListEffect( + onItemsChange = { newItems -> + pinnedMessageItems = newItems + } + ) + + fun handleEvent(event: PinnedMessagesListEvents) { + when (event) { + is PinnedMessagesListEvents.HandleAction -> sessionCoroutineScope.handleTimelineAction(event.action, event.event) + } + } + + return pinnedMessagesListState( + timelineRoomInfo = timelineRoomInfo, + timelineProtectionState = timelineProtectionState, + linkState = linkState, + displayThreadSummaries = displayThreadSummaries, + userEventPermissions = userEventPermissions, + timelineItems = pinnedMessageItems, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.handleTimelineAction( + action: TimelineItemAction, + targetEvent: TimelineItem.Event, + ) = launch { + when (action) { + TimelineItemAction.ViewSource -> { + navigator.navigateToEventDebugInfo(targetEvent.eventId, targetEvent.debugInfo) + } + TimelineItemAction.Forward -> { + targetEvent.eventId?.let { eventId -> + navigator.forwardEvent(eventId) + } + } + TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) + TimelineItemAction.ViewInTimeline -> { + targetEvent.eventId?.let { eventId -> + analyticsService.captureInteraction(Interaction.Name.PinnedMessageListViewTimeline) + navigator.viewInTimeline(eventId) + } + } + else -> Unit + } + } + + private suspend fun handleUnpinAction(targetEvent: TimelineItem.Event) { + if (targetEvent.eventId == null) return + analyticsService.capture( + PinUnpinAction( + from = PinUnpinAction.From.MessagePinningList, + kind = PinUnpinAction.Kind.Unpin, + ) + ) + timelineProvider.invokeOnTimeline { + unpinEvent(targetEvent.eventId) + .onFailure { + Timber.e(it, "Failed to unpin event ${targetEvent.eventId}") + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error)) + } + } + } + + @Composable + private fun userEventPermissions(updateKey: Long): State { + return produceState(UserEventPermissions.DEFAULT, key1 = updateKey) { + value = UserEventPermissions( + canSendMessage = false, + canSendReaction = false, + canRedactOwn = room.canRedactOwn().getOrElse { false }, + canRedactOther = room.canRedactOther().getOrElse { false }, + canPinUnpin = room.canPinUnpin().getOrElse { false }, + ) + } + } + + @Composable + private fun PinnedMessagesListEffect(onItemsChange: (AsyncData>) -> Unit) { + val updatedOnItemsChange by rememberUpdatedState(onItemsChange) + + val timelineState by timelineProvider.timelineStateFlow.collectAsState() + + LaunchedEffect(timelineState) { + when (val asyncTimeline = timelineState) { + AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized) + is AsyncData.Failure -> flowOf(AsyncData.Failure(asyncTimeline.error)) + is AsyncData.Loading -> flowOf(AsyncData.Loading()) + is AsyncData.Success -> { + val timelineItemsFlow = asyncTimeline.data.timelineItems + combine(timelineItemsFlow, room.membersStateFlow) { items, membersState -> + timelineItemsFactory.replaceWith( + timelineItems = items, + roomMembers = membersState.roomMembers().orEmpty() + ) + }.launchIn(this) + + timelineItemsFactory.timelineItems.map { timelineItems -> + AsyncData.Success(timelineItems) + } + } + } + .onEach { items -> + updatedOnItemsChange(items) + } + .launchIn(this) + } + } + + @Composable + private fun pinnedMessagesListState( + timelineRoomInfo: TimelineRoomInfo, + timelineProtectionState: TimelineProtectionState, + displayThreadSummaries: Boolean, + linkState: LinkState, + userEventPermissions: UserEventPermissions, + timelineItems: AsyncData>, + eventSink: (PinnedMessagesListEvents) -> Unit + ): PinnedMessagesListState { + return when (timelineItems) { + AsyncData.Uninitialized, is AsyncData.Loading -> PinnedMessagesListState.Loading + is AsyncData.Failure -> PinnedMessagesListState.Failed + is AsyncData.Success -> { + if (timelineItems.data.isEmpty()) { + PinnedMessagesListState.Empty + } else { + val actionListState = actionListPresenter.present() + PinnedMessagesListState.Filled( + timelineRoomInfo = timelineRoomInfo, + timelineProtectionState = timelineProtectionState, + displayThreadSummaries = displayThreadSummaries, + linkState = linkState, + userEventPermissions = userEventPermissions, + timelineItems = timelineItems.data, + actionListState = actionListState, + eventSink = eventSink + ) + } + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt new file mode 100644 index 0000000..c62d293 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListState.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.list + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import io.element.android.features.messages.impl.UserEventPermissions +import io.element.android.features.messages.impl.actionlist.ActionListState +import io.element.android.features.messages.impl.link.LinkState +import io.element.android.features.messages.impl.timeline.TimelineRoomInfo +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.libraries.ui.strings.CommonPlurals +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +@Immutable +sealed interface PinnedMessagesListState { + data object Failed : PinnedMessagesListState + data object Loading : PinnedMessagesListState + data object Empty : PinnedMessagesListState + data class Filled( + val timelineRoomInfo: TimelineRoomInfo, + val timelineProtectionState: TimelineProtectionState, + val userEventPermissions: UserEventPermissions, + val timelineItems: ImmutableList, + val actionListState: ActionListState, + val linkState: LinkState, + val displayThreadSummaries: Boolean, + val eventSink: (PinnedMessagesListEvents) -> Unit, + ) : PinnedMessagesListState { + val loadedPinnedMessagesCount = timelineItems.count { timelineItem -> timelineItem is TimelineItem.Event } + } + + @Composable + fun title(): String { + return when (this) { + is Filled -> { + pluralStringResource(id = CommonPlurals.screen_pinned_timeline_screen_title, loadedPinnedMessagesCount, loadedPinnedMessagesCount) + } + else -> stringResource(id = CommonStrings.screen_pinned_timeline_screen_title_empty) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt new file mode 100644 index 0000000..a3ed06c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListStateProvider.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.list + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.UserEventPermissions +import io.element.android.features.messages.impl.actionlist.ActionListState +import io.element.android.features.messages.impl.actionlist.anActionListState +import io.element.android.features.messages.impl.link.LinkState +import io.element.android.features.messages.impl.link.aLinkState +import io.element.android.features.messages.impl.timeline.TimelineRoomInfo +import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator +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.aTimelineRoomInfo +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.aTimelineItemAudioContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +open class PinnedMessagesListStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aFailedPinnedMessagesListState(), + aLoadingPinnedMessagesListState(), + anEmptyPinnedMessagesListState(), + aLoadedPinnedMessagesListState( + timelineItems = persistentListOf( + aTimelineItemEvent( + isMine = false, + content = aTimelineItemTextContent("A pinned message"), + groupPosition = TimelineItemGroupPosition.Last, + timelineItemReactions = aTimelineItemReactions(0) + ), + aTimelineItemEvent( + isMine = false, + content = aTimelineItemAudioContent("A pinned file"), + groupPosition = TimelineItemGroupPosition.Middle, + timelineItemReactions = aTimelineItemReactions(0) + ), + aTimelineItemEvent( + isMine = false, + content = aTimelineItemPollContent("A pinned poll?"), + groupPosition = TimelineItemGroupPosition.First, + timelineItemReactions = aTimelineItemReactions(0) + ), + aTimelineItemDaySeparator(), + aTimelineItemEvent( + isMine = true, + content = aTimelineItemTextContent("A pinned message"), + groupPosition = TimelineItemGroupPosition.Last, + timelineItemReactions = aTimelineItemReactions(0) + ), + aTimelineItemEvent( + isMine = true, + content = aTimelineItemFileContent("A pinned file?"), + groupPosition = TimelineItemGroupPosition.Middle, + timelineItemReactions = aTimelineItemReactions(0) + ), + aTimelineItemEvent( + isMine = true, + content = aTimelineItemPollContent("A pinned poll?"), + groupPosition = TimelineItemGroupPosition.First, + timelineItemReactions = aTimelineItemReactions(0) + ), + ) + ) + ) +} + +fun aFailedPinnedMessagesListState() = PinnedMessagesListState.Failed + +fun aLoadingPinnedMessagesListState() = PinnedMessagesListState.Loading + +fun anEmptyPinnedMessagesListState() = PinnedMessagesListState.Empty + +fun aLoadedPinnedMessagesListState( + timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(), + timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(), + linkState: LinkState = aLinkState(), + timelineItems: List = emptyList(), + actionListState: ActionListState = anActionListState(), + aUserEventPermissions: UserEventPermissions = UserEventPermissions.DEFAULT, + displayThreadSummaries: Boolean = false, + eventSink: (PinnedMessagesListEvents) -> Unit = {} +) = PinnedMessagesListState.Filled( + timelineRoomInfo = timelineRoomInfo, + timelineProtectionState = timelineProtectionState, + linkState = linkState, + timelineItems = timelineItems.toImmutableList(), + actionListState = actionListState, + userEventPermissions = aUserEventPermissions, + displayThreadSummaries = displayThreadSummaries, + eventSink = eventSink, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListTimelineActionPostProcessor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListTimelineActionPostProcessor.kt new file mode 100644 index 0000000..a7d03fa --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListTimelineActionPostProcessor.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.list + +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor + +class PinnedMessagesListTimelineActionPostProcessor : TimelineItemActionPostProcessor { + override fun process(actions: List): List { + return buildList { + add(TimelineItemAction.ViewInTimeline) + actions.firstOrNull { it == TimelineItemAction.Unpin }?.let(::add) + actions.firstOrNull { it == TimelineItemAction.Forward }?.let(::add) + actions.firstOrNull { it == TimelineItemAction.ViewSource }?.let(::add) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt new file mode 100644 index 0000000..18175f1 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -0,0 +1,307 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.list + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +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.items +import androidx.compose.foundation.lazy.rememberLazyListState +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.PreviewParameter +import androidx.compose.ui.unit.dp +import im.vector.app.features.analytics.plan.Interaction +import io.element.android.compound.tokens.generated.CompoundIcons +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.link.LinkEvents +import io.element.android.features.messages.impl.link.LinkView +import io.element.android.features.messages.impl.timeline.components.TimelineItemRow +import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.poll.api.pollcontent.PollTitleView +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.TopAppBar +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.analytics.compose.LocalAnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction +import io.element.android.wysiwyg.link.Link + +@Composable +fun PinnedMessagesListView( + state: PinnedMessagesListState, + onBackClick: () -> Unit, + onEventClick: (event: TimelineItem.Event) -> Unit, + onUserDataClick: (MatrixUser) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + val analyticsService = LocalAnalyticsService.current + PinnedMessagesListTopBar( + state = state, + onBackClick = { + analyticsService.captureInteraction(Interaction.Name.PinnedMessageBannerCloseListButton) + onBackClick() + } + ) + }, + content = { padding -> + PinnedMessagesListContent( + state = state, + onEventClick = onEventClick, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onErrorDismiss = onBackClick, + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding), + ) + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PinnedMessagesListTopBar( + state: PinnedMessagesListState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + titleStr = state.title(), + navigationIcon = { BackButton(onClick = onBackClick) }, + modifier = modifier, + ) +} + +@Composable +private fun PinnedMessagesListContent( + state: PinnedMessagesListState, + onEventClick: (event: TimelineItem.Event) -> Unit, + onUserDataClick: (MatrixUser) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, + onErrorDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier.fillMaxSize()) { + when (state) { + PinnedMessagesListState.Failed -> { + ErrorDialog( + title = stringResource(id = CommonStrings.error_unknown), + content = stringResource(id = CommonStrings.error_failed_loading_messages), + onSubmit = onErrorDismiss + ) + } + PinnedMessagesListState.Empty -> PinnedMessagesListEmpty() + is PinnedMessagesListState.Filled -> PinnedMessagesListLoaded( + state = state, + displayThreadSummaries = state.displayThreadSummaries, + onEventClick = onEventClick, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + ) + PinnedMessagesListState.Loading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + } + } +} + +@Composable +private fun PinnedMessagesListEmpty( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.padding( + horizontal = 32.dp, + vertical = 48.dp, + ), + contentAlignment = Alignment.Center, + ) { + val pinActionText = stringResource(id = CommonStrings.action_pin) + IconTitleSubtitleMolecule( + title = stringResource(id = CommonStrings.screen_pinned_timeline_empty_state_headline), + subTitle = stringResource(id = CommonStrings.screen_pinned_timeline_empty_state_description, pinActionText), + iconStyle = BigIcon.Style.Default(CompoundIcons.Pin()), + ) + } +} + +@Composable +private fun PinnedMessagesListLoaded( + state: PinnedMessagesListState.Filled, + displayThreadSummaries: Boolean, + onEventClick: (event: TimelineItem.Event) -> Unit, + onUserDataClick: (MatrixUser) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, + modifier: Modifier = Modifier, +) { + fun onActionSelected(timelineItemAction: TimelineItemAction, event: TimelineItem.Event) { + state.actionListState.eventSink( + ActionListEvents.Clear + ) + state.eventSink( + PinnedMessagesListEvents.HandleAction( + action = timelineItemAction, + event = event, + ) + ) + } + + fun onMessageLongClick(event: TimelineItem.Event) { + state.actionListState.eventSink( + ActionListEvents.ComputeForMessage( + event = event, + userEventPermissions = state.userEventPermissions, + ) + ) + } + + ActionListView( + state = state.actionListState, + onSelectAction = ::onActionSelected, + onCustomReactionClick = {}, + onEmojiReactionClick = { _, _ -> }, + onVerifiedUserSendFailureClick = {} + ) + LazyColumn( + modifier = modifier.fillMaxSize(), + state = rememberLazyListState(), + reverseLayout = true, + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items( + items = state.timelineItems, + contentType = { timelineItem -> timelineItem.contentType() }, + key = { timelineItem -> timelineItem.identifier() }, + ) { timelineItem -> + TimelineItemRow( + timelineItem = timelineItem, + timelineMode = Timeline.Mode.PinnedEvents, + timelineRoomInfo = state.timelineRoomInfo, + renderReadReceipts = false, + timelineProtectionState = state.timelineProtectionState, + isLastOutgoingMessage = false, + focusedEventId = null, + onUserDataClick = onUserDataClick, + onLinkClick = { link -> + state.linkState.eventSink(LinkEvents.OnLinkClick(link)) + }, + onLinkLongClick = onLinkLongClick, + onContentClick = onEventClick, + onLongClick = ::onMessageLongClick, + displayThreadSummaries = displayThreadSummaries, + inReplyToClick = {}, + onReactionClick = { _, _ -> }, + onReactionLongClick = { _, _ -> }, + onMoreReactionsClick = {}, + onReadReceiptClick = {}, + onSwipeToReply = {}, + onJoinCallClick = {}, + eventSink = {}, + eventContentView = { event, contentModifier, onContentLayoutChange -> + TimelineItemEventContentViewWrapper( + event = event, + timelineProtectionState = state.timelineProtectionState, + onContentClick = { onEventClick(event) }, + onLongClick = { onMessageLongClick(event) }, + onLinkClick = { link -> + state.linkState.eventSink(LinkEvents.OnLinkClick(link)) + }, + onLinkLongClick = onLinkLongClick, + modifier = contentModifier, + onContentLayoutChange = onContentLayoutChange + ) + }, + ) + } + } + LinkView( + state.linkState, + onLinkValid = onLinkClick, + ) +} + +@Composable +private fun TimelineItemEventContentViewWrapper( + event: TimelineItem.Event, + timelineProtectionState: TimelineProtectionState, + onContentClick: () -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, + onLongClick: (() -> Unit)?, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier, +) { + if (event.content is TimelineItemPollContent) { + PollTitleView( + title = event.content.question, + isPollEnded = event.content.isEnded, + modifier = modifier + ) + } else { + TimelineItemEventContentView( + content = event.content, + hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId), + onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + eventSink = { }, + modifier = modifier, + onContentClick = onContentClick, + onLongClick = onLongClick, + onContentLayoutChange = onContentLayoutChange + ) + } +} + +@PreviewsDayNight +@Composable +internal fun PinnedMessagesListViewPreview(@PreviewParameter(PinnedMessagesListStateProvider::class) state: PinnedMessagesListState) = + ElementPreview { + PinnedMessagesListView( + state = state, + onBackClick = {}, + onEventClick = { }, + onUserDataClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + ) + } 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 0000000..966b83d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageEvents.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.report + +sealed interface ReportMessageEvents { + data class UpdateReason(val reason: String) : ReportMessageEvents + data object ToggleBlockUser : ReportMessageEvents + data object Report : ReportMessageEvents + data 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 0000000..9a19e82 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageNode.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.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) +@AssistedInject +class ReportMessageNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: ReportMessagePresenter.Factory, +) : Node(buildContext, plugins = plugins) { + data class Inputs( + val eventId: EventId, + val senderId: UserId, + ) : NodeInputs + + private val 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, + onBackClick = ::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 0000000..5dee4f5 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenter.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.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.JoinedRoom +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@AssistedInject +class ReportMessagePresenter( + private val room: JoinedRoom, + @Assisted private val inputs: Inputs, + private val snackbarDispatcher: SnackbarDispatcher, +) : Presenter { + 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> = remember { mutableStateOf(AsyncAction.Uninitialized) } + + fun handleEvent(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 = AsyncAction.Uninitialized + } + } + + return ReportMessageState( + reason = reason, + blockUser = blockUser, + result = result.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.report( + eventId: EventId, + userId: UserId, + reason: String, + blockUser: Boolean, + result: MutableState>, + ) = 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 0000000..38eee9d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageState.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.report + +import io.element.android.libraries.architecture.AsyncAction + +data class ReportMessageState( + val reason: String, + val blockUser: Boolean, + val result: AsyncAction, + 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 0000000..0e2b4b0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageStateProvider.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.report + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction + +open class ReportMessageStateProvider : PreviewParameterProvider { + override val values: Sequence + 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 = AsyncAction.Loading), + aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = AsyncAction.Failure(RuntimeException("error"))), + aReportMessageState(reason = "This user is making the chat very toxic.", blockUser = true, result = AsyncAction.Success(Unit)), + // Add other states here + ) +} + +fun aReportMessageState( + reason: String = "", + blockUser: Boolean = false, + result: AsyncAction = AsyncAction.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 0000000..a2c3987 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.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.Switch +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.messages.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +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.TextField +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReportMessageView( + state: ReportMessageState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + val isSending = state.result is AsyncAction.Loading + AsyncActionView( + async = state.result, + progressDialog = {}, + onSuccess = { onBackClick() }, + errorMessage = { stringResource(CommonStrings.error_unknown) }, + onErrorDismiss = { state.eventSink(ReportMessageEvents.ClearError) } + ) + + Scaffold( + topBar = { + TopAppBar( + titleStr = stringResource(CommonStrings.action_report_content), + navigationIcon = { + BackButton(onClick = onBackClick) + } + ) + }, + modifier = modifier + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + .imePadding() + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + Spacer(modifier = Modifier.height(20.dp)) + + TextField( + value = state.reason, + onValueChange = { state.eventSink(ReportMessageEvents.UpdateReason(it)) }, + placeholder = stringResource(R.string.screen_report_content_hint), + minLines = 3, + enabled = !isSending, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 90.dp), + supportingText = stringResource(R.string.screen_report_content_explanation), + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = stringResource(R.string.screen_report_content_block_user), + style = ElementTheme.typography.fontBodyLgRegular, + ) + Text( + text = stringResource(R.string.screen_report_content_block_user_hint), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } + Switch( + enabled = !isSending, + checked = state.blockUser, + onCheckedChange = { state.eventSink(ReportMessageEvents.ToggleBlockUser) }, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + 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) + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun ReportMessageViewPreview(@PreviewParameter(ReportMessageStateProvider::class) state: ReportMessageState) = ElementPreview { + ReportMessageView( + onBackClick = {}, + state = state, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt new file mode 100644 index 0000000..10922ca --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.threads + +import android.app.Activity +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.lifecycle.Lifecycle +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.messages.impl.MessagesNavigator +import io.element.android.features.messages.impl.MessagesPresenter +import io.element.android.features.messages.impl.MessagesView +import io.element.android.features.messages.impl.actionlist.ActionListPresenter +import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor +import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvent +import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter +import io.element.android.features.messages.impl.timeline.TimelineController +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.di.LocalTimelineItemPresenterFactories +import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab +import io.element.android.libraries.androidutils.system.openUrlInExternalApp +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom +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.ThreadId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.room.CreateTimelineParams +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.alias.matches +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.mediaplayer.api.MediaPlayer +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.appnavstate.api.AppNavigationStateService +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +@ContributesNode(RoomScope::class) +@AssistedInject +class ThreadedMessagesNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, + private val room: JoinedRoom, + private val analyticsService: AnalyticsService, + messageComposerPresenterFactory: MessageComposerPresenter.Factory, + timelinePresenterFactory: TimelinePresenter.Factory, + presenterFactory: MessagesPresenter.Factory, + actionListPresenterFactory: ActionListPresenter.Factory, + private val timelineItemPresenterFactories: TimelineItemPresenterFactories, + private val mediaPlayer: MediaPlayer, + private val permalinkParser: PermalinkParser, + private val appNavigationStateService: AppNavigationStateService, +) : Node(buildContext, plugins = plugins), MessagesNavigator { + data class Inputs( + val threadRootEventId: ThreadId, + val focusedEventId: EventId?, + ) : NodeInputs + + private val inputs = inputs() + private val callback: Callback = callback() + + // TODO use a loading state node to preload this instead of using `runBlocking` + private val threadedTimeline = runBlocking { room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = inputs.threadRootEventId)).getOrThrow() } + private val timelineController = TimelineController(room, threadedTimeline) + private val presenter = presenterFactory.create( + navigator = this, + composerPresenter = messageComposerPresenterFactory.create(timelineController, this), + timelinePresenter = timelinePresenterFactory.create(timelineController = timelineController, this), + // TODO add special processor for threaded timeline + actionListPresenter = actionListPresenterFactory.create( + postProcessor = TimelineItemActionPostProcessor.Default, + timelineMode = timelineController.mainTimelineMode(), + ), + timelineController = timelineController, + ) + + interface Callback : Plugin { + fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean + fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) + fun navigateToRoomMemberDetails(userId: UserId) + fun handlePermalinkClick(data: PermalinkData) + fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) + fun handleForwardEventClick(eventId: EventId) + fun navigateToReportMessage(eventId: EventId, senderId: UserId) + fun navigateToSendLocation() + fun navigateToCreatePoll() + fun navigateToEditPoll(eventId: EventId) + fun navigateToRoomCall(roomId: RoomId) + fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) + } + + override fun onBuilt() { + super.onBuilt() + lifecycle.subscribe( + onCreate = { + sessionCoroutineScope.launch { analyticsService.capture(room.toAnalyticsViewRoom()) } + }, + onStart = { + appNavigationStateService.onNavigateToThread(id, inputs.threadRootEventId) + }, + onStop = { + appNavigationStateService.onLeavingThread(id) + }, + onDestroy = { + mediaPlayer.close() + } + ) + } + + private fun onLinkClick( + activity: Activity, + darkTheme: Boolean, + url: String, + eventSink: (TimelineEvents) -> Unit, + customTab: Boolean + ) { + when (val permalink = permalinkParser.parse(url)) { + is PermalinkData.UserLink -> { + // Open the room member profile, it will fallback to + // the user profile if the user is not in the room + callback.navigateToRoomMemberDetails(permalink.userId) + } + is PermalinkData.RoomLink -> { + handleRoomLinkClick(permalink, eventSink) + } + is PermalinkData.FallbackLink -> { + if (customTab) { + activity.openUrlInChromeCustomTab(null, darkTheme, url) + } else { + activity.openUrlInExternalApp(url) + } + } + is PermalinkData.RoomEmailInviteLink -> { + activity.openUrlInChromeCustomTab(null, darkTheme, url) + } + } + } + + private fun handleRoomLinkClick( + roomLink: PermalinkData.RoomLink, + eventSink: (TimelineEvents) -> Unit, + ) { + if (room.matches(roomLink.roomIdOrAlias)) { + val eventId = roomLink.eventId + if (eventId != null) { + eventSink(TimelineEvents.FocusOnEvent(eventId)) + } else { + // Click on the same room, navigate up + // Note that it can not be enough to go back to the room if the thread has been opened + // following a permalink from another thread. In this case navigating up will go back + // to the previous thread. But this should not happen often. + navigateUp() + } + } else { + callback.handlePermalinkClick(roomLink) + } + } + + override fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { + callback.navigateToEventDebugInfo(eventId, debugInfo) + } + + override fun forwardEvent(eventId: EventId) { + callback.handleForwardEventClick(eventId) + } + + override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { + callback.navigateToReportMessage(eventId, senderId) + } + + override fun navigateToEditPoll(eventId: EventId) { + callback.navigateToEditPoll(eventId) + } + + override fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) { + callback.navigateToPreviewAttachments(attachments, inReplyToEventId) + } + + override fun navigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List) { + val permalinkData = PermalinkData.RoomLink(roomId.toRoomIdOrAlias(), eventId, viaParameters = serverNames.toImmutableList()) + callback.handlePermalinkClick(permalinkData) + } + + override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { + callback.navigateToThread(threadRootId, focusedEventId) + } + + override fun close() = navigateUp() + + @Composable + override fun View(modifier: Modifier) { + val activity = requireNotNull(LocalActivity.current) + val isDark = ElementTheme.isLightTheme.not() + CompositionLocalProvider( + LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, + ) { + val state = presenter.present() + OnLifecycleEvent { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> state.composerState.eventSink(MessageComposerEvent.SaveDraft) + else -> Unit + } + } + MessagesView( + state = state, + onBackClick = this::navigateUp, + onRoomDetailsClick = {}, + onEventContentClick = { isLive, event -> + if (isLive) { + callback.handleEventClick(timelineController.mainTimelineMode(), event) + } else { + val detachedTimelineMode = timelineController.detachedTimelineMode() + if (detachedTimelineMode != null) { + callback.handleEventClick(detachedTimelineMode, event) + } else { + false + } + } + }, + onUserDataClick = callback::navigateToRoomMemberDetails, + onLinkClick = { url, customTab -> + onLinkClick( + activity = activity, + darkTheme = isDark, + url = url, + eventSink = state.timelineState.eventSink, + customTab = customTab, + ) + }, + onSendLocationClick = callback::navigateToSendLocation, + onCreatePollClick = callback::navigateToCreatePoll, + onJoinCallClick = { callback.navigateToRoomCall(room.roomId) }, + onViewAllPinnedMessagesClick = {}, + modifier = modifier, + knockRequestsBannerView = {}, + ) + + var focusedEventId by rememberSaveable { + mutableStateOf(inputs.focusedEventId) + } + LaunchedEffect(Unit) { + focusedEventId?.also { eventId -> + state.timelineState.eventSink(TimelineEvents.FocusOnEvent(eventId)) + } + // Reset the focused event id to null to avoid refocusing when restoring node. + focusedEventId = null + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt new file mode 100644 index 0000000..fbf5b9d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.features.messages.api.timeline.HtmlConverterProvider +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.wysiwyg.compose.StyledHtmlConverter +import io.element.android.wysiwyg.display.MentionDisplayHandler +import io.element.android.wysiwyg.display.TextDisplay +import io.element.android.wysiwyg.utils.HtmlConverter +import uniffi.wysiwyg_composer.newMentionDetector + +@ContributesBinding(RoomScope::class) +@SingleIn(RoomScope::class) +class DefaultHtmlConverterProvider( + private val mentionSpanProvider: MentionSpanProvider, +) : HtmlConverterProvider { + private val htmlConverter: MutableState = mutableStateOf(null) + + @Composable + override fun Update() { + val isInEditMode = LocalInspectionMode.current + val mentionDetector = remember(isInEditMode) { + if (isInEditMode) null else newMentionDetector() + } + + val editorStyle = ElementRichTextEditorStyle.textStyle() + val context = LocalContext.current + + htmlConverter.value = remember(editorStyle) { + StyledHtmlConverter( + context = context, + mentionDisplayHandler = object : MentionDisplayHandler { + override fun resolveAtRoomMentionDisplay(): TextDisplay { + val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan() + return TextDisplay.Custom(mentionSpan) + } + + override fun resolveMentionDisplay(text: String, url: String): TextDisplay { + val mentionSpan = mentionSpanProvider.getMentionSpanFor(text, url) + return if (mentionSpan != null) { + TextDisplay.Custom(mentionSpan) + } else { + TextDisplay.Plain + } + } + }, + isEditor = false, + isMention = { _, url -> + mentionDetector?.isMention(url).orFalse() + } + ).apply { + configureWith(editorStyle) + } + } + } + + override fun provide(): HtmlConverter { + return htmlConverter.value ?: error("HtmlConverter wasn't instantiated. Make sure to call HtmlConverterProvider.Update() first.") + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/MarkAsFullyRead.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/MarkAsFullyRead.kt new file mode 100644 index 0000000..7fe1705 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/MarkAsFullyRead.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline + +import dev.zacsweers.metro.ContributesBinding +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.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.withContext +import timber.log.Timber + +interface MarkAsFullyRead { + suspend operator fun invoke(roomId: RoomId, eventId: EventId): Result +} + +@ContributesBinding(SessionScope::class) +class DefaultMarkAsFullyRead( + private val matrixClient: MatrixClient, + private val coroutineDispatchers: CoroutineDispatchers, +) : MarkAsFullyRead { + override suspend fun invoke(roomId: RoomId, eventId: EventId): Result = withContext(coroutineDispatchers.io) { + matrixClient.markRoomAsFullyRead(roomId, eventId).onFailure { + Timber.e(it, "Failed to mark room $roomId as fully read for event $eventId") + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt new file mode 100644 index 0000000..e41ac7b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline + +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import dev.zacsweers.metro.binding +import io.element.android.features.messages.impl.timeline.di.LiveTimeline +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.room.CreateTimelineParams +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.TimelineProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import java.io.Closeable +import java.util.Optional + +/** + * This controller is responsible of using the right timeline to display messages and make associated actions. + * It can be focused on the live timeline or on a detached timeline (focusing an unknown event). + */ +@SingleIn(RoomScope::class) +@ContributesBinding(RoomScope::class, binding = binding()) +class TimelineController( + private val room: JoinedRoom, + @LiveTimeline private val liveTimeline: Timeline, +) : Closeable, TimelineProvider { + private val coroutineScope = CoroutineScope(SupervisorJob()) + + private val liveTimelineFlow = flowOf(liveTimeline) + private val detachedTimelineFlow = MutableStateFlow>(Optional.empty()) + + @OptIn(ExperimentalCoroutinesApi::class) + fun timelineItems(): Flow> { + return currentTimelineFlow.flatMapLatest { it.timelineItems } + } + + fun isLive(): Flow { + return detachedTimelineFlow.map { !it.isPresent } + } + + fun mainTimelineMode(): Timeline.Mode = liveTimeline.mode + + fun detachedTimelineMode(): Timeline.Mode? { + return detachedTimelineFlow.value.orElse(null)?.mode + } + + suspend fun invokeOnCurrentTimeline(block: suspend (Timeline.() -> Unit)) { + currentTimelineFlow.value.run { + block(this) + } + } + + suspend fun focusOnEvent(eventId: EventId, threadRootId: ThreadId?): Result { + return if (threadRootId != null) { + Result.success(EventFocusResult.IsInThread(threadRootId)) + } else { + room.createTimeline(CreateTimelineParams.Focused(eventId)) + .onFailure { + if (it is CancellationException) { + throw it + } + } + .map { newDetachedTimeline -> + detachedTimelineFlow.getAndUpdate { current -> + if (current.isPresent) { + current.get().close() + } + Optional.of(newDetachedTimeline) + } + EventFocusResult.FocusedOnLive + } + } + } + + /** + * Makes sure the controller is focused on the live timeline. + * This does close the detached timeline if any. + */ + fun focusOnLive() { + closeDetachedTimeline() + } + + private fun closeDetachedTimeline() { + detachedTimelineFlow.getAndUpdate { + when { + it.isPresent -> { + it.get().close() + Optional.empty() + } + else -> Optional.empty() + } + } + } + + override fun close() { + coroutineScope.cancel() + closeDetachedTimeline() + } + + suspend fun paginate(direction: Timeline.PaginationDirection): Result { + return currentTimelineFlow.value.paginate(direction) + .onSuccess { hasReachedEnd -> + if (direction == Timeline.PaginationDirection.FORWARDS && hasReachedEnd) { + focusOnLive() + } + } + } + + private val currentTimelineFlow = combine(liveTimelineFlow, detachedTimelineFlow) { live, detached -> + when { + detached.isPresent -> detached.get() + else -> live + } + }.stateIn(coroutineScope, SharingStarted.Eagerly, room.liveTimeline) + + override fun activeTimelineFlow(): StateFlow { + return currentTimelineFlow + } +} + +sealed interface EventFocusResult { + data object FocusedOnLive : EventFocusResult + data class IsInThread(val threadId: ThreadId) : EventFocusResult +} 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 0000000..262c4f9 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline + +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.core.RoomId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield +import kotlin.time.Duration + +sealed interface TimelineEvents { + data class OnScrollFinished(val firstIndex: Int) : TimelineEvents + data class FocusOnEvent(val eventId: EventId, val debounce: Duration = Duration.ZERO) : TimelineEvents + data object ClearFocusRequestState : TimelineEvents + data object OnFocusEventRender : TimelineEvents + data object JumpToLive : TimelineEvents + + data object HideShieldDialog : TimelineEvents + + /** + * Events coming from a timeline item. + */ + sealed interface EventFromTimelineItem : TimelineEvents + + data class ComputeVerifiedUserSendFailure(val event: TimelineItem.Event) : EventFromTimelineItem + data class ShowShieldDialog(val messageShield: MessageShield) : EventFromTimelineItem + data class LoadMore(val direction: Timeline.PaginationDirection) : EventFromTimelineItem + data class OpenThread(val threadRootEventId: ThreadId, val focusedEvent: EventId?) : EventFromTimelineItem + + /** + * Navigate to the predecessor or successor room of the current room. + */ + data class NavigateToPredecessorOrSuccessorRoom(val roomId: RoomId) : EventFromTimelineItem + + /** + * Events coming from a poll item. + */ + sealed interface TimelineItemPollEvents : EventFromTimelineItem + + data class SelectPollAnswer( + val pollStartId: EventId, + val answerId: String + ) : TimelineItemPollEvents + + data class EndPoll( + val pollStartId: EventId, + ) : TimelineItemPollEvents + + data class EditPoll( + val pollStartId: EventId, + ) : TimelineItemPollEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexer.kt new file mode 100644 index 0000000..b612908 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexer.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline + +import dev.zacsweers.metro.Inject +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.matrix.api.core.EventId +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber + +@Inject +class TimelineItemIndexer { + // This is a latch to wait for the first process call + private val firstProcessLatch = CompletableDeferred() + private val timelineEventsIndexes = mutableMapOf() + + private val mutex = Mutex() + + suspend fun isKnown(eventId: EventId): Boolean { + firstProcessLatch.await() + return mutex.withLock { + timelineEventsIndexes.containsKey(eventId).also { + Timber.d("$eventId isKnown = $it") + } + } + } + + suspend fun indexOf(eventId: EventId): Int { + firstProcessLatch.await() + return mutex.withLock { + (timelineEventsIndexes[eventId] ?: -1).also { + Timber.d("indexOf $eventId= $it") + } + } + } + + suspend fun process(timelineItems: List) = mutex.withLock { + Timber.d("process ${timelineItems.size} items") + timelineEventsIndexes.clear() + timelineItems.forEachIndexed { index, timelineItem -> + when (timelineItem) { + is TimelineItem.Event -> { + processEvent(timelineItem, index) + } + is TimelineItem.GroupedEvents -> { + timelineItem.events.forEach { event -> + processEvent(event, index) + } + } + else -> Unit + } + } + firstProcessLatch.complete(Unit) + } + + private fun processEvent(event: TimelineItem.Event, index: Int) { + if (event.eventId == null) return + timelineEventsIndexes[event.eventId] = index + } +} 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 0000000..e44cac4 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -0,0 +1,464 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.messages.impl.MessagesNavigator +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvents +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState +import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory +import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig +import io.element.android.features.messages.impl.timeline.model.NewEventState +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel +import io.element.android.features.messages.impl.typing.TypingNotificationState +import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager +import io.element.android.features.poll.api.actions.EndPollAction +import io.element.android.features.poll.api.actions.SendPollResponseAction +import io.element.android.features.roomcall.api.RoomCallState +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.core.asEventId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.room.roomMembers +import io.element.android.libraries.matrix.api.timeline.ReceiptType +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield +import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin +import io.element.android.libraries.matrix.ui.room.canSendMessageAsState +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationTapOpensTimeline +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.finishLongRunningTransaction +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber + +const val FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS = 200L + +@AssistedInject +class TimelinePresenter( + timelineItemsFactoryCreator: TimelineItemsFactory.Creator, + private val room: JoinedRoom, + private val dispatchers: CoroutineDispatchers, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, + @Assisted private val navigator: MessagesNavigator, + private val redactedVoiceMessageManager: RedactedVoiceMessageManager, + private val sendPollResponseAction: SendPollResponseAction, + private val endPollAction: EndPollAction, + private val sessionPreferencesStore: SessionPreferencesStore, + @Assisted private val timelineController: TimelineController, + private val timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(), + private val resolveVerifiedUserSendFailurePresenter: Presenter, + private val typingNotificationPresenter: Presenter, + private val roomCallStatePresenter: Presenter, + private val featureFlagService: FeatureFlagService, + private val analyticsService: AnalyticsService, +) : Presenter { + private val tag = "TimelinePresenter" + @AssistedFactory + interface Factory { + fun create( + timelineController: TimelineController, + navigator: MessagesNavigator + ): TimelinePresenter + } + + private val timelineItemsFactory: TimelineItemsFactory = timelineItemsFactoryCreator.create( + config = TimelineItemsFactoryConfig( + computeReadReceipts = true, + computeReactions = true, + ) + ) + private var timelineItems by mutableStateOf>(persistentListOf()) + + private val focusRequestState: MutableState = mutableStateOf(FocusRequestState.None) + + @Composable + override fun present(): TimelineState { + LaunchedEffect(Unit) { + val parent = analyticsService.getLongRunningTransaction(OpenRoom) + analyticsService.startLongRunningTransaction(DisplayFirstTimelineItems, parent) + } + + val localScope = rememberCoroutineScope() + + val timelineMode = remember { timelineController.mainTimelineMode() } + + val lastReadReceiptId = rememberSaveable { mutableStateOf(null) } + + val roomInfo by room.roomInfoFlow.collectAsState() + + val syncUpdateFlow = room.syncUpdateFlow.collectAsState() + + val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.RoomMessage, updateKey = syncUpdateFlow.value) + val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.Reaction, updateKey = syncUpdateFlow.value) + + val prevMostRecentItemId = rememberSaveable { mutableStateOf(null) } + + val newEventState = remember { mutableStateOf(NewEventState.None) } + val messageShield: MutableState = remember { mutableStateOf(null) } + + val resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailurePresenter.present() + val isSendPublicReadReceiptsEnabled by remember { + sessionPreferencesStore.isSendPublicReadReceiptsEnabled() + }.collectAsState(initial = true) + val renderReadReceipts by remember { + sessionPreferencesStore.isRenderReadReceiptsEnabled() + }.collectAsState(initial = true) + val isLive by remember { + timelineController.isLive() + }.collectAsState(initial = true) + + val displayThreadSummaries by produceState(false) { + value = featureFlagService.isFeatureEnabled(FeatureFlags.Threads) + } + + fun handleEvent(event: TimelineEvents) { + when (event) { + is TimelineEvents.LoadMore -> { + if (event.direction == Timeline.PaginationDirection.FORWARDS && timelineMode is Timeline.Mode.Thread) { + // Do not paginate forwards in thread mode, as it's not supported + return + } + localScope.launch { + timelineController.paginate(direction = event.direction) + } + } + is TimelineEvents.OnScrollFinished -> { + if (isLive) { + if (event.firstIndex == 0) { + newEventState.value = NewEventState.None + } + Timber.tag(tag).d("## sendReadReceiptIfNeeded firstVisibleIndex: ${event.firstIndex}") + sessionCoroutineScope.sendReadReceiptIfNeeded( + firstVisibleIndex = event.firstIndex, + timelineItems = timelineItems, + lastReadReceiptId = lastReadReceiptId, + readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE, + ) + } else { + newEventState.value = NewEventState.None + } + } + is TimelineEvents.SelectPollAnswer -> sessionCoroutineScope.launch { + timelineController.invokeOnCurrentTimeline { + sendPollResponseAction.execute( + timeline = this, + pollStartId = event.pollStartId, + answerId = event.answerId + ) + } + } + is TimelineEvents.EndPoll -> sessionCoroutineScope.launch { + timelineController.invokeOnCurrentTimeline { + endPollAction.execute( + timeline = this, + pollStartId = event.pollStartId, + ) + } + } + is TimelineEvents.EditPoll -> { + navigator.navigateToEditPoll(event.pollStartId) + } + is TimelineEvents.FocusOnEvent -> sessionCoroutineScope.launch { + focusRequestState.value = FocusRequestState.Requested(event.eventId, event.debounce) + delay(event.debounce) + Timber.tag(tag).d("Started focus on ${event.eventId}") + focusOnEvent(event.eventId, focusRequestState) + }.start() + is TimelineEvents.OnFocusEventRender -> { + // If there was a pending 'notification tap opens timeline' transaction, finish it now we're focused in the required event + analyticsService.finishLongRunningTransaction(NotificationTapOpensTimeline) + + focusRequestState.value = focusRequestState.value.onFocusEventRender() + } + is TimelineEvents.ClearFocusRequestState -> { + focusRequestState.value = FocusRequestState.None + } + is TimelineEvents.JumpToLive -> { + timelineController.focusOnLive() + } + TimelineEvents.HideShieldDialog -> messageShield.value = null + is TimelineEvents.ShowShieldDialog -> messageShield.value = event.messageShield + is TimelineEvents.ComputeVerifiedUserSendFailure -> { + resolveVerifiedUserSendFailureState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(event.event)) + } + is TimelineEvents.NavigateToPredecessorOrSuccessorRoom -> { + // Navigate to the predecessor or successor room + val serverNames = calculateServerNamesForRoom(room) + navigator.navigateToRoom(event.roomId, null, serverNames) + } + is TimelineEvents.OpenThread -> { + navigator.navigateToThread( + threadRootId = event.threadRootEventId, + focusedEventId = event.focusedEvent, + ) + } + } + } + + LaunchedEffect(Unit) { + timelineItemsFactory.timelineItems + .onEach { newTimelineItems -> + timelineItemIndexer.process(newTimelineItems) + timelineItems = newTimelineItems + + analyticsService.run { + finishLongRunningTransaction(DisplayFirstTimelineItems) + finishLongRunningTransaction(OpenRoom) + } + } + .launchIn(this) + + combine(timelineController.timelineItems(), room.membersStateFlow) { items, membersState -> + val parent = analyticsService.getLongRunningTransaction(DisplayFirstTimelineItems) + val transaction = parent?.startChild("timelineItemsFactory.replaceWith", "Processing timeline items") + transaction?.setData("items", items.count()) + timelineItemsFactory.replaceWith( + timelineItems = items, + roomMembers = membersState.roomMembers().orEmpty() + ) + transaction?.finish() + items + } + .onEach(redactedVoiceMessageManager::onEachMatrixTimelineItem) + .flowOn(dispatchers.computation) + .launchIn(this) + } + + LaunchedEffect(timelineItems.size) { + computeNewItemState(timelineItems, prevMostRecentItemId, newEventState) + } + + LaunchedEffect(timelineItems.size, focusRequestState.value) { + val currentFocusRequestState = focusRequestState.value + if (currentFocusRequestState is FocusRequestState.Success && !currentFocusRequestState.rendered) { + val eventId = currentFocusRequestState.eventId + if (timelineItemIndexer.isKnown(eventId)) { + val index = timelineItemIndexer.indexOf(eventId) + focusRequestState.value = FocusRequestState.Success(eventId = eventId, index = index) + } else { + Timber.w("Unknown timeline item for focused item, can't render focus") + } + } + } + + val typingNotificationState = typingNotificationPresenter.present() + val roomCallState = roomCallStatePresenter.present() + val timelineRoomInfo by remember(typingNotificationState, roomCallState, roomInfo) { + derivedStateOf { + TimelineRoomInfo( + name = roomInfo.name, + isDm = roomInfo.isDm.orFalse(), + userHasPermissionToSendMessage = userHasPermissionToSendMessage, + userHasPermissionToSendReaction = userHasPermissionToSendReaction, + roomCallState = roomCallState, + pinnedEventIds = roomInfo.pinnedEventIds, + typingNotificationState = typingNotificationState, + predecessorRoom = room.predecessorRoom(), + ) + } + } + + LaunchedEffect(focusRequestState.value) { + Timber.tag(tag).d("Timeline: $timelineMode | focus state: ${focusRequestState.value}") + } + + return TimelineState( + timelineItems = timelineItems, + timelineMode = timelineMode, + timelineRoomInfo = timelineRoomInfo, + renderReadReceipts = renderReadReceipts, + newEventState = newEventState.value, + isLive = isLive, + focusRequestState = focusRequestState.value, + messageShield = messageShield.value, + resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, + displayThreadSummaries = displayThreadSummaries, + eventSink = ::handleEvent, + ) + } + + private suspend fun focusOnEvent( + eventId: EventId, + focusRequestState: MutableState, + ) { + if (timelineItemIndexer.isKnown(eventId)) { + val index = timelineItemIndexer.indexOf(eventId) + focusRequestState.value = FocusRequestState.Success(eventId = eventId, index = index) + return + } + + Timber.tag(tag).d("Event $eventId not found in the loaded timeline, loading a focused timeline") + focusRequestState.value = FocusRequestState.Loading(eventId = eventId) + + val threadId = room.threadRootIdForEvent(eventId).getOrElse { + focusRequestState.value = FocusRequestState.Failure(it) + return + } + + if (timelineController.mainTimelineMode() is Timeline.Mode.Thread && threadId == null) { + // We are in a thread timeline, and the event isn't part of a thread, we need to navigate back to the room + focusRequestState.value = FocusRequestState.None + navigator.navigateToRoom(room.roomId, eventId, calculateServerNamesForRoom(room)) + } else { + Timber.tag(tag).d("Focusing on event $eventId - thread $threadId") + timelineController.focusOnEvent(eventId, threadId) + .onSuccess { result -> + when (result) { + is EventFocusResult.FocusedOnLive -> { + focusRequestState.value = FocusRequestState.Success(eventId = eventId) + } + is EventFocusResult.IsInThread -> { + val currentThreadId = (timelineController.mainTimelineMode() as? Timeline.Mode.Thread)?.threadRootId + if (currentThreadId == result.threadId) { + // It's the same thread, we just focus on the event + focusRequestState.value = FocusRequestState.Success(eventId = eventId) + } else { + focusRequestState.value = FocusRequestState.Success(eventId = result.threadId.asEventId()) + // It's part of a thread we're not in, let's open it in another timeline + navigator.navigateToThread(result.threadId, eventId) + } + } + } + } + .onFailure { + focusRequestState.value = FocusRequestState.Failure(it) + } + } + } + + /** + * 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 None from this method, but need to be reset from somewhere else. + */ + private suspend fun computeNewItemState( + timelineItems: ImmutableList, + prevMostRecentItemId: MutableState, + newEventState: MutableState + ) = withContext(dispatchers.computation) { + // FromMe is prioritized over FromOther, so skip if we already have a FromMe + if (newEventState.value == NewEventState.FromMe) { + return@withContext + } + val newMostRecentItem = timelineItems.firstOrNull { + // Ignore typing item + (it as? TimelineItem.Virtual)?.model !is TimelineItemTypingNotificationModel + } + val prevMostRecentItemIdValue = prevMostRecentItemId.value + val newMostRecentItemId = newMostRecentItem?.identifier() + val hasNewEvent = prevMostRecentItemIdValue != null && + newMostRecentItem is TimelineItem.Event && + newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION && + newMostRecentItemId != prevMostRecentItemIdValue + + if (hasNewEvent) { + val newMostRecentEvent = newMostRecentItem + // Scroll to bottom if the new event is from me, even if sent from another device + val fromMe = newMostRecentEvent?.isMine == true + newEventState.value = if (fromMe) { + NewEventState.FromMe + } else { + NewEventState.FromOther + } + } + prevMostRecentItemId.value = newMostRecentItemId + } + + private fun CoroutineScope.sendReadReceiptIfNeeded( + firstVisibleIndex: Int, + timelineItems: ImmutableList, + lastReadReceiptId: MutableState, + readReceiptType: ReceiptType, + ) = launch(dispatchers.computation) { + // If we are at the bottom of timeline, we mark the room as read. + if (firstVisibleIndex == 0) { + timelineController.invokeOnCurrentTimeline { + markAsRead(receiptType = readReceiptType) + } + } else { + // 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 && eventId != lastReadReceiptId.value) { + lastReadReceiptId.value = eventId + timelineController.invokeOnCurrentTimeline { + sendReadReceipt(eventId = eventId, receiptType = readReceiptType) + } + } + } + } + + private fun getLastEventIdBeforeOrAt(index: Int, items: ImmutableList): EventId? { + for (i in index until items.count()) { + val item = items[i] + if (item is TimelineItem.Event) { + return item.eventId + } + } + return null + } +} + +private fun FocusRequestState.onFocusEventRender(): FocusRequestState { + return when (this) { + is FocusRequestState.Success -> copy(rendered = true) + else -> this + } +} + +// Workaround for not having the server names available, get possible server names from the user ids of the room members +private fun calculateServerNamesForRoom(room: JoinedRoom): List { + // If we have no room members, return right ahead + val serverNames = room.membersStateFlow.value.roomMembers() ?: return emptyList() + + // Otherwise get the three most common server names from the user ids of the room members + return serverNames + .mapNotNull { it.userId.domainName } + .groupingBy { it } + .eachCount() + .let { map -> + map.keys.sortedByDescending { map[it] } + } + .take(3) +} 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 0000000..3ec168f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline + +import androidx.compose.runtime.Immutable +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState +import io.element.android.features.messages.impl.timeline.model.NewEventState +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.typing.TypingNotificationState +import io.element.android.features.roomcall.api.RoomCallState +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield +import kotlinx.collections.immutable.ImmutableList +import kotlin.time.Duration + +data class TimelineState( + val timelineItems: ImmutableList, + val timelineRoomInfo: TimelineRoomInfo, + val timelineMode: Timeline.Mode, + val renderReadReceipts: Boolean, + val newEventState: NewEventState, + val isLive: Boolean, + val focusRequestState: FocusRequestState, + // If not null, info will be rendered in a dialog + val messageShield: MessageShield?, + val resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState, + val displayThreadSummaries: Boolean, + val eventSink: (TimelineEvents) -> Unit, +) { + private val lastTimelineEvent = timelineItems.firstOrNull { it is TimelineItem.Event } as? TimelineItem.Event + val hasAnyEvent = lastTimelineEvent != null + val focusedEventId = focusRequestState.eventId() + + fun isLastOutgoingMessage(uniqueId: UniqueId): Boolean { + return isLive && lastTimelineEvent != null && lastTimelineEvent.isMine && lastTimelineEvent.id == uniqueId + } +} + +@Immutable +sealed interface FocusRequestState { + data object None : FocusRequestState + data class Requested(val eventId: EventId, val debounce: Duration) : FocusRequestState + data class Loading(val eventId: EventId) : FocusRequestState + data class Success( + val eventId: EventId, + val index: Int = -1, + // This is used to know if the event has been rendered yet. + val rendered: Boolean = false, + ) : FocusRequestState { + val isIndexed + get() = index != -1 + } + + data class Failure(val throwable: Throwable) : FocusRequestState + + fun eventId(): EventId? { + return when (this) { + is Requested -> eventId + is Loading -> eventId + is Success -> eventId + else -> null + } + } +} + +data class TimelineRoomInfo( + val isDm: Boolean, + val name: String?, + val userHasPermissionToSendMessage: Boolean, + val userHasPermissionToSendReaction: Boolean, + val roomCallState: RoomCallState, + val pinnedEventIds: ImmutableList, + val typingNotificationState: TypingNotificationState, + val predecessorRoom: PredecessorRoom?, +) 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 0000000..b5f9e32 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline + +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState +import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData +import io.element.android.features.messages.impl.timeline.model.NewEventState +import io.element.android.features.messages.impl.timeline.model.ReadReceiptData +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.TimelineItemReadReceipts +import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo +import io.element.android.features.messages.impl.timeline.model.anAggregatedReaction +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.features.messages.impl.typing.TypingNotificationState +import io.element.android.features.messages.impl.typing.aTypingNotificationState +import io.element.android.features.roomcall.api.aStandByCallState +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.UniqueId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +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.MessageShield +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails +import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import java.util.UUID +import kotlin.random.Random + +fun aTimelineState( + timelineItems: ImmutableList = persistentListOf(), + timelineMode: Timeline.Mode = Timeline.Mode.Live, + renderReadReceipts: Boolean = false, + timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(), + focusedEventIndex: Int = -1, + isLive: Boolean = true, + messageShield: MessageShield? = null, + resolveVerifiedUserSendFailureState: ResolveVerifiedUserSendFailureState = aResolveVerifiedUserSendFailureState(), + displayThreadSummaries: Boolean = false, + eventSink: (TimelineEvents) -> Unit = {}, +): TimelineState { + val focusedEventId = timelineItems.filterIsInstance().getOrNull(focusedEventIndex)?.eventId + val focusRequestState = if (focusedEventId != null) { + FocusRequestState.Success(focusedEventId, focusedEventIndex) + } else { + FocusRequestState.None + } + return TimelineState( + timelineItems = timelineItems, + timelineMode = timelineMode, + timelineRoomInfo = timelineRoomInfo, + renderReadReceipts = renderReadReceipts, + newEventState = NewEventState.None, + isLive = isLive, + focusRequestState = focusRequestState, + messageShield = messageShield, + resolveVerifiedUserSendFailureState = resolveVerifiedUserSendFailureState, + displayThreadSummaries = displayThreadSummaries, + eventSink = eventSink, + ) +} + +internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList { + 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.Failed.Unknown("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.Failed.Unknown("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( + id = UniqueId(UUID.randomUUID().toString()), + model = aTimelineItemDaySeparatorModel("Today"), + ) +} + +internal fun aTimelineItemEvent( + eventId: EventId = EventId("\$" + Random.nextInt().toString()), + transactionId: TransactionId? = null, + isMine: Boolean = false, + isEditable: Boolean = false, + canBeRepliedTo: Boolean = false, + senderDisplayName: String = "Sender", + displayNameAmbiguous: Boolean = false, + content: TimelineItemEventContent = aTimelineItemTextContent(), + groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, + sendState: LocalEventSendState? = null, + inReplyTo: InReplyToDetails? = null, + threadInfo: TimelineItemThreadInfo? = null, + debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(), + timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(), + readReceiptState: TimelineItemReadReceipts = aTimelineItemReadReceipts(), + messageShield: MessageShield? = null, +): TimelineItem.Event { + return TimelineItem.Event( + id = UniqueId(UUID.randomUUID().toString()), + eventId = eventId, + transactionId = transactionId, + senderId = UserId("@senderId:domain"), + senderAvatar = AvatarData("@senderId:domain", "sender", size = AvatarSize.TimelineSender), + content = content, + reactionsState = timelineItemReactions, + readReceiptState = readReceiptState, + sentTime = "12:34", + isMine = isMine, + isEditable = isEditable, + canBeRepliedTo = canBeRepliedTo, + senderProfile = aProfileTimelineDetailsReady( + displayName = senderDisplayName, + displayNameAmbiguous = displayNameAmbiguous, + ), + groupPosition = groupPosition, + localSendState = sendState, + inReplyTo = inReplyTo, + threadInfo = threadInfo, + origin = null, + timelineItemDebugInfoProvider = { debugInfo }, + messageShieldProvider = { messageShield }, + sendHandleProvider = { 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( + anAggregatedReaction( + key = key, + count = index + 1, + isHighlighted = isHighlighted + ) + ) + } + }.toImmutableList() + ) +} + +internal fun aTimelineItemDebugInfo( + model: String = "Rust(Model())", + originalJson: String? = null, + latestEditedJson: String? = null, +) = TimelineItemDebugInfo( + model, + originalJson, + latestEditedJson +) + +internal fun aTimelineItemReadReceipts( + receipts: List = emptyList(), +): TimelineItemReadReceipts { + return TimelineItemReadReceipts( + receipts = receipts.toImmutableList(), + ) +} + +internal fun aGroupedEvents( + id: UniqueId = UniqueId("0"), + withReadReceipts: Boolean = false, +): TimelineItem.GroupedEvents { + val event1 = aTimelineItemEvent( + isMine = true, + content = aTimelineItemStateEventContent(), + groupPosition = TimelineItemGroupPosition.None, + readReceiptState = TimelineItemReadReceipts( + receipts = (if (withReadReceipts) listOf(aReadReceiptData(0)) else emptyList()).toImmutableList() + ), + ) + val event2 = aTimelineItemEvent( + isMine = true, + content = aTimelineItemStateEventContent(body = "Another state event"), + groupPosition = TimelineItemGroupPosition.None, + readReceiptState = TimelineItemReadReceipts( + receipts = (if (withReadReceipts) listOf(aReadReceiptData(1)) else emptyList()).toImmutableList() + ), + ) + val events = listOf(event1, event2) + return TimelineItem.GroupedEvents( + id = id, + events = events.toImmutableList(), + aggregatedReadReceipts = events.flatMap { it.readReceiptState.receipts }.toImmutableList(), + ) +} + +internal fun aTimelineRoomInfo( + name: String = "Room name", + isDm: Boolean = false, + userHasPermissionToSendMessage: Boolean = true, + pinnedEventIds: List = emptyList(), + typingNotificationState: TypingNotificationState = aTypingNotificationState(), + predecessorRoom: PredecessorRoom? = null, +) = TimelineRoomInfo( + isDm = isDm, + name = name, + userHasPermissionToSendMessage = userHasPermissionToSendMessage, + userHasPermissionToSendReaction = true, + roomCallState = aStandByCallState(), + pinnedEventIds = pinnedEventIds.toImmutableList(), + typingNotificationState = typingNotificationState, + predecessorRoom = predecessorRoom, +) 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 0000000..b3f69f0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -0,0 +1,419 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline + +import android.view.HapticFeedbackConstants +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +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.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.material3.FloatingActionButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureView +import io.element.android.features.messages.impl.timeline.components.TimelineItemRow +import io.element.android.features.messages.impl.timeline.components.toText +import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories +import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories +import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView +import io.element.android.features.messages.impl.timeline.model.NewEventState +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.protection.TimelineProtectionState +import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState +import io.element.android.libraries.androidutils.system.copyToClipboard +import io.element.android.libraries.designsystem.components.dialogs.AlertDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.FloatingActionButton +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.utils.animateScrollToItemCenter +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.libraries.ui.utils.time.isTalkbackActive +import io.element.android.wysiwyg.link.Link +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.launch +import timber.log.Timber +import kotlin.time.Duration.Companion.milliseconds + +@Composable +fun TimelineView( + state: TimelineState, + timelineProtectionState: TimelineProtectionState, + onUserDataClick: (MatrixUser) -> Unit, + onLinkClick: (Link) -> Unit, + onContentClick: (TimelineItem.Event) -> Unit, + onMessageLongClick: (TimelineItem.Event) -> Unit, + onSwipeToReply: (TimelineItem.Event) -> Unit, + onReactionClick: (emoji: String, TimelineItem.Event) -> Unit, + onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit, + onMoreReactionsClick: (TimelineItem.Event) -> Unit, + onReadReceiptClick: (TimelineItem.Event) -> Unit, + onJoinCallClick: () -> Unit, + modifier: Modifier = Modifier, + lazyListState: LazyListState = rememberLazyListState(), + forceJumpToBottomVisibility: Boolean = false, + nestedScrollConnection: NestedScrollConnection = rememberNestedScrollInteropConnection(), +) { + fun clearFocusRequestState() { + state.eventSink(TimelineEvents.ClearFocusRequestState) + } + + fun onScrollFinishAt(firstVisibleIndex: Int) { + state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex)) + } + + fun onFocusEventRender() { + state.eventSink(TimelineEvents.OnFocusEventRender) + } + + fun onJumpToLive() { + state.eventSink(TimelineEvents.JumpToLive) + } + + val context = LocalContext.current + val view = LocalView.current + // Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version + val useReverseLayout = !isTalkbackActive() + + fun inReplyToClick(eventId: EventId) { + state.eventSink(TimelineEvents.FocusOnEvent(eventId)) + } + + fun onLinkLongClick(link: Link) { + view.performHapticFeedback( + HapticFeedbackConstants.LONG_PRESS + ) + context.copyToClipboard( + link.url, + context.getString(CommonStrings.common_copied_to_clipboard) + ) + } + + fun prefetchMoreItems() { + state.eventSink(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS)) + } + + // Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms + AnimatedVisibility(visible = true, enter = fadeIn()) { + Box(modifier) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(nestedScrollConnection) + .testTag(TestTags.timeline), + state = lazyListState, + reverseLayout = useReverseLayout, + contentPadding = PaddingValues(top = 64.dp, bottom = 8.dp), + ) { + items( + items = state.timelineItems, + contentType = { timelineItem -> timelineItem.contentType() }, + key = { timelineItem -> timelineItem.identifier() }, + ) { timelineItem -> + TimelineItemRow( + timelineItem = timelineItem, + timelineMode = state.timelineMode, + timelineRoomInfo = state.timelineRoomInfo, + timelineProtectionState = timelineProtectionState, + renderReadReceipts = state.renderReadReceipts, + isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()), + focusedEventId = state.focusedEventId, + displayThreadSummaries = state.displayThreadSummaries, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onLinkLongClick = ::onLinkLongClick, + onContentClick = onContentClick, + onLongClick = onMessageLongClick, + inReplyToClick = ::inReplyToClick, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, + onReadReceiptClick = onReadReceiptClick, + onSwipeToReply = onSwipeToReply, + onJoinCallClick = onJoinCallClick, + eventSink = state.eventSink, + ) + } + } + + FocusRequestStateView( + focusRequestState = state.focusRequestState, + onClearFocusRequestState = ::clearFocusRequestState + ) + + TimelinePrefetchingHelper( + lazyListState = lazyListState, + prefetch = ::prefetchMoreItems + ) + + TimelineScrollHelper( + hasAnyEvent = state.hasAnyEvent, + lazyListState = lazyListState, + forceJumpToBottomVisibility = forceJumpToBottomVisibility, + newEventState = state.newEventState, + isLive = state.isLive, + focusRequestState = state.focusRequestState, + onScrollFinishAt = ::onScrollFinishAt, + onJumpToLive = ::onJumpToLive, + onFocusEventRender = ::onFocusEventRender, + ) + } + } + + ResolveVerifiedUserSendFailureView(state = state.resolveVerifiedUserSendFailureState) + + MessageShieldDialog(state) +} + +@Composable +private fun MessageShieldDialog(state: TimelineState) { + val messageShield = state.messageShield ?: return + AlertDialog( + content = messageShield.toText(), + onDismiss = { state.eventSink.invoke(TimelineEvents.HideShieldDialog) }, + ) +} + +@Composable +private fun TimelinePrefetchingHelper( + lazyListState: LazyListState, + prefetch: () -> Unit, +) { + val latestPrefetch by rememberUpdatedState(prefetch) + + LaunchedEffect(Unit) { + // We're using snapshot flows for these because using `LaunchedEffect` with `derivedState` doesn't seem to be responsive enough + val firstVisibleItemIndexFlow = snapshotFlow { lazyListState.firstVisibleItemIndex } + val layoutInfoFlow = snapshotFlow { lazyListState.layoutInfo } + val isScrollingFlow = snapshotFlow { lazyListState.isScrollInProgress } + // This value changes too frequently, so we debounce it to avoid unnecessary prefetching. It's the equivalent of a conditional 'throttleLatest' + .conflate() + .transform { isScrolling -> + emit(isScrolling) + if (isScrolling) delay(100.milliseconds) + } + + val isCloseToStartOfLoadedTimelineFlow = combine(layoutInfoFlow, firstVisibleItemIndexFlow) { layoutInfo, firstVisibleItemIndex -> + firstVisibleItemIndex + layoutInfo.visibleItemsInfo.size >= layoutInfo.totalItemsCount - 40 + } + + combine( + isCloseToStartOfLoadedTimelineFlow.distinctUntilChanged(), + isScrollingFlow.distinctUntilChanged(), + ) { needsPrefetch, isScrolling -> + needsPrefetch && isScrolling + } + .distinctUntilChanged() + .collectLatest { needsPrefetch -> + if (needsPrefetch) { + Timber.d("Prefetching pagination with ${lazyListState.layoutInfo.totalItemsCount} items") + latestPrefetch() + } + } + } +} + +@Composable +private fun BoxScope.TimelineScrollHelper( + hasAnyEvent: Boolean, + lazyListState: LazyListState, + newEventState: NewEventState, + isLive: Boolean, + forceJumpToBottomVisibility: Boolean, + focusRequestState: FocusRequestState, + onScrollFinishAt: (Int) -> Unit, + onJumpToLive: () -> Unit, + onFocusEventRender: () -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } } + val canAutoScroll by remember { + derivedStateOf { + lazyListState.firstVisibleItemIndex < 3 && isLive + } + } + var jumpToLiveHandled by remember { mutableStateOf(true) } + + /** + * @param force If true, scroll to the bottom even if the user is already seeing the most recent item. + * This fixes the issue where the user is seeing typing notification and so the read receipt is not sent + * when a new message comes in. + */ + fun scrollToBottom(force: Boolean) { + coroutineScope.launch { + if (lazyListState.firstVisibleItemIndex > 10) { + lazyListState.scrollToItem(0) + } else if (force || lazyListState.firstVisibleItemIndex != 0) { + lazyListState.animateScrollToItem(0) + } + } + } + + fun jumpToBottom() { + if (isLive) { + scrollToBottom(force = false) + } else { + jumpToLiveHandled = false + onJumpToLive() + } + } + + LaunchedEffect(jumpToLiveHandled, isLive) { + if (!jumpToLiveHandled && isLive) { + lazyListState.scrollToItem(0) + jumpToLiveHandled = true + } + } + + val latestOnFocusEventRender by rememberUpdatedState(onFocusEventRender) + LaunchedEffect(focusRequestState) { + if (focusRequestState is FocusRequestState.Success && focusRequestState.isIndexed && !focusRequestState.rendered) { + lazyListState.animateScrollToItemCenter(focusRequestState.index) + latestOnFocusEventRender() + } + } + + LaunchedEffect(canAutoScroll, newEventState) { + val shouldScrollToBottom = isScrollFinished && + (canAutoScroll && newEventState == NewEventState.FromOther || newEventState == NewEventState.FromMe) + if (shouldScrollToBottom) { + scrollToBottom(force = true) + } + } + + val latestOnScrollFinishAt by rememberUpdatedState(onScrollFinishAt) + LaunchedEffect(isScrollFinished, hasAnyEvent) { + if (isScrollFinished && hasAnyEvent) { + // Notify the parent composable about the first visible item index when scrolling finishes + latestOnScrollFinishAt(lazyListState.firstVisibleItemIndex) + } + } + + JumpToBottomButton( + // Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered + isVisible = !canAutoScroll || forceJumpToBottomVisibility || !isLive, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 24.dp, bottom = 12.dp), + onClick = { jumpToBottom() }, + ) +} + +@Composable +private fun JumpToBottomButton( + isVisible: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + modifier = modifier, + visible = isVisible, + 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) + .rotate(90f), + imageVector = CompoundIcons.ArrowRight(), + contentDescription = stringResource(id = CommonStrings.a11y_jump_to_bottom) + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineViewPreview( + @PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent +) = ElementPreview { + val timelineItems = aTimelineItemList(content) + val timelineEvents = timelineItems.filterIsInstance() + val lastEventIdFromMe = timelineEvents.firstOrNull { it.isMine }?.eventId + val lastEventIdFromOther = timelineEvents.firstOrNull { !it.isMine }?.eventId + CompositionLocalProvider( + LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(), + ) { + TimelineView( + state = aTimelineState( + timelineItems = timelineItems, + timelineRoomInfo = aTimelineRoomInfo( + pinnedEventIds = listOfNotNull(lastEventIdFromMe, lastEventIdFromOther) + ), + focusedEventIndex = 0, + ), + timelineProtectionState = aTimelineProtectionState(), + onUserDataClick = {}, + onLinkClick = {}, + onContentClick = {}, + onMessageLongClick = {}, + onSwipeToReply = {}, + onReactionClick = { _, _ -> }, + onReactionLongClick = { _, _ -> }, + onMoreReactionsClick = {}, + onReadReceiptClick = {}, + onJoinCallClick = {}, + forceJumpToBottomVisibility = true, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt new file mode 100644 index 0000000..5f9c3d0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewMessageShieldPreview.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import io.element.android.features.messages.impl.timeline.components.aCriticalShield +import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories +import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories +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.features.messages.impl.timeline.protection.aTimelineProtectionState +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import kotlinx.collections.immutable.toImmutableList + +@PreviewsDayNight +@Composable +internal fun TimelineViewMessageShieldPreview() = ElementPreview { + val timelineItems = aTimelineItemList(aTimelineItemTextContent()) + // For consistency, ensure that there is a message in the timeline (the last one) with an error. + val messageShield = aCriticalShield() + val items = listOf( + (timelineItems.first() as TimelineItem.Event).copy( + messageShieldProvider = { messageShield }, + ) + ) + timelineItems.drop(1) + CompositionLocalProvider( + LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(), + ) { + TimelineView( + state = aTimelineState( + timelineItems = items.toImmutableList(), + messageShield = messageShield, + ), + timelineProtectionState = aTimelineProtectionState(), + onUserDataClick = {}, + onLinkClick = {}, + onContentClick = {}, + onMessageLongClick = {}, + onSwipeToReply = {}, + onReactionClick = { _, _ -> }, + onReactionLongClick = { _, _ -> }, + onMoreReactionsClick = {}, + onReadReceiptClick = {}, + onJoinCallClick = {}, + forceJumpToBottomVisibility = true, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/a11y/Reactions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/a11y/Reactions.kt new file mode 100644 index 0000000..cd71250 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/a11y/Reactions.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.a11y + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import io.element.android.features.messages.impl.R +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +@ReadOnlyComposable +fun a11yReactionAction( + emoji: String, + userAlreadyReacted: Boolean, +): String { + return if (userAlreadyReacted) { + stringResource(id = CommonStrings.a11y_remove_reaction_with, emoji) + } else { + stringResource(id = CommonStrings.a11y_react_with, emoji) + } +} + +@Composable +@ReadOnlyComposable +fun a11yReactionDetails( + emoji: String, + userAlreadyReacted: Boolean, + reactionCount: Int, +): String { + val reaction = if (emoji.startsWith("mxc://")) { + stringResource(CommonStrings.common_an_image) + } else { + emoji + } + return if (userAlreadyReacted) { + if (reactionCount == 1) { + stringResource(R.string.screen_room_timeline_reaction_you_a11y, reaction) + } else { + pluralStringResource( + R.plurals.screen_room_timeline_reaction_including_you_a11y, + reactionCount - 1, + reactionCount - 1, + reaction, + ) + } + } else { + pluralStringResource( + R.plurals.screen_room_timeline_reaction_a11y, + reactionCount, + reactionCount, + reaction, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt new file mode 100644 index 0000000..6be00fc --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.runtime.Composable +import io.element.android.features.messages.impl.timeline.TimelineRoomInfo +import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState +import io.element.android.libraries.matrix.api.timeline.Timeline + +// For previews +@Composable +internal fun ATimelineItemEventRow( + event: TimelineItem.Event, + timelineMode: Timeline.Mode = Timeline.Mode.Live, + timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(), + renderReadReceipts: Boolean = false, + isLastOutgoingMessage: Boolean = false, + timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(), + displayThreadSummaries: Boolean = false, +) = TimelineItemEventRow( + event = event, + timelineMode = timelineMode, + timelineRoomInfo = timelineRoomInfo, + renderReadReceipts = renderReadReceipts, + timelineProtectionState = timelineProtectionState, + isLastOutgoingMessage = isLastOutgoingMessage, + displayThreadSummaries = displayThreadSummaries, + onEventClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onUserDataClick = {}, + inReplyToClick = {}, + onReactionClick = { _, _ -> }, + onReactionLongClick = { _, _ -> }, + onMoreReactionsClick = {}, + onReadReceiptClick = {}, + onSwipeToReply = {}, + eventSink = {}, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/CallMenuItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/CallMenuItem.kt new file mode 100644 index 0000000..73e6c18 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/CallMenuItem.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.runtime.Composable +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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.roomcall.api.RoomCallState +import io.element.android.features.roomcall.api.RoomCallStateProvider +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.Text +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun CallMenuItem( + roomCallState: RoomCallState, + onJoinCallClick: () -> Unit, + modifier: Modifier = Modifier, +) { + when (roomCallState) { + RoomCallState.Unavailable -> { + Box(modifier) + } + is RoomCallState.StandBy -> { + StandByCallMenuItem( + roomCallState = roomCallState, + onJoinCallClick = onJoinCallClick, + modifier = modifier, + ) + } + is RoomCallState.OnGoing -> { + OnGoingCallMenuItem( + roomCallState = roomCallState, + onJoinCallClick = onJoinCallClick, + modifier = modifier, + ) + } + } +} + +@Composable +private fun StandByCallMenuItem( + roomCallState: RoomCallState.StandBy, + onJoinCallClick: () -> Unit, + modifier: Modifier = Modifier, +) { + IconButton( + modifier = modifier, + onClick = onJoinCallClick, + enabled = roomCallState.canStartCall, + ) { + Icon( + imageVector = CompoundIcons.VideoCallSolid(), + contentDescription = stringResource(CommonStrings.a11y_start_call), + ) + } +} + +@Composable +private fun OnGoingCallMenuItem( + roomCallState: RoomCallState.OnGoing, + onJoinCallClick: () -> Unit, + modifier: Modifier = Modifier, +) { + if (!roomCallState.isUserLocallyInTheCall) { + Button( + onClick = onJoinCallClick, + colors = ButtonDefaults.buttonColors( + contentColor = ElementTheme.colors.bgCanvasDefault, + containerColor = ElementTheme.colors.iconAccentTertiary + ), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp), + modifier = modifier.heightIn(min = 36.dp), + enabled = roomCallState.canJoinCall, + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.VideoCallSolid(), + contentDescription = null + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(CommonStrings.action_join), + style = ElementTheme.typography.fontBodyMdMedium + ) + Spacer(Modifier.width(8.dp)) + } + } else { + // Else user is already in the call, hide the button. + Box(modifier) + } +} + +@PreviewsDayNight +@Composable +internal fun CallMenuItemPreview( + @PreviewParameter(RoomCallStateProvider::class) roomCallState: RoomCallState +) = ElementPreview { + CallMenuItem( + roomCallState = roomCallState, + onJoinCallClick = {} + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ContentPadding.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ContentPadding.kt new file mode 100644 index 0000000..f7c3627 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ContentPadding.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +enum class ContentPadding { + Textual, + Media, + CaptionedMedia +} 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 0000000..8923b0c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +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.material3.ripple +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.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +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.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.toDp +import io.element.android.libraries.designsystem.text.toPx +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.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.utils.time.isTalkbackActive + +private val BUBBLE_RADIUS = 12.dp +private val avatarRadius = AvatarSize.TimelineSender.dp / 2 + +private val MIN_BUBBLE_WIDTH = 80.dp + +@Composable +fun MessageEventBubble( + state: BubbleState, + interactionSource: MutableInteractionSource, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit = {}, +) { + val clickableModifier = if (isTalkbackActive()) { + Modifier + } else { + Modifier + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + indication = ripple(), + interactionSource = interactionSource + ) + .onKeyboardContextMenuAction(onLongClick) + } + + // Ignore state.isHighlighted for now, we need a design decision on it. + val backgroundBubbleColor = MessageEventBubbleDefaults.backgroundBubbleColor(state.isMine) + val bubbleShape = remember(state) { MessageEventBubbleDefaults.shape(state.cutTopStart, state.groupPosition, state.isMine) } + val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx() + val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx() + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + BoxWithConstraints( + modifier = modifier + .graphicsLayer { + shape = bubbleShape + clip = true + compositingStrategy = CompositingStrategy.Offscreen + } + .drawWithContent { + drawRect(backgroundBubbleColor) + drawContent() + if (state.cutTopStart) { + drawCircle( + color = Color.Black, + center = Offset( + x = if (isRtl) size.width else 0f, + y = yOffsetPx, + ), + radius = radiusPx, + blendMode = BlendMode.Clear, + ) + } + }, + // 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 + ) { + Box( + modifier = Modifier + .testTag(TestTags.messageBubble) + .widthIn( + min = MIN_BUBBLE_WIDTH, + max = (constraints.maxWidth * MessageEventBubbleDefaults.BUBBLE_WIDTH_RATIO) + .toInt() + .toDp() + ) + .then(clickableModifier), + content = content, + ) + } +} + +object MessageEventBubbleDefaults { + fun shape(cutTopStart: Boolean, groupPosition: TimelineItemGroupPosition, isMine: Boolean): Shape { + val topLeftCorner = if (cutTopStart) 0.dp else BUBBLE_RADIUS + return when (groupPosition) { + TimelineItemGroupPosition.First -> if (isMine) { + RoundedCornerShape(BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS) + } else { + RoundedCornerShape(topLeftCorner, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp) + } + TimelineItemGroupPosition.Middle -> if (isMine) { + RoundedCornerShape(BUBBLE_RADIUS, 0.dp, 0.dp, BUBBLE_RADIUS) + } else { + RoundedCornerShape(0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp) + } + TimelineItemGroupPosition.Last -> if (isMine) { + RoundedCornerShape(BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS) + } else { + RoundedCornerShape(0.dp, BUBBLE_RADIUS, BUBBLE_RADIUS, BUBBLE_RADIUS) + } + TimelineItemGroupPosition.None -> + RoundedCornerShape( + topLeftCorner, + BUBBLE_RADIUS, + BUBBLE_RADIUS, + BUBBLE_RADIUS + ) + } + } + + @Composable + fun backgroundBubbleColor(isMine: Boolean): Color { + return if (isMine) { + ElementTheme.colors.messageFromMeBackground + } else { + ElementTheme.colors.messageFromOtherBackground + } + } + + // Design says: The maximum width of a bubble is still 3/4 of the screen width. But try with 78% now. + const val BUBBLE_WIDTH_RATIO = 0.78f +} + +@PreviewsDayNight +@Composable +internal fun MessageEventBubblePreview(@PreviewParameter(BubbleStateProvider::class) state: BubbleState) = ElementPreview { + // 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 = remember { MutableInteractionSource() }, + onClick = {}, + onLongClick = {}, + ) { + // 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} isMine:${state.isMine.to01()}", + style = ElementTheme.typography.fontBodyXsRegular, + ) + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageShieldView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageShieldView.kt new file mode 100644 index 0000000..401fd7e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageShieldView.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +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.padding +import androidx.compose.foundation.layout.size +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.res.stringResource +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.MessageShield +import io.element.android.libraries.matrix.api.timeline.item.event.isCritical +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun MessageShieldView( + shield: MessageShield, + modifier: Modifier = Modifier +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + Icon( + imageVector = shield.toIcon(), + contentDescription = null, + modifier = Modifier.size(15.dp), + tint = shield.toIconColor(), + ) + Spacer(modifier = Modifier.size(4.dp)) + Text( + text = shield.toText(), + style = ElementTheme.typography.fontBodySmMedium, + color = shield.toTextColor() + ) + } +} + +@Composable +internal fun MessageShield.toIconColor(): Color { + return when (isCritical) { + true -> ElementTheme.colors.iconCriticalPrimary + false -> ElementTheme.colors.iconSecondary + } +} + +@Composable +private fun MessageShield.toTextColor(): Color { + return when (isCritical) { + true -> ElementTheme.colors.textCriticalPrimary + false -> ElementTheme.colors.textSecondary + } +} + +@Composable +internal fun MessageShield.toText(): String { + return stringResource( + id = when (this) { + is MessageShield.AuthenticityNotGuaranteed -> CommonStrings.event_shield_reason_authenticity_not_guaranteed + is MessageShield.UnknownDevice -> CommonStrings.event_shield_reason_unknown_device + is MessageShield.UnsignedDevice -> CommonStrings.event_shield_reason_unsigned_device + is MessageShield.UnverifiedIdentity -> CommonStrings.event_shield_reason_unverified_identity + is MessageShield.SentInClear -> CommonStrings.event_shield_reason_sent_in_clear + is MessageShield.VerificationViolation -> CommonStrings.event_shield_reason_previously_verified + is MessageShield.MismatchedSender -> CommonStrings.event_shield_mismatched_sender + } + ) +} + +@Composable +internal fun MessageShield.toIcon(): ImageVector { + return when (this) { + is MessageShield.AuthenticityNotGuaranteed -> CompoundIcons.Info() + is MessageShield.UnknownDevice, + is MessageShield.UnsignedDevice, + is MessageShield.UnverifiedIdentity, + is MessageShield.VerificationViolation, + is MessageShield.MismatchedSender -> CompoundIcons.HelpSolid() + is MessageShield.SentInClear -> CompoundIcons.LockOff() + } +} + +@PreviewsDayNight +@Composable +internal fun MessageShieldViewPreview() { + ElementPreview { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + MessageShieldView( + shield = MessageShield.UnknownDevice(true) + ) + MessageShieldView( + shield = MessageShield.UnverifiedIdentity(true) + ) + MessageShieldView( + shield = MessageShield.AuthenticityNotGuaranteed(false) + ) + MessageShieldView( + shield = MessageShield.UnsignedDevice(false) + ) + MessageShieldView( + shield = MessageShield.SentInClear(false) + ) + MessageShieldView( + shield = MessageShield.VerificationViolation(false) + ) + MessageShieldView( + shield = MessageShield.MismatchedSender(false) + ) + } + } +} 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 0000000..3c46437 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageStateEventContainer.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +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.unit.dp +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.ui.strings.CommonStrings + +private val CORNER_RADIUS = 8.dp + +@Composable +fun MessageStateEventContainer( + interactionSource: MutableInteractionSource, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit = {}, +) { + val backgroundColor = Color.Transparent + val shape = RoundedCornerShape(CORNER_RADIUS) + Surface( + modifier = modifier + .widthIn(min = 80.dp) + .clip(shape) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), + indication = ripple(), + interactionSource = interactionSource + ) + .onKeyboardContextMenuAction(onLongClick), + color = backgroundColor, + shape = shape, + content = content + ) +} + +@PreviewsDayNight +@Composable +internal fun MessageStateEventContainerPreview() = ElementPreview { + MessageStateEventContainer( + interactionSource = remember { MutableInteractionSource() }, + onClick = {}, + onLongClick = {}, + ) +} 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 0000000..dc8e171 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.height +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.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +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.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.messages.impl.R +import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction +import io.element.android.features.messages.impl.timeline.a11y.a11yReactionDetails +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.icons.CompoundDrawables +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +@Suppress("ModifierClickableOrder") // This is needed to display the right ripple shape +fun MessagesReactionButton( + onClick: () -> Unit, + onLongClick: () -> 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 + } + + val a11yText = when (content) { + is MessagesReactionsButtonContent.Icon -> stringResource(id = R.string.screen_room_timeline_add_reaction) + is MessagesReactionsButtonContent.Text -> content.text + is MessagesReactionsButtonContent.Reaction -> { + a11yReactionDetails( + emoji = content.reaction.key, + userAlreadyReacted = content.isHighlighted, + reactionCount = content.reaction.count, + ) + } + } + + Surface( + modifier = modifier + .background(Color.Transparent) + // Outer border, same colour as background + .border( + BorderStroke(2.dp, ElementTheme.colors.bgCanvasDefault), + 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))) + .combinedClickable( + onClick = onClick, + onClickLabel = (content as? MessagesReactionsButtonContent.Reaction)?.let { + a11yReactionAction( + emoji = content.reaction.key, + userAlreadyReacted = content.isHighlighted + ) + }, + onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), + onLongClick = onLongClick + ) + .onKeyboardContextMenuAction(onLongClick) + // 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) + .clearAndSetSemantics { + contentDescription = a11yText + }, + color = buttonColor + ) { + when (content) { + is MessagesReactionsButtonContent.Icon -> IconContent(resourceId = content.resourceId) + is MessagesReactionsButtonContent.Text -> TextContent(text = content.text) + is MessagesReactionsButtonContent.Reaction -> ReactionContent(reaction = content.reaction) + } + } +} + +@Immutable +sealed interface MessagesReactionsButtonContent { + data class Text(val text: String) : MessagesReactionsButtonContent + data class Icon(@DrawableRes val resourceId: Int) : MessagesReactionsButtonContent + + data class Reaction(val reaction: AggregatedReaction) : MessagesReactionsButtonContent + + val isHighlighted get() = this is Reaction && reaction.isHighlighted +} + +internal val REACTION_EMOJI_LINE_HEIGHT = 20.sp +internal const val REACTION_IMAGE_ASPECT_RATIO = 1.0f +private val ADD_EMOJI_SIZE = 16.dp + +@Composable +private fun TextContent( + text: String, +) = Text( + modifier = Modifier + .height(REACTION_EMOJI_LINE_HEIGHT.toDp()), + text = text, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textPrimary +) + +@Composable +private fun IconContent( + @DrawableRes resourceId: Int, +) = Icon( + resourceId = resourceId, + contentDescription = stringResource(id = R.string.screen_room_timeline_add_reaction), + tint = ElementTheme.colors.iconSecondary, + modifier = Modifier + .size(ADD_EMOJI_SIZE) +) + +@Composable +private fun ReactionContent( + reaction: AggregatedReaction, +) = Row( + verticalAlignment = Alignment.CenterVertically, +) { + // Check if this is a custom reaction (MSC4027) + if (reaction.key.startsWith("mxc://")) { + AsyncImage( + modifier = Modifier + .heightIn(min = REACTION_EMOJI_LINE_HEIGHT.toDp(), max = REACTION_EMOJI_LINE_HEIGHT.toDp()) + .aspectRatio(REACTION_IMAGE_ASPECT_RATIO, false), + model = MediaRequestData(MediaSource(reaction.key), MediaRequestData.Kind.Content), + contentDescription = null + ) + } else { + Text( + text = reaction.displayKey, + style = ElementTheme.typography.fontBodyMdRegular.copy( + fontSize = 15.sp, + lineHeight = REACTION_EMOJI_LINE_HEIGHT, + ), + ) + } + if (reaction.count > 1) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = reaction.count.toString(), + color = if (reaction.isHighlighted) ElementTheme.colors.textPrimary else ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun MessagesReactionButtonPreview(@PreviewParameter(AggregatedReactionProvider::class) reaction: AggregatedReaction) = ElementPreview { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Reaction(reaction), + onClick = {}, + onLongClick = {} + ) +} + +@PreviewsDayNight +@Composable +internal fun MessagesReactionButtonAddPreview() = ElementPreview { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Icon(CompoundDrawables.ic_compound_reaction_add), + onClick = {}, + onLongClick = {} + ) +} + +@PreviewsDayNight +@Composable +internal fun MessagesReactionButtonExtraPreview() = ElementPreview { + Row { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Text("12 more"), + onClick = {}, + onLongClick = {} + ) + MessagesReactionButton( + content = MessagesReactionsButtonContent.Reaction( + aTimelineItemReactions().reactions.first().copy( + key = "A very long reaction with many characters that should be truncated" + ) + ), + onClick = {}, + onLongClick = {} + ) + } +} 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 0000000..56fa2d9 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ReplySwipeIndicator.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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, + imageVector = CompoundIcons.Reply(), + ) +} + +@PreviewsDayNight +@Composable +internal fun ReplySwipeIndicatorPreview() = ElementPreview { + 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 0000000..4036959 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.PaddingValues +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.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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.timeline.TimelineEvents +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.isEdited +import io.element.android.features.messages.impl.timeline.model.event.isRedacted +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.matrix.api.timeline.item.event.isCritical +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun TimelineEventTimestampView( + event: TimelineItem.Event, + eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, + modifier: Modifier = Modifier, +) { + val formattedTime = event.sentTime + val hasError = event.failedToSend + val hasEncryptionCritical = event.messageShield?.isCritical.orFalse() + val isMessageEdited = event.content.isEdited() + val isMessageRedacted = event.content.isRedacted() + val tint = if (hasError || hasEncryptionCritical && !isMessageRedacted) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textSecondary + Row( + modifier = Modifier + .padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing)) + .then(modifier), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isMessageEdited) { + Text( + stringResource(CommonStrings.common_edited_suffix), + style = ElementTheme.typography.fontBodyXsRegular, + color = tint, + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + formattedTime, + style = ElementTheme.typography.fontBodyXsRegular, + color = tint, + ) + if (hasError) { + val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser + Spacer(modifier = Modifier.width(2.dp)) + Icon( + imageVector = CompoundIcons.ErrorSolid(), + contentDescription = stringResource(id = CommonStrings.common_sending_failed), + tint = tint, + modifier = Modifier + .size(15.dp, 18.dp) + .clickable( + enabled = isVerifiedUserSendFailure, + onClickLabel = stringResource(CommonStrings.action_open_context_menu), + ) { + eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event)) + } + ) + } + + if (!isMessageRedacted) { + event.messageShield?.let { shield -> + Spacer(modifier = Modifier.width(2.dp)) + Icon( + imageVector = shield.toIcon(), + contentDescription = stringResource(id = CommonStrings.a11y_encryption_details), + modifier = Modifier + .size(15.dp) + .clickable( + onClickLabel = stringResource(CommonStrings.a11y_view_details), + ) { + eventSink(TimelineEvents.ShowShieldDialog(shield)) + }, + tint = shield.toIconColor(), + ) + Spacer(modifier = Modifier.width(4.dp)) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineEventTimestampViewPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) = ElementPreview { + TimelineEventTimestampView( + event = event, + eventSink = {}, + ) +} + +object TimelineEventTimestampViewDefaults { + val spacing = 16.dp +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt new file mode 100644 index 0000000..c921135 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemCallNotifyView.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +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.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +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.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +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.TimelineItemRtcNotificationContent +import io.element.android.features.roomcall.api.RoomCallState +import io.element.android.features.roomcall.api.RoomCallStateProvider +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.toDp +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun TimelineItemCallNotifyView( + event: TimelineItem.Event, + roomCallState: RoomCallState, + onLongClick: (TimelineItem.Event) -> Unit, + onJoinCallClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .border(1.dp, ElementTheme.colors.borderInteractiveSecondary, RoundedCornerShape(8.dp)) + .combinedClickable( + enabled = true, + onClick = {}, + onLongClick = { onLongClick(event) }, + onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), + ) + .onKeyboardContextMenuAction { onLongClick(event) } + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + avatarData = event.senderAvatar, + avatarType = AvatarType.User, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = event.safeSenderName, + style = ElementTheme.typography.fontBodyLgMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier.size(20.sp.toDp()), + imageVector = CompoundIcons.VideoCallSolid(), + contentDescription = null, + tint = ElementTheme.colors.iconSecondary, + ) + Text( + text = stringResource(CommonStrings.common_call_started), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (roomCallState is RoomCallState.OnGoing) { + CallMenuItem( + roomCallState = roomCallState, + onJoinCallClick = onJoinCallClick, + ) + } else { + Text( + text = event.sentTime, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemCallNotifyViewPreview() = ElementPreview { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + RoomCallStateProvider() + .values + .filter { it !is RoomCallState.Unavailable } + .forEach { roomCallState -> + TimelineItemCallNotifyView( + event = aTimelineItemEvent(content = TimelineItemRtcNotificationContent()), + roomCallState = roomCallState, + onLongClick = {}, + onJoinCallClick = {}, + ) + } + } +} 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 0000000..3689c84 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventForTimestampViewProvider.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.aTimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield + +class TimelineItemEventForTimestampViewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aTimelineItemEvent(), + aTimelineItemEvent().copy(localSendState = LocalEventSendState.Sending.Event), + aTimelineItemEvent().copy(localSendState = LocalEventSendState.Failed.Unknown("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.Failed.Unknown("AN_ERROR"), + content = aTimelineItemTextContent().copy(isEdited = true), + ), + aTimelineItemEvent( + messageShield = MessageShield.AuthenticityNotGuaranteed(isCritical = false), + ), + aTimelineItemEvent( + messageShield = MessageShield.UnknownDevice(isCritical = true), + ), + aTimelineItemEvent( + content = aTimelineItemRedactedContent(), + messageShield = MessageShield.SentInClear(isCritical = 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 0000000..1f40e94 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -0,0 +1,898 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import android.annotation.SuppressLint +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.BoxWithConstraints +import androidx.compose.foundation.layout.Column +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.widthIn +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.movableContentOf +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.graphicsLayer +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.platform.ViewConfiguration +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.traversalIndex +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset +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 io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.timeline.TimelineEvents +import io.element.android.features.messages.impl.timeline.TimelineRoomInfo +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.layout.ContentAvoidingLayout +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState +import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView +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.TimelineItemThreadInfo +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.TimelineItemPollContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent +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.features.messages.impl.timeline.protection.TimelineProtectionEvent +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.messages.impl.timeline.protection.mustBeProtected +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.colors.AvatarColorsProvider +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.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.modifiers.niceClickable +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.Icon +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.core.toThreadId +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.EmbeddedEventInfo +import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl +import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName +import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView +import io.element.android.libraries.matrix.ui.messages.reply.eventId +import io.element.android.libraries.matrix.ui.messages.sender.SenderName +import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.strings.CommonPlurals +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.libraries.ui.utils.time.isTalkbackActive +import io.element.android.wysiwyg.link.Link +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.roundToInt + +// The bubble has a negative margin to be placed a bit upper regarding the sender +// information and overlap the avatar. +val NEGATIVE_MARGIN_FOR_BUBBLE = (-8).dp + +// Width of the transparent border around the sender avatar +val SENDER_AVATAR_BORDER_WIDTH = 3.dp + +private val BUBBLE_INCOMING_OFFSET = 16.dp + +@Composable +fun TimelineItemEventRow( + event: TimelineItem.Event, + timelineMode: Timeline.Mode, + timelineRoomInfo: TimelineRoomInfo, + timelineProtectionState: TimelineProtectionState, + renderReadReceipts: Boolean, + isLastOutgoingMessage: Boolean, + displayThreadSummaries: Boolean, + onEventClick: () -> Unit, + onLongClick: () -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, + onUserDataClick: (MatrixUser) -> Unit, + inReplyToClick: (EventId) -> Unit, + onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, + onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, + onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit, + onReadReceiptClick: (event: TimelineItem.Event) -> Unit, + onSwipeToReply: () -> Unit, + eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, + modifier: Modifier = Modifier, + eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange -> + // Only pass down a custom clickable lambda if the content can be clicked separately + val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable } + + TimelineItemEventContentView( + content = event.content, + hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId), + onContentClick = onContentClick, + onLongClick = onLongClick, + onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + eventSink = eventSink, + modifier = contentModifier, + onContentLayoutChange = onContentLayoutChange + ) + }, +) { + val coroutineScope = rememberCoroutineScope() + val interactionSource = remember { MutableInteractionSource() } + + val onContentClick = if (event.mustBeProtected()) { + // In this case, let the content handle the click + {} + } else { + onEventClick + } + + fun onUserDataClick() { + val sender = MatrixUser( + userId = event.senderId, + displayName = event.senderProfile.getDisplayName(), + avatarUrl = event.senderProfile.getAvatarUrl(), + ) + onUserDataClick(sender) + } + + fun inReplyToClick() { + val inReplyToEventId = event.inReplyTo?.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)) + } + val canReply = timelineRoomInfo.userHasPermissionToSendMessage && event.canBeRepliedTo + if (canReply) { + val state: SwipeableActionsState = rememberSwipeableActionsState() + val offset = state.offset.floatValue + val swipeThresholdPx = 40.dp.toPx() + val thresholdCrossed = abs(offset) > swipeThresholdPx + SwipeSensitivity(3f) { + Box(Modifier.fillMaxWidth()) { + Row(modifier = Modifier.matchParentSize()) { + ReplySwipeIndicator({ offset / 120 }) + } + TimelineItemEventRowContent( + event = event, + timelineMode = timelineMode, + timelineProtectionState = timelineProtectionState, + timelineRoomInfo = timelineRoomInfo, + interactionSource = interactionSource, + onContentClick = onContentClick, + onLongClick = onLongClick, + inReplyToClick = ::inReplyToClick, + onUserDataClick = ::onUserDataClick, + onReactionClick = { emoji -> onReactionClick(emoji, event) }, + onReactionLongClick = { emoji -> onReactionLongClick(emoji, event) }, + onMoreReactionsClick = { onMoreReactionsClick(event) }, + 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, + ), + eventSink = eventSink, + eventContentView = eventContentView, + ) + } + } + } else { + TimelineItemEventRowContent( + event = event, + timelineMode = timelineMode, + timelineProtectionState = timelineProtectionState, + timelineRoomInfo = timelineRoomInfo, + interactionSource = interactionSource, + onContentClick = onContentClick, + onLongClick = onLongClick, + inReplyToClick = ::inReplyToClick, + onUserDataClick = ::onUserDataClick, + onReactionClick = { emoji -> onReactionClick(emoji, event) }, + onReactionLongClick = { emoji -> onReactionLongClick(emoji, event) }, + onMoreReactionsClick = { onMoreReactionsClick(event) }, + eventSink = eventSink, + eventContentView = eventContentView, + ) + } + + if (displayThreadSummaries && timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadRoot) { + ThreadSummaryView( + modifier = if (event.isMine) { + Modifier.align(Alignment.End).padding(end = 16.dp) + } else { + if (timelineRoomInfo.isDm) Modifier else Modifier.padding(start = 16.dp) + }.padding(top = 2.dp), + threadSummary = event.threadInfo.summary, + latestEventText = event.threadInfo.latestEventText, + isOutgoing = event.isMine, + onClick = { + event.eventId?.let { + eventSink(TimelineEvents.OpenThread(it.toThreadId(), null)) + } + } + ) + } + + // Read receipts / Send state + TimelineItemReadReceiptView( + state = ReadReceiptViewState( + sendState = event.localSendState, + isLastOutgoingMessage = isLastOutgoingMessage, + receipts = event.readReceiptState.receipts, + ), + renderReadReceipts = renderReadReceipts, + onReadReceiptsClick = { onReadReceiptClick(event) }, + modifier = Modifier.padding(top = 4.dp) + ) + } +} + +@Composable +private fun ThreadSummaryView( + threadSummary: ThreadSummary, + latestEventText: String?, + isOutgoing: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + BoxWithConstraints(modifier = modifier) { + Row( + modifier = Modifier + .then(if (!isOutgoing) Modifier.padding(start = 16.dp) else Modifier) + .graphicsLayer { + shape = RoundedCornerShape(8.dp) + clip = true + } + .background(MessageEventBubbleDefaults.backgroundBubbleColor(isOutgoing)) + .niceClickable(onClick) + .padding(horizontal = 12.dp, vertical = 10.dp) + .widthIn(max = (maxWidth - 24.dp) * MessageEventBubbleDefaults.BUBBLE_WIDTH_RATIO), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.ThreadsSolid(), + contentDescription = null, + tint = ElementTheme.colors.iconSecondary, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = pluralStringResource(CommonPlurals.common_replies, threadSummary.numberOfReplies.toInt(), threadSummary.numberOfReplies), + style = ElementTheme.typography.fontBodySmMedium, + color = ElementTheme.colors.textSecondary, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + threadSummary.latestEvent.dataOrNull()?.let { latestEvent -> + val avatarData = AvatarData( + id = latestEvent.senderId.value, + name = latestEvent.senderProfile.getDisplayName(), + url = latestEvent.senderProfile.getAvatarUrl(), + size = AvatarSize.TimelineThreadLatestEventSender, + ) + Avatar( + avatarData = avatarData, + avatarType = AvatarType.User, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = latestEvent.senderProfile.getDisambiguatedDisplayName(latestEvent.senderId), + style = ElementTheme.typography.fontBodySmMedium, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.width(4.dp)) + + latestEventText?.let { + Text( + text = it, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + } + } + } +} + +/** + * 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 +private 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, + timelineMode: Timeline.Mode, + timelineProtectionState: TimelineProtectionState, + timelineRoomInfo: TimelineRoomInfo, + interactionSource: MutableInteractionSource, + onContentClick: () -> Unit, + onLongClick: () -> Unit, + inReplyToClick: () -> Unit, + onUserDataClick: () -> Unit, + onReactionClick: (emoji: String) -> Unit, + onReactionLongClick: (emoji: String) -> Unit, + onMoreReactionsClick: (event: TimelineItem.Event) -> Unit, + eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, + modifier: Modifier = Modifier, + eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit, +) { + 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, + pinIcon, + ) = createRefs() + + // Sender + if (event.showSenderInformation && !timelineRoomInfo.isDm) { + MessageSenderInformation( + event.senderId, + event.senderProfile, + event.senderAvatar, + onUserDataClick, + Modifier + .constrainAs(sender) { + top.linkTo(parent.top) + // Required for correct RTL layout + start.linkTo(parent.start) + } + .padding(horizontal = 16.dp) + .zIndex(1f), + ) + } + + // Message bubble + val bubbleState = BubbleState( + groupPosition = event.groupPosition, + isMine = event.isMine, + timelineRoomInfo = timelineRoomInfo, + ) + MessageEventBubble( + modifier = Modifier + .constrainAs(message) { + val topMargin = if (bubbleState.cutTopStart) { + NEGATIVE_MARGIN_FOR_BUBBLE + } else { + 0.dp + } + top.linkTo(sender.bottom, margin = topMargin) + if (event.isMine) { + end.linkTo(parent.end, margin = 16.dp) + } else { + val startMargin = if (timelineRoomInfo.isDm) 16.dp else 16.dp + BUBBLE_INCOMING_OFFSET + start.linkTo(parent.start, margin = startMargin) + } + }, + state = bubbleState, + interactionSource = interactionSource, + onClick = onContentClick, + onLongClick = onLongClick, + ) { + MessageEventBubbleContent( + event = event, + timelineMode = timelineMode, + timelineProtectionState = timelineProtectionState, + onMessageLongClick = onLongClick, + inReplyToClick = inReplyToClick, + eventSink = eventSink, + eventContentView = eventContentView, + ) + } + + // Pin icon + val isEventPinned = timelineRoomInfo.pinnedEventIds.contains(event.eventId) + if (isEventPinned) { + Icon( + imageVector = CompoundIcons.PinSolid(), + contentDescription = stringResource(CommonStrings.common_pinned), + tint = ElementTheme.colors.iconTertiary, + modifier = Modifier + .padding(1.dp) + .size(16.dp) + .constrainAs(pinIcon) { + top.linkTo(message.top) + if (event.isMine) { + end.linkTo(message.start, margin = 8.dp) + } else { + start.linkTo(message.end, margin = 8.dp) + } + } + ) + } + + // Reactions + if (event.reactionsState.reactions.isNotEmpty()) { + TimelineItemReactionsView( + reactionsState = event.reactionsState, + userCanSendReaction = timelineRoomInfo.userHasPermissionToSendReaction, + isOutgoing = event.isMine, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = { onMoreReactionsClick(event) }, + modifier = Modifier + .constrainAs(reactions) { + top.linkTo(message.bottom, margin = (-4).dp) + linkStartOrEnd(event) + } + .zIndex(1f) + .padding( + // Note: due to the applied constraints, start is left for other's message and right for mine + // In design we want a offset of 6.dp compare to the bubble, so start is 22.dp (16 + 6) + start = when { + event.isMine -> 22.dp + timelineRoomInfo.isDm -> 22.dp + else -> 22.dp + BUBBLE_INCOMING_OFFSET + }, + end = 16.dp + ) + ) + } + } +} + +@Composable +private fun MessageSenderInformation( + senderId: UserId, + senderProfile: ProfileDetails, + senderAvatar: AvatarData, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val avatarColors = AvatarColorsProvider.provide(senderAvatar.id) + Row( + modifier = modifier + // Add external clickable modifier with no indicator so the touch target is larger than just the display name + .clickable(onClick = onClick, enabled = true, interactionSource = remember { MutableInteractionSource() }, indication = null) + .clearAndSetSemantics { + hideFromAccessibility() + } + ) { + Avatar( + modifier = Modifier + .testTag(TestTags.timelineItemSenderAvatar) + .clip(CircleShape) + .clickable(onClick = onClick), + avatarData = senderAvatar, + avatarType = AvatarType.User, + ) + SenderName( + modifier = Modifier + .testTag(TestTags.timelineItemSenderName) + .clip(RoundedCornerShape(6.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 4.dp), + senderId = senderId, + senderProfile = senderProfile, + senderNameMode = SenderNameMode.Timeline(avatarColors.foreground), + ) + } +} + +@Suppress("MultipleEmitters") // False positive +@Composable +private fun MessageEventBubbleContent( + event: TimelineItem.Event, + timelineMode: Timeline.Mode, + timelineProtectionState: TimelineProtectionState, + onMessageLongClick: () -> Unit, + inReplyToClick: () -> Unit, + eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, + @SuppressLint("ModifierParameter") + // need to rename this modifier to prevent linter false positives + @Suppress("ModifierNaming") + bubbleModifier: Modifier = Modifier, + eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit, +) { + // Long clicks are not not automatically propagated from a `clickable` + // to its `combinedClickable` parent so we do it manually + fun onTimestampLongClick() = onMessageLongClick() + + @Composable + fun ThreadDecoration( + modifier: Modifier = Modifier + ) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.Start), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.height(14.dp), + imageVector = CompoundIcons.Threads(), + contentDescription = null, + tint = ElementTheme.colors.iconSecondary, + ) + Text( + text = stringResource(CommonStrings.common_thread), + style = ElementTheme.typography.fontBodyXsRegular, + color = ElementTheme.colors.textPrimary, + modifier = Modifier.clearAndSetSemantics { } + ) + } + } + + @Composable + fun WithTimestampLayout( + timestampPosition: TimestampPosition, + eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, + modifier: Modifier = Modifier, + canShrinkContent: Boolean = false, + content: @Composable (onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit) -> Unit, + ) { + @Suppress("NAME_SHADOWING") + val content = remember { movableContentOf(content) } + when (timestampPosition) { + TimestampPosition.Overlay -> + Box(modifier, contentAlignment = Alignment.Center) { + content {} + TimelineEventTimestampView( + event = event, + eventSink = eventSink, + modifier = Modifier + // Outer padding + .padding(horizontal = 4.dp, vertical = 4.dp) + .background(ElementTheme.colors.bgSubtleSecondary, RoundedCornerShape(10.0.dp)) + .align(Alignment.BottomEnd) + // Inner padding + .padding(horizontal = 4.dp, vertical = 2.dp) + ) + } + TimestampPosition.Aligned -> + ContentAvoidingLayout( + modifier = modifier, + // The spacing is negative to make the content overlap the empty space at the start of the timestamp + spacing = (-4).dp, + overlayOffset = DpOffset(0.dp, -1.dp), + shrinkContent = canShrinkContent, + content = { content(this::onContentLayoutChange) }, + overlay = { + TimelineEventTimestampView( + event = event, + eventSink = eventSink, + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + ) + TimestampPosition.Below -> + Column(modifier) { + content {} + TimelineEventTimestampView( + event = event, + eventSink = eventSink, + modifier = Modifier + .align(Alignment.End) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + } + + /** Groups the different components in a Column with some space between them. */ + @Composable + fun CommonLayout( + timestampPosition: TimestampPosition, + showThreadDecoration: Boolean, + paddingBehaviour: ContentPadding, + inReplyToDetails: InReplyToDetails?, + modifier: Modifier = Modifier, + canShrinkContent: Boolean = false, + ) { + val timestampLayoutModifier = + if (inReplyToDetails != null && timestampPosition == TimestampPosition.Overlay) { + Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp) + } else { + Modifier + } + + val topPadding = if (inReplyToDetails != null) 0.dp else 8.dp + val contentModifier = when (paddingBehaviour) { + ContentPadding.Textual -> + Modifier.padding(start = 12.dp, end = 12.dp, top = topPadding, bottom = 8.dp) + ContentPadding.Media -> { + if (inReplyToDetails == null) { + Modifier + } else { + Modifier.clip(RoundedCornerShape(10.dp)) + } + } + ContentPadding.CaptionedMedia -> + Modifier.padding(start = 8.dp, end = 8.dp, top = topPadding, bottom = 8.dp) + } + + val threadDecoration = @Composable { + if (showThreadDecoration) { + ThreadDecoration(modifier = Modifier.padding(top = 8.dp, start = 12.dp, end = 12.dp)) + } + } + val contentWithTimestamp = @Composable { + WithTimestampLayout( + timestampPosition = timestampPosition, + eventSink = eventSink, + canShrinkContent = canShrinkContent, + modifier = timestampLayoutModifier.semantics(mergeDescendants = false) { + isTraversalGroup = true + traversalIndex = -1f + }, + content = { onContentLayoutChange -> + eventContentView(contentModifier, onContentLayoutChange) + } + ) + } + + val inReplyTo = @Composable { inReplyTo: InReplyToDetails -> + val topPadding = if (showThreadDecoration) 0.dp else 8.dp + val inReplyToModifier = Modifier + .padding(top = topPadding, start = 8.dp, end = 8.dp) + .clip(RoundedCornerShape(6.dp)) + + val talkbackCompatModifier = if (isTalkbackActive()) { + // Use z-index to make the replied to text being read after the message + // Usually, you'd use traversalIndex for that, but it's not working for some reason + inReplyToModifier.zIndex(1f) + } else { + inReplyToModifier.clickable(onClick = inReplyToClick) + } + InReplyToView( + inReplyTo = inReplyTo, + hideImage = timelineProtectionState.hideMediaContent(inReplyTo.eventId()), + modifier = talkbackCompatModifier, + ) + } + if (inReplyToDetails != null) { + // Use SubComposeLayout only if necessary as it can have consequences on the performance. + EqualWidthColumn(spacing = 8.dp) { + threadDecoration() + inReplyTo(inReplyToDetails) + contentWithTimestamp() + } + } else { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + threadDecoration() + contentWithTimestamp() + } + } + } + + val timestampPosition = when (event.content) { + is TimelineItemImageContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay + is TimelineItemVideoContent -> if (event.content.showCaption) TimestampPosition.Aligned else TimestampPosition.Overlay + is TimelineItemStickerContent, + is TimelineItemLocationContent -> TimestampPosition.Overlay + is TimelineItemPollContent -> TimestampPosition.Below + else -> TimestampPosition.Default + } + val paddingBehaviour = when (event.content) { + is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media + is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media + is TimelineItemStickerContent, + is TimelineItemLocationContent -> ContentPadding.Media + else -> ContentPadding.Textual + } + CommonLayout( + showThreadDecoration = timelineMode !is Timeline.Mode.Thread && event.threadInfo is TimelineItemThreadInfo.ThreadResponse, + timestampPosition = timestampPosition, + paddingBehaviour = paddingBehaviour, + inReplyToDetails = event.inReplyTo, + canShrinkContent = event.content is TimelineItemVoiceContent, + modifier = bubbleModifier, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + senderDisplayName = "Sender with a super long name that should ellipsize", + isMine = isMine, + content = aTimelineItemTextContent( + body = "A long text which will be displayed on several lines and" + + " hopefully can be manually adjusted to test different behaviors." + ), + groupPosition = TimelineItemGroupPosition.First, + ), + ) + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemImageContent( + aspectRatio = 2.5f + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + senderDisplayName = "Sender with a super long name that should ellipsize", + isMine = isMine, + content = aTimelineItemTextContent( + body = "A long text which will be displayed on several lines and" + + " hopefully can be manually adjusted to test different behaviors." + ), + groupPosition = TimelineItemGroupPosition.First, + threadInfo = TimelineItemThreadInfo.ThreadRoot( + latestEventText = "This is the latest message in the thread", + summary = ThreadSummary(AsyncData.Success( + EmbeddedEventInfo( + eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")), + content = MessageContent( + body = "This is the latest message in the thread", + inReplyTo = null, + isEdited = false, + threadInfo = null, + type = TextMessageType("This is the latest message in the thread", null) + ), + senderId = UserId("@user:id"), + senderProfile = ProfileDetails.Ready( + displayName = "Alice", + avatarUrl = null, + displayNameAmbiguous = false, + ), + timestamp = 0L, + ) + ), numberOfReplies = 20L) + ) + ), + displayThreadSummaries = true, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun ThreadSummaryViewPreview() { + ElementPreview { + val body = "This is the latest message in the thread" + val threadSummary = ThreadSummary( + AsyncData.Success( + EmbeddedEventInfo( + eventOrTransactionId = EventOrTransactionId.Event(EventId("\$event-id")), + content = MessageContent( + body = body, + inReplyTo = null, + isEdited = false, + threadInfo = null, + type = TextMessageType(body, null) + ), + senderId = UserId("@user:id"), + senderProfile = ProfileDetails.Ready( + displayName = "Alice", + avatarUrl = null, + displayNameAmbiguous = true, + ), + timestamp = 0L, + ) + ), + numberOfReplies = 12, + ) + + ThreadSummaryView( + threadSummary = threadSummary, + latestEventText = "Some event with a very long text that should get clipped", + isOutgoing = true, + onClick = {}, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowDisambiguatedPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowDisambiguatedPreview.kt new file mode 100644 index 0000000..4b6223f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowDisambiguatedPreview.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsDisambiguatedProvider + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowDisambiguatedPreview( + @PreviewParameter(InReplyToDetailsDisambiguatedProvider::class) inReplyToDetails: InReplyToDetails, +) = ElementPreview { + TimelineItemEventRowWithReplyContentToPreview( + inReplyToDetails = inReplyToDetails, + displayNameAmbiguous = true, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowForDirectRoomPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowForDirectRoomPreview.kt new file mode 100644 index 0000000..86afd9d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowForDirectRoomPreview.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo +import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition +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.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowForDirectRoomPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = it, + content = aTimelineItemTextContent( + body = "A long text which will be displayed on several lines and" + + " hopefully can be manually adjusted to test different behaviors." + ), + groupPosition = TimelineItemGroupPosition.First, + ), + timelineRoomInfo = aTimelineRoomInfo( + isDm = true, + ), + ) + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = it, + content = aTimelineItemImageContent( + aspectRatio = 5f + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + timelineRoomInfo = aTimelineRoomInfo( + isDm = true, + ), + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowLongSenderNamePreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowLongSenderNamePreview.kt new file mode 100644 index 0000000..e40ab16 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowLongSenderNamePreview.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.libraries.designsystem.preview.ElementPreviewLight + +// Note: no need for light/dark variant for this preview +@Preview +@Composable +internal fun TimelineItemEventRowLongSenderNamePreview() = ElementPreviewLight { + ATimelineItemEventRow( + event = aTimelineItemEvent( + senderDisplayName = "a long sender display name to test single line and ellipsis at the end of the line", + ), + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowShieldPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowShieldPreview.kt new file mode 100644 index 0000000..4478854 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowShieldPreview.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition +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.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowShieldPreview() = ElementPreview { + Column { + ATimelineItemEventRow( + event = aTimelineItemEvent( + senderDisplayName = "Sender with a super long name that should ellipsize", + isMine = true, + content = aTimelineItemTextContent( + body = "Message sent from unsigned device" + ), + groupPosition = TimelineItemGroupPosition.First, + messageShield = aCriticalShield() + ), + ) + ATimelineItemEventRow( + event = aTimelineItemEvent( + senderDisplayName = "Sender with a super long name that should ellipsize", + content = aTimelineItemTextContent( + body = "Short Message with authenticity warning" + ), + groupPosition = TimelineItemGroupPosition.Middle, + messageShield = aWarningShield() + ), + ) + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = true, + content = aTimelineItemImageContent( + aspectRatio = 2.5f + ), + groupPosition = TimelineItemGroupPosition.Last, + messageShield = aCriticalShield() + ), + ) + ATimelineItemEventRow( + event = aTimelineItemEvent( + content = aTimelineItemImageContent( + aspectRatio = 2.5f + ), + groupPosition = TimelineItemGroupPosition.Last, + messageShield = aWarningShield() + ), + ) + } +} + +private fun aWarningShield() = MessageShield.AuthenticityNotGuaranteed(isCritical = false) + +internal fun aCriticalShield() = MessageShield.UnverifiedIdentity(isCritical = true) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt new file mode 100644 index 0000000..c588b1d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowTimestampPreview.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewParameter +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.TimelineItemTextContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowTimestampPreview( + @PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event +) = ElementPreview { + Column { + when (event.content) { + is TimelineItemTextContent -> listOf( + "Text", + "Text longer, displayed on 1 line", + "Text which should be rendered on several lines", + ).forEach { str -> + ATimelineItemEventRow( + event = event.copy( + content = event.content.copy( + body = str, + formattedBody = str, + ), + reactionsState = aTimelineItemReactions(count = 0), + ), + ) + } + else -> ATimelineItemEventRow( + event = event.copy( + content = event.content, + reactionsState = aTimelineItemReactions(count = 0), + ), + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowUtdPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowUtdPreview.kt new file mode 100644 index 0000000..49328ac --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowUtdPreview.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +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.TimelineItemGroupPosition +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import io.element.android.libraries.matrix.api.timeline.item.event.UtdCause + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowUtdPreview() = ElementPreview { + Column { + ATimelineItemEventRow( + event = aTimelineItemEvent( + senderDisplayName = "Alice", + isMine = false, + content = TimelineItemEncryptedContent( + data = UnableToDecryptContent.Data.MegolmV1AesSha2( + sessionId = "sessionId", + utdCause = UtdCause.UnsignedDevice, + ) + ), + timelineItemReactions = aTimelineItemReactions(count = 0), + groupPosition = TimelineItemGroupPosition.First, + ), + ) + ATimelineItemEventRow( + event = aTimelineItemEvent( + senderDisplayName = "Bob", + isMine = false, + content = TimelineItemEncryptedContent( + data = UnableToDecryptContent.Data.MegolmV1AesSha2( + sessionId = "sessionId", + utdCause = UtdCause.VerificationViolation, + ) + ), + groupPosition = TimelineItemGroupPosition.First, + timelineItemReactions = aTimelineItemReactions(count = 0) + ), + ) + + ATimelineItemEventRow( + event = aTimelineItemEvent( + senderDisplayName = "Bob", + isMine = false, + content = TimelineItemEncryptedContent( + data = UnableToDecryptContent.Data.MegolmV1AesSha2( + sessionId = "sessionId", + utdCause = UtdCause.SentBeforeWeJoined, + ) + ), + groupPosition = TimelineItemGroupPosition.Last, + timelineItemReactions = aTimelineItemReactions(count = 0) + ), + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithManyReactionsPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithManyReactionsPreview.kt new file mode 100644 index 0000000..9b9080e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithManyReactionsPreview.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +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.aTimelineItemTextContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithManyReactionsPreview() = ElementPreview { + Column { + listOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemTextContent( + body = "A couple of multi-line messages with many reactions attached." + + " One sent by me and another from someone else." + ), + timelineItemReactions = aTimelineItemReactions(count = 20), + ), + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithRRPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithRRPreview.kt new file mode 100644 index 0000000..f46ba78 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithRRPreview.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewParameter +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.receipt.ReadReceiptViewState +import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewStateForTimelineItemEventRowProvider +import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +// Note: I add to reduce the size of the fun name, or it does not compile. +// Previous name: TimelineItemEventRowWithSendingStateAndReadReceiptPreview +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithRRPreview( + @PreviewParameter(ReadReceiptViewStateForTimelineItemEventRowProvider::class) state: ReadReceiptViewState, +) = ElementPreview { + Column { + // A message from someone else + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = false, + sendState = null, + content = aTimelineItemTextContent(body = "A message from someone else"), + timelineItemReactions = aTimelineItemReactions(count = 0), + readReceiptState = TimelineItemReadReceipts(state.receipts), + ), + renderReadReceipts = true, + isLastOutgoingMessage = false, + ) + // A message from current user + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = true, + sendState = state.sendState, + content = aTimelineItemTextContent(body = "A message from me"), + timelineItemReactions = aTimelineItemReactions(count = 0), + readReceiptState = TimelineItemReadReceipts(state.receipts), + ), + renderReadReceipts = true, + isLastOutgoingMessage = false, + ) + // Another message from current user + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = true, + sendState = state.sendState, + content = aTimelineItemTextContent(body = "A last message from me"), + timelineItemReactions = aTimelineItemReactions(count = 0), + readReceiptState = TimelineItemReadReceipts(state.receipts), + ), + renderReadReceipts = true, + isLastOutgoingMessage = true, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyInformativePreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyInformativePreview.kt new file mode 100644 index 0000000..cdc60a9 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyInformativePreview.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsInformativeProvider + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithReplyInformativePreview( + @PreviewParameter(InReplyToDetailsInformativeProvider::class) inReplyToDetails: InReplyToDetails, +) = ElementPreview { + TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyOtherPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyOtherPreview.kt new file mode 100644 index 0000000..3071fe1 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyOtherPreview.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsOtherProvider + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithReplyOtherPreview( + @PreviewParameter(InReplyToDetailsOtherProvider::class) inReplyToDetails: InReplyToDetails, +) = ElementPreview { + TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt new file mode 100644 index 0000000..7ddcadf --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRowWithReplyPreview.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewParameter +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.TimelineItemGroupPosition +import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo +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.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider + +@PreviewsDayNight +@Composable +internal fun TimelineItemEventRowWithReplyPreview( + @PreviewParameter(InReplyToDetailsProvider::class) inReplyToDetails: InReplyToDetails, +) = ElementPreview { + TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails) +} + +@Composable +internal fun TimelineItemEventRowWithReplyContentToPreview( + inReplyToDetails: InReplyToDetails, + displayNameAmbiguous: Boolean = false, +) { + Column { + sequenceOf(false, true).forEach { + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = it, + timelineItemReactions = aTimelineItemReactions(count = 0), + content = aTimelineItemTextContent(body = "A reply."), + inReplyTo = inReplyToDetails, + displayNameAmbiguous = displayNameAmbiguous, + groupPosition = TimelineItemGroupPosition.First, + ), + ) + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = it, + timelineItemReactions = aTimelineItemReactions(count = 0), + content = aTimelineItemImageContent( + aspectRatio = 2.5f, + filename = "image.jpg", + caption = "A reply with an image.", + ), + inReplyTo = inReplyToDetails, + displayNameAmbiguous = displayNameAmbiguous, + threadInfo = TimelineItemThreadInfo.ThreadResponse(threadRootId = ThreadId("\$thread-root-id")), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventTimestampBelowPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventTimestampBelowPreview.kt new file mode 100644 index 0000000..975073c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventTimestampBelowPreview.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent +import io.element.android.libraries.designsystem.preview.ElementPreviewLight + +// Note: no need for light/dark variant for this preview, we only look at the timestamp position +@Preview +@Composable +internal fun TimelineItemEventTimestampBelowPreview() = ElementPreviewLight { + ATimelineItemEventRow( + event = aTimelineItemEvent(content = aTimelineItemPollContent()), + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt new file mode 100644 index 0000000..66f2459 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import io.element.android.features.messages.impl.R +import io.element.android.features.messages.impl.timeline.TimelineEvents +import io.element.android.features.messages.impl.timeline.TimelineRoomInfo +import io.element.android.features.messages.impl.timeline.aGroupedEvents +import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo +import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView +import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState +import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.ui.utils.time.isTalkbackActive +import io.element.android.wysiwyg.link.Link + +@Composable +fun TimelineItemGroupedEventsRow( + timelineItem: TimelineItem.GroupedEvents, + timelineMode: Timeline.Mode, + timelineRoomInfo: TimelineRoomInfo, + timelineProtectionState: TimelineProtectionState, + renderReadReceipts: Boolean, + isLastOutgoingMessage: Boolean, + focusedEventId: EventId?, + displayThreadSummaries: Boolean, + onClick: (TimelineItem.Event) -> Unit, + onLongClick: (TimelineItem.Event) -> Unit, + inReplyToClick: (EventId) -> Unit, + onUserDataClick: (MatrixUser) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, + onReactionClick: (key: String, TimelineItem.Event) -> Unit, + onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, + onMoreReactionsClick: (TimelineItem.Event) -> Unit, + onReadReceiptClick: (TimelineItem.Event) -> Unit, + eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, + modifier: Modifier = Modifier, + eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = + { event, contentModifier, onContentLayoutChange -> + TimelineItemEventContentView( + content = event.content, + hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId), + onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + eventSink = eventSink, + modifier = contentModifier, + onContentClick = null, + onLongClick = null, + onContentLayoutChange = onContentLayoutChange + ) + }, +) { + val isExpanded = rememberSaveable { mutableStateOf(false) } + + fun onExpandGroupClick() { + isExpanded.value = !isExpanded.value + } + + TimelineItemGroupedEventsRowContent( + isExpanded = isExpanded.value, + onExpandGroupClick = ::onExpandGroupClick, + timelineItem = timelineItem, + timelineMode = timelineMode, + timelineRoomInfo = timelineRoomInfo, + timelineProtectionState = timelineProtectionState, + focusedEventId = focusedEventId, + renderReadReceipts = renderReadReceipts, + isLastOutgoingMessage = isLastOutgoingMessage, + displayThreadSummaries = displayThreadSummaries, + onClick = onClick, + onLongClick = onLongClick, + inReplyToClick = inReplyToClick, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, + onReadReceiptClick = onReadReceiptClick, + eventSink = eventSink, + modifier = modifier, + eventContentView = eventContentView, + ) +} + +@Composable +private fun TimelineItemGroupedEventsRowContent( + isExpanded: Boolean, + onExpandGroupClick: () -> Unit, + timelineItem: TimelineItem.GroupedEvents, + timelineMode: Timeline.Mode, + timelineRoomInfo: TimelineRoomInfo, + timelineProtectionState: TimelineProtectionState, + focusedEventId: EventId?, + renderReadReceipts: Boolean, + isLastOutgoingMessage: Boolean, + displayThreadSummaries: Boolean, + onClick: (TimelineItem.Event) -> Unit, + onLongClick: (TimelineItem.Event) -> Unit, + inReplyToClick: (EventId) -> Unit, + onUserDataClick: (MatrixUser) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, + onReactionClick: (key: String, TimelineItem.Event) -> Unit, + onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, + onMoreReactionsClick: (TimelineItem.Event) -> Unit, + onReadReceiptClick: (TimelineItem.Event) -> Unit, + eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, + modifier: Modifier = Modifier, + eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = + { event, contentModifier, onContentLayoutChange -> + TimelineItemEventContentView( + content = event.content, + hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId), + onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + eventSink = eventSink, + modifier = contentModifier, + onContentClick = null, + onLongClick = null, + onContentLayoutChange = onContentLayoutChange + ) + }, +) { + Column(modifier = modifier.animateContentSize()) { + GroupHeaderView( + text = pluralStringResource( + id = R.plurals.screen_room_timeline_state_changes, + count = timelineItem.events.size, + timelineItem.events.size + ), + isExpanded = isExpanded, + isHighlighted = !isExpanded && timelineItem.events.any { it.isEvent(focusedEventId) }, + onClick = onExpandGroupClick, + ) + if (isExpanded) { + Column { + timelineItem.events.let { + if (isTalkbackActive()) { + it.reversed() + } else { + it + } + }.forEach { subGroupEvent -> + TimelineItemRow( + timelineMode = timelineMode, + timelineItem = subGroupEvent, + timelineRoomInfo = timelineRoomInfo, + timelineProtectionState = timelineProtectionState, + renderReadReceipts = renderReadReceipts, + isLastOutgoingMessage = isLastOutgoingMessage, + focusedEventId = focusedEventId, + displayThreadSummaries = displayThreadSummaries, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onContentClick = onClick, + onLongClick = onLongClick, + inReplyToClick = inReplyToClick, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, + onReadReceiptClick = onReadReceiptClick, + onSwipeToReply = {}, + onJoinCallClick = {}, + eventSink = eventSink, + eventContentView = eventContentView, + ) + } + } + } else if (renderReadReceipts) { + TimelineItemReadReceiptView( + state = ReadReceiptViewState( + sendState = null, + isLastOutgoingMessage = false, + receipts = timelineItem.aggregatedReadReceipts, + ), + renderReadReceipts = true, + onReadReceiptsClick = onExpandGroupClick + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPreview { + val events = aGroupedEvents(withReadReceipts = true) + TimelineItemGroupedEventsRowContent( + isExpanded = true, + onExpandGroupClick = {}, + timelineItem = events, + timelineMode = Timeline.Mode.Live, + timelineRoomInfo = aTimelineRoomInfo(), + timelineProtectionState = aTimelineProtectionState(), + focusedEventId = events.events.first().eventId, + renderReadReceipts = true, + isLastOutgoingMessage = false, + displayThreadSummaries = false, + onClick = {}, + onLongClick = {}, + onLinkLongClick = {}, + inReplyToClick = {}, + onUserDataClick = {}, + onLinkClick = {}, + onReactionClick = { _, _ -> }, + onReactionLongClick = { _, _ -> }, + onMoreReactionsClick = {}, + onReadReceiptClick = {}, + eventSink = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPreview { + TimelineItemGroupedEventsRowContent( + isExpanded = false, + onExpandGroupClick = {}, + timelineItem = aGroupedEvents(withReadReceipts = true), + timelineMode = Timeline.Mode.Live, + timelineRoomInfo = aTimelineRoomInfo(), + timelineProtectionState = aTimelineProtectionState(), + focusedEventId = null, + renderReadReceipts = true, + isLastOutgoingMessage = false, + displayThreadSummaries = false, + onClick = {}, + onLongClick = {}, + onLinkLongClick = {}, + inReplyToClick = {}, + onUserDataClick = {}, + onLinkClick = {}, + onReactionClick = { _, _ -> }, + onReactionLongClick = { _, _ -> }, + onMoreReactionsClick = {}, + onReadReceiptClick = {}, + eventSink = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt new file mode 100644 index 0000000..b7cefb8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import io.element.android.features.messages.impl.R +import io.element.android.features.messages.impl.timeline.aTimelineItemReactions +import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +/** + * A flow layout for reactions that will show a collapse/expand button when the layout wraps over a defined number of rows. + * It displays an add more button when there are greater than 0 reactions and always displays the reaction and add more button + * on the same row (moving them both to a new row if necessary). + * @param expandButton The expand button + * @param addMoreButton The add more button + * @param modifier The modifier to apply to this layout + * @param itemSpacing The horizontal spacing between items + * @param rowSpacing The vertical spacing between rows + * @param expanded Whether the layout should display in expanded or collapsed state + * @param rowsBeforeCollapsible The number of rows before the collapse/expand button is shown + * @param reactions The reaction buttons + */ +@Composable +fun TimelineItemReactionsLayout( + expandButton: @Composable () -> Unit, + addMoreButton: (@Composable () -> Unit)?, + modifier: Modifier = Modifier, + itemSpacing: Dp = 0.dp, + rowSpacing: Dp = 0.dp, + expanded: Boolean = false, + rowsBeforeCollapsible: Int? = 2, + reactions: @Composable () -> Unit, +) { + SubcomposeLayout(modifier) { constraints -> + // Given the placeables and returns a structure representing + // how they should wrap on to multiple rows given the constraints max width. + fun calculateRows(placeables: List): List> { + val rows = mutableListOf>() + var currentRow = mutableListOf() + var rowX = 0 + + placeables.forEach { placeable -> + val horizontalSpacing = if (currentRow.isEmpty()) 0 else itemSpacing.toPx().toInt() + // If the current view does not fit on this row bump to the next + if (rowX + placeable.width > constraints.maxWidth) { + rows.add(currentRow) + currentRow = mutableListOf() + rowX = 0 + } + rowX += horizontalSpacing + placeable.width + currentRow.add(placeable) + } + // If there are items in the current row remember to append it to the returned value + if (currentRow.size > 0) { + rows.add(currentRow) + } + return rows + } + + // Used to render the collapsed state, this takes the rows inputted and adds the extra button to the last row, + // removing only as many trailing reactions as needed to make space for it. + fun replaceTrailingItemsWithButtons(rowsIn: List>, expandButton: Placeable, addMoreButton: Placeable?): List> { + val rows = rowsIn.toMutableList() + val lastRow = rows.last() + val buttonsWidth = expandButton.width + itemSpacing.toPx().toInt() + (addMoreButton?.width ?: 0) + var rowX = 0 + lastRow.forEachIndexed { i, placeable -> + val horizontalSpacing = if (i == 0) 0 else itemSpacing.toPx().toInt() + rowX += placeable.width + horizontalSpacing + if (rowX > constraints.maxWidth - (buttonsWidth + horizontalSpacing)) { + val lastRowWithButton = lastRow.take(i) + listOfNotNull(expandButton, addMoreButton) + rows[rows.size - 1] = lastRowWithButton + return rows + } + } + val lastRowWithButton = lastRow + listOfNotNull(expandButton, addMoreButton) + rows[rows.size - 1] = lastRowWithButton + return rows + } + + // To prevent the add more and expand buttons from wrapping on to separate lines. + // If there is one item on the last line, it moves the expand button down. + fun ensureCollapseAndAddMoreButtonsAreOnTheSameRow(rowsIn: List>): List> { + val lastRow = rowsIn.last().toMutableList() + if (lastRow.size != 1) { + return rowsIn + } + val rows = rowsIn.toMutableList() + val secondLastRow = rows[rows.size - 2].toMutableList() + val expandButtonPlaceable = secondLastRow.removeAt(secondLastRow.lastIndex) + lastRow.add(0, expandButtonPlaceable) + rows[rows.size - 2] = secondLastRow + rows[rows.size - 1] = lastRow + return rows + } + + // Given a list of rows place them in the layout. + fun layoutRows(rows: List>): MeasureResult { + var width = 0 + var height = 0 + val placeables = rows.mapIndexed { i, row -> + var rowX = 0 + var rowHeight = 0 + val verticalSpacing = if (i == 0) 0 else rowSpacing.toPx().toInt() + val rowWithPoints = row.mapIndexed { j, placeable -> + val horizontalSpacing = if (j == 0) 0 else itemSpacing.toPx().toInt() + val point = IntOffset(rowX + horizontalSpacing, height + verticalSpacing) + rowX += placeable.width + horizontalSpacing + rowHeight = maxOf(rowHeight, placeable.height) + Pair(placeable, point) + } + height += rowHeight + verticalSpacing + width = maxOf(width, rowX) + rowWithPoints + }.flatten() + + return layout(width = width, height = height) { + placeables.forEach { + val (placeable, origin) = it + placeable.placeRelative(origin.x, origin.y) + } + } + } + + var reactionsPlaceables = subcompose(0, reactions).map { it.measure(constraints) } + if (reactionsPlaceables.isEmpty()) { + return@SubcomposeLayout layoutRows(listOf()) + } + var expandPlaceable = subcompose(1, expandButton).first().measure(constraints) + // Enforce all reaction buttons have the same height + val maxHeight = (reactionsPlaceables + listOf(expandPlaceable)).maxOf { it.height } + val newConstrains = constraints.copy(minHeight = maxHeight) + reactionsPlaceables = subcompose(2, reactions).map { it.measure(newConstrains) } + expandPlaceable = subcompose(3, expandButton).first().measure(newConstrains) + val addMorePlaceable = addMoreButton?.let { subcompose(4, addMoreButton).first().measure(newConstrains) } + + // Calculate the layout of the rows with the reactions button and add more button + val reactionsAndAddMore = calculateRows(reactionsPlaceables + listOfNotNull(addMorePlaceable)) + // If we have extended beyond the defined number of rows we are showing the expand/collapse ui + if (rowsBeforeCollapsible?.let { reactionsAndAddMore.size > it } == true) { + if (expanded) { + // Show all subviews with the add more button at the end + var reactionsAndButtons = calculateRows(reactionsPlaceables + listOfNotNull(expandPlaceable, addMorePlaceable)) + reactionsAndButtons = ensureCollapseAndAddMoreButtonsAreOnTheSameRow(reactionsAndButtons) + layoutRows(reactionsAndButtons) + } else { + // Truncate to `rowsBeforeCollapsible` number of rows and replace the reactions at the end of the last row with the buttons + val collapsedRows = reactionsAndAddMore.take(rowsBeforeCollapsible) + val collapsedRowsWithButtons = replaceTrailingItemsWithButtons(collapsedRows, expandPlaceable, addMorePlaceable) + layoutRows(collapsedRowsWithButtons) + } + } else { + // Otherwise we are just showing all items without the expand button + layoutRows(reactionsAndAddMore) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemReactionsLayoutPreview() = ElementPreview { + TimelineItemReactionsLayout( + expanded = false, + expandButton = { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Text( + text = stringResource(id = R.string.screen_room_timeline_less_reactions) + ), + onClick = {}, + onLongClick = {} + ) + }, + addMoreButton = { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Icon(CompoundDrawables.ic_compound_reaction_add), + onClick = {}, + onLongClick = {} + ) + }, + reactions = { + aTimelineItemReactions(count = 18).reactions.forEach { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Reaction( + it + ), + onClick = {}, + onLongClick = {} + ) + } + } + ) +} 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 0000000..9698227 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +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.architecture.coverage.ExcludeFromCoverage +import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun TimelineItemReactionsView( + reactionsState: TimelineItemReactions, + isOutgoing: Boolean, + userCanSendReaction: Boolean, + onReactionClick: (emoji: String) -> Unit, + onReactionLongClick: (emoji: String) -> Unit, + onMoreReactionsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var expanded: Boolean by rememberSaveable { mutableStateOf(false) } + TimelineItemReactionsView( + modifier = modifier.semantics { + hideFromAccessibility() + }, + reactions = reactionsState.reactions, + userCanSendReaction = userCanSendReaction, + expanded = expanded, + isOutgoing = isOutgoing, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, + onToggleExpandClick = { expanded = !expanded }, + ) +} + +@Composable +private fun TimelineItemReactionsView( + reactions: ImmutableList, + userCanSendReaction: Boolean, + isOutgoing: Boolean, + expanded: Boolean, + onReactionClick: (emoji: String) -> Unit, + onReactionLongClick: (emoji: String) -> Unit, + onMoreReactionsClick: () -> Unit, + onToggleExpandClick: () -> Unit, + modifier: Modifier = Modifier +) { + // In LTR languages we want an incoming message's reactions to be LTR and outgoing to be RTL. + // For RTL languages it should be the opposite. + val currentLayout = LocalLayoutDirection.current + val reactionsLayoutDirection = when { + !isOutgoing -> currentLayout + currentLayout == LayoutDirection.Ltr -> LayoutDirection.Rtl + else -> LayoutDirection.Ltr + } + + CompositionLocalProvider(LocalLayoutDirection provides reactionsLayoutDirection) { + TimelineItemReactionsLayout( + modifier = modifier, + itemSpacing = 4.dp, + rowSpacing = 4.dp, + expanded = expanded, + expandButton = { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Text( + text = stringResource( + id = if (expanded) { + R.string.screen_room_timeline_reactions_show_less + } else { + R.string.screen_room_timeline_reactions_show_more + } + ) + ), + onClick = onToggleExpandClick, + onLongClick = {} + ) + }, + addMoreButton = if (userCanSendReaction) { + { + CompositionLocalProvider(LocalLayoutDirection provides currentLayout) { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Icon(CompoundDrawables.ic_compound_reaction_add), + onClick = onMoreReactionsClick, + onLongClick = {} + ) + } + } + } else { + null + }, + reactions = { + reactions.forEach { reaction -> + CompositionLocalProvider(LocalLayoutDirection provides currentLayout) { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Reaction(reaction = reaction), + onClick = { + // Always allow user to redact their own reactions + if (reaction.isHighlighted || userCanSendReaction) { + onReactionClick(reaction.key) + } + }, + onLongClick = { onReactionLongClick(reaction.key) } + ) + } + } + } + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemReactionsViewPreview() = ElementPreview { + ContentToPreview( + reactions = aTimelineItemReactions(count = 1).reactions + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemReactionsViewFewPreview() = ElementPreview { + ContentToPreview( + reactions = aTimelineItemReactions(count = 3).reactions + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemReactionsViewIncomingPreview() = ElementPreview { + ContentToPreview( + reactions = aTimelineItemReactions(count = 18).reactions + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemReactionsViewOutgoingPreview() = ElementPreview { + ContentToPreview( + reactions = aTimelineItemReactions(count = 18).reactions, + isOutgoing = true + ) +} + +@ExcludeFromCoverage +@Composable +private fun ContentToPreview( + reactions: ImmutableList, + isOutgoing: Boolean = false +) { + TimelineItemReactionsView( + reactionsState = TimelineItemReactions( + reactions + ), + userCanSendReaction = true, + isOutgoing = isOutgoing, + onReactionClick = {}, + onReactionLongClick = {}, + onMoreReactionsClick = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt new file mode 100644 index 0000000..4a0a6f7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -0,0 +1,258 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +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.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.messages.impl.timeline.TimelineEvents +import io.element.android.features.messages.impl.timeline.TimelineRoomInfo +import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.libraries.designsystem.colors.gradientSubtleColors +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.toPx +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.libraries.ui.utils.time.isTalkbackActive +import io.element.android.wysiwyg.link.Link +import kotlin.time.DurationUnit + +@Composable +internal fun TimelineItemRow( + timelineItem: TimelineItem, + timelineMode: Timeline.Mode, + timelineRoomInfo: TimelineRoomInfo, + renderReadReceipts: Boolean, + isLastOutgoingMessage: Boolean, + timelineProtectionState: TimelineProtectionState, + focusedEventId: EventId?, + displayThreadSummaries: Boolean, + onUserDataClick: (MatrixUser) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, + onContentClick: (TimelineItem.Event) -> Unit, + onLongClick: (TimelineItem.Event) -> Unit, + inReplyToClick: (EventId) -> Unit, + onReactionClick: (key: String, TimelineItem.Event) -> Unit, + onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, + onMoreReactionsClick: (TimelineItem.Event) -> Unit, + onReadReceiptClick: (TimelineItem.Event) -> Unit, + onSwipeToReply: (TimelineItem.Event) -> Unit, + onJoinCallClick: () -> Unit, + eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, + modifier: Modifier = Modifier, + eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = + { event, contentModifier, onContentLayoutChange -> + TimelineItemEventContentView( + content = event.content, + hideMediaContent = timelineProtectionState.hideMediaContent(event.eventId), + onShowContentClick = { timelineProtectionState.eventSink(TimelineProtectionEvent.ShowContent(event.eventId)) }, + onContentClick = { onContentClick(event) }, + onLongClick = { onLongClick(event) }, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + eventSink = eventSink, + modifier = contentModifier, + onContentLayoutChange = onContentLayoutChange + ) + }, +) { + val backgroundModifier = if (timelineItem.isEvent(focusedEventId)) { + val focusedEventOffset = if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) { + 14.dp + } else { + 2.dp + } + Modifier.focusedEvent(focusedEventOffset) + } else { + Modifier + } + Box(modifier = modifier.then(backgroundModifier)) { + when (timelineItem) { + is TimelineItem.Virtual -> { + TimelineItemVirtualRow( + virtual = timelineItem, + timelineRoomInfo = timelineRoomInfo, + eventSink = eventSink, + ) + } + is TimelineItem.Event -> { + when (timelineItem.content) { + is TimelineItemStateContent, is TimelineItemLegacyCallInviteContent -> { + TimelineItemStateEventRow( + event = timelineItem, + renderReadReceipts = renderReadReceipts, + isLastOutgoingMessage = isLastOutgoingMessage, + onClick = { onContentClick(timelineItem) }, + onReadReceiptsClick = onReadReceiptClick, + onLongClick = { onLongClick(timelineItem) }, + eventSink = eventSink, + ) + } + is TimelineItemRtcNotificationContent -> { + TimelineItemCallNotifyView( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp), + event = timelineItem, + roomCallState = timelineRoomInfo.roomCallState, + onLongClick = onLongClick, + onJoinCallClick = onJoinCallClick, + ) + } + else -> { + val a11yVoiceMessage = stringResource(CommonStrings.a11y_voice_message) + TimelineItemEventRow( + modifier = Modifier + .semantics(mergeDescendants = true) { + contentDescription = if (timelineItem.content is TimelineItemVoiceContent) { + val voiceMessageText = String.format(a11yVoiceMessage, timelineItem.content.duration.toString(DurationUnit.MINUTES)) + "${timelineItem.safeSenderName}, $voiceMessageText" + } else { + timelineItem.safeSenderName + } + // For Polls, allow the answers to be traversed by Talkback + isTraversalGroup = timelineItem.content is TimelineItemPollContent || + timelineItem.failedToSend || + timelineItem.messageShield != null + // TODO Also set to true when the event has link(s) + } + // Custom clickable that applies over the whole item for accessibility + .then( + if (isTalkbackActive()) { + Modifier + .combinedClickable( + onClick = { onContentClick(timelineItem) }, + onLongClick = { onLongClick(timelineItem) }, + onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), + ) + .onKeyboardContextMenuAction { onLongClick(timelineItem) } + } else { + Modifier + } + ), + event = timelineItem, + timelineMode = timelineMode, + timelineRoomInfo = timelineRoomInfo, + renderReadReceipts = renderReadReceipts, + timelineProtectionState = timelineProtectionState, + isLastOutgoingMessage = isLastOutgoingMessage, + displayThreadSummaries = displayThreadSummaries, + onEventClick = { onContentClick(timelineItem) }, + onLongClick = { onLongClick(timelineItem) }, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onUserDataClick = onUserDataClick, + inReplyToClick = inReplyToClick, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, + onReadReceiptClick = onReadReceiptClick, + onSwipeToReply = { onSwipeToReply(timelineItem) }, + eventSink = eventSink, + eventContentView = { contentModifier, onContentLayoutChange -> + eventContentView(timelineItem, contentModifier, onContentLayoutChange) + }, + ) + } + } + } + is TimelineItem.GroupedEvents -> { + TimelineItemGroupedEventsRow( + timelineItem = timelineItem, + timelineMode = timelineMode, + timelineRoomInfo = timelineRoomInfo, + timelineProtectionState = timelineProtectionState, + renderReadReceipts = renderReadReceipts, + isLastOutgoingMessage = isLastOutgoingMessage, + focusedEventId = focusedEventId, + displayThreadSummaries = displayThreadSummaries, + onClick = onContentClick, + onLongClick = onLongClick, + inReplyToClick = inReplyToClick, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, + onReadReceiptClick = onReadReceiptClick, + eventSink = eventSink, + ) + } + } + } +} + +@Suppress("ModifierComposable") +@Composable +private fun Modifier.focusedEvent( + focusedEventOffset: Dp, +): Modifier { + val highlightedLineColor = ElementTheme.colors.borderAccentSubtle + val gradientColors = gradientSubtleColors() + val verticalOffset = focusedEventOffset.toPx() + val verticalRatio = 0.7f + return drawWithCache { + val brush = Brush.verticalGradient( + colors = gradientColors, + endY = size.height * verticalRatio, + ) + onDrawBehind { + drawRect( + brush, + topLeft = Offset(0f, verticalOffset), + size = Size(size.width, size.height * verticalRatio) + ) + drawLine( + highlightedLineColor, + start = Offset(0f, verticalOffset), + end = Offset(size.width, verticalOffset) + ) + } + }.padding(top = 4.dp) +} + +@PreviewsDayNight +@Composable +internal fun FocusedEventPreview() = ElementPreview { + Box( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + .height(160.dp) + .focusedEvent(0.dp), + ) +} 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 0000000..e796e93 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.Column +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.unit.dp +import androidx.compose.ui.zIndex +import io.element.android.features.messages.impl.timeline.TimelineEvents +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.receipt.ReadReceiptViewState +import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView +import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData +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.TimelineItemReadReceipts +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun TimelineItemStateEventRow( + event: TimelineItem.Event, + renderReadReceipts: Boolean, + isLastOutgoingMessage: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + onReadReceiptsClick: (event: TimelineItem.Event) -> Unit, + eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + Column( + modifier = modifier + .fillMaxWidth() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 2.dp) + .wrapContentHeight(), + contentAlignment = Alignment.Center + ) { + MessageStateEventContainer( + interactionSource = interactionSource, + onClick = onClick, + onLongClick = onLongClick, + modifier = Modifier + .zIndex(-1f) + .widthIn(max = 320.dp) + ) { + TimelineItemEventContentView( + content = event.content, + onLinkClick = {}, + onLinkLongClick = {}, + hideMediaContent = false, + onShowContentClick = {}, + eventSink = eventSink, + onContentClick = null, + onLongClick = null, + modifier = Modifier.defaultTimelineContentPadding() + ) + } + } + TimelineItemReadReceiptView( + state = ReadReceiptViewState( + sendState = event.localSendState, + isLastOutgoingMessage = isLastOutgoingMessage, + receipts = event.readReceiptState.receipts, + ), + renderReadReceipts = renderReadReceipts, + onReadReceiptsClick = { onReadReceiptsClick(event) }, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemStateEventRowPreview() = ElementPreview { + TimelineItemStateEventRow( + event = aTimelineItemEvent( + isMine = false, + content = aTimelineItemStateEventContent(), + groupPosition = TimelineItemGroupPosition.None, + readReceiptState = TimelineItemReadReceipts( + receipts = persistentListOf(aReadReceiptData(0)), + ) + ), + renderReadReceipts = true, + isLastOutgoingMessage = false, + onClick = {}, + onLongClick = {}, + onReadReceiptsClick = {}, + eventSink = {} + ) +} 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 0000000..be94512 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import io.element.android.features.messages.impl.timeline.TimelineEvents +import io.element.android.features.messages.impl.timeline.TimelineRoomInfo +import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView +import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemReadMarkerView +import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemRoomBeginningView +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.virtual.TimelineItemDaySeparatorModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLastForwardIndicatorModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel +import io.element.android.features.messages.impl.typing.TypingNotificationView +import timber.log.Timber + +@Composable +fun TimelineItemVirtualRow( + virtual: TimelineItem.Virtual, + timelineRoomInfo: TimelineRoomInfo, + eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, + modifier: Modifier = Modifier +) { + Box(modifier = modifier) { + when (virtual.model) { + is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model) + TimelineItemReadMarkerModel -> TimelineItemReadMarkerView() + TimelineItemRoomBeginningModel -> { + TimelineItemRoomBeginningView( + predecessorRoom = timelineRoomInfo.predecessorRoom, + roomName = timelineRoomInfo.name, + isDm = timelineRoomInfo.isDm, + onPredecessorRoomClick = { roomId -> + eventSink(TimelineEvents.NavigateToPredecessorOrSuccessorRoom(roomId)) + }, + ) + } + is TimelineItemLoadingIndicatorModel -> { + TimelineLoadingMoreIndicator(virtual.model.direction) + val latestEventSink by rememberUpdatedState(eventSink) + LaunchedEffect(virtual.model.timestamp) { + Timber.d("Pagination triggered by load more indicator") + latestEventSink(TimelineEvents.LoadMore(virtual.model.direction)) + } + } + // Empty model trick to avoid timeline jumping during forward pagination. + is TimelineItemLastForwardIndicatorModel -> { + Spacer(modifier = Modifier) + } + is TimelineItemTypingNotificationModel -> { + TypingNotificationView( + state = timelineRoomInfo.typingNotificationState, + ) + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimestampPosition.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimestampPosition.kt new file mode 100644 index 0000000..605db65 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimestampPosition.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components + +enum class TimestampPosition { + /** + * Timestamp should overlay the timeline event content (eg. image). + */ + Overlay, + + /** + * Timestamp should be aligned with the timeline event content if this is possible (eg. text). + */ + Aligned, + + /** + * Timestamp should always be rendered below the timeline event content (eg. poll). + */ + Below; + + companion object { + /** + * Default timestamp position for timeline event contents. + */ + val Default: TimestampPosition = Aligned + } +} 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 0000000..0226eea --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import io.element.android.emojibasebindings.Emoji +import io.element.android.features.messages.impl.timeline.components.customreaction.picker.EmojiPicker +import io.element.android.features.messages.impl.timeline.components.customreaction.picker.EmojiPickerPresenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.hide +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomReactionBottomSheet( + state: CustomReactionState, + onSelectEmoji: (EventOrTransactionId, Emoji) -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() + val target = state.target as? CustomReactionState.Target.Success + + fun onDismiss() { + state.eventSink(CustomReactionEvents.DismissCustomReactionSheet) + } + + fun onEmojiSelectedDismiss(emoji: Emoji) { + if (target?.event == null) return + sheetState.hide(coroutineScope) { + state.eventSink(CustomReactionEvents.DismissCustomReactionSheet) + onSelectEmoji(target.event.eventOrTransactionId, emoji) + } + } + + if (target?.emojibaseStore != null && target.event.eventId != null) { + ModalBottomSheet( + onDismissRequest = ::onDismiss, + sheetState = sheetState, + modifier = modifier + ) { + val presenter = remember { + EmojiPickerPresenter( + emojibaseStore = target.emojibaseStore, + recentEmojis = state.recentEmojis, + coroutineDispatchers = CoroutineDispatchers.Default, + ) + } + EmojiPicker( + onSelectEmoji = ::onEmojiSelectedDismiss, + state = presenter.present(), + selectedEmojis = state.selectedEmoji, + 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 0000000..b73d129 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionEvents.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.customreaction + +import io.element.android.features.messages.impl.timeline.model.TimelineItem + +sealed interface CustomReactionEvents { + data class ShowCustomReactionSheet(val event: TimelineItem.Event) : CustomReactionEvents + data object DismissCustomReactionSheet : 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 0000000..4ccfc3f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.customreaction + +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.setValue +import dev.zacsweers.metro.Inject +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.recentemojis.api.EmojibaseProvider +import io.element.android.libraries.recentemojis.api.GetRecentEmojis +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.launch + +@Inject +class CustomReactionPresenter( + private val emojibaseProvider: EmojibaseProvider, + private val getRecentEmojis: GetRecentEmojis, +) : Presenter { + @Composable + override fun present(): CustomReactionState { + val localCoroutineScope = rememberCoroutineScope() + var recentEmojis by remember { mutableStateOf>(persistentListOf()) } + + val target: MutableState = remember { + mutableStateOf(CustomReactionState.Target.None) + } + + fun handleShowCustomReactionSheet(event: TimelineItem.Event) { + target.value = CustomReactionState.Target.Loading(event) + localCoroutineScope.launch { + recentEmojis = getRecentEmojis().getOrNull() ?: persistentListOf() + target.value = CustomReactionState.Target.Success( + event = event, + emojibaseStore = emojibaseProvider.emojibaseStore + ) + } + } + + fun handleDismissCustomReactionSheet() { + target.value = CustomReactionState.Target.None + } + + fun handleEvent(event: CustomReactionEvents) { + when (event) { + is CustomReactionEvents.ShowCustomReactionSheet -> handleShowCustomReactionSheet(event.event) + is CustomReactionEvents.DismissCustomReactionSheet -> handleDismissCustomReactionSheet() + } + } + val event = (target.value as? CustomReactionState.Target.Success)?.event + val selectedEmoji = event + ?.reactionsState + ?.reactions + ?.mapNotNull { if (it.isHighlighted) it.key else null } + .orEmpty() + .toImmutableSet() + + return CustomReactionState( + target = target.value, + selectedEmoji = selectedEmoji, + recentEmojis = recentEmojis, + eventSink = ::handleEvent, + ) + } +} 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 0000000..1d3e3e8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.customreaction + +import io.element.android.emojibasebindings.EmojibaseStore +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet + +data class CustomReactionState( + val target: Target, + val selectedEmoji: ImmutableSet, + val recentEmojis: ImmutableList, + val eventSink: (CustomReactionEvents) -> Unit, +) { + sealed interface Target { + data object None : Target + data class Loading(val event: TimelineItem.Event) : Target + data class Success( + val event: TimelineItem.Event, + val emojibaseStore: EmojibaseStore, + ) : Target + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt new file mode 100644 index 0000000..629eaa7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.customreaction + +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.Row +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.ripple +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.Color +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.compound.theme.ElementTheme +import io.element.android.emojibasebindings.Emoji +import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.toDp +import io.element.android.libraries.designsystem.theme.components.Text +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun EmojiItem( + item: Emoji, + isSelected: Boolean, + onSelectEmoji: (Emoji) -> Unit, + modifier: Modifier = Modifier, + emojiSize: TextUnit = 20.sp, +) { + val backgroundColor = if (isSelected) { + ElementTheme.colors.bgActionPrimaryRest + } else { + Color.Transparent + } + val description = a11yReactionAction( + emoji = item.unicode, + userAlreadyReacted = isSelected, + ) + Box( + modifier = modifier + .sizeIn(minWidth = 40.dp, minHeight = 40.dp) + .background(backgroundColor, CircleShape) + .clickable( + enabled = true, + onClick = { onSelectEmoji(item) }, + indication = ripple(bounded = false, radius = emojiSize.toDp() / 2 + 10.dp), + interactionSource = remember { MutableInteractionSource() } + ) + .clearAndSetSemantics { + contentDescription = description + }, + contentAlignment = Alignment.Center + ) { + Text( + text = item.unicode, + style = LocalTextStyle.current.copy(fontSize = emojiSize), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun EmojiItemPreview() = ElementPreview { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (isSelected in listOf(true, false)) { + EmojiItem( + item = Emoji( + hexcode = "", + label = "", + tags = null, + shortcodes = persistentListOf(), + unicode = "👍", + skins = null + ), + isSelected = isSelected, + onSelectEmoji = {}, + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojibaseExtensions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojibaseExtensions.kt new file mode 100644 index 0000000..0f967cc --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojibaseExtensions.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.customreaction + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.EmojiEvents +import androidx.compose.material.icons.outlined.EmojiFlags +import androidx.compose.material.icons.outlined.EmojiFoodBeverage +import androidx.compose.material.icons.outlined.EmojiNature +import androidx.compose.material.icons.outlined.EmojiObjects +import androidx.compose.material.icons.outlined.EmojiPeople +import androidx.compose.material.icons.outlined.EmojiSymbols +import androidx.compose.material.icons.outlined.EmojiTransportation +import androidx.compose.ui.graphics.vector.ImageVector +import io.element.android.emojibasebindings.EmojibaseCategory +import io.element.android.features.messages.impl.R + +@get:StringRes +val EmojibaseCategory.title: Int + get() = when (this) { + EmojibaseCategory.People -> R.string.emoji_picker_category_people + EmojibaseCategory.Nature -> R.string.emoji_picker_category_nature + EmojibaseCategory.Foods -> R.string.emoji_picker_category_foods + EmojibaseCategory.Activity -> R.string.emoji_picker_category_activity + EmojibaseCategory.Places -> R.string.emoji_picker_category_places + EmojibaseCategory.Objects -> R.string.emoji_picker_category_objects + EmojibaseCategory.Symbols -> R.string.emoji_picker_category_symbols + EmojibaseCategory.Flags -> R.string.emoji_picker_category_flags + } + +val EmojibaseCategory.icon: ImageVector + get() = when (this) { + EmojibaseCategory.People -> Icons.Outlined.EmojiPeople + EmojibaseCategory.Nature -> Icons.Outlined.EmojiNature + EmojibaseCategory.Foods -> Icons.Outlined.EmojiFoodBeverage + EmojibaseCategory.Activity -> Icons.Outlined.EmojiEvents + EmojibaseCategory.Places -> Icons.Outlined.EmojiTransportation + EmojibaseCategory.Objects -> Icons.Outlined.EmojiObjects + EmojibaseCategory.Symbols -> Icons.Outlined.EmojiSymbols + EmojibaseCategory.Flags -> Icons.Outlined.EmojiFlags + } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPicker.kt new file mode 100644 index 0000000..70d962f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPicker.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.customreaction.picker + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.Tab +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.emojibasebindings.Emoji +import io.element.android.features.messages.impl.timeline.components.customreaction.EmojiItem +import io.element.android.features.messages.impl.timeline.components.customreaction.icon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.toSp +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.SearchBar +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EmojiPicker( + onSelectEmoji: (Emoji) -> Unit, + state: EmojiPickerState, + selectedEmojis: ImmutableSet, + modifier: Modifier = Modifier, +) { + val coroutineScope = rememberCoroutineScope() + val pagerState = rememberPagerState(pageCount = { state.categories.size }) + Column(modifier) { + SearchBar( + modifier = Modifier.padding(bottom = 10.dp), + query = state.searchQuery, + onQueryChange = { state.eventSink(EmojiPickerEvents.UpdateSearchQuery(it)) }, + resultState = state.searchResults, + active = state.isSearchActive, + onActiveChange = { state.eventSink(EmojiPickerEvents.ToggleSearchActive(it)) }, + windowInsets = WindowInsets(0, 0, 0, 0), + placeHolderTitle = stringResource(CommonStrings.emoji_picker_search_placeholder), + ) { emojis -> + EmojiResults( + emojis = emojis, + isEmojiSelected = { selectedEmojis.contains(it.unicode) }, + onSelectEmoji = onSelectEmoji, + ) + } + + if (!state.isSearchActive) { + SecondaryTabRow( + selectedTabIndex = pagerState.currentPage, + ) { + state.categories.forEachIndexed { index, category -> + Tab( + icon = { + when (category.icon) { + is IconSource.Resource -> Icon( + resourceId = category.icon.id, + contentDescription = stringResource(id = category.titleId) + ) + is IconSource.Vector -> Icon( + imageVector = category.icon.vector, + contentDescription = stringResource(id = category.titleId) + ) + } + }, + selected = pagerState.currentPage == index, + onClick = { + coroutineScope.launch { pagerState.animateScrollToPage(index) } + } + ) + } + } + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + ) { index -> + val emojis = state.categories[index].emojis + EmojiResults( + emojis = emojis, + isEmojiSelected = { selectedEmojis.contains(it.unicode) }, + onSelectEmoji = onSelectEmoji, + ) + } + } + } +} + +@Composable +private fun EmojiResults( + emojis: ImmutableList, + isEmojiSelected: (Emoji) -> Boolean, + onSelectEmoji: (Emoji) -> Unit, +) { + LazyVerticalGrid( + modifier = Modifier.fillMaxSize(), + columns = GridCells.Adaptive(minSize = 48.dp), + contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + items(emojis, key = { it.unicode }) { item -> + EmojiItem( + modifier = Modifier.aspectRatio(1f), + item = item, + isSelected = isEmojiSelected(item), + onSelectEmoji = onSelectEmoji, + emojiSize = 32.dp.toSp(), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun EmojiPickerPreview(@PreviewParameter(EmojiPickerStateProvider::class) state: EmojiPickerState) = ElementPreview { + EmojiPicker( + onSelectEmoji = {}, + state = state, + selectedEmojis = persistentSetOf("😀", "😄", "😃"), + modifier = Modifier.fillMaxWidth(), + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerEvents.kt new file mode 100644 index 0000000..d0c4907 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.customreaction.picker + +sealed interface EmojiPickerEvents { + data class ToggleSearchActive(val isActive: Boolean) : EmojiPickerEvents + data class UpdateSearchQuery(val query: String) : EmojiPickerEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerPresenter.kt new file mode 100644 index 0000000..aed5684 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerPresenter.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.customreaction.picker + +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.platform.LocalInspectionMode +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.emojibasebindings.Emoji +import io.element.android.emojibasebindings.EmojibaseStore +import io.element.android.features.messages.impl.R +import io.element.android.features.messages.impl.timeline.components.customreaction.icon +import io.element.android.features.messages.impl.timeline.components.customreaction.title +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds + +class EmojiPickerPresenter( + private val emojibaseStore: EmojibaseStore, + private val recentEmojis: ImmutableList, + private val coroutineDispatchers: CoroutineDispatchers, +) : Presenter { + @Composable + override fun present(): EmojiPickerState { + var searchQuery by remember { mutableStateOf("") } + var isSearchActive by remember { mutableStateOf(false) } + var emojiResults by remember { mutableStateOf>>(SearchBarResultState.Initial()) } + + val recentEmojiIcon = CompoundIcons.History() + val categories = remember { + val providedCategories = emojibaseStore.categories.map { (category, emojis) -> + EmojiCategory( + titleId = category.title, + icon = IconSource.Vector(category.icon), + emojis = emojis + ) + } + if (recentEmojis.isNotEmpty()) { + val recentEmojis = recentEmojis.mapNotNull { recentEmoji -> + emojibaseStore.allEmojis.find { it.unicode == recentEmoji } + }.toImmutableList() + val recentCategory = + EmojiCategory( + titleId = R.string.emoji_picker_category_recent, + icon = IconSource.Vector(recentEmojiIcon), + emojis = recentEmojis + ) + (listOf(recentCategory) + providedCategories).toImmutableList() + } else { + providedCategories.toImmutableList() + } + } + + LaunchedEffect(searchQuery) { + emojiResults = if (searchQuery.isEmpty()) { + SearchBarResultState.Initial() + } else { + // Add a small delay to avoid doing too many computations when the user is typing quickly + delay(100.milliseconds) + + val lowercaseQuery = searchQuery.lowercase() + val results = withContext(coroutineDispatchers.computation) { + emojibaseStore.allEmojis + .asSequence() + .filter { emoji -> + emoji.tags.orEmpty().any { it.contains(lowercaseQuery) } || + emoji.shortcodes.any { it.contains(lowercaseQuery) } + } + .take(60) + .toImmutableList() + } + + SearchBarResultState.Results(results) + } + } + + val isInPreview = LocalInspectionMode.current + fun handleEvent(event: EmojiPickerEvents) { + when (event) { + // For some reason, in preview mode the SearchBar emits this event with an `isActive = true` value automatically + is EmojiPickerEvents.ToggleSearchActive -> if (!isInPreview) { + isSearchActive = event.isActive + } + is EmojiPickerEvents.UpdateSearchQuery -> searchQuery = event.query + } + } + + return EmojiPickerState( + categories = categories, + allEmojis = emojibaseStore.allEmojis, + searchQuery = searchQuery, + isSearchActive = isSearchActive, + searchResults = emojiResults, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerState.kt new file mode 100644 index 0000000..b050c34 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerState.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.customreaction.picker + +import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable +import io.element.android.emojibasebindings.Emoji +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import kotlinx.collections.immutable.ImmutableList + +// Emoji is unstable (because from an external library?), so we annotate with @Immutable +@Immutable +data class EmojiPickerState( + val categories: ImmutableList, + val allEmojis: ImmutableList, + val searchQuery: String, + val isSearchActive: Boolean, + val searchResults: SearchBarResultState>, + val eventSink: (EmojiPickerEvents) -> Unit, +) + +/** + * Represents a category of emojis with a title id, icon, and the list of associated emojis. + */ +data class EmojiCategory( + @StringRes val titleId: Int, + val icon: IconSource, + val emojis: ImmutableList, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerStateProvider.kt new file mode 100644 index 0000000..e26941e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerStateProvider.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.customreaction.picker + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.emojibasebindings.Emoji +import io.element.android.emojibasebindings.EmojibaseCategory +import io.element.android.features.messages.impl.R +import io.element.android.features.messages.impl.timeline.components.customreaction.icon +import io.element.android.features.messages.impl.timeline.components.customreaction.title +import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +class EmojiPickerStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anEmojiPickerState(), + anEmojiPickerState(isSearchActive = true), + anEmojiPickerState(isSearchActive = true, searchQuery = "smile"), + anEmojiPickerState( + isSearchActive = true, + searchQuery = "smile", + searchResults = SearchBarResultState.Results(emojiList()) + ), + ) +} + +private fun recentEmojisCategory() = EmojiCategory( + titleId = R.string.emoji_picker_category_recent, + icon = IconSource.Resource(CompoundDrawables.ic_compound_history), + emojis = emojiList(), +) + +private fun emojiList(): ImmutableList = persistentListOf( + Emoji( + "0x00", + "grinning face", + persistentListOf("grinning"), + persistentListOf("smile, grin"), + "😀", + null + ), + Emoji( + "0x01", + "crying face", + persistentListOf("crying"), + persistentListOf("smile, crying"), + "\uD83E\uDD72", + null + ) +) + +internal fun anEmojiPickerState( + categories: ImmutableList = (listOf(recentEmojisCategory()) + EmojibaseCategory.entries.map { + EmojiCategory( + titleId = it.title, + icon = IconSource.Vector(it.icon), + emojis = emojiList(), + ) + }).toImmutableList(), + allEmojis: ImmutableList = categories.flatMap { it.emojis }.toImmutableList(), + searchQuery: String = "", + isSearchActive: Boolean = false, + searchResults: SearchBarResultState> = SearchBarResultState.Initial(), + eventSink: (EmojiPickerEvents) -> Unit = {}, +) = EmojiPickerState( + categories = categories, + allEmojis = allEmojis, + searchQuery = searchQuery, + isSearchActive = isSearchActive, + searchResults = searchResults, + eventSink = eventSink, +) 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 0000000..f25957e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAspectRatioBox.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 + +const val MIN_HEIGHT_IN_DP = 100 +const val MAX_HEIGHT_IN_DP = 360 +const val DEFAULT_ASPECT_RATIO = 1.33f + +@Composable +fun TimelineItemAspectRatioBox( + aspectRatio: Float?, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, + minHeight: Int = MIN_HEIGHT_IN_DP, + maxHeight: Int = MAX_HEIGHT_IN_DP, + content: @Composable (BoxScope.() -> Unit), +) { + val safeAspectRatio = aspectRatio ?: DEFAULT_ASPECT_RATIO + Box( + modifier = modifier + .heightIn(min = minHeight.dp, max = maxHeight.dp) + .aspectRatio(safeAspectRatio, false), + contentAlignment = contentAlignment, + content = content + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt new file mode 100644 index 0000000..061886c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentView.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.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.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.libraries.designsystem.theme.components.Text + +/** + * package-private, you should only use TimelineItemFileView and TimelineItemAudioView. + */ +@Composable +fun TimelineItemAttachmentView( + filename: String, + fileExtensionAndSize: String, + caption: String?, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier, + icon: (@Composable () -> Unit) = {}, +) { + Column( + modifier = modifier, + ) { + TimelineItemAttachmentHeaderView( + filename = filename, + fileExtensionAndSize = fileExtensionAndSize, + hasCaption = caption != null, + onContentLayoutChange = onContentLayoutChange, + icon = icon, + ) + if (caption != null) { + TimelineItemAttachmentCaptionView( + modifier = Modifier.padding(top = 4.dp), + caption = caption, + onContentLayoutChange = onContentLayoutChange, + ) + } + } +} + +@Composable +private fun TimelineItemAttachmentHeaderView( + filename: String, + fileExtensionAndSize: String, + hasCaption: Boolean, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier, + icon: (@Composable () -> Unit), +) { + val iconSize = 32.dp + val spacing = 8.dp + Row( + modifier = modifier, + ) { + Box( + modifier = Modifier + .size(iconSize) + .clip(CircleShape) + .background(ElementTheme.colors.bgCanvasDefault), + contentAlignment = Alignment.Center, + ) { + icon() + } + Spacer(Modifier.width(spacing)) + Column { + Text( + text = filename, + color = ElementTheme.colors.textPrimary, + maxLines = 2, + style = ElementTheme.typography.fontBodyLgRegular, + overflow = TextOverflow.Ellipsis + ) + Text( + text = fileExtensionAndSize, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmRegular, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + onTextLayout = if (hasCaption) { + {} + } else { + ContentAvoidingLayout.measureLastTextLine( + onContentLayoutChange = onContentLayoutChange, + extraWidth = iconSize + spacing + ) + }, + ) + } + } +} + +@Composable +private fun TimelineItemAttachmentCaptionView( + caption: String, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier, +) { + Text( + modifier = modifier, + text = caption, + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyLgRegular, + onTextLayout = ContentAvoidingLayout.measureLastTextLine( + onContentLayoutChange = onContentLayoutChange, + ) + ) +} 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 0000000..d691575 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.foundation.layout.size +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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon + +@Composable +fun TimelineItemAudioView( + content: TimelineItemAudioContent, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier, +) { + TimelineItemAttachmentView( + filename = content.filename, + fileExtensionAndSize = content.fileExtensionAndSize, + caption = content.caption, + onContentLayoutChange = onContentLayoutChange, + modifier = modifier, + icon = { + Icon( + imageVector = CompoundIcons.Audio(), + contentDescription = null, + tint = ElementTheme.colors.iconPrimary, + modifier = Modifier + .size(16.dp), + ) + } + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemAudioViewPreview(@PreviewParameter(TimelineItemAudioContentProvider::class) content: TimelineItemAudioContent) = + ElementPreview { + TimelineItemAudioView( + content, + onContentLayoutChange = {}, + ) + } 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 0000000..48b3acf --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContentProvider +import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import io.element.android.libraries.matrix.api.timeline.item.event.UtdCause +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun TimelineItemEncryptedView( + content: TimelineItemEncryptedContent, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier +) { + val (textId, iconId) = when (content.data) { + is UnableToDecryptContent.Data.MegolmV1AesSha2 -> { + when (content.data.utdCause) { + UtdCause.SentBeforeWeJoined -> { + CommonStrings.common_unable_to_decrypt_no_access to CompoundDrawables.ic_compound_block + } + UtdCause.VerificationViolation -> { + CommonStrings.common_unable_to_decrypt_verification_violation to CompoundDrawables.ic_compound_block + } + UtdCause.UnsignedDevice, + UtdCause.UnknownDevice -> { + CommonStrings.common_unable_to_decrypt_insecure_device to CompoundDrawables.ic_compound_block + } + UtdCause.HistoricalMessageAndBackupIsDisabled -> { + CommonStrings.timeline_decryption_failure_historical_event_no_key_backup to CompoundDrawables.ic_compound_block + } + UtdCause.HistoricalMessageAndDeviceIsUnverified -> { + CommonStrings.timeline_decryption_failure_historical_event_unverified_device to CompoundDrawables.ic_compound_block + } + UtdCause.WithheldUnverifiedOrInsecureDevice -> { + CommonStrings.timeline_decryption_failure_withheld_unverified to CompoundDrawables.ic_compound_block + } + UtdCause.WithheldBySender -> { + CommonStrings.timeline_decryption_failure_unable_to_decrypt to CompoundDrawables.ic_compound_error + } + else -> { + CommonStrings.common_waiting_for_decryption_key to CompoundDrawables.ic_compound_time + } + } + } + else -> { + // Should not happen, we only supports megolm in rooms + CommonStrings.common_waiting_for_decryption_key to CompoundDrawables.ic_compound_time + } + } + TimelineItemInformativeView( + text = stringResource(id = textId), + iconDescription = stringResource(id = CommonStrings.dialog_title_warning), + iconResourceId = iconId, + onContentLayoutChange = onContentLayoutChange, + modifier = modifier + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemEncryptedViewPreview( + @PreviewParameter(TimelineItemEncryptedContentProvider::class) content: TimelineItemEncryptedContent +) = ElementPreview { + TimelineItemEncryptedView( + content = content, + onContentLayoutChange = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt new file mode 100644 index 0000000..73cd1ad --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.features.messages.impl.timeline.TimelineEvents +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories +import io.element.android.features.messages.impl.timeline.di.rememberPresenter +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.TimelineItemLegacyCallInviteContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent +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.timeline.model.event.TimelineItemVoiceContent +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.voiceplayer.api.VoiceMessageState +import io.element.android.wysiwyg.link.Link + +@Composable +fun TimelineItemEventContentView( + content: TimelineItemEventContent, + hideMediaContent: Boolean, + onContentClick: (() -> Unit)?, + onLongClick: (() -> Unit)?, + onShowContentClick: () -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, + eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, + modifier: Modifier = Modifier, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, +) { + val presenterFactories = LocalTimelineItemPresenterFactories.current + when (content) { + is TimelineItemEncryptedContent -> TimelineItemEncryptedView( + content = content, + onContentLayoutChange = onContentLayoutChange, + modifier = modifier + ) + is TimelineItemRedactedContent -> TimelineItemRedactedView( + content = content, + onContentLayoutChange = onContentLayoutChange, + modifier = modifier + ) + is TimelineItemTextBasedContent -> TimelineItemTextView( + content = content, + modifier = modifier, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onContentLayoutChange = onContentLayoutChange + ) + is TimelineItemUnknownContent -> TimelineItemUnknownView( + content = content, + onContentLayoutChange = onContentLayoutChange, + modifier = modifier + ) + is TimelineItemLocationContent -> TimelineItemLocationView( + content = content, + modifier = modifier + ) + is TimelineItemImageContent -> TimelineItemImageView( + content = content, + hideMediaContent = hideMediaContent, + onContentClick = onContentClick, + onLongClick = onLongClick, + onShowContentClick = onShowContentClick, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onContentLayoutChange = onContentLayoutChange, + modifier = modifier, + ) + is TimelineItemStickerContent -> TimelineItemStickerView( + content = content, + hideMediaContent = hideMediaContent, + onContentClick = onContentClick, + onLongClick = onLongClick, + onShowClick = onShowContentClick, + modifier = modifier, + ) + is TimelineItemVideoContent -> TimelineItemVideoView( + content = content, + hideMediaContent = hideMediaContent, + onContentClick = onContentClick, + onLongClick = onLongClick, + onShowContentClick = onShowContentClick, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onContentLayoutChange = onContentLayoutChange, + modifier = modifier + ) + is TimelineItemFileContent -> TimelineItemFileView( + content = content, + onContentLayoutChange = onContentLayoutChange, + modifier = modifier + ) + is TimelineItemAudioContent -> TimelineItemAudioView( + content = content, + onContentLayoutChange = onContentLayoutChange, + modifier = modifier + ) + is TimelineItemLegacyCallInviteContent -> TimelineItemLegacyCallInviteView(modifier = modifier) + is TimelineItemStateContent -> TimelineItemStateView( + content = content, + modifier = modifier + ) + is TimelineItemPollContent -> TimelineItemPollView( + content = content, + eventSink = eventSink, + modifier = modifier, + ) + is TimelineItemVoiceContent -> { + val presenter: Presenter = presenterFactories.rememberPresenter(content) + TimelineItemVoiceView( + state = presenter.present(), + content = content, + onContentLayoutChange = onContentLayoutChange, + modifier = modifier + ) + } + is TimelineItemRtcNotificationContent -> error("This shouldn't be rendered as the content of a bubble") + } +} 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 0000000..293e1c8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +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.icons.CompoundDrawables +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun TimelineItemFileView( + content: TimelineItemFileContent, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier, +) { + TimelineItemAttachmentView( + filename = content.filename, + fileExtensionAndSize = content.fileExtensionAndSize, + caption = content.caption, + onContentLayoutChange = onContentLayoutChange, + modifier = modifier, + icon = { + Icon( + resourceId = CompoundDrawables.ic_compound_attachment, + contentDescription = stringResource(CommonStrings.common_file), + tint = ElementTheme.colors.iconPrimary, + modifier = Modifier + .size(16.dp) + .rotate(-45f), + ) + } + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemFileViewPreview(@PreviewParameter(TimelineItemFileContentProvider::class) content: TimelineItemFileContent) = ElementPreview { + TimelineItemFileView( + content, + onContentLayoutChange = {}, + ) +} 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 0000000..a8cbb89 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import android.text.SpannedString +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +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.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition +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.features.messages.impl.timeline.model.event.aTimelineItemImageContent +import io.element.android.features.messages.impl.timeline.protection.ProtectedView +import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent +import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.libraries.ui.utils.time.isTalkbackActive +import io.element.android.wysiwyg.compose.EditorStyledText +import io.element.android.wysiwyg.link.Link + +@Composable +fun TimelineItemImageView( + content: TimelineItemImageContent, + hideMediaContent: Boolean, + onContentClick: (() -> Unit)?, + onLongClick: (() -> Unit)?, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, + onShowContentClick: () -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier, +) { + val a11yLabel = stringResource(CommonStrings.common_image) + val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel + Column(modifier = modifier) { + val containerModifier = if (content.showCaption) { + Modifier.clip(RoundedCornerShape(10.dp)) + } else { + Modifier + } + TimelineItemAspectRatioBox( + modifier = containerModifier.blurHashBackground(content.blurhash, alpha = 0.9f), + aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent), + ) { + ProtectedView( + hideContent = hideMediaContent, + onShowClick = onShowContentClick, + ) { + var isLoaded by remember { mutableStateOf(false) } + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .then(if (isLoaded) Modifier.background(Color.White) else Modifier) + .then( + if (!isTalkbackActive() && onContentClick != null) { + Modifier + .combinedClickable( + onClick = onContentClick, + onLongClick = onLongClick, + ) + .onKeyboardContextMenuAction(onLongClick) + } else { + Modifier + } + ), + model = content.thumbnailMediaRequestData, + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + contentDescription = description, + onState = { isLoaded = it is AsyncImagePainter.State.Success }, + ) + } + } + + if (content.showCaption) { + Spacer(modifier = Modifier.height(8.dp)) + val caption = if (LocalInspectionMode.current) { + SpannedString(content.caption) + } else { + content.formattedCaption ?: SpannedString(content.caption) + } + CompositionLocalProvider( + LocalContentColor provides ElementTheme.colors.textPrimary, + LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular + ) { + val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO + EditorStyledText( + modifier = Modifier + .padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout + .widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio), + text = caption, + style = ElementRichTextEditorStyle.textStyle(), + onLinkClickedListener = onLinkClick, + onLinkLongClickedListener = onLinkLongClick, + releaseOnDetach = false, + onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange), + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemImageViewPreview(@PreviewParameter(TimelineItemImageContentProvider::class) content: TimelineItemImageContent) = ElementPreview { + TimelineItemImageView( + content = content, + hideMediaContent = false, + onShowContentClick = {}, + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemImageViewHideMediaContentPreview() = ElementPreview { + TimelineItemImageView( + content = aTimelineItemImageContent(), + hideMediaContent = true, + onShowContentClick = {}, + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineImageWithCaptionRowPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemImageContent( + filename = "image.jpg", + caption = "A long caption that may wrap into several lines", + aspectRatio = 2.5f, + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = false, + content = aTimelineItemImageContent( + filename = "image.jpg", + caption = "Image with null aspectRatio", + aspectRatio = null, + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } +} 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 0000000..41a950e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +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.layout.onSizeChanged +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun TimelineItemInformativeView( + text: String, + iconDescription: String, + @DrawableRes iconResourceId: Int, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.onSizeChanged { size -> + onContentLayoutChange( + ContentAvoidingLayoutData( + contentWidth = size.width, + contentHeight = size.height, + ) + ) + }, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.height(20.dp) + ) { + Icon( + resourceId = iconResourceId, + tint = ElementTheme.colors.iconSecondary, + contentDescription = iconDescription, + modifier = Modifier.size(16.dp) + ) + } + Spacer(modifier = Modifier.width(4.dp)) + Text( + fontStyle = FontStyle.Italic, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdRegular, + text = text + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemInformativeViewPreview() = ElementPreview { + TimelineItemInformativeView( + text = "Info", + iconDescription = "", + iconResourceId = CompoundDrawables.ic_compound_delete, + onContentLayoutChange = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLegacyCallInviteView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLegacyCallInviteView.kt new file mode 100644 index 0000000..d20892a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLegacyCallInviteView.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.width +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.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.R +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun TimelineItemLegacyCallInviteView( + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + ) { + Icon( + imageVector = CompoundIcons.VoiceCallSolid(), + contentDescription = null, + tint = ElementTheme.colors.iconSecondary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdRegular, + text = stringResource(R.string.screen_room_timeline_legacy_call), + textAlign = TextAlign.Start, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemLegacyCallInviteViewPreview() = ElementPreview { + TimelineItemLegacyCallInviteView() +} 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 0000000..9ebe35a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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 + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) = + ElementPreview { + TimelineItemLocationView( + content = content, + ) + } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt new file mode 100644 index 0000000..ba72c06 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.messages.impl.timeline.TimelineEvents +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContentProvider +import io.element.android.features.poll.api.pollcontent.PollContentView +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.core.EventId +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun TimelineItemPollView( + content: TimelineItemPollContent, + eventSink: (TimelineEvents.TimelineItemPollEvents) -> Unit, + modifier: Modifier = Modifier, +) { + fun onSelectAnswer(pollStartId: EventId, answerId: String) { + eventSink(TimelineEvents.SelectPollAnswer(pollStartId, answerId)) + } + + fun onEndPoll(pollStartId: EventId) { + eventSink(TimelineEvents.EndPoll(pollStartId)) + } + + fun onEditPoll(pollStartId: EventId) { + eventSink(TimelineEvents.EditPoll(pollStartId)) + } + + PollContentView( + eventId = content.eventId, + question = content.question, + answerItems = content.answerItems.toImmutableList(), + pollKind = content.pollKind, + isPollEnded = content.isEnded, + isPollEditable = content.isEditable, + isMine = content.isMine, + onSelectAnswer = ::onSelectAnswer, + onEditPoll = ::onEditPoll, + onEndPoll = ::onEndPoll, + modifier = modifier, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemPollViewPreview(@PreviewParameter(TimelineItemPollContentProvider::class) content: TimelineItemPollContent) = + ElementPreview { + TimelineItemPollView( + content = content, + eventSink = {}, + ) + } 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 0000000..e32de7f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemRedactedView.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent +import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun TimelineItemRedactedView( + @Suppress("UNUSED_PARAMETER") content: TimelineItemRedactedContent, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier +) { + TimelineItemInformativeView( + text = stringResource(id = CommonStrings.common_message_removed), + iconDescription = stringResource(id = CommonStrings.common_message_removed), + iconResourceId = CompoundDrawables.ic_compound_delete, + onContentLayoutChange = onContentLayoutChange, + modifier = modifier + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemRedactedViewPreview() = ElementPreview { + TimelineItemRedactedView( + TimelineItemRedactedContent, + onContentLayoutChange = {}, + ) +} 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 0000000..4eaf932 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStateView.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import io.element.android.compound.theme.ElementTheme +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun TimelineItemStateView( + content: TimelineItemStateContent, + modifier: Modifier = Modifier +) { + Text( + modifier = modifier, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdRegular, + text = content.body, + textAlign = TextAlign.Center, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemStateViewPreview() = ElementPreview { + TimelineItemStateView( + content = aTimelineItemStateEventContent(), + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt new file mode 100644 index 0000000..104e420 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +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.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.PreviewParameter +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContentProvider +import io.element.android.features.messages.impl.timeline.protection.ProtectedView +import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent +import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.ui.strings.CommonStrings + +private const val STICKER_SIZE_IN_DP = 128 + +@Composable +fun TimelineItemStickerView( + content: TimelineItemStickerContent, + hideMediaContent: Boolean, + onContentClick: (() -> Unit)?, + onLongClick: (() -> Unit)?, + onShowClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val description = content.bestDescription.takeIf { it.isNotEmpty() } ?: stringResource(CommonStrings.common_image) + Column( + modifier = modifier.semantics { contentDescription = description }, + ) { + TimelineItemAspectRatioBox( + modifier = Modifier.blurHashBackground(content.blurhash, alpha = 0.9f), + aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent), + minHeight = STICKER_SIZE_IN_DP, + maxHeight = STICKER_SIZE_IN_DP, + ) { + ProtectedView( + hideContent = hideMediaContent, + onShowClick = onShowClick, + ) { + var isLoaded by remember { mutableStateOf(false) } + AsyncImage( + modifier = Modifier + .fillMaxSize() + .then(if (isLoaded) Modifier.background(Color.White) else Modifier) + .then( + if (onContentClick != null) { + Modifier + .combinedClickable( + onClick = onContentClick, + onLongClick = onLongClick, + onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), + ) + .onKeyboardContextMenuAction(onLongClick) + } else { + Modifier + } + ), + model = MediaRequestData( + source = content.preferredMediaSource, + kind = MediaRequestData.Kind.File( + fileName = content.filename, + mimeType = content.mimeType, + ), + ), + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + contentDescription = description, + onState = { isLoaded = it is AsyncImagePainter.State.Success }, + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemStickerViewPreview(@PreviewParameter(TimelineItemStickerContentProvider::class) content: TimelineItemStickerContent) = ElementPreview { + TimelineItemStickerView( + content = content, + hideMediaContent = false, + onContentClick = {}, + onLongClick = {}, + onShowClick = {}, + ) +} 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 0000000..0449ef2 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import android.text.SpannedString +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +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.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.utils.containsOnlyEmojis +import io.element.android.libraries.androidutils.text.LinkifyHelper +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle +import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater +import io.element.android.wysiwyg.compose.EditorStyledText +import io.element.android.wysiwyg.link.Link + +@Composable +fun TimelineItemTextView( + content: TimelineItemTextBasedContent, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, + modifier: Modifier = Modifier, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, +) { + val emojiOnly = content.formattedBody.toString() == content.body && + content.body.replace(" ", "").containsOnlyEmojis() + val textStyle = when { + emojiOnly -> ElementTheme.typography.fontHeadingXlRegular + else -> ElementTheme.typography.fontBodyLgRegular + } + CompositionLocalProvider( + LocalContentColor provides ElementTheme.colors.textPrimary, + LocalTextStyle provides textStyle + ) { + val text = getTextWithResolvedMentions(content) + Box(modifier.semantics { contentDescription = content.plainText }) { + EditorStyledText( + text = text, + onLinkClickedListener = onLinkClick, + onLinkLongClickedListener = onLinkLongClick, + style = ElementRichTextEditorStyle.textStyle(), + onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange), + releaseOnDetach = false, + ) + } + } +} + +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +@Composable +internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent): CharSequence { + val mentionSpanUpdater = LocalMentionSpanUpdater.current + val bodyWithResolvedMentions = mentionSpanUpdater.rememberMentionSpans(content.formattedBody) + return SpannedString.valueOf(bodyWithResolvedMentions) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemTextViewPreview( + @PreviewParameter(TimelineItemTextBasedContentProvider::class) content: TimelineItemTextBasedContent +) = ElementPreview { + TimelineItemTextView( + content = content, + onLinkClick = {}, + onLinkLongClick = {}, + ) +} + +@Preview +@Composable +internal fun TimelineItemTextViewWithLinkifiedUrlPreview() = ElementPreview { + val content = aTimelineItemTextContent( + formattedBody = LinkifyHelper.linkify("The link should end after the first '?' (url: github.com/element-hq/element-x-android/README?)?.") + ) + TimelineItemTextView( + content = content, + onLinkClick = {}, + onLinkLongClick = {}, + ) +} + +@Preview +@Composable +internal fun TimelineItemTextViewWithLinkifiedUrlAndNestedParenthesisPreview() = ElementPreview { + val content = aTimelineItemTextContent( + formattedBody = LinkifyHelper.linkify("The link should end after the '(ME)' ((url: github.com/element-hq/element-x-android/READ(ME)))!") + ) + TimelineItemTextView( + content = content, + onLinkClick = {}, + onLinkLongClick = {}, + ) +} 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 0000000..fbbefca --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemUnknownView.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent +import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun TimelineItemUnknownView( + @Suppress("UNUSED_PARAMETER") content: TimelineItemUnknownContent, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier +) { + TimelineItemInformativeView( + text = stringResource(id = CommonStrings.common_unsupported_event), + iconDescription = stringResource(id = CommonStrings.dialog_title_warning), + iconResourceId = CompoundDrawables.ic_compound_info_solid, + onContentLayoutChange = onContentLayoutChange, + modifier = modifier + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemUnknownViewPreview() = ElementPreview { + TimelineItemUnknownView( + content = TimelineItemUnknownContent, + onContentLayoutChange = {}, + ) +} 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 0000000..f5e7607 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import android.text.SpannedString +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +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.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.components.ATimelineItemEventRow +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition +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.features.messages.impl.timeline.model.event.aTimelineItemVideoContent +import io.element.android.features.messages.impl.timeline.protection.ProtectedView +import io.element.android.features.messages.impl.timeline.protection.coerceRatioWhenHidingContent +import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction +import io.element.android.libraries.designsystem.modifiers.roundedBackground +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT +import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH +import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.libraries.ui.utils.time.isTalkbackActive +import io.element.android.wysiwyg.compose.EditorStyledText +import io.element.android.wysiwyg.link.Link + +@Composable +fun TimelineItemVideoView( + content: TimelineItemVideoContent, + hideMediaContent: Boolean, + onContentClick: (() -> Unit)?, + onLongClick: (() -> Unit)?, + onShowContentClick: () -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier, +) { + val isTalkbackActive = isTalkbackActive() + val a11yLabel = stringResource(CommonStrings.common_video) + val description = content.caption?.let { "$a11yLabel: $it" } ?: a11yLabel + Column(modifier = modifier) { + val containerModifier = if (content.showCaption) { + Modifier + .padding(top = 6.dp) + .clip(RoundedCornerShape(6.dp)) + } else { + Modifier + } + TimelineItemAspectRatioBox( + modifier = containerModifier.blurHashBackground(content.blurHash, alpha = 0.9f), + aspectRatio = coerceRatioWhenHidingContent(content.aspectRatio, hideMediaContent), + contentAlignment = Alignment.Center, + ) { + ProtectedView( + hideContent = hideMediaContent, + onShowClick = onShowContentClick, + ) { + var isLoaded by remember { mutableStateOf(false) } + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .then(if (isLoaded) Modifier.background(Color.White) else Modifier) + .then( + if (!isTalkbackActive && onContentClick != null) { + Modifier + .combinedClickable( + onClick = onContentClick, + onLongClick = onLongClick, + ) + .onKeyboardContextMenuAction(onLongClick) + } else { + Modifier + } + ), + model = MediaRequestData( + source = content.thumbnailSource, + kind = MediaRequestData.Kind.Thumbnail( + width = content.thumbnailWidth?.toLong() ?: MAX_THUMBNAIL_WIDTH, + height = content.thumbnailHeight?.toLong() ?: MAX_THUMBNAIL_HEIGHT, + ) + ), + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + contentDescription = description, + onState = { isLoaded = it is AsyncImagePainter.State.Success }, + ) + + Box( + modifier = Modifier.roundedBackground(), + contentAlignment = Alignment.Center, + ) { + Image( + imageVector = CompoundIcons.PlaySolid(), + contentDescription = stringResource(id = CommonStrings.a11y_play), + colorFilter = ColorFilter.tint(Color.White), + modifier = Modifier.semantics { hideFromAccessibility() } + ) + } + } + } + + if (content.showCaption) { + Spacer(modifier = Modifier.height(8.dp)) + val caption = if (LocalInspectionMode.current) { + SpannedString(content.caption) + } else { + content.formattedCaption ?: SpannedString(content.caption) + } + CompositionLocalProvider( + LocalContentColor provides ElementTheme.colors.textPrimary, + LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular, + ) { + val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO + EditorStyledText( + modifier = Modifier + .padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout + .widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio), + text = caption, + onLinkClickedListener = onLinkClick, + onLinkLongClickedListener = onLinkLongClick, + style = ElementRichTextEditorStyle.textStyle(), + releaseOnDetach = false, + onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange), + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemVideoViewPreview(@PreviewParameter(TimelineItemVideoContentProvider::class) content: TimelineItemVideoContent) = ElementPreview { + TimelineItemVideoView( + content = content, + hideMediaContent = false, + onShowContentClick = {}, + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemVideoViewHideMediaContentPreview() = ElementPreview { + TimelineItemVideoView( + content = aTimelineItemVideoContent(), + hideMediaContent = true, + onShowContentClick = {}, + onContentClick = {}, + onLongClick = {}, + onLinkClick = {}, + onLinkLongClick = {}, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineVideoWithCaptionRowPreview() = ElementPreview { + Column { + sequenceOf(false, true).forEach { isMine -> + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = isMine, + content = aTimelineItemVideoContent().copy( + filename = "video.mp4", + caption = "A long caption that may wrap into several lines", + aspectRatio = 2.5f, + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } + ATimelineItemEventRow( + event = aTimelineItemEvent( + isMine = false, + content = aTimelineItemVideoContent().copy( + filename = "video.mp4", + caption = "Video with null aspect ratio", + aspectRatio = null, + ), + groupPosition = TimelineItemGroupPosition.Last, + ), + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt new file mode 100644 index 0000000..a3f214f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt @@ -0,0 +1,298 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import androidx.compose.foundation.background +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.IconButtonDefaults +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.graphics.vector.ImageVector +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider +import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.Text +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.libraries.ui.utils.time.isTalkbackActive +import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents +import io.element.android.libraries.voiceplayer.api.VoiceMessageState +import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider +import kotlinx.coroutines.delay + +@Composable +fun TimelineItemVoiceView( + state: VoiceMessageState, + content: TimelineItemVoiceContent, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + modifier: Modifier = Modifier, +) { + fun playPause() { + state.eventSink(VoiceMessageEvents.PlayPause) + } + + val a11y = stringResource(CommonStrings.common_voice_message) + val a11yActionLabel = stringResource( + when (state.button) { + VoiceMessageState.Button.Play -> CommonStrings.a11y_play + VoiceMessageState.Button.Pause -> CommonStrings.a11y_pause + VoiceMessageState.Button.Downloading -> CommonStrings.common_downloading + VoiceMessageState.Button.Retry -> CommonStrings.action_retry + VoiceMessageState.Button.Disabled -> CommonStrings.error_unknown + } + ) + Row( + modifier = modifier + .clearAndSetSemantics { + contentDescription = a11y + if (state.button == VoiceMessageState.Button.Disabled) { + disabled() + } else if (state.button in listOf(VoiceMessageState.Button.Play, VoiceMessageState.Button.Pause)) { + onClick(label = a11yActionLabel) { + playPause() + true + } + } + } + .onSizeChanged { + onContentLayoutChange( + ContentAvoidingLayoutData( + contentWidth = it.width, + contentHeight = it.height, + ) + ) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + if (!isTalkbackActive()) { + when (state.button) { + VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause) + VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause) + VoiceMessageState.Button.Downloading -> ProgressButton() + VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause) + VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false) + } + } + Spacer(Modifier.width(8.dp)) + Text( + text = state.time, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(8.dp)) + WaveformPlaybackView( + showCursor = state.showCursor, + playbackProgress = state.progress, + waveform = content.waveform, + modifier = Modifier.height(34.dp), + seekEnabled = !isTalkbackActive(), + onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) }, + ) + } +} + +@Composable +private fun PlayButton( + onClick: () -> Unit, + enabled: Boolean = true, +) { + CustomIconButton( + onClick = onClick, + enabled = enabled, + ) { + ControlIcon( + imageVector = CompoundIcons.PlaySolid(), + contentDescription = stringResource(id = CommonStrings.a11y_play), + ) + } +} + +@Composable +private fun PauseButton( + onClick: () -> Unit, +) { + CustomIconButton( + onClick = onClick, + ) { + ControlIcon( + imageVector = CompoundIcons.PauseSolid(), + contentDescription = stringResource(id = CommonStrings.a11y_pause), + ) + } +} + +@Composable +private fun RetryButton( + onClick: () -> Unit, +) { + CustomIconButton( + onClick = onClick, + ) { + ControlIcon( + imageVector = CompoundIcons.Restart(), + contentDescription = stringResource(id = CommonStrings.action_retry), + ) + } +} + +@Composable +private fun ControlIcon( + imageVector: ImageVector, + contentDescription: String?, +) { + Icon( + modifier = Modifier.padding(vertical = 10.dp), + imageVector = imageVector, + contentDescription = contentDescription, + ) +} + +/** + * Progress button is shown when the voice message is being downloaded. + * + * The progress indicator is optimistic and displays a pause button (which + * indicates the audio is playing) for 2 seconds before revealing the + * actual progress indicator. + */ +@Composable +private fun ProgressButton( + displayImmediately: Boolean = false, +) { + var canDisplay by remember { mutableStateOf(displayImmediately) } + LaunchedEffect(Unit) { + delay(2000L) + canDisplay = true + } + CustomIconButton( + onClick = {}, + enabled = false, + ) { + if (canDisplay) { + CircularProgressIndicator( + modifier = Modifier + .padding(2.dp) + .size(16.dp), + color = ElementTheme.colors.iconSecondary, + strokeWidth = 2.dp, + ) + } else { + ControlIcon( + imageVector = CompoundIcons.PauseSolid(), + contentDescription = stringResource(id = CommonStrings.a11y_pause), + ) + } + } +} + +@Composable +private fun CustomIconButton( + onClick: () -> Unit, + enabled: Boolean = true, + content: @Composable () -> Unit, +) { + IconButton( + onClick = onClick, + modifier = Modifier + .background(color = ElementTheme.colors.bgCanvasDefault, shape = CircleShape) + .size(36.dp), + enabled = enabled, + colors = IconButtonDefaults.iconButtonColors( + contentColor = ElementTheme.colors.iconSecondary, + disabledContentColor = ElementTheme.colors.iconDisabled, + ), + content = content, + ) +} + +open class TimelineItemVoiceViewParametersProvider : PreviewParameterProvider { + private val voiceMessageStateProvider = VoiceMessageStateProvider() + private val timelineItemVoiceContentProvider = TimelineItemVoiceContentProvider() + override val values: Sequence + get() = timelineItemVoiceContentProvider.values.flatMap { content -> + voiceMessageStateProvider.values.map { state -> + TimelineItemVoiceViewParameters( + state = state, + content = content, + ) + } + } +} + +data class TimelineItemVoiceViewParameters( + val state: VoiceMessageState, + val content: TimelineItemVoiceContent, +) + +@PreviewsDayNight +@Composable +internal fun TimelineItemVoiceViewPreview( + @PreviewParameter(TimelineItemVoiceViewParametersProvider::class) timelineItemVoiceViewParameters: TimelineItemVoiceViewParameters, +) = ElementPreview { + TimelineItemVoiceView( + state = timelineItemVoiceViewParameters.state, + content = timelineItemVoiceViewParameters.content, + onContentLayoutChange = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemVoiceViewUnifiedPreview() = ElementPreview { + val timelineItemVoiceViewParametersProvider = TimelineItemVoiceViewParametersProvider() + Column { + timelineItemVoiceViewParametersProvider.values.forEach { + TimelineItemVoiceView( + state = it.state, + content = it.content, + onContentLayoutChange = {}, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun ProgressButtonPreview() = ElementPreview { + Row { + ProgressButton(displayImmediately = true) + ProgressButton(displayImmediately = false) + } +} 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 0000000..0c13214 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.group + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +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.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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 + +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() + .toggleable( + value = isExpanded, + onValueChange = { onClick() }, + role = Role.DropdownList, + ) + .clearAndSetSemantics { + contentDescription = text + }, + 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 = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdRegular, + ) + val rotation: Float by animateFloatAsState( + targetValue = if (isExpanded) 90f else 0f, + animationSpec = tween( + delayMillis = 0, + durationMillis = 300, + ), + label = "chevron" + ) + Icon( + modifier = Modifier.rotate(rotation), + imageVector = CompoundIcons.ChevronRight(), + contentDescription = null, + tint = ElementTheme.colors.iconSecondary + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun GroupHeaderViewPreview() = ElementPreview { + 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/layout/ContentAvoidingLayout.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt new file mode 100644 index 0000000..04974df --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.layout + +import android.text.Layout +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.text.roundToPx +import io.element.android.wysiwyg.compose.EditorStyledText +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * A layout with 2 children: the [content] and the [overlay]. + * + * It will try to place the [overlay] on top of the [content] if possible, avoiding the area of it that is non-overlapping. + * If the [overlay] can't be placed on top of the [content], it will be placed to the right of it, if it fits, otherwise, to its bottom in a new row. + * + * @param overlay The 'overlay' component of the layout, which will be positioned relative to the [content]. + * @param modifier The modifier for the layout. + * @param spacing The spacing between the [content] and the [overlay]. Defaults to `0.dp`. + * @param overlayOffset The offset of the [overlay] from the bottom right corner of the [content]. + * @param shrinkContent Whether the content should be shrunk to fit the available width or not. Defaults to `false`. + * @param content The 'content' component of the layout. + */ +@Composable +fun ContentAvoidingLayout( + overlay: @Composable () -> Unit, + modifier: Modifier = Modifier, + spacing: Dp = 0.dp, + overlayOffset: DpOffset = DpOffset.Zero, + shrinkContent: Boolean = false, + content: @Composable ContentAvoidingLayoutScope.() -> Unit, +) { + val scope = remember { ContentAvoidingLayoutScopeInstance() } + + Layout( + modifier = modifier, + content = { + scope.content() + overlay() + } + ) { measurables, constraints -> + + // Measure the `overlay` view first, in case we need to shrink the `content` + val overlayPlaceable = measurables.last().measure(Constraints(minWidth = 0, maxWidth = constraints.maxWidth)) + val contentConstraints = if (shrinkContent) { + Constraints(minWidth = 0, maxWidth = constraints.maxWidth - overlayPlaceable.width) + } else { + Constraints(minWidth = 0, maxWidth = constraints.maxWidth) + } + val contentPlaceable = measurables.first().measure(contentConstraints) + + var layoutWidth = contentPlaceable.width + var layoutHeight = contentPlaceable.height + + val data = scope.data.value + + // Free space = width of the whole component - width of its non overlapping contents + val freeSpace = max(contentPlaceable.width - data.nonOverlappingContentWidth, 0) + + when { + // When the content + the overlay don't fit in the available max width, we need to move the overlay to a new row + !shrinkContent && data.nonOverlappingContentWidth + overlayPlaceable.width > constraints.maxWidth -> { + layoutHeight += overlayPlaceable.height + overlayOffset.y.roundToPx() + } + // If the content is smaller than the available max width, we can move the overlay to the right of the content + contentPlaceable.width < constraints.maxWidth -> { + // If both the content and the overlay plus the padding can fit inside the current layoutWidth, there is no need to increase it + if (freeSpace < overlayPlaceable.width + spacing.roundToPx()) { + // Otherwise, we need to increase it by the width of the overlay + some padding adjustments + val calculatedWidth = max(data.nonOverlappingContentWidth + overlayPlaceable.width + spacing.roundToPx(), contentPlaceable.width) + layoutWidth = min(calculatedWidth, constraints.maxWidth) + } + } + else -> Unit + } + + layoutWidth = max(layoutWidth, constraints.minWidth) + layoutHeight = max(layoutHeight, constraints.minHeight) + + layout(layoutWidth, layoutHeight) { + contentPlaceable.placeRelative(0, 0) + overlayPlaceable.placeRelative(layoutWidth - overlayPlaceable.width, layoutHeight - overlayPlaceable.height + overlayOffset.y.roundToPx()) + } + } +} + +/** + * Data class to hold the content layout data. + * This is used to pass the data from the content to the [ContentAvoidingLayout]. + * + * @param contentWidth The full width of the content in pixels. + * @param contentHeight The full height of the content in pixels. + * @param nonOverlappingContentWidth The width of the part of the content that can't overlap with the timestamp. + * @param nonOverlappingContentHeight The height of the part of the content that can't overlap with the timestamp. + */ +data class ContentAvoidingLayoutData( + val contentWidth: Int = 0, + val contentHeight: Int = 0, + val nonOverlappingContentWidth: Int = contentWidth, + val nonOverlappingContentHeight: Int = contentHeight, +) + +/** + * A scope for the [ContentAvoidingLayout]. + */ +interface ContentAvoidingLayoutScope { + /** + * It should be called when the content layout changes, so it can update the [ContentAvoidingLayoutData] and measure and layout the content properly. + */ + fun onContentLayoutChange(data: ContentAvoidingLayoutData) +} + +private class ContentAvoidingLayoutScopeInstance( + val data: MutableState = mutableStateOf(ContentAvoidingLayoutData()), +) : ContentAvoidingLayoutScope { + override fun onContentLayoutChange(data: ContentAvoidingLayoutData) { + this.data.value = data + } +} + +object ContentAvoidingLayout { + /** + * Measures the last line of a [TextLayoutResult] and calls [onContentLayoutChange] with the [ContentAvoidingLayoutData]. + * + * This is supposed to be used in the `onTextLayout` parameter of a Text based component. + */ + @Composable + internal fun measureLastTextLine( + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + extraWidth: Dp = 0.dp, + ): ((TextLayoutResult) -> Unit) { + val layoutDirection = LocalLayoutDirection.current + val extraWidthPx = extraWidth.roundToPx() + return { textLayout: TextLayoutResult -> + // We need to add the external extra width so it's not taken into account as 'free space' + val lastLineWidth = when (layoutDirection) { + LayoutDirection.Ltr -> textLayout.getLineRight(textLayout.lineCount - 1).roundToInt() + LayoutDirection.Rtl -> textLayout.getLineLeft(textLayout.lineCount - 1).roundToInt() + } + val lastLineHeight = textLayout.getLineBottom(textLayout.lineCount - 1).roundToInt() + onContentLayoutChange( + ContentAvoidingLayoutData( + contentWidth = textLayout.size.width + extraWidthPx, + contentHeight = textLayout.size.height, + nonOverlappingContentWidth = lastLineWidth + extraWidthPx, + nonOverlappingContentHeight = lastLineHeight, + ) + ) + } + } + + /** + * Measures the last line of a [Layout] and calls [onContentLayoutChange] with the [ContentAvoidingLayoutData]. + * + * This is supposed to be used in the `onTextLayout` parameter of an [EditorStyledText] component. + */ + @Composable + internal fun measureLegacyLastTextLine( + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, + extraWidth: Dp = 0.dp, + ): ((Layout) -> Unit) { + val extraWidthPx = extraWidth.roundToPx() + return { textLayout: Layout -> + // We need to add the external extra width so it's not taken into account as 'free space' + val lastLineWidth = textLayout.getLineWidth(textLayout.lineCount - 1).roundToInt() + val lastLineHeight = textLayout.getLineBottom(textLayout.lineCount - 1) + onContentLayoutChange( + ContentAvoidingLayoutData( + contentWidth = textLayout.width + extraWidthPx, + contentHeight = textLayout.height, + nonOverlappingContentWidth = lastLineWidth + extraWidthPx, + nonOverlappingContentHeight = lastLineHeight, + ) + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryEvents.kt new file mode 100644 index 0000000..987b61e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryEvents.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.reactionsummary + +import io.element.android.features.messages.impl.timeline.model.AggregatedReaction +import io.element.android.libraries.matrix.api.core.EventId + +sealed interface ReactionSummaryEvents { + data object Clear : ReactionSummaryEvents + data class ShowReactionSummary(val eventId: EventId, val reactions: List, val selectedKey: String) : ReactionSummaryEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt new file mode 100644 index 0000000..a95fe57 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.reactionsummary + +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 dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.roomMembers +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Inject +class ReactionSummaryPresenter( + private val room: BaseRoom, +) : Presenter { + @Composable + override fun present(): ReactionSummaryState { + val membersState by room.membersStateFlow.collectAsState() + + val target: MutableState = remember { + mutableStateOf(null) + } + val targetWithAvatars = populateSenderAvatars(members = membersState.roomMembers().orEmpty().toImmutableList(), summary = target.value) + + fun handleEvent(event: ReactionSummaryEvents) { + when (event) { + is ReactionSummaryEvents.ShowReactionSummary -> target.value = ReactionSummaryState.Summary( + reactions = event.reactions.toImmutableList(), + selectedKey = event.selectedKey, + selectedEventId = event.eventId + ) + ReactionSummaryEvents.Clear -> target.value = null + } + } + return ReactionSummaryState( + target = targetWithAvatars.value, + eventSink = ::handleEvent, + ) + } + + @Composable + private fun populateSenderAvatars(members: ImmutableList, summary: ReactionSummaryState.Summary?) = remember(summary) { + derivedStateOf { + summary?.let { summary -> + summary.copy(reactions = summary.reactions.map { reaction -> + reaction.copy(senders = reaction.senders.map { sender -> + val member = members.firstOrNull { it.userId == sender.senderId } + val user = MatrixUser( + userId = sender.senderId, + displayName = member?.displayName, + avatarUrl = member?.avatarUrl + ) + sender.copy(user = user) + }.toImmutableList()) + }.toImmutableList()) + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryState.kt new file mode 100644 index 0000000..cf5342f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryState.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.reactionsummary + +import io.element.android.features.messages.impl.timeline.model.AggregatedReaction +import io.element.android.libraries.matrix.api.core.EventId +import kotlinx.collections.immutable.ImmutableList + +data class ReactionSummaryState( + val target: Summary?, + val eventSink: (ReactionSummaryEvents) -> Unit +) { + data class Summary( + val reactions: ImmutableList, + val selectedKey: String, + val selectedEventId: EventId + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryStateProvider.kt new file mode 100644 index 0000000..82974c8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryStateProvider.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.reactionsummary + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.timeline.aTimelineItemReactions +import io.element.android.libraries.matrix.api.core.EventId + +open class ReactionSummaryStateProvider : PreviewParameterProvider { + override val values = sequenceOf(aReactionSummaryState()) +} + +fun aReactionSummaryState(): ReactionSummaryState { + val reactions = aTimelineItemReactions(8, true).reactions + return ReactionSummaryState( + target = ReactionSummaryState.Summary( + reactions = reactions, + selectedKey = reactions[0].key, + selectedEventId = EventId("$1234"), + ), + eventSink = {} + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt new file mode 100644 index 0000000..944fd9a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt @@ -0,0 +1,296 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.reactionsummary + +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +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.runtime.setValue +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.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +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 coil3.compose.AsyncImage +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.messages.impl.timeline.a11y.a11yReactionDetails +import io.element.android.features.messages.impl.timeline.components.REACTION_IMAGE_ASPECT_RATIO +import io.element.android.features.messages.impl.timeline.model.AggregatedReaction +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.avatar.AvatarType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.toDp +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +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.media.MediaSource +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrix.ui.model.getAvatarData +import kotlinx.coroutines.launch + +internal val REACTION_SUMMARY_LINE_HEIGHT = 25.sp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReactionSummaryView( + state: ReactionSummaryState, + modifier: Modifier = Modifier, +) { + fun onDismiss() { + state.eventSink(ReactionSummaryEvents.Clear) + } + + if (state.target != null) { + ModalBottomSheet( + onDismissRequest = ::onDismiss, + modifier = modifier + ) { + ReactionSummaryViewContent(summary = state.target) + } + } +} + +@Composable +private fun ReactionSummaryViewContent( + summary: ReactionSummaryState.Summary, +) { + val animationScope = rememberCoroutineScope() + var selectedReactionKey: String by rememberSaveable { mutableStateOf(summary.selectedKey) } + val selectedReactionIndex: Int by remember { + derivedStateOf { + summary.reactions.indexOfFirst { it.key == selectedReactionKey } + } + } + val pagerState = rememberPagerState(initialPage = selectedReactionIndex, pageCount = { summary.reactions.size }) + val reactionListState = rememberLazyListState() + + LaunchedEffect(pagerState.currentPage) { + selectedReactionKey = summary.reactions[pagerState.currentPage].key + val visibleInfo = reactionListState.layoutInfo.visibleItemsInfo + if (selectedReactionIndex <= visibleInfo.first().index || selectedReactionIndex >= visibleInfo.last().index) { + reactionListState.animateScrollToItem(selectedReactionIndex) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + ) { + LazyRow( + state = reactionListState, + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(start = 12.dp, end = 12.dp, bottom = 12.dp) + ) { + items(summary.reactions) { reaction -> + AggregatedReactionButton( + reaction = reaction, + isHighlighted = selectedReactionKey == reaction.key, + onClick = { + selectedReactionKey = reaction.key + animationScope.launch { + pagerState.animateScrollToPage(selectedReactionIndex) + } + } + ) + } + } + HorizontalPager(state = pagerState) { page -> + LazyColumn(modifier = Modifier.fillMaxHeight()) { + items(summary.reactions[page].senders) { sender -> + val user = sender.user ?: MatrixUser(userId = sender.senderId) + SenderRow( + avatarData = user.getAvatarData(AvatarSize.UserListItem), + name = user.displayName ?: user.userId.value, + userId = user.userId.value, + sentTime = sender.sentTime + ) + } + } + } + } +} + +@Composable +private fun AggregatedReactionButton( + reaction: AggregatedReaction, + isHighlighted: Boolean, + onClick: () -> Unit, +) { + val buttonColor = if (isHighlighted) { + ElementTheme.colors.bgActionPrimaryRest + } else { + Color.Transparent + } + val textColor = if (isHighlighted) { + MaterialTheme.colorScheme.inversePrimary + } else { + ElementTheme.colors.textPrimary + } + val roundedCornerShape = RoundedCornerShape(corner = CornerSize(percent = 50)) + val a11yText = a11yReactionDetails( + emoji = reaction.key, + userAlreadyReacted = reaction.isHighlighted, + reactionCount = reaction.count, + ) + Surface( + modifier = Modifier + .background(buttonColor, roundedCornerShape) + .clip(roundedCornerShape) + .clickable(onClick = onClick) + .padding(vertical = 8.dp, horizontal = 12.dp) + .selectable( + selected = isHighlighted, + role = Role.Tab, + onClick = onClick, + ) + .clearAndSetSemantics { + contentDescription = a11yText + }, + color = buttonColor, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier, + ) { + // Check if this is a custom reaction (MSC4027) + if (reaction.key.startsWith("mxc://")) { + AsyncImage( + modifier = Modifier + .heightIn(min = REACTION_SUMMARY_LINE_HEIGHT.toDp(), max = REACTION_SUMMARY_LINE_HEIGHT.toDp()) + .aspectRatio(REACTION_IMAGE_ASPECT_RATIO, false), + model = MediaRequestData(MediaSource(reaction.key), MediaRequestData.Kind.Content), + contentDescription = null + ) + } else { + Text( + text = reaction.displayKey, + style = ElementTheme.typography.fontBodyMdRegular.copy( + fontSize = 20.sp, + lineHeight = REACTION_SUMMARY_LINE_HEIGHT + ), + ) + } + if (reaction.count > 1) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = reaction.count.toString(), + color = textColor, + style = ElementTheme.typography.fontBodyMdRegular.copy( + fontSize = 20.sp, + lineHeight = REACTION_SUMMARY_LINE_HEIGHT + ) + ) + } + } + } +} + +@Composable +private fun SenderRow( + avatarData: AvatarData, + name: String, + userId: String, + sentTime: String, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 4.dp) + .semantics(mergeDescendants = true) {}, + verticalAlignment = Alignment.CenterVertically + ) { + Avatar( + avatarData = avatarData, + avatarType = AvatarType.User, + ) + Column( + modifier = Modifier.padding(start = 12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom + ) { + Text( + modifier = Modifier + .padding(end = 4.dp) + .weight(1f), + text = name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyMdRegular, + ) + Text( + text = sentTime, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodySmRegular, + ) + } + Text( + text = userId, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodySmRegular, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun ReactionSummaryViewContentPreview( + @PreviewParameter(ReactionSummaryStateProvider::class) state: ReactionSummaryState +) = ElementPreview { + ReactionSummaryViewContent(summary = state.target as ReactionSummaryState.Summary) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewState.kt new file mode 100644 index 0000000..2d3f230 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewState.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.receipt + +import io.element.android.features.messages.impl.timeline.model.ReadReceiptData +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import kotlinx.collections.immutable.ImmutableList + +data class ReadReceiptViewState( + val sendState: LocalEventSendState?, + val isLastOutgoingMessage: Boolean, + val receipts: ImmutableList, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateForTimelineItemEventRowProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateForTimelineItemEventRowProvider.kt new file mode 100644 index 0000000..c5140c4 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateForTimelineItemEventRowProvider.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.receipt + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.timeline.model.ReadReceiptData +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState + +class ReadReceiptViewStateForTimelineItemEventRowProvider : + PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aReadReceiptViewState( + sendState = LocalEventSendState.Sending.Event, + ), + aReadReceiptViewState( + sendState = LocalEventSendState.Sent(EventId("\$eventId")), + ), + aReadReceiptViewState( + sendState = LocalEventSendState.Sent(EventId("\$eventId")), + receipts = mutableListOf().apply { + repeat(5) { + add( + aReadReceiptData( + it + ) + ) + } + }, + ), + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateProvider.kt new file mode 100644 index 0000000..64a7cf3 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStateProvider.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.receipt + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.timeline.model.ReadReceiptData +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.avatar.anAvatarData +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import kotlinx.collections.immutable.toImmutableList + +class ReadReceiptViewStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aReadReceiptViewState(), + aReadReceiptViewState(sendState = LocalEventSendState.Sending.Event), + aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId"))), + aReadReceiptViewState( + sendState = LocalEventSendState.Sent(EventId("\$eventId")), + receipts = List(1) { aReadReceiptData(it) }, + ), + aReadReceiptViewState( + sendState = LocalEventSendState.Sent(EventId("\$eventId")), + receipts = List(2) { aReadReceiptData(it) }, + ), + aReadReceiptViewState( + sendState = LocalEventSendState.Sent(EventId("\$eventId")), + receipts = List(3) { aReadReceiptData(it) }, + ), + aReadReceiptViewState( + sendState = LocalEventSendState.Sent(EventId("\$eventId")), + receipts = List(4) { aReadReceiptData(it) }, + ), + aReadReceiptViewState( + sendState = LocalEventSendState.Sent(EventId("\$eventId")), + receipts = List(5) { aReadReceiptData(it) }, + ), + ) +} + +internal fun aReadReceiptViewState( + sendState: LocalEventSendState? = null, + isLastOutgoingMessage: Boolean = true, + receipts: List = emptyList(), +) = ReadReceiptViewState( + sendState = sendState, + isLastOutgoingMessage = isLastOutgoingMessage, + receipts = receipts.toImmutableList(), +) + +internal fun aReadReceiptData( + index: Int, + avatarData: AvatarData = anAvatarData( + id = "$index", + size = AvatarSize.TimelineReadReceipt + ), + formattedDate: String = "12:34", +) = ReadReceiptData( + avatarData = avatarData, + formattedDate = formattedDate, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt new file mode 100644 index 0000000..b03aaf0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.receipt + +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.Row +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.shape.CircleShape +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.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import io.element.android.appconfig.TimelineConfig +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.timeline.model.ReadReceiptData +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.avatar.getBestName +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.testtags.TestTags +import io.element.android.libraries.ui.strings.CommonPlurals +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun TimelineItemReadReceiptView( + state: ReadReceiptViewState, + renderReadReceipts: Boolean, + onReadReceiptsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + if (state.receipts.isNotEmpty()) { + if (renderReadReceipts) { + ReadReceiptsRow( + modifier = modifier.clearAndSetSemantics { + hideFromAccessibility() + } + ) { + ReadReceiptsAvatars( + receipts = state.receipts, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .clickable { + onReadReceiptsClick() + } + .padding(2.dp) + ) + } + } + } else { + when (state.sendState) { + is LocalEventSendState.Sending -> { + ReadReceiptsRow(modifier) { + Icon( + modifier = Modifier.padding(2.dp), + imageVector = CompoundIcons.Circle(), + contentDescription = stringResource(id = CommonStrings.common_sending), + tint = ElementTheme.colors.iconSecondary + ) + } + } + is LocalEventSendState.Failed -> { + // Error? The timestamp is already displayed in red + } + null, + is LocalEventSendState.Sent -> { + if (state.isLastOutgoingMessage) { + ReadReceiptsRow(modifier = modifier) { + Icon( + modifier = Modifier.padding(2.dp), + imageVector = CompoundIcons.CheckCircle(), + contentDescription = stringResource(id = CommonStrings.common_sent), + tint = ElementTheme.colors.iconSecondary + ) + } + } + } + } + } +} + +@Composable +private fun ReadReceiptsRow( + modifier: Modifier = Modifier, + content: @Composable () -> Unit = {}, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(AvatarSize.TimelineReadReceipt.dp + 8.dp) + .padding(horizontal = 18.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .padding(horizontal = 4.dp) + ) { + content() + } + } +} + +@Composable +private fun ReadReceiptsAvatars( + receipts: ImmutableList, + modifier: Modifier = Modifier +) { + val avatarSize = AvatarSize.TimelineReadReceipt.dp + val avatarStrokeSize = 1.dp + val avatarStrokeColor = ElementTheme.colors.bgCanvasDefault + val receiptDescription = computeReceiptDescription(receipts) + Row( + modifier = modifier + .clearAndSetSemantics { + testTag = TestTags.messageReadReceipts.value + contentDescription = receiptDescription + }, + horizontalArrangement = Arrangement.spacedBy(4.dp - avatarStrokeSize), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + contentAlignment = Alignment.CenterEnd, + ) { + receipts + .take(TimelineConfig.MAX_READ_RECEIPT_TO_DISPLAY) + .reversed() + .forEachIndexed { index, readReceiptData -> + Box( + modifier = Modifier + .padding(end = (12.dp + avatarStrokeSize * 2) * index) + .size(size = avatarSize + avatarStrokeSize * 2) + .clip(CircleShape) + .background(avatarStrokeColor) + .zIndex(index.toFloat()), + contentAlignment = Alignment.Center, + ) { + Avatar( + avatarData = readReceiptData.avatarData, + avatarType = AvatarType.User, + ) + } + } + } + if (receipts.size > TimelineConfig.MAX_READ_RECEIPT_TO_DISPLAY) { + Text( + text = "+" + (receipts.size - TimelineConfig.MAX_READ_RECEIPT_TO_DISPLAY), + style = ElementTheme.typography.fontBodyXsRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } +} + +@Composable +private fun computeReceiptDescription(receipts: ImmutableList): String { + return when (receipts.size) { + 0 -> "" // Cannot happen + 1 -> stringResource( + id = CommonStrings.a11y_read_receipts_single, + receipts[0].avatarData.getBestName() + ) + 2 -> stringResource( + id = CommonStrings.a11y_read_receipts_multiple, + receipts[0].avatarData.getBestName(), + receipts[1].avatarData.getBestName(), + ) + else -> pluralStringResource( + id = CommonPlurals.a11y_read_receipts_multiple_with_others, + count = receipts.size - 1, + receipts[0].avatarData.getBestName(), + receipts.size - 1 + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemReadReceiptViewPreview( + @PreviewParameter(ReadReceiptViewStateProvider::class) state: ReadReceiptViewState, +) = ElementPreview { + TimelineItemReadReceiptView( + state = state, + renderReadReceipts = true, + onReadReceiptsClick = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheet.kt new file mode 100644 index 0000000..57c46a6 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheet.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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 androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ListItem +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.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.MatrixUserRow +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ReadReceiptBottomSheet( + state: ReadReceiptBottomSheetState, + onUserDataClick: (UserId) -> Unit, + modifier: Modifier = Modifier, +) { + val isVisible = state.selectedEvent != null + + 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() + state.eventSink(ReadReceiptBottomSheetEvents.Dismiss) + } + } + ) { + ReadReceiptBottomSheetContent( + state = state, + onUserDataClick = { + coroutineScope.launch { + sheetState.hide() + state.eventSink(ReadReceiptBottomSheetEvents.Dismiss) + onUserDataClick.invoke(it) + } + }, + ) + // FIXME remove after https://issuetracker.google.com/issues/275849044 + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +@Composable +private fun ReadReceiptBottomSheetContent( + state: ReadReceiptBottomSheetState, + onUserDataClick: (UserId) -> Unit, +) { + LazyColumn { + item { + ListItem( + headlineContent = { + Text(text = stringResource(id = CommonStrings.common_seen_by)) + } + ) + } + items( + items = state.selectedEvent?.readReceiptState?.receipts.orEmpty() + ) { + val userId = UserId(it.avatarData.id) + MatrixUserRow( + modifier = Modifier.clickable { onUserDataClick(userId) }, + matrixUser = MatrixUser( + userId = userId, + displayName = it.avatarData.name, + avatarUrl = it.avatarData.url, + ), + avatarSize = AvatarSize.ReadReceiptList, + trailingContent = { + Text( + text = it.formattedDate, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun ReadReceiptBottomSheetPreview(@PreviewParameter(ReadReceiptBottomSheetStateProvider::class) state: ReadReceiptBottomSheetState) = ElementPreview { + Column { + ReadReceiptBottomSheetContent( + state = state, + onUserDataClick = {}, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetEvents.kt new file mode 100644 index 0000000..04723d0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetEvents.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet + +import io.element.android.features.messages.impl.timeline.model.TimelineItem + +sealed interface ReadReceiptBottomSheetEvents { + data class EventSelected(val event: TimelineItem.Event) : ReadReceiptBottomSheetEvents + data object Dismiss : ReadReceiptBottomSheetEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetPresenter.kt new file mode 100644 index 0000000..e1264b7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetPresenter.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet + +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 dev.zacsweers.metro.Inject +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.architecture.Presenter + +@Inject +class ReadReceiptBottomSheetPresenter : Presenter { + @Composable + override fun present(): ReadReceiptBottomSheetState { + var selectedEvent: TimelineItem.Event? by remember { mutableStateOf(null) } + + fun handleEvent(event: ReadReceiptBottomSheetEvents) { + @Suppress("LiftReturnOrAssignment") + when (event) { + is ReadReceiptBottomSheetEvents.EventSelected -> { + selectedEvent = event.event + } + ReadReceiptBottomSheetEvents.Dismiss -> { + selectedEvent = null + } + } + } + + return ReadReceiptBottomSheetState( + selectedEvent = selectedEvent, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetState.kt new file mode 100644 index 0000000..7ec4107 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetState.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet + +import io.element.android.features.messages.impl.timeline.model.TimelineItem + +data class ReadReceiptBottomSheetState( + val selectedEvent: TimelineItem.Event?, + val eventSink: (ReadReceiptBottomSheetEvents) -> Unit, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStateProvider.kt new file mode 100644 index 0000000..ff297a4 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStateProvider.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewStateProvider +import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import kotlinx.collections.immutable.toImmutableList + +class ReadReceiptBottomSheetStateProvider : PreviewParameterProvider { + // Reuse the provider ReadReceiptViewStateProvider + private val readReceiptViewStateProvider = ReadReceiptViewStateProvider() + override val values: Sequence = readReceiptViewStateProvider.values + .filter { it.sendState is LocalEventSendState.Sent } + .map { readReceiptViewState -> + ReadReceiptBottomSheetState( + selectedEvent = aTimelineItemEvent( + readReceiptState = TimelineItemReadReceipts( + receipts = readReceiptViewState.receipts.map { readReceiptData -> + readReceiptData + .copy(avatarData = readReceiptData.avatarData.copy(id = "@${readReceiptData.avatarData.id}:localhost")) + }.toImmutableList() + ) + ), + eventSink = {}, + ) + } +} 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 0000000..30befea --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineItemDaySeparatorView.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +internal fun TimelineItemDaySeparatorView( + model: TimelineItemDaySeparatorModel, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier + .semantics { + heading() + }, + text = model.formattedDate, + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textPrimary, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemDaySeparatorViewPreview( + @PreviewParameter(TimelineItemDaySeparatorModelProvider::class) model: TimelineItemDaySeparatorModel +) = ElementPreview { + TimelineItemDaySeparatorView( + model = model, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineItemReadMarkerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineItemReadMarkerView.kt new file mode 100644 index 0000000..bb9ab2a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineItemReadMarkerView.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.virtual + +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Column +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.res.stringResource +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.messages.impl.R +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +internal fun TimelineItemReadMarkerView( + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 12.dp, horizontal = 18.dp), + horizontalAlignment = Alignment.End, + verticalArrangement = spacedBy(4.dp), + ) { + Text( + text = stringResource(id = R.string.screen_room_timeline_read_marker_title).uppercase(), + style = ElementTheme.typography.fontBodySmMedium, + color = ElementTheme.colors.textActionAccent, + ) + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 2.dp), + color = ElementTheme.colors.textActionAccent, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemReadMarkerViewPreview() = ElementPreview { + TimelineItemReadMarkerView() +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineItemRoomBeginningView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineItemRoomBeginningView.kt new file mode 100644 index 0000000..f812b40 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineItemRoomBeginningView.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.virtual + +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.messages.impl.R +import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.toAnnotatedString +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.allBooleans +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom + +@Composable +fun TimelineItemRoomBeginningView( + roomName: String?, + predecessorRoom: PredecessorRoom?, + isDm: Boolean, + onPredecessorRoomClick: (RoomId) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxWidth() + ) { + if (predecessorRoom != null) { + ComposerAlertMolecule( + avatar = null, + content = stringResource(R.string.screen_room_timeline_upgraded_room_message).toAnnotatedString(), + onSubmitClick = { onPredecessorRoomClick(predecessorRoom.roomId) }, + submitText = stringResource(R.string.screen_room_timeline_upgraded_room_action) + ) + } + // Only display for non-DM room + if (!isDm) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + val text = if (roomName == null) { + stringResource(id = R.string.screen_room_timeline_beginning_of_room_no_name) + } else { + stringResource(id = R.string.screen_room_timeline_beginning_of_room, roomName) + } + Text( + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdRegular, + text = text, + textAlign = TextAlign.Center, + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineItemRoomBeginningViewPreview() = ElementPreview { + Column(verticalArrangement = spacedBy(16.dp)) { + allBooleans.forEach { isDm -> + TimelineItemRoomBeginningView( + predecessorRoom = null, + roomName = null, + isDm = isDm, + onPredecessorRoomClick = {}, + ) + TimelineItemRoomBeginningView( + predecessorRoom = null, + roomName = "Room Name", + isDm = isDm, + onPredecessorRoomClick = {}, + ) + TimelineItemRoomBeginningView( + predecessorRoom = PredecessorRoom(RoomId("!roomId:matrix.org")), + roomName = "Room Name", + isDm = isDm, + onPredecessorRoomClick = {}, + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineLoadingMoreIndicator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineLoadingMoreIndicator.kt new file mode 100644 index 0000000..571c884 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineLoadingMoreIndicator.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.virtual + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator +import io.element.android.libraries.matrix.api.timeline.Timeline + +@Composable +internal fun TimelineLoadingMoreIndicator( + direction: Timeline.PaginationDirection, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + when (direction) { + Timeline.PaginationDirection.FORWARDS -> { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp) + .height(1.dp) + ) + } + Timeline.PaginationDirection.BACKWARDS -> { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun TimelineLoadingMoreIndicatorPreview() = ElementPreview { + Column( + modifier = Modifier.padding(vertical = 2.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TimelineLoadingMoreIndicator(Timeline.PaginationDirection.BACKWARDS) + TimelineLoadingMoreIndicator(Timeline.PaginationDirection.FORWARDS) + } +} 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 0000000..6288f3f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.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) +@AssistedInject +class EventDebugInfoNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : Node(buildContext, plugins = plugins) { + data class Inputs( + val eventId: EventId?, + val timelineItemDebugInfo: TimelineItemDebugInfo, + ) : NodeInputs + + private val inputs = inputs() + + private fun onBackClick() { + navigateUp() + } + + @Composable + override fun View(modifier: Modifier) = with(inputs) { + EventDebugInfoView( + eventId = eventId, + model = timelineItemDebugInfo.model, + originalJson = timelineItemDebugInfo.originalJson, + latestEditedJson = timelineItemDebugInfo.latestEditedJson, + onBackClick = ::onBackClick + ) + } +} 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 0000000..8a19b88 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.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.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.draw.rotate +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.core.content.getSystemService +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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 org.json.JSONException +import org.json.JSONObject + +/** + * Screen used to display debug info for events. + * It will only be available in debug builds. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EventDebugInfoView( + eventId: EventId?, + model: String, + originalJson: String?, + latestEditedJson: String?, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + isTest: Boolean = false, +) { + val sectionsInitiallyExpanded = isTest || LocalInspectionMode.current + Scaffold( + topBar = { + TopAppBar( + titleStr = "Debug event info", + navigationIcon = { BackButton(onClick = onBackClick) } + ) + }, + modifier = modifier + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(padding) // Window insets + .consumeWindowInsets(padding) + // Internal padding + .padding(horizontal = 16.dp) + ) { + 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 = prettyJSON(originalJson), initiallyExpanded = sectionsInitiallyExpanded) + } + } + if (latestEditedJson != null) { + item { + CollapsibleSection(title = "Latest edited JSON:", text = prettyJSON(latestEditedJson), initiallyExpanded = sectionsInitiallyExpanded) + } + } + } + } +} + +private fun prettyJSON(maybeJSON: String): String { + return try { + JSONObject(maybeJSON).toString(2) + } catch (e: JSONException) { + // Prefer not pretty-printing over crashing if the data is not actually JSON + maybeJSON + } +} + +@Composable +private fun CollapsibleSection( + title: String, + text: String, + 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( + modifier = Modifier.rotate(if (isExpanded) 180f else 0f), + imageVector = CompoundIcons.ChevronDown(), + 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()) } + 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), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun EventDebugInfoViewPreview() = ElementPreview { + EventDebugInfoView( + eventId = EventId("\$some-event-id"), + model = "Rust(\n\tModel()\n)", + originalJson = "{\"name\": \"original\"}", + latestEditedJson = "{\"name\": \"edited\"}", + onBackClick = { } + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/FakeTimelineItemPresenterFactories.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/FakeTimelineItemPresenterFactories.kt new file mode 100644 index 0000000..7c36521 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/FakeTimelineItemPresenterFactories.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.di + +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.voiceplayer.api.VoiceMessageState +import io.element.android.libraries.voiceplayer.api.aVoiceMessageState + +/** + * A fake [TimelineItemPresenterFactories] for screenshot tests. + */ +fun aFakeTimelineItemPresenterFactories() = TimelineItemPresenterFactories( + mapOf( + Pair( + TimelineItemVoiceContent::class, + TimelineItemPresenterFactory { Presenter { aVoiceMessageState() } }, + ), + ) +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/LiveTimeline.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/LiveTimeline.kt new file mode 100644 index 0000000..ff7a170 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/LiveTimeline.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.di + +import dev.zacsweers.metro.Qualifier + +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +@Qualifier +annotation class LiveTimeline diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/LocalTimelineItemPresenterFactories.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/LocalTimelineItemPresenterFactories.kt new file mode 100644 index 0000000..beef7a8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/LocalTimelineItemPresenterFactories.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.di + +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * Provides a [TimelineItemPresenterFactories] to the composition. + */ +val LocalTimelineItemPresenterFactories = staticCompositionLocalOf { + TimelineItemPresenterFactories(emptyMap()) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemEventContentKey.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemEventContentKey.kt new file mode 100644 index 0000000..49b10a4 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemEventContentKey.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.di + +import dev.zacsweers.metro.MapKey +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import kotlin.reflect.KClass + +/** + * Annotation to add a factory of type [TimelineItemPresenterFactory] to a + * dependency injection map multi binding keyed with a subclass of [TimelineItemEventContent]. + */ +@Retention(AnnotationRetention.RUNTIME) +@MapKey +annotation class TimelineItemEventContentKey(val value: KClass) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactories.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactories.kt new file mode 100644 index 0000000..1063157 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactories.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.di + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.Multibinds +import dev.zacsweers.metro.SingleIn +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.RoomScope +import kotlin.reflect.KClass + +/** + * Container that declares the [TimelineItemPresenterFactory] map multi binding. + * + * Its sole purpose is to support the case of an empty map multibinding. + */ +@BindingContainer +@ContributesTo(RoomScope::class) +interface TimelineItemPresenterFactoriesModule { + @Multibinds + fun multiBindTimelineItemPresenterFactories(): @JvmSuppressWildcards Map, TimelineItemPresenterFactory<*, *>> +} + +/** + * Room level caching layer for the [TimelineItemPresenterFactory] instances. + * + * It will cache the presenter instances in the room scope, so that they can be + * reused across recompositions of the timeline items that happen whenever an item + * goes out of the [LazyColumn] viewport. + */ +@SingleIn(RoomScope::class) +@Inject +class TimelineItemPresenterFactories( + private val factories: @JvmSuppressWildcards Map, TimelineItemPresenterFactory<*, *>>, +) { + private val presenters: MutableMap> = mutableMapOf() + + /** + * Creates and caches a presenter for the given content. + * + * Will throw if the presenter is not found in the [TimelineItemPresenterFactory] map multi binding. + * + * @param C The [TimelineItemEventContent] subtype handled by this TimelineItem presenter. + * @param S The state type produced by this timeline item presenter. + * @param content The [TimelineItemEventContent] instance to create a presenter for. + * @param contentClass The class of [content]. + * @return An instance of a TimelineItem presenter that will be cached in the room scope. + */ + @Composable + fun rememberPresenter( + content: C, + contentClass: KClass, + ): Presenter = remember(content) { + presenters[content]?.let { + @Suppress("UNCHECKED_CAST") + it as Presenter + } ?: factories.getValue(contentClass).let { + @Suppress("UNCHECKED_CAST") + (it as TimelineItemPresenterFactory).create(content).apply { + presenters[content] = this + } + } + } +} + +/** + * Creates and caches a presenter for the given content. + * + * Will throw if the presenter is not found in the [TimelineItemPresenterFactory] map multi binding. + * + * @param C The [TimelineItemEventContent] subtype handled by this TimelineItem presenter. + * @param S The state type produced by this timeline item presenter. + * @param content The [TimelineItemEventContent] instance to create a presenter for. + * @return An instance of a TimelineItem presenter that will be cached in the room scope. + */ +@Composable +inline fun TimelineItemPresenterFactories.rememberPresenter( + content: C +): Presenter = rememberPresenter( + content = content, + contentClass = C::class +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactory.kt new file mode 100644 index 0000000..7d8cd20 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactory.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.di + +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.libraries.architecture.Presenter + +/** + * A factory for a [Presenter] associated with a timeline item. + * + * Implementations should be annotated with [dev.zacsweers.metro.AssistedFactory] to be created by the dependency injection library. + * + * @param C The timeline item's [TimelineItemEventContent] subtype. + * @param S The [Presenter]'s state class. + * @return A [Presenter] that produces a state of type [S] for the given content of type [C]. + */ +fun interface TimelineItemPresenterFactory { + fun create(content: C): Presenter +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/TimelineItemsCacheInvalidator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/TimelineItemsCacheInvalidator.kt new file mode 100644 index 0000000..7cc6d23 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/TimelineItemsCacheInvalidator.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.diff + +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.androidutils.diff.DefaultDiffCacheInvalidator +import io.element.android.libraries.androidutils.diff.DiffCacheInvalidator +import io.element.android.libraries.androidutils.diff.MutableDiffCache + +/** + * [DiffCacheInvalidator] implementation for [TimelineItem]. + * It uses [DefaultDiffCacheInvalidator] and invalidate the cache around the updated item so that those items are computed again. + * This is needed because a timeline item is computed based on the previous and next items. + */ +internal class TimelineItemsCacheInvalidator : DiffCacheInvalidator { + private val delegate = DefaultDiffCacheInvalidator() + + override fun onChanged(position: Int, count: Int, cache: MutableDiffCache) { + delegate.onChanged(position, count, cache) + } + + override fun onMoved(fromPosition: Int, toPosition: Int, cache: MutableDiffCache) { + delegate.onMoved(fromPosition, toPosition, cache) + } + + override fun onInserted(position: Int, count: Int, cache: MutableDiffCache) { + cache.invalidateAround(position) + delegate.onInserted(position, count, cache) + } + + override fun onRemoved(position: Int, count: Int, cache: MutableDiffCache) { + cache.invalidateAround(position) + delegate.onRemoved(position, count, cache) + } +} + +/** + * Invalidate the cache around the given position. + * It invalidates the previous and next items. + */ +private fun MutableDiffCache<*>.invalidateAround(position: Int) { + if (position > 0) { + set(position - 1, null) + } + if (position < indices().last) { + set(position + 1, null) + } +} 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 0000000..7b369fe --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.factories + +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.messages.impl.timeline.diff.TimelineItemsCacheInvalidator +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.androidutils.diff.DiffCacheUpdater +import io.element.android.libraries.androidutils.diff.MutableListDiffCache +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +@AssistedInject +class TimelineItemsFactory( + @Assisted config: TimelineItemsFactoryConfig, + eventItemFactoryCreator: TimelineItemEventFactory.Creator, + private val dispatchers: CoroutineDispatchers, + private val virtualItemFactory: TimelineItemVirtualFactory, + private val timelineItemGrouper: TimelineItemGrouper, +) { + @AssistedFactory + interface Creator { + fun create(config: TimelineItemsFactoryConfig): TimelineItemsFactory + } + + private val eventItemFactory = eventItemFactoryCreator.create(config) + private val _timelineItems = MutableSharedFlow>(replay = 1) + private val lock = Mutex() + private val diffCache = MutableListDiffCache() + private val diffCacheUpdater = DiffCacheUpdater( + diffCache = diffCache, + detectMoves = false, + cacheInvalidator = TimelineItemsCacheInvalidator() + ) { old, new -> + if (old is MatrixTimelineItem.Event && new is MatrixTimelineItem.Event) { + old.uniqueId == new.uniqueId + } else { + false + } + } + + val timelineItems: Flow> = _timelineItems.distinctUntilChanged() + + suspend fun replaceWith( + timelineItems: List, + roomMembers: List, + ) = withContext(dispatchers.computation) { + lock.withLock { + diffCacheUpdater.updateWith(timelineItems) + buildAndEmitTimelineItemStates(timelineItems, roomMembers) + } + } + + private suspend fun buildAndEmitTimelineItemStates( + timelineItems: List, + roomMembers: List, + ) { + val newTimelineItemStates = ArrayList() + for (index in diffCache.indices().reversed()) { + val cacheItem = diffCache.get(index) + if (cacheItem == null) { + buildAndCacheItem(timelineItems, index, roomMembers)?.also { timelineItemState -> + newTimelineItemStates.add(timelineItemState) + } + } else { + val updatedItem = if (cacheItem is TimelineItem.Event && roomMembers.isNotEmpty()) { + eventItemFactory.update( + timelineItem = cacheItem, + receivedMatrixTimelineItem = timelineItems[index] as MatrixTimelineItem.Event, + roomMembers = roomMembers + ) + } else { + cacheItem + } + newTimelineItemStates.add(updatedItem) + } + } + val result = timelineItemGrouper.group(newTimelineItemStates).toImmutableList() + this._timelineItems.emit(result) + } + + private suspend fun buildAndCacheItem( + timelineItems: List, + index: Int, + roomMembers: List, + ): TimelineItem? { + val timelineItem = + when (val currentTimelineItem = timelineItems[index]) { + is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem, index, timelineItems, roomMembers) + is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem) + MatrixTimelineItem.Other -> null + } + diffCache[index] = timelineItem + return timelineItem + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryConfig.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryConfig.kt new file mode 100644 index 0000000..50124eb --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryConfig.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.factories + +/** + * Some data used to configure the creation of timeline items. + * @param computeReadReceipts when false, read receipts will be empty. + * @param computeReactions when false, reactions will be empty. + */ +data class TimelineItemsFactoryConfig( + val computeReadReceipts: Boolean, + val computeReactions: Boolean, +) 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 0000000..31cf689 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.factories.event + +import dev.zacsweers.metro.Inject +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent +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.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent +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.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +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.api.timeline.item.event.getDisambiguatedDisplayName + +@Inject +class TimelineItemContentFactory( + private val messageFactory: TimelineItemContentMessageFactory, + private val redactedMessageFactory: TimelineItemContentRedactedFactory, + private val stickerFactory: TimelineItemContentStickerFactory, + private val pollFactory: TimelineItemContentPollFactory, + private val utdFactory: TimelineItemContentUTDFactory, + private val roomMembershipFactory: TimelineItemContentRoomMembershipFactory, + private val profileChangeFactory: TimelineItemContentProfileChangeFactory, + private val stateFactory: TimelineItemContentStateFactory, + private val failedToParseMessageFactory: TimelineItemContentFailedToParseMessageFactory, + private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory, + private val sessionId: SessionId, +) { + suspend fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent { + return create( + itemContent = eventTimelineItem.content, + eventId = eventTimelineItem.eventId, + isEditable = eventTimelineItem.isEditable, + sender = eventTimelineItem.sender, + senderProfile = eventTimelineItem.senderProfile, + ) + } + + suspend fun create( + itemContent: EventContent, + eventId: EventId?, + isEditable: Boolean, + sender: UserId, + senderProfile: ProfileDetails, + ): TimelineItemEventContent { + val isOutgoing = sessionId == sender + return when (itemContent) { + is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent) + is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent) + is MessageContent -> { + val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(sender) + messageFactory.create( + content = itemContent, + senderDisambiguatedDisplayName = senderDisambiguatedDisplayName, + eventId = eventId, + ) + } + is ProfileChangeContent -> { + val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(sender) + profileChangeFactory.create(itemContent, isOutgoing, sender, senderDisambiguatedDisplayName) + } + is RedactedContent -> redactedMessageFactory.create(itemContent) + is RoomMembershipContent -> { + val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(sender) + roomMembershipFactory.create(itemContent, isOutgoing, sender, senderDisambiguatedDisplayName) + } + is LegacyCallInviteContent -> TimelineItemLegacyCallInviteContent + is StateContent -> { + val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(sender) + stateFactory.create(itemContent, isOutgoing, sender, senderDisambiguatedDisplayName) + } + is StickerContent -> stickerFactory.create(itemContent) + is PollContent -> pollFactory.create(eventId, isEditable, isOutgoing, itemContent) + is UnableToDecryptContent -> utdFactory.create(itemContent) + is CallNotifyContent -> TimelineItemRtcNotificationContent() + 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 0000000..c0c3310 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFailedToParseMessageFactory.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.factories.event + +import dev.zacsweers.metro.Inject +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 + +@Inject +class TimelineItemContentFailedToParseMessageFactory { + fun create(@Suppress("UNUSED_PARAMETER") 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 0000000..a7075d1 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFailedToParseStateFactory.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.factories.event + +import dev.zacsweers.metro.Inject +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 + +@Inject +class TimelineItemContentFailedToParseStateFactory { + @Suppress("UNUSED_PARAMETER") + 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 0000000..a3c8a1b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -0,0 +1,279 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.factories.event + +import android.text.style.URLSpan +import androidx.core.text.buildSpannedString +import androidx.core.text.getSpans +import androidx.core.text.toSpannable +import dev.zacsweers.metro.Inject +import io.element.android.features.location.api.Location +import io.element.android.features.messages.api.timeline.HtmlConverterProvider +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.TimelineItemStickerContent +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.TimelineItemVoiceContent +import io.element.android.features.messages.impl.utils.TextPillificationHelper +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.androidutils.text.safeLinkify +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +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.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.OtherMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType +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.api.timeline.item.event.VoiceMessageType +import io.element.android.libraries.matrix.ui.messages.toHtmlDocument +import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlin.time.Duration + +@Inject +class TimelineItemContentMessageFactory( + private val fileSizeFormatter: FileSizeFormatter, + private val fileExtensionExtractor: FileExtensionExtractor, + private val htmlConverterProvider: HtmlConverterProvider, + private val permalinkParser: PermalinkParser, + private val textPillificationHelper: TextPillificationHelper, +) { + suspend fun create( + content: MessageContent, + senderDisambiguatedDisplayName: String, + eventId: EventId?, + ): TimelineItemEventContent { + return when (val messageType = content.type) { + is EmoteMessageType -> { + val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}" + val formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisambiguatedDisplayName") ?: textPillificationHelper.pillify( + emoteBody + ).safeLinkify() + TimelineItemEmoteContent( + body = emoteBody, + htmlDocument = messageType.formatted?.toHtmlDocument( + permalinkParser = permalinkParser, + prefix = "* $senderDisambiguatedDisplayName", + ), + formattedBody = formattedBody, + isEdited = content.isEdited, + ) + } + is ImageMessageType -> { + val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height) + TimelineItemImageContent( + filename = messageType.filename, + fileSize = messageType.info?.size ?: 0, + caption = messageType.caption?.trimEnd(), + formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), + isEdited = content.isEdited, + 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(), + thumbnailWidth = messageType.info?.thumbnailInfo?.width?.toInt(), + thumbnailHeight = messageType.info?.thumbnailInfo?.height?.toInt(), + aspectRatio = aspectRatio, + formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0), + fileExtension = fileExtensionExtractor.extractFromName(messageType.filename) + ) + } + is StickerMessageType -> { + val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height) + TimelineItemStickerContent( + filename = messageType.filename, + fileSize = messageType.info?.size ?: 0, + caption = messageType.caption?.trimEnd(), + formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), + isEdited = content.isEdited, + 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.filename) + ) + } + is LocationMessageType -> { + val location = Location.fromGeoUri(messageType.geoUri) + if (location == null) { + val body = messageType.body.trimEnd() + TimelineItemTextContent( + body = body, + htmlDocument = null, + formattedBody = body, + isEdited = content.isEdited, + ) + } else { + TimelineItemLocationContent( + body = messageType.body.trimEnd(), + location = location, + description = messageType.description + ) + } + } + is VideoMessageType -> { + val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height) + TimelineItemVideoContent( + filename = messageType.filename, + fileSize = messageType.info?.size ?: 0, + caption = messageType.caption?.trimEnd(), + formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), + isEdited = content.isEdited, + thumbnailSource = messageType.info?.thumbnailSource, + mediaSource = messageType.source, + mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, + width = messageType.info?.width?.toInt(), + height = messageType.info?.height?.toInt(), + thumbnailWidth = messageType.info?.thumbnailInfo?.width?.toInt(), + thumbnailHeight = messageType.info?.thumbnailInfo?.height?.toInt(), + duration = messageType.info?.duration ?: Duration.ZERO, + blurHash = messageType.info?.blurhash, + aspectRatio = aspectRatio, + formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0), + fileExtension = fileExtensionExtractor.extractFromName(messageType.filename), + ) + } + is AudioMessageType -> { + TimelineItemAudioContent( + filename = messageType.filename, + fileSize = messageType.info?.size ?: 0, + caption = messageType.caption?.trimEnd(), + formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), + isEdited = content.isEdited, + mediaSource = messageType.source, + duration = messageType.info?.duration ?: Duration.ZERO, + mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, + formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0), + fileExtension = fileExtensionExtractor.extractFromName(messageType.filename), + ) + } + is VoiceMessageType -> { + TimelineItemVoiceContent( + eventId = eventId, + filename = messageType.filename, + fileSize = messageType.info?.size ?: 0, + caption = messageType.caption?.trimEnd(), + formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), + isEdited = content.isEdited, + mediaSource = messageType.source, + duration = messageType.info?.duration ?: Duration.ZERO, + mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, + waveform = messageType.details?.waveform?.toImmutableList() ?: persistentListOf(), + formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0), + fileExtension = fileExtensionExtractor.extractFromName(messageType.filename) + ) + } + is FileMessageType -> { + val fileExtension = fileExtensionExtractor.extractFromName(messageType.filename) + TimelineItemFileContent( + filename = messageType.filename, + fileSize = messageType.info?.size ?: 0, + caption = messageType.caption?.trimEnd(), + formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), + isEdited = content.isEdited, + thumbnailSource = messageType.info?.thumbnailSource, + mediaSource = messageType.source, + mimeType = messageType.info?.mimetype ?: MimeTypes.fromFileExtension(fileExtension), + formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0), + fileExtension = fileExtension + ) + } + is NoticeMessageType -> { + val body = messageType.body.trimEnd() + val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify( + body + ).safeLinkify() + val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser) + TimelineItemNoticeContent( + body = body, + htmlDocument = htmlDocument, + formattedBody = formattedBody, + isEdited = content.isEdited, + ) + } + is TextMessageType -> { + val body = messageType.body.trimEnd() + val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify( + body + ).safeLinkify() + TimelineItemTextContent( + body = body, + htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser), + formattedBody = formattedBody, + isEdited = content.isEdited, + ) + } + is OtherMessageType -> { + val body = messageType.body.trimEnd() + TimelineItemTextContent( + body = body, + htmlDocument = null, + formattedBody = textPillificationHelper.pillify(body).safeLinkify(), + isEdited = content.isEdited, + ) + } + } + } + + private fun aspectRatioOf(width: Long?, height: Long?): Float? { + val result = if (height != null && width != null) { + width.toFloat() / height.toFloat() + } else { + null + } + + return result?.takeIf { it.isFinite() } + } + + private fun parseHtml(formattedBody: FormattedBody?, prefix: String? = null): CharSequence? { + if (formattedBody == null || formattedBody.format != MessageFormat.HTML) return null + val result = htmlConverterProvider.provide() + .fromHtmlToSpans(formattedBody.body.trimEnd()) + .let { textPillificationHelper.pillify(it) } + .safeLinkify() + return if (prefix != null) { + buildSpannedString { + append(prefix) + append(" ") + append(result) + } + } else { + result + } + } +} + +@Suppress("USELESS_ELVIS") +private fun String.withLinks(): CharSequence? { + // Note: toSpannable() can return null when running unit tests + val spannable = safeLinkify().toSpannable() ?: return null + return spannable.takeIf { spannable.getSpans(0, length).isNotEmpty() } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt new file mode 100644 index 0000000..2ceb81c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.factories.event + +import dev.zacsweers.metro.Inject +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent +import io.element.android.features.poll.api.pollcontent.PollContentStateFactory +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent + +@Inject +class TimelineItemContentPollFactory( + private val pollContentStateFactory: PollContentStateFactory, +) { + suspend fun create( + eventId: EventId?, + isEditable: Boolean, + isOwn: Boolean, + content: PollContent, + ): TimelineItemEventContent { + val pollContentState = pollContentStateFactory.create(eventId, isEditable, isOwn, content) + return TimelineItemPollContent( + isMine = pollContentState.isMine, + isEditable = pollContentState.isPollEditable, + eventId = eventId, + question = pollContentState.question, + answerItems = pollContentState.answerItems, + pollKind = pollContentState.pollKind, + isEnded = pollContentState.isPollEnded, + isEdited = content.isEdited + ) + } +} 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 0000000..083b256 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentProfileChangeFactory.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.factories.event + +import dev.zacsweers.metro.Inject +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.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.event.EventContent + +@Inject +class TimelineItemContentProfileChangeFactory( + private val timelineEventFormatter: TimelineEventFormatter, +) { + fun create(content: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): TimelineItemEventContent { + val text = timelineEventFormatter.format(content, isOutgoing, sender, senderDisambiguatedDisplayName) + 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 0000000..d60a063 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentRedactedFactory.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.factories.event + +import dev.zacsweers.metro.Inject +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 + +@Inject +class TimelineItemContentRedactedFactory { + fun create(@Suppress("UNUSED_PARAMETER") 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 0000000..73c92c4 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentRoomMembershipFactory.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.factories.event + +import dev.zacsweers.metro.Inject +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.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.event.EventContent + +@Inject +class TimelineItemContentRoomMembershipFactory( + private val timelineEventFormatter: TimelineEventFormatter, +) { + fun create(eventContent: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): TimelineItemEventContent { + val text = timelineEventFormatter.format(eventContent, isOutgoing, sender, senderDisambiguatedDisplayName) + 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 0000000..7eb071b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStateFactory.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.factories.event + +import dev.zacsweers.metro.Inject +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.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.event.EventContent + +@Inject +class TimelineItemContentStateFactory( + private val timelineEventFormatter: TimelineEventFormatter, +) { + fun create(eventContent: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): TimelineItemEventContent { + val text = timelineEventFormatter.format(eventContent, isOutgoing, sender, senderDisambiguatedDisplayName) + 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 0000000..2f35254 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStickerFactory.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.factories.event + +import dev.zacsweers.metro.Inject +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent +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.StickerContent +import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor + +@Inject +class TimelineItemContentStickerFactory( + private val fileSizeFormatter: FileSizeFormatter, + private val fileExtensionExtractor: FileExtensionExtractor +) { + private fun aspectRatioOf(width: Long?, height: Long?): Float? { + val result = if (height != null && width != null) { + width.toFloat() / height.toFloat() + } else { + null + } + + return result?.takeIf { it.isFinite() } + } + + fun create(content: StickerContent): TimelineItemEventContent { + val aspectRatio = aspectRatioOf(content.info.width, content.info.height) + + return TimelineItemStickerContent( + filename = content.filename, + fileSize = content.info.size ?: 0L, + caption = content.body, + formattedCaption = null, + isEdited = false, + mediaSource = content.source, + thumbnailSource = content.info.thumbnailSource, + mimeType = content.info.mimetype ?: MimeTypes.Images, + blurhash = content.info.blurhash, + width = content.info.width?.toInt(), + height = content.info.height?.toInt(), + aspectRatio = aspectRatio, + formattedFileSize = fileSizeFormatter.format(content.info.size ?: 0), + fileExtension = fileExtensionExtractor.extractFromName(content.filename) + ) + } +} 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 0000000..0b3719f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentUTDFactory.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.factories.event + +import dev.zacsweers.metro.Inject +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 + +@Inject +class TimelineItemContentUTDFactory { + 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 0000000..77f1f96 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.factories.event + +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig +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.AggregatedReactionSender +import io.element.android.features.messages.impl.timeline.model.ReadReceiptData +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.TimelineItemReadReceipts +import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo +import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter +import io.element.android.libraries.core.bool.orTrue +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode +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.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo +import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl +import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName +import io.element.android.libraries.matrix.ui.messages.reply.map +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@AssistedInject +class TimelineItemEventFactory( + @Assisted private val config: TimelineItemsFactoryConfig, + private val contentFactory: TimelineItemContentFactory, + private val matrixClient: MatrixClient, + private val dateFormatter: DateFormatter, + private val permalinkParser: PermalinkParser, + private val summaryFormatter: MessageSummaryFormatter, +) { + @AssistedFactory + interface Creator { + fun create(config: TimelineItemsFactoryConfig): TimelineItemEventFactory + } + + suspend fun create( + currentTimelineItem: MatrixTimelineItem.Event, + index: Int, + timelineItems: List, + roomMembers: List, + ): TimelineItem.Event { + val currentSender = currentTimelineItem.event.sender + val groupPosition = + computeGroupPosition(currentTimelineItem, timelineItems, index) + val senderProfile = currentTimelineItem.event.senderProfile + val sentTime = dateFormatter.format( + timestamp = currentTimelineItem.event.timestamp, + mode = DateFormatterMode.TimeOnly, + ) + val senderAvatarData = AvatarData( + id = currentSender.value, + name = senderProfile.getDisambiguatedDisplayName(currentSender), + url = senderProfile.getAvatarUrl(), + size = AvatarSize.TimelineSender + ) + val mappedThreadInfo = when (val threadInfo = currentTimelineItem.event.threadInfo()) { + is EventThreadInfo.ThreadResponse -> { + TimelineItemThreadInfo.ThreadResponse(threadInfo.threadRootId) + } + is EventThreadInfo.ThreadRoot -> { + TimelineItemThreadInfo.ThreadRoot( + summary = threadInfo.summary, + latestEventText = threadInfo.summary.latestEvent.dataOrNull() + ?.let { + contentFactory.create( + itemContent = it.content, + eventId = it.eventOrTransactionId.eventId, + isEditable = false, + sender = it.senderId, + senderProfile = it.senderProfile, + ) + } + ?.let(summaryFormatter::format) + ) + } + null -> null + } + + return TimelineItem.Event( + id = currentTimelineItem.uniqueId, + eventId = currentTimelineItem.eventId, + transactionId = currentTimelineItem.transactionId, + senderId = currentSender, + senderProfile = senderProfile, + senderAvatar = senderAvatarData, + content = contentFactory.create(currentTimelineItem.event), + isMine = currentTimelineItem.event.isOwn, + isEditable = currentTimelineItem.event.isEditable, + canBeRepliedTo = currentTimelineItem.event.canBeRepliedTo, + sentTimeMillis = currentTimelineItem.event.timestamp, + sentTime = sentTime, + groupPosition = groupPosition, + reactionsState = currentTimelineItem.computeReactionsState(), + readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers), + localSendState = currentTimelineItem.event.localSendState, + inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser), + threadInfo = mappedThreadInfo, + origin = currentTimelineItem.event.origin, + timelineItemDebugInfoProvider = currentTimelineItem.event.timelineItemDebugInfoProvider, + messageShieldProvider = currentTimelineItem.event.messageShieldProvider, + sendHandleProvider = currentTimelineItem.event.sendHandleProvider, + ) + } + + fun update( + timelineItem: TimelineItem.Event, + receivedMatrixTimelineItem: MatrixTimelineItem.Event, + roomMembers: List, + ): TimelineItem.Event { + return timelineItem.copy( + readReceiptState = receivedMatrixTimelineItem.computeReadReceiptState(roomMembers) + ) + } + + private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions { + if (!config.computeReactions) { + return TimelineItemReactions(reactions = persistentListOf()) + } + var aggregatedReactions = this.event.reactions.map { reaction -> + // Sort reactions within an aggregation by timestamp descending. + // This puts the most recent at the top, useful in cases like the + // reaction summary view or getting the most recent reaction. + AggregatedReaction( + key = reaction.key, + currentUserId = matrixClient.sessionId, + senders = reaction.senders + .sortedByDescending { it.timestamp } + .map { + AggregatedReactionSender( + senderId = it.senderId, + timestamp = it.timestamp, + sentTime = dateFormatter.format( + it.timestamp, + DateFormatterMode.TimeOrDate, + ), + ) + } + .toImmutableList() + ) + } + // Sort aggregated reactions by count and then timestamp ascending, using + // the most recent reaction in the aggregation(hence index 0). + // This appends new aggregations on the end of the reaction layout. + aggregatedReactions = aggregatedReactions + .sortedWith( + compareByDescending { it.count } + .thenBy { it.senders[0].timestamp } + ) + return TimelineItemReactions( + reactions = aggregatedReactions.toImmutableList() + ) + } + + private fun MatrixTimelineItem.Event.computeReadReceiptState( + roomMembers: List, + ): TimelineItemReadReceipts { + if (!config.computeReadReceipts) { + return TimelineItemReadReceipts(receipts = persistentListOf()) + } + return TimelineItemReadReceipts( + receipts = event.receipts + .map { receipt -> + val roomMember = roomMembers.find { it.userId == receipt.userId } + ReadReceiptData( + avatarData = AvatarData( + id = receipt.userId.value, + name = roomMember?.displayName, + url = roomMember?.avatarUrl, + size = AvatarSize.TimelineReadReceipt, + ), + formattedDate = dateFormatter.format( + receipt.timestamp, + mode = DateFormatterMode.TimeOrDate, + ) + ) + } + .toImmutableList() + ) + } + + private fun computeGroupPosition( + currentTimelineItem: MatrixTimelineItem.Event, + timelineItems: List, + 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 + } + } + } + // In the following case, we have nextSender != currentSender == true + previousSender == currentSender -> { + 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 0000000..afdd973 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemDaySeparatorFactory.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.factories.virtual + +import dev.zacsweers.metro.Inject +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.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem + +@Inject +class TimelineItemDaySeparatorFactory( + private val dateFormatter: DateFormatter, +) { + fun create(virtualItem: VirtualTimelineItem.DayDivider): TimelineItemVirtualModel { + val formattedDate = dateFormatter.format( + timestamp = virtualItem.timestamp, + mode = DateFormatterMode.Day, + useRelative = true, + ) + 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 0000000..73fc466 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.factories.virtual + +import dev.zacsweers.metro.Inject +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLastForwardIndicatorModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemRoomBeginningModel +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel +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 + +@Inject +class TimelineItemVirtualFactory( + private val daySeparatorFactory: TimelineItemDaySeparatorFactory, +) { + fun create( + virtualTimelineItem: MatrixTimelineItem.Virtual, + ): TimelineItem.Virtual { + return TimelineItem.Virtual( + id = virtualTimelineItem.uniqueId, + 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.RoomBeginning -> TimelineItemRoomBeginningModel + is VirtualTimelineItem.LoadingIndicator -> TimelineItemLoadingIndicatorModel( + direction = inner.direction, + timestamp = inner.timestamp + ) + is VirtualTimelineItem.LastForwardIndicator -> TimelineItemLastForwardIndicatorModel + VirtualTimelineItem.TypingNotification -> TimelineItemTypingNotificationModel + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStateProvider.kt new file mode 100644 index 0000000..0a6fb70 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStateProvider.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.focus + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.timeline.FocusRequestState +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.errors.FocusEventException + +open class FocusRequestStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + FocusRequestState.Loading( + eventId = EventId("\$anEventId"), + ), + FocusRequestState.Failure( + FocusEventException.EventNotFound( + eventId = EventId("\$anEventId"), + ) + ), + FocusRequestState.Failure( + FocusEventException.InvalidEventId( + eventId = "invalid", + err = "An error" + ) + ), + FocusRequestState.Failure( + FocusEventException.Other( + msg = "An error" + ) + ), + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStateView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStateView.kt new file mode 100644 index 0000000..3dff7ac --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/focus/FocusRequestStateView.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.focus + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.window.DialogProperties +import io.element.android.features.messages.impl.timeline.FocusRequestState +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.room.errors.FocusEventException +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun FocusRequestStateView( + focusRequestState: FocusRequestState, + onClearFocusRequestState: () -> Unit, + modifier: Modifier = Modifier, +) { + when (focusRequestState) { + is FocusRequestState.Failure -> { + val errorMessage = when (focusRequestState.throwable) { + is FocusEventException.EventNotFound, + is FocusEventException.InvalidEventId -> stringResource(id = CommonStrings.error_message_not_found) + is FocusEventException.Other -> stringResource(id = CommonStrings.error_unknown) + else -> stringResource(id = CommonStrings.error_unknown) + } + ErrorDialog( + content = errorMessage, + onSubmit = onClearFocusRequestState, + modifier = modifier, + ) + } + is FocusRequestState.Loading -> { + ProgressDialog( + modifier = modifier, + properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true), + onDismissRequest = onClearFocusRequestState, + ) + } + else -> Unit + } +} + +@PreviewsDayNight +@Composable +internal fun FocusRequestStateViewPreview( + @PreviewParameter(FocusRequestStateProvider::class) state: FocusRequestState, +) = ElementPreview { + FocusRequestStateView( + focusRequestState = state, + onClearFocusRequestState = {}, + ) +} 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 0000000..12c4571 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.TimelineItemLegacyCallInviteContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent +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.TimelineItemRtcNotificationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent +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.timeline.model.event.TimelineItemVoiceContent +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent +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.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +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 TimelineItemStickerContent, + is TimelineItemFileContent, + is TimelineItemVideoContent, + is TimelineItemAudioContent, + is TimelineItemLocationContent, + is TimelineItemPollContent, + is TimelineItemVoiceContent, + TimelineItemRedactedContent, + TimelineItemUnknownContent, + is TimelineItemLegacyCallInviteContent, + is TimelineItemRtcNotificationContent -> 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) { + // Can be grouped + is FailedToParseMessageLikeContent, + is MessageContent, + RedactedContent, + is StickerContent, + is PollContent, + is UnableToDecryptContent -> true + // Can't be grouped + is FailedToParseStateContent, + is ProfileChangeContent, + is RoomMembershipContent, + UnknownContent, + is LegacyCallInviteContent, + CallNotifyContent, + 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 0000000..6af4f8b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouper.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.groups + +import androidx.annotation.VisibleForTesting +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +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.UniqueId +import kotlinx.collections.immutable.toImmutableList + +@SingleIn(RoomScope::class) +@Inject +class TimelineItemGrouper { + /** + * Keys are identifier of items in a group, only one by group will be kept. + * Values are the actual groupIds. + */ + private val groupIds = HashMap() + + /** + * Create a new list of [TimelineItem] by grouping some of them into [TimelineItem.GroupedEvents]. + */ + fun group(from: List): List { + val result = mutableListOf() + val currentGroup = mutableListOf() + 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.addGroup( + groupIds: MutableMap, + groupOfItems: MutableList +) { + 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 = UniqueId(groupId), + events = groupOfItems.toImmutableList(), + aggregatedReadReceipts = groupOfItems.flatMap { it.readReceiptState.receipts }.toImmutableList() + ) + ) + } +} + +private fun MutableMap.getOrPutGroupId(timelineItems: List): String { + assert(timelineItems.isNotEmpty()) + for (item in timelineItems) { + val itemIdentifier = item.identifier() + if (this.contains(itemIdentifier.value)) { + return this[itemIdentifier.value]!! + } + } + val timelineItem = timelineItems.first() + return computeGroupIdWith(timelineItem).value.also { groupId -> + this[timelineItem.identifier().value] = groupId + } +} + +@VisibleForTesting +internal fun computeGroupIdWith(timelineItem: TimelineItem): UniqueId = UniqueId("${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 0000000..5fab807 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReaction.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model + +import io.element.android.libraries.core.extensions.ellipsize +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.collections.immutable.ImmutableList + +/** + * 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 currentUserId the ID of the currently logged in user + * @property key the full reaction key (e.g. "👍", "YES!") + * @property senders the list of users who sent the reactions + */ +data class AggregatedReaction( + val currentUserId: UserId, + val key: String, + val senders: ImmutableList +) { + /** + * The key to be displayed on screen. + * + * See [MAX_DISPLAY_CHARS]. + */ + val displayKey: String = key.ellipsize(MAX_DISPLAY_CHARS) + + /** + * The number of users who reacted with this key. + */ + val count: Int = senders.count() + + /** + * True if the reaction has (also) been sent by the current user. + */ + val isHighlighted: Boolean = senders.any { it.senderId == currentUserId } +} 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 0000000..e5eb52c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.collections.immutable.toImmutableList +import java.text.DateFormat +import java.util.Date +import java.util.TimeZone + +open class AggregatedReactionProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf(false, true).flatMap { + sequenceOf( + anAggregatedReaction(isHighlighted = it), + anAggregatedReaction(isHighlighted = it, count = 88), + ) + } +} + +fun anAggregatedReaction( + userId: UserId = UserId("@alice:server.org"), + key: String = "👍", + count: Int = 1, + isHighlighted: Boolean = false, +): AggregatedReaction { + val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, java.util.Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + val timestamp = 1_689_061_264L + val date = Date(timestamp) + val senders = buildList { + repeat(count) { index -> + add( + AggregatedReactionSender( + senderId = if (isHighlighted && index == 0) userId else UserId("@user$index:server.org"), + timestamp = timestamp, + sentTime = timeFormatter.format(date), + ) + ) + } + } + return AggregatedReaction( + currentUserId = userId, + key = key, + senders = senders.toImmutableList() + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionSender.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionSender.kt new file mode 100644 index 0000000..347de26 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionSender.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser + +data class AggregatedReactionSender( + val senderId: UserId, + val timestamp: Long, + val sentTime: String, + val user: MatrixUser? = null +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/NewEventState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/NewEventState.kt new file mode 100644 index 0000000..cac2798 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/NewEventState.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model + +/** + * Model if there is a new event in the timeline and if it is from me or from other. + * This can be used to scroll to the bottom of the list when a new event is added. + */ +enum class NewEventState { + None, + FromMe, + FromOther +} 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 0000000..1f01b16 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.TimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent +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.SendHandle +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.UniqueId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield +import io.element.android.libraries.matrix.api.timeline.item.event.MessageShieldProvider +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +import io.element.android.libraries.matrix.api.timeline.item.event.SendHandleProvider +import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemDebugInfoProvider +import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin +import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails +import kotlinx.collections.immutable.ImmutableList + +@Immutable +sealed interface TimelineItem { + fun identifier(): UniqueId = when (this) { + is Event -> id + is Virtual -> id + is GroupedEvents -> id + } + + fun isEvent(eventId: EventId?): Boolean { + if (eventId == null) return false + return when (this) { + is Event -> this.eventId == eventId + else -> false + } + } + + fun contentType(): String = when (this) { + is Event -> content.type + is Virtual -> model.type + is GroupedEvents -> "groupedEvent" + } + + data class Virtual( + val id: UniqueId, + val model: TimelineItemVirtualModel + ) : TimelineItem + + data class Event( + val id: UniqueId, + // Note: eventId can be null when the event is a local echo + val eventId: EventId? = null, + val transactionId: TransactionId? = null, + val senderId: UserId, + val senderProfile: ProfileDetails, + val senderAvatar: AvatarData, + val content: TimelineItemEventContent, + val sentTimeMillis: Long = 0L, + val sentTime: String = "", + val isMine: Boolean = false, + val isEditable: Boolean, + val canBeRepliedTo: Boolean, + val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, + val reactionsState: TimelineItemReactions, + val readReceiptState: TimelineItemReadReceipts, + val localSendState: LocalEventSendState?, + val inReplyTo: InReplyToDetails?, + val threadInfo: TimelineItemThreadInfo?, + val origin: TimelineItemEventOrigin?, + val timelineItemDebugInfoProvider: TimelineItemDebugInfoProvider, + val messageShieldProvider: MessageShieldProvider, + val sendHandleProvider: SendHandleProvider, + ) : TimelineItem { + val showSenderInformation = groupPosition.isNew() && !isMine + + val safeSenderName: String = senderProfile.getDisambiguatedDisplayName(senderId) + + val failedToSend: Boolean = localSendState is LocalEventSendState.Failed + + val isTextMessage: Boolean = content is TimelineItemTextBasedContent + + val isSticker: Boolean = content is TimelineItemStickerContent + + val isRemote = eventId != null + + /** Whether a click on any part of the event bubble should trigger the 'onContentClick' callback. + * + * This is `true` for all events except for visual media events with a caption or formatted caption. + */ + val isWholeContentClickable = when (content) { + is TimelineItemStickerContent -> content.formattedCaption == null && content.caption == null + is TimelineItemImageContent -> content.formattedCaption == null && content.caption == null + is TimelineItemVideoContent -> content.formattedCaption == null && content.caption == null + else -> true + } + + val eventOrTransactionId: EventOrTransactionId + get() = EventOrTransactionId.from(eventId = eventId, transactionId = transactionId) + + // No need to be lazy here? + val messageShield: MessageShield? = messageShieldProvider(strict = false) + + val debugInfo: TimelineItemDebugInfo + get() = timelineItemDebugInfoProvider() + + val sendhandle: SendHandle? get() = sendHandleProvider() + } + + data class GroupedEvents( + val id: UniqueId, + val events: ImmutableList, + val aggregatedReadReceipts: ImmutableList, + ) : TimelineItem +} + +sealed interface TimelineItemThreadInfo { + data class ThreadRoot(val summary: ThreadSummary, val latestEventText: String?) : TimelineItemThreadInfo + data class ThreadResponse(val threadRootId: ThreadId) : TimelineItemThreadInfo +} 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 0000000..32cbe89 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemGroupPosition.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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. + */ + data 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. + */ + data object Middle : TimelineItemGroupPosition + + /** + * The event is part of a group of events from the same sender and is the last sent Event. + */ + data 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. + */ + data 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/TimelineItemReactions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReactions.kt new file mode 100644 index 0000000..b20cc51 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReactions.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +data class TimelineItemReactions( + val reactions: ImmutableList +) { + val highlightedKeys: ImmutableList + get() = reactions + .filter { it.isHighlighted } + .map { it.key } + .toImmutableList() +} 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 0000000..3190e61 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReactionsProvider.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model + +import kotlinx.collections.immutable.toImmutableList + +fun aTimelineItemReactions() = TimelineItemReactions( + // Use values from AggregatedReactionProvider + reactions = AggregatedReactionProvider().values.toImmutableList() +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReadReceipts.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReadReceipts.kt new file mode 100644 index 0000000..e8d423e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemReadReceipts.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model + +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import kotlinx.collections.immutable.ImmutableList + +data class TimelineItemReadReceipts( + val receipts: ImmutableList, +) + +data class ReadReceiptData( + val avatarData: AvatarData, + val formattedDate: String, +) 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 0000000..2cbd145 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/bubble/BubbleState.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.bubble + +import io.element.android.features.messages.impl.timeline.TimelineRoomInfo +import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition + +data class BubbleState( + val groupPosition: TimelineItemGroupPosition, + val isMine: Boolean, + val timelineRoomInfo: TimelineRoomInfo, +) { + /** True to cut out the top start corner of the bubble, to give margin for the sender avatar. */ + val cutTopStart: Boolean = groupPosition.isNew() && !isMine && !timelineRoomInfo.isDm +} 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 0000000..cdd24ed --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/bubble/BubbleStateProvider.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.TimelineRoomInfo +import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo +import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition + +open class BubbleStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TimelineItemGroupPosition.First, + TimelineItemGroupPosition.Middle, + TimelineItemGroupPosition.Last, + TimelineItemGroupPosition.None, + ).map { groupPosition -> + sequenceOf(false, true).map { isMine -> + aBubbleState( + groupPosition = groupPosition, + isMine = isMine, + ) + } + } + .flatten() +} + +internal fun aBubbleState( + groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.First, + isMine: Boolean = false, + timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(), +) = BubbleState( + groupPosition = groupPosition, + isMine = isMine, + timelineRoomInfo = timelineRoomInfo, +) 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 0000000..f28de4f --- /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) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize +import kotlin.time.Duration + +data class TimelineItemAudioContent( + override val filename: String, + override val fileSize: Long?, + override val caption: String?, + override val formattedCaption: CharSequence?, + override val isEdited: Boolean, + val duration: Duration, + override val mediaSource: MediaSource, + override val mimeType: String, + override val formattedFileSize: String, + override val fileExtension: String, +) : TimelineItemEventContentWithAttachment { + 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 0000000..9fddfa9 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 +import kotlin.time.Duration.Companion.milliseconds + +open class TimelineItemAudioContentProvider : PreviewParameterProvider { + override val values: Sequence + 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"), + aTimelineItemAudioContent(caption = "A caption"), + aTimelineItemAudioContent(caption = "An even bigger bigger bigger bigger bigger bigger bigger caption"), + ) +} + +fun aTimelineItemAudioContent( + fileName: String = "A sound.mp3", + caption: String? = null, +) = TimelineItemAudioContent( + filename = fileName, + fileSize = 100 * 1024L, + caption = caption, + formattedCaption = null, + isEdited = false, + mimeType = MimeTypes.Mp3, + formattedFileSize = "100kB", + fileExtension = "mp3", + duration = 100.milliseconds, + mediaSource = 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 0000000..28946e8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.libraries.matrix.ui.messages.toPlainText +import org.jsoup.nodes.Document + +data class TimelineItemEmoteContent( + override val body: String, + override val htmlDocument: Document?, + override val formattedBody: CharSequence, + override val isEdited: Boolean, +) : TimelineItemTextBasedContent { + override val type: String = "TimelineItemEmoteContent" + override val plainText: String = htmlDocument?.toPlainText() ?: body +} 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 0000000..5d82d17 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContent.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/TimelineItemEncryptedContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContentProvider.kt new file mode 100644 index 0000000..bb53da3 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEncryptedContentProvider.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 io.element.android.libraries.matrix.api.timeline.item.event.UtdCause + +open class TimelineItemEncryptedContentProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aTimelineItemEncryptedContent(), + aTimelineItemEncryptedContent( + data = UnableToDecryptContent.Data.MegolmV1AesSha2( + sessionId = "sessionId", + utdCause = UtdCause.SentBeforeWeJoined, + ) + ), + aTimelineItemEncryptedContent( + data = UnableToDecryptContent.Data.MegolmV1AesSha2( + sessionId = "sessionId", + utdCause = UtdCause.VerificationViolation, + ) + ), + aTimelineItemEncryptedContent( + data = UnableToDecryptContent.Data.MegolmV1AesSha2( + sessionId = "sessionId", + utdCause = UtdCause.UnsignedDevice, + ) + ), + aTimelineItemEncryptedContent( + data = UnableToDecryptContent.Data.MegolmV1AesSha2( + sessionId = "sessionId", + utdCause = UtdCause.HistoricalMessageAndBackupIsDisabled, + ) + ), + aTimelineItemEncryptedContent( + data = UnableToDecryptContent.Data.MegolmV1AesSha2( + sessionId = "sessionId", + utdCause = UtdCause.HistoricalMessageAndDeviceIsUnverified, + ) + ), + aTimelineItemEncryptedContent( + data = UnableToDecryptContent.Data.MegolmV1AesSha2( + sessionId = "sessionId", + utdCause = UtdCause.WithheldUnverifiedOrInsecureDevice, + ) + ), + aTimelineItemEncryptedContent( + data = UnableToDecryptContent.Data.MegolmV1AesSha2( + sessionId = "sessionId", + utdCause = UtdCause.WithheldBySender, + ) + ), + aTimelineItemEncryptedContent( + data = UnableToDecryptContent.Data.MegolmV1AesSha2( + sessionId = "sessionId", + utdCause = UtdCause.Unknown, + ) + ), + ) +} + +private fun aTimelineItemEncryptedContent( + data: UnableToDecryptContent.Data = UnableToDecryptContent.Data.Unknown +) = TimelineItemEncryptedContent( + data = data +) 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 0000000..9c4c48d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.media.MediaSource +import kotlin.time.Duration + +@Immutable +sealed interface TimelineItemEventContent { + val type: String +} + +interface TimelineItemEventMutableContent { + /** Whether the event has been edited. */ + val isEdited: Boolean +} + +@Immutable +sealed interface TimelineItemEventContentWithAttachment : + TimelineItemEventContent, + TimelineItemEventMutableContent { + val filename: String + val fileSize: Long? + val caption: String? + val formattedCaption: CharSequence? + val mediaSource: MediaSource + val mimeType: String + val formattedFileSize: String + val fileExtension: String + + val bestDescription: String + get() = caption ?: filename +} + +/** + * Only text based content can be copied. + */ +fun TimelineItemEventContent.canBeCopied(): Boolean = + this is TimelineItemTextBasedContent + +/** + * Returns true if the event content can be forwarded. + */ +fun TimelineItemEventContent.canBeForwarded(): Boolean = + when (this) { + is TimelineItemTextBasedContent, + is TimelineItemImageContent, + is TimelineItemFileContent, + is TimelineItemAudioContent, + is TimelineItemVideoContent, + is TimelineItemLocationContent, + is TimelineItemVoiceContent -> true + // Stickers can't be forwarded (yet) so we don't show the option + // See https://github.com/element-hq/element-x-android/issues/2161 + is TimelineItemStickerContent -> false + else -> false + } + +/** + * Return true if user can react (i.e. send a reaction) on the event content. + * This does not take into account the power level of the user. + */ +fun TimelineItemEventContent.canReact(): Boolean = + when (this) { + is TimelineItemTextBasedContent, + is TimelineItemAudioContent, + is TimelineItemEncryptedContent, + is TimelineItemFileContent, + is TimelineItemImageContent, + is TimelineItemStickerContent, + is TimelineItemLocationContent, + is TimelineItemPollContent, + is TimelineItemVoiceContent, + is TimelineItemVideoContent -> true + is TimelineItemStateContent, + is TimelineItemRedactedContent, + is TimelineItemLegacyCallInviteContent, + is TimelineItemRtcNotificationContent, + TimelineItemUnknownContent -> false + } + +/** + * Whether the event content has been edited. + */ +fun TimelineItemEventContent.isEdited(): Boolean = when (this) { + is TimelineItemEventMutableContent -> isEdited + else -> false +} + +/** + * Whether the event content has been redacted. + */ +fun TimelineItemEventContent.isRedacted(): Boolean = this is TimelineItemRedactedContent + +fun TimelineItemEventContentWithAttachment.duration(): Duration? { + return when (this) { + is TimelineItemAudioContent -> duration + is TimelineItemVideoContent -> duration + is TimelineItemVoiceContent -> duration + else -> null + } +} 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 0000000..ac93a0a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import android.graphics.Typeface +import android.text.style.StyleSpan +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.core.text.buildSpannedString +import androidx.core.text.inSpans +import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent +import org.jsoup.nodes.Document + +class TimelineItemEventContentProvider : PreviewParameterProvider { + override val values = sequenceOf( + aTimelineItemEmoteContent(), + aTimelineItemEncryptedContent(), + aTimelineItemImageContent(), + aTimelineItemVideoContent(), + aTimelineItemFileContent(), + aTimelineItemFileContent("A bigger file name which doesn't fit.pdf"), + aTimelineItemAudioContent(), + aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit .mp3"), + aTimelineItemVoiceContent(), + aTimelineItemLocationContent(), + aTimelineItemLocationContent("Location description"), + aTimelineItemPollContent(), + aTimelineItemNoticeContent(), + aTimelineItemRedactedContent(), + aTimelineItemTextContent(), + aTimelineItemUnknownContent(), + aTimelineItemTextContent().copy(isEdited = true), + aTimelineItemTextContent(body = AN_EMOJI_ONLY_TEXT) + ) +} + +const val AN_EMOJI_ONLY_TEXT = "😁" + +class TimelineItemTextBasedContentProvider : PreviewParameterProvider { + private fun buildSpanned(text: String) = buildSpannedString { + inSpans(StyleSpan(Typeface.BOLD)) { + append("Rich Text") + } + append(" ") + append(text) + } + + override val values = sequenceOf( + aTimelineItemEmoteContent(), + aTimelineItemEmoteContent().copy(formattedBody = buildSpanned("Emote")), + aTimelineItemNoticeContent(), + aTimelineItemNoticeContent().copy(formattedBody = buildSpanned("Notice")), + aTimelineItemTextContent(), + aTimelineItemTextContent().copy(formattedBody = buildSpanned("Text")), + ) +} + +fun aTimelineItemEmoteContent( + body: String = "Emote", + htmlDocument: Document? = null, + formattedBody: CharSequence = body, + isEdited: Boolean = false, +) = TimelineItemEmoteContent( + body = body, + htmlDocument = htmlDocument, + formattedBody = formattedBody, + isEdited = isEdited, +) + +fun aTimelineItemEncryptedContent() = TimelineItemEncryptedContent( + data = UnableToDecryptContent.Data.Unknown +) + +fun aTimelineItemNoticeContent( + body: String = "Notice", + htmlDocument: Document? = null, + formattedBody: CharSequence = body, + isEdited: Boolean = false, +) = TimelineItemNoticeContent( + body = body, + htmlDocument = htmlDocument, + formattedBody = formattedBody, + isEdited = isEdited, +) + +fun aTimelineItemRedactedContent() = TimelineItemRedactedContent + +fun aTimelineItemTextContent( + body: String = "Text", + htmlDocument: Document? = null, + formattedBody: CharSequence = body, + isEdited: Boolean = false, +) = TimelineItemTextContent( + body = body, + htmlDocument = htmlDocument, + formattedBody = formattedBody, + isEdited = isEdited, +) + +fun aTimelineItemUnknownContent() = TimelineItemUnknownContent + +fun aTimelineItemStateEventContent( + body: String = "A state event", +) = TimelineItemStateEventContent( + body = body, +) 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 0000000..680797e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContent.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize + +data class TimelineItemFileContent( + override val filename: String, + override val fileSize: Long?, + override val caption: String?, + override val formattedCaption: CharSequence?, + override val isEdited: Boolean, + override val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + override val formattedFileSize: String, + override val fileExtension: String, + override val mimeType: String, +) : TimelineItemEventContentWithAttachment { + 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 0000000..58e25e5 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemFileContentProvider.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 { + override val values: Sequence + 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"), + aTimelineItemFileContent(caption = "A caption"), + aTimelineItemFileContent(caption = "An even bigger bigger bigger bigger bigger bigger bigger caption"), + ) +} + +fun aTimelineItemFileContent( + fileName: String = "A file.pdf", + caption: String? = null, +) = TimelineItemFileContent( + filename = fileName, + fileSize = 100 * 1024L, + caption = caption, + formattedCaption = null, + isEdited = false, + thumbnailSource = null, + mediaSource = 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 0000000..4828f22 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAnimatedImage +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT +import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH +import io.element.android.libraries.matrix.ui.media.MediaRequestData + +data class TimelineItemImageContent( + override val filename: String, + override val fileSize: Long?, + override val caption: String?, + override val formattedCaption: CharSequence?, + override val isEdited: Boolean, + override val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + override val formattedFileSize: String, + override val fileExtension: String, + override val mimeType: String, + val blurhash: String?, + val width: Int?, + val height: Int?, + val thumbnailWidth: Int?, + val thumbnailHeight: Int?, + val aspectRatio: Float? +) : TimelineItemEventContentWithAttachment { + override val type: String = "TimelineItemImageContent" + + val showCaption = caption != null + + val thumbnailMediaRequestData: MediaRequestData by lazy { + if (mimeType.isMimeTypeAnimatedImage()) { + MediaRequestData( + source = mediaSource, + kind = MediaRequestData.Kind.File( + fileName = filename, + mimeType = mimeType + ) + ) + } else { + MediaRequestData( + source = thumbnailSource ?: mediaSource, + kind = MediaRequestData.Kind.Thumbnail( + width = thumbnailWidth?.toLong() ?: MAX_THUMBNAIL_WIDTH, + height = thumbnailHeight?.toLong() ?: MAX_THUMBNAIL_HEIGHT + ), + ) + } + } +} 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 0000000..d07d5db --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContentProvider.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 +import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH + +open class TimelineItemImageContentProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aTimelineItemImageContent(), + aTimelineItemImageContent(aspectRatio = 1.0f), + aTimelineItemImageContent(aspectRatio = 1.5f), + aTimelineItemImageContent(blurhash = null), + ) +} + +fun aTimelineItemImageContent( + aspectRatio: Float? = 0.5f, + blurhash: String? = A_BLUR_HASH, + filename: String = "A picture.jpg", + caption: String? = null, +) = TimelineItemImageContent( + filename = filename, + fileSize = 4 * 1024 * 1024L, + caption = caption, + formattedCaption = null, + isEdited = false, + mediaSource = MediaSource(""), + thumbnailSource = null, + mimeType = MimeTypes.IMAGE_JPEG, + blurhash = blurhash, + width = null, + height = 300, + thumbnailWidth = null, + thumbnailHeight = 150, + aspectRatio = aspectRatio, + formattedFileSize = "4MB", + fileExtension = "jpg" +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLegacyCallInviteContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLegacyCallInviteContent.kt new file mode 100644 index 0000000..088e438 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLegacyCallInviteContent.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.libraries.matrix.api.timeline.item.event.EventType + +data object TimelineItemLegacyCallInviteContent : TimelineItemEventContent { + override val type: String + get() = EventType.CALL_INVITE +} 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 0000000..1114b2a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..0fd3f5f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 { + override val values: Sequence + 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 0000000..bb6f6db --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.libraries.matrix.ui.messages.toPlainText +import org.jsoup.nodes.Document + +data class TimelineItemNoticeContent( + override val body: String, + override val htmlDocument: Document?, + override val formattedBody: CharSequence, + override val isEdited: Boolean, +) : TimelineItemTextBasedContent { + override val type: String = "TimelineItemNoticeContent" + override val plainText: String = htmlDocument?.toPlainText() ?: body +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt new file mode 100644 index 0000000..8ddeb3d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.features.poll.api.pollcontent.PollAnswerItem +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.poll.PollKind + +data class TimelineItemPollContent( + val isMine: Boolean, + val isEditable: Boolean, + val eventId: EventId?, + val question: String, + val answerItems: List, + val pollKind: PollKind, + val isEnded: Boolean, + override val isEdited: Boolean, +) : TimelineItemEventContent, + TimelineItemEventMutableContent { + override val type: String = "TimelineItemPollContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt new file mode 100644 index 0000000..26577f5 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.poll.api.pollcontent.PollAnswerItem +import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList +import io.element.android.features.poll.api.pollcontent.aPollQuestion +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.poll.PollKind + +open class TimelineItemPollContentProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aTimelineItemPollContent(), + aTimelineItemPollContent().copy(pollKind = PollKind.Undisclosed), + aTimelineItemPollContent().copy(isMine = true), + aTimelineItemPollContent().copy(isMine = true, isEditable = true), + ) +} + +fun aTimelineItemPollContent( + question: String = aPollQuestion(), + answerItems: List = aPollAnswerItemList(), + isMine: Boolean = false, + isEditable: Boolean = false, + isEnded: Boolean = false, + isEdited: Boolean = false, +): TimelineItemPollContent { + return TimelineItemPollContent( + eventId = EventId("\$anEventId"), + pollKind = PollKind.Disclosed, + question = question, + answerItems = answerItems, + isMine = isMine, + isEditable = isEditable, + isEnded = isEnded, + isEdited = isEdited, + ) +} 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 0000000..b4d7530 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemProfileChangeContent.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..2a9a23d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRedactedContent.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +data 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 0000000..4d43f0d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRoomMembershipContent.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/TimelineItemRtcNotificationContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRtcNotificationContent.kt new file mode 100644 index 0000000..00ad32b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemRtcNotificationContent.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +class TimelineItemRtcNotificationContent : TimelineItemEventContent { + override val type: String = "org.matrix.msc4075.rtc.notification" +} 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 0000000..7293fb7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStateContent.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import androidx.compose.runtime.Immutable + +@Immutable +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 0000000..6c24e50 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStateEventContent.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/TimelineItemStickerContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt new file mode 100644 index 0000000..d333c0b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContent.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.libraries.matrix.api.media.MediaSource + +data class TimelineItemStickerContent( + override val filename: String, + override val fileSize: Long?, + override val caption: String?, + override val formattedCaption: CharSequence?, + override val isEdited: Boolean, + override val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + override val formattedFileSize: String, + override val fileExtension: String, + override val mimeType: String, + val blurhash: String?, + val width: Int?, + val height: Int?, + val aspectRatio: Float? +) : TimelineItemEventContentWithAttachment { + override val type: String = "TimelineItemStickerContent" + + /* Stickers are supposed to be small images so + we allow using the mediaSource (unless the url is empty) */ + val preferredMediaSource = if (mediaSource.url.isEmpty()) thumbnailSource else mediaSource +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentProvider.kt new file mode 100644 index 0000000..eb564cd --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemStickerContentProvider.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 +import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH + +open class TimelineItemStickerContentProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aTimelineItemStickerContent(), + aTimelineItemStickerContent(aspectRatio = 1.0f), + aTimelineItemStickerContent(aspectRatio = 1.5f), + aTimelineItemStickerContent(blurhash = null), + ) +} + +fun aTimelineItemStickerContent( + aspectRatio: Float = 0.5f, + blurhash: String? = A_BLUR_HASH, +) = TimelineItemStickerContent( + filename = "a sticker.gif", + fileSize = 4 * 1024 * 1024L, + caption = "a body", + formattedCaption = null, + isEdited = false, + mediaSource = MediaSource(""), + thumbnailSource = null, + mimeType = MimeTypes.IMAGE_JPEG, + blurhash = blurhash, + width = null, + height = 128, + aspectRatio = aspectRatio, + formattedFileSize = "4MB", + fileExtension = "jpg" +) 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 0000000..4d941e7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import androidx.compose.runtime.Immutable +import org.jsoup.nodes.Document + +/** + * Represents a text based content of a timeline item event (a message, a notice, an emote event...). + */ +@Immutable +sealed interface TimelineItemTextBasedContent : + TimelineItemEventContent, + TimelineItemEventMutableContent { + /** The raw body of the event, in Markdown format. */ + val body: String + + /** The parsed HTML DOM of the formatted event body. */ + val htmlDocument: Document? + + /** The formatted body of the event, already parsed and with the DOM translated to Android spans. + * This can also includes mention spans from permalink parsing */ + val formattedBody: CharSequence + + /** The plain text version of the event body. This is the Markdown version without actual Markdown formatting. */ + val plainText: String + + /** The raw HTML body of the event. */ + val htmlBody: String? + get() = htmlDocument?.body()?.html() +} 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 0000000..2de0d6c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.libraries.matrix.ui.messages.toPlainText +import org.jsoup.nodes.Document + +data class TimelineItemTextContent( + override val body: String, + override val htmlDocument: Document?, + override val formattedBody: CharSequence, + override val isEdited: Boolean, +) : TimelineItemTextBasedContent { + override val type: String = "TimelineItemTextContent" + override val plainText: String = htmlDocument?.toPlainText() ?: body +} 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 0000000..ce9525c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemUnknownContent.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..80635c6 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContent.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.libraries.matrix.api.media.MediaSource +import kotlin.time.Duration + +data class TimelineItemVideoContent( + override val filename: String, + override val fileSize: Long?, + override val caption: String?, + override val formattedCaption: CharSequence?, + override val isEdited: Boolean, + val duration: Duration, + override val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + val aspectRatio: Float?, + val blurHash: String?, + val height: Int?, + val width: Int?, + val thumbnailWidth: Int?, + val thumbnailHeight: Int?, + override val mimeType: String, + override val formattedFileSize: String, + override val fileExtension: String, +) : TimelineItemEventContentWithAttachment { + override val type: String = "TimelineItemImageContent" + + val showCaption = caption != null +} 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 0000000..0fadb6c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVideoContentProvider.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 +import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH +import kotlin.time.Duration.Companion.milliseconds + +open class TimelineItemVideoContentProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aTimelineItemVideoContent(), + aTimelineItemVideoContent(aspectRatio = 1.0f), + aTimelineItemVideoContent(aspectRatio = 1.5f), + aTimelineItemVideoContent(blurhash = null), + ) +} + +fun aTimelineItemVideoContent( + aspectRatio: Float = 0.5f, + blurhash: String? = A_BLUR_HASH, +) = TimelineItemVideoContent( + filename = "Video.mp4", + fileSize = 14 * 1024 * 1024L, + caption = null, + formattedCaption = null, + isEdited = false, + thumbnailSource = null, + blurHash = blurhash, + aspectRatio = aspectRatio, + duration = 100.milliseconds, + mediaSource = MediaSource(""), + width = 150, + height = 300, + thumbnailWidth = 150, + thumbnailHeight = 300, + mimeType = MimeTypes.Mp4, + formattedFileSize = "14MB", + fileExtension = "mp4" +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt new file mode 100644 index 0000000..29a432e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.event + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import kotlinx.collections.immutable.ImmutableList +import kotlin.time.Duration + +data class TimelineItemVoiceContent( + val eventId: EventId?, + override val filename: String, + override val fileSize: Long?, + override val caption: String?, + override val formattedCaption: CharSequence?, + override val isEdited: Boolean, + val duration: Duration, + override val mediaSource: MediaSource, + override val formattedFileSize: String, + override val fileExtension: String, + override val mimeType: String, + val waveform: ImmutableList, +) : TimelineItemEventContentWithAttachment { + override val type: String = "TimelineItemAudioContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt new file mode 100644 index 0000000..d596963 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import kotlinx.collections.immutable.toImmutableList +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes + +open class TimelineItemVoiceContentProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aTimelineItemVoiceContent( + duration = 1.milliseconds, + waveform = listOf(), + ), + aTimelineItemVoiceContent( + duration = 10_000.milliseconds, + waveform = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f), + ), + aTimelineItemVoiceContent( + duration = 30.minutes, + waveform = List(1024) { it / 1024f }, + ), + ) +} + +fun aTimelineItemVoiceContent( + eventId: EventId? = EventId("\$anEventId"), + filename: String = "filename doesn't really matter for a voice message", + caption: String? = "body doesn't really matter for a voice message", + duration: Duration = 61_000.milliseconds, + contentUri: String = "mxc://matrix.org/1234567890abcdefg", + mimeType: String = MimeTypes.Ogg, + mediaSource: MediaSource = MediaSource(contentUri), + waveform: List = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f), +) = TimelineItemVoiceContent( + eventId = eventId, + fileSize = 1024 * 1024, + filename = filename, + caption = caption, + formattedCaption = null, + isEdited = false, + duration = duration, + mediaSource = mediaSource, + mimeType = mimeType, + waveform = waveform.toImmutableList(), + formattedFileSize = "1.0 MB", + fileExtension = "ogg", +) 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 0000000..a555644 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModel.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..d31d35d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemDaySeparatorModelProvider.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.virtual + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +class TimelineItemDaySeparatorModelProvider : PreviewParameterProvider { + 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/TimelineItemLastForwardIndicatorModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLastForwardIndicatorModel.kt new file mode 100644 index 0000000..a87b577 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLastForwardIndicatorModel.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.virtual + +data object TimelineItemLastForwardIndicatorModel : TimelineItemVirtualModel { + override val type: String = "TimelineItemLastForwardIndicatorModel" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLoadingIndicatorModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLoadingIndicatorModel.kt new file mode 100644 index 0000000..f05110c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemLoadingIndicatorModel.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.virtual + +import io.element.android.libraries.matrix.api.timeline.Timeline + +data class TimelineItemLoadingIndicatorModel( + val direction: Timeline.PaginationDirection, + val timestamp: Long, +) : TimelineItemVirtualModel { + override val type: String = "TimelineItemLoadingIndicatorModel" +} 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 0000000..b163760 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemReadMarkerModel.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.virtual + +data 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/TimelineItemRoomBeginningModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemRoomBeginningModel.kt new file mode 100644 index 0000000..b53c96d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemRoomBeginningModel.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.virtual + +data object TimelineItemRoomBeginningModel : TimelineItemVirtualModel { + override val type: String = "TimelineItemRoomBeginningModel" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemTypingNotificationModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemTypingNotificationModel.kt new file mode 100644 index 0000000..cdb957e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemTypingNotificationModel.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model.virtual + +data object TimelineItemTypingNotificationModel : TimelineItemVirtualModel { + override val type: String = "TimelineItemTypingNotificationModel" +} 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 0000000..b1ab5c9 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemVirtualModel.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/protection/AspectRatioProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/AspectRatioProvider.kt new file mode 100644 index 0000000..0ebd634 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/AspectRatioProvider.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.protection + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +class AspectRatioProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + null, + 0.05f, + 1f, + 20f, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/ProtectedView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/ProtectedView.kt new file mode 100644 index 0000000..de55735 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/ProtectedView.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.protection + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +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.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.messages.impl.timeline.components.event.TimelineItemAspectRatioBox +import io.element.android.libraries.designsystem.components.blurhash.blurHashBackground +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH +import io.element.android.libraries.ui.strings.CommonStrings + +@SuppressWarnings("ModifierClickableOrder") +@Composable +fun ProtectedView( + hideContent: Boolean, + onShowClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + if (hideContent) { + Box( + modifier = modifier + .fillMaxSize() + .background(Color(0x99000000)), + contentAlignment = Alignment.Center, + ) { + ElementTheme(darkTheme = false, applySystemBarsUpdate = false) { + // Not using a button to be able to have correct size + Text( + modifier = Modifier + .clip(RoundedCornerShape(percent = 50)) + .clickable( + onClick = onShowClick, + role = Role.Button, + ) + .padding(4.dp) + .border( + width = 1.dp, + color = ElementTheme.colors.borderInteractiveSecondary, + shape = CircleShape, + ) + .padding( + horizontal = 16.dp, + vertical = 4.dp, + ), + text = stringResource(CommonStrings.action_show), + color = ElementTheme.colors.textOnSolidPrimary, + style = ElementTheme.typography.fontBodyLgMedium, + ) + } + } + } else { + content() + } +} + +@PreviewsDayNight +@Composable +internal fun ProtectedViewPreview( + @PreviewParameter(AspectRatioProvider::class) aspectRatio: Float?, +) = ElementPreview { + TimelineItemAspectRatioBox( + modifier = Modifier.blurHashBackground(A_BLUR_HASH, alpha = 0.9f), + aspectRatio = coerceRatioWhenHidingContent(aspectRatio, true), + ) { + ProtectedView( + hideContent = true, + onShowClick = {}, + content = {}, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/RatioHelper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/RatioHelper.kt new file mode 100644 index 0000000..d6031dc --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/RatioHelper.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.protection + +fun coerceRatioWhenHidingContent(aspectRatio: Float?, hideContent: Boolean): Float? { + return if (hideContent) { + aspectRatio?.coerceIn( + minimumValue = 0.5f, + maximumValue = 3f + ) + } else { + aspectRatio + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineItem.kt new file mode 100644 index 0000000..5a5363f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineItem.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.protection + +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.TimelineItemEmoteContent +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.TimelineItemLegacyCallInviteContent +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.TimelineItemPollContent +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.TimelineItemRtcNotificationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent +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.model.event.TimelineItemVoiceContent + +/** + * Return true if the event must be hidden by default when the setting to hide images and videos is enabled. + */ +fun TimelineItem.mustBeProtected(): Boolean { + return when (this) { + is TimelineItem.Event -> when (content) { + is TimelineItemImageContent, + is TimelineItemVideoContent, + is TimelineItemStickerContent -> true + is TimelineItemAudioContent, + is TimelineItemRtcNotificationContent, + is TimelineItemEncryptedContent, + is TimelineItemFileContent, + TimelineItemLegacyCallInviteContent, + is TimelineItemLocationContent, + is TimelineItemPollContent, + TimelineItemRedactedContent, + is TimelineItemProfileChangeContent, + is TimelineItemRoomMembershipContent, + is TimelineItemStateEventContent, + is TimelineItemEmoteContent, + is TimelineItemNoticeContent, + is TimelineItemTextContent, + TimelineItemUnknownContent, + is TimelineItemVoiceContent -> false + } + is TimelineItem.Virtual -> false + is TimelineItem.GroupedEvents -> false + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionEvent.kt new file mode 100644 index 0000000..3d75268 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionEvent.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.protection + +import io.element.android.libraries.matrix.api.core.EventId + +sealed interface TimelineProtectionEvent { + data class ShowContent(val eventId: EventId?) : TimelineProtectionEvent +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt new file mode 100644 index 0000000..b2ea1d6 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.protection + +import androidx.compose.runtime.Composable +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 dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.mapState +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaPreviewService +import io.element.android.libraries.matrix.api.media.isPreviewEnabled +import io.element.android.libraries.matrix.api.room.BaseRoom +import kotlinx.collections.immutable.toImmutableSet + +@Inject +class TimelineProtectionPresenter( + private val mediaPreviewService: MediaPreviewService, + private val room: BaseRoom, +) : Presenter { + private val allowedEvents = mutableStateOf>(setOf()) + + @Composable + override fun present(): TimelineProtectionState { + val mediaPreviewValue = remember { + mediaPreviewService.mediaPreviewConfigFlow.mapState { config -> config.mediaPreviewValue } + }.collectAsState() + val roomInfo = room.roomInfoFlow.collectAsState() + val protectionState by remember { + derivedStateOf { + val isPreviewEnabled = mediaPreviewValue.value.isPreviewEnabled(roomInfo.value.joinRule) + if (isPreviewEnabled) { + ProtectionState.RenderAll + } else { + ProtectionState.RenderOnly(eventIds = allowedEvents.value.toImmutableSet()) + } + } + } + + fun handleEvent(event: TimelineProtectionEvent) { + when (event) { + is TimelineProtectionEvent.ShowContent -> { + allowedEvents.value = allowedEvents.value + setOfNotNull(event.eventId) + } + } + } + + return TimelineProtectionState( + protectionState = protectionState, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionState.kt new file mode 100644 index 0000000..169e5ba --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.protection + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.core.EventId +import kotlinx.collections.immutable.ImmutableSet + +data class TimelineProtectionState( + val protectionState: ProtectionState, + val eventSink: (TimelineProtectionEvent) -> Unit, +) { + fun hideMediaContent(eventId: EventId?) = when (protectionState) { + is ProtectionState.RenderAll -> false + is ProtectionState.RenderOnly -> eventId !in protectionState.eventIds + } +} + +@Immutable +sealed interface ProtectionState { + data object RenderAll : ProtectionState + data class RenderOnly(val eventIds: ImmutableSet) : ProtectionState +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStateProvider.kt new file mode 100644 index 0000000..3e7bfdb --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStateProvider.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.protection + +fun aTimelineProtectionState( + protectionState: ProtectionState = ProtectionState.RenderAll, + eventSink: (TimelineProtectionEvent) -> Unit = {}, +) = TimelineProtectionState( + protectionState = protectionState, + eventSink = eventSink, +) 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 0000000..311ff8c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/util/Modifiers.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/topbars/MessagesViewTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt new file mode 100644 index 0000000..7727b28 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.topbars + +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.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +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.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.timeline.components.CallMenuItem +import io.element.android.features.roomcall.api.RoomCallState +import io.element.android.features.roomcall.api.aStandByCallState +import io.element.android.features.roomcall.api.anOngoingCallState +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.avatar.AvatarType +import io.element.android.libraries.designsystem.components.avatar.anAvatarData +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +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.matrix.api.encryption.identity.IdentityState +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun MessagesViewTopBar( + roomName: String?, + roomAvatar: AvatarData, + isTombstoned: Boolean, + heroes: ImmutableList, + roomCallState: RoomCallState, + dmUserIdentityState: IdentityState?, + onRoomDetailsClick: () -> Unit, + onJoinCallClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + modifier = modifier, + navigationIcon = { + BackButton(onClick = onBackClick) + }, + title = { + val roundedCornerShape = RoundedCornerShape(8.dp) + Row( + modifier = Modifier + .clip(roundedCornerShape) + .clickable { onRoomDetailsClick() }, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val titleModifier = Modifier.weight(1f, fill = false) + RoomAvatarAndNameRow( + roomName = roomName, + roomAvatar = roomAvatar, + isTombstoned = isTombstoned, + heroes = heroes, + modifier = titleModifier + ) + + when (dmUserIdentityState) { + IdentityState.Verified -> { + Icon( + imageVector = CompoundIcons.Verified(), + tint = ElementTheme.colors.iconSuccessPrimary, + contentDescription = null, + ) + } + IdentityState.VerificationViolation -> { + Icon( + imageVector = CompoundIcons.ErrorSolid(), + tint = ElementTheme.colors.iconCriticalPrimary, + contentDescription = null, + ) + } + else -> Unit + } + } + }, + actions = { + CallMenuItem( + roomCallState = roomCallState, + onJoinCallClick = onJoinCallClick, + ) + Spacer(Modifier.width(8.dp)) + }, + windowInsets = WindowInsets(0.dp) + ) +} + +@Composable +private fun RoomAvatarAndNameRow( + roomName: String?, + roomAvatar: AvatarData, + heroes: ImmutableList, + isTombstoned: Boolean, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Avatar( + avatarData = roomAvatar, + avatarType = AvatarType.Room( + heroes = heroes, + isTombstoned = isTombstoned, + ), + ) + Text( + modifier = Modifier + .padding(horizontal = 8.dp) + .semantics { + heading() + }, + text = roomName ?: stringResource(CommonStrings.common_no_room_name), + style = ElementTheme.typography.fontBodyLgMedium, + fontStyle = FontStyle.Italic.takeIf { roomName == null }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@PreviewsDayNight +@Composable +internal fun MessagesViewTopBarPreview() = ElementPreview { + @Composable + fun AMessagesViewTopBar( + roomName: String? = "Room name", + roomAvatar: AvatarData = anAvatarData( + name = "Room name", + size = AvatarSize.TimelineRoom, + ), + isTombstoned: Boolean = false, + heroes: ImmutableList = persistentListOf(), + roomCallState: RoomCallState = RoomCallState.Unavailable, + dmUserIdentityState: IdentityState? = null, + ) = MessagesViewTopBar( + roomName = roomName, + roomAvatar = roomAvatar, + isTombstoned = isTombstoned, + heroes = heroes, + roomCallState = roomCallState, + dmUserIdentityState = dmUserIdentityState, + onRoomDetailsClick = {}, + onJoinCallClick = {}, + onBackClick = {}, + ) + Column { + AMessagesViewTopBar() + HorizontalDivider() + AMessagesViewTopBar( + heroes = aMatrixUserList().map { it.getAvatarData(AvatarSize.TimelineRoom) }.toImmutableList(), + roomCallState = anOngoingCallState(), + ) + HorizontalDivider() + AMessagesViewTopBar( + roomName = null, + roomCallState = anOngoingCallState(canJoinCall = false), + ) + HorizontalDivider() + AMessagesViewTopBar( + roomName = "A DM with a very very very long name", + roomAvatar = anAvatarData( + size = AvatarSize.TimelineRoom, + url = "https://some-avatar.jpg" + ), + roomCallState = aStandByCallState(canStartCall = false), + dmUserIdentityState = IdentityState.Verified + ) + HorizontalDivider() + AMessagesViewTopBar( + roomName = "A DM with a very very very long name", + isTombstoned = true, + dmUserIdentityState = IdentityState.VerificationViolation + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/ThreadTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/ThreadTopBar.kt new file mode 100644 index 0000000..2247566 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/ThreadTopBar.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.topbars + +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.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.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +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.avatar.AvatarType +import io.element.android.libraries.designsystem.components.avatar.anAvatarData +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ThreadTopBar( + roomName: String?, + roomAvatarData: AvatarData, + heroes: ImmutableList, + isTombstoned: Boolean, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + modifier = modifier, + navigationIcon = { + BackButton(onClick = onBackClick) + }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Avatar( + avatarData = roomAvatarData, + avatarType = AvatarType.Room( + heroes = heroes, + isTombstoned = isTombstoned, + ), + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .semantics { + heading() + }, + ) { + Text( + text = stringResource(CommonStrings.common_thread), + style = ElementTheme.typography.fontBodyLgMedium, + ) + Text( + text = roomName ?: stringResource(CommonStrings.common_no_room_name), + style = ElementTheme.typography.fontBodySmRegular, + fontStyle = FontStyle.Italic.takeIf { roomName == null }, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + ) +} + +@PreviewsDayNight +@Composable +internal fun ThreadTopBarPreview() = ElementPreview { + @Composable + fun AThreadTopBar( + roomName: String? = "Room name", + roomAvatarData: AvatarData = anAvatarData( + name = "Room name", + size = AvatarSize.TimelineRoom, + ), + isTombstoned: Boolean = false, + heroes: ImmutableList = persistentListOf(), + ) = ThreadTopBar( + roomName = roomName, + roomAvatarData = roomAvatarData, + isTombstoned = isTombstoned, + heroes = heroes, + onBackClick = {}, + ) + Column { + AThreadTopBar() + HorizontalDivider() + AThreadTopBar( + heroes = aMatrixUserList().map { it.getAvatarData(AvatarSize.TimelineRoom) }.toImmutableList(), + ) + HorizontalDivider() + AThreadTopBar( + roomName = null, + ) + HorizontalDivider() + AThreadTopBar( + roomAvatarData = anAvatarData( + name = "Room name", + url = "https://some-avatar.jpg", + size = AvatarSize.TimelineRoom, + ), + ) + HorizontalDivider() + AThreadTopBar( + isTombstoned = true, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt new file mode 100644 index 0000000..a27d916 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.typing + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ProduceStateScope +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.setValue +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.roomMembers +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@Inject +class TypingNotificationPresenter( + private val room: JoinedRoom, + private val sessionPreferencesStore: SessionPreferencesStore, +) : Presenter { + @Composable + override fun present(): TypingNotificationState { + val renderTypingNotifications by remember { + sessionPreferencesStore.isRenderTypingNotificationsEnabled() + }.collectAsState(initial = true) + val typingMembersState by produceState(initialValue = persistentListOf(), key1 = renderTypingNotifications) { + if (renderTypingNotifications) { + observeRoomTypingMembers() + } else { + value = persistentListOf() + } + } + + // This will keep the space reserved for the typing notifications after the first one is displayed + var reserveSpace by remember { mutableStateOf(false) } + LaunchedEffect(renderTypingNotifications, typingMembersState) { + if (renderTypingNotifications && typingMembersState.isNotEmpty()) { + reserveSpace = true + } + } + + return TypingNotificationState( + renderTypingNotifications = renderTypingNotifications, + typingMembers = typingMembersState, + reserveSpace = reserveSpace, + ) + } + + private fun ProduceStateScope>.observeRoomTypingMembers() { + combine(room.roomTypingMembersFlow, room.membersStateFlow) { typingMembers, membersState -> + typingMembers + .map { userId -> + membersState.roomMembers() + ?.firstOrNull { roomMember -> roomMember.userId == userId } + ?.toTypingRoomMember() + ?: createDefaultRoomMemberForTyping(userId) + } + } + .distinctUntilChanged() + .onEach { members -> + value = members.toImmutableList() + } + .launchIn(this) + } +} + +private fun RoomMember.toTypingRoomMember(): TypingRoomMember { + return TypingRoomMember( + disambiguatedDisplayName = disambiguatedDisplayName, + ) +} + +private fun createDefaultRoomMemberForTyping(userId: UserId): TypingRoomMember { + return TypingRoomMember( + disambiguatedDisplayName = userId.value, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationState.kt new file mode 100644 index 0000000..e6c1f56 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationState.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.typing + +import kotlinx.collections.immutable.ImmutableList + +/** + * State for the typing notification view. + */ +data class TypingNotificationState( + /** Whether to render the typing notifications based on the user's preferences. */ + val renderTypingNotifications: Boolean, + /** The room members currently typing. */ + val typingMembers: ImmutableList, + /** Whether to reserve space for the typing notifications at the bottom of the timeline. */ + val reserveSpace: Boolean, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationStateProvider.kt new file mode 100644 index 0000000..0506026 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationStateProvider.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.typing + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import kotlinx.collections.immutable.toImmutableList + +class TypingNotificationStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aTypingNotificationState(), + aTypingNotificationState( + typingMembers = listOf( + aTypingRoomMember(), + ), + ), + aTypingNotificationState( + typingMembers = listOf( + aTypingRoomMember(disambiguatedDisplayName = "Alice"), + ), + ), + aTypingNotificationState( + typingMembers = listOf( + aTypingRoomMember(disambiguatedDisplayName = "Alice (@alice:example.com)"), + ), + ), + aTypingNotificationState( + typingMembers = listOf( + aTypingRoomMember(disambiguatedDisplayName = "Alice"), + aTypingRoomMember(disambiguatedDisplayName = "Bob"), + ), + ), + aTypingNotificationState( + typingMembers = listOf( + aTypingRoomMember(disambiguatedDisplayName = "Alice"), + aTypingRoomMember(disambiguatedDisplayName = "Bob"), + aTypingRoomMember(disambiguatedDisplayName = "Charlie"), + ), + ), + aTypingNotificationState( + typingMembers = listOf( + aTypingRoomMember(disambiguatedDisplayName = "Alice"), + aTypingRoomMember(disambiguatedDisplayName = "Bob"), + aTypingRoomMember(disambiguatedDisplayName = "Charlie"), + aTypingRoomMember(disambiguatedDisplayName = "Dan"), + aTypingRoomMember(disambiguatedDisplayName = "Eve"), + ), + ), + aTypingNotificationState( + typingMembers = listOf( + aTypingRoomMember(disambiguatedDisplayName = "Alice with a very long display name which means that it will be truncated"), + ), + ), + aTypingNotificationState( + typingMembers = emptyList(), + reserveSpace = true, + ), + ) +} + +internal fun aTypingNotificationState( + typingMembers: List = emptyList(), + reserveSpace: Boolean = false, +) = TypingNotificationState( + renderTypingNotifications = true, + typingMembers = typingMembers.toImmutableList(), + reserveSpace = reserveSpace, +) + +internal fun aTypingRoomMember( + disambiguatedDisplayName: String = "@alice:example.com", +) = TypingRoomMember( + disambiguatedDisplayName = disambiguatedDisplayName, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationView.kt new file mode 100644 index 0000000..c8e5722 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationView.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.typing + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +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.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.messages.impl.R +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text +import kotlinx.collections.immutable.ImmutableList + +@Suppress("MultipleEmitters") // False positive +@Composable +fun TypingNotificationView( + state: TypingNotificationState, + modifier: Modifier = Modifier, +) { + val displayNotifications = state.typingMembers.isNotEmpty() && state.renderTypingNotifications + + @Suppress("ModifierNaming") + @Composable + fun TypingText(text: AnnotatedString, textModifier: Modifier = Modifier) { + Text( + modifier = textModifier, + text = text, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + + // Display the typing notification space when either a typing notification needs to be displayed or a previous one already was + AnimatedVisibility( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + visible = displayNotifications || state.reserveSpace, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + val typingNotificationText = computeTypingNotificationText(state.typingMembers) + Box(contentAlignment = Alignment.BottomStart) { + // Reserve the space for the typing notification by adding an invisible text + TypingText( + text = typingNotificationText, + textModifier = Modifier + .alpha(0f) + // Remove the semantics of the text to avoid screen readers to read it + .clearAndSetSemantics { } + ) + + // Display the actual notification + AnimatedVisibility( + visible = displayNotifications, + enter = fadeIn(), + exit = fadeOut(), + ) { + TypingText(text = typingNotificationText, textModifier = Modifier.padding(horizontal = 24.dp)) + } + } + } +} + +@Composable +private fun computeTypingNotificationText(typingMembers: ImmutableList): AnnotatedString { + // Remember the last value to avoid empty typing messages while animating + var result by remember { mutableStateOf(AnnotatedString("")) } + if (typingMembers.isNotEmpty()) { + val names = when (typingMembers.size) { + 0 -> "" // Cannot happen + 1 -> typingMembers[0].disambiguatedDisplayName + 2 -> stringResource( + id = R.string.screen_room_typing_two_members, + typingMembers[0].disambiguatedDisplayName, + typingMembers[1].disambiguatedDisplayName, + ) + else -> pluralStringResource( + id = R.plurals.screen_room_typing_many_members, + count = typingMembers.size - 2, + typingMembers[0].disambiguatedDisplayName, + typingMembers[1].disambiguatedDisplayName, + typingMembers.size - 2, + ) + } + // Get the translated string with a fake pattern + val tmpString = pluralStringResource( + id = R.plurals.screen_room_typing_notification, + count = typingMembers.size, + "<>", + ) + // Split the string in 3 parts + val parts = tmpString.split("<>") + // And rebuild the string with the names + result = buildAnnotatedString { + append(parts[0]) + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(names) + } + append(parts[1]) + } + } + return result +} + +@PreviewsDayNight +@Composable +internal fun TypingNotificationViewPreview( + @PreviewParameter(TypingNotificationStateProvider::class) state: TypingNotificationState, +) = ElementPreview { + TypingNotificationView( + modifier = if (state.reserveSpace) Modifier.border(1.dp, Color.Blue) else Modifier, + state = state, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingRoomMember.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingRoomMember.kt new file mode 100644 index 0000000..de7232c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingRoomMember.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.typing + +data class TypingRoomMember( + val disambiguatedDisplayName: String, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/Emoji.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/Emoji.kt new file mode 100644 index 0000000..3a0f16b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/Emoji.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.utils + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalInspectionMode +import com.sigpwned.emoji4j.core.Grapheme.Type.EMOJI +import com.sigpwned.emoji4j.core.Grapheme.Type.PICTOGRAPHIC +import com.sigpwned.emoji4j.core.GraphemeMatchResult +import com.sigpwned.emoji4j.core.GraphemeMatcher +import io.element.android.features.messages.impl.timeline.model.event.AN_EMOJI_ONLY_TEXT + +/** + * Returns true if the string consists exclusively of "emoji or pictographic graphemes". + */ +@Composable +fun String.containsOnlyEmojis(): Boolean { + if (LocalInspectionMode.current) return this == AN_EMOJI_ONLY_TEXT + if (isEmpty()) return false + return containsOnlyEmojisInternal() +} + +internal fun String.containsOnlyEmojisInternal(): Boolean { + val matcher = GraphemeMatcher(this) + var m: GraphemeMatchResult? = null + var contiguous = true + var previous = 0 + while (contiguous && matcher.find()) { + m = matcher.toMatchResult() + // Many non-"emoji" characters are pictographics. We only want to identify this specific range + // https://en.wikipedia.org/wiki/Miscellaneous_Symbols_and_Pictographs + val isEmoji = m!!.grapheme().type == EMOJI || m.grapheme().type == PICTOGRAPHIC && m.group() in "🌍".."🗺" + contiguous = isEmoji and (m.start() == previous) + previous = m.end() + } + + return contiguous and (m?.end() == length) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt new file mode 100644 index 0000000..f74d3b5 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.utils + +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.URLSpan +import android.util.Patterns +import androidx.core.text.getSpans +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.MatrixPatternType +import io.element.android.libraries.matrix.api.core.MatrixPatterns +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.getMentionSpans +import io.element.android.wysiwyg.view.spans.CodeBlockSpan +import io.element.android.wysiwyg.view.spans.InlineCodeSpan + +interface TextPillificationHelper { + fun pillify(text: CharSequence, pillifyPermalinks: Boolean = true): CharSequence +} + +@ContributesBinding(RoomScope::class) +class DefaultTextPillificationHelper( + private val mentionSpanProvider: MentionSpanProvider, + private val permalinkParser: PermalinkParser, + private val permalinkBuilder: PermalinkBuilder, +) : TextPillificationHelper { + @Suppress("LoopWithTooManyJumpStatements") + override fun pillify(text: CharSequence, pillifyPermalinks: Boolean): CharSequence { + return SpannableStringBuilder(text).apply { + pillifyMatrixPatterns(this) + if (pillifyPermalinks) { + pillifyPermalinks(this) + } + } + } + + private fun pillifyMatrixPatterns(text: SpannableStringBuilder) { + val matches = MatrixPatterns.findPatterns(text, permalinkParser).sortedByDescending { it.end } + if (matches.isEmpty()) return + for (match in matches) { + if (!text.canPillify(match.start, match.end)) continue + when (match.type) { + MatrixPatternType.USER_ID -> { + val userId = UserId(match.value) + val mentionSpan = mentionSpanProvider.createUserMentionSpan(userId) + text.replace(match.start, match.end, "@ ") + text.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + permalinkBuilder.permalinkForUser(userId).getOrNull()?.also { permalink -> + // Also add a URLSpan in case of raw user id so it can be clicked + val urlSpan = URLSpan(permalink) + text.setSpan(urlSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + MatrixPatternType.ROOM_ALIAS -> { + val roomAlias = RoomAlias(match.value) + val mentionSpan = mentionSpanProvider.createRoomMentionSpan(roomAlias.toRoomIdOrAlias()) + text.replace(match.start, match.end, "@ ") + text.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + permalinkBuilder.permalinkForRoomAlias(roomAlias).getOrNull()?.also { permalink -> + // Also add a URLSpan in case of raw room alias so it can be clicked + val urlSpan = URLSpan(permalink) + text.setSpan(urlSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + MatrixPatternType.AT_ROOM -> { + val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan() + text.replace(match.start, match.end, "@ ") + text.setSpan(mentionSpan, match.start, match.start + 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + else -> Unit + } + } + } + + private fun pillifyPermalinks(text: SpannableStringBuilder) { + for (match in Patterns.WEB_URL.toRegex().findAll(text)) { + val start = match.range.first + val end = match.range.last + 1 + if (!text.canPillify(start, end)) continue + val url = text.substring(match.range) + val mentionSpan = mentionSpanProvider.getMentionSpanFor(match.value, url) + if (mentionSpan != null) { + text.setSpan(mentionSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + } + + private fun Spanned.canPillify(start: Int, end: Int): Boolean { + if (getMentionSpans(start, end).isNotEmpty()) return false + if (getSpans(start, end).isNotEmpty()) return false + if (getSpans(start, end).isNotEmpty()) return false + return true + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt new file mode 100644 index 0000000..0aeb3bb --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.utils.messagesummary + +import android.content.Context +import dev.zacsweers.metro.ContributesBinding +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.TimelineItemLegacyCallInviteContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent +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.TimelineItemRtcNotificationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent +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.timeline.model.event.TimelineItemVoiceContent +import io.element.android.libraries.core.extensions.toSafeLength +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.ui.strings.CommonStrings + +@ContributesBinding(RoomScope::class) +class DefaultMessageSummaryFormatter( + @ApplicationContext private val context: Context, +) : MessageSummaryFormatter { + override fun format(content: TimelineItemEventContent): String { + return when (content) { + is TimelineItemTextBasedContent -> content.plainText + is TimelineItemProfileChangeContent -> content.body + is TimelineItemStateContent -> 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 TimelineItemPollContent -> content.question + is TimelineItemVoiceContent -> context.getString(CommonStrings.common_voice_message) + is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event) + is TimelineItemImageContent -> context.getString(CommonStrings.common_image) + is TimelineItemStickerContent -> context.getString(CommonStrings.common_sticker) + is TimelineItemVideoContent -> context.getString(CommonStrings.common_video) + is TimelineItemFileContent -> context.getString(CommonStrings.common_file) + is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio) + is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call) + is TimelineItemRtcNotificationContent -> context.getString(CommonStrings.common_call_started) + } + // Truncate the message to a safe length to avoid crashes in Compose + .toSafeLength() + } +} 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 0000000..084dce1 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatter.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.utils.messagesummary + +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent + +interface MessageSummaryFormatter { + fun format(event: TimelineItem.Event): String { + return format(event.content) + } + fun format(content: TimelineItemEventContent): String +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt new file mode 100644 index 0000000..051ded0 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.voicemessages.composer + +import android.Manifest +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.setValue +import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesBinding +import im.vector.app.features.analytics.plan.Composer +import io.element.android.features.messages.api.MessageComposerContext +import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent +import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerPresenter +import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.mediaupload.api.MediaSenderFactory +import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent +import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent +import io.element.android.libraries.textcomposer.model.VoiceMessageState +import io.element.android.libraries.voiceplayer.api.VoiceMessageException +import io.element.android.libraries.voicerecorder.api.VoiceRecorder +import io.element.android.libraries.voicerecorder.api.VoiceRecorderState +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber +import java.io.File +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +@AssistedInject +class DefaultVoiceMessageComposerPresenter( + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, + @Assisted private val timelineMode: Timeline.Mode, + private val voiceRecorder: VoiceRecorder, + private val analyticsService: AnalyticsService, + mediaSenderFactory: MediaSenderFactory, + private val player: VoiceMessageComposerPlayer, + private val messageComposerContext: MessageComposerContext, + permissionsPresenterFactory: PermissionsPresenter.Factory +) : VoiceMessageComposerPresenter { + @ContributesBinding(RoomScope::class) + @AssistedFactory + interface Factory : VoiceMessageComposerPresenter.Factory { + override fun create(timelineMode: Timeline.Mode): DefaultVoiceMessageComposerPresenter + } + + private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO) + private val mediaSender = mediaSenderFactory.create(timelineMode) + + @Composable + override fun present(): VoiceMessageComposerState { + val localCoroutineScope = rememberCoroutineScope() + val recorderState by voiceRecorder.state.collectAsState(initial = VoiceRecorderState.Idle) + val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.Initial) + val keepScreenOn by remember { derivedStateOf { recorderState is VoiceRecorderState.Recording } } + + val permissionState = permissionsPresenter.present() + var isSending by remember { mutableStateOf(false) } + var showSendFailureDialog by remember { mutableStateOf(false) } + + LaunchedEffect(recorderState) { + val recording = recorderState as? VoiceRecorderState.Finished + ?: return@LaunchedEffect + player.setMedia(recording.file.path) + } + + fun handleLifecycleEvent(event: Lifecycle.Event) { + when (event) { + Lifecycle.Event.ON_PAUSE -> { + sessionCoroutineScope.finishRecording() + player.pause() + } + Lifecycle.Event.ON_DESTROY -> { + sessionCoroutineScope.cancelRecording() + } + else -> {} + } + } + + fun handleVoiceMessageRecorderEvent(event: VoiceMessageRecorderEvent) { + when (event) { + VoiceMessageRecorderEvent.Start -> { + Timber.v("Voice message record button pressed") + when { + permissionState.permissionGranted -> { + localCoroutineScope.startRecording() + } + else -> { + Timber.i("Voice message permission needed") + permissionState.eventSink(PermissionsEvents.RequestPermissions) + } + } + } + VoiceMessageRecorderEvent.Stop -> { + Timber.v("Voice message stop button pressed") + localCoroutineScope.finishRecording() + } + VoiceMessageRecorderEvent.Cancel -> { + Timber.v("Voice message cancel button tapped") + localCoroutineScope.cancelRecording() + } + } + } + + fun handleVoiceMessagePlayerEvent(event: VoiceMessagePlayerEvent) { + localCoroutineScope.launch { + when (event) { + VoiceMessagePlayerEvent.Play -> player.play() + VoiceMessagePlayerEvent.Pause -> player.pause() + is VoiceMessagePlayerEvent.Seek -> player.seek(event.position) + } + } + } + + fun sendVoiceMessage() { + val finishedState = recorderState as? VoiceRecorderState.Finished + if (finishedState == null) { + val exception = VoiceMessageException.FileException("No file to send") + analyticsService.trackError(exception) + Timber.e(exception) + return + } + if (isSending) { + return + } + isSending = true + player.pause() + analyticsService.captureComposerEvent() + sessionCoroutineScope.launch { + val result = sendMessage( + file = finishedState.file, + mimeType = finishedState.mimeType, + waveform = finishedState.waveform, + ) + if (result.isFailure) { + showSendFailureDialog = true + } + }.invokeOnCompletion { + isSending = false + } + } + + fun handleEvent(event: VoiceMessageComposerEvent) { + when (event) { + is VoiceMessageComposerEvent.RecorderEvent -> handleVoiceMessageRecorderEvent(event.recorderEvent) + is VoiceMessageComposerEvent.PlayerEvent -> handleVoiceMessagePlayerEvent(event.playerEvent) + is VoiceMessageComposerEvent.SendVoiceMessage -> localCoroutineScope.launch { + sendVoiceMessage() + } + VoiceMessageComposerEvent.DeleteVoiceMessage -> { + player.pause() + localCoroutineScope.deleteRecording() + } + VoiceMessageComposerEvent.DismissPermissionsRationale -> { + permissionState.eventSink(PermissionsEvents.CloseDialog) + } + VoiceMessageComposerEvent.AcceptPermissionRationale -> { + permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog) + } + is VoiceMessageComposerEvent.LifecycleEvent -> handleLifecycleEvent(event.event) + VoiceMessageComposerEvent.DismissSendFailureDialog -> { + showSendFailureDialog = false + } + } + } + + return VoiceMessageComposerState( + voiceMessageState = when (val state = recorderState) { + is VoiceRecorderState.Recording -> VoiceMessageState.Recording( + duration = state.elapsedTime, + levels = state.levels + // Keep only the last 128 samples for display, else we can have a crash + .takeLast(128) + .toImmutableList(), + ) + is VoiceRecorderState.Finished -> + previewState( + playerState = playerState, + recorderState = recorderState, + isSending = isSending + ) + else -> VoiceMessageState.Idle + }, + showPermissionRationaleDialog = permissionState.showDialog, + showSendFailureDialog = showSendFailureDialog, + keepScreenOn = keepScreenOn, + eventSink = ::handleEvent, + ) + } + + @Composable + private fun previewState( + playerState: VoiceMessageComposerPlayer.State, + recorderState: VoiceRecorderState, + isSending: Boolean, + ): VoiceMessageState { + val showCursor by remember(playerState.isStopped, isSending) { derivedStateOf { !playerState.isStopped && !isSending } } + val playerTime by remember(playerState, recorderState) { derivedStateOf { displayTime(playerState, recorderState) } } + val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } } + + return VoiceMessageState.Preview( + isSending = isSending, + isPlaying = playerState.isPlaying, + showCursor = showCursor, + playbackProgress = playerState.progress, + time = playerTime, + waveform = waveform, + ) + } + + private fun CoroutineScope.startRecording() = launch { + try { + voiceRecorder.startRecord() + } catch (e: SecurityException) { + Timber.e(e, "Voice message error") + analyticsService.trackError(VoiceMessageException.PermissionMissing("Expected permission to record but none", e)) + } + } + + private fun CoroutineScope.finishRecording() = launch { + voiceRecorder.stopRecord() + } + + private fun CoroutineScope.cancelRecording() = launch { + voiceRecorder.stopRecord(cancelled = true) + } + + private fun CoroutineScope.deleteRecording() = launch { + voiceRecorder.deleteRecording() + } + + private suspend fun sendMessage( + file: File, + mimeType: String, + waveform: List, + ): Result { + val result = mediaSender.sendVoiceMessage( + uri = file.toUri(), + mimeType = mimeType, + waveForm = waveform, + ) + + if (result.isFailure) { + Timber.e(result.exceptionOrNull(), "Voice message error") + return result + } + + voiceRecorder.deleteRecording() + + return result + } + + private fun AnalyticsService.captureComposerEvent() = + capture( + Composer( + inThread = messageComposerContext.composerMode.inThread, + isEditing = messageComposerContext.composerMode.isEditing, + isReply = messageComposerContext.composerMode.isReply, + messageType = Composer.MessageType.VoiceMessage, + ) + ) +} + +private fun VoiceRecorderState.finishedWaveform(): ImmutableList = + (this as? VoiceRecorderState.Finished) + ?.waveform + .orEmpty() + .toImmutableList() + +/** + * The time to display depending on the player state. + * + * Either the current position or total duration. + */ +private fun displayTime( + playerState: VoiceMessageComposerPlayer.State, + recording: VoiceRecorderState +): Duration = when { + !playerState.isStopped -> + playerState.currentPosition.milliseconds + recording is VoiceRecorderState.Finished -> + recording.duration + else -> + 0.milliseconds +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt new file mode 100644 index 0000000..1e55c45 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.voicemessages.composer + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.mediaplayer.api.MediaPlayer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * A media player for the voice message composer. + * + * @param mediaPlayer The [MediaPlayer] to use. + * @param sessionCoroutineScope + */ +@Inject +class VoiceMessageComposerPlayer( + private val mediaPlayer: MediaPlayer, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, +) { + companion object { + const val MIME_TYPE = MimeTypes.Ogg + } + + private var mediaPath: String? = null + + private var seekJob: Job? = null + private val seekingTo = MutableStateFlow(null) + + val state: Flow = combine(mediaPlayer.state, seekingTo) { state, seekingTo -> + state to seekingTo + }.scan(InternalState.NotLoaded) { prevState, (state, seekingTo) -> + if (mediaPath == null || mediaPath != state.mediaId) { + return@scan InternalState.NotLoaded + } + + InternalState( + playState = calcPlayState(prevState.playState, seekingTo, state), + currentPosition = state.currentPosition, + duration = state.duration, + seekingTo = seekingTo, + ) + }.map { + State( + playState = it.playState, + currentPosition = it.currentPosition, + progress = calcProgress(it), + ) + }.distinctUntilChanged() + + /** + * Set the voice message to be played. + */ + suspend fun setMedia(mediaPath: String) { + this.mediaPath = mediaPath + mediaPlayer.setMedia( + uri = mediaPath, + mediaId = mediaPath, + mimeType = MIME_TYPE, + ) + } + + /** + * Start playing from the current position. + * + * Call [setMedia] before calling this method. + */ + suspend fun play() { + val mediaPath = this.mediaPath + if (mediaPath == null) { + Timber.e("Set media before playing") + return + } + + mediaPlayer.ensureMediaReady(mediaPath) + + mediaPlayer.play() + } + + /** + * Pause playback. + */ + fun pause() { + if (mediaPath == mediaPlayer.state.value.mediaId) { + mediaPlayer.pause() + } + } + + /** + * Seek to a given position in the current media. + * + * Call [setMedia] before calling this method. + * + * @param position The position to seek to between 0 and 1. + */ + suspend fun seek(position: Float) { + val mediaPath = this.mediaPath + if (mediaPath == null) { + Timber.e("Set media before seeking") + return + } + + seekJob?.cancelAndJoin() + seekingTo.value = position + seekJob = sessionCoroutineScope.launch { + val mediaState = mediaPlayer.ensureMediaReady(mediaPath) + val duration = mediaState.duration ?: return@launch + val positionMs = (duration * position).toLong() + mediaPlayer.seekTo(positionMs) + }.apply { + invokeOnCompletion { + seekingTo.value = null + } + } + } + + private suspend fun MediaPlayer.ensureMediaReady(mediaPath: String): MediaPlayer.State { + val state = state.value + if (state.mediaId == mediaPath && state.isReady) { + return state + } + + return setMedia( + uri = mediaPath, + mediaId = mediaPath, + mimeType = MIME_TYPE, + ) + } + + private fun calcPlayState(prevPlayState: PlayState, seekingTo: Float?, state: MediaPlayer.State): PlayState { + if (state.mediaId == null || state.mediaId != mediaPath) { + return PlayState.Stopped + } + + // If we were stopped and the player didn't start playing or seeking, we are still stopped. + if (prevPlayState == PlayState.Stopped && !state.isPlaying && seekingTo == null) { + return PlayState.Stopped + } + + return if (state.isPlaying) { + PlayState.Playing + } else { + PlayState.Paused + } + } + + private fun calcProgress(state: InternalState): Float { + if (state.seekingTo != null) { + return state.seekingTo + } + + if (state.playState == PlayState.Stopped) { + return 0f + } + + if (state.duration == null) { + return 0f + } + + return (state.currentPosition.toFloat() / state.duration.toFloat()) + .coerceAtMost(1f) // Current position may exceed reported duration + } + + /** + * @property playState Whether this player is currently playing. See [PlayState]. + * @property currentPosition The elapsed time of this player in milliseconds. + * @property progress The progress of this player between 0 and 1. + */ + data class State( + val playState: PlayState, + val currentPosition: Long, + val progress: Float, + ) { + companion object { + val Initial = State( + playState = PlayState.Stopped, + currentPosition = 0L, + progress = 0f, + ) + } + + /** + * Whether this player is currently playing. + */ + val isPlaying get() = this.playState == PlayState.Playing + + /** + * Whether this player is currently stopped. + */ + val isStopped get() = this.playState == PlayState.Stopped + } + + enum class PlayState { + /** + * The player is stopped, i.e. it has just been initialised. + */ + Stopped, + + /** + * The player is playing. + */ + Playing, + + /** + * The player has been paused. The player can also enter the paused state after seeking to a position. + */ + Paused, + } + + private data class InternalState( + val playState: PlayState, + val currentPosition: Long, + val duration: Long?, + val seekingTo: Float?, + ) { + companion object { + val NotLoaded = InternalState( + playState = PlayState.Stopped, + currentPosition = 0L, + duration = null, + seekingTo = null, + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessagePermissionRationaleDialog.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessagePermissionRationaleDialog.kt new file mode 100644 index 0000000..e45f28d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessagePermissionRationaleDialog.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.voicemessages.composer + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun VoiceMessagePermissionRationaleDialog( + onContinue: () -> Unit, + onDismiss: () -> Unit, + appName: String, +) { + ConfirmationDialog( + content = stringResource(CommonStrings.error_missing_microphone_voice_rationale_android, appName), + onSubmitClick = onContinue, + onDismiss = onDismiss, + submitText = stringResource(CommonStrings.action_continue), + cancelText = stringResource(CommonStrings.action_cancel), + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageSendingFailedDialog.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageSendingFailedDialog.kt new file mode 100644 index 0000000..9a7a2b8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageSendingFailedDialog.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.voicemessages.composer + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun VoiceMessageSendingFailedDialog( + onDismiss: () -> Unit, +) { + ErrorDialog( + title = stringResource(CommonStrings.common_error), + content = stringResource(CommonStrings.error_failed_uploading_voice_message), + onSubmit = onDismiss, + submitText = stringResource(CommonStrings.action_ok), + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManager.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManager.kt new file mode 100644 index 0000000..08b5630 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManager.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.voicemessages.timeline + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +import io.element.android.libraries.mediaplayer.api.MediaPlayer +import kotlinx.coroutines.withContext + +interface RedactedVoiceMessageManager { + suspend fun onEachMatrixTimelineItem(timelineItems: List) +} + +@ContributesBinding(RoomScope::class) +class DefaultRedactedVoiceMessageManager( + private val dispatchers: CoroutineDispatchers, + private val mediaPlayer: MediaPlayer, +) : RedactedVoiceMessageManager { + override suspend fun onEachMatrixTimelineItem(timelineItems: List) { + withContext(dispatchers.computation) { + mediaPlayer.state.value.let { playerState -> + if (playerState.isPlaying && playerState.mediaId != null) { + val needsToPausePlayer = timelineItems.any { + it is MatrixTimelineItem.Event && + playerState.mediaId == it.eventId?.value && + it.event.content is RedactedContent + } + if (needsToPausePlayer) { + withContext(dispatchers.main) { mediaPlayer.pause() } + } + } + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt new file mode 100644 index 0000000..eef5c5e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.voicemessages.timeline + +import androidx.compose.runtime.Composable +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.IntoMap +import io.element.android.features.messages.impl.timeline.di.TimelineItemEventContentKey +import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactory +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.voiceplayer.api.VoiceMessagePresenterFactory +import io.element.android.libraries.voiceplayer.api.VoiceMessageState + +@BindingContainer +@ContributesTo(RoomScope::class) +interface VoiceMessagePresenterModule { + @Binds + @IntoMap + @TimelineItemEventContentKey(TimelineItemVoiceContent::class) + fun bindVoiceMessagePresenterFactory(factory: VoiceMessagePresenter.Factory): TimelineItemPresenterFactory<*, *> +} + +@AssistedInject +class VoiceMessagePresenter( + voiceMessagePresenterFactory: VoiceMessagePresenterFactory, + @Assisted private val content: TimelineItemVoiceContent, +) : Presenter { + @AssistedFactory + fun interface Factory : TimelineItemPresenterFactory { + override fun create(content: TimelineItemVoiceContent): VoiceMessagePresenter + } + + private val presenter = voiceMessagePresenterFactory.createVoiceMessagePresenter( + eventId = content.eventId, + mediaSource = content.mediaSource, + mimeType = content.mimeType, + filename = content.filename, + duration = content.duration, + ) + + @Composable + override fun present(): VoiceMessageState { + return presenter.present() + } +} diff --git a/features/messages/impl/src/main/res/values-be/translations.xml b/features/messages/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..ff8fd2e --- /dev/null +++ b/features/messages/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,57 @@ + + + "Актыўнасці" + "Сцягі" + "Ежа & Напоі" + "Жывёлы & Прырода" + "Аб\'екты" + "Усмешкі & Удзельнікі" + "Падарожжы & Месцы" + "Сімвалы" + "Не атрымалася апрацаваць медыяфайл для загрузкі, паспрабуйце яшчэ раз." + "Не атрымалася загрузіць медыяфайлы, паспрабуйце яшчэ раз." + "Заблакіраваць карыстальніка" + "Адзначце, ці хочаце вы схаваць усе бягучыя і будучыя паведамленні ад гэтага карыстальніка" + "Гэтае паведамленне будзе перададзена адміністратару вашага хатняга сервера. Яны не змогуць прачытаць зашыфраваныя паведамленні." + "Прычына, па якой вы паскардзіліся на гэты змест" + "Камера" + "Зрабіць фота" + "Запісаць відэа" + "Далучэнне" + "Бібліятэка фота & відэа" + "Месцазнаходжанне" + "Апытанне" + "Фармаціраванне тэксту" + "Гісторыя паведамленняў зараз недаступна." + "Гісторыя паведамленняў у гэтым пакоі недаступная. Праверце гэтую прыладу, каб убачыць гісторыю паведамленняў." + "Вы хочаце запрасіць іх назад?" + "Вы адзін у гэтым чаце" + "Апавясціць увесь пакой" + "Усе" + "Адправіць зноў" + "Не ўдалося адправіць ваша паведамленне" + "Дадаць эмодзі" + "Гэта пачатак %1$s." + "Гэта пачатак гэтай размовы." + "Паказаць менш" + "Паведамленне скапіравана" + "У Вас няма дазволу на публікацыю ў гэтым пакоі" + "Паказаць менш" + "Паказаць больш" + "Новае" + + "%1$d змена ў пакоі" + "%1$d змены ў пакоі" + "%1$d змен у пакоі" + + + "%1$s, %2$s і %3$d іншы" + "%1$s, %2$s і %3$d іншыя" + + + "%1$s піша" + "%1$s пішуць" + "%1$s пішуць" + + "%1$s і %2$s" + diff --git a/features/messages/impl/src/main/res/values-bg/translations.xml b/features/messages/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..57a9fed --- /dev/null +++ b/features/messages/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,60 @@ + + + "Дейности" + "Знамена" + "Храна & Напитки" + "Животни & Природа" + "Обекти" + "Усмивки & Хора" + "Пътуване & Места" + "Символи" + "Докоснете, за да промените качеството на качване на видео" + "Файлът не можа да бъде качен." + "Неуспешна обработка на мултимедия за качване, моля, опитайте отново." + "Неуспешно качване на мултимедия, моля, опитайте отново." + "Файлът е твърде голям за качване" + "Блокиране на потребителя" + "Отметнете ако искате да скриете всички настоящи и бъдещи съобщения от този потребител" + "Това съобщение ще бъде докладвано на администратора на вашия сървър. Те няма да могат да четат шифровани съобщения." + "Причина за докладване на това съдържание" + "Камера" + "Снимка" + "Запис на видео" + "Прикачен файл" + "Снимки & Видео Библиотека" + "Местоположение" + "Анкета" + "Форматиране на текст" + "Хронологията на съобщенията не е налична в момента." + "Искате ли да ги поканите обратно?" + "Вие сте сами в този чат" + "Всеки" + "Изпращане отново" + "Вашето съобщение не успя да се изпрати" + "Добавяне на емоджи" + "Това е началото на %1$s." + "Това е началото на този разговор." + "Показване на по-малко" + "Съобщението е копирано" + "Нямате разрешение да публикувате в тази стая" + "Показване на по-малко" + "Показване на повече" + "Нови" + + "%1$d промяна в стаята" + "%1$d промени в стаята" + + "Преминаване към новата стая" + "Тази стая е заменена и вече не е активна" + "Преглед на старите съобщения" + "Тази стая е продължение на друга стая" + + "%1$s, %2$s и %3$d друг" + "%1$s, %2$s и %3$d други" + + + "%1$s пише" + "%1$s пишат" + + "%1$s и %2$s" + 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 0000000..3b9ffb4 --- /dev/null +++ b/features/messages/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,84 @@ + + + "Aktivity" + "Vlajky" + "Jídlo a nápoje" + "Zvířata a příroda" + "Předměty" + "Smajlíci a lidé" + "Cestování a místa" + "Nedávné emotikony" + "Symboly" + "Titulky nemusí být viditelné pro lidi, kteří používají starší aplikace." + "Klepnutím změníte kvalitu nahrávání videa" + "Soubor nelze nahrát." + "Nahrání média se nezdařilo, zkuste to prosím znovu." + "Nahrání média se nezdařilo, zkuste to prosím znovu." + "Maximální povolená velikost souboru je %1$s." + "Soubor je pro nahrání příliš velký." + "Položka %1$d z %2$d" + "Optimalizace kvality obrazu" + "Probíhá zpracování…" + "Zablokovat uživatele" + "Zaškrtněte, pokud chcete skrýt všechny aktuální a budoucí zprávy od tohoto uživatele" + "Tato zpráva bude nahlášena správci vašeho domovského serveru. Nebude si moci přečíst žádné šifrované zprávy." + "Důvod nahlášení tohoto obsahu" + "Fotoaparát" + "Vyfotit" + "Natočit video" + "Příloha" + "Knihovna fotografií a videí" + "Poloha" + "Hlasování" + "Formátování textu" + "Historie zpráv je momentálně v této místnosti nedostupná" + "Historie zpráv není v této místnosti k dispozici. Ověřte toto zařízení, abyste viděli historii zpráv." + "Chtěli byste je pozvat zpět?" + "V tomto chatu jste sami" + "Informujte celou místnost" + "Všichni" + "Odeslat znovu" + "Vaši zprávu se nepodařilo odeslat" + "Přidat emoji" + "Toto je začátek %1$s." + "Toto je začátek této konverzace." + "Nepodporované volání. Zeptejte se, zda volající může používat novou aplikaci Element X." + "Zobrazit méně" + "Zpráva zkopírována" + "Nemáte oprávnění zveřejňovat příspěvky v této místnosti" + + "%1$d člen reagoval s %2$s" + "%1$d členové reagovali s %2$s" + "%1$d členů reagovalo s %2$s" + + + "Vy a %1$d člen jste reagovali s %2$s" + "Vy a %1$d členové jste reagovali s %2$s" + "Vy a %1$d členů reagovalo s %2$s" + + "Reagovali jste s %1$s" + "Zobrazit méně" + "Zobrazit více" + "Zobrazit souhrn reakcí" + "Nové" + + "%1$d změna místnosti" + "%1$d změny místnosti" + "%1$d změn místnosti" + + "Přejít do nové místnosti" + "Tato místnost byla nahrazena a již není aktivní" + "Zobrazit staré zprávy" + "Tato místnost je pokračováním jiné místnosti" + + "%1$s, %2$s a %3$d další" + "%1$s, %2$s a %3$d další" + "%1$s, %2$s a %3$d dalších" + + + "%1$s píše" + "%1$s píší" + "%1$s píše" + + "%1$s a %2$s" + diff --git a/features/messages/impl/src/main/res/values-cy/translations.xml b/features/messages/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..2d2c368 --- /dev/null +++ b/features/messages/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,99 @@ + + + "Gweithgareddau" + "Baneri" + "Bwyd a Diod" + "Anifeiliaid a Natur" + "Gwrthrychau" + "Wynebau Hapus a Phobl" + "Teithio a Llefydd" + "Emojis diweddar" + "Symbolau" + "Efallai na fydd capsiynau yn weladwy i bobl sy\'n defnyddio apiau hŷn." + "Tapiwch i newid ansawdd llwytho\'r fideo" + "Nid oedd modd llwytho\'r ffeil." + "Wedi methu â phrosesu cyfryngau i\'w llwytho, ceisiwch eto." + "Wedi methu llwytho cyfryngau, ceisiwch eto." + "Y maint ffeil mwyaf a ganiateir yw %1$s ." + "Mae\'r ffeil yn rhy fawr i\'w llwytho" + "Eitem %1$d o %2$d" + "Optimeiddio ansawdd delwedd" + "Prosesu…" + "Rhwystro defnyddiwr" + "Gwiriwch a ydych am guddio\'r holl negeseuon presennol ac yn y dyfodol gan y defnyddiwr hwn" + "Bydd y neges hon yn cael ei hadrodd i weinyddwr eich gweinyddwr cartref. Fyddan nhw ddim yn gallu darllen unrhyw negeseuon wedi\'u hamgryptio." + "Rheswm dros adrodd am y cynnwys hwn" + "Camera" + "Cymryd llun" + "Recordio fideo" + "Atodiad" + "Llyfrgell Ffotograffau a Fideo" + "Lleoliad" + "Pôl" + "Fformatio Testun" + "Nid yw hanes negeseuon ar gael ar hyn o bryd." + "Nid yw hanes negeseuon ar gael yn yr ystafell hon. Dilyswch y ddyfais hon i weld hanes eich neges." + "Hoffech chi eu gwahodd yn ôl?" + "Rydych chi ar eich pen eich hun yn y sgwrs hon" + "Rhowch wybod i\'r ystafell gyfan" + "Pawb" + "Anfon eto" + "Methodd eich neges ag anfon" + "Ychwanegu emoji" + "Dyma ddechrau %1$s." + "Dyma ddechrau\'r sgwrs hon." + "Galwad heb ei chefnogi. Gofynnwch a all y galwr ddefnyddio\'r ap Element X newydd." + "Dangos llai" + "Neges wedi\'i chopïo" + "Does gennych chi ddim caniatâd i bostio i\'r ystafell hon" + + "Ymatebodd %1$d aelodau gyda %2$s" + "Ymatebodd %1$d aelodau gyda %2$s" + "Ymatebodd %1$d aelod gyda %2$s" + "Ymatebodd %1$d aelod gyda %2$s" + "Ymatebodd %1$d aelod gyda %2$s" + "Ymatebodd %1$d aelod gyda %2$s" + + + "Fe wnaethoch chi a%1$d aelod ymateb gyda%2$s" + "Fe wnaethoch chi a%1$d aelod ymateb gyda%2$s" + "Fe wnaethoch chi a%1$d aelod ymateb gyda%2$s" + "Fe wnaethoch chi a%1$d aelod ymateb gyda%2$s" + "Fe wnaethoch chi a%1$d aelod ymateb gyda%2$s" + "Fe wnaethoch chi a%1$d aelod ymateb gyda%2$s" + + "Rydych chi wedi ymateb gyda %1$s" + "Dangos llai" + "Dangos rhagor" + "Dangos crynodeb o ymatebion" + "Newydd" + + "%1$d newid ystafelloedd" + "%1$d newid ystafell" + "%1$d newid ystafell" + "%1$d newid ystafell" + "%1$d newid ystafell" + "%1$d newid ystafell" + + "Neidio i ystafell newydd" + "Mae\'r ystafell hon wedi\'i disodli ac nid yw\'n weithredol mwyach" + "Gweld hen negeseuon" + "Mae\'r ystafell hon yn barhad o ystafell arall" + + "%1$s, %2$s a %3$d arall" + "%1$s, %2$s a %3$d arall" + "%1$s, %2$s a %3$d arall" + "%1$s, %2$s a %3$d arall" + "%1$s, %2$s a %3$d arall" + "%1$s, %2$s a %3$d arall" + + + "Mae %1$s yn teipio" + "Mae %1$s yn teipio" + "Mae %1$s yn teipio" + "Mae %1$s yn teipio" + "Mae %1$s yn teipio" + "Mae %1$s yn teipio" + + "%1$s a %2$s" + diff --git a/features/messages/impl/src/main/res/values-da/translations.xml b/features/messages/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..af7b0f0 --- /dev/null +++ b/features/messages/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,79 @@ + + + "Aktiviteter" + "Flag" + "Mad og drikke" + "Dyr og natur" + "Objekter" + "Smileys og personer" + "Rejser og steder" + "Seneste emojis" + "Symboler" + "Billedtekster er muligvis ikke synlige for personer, der bruger ældre apps." + "Tryk for at ændre videokvaliteten i uploadet" + "Filen kunne ikke uploades." + "Det lykkedes ikke at behandle medier til upload. Prøv venligst igen." + "Upload af medier mislykkedes. Prøv igen." + "Den maksimalt tilladte filstørrelse er %1$s ." + "Filen er for stor til at kunne uploades." + "Fil %1$d af %2$d" + "Optimér billedkvaliteten" + "Behandler…" + "Bloker bruger" + "Marker, hvis du vil skjule alle nuværende og fremtidige beskeder fra denne bruger" + "Denne meddelelse vil blive indberettet til administratoren af din hjemmeserver. De vil ikke være i stand til at læse nogen krypterede meddelelser." + "Årsag til indberetning af dette indhold" + "Kamera" + "Tag billede" + "Optag video" + "Vedhæftning" + "Foto- og videobibliotek" + "Lokation" + "Afstemning" + "Tekstformatering" + "Beskedhistorikken er i øjeblikket ikke tilgængelig." + "Beskedhistorikken er ikke tilgængelig i dette rum. Bekræft denne enhed for at se din beskedhistorik." + "Vil du invitere dem igen?" + "Du er alene i denne samtale" + "Giv hele rummet besked" + "Alle" + "Send igen" + "Din besked kunne ikke sendes" + "Tilføj emoji" + "Dette er begyndelsen på %1$s." + "Dette er begyndelsen på denne samtale." + "Ikke-understøttet opkald. Spørg, om den, der ringer op, kan bruge den nye Element X-app." + "Vis mindre" + "Beskeden er kopieret" + "Du har ikke tilladelse til at slå noget op i dette rum" + + "%1$d medlem reagerede med %2$s" + "%1$d medlemmer reagerede med %2$s" + + + "Du og %1$d medlem reagerede med %2$s" + "Du og %1$d medlemmer reagerede med %2$s" + + "Du reagerede med %1$s" + "Vis mindre" + "Vis mere" + "Vis oversigt over reaktioner" + "Ny" + + "%1$d rumændring" + "%1$d rumændringer" + + "Gå til nyt rum" + "Dette rum er blevet erstattet og er ikke længere aktivt" + "Se ældre beskeder" + "Dette rum er en fortsættelse af et andet rum" + + "%1$s, %2$s og %3$d anden" + "%1$s, %2$s og %3$d andre" + + + "%1$s skriver" + "%1$s skriver" + + "%1$s og %2$s" + 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 0000000..458be8a --- /dev/null +++ b/features/messages/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,79 @@ + + + "Aktivitäten" + "Flaggen" + "Essen & Trinken" + "Tiere & Natur" + "Objekte" + "Smileys & Menschen" + "Reisen & Orte" + "Zuletzt verwendete Emojis" + "Symbole" + "Bildunterschriften sind für Nutzer älterer Apps möglicherweise nicht sichtbar." + "Tippe, um die Qualität des Video-Uploads zu ändern" + "Die Datei konnte nicht hochgeladen werden." + "Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut." + "Das Hochladen der Medien ist fehlgeschlagen. Bitte versuche es erneut." + "Die maximal zulässige Dateigröße beträgt %1$s." + "Die Datei ist zu groß zum Hochladen." + "%1$d von %2$d" + "Optimiere die Bildqualität" + "Verarbeitung läuft …" + "Nutzer blockieren" + "Prüfe, ob du alle aktuellen und zukünftigen Nachrichten dieses Nutzers ausblenden möchtest" + "Diese Nachricht wird dem Administrator deines Homeservers gemeldet. Dieser kann allerdings keine verschlüsselten Nachrichten lesen." + "Grund für die Meldung dieses Inhalts" + "Kamera" + "Foto aufnehmen" + "Video aufnehmen" + "Anhang" + "Foto- und Videogalerie" + "Standort" + "Umfrage" + "Textformatierung" + "Der Nachrichtenverlauf ist derzeit nicht verfügbar" + "Der Nachrichtenverlauf ist nicht verfügbar. Verifiziere dieses Gerät, um deinen Nachrichtenverlauf zu sehen." + "Möchtest du sie wieder einladen?" + "Du bist allein in diesem Chat" + "Alle Mitglieder benachrichtigen" + "Alle" + "Erneut senden" + "Deine Nachricht konnte nicht gesendet werden" + "Emoji hinzufügen" + "Dies ist der Anfang von %1$s." + "Dies ist der Anfang dieses Gesprächs." + "Nicht unterstützter Anruf. Frag den Anrufer, ob er die neue Element X-App nutzen kann." + "Weniger anzeigen" + "Nachricht wurde kopiert" + "Du bist nicht berechtigt, in diesem Chat zu schreiben" + + "%1$d Mitglied reagierte mit %2$s" + "%1$d Mitglieder reagierten mit %2$s" + + + "Du und %1$d Mitglied reagierten mit %2$s" + "Du und %1$d Mitglieder reagierten mit %2$s" + + "Du hast reagiert mit %1$s" + "Weniger anzeigen" + "Mehr anzeigen" + "Zusammenfassung der Reaktionen anzeigen" + "Neu" + + "%1$d Änderung im Chat" + "%1$d Änderungen im Chat" + + "Zum Nachfolge-Chat springen" + "Dieser Chat wurde stillgelegt und ist nicht mehr aktiv" + "Alte Nachrichten ansehen" + "Dieser Chat ist eine Fortsetzung eines anderen Chats" + + "%1$s, %2$s und %3$d weitere Person" + "%1$s, %2$s und %3$d weitere Person" + + + "%1$s schreibt…" + "%1$s schreiben…" + + "%1$s und %2$s" + diff --git a/features/messages/impl/src/main/res/values-el/translations.xml b/features/messages/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..1bee722 --- /dev/null +++ b/features/messages/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,71 @@ + + + "Δραστηριότητες" + "Σημαίες" + "Φαγητό & Ποτό" + "Ζώα & Φύση" + "Αντικείμενα" + "Φατσούλες & Άνθρωποι" + "Ταξίδια & Μέρη" + "Σύμβολα" + "Οι λεζάντες ενδέχεται να μην είναι ορατές σε άτομα που χρησιμοποιούν παλαιότερες εφαρμογές." + "Αποτυχία μεταφόρτωσης μέσου, δοκίμασε ξανά." + "Αποτυχία μεταφόρτωσης πολυμέσων, δοκίμασε ξανά." + "Αποκλεισμός χρήστη" + "Επέλεξε εάν θες να αποκρύψεις όλα τα τρέχοντα και μελλοντικά μηνύματα από αυτόν τον χρήστη" + "Αυτό το μήνυμα θα αναφερθεί στον διαχειριστή του οικιακού διακομιστή σας. Δεν θα μπορεί να διαβάσει κρυπτογραφημένα μηνύματα." + "Λόγος αναφοράς αυτού του περιεχομένου" + "Κάμερα" + "Τράβηξε φωτογραφία" + "Εγγραφή βίντεο" + "Επισύναψη" + "Βιβλιοθήκη Φωτογραφιών & Βίντεο" + "Τοποθεσία" + "Δημοσκόπηση" + "Μορφοποίηση Κειμένου" + "Το ιστορικό μηνυμάτων δεν είναι διαθέσιμο προς το παρόν." + "Το ιστορικό μηνυμάτων δεν είναι διαθέσιμο σε αυτή την αίθουσα. Επαληθεύστε αυτή τη συσκευή για να δείτε το ιστορικό των μηνυμάτων σας." + "Θα ήθελες να τους προσκαλέσεις και συ;" + "Είσαι μόνος σε αυτή τη συνομιλία" + "Ειδοποιήστε όλη την αίθουσα" + "Όλοι" + "Αποστολή ξανά" + "Το μήνυμά σου απέτυχε να σταλεί" + "Προσθήκη emoji" + "Αυτή είναι η αρχή του %1$s." + "Αυτή είναι η αρχή τούτης της συνομιλίας." + "Μη υποστηριζόμενη κλήση. Ρώτα εάν ο καλών μπορεί να χρησιμοποιήσει τη νέα εφαρμογή Element X." + "Εμφάνιση λιγότερων" + "Το μήνυμα αντιγράφηκε" + "Δεν έχετε δικαιώματα για να δημοσιεύσετε σε αυτήν την αίθουσα" + + "%1$d μέλος αντέδρασε με %2$s" + "%1$d μέλη αντέδρασαν με %2$s" + + + "Εσύ και %1$d μέλος αντιδράσατε με %2$s" + "Εσύ και τα %1$d μέλη αντιδράσατε με %2$s" + + "Αντέδρασες με %1$s" + "Εμφάνιση λιγότερων" + "Εμφάνιση περισσότερων" + "Εμφάνιση περίληψης αντιδράσεων" + "Νέο" + + "%1$d αλλαγή αίθουσας" + "%1$d αλλαγές αίθουσας" + + "Μετάβαση σε νέα αίθουσα" + "Αυτή η αίθουσα έχει αντικατασταθεί και δεν είναι πλέον ενεργή" + "Δες παλιά μηνύματα" + "Αυτή η αίθουσα αποτελεί συνέχεια μιας άλλης αίθουσας" + + "%1$s, %2$s και %3$d ακόμη" + "%1$s, %2$s και %3$d ακόμη" + + + "Ο χρήστης %1$s πληκτρολογεί" + "Οι χρήστες %1$s πληκτρολογούν" + + "%1$s και %2$s" + diff --git a/features/messages/impl/src/main/res/values-en-rUS/translations.xml b/features/messages/impl/src/main/res/values-en-rUS/translations.xml new file mode 100644 index 0000000..d19cc82 --- /dev/null +++ b/features/messages/impl/src/main/res/values-en-rUS/translations.xml @@ -0,0 +1,4 @@ + + + "Optimize image quality" + 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 0000000..e8f639f --- /dev/null +++ b/features/messages/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,57 @@ + + + "Actividades" + "Banderas" + "Comida y bebida" + "Animales y naturaleza" + "Objetos" + "Emojis y personas" + "Viajes y lugares" + "Símbolos" + "Es posible que las leyendas no sean visibles para las personas que usan aplicaciones más antiguas." + "Error al procesar el contenido multimedia, por favor inténtalo de nuevo." + "Error al subir el contenido multimedia, por favor inténtalo de nuevo." + "Bloquear usuario" + "Marca esta casilla si quieres ocultar todos los mensajes actuales y futuros de este usuario" + "Se denunciará este mensaje al administrador de tu servidor base. No será capaz de leer ningún mensaje cifrado." + "Motivo para denunciar este contenido" + "Cámara" + "Hacer foto" + "Grabar video" + "Archivo adjunto" + "Biblioteca de fotos y vídeos" + "Ubicación" + "Encuesta" + "Formato de texto" + "El historial de mensajes no está disponible en este momento." + "El historial de mensajes no está disponible en esta sala. Verifica este dispositivo para ver tu historial de mensajes." + "¿Quieres volver a invitarlos?" + "Estás solo en este chat" + "Notificar a toda la sala" + "Todos" + "Enviar de nuevo" + "No se pudo enviar tu mensaje" + "Añadir emoji" + "Este es el principio de %1$s." + "Este es el principio de esta conversación." + "Llamada no compatible. Pregunta a la persona que llama si puede utilizar la nueva aplicación Element X." + "Mostrar menos" + "Mensaje copiado" + "No tienes permiso para publicar en esta sala" + "Mostrar menos" + "Mostrar más" + "Nuevos" + + "%1$d cambio en la sala" + "%1$d cambios en la sala" + + + "%1$s, %2$s y %3$d otro" + "%1$s, %2$s y %3$d otros" + + + "%1$s está escribiendo" + "%1$s están escribiendo" + + "%1$s y %2$s" + diff --git a/features/messages/impl/src/main/res/values-et/translations.xml b/features/messages/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..b2129f4 --- /dev/null +++ b/features/messages/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,79 @@ + + + "Tegevused" + "Lipud" + "Toit ja jook" + "Loomad ja loodus" + "Esemed" + "Emotikonid ja inimesed" + "Reisimine ja kohad" + "Hiljutised emojid" + "Sümbolid" + "Selgitused ja alapealkirjad ei pruugi olla nähtavad vanemate rakenduste kasutajatele." + "Klõpsa üleslaaditava video kvaliteedi muutmiseks" + "Faili üleslaadimine ei õnnestunud." + "Meediafaili töötlemine enne üleslaadimist ei õnnestunud. Palun proovi uuesti." + "Meediafaili üleslaadimine ei õnnestunud. Palun proovi uuesti." + "Maksimaalne lubatud failisuurus on %1$s." + "Fail on üleslaadimiseks liiga suur" + "Objekt %1$d/%2$d" + "Optimeeri pildikvaliteeti" + "Töötlen…" + "Blokeeri kasutaja" + "Vali see eelistus, kui sa soovid peita selle kasutaja kõik senised ja tulevased sõnumid" + "Teade selle sõnumi kohta edastatakse sinu koduserveri haldajale. Haldajal ei ole võimalik lugeda krüptitud sõnumite sisu." + "Sellest sisust teatamise põhjus" + "Kaamera" + "Tee pilt" + "Salvesta video" + "Manus" + "Fotode ja videote galerii" + "Asukoht" + "Küsitlus" + "Tekstivorming" + "Sõnumite ajalugu pole hetkel saadaval" + "Selle jututoa sõnumite ajalugu pole hetkel saadaval. Verifitseeri see seade ja näed tervet oma sõnumiteajalugu." + "Kas sa sooviksid neid tagasi kutsuda?" + "Sa oled selles vestluses üksinda" + "Teavita kogu jututuba" + "Kõik" + "Saada uuesti" + "Sinu sõnumi saatmine ei õnnestunud" + "Lisa emotikon" + "See on %1$s jututoa algus." + "See on antud vestluse algus." + "Kõne pole nende rakenduste vahel toetatud. Palun küsi teiselt osapoolelt, kas ta oleks nõus kasutama Element X rakendust." + "Näita vähem" + "Sõnum on kopeeritud" + "Sul pole õigusi siia jututuppa kirjutada" + + "%1$d liige reageeris: %2$s" + "%1$d liige reageerisid: %2$s" + + + "Sina ja veel %1$d liige reageeris: %2$s" + "Sina ja veel %1$d liiget reageerisid: %2$s" + + "Sina reageerisid: %1$s" + "Näita vähem" + "Näita rohkem" + "Näita reageerimiste kokkuvõtet" + "Uus" + + "%1$d jututoa muudatus" + "%1$d jututoa muudatust" + + "Hüppa uude jututuppa" + "See jututuba on asendatud uuega ning pole enam aktiivne" + "Vaata vanu sõnumeid" + "See jututuba on varasema jututoa jätk" + + "%1$s, %2$s ja veel %3$d osaleja" + "%1$s, %2$s ja veel %3$d osalejat" + + + "%1$s kirjutab" + "%1$s kirjutavad" + + "%1$s ja %2$s" + diff --git a/features/messages/impl/src/main/res/values-eu/translations.xml b/features/messages/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..fdd0b79 --- /dev/null +++ b/features/messages/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,53 @@ + + + "Jarduerak" + "Banderak" + "Jana eta edana" + "Animaliak eta natura" + "Objektuak" + "Irribartxoak eta jendea" + "Bidaiak eta tokiak" + "Ikurrak" + "Huts egin du multimedia igotzeak, saiatu berriro." + "Blokeatu erabiltzailea" + "Mezua zure zerbitzariko administratzaileari jakinaraziko zaio. Ezingo dute zifratutako mezurik irakurri." + "Edukia salatzeko arrazoia" + "Kamera" + "Egin argazkia" + "Grabatu bideoa" + "Eranskina" + "Argazki- eta bideo-liburutegia" + "Kokapena" + "Inkesta" + "Testuaren formatua" + "Mezuen historia ez dago erabilgarri gaur-gaurkoz." + "Berriro gonbidatu nahi al dituzu?" + "Bakarrik zaude txat honetan" + "Jakinarazi gela osoari" + "Guztiak" + "Bidali berriro" + "Huts egin du mezuaren bidalketak" + "Gehitu emojia" + "Hauxe da %1$s(r)en hasiera" + "Hau da elkarrizketaren hasiera." + "Deia ez da bateragarria. Galdetu deika ari denari Element X aplikazio berria erabil dezakeen." + "Erakutsi gutxiago" + "Mezua kopiatu da" + "Ez duzu gela honetara mezuak bidaltzeko baimenik" + "Erakutsi gutxiago" + "Erakutsi gehiago" + "Berria" + + "Gela aldaketa %1$d" + "%1$d gela aldaketa" + + + "%1$s, %2$s, eta beste %3$d" + "%1$s, %2$s, eta beste %3$d" + + + "%1$s idazten ari da" + "%1$s idazten ari dira" + + "%1$s eta %2$s" + diff --git a/features/messages/impl/src/main/res/values-fa/translations.xml b/features/messages/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..b102f82 --- /dev/null +++ b/features/messages/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,54 @@ + + + "فعّالیت‌ها" + "پرچم‌ها" + "غذا و نوشیدنی" + "حیوانات و طبعیت" + "اشیا" + "شکلک‌ها و افراد" + "سفر و مکان‌ها" + "شکلک‌های اخیر" + "نمادها" + "پردازش رسانه برای بارگذاری شکست خورد. لطفاً دوباره تلاش کنید." + "بارگذاری رسانه شکست خورد. لطفاً دوباره تلاش کنید." + "پردازش کردن…" + "انسداد کاربر" + "اگر می‌خواهید همه پیام‌های فعلی و آینده را از این کاربر را پنهان کنید، علامت بزنید" + "این پیام به مدیر کارساز خانگی شما گزارش خواهد شد. آنها قادر به خواندن پیام های رمزگذاری شده نخواهند بود." + "دلیل گزارش این محتوا" + "دوربین" + "عکس گرفتن" + "ضبط ویدیو" + "پیوست" + "کتابخانهٔ عکس و ویدیو" + "مکان" + "نظرسنجی" + "قالب‌بندی متن" + "تاریخچه پیام درحال حاضر دردسترس نیست." + "تاریخچهٔ پیام‌های این اتاق در دسترس نیست. برای دیدن تاریخچهٔ پیام‌هایتان این افزاره را تأیید کنید." + "می‌خواهید دوباره دعوتش کنید؟" + "در این گپ تنهایید" + "آگاهی به تمام اتاق" + "هرکسی" + "فرستادن دوباره" + "فرستادن پیامتان شکست خورد" + "افزودن شکلک" + "آغاز %1$s است." + "این، آغاز گفت‌وگوست." + "نمایش کم‌تر" + "پیام رونوشت شد" + "اجازهٔ فرستادن به این اتاق را ندارید" + "نمایش کم‌تر" + "نمایش بیش‌تر" + "نمایش خلاصهٔ واکنش‌ها" + "جدید" + + "%1$dتغییر اتاق" + "%1$dتغییر اتاق" + + "پرش به اتاق جدید" + "این اتاق جایگزین شده و دیگر فعّال نیست" + "دیدن پیام‌های قدیمی" + "این اتاق ادامهٔ اتاقی دیگر است" + "%1$s و %2$s" + diff --git a/features/messages/impl/src/main/res/values-fi/translations.xml b/features/messages/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..26c2c7f --- /dev/null +++ b/features/messages/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,79 @@ + + + "Aktiviteetit" + "Liput" + "Ruoka ja juoma" + "Eläimet ja luonto" + "Esineet" + "Hymiöt ja ihmiset" + "Matkustaminen ja paikat" + "Viimeaikaiset emojit" + "Symbolit" + "Kuvatekstit eivät välttämättä näy ihmisille, jotka käyttävät vanhempia sovelluksia." + "Napauta muuttaaksesi videon lähetyslaatua" + "Tiedostoa ei voitu lähettää." + "Median käsittely epäonnistui, yritä uudelleen." + "Median lähettäminen epäonnistui, yritä uudelleen." + "Suurin sallittu tiedostokoko on %1$s." + "Tiedosto on liian suuri lähetettäväksi" + "Kohde %1$d / %2$d" + "Optimoi kuvanlaatu" + "Käsitellään…" + "Estä käyttäjä" + "Valitse tämä, jos haluat piilottaa kaikki nykyiset ja tulevat viestit tältä käyttäjältä" + "Tämä viesti ilmoitetaan kotipalvelimesi ylläpitäjälle. Ylläpitäjä ei pysty lukemaan salattuja viestejä." + "Syy tämän sisällön ilmoittamiseen" + "Kamera" + "Ota kuva" + "Nauhoita video" + "Liite" + "Kuva- ja videokirjasto" + "Sijainti" + "Kysely" + "Tekstin muotoilu" + "Viestihistoria ei ole tällä hetkellä saatavilla" + "Viestihistoria ei ole käytettävissä tässä huoneessa. Vahvista tämä laite nähdäksesi viestihistoriasi." + "Haluatko kutsua heidät takaisin?" + "Olet yksin tässä keskustelussa" + "Ilmoita koko huoneelle" + "Kaikki" + "Lähetä uudelleen" + "Viestisi lähettäminen epäonnistui" + "Lisää reaktio" + "Tämä on huoneen %1$s alku." + "Tämä on tämän keskustelun alku." + "Puhelu, jota ei tueta. Kysy, voiko soittaja käyttää uutta Element X -sovellusta." + "Näytä vähemmän" + "Viesti kopioitu" + "Sinulla ei ole oikeutta kirjoittaa tässä huoneessa" + + "%1$d jäsen reagoi %2$s" + "%1$d jäsentä reagoivat %2$s" + + + "Sinä ja %1$d jäsen reagoitte %2$s" + "Sinä ja %1$d jäsentä reagoitte %2$s" + + "Reagoit seuraavasti: %1$s" + "Näytä vähemmän" + "Näytä lisää" + "Näytä reaktioiden yhteenveto" + "Uusi" + + "%1$d muutos huoneeseen" + "%1$d muutosta huoneeseen" + + "Siirry uuteen huoneeseen" + "Tämä huone on korvattu, eikä se ole enää aktiivinen" + "Katso vanhoja viestejä" + "Tämä huone on jatkoa toiselle huoneelle" + + "%1$s, %2$s ja %3$d muu" + "%1$s, %2$s ja %3$d muuta" + + + "%1$s kirjoittaa" + "%1$s kirjoittavat" + + "%1$s ja %2$s" + 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 0000000..cb8e65f --- /dev/null +++ b/features/messages/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,79 @@ + + + "Activités" + "Drapeaux" + "Nourriture et boissons" + "Animaux et nature" + "Objets" + "Émoticônes et personnes" + "Voyages & lieux" + "Emojis récents" + "Symboles" + "Les légendes peuvent ne pas être visibles pour les utilisateurs d’anciennes applications." + "Cliquez pour modifier la qualité d’envoi de la vidéo" + "Le fichier n’a pas pu être envoyé." + "Échec du traitement des médias à télécharger, veuillez réessayer." + "Échec du téléchargement du média, veuillez réessayer." + "La taille maximale autorisée pour les fichiers est de %1$s." + "Le fichier est trop volumineux pour être envoyé." + "Élément %1$d sur %2$d" + "Optimiser la qualité de l’image" + "Traitement en cours…" + "Bloquer l’utilisateur" + "Cochez si vous souhaitez masquer tous les messages actuels et futurs de cet utilisateur." + "Ce message sera signalé à l’administrateur de votre serveur d’accueil. Il ne pourra lire aucun message chiffré." + "Raison du signalement de ce contenu" + "Appareil photo" + "Prendre une photo" + "Enregistrer une vidéo" + "Pièce jointe" + "Galerie Photo et Vidéo" + "Position" + "Sondage" + "Formatage du texte" + "L’historique des messages n’est actuellement pas disponible dans ce salon" + "L’historique de la discussion n’est pas disponible. Vérifiez cette session pour accéder à l’historique." + "Souhaitez-vous inviter l’ancien membre à revenir ?" + "Vous êtes seul dans cette discussion" + "Notifier tout le salon" + "Tout le monde" + "Envoyer à nouveau" + "Votre message n’a pas pu être envoyé" + "Ajouter un émoji" + "Ceci est le début de %1$s." + "Ceci est le début de cette conversation." + "Appel non pris en charge. Demandez à l’appelant s’il peut utiliser la nouvelle application Element X pour vous appeler." + "Afficher moins" + "Message copié" + "Vous n’êtes pas autorisé à publier dans ce salon" + + "%1$d membre a réagi avec %2$s" + "%1$d membres ont réagi avec %2$s" + + + "Vous et %1$d membre avez réagi avec %2$s" + "Vous et %1$d membres avez réagi avec %2$s" + + "Vous avez réagi avec %1$s" + "Afficher moins" + "Afficher plus" + "Afficher le résumé des réactions" + "Nouveau" + + "%1$d changement dans le salon" + "%1$d changements dans le salon" + + "Aller dans le nouveau salon" + "Ce salon a été remplacé et n’est plus actif" + "Voir les anciens messages" + "Ce salon est la continuation du salon précédent" + + "%1$s, %2$s et %3$d autre" + "%1$s, %2$s et %3$d autres" + + + "%1$s écrit" + "%1$s écrivent" + + "%1$s et %2$s" + diff --git a/features/messages/impl/src/main/res/values-hu/translations.xml b/features/messages/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..f859aa5 --- /dev/null +++ b/features/messages/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,79 @@ + + + "Tevékenységek" + "Zászlók" + "Étel és ital" + "Állatok és természet" + "Tárgyak" + "Mosolyok és emberek" + "Utazás és helyek" + "Legutóbbi emodzsik" + "Szimbólumok" + "Előfordulhat, hogy a feliratok nem láthatók a régebbi alkalmazásokat használók számára." + "Koppintson a feltöltött videók minőségének módosításához" + "A fájl nem tölthető fel." + "Nem sikerült feldolgozni a feltöltendő médiát, próbálja újra." + "Nem sikerült a média feltöltése, próbálja újra." + "A maximálisan megengedett fájlméret: %1$s ." + "A fájl túl nagy a feltöltéshez" + "%1$d. elem / %2$d" + "Képminőség optimalizációja" + "Feldolgozás…" + "Felhasználó letiltása" + "Jelölje be, ha el akarja rejteni az összes jelenlegi és jövőbeli üzenetet ettől a felhasználótól" + "Ez az üzenet jelentve lesz a Matrix-kiszolgáló adminisztrátorának. Nem fogja tudni elolvasni a titkosított üzeneteket." + "A tartalom jelentésének oka" + "Kamera" + "Fénykép készítése" + "Videó rögzítése" + "Melléklet" + "Fénykép- és videótár" + "Hely" + "Szavazás" + "Szövegformázás" + "Az üzenetelőzmények jelenleg nem érhetők el." + "Az üzenetelőzmények nem érhetők el ebben a szobában. Ellenőrizze ezt az eszközt, hogy lássa az előzményeket." + "Visszahívja?" + "Egyedül van ebben a csevegésben" + "Az egész szoba értesítése" + "Mindenki" + "Újraküldés" + "Az üzenet elküldése sikertelen" + "Emodzsi hozzáadása" + "Ez a(z) %1$s kezdete." + "Ez a beszélgetés kezdete." + "Nem támogatott hívás. Kérdezze meg, hogy a hívó fél tudja-e használni az új Element X alkalmazást." + "Kevesebb megjelenítése" + "Üzenet másolva" + "Nincs jogosultsága arra, hogy bejegyzést tegyen közzé ebben a szobában" + + "%1$d tag reagált így: %2$s" + "%1$d tag reagált így: %2$s" + + + "Ön és %1$d tag reagáltak így: %2$s" + "Ön és %1$d tag reagáltak így: %2$s" + + "Ezzel reagált: %1$s" + "Kevesebb megjelenítése" + "Több megjelenítése" + "Reakció-összefoglaló megjelenítése" + "Új" + + "%1$d szobaváltozás" + "%1$d szobaváltozás" + + "Ugrás az új szobába" + "Ezt a szobát lecserélték, és már nem aktív." + "Régi üzenetek megtekintése" + "Ez a szoba egy másik szoba folytatása" + + "%1$s, %2$s és %3$d további felhasználó" + "%1$s, %2$s és %3$d további felhasználó" + + + "%1$s éppen ír…" + "%1$s éppen ír…" + + "%1$s és %2$s" + diff --git a/features/messages/impl/src/main/res/values-in/translations.xml b/features/messages/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..1be1dba --- /dev/null +++ b/features/messages/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,66 @@ + + + "Aktivitas" + "Bendera" + "Makanan & Minuman" + "Hewan & Alam" + "Objek" + "Senyuman & Orang" + "Wisata & Tempat" + "Simbol" + "Keterangan mungkin tidak terlihat oleh orang yang menggunakan aplikasi lama." + "Gagal memproses media untuk diunggah, silakan coba lagi." + "Gagal mengunggah media, silakan coba lagi." + "Blokir pengguna" + "Centang jika Anda ingin menyembunyikan semua pesan saat ini dan yang akan datang dari pengguna ini" + "Pesan ini akan dilaporkan ke administrator homeserver Anda. Mereka tidak akan dapat membaca pesan terenkripsi apa pun." + "Alasan melaporkan konten ini" + "Kamera" + "Ambil foto" + "Rekam video" + "Lampiran" + "Pustaka Foto & Video" + "Lokasi" + "Pemungutan suara" + "Pemformatan Teks" + "Riwayat pesan saat ini tidak tersedia di ruangan ini" + "Riwayat pesan tidak tersedia di ruangan ini. Verifikasi perangkat ini untuk melihat riwayat pesan." + "Apakah Anda ingin mengundang mereka kembali?" + "Anda sendirian di obrolan ini" + "Beri tahu seluruh ruangan" + "Semua orang" + "Kirim ulang" + "Pesan Anda gagal dikirim" + "Tambahkan emoji" + "Ini adalah awal dari %1$s." + "Ini adalah awal dari percakapan ini." + "Panggilan tidak didukung. Tanyakan apakah penelepon dapat menggunakan aplikasi Element X yang baru." + "Tampilkan lebih sedikit" + "Pesan disalin" + "Anda tidak memiliki izin untuk mengirim di ruangan ini" + + "%1$d anggota bereaksi dengan %2$s" + + + "Anda dan %1$d anggota bereaksi dengan %2$s" + + "Anda bereaksi dengan %1$s" + "Tampilkan lebih sedikit" + "Tampilkan lebih banyak" + "Tampilkan ringkasan reaksi" + "Baru" + + "%1$d perubahan ruangan" + + "Lompat ke ruangan baru" + "Ruangan ini telah diganti dan tidak lagi aktif" + "Lihat pesan lama" + "Ruangan ini adalah lanjutan dari ruangan lain" + + "%1$s, %2$s, dan %3$d lainnya" + + + "%1$s sedang mengetik" + + "%1$s dan %2$s" + 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 0000000..c5b98c8 --- /dev/null +++ b/features/messages/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,79 @@ + + + "Attività" + "Bandiere" + "Cibi & Bevande" + "Animali & Natura" + "Oggetti" + "Faccine & Persone" + "Viaggi & Luoghi" + "Emoji recenti" + "Simboli" + "Le didascalie potrebbero non essere visibili agli utenti di app meno recenti." + "Tocca per modificare la qualità di caricamento del video" + "Impossibile caricare il file." + "Elaborazione del file multimediale da caricare fallita, riprova." + "Caricamento del file multimediale fallito, riprova." + "La dimensione massima consentita del file è %1$s ." + "Il file è troppo grande per essere caricato" + "Elemento %1$d di %2$d" + "Ottimizza la qualità delle immagini" + "Elaborazione…" + "Blocca utente" + "Seleziona se vuoi nascondere tutti i messaggi attuali e futuri di questo utente" + "Questo messaggio verrà segnalato all\'amministratore dell\'homeserver. Questi non sarà in grado di leggere i messaggi cifrati." + "Motivo della segnalazione di questo contenuto" + "Fotocamera" + "Scatta foto" + "Registra video" + "Allegato" + "Libreria di foto e video" + "Posizione" + "Sondaggio" + "Formattazione del testo" + "La cronologia dei messaggi non è attualmente disponibile." + "La cronologia dei messaggi non è disponibile in questa stanza. Verifica questo dispositivo per vedere la cronologia dei messaggi." + "Vorresti invitarli di nuovo?" + "Ci sei solo tu in questa chat" + "Notifica l\'intera stanza" + "Tutti" + "Invia di nuovo" + "Il tuo messaggio non è stato inviato" + "Aggiungi emoji" + "Questo è l\'inizio di %1$s." + "Questo è l\'inizio della conversazione." + "Chiamata non supportata. Chiedi se il chiamante può utilizzare la nuova app Element X." + "Mostra meno" + "Messaggio copiato" + "Non sei autorizzato a postare in questa stanza" + + "un membro ha reagito con %2$s" + "%1$d membri hanno reagito con %2$s" + + + "Tu e un altro membro avete reagito con %2$s" + "Tu e %1$d altri membri avete reagito con %2$s" + + "Hai reagito con %1$s" + "Mostra meno" + "Mostra di più" + "Mostra il riepilogo delle reazioni" + "Nuovo" + + "%1$d modifica alla stanza" + "%1$d modifiche alla stanza" + + "Vai alla nuova stanza" + "Questa stanza è stata sostituita e non è più attiva" + "Visualizza i vecchi messaggi" + "Questa stanza è la continuazione di un\'altra stanza" + + "%1$s, %2$s e %3$d altro" + "%1$s, %2$s e altri %3$d" + + + "%1$s sta scrivendo" + "%1$s stanno scrivendo" + + "%1$s e %2$s" + diff --git a/features/messages/impl/src/main/res/values-ka/translations.xml b/features/messages/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..842fa4b --- /dev/null +++ b/features/messages/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,55 @@ + + + "აქტივობები" + "დროშები" + "Საჭმელ-სასმელი" + "ცხოველები & ბუნება" + "ობიექტები" + "ღიმილები & ხალხი" + "მოგზაურობა და ადგილები" + "სიმბოლოები" + "მედიის ატვირთვა ვერ მოხერხდა. გთხოვთ, სცადოთ ხელახლა." + "მედიის ატვირთვა ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა." + "მომხმარებლის დაბლოკვა" + "შეამოწმეთ, გსურთ თუ არა ამ მომხმარებლის ყველა მიმდინარე და მომავალი შეტყობინების დამალვა" + "ეს შეტყობინება გაგზავნილი იქნება თქვენი სახლის სერვერის ადმინისტრატორისადმი. მას არ ექნება დაშიფვრული შეტყობინებების წაკითხვის შესაძლებლობა." + "ამ კონტენტის დარეპორტების მიზეზი" + "კამერა" + "ფოტოს გადაღება" + "ვიდეოს ჩაწერა" + "დანართი" + "ფოტოსა და ვიდეოს ბიბლიოთეკა" + "ადგილმდებარეობა" + "გამოკითხვა" + "ტექსტის ფორმატირება" + "შეტყობინებების ისტორია ამჟამად მიუწვდომელია." + "შეტყობინებების ისტორია ამ ოთახში მიუწვდომელია. დაადასტურეთ ეს მოწყობილობა თქვენი შეტყობინებების ისტორიის სანახავად." + "გსურთ მათი კვლავ მოწვევა?" + "თქვენ მარტო ხართ ამ ჩატში" + "მთელი ოთახისათვის შეტყობინება" + "ყველა" + "Ხელახლა გაგზავნა" + "თქვენი შეტყობინების გაგზავნა ვერ მოხერხდა" + "ემოჯის დამატება" + "ეს არის %1$s-ს დასაწყისი." + "ეს არის ამ საუბრის დასაწყისი." + "ნაკლების ჩვენება" + "შეტყობინება დაკოპირდა" + "თქვენ არ გაქვთ ამ ოთახში გამოქვეყნების ნებართვა" + "ნაკლების ჩვენება" + "მეტის ჩვენება" + "ახალი" + + "%1$dოთახის ცვლილება" + "%1$dოთახის ცვლილებები" + + + "%1$s, %2$s და %3$d სხვა" + "%1$s%2$sდა %3$d სხვა" + + + "%1$s წერს" + "%1$s წერენ" + + "%1$s და %2$s" + diff --git a/features/messages/impl/src/main/res/values-ko/translations.xml b/features/messages/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..ddcecb5 --- /dev/null +++ b/features/messages/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,72 @@ + + + "활동" + "깃발" + "음식 & 음료" + "동물 & 자연" + "사물" + "표정 & 사람" + "여행 & 장소" + "상징" + "캡션은 오래된 앱을 사용하는 사용자에게 표시되지 않을 수 있습니다." + "비디오 업로드 품질을 변경하려면 탭하세요" + "파일을 업로드할 수 없습니다." + "미디어 업로드 처리가 실패했습니다. 다시 시도해 주세요." + "미디어 파일 업로드에 실패했습니다. 다시 시도해 주세요." + "허용되는 최대 파일 크기는 %1$s 입니다." + "파일 크기가 너무 커서 업로드할 수 없습니다." + "이미지 품질 최적화" + "처리 중…" + "사용자 차단하기" + "이 사용자의 현재 및 향후 모든 메시지를 숨기려면 확인하세요." + "이 메시지는 홈서버의 관리자에게 보고되었습니다. 암호화된 메시지는 읽을 수 없습니다." + "이 콘텐츠를 신고하는 이유" + "카메라" + "사진 찍기" + "동영상 녹화" + "첨부 파일" + "사진 & 동영상 라이브러리" + "위치" + "투표" + "텍스트 서식" + "메시지 기록은 현재 사용할 수 없습니다." + "이 룸에서는 메시지 기록을 사용할 수 없습니다. 이 기기를 확인하여 메시지 기록을 확인하세요." + "그들을 다시 초대하시겠습니까?" + "이 채팅에는 귀하만 있습니다." + "방 전체에 알림" + "모두" + "다시 보내기" + "메시지 전송에 실패했습니다." + "반응 추가" + "%1$s의 시작입니다." + "대화의 시작입니다." + "지원되지 않는 통화입니다. 발신자에게 새로운 Element X 앱을 사용할 수 있는지 문의하시기 바랍니다." + "덜 보기" + "메시지 복사됨" + "이 방에 게시할 수 있는 권한이 없습니다." + + "%1$d 회원들이 반응했습니다: %2$s" + + + "당신과 %1$d 멤버들은 다음과 같이 반응했습니다 %2$s" + + "당신은 다음과 같이 반응했습니다 %1$s" + "덜 보기" + "더 보기" + "반응 요약 표시" + "신규" + + "%1$d 방 변경" + + "새로운 방으로 이동" + "이 방은 대체되어 더 이상 활성화되어 있지 않습니다" + "이전 메시지 보기" + "이 방은 다른 방의 연속입니다." + + "%1$s, %2$s 및 %3$d 기타" + + + "%1$s 입력 중입니다" + + "%1$s 그리고 %2$s" + diff --git a/features/messages/impl/src/main/res/values-lt/translations.xml b/features/messages/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..627d431 --- /dev/null +++ b/features/messages/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,30 @@ + + + "Veikla" + "Vėliavos" + "Maistas ir Gėrimai" + "Gyvūnai ir Gamta" + "Objektai" + "Šypsenėlės ir Žmonės" + "Kelionės ir Vietovės" + "Simboliai" + "Nepavyko apdoroti įkeliamos laikmenos, bandykite dar kartą." + "Nepavyko įkelti laikmenos, pabandykite dar kartą." + "Blokuoti vartotoją" + "Pažymėkite, jei norite paslėpti visas esamas ir būsimas šio vartotojo žinutes" + "Apie šią žinutę bus pranešta Jūsų serverio administracijai. Jie negalės perskaityti jokių užšifruotų žinučių." + "Skundo dėl šio turinio priežastis" + "Kamera" + "Fotografuoti" + "Įrašyti vaizdo įrašą" + "Priedas" + "Nuotraukų ir vaizdo įrašų biblioteka" + "Tai yra %1$s pradžia." + "Tai yra šio pokalbio pradžia." + "Naujų" + + "%1$d kambario pakeitimas" + "%1$d kambario pakeitimai" + "%1$d kambario pakeitimų" + + diff --git a/features/messages/impl/src/main/res/values-nb/translations.xml b/features/messages/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..69b6586 --- /dev/null +++ b/features/messages/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,79 @@ + + + "Aktiviteter" + "Flagg" + "Mat og drikke" + "Dyr og natur" + "Gjenstander" + "Smilefjes og mennesker" + "Reising og steder" + "Nylige emojier" + "Symboler" + "Teksting er kanskje ikke synlig for personer som bruker eldre apper." + "Trykk for å endre kvaliteten på videoopplastingen" + "Filen kunne ikke lastes opp." + "Kunne ikke behandle medier for opplasting, vennligst prøv igjen." + "Opplasting av medier mislyktes, vennligst prøv igjen." + "Maksimal tillatt filstørrelse er %1$s." + "Filen er for stor til å lastes opp" + "Fil %1$d av %2$d" + "Optimaliser bildekvaliteten" + "Behandler…" + "Blokker bruker" + "Kryss av for om du vil skjule alle nåværende og fremtidige meldinger fra denne brukeren" + "Denne meldingen vil bli rapportert til hjemmeserverens administratorer. De vil ikke kunne lese noen krypterte meldinger." + "Begrunnelse for å rapportere dette innholdet" + "Kamera" + "Ta bilde" + "Ta opp video" + "Vedlegg" + "Foto- og videobibliotek" + "Lokasjon" + "Avstemning" + "Tekstformatering" + "Meldingshistorikken er for øyeblikket ikke tilgjengelig." + "Meldingshistorikk er ikke tilgjengelig i dette rommet. Bekreft denne enheten for å se meldingshistorikken din." + "Vil du invitere dem tilbake?" + "Du er alene i denne chatten" + "Varsle hele rommet" + "Alle" + "Send igjen" + "Meldingen din ble ikke sendt" + "Legg til emoji" + "Dette er begynnelsen på %1$s." + "Dette er begynnelsen på denne samtalen." + "Anrop som ikke støttes. Spør om den som ringer kan bruke den nye Element X-appen." + "Vis mindre" + "Melding kopiert" + "Du har ikke tillatelse til å legge ut innlegg i dette rommet" + + "%1$d medlem reagerte med %2$s" + "%1$d medlemmer reagerte med %2$s" + + + "Du og %1$d medlem reagerte med%2$s" + "Du og %1$d medlemmer reagerte med%2$s" + + "Du reagerte med %1$s" + "Vis mindre" + "Vis mer" + "Vis sammendrag av reaksjoner" + "Ny" + + "%1$d romendring" + "%1$d romendringer" + + "Gå til nytt rom" + "Dette rommet har blitt erstattet og er ikke lenger aktivt" + "Se gamle meldinger" + "Dette rommet er en fortsettelse av et annet rom" + + "%1$s, %2$s og %3$d annet" + "%1$s, %2$s og %3$d andre" + + + "%1$s skriver" + "%1$s skriver" + + "%1$s og %2$s" + diff --git a/features/messages/impl/src/main/res/values-nl/translations.xml b/features/messages/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..afc25d2 --- /dev/null +++ b/features/messages/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,55 @@ + + + "Activiteiten" + "Vlaggen" + "Eten & Drinken" + "Dieren & Natuur" + "Voorwerpen" + "Smileys & Mensen" + "Reizen & Locaties" + "Symbolen" + "Het verwerken van media voor uploaden is mislukt. Probeer het opnieuw." + "Het uploaden van media is mislukt. Probeer het opnieuw." + "Gebruiker blokkeren" + "Vink aan als je alle huidige en toekomstige berichten van deze gebruiker wilt verbergen" + "Dit bericht wordt gerapporteerd aan de beheerder van je homeserver. Ze zullen geen versleutelde berichten kunnen lezen." + "Reden voor het melden van deze inhoud" + "Camera" + "Foto maken" + "Video opnemen" + "Bijlage" + "Foto & Video Bibliotheek" + "Locatie" + "Peiling" + "Tekstopmaak" + "Berichtgeschiedenis is momenteel niet beschikbaar." + "Berichtgeschiedenis is niet beschikbaar in deze kamer. Verifieer dit apparaat om je berichtgeschiedenis te bekijken." + "Wil je ze terug uitnodigen?" + "Je bent alleen in deze chat" + "Stuur een melding naar de hele kamer" + "Iedereen" + "Opnieuw verzenden" + "Je bericht is niet verzonden" + "Emoji toevoegen" + "Dit is het begin van %1$s." + "Dit is het begin van dit gesprek." + "Toon minder" + "Bericht gekopieerd" + "Je hebt geen toestemming om berichten in deze kamer te plaatsen" + "Toon minder" + "Meer tonen" + "Nieuw" + + "%1$d kamerverandering" + "%1$d kamerveranderingen" + + + "%1$s, %2$s en %3$d andere" + "%1$s, %2$s en %3$d anderen" + + + "%1$s is aan het typen" + "%1$s zijn aan het typen" + + "%1$s en %2$s" + diff --git a/features/messages/impl/src/main/res/values-pl/translations.xml b/features/messages/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..bd9f649 --- /dev/null +++ b/features/messages/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,84 @@ + + + "Aktywności" + "Flagi" + "Jedzenie i napoje" + "Zwierzęta i natura" + "Obiekty" + "Buźki i osoby" + "Podróż i miejsca" + "Ostatnie emoji" + "Symbole" + "Opis może być niedostępny dla osób korzystających ze starszej wersji aplikacji." + "Dotknij, aby zmienić jakość przesyłania wideo." + "Nie udało się przesłać pliku." + "Przetwarzanie multimediów do przesłania nie powiodło się, spróbuj ponownie." + "Przesyłanie multimediów nie powiodło się, spróbuj ponownie." + "Maksymalny dozwolony rozmiar pliku to %1$s." + "Plik jest za duży, aby go przesłać." + "Pozycja %1$d z %2$d" + "Zoptymalizuj jakość obrazu" + "Przetwarzanie…" + "Zablokuj użytkownika" + "Sprawdź, czy chcesz ukryć wszystkie bieżące i przyszłe wiadomości od tego użytkownika." + "Ta wiadomość zostanie zgłoszona do administratora Twojego serwera domowego. Nie będzie mógł on przeczytać żadnych zaszyfrowanych wiadomości." + "Powód zgłoszenia treści" + "Kamera" + "Zrób zdjęcie" + "Nagraj film" + "Załącznik" + "Zdjęcia i filmy" + "Lokalizacja" + "Ankieta" + "Formatowanie tekstu" + "Historia wiadomości jest obecnie niedostępna." + "Historia wiadomości jest niedostępna w tym pokoju. Zweryfikuj to urządzenie, aby zobaczyć historię wiadomości." + "Czy chcesz zaprosić ich z powrotem?" + "Jesteś sam na tym czacie" + "Powiadom cały pokój" + "Wszyscy" + "Wyślij ponownie" + "Nie udało się wysłać wiadomości" + "Dodaj emoji" + "To jest początek %1$s" + "To jest początek tej konwersacji" + "Nieobsługiwane połączenie. Zapytaj, czy dzwoniący może użyć nowej aplikacji Element X." + "Pokaż mniej" + "Skopiowano wiadomość" + "Nie masz uprawnień, aby pisać w tym pokoju" + + "%1$d członek zareagował z %2$s" + "%1$d członków zareagowało z %2$s" + "%1$d członków zareagowało z %2$s" + + + "Ty i %1$d członek zareagowaliście z %2$s" + "Ty i %1$d członków zareagowaliście z %2$s" + "Ty i %1$d członków zareagowaliście z %2$s" + + "Zareagowałeś z %1$s" + "Pokaż mniej" + "Pokaż więcej" + "Pokaż podsumowanie reakcji" + "Nowe" + + "%1$d zmiana pokoju" + "%1$d zmiany pokoju" + "%1$d zmian pokoju" + + "Przejdź do nowego pokoju" + "Ten pokój został zmieniony i nie jest już aktywny" + "Zobacz stare wiadomości" + "Ten pokój jest kontynuacją innego pokoju" + + "%1$s, %2$s i %3$d inny" + "%1$s, %2$s i %3$d innych" + "%1$s, %2$s i %3$d innych" + + + "%1$s pisze" + "%1$s piszą" + "%1$s piszą" + + "%1$s i %2$s" + diff --git a/features/messages/impl/src/main/res/values-pt-rBR/translations.xml b/features/messages/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..6098fd0 --- /dev/null +++ b/features/messages/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,79 @@ + + + "Atividades" + "Bandeiras" + "Comida & Bebida" + "Animais & Natureza" + "Objetos" + "Sorrisos & Pessoas" + "Viagens & Lugares" + "Emojis recentes" + "Símbolos" + "As legendas podem não ser visíveis para pessoas que usam apps mais antigos." + "Toque para alterar a qualidade do envio do vídeo" + "O arquivo não pôde ser enviado." + "Falha ao processar a mídia para o envio. Tente novamente." + "Falha ao enviar mídia. Tente novamente." + "O tamanho de arquivo máximo permitido é %1$s." + "O arquivo é muito grande para enviar" + "%1$d de %2$d itens" + "Otimizar qualidade da imagem" + "Processando…" + "Bloquear usuário" + "Marque se você deseja ocultar todas as mensagens atuais e futuras desse usuário" + "Essa mensagem será reportada ao administrador do seu servidor-casa. Eles não conseguirão ler nenhuma mensagem criptografada." + "Motivo por denunciar este conteúdo" + "Câmera" + "Tirar foto" + "Gravar vídeo" + "Anexo" + "Biblioteca de fotos e vídeos" + "Localização" + "Enquete" + "Formatação de texto" + "O histórico de mensagens não está disponível no momento." + "O histórico de mensagens não está disponível nesta sala. Verifique este dispositivo para ver seu histórico de mensagens." + "Gostaria de convidá-los de volta?" + "Você está sozinho nesta conversa" + "Notificar a sala inteira" + "Todos" + "Enviar novamente" + "Sua mensagem não foi enviada" + "Adicionar emoji" + "Este é o início de %1$s." + "Este é o início desta conversa." + "Chamada não suportada. Pergunte se o remetente pode usar o novo aplicativo Element X." + "Mostrar menos" + "Mensagem copiada" + "Você não tem permissão para postar nesta sala" + + "%1$d membro reagiu com %2$s" + "%1$d membros reagiram com %2$s" + + + "Você e mais %1$d membro reagiram com %2$s" + "Você e mais %1$d membros reagiram com %2$s" + + "Você reagiu com %1$s" + "Mostrar menos" + "Mostrar mais" + "Mostrar resumo das reações" + "Novo" + + "%1$d alteração na sala" + "%1$d alterações na sala" + + "Ir para a sala nova" + "Esta sala foi substituída e não está mais ativa" + "Ver mensagens antigas" + "Esta sala é a continuação de outra sala" + + "%1$s, %2$s e %3$d outro" + "%1$s, %2$s e %3$d outros" + + + "%1$s está digitando" + "%1$s estão digitando" + + "%1$s e %2$s" + diff --git a/features/messages/impl/src/main/res/values-pt/translations.xml b/features/messages/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..3848f04 --- /dev/null +++ b/features/messages/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,79 @@ + + + "Atividades" + "Bandeiras" + "Comidas e Bebidas" + "Animais e Natureza" + "Objetos" + "Caras e Pessoas" + "Viagens e Lugares" + "Emojis recentes" + "Símbolos" + "As legendas poderão não ser visíveis em versões mais antigas da aplicação." + "Toca para alterar a qualidade de carregamento do vídeo" + "Não foi possível enviar o ficheiro" + "Falha ao processar multimédia para carregamento, por favor tente novamente." + "Falhar ao carregar multimédia, por favor tente novamente." + "O tamanho máximo permitido é %1$s." + "O ficheiro é demasiado grande para enviar" + "Item %1$d de %2$d" + "Optimiza a qualidade da imagem" + "A processar…" + "Bloquear utilizador" + "Ativar para ocultar todas as atuais e futuras mensagens deste utilizador" + "Esta mensagem será denunciada ao administrador do teu servidor. Porém, não lhe será possível ler quaisquer mensagens cifradas." + "Razão de denúncia" + "Câmara" + "Tirar foto" + "Gravar vídeo" + "Anexo" + "Biblioteca de fotos e vídeos" + "Localização" + "Sondagem" + "Formatação de texto" + "De momento, o histórico de mensagens está indisponível." + "O histórico de mensagens não está disponível nesta sala. Verifica este dispositivo para veres o histórico." + "Gostarias de convidá-lo de volta?" + "Estás sozinho nesta conversa" + "Notificar toda a sala" + "Toda a gente" + "Enviar novamente" + "Falha ao enviar a tua mensagem" + "Adicionar emoji" + "%1$s começou aqui." + "Esta conversa começou aqui." + "Chamada não suportada. Pergunta se é possível utilizar a nova aplicação Element X." + "Mostrar menos" + "Mensagem copiada" + "Não tens permissão para publicar nesta sala" + + "%1$d membro reagiu com %2$s" + "%1$d membros reagiram com %2$s" + + + "Tu e %1$d membro reagiram com %2$s" + "Tu e %1$d membros reagiram com %2$s" + + "Reagiste com %1$s" + "Mostrar menos" + "Mostrar mais" + "Mostrar sumário de reações" + "Novas" + + "%1$d alteração à sala" + "%1$d alterações à sala" + + "Ir para a nova sala" + "Esta sala foi substituída e já não se encontra ativa" + "Ver mensagens antigas" + "Esta sala é a continuação de uma outra" + + "%1$s, %2$s e %3$d outro" + "%1$s, %2$s e %3$d outros" + + + "%1$s está a escrever" + "%1$s estão a escrever" + + "%1$s e %2$s" + 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 0000000..a121bc7 --- /dev/null +++ b/features/messages/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,84 @@ + + + "Activități" + "Steaguri" + "Mâncare & Băutură" + "Animale și Natură" + "Obiecte" + "Fețe zâmbitoare & Oameni" + "Călătorii & Locuri" + "Emoticoane recente" + "Simboluri" + "Este posibil ca descrierile să nu fie vizibile pentru persoanele care folosesc aplicații mai vechi." + "Atingeți pentru a modifica calitatea încărcării videoclipului" + "Fișierul nu a putut fi încărcat." + "Procesarea datelor media a eșuat, vă rugăm să încercați din nou." + "Încărcarea fișierelor media a eșuat, încercați din nou." + "Dimensiunea maximă permisă pentru fișiere este de %1$s." + "Fișierul este prea mare pentru a fi încărcat." + "Elementul %1$d din %2$d" + "Optimizați calitatea imaginii" + "Se procesează…" + "Blocați utilizatorul" + "Confirmați că doriți să ascundeți toate mesajele curente și viitoare de la acest utilizator" + "Acest mesaj va fi raportat administratorilor homeserver-ului tau. Ei nu vor putea citi niciun mesaj criptat." + "Motivul raportării acestui conținut" + "Cameră foto" + "Faceți o fotografie" + "Înregistrați un videoclip" + "Atașament" + "Bibliotecă foto și video" + "Locație" + "Sondaj" + "Formatarea textului" + "Mesajele anterioare nu sunt momentan disponibile în această cameră" + "Mesajele anterioare nu sunt disponibile în această cameră. Verificați acest dispozitiv pentru a vedea mesajele anterioare." + "Doriți să îi invitați înapoi?" + "Sunteți singur în această cameră" + "Notificați întreaga cameră" + "Toți" + "Trimiteți din nou" + "Mesajul dvs. nu a putut fi trimis" + "Adăugați emoji" + "Acesta este începutul conversației %1$s." + "Acesta este începutul acestei conversații." + "Apel neacceptat. Întrebați apelantul dacă poate utiliza noua aplicație Element X." + "Afișați mai puțin" + "Mesaj copiat" + "Nu aveți permisiunea de a posta în această cameră" + + "%1$d membru a reacționat cu %2$s" + "%1$d membri au reacționat cu %2$s" + "%1$d membri au reacționat cu %2$s" + + + "Dumneavoastră si %1$d membru ați reacționat cu %2$s" + "Dumneavoastră si %1$d membri ați reacționat cu %2$s" + "Dumneavoastră si %1$d membri ați reacționat cu %2$s" + + "Ați reacționat cu %1$s" + "Afișați mai puțin" + "Afișați mai mult" + "Afișați rezumatul reacțiilor" + "Nou" + + "%1$d schimbare a camerii" + "%1$d schimbări ale camerei" + "%1$d schimbări ale camerei" + + "Săriți la noua cameră" + "Această cameră a fost înlocuită și nu mai este activă." + "Vedeți mesajele vechi" + "Această cameră este o continuare a unei alte camere." + + "%1$s, %2$s și încă %3$d" + "%1$s, %2$s și încă %3$d" + "%1$s, %2$s și încă %3$d" + + + "%1$s tastează" + "%1$s tastează" + "%1$s tastează" + + "%1$s și %2$s" + diff --git a/features/messages/impl/src/main/res/values-ru/translations.xml b/features/messages/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..4656a59 --- /dev/null +++ b/features/messages/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,84 @@ + + + "Деятельность" + "Флаги" + "Еда и напитки" + "Животные и природа" + "Объекты" + "Улыбки и люди" + "Путешествия и места" + "Недавние эмодзи" + "Символы" + "Подпись может быть не видна пользователям старых приложений." + "Нажмите, чтобы изменить качество загружаемого видео." + "Файл не может быть загружен." + "Не удалось обработать медиафайл для загрузки, попробуйте еще раз." + "Не удалось загрузить медиафайлы, попробуйте еще раз." + "Максимальный размер файла: %1$s." + "Файл слишком большой для загрузки." + "Элемент %1$d из %2$d" + "Оптимизировать качество изображения" + "Обработка…" + "Заблокировать пользователя" + "Отметьте, хотите ли вы скрыть все текущие и будущие сообщения от этого пользователя" + "Это сообщение будет передано администратору вашего домашнего сервера. Они не смогут прочитать зашифрованные сообщения." + "Причина, по которой вы пожаловались на этот контент" + "Камера" + "Сделать фото" + "Записать видео" + "Вложение" + "Фото и видео" + "Местоположение" + "Опрос" + "Форматирование текста" + "В настоящее время история сообщений недоступна в этой комнате." + "История сообщений в этой комнате недоступна. Проверьте это устройство, чтобы увидеть историю сообщений." + "Хотите пригласить их снова?" + "Вы одни в этой комнате" + "Уведомить всю комнату" + "Все" + "Отправить снова" + "Не удалось отправить ваше сообщение" + "Добавить эмодзи" + "Это начало %1$s." + "Это начало разговора." + "Неподдерживаемый вызов. уточните, может ли звонящий использовать новое приложение Element X." + "Показать меньше" + "Сообщение скопировано" + "У вас нет разрешения публиковать сообщения в этой комнате" + + "%1$d участник отреагировал %2$s" + "%1$d участника отреагировало %2$s" + "%1$d участников отреагировало %2$s" + + + "Вы и %1$d участник отреагировали %2$s" + "Вы и %1$d участника отреагировали %2$s" + "Вы и %1$d участников отреагировали %2$s" + + "Вы отреагировали %1$s" + "Показать меньше" + "Показать больше" + "Показать сводку реакций" + "Новый" + + "%1$d изменение в комнате" + "%1$d изменения в комнате" + "%1$d изменений в комнате" + + "Перейти в новую комнату" + "Эта комната была заменена и больше не активна" + "Посмотреть старые сообщения" + "Эта комната является продолжением другой комнаты" + + "%1$s, %2$s и %3$d" + "%1$s, %2$s и другие %3$d" + "%1$s, %2$s и другие %3$d" + + + "%1$s набирает сообщение" + "%1$s набирают сообщения" + "%1$s набирают сообщения" + + "%1$s и %2$s" + 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 0000000..bca6ccf --- /dev/null +++ b/features/messages/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,84 @@ + + + "Aktivity" + "Vlajky" + "Jedlo a nápoje" + "Zvieratá a príroda" + "Predmety" + "Smajlíky a ľudia" + "Cestovanie a miesta" + "Nedávne emotikony" + "Symboly" + "Titulky nemusia byť viditeľné pre ľudí používajúcich staršie aplikácie." + "Ťuknutím zmeníte kvalitu nahratého videa" + "Súbor sa nepodarilo nahrať." + "Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova." + "Nepodarilo sa nahrať médiá, skúste to prosím znova." + "Maximálna povolená veľkosť súboru je %1$s." + "Súbor je príliš veľký na nahratie" + "Položka %1$d z %2$d" + "Optimalizovať kvalitu obrázku" + "Prebieha spracovanie…" + "Zablokovať používateľa" + "Označte, či chcete skryť všetky aktuálne a budúce správy od tohto používateľa" + "Táto správa bude nahlásená správcovi vášho domovského servera. Nebude môcť prečítať žiadne šifrované správy." + "Dôvod nahlásenia tohto obsahu" + "Kamera" + "Urobiť fotku" + "Nahrať video" + "Príloha" + "Knižnica fotografií a videí" + "Poloha" + "Anketa" + "Formátovanie textu" + "História správ v tejto miestnosti nie je momentálne k dispozícii" + "História správ nie je v tejto miestnosti k dispozícii. Ak chcete zobraziť históriu správ, overte toto zariadenie." + "Chceli by ste ich pozvať späť?" + "V tomto rozhovore ste sami" + "Informovať celú miestnosť" + "Všetci" + "Odoslať znova" + "Vašu správu sa nepodarilo odoslať" + "Pridať emoji" + "Toto je začiatok %1$s." + "Toto je začiatok tejto konverzácie." + "Nepodporovaný hovor. Opýtajte sa, či volajúci môže použiť novú aplikáciu Element X." + "Zobraziť menej" + "Správa skopírovaná" + "Nemáte povolenie uverejňovať príspevky v tejto miestnosti" + + "%1$d člen reagoval s %2$s" + "%1$d členovia reagovali s %2$s" + "%1$d členov reagovalo s %2$s" + + + "Vy a %1$d člen reagoval s %2$s" + "Vy a %1$d členovia reagovali s %2$s" + "Vy a %1$d členov reagovalo s %2$s" + + "Reagovali ste s %1$s" + "Zobraziť menej" + "Zobraziť viac" + "Zobraziť súhrn reakcií" + "Nové" + + "%1$d zmena miestnosti" + "%1$d zmeny miestnosti" + "%1$d zmien miestnosti" + + "Prejsť do novej miestnosti" + "Táto miestnosť bola nahradená a už nie je aktívna" + "Zobraziť staré správy" + "Táto miestnosť je pokračovaním inej miestnosti" + + "%1$s, %2$s a %3$d ďalší" + "%1$s, %2$s a %3$d ďalší" + "%1$s, %2$s a %3$d ďalší" + + + "%1$s píše" + "%1$s píšu" + "%1$s píšu" + + "%1$s a %2$s" + diff --git a/features/messages/impl/src/main/res/values-sv/translations.xml b/features/messages/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..cd5423b --- /dev/null +++ b/features/messages/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,77 @@ + + + "Aktiviteter" + "Flaggor" + "Mat & dryck" + "Djur & natur" + "Föremål" + "Smileys & personer" + "Resor & platser" + "Symboler" + "Bildtexter kanske inte är synliga för personer som använder äldre appar." + "Tryck för att ändra videouppladdningskvaliteten" + "Filen kunde inte laddas upp." + "Misslyckades att bearbeta media för uppladdning, vänligen pröva igen." + "Misslyckades att ladda upp media, vänligen pröva igen." + "Den maximala tillåtna filstorleken är %1$s." + "Filen är för stor för att laddas upp" + "Optimera bildkvalitet" + "Bearbetar …" + "Blockera användare" + "Markera om du vill dölja alla nuvarande och framtida meddelanden från denna användare" + "Det här meddelandet kommer att rapporteras till din hemservers administratör. Denne kommer inte att kunna läsa några krypterade meddelanden." + "Anledning till att rapportera detta innehåll" + "Kamera" + "Ta ett foto" + "Spela in video" + "Bilaga" + "Foto- och videobibliotek" + "Plats" + "Omröstning" + "Textformatering" + "Meddelandehistoriken är för närvarande otillgänglig." + "Meddelandehistorik är inte tillgänglig i det här rummet. Verifiera den här enheten för att se din meddelandehistorik." + "Vill du bjuda tillbaka dem?" + "Du är ensam i den här chatten" + "Meddela hela rummet" + "Alla" + "Skicka igen" + "Ditt meddelande kunde inte skickas" + "Lägg till emoji" + "Det här är början på %1$s." + "Detta är början på det här samtalet." + "Samtalet stöds inte. Fråga om den som ringer kan använda den nya Element X-appen." + "Visa mindre" + "Meddelande kopierat" + "Du är inte behörig att göra inlägg i det här rummet" + + "%1$d medlem reagerade med %2$s" + "%1$d medlemmar reagerade med %2$s" + + + "Du och %1$d medlem reagerade med %2$s" + "Du och %1$d medlemmarna reagerade med %2$s" + + "Du reagerade med %1$s" + "Visa mindre" + "Visa mer" + "Visa sammanfattning av reaktioner" + "Nytt" + + "%1$d rumsändring" + "%1$d rumsändringar" + + "Hoppa till nytt rum" + "Det här rummet har ersatts och är inte längre aktivt" + "Se gamla meddelanden" + "Det här rummet är en fortsättning på ett annat rum" + + "%1$s, %2$s och %3$d annan" + "%1$s, %2$s och %3$d andra" + + + "%1$s skriver" + "%1$s skriver" + + "%1$s och %2$s" + diff --git a/features/messages/impl/src/main/res/values-tr/translations.xml b/features/messages/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..fbd425e --- /dev/null +++ b/features/messages/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,57 @@ + + + "Aktiviteler" + "Bayraklar" + "Yiyecek ve İçecek" + "Hayvanlar ve Doğa" + "Nesneler" + "İfadeler ve İnsanlar" + "Seyahat ve Yerler" + "Simgeler" + "Açıklamalar, eski uygulamaları kullanan kişiler tarafından görülemeyebilir." + "Medya yüklenemedi, lütfen tekrar deneyin." + "Medya yüklenemedi, lütfen tekrar deneyin." + "Kullanıcıyı engelle" + "Bu kullanıcıdan gelen mevcut ve gelecekteki tüm mesajları gizlemek isteyip istemediğinizi işaretleyin" + "Bu mesaj ana sunucunuzun yöneticisine bildirilecektir. Şifrelenmiş mesajları okuyamayacaklardır." + "Bu içeriğin bildirilme nedeni" + "Kamera" + "Fotoğraf çek" + "Video kaydet" + "Ek" + "Fotoğraf ve Video Kütüphanesi" + "Konum" + "Anket" + "Metin Biçimlendirme" + "Mesaj geçmişi şu anda kullanılamıyor." + "Mesaj geçmişi bu odada kullanılamıyor. Mesaj geçmişinizi görmek için bu cihazı doğrulayın." + "Onları geri davet etmek ister misin?" + "Bu sohbette yalnızsın" + "Tüm odaya bildir" + "Herkes" + "Tekrar gönder" + "Mesajınız gönderilemedi" + "Emoji ekle" + "Bu, %1$s odasının başlangıcıdır." + "Bu konuşmanın başlangıcıdır." + "Desteklenmeyen çağrı. Arayanın yeni Element X uygulamasını kullanıp kullanamayacağını sorun." + "Daha az göster" + "Mesaj kopyalandı" + "Bu odada gönderi yapma izniniz yok" + "Daha az göster" + "Daha fazla göster" + "Yeni" + + "%1$d oda değişikliği" + "%1$d oda değişikliği" + + + "%1$s, %2$s ve %3$d diğer" + "%1$s, %2$s ve %3$d diğer" + + + "%1$s yazıyor" + "%1$s yazıyor" + + "%1$s ve %2$s" + diff --git a/features/messages/impl/src/main/res/values-uk/translations.xml b/features/messages/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..90ef76e --- /dev/null +++ b/features/messages/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,82 @@ + + + "Дії" + "Прапори" + "Їжа та напої" + "Тварини та природа" + "Об\'єкти" + "Смайлики та люди" + "Подорожі та місця" + "Символи" + "Користувачі старих застосунків можуть не бачити підписи." + "Натисніть, щоб змінити якість вивантажуваного відео" + "Файл не може бути вивантажено." + "Не вдалося обробити медіафайл для завантаження, спробуйте ще раз." + "Не вдалося завантажити медіафайл, спробуйте ще раз." + "Максимально дозволений розмір файлу — %1$s." + "Файл завеликий для вивантаження" + "Оптимізувати якість зображення" + "Обробка…" + "Заблокувати користувача" + "Перевірте, чи хочете ви приховати всі поточні та майбутні повідомлення від цього користувача" + "Це повідомлення буде надіслано адміністраторам вашого домашнього сервера. Вони не зможуть прочитати зашифровані повідомлення." + "Причина скарги на цей вміст" + "Камера" + "Зробити фото" + "Записати відео" + "Вкладення" + "Бібліотека фото та відео" + "Розташування" + "Опитування" + "Форматування тексту" + "Історія повідомлень наразі недоступна." + "Історія повідомлень недоступна в цій кімнаті. Перевірте цей пристрій, щоб побачити історію повідомлень." + "Чи хотіли б ви запросити їх знову?" + "Ви одні в цій бесіді" + "Сповістити всю кімнату" + "Усі" + "Надіслати знову" + "Не вдалося надіслати ваше повідомлення" + "Додати смайлики" + "Це початок %1$s" + "Це початок цієї розмови." + "Непідтримуваний виклик. Запитайте, чи може абонент використовувати новий застосунок Element X." + "Показувати менше" + "Повідомлення скопійовано" + "У вас немає дозволу на публікацію в цій кімнаті" + + "%1$d учасник відреагував з %2$s" + "%1$d учасники відреагували з %2$s" + "%1$d учасників відреагували з %2$s" + + + "Ви та ще %1$d учасник відреагували з %2$s" + "Ви та ще %1$d учасники відреагували з %2$s" + "Ви та ще %1$d учасників відреагували з %2$s" + + "Ви відреагували за допомогою %1$s" + "Показувати менше" + "Показати більше" + "Показати підсумок реакцій" + "Нове" + + "%1$d зміна в кімнаті" + "%1$d зміни в кімнаті" + "%1$d змін у кімнаті" + + "Перейти до нової кімнати" + "Цю кімнату замінили, і вона більше не активна." + "Перегляд давніших повідомлень" + "Ця кімната є продовженням іншої кімнати" + + "%1$s%2$s та %3$d інший" + "%1$s%2$s та %3$d інші" + "%1$s%2$s та %3$d інших" + + + "%1$s пише" + "%1$s пишуть" + "%1$s пишуть" + + "%1$s та %2$s" + diff --git a/features/messages/impl/src/main/res/values-ur/translations.xml b/features/messages/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..3b743a6 --- /dev/null +++ b/features/messages/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,55 @@ + + + "سرگرمیاں" + "جھنڈے" + "طعم و مشروب" + "حیوانات و فطرت" + "اشیاء" + "مسکراہٹیں و لوگ" + "سفر و مقامات" + "علامتیں" + "وسائط کا معالجہ برائے ترفیع ناکام، برائے مہربانی دوبارہ کوشش کریں۔" + "وسائط رفع کرنے میں ناکام، برائے مہربانی دوبارہ کوشش کریں۔" + "صارف کو مسدود کریں" + "پڑتال کریں کہ کیا آپ اس صارف سے تمام موجودہ اور مستقبلی پیغامات چھپانا چاہتے ہیں۔" + "اس پیغام کی اطلاع آپکے منزلی خادم کے منتظم کو دی جائیگی۔ وہ کوئی مرموزکردہ پیغامات نہیں پڑھ سکیں گے۔" + "اس مواد کی اطلاع دینے کی وجہ" + "تصویرگر" + "تصویر لیں" + "ویڈیو ثبت کریں" + "منسلکہ" + "تصویر اور ویڈیو مکتب" + "مقام" + "رائے شماری" + "متن کی تنسیق" + "پیغام کی سرگزشت فی الحال دستیاب نہیں ہے" + "اس کمرے میں پیغام کی سرگزشت دستیاب نہیں ہے۔ اپنے پیغام کی سرگزشت دیکھنے کے لیے اس آلے کی توثیق کریں۔" + "کیا آپ انہیں واپس مدعو کرنا چاہیں گے؟" + "آپ اس گفتگو میں تنہا ہیں" + "پورے کمرے کو مطلع کریں" + "ہر کوئی" + "دوبارہ بھیجیں" + "آپ کا پیغام بھیجنے میں ناکام" + "رمزِ تعبیری شامل کریں" + "یہ %1$s کا آغاز ہے" + "یہ اس گفتگو کا آغاز ہے" + "کم دکھائیں" + "پیغام نقل کردہ" + "آپ کو اس کمرے میں پوسٹ کرنے کی اجازت نہیں ہے" + "کم دکھائیں" + "مزید دکھائیں" + "نیا" + + "%1$d کمرے کی تبدیلی" + "%1$d کمرے کی تبدیلیاں" + + + "%1$s، %2$s اور %3$d دیگر" + "%1$s، %2$s اور %3$d دیگر" + + + "%1$s تحریر کر رہا ہے" + "%1$s تحریر کر رہے ہیں" + + "%1$s اور %2$s" + diff --git a/features/messages/impl/src/main/res/values-uz/translations.xml b/features/messages/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..8f75212 --- /dev/null +++ b/features/messages/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,77 @@ + + + "Faoliyatlar" + "Bayroqlar" + "Oziq-ovqat va ichimliklar" + "Hayvonlar va tabiat" + "Ob\'ektlar" + "Smayllar va odamlar" + "Sayohat va Joylar" + "Belgilar" + "Taglavhalar eski ilovalardan foydalanuvchilarga ko‘rinmasligi mumkin." + "Video yuklash sifatini oʻzgartirish uchun bosing" + "Faylni yuklab boʻlmadi." + "Mediani yuklab bo‘lmadi, qayta urinib ko‘ring." + "Media yuklanmadi, qayta urinib ko‘ring." + "Ruxsat etilgan maksimal fayl hajmi %1$s ." + "Fayl yuklash uchun juda katta" + "Tasvir sifatini optimallashtirish" + "Qayta ishlanmoqda…" + "Foydalanuvchini bloklash" + "Ushbu foydalanuvchidan barcha joriy va kelajakdagi xabarlarni yashirishni xohlayotganingizni tekshiring" + "Bu xabar uy serveringiz administratoriga xabar qilinadi. Ular hech qanday shifrlangan xabarlarni o\'qiy olmaydi." + "Ushbu kontent haqida xabar berish sababi" + "Kamera" + "Rasmga olmoq" + "Video yozib olish" + "Biriktirma" + "Fotosurat va video kutubxonasi" + "Joylashuv" + "So\'ro\'vnoma" + "Matnni formatlash" + "Xabarlar tarixi hozirda mavjud emas." + "Xabar tarixi ushbu xonada mavjud emas. Xabar tarixini koʻrish uchun ushbu qurilmani tasdiqlang." + "Ularni yana taklif qilmoqchimisiz?" + "Siz bu chatda yolg\'izsiz" + "Butun xonani xabardor qiling" + "Har kim" + "Yana yuboring" + "Xabaringiz yuborilmadi" + "Emoji qo\'shmoq" + "Bu %1$sni boshlanishi" + "Bu suhbatning boshlanishi." + "Chaqiruv qabul qilinmaydi. Chaqiruvchidan yangi Element X ilovasidan foydalanishi mumkinligini so‘rang." + "Kamroq ko\'rsatish" + "Xabar nusxalandi" + "Sizda bu xonaga post yozishga ruxsat yo‘q" + + "%1$d ta a’zo %2$s bilan munosabat bildirdi" + "%1$d ta a’zo %2$s bilan munosabat bildirdi" + + + "Siz va %1$d ta a’zo %2$s bilan munosabat bildirdi" + "Siz va %1$d ta a’zo %2$s bilan munosabat bildirdi" + + "%1$s bilan munosabat bildirdingiz" + "Kamroq ko\'rsatish" + "Ko\'proq ko\'rsatish" + "Reaksiyalar xulosasini chiqarish" + "Yangi" + + "%1$dxonani almashtirish" + "%1$dxona o\'zgarishi" + + "Yangi xonaga o‘tish" + "Bu room almashtirildi va endi faol emas" + "Eski xabarlarni ko‘rish" + "Bu xona boshqa xonaning davomi" + + "%1$s, %2$s va %3$d boshqalar" + "%1$s, %2$s va %3$d boshqalar" + + + "%s yozmoqda…" + "%s yozmoqda…" + + "%1$s va %2$s" + diff --git a/features/messages/impl/src/main/res/values-zh-rTW/translations.xml b/features/messages/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..eb6ea81 --- /dev/null +++ b/features/messages/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,74 @@ + + + "活動" + "旗幟" + "食物與飲料" + "動物與大自然" + "物品" + "表情與人物" + "旅行與景點" + "最近使用的表情符號" + "標誌" + "使用舊應用程式的使用者可能看不到標題。" + "輕點即可變更影片上傳品質" + "無法上傳檔案。" + "無法處理要上傳的媒體,請再試一次。" + "無法上傳媒體檔案,請稍後再試。" + "允許的最大檔案大小為 %1$s。" + "檔案太大,無法上傳" + "第 %1$d 個項目,共 %2$d 個" + "最佳化影像品質" + "正在處理……" + "封鎖使用者" + "檢查您是否要隱藏所有來自此使用者的目前及未來的訊息" + "此訊息將會回報給您的家伺服器管理員。他們將無法讀取任何已加密的訊息。" + "檢舉這個內容的原因" + "照相機" + "拍照" + "錄影" + "附件" + "照片與影片庫" + "位置" + "投票" + "格式化文字" + "目前無法檢視訊息歷史紀錄。" + "無法檢視此聊天室的訊息歷史紀錄。驗證此裝置以檢視您的訊息紀錄。" + "您想要邀請他們回來嗎?" + "此聊天室只有您一個人" + "通知整個聊天室" + "所有人" + "重傳" + "無法傳送您的訊息" + "新增表情符號" + "這是 %1$s 的開頭。" + "這是此對話的開頭。" + "不支援的通話。詢問通話者是否可以使用新的 Element X 應用程式。" + "較少" + "訊息已複製" + "您沒有權限在此聊天室傳送訊息" + + "%1$d 個成員有 %2$s 個反應" + + + "您與 %1$d 個成員有 %2$s 的反應" + + "您反應了 %1$s" + "較少" + "更多" + "顯示反應摘要" + "新訊息" + + "%1$d 個聊天室變更" + + "跳到新聊天室" + "此聊天室已被取代,不再活躍" + "檢視舊訊息" + "此聊天室為另一個聊天是的延續" + + "%1$s、%2$s 和其他 %3$d 個人" + + + "%1$s 正在打字" + + "%1$s 和 %2$s" + diff --git a/features/messages/impl/src/main/res/values-zh/translations.xml b/features/messages/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..8d43d73 --- /dev/null +++ b/features/messages/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,74 @@ + + + "活动" + "旗帜" + "食物和饮料" + "动物和自然" + "物品" + "表情和人物" + "旅行和地点" + "最近的 Emoji" + "符号" + "使用旧版应用程序的用户可能无法看到字幕。" + "点按以更改视频上传质量" + "无法上传该文件。" + "处理要上传的媒体失败,请重试。" + "上传媒体失败,请重试。" + "允许的最大文件大小为%1$s 。" + "文件太大,无法上传" + "第%1$d/%2$d项" + "优化图像质量" + "处理中…" + "封禁用户" + "请确认是否要隐藏该用户当前和未来的所有信息" + "此消息将举报给您的服务器管理员。他们无法读取任何加密消息。" + "举报此内容的原因" + "相机" + "拍摄照片" + "录制视频" + "附件" + "照片和视频库" + "位置" + "投票" + "文本格式化" + "消息历史记录当前不可用。" + "此聊天室无法查看消息历史记录。请验证此设备以查看之。" + "您想邀请他们回来吗?" + "聊天中只有你一个人" + "通知整个聊天室" + "所有人" + "再次发送" + "消息发送失败" + "添加表情符号" + "这是 %1$s 聊天室的开始。" + "这是本对话的开始。" + "不支持的呼叫。询问呼叫者是否可以使用新的 Element X 应用程序。" + "折叠" + "消息已复制" + "您无权在此聊天室发言" + + "%1$d 个成员添加表情符号 %2$s" + + + "您与 %1$d 个成员添加表情符号 %2$s" + + "您添加了表情符号%1$s" + "折叠" + "展开" + "显示反应摘要" + "新消息" + + "%1$d 个聊天室变化" + + "跳转至新房间" + "本房间已被替换,现已失效" + "查看历史消息" + "该聊天室是其他聊天室的延续" + + "%1$s,%2$s 和其他 %3$d 个人" + + + "%1$s 正在输入" + + "%1$s 和 %2$s" + 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 0000000..7b0827d --- /dev/null +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -0,0 +1,79 @@ + + + "Activities" + "Flags" + "Food & Drink" + "Animals & Nature" + "Objects" + "Smileys & People" + "Travel & Places" + "Recent emojis" + "Symbols" + "Captions might not be visible to people using older apps." + "Tap to change the video upload quality" + "The file could not be uploaded." + "Failed processing media to upload, please try again." + "Failed uploading media, please try again." + "The maximum file size allowed is %1$s." + "The file is too large to upload" + "Item %1$d of %2$d" + "Optimise image quality" + "Processing…" + "Block user" + "Check if you want to hide all current and future messages from this user" + "This message will be reported to your homeserver’s administrator. They will not be able to read any encrypted messages." + "Reason for reporting this content" + "Camera" + "Take photo" + "Record video" + "Attachment" + "Photo & Video Library" + "Location" + "Poll" + "Text Formatting" + "Message history is currently unavailable." + "Message history is unavailable in this room. Verify this device to see your message history." + "Would you like to invite them back?" + "You are alone in this chat" + "Notify the whole room" + "Everyone" + "Send again" + "Your message failed to send" + "Add a reaction" + "This is the beginning of %1$s." + "This is the beginning of this conversation." + "Unsupported call. Ask if the caller can use the new Element X app." + "Show less" + "Message copied" + "You do not have permission to post to this room" + + "%1$d member reacted with %2$s" + "%1$d members reacted with %2$s" + + + "You and %1$d member reacted with %2$s" + "You and %1$d members reacted with %2$s" + + "You reacted with %1$s" + "Show less" + "Show more" + "Show reactions summary" + "New" + + "%1$d room change" + "%1$d room changes" + + "Jump to new room" + "This room has been replaced and is no longer active" + "See old messages" + "This room is a continuation of another room" + + "%1$s, %2$s and %3$d other" + "%1$s, %2$s and %3$d others" + + + "%1$s is typing" + "%1$s are typing" + + "%1$s and %2$s" + diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt new file mode 100644 index 0000000..90c31b9 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.compose.runtime.Composable +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.features.call.test.FakeElementCallEntryPoint +import io.element.android.features.forward.test.FakeForwardEntryPoint +import io.element.android.features.knockrequests.test.FakeKnockRequestsListEntryPoint +import io.element.android.features.location.test.FakeLocationService +import io.element.android.features.location.test.FakeSendLocationEntryPoint +import io.element.android.features.location.test.FakeShowLocationEntryPoint +import io.element.android.features.messages.api.MessagesEntryPoint +import io.element.android.features.messages.impl.pinned.banner.createPinnedEventsTimelineProvider +import io.element.android.features.messages.impl.timeline.createTimelineController +import io.element.android.features.poll.test.create.FakeCreatePollEntryPoint +import io.element.android.libraries.dateformatter.test.FakeDateFormatter +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.permalink.PermalinkData +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache +import io.element.android.libraries.matrix.ui.messages.RoomNamesCache +import io.element.android.libraries.mediaviewer.test.FakeMediaViewerEntryPoint +import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme +import io.element.android.libraries.textcomposer.mentions.MentionSpanUpdater +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DefaultMessagesEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() = runTest { + val entryPoint = DefaultMessagesEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + MessagesFlowNode( + buildContext = buildContext, + plugins = plugins, + roomListService = FakeRoomListService(), + sessionId = A_SESSION_ID, + sendLocationEntryPoint = FakeSendLocationEntryPoint(), + showLocationEntryPoint = FakeShowLocationEntryPoint(), + createPollEntryPoint = FakeCreatePollEntryPoint(), + elementCallEntryPoint = FakeElementCallEntryPoint(), + mediaViewerEntryPoint = FakeMediaViewerEntryPoint(), + forwardEntryPoint = FakeForwardEntryPoint(), + analyticsService = FakeAnalyticsService(), + locationService = FakeLocationService(), + room = FakeBaseRoom(), + roomMemberProfilesCache = RoomMemberProfilesCache(), + roomNamesCache = RoomNamesCache(), + mentionSpanUpdater = object : MentionSpanUpdater { + override fun updateMentionSpans(text: CharSequence) = text + + @Composable + override fun rememberMentionSpans(text: CharSequence) = text + }, + mentionSpanTheme = MentionSpanTheme(A_USER_ID), + pinnedEventsTimelineProvider = createPinnedEventsTimelineProvider(), + timelineController = createTimelineController(), + knockRequestsListEntryPoint = FakeKnockRequestsListEntryPoint(), + dateFormatter = FakeDateFormatter(), + coroutineDispatchers = testCoroutineDispatchers(), + ) + } + val callback = object : MessagesEntryPoint.Callback { + override fun navigateToRoomDetails() = lambdaError() + override fun navigateToRoomMemberDetails(userId: UserId) = lambdaError() + override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError() + override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError() + override fun navigateToRoom(roomId: RoomId) = lambdaError() + } + val initialTarget = MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID) + val params = MessagesEntryPoint.Params(initialTarget) + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + params = params, + callback = callback, + ) + assertThat(result).isInstanceOf(MessagesFlowNode::class.java) + assertThat(result.plugins).contains(MessagesEntryPoint.Params(initialTarget)) + assertThat(result.plugins).contains(callback) + } + + @Test + fun `test initial target to nav target mapping`() { + assertThat(MessagesEntryPoint.InitialTarget.Messages(focusedEventId = AN_EVENT_ID).toNavTarget()) + .isEqualTo(MessagesFlowNode.NavTarget.Messages(focusedEventId = AN_EVENT_ID)) + assertThat(MessagesEntryPoint.InitialTarget.PinnedMessages.toNavTarget()) + .isEqualTo(MessagesFlowNode.NavTarget.PinnedMessagesList) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt new file mode 100644 index 0000000..68d2cd8 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl + +import io.element.android.features.messages.impl.attachments.Attachment +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.ThreadId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.collections.immutable.ImmutableList + +class FakeMessagesNavigator( + private val onShowEventDebugInfoClickLambda: (eventId: EventId?, debugInfo: TimelineItemDebugInfo) -> Unit = { _, _ -> lambdaError() }, + private val onForwardEventClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() }, + private val onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() }, + private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() }, + private val onPreviewAttachmentLambda: (attachments: ImmutableList, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() }, + private val onNavigateToRoomLambda: (roomId: RoomId, threadId: EventId?, serverNames: List) -> Unit = { _, _, _ -> lambdaError() }, + private val onOpenThreadLambda: (threadRootId: ThreadId, focusedEventId: EventId?) -> Unit = { _, _ -> lambdaError() }, + private val closeLambda: () -> Unit = { lambdaError() }, +) : MessagesNavigator { + override fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { + onShowEventDebugInfoClickLambda(eventId, debugInfo) + } + + override fun forwardEvent(eventId: EventId) { + onForwardEventClickLambda(eventId) + } + + override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { + onReportContentClickLambda(eventId, senderId) + } + + override fun navigateToEditPoll(eventId: EventId) { + onEditPollClickLambda(eventId) + } + + override fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) { + onPreviewAttachmentLambda(attachments, inReplyToEventId) + } + + override fun navigateToRoom(roomId: RoomId, eventId: EventId?, serverNames: List) { + onNavigateToRoomLambda(roomId, eventId, serverNames) + } + + override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { + onOpenThreadLambda(threadRootId, focusedEventId) + } + + override fun close() { + closeLambda() + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt new file mode 100644 index 0000000..852e250 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -0,0 +1,1381 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.messages.impl + +import androidx.lifecycle.Lifecycle +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.PinUnpinAction +import io.element.android.features.messages.impl.actionlist.ActionListEvents +import io.element.android.features.messages.impl.actionlist.ActionListState +import io.element.android.features.messages.impl.actionlist.anActionListState +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState +import io.element.android.features.messages.impl.fixtures.aMessageEvent +import io.element.android.features.messages.impl.link.aLinkState +import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvent +import io.element.android.features.messages.impl.messagecomposer.MessageComposerState +import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState +import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState +import io.element.android.features.messages.impl.timeline.FakeMarkAsFullyRead +import io.element.android.features.messages.impl.timeline.MarkAsFullyRead +import io.element.android.features.messages.impl.timeline.TimelineController +import io.element.android.features.messages.impl.timeline.TimelineEvents +import io.element.android.features.messages.impl.timeline.aTimelineState +import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo +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.impl.timeline.model.event.aTimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState +import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider +import io.element.android.features.messages.test.timeline.voicemessages.composer.FakeDefaultVoiceMessageComposerPresenterFactory +import io.element.android.features.roomcall.api.aStandByCallState +import io.element.android.features.roommembermoderation.api.RoomMemberModerationState +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.mimetype.MimeTypes +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.snackbar.SnackbarDispatcher +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.RoomId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.core.toThreadId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +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.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_CAPTION +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.A_THREAD_ID +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.core.aBuildMeta +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.matrix.test.timeline.aTimelineItemDebugInfo +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails +import io.element.android.libraries.recentemojis.api.AddRecentEmoji +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.textcomposer.model.TextEditorState +import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.FakeLifecycleOwner +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.consumeItemsUntilTimeout +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.testCoroutineDispatchers +import io.element.android.tests.testutils.testWithLifecycleOwner +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds + +@Suppress("LargeClass") +class MessagesPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createMessagesPresenter() + presenter.testWithLifecycleOwner { + val initialState = consumeItemsUntilTimeout().last() + assertThat(initialState.roomId).isEqualTo(A_ROOM_ID) + assertThat(initialState.roomName).isEqualTo("") + assertThat(initialState.roomAvatar) + .isEqualTo(AvatarData(id = A_ROOM_ID.value, name = "", url = AN_AVATAR_URL, size = AvatarSize.TimelineRoom)) + assertThat(initialState.userEventPermissions.canSendMessage).isTrue() + assertThat(initialState.userEventPermissions.canRedactOwn).isTrue() + assertThat(initialState.snackbarMessage).isNull() + assertThat(initialState.inviteProgress).isEqualTo(AsyncData.Uninitialized) + assertThat(initialState.showReinvitePrompt).isFalse() + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - check that the room's unread flag is removed`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + markAsReadResult = { lambdaError() } + ), + typingNoticeResult = { Result.success(Unit) }, + ) + val presenter = createMessagesPresenter(joinedRoom = room) + presenter.testWithLifecycleOwner { + runCurrent() + assertThat(room.baseRoom.setUnreadFlagCalls).isEqualTo(listOf(false)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - handle toggling a reaction`() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + val toggleReactionSuccess = lambdaRecorder { _: String, _: EventOrTransactionId -> Result.success(true) } + val toggleReactionFailure = + lambdaRecorder { _: String, _: EventOrTransactionId -> Result.failure(IllegalStateException("Failed to send reaction")) } + val addRecentEmojiResult = lambdaRecorder { _: String -> Result.success(Unit) } + + val timeline = FakeTimeline().apply { + this.toggleReactionLambda = toggleReactionSuccess + } + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ), + liveTimeline = timeline, + typingNoticeResult = { Result.success(Unit) }, + ) + val presenter = createMessagesPresenter( + timeline = timeline, + joinedRoom = room, + addRecentEmoji = AddRecentEmoji { addRecentEmojiResult(it) }, + coroutineDispatchers = coroutineDispatchers, + ) + presenter.testWithLifecycleOwner { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID.toEventOrTransactionId())) + advanceUntilIdle() + assert(toggleReactionSuccess) + .isCalledOnce() + .with(value("👍"), value(AN_EVENT_ID.toEventOrTransactionId())) + // No crashes when sending a reaction failed + timeline.toggleReactionLambda = toggleReactionFailure + initialState.eventSink(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID.toEventOrTransactionId())) + advanceUntilIdle() + assert(toggleReactionFailure) + .isCalledOnce() + .with(value("👍"), value(AN_EVENT_ID.toEventOrTransactionId())) + addRecentEmojiResult.assertions().isCalledOnce().with(value("👍")) + } + } + + @Test + fun `present - handle toggling a reaction twice`() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + var toggle = false + val toggleReactionSuccess = lambdaRecorder { _: String, _: EventOrTransactionId -> + toggle = !toggle + Result.success(toggle) + } + val addRecentEmoji = lambdaRecorder { _: String -> + Result.success(Unit) + } + val timeline = FakeTimeline().apply { + this.toggleReactionLambda = toggleReactionSuccess + } + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ), + liveTimeline = timeline, + typingNoticeResult = { Result.success(Unit) }, + ) + val presenter = createMessagesPresenter( + timeline = timeline, + joinedRoom = room, + addRecentEmoji = AddRecentEmoji { addRecentEmoji(it) }, + coroutineDispatchers = coroutineDispatchers + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID.toEventOrTransactionId())) + initialState.eventSink(MessagesEvents.ToggleReaction("👍", AN_EVENT_ID.toEventOrTransactionId())) + advanceUntilIdle() + assert(toggleReactionSuccess) + .isCalledExactly(2) + .withSequence( + listOf(value("👍"), value(AN_EVENT_ID.toEventOrTransactionId())), + listOf(value("👍"), value(AN_EVENT_ID.toEventOrTransactionId())), + ) + skipItems(1) + addRecentEmoji.assertions().isCalledOnce().with(value("👍")) + } + } + + @Test + fun `present - handle action forward`() = runTest { + val onForwardEventClickLambda = lambdaRecorder { } + val navigator = FakeMessagesNavigator( + onForwardEventClickLambda = onForwardEventClickLambda, + ) + val presenter = createMessagesPresenter(navigator = navigator) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent())) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + onForwardEventClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) + } + } + + @Test + fun `present - handle action copy`() = runTest { + val clipboardHelper = FakeClipboardHelper() + val event = aMessageEvent() + val presenter = createMessagesPresenter(clipboardHelper = clipboardHelper) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.CopyText, event)) + skipItems(2) + assertThat(clipboardHelper.clipboardContents).isEqualTo((event.content as TimelineItemTextContent).body) + } + } + + @Test + fun `present - handle action copy link`() = runTest { + val clipboardHelper = FakeClipboardHelper() + val event = aMessageEvent() + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + eventPermalinkResult = { Result.success("a link") }, + ), + typingNoticeResult = { Result.success(Unit) }, + ) + val presenter = createMessagesPresenter( + clipboardHelper = clipboardHelper, + joinedRoom = room, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.CopyLink, event)) + skipItems(2) + assertThat(clipboardHelper.clipboardContents).isEqualTo("a link") + } + } + + @Test + fun `present - handle action reply`() = runTest { + val composerRecorder = EventsRecorder() + val presenter = createMessagesPresenter( + messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) }, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent())) + awaitItem() + composerRecorder.assertSingle( + MessageComposerEvent.SetMode( + composerMode = MessageComposerMode.Reply( + replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID), + hideImage = false, + ) + ) + ) + } + } + + @Test + fun `present - handle action reply to an event with no id does nothing`() = runTest { + val presenter = createMessagesPresenter() + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null))) + skipItems(1) + } + } + + @Test + fun `present - handle action reply to an image media message`() = runTest { + val composerRecorder = EventsRecorder() + val presenter = createMessagesPresenter( + messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) }, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + val mediaMessage = aMessageEvent( + content = TimelineItemImageContent( + filename = "image.jpg", + fileSize = 4 * 1024 * 1024L, + caption = null, + formattedCaption = null, + isEdited = false, + mediaSource = MediaSource(AN_AVATAR_URL), + thumbnailSource = null, + mimeType = MimeTypes.Jpeg, + blurhash = null, + width = 20, + height = 20, + thumbnailWidth = null, + thumbnailHeight = null, + aspectRatio = 1.0f, + fileExtension = "jpg", + formattedFileSize = "4MB" + ) + ) + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage)) + awaitItem() + composerRecorder.assertSingle( + MessageComposerEvent.SetMode( + composerMode = MessageComposerMode.Reply( + replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID), + hideImage = false, + ) + ) + ) + } + } + + @Test + fun `present - handle action reply to a video media message`() = runTest { + val composerRecorder = EventsRecorder() + val presenter = createMessagesPresenter( + messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) }, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + val mediaMessage = aMessageEvent( + content = TimelineItemVideoContent( + filename = "video.mp4", + fileSize = 50 * 1024 * 1024L, + caption = null, + formattedCaption = null, + isEdited = false, + duration = 10.milliseconds, + mediaSource = MediaSource(AN_AVATAR_URL), + thumbnailSource = MediaSource(AN_AVATAR_URL), + mimeType = MimeTypes.Mp4, + blurHash = null, + width = 20, + height = 20, + thumbnailWidth = 20, + thumbnailHeight = 20, + aspectRatio = 1.0f, + fileExtension = "mp4", + formattedFileSize = "50MB" + ) + ) + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage)) + awaitItem() + composerRecorder.assertSingle( + MessageComposerEvent.SetMode( + composerMode = MessageComposerMode.Reply( + replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID), + hideImage = false, + ) + ) + ) + } + } + + @Test + fun `present - handle action reply to a file media message`() = runTest { + val composerRecorder = EventsRecorder() + val presenter = createMessagesPresenter( + messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) }, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + val mediaMessage = aMessageEvent( + content = TimelineItemFileContent( + filename = "file.pdf", + fileSize = 10 * 1024 * 1024L, + caption = null, + isEdited = false, + formattedCaption = null, + mediaSource = MediaSource(AN_AVATAR_URL), + thumbnailSource = MediaSource(AN_AVATAR_URL), + formattedFileSize = "10 MB", + mimeType = MimeTypes.Pdf, + fileExtension = "pdf", + ) + ) + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, mediaMessage)) + awaitItem() + composerRecorder.assertSingle( + MessageComposerEvent.SetMode( + composerMode = MessageComposerMode.Reply( + replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID), + hideImage = false, + ) + ) + ) + } + } + + @Test + fun `present - handle action edit`() = runTest { + val composerRecorder = EventsRecorder() + val presenter = createMessagesPresenter( + messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) }, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent())) + awaitItem() + composerRecorder.assertSingle( + MessageComposerEvent.SetMode( + composerMode = MessageComposerMode.Edit( + eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(), + content = (aMessageEvent().content as TimelineItemTextContent).body + ) + ) + ) + } + } + + @Test + fun `present - handle action edit poll`() = runTest { + val onEditPollClickLambda = lambdaRecorder { } + val navigator = FakeMessagesNavigator( + onEditPollClickLambda = onEditPollClickLambda + ) + val presenter = createMessagesPresenter(navigator = navigator) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EditPoll, aMessageEvent(content = aTimelineItemPollContent()))) + awaitItem() + onEditPollClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) + } + } + + @Test + fun `present - handle action end poll`() = runTest { + val timelineEventSink = EventsRecorder() + val presenter = createMessagesPresenter(timelineEventSink = timelineEventSink) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EndPoll, aMessageEvent(content = aTimelineItemPollContent()))) + delay(1) + timelineEventSink.assertSingle(TimelineEvents.EndPoll(AN_EVENT_ID)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - handle action redact`() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + + val liveTimeline = FakeTimeline() + val joinedRoom = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ), + liveTimeline = liveTimeline, + typingNoticeResult = { Result.success(Unit) }, + ) + + val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) } + liveTimeline.redactEventLambda = redactEventLambda + val presenter = createMessagesPresenter( + timeline = liveTimeline, + joinedRoom = joinedRoom, + coroutineDispatchers = coroutineDispatchers, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + val messageEvent = aMessageEvent() + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Redact, messageEvent)) + awaitItem() + assert(redactEventLambda) + .isCalledOnce() + .with(value(messageEvent.eventOrTransactionId), value(null)) + } + } + + @Test + fun `present - handle action report content`() = runTest { + val onReportContentClickLambda = lambdaRecorder { _: EventId, _: UserId -> } + val navigator = FakeMessagesNavigator( + onReportContentClickLambda = onReportContentClickLambda + ) + val presenter = createMessagesPresenter(navigator = navigator) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent())) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + onReportContentClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID), value(A_USER_ID)) + } + } + + @Test + fun `present - handle dismiss action`() = runTest { + val presenter = createMessagesPresenter() + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvents.Dismiss) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - handle action show developer info`() = runTest { + val onShowEventDebugInfoClickLambda = lambdaRecorder { _: EventId?, _: TimelineItemDebugInfo -> } + val navigator = FakeMessagesNavigator( + onShowEventDebugInfoClickLambda = onShowEventDebugInfoClickLambda + ) + val presenter = createMessagesPresenter(navigator = navigator) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ViewSource, aMessageEvent())) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + onShowEventDebugInfoClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID), value(aTimelineItemDebugInfo())) + } + } + + @Test + fun `present - shows prompt to reinvite users in DM`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(isDirect = true, joinedMembersCount = 1, activeMembersCount = 1)) + }, + typingNoticeResult = { Result.success(Unit) }, + ) + val presenter = createMessagesPresenter(joinedRoom = room) + presenter.testWithLifecycleOwner { + 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.textEditorState as TextEditorState.Markdown).state.hasFocus = true + // Skip intermediate states + skipItems(2) + val focusedState = awaitItem() + assertThat(focusedState.showReinvitePrompt).isTrue() + // If it's dismissed then we stop showing the alert + initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Cancel)) + skipItems(1) + val dismissedState = awaitItem() + assertThat(dismissedState.showReinvitePrompt).isFalse() + } + } + + @Test + fun `present - doesn't show reinvite prompt in non-direct room`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(isDirect = false, joinedMembersCount = 1, activeMembersCount = 1)) + }, + typingNoticeResult = { Result.success(Unit) }, + ) + val presenter = createMessagesPresenter(joinedRoom = room) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + assertThat(initialState.showReinvitePrompt).isFalse() + (initialState.composerState.textEditorState as TextEditorState.Markdown).state.hasFocus = true + // Skip intermediate events + skipItems(1) + val focusedState = awaitItem() + assertThat(focusedState.showReinvitePrompt).isFalse() + } + } + + @Test + fun `present - doesn't show reinvite prompt if other party is present`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(isDirect = true, joinedMembersCount = 2, activeMembersCount = 2)) + }, + typingNoticeResult = { Result.success(Unit) }, + ) + val presenter = createMessagesPresenter(joinedRoom = room) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + assertThat(initialState.showReinvitePrompt).isFalse() + (initialState.composerState.textEditorState as TextEditorState.Markdown).state.hasFocus = true + // Skip intermediate events + skipItems(1) + val focusedState = awaitItem() + assertThat(focusedState.showReinvitePrompt).isFalse() + } + } + + @Test + fun `present - handle reinviting other user when memberlist is ready`() = runTest { + val inviteUserResult = lambdaRecorder { _: UserId -> Result.success(Unit) } + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ), + typingNoticeResult = { Result.success(Unit) }, + inviteUserResult = inviteUserResult, + ) + room.givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf( + aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN), + aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE), + ) + ) + ) + val presenter = createMessagesPresenter(joinedRoom = room) + presenter.testWithLifecycleOwner { + val initialState = consumeItemsUntilTimeout().last() + initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) + skipItems(1) + val loadingState = awaitItem() + assertThat(loadingState.inviteProgress.isLoading()).isTrue() + val newState = awaitItem() + assertThat(newState.inviteProgress.isSuccess()).isTrue() + inviteUserResult.assertions().isCalledOnce().with(value(A_SESSION_ID_2)) + } + } + + @Test + fun `present - handle reinviting other user when memberlist is error`() = runTest { + val inviteUserResult = lambdaRecorder { _: UserId -> Result.success(Unit) } + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ), + typingNoticeResult = { Result.success(Unit) }, + inviteUserResult = inviteUserResult, + ) + room.givenRoomMembersState( + RoomMembersState.Error( + failure = Throwable(), + prevRoomMembers = persistentListOf( + aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN), + aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE), + ) + ) + ) + val presenter = createMessagesPresenter(joinedRoom = room) + presenter.testWithLifecycleOwner { + val initialState = consumeItemsUntilTimeout().last() + initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) + skipItems(1) + val loadingState = consumeItemsUntilPredicate { state -> + state.inviteProgress.isLoading() + }.last() + assertThat(loadingState.inviteProgress.isLoading()).isTrue() + val newState = awaitItem() + assertThat(newState.inviteProgress.isSuccess()).isTrue() + inviteUserResult.assertions().isCalledOnce().with(value(A_SESSION_ID_2)) + } + } + + @Test + fun `present - handle reinviting other user when memberlist is not ready`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ), + typingNoticeResult = { Result.success(Unit) }, + ) + room.givenRoomMembersState(RoomMembersState.Unknown) + val presenter = createMessagesPresenter(joinedRoom = room) + presenter.testWithLifecycleOwner { + val initialState = consumeItemsUntilTimeout().last() + 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 = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ), + typingNoticeResult = { Result.success(Unit) }, + inviteUserResult = { Result.failure(RuntimeException("Oops!")) }, + ) + room.givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf( + aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN), + aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE), + ) + ) + ) + val presenter = createMessagesPresenter(joinedRoom = room) + presenter.testWithLifecycleOwner { + val initialState = consumeItemsUntilTimeout().last() + initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) + + val loadingState = consumeItemsUntilPredicate { state -> + state.inviteProgress.isLoading() + }.last() + assertThat(loadingState.inviteProgress.isLoading()).isTrue() + + val failureState = consumeItemsUntilPredicate { state -> + state.inviteProgress.isFailure() + }.last() + assertThat(failureState.inviteProgress.isFailure()).isTrue() + } + } + + @Test + fun `present - permission to post`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + canUserSendMessageResult = { _, messageEventType -> + when (messageEventType) { + MessageEventType.RoomMessage -> Result.success(true) + MessageEventType.Reaction -> Result.success(true) + else -> lambdaError() + } + }, + ), + typingNoticeResult = { Result.success(Unit) }, + ) + val presenter = createMessagesPresenter(joinedRoom = room) + presenter.testWithLifecycleOwner { + skipItems(1) + val state = awaitItem() + assertThat(state.userEventPermissions.canSendMessage).isTrue() + } + } + + @Test + fun `present - no permission to post`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + canUserSendMessageResult = { _, messageEventType -> + when (messageEventType) { + MessageEventType.RoomMessage -> Result.success(false) + MessageEventType.Reaction -> Result.success(false) + else -> lambdaError() + } + }, + ), + typingNoticeResult = { Result.success(Unit) }, + ) + val presenter = createMessagesPresenter(joinedRoom = room) + presenter.testWithLifecycleOwner { + // Default value + assertThat(awaitItem().userEventPermissions.canSendMessage).isTrue() + assertThat(awaitItem().userEventPermissions.canSendMessage).isFalse() + } + } + + @Test + fun `present - permission to redact own`() = runTest { + val joinedRoom = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canRedactOwnResult = { Result.success(true) }, + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOtherResult = { Result.success(false) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ), + typingNoticeResult = { Result.success(Unit) }, + ) + val presenter = createMessagesPresenter(joinedRoom = joinedRoom) + presenter.testWithLifecycleOwner { + val initialState = consumeItemsUntilPredicate { it.userEventPermissions.canRedactOwn }.last() + assertThat(initialState.userEventPermissions.canRedactOwn).isTrue() + assertThat(initialState.userEventPermissions.canRedactOther).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - permission to redact other`() = runTest { + val joinedRoom = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canRedactOtherResult = { Result.success(true) }, + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(false) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ), + typingNoticeResult = { Result.success(Unit) }, + ) + val presenter = createMessagesPresenter(joinedRoom = joinedRoom) + presenter.testWithLifecycleOwner { + val initialState = consumeItemsUntilPredicate { it.userEventPermissions.canRedactOther }.last() + assertThat(initialState.userEventPermissions.canRedactOwn).isFalse() + assertThat(initialState.userEventPermissions.canRedactOther).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - handle action reply to a poll`() = runTest { + val composerRecorder = EventsRecorder() + val presenter = createMessagesPresenter( + messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) }, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + val poll = aMessageEvent( + content = aTimelineItemPollContent() + ) + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, poll)) + skipItems(1) + composerRecorder.assertSingle( + MessageComposerEvent.SetMode( + composerMode = MessageComposerMode.Reply( + replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID), + hideImage = false, + ) + ) + ) + } + } + + @Test + fun `present - handle action pin`() = runTest { + val successPinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) } + val failurePinEventLambda = lambdaRecorder { _: EventId -> Result.failure(AN_EXCEPTION) } + val analyticsService = FakeAnalyticsService() + val timeline = FakeTimeline() + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ), + liveTimeline = timeline, + typingNoticeResult = { Result.success(Unit) }, + ) + val presenter = createMessagesPresenter( + timeline = timeline, + joinedRoom = room, + analyticsService = analyticsService, + ) + presenter.testWithLifecycleOwner { + val messageEvent = aMessageEvent( + content = aTimelineItemTextContent() + ) + val initialState = awaitItem() + + timeline.pinEventLambda = successPinEventLambda + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Pin, messageEvent)) + assert(successPinEventLambda).isCalledOnce().with(value(messageEvent.eventId)) + + timeline.pinEventLambda = failurePinEventLambda + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Pin, messageEvent)) + assert(failurePinEventLambda).isCalledOnce().with(value(messageEvent.eventId)) + skipItems(1) + assertThat(awaitItem().snackbarMessage).isNotNull() + assertThat(analyticsService.capturedEvents).containsExactly( + PinUnpinAction(kind = PinUnpinAction.Kind.Pin, from = PinUnpinAction.From.Timeline), + PinUnpinAction(kind = PinUnpinAction.Kind.Pin, from = PinUnpinAction.From.Timeline) + ) + } + } + + @Test + fun `present - handle action unpin`() = runTest { + val successUnpinEventLambda = lambdaRecorder { _: EventId -> Result.success(true) } + val failureUnpinEventLambda = lambdaRecorder { _: EventId -> Result.failure(AN_EXCEPTION) } + val timeline = FakeTimeline() + val analyticsService = FakeAnalyticsService() + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ), + liveTimeline = timeline, + typingNoticeResult = { Result.success(Unit) }, + ) + val presenter = createMessagesPresenter( + timeline = timeline, + joinedRoom = room, + analyticsService = analyticsService + ) + presenter.testWithLifecycleOwner { + val messageEvent = aMessageEvent( + content = aTimelineItemTextContent() + ) + val initialState = awaitItem() + + timeline.unpinEventLambda = successUnpinEventLambda + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Unpin, messageEvent)) + assert(successUnpinEventLambda).isCalledOnce().with(value(messageEvent.eventId)) + + timeline.unpinEventLambda = failureUnpinEventLambda + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Unpin, messageEvent)) + assert(failureUnpinEventLambda).isCalledOnce().with(value(messageEvent.eventId)) + skipItems(1) + assertThat(awaitItem().snackbarMessage).isNotNull() + assertThat(analyticsService.capturedEvents).containsExactly( + PinUnpinAction(kind = PinUnpinAction.Kind.Unpin, from = PinUnpinAction.From.Timeline), + PinUnpinAction(kind = PinUnpinAction.Kind.Unpin, from = PinUnpinAction.From.Timeline) + ) + } + } + + @Test + fun `present - handle action edit caption`() = runTest { + val messageEvent = aMessageEvent( + content = aTimelineItemImageContent( + caption = A_CAPTION, + ) + ) + val composerRecorder = EventsRecorder() + val presenter = createMessagesPresenter( + messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) }, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.EditCaption, messageEvent)) + awaitItem() + composerRecorder.assertSingle( + MessageComposerEvent.SetMode( + composerMode = MessageComposerMode.EditCaption( + eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(), + content = A_CAPTION, + ) + ) + ) + } + } + + @Test + fun `present - handle action add caption`() = runTest { + val composerRecorder = EventsRecorder() + val presenter = createMessagesPresenter( + messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) }, + ) + val messageEvent = aMessageEvent( + content = aTimelineItemImageContent( + caption = null, + ) + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.AddCaption, messageEvent)) + awaitItem() + composerRecorder.assertSingle( + MessageComposerEvent.SetMode( + composerMode = MessageComposerMode.EditCaption( + eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(), + content = "", + ) + ) + ) + } + } + + @Test + fun `present - handle action remove caption`() = runTest { + val messageEvent = aMessageEvent( + content = aTimelineItemImageContent( + caption = A_CAPTION, + ) + ) + val editCaptionLambda = lambdaRecorder { _: EventOrTransactionId, _: String?, _: String? -> Result.success(Unit) } + val timeline = FakeTimeline().apply { + this.editCaptionLambda = editCaptionLambda + } + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ), + liveTimeline = timeline, + typingNoticeResult = { Result.success(Unit) }, + ) + val presenter = createMessagesPresenter( + joinedRoom = room, + timeline = timeline, + ) + presenter.testWithLifecycleOwner { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.RemoveCaption, messageEvent)) + editCaptionLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID.toEventOrTransactionId()), value(null), value(null)) + } + } + + @Test + fun `present - handle action view in timeline, it should have no effect`() = runTest { + val messageEvent = aMessageEvent( + content = aTimelineItemTextContent() + ) + val presenter = createMessagesPresenter() + presenter.testWithLifecycleOwner { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ViewInTimeline, messageEvent)) + // No op! + } + } + + @Test + fun `present - room with successor room includes successor info in state`() = runTest { + val successorRoomId = RoomId("!successor:server.org") + val successorReason = "This room has been moved to a new location" + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + initialRoomInfo = aRoomInfo( + successorRoom = SuccessorRoom( + roomId = successorRoomId, + reason = successorReason + ) + ) + ), + typingNoticeResult = { Result.success(Unit) }, + ) + val presenter = createMessagesPresenter(joinedRoom = room) + presenter.testWithLifecycleOwner { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.successorRoom).isNotNull() + assertThat(initialState.successorRoom?.roomId).isEqualTo(successorRoomId) + assertThat(initialState.successorRoom?.reason).isEqualTo(successorReason) + } + } + + @Test + fun `present - room without successor room has null successor info in state`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + initialRoomInfo = aRoomInfo(successorRoom = null) + ), + typingNoticeResult = { Result.success(Unit) }, + ) + val presenter = createMessagesPresenter(joinedRoom = room) + presenter.testWithLifecycleOwner { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.successorRoom).isNull() + } + } + + @Test + fun `present - when room is encrypted and a DM, the DM user's identity state is fetched onResume`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + sessionId = A_SESSION_ID, + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + initialRoomInfo = aRoomInfo(isDirect = true, isEncrypted = true) + ).apply { + givenRoomMembersState(RoomMembersState.Ready(persistentListOf(aRoomMember(userId = A_SESSION_ID), aRoomMember(userId = A_USER_ID_2)))) + }, + typingNoticeResult = { Result.success(Unit) }, + ) + val encryptionService = FakeEncryptionService(getUserIdentityResult = { Result.success(IdentityState.Verified) }) + + val presenter = createMessagesPresenter(joinedRoom = room, encryptionService = encryptionService) + val lifecycleOwner = FakeLifecycleOwner() + presenter.testWithLifecycleOwner(lifecycleOwner) { + val initialState = awaitItem() + assertThat(initialState.dmUserVerificationState).isNull() + + skipItems(1) + ensureAllEventsConsumed() + + lifecycleOwner.givenState(Lifecycle.State.RESUMED) + assertThat(awaitItem().dmUserVerificationState).isEqualTo(IdentityState.Verified) + } + } + + @Test + fun `present - handle action reply in thread for an event in a thread`() = runTest { + val openThreadLambda = lambdaRecorder { _: ThreadId, _: EventId? -> } + val presenter = createMessagesPresenter( + navigator = FakeMessagesNavigator(onOpenThreadLambda = openThreadLambda), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.Threads.key to true) + ), + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink( + MessagesEvents.HandleAction( + action = TimelineItemAction.ReplyInThread, + event = aMessageEvent(threadInfo = TimelineItemThreadInfo.ThreadResponse(A_THREAD_ID)) + ) + ) + awaitItem() + openThreadLambda.assertions().isCalledOnce().with(value(A_THREAD_ID), value(null)) + } + } + + @Test + fun `present - handle action reply in thread to start a new thread`() = runTest { + val openThreadLambda = lambdaRecorder { _: ThreadId, _: EventId? -> } + val presenter = createMessagesPresenter( + navigator = FakeMessagesNavigator(onOpenThreadLambda = openThreadLambda), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.Threads.key to true) + ), + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink( + MessagesEvents.HandleAction( + action = TimelineItemAction.ReplyInThread, + event = aMessageEvent( + // The event id will be used as the thread id instead + eventId = AN_EVENT_ID, + threadInfo = null, + ) + ) + ) + awaitItem() + openThreadLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID.toThreadId()), value(null)) + } + } + + @Test + fun `present - handle action reply in a thread with threads disabled`() = runTest { + val composerRecorder = EventsRecorder() + val presenter = createMessagesPresenter( + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.Threads.key to false) + ), + messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder) }, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvents.HandleAction(TimelineItemAction.ReplyInThread, aMessageEvent())) + awaitItem() + composerRecorder.assertSingle( + MessageComposerEvent.SetMode( + composerMode = MessageComposerMode.Reply( + replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID), + hideImage = false, + ) + ) + ) + } + } + + @Test + fun `present - handle MarkAsFullyReadAndExit marks the room as fully read and navigates up`() = runTest { + val markAsFullyReadRecorder = lambdaRecorder { _, _ -> } + val markAsFullyReadUseCase = FakeMarkAsFullyRead(markAsFullyReadRecorder) + val closeLambda = lambdaRecorder {} + val navigator = FakeMessagesNavigator(closeLambda = closeLambda) + + val presenter = createMessagesPresenter( + timeline = FakeTimeline(getLatestEventIdResult = { Result.success(AN_EVENT_ID) }), + markAsFullyRead = markAsFullyReadUseCase, + navigator = navigator, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvents.MarkAsFullyReadAndExit) + + runCurrent() + + markAsFullyReadRecorder.assertions().isCalledOnce() + closeLambda.assertions().isCalledOnce() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - handle MarkAsFullyReadAndExit still navigates up if marking as read fails`() = runTest { + val markAsFullyReadUseCase = FakeMarkAsFullyRead { _, _ -> error("boom") } + val closeLambda = lambdaRecorder {} + val navigator = FakeMessagesNavigator(closeLambda = closeLambda) + + val presenter = createMessagesPresenter( + timeline = FakeTimeline(getLatestEventIdResult = { Result.success(AN_EVENT_ID) }), + markAsFullyRead = markAsFullyReadUseCase, + navigator = navigator, + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvents.MarkAsFullyReadAndExit) + + runCurrent() + + closeLambda.assertions().isCalledOnce() + + cancelAndIgnoreRemainingEvents() + } + } + + private fun TestScope.createMessagesPresenter( + coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), + timeline: Timeline = FakeTimeline(), + joinedRoom: FakeJoinedRoom = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(id = roomId, name = "")) + }, + liveTimeline = timeline, + typingNoticeResult = { Result.success(Unit) }, + ), + navigator: FakeMessagesNavigator = FakeMessagesNavigator(), + clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), + analyticsService: FakeAnalyticsService = FakeAnalyticsService(), + timelineEventSink: (TimelineEvents) -> Unit = {}, + permalinkParser: PermalinkParser = FakePermalinkParser(), + messageComposerPresenter: Presenter = Presenter { + aMessageComposerState( + // Use TextEditorState.Markdown, so that we can request focus manually. + textEditorState = aTextEditorStateMarkdown(initialText = "", initialFocus = false) + ) + }, + roomMemberModerationPresenter: Presenter = Presenter { + aRoomMemberModerationState() + }, + encryptionService: FakeEncryptionService = FakeEncryptionService(), + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), + actionListEventSink: (ActionListEvents) -> Unit = {}, + addRecentEmoji: AddRecentEmoji = AddRecentEmoji { _ -> lambdaError() }, + markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead(), + ): MessagesPresenter { + return MessagesPresenter( + navigator = navigator, + room = joinedRoom, + composerPresenter = messageComposerPresenter, + voiceMessageComposerPresenterFactory = FakeDefaultVoiceMessageComposerPresenterFactory(backgroundScope), + timelinePresenter = { aTimelineState(eventSink = timelineEventSink) }, + timelineProtectionPresenter = { aTimelineProtectionState() }, + identityChangeStatePresenter = { anIdentityChangeState() }, + linkPresenter = { aLinkState() }, + actionListPresenter = { anActionListState(eventSink = actionListEventSink) }, + customReactionPresenter = { aCustomReactionState() }, + reactionSummaryPresenter = { aReactionSummaryState() }, + readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() }, + pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() }, + roomCallStatePresenter = { aStandByCallState() }, + roomMemberModerationPresenter = roomMemberModerationPresenter, + snackbarDispatcher = SnackbarDispatcher(), + dispatchers = coroutineDispatchers, + clipboardHelper = clipboardHelper, + htmlConverterProvider = FakeHtmlConverterProvider(), + buildMeta = aBuildMeta(), + timelineController = TimelineController(joinedRoom, timeline), + permalinkParser = permalinkParser, + analyticsService = analyticsService, + encryptionService = encryptionService, + featureFlagService = featureFlagService, + addRecentEmoji = addRecentEmoji, + markAsFullyRead = markAsFullyRead, + sessionCoroutineScope = backgroundScope, + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt new file mode 100644 index 0000000..23665a0 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -0,0 +1,633 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onLast +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeRight +import androidx.compose.ui.text.AnnotatedString +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.emojibasebindings.Emoji +import io.element.android.emojibasebindings.EmojibaseCategory +import io.element.android.emojibasebindings.EmojibaseStore +import io.element.android.features.messages.impl.actionlist.ActionListEvents +import io.element.android.features.messages.impl.actionlist.ActionListState +import io.element.android.features.messages.impl.actionlist.anActionListState +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aChangedIdentitySendFailure +import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState +import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerItem +import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMessagesBannerState +import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS +import io.element.android.features.messages.impl.timeline.TimelineEvents +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.aTimelineItemList +import io.element.android.features.messages.impl.timeline.aTimelineItemReadReceipts +import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo +import io.element.android.features.messages.impl.timeline.aTimelineState +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents +import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData +import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents +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.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom +import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl +import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParamsAndResult +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams +import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParamsAndResult +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.assertNoNodeWithText +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.setSafeContent +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.time.Duration.Companion.milliseconds + +@RunWith(AndroidJUnit4::class) +class MessagesViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invoke expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aMessagesState( + eventSink = eventsRecorder + ) + ensureCalledOnce { callback -> + rule.setMessagesView( + state = state, + onBackClick = callback, + ) + rule.pressBack() + } + } + + @Test + fun `clicking on room name invoke expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aMessagesState( + eventSink = eventsRecorder + ) + ensureCalledOnce { callback -> + rule.setMessagesView( + state = state, + onRoomDetailsClick = callback, + ) + rule.onNodeWithText(state.roomName.orEmpty(), useUnmergedTree = true).performClick() + } + } + + @Test + fun `clicking on join call invoke expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aMessagesState( + eventSink = eventsRecorder + ) + ensureCalledOnce { callback -> + rule.setMessagesView( + state = state, + onJoinCallClick = callback, + ) + val joinCallContentDescription = rule.activity.getString(CommonStrings.a11y_start_call) + rule.onNodeWithContentDescription(joinCallContentDescription).performClick() + } + } + + @Test + fun `clicking on an Event invoke expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aMessagesState( + timelineState = aTimelineState( + timelineItems = aTimelineItemList(aTimelineItemTextContent()), + ), + eventSink = eventsRecorder + ) + val timelineItem = state.timelineState.timelineItems.first() + val callback = EnsureCalledOnceWithTwoParamsAndResult( + expectedParam1 = true, + expectedParam2 = timelineItem, + result = true, + ) + rule.setMessagesView( + state = state, + onEventClick = callback, + ) + // Cannot perform click on "Text", it's not detected. Use tag instead + rule.onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performClick() + callback.assertSuccess() + } + + @Test + fun `long clicking on an Event emits the expected Event userHasPermissionToSendMessage`() { + `long clicking on an Event emits the expected Event`(userHasPermissionToSendMessage = true) + } + + @Test + fun `long clicking on an Event emits the expected Event userHasPermissionToRedactOwn`() { + `long clicking on an Event emits the expected Event`(userHasPermissionToRedactOwn = true) + } + + @Test + fun `long clicking on an Event emits the expected Event userHasPermissionToRedactOther`() { + `long clicking on an Event emits the expected Event`(userHasPermissionToRedactOther = true) + } + + @Test + fun `long clicking on an Event emits the expected Event userHasPermissionToSendReaction`() { + `long clicking on an Event emits the expected Event`(userHasPermissionToSendReaction = true) + } + + private fun `long clicking on an Event emits the expected Event`( + userHasPermissionToSendMessage: Boolean = false, + userHasPermissionToRedactOwn: Boolean = false, + userHasPermissionToRedactOther: Boolean = false, + userHasPermissionToSendReaction: Boolean = false, + userCanPinEvent: Boolean = false, + ) { + val eventsRecorder = EventsRecorder() + val state = aMessagesState( + actionListState = anActionListState( + eventSink = eventsRecorder + ), + userEventPermissions = UserEventPermissions( + canSendMessage = userHasPermissionToSendMessage, + canRedactOwn = userHasPermissionToRedactOwn, + canRedactOther = userHasPermissionToRedactOther, + canSendReaction = userHasPermissionToSendReaction, + canPinUnpin = userCanPinEvent, + ), + timelineState = aTimelineState( + timelineItems = aTimelineItemList(aTimelineItemTextContent()), + ), + ) + val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event + rule.setMessagesView( + state = state, + ) + // Cannot perform click on "Text", it's not detected. Use tag instead + rule.onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performTouchInput { longClick() } + eventsRecorder.assertSingle( + ActionListEvents.ComputeForMessage( + event = timelineItem, + userEventPermissions = state.userEventPermissions, + ) + ) + } + + @Test + @Config(qualifiers = "h1024dp") + fun `clicking on a read receipt list emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val state = aMessagesState( + timelineState = aTimelineState( + renderReadReceipts = true, + timelineItems = persistentListOf( + aTimelineItemEvent( + readReceiptState = aTimelineItemReadReceipts( + receipts = listOf( + aReadReceiptData(0), + ), + ), + ), + ), + ), + readReceiptBottomSheetState = aReadReceiptBottomSheetState( + eventSink = eventsRecorder + ), + ) + val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event + rule.setMessagesView( + state = state, + ) + rule.onNodeWithTag(TestTags.messageReadReceipts.value, useUnmergedTree = true).performClick() + eventsRecorder.assertSingle(ReadReceiptBottomSheetEvents.EventSelected(timelineItem)) + } + + @Test + fun `swiping on an Event emits the expected Event`() { + swipeTest(userHasPermissionToSendMessage = true) + } + + @Test + fun `swiping on an Event emits no Event if user does not have permission to send message`() { + swipeTest(userHasPermissionToSendMessage = false) + } + + private fun swipeTest(userHasPermissionToSendMessage: Boolean) { + val eventsRecorder = EventsRecorder() + val canBeRepliedEvent = aTimelineItemEvent(canBeRepliedTo = true) + val cannotBeRepliedEvent = aTimelineItemEvent(canBeRepliedTo = false) + val state = aMessagesState( + timelineState = aTimelineState( + timelineItems = persistentListOf(canBeRepliedEvent, cannotBeRepliedEvent), + timelineRoomInfo = aTimelineRoomInfo( + userHasPermissionToSendMessage = userHasPermissionToSendMessage + ), + ), + eventSink = eventsRecorder, + ) + rule.setMessagesView( + state = state, + ) + rule.onAllNodesWithTag(TestTags.messageBubble.value).apply { + onFirst().performTouchInput { swipeRight(endX = 200f) } + onLast().performTouchInput { swipeRight(endX = 200f) } + } + if (userHasPermissionToSendMessage) { + eventsRecorder.assertSingle(MessagesEvents.HandleAction(TimelineItemAction.Reply, canBeRepliedEvent)) + } else { + eventsRecorder.assertEmpty() + } + } + + @Test + fun `clicking on send location invoke expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aMessagesState( + composerState = aMessageComposerState( + showAttachmentSourcePicker = true + ), + eventSink = eventsRecorder + ) + ensureCalledOnce { callback -> + rule.setMessagesView( + state = state, + onSendLocationClick = callback, + ) + rule.clickOn(R.string.screen_room_attachment_source_location) + } + } + + @Test + fun `clicking on create poll invoke expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aMessagesState( + composerState = aMessageComposerState( + showAttachmentSourcePicker = true + ), + eventSink = eventsRecorder + ) + ensureCalledOnce { callback -> + rule.setMessagesView( + state = state, + onCreatePollClick = callback, + ) + // Then click on the poll action + rule.clickOn(R.string.screen_room_attachment_source_poll) + } + } + + @Test + @Config(qualifiers = "h1024dp") + fun `clicking on the avatar of the sender of an Event emits the expected event`() { + val eventsRecorder = EventsRecorder() + val state = aMessagesState( + eventSink = eventsRecorder + ) + val timelineEvent = state.timelineState.timelineItems.filterIsInstance().first() + rule.setMessagesView(state = state) + rule.onNodeWithTag(TestTags.timelineItemSenderAvatar.value, useUnmergedTree = true).performClick() + eventsRecorder.assertSingle( + MessagesEvents.OnUserClicked( + MatrixUser( + userId = timelineEvent.senderId, + displayName = timelineEvent.senderProfile.getDisplayName(), + avatarUrl = timelineEvent.senderProfile.getAvatarUrl() + ) + ) + ) + } + + @Test + @Config(qualifiers = "h1024dp") + fun `clicking on the display name of the sender of an Event emits expected event`() { + val eventsRecorder = EventsRecorder() + val state = aMessagesState(eventSink = eventsRecorder) + val timelineEvent = state.timelineState.timelineItems.filterIsInstance().first() + rule.setMessagesView(state = state) + rule.onNodeWithTag(TestTags.timelineItemSenderAvatar.value, useUnmergedTree = true).performClick() + eventsRecorder.assertSingle( + MessagesEvents.OnUserClicked( + MatrixUser( + userId = timelineEvent.senderId, + displayName = timelineEvent.senderProfile.getDisplayName(), + avatarUrl = timelineEvent.senderProfile.getAvatarUrl() + ) + ) + ) + } + + @Test + fun `selecting a action on a message emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val state = aMessagesState( + eventSink = eventsRecorder + ) + val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event + val stateWithMessageAction = state.copy( + actionListState = anActionListState( + target = ActionListState.Target.Success( + event = timelineItem, + sentTimeFull = "", + displayEmojiReactions = true, + actions = persistentListOf(TimelineItemAction.Edit), + verifiedUserSendFailure = VerifiedUserSendFailure.None, + recentEmojis = persistentListOf(), + ) + ), + ) + rule.setMessagesView( + state = stateWithMessageAction, + ) + rule.clickOn(CommonStrings.action_edit) + // Give time for the close animation to complete + rule.mainClock.advanceTimeBy(milliseconds = 1_000) + eventsRecorder.assertSingle(MessagesEvents.HandleAction(TimelineItemAction.Edit, timelineItem)) + } + + @Test + fun `clicking on a reaction emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val state = aMessagesState( + timelineState = aTimelineState( + timelineItems = aTimelineItemList(aTimelineItemTextContent()), + ), + eventSink = eventsRecorder, + ) + val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event + rule.setMessagesView( + state = state, + ) + rule.onAllNodesWithText( + text = "👍️", + useUnmergedTree = true, + ).onFirst().performClick() + eventsRecorder.assertSingle(MessagesEvents.ToggleReaction("👍️", timelineItem.eventOrTransactionId)) + } + + @Test + fun `long clicking on a reaction emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val state = aMessagesState( + timelineState = aTimelineState( + timelineItems = aTimelineItemList(aTimelineItemTextContent()), + ), + reactionSummaryState = aReactionSummaryState( + target = null, + eventSink = eventsRecorder, + ), + ) + val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event + rule.setMessagesView( + state = state, + ) + rule.onAllNodesWithText( + text = "👍️", + useUnmergedTree = true, + ).onFirst().performTouchInput { longClick() } + eventsRecorder.assertSingle(ReactionSummaryEvents.ShowReactionSummary(timelineItem.eventId!!, timelineItem.reactionsState.reactions, "👍️")) + } + + @Test + fun `clicking on more reaction emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val state = aMessagesState( + timelineState = aTimelineState( + timelineItems = aTimelineItemList(aTimelineItemTextContent()), + ), + customReactionState = aCustomReactionState( + eventSink = eventsRecorder, + ), + ) + val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event + rule.setMessagesView( + state = state, + ) + val moreReactionContentDescription = rule.activity.getString(R.string.screen_room_timeline_add_reaction) + rule.onAllNodesWithContentDescription(moreReactionContentDescription).onFirst().performClick() + eventsRecorder.assertSingle(CustomReactionEvents.ShowCustomReactionSheet(timelineItem)) + } + + @Test + fun `clicking on more reaction from action list emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val state = aMessagesState( + timelineState = aTimelineState( + timelineItems = aTimelineItemList(aTimelineItemTextContent()), + ), + ) + val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event + val stateWithActionListState = state.copy( + actionListState = anActionListState( + target = ActionListState.Target.Success( + event = timelineItem, + sentTimeFull = "", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf(TimelineItemAction.Edit), + recentEmojis = persistentListOf(), + ), + ), + customReactionState = aCustomReactionState( + eventSink = eventsRecorder + ), + ) + rule.setMessagesView( + state = stateWithActionListState, + ) + val moreReactionContentDescription = rule.activity.getString(CommonStrings.a11y_react_with_other_emojis) + rule.onNodeWithContentDescription(moreReactionContentDescription).performClick() + // Give time for the close animation to complete + rule.mainClock.advanceTimeBy(milliseconds = 1_000) + eventsRecorder.assertSingle(CustomReactionEvents.ShowCustomReactionSheet(timelineItem)) + } + + @Test + fun `clicking on verified user send failure from action list emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val state = aMessagesState() + val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event + val stateWithActionListState = state.copy( + actionListState = anActionListState( + target = ActionListState.Target.Success( + event = timelineItem, + sentTimeFull = "", + displayEmojiReactions = true, + verifiedUserSendFailure = aChangedIdentitySendFailure(), + actions = persistentListOf(), + recentEmojis = persistentListOf(), + ), + ), + timelineState = aTimelineState(eventSink = eventsRecorder) + ) + rule.setMessagesView( + state = stateWithActionListState, + ) + val verifiedUserSendFailure = rule.activity.getString(CommonStrings.screen_timeline_item_menu_send_failure_changed_identity, "Alice") + rule.onNodeWithText(verifiedUserSendFailure).performClick() + // Give time for the close animation to complete + rule.mainClock.advanceTimeBy(milliseconds = 1_000) + eventsRecorder.assertSingle(TimelineEvents.ComputeVerifiedUserSendFailure(timelineItem)) + } + + @Test + fun `clicking on a custom emoji emits the expected Events`() { + val aUnicode = "🙈" + val customReactionStateEventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() + val state = aMessagesState( + eventSink = eventsRecorder, + ) + val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event + val stateWithCustomReactionState = state.copy( + customReactionState = aCustomReactionState( + target = CustomReactionState.Target.Success( + event = timelineItem, + emojibaseStore = EmojibaseStore( + categories = persistentMapOf( + EmojibaseCategory.People to persistentListOf( + Emoji( + hexcode = "", + label = "", + tags = persistentListOf(), + shortcodes = persistentListOf(), + unicode = aUnicode, + skins = null, + ) + ) + ) + ), + ), + eventSink = customReactionStateEventsRecorder + ), + ) + rule.setMessagesView( + state = stateWithCustomReactionState, + ) + rule.onNodeWithText(aUnicode, useUnmergedTree = true).performClick() + // Give time for the close animation to complete + rule.mainClock.advanceTimeBy(milliseconds = 1_000) + customReactionStateEventsRecorder.assertSingle(CustomReactionEvents.DismissCustomReactionSheet) + eventsRecorder.assertSingle(MessagesEvents.ToggleReaction(aUnicode, timelineItem.eventOrTransactionId)) + } + + @Test + fun `clicking on pinned messages banner emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val state = aMessagesState( + timelineState = aTimelineState(eventSink = eventsRecorder), + pinnedMessagesBannerState = aLoadedPinnedMessagesBannerState( + knownPinnedMessagesCount = 2, + currentPinnedMessageIndex = 0, + currentPinnedMessage = PinnedMessagesBannerItem( + eventId = AN_EVENT_ID, + formatted = AnnotatedString("This is a pinned message") + ), + ), + ) + rule.setMessagesView(state = state) + rule.onNodeWithText("This is a pinned message").performClick() + eventsRecorder.assertSingle(TimelineEvents.FocusOnEvent(AN_EVENT_ID, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)) + } + + @Test + fun `clicking on successor room button emits expected event`() { + val eventsRecorder = EventsRecorder() + val successorRoomId = RoomId("!successor:server.org") + val state = aMessagesState( + successorRoom = SuccessorRoom( + roomId = successorRoomId, + reason = "This room has been upgraded" + ), + timelineState = aTimelineState(eventSink = eventsRecorder) + ) + rule.setMessagesView(state = state) + val text = rule.activity.getString(R.string.screen_room_timeline_tombstoned_room_action) + // The bottomsheet subcompose seems to make the node to appear twice + rule.onAllNodesWithText(text).onFirst().performClick() + eventsRecorder.assertSingle(TimelineEvents.NavigateToPredecessorOrSuccessorRoom(successorRoomId)) + } + + @Test + fun `no banner shown when there is no successor room`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aMessagesState( + successorRoom = null, + eventSink = eventsRecorder + ) + rule.setMessagesView(state = state) + rule.assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_message) + rule.assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_action) + } +} + +private fun AndroidComposeTestRule.setMessagesView( + state: MessagesState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onRoomDetailsClick: () -> Unit = EnsureNeverCalled(), + onEventClick: (isLive: Boolean, event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithTwoParamsAndResult(), + onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), + onLinkClick: (String, Boolean) -> Unit = EnsureNeverCalledWithTwoParams(), + onSendLocationClick: () -> Unit = EnsureNeverCalled(), + onCreatePollClick: () -> Unit = EnsureNeverCalled(), + onJoinCallClick: () -> Unit = EnsureNeverCalled(), + onViewAllPinnedMessagesClick: () -> Unit = EnsureNeverCalled(), +) { + setSafeContent { + // Cannot use the RichTextEditor, so simulate a LocalInspectionMode + CompositionLocalProvider(LocalInspectionMode provides true) { + MessagesView( + state = state, + onBackClick = onBackClick, + onRoomDetailsClick = onRoomDetailsClick, + onEventContentClick = onEventClick, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onSendLocationClick = onSendLocationClick, + onCreatePollClick = onCreatePollClick, + onJoinCallClick = onJoinCallClick, + onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick, + knockRequestsBannerView = {}, + ) + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt new file mode 100644 index 0000000..7c61d2b --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt @@ -0,0 +1,1556 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.actionlist + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.aUserEventPermissions +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.actionlist.model.TimelineItemActionPostProcessor +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory +import io.element.android.features.messages.impl.fixtures.aMessageEvent +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent +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.aTimelineItemPollContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent +import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList +import io.element.android.libraries.dateformatter.test.FakeDateFormatter +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +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_CAPTION +import io.element.android.libraries.matrix.test.A_MESSAGE +import io.element.android.libraries.matrix.test.A_THREAD_ID +import io.element.android.libraries.matrix.test.A_TRANSACTION_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore +import io.element.android.libraries.recentemojis.api.GetRecentEmojis +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +@Suppress("LargeClass") +class ActionListPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private val suggestedEmojis = persistentListOf("👍️", "👎️", "🔥", "❤️", "👏") + + @Test + fun `present - initial state`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + moleculeFlow(RecompositionMode.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 = createActionListPresenter(isDeveloperModeEnabled = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent(isMine = true, isEditable = false, content = TimelineItemRedactedContent) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = false, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.ViewSource, + ), + recentEmojis = suggestedEmojis, + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for message from others redacted`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = false, + isEditable = false, + content = TimelineItemRedactedContent + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = false, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.ViewSource, + ), + recentEmojis = suggestedEmojis, + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for others message`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = false, + isEditable = false, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE) + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.CopyText, + TimelineItemAction.ViewSource, + TimelineItemAction.ReportContent, + ), + recentEmojis = suggestedEmojis, + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for others message in a thread`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + presenter.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = false, + isEditable = false, + threadInfo = TimelineItemThreadInfo.ThreadResponse(threadRootId = A_THREAD_ID), + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE) + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.ReplyInThread, + TimelineItemAction.Forward, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.CopyText, + TimelineItemAction.ViewSource, + TimelineItemAction.ReportContent, + ), + recentEmojis = suggestedEmojis, + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for others message cannot sent message`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = false, + isEditable = false, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE) + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = false, + canSendReaction = true, + canPinUnpin = true, + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.Forward, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.CopyText, + TimelineItemAction.ViewSource, + TimelineItemAction.ReportContent, + ), + recentEmojis = suggestedEmojis, + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for others message and can redact`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = false, + isEditable = false, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE) + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = true, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.CopyText, + TimelineItemAction.ViewSource, + TimelineItemAction.ReportContent, + TimelineItemAction.Redact, + ), + recentEmojis = suggestedEmojis, + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for others message and cannot send reaction`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = false, + isEditable = false, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE) + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = true, + canSendMessage = true, + canSendReaction = false, + canPinUnpin = true, + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = false, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.CopyText, + TimelineItemAction.ViewSource, + TimelineItemAction.ReportContent, + TimelineItemAction.Redact, + ), + recentEmojis = suggestedEmojis, + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for my message`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE) + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.Edit, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.CopyText, + TimelineItemAction.ViewSource, + TimelineItemAction.Redact, + ), + recentEmojis = suggestedEmojis, + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for my message in a thread`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + presenter.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + threadInfo = TimelineItemThreadInfo.ThreadResponse(threadRootId = A_THREAD_ID), + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE) + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.ReplyInThread, + TimelineItemAction.Forward, + TimelineItemAction.Edit, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.CopyText, + TimelineItemAction.ViewSource, + TimelineItemAction.Redact, + ), + recentEmojis = suggestedEmojis, + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for my message cannot redact`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE) + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.Edit, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.CopyText, + TimelineItemAction.ViewSource, + ), + recentEmojis = suggestedEmojis, + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for my message no permission`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE) + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = false, + canSendMessage = false, + canSendReaction = false, + canPinUnpin = false, + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = false, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.Forward, + TimelineItemAction.CopyLink, + TimelineItemAction.CopyText, + TimelineItemAction.ViewSource, + ), + recentEmojis = suggestedEmojis, + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for a media item`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + isEditable = true, + content = aTimelineItemImageContent(), + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ), + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.AddCaption, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.ViewSource, + TimelineItemAction.Redact, + ), + recentEmojis = suggestedEmojis, + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for a media with caption item`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + isEditable = true, + content = aTimelineItemImageContent( + caption = A_CAPTION, + ), + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ), + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.EditCaption, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.CopyCaption, + TimelineItemAction.RemoveCaption, + TimelineItemAction.ViewSource, + TimelineItemAction.Redact, + ), + recentEmojis = suggestedEmojis, + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for a media with caption item - other user event`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = false, + isEditable = false, + content = aTimelineItemImageContent( + caption = A_CAPTION, + ), + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ), + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.CopyCaption, + TimelineItemAction.ViewSource, + TimelineItemAction.ReportContent, + ), + recentEmojis = suggestedEmojis, + ) + ) + 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 = createActionListPresenter(isDeveloperModeEnabled = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val stateEvent = aTimelineItemEvent( + isMine = true, + content = aTimelineItemStateEventContent(), + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = stateEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = stateEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = false, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.ViewSource, + ), + recentEmojis = suggestedEmojis, + ) + ) + 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 = createActionListPresenter(isDeveloperModeEnabled = false) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val stateEvent = aTimelineItemEvent( + isMine = true, + content = aTimelineItemStateEventContent(), + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = stateEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) + ) + ) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute message in non-debuggable build`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = false) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE) + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.Edit, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.CopyText, + TimelineItemAction.Redact, + ), + recentEmojis = suggestedEmojis, + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute message when user can't pin`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE) + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = false, + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.Edit, + TimelineItemAction.CopyLink, + TimelineItemAction.CopyText, + TimelineItemAction.ViewSource, + TimelineItemAction.Redact, + ), + recentEmojis = suggestedEmojis, + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute message when event is already pinned`() = runTest { + val room = FakeBaseRoom().apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) + } + val presenter = createActionListPresenter( + isDeveloperModeEnabled = true, + room = room + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE) + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.Edit, + TimelineItemAction.CopyLink, + TimelineItemAction.Unpin, + TimelineItemAction.CopyText, + TimelineItemAction.ViewSource, + TimelineItemAction.Redact, + ), + recentEmojis = suggestedEmojis, + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute message with no actions`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = false) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE) + ) + val redactedEvent = aMessageEvent( + isMine = true, + content = TimelineItemRedactedContent, + ) + + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) + ) + ) + assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java) + + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = redactedEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = false, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + ) + ) + ) + awaitItem().run { + assertThat(target).isEqualTo(ActionListState.Target.None) + } + } + } + + @Test + fun `present - compute not sent message`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = false) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + // No event id, so it's not sent yet + eventId = null, + isMine = true, + canBeRepliedTo = false, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = A_MESSAGE), + ) + + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.Edit, + TimelineItemAction.CopyText, + TimelineItemAction.Redact, + ), + recentEmojis = suggestedEmojis, + ) + ) + } + } + + @Test + fun `present - compute for editable poll message`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = false) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + isEditable = true, + content = aTimelineItemPollContent(answerItems = aPollAnswerItemList(hasVotes = false)), + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.EndPoll, + TimelineItemAction.Reply, + TimelineItemAction.EditPoll, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.Redact, + ), + recentEmojis = suggestedEmojis, + ) + ) + } + } + + @Test + fun `present - compute for non-editable poll message`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = false) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + isEditable = false, + content = aTimelineItemPollContent(answerItems = aPollAnswerItemList(hasVotes = true)), + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.EndPoll, + TimelineItemAction.Reply, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.Redact, + ), + recentEmojis = suggestedEmojis, + ) + ) + } + } + + @Test + fun `present - compute for ended poll message`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = false) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + isEditable = false, + content = aTimelineItemPollContent(isEnded = true), + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.Redact, + ), + recentEmojis = suggestedEmojis, + ) + ) + } + } + + @Test + fun `present - compute for voice message`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = false) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + isEditable = false, + content = aTimelineItemVoiceContent( + caption = null, + ), + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.Redact, + ), + recentEmojis = suggestedEmojis, + ) + ) + } + } + + @Test + fun `present - compute for call notify`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + content = TimelineItemRtcNotificationContent(), + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = false, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.ViewSource + ), + recentEmojis = suggestedEmojis, + ) + ) + } + } + + @Test + fun `present - compute for verified user send failure`() = runTest { + val room = FakeBaseRoom( + userDisplayNameResult = { Result.success("Alice") } + ) + val presenter = createActionListPresenter(isDeveloperModeEnabled = false, room = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + sendState = LocalEventSendState.Failed.VerifiedUserChangedIdentity(users = listOf(A_USER_ID)), + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions(), + ) + ) + skipItems(1) + val successState = awaitItem() + val target = successState.target as ActionListState.Target.Success + assertThat(target.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(userDisplayName = "Alice")) + } + } + + @Test + fun `present - compute for threaded timeline with threads enabled`() = runTest { + val presenter = createActionListPresenter( + isDeveloperModeEnabled = false, + timelineMode = Timeline.Mode.Thread(A_THREAD_ID), + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.Threads.key to true)), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + isEditable = false, + content = aTimelineItemVoiceContent( + caption = null, + ), + threadInfo = TimelineItemThreadInfo.ThreadResponse(threadRootId = A_THREAD_ID) + ) + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + // This is Reply, not ReplyInThread + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.Redact, + ), + recentEmojis = suggestedEmojis, + ) + ) + } + } + + @Test + fun `present - compute for remote timeline item with threads enabled`() = runTest { + val presenter = createActionListPresenter( + isDeveloperModeEnabled = false, + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.Threads.key to true)), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + eventId = AN_EVENT_ID, + isMine = true, + isEditable = false, + content = aTimelineItemVoiceContent( + caption = null, + ), + ) + + assertThat(messageEvent.isRemote).isTrue() + + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.ReplyInThread, + TimelineItemAction.Forward, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.Redact, + ), + recentEmojis = suggestedEmojis, + ) + ) + } + } + + @Test + fun `present - compute for remote timeline item already in thread with threads enabled`() = runTest { + val presenter = createActionListPresenter( + isDeveloperModeEnabled = false, + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.Threads.key to true)), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + eventId = AN_EVENT_ID, + isMine = true, + isEditable = false, + content = aTimelineItemVoiceContent( + caption = null, + ), + threadInfo = TimelineItemThreadInfo.ThreadResponse(threadRootId = A_THREAD_ID), + ) + + assertThat(messageEvent.isRemote).isTrue() + + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.ReplyInThread, + TimelineItemAction.Forward, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.Redact, + ), + recentEmojis = suggestedEmojis, + ) + ) + } + } + + @Test + fun `present - compute for local timeline item with threads enabled`() = runTest { + val presenter = createActionListPresenter( + isDeveloperModeEnabled = false, + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.Threads.key to true)), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + eventId = null, + transactionId = A_TRANSACTION_ID, + isMine = true, + isEditable = false, + content = aTimelineItemVoiceContent( + caption = null, + ), + ) + + assertThat(messageEvent.isRemote).isFalse() + + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + // Can't reply in thread for local events + TimelineItemAction.Reply, + TimelineItemAction.Redact, + ), + recentEmojis = suggestedEmojis, + ) + ) + } + } + + @Test + fun `present - recentEmojis merges suggested and recent emojis`() = runTest { + val suggestedEmojis = persistentListOf("👍️", "👎️", "🔥", "❤️", "👏") + val otherEmojis = (0..100).map { it.toString() } + + val presenter = createActionListPresenter( + isDeveloperModeEnabled = false, + recentEmojis = GetRecentEmojis { Result.success((listOf("👍️", ":)", "❤️") + otherEmojis).toImmutableList()) }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + eventId = null, + transactionId = A_TRANSACTION_ID, + isMine = true, + isEditable = false, + content = aTimelineItemVoiceContent( + caption = null, + ), + ) + + initialState.eventSink.invoke( + ActionListEvents.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true + ) + ) + ) + val successState = awaitItem() + assertThat(successState.target).isInstanceOf(ActionListState.Target.Success::class.java) + + // Check items are deduplicated between suggested and recent emojis and we take at most 100 items + val expectedEmojis = (suggestedEmojis + persistentListOf(":)") + otherEmojis).take(100) + assertThat((successState.target as ActionListState.Target.Success).recentEmojis) + .isEqualTo(expectedEmojis) + } + } +} + +private fun createActionListPresenter( + isDeveloperModeEnabled: Boolean, + room: BaseRoom = FakeBaseRoom(), + timelineMode: Timeline.Mode = Timeline.Mode.Live, + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), + recentEmojis: GetRecentEmojis = GetRecentEmojis { Result.success(persistentListOf()) }, +): ActionListPresenter { + val preferencesStore = InMemoryAppPreferencesStore(isDeveloperModeEnabled = isDeveloperModeEnabled) + return DefaultActionListPresenter( + postProcessor = TimelineItemActionPostProcessor.Default, + appPreferencesStore = preferencesStore, + room = room, + userSendFailureFactory = VerifiedUserSendFailureFactory(room), + dateFormatter = FakeDateFormatter(), + timelineMode = timelineMode, + featureFlagService = featureFlagService, + getRecentEmojis = recentEmojis, + ) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparatorTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparatorTest.kt new file mode 100644 index 0000000..2209b63 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparatorTest.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.actionlist.model + +import org.junit.Test + +class TimelineItemActionComparatorTest { + @Test + fun `check that the list in the comparator only contain each item once`() { + val sut = TimelineItemActionComparator() + sut.orderedList.forEach { + require(sut.orderedList.count { item -> item == it } == 1, { "Duplicate ${it::class.java}.$it" }) + } + } + + @Test + fun `check that the list in the comparator contains all the items`() { + val sut = TimelineItemActionComparator() + TimelineItemAction.entries.forEach { + require(it in sut.orderedList, { "Missing ${it::class.simpleName}.$it in orderedList" }) + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt new file mode 100644 index 0000000..5263182 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt @@ -0,0 +1,634 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.messages.impl.attachments + +import android.net.Uri +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +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.OnDoneListener +import io.element.android.features.messages.impl.attachments.preview.SendActionState +import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState +import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation +import io.element.android.features.messages.impl.fixtures.aMediaAttachment +import io.element.android.features.messages.test.attachments.video.FakeMediaOptimizationSelectorPresenterFactory +import io.element.android.libraries.androidutils.file.TemporaryUriDeleter +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.core.EventId +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.permalink.PermalinkBuilder +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.test.A_CAPTION +import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler +import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.mediaupload.api.MediaSenderFactory +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.mediaupload.impl.DefaultMediaSender +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo +import io.element.android.libraries.mediaviewer.api.anApkMediaInfo +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.awaitLastSequentialItem +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +@Suppress("LargeClass") +@RunWith(RobolectricTestRunner::class) +class AttachmentsPreviewPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private val mockMediaUrl: Uri = mockk("localMediaUri") { + every { path } returns "/path/to/media" + } + + @Test + fun `present - initial state`() = runTest { + createAttachmentsPreviewPresenter().test { + val initialState = awaitItem() + assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) + } + } + + @Test + fun `present - send media success scenario`() = runTest { + val sendFileResult = + lambdaRecorder> { _, _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + val room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendFileLambda = sendFileResult + }, + ) + val onDoneListener = lambdaRecorder { } + val mediaPreProcessor = FakeMediaPreProcessor() + val presenter = createAttachmentsPreviewPresenter( + room = room, + mediaPreProcessor = mediaPreProcessor, + onDoneListener = { onDoneListener() }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) + initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = true)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) + sendFileResult.assertions().isCalledOnce() + onDoneListener.assertions().isCalledOnce() + assertThat(mediaPreProcessor.cleanUpCallCount).isEqualTo(1) + } + } + + @Test + fun `present - send media after pre-processing success scenario`() = runTest { + val sendFileResult = + lambdaRecorder> { _, _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + val room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendFileLambda = sendFileResult + }, + ) + val onDoneListener = lambdaRecorder { } + val processLatch = CompletableDeferred() + val mediaPreProcessor = FakeMediaPreProcessor(processLatch) + val presenter = createAttachmentsPreviewPresenter( + room = room, + mediaPreProcessor = mediaPreProcessor, + onDoneListener = { onDoneListener() }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) + // Pre-processing finishes + processLatch.complete(Unit) + advanceUntilIdle() + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) + initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) + sendFileResult.assertions().isCalledOnce() + onDoneListener.assertions().isCalledOnce() + assertThat(mediaPreProcessor.cleanUpCallCount).isEqualTo(1) + } + } + + @Test + fun `present - send media before pre-processing success scenario`() = runTest { + val sendFileResult = + lambdaRecorder> { _, _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + val room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendFileLambda = sendFileResult + }, + ) + val onDoneListener = lambdaRecorder { } + val processLatch = CompletableDeferred() + val mediaPreProcessor = FakeMediaPreProcessor(processLatch) + val presenter = createAttachmentsPreviewPresenter( + room = room, + mediaPreProcessor = mediaPreProcessor, + onDoneListener = { onDoneListener() }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) + initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) + // Pre-processing finishes + processLatch.complete(Unit) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = true)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) + sendFileResult.assertions().isCalledOnce() + onDoneListener.assertions().isCalledOnce() + assertThat(mediaPreProcessor.cleanUpCallCount).isEqualTo(1) + } + } + + @Test + fun `present - send media with pre-processing failure after user sends media`() = runTest { + val room = FakeJoinedRoom() + val onDoneListener = lambdaRecorder { } + val processLatch = CompletableDeferred() + val presenter = createAttachmentsPreviewPresenter( + room = room, + mediaPreProcessor = FakeMediaPreProcessor().apply { + givenResult(Result.failure(Exception())) + }, + onDoneListener = { onDoneListener() }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) + initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) + // Pre-processing finishes + processLatch.complete(Unit) + assertThat(awaitItem().sendActionState).isInstanceOf(SendActionState.Failure::class.java) + } + } + + @Test + fun `present - send media with pre-processing failure before user sends media`() = runTest { + val room = FakeJoinedRoom() + val onDoneListener = lambdaRecorder { } + val processLatch = CompletableDeferred() + val presenter = createAttachmentsPreviewPresenter( + room = room, + mediaPreProcessor = FakeMediaPreProcessor().apply { + givenResult(Result.failure(Exception())) + }, + onDoneListener = { onDoneListener() }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) + // Pre-processing finishes + processLatch.complete(Unit) + advanceUntilIdle() + initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) + assertThat(awaitItem().sendActionState).isInstanceOf(SendActionState.Failure::class.java) + } + } + + @Test + fun `present - cancel scenario`() = runTest { + val onDoneListener = lambdaRecorder { } + val deleteCallback = lambdaRecorder {} + val mediaPreProcessor = FakeMediaPreProcessor() + val presenter = createAttachmentsPreviewPresenter( + mediaPreProcessor = mediaPreProcessor, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + onDoneListener = { onDoneListener() }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) + initialState.eventSink(AttachmentsPreviewEvents.CancelAndDismiss) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) + deleteCallback.assertions().isCalledOnce() + onDoneListener.assertions().isCalledOnce() + assertThat(mediaPreProcessor.cleanUpCallCount).isEqualTo(1) + } + } + + @Test + fun `present - send image with caption success scenario`() = runTest { + val sendImageResult = + lambdaRecorder { _: File, _: File?, _: ImageInfo, _: String?, _: String?, _: EventId? -> + Result.success(FakeMediaUploadHandler()) + } + val mediaPreProcessor = FakeMediaPreProcessor().apply { + givenImageResult() + } + val room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendImageLambda = sendImageResult + }, + ) + val onDoneListener = lambdaRecorder { } + val presenter = createAttachmentsPreviewPresenter( + room = room, + mediaPreProcessor = mediaPreProcessor, + onDoneListener = { onDoneListener() }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) + initialState.textEditorState.setMarkdown(A_CAPTION) + initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) + assertThat(awaitItem().sendActionState).isInstanceOf(SendActionState.Sending.ReadyToUpload::class.java) + assertThat(awaitItem().sendActionState).isInstanceOf(SendActionState.Sending.Uploading::class.java) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) + sendImageResult.assertions().isCalledOnce().with( + any(), + any(), + any(), + value(A_CAPTION), + any(), + any(), + ) + onDoneListener.assertions().isCalledOnce() + } + } + + @Test + fun `present - send video with caption success scenario`() = runTest { + val sendVideoResult = + lambdaRecorder { _: File, _: File?, _: VideoInfo, _: String?, _: String?, _: EventId? -> + Result.success(FakeMediaUploadHandler()) + } + val mediaPreProcessor = FakeMediaPreProcessor().apply { + givenVideoResult() + } + val room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendVideoLambda = sendVideoResult + }, + ) + val onDoneListener = lambdaRecorder { } + val presenter = createAttachmentsPreviewPresenter( + room = room, + mediaPreProcessor = mediaPreProcessor, + onDoneListener = { onDoneListener() }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) + initialState.textEditorState.setMarkdown(A_CAPTION) + initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) + assertThat(awaitItem().sendActionState).isInstanceOf(SendActionState.Sending.ReadyToUpload::class.java) + assertThat(awaitItem().sendActionState).isInstanceOf(SendActionState.Sending.Uploading::class.java) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) + sendVideoResult.assertions().isCalledOnce().with( + any(), + any(), + any(), + value(A_CAPTION), + any(), + any(), + ) + onDoneListener.assertions().isCalledOnce() + } + } + + @Test + fun `present - send audio with caption success scenario`() = runTest { + val sendAudioResult = + lambdaRecorder> { _, _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + val mediaPreProcessor = FakeMediaPreProcessor().apply { + givenAudioResult() + } + val room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendAudioLambda = sendAudioResult + }, + ) + val onDoneListener = lambdaRecorder { } + val presenter = createAttachmentsPreviewPresenter( + room = room, + mediaPreProcessor = mediaPreProcessor, + onDoneListener = { onDoneListener() }, + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) + initialState.textEditorState.setMarkdown(A_CAPTION) + initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) + assertThat(awaitItem().sendActionState).isInstanceOf(SendActionState.Sending.ReadyToUpload::class.java) + assertThat(awaitItem().sendActionState).isInstanceOf(SendActionState.Sending.Uploading::class.java) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) + sendAudioResult.assertions().isCalledOnce().with( + any(), + any(), + value(A_CAPTION), + any(), + any(), + ) + onDoneListener.assertions().isCalledOnce() + } + } + + @Test + fun `present - send media failure scenario`() = runTest { + val failure = MediaPreProcessor.Failure(null) + val sendFileResult = + lambdaRecorder> { _, _, _, _, _ -> + Result.failure(failure) + } + val onDoneListenerResult = lambdaRecorder {} + val room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendFileLambda = sendFileResult + }, + ) + val presenter = createAttachmentsPreviewPresenter(room = room, onDoneListener = onDoneListenerResult) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) + initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) + + // Check that the onDoneListener is called so the screen would be dismissed + onDoneListenerResult.assertions().isCalledOnce() + + val failureState = awaitItem() + assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure, mediaUploadInfo)) + sendFileResult.assertions().isCalledOnce() + failureState.eventSink(AttachmentsPreviewEvents.CancelAndClearSendState) + val clearedState = awaitLastSequentialItem() + assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + } + } + + @Test + fun `present - dismissing the progress dialog stops media upload`() = runTest { + val onDoneListenerResult = lambdaRecorder {} + val presenter = createAttachmentsPreviewPresenter( + room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendFileLambda = { _, _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + } + ), + onDoneListener = onDoneListenerResult, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) + initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(mediaUploadInfo)) + initialState.eventSink(AttachmentsPreviewEvents.CancelAndClearSendState) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + // The sending is cancelled and the state is kept at ReadyToUpload + ensureAllEventsConsumed() + + // Check that the onDoneListener is called so the screen would be dismissed + onDoneListenerResult.assertions().isCalledOnce() + } + } + + @Test + fun `present - file too large will display error`() = runTest { + val onDoneListenerResult = lambdaRecorder {} + + val localMedia = aLocalMedia(uri = Uri.EMPTY, mediaInfo = anApkMediaInfo()) + val maxUploadSize = 999L // Set a max upload size smaller than the file size + + val presenter = createAttachmentsPreviewPresenter( + localMedia = localMedia, + room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendFileLambda = { _, _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + } + ), + onDoneListener = onDoneListenerResult, + mediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory { + MediaOptimizationSelectorState( + // Set a max upload size smaller than the file size + maxUploadSize = AsyncData.Success(maxUploadSize), + videoSizeEstimations = AsyncData.Uninitialized, + isImageOptimizationEnabled = null, + selectedVideoPreset = null, + displayMediaSelectorViews = false, + displayVideoPresetSelectorDialog = false, + eventSink = {}, + ) + } + ) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + assertThat(localMedia.info.fileSize).isGreaterThan(maxUploadSize) + + consumeItemsUntilPredicate { it.mediaOptimizationSelectorState.maxUploadSize.isSuccess() } + + assertThat(awaitItem().displayFileTooLargeError).isTrue() + } + } + + @Test + fun `present - video size estimations too large will display error`() = runTest { + val onDoneListenerResult = lambdaRecorder {} + + val localMedia = aLocalMedia(uri = Uri.EMPTY, mediaInfo = aVideoMediaInfo()) + + val presenter = createAttachmentsPreviewPresenter( + localMedia = localMedia, + room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendFileLambda = { _, _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + } + ), + onDoneListener = onDoneListenerResult, + mediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory { + MediaOptimizationSelectorState( + // Set a max upload size smaller than the file size + maxUploadSize = AsyncData.Success(Long.MAX_VALUE), + videoSizeEstimations = AsyncData.Success( + persistentListOf( + VideoUploadEstimation( + preset = VideoCompressionPreset.LOW, + // The important field is canUpload, it will normally be based on the sizeInBytes + canUpload = false, + sizeInBytes = 0L, + ), + VideoUploadEstimation( + preset = VideoCompressionPreset.STANDARD, + canUpload = false, + sizeInBytes = 0L, + ), + VideoUploadEstimation( + preset = VideoCompressionPreset.HIGH, + canUpload = false, + sizeInBytes = 0L, + ), + ) + ), + isImageOptimizationEnabled = null, + selectedVideoPreset = null, + displayMediaSelectorViews = false, + displayVideoPresetSelectorDialog = false, + eventSink = {}, + ) + } + ) + + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + consumeItemsUntilPredicate { + it.mediaOptimizationSelectorState.maxUploadSize.isSuccess() && + it.mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull()?.isNotEmpty() == true + } + + assertThat(awaitItem().displayFileTooLargeError).isTrue() + } + } + + private fun TestScope.createAttachmentsPreviewPresenter( + localMedia: LocalMedia = aLocalMedia( + uri = mockMediaUrl, + ), + room: JoinedRoom = FakeJoinedRoom(), + timelineMode: Timeline.Mode = Timeline.Mode.Live, + permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), + mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(), + temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(), + onDoneListener: OnDoneListener = OnDoneListener { lambdaError() }, + displayMediaQualitySelectorViews: Boolean = false, + mediaOptimizationSelectorPresenterFactory: FakeMediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory( + fakePresenter = { + MediaOptimizationSelectorState( + maxUploadSize = AsyncData.Uninitialized, + videoSizeEstimations = AsyncData.Uninitialized, + isImageOptimizationEnabled = null, + selectedVideoPreset = null, + displayMediaSelectorViews = displayMediaQualitySelectorViews, + displayVideoPresetSelectorDialog = false, + eventSink = {}, + ) + } + ), + ): AttachmentsPreviewPresenter { + return AttachmentsPreviewPresenter( + attachment = aMediaAttachment(localMedia), + onDoneListener = onDoneListener, + mediaSenderFactory = MediaSenderFactory { timelineMode -> + DefaultMediaSender( + preProcessor = mediaPreProcessor, + room = room, + timelineMode = timelineMode, + mediaOptimizationConfigProvider = { + MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) + } + ) + }, + permalinkBuilder = permalinkBuilder, + temporaryUriDeleter = temporaryUriDeleter, + sessionCoroutineScope = this, + dispatchers = testCoroutineDispatchers(), + mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory, + timelineMode = timelineMode, + inReplyToEventId = null, + ) + } + + private val mediaUploadInfo = MediaUploadInfo.AnyFile( + File("test"), + FileInfo( + mimetype = MimeTypes.Any, + size = 999L, + thumbnailInfo = null, + thumbnailSource = null, + ) + ) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/SendActionStateTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/SendActionStateTest.kt new file mode 100644 index 0000000..208d9cc --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/SendActionStateTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.attachments.preview.SendActionState +import io.element.android.features.messages.impl.attachments.preview.aMediaUploadInfo +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import org.junit.Test + +class SendActionStateTest { + @Test + fun `mediaUploadInfo() should return the value from Uploading class`() { + val mediaUploadInfo: MediaUploadInfo = aMediaUploadInfo() + val state: SendActionState = SendActionState.Sending.Uploading(mediaUploadInfo = aMediaUploadInfo()) + assertThat(state.mediaUploadInfo()).isEqualTo(mediaUploadInfo) + } + + @Test + fun `mediaUploadInfo() should return the value from ReadyToUpload class`() { + val mediaUploadInfo: MediaUploadInfo = aMediaUploadInfo() + val state: SendActionState = SendActionState.Sending.ReadyToUpload(mediaInfo = aMediaUploadInfo()) + assertThat(state.mediaUploadInfo()).isEqualTo(mediaUploadInfo) + } + + @Test + fun `mediaUploadInfo() should return the value from Failure class`() { + val mediaUploadInfo: MediaUploadInfo = aMediaUploadInfo() + val state: SendActionState = SendActionState.Failure(error = IllegalStateException("An error"), mediaUploadInfo = aMediaUploadInfo()) + assertThat(state.mediaUploadInfo()).isEqualTo(mediaUploadInfo) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt new file mode 100644 index 0000000..aad3e0b --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.attachments.video + +import android.net.Uri +import android.util.Size +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.test.attachments.video.FakeVideoMetadataExtractor +import io.element.android.features.messages.test.attachments.video.FakeVideoMetadataExtractorFactory +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.mediaupload.api.MaxUploadSizeProvider +import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo +import io.element.android.libraries.mediaviewer.api.anImageMediaInfo +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.tests.testutils.WarmUpRule +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.time.Duration.Companion.minutes + +@RunWith(AndroidJUnit4::class) +class DefaultMediaOptimizationSelectorPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private val mockMediaUrl: Uri = mockk("localMediaUri") + + @Test + fun `present - initial state`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().run { + // Loading + assertThat(videoSizeEstimations).isInstanceOf(AsyncData.Loading::class.java) + assertThat(maxUploadSize).isInstanceOf(AsyncData.Loading::class.java) + // Not loaded yet + assertThat(isImageOptimizationEnabled).isNull() + assertThat(selectedVideoPreset).isNull() + assertThat(displayMediaSelectorViews).isNull() + assertThat(displayVideoPresetSelectorDialog).isFalse() + } + + // The data will load after the first recomposition + awaitItem().run { + assertThat(videoSizeEstimations).isInstanceOf(AsyncData.Success::class.java) + assertThat(maxUploadSize).isInstanceOf(AsyncData.Success::class.java) + assertThat(isImageOptimizationEnabled).isTrue() + assertThat(selectedVideoPreset).isEqualTo(VideoCompressionPreset.STANDARD) + assertThat(displayMediaSelectorViews).isTrue() + assertThat(displayVideoPresetSelectorDialog).isFalse() + } + } + } + + @Test + fun `present - if media info is not video, the video state won't load`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter( + localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()) + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip loading state + skipItems(1) + + // The data will load after the first recomposition + awaitItem().run { + assertThat(videoSizeEstimations).isInstanceOf(AsyncData.Uninitialized::class.java) + assertThat(selectedVideoPreset).isNull() + } + } + } + + @Test + fun `present - OpenVideoPresetSelectorDialog displays it, DismissVideoPresetSelectorDialog hides it`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip loading state + val eventSink = awaitItem().eventSink + + assertThat(awaitItem().displayVideoPresetSelectorDialog).isFalse() + + eventSink(MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog) + + assertThat(awaitItem().displayVideoPresetSelectorDialog).isTrue() + + eventSink(MediaOptimizationSelectorEvent.DismissVideoPresetSelectorDialog) + + assertThat(awaitItem().displayVideoPresetSelectorDialog).isFalse() + } + } + + @Test + fun `present - SelectVideoPreset sets it and dismisses the dialog`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip loading state + val eventSink = awaitItem().eventSink + + assertThat(awaitItem().displayVideoPresetSelectorDialog).isFalse() + + eventSink(MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog) + + assertThat(awaitItem().displayVideoPresetSelectorDialog).isTrue() + + eventSink(MediaOptimizationSelectorEvent.SelectVideoPreset(VideoCompressionPreset.LOW)) + + assertThat(awaitItem().selectedVideoPreset).isEqualTo(VideoCompressionPreset.LOW) + assertThat(awaitItem().displayVideoPresetSelectorDialog).isFalse() + } + } + + @Test + fun `present - SelectVideoPreset won't do anything if there is no metadata`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter( + mediaExtractorFactory = FakeVideoMetadataExtractorFactory(FakeVideoMetadataExtractor(sizeResult = Result.failure(AN_EXCEPTION))), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip loading state + val eventSink = awaitItem().eventSink + + assertThat(awaitItem().videoSizeEstimations.dataOrNull()).isNull() + + eventSink(MediaOptimizationSelectorEvent.SelectVideoPreset(VideoCompressionPreset.LOW)) + + ensureAllEventsConsumed() + } + } + + @Test + fun `present - SelectVideoPreset won't select the preset if it won't allow to upload the video`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter( + mediaExtractorFactory = FakeVideoMetadataExtractorFactory( + FakeVideoMetadataExtractor( + sizeResult = Result.success(Size(10_000, 10_000)), + duration = Result.success(10.minutes) + ) + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip loading and loaded states + val eventSink = awaitItem().eventSink + skipItems(1) + + // No video results could be uploaded + awaitItem().run { + val videoSizeEstimations = videoSizeEstimations.dataOrNull() + assertThat(videoSizeEstimations).isNotNull() + assertThat(videoSizeEstimations!!.none { it.canUpload }).isTrue() + } + + eventSink(MediaOptimizationSelectorEvent.SelectVideoPreset(VideoCompressionPreset.HIGH)) + + ensureAllEventsConsumed() + } + } + + @Test + fun `present - SelectImageOptimization sets the new value`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter( + localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip loading state + val eventSink = awaitItem().eventSink + + assertThat(awaitItem().isImageOptimizationEnabled).isTrue() + + eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(false)) + + assertThat(awaitItem().isImageOptimizationEnabled).isFalse() + } + } + + @Test + fun `present - max upload size will default to 100MB if we can't get it`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter( + maxUploadSizeProvider = MaxUploadSizeProvider { Result.failure(AN_EXCEPTION) } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip loading and loaded state + skipItems(1) + assertThat(awaitItem().maxUploadSize.dataOrNull()).isEqualTo(1024 * 1024 * 100) + } + } + + @Test + fun `present - with feature flag disabled won't display the media quality selector views`() = runTest { + val presenter = createDefaultMediaOptimizationSelectorPresenter( + featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to false)), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip loading and loaded state + skipItems(1) + assertThat(awaitItem().displayMediaSelectorViews).isFalse() + } + } + + private fun createDefaultMediaOptimizationSelectorPresenter( + localMedia: LocalMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()), + maxUploadSizeProvider: MaxUploadSizeProvider = MaxUploadSizeProvider { Result.success(1_000L) }, + sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)), + mediaExtractorFactory: FakeVideoMetadataExtractorFactory = FakeVideoMetadataExtractorFactory(), + ): DefaultMediaOptimizationSelectorPresenter { + return DefaultMediaOptimizationSelectorPresenter( + localMedia = localMedia, + maxUploadSizeProvider = maxUploadSizeProvider, + sessionPreferencesStore = sessionPreferencesStore, + featureFlagService = featureFlagService, + mediaExtractorFactory = mediaExtractorFactory, + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenterTest.kt new file mode 100644 index 0000000..6fb193e --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStatePresenterTest.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomMembersState +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.encryption.FakeEncryptionService +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class IdentityChangeStatePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createIdentityChangeStatePresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.roomMemberIdentityStateChanges).isEmpty() + } + } + + @Test + fun `present - when the room emits identity change, the presenter emits new state`() = runTest { + val identityStateChanges = MutableStateFlow(emptyList()) + val room = FakeJoinedRoom(identityStateChangesFlow = identityStateChanges).apply { + givenRoomInfo(aRoomInfo(isEncrypted = true)) + } + val presenter = createIdentityChangeStatePresenter(room) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.roomMemberIdentityStateChanges).isEmpty() + identityStateChanges.emit( + listOf( + IdentityStateChange( + userId = A_USER_ID_2, + identityState = IdentityState.PinViolation, + ), + ) + ) + val finalItem = awaitItem() + assertThat(finalItem.roomMemberIdentityStateChanges).hasSize(1) + val value = finalItem.roomMemberIdentityStateChanges.first() + assertThat(value.identityRoomMember.userId).isEqualTo(A_USER_ID_2) + assertThat(value.identityState).isEqualTo(IdentityState.PinViolation) + } + } + + @Test + fun `present - when the clear room emits identity change, the presenter does not emit new state`() = runTest { + val identityStateChanges = MutableStateFlow(emptyList()) + val room = FakeJoinedRoom( + identityStateChangesFlow = identityStateChanges, + enableEncryptionResult = { Result.success(Unit) } + ) + val presenter = createIdentityChangeStatePresenter(room) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.roomMemberIdentityStateChanges).isEmpty() + identityStateChanges.emit( + listOf( + IdentityStateChange( + userId = A_USER_ID_2, + identityState = IdentityState.PinViolation, + ), + ) + ) + // No item emitted. + expectNoEvents() + // Room becomes encrypted. + room.givenRoomInfo(aRoomInfo(isEncrypted = true)) + + val finalItem = awaitItem() + assertThat(finalItem.roomMemberIdentityStateChanges).hasSize(1) + val value = finalItem.roomMemberIdentityStateChanges.first() + assertThat(value.identityRoomMember.userId).isEqualTo(A_USER_ID_2) + assertThat(value.identityRoomMember.displayNameOrDefault).isEqualTo(A_USER_ID_2.extractedDisplayName) + assertThat(value.identityState).isEqualTo(IdentityState.PinViolation) + } + } + + @Test + fun `present - when the room emits identity change, the presenter emits new state with member details`() = + runTest { + val identityStateChanges = MutableStateFlow(emptyList()) + val room = FakeJoinedRoom(identityStateChangesFlow = identityStateChanges).apply { + givenRoomMembersState( + RoomMembersState.Ready( + listOf( + aRoomMember( + A_USER_ID_2, + displayName = "Alice", + ), + ).toImmutableList() + ) + ) + givenRoomInfo(aRoomInfo(isEncrypted = true)) + } + val presenter = createIdentityChangeStatePresenter(room) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.roomMemberIdentityStateChanges).isEmpty() + identityStateChanges.emit( + listOf( + IdentityStateChange( + userId = A_USER_ID_2, + identityState = IdentityState.PinViolation, + ), + ) + ) + val finalItem = awaitItem() + assertThat(finalItem.roomMemberIdentityStateChanges).hasSize(1) + val value = finalItem.roomMemberIdentityStateChanges.first() + assertThat(value.identityRoomMember.userId).isEqualTo(A_USER_ID_2) + assertThat(value.identityRoomMember.displayNameOrDefault).isEqualTo("Alice") + assertThat(value.identityRoomMember.avatarData.size).isEqualTo(AvatarSize.ComposerAlert) + assertThat(value.identityState).isEqualTo(IdentityState.PinViolation) + } + } + + @Test + fun `present - when the user pins the identity, the presenter invokes the encryption service api`() = + runTest { + val lambda = lambdaRecorder> { Result.success(Unit) } + val encryptionService = FakeEncryptionService( + pinUserIdentityResult = lambda, + ) + val presenter = createIdentityChangeStatePresenter(encryptionService = encryptionService) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(IdentityChangeEvent.PinIdentity(A_USER_ID)) + lambda.assertions().isCalledOnce().with(value(A_USER_ID)) + } + } + + @Test + fun `present - when the user withdraws the identity, the presenter invokes the encryption service api`() = + runTest { + val lambda = lambdaRecorder> { Result.success(Unit) } + val encryptionService = FakeEncryptionService( + withdrawVerificationResult = lambda, + ) + val presenter = createIdentityChangeStatePresenter(encryptionService = encryptionService) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(IdentityChangeEvent.WithdrawVerification(A_USER_ID)) + lambda.assertions().isCalledOnce().with(value(A_USER_ID)) + } + } + + private fun createIdentityChangeStatePresenter( + room: JoinedRoom = FakeJoinedRoom(), + encryptionService: EncryptionService = FakeEncryptionService(), + ): IdentityChangeStatePresenter { + return IdentityChangeStatePresenter( + room = room, + encryptionService = encryptionService, + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateViewTest.kt new file mode 100644 index 0000000..24779ba --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/identity/IdentityChangeStateViewTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.identity + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.designsystem.components.avatar.anAvatarData +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import io.element.android.libraries.matrix.ui.room.IdentityRoomMember +import io.element.android.libraries.matrix.ui.room.RoomMemberIdentityStateChange +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class IdentityChangeStateViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `show and resolve pin violation`() { + val eventsRecorder = EventsRecorder() + rule.setIdentityChangeStateView( + state = anIdentityChangeState( + listOf( + RoomMemberIdentityStateChange( + identityRoomMember = IdentityRoomMember(UserId("@alice:localhost"), "Alice", anAvatarData()), + identityState = IdentityState.PinViolation + ) + ), + eventsRecorder + ), + ) + + rule.onNodeWithText("identity was reset", substring = true).assertExists("should display pin violation warning") + rule.onNodeWithText("@alice:localhost", substring = true).assertExists("should display user mxid") + rule.onNodeWithText("Alice", substring = true).assertExists("should display user displayname") + + rule.clickOn(res = CommonStrings.action_dismiss) + eventsRecorder.assertSingle(IdentityChangeEvent.PinIdentity(UserId("@alice:localhost"))) + } + + @Test + fun `show and resolve verification violation`() { + val eventsRecorder = EventsRecorder() + rule.setIdentityChangeStateView( + state = anIdentityChangeState( + listOf( + RoomMemberIdentityStateChange( + identityRoomMember = IdentityRoomMember(UserId("@alice:localhost"), "Alice", anAvatarData()), + identityState = IdentityState.VerificationViolation + ) + ), + eventsRecorder + ), + ) + + rule.onNodeWithText("identity was reset", substring = true).assertExists("should display verification violation warning") + rule.onNodeWithText("@alice:localhost", substring = true).assertExists("should display user mxid") + rule.onNodeWithText("Alice", substring = true).assertExists("should display user displayname") + + rule.clickOn(res = CommonStrings.crypto_identity_change_withdraw_verification_action) + eventsRecorder.assertSingle(IdentityChangeEvent.WithdrawVerification(UserId("@alice:localhost"))) + } + + @Test + fun `Should not show any banner if no violations`() { + rule.setIdentityChangeStateView( + state = anIdentityChangeState( + listOf( + RoomMemberIdentityStateChange( + identityRoomMember = IdentityRoomMember(UserId("@alice:localhost"), "Alice", anAvatarData()), + identityState = IdentityState.Verified + ), + RoomMemberIdentityStateChange( + identityRoomMember = IdentityRoomMember(UserId("@bob:localhost"), "Bob", anAvatarData()), + identityState = IdentityState.Pinned + ) + ), + ), + ) + + rule.onNodeWithText("identity was reset", substring = true).assertDoesNotExist() + } + + private fun AndroidComposeTestRule.setIdentityChangeStateView( + state: IdentityChangeState, + ) { + setContent { + IdentityChangeStateView( + state = state, + onLinkClick = { _, _ -> }, + ) + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailurePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailurePresenterTest.kt new file mode 100644 index 0000000..7f22de9 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailurePresenterTest.kt @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.sendfailure.resolve + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailure +import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUserSendFailureFactory +import io.element.android.features.messages.impl.fixtures.aMessageEvent +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.architecture.AsyncAction +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_TRANSACTION_ID +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.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class ResolveVerifiedUserSendFailurePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createResolveVerifiedUserSendFailurePresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + } + } + + @Test + fun `present - remote message scenario`() = runTest { + val presenter = createResolveVerifiedUserSendFailurePresenter() + presenter.test { + val sentMessage = aMessageEvent() + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(sentMessage)) + ensureAllEventsConsumed() + } + } + + @Test + fun `present - sent message scenario`() = runTest { + val presenter = createResolveVerifiedUserSendFailurePresenter() + presenter.test { + val sentMessage = aMessageEvent( + sendState = LocalEventSendState.Sent(AN_EVENT_ID) + ) + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(sentMessage)) + ensureAllEventsConsumed() + } + } + + @Test + fun `present - unknown failed message scenario`() = runTest { + val presenter = createResolveVerifiedUserSendFailurePresenter() + presenter.test { + val failedMessage = aMessageEvent( + sendState = LocalEventSendState.Failed.Unknown("") + ) + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage)) + ensureAllEventsConsumed() + } + } + + @Test + fun `present - verified user unsigned device failure dismiss scenario`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + userDisplayNameResult = { userId -> + Result.success(userId.value) + }, + ) + ) + val presenter = createResolveVerifiedUserSendFailurePresenter(room) + presenter.test { + val failedMessage = aVerifiedUserHasUnsignedDeviceFailedMessage() + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage)) + skipItems(1) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.UnsignedDevice.FromYou) + state.eventSink(ResolveVerifiedUserSendFailureEvents.Dismiss) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + } + ensureAllEventsConsumed() + } + } + + @Test + fun `present - verified user unsigned device failure retry scenario`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + userDisplayNameResult = { userId -> + Result.success(userId.value) + }, + ) + ) + val presenter = createResolveVerifiedUserSendFailurePresenter(room) + presenter.test { + val failedMessage = aVerifiedUserHasUnsignedDeviceFailedMessage() + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage)) + + skipItems(1) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.UnsignedDevice.FromYou) + state.eventSink(ResolveVerifiedUserSendFailureEvents.Retry) + } + awaitItem().also { state -> + assertThat(state.retryAction).isEqualTo(AsyncAction.Loading) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + assertThat(state.retryAction).isEqualTo(AsyncAction.Success(Unit)) + } + ensureAllEventsConsumed() + } + } + + @Test + fun `present - verified user unsigned device failure resolve and resend scenario`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + userDisplayNameResult = { userId -> + Result.success(userId.value) + }, + ), + ignoreDeviceTrustAndResendResult = { _, _ -> + Result.success(Unit) + }, + ) + val presenter = createResolveVerifiedUserSendFailurePresenter(room) + presenter.test { + val failedMessage = aVerifiedUserHasUnsignedDeviceFailedMessage() + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage)) + + skipItems(1) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.UnsignedDevice.FromYou) + state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend) + } + awaitItem().also { state -> + assertThat(state.resolveAction).isEqualTo(AsyncAction.Loading) + } + // This should move to the next user + skipItems(2) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.UnsignedDevice.FromOther(A_USER_ID_2.value)) + assertThat(state.resolveAction).isEqualTo(AsyncAction.Success(Unit)) + state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend) + } + + skipItems(3) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + } + ensureAllEventsConsumed() + } + } + + @Test + fun `present - verified user unsigned device failure resolve and resend scenario with error`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + userDisplayNameResult = { userId -> + Result.success(userId.value) + }, + ), + ignoreDeviceTrustAndResendResult = { _, _ -> + Result.failure(Exception()) + }, + ) + val presenter = createResolveVerifiedUserSendFailurePresenter(room) + presenter.test { + val failedMessage = aVerifiedUserHasUnsignedDeviceFailedMessage() + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage)) + + skipItems(1) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.UnsignedDevice.FromYou) + state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend) + } + awaitItem().also { state -> + assertThat(state.resolveAction).isEqualTo(AsyncAction.Loading) + } + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.UnsignedDevice.FromYou) + assertThat(state.resolveAction).isInstanceOf(AsyncAction.Failure::class.java) + } + ensureAllEventsConsumed() + } + } + + @Test + fun `present - verified user changed identity failure retry scenario`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + userDisplayNameResult = { userId -> + Result.success(userId.value) + }, + ) + ) + val presenter = createResolveVerifiedUserSendFailurePresenter(room) + presenter.test { + val failedMessage = aVerifiedUserChangedIdentityMessage() + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage)) + + skipItems(1) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(A_USER_ID.value)) + state.eventSink(ResolveVerifiedUserSendFailureEvents.Retry) + } + awaitItem().also { state -> + assertThat(state.retryAction).isEqualTo(AsyncAction.Loading) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + assertThat(state.retryAction).isEqualTo(AsyncAction.Success(Unit)) + } + ensureAllEventsConsumed() + } + } + + @Test + fun `present - verified user changed identity failure resolve and resend scenario`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + userDisplayNameResult = { userId -> + Result.success(userId.value) + }, + ), + withdrawVerificationAndResendResult = { _, _ -> + Result.success(Unit) + }, + ) + val presenter = createResolveVerifiedUserSendFailurePresenter(room) + presenter.test { + val failedMessage = aVerifiedUserChangedIdentityMessage() + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage)) + + skipItems(1) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(A_USER_ID.value)) + state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend) + } + awaitItem().also { state -> + assertThat(state.resolveAction).isEqualTo(AsyncAction.Loading) + } + // This should move to the next user + skipItems(2) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(A_USER_ID_2.value)) + assertThat(state.resolveAction).isEqualTo(AsyncAction.Success(Unit)) + state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend) + } + + skipItems(3) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + } + ensureAllEventsConsumed() + } + } + + @Test + fun `present - verified user changed identity failure resolve and resend scenario with error`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + userDisplayNameResult = { userId -> + Result.success(userId.value) + }, + ), + withdrawVerificationAndResendResult = { _, _ -> + Result.failure(Exception()) + }, + ) + val presenter = createResolveVerifiedUserSendFailurePresenter(room) + presenter.test { + val failedMessage = aVerifiedUserChangedIdentityMessage() + val initialState = awaitItem() + assertThat(initialState.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.None) + initialState.eventSink(ResolveVerifiedUserSendFailureEvents.ComputeForMessage(failedMessage)) + + skipItems(1) + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(A_USER_ID.value)) + state.eventSink(ResolveVerifiedUserSendFailureEvents.ResolveAndResend) + } + awaitItem().also { state -> + assertThat(state.resolveAction).isEqualTo(AsyncAction.Loading) + } + awaitItem().also { state -> + assertThat(state.verifiedUserSendFailure).isEqualTo(VerifiedUserSendFailure.ChangedIdentity(A_USER_ID.value)) + assertThat(state.resolveAction).isInstanceOf(AsyncAction.Failure::class.java) + } + ensureAllEventsConsumed() + } + } + + private fun aVerifiedUserHasUnsignedDeviceFailedMessage(): TimelineItem.Event { + return aMessageEvent( + transactionId = A_TRANSACTION_ID, + sendState = LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice( + mapOf( + A_USER_ID to emptyList(), + A_USER_ID_2 to emptyList() + ) + ) + ) + } + + private fun aVerifiedUserChangedIdentityMessage(): TimelineItem.Event { + return aMessageEvent( + transactionId = A_TRANSACTION_ID, + sendState = LocalEventSendState.Failed.VerifiedUserChangedIdentity( + listOf(A_USER_ID, A_USER_ID_2) + ) + ) + } + + private fun createResolveVerifiedUserSendFailurePresenter( + room: FakeJoinedRoom = FakeJoinedRoom(), + ): ResolveVerifiedUserSendFailurePresenter { + return ResolveVerifiedUserSendFailurePresenter( + room = room, + verifiedUserSendFailureFactory = VerifiedUserSendFailureFactory(room), + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureViewTest.kt new file mode 100644 index 0000000..df1311b --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureViewTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.sendfailure.resolve + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.setSafeContent +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ResolveVerifiedUserSendFailureViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on resolve and resend emit the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setResolveVerifiedUserSendFailureView( + state = aResolveVerifiedUserSendFailureState( + verifiedUserSendFailure = aChangedIdentitySendFailure(), + eventSink = eventsRecorder, + ), + ) + + rule.clickOn(res = CommonStrings.screen_resolve_send_failure_changed_identity_primary_button_title) + eventsRecorder.assertSingle(ResolveVerifiedUserSendFailureEvents.ResolveAndResend) + } + + @Test + fun `clicking on retry emit the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setResolveVerifiedUserSendFailureView( + state = aResolveVerifiedUserSendFailureState( + verifiedUserSendFailure = aChangedIdentitySendFailure(), + eventSink = eventsRecorder, + ), + ) + + rule.clickOn(res = CommonStrings.action_retry) + eventsRecorder.assertSingle(ResolveVerifiedUserSendFailureEvents.Retry) + } + + private fun AndroidComposeTestRule.setResolveVerifiedUserSendFailureView( + state: ResolveVerifiedUserSendFailureState, + ) { + setSafeContent { + ResolveVerifiedUserSendFailureView(state = state) + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/draft/FakeComposerDraftService.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/draft/FakeComposerDraftService.kt new file mode 100644 index 0000000..2fd73d5 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/draft/FakeComposerDraftService.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.draft + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.room.draft.ComposerDraft + +class FakeComposerDraftService : ComposerDraftService { + var loadDraftLambda: (RoomId, ThreadId?, Boolean) -> ComposerDraft? = { _, _, _ -> null } + override suspend fun loadDraft( + roomId: RoomId, + threadRoot: ThreadId?, + isVolatile: Boolean + ): ComposerDraft? = loadDraftLambda(roomId, threadRoot, isVolatile) + + var saveDraftLambda: (RoomId, ThreadId?, ComposerDraft?, Boolean) -> Unit = { _, _, _, _ -> } + override suspend fun updateDraft( + roomId: RoomId, + threadRoot: ThreadId?, + draft: ComposerDraft?, + isVolatile: Boolean + ) = saveDraftLambda(roomId, threadRoot, draft, isVolatile) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/draft/VolatileComposerDraftStoreTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/draft/VolatileComposerDraftStoreTest.kt new file mode 100644 index 0000000..5433aff --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/draft/VolatileComposerDraftStoreTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.draft + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.draft.ComposerDraft +import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_THREAD_ID +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class VolatileComposerDraftStoreTest { + private val roomId = A_ROOM_ID + private val sut = VolatileComposerDraftStore() + private val draft = ComposerDraft("plainText", "htmlText", ComposerDraftType.NewMessage) + + @Test + fun `when storing a non-null draft and then loading it, it's loaded and removed`() = runTest { + val initialDraft = sut.loadDraft(roomId = roomId, threadRoot = null) + assertThat(initialDraft).isNull() + + sut.updateDraft(roomId = roomId, threadRoot = null, draft = draft) + + val loadedDraft = sut.loadDraft(roomId = roomId, threadRoot = null) + assertThat(loadedDraft).isEqualTo(draft) + + val loadedDraftAfter = sut.loadDraft(roomId = roomId, threadRoot = null) + assertThat(loadedDraftAfter).isNull() + + // In thread: + val threadRoot = A_THREAD_ID + val initialThreadDraft = sut.loadDraft(roomId = roomId, threadRoot = threadRoot) + assertThat(initialThreadDraft).isNull() + + sut.updateDraft(roomId = roomId, threadRoot = threadRoot, draft = draft) + + val loadedThreadDraft = sut.loadDraft(roomId = roomId, threadRoot = threadRoot) + assertThat(loadedThreadDraft).isEqualTo(draft) + + val loadedThreadDraftAfter = sut.loadDraft(roomId = roomId, threadRoot = threadRoot) + assertThat(loadedThreadDraftAfter).isNull() + } + + @Test + fun `when storing a null draft and then loading it, it's removing the previous one`() = runTest { + val initialDraft = sut.loadDraft(roomId = roomId, threadRoot = null) + assertThat(initialDraft).isNull() + + sut.updateDraft(roomId = roomId, threadRoot = null, draft = draft) + sut.updateDraft(roomId = roomId, threadRoot = null, draft = null) + + val loadedDraft = sut.loadDraft(roomId = roomId, threadRoot = null) + assertThat(loadedDraft).isNull() + + // In thread: + val threadRoot = A_THREAD_ID + val initialThreadDraft = sut.loadDraft(roomId = roomId, threadRoot = threadRoot) + assertThat(initialThreadDraft).isNull() + + sut.updateDraft(roomId = roomId, threadRoot = threadRoot, draft = draft) + sut.updateDraft(roomId = roomId, threadRoot = threadRoot, draft = null) + + val loadedThreadDraft = sut.loadDraft(roomId = roomId, threadRoot = threadRoot) + assertThat(loadedThreadDraft).isNull() + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MediaAttachmentFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MediaAttachmentFixtures.kt new file mode 100644 index 0000000..77207d6 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MediaAttachmentFixtures.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.fixtures + +import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.libraries.mediaviewer.api.local.LocalMedia + +fun aMediaAttachment(localMedia: LocalMedia) = Attachment.Media( + localMedia = localMedia, +) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt new file mode 100644 index 0000000..00babf4 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.fixtures + +import io.element.android.features.messages.impl.timeline.aTimelineItemDebugInfo +import io.element.android.features.messages.impl.timeline.aTimelineItemReactions +import io.element.android.features.messages.impl.timeline.model.ReadReceiptData +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts +import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo +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.core.TransactionId +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.matrix.api.timeline.item.event.MessageShieldProvider +import io.element.android.libraries.matrix.api.timeline.item.event.SendHandleProvider +import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemDebugInfoProvider +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.core.FakeSendHandle +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails +import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady +import kotlinx.collections.immutable.toImmutableList + +internal fun aMessageEvent( + eventId: EventId? = AN_EVENT_ID, + transactionId: TransactionId? = null, + isMine: Boolean = true, + isEditable: Boolean = true, + canBeRepliedTo: Boolean = true, + content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = A_MESSAGE, isEdited = false), + inReplyTo: InReplyToDetails? = null, + threadInfo: TimelineItemThreadInfo? = null, + sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID), + debugInfoProvider: TimelineItemDebugInfoProvider = TimelineItemDebugInfoProvider { aTimelineItemDebugInfo() }, + messageShieldProvider: MessageShieldProvider = MessageShieldProvider { null }, + sendHandleProvider: SendHandleProvider = SendHandleProvider { FakeSendHandle() } +) = TimelineItem.Event( + id = UniqueId(eventId?.value.orEmpty()), + eventId = eventId, + transactionId = transactionId, + senderId = A_USER_ID, + senderProfile = aProfileTimelineDetailsReady(displayName = A_USER_NAME), + senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME, size = AvatarSize.TimelineSender), + content = content, + sentTime = "", + isMine = isMine, + isEditable = isEditable, + canBeRepliedTo = canBeRepliedTo, + reactionsState = aTimelineItemReactions(count = 0), + readReceiptState = TimelineItemReadReceipts(emptyList().toImmutableList()), + localSendState = sendState, + inReplyTo = inReplyTo, + threadInfo = threadInfo, + origin = null, + timelineItemDebugInfoProvider = debugInfoProvider, + messageShieldProvider = messageShieldProvider, + sendHandleProvider = sendHandleProvider, +) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt new file mode 100644 index 0000000..c7eb7c0 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/TimelineItemsFactoryFixtures.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.fixtures + +import io.element.android.features.messages.impl.messagesummary.FakeMessageSummaryFormatter +import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory +import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig +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.TimelineItemContentPollFactory +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.utils.FakeTextPillificationHelper +import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider +import io.element.android.features.poll.test.pollcontent.FakePollContentStateFactory +import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter +import io.element.android.libraries.dateformatter.test.FakeDateFormatter +import io.element.android.libraries.eventformatter.api.TimelineEventFormatter +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.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope + +internal fun TestScope.aTimelineItemsFactoryCreator(): TimelineItemsFactory.Creator { + return object : TimelineItemsFactory.Creator { + override fun create(config: TimelineItemsFactoryConfig): TimelineItemsFactory { + return aTimelineItemsFactory(config) + } + } +} + +internal fun TestScope.aTimelineItemsFactory( + config: TimelineItemsFactoryConfig, +): TimelineItemsFactory { + val timelineEventFormatter = aTimelineEventFormatter() + val matrixClient = FakeMatrixClient() + return TimelineItemsFactory( + dispatchers = testCoroutineDispatchers(), + eventItemFactoryCreator = object : TimelineItemEventFactory.Creator { + override fun create(config: TimelineItemsFactoryConfig): TimelineItemEventFactory { + return TimelineItemEventFactory( + contentFactory = TimelineItemContentFactory( + messageFactory = TimelineItemContentMessageFactory( + fileSizeFormatter = FakeFileSizeFormatter(), + fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), + htmlConverterProvider = FakeHtmlConverterProvider(), + permalinkParser = FakePermalinkParser(), + textPillificationHelper = FakeTextPillificationHelper(), + ), + redactedMessageFactory = TimelineItemContentRedactedFactory(), + stickerFactory = TimelineItemContentStickerFactory( + fileSizeFormatter = FakeFileSizeFormatter(), + fileExtensionExtractor = FileExtensionExtractorWithoutValidation() + ), + pollFactory = TimelineItemContentPollFactory(FakePollContentStateFactory()), + utdFactory = TimelineItemContentUTDFactory(), + roomMembershipFactory = TimelineItemContentRoomMembershipFactory(timelineEventFormatter), + profileChangeFactory = TimelineItemContentProfileChangeFactory(timelineEventFormatter), + stateFactory = TimelineItemContentStateFactory(timelineEventFormatter), + failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(), + failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(), + sessionId = matrixClient.sessionId, + ), + matrixClient = matrixClient, + dateFormatter = FakeDateFormatter(), + permalinkParser = FakePermalinkParser(), + config = config, + summaryFormatter = FakeMessageSummaryFormatter(), + ) + } + }, + virtualItemFactory = TimelineItemVirtualFactory( + daySeparatorFactory = TimelineItemDaySeparatorFactory( + FakeDateFormatter() + ), + ), + timelineItemGrouper = TimelineItemGrouper(), + config = config + ) +} + +internal fun aTimelineEventFormatter(): TimelineEventFormatter { + return object : TimelineEventFormatter { + override fun format(content: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): CharSequence? { + return "" + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/DefaultLinkCheckerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/DefaultLinkCheckerTest.kt new file mode 100644 index 0000000..95adf0b --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/DefaultLinkCheckerTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import com.google.common.truth.Truth.assertThat +import io.element.android.wysiwyg.link.Link +import org.junit.Test + +class DefaultLinkCheckerTest { + private val sut = DefaultLinkChecker() + + @Test + fun `when url and text are identical, the link is safe`() { + assertThat(sut.isSafe(Link("url", "url"))).isTrue() + } + + @Test + fun `when url is not safe, the link is safe`() { + assertThat(sut.isSafe(Link("url", "https://example.org"))).isTrue() + } + + @Test + fun `when text is a url, and url is identical the link is safe`() { + assertThat(sut.isSafe(Link("https://example.org", "https://example.org"))).isTrue() + } + + @Test + fun `when url contains RtL char, the link is not safe`() { + assertThat(sut.isSafe(Link("https://example\u202E.org", "text"))).isFalse() + } + + @Test + fun `when text is not a url, the link is safe`() { + assertThat(sut.isSafe(Link("https://example.org", "url"))).isTrue() + } + + @Test + fun `when text is a url and hosts match, the link is safe`() { + assertThat(sut.isSafe(Link("https://example.org/some/path", "https://example.org"))).isTrue() + } + + @Test + fun `when text is a url and hosts do not match, the link is safe`() { + assertThat(sut.isSafe(Link("https://example.org", "https://evil.org"))).isFalse() + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/FakeLinkChecker.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/FakeLinkChecker.kt new file mode 100644 index 0000000..c3bed3a --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/FakeLinkChecker.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.wysiwyg.link.Link + +class FakeLinkChecker( + private val isSafeResult: (Link) -> Boolean = { lambdaError() } +) : LinkChecker { + override fun isSafe(link: Link) = isSafeResult(link) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/LinkPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/LinkPresenterTest.kt new file mode 100644 index 0000000..02292cf --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/LinkPresenterTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import io.element.android.wysiwyg.link.Link +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +val aLink = Link(url = "url", text = "text") + +class LinkPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.linkClick).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - safe link case`() = runTest { + val isSafeResult = lambdaRecorder { + true + } + val presenter = createPresenter( + linkChecker = FakeLinkChecker(isSafeResult = isSafeResult) + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.linkClick).isEqualTo(AsyncAction.Uninitialized) + initialState.eventSink(LinkEvents.OnLinkClick(aLink)) + assertThat(awaitItem().linkClick).isEqualTo(AsyncAction.Loading) + val state = awaitItem() + assertThat(state.linkClick).isEqualTo(AsyncAction.Success(aLink)) + isSafeResult.assertions().isCalledOnce().with(value(aLink)) + } + } + + @Test + fun `present - suspicious link case - cancel`() = runTest { + val presenter = createPresenter( + linkChecker = FakeLinkChecker(isSafeResult = { false }) + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.linkClick).isEqualTo(AsyncAction.Uninitialized) + initialState.eventSink(LinkEvents.OnLinkClick(aLink)) + assertThat(awaitItem().linkClick).isEqualTo(AsyncAction.Loading) + val state = awaitItem() + assertThat(state.linkClick).isEqualTo(ConfirmingLinkClick(aLink)) + state.eventSink(LinkEvents.Cancel) + val finalState = awaitItem() + assertThat(finalState.linkClick).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - suspicious link case - confirm`() = runTest { + val presenter = createPresenter( + linkChecker = FakeLinkChecker(isSafeResult = { false }) + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.linkClick).isEqualTo(AsyncAction.Uninitialized) + initialState.eventSink(LinkEvents.OnLinkClick(aLink)) + assertThat(awaitItem().linkClick).isEqualTo(AsyncAction.Loading) + val state = awaitItem() + assertThat(state.linkClick).isEqualTo(ConfirmingLinkClick(aLink)) + state.eventSink(LinkEvents.Confirm) + val finalState = awaitItem() + assertThat(finalState.linkClick).isEqualTo(AsyncAction.Success(aLink)) + } + } + + private fun createPresenter( + linkChecker: LinkChecker = FakeLinkChecker(), + ) = LinkPresenter( + linkChecker = linkChecker, + ) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/LinkViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/LinkViewTest.kt new file mode 100644 index 0000000..54c95d8 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/link/LinkViewTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.link + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.wysiwyg.link.Link +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LinkViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on cancel emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setLinkView( + aLinkState( + linkClick = ConfirmingLinkClick(aLink), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle( + LinkEvents.Cancel + ) + } + + @Test + fun `clicking on continue emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setLinkView( + aLinkState( + linkClick = ConfirmingLinkClick(aLink), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_continue) + eventsRecorder.assertSingle( + LinkEvents.Confirm + ) + } + + @Test + fun `success state invokes the callback and emits the expected event`() { + val eventsRecorder = EventsRecorder() + ensureCalledOnceWithParam(aLink) { callback -> + rule.setLinkView( + aLinkState( + linkClick = AsyncAction.Success(aLink), + eventSink = eventsRecorder, + ), + onLinkValid = callback, + ) + } + eventsRecorder.assertSingle( + LinkEvents.Cancel + ) + } +} + +private fun AndroidComposeTestRule.setLinkView( + state: LinkState, + onLinkValid: (Link) -> Unit = EnsureNeverCalledWithParam(), +) { + setContent { + LinkView( + state = state, + onLinkValid = onLinkValid, + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultRoomAliasSuggestionsDataSourceTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultRoomAliasSuggestionsDataSourceTest.kt new file mode 100644 index 0000000..5cd3607 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultRoomAliasSuggestionsDataSourceTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.messagecomposer + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.messagecomposer.suggestions.DefaultRoomAliasSuggestionsDataSource +import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestion +import io.element.android.libraries.matrix.test.A_ROOM_ALIAS +import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultRoomAliasSuggestionsDataSourceTest { + @Test + fun `getAllRoomAliasSuggestions must emit a list of room alias suggestions`() = runTest { + val roomListService = FakeRoomListService() + val sut = DefaultRoomAliasSuggestionsDataSource( + roomListService + ) + val aRoomSummaryWithAnAlias = aRoomSummary( + canonicalAlias = A_ROOM_ALIAS + ) + sut.getAllRoomAliasSuggestions().test { + assertThat(awaitItem()).isEmpty() + roomListService.postAllRooms( + listOf( + aRoomSummary(roomId = A_ROOM_ID_2, canonicalAlias = null), + aRoomSummaryWithAnAlias, + ) + ) + assertThat(awaitItem()).isEqualTo( + listOf( + RoomAliasSuggestion( + roomAlias = A_ROOM_ALIAS, + roomId = aRoomSummaryWithAnAlias.roomId, + roomName = aRoomSummaryWithAnAlias.info.name, + roomAvatarUrl = aRoomSummaryWithAnAlias.info.avatarUrl + ) + ) + ) + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/FakeRoomAliasSuggestionsDataSource.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/FakeRoomAliasSuggestionsDataSource.kt new file mode 100644 index 0000000..d33f084 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/FakeRoomAliasSuggestionsDataSource.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.messagecomposer + +import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestion +import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestionsDataSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeRoomAliasSuggestionsDataSource( + initialData: List = emptyList() +) : RoomAliasSuggestionsDataSource { + private val roomAliasSuggestions = MutableStateFlow(initialData) + + override fun getAllRoomAliasSuggestions(): Flow> { + return roomAliasSuggestions + } + + fun emitRoomAliasSuggestions(newData: List) { + roomAliasSuggestions.value = newData + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt new file mode 100644 index 0000000..4a6e777 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -0,0 +1,1611 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.messages.impl.messagecomposer + +import android.net.Uri +import androidx.compose.runtime.remember +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.Composer +import im.vector.app.features.analytics.plan.Interaction +import io.element.android.features.location.api.LocationService +import io.element.android.features.location.test.FakeLocationService +import io.element.android.features.messages.impl.FakeMessagesNavigator +import io.element.android.features.messages.impl.MessagesNavigator +import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.features.messages.impl.draft.ComposerDraftService +import io.element.android.features.messages.impl.draft.FakeComposerDraftService +import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor +import io.element.android.features.messages.impl.timeline.TimelineController +import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter +import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper +import io.element.android.features.messages.impl.utils.TextPillificationHelper +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +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.ThreadId +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.permalink.PermalinkBuilder +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.room.IntentionalMention +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.room.draft.ComposerDraft +import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.TimelineException +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +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_CAPTION +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_ROOM_ID +import io.element.android.libraries.matrix.test.A_TRANSACTION_ID +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_ID_3 +import io.element.android.libraries.matrix.test.A_USER_ID_4 +import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler +import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails +import io.element.android.libraries.mediapickers.api.PickerProvider +import io.element.android.libraries.mediapickers.test.FakePickerProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.mediaupload.api.MediaSenderFactory +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.mediaupload.impl.DefaultMediaSender +import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.libraries.push.test.notifications.conversations.FakeNotificationConversationService +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme +import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.SuggestionType +import io.element.android.libraries.textcomposer.model.TextEditorState +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.waitForPredicate +import io.mockk.mockk +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import uniffi.wysiwyg_composer.MentionsState +import java.io.File + +@Suppress("LargeClass") +@RunWith(RobolectricTestRunner::class) +class MessageComposerPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + 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 mediaPreProcessor = FakeMediaPreProcessor() + private val snackbarDispatcher = SnackbarDispatcher() + private val mockMediaUrl: Uri = mockk("localMediaUri") + private val localMediaFactory = FakeLocalMediaFactory(mockMediaUrl) + private val analyticsService = FakeAnalyticsService() + private val notificationConversationService = FakeNotificationConversationService() + + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.isFullScreen).isFalse() + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") + assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal) + assertThat(initialState.showAttachmentSourcePicker).isFalse() + assertThat(initialState.canShareLocation).isTrue() + } + } + + @Test + fun `present - toggle fullscreen`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(MessageComposerEvent.ToggleFullScreenState) + val fullscreenState = awaitItem() + assertThat(fullscreenState.isFullScreen).isTrue() + fullscreenState.eventSink.invoke(MessageComposerEvent.ToggleFullScreenState) + val notFullscreenState = awaitItem() + assertThat(notFullscreenState.isFullScreen).isFalse() + } + } + + @Test + fun `present - change message`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.textEditorState.setHtml(A_MESSAGE) + assertThat(initialState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + initialState.textEditorState.setHtml("") + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") + } + } + + @Test + fun `present - change mode to edit`() = runTest { + val loadDraftLambda = lambdaRecorder { _: RoomId, _: ThreadId?, _: Boolean -> + ComposerDraft(A_MESSAGE, A_MESSAGE, ComposerDraftType.NewMessage) + } + val updateDraftLambda = lambdaRecorder { _: RoomId, _: ThreadId?, _: ComposerDraft?, _: Boolean -> } + val draftService = FakeComposerDraftService().apply { + this.loadDraftLambda = loadDraftLambda + this.saveDraftLambda = updateDraftLambda + } + val presenter = createPresenter( + draftService = draftService, + ) + moleculeFlow(RecompositionMode.Immediate) { + val state = presenter.present() + remember(state, state.textEditorState.messageHtml()) { state } + }.test { + var state = awaitFirstItem() + val mode = anEditMode(message = ANOTHER_MESSAGE) + state.eventSink.invoke(MessageComposerEvent.SetMode(mode)) + state = awaitItem() + assertThat(state.mode).isEqualTo(mode) + assertThat(state.textEditorState.messageHtml()).isEqualTo(ANOTHER_MESSAGE) + state = backToNormalMode(state) + // The message that was being edited is cleared and volatile draft is loaded + assertThat(state.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + + assert(loadDraftLambda) + .isCalledExactly(2) + .withSequence( + // Automatic load of draft + listOf(value(A_ROOM_ID), value(null), value(false)), + // Load of volatile draft when closing edit mode + listOf(value(A_ROOM_ID), value(null), value(true)) + ) + + assert(updateDraftLambda) + .isCalledOnce() + .with(value(A_ROOM_ID), value(null), any(), value(true)) + } + } + + @Test + fun `present - change mode to edit caption`() = runTest { + val loadDraftLambda = lambdaRecorder { _: RoomId, _: ThreadId?, _: Boolean -> + ComposerDraft(A_MESSAGE, A_MESSAGE, ComposerDraftType.NewMessage) + } + val updateDraftLambda = lambdaRecorder { _: RoomId, _: ThreadId?, _: ComposerDraft?, _: Boolean -> } + val draftService = FakeComposerDraftService().apply { + this.loadDraftLambda = loadDraftLambda + this.saveDraftLambda = updateDraftLambda + } + val presenter = createPresenter( + draftService = draftService, + ) + moleculeFlow(RecompositionMode.Immediate) { + val state = presenter.present() + remember(state, state.textEditorState.messageHtml()) { state } + }.test { + var state = awaitFirstItem() + val mode = anEditCaptionMode(caption = A_CAPTION) + state.eventSink.invoke(MessageComposerEvent.SetMode(mode)) + state = awaitItem() + assertThat(state.mode).isEqualTo(mode) + assertThat(state.textEditorState.messageHtml()).isEqualTo(A_CAPTION) + state = backToNormalMode(state) + // The caption that was being edited is cleared and volatile draft is loaded + assertThat(state.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + + assert(loadDraftLambda) + .isCalledExactly(2) + .withSequence( + // Automatic load of draft + listOf(value(A_ROOM_ID), value(null), value(false)), + // Load of volatile draft when closing edit mode + listOf(value(A_ROOM_ID), value(null), value(true)) + ) + assert(updateDraftLambda) + .isCalledOnce() + .with(value(A_ROOM_ID), value(null), any(), value(true)) + } + } + + @Test + fun `present - change mode to edit caption and send the caption`() = runTest { + val editCaptionLambda = lambdaRecorder { _: EventOrTransactionId, _: String?, _: String? -> + Result.success(Unit) + } + val timeline = FakeTimeline().apply { + this.editCaptionLambda = editCaptionLambda + } + val joinedRoom = FakeJoinedRoom( + liveTimeline = timeline, + typingNoticeResult = { Result.success(Unit) } + ) + val presenter = createPresenter( + room = joinedRoom, + isRichTextEditorEnabled = false, + ) + val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("") }) + presenter.test { + var state = awaitFirstItem() + val mode = anEditCaptionMode(caption = A_CAPTION) + state.eventSink.invoke(MessageComposerEvent.SetMode(mode)) + state = awaitItem() + assertThat(state.mode).isEqualTo(mode) + assertThat(state.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_CAPTION) + state.eventSink.invoke(MessageComposerEvent.SendMessage) + val messageSentState = awaitItem() + assertThat(messageSentState.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo("") + waitForPredicate { analyticsService.capturedEvents.size == 1 } + assertThat(analyticsService.capturedEvents).containsExactly( + Composer( + inThread = false, + isEditing = true, + isReply = false, + messageType = Composer.MessageType.Text, + ) + ) + assert(editCaptionLambda) + .isCalledOnce() + .with(value(AN_EVENT_ID.toEventOrTransactionId()), value(A_CAPTION), value(null)) + } + } + + @Test + fun `present - change mode to reply after edit`() = runTest { + val loadDraftLambda = lambdaRecorder { _: RoomId, _: ThreadId?, _: Boolean -> + ComposerDraft(A_MESSAGE, A_MESSAGE, ComposerDraftType.NewMessage) + } + val updateDraftLambda = lambdaRecorder { _: RoomId, _: ThreadId?, _: ComposerDraft?, _: Boolean -> } + val draftService = FakeComposerDraftService().apply { + this.loadDraftLambda = loadDraftLambda + this.saveDraftLambda = updateDraftLambda + } + val presenter = createPresenter( + draftService = draftService, + ) + moleculeFlow(RecompositionMode.Immediate) { + val state = presenter.present() + remember(state, state.textEditorState.messageHtml()) { state } + }.test { + var state = awaitFirstItem() + val editMode = anEditMode(message = ANOTHER_MESSAGE) + state.eventSink.invoke(MessageComposerEvent.SetMode(editMode)) + state = awaitItem() + assertThat(state.mode).isEqualTo(editMode) + assertThat(state.textEditorState.messageHtml()).isEqualTo(ANOTHER_MESSAGE) + + val replyMode = aReplyMode() + state.eventSink.invoke(MessageComposerEvent.SetMode(replyMode)) + state = awaitItem() + assertThat(state.mode).isEqualTo(replyMode) + assertThat(state.textEditorState.messageHtml()).isEmpty() + + assert(loadDraftLambda) + .isCalledOnce() + .with(value(A_ROOM_ID), value(null), value(false)) + + assert(updateDraftLambda) + .isCalledOnce() + .with(value(A_ROOM_ID), value(null), any(), value(true)) + } + } + + @Test + fun `present - change mode to reply`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + var state = awaitFirstItem() + val mode = aReplyMode() + state.eventSink.invoke(MessageComposerEvent.SetMode(mode)) + state = awaitItem() + assertThat(state.mode).isEqualTo(mode) + assertThat(state.textEditorState.messageHtml()).isEqualTo("") + backToNormalMode(state) + } + } + + @Test + fun `present - cancel reply`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + var state = awaitFirstItem() + val mode = aReplyMode() + state.eventSink.invoke(MessageComposerEvent.SetMode(mode)) + state = awaitItem() + assertThat(state.mode).isEqualTo(mode) + state.textEditorState.setHtml(A_REPLY) + state = backToNormalMode(state) + + // The message typed while replying is not cleared + assertThat(state.textEditorState.messageHtml()).isEqualTo(A_REPLY) + } + } + + @Test + fun `present - send message with rich text enabled`() = runTest { + val presenter = createPresenter( + room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendMessageLambda = { _, _, _ -> Result.success(Unit) } + }, + typingNoticeResult = { Result.success(Unit) } + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + val state = presenter.present() + remember(state, state.textEditorState.messageHtml()) { state } + }.test { + val initialState = awaitFirstItem() + initialState.textEditorState.setHtml(A_MESSAGE) + val withMessageState = awaitItem() + assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + withMessageState.eventSink.invoke(MessageComposerEvent.SendMessage) + val messageSentState = awaitItem() + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") + waitForPredicate { analyticsService.capturedEvents.size == 1 } + assertThat(analyticsService.capturedEvents).containsExactly( + Composer( + inThread = false, + isEditing = false, + isReply = false, + messageType = Composer.MessageType.Text, + ) + ) + } + } + + @Test + fun `present - send message with plain text enabled`() = runTest { + val permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { Result.success("") }) + val presenter = createPresenter( + isRichTextEditorEnabled = false, + room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendMessageLambda = { _, _, _ -> Result.success(Unit) } + }, + typingNoticeResult = { Result.success(Unit) } + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + val state = presenter.present() + val messageMarkdown = state.textEditorState.messageMarkdown(permalinkBuilder) + remember(state, messageMarkdown) { state } + }.test { + val initialState = awaitFirstItem() + initialState.textEditorState.setMarkdown(A_MESSAGE) + val withMessageState = awaitItem() + assertThat(withMessageState.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE) + assertThat(withMessageState.textEditorState.messageHtml()).isNull() + withMessageState.eventSink.invoke(MessageComposerEvent.SendMessage) + val messageSentState = awaitItem() + assertThat(messageSentState.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo("") + waitForPredicate { analyticsService.capturedEvents.size == 1 } + assertThat(analyticsService.capturedEvents).containsExactly( + Composer( + inThread = false, + isEditing = false, + isReply = false, + messageType = Composer.MessageType.Text, + ) + ) + } + } + + @Test + fun `present - edit sent message`() = runTest { + val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List -> + Result.success(Unit) + } + val timeline = FakeTimeline().apply { + this.editMessageLambda = editMessageLambda + } + val joinedRoom = FakeJoinedRoom( + liveTimeline = timeline, + typingNoticeResult = { Result.success(Unit) } + ) + val presenter = createPresenter( + joinedRoom, + ) + moleculeFlow(RecompositionMode.Immediate) { + val state = presenter.present() + remember(state, state.textEditorState.messageHtml()) { state } + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") + val mode = anEditMode() + initialState.eventSink.invoke(MessageComposerEvent.SetMode(mode)) + val withMessageState = awaitItem() + assertThat(withMessageState.mode).isEqualTo(mode) + assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + withMessageState.textEditorState.setHtml(ANOTHER_MESSAGE) + val withEditedMessageState = awaitItem() + assertThat(withEditedMessageState.textEditorState.messageHtml()).isEqualTo(ANOTHER_MESSAGE) + withEditedMessageState.eventSink.invoke(MessageComposerEvent.SendMessage) + skipItems(1) + val messageSentState = awaitItem() + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") + + advanceUntilIdle() + + assert(editMessageLambda) + .isCalledOnce() + .with(value(AN_EVENT_ID.toEventOrTransactionId()), value(ANOTHER_MESSAGE), value(ANOTHER_MESSAGE), any()) + + assertThat(analyticsService.capturedEvents).containsExactly( + Composer( + inThread = false, + isEditing = true, + isReply = false, + messageType = Composer.MessageType.Text, + ) + ) + } + } + + @Test + fun `present - edit sent message event not found`() = runTest { + val timelineEditMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List -> + Result.failure(TimelineException.EventNotFound) + } + val timeline = FakeTimeline().apply { + this.editMessageLambda = timelineEditMessageLambda + } + val roomEditMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List -> + Result.success(Unit) + } + val joinedRoom = FakeJoinedRoom( + liveTimeline = timeline, + typingNoticeResult = { Result.success(Unit) }, + editMessageLambda = roomEditMessageLambda, + ) + val presenter = createPresenter( + joinedRoom, + ) + moleculeFlow(RecompositionMode.Immediate) { + val state = presenter.present() + remember(state, state.textEditorState.messageHtml()) { state } + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") + val mode = anEditMode() + initialState.eventSink.invoke(MessageComposerEvent.SetMode(mode)) + val withMessageState = awaitItem() + assertThat(withMessageState.mode).isEqualTo(mode) + assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + withMessageState.textEditorState.setHtml(ANOTHER_MESSAGE) + val withEditedMessageState = awaitItem() + assertThat(withEditedMessageState.textEditorState.messageHtml()).isEqualTo(ANOTHER_MESSAGE) + withEditedMessageState.eventSink.invoke(MessageComposerEvent.SendMessage) + skipItems(1) + val messageSentState = awaitItem() + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") + + advanceUntilIdle() + + assert(timelineEditMessageLambda) + .isCalledOnce() + .with(value(AN_EVENT_ID.toEventOrTransactionId()), value(ANOTHER_MESSAGE), value(ANOTHER_MESSAGE), any()) + + assert(roomEditMessageLambda) + .isCalledOnce() + .with(value(AN_EVENT_ID), value(ANOTHER_MESSAGE), value(ANOTHER_MESSAGE), any()) + + assertThat(analyticsService.capturedEvents).containsExactly( + Composer( + inThread = false, + isEditing = true, + isReply = false, + messageType = Composer.MessageType.Text, + ) + ) + } + } + + @Test + fun `present - edit not sent message`() = runTest { + val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List -> + Result.success(Unit) + } + val timeline = FakeTimeline().apply { + this.editMessageLambda = editMessageLambda + } + val joinedRoom = FakeJoinedRoom( + liveTimeline = timeline, + typingNoticeResult = { Result.success(Unit) }, + ) + val presenter = createPresenter( + joinedRoom, + ) + moleculeFlow(RecompositionMode.Immediate) { + val state = presenter.present() + remember(state, state.textEditorState.messageHtml()) { state } + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") + val mode = anEditMode(eventOrTransactionId = A_TRANSACTION_ID.toEventOrTransactionId()) + initialState.eventSink.invoke(MessageComposerEvent.SetMode(mode)) + val withMessageState = awaitItem() + assertThat(withMessageState.mode).isEqualTo(mode) + assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + withMessageState.textEditorState.setHtml(ANOTHER_MESSAGE) + val withEditedMessageState = awaitItem() + assertThat(withEditedMessageState.textEditorState.messageHtml()).isEqualTo(ANOTHER_MESSAGE) + withEditedMessageState.eventSink.invoke(MessageComposerEvent.SendMessage) + skipItems(1) + val messageSentState = awaitItem() + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") + + advanceUntilIdle() + + assert(editMessageLambda) + .isCalledOnce() + .with(value(A_TRANSACTION_ID.toEventOrTransactionId()), value(ANOTHER_MESSAGE), value(ANOTHER_MESSAGE), any()) + + assertThat(analyticsService.capturedEvents).containsExactly( + Composer( + inThread = false, + isEditing = true, + isReply = false, + messageType = Composer.MessageType.Text, + ) + ) + } + } + + @Test + fun `present - reply message`() = runTest { + val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List, _: Boolean -> + Result.success(Unit) + } + val timeline = FakeTimeline().apply { + this.replyMessageLambda = replyMessageLambda + } + val joinedRoom = FakeJoinedRoom( + liveTimeline = timeline, + typingNoticeResult = { Result.success(Unit) } + ) + val presenter = createPresenter( + joinedRoom, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") + val mode = aReplyMode() + initialState.eventSink.invoke(MessageComposerEvent.SetMode(mode)) + val state = awaitItem() + assertThat(state.mode).isEqualTo(mode) + assertThat(state.textEditorState.messageHtml()).isEqualTo("") + state.textEditorState.setHtml(A_REPLY) + assertThat(state.textEditorState.messageHtml()).isEqualTo(A_REPLY) + state.eventSink.invoke(MessageComposerEvent.SendMessage) + val messageSentState = awaitItem() + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") + + advanceUntilIdle() + + assert(replyMessageLambda) + .isCalledOnce() + .with(any(), value(A_REPLY), value(A_REPLY), any(), value(false)) + + assertThat(analyticsService.capturedEvents).containsExactly( + Composer( + inThread = false, + isEditing = false, + isReply = true, + messageType = Composer.MessageType.Text, + ) + ) + } + } + + @Test + fun `present - Open attachments menu`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.showAttachmentSourcePicker).isFalse() + initialState.eventSink(MessageComposerEvent.AddAttachment) + assertThat(awaitItem().showAttachmentSourcePicker).isTrue() + } + } + + @Test + fun `present - Dismiss attachments menu`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink(MessageComposerEvent.AddAttachment) + skipItems(1) + + initialState.eventSink(MessageComposerEvent.DismissAttachmentMenu) + assertThat(awaitItem().showAttachmentSourcePicker).isFalse() + } + } + + @Test + fun `present - Pick image from gallery`() = runTest { + val room = FakeJoinedRoom( + typingNoticeResult = { Result.success(Unit) } + ) + val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList, _: EventId? -> } + val navigator = FakeMessagesNavigator( + onPreviewAttachmentLambda = onPreviewAttachmentLambda + ) + val presenter = createPresenter( + room = room, + navigator = navigator, + ) + 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(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink(MessageComposerEvent.PickAttachmentSource.FromGallery) + onPreviewAttachmentLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - Pick video from gallery`() = runTest { + val room = FakeJoinedRoom( + typingNoticeResult = { Result.success(Unit) } + ) + val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList, _: EventId? -> } + val navigator = FakeMessagesNavigator( + onPreviewAttachmentLambda = onPreviewAttachmentLambda + ) + val presenter = createPresenter( + room = room, + navigator = navigator, + ) + 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(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink(MessageComposerEvent.PickAttachmentSource.FromGallery) + onPreviewAttachmentLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - Pick media from gallery & cancel does nothing`() = runTest { + val presenter = createPresenter() + with(pickerProvider) { + givenResult(null) // Simulate a user canceling the flow + givenMimeType(MimeTypes.Images) + } + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink(MessageComposerEvent.PickAttachmentSource.FromGallery) + // No crashes here, otherwise it fails + } + } + + @Test + fun `present - Pick file from storage will open the preview`() = runTest { + val room = FakeJoinedRoom( + typingNoticeResult = { Result.success(Unit) } + ) + val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList, _: EventId? -> } + val navigator = FakeMessagesNavigator( + onPreviewAttachmentLambda = onPreviewAttachmentLambda + ) + val presenter = createPresenter( + room = room, + navigator = navigator, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink(MessageComposerEvent.PickAttachmentSource.FromFiles) + onPreviewAttachmentLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - create poll`() = runTest { + val room = FakeJoinedRoom( + typingNoticeResult = { Result.success(Unit) } + ) + val presenter = createPresenter(room = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink(MessageComposerEvent.AddAttachment) + val attachmentOpenState = awaitItem() + assertThat(attachmentOpenState.showAttachmentSourcePicker).isTrue() + initialState.eventSink(MessageComposerEvent.PickAttachmentSource.Poll) + val finalState = awaitItem() + assertThat(finalState.showAttachmentSourcePicker).isFalse() + } + } + + @Test + fun `present - share location`() = runTest { + val room = FakeJoinedRoom( + typingNoticeResult = { Result.success(Unit) } + ) + val presenter = createPresenter(room = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink(MessageComposerEvent.AddAttachment) + val attachmentOpenState = awaitItem() + assertThat(attachmentOpenState.showAttachmentSourcePicker).isTrue() + initialState.eventSink(MessageComposerEvent.PickAttachmentSource.Location) + val finalState = awaitItem() + assertThat(finalState.showAttachmentSourcePicker).isFalse() + } + } + + @Test + fun `present - Take photo`() = runTest { + val room = FakeJoinedRoom( + typingNoticeResult = { Result.success(Unit) } + ) + val permissionPresenter = FakePermissionsPresenter().apply { setPermissionGranted() } + val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList, _: EventId? -> } + val navigator = FakeMessagesNavigator( + onPreviewAttachmentLambda = onPreviewAttachmentLambda + ) + val presenter = createPresenter( + room = room, + permissionPresenter = permissionPresenter, + navigator = navigator, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink(MessageComposerEvent.PickAttachmentSource.PhotoFromCamera) + onPreviewAttachmentLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - Take photo with permission request`() = runTest { + val room = FakeJoinedRoom( + typingNoticeResult = { Result.success(Unit) } + ) + val permissionPresenter = FakePermissionsPresenter() + val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList, _: EventId? -> } + val navigator = FakeMessagesNavigator( + onPreviewAttachmentLambda = onPreviewAttachmentLambda + ) + val presenter = createPresenter( + room = room, + permissionPresenter = permissionPresenter, + navigator = navigator, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink(MessageComposerEvent.PickAttachmentSource.PhotoFromCamera) + permissionPresenter.setPermissionGranted() + onPreviewAttachmentLambda.assertions().isCalledOnce() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - Record video`() = runTest { + val room = FakeJoinedRoom( + typingNoticeResult = { Result.success(Unit) } + ) + val permissionPresenter = FakePermissionsPresenter().apply { setPermissionGranted() } + val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList, _: EventId? -> } + val navigator = FakeMessagesNavigator( + onPreviewAttachmentLambda = onPreviewAttachmentLambda + ) + val presenter = createPresenter( + room = room, + permissionPresenter = permissionPresenter, + navigator = navigator, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink(MessageComposerEvent.PickAttachmentSource.VideoFromCamera) + onPreviewAttachmentLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - Record video with permission request`() = runTest { + val room = FakeJoinedRoom( + typingNoticeResult = { Result.success(Unit) } + ) + val permissionPresenter = FakePermissionsPresenter() + val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList, _: EventId? -> } + val navigator = FakeMessagesNavigator( + onPreviewAttachmentLambda = onPreviewAttachmentLambda + ) + val presenter = createPresenter( + room = room, + permissionPresenter = permissionPresenter, + navigator = navigator, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink(MessageComposerEvent.PickAttachmentSource.VideoFromCamera) + val permissionState = awaitItem() + assertThat(permissionState.showAttachmentSourcePicker).isFalse() + permissionPresenter.setPermissionGranted() + skipItems(1) + onPreviewAttachmentLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - errors are tracked`() = runTest { + val testException = Exception("Test error") + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink(MessageComposerEvent.Error(testException)) + assertThat(analyticsService.trackedErrors).containsExactly(testException) + } + } + + @Test + fun `present - ToggleTextFormatting toggles text formatting`() = runTest { + val presenter = createPresenter(isRichTextEditorEnabled = false) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.showTextFormatting).isFalse() + initialState.eventSink(MessageComposerEvent.AddAttachment) + val composerOptions = awaitItem() + assertThat(composerOptions.showAttachmentSourcePicker).isTrue() + composerOptions.eventSink(MessageComposerEvent.ToggleTextFormatting(true)) + skipItems(2) // composer options closed + val showTextFormatting = awaitItem() + assertThat(showTextFormatting.showAttachmentSourcePicker).isFalse() + assertThat(showTextFormatting.showTextFormatting).isTrue() + assertThat(analyticsService.capturedEvents).containsExactly( + Interaction(index = null, interactionType = null, name = Interaction.Name.MobileRoomComposerFormattingEnabled) + ) + analyticsService.capturedEvents.clear() + showTextFormatting.eventSink(MessageComposerEvent.ToggleTextFormatting(false)) + skipItems(1) + val finished = awaitItem() + assertThat(finished.showTextFormatting).isFalse() + assertThat(analyticsService.capturedEvents).isEmpty() + } + } + + @Test + fun `present - room member mention suggestions`() = runTest { + val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN) + val invitedUser = aRoomMember(userId = A_USER_ID_3, membership = RoomMembershipState.INVITE) + val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN) + val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN) + var canUserTriggerRoomNotificationResult = true + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom(canUserTriggerRoomNotificationResult = { Result.success(canUserTriggerRoomNotificationResult) }), + typingNoticeResult = { Result.success(Unit) } + ).apply { + givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf(currentUser, invitedUser, bob, david), + ) + ) + givenRoomInfo(aRoomInfo(isDirect = false)) + } + val presenter = createPresenter(room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + // A null suggestion (no suggestion was received) returns nothing + initialState.eventSink(MessageComposerEvent.SuggestionReceived(null)) + assertThat(awaitItem().suggestions).isEmpty() + + // An empty suggestion returns the room and joined members that are not the current user + initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) + assertThat(awaitItem().suggestions) + .containsExactly(ResolvedSuggestion.AtRoom, ResolvedSuggestion.Member(bob), ResolvedSuggestion.Member(david)) + + // A suggestion containing a part of "room" will also return the room mention + initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "roo"))) + assertThat(awaitItem().suggestions).containsExactly(ResolvedSuggestion.AtRoom) + + // A non-empty suggestion will return those joined members whose user id matches it + initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "bob"))) + assertThat(awaitItem().suggestions).containsExactly(ResolvedSuggestion.Member(bob)) + + // A non-empty suggestion will return those joined members whose display name matches it + initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "dave"))) + assertThat(awaitItem().suggestions).containsExactly(ResolvedSuggestion.Member(david)) + + // If the suggestion isn't a mention, no suggestions are returned + initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, ""))) + assertThat(awaitItem().suggestions).isEmpty() + + // If user has no permission to send `@room` mentions, `RoomMemberSuggestion.Room` is not returned + canUserTriggerRoomNotificationResult = false + initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) + assertThat(awaitItem().suggestions) + .containsExactly(ResolvedSuggestion.Member(bob), ResolvedSuggestion.Member(david)) + } + } + + @Test + fun `present - room member mention suggestions in a DM`() = runTest { + val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN) + val invitedUser = aRoomMember(userId = A_USER_ID_3, membership = RoomMembershipState.INVITE) + val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN) + val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN) + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom(canUserTriggerRoomNotificationResult = { Result.success(true) }), + typingNoticeResult = { Result.success(Unit) } + ).apply { + givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf(currentUser, invitedUser, bob, david), + ) + ) + givenRoomInfo( + aRoomInfo( + isDirect = true, + activeMembersCount = 2, + ) + ) + } + val presenter = createPresenter(room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + + // An empty suggestion returns the joined members that are not the current user, but not the room + initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) + skipItems(1) + assertThat(awaitItem().suggestions) + .containsExactly(ResolvedSuggestion.Member(bob), ResolvedSuggestion.Member(david)) + } + } + + fun `present - InsertSuggestion`() = runTest { + val presenter = createPresenter( + permalinkBuilder = FakePermalinkBuilder( + permalinkForUserLambda = { + Result.success("https://matrix.to/#/${A_USER_ID_2.value}") + } + ) + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.textEditorState.setHtml("Hey @bo") + initialState.eventSink(MessageComposerEvent.InsertSuggestion(ResolvedSuggestion.Member(aRoomMember(userId = A_USER_ID_2)))) + + assertThat(initialState.textEditorState.messageHtml()) + .isEqualTo("Hey ${A_USER_ID_2.value}") + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - send messages with intentional mentions`() = runTest { + val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List, _: Boolean -> + Result.success(Unit) + } + val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List -> + Result.success(Unit) + } + val sendMessageResult = lambdaRecorder { _: String, _: String?, _: List -> + Result.success(Unit) + } + val timeline = FakeTimeline().apply { + this.replyMessageLambda = replyMessageLambda + this.editMessageLambda = editMessageLambda + sendMessageLambda = sendMessageResult + } + val room = FakeJoinedRoom( + liveTimeline = timeline, + typingNoticeResult = { Result.success(Unit) } + ) + val presenter = createPresenter(room = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + + // Check intentional mentions on message sent + val mentionUser1 = listOf(A_USER_ID.value) + (initialState.textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState( + userIds = mentionUser1, + roomIds = emptyList(), + roomAliases = emptyList(), + hasAtRoomMention = false + ) + initialState.textEditorState.setHtml(A_MESSAGE) + initialState.eventSink(MessageComposerEvent.SendMessage) + + advanceUntilIdle() + + sendMessageResult.assertions().isCalledOnce() + .with(value(A_MESSAGE), any(), value(listOf(IntentionalMention.User(A_USER_ID)))) + + // Check intentional mentions on reply sent + initialState.eventSink(MessageComposerEvent.SetMode(aReplyMode())) + val mentionUser2 = listOf(A_USER_ID_2.value) + (awaitItem().textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState( + userIds = mentionUser2, + roomIds = emptyList(), + roomAliases = emptyList(), + hasAtRoomMention = false + ) + + initialState.eventSink(MessageComposerEvent.SendMessage) + advanceUntilIdle() + + assert(replyMessageLambda) + .isCalledOnce() + .with(any(), any(), any(), value(listOf(IntentionalMention.User(A_USER_ID_2))), value(false)) + + // Check intentional mentions on edit message + skipItems(1) + initialState.eventSink(MessageComposerEvent.SetMode(anEditMode())) + val mentionUser3 = listOf(A_USER_ID_3.value) + (awaitItem().textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState( + userIds = mentionUser3, + roomIds = emptyList(), + roomAliases = emptyList(), + hasAtRoomMention = false + ) + + initialState.eventSink(MessageComposerEvent.SendMessage) + advanceUntilIdle() + + assert(editMessageLambda) + .isCalledOnce() + .with(any(), any(), any(), value(listOf(IntentionalMention.User(A_USER_ID_3)))) + + skipItems(1) + } + } + + @Test + fun `present - send uri`() = runTest { + val presenter = createPresenter( + room = FakeJoinedRoom( + typingNoticeResult = { Result.success(Unit) }, + liveTimeline = FakeTimeline().apply { + sendFileLambda = { _, _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + } + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + val state = presenter.present() + remember(state, state.textEditorState.messageHtml()) { state } + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(MessageComposerEvent.SendUri(Uri.parse("content://uri"))) + waitForPredicate { mediaPreProcessor.processCallCount == 1 } + } + } + + @Test + fun `present - handle typing notice event`() = runTest { + val typingNoticeResult = lambdaRecorder> { Result.success(Unit) } + val room = FakeJoinedRoom( + typingNoticeResult = typingNoticeResult, + ) + val presenter = createPresenter(room = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + typingNoticeResult.assertions().isNeverCalled() + initialState.eventSink.invoke(MessageComposerEvent.TypingNotice(true)) + initialState.eventSink.invoke(MessageComposerEvent.TypingNotice(false)) + advanceUntilIdle() + typingNoticeResult.assertions().isCalledExactly(2) + .withSequence( + listOf(value(true)), + listOf(value(false)), + ) + } + } + + @Test + fun `present - handle typing notice event when sending typing notice is disabled`() = runTest { + val typingNoticeResult = lambdaRecorder> { Result.success(Unit) } + val room = FakeJoinedRoom( + typingNoticeResult = typingNoticeResult + ) + val store = InMemorySessionPreferencesStore( + isSendTypingNotificationsEnabled = false + ) + val presenter = createPresenter(room = room, sessionPreferencesStore = store) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + typingNoticeResult.assertions().isNeverCalled() + initialState.eventSink.invoke(MessageComposerEvent.TypingNotice(true)) + initialState.eventSink.invoke(MessageComposerEvent.TypingNotice(false)) + typingNoticeResult.assertions().isNeverCalled() + } + } + + @Test + fun `present - when there is no draft, nothing is restored`() = runTest { + val loadDraftLambda = lambdaRecorder { _, _, _ -> null } + val composerDraftService = FakeComposerDraftService().apply { + this.loadDraftLambda = loadDraftLambda + } + val presenter = createPresenter(draftService = composerDraftService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitFirstItem() + assert(loadDraftLambda) + .isCalledOnce() + .with(value(A_ROOM_ID), value(null), value(false)) + + ensureAllEventsConsumed() + } + } + + @Test + fun `present - when there is a draft for new message with plain text, it is restored`() = runTest { + val loadDraftLambda = lambdaRecorder { _, _, _ -> + ComposerDraft(plainText = A_MESSAGE, htmlText = null, draftType = ComposerDraftType.NewMessage) + } + val composerDraftService = FakeComposerDraftService().apply { + this.loadDraftLambda = loadDraftLambda + } + val permalinkBuilder = FakePermalinkBuilder() + val presenter = createPresenter( + draftService = composerDraftService, + permalinkBuilder = permalinkBuilder, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE) + assertThat(state.textEditorState.messageHtml()).isNull() + } + assert(loadDraftLambda) + .isCalledOnce() + .with(value(A_ROOM_ID), value(null), value(false)) + + ensureAllEventsConsumed() + } + } + + @Test + fun `present - when there is a draft for new message with rich text, it is restored`() = runTest { + val loadDraftLambda = lambdaRecorder { _, _, _ -> + ComposerDraft( + plainText = A_MESSAGE, + htmlText = A_MESSAGE, + draftType = ComposerDraftType.NewMessage + ) + } + val composerDraftService = FakeComposerDraftService().apply { + this.loadDraftLambda = loadDraftLambda + } + val permalinkBuilder = FakePermalinkBuilder() + val presenter = createPresenter( + draftService = composerDraftService, + permalinkBuilder = permalinkBuilder, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.showTextFormatting).isTrue() + assertThat(state.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE) + assertThat(state.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + } + assert(loadDraftLambda) + .isCalledOnce() + .with(value(A_ROOM_ID), value(null), value(false)) + ensureAllEventsConsumed() + } + } + + @Test + fun `present - when there is a draft for edit, it is restored`() = runTest { + val loadDraftLambda = lambdaRecorder { _, _, _ -> + ComposerDraft( + plainText = A_MESSAGE, + htmlText = null, + draftType = ComposerDraftType.Edit(AN_EVENT_ID) + ) + } + val composerDraftService = FakeComposerDraftService().apply { + this.loadDraftLambda = loadDraftLambda + } + val permalinkBuilder = FakePermalinkBuilder() + val presenter = createPresenter( + draftService = composerDraftService, + permalinkBuilder = permalinkBuilder, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.showTextFormatting).isFalse() + assertThat(state.mode).isEqualTo(anEditMode()) + assertThat(state.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE) + assertThat(state.textEditorState.messageHtml()).isNull() + } + assert(loadDraftLambda) + .isCalledOnce() + .with(value(A_ROOM_ID), value(null), value(false)) + + ensureAllEventsConsumed() + } + } + + @Test + fun `present - when there is a draft for reply, it is restored`() = runTest { + val loadDraftLambda = lambdaRecorder { _, _, _ -> + ComposerDraft( + plainText = A_MESSAGE, + htmlText = null, + draftType = ComposerDraftType.Reply(AN_EVENT_ID) + ) + } + val composerDraftService = FakeComposerDraftService().apply { + this.loadDraftLambda = loadDraftLambda + } + val loadReplyDetailsLambda = lambdaRecorder { eventId -> + InReplyTo.Pending(eventId) + } + val timeline = FakeTimeline().apply { + this.loadReplyDetailsLambda = loadReplyDetailsLambda + } + val room = FakeJoinedRoom( + liveTimeline = timeline, + typingNoticeResult = { Result.success(Unit) }, + ) + val permalinkBuilder = FakePermalinkBuilder() + val presenter = createPresenter( + room = room, + draftService = composerDraftService, + permalinkBuilder = permalinkBuilder, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.showTextFormatting).isFalse() + assertThat(state.mode).isEqualTo(aReplyMode()) + assertThat(state.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE) + assertThat(state.textEditorState.messageHtml()).isNull() + } + assert(loadDraftLambda) + .isCalledOnce() + .with(value(A_ROOM_ID), value(null), value(false)) + + assert(loadReplyDetailsLambda) + .isCalledOnce() + .with(value(AN_EVENT_ID)) + + ensureAllEventsConsumed() + } + } + + @Test + fun `present - when save draft event is invoked and composer is empty then service is called with null draft`() = runTest { + val saveDraftLambda = lambdaRecorder { _, _, _, _ -> } + val composerDraftService = FakeComposerDraftService().apply { + this.saveDraftLambda = saveDraftLambda + } + val presenter = createPresenter(draftService = composerDraftService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(MessageComposerEvent.SaveDraft) + advanceUntilIdle() + assert(saveDraftLambda) + .isCalledOnce() + .with(value(A_ROOM_ID), value(null), value(null), value(false)) + } + } + + @Test + fun `present - when save draft event is invoked and composer is not empty then service is called`() = runTest { + val saveDraftLambda = lambdaRecorder { _, _, _, _ -> } + val composerDraftService = FakeComposerDraftService().apply { + this.saveDraftLambda = saveDraftLambda + } + val permalinkBuilder = FakePermalinkBuilder() + val presenter = createPresenter( + isRichTextEditorEnabled = false, + draftService = composerDraftService, + permalinkBuilder = permalinkBuilder, + ) + moleculeFlow(RecompositionMode.Immediate) { + val state = presenter.present() + val messageMarkdown = state.textEditorState.messageMarkdown(permalinkBuilder) + remember(state, messageMarkdown) { state } + }.test { + val initialState = awaitFirstItem() + initialState.textEditorState.setMarkdown(A_MESSAGE) + + val withMessageState = awaitItem() + assertThat(withMessageState.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE) + withMessageState.eventSink(MessageComposerEvent.SaveDraft) + advanceUntilIdle() + + withMessageState.eventSink(MessageComposerEvent.ToggleTextFormatting(true)) + skipItems(1) + val withFormattingState = awaitItem() + assertThat(withFormattingState.showTextFormatting).isTrue() + withFormattingState.eventSink(MessageComposerEvent.SaveDraft) + advanceUntilIdle() + + withFormattingState.eventSink(MessageComposerEvent.SetMode(anEditMode())) + val withEditModeState = awaitItem() + assertThat(withEditModeState.mode).isEqualTo(anEditMode()) + withEditModeState.eventSink(MessageComposerEvent.SaveDraft) + advanceUntilIdle() + + withEditModeState.eventSink(MessageComposerEvent.SetMode(aReplyMode())) + val withReplyModeState = awaitItem() + assertThat(withReplyModeState.mode).isEqualTo(aReplyMode()) + withReplyModeState.eventSink(MessageComposerEvent.SaveDraft) + advanceUntilIdle() + + assert(saveDraftLambda) + .isCalledExactly(5) + .withSequence( + listOf( + value(A_ROOM_ID), + value(null), + value(ComposerDraft(plainText = A_MESSAGE, htmlText = null, draftType = ComposerDraftType.NewMessage)), + value(false) + ), + listOf( + value(A_ROOM_ID), + value(null), + value(ComposerDraft(plainText = A_MESSAGE, htmlText = A_MESSAGE, draftType = ComposerDraftType.NewMessage)), + value(false) + ), + listOf( + value(A_ROOM_ID), + value(null), + value(ComposerDraft(plainText = A_MESSAGE, htmlText = A_MESSAGE, draftType = ComposerDraftType.NewMessage)), + // The volatile draft created when switching to edit mode. + value(true) + ), + listOf( + value(A_ROOM_ID), + value(null), + value(ComposerDraft(plainText = A_MESSAGE, htmlText = A_MESSAGE, draftType = ComposerDraftType.Edit(AN_EVENT_ID))), + value(false) + ), + listOf( + value(A_ROOM_ID), + value(null), + // When moving from edit mode, text composer is cleared, so the draft is null + value(null), + value(false) + ) + ) + } + } + + private suspend fun ReceiveTurbine.backToNormalMode(state: MessageComposerState, skipCount: Int = 0): MessageComposerState { + state.eventSink.invoke(MessageComposerEvent.CloseSpecialMode) + skipItems(skipCount) + val normalState = awaitItem() + assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal) + return normalState + } + + private fun TestScope.createPresenter( + room: JoinedRoom = FakeJoinedRoom( + typingNoticeResult = { Result.success(Unit) } + ), + timeline: Timeline = room.liveTimeline, + navigator: MessagesNavigator = FakeMessagesNavigator(), + pickerProvider: PickerProvider = this@MessageComposerPresenterTest.pickerProvider, + locationService: LocationService = FakeLocationService(true), + sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(), + mediaPreProcessor: MediaPreProcessor = this@MessageComposerPresenterTest.mediaPreProcessor, + snackbarDispatcher: SnackbarDispatcher = this@MessageComposerPresenterTest.snackbarDispatcher, + permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(), + permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), + permalinkParser: PermalinkParser = FakePermalinkParser(), + mentionSpanProvider: MentionSpanProvider = MentionSpanProvider( + permalinkParser = permalinkParser, + mentionSpanFormatter = FakeMentionSpanFormatter(), + mentionSpanTheme = MentionSpanTheme(A_USER_ID) + ), + textPillificationHelper: TextPillificationHelper = FakeTextPillificationHelper(), + isRichTextEditorEnabled: Boolean = true, + draftService: ComposerDraftService = FakeComposerDraftService(), + mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), + ) = MessageComposerPresenter( + navigator = navigator, + sessionCoroutineScope = this, + room = room, + mediaPickerProvider = pickerProvider, + sessionPreferencesStore = sessionPreferencesStore, + localMediaFactory = localMediaFactory, + mediaSenderFactory = MediaSenderFactory { timelineMode -> + DefaultMediaSender( + preProcessor = mediaPreProcessor, + room = room, + timelineMode = timelineMode, + mediaOptimizationConfigProvider = { + MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD + ) + } + ) + }, + snackbarDispatcher = snackbarDispatcher, + analyticsService = analyticsService, + locationService = locationService, + messageComposerContext = DefaultMessageComposerContext(), + richTextEditorStateFactory = TestRichTextEditorStateFactory(), + roomAliasSuggestionsDataSource = FakeRoomAliasSuggestionsDataSource(), + permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), + permalinkParser = permalinkParser, + permalinkBuilder = permalinkBuilder, + timelineController = TimelineController(room, timeline), + draftService = draftService, + mentionSpanProvider = mentionSpanProvider, + pillificationHelper = textPillificationHelper, + suggestionsProcessor = SuggestionsProcessor(), + mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, + notificationConversationService = notificationConversationService, + ).apply { + isTesting = true + showTextFormatting = isRichTextEditorEnabled + } + + private suspend fun ReceiveTurbine.awaitFirstItem(): T { + skipItems(1) + return awaitItem() + } +} + +fun anEditMode( + eventOrTransactionId: EventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(), + message: String = A_MESSAGE, +) = MessageComposerMode.Edit(eventOrTransactionId, message) + +fun anEditCaptionMode( + eventOrTransactionId: EventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(), + caption: String = A_CAPTION, +) = MessageComposerMode.EditCaption( + eventOrTransactionId = eventOrTransactionId, + content = caption, +) + +fun aReplyMode() = MessageComposerMode.Reply( + replyToDetails = InReplyToDetails.Loading(AN_EVENT_ID), + hideImage = false, +) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/TestRichTextEditorStateFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/TestRichTextEditorStateFactory.kt new file mode 100644 index 0000000..41acf8b --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/TestRichTextEditorStateFactory.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.messagecomposer + +import androidx.compose.runtime.Composable +import io.element.android.wysiwyg.compose.RichTextEditorState +import io.element.android.wysiwyg.compose.rememberRichTextEditorState + +class TestRichTextEditorStateFactory : RichTextEditorStateFactory { + @Composable + override fun remember(): RichTextEditorState { + return rememberRichTextEditorState("", fake = true) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt new file mode 100644 index 0000000..daba41f --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt @@ -0,0 +1,341 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.messagecomposer.suggestions + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.test.A_ROOM_ALIAS +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.aRoomMember +import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion +import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.SuggestionType +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SuggestionsProcessorTest { + private fun aMentionSuggestion(text: String) = Suggestion(0, 1, SuggestionType.Mention, text) + private fun aRoomSuggestion(text: String) = Suggestion(0, 1, SuggestionType.Room, text) + private val aCommandSuggestion = Suggestion(0, 1, SuggestionType.Command, "") + private val aCustomSuggestion = Suggestion(0, 1, SuggestionType.Custom("*"), "") + + private val suggestionsProcessor = SuggestionsProcessor() + + @Test + fun `processing null suggestion will return empty suggestion`() = runTest { + val result = suggestionsProcessor.process( + suggestion = null, + roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember())), + roomAliasSuggestions = emptyList(), + currentUserId = A_USER_ID, + canSendRoomMention = { true }, + ) + assertThat(result).isEmpty() + } + + @Test + fun `processing Command will return empty suggestion`() = runTest { + val result = suggestionsProcessor.process( + suggestion = aCommandSuggestion, + roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember())), + roomAliasSuggestions = emptyList(), + currentUserId = A_USER_ID, + canSendRoomMention = { true }, + ) + assertThat(result).isEmpty() + } + + @Test + fun `processing Custom will return empty suggestion`() = runTest { + val result = suggestionsProcessor.process( + suggestion = aCustomSuggestion, + roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember())), + roomAliasSuggestions = emptyList(), + currentUserId = A_USER_ID, + canSendRoomMention = { true }, + ) + assertThat(result).isEmpty() + } + + @Test + fun `processing Mention suggestion with not loaded members will return empty suggestion`() = runTest { + val result = suggestionsProcessor.process( + suggestion = aMentionSuggestion(""), + roomMembersState = RoomMembersState.Unknown, + roomAliasSuggestions = emptyList(), + currentUserId = A_USER_ID, + canSendRoomMention = { true }, + ) + assertThat(result).isEmpty() + } + + @Test + fun `processing Mention suggestion with no members will return empty suggestion`() = runTest { + val result = suggestionsProcessor.process( + suggestion = aMentionSuggestion(""), + roomMembersState = RoomMembersState.Ready(persistentListOf()), + roomAliasSuggestions = emptyList(), + currentUserId = A_USER_ID, + canSendRoomMention = { true }, + ) + assertThat(result).isEmpty() + } + + @Test + fun `processing Room suggestion with no aliases will return empty suggestion`() = runTest { + val result = suggestionsProcessor.process( + suggestion = aRoomSuggestion(""), + roomMembersState = RoomMembersState.Ready(persistentListOf()), + roomAliasSuggestions = emptyList(), + currentUserId = A_USER_ID, + canSendRoomMention = { true }, + ) + assertThat(result).isEmpty() + } + + @Test + fun `processing Room suggestion with aliases ignoring cases will return a suggestion`() = runTest { + val aRoomSummary = aRoomSummary(canonicalAlias = A_ROOM_ALIAS) + val result = suggestionsProcessor.process( + suggestion = aRoomSuggestion("ALI"), + roomMembersState = RoomMembersState.Ready(persistentListOf()), + roomAliasSuggestions = listOf( + RoomAliasSuggestion( + roomAlias = A_ROOM_ALIAS, + roomId = aRoomSummary.roomId, + roomName = aRoomSummary.info.name, + roomAvatarUrl = aRoomSummary.info.avatarUrl, + ) + ), + currentUserId = A_USER_ID, + canSendRoomMention = { true }, + ) + assertThat(result).isEqualTo( + listOf( + ResolvedSuggestion.Alias( + roomAlias = A_ROOM_ALIAS, + roomId = aRoomSummary.roomId, + roomName = aRoomSummary.info.name, + roomAvatarUrl = aRoomSummary.info.avatarUrl, + ) + ) + ) + } + + @Test + fun `processing Room suggestion with aliases will return a suggestion when matching on alias`() = runTest { + val aRoomSummary = aRoomSummary(canonicalAlias = A_ROOM_ALIAS) + val result = suggestionsProcessor.process( + suggestion = aRoomSuggestion("ali"), + roomMembersState = RoomMembersState.Ready(persistentListOf()), + roomAliasSuggestions = listOf( + RoomAliasSuggestion( + roomAlias = A_ROOM_ALIAS, + roomId = aRoomSummary.roomId, + roomName = aRoomSummary.info.name, + roomAvatarUrl = aRoomSummary.info.avatarUrl, + ) + ), + currentUserId = A_USER_ID, + canSendRoomMention = { true }, + ) + assertThat(result).isEqualTo( + listOf( + ResolvedSuggestion.Alias( + roomAlias = A_ROOM_ALIAS, + roomId = aRoomSummary.roomId, + roomName = aRoomSummary.info.name, + roomAvatarUrl = aRoomSummary.info.avatarUrl, + ) + ) + ) + } + + @Test + fun `processing Room suggestion with aliases not found will return no suggestions`() = runTest { + val aRoomSummary = aRoomSummary(canonicalAlias = A_ROOM_ALIAS) + val result = suggestionsProcessor.process( + suggestion = aRoomSuggestion("tot"), + roomMembersState = RoomMembersState.Ready(persistentListOf()), + roomAliasSuggestions = listOf( + RoomAliasSuggestion( + roomAlias = A_ROOM_ALIAS, + roomId = aRoomSummary.roomId, + roomName = "Element", + roomAvatarUrl = aRoomSummary.info.avatarUrl, + ) + ), + currentUserId = A_USER_ID, + canSendRoomMention = { true }, + ) + assertThat(result).isEmpty() + } + + @Test + fun `processing Room suggestion will return a suggestion when matching on room name`() = runTest { + val aRoomSummary = aRoomSummary(canonicalAlias = A_ROOM_ALIAS) + val result = suggestionsProcessor.process( + suggestion = aRoomSuggestion("lement"), + roomMembersState = RoomMembersState.Ready(persistentListOf()), + roomAliasSuggestions = listOf( + RoomAliasSuggestion( + roomAlias = A_ROOM_ALIAS, + roomId = aRoomSummary.roomId, + roomName = "Element", + roomAvatarUrl = aRoomSummary.info.avatarUrl, + ) + ), + currentUserId = A_USER_ID, + canSendRoomMention = { true }, + ) + assertThat(result).isEqualTo( + listOf( + ResolvedSuggestion.Alias( + roomAlias = A_ROOM_ALIAS, + roomId = aRoomSummary.roomId, + roomName = "Element", + roomAvatarUrl = aRoomSummary.info.avatarUrl, + ) + ) + ) + } + + @Test + fun `processing Room suggestion will not return a suggestion when room has no name`() = runTest { + val aRoomSummary = aRoomSummary(canonicalAlias = A_ROOM_ALIAS) + val result = suggestionsProcessor.process( + suggestion = aRoomSuggestion("lement"), + roomMembersState = RoomMembersState.Ready(persistentListOf()), + roomAliasSuggestions = listOf( + RoomAliasSuggestion( + roomAlias = A_ROOM_ALIAS, + roomId = aRoomSummary.roomId, + roomName = null, + roomAvatarUrl = aRoomSummary.info.avatarUrl, + ) + ), + currentUserId = A_USER_ID, + canSendRoomMention = { true }, + ) + assertThat(result).isEmpty() + } + + @Test + fun `processing Mention suggestion with return matching matrix Id`() = runTest { + val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = null) + val result = suggestionsProcessor.process( + suggestion = aMentionSuggestion("ali"), + roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember)), + roomAliasSuggestions = emptyList(), + currentUserId = A_USER_ID_2, + canSendRoomMention = { true }, + ) + assertThat(result).isEqualTo( + listOf( + ResolvedSuggestion.Member(aRoomMember) + ) + ) + } + + @Test + fun `processing Mention suggestion with not return the current user`() = runTest { + val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = null) + val result = suggestionsProcessor.process( + suggestion = aMentionSuggestion("ali"), + roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember)), + roomAliasSuggestions = emptyList(), + currentUserId = UserId("@alice:server.org"), + canSendRoomMention = { true }, + ) + assertThat(result).isEmpty() + } + + @Test + fun `processing Mention suggestion with return empty list if there is no matches`() = runTest { + val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = "alice") + val result = suggestionsProcessor.process( + suggestion = aMentionSuggestion("bo"), + roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember)), + roomAliasSuggestions = emptyList(), + currentUserId = A_USER_ID_2, + canSendRoomMention = { true }, + ) + assertThat(result).isEmpty() + } + + @Test + fun `processing Mention suggestion with not return not joined member`() = runTest { + val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), membership = RoomMembershipState.INVITE) + val result = suggestionsProcessor.process( + suggestion = aMentionSuggestion("ali"), + roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember)), + roomAliasSuggestions = emptyList(), + currentUserId = A_USER_ID_2, + canSendRoomMention = { true }, + ) + assertThat(result).isEmpty() + } + + @Test + fun `processing Mention suggestion with return matching display name`() = runTest { + val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = "bob") + val result = suggestionsProcessor.process( + suggestion = aMentionSuggestion("bo"), + roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember)), + roomAliasSuggestions = emptyList(), + currentUserId = A_USER_ID_2, + canSendRoomMention = { true }, + ) + assertThat(result).isEqualTo( + listOf( + ResolvedSuggestion.Member(aRoomMember) + ) + ) + } + + @Test + fun `processing Mention suggestion with return matching display name and room if allowed`() = runTest { + val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = "ro") + val result = suggestionsProcessor.process( + suggestion = aMentionSuggestion("ro"), + roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember)), + roomAliasSuggestions = emptyList(), + currentUserId = A_USER_ID_2, + canSendRoomMention = { true }, + ) + assertThat(result).isEqualTo( + listOf( + ResolvedSuggestion.AtRoom, + ResolvedSuggestion.Member(aRoomMember), + ) + ) + } + + @Test + fun `processing Mention suggestion with return matching display name but not room if not allowed`() = runTest { + val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = "ro") + val result = suggestionsProcessor.process( + suggestion = aMentionSuggestion("ro"), + roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember)), + roomAliasSuggestions = emptyList(), + currentUserId = A_USER_ID_2, + canSendRoomMention = { false }, + ) + assertThat(result).isEqualTo( + listOf( + ResolvedSuggestion.Member(aRoomMember), + ) + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagesummary/FakeMessageSummaryFormatter.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagesummary/FakeMessageSummaryFormatter.kt new file mode 100644 index 0000000..681bbf0 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagesummary/FakeMessageSummaryFormatter.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.messagesummary + +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter + +class FakeMessageSummaryFormatter : MessageSummaryFormatter { + private var result = "A message" + + override fun format(content: TimelineItemEventContent): String = result + + fun givenMessageResult(value: String) { + result = value + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt new file mode 100644 index 0000000..d8269f8 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.banner + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider +import io.element.android.libraries.eventformatter.test.FakePinnedMessagesBannerFormatter +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +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_UNIQUE_ID +import io.element.android.libraries.matrix.test.A_UNIQUE_ID_2 +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.sync.FakeSyncService +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.matrix.test.timeline.aMessageContent +import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PinnedMessagesBannerPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createPinnedMessagesBannerPresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(PinnedMessagesBannerState.Hidden) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - loading state`() = runTest { + val room = FakeJoinedRoom( + createTimelineResult = { Result.success(FakeTimeline()) } + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) + } + val presenter = createPinnedMessagesBannerPresenter(room = room) + presenter.test { + skipItems(2) + val loadingState = awaitItem() as PinnedMessagesBannerState.Loading + assertThat(loadingState).isEqualTo(PinnedMessagesBannerState.Loading(1)) + assertThat(loadingState.pinnedMessagesCount()).isEqualTo(1) + assertThat(loadingState.currentPinnedMessageIndex()).isEqualTo(0) + } + } + + @Test + fun `present - loaded state`() = runTest { + val messageContent = aMessageContent("A message") + val pinnedEventsTimeline = FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + eventId = AN_EVENT_ID, + content = messageContent, + ), + ) + ) + ) + ) + val room = FakeJoinedRoom( + createTimelineResult = { Result.success(pinnedEventsTimeline) } + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID, AN_EVENT_ID_2))) + } + val presenter = createPinnedMessagesBannerPresenter(room = room) + presenter.test { + skipItems(3) + val loadedState = awaitItem() as PinnedMessagesBannerState.Loaded + assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(0) + assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(1) + assertThat(loadedState.currentPinnedMessage.formatted.text).isEqualTo(messageContent.toString()) + } + } + + @Test + fun `present - loaded state - multiple pinned messages`() = runTest { + val messageContent1 = aMessageContent("A message") + val messageContent2 = aMessageContent("Another message") + val pinnedEventsTimeline = FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + eventId = AN_EVENT_ID, + content = messageContent1, + ), + ), + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID_2, + event = anEventTimelineItem( + eventId = AN_EVENT_ID_2, + content = messageContent2, + ), + ) + ) + ) + ) + val room = FakeJoinedRoom( + createTimelineResult = { Result.success(pinnedEventsTimeline) } + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID, AN_EVENT_ID_2))) + } + val presenter = createPinnedMessagesBannerPresenter(room = room) + presenter.test { + skipItems(3) + awaitItem().also { loadedState -> + loadedState as PinnedMessagesBannerState.Loaded + assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(1) + assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(2) + assertThat(loadedState.currentPinnedMessage.formatted.text).isEqualTo(messageContent2.toString()) + loadedState.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned) + } + + awaitItem().also { loadedState -> + loadedState as PinnedMessagesBannerState.Loaded + assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(0) + assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(2) + assertThat(loadedState.currentPinnedMessage.formatted.text).isEqualTo(messageContent1.toString()) + loadedState.eventSink(PinnedMessagesBannerEvents.MoveToNextPinned) + } + + awaitItem().also { loadedState -> + loadedState as PinnedMessagesBannerState.Loaded + assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(1) + assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(2) + assertThat(loadedState.currentPinnedMessage.formatted.text).isEqualTo(messageContent2.toString()) + } + } + } + + @Test + fun `present - timeline failed`() = runTest { + val room = FakeJoinedRoom( + createTimelineResult = { Result.failure(Exception()) } + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) + } + val presenter = createPinnedMessagesBannerPresenter(room = room) + presenter.test { + skipItems(2) + awaitItem().also { state -> + val loadingState = state as PinnedMessagesBannerState.Loading + assertThat(loadingState).isEqualTo(PinnedMessagesBannerState.Loading(1)) + assertThat(loadingState.pinnedMessagesCount()).isEqualTo(1) + assertThat(loadingState.currentPinnedMessageIndex()).isEqualTo(0) + } + awaitItem().also { failedState -> + assertThat(failedState).isEqualTo(PinnedMessagesBannerState.Hidden) + } + } + } + + private fun TestScope.createPinnedMessagesBannerPresenter( + room: JoinedRoom = FakeJoinedRoom(), + itemFactory: PinnedMessagesBannerItemFactory = PinnedMessagesBannerItemFactory( + coroutineDispatchers = testCoroutineDispatchers(), + formatter = FakePinnedMessagesBannerFormatter( + formatLambda = { event -> "${event.content}" } + ) + ), + syncService: SyncService = FakeSyncService(), + ): PinnedMessagesBannerPresenter { + val timelineProvider = createPinnedEventsTimelineProvider( + room = room, + syncService = syncService, + ) + timelineProvider.launchIn(backgroundScope) + + return PinnedMessagesBannerPresenter( + room = room, + itemFactory = itemFactory, + pinnedEventsTimelineProvider = timelineProvider, + ) + } +} + +internal fun TestScope.createPinnedEventsTimelineProvider( + room: JoinedRoom = FakeJoinedRoom(), + syncService: SyncService = FakeSyncService(), +) = DefaultPinnedEventsTimelineProvider( + room = room, + syncService = syncService, + dispatchers = testCoroutineDispatchers(), +) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerViewTest.kt new file mode 100644 index 0000000..a7bbdf9 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerViewTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.banner + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PinnedMessagesBannerViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on the banner invoke expected callback`() { + val eventsRecorder = EventsRecorder() + val state = aLoadedPinnedMessagesBannerState( + eventSink = eventsRecorder + ) + val pinnedEventId = state.currentPinnedMessage.eventId + ensureCalledOnceWithParam(pinnedEventId) { callback -> + rule.setPinnedMessagesBannerView( + state = state, + onClick = callback + ) + rule.onRoot().performClick() + eventsRecorder.assertSingle(PinnedMessagesBannerEvents.MoveToNextPinned) + } + } + + @Test + fun `clicking on view all emit the expected event`() { + val eventsRecorder = EventsRecorder(expectEvents = true) + val state = aLoadedPinnedMessagesBannerState( + eventSink = eventsRecorder + ) + ensureCalledOnce { callback -> + rule.setPinnedMessagesBannerView( + state = state, + onViewAllClick = callback + ) + rule.clickOn(CommonStrings.screen_room_pinned_banner_view_all_button_title) + } + } +} + +private fun AndroidComposeTestRule.setPinnedMessagesBannerView( + state: PinnedMessagesBannerState, + onClick: (EventId) -> Unit = EnsureNeverCalledWithParam(), + onViewAllClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + PinnedMessagesBannerView( + state = state, + onClick = onClick, + onViewAllClick = onViewAllClick + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/FakePinnedMessagesListNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/FakePinnedMessagesListNavigator.kt new file mode 100644 index 0000000..479139a --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/FakePinnedMessagesListNavigator.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.list + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo + +class FakePinnedMessagesListNavigator : PinnedMessagesListNavigator { + var onViewInTimelineClickLambda: ((EventId) -> Unit)? = null + override fun viewInTimeline(eventId: EventId) { + onViewInTimelineClickLambda?.invoke(eventId) + } + + var onShowEventDebugInfoClickLambda: ((EventId?, TimelineItemDebugInfo) -> Unit)? = null + override fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { + onShowEventDebugInfoClickLambda?.invoke(eventId, debugInfo) + } + + var onForwardEventClickLambda: ((EventId) -> Unit)? = null + override fun forwardEvent(eventId: EventId) { + onForwardEventClickLambda?.invoke(eventId) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt new file mode 100644 index 0000000..8807951 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.list + +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.PinUnpinAction +import io.element.android.features.messages.impl.actionlist.anActionListState +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator +import io.element.android.features.messages.impl.link.aLinkState +import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState +import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_UNIQUE_ID +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.sync.FakeSyncService +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.matrix.test.timeline.aMessageContent +import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PinnedMessagesListPresenterTest { + @Test + fun `present - initial state`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) + } + ) + val presenter = createPinnedMessagesListPresenter(room = room) + presenter.test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo(PinnedMessagesListState.Loading) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - timeline failure state`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) + }, + createTimelineResult = { Result.failure(RuntimeException()) }, + ) + val presenter = createPinnedMessagesListPresenter(room = room) + presenter.test { + skipItems(3) + val failureState = awaitItem() + assertThat(failureState).isEqualTo(PinnedMessagesListState.Failed) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - empty state`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf())) + }, + createTimelineResult = { Result.success(FakeTimeline()) }, + ) + val presenter = createPinnedMessagesListPresenter(room = room) + presenter.test { + skipItems(3) + val emptyState = awaitItem() + assertThat(emptyState).isEqualTo(PinnedMessagesListState.Empty) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - filled state`() = runTest { + val pinnedEventsTimeline = createPinnedMessagesTimeline() + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) + }, + createTimelineResult = { Result.success(pinnedEventsTimeline) }, + ) + val presenter = createPinnedMessagesListPresenter(room = room) + presenter.test { + skipItems(3) + val filledState = awaitItem() as PinnedMessagesListState.Filled + assertThat(filledState.timelineItems).hasSize(1) + assertThat(filledState.loadedPinnedMessagesCount).isEqualTo(1) + assertThat(filledState.userEventPermissions.canRedactOwn).isTrue() + assertThat(filledState.userEventPermissions.canRedactOther).isTrue() + assertThat(filledState.userEventPermissions.canPinUnpin).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - unpin event`() = runTest { + val successUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.success(true) } + val failureUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.failure(AN_EXCEPTION) } + val pinnedEventsTimeline = createPinnedMessagesTimeline() + val analyticsService = FakeAnalyticsService() + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) + }, + createTimelineResult = { Result.success(pinnedEventsTimeline) }, + ) + val presenter = createPinnedMessagesListPresenter(room = room, analyticsService = analyticsService) + presenter.test { + skipItems(3) + val filledState = awaitItem() as PinnedMessagesListState.Filled + val eventItem = filledState.timelineItems.first() as TimelineItem.Event + + pinnedEventsTimeline.unpinEventLambda = successUnpinEventLambda + filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Unpin, eventItem)) + advanceUntilIdle() + + pinnedEventsTimeline.unpinEventLambda = failureUnpinEventLambda + filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Unpin, eventItem)) + advanceUntilIdle() + + cancelAndIgnoreRemainingEvents() + + assert(successUnpinEventLambda) + .isCalledOnce() + .with(value(AN_EVENT_ID)) + + assert(failureUnpinEventLambda) + .isCalledOnce() + .with(value(AN_EVENT_ID)) + + assertThat(analyticsService.capturedEvents).containsExactly( + PinUnpinAction(kind = PinUnpinAction.Kind.Unpin, from = PinUnpinAction.From.MessagePinningList), + PinUnpinAction(kind = PinUnpinAction.Kind.Unpin, from = PinUnpinAction.From.MessagePinningList) + ) + } + } + + @Test + fun `present - navigate to event`() = runTest { + val onViewInTimelineClickLambda = lambdaRecorder { _: EventId -> } + val navigator = FakePinnedMessagesListNavigator().apply { + this.onViewInTimelineClickLambda = onViewInTimelineClickLambda + } + val pinnedEventsTimeline = createPinnedMessagesTimeline() + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) + }, + createTimelineResult = { Result.success(pinnedEventsTimeline) }, + ) + val presenter = createPinnedMessagesListPresenter(room = room, navigator = navigator) + presenter.test { + skipItems(3) + val filledState = awaitItem() as PinnedMessagesListState.Filled + val eventItem = filledState.timelineItems.first() as TimelineItem.Event + filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.ViewInTimeline, eventItem)) + advanceUntilIdle() + cancelAndIgnoreRemainingEvents() + assert(onViewInTimelineClickLambda) + .isCalledOnce() + .with(value(AN_EVENT_ID)) + } + } + + @Test + fun `present - show view source action`() = runTest { + val onShowEventDebugInfoClickLambda = lambdaRecorder { _: EventId?, _: TimelineItemDebugInfo -> } + val navigator = FakePinnedMessagesListNavigator().apply { + this.onShowEventDebugInfoClickLambda = onShowEventDebugInfoClickLambda + } + val pinnedEventsTimeline = createPinnedMessagesTimeline() + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) + }, + createTimelineResult = { Result.success(pinnedEventsTimeline) }, + ) + val presenter = createPinnedMessagesListPresenter(room = room, navigator = navigator) + presenter.test { + skipItems(3) + val filledState = awaitItem() as PinnedMessagesListState.Filled + val eventItem = filledState.timelineItems.first() as TimelineItem.Event + filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.ViewSource, eventItem)) + advanceUntilIdle() + cancelAndIgnoreRemainingEvents() + assert(onShowEventDebugInfoClickLambda) + .isCalledOnce() + .with(value(AN_EVENT_ID), value(eventItem.debugInfo)) + } + } + + @Test + fun `present - forward event`() = runTest { + val onForwardEventClickLambda = lambdaRecorder { _: EventId -> } + val navigator = FakePinnedMessagesListNavigator().apply { + this.onForwardEventClickLambda = onForwardEventClickLambda + } + val pinnedEventsTimeline = createPinnedMessagesTimeline() + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) + }, + createTimelineResult = { Result.success(pinnedEventsTimeline) }, + ) + val presenter = createPinnedMessagesListPresenter(room = room, navigator = navigator) + presenter.test { + skipItems(3) + val filledState = awaitItem() as PinnedMessagesListState.Filled + val eventItem = filledState.timelineItems.first() as TimelineItem.Event + filledState.eventSink(PinnedMessagesListEvents.HandleAction(TimelineItemAction.Forward, eventItem)) + advanceUntilIdle() + cancelAndIgnoreRemainingEvents() + assert(onForwardEventClickLambda) + .isCalledOnce() + .with(value(AN_EVENT_ID)) + } + } + + private fun createPinnedMessagesTimeline(): FakeTimeline { + val messageContent = aMessageContent("A message") + return FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + eventId = AN_EVENT_ID, + content = messageContent, + ), + ) + ) + ) + ) + } + + private fun TestScope.createPinnedMessagesListPresenter( + navigator: PinnedMessagesListNavigator = FakePinnedMessagesListNavigator(), + room: JoinedRoom = FakeJoinedRoom(), + syncService: SyncService = FakeSyncService(), + analyticsService: AnalyticsService = FakeAnalyticsService(), + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), + ): PinnedMessagesListPresenter { + val timelineProvider = DefaultPinnedEventsTimelineProvider( + room = room, + syncService = syncService, + dispatchers = testCoroutineDispatchers(), + ) + timelineProvider.launchIn(backgroundScope) + return PinnedMessagesListPresenter( + navigator = navigator, + room = room, + timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(), + timelineProvider = timelineProvider, + timelineProtectionPresenter = { aTimelineProtectionState() }, + snackbarDispatcher = SnackbarDispatcher(), + actionListPresenter = { anActionListState() }, + linkPresenter = { aLinkState() }, + analyticsService = analyticsService, + featureFlagService = featureFlagService, + sessionCoroutineScope = this, + htmlConverterProvider = FakeHtmlConverterProvider(), + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListTimelineActionPostProcessorTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListTimelineActionPostProcessorTest.kt new file mode 100644 index 0000000..df79394 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListTimelineActionPostProcessorTest.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.list + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import org.junit.Test + +class PinnedMessagesListTimelineActionPostProcessorTest { + @Test + fun `ensure that ViewInTimeline is added`() { + val sut = PinnedMessagesListTimelineActionPostProcessor() + val result = sut.process( + listOf() + ) + assertThat(result).isEqualTo( + listOf(TimelineItemAction.ViewInTimeline) + ) + } + + @Test + fun `ensure that some actions are kept and some other are filtered out`() { + val sut = PinnedMessagesListTimelineActionPostProcessor() + val result = sut.process( + TimelineItemAction.entries.toList() + ) + assertThat(result).isEqualTo( + listOf( + TimelineItemAction.ViewInTimeline, + TimelineItemAction.Unpin, + TimelineItemAction.Forward, + TimelineItemAction.ViewSource, + ) + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt new file mode 100644 index 0000000..3f96f1d --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.list + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.messages.impl.actionlist.ActionListEvents +import io.element.android.features.messages.impl.actionlist.anActionListState +import io.element.android.features.messages.impl.timeline.aTimelineItemList +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.setSafeContent +import io.element.android.wysiwyg.link.Link +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PinnedMessagesListViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on back calls the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aLoadedPinnedMessagesListState( + eventSink = eventsRecorder + ) + ensureCalledOnce { callback -> + rule.setPinnedMessagesListView( + state = state, + onBackClick = callback + ) + rule.pressBack() + } + } + + @Test + fun `click on an event calls the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val content = aTimelineItemFileContent() + val state = aLoadedPinnedMessagesListState( + timelineItems = aTimelineItemList(content), + eventSink = eventsRecorder + ) + + val event = state.timelineItems.first() as TimelineItem.Event + ensureCalledOnceWithParam(event) { callback -> + rule.setPinnedMessagesListView( + state = state, + onEventClick = callback + ) + rule.onAllNodesWithText(content.filename).onFirst().performClick() + } + } + + @Test + fun `long click on an event emits the expected event`() { + val eventsRecorder = EventsRecorder(expectEvents = true) + val content = aTimelineItemFileContent() + val state = aLoadedPinnedMessagesListState( + timelineItems = aTimelineItemList(content), + actionListState = anActionListState(eventSink = eventsRecorder) + ) + + rule.setPinnedMessagesListView( + state = state, + ) + rule.onAllNodesWithText(content.filename).onFirst() + .performTouchInput { + longClick() + } + val event = state.timelineItems.first() as TimelineItem.Event + eventsRecorder.assertSingle(ActionListEvents.ComputeForMessage(event, state.userEventPermissions)) + } +} + +private fun AndroidComposeTestRule.setPinnedMessagesListView( + state: PinnedMessagesListState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onEventClick: (event: TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + onUserDataClick: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(), + onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(), + onLinkLongClick: (Link) -> Unit = EnsureNeverCalledWithParam(), +) { + setSafeContent(clearAndroidUiDispatcher = true) { + PinnedMessagesListView( + state = state, + onBackClick = onBackClick, + onEventClick = onEventClick, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenterTest.kt new file mode 100644 index 0000000..fd8d33d --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenterTest.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.report + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +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.JoinedRoom +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.FakeJoinedRoom +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class ReportMessagePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `presenter - initial state`() = runTest { + val presenter = createReportMessagePresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.reason).isEmpty() + assertThat(initialState.blockUser).isFalse() + assertThat(initialState.result).isInstanceOf(AsyncAction.Uninitialized::class.java) + } + } + + @Test + fun `presenter - update reason`() = runTest { + val presenter = createReportMessagePresenter() + moleculeFlow(RecompositionMode.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 = createReportMessagePresenter() + moleculeFlow(RecompositionMode.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 reportContentResult = lambdaRecorder> { _, _, _ -> + Result.success(Unit) + } + val room = FakeJoinedRoom( + reportContentResult = reportContentResult + ) + val presenter = createReportMessagePresenter(joinedRoom = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(ReportMessageEvents.ToggleBlockUser) + skipItems(1) + initialState.eventSink(ReportMessageEvents.Report) + assertThat(awaitItem().result).isInstanceOf(AsyncAction.Loading::class.java) + assertThat(awaitItem().result).isInstanceOf(AsyncAction.Success::class.java) + reportContentResult.assertions().isCalledOnce() + } + } + + @Test + fun `presenter - handle successful report`() = runTest { + val reportContentResult = lambdaRecorder> { _, _, _ -> + Result.success(Unit) + } + val room = FakeJoinedRoom( + reportContentResult = reportContentResult + ) + val presenter = createReportMessagePresenter(joinedRoom = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(ReportMessageEvents.Report) + assertThat(awaitItem().result).isInstanceOf(AsyncAction.Loading::class.java) + assertThat(awaitItem().result).isInstanceOf(AsyncAction.Success::class.java) + reportContentResult.assertions().isCalledOnce() + } + } + + @Test + fun `presenter - handle failed report`() = runTest { + val reportContentResult = lambdaRecorder> { _, _, _ -> + Result.failure(Exception("Failed to report content")) + } + val room = FakeJoinedRoom( + reportContentResult = reportContentResult + ) + val presenter = createReportMessagePresenter(joinedRoom = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(ReportMessageEvents.Report) + assertThat(awaitItem().result).isInstanceOf(AsyncAction.Loading::class.java) + val resultState = awaitItem() + assertThat(resultState.result).isInstanceOf(AsyncAction.Failure::class.java) + reportContentResult.assertions().isCalledOnce() + + resultState.eventSink(ReportMessageEvents.ClearError) + assertThat(awaitItem().result).isInstanceOf(AsyncAction.Uninitialized::class.java) + } + } + + private fun createReportMessagePresenter( + inputs: ReportMessagePresenter.Inputs = ReportMessagePresenter.Inputs(AN_EVENT_ID, A_USER_ID), + joinedRoom: JoinedRoom = FakeJoinedRoom(), + snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), + ) = ReportMessagePresenter( + inputs = inputs, + room = joinedRoom, + snackbarDispatcher = snackbarDispatcher, + ) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt new file mode 100644 index 0000000..315d9c4 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.junit4.createComposeRule +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultHtmlConverterProviderTest { + @get:Rule val composeTestRule = createComposeRule() + + private val provider = DefaultHtmlConverterProvider( + mentionSpanProvider = MentionSpanProvider( + permalinkParser = FakePermalinkParser(), + mentionSpanFormatter = FakeMentionSpanFormatter(), + mentionSpanTheme = MentionSpanTheme(A_USER_ID) + ) + ) + + @Test + fun `calling provide without calling Update first should throw an exception`() { + val exception = runCatchingExceptions { provider.provide() }.exceptionOrNull() + + assertThat(exception).isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun `calling provide after calling Update first should return an HtmlConverter`() { + composeTestRule.setContent { + CompositionLocalProvider(LocalInspectionMode provides true) { + provider.Update() + } + } + val htmlConverter = runCatchingExceptions { provider.provide() }.getOrNull() + + assertThat(htmlConverter).isNotNull() + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultMarkAsFullyReadTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultMarkAsFullyReadTest.kt new file mode 100644 index 0000000..395ed01 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultMarkAsFullyReadTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.messages.impl.timeline + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +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.FakeMatrixClient +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultMarkAsFullyReadTest { + @Test + fun `When marking as read fails, no exception is thrown`() = runTest { + val markAsFullyRead = DefaultMarkAsFullyRead( + matrixClient = FakeMatrixClient( + markRoomAsFullyReadResult = { _, _ -> Result.failure(IllegalStateException("Room not found")) }, + ).apply { + givenGetRoomResult(A_ROOM_ID, null) + }, + coroutineDispatchers = testCoroutineDispatchers(), + ) + assertThat(markAsFullyRead.invoke(A_ROOM_ID, AN_EVENT_ID).isFailure).isTrue() + runCurrent() + } + + @Test + fun `When marking as read is successful, the expected method is invoked`() = runTest { + val markAsFullyReadResult = lambdaRecorder> { _, _ -> Result.success(Unit) } + val markAsFullyRead = DefaultMarkAsFullyRead( + matrixClient = FakeMatrixClient( + markRoomAsFullyReadResult = markAsFullyReadResult, + ), + coroutineDispatchers = testCoroutineDispatchers(), + ) + assertThat(markAsFullyRead.invoke(A_ROOM_ID, AN_EVENT_ID).isSuccess).isTrue() + runCurrent() + markAsFullyReadResult.assertions().isCalledOnce().with(value(A_ROOM_ID), value(AN_EVENT_ID)) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/FakeMarkAsFullyRead.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/FakeMarkAsFullyRead.kt new file mode 100644 index 0000000..8dca151 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/FakeMarkAsFullyRead.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline + +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeMarkAsFullyRead( + private val invokeResult: (RoomId, EventId) -> Unit = { _, _ -> lambdaError() }, +) : MarkAsFullyRead { + override suspend fun invoke(roomId: RoomId, eventId: EventId): Result { + return runCatchingExceptions { invokeResult(roomId, eventId) } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt new file mode 100644 index 0000000..034c952 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.IntentionalMention +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_UNIQUE_ID +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class TimelineControllerTest { + @Test + fun `test switching between live and detached timeline`() = runTest { + val liveTimeline = FakeTimeline(name = "live") + val detachedTimeline = FakeTimeline(name = "detached") + val joinedRoom = FakeJoinedRoom( + liveTimeline = liveTimeline, + createTimelineResult = { Result.success(detachedTimeline) } + ) + val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) + + sut.activeTimelineFlow().test { + awaitItem().also { state -> + assertThat(state).isEqualTo(liveTimeline) + } + assertThat(sut.isLive().first()).isTrue() + sut.focusOnEvent(AN_EVENT_ID, null) + awaitItem().also { state -> + assertThat(state).isEqualTo(detachedTimeline) + } + assertThat(sut.isLive().first()).isFalse() + assertThat(detachedTimeline.closeCounter).isEqualTo(0) + sut.focusOnLive() + assertThat(sut.isLive().first()).isTrue() + awaitItem().also { state -> + assertThat(state).isEqualTo(liveTimeline) + } + assertThat(detachedTimeline.closeCounter).isEqualTo(1) + } + } + + @Test + fun `test switching between detached 1 and detached 2 should close detached 1`() = runTest { + val liveTimeline = FakeTimeline(name = "live") + val detachedTimeline1 = FakeTimeline(name = "detached 1") + val detachedTimeline2 = FakeTimeline(name = "detached 2") + var callNumber = 0 + val joinedRoom = FakeJoinedRoom( + liveTimeline = liveTimeline, + createTimelineResult = { + callNumber++ + when (callNumber) { + 1 -> Result.success(detachedTimeline1) + 2 -> Result.success(detachedTimeline2) + else -> lambdaError() + } + } + ) + val sut = TimelineController(joinedRoom, liveTimeline) + + sut.activeTimelineFlow().test { + awaitItem().also { state -> + assertThat(state).isEqualTo(liveTimeline) + } + sut.focusOnEvent(AN_EVENT_ID, null) + awaitItem().also { state -> + assertThat(state).isEqualTo(detachedTimeline1) + } + assertThat(detachedTimeline1.closeCounter).isEqualTo(0) + assertThat(detachedTimeline2.closeCounter).isEqualTo(0) + // Focus on another event should close the previous detached timeline + sut.focusOnEvent(AN_EVENT_ID, null) + awaitItem().also { state -> + assertThat(state).isEqualTo(detachedTimeline2) + } + assertThat(detachedTimeline1.closeCounter).isEqualTo(1) + assertThat(detachedTimeline2.closeCounter).isEqualTo(0) + } + } + + @Test + fun `test switching to live when already in live should have no effect`() = runTest { + val liveTimeline = FakeTimeline(name = "live") + val joinedRoom = FakeJoinedRoom( + liveTimeline = liveTimeline + ) + val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) + sut.activeTimelineFlow().test { + awaitItem().also { state -> + assertThat(state).isEqualTo(liveTimeline) + } + assertThat(sut.isLive().first()).isTrue() + sut.focusOnLive() + assertThat(sut.isLive().first()).isTrue() + } + } + + @Test + fun `test closing the TimelineController should close the detached timeline`() = runTest { + val liveTimeline = FakeTimeline(name = "live") + val detachedTimeline = FakeTimeline(name = "detached") + val joinedRoom = FakeJoinedRoom( + liveTimeline = liveTimeline, + createTimelineResult = { Result.success(detachedTimeline) } + ) + val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) + sut.activeTimelineFlow().test { + awaitItem().also { state -> + assertThat(state).isEqualTo(liveTimeline) + } + sut.focusOnEvent(AN_EVENT_ID, null) + awaitItem().also { state -> + assertThat(state).isEqualTo(detachedTimeline) + } + assertThat(detachedTimeline.closeCounter).isEqualTo(0) + sut.close() + assertThat(detachedTimeline.closeCounter).isEqualTo(1) + } + } + + @Test + fun `test getting timeline item`() = runTest { + val liveTimeline = FakeTimeline( + name = "live", + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem()) + ) + ) + ) + val joinedRoom = FakeJoinedRoom( + liveTimeline = liveTimeline + ) + val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) + assertThat(sut.timelineItems().first()).hasSize(1) + } + + @Test + fun `test invokeOnCurrentTimeline use the detached timeline and not the live timeline`() = runTest { + val lambdaForDetached = lambdaRecorder { _: String, _: String?, _: List -> + Result.success(Unit) + } + val lambdaForLive = lambdaRecorder(ensureNeverCalled = true) { _: String, _: String?, _: List -> + Result.success(Unit) + } + val liveTimeline = FakeTimeline(name = "live").apply { + sendMessageLambda = lambdaForLive + } + val detachedTimeline = FakeTimeline(name = "detached").apply { + sendMessageLambda = lambdaForDetached + } + val joinedRoom = FakeJoinedRoom( + liveTimeline = liveTimeline, + createTimelineResult = { Result.success(detachedTimeline) } + ) + val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) + sut.activeTimelineFlow().test { + sut.focusOnEvent(AN_EVENT_ID, null) + awaitItem().also { state -> + assertThat(state).isEqualTo(liveTimeline) + } + sut.focusOnEvent(AN_EVENT_ID, null) + awaitItem().also { state -> + assertThat(state).isEqualTo(detachedTimeline) + } + sut.invokeOnCurrentTimeline { + sendMessage("body", "htmlBody", emptyList()) + } + lambdaForDetached.assertions().isCalledOnce() + } + } + + @Test + fun `test last forward pagination on a detached timeline should switch to live timeline`() = runTest { + val liveTimeline = FakeTimeline(name = "live") + val detachedTimeline = FakeTimeline(name = "detached") + val joinedRoom = FakeJoinedRoom( + liveTimeline = liveTimeline, + createTimelineResult = { Result.success(detachedTimeline) } + ) + val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) + + sut.activeTimelineFlow().test { + awaitItem().also { state -> + assertThat(state).isEqualTo(liveTimeline) + } + sut.focusOnEvent(AN_EVENT_ID, null) + awaitItem().also { state -> + assertThat(state).isEqualTo(detachedTimeline) + } + val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection -> + Result.success(true) + } + detachedTimeline.apply { + this.paginateLambda = paginateLambda + } + sut.paginate(Timeline.PaginationDirection.FORWARDS) + awaitItem().also { state -> + assertThat(state).isEqualTo(liveTimeline) + } + } + } +} + +internal fun createTimelineController( + room: FakeJoinedRoom = FakeJoinedRoom(liveTimeline = FakeTimeline()), + liveTimeline: Timeline = FakeTimeline(name = "live"), +): TimelineController { + return TimelineController( + room = room, + liveTimeline = liveTimeline + ) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexerTest.kt new file mode 100644 index 0000000..65a29f5 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineItemIndexerTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class TimelineItemIndexerTest { + @Test + fun `test TimelineItemIndexer`() = runTest { + val eventIds = mutableListOf() + val data = listOf( + aTimelineItemEvent().also { eventIds.add(it.eventId!!) }, + aTimelineItemEvent().also { eventIds.add(it.eventId!!) }, + aGroupedEvents().also { groupedEvents -> + groupedEvents.events.forEach { eventIds.add(it.eventId!!) } + }, + TimelineItem.Virtual( + id = UniqueId("dummy"), + model = TimelineItemReadMarkerModel + ), + ) + assertThat(eventIds.size).isEqualTo(4) + val sut = TimelineItemIndexer() + sut.process(data) + eventIds.forEach { + assertThat(sut.isKnown(it)).isTrue() + } + assertThat(sut.indexOf(eventIds[0])).isEqualTo(0) + assertThat(sut.indexOf(eventIds[1])).isEqualTo(1) + assertThat(sut.indexOf(eventIds[2])).isEqualTo(2) + assertThat(sut.indexOf(eventIds[3])).isEqualTo(2) + + // Unknown event + assertThat(sut.isKnown(AN_EVENT_ID)).isFalse() + assertThat(sut.indexOf(AN_EVENT_ID)).isEqualTo(-1) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt new file mode 100644 index 0000000..13c28da --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -0,0 +1,1061 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline + +import app.cash.molecule.RecompositionMode +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.messages.impl.FakeMessagesNavigator +import io.element.android.features.messages.impl.crypto.sendfailure.resolve.aResolveVerifiedUserSendFailureState +import io.element.android.features.messages.impl.fixtures.aMessageEvent +import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactoryCreator +import io.element.android.features.messages.impl.timeline.components.aCriticalShield +import io.element.android.features.messages.impl.timeline.model.NewEventState +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.typing.aTypingNotificationState +import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager +import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager +import io.element.android.features.messages.impl.voicemessages.timeline.aRedactedMatrixTimeline +import io.element.android.features.poll.api.actions.EndPollAction +import io.element.android.features.poll.api.actions.SendPollResponseAction +import io.element.android.features.poll.test.actions.FakeEndPollAction +import io.element.android.features.poll.test.actions.FakeSendPollResponseAction +import io.element.android.features.roomcall.api.aStandByCallState +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.RoomId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.core.asEventId +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.ReceiptType +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction +import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender +import io.element.android.libraries.matrix.api.timeline.item.event.Receipt +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.AN_EVENT_ID_2 +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_THREAD_ID +import io.element.android.libraries.matrix.test.A_THREAD_ID_2 +import io.element.android.libraries.matrix.test.A_UNIQUE_ID +import io.element.android.libraries.matrix.test.A_UNIQUE_ID_2 +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.matrix.test.timeline.aMessageContent +import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.awaitLastSequentialItem +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import java.util.Date +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@Suppress("LargeClass") +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +class TimelinePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createTimelinePresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.timelineItems).isEmpty() + assertThat(initialState.isLive).isTrue() + assertThat(initialState.newEventState).isEqualTo(NewEventState.None) + assertThat(initialState.focusedEventId).isNull() + assertThat(initialState.focusRequestState).isEqualTo(FocusRequestState.None) + } + } + + @Test + fun `present - load more`() = runTest { + val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection -> + Result.success(false) + } + val timeline = FakeTimeline().apply { + this.paginateLambda = paginateLambda + } + val presenter = createTimelinePresenter(timeline = timeline) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS)) + initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.FORWARDS)) + assert(paginateLambda) + .isCalledExactly(2) + .withSequence( + listOf(value(Timeline.PaginationDirection.BACKWARDS)), + listOf(value(Timeline.PaginationDirection.FORWARDS)) + ) + } + } + + @Test + fun `present - on scroll finished mark a room as read if the first visible index is 0 - read private`() { + `present - on scroll finished mark a room as read if the first visible index is 0`( + isSendPublicReadReceiptsEnabled = false, + expectedReceiptType = ReceiptType.READ_PRIVATE, + ) + } + + @Test + fun `present - on scroll finished mark a room as read if the first visible index is 0 - read`() { + `present - on scroll finished mark a room as read if the first visible index is 0`( + isSendPublicReadReceiptsEnabled = true, + expectedReceiptType = ReceiptType.READ, + ) + } + + private fun `present - on scroll finished mark a room as read if the first visible index is 0`( + isSendPublicReadReceiptsEnabled: Boolean, + expectedReceiptType: ReceiptType, + ) = runTest(StandardTestDispatcher()) { + val markAsReadResult = lambdaRecorder> { Result.success(Unit) } + val sendReadReceiptLambda = lambdaRecorder> { _, _ -> Result.success(Unit) } + val timeline = FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem()) + ) + ), + markAsReadResult = markAsReadResult, + sendReadReceiptLambda = sendReadReceiptLambda, + ) + val room = FakeJoinedRoom( + liveTimeline = timeline, + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + ) + ) + val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = isSendPublicReadReceiptsEnabled) + val presenter = createTimelinePresenter( + timeline = timeline, + room = room, + sessionPreferencesStore = sessionPreferencesStore, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) + runCurrent() + assert(markAsReadResult) + .isCalledOnce() + .with(value(expectedReceiptType)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - on scroll finished send read receipt if an event is before the index`() = runTest { + val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType -> + Result.success(Unit) + } + val timeline = FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem()), + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID_2, + event = anEventTimelineItem( + eventId = AN_EVENT_ID_2, + content = aMessageContent("Test message") + ) + ) + ) + ) + ).apply { + this.sendReadReceiptLambda = sendReadReceiptsLambda + } + val presenter = createTimelinePresenter(timeline) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + awaitItem().run { + eventSink.invoke(TimelineEvents.OnScrollFinished(1)) + } + advanceUntilIdle() + assert(sendReadReceiptsLambda) + .isCalledOnce() + .with(any(), value(ReceiptType.READ)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - on scroll finished send a private read receipt if an event is at an index other than 0 and public read receipts are disabled`() = runTest { + val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType -> + Result.success(Unit) + } + val timeline = FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem()), + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID_2, + event = anEventTimelineItem( + eventId = AN_EVENT_ID_2, + content = aMessageContent("Test message") + ) + ) + ) + ), + markAsReadResult = { Result.success(Unit) }, + sendReadReceiptLambda = sendReadReceiptsLambda, + ) + val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false) + val presenter = createTimelinePresenter( + timeline = timeline, + sessionPreferencesStore = sessionPreferencesStore, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + awaitItem().run { + eventSink.invoke(TimelineEvents.OnScrollFinished(0)) + eventSink.invoke(TimelineEvents.OnScrollFinished(1)) + } + advanceUntilIdle() + assert(sendReadReceiptsLambda) + .isCalledOnce() + .with(any(), value(ReceiptType.READ_PRIVATE)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - on scroll finished will not send read receipt the first visible event is the same as before`() = runTest { + val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType -> + Result.success(Unit) + } + val timeline = FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem()), + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID_2, + event = anEventTimelineItem( + eventId = AN_EVENT_ID_2, + content = aMessageContent("Test message") + ) + ) + ) + ) + ).apply { + this.sendReadReceiptLambda = sendReadReceiptsLambda + } + val presenter = createTimelinePresenter(timeline) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + awaitItem().run { + eventSink.invoke(TimelineEvents.OnScrollFinished(1)) + eventSink.invoke(TimelineEvents.OnScrollFinished(1)) + } + advanceUntilIdle() + cancelAndIgnoreRemainingEvents() + assert(sendReadReceiptsLambda).isCalledOnce() + } + } + + @Test + fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest { + val sendReadReceiptsLambda = lambdaRecorder { _: EventId, _: ReceiptType -> + Result.success(Unit) + } + val timeline = FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Virtual(A_UNIQUE_ID, VirtualTimelineItem.ReadMarker), + MatrixTimelineItem.Virtual(A_UNIQUE_ID, VirtualTimelineItem.ReadMarker) + ) + ) + ).apply { + this.sendReadReceiptLambda = sendReadReceiptsLambda + } + val presenter = createTimelinePresenter(timeline) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) + cancelAndIgnoreRemainingEvents() + assert(sendReadReceiptsLambda).isNeverCalled() + } + } + + @Test + fun `present - covers newEventState scenarios`() = runTest { + val timelineItems = MutableStateFlow(emptyList()) + val timeline = FakeTimeline( + timelineItems = timelineItems, + markAsReadResult = { Result.success(Unit) }, + ) + val presenter = createTimelinePresenter(timeline) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.newEventState).isEqualTo(NewEventState.None) + assertThat(initialState.timelineItems.size).isEqualTo(0) + timelineItems.emit( + listOf(MatrixTimelineItem.Event(UniqueId("0"), anEventTimelineItem(content = aMessageContent()))) + ) + consumeItemsUntilPredicate { it.timelineItems.size == 1 } + // Mimics sending a message, and assert newEventState is FromMe + timelineItems.getAndUpdate { items -> + val event = anEventTimelineItem(content = aMessageContent(), isOwn = true) + items + listOf(MatrixTimelineItem.Event(UniqueId("1"), event)) + } + consumeItemsUntilPredicate { it.timelineItems.size == 2 } + awaitLastSequentialItem().also { state -> + assertThat(state.newEventState).isEqualTo(NewEventState.FromMe) + } + // Mimics receiving a message without clearing the previous FromMe + timelineItems.getAndUpdate { items -> + val event = anEventTimelineItem(content = aMessageContent()) + items + listOf(MatrixTimelineItem.Event(UniqueId("2"), event)) + } + consumeItemsUntilPredicate { it.timelineItems.size == 3 } + + // Scroll to bottom to clear previous FromMe + initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) + awaitLastSequentialItem().also { state -> + assertThat(state.newEventState).isEqualTo(NewEventState.None) + } + // Mimics receiving a message and assert newEventState is FromOther + timelineItems.getAndUpdate { items -> + val event = anEventTimelineItem(content = aMessageContent()) + items + listOf(MatrixTimelineItem.Event(UniqueId("3"), event)) + } + consumeItemsUntilPredicate { it.timelineItems.size == 4 } + awaitLastSequentialItem().also { state -> + assertThat(state.newEventState).isEqualTo(NewEventState.FromOther) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - reaction ordering`() = runTest { + val timelineItems = MutableStateFlow(emptyList()) + val timeline = FakeTimeline( + timelineItems = timelineItems, + ) + val presenter = createTimelinePresenter(timeline) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.newEventState).isEqualTo(NewEventState.None) + assertThat(initialState.timelineItems.size).isEqualTo(0) + val now = Date().time + val minuteInMillis = 60 * 1000 + // Use index as a convenient value for timestamp + val (alice, bob, charlie) = aMatrixUserList().take(3).mapIndexed { i, user -> + ReactionSender(senderId = user.userId, timestamp = now + i * minuteInMillis) + } + val oneReaction = persistentListOf( + EventReaction( + key = "❤️", + senders = persistentListOf(alice, charlie) + ), + EventReaction( + key = "👍", + senders = persistentListOf(alice, bob) + ), + EventReaction( + key = "🐶", + senders = persistentListOf(charlie) + ), + ) + timelineItems.emit( + listOf(MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem(reactions = oneReaction))) + ) + val item = awaitItem().timelineItems.first() + assertThat(item).isInstanceOf(TimelineItem.Event::class.java) + val event = item as TimelineItem.Event + val reactions = event.reactionsState.reactions + assertThat(reactions.size).isEqualTo(3) + + // Aggregated reactions are sorted by count first and then timestamp ascending(new ones tagged on the end) + assertThat(reactions[0].count).isEqualTo(2) + assertThat(reactions[0].key).isEqualTo("👍") + assertThat(reactions[0].senders[0].senderId).isEqualTo(bob.senderId) + + assertThat(reactions[1].count).isEqualTo(2) + assertThat(reactions[1].key).isEqualTo("❤️") + assertThat(reactions[1].senders[0].senderId).isEqualTo(charlie.senderId) + + assertThat(reactions[2].count).isEqualTo(1) + assertThat(reactions[2].key).isEqualTo("🐶") + assertThat(reactions[2].senders[0].senderId).isEqualTo(charlie.senderId) + } + } + + @Test + fun `present - PollAnswerSelected event`() = runTest { + val sendPollResponseAction = FakeSendPollResponseAction() + val presenter = createTimelinePresenter( + sendPollResponseAction = sendPollResponseAction, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvents.SelectPollAnswer(AN_EVENT_ID, "anAnswerId")) + } + delay(1) + sendPollResponseAction.verifyExecutionCount(1) + } + + @Test + fun `present - PollEndClicked event`() = runTest { + val endPollAction = FakeEndPollAction() + val presenter = createTimelinePresenter( + endPollAction = endPollAction, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvents.EndPoll(AN_EVENT_ID)) + } + delay(1) + endPollAction.verifyExecutionCount(1) + } + + @Test + fun `present - PollEditClicked event navigates`() = runTest { + val onEditPollClickLambda = lambdaRecorder { _: EventId -> } + val navigator = FakeMessagesNavigator( + onEditPollClickLambda = onEditPollClickLambda + ) + val presenter = createTimelinePresenter( + messagesNavigator = navigator, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitFirstItem().eventSink(TimelineEvents.EditPoll(AN_EVENT_ID)) + onEditPollClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) + } + } + + @Test + fun `present - side effect on redacted items is invoked`() = runTest { + val redactedVoiceMessageManager = FakeRedactedVoiceMessageManager() + val presenter = createTimelinePresenter( + timeline = FakeTimeline( + timelineItems = flowOf( + aRedactedMatrixTimeline(AN_EVENT_ID), + ) + ), + redactedVoiceMessageManager = redactedVoiceMessageManager, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(0) + skipItems(2) + assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(1) + } + } + + @Test + fun `present - focus on event and jump to live make the presenter update the state with the correct Events`() = runTest { + val detachedTimeline = FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem(), + ) + ) + ) + ) + val liveTimeline = FakeTimeline( + timelineItems = flowOf(emptyList()) + ) + val room = FakeJoinedRoom( + liveTimeline = liveTimeline, + createTimelineResult = { Result.success(detachedTimeline) }, + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + threadRootIdForEventResult = { _ -> Result.success(null) }, + ), + ) + val presenter = createTimelinePresenter( + room = room, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) + awaitItem().also { state -> + assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) + assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO)) + } + awaitItem().also { state -> + assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) + assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID)) + } + skipItems(2) + awaitItem().also { state -> + assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Success(AN_EVENT_ID)) + assertThat(state.timelineItems).isNotEmpty() + } + initialState.eventSink.invoke(TimelineEvents.JumpToLive) + skipItems(2) + awaitItem().also { state -> + // Event stays focused + assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) + assertThat(state.timelineItems).isEmpty() + } + } + } + + @Test + fun `present - focus on known event retrieves the event from cache`() = runTest { + val timelineItemIndexer = TimelineItemIndexer() + val presenter = createTimelinePresenter( + room = FakeJoinedRoom( + liveTimeline = FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem(eventId = AN_EVENT_ID), + ) + ) + ) + ), + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + threadRootIdForEventResult = { Result.success(null) }, + ), + ), + timelineItemIndexer = timelineItemIndexer, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + + advanceUntilIdle() + + // Pre-populate the indexer after the first items have been retrieved + timelineItemIndexer.process(listOf(aMessageEvent(eventId = AN_EVENT_ID))) + + initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) + + advanceUntilIdle() + + awaitItem().also { state -> + assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) + assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO)) + } + awaitItem().also { state -> + assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) + assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Success(AN_EVENT_ID, 0)) + } + } + } + + @Test + fun `present - focus on event error case`() = runTest { + val presenter = createTimelinePresenter( + room = FakeJoinedRoom( + liveTimeline = FakeTimeline( + timelineItems = flowOf(emptyList()), + ), + createTimelineResult = { Result.failure(RuntimeException("An error")) }, + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + threadRootIdForEventResult = { _ -> Result.success(null) }, + ), + ) + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) + awaitItem().also { state -> + assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) + assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO)) + } + awaitItem().also { state -> + assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) + assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID)) + } + awaitItem().also { state -> + assertThat(state.focusRequestState).isInstanceOf(FocusRequestState.Failure::class.java) + state.eventSink(TimelineEvents.ClearFocusRequestState) + } + awaitItem().also { state -> + assertThat(state.focusRequestState).isEqualTo(FocusRequestState.None) + } + } + } + + @Test + fun `present - focus on event in a thread opens the thread`() = runTest { + val threadId = A_THREAD_ID + val detachedTimeline = FakeTimeline( + mode = Timeline.Mode.FocusedOnEvent(AN_EVENT_ID_2), + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem(), + ) + ) + ) + ) + val liveTimeline = FakeTimeline( + timelineItems = flowOf(emptyList()) + ) + val room = FakeJoinedRoom( + liveTimeline = liveTimeline, + createTimelineResult = { Result.success(detachedTimeline) }, + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + threadRootIdForEventResult = { _ -> Result.success(threadId) }, + ), + ) + val openThreadLambda = lambdaRecorder { _: ThreadId, _: EventId? -> } + val navigator = FakeMessagesNavigator(onOpenThreadLambda = openThreadLambda) + val presenter = createTimelinePresenter( + room = room, + timeline = liveTimeline, + messagesNavigator = navigator, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) + + awaitItem().also { state -> + assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) + assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO)) + } + + advanceUntilIdle() + + assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID)) + + // The live timeline focuses in the thread root + assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Success(A_THREAD_ID.asEventId())) + + // The thread is opened + openThreadLambda.assertions() + .isCalledOnce() + .with( + value(threadId), + value(AN_EVENT_ID), + ) + } + } + + @Test + fun `present - focus on event in a thread when in the same thread just moves the focus`() = runTest { + val threadId = A_THREAD_ID + val detachedTimeline = FakeTimeline( + mode = Timeline.Mode.FocusedOnEvent(AN_EVENT_ID_2), + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem(), + ) + ) + ) + ) + val liveTimeline = FakeTimeline( + mode = Timeline.Mode.Thread(threadId), + timelineItems = flowOf(emptyList()) + ) + val room = FakeJoinedRoom( + liveTimeline = liveTimeline, + createTimelineResult = { Result.success(detachedTimeline) }, + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + threadRootIdForEventResult = { _ -> Result.success(threadId) }, + ), + ) + val openThreadLambda = lambdaRecorder { _: ThreadId, _: EventId? -> } + val navigator = FakeMessagesNavigator(onOpenThreadLambda = openThreadLambda) + val presenter = createTimelinePresenter( + room = room, + timeline = liveTimeline, + messagesNavigator = navigator, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) + + awaitItem().also { state -> + assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) + assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO)) + } + + advanceUntilIdle() + + assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID)) + + // The live timeline focuses in the event directly since we are already in the thread + assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Success(AN_EVENT_ID)) + + // The thread is not opened again + openThreadLambda.assertions().isNeverCalled() + } + } + + @Test + fun `present - focus on event in a thread when in a different thread opens the new thread`() = runTest { + val currentThreadId = A_THREAD_ID + val detachedTimeline = FakeTimeline( + mode = Timeline.Mode.FocusedOnEvent(AN_EVENT_ID_2), + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem(), + ) + ) + ) + ) + val liveTimeline = FakeTimeline( + mode = Timeline.Mode.Thread(currentThreadId), + timelineItems = flowOf(emptyList()) + ) + val room = FakeJoinedRoom( + liveTimeline = liveTimeline, + createTimelineResult = { Result.success(detachedTimeline) }, + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + // Use a different thread id + threadRootIdForEventResult = { _ -> Result.success(A_THREAD_ID_2) }, + ), + ) + val openThreadLambda = lambdaRecorder { _: ThreadId, _: EventId? -> } + val navigator = FakeMessagesNavigator(onOpenThreadLambda = openThreadLambda) + val presenter = createTimelinePresenter( + room = room, + timeline = liveTimeline, + messagesNavigator = navigator, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) + + awaitItem().also { state -> + assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) + assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO)) + } + + advanceUntilIdle() + + assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID)) + + // The live timeline focuses in the event directly since we are already in the thread + assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Success(A_THREAD_ID_2.asEventId())) + + // The other thread is opened + openThreadLambda.assertions() + .isCalledOnce() + .with( + value(A_THREAD_ID_2), + value(AN_EVENT_ID), + ) + } + } + + @Test + fun `present - focus on event in a the room while in a thread of that room opens the room`() = runTest { + val detachedTimeline = FakeTimeline( + mode = Timeline.Mode.FocusedOnEvent(AN_EVENT_ID_2), + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem(), + ) + ) + ) + ) + val liveTimeline = FakeTimeline( + mode = Timeline.Mode.Thread(A_THREAD_ID), + timelineItems = flowOf(emptyList()) + ) + val room = FakeJoinedRoom( + liveTimeline = liveTimeline, + createTimelineResult = { Result.success(detachedTimeline) }, + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + // The event is in the main timeline, not in a thread + threadRootIdForEventResult = { _ -> Result.success(null) }, + ), + ) + val openRoomLambda = lambdaRecorder { _: RoomId, _: EventId?, _: List -> } + val navigator = FakeMessagesNavigator(onNavigateToRoomLambda = openRoomLambda) + val presenter = createTimelinePresenter( + room = room, + timeline = liveTimeline, + messagesNavigator = navigator, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) + + awaitItem().also { state -> + assertThat(state.focusedEventId).isEqualTo(AN_EVENT_ID) + assertThat(state.focusRequestState).isEqualTo(FocusRequestState.Requested(AN_EVENT_ID, Duration.ZERO)) + } + + advanceUntilIdle() + + assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.Loading(AN_EVENT_ID)) + + // The focus state will reset + assertThat(awaitItem().focusRequestState).isEqualTo(FocusRequestState.None) + + // The room is opened again + openRoomLambda.assertions() + .isCalledOnce() + .with( + value(room.roomId), + value(AN_EVENT_ID), + value(emptyList()) + ) + } + } + + @Test + fun `present - show shield hide shield`() = runTest { + val presenter = createTimelinePresenter() + val shield = aCriticalShield() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.messageShield).isNull() + initialState.eventSink(TimelineEvents.ShowShieldDialog(shield)) + awaitItem().also { state -> + assertThat(state.messageShield).isEqualTo(shield) + state.eventSink(TimelineEvents.HideShieldDialog) + } + awaitItem().also { state -> + assertThat(state.messageShield).isNull() + } + } + } + + @Test + fun `present - when room member info is loaded, read receipts info should be updated`() = runTest { + val timeline = FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event( + A_UNIQUE_ID, + anEventTimelineItem( + sender = A_USER_ID, + receipts = persistentListOf( + Receipt( + userId = A_USER_ID, + timestamp = 0L, + ) + ) + ) + ) + ) + ) + ) + val room = FakeJoinedRoom( + liveTimeline = timeline, + baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }), + ).apply { + givenRoomMembersState(RoomMembersState.Unknown) + } + + val avatarUrl = "https://domain.com/avatar.jpg" + + val presenter = createTimelinePresenter(timeline, room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = consumeItemsUntilPredicate(30.seconds) { it.timelineItems.isNotEmpty() }.last() + val event = initialState.timelineItems.first() as TimelineItem.Event + assertThat(event.senderAvatar.url).isNull() + assertThat(event.readReceiptState.receipts.first().avatarData.url).isNull() + + room.givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf(aRoomMember(userId = A_USER_ID, avatarUrl = avatarUrl)) + ) + ) + + val updatedEvent = awaitItem().timelineItems.first() as TimelineItem.Event + assertThat(updatedEvent.readReceiptState.receipts.first().avatarData.url).isEqualTo(avatarUrl) + } + } + + @Test + fun `present - timeline room info includes predecessor room when room has predecessor`() = runTest { + val predecessorRoomId = RoomId("!predecessor:server.org") + val predecessorRoom = PredecessorRoom(roomId = predecessorRoomId) + + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + predecessorRoomResult = { predecessorRoom } + ), + ) + + val presenter = createTimelinePresenter(room = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.timelineRoomInfo.predecessorRoom).isNotNull() + assertThat(initialState.timelineRoomInfo.predecessorRoom?.roomId).isEqualTo(predecessorRoomId) + } + } + + @Test + fun `present - timeline room info no predecessor`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + predecessorRoomResult = { null } + ), + ) + val presenter = createTimelinePresenter(room = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.timelineRoomInfo.predecessorRoom).isNull() + } + } + + @Test + fun `present - timeline event navigate to room`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + ), + ) + val onNavigateToRoomLambda = lambdaRecorder, Unit> { _, _, _ -> } + val navigator = FakeMessagesNavigator( + onNavigateToRoomLambda = onNavigateToRoomLambda + ) + val presenter = createTimelinePresenter(room = room, messagesNavigator = navigator) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(TimelineEvents.NavigateToPredecessorOrSuccessorRoom(A_ROOM_ID)) + assert(onNavigateToRoomLambda) + .isCalledOnce() + .with( + value(A_ROOM_ID), + // No event id when navigating to a successor/predecessor room + value(null), + value(emptyList()) + ) + } + } + + private suspend fun ReceiveTurbine.awaitFirstItem(): T { + return awaitItem() + } + + private fun TestScope.createTimelinePresenter( + timeline: Timeline = FakeTimeline(), + room: FakeJoinedRoom = FakeJoinedRoom( + liveTimeline = timeline, + baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }), + ), + redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(), + messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(), + endPollAction: EndPollAction = FakeEndPollAction(), + sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(), + sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), + timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(), + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), + ): TimelinePresenter { + return TimelinePresenter( + timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(), + room = room, + dispatchers = testCoroutineDispatchers(), + sessionCoroutineScope = this, + navigator = messagesNavigator, + redactedVoiceMessageManager = redactedVoiceMessageManager, + endPollAction = endPollAction, + sendPollResponseAction = sendPollResponseAction, + sessionPreferencesStore = sessionPreferencesStore, + timelineItemIndexer = timelineItemIndexer, + timelineController = TimelineController(room, timeline), + resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() }, + typingNotificationPresenter = { aTypingNotificationState() }, + roomCallStatePresenter = { aStandByCallState() }, + featureFlagService = featureFlagService, + analyticsService = FakeAnalyticsService(), + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt new file mode 100644 index 0000000..cdc0a6c --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToIndex +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.messages.impl.timeline.components.aCriticalShield +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemUnknownContent +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemLoadingIndicatorModel +import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.setSafeContent +import io.element.android.wysiwyg.link.Link +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TimelineViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `reaching the end of the timeline with more events to load emits a LoadMore event`() { + val eventsRecorder = EventsRecorder() + rule.setTimelineView( + state = aTimelineState( + timelineItems = persistentListOf( + TimelineItem.Virtual( + id = UniqueId("backward_pagination"), + model = TimelineItemLoadingIndicatorModel(Timeline.PaginationDirection.BACKWARDS, 0) + ), + ), + eventSink = eventsRecorder, + ), + ) + eventsRecorder.assertSingle(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS)) + } + + @Test + fun `reaching the end of the timeline does not send a LoadMore event`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setTimelineView( + state = aTimelineState( + eventSink = eventsRecorder, + ), + ) + } + + @Test + fun `scroll to bottom on live timeline does not emit the Event`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setTimelineView( + state = aTimelineState( + isLive = true, + eventSink = eventsRecorder, + ), + forceJumpToBottomVisibility = true, + ) + val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom) + rule.onNodeWithContentDescription(contentDescription).performClick() + } + + @Test + fun `scroll to bottom on detached timeline emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setTimelineView( + state = aTimelineState( + isLive = false, + eventSink = eventsRecorder, + ), + ) + val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom) + rule.onNodeWithContentDescription(contentDescription).performClick() + eventsRecorder.assertSingle(TimelineEvents.JumpToLive) + } + + @Test + fun `show shield dialog`() { + val eventsRecorder = EventsRecorder() + rule.setTimelineView( + state = aTimelineState( + timelineItems = persistentListOf( + aTimelineItemEvent( + // Do not use a Text because EditorStyledText cannot be used in UI test. + content = aTimelineItemImageContent(), + messageShield = MessageShield.UnverifiedIdentity(true), + ), + ), + eventSink = eventsRecorder, + ), + ) + val contentDescription = rule.activity.getString(CommonStrings.a11y_encryption_details) + rule.onNodeWithContentDescription(contentDescription).performClick() + eventsRecorder.assertList( + listOf( + TimelineEvents.OnScrollFinished(0), + TimelineEvents.ShowShieldDialog(MessageShield.UnverifiedIdentity(true)), + ) + ) + } + + @Test + fun `hide shield dialog`() { + val eventsRecorder = EventsRecorder() + rule.setTimelineView( + state = aTimelineState( + isLive = false, + eventSink = eventsRecorder, + messageShield = aCriticalShield(), + ), + ) + rule.clickOn(CommonStrings.action_ok) + eventsRecorder.assertSingle(TimelineEvents.HideShieldDialog) + } + + @Test + fun `scrolling near to the start of the loaded items triggers a pre-fetch`() { + val eventsRecorder = EventsRecorder() + val items = List(200) { + aTimelineItemEvent( + eventId = EventId("\$event_$it"), + content = aTimelineItemUnknownContent(), + ) + }.toImmutableList() + + rule.setTimelineView( + state = aTimelineState( + timelineItems = items, + eventSink = eventsRecorder, + focusedEventIndex = -1, + isLive = false, + ), + ) + + rule.onNodeWithTag("timeline").performScrollToIndex(180) + + rule.mainClock.advanceTimeBy(1000) + + eventsRecorder.assertList( + listOf( + TimelineEvents.OnScrollFinished(firstIndex = 0), + TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS), + ) + ) + } +} + +private fun AndroidComposeTestRule.setTimelineView( + state: TimelineState, + timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(), + onUserDataClick: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(), + onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(), + onMessageClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + onMessageLongClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + onSwipeToReply: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + onReactionClick: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(), + onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(), + onMoreReactionsClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + onReadReceiptClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + onJoinCallClick: () -> Unit = EnsureNeverCalled(), + forceJumpToBottomVisibility: Boolean = false, +) { + setSafeContent(clearAndroidUiDispatcher = true) { + TimelineView( + state = state, + timelineProtectionState = timelineProtectionState, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onContentClick = onMessageClick, + onMessageLongClick = onMessageLongClick, + onSwipeToReply = onSwipeToReply, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, + onReadReceiptClick = onReadReceiptClick, + onJoinCallClick = onJoinCallClick, + forceJumpToBottomVisibility = forceJumpToBottomVisibility, + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenterTest.kt new file mode 100644 index 0000000..a43b412 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenterTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.customreaction + +import app.cash.molecule.RecompositionMode +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.aTimelineItemReactions +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.recentemojis.test.FakeEmojibaseProvider +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class CustomReactionPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private val presenter = CustomReactionPresenter( + emojibaseProvider = FakeEmojibaseProvider(), + getRecentEmojis = { Result.success(persistentListOf()) }, + ) + + @Test + fun `present - handle selecting and de-selecting an event`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val event = aTimelineItemEvent(eventId = AN_EVENT_ID) + val initialState = awaitItem() + assertThat(initialState.target).isEqualTo(CustomReactionState.Target.None) + + initialState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event)) + + assertThat(awaitItem().target).isEqualTo(CustomReactionState.Target.Loading(event)) + + val eventId = (awaitItem().target as? CustomReactionState.Target.Success)?.event?.eventId + assertThat(eventId).isEqualTo(AN_EVENT_ID) + + initialState.eventSink(CustomReactionEvents.DismissCustomReactionSheet) + assertThat(awaitItem().target).isEqualTo(CustomReactionState.Target.None) + } + } + + @Test + fun `present - handle selected emojis`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val reactions = aTimelineItemReactions(count = 1, isHighlighted = true) + val event = aTimelineItemEvent(eventId = AN_EVENT_ID, timelineItemReactions = reactions) + val initialState = awaitItem() + assertThat(initialState.target).isEqualTo(CustomReactionState.Target.None) + + val key = reactions.reactions.first().key + initialState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event)) + + assertThat(awaitItem().target).isEqualTo(CustomReactionState.Target.Loading(event)) + + val stateWithSelectedEmojis = awaitItem() + val eventId = (stateWithSelectedEmojis.target as? CustomReactionState.Target.Success)?.event?.eventId + assertThat(eventId).isEqualTo(AN_EVENT_ID) + assertThat(stateWithSelectedEmojis.selectedEmoji).contains(key) + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerPresenterTest.kt new file mode 100644 index 0000000..aa177b0 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerPresenterTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.customreaction.picker + +import androidx.compose.runtime.InternalComposeApi +import androidx.compose.runtime.currentComposer +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.emojibasebindings.Emoji +import io.element.android.emojibasebindings.EmojibaseCategory +import io.element.android.emojibasebindings.EmojibaseStore +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class EmojiPickerPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `UpdateSearchQuery loads new results`() = runTest { + testPresenter { + skipItems(1) + + val initialState = awaitItem() + assertThat(initialState.searchQuery).isEmpty() + assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java) + + initialState.eventSink(EmojiPickerEvents.UpdateSearchQuery("smile")) + assertThat(awaitItem().searchQuery).isEqualTo("smile") + + val stateWithResults = awaitItem() + assertThat(stateWithResults.searchQuery).isEqualTo("smile") + assertThat(stateWithResults.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) + } + } + + @Test + fun `ToggleSearchActive toggles the search state`() = runTest { + testPresenter { + skipItems(1) + + val initialState = awaitItem() + assertThat(initialState.isSearchActive).isFalse() + + initialState.eventSink(EmojiPickerEvents.ToggleSearchActive(true)) + assertThat(awaitItem().isSearchActive).isTrue() + + initialState.eventSink(EmojiPickerEvents.ToggleSearchActive(false)) + assertThat(awaitItem().isSearchActive).isFalse() + } + } + + @Test + fun `recent emojis are automatically added to the categories if present`() = runTest { + val providedCategories = persistentListOf(emojiCategory(EmojibaseCategory.Activity)) + val presenter = createPresenter( + categories = providedCategories, + recentEmojis = persistentListOf("😊"), + ) + testPresenter(presenter) { + skipItems(1) + + val initialState = awaitItem() + assertThat(providedCategories.size).isNotEqualTo(initialState.categories.size) + assertThat(initialState.categories.size).isEqualTo(2) + } + } + + private fun TestScope.createPresenter( + categories: ImmutableList>> = persistentListOf(emojiCategory()), + recentEmojis: ImmutableList = persistentListOf(), + ) = EmojiPickerPresenter( + emojibaseStore = EmojibaseStore(categories.toMap().toImmutableMap()), + recentEmojis = recentEmojis, + coroutineDispatchers = testCoroutineDispatchers(), + ) + + private fun emojiCategory( + category: EmojibaseCategory = EmojibaseCategory.Activity, + emojis: ImmutableList = persistentListOf( + Emoji("1F3C3", "Smile", persistentListOf("smile"), persistentListOf("smile"), "😊", skins = null) + ) + ) = category to emojis + + @OptIn(InternalComposeApi::class) + private suspend fun TestScope.testPresenter( + presenter: EmojiPickerPresenter = createPresenter(), + testBlock: suspend TurbineTestContext.() -> Unit, + ) { + moleculeFlow(RecompositionMode.Immediate) { + // These are needed to load the history icon in the presenter + currentComposer.startProviders(arrayOf( + LocalContext provides InstrumentationRegistry.getInstrumentation().context, + LocalConfiguration provides InstrumentationRegistry.getInstrumentation().context.resources.configuration, + )) + val state = presenter.present() + currentComposer.endProviders() + state + }.test { + testBlock() + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt new file mode 100644 index 0000000..f8b416a --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.messages.impl.timeline.TimelineEvents +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.pressTag +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TimelineItemPollViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `answering a poll with first answer should emit a PollAnswerSelected event`() { + testAnswer(answerIndex = 0) + } + + @Test + fun `answering a poll with second answer should emit a PollAnswerSelected event`() { + testAnswer(answerIndex = 1) + } + + private fun testAnswer(answerIndex: Int) { + val eventsRecorder = EventsRecorder() + val content = aTimelineItemPollContent() + rule.setContent { + TimelineItemPollView( + content = content, + eventSink = eventsRecorder + ) + } + val answer = content.answerItems[answerIndex].answer + rule.onNode( + matcher = hasText(answer.text), + useUnmergedTree = true, + ).performClick() + eventsRecorder.assertSingle(TimelineEvents.SelectPollAnswer(content.eventId!!, answer.id)) + } + + @Test + fun `editing a poll should emit a PollEditClicked event`() { + val eventsRecorder = EventsRecorder() + val content = aTimelineItemPollContent( + isMine = true, + isEditable = true, + ) + rule.setContent { + TimelineItemPollView( + content = content, + eventSink = eventsRecorder + ) + } + rule.clickOn(CommonStrings.action_edit_poll) + eventsRecorder.assertSingle(TimelineEvents.EditPoll(content.eventId!!)) + } + + @Test + fun `closing a poll should emit a PollEndClicked event`() { + val eventsRecorder = EventsRecorder() + val content = aTimelineItemPollContent( + isMine = true, + ) + rule.setContent { + TimelineItemPollView( + content = content, + eventSink = eventsRecorder + ) + } + rule.clickOn(CommonStrings.action_end_poll) + // A confirmation dialog should be shown + eventsRecorder.assertEmpty() + rule.pressTag(TestTags.dialogPositive.value) + eventsRecorder.assertSingle(TimelineEvents.EndPoll(content.eventId!!)) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineTextViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineTextViewTest.kt new file mode 100644 index 0000000..154225a --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineTextViewTest.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.event + +import android.text.SpannableString +import android.text.SpannedString +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.core.text.buildSpannedString +import androidx.core.text.inSpans +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent +import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +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_USER_ID +import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache +import io.element.android.libraries.matrix.ui.messages.RoomNamesCache +import io.element.android.libraries.textcomposer.mentions.DefaultMentionSpanUpdater +import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater +import io.element.android.libraries.textcomposer.mentions.MentionSpan +import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme +import io.element.android.libraries.textcomposer.mentions.MentionSpanUpdater +import io.element.android.libraries.textcomposer.mentions.MentionType +import io.element.android.libraries.textcomposer.mentions.getMentionSpans +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.wysiwyg.view.spans.CustomMentionSpan +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TimelineTextViewTest { + @get:Rule val rule = createAndroidComposeRule() + + private val mentionSpanTheme = MentionSpanTheme(currentUserId = A_USER_ID) + private val formatLambda = lambdaRecorder { mentionType -> mentionType.toString() } + private val mentionSpanFormatter = FakeMentionSpanFormatter(formatLambda) + + @Test + fun `getTextWithResolvedMentions - does nothing for a non spannable CharSequence`() = runTest { + val charSequence = "Hello @alice:example.com" + val mentionSpanUpdater = aMentionSpanUpdater() + val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence)) + + assertThat(result.getMentionSpans()).isEmpty() + assert(formatLambda).isNeverCalled() + } + + @Test + fun `getTextWithResolvedMentions - does nothing if there are no mentions`() = runTest { + val charSequence = SpannableString("Hello @alice:example.com") + val mentionSpanUpdater = aMentionSpanUpdater() + val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence)) + + assertThat(result.getMentionSpans()).isEmpty() + assert(formatLambda).isNeverCalled() + } + + @Test + fun `getTextWithResolvedMentions - just returns the body if there is no formattedBody`() = runTest { + val charSequence = "Hello @alice:example.com" + val mentionSpanUpdater = aMentionSpanUpdater() + val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(body = charSequence, formattedBody = null)) + + assertThat(result.getMentionSpans()).isEmpty() + assertThat(result.toString()).isEqualTo(charSequence) + assert(formatLambda).isNeverCalled() + } + + @Test + fun `getTextWithResolvedMentions - with Room mention format correctly`() = runTest { + val mentionType = MentionType.Room(roomIdOrAlias = A_ROOM_ID_2.toRoomIdOrAlias()) + val charSequence = buildSpannedString { + append("Hello ") + inSpans(MentionSpan(mentionType)) { + append(A_ROOM_ID.value) + } + } + val mentionSpanUpdater = aMentionSpanUpdater() + val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence)) + + val expectedDisplayText = mentionType.toString() + assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText) + assertThat(result).isEqualTo(charSequence) + assert(formatLambda).isCalledOnce() + } + + @Test + fun `getTextWithResolvedMentions - replaces MentionSpan's text`() = runTest { + val mentionType = MentionType.User(userId = A_USER_ID) + val charSequence = buildSpannedString { + append("Hello ") + inSpans(MentionSpan(mentionType)) { + append("@NotAlice") + } + } + val mentionSpanUpdater = aMentionSpanUpdater() + val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence)) + + val expectedDisplayText = mentionType.toString() + assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText) + assert(formatLambda).isCalledOnce() + } + + @Test + fun `getTextWithResolvedMentions - replaces MentionSpan's text inside CustomMentionSpan`() = runTest { + val mentionType = MentionType.User(userId = A_USER_ID) + val charSequence = buildSpannedString { + append("Hello ") + inSpans(CustomMentionSpan(MentionSpan(mentionType))) { + append("@NotAlice") + } + } + val mentionSpanUpdater = aMentionSpanUpdater() + val expectedDisplayText = mentionType.toString() + val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence)) + assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText) + assert(formatLambda).isCalledOnce() + } + + private suspend fun AndroidComposeTestRule.getText( + mentionSpanUpdater: MentionSpanUpdater, + content: TimelineItemTextBasedContent, + ): CharSequence { + val completable = CompletableDeferred() + setContent { + CompositionLocalProvider( + LocalMentionSpanUpdater provides mentionSpanUpdater + ) { + completable.complete(getTextWithResolvedMentions(content = content)) + } + } + return completable.await() + } + + private fun aMentionSpanUpdater(): MentionSpanUpdater { + return DefaultMentionSpanUpdater( + formatter = mentionSpanFormatter, + theme = mentionSpanTheme, + roomMemberProfilesCache = RoomMemberProfilesCache(), + roomNamesCache = RoomNamesCache(), + ) + } + + private fun aTextContentWithFormattedBody(formattedBody: CharSequence?, body: String = "") = + TimelineItemTextContent( + body = body, + htmlDocument = null, + formattedBody = formattedBody ?: SpannedString(body), + isEdited = false + ) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenterTest.kt new file mode 100644 index 0000000..a05b7da --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenterTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.reactionsummary + +import app.cash.molecule.RecompositionMode +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.model.anAggregatedReaction +import io.element.android.libraries.matrix.api.room.RoomMembersState +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_USER_ID +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class ReactionSummaryPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private val aggregatedReaction = anAggregatedReaction(userId = A_USER_ID, key = "👍", isHighlighted = true) + private val roomMember = aRoomMember(userId = A_USER_ID, avatarUrl = AN_AVATAR_URL, displayName = A_USER_NAME) + private val summaryEvent = ReactionSummaryEvents.ShowReactionSummary(AN_EVENT_ID, listOf(aggregatedReaction), aggregatedReaction.key) + private val room = FakeBaseRoom().apply { + givenRoomMembersState(RoomMembersState.Ready(persistentListOf(roomMember))) + } + private val presenter = ReactionSummaryPresenter(room) + + @Test + fun `present - handle showing and hiding the reaction summary`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.target).isNull() + + initialState.eventSink(summaryEvent) + assertThat(awaitItem().target).isNotNull() + + initialState.eventSink(ReactionSummaryEvents.Clear) + assertThat(awaitItem().target).isNull() + } + } + + @Test + fun `present - handle reaction summary content and avatars populated`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.target).isNull() + + initialState.eventSink(summaryEvent) + val reactions = awaitItem().target?.reactions + assertThat(reactions?.count()).isEqualTo(1) + assertThat(reactions?.first()?.key).isEqualTo("👍") + assertThat(reactions?.first()?.senders?.first()?.senderId).isEqualTo(A_USER_ID) + assertThat(reactions?.first()?.senders?.first()?.user?.userId).isEqualTo(A_USER_ID) + assertThat(reactions?.first()?.senders?.first()?.user?.avatarUrl).isEqualTo(AN_AVATAR_URL) + assertThat(reactions?.first()?.senders?.first()?.user?.displayName).isEqualTo(A_USER_NAME) + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetPresenterTest.kt new file mode 100644 index 0000000..d39500f --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetPresenterTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet + +import app.cash.molecule.RecompositionMode +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.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class ReadReceiptBottomSheetPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - handle event selected`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val selectedEvent = aTimelineItemEvent() + initialState.eventSink(ReadReceiptBottomSheetEvents.EventSelected(selectedEvent)) + assertThat(awaitItem().selectedEvent).isSameInstanceAs(selectedEvent) + } + } + + @Test + fun `present - handle dismiss`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val selectedEvent = aTimelineItemEvent() + initialState.eventSink(ReadReceiptBottomSheetEvents.EventSelected(selectedEvent)) + skipItems(1) + initialState.eventSink(ReadReceiptBottomSheetEvents.Dismiss) + assertThat(awaitItem().selectedEvent).isNull() + } + } + + private fun createPresenter() = ReadReceiptBottomSheetPresenter() +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt new file mode 100644 index 0000000..160e368 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -0,0 +1,832 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.factories.event + +import android.net.Uri +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.SpannedString +import android.text.style.URLSpan +import androidx.core.text.buildSpannedString +import androidx.core.text.inSpans +import androidx.core.text.toSpannable +import com.google.common.truth.Truth.assertThat +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.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.TimelineItemStickerContent +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.TimelineItemVoiceContent +import io.element.android.features.messages.impl.utils.FakeTextPillificationHelper +import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider +import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.media.AudioDetails +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.ThumbnailInfo +import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo +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.MessageType +import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType +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.VideoMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.media.aMediaSource +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.test.timeline.aStickerContent +import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH +import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +@Suppress("LargeClass") +@RunWith(RobolectricTestRunner::class) +class TimelineItemContentMessageFactoryTest { + @Test + fun `test create OtherMessageType`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent(type = OtherMessageType(msgType = "a_type", body = "body")), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemTextContent( + body = "body", + htmlDocument = null, + isEdited = false, + formattedBody = SpannedString("body"), + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create LocationMessageType not null`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent(type = LocationMessageType("body", "geo:1,2", "description")), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemLocationContent( + body = "body", + location = Location(lat = 1.0, lon = 2.0, accuracy = 0.0F), + description = "description", + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create LocationMessageType null`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent(type = LocationMessageType("body", "", null)), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemTextContent( + body = "body", + htmlDocument = null, + isEdited = false, + formattedBody = "body", + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create TextMessageType`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent(type = TextMessageType("body", null)), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemTextContent( + body = "body", + htmlDocument = null, + isEdited = false, + formattedBody = SpannedString("body"), + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create TextMessageType with simple link`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent(type = TextMessageType("https://www.example.org", null)), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) as TimelineItemTextContent + val expected = TimelineItemTextContent( + body = "https://www.example.org", + htmlDocument = null, + isEdited = false, + formattedBody = buildSpannedString { + inSpans(URLSpan("https://www.example.org")) { + append("https://www.example.org") + } + } + ) + assertThat(result.body).isEqualTo(expected.body) + assertThat(result.htmlDocument).isEqualTo(expected.htmlDocument) + assertThat(result.plainText).isEqualTo(expected.plainText) + assertThat(result.isEdited).isEqualTo(expected.isEdited) + assertThat(result.formattedBody).isInstanceOf(Spanned::class.java) + val spanned = result.formattedBody as Spanned + assertThat(spanned.toString()).isEqualTo("https://www.example.org") + val urlSpans = spanned.getSpans(0, spanned.length, URLSpan::class.java) + assertThat(urlSpans).hasLength(1) + assertThat(urlSpans[0].url).isEqualTo("https://www.example.org") + } + + @Test + fun `test create TextMessageType with HTML formatted body`() = runTest { + val expected = buildSpannedString { + append("link to ") + inSpans(URLSpan("https://matrix.org")) { + append("https://matrix.org") + } + append(" ") + inSpans(URLSpan("https://matrix.org")) { + append("and manually added link") + } + }.toSpannable() + val sut = createTimelineItemContentMessageFactory( + htmlConverterTransform = { expected } + ) + val result = sut.create( + content = createMessageContent( + type = TextMessageType( + body = "body", + formatted = FormattedBody(MessageFormat.HTML, expected.toString()) + ) + ), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expected) + } + + @Test + fun `test create TextMessageType with unknown formatted body does nothing`() = runTest { + val sut = createTimelineItemContentMessageFactory( + htmlConverterTransform = { it } + ) + val result = sut.create( + content = createMessageContent( + type = TextMessageType( + body = "body", + formatted = FormattedBody(MessageFormat.UNKNOWN, "formatted") + ) + ), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("body")) + } + + @Test + fun `test create VideoMessageType`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent(type = VideoMessageType("filename", null, null, MediaSource("url"), null)), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemVideoContent( + filename = "filename", + fileSize = 0L, + caption = null, + formattedCaption = null, + isEdited = false, + duration = Duration.ZERO, + mediaSource = MediaSource(url = "url", json = null), + thumbnailSource = null, + aspectRatio = null, + blurHash = null, + height = null, + width = null, + mimeType = MimeTypes.OctetStream, + formattedFileSize = "0 Bytes", + thumbnailWidth = null, + thumbnailHeight = null, + fileExtension = "", + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create VideoMessageType with info`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent( + type = VideoMessageType( + filename = "body.mp4", + caption = "body.mp4 caption", + formattedCaption = FormattedBody(MessageFormat.HTML, "formatted"), + source = MediaSource("url"), + info = VideoInfo( + duration = 1.minutes, + height = 100, + width = 300, + mimetype = MimeTypes.Mp4, + size = 555, + thumbnailInfo = ThumbnailInfo( + height = 10L, + width = 5L, + mimetype = MimeTypes.Jpeg, + size = 111L, + ), + thumbnailSource = MediaSource("url_thumbnail"), + blurhash = A_BLUR_HASH, + ), + ), + isEdited = true, + ), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemVideoContent( + filename = "body.mp4", + fileSize = 555L, + caption = "body.mp4 caption", + formattedCaption = SpannedString("formatted"), + isEdited = true, + duration = 1.minutes, + mediaSource = MediaSource(url = "url", json = null), + thumbnailSource = MediaSource("url_thumbnail"), + aspectRatio = 3f, + blurHash = A_BLUR_HASH, + height = 100, + width = 300, + mimeType = MimeTypes.Mp4, + formattedFileSize = "555 Bytes", + thumbnailWidth = 5, + thumbnailHeight = 10, + fileExtension = "mp4", + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create AudioMessageType`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent(type = AudioMessageType("filename", null, null, MediaSource("url"), null)), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemAudioContent( + filename = "filename", + fileSize = 0L, + caption = null, + formattedCaption = null, + isEdited = false, + duration = Duration.ZERO, + mediaSource = MediaSource(url = "url", json = null), + mimeType = MimeTypes.OctetStream, + formattedFileSize = "0 Bytes", + fileExtension = "", + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create AudioMessageType with info`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent( + type = AudioMessageType( + filename = "body.mp3", + caption = null, + formattedCaption = null, + source = MediaSource("url"), + info = AudioInfo( + duration = 1.minutes, + size = 123L, + mimetype = MimeTypes.Mp3, + ) + ), + isEdited = true, + ), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemAudioContent( + filename = "body.mp3", + fileSize = 123L, + caption = null, + formattedCaption = null, + isEdited = true, + duration = 1.minutes, + mediaSource = MediaSource(url = "url", json = null), + mimeType = MimeTypes.Mp3, + formattedFileSize = "123 Bytes", + fileExtension = "mp3", + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create VoiceMessageType`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent(type = VoiceMessageType("filename", null, null, MediaSource("url"), null, null)), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemVoiceContent( + filename = "filename", + fileSize = 0L, + eventId = AN_EVENT_ID, + caption = null, + formattedCaption = null, + isEdited = false, + duration = Duration.ZERO, + mediaSource = MediaSource(url = "url", json = null), + mimeType = MimeTypes.OctetStream, + waveform = emptyList().toImmutableList(), + fileExtension = "", + formattedFileSize = "0 Bytes", + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create VoiceMessageType with info`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent( + type = VoiceMessageType( + filename = "body.ogg", + caption = null, + formattedCaption = null, + source = MediaSource("url"), + info = AudioInfo( + duration = 1.minutes, + size = 123L, + mimetype = MimeTypes.Ogg, + ), + details = AudioDetails( + duration = 1.minutes, + waveform = persistentListOf(1f, 2f), + ), + ), + isEdited = true, + ), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemVoiceContent( + eventId = AN_EVENT_ID, + filename = "body.ogg", + fileSize = 123L, + caption = null, + formattedCaption = null, + isEdited = true, + duration = 1.minutes, + mediaSource = MediaSource(url = "url", json = null), + mimeType = MimeTypes.Ogg, + waveform = persistentListOf(1f, 2f), + fileExtension = "ogg", + formattedFileSize = "123 Bytes", + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create ImageMessageType`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent(type = ImageMessageType("filename", "body", null, MediaSource("url"), null)), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemImageContent( + filename = "filename", + fileSize = 0L, + caption = "body", + formattedCaption = null, + isEdited = false, + mediaSource = MediaSource(url = "url", json = null), + thumbnailSource = null, + formattedFileSize = "0 Bytes", + fileExtension = "", + mimeType = MimeTypes.OctetStream, + blurhash = null, + width = null, + height = null, + thumbnailWidth = null, + thumbnailHeight = null, + aspectRatio = null + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create StickerMessageType`() = runTest { + val sut = createTimelineItemContentStickerFactory() + val result = sut.create( + content = createStickerContent( + filename = "filename", + inImageInfo = ImageInfo(32, 32, "image/webp", 8192, null, MediaSource("thumbnail://url"), null), + inUrl = "url" + ) + ) + val expected = TimelineItemStickerContent( + filename = "filename", + fileSize = 8_192L, + caption = null, + formattedCaption = null, + isEdited = false, + mediaSource = MediaSource(url = "url", json = null), + thumbnailSource = MediaSource(url = "thumbnail://url", json = null), + formattedFileSize = "8192 Bytes", + fileExtension = "", + mimeType = MimeTypes.WebP, + blurhash = null, + width = 32, + height = 32, + aspectRatio = 1.0f + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create ImageMessageType with info`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent( + type = ImageMessageType( + filename = "body.jpg", + caption = "body.jpg caption", + formattedCaption = FormattedBody(MessageFormat.HTML, "formatted"), + source = MediaSource("url"), + info = ImageInfo( + height = 10L, + width = 5L, + mimetype = MimeTypes.Jpeg, + size = 888L, + thumbnailInfo = ThumbnailInfo( + height = 10L, + width = 5L, + mimetype = MimeTypes.Jpeg, + size = 111L, + ), + thumbnailSource = MediaSource("url_thumbnail"), + blurhash = A_BLUR_HASH, + ) + ), + isEdited = true, + ), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemImageContent( + filename = "body.jpg", + fileSize = 888L, + caption = "body.jpg caption", + formattedCaption = SpannedString("formatted"), + isEdited = true, + mediaSource = MediaSource(url = "url", json = null), + thumbnailSource = MediaSource("url_thumbnail"), + formattedFileSize = "888 Bytes", + fileExtension = "jpg", + mimeType = MimeTypes.Jpeg, + blurhash = A_BLUR_HASH, + width = 5, + height = 10, + thumbnailWidth = 5, + thumbnailHeight = 10, + aspectRatio = 0.5f, + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create FileMessageType`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent(type = FileMessageType("filename", null, null, MediaSource("url"), null)), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemFileContent( + filename = "filename", + fileSize = 0L, + caption = null, + formattedCaption = null, + isEdited = false, + mediaSource = MediaSource(url = "url", json = null), + thumbnailSource = null, + formattedFileSize = "0 Bytes", + fileExtension = "", + mimeType = MimeTypes.OctetStream + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create FileMessageType with info`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent( + type = FileMessageType( + filename = "body.pdf", + caption = null, + formattedCaption = null, + source = MediaSource("url"), + info = FileInfo( + mimetype = MimeTypes.Pdf, + size = 123L, + thumbnailInfo = ThumbnailInfo( + height = 10L, + width = 5L, + mimetype = MimeTypes.Jpeg, + size = 111L, + ), + thumbnailSource = MediaSource("url_thumbnail"), + ) + ), + isEdited = true, + ), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemFileContent( + filename = "body.pdf", + fileSize = 123L, + caption = null, + formattedCaption = null, + isEdited = true, + mediaSource = MediaSource(url = "url", json = null), + thumbnailSource = MediaSource("url_thumbnail"), + formattedFileSize = "123 Bytes", + fileExtension = "pdf", + mimeType = MimeTypes.Pdf + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create NoticeMessageType`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent(type = NoticeMessageType("body", null)), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemNoticeContent( + body = "body", + htmlDocument = null, + formattedBody = SpannedString("body"), + isEdited = false, + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create NoticeMessageType with HTML formatted body`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent( + type = NoticeMessageType( + body = "body", + formatted = FormattedBody(MessageFormat.HTML, "formatted") + ) + ), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + (result as TimelineItemNoticeContent).formattedBody.assertSpannedEquals(SpannedString("formatted")) + } + + @Test + fun `test create EmoteMessageType`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent(type = EmoteMessageType("body", null)), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + val expected = TimelineItemEmoteContent( + body = "* Bob body", + htmlDocument = null, + formattedBody = SpannedString("* Bob body"), + isEdited = false, + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test create EmoteMessageType with HTML formatted body`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent( + type = EmoteMessageType( + body = "body", + formatted = FormattedBody(MessageFormat.HTML, "formatted") + ) + ), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + + (result as TimelineItemEmoteContent).formattedBody.assertSpannedEquals(SpannableString("* Bob formatted")) + } + + @Test + fun `a message with existing URLSpans keeps it after linkification`() = runTest { + val expectedSpanned = SpannableStringBuilder().apply { + append("Test ") + inSpans(URLSpan("https://www.example.org")) { + append("me@matrix.org") + } + }.toSpannable() + val sut = createTimelineItemContentMessageFactory( + htmlConverterTransform = { expectedSpanned }, + permalinkParser = FakePermalinkParser { PermalinkData.FallbackLink(Uri.EMPTY) } + ) + val result = sut.create( + content = createMessageContent( + type = TextMessageType( + body = "Test [me@matrix.org](https://www.example.org)", + formatted = FormattedBody(MessageFormat.HTML, "Test me@matrix.org") + ) + ), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + (result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned) + } + + @Test + fun `a message with plain URL in a formatted body Spanned format gets linkified too`() = runTest { + val expectedSpanned = buildSpannedString { + append("Test ") + inSpansWithFlags(URLSpan("https://www.example.org"), flags = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) { + append("https://www.example.org") + } + } + val sut = createTimelineItemContentMessageFactory( + htmlConverterTransform = { expectedSpanned }, + permalinkParser = FakePermalinkParser { PermalinkData.FallbackLink(Uri.EMPTY) } + ) + val result = sut.create( + content = createMessageContent( + type = TextMessageType( + body = "Test [me@matrix.org](https://www.example.org)", + formatted = FormattedBody(MessageFormat.HTML, "Test https://www.example.org") + ) + ), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + (result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned) + } + + @Test + fun `a message with plain URL in a formatted body with plain text format gets linkified too`() = runTest { + val resultString = "Test https://www.example.org" + val expectedSpanned = buildSpannedString { + append("Test ") + inSpansWithFlags(URLSpan("https://www.example.org"), flags = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) { + append("https://www.example.org") + } + }.toSpannable() + val sut = createTimelineItemContentMessageFactory( + htmlConverterTransform = { resultString }, + permalinkParser = FakePermalinkParser { PermalinkData.FallbackLink(Uri.EMPTY) } + ) + val result = sut.create( + content = createMessageContent( + type = TextMessageType( + body = "Test [me@matrix.org](https://www.example.org)", + formatted = FormattedBody(MessageFormat.HTML, "Test https://www.example.org") + ) + ), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + + (result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned) + } + + private fun createMessageContent( + body: String = "Body", + inReplyTo: InReplyTo? = null, + isEdited: Boolean = false, + threadInfo: EventThreadInfo? = null, + type: MessageType, + ): MessageContent { + return MessageContent( + body = body, + inReplyTo = inReplyTo, + isEdited = isEdited, + threadInfo = threadInfo, + type = type, + ) + } + + private fun createTimelineItemContentMessageFactory( + htmlConverterTransform: (String) -> CharSequence = { it }, + permalinkParser: FakePermalinkParser = FakePermalinkParser(), + ) = TimelineItemContentMessageFactory( + fileSizeFormatter = FakeFileSizeFormatter(), + fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), + htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform), + permalinkParser = permalinkParser, + textPillificationHelper = FakeTextPillificationHelper(), + ) + + private fun createStickerContent( + filename: String = "filename", + inImageInfo: ImageInfo, + inUrl: String, + body: String? = null, + ): StickerContent { + return aStickerContent( + filename = filename, + body = body, + info = inImageInfo, + mediaSource = aMediaSource(url = inUrl), + ) + } + + private fun createTimelineItemContentStickerFactory() = TimelineItemContentStickerFactory( + fileSizeFormatter = FakeFileSizeFormatter(), + fileExtensionExtractor = FileExtensionExtractorWithoutValidation() + ) +} + +private inline fun SpannableStringBuilder.inSpansWithFlags(span: Any, flags: Int, action: SpannableStringBuilder.() -> Unit) { + val start = this.length + action() + val end = this.length + setSpan(span, start, end, flags) +} + +fun CharSequence?.assertSpannedEquals(other: CharSequence?) { + if (this == null && other == null) { + return + } else if (this is Spanned && other is Spanned) { + assertThat(this.toString()).isEqualTo(other.toString()) + assertThat(this.length).isEqualTo(other.length) + val thisSpans = this.getSpans(0, this.length, Any::class.java) + val otherSpans = other.getSpans(0, other.length, Any::class.java) + if (thisSpans.size != otherSpans.size) { + fail("Expected ${thisSpans.size} spans, got ${otherSpans.size}") + } + thisSpans.forEachIndexed { index, span -> + val otherSpan = otherSpans[index] + // URLSpans don't have a proper `equals` implementation, so we compare the URL instead + if (span is URLSpan && otherSpan is URLSpan) { + assertThat(span.url).isEqualTo(otherSpan.url) + } else { + assertThat(span).isEqualTo(otherSpan) + } + assertThat(this.getSpanStart(span)).isEqualTo(other.getSpanStart(otherSpan)) + assertThat(this.getSpanEnd(span)).isEqualTo(other.getSpanEnd(otherSpan)) + assertThat(this.getSpanFlags(span)).isEqualTo(other.getSpanFlags(otherSpan)) + } + } else { + val thisString = this?.toString() ?: "null" + val otherString = other?.toString() ?: "null" + fail("Expected Spanned, got $thisString and $otherString") + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt new file mode 100644 index 0000000..2a31d90 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.groups + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.fixtures.aMessageEvent +import io.element.android.features.messages.impl.timeline.aTimelineItemDebugInfo +import io.element.android.features.messages.impl.timeline.aTimelineItemReactions +import io.element.android.features.messages.impl.timeline.model.ReadReceiptData +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts +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.core.UniqueId +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.core.FakeSendHandle +import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady +import kotlinx.collections.immutable.toImmutableList +import org.junit.Test + +class TimelineItemGrouperTest { + private val sut = TimelineItemGrouper() + + private val aGroupableItem = TimelineItem.Event( + id = UniqueId("0"), + senderId = A_USER_ID, + senderAvatar = anAvatarData(), + senderProfile = aProfileTimelineDetailsReady(displayName = ""), + content = TimelineItemStateEventContent(body = "a state event"), + reactionsState = aTimelineItemReactions(count = 0), + readReceiptState = TimelineItemReadReceipts(emptyList().toImmutableList()), + localSendState = LocalEventSendState.Sent(AN_EVENT_ID), + isEditable = false, + canBeRepliedTo = false, + inReplyTo = null, + threadInfo = null, + origin = null, + timelineItemDebugInfoProvider = { aTimelineItemDebugInfo() }, + messageShieldProvider = { null }, + sendHandleProvider = { FakeSendHandle() }, + ) + private val aNonGroupableItem = aMessageEvent() + private val aNonGroupableItemNoEvent = TimelineItem.Virtual(UniqueId("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 = UniqueId("1")), + aGroupableItem.copy(id = UniqueId("0")), + ), + ) + assertThat(result).isEqualTo( + listOf( + TimelineItem.GroupedEvents( + id = computeGroupIdWith(aGroupableItem), + events = listOf( + aGroupableItem.copy(id = UniqueId("0")), + aGroupableItem.copy(id = UniqueId("1")), + ).toImmutableList(), + aggregatedReadReceipts = emptyList().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( + id = computeGroupIdWith(aGroupableItem), + events = listOf( + aGroupableItem, + aGroupableItem, + ).toImmutableList(), + aggregatedReadReceipts = emptyList().toImmutableList(), + ), + aNonGroupableItem, + TimelineItem.GroupedEvents( + id = computeGroupIdWith(aGroupableItem), + events = listOf( + aGroupableItem, + aGroupableItem, + aGroupableItem, + ).toImmutableList(), + aggregatedReadReceipts = emptyList().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 = UniqueId("1")), + aGroupableItem.copy(id = UniqueId("2")) + ) + val expectedGroupId = sut.group(groupableItems).first().identifier() + groupableItems.add(0, aGroupableItem.copy(UniqueId("3"))) + groupableItems.add(2, aGroupableItem.copy(UniqueId("4"))) + groupableItems.add(aGroupableItem.copy(UniqueId("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/impl/timeline/model/AggregatedReactionTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionTest.kt new file mode 100644 index 0000000..2cc690a --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionTest.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AggregatedReactionTest { + @Test + fun `reaction display key is shortened`() { + val reaction = anAggregatedReaction( + key = "1234567890123456790", + count = 1 + ) + + assertEquals("1234567890123456…", reaction.displayKey) + } + + @Test + fun `reaction count and isHighlighted are computed correctly`() { + val reaction = anAggregatedReaction( + key = "1234567890123456790", + count = 3, + isHighlighted = true + ) + + assertEquals(3, reaction.count) + assertEquals(true, reaction.isHighlighted) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/ProtectedViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/ProtectedViewTest.kt new file mode 100644 index 0000000..af3acee --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/ProtectedViewTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.protection + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.lambda.lambdaError +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ProtectedViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `when hideContent is false, the content is rendered`() { + rule.setProtectedView( + hideContent = false, + content = { + Text("Hello") + } + ) + rule.onNodeWithText("Hello").assertExists() + } + + @Test + fun `when hideContent is true, the content is not rendered, and user can reveal it`() { + ensureCalledOnce { + rule.setProtectedView( + hideContent = true, + onShowClick = it, + content = { + Text("Hello") + } + ) + rule.onNodeWithText("Hello").assertDoesNotExist() + rule.clickOn(CommonStrings.action_show) + } + } +} + +private fun AndroidComposeTestRule.setProtectedView( + hideContent: Boolean = false, + onShowClick: () -> Unit = { lambdaError() }, + content: @Composable () -> Unit = {}, +) { + setContent { + ProtectedView( + hideContent = hideContent, + onShowClick = onShowClick, + content = content + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt new file mode 100644 index 0000000..d6af8eb --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.protection + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.media.MediaPreviewConfig +import io.element.android.libraries.matrix.api.media.MediaPreviewService +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.media.FakeMediaPreviewService +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class TimelineProtectionPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderAll) + } + } + + @Test + fun `present - media preview value off`() = runTest { + val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Off, hideInviteAvatar = false) + val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig)) + val presenter = createPresenter(mediaPreviewService = mediaPreviewService) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf())) + // ShowContent with null should have no effect. + initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = null)) + initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = AN_EVENT_ID)) + val finalState = awaitItem() + assertThat(finalState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID))) + } + } + + @Test + fun `present - media preview value private in public room`() = runTest { + val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Private, hideInviteAvatar = false) + val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig)) + val room = FakeBaseRoom(initialRoomInfo = aRoomInfo(joinRule = JoinRule.Public)) + val presenter = createPresenter(mediaPreviewService = mediaPreviewService, room = room) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf())) + // ShowContent with null should have no effect. + initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = null)) + initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = AN_EVENT_ID)) + val finalState = awaitItem() + assertThat(finalState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID))) + } + } + + @Test + fun `present - media preview value private in non public room`() = runTest { + val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Private, hideInviteAvatar = false) + val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig)) + val room = FakeBaseRoom(initialRoomInfo = aRoomInfo(joinRule = JoinRule.Invite)) + val presenter = createPresenter(mediaPreviewService = mediaPreviewService, room = room) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderAll) + // ShowContent with null should have no effect. + initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = null)) + initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = AN_EVENT_ID)) + } + } + + private fun createPresenter( + room: BaseRoom = FakeBaseRoom(), + mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(), + ) = TimelineProtectionPresenter( + mediaPreviewService = mediaPreviewService, + room = room, + ) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStateTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStateTest.kt new file mode 100644 index 0000000..645039b --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStateTest.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.protection + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Test + +class TimelineProtectionStateTest { + @Test + fun `when protectionState is RenderAll, hideMediaContent always return null`() { + val sut = aTimelineProtectionState( + protectionState = ProtectionState.RenderAll + ) + assertThat(sut.hideMediaContent(null)).isFalse() + assertThat(sut.hideMediaContent(AN_EVENT_ID)).isFalse() + } + + @Test + fun `when protectionState is RenderOnly with empty set, hideMediaContent always return true`() { + val sut = aTimelineProtectionState( + protectionState = ProtectionState.RenderOnly(persistentSetOf()) + ) + assertThat(sut.hideMediaContent(null)).isTrue() + assertThat(sut.hideMediaContent(AN_EVENT_ID)).isTrue() + } + + @Test + fun `when protectionState is RenderOnly with an Event, hideMediaContent can return true or false`() { + val sut = aTimelineProtectionState( + protectionState = ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID)) + ) + assertThat(sut.hideMediaContent(null)).isTrue() + assertThat(sut.hideMediaContent(AN_EVENT_ID)).isFalse() + assertThat(sut.hideMediaContent(AN_EVENT_ID_2)).isTrue() + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt new file mode 100644 index 0000000..896c529 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.typing + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.Event +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomMembersState +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_ID_3 +import io.element.android.libraries.matrix.test.A_USER_ID_4 +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +@Suppress("LargeClass") +class TypingNotificationPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.renderTypingNotifications).isTrue() + assertThat(initialState.typingMembers).isEmpty() + assertThat(initialState.reserveSpace).isFalse() + } + } + + @Test + fun `present - typing notification disabled`() = runTest { + val typingMembersFlow = MutableStateFlow>(emptyList()) + val room = FakeJoinedRoom(roomTypingMembersFlow = typingMembersFlow) + val sessionPreferencesStore = InMemorySessionPreferencesStore( + isRenderTypingNotificationsEnabled = false, + ) + val presenter = createPresenter( + joinedRoom = room, + sessionPreferencesStore = sessionPreferencesStore, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.renderTypingNotifications).isFalse() + assertThat(initialState.typingMembers).isEmpty() + typingMembersFlow.emit(listOf(A_USER_ID_2)) + expectNoEvents() + // Preferences changes + sessionPreferencesStore.setRenderTypingNotifications(true) + skipItems(1) + val oneMemberTypingState = awaitItem() + assertThat(oneMemberTypingState.renderTypingNotifications).isTrue() + assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1) + assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo( + TypingRoomMember( + disambiguatedDisplayName = A_USER_ID_2.value, + ) + ) + // Preferences changes again + sessionPreferencesStore.setRenderTypingNotifications(false) + skipItems(2) + val finalState = awaitItem() + assertThat(finalState.renderTypingNotifications).isFalse() + assertThat(finalState.typingMembers).isEmpty() + } + } + + @Test + fun `present - state is updated when a member is typing, member is not known`() = runTest { + val typingMembersFlow = MutableStateFlow>(emptyList()) + val room = FakeJoinedRoom(roomTypingMembersFlow = typingMembersFlow) + val presenter = createPresenter(joinedRoom = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.typingMembers).isEmpty() + typingMembersFlow.emit(listOf(A_USER_ID_2)) + val oneMemberTypingState = awaitItem() + assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1) + assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo( + TypingRoomMember( + disambiguatedDisplayName = A_USER_ID_2.value, + ) + ) + // User stops typing + typingMembersFlow.emit(emptyList()) + skipItems(1) + val finalState = awaitItem() + assertThat(finalState.typingMembers).isEmpty() + } + } + + @Test + fun `present - state is updated when a member is typing, member is known`() = runTest { + val aKnownRoomMember = createKnownRoomMember(userId = A_USER_ID_2) + val typingMembersFlow = MutableStateFlow>(emptyList()) + val room = FakeJoinedRoom(roomTypingMembersFlow = typingMembersFlow).apply { + givenRoomMembersState( + RoomMembersState.Ready( + listOf( + createKnownRoomMember(A_USER_ID), + aKnownRoomMember, + createKnownRoomMember(A_USER_ID_3), + createKnownRoomMember(A_USER_ID_4), + ).toImmutableList() + ) + ) + } + val presenter = createPresenter(joinedRoom = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.typingMembers).isEmpty() + typingMembersFlow.emit(listOf(A_USER_ID_2)) + val oneMemberTypingState = awaitItem() + assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1) + assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo( + TypingRoomMember( + disambiguatedDisplayName = "Alice Doe (@bob:server.org)", + ) + ) + // User stops typing + typingMembersFlow.emit(emptyList()) + skipItems(1) + val finalState = awaitItem() + assertThat(finalState.typingMembers).isEmpty() + } + } + + @Test + fun `present - state is updated when a member is typing, member is not known, then known`() = runTest { + val aKnownRoomMember = createKnownRoomMember(A_USER_ID_2) + val typingMembersFlow = MutableStateFlow>(emptyList()) + val room = FakeJoinedRoom(roomTypingMembersFlow = typingMembersFlow) + val presenter = createPresenter(joinedRoom = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.typingMembers).isEmpty() + typingMembersFlow.emit(listOf(A_USER_ID_2)) + val oneMemberTypingState = awaitItem() + assertThat(oneMemberTypingState.typingMembers.size).isEqualTo(1) + assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo( + TypingRoomMember( + disambiguatedDisplayName = A_USER_ID_2.value, + ) + ) + // User is getting known + room.givenRoomMembersState( + RoomMembersState.Ready( + listOf(aKnownRoomMember).toImmutableList() + ) + ) + skipItems(1) + val finalState = awaitItem() + assertThat(finalState.typingMembers.first()).isEqualTo( + TypingRoomMember( + disambiguatedDisplayName = "Alice Doe (@bob:server.org)", + ) + ) + } + } + + @Test + fun `present - reserveSpace becomes true once we get the first typing notification with room members`() = runTest { + val typingMembersFlow = MutableStateFlow>(emptyList()) + val room = FakeJoinedRoom(roomTypingMembersFlow = typingMembersFlow) + val presenter = createPresenter(joinedRoom = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.typingMembers).isEmpty() + typingMembersFlow.emit(listOf(A_USER_ID_2)) + skipItems(1) + val updatedTypingState = awaitItem() + assertThat(updatedTypingState.reserveSpace).isTrue() + // User stops typing + typingMembersFlow.emit(emptyList()) + // Is still true for all future events + val futureEvents = cancelAndConsumeRemainingEvents() + for (event in futureEvents) { + if (event is Event.Item) { + assertThat(event.value.reserveSpace).isTrue() + } + } + } + } + + private fun createPresenter( + joinedRoom: JoinedRoom = FakeJoinedRoom().apply { + givenRoomInfo(aRoomInfo(id = roomId, name = "")) + }, + sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore( + isRenderTypingNotificationsEnabled = true + ), + ) = TypingNotificationPresenter( + room = joinedRoom, + sessionPreferencesStore = sessionPreferencesStore, + ) + + private fun createKnownRoomMember( + userId: UserId, + ) = aRoomMember( + userId = userId, + displayName = "Alice Doe", + isNameAmbiguous = true, + ) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt new file mode 100644 index 0000000..16c8c92 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.utils + +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.textcomposer.mentions.MentionSpanFormatter +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme +import io.element.android.libraries.textcomposer.mentions.MentionType +import io.element.android.libraries.textcomposer.mentions.getMentionSpans +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DefaultTextPillificationHelperTest { + @Test + fun `pillify - adds pills for user ids`() { + val text = "A @user:server.com" + val formatter = FakeMentionSpanFormatter() + val userId = UserId("@user:server.com") + val helper = aTextPillificationHelper( + permalinkParser = FakePermalinkParser(result = { + PermalinkData.UserLink(userId) + }), + permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { + Result.success("https://matrix.to/#/@user:server.com") + }), + mentionSpanFormatter = formatter, + ) + val pillified = helper.pillify(text) + val mentionSpans = pillified.getMentionSpans() + assertThat(mentionSpans).hasSize(1) + val mentionSpan = mentionSpans.first() + assertThat(mentionSpan.type).isInstanceOf(MentionType.User::class.java) + val userType = mentionSpan.type as MentionType.User + assertThat(userType.userId).isEqualTo(userId) + val formatted = formatter.formatDisplayText(MentionType.User(userId)) + assertThat(mentionSpan.displayText.toString()).isEqualTo(formatted) + } + + @Test + fun `pillify - adds pills for room aliases`() { + val text = "A #room:server.com" + val roomAlias = RoomAlias("#room:server.com") + val formatter = FakeMentionSpanFormatter() + val helper = aTextPillificationHelper( + permalinkParser = FakePermalinkParser(result = { + PermalinkData.RoomLink(RoomIdOrAlias.Alias(roomAlias)) + }), + permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = { + Result.success("https://matrix.to/#/#room:server.com") + }), + mentionSpanFormatter = formatter, + ) + val pillified = helper.pillify(text) + val mentionSpans = pillified.getMentionSpans() + assertThat(mentionSpans).hasSize(1) + val mentionSpan = mentionSpans.first() + assertThat(mentionSpan.type).isInstanceOf(MentionType.Room::class.java) + val roomType = mentionSpan.type as MentionType.Room + assertThat(roomType.roomIdOrAlias).isEqualTo(roomAlias.toRoomIdOrAlias()) + val formatted = formatter.formatDisplayText(MentionType.Room(roomAlias.toRoomIdOrAlias())) + assertThat(mentionSpan.displayText.toString()).isEqualTo(formatted) + } + + @Test + fun `pillify - adds pills for @room mentions`() { + val text = "An @room mention" + val formatter = FakeMentionSpanFormatter() + val helper = aTextPillificationHelper( + permalinkParser = FakePermalinkParser(result = { + PermalinkData.FallbackLink(Uri.EMPTY) + }), + mentionSpanFormatter = formatter, + ) + val pillified = helper.pillify(text) + val mentionSpans = pillified.getMentionSpans() + assertThat(mentionSpans).hasSize(1) + val mentionSpan = mentionSpans.first() + assertThat(mentionSpan.type).isEqualTo(MentionType.Everyone) + val formatted = formatter.formatDisplayText(MentionType.Everyone) + assertThat(mentionSpan.displayText.toString()).isEqualTo(formatted) + } + + @Test + fun `pillify - adds pills for message permalinks`() { + val text = "Check this message: https://matrix.to/#/!roomid:server.com/$123" + val roomId = RoomId("!roomid:server.com") + val eventId = EventId("$123") + val formatter = FakeMentionSpanFormatter() + val helper = aTextPillificationHelper( + permalinkParser = FakePermalinkParser(result = { + PermalinkData.RoomLink( + roomIdOrAlias = RoomIdOrAlias.Id(roomId), + eventId = eventId + ) + }), + permalinkBuilder = FakePermalinkBuilder(), + mentionSpanFormatter = formatter, + ) + val pillified = helper.pillify(text) + val mentionSpans = pillified.getMentionSpans() + assertThat(mentionSpans).hasSize(1) + val mentionSpan = mentionSpans.first() + assertThat(mentionSpan.type).isInstanceOf(MentionType.Message::class.java) + val messageType = mentionSpan.type as MentionType.Message + assertThat(messageType.roomIdOrAlias).isEqualTo(roomId.toRoomIdOrAlias()) + assertThat(messageType.eventId).isEqualTo(eventId) + val formatted = formatter.formatDisplayText(MentionType.Message(roomId.toRoomIdOrAlias(), eventId)) + assertThat(mentionSpan.displayText.toString()).isEqualTo(formatted) + } + + @Test + fun `pillify - with pillifyPermalinks false does not add pills for permalinks`() { + val text = "Check this message: https://matrix.to/#/!roomid:server.com/$123" + val roomId = RoomId("!roomid:server.com") + val eventId = EventId("$123") + val formatter = FakeMentionSpanFormatter() + val helper = aTextPillificationHelper( + permalinkParser = FakePermalinkParser(result = { + PermalinkData.RoomLink( + roomIdOrAlias = RoomIdOrAlias.Id(roomId), + eventId = eventId + ) + }), + permalinkBuilder = FakePermalinkBuilder(), + mentionSpanFormatter = formatter, + ) + val pillified = helper.pillify(text, pillifyPermalinks = false) + val mentionSpans = pillified.getMentionSpans() + assertThat(mentionSpans).isEmpty() + } + + @Test + fun `pillify - with pillifyPermalinks false still adds pills for matrix patterns`() { + val text = "A @user:server.com mention and a permalink https://matrix.to/#/!roomid:server.com/$123" + val userId = UserId("@user:server.com") + val formatter = FakeMentionSpanFormatter() + val helper = aTextPillificationHelper( + permalinkParser = FakePermalinkParser(result = { + PermalinkData.UserLink(userId) + }), + permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { + Result.success("https://matrix.to/#/@user:server.com") + }), + mentionSpanFormatter = formatter, + ) + val pillified = helper.pillify(text, pillifyPermalinks = false) + val mentionSpans = pillified.getMentionSpans() + assertThat(mentionSpans).hasSize(1) + val mentionSpan = mentionSpans.first() + assertThat(mentionSpan.type).isInstanceOf(MentionType.User::class.java) + val userType = mentionSpan.type as MentionType.User + assertThat(userType.userId).isEqualTo(userId) + } + + @Test + fun `pillify - with pillifyPermalinks true adds pills for both matrix patterns and permalinks`() { + val text = "A @user:server.com mention and a permalink https://matrix.to/#/!roomid:server.com/$123" + val userId = UserId("@user:server.com") + val roomId = RoomId("!roomid:server.com") + val eventId = EventId("$123") + val formatter = FakeMentionSpanFormatter() + val permalinkParser = FakePermalinkParser(result = { url -> + if (url.contains("matrix.to")) { + PermalinkData.RoomLink( + roomIdOrAlias = RoomIdOrAlias.Id(roomId), + eventId = eventId + ) + } else { + PermalinkData.UserLink(userId) + } + }) + val helper = aTextPillificationHelper( + permalinkParser = permalinkParser, + permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { + Result.success("https://matrix.to/#/@user:server.com") + }), + mentionSpanFormatter = formatter, + ) + val pillified = helper.pillify(text, pillifyPermalinks = true) + val mentionSpans = pillified.getMentionSpans() + assertThat(mentionSpans).hasSize(2) + + // Check that we have both a user mention and a message mention + val types = mentionSpans.map { it.type::class.java } + assertThat(types).contains(MentionType.User::class.java) + assertThat(types).contains(MentionType.Message::class.java) + + // Verify the user mention + val userMention = mentionSpans.first { it.type is MentionType.User }.type as MentionType.User + assertThat(userMention.userId).isEqualTo(userId) + + // Verify the message mention + val messageMention = mentionSpans.first { it.type is MentionType.Message }.type as MentionType.Message + assertThat(messageMention.roomIdOrAlias).isEqualTo(roomId.toRoomIdOrAlias()) + assertThat(messageMention.eventId).isEqualTo(eventId) + } + + private fun aTextPillificationHelper( + permalinkParser: PermalinkParser = FakePermalinkParser(), + permalinkBuilder: FakePermalinkBuilder = FakePermalinkBuilder(), + mentionSpanFormatter: MentionSpanFormatter = FakeMentionSpanFormatter(), + ): TextPillificationHelper { + val mentionSpanProvider = MentionSpanProvider( + permalinkParser = permalinkParser, + mentionSpanFormatter = mentionSpanFormatter, + mentionSpanTheme = MentionSpanTheme(A_USER_ID), + ) + return DefaultTextPillificationHelper( + mentionSpanProvider = mentionSpanProvider, + permalinkBuilder = permalinkBuilder, + permalinkParser = permalinkParser, + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/EmojiTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/EmojiTest.kt new file mode 100644 index 0000000..5c979db --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/EmojiTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.utils + +import org.junit.Assert +import org.junit.Assert.assertTrue +import org.junit.Test + +class EmojiTest { + @Test + fun validEmojis() { + // Simple single/multiple single-codepoint emojis per string + assertTrue("👍".containsOnlyEmojisInternal()) + assertTrue("😀".containsOnlyEmojisInternal()) + assertTrue("🙂🙁".containsOnlyEmojisInternal()) + assertTrue("👁❤️🍝".containsOnlyEmojisInternal()) // 👁 is a pictographic + assertTrue("👨‍👩‍👦1️⃣🚀👳🏾‍♂️🪩".containsOnlyEmojisInternal()) + assertTrue("🌍🌎🌏".containsOnlyEmojisInternal()) + + // Awkward multi-codepoint graphemes + assertTrue("🧑‍🧑‍🧒‍🧒".containsOnlyEmojisInternal()) + assertTrue("🏴‍☠".containsOnlyEmojisInternal()) + assertTrue("👩🏿‍🔧".containsOnlyEmojisInternal()) + + Assert.assertFalse("".containsOnlyEmojisInternal()) + Assert.assertFalse(" ".containsOnlyEmojisInternal()) + Assert.assertFalse("🙂 🙁".containsOnlyEmojisInternal()) + Assert.assertFalse(" 🙂 🙁 ".containsOnlyEmojisInternal()) + Assert.assertFalse("Hello".containsOnlyEmojisInternal()) + Assert.assertFalse("Hello 👋".containsOnlyEmojisInternal()) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeMentionSpanFormatter.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeMentionSpanFormatter.kt new file mode 100644 index 0000000..6ef91ac --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeMentionSpanFormatter.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.utils + +import io.element.android.libraries.textcomposer.mentions.MentionSpanFormatter +import io.element.android.libraries.textcomposer.mentions.MentionType + +class FakeMentionSpanFormatter( + private val formatLambda: (MentionType) -> CharSequence = { type -> type.toString() }, +) : MentionSpanFormatter { + override fun formatDisplayText(mentionType: MentionType): CharSequence { + return formatLambda(mentionType) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeTextPillificationHelper.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeTextPillificationHelper.kt new file mode 100644 index 0000000..54faa03 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/FakeTextPillificationHelper.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.utils + +class FakeTextPillificationHelper( + private val pillifyLambda: (CharSequence, Boolean) -> CharSequence = { text, _ -> text } +) : TextPillificationHelper { + override fun pillify(text: CharSequence, pillifyPermalinks: Boolean): CharSequence { + return pillifyLambda(text, pillifyPermalinks) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt new file mode 100644 index 0000000..48348f5 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt @@ -0,0 +1,715 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.messages.impl.voicemessages.composer + +import android.Manifest +import androidx.lifecycle.Lifecycle +import app.cash.turbine.TurbineTestContext +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.Composer +import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent +import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState +import io.element.android.features.messages.impl.messagecomposer.aReplyMode +import io.element.android.features.messages.test.FakeMessageComposerContext +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.AudioInfo +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig +import io.element.android.libraries.mediaupload.impl.DefaultMediaSender +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.permissions.api.aPermissionsState +import io.element.android.libraries.permissions.test.FakePermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent +import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent +import io.element.android.libraries.textcomposer.model.VoiceMessageState +import io.element.android.libraries.voiceplayer.api.VoiceMessageException +import io.element.android.libraries.voicerecorder.api.VoiceRecorder +import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import java.io.File +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class DefaultVoiceMessageComposerPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private val voiceRecorder = FakeVoiceRecorder( + recordingDuration = RECORDING_DURATION + ) + private val analyticsService = FakeAnalyticsService() + private val sendVoiceMessageResult = + lambdaRecorder, EventId?, Result> { _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + private val joinedRoom = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendVoiceMessageLambda = sendVoiceMessageResult + }, + ) + private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() } + private val mediaSender = DefaultMediaSender( + preProcessor = mediaPreProcessor, + room = joinedRoom, + timelineMode = Timeline.Mode.Live, + mediaOptimizationConfigProvider = { MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) }, + ) + private val messageComposerContext = FakeMessageComposerContext() + + companion object { + private val RECORDING_DURATION = 1.seconds + private val RECORDING_STATE = VoiceMessageState.Recording(RECORDING_DURATION, listOf(0.1f, 0.2f).toImmutableList()) + } + + @Test + fun `present - initial state`() = runTest { + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + voiceRecorder.assertCalls(started = 0) + + testPauseAndDestroy(initialState) + } + } + + @Test + fun `present - recording state`() = runTest { + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) + voiceRecorder.assertCalls(started = 1) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - recording state - number of levels is limited`() = runTest { + val numberOfLevels = 200 + val levels = List(numberOfLevels) { it / numberOfLevels.toFloat() } + val voiceRecorder = FakeVoiceRecorder( + levels = levels, + recordingDuration = RECORDING_DURATION, + ) + val presenter = createDefaultVoiceMessageComposerPresenter( + voiceRecorder = voiceRecorder, + ) + presenter.test { + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + skipItems(numberOfLevels / 2 - 1) + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isInstanceOf(VoiceMessageState.Recording::class.java) + val recordingState = finalState.voiceMessageState as VoiceMessageState.Recording + // The number of levels should be limited to 128 items + assertThat(recordingState.levels.size).isEqualTo(128) + assertThat(recordingState.levels).isEqualTo(levels.takeLast(128)) + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - recording keeps screen on`() = runTest { + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + awaitItem().apply { + eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + assertThat(keepScreenOn).isFalse() + } + + awaitItem().apply { + assertThat(keepScreenOn).isTrue() + eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) + } + + val finalState = awaitItem().apply { + assertThat(keepScreenOn).isFalse() + } + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - abort recording`() = runTest { + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Cancel)) + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - finish recording`() = runTest { + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) + + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState()) + voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - play recording before it is ready`() = runTest { + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + val finalState = awaitItem().apply { + this.eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play)) + } + + // Nothing should happen + assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) + voiceRecorder.assertCalls(started = 1, stopped = 0, deleted = 0) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - play recording`() = runTest { + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) + awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play)) + val finalState = awaitItem().also { + assertThat(it.voiceMessageState).isEqualTo(aPlayingState()) + } + voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - pause recording`() = runTest { + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) + awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play)) + awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Pause)) + val finalState = awaitItem().also { + assertThat(it.voiceMessageState).isEqualTo(aPausedState()) + } + voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - seek recording`() = runTest { + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) + awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.5f))) + awaitItem().apply { + assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true)) + } + awaitItem().apply { + assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 5.seconds, showCursor = true)) + eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.2f))) + } + awaitItem().apply { + assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 5.seconds, showCursor = true)) + } + val finalState = awaitItem().apply { + assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 2.seconds, showCursor = true)) + } + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - delete recording`() = runTest { + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) + awaitItem().eventSink(VoiceMessageComposerEvent.DeleteVoiceMessage) + + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - delete while playing`() = runTest { + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) + awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play)) + awaitItem().eventSink(VoiceMessageComposerEvent.DeleteVoiceMessage) + awaitItem().apply { + assertThat(voiceMessageState).isEqualTo(aPausedState()) + } + + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - send recording`() = runTest { + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) + awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage) + assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState()) + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + sendVoiceMessageResult.assertions().isCalledOnce() + voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - sending is tracked`() = runTest { + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + // Send a normal voice message + messageComposerContext.composerMode = MessageComposerMode.Normal + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) + awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage) + skipItems(1) // Sending state + advanceUntilIdle() + // Now reply with a voice message + messageComposerContext.composerMode = aReplyMode() + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) + awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage) + val finalState = awaitItem() // Sending state + + assertThat(analyticsService.capturedEvents).containsExactly( + aVoiceMessageComposerEvent(isReply = false), + aVoiceMessageComposerEvent(isReply = true) + ) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - send while playing`() = runTest { + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) + awaitItem().eventSink(VoiceMessageComposerEvent.PlayerEvent(VoiceMessagePlayerEvent.Play)) + awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage) + assertThat(awaitItem().voiceMessageState).isEqualTo(aPlayingState().toSendingState()) + skipItems(1) // Duplicate sending state + + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + sendVoiceMessageResult.assertions().isCalledOnce() + voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - send recording before previous completed, waits`() = runTest { + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) + awaitItem().run { + eventSink(VoiceMessageComposerEvent.SendVoiceMessage) + eventSink(VoiceMessageComposerEvent.SendVoiceMessage) + } + assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState()) + + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + sendVoiceMessageResult.assertions().isCalledOnce() + voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - send failures aren't tracked`() = runTest { + // Let sending fail due to media preprocessing error + mediaPreProcessor.givenResult(Result.failure(Exception())) + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) + awaitItem().apply { + assertThat(voiceMessageState).isEqualTo(aPreviewState()) + eventSink(VoiceMessageComposerEvent.SendVoiceMessage) + } + + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState(isSending = true)) + sendVoiceMessageResult.assertions().isNeverCalled() + assertThat(analyticsService.trackedErrors).isEmpty() + voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - send failures can be retried`() = runTest { + // Let sending fail due to media preprocessing error + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + mediaPreProcessor.givenResult(Result.failure(Exception())) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) + val previewState = awaitItem() + + previewState.eventSink(VoiceMessageComposerEvent.SendVoiceMessage) + assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState()) + + ensureAllEventsConsumed() + assertThat(previewState.voiceMessageState).isEqualTo(aPreviewState()) + sendVoiceMessageResult.assertions().isNeverCalled() + + mediaPreProcessor.givenAudioResult() + previewState.eventSink(VoiceMessageComposerEvent.SendVoiceMessage) + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + sendVoiceMessageResult.assertions().isCalledOnce() + voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - send failures are displayed as an error dialog`() = runTest { + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + // Let sending fail due to media preprocessing error + mediaPreProcessor.givenResult(Result.failure(Exception())) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) + awaitItem().eventSink(VoiceMessageComposerEvent.SendVoiceMessage) + + assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState()) + + awaitItem().apply { + assertThat(voiceMessageState).isEqualTo(aPreviewState().toSendingState()) + assertThat(showSendFailureDialog).isTrue() + } + + awaitItem().apply { + assertThat(voiceMessageState).isEqualTo(aPreviewState()) + assertThat(showSendFailureDialog).isTrue() + eventSink(VoiceMessageComposerEvent.DismissSendFailureDialog) + } + + val finalState = awaitItem().apply { + assertThat(voiceMessageState).isEqualTo(aPreviewState()) + assertThat(showSendFailureDialog).isFalse() + } + + sendVoiceMessageResult.assertions().isNeverCalled() + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - send error - missing recording is tracked`() = runTest { + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + val initialState = awaitItem() + // Send the message before recording anything + initialState.eventSink(VoiceMessageComposerEvent.SendVoiceMessage) + + assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + sendVoiceMessageResult.assertions().isNeverCalled() + assertThat(analyticsService.trackedErrors).hasSize(1) + voiceRecorder.assertCalls(started = 0) + + testPauseAndDestroy(initialState) + } + } + + @Test + fun `present - record error - security exceptions are tracked`() = runTest { + val exception = SecurityException("") + voiceRecorder.givenThrowsSecurityException(exception) + val presenter = createDefaultVoiceMessageComposerPresenter() + presenter.test { + val initialState = awaitItem() + initialState.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + + sendVoiceMessageResult.assertions().isNeverCalled() + assertThat(analyticsService.trackedErrors).containsExactly( + VoiceMessageException.PermissionMissing(message = "Expected permission to record but none", cause = exception) + ) + voiceRecorder.assertCalls(started = 1) + + testPauseAndDestroy(initialState) + } + } + + @Test + fun `present - permission accepted first time`() = runTest { + val permissionsPresenter = createFakePermissionsPresenter( + recordPermissionGranted = false, + ) + val presenter = createDefaultVoiceMessageComposerPresenter( + permissionsPresenter = permissionsPresenter, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle) + + initialState.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop)) + voiceRecorder.assertCalls(stopped = 1) + + permissionsPresenter.setPermissionGranted() + + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) + voiceRecorder.assertCalls(stopped = 1, started = 1) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - permission denied previously`() = runTest { + val permissionsPresenter = createFakePermissionsPresenter( + recordPermissionGranted = false, + ) + val presenter = createDefaultVoiceMessageComposerPresenter( + permissionsPresenter = permissionsPresenter, + ) + presenter.test { + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + + // See the dialog and accept it + awaitItem().also { + assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + assertThat(it.showPermissionRationaleDialog).isTrue() + it.eventSink(VoiceMessageComposerEvent.AcceptPermissionRationale) + } + + // Dialog is hidden, user accepts permissions + assertThat(awaitItem().showPermissionRationaleDialog).isFalse() + + permissionsPresenter.setPermissionGranted() + + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + val finalState = awaitItem() + assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE) + voiceRecorder.assertCalls(started = 1) + + testPauseAndDestroy(finalState) + } + } + + @Test + fun `present - permission rationale dismissed`() = runTest { + val permissionsPresenter = createFakePermissionsPresenter( + recordPermissionGranted = false, + ) + val presenter = createDefaultVoiceMessageComposerPresenter( + permissionsPresenter = permissionsPresenter, + ) + presenter.test { + awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + + // See the dialog and accept it + awaitItem().also { + assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + assertThat(it.showPermissionRationaleDialog).isTrue() + it.eventSink(VoiceMessageComposerEvent.DismissPermissionsRationale) + } + + // Dialog is hidden, user tries to record again + awaitItem().also { + assertThat(it.showPermissionRationaleDialog).isFalse() + it.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start)) + } + + // Dialog is shown once again + val finalState = awaitItem().also { + assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle) + assertThat(it.showPermissionRationaleDialog).isTrue() + } + voiceRecorder.assertCalls(started = 0) + + testPauseAndDestroy(finalState) + } + } + + private suspend fun TurbineTestContext.testPauseAndDestroy( + mostRecentState: VoiceMessageComposerState, + ) { + mostRecentState.eventSink( + VoiceMessageComposerEvent.LifecycleEvent(event = Lifecycle.Event.ON_PAUSE) + ) + + val onPauseState = when (val state = mostRecentState.voiceMessageState) { + VoiceMessageState.Idle -> mostRecentState + is VoiceMessageState.Recording -> { + // If recorder was active, it stops + awaitItem().apply { + assertThat(voiceMessageState).isEqualTo(aPreviewState()) + } + } + is VoiceMessageState.Preview -> when (state.isPlaying) { + // If the preview was playing, it pauses + true -> awaitItem().apply { + assertThat(voiceMessageState).isEqualTo(aPausedState()) + } + false -> mostRecentState + } + } + + onPauseState.eventSink( + VoiceMessageComposerEvent.LifecycleEvent(event = Lifecycle.Event.ON_DESTROY) + ) + + when (val state = onPauseState.voiceMessageState) { + VoiceMessageState.Idle -> + ensureAllEventsConsumed() + is VoiceMessageState.Recording -> + assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle) + is VoiceMessageState.Preview -> when (state.isSending) { + true -> ensureAllEventsConsumed() + false -> assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle) + } + } + } + + private fun TestScope.createDefaultVoiceMessageComposerPresenter( + permissionsPresenter: PermissionsPresenter = createFakePermissionsPresenter(), + voiceRecorder: VoiceRecorder = this@DefaultVoiceMessageComposerPresenterTest.voiceRecorder, + ): DefaultVoiceMessageComposerPresenter { + return DefaultVoiceMessageComposerPresenter( + sessionCoroutineScope = backgroundScope, + timelineMode = Timeline.Mode.Live, + voiceRecorder = voiceRecorder, + analyticsService = analyticsService, + mediaSenderFactory = { mediaSender }, + player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this), + messageComposerContext = messageComposerContext, + permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter), + ) + } + + private fun createFakePermissionsPresenter( + recordPermissionGranted: Boolean = true, + recordPermissionShowDialog: Boolean = false, + ): FakePermissionsPresenter { + val initialPermissionState = aPermissionsState( + showDialog = recordPermissionShowDialog, + permission = Manifest.permission.RECORD_AUDIO, + permissionGranted = recordPermissionGranted, + ) + return FakePermissionsPresenter( + initialState = initialPermissionState + ) + } + + private fun aPreviewState( + isPlaying: Boolean = false, + playbackProgress: Float = 0f, + isSending: Boolean = false, + time: Duration = RECORDING_DURATION, + showCursor: Boolean = false, + waveform: List = voiceRecorder.waveform, + ) = VoiceMessageState.Preview( + isPlaying = isPlaying, + playbackProgress = playbackProgress, + isSending = isSending, + time = time, + showCursor = showCursor, + waveform = waveform.toImmutableList(), + ) + + private fun aPlayingState() = + aPreviewState( + isPlaying = true, + playbackProgress = 0.1f, + showCursor = true, + time = RECORDING_DURATION, + ) + + private fun aPausedState() = + aPlayingState() + .copy(isPlaying = false) + + private fun VoiceMessageState.Preview.toSendingState() = + copy( + isPlaying = false, + isSending = true, + showCursor = false, + time = RECORDING_DURATION, + ) +} + +private fun aVoiceMessageComposerEvent( + isReply: Boolean = false +) = Composer( + inThread = false, + isEditing = false, + isReply = isReply, + messageType = Composer.MessageType.VoiceMessage, + startsThread = null +) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/FakeRedactedVoiceMessageManager.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/FakeRedactedVoiceMessageManager.kt new file mode 100644 index 0000000..39355a2 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/FakeRedactedVoiceMessageManager.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.voicemessages.timeline + +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem + +class FakeRedactedVoiceMessageManager : RedactedVoiceMessageManager { + private val _invocations: MutableList> = mutableListOf() + val invocations: List> + get() = _invocations + + override suspend fun onEachMatrixTimelineItem(timelineItems: List) { + _invocations.add(timelineItems) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt new file mode 100644 index 0000000..8bced93 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.voicemessages.timeline + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +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_USER_ID +import io.element.android.libraries.matrix.test.core.FakeSendHandle +import io.element.android.libraries.mediaplayer.api.MediaPlayer +import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RedactedVoiceMessageManagerTest { + @Test + fun `redacted event - no playing related media`() = runTest { + val mediaPlayer = FakeMediaPlayer().apply { + setMedia(uri = "someUri", mediaId = AN_EVENT_ID.value, mimeType = MimeTypes.Ogg) + play() + } + val manager = aDefaultRedactedVoiceMessageManager(mediaPlayer = mediaPlayer) + + assertThat(mediaPlayer.state.value.mediaId).isEqualTo(AN_EVENT_ID.value) + assertThat(mediaPlayer.state.value.isPlaying).isTrue() + + manager.onEachMatrixTimelineItem(aRedactedMatrixTimeline(AN_EVENT_ID_2)) + + assertThat(mediaPlayer.state.value.mediaId).isEqualTo(AN_EVENT_ID.value) + assertThat(mediaPlayer.state.value.isPlaying).isTrue() + } + + @Test + fun `redacted event - playing related media is paused`() = runTest { + val mediaPlayer = FakeMediaPlayer().apply { + setMedia(uri = "someUri", mediaId = AN_EVENT_ID.value, mimeType = MimeTypes.Ogg) + play() + } + val manager = aDefaultRedactedVoiceMessageManager(mediaPlayer = mediaPlayer) + + assertThat(mediaPlayer.state.value.mediaId).isEqualTo(AN_EVENT_ID.value) + assertThat(mediaPlayer.state.value.isPlaying).isTrue() + + manager.onEachMatrixTimelineItem(aRedactedMatrixTimeline(AN_EVENT_ID)) + + assertThat(mediaPlayer.state.value.mediaId).isEqualTo(AN_EVENT_ID.value) + assertThat(mediaPlayer.state.value.isPlaying).isFalse() + } +} + +fun TestScope.aDefaultRedactedVoiceMessageManager( + mediaPlayer: MediaPlayer = FakeMediaPlayer(), +) = DefaultRedactedVoiceMessageManager( + dispatchers = this.testCoroutineDispatchers(true), + mediaPlayer = mediaPlayer, +) + +fun aRedactedMatrixTimeline(eventId: EventId) = listOf( + MatrixTimelineItem.Event( + uniqueId = UniqueId("0"), + event = EventTimelineItem( + eventId = eventId, + transactionId = null, + isEditable = false, + canBeRepliedTo = false, + isOwn = false, + isRemote = false, + localSendState = null, + reactions = persistentListOf(), + receipts = persistentListOf(), + sender = A_USER_ID, + senderProfile = ProfileDetails.Unavailable, + timestamp = 9442, + content = RedactedContent, + origin = null, + timelineItemDebugInfoProvider = { + TimelineItemDebugInfo( + model = "enim", + originalJson = null, + latestEditedJson = null, + ) + }, + messageShieldProvider = { null }, + sendHandleProvider = { FakeSendHandle() }, + ), + ) +) diff --git a/features/messages/test/build.gradle.kts b/features/messages/test/build.gradle.kts new file mode 100644 index 0000000..09d3573 --- /dev/null +++ b/features/messages/test/build.gradle.kts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.messages.test" +} + +dependencies { + api(projects.features.messages.impl) + implementation(projects.libraries.matrix.test) + implementation(projects.libraries.mediaplayer.test) + implementation(projects.libraries.mediaupload.test) + implementation(projects.libraries.mediaviewer.api) + implementation(projects.libraries.permissions.test) + implementation(projects.libraries.preferences.api) + implementation(projects.libraries.voicerecorder.test) + implementation(projects.services.analytics.test) + implementation(projects.tests.testutils) + implementation(projects.libraries.mediaupload.impl) +} diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/FakeMessageComposerContext.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/FakeMessageComposerContext.kt new file mode 100644 index 0000000..25978f2 --- /dev/null +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/FakeMessageComposerContext.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.test + +import io.element.android.features.messages.api.MessageComposerContext +import io.element.android.libraries.textcomposer.model.MessageComposerMode + +class FakeMessageComposerContext( + override var composerMode: MessageComposerMode = MessageComposerMode.Normal +) : MessageComposerContext diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/FakeMessagesEntryPoint.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/FakeMessagesEntryPoint.kt new file mode 100644 index 0000000..491e1ff --- /dev/null +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/FakeMessagesEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.messages.api.MessagesEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeMessagesEntryPoint : MessagesEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: MessagesEntryPoint.Params, + callback: MessagesEntryPoint.Callback, + ): Node = lambdaError() +} diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeMediaOptimizationSelectorPresenterFactory.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeMediaOptimizationSelectorPresenterFactory.kt new file mode 100644 index 0000000..02a6918 --- /dev/null +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeMediaOptimizationSelectorPresenterFactory.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.test.attachments.video + +import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter +import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.mediaviewer.api.local.LocalMedia + +class FakeMediaOptimizationSelectorPresenterFactory( + private val fakePresenter: MediaOptimizationSelectorPresenter = MediaOptimizationSelectorPresenter { + MediaOptimizationSelectorState( + maxUploadSize = AsyncData.Uninitialized, + videoSizeEstimations = AsyncData.Uninitialized, + isImageOptimizationEnabled = null, + selectedVideoPreset = null, + displayMediaSelectorViews = null, + displayVideoPresetSelectorDialog = false, + eventSink = {}, + ) + } +) : MediaOptimizationSelectorPresenter.Factory { + override fun create(localMedia: LocalMedia): MediaOptimizationSelectorPresenter { + return fakePresenter + } +} diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeVideoMetadataExtractor.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeVideoMetadataExtractor.kt new file mode 100644 index 0000000..6a815c4 --- /dev/null +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/attachments/video/FakeVideoMetadataExtractor.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.test.attachments.video + +import android.net.Uri +import android.util.Size +import io.element.android.features.messages.impl.attachments.video.VideoMetadataExtractor +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +class FakeVideoMetadataExtractor( + private val sizeResult: Result = Result.success(Size(1, 1)), + private val duration: Result = Result.success(1.milliseconds), +) : VideoMetadataExtractor { + override fun getSize(): Result = sizeResult + + override fun getDuration(): Result = duration + + override fun close() = Unit +} + +class FakeVideoMetadataExtractorFactory( + private val fakeVideoMetadataExtractor: FakeVideoMetadataExtractor = FakeVideoMetadataExtractor(), +) : VideoMetadataExtractor.Factory { + override fun create(uri: Uri): VideoMetadataExtractor { + return fakeVideoMetadataExtractor + } +} diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt new file mode 100644 index 0000000..75b700b --- /dev/null +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.test.timeline + +import androidx.compose.runtime.Composable +import io.element.android.features.messages.api.timeline.HtmlConverterProvider +import io.element.android.wysiwyg.utils.HtmlConverter + +class FakeHtmlConverterProvider( + private val transform: (String) -> CharSequence = { it }, +) : HtmlConverterProvider { + @Composable + override fun Update() = Unit + + override fun provide(): HtmlConverter { + return object : HtmlConverter { + override fun fromHtmlToSpans(html: String): CharSequence { + return transform(html) + } + } + } +} diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt new file mode 100644 index 0000000..0de1b69 --- /dev/null +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.test.timeline.voicemessages.composer + +import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter +import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer +import io.element.android.features.messages.test.FakeMessageComposerContext +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer +import io.element.android.libraries.mediaupload.api.MediaSender +import io.element.android.libraries.mediaupload.impl.DefaultMediaSender +import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory +import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder +import io.element.android.services.analytics.test.FakeAnalyticsService +import kotlinx.coroutines.CoroutineScope + +class FakeDefaultVoiceMessageComposerPresenterFactory( + private val sessionCoroutineScope: CoroutineScope, + private val mediaSender: MediaSender = DefaultMediaSender( + preProcessor = FakeMediaPreProcessor(), + room = FakeJoinedRoom(), + timelineMode = Timeline.Mode.Live, + mediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), + ), +) : DefaultVoiceMessageComposerPresenter.Factory { + override fun create(timelineMode: Timeline.Mode): DefaultVoiceMessageComposerPresenter { + return DefaultVoiceMessageComposerPresenter( + sessionCoroutineScope = sessionCoroutineScope, + timelineMode = timelineMode, + voiceRecorder = FakeVoiceRecorder(), + analyticsService = FakeAnalyticsService(), + mediaSenderFactory = { mediaSender }, + player = VoiceMessageComposerPlayer( + mediaPlayer = FakeMediaPlayer(), + sessionCoroutineScope = sessionCoroutineScope, + ), + messageComposerContext = FakeMessageComposerContext(), + permissionsPresenterFactory = FakePermissionsPresenterFactory(), + ) + } +} diff --git a/features/migration/api/build.gradle.kts b/features/migration/api/build.gradle.kts new file mode 100644 index 0000000..23cb041 --- /dev/null +++ b/features/migration/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.migration.api" +} + +dependencies { + implementation(projects.libraries.architecture) +} diff --git a/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationEntryPoint.kt b/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationEntryPoint.kt new file mode 100644 index 0000000..471ee30 --- /dev/null +++ b/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.api + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +interface MigrationEntryPoint { + @Composable + fun present(): MigrationState + + @Composable + fun Render( + state: MigrationState, + modifier: Modifier, + ) +} diff --git a/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationState.kt b/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationState.kt new file mode 100644 index 0000000..000d898 --- /dev/null +++ b/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationState.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.api + +import io.element.android.libraries.architecture.AsyncData + +data class MigrationState( + val migrationAction: AsyncData, +) diff --git a/features/migration/impl/build.gradle.kts b/features/migration/impl/build.gradle.kts new file mode 100644 index 0000000..eb22d06 --- /dev/null +++ b/features/migration/impl/build.gradle.kts @@ -0,0 +1,41 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.migration.impl" +} + +setupDependencyInjection() + +dependencies { + implementation(projects.features.announcement.api) + implementation(projects.features.migration.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.preferences.impl) + implementation(libs.androidx.datastore.preferences) + implementation(projects.features.rageshake.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.sessionStorage.api) + implementation(projects.libraries.uiStrings) + + testCommonDependencies(libs) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.sessionStorage.test) + testImplementation(projects.libraries.preferences.test) + testImplementation(projects.features.announcement.test) + testImplementation(projects.features.rageshake.test) +} diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationEntryPoint.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationEntryPoint.kt new file mode 100644 index 0000000..edefa7c --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationEntryPoint.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.api.MigrationEntryPoint +import io.element.android.features.api.MigrationState + +@ContributesBinding(AppScope::class) +class DefaultMigrationEntryPoint( + private val migrationPresenter: MigrationPresenter, +) : MigrationEntryPoint { + @Composable + override fun present(): MigrationState = migrationPresenter.present() + + @Composable + override fun Render( + state: MigrationState, + modifier: Modifier, + ) = MigrationView( + migrationState = state, + modifier = modifier, + ) +} diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationStore.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationStore.kt new file mode 100644 index 0000000..fe73a6a --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationStore.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val applicationMigrationVersion = intPreferencesKey("applicationMigrationVersion") + +@ContributesBinding(AppScope::class) +class DefaultMigrationStore( + preferenceDataStoreFactory: PreferenceDataStoreFactory, +) : MigrationStore { + private val store = preferenceDataStoreFactory.create("elementx_migration") + + override suspend fun setApplicationMigrationVersion(version: Int) { + store.edit { prefs -> + prefs[applicationMigrationVersion] = version + } + } + + override fun applicationMigrationVersion(): Flow { + return store.data.map { prefs -> + prefs[applicationMigrationVersion] ?: -1 + } + } +} diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt new file mode 100644 index 0000000..2d78e5f --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl + +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.element.android.features.api.MigrationState +import io.element.android.features.migration.impl.migrations.AppMigration +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import timber.log.Timber + +@SingleIn(AppScope::class) +@Inject +class MigrationPresenter( + private val migrationStore: MigrationStore, + migrations: Set<@JvmSuppressWildcards AppMigration>, +) : Presenter { + private val orderedMigrations = migrations.sortedBy { it.order } + private val lastMigration: Int = orderedMigrations.lastOrNull()?.order ?: 0 + private var isFreshInstall = false + + @Composable + override fun present(): MigrationState { + val migrationStoreVersion by remember { + migrationStore.applicationMigrationVersion() + }.collectAsState(initial = null) + var migrationAction: AsyncData by remember { mutableStateOf(AsyncData.Uninitialized) } + + // Uncomment this block to run the migration everytime +// LaunchedEffect(Unit) { +// Timber.d("Resetting migration version to 0") +// migrationStore.setApplicationMigrationVersion(0) +// } + + LaunchedEffect(migrationStoreVersion) { + val migrationValue = migrationStoreVersion ?: return@LaunchedEffect + if (migrationValue == -1) { + Timber.d("Fresh install, or previous installed application did not have the migration mechanism.") + isFreshInstall = true + } + if (migrationValue == lastMigration) { + Timber.d("Current app migration version: $migrationValue. No migration needed.") + migrationAction = AsyncData.Success(Unit) + return@LaunchedEffect + } + migrationAction = AsyncData.Loading(Unit) + val nextMigration = orderedMigrations.firstOrNull { it.order > migrationValue } + if (nextMigration != null) { + Timber.d("Current app migration version: $migrationValue. Applying migration: ${nextMigration.order}") + nextMigration.migrate(isFreshInstall) + migrationStore.setApplicationMigrationVersion(nextMigration.order) + } + } + + return MigrationState( + migrationAction = migrationAction, + ) + } +} diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStateProvider.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStateProvider.kt new file mode 100644 index 0000000..8f738d3 --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStateProvider.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.api.MigrationState +import io.element.android.libraries.architecture.AsyncData + +internal class MigrationStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aMigrationState(), + aMigrationState(migrationAction = AsyncData.Loading(Unit)), + ) +} + +internal fun aMigrationState( + migrationAction: AsyncData = AsyncData.Uninitialized, +) = MigrationState( + migrationAction = migrationAction, +) diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStore.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStore.kt new file mode 100644 index 0000000..7fdade8 --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStore.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl + +import kotlinx.coroutines.flow.Flow + +interface MigrationStore { + /** + * Return of flow of the current value for application migration version. + * If the value is not set, it will emit 0. + * If the emitted value is lower than the current application migration version, it means + * that a migration should occur, and at the end [setApplicationMigrationVersion] should be called. + */ + fun applicationMigrationVersion(): Flow + + /** + * Set the application migration version, typically after a migration has been done. + */ + suspend fun setApplicationMigrationVersion(version: Int) +} diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationView.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationView.kt new file mode 100644 index 0000000..a294c32 --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationView.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.api.MigrationState +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun MigrationView( + migrationState: MigrationState, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + CircularProgressIndicator() + if (migrationState.migrationAction.isLoading()) { + Text(text = stringResource(id = CommonStrings.common_please_wait)) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun MigrationViewPreview( + @PreviewParameter(MigrationStateProvider::class) state: MigrationState, +) = ElementPreview { + MigrationView( + migrationState = state, + ) +} diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration.kt new file mode 100644 index 0000000..dcac76c --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl.migrations + +interface AppMigration { + val order: Int + suspend fun migrate(isFreshInstall: Boolean) +} diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration01.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration01.kt new file mode 100644 index 0000000..60969dc --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration01.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl.migrations + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.features.rageshake.api.logs.LogFilesRemover + +/** + * Remove existing logs from the device to remove any leaks of sensitive data. + */ +@ContributesIntoSet(AppScope::class) +@Inject +class AppMigration01( + private val logFilesRemover: LogFilesRemover, +) : AppMigration { + override val order: Int = 1 + + override suspend fun migrate(isFreshInstall: Boolean) { + logFilesRemover.perform() + } +} diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02.kt new file mode 100644 index 0000000..6468274 --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl.migrations + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory +import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.coroutines.coroutineScope + +/** + * This migration sets the skip session verification preference to true for all existing sessions. + * This way we don't force existing users to verify their session again. + */ +@ContributesIntoSet(AppScope::class) +@Inject +class AppMigration02( + private val sessionStore: SessionStore, + private val sessionPreferenceStoreFactory: SessionPreferencesStoreFactory, +) : AppMigration { + override val order: Int = 2 + + override suspend fun migrate(isFreshInstall: Boolean) { + coroutineScope { + for (session in sessionStore.getAllSessions()) { + val sessionId = SessionId(session.userId) + val preferences = sessionPreferenceStoreFactory.get(sessionId, this) + preferences.setSkipSessionVerification(true) + // This session preference store must be ephemeral since it's not created with the right coroutine scope + sessionPreferenceStoreFactory.remove(sessionId) + } + } + } +} diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration03.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration03.kt new file mode 100644 index 0000000..62674cf --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration03.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl.migrations + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject + +/** + * This performs the same operation as [AppMigration01], since we need to clear the local logs again. + */ +@ContributesIntoSet(AppScope::class) +@Inject +class AppMigration03( + private val migration01: AppMigration01, +) : AppMigration { + override val order: Int = 3 + + override suspend fun migrate(isFreshInstall: Boolean) { + migration01.migrate(isFreshInstall) + } +} diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04.kt new file mode 100644 index 0000000..8ac4146 --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl.migrations + +import android.content.Context +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.annotations.ApplicationContext + +/** + * Remove notifications.bin file, used to store notification data locally. + */ +@ContributesIntoSet(AppScope::class) +@Inject +class AppMigration04( + @ApplicationContext private val context: Context, +) : AppMigration { + companion object { + internal const val NOTIFICATION_FILE_NAME = "notifications.bin" + } + override val order: Int = 4 + + override suspend fun migrate(isFreshInstall: Boolean) { + runCatchingExceptions { context.getDatabasePath(NOTIFICATION_FILE_NAME).delete() } + } +} diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration05.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration05.kt new file mode 100644 index 0000000..094248f --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration05.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl.migrations + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.libraries.di.BaseDirectory +import io.element.android.libraries.sessionstorage.api.SessionStore +import java.io.File + +@ContributesIntoSet(AppScope::class) +@Inject +class AppMigration05( + private val sessionStore: SessionStore, + @BaseDirectory private val baseDirectory: File, +) : AppMigration { + override val order: Int = 5 + + override suspend fun migrate(isFreshInstall: Boolean) { + val allSessions = sessionStore.getAllSessions() + for (session in allSessions) { + if (session.sessionPath.isEmpty()) { + val sessionPath = File(baseDirectory, session.userId.replace(':', '_')).absolutePath + sessionStore.updateData(session.copy(sessionPath = sessionPath)) + } + } + } +} diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration06.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration06.kt new file mode 100644 index 0000000..0fbda43 --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration06.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl.migrations + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.libraries.di.CacheDirectory +import io.element.android.libraries.sessionstorage.api.SessionStore +import java.io.File + +/** + * Create the cache directory for the existing sessions. + */ +@ContributesIntoSet(AppScope::class) +@Inject +class AppMigration06( + private val sessionStore: SessionStore, + @CacheDirectory private val cacheDirectory: File, +) : AppMigration { + override val order: Int = 6 + + override suspend fun migrate(isFreshInstall: Boolean) { + val allSessions = sessionStore.getAllSessions() + for (session in allSessions) { + if (session.cachePath.isEmpty()) { + val sessionFile = File(session.sessionPath) + val sessionFolder = sessionFile.name + val cachePath = File(cacheDirectory, sessionFolder) + sessionStore.updateData(session.copy(cachePath = cachePath.absolutePath)) + // Move existing cache files + listOf( + "matrix-sdk-event-cache.sqlite3", + "matrix-sdk-event-cache.sqlite3-shm", + "matrix-sdk-event-cache.sqlite3-wal", + ).map { fileName -> + File(sessionFile, fileName) + }.takeIf { files -> + files.all { it.exists() } + }?.forEach { cacheFile -> + val targetFile = File(cachePath, cacheFile.name) + cacheFile.copyTo(targetFile) + cacheFile.delete() + } + } + } + } +} diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration07.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration07.kt new file mode 100644 index 0000000..d562be7 --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration07.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl.migrations + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.features.rageshake.api.logs.LogFilesRemover + +/** + * Delete the previous log files. + */ +@ContributesIntoSet(AppScope::class) +@Inject +class AppMigration07( + private val logFilesRemover: LogFilesRemover, +) : AppMigration { + override val order: Int = 7 + + override suspend fun migrate(isFreshInstall: Boolean) { + logFilesRemover.perform { file -> + file.name.startsWith("logs-") + } + } +} diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08.kt new file mode 100644 index 0000000..0f3b33a --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl.migrations + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService + +/** + * Ensure the new notification sound banner is displayed, but only on application upgrade. + */ +@ContributesIntoSet(AppScope::class) +@Inject +class AppMigration08( + private val announcementService: AnnouncementService, +) : AppMigration { + override val order: Int = 8 + + override suspend fun migrate(isFreshInstall: Boolean) { + if (!isFreshInstall) { + announcementService.showAnnouncement(Announcement.NewNotificationSound) + } + } +} diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/InMemoryMigrationStore.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/InMemoryMigrationStore.kt new file mode 100644 index 0000000..d9724db --- /dev/null +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/InMemoryMigrationStore.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class InMemoryMigrationStore( + initialApplicationMigrationVersion: Int = 0 +) : MigrationStore { + private val applicationMigrationVersion = MutableStateFlow(initialApplicationMigrationVersion) + + override suspend fun setApplicationMigrationVersion(version: Int) { + applicationMigrationVersion.value = version + } + + override fun applicationMigrationVersion(): Flow { + return applicationMigrationVersion + } +} diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/MigrationPresenterTest.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/MigrationPresenterTest.kt new file mode 100644 index 0000000..f3f7e27 --- /dev/null +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/MigrationPresenterTest.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.migration.impl.migrations.AppMigration +import io.element.android.libraries.architecture.AsyncData +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class MigrationPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - run all migrations on fresh installation, and last version should be stored`() = runTest { + val migrations = (1..10).map { order -> + FakeAppMigration(order = order) + } + val store = InMemoryMigrationStore(initialApplicationMigrationVersion = -1) + val presenter = createPresenter( + migrationStore = store, + migrations = migrations.toSet(), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.migrationAction).isEqualTo(AsyncData.Uninitialized) + skipItems(migrations.size) + awaitItem().also { state -> + assertThat(state.migrationAction).isEqualTo(AsyncData.Success(Unit)) + } + assertThat(store.applicationMigrationVersion().first()).isEqualTo(migrations.maxOf { it.order }) + } + for (migration in migrations) { + migration.migrateLambda.assertions().isCalledOnce().with(value(true)) + } + } + + @Test + fun `present - no migration should occurs if ApplicationMigrationVersion is the last one`() = runTest { + val migrations = (1..10).map { + FakeAppMigration( + order = it, + migrateLambda = lambdaRecorder { lambdaError() }, + ) + } + val store = InMemoryMigrationStore(migrations.maxOf { it.order }) + val presenter = createPresenter( + migrationStore = store, + migrations = migrations.toSet(), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.migrationAction).isEqualTo(AsyncData.Uninitialized) + awaitItem().also { state -> + assertThat(state.migrationAction).isEqualTo(AsyncData.Success(Unit)) + } + } + } + + @Test + fun `present - testing all migrations`() = runTest { + val store = InMemoryMigrationStore(0) + val migrations = (1..10).map { FakeAppMigration(it) } + val presenter = createPresenter( + migrationStore = store, + migrations = migrations.toSet(), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.migrationAction).isEqualTo(AsyncData.Uninitialized) + awaitItem().also { state -> + assertThat(state.migrationAction).isEqualTo(AsyncData.Loading(Unit)) + } + consumeItemsUntilPredicate { it.migrationAction is AsyncData.Success } + assertThat(store.applicationMigrationVersion().first()).isEqualTo(migrations.maxOf { it.order }) + for (migration in migrations) { + migration.migrateLambda.assertions().isCalledOnce().with(value(false)) + } + } + } +} + +private fun createPresenter( + migrationStore: MigrationStore = InMemoryMigrationStore(0), + migrations: Set = setOf(FakeAppMigration(1)), +) = MigrationPresenter( + migrationStore = migrationStore, + migrations = migrations, +) + +private class FakeAppMigration( + override val order: Int, + val migrateLambda: LambdaOneParamRecorder = lambdaRecorder { }, +) : AppMigration { + override suspend fun migrate(isFreshInstall: Boolean) { + migrateLambda(isFreshInstall) + } +} diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration01Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration01Test.kt new file mode 100644 index 0000000..9f87670 --- /dev/null +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration01Test.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl.migrations + +import io.element.android.features.rageshake.test.logs.FakeLogFilesRemover +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AppMigration01Test { + @Test + fun `test migration`() = runTest { + val logsFileRemover = FakeLogFilesRemover() + val migration = AppMigration01(logsFileRemover) + + migration.migrate(true) + + logsFileRemover.performLambda.assertions().isCalledOnce() + } +} diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02Test.kt new file mode 100644 index 0000000..c326d80 --- /dev/null +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02Test.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl.migrations + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory +import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AppMigration02Test { + @Test + fun `test migration`() = runTest { + val sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData()), + ) + val sessionPreferencesStore = InMemorySessionPreferencesStore(isSessionVerificationSkipped = false) + val sessionPreferencesStoreFactory = FakeSessionPreferencesStoreFactory( + getLambda = lambdaRecorder { _, _ -> sessionPreferencesStore }, + removeLambda = lambdaRecorder { _ -> } + ) + val migration = AppMigration02(sessionStore = sessionStore, sessionPreferenceStoreFactory = sessionPreferencesStoreFactory) + + migration.migrate(true) + + // We got the session preferences store + sessionPreferencesStoreFactory.getLambda.assertions().isCalledOnce() + // We changed the settings for the skipping the session verification + assertThat(sessionPreferencesStore.isSessionVerificationSkipped().first()).isTrue() + // We removed the session preferences store from cache + sessionPreferencesStoreFactory.removeLambda.assertions().isCalledOnce() + } +} diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration03Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration03Test.kt new file mode 100644 index 0000000..ad69f03 --- /dev/null +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration03Test.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl.migrations + +import io.element.android.features.rageshake.test.logs.FakeLogFilesRemover +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AppMigration03Test { + @Test + fun `test migration`() = runTest { + val logsFileRemover = FakeLogFilesRemover() + val migration = AppMigration03(migration01 = AppMigration01(logsFileRemover)) + + migration.migrate(true) + + logsFileRemover.performLambda.assertions().isCalledOnce() + } +} diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04Test.kt new file mode 100644 index 0000000..7b954ab --- /dev/null +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04Test.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl.migrations + +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AppMigration04Test { + @Test + fun `test migration`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + + // Create fake temporary file at the path to be deleted + val file = context.getDatabasePath(AppMigration04.NOTIFICATION_FILE_NAME) + file.parentFile?.mkdirs() + file.createNewFile() + assertThat(file.exists()).isTrue() + + val migration = AppMigration04(context) + + migration.migrate(true) + + // Check that the file has been deleted + assertThat(file.exists()).isFalse() + } +} diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration05Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration05Test.kt new file mode 100644 index 0000000..fee0a2e --- /dev/null +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration05Test.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl.migrations + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.File + +class AppMigration05Test { + @Test + fun `empty session path should be set to an expected path`() = runTest { + val sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_SESSION_ID.value, + sessionPath = "", + ) + ) + ) + val migration = AppMigration05(sessionStore = sessionStore, baseDirectory = File("/a/path")) + migration.migrate(true) + val storedData = sessionStore.getSession(A_SESSION_ID.value)!! + assertThat(storedData.sessionPath).isEqualTo("/a/path/${A_SESSION_ID.value.replace(':', '_')}") + } + + @Test + fun `non empty session path should not be impacted by the migration`() = runTest { + val sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_SESSION_ID.value, + sessionPath = "/a/path/existing", + ) + ) + ) + val migration = AppMigration05(sessionStore = sessionStore, baseDirectory = File("/a/path")) + migration.migrate(true) + val storedData = sessionStore.getSession(A_SESSION_ID.value)!! + assertThat(storedData.sessionPath).isEqualTo("/a/path/existing") + } +} diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration06Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration06Test.kt new file mode 100644 index 0000000..f541022 --- /dev/null +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration06Test.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl.migrations + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.File + +class AppMigration06Test { + @Test + fun `empty cache path should be set to an expected path`() = runTest { + val sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_SESSION_ID.value, + sessionPath = "/a/path/to/a/session/AN_ID", + cachePath = "", + ) + ) + ) + val migration = AppMigration06(sessionStore = sessionStore, cacheDirectory = File("/a/path/cache")) + migration.migrate(true) + val storedData = sessionStore.getSession(A_SESSION_ID.value)!! + assertThat(storedData.cachePath).isEqualTo("/a/path/cache/AN_ID") + } + + @Test + fun `non empty cache path should not be impacted by the migration`() = runTest { + val sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_SESSION_ID.value, + cachePath = "/a/path/existing", + ) + ) + ) + val migration = AppMigration05(sessionStore = sessionStore, baseDirectory = File("/a/path/cache")) + migration.migrate(true) + val storedData = sessionStore.getSession(A_SESSION_ID.value)!! + assertThat(storedData.cachePath).isEqualTo("/a/path/existing") + } +} diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration07Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration07Test.kt new file mode 100644 index 0000000..2f5027d --- /dev/null +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration07Test.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl.migrations + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.rageshake.test.logs.FakeLogFilesRemover +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.File + +class AppMigration07Test { + @Test + fun `test migration`() = runTest { + val performLambda = lambdaRecorder<(File) -> Boolean, Unit> { predicate -> + // Test the predicate + assertThat(predicate(File("logs-0433.log.gz"))).isTrue() + assertThat(predicate(File("logs.2024-08-01-20.log.gz"))).isFalse() + } + val logsFileRemover = FakeLogFilesRemover(performLambda = performLambda) + val migration = AppMigration07(logsFileRemover) + migration.migrate(true) + performLambda.assertions().isCalledOnce() + } +} diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08Test.kt new file mode 100644 index 0000000..46bde27 --- /dev/null +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08Test.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl.migrations + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.rageshake.test.logs.FakeAnnouncementService +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AppMigration08Test { + @Test + fun `migration on fresh install should not invoke the AnnouncementService`() = runTest { + val service = FakeAnnouncementService( + showAnnouncementResult = { lambdaError() }, + ) + val migration = AppMigration08(service) + migration.migrate(isFreshInstall = true) + assertThat(service.announcementsToShowFlow().first()).isEmpty() + } + + @Test + fun `migration on upgrade should invoke the AnnouncementService`() = runTest { + val showAnnouncementResult = lambdaRecorder { } + val service = FakeAnnouncementService( + showAnnouncementResult = showAnnouncementResult, + ) + val migration = AppMigration08(service) + migration.migrate(isFreshInstall = false) + showAnnouncementResult.assertions().isCalledOnce() + .with(value(Announcement.NewNotificationSound)) + } +} diff --git a/features/networkmonitor/api/build.gradle.kts b/features/networkmonitor/api/build.gradle.kts new file mode 100644 index 0000000..1cc9869 --- /dev/null +++ b/features/networkmonitor/api/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.networkmonitor.api" +} + +dependencies { + implementation(libs.coroutines.core) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) +} 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 0000000..484a210 --- /dev/null +++ b/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/NetworkMonitor.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.networkmonitor.api + +import kotlinx.coroutines.flow.StateFlow + +/** + * Monitors the network status of the device, providing the current network connectivity status as a flow. + * + * **Note:** network connectivity does not imply internet connectivity. The device can be connected to a network that can't reach the homeserver. + */ +interface NetworkMonitor { + /** + * A flow containing the current network connectivity status. + */ + val connectivity: StateFlow +} 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 0000000..f96f866 --- /dev/null +++ b/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/NetworkStatus.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.networkmonitor.api + +/** + * Network connectivity status of the device. + * + * **Note:** this is *network* connectivity status, not *internet* connectivity status. + */ +enum class NetworkStatus { + /** + * The device is connected to a network. + */ + Connected, + + /** + * The device is not connected to any networks. + */ + Disconnected +} diff --git a/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/ui/ConnectivityIndicator.kt b/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/ui/ConnectivityIndicator.kt new file mode 100644 index 0000000..c79f2a1 --- /dev/null +++ b/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/ui/ConnectivityIndicator.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.networkmonitor.api.ui + +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.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 androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.Text +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun ConnectivityIndicator( + verticalPadding: Dp, + modifier: Modifier = Modifier, +) { + Row( + modifier + .fillMaxWidth() + .background(ElementTheme.colors.bgSubtlePrimary) + .statusBarsPadding() + .padding(vertical = verticalPadding), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = CompoundIcons.Offline(), + contentDescription = null, + tint = ElementTheme.colors.iconPrimary, + modifier = Modifier.size(16.sp.toDp()), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(CommonStrings.common_offline), + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textPrimary, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun ConnectivityIndicatorPreview() = ElementPreview { + ConnectivityIndicator(verticalPadding = 6.dp) +} diff --git a/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/ui/ConnectivityIndicatorContainer.kt b/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/ui/ConnectivityIndicatorContainer.kt new file mode 100644 index 0000000..7c3ff4a --- /dev/null +++ b/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/ui/ConnectivityIndicatorContainer.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.networkmonitor.api.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.statusBars +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.unit.dp + +private val INDICATOR_VERTICAL_PADDING = 6.dp + +/** + * A view that displays a connectivity indicator when the device is offline. + */ +@Composable +fun ConnectivityIndicatorContainer( + isOnline: Boolean, + modifier: Modifier = Modifier, + content: @Composable (Modifier) -> Unit = {}, +) { + val isIndicatorVisible = remember { MutableTransitionState(!isOnline) }.apply { targetState = !isOnline } + Column(modifier = modifier) { + val statusBarTopPadding = if (LocalInspectionMode.current) { + // Needed to get valid UI previews + 24.dp + } else { + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + INDICATOR_VERTICAL_PADDING + } + val target = if (isIndicatorVisible.targetState) statusBarTopPadding else 0.dp + val topWindowInset by animateDpAsState( + targetValue = target, + animationSpec = spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = 1.dp, + ), + label = "insets-animation", + ) + // Display the network indicator with an animation + AnimatedVisibility( + visibleState = isIndicatorVisible, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + ConnectivityIndicator(verticalPadding = INDICATOR_VERTICAL_PADDING) + } + // Consume the window insets to avoid double padding. + content( + Modifier.consumeWindowInsets(PaddingValues(top = topWindowInset)) + ) + } +} diff --git a/features/networkmonitor/impl/build.gradle.kts b/features/networkmonitor/impl/build.gradle.kts new file mode 100644 index 0000000..ba754ec --- /dev/null +++ b/features/networkmonitor/impl/build.gradle.kts @@ -0,0 +1,26 @@ +import extension.setupDependencyInjection + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +setupDependencyInjection() + +android { + namespace = "io.element.android.features.networkmonitor.impl" +} + +dependencies { + implementation(libs.coroutines.core) + 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 0000000..f13a099 --- /dev/null +++ b/features/networkmonitor/impl/src/main/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + diff --git a/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/DefaultNetworkMonitor.kt b/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/DefaultNetworkMonitor.kt new file mode 100644 index 0000000..8e10cee --- /dev/null +++ b/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/DefaultNetworkMonitor.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@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.NetworkRequest +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.di.annotations.ApplicationContext +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 + +@ContributesBinding(scope = AppScope::class) +@SingleIn(AppScope::class) +class DefaultNetworkMonitor( + @ApplicationContext context: Context, + @AppCoroutineScope + appCoroutineScope: CoroutineScope, +) : NetworkMonitor { + private val connectivityManager: ConnectivityManager = context.getSystemService(ConnectivityManager::class.java) + + override val connectivity: StateFlow = 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.Disconnected) + } + } + + override fun onAvailable(network: Network) { + if (activeNetworksCount.incrementAndGet() > 0) { + trySendBlocking(NetworkStatus.Connected) + } + } + } + trySendBlocking(connectivityManager.activeNetworkStatus()) + val request = NetworkRequest.Builder().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 if (activeNetwork != null) NetworkStatus.Connected else NetworkStatus.Disconnected + } +} diff --git a/features/networkmonitor/test/build.gradle.kts b/features/networkmonitor/test/build.gradle.kts new file mode 100644 index 0000000..5a6eff9 --- /dev/null +++ b/features/networkmonitor/test/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..37d569d --- /dev/null +++ b/features/networkmonitor/test/src/main/kotlin/io/element/android/features/networkmonitor/test/FakeNetworkMonitor.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.Connected) : NetworkMonitor { + override val connectivity = MutableStateFlow(initialStatus) +} diff --git a/features/poll/api/build.gradle.kts b/features/poll/api/build.gradle.kts new file mode 100644 index 0000000..fb7534a --- /dev/null +++ b/features/poll/api/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.poll.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.matrix.api) +} diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/EndPollAction.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/EndPollAction.kt new file mode 100644 index 0000000..a46a2db --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/EndPollAction.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.api.actions + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline + +interface EndPollAction { + suspend fun execute(timeline: Timeline, pollStartId: EventId): Result +} diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt new file mode 100644 index 0000000..00b60ed --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.api.actions + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline + +interface SendPollResponseAction { + suspend fun execute( + timeline: Timeline, + pollStartId: EventId, + answerId: String + ): Result +} diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/create/CreatePollEntryPoint.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/create/CreatePollEntryPoint.kt new file mode 100644 index 0000000..1bdb20b --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/create/CreatePollEntryPoint.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.api.create + +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.matrix.api.timeline.Timeline + +interface CreatePollEntryPoint : FeatureEntryPoint { + data class Params( + val timelineMode: Timeline.Mode, + val mode: CreatePollMode, + ) + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + ): Node +} diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/create/CreatePollMode.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/create/CreatePollMode.kt new file mode 100644 index 0000000..2843109 --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/create/CreatePollMode.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.api.create + +import io.element.android.libraries.matrix.api.core.EventId + +sealed interface CreatePollMode { + data object NewPoll : CreatePollMode + data class EditPoll(val eventId: EventId) : CreatePollMode +} diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/history/PollHistoryEntryPoint.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/history/PollHistoryEntryPoint.kt new file mode 100644 index 0000000..15bc156 --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/history/PollHistoryEntryPoint.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.api.history + +import io.element.android.libraries.architecture.SimpleFeatureEntryPoint + +interface PollHistoryEntryPoint : SimpleFeatureEntryPoint diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerItem.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerItem.kt new file mode 100644 index 0000000..1fa7eca --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerItem.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.api.pollcontent + +import io.element.android.libraries.matrix.api.poll.PollAnswer + +/** + * UI model for a [PollAnswer]. + * + * @property answer the poll answer. + * @property isSelected whether the user has selected this answer. + * @property isEnabled whether the answer can be voted. + * @property isWinner whether this is the winner answer in the poll. + * @property showVotes whether the votes for this answer should be displayed. + * @property votesCount the number of votes for this answer. + * @property percentage the percentage of votes for this answer. + */ +data class PollAnswerItem( + val answer: PollAnswer, + val isSelected: Boolean, + val isEnabled: Boolean, + val isWinner: Boolean, + val showVotes: Boolean, + val votesCount: Int, + val percentage: Float, +) diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerView.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerView.kt new file mode 100644 index 0000000..489a8d4 --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollAnswerView.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.api.pollcontent + +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.poll.api.R +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor +import io.element.android.libraries.designsystem.toEnabledColor +import io.element.android.libraries.designsystem.utils.CommonDrawables +import io.element.android.libraries.ui.strings.CommonPlurals +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun PollAnswerView( + answerItem: PollAnswerItem, + modifier: Modifier = Modifier, +) { + val nbVotesText = pluralStringResource( + id = CommonPlurals.common_poll_votes_count, + count = answerItem.votesCount, + answerItem.votesCount, + ) + val a11yText = buildString { + val sentenceDelimiter = stringResource(CommonStrings.common_sentence_delimiter) + append(answerItem.answer.text.removeSuffix(".")) + if (answerItem.showVotes) { + append(sentenceDelimiter) + append(nbVotesText) + if (answerItem.votesCount != 0) { + append(sentenceDelimiter) + (answerItem.percentage * 100).toInt().let { percent -> + append(pluralStringResource(R.plurals.a11y_polls_percent_of_total, percent, percent)) + } + } + if (answerItem.isWinner) { + append(sentenceDelimiter) + append(stringResource(R.string.a11y_polls_winning_answer)) + } + } + } + Row( + modifier = modifier + .fillMaxWidth() + .clearAndSetSemantics { + contentDescription = a11yText + }, + ) { + Icon( + imageVector = if (answerItem.isSelected) { + CompoundIcons.CheckCircleSolid() + } else { + CompoundIcons.Circle() + }, + contentDescription = null, + modifier = Modifier + .padding(0.5.dp) + .size(22.dp), + tint = if (answerItem.isEnabled) { + if (answerItem.isSelected) { + ElementTheme.colors.iconPrimary + } else { + ElementTheme.colors.iconSecondary + } + } else { + ElementTheme.colors.iconDisabled + }, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Row { + Text( + modifier = Modifier.weight(1f), + text = answerItem.answer.text, + style = if (answerItem.isWinner) ElementTheme.typography.fontBodyLgMedium else ElementTheme.typography.fontBodyLgRegular, + ) + if (answerItem.showVotes) { + Row( + modifier = Modifier.align(Alignment.Bottom), + verticalAlignment = Alignment.CenterVertically, + ) { + if (answerItem.isWinner) { + Icon( + resourceId = CommonDrawables.ic_winner, + contentDescription = null, + tint = ElementTheme.colors.iconAccentTertiary, + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = nbVotesText, + style = ElementTheme.typography.fontBodySmMedium, + color = ElementTheme.colors.textPrimary, + ) + } else { + Text( + text = nbVotesText, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } + } + } + Spacer(modifier = Modifier.height(10.dp)) + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = if (answerItem.isWinner) ElementTheme.colors.textSuccessPrimary else answerItem.isEnabled.toEnabledColor(), + progress = { + when { + answerItem.showVotes -> answerItem.percentage + answerItem.isSelected -> 1f + else -> 0f + } + }, + trackColor = ElementTheme.colors.progressIndicatorTrackColor, + strokeCap = StrokeCap.Round, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun PollAnswerViewDisclosedNotSelectedPreview() = ElementPreview { + PollAnswerView( + answerItem = aPollAnswerItem(showVotes = true, isSelected = false), + ) +} + +@PreviewsDayNight +@Composable +internal fun PollAnswerViewDisclosedSelectedPreview() = ElementPreview { + PollAnswerView( + answerItem = aPollAnswerItem(showVotes = true, isSelected = true), + ) +} + +@PreviewsDayNight +@Composable +internal fun PollAnswerViewUndisclosedNotSelectedPreview() = ElementPreview { + PollAnswerView( + answerItem = aPollAnswerItem(showVotes = false, isSelected = false), + ) +} + +@PreviewsDayNight +@Composable +internal fun PollAnswerViewUndisclosedSelectedPreview() = ElementPreview { + PollAnswerView( + answerItem = aPollAnswerItem(showVotes = false, isSelected = true), + ) +} + +@PreviewsDayNight +@Composable +internal fun PollAnswerViewEndedWinnerNotSelectedPreview() = ElementPreview { + PollAnswerView( + answerItem = aPollAnswerItem(showVotes = true, isSelected = false, isEnabled = false, isWinner = true), + ) +} + +@PreviewsDayNight +@Composable +internal fun PollAnswerViewEndedWinnerSelectedPreview() = ElementPreview { + PollAnswerView( + answerItem = aPollAnswerItem(showVotes = true, isSelected = true, isEnabled = false, isWinner = true), + ) +} + +@PreviewsDayNight +@Composable +internal fun PollAnswerViewEndedSelectedPreview() = ElementPreview { + PollAnswerView( + answerItem = aPollAnswerItem(showVotes = true, isSelected = true, isEnabled = false, isWinner = false), + ) +} diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentState.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentState.kt new file mode 100644 index 0000000..1689408 --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentState.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.api.pollcontent + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.poll.PollKind +import kotlinx.collections.immutable.ImmutableList + +/** + * UI model for a PollContent. + * @property eventId the event id of the poll. + * @property question the poll question. + * @property answerItems the list of answers. + * @property pollKind the kind of poll. + * @property isPollEditable whether the poll is editable. + * @property isPollEnded whether the poll is ended. + * @property isMine whether the poll has been created by me. + */ +data class PollContentState( + val eventId: EventId?, + val question: String, + val answerItems: ImmutableList, + val pollKind: PollKind, + val isPollEditable: Boolean, + val isPollEnded: Boolean, + val isMine: Boolean, +) diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFactory.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFactory.kt new file mode 100644 index 0000000..c9bd31e --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFactory.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.api.pollcontent + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent + +interface PollContentStateFactory { + suspend fun create(eventTimelineItem: EventTimelineItem, content: PollContent): PollContentState { + return create( + eventId = eventTimelineItem.eventId, + isEditable = eventTimelineItem.isEditable, + isOwn = eventTimelineItem.isOwn, + content = content, + ) + } + suspend fun create(eventId: EventId?, isEditable: Boolean, isOwn: Boolean, content: PollContent): PollContentState +} diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFixtures.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFixtures.kt new file mode 100644 index 0000000..7f15c7d --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFixtures.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.api.pollcontent + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.poll.PollAnswer +import io.element.android.libraries.matrix.api.poll.PollKind +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +fun aPollQuestion() = "What type of food should we have at the party?" + +fun aPollAnswerItemList( + hasVotes: Boolean = true, + isEnded: Boolean = false, + showVotes: Boolean = true, +) = persistentListOf( + aPollAnswerItem( + answer = PollAnswer("option_1", "Italian \uD83C\uDDEE\uD83C\uDDF9"), + showVotes = showVotes, + isEnabled = !isEnded, + isWinner = isEnded, + votesCount = if (hasVotes) 5 else 0, + percentage = if (hasVotes) 0.5f else 0f + ), + aPollAnswerItem( + answer = PollAnswer("option_2", "Chinese \uD83C\uDDE8\uD83C\uDDF3"), + showVotes = showVotes, + isEnabled = !isEnded, + isWinner = false, + votesCount = 0, + percentage = 0f + ), + aPollAnswerItem( + answer = PollAnswer("option_3", "Brazilian \uD83C\uDDE7\uD83C\uDDF7"), + showVotes = showVotes, + isEnabled = !isEnded, + isWinner = false, + isSelected = true, + votesCount = if (hasVotes) 1 else 0, + percentage = if (hasVotes) 0.1f else 0f + ), + aPollAnswerItem( + showVotes = showVotes, + isEnabled = !isEnded, + votesCount = if (hasVotes) 4 else 0, + percentage = if (hasVotes) 0.4f else 0f, + ), +) + +fun aPollAnswerItem( + answer: PollAnswer = PollAnswer( + "option_4", + "French \uD83C\uDDEB\uD83C\uDDF7 But make it a very very very long option then this should just keep expanding" + ), + isSelected: Boolean = false, + isEnabled: Boolean = true, + isWinner: Boolean = false, + showVotes: Boolean = true, + votesCount: Int = 4, + percentage: Float = 0.4f, +) = PollAnswerItem( + answer = answer, + isSelected = isSelected, + isEnabled = isEnabled, + isWinner = isWinner, + showVotes = showVotes, + votesCount = votesCount, + percentage = percentage +) + +fun aPollContentState( + eventId: EventId? = null, + isMine: Boolean = false, + isEnded: Boolean = false, + showVotes: Boolean = true, + isPollEditable: Boolean = true, + hasVotes: Boolean = true, + question: String = aPollQuestion(), + pollKind: PollKind = PollKind.Disclosed, + answerItems: ImmutableList = aPollAnswerItemList( + isEnded = isEnded, + showVotes = showVotes, + hasVotes = hasVotes + ), +) = PollContentState( + eventId = eventId, + question = question, + answerItems = answerItems, + pollKind = pollKind, + isPollEditable = isMine && !isEnded && isPollEditable, + isPollEnded = isEnded, + isMine = isMine, +) diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt new file mode 100644 index 0000000..e1df373 --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt @@ -0,0 +1,302 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.api.pollcontent + +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.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +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.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +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.poll.PollAnswer +import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun PollContentView( + state: PollContentState, + onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, + onEditPoll: (pollStartId: EventId) -> Unit, + onEndPoll: (pollStartId: EventId) -> Unit, + modifier: Modifier = Modifier, +) { + PollContentView( + eventId = state.eventId, + question = state.question, + answerItems = state.answerItems, + pollKind = state.pollKind, + isPollEditable = state.isPollEditable, + isPollEnded = state.isPollEnded, + isMine = state.isMine, + onEditPoll = onEditPoll, + onSelectAnswer = onSelectAnswer, + onEndPoll = onEndPoll, + modifier = modifier, + ) +} + +@Composable +fun PollContentView( + eventId: EventId?, + question: String, + answerItems: ImmutableList, + pollKind: PollKind, + isPollEditable: Boolean, + isPollEnded: Boolean, + isMine: Boolean, + onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, + onEditPoll: (pollStartId: EventId) -> Unit, + onEndPoll: (pollStartId: EventId) -> Unit, + modifier: Modifier = Modifier, +) { + val votesCount = remember(answerItems) { answerItems.sumOf { it.votesCount } } + + fun onSelectAnswer(pollAnswer: PollAnswer) { + eventId?.let { onSelectAnswer(it, pollAnswer.id) } + } + + fun onEditPoll() { + eventId?.let { onEditPoll(it) } + } + + fun onEndPoll() { + eventId?.let { onEndPoll(it) } + } + + var showConfirmation: Boolean by remember { mutableStateOf(false) } + + if (showConfirmation) { + ConfirmationDialog( + content = stringResource(id = CommonStrings.common_poll_end_confirmation), + onSubmitClick = { + onEndPoll() + showConfirmation = false + }, + onDismiss = { showConfirmation = false }, + ) + } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + PollTitleView(title = question, isPollEnded = isPollEnded) + + PollAnswers(answerItems = answerItems, onSelectAnswer = ::onSelectAnswer) + + if (isPollEnded || pollKind == PollKind.Disclosed) { + DisclosedPollBottomNotice(votesCount = votesCount) + } else { + UndisclosedPollBottomNotice() + } + + if (isMine) { + CreatorView( + isPollEnded = isPollEnded, + isPollEditable = isPollEditable, + onEditPoll = ::onEditPoll, + onEndPoll = { showConfirmation = true }, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun PollAnswers( + answerItems: ImmutableList, + onSelectAnswer: (PollAnswer) -> Unit, +) { + Column( + modifier = Modifier.selectableGroup(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + answerItems.forEach { + PollAnswerView( + answerItem = it, + modifier = Modifier + .selectable( + selected = it.isSelected, + enabled = it.isEnabled, + onClick = { onSelectAnswer(it.answer) }, + role = Role.RadioButton, + ), + ) + } + } +} + +@Composable +private fun ColumnScope.DisclosedPollBottomNotice( + votesCount: Int, +) { + Text( + modifier = Modifier.align(Alignment.End), + style = ElementTheme.typography.fontBodyXsRegular, + color = ElementTheme.colors.textSecondary, + text = stringResource(CommonStrings.common_poll_total_votes, votesCount), + ) +} + +@Composable +private fun ColumnScope.UndisclosedPollBottomNotice() { + Text( + modifier = Modifier + .align(Alignment.Start) + .padding(start = 34.dp), + style = ElementTheme.typography.fontBodyXsRegular, + color = ElementTheme.colors.textSecondary, + text = stringResource(CommonStrings.common_poll_undisclosed_text), + ) +} + +@Composable +private fun CreatorView( + isPollEnded: Boolean, + isPollEditable: Boolean, + onEditPoll: () -> Unit, + onEndPoll: () -> Unit, + modifier: Modifier = Modifier +) { + when { + isPollEditable -> + Button( + text = stringResource(id = CommonStrings.action_edit_poll), + onClick = onEditPoll, + modifier = modifier, + ) + !isPollEnded -> + Button( + text = stringResource(id = CommonStrings.action_end_poll), + onClick = onEndPoll, + modifier = modifier, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun PollContentViewUndisclosedPreview() = ElementPreview { + PollContentView( + eventId = EventId("\$anEventId"), + question = "What type of food should we have at the party?", + answerItems = aPollAnswerItemList(showVotes = false), + pollKind = PollKind.Undisclosed, + isPollEnded = false, + isPollEditable = false, + isMine = false, + onSelectAnswer = { _, _ -> }, + onEditPoll = {}, + onEndPoll = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun PollContentViewDisclosedPreview() = ElementPreview { + PollContentView( + eventId = EventId("\$anEventId"), + question = "What type of food should we have at the party?", + answerItems = aPollAnswerItemList(), + pollKind = PollKind.Disclosed, + isPollEnded = false, + isPollEditable = false, + isMine = false, + onSelectAnswer = { _, _ -> }, + onEditPoll = {}, + onEndPoll = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun PollContentViewEndedPreview() = ElementPreview { + PollContentView( + eventId = EventId("\$anEventId"), + question = "What type of food should we have at the party?", + answerItems = aPollAnswerItemList(isEnded = true), + pollKind = PollKind.Disclosed, + isPollEnded = true, + isPollEditable = false, + isMine = false, + onSelectAnswer = { _, _ -> }, + onEditPoll = {}, + onEndPoll = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun PollContentViewCreatorEditablePreview() = ElementPreview { + PollContentView( + eventId = EventId("\$anEventId"), + question = "What type of food should we have at the party?", + answerItems = aPollAnswerItemList(hasVotes = false, isEnded = false), + pollKind = PollKind.Disclosed, + isPollEnded = false, + isPollEditable = true, + isMine = true, + onSelectAnswer = { _, _ -> }, + onEditPoll = {}, + onEndPoll = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun PollContentViewCreatorPreview() = ElementPreview { + PollContentView( + eventId = EventId("\$anEventId"), + question = "What type of food should we have at the party?", + answerItems = aPollAnswerItemList(isEnded = false), + pollKind = PollKind.Disclosed, + isPollEnded = false, + isPollEditable = false, + isMine = true, + onSelectAnswer = { _, _ -> }, + onEditPoll = {}, + onEndPoll = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun PollContentViewCreatorEndedPreview() = ElementPreview { + PollContentView( + eventId = EventId("\$anEventId"), + question = "What type of food should we have at the party?", + answerItems = aPollAnswerItemList(isEnded = true), + pollKind = PollKind.Disclosed, + isPollEnded = true, + isPollEditable = false, + isMine = true, + onSelectAnswer = { _, _ -> }, + onEditPoll = {}, + onEndPoll = {}, + ) +} diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollTitleView.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollTitleView.kt new file mode 100644 index 0000000..06eb759 --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollTitleView.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.api.pollcontent + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +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.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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 +fun PollTitleView( + title: String, + isPollEnded: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (isPollEnded) { + Icon( + imageVector = CompoundIcons.PollsEnd(), + contentDescription = stringResource(id = CommonStrings.a11y_poll_end), + modifier = Modifier.size(22.dp) + ) + } else { + Icon( + imageVector = CompoundIcons.Polls(), + contentDescription = stringResource(id = CommonStrings.a11y_poll), + modifier = Modifier.size(22.dp) + ) + } + Text( + text = title, + style = ElementTheme.typography.fontBodyLgMedium + ) + } +} + +@PreviewsDayNight +@Composable +internal fun PollTitleViewPreview() = ElementPreview { + PollTitleView( + title = "What is your favorite color?", + isPollEnded = false + ) +} diff --git a/features/poll/api/src/main/res/values-cs/translations.xml b/features/poll/api/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..e87760d --- /dev/null +++ b/features/poll/api/src/main/res/values-cs/translations.xml @@ -0,0 +1,10 @@ + + + + "%1$d procento z celkového počtu hlasů" + "%1$d procenta z celkového počtu hlasů" + "%1$d procent z celkového počtu hlasů" + + "Odstraní předchozí výběr" + "Toto je vítězná odpověď" + diff --git a/features/poll/api/src/main/res/values-cy/translations.xml b/features/poll/api/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..207b2f5 --- /dev/null +++ b/features/poll/api/src/main/res/values-cy/translations.xml @@ -0,0 +1,13 @@ + + + + "%1$d y cant o\'r holl bleidleisiau" + "%1$d y cant o\'r holl bleidleisiau" + "%1$d y cant o\'r holl bleidleisiau" + "%1$d y cant o\'r holl bleidleisiau" + "%1$d y cant o\'r holl bleidleisiau" + "%1$d y cant o\'r holl bleidleisiau" + + "Bydd yn dileu\'r dewis blaenorol" + "Dyma\'r ateb buddugol" + diff --git a/features/poll/api/src/main/res/values-da/translations.xml b/features/poll/api/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..58517b3 --- /dev/null +++ b/features/poll/api/src/main/res/values-da/translations.xml @@ -0,0 +1,9 @@ + + + + "%1$d% af de samlede stemmer" + "%1$d procent af det samlede antal stemmer" + + "Fjerner tidligere valg" + "Dette er det vindende svar" + diff --git a/features/poll/api/src/main/res/values-de/translations.xml b/features/poll/api/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..cce5484 --- /dev/null +++ b/features/poll/api/src/main/res/values-de/translations.xml @@ -0,0 +1,9 @@ + + + + "%1$d Prozent aller Stimmen" + "%1$d Prozent aller Stimmen" + + "Entfernt die vorherige Auswahl" + "Das ist die meistgewählte Antwort" + diff --git a/features/poll/api/src/main/res/values-el/translations.xml b/features/poll/api/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..fcd3497 --- /dev/null +++ b/features/poll/api/src/main/res/values-el/translations.xml @@ -0,0 +1,8 @@ + + + + "%1$d τοις εκατό των συνολικών ψήφων" + "%1$d τοις εκατό του συνόλου των ψήφων" + + "Αυτή είναι η νικητήρια απάντηση" + diff --git a/features/poll/api/src/main/res/values-et/translations.xml b/features/poll/api/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..ec04ca0 --- /dev/null +++ b/features/poll/api/src/main/res/values-et/translations.xml @@ -0,0 +1,9 @@ + + + + "%1$d protsent kõikidest antud häältest" + "%1$d protsenti kõikidest antud häältest" + + "See kustutab eelmise valiku" + "See vastus võitis" + diff --git a/features/poll/api/src/main/res/values-eu/translations.xml b/features/poll/api/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..07f2180 --- /dev/null +++ b/features/poll/api/src/main/res/values-eu/translations.xml @@ -0,0 +1,5 @@ + + + "Aurreko hautaketa kenduko du" + "Erantzun hau gailendu da" + diff --git a/features/poll/api/src/main/res/values-fa/translations.xml b/features/poll/api/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..af9620c --- /dev/null +++ b/features/poll/api/src/main/res/values-fa/translations.xml @@ -0,0 +1,5 @@ + + + "گزینش پیشین را برخواهد داشت" + "این پاسخ برنده است" + diff --git a/features/poll/api/src/main/res/values-fi/translations.xml b/features/poll/api/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..1dbda4a --- /dev/null +++ b/features/poll/api/src/main/res/values-fi/translations.xml @@ -0,0 +1,9 @@ + + + + "%1$d prosentti kaikista äänistä" + "%1$d prosenttia kaikista äänistä" + + "Poistaa edellisen valinnan" + "Tämä on voittava vastaus" + diff --git a/features/poll/api/src/main/res/values-fr/translations.xml b/features/poll/api/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..d2ab7cb --- /dev/null +++ b/features/poll/api/src/main/res/values-fr/translations.xml @@ -0,0 +1,9 @@ + + + + "%1$d pour cent du total des votes" + "%1$d pour cent du total des votes" + + "Supprimera la sélection précédente" + "C’est la réponse gagnante" + diff --git a/features/poll/api/src/main/res/values-hu/translations.xml b/features/poll/api/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..9aa6d04 --- /dev/null +++ b/features/poll/api/src/main/res/values-hu/translations.xml @@ -0,0 +1,9 @@ + + + + "az összes szavazat %1$d százaléka" + "az összes szavazat %1$d százaléka" + + "Eltávolítja a korábbi kijelölést" + "Ez a győztes válasz" + diff --git a/features/poll/api/src/main/res/values-in/translations.xml b/features/poll/api/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..1810031 --- /dev/null +++ b/features/poll/api/src/main/res/values-in/translations.xml @@ -0,0 +1,7 @@ + + + + "%1$d persen dari total suara" + + "Ini adalah jawaban yang menang" + diff --git a/features/poll/api/src/main/res/values-it/translations.xml b/features/poll/api/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..7b24b9a --- /dev/null +++ b/features/poll/api/src/main/res/values-it/translations.xml @@ -0,0 +1,9 @@ + + + + "%1$d percento dei voti totali" + "%1$d percento dei voti totali" + + "Rimuoverà la selezione precedente" + "Questa è la risposta vincente" + diff --git a/features/poll/api/src/main/res/values-ko/translations.xml b/features/poll/api/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..e5c0c06 --- /dev/null +++ b/features/poll/api/src/main/res/values-ko/translations.xml @@ -0,0 +1,8 @@ + + + + "%1$d 총 투표율" + + "이전 선택 항목을 제거합니다" + "이것이 승리의 답입니다" + diff --git a/features/poll/api/src/main/res/values-nb/translations.xml b/features/poll/api/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..8b81fd3 --- /dev/null +++ b/features/poll/api/src/main/res/values-nb/translations.xml @@ -0,0 +1,9 @@ + + + + "%1$d prosent av totalt antall stemmer" + "%1$d prosent av totalt antall stemmer" + + "Vil fjerne forrige valg" + "Dette er vinnersvaret" + diff --git a/features/poll/api/src/main/res/values-nl/translations.xml b/features/poll/api/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..0bc6a6e --- /dev/null +++ b/features/poll/api/src/main/res/values-nl/translations.xml @@ -0,0 +1,4 @@ + + + "Verwijdert de vorige selectie" + diff --git a/features/poll/api/src/main/res/values-pl/translations.xml b/features/poll/api/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..5e4ed3f --- /dev/null +++ b/features/poll/api/src/main/res/values-pl/translations.xml @@ -0,0 +1,10 @@ + + + + "%1$d procent wszystkich głosów" + "%1$d procenty wszystkich głosów" + "%1$d procent wszystkich głosów" + + "Spowoduje to usunięcie poprzedniego zaznaczenia" + "Zwycięska odpowiedź" + diff --git a/features/poll/api/src/main/res/values-pt-rBR/translations.xml b/features/poll/api/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..77bbb3d --- /dev/null +++ b/features/poll/api/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,9 @@ + + + + "%1$d por cento de todos os votos" + "%1$d por cento de todos os votos" + + "Removerá a seleção anterior" + "Esta é a resposta vencedora" + diff --git a/features/poll/api/src/main/res/values-pt/translations.xml b/features/poll/api/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..81db444 --- /dev/null +++ b/features/poll/api/src/main/res/values-pt/translations.xml @@ -0,0 +1,9 @@ + + + + "%1$d porcento de todos os votos" + "%1$d porcento de todos os votos" + + "Irá remover seleção anterior" + "Esta é a reposta vencedora" + diff --git a/features/poll/api/src/main/res/values-ro/translations.xml b/features/poll/api/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..ef86b4f --- /dev/null +++ b/features/poll/api/src/main/res/values-ro/translations.xml @@ -0,0 +1,10 @@ + + + + "%1$d la suta din totalul voturilor" + "%1$d la suta din totalul voturilor" + "%1$d la suta din totalul voturilor" + + "Va șterge selecția anterioară" + "Acesta este votul câștigător" + diff --git a/features/poll/api/src/main/res/values-ru/translations.xml b/features/poll/api/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..0e97fc7 --- /dev/null +++ b/features/poll/api/src/main/res/values-ru/translations.xml @@ -0,0 +1,10 @@ + + + + "%1$d процент от общего числа голосов" + "%1$d процента от общего числа голосов" + "%1$d процентов от общего числа голосов" + + "Удалить предыдущий ответ" + "Это лучший ответ" + diff --git a/features/poll/api/src/main/res/values-sk/translations.xml b/features/poll/api/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..eed87c6 --- /dev/null +++ b/features/poll/api/src/main/res/values-sk/translations.xml @@ -0,0 +1,10 @@ + + + + "%1$d percento z celkového počtu hlasov" + "%1$d percentá z celkového počtu hlasov" + "%1$d percent z celkového počtu hlasov" + + "Odstráni predchádzajúci výber" + "Toto je víťazná odpoveď" + diff --git a/features/poll/api/src/main/res/values-sv/translations.xml b/features/poll/api/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..e04a499 --- /dev/null +++ b/features/poll/api/src/main/res/values-sv/translations.xml @@ -0,0 +1,9 @@ + + + + "%1$d procent av totala röster" + "%1$d procent av totala röster" + + "Kommer att ta bort föregående val" + "Detta är det vinnande svaret" + diff --git a/features/poll/api/src/main/res/values-uk/translations.xml b/features/poll/api/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..20d094a --- /dev/null +++ b/features/poll/api/src/main/res/values-uk/translations.xml @@ -0,0 +1,10 @@ + + + + "%1$d відсоток від усіх голосів" + "%1$d відсотки від усіх голосів" + "%1$d відсотків від усіх голосів" + + "Попередній вибір буде прибрано" + "Ця відповідь перемогла" + diff --git a/features/poll/api/src/main/res/values-uz/translations.xml b/features/poll/api/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..8446135 --- /dev/null +++ b/features/poll/api/src/main/res/values-uz/translations.xml @@ -0,0 +1,9 @@ + + + + "Jami ovozlarning %1$d foizi" + "Jami ovozlarning %1$d foizi" + + "Oldingi tanlov olib tashlanadi" + "Bu g\'alaba qozongan javob" + diff --git a/features/poll/api/src/main/res/values-zh-rTW/translations.xml b/features/poll/api/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..da6b7eb --- /dev/null +++ b/features/poll/api/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,8 @@ + + + + "總票數的百分之 %1$d" + + "將會移除先前的選擇" + "這是得票數最高的選項" + diff --git a/features/poll/api/src/main/res/values-zh/translations.xml b/features/poll/api/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..773d2b0 --- /dev/null +++ b/features/poll/api/src/main/res/values-zh/translations.xml @@ -0,0 +1,8 @@ + + + + "%1$d 总投票百分比" + + "将移除之前的选择" + "这是获胜的答案" + diff --git a/features/poll/api/src/main/res/values/localazy.xml b/features/poll/api/src/main/res/values/localazy.xml new file mode 100644 index 0000000..ebba470 --- /dev/null +++ b/features/poll/api/src/main/res/values/localazy.xml @@ -0,0 +1,9 @@ + + + + "%1$d percent of total votes" + "%1$d percents of total votes" + + "Will remove previous selection" + "This is the winning answer" + diff --git a/features/poll/impl/build.gradle.kts b/features/poll/impl/build.gradle.kts new file mode 100644 index 0000000..175f7b4 --- /dev/null +++ b/features/poll/impl/build.gradle.kts @@ -0,0 +1,46 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.poll.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + api(projects.features.poll.api) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.services.analytics.api) + implementation(projects.features.messages.api) + implementation(projects.libraries.dateformatter.api) + implementation(projects.libraries.uiStrings) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.services.analytics.test) + testImplementation(projects.features.messages.test) + testImplementation(projects.libraries.dateformatter.test) + testImplementation(projects.features.poll.test) +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/PollConstants.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/PollConstants.kt new file mode 100644 index 0000000..c3579dc --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/PollConstants.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl + +internal object PollConstants { + const val MIN_ANSWERS = 2 + const val MAX_ANSWERS = 20 + const val MAX_ANSWER_LENGTH = 240 + const val MAX_SELECTIONS = 1 +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultEndPollAction.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultEndPollAction.kt new file mode 100644 index 0000000..a483930 --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultEndPollAction.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.actions + +import dev.zacsweers.metro.ContributesBinding +import im.vector.app.features.analytics.plan.PollEnd +import io.element.android.features.poll.api.actions.EndPollAction +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.services.analytics.api.AnalyticsService + +@ContributesBinding(RoomScope::class) +class DefaultEndPollAction( + private val analyticsService: AnalyticsService, +) : EndPollAction { + override suspend fun execute(timeline: Timeline, pollStartId: EventId): Result { + return timeline.endPoll( + pollStartId = pollStartId, + text = "The poll with event id: $pollStartId has ended." + ).onSuccess { + analyticsService.capture(PollEnd()) + } + } +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultSendPollResponseAction.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultSendPollResponseAction.kt new file mode 100644 index 0000000..f48604b --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultSendPollResponseAction.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.actions + +import dev.zacsweers.metro.ContributesBinding +import im.vector.app.features.analytics.plan.PollVote +import io.element.android.features.poll.api.actions.SendPollResponseAction +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.services.analytics.api.AnalyticsService + +@ContributesBinding(RoomScope::class) +class DefaultSendPollResponseAction( + private val analyticsService: AnalyticsService, +) : SendPollResponseAction { + override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerId: String): Result { + return timeline.sendPollResponse( + pollStartId = pollStartId, + answers = listOf(answerId), + ).onSuccess { + analyticsService.capture(PollVote()) + } + } +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvents.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvents.kt new file mode 100644 index 0000000..3d1c162 --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvents.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.create + +import io.element.android.libraries.matrix.api.poll.PollKind + +sealed interface CreatePollEvents { + data object Save : CreatePollEvents + data class Delete(val confirmed: Boolean) : CreatePollEvents + data class SetQuestion(val question: String) : CreatePollEvents + data class SetAnswer(val index: Int, val text: String) : CreatePollEvents + data object AddAnswer : CreatePollEvents + data class RemoveAnswer(val index: Int) : CreatePollEvents + data class SetPollKind(val pollKind: PollKind) : CreatePollEvents + data object NavBack : CreatePollEvents + data object ConfirmNavBack : CreatePollEvents + data object HideConfirmation : CreatePollEvents +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollException.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollException.kt new file mode 100644 index 0000000..e5bb32e --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollException.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.create + +internal sealed class CreatePollException : Exception() { + data class GetPollFailed( + override val message: String?, + override val cause: Throwable? + ) : CreatePollException() + + data class SavePollFailed( + override val message: String?, + override val cause: Throwable? + ) : CreatePollException() +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollNode.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollNode.kt new file mode 100644 index 0000000..9e08b58 --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollNode.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.create + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.annotations.ContributesNode +import io.element.android.features.poll.api.create.CreatePollMode +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.timeline.Timeline +import io.element.android.services.analytics.api.AnalyticsService +import java.util.concurrent.atomic.AtomicBoolean + +@ContributesNode(RoomScope::class) +@AssistedInject +class CreatePollNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: CreatePollPresenter.Factory, + analyticsService: AnalyticsService, +) : Node(buildContext, plugins = plugins) { + data class Inputs(val mode: CreatePollMode, val timelineMode: Timeline.Mode) : NodeInputs + + private val inputs: Inputs = inputs() + + private var isNavigatingUp = AtomicBoolean(false) + + private val presenter = presenterFactory.create( + backNavigator = { + if (isNavigatingUp.compareAndSet(false, true)) { + navigateUp() + } + }, + mode = inputs.mode, + timelineMode = inputs.timelineMode, + ) + + init { + lifecycle.subscribe( + onResume = { + analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.CreatePollView)) + } + ) + } + + @Composable + override fun View(modifier: Modifier) { + CreatePollView( + state = presenter.present(), + modifier = modifier, + ) + } +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt new file mode 100644 index 0000000..3da8c3d --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.create + +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.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.Composer +import im.vector.app.features.analytics.plan.PollCreation +import io.element.android.features.messages.api.MessageComposerContext +import io.element.android.features.poll.api.create.CreatePollMode +import io.element.android.features.poll.impl.PollConstants.MAX_SELECTIONS +import io.element.android.features.poll.impl.data.PollRepository +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.poll.PollAnswer +import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.matrix.api.poll.isDisclosed +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch +import timber.log.Timber + +@AssistedInject +class CreatePollPresenter( + repositoryFactory: PollRepository.Factory, + private val analyticsService: AnalyticsService, + private val messageComposerContext: MessageComposerContext, + @Assisted private val navigateUp: () -> Unit, + @Assisted private val mode: CreatePollMode, + @Assisted private val timelineMode: Timeline.Mode, +) : Presenter { + @AssistedFactory + fun interface Factory { + fun create( + timelineMode: Timeline.Mode, + backNavigator: () -> Unit, + mode: CreatePollMode + ): CreatePollPresenter + } + + private val repository = repositoryFactory.create(timelineMode) + + @Composable + override fun present(): CreatePollState { + // The initial state of the form. In edit mode this will be populated with the poll being edited. + var initialPoll: PollFormState by rememberSaveable(stateSaver = pollFormStateSaver) { + mutableStateOf(PollFormState.Empty) + } + // The current state of the form. + var poll: PollFormState by rememberSaveable(stateSaver = pollFormStateSaver) { + mutableStateOf(initialPoll) + } + + // Whether the form has been changed from the initial state + val isDirty: Boolean by remember { derivedStateOf { poll != initialPoll } } + + var showBackConfirmation: Boolean by rememberSaveable { mutableStateOf(false) } + var showDeleteConfirmation: Boolean by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(Unit) { + if (mode is CreatePollMode.EditPoll) { + repository.getPoll(mode.eventId).onSuccess { + val loadedPoll = PollFormState( + question = it.question, + answers = it.answers.map(PollAnswer::text).toImmutableList(), + isDisclosed = it.kind.isDisclosed, + ) + initialPoll = loadedPoll + poll = loadedPoll + }.onFailure { + analyticsService.trackGetPollFailed(it) + navigateUp() + } + } + } + + val canSave: Boolean by remember { derivedStateOf { poll.isValid } } + val canAddAnswer: Boolean by remember { derivedStateOf { poll.canAddAnswer } } + val immutableAnswers: ImmutableList by remember { derivedStateOf { poll.toUiAnswers() } } + + val scope = rememberCoroutineScope() + + fun handleEvent(event: CreatePollEvents) { + when (event) { + is CreatePollEvents.Save -> scope.launch { + if (canSave) { + repository.savePoll( + existingPollId = when (mode) { + is CreatePollMode.EditPoll -> mode.eventId + is CreatePollMode.NewPoll -> null + }, + question = poll.question, + answers = poll.answers, + pollKind = poll.pollKind, + maxSelections = MAX_SELECTIONS, + ).onSuccess { + analyticsService.capturePollSaved( + isUndisclosed = poll.pollKind == PollKind.Undisclosed, + numberOfAnswers = poll.answers.size, + ) + }.onFailure { + analyticsService.trackSavePollFailed(it, mode) + } + navigateUp() + } else { + Timber.d("Cannot create poll") + } + } + is CreatePollEvents.Delete -> { + if (mode !is CreatePollMode.EditPoll) { + return + } + + if (!event.confirmed) { + showDeleteConfirmation = true + return + } + + scope.launch { + showDeleteConfirmation = false + repository.deletePoll(mode.eventId) + navigateUp() + } + } + is CreatePollEvents.AddAnswer -> { + poll = poll.withNewAnswer() + } + is CreatePollEvents.RemoveAnswer -> { + poll = poll.withAnswerRemoved(event.index) + } + is CreatePollEvents.SetAnswer -> { + poll = poll.withAnswerChanged(event.index, event.text) + } + is CreatePollEvents.SetPollKind -> { + poll = poll.copy(isDisclosed = event.pollKind.isDisclosed) + } + is CreatePollEvents.SetQuestion -> { + poll = poll.copy(question = event.question) + } + is CreatePollEvents.NavBack -> { + navigateUp() + } + CreatePollEvents.ConfirmNavBack -> { + val shouldConfirm = isDirty + if (shouldConfirm) { + showBackConfirmation = true + } else { + navigateUp() + } + } + is CreatePollEvents.HideConfirmation -> { + showBackConfirmation = false + showDeleteConfirmation = false + } + } + } + + return CreatePollState( + mode = when (mode) { + is CreatePollMode.NewPoll -> CreatePollState.Mode.New + is CreatePollMode.EditPoll -> CreatePollState.Mode.Edit + }, + canSave = canSave, + canAddAnswer = canAddAnswer, + question = poll.question, + answers = immutableAnswers, + pollKind = poll.pollKind, + showBackConfirmation = showBackConfirmation, + showDeleteConfirmation = showDeleteConfirmation, + eventSink = ::handleEvent, + ) + } + + private fun AnalyticsService.capturePollSaved( + isUndisclosed: Boolean, + numberOfAnswers: Int, + ) { + capture( + Composer( + inThread = messageComposerContext.composerMode.inThread, + isEditing = mode is CreatePollMode.EditPoll, + isReply = messageComposerContext.composerMode.isReply, + messageType = Composer.MessageType.Poll, + ) + ) + capture( + PollCreation( + action = when (mode) { + is CreatePollMode.EditPoll -> PollCreation.Action.Edit + is CreatePollMode.NewPoll -> PollCreation.Action.Create + }, + isUndisclosed = isUndisclosed, + numberOfAnswers = numberOfAnswers, + ) + ) + } +} + +private fun AnalyticsService.trackGetPollFailed(cause: Throwable) { + val exception = CreatePollException.GetPollFailed( + message = "Tried to edit poll but couldn't get poll", + cause = cause, + ) + Timber.e(exception) + trackError(exception) +} + +private fun AnalyticsService.trackSavePollFailed(cause: Throwable, mode: CreatePollMode) { + val exception = CreatePollException.SavePollFailed( + message = when (mode) { + CreatePollMode.NewPoll -> "Failed to create poll" + is CreatePollMode.EditPoll -> "Failed to edit poll" + }, + cause = cause, + ) + Timber.e(exception) + trackError(exception) +} + +fun PollFormState.toUiAnswers(): ImmutableList { + return answers.map { answer -> + Answer( + text = answer, + canDelete = canDeleteAnswer, + ) + }.toImmutableList() +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt new file mode 100644 index 0000000..1046f25 --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.create + +import io.element.android.libraries.matrix.api.poll.PollKind +import kotlinx.collections.immutable.ImmutableList + +data class CreatePollState( + val mode: Mode, + val canSave: Boolean, + val canAddAnswer: Boolean, + val question: String, + val answers: ImmutableList, + val pollKind: PollKind, + val showBackConfirmation: Boolean, + val showDeleteConfirmation: Boolean, + val eventSink: (CreatePollEvents) -> Unit, +) { + enum class Mode { + New, + Edit, + } + + val canDelete: Boolean = mode == Mode.Edit +} + +data class Answer( + val text: String, + val canDelete: Boolean, +) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollStateProvider.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollStateProvider.kt new file mode 100644 index 0000000..20d9f0d --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollStateProvider.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.create + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.poll.PollKind +import kotlinx.collections.immutable.toImmutableList + +class CreatePollStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aCreatePollState( + mode = CreatePollState.Mode.New, + canCreate = false, + canAddAnswer = true, + question = "", + answers = listOf( + Answer("", false), + Answer("", false) + ), + pollKind = PollKind.Disclosed, + showBackConfirmation = false, + showDeleteConfirmation = false, + ), + aCreatePollState( + mode = CreatePollState.Mode.New, + canCreate = true, + canAddAnswer = true, + question = "What type of food should we have?", + answers = listOf( + Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false), + Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false), + ), + showBackConfirmation = false, + showDeleteConfirmation = false, + pollKind = PollKind.Undisclosed, + ), + aCreatePollState( + mode = CreatePollState.Mode.New, + canCreate = true, + canAddAnswer = true, + question = "What type of food should we have?", + answers = listOf( + Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false), + Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false), + ), + showBackConfirmation = true, + showDeleteConfirmation = false, + pollKind = PollKind.Undisclosed, + ), + aCreatePollState( + mode = CreatePollState.Mode.New, + canCreate = true, + canAddAnswer = true, + question = "What type of food should we have?", + answers = listOf( + Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", true), + Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", true), + Answer("Brazilian \uD83C\uDDE7\uD83C\uDDF7", true), + Answer("French \uD83C\uDDEB\uD83C\uDDF7", true), + ), + showBackConfirmation = false, + showDeleteConfirmation = false, + pollKind = PollKind.Undisclosed, + ), + aCreatePollState( + mode = CreatePollState.Mode.New, + canCreate = true, + canAddAnswer = false, + question = "Should there be more than 20 answers?", + answers = listOf( + Answer("1", true), + Answer("2", true), + Answer("3", true), + Answer("4", true), + Answer("5", true), + Answer("6", true), + Answer("7", true), + Answer("8", true), + Answer("9", true), + Answer("10", true), + Answer("11", true), + Answer("12", true), + Answer("13", true), + Answer("14", true), + Answer("15", true), + Answer("16", true), + Answer("17", true), + Answer("18", true), + Answer("19", true), + Answer("20", true), + ), + showBackConfirmation = false, + showDeleteConfirmation = false, + pollKind = PollKind.Undisclosed, + ), + aCreatePollState( + mode = CreatePollState.Mode.New, + canCreate = true, + canAddAnswer = true, + question = "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 commodo consequat. Duis aute irure dolor" + + " in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt" + + " in culpa qui officia deserunt mollit anim id est laborum.", + answers = listOf( + Answer( + "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 commodo consequat. Duis a.", + false + ), + Answer( + "Laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore" + + " eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mol.", + false + ), + ), + showBackConfirmation = false, + showDeleteConfirmation = false, + pollKind = PollKind.Undisclosed, + ), + aCreatePollState( + mode = CreatePollState.Mode.Edit, + canCreate = false, + canAddAnswer = true, + question = "", + answers = listOf( + Answer("", false), + Answer("", false) + ), + pollKind = PollKind.Disclosed, + showDeleteConfirmation = false, + showBackConfirmation = false, + ), + aCreatePollState( + mode = CreatePollState.Mode.Edit, + canCreate = false, + canAddAnswer = true, + question = "", + answers = listOf( + Answer("", false), + Answer("", false) + ), + pollKind = PollKind.Disclosed, + showDeleteConfirmation = true, + showBackConfirmation = false, + ), + ) +} + +private fun aCreatePollState( + mode: CreatePollState.Mode, + canCreate: Boolean, + canAddAnswer: Boolean, + question: String, + answers: List, + showBackConfirmation: Boolean, + showDeleteConfirmation: Boolean, + pollKind: PollKind +): CreatePollState { + return CreatePollState( + mode = mode, + canSave = canCreate, + canAddAnswer = canAddAnswer, + question = question, + answers = answers.toImmutableList(), + showBackConfirmation = showBackConfirmation, + showDeleteConfirmation = showDeleteConfirmation, + pollKind = pollKind, + eventSink = {} + ) +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt new file mode 100644 index 0000000..53caf33 --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.create + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +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.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.poll.impl.R +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.SaveChangesDialog +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +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.TextField +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun CreatePollView( + state: CreatePollState, + modifier: Modifier = Modifier, +) { + val coroutineScope = rememberCoroutineScope() + + val navBack = { state.eventSink(CreatePollEvents.ConfirmNavBack) } + BackHandler(onBack = navBack) + if (state.showBackConfirmation) { + SaveChangesDialog( + onSubmitClick = { state.eventSink(CreatePollEvents.NavBack) }, + onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) } + ) + } + if (state.showDeleteConfirmation) { + ConfirmationDialog( + title = stringResource(id = R.string.screen_edit_poll_delete_confirmation_title), + content = stringResource(id = R.string.screen_edit_poll_delete_confirmation), + onSubmitClick = { state.eventSink(CreatePollEvents.Delete(confirmed = true)) }, + onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) } + ) + } + val questionFocusRequester = remember { FocusRequester() } + val answerFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + questionFocusRequester.requestFocus() + } + Scaffold( + modifier = modifier, + topBar = { + CreatePollTopAppBar( + mode = state.mode, + saveEnabled = state.canSave, + onBackClick = navBack, + onSaveClick = { state.eventSink(CreatePollEvents.Save) } + ) + }, + ) { paddingValues -> + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + .imePadding() + .fillMaxSize(), + state = lazyListState, + ) { + item { + Column { + ListItem( + headlineContent = { + TextField( + label = stringResource(id = R.string.screen_create_poll_question_desc), + value = state.question, + onValueChange = { + state.eventSink(CreatePollEvents.SetQuestion(it)) + }, + modifier = Modifier + .focusRequester(questionFocusRequester) + .fillMaxWidth(), + placeholder = stringResource(id = R.string.screen_create_poll_question_hint), + keyboardOptions = keyboardOptions, + ) + } + ) + } + } + itemsIndexed(state.answers) { index, answer -> + val isLastItem = index == state.answers.size - 1 + ListItem( + headlineContent = { + TextField( + value = answer.text, + onValueChange = { + state.eventSink(CreatePollEvents.SetAnswer(index, it)) + }, + modifier = Modifier + .then(if (isLastItem) Modifier.focusRequester(answerFocusRequester) else Modifier) + .fillMaxWidth(), + placeholder = stringResource(id = R.string.screen_create_poll_answer_hint, index + 1), + keyboardOptions = keyboardOptions, + ) + }, + trailingContent = ListItemContent.Custom { + Icon( + imageVector = CompoundIcons.Delete(), + contentDescription = stringResource(R.string.screen_create_poll_delete_option_a11y, answer.text), + modifier = Modifier.clickable(answer.canDelete) { + state.eventSink(CreatePollEvents.RemoveAnswer(index)) + }, + ) + }, + style = if (answer.canDelete) ListItemStyle.Destructive else ListItemStyle.Default, + ) + } + if (state.canAddAnswer) { + item { + ListItem( + headlineContent = { Text(text = stringResource(id = R.string.screen_create_poll_add_option_btn)) }, + leadingContent = ListItemContent.Icon( + iconSource = IconSource.Vector(CompoundIcons.Plus()), + ), + style = ListItemStyle.Primary, + onClick = { + state.eventSink(CreatePollEvents.AddAnswer) + coroutineScope.launch(Dispatchers.Main) { + lazyListState.animateScrollToItem(state.answers.size + 1) + answerFocusRequester.requestFocus() + } + }, + ) + } + } + item { + Column { + HorizontalDivider() + ListItem( + headlineContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_headline)) }, + supportingContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_desc)) }, + trailingContent = ListItemContent.Switch( + checked = state.pollKind == PollKind.Undisclosed, + ), + onClick = { + state.eventSink( + CreatePollEvents.SetPollKind( + if (state.pollKind == PollKind.Disclosed) PollKind.Undisclosed else PollKind.Disclosed + ) + ) + }, + ) + if (state.canDelete) { + ListItem( + headlineContent = { Text(text = stringResource(id = CommonStrings.action_delete_poll)) }, + style = ListItemStyle.Destructive, + onClick = { state.eventSink(CreatePollEvents.Delete(confirmed = false)) }, + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CreatePollTopAppBar( + mode: CreatePollState.Mode, + saveEnabled: Boolean, + onBackClick: () -> Unit = {}, + onSaveClick: () -> Unit = {}, +) { + TopAppBar( + titleStr = when (mode) { + CreatePollState.Mode.New -> stringResource(id = R.string.screen_create_poll_title) + CreatePollState.Mode.Edit -> stringResource(id = R.string.screen_edit_poll_title) + }, + navigationIcon = { + BackButton(onClick = onBackClick) + }, + actions = { + TextButton( + text = when (mode) { + CreatePollState.Mode.New -> stringResource(id = CommonStrings.action_create) + CreatePollState.Mode.Edit -> stringResource(id = CommonStrings.action_done) + }, + onClick = onSaveClick, + enabled = saveEnabled, + ) + } + ) +} + +@PreviewsDayNight +@Composable +internal fun CreatePollViewPreview( + @PreviewParameter(CreatePollStateProvider::class) state: CreatePollState +) = ElementPreview { + CreatePollView( + state = state, + ) +} + +private val keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + imeAction = ImeAction.Next, +) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/DefaultCreatePollEntryPoint.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/DefaultCreatePollEntryPoint.kt new file mode 100644 index 0000000..7566a5d --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/DefaultCreatePollEntryPoint.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.create + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.poll.api.create.CreatePollEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultCreatePollEntryPoint : CreatePollEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: CreatePollEntryPoint.Params, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf(CreatePollNode.Inputs(timelineMode = params.timelineMode, mode = params.mode)) + ) + } +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/PollFormState.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/PollFormState.kt new file mode 100644 index 0000000..46045d7 --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/PollFormState.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.create + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.mapSaver +import io.element.android.features.poll.impl.PollConstants +import io.element.android.features.poll.impl.PollConstants.MIN_ANSWERS +import io.element.android.libraries.matrix.api.poll.PollKind +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/** + * Represents the state of the poll creation / edit form. + * + * Save this state using [pollFormStateSaver]. + */ +data class PollFormState( + val question: String, + val answers: ImmutableList, + val isDisclosed: Boolean, +) { + companion object { + val Empty = PollFormState( + question = "", + answers = MutableList(MIN_ANSWERS) { "" }.toImmutableList(), + isDisclosed = true, + ) + } + + val pollKind + get() = when (isDisclosed) { + true -> PollKind.Disclosed + false -> PollKind.Undisclosed + } + + /** + * Create a copy of the [PollFormState] with a new blank answer added. + * + * If the maximum number of answers has already been reached an answer is not added. + */ + fun withNewAnswer(): PollFormState { + if (!canAddAnswer) { + return this + } + + return copy(answers = (answers + "").toImmutableList()) + } + + /** + * Create a copy of the [PollFormState] with the answer at [index] removed. + * + * If the answer doesn't exist or can't be removed, the state is unchanged. + * + * @param index the index of the answer to remove. + * + * @return a new [PollFormState] with the answer at [index] removed. + */ + fun withAnswerRemoved(index: Int): PollFormState { + if (!canDeleteAnswer) { + return this + } + + return copy(answers = answers.filterIndexed { i, _ -> i != index }.toImmutableList()) + } + + /** + * Create a copy of the [PollFormState] with the answer at [index] changed. + * + * If the new answer is longer than [PollConstants.MAX_ANSWER_LENGTH], it will be truncated. + * + * @param index the index of the answer to change. + * @param rawAnswer the new answer as the user typed it. + * + * @return a new [PollFormState] with the answer at [index] changed. + */ + fun withAnswerChanged(index: Int, rawAnswer: String): PollFormState = + copy(answers = answers.toMutableList().apply { + this[index] = rawAnswer.take(PollConstants.MAX_ANSWER_LENGTH) + }.toImmutableList()) + + /** + * Whether a new answer can be added. + */ + val canAddAnswer get() = answers.size < PollConstants.MAX_ANSWERS + + /** + * Whether any answer can be deleted. + */ + val canDeleteAnswer get() = answers.size > MIN_ANSWERS + + /** + * Whether the form is currently valid. + */ + val isValid get() = question.isNotBlank() && answers.size >= MIN_ANSWERS && answers.all { it.isNotBlank() } +} + +/** + * A [Saver] for [PollFormState]. + */ +internal val pollFormStateSaver = mapSaver( + save = { + mutableMapOf( + "question" to it.question, + "answers" to it.answers.toTypedArray(), + "isDisclosed" to it.isDisclosed, + ) + }, + restore = { saved -> + PollFormState( + question = saved["question"] as String, + answers = (saved["answers"] as Array<*>).map { it as String }.toImmutableList(), + isDisclosed = saved["isDisclosed"] as Boolean, + ) + } +) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/data/PollRepository.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/data/PollRepository.kt new file mode 100644 index 0000000..d3f1777 --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/data/PollRepository.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.data + +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.core.extensions.flatMap +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.matrix.api.room.CreateTimelineParams +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.TimelineProvider +import io.element.android.libraries.matrix.api.timeline.getActiveTimeline +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first + +@AssistedInject +class PollRepository( + private val room: JoinedRoom, + private val defaultTimelineProvider: TimelineProvider, + @Assisted private val timelineMode: Timeline.Mode, +) { + @AssistedFactory + fun interface Factory { + fun create( + timelineMode: Timeline.Mode, + ): PollRepository + } + + suspend fun getPoll(eventId: EventId): Result = runCatchingExceptions { + getTimelineProvider() + .getOrThrow() + .getActiveTimeline() + .timelineItems + .first() + .asSequence() + .filterIsInstance() + .first { it.eventId == eventId } + .event + .content as PollContent + } + + suspend fun savePoll( + existingPollId: EventId?, + question: String, + answers: List, + pollKind: PollKind, + maxSelections: Int, + ): Result = when (existingPollId) { + null -> getTimelineProvider().flatMap { timelineProvider -> + timelineProvider + .getActiveTimeline() + .createPoll( + question = question, + answers = answers, + maxSelections = maxSelections, + pollKind = pollKind, + ) + } + else -> getTimelineProvider().flatMap { timelineProvider -> + timelineProvider.getActiveTimeline() + .editPoll( + pollStartId = existingPollId, + question = question, + answers = answers, + maxSelections = maxSelections, + pollKind = pollKind, + ) + } + } + + suspend fun deletePoll( + pollStartId: EventId, + ): Result = + getTimelineProvider().flatMap { timelineProvider -> + timelineProvider.getActiveTimeline() + .redactEvent( + eventOrTransactionId = pollStartId.toEventOrTransactionId(), + reason = null, + ) + } + + private suspend fun getTimelineProvider(): Result { + return when (timelineMode) { + is Timeline.Mode.Thread -> { + val threadedTimelineResult = room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId)) + threadedTimelineResult.map { threadedTimeline -> + object : TimelineProvider { + private val flow = MutableStateFlow(threadedTimeline) + override fun activeTimelineFlow(): StateFlow = flow + } + } + } + else -> Result.success(defaultTimelineProvider) + } + } +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/DefaultPollHistoryEntryPoint.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/DefaultPollHistoryEntryPoint.kt new file mode 100644 index 0000000..920e2db --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/DefaultPollHistoryEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.history + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.poll.api.history.PollHistoryEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultPollHistoryEntryPoint : PollHistoryEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext): Node { + return parentNode.createNode(buildContext) + } +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvents.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvents.kt new file mode 100644 index 0000000..14ce13d --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvents.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.history + +import io.element.android.features.poll.impl.history.model.PollHistoryFilter +import io.element.android.libraries.matrix.api.core.EventId + +sealed interface PollHistoryEvents { + data object LoadMore : PollHistoryEvents + data class SelectPollAnswer(val pollStartId: EventId, val answerId: String) : PollHistoryEvents + data class EndPoll(val pollStartId: EventId) : PollHistoryEvents + data class SelectFilter(val filter: PollHistoryFilter) : PollHistoryEvents +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryFlowNode.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryFlowNode.kt new file mode 100644 index 0000000..a71edeb --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryFlowNode.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.history + +import android.os.Parcelable +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.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.push +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.poll.api.create.CreatePollEntryPoint +import io.element.android.features.poll.api.create.CreatePollMode +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +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.timeline.Timeline +import kotlinx.parcelize.Parcelize + +@ContributesNode(RoomScope::class) +@AssistedInject +class PollHistoryFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val createPollEntryPoint: CreatePollEntryPoint, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +) { + sealed interface NavTarget : Parcelable { + @Parcelize + data object Root : NavTarget + + @Parcelize + data class EditPoll(val pollStartEventId: EventId) : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.EditPoll -> { + createPollEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = CreatePollEntryPoint.Params( + timelineMode = Timeline.Mode.Live, + mode = CreatePollMode.EditPoll(eventId = navTarget.pollStartEventId) + ) + ) + } + NavTarget.Root -> { + val callback = object : PollHistoryNode.Callback { + override fun navigateToEditPoll(pollStartEventId: EventId) { + backstack.push(NavTarget.EditPoll(pollStartEventId)) + } + } + createNode( + buildContext = buildContext, + plugins = listOf(callback) + ) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView() + } +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryNode.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryNode.kt new file mode 100644 index 0000000..ba1018e --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryNode.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.history + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId + +@ContributesNode(RoomScope::class) +@AssistedInject +class PollHistoryNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: PollHistoryPresenter, +) : Node( + buildContext = buildContext, + plugins = plugins, +) { + interface Callback : Plugin { + fun navigateToEditPoll(pollStartEventId: EventId) + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + PollHistoryView( + state = presenter.present(), + modifier = modifier, + onEditPoll = callback::navigateToEditPoll, + goBack = this::navigateUp, + ) + } +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt new file mode 100644 index 0000000..434e2ed --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.history + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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 dev.zacsweers.metro.Inject +import io.element.android.features.poll.api.actions.EndPollAction +import io.element.android.features.poll.api.actions.SendPollResponseAction +import io.element.android.features.poll.impl.history.model.PollHistoryFilter +import io.element.android.features.poll.impl.history.model.PollHistoryItems +import io.element.android.features.poll.impl.history.model.PollHistoryItemsFactory +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +@Inject +class PollHistoryPresenter( + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, + private val sendPollResponseAction: SendPollResponseAction, + private val endPollAction: EndPollAction, + private val pollHistoryItemFactory: PollHistoryItemsFactory, + private val room: JoinedRoom, +) : Presenter { + @Composable + override fun present(): PollHistoryState { + val timeline = room.liveTimeline + val paginationState by timeline.backwardPaginationStatus.collectAsState() + val pollHistoryItemsFlow = remember { + timeline.timelineItems.map { items -> + pollHistoryItemFactory.create(items) + } + } + var activeFilter by rememberSaveable { + mutableStateOf(PollHistoryFilter.ONGOING) + } + val pollHistoryItems by pollHistoryItemsFlow.collectAsState(initial = PollHistoryItems()) + LaunchedEffect(paginationState, pollHistoryItems.size) { + if (pollHistoryItems.size == 0 && paginationState.canPaginate) loadMore(timeline) + } + val isLoading by remember { + derivedStateOf { + pollHistoryItems.size == 0 || paginationState.isPaginating + } + } + val coroutineScope = rememberCoroutineScope() + fun handleEvent(event: PollHistoryEvents) { + when (event) { + is PollHistoryEvents.LoadMore -> { + coroutineScope.loadMore(timeline) + } + is PollHistoryEvents.SelectPollAnswer -> sessionCoroutineScope.launch { + sendPollResponseAction.execute( + timeline = timeline, + pollStartId = event.pollStartId, + answerId = event.answerId + ) + } + is PollHistoryEvents.EndPoll -> sessionCoroutineScope.launch { + endPollAction.execute(timeline = timeline, pollStartId = event.pollStartId) + } + is PollHistoryEvents.SelectFilter -> { + activeFilter = event.filter + } + } + } + + return PollHistoryState( + isLoading = isLoading, + hasMoreToLoad = paginationState.hasMoreToLoad, + pollHistoryItems = pollHistoryItems, + activeFilter = activeFilter, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.loadMore(pollHistory: Timeline) = launch { + pollHistory.paginate(Timeline.PaginationDirection.BACKWARDS) + } +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryState.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryState.kt new file mode 100644 index 0000000..bddfd15 --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.history + +import io.element.android.features.poll.impl.history.model.PollHistoryFilter +import io.element.android.features.poll.impl.history.model.PollHistoryItem +import io.element.android.features.poll.impl.history.model.PollHistoryItems +import kotlinx.collections.immutable.ImmutableList + +data class PollHistoryState( + val isLoading: Boolean, + val hasMoreToLoad: Boolean, + val activeFilter: PollHistoryFilter, + val pollHistoryItems: PollHistoryItems, + val eventSink: (PollHistoryEvents) -> Unit, +) { + fun pollHistoryForFilter(filter: PollHistoryFilter): ImmutableList { + return when (filter) { + PollHistoryFilter.ONGOING -> pollHistoryItems.ongoing + PollHistoryFilter.PAST -> pollHistoryItems.past + } + } +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryStateProvider.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryStateProvider.kt new file mode 100644 index 0000000..a08550f --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryStateProvider.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.history + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.poll.api.pollcontent.PollContentState +import io.element.android.features.poll.api.pollcontent.aPollContentState +import io.element.android.features.poll.impl.history.model.PollHistoryFilter +import io.element.android.features.poll.impl.history.model.PollHistoryItem +import io.element.android.features.poll.impl.history.model.PollHistoryItems +import kotlinx.collections.immutable.toImmutableList + +class PollHistoryStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aPollHistoryState(), + aPollHistoryState( + isLoading = true, + hasMoreToLoad = true, + activeFilter = PollHistoryFilter.PAST, + ), + aPollHistoryState( + activeFilter = PollHistoryFilter.ONGOING, + currentItems = emptyList(), + ), + aPollHistoryState( + activeFilter = PollHistoryFilter.PAST, + currentItems = emptyList(), + ), + aPollHistoryState( + activeFilter = PollHistoryFilter.PAST, + currentItems = emptyList(), + hasMoreToLoad = true, + ), + ) +} + +internal fun aPollHistoryState( + isLoading: Boolean = false, + hasMoreToLoad: Boolean = false, + activeFilter: PollHistoryFilter = PollHistoryFilter.ONGOING, + currentItems: List = listOf( + aPollHistoryItem(), + ), + eventSink: (PollHistoryEvents) -> Unit = {}, +) = PollHistoryState( + isLoading = isLoading, + hasMoreToLoad = hasMoreToLoad, + activeFilter = activeFilter, + pollHistoryItems = PollHistoryItems( + ongoing = currentItems.toImmutableList(), + past = currentItems.toImmutableList(), + ), + eventSink = eventSink, +) + +internal fun aPollHistoryItem( + formattedDate: String = "01/12/2023", + state: PollContentState = aPollContentState(), +) = PollHistoryItem( + formattedDate = formattedDate, + state = state, +) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt new file mode 100644 index 0000000..9a654c2 --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.history + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +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.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.poll.api.pollcontent.PollContentView +import io.element.android.features.poll.impl.R +import io.element.android.features.poll.impl.history.model.PollHistoryFilter +import io.element.android.features.poll.impl.history.model.PollHistoryItem +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.SegmentedButton +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.components.TopAppBar +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PollHistoryView( + state: PollHistoryState, + onEditPoll: (EventId) -> Unit, + goBack: () -> Unit, + modifier: Modifier = Modifier, +) { + fun onLoadMore() { + state.eventSink(PollHistoryEvents.LoadMore) + } + + fun onSelectAnswer(pollStartId: EventId, answerId: String) { + state.eventSink(PollHistoryEvents.SelectPollAnswer(pollStartId, answerId)) + } + + fun onEndPoll(pollStartId: EventId) { + state.eventSink(PollHistoryEvents.EndPoll(pollStartId)) + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + titleStr = stringResource(R.string.screen_polls_history_title), + navigationIcon = { + BackButton(onClick = goBack) + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + ) { + val pagerState = rememberPagerState(state.activeFilter.ordinal, 0f) { + PollHistoryFilter.entries.size + } + LaunchedEffect(state.activeFilter) { + pagerState.scrollToPage(state.activeFilter.ordinal) + } + PollHistoryFilterButtons( + activeFilter = state.activeFilter, + onSelectFilter = { state.eventSink(PollHistoryEvents.SelectFilter(it)) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + modifier = Modifier.fillMaxSize() + ) { page -> + val filter = PollHistoryFilter.entries[page] + val pollHistoryItems = state.pollHistoryForFilter(filter) + PollHistoryList( + filter = filter, + pollHistoryItems = pollHistoryItems, + hasMoreToLoad = state.hasMoreToLoad, + isLoading = state.isLoading, + onSelectAnswer = ::onSelectAnswer, + onEditPoll = onEditPoll, + onEndPoll = ::onEndPoll, + onLoadMore = ::onLoadMore, + modifier = Modifier.fillMaxSize(), + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PollHistoryFilterButtons( + activeFilter: PollHistoryFilter, + onSelectFilter: (PollHistoryFilter) -> Unit, + modifier: Modifier = Modifier, +) { + SingleChoiceSegmentedButtonRow(modifier = modifier) { + PollHistoryFilter.entries.forEach { filter -> + SegmentedButton( + index = filter.ordinal, + count = PollHistoryFilter.entries.size, + selected = activeFilter == filter, + onClick = { onSelectFilter(filter) }, + text = stringResource(filter.stringResource), + ) + } + } +} + +@Composable +private fun PollHistoryList( + filter: PollHistoryFilter, + pollHistoryItems: ImmutableList, + hasMoreToLoad: Boolean, + isLoading: Boolean, + onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, + onEditPoll: (pollStartId: EventId) -> Unit, + onEndPoll: (pollStartId: EventId) -> Unit, + onLoadMore: () -> Unit, + modifier: Modifier = Modifier, +) { + val lazyListState = rememberLazyListState() + LazyColumn( + state = lazyListState, + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + items(pollHistoryItems) { pollHistoryItem -> + PollHistoryItemRow( + pollHistoryItem = pollHistoryItem, + onSelectAnswer = onSelectAnswer, + onEditPoll = onEditPoll, + onEndPoll = onEndPoll, + modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp) + ) + } + if (pollHistoryItems.isEmpty()) { + item { + Column( + modifier = Modifier + .fillParentMaxSize() + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val emptyStringResource = if (filter == PollHistoryFilter.PAST) { + stringResource(R.string.screen_polls_history_empty_past) + } else { + stringResource(R.string.screen_polls_history_empty_ongoing) + } + Text( + text = emptyStringResource, + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textSecondary, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp, horizontal = 16.dp), + textAlign = TextAlign.Center, + ) + + if (hasMoreToLoad) { + LoadMoreButton(isLoading = isLoading, onClick = onLoadMore) + } + } + } + } else if (hasMoreToLoad) { + item { + LoadMoreButton(isLoading = isLoading, onClick = onLoadMore) + } + } + } +} + +@Composable +private fun LoadMoreButton(isLoading: Boolean, onClick: () -> Unit) { + Button( + text = stringResource(CommonStrings.action_load_more), + showProgress = isLoading, + onClick = onClick, + modifier = Modifier.padding(vertical = 24.dp), + ) +} + +@Composable +private fun PollHistoryItemRow( + pollHistoryItem: PollHistoryItem, + onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, + onEditPoll: (pollStartId: EventId) -> Unit, + onEndPoll: (pollStartId: EventId) -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.semantics(mergeDescendants = true) { + // Allow the answers to be traversed by Talkback + isTraversalGroup = true + }, + border = BorderStroke(1.dp, ElementTheme.colors.borderInteractiveSecondary), + shape = RoundedCornerShape(size = 12.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = pollHistoryItem.formattedDate, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmRegular, + ) + Spacer(modifier = Modifier.height(4.dp)) + PollContentView( + state = pollHistoryItem.state, + onSelectAnswer = onSelectAnswer, + onEditPoll = onEditPoll, + onEndPoll = onEndPoll, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun PollHistoryViewPreview( + @PreviewParameter(PollHistoryStateProvider::class) state: PollHistoryState +) = ElementPreview { + PollHistoryView( + state = state, + onEditPoll = {}, + goBack = {}, + ) +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/model/PollHistoryFilter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/model/PollHistoryFilter.kt new file mode 100644 index 0000000..51449a7 --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/model/PollHistoryFilter.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.history.model + +import io.element.android.features.poll.impl.R + +enum class PollHistoryFilter(val stringResource: Int) { + ONGOING(R.string.screen_polls_history_filter_ongoing), + PAST(R.string.screen_polls_history_filter_past), +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/model/PollHistoryItem.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/model/PollHistoryItem.kt new file mode 100644 index 0000000..b688000 --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/model/PollHistoryItem.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.history.model + +import io.element.android.features.poll.api.pollcontent.PollContentState + +data class PollHistoryItem( + val formattedDate: String, + val state: PollContentState, +) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/model/PollHistoryItems.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/model/PollHistoryItems.kt new file mode 100644 index 0000000..c1df275 --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/model/PollHistoryItems.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.history.model + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class PollHistoryItems( + val ongoing: ImmutableList = persistentListOf(), + val past: ImmutableList = persistentListOf(), +) { + val size = ongoing.size + past.size +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/model/PollHistoryItemsFactory.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/model/PollHistoryItemsFactory.kt new file mode 100644 index 0000000..5edd99c --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/model/PollHistoryItemsFactory.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.history.model + +import dev.zacsweers.metro.Inject +import io.element.android.features.poll.api.pollcontent.PollContentStateFactory +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.withContext + +@Inject +class PollHistoryItemsFactory( + private val pollContentStateFactory: PollContentStateFactory, + private val dateFormatter: DateFormatter, + private val dispatchers: CoroutineDispatchers, +) { + suspend fun create(timelineItems: List): PollHistoryItems = withContext(dispatchers.computation) { + val past = ArrayList() + val ongoing = ArrayList() + for (index in timelineItems.indices.reversed()) { + val timelineItem = timelineItems[index] + val pollHistoryItem = create(timelineItem) ?: continue + if (pollHistoryItem.state.isPollEnded) { + past.add(pollHistoryItem) + } else { + ongoing.add(pollHistoryItem) + } + } + PollHistoryItems( + ongoing = ongoing.toImmutableList(), + past = past.toImmutableList() + ) + } + + private suspend fun create(timelineItem: MatrixTimelineItem): PollHistoryItem? { + return when (timelineItem) { + is MatrixTimelineItem.Event -> { + val pollContent = timelineItem.event.content as? PollContent ?: return null + val pollContentState = pollContentStateFactory.create( + eventId = timelineItem.eventId, + isEditable = timelineItem.event.isEditable, + isOwn = timelineItem.event.isOwn, + content = pollContent, + ) + PollHistoryItem( + formattedDate = dateFormatter.format( + timestamp = timelineItem.event.timestamp, + mode = DateFormatterMode.Day, + useRelative = true + ), + state = pollContentState + ) + } + else -> null + } + } +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/model/DefaultPollContentStateFactory.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/model/DefaultPollContentStateFactory.kt new file mode 100644 index 0000000..f781b94 --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/model/DefaultPollContentStateFactory.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.model + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.poll.api.pollcontent.PollAnswerItem +import io.element.android.features.poll.api.pollcontent.PollContentState +import io.element.android.features.poll.api.pollcontent.PollContentStateFactory +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.poll.isDisclosed +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +import kotlinx.collections.immutable.toImmutableList + +@ContributesBinding(RoomScope::class) +class DefaultPollContentStateFactory( + private val matrixClient: MatrixClient, +) : PollContentStateFactory { + override suspend fun create( + eventId: EventId?, + isEditable: Boolean, + isOwn: Boolean, + content: PollContent, + ): PollContentState { + val totalVoteCount = content.votes.flatMap { it.value }.size + val myVotes = content.votes.filter { matrixClient.sessionId in it.value }.keys + val isPollEnded = content.endTime != null + val winnerIds = if (!isPollEnded) { + emptyList() + } else { + content.answers + .map { answer -> answer.id } + .groupBy { answerId -> content.votes[answerId]?.size ?: 0 } // Group by votes count + .maxByOrNull { (votes, _) -> votes } // Keep max voted answers + ?.takeIf { (votes, _) -> votes > 0 } // Ignore if no option has been voted + ?.value + .orEmpty() + } + val answerItems = content.answers.map { answer -> + val answerVoteCount = content.votes[answer.id]?.size ?: 0 + val isSelected = answer.id in myVotes + val isWinner = answer.id in winnerIds + val percentage = if (totalVoteCount > 0) answerVoteCount.toFloat() / totalVoteCount.toFloat() else 0f + PollAnswerItem( + answer = answer, + isSelected = isSelected, + isEnabled = !isPollEnded, + isWinner = isWinner, + showVotes = content.kind.isDisclosed || isPollEnded, + votesCount = answerVoteCount, + percentage = percentage, + ) + } + + return PollContentState( + eventId = eventId, + question = content.question, + answerItems = answerItems.toImmutableList(), + pollKind = content.kind, + isPollEditable = isEditable, + isPollEnded = isPollEnded, + isMine = isOwn, + ) + } +} diff --git a/features/poll/impl/src/main/res/values-be/translations.xml b/features/poll/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..1d0238b --- /dev/null +++ b/features/poll/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,19 @@ + + + "Дадаць варыянт" + "Паказаць вынікі толькі пасля заканчэння апытання" + "Схаваць галасы" + "Варыянт %1$d" + "Вашы змены не былі захаваны. Вы ўпэўнены, што хочаце вярнуцца?" + "Пытанне або тэма" + "Пра што апытанне?" + "Стварэнне апытання" + "Вы ўпэўнены, што хочаце выдаліць гэтае апытанне?" + "Выдаліць апытанне" + "Рэдагаваць апытанне" + "Немагчыма знайсці бягучыя апытанні." + "Немагчыма знайсці мінулыя апытанні." + "Бягучыя" + "Мінулыя" + "Апытанні" + diff --git a/features/poll/impl/src/main/res/values-bg/translations.xml b/features/poll/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..d582841 --- /dev/null +++ b/features/poll/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,18 @@ + + + "Добавяне на опция" + "Показване на резултатите само след приключване на анкетата" + "Скриване на гласовете" + "Опция %1$d" + "Въпрос или тема" + "За какво се отнася анкетата?" + "Създаване на анкета" + "Сигурни ли сте, че искате да изтриете тази анкета?" + "Изтриване на анкетата" + "Редактиране на анкетата" + "Не се намират текущи анкети." + "Не се намират приключили анкети." + "Текущи" + "Приключили" + "Анкети" + diff --git a/features/poll/impl/src/main/res/values-cs/translations.xml b/features/poll/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..b5c7f32 --- /dev/null +++ b/features/poll/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,20 @@ + + + "Přidat volbu" + "Zobrazit výsledky až po skončení hlasování" + "Anonymní hlasování" + "Volba %1$d" + "Vaše změny nebyly uloženy. Opravdu se chcete vrátit?" + "Smazat možnost %1$s" + "Otázka nebo téma" + "Čeho se hlasování týká?" + "Vytvořit hlasování" + "Opravdu chcete odstranit toto hlasování?" + "Odstranit hlasování" + "Upravit hlasování" + "Nelze najít žádná probíhající hlasování." + "Nelze najít žádná minulá hlasování." + "Probíhající" + "Minulé" + "Hlasování" + diff --git a/features/poll/impl/src/main/res/values-cy/translations.xml b/features/poll/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..d325c13 --- /dev/null +++ b/features/poll/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,20 @@ + + + "Ychwanegu dewis" + "Dangos canlyniadau dim ond ar ôl i\'r pleidleisio ddod i ben" + "Cuddio pleidleisiau" + "Dewis %1$d" + "Dyw eich newidiadau heb gael eu cadw. Ydych chi\'n siŵr eich bod am fynd nôl?" + "Dileu opsiwn %1$s" + "Cwestiwn neu bwnc" + "Am beth mae\'r bleidlais?" + "Creu Pleidlais" + "Ydych chi\'n siŵr eich bod am ddileu\'r bleidlais hon?" + "Dileu Pleidlais" + "Golygu pleidlais" + "Methu dod o hyd i unrhyw bleidleisiau cyfredol." + "Methu dod o hyd i unrhyw bleidleisiau blaenorol." + "Parhaus" + "Gorffennol" + "Pleidleisiau" + diff --git a/features/poll/impl/src/main/res/values-da/translations.xml b/features/poll/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..84ab5de --- /dev/null +++ b/features/poll/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,20 @@ + + + "Tilføj mulighed" + "Vis først resultater, når afstemningen er slut" + "Skjul stemmer" + "Mulighed %1$d" + "Dine ændringer er ikke blevet gemt. Er du sikker på, at du vil gå tilbage?" + "Slet mulighed %1$s" + "Spørgsmål eller emne" + "Hvad handler afstemningen om?" + "Opret afstemning" + "Er du sikker på, at du ønsker at slette denne afstemning?" + "Slet afstemning" + "Redigér afstemning" + "Kan ikke finde nogen igangværende afstemninger." + "Kan ikke finde nogen tidligere afstemninger." + "Igangværende" + "Tidligere" + "Afstemninger" + diff --git a/features/poll/impl/src/main/res/values-de/translations.xml b/features/poll/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..dc19f4d --- /dev/null +++ b/features/poll/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,20 @@ + + + "Option hinzufügen" + "Ergebnisse erst nach Ende der Umfrage anzeigen" + "Anonyme Umfrage" + "Option %1$d" + "Deine Änderungen wurden nicht gespeichert. Bist du sicher, dass du zurückgehen willst?" + "Lösche Option %1$s" + "Frage oder Thema" + "Worum geht es bei der Umfrage?" + "Umfrage erstellen" + "Möchtest du diese Umfrage wirklich löschen?" + "Umfrage löschen" + "Umfrage bearbeiten" + "Keine laufenden Umfragen vorhanden." + "Keine beendeten Umfragen vorhanden." + "Laufend" + "Beendet" + "Umfragen" + diff --git a/features/poll/impl/src/main/res/values-el/translations.xml b/features/poll/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..108c9c6 --- /dev/null +++ b/features/poll/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,20 @@ + + + "Προσθήκη επιλογής" + "Εμφάνιση αποτελεσμάτων μόνο μετά τη λήξη της ψηφοφορίας" + "Απόκρυψη ψήφων" + "Επιλογή %1$d" + "Οι αλλαγές σου δεν έχουν αποθηκευτεί. Σίγουρα θες να πας πίσω;" + "Διαγραφή επιλογής %1$s" + "Ερώτηση ή θέμα" + "Τί αφορά η δημοσκόπηση;" + "Δημιουργία Δημοσκόπησης" + "Θες σίγουρα να διαγράψεις αυτήν τη δημοσκόπηση;" + "Διαγραφή Δημοσκόπησης" + "Επεξεργασία δημοσκόπησης" + "Δεν είναι δυνατή η εύρεση ενεργών δημοσκοπήσεων" + "Δεν είναι δυνατή η εύρεση παλιών δημοσκοπήσεων" + "Σε εξέλιξη" + "Παρελθόν" + "Δημοσκοπήσεις" + diff --git a/features/poll/impl/src/main/res/values-es/translations.xml b/features/poll/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..adc90e5 --- /dev/null +++ b/features/poll/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,19 @@ + + + "Añadir opción" + "Mostrar los resultados solo después de que finalice la encuesta" + "Ocultar votos" + "Opción %1$d" + "Tus cambios no se han guardado. ¿Estás seguro de que quieres volver atrás?" + "Pregunta o tema" + "¿De qué trata la encuesta?" + "Crear una Encuesta" + "¿Seguro que quieres eliminar esta encuesta?" + "Eliminar encuesta" + "Editar encuesta" + "No se pudo encontrar ninguna encuesta en curso." + "No se pudo encontrar ninguna encuesta anterior." + "En curso" + "Anteriores" + "Encuestas" + diff --git a/features/poll/impl/src/main/res/values-et/translations.xml b/features/poll/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..8312856 --- /dev/null +++ b/features/poll/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,20 @@ + + + "Lisa veel üks valik" + "Näita tulemusi alles pärast küsitluse lõppu" + "Peida hääled" + "Valik %1$d" + "Sinu tehtud muudatused pole veel salvestatud. Kas sa oled kindel, et soovid minna tagasi?" + "Kustuta valik: %1$s" + "Küsimus või teema" + "Mis on küsitluse teema?" + "Loo küsitlus" + "Kas sa oled kindel, et soovid selle küsitluse kustutada?" + "Kustuta küsitlus" + "Muuda küsitlust" + "Ei leia ühtegi käimasolevat küsitlust." + "Ei leia ühtegi varasemat küsitlust." + "Käimasolev küsitlus" + "Varasem küsitlus" + "Küsitlused" + diff --git a/features/poll/impl/src/main/res/values-eu/translations.xml b/features/poll/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..768854d --- /dev/null +++ b/features/poll/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,19 @@ + + + "Gehitu aukera" + "Erakutsi emaitzak inkesta amaitutakoan soilik" + "Ezkutatu botoak" + "%1$d. aukera" + "Zure aldaketak ez dira gorde. Ziur itzuli nahi duzula?" + "Galdera edo gaia" + "Zeri buruzko inkesta da?" + "Sortu inkesta" + "Ziur inkesta hau ezabatu nahi duzula?" + "Ezabatu inkesta" + "Editatu inkesta" + "Ezin da abian dagoen inkestarik aurkitu." + "Ezin da iraungitako inkestarik aurkitu." + "Abian direnak" + "Iraungitakoak" + "Inkestak" + diff --git a/features/poll/impl/src/main/res/values-fa/translations.xml b/features/poll/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..eeedc6c --- /dev/null +++ b/features/poll/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,19 @@ + + + "افزودن گزینه" + "نمایش نتیجه‌ها تنها پس از پایان نظرسنجی" + "نهفتن رأی‌ها" + "گزینهٔ %1$d" + "تغییراتتان ذخیره نشده‌اند. مطمئنید که می‌خواهید برگردید؟" + "پرسش یا موضوع" + "این نظرسنجی دربارهٔ چیست؟" + "ایجاد نظرسنجی" + "مطمئنید که می‌خواهید این نظرسنجی را حذف کنید؟" + "حذف نظرسنجی" + "ویرایش نظرسنجی" + "نتوانست هیچ نظرسنجی در جریانی بیابد." + "نتوانست هیچ نظرسنجی گذشته‌ای بیابد." + "در جریان" + "گذشته" + "نظرسنجی‌ها" + diff --git a/features/poll/impl/src/main/res/values-fi/translations.xml b/features/poll/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..02071bb --- /dev/null +++ b/features/poll/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,20 @@ + + + "Lisää vaihtoehto" + "Näytä tulokset vasta kyselyn päätyttyä" + "Piilota äänet" + "Vaihtoehto %1$d" + "Muutoksiasi ei ole tallennettu. Haluatko varmasti palata takaisin?" + "Poista vaihtoehto %1$s" + "Kysymys tai aihe" + "Mistä kyselyssä on kyse?" + "Luo kysely" + "Haluatko varmasti poistaa tämän kyselyn?" + "Poista kysely" + "Muokkaa kyselyä" + "Meneillään olevia kyselyjä ei löytynyt." + "Aiempia kyselyjä ei löytynyt." + "Meneillään olevat" + "Aiemmat" + "Kyselyt" + diff --git a/features/poll/impl/src/main/res/values-fr/translations.xml b/features/poll/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..badf388 --- /dev/null +++ b/features/poll/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,20 @@ + + + "Ajouter une option" + "Afficher les résultats uniquement après la fin du sondage" + "Masquer les votes" + "Option %1$d" + "Vos modifications n’ont pas été enregistrées. Êtes-vous certain de vouloir quitter ?" + "Supprimer l’option %1$s" + "Question ou sujet" + "Quel est le sujet du sondage ?" + "Créer un sondage" + "Êtes-vous certain de vouloir supprimer ce sondage ?" + "Supprimer le sondage" + "Modifier le sondage" + "Impossible de trouver des sondages en cours." + "Impossible de trouver des sondages terminés." + "En cours" + "Terminés" + "Sondages" + diff --git a/features/poll/impl/src/main/res/values-hu/translations.xml b/features/poll/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..ed6a793 --- /dev/null +++ b/features/poll/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,20 @@ + + + "Lehetőség hozzáadása" + "Eredmények megjelenítése csak a szavazás befejezése után" + "Szavazatok elrejtése" + "%1$d. lehetőség" + "A módosítások nem lettek mentve. Biztos, hogy visszalép?" + "Lehetőség törlése: %1$s" + "Kérdés vagy téma" + "Miről szól ez a szavazás?" + "Szavazás létrehozása" + "Biztos, hogy törli ezt a szavazást?" + "Szavazás törlése" + "Szavazás szerkesztése" + "Nem találhatók folyamatban lévő szavazások." + "Nem található korábbi szavazás." + "Folyamatban" + "Korábbi" + "Szavazások" + diff --git a/features/poll/impl/src/main/res/values-in/translations.xml b/features/poll/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..99250c4 --- /dev/null +++ b/features/poll/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,20 @@ + + + "Tambahkan opsi" + "Tampilkan hasil hanya setelah pemungutan suara berakhir" + "Pemungutan suara anonim" + "Opsi %1$d" + "Perubahan Anda belum disimpan. Apakah Anda yakin ingin kembali?" + "Hapus opsi %1$s" + "Pertanyaan atau topik" + "Tentang apa pemungutan suara ini?" + "Buat pemungutan suara" + "Apakah Anda yakin ingin menghapus pemungutan suara ini?" + "Hapus pemungutan suara" + "Sunting pemungutan suara" + "Tidak dapat menemukan pemungutan suara yang sedang berlangsung." + "Tidak dapat menemukan pemungutan suara sebelumnya." + "Sedang berlangsung" + "Masa lalu" + "Pemungutan suara" + diff --git a/features/poll/impl/src/main/res/values-it/translations.xml b/features/poll/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..3ffbf03 --- /dev/null +++ b/features/poll/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,20 @@ + + + "Aggiungi opzione" + "Mostra i risultati solo al termine del sondaggio" + "Nascondi voti" + "Opzione %1$d" + "Le modifiche non sono state salvate. Vuoi davvero tornare indietro?" + "Elimina l\'opzione %1$s" + "Domanda o argomento" + "Di cosa parla il sondaggio?" + "Crea sondaggio" + "Vuoi davvero eliminare questo sondaggio?" + "Elimina sondaggio" + "Modifica sondaggio" + "Impossibile trovare sondaggi in corso." + "Impossibile trovare sondaggi passati." + "In corso" + "Passato" + "Sondaggi" + diff --git a/features/poll/impl/src/main/res/values-ka/translations.xml b/features/poll/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..fe70a88 --- /dev/null +++ b/features/poll/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,19 @@ + + + "ვარიანტის დამატება" + "შედეგების ჩვენება მხოლოდ გამოკითხვის დასრულების შემდეგ" + "ხმების დამალვა" + "ვარიანტი %1$d" + "თქვენი ცვლილებები არაა შენახული. დარწმუნებული ხართ დაბრუნებაში?" + "კითხვა ან თემა" + "რას ეხება გამოკითხვა?" + "გამოკითხვის შექმნა" + "დარწმუნებული ხართ, რომ გსურთ ამ გამოკითხვის წაშლა?" + "გამოკითხვის წაშლა" + "გამოკითხვის რედაქტირება" + "მიმდინარე გამოკითხვები ვერ მოიძებნა." + "ბოლო გამოკითხვების მოძებნა ვერ მოხერხდა." + "მიმდინარე" + "წარსული" + "გამოკითხვები" + diff --git a/features/poll/impl/src/main/res/values-ko/translations.xml b/features/poll/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..3c85a48 --- /dev/null +++ b/features/poll/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,20 @@ + + + "옵션 추가" + "투표가 끝난 이후에만 결과 표시" + "투표 숨기기" + "옵션 %1$d" + "변경 내용이 저장되지 않았습니다. 정말로 돌아가시겠습니까?" + "삭제 옵션 %1$s" + "질문 또는 주제" + "무슨 투표인가요?" + "투표 생성" + "정말 이 투표를 삭제하시겠습니까?" + "투표 삭제" + "투표 수정" + "진행 중인 투표를 찾을 수 없습니다." + "과거의 투표를 찾을 수 없습니다." + "진행 중" + "과거" + "투표" + diff --git a/features/poll/impl/src/main/res/values-nb/translations.xml b/features/poll/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..7396cf9 --- /dev/null +++ b/features/poll/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,20 @@ + + + "Legg til alternativ" + "Vis resultater bare etter at avstemningen er avsluttet" + "Skjul stemmer" + "Alternativ %1$d" + "Endringene dine er ikke lagret. Er du sikker på at du vil gå tilbake?" + "Slett alternativet %1$s" + "Spørsmål eller emne" + "Hva handler avstemningen om?" + "Opprett avstemning" + "Er du sikker på at du vil slette denne avstemningen?" + "Slett avstemning" + "Rediger avstemning" + "Finner ingen pågående avstemninger." + "Kan ikke finne noen tidligere avstemninger." + "Pågående" + "Fortid" + "Avstemninger" + diff --git a/features/poll/impl/src/main/res/values-nl/translations.xml b/features/poll/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..e6c99c1 --- /dev/null +++ b/features/poll/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,19 @@ + + + "Optie toevoegen" + "Resultaten pas weergeven nadat de peiling is afgelopen" + "Stemmen verbergen" + "Optie %1$d" + "Je wijzigingen zijn niet opgeslagen. Weet je zeker dat je terug wilt gaan?" + "Vraag of onderwerp" + "Waar gaat de peiling over?" + "Peiling maken" + "Weet je zeker dat je deze peiling wilt verwijderen?" + "Peiling verwijderen" + "Peiling wijzigen" + "Kan geen actieve peilingen vinden." + "Kan geen eerdere peilingen vinden." + "Actief" + "Afgelopen" + "Peilingen" + diff --git a/features/poll/impl/src/main/res/values-pl/translations.xml b/features/poll/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..fef519c --- /dev/null +++ b/features/poll/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,20 @@ + + + "Dodaj opcję" + "Pokaż wyniki dopiero po zakończeniu ankiety" + "Ukryj głosy" + "Opcja %1$d" + "Zmiany nie zostały zapisane. Czy na pewno chcesz wrócić?" + "Usuń opcję %1$s" + "Pytanie lub temat" + "Czego dotyczy ankieta?" + "Utwórz ankietę" + "Czy na pewno chcesz usunąć tę ankietę?" + "Usuń ankietę" + "Edytuj ankietę" + "Nie znaleziono ankiet w trakcie." + "Nie znaleziono ankiet." + "W trakcie" + "Przeszłe" + "Ankiety" + diff --git a/features/poll/impl/src/main/res/values-pt-rBR/translations.xml b/features/poll/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..afbae89 --- /dev/null +++ b/features/poll/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,20 @@ + + + "Adicionar opção" + "Mostrar resultados somente após o término da enquete" + "Ocultar votos" + "%1$dª opção" + "Suas alterações não foram salvas. Tem certeza de que você quer voltar?" + "Apagar opção %1$s" + "Pergunta ou tópico" + "Sobre o que é a enquete?" + "Criar enquete" + "Tem certeza de que quer apagar esta enquete?" + "Excluir enquete" + "Editar enquete" + "Não foi possível encontrar nenhuma enquete em andamento." + "Não foi possível encontrar nenhuma enquete anterior." + "Em andamento" + "Anteriores" + "Enquetes" + diff --git a/features/poll/impl/src/main/res/values-pt/translations.xml b/features/poll/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..26bacb9 --- /dev/null +++ b/features/poll/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,20 @@ + + + "Adicionar opção" + "Mostrar resultados só após o da sondagem" + "Ocultar votos" + "Opção %1$d" + "As tuas alterações não foram guardadas. Tens a certeza que queres voltar atrás?" + "Eliminar opção %1$s" + "Pergunta ou tópico" + "De que trata a sondagem?" + "Criar sondagem" + "Tens a certeza que queres apagar esta sondagem?" + "Eliminar sondagem" + "Editar sondagem" + "Não foi possível encontrar nenhuma sondagem em curso." + "Não foi possível encontrar nenhuma sondagem anterior." + "Em curso" + "Passado" + "Sondagens" + diff --git a/features/poll/impl/src/main/res/values-ro/translations.xml b/features/poll/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..65cf16e --- /dev/null +++ b/features/poll/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,20 @@ + + + "Adăugați o opțiune" + "Afișați rezultatele numai după încheierea sondajului" + "Sondaj anonim" + "Opțiune %1$d" + "Modificările dumneavoastră nu au fost salvate. Sunteți sigur că doriți să vă întoarceți?" + "Ștergeți opțiunea %1$s" + "Întrebare sau subiect" + "Despre ce este sondajul?" + "Creați un sondaj" + "Sunteți sigur că doriți să ștergeți acest sondaj?" + "Ștergeți sondajul" + "Editați sondajul" + "Nu s-au putut găsi sondaje în curs de desfășurare." + "Nu s-au putut găsi sondaje anterioare." + "În desfășurare" + "Trecut" + "Sondaje" + diff --git a/features/poll/impl/src/main/res/values-ru/translations.xml b/features/poll/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..ac4be39 --- /dev/null +++ b/features/poll/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,20 @@ + + + "Добавить вариант" + "Показывать результаты только после окончания опроса" + "Скрыть голоса" + "Вариант %1$d" + "Изменения не сохранены. Вы действительно хотите вернуться?" + "Удалить опцию %1$s" + "Вопрос или тема" + "О чём будет опрос?" + "Создать опрос" + "Вы уверены, что хотите удалить этот опрос?" + "Удалить опрос" + "Редактировать опрос" + "Не найдено текущих опросов." + "Не найдено прошлых опросов." + "Текущие" + "Прошлые" + "Опросы" + diff --git a/features/poll/impl/src/main/res/values-sk/translations.xml b/features/poll/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..a3c5129 --- /dev/null +++ b/features/poll/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,20 @@ + + + "Pridať možnosť" + "Zobraziť výsledky až po skončení ankety" + "Anonymná anketa" + "Možnosť %1$d" + "Vaše zmeny neboli uložené. Naozaj sa chcete vrátiť?" + "Odstrániť možnosť %1$s" + "Otázka alebo téma" + "O čom je anketa?" + "Vytvoriť anketu" + "Ste si istý, že chcete odstrániť túto anketu?" + "Odstrániť anketu" + "Upraviť anketu" + "Nepodarilo sa nájsť žiadne prebiehajúce ankety." + "Nie je možné nájsť žiadne predchádzajúce ankety." + "Prebiehajúce" + "Minulé" + "Ankety" + diff --git a/features/poll/impl/src/main/res/values-sv/translations.xml b/features/poll/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..423b17d --- /dev/null +++ b/features/poll/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,20 @@ + + + "Lägg till alternativ" + "Visa resultat först efter att omröstningen avslutats" + "Dölj röster" + "Alternativ %1$d" + "Dina ändringar har inte sparats. Är du säker på att du vill gå tillbaka?" + "Radera alternativet %1$s" + "Fråga eller ämne" + "Vad handlar omröstningen om?" + "Skapa omröstning" + "Är du säker på att du vill radera den här omröstningen?" + "Radera omröstning" + "Redigera omröstning" + "Kan inte hitta några pågående omröstningar." + "Kan inte hitta några tidigare omröstningar." + "Pågående" + "Tidigare" + "Omröstningar" + diff --git a/features/poll/impl/src/main/res/values-tr/translations.xml b/features/poll/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..eecf3ad --- /dev/null +++ b/features/poll/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,19 @@ + + + "Seçenek ekle" + "Sonuçları yalnızca anket bittikten sonra göster" + "Oyları gizle" + "Seçenek %1$d" + "Değişiklikleriniz kaydedilmedi. Geri dönmek istediğinden emin misin?" + "Soru veya konu" + "Anket ne hakkında?" + "Anket Oluştur" + "Bu anketi silmek istediğinize emin misiniz?" + "Anketi Sil" + "Anketi düzenle" + "Devam eden bir anket bulamadım." + "Geçmiş herhangi bir anket bulamıyorum." + "Devam ediyor" + "Geçmiş" + "Anketler" + diff --git a/features/poll/impl/src/main/res/values-uk/translations.xml b/features/poll/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..1e62e17 --- /dev/null +++ b/features/poll/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,20 @@ + + + "Додати варіант" + "Показувати результати тільки після закінчення опитування" + "Приховати голоси" + "Варіант %1$d" + "Внесені зміни не збережено. Ви впевнені, що хочете повернутися?" + "Видалити варіант %1$s" + "Питання або тема" + "Про що йдеться в опитуванні?" + "Створити опитування" + "Ви впевнені, що хочете видалити це опитування?" + "Видалити опитування" + "Редагувати опитування" + "Не вдалося знайти жодних поточних опитувань." + "Не вдалося знайти жодних минулих опитувань." + "Поточні" + "Минулі" + "Опитування" + diff --git a/features/poll/impl/src/main/res/values-ur/translations.xml b/features/poll/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..8bb0468 --- /dev/null +++ b/features/poll/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,19 @@ + + + "اختیار شامل کریں" + "رائے شماری کے بعد ہی نتائج ظاہر کریں" + "آراء چھپائیں" + "اختیار %1$d" + "آپ کی تبدیلیاں محفوظ نہیں کی گئیں۔ کیا آپ کو یقین ہے کہ آپ واپس جانا چاہتے ہیں؟" + "سوال یا موضوع" + "رائے شماری کس بارے میں ہے؟" + "رائے شماری بنائیں" + "کیا آپ کو یقین ہے کہ آپ اس رائے شماری کو حذف کرنا چاہتے ہیں؟" + "رائے شماری حذف کریں" + "رائے شماری میں ترمیم کریں" + "کوئی جاری رائے شماری ہا نہیں مل سکے۔" + "ماضی کے کوئی رائے شماری ہا نہیں مل سکے۔" + "جاری" + "ماضی" + "رائے شماری ہا" + diff --git a/features/poll/impl/src/main/res/values-uz/translations.xml b/features/poll/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..28f496f --- /dev/null +++ b/features/poll/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,20 @@ + + + "Variant qo\'shish" + "Natijalarni faqat soʻrov tugagandan keyin koʻrsatish" + "Ovozlarni yashirish" + "Variant%1$d" + "Oʻzgarishlar saqlanmadi. Haqiqatan ham orqaga qaytmoqchimisiz?" + "%1$s variantini o‘chirish" + "Savol yoki mavzu" + "So\'rovnoma nima haqida?" + "So‘rovnoma yaratish" + "Siz rostdan ham bu soʻrovnomani oʻchirib tashlamoqchimisiz?" + "So‘rovnomani o‘chirish" + "So‘rovnomani tahrirlash" + "Davom etayotgan soʻrovlar topilmadi." + "Avvalgi soʻrovnomalar topilmadi." + "Jarayonda" + "Oʻtgan" + "Soʻrovnomalar" + diff --git a/features/poll/impl/src/main/res/values-zh-rTW/translations.xml b/features/poll/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..5aa1693 --- /dev/null +++ b/features/poll/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,20 @@ + + + "新增選項" + "只在投票結束後顯示結果" + "隱藏票數" + "選項 %1$d" + "變更尚未儲存,您確定要返回嗎?" + "刪除選項 %1$s" + "問題或主題" + "投什麼?" + "建立投票" + "您確定要刪除投票嗎?" + "刪除投票" + "編輯投票" + "沒有進行中的投票。" + "沒有已結束的投票。" + "進行中" + "已結束" + "所有投票" + diff --git a/features/poll/impl/src/main/res/values-zh/translations.xml b/features/poll/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..f231e99 --- /dev/null +++ b/features/poll/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,20 @@ + + + "添加选项" + "仅在投票结束后显示结果" + "隐藏投票" + "选项 %1$d" + "更改尚未保存,确定要返回吗?" + "删除选项%1$s" + "问题或话题" + "投票的内容是什么?" + "创建投票" + "您确定要删除此投票吗?" + "删除投票" + "编辑投票" + "无法找到正在进行的投票。" + "无法找到历史投票" + "正在进行" + "历史" + "投票" + diff --git a/features/poll/impl/src/main/res/values/localazy.xml b/features/poll/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..6019d90 --- /dev/null +++ b/features/poll/impl/src/main/res/values/localazy.xml @@ -0,0 +1,20 @@ + + + "Add option" + "Show results only after poll ends" + "Hide votes" + "Option %1$d" + "Your changes have not been saved. Are you sure you want to go back?" + "Delete option %1$s" + "Question or topic" + "What is the poll about?" + "Create Poll" + "Are you sure you want to delete this poll?" + "Delete Poll" + "Edit poll" + "Can\'t find any ongoing polls." + "Can\'t find any past polls." + "Ongoing" + "Past" + "Polls" + diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/PollFixtures.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/PollFixtures.kt new file mode 100644 index 0000000..db6c8e8 --- /dev/null +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/PollFixtures.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.poll.PollAnswer +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +import io.element.android.libraries.matrix.test.timeline.aPollContent +import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +fun aPollTimelineItems( + polls: Map = emptyMap(), +): Flow> { + return flowOf( + polls.map { entry -> + MatrixTimelineItem.Event( + uniqueId = UniqueId(entry.key.value), + event = anEventTimelineItem( + eventId = entry.key, + content = entry.value, + ) + ) + } + ) +} + +fun anOngoingPollContent() = aPollContent( + question = "Do you like polls?", + answers = persistentListOf( + PollAnswer("1", "Yes"), + PollAnswer("2", "No"), + PollAnswer("2", "Maybe"), + ), +) + +fun anEndedPollContent() = anOngoingPollContent().copy( + endTime = 1702400215U +) diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt new file mode 100644 index 0000000..1f916eb --- /dev/null +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt @@ -0,0 +1,576 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.create + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.Composer +import im.vector.app.features.analytics.plan.PollCreation +import io.element.android.features.messages.test.FakeMessageComposerContext +import io.element.android.features.poll.api.create.CreatePollMode +import io.element.android.features.poll.impl.aPollTimelineItems +import io.element.android.features.poll.impl.anOngoingPollContent +import io.element.android.features.poll.impl.data.PollRepository +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class CreatePollPresenterTest { + @get:Rule val warmUpRule = WarmUpRule() + + private val pollEventId = AN_EVENT_ID + private var navUpInvocationsCount = 0 + private val existingPoll = anOngoingPollContent() + private val timeline = FakeTimeline( + timelineItems = aPollTimelineItems(mapOf(pollEventId to existingPoll)) + ) + private val fakeJoinedRoom = FakeJoinedRoom( + liveTimeline = timeline + ) + private val fakeAnalyticsService = FakeAnalyticsService() + private val fakeMessageComposerContext = FakeMessageComposerContext() + + @Test + fun `default state has proper default values`() = runTest { + val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitDefaultItem() + } + } + + @Test + fun `in edit mode, poll values are loaded`() = runTest { + val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(AN_EVENT_ID)) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitDefaultItem() + awaitPollLoaded() + } + } + + @Test + fun `in edit mode, if poll doesn't exist, error is tracked and screen is closed`() = runTest { + val room = FakeJoinedRoom( + liveTimeline = FakeTimeline() + ) + val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(AN_EVENT_ID), room = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitDefaultItem() + assertThat(fakeAnalyticsService.trackedErrors.filterIsInstance()).isNotEmpty() + assertThat(navUpInvocationsCount).isEqualTo(1) + } + } + + @Test + fun `non blank question and 2 answers are required to create a poll`() = runTest { + val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + assertThat(initial.canSave).isFalse() + + initial.eventSink(CreatePollEvents.SetQuestion("A question?")) + val questionSet = awaitItem() + assertThat(questionSet.canSave).isFalse() + + questionSet.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1")) + val answer1Set = awaitItem() + assertThat(answer1Set.canSave).isFalse() + + answer1Set.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2")) + val answer2Set = awaitItem() + assertThat(answer2Set.canSave).isTrue() + } + } + + @Test + fun `create poll sends a poll start event`() = runTest { + val createPollResult = lambdaRecorder, Int, PollKind, Result> { _, _, _, _ -> Result.success(Unit) } + val presenter = createCreatePollPresenter( + room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + createPollLambda = createPollResult + }, + ), + mode = CreatePollMode.NewPoll, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + initial.eventSink(CreatePollEvents.SetQuestion("A question?")) + initial.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1")) + initial.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2")) + skipItems(3) + initial.eventSink(CreatePollEvents.Save) + delay(1) // Wait for the coroutine to finish + createPollResult.assertions().isCalledOnce() + .with( + value("A question?"), + value(listOf("Answer 1", "Answer 2")), + value(1), + value(PollKind.Disclosed), + ) + assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(2) + assertThat(fakeAnalyticsService.capturedEvents[0]).isEqualTo( + Composer( + inThread = false, + isEditing = false, + isReply = false, + messageType = Composer.MessageType.Poll, + ) + ) + assertThat(fakeAnalyticsService.capturedEvents[1]).isEqualTo( + PollCreation( + action = PollCreation.Action.Create, + isUndisclosed = false, + numberOfAnswers = 2, + ) + ) + } + } + + @Test + fun `when poll creation fails, error is tracked`() = runTest { + val error = Exception("cause") + val createPollResult = lambdaRecorder, Int, PollKind, Result> { _, _, _, _ -> + Result.failure(error) + } + val presenter = createCreatePollPresenter( + room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + createPollLambda = createPollResult + }, + ), + mode = CreatePollMode.NewPoll, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitDefaultItem().eventSink(CreatePollEvents.SetQuestion("A question?")) + awaitItem().eventSink(CreatePollEvents.SetAnswer(0, "Answer 1")) + awaitItem().eventSink(CreatePollEvents.SetAnswer(1, "Answer 2")) + awaitItem().eventSink(CreatePollEvents.Save) + delay(1) // Wait for the coroutine to finish + createPollResult.assertions().isCalledOnce() + assertThat(fakeAnalyticsService.capturedEvents).isEmpty() + assertThat(fakeAnalyticsService.trackedErrors).hasSize(1) + assertThat(fakeAnalyticsService.trackedErrors).containsExactly( + CreatePollException.SavePollFailed("Failed to create poll", error) + ) + } + } + + @Test + fun `edit poll sends a poll edit event`() = runTest { + val editPollLambda = lambdaRecorder { _: EventId, _: String, _: List, _: Int, _: PollKind -> + Result.success(Unit) + } + timeline.apply { + this.editPollLambda = editPollLambda + } + val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId)) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitDefaultItem() + awaitPollLoaded().apply { + eventSink(CreatePollEvents.SetQuestion("Changed question")) + } + awaitItem().apply { + eventSink(CreatePollEvents.SetAnswer(0, "Changed answer 1")) + } + awaitItem().apply { + eventSink(CreatePollEvents.SetAnswer(1, "Changed answer 2")) + } + awaitPollLoaded( + newQuestion = "Changed question", + newAnswer1 = "Changed answer 1", + newAnswer2 = "Changed answer 2", + ).apply { + eventSink(CreatePollEvents.Save) + } + advanceUntilIdle() // Wait for the coroutine to finish + + assert(editPollLambda) + .isCalledOnce() + .with( + value(pollEventId), + value("Changed question"), + value(listOf("Changed answer 1", "Changed answer 2", "Maybe")), + value(1), + value(PollKind.Disclosed) + ) + + assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(2) + assertThat(fakeAnalyticsService.capturedEvents[0]).isEqualTo( + Composer( + inThread = false, + isEditing = true, + isReply = false, + messageType = Composer.MessageType.Poll, + ) + ) + assertThat(fakeAnalyticsService.capturedEvents[1]).isEqualTo( + PollCreation( + action = PollCreation.Action.Edit, + isUndisclosed = false, + numberOfAnswers = 3, + ) + ) + } + } + + @Test + fun `when edit poll fails, error is tracked`() = runTest { + val error = Exception("cause") + val presenter = createCreatePollPresenter( + room = FakeJoinedRoom( + liveTimeline = timeline, + ), + mode = CreatePollMode.EditPoll(pollEventId), + ) + val editPollLambda = lambdaRecorder { _: EventId, _: String, _: List, _: Int, _: PollKind -> + Result.failure(error) + } + timeline.apply { + this.editPollLambda = editPollLambda + } + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitDefaultItem() + awaitPollLoaded().eventSink(CreatePollEvents.SetAnswer(0, "A")) + awaitPollLoaded(newAnswer1 = "A").eventSink(CreatePollEvents.Save) + advanceUntilIdle() // Wait for the coroutine to finish + editPollLambda.assertions().isCalledOnce() + assertThat(fakeAnalyticsService.capturedEvents).isEmpty() + assertThat(fakeAnalyticsService.trackedErrors).hasSize(1) + assertThat(fakeAnalyticsService.trackedErrors).containsExactly( + CreatePollException.SavePollFailed("Failed to edit poll", error) + ) + } + } + + @Test + fun `add answer button adds an empty answer and removing it removes it`() = runTest { + val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + assertThat(initial.answers.size).isEqualTo(2) + + initial.eventSink(CreatePollEvents.AddAnswer) + val answerAdded = awaitItem() + assertThat(answerAdded.answers.size).isEqualTo(3) + assertThat(answerAdded.answers[2].text).isEmpty() + + initial.eventSink(CreatePollEvents.RemoveAnswer(2)) + val answerRemoved = awaitItem() + assertThat(answerRemoved.answers.size).isEqualTo(2) + } + } + + @Test + fun `set question sets the question`() = runTest { + val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + initial.eventSink(CreatePollEvents.SetQuestion("A question?")) + val questionSet = awaitItem() + assertThat(questionSet.question).isEqualTo("A question?") + } + } + + @Test + fun `set poll answer sets the given poll answer`() = runTest { + val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + initial.eventSink(CreatePollEvents.SetAnswer(0, "This is answer 1")) + val answerSet = awaitItem() + assertThat(answerSet.answers.first().text).isEqualTo("This is answer 1") + } + } + + @Test + fun `set poll kind sets the poll kind`() = runTest { + val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + initial.eventSink(CreatePollEvents.SetPollKind(PollKind.Undisclosed)) + val kindSet = awaitItem() + assertThat(kindSet.pollKind).isEqualTo(PollKind.Undisclosed) + } + } + + @Test + fun `can add options when between 2 and 20 and then no more`() = runTest { + val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + assertThat(initial.canAddAnswer).isTrue() + repeat(17) { + initial.eventSink(CreatePollEvents.AddAnswer) + assertThat(awaitItem().canAddAnswer).isTrue() + } + initial.eventSink(CreatePollEvents.AddAnswer) + assertThat(awaitItem().canAddAnswer).isFalse() + } + } + + @Test + fun `can delete option if there are more than 2`() = runTest { + val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + assertThat(initial.answers.all { it.canDelete }).isFalse() + initial.eventSink(CreatePollEvents.AddAnswer) + assertThat(awaitItem().answers.all { it.canDelete }).isTrue() + } + } + + @Test + fun `option with more than 240 char is truncated`() = runTest { + val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + initial.eventSink(CreatePollEvents.SetAnswer(0, "A".repeat(241))) + assertThat(awaitItem().answers.first().text.length).isEqualTo(240) + } + } + + @Test + fun `navBack event calls navBack lambda`() = runTest { + val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + assertThat(navUpInvocationsCount).isEqualTo(0) + initial.eventSink(CreatePollEvents.NavBack) + assertThat(navUpInvocationsCount).isEqualTo(1) + } + } + + @Test + fun `confirm nav back from new poll with blank fields calls nav back lambda`() = runTest { + val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + assertThat(navUpInvocationsCount).isEqualTo(0) + assertThat(initial.showBackConfirmation).isFalse() + initial.eventSink(CreatePollEvents.ConfirmNavBack) + assertThat(navUpInvocationsCount).isEqualTo(1) + } + } + + @Test + fun `confirm nav back from new poll with non blank fields shows confirmation dialog and cancelling hides it`() = runTest { + val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initial = awaitItem() + initial.eventSink(CreatePollEvents.SetQuestion("Non blank")) + assertThat(awaitItem().showBackConfirmation).isFalse() + initial.eventSink(CreatePollEvents.ConfirmNavBack) + assertThat(awaitItem().showBackConfirmation).isTrue() + initial.eventSink(CreatePollEvents.HideConfirmation) + assertThat(awaitItem().showBackConfirmation).isFalse() + assertThat(navUpInvocationsCount).isEqualTo(0) + } + } + + @Test + fun `confirm nav back from existing poll with unchanged fields calls nav back lambda`() = runTest { + val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId)) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitDefaultItem() + val loaded = awaitPollLoaded() + assertThat(navUpInvocationsCount).isEqualTo(0) + assertThat(loaded.showBackConfirmation).isFalse() + loaded.eventSink(CreatePollEvents.ConfirmNavBack) + assertThat(navUpInvocationsCount).isEqualTo(1) + } + } + + @Test + fun `confirm nav back from existing poll with changed fields shows confirmation dialog and cancelling hides it`() = runTest { + val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId)) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitDefaultItem() + val loaded = awaitPollLoaded() + loaded.eventSink(CreatePollEvents.SetQuestion("CHANGED")) + assertThat(awaitItem().showBackConfirmation).isFalse() + loaded.eventSink(CreatePollEvents.ConfirmNavBack) + assertThat(awaitItem().showBackConfirmation).isTrue() + loaded.eventSink(CreatePollEvents.HideConfirmation) + assertThat(awaitItem().showBackConfirmation).isFalse() + assertThat(navUpInvocationsCount).isEqualTo(0) + } + } + + @Test + fun `delete confirms`() = runTest { + val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId)) + val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) } + timeline.redactEventLambda = redactEventLambda + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitDefaultItem() + awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false)) + awaitDeleteConfirmation() + assert(redactEventLambda).isNeverCalled() + } + } + + @Test + fun `delete can be cancelled`() = runTest { + val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId)) + val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) } + timeline.redactEventLambda = redactEventLambda + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitDefaultItem() + awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false)) + awaitDeleteConfirmation().eventSink(CreatePollEvents.HideConfirmation) + awaitPollLoaded().apply { + assertThat(showDeleteConfirmation).isFalse() + } + assert(redactEventLambda).isNeverCalled() + } + } + + @Test + fun `delete can be confirmed`() = runTest { + val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId)) + val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) } + timeline.redactEventLambda = redactEventLambda + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitDefaultItem() + awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false)) + awaitDeleteConfirmation().eventSink(CreatePollEvents.Delete(confirmed = true)) + awaitPollLoaded().apply { + assertThat(showDeleteConfirmation).isFalse() + } + assert(redactEventLambda) + .isCalledOnce() + .with(value(pollEventId.toEventOrTransactionId()), any()) + } + } + + private suspend fun TurbineTestContext.awaitDefaultItem() = + awaitItem().apply { + assertThat(canSave).isFalse() + assertThat(canAddAnswer).isTrue() + assertThat(question).isEmpty() + assertThat(answers).isEqualTo(listOf(Answer("", false), Answer("", false))) + assertThat(pollKind).isEqualTo(PollKind.Disclosed) + assertThat(showBackConfirmation).isFalse() + assertThat(showDeleteConfirmation).isFalse() + } + + private suspend fun TurbineTestContext.awaitDeleteConfirmation() = + awaitItem().apply { + assertThat(showDeleteConfirmation).isTrue() + } + + private suspend fun TurbineTestContext.awaitPollLoaded( + newQuestion: String? = null, + newAnswer1: String? = null, + newAnswer2: String? = null, + ) = + awaitItem().also { state -> + assertThat(state.canSave).isTrue() + assertThat(state.canAddAnswer).isTrue() + assertThat(state.question).isEqualTo(newQuestion ?: existingPoll.question) + assertThat(state.answers).isEqualTo(existingPoll.expectedAnswersState().toMutableList().apply { + newAnswer1?.let { this[0] = Answer(it, true) } + newAnswer2?.let { this[1] = Answer(it, true) } + }) + assertThat(state.pollKind).isEqualTo(existingPoll.kind) + } + + private fun createCreatePollPresenter( + mode: CreatePollMode = CreatePollMode.NewPoll, + room: FakeJoinedRoom = fakeJoinedRoom, + timelineMode: Timeline.Mode = Timeline.Mode.Live, + ): CreatePollPresenter = CreatePollPresenter( + repositoryFactory = object : PollRepository.Factory { + override fun create(timelineMode: Timeline.Mode): PollRepository { + return PollRepository(room, LiveTimelineProvider(room), timelineMode) + } + }, + analyticsService = fakeAnalyticsService, + messageComposerContext = fakeMessageComposerContext, + navigateUp = { navUpInvocationsCount++ }, + mode = mode, + timelineMode = timelineMode, + ) +} + +private fun PollContent.expectedAnswersState() = answers.map { answer -> + Answer( + text = answer.text, + canDelete = answers.size > 2, + ) +} diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/DefaultCreatePollEntryPointTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/DefaultCreatePollEntryPointTest.kt new file mode 100644 index 0000000..573c7b1 --- /dev/null +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/DefaultCreatePollEntryPointTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.create + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.test.FakeMessageComposerContext +import io.element.android.features.poll.api.create.CreatePollEntryPoint +import io.element.android.features.poll.api.create.CreatePollMode +import io.element.android.features.poll.impl.data.PollRepository +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultCreatePollEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultCreatePollEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + CreatePollNode( + buildContext = buildContext, + plugins = plugins, + presenterFactory = { timelineMode: Timeline.Mode, backNavigator: () -> Unit, mode: CreatePollMode -> + CreatePollPresenter( + repositoryFactory = { + val room = FakeJoinedRoom() + PollRepository(room, LiveTimelineProvider(room), timelineMode) + }, + analyticsService = FakeAnalyticsService(), + messageComposerContext = FakeMessageComposerContext(), + navigateUp = backNavigator, + mode = mode, + timelineMode = timelineMode, + ) + }, + analyticsService = FakeAnalyticsService(), + ) + } + val params = CreatePollEntryPoint.Params( + timelineMode = Timeline.Mode.Live, + mode = CreatePollMode.NewPoll, + ) + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + params = params, + ) + assertThat(result).isInstanceOf(CreatePollNode::class.java) + assertThat(result.plugins).contains(CreatePollNode.Inputs(timelineMode = params.timelineMode, mode = params.mode)) + } +} diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/PollFormStateSaverTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/PollFormStateSaverTest.kt new file mode 100644 index 0000000..86e4b28 --- /dev/null +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/PollFormStateSaverTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.create + +import androidx.compose.runtime.saveable.SaverScope +import com.google.common.truth.Truth.assertThat +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test + +class PollFormStateSaverTest { + companion object { + val CanSaveScope = SaverScope { true } + } + + @Test + fun `test save and restore`() { + val state = PollFormState( + question = "question", + answers = persistentListOf("answer1", "answer2"), + isDisclosed = true, + ) + + val saved = with(CanSaveScope) { + with(pollFormStateSaver) { + save(state) + } + } + + val restored = saved?.let { + pollFormStateSaver.restore(it) + } + + assertThat(restored).isEqualTo(state) + } +} diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/PollFormStateTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/PollFormStateTest.kt new file mode 100644 index 0000000..8f6bc32 --- /dev/null +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/PollFormStateTest.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.create + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.poll.impl.PollConstants +import io.element.android.libraries.matrix.api.poll.PollKind +import kotlinx.collections.immutable.toImmutableList +import org.junit.Test + +class PollFormStateTest { + @Test + fun `with new answer`() { + val state = PollFormState.Empty + val newState = state.withNewAnswer() + assertThat(newState.answers).isEqualTo(listOf("", "", "")) + } + + @Test + fun `with new answer, given cannot add, doesn't add`() { + val state = PollFormState.Empty.withBlankAnswers(PollConstants.MAX_ANSWERS) + val newState = state.withNewAnswer() + assertThat(newState).isEqualTo(state) + } + + @Test + fun `with answer deleted, given cannot delete, doesn't delete`() { + val state = PollFormState.Empty + val newState = state.withAnswerRemoved(0) + assertThat(newState).isEqualTo(state) + } + + @Test + fun `with answer deleted, given can delete`() { + val state = PollFormState.Empty.withNewAnswer() + val newState = state.withAnswerRemoved(0) + assertThat(newState).isEqualTo(PollFormState.Empty) + } + + @Test + fun `with answer changed`() { + val state = PollFormState.Empty + val newState = state.withAnswerChanged(1, "New answer") + assertThat(newState).isEqualTo(PollFormState.Empty.copy( + answers = listOf("", "New answer").toImmutableList() + )) + } + + @Test + fun `with answer changed, given it is too long, truncates`() { + val tooLongAnswer = "a".repeat(PollConstants.MAX_ANSWER_LENGTH * 2) + val truncatedAnswer = "a".repeat(PollConstants.MAX_ANSWER_LENGTH) + val state = PollFormState.Empty + val newState = state.withAnswerChanged(1, tooLongAnswer) + assertThat(newState).isEqualTo(PollFormState.Empty.copy( + answers = listOf("", truncatedAnswer).toImmutableList() + )) + } + + @Test + fun `can add answer is true when it does not have max answers`() { + val state = PollFormState.Empty.withBlankAnswers(PollConstants.MAX_ANSWERS - 1) + assertThat(state.canAddAnswer).isTrue() + } + + @Test + fun `can add answer is false when it has max answers`() { + val state = PollFormState.Empty.withBlankAnswers(PollConstants.MAX_ANSWERS) + assertThat(state.canAddAnswer).isFalse() + } + + @Test + fun `can delete answer is false when it has min answers`() { + val state = PollFormState.Empty.withBlankAnswers(PollConstants.MIN_ANSWERS) + assertThat(state.canDeleteAnswer).isFalse() + } + + @Test + fun `can delete answer is true when it has more than min answers`() { + val numAnswers = PollConstants.MIN_ANSWERS + 1 + val state = PollFormState.Empty.withBlankAnswers(numAnswers) + assertThat(state.canDeleteAnswer).isTrue() + } + + @Test + fun `is valid is true when it is valid`() { + val state = aValidPollFormState() + assertThat(state.isValid).isTrue() + } + + @Test + fun `is valid is false when question is blank`() { + val state = aValidPollFormState().copy(question = "") + assertThat(state.isValid).isFalse() + } + + @Test + fun `is valid is false when not enough answers`() { + val state = aValidPollFormState().copy(answers = listOf("").toImmutableList()) + assertThat(state.isValid).isFalse() + } + + @Test + fun `is valid is false when one answer is blank`() { + val state = aValidPollFormState().withNewAnswer() + assertThat(state.isValid).isFalse() + } + + @Test + fun `poll kind when is disclosed`() { + val state = PollFormState.Empty.copy(isDisclosed = true) + assertThat(state.pollKind).isEqualTo(PollKind.Disclosed) + } + + @Test + fun `poll kind when is not disclosed`() { + val state = PollFormState.Empty.copy(isDisclosed = false) + assertThat(state.pollKind).isEqualTo(PollKind.Undisclosed) + } +} + +private fun aValidPollFormState(): PollFormState { + return PollFormState.Empty.copy( + question = "question", + answers = listOf("answer1", "answer2").toImmutableList(), + isDisclosed = true, + ) +} + +private fun PollFormState.withBlankAnswers(numAnswers: Int): PollFormState = + copy(answers = List(numAnswers) { "" }.toImmutableList()) diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/DefaultPollHistoryEntryPointTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/DefaultPollHistoryEntryPointTest.kt new file mode 100644 index 0000000..d0d0490 --- /dev/null +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/DefaultPollHistoryEntryPointTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.history + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.features.poll.test.create.FakeCreatePollEntryPoint +import io.element.android.tests.testutils.node.TestParentNode +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DefaultPollHistoryEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() = runTest { + val entryPoint = DefaultPollHistoryEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + PollHistoryFlowNode( + buildContext = buildContext, + plugins = plugins, + createPollEntryPoint = FakeCreatePollEntryPoint(), + ) + } + val result = entryPoint.createNode(parentNode, BuildContext.root(null)) + assertThat(result).isInstanceOf(PollHistoryFlowNode::class.java) + } +} diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt new file mode 100644 index 0000000..8d4cedc --- /dev/null +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.history + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.poll.api.actions.EndPollAction +import io.element.android.features.poll.api.actions.SendPollResponseAction +import io.element.android.features.poll.impl.aPollTimelineItems +import io.element.android.features.poll.impl.anEndedPollContent +import io.element.android.features.poll.impl.anOngoingPollContent +import io.element.android.features.poll.impl.history.model.PollHistoryFilter +import io.element.android.features.poll.impl.history.model.PollHistoryItemsFactory +import io.element.android.features.poll.impl.model.DefaultPollContentStateFactory +import io.element.android.features.poll.test.actions.FakeEndPollAction +import io.element.android.features.poll.test.actions.FakeSendPollResponseAction +import io.element.android.libraries.dateformatter.test.FakeDateFormatter +import io.element.android.libraries.matrix.api.timeline.Timeline +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.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class PollHistoryPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private val backwardPaginationStatus = MutableStateFlow(Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = true)) + private val timeline = FakeTimeline( + timelineItems = aPollTimelineItems( + mapOf( + AN_EVENT_ID to anOngoingPollContent(), + AN_EVENT_ID_2 to anEndedPollContent() + ) + ), + backwardPaginationStatus = backwardPaginationStatus + ) + private val room = FakeJoinedRoom( + liveTimeline = timeline + ) + + @Test + fun `present - initial states`() = runTest { + val presenter = createPollHistoryPresenter(room = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().also { state -> + assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.ONGOING) + assertThat(state.pollHistoryItems.size).isEqualTo(0) + assertThat(state.isLoading).isTrue() + assertThat(state.hasMoreToLoad).isTrue() + } + awaitItem().also { state -> + assertThat(state.pollHistoryItems.size).isEqualTo(2) + assertThat(state.pollHistoryItems.ongoing).hasSize(1) + assertThat(state.pollHistoryItems.past).hasSize(1) + } + } + } + + @Test + fun `present - change filter scenario`() = runTest { + val presenter = createPollHistoryPresenter(room = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().also { state -> + assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.ONGOING) + state.eventSink(PollHistoryEvents.SelectFilter(PollHistoryFilter.PAST)) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.PAST) + state.eventSink(PollHistoryEvents.SelectFilter(PollHistoryFilter.ONGOING)) + } + awaitItem().also { state -> + assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.ONGOING) + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - poll actions scenario`() = runTest { + val sendPollResponseAction = FakeSendPollResponseAction() + val endPollAction = FakeEndPollAction() + val presenter = createPollHistoryPresenter( + sendPollResponseAction = sendPollResponseAction, + endPollAction = endPollAction + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val state = awaitItem() + state.eventSink(PollHistoryEvents.EndPoll(AN_EVENT_ID)) + runCurrent() + endPollAction.verifyExecutionCount(1) + state.eventSink(PollHistoryEvents.SelectPollAnswer(AN_EVENT_ID, "answer")) + runCurrent() + sendPollResponseAction.verifyExecutionCount(1) + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun `present - load more scenario`() = runTest { + val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection -> + Result.success(false) + } + timeline.apply { + this.paginateLambda = paginateLambda + } + val presenter = createPollHistoryPresenter(room = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val loadedState = awaitItem() + assertThat(loadedState.isLoading).isFalse() + loadedState.eventSink(PollHistoryEvents.LoadMore) + backwardPaginationStatus.getAndUpdate { it.copy(isPaginating = true) } + awaitItem().also { state -> + assertThat(state.isLoading).isTrue() + } + backwardPaginationStatus.getAndUpdate { it.copy(isPaginating = false) } + awaitItem().also { state -> + assertThat(state.isLoading).isFalse() + } + // Called once by the initial load and once by the load more event + assert(paginateLambda).isCalledExactly(2) + } + } +} + +internal fun TestScope.createPollHistoryPresenter( + room: FakeJoinedRoom = FakeJoinedRoom(), + endPollAction: EndPollAction = FakeEndPollAction(), + sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(), + pollHistoryItemFactory: PollHistoryItemsFactory = PollHistoryItemsFactory( + pollContentStateFactory = DefaultPollContentStateFactory(FakeMatrixClient()), + dateFormatter = FakeDateFormatter(), + dispatchers = testCoroutineDispatchers(), + ), +): PollHistoryPresenter { + return PollHistoryPresenter( + sessionCoroutineScope = this, + sendPollResponseAction = sendPollResponseAction, + endPollAction = endPollAction, + pollHistoryItemFactory = pollHistoryItemFactory, + room = room, + ) +} diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt new file mode 100644 index 0000000..1ff25a0 --- /dev/null +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.history + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.poll.api.pollcontent.aPollContentState +import io.element.android.features.poll.impl.R +import io.element.android.features.poll.impl.history.model.PollHistoryFilter +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class PollHistoryViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setPollHistoryViewView( + aPollHistoryState( + eventSink = eventsRecorder + ), + goBack = it + ) + rule.pressBack() + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on edit poll invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val eventId = EventId("\$anEventId") + val state = aPollHistoryState( + currentItems = listOf( + aPollHistoryItem( + state = aPollContentState( + eventId = eventId, + isMine = true, + isEnded = false, + ) + ) + ), + eventSink = eventsRecorder + ) + ensureCalledOnceWithParam(eventId) { + rule.setPollHistoryViewView( + state = state, + onEditPoll = it + ) + rule.clickOn(CommonStrings.action_edit_poll) + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on poll end emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val eventId = EventId("\$anEventId") + val state = aPollHistoryState( + currentItems = listOf( + aPollHistoryItem( + state = aPollContentState( + eventId = eventId, + isMine = true, + isEnded = false, + isPollEditable = false, + ) + ) + ), + eventSink = eventsRecorder + ) + rule.setPollHistoryViewView( + state = state, + ) + rule.clickOn(CommonStrings.action_end_poll) + // Cancel the dialog + rule.clickOn(CommonStrings.action_cancel) + // Do it again, and confirm the dialog + rule.clickOn(CommonStrings.action_end_poll) + eventsRecorder.assertEmpty() + rule.clickOn(CommonStrings.action_ok) + eventsRecorder.assertSingle( + PollHistoryEvents.EndPoll(eventId) + ) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on poll answer emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val eventId = EventId("\$anEventId") + val state = aPollHistoryState( + currentItems = listOf( + aPollHistoryItem( + state = aPollContentState( + eventId = eventId, + isMine = true, + isEnded = false, + isPollEditable = false, + ) + ) + ), + eventSink = eventsRecorder + ) + val answer = state.pollHistoryItems.ongoing.first().state.answerItems.first().answer + rule.setPollHistoryViewView( + state = state, + ) + rule.onNodeWithText( + text = answer.text, + useUnmergedTree = true, + ).performClick() + eventsRecorder.assertSingle( + PollHistoryEvents.SelectPollAnswer(eventId, answer.id) + ) + } + + @Test + fun `clicking on past tab emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setPollHistoryViewView( + aPollHistoryState( + eventSink = eventsRecorder + ), + ) + rule.clickOn(R.string.screen_polls_history_filter_past) + eventsRecorder.assertSingle( + PollHistoryEvents.SelectFilter(filter = PollHistoryFilter.PAST) + ) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on load more emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setPollHistoryViewView( + aPollHistoryState( + hasMoreToLoad = true, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_load_more) + eventsRecorder.assertSingle( + PollHistoryEvents.LoadMore + ) + } +} + +private fun AndroidComposeTestRule.setPollHistoryViewView( + state: PollHistoryState, + onEditPoll: (EventId) -> Unit = EnsureNeverCalledWithParam(), + goBack: () -> Unit = EnsureNeverCalled(), +) { + setContent { + PollHistoryView( + state = state, + onEditPoll = onEditPoll, + goBack = goBack, + ) + } +} diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt new file mode 100644 index 0000000..438d451 --- /dev/null +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt @@ -0,0 +1,291 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.impl.pollcontent + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.poll.api.pollcontent.PollAnswerItem +import io.element.android.features.poll.api.pollcontent.PollContentState +import io.element.android.features.poll.impl.model.DefaultPollContentStateFactory +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.poll.PollAnswer +import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +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.A_USER_ID_10 +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.A_USER_ID_3 +import io.element.android.libraries.matrix.test.A_USER_ID_4 +import io.element.android.libraries.matrix.test.A_USER_ID_5 +import io.element.android.libraries.matrix.test.A_USER_ID_6 +import io.element.android.libraries.matrix.test.A_USER_ID_7 +import io.element.android.libraries.matrix.test.A_USER_ID_8 +import io.element.android.libraries.matrix.test.A_USER_ID_9 +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PollContentStateFactoryTest { + private val factory = DefaultPollContentStateFactory(FakeMatrixClient()) + private val eventTimelineItem = anEventTimelineItem() + + @Test + fun `Disclosed poll - not ended, no votes`() = runTest { + val state = factory.create(eventTimelineItem, aPollContent()) + val expectedState = aPollContentState() + assertThat(state).isEqualTo(expectedState) + } + + @Test + fun `Disclosed poll - not ended, some votes, including one from current user`() = runTest { + val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap() + val state = factory.create( + eventTimelineItem, + aPollContent(votes = votes) + ) + val expectedState = aPollContentState( + answerItems = listOf( + aPollAnswerItem(answer = A_POLL_ANSWER_1, votesCount = 3, percentage = 0.3f), + aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, votesCount = 6, percentage = 0.6f), + aPollAnswerItem(answer = A_POLL_ANSWER_3), + aPollAnswerItem(answer = A_POLL_ANSWER_4, votesCount = 1, percentage = 0.1f), + ) + ) + assertThat(state).isEqualTo(expectedState) + } + + @Test + fun `Disclosed poll - ended, no votes, no winner`() = runTest { + val state = factory.create(eventTimelineItem, aPollContent(endTime = 1UL)) + val expectedState = aPollContentState().let { + it.copy( + answerItems = it.answerItems.map { answerItem -> answerItem.copy(isEnabled = false) }.toImmutableList(), + isPollEnded = true, + ) + } + assertThat(state).isEqualTo(expectedState) + } + + @Test + fun `Disclosed poll - ended, some votes, including one from current user (winner)`() = runTest { + val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap() + val state = factory.create( + eventTimelineItem, + aPollContent(votes = votes, endTime = 1UL) + ) + val expectedState = aPollContentState( + answerItems = listOf( + aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, votesCount = 3, percentage = 0.3f), + aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, isWinner = true, votesCount = 6, percentage = 0.6f), + aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false), + aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, votesCount = 1, percentage = 0.1f), + ), + isEnded = true, + ) + assertThat(state).isEqualTo(expectedState) + } + + @Test + fun `Disclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest { + val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap() + val state = factory.create( + eventTimelineItem, + aPollContent(votes = votes, endTime = 1UL) + ) + val expectedState = aPollContentState( + answerItems = listOf( + aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f), + aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, votesCount = 2, percentage = 0.2f), + aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false), + aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f), + ), + isEnded = true, + ) + assertThat(state).isEqualTo(expectedState) + } + + @Test + fun `Undisclosed poll - not ended, no votes`() = runTest { + val state = factory.create(eventTimelineItem, aPollContent(PollKind.Undisclosed)) + val expectedState = aPollContentState(pollKind = PollKind.Undisclosed).let { + it.copy( + answerItems = it.answerItems.map { answerItem -> answerItem.copy(showVotes = false) }.toImmutableList() + ) + } + assertThat(state).isEqualTo(expectedState) + } + + @Test + fun `Undisclosed poll - not ended, some votes, including one from current user`() = runTest { + val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap() + val state = factory.create( + eventTimelineItem, + aPollContent(PollKind.Undisclosed, votes = votes) + ) + val expectedState = aPollContentState( + pollKind = PollKind.Undisclosed, + answerItems = listOf( + aPollAnswerItem(answer = A_POLL_ANSWER_1, showVotes = false, votesCount = 3, percentage = 0.3f), + aPollAnswerItem(answer = A_POLL_ANSWER_2, showVotes = false, isSelected = true, votesCount = 6, percentage = 0.6f), + aPollAnswerItem(answer = A_POLL_ANSWER_3, showVotes = false), + aPollAnswerItem(answer = A_POLL_ANSWER_4, showVotes = false, votesCount = 1, percentage = 0.1f), + ), + ) + assertThat(state).isEqualTo(expectedState) + } + + @Test + fun `Undisclosed poll - ended, no votes, no winner`() = runTest { + val state = factory.create(eventTimelineItem, aPollContent(PollKind.Undisclosed, endTime = 1UL)) + val expectedState = aPollContentState( + isEnded = true, + pollKind = PollKind.Undisclosed + ).let { + it.copy( + answerItems = it.answerItems.map { answerItem -> answerItem.copy(showVotes = true, isEnabled = false) }.toImmutableList(), + ) + } + assertThat(state).isEqualTo(expectedState) + } + + @Test + fun `Undisclosed poll - ended, some votes, including one from current user (winner)`() = runTest { + val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap() + val state = factory.create( + eventTimelineItem, + aPollContent(PollKind.Undisclosed, votes = votes, endTime = 1UL) + ) + val expectedState = aPollContentState( + pollKind = PollKind.Undisclosed, + answerItems = listOf( + aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, votesCount = 3, percentage = 0.3f), + aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, isWinner = true, votesCount = 6, percentage = 0.6f), + aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false), + aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, votesCount = 1, percentage = 0.1f), + ), + isEnded = true, + ) + assertThat(state).isEqualTo(expectedState) + } + + @Test + fun `Undisclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest { + val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap() + val state = factory.create( + eventTimelineItem, + aPollContent(PollKind.Undisclosed, votes = votes, endTime = 1UL) + ) + val expectedState = aPollContentState( + pollKind = PollKind.Undisclosed, + answerItems = listOf( + aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f), + aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, votesCount = 2, percentage = 0.2f), + aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false), + aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f), + ), + isEnded = true, + ) + assertThat(state).isEqualTo(expectedState) + } + + @Test + fun `eventId is populated`() = runTest { + val state = factory.create(eventTimelineItem, aPollContent()) + assertThat(state.eventId).isEqualTo(eventTimelineItem.eventId) + } + + private fun aPollContent( + pollKind: PollKind = PollKind.Disclosed, + votes: ImmutableMap> = persistentMapOf(), + endTime: ULong? = null, + ): PollContent = PollContent( + question = A_POLL_QUESTION, + kind = pollKind, + maxSelections = 1UL, + answers = persistentListOf(A_POLL_ANSWER_1, A_POLL_ANSWER_2, A_POLL_ANSWER_3, A_POLL_ANSWER_4), + votes = votes, + endTime = endTime, + isEdited = false, + ) + + private fun aPollContentState( + eventId: EventId? = AN_EVENT_ID, + pollKind: PollKind = PollKind.Disclosed, + answerItems: List = listOf( + aPollAnswerItem(A_POLL_ANSWER_1), + aPollAnswerItem(A_POLL_ANSWER_2), + aPollAnswerItem(A_POLL_ANSWER_3), + aPollAnswerItem(A_POLL_ANSWER_4), + ), + isEnded: Boolean = false, + isMine: Boolean = false, + isEditable: Boolean = false, + question: String = A_POLL_QUESTION, + ) = PollContentState( + eventId = eventId, + question = question, + answerItems = answerItems.toImmutableList(), + pollKind = pollKind, + isPollEditable = isEditable, + isPollEnded = isEnded, + isMine = isMine, + ) + + private fun aPollAnswerItem( + answer: PollAnswer, + isSelected: Boolean = false, + isEnabled: Boolean = true, + isWinner: Boolean = false, + showVotes: Boolean = true, + votesCount: Int = 0, + percentage: Float = 0f, + ) = PollAnswerItem( + answer = answer, + isSelected = isSelected, + isEnabled = isEnabled, + isWinner = isWinner, + showVotes = showVotes, + votesCount = votesCount, + percentage = percentage, + ) + + private companion object TestData { + private const val A_POLL_QUESTION = "What is your favorite food?" + private val A_POLL_ANSWER_1 = PollAnswer("id_1", "Pizza") + private val A_POLL_ANSWER_2 = PollAnswer("id_2", "Pasta") + private val A_POLL_ANSWER_3 = PollAnswer("id_3", "French Fries") + private val A_POLL_ANSWER_4 = PollAnswer("id_4", "Hamburger") + + private val MY_USER_WINNING_VOTES = persistentMapOf( + A_POLL_ANSWER_1 to persistentListOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4), + // First item (A_USER_ID) is for my vote + // winner + A_POLL_ANSWER_2 to persistentListOf(A_USER_ID, A_USER_ID_5, A_USER_ID_6, A_USER_ID_7, A_USER_ID_8, A_USER_ID_9), + A_POLL_ANSWER_3 to persistentListOf(), + A_POLL_ANSWER_4 to persistentListOf(A_USER_ID_10), + ) + private val OTHER_WINNING_VOTES = persistentMapOf( + // A winner + A_POLL_ANSWER_1 to persistentListOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4, A_USER_ID_5), + // First item (A_USER_ID) is for my vote + A_POLL_ANSWER_2 to persistentListOf(A_USER_ID, A_USER_ID_6), + A_POLL_ANSWER_3 to persistentListOf(), + // Other winner + A_POLL_ANSWER_4 to persistentListOf(A_USER_ID_7, A_USER_ID_8, A_USER_ID_9, A_USER_ID_10), + ) + } +} diff --git a/features/poll/test/build.gradle.kts b/features/poll/test/build.gradle.kts new file mode 100644 index 0000000..a377980 --- /dev/null +++ b/features/poll/test/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.poll.test" +} + +dependencies { + implementation(projects.libraries.matrix.api) + api(projects.features.poll.api) + implementation(libs.kotlinx.collections.immutable) + implementation(projects.tests.testutils) +} diff --git a/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeEndPollAction.kt b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeEndPollAction.kt new file mode 100644 index 0000000..1a0b8b2 --- /dev/null +++ b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeEndPollAction.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.test.actions + +import io.element.android.features.poll.api.actions.EndPollAction +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline + +class FakeEndPollAction : EndPollAction { + private var executionCount = 0 + + fun verifyExecutionCount(count: Int) { + assert(executionCount == count) + } + + override suspend fun execute(timeline: Timeline, pollStartId: EventId): Result { + executionCount++ + return Result.success(Unit) + } +} diff --git a/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeSendPollResponseAction.kt b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeSendPollResponseAction.kt new file mode 100644 index 0000000..1a779d5 --- /dev/null +++ b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeSendPollResponseAction.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.test.actions + +import io.element.android.features.poll.api.actions.SendPollResponseAction +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline + +class FakeSendPollResponseAction : SendPollResponseAction { + private var executionCount = 0 + + fun verifyExecutionCount(count: Int) { + assert(executionCount == count) + } + + override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerId: String): Result { + executionCount++ + return Result.success(Unit) + } +} diff --git a/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/create/FakeCreatePollEntryPoint.kt b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/create/FakeCreatePollEntryPoint.kt new file mode 100644 index 0000000..bad968d --- /dev/null +++ b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/create/FakeCreatePollEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.test.create + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.poll.api.create.CreatePollEntryPoint +import io.element.android.features.poll.api.create.CreatePollEntryPoint.Params +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeCreatePollEntryPoint : CreatePollEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + ): Node = lambdaError() +} diff --git a/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/history/FakePollHistoryEntryPoint.kt b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/history/FakePollHistoryEntryPoint.kt new file mode 100644 index 0000000..2c211c8 --- /dev/null +++ b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/history/FakePollHistoryEntryPoint.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.test.history + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.poll.api.history.PollHistoryEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePollHistoryEntryPoint : PollHistoryEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + ): Node = lambdaError() +} diff --git a/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/pollcontent/FakePollContentStateFactory.kt b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/pollcontent/FakePollContentStateFactory.kt new file mode 100644 index 0000000..87755af --- /dev/null +++ b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/pollcontent/FakePollContentStateFactory.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.poll.test.pollcontent + +import io.element.android.features.poll.api.pollcontent.PollAnswerItem +import io.element.android.features.poll.api.pollcontent.PollContentState +import io.element.android.features.poll.api.pollcontent.PollContentStateFactory +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +import kotlinx.collections.immutable.toImmutableList + +class FakePollContentStateFactory : PollContentStateFactory { + override suspend fun create(eventId: EventId?, isEditable: Boolean, isOwn: Boolean, content: PollContent): PollContentState { + return PollContentState( + eventId = eventId, + question = content.question, + answerItems = emptyList().toImmutableList(), + pollKind = content.kind, + isPollEditable = isEditable, + isPollEnded = content.endTime != null, + isMine = isOwn, + ) + } +} diff --git a/features/preferences/api/build.gradle.kts b/features/preferences/api/build.gradle.kts new file mode 100644 index 0000000..b5a08b9 --- /dev/null +++ b/features/preferences/api/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") + id("kotlin-parcelize") +} + +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 0000000..82fcd10 --- /dev/null +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/CacheService.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 +} 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 0000000..82ff1e7 --- /dev/null +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.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.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.parcelize.Parcelize + +interface PreferencesEntryPoint : FeatureEntryPoint { + sealed interface InitialTarget : Parcelable { + @Parcelize + data object Root : InitialTarget + + @Parcelize + data object NotificationSettings : InitialTarget + + @Parcelize + data object NotificationTroubleshoot : InitialTarget + } + + data class Params(val initialElement: InitialTarget) : NodeInputs + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + callback: Callback, + ): Node + + interface Callback : Plugin { + fun navigateToAddAccount() + fun navigateToBugReport() + fun navigateToSecureBackup() + fun navigateToRoomNotificationSettings(roomId: RoomId) + fun navigateToEvent(roomId: RoomId, eventId: EventId) + } +} diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts new file mode 100644 index 0000000..ad28c90 --- /dev/null +++ b/features/preferences/impl/build.gradle.kts @@ -0,0 +1,121 @@ +import config.BuildTimeConfig +import extension.buildConfigFieldStr +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.preferences.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + + buildFeatures { + buildConfig = true + } + + defaultConfig { + buildConfigFieldStr( + name = "URL_COPYRIGHT", + value = BuildTimeConfig.URL_COPYRIGHT ?: "https://element.io/copyright", + ) + buildConfigFieldStr( + name = "URL_ACCEPTABLE_USE", + value = BuildTimeConfig.URL_ACCEPTABLE_USE ?: "https://element.io/acceptable-use-policy-terms", + ) + buildConfigFieldStr( + name = "URL_PRIVACY", + value = BuildTimeConfig.URL_PRIVACY ?: "https://element.io/privacy", + ) + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.libraries.androidutils) + implementation(projects.appconfig) + 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.pushstore.api) + implementation(projects.libraries.indicator.api) + implementation(projects.libraries.preferences.api) + implementation(projects.libraries.troubleshoot.api) + implementation(projects.libraries.testtags) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.mediapickers.api) + implementation(projects.libraries.mediaupload.api) + implementation(projects.libraries.permissions.api) + implementation(projects.libraries.push.api) + implementation(projects.libraries.pushproviders.api) + implementation(projects.libraries.uiUtils) + implementation(projects.libraries.fullscreenintent.api) + implementation(projects.features.rageshake.api) + implementation(projects.features.lockscreen.api) + implementation(projects.features.analytics.api) + implementation(projects.features.enterprise.api) + implementation(projects.features.licenses.api) + implementation(projects.features.logout.api) + implementation(projects.features.deactivation.api) + implementation(projects.features.home.api) + implementation(projects.features.invite.api) + implementation(projects.services.analytics.api) + implementation(projects.services.analytics.compose) + implementation(projects.services.appnavstate.api) + implementation(projects.services.toolbox.api) + implementation(libs.datetime) + implementation(libs.coil.compose) + implementation(libs.color.picker) + implementation(libs.androidx.browser) + implementation(libs.androidx.datastore.preferences) + api(projects.features.preferences.api) + implementation(libs.showkase) + + implementation(platform(libs.network.okhttp.bom)) + implementation(libs.network.okhttp) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.libraries.mediapickers.test) + testImplementation(projects.libraries.mediaupload.test) + testImplementation(projects.libraries.permissions.test) + testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.push.test) + testImplementation(projects.libraries.pushstore.test) + testImplementation(projects.libraries.roomselect.test) + testImplementation(projects.libraries.troubleshoot.test) + testImplementation(projects.features.deactivation.test) + testImplementation(projects.features.enterprise.test) + testImplementation(projects.features.invite.test) + testImplementation(projects.features.licenses.test) + testImplementation(projects.features.lockscreen.test) + testImplementation(projects.features.rageshake.test) + testImplementation(projects.features.logout.test) + testImplementation(projects.libraries.indicator.test) + testImplementation(projects.libraries.pushproviders.test) + testImplementation(projects.libraries.sessionStorage.test) + testImplementation(projects.services.appnavstate.impl) + testImplementation(projects.services.analytics.test) + testImplementation(projects.services.toolbox.test) +} 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 0000000..34ea628 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultCacheService.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.features.preferences.api.CacheService +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultCacheService : CacheService { + private val _clearedCacheEventFlow = MutableSharedFlow(0) + override val clearedCacheEventFlow: Flow = _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 0000000..4348b33 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.preferences.api.PreferencesEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultPreferencesEntryPoint : PreferencesEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: PreferencesEntryPoint.Params, + callback: PreferencesEntryPoint.Callback, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf(params, callback) + ) + } +} + +internal fun PreferencesEntryPoint.InitialTarget.toNavTarget() = when (this) { + is PreferencesEntryPoint.InitialTarget.Root -> PreferencesFlowNode.NavTarget.Root + is PreferencesEntryPoint.InitialTarget.NotificationSettings -> PreferencesFlowNode.NavTarget.NotificationSettings + PreferencesEntryPoint.InitialTarget.NotificationTroubleshoot -> PreferencesFlowNode.NavTarget.TroubleshootNotifications +} 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 0000000..c7328fb --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.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.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.deactivation.api.AccountDeactivationEntryPoint +import io.element.android.features.licenses.api.OpenSourceLicensesEntryPoint +import io.element.android.features.lockscreen.api.LockScreenEntryPoint +import io.element.android.features.logout.api.LogoutEntryPoint +import io.element.android.features.preferences.api.PreferencesEntryPoint +import io.element.android.features.preferences.impl.about.AboutNode +import io.element.android.features.preferences.impl.advanced.AdvancedSettingsNode +import io.element.android.features.preferences.impl.analytics.AnalyticsSettingsNode +import io.element.android.features.preferences.impl.blockedusers.BlockedUsersNode +import io.element.android.features.preferences.impl.developer.DeveloperSettingsNode +import io.element.android.features.preferences.impl.labs.LabsNode +import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode +import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingNode +import io.element.android.features.preferences.impl.root.PreferencesRootNode +import io.element.android.features.preferences.impl.user.editprofile.EditUserProfileNode +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.appyx.canPop +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope +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.user.MatrixUser +import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint +import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +@AssistedInject +class PreferencesFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val lockScreenEntryPoint: LockScreenEntryPoint, + private val notificationTroubleShootEntryPoint: NotificationTroubleShootEntryPoint, + private val pushHistoryEntryPoint: PushHistoryEntryPoint, + private val logoutEntryPoint: LogoutEntryPoint, + private val openSourceLicensesEntryPoint: OpenSourceLicensesEntryPoint, + private val accountDeactivationEntryPoint: AccountDeactivationEntryPoint, +) : BaseFlowNode( + backstack = BackStack( + initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(), + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +) { + sealed interface NavTarget : Parcelable { + @Parcelize + data object Root : NavTarget + + @Parcelize + data object DeveloperSettings : NavTarget + + @Parcelize + data object AdvancedSettings : NavTarget + + @Parcelize + data object Labs : NavTarget + + @Parcelize + data object AnalyticsSettings : NavTarget + + @Parcelize + data object About : NavTarget + + @Parcelize + data object NotificationSettings : NavTarget + + @Parcelize + data object TroubleshootNotifications : NavTarget + + @Parcelize + data object PushHistory : NavTarget + + @Parcelize + data object LockScreenSettings : NavTarget + + @Parcelize + data class EditDefaultNotificationSetting(val isOneToOne: Boolean) : NavTarget + + @Parcelize + data class UserProfile(val matrixUser: MatrixUser) : NavTarget + + @Parcelize + data object BlockedUsers : NavTarget + + @Parcelize + data object SignOut : NavTarget + + @Parcelize + data object AccountDeactivation : NavTarget + + @Parcelize + data object OssLicenses : NavTarget + } + + private val callback: PreferencesEntryPoint.Callback = callback() + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Root -> { + val callback = object : PreferencesRootNode.Callback { + override fun navigateToAddAccount() { + callback.navigateToAddAccount() + } + + override fun navigateToBugReport() { + callback.navigateToBugReport() + } + + override fun navigateToSecureBackup() { + callback.navigateToSecureBackup() + } + + override fun navigateToAnalyticsSettings() { + backstack.push(NavTarget.AnalyticsSettings) + } + + override fun navigateToAbout() { + backstack.push(NavTarget.About) + } + + override fun navigateToDeveloperSettings() { + backstack.push(NavTarget.DeveloperSettings) + } + + override fun navigateToNotificationSettings() { + backstack.push(NavTarget.NotificationSettings) + } + + override fun navigateToLockScreenSettings() { + backstack.push(NavTarget.LockScreenSettings) + } + + override fun navigateToAdvancedSettings() { + backstack.push(NavTarget.AdvancedSettings) + } + + override fun navigateToLabs() { + backstack.push(NavTarget.Labs) + } + + override fun navigateToUserProfile(matrixUser: MatrixUser) { + backstack.push(NavTarget.UserProfile(matrixUser)) + } + + override fun navigateToBlockedUsers() { + backstack.push(NavTarget.BlockedUsers) + } + + override fun startSignOutFlow() { + backstack.push(NavTarget.SignOut) + } + + override fun startAccountDeactivationFlow() { + backstack.push(NavTarget.AccountDeactivation) + } + } + createNode(buildContext, plugins = listOf(callback)) + } + NavTarget.DeveloperSettings -> { + val developerSettingsCallback = object : DeveloperSettingsNode.Callback { + override fun navigateToPushHistory() { + backstack.push(NavTarget.PushHistory) + } + + override fun onDone() { + backstack.pop() + } + } + createNode(buildContext, listOf(developerSettingsCallback)) + } + NavTarget.Labs -> { + val callback = object : LabsNode.Callback { + override fun onDone() { + backstack.pop() + } + } + createNode(buildContext, listOf(callback)) + } + NavTarget.About -> { + val callback = object : AboutNode.Callback { + override fun navigateToOssLicenses() { + backstack.push(NavTarget.OssLicenses) + } + } + createNode(buildContext, listOf(callback)) + } + NavTarget.AnalyticsSettings -> { + createNode(buildContext) + } + NavTarget.NotificationSettings -> { + val notificationSettingsCallback = object : NotificationSettingsNode.Callback { + override fun navigateToEditDefaultNotificationSetting(isOneToOne: Boolean) { + backstack.push(NavTarget.EditDefaultNotificationSetting(isOneToOne)) + } + + override fun navigateToTroubleshootNotifications() { + backstack.push(NavTarget.TroubleshootNotifications) + } + } + createNode(buildContext, listOf(notificationSettingsCallback)) + } + NavTarget.TroubleshootNotifications -> { + notificationTroubleShootEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + callback = object : NotificationTroubleShootEntryPoint.Callback { + override fun onDone() { + if (backstack.canPop()) { + backstack.pop() + } else { + navigateUp() + } + } + + override fun navigateToBlockedUsers() { + backstack.push(NavTarget.BlockedUsers) + } + }, + ) + } + NavTarget.PushHistory -> { + pushHistoryEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + callback = object : PushHistoryEntryPoint.Callback { + override fun onDone() { + if (backstack.canPop()) { + backstack.pop() + } else { + navigateUp() + } + } + + override fun navigateToEvent(roomId: RoomId, eventId: EventId) { + callback.navigateToEvent(roomId, eventId) + } + }, + ) + } + is NavTarget.EditDefaultNotificationSetting -> { + val callback = object : EditDefaultNotificationSettingNode.Callback { + override fun navigateToRoomNotificationSettings(roomId: RoomId) { + callback.navigateToRoomNotificationSettings(roomId) + } + } + val input = EditDefaultNotificationSettingNode.Inputs(navTarget.isOneToOne) + createNode(buildContext, plugins = listOf(input, callback)) + } + NavTarget.AdvancedSettings -> { + createNode(buildContext) + } + is NavTarget.UserProfile -> { + val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser) + val callback = object : EditUserProfileNode.Callback { + override fun onDone() { + backstack.pop() + } + } + createNode(buildContext, listOf(inputs, callback)) + } + NavTarget.LockScreenSettings -> { + lockScreenEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + navTarget = LockScreenEntryPoint.Target.Settings, + callback = object : LockScreenEntryPoint.Callback { + override fun onSetupDone() { + // No op + } + } + ) + } + NavTarget.BlockedUsers -> { + createNode(buildContext) + } + NavTarget.SignOut -> { + val callBack: LogoutEntryPoint.Callback = object : LogoutEntryPoint.Callback { + override fun navigateToSecureBackup() { + callback.navigateToSecureBackup() + } + } + logoutEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + callback = callBack, + ) + } + is NavTarget.OssLicenses -> { + openSourceLicensesEntryPoint.createNode(this, buildContext) + } + NavTarget.AccountDeactivation -> { + accountDeactivationEntryPoint.createNode(this, buildContext) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView() + } +} 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 0000000..4ece133 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutNode.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.about + +import android.app.Activity +import androidx.activity.compose.LocalActivity +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class AboutNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: AboutPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun navigateToOssLicenses() + } + + private val callback: Callback = callback() + + private fun onElementLegalClick( + activity: Activity, + darkTheme: Boolean, + elementLegal: ElementLegal, + ) { + activity.openUrlInChromeCustomTab(null, darkTheme, elementLegal.url) + } + + @Composable + override fun View(modifier: Modifier) { + val activity = requireNotNull(LocalActivity.current) + val isDark = ElementTheme.isLightTheme.not() + val state = presenter.present() + AboutView( + state = state, + onBackClick = ::navigateUp, + onElementLegalClick = { elementLegal -> + onElementLegalClick(activity, isDark, elementLegal) + }, + onOpenSourceLicensesClick = callback::navigateToOssLicenses, + 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 0000000..6abf896 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutPresenter.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.about + +import androidx.compose.runtime.Composable +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter + +@Inject +class AboutPresenter : Presenter { + @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 0000000..165585b --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutState.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.about + +import kotlinx.collections.immutable.ImmutableList + +data class AboutState( + val elementLegals: ImmutableList, +) 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 0000000..561fe53 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutStateProvider.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.about + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import kotlinx.collections.immutable.toImmutableList + +open class AboutStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anAboutState(), + ) +} + +fun anAboutState( + elementLegals: List = getAllLegals(), +) = AboutState( + elementLegals = elementLegals.toImmutableList(), +) 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 0000000..b71db18 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.PreviewParameter +import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun AboutView( + state: AboutState, + onElementLegalClick: (ElementLegal) -> Unit, + onOpenSourceLicensesClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + PreferencePage( + modifier = modifier, + onBackClick = onBackClick, + title = stringResource(id = CommonStrings.common_about) + ) { + state.elementLegals.forEach { elementLegal -> + ListItem( + headlineContent = { + Text(stringResource(id = elementLegal.titleRes)) + }, + onClick = { onElementLegalClick(elementLegal) } + ) + } + ListItem( + headlineContent = { + Text(stringResource(id = CommonStrings.common_open_source_licenses)) + }, + onClick = onOpenSourceLicensesClick, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun AboutViewPreview(@PreviewParameter(AboutStateProvider::class) state: AboutState) = ElementPreview { + AboutView( + state = state, + onElementLegalClick = {}, + onOpenSourceLicensesClick = {}, + onBackClick = {}, + ) +} 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 0000000..0f3c25a --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/ElementLegal.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2021-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.about + +import androidx.annotation.StringRes +import io.element.android.features.preferences.impl.BuildConfig +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +private const val COPYRIGHT_URL = BuildConfig.URL_COPYRIGHT +private const val USE_POLICY_URL = BuildConfig.URL_ACCEPTABLE_USE +private const val PRIVACY_URL = BuildConfig.URL_PRIVACY + +sealed class ElementLegal( + @StringRes val titleRes: Int, + val url: String, +) { + data object Copyright : ElementLegal(CommonStrings.common_copyright, COPYRIGHT_URL) + data object AcceptableUsePolicy : ElementLegal(CommonStrings.common_acceptable_use_policy, USE_POLICY_URL) + data object PrivacyPolicy : ElementLegal(CommonStrings.common_privacy_policy, PRIVACY_URL) +} + +fun getAllLegals(): ImmutableList { + return persistentListOf( + ElementLegal.Copyright, + ElementLegal.AcceptableUsePolicy, + ElementLegal.PrivacyPolicy, + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt new file mode 100644 index 0000000..b3fb68f --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.advanced + +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset + +sealed interface AdvancedSettingsEvents { + data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents + data class SetSharePresenceEnabled(val enabled: Boolean) : AdvancedSettingsEvents + data class SetCompressMedia(val compress: Boolean) : AdvancedSettingsEvents + data class SetCompressImages(val compress: Boolean) : AdvancedSettingsEvents + data class SetVideoUploadQuality(val videoPreset: VideoCompressionPreset) : AdvancedSettingsEvents + data class SetTheme(val theme: ThemeOption) : AdvancedSettingsEvents + data class SetTimelineMediaPreviewValue(val value: MediaPreviewValue) : AdvancedSettingsEvents + data class SetHideInviteAvatars(val value: Boolean) : AdvancedSettingsEvents +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt new file mode 100644 index 0000000..e58706e --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.advanced + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class AdvancedSettingsNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: AdvancedSettingsPresenter, +) : Node(buildContext, plugins = plugins) { + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + AdvancedSettingsView( + state = state, + modifier = modifier, + onBackClick = ::navigateUp + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt new file mode 100644 index 0000000..c2871e0 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.advanced + +import androidx.compose.runtime.Composable +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 dev.zacsweers.metro.Inject +import io.element.android.compound.theme.Theme +import io.element.android.compound.theme.mapToTheme +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +@Inject +class AdvancedSettingsPresenter( + private val appPreferencesStore: AppPreferencesStore, + private val sessionPreferencesStore: SessionPreferencesStore, + private val mediaPreviewConfigStateStore: MediaPreviewConfigStateStore, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, + private val featureFlagService: FeatureFlagService, +) : Presenter { + @Composable + override fun present(): AdvancedSettingsState { + val isDeveloperModeEnabled by remember { + appPreferencesStore.isDeveloperModeEnabledFlow() + }.collectAsState(initial = false) + val isSharePresenceEnabled by remember { + sessionPreferencesStore.isSharePresenceEnabled() + }.collectAsState(initial = true) + val theme = remember { + appPreferencesStore.getThemeFlow().mapToTheme() + }.collectAsState(initial = Theme.System) + + val mediaPreviewConfigState = mediaPreviewConfigStateStore.state() + + val themeOption by remember { + derivedStateOf { + when (theme.value) { + Theme.System -> ThemeOption.System + Theme.Dark -> ThemeOption.Dark + Theme.Light -> ThemeOption.Light + } + } + } + + val hasSplitMediaQualityOptions by produceState(null) { + value = featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality) + } + + val mediaOptimizationState by produceState(null) { + val hasSplitMediaQualityOptionsFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.SelectableMediaQuality) + combine( + hasSplitMediaQualityOptionsFlow, + sessionPreferencesStore.doesOptimizeImages(), + sessionPreferencesStore.getVideoCompressionPreset() + ) { hasSplitOptions, compressImages, videoPreset -> + if (hasSplitMediaQualityOptions == true) { + value = MediaOptimizationState.Split( + compressImages = compressImages, + videoPreset = videoPreset, + ) + } else if (hasSplitMediaQualityOptions == false) { + value = MediaOptimizationState.AllMedia(isEnabled = compressImages) + } + }.collect() + } + + fun handleEvent(event: AdvancedSettingsEvents) { + when (event) { + is AdvancedSettingsEvents.SetDeveloperModeEnabled -> sessionCoroutineScope.launch { + appPreferencesStore.setDeveloperModeEnabled(event.enabled) + } + is AdvancedSettingsEvents.SetSharePresenceEnabled -> sessionCoroutineScope.launch { + sessionPreferencesStore.setSharePresence(event.enabled) + } + is AdvancedSettingsEvents.SetCompressMedia -> sessionCoroutineScope.launch { + sessionPreferencesStore.setOptimizeImages(event.compress) + } + is AdvancedSettingsEvents.SetTheme -> sessionCoroutineScope.launch { + when (event.theme) { + ThemeOption.System -> appPreferencesStore.setTheme(Theme.System.name) + ThemeOption.Dark -> appPreferencesStore.setTheme(Theme.Dark.name) + ThemeOption.Light -> appPreferencesStore.setTheme(Theme.Light.name) + } + } + is AdvancedSettingsEvents.SetHideInviteAvatars -> mediaPreviewConfigStateStore.setHideInviteAvatars(event.value) + is AdvancedSettingsEvents.SetTimelineMediaPreviewValue -> mediaPreviewConfigStateStore.setTimelineMediaPreviewValue(event.value) + is AdvancedSettingsEvents.SetCompressImages -> sessionCoroutineScope.launch { + sessionPreferencesStore.setOptimizeImages(event.compress) + } + is AdvancedSettingsEvents.SetVideoUploadQuality -> sessionCoroutineScope.launch { + sessionPreferencesStore.setVideoCompressionPreset(event.videoPreset) + } + } + } + + return AdvancedSettingsState( + isDeveloperModeEnabled = isDeveloperModeEnabled, + isSharePresenceEnabled = isSharePresenceEnabled, + mediaOptimizationState = mediaOptimizationState, + theme = themeOption, + mediaPreviewConfigState = mediaPreviewConfigState, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt new file mode 100644 index 0000000..6eb7414 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.advanced + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.designsystem.components.preferences.DropdownOption +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.ui.strings.CommonStrings + +data class AdvancedSettingsState( + val isDeveloperModeEnabled: Boolean, + val isSharePresenceEnabled: Boolean, + val mediaOptimizationState: MediaOptimizationState?, + val theme: ThemeOption, + val mediaPreviewConfigState: MediaPreviewConfigState, + val eventSink: (AdvancedSettingsEvents) -> Unit +) + +sealed interface MediaOptimizationState { + data class AllMedia(val isEnabled: Boolean) : MediaOptimizationState + data class Split( + val compressImages: Boolean, + val videoPreset: VideoCompressionPreset, + ) : MediaOptimizationState + + val shouldCompressImages: Boolean get() = when (this) { + is AllMedia -> isEnabled + is Split -> compressImages + } +} + +enum class ThemeOption : DropdownOption { + System { + @Composable + @ReadOnlyComposable + override fun getText(): String = stringResource(CommonStrings.common_system) + }, + Dark { + @Composable + @ReadOnlyComposable + override fun getText(): String = stringResource(CommonStrings.common_dark) + }, + Light { + @Composable + @ReadOnlyComposable + override fun getText(): String = stringResource(CommonStrings.common_light) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt new file mode 100644 index 0000000..6cbe6e5 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.advanced + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset + +open class AdvancedSettingsStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aAdvancedSettingsState(), + aAdvancedSettingsState(isDeveloperModeEnabled = true), + aAdvancedSettingsState(isSharePresenceEnabled = true), + aAdvancedSettingsState(mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true)), + aAdvancedSettingsState(hideInviteAvatars = true), + aAdvancedSettingsState(timelineMediaPreviewValue = MediaPreviewValue.Off), + aAdvancedSettingsState(setHideInviteAvatarsAction = AsyncAction.Loading), + aAdvancedSettingsState(setTimelineMediaPreviewAction = AsyncAction.Loading), + aAdvancedSettingsState(mediaOptimizationState = MediaOptimizationState.Split( + compressImages = true, + videoPreset = VideoCompressionPreset.HIGH, + )), + ) +} + +fun aAdvancedSettingsState( + isDeveloperModeEnabled: Boolean = false, + isSharePresenceEnabled: Boolean = false, + mediaOptimizationState: MediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = false), + theme: ThemeOption = ThemeOption.System, + hideInviteAvatars: Boolean = false, + timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On, + setTimelineMediaPreviewAction: AsyncAction = AsyncAction.Uninitialized, + setHideInviteAvatarsAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (AdvancedSettingsEvents) -> Unit = {}, +) = AdvancedSettingsState( + isDeveloperModeEnabled = isDeveloperModeEnabled, + isSharePresenceEnabled = isSharePresenceEnabled, + mediaOptimizationState = mediaOptimizationState, + theme = theme, + mediaPreviewConfigState = MediaPreviewConfigState( + hideInviteAvatars = hideInviteAvatars, + timelineMediaPreviewValue = timelineMediaPreviewValue, + setTimelineMediaPreviewAction = setTimelineMediaPreviewAction, + setHideInviteAvatarsAction = setHideInviteAvatarsAction + ), + eventSink = eventSink +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt new file mode 100644 index 0000000..b518dae --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt @@ -0,0 +1,346 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.advanced + +import androidx.compose.foundation.layout.navigationBarsPadding +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.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import im.vector.app.features.analytics.plan.Interaction +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage +import io.element.android.libraries.designsystem.components.dialogs.ListDialog +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown +import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListSectionHeader +import io.element.android.libraries.designsystem.theme.components.ListSupportingText +import io.element.android.libraries.designsystem.theme.components.ListSupportingTextDefaults +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.analytics.compose.LocalAnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun AdvancedSettingsView( + state: AdvancedSettingsState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val analyticsService = LocalAnalyticsService.current + + val snackbarDispatcher = LocalSnackbarDispatcher.current + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = snackbarMessage) + + PreferencePage( + modifier = modifier, + onBackClick = onBackClick, + title = stringResource(id = CommonStrings.common_advanced_settings), + snackbarHost = { + SnackbarHost( + snackbarHostState, + modifier = Modifier.navigationBarsPadding() + ) + } + ) { + PreferenceDropdown( + title = stringResource(id = CommonStrings.common_appearance), + selectedOption = state.theme, + options = ThemeOption.entries.toImmutableList(), + onSelectOption = { themeOption -> + state.eventSink(AdvancedSettingsEvents.SetTheme(themeOption)) + } + ) + ListItem( + headlineContent = { + Text(text = stringResource(id = CommonStrings.action_view_source)) + }, + supportingContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_view_source_description)) + }, + trailingContent = ListItemContent.Switch( + checked = state.isDeveloperModeEnabled, + ), + onClick = { state.eventSink(AdvancedSettingsEvents.SetDeveloperModeEnabled(!state.isDeveloperModeEnabled)) } + ) + ListItem( + headlineContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_share_presence)) + }, + supportingContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_share_presence_description)) + }, + trailingContent = ListItemContent.Switch( + checked = state.isSharePresenceEnabled, + ), + onClick = { state.eventSink(AdvancedSettingsEvents.SetSharePresenceEnabled(!state.isSharePresenceEnabled)) } + ) + val compressImages = state.mediaOptimizationState?.shouldCompressImages + + when (state.mediaOptimizationState) { + null -> Unit + is MediaOptimizationState.AllMedia -> { + ListItem( + headlineContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_title)) + }, + supportingContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_description)) + }, + trailingContent = ListItemContent.Switch( + checked = compressImages ?: false, + ), + onClick = { + val newValue = !(compressImages ?: false) + analyticsService.captureInteraction( + if (newValue) { + Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled + } else { + Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled + } + ) + state.eventSink(AdvancedSettingsEvents.SetCompressMedia(newValue)) + } + ) + } + is MediaOptimizationState.Split -> { + ListItem( + headlineContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_title)) + }, + supportingContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_description)) + }, + trailingContent = ListItemContent.Switch( + checked = compressImages ?: false, + ), + onClick = { + val newValue = !(compressImages ?: false) + analyticsService.captureInteraction( + if (newValue) { + Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled + } else { + Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled + } + ) + state.eventSink(AdvancedSettingsEvents.SetCompressMedia(newValue)) + } + ) + + var displaySelectorDialog by remember { mutableStateOf(false) } + + ListItem( + headlineContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_title)) + }, + supportingContent = { + val description = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_description) + val quality = when (state.mediaOptimizationState.videoPreset) { + VideoCompressionPreset.LOW -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_low) + VideoCompressionPreset.STANDARD -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_standard) + VideoCompressionPreset.HIGH -> stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_high) + } + val descriptionWithValue = remember(quality) { + String.format(description, quality) + } + Text(text = descriptionWithValue) + }, + onClick = { displaySelectorDialog = true }, + ) + + if (displaySelectorDialog) { + VideoQualitySelectorDialog( + selectedPreset = state.mediaOptimizationState.videoPreset, + onSubmit = { preset -> + state.eventSink(AdvancedSettingsEvents.SetVideoUploadQuality(preset)) + displaySelectorDialog = false + }, + onDismiss = { displaySelectorDialog = false }, + ) + } + } + } + + ModerationAndSafety(state) + } +} + +@Composable +private fun VideoQualitySelectorDialog( + selectedPreset: VideoCompressionPreset, + onSubmit: (VideoCompressionPreset) -> Unit, + onDismiss: () -> Unit +) { + val videoPresets = VideoCompressionPreset.entries + var localSelectedPreset by remember { mutableStateOf(selectedPreset) } + ListDialog( + title = stringResource(CommonStrings.dialog_video_quality_selector_title), + subtitle = stringResource(CommonStrings.dialog_default_video_quality_selector_subtitle), + onSubmit = { onSubmit(localSelectedPreset) }, + onDismissRequest = onDismiss, + applyPaddingToContents = false, + ) { + for (preset in videoPresets) { + val isSelected = preset == localSelectedPreset + item( + key = preset, + contentType = preset, + ) { + val title = when (preset) { + VideoCompressionPreset.LOW -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_low) + VideoCompressionPreset.STANDARD -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_standard) + VideoCompressionPreset.HIGH -> stringResource(R.string.screen_advanced_settings_optimise_video_upload_quality_high) + } + val subtitle = when (preset) { + VideoCompressionPreset.LOW -> stringResource(CommonStrings.common_video_quality_low_description) + VideoCompressionPreset.STANDARD -> stringResource(CommonStrings.common_video_quality_standard_description) + VideoCompressionPreset.HIGH -> stringResource(CommonStrings.common_video_quality_high_description) + } + ListItem( + headlineContent = { + Text( + text = title, + style = ElementTheme.typography.fontBodyLgMedium, + ) + }, + supportingContent = { + Text( + text = subtitle, + style = ElementTheme.materialTypography.bodyMedium, + color = ElementTheme.colors.textSecondary, + ) + }, + leadingContent = ListItemContent.RadioButton( + selected = isSelected, + ), + onClick = { + localSelectedPreset = preset + }, + ) + } + } + } +} + +@Composable +private fun ModerationAndSafety( + state: AdvancedSettingsState, + modifier: Modifier = Modifier, +) { + PreferenceCategory( + modifier = modifier, + title = stringResource(R.string.screen_advanced_settings_moderation_and_safety_section_title), + showTopDivider = true + ) { + PreferenceSwitch( + title = stringResource(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title), + isChecked = state.mediaPreviewConfigState.hideInviteAvatars, + onCheckedChange = { + state.eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(it)) + }, + enabled = !state.mediaPreviewConfigState.setHideInviteAvatarsAction.isLoading() + ) + ListSectionHeader( + title = stringResource(R.string.screen_advanced_settings_show_media_timeline_title), + hasDivider = false, + description = { + ListSupportingText( + text = stringResource(R.string.screen_advanced_settings_show_media_timeline_subtitle), + contentPadding = ListSupportingTextDefaults.Padding.None, + ) + } + ) + ListItem( + headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_hide)) }, + leadingContent = ListItemContent.RadioButton( + selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Off, + compact = true + ), + onClick = { + state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) + }, + enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() + ) + ListItem( + headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_private_rooms)) }, + leadingContent = ListItemContent.RadioButton( + selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Private, + compact = true + ), + onClick = { + state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) + }, + enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() + ) + ListItem( + headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_show)) }, + leadingContent = ListItemContent.RadioButton( + selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.On, + compact = true + ), + onClick = { + state.eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.On)) + }, + enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading() + ) + } +} + +@PreviewWithLargeHeight +@Composable +internal fun AdvancedSettingsViewLightPreview(@PreviewParameter(AdvancedSettingsStateProvider::class) state: AdvancedSettingsState) = + ElementPreviewLight { ContentToPreview(state) } + +@PreviewWithLargeHeight +@Composable +internal fun AdvancedSettingsViewDarkPreview(@PreviewParameter(AdvancedSettingsStateProvider::class) state: AdvancedSettingsState) = + ElementPreviewDark { ContentToPreview(state) } + +@ExcludeFromCoverage +@Composable +private fun ContentToPreview(state: AdvancedSettingsState) { + AdvancedSettingsView( + state = state, + onBackClick = { } + ) +} + +@Composable +@PreviewsDayNight +internal fun VideoQualitySelectorDialogPreview() { + ElementPreview { + VideoQualitySelectorDialog( + selectedPreset = VideoCompressionPreset.STANDARD, + onSubmit = { /* no-op */ }, + onDismiss = { /* no-op */ } + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStore.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStore.kt new file mode 100644 index 0000000..d23168e --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStore.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.advanced + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.media.MediaPreviewService +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber + +data class MediaPreviewConfigState( + val hideInviteAvatars: Boolean, + val timelineMediaPreviewValue: MediaPreviewValue, + val setHideInviteAvatarsAction: AsyncAction, + val setTimelineMediaPreviewAction: AsyncAction, +) + +interface MediaPreviewConfigStateStore { + @Composable + fun state(): MediaPreviewConfigState + fun setHideInviteAvatars(hide: Boolean) + fun setTimelineMediaPreviewValue(value: MediaPreviewValue) +} + +@ContributesBinding(SessionScope::class) +@SingleIn(SessionScope::class) +class DefaultMediaPreviewConfigStateStore( + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, + private val mediaPreviewService: MediaPreviewService, + private val snackbarDispatcher: SnackbarDispatcher, +) : MediaPreviewConfigStateStore { + private val hideInviteAvatars = mutableStateOf(false) + private val timelineMediaPreviewValue = mutableStateOf(MediaPreviewValue.On) + private val setHideInviteAvatarsAction = mutableStateOf>(AsyncAction.Uninitialized) + private val setTimelineMediaPreviewAction = mutableStateOf>(AsyncAction.Uninitialized) + + init { + val configFlow = mediaPreviewService.mediaPreviewConfigFlow + val hideInviteAvatarsFlow = configFlow.map { it.hideInviteAvatar }.distinctUntilChanged() + val timelineMediaPreviewFlow = configFlow.map { it.mediaPreviewValue }.distinctUntilChanged() + + hideInviteAvatarsFlow + .onEach { + Timber.d("Hide invite avatars changed to $it") + hideInviteAvatars.value = it + } + .launchIn(sessionCoroutineScope) + + timelineMediaPreviewFlow + .onEach { + Timber.d("Timeline media preview value changed to $it") + timelineMediaPreviewValue.value = it + } + .launchIn(sessionCoroutineScope) + } + + @Composable + override fun state(): MediaPreviewConfigState { + return MediaPreviewConfigState( + hideInviteAvatars = hideInviteAvatars.value, + timelineMediaPreviewValue = timelineMediaPreviewValue.value, + setHideInviteAvatarsAction = setHideInviteAvatarsAction.value, + setTimelineMediaPreviewAction = setTimelineMediaPreviewAction.value, + ) + } + + override fun setHideInviteAvatars(hide: Boolean) { + sessionCoroutineScope.launch { + val prevHideInviteAvatars = hideInviteAvatars.value + if (prevHideInviteAvatars == hide) return@launch + Timber.d("Setting hide invite avatars to $hide") + hideInviteAvatars.value = hide + runUpdatingState(setHideInviteAvatarsAction) { + mediaPreviewService + .setHideInviteAvatars(hide) + .onFailure { + hideInviteAvatars.value = prevHideInviteAvatars + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_something_went_wrong_message)) + } + } + } + } + + override fun setTimelineMediaPreviewValue(value: MediaPreviewValue) { + sessionCoroutineScope.launch { + val prevTimelineMediaPreviewValue = timelineMediaPreviewValue.value + if (prevTimelineMediaPreviewValue == value) return@launch + Timber.d("Setting timeline media preview value to $value") + timelineMediaPreviewValue.value = value + runUpdatingState(setTimelineMediaPreviewAction) { + mediaPreviewService + .setMediaPreviewValue(value) + .onFailure { + timelineMediaPreviewValue.value = prevTimelineMediaPreviewValue + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_something_went_wrong_message)) + } + } + } + } +} 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 0000000..8931f7f --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsNode.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class AnalyticsSettingsNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: AnalyticsSettingsPresenter, +) : Node(buildContext, plugins = plugins) { + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + AnalyticsSettingsView( + state = state, + onBackClick = ::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 0000000..3affad5 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsPresenter.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.analytics + +import androidx.compose.runtime.Composable +import dev.zacsweers.metro.Inject +import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesState +import io.element.android.libraries.architecture.Presenter + +@Inject +class AnalyticsSettingsPresenter( + private val analyticsPreferencesPresenter: Presenter, +) : Presenter { + @Composable + override fun present(): AnalyticsSettingsState { + val analyticsPreferencesState = analyticsPreferencesPresenter.present() + + return AnalyticsSettingsState( + analyticsPreferencesState = analyticsPreferencesState, + ) + } +} 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 0000000..ba2b9df --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsState.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.analytics + +import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesState + +data class AnalyticsSettingsState( + val analyticsPreferencesState: 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 0000000..9e15ea2 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsStateProvider.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 { + override val values: Sequence + get() = sequenceOf( + aAnalyticsSettingsState(), + ) +} + +fun aAnalyticsSettingsState() = AnalyticsSettingsState( + analyticsPreferencesState = 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 0000000..ef84600 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsView.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.PreviewParameter +import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesView +import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun AnalyticsSettingsView( + state: AnalyticsSettingsState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + PreferencePage( + modifier = modifier, + onBackClick = onBackClick, + title = stringResource(id = CommonStrings.common_analytics) + ) { + AnalyticsPreferencesView( + state = state.analyticsPreferencesState, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun AnalyticsSettingsViewPreview(@PreviewParameter(AnalyticsSettingsStateProvider::class) state: AnalyticsSettingsState) = ElementPreview { + AnalyticsSettingsView( + state = state, + onBackClick = {}, + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersEvents.kt new file mode 100644 index 0000000..0db5c4c --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersEvents.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.blockedusers + +import io.element.android.libraries.matrix.api.core.UserId + +sealed interface BlockedUsersEvents { + data class Unblock(val userId: UserId) : BlockedUsersEvents + data object ConfirmUnblock : BlockedUsersEvents + data object Cancel : BlockedUsersEvents +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersNode.kt new file mode 100644 index 0000000..44e6a15 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersNode.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.blockedusers + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class BlockedUsersNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: BlockedUsersPresenter, +) : Node(buildContext = buildContext, plugins = plugins) { + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + BlockedUsersView( + state = state, + onBackClick = ::navigateUp, + modifier = modifier, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt new file mode 100644 index 0000000..fc150d0 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.blockedusers + +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.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +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 kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Inject +class BlockedUsersPresenter( + private val matrixClient: MatrixClient, + private val featureFlagService: FeatureFlagService, +) : Presenter { + @Composable + override fun present(): BlockedUsersState { + val coroutineScope = rememberCoroutineScope() + + var pendingUserToUnblock by remember { + mutableStateOf(null) + } + val unblockUserAction: MutableState> = remember { + mutableStateOf(AsyncAction.Uninitialized) + } + + val renderBlockedUsersDetail by remember { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.ShowBlockedUsersDetails) + }.collectAsState(initial = false) + val ignoredUserIds by matrixClient.ignoredUsersFlow.collectAsState() + val ignoredMatrixUser by produceState( + initialValue = ignoredUserIds.map { MatrixUser(userId = it) }, + key1 = renderBlockedUsersDetail, + key2 = ignoredUserIds + ) { + value = ignoredUserIds.map { + if (renderBlockedUsersDetail) { + matrixClient.getProfile(it).getOrNull() + } else { + null + } + ?: MatrixUser(userId = it) + } + } + + fun handleEvent(event: BlockedUsersEvents) { + when (event) { + is BlockedUsersEvents.Unblock -> { + pendingUserToUnblock = event.userId + unblockUserAction.value = AsyncAction.ConfirmingNoParams + } + BlockedUsersEvents.ConfirmUnblock -> { + pendingUserToUnblock?.let { + coroutineScope.unblockUser(it, unblockUserAction) + pendingUserToUnblock = null + } + } + BlockedUsersEvents.Cancel -> { + pendingUserToUnblock = null + unblockUserAction.value = AsyncAction.Uninitialized + } + } + } + return BlockedUsersState( + blockedUsers = ignoredMatrixUser.toImmutableList(), + unblockUserAction = unblockUserAction.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.unblockUser(userId: UserId, asyncAction: MutableState>) = launch { + runUpdatingState(asyncAction) { + matrixClient.unignoreUser(userId) + } + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersState.kt new file mode 100644 index 0000000..8c47837 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersState.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.blockedusers + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList + +data class BlockedUsersState( + val blockedUsers: ImmutableList, + val unblockUserAction: AsyncAction, + val eventSink: (BlockedUsersEvents) -> Unit, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersStateProvider.kt new file mode 100644 index 0000000..92cc8a0 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersStateProvider.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.blockedusers + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import kotlinx.collections.immutable.toImmutableList + +class BlockedUsersStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aBlockedUsersState(), + aBlockedUsersState(blockedUsers = aMatrixUserList().map { it.copy(displayName = null, avatarUrl = null) }), + aBlockedUsersState(blockedUsers = emptyList()), + aBlockedUsersState(unblockUserAction = AsyncAction.ConfirmingNoParams), + aBlockedUsersState(unblockUserAction = AsyncAction.Loading), + aBlockedUsersState(unblockUserAction = AsyncAction.Failure(RuntimeException("Failed to unblock user"))), + aBlockedUsersState(unblockUserAction = AsyncAction.Success(Unit)), + ) +} + +internal fun aBlockedUsersState( + blockedUsers: List = aMatrixUserList(), + unblockUserAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (BlockedUsersEvents) -> Unit = {}, +): BlockedUsersState { + return BlockedUsersState( + blockedUsers = blockedUsers.toImmutableList(), + unblockUserAction = unblockUserAction, + eventSink = eventSink, + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt new file mode 100644 index 0000000..40e5802 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.blockedusers + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.tooling.preview.PreviewParameter +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.components.async.AsyncIndicator +import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost +import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Scaffold +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.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.MatrixUserRow +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BlockedUsersView( + state: BlockedUsersState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + Scaffold( + topBar = { + TopAppBar( + titleStr = stringResource(CommonStrings.common_blocked_users), + navigationIcon = { + BackButton(onClick = onBackClick) + } + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier.padding(padding) + ) { + items(state.blockedUsers) { matrixUser -> + BlockedUserItem( + matrixUser = matrixUser, + onClick = { state.eventSink(BlockedUsersEvents.Unblock(it)) } + ) + } + } + } + + val asyncIndicatorState = rememberAsyncIndicatorState() + AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), state = asyncIndicatorState) + + when (state.unblockUserAction) { + is AsyncAction.Loading -> { + LaunchedEffect(state.unblockUserAction) { + asyncIndicatorState.enqueue { + AsyncIndicator.Loading(text = stringResource(R.string.screen_blocked_users_unblocking)) + } + } + } + is AsyncAction.Failure -> { + LaunchedEffect(state.unblockUserAction) { + asyncIndicatorState.enqueue(durationMs = AsyncIndicator.DURATION_SHORT) { + AsyncIndicator.Failure(text = stringResource(CommonStrings.common_failed)) + } + } + } + is AsyncAction.Success -> { + LaunchedEffect(state.unblockUserAction) { + asyncIndicatorState.clear() + } + } + is AsyncAction.Confirming -> { + ConfirmationDialog( + title = stringResource(R.string.screen_blocked_users_unblock_alert_title), + content = stringResource(R.string.screen_blocked_users_unblock_alert_description), + submitText = stringResource(R.string.screen_blocked_users_unblock_alert_action), + onSubmitClick = { state.eventSink(BlockedUsersEvents.ConfirmUnblock) }, + onDismiss = { state.eventSink(BlockedUsersEvents.Cancel) } + ) + } + else -> Unit + } + } +} + +@Composable +private fun BlockedUserItem( + matrixUser: MatrixUser, + onClick: (UserId) -> Unit, +) { + MatrixUserRow( + modifier = Modifier.clickable { onClick(matrixUser.userId) }, + matrixUser = matrixUser, + ) +} + +@PreviewsDayNight +@Composable +internal fun BlockedUsersViewPreview(@PreviewParameter(BlockedUsersStateProvider::class) state: BlockedUsersState) { + ElementPreview { + BlockedUsersView( + state = state, + onBackClick = {} + ) + } +} 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 0000000..3bf4f37 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.developer + +import androidx.compose.ui.graphics.Color +import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem +import io.element.android.libraries.featureflag.ui.model.FeatureUiModel +import io.element.android.libraries.matrix.api.tracing.TraceLogPack + +sealed interface DeveloperSettingsEvents { + data class UpdateEnabledFeature(val feature: FeatureUiModel, val isEnabled: Boolean) : DeveloperSettingsEvents + data class SetCustomElementCallBaseUrl(val baseUrl: String?) : DeveloperSettingsEvents + data class SetTracingLogLevel(val logLevel: LogLevelItem) : DeveloperSettingsEvents + data class ToggleTracingLogPack(val logPack: TraceLogPack, val enabled: Boolean) : DeveloperSettingsEvents + data class SetShowColorPicker(val show: Boolean) : DeveloperSettingsEvents + data class ChangeBrandColor(val color: Color?) : DeveloperSettingsEvents + data 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 0000000..98c7d89 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.developer + +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.airbnb.android.showkase.models.Showkase +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.designsystem.showkase.getBrowserIntent +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class DeveloperSettingsNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: DeveloperSettingsPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun navigateToPushHistory() + fun onDone() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val activity = requireNotNull(LocalActivity.current) + fun openShowkase() { + val intent = Showkase.getBrowserIntent(activity) + activity.startActivity(intent) + } + + val state = presenter.present() + DeveloperSettingsView( + state = state, + modifier = modifier, + onOpenShowkase = ::openShowkase, + onPushHistoryClick = callback::navigateToPushHistory, + onBackClick = callback::onDone, + ) + } +} 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 0000000..52e522d --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf +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 androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.graphics.toArgb +import dev.zacsweers.metro.Inject +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.preferences.impl.developer.tracing.toLogLevel +import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem +import io.element.android.features.preferences.impl.model.EnabledFeature +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.RageshakePreferencesState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType +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 io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.net.URL + +@Inject +class DeveloperSettingsPresenter( + private val sessionId: SessionId, + private val featureFlagService: FeatureFlagService, + private val computeCacheSizeUseCase: ComputeCacheSizeUseCase, + private val clearCacheUseCase: ClearCacheUseCase, + private val rageshakePresenter: Presenter, + private val appPreferencesStore: AppPreferencesStore, + private val buildMeta: BuildMeta, + private val enterpriseService: EnterpriseService, +) : Presenter { + @Composable + override fun present(): DeveloperSettingsState { + val rageshakeState = rageshakePresenter.present() + val enabledFeatures = remember { + mutableStateListOf() + } + val cacheSize = remember { + mutableStateOf>(AsyncData.Uninitialized) + } + val clearCacheAction = remember { + mutableStateOf>(AsyncAction.Uninitialized) + } + var showColorPicker by remember { + mutableStateOf(false) + } + val customElementCallBaseUrl by remember { + appPreferencesStore + .getCustomElementCallBaseUrlFlow() + }.collectAsState(initial = null) + + val tracingLogLevelFlow = remember { + appPreferencesStore.getTracingLogLevelFlow().map { AsyncData.Success(it.toLogLevelItem()) } + } + val tracingLogLevel by tracingLogLevelFlow.collectAsState(initial = AsyncData.Uninitialized) + val tracingLogPacks by produceState(persistentListOf()) { + appPreferencesStore.getTracingLogPacksFlow() + // Sort the entries alphabetically by its title + .map { it.sortedBy { pack -> pack.title } } + .collectLatest { value = it.toImmutableList() } + } + + LaunchedEffect(Unit) { + featureFlagService.getAvailableFeatures() + .run { + // Never display room directory search in release builds for Play Store + if (buildMeta.flavorDescription == "GooglePlay" && buildMeta.buildType == BuildType.RELEASE) { + filterNot { it.key == FeatureFlags.RoomDirectorySearch.key } + } else { + this + } + } + .forEach { feature -> + enabledFeatures.add(EnabledFeature(feature, featureFlagService.isFeatureEnabled(feature))) + } + } + val featureUiModels = createUiModels(enabledFeatures) + val coroutineScope = rememberCoroutineScope() + // Compute cache size each time the clear cache action value is changed + LaunchedEffect(clearCacheAction.value.isSuccess()) { + computeCacheSize(cacheSize) + } + + fun handleEvent(event: DeveloperSettingsEvents) { + when (event) { + is DeveloperSettingsEvents.UpdateEnabledFeature -> coroutineScope.updateEnabledFeature( + enabledFeatures = enabledFeatures, + featureKey = event.feature.key, + enabled = event.isEnabled, + triggerClearCache = { handleEvent(DeveloperSettingsEvents.ClearCache) } + ) + is DeveloperSettingsEvents.SetCustomElementCallBaseUrl -> coroutineScope.launch { + val urlToSave = event.baseUrl.takeIf { !it.isNullOrEmpty() } + appPreferencesStore.setCustomElementCallBaseUrl(urlToSave) + } + DeveloperSettingsEvents.ClearCache -> coroutineScope.clearCache(clearCacheAction) + is DeveloperSettingsEvents.SetTracingLogLevel -> coroutineScope.launch { + appPreferencesStore.setTracingLogLevel(event.logLevel.toLogLevel()) + } + is DeveloperSettingsEvents.ToggleTracingLogPack -> coroutineScope.launch { + val currentPacks = tracingLogPacks.toMutableSet() + if (currentPacks.contains(event.logPack)) { + currentPacks.remove(event.logPack) + } else { + currentPacks.add(event.logPack) + } + appPreferencesStore.setTracingLogPacks(currentPacks) + } + is DeveloperSettingsEvents.ChangeBrandColor -> coroutineScope.launch { + showColorPicker = false + val color = event.color + ?.toArgb() + ?.toHexString(HexFormat.UpperCase) + ?.substring(2, 8) + ?.padStart(7, '#') + enterpriseService.overrideBrandColor(sessionId, color) + } + is DeveloperSettingsEvents.SetShowColorPicker -> { + showColorPicker = event.show + } + } + } + + return DeveloperSettingsState( + features = featureUiModels, + cacheSize = cacheSize.value, + clearCacheAction = clearCacheAction.value, + rageshakeState = rageshakeState, + customElementCallBaseUrlState = CustomElementCallBaseUrlState( + baseUrl = customElementCallBaseUrl, + validator = ::customElementCallUrlValidator, + ), + tracingLogLevel = tracingLogLevel, + tracingLogPacks = tracingLogPacks, + isEnterpriseBuild = enterpriseService.isEnterpriseBuild, + showColorPicker = showColorPicker, + eventSink = ::handleEvent, + ) + } + + @Composable + private fun createUiModels( + enabledFeatures: SnapshotStateList, + ): ImmutableList { + return enabledFeatures.map { enabledFeature -> + key(enabledFeature.feature.key) { + remember(enabledFeature) { + FeatureUiModel( + key = enabledFeature.feature.key, + title = enabledFeature.feature.title, + description = enabledFeature.feature.description, + icon = null, + isEnabled = enabledFeature.isEnabled + ) + } + } + }.toImmutableList() + } + + private fun CoroutineScope.updateEnabledFeature( + enabledFeatures: SnapshotStateList, + featureKey: String, + enabled: Boolean, + @Suppress("UNUSED_PARAMETER") triggerClearCache: () -> Unit, + ) = launch { + val featureIndex = enabledFeatures.indexOfFirst { it.feature.key == featureKey }.takeIf { it != -1 } ?: return@launch + val feature = enabledFeatures[featureIndex].feature + if (featureFlagService.setFeatureEnabled(feature, enabled)) { + enabledFeatures[featureIndex] = enabledFeatures[featureIndex].copy(isEnabled = enabled) + } + } + + private fun CoroutineScope.computeCacheSize(cacheSize: MutableState>) = launch { + suspend { + computeCacheSizeUseCase() + }.runCatchingUpdatingState(cacheSize) + } + + private fun CoroutineScope.clearCache(clearCacheAction: MutableState>) = launch { + suspend { + clearCacheUseCase() + }.runCatchingUpdatingState(clearCacheAction) + } +} + +private fun customElementCallUrlValidator(url: String?): Boolean { + return runCatchingExceptions { + if (url.isNullOrEmpty()) return@runCatchingExceptions + val parsedUrl = URL(url) + if (parsedUrl.protocol !in listOf("http", "https")) error("Incorrect protocol") + if (parsedUrl.host.isNullOrBlank()) error("Missing host") + }.isSuccess +} 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 0000000..f97270d --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.developer + +import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem +import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.featureflag.ui.model.FeatureUiModel +import io.element.android.libraries.matrix.api.tracing.TraceLogPack +import kotlinx.collections.immutable.ImmutableList + +data class DeveloperSettingsState( + val features: ImmutableList, + val cacheSize: AsyncData, + val rageshakeState: RageshakePreferencesState, + val clearCacheAction: AsyncAction, + val customElementCallBaseUrlState: CustomElementCallBaseUrlState, + val tracingLogLevel: AsyncData, + val tracingLogPacks: ImmutableList, + val isEnterpriseBuild: Boolean, + val showColorPicker: Boolean, + val eventSink: (DeveloperSettingsEvents) -> Unit +) { + val showLoader = clearCacheAction is AsyncAction.Loading +} + +data class CustomElementCallBaseUrlState( + val baseUrl: String?, + val validator: (String?) -> Boolean, +) 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 0000000..ea16ed9 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.developer + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem +import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList +import io.element.android.libraries.matrix.api.tracing.TraceLogPack +import kotlinx.collections.immutable.toImmutableList + +open class DeveloperSettingsStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aDeveloperSettingsState(), + aDeveloperSettingsState( + clearCacheAction = AsyncAction.Loading + ), + aDeveloperSettingsState( + customElementCallBaseUrlState = aCustomElementCallBaseUrlState( + baseUrl = "https://call.element.ahoy", + ) + ), + aDeveloperSettingsState( + isEnterpriseBuild = true, + showColorPicker = true, + ), + ) +} + +fun aDeveloperSettingsState( + clearCacheAction: AsyncAction = AsyncAction.Uninitialized, + customElementCallBaseUrlState: CustomElementCallBaseUrlState = aCustomElementCallBaseUrlState(), + traceLogPacks: List = emptyList(), + isEnterpriseBuild: Boolean = false, + showColorPicker: Boolean = false, + eventSink: (DeveloperSettingsEvents) -> Unit = {}, +) = DeveloperSettingsState( + features = aFeatureUiModelList(), + rageshakeState = aRageshakePreferencesState(), + cacheSize = AsyncData.Success("1.2 MB"), + clearCacheAction = clearCacheAction, + customElementCallBaseUrlState = customElementCallBaseUrlState, + tracingLogLevel = AsyncData.Success(LogLevelItem.INFO), + tracingLogPacks = traceLogPacks.toImmutableList(), + isEnterpriseBuild = isEnterpriseBuild, + showColorPicker = showColorPicker, + eventSink = eventSink, +) + +fun aCustomElementCallBaseUrlState( + baseUrl: String? = null, + validator: (String?) -> Boolean = { true }, +) = CustomElementCallBaseUrlState( + baseUrl = baseUrl, + validator = validator, +) 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 0000000..6d34e97 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.developer + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.preferences.impl.R +import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem +import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown +import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch +import io.element.android.libraries.designsystem.components.preferences.PreferenceTextField +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.featureflag.ui.FeatureListView +import io.element.android.libraries.featureflag.ui.model.FeatureUiModel +import io.element.android.libraries.matrix.api.tracing.TraceLogPack +import io.element.android.libraries.ui.strings.CommonStrings +import io.mhssn.colorpicker.ColorPickerDialog +import io.mhssn.colorpicker.ColorPickerType +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun DeveloperSettingsView( + state: DeveloperSettingsState, + onOpenShowkase: () -> Unit, + onPushHistoryClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + if (state.showLoader) { + ProgressDialog() + } + BackHandler( + enabled = !state.showLoader, + onBack = onBackClick, + ) + PreferencePage( + modifier = modifier, + onBackClick = { + if (!state.showLoader) { + onBackClick() + } + }, + title = stringResource(id = CommonStrings.common_developer_options) + ) { + // Note: this is OK to hardcode strings in this debug screen. + PreferenceCategory( + title = "Feature flags", + ) { + FeatureListContent(state) + } + NotificationCategory(onPushHistoryClick) + ElementCallCategory(state = state) + + PreferenceCategory(title = "Rust SDK") { + PreferenceDropdown( + title = "Tracing log level", + supportingText = "Requires app reboot", + selectedOption = state.tracingLogLevel.dataOrNull(), + options = LogLevelItem.entries.toImmutableList(), + onSelectOption = { logLevel -> + state.eventSink(DeveloperSettingsEvents.SetTracingLogLevel(logLevel)) + } + ) + } + PreferenceCategory(title = "Enable trace logs per SDK feature") { + Text( + text = "Requires app reboot", + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + ) + for (logPack in TraceLogPack.entries) { + PreferenceSwitch( + title = logPack.title, + isChecked = state.tracingLogPacks.contains(logPack), + onCheckedChange = { isChecked -> state.eventSink(DeveloperSettingsEvents.ToggleTracingLogPack(logPack, isChecked)) } + ) + } + } + + PreferenceCategory(title = "Showkase") { + ListItem( + headlineContent = { + Text("Open Showkase browser") + }, + onClick = onOpenShowkase + ) + } + RageshakePreferencesView( + state = state.rageshakeState, + ) + if (state.isEnterpriseBuild) { + PreferenceCategory(title = "Theme") { + ListItem( + headlineContent = { + Text("Change brand color") + }, + onClick = { + state.eventSink(DeveloperSettingsEvents.SetShowColorPicker(true)) + } + ) + ListItem( + headlineContent = { + Text("Reset brand color") + }, + onClick = { + state.eventSink(DeveloperSettingsEvents.ChangeBrandColor(null)) + } + ) + } + } + PreferenceCategory(title = "Crash") { + ListItem( + headlineContent = { + Text("Crash the app 💥") + }, + onClick = { error("This crash is a test.") } + ) + } + val cache = state.cacheSize + PreferenceCategory(title = "Cache") { + ListItem( + headlineContent = { + Text("Clear cache") + }, + trailingContent = if (state.cacheSize.isLoading() || state.clearCacheAction.isLoading()) { + ListItemContent.Custom { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(20.dp), + strokeWidth = 2.dp + ) + } + } else { + ListItemContent.Text(cache.dataOrNull().orEmpty()) + }, + onClick = { + if (state.clearCacheAction.isLoading().not()) { + state.eventSink(DeveloperSettingsEvents.ClearCache) + } + } + ) + } + } + ColorPickerDialog( + show = state.showColorPicker, + type = ColorPickerType.Classic( + showAlphaBar = false, + ), + onDismissRequest = { + state.eventSink(DeveloperSettingsEvents.SetShowColorPicker(false)) + }, + onPickedColor = { + state.eventSink(DeveloperSettingsEvents.ChangeBrandColor(it)) + }, + ) +} + +@Composable +private fun ElementCallCategory( + state: DeveloperSettingsState, +) { + PreferenceCategory(title = "Element Call") { + val callUrlState = state.customElementCallBaseUrlState + + val supportingText = if (callUrlState.baseUrl.isNullOrEmpty()) { + stringResource(R.string.screen_advanced_settings_element_call_base_url_description) + } else { + callUrlState.baseUrl + } + PreferenceTextField( + headline = stringResource(R.string.screen_advanced_settings_element_call_base_url), + value = callUrlState.baseUrl, + placeholder = "https://.../room", + supportingText = supportingText, + validation = callUrlState.validator, + onValidationErrorMessage = stringResource(R.string.screen_advanced_settings_element_call_base_url_validation_error), + displayValue = { value -> !value.isNullOrEmpty() }, + keyboardOptions = KeyboardOptions.Default.copy(autoCorrectEnabled = false, keyboardType = KeyboardType.Uri), + onChange = { state.eventSink(DeveloperSettingsEvents.SetCustomElementCallBaseUrl(it)) } + ) + } +} + +@Composable +private fun NotificationCategory(onPushHistoryClick: () -> Unit) { + PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_title)) { + ListItem( + headlineContent = { + Text(stringResource(R.string.troubleshoot_notifications_entry_point_push_history_title)) + }, + onClick = onPushHistoryClick, + ) + } +} + +@Composable +private fun FeatureListContent( + state: DeveloperSettingsState, +) { + fun onFeatureEnabled(feature: FeatureUiModel, isEnabled: Boolean) { + state.eventSink(DeveloperSettingsEvents.UpdateEnabledFeature(feature, isEnabled)) + } + + FeatureListView( + features = state.features, + onCheckedChange = ::onFeatureEnabled, + ) +} + +@PreviewsDayNight +@Composable +internal fun DeveloperSettingsViewPreview( + @PreviewParameter(DeveloperSettingsStateProvider::class) state: DeveloperSettingsState +) = ElementPreview { + DeveloperSettingsView( + state = state, + onOpenShowkase = {}, + onPushHistoryClick = {}, + onBackClick = {} + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/LogLevelItem.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/LogLevelItem.kt new file mode 100644 index 0000000..ca8fe00 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/LogLevelItem.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.developer.tracing + +import androidx.compose.runtime.Composable +import io.element.android.libraries.designsystem.components.preferences.DropdownOption + +enum class LogLevelItem : DropdownOption { + ERROR { + @Composable + override fun getText(): String = "Error" + }, + WARN { + @Composable + override fun getText(): String = "Warn" + }, + INFO { + @Composable + override fun getText(): String = "Info" + }, + DEBUG { + @Composable + override fun getText(): String = "Debug" + }, + TRACE { + @Composable + override fun getText(): String = "Trace" + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/LogLevelMapper.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/LogLevelMapper.kt new file mode 100644 index 0000000..c271499 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/LogLevelMapper.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.developer.tracing + +import io.element.android.libraries.matrix.api.tracing.LogLevel + +fun LogLevelItem.toLogLevel(): LogLevel { + return when (this) { + LogLevelItem.ERROR -> io.element.android.libraries.matrix.api.tracing.LogLevel.ERROR + LogLevelItem.WARN -> io.element.android.libraries.matrix.api.tracing.LogLevel.WARN + LogLevelItem.INFO -> io.element.android.libraries.matrix.api.tracing.LogLevel.INFO + LogLevelItem.DEBUG -> io.element.android.libraries.matrix.api.tracing.LogLevel.DEBUG + LogLevelItem.TRACE -> io.element.android.libraries.matrix.api.tracing.LogLevel.TRACE + } +} + +fun LogLevel.toLogLevelItem(): LogLevelItem { + return when (this) { + LogLevel.ERROR -> LogLevelItem.ERROR + LogLevel.WARN -> LogLevelItem.WARN + LogLevel.INFO -> LogLevelItem.INFO + LogLevel.DEBUG -> LogLevelItem.DEBUG + LogLevel.TRACE -> LogLevelItem.TRACE + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsEvents.kt new file mode 100644 index 0000000..bc948da --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsEvents.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.labs + +import io.element.android.libraries.featureflag.ui.model.FeatureUiModel + +sealed interface LabsEvents { + data class ToggleFeature(val feature: FeatureUiModel) : LabsEvents +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsNode.kt new file mode 100644 index 0000000..b7ba73c --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsNode.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.labs + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class LabsNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: LabsPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onDone() + } + + val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + LabsView( + state = state, + onBack = callback::onDone, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsPresenter.kt new file mode 100644 index 0000000..3b7d499 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsPresenter.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.labs + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import dev.zacsweers.metro.Inject +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.preferences.impl.R +import io.element.android.features.preferences.impl.model.EnabledFeature +import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.theme.components.IconSource +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 io.element.android.services.toolbox.api.strings.StringProvider +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch + +@Inject +class LabsPresenter( + private val stringProvider: StringProvider, + private val featureFlagService: FeatureFlagService, + private val clearCacheUseCase: ClearCacheUseCase, +) : Presenter { + @Composable + override fun present(): LabsState { + val coroutineScope = rememberCoroutineScope() + val enabledFeatures = remember { + mutableStateListOf() + } + LaunchedEffect(Unit) { + featureFlagService.getAvailableFeatures(isInLabs = true) + .forEach { feature -> + enabledFeatures.add(EnabledFeature(feature, featureFlagService.isFeatureEnabled(feature))) + } + } + var isApplyingChanges by remember { mutableStateOf(false) } + val featureUiModels = createUiModels(enabledFeatures) + + fun handleEvent(event: LabsEvents) { + when (event) { + is LabsEvents.ToggleFeature -> coroutineScope.launch { + val featureIndex = enabledFeatures.indexOfFirst { it.feature.key == event.feature.key }.takeIf { it != -1 } ?: return@launch + val enabledFeature = enabledFeatures[featureIndex] + val feature = enabledFeature.feature + val newValue = enabledFeature.isEnabled.not() + if (featureFlagService.setFeatureEnabled(feature, newValue)) { + enabledFeatures[featureIndex] = enabledFeatures[featureIndex].copy(isEnabled = newValue) + when (feature.key) { + FeatureFlags.Threads.key -> { + // Threads require a cache clear to recreate the event cache + clearCacheUseCase() + isApplyingChanges = true + } + } + } + } + } + } + return LabsState( + features = featureUiModels, + isApplyingChanges = isApplyingChanges, + eventSink = ::handleEvent, + ) + } + + @Composable + private fun createUiModels( + enabledFeatures: SnapshotStateList, + ): ImmutableList { + return enabledFeatures.map { enabledFeature -> + key(enabledFeature.feature.key) { + val title = when (enabledFeature.feature) { + FeatureFlags.Threads -> stringProvider.getString(R.string.screen_labs_enable_threads) + else -> enabledFeature.feature.title + } + val description = when (enabledFeature.feature) { + FeatureFlags.Threads -> stringProvider.getString(R.string.screen_labs_enable_threads_description) + else -> enabledFeature.feature.description + } + val icon = when (enabledFeature.feature) { + FeatureFlags.Threads -> CompoundIcons.Threads() + else -> null + } + remember(enabledFeature) { + FeatureUiModel( + key = enabledFeature.feature.key, + title = title, + description = description, + icon = icon?.let(IconSource::Vector), + isEnabled = enabledFeature.isEnabled + ) + } + } + }.toImmutableList() + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsState.kt new file mode 100644 index 0000000..42e70d5 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsState.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.labs + +import io.element.android.libraries.featureflag.ui.model.FeatureUiModel +import kotlinx.collections.immutable.ImmutableList + +data class LabsState( + val features: ImmutableList, + val isApplyingChanges: Boolean, + val eventSink: (LabsEvents) -> Unit, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsStateProvider.kt new file mode 100644 index 0000000..08e251d --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsStateProvider.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.labs + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.featureflag.ui.model.FeatureUiModel +import kotlinx.collections.immutable.toImmutableList + +internal class LabsStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aLabsState(features = aFeatureList()), + aLabsState(features = aFeatureList(), isApplyingChanges = true), + ) +} + +internal fun aLabsState( + features: List = emptyList(), + isApplyingChanges: Boolean = false, +) = LabsState( + features = features.toImmutableList(), + isApplyingChanges = isApplyingChanges, + eventSink = {}, +) + +internal fun aFeatureList() = listOf( + FeatureUiModel( + key = "feature_1", + title = "Feature 1", + description = "This is a description of feature 1.", + isEnabled = true, + icon = IconSource.Resource(CompoundDrawables.ic_compound_threads), + ), + FeatureUiModel( + key = "feature_2", + title = "Feature 2", + description = "This is a description of feature 2.", + isEnabled = false, + icon = IconSource.Resource(CompoundDrawables.ic_compound_video_call), + ) +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsView.kt new file mode 100644 index 0000000..7a6703e --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsView.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.labs + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.PaddingValues +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.systemBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.preferences.impl.R +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.BigIcon +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.list.SwitchListItem +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.TopAppBar + +/** + * The contents of the Labs screen. + * Design: https://www.figma.com/design/V0dkfRAW6T3yCQKjahpzkX/ER-46-EX--Threads?node-id=2004-27319&t=yssy1yYYigsGON3s-0 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LabsView( + state: LabsState, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + if (state.isApplyingChanges) { + ProgressDialog() + } + + BackHandler( + enabled = !state.isApplyingChanges, + onBack = onBack, + ) + + HeaderFooterPage( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + .imePadding(), + topBar = { + TopAppBar( + titleStr = stringResource(R.string.screen_labs_title), + navigationIcon = { + BackButton(onClick = onBack, enabled = !state.isApplyingChanges) + } + ) + }, + header = { + IconTitleSubtitleMolecule( + modifier = Modifier.padding(top = 24.dp, start = 24.dp, end = 24.dp), + title = stringResource(R.string.screen_labs_header_title), + subTitle = stringResource(R.string.screen_labs_header_description), + iconStyle = BigIcon.Style.Default(CompoundIcons.Labs()) + ) + }, + contentPadding = PaddingValues(), + content = { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 20.dp), + ) { + items(items = state.features, key = { it.key }) { feature -> + SwitchListItem( + leadingContent = feature.icon?.let { ListItemContent.Icon(it) }, + headline = feature.title, + supportingText = feature.description, + value = feature.isEnabled, + onChange = { + state.eventSink(LabsEvents.ToggleFeature(feature)) + } + ) + } + } + } + ) +} + +@PreviewsDayNight +@Composable +internal fun LabsViewPreview(@PreviewParameter(LabsStateProvider::class) state: LabsState) { + ElementPreview { + LabsView(state = state, onBack = {}) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/model/EnabledFeature.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/model/EnabledFeature.kt new file mode 100644 index 0000000..2d1b5e5 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/model/EnabledFeature.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.model + +import io.element.android.libraries.featureflag.api.Feature + +data class EnabledFeature( + val feature: Feature, + val isEnabled: Boolean, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt new file mode 100644 index 0000000..8f1b332 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.notifications + +sealed interface NotificationSettingsEvents { + data object RefreshSystemNotificationsEnabled : NotificationSettingsEvents + data class SetNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents + data class SetAtRoomNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents + data class SetCallNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents + data class SetInviteForMeNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents + data object FixConfigurationMismatch : NotificationSettingsEvents + data object ClearConfigurationMismatchError : NotificationSettingsEvents + data object ClearNotificationChangeError : NotificationSettingsEvents + data object ChangePushProvider : NotificationSettingsEvents + data object CancelChangePushProvider : NotificationSettingsEvents + data class SetPushProvider(val index: Int) : NotificationSettingsEvents +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt new file mode 100644 index 0000000..d300217 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.notifications + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class NotificationSettingsNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: NotificationSettingsPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun navigateToEditDefaultNotificationSetting(isOneToOne: Boolean) + fun navigateToTroubleshootNotifications() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + NotificationSettingsView( + state = state, + onOpenEditDefault = callback::navigateToEditDefaultNotificationSetting, + onBackClick = ::navigateUp, + onTroubleshootNotificationsClick = callback::navigateToTroubleshootNotifications, + modifier = modifier, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt new file mode 100644 index 0000000..9d9e80b --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt @@ -0,0 +1,274 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.notifications + +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.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingStateNoSuccess +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +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 io.element.android.libraries.pushstore.api.UserPushStore +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds + +@Inject +class NotificationSettingsPresenter( + private val notificationSettingsService: NotificationSettingsService, + private val userPushStoreFactory: UserPushStoreFactory, + private val matrixClient: MatrixClient, + private val pushService: PushService, + private val systemNotificationsEnabledProvider: SystemNotificationsEnabledProvider, + private val fullScreenIntentPermissionsPresenter: Presenter, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, +) : Presenter { + @Composable + override fun present(): NotificationSettingsState { + val userPushStore = remember { userPushStoreFactory.getOrCreate(matrixClient.sessionId) } + val systemNotificationsEnabled: MutableState = remember { + mutableStateOf(systemNotificationsEnabledProvider.notificationsEnabled()) + } + val changeNotificationSettingAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + + val localCoroutineScope = rememberCoroutineScope() + val appNotificationsEnabled by remember { + userPushStore.getNotificationEnabledForDevice() + }.collectAsState(initial = false) + + val matrixSettings: MutableState = remember { + mutableStateOf(NotificationSettingsState.MatrixSettings.Uninitialized) + } + + // Used to force a recomposition + var refreshFullScreenIntentSettings by remember { mutableIntStateOf(0) } + + LaunchedEffect(Unit) { + fetchSettings(matrixSettings) + observeNotificationSettings(matrixSettings, changeNotificationSettingAction) + } + + // List of PushProvider -> Distributor + val distributors = remember { + pushService.getAvailablePushProviders() + .flatMap { pushProvider -> + pushProvider.getDistributors().map { distributor -> + pushProvider to distributor + } + } + } + // List of Distributors + val availableDistributors = remember { + distributors.map { it.second }.toImmutableList() + } + + var currentDistributor by remember { mutableStateOf>(AsyncData.Uninitialized) } + var refreshPushProvider by remember { mutableIntStateOf(0) } + + LaunchedEffect(refreshPushProvider) { + val p = pushService.getCurrentPushProvider(matrixClient.sessionId) + val distributor = p?.getCurrentDistributor(matrixClient.sessionId) + currentDistributor = if (distributor != null) { + AsyncData.Success(distributor) + } else { + AsyncData.Failure(Exception("Failed to get current push provider")) + } + } + + var showChangePushProviderDialog by remember { mutableStateOf(false) } + + fun CoroutineScope.changePushProvider( + data: Pair? + ) = launch { + showChangePushProviderDialog = false + data ?: return@launch + val (pushProvider, distributor) = data + // No op if the distributor is the same. + if (distributor == currentDistributor.dataOrNull()) return@launch + currentDistributor = AsyncData.Loading(currentDistributor.dataOrNull()) + pushService.registerWith( + matrixClient = matrixClient, + pushProvider = pushProvider, + distributor = distributor + ) + .fold( + { + refreshPushProvider++ + }, + { + currentDistributor = AsyncData.Failure(it) + } + ) + } + + fun handleEvent(event: NotificationSettingsEvents) { + when (event) { + is NotificationSettingsEvents.SetAtRoomNotificationsEnabled -> { + localCoroutineScope.setAtRoomNotificationsEnabled(event.enabled, changeNotificationSettingAction) + } + is NotificationSettingsEvents.SetCallNotificationsEnabled -> { + localCoroutineScope.setCallNotificationsEnabled(event.enabled, changeNotificationSettingAction) + } + is NotificationSettingsEvents.SetInviteForMeNotificationsEnabled -> { + localCoroutineScope.setInviteForMeNotificationsEnabled(event.enabled, changeNotificationSettingAction) + } + is NotificationSettingsEvents.SetNotificationsEnabled -> sessionCoroutineScope.setNotificationsEnabled(userPushStore, event.enabled) + NotificationSettingsEvents.ClearConfigurationMismatchError -> { + matrixSettings.value = NotificationSettingsState.MatrixSettings.Invalid(fixFailed = false) + } + NotificationSettingsEvents.FixConfigurationMismatch -> localCoroutineScope.fixConfigurationMismatch(matrixSettings) + NotificationSettingsEvents.RefreshSystemNotificationsEnabled -> { + systemNotificationsEnabled.value = systemNotificationsEnabledProvider.notificationsEnabled() + refreshFullScreenIntentSettings++ + } + NotificationSettingsEvents.ClearNotificationChangeError -> changeNotificationSettingAction.value = AsyncAction.Uninitialized + NotificationSettingsEvents.ChangePushProvider -> showChangePushProviderDialog = true + NotificationSettingsEvents.CancelChangePushProvider -> showChangePushProviderDialog = false + is NotificationSettingsEvents.SetPushProvider -> localCoroutineScope.changePushProvider(distributors.getOrNull(event.index)) + } + } + + return NotificationSettingsState( + matrixSettings = matrixSettings.value, + appSettings = NotificationSettingsState.AppSettings( + systemNotificationsEnabled = systemNotificationsEnabled.value, + appNotificationsEnabled = appNotificationsEnabled, + ), + changeNotificationSettingAction = changeNotificationSettingAction.value, + currentPushDistributor = currentDistributor, + availablePushDistributors = availableDistributors, + showChangePushProviderDialog = showChangePushProviderDialog, + fullScreenIntentPermissionsState = key(refreshFullScreenIntentSettings) { fullScreenIntentPermissionsPresenter.present() }, + eventSink = ::handleEvent, + ) + } + + @OptIn(FlowPreview::class) + private fun CoroutineScope.observeNotificationSettings( + target: MutableState, + changeNotificationSettingAction: MutableState>, + ) { + notificationSettingsService.notificationSettingsChangeFlow + .debounce(0.5.seconds) + .onEach { + fetchSettings(target) + changeNotificationSettingAction.value = AsyncAction.Uninitialized + } + .launchIn(this) + } + + private fun CoroutineScope.fetchSettings(target: MutableState) = launch { + val groupDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = false, isOneToOne = false).getOrThrow() + val encryptedGroupDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = false).getOrThrow() + + val oneToOneDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = false, isOneToOne = true).getOrThrow() + val encryptedOneToOneDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = true).getOrThrow() + + if (groupDefaultMode != encryptedGroupDefaultMode || oneToOneDefaultMode != encryptedOneToOneDefaultMode) { + target.value = NotificationSettingsState.MatrixSettings.Invalid(fixFailed = false) + return@launch + } + + val callNotificationsEnabled = notificationSettingsService.isCallEnabled().getOrThrow() + val atRoomNotificationsEnabled = notificationSettingsService.isRoomMentionEnabled().getOrThrow() + val inviteForMeNotificationsEnabled = notificationSettingsService.isInviteForMeEnabled().getOrThrow() + + target.value = NotificationSettingsState.MatrixSettings.Valid( + atRoomNotificationsEnabled = atRoomNotificationsEnabled, + callNotificationsEnabled = callNotificationsEnabled, + inviteForMeNotificationsEnabled = inviteForMeNotificationsEnabled, + defaultGroupNotificationMode = encryptedGroupDefaultMode, + defaultOneToOneNotificationMode = encryptedOneToOneDefaultMode, + ) + } + + private fun CoroutineScope.fixConfigurationMismatch(target: MutableState) = launch { + runCatchingExceptions { + val groupDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = false, isOneToOne = false).getOrThrow() + val encryptedGroupDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = false).getOrThrow() + + if (groupDefaultMode != encryptedGroupDefaultMode) { + notificationSettingsService.setDefaultRoomNotificationMode( + isEncrypted = encryptedGroupDefaultMode != RoomNotificationMode.ALL_MESSAGES, + mode = RoomNotificationMode.ALL_MESSAGES, + isOneToOne = false, + ) + } + + val oneToOneDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = false, isOneToOne = true).getOrThrow() + val encryptedOneToOneDefaultMode = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = true).getOrThrow() + + if (oneToOneDefaultMode != encryptedOneToOneDefaultMode) { + notificationSettingsService.setDefaultRoomNotificationMode( + isEncrypted = encryptedOneToOneDefaultMode != RoomNotificationMode.ALL_MESSAGES, + mode = RoomNotificationMode.ALL_MESSAGES, + isOneToOne = true, + ) + } + }.fold( + onSuccess = {}, + onFailure = { + target.value = NotificationSettingsState.MatrixSettings.Invalid(fixFailed = true) + } + ) + } + + private fun CoroutineScope.setAtRoomNotificationsEnabled(enabled: Boolean, action: MutableState>) = launch { + action.runUpdatingStateNoSuccess { + notificationSettingsService.setRoomMentionEnabled(enabled) + } + } + + private fun CoroutineScope.setCallNotificationsEnabled(enabled: Boolean, action: MutableState>) = launch { + action.runUpdatingStateNoSuccess { + notificationSettingsService.setCallEnabled(enabled) + } + } + + private fun CoroutineScope.setInviteForMeNotificationsEnabled(enabled: Boolean, action: MutableState>) = launch { + action.runUpdatingStateNoSuccess { + notificationSettingsService.setInviteForMeEnabled(enabled) + } + } + + private fun CoroutineScope.setNotificationsEnabled(userPushStore: UserPushStore, enabled: Boolean) = launch { + userPushStore.setNotificationEnabledForDevice(enabled) + if (enabled) { + pushService.ensurePusherIsRegistered(matrixClient) + } else { + pushService.getCurrentPushProvider(matrixClient.sessionId)?.unregister(matrixClient) + } + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt new file mode 100644 index 0000000..0a55909 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.notifications + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.pushproviders.api.Distributor +import kotlinx.collections.immutable.ImmutableList + +data class NotificationSettingsState( + val matrixSettings: MatrixSettings, + val appSettings: AppSettings, + val changeNotificationSettingAction: AsyncAction, + val currentPushDistributor: AsyncData, + val availablePushDistributors: ImmutableList, + val showChangePushProviderDialog: Boolean, + val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState, + val eventSink: (NotificationSettingsEvents) -> Unit, +) { + sealed interface MatrixSettings { + data object Uninitialized : MatrixSettings + data class Valid( + val atRoomNotificationsEnabled: Boolean, + val callNotificationsEnabled: Boolean, + val inviteForMeNotificationsEnabled: Boolean, + val defaultGroupNotificationMode: RoomNotificationMode?, + val defaultOneToOneNotificationMode: RoomNotificationMode?, + ) : MatrixSettings + + data class Invalid( + val fixFailed: Boolean + ) : MatrixSettings + } + + data class AppSettings( + val systemNotificationsEnabled: Boolean, + val appNotificationsEnabled: Boolean, + ) + + /** + * Whether the advanced settings should be shown. + * This is true if the current push distributor is in a failure state or if there are multiple push distributors available. + */ + val showAdvancedSettings: Boolean = currentPushDistributor.isFailure() || availablePushDistributors.size > 1 +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt new file mode 100644 index 0000000..fb39644 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.notifications + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState +import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.pushproviders.api.Distributor +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +open class NotificationSettingsStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aValidNotificationSettingsState(systemNotificationsEnabled = false), + aValidNotificationSettingsState(), + aValidNotificationSettingsState(changeNotificationSettingAction = AsyncAction.Loading), + aValidNotificationSettingsState(changeNotificationSettingAction = AsyncAction.Failure(RuntimeException("error"))), + aValidNotificationSettingsState( + availablePushDistributors = listOf(aDistributor("Firebase")), + changeNotificationSettingAction = AsyncAction.Failure(RuntimeException("error")), + ), + aValidNotificationSettingsState(availablePushDistributors = listOf(aDistributor("Firebase"))), + aValidNotificationSettingsState(showChangePushProviderDialog = true), + aValidNotificationSettingsState( + availablePushDistributors = listOf( + aDistributor("Firebase"), + aDistributor("ntfy", "app.id1"), + aDistributor("ntfy", "app.id2"), + ), + showChangePushProviderDialog = true, + ), + aValidNotificationSettingsState(currentPushDistributor = AsyncData.Loading()), + aValidNotificationSettingsState(currentPushDistributor = AsyncData.Failure(Exception("Failed to change distributor"))), + aInvalidNotificationSettingsState(), + aInvalidNotificationSettingsState(fixFailed = true), + aValidNotificationSettingsState(fullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(permissionGranted = false)), + aValidNotificationSettingsState(appNotificationEnabled = false), + ) +} + +fun aValidNotificationSettingsState( + changeNotificationSettingAction: AsyncAction = AsyncAction.Uninitialized, + atRoomNotificationsEnabled: Boolean = true, + callNotificationsEnabled: Boolean = true, + inviteForMeNotificationsEnabled: Boolean = true, + systemNotificationsEnabled: Boolean = true, + appNotificationEnabled: Boolean = true, + currentPushDistributor: AsyncData = AsyncData.Success(aDistributor("Firebase")), + availablePushDistributors: List = listOf( + aDistributor("Firebase"), + aDistributor("ntfy"), + ), + showChangePushProviderDialog: Boolean = false, + fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(), + eventSink: (NotificationSettingsEvents) -> Unit = {}, +) = NotificationSettingsState( + matrixSettings = NotificationSettingsState.MatrixSettings.Valid( + atRoomNotificationsEnabled = atRoomNotificationsEnabled, + callNotificationsEnabled = callNotificationsEnabled, + inviteForMeNotificationsEnabled = inviteForMeNotificationsEnabled, + defaultGroupNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, + defaultOneToOneNotificationMode = RoomNotificationMode.ALL_MESSAGES, + ), + appSettings = NotificationSettingsState.AppSettings( + systemNotificationsEnabled = systemNotificationsEnabled, + appNotificationsEnabled = appNotificationEnabled, + ), + changeNotificationSettingAction = changeNotificationSettingAction, + currentPushDistributor = currentPushDistributor, + availablePushDistributors = availablePushDistributors.toImmutableList(), + showChangePushProviderDialog = showChangePushProviderDialog, + fullScreenIntentPermissionsState = fullScreenIntentPermissionsState, + eventSink = eventSink, +) + +fun aInvalidNotificationSettingsState( + fixFailed: Boolean = false, + eventSink: (NotificationSettingsEvents) -> Unit = {}, +) = NotificationSettingsState( + matrixSettings = NotificationSettingsState.MatrixSettings.Invalid( + fixFailed = fixFailed, + ), + appSettings = NotificationSettingsState.AppSettings( + systemNotificationsEnabled = false, + appNotificationsEnabled = true, + ), + changeNotificationSettingAction = AsyncAction.Uninitialized, + currentPushDistributor = AsyncData.Uninitialized, + availablePushDistributors = persistentListOf(), + showChangePushProviderDialog = false, + fullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(), + eventSink = eventSink, +) + +fun aDistributor( + name: String = "Name", + value: String = "$name Value", +) = Distributor( + value = value, + name = name, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt new file mode 100644 index 0000000..09f54b7 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.notifications + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.androidutils.system.startNotificationSettingsIntent +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.Announcement +import io.element.android.libraries.designsystem.components.AnnouncementType +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.components.dialogs.ListOption +import io.element.android.libraries.designsystem.components.dialogs.SingleSelectionDialog +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent +import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.toImmutableList + +/** + * A view that allows a user edit their global notification settings. + */ +@Composable +fun NotificationSettingsView( + state: NotificationSettingsState, + onOpenEditDefault: (isOneToOne: Boolean) -> Unit, + onTroubleshootNotificationsClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + OnLifecycleEvent { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(NotificationSettingsEvents.RefreshSystemNotificationsEnabled) + else -> Unit + } + } + PreferencePage( + modifier = modifier, + onBackClick = onBackClick, + title = stringResource(id = R.string.screen_notification_settings_title) + ) { + when (state.matrixSettings) { + is NotificationSettingsState.MatrixSettings.Invalid -> InvalidNotificationSettingsView( + showError = state.matrixSettings.fixFailed, + onContinueClick = { state.eventSink(NotificationSettingsEvents.FixConfigurationMismatch) }, + onDismissError = { state.eventSink(NotificationSettingsEvents.ClearConfigurationMismatchError) }, + ) + NotificationSettingsState.MatrixSettings.Uninitialized -> return@PreferencePage + is NotificationSettingsState.MatrixSettings.Valid -> NotificationSettingsContentView( + matrixSettings = state.matrixSettings, + state = state, + onNotificationsEnabledChange = { state.eventSink(NotificationSettingsEvents.SetNotificationsEnabled(it)) }, + onGroupChatsClick = { onOpenEditDefault(false) }, + onDirectChatsClick = { onOpenEditDefault(true) }, + onMentionNotificationsChange = { state.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(it)) }, + // TODO We are removing the call notification toggle until support for call notifications has been added +// onCallsNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetCallNotificationsEnabled(it)) }, + onInviteForMeNotificationsChange = { state.eventSink(NotificationSettingsEvents.SetInviteForMeNotificationsEnabled(it)) }, + onTroubleshootNotificationsClick = onTroubleshootNotificationsClick, + ) + } + AsyncActionView( + async = state.changeNotificationSettingAction, + errorMessage = { stringResource(R.string.screen_notification_settings_edit_failed_updating_default_mode) }, + onErrorDismiss = { state.eventSink(NotificationSettingsEvents.ClearNotificationChangeError) }, + onSuccess = {}, + ) + } +} + +@Composable +private fun NotificationSettingsContentView( + matrixSettings: NotificationSettingsState.MatrixSettings.Valid, + state: NotificationSettingsState, + onNotificationsEnabledChange: (Boolean) -> Unit, + onGroupChatsClick: () -> Unit, + onDirectChatsClick: () -> Unit, + onMentionNotificationsChange: (Boolean) -> Unit, + // TODO We are removing the call notification toggle until support for call notifications has been added +// onCallsNotificationsChanged: (Boolean) -> Unit, + onInviteForMeNotificationsChange: (Boolean) -> Unit, + onTroubleshootNotificationsClick: () -> Unit, +) { + val context = LocalContext.current + val systemSettings: NotificationSettingsState.AppSettings = state.appSettings + if (systemSettings.appNotificationsEnabled && !systemSettings.systemNotificationsEnabled) { + ListItem( + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.NotificationsOffSolid())), + headlineContent = { + Text(stringResource(id = R.string.screen_notification_settings_system_notifications_turned_off)) + }, + supportingContent = { + Text( + stringResource( + id = R.string.screen_notification_settings_system_notifications_action_required, + stringResource(id = R.string.screen_notification_settings_system_notifications_action_required_content_link) + ) + ) + }, + onClick = { + context.startNotificationSettingsIntent() + } + ) + } + + PreferenceSwitch( + title = stringResource(id = R.string.screen_notification_settings_enable_notifications), + isChecked = systemSettings.appNotificationsEnabled, + onCheckedChange = onNotificationsEnabledChange + ) + + if (systemSettings.appNotificationsEnabled) { + if (!state.fullScreenIntentPermissionsState.permissionGranted) { + PreferenceCategory { + ListItem( + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.VoiceCallSolid())), + headlineContent = { + Text(stringResource(id = R.string.full_screen_intent_banner_title)) + }, + supportingContent = { + Text(stringResource(R.string.full_screen_intent_banner_message)) + }, + onClick = { + state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvents.OpenSettings) + } + ) + } + } + PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_notification_section_title)) { + ListItem( + headlineContent = { + Text(stringResource(id = R.string.screen_notification_settings_group_chats)) + }, + supportingContent = { + Text(getTitleForRoomNotificationMode(mode = matrixSettings.defaultGroupNotificationMode)) + }, + onClick = onGroupChatsClick + ) + ListItem( + headlineContent = { + Text(stringResource(id = R.string.screen_notification_settings_direct_chats)) + }, + supportingContent = { + Text(getTitleForRoomNotificationMode(mode = matrixSettings.defaultOneToOneNotificationMode)) + }, + onClick = onDirectChatsClick + ) + } + + PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_mode_mentions)) { + PreferenceSwitch( + modifier = Modifier, + title = stringResource(id = R.string.screen_notification_settings_room_mention_label), + isChecked = matrixSettings.atRoomNotificationsEnabled, + onCheckedChange = onMentionNotificationsChange + ) + } + PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_additional_settings_section_title)) { + // TODO We are removing the call notification toggle until support for call notifications has been added +// PreferenceSwitch( +// modifier = Modifier, +// title = stringResource(id = CommonStrings.screen_notification_settings_calls_label), +// isChecked = matrixSettings.callNotificationsEnabled, +// switchAlignment = Alignment.Top, +// onCheckedChange = onCallsNotificationsChanged +// ) + PreferenceSwitch( + modifier = Modifier, + title = stringResource(id = R.string.screen_notification_settings_invite_for_me_label), + isChecked = matrixSettings.inviteForMeNotificationsEnabled, + onCheckedChange = onInviteForMeNotificationsChange + ) + } + PreferenceCategory(title = stringResource(id = R.string.troubleshoot_notifications_entry_point_section)) { + ListItem( + headlineContent = { + Text(stringResource(id = R.string.troubleshoot_notifications_entry_point_title)) + }, + onClick = onTroubleshootNotificationsClick + ) + } + if (state.showAdvancedSettings) { + PreferenceCategory(title = stringResource(id = CommonStrings.common_advanced_settings)) { + ListItem( + headlineContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_push_provider_android)) + }, + trailingContent = when (state.currentPushDistributor) { + AsyncData.Uninitialized, + is AsyncData.Loading -> ListItemContent.Custom { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(20.dp), + strokeWidth = 2.dp + ) + } + is AsyncData.Failure -> ListItemContent.Text( + stringResource(id = CommonStrings.common_error) + ) + is AsyncData.Success -> ListItemContent.Text( + state.currentPushDistributor.dataOrNull()?.name ?: "" + ) + }, + onClick = { + if (state.currentPushDistributor.isReady()) { + state.eventSink(NotificationSettingsEvents.ChangePushProvider) + } + } + ) + } + if (state.showChangePushProviderDialog) { + SingleSelectionDialog( + title = stringResource(id = R.string.screen_advanced_settings_choose_distributor_dialog_title_android), + options = state.availablePushDistributors.map { distributor -> + // If there are several distributors with the same name, use the full name + val title = if (state.availablePushDistributors.count { it.name == distributor.name } > 1) { + distributor.fullName + } else { + distributor.name + } + ListOption(title = title) + }.toImmutableList(), + initialSelection = state.availablePushDistributors.indexOf(state.currentPushDistributor.dataOrNull()), + onSelectOption = { index -> + state.eventSink( + NotificationSettingsEvents.SetPushProvider(index) + ) + }, + onDismissRequest = { state.eventSink(NotificationSettingsEvents.CancelChangePushProvider) }, + ) + } + } + } +} + +@Composable +private fun getTitleForRoomNotificationMode(mode: RoomNotificationMode?) = + when (mode) { + RoomNotificationMode.ALL_MESSAGES -> stringResource(id = R.string.screen_notification_settings_edit_mode_all_messages) + RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> stringResource(id = R.string.screen_notification_settings_edit_mode_mentions_and_keywords) + RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute) + null -> "" + } + +@Composable +private fun InvalidNotificationSettingsView( + showError: Boolean, + onContinueClick: () -> Unit, + onDismissError: () -> Unit, + modifier: Modifier = Modifier, +) { + Announcement( + title = stringResource(R.string.screen_notification_settings_configuration_mismatch), + description = stringResource(R.string.screen_notification_settings_configuration_mismatch_description), + type = AnnouncementType.Actionable( + onActionClick = onContinueClick, + actionText = stringResource(CommonStrings.action_continue), + onDismissClick = null, + ), + modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + + if (showError) { + ErrorDialog( + title = stringResource(id = CommonStrings.dialog_title_error), + content = stringResource(id = R.string.screen_notification_settings_failed_fixing_configuration), + onSubmit = onDismissError + ) + } +} + +@PreviewsDayNight +@Composable +internal fun NotificationSettingsViewPreview(@PreviewParameter(NotificationSettingsStateProvider::class) state: NotificationSettingsState) = ElementPreview { + NotificationSettingsView( + state = state, + onBackClick = {}, + onOpenEditDefault = {}, + onTroubleshootNotificationsClick = {}, + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/SystemNotificationsEnabledProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/SystemNotificationsEnabledProvider.kt new file mode 100644 index 0000000..a21dbb1 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/SystemNotificationsEnabledProvider.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.notifications + +import androidx.core.app.NotificationManagerCompat +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn + +interface SystemNotificationsEnabledProvider { + fun notificationsEnabled(): Boolean +} + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultSystemNotificationsEnabledProvider( + private val notificationManager: NotificationManagerCompat, +) : SystemNotificationsEnabledProvider { + override fun notificationsEnabled(): Boolean { + return notificationManager.areNotificationsEnabled() + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/DefaultNotificationSettingOption.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/DefaultNotificationSettingOption.kt new file mode 100644 index 0000000..f7fc251 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/DefaultNotificationSettingOption.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.notifications.edit +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.room.RoomNotificationMode + +@Composable +fun DefaultNotificationSettingOption( + mode: RoomNotificationMode, + onSelectOption: (RoomNotificationMode) -> Unit, + displayMentionsOnlyDisclaimer: Boolean, + modifier: Modifier = Modifier, + isSelected: Boolean = false, +) { + val title = when (mode) { + RoomNotificationMode.ALL_MESSAGES -> stringResource(id = R.string.screen_notification_settings_edit_mode_all_messages) + RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> stringResource(id = R.string.screen_notification_settings_edit_mode_mentions_and_keywords) + else -> "" + } + val subtitle = when { + mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY && displayMentionsOnlyDisclaimer -> { + stringResource(id = R.string.screen_notification_settings_mentions_only_disclaimer) + } + else -> null + } + ListItem( + modifier = modifier, + headlineContent = { Text(title) }, + supportingContent = subtitle?.let { { Text(it) } }, + trailingContent = ListItemContent.RadioButton(selected = isSelected), + onClick = { onSelectOption(mode) }, + ) +} + +@PreviewsDayNight +@Composable +internal fun DefaultNotificationSettingOptionPreview() = ElementPreview { + Column { + DefaultNotificationSettingOption( + mode = RoomNotificationMode.ALL_MESSAGES, + isSelected = true, + displayMentionsOnlyDisclaimer = false, + onSelectOption = {}, + ) + DefaultNotificationSettingOption( + mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, + isSelected = false, + displayMentionsOnlyDisclaimer = false, + onSelectOption = {}, + ) + DefaultNotificationSettingOption( + mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, + isSelected = false, + displayMentionsOnlyDisclaimer = true, + onSelectOption = {}, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt new file mode 100644 index 0000000..9609798 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.notifications.edit + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId + +@ContributesNode(SessionScope::class) +@AssistedInject +class EditDefaultNotificationSettingNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: EditDefaultNotificationSettingPresenter.Factory +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun navigateToRoomNotificationSettings(roomId: RoomId) + } + + data class Inputs( + val isOneToOne: Boolean + ) : NodeInputs + + private val callback: Callback = callback() + private val inputs = inputs() + private val presenter = presenterFactory.create(inputs.isOneToOne) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + EditDefaultNotificationSettingView( + state = state, + openRoomNotificationSettings = callback::navigateToRoomNotificationSettings, + onBackClick = ::navigateUp, + modifier = modifier, + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt new file mode 100644 index 0000000..df52471 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.notifications.edit + +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.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingStateNoSuccess +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.notificationsettings.NotificationSettingsService +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.libraries.matrix.ui.model.getAvatarData +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import java.text.Collator +import kotlin.time.Duration.Companion.seconds + +@AssistedInject +class EditDefaultNotificationSettingPresenter( + private val notificationSettingsService: NotificationSettingsService, + @Assisted private val isOneToOne: Boolean, + private val roomListService: RoomListService, +) : Presenter { + @AssistedFactory + interface Factory { + fun create(oneToOne: Boolean): EditDefaultNotificationSettingPresenter + } + + private val collator = Collator.getInstance().apply { + decomposition = Collator.CANONICAL_DECOMPOSITION + } + + @Composable + override fun present(): EditDefaultNotificationSettingState { + var displayMentionsOnlyDisclaimer by remember { mutableStateOf(false) } + + val mode: MutableState = remember { + mutableStateOf(null) + } + + val changeNotificationSettingAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + + val roomsWithUserDefinedMode: MutableState> = remember { + mutableStateOf(emptyList()) + } + + val localCoroutineScope = rememberCoroutineScope() + LaunchedEffect(Unit) { + fetchSettings(mode) + observeNotificationSettings(mode, changeNotificationSettingAction) + observeRoomSummaries(roomsWithUserDefinedMode) + displayMentionsOnlyDisclaimer = !notificationSettingsService.canHomeServerPushEncryptedEventsToDevice().getOrDefault(true) + } + + fun handleEvent(event: EditDefaultNotificationSettingStateEvents) { + when (event) { + is EditDefaultNotificationSettingStateEvents.SetNotificationMode -> { + localCoroutineScope.setDefaultNotificationMode(event.mode, changeNotificationSettingAction) + } + EditDefaultNotificationSettingStateEvents.ClearError -> changeNotificationSettingAction.value = AsyncAction.Uninitialized + } + } + + return EditDefaultNotificationSettingState( + isOneToOne = isOneToOne, + mode = mode.value, + roomsWithUserDefinedMode = roomsWithUserDefinedMode.value.toImmutableList(), + changeNotificationSettingAction = changeNotificationSettingAction.value, + displayMentionsOnlyDisclaimer = displayMentionsOnlyDisclaimer, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.fetchSettings(mode: MutableState) = launch { + mode.value = notificationSettingsService.getDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = isOneToOne).getOrThrow() + } + + @OptIn(FlowPreview::class) + private fun CoroutineScope.observeNotificationSettings( + mode: MutableState, + changeNotificationSettingAction: MutableState>, + ) { + notificationSettingsService.notificationSettingsChangeFlow + .debounce(0.5.seconds) + .onEach { + fetchSettings(mode) + changeNotificationSettingAction.value = AsyncAction.Uninitialized + } + .launchIn(this) + } + + private fun CoroutineScope.observeRoomSummaries(roomsWithUserDefinedMode: MutableState>) { + roomListService.allRooms + .summaries + .onEach { roomSummaries -> + updateRoomsWithUserDefinedMode(roomSummaries, roomsWithUserDefinedMode) + } + .launchIn(this) + } + + private suspend fun updateRoomsWithUserDefinedMode( + summaries: List, + roomsWithUserDefinedMode: MutableState> + ) { + val roomWithUserDefinedRules: Set = notificationSettingsService.getRoomsWithUserDefinedRules().getOrDefault(emptyList()).toSet() + roomsWithUserDefinedMode.value = summaries + .filter { roomSummary -> + roomWithUserDefinedRules.contains(roomSummary.roomId) && roomSummary.isOneToOne == isOneToOne + } + .map { roomSummary -> + EditNotificationSettingRoomInfo( + roomId = roomSummary.roomId, + name = roomSummary.info.name, + heroesAvatar = roomSummary.info.heroes.map { hero -> + hero.getAvatarData(AvatarSize.CustomRoomNotificationSetting) + }.toImmutableList(), + avatarData = roomSummary.info.getAvatarData(AvatarSize.CustomRoomNotificationSetting), + notificationMode = roomSummary.info.userDefinedNotificationMode, + ) + } + // locale sensitive sorting + .sortedWith( + compareBy(collator) { roomSummary -> + // Collator does not handle null values, so we provide a fallback + roomSummary.name ?: roomSummary.roomId.value + } + ) + } + + private fun CoroutineScope.setDefaultNotificationMode(mode: RoomNotificationMode, action: MutableState>) = launch { + action.runUpdatingStateNoSuccess { + // On modern clients, we don't have different settings for encrypted and non-encrypted rooms (Legacy clients did). + notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = true, mode = mode, isOneToOne = isOneToOne) + .map { + notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, mode = mode, isOneToOne = isOneToOne) + } + } + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt new file mode 100644 index 0000000..17c8cad --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.notifications.edit + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import kotlinx.collections.immutable.ImmutableList + +data class EditDefaultNotificationSettingState( + val isOneToOne: Boolean, + val mode: RoomNotificationMode?, + val roomsWithUserDefinedMode: ImmutableList, + val changeNotificationSettingAction: AsyncAction, + val displayMentionsOnlyDisclaimer: Boolean, + val eventSink: (EditDefaultNotificationSettingStateEvents) -> Unit, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateEvents.kt new file mode 100644 index 0000000..a7993c1 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateEvents.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.notifications.edit + +import io.element.android.libraries.matrix.api.room.RoomNotificationMode + +sealed interface EditDefaultNotificationSettingStateEvents { + data class SetNotificationMode(val mode: RoomNotificationMode) : EditDefaultNotificationSettingStateEvents + data object ClearError : EditDefaultNotificationSettingStateEvents +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt new file mode 100644 index 0000000..389554d --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.notifications.edit + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +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.room.RoomNotificationMode +import kotlinx.collections.immutable.persistentListOf + +open class EditDefaultNotificationSettingStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anEditDefaultNotificationSettingsState(), + anEditDefaultNotificationSettingsState(isOneToOne = true), + anEditDefaultNotificationSettingsState(changeNotificationSettingAction = AsyncAction.Loading), + anEditDefaultNotificationSettingsState(changeNotificationSettingAction = AsyncAction.Failure(RuntimeException("error"))), + anEditDefaultNotificationSettingsState(displayMentionsOnlyDisclaimer = true), + ) +} + +private fun anEditDefaultNotificationSettingsState( + isOneToOne: Boolean = false, + changeNotificationSettingAction: AsyncAction = AsyncAction.Uninitialized, + displayMentionsOnlyDisclaimer: Boolean = false, +) = EditDefaultNotificationSettingState( + isOneToOne = isOneToOne, + mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, + roomsWithUserDefinedMode = persistentListOf( + anEditNotificationSettingRoomInfo("Room"), + anEditNotificationSettingRoomInfo(null), + ), + changeNotificationSettingAction = changeNotificationSettingAction, + displayMentionsOnlyDisclaimer = displayMentionsOnlyDisclaimer, + eventSink = {} +) + +private fun anEditNotificationSettingRoomInfo( + name: String?, +) = EditNotificationSettingRoomInfo( + roomId = RoomId("!roomId:domain"), + name = name, + avatarData = AvatarData( + id = "!roomId:domain", + name = name, + url = null, + size = AvatarSize.CustomRoomNotificationSetting, + ), + heroesAvatar = persistentListOf(), + notificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt new file mode 100644 index 0000000..3b46ae7 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.notifications.edit + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ListItem +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.RoomNotificationMode +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * A view that allows a user to edit the default notification setting for rooms. This can be set separately + * for one-to-one and group rooms, indicated by [EditDefaultNotificationSettingState.isOneToOne]. + */ +@Composable +fun EditDefaultNotificationSettingView( + state: EditDefaultNotificationSettingState, + openRoomNotificationSettings: (roomId: RoomId) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val title = if (state.isOneToOne) { + R.string.screen_notification_settings_direct_chats + } else { + R.string.screen_notification_settings_group_chats + } + PreferencePage( + modifier = modifier, + onBackClick = onBackClick, + title = stringResource(id = title) + ) { + // Only ALL_MESSAGES and MENTIONS_AND_KEYWORDS_ONLY are valid global defaults. + val validModes = listOf(RoomNotificationMode.ALL_MESSAGES, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + + val categoryTitle = if (state.isOneToOne) { + R.string.screen_notification_settings_edit_screen_direct_section_header + } else { + R.string.screen_notification_settings_edit_screen_group_section_header + } + PreferenceCategory( + title = stringResource(id = categoryTitle), + showTopDivider = false, + ) { + if (state.mode != null) { + Column(modifier = Modifier.selectableGroup()) { + validModes.forEach { item -> + DefaultNotificationSettingOption( + mode = item, + isSelected = state.mode == item, + displayMentionsOnlyDisclaimer = state.displayMentionsOnlyDisclaimer, + onSelectOption = { state.eventSink(EditDefaultNotificationSettingStateEvents.SetNotificationMode(it)) } + ) + } + } + } + } + if (state.roomsWithUserDefinedMode.isNotEmpty()) { + PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_edit_custom_settings_section_title)) { + state.roomsWithUserDefinedMode.forEach { summary -> + val subtitle = when (summary.notificationMode) { + RoomNotificationMode.ALL_MESSAGES -> stringResource(id = R.string.screen_notification_settings_edit_mode_all_messages) + RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> { + stringResource(id = R.string.screen_notification_settings_edit_mode_mentions_and_keywords) + } + RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute) + null -> "" + } + ListItem( + headlineContent = { + val roomName = summary.name + Text( + text = roomName ?: stringResource(id = CommonStrings.common_no_room_name), + fontStyle = FontStyle.Italic.takeIf { roomName == null } + ) + }, + supportingContent = { + Text(text = subtitle) + }, + leadingContent = ListItemContent.Custom { + Avatar( + avatarData = summary.avatarData, + avatarType = AvatarType.Room( + heroes = summary.heroesAvatar, + ), + ) + }, + onClick = { + openRoomNotificationSettings(summary.roomId) + } + ) + } + } + } + AsyncActionView( + async = state.changeNotificationSettingAction, + errorMessage = { stringResource(R.string.screen_notification_settings_edit_failed_updating_default_mode) }, + onErrorDismiss = { state.eventSink(EditDefaultNotificationSettingStateEvents.ClearError) }, + onSuccess = {}, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun EditDefaultNotificationSettingViewPreview( + @PreviewParameter(EditDefaultNotificationSettingStateProvider::class) state: EditDefaultNotificationSettingState +) = ElementPreview { + EditDefaultNotificationSettingView( + state = state, + openRoomNotificationSettings = {}, + onBackClick = {}, + ) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditNotificationSettingRoomInfo.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditNotificationSettingRoomInfo.kt new file mode 100644 index 0000000..daed077 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditNotificationSettingRoomInfo.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.notifications.edit + +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import kotlinx.collections.immutable.ImmutableList + +data class EditNotificationSettingRoomInfo( + val roomId: RoomId, + val name: String?, + val heroesAvatar: ImmutableList, + val avatarData: AvatarData, + val notificationMode: RoomNotificationMode? +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvents.kt new file mode 100644 index 0000000..be26686 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvents.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.root + +import io.element.android.libraries.matrix.api.core.SessionId + +sealed interface PreferencesRootEvents { + data object OnVersionInfoClick : PreferencesRootEvents + data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvents +} 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 0000000..7dafcfa --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.root + +import android.app.Activity +import androidx.activity.compose.LocalActivity +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.logout.api.direct.DirectLogoutEvents +import io.element.android.features.logout.api.direct.DirectLogoutView +import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.user.MatrixUser + +@ContributesNode(SessionScope::class) +@AssistedInject +class PreferencesRootNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: PreferencesRootPresenter, + private val directLogoutView: DirectLogoutView, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun navigateToAddAccount() + fun navigateToBugReport() + fun navigateToSecureBackup() + fun navigateToAnalyticsSettings() + fun navigateToAbout() + fun navigateToDeveloperSettings() + fun navigateToNotificationSettings() + fun navigateToLockScreenSettings() + fun navigateToAdvancedSettings() + fun navigateToLabs() + fun navigateToUserProfile(matrixUser: MatrixUser) + fun navigateToBlockedUsers() + fun startSignOutFlow() + fun startAccountDeactivationFlow() + } + + private val callback: Callback = callback() + + private fun onManageAccountClick( + activity: Activity, + url: String?, + isDark: Boolean, + ) { + url?.let { + activity.openUrlInChromeCustomTab( + null, + darkTheme = isDark, + url = it + ) + } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val activity = requireNotNull(LocalActivity.current) + val isDark = ElementTheme.isLightTheme.not() + PreferencesRootView( + state = state, + modifier = modifier, + onBackClick = this::navigateUp, + onAddAccountClick = callback::navigateToAddAccount, + onOpenRageShake = callback::navigateToBugReport, + onOpenAnalytics = callback::navigateToAnalyticsSettings, + onOpenAbout = callback::navigateToAbout, + onSecureBackupClick = callback::navigateToSecureBackup, + onOpenDeveloperSettings = callback::navigateToDeveloperSettings, + onOpenAdvancedSettings = callback::navigateToAdvancedSettings, + onOpenLabs = callback::navigateToLabs, + onManageAccountClick = { onManageAccountClick(activity, it, isDark) }, + onOpenNotificationSettings = callback::navigateToNotificationSettings, + onOpenLockScreenSettings = callback::navigateToLockScreenSettings, + onOpenUserProfile = callback::navigateToUserProfile, + onOpenBlockedUsers = callback::navigateToBlockedUsers, + onSignOutClick = { + if (state.directLogoutState.canDoDirectSignOut) { + state.directLogoutState.eventSink(DirectLogoutEvents.Logout(ignoreSdkError = false)) + } else { + callback.startSignOutFlow() + } + }, + onDeactivateClick = callback::startAccountDeactivationFlow + ) + + directLogoutView.Render(state = state.directLogoutState) + } +} 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 0000000..4e83f7c --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.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 dev.zacsweers.metro.Inject +import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider +import io.element.android.features.rageshake.api.RageshakeFeatureAvailability +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.indicator.api.IndicatorService +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.oidc.AccountManagementAction +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +@Inject +class PreferencesRootPresenter( + private val matrixClient: MatrixClient, + private val sessionVerificationService: SessionVerificationService, + private val analyticsService: AnalyticsService, + private val versionFormatter: VersionFormatter, + private val snackbarDispatcher: SnackbarDispatcher, + private val indicatorService: IndicatorService, + private val directLogoutPresenter: Presenter, + private val showDeveloperSettingsProvider: ShowDeveloperSettingsProvider, + private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, + private val featureFlagService: FeatureFlagService, + private val sessionStore: SessionStore, +) : Presenter { + @Composable + override fun present(): PreferencesRootState { + val coroutineScope = rememberCoroutineScope() + val matrixUser = matrixClient.userProfile.collectAsState() + LaunchedEffect(Unit) { + // Force a refresh of the profile + matrixClient.getUserProfile() + } + + val isMultiAccountEnabled by remember { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount) + }.collectAsState(initial = false) + + val otherSessions by remember { + sessionStore.sessionsFlow().map { list -> + list + .filter { it.userId != matrixClient.sessionId.value } + .map { + MatrixUser( + userId = UserId(it.userId), + displayName = it.userDisplayName, + avatarUrl = it.userAvatarUrl, + ) + } + .toImmutableList() + } + }.collectAsState(initial = persistentListOf()) + + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + val hasAnalyticsProviders = remember { analyticsService.getAvailableAnalyticsProviders().isNotEmpty() } + + // We should display the 'complete verification' option if the current session can be verified + val canVerifyUserSession by sessionVerificationService.needsSessionVerification.collectAsState(false) + + val showSecureBackupIndicator by indicatorService.showSettingChatBackupIndicator() + + val accountManagementUrl: MutableState = remember { + mutableStateOf(null) + } + val devicesManagementUrl: MutableState = remember { + mutableStateOf(null) + } + var canDeactivateAccount by remember { + mutableStateOf(false) + } + val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false) + LaunchedEffect(Unit) { + canDeactivateAccount = matrixClient.canDeactivateAccount() + } + + val showBlockedUsersItem by produceState(initialValue = false) { + matrixClient.ignoredUsersFlow + .onEach { value = it.isNotEmpty() } + .launchIn(this) + } + + val showLabsItem = remember { featureFlagService.getAvailableFeatures(isInLabs = true).isNotEmpty() } + + val directLogoutState = directLogoutPresenter.present() + + LaunchedEffect(Unit) { + initAccountManagementUrl(accountManagementUrl, devicesManagementUrl) + } + + val showDeveloperSettings by showDeveloperSettingsProvider.showDeveloperSettings.collectAsState() + + fun handleEvent(event: PreferencesRootEvents) { + when (event) { + is PreferencesRootEvents.OnVersionInfoClick -> { + showDeveloperSettingsProvider.unlockDeveloperSettings(coroutineScope) + } + is PreferencesRootEvents.SwitchToSession -> coroutineScope.launch { + sessionStore.setLatestSession(event.sessionId.value) + } + } + } + + return PreferencesRootState( + myUser = matrixUser.value, + version = versionFormatter.get(), + deviceId = matrixClient.deviceId, + isMultiAccountEnabled = isMultiAccountEnabled, + otherSessions = otherSessions, + showSecureBackup = !canVerifyUserSession, + showSecureBackupBadge = showSecureBackupIndicator, + accountManagementUrl = accountManagementUrl.value, + devicesManagementUrl = devicesManagementUrl.value, + showAnalyticsSettings = hasAnalyticsProviders, + canReportBug = canReportBug, + showDeveloperSettings = showDeveloperSettings, + canDeactivateAccount = canDeactivateAccount, + showBlockedUsersItem = showBlockedUsersItem, + showLabsItem = showLabsItem, + directLogoutState = directLogoutState, + snackbarMessage = snackbarMessage, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.initAccountManagementUrl( + accountManagementUrl: MutableState, + devicesManagementUrl: MutableState, + ) = launch { + accountManagementUrl.value = matrixClient.getAccountManagementUrl(AccountManagementAction.Profile).getOrNull() + devicesManagementUrl.value = matrixClient.getAccountManagementUrl(AccountManagementAction.SessionsList).getOrNull() + } +} 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 0000000..dd03b3e --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.root + +import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList + +data class PreferencesRootState( + val myUser: MatrixUser, + val version: String, + val deviceId: DeviceId?, + val isMultiAccountEnabled: Boolean, + val otherSessions: ImmutableList, + val showSecureBackup: Boolean, + val showSecureBackupBadge: Boolean, + val accountManagementUrl: String?, + val devicesManagementUrl: String?, + val canReportBug: Boolean, + val showAnalyticsSettings: Boolean, + val showDeveloperSettings: Boolean, + val canDeactivateAccount: Boolean, + val showBlockedUsersItem: Boolean, + val showLabsItem: Boolean, + val directLogoutState: DirectLogoutState, + val snackbarMessage: SnackbarMessage?, + val eventSink: (PreferencesRootEvents) -> Unit, +) 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 0000000..c979bd2 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.root + +import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.toImmutableList + +fun aPreferencesRootState( + myUser: MatrixUser = aMatrixUser(), + otherSessions: List = emptyList(), + eventSink: (PreferencesRootEvents) -> Unit = { _ -> }, +) = PreferencesRootState( + myUser = myUser, + version = "Version 1.1 (1)", + deviceId = DeviceId("ILAKNDNASDLK"), + isMultiAccountEnabled = true, + otherSessions = otherSessions.toImmutableList(), + showSecureBackup = true, + showSecureBackupBadge = true, + accountManagementUrl = "aUrl", + devicesManagementUrl = "anOtherUrl", + showAnalyticsSettings = true, + canReportBug = true, + showDeveloperSettings = true, + showBlockedUsersItem = true, + showLabsItem = true, + canDeactivateAccount = true, + snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete), + directLogoutState = aDirectLogoutState(), + eventSink = eventSink, +) 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 0000000..66398d9 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -0,0 +1,376 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.root + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +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.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.preferences.impl.R +import io.element.android.features.preferences.impl.user.UserPreferences +import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost +import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState +import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.MatrixUserProvider +import io.element.android.libraries.matrix.ui.components.MatrixUserRow +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun PreferencesRootView( + state: PreferencesRootState, + onBackClick: () -> Unit, + onAddAccountClick: () -> Unit, + onSecureBackupClick: () -> Unit, + onManageAccountClick: (url: String) -> Unit, + onOpenAnalytics: () -> Unit, + onOpenRageShake: () -> Unit, + onOpenLockScreenSettings: () -> Unit, + onOpenAbout: () -> Unit, + onOpenDeveloperSettings: () -> Unit, + onOpenAdvancedSettings: () -> Unit, + onOpenLabs: () -> Unit, + onOpenNotificationSettings: () -> Unit, + onOpenUserProfile: (MatrixUser) -> Unit, + onOpenBlockedUsers: () -> Unit, + onSignOutClick: () -> Unit, + onDeactivateClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) + + // Include pref from other modules + PreferencePage( + modifier = modifier, + onBackClick = onBackClick, + title = stringResource(id = CommonStrings.common_settings), + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { + UserPreferences( + modifier = Modifier.clickable { + onOpenUserProfile(state.myUser) + }, + user = state.myUser, + ) + if (state.isMultiAccountEnabled) { + MultiAccountSection( + state = state, + onAddAccountClick = onAddAccountClick, + ) + } + // 'Manage my app' section + ManageAppSection( + state = state, + onOpenNotificationSettings = onOpenNotificationSettings, + onOpenLockScreenSettings = onOpenLockScreenSettings, + onSecureBackupClick = onSecureBackupClick, + ) + + // 'Account' section + ManageAccountSection( + state = state, + onManageAccountClick = onManageAccountClick, + onOpenBlockedUsers = onOpenBlockedUsers + ) + + // General section + GeneralSection( + state = state, + onOpenAbout = onOpenAbout, + onOpenAnalytics = onOpenAnalytics, + onOpenRageShake = onOpenRageShake, + onOpenAdvancedSettings = onOpenAdvancedSettings, + onOpenDeveloperSettings = onOpenDeveloperSettings, + onOpenLabs = onOpenLabs, + onSignOutClick = onSignOutClick, + onDeactivateClick = onDeactivateClick, + ) + + Footer( + version = state.version, + deviceId = state.deviceId, + onClick = if (!state.showDeveloperSettings) { + { state.eventSink(PreferencesRootEvents.OnVersionInfoClick) } + } else { + null + } + ) + } +} + +@Composable +private fun ColumnScope.MultiAccountSection( + state: PreferencesRootState, + onAddAccountClick: () -> Unit, +) { + HorizontalDivider( + thickness = 8.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) + state.otherSessions.forEach { matrixUser -> + MatrixUserRow( + modifier = Modifier.clickable { + state.eventSink(PreferencesRootEvents.SwitchToSession(matrixUser.userId)) + }, + matrixUser = matrixUser, + avatarSize = AvatarSize.AccountItem, + ) + HorizontalDivider() + } + ListItem( + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())), + headlineContent = { + Text(stringResource(CommonStrings.common_add_another_account)) + }, + onClick = onAddAccountClick, + ) + HorizontalDivider( + thickness = 8.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) +} + +@Composable +private fun ColumnScope.ManageAppSection( + state: PreferencesRootState, + onOpenNotificationSettings: () -> Unit, + onOpenLockScreenSettings: () -> Unit, + onSecureBackupClick: () -> Unit, +) { + ListItem( + headlineContent = { Text(stringResource(id = R.string.screen_notification_settings_title)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Notifications())), + onClick = onOpenNotificationSettings, + ) + ListItem( + headlineContent = { Text(stringResource(id = CommonStrings.common_screen_lock)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), + onClick = onOpenLockScreenSettings, + ) + if (state.showSecureBackup) { + ListItem( + headlineContent = { Text(stringResource(id = CommonStrings.common_encryption)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Key())), + trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge }, + onClick = onSecureBackupClick, + ) + } + HorizontalDivider() +} + +@Composable +private fun ColumnScope.ManageAccountSection( + state: PreferencesRootState, + onManageAccountClick: (url: String) -> Unit, + onOpenBlockedUsers: () -> Unit, +) { + state.accountManagementUrl?.let { url -> + ListItem( + headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())), + trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())), + onClick = { onManageAccountClick(url) }, + ) + } + + state.devicesManagementUrl?.let { url -> + ListItem( + headlineContent = { Text(stringResource(id = CommonStrings.action_manage_devices)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())), + trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())), + onClick = { onManageAccountClick(url) }, + ) + } + + if (state.showBlockedUsersItem) { + ListItem( + headlineContent = { Text(stringResource(id = CommonStrings.common_blocked_users)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())), + onClick = onOpenBlockedUsers, + ) + } + + if (state.accountManagementUrl != null || state.devicesManagementUrl != null || state.showBlockedUsersItem) { + HorizontalDivider() + } +} + +@Composable +private fun ColumnScope.GeneralSection( + state: PreferencesRootState, + onOpenAbout: () -> Unit, + onOpenAnalytics: () -> Unit, + onOpenRageShake: () -> Unit, + onOpenAdvancedSettings: () -> Unit, + onOpenLabs: () -> Unit, + onOpenDeveloperSettings: () -> Unit, + onSignOutClick: () -> Unit, + onDeactivateClick: () -> Unit, +) { + ListItem( + headlineContent = { Text(stringResource(id = CommonStrings.common_about)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())), + onClick = onOpenAbout, + ) + if (state.canReportBug) { + ListItem( + headlineContent = { Text(stringResource(id = CommonStrings.common_report_a_problem)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())), + onClick = onOpenRageShake + ) + } + if (state.showAnalyticsSettings) { + ListItem( + headlineContent = { Text(stringResource(id = CommonStrings.common_analytics)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Chart())), + onClick = onOpenAnalytics, + ) + } + ListItem( + headlineContent = { Text(stringResource(id = CommonStrings.common_advanced_settings)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Settings())), + onClick = onOpenAdvancedSettings, + ) + + if (state.showLabsItem) { + ListItem( + headlineContent = { Text(stringResource(id = R.string.screen_labs_title)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Labs())), + onClick = onOpenLabs, + ) + } + + ListItem( + headlineContent = { Text(stringResource(id = CommonStrings.action_signout)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.SignOut())), + style = ListItemStyle.Destructive, + onClick = onSignOutClick, + ) + if (state.canDeactivateAccount) { + ListItem( + headlineContent = { Text(stringResource(id = CommonStrings.action_deactivate_account)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Warning())), + style = ListItemStyle.Destructive, + onClick = onDeactivateClick, + ) + } + // Put developer settings at the end, so nothing bad happens if the user clicks 8 times to enable the entry + if (state.showDeveloperSettings) { + DeveloperPreferencesView(onOpenDeveloperSettings) + } +} + +@Composable +private fun ColumnScope.Footer( + version: String, + deviceId: DeviceId?, + onClick: (() -> Unit)?, +) { + val text = remember(version, deviceId) { + buildString { + append(version) + if (deviceId != null) { + append("\n") + append(deviceId) + } + } + } + Text( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 16.dp) + .clickable(enabled = onClick != null, onClick = onClick ?: {}) + .padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 24.dp), + textAlign = TextAlign.Center, + text = text, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) +} + +@Composable +private fun DeveloperPreferencesView(onOpenDeveloperSettings: () -> Unit) { + ListItem( + headlineContent = { Text(stringResource(id = CommonStrings.common_developer_options)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Code())), + onClick = onOpenDeveloperSettings + ) +} + +@PreviewWithLargeHeight +@Composable +internal fun PreferencesRootViewLightPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = + ElementPreviewLight { ContentToPreview(matrixUser) } + +@PreviewWithLargeHeight +@Composable +internal fun PreferencesRootViewDarkPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = + ElementPreviewDark { ContentToPreview(matrixUser) } + +@ExcludeFromCoverage +@Composable +private fun ContentToPreview(matrixUser: MatrixUser) { + PreferencesRootView( + state = aPreferencesRootState(myUser = matrixUser), + onBackClick = {}, + onAddAccountClick = {}, + onOpenAnalytics = {}, + onOpenRageShake = {}, + onOpenDeveloperSettings = {}, + onOpenAdvancedSettings = {}, + onOpenLabs = {}, + onOpenAbout = {}, + onSecureBackupClick = {}, + onManageAccountClick = {}, + onOpenNotificationSettings = {}, + onOpenLockScreenSettings = {}, + onOpenUserProfile = {}, + onOpenBlockedUsers = {}, + onSignOutClick = {}, + onDeactivateClick = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun MultiAccountSectionPreview() = ElementPreview { + Column { + MultiAccountSection( + state = aPreferencesRootState( + otherSessions = aMatrixUserList(), + ), + onAddAccountClick = {}, + ) + } +} 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 0000000..52517b5 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/VersionFormatter.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.root + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.toolbox.api.strings.StringProvider + +interface VersionFormatter { + fun get(): String +} + +@ContributesBinding(AppScope::class) +class DefaultVersionFormatter( + private val stringProvider: StringProvider, + private val buildMeta: BuildMeta, +) : VersionFormatter { + override fun get(): String { + val base = stringProvider.getString( + CommonStrings.settings_version_number, + buildMeta.versionName, + buildMeta.versionCode.toString() + ) + return if (buildMeta.gitBranchName == "main") { + base + } else { + // In case of a build not from main, we display the branch name and the revision + "$base\n${buildMeta.gitBranchName} (${buildMeta.gitRevision})" + } + } +} 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 0000000..6c26866 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ClearCacheUseCase.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.tasks + +import android.content.Context +import coil3.SingletonImageLoader +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Provider +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.features.preferences.impl.DefaultCacheService +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.push.api.PushService +import io.element.android.services.appnavstate.api.ActiveRoomsHolder +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient + +interface ClearCacheUseCase { + suspend operator fun invoke() +} + +@ContributesBinding(SessionScope::class) +class DefaultClearCacheUseCase( + @ApplicationContext private val context: Context, + private val matrixClient: MatrixClient, + private val coroutineDispatchers: CoroutineDispatchers, + private val defaultCacheService: DefaultCacheService, + private val okHttpClient: Provider, + private val pushService: PushService, + private val seenInvitesStore: SeenInvitesStore, + private val activeRoomsHolder: ActiveRoomsHolder, +) : ClearCacheUseCase { + override suspend fun invoke() = withContext(coroutineDispatchers.io) { + // Active rooms should be disposed of before clearing the cache + activeRoomsHolder.clear(matrixClient.sessionId) + // Clear Matrix cache + matrixClient.clearCache() + // Clear Coil cache + SingletonImageLoader.get(context).let { + it.diskCache?.clear() + it.memoryCache?.clear() + } + // Clear OkHttp cache + okHttpClient().cache?.delete() + // Clear app cache + context.cacheDir.deleteRecursively() + // Clear some settings + seenInvitesStore.clear() + // Ensure any error will be displayed again + pushService.setIgnoreRegistrationError(matrixClient.sessionId, false) + pushService.resetBatteryOptimizationState() + // Ensure the app is restarted + defaultCacheService.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 0000000..a7ac97c --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/ComputeCacheSizeUseCase.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.tasks + +import android.content.Context +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.file.getSizeOfFiles +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.MatrixClient +import kotlinx.coroutines.withContext + +interface ComputeCacheSizeUseCase { + suspend operator fun invoke(): String +} + +@ContributesBinding(SessionScope::class) +class DefaultComputeCacheSizeUseCase( + @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 0000000..a9066dc --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/UserPreferences.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.user + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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 + ) +} + +@PreviewsDayNight +@Composable +internal fun UserPreferencesPreview(@PreviewParameter(MatrixUserWithNullProvider::class) matrixUser: MatrixUser?) = ElementPreview { + UserPreferences(matrixUser) +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvents.kt new file mode 100644 index 0000000..f7f2ffc --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvents.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.user.editprofile + +import io.element.android.libraries.matrix.ui.media.AvatarAction + +sealed interface EditUserProfileEvents { + data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvents + data class UpdateDisplayName(val name: String) : EditUserProfileEvents + data object Exit : EditUserProfileEvents + data object Save : EditUserProfileEvents + data object CloseDialog : EditUserProfileEvents +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNavigator.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNavigator.kt new file mode 100644 index 0000000..2935bce --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNavigator.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.user.editprofile + +interface EditUserProfileNavigator { + fun close() +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNode.kt new file mode 100644 index 0000000..2303abb --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNode.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.user.editprofile + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.user.MatrixUser + +@ContributesNode(SessionScope::class) +@AssistedInject +class EditUserProfileNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: EditUserProfilePresenter.Factory, +) : Node(buildContext, plugins = plugins), + EditUserProfileNavigator { + data class Inputs( + val matrixUser: MatrixUser + ) : NodeInputs + + interface Callback : Plugin { + fun onDone() + } + + val matrixUser = inputs().matrixUser + val callback: Callback = callback() + val presenter = presenterFactory.create( + matrixUser = matrixUser, + navigator = this, + ) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + EditUserProfileView( + state = state, + onEditProfileSuccess = ::close, + modifier = modifier + ) + } + + override fun close() = callback.onDone() +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt new file mode 100644 index 0000000..5960713 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.user.editprofile + +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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 androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.core.net.toUri +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.androidutils.file.TemporaryUriDeleter +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.mediapickers.api.PickerProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsPresenter +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +@AssistedInject +class EditUserProfilePresenter( + @Assisted private val matrixUser: MatrixUser, + @Assisted private val navigator: EditUserProfileNavigator, + private val matrixClient: MatrixClient, + private val mediaPickerProvider: PickerProvider, + private val mediaPreProcessor: MediaPreProcessor, + private val temporaryUriDeleter: TemporaryUriDeleter, + private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, + permissionsPresenterFactory: PermissionsPresenter.Factory, +) : Presenter { + private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA) + private var pendingPermissionRequest = false + + @AssistedFactory + interface Factory { + fun create( + matrixUser: MatrixUser, + navigator: EditUserProfileNavigator, + ): EditUserProfilePresenter + } + + @Composable + override fun present(): EditUserProfileState { + val cameraPermissionState = cameraPermissionPresenter.present() + var userAvatarUri by rememberSaveable { mutableStateOf(matrixUser.avatarUrl) } + var userDisplayName by rememberSaveable { mutableStateOf(matrixUser.displayName) } + val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( + onResult = { uri -> + if (uri != null) { + temporaryUriDeleter.delete(userAvatarUri?.toUri()) + userAvatarUri = uri.toString() + } + } + ) + val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker( + onResult = { uri -> + if (uri != null) { + temporaryUriDeleter.delete(userAvatarUri?.toUri()) + userAvatarUri = uri.toString() + } + } + ) + + val avatarActions by remember(userAvatarUri) { + derivedStateOf { + listOfNotNull( + AvatarAction.TakePhoto, + AvatarAction.ChoosePhoto, + AvatarAction.Remove.takeIf { userAvatarUri != null }, + ).toImmutableList() + } + } + + LaunchedEffect(cameraPermissionState.permissionGranted) { + if (cameraPermissionState.permissionGranted && pendingPermissionRequest) { + pendingPermissionRequest = false + cameraPhotoPicker.launch() + } + } + + val saveAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + val localCoroutineScope = rememberCoroutineScope() + + val canSave = remember(userDisplayName, userAvatarUri) { + val hasProfileChanged = hasDisplayNameChanged(userDisplayName, matrixUser) || + hasAvatarUrlChanged(userAvatarUri, matrixUser) + !userDisplayName.isNullOrBlank() && hasProfileChanged + } + + fun handleEvent(event: EditUserProfileEvents) { + when (event) { + is EditUserProfileEvents.Save -> localCoroutineScope.saveChanges( + name = userDisplayName, + avatarUri = userAvatarUri?.toUri(), + currentUser = matrixUser, + action = saveAction, + ) + is EditUserProfileEvents.HandleAvatarAction -> { + when (event.action) { + AvatarAction.ChoosePhoto -> galleryImagePicker.launch() + AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) { + cameraPhotoPicker.launch() + } else { + pendingPermissionRequest = true + cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) + } + AvatarAction.Remove -> { + temporaryUriDeleter.delete(userAvatarUri?.toUri()) + userAvatarUri = null + } + } + } + is EditUserProfileEvents.UpdateDisplayName -> userDisplayName = event.name + EditUserProfileEvents.Exit -> { + when (saveAction.value) { + is AsyncAction.Confirming -> { + // Close the dialog right now + saveAction.value = AsyncAction.Uninitialized + navigator.close() + } + AsyncAction.Loading -> Unit + is AsyncAction.Failure, + is AsyncAction.Success -> { + // Should not happen + } + AsyncAction.Uninitialized -> { + if (canSave) { + saveAction.value = AsyncAction.ConfirmingCancellation + } else { + navigator.close() + } + } + } + } + EditUserProfileEvents.CloseDialog -> saveAction.value = AsyncAction.Uninitialized + } + } + + return EditUserProfileState( + userId = matrixUser.userId, + displayName = userDisplayName.orEmpty(), + userAvatarUrl = userAvatarUri, + avatarActions = avatarActions, + saveButtonEnabled = canSave && saveAction.value !is AsyncAction.Loading, + saveAction = saveAction.value, + cameraPermissionState = cameraPermissionState, + eventSink = ::handleEvent, + ) + } + + private fun hasDisplayNameChanged(name: String?, currentUser: MatrixUser) = + name?.trim() != currentUser.displayName?.trim() + + private fun hasAvatarUrlChanged(avatarUri: String?, currentUser: MatrixUser) = + avatarUri?.trim() != currentUser.avatarUrl?.trim() + + private fun CoroutineScope.saveChanges( + name: String?, + avatarUri: Uri?, + currentUser: MatrixUser, + action: MutableState>, + ) = launch { + val results = mutableListOf>() + suspend { + if (!name.isNullOrEmpty() && name.trim() != currentUser.displayName.orEmpty().trim()) { + results.add(matrixClient.setDisplayName(name).onFailure { + Timber.e(it, "Failed to set user's display name") + }) + } + if (avatarUri?.toString()?.trim() != currentUser.avatarUrl?.trim()) { + results.add(updateAvatar(avatarUri).onFailure { + Timber.e(it, "Failed to update user's avatar") + }) + } + if (results.all { it.isSuccess }) Unit else results.first { it.isFailure }.getOrThrow() + }.runCatchingUpdatingState(action) + } + + private suspend fun updateAvatar(avatarUri: Uri?): Result { + return runCatchingExceptions { + if (avatarUri != null) { + val preprocessed = mediaPreProcessor.process( + uri = avatarUri, + mimeType = MimeTypes.Jpeg, + deleteOriginal = false, + mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), + ).getOrThrow() + matrixClient.uploadAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow() + } else { + matrixClient.removeAvatar().getOrThrow() + } + }.onFailure { Timber.e(it, "Unable to update avatar") } + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt new file mode 100644 index 0000000..9becf6c --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.user.editprofile + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.permissions.api.PermissionsState +import kotlinx.collections.immutable.ImmutableList + +data class EditUserProfileState( + val userId: UserId, + val displayName: String, + val userAvatarUrl: String?, + val avatarActions: ImmutableList, + val saveButtonEnabled: Boolean, + val saveAction: AsyncAction, + val cameraPermissionState: PermissionsState, + val eventSink: (EditUserProfileEvents) -> Unit +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt new file mode 100644 index 0000000..56b734a --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.user.editprofile + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.permissions.api.PermissionsState +import io.element.android.libraries.permissions.api.aPermissionsState +import kotlinx.collections.immutable.toImmutableList + +open class EditUserProfileStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aEditUserProfileState(), + aEditUserProfileState(userAvatarUrl = "example://uri"), + aEditUserProfileState(saveAction = AsyncAction.ConfirmingCancellation), + ) +} + +fun aEditUserProfileState( + userId: UserId = UserId("@john.doe:matrix.org"), + displayName: String = "John Doe", + userAvatarUrl: String? = null, + avatarActions: List = emptyList(), + saveButtonEnabled: Boolean = true, + saveAction: AsyncAction = AsyncAction.Uninitialized, + cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false), + eventSink: (EditUserProfileEvents) -> Unit = {}, +) = EditUserProfileState( + userId = userId, + displayName = displayName, + userAvatarUrl = userAvatarUrl, + avatarActions = avatarActions.toImmutableList(), + saveButtonEnabled = saveButtonEnabled, + saveAction = saveAction, + cameraPermissionState = cameraPermissionState, + eventSink = eventSink, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt new file mode 100644 index 0000000..d6f0fcb --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.user.editprofile + +import androidx.activity.compose.BackHandler +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.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.SaveChangesDialog +import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.TextField +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.EditableAvatarView +import io.element.android.libraries.permissions.api.PermissionsView +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditUserProfileView( + state: EditUserProfileState, + onEditProfileSuccess: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + val isAvatarActionsSheetVisible = remember { mutableStateOf(false) } + + fun onAvatarClick() { + focusManager.clearFocus() + isAvatarActionsSheetVisible.value = true + } + + fun onBackClick() { + focusManager.clearFocus() + state.eventSink(EditUserProfileEvents.Exit) + } + + BackHandler( + enabled = true, + ::onBackClick, + ) + Scaffold( + modifier = modifier.clearFocusOnTap(focusManager), + topBar = { + TopAppBar( + titleStr = stringResource(R.string.screen_edit_profile_title), + navigationIcon = { BackButton(::onBackClick) }, + actions = { + TextButton( + text = stringResource(CommonStrings.action_save), + enabled = state.saveButtonEnabled, + onClick = { + focusManager.clearFocus() + state.eventSink(EditUserProfileEvents.Save) + }, + ) + } + ) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .padding(horizontal = 16.dp) + .navigationBarsPadding() + .imePadding() + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(24.dp)) + EditableAvatarView( + matrixId = state.userId.value, + displayName = state.displayName, + avatarUrl = state.userAvatarUrl, + avatarSize = AvatarSize.EditProfileDetails, + avatarType = AvatarType.User, + onAvatarClick = { onAvatarClick() }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + text = state.userId.value, + style = ElementTheme.typography.fontBodyLgRegular, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(40.dp)) + TextField( + label = stringResource(R.string.screen_edit_profile_display_name), + value = state.displayName, + placeholder = stringResource(CommonStrings.common_room_name_placeholder), + singleLine = true, + onValueChange = { state.eventSink(EditUserProfileEvents.UpdateDisplayName(it)) }, + ) + } + + AvatarActionBottomSheet( + actions = state.avatarActions, + isVisible = isAvatarActionsSheetVisible.value, + onDismiss = { isAvatarActionsSheetVisible.value = false }, + onSelectAction = { state.eventSink(EditUserProfileEvents.HandleAvatarAction(it)) } + ) + + AsyncActionView( + async = state.saveAction, + progressDialog = { + AsyncActionViewDefaults.ProgressDialog( + progressText = stringResource(R.string.screen_edit_profile_updating_details), + ) + }, + confirmationDialog = { confirming -> + when (confirming) { + is AsyncAction.ConfirmingCancellation -> { + SaveChangesDialog( + onSubmitClick = { state.eventSink(EditUserProfileEvents.Exit) }, + onDismiss = { state.eventSink(EditUserProfileEvents.CloseDialog) } + ) + } + } + }, + onSuccess = { onEditProfileSuccess() }, + errorTitle = { stringResource(R.string.screen_edit_profile_error_title) }, + errorMessage = { stringResource(R.string.screen_edit_profile_error) }, + onErrorDismiss = { state.eventSink(EditUserProfileEvents.CloseDialog) }, + ) + } + PermissionsView( + state = state.cameraPermissionState, + ) +} + +@PreviewsDayNight +@Composable +internal fun EditUserProfileViewPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) = + ElementPreview { + EditUserProfileView( + onEditProfileSuccess = {}, + state = state, + ) + } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/utils/ShowDeveloperSettingsProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/utils/ShowDeveloperSettingsProvider.kt new file mode 100644 index 0000000..b0cfea0 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/utils/ShowDeveloperSettingsProvider.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.utils + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.ui.utils.MultipleTapToUnlock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +@Inject +class ShowDeveloperSettingsProvider( + buildMeta: BuildMeta, +) { + companion object { + const val DEVELOPER_SETTINGS_COUNTER = 7 + } + + private val multipleTapToUnlock = MultipleTapToUnlock(DEVELOPER_SETTINGS_COUNTER) + private val isDeveloperBuild = buildMeta.buildType != BuildType.RELEASE + + private val _showDeveloperSettings = MutableStateFlow(isDeveloperBuild) + val showDeveloperSettings: StateFlow = _showDeveloperSettings + + fun unlockDeveloperSettings(scope: CoroutineScope) { + if (multipleTapToUnlock.unlock(scope)) { + _showDeveloperSettings.value = true + } + } +} diff --git a/features/preferences/impl/src/main/res/values-be/translations.xml b/features/preferences/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..b27a22b --- /dev/null +++ b/features/preferences/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,58 @@ + + + "Каб не прапусціць важны званок, зменіце налады, каб дазволіць поўнаэкранныя апавяшчэнні, калі тэлефон заблакіраваны." + "Палепшыце якасць званкоў" + "Выберыце спосаб атрымання апавяшчэнняў" + "Рэжым распрацоўшчыка" + "Падайце распрацоўнікам доступ да функцый і функцыянальным магчымасцям." + "Карыстальніцкі URL сервера Element Call" + "Усталюйце карыстальніцкі асноўны URL для Element Call." + "Адрас пазначаны няправільна, пераканайцеся, што вы ўказалі пратакол (http/https) і правільны адрас." + "Пастаўшчык push-апавяшчэнняў" + "Адключыць рэдактар фарматаванага тэксту і ўключыць Markdown." + "Апавяшчэнні аб чытанні" + "Калі выключыць, вашы пасведчанні аб прачытанні нікому не будуць адпраўляцца. Вы па-ранейшаму будзеце атрымліваць пасведчанні аб прачытанні ад іншых карыстальнікаў." + "Падзяліцеся прысутнасцю" + "Калі гэта выключана, вы не зможаце адпраўляць або атрымліваць апавяшчэнні аб прачытанні або апавяшчэнні аб наборы тэксту" + "Уключыце опцыю для прагляду паведамленняў у хроніцы." + "У вас няма заблакіраваных карыстальнікаў" + "Разблакіраваць" + "Вы зноў зможаце ўбачыць усе паведамленні." + "Разблакіраваць карыстальніка" + "Разблакіроўка…" + "Бачнае імя" + "Ваша бачнае імя" + "Узнікла невядомая памылка, і інфармацыю не ўдалося змяніць." + "Немагчыма абнавіць профіль" + "Рэдагаваць профіль" + "Абнаўленне профілю…" + "Дадатковыя налады" + "Аўдыя і відэа званкі" + "Неадпаведнасць канфігурацыі" + "Мы спрасцілі налады апавяшчэнняў, каб спрасціць пошук опцый. Некаторыя карыстальніцкія наладкі, абраныя вамі раней, не адлюстроўваюцца ў дадзеным меню, але яны ўсё яшчэ актыўныя. + +Калі вы працягнеце, некаторыя налады могуць быць зменены." + "Прамыя чаты" + "Карыстальніцкія налады для кожнага чата" + "Пры абнаўленні налад апавяшчэнняў адбылася памылка." + "Усе паведамленні" + "Толькі згадванні і ключавыя словы" + "Апавяшчаць мяне ў асабістых чатах" + "Апавяшчаць мяне ў групавых чатах" + "Уключыць апавяшчэнні на гэтай прыладзе" + "Канфігурацыя не была выпраўлена, паспрабуйце яшчэ раз." + "Групавыя чаты" + "Запрашэнні" + "Ваш хатні сервер не падтрымлівае гэтую опцыю ў зашыфраваных пакоях, вы можаце не атрымаць апавяшчэнне ў некаторых пакоях." + "Згадванні" + "Усе" + "Згадванні" + "Апавясціць мяне" + "Апавясціць пра @room" + "Каб атрымліваць апавяшчэнні, змяніце свой %1$s." + "налады сістэмы" + "Сістэмныя апавяшчэнні выключаны" + "Апавяшчэнні" + "Выпраўленне непаладак" + "Выпраўленне непаладак з апавяшчэннямі" + diff --git a/features/preferences/impl/src/main/res/values-bg/translations.xml b/features/preferences/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..704bcc2 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,59 @@ + + + "Изберете как да получавате известия" + "Режим за програмисти" + "Активирайте, за да имате достъп до функции и функционалности за програмисти." + "Скриване на профилните снимки в заявките за покана за стая" + "Експерименти" + "Качвайте снимки и видеоклипове по-бързо и намалете използването на данни" + "Оптимизиране на качеството на медията" + "Модерация и безопасност" + "Изключете редактора за форматиран текст, за да пишете Markdown ръчно." + "Потвърждения за прочитане" + "Ако е изключено, вашите потвърждения за прочитане няма да бъдат изпращани на никого. Все още ще получавате потвърждения за прочитане от други потребители." + "Споделяне на присъствието" + "Ако е изключено, няма да можете да изпращате или получавате потвърждения за прочитане или известия за писане." + "Скриване винаги" + "Показване винаги" + "В частни стаи" + "Скрита мултимедия винаги може да бъде показана, като се докосне" + "Показване на мултимедия в хронологията" + "Активиране на опцията за преглед на изходния код на съобщението в хронологията." + "Отблокиране" + "Ще можете да виждате отново всички съобщения от тях." + "Отблокиране на потребителя" + "Име" + "Вашето Име" + "Възникна неизвестна грешка и информацията не можа да бъде променена." + "Не може да се обнови профила" + "Редактиране на профила" + "Обновяване на профила…" + "Включване на отговори в нишка" + "Искате ли да експериментирате?" + "Експерименти" + "Допълнителни настройки" + "Аудио и видео разговори" + "Несъответствие в конфигурацията" + "Директни чатове" + "Персонализирана настройка за чат" + "Възникна грешка при обновяването на настройките за известия." + "Всички съобщения" + "Само споменавания и ключови думи" + "В директни чатове да бъда известяван за" + "В групови чатове да бъда известяван за" + "Включване на известията на това устройство" + "Конфигурацията не е оправена, моля, опитайте отново." + "Групови чатове" + "Покани" + "Вашият сървър не поддържа тази опция в шифровани стаи, може да не получавате известия в някои стаи." + "Споменавания" + "Споменавания" + "Да бъда известяван за" + "Известяване за @room" + "За да получавате известия, моля, променете своя %1$s" + "системни настройки" + "Системните известия са изключени" + "Известия" + "Отстраняване на неизправности" + "Отстраняване на неизправности с известията" + diff --git a/features/preferences/impl/src/main/res/values-cs/translations.xml b/features/preferences/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..0127a98 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,84 @@ + + + "Abyste nikdy nezmeškali důležitý hovor, změňte nastavení tak, abyste povolili oznámení na celé obrazovce, když je telefon uzamčen." + "Vylepšete si zážitek z volání" + "Vyberte, jak chcete přijímat oznámení" + "Vývojářský režim" + "Povolením získáte přístup k funkcím a funkcím pro vývojáře." + "Vlastní URL pro Element Call" + "Nastavte vlastní URL pro Element Call." + "Neplatné URL, ujistěte se, že jste uvedli protokol (http/https) a správnou adresu." + "Skrýt avatary v žádostech o pozvání do místnosti" + "Skrýt náhledy médií na časové ose" + "Experimentální funkce" + "Rychlejší nahrávání fotografií a videí a snížení spotřeby dat" + "Optimalizace kvality médií" + "Moderování a bezpečnost" + "Automaticky optimalizovat obrázky pro rychlejší nahrávání a menší velikosti souborů." + "Optimalizace kvality nahrávání obrázků" + "%1$s. Klepnutím sem provedete změnu." + "Vysoká (1080p)" + "Nízká (480p)" + "Standardní (720p)" + "Kvalita nahrávání videa" + "Poskytovatel push oznámení" + "Vypněte editor formátovaného textu pro ruční zadání Markdown." + "Potvrzení o přečtení" + "Pokud je vypnuto, potvrzení o přečtení se nikomu neodesílají. Stále budete dostávat potvrzení o přečtení od ostatních uživatelů." + "Sdílejte přítomnost" + "Pokud je tato funkce vypnutá, nebudete moci odesílat ani přijímat potvrzení o přečtení ani upozornění o psaní." + "Vždy skrýt" + "Vždy zobrazit" + "V soukromých místnostech" + "Skryté médium lze vždy zobrazit klepnutím na něj" + "Zobrazit média na časové ose" + "Povolit možnost zobrazení zdroje zprávy na časové ose." + "Nemáte žádné blokované uživatele" + "Odblokovat" + "Znovu uvidíte všechny zprávy od nich." + "Odblokovat uživatele" + "Odblokování…" + "Zobrazované jméno" + "Vaše zobrazované jméno" + "Došlo k neznámé chybě a informace nelze změnit." + "Nelze aktualizovat profil" + "Upravit profil" + "Aktualizace profilu…" + "Povolit odpovědi ve vlákně" + "Aplikace se restartuje, aby se tato změna projevila." + "Vyzkoušejte naše nejnovější nápady, které jsou ve vývoji. Tyto funkce nejsou finalizované; mohou být nestabilní a mohou se změnit." + "Máte chuť experimentovat?" + "Experimentální funkce" + "Další nastavení" + "Halsové a video hovory" + "Neshoda konfigurace" + "Zjednodušili jsme nastavení oznámení, abychom usnadnili hledání možností. + +Některá vlastní nastavení, která jste si vybrali v minulosti, se zde nezobrazují, ale jsou stále aktivní. + +Pokud budete pokračovat, některá nastavení se mohou změnit." + "Přímé zprávy" + "Vlastní nastavení pro chat" + "Při aktualizaci nastavení oznámení došlo k chybě." + "Všechny zprávy" + "Pouze zmínky a klíčová slova" + "V přímých zprávách mě upozornit na" + "Ve skupinových chatech mě upozornit na" + "Povolit oznámení na tomto zařízení" + "Konfigurace nebyla opravena, zkuste to prosím znovu." + "Skupinové chaty" + "Pozvánky" + "Váš domovský server tuto možnost v zašifrovaných místnostech nepodporuje, v některých místnostech nemusíte být upozorněni." + "Zmínky" + "Vše" + "Zmínky" + "Upozornit mě na" + "Upozornit mě na @room" + "Chcete-li dostávat oznámení, změňte prosím svůj %1$s." + "systémová nastavení" + "Systémová oznámení byla vypnuta" + "Oznámení" + "Historie push oznámení" + "Odstraňování problémů" + "Odstraňování problémů s upozorněními" + diff --git a/features/preferences/impl/src/main/res/values-cy/translations.xml b/features/preferences/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..9711eda --- /dev/null +++ b/features/preferences/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,76 @@ + + + "Er mwyn sicrhau fyddwch chi ddim yn colli galwad bwysig, newidiwch eich gosodiadau i ganiatáu hysbysiadau sgrin lawn pan fydd eich ffôn wedi\'i gloi." + "Gwella profiad eich galwadau" + "Dewiswch sut i dderbyn hysbysiadau" + "Modd datblygwr" + "Galluogi i gael mynediad at nodweddion a swyddogaethau datblygwyr." + "URL sylfaen Galwad Element Cyfaddas" + "Gosod URL sylfaen cyfaddas ar gyfer Galwad Element." + "URL annilys, gwnewch yn siŵr eich bod yn cynnwys y protocol (http/https) a\'r cyfeiriad cywir." + "Cuddio afatarau yn y ceisiadau gwahoddiad i ystafell" + "Cuddio rhagolygon cyfryngau yn y llinell amser" + "Llwythwch i fyny lluniau a fideos yn gynt a lleihau\'r defnydd o ddata" + "Optimeiddio ansawdd y cyfryngau" + "Cymedroli a Diogelwch" + "Optimeiddio delweddau\'n awtomatig ar gyfer llwytho cyflymach a meintiau ffeiliau llai." + "Optimeiddio ansawdd llwytho delweddau" + "%1$s Tapiwch yma i newid." + "Uchel (1080p)" + "Isel (480c)" + "Safonol (720p)" + "Ansawdd lwytho fideo" + "Darparwr hysbysiad gwthio" + "Analluogi\'r golygydd testun cyfoethog i deipio Markdown â llaw." + "Derbynebau darllen" + "Os wedi\'i ddiffodd, fydd eich derbynebau darllen ddim yn cael eu hanfon at unrhyw un. Byddwch yn dal i dderbyn derbynebau darllen gan ddefnyddwyr eraill." + "Rhannu presenoldeb" + "Os wedi\'i ddiffodd, fyddwch chi ddim yn gallu anfon na derbyn derbynebau wedi\'u darllen na hysbysiadau teipio." + "Cuddio bob tro" + "Dangos bob tro" + "Mewn ystafelloedd preifat" + "Mae modd dangos cyfrwng cudd trwy dapio arno" + "Dangos cyfryngau mewn llinell amser" + "Galluogi\'r dewis i weld ffynhonnell y neges yn y llinell amser." + "Does gennych chi ddim defnyddwyr sydd wedi\'u rhwystro" + "Dad-rwystro" + "Byddwch yn gallu gweld pob neges oddi wrthyn nhw eto." + "Dadrwystro defnyddiwr" + "Wrthi\'n dadrwystro…" + "Enw dangos" + "Eich enw dangos" + "Cafwyd gwall anhysbys a doedd dim modd newid y manylion." + "Methu diweddaru\'r proffil" + "Golygu proffil" + "Yn diweddaru proffil…" + "Gosodiadau ychwanegol" + "Galwadau sain a fideo" + "Anghydweddiad y ffurfweddiad" + "Rydym wedi symleiddio\'r Gosodiadau Hysbysiadau i\'w gwneud yn haws dod o hyd i ddewisiadau. Nid yw rhai gosodiadau cyfaddas rydych chi wedi\'u dewis yn y gorffennol yn cael eu dangos yma, ond maen nhw\'n dal yn weithredol. + +Os ewch ymlaen, efallai y bydd rhai o\'ch gosodiadau\'n newid." + "Sgyrsiau uniongyrchol" + "Gosodiad cyfaddas fesul sgwrs" + "Digwyddodd gwall wrth ddiweddaru\'r gosodiad hysbysu." + "Pob neges" + "Crybwylliadau ac Allweddeiriau\'n unig" + "Ar sgyrsiau uniongyrchol, rhoi gwybod i mi am" + "Ar sgyrsiau grŵp, rhoi gwybod i mi am" + "Galluogi hysbysiadau ar y ddyfais hon" + "Nid yw\'r ffurfweddiad wedi\'i gywiro, ceisiwch eto." + "Sgyrsiau grŵp" + "Gwahoddiadau" + "Nid yw eich gweinydd cartref yn cefnogi\'r dewis hwn mewn ystafelloedd sydd wedi\'u hamgryptio, efallai fyddwch chi ddim yn cael gwybod mewn rhai ystafelloedd." + "Crybwyll" + "Y Cyfan" + "Crybwyll" + "Rhoi gwybod i mi am" + "Rhoi gwybod i mi ar @ystafell" + "I dderbyn hysbysiadau, newidiwch eich %1$s." + "gosodiadau system" + "Hysbysiadau system wedi\'u diffodd" + "Hysbysiadau" + "Hanes gwthio" + "Datrys Problemau" + "Hysbysiadau datrys problemau" + diff --git a/features/preferences/impl/src/main/res/values-da/translations.xml b/features/preferences/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..f258fb0 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,82 @@ + + + "For at sikre, at du aldrig går glip af et vigtigt opkald, skal du ændre dine indstillinger til at tillade underretninger i fuld skærm, når din telefon er låst." + "Gør din opkaldsoplevelse bedre" + "Vælg, hvordan du vil modtage notifikationer" + "Udviklertilstand" + "Aktivér for at få adgang til funktioner og funktionalitet for udviklere." + "Brugerdefineret URL til opkaldsbase for Element" + "Angiv en brugerdefineret basis-URL til Element Call." + "Ugyldig URL, sørg for at inkludere protokollen (http/https) og den korrekte adresse." + "Skjul avatarer i anmodninger om invitation til rum" + "Skjul forhåndsvisning af medier i tidslinjen" + "Laboratorier" + "Upload fotos og videoer hurtigere, og reducér dataforbrug" + "Optimér mediekvaliteten" + "Moderation og sikkerhed" + "Optimér automatisk billeder for hurtigere uploads og mindre filstørrelser." + "Optimér kvaliteten på overførte billeder" + "%1$s. Tryk her for at ændre." + "Høj (1080p)" + "Lav (480p)" + "Standard (720p)" + "Kvalitet på overførte videoer" + "Udbyder af push-notifikationer" + "Deaktiver rich text-editoren for at skrive Markdown manuelt." + "Kvitteringer​•​for​•​læsning" + "Hvis deaktiveret, sendes dine læsekvitteringer ikke til nogen. Du vil stadig modtage læsekvitteringer fra andre brugere." + "Del tilstedeværelse" + "Hvis deaktiveret, kan du ikke sende eller modtage læsekvitteringer eller indtastningsmeddelelser." + "Skjul altid" + "Vis altid" + "I private rum" + "Et skjult medie kan altid vises ved at trykke på det" + "Vis medier i tidslinjen" + "Aktivér mulighed for at se meddelelseskilden i tidslinjen." + "Du har ingen blokerede brugere" + "Fjern blokering" + "Du vil være i stand til at se alle beskeder fra dem igen." + "Fjern blokering af bruger" + "Fjerner blokering…" + "Vist navn" + "Dit viste navn" + "Der opstod en ukendt fejl, og oplysningerne kunne ikke ændres." + "Kan ikke opdatere profilen" + "Redigér profil" + "Opdaterer profil…" + "Aktivér svar-tråde" + "Appen genstarter for at anvende denne ændring." + "Prøv vores nyeste idéer under udvikling. Disse funktioner er ikke færdige; de ​​kan være ustabile og kan ændre sig." + "Er du i humør til at eksperimentere?" + "Laboratorier" + "Yderligere indstillinger" + "Lyd- og videoopkald" + "Uoverensstemmelse i konfigurationen" + "Vi har forenklet indstillingerne for notifikationer for at gøre det nemmere at finde dem. Nogle af de brugerdefinerede indstillinger, du tidligere har valgt, vises ikke her, men de er stadig aktive. + +Hvis du fortsætter, kan nogle af dine indstillinger blive ændret." + "Direkte samtaler" + "Brugerdefineret indstilling pr. samtale" + "Der opstod en fejl under opdatering af notifikationsindstillingen." + "Alle beskeder" + "Kun omtaler og nøgleord" + "I direkte samtaler, giv mig besked om" + "I gruppesamtaler, giv mig besked ved" + "Aktivér notifikationer på denne enhed" + "Konfigurationen er ikke blevet rettet, prøv igen." + "Gruppesamtaler" + "Invitationer" + "Din hjemmeserver understøtter ikke denne mulighed i krypterede rum, og derfor er det muligt at du ikke får besked i alle rum." + "Omtaler" + "Alle" + "Omtaler" + "Giv mig besked om" + "Giv mig besked ved @room" + "For at modtage notifikationer, skal du ændre din %1$s ." + "systemindstillinger" + "Systemmeddelelser slået fra" + "Notifikationer" + "Push-historik" + "Fejlfind" + "Fejlfinding af meddelelser" + diff --git a/features/preferences/impl/src/main/res/values-de/translations.xml b/features/preferences/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..c3722ec --- /dev/null +++ b/features/preferences/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,82 @@ + + + "Damit du keinen wichtigen Anruf verpasst, ändere bitte deine Einstellungen so, dass du bei gesperrtem Telefon Benachrichtigungen im Vollbildmodus erhältst." + "Verbessere dein Anruferlebnis" + "Wähle aus, wie du Benachrichtigungen erhalten möchtest" + "Entwicklermodus" + "Aktivieren, um Zugriff auf Features und Funktionen für Entwickler zu aktivieren." + "Benutzerdefinierte Element Call Basis-URL" + "Lege eine eigene Basis-URL für Element Call fest." + "Ungültige URL, bitte gib das Protokoll (http/https) und die richtige Adresse an." + "Avatare in Chateinladungen ausblenden" + "Medienvorschau im Nachrichtenverlauf ausblenden" + "Labs" + "Lade Fotos und Videos schneller hoch und reduziere den Datenverbrauch" + "Optimiere die Medienqualität" + "Moderation und Sicherheit" + "Optimiere Bilder automatisch für schnellere Uploads und kleinere Dateigrößen." + "Optimiere die Qualität zum Hochladen von Bildern." + "%1$s. Tippe hier, um zu ändern." + "Hoch (1080p)" + "Niedrig (480p)" + "Standard (720p)" + "Video-Upload-Qualität" + "Dienst für Push-Benachrichtigungen" + "Deaktiviere den Rich-Text-Editor, um Markdown manuell einzugeben." + "Lesebestätigungen" + "Wenn diese Option deaktiviert ist, werden deine Lesebestätigungen an niemanden gesendet. Du erhältst weiterhin Lesebestätigungen von anderen Nutzern." + "Präsenz teilen" + "Wenn diese Option deaktiviert ist, kannst du keine Lesebestätigungen oder Tipp-Indikatoren senden oder empfangen." + "Immer ausblenden" + "Immer anzeigen" + "In privaten Chats" + "Ausgeblendete Medien können jederzeit durch Antippen angezeigt werden" + "Medien im Nachrichtenverlauf anzeigen" + "Aktiviere die Option, um die Quelle der Nachricht im Nachrichtenverlauf zu sehen." + "Du hast keine blockierten Nutzer" + "Blockierung aufheben" + "Der Nutzer kann dir wieder Nachrichten senden & alle Nachrichten des Nutzers werden wieder angezeigt." + "Blockierung aufheben" + "Blockierung wird aufgehoben…" + "Anzeigename" + "Dein Anzeigename" + "Ein unbekannter Fehler ist aufgetreten und die Informationen konnten nicht geändert werden." + "Profil kann nicht aktualisiert werden" + "Profil bearbeiten" + "Profil wird aktualisiert…" + "Thread-Antworten aktivieren" + "Die App wird neu gestartet, um diese Änderung zu übernehmen." + "Probier unsere neuesten Ideen in der Entwicklung aus. Diese Funktionen sind noch nicht fertiggestellt; sie können instabil sein und sich noch ändern." + "Entdeckungsfreudig?" + "Labs" + "Zusätzliche Einstellungen" + "Audio- und Videoanrufe" + "Konfiguration stimmt nicht überein" + "Wir haben die Einstellungen für Benachrichtigungen vereinfacht. Einige Einstellungen, die du gewählt hast, werden hier nicht angezeigt, sind aber immer noch aktiv. + +Wenn du fortfährst, können sich einige deiner Einstellungen ändern." + "Direktnachrichten" + "Benutzerdefinierte Einstellung pro Chat" + "Beim Aktualisieren der Benachrichtigungseinstellungen ist ein Fehler aufgetreten." + "Alle Nachrichten" + "Nur Erwähnungen und Schlüsselwörter" + "Benachrichtige mich bei Direktnachrichten über" + "Bei Gruppenchats benachrichtige mich bei" + "Benachrichtigungen auf diesem Gerät aktivieren" + "Die Konfiguration wurde nicht korrigiert, bitte versuche es erneut." + "Gruppenchats" + "Einladungen" + "Dein Homeserver unterstützt diese Option in verschlüsselten Chats nicht. In einigen Chats erhältst du möglicherweise keine Benachrichtigungen." + "Erwähnungen" + "Alle" + "Erwähnungen" + "Benachrichtige mich bei" + "Benachrichtige mich bei @room" + "Um Benachrichtigungen zu erhalten, ändere bitte deine %1$s." + "Systemeinstellungen" + "Systembenachrichtigungen deaktiviert" + "Benachrichtigungen" + "Verlauf pushen" + "Fehlerbehebung" + "Fehlerbehebung für Benachrichtigungen" + diff --git a/features/preferences/impl/src/main/res/values-el/translations.xml b/features/preferences/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..49d2f6d --- /dev/null +++ b/features/preferences/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,69 @@ + + + "Για να διασφαλίσετε ότι δεν θα χάσετε ποτέ μια σημαντική κλήση, αλλάξτε τις ρυθμίσεις σας ώστε να επιτρέπονται οι ειδοποιήσεις πλήρους οθόνης όταν το τηλέφωνό σας είναι κλειδωμένο." + "Βελτίωσε την εμπειρία κλήσεων" + "Επέλεξε τον τρόπο λήψης ειδοποιήσεων" + "Λειτουργία προγραμματιστή" + "Ενεργοποίησε την πρόσβαση σε δυνατότητες και λειτουργικότητα για προγραμματιστές." + "Προσαρμοσμένο URL βάσης κλήσεων Element" + "Όρισε μια προσαρμοσμένη διεύθυνση βάσης URL για κλήση Element." + "Μη έγκυρη διεύθυνση URL, βεβαιώσου ότι έχεις συμπεριλάβει το πρωτόκολλο (http/https) και τη σωστή διεύθυνση." + "Απόκρυψη εικόνων προφίλ σε αιτήματα πρόσκλησης αίθουσας" + "Απόκρυψη προεπισκοπήσεων πολυμέσων στο timeline" + "Ανέβασε φωτογραφίες και βίντεο γρηγορότερα και μείωσε τη χρήση δεδομένων" + "Βελτιστοποίηση ποιότητας των μέσων" + "Συντονισμός και Ασφάλεια" + "Πάροχος ειδοποιήσεων push" + "Απενεργοποίησε τον επεξεργαστή εμπλουτισμένου κειμένου για να πληκτρολογήσεις Markdown χειροκίνητα." + "Αποδεικτικά ανάγνωσης" + "Εάν απενεργοποιηθεί, τα αποδεικτικά ανάγνωσης δεν θα στέλνονται σε κανέναν. Θα εξακολουθείς να λαμβάνεις αποδεικτικά ανάγνωσης από άλλους χρήστες." + "Κοινή χρήση παρουσίας" + "Εάν απενεργοποιηθεί, δεν θα μπορείς να στέλνεις ή να λαμβάνεις αποδεικτικά ανάγνωσης ή ειδοποιήσεις πληκτρολόγησης." + "Πάντα απόκρυψη" + "Πάντα εμφάνιση" + "Σε ιδιωτικές αίθουσες" + "Ένα κρυφό πολυμέσο μπορεί πάντα να εμφανιστεί πατώντας το" + "Εμφάνιση πολυμέσων στο timeline" + "Ενεργοποίησε την επιλογή για προβολή πηγής μηνυμάτων στη ροή." + "Δεν έχεις αποκλεισμένους χρήστες" + "Άρση αποκλεισμού" + "Θα μπορείς να δεις ξανά όλα τα μηνύματα του." + "Κατάργηση αποκλεισμού χρήστη" + "Άρση αποκλεισμού…" + "Εμφανιζόμενο όνομα" + "Το εμφανιζόμενο όνομά σου" + "Παρουσιάστηκε ένα άγνωστο σφάλμα και οι πληροφορίες δεν μπορούσαν να αλλάξουν." + "Δεν είναι δυνατή η ενημέρωση του προφίλ" + "Επεξεργασία προφίλ" + "Ενημέρωση προφίλ…" + "Πρόσθετες ρυθμίσεις" + "Κλήσεις ήχου και βίντεο" + "Αναντιστοιχία διαμόρφωσης" + "Απλοποιήσαμε τις Ρυθμίσεις Ειδοποιήσεων για να διευκολύνουμε την εύρεση επιλογών. Ορισμένες προσαρμοσμένες ρυθμίσεις που έχετε επιλέξει στο παρελθόν δεν εμφανίζονται εδώ, αλλά εξακολουθούν να είναι ενεργές. + +Εάν προχωρήσεις, ορισμένες από τις ρυθμίσεις σας ενδέχεται να αλλάξουν." + "Άμεσες συνομιλίες" + "Προσαρμοσμένη ρύθμιση ανά συνομιλία" + "Παρουσιάστηκε σφάλμα κατά την ενημέρωση της ρύθμισης ειδοποίησης." + "Όλα τα μηνύματα" + "Μόνο αναφορές και λέξεις-κλειδιά" + "Σε άμεσες συνομιλίες, ειδοποίησέ με για" + "Σε ομαδικές συνομιλίες, ειδοποίησέ με για" + "Ενεργοποίηση ειδοποιήσεων σε αυτήν τη συσκευή" + "Η διαμόρφωση δεν έχει διορθωθεί, δοκίμασε ξανά." + "Ομαδικές συνομιλίες" + "Προσκλήσεις" + "Ο αρχικός διακομιστής σας δεν υποστηρίζει αυτή την επιλογή σε κρυπτογραφημένες αίθουσες, ενδέχεται να μην λαμβάνετε ειδοποιήσεις σε ορισμένες αίθουσες." + "Αναφορές" + "Όλα" + "Αναφορές" + "Ειδοποίησέ με για" + "Ειδοποίηση για @room" + "Για να λαμβάνεις ειδοποιήσεις, άλλαξε το %1$s." + "ρυθμίσεις συστήματος" + "Ειδοποιήσεις συστήματος ανενεργές" + "Ειδοποιήσεις" + "Ιστορικό push" + "Αντιμετώπιση προβλημάτων" + "Αντιμετώπιση προβλημάτων ειδοποιήσεων" + diff --git a/features/preferences/impl/src/main/res/values-en-rUS/translations.xml b/features/preferences/impl/src/main/res/values-en-rUS/translations.xml new file mode 100644 index 0000000..b1f615e --- /dev/null +++ b/features/preferences/impl/src/main/res/values-en-rUS/translations.xml @@ -0,0 +1,6 @@ + + + "Optimize media quality" + "Automatically optimize images for faster uploads and smaller file sizes." + "Optimize image upload quality" + diff --git a/features/preferences/impl/src/main/res/values-es/translations.xml b/features/preferences/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..afdc23d --- /dev/null +++ b/features/preferences/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,69 @@ + + + "Para asegurarte de que nunca te pierdas una llamada importante, modifica tus ajustes para permitir notificaciones a pantalla completa cuando el teléfono esté bloqueado." + "Mejora tu experiencia de llamada" + "Elige cómo recibir las notificaciones" + "Modo desarrollador" + "Habilita para tener acceso a características y funcionalidades para desarrolladores." + "URL base personalizada de Element Call" + "Define una URL base personalizada para Element Call." + "URL no válida, asegúrate de incluir el protocolo (http/https) y la dirección correcta." + "Ocultar avatares en las invitaciones a salas" + "Ocultar vistas previas de multimedia en la cronología" + "Sube fotos y vídeos más rápido y reduce el uso de datos" + "Optimizar la calidad de los medios" + "Moderación y seguridad" + "Proveedor de notificaciones push" + "Desactiva el editor de texto enriquecido para escribir Markdown manualmente." + "Confirmaciones de lectura" + "Si se desactiva, las confirmaciones de lectura no se enviarán a nadie. Seguirás recibiendo confirmaciones de lectura de otros usuarios." + "Compartir presencia" + "Si se desactiva, no podrás enviar ni recibir confirmaciones de lectura ni notificaciones de escritura." + "Ocultar siempre" + "Mostrar siempre" + "En las salas privadas" + "Siempre se puede mostrar un ítem multimedia oculto pulsando sobre él" + "Mostrar multimedia en la cronología" + "Habilita la opción para ver el contenido en bruto del mensaje en la cronología." + "No tienes usuarios bloqueados" + "Desbloquear" + "Podrás ver todos sus mensajes de nuevo." + "Desbloquear usuario" + "Desbloqueando…" + "Nombre público" + "Tu nombre visible" + "Se encontró un error desconocido y no se pudo cambiar la información." + "No se puede actualizar el perfil" + "Editar perfil" + "Actualizando perfil…" + "Ajustes adicionales" + "Llamadas de audio y vídeo" + "La configuración no coincide" + "Hemos simplificado la Configuración de Notificaciones para hacer las opciones más fáciles de encontrar. Algunas configuraciones personalizadas que has elegido en el pasado no se muestran aquí, pero siguen activas. + +Si continúas, es posible que algunos de tus ajustes cambien." + "Chats directos" + "Configuración personalizada por chat" + "Se ha producido un error al actualizar la configuración de notificaciones." + "Todos los mensajes" + "Únicamente Menciones y Palabras clave" + "En los chats directos, notifícame por" + "En los chats grupales, notifícame por" + "Habilitar las notificaciones en este dispositivo" + "La configuración no se ha corregido, por favor inténtalo de nuevo." + "Chats grupales" + "Invitaciones" + "Tu servidor base no admite esta opción en salas cifradas, puede que no recibas notificaciones de algunas salas." + "Menciones" + "Todos" + "Menciones" + "Notificarme para" + "Notificarme con @room" + "Para recibir notificaciones, cambia tus %1$s." + "ajustes del sistema" + "Notificaciones del sistema desactivadas" + "Notificaciones" + "Historial de notificaciones push" + "Solucionar problemas" + "Solucionar problemas con las notificaciones" + diff --git a/features/preferences/impl/src/main/res/values-et/translations.xml b/features/preferences/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..4e7ba1c --- /dev/null +++ b/features/preferences/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,82 @@ + + + "Selleks, et sul ainsamgi tähtis kõne ei jääks märkamata, siis palun muuda oma nutiseadme seadistusi nii, et lukustusvaates oleksid täisekraani mõõtu teavitused." + "Sinu tõhusad telefonikõned" + "Vali kuidas sa soovid saada teavitusi" + "Arendaja valikud" + "Selle eelistuse sisselülitamisel lisanduvad rakendusse arendaja tööks vajalikud valikud." + "Element Calli kohandatud teenuseaadress" + "Seadista kohandatud teenuseaadress Element Calli jaoks." + "Vigane url. Palun vaata, et url algaks protokolliga (http/https) ning aadress ise oleks ka õige." + "Peida jututubade kutsetest tunnuspildid" + "Peida meedia eelvaated ajajoonel" + "Katsed" + "Sellega laadid fotosid ja videoid kiiremini üles ning vähendad andmemahtu" + "Optimeeri meedia kvaliteeti" + "Modereerimine ja ohutus" + "Kiirema üleslaadimise ja väiksemate failide nimel optimeeri pilte automaatselt." + "Optimeeri üleslaaditavate piltide kvaliteeti." + "%1$s. Muutmiseks klõpsi siin." + "Kõrge (1080p)" + "Madal (480p)" + "Standard (720p)" + "Üleslaaditavate videote kvaliteet" + "Tõuketeavituste pakkuja" + "Kui soovid Markdown-vormingut käsitsi lisada, siis lülita vormindatud teksti toimeti välja." + "Lugemisteatised" + "Kui lülitad selle valiku välja, siis mitte keegi enam ei saa sinult lugemisteatisi. Küll aga saad sina teiste kasutajate lugemisteatisi." + "Jaga oma olekut" + "Kui see eelistus on välja lülitatud, siis sa ei saa ega saada ei lugemisteatisi ega kirjutamise teavitusi." + "Peida alati" + "Näita alati" + "Privaatsetes jututubades" + "Peidetud meediumi saad alati näha temal klõpsides" + "Näita ajajoonel meediat" + "Selle eelistuse sisselülitamisel on võimalik ajajoonel vaadata sõnumite lähtekoodi." + "Sa pole ühtegi kasutajat blokeerinud" + "Eemalda blokeering" + "Nüüd näed sa jälle kõiki tema sõnumeid" + "Eemalda kasutajalt blokeering" + "Eemaldame blokeeringu…" + "Kuvatav nimi" + "Sinu kuvatav nimi" + "Tekkis tundmatu viga ning teavet ei õnnestunud muuta." + "Profiili uuendamine ei õnnestunud" + "Muuda profiili" + "Profiil on muutmisel…" + "Võta kasutusele vastamine jutulõngas" + "Selle muudatuse jõustamiseks käivitub rakendus uuesti." + "Katseta meie uusimaid arendusideid. Need funktsionaalsused pole veel lõplikud, nad ei pruugi toimida parimal viisil ning võivad veel muutuda." + "Kas tahad katsetada?" + "Katsed" + "Täiendavad seadistused" + "Hääl- ja videokõned" + "Eelistused ei sobi omavahel" + "Et eelistusi oleks kergem leida, me oleme lihtsustanud teavituste seadistusi. Kuigi mõned varem valitud eelistused pole siin näha, siis nad kehtivad jätkuvalt. + +Kui sa jätkad muutmist, siis võivad muutuda ka need peidetud eelistused." + "Otsevestlused" + "Kohandatud seadistused eraldi igale vestlusele" + "Teavituste seadistamisel tekkis viga" + "Kõikide sõnumite korral" + "Mainimiste ja võtmesõnade alusel" + "Otsevestluste puhul teavita mind" + "Jututubades teavita mind" + "Lülita teavitused selles seadmes sisse" + "Seadistus on veel parandamata, palun proovi uuesti." + "Rühmavestlused" + "Kutsed" + "Sinu koduserver ei toeta seda funktsionaalsust krüptitud jututubades ja seega ei pruugi kõik teavitused sinuni jõuda." + "Mainimiste alusel" + "Kõik" + "Mainimiste alusel" + "Teavita mind" + "Teavita mind @jututoa puhul" + "Teavituste saamiseks palun muuda oma %1$s." + "süsteemi seadistusi" + "Süsteemi teavitused on välja lülitatud" + "Teavitused" + "Tõuketeadete ajalugu" + "Veaotsing" + "Teavituste veaotsing" + diff --git a/features/preferences/impl/src/main/res/values-eu/translations.xml b/features/preferences/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..42a90e0 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,58 @@ + + + "Dei garrantzitsurik galduko ez duzula ziurtatzeko, aldatu ezarpenak telefonoa blokeatuta dagoenean pantaila osoko jakinarazpenak baimentzeko." + "Hobetu deien esperientzia" + "Aukeratu jakinarazpenak nola jaso" + "Garatzaile modua" + "Gaitu garatzaileentzako ezaugarrietarako eta funtzionalitateetarako sarbidea izateko." + "Igo argazkiak eta bideoak azkarrago eta murriztu datuen erabilera" + "Optimizatu multimediaren kalitatea" + "Moderazioa eta Segurtasuna" + "Optimizatu irudien igoera-kalitatea" + "Handia (1080p)" + "Txikia (480p)" + "Ertaina (720p)" + "Bideoen igoera-kalitatea" + "Push jakinarazpen hornitzailea" + "Desgaitu testu aberatseko editorea Markdown eskuz idazteko." + "Irakurketa-agiriak" + "Desaktibatutaz gero, ez zaizkio inori bidaliko mezuak irakurri izanaren agiriak. Beste erabiltzaile batzuen irakurketa-agiriak jasoko dituzu oraindik ere." + "Partekatu presentzia" + "Ezkutatu beti" + "Erakutsi beti" + "Gaitu aukera mezuaren iturria denbora-lerroan ikusteko." + "Ez duzu erabiltzailerik blokeatu" + "Desblokeatu" + "Beraien mezu guztiak berriro ikusteko aukera izango duzu." + "Desblokeatu erabiltzailea" + "Desblokeatzen…" + "Pantaila-izena" + "Zure pantaila-izena" + "Errore ezezagun bat aurkitu da eta ezin izan da informazioa aldatu." + "Ezin da profila eguneratu" + "Editatu profila" + "Profila eguneratzen…" + "Ezarpen gehiago" + "Audio eta bideo deiak" + "Konfigurazioa ez dator bat" + "Txat zuzenak" + "Errorea gertatu da jakinarazpen-ezarpena eguneratzean." + "Mezu guztiak" + "Aipamenak eta hitz gakoak soilik" + "Zuzeneko txatetan, jakinarazi" + "Taldeko txatetan, jakinarazi" + "Gaitu jakinarazpenak gailu honetan" + "Konfigurazioa ez da zuzendu; saiatu berriro." + "Taldeko txatak" + "Gonbidapenak" + "Zure zerbitzaria ez da bateragarria enkriptatutako gelen aukerarekin; litekeena da gela batzuetako jakinarazpenak ez jasotzea." + "Aipamenak" + "Guztia" + "Aipamenak" + "Jakinarazi" + "Jakinarazi @gelan" + "Jakinarazpenak jasotzeko, aldatu %1$s." + "sistemaren ezarpenak" + "Sistemaren jakinarazpenak desaktibatuta daude" + "Jakinarazpenak" + diff --git a/features/preferences/impl/src/main/res/values-fa/translations.xml b/features/preferences/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..05e73fe --- /dev/null +++ b/features/preferences/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,70 @@ + + + "بهبود تجریهٔ تماستان" + "گزینش چگونگی دریافت آگاهی" + "حالت توسعه‌دهنده" + "دسترسی به ویژگی ها و عملکردها را برای توسعه دهندگان فعال کنید." + "نشانی پایهٔ تماس المنتی سفارشی" + "تنظمی نشانی پایه‌‌ای سفارشی برای تماس المنتی." + "URL نامعتبر، لطفا مطمئن شوید که پروتکل (http/https) و آدرس صحیح را درج کرده اید." + "نهفتن چهرک‌ها در درخواست‌های دعوت اتاق" + "نهفتن رسانه در خط زمانی" + "آزمایشگاه‌ها" + "بهینه سازی کیفیت رسانه" + "نظارت و امنیت" + "زیاد (۱۰۸۰ت)" + "کم (۴۸۰ت)" + "استاندارد (۷۲۰ت)" + "کیفیت بارگذاری ویدیو" + "فراهم کنندهٔ آگاهی‌های ارسالی" + "از کار انداختن ویرایشگر متن غنی یا نوشتن دستی مارک‌دون." + "رسید‌های خواندن" + "هم‌رسانی حضور" + "نهفتن همیشگی" + "نمایش همیشگی" + "در اتاق‌های خصوصی" + "رسانه‌های نهفته همواره خواهند توانست با زدن رویشان نمایان شوند" + "نمایش رسانه در خط زمانی" + "هیچ کاربر مسدودی ندارید" + "رفع انسداد" + "قادر خواهید بود دوباره همهٔ پیام‌هایش را ببینید." + "رفع انسداد کاربر" + "رفع کردن انسداد…" + "نام نمایشی" + "نام نمایشیتان" + "خطایی ناشناخته رخ داد و اطّلاعات نتوانستند تغییر کنند." + "ناتوان در به‌روز کردن نمایه" + "ویرایش نمایه" + "به‌روز کردن نمایه…" + "آزمایشگاه‌ها" + "تنظیمات اضافی" + "تماس‌های صوتی و تصویری" + "نامتطابقت در پیکربندی" + "تنظمیات آگاهی را ساده کرده‌ایم تا یافتن انتخاب‌ها را ساده‌تر کنیم. برهی تنظمیات سفارسی که در گذشته گزیده‌اید این‌جا نشان داده نمی‌شوند؛ ولی همچنن فعّالند. + +با ادامه داد ممکن است برخی تنظیماتتان تغییر کنند." + "گپ‌های مستقیم" + "تنظیمات سفارشی برای هر گپ" + "هنگام به‌روز کردن تنظیمات آگاهی خطایی رخ داد." + "همهٔ پیام‌ها" + "فقط اشاره‌ها و کلیدواژگان" + "آگاهی در گپ‌های مستقیم برای" + "آگاهی در گپ‌های گروهی برای" + "به کار انداختن آگاهی‌ها روی این افزاره" + "پیکربندی درست نشد. لطفاً دوباره تلاش کنید." + "گپ‌های گروهی" + "دعوت‌ها" + "کارساز خانگیتان از این گزینه در اتاق‌های رمز شده پشتیبانی نمی‌کند. ممکن است در برخی اتاق‌ها آگاه نشوید." + "اشاره‌ها" + "همه" + "اشاره‌ها" + "آگاه کردنم برای" + "آگاه کردنم برای ‪@room" + "برای گرفتن آگاهی‌ها لطفاً%1$sتان را تغییر دهید." + "تنظیمات سامانه" + "آگاهی‌های سامانه‌ای خاموش شدند" + "آگاهی‌ها" + "تاریخچهٔ آگاهی‌های ارسالی" + "رفع‌اشکال" + "رفع‌اشکال آگاهی‌ها" + diff --git a/features/preferences/impl/src/main/res/values-fi/translations.xml b/features/preferences/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..f1462f3 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,82 @@ + + + "Salli koko näytön ilmoitukset, kun laite on lukittu, jos et halua koskaan missata tärkeää puhelua." + "Paranna puhelukokemustasi" + "Valitse, miten haluat vastaanottaa ilmoituksia" + "Kehittäjätila" + "Ottamalla käyttöön pääset käsiksi kehittäjille tarkoitettuihin ominaisuuksiin." + "Mukautettu Element Call URL-osoite" + "Aseta mukautettu URL-osoite Element Callille." + "Virheellinen URL-osoite. Varmista, että sisällytät protokollan (http/https) ja oikean osoitteen." + "Piilota huoneiden avatarit kutsuista" + "Piilota median esikatselu aikajanalla" + "Labrat" + "Lähetä valokuvia ja videoita nopeammin ja vähennä datan käyttöä." + "Optimoi median laatu" + "Moderointi ja Turvallisuus" + "Optimoi kuvat automaattisesti nopeampia lähetysnopeuksia ja pienempiä tiedostokokoja varten." + "Optimoi kuvien lähetyslaatu" + "%1$s. Napauta tästä vaihtaaksesi." + "Korkea (1080p)" + "Matala (480p)" + "Normaali (720p)" + "Videon lähetyslaatu" + "Push-ilmoitusten tarjoaja" + "Ota rikastettu tekstieditori pois käytöstä, jotta voit kirjoittaa Markdownia manuaalisesti." + "Lukukuittaukset" + "Jos tämä on poissa päältä, sinun lukukuittauksia ei lähetetä kenellekään. Vastaanotat silti lukukuittauksia muilta käyttäjiltä." + "Jaa läsnäolo" + "Jos tämä on poissa päältä, et lähetä tai vastaanota lukukuittauksia tai kirjoitusilmotuksia." + "Piilota aina" + "Näytä aina" + "Yksityisissä huoneissa" + "Piilotetun median voi aina näyttää napauttamalla sitä" + "Näytä media aikajanalla" + "Ota käyttöön mahdollisuus tarkastella viestin lähdettä aikajanalla." + "Et ole estänyt ketään" + "Poista esto" + "Näet jälleen kaikki heidän lähettämänsä viestit." + "Poista käyttäjän esto" + "Poistetaan estoa…" + "Näyttönimi" + "Näyttönimesi" + "Tuntematon virhe tapahtui, eikä tietoja voitu muuttaa." + "Profiilin muokkaaminen ei onnistunut" + "Muokkaa profiilia" + "Muokataan profiilia…" + "Ota käyttöön viestiketjuvastaukset" + "Sovellus käynnistyy uudelleen muutoksen käyttöönottamiseksi." + "Kokeile uusimpia kehitteillä olevia ideoitamme. Nämä ominaisuudet eivät ole vielä valmiita; ne voivat olla epävakaita ja muuttua." + "Kokeilunhaluinen olo?" + "Labrat" + "Lisäasetukset" + "Ääni- ja videopuheluista" + "Konfiguraatio ei täsmää" + "Olemme yksinkertaistaneet ilmoitusasetuksia, jotta vaihtoehdot olisi helpompi löytää. Joitakin aiemmin valitsemiasi asetuksia ei näytetä tässä, mutta ne ovat edelleen voimassa. + +Jos jatkat, jotkin asetukset saattavat muuttua." + "Yksityiskeskusteluissa" + "Keskustelukohtaiset asetukset" + "Ilmoitusasetusten muokkaamisessa tapahtui virhe." + "Kaikista viesteistä" + "Vain maininnoista ja avainsanoista" + "Yksityiskeskusteluissa, ilmoita minulle" + "Ryhmäkeskusteluissa, ilmoita minulle" + "Ota ilmoitukset käyttöön tällä laitteella" + "Määritystä ei ole korjattu, yritä uudelleen." + "Ryhmäkeskusteluissa" + "Kutsut" + "Kotipalvelimesi ei tue tätä vaihtoehtoa salatuissa huoneissa, joten et ehkä saa ilmoitusta joissakin huoneissa." + "Maininnat" + "Kaikki" + "Maininnat" + "Ilmoita minulle" + "Ilmoita minulle @room-maininnoista" + "Jos haluat saada ilmoituksia, vaihda %1$s." + "järjestelmäsi asetuksia" + "Järjestelmän ilmoitukset on poissa päältä" + "Ilmoitukset" + "Push-historia" + "Vianmääritys" + "Ilmoitusten vianmääritys" + diff --git a/features/preferences/impl/src/main/res/values-fr/translations.xml b/features/preferences/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..5ee9138 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,82 @@ + + + "Afin de ne jamais manquer un appel important, veuillez modifier vos paramètres pour autoriser les notifications en plein écran lorsque votre appareil est verrouillé." + "Améliorez votre expérience d’appel" + "Choisissez le mode de réception des notifications" + "Mode développeur" + "Activer pour pouvoir accéder aux fonctionnalités destinées aux développeurs." + "URL de base pour Element Call personnalisée" + "Configurer une URL de base pour Element Call." + "URL invalide, assurez-vous d’inclure le protocol (http/https) et l’adresse correcte." + "Masquer les avatars des salons dans les invitations" + "Masquer les aperçus des médias dans les discussions" + "Expérimental" + "Téléchargez des photos et des vidéos plus rapidement et réduisez la consommation de données" + "Optimisez la qualité des médias" + "Modération et sécurité" + "Optimiser automatiquement les images pour des envois plus rapides et des tailles de fichiers plus petites." + "Optimiser la qualité des images envoyées" + "%1$s. Appuyez ici pour changer." + "Haute définition (1080p)" + "Basse résolution (480p)" + "Résolution standard (720p)" + "Qualité des vidéos envoyées" + "Fournisseur de Push" + "Désactivez l’éditeur de texte enrichi pour saisir manuellement du Markdown." + "Accusés de lecture" + "En cas de désactivation, vos accusés de lecture ne seront pas envoyés aux autres membres. Vous verrez toujours les accusés des autres membres." + "Partager la présence" + "Si cette option est désactivée, vous ne pourrez ni envoyer ni recevoir de confirmations de lecture ni de notifications de saisie." + "Toujours cacher" + "Toujours montrer" + "Dans les salons privés" + "Un média caché peut toujours être affiché en cliquant dessus" + "Afficher les médias dans les discussions." + "Activer cette option pour pouvoir voir la source des messages dans la discussion." + "Vous n’avez bloqué personne" + "Débloquer" + "Vous pourrez à nouveau voir tous ses messages." + "Débloquer l’utilisateur" + "Déblocage…" + "Pseudonyme" + "Votre pseudonyme" + "Une erreur inconnue s’est produite et les informations n’ont pas pu être modifiées." + "Impossible de mettre à jour le profil" + "Modifier le profil" + "Mise à jour du profil…" + "Activez les fils de discussion." + "Un changement entraînera le redémarrage de l’application." + "Découvrez nos dernières idées en cours de développement. Ces fonctionnalités ne sont pas encore finalisées; elles peuvent être instables et évoluer." + "Envie d’expérimenter?" + "Expérimental" + "Réglages supplémentaires" + "Appels audio et vidéo" + "Incompatibilité de configuration" + "Nous avons simplifié les paramètres des notifications pour que les options soient plus faciles à trouver. Certains paramètres personnalisés que vous avez choisis par le passé ne sont pas affichés ici, mais ils sont toujours actifs. + +Si vous continuez, il est possible que certains de vos paramètres soient modifiés." + "Discussions directes" + "Paramétrage personnalisé par salon" + "Une erreur s’est produite lors de la mise à jour du paramètre de notification." + "Tous les messages" + "Mentions et mots clés uniquement" + "Sur les discussions directes, prévenez-moi pour" + "Lors de discussions de groupe, prévenez-moi pour" + "Activer les notifications sur cet appareil" + "La configuration n’a pas été corrigée, veuillez réessayer." + "Discussions de groupe" + "Invitations" + "Votre serveur d’accueil ne supporte pas cette option pour les salons chiffrés, vous pourriez ne pas être notifié(e) dans certains salons." + "Mentions" + "Tous" + "Mentions" + "Prévenez-moi pour" + "Prévenez-moi si un message contient \"@room\"" + "Pour recevoir des notifications, veuillez modifier votre %1$s." + "paramètres du système" + "Les notifications du système sont désactivées" + "Notifications" + "Historique des Push" + "Dépannage" + "Dépanner les notifications" + diff --git a/features/preferences/impl/src/main/res/values-hu/translations.xml b/features/preferences/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..c588e60 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,82 @@ + + + "Hogy sose maradjon le egyetlen fontos hívásról sem, a beállításokban engedélyezze a teljes képernyős értesítéseket, amikor a telefon zárolva van." + "Fokozza a hívásélményét" + "Válassza ki az értesítések fogadási módját" + "Fejlesztői mód" + "Engedélyezze, hogy elérje a fejlesztőknek szánt funkciókat." + "Egyéni Element Call alapwebcím" + "Egyéni alapwebcím beállítása az Element Callhoz." + "Érvénytelen webcím, győződjön meg arról, hogy szerepel-e benne a protokoll (http/https), és hogy helyes-e a cím." + "Profilképek elrejtése a szobameghívókban" + "Médiaelőnézetek elrejtése az idővonalon" + "Kísérletek" + "Töltse fel gyorsabban a fényképeket és videókat, valamint csökkentse az adatforgalmat" + "Média minőségének optimalizálása" + "Moderálás és biztonság" + "Képek automatikus optimalizációja a gyorsabb feltöltések és kisebb fájlméretek érdekében." + "Képfeltöltési minőség optimalizációja" + "%1$s. Koppintson a megváltoztatáshoz." + "Magas (1080p)" + "Alacsony (480p)" + "Szokásos (720p)" + "Feltöltött videó minősége" + "Leküldéses értesítések szolgáltatója" + "A formázott szöveges szerkesztő letiltása, hogy kézzel írhasson Markdownt." + "Olvasási visszaigazolások" + "Ha ki van kapcsolva, az olvasási visszaigazolások nem lesznek elküldve senkinek. A többi felhasználó olvasási visszaigazolását továbbra is meg fogja kapni." + "Jelenlét megosztása" + "Ha ki van kapcsolva, nem tud olvasási visszaigazolást vagy írási értesítést küldeni és fogadni" + "Elrejtés mindig" + "Megjelenítés mindig" + "Privát szobákban" + "A rejtett médiatartalmak koppintással jeleníthetők meg" + "Média megjelenítése az idővonalon" + "Engedélyezze a beállítást az üzenet forrásának megjelenítéséhez az idővonalon." + "Nincsenek letiltott felhasználók" + "Letiltás feloldása" + "Újra látni fogja az összes üzenetét." + "Felhasználó letiltásának feloldása" + "Tiltás feloldása…" + "Megjelenítendő név" + "Saját megjelenítendő név" + "Ismeretlen hiba történt, és az információ módosítása nem sikerült." + "Nem sikerült frissíteni a profilt" + "Profil szerkesztése" + "Profil frissítése…" + "Üzenetszál válaszok engedélyezése" + "Az alkalmazás újraindul, hogy a változás érvénybe lépjen." + "Próbálja ki legújabb fejlesztéseinket. Ezek a funkciók még nem véglegesek, instabilak lehetnek és változhatnak." + "Kísérletezni szeretne?" + "Kísérletek" + "További beállítások" + "Hang- és videóhívások" + "Konfigurációs eltérés" + "Egyszerűsítettük az értesítési beállításokat, hogy könnyebben megtalálhatók legyenek a lehetőségek. A korábban kiválasztott egyéni beállítások némelyike nem jelenik meg itt, de továbbra is aktív. + +Ha folytatja, egyes beállítások megváltozhatnak." + "Közvetlen csevegések" + "Egyéni beállítás csevegésenként" + "Hiba történt az értesítési beállítás frissítésekor." + "Összes üzenet" + "Csak említések és kulcsszavak" + "Közvetlen csevegéseknél értesítés ezekről:" + "Csoportos csevegésekben értesítés ezekről:" + "Értesítések engedélyezése ezen az eszközön" + "A konfiguráció nem lett kijavítva, próbálja újra." + "Csoportos csevegések" + "Meghívók" + "A Matrix-kiszolgálója nem támogatja ezt a beállítást a titkosított szobákban, előfordulhat, hogy egyes szobákban nem kap értesítést." + "Említések" + "Összes" + "Említések" + "Értesítés ezekről:" + "Értesítés a @room említésekor" + "Az értesítések fogadásához kérjük, módosítsa a %1$s." + "rendszerbeállításokat" + "A rendszerértesítések ki vannak kapcsolva" + "Értesítések" + "Leküldéses értesítések előzmények" + "Hibaelhárítás" + "Értesítések hibaelhárítása" + diff --git a/features/preferences/impl/src/main/res/values-in/translations.xml b/features/preferences/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..ffc00d3 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,71 @@ + + + "Untuk memastikan Anda tidak melewatkan panggilan penting, silakan ubah pengaturan Anda untuk memperbolehkan notifikasi layar penuh ketika ponsel Anda terkunci." + "Tingkatkan pengalaman panggilan Anda" + "Pilih cara menerima notifikasi" + "Mode pengembang" + "Aktifkan untuk mengakses fitur dan fungsi untuk para pengembang." + "URL dasar Element Call khusus" + "Tetapkan URL dasar khusus untuk Element Call." + "URL tidak valid, pastikan Anda menyertakan protokol (http/https) dan alamat yang benar." + "Sembunyikan avatar dalam permintaan undangan ruangan" + "Sembunyikan pratinjau media pada lini masa" + "Unggah foto dan video lebih cepat dan kurangi penggunaan data" + "Optimalkan kualitas media" + "Moderasi dan Keamanan" + "Penyedia notifikasi dorongan" + "Nonaktifkan penyunting teks kaya untuk mengetik Markdown secara manual." + "Laporan dibaca" + "Jika dimatikan, laporan dibaca Anda tidak akan dikirim kepada siapa pun. Anda masih akan menerima laporan dibaca dari pengguna lain." + "Bagikan presensi" + "Jika dimatikan, Anda tidak akan dapat mengirim atau menerima laporan dibaca atau notifikasi pengetikan" + "Selalu sembunyikan" + "Selalu tampilkan" + "Dalam ruangan privat" + "Media tersembunyi selalu dapat ditampilkan dengan mengetuknya" + "Tampilkan media pada lini masa" + "Aktifkan opsi untuk melihat sumber pesan dalam lini masa." + "Anda tidak memiliki pengguna yang diblokir" + "Buka blokir" + "Anda akan dapat melihat semua pesan dari mereka lagi." + "Buka blokir pengguna" + "Membatalkan pemblokiran…" + "Nama tampilan" + "Nama tampilan Anda" + "Terjadi kesalahan yang tidak diketahui dan informasi tidak dapat diubah." + "Tidak dapat memperbarui profil" + "Sunting profil" + "Memperbarui profil…" + "Pengaturan tambahan" + "Panggilan audio dan video" + "Ketidakcocokan pengaturan" + "Kami telah menyederhanakan Pengaturan Pemberitahuan untuk membuat opsi lebih mudah ditemukan. + +Beberapa pengaturan khusus yang Anda pilih di masa lalu tidak ditampilkan di sini, tetapi masih aktif. + +Jika Anda melanjutkan, beberapa pengaturan Anda dapat berubah." + "Obrolan langsung" + "Pengaturan khusus per obrolan" + "Terjadi kesalahan saat memperbarui pengaturan pemberitahuan." + "Semua pesan" + "Sebutan dan Kata Kunci saja" + "Di obrolan langsung, beri tahu saya tentang" + "Di obrolan grup, beri tahu tentang" + "Aktifkan pemberitahuan di perangkat ini" + "Pengaturan belum diperbaiki, silakan coba lagi." + "Obrolan grup" + "Undangan" + "Homeserver Anda tidak mendukung opsi ini dalam ruangan terenkripsi, Anda mungkin tidak diberi tahu dalam beberapa ruangan." + "Sebutan" + "Semua" + "Sebutan" + "Beri tahu saya tentang" + "Beri tahu saya pada @room" + "Untuk menerima pemberitahuan, silakan ubah %1$s Anda." + "pengaturan sistem" + "Pemberitahuan sistem dimatikan" + "Notifikasi" + "Riwayat dorongan" + "Pemecahan masalah" + "Pecahkan masalah notifikasi" + diff --git a/features/preferences/impl/src/main/res/values-it/translations.xml b/features/preferences/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..a10dcc9 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,82 @@ + + + "Per non perdere mai una chiamata importante, modifica le impostazioni per consentire le notifiche a schermo intero quando il telefono è bloccato." + "Migliora la tua esperienza di chiamata" + "Scegli come ricevere le notifiche" + "Modalità sviluppatore" + "Attiva per avere accesso alle funzionalità per sviluppatori." + "URL base di Element Call personalizzato" + "Imposta un URL di base personalizzato per Element Call." + "URL non valido, assicurati di includere il protocollo (http/https) e l\'indirizzo corretto." + "Nascondi gli avatar nelle richieste di invito alle stanze" + "Nascondi le anteprime dei media nelle conversazioni" + "Labs" + "Carica foto e video più velocemente e riduci l\'utilizzo dei dati" + "Ottimizza la qualità dei contenuti multimediali" + "Moderazione e Sicurezza" + "Ottimizza automaticamente le immagini per caricamenti più rapidi e file di dimensioni ridotte." + "Ottimizza la qualità del caricamento delle immagini" + "%1$s. Tocca qui per cambiarla." + "Alta (1080p)" + "Bassa (480p)" + "Standard (720p)" + "Qualità del caricamento video" + "Fornitore di notifiche push" + "Disattiva l\'editor di testo avanzato per scrivere manualmente in Markdown" + "Conferme di visualizzazione" + "Se disattivato, le tue conferme di visualizzazione non verranno inviate a nessuno. Riceverai comunque conferme di visualizzazione da altri utenti." + "Condividi presenza online" + "Se disattivato, non potrai né inviare né ricevere conferme di lettura o notifiche di scrittura." + "Nascondi sempre" + "Mostra sempre" + "Nelle stanze private" + "Un file multimediale nascosto può sempre essere visualizzato toccandolo" + "Mostra i media nella conversazione" + "Attiva l\'opzione per visualizzare il codice sorgente del messaggio nella conversazione." + "Non hai utenti bloccati" + "Sblocca" + "Potrai vedere di nuovo tutti i suoi messaggi." + "Sblocca utente" + "Sblocco in corso…" + "Nome visualizzato" + "Il tuo nome visualizzato" + "Si è verificato un errore sconosciuto e non è stato possibile modificare le informazioni." + "Impossibile aggiornare il profilo" + "Modifica profilo" + "Aggiornamento del profilo…" + "Abilita le risposte alle discussioni" + "L\'app si riavvierà per applicare questa modifica." + "Prova le nostre ultime idee in fase di sviluppo. Queste funzionalità non sono definitive; potrebbero essere instabili e soggette a modifiche." + "Hai voglia di sperimentare?" + "Labs" + "Impostazioni aggiuntive" + "Chiamate audio e video" + "Mancata corrispondenza di configurazione" + "Abbiamo semplificato le impostazioni di notifica per rendere le opzioni più facili da trovare. Alcune impostazioni personalizzate che hai scelto in passato non sono mostrate qui, ma sono ancora attive. + +Se procedi, alcune delle tue impostazioni potrebbero cambiare." + "Conversazioni dirette" + "Impostazione personalizzata per conversazione" + "Si è verificato un errore durante l\'aggiornamento delle impostazioni di notifica." + "Tutti i messaggi" + "Solo menzioni e parole chiave" + "Nelle conversazioni dirette, avvisami per" + "Nelle conversazioni di gruppo, avvisami per" + "Attiva le notifiche su questo dispositivo" + "La configurazione non è stata corretta, riprova." + "Chat di gruppo" + "Inviti" + "Il tuo homeserver non supporta questa opzione nelle stanze crifrate, quindi potresti non ricevere notifiche in alcune stanze." + "Menzioni" + "Tutto" + "Menzioni" + "Avvisami per" + "Avvisami con @room" + "Per ricevere notifiche, modifica le tue %1$s." + "impostazioni di sistema" + "Notifiche di sistema disattivate" + "Notifiche" + "Cronologia push" + "Risoluzione dei problemi" + "Risoluzione di problemi delle notifiche" + diff --git a/features/preferences/impl/src/main/res/values-ka/translations.xml b/features/preferences/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..1efe197 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,55 @@ + + + "აირჩიეთ, თუ როგორ გსურთ შეტყობინებების მიღება" + "დეველოპერის რეჟიმი" + "ჩართეთ დეველოპერების ფუნქციებზე წვდომა." + "მორგებული Element-ის ზარის საბაზისო URL" + "დააყენეთ საბაზისო URL Element-ის ზარებისათვის." + "არასწორი URL, გთხოვთ, დარწმუნდეთ, რომ შეიტანეთ პროტოკოლი (http/https) და სწორი მისამართი." + "გამორთეთ მდიდარი ტექსტის რედაქტორი, რათა ხელით აკრიფოთ Markdown." + "წაკითხვის შეტყობინებები" + "გამორთვის შემთხვევაში სხვები ვერ მიიღებენ თქვენი წაკითხვის შეტყობინებებს. თქვენ მაინც მიიღებთ სხვების წაკითხვის შეტყობინებებს." + "მყოფობის გაზიარება" + "გამორთვის შემთხვევაში თქვენ ვერ მიიღებთ და ვერ გაგზავნით წაკითხვის ან წერის შეტყობინებებს." + "ჩართეთ ოპცია რათა შეტყობინების წყაროს დროის ისტორია ნახოთ." + "თქვენ არ დაგიბლოკავთ მომხმარებლები" + "განბლოკვა" + "თქვენ კვლავ შეძლებთ მათგან ყველა შეტყობინების ნახვას." + "Მომხმარებლის განბლოკვა" + "განბლოკვა…" + "ნაჩვენები სახელი" + "თქვენი ნაჩვენები სახელი" + "დაფიქსირდა უცნობი შეცდომა და ინფორმაციის შეცვლა ვერ მოხერხდა." + "პროფილის განახლება ვერ მოხერხდა" + "Პროფილის რედაქტირება" + "პროფილის განახლება…" + "დამატებითი პარამეტრები" + "აუდიო და ვიდეო ზარები" + "კონფიგურაციის შეუსაბამობა" + "ჩვენ გავამარტივეთ შეტყობინებების პარამეტრები, რათა გაგიადვილოთ ვარიანტების პოვნა. + +თქვენ მიერ წარსულში არჩეული ზოგიერთი მორგებული პარამეტრი აქ არ არის ნაჩვენები, მაგრამ ისინი კვლავ აქტიურია. თუ გააგრძელებთ, თქვენი ზოგიერთი პარამეტრი შეიძლება შეიცვალოს." + "პირდაპირი ჩატები" + "მორგებული პარამეტრი ჩატზე" + "შეტყობინებების პარამეტრის განახლებისას მოხდა შეცდომა." + "ყველა შეტყობინება" + "მხოლოდ ხსენებები და საკვანძო სიტყვები" + "პირდაპირ ჩატებზე, შემატყობინეთ:" + "ჯგუფურ ჩატებზე, შემატყობინეთ:" + "შეტყობინებების ჩართვა ამ მოწყობილობაზე" + "კონფიგურაცია არ გამოსწორებულა, გთხოვთ, კვლავ სცადოთ." + "ჯგუფური ჩატები" + "მოსაწვევები" + "თქვენი სახლის სერვერი არ უჭერს მხარს ამ პარამეტრს დაშიფრულ ოთახებში, ზოგიერთ ოთახში შეიძლება არ მიიღოთ შეტყობინება." + "ხსენებები" + "ყველა" + "ხსენებები" + "ჩემი შეტყობინება შემდეგისთვის:" + "ჩემი შეტყობინება @room-ზე" + "შეტყობინებების მისაღებად გთხოვთ შეცვალოთ %1$s." + "სისტემის პარამეტრები" + "სისტემის შეტყობინებები გამორთულია" + "შეტყობინებები" + "პრობლემების გადაჭრა" + "პრობლემების გადაჭრის შეტყობინებები" + diff --git a/features/preferences/impl/src/main/res/values-ko/translations.xml b/features/preferences/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..065384a --- /dev/null +++ b/features/preferences/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,77 @@ + + + "중요한 전화를 놓치지 않으려면 휴대폰이 잠겨 있을 때 전체 화면 알림을 허용하도록 설정을 변경하세요." + "통화 경험을 향상시키세요" + "어떻게 알림을 받을지 선택하기" + "개발자 모드" + "개발자가 기능에 액세스할 수 있도록 합니다." + "사용자 정의 요소 호출 베이스 URL" + "Element Call에 대한 사용자 지정 기본 URL을 설정하세요." + "URL이 잘못되었습니다. 프로토콜(http/https)과 올바른 주소를 포함했는지 확인하세요." + "방 초대 요청에서 아바타 숨기기" + "타임라인에서 미디어 미리 보기 숨기기" + "사진과 동영상을 더 빠르게 업로드하고 데이터 사용량을 줄이세요" + "미디어 품질 최적화" + "중재와 안전" + "더 빠른 업로드와 더 작은 파일 크기에 맞춰 이미지를 자동으로 최적화합니다." + "이미지 업로드 품질 최적화" + "%1$s. 여기를 탭하여 변경하세요." + "고화질 (1080p)" + "저화질 (480p)" + "표준 화질 (720p) +" + "비디오 업로드 품질" + "푸시 알림 제공자" + "마크다운을 직접 입력하려면 서식 있는 텍스트 편집기를 비활성화하세요." + "읽기 확인" + "이 기능을 해제하면 읽기 확인이 누구에게도 전송되지 않습니다. 다른 사용자의 읽기 확인은 계속 수신됩니다." + "현재 상태 공유" + "이 기능을 해제하면 읽기 확인 및 타이핑 알림을 보내거나 받을 수 없습니다." + "항상 숨기기" + "항상 표시" + "비공개 방에서" + "숨겨진 미디어는 터치로 표시할 수 있습니다." + "타임라인에 미디어 표시" + "타임라인에서 메시지 소스를 볼 수 있는 옵션을 활성화합니다." + "차단된 사용자가 없습니다." + "차단 해제" + "그들로부터 보낸 모든 메시지를 다시 볼 수 있게 됩니다." + "사용자 차단 해제" + "차단 해제 중…" + "표시되는 이름" + "내 표시되는 이름" + "알 수 없는 오류가 발생하여 정보를 변경할 수 없습니다." + "프로필을 업데이트할 수 없음" + "프로필 수정" + "프로필 업데이트 중…" + "추가 설정" + "음성 및 동영상 통화" + "구성 불일치" + "알림 설정을 간소화하여 옵션을 더 쉽게 찾을 수 있도록 했습니다. 과거에 선택한 일부 맞춤 설정은 여기에서 표시되지 않지만, 여전히 활성화되어 있습니다. + +계속 진행하면 일부 설정이 변경될 수 있습니다." + "직접 채팅" + "채팅별 맞춤 설정" + "알림 설정 업데이트 중 오류가 발생했습니다." + "모든 메시지" + "언급 및 키워드만" + "다이렉트 채팅에서 알림 받기" + "그룹 채팅에서 나에게 알림을 보내세요" + "이 장치에서 알림 사용" + "설정이 수정되지 않았습니다. 다시 시도해 주세요." + "그룹 채팅" + "초대" + "귀하의 홈서버는 암호화된 방에서 이 옵션을 지원하지 않으므로, 일부 방에서는 알림이 표시되지 않을 수 있습니다." + "언급" + "모두" + "언급" + "나에게 알려주세요" + @room 에서 알림 받기 + "알림을 받으려면 %1$s 을 변경해 주세요." + "시스템 설정" + "시스템 알림이 꺼져 있습니다." + "알림" + "푸시 기록" + "문제 해결" + "문제 해결 알림" + diff --git a/features/preferences/impl/src/main/res/values-lt/translations.xml b/features/preferences/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..b0a2f7b --- /dev/null +++ b/features/preferences/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,7 @@ + + + "Pasirinkite, kaip gauti pranešimus" + "Atblokuoti" + "Vėl galėsite matyti visas iš jų gautas žinutes." + "Atblokuoti vartotoją" + diff --git a/features/preferences/impl/src/main/res/values-nb/translations.xml b/features/preferences/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..90ec12a --- /dev/null +++ b/features/preferences/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,82 @@ + + + "For å sikre at du aldri går glipp av en viktig samtale, må du endre innstillingene dine for å tillate fullskjermvarsler når telefonen er låst." + "Forbedre samtaleopplevelsen din" + "Velg hvordan du vil motta varsler" + "Utviklermodus" + "Aktiver for å få tilgang til funksjoner og funksjonalitet for utviklere." + "Egendefinert Element Call base URL" + "Angi en egendefinert base URL for Element Call." + "Ugyldig URL. Pass på at du inkluderer protokollen (http/https) og riktig adresse." + "Skjul avatarer i invitasjonsforespørsler til rom" + "Skjul forhåndsvisninger av medier på tidslinjen" + "Prøvefunksjoner" + "Last opp bilder og videoer raskere og reduser databruken" + "Optimaliser mediekvaliteten" + "Moderasjon og sikkerhet" + "Optimaliser bilder automatisk for raskere opplastinger og mindre filstørrelser." + "Optimaliser kvaliteten på bildeopplasting" + "%1$s. Trykk her for å endre." + "Høy (1080p)" + "Lav (480p)" + "Standard (720p)" + "Kvalitet på videoopplasting" + "Leverandør av pushvarsling" + "Deaktiver rik tekstredigering for å skrive Markdown manuelt." + "Lesebekreftelser" + "Hvis slått av, sendes ikke lesebekreftelsene dine til noen. Du vil fortsatt motta lesebekreftelser fra andre brukere." + "Del tilstedeværelse" + "Hvis slått av, kan du ikke sende eller motta lesebekreftelser eller skrivevarsler." + "Skjul alltid" + "Vis alltid" + "I private rom" + "Et skjult medium kan alltid vises ved å trykke på det" + "Vis medier i tidslinjen" + "Aktiver alternativet for å vise meldingskilden på tidslinjen." + "Du har ingen blokkerte brukere" + "Fjern blokkering" + "Du vil kunne se alle meldingene fra dem igjen." + "Fjern blokkering av bruker" + "Fjerner blokkering …" + "Visningsnavn" + "Ditt visningsnavn" + "Det oppstod en ukjent feil, og informasjonen kunne ikke endres." + "Kan ikke oppdatere profilen" + "Rediger profil" + "Oppdaterer profilen…" + "Aktiver trådsvar" + "Appen vil starte på nytt for å implementere denne endringen." + "Prøv ut våre nyeste ideer under utvikling. Disse funksjonene er ikke ferdig utviklet; de kan være ustabile og kan endres." + "Lyst til å prøve noe nytt?" + "Prøvefunksjoner" + "Ytterligere innstillinger" + "Lyd- og videosamtaler" + "Uoverensstemmelse i konfigurasjonen" + "Vi har forenklet varslingsinnstillingene for å gjøre det lettere å finne alternativene. Noen av de egendefinerte innstillingene du har valgt tidligere, vises ikke her, men de er fortsatt aktive. + +Hvis du fortsetter, kan noen av innstillingene dine endres." + "Direkte chatter" + "Egendefinert innstilling per chat" + "Det oppstod en feil under oppdatering av varslingsinnstillingen." + "Alle meldinger" + "Bare omtaler og nøkkelord" + "På direkte chatter, varsle meg for" + "I gruppechatter, varsle meg om" + "Aktiver varsler på denne enheten" + "Konfigurasjonen er ikke korrigert, prøv igjen." + "Gruppechatter" + "Invitasjoner" + "Hjemmeserveren din støtter ikke dette alternativet i krypterte rom, og det kan hende at du ikke blir varslet i enkelte rom." + "Omtaler" + "Alle" + "Omtaler" + "Varsle meg om" + "Gi meg varsel på @room" + "For å motta varsler, vennligst endre %1$s." + "systeminnstillinger" + "Systemvarsler er slått av" + "Varslinger" + "Push-historikk" + "Feilsøk" + "Feilsøk varsler" + diff --git a/features/preferences/impl/src/main/res/values-nl/translations.xml b/features/preferences/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..0b6419a --- /dev/null +++ b/features/preferences/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,58 @@ + + + "Pas je instellingen aan om meldingen op het volledige scherm toe te staan wanneer de telefoon is vergrendeld. Zo mis je nooit een belangrijk gesprek." + "Verbeter je gesprekservaring" + "Kies hoe je meldingen wilt ontvangen" + "Ontwikkelaarsmodus" + "Schakel in om toegang te krijgen tot tools en functies voor ontwikkelaars." + "Aangepaste basis-URL voor Element Call" + "Stel een aangepaste basis-URL in voor Element Call." + "Ongeldige URL, zorg ervoor dat je het protocol (http/https) en het juiste adres invult." + "Push-meldingen provider" + "Schakel de uitgebreide tekstverwerker uit om Markdown handmatig te typen." + "Leesbevestigingen" + "Indien uitgeschakeld worden er geen leesbevestigingen verstuurd. Je ontvangt nog steeds leesbevestigingen van andere gebruikers." + "Aanwezigheid delen" + "Indien uitgeschakeld kun je geen leesbevestigingen en typmeldingen verzenden of ontvangen." + "Schakel optie in om de berichtbron in de tijdlijn te bekijken." + "Je hebt geen geblokkeerde gebruikers." + "Deblokkeren" + "Je zult alle berichten van hen weer kunnen zien." + "Gebruiker deblokkeren" + "Deblokkeren…" + "Weergavenaam" + "Je weergavenaam" + "Er is een onbekende fout opgetreden en de informatie kon niet worden gewijzigd." + "Kan profiel niet bijwerken" + "Profiel bewerken" + "Profiel bijwerken…" + "Aanvullende instellingen" + "Audio- en videogesprekken" + "Configuratie komt niet overeen" + "We hebben de instellingen voor meldingen vereenvoudigd, zodat je de opties gemakkelijker kunt vinden. Sommige instellingen die je in het verleden hebt aangepast, worden hier niet getoond, maar zijn nog steeds actief. + +Als je doorgaat, kunnen sommige van je instellingen veranderen." + "Directe chats" + "Aangepaste instelling per chat" + "Er is een fout opgetreden bij het bijwerken van de meldingsinstelling." + "Alle berichten" + "Alleen vermeldingen en trefwoorden" + "Bij directe chats, stuur me een melding voor" + "Bij groep chats, stuur me een melding voor" + "Meldingen op dit apparaat inschakelen" + "De configuratie is niet gecorrigeerd. Probeer het opnieuw." + "Groep chats" + "Uitnodigingen" + "Je homeserver ondersteunt deze optie niet in versleutelde kamers; in sommige kamers krijg je mogelijk geen meldingen." + "Vermeldingen" + "Alles" + "Vermeldingen" + "Stuur me een melding voor" + "Stuur me een melding bij @kamer" + "Wijzig je %1$s om meldingen te ontvangen." + "systeeminstellingen" + "Systeemmeldingen uitgeschakeld" + "Meldingen" + "Problemen oplossen" + "Problemen met meldingen oplossen" + diff --git a/features/preferences/impl/src/main/res/values-pl/translations.xml b/features/preferences/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..9e2c56e --- /dev/null +++ b/features/preferences/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,82 @@ + + + "Upewnij się, że nie pominiesz żadnego połączenia. Zmień swoje ustawienia i zezwól na powiadomienia na blokadzie ekranu." + "Popraw jakość swoich rozmów" + "Wybierz sposób otrzymywania powiadomień" + "Tryb programisty" + "Włącz, aby uzyskać dostęp do funkcji dla deweloperów." + "Własny bazowy URL dla połączeń Element" + "Ustaw własny bazowy URL dla połączeń Element" + "Nieprawidłowy adres URL, upewnij się, że zawiera protokół (http/https) i poprawny adres." + "Ukryj awatary w prośbach o dołączenie do pokoju" + "Ukryj podglądy multimediów na osi czasu" + "Laboratoria" + "Przesyłaj zdjęcia i filmy szybciej, zmniejszając zużycie danych" + "Optymalizuj jakość multimediów" + "Moderacja i bezpieczeństwo" + "Automatycznie optymalizuj obrazy, aby szybciej je przesyłać i zmniejszać rozmiar plików." + "Zoptymalizuj jakość przesyłania obrazów" + "%1$s. Dotknij tutaj, aby zmienić." + "Wysoka (1080p)" + "Niska (480p)" + "Standardowa (720p)" + "Jakość przesyłania wideo" + "Dostawca powiadomień push" + "Wyłącz edytor tekstu bogatego, aby pisać tekst Markdown ręcznie." + "Potwierdzenia odczytania" + "Gdy wyłączona, Twoje potwierdzenia odczytania nie zostaną wysłane. Potwierdzenia od innych wciąż będą odbierane." + "Udostępnij obecność" + "Gdy wyłączona, nie będziesz mógł wysyłać lub odbierać potwierdzeń odczytu ani powiadomień pisania." + "Zawsze ukrywaj" + "Zawsze pokazuj" + "W pokojach prywatnych" + "Ukryte media można zawsze wyświetlić, dotykając ich" + "Pokaż media na osi czasu" + "Włącz opcję, aby wyświetlić źródło wiadomości na osi czasu." + "Nie blokujesz żadnych użytkowników" + "Odblokuj" + "Będziesz mógł ponownie zobaczyć wszystkie wiadomości od tego użytkownika." + "Odblokuj użytkownika" + "Odblokowuję…" + "Wyświetlana nazwa" + "Twoja wyświetlana nazwa" + "Wystąpił nieznany błąd przez co nie można było zmienić informacji." + "Nie można zaktualizować profilu" + "Edytuj profil" + "Aktualizuję profil…" + "Włącz odpowiedzi w wątkach" + "Aplikacja uruchomi się ponownie, aby zastosować tę zmianę." + "Wypróbuj nasze najnowsze pomysły w fazie rozwoju. Funkcje te nie są jeszcze sfinalizowane; mogą być niestabilne i ulec zmianie." + "Chcesz poeksperymentować?" + "Laboratoria" + "Dodatkowe ustawienia" + "Połączenia audio i wideo" + "Niezgodność konfiguracji" + "Uprościliśmy Ustawienia powiadomień, aby ułatwić nawigowanie między opcjami. Niektóre ustawienia, które wybrałeś mogły zniknąć, lecz są wciąż aktywne. + +Niektóre ustawienia mogą ulec zmianie, jeśli kontynuujesz." + "Czaty prywatne" + "Ustawienia własne wybranego czatu" + "Wystąpił błąd podczas aktualizacji ustawienia powiadomień." + "Wszystkie wiadomości" + "Tylko wzmianki i słowa kluczowe" + "Na czatach prywatnych, powiadamiaj mnie przez" + "Na czatach grupowych powiadamiaj mnie przez" + "Włącz powiadomienia na tym urządzeniu" + "Konfiguracja nie została poprawiona, spróbuj ponownie." + "Czaty grupowe" + "Zaproszenia" + "Twój serwer domowy nie wspiera tej opcji w pokojach szyfrowanych, możesz nie otrzymać powiadomień z niektórych pokoi." + "Wzmianki" + "Wszystkie" + "Wzmianki" + "Powiadamiaj mnie przez" + "Powiadom mnie na @pokój" + "Aby otrzymywać powiadomienia, zmień swoje%1$s." + "ustawienia systemowe" + "Powiadomienia systemowe wyłączone" + "Powiadomienia" + "Historia powiadomień Push" + "Rozwiązywanie problemów" + "Rozwiązywanie problemów powiadomień" + diff --git a/features/preferences/impl/src/main/res/values-pt-rBR/translations.xml b/features/preferences/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..f6e1bc9 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,82 @@ + + + "Para garantir que você nunca perca uma chamada importante, por favor altere as suas configurações para permitir notificações em tela cheia enquanto o seu celular estiver bloqueado." + "Melhore a sua experiência de chamadas" + "Escolha como receber notificações" + "Modo de desenvolvedor" + "Ative para ter acesso a recursos e funcionalidades para desenvolvedores." + "URL base do Element Call personalizada" + "Defina uma URL base personalizada para o Element Call." + "URL inválida, por favor verifique se o protocolo (http/https) está incluso e o endereço correto." + "Ocultar avatares em solicitações de convite para salas" + "Ocultar pré-visualizações de mídia na linha do tempo" + "Experimentos" + "Envie fotos e vídeos com mais rapidez e reduza o uso de dados" + "Otimizar a qualidade da mídia" + "Moderação e segurança" + "Otimizar automaticamente as imagens para envios mais rápidos e arquivos com tamanhos menores." + "Otimizar qualidade de envio de imagens" + "%1$s. Toque aqui para alterar." + "Alta (1080p)" + "Baixa (480p)" + "Normal (720p)" + "Qualidade de envio de vídeos" + "Provedor de notificações push" + "Desative o editor de rich text para digitar Markdown manualmente." + "Confirmações de leitura" + "Se desligado, suas confirmações de leitura não serão enviadas para ninguém. Você ainda receberá confirmações de leitura de outros usuários." + "Compartilhar presença" + "Se desligado, você não poderá enviar ou receber confirmações de leitura ou notificações de digitação." + "Ocultar sempre" + "Mostrar sempre" + "Em salas privadas" + "Uma mídia oculta sempre pode ser exibida se você tocar nela" + "Mostrar mídia na linha do tempo" + "Ative a opção para visualizar o fonte da mensagem na linha do tempo." + "Você não tem usuários bloqueados" + "Desbloquear" + "Você poderá ver todas as mensagens desta pessoa novamente." + "Desbloquear usuário" + "Desbloqueando…" + "Nome de exibição" + "Seu nome de exibição" + "Ocorreu um erro desconhecido e as informações não puderam ser alteradas." + "Não foi possível atualizar o perfil" + "Editar perfil" + "Atualizando o perfil…" + "Ativar respostas de tópicos" + "O app será reiniciado para aplicar esta mudança." + "Teste as nossas mais novas ideias em desenvolvimento. Esses recursos não estão finalizados; podem estar instáveis, e podem mudar." + "Se sentindo experimental?" + "Experimentos" + "Configurações adicionais" + "Chamadas de áudio e vídeo" + "Não correspondência de configuração" + "Simplificamos as configurações de notificações para facilitar a localização das opções. Algumas configurações personalizadas que você escolheu no passado não são mostradas aqui, mas ainda estão ativas. + +Se você continuar, algumas de suas configurações poderão mudar." + "Conversas diretas" + "Configuração personalizada por conversa" + "Ocorreu um erro ao atualizar a configuração de notificação." + "Todas as mensagens" + "Somente menções e palavras-chave" + "Em conversas diretas, me notifique de" + "Em conversas em grupos, me notifique de" + "Ativar notificações neste dispositivo" + "A configuração não foi corrigida, tente novamente." + "Conversas em grupo" + "Convites" + "Seu servidor-casa não suporta esta opção em salas criptografadas. Você pode não ser notificado em algumas salas." + "Menções" + "Todos" + "Menções" + "Me notifique de" + "Notifique-me quando usam o @room" + "Para receber notificações, altere as %1$s." + "configurações do seu sistema" + "Notificações do sistema desativadas" + "Notificações" + "Histórico de push" + "Solução de problemas" + "Solucionar problemas de notificações" + diff --git a/features/preferences/impl/src/main/res/values-pt/translations.xml b/features/preferences/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..8ece6bd --- /dev/null +++ b/features/preferences/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,76 @@ + + + "Para garantir que nunca perdes uma chamada importante, altera as configurações para permitir notificações em ecrã inteiro quando o telemóvel está bloqueado." + "Melhora a tua experiência de chamada" + "Escolhe como receber notificações" + "Modo de programador" + "Permite o acesso a funcionalidades para programadores." + "URL base para Element Call personalizado" + "Define um URL base para a Element Call." + "URL inválido, certifica-te de que incluis o protocolo (http/https) e o endereço correto." + "Ocultar avatares nos pedidos de acesso a salas" + "Ocultar pré-visualizações de multimédia na cronologia" + "Carrega fotos e vídeos mais rapidamente e reduz a utilização de dados" + "Otimiza a qualidade da mídia" + "Moderação e Segurança" + "Otimiza automaticamente as imagens para carregamentos mais rápidos e tamanhos de ficheiros mais pequenos." + "Optimiza a qualidade do carregamento de imagens" + "%1$s. Toca aqui para alterar." + "Alta (1080p)" + "Baixa (480p)" + "Padrão (720p)" + "Qualidade de carregamento do vídeo" + "Fornecedor de envio" + "Desativa o editor de texto rico para poderes escrever Markdown manualmente." + "Recibos de leitura" + "Se desativada, os teus recibos de leitura não serão enviados a ninguém. Continuas a receber recibos de leitura de outros utilizadores." + "Partilhar presença" + "Se desativado, não poderás enviar ou receber recibos de leitura ou notificações de escrita." + "Ocultar sempre" + "Mostrar sempre" + "Em salas privadas" + "Multimédia oculta pode sempre ser exibida tocando-lhe" + "Mostrar multimédia na cronologia" + "Ativa a opção para ver a origem da mensagem na cronologia." + "Não tens nenhum utilizador bloqueado" + "Desbloquear" + "Poderás voltar a ver todas as suas mensagens." + "Desbloquear utilizador" + "A desbloquear…" + "Pseudónimo" + "O teu pseudónimo" + "Foi encontrado um erro desconhecido e a informação não foi alterada." + "Não foi possível atualizar o perfil" + "Editar perfil" + "A atualizar o perfil…" + "Configurações adicionais" + "Chamadas de áudio e vídeo" + "Incompatibilidade de configuração" + "Simplificámos as configurações de notificação para tornar as opções mais fáceis de encontrar. Algumas configurações personalizadas que escolheste no passado não são mostradas aqui, mas continuam ativas. + +Se prosseguires, algumas delas podem ser alteradas." + "Diretas" + "Configuração personalizada por conversa" + "Erro ao atualizar a configuração de notificação." + "Qualquer mensagem" + "Menções ou palavras-chave" + "Em conversas diretas, notifica-me se receber" + "Em conversas de grupo, notifica-me se receber" + "Ativar as notificações neste dispositivo" + "A configuração não foi corrigida, tenta novamente." + "De grupo" + "Convites" + "O teu servidor não suporta esta opção em salas cifradas, pelo que poderás não ser notificado em algumas salas." + "Menções" + "Tudo" + "Menções" + "Conversas" + "Quando aparece uma @room" + "Para receberes notificações, altera as tuas %1$s." + "configurações do sistema" + "Notificações do sistema desativadas" + "Notificações" + "Histórico de push" + "Resolução de problemas" + "Corrigir notificações" + diff --git a/features/preferences/impl/src/main/res/values-ro/translations.xml b/features/preferences/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..6f8e41c --- /dev/null +++ b/features/preferences/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,84 @@ + + + "Pentru a vă asigura că nu pierdeți niciodată un apel important, vă rugăm să modificați setările pentru a permite notificări fullscreen atunci când telefonul este blocat." + "Îmbunătățiți-vă experiența in timpul unui apel" + "Alegeți modul de primire a notificărilor" + "Modul dezvoltator" + "Activați pentru a avea acces la funcționalități pentru dezvoltatori." + "Adresa URL de bază Element Call" + "Setați o adresă URL de bază personalizată pentru Element Call." + "URL invalid, vă rugăm să vă asigurați că includeți protocolul (http/https) și adresa corectă." + "Ascundeți avatarele din invitațiile pentru camere" + "Ascundeți previzualizările media în lista de mesaje" + "Laboratoare" + "Încărcați fotografii și videoclipuri mai rapid și reduceți consumul de date" + "Optimizați calitatea media" + "Moderare și siguranță" + "Optimizați automat imaginile pentru încărcări mai rapide și dimensiuni mai mici ale fișierelor." + "Optimizați calitatea încărcării imaginilor" + "%1$s. Atingeți aici pentru a schimba." + "Înaltă (1080p)" + "Scăzută (480p)" + "Standard (720p)" + "Calitatea încărcării videoclipurilor" + "Furnizor de notificări push" + "Dezactivați editorul avansat pentru a tasta manual Markdown." + "Chitanțe de citire" + "Dacă dezactivată, chitanțele dumneavoastră de citire nu vor fi trimise nimănui. Veți primi în continuare chitanțe de citire de la alți utilizatori." + "Împărtășiți prezența" + "Dacă dezactivată, nu veți putea trimite sau primi chitanțe de citire sau notificări de tastare." + "Ascundeţi întotdeauna" + "Afișați întotdeauna" + "În camere private" + "Un fișier media ascuns poate fi afișat oricând prin apăsarea pe acesta." + "Afișați conținutul media în lista de mesaje" + "Activați opțiunea pentru a vizualiza sursa mesajelor." + "Nu aveți utilizatori blocați" + "Deblocați" + "La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta." + "Deblocați utilizatorul" + "Se deblochează…" + "Nume" + "Numele dumneavoastra" + "A fost întâlnită o eroare necunoscută și informațiile nu au putut fi modificate." + "Nu s-a putut actualiza profilul" + "Editați profilul" + "Se actualizează profilul…" + "Activați răspunsurile în fir" + "Aplicația va reporni pentru a aplica această modificare." + "Încercați cele mai noi idei în curs de dezvoltare. Aceste funcții nu sunt finalizate; pot fi instabile și pot suferi modificări." + "Doriți experiențe noi?" + "Laboratoare" + "Setări adiționale" + "Apeluri audio și video" + "Nepotrivire de configurație" + "Am simplificat Setările pentru notificări pentru a face opțiunile mai ușor de găsit. + +Unele setări personalizate pe care le-ați ales în trecut nu sunt afișate aici, dar sunt încă active. + +Dacă continuați, unele dintre setările dumneavoastră pot fi modificate." + "Discuții directe" + "Setare personalizată per chat" + "A apărut o eroare în timpul actualizării setărilor pentru notificari." + "Toate mesajele" + "Numai mențiuni și cuvinte cheie" + "În conversațiile directe, anunță-mă pentru" + "În conversațiile de grup, anunțați-mă pentru" + "Activați notificările pe acest dispozitiv" + "Configurația nu a fost corectată, vă rugăm să încercați din nou." + "Discuții de grup" + "Invitații" + "Serverul dumneavoastră nu acceptă această opțiune în camerele criptate, este posibil să nu primiți notificări în unele camere." + "Mențiuni" + "Toate" + "Mențiuni" + "Anunță-mă pentru" + "Anunțați-mă pentru @room" + "Pentru a primi notificări, vă rugăm să vă schimbați %1$s." + "Setări de sistem" + "Notificările de sistem sunt dezactivate" + "Notificări" + "Istoricul notificărilor" + "Depanare" + "Depanați notificările" + diff --git a/features/preferences/impl/src/main/res/values-ru/translations.xml b/features/preferences/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..7b2e1eb --- /dev/null +++ b/features/preferences/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,82 @@ + + + "Чтобы больше не пропускать важные звонки, разрешите приложению показывать полноэкранные уведомления на заблокированном экране телефона." + "Улучшите качество звонков" + "Выберите способ получения уведомлений" + "Режим разработчика" + "Предоставьте разработчикам доступ к функциям и функциональным возможностям." + "Базовый URL сервера звонков Element" + "Задайте свой сервер Element Call." + "Адрес указан неверно, удостоверьтесь, что вы указали протокол (http/https) и правильный адрес." + "Скрыть аватары в запросах на приглашение в комнату" + "Скрыть предварительный просмотр медиафайлов на временной шкале" + "Лаборатория" + "Загружайте фотографии и видео быстрее и сокращайте потребление трафика" + "Оптимизировать качество мультимедиа" + "Модерация и безопасность" + "Автоматически оптимизируйте изображения для более быстрой загрузки и уменьшения размера файлов." + "Оптимизируйте качество загрузки изображения" + "%1$s. Нажмите здесь, чтобы изменить." + "Высокое (1080p)" + "Низкое (480p)" + "Среднее (720p)" + "Качество загружаемого видео" + "Поставщик push-уведомлений" + "Отключить редактор форматированного текста и включить Markdown." + "Уведомления о прочтении" + "Если этот параметр выключен, ваш статус о прочтении не будет отображаться. Вы по-прежнему будете видеть статус о прочтении от других пользователей." + "Поделиться присутствием" + "Если выключено, вы не сможете отправлять, получать уведомления о прочтении и наборе текста" + "Всегда скрывать" + "Всегда показывать" + "В личных комнатах" + "Скрытый медиафайл всегда можно отобразить, нажав на него." + "Показать медиафайлы в хронологии" + "Включить опцию просмотра источника сообщения в ленте." + "У вас нет заблокированных пользователей" + "Разблокировать" + "Вы снова сможете увидеть все сообщения." + "Разблокировать пользователя" + "Разблокировка…" + "Отображаемое имя" + "Ваше отображаемое имя" + "Произошла неизвестная ошибка, изменить информацию не удалось." + "Невозможно обновить профиль" + "Редактировать профиль" + "Обновление профиля…" + "Включить ответы в топике" + "Приложение перезапустится, чтобы применить это изменение." + "Попробуйте наши последние идеи в разработке. Эти функции ещё не завершены, они могут быть нестабильны и могут измениться." + "Хотите попробовать?" + "Лаборатория" + "Дополнительные параметры" + "Аудио и видео звонки" + "Несоответствие конфигурации" + "Мы упростили настройки уведомлений, чтобы упростить поиск опций. Некоторые пользовательские настройки, выбранные вами ранее, не отображаются в данном меню, но они все еще активны. + +Если вы продолжите, некоторые настройки могут быть изменены." + "В личных чатах" + "Персональные настройки для каждого чата" + "Произошла ошибка при обновлении настройки уведомления." + "О всех сообщениях" + "Только упоминания и ключевые слова" + "Уведомлять меня в личных чатах" + "Уведомлять меня в групповых чатах" + "Включить уведомления на данном устройстве" + "Конфигурация не была исправлена, попробуйте еще раз." + "В групповых чатах" + "Приглашения" + "Ваш домашний сервер не поддерживает эту опцию в зашифрованных комнатах, в некоторых комнатах вы можете не получать уведомления." + "Упоминания" + "Все" + "Упоминания" + "Уведомлять меня" + "Уведомлять меня при упоминании @room" + "Чтобы получать уведомления, измените свой %1$s." + "настройки системы" + "Системные уведомления выключены" + "Уведомления" + "История уведомлений" + "Устранение неполадок" + "Уведомления об устранении неполадок" + diff --git a/features/preferences/impl/src/main/res/values-sk/translations.xml b/features/preferences/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..9968c6d --- /dev/null +++ b/features/preferences/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,84 @@ + + + "Aby ste už nikdy nezmeškali dôležitý hovor, zmeňte svoje nastavenia a povoľte upozornenia na celú obrazovku, keď je váš telefón uzamknutý." + "Vylepšite svoj zážitok z hovoru" + "Vyberte spôsob prijímania oznámení" + "Vývojársky režim" + "Umožniť prístup k možnostiam a funkciám pre vývojárov." + "Vlastná Element Call základná URL adresa" + "Nastaviť vlastnú základnú URL adresu pre Element Call." + "Neplatná adresa URL, uistite sa, že ste uviedli protokol (http/https) a správnu adresu." + "Skrytie profilové obrázky v žiadostiach o pozvánku do miestnosti" + "Skryť ukážky médií na časovej osi" + "Laboratóriá" + "Nahrávajte fotografie a videá rýchlejšie a znížte spotrebu dát" + "Optimalizovať kvalitu médií" + "Moderovanie a bezpečnosť" + "Automaticky optimalizovať obrázky pre rýchlejšie nahrávanie a menšie veľkosti súborov." + "Optimalizovať kvalitu nahrávaných obrázkov" + "%1$s. Ťuknite sem, ak ju chcete zmeniť." + "Vysoká (1080p)" + "Nízka (480p)" + "Štandardná (720p)" + "Kvalita nahrávania videa" + "Poskytovateľ oznámení Push" + "Vypnite rozšírený textový editor na ručné písanie Markdown." + "Potvrdenia o prečítaní" + "Ak je táto funkcia vypnutá, vaše potvrdenia o prečítaní sa nebudú nikomu odosielať. Stále budete dostávať potvrdenia o prečítaní od ostatných používateľov." + "Zdieľať prítomnosť" + "Ak je vypnuté, nebudete môcť odosielať ani prijímať potvrdenia o prečítaní alebo upozornenia o písaní" + "Vždy skryť" + "Vždy zobraziť" + "V súkromných miestnostiach" + "Skryté médium sa dá vždy zobraziť ťuknutím naň" + "Zobraziť médiá na časovej osi" + "Povoliť možnosť zobrazenia zdroja správy na časovej osi." + "Nemáte žiadnych blokovaných používateľov" + "Odblokovať" + "Všetky správy od nich budete môcť opäť vidieť." + "Odblokovať používateľa" + "Ruší sa blokovanie…" + "Zobrazované meno" + "Vaše zobrazované meno" + "Vyskytla sa neznáma chyba a informácie nebolo možné zmeniť." + "Nepodarilo sa aktualizovať profil" + "Upraviť profil" + "Aktualizácia profilu…" + "Povoliť odpovede vo vlákne" + "Aplikácia sa reštartuje, aby sa táto zmena prejavila." + "Vyskúšajte naše najnovšie nápady vo vývoji. Tieto funkcie nie sú finalizované; môžu byť nestabilné a môžu sa zmeniť." + "Máte chuť experimentovať?" + "Laboratóriá" + "Ďalšie nastavenia" + "Audio a video hovory" + "Nezhoda konfigurácie" + "Zjednodušili sme Nastavenia oznámení, aby ste ľahšie našli možnosti. + +Niektoré vlastné nastavenia, ktoré ste si nastavili v minulosti, sa tu nezobrazujú, ale sú stále aktívne. + +Ak budete pokračovať, niektoré z vašich nastavení sa môžu zmeniť." + "Priame konverzácie" + "Vlastné nastavenie pre konverzácie" + "Pri aktualizácii nastavenia oznámenia došlo k chybe." + "Všetky správy" + "Iba zmienky a kľúčové slová" + "Pri priamych rozhovoroch ma upozorniť na" + "Pri skupinových rozhovoroch ma upozorniť na" + "Povoliť oznámenia na tomto zariadení" + "Konfigurácia nebola opravená, skúste to prosím znova." + "Skupinové rozhovory" + "Pozvánky" + "Váš domovský server nepodporuje túto možnosť v šifrovaných miestnostiach, v niektorých miestnostiach nemusíte dostať upozornenie." + "Zmienky" + "Všetky" + "Zmienky" + "Upozorniť ma na" + "Upozorniť ma na @miestnosť" + "Ak chcete dostávať oznámenia, zmeňte prosím svoje %1$s." + "nastavenia systému" + "Systémové oznámenia sú vypnuté" + "Oznámenia" + "História push oznámení" + "Riešenie problémov" + "Oznámenia riešení problémov" + diff --git a/features/preferences/impl/src/main/res/values-sv/translations.xml b/features/preferences/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..e25e36a --- /dev/null +++ b/features/preferences/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,76 @@ + + + "För att säkerställa att du aldrig missar ett viktigt samtal, ändra dina inställningar för att tillåta helskärmsmeddelanden när telefonen är låst." + "Förbättra din samtalsupplevelse" + "Välj hur du vill ta emot aviseringar" + "Utvecklarläge" + "Aktivera för att ha tillgång till funktionalitet för utvecklare." + "Anpassad bas-URL för Element Call" + "Ange en anpassad bas-URL för Element Call." + "Ogiltig URL, se till att du inkluderar protokollet (http/https) och rätt adress." + "Dölj avatarer i förfrågningar om rumsinbjudningar" + "Dölj förhandsgranskningar av media i tidslinjen" + "Ladda upp foton och videor snabbare och minska dataanvändningen" + "Optimera mediekvaliteten" + "Moderering och säkerhet" + "Optimera bilder automatiskt för snabbare uppladdningar och mindre filstorlekar." + "Optimera bilduppladdningskvalitet" + "%1$s. Tryck här för att ändra." + "Hög (1080p)" + "Låg (480p)" + "Standard (720p)" + "Videouppladdningskvalitet" + "Pushnotisleverantör" + "Inaktivera rik-text-redigeraren för att skriva Markdown manuellt." + "Läskvitton" + "Om det är avstängt kommer dina läskvitton inte att skickas till någon. Du kommer fortfarande att få läskvitton från andra användare." + "Dela närvaro" + "Om det är avstängt kan du inte skicka eller ta emot läskvitton eller skrivnotiser" + "Göm alltid" + "Visa alltid" + "I privata rum" + "En dold media kan alltid visas genom att trycka på den" + "Visa media i tidslinjen" + "Aktivera alternativet för att visa meddelandekälla i tidslinjen." + "Du har inga blockerade användare" + "Avblockera" + "Du kommer att kunna se alla meddelanden från dem igen." + "Avblockera användare" + "Avblockerar …" + "Visningsnamn" + "Ditt visningsnamn" + "Ett okänt fel påträffades och informationen kunde inte ändras." + "Kunde inte uppdatera profilen" + "Redigera profil" + "Uppdaterar profil …" + "Ytterligare inställningar" + "Ljud- och videosamtal" + "Konfigurationen matchar inte" + "Vi har förenklat aviseringsinställningarna för att göra alternativen enklare att hitta. Vissa anpassade inställningar som du har valt tidigare visas inte här, men de är fortfarande aktiva. + +Om du fortsätter kan vissa av dina inställningar ändras." + "Direktchattar" + "Anpassad inställning per chatt" + "Ett fel uppstod vid uppdatering av aviseringsinställningen." + "Alla meddelanden" + "Endast omnämnanden och nyckelord" + "På direktchattar, meddela mig för" + "På gruppchattar, meddela mig för" + "Aktivera aviseringar på den här enheten" + "Konfigurationen har inte korrigerats, vänligen pröva igen." + "Gruppchattar" + "Inbjudningar" + "Din hemserver stöder inte det här alternativet i krypterade rum, du kanske inte aviseras i vissa rum." + "Omnämnanden" + "Alla" + "Omnämnanden" + "Meddela mig för" + "Meddela mig på @room" + "För att få aviseringar, vänligen ändra dina %1$s." + "systeminställningar" + "Systemaviseringar avstängda" + "Aviseringar" + "Push-historik" + "Felsök" + "Felsök aviseringar" + diff --git a/features/preferences/impl/src/main/res/values-tr/translations.xml b/features/preferences/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..fd0a949 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,63 @@ + + + "Önemli bir aramayı asla kaçırmamak için, telefonunuz kilitliyken tam ekran bildirimlere izin vermek üzere ayarlarınızı değiştirin." + "Arama deneyiminizi geliştirin" + "Bildirimleri nasıl alacağınızı seçin" + "Geliştirici modu" + "Geliştiriciler için özelliklere ve işlevlere erişim sağlayın." + "Özel Element Call temel URL\'si" + "Element Call için özel bir temel URL ayarlayın." + "Geçersiz URL, lütfen protokolü (http/https) ve doğru adresi eklediğinizden emin olun." + "Oda davet isteklerinde avatarları gizle" + "Zaman çizelgesinde medya ön izlemelerini kapat" + "Fotoğraf ve videoları daha hızlı yükleyin ve veri kullanımını azaltın" + "Medya kalitesini optimize edin" + "Yönetim ve Güvenlik" + "Anlık bildirim sağlayıcısı" + "Markdown\'ı manuel olarak yazmak için zengin metin düzenleyicisini devre dışı bırakın." + "Okundu bilgisi" + "Kapatılırsa, okundu bilgileriniz kimseye gönderilmez. Diğer kullanıcılardan okundu bilgisi almaya devam edersiniz." + "Varlığı paylaşın" + "Kapatılırsa, okundu bilgisi veya yazma bildirimleri gönderemez veya alamazsınız." + "Zaman çizelgesinde mesaj kaynağını görüntüleme seçeneğini etkinleştirin." + "Engellenen kullanıcı yok." + "Engellemeyi kaldır" + "Onlardan gelen tüm mesajları tekrar görebileceksiniz." + "Kullanıcının engelini kaldır" + "Engel kaldırılıyor…" + "Görünen ad" + "Görünen adınız" + "Bilinmeyen bir hatayla karşılaşıldı ve bilgiler değiştirilemedi." + "Profil güncellenemiyor" + "Profili düzenle" + "Profil güncelleniyor…" + "Ek ayarlar" + "Sesli ve Görüntülü aramalar" + "Yapılandırma uyuşmazlığı" + "Seçeneklerin bulunmasını kolaylaştırmak için Bildirim Ayarlarını basitleştirdik. Geçmişte seçtiğiniz bazı özel ayarlar burada gösterilmez, ancak hala aktiftir. + +Devam ederseniz, bazı ayarlarınız değişebilir." + "Doğrudan sohbetler" + "Sohbet başına özel ayar" + "Bildirim ayarı güncellenirken bir hata oluştu." + "Tüm mesajlar" + "Yalnızca Bahsetmeler ve Anahtar Kelimeler" + "Doğrudan sohbetlerde, beni bilgilendir" + "Grup sohbetlerinde, beni bilgilendir" + "Bu cihazda bildirimleri etkinleştir" + "Yapılandırma düzeltilmedi, lütfen tekrar deneyin." + "Grup sohbetleri" + "Davetler" + "Ana sunucunuz şifreli odalarda bu seçeneği desteklemiyor, bazı odalarda bildirim almayabilirsiniz." + "Bahsetmeler" + "Tümü" + "Bahsetmeler" + "Bana bildir" + "Bana @room\'da bildir" + "Bildirimleri almak için lütfen %1$s değiştirin." + "si̇stem ayarları" + "Sistem bildirimleri kapalı" + "Bildirimler" + "Sorun gider" + "Sorun Giderme Bildirimleri" + diff --git a/features/preferences/impl/src/main/res/values-uk/translations.xml b/features/preferences/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..63a4db2 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,76 @@ + + + "Щоб ніколи не пропустити важливий виклик, змініть налаштування, щоб увімкнути повноекранні сповіщення, коли телефон заблоковано." + "Покращуйте досвід дзвінків" + "Виберіть спосіб отримання сповіщень" + "Режим розробника" + "Увімкніть доступ до функцій і можливостей для розробників." + "Користувацька URL-адреса Element Call" + "Встановіть URL-адресу для Element Call." + "Неправильна URL-адреса. Переконайтеся, що ви вказали протокол (http/https) та правильну адресу." + "Сховати аватари у запитах на запрошення до кімнат" + "Сховати попередній перегляд медіа у стрічці" + "Швидше завантажуйте фотографії та відео та зменшуйте використання даних" + "Оптимізуйте медіаякість" + "Модерування й безпека" + "Автоматична оптимізація зображень для швидшого вивантаження та зменшення розміру файлів." + "Оптимізація якості вивантажуваних зображень" + "%1$s, торкніться тут, щоб змінити." + "Висока (1080p)" + "Низька (480p)" + "Стандартна (720p)" + "Якість вивантаження відео" + "Постачальник push-сповіщень" + "Вимкніть редактор розширеного тексту, щоб вводити Markdown вручну." + "Читати журнали" + "Якщо вимкнено, ваші сповіщення про прочитання нікому не надсилатимуться. Ви все одно отримуватимете сповіщення про прочитання від інших користувачів." + "Поділіться присутністю" + "Якщо цей параметр вимкнено, ви не зможете надсилати й отримувати звіти про прочитання чи сповіщення про введення тексту." + "Завжди ховати" + "Завжди показувати" + "У приватних кімнатах" + "Сховані медіа завжди можна переглянути, натиснувши на нього" + "Показувати медіа у стрічці" + "Увімкнути опцію для перегляду коду повідомлення в стрічці" + "У вас немає заблокованих користувачів." + "Розблокувати" + "Ви знову зможете бачити всі повідомлення від них." + "Розблокувати користувача" + "Розблокування…" + "Показуване ім\'я" + "Ваше показуване ім\'я" + "Виявлена невідома помилка, і не вдалося змінити інформацію." + "Неможливо оновити профіль" + "Редагувати профіль" + "Оновлення профілю…" + "Додаткові налаштування" + "Аудіо та відеодзвінки" + "Невідповідність конфігурації" + "Ми спростили налаштування сповіщень, щоб полегшити пошук параметрів. Деякі користувацькі налаштування, які ви вибрали раніше, тут не відображаються, але вони все ще активні. + +Якщо ви продовжите, деякі з ваших налаштувань можуть змінитися." + "Особисті бесіди" + "Користувальницькі налаштування бесід" + "Під час оновлення налаштувань сповіщень сталася помилка." + "Всі повідомлення" + "Тільки згадки та ключові слова" + "В особистих бесідах сповіщати про" + "У групових бесідах сповіщати мене про" + "Увімкнути сповіщення на цьому пристрої" + "Конфігурацію не виправлено, спробуйте ще раз." + "Групові бесіди" + "Запрошення" + "Ваш домашній сервер не підтримує цю опцію в зашифрованих кімнатах, ви можете не отримати сповіщення в деяких кімнатах." + "Згадки" + "Усі" + "Згадки" + "Сповіщати мене про" + "Сповіщати про @room" + "Щоб отримувати сповіщення змініть свої %1$s." + "системні налаштування" + "Системні сповіщення вимкнені" + "Сповіщення" + "Історія push-сповіщень" + "Усунення несправностей" + "Усунення неполадок сповіщень" + diff --git a/features/preferences/impl/src/main/res/values-ur/translations.xml b/features/preferences/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..470763e --- /dev/null +++ b/features/preferences/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,58 @@ + + + "اس بات کو یقینی بنانے کے لیے کہ آپ کبھی بھی اہم مکالمہ سے محروم نہ ہوں، براہ کرم اپنی ترتیبات تبدیل کریں تاکہ آپ کا ہاتف مقفل ہونے پر مکمل پردۂ نمائش اطلاعات کی اجازت دی جا سکے۔" + "اپنے مکالمتی تجربے کو احسن کریں" + "اطلاعات موصول کرنے کا طریقہ چنیں۔" + "مطور وضع" + "مطورین کیلئے خصوصیات اور فعالیت تک رسائی حاصل کرنے کے لیے فعال کریں۔" + "حسب ضرورت ایلیمنٹ مکالمہ بنیادی عنوان" + "ایلیمنٹ مکالمہ کیلئے حسب ضرورت بنیادی عنوان ترتیب دیں" + "باطل عنوان، برائے مہربانی یقینی بنائیں کہ آپ دستور (http/https) اور صحیح پتہ شامل کریں۔" + "دھکا اطلاع فراہم کنندہ" + "مارک ڈاون کو دستی طور پر تحریر کرنے کے لیے امیر متن مدون کو غیر فعال کریں۔" + "پڑھنے کی رسیدیں" + "اگر بند کر دیا جائے تو، آپ کی پڑھنے کی رسیدیں کسی کو نہیں بھیجی جائیں گی۔ آپ اب بھی دوسرے صارفین سے پڑھنے کی رسیدیں وصول کریں گے۔" + "موجودگی کا اشتراک کریں" + "اگربند کیا، تو آپ پڑھنے کی رسیدیں یا تحریر کی اطلاعات ارسال یا وصول نہیں کر سکیں گے۔" + "جدول زمانی میں پیغام کا ماخذ دیکھنے کے لئے اختیار فعال کریں۔" + "آپ کے کوئی مسدود صارفین نہیں ہے۔" + "غیر مسدود کریں" + "آپ انکی جانب سے تمام پیغامات دوبارہ دیکھ سکیں گے۔" + "صارف کو غیر مسدود کریں" + "غیر مسدود کر رہا ہے…" + "نمائشی نام" + "آپکا نمائشی نام" + "ایک نامعلوم نقص کا سامنا کرنا پڑا اور معلومات کو تبدیل نہیں کیا جا سکا۔" + "نمایہ کی تجدید کرنے سے قاصر" + "نمایہ میں ترمیم کریں" + "نمایہ کی تجدید ہو رہی ہے…" + "اضافی ترتیبات" + "صوتی اور بصری مکالمات" + "تکوین کی عدم مطابقت" + "ہم نے اختیارات کو تلاش کرنا آسان بنانے کے لیے اطلاعات کی ترتیبات کو آسان بنایا ہے۔ آپ کی ماضی میں منتخب کردہ کچھ حسب ضرورت ترتیبات یہاں نہیں دکھائی گئی ہیں، لیکن وہ اب بھی فعال ہیں۔ + + اگر آپ آگے بڑھتے ہیں تو آپ کی کچھ ترتیبات تبدیل ہو سکتی ہیں۔" + "براہ راست گفتگوہا" + "فی گفتگو حسب ضرورت ترتیب" + "اطلاع کی ترتیب کی تجدید کرتے ہوئے ایک نقص واقع ہوا۔" + "تمام پیغامات" + "صرف تذکرے اور کلیدی الفاظ" + "براہ راست گفتگوہا پر، مجھے مطلع کریں برائے" + "گروہی گفتگوہا پر، مجھے مطلع کریں" + "اس آلے پر اطلاعات فعال کریں" + "تکوین کو درست نہیں کیا گیا ہے، برائے مہربانی دوبارہ کوشش کریں۔" + "گروہی گفتگوہا" + "دعوت نامے" + "آپ کا منزلی خادم مرموز کردہ کمروں میں اس اختیار کی حمایت نہیں کرت، ہوسکتا ہے کچھ کمروں میں آپ کو مطلع نہ کیا جائے۔" + "تذکرے" + "تمام" + "تذکرے" + "مجھے مطلع کریں برائے" + "مجھے @کمرہ پر مطلع کریں" + "اطلاعات موصول کرنے کے لئے، براہ کرم اپنا %1$s تبدیل کریں۔" + "نظام کی ترتیبات" + "نظام کی اطلاعات بند کر دی گئیں" + "اطلاعات" + "ازالہ کریں" + "اطلاعات کا ازالہ کریں" + diff --git a/features/preferences/impl/src/main/res/values-uz/translations.xml b/features/preferences/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..82e9b24 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,76 @@ + + + "Muhim qoʻngʻiroqlarni oʻtkazib yubormasligingiz uchun telefoningiz qulflangan holatida toʻliq ekranli bildirishnomalarni ko‘rsatishga ruxsat beradigan qilib sozlamalaringizni oʻzgartiring." + "Qoʻngʻiroq tajribangizni yaxshilang" + "Bildirishnomalarni qanday qabul qilishni tanlang" + "Dasturchi rejimi" + "Ishlab chiquvchilar uchun xususiyatlar va funksiyalarga kirishni yoqing." + "Maxsus element qo‘ng‘iroqlar bazasi URL manzili" + "Element qo\'ng\'irog\'iga maxsus asosiy url or\'natish" + "URL noto‘g‘ri, iltimos, protokol (http/https) va to‘g‘ri manzilni kiritganingizga ishonch hosil qiling." + "Xonaga taklif so‘rovlarida avatarlarni berkitish" + "Vaqt jadvalida mediaga razm solishlarni berkitish" + "Rasm va videolarni tezroq yuklang va trafik sarfini kamaytiring" + "Media sifatini yaxshilash" + "Moderatsiya va xavfsizlik" + "Tezroq yuklash va kichikroq fayl hajmi uchun rasmlarni avtomatik optimallashtirish." + "Rasm yuklash sifatini optimallashtirish" + "%1$s. Oʻzgartirish uchun bu yerga bosing." + "Yuqori (1080p)" + "Past (480p)" + "Standart (720p)" + "Video yuklash sifati" + "Push bildirishnoma provayderi" + "Boy matn muharriri o\'chiring Markdown bilan qo\'lda yozish uchun" + "Kvitansiyalarni oʻqish" + "Agar oʻchirib qo‘yilsa, sizning oʻqilganlik bildirishnomangiz hech kimga yuborilmaydi. Siz boshqa foydalanuvchilardan oʻqilganlik bildirishnomalarini olishda davom etasiz." + "Mavjudligini ulashish" + "Agar oʻchirib qoʻyilsa, siz oʻqilganlik haqidagi bildirishnomalarni yoki yozayotganingiz haqidagi xabarlarni yubora olmaysiz va qabul qila olmaysiz." + "Doim berkitilsin" + "Har doim ko‘rsatish" + "Shaxsiy xonalarda" + "Yashirin media har doim unga bosish orqali ko‘rsatilishi mumkin" + "Vaqt jadvalida media ko‘rsatish" + "Xabar manbasini vaqt jadvalida ko‘rish imkoniyatini yoqing." + "Sizda bloklangan foydalanuvchi yo‘q" + "Blokdan chiqarish" + "Ulardan kelgan barcha xabarlarni yana koʻrishingiz mumkin boʻladi." + "Foydalanuvchini blokdan chiqarish" + "Blokdan chiqarilmoqda…" + "Ko\'rsatiladigan ism" + "Ismingizni ko\'rsating" + "Noma\'lum xatolik yuz berdi va ma\'lumotni o\'zgartirib bo\'lmadi." + "Profilni yangilab bo‘lmadi" + "Profilni tahrirlash" + "Profil yangilanmoqda…" + "Qo\'shimcha sozlamalar" + "Audio va video qo\'ng\'iroqlar" + "Konfiguratsiya mos kelmasligi" + "Variantlarni topishni osonlashtirish uchun bildirishnomalar sozlamalarini soddalashtirdik. Ilgari siz tanlagan baʼzi shaxsiy sozlamalar bu yerda koʻrsatilmaydi, lekin ular hali ham faol. + +Davom ettirsangiz, baʼzi sozlamalaringiz oʻzgarishi mumkin." + "To\'g\'ridan-to\'g\'ri suhbatlar" + "Har bir suhbat uchun moslashtirilgan sozlama" + "Bildirishnoma sozlamalarini yangilashda xatolik yuz berdi." + "Barcha xabarlar" + "Faqat eslatmalar va kalit so\'zlar" + "To\'g\'ridan-to\'g\'ri suhbats, menga xabar bering" + "Guruh suhbatlarida menga xabar bering" + "Ushbu qurilmada bildirishnomalarni yoqing" + "Konfiguratsiya tuzatilmadi, qayta urinib ko\'ring." + "Guruh suhbatlari" + "Taklifnomalar" + "Uy serveringiz shifrlangan xonalarda ushbu imkoniyatni qoʻllab-quvvatlamaydi, shuning uchun baʼzi xonalardagi xabarlarni olmasligingiz mumkin." + "Eslatmalar" + "Hammasi" + "Eslatmalar" + "Menga xabar bering" + "Menga @room orqali xabar bering" + "Bildirishnomalarni olish uchun, iltimos, o\'zingizni %1$singizni o\'zgartiring." + "tizim sozlamalari" + "Tizim bildirishnomalari o\'chirilgan" + "Bildirishnomalar" + "Bildirishnoma tarixi" + "Muammolarni bartaraf etish" + "Bildirishnomalar bilan bog‘liq muammolarni bartaraf etish" + diff --git a/features/preferences/impl/src/main/res/values-zh-rTW/translations.xml b/features/preferences/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..6349111 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,82 @@ + + + "為確保您永遠不會錯過重要通話,請變更設定以允許在手機鎖定時允許全螢幕通知。" + "提升您的通話體驗" + "選擇接收通知的機制" + "開發者模式" + "啟用以存取供開發者使用的功能。" + "自訂 Element 通話基礎 URL" + "設定 Element 通話的自訂基礎 URL。" + "無效的 URL,請確定包含了協定 (http/https) 與正確的地址。" + "在聊天室邀請請求中隱藏大頭貼" + "在時間軸中隱藏媒體預覽" + "實驗室" + "上傳照片與影片更快且減少資料使用量" + "最佳化媒體品質" + "管理與安全" + "自動最佳化影像以提供更快的上傳速度與較小的檔案大小。" + "最佳化影像上傳品質" + "%1$s。輕點此處以變更。" + "高 (1080p)" + "低 (480p)" + "標準 (720p)" + "視訊上傳品質" + "推播通知提供者" + "手動輸入 Markdown,停用格式化文字編輯器。" + "已讀回條" + "若關閉,您的讀取回條將不會傳送給任何人。您仍然會收到來自其他使用者的讀取回條。" + "分享動態" + "若關閉,您將無法傳送或接收讀取回條或輸入通知。" + "永遠隱藏" + "永遠顯示" + "在私人聊天室中" + "隨時可以透過點選來顯示隱藏媒體" + "在時間軸中顯示媒體" + "啟用選項以在時間軸中檢視訊息來源。" + "您並未封鎖使用者" + "解除封鎖" + "您將無法看到任何來自他們的訊息。" + "解除封鎖使用者" + "正在解除封鎖……" + "顯示名稱" + "您的顯示名稱" + "遇到未知錯誤,無法變更資訊。" + "無法更新個人檔案" + "編輯個人檔案" + "正在更新個人檔案…" + "啟用討論串回覆" + "應用程式將會重新啟動以套用此變更。" + "試試我們正在開發中的最新構想。這些功能可能尚未完成,可能不夠穩定,也可能隨時變動。" + "想不想來點新花樣?" + "實驗室" + "其他設定" + "音訊與視訊通話" + "組態錯誤" + "我們簡化了通知設定,讓選項更容易搜尋。您過去選擇的某些自訂設定將不會在此顯示,但它們仍有作用。 + +若您繼續,您的某些設定可能會變更。" + "私訊" + "每個聊天分開設定" + "更新通知設定時發生錯誤。" + "所有訊息" + "僅限提及與關鍵字" + "在私人訊息中通知我" + "在群組聊天中通知我" + "在這個裝置上開啟通知" + "組態尚未更正,請再試一次。" + "群組聊天" + "邀請" + "您的家伺服器在加密聊天室中不支援此選項,可能無法收到部份聊天室的通知。" + "提及" + "全部" + "提及" + "通知我" + "於 @room 通知我" + "要收到通知,請變更您的 %1$s。" + "系統設定" + "已關閉系統通知" + "通知" + "推播通知歷史紀錄" + "疑難排解" + "疑難排解通知" + diff --git a/features/preferences/impl/src/main/res/values-zh/translations.xml b/features/preferences/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..0c576b5 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,82 @@ + + + "为确保您不会错过重要来电,请更改设置以允许锁屏时的全屏通知。" + "提升通话体验" + "选择如何接收通知" + "开发者模式" + "允许开发人员访问特性和功能。" + "自定义 Element Call URL" + "为 Element 通话设置根 URL。" + "URL 无效,请确保包含协议(http/https)和正确的地址。" + "在房间邀请请求中隐藏头像" + "在时间轴中隐藏媒体预览" + "实验室" + "针对上传进行优化" + "媒体" + "内容审核与安全" + "自动优化图像以实现更快的上传速度和更小的文件大小。" + "优化图片上传质量" + "%1$s。点击此处更改。" + "高 (1080p)" + "低画质 (480p)" + "标准 (720p)" + "视频上传质量" + "通知推送提供者" + "禁用富文本编辑器,手动输入 Markdown。" + "已读回执" + "关闭后已读回执将不会发送给他人,但仍能收到他人的已读回执。" + "分享在线状态" + "关闭后将无法发送或接收已读回执、输入通知" + "始终隐藏" + "始终显示" + "在私人房间" + "随时可以通过点击隐藏的媒体来显示它" + "在时间轴中显示媒体" + "启用在时间轴中查看消息源码的选项。" + "您没有屏蔽用户" + "解封" + "可以重新接收他们的消息。" + "解封用户" + "正在解除屏蔽……" + "显示名称" + "你的显示名称" + "遇到未知错误,无法更改信息。" + "无法更新个人资料" + "编辑个人资料" + "更新个人资料……" + "启用主题回复" + "应用将重启以应用此更改。" + "尝试我们最新的开发理念。这些功能尚未最终确定,可能不稳定,也可能会发生变化。" + "想尝试新功能?" + "实验室" + "更多设置" + "音视频通话" + "配置不匹配" + "我们简化了通知设置,使选项更易于查找。您过去选择的某些自定义设置未在此处显示,但它们仍然有效。 + +如果继续,您的某些设置可能会更改。" + "私聊" + "各聊天室的独立设置" + "更新通知设置时出错。" + "全部消息" + "仅限提及和关键词" + "在私聊中,请通知我:" + "在群聊中,请通知我:" + "在此设备上启用通知" + "配置尚未更正,请重试。" + "群聊" + "邀请" + "服务器在加密聊天室中不支持此选项,因此在某些聊天室可能无法收到通知。" + "提及" + "全部" + "提及" + "请通知我:" + @room 时通知我 + "要接收通知,请更改您的 %1$s。" + "系统设置" + "系统通知已关闭" + "通知" + "推送历史记录" + "排查问题" + "排查通知问题" + diff --git a/features/preferences/impl/src/main/res/values/localazy.xml b/features/preferences/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..31ee676 --- /dev/null +++ b/features/preferences/impl/src/main/res/values/localazy.xml @@ -0,0 +1,82 @@ + + + "To ensure you never miss an important call, please change your settings to allow full-screen notifications when your phone is locked." + "Enhance your call experience" + "Choose how to receive notifications" + "Developer mode" + "Enable to have access to features and functionality for developers." + "Custom Element Call base URL" + "Set a custom base URL for Element Call." + "Invalid URL, please make sure you include the protocol (http/https) and the correct address." + "Hide avatars in room invite requests" + "Hide media previews in timeline" + "Labs" + "Upload photos and videos faster and reduce data usage" + "Optimise media quality" + "Moderation and Safety" + "Automatically optimise images for faster uploads and smaller file sizes." + "Optimise image upload quality" + "%1$s. Tap here to change." + "High (1080p)" + "Low (480p)" + "Standard (720p)" + "Video upload quality" + "Push notification provider" + "Disable the rich text editor to type Markdown manually." + "Read receipts" + "If turned off, your read receipts won\'t be sent to anyone. You will still receive read receipts from other users." + "Share presence" + "If turned off, you won’t be able to send or receive read receipts or typing notifications." + "Always hide" + "Always show" + "In private rooms" + "A hidden media can always be shown by tapping on it" + "Show media in timeline" + "Enable option to view message source in the timeline." + "You have no blocked users" + "Unblock" + "You\'ll be able to see all messages from them again." + "Unblock user" + "Unblocking…" + "Display name" + "Your display name" + "An unknown error was encountered and the information couldn\'t be changed." + "Unable to update profile" + "Edit profile" + "Updating profile…" + "Enable thread replies" + "The app will restart to apply this change." + "Try out our latest ideas in development. These features are not finalised; they may be unstable, may change." + "Feeling experimental?" + "Labs" + "Additional settings" + "Audio and video calls" + "Configuration mismatch" + "We’ve simplified Notifications Settings to make options easier to find. Some custom settings you’ve chosen in the past are not shown here, but they’re still active. + +If you proceed, some of your settings may change." + "Direct chats" + "Custom setting per chat" + "An error occurred while updating the notification setting." + "All messages" + "Mentions and Keywords only" + "On direct chats, notify me for" + "On group chats, notify me for" + "Enable notifications on this device" + "The configuration has not been corrected, please try again." + "Group chats" + "Invitations" + "Your homeserver does not support this option in encrypted rooms, you may not get notified in some rooms." + "Mentions" + "All" + "Mentions" + "Notify me for" + "Notify me on @room" + "To receive notifications, please change your %1$s." + "system settings" + "System notifications turned off" + "Notifications" + "Push history" + "Troubleshoot" + "Troubleshoot notifications" + diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt new file mode 100644 index 0000000..7a95062 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.features.deactivation.test.FakeAccountDeactivationEntryPoint +import io.element.android.features.licenses.test.FakeOpenSourceLicensesEntryPoint +import io.element.android.features.lockscreen.test.FakeLockScreenEntryPoint +import io.element.android.features.logout.test.FakeLogoutEntryPoint +import io.element.android.features.preferences.api.PreferencesEntryPoint +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleShootEntryPoint +import io.element.android.libraries.troubleshoot.test.FakePushHistoryEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultPreferencesEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultPreferencesEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + PreferencesFlowNode( + buildContext = buildContext, + plugins = plugins, + lockScreenEntryPoint = FakeLockScreenEntryPoint(), + notificationTroubleShootEntryPoint = FakeNotificationTroubleShootEntryPoint(), + pushHistoryEntryPoint = FakePushHistoryEntryPoint(), + logoutEntryPoint = FakeLogoutEntryPoint(), + openSourceLicensesEntryPoint = FakeOpenSourceLicensesEntryPoint(), + accountDeactivationEntryPoint = FakeAccountDeactivationEntryPoint(), + ) + } + val callback = object : PreferencesEntryPoint.Callback { + override fun navigateToAddAccount() = lambdaError() + override fun navigateToBugReport() = lambdaError() + override fun navigateToSecureBackup() = lambdaError() + override fun navigateToRoomNotificationSettings(roomId: RoomId) = lambdaError() + override fun navigateToEvent(roomId: RoomId, eventId: EventId) = lambdaError() + } + val params = PreferencesEntryPoint.Params( + initialElement = PreferencesEntryPoint.InitialTarget.NotificationSettings, + ) + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + params = params, + callback = callback, + ) + assertThat(result).isInstanceOf(PreferencesFlowNode::class.java) + assertThat(result.plugins).contains(params) + assertThat(result.plugins).contains(callback) + } + + @Test + fun `test initial target to nav target mapping`() { + assertThat(PreferencesEntryPoint.InitialTarget.Root.toNavTarget()) + .isEqualTo(PreferencesFlowNode.NavTarget.Root) + assertThat(PreferencesEntryPoint.InitialTarget.NotificationSettings.toNavTarget()) + .isEqualTo(PreferencesFlowNode.NavTarget.NotificationSettings) + assertThat(PreferencesEntryPoint.InitialTarget.NotificationTroubleshoot.toNavTarget()) + .isEqualTo(PreferencesFlowNode.NavTarget.TroubleshootNotifications) + } +} 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 0000000..eb0aa73 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/about/AboutPresenterTest.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.about + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class AboutPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = AboutPresenter() + moleculeFlow(RecompositionMode.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/about/AboutViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/about/AboutViewTest.kt new file mode 100644 index 0000000..258e985 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/about/AboutViewTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.about + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AboutViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invokes back callback`() { + ensureCalledOnce { callback -> + rule.setAboutView( + anAboutState(), + onBackClick = callback, + ) + rule.pressBack() + } + } + + @Test + fun `clicking on an item invokes the expected callback`() { + val state = anAboutState() + ensureCalledOnceWithParam(state.elementLegals.first()) { callback -> + rule.setAboutView( + state, + onElementLegalClick = callback, + ) + rule.clickOn(state.elementLegals.first().titleRes) + } + } + + @Test + fun `clicking on the open source licenses invokes the expected callback`() { + ensureCalledOnce { callback -> + rule.setAboutView( + anAboutState(), + onOpenSourceLicensesClick = callback, + ) + rule.clickOn(CommonStrings.common_open_source_licenses) + } + } +} + +private fun AndroidComposeTestRule.setAboutView( + state: AboutState, + onElementLegalClick: (ElementLegal) -> Unit = EnsureNeverCalledWithParam(), + onOpenSourceLicensesClick: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + AboutView( + state = state, + onElementLegalClick = onElementLegalClick, + onOpenSourceLicensesClick = onOpenSourceLicensesClick, + onBackClick = onBackClick, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt new file mode 100644 index 0000000..942d549 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt @@ -0,0 +1,311 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.advanced + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore +import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class AdvancedSettingsPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createAdvancedSettingsPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + with(awaitItem()) { + assertThat(isDeveloperModeEnabled).isFalse() + assertThat(isSharePresenceEnabled).isTrue() + assertThat(mediaOptimizationState).isNull() + assertThat(theme).isEqualTo(ThemeOption.System) + assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) + assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Uninitialized) + } + + // After the initial state, we expect the media optimization state to be set + with(awaitItem()) { + assertThat(mediaOptimizationState).isInstanceOf(MediaOptimizationState.AllMedia::class.java) + assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue() + } + } + } + + @Test + fun `present - developer mode on off`() = runTest { + val presenter = createAdvancedSettingsPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip until the initial data it loaded + skipItems(1) + + with(awaitItem()) { + assertThat(isDeveloperModeEnabled).isFalse() + eventSink(AdvancedSettingsEvents.SetDeveloperModeEnabled(true)) + } + with(awaitItem()) { + assertThat(isDeveloperModeEnabled).isTrue() + eventSink(AdvancedSettingsEvents.SetDeveloperModeEnabled(false)) + } + with(awaitItem()) { + assertThat(isDeveloperModeEnabled).isFalse() + } + } + } + + @Test + fun `present - share presence off on`() = runTest { + val presenter = createAdvancedSettingsPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip until the initial data it loaded + skipItems(1) + + with(awaitItem()) { + assertThat(isSharePresenceEnabled).isTrue() + eventSink(AdvancedSettingsEvents.SetSharePresenceEnabled(false)) + } + with(awaitItem()) { + assertThat(isSharePresenceEnabled).isFalse() + eventSink(AdvancedSettingsEvents.SetSharePresenceEnabled(true)) + } + with(awaitItem()) { + assertThat(isSharePresenceEnabled).isTrue() + } + } + } + + @Test + fun `present - compress media off on`() = runTest { + val presenter = createAdvancedSettingsPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip until the initial data it loaded + skipItems(1) + + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue() + eventSink(AdvancedSettingsEvents.SetCompressMedia(false)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isFalse() + eventSink(AdvancedSettingsEvents.SetCompressMedia(true)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.AllMedia).isEnabled).isTrue() + } + } + } + + @Test + fun `present - compress images off on`() = runTest { + val presenter = createAdvancedSettingsPresenter( + featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.SelectableMediaQuality, true) + } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip until the initial data it loaded + skipItems(1) + + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isTrue() + eventSink(AdvancedSettingsEvents.SetCompressImages(false)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isFalse() + eventSink(AdvancedSettingsEvents.SetCompressImages(true)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).compressImages).isTrue() + } + } + } + + @Test + fun `present - video upload quality selector`() = runTest { + val presenter = createAdvancedSettingsPresenter( + featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.SelectableMediaQuality, true) + } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip until the initial data it loaded + skipItems(1) + + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.STANDARD) + eventSink(AdvancedSettingsEvents.SetVideoUploadQuality(VideoCompressionPreset.LOW)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.LOW) + eventSink(AdvancedSettingsEvents.SetVideoUploadQuality(VideoCompressionPreset.HIGH)) + } + with(awaitItem()) { + assertThat((mediaOptimizationState as MediaOptimizationState.Split).videoPreset).isEqualTo(VideoCompressionPreset.HIGH) + } + } + } + + @Test + fun `present - change theme`() = runTest { + val presenter = createAdvancedSettingsPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip until the initial data it loaded + skipItems(1) + + with(awaitItem()) { + assertThat(theme).isEqualTo(ThemeOption.System) + eventSink(AdvancedSettingsEvents.SetTheme(ThemeOption.Dark)) + } + with(awaitItem()) { + assertThat(theme).isEqualTo(ThemeOption.Dark) + eventSink(AdvancedSettingsEvents.SetTheme(ThemeOption.Light)) + } + with(awaitItem()) { + assertThat(theme).isEqualTo(ThemeOption.Light) + eventSink(AdvancedSettingsEvents.SetTheme(ThemeOption.System)) + } + with(awaitItem()) { + assertThat(theme).isEqualTo(ThemeOption.System) + } + } + } + + @Test + fun `present - hide invite avatars`() = runTest { + val mediaPreviewStore = FakeMediaPreviewConfigStateStore() + val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip until the initial data it loaded + skipItems(1) + + with(awaitItem()) { + assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() + eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(true)) + } + with(awaitItem()) { + assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue() + eventSink(AdvancedSettingsEvents.SetHideInviteAvatars(false)) + } + with(awaitItem()) { + assertThat(mediaPreviewConfigState.hideInviteAvatars).isFalse() + } + } + assertThat(mediaPreviewStore.getSetHideInviteAvatarsEvents()).isEqualTo(listOf(true, false)) + } + + @Test + fun `present - timeline media preview value`() = runTest { + val mediaPreviewStore = FakeMediaPreviewConfigStateStore() + val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip until the initial data it loaded + skipItems(1) + + with(awaitItem()) { + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) + eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) + } + with(awaitItem()) { + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off) + eventSink(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) + } + with(awaitItem()) { + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private) + } + } + assertThat(mediaPreviewStore.getSetTimelineMediaPreviewValueEvents()).isEqualTo( + listOf(MediaPreviewValue.Off, MediaPreviewValue.Private) + ) + } + + @Test + fun `present - media preview state with custom initial values`() = runTest { + val mediaPreviewStore = FakeMediaPreviewConfigStateStore( + hideInviteAvatarsValue = true, + timelineMediaPreviewValue = MediaPreviewValue.Private + ) + val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip until the initial data it loaded + skipItems(1) + + with(awaitItem()) { + assertThat(mediaPreviewConfigState.hideInviteAvatars).isTrue() + assertThat(mediaPreviewConfigState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private) + } + } + } + + @Test + fun `present - async actions state`() = runTest { + val mediaPreviewStore = FakeMediaPreviewConfigStateStore( + setHideInviteAvatarsActionValue = AsyncAction.Loading, + setTimelineMediaPreviewActionValue = AsyncAction.Success(Unit) + ) + val presenter = createAdvancedSettingsPresenter(mediaPreviewConfigStateStore = mediaPreviewStore) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip until the initial data it loaded + skipItems(1) + + with(awaitItem()) { + assertThat(mediaPreviewConfigState.setHideInviteAvatarsAction).isEqualTo(AsyncAction.Loading) + assertThat(mediaPreviewConfigState.setTimelineMediaPreviewAction).isEqualTo(AsyncAction.Success(Unit)) + } + } + } + + private fun CoroutineScope.createAdvancedSettingsPresenter( + appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), + sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), + mediaPreviewConfigStateStore: MediaPreviewConfigStateStore = FakeMediaPreviewConfigStateStore(), + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), + ) = AdvancedSettingsPresenter( + appPreferencesStore = appPreferencesStore, + sessionPreferencesStore = sessionPreferencesStore, + mediaPreviewConfigStateStore = mediaPreviewConfigStateStore, + featureFlagService = featureFlagService, + sessionCoroutineScope = this, + ) +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt new file mode 100644 index 0000000..36fd309 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.advanced + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.Interaction +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.compose.LocalAnalyticsService +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class AdvancedSettingsViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder + ), + onBackClick = it + ) + rule.pressBack() + } + } + + @Test + fun `clicking on other theme emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.common_appearance) + rule.clickOn(CommonStrings.common_dark) + eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTheme(ThemeOption.Dark)) + } + + @Test + fun `clicking on View source emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_view_source) + eventsRecorder.assertSingle(AdvancedSettingsEvents.SetDeveloperModeEnabled(true)) + } + + @Test + fun `clicking on Share presence emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_advanced_settings_share_presence) + eventsRecorder.assertSingle(AdvancedSettingsEvents.SetSharePresenceEnabled(true)) + } + + @Test + fun `clicking on media to enable compression emits the expected event`() { + val eventsRecorder = EventsRecorder() + val analyticsService = FakeAnalyticsService() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + ), + analyticsService = analyticsService + ) + rule.clickOn(R.string.screen_advanced_settings_media_compression_description) + eventsRecorder.assertSingle(AdvancedSettingsEvents.SetCompressMedia(true)) + assertThat(analyticsService.capturedEvents).isEqualTo( + listOf( + Interaction( + name = Interaction.Name.MobileSettingsOptimizeMediaUploadsEnabled + ) + ) + ) + } + + @Test + fun `clicking on media to disable compression emits the expected event`() { + val eventsRecorder = EventsRecorder() + val analyticsService = FakeAnalyticsService() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true), + eventSink = eventsRecorder, + ), + analyticsService = analyticsService + ) + rule.clickOn(R.string.screen_advanced_settings_media_compression_description) + eventsRecorder.assertSingle(AdvancedSettingsEvents.SetCompressMedia(false)) + assertThat(analyticsService.capturedEvents).isEqualTo( + listOf( + Interaction( + name = Interaction.Name.MobileSettingsOptimizeMediaUploadsDisabled + ) + ) + ) + } + + @Test + @Config(qualifiers = "h1080dp") + fun `clicking on hide invite avatars emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + hideInviteAvatars = false + ), + ) + rule.clickOn(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title) + eventsRecorder.assertSingle(AdvancedSettingsEvents.SetHideInviteAvatars(true)) + } + + @Test + @Config(qualifiers = "h1080dp") + fun `clicking on timeline media preview always hide emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + timelineMediaPreviewValue = MediaPreviewValue.On + ), + ) + rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide) + eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) + } + + @Test + @Config(qualifiers = "h1080dp") + fun `clicking on timeline media preview private rooms emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + timelineMediaPreviewValue = MediaPreviewValue.On + ), + ) + rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms) + eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) + } + + @Test + @Config(qualifiers = "h1080dp") + fun `clicking on timeline media preview always show emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + timelineMediaPreviewValue = MediaPreviewValue.Off + ), + ) + rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_always_show) + eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.On)) + } + + @Test + @Config(qualifiers = "h1080dp") + fun `hide invite avatars toggle is disabled when action is loading`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + hideInviteAvatars = false, + setHideInviteAvatarsAction = AsyncAction.Loading + ), + ) + // The toggle should be disabled, so clicking should not emit any events + rule.clickOn(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title) + } + + @Test + @Config(qualifiers = "h1080dp") + fun `timeline media preview options are disabled when action is loading`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + timelineMediaPreviewValue = MediaPreviewValue.On, + setTimelineMediaPreviewAction = AsyncAction.Loading + ), + ) + // The options should be disabled, so clicking should not emit any events + rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide) + rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms) + } +} + +private fun AndroidComposeTestRule.setAdvancedSettingsView( + state: AdvancedSettingsState, + analyticsService: AnalyticsService = FakeAnalyticsService(), + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + CompositionLocalProvider( + LocalAnalyticsService provides analyticsService, + ) { + AdvancedSettingsView( + state = state, + onBackClick = onBackClick, + ) + } + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/FakeMediaPreviewConfigStateStore.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/FakeMediaPreviewConfigStateStore.kt new file mode 100644 index 0000000..b07e8b7 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/FakeMediaPreviewConfigStateStore.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.advanced + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.media.MediaPreviewValue + +class FakeMediaPreviewConfigStateStore( + hideInviteAvatarsValue: Boolean = false, + timelineMediaPreviewValue: MediaPreviewValue = MediaPreviewValue.On, + setHideInviteAvatarsActionValue: AsyncAction = AsyncAction.Uninitialized, + setTimelineMediaPreviewActionValue: AsyncAction = AsyncAction.Uninitialized, +) : MediaPreviewConfigStateStore { + private val hideInviteAvatars = mutableStateOf(hideInviteAvatarsValue) + private val timelineMediaPreviewValue = mutableStateOf(timelineMediaPreviewValue) + private val setHideInviteAvatarsAction = mutableStateOf(setHideInviteAvatarsActionValue) + private val setTimelineMediaPreviewAction = mutableStateOf(setTimelineMediaPreviewActionValue) + + private val setHideInviteAvatarsEvents = mutableListOf() + private val setTimelineMediaPreviewValueEvents = mutableListOf() + + @Composable + override fun state(): MediaPreviewConfigState { + return MediaPreviewConfigState( + hideInviteAvatars = hideInviteAvatars.value, + timelineMediaPreviewValue = timelineMediaPreviewValue.value, + setHideInviteAvatarsAction = setHideInviteAvatarsAction.value, + setTimelineMediaPreviewAction = setTimelineMediaPreviewAction.value, + ) + } + + override fun setHideInviteAvatars(hide: Boolean) { + setHideInviteAvatarsEvents.add(hide) + hideInviteAvatars.value = hide + } + + override fun setTimelineMediaPreviewValue(value: MediaPreviewValue) { + setTimelineMediaPreviewValueEvents.add(value) + timelineMediaPreviewValue.value = value + } + + fun getSetHideInviteAvatarsEvents(): List = setHideInviteAvatarsEvents.toList() + fun getSetTimelineMediaPreviewValueEvents(): List = setTimelineMediaPreviewValueEvents.toList() +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStoreTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStoreTest.kt new file mode 100644 index 0000000..becd6b1 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/MediaPreviewConfigStateStoreTest.kt @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.advanced + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.matrix.api.media.MediaPreviewConfig +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.matrix.test.media.FakeMediaPreviewService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class MediaPreviewConfigStateStoreTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `initial state is correct with default values`() = runTest { + val store = createMediaPreviewConfigStateStore() + + moleculeFlow(RecompositionMode.Immediate) { + store.state() + }.test { + val initialState = awaitItem() + assertThat(initialState.hideInviteAvatars).isFalse() + assertThat(initialState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) + assertThat(initialState.setHideInviteAvatarsAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(initialState.setTimelineMediaPreviewAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + } + } + + @Test + fun `state updates when config flow emits new values`() = runTest { + val configFlow = MutableStateFlow(MediaPreviewConfig.DEFAULT) + val mediaPreviewService = FakeMediaPreviewService(configFlow) + val store = createMediaPreviewConfigStateStore(mediaPreviewService = mediaPreviewService) + + moleculeFlow(RecompositionMode.Immediate) { + store.state() + }.test { + // Initial state + val initialState = awaitItem() + assertThat(initialState.hideInviteAvatars).isFalse() + assertThat(initialState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) + + // Update config + configFlow.value = MediaPreviewConfig(hideInviteAvatar = true, mediaPreviewValue = MediaPreviewValue.Private) + + skipItems(1) + // Updated state + val updatedState = awaitItem() + assertThat(updatedState.hideInviteAvatars).isTrue() + assertThat(updatedState.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Private) + } + } + + @Test + fun `setHideInviteAvatars updates state and calls service on success`() = runTest { + val setHideInviteAvatarsValueLambda = lambdaRecorder> { Result.success(Unit) } + val mediaPreviewService = FakeMediaPreviewService( + setHideInviteAvatarsResult = setHideInviteAvatarsValueLambda + ) + val store = createMediaPreviewConfigStateStore(mediaPreviewService = mediaPreviewService) + moleculeFlow(RecompositionMode.Immediate) { + store.state() + }.test { + awaitItem().also { state -> + assertThat(state.hideInviteAvatars).isFalse() + } + store.setHideInviteAvatars(true) + + awaitItem().also { state -> + assertThat(state.hideInviteAvatars).isTrue() + } + awaitItem().also { state -> + assertThat(state.hideInviteAvatars).isTrue() + assertThat(state.setHideInviteAvatarsAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.hideInviteAvatars).isTrue() + assertThat(state.setHideInviteAvatarsAction).isInstanceOf(AsyncAction.Success::class.java) + } + assert(setHideInviteAvatarsValueLambda).isCalledOnce() + } + } + + @Test + fun `setHideInviteAvatars reverts state on failure`() = runTest { + val setHideInviteAvatarsValueLambda = lambdaRecorder> { + Result.failure(Exception()) + } + val mediaPreviewService = FakeMediaPreviewService( + setHideInviteAvatarsResult = setHideInviteAvatarsValueLambda + ) + val store = createMediaPreviewConfigStateStore(mediaPreviewService = mediaPreviewService) + moleculeFlow(RecompositionMode.Immediate) { + store.state() + }.test { + awaitItem().also { state -> + assertThat(state.hideInviteAvatars).isFalse() + } + store.setHideInviteAvatars(true) + + awaitItem().also { state -> + assertThat(state.hideInviteAvatars).isTrue() + } + awaitItem().also { state -> + assertThat(state.hideInviteAvatars).isTrue() + assertThat(state.setHideInviteAvatarsAction).isInstanceOf(AsyncAction.Loading::class.java) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.hideInviteAvatars).isFalse() + assertThat(state.setHideInviteAvatarsAction).isInstanceOf(AsyncAction.Failure::class.java) + } + assert(setHideInviteAvatarsValueLambda).isCalledOnce() + } + } + + @Test + fun `setTimelineMediaPreviewValue updates state and calls service on success`() = runTest { + val setMediaPreviewValueLambda = lambdaRecorder> { Result.success(Unit) } + val mediaPreviewService = FakeMediaPreviewService( + setMediaPreviewValueResult = setMediaPreviewValueLambda + ) + val store = createMediaPreviewConfigStateStore(mediaPreviewService = mediaPreviewService) + moleculeFlow(RecompositionMode.Immediate) { + store.state() + }.test { + awaitItem().also { state -> + assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) + } + store.setTimelineMediaPreviewValue(MediaPreviewValue.Off) + + awaitItem().also { state -> + assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off) + } + awaitItem().also { state -> + assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off) + assertThat(state.setTimelineMediaPreviewAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off) + assertThat(state.setTimelineMediaPreviewAction).isInstanceOf(AsyncAction.Success::class.java) + } + assert(setMediaPreviewValueLambda).isCalledOnce() + } + } + + @Test + fun `setTimelineMediaPreviewValue reverts state on failure`() = runTest { + val setMediaPreviewValueLambda = lambdaRecorder> { + Result.failure(Exception()) + } + val mediaPreviewService = FakeMediaPreviewService( + setMediaPreviewValueResult = setMediaPreviewValueLambda + ) + val store = createMediaPreviewConfigStateStore(mediaPreviewService = mediaPreviewService) + moleculeFlow(RecompositionMode.Immediate) { + store.state() + }.test { + awaitItem().also { state -> + assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) + } + store.setTimelineMediaPreviewValue(MediaPreviewValue.Off) + + awaitItem().also { state -> + assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off) + } + awaitItem().also { state -> + assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.Off) + assertThat(state.setTimelineMediaPreviewAction).isInstanceOf(AsyncAction.Loading::class.java) + } + skipItems(1) + awaitItem().also { state -> + assertThat(state.timelineMediaPreviewValue).isEqualTo(MediaPreviewValue.On) + assertThat(state.setTimelineMediaPreviewAction).isInstanceOf(AsyncAction.Failure::class.java) + } + assert(setMediaPreviewValueLambda).isCalledOnce() + } + } + + private fun TestScope.createMediaPreviewConfigStateStore( + mediaPreviewService: FakeMediaPreviewService = FakeMediaPreviewService(), + snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher() + ): MediaPreviewConfigStateStore = DefaultMediaPreviewConfigStateStore( + sessionCoroutineScope = backgroundScope, + mediaPreviewService = mediaPreviewService, + snackbarDispatcher = snackbarDispatcher + ) +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsPresenterTest.kt new file mode 100644 index 0000000..5a00a1f --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsPresenterTest.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.analytics + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.analytics.api.preferences.aAnalyticsPreferencesState +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class AnalyticsSettingsPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = AnalyticsSettingsPresenter( + analyticsPreferencesPresenter = { aAnalyticsPreferencesState() }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.analyticsPreferencesState.isEnabled).isFalse() + } + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt new file mode 100644 index 0000000..b354976 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.blockedusers + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BlockedUserViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invokes back callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setBlockedUsersView( + aBlockedUsersState( + eventSink = eventsRecorder + ), + onBackClick = callback, + ) + rule.pressBack() + } + } + + @Test + fun `clicking on a user emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val userList = aMatrixUserList() + rule.setBlockedUsersView( + aBlockedUsersState( + blockedUsers = userList, + eventSink = eventsRecorder + ), + ) + rule.onNodeWithText(userList.first().displayName.orEmpty()).performClick() + eventsRecorder.assertSingle(BlockedUsersEvents.Unblock(userList.first().userId)) + } + + @Test + fun `clicking on cancel sends a BlockedUsersEvents`() { + val eventsRecorder = EventsRecorder() + rule.setBlockedUsersView( + aBlockedUsersState( + unblockUserAction = AsyncAction.ConfirmingNoParams, + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(BlockedUsersEvents.Cancel) + } + + @Test + fun `clicking on confirm sends a BlockedUsersEvents`() { + val eventsRecorder = EventsRecorder() + rule.setBlockedUsersView( + aBlockedUsersState( + unblockUserAction = AsyncAction.ConfirmingNoParams, + eventSink = eventsRecorder + ), + ) + rule.clickOn(R.string.screen_blocked_users_unblock_alert_action) + eventsRecorder.assertSingle(BlockedUsersEvents.ConfirmUnblock) + } +} + +private fun AndroidComposeTestRule.setBlockedUsersView( + state: BlockedUsersState, + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + BlockedUsersView( + state = state, + onBackClick = onBackClick, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTest.kt new file mode 100644 index 0000000..d3ac29d --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTest.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.blockedusers + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +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.user.MatrixUser +import io.element.android.libraries.matrix.test.AN_EXCEPTION +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.FakeMatrixClient +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class BlockedUsersPresenterTest { + @Test + fun `present - initial state with no blocked users`() = runTest { + val presenter = aBlockedUsersPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + with(awaitItem()) { + assertThat(blockedUsers).isEmpty() + assertThat(unblockUserAction).isEqualTo(AsyncAction.Uninitialized) + } + } + } + + @Test + fun `present - initial state with blocked users`() = runTest { + val matrixClient = FakeMatrixClient( + ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID)) + ) + val presenter = aBlockedUsersPresenter(matrixClient = matrixClient) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + with(awaitItem()) { + assertThat(blockedUsers).isEqualTo(persistentListOf(MatrixUser(A_USER_ID))) + assertThat(unblockUserAction).isEqualTo(AsyncAction.Uninitialized) + } + } + } + + @Test + fun `present - blocked users list updates with new emissions`() = runTest { + val ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID)) + val matrixClient = FakeMatrixClient( + ignoredUsersFlow = ignoredUsersFlow + ) + val presenter = aBlockedUsersPresenter(matrixClient = matrixClient) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + with(awaitItem()) { + assertThat(blockedUsers).isEqualTo(listOf(MatrixUser(A_USER_ID))) + } + ignoredUsersFlow.value = persistentListOf(A_USER_ID, A_USER_ID_2) + skipItems(1) + with(awaitItem()) { + assertThat(blockedUsers).isEqualTo(listOf(MatrixUser(A_USER_ID), MatrixUser(A_USER_ID_2))) + } + } + } + + @Test + fun `present - blocked users list with data`() = runTest { + val alice = MatrixUser(A_USER_ID, displayName = "Alice", avatarUrl = "aliceAvatar") + val matrixClient = FakeMatrixClient( + ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID, A_USER_ID_2)) + ).apply { + givenGetProfileResult(A_USER_ID, Result.success(alice)) + givenGetProfileResult(A_USER_ID_2, Result.failure(AN_EXCEPTION)) + } + val presenter = aBlockedUsersPresenter( + matrixClient = matrixClient, + featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.ShowBlockedUsersDetails, true) + } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + with(awaitItem()) { + assertThat(blockedUsers).isEqualTo(listOf(MatrixUser(A_USER_ID), MatrixUser(A_USER_ID_2))) + } + // Alice is resolved + with(awaitItem()) { + assertThat(blockedUsers).isEqualTo(listOf(alice, MatrixUser(A_USER_ID_2))) + } + } + } + + @Test + fun `present - unblock user`() = runTest { + val matrixClient = FakeMatrixClient( + ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID)) + ) + val presenter = aBlockedUsersPresenter(matrixClient = matrixClient) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(BlockedUsersEvents.Unblock(A_USER_ID)) + + assertThat(awaitItem().unblockUserAction).isInstanceOf(AsyncAction.Confirming::class.java) + initialState.eventSink(BlockedUsersEvents.ConfirmUnblock) + + assertThat(awaitItem().unblockUserAction).isInstanceOf(AsyncAction.Loading::class.java) + assertThat(awaitItem().unblockUserAction).isInstanceOf(AsyncAction.Success::class.java) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - unblock user handles failure`() = runTest { + val matrixClient = FakeMatrixClient( + unIgnoreUserResult = { Result.failure(IllegalStateException("User not banned")) }, + ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID)) + ) + val presenter = aBlockedUsersPresenter(matrixClient = matrixClient) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(BlockedUsersEvents.Unblock(A_USER_ID)) + + assertThat(awaitItem().unblockUserAction).isInstanceOf(AsyncAction.Confirming::class.java) + initialState.eventSink(BlockedUsersEvents.ConfirmUnblock) + + assertThat(awaitItem().unblockUserAction).isInstanceOf(AsyncAction.Loading::class.java) + assertThat(awaitItem().unblockUserAction).isInstanceOf(AsyncAction.Failure::class.java) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - unblock user then cancel`() = runTest { + val matrixClient = FakeMatrixClient( + unIgnoreUserResult = { Result.failure(IllegalStateException("User not banned")) }, + ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID)) + ) + val presenter = aBlockedUsersPresenter(matrixClient = matrixClient) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(BlockedUsersEvents.Unblock(A_USER_ID)) + + assertThat(awaitItem().unblockUserAction).isInstanceOf(AsyncAction.Confirming::class.java) + initialState.eventSink(BlockedUsersEvents.Cancel) + + assertThat(awaitItem().unblockUserAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - confirm unblock without a pending blocked user does nothing`() = runTest { + val presenter = aBlockedUsersPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(BlockedUsersEvents.ConfirmUnblock) + ensureAllEventsConsumed() + } + } + + private fun aBlockedUsersPresenter( + matrixClient: FakeMatrixClient = FakeMatrixClient(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), + ) = BlockedUsersPresenter( + matrixClient = matrixClient, + featureFlagService = featureFlagService, + ) +} 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 0000000..fe2d844 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.preferences.impl.developer + +import androidx.compose.ui.graphics.Color +import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem +import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase +import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase +import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.featureflag.api.Feature +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeature +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DeveloperSettingsPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - ensures initial states are correct`() = runTest { + val getAvailableFeaturesResult = lambdaRecorder> { _, _ -> + listOf( + FakeFeature( + key = "feature_1", + title = "Feature 1", + isInLabs = false, + ) + ) + } + val presenter = createDeveloperSettingsPresenter( + featureFlagService = FakeFeatureFlagService(getAvailableFeaturesResult = getAvailableFeaturesResult) + ) + presenter.test { + awaitItem().also { state -> + assertThat(state.features).isEmpty() + assertThat(state.clearCacheAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(state.cacheSize).isEqualTo(AsyncData.Uninitialized) + assertThat(state.customElementCallBaseUrlState).isNotNull() + assertThat(state.customElementCallBaseUrlState.baseUrl).isNull() + assertThat(state.rageshakeState.isEnabled).isFalse() + assertThat(state.rageshakeState.isSupported).isTrue() + assertThat(state.rageshakeState.sensitivity).isEqualTo(0.3f) + assertThat(state.tracingLogLevel).isEqualTo(AsyncData.Uninitialized) + assertThat(state.isEnterpriseBuild).isFalse() + assertThat(state.showColorPicker).isFalse() + } + awaitItem().also { state -> + assertThat(state.features).isNotEmpty() + assertThat(state.features).hasSize(1) + assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.INFO) + } + awaitItem().also { state -> + assertThat(state.cacheSize).isInstanceOf(AsyncData.Success::class.java) + } + getAvailableFeaturesResult.assertions().isCalledOnce() + .with(value(false), value(false)) + } + } + + @Test + fun `present - ensures Room directory search is not present on release Google Play builds`() = runTest { + val buildMeta = aBuildMeta(buildType = BuildType.RELEASE, flavorDescription = "GooglePlay") + val presenter = createDeveloperSettingsPresenter(buildMeta = buildMeta) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.features).doesNotContain(FeatureFlags.RoomDirectorySearch) + } + } + } + + @Test + fun `present - ensures state is updated when enabled feature event is triggered`() = runTest { + val presenter = createDeveloperSettingsPresenter() + presenter.test { + skipItems(2) + awaitItem().also { state -> + val feature = state.features.first { !it.isEnabled } + state.eventSink(DeveloperSettingsEvents.UpdateEnabledFeature(feature, !feature.isEnabled)) + } + awaitItem().also { state -> + val feature = state.features.first() + assertThat(feature.isEnabled).isTrue() + assertThat(feature.key).isEqualTo(feature.key) + } + } + } + + @Test + fun `present - clear cache`() = runTest { + val clearCacheUseCase = FakeClearCacheUseCase() + val presenter = createDeveloperSettingsPresenter(clearCacheUseCase = clearCacheUseCase) + presenter.test { + skipItems(2) + assertThat(clearCacheUseCase.executeHasBeenCalled).isFalse() + awaitItem().also { state -> + state.eventSink(DeveloperSettingsEvents.ClearCache) + } + awaitItem().also { state -> + assertThat(state.clearCacheAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.clearCacheAction).isInstanceOf(AsyncAction.Success::class.java) + assertThat(clearCacheUseCase.executeHasBeenCalled).isTrue() + } + awaitItem().also { state -> + assertThat(state.cacheSize).isInstanceOf(AsyncData.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.cacheSize).isInstanceOf(AsyncData.Success::class.java) + } + } + } + + @Test + fun `present - custom element call base url`() = runTest { + val preferencesStore = InMemoryAppPreferencesStore() + val presenter = createDeveloperSettingsPresenter(preferencesStore = preferencesStore) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.customElementCallBaseUrlState.baseUrl).isNull() + state.eventSink(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.ahoy")) + } + awaitItem().also { state -> + assertThat(state.customElementCallBaseUrlState.baseUrl).isEqualTo("https://call.element.ahoy") + } + } + } + + @Test + fun `present - custom element call base url validator needs at least an HTTP scheme and host`() = runTest { + val presenter = createDeveloperSettingsPresenter() + presenter.test { + skipItems(2) + val urlValidator = awaitItem().customElementCallBaseUrlState.validator + assertThat(urlValidator("")).isTrue() // We allow empty string to clear the value and use the default one + assertThat(urlValidator("test")).isFalse() + assertThat(urlValidator("http://")).isFalse() + assertThat(urlValidator("geo://test")).isFalse() + assertThat(urlValidator("https://call.element.io")).isTrue() + } + } + + @Test + fun `present - changing tracing log level`() = runTest { + val preferences = InMemoryAppPreferencesStore() + val presenter = createDeveloperSettingsPresenter(preferencesStore = preferences) + presenter.test { + skipItems(2) + awaitItem().also { state -> + assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.INFO) + state.eventSink(DeveloperSettingsEvents.SetTracingLogLevel(LogLevelItem.TRACE)) + } + awaitItem().also { state -> + assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.TRACE) + } + } + } + + @Test + fun `present - enterprise build can change the brand color`() = runTest { + val overrideBrandColorResult = lambdaRecorder { _, _ -> } + val presenter = createDeveloperSettingsPresenter( + enterpriseService = FakeEnterpriseService( + isEnterpriseBuild = true, + overrideBrandColorResult = overrideBrandColorResult, + ) + ) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isEnterpriseBuild).isTrue() + initialState.eventSink(DeveloperSettingsEvents.SetShowColorPicker(true)) + assertThat(awaitItem().showColorPicker).isTrue() + initialState.eventSink(DeveloperSettingsEvents.SetShowColorPicker(false)) + assertThat(awaitItem().showColorPicker).isFalse() + initialState.eventSink(DeveloperSettingsEvents.SetShowColorPicker(true)) + assertThat(awaitItem().showColorPicker).isTrue() + initialState.eventSink(DeveloperSettingsEvents.ChangeBrandColor(Color.Green)) + assertThat(awaitItem().showColorPicker).isFalse() + skipItems(1) + overrideBrandColorResult.assertions().isCalledOnce() + .with(value(A_SESSION_ID), value("#00FF00")) + } + } + + private fun createDeveloperSettingsPresenter( + sessionId: SessionId = A_SESSION_ID, + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService( + getAvailableFeaturesResult = { _, _ -> + listOf( + FakeFeature( + key = "feature_1", + title = "Feature 1", + isInLabs = false, + ) + ) + } + ), + cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(), + clearCacheUseCase: FakeClearCacheUseCase = FakeClearCacheUseCase(), + preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), + buildMeta: BuildMeta = aBuildMeta(), + enterpriseService: EnterpriseService = FakeEnterpriseService(), + ): DeveloperSettingsPresenter { + return DeveloperSettingsPresenter( + sessionId = sessionId, + featureFlagService = featureFlagService, + computeCacheSizeUseCase = cacheSizeUseCase, + clearCacheUseCase = clearCacheUseCase, + rageshakePresenter = { aRageshakePreferencesState() }, + appPreferencesStore = preferencesStore, + buildMeta = buildMeta, + enterpriseService = enterpriseService, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt new file mode 100644 index 0000000..e812bf6 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.developer + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.isEditable +import androidx.compose.ui.test.isFocusable +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.preferences.impl.R +import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class DeveloperSettingsViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setDeveloperSettingsView( + state = aDeveloperSettingsState( + eventSink = eventsRecorder + ), + onBackClick = it + ) + rule.pressBack() + } + } + + @Config(qualifiers = "h1500dp") + @Test + fun `clicking on push history notification invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setDeveloperSettingsView( + state = aDeveloperSettingsState( + eventSink = eventsRecorder + ), + onPushHistoryClick = it + ) + rule.clickOn(R.string.troubleshoot_notifications_entry_point_push_history_title) + } + } + + @Config(qualifiers = "h1500dp") + @Test + fun `clicking on element call url open the dialogs and submit emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setDeveloperSettingsView( + state = aDeveloperSettingsState( + eventSink = eventsRecorder + ), + ) + rule.clickOn(R.string.screen_advanced_settings_element_call_base_url) + val textInputNode = rule.onAllNodes(isEditable().and(isFocusable())).filterToOne(hasAnyAncestor(isDialog())) + textInputNode.performTextInput("https://call.element.dev") + rule.clickOn(CommonStrings.action_ok) + eventsRecorder.assertSingle(DeveloperSettingsEvents.SetCustomElementCallBaseUrl("https://call.element.dev")) + } + + @Config(qualifiers = "h2000dp") + @Test + fun `clicking on open showkase invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setDeveloperSettingsView( + state = aDeveloperSettingsState( + eventSink = eventsRecorder + ), + onOpenShowkase = it + ) + rule.onNodeWithText("Open Showkase browser").performClick() + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on log level emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setDeveloperSettingsView( + state = aDeveloperSettingsState( + eventSink = eventsRecorder + ), + ) + rule.onNodeWithText("Tracing log level").performClick() + rule.onNodeWithText("Debug").performClick() + eventsRecorder.assertSingle(DeveloperSettingsEvents.SetTracingLogLevel(LogLevelItem.DEBUG)) + } + + @Config(qualifiers = "h2000dp") + @Test + fun `clicking on clear cache emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setDeveloperSettingsView( + state = aDeveloperSettingsState( + eventSink = eventsRecorder + ), + ) + rule.onNodeWithText("Clear cache").performClick() + eventsRecorder.assertSingle(DeveloperSettingsEvents.ClearCache) + } +} + +private fun AndroidComposeTestRule.setDeveloperSettingsView( + state: DeveloperSettingsState, + onOpenShowkase: () -> Unit = EnsureNeverCalled(), + onPushHistoryClick: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled() +) { + setContent { + DeveloperSettingsView( + state = state, + onOpenShowkase = onOpenShowkase, + onPushHistoryClick = onPushHistoryClick, + onBackClick = onBackClick, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/labs/LabsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/labs/LabsPresenterTest.kt new file mode 100644 index 0000000..acf65ef --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/labs/LabsPresenterTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.labs + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase +import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase +import io.element.android.libraries.featureflag.api.Feature +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeature +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LabsPresenterTest { + @Test + fun `present - ensures features are displayed in the correct order`() = runTest { + val availableFeatures = listOf( + FakeFeature( + key = "feature_1", + title = "Feature 1", + isInLabs = true, + ), + FakeFeature( + key = "feature_2", + title = "Feature 3", + isInLabs = true, + ) + ) + val getAvailableFeaturesResult = lambdaRecorder> { _, _ -> + availableFeatures + } + createLabsPresenter( + getAvailableFeaturesResult = getAvailableFeaturesResult, + ).test { + skipItems(1) + val receivedFeatures = awaitItem().features + assertThat(receivedFeatures).hasSize(2) + assertThat(receivedFeatures[0].key).isEqualTo(availableFeatures[0].key) + assertThat(receivedFeatures[1].key).isEqualTo(availableFeatures[1].key) + getAvailableFeaturesResult.assertions().isCalledOnce() + .with(value(false), value(true)) + } + } + + @Test + fun `present - ToggleFeature actually toggles the value`() = runTest { + val availableFeatures = listOf( + FakeFeature( + key = "feature_1", + title = "Feature 1", + isInLabs = true, + ), + ) + createLabsPresenter( + getAvailableFeaturesResult = { _, _ -> availableFeatures }, + ).test { + skipItems(1) + val initialItem = awaitItem() + val feature = initialItem.features.first() + assertThat(feature.isEnabled).isFalse() + // Toggle the feature, should be true now + initialItem.eventSink(LabsEvents.ToggleFeature(feature)) + assertThat(awaitItem().features.first().isEnabled).isTrue() + // Toggle the feature, should be false now + initialItem.eventSink(LabsEvents.ToggleFeature(feature)) + assertThat(awaitItem().features.first().isEnabled).isFalse() + } + } + + @Test + fun `present - ToggleFeature with the 'Threads' feature resets the cache`() = runTest { + val availableFeatures = listOf( + FakeFeature( + key = FeatureFlags.Threads.key, + title = "Threads", + isInLabs = true, + ), + ) + + val clearCacheUseCase = FakeClearCacheUseCase() + createLabsPresenter( + getAvailableFeaturesResult = { _, _ -> availableFeatures }, + clearCacheUseCase = clearCacheUseCase, + ).test { + skipItems(1) + val initialItem = awaitItem() + val feature = initialItem.features.first() + assertThat(feature.isEnabled).isFalse() + assertThat(initialItem.isApplyingChanges).isFalse() + // Toggle the feature + initialItem.eventSink(LabsEvents.ToggleFeature(feature)) + assertThat(awaitItem().features.first().isEnabled).isTrue() + // The clear cache use case should have been called + assertThat(awaitItem().isApplyingChanges).isTrue() + assertThat(clearCacheUseCase.executeHasBeenCalled).isTrue() + } + } + + private fun createLabsPresenter( + getAvailableFeaturesResult: (Boolean, Boolean) -> List = { _, _ -> emptyList() }, + clearCacheUseCase: ClearCacheUseCase = FakeClearCacheUseCase(), + ): LabsPresenter { + return LabsPresenter( + stringProvider = FakeStringProvider(), + featureFlagService = FakeFeatureFlagService(getAvailableFeaturesResult = getAvailableFeaturesResult), + clearCacheUseCase = clearCacheUseCase, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTest.kt new file mode 100644 index 0000000..153c265 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTest.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.notifications + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingPresenter +import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingStateEvents +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +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_ID_2 +import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.tests.testutils.awaitLastSequentialItem +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class EditDefaultNotificationSettingsPresenterTest { + @Test + fun `present - ensures initial state is correct`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService( + getRoomsWithUserDefinedRulesResult = { Result.success(emptyList()) }, + ) + val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.mode).isNull() + assertThat(initialState.isOneToOne).isFalse() + assertThat(initialState.roomsWithUserDefinedMode).isEmpty() + + val loadedState = consumeItemsUntilPredicate { + it.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + }.last() + assertThat(loadedState.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + + assertThat(loadedState.displayMentionsOnlyDisclaimer).isFalse() + } + } + + @Test + fun `present - ensure list of rooms with user defined mode`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService( + initialRoomMode = RoomNotificationMode.ALL_MESSAGES, + initialRoomModeIsDefault = false, + getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID)) }, + ) + val roomListService = FakeRoomListService() + val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService) + presenter.test { + roomListService.postAllRooms(listOf(aRoomSummary(userDefinedNotificationMode = RoomNotificationMode.ALL_MESSAGES))) + val loadedState = consumeItemsUntilPredicate { state -> + state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.ALL_MESSAGES } + }.last() + assertThat(loadedState.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.ALL_MESSAGES }).isTrue() + } + } + + @Test + fun `present - ensure list of rooms is sorted`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService( + initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, + initialRoomModeIsDefault = false, + getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID, A_ROOM_ID_2)) }, + ) + val roomListService = FakeRoomListService() + val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService) + presenter.test { + roomListService.postAllRooms( + listOf( + aRoomSummary( + roomId = A_ROOM_ID, + name = "Z", + userDefinedNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, + ), + aRoomSummary( + roomId = A_ROOM_ID_2, + name = "A", + userDefinedNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, + ), + ), + ) + val loadedState = consumeItemsUntilPredicate { state -> + state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY } + }.last() + assertThat(loadedState.roomsWithUserDefinedMode[0].name).isEqualTo("A") + assertThat(loadedState.roomsWithUserDefinedMode[1].name).isEqualTo("Z") + } + } + + @Test + fun `present - ensure list of rooms is sorted, with name null`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService( + initialRoomMode = RoomNotificationMode.MUTE, + initialRoomModeIsDefault = false, + getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID, A_ROOM_ID_2)) }, + ) + val roomListService = FakeRoomListService() + val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService) + presenter.test { + roomListService.postAllRooms( + listOf( + aRoomSummary( + roomId = A_ROOM_ID, + name = "Z", + userDefinedNotificationMode = RoomNotificationMode.MUTE, + ), + aRoomSummary( + roomId = A_ROOM_ID_2, + name = null, + userDefinedNotificationMode = RoomNotificationMode.MUTE, + ), + ), + ) + val loadedState = consumeItemsUntilPredicate { state -> + state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.MUTE } + }.last() + assertThat(loadedState.roomsWithUserDefinedMode[0].name).isNull() + assertThat(loadedState.roomsWithUserDefinedMode[1].name).isEqualTo("Z") + } + } + + @Test + fun `present - edit default notification setting`() = runTest { + val presenter = createEditDefaultNotificationSettingPresenter( + notificationSettingsService = FakeNotificationSettingsService( + getRoomsWithUserDefinedRulesResult = { Result.success(emptyList()) }, + ), + ) + presenter.test { + awaitItem().eventSink(EditDefaultNotificationSettingStateEvents.SetNotificationMode(RoomNotificationMode.ALL_MESSAGES)) + val loadedState = consumeItemsUntilPredicate { + it.mode == RoomNotificationMode.ALL_MESSAGES + }.last() + assertThat(loadedState.mode).isEqualTo(RoomNotificationMode.ALL_MESSAGES) + } + } + + @Test + fun `present - edit default notification setting failed`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService( + getRoomsWithUserDefinedRulesResult = { Result.success(emptyList()) }, + ) + val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService) + notificationSettingsService.givenSetDefaultNotificationModeError(AN_EXCEPTION) + presenter.test { + awaitItem().eventSink(EditDefaultNotificationSettingStateEvents.SetNotificationMode(RoomNotificationMode.ALL_MESSAGES)) + val errorState = consumeItemsUntilPredicate { + it.changeNotificationSettingAction.isFailure() + }.last() + assertThat(errorState.changeNotificationSettingAction.isFailure()).isTrue() + errorState.eventSink(EditDefaultNotificationSettingStateEvents.ClearError) + val clearErrorState = consumeItemsUntilPredicate { + it.changeNotificationSettingAction.isUninitialized() + }.last() + assertThat(clearErrorState.changeNotificationSettingAction.isUninitialized()).isTrue() + } + } + + @Test + fun `present - display mentions only warning if homeserver does not support it`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService( + getRoomsWithUserDefinedRulesResult = { Result.success(emptyList()) }, + ).apply { + givenCanHomeServerPushEncryptedEventsToDeviceResult(Result.success(false)) + } + val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService) + presenter.test { + assertThat(awaitLastSequentialItem().displayMentionsOnlyDisclaimer).isTrue() + } + } + + private fun createEditDefaultNotificationSettingPresenter( + notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), + roomListService: FakeRoomListService = FakeRoomListService(), + ): EditDefaultNotificationSettingPresenter { + return EditDefaultNotificationSettingPresenter( + notificationSettingsService = notificationSettingsService, + isOneToOne = false, + roomListService = roomListService, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/FakeSystemNotificationsEnabledProvider.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/FakeSystemNotificationsEnabledProvider.kt new file mode 100644 index 0000000..57357b5 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/FakeSystemNotificationsEnabledProvider.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.notifications + +class FakeSystemNotificationsEnabledProvider : SystemNotificationsEnabledProvider { + override fun notificationsEnabled(): Boolean { + return true + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt new file mode 100644 index 0000000..9b36c47 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt @@ -0,0 +1,372 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.notifications + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState +import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.test.FakePushService +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.libraries.pushproviders.test.FakePushProvider +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.tests.testutils.awaitLastSequentialItem +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds + +class NotificationSettingsPresenterTest { + @Test + fun `present - ensures initial state is correct`() = runTest { + val presenter = createNotificationSettingsPresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.appSettings.appNotificationsEnabled).isFalse() + assertThat(initialState.appSettings.systemNotificationsEnabled).isTrue() + assertThat(initialState.matrixSettings).isEqualTo(NotificationSettingsState.MatrixSettings.Uninitialized) + val loadedState = consumeItemsUntilPredicate { + it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid + }.last() + assertThat(loadedState.appSettings.appNotificationsEnabled).isTrue() + assertThat(loadedState.appSettings.systemNotificationsEnabled).isTrue() + val valid = loadedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid + assertThat(valid?.atRoomNotificationsEnabled).isFalse() + assertThat(valid?.callNotificationsEnabled).isFalse() + assertThat(valid?.inviteForMeNotificationsEnabled).isFalse() + assertThat(valid?.defaultGroupNotificationMode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + assertThat(valid?.defaultOneToOneNotificationMode).isEqualTo(RoomNotificationMode.ALL_MESSAGES) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - default group notification mode changed`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService() + val presenter = createNotificationSettingsPresenter(notificationSettingsService) + presenter.test { + notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = true, isOneToOne = false, mode = RoomNotificationMode.ALL_MESSAGES) + notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, isOneToOne = false, mode = RoomNotificationMode.ALL_MESSAGES) + val updatedState = consumeItemsUntilPredicate { + (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid) + ?.defaultGroupNotificationMode == RoomNotificationMode.ALL_MESSAGES + }.last() + val valid = updatedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid + assertThat(valid?.defaultGroupNotificationMode).isEqualTo(RoomNotificationMode.ALL_MESSAGES) + } + } + + @Test + fun `present - notification settings mismatched`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService() + val presenter = createNotificationSettingsPresenter(notificationSettingsService) + presenter.test { + notificationSettingsService.setDefaultRoomNotificationMode( + isEncrypted = true, + isOneToOne = false, + mode = RoomNotificationMode.ALL_MESSAGES + ) + notificationSettingsService.setDefaultRoomNotificationMode( + isEncrypted = false, + isOneToOne = false, + mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + ) + val updatedState = consumeItemsUntilPredicate { + it.matrixSettings is NotificationSettingsState.MatrixSettings.Invalid + }.last() + assertThat(updatedState.matrixSettings).isEqualTo(NotificationSettingsState.MatrixSettings.Invalid(fixFailed = false)) + } + } + + @Test + fun `present - fix notification settings mismatched`() = runTest { + // Start with a mismatched configuration + val notificationSettingsService = FakeNotificationSettingsService( + initialEncryptedGroupDefaultMode = RoomNotificationMode.ALL_MESSAGES, + initialGroupDefaultMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, + initialEncryptedOneToOneDefaultMode = RoomNotificationMode.ALL_MESSAGES, + initialOneToOneDefaultMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + ) + val presenter = createNotificationSettingsPresenter(notificationSettingsService) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(NotificationSettingsEvents.FixConfigurationMismatch) + val fixedState = consumeItemsUntilPredicate(timeout = 2000.milliseconds) { + it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid + }.last() + val fixedMatrixState = fixedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid + assertThat(fixedMatrixState?.defaultGroupNotificationMode).isEqualTo(RoomNotificationMode.ALL_MESSAGES) + } + } + + @Test + fun `present - set notifications enabled`() = runTest { + val unregisterWithResult = lambdaRecorder> { Result.success(Unit) } + val ensurePusherIsRegisteredResult = lambdaRecorder> { Result.success(Unit) } + val presenter = createNotificationSettingsPresenter( + pushService = FakePushService( + currentPushProvider = { + FakePushProvider( + unregisterWithResult = unregisterWithResult, + ) + }, + ensurePusherIsRegisteredResult = ensurePusherIsRegisteredResult, + ) + ) + presenter.test { + val loadedState = consumeItemsUntilPredicate { + it.matrixSettings is NotificationSettingsState.MatrixSettings.Valid + }.last() + assertThat(loadedState.appSettings.appNotificationsEnabled).isTrue() + loadedState.eventSink(NotificationSettingsEvents.SetNotificationsEnabled(false)) + val updatedState = consumeItemsUntilPredicate { + !it.appSettings.appNotificationsEnabled + }.last() + assertThat(updatedState.appSettings.appNotificationsEnabled).isFalse() + unregisterWithResult.assertions().isCalledOnce() + // Enable notification again + loadedState.eventSink(NotificationSettingsEvents.SetNotificationsEnabled(true)) + val updatedState2 = consumeItemsUntilPredicate { + it.appSettings.appNotificationsEnabled + }.last() + assertThat(updatedState2.appSettings.appNotificationsEnabled).isTrue() + ensurePusherIsRegisteredResult.assertions().isCalledOnce() + } + } + + @Test + fun `present - set call notifications enabled`() = runTest { + val presenter = createNotificationSettingsPresenter() + presenter.test { + val loadedState = consumeItemsUntilPredicate { + (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.callNotificationsEnabled == false + }.last() + val validMatrixState = loadedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid + assertThat(validMatrixState?.callNotificationsEnabled).isFalse() + loadedState.eventSink(NotificationSettingsEvents.SetCallNotificationsEnabled(true)) + val updatedState = consumeItemsUntilPredicate { + (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.callNotificationsEnabled == true + }.last() + val updatedMatrixState = updatedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid + assertThat(updatedMatrixState?.callNotificationsEnabled).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - set invite for me notifications enabled`() = runTest { + val presenter = createNotificationSettingsPresenter() + presenter.test { + val loadedState = consumeItemsUntilPredicate { + (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.inviteForMeNotificationsEnabled == false + }.last() + val validMatrixState = loadedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid + assertThat(validMatrixState?.inviteForMeNotificationsEnabled).isFalse() + loadedState.eventSink(NotificationSettingsEvents.SetInviteForMeNotificationsEnabled(true)) + val updatedState = consumeItemsUntilPredicate { + (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.inviteForMeNotificationsEnabled == true + }.last() + val updatedMatrixState = updatedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid + assertThat(updatedMatrixState?.inviteForMeNotificationsEnabled).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - set atRoom notifications enabled`() = runTest { + val presenter = createNotificationSettingsPresenter() + presenter.test { + val loadedState = consumeItemsUntilPredicate { + (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.atRoomNotificationsEnabled == false + }.last() + val validMatrixState = loadedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid + assertThat(validMatrixState?.atRoomNotificationsEnabled).isFalse() + loadedState.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(true)) + val updatedState = consumeItemsUntilPredicate { + (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.atRoomNotificationsEnabled == true + }.last() + val updatedMatrixState = updatedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid + assertThat(updatedMatrixState?.atRoomNotificationsEnabled).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - clear notification settings change error`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService() + val presenter = createNotificationSettingsPresenter(notificationSettingsService) + notificationSettingsService.givenSetAtRoomError(AN_EXCEPTION) + presenter.test { + val loadedState = consumeItemsUntilPredicate { + (it.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid)?.atRoomNotificationsEnabled == false + }.last() + val validMatrixState = loadedState.matrixSettings as? NotificationSettingsState.MatrixSettings.Valid + assertThat(validMatrixState?.atRoomNotificationsEnabled).isFalse() + loadedState.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(true)) + val errorState = consumeItemsUntilPredicate { + it.changeNotificationSettingAction.isFailure() + }.last() + assertThat(errorState.changeNotificationSettingAction.isFailure()).isTrue() + errorState.eventSink(NotificationSettingsEvents.ClearNotificationChangeError) + val clearErrorState = consumeItemsUntilPredicate { + it.changeNotificationSettingAction.isUninitialized() + }.last() + assertThat(clearErrorState.changeNotificationSettingAction.isUninitialized()).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - change push provider`() = runTest { + val presenter = createNotificationSettingsPresenter( + pushService = createFakePushService(), + ) + presenter.test { + val initialState = awaitLastSequentialItem() + assertThat(initialState.currentPushDistributor).isEqualTo(AsyncData.Success(Distributor(value = "aDistributorValue0", name = "aDistributorName0"))) + assertThat(initialState.availablePushDistributors).containsExactly( + Distributor(value = "aDistributorValue0", name = "aDistributorName0"), + Distributor(value = "aDistributorValue1", name = "aDistributorName1"), + ) + initialState.eventSink.invoke(NotificationSettingsEvents.ChangePushProvider) + val withDialog = awaitItem() + assertThat(withDialog.showChangePushProviderDialog).isTrue() + // Cancel + withDialog.eventSink(NotificationSettingsEvents.CancelChangePushProvider) + val withoutDialog = awaitItem() + assertThat(withoutDialog.showChangePushProviderDialog).isFalse() + withDialog.eventSink.invoke(NotificationSettingsEvents.ChangePushProvider) + assertThat(awaitItem().showChangePushProviderDialog).isTrue() + withDialog.eventSink(NotificationSettingsEvents.SetPushProvider(1)) + val withNewProvider = awaitItem() + assertThat(withNewProvider.showChangePushProviderDialog).isFalse() + assertThat(withNewProvider.currentPushDistributor).isInstanceOf(AsyncData.Loading::class.java) + skipItems(1) + val lastItem = awaitItem() + assertThat(lastItem.currentPushDistributor).isEqualTo(AsyncData.Success(Distributor(value = "aDistributorValue1", name = "aDistributorName1"))) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - change push provider to the same value is no op`() = runTest { + val presenter = createNotificationSettingsPresenter( + pushService = createFakePushService(), + ) + presenter.test { + val initialState = awaitLastSequentialItem() + assertThat(initialState.currentPushDistributor).isEqualTo(AsyncData.Success(Distributor(value = "aDistributorValue0", name = "aDistributorName0"))) + assertThat(initialState.availablePushDistributors).containsExactly( + Distributor(value = "aDistributorValue0", name = "aDistributorName0"), + Distributor(value = "aDistributorValue1", name = "aDistributorName1"), + ) + initialState.eventSink.invoke(NotificationSettingsEvents.ChangePushProvider) + assertThat(awaitItem().showChangePushProviderDialog).isTrue() + // Choose the same value (index 0) + initialState.eventSink(NotificationSettingsEvents.SetPushProvider(0)) + val withNewProvider = awaitItem() + assertThat(withNewProvider.showChangePushProviderDialog).isFalse() + expectNoEvents() + } + } + + @Test + fun `present - RefreshSystemNotificationsEnabled also refreshes fullScreenIntentState`() = runTest { + var lambdaResult = aFullScreenIntentPermissionsState(permissionGranted = false) + val fullScreenIntentPermissionsStateLambda = { lambdaResult } + val presenter = createNotificationSettingsPresenter( + pushService = createFakePushService(), + fullScreenIntentPermissionsStateLambda = fullScreenIntentPermissionsStateLambda, + ) + presenter.test { + val initialState = awaitLastSequentialItem() + assertThat(initialState.fullScreenIntentPermissionsState.permissionGranted).isFalse() + + // Change the notification settings + lambdaResult = lambdaResult.copy(permissionGranted = true) + // Check it's not changed unless we refresh + expectNoEvents() + + // Refresh + initialState.eventSink.invoke(NotificationSettingsEvents.RefreshSystemNotificationsEnabled) + assertThat(awaitItem().fullScreenIntentPermissionsState.permissionGranted).isTrue() + } + } + + @Test + fun `present - change push provider error`() = runTest { + val presenter = createNotificationSettingsPresenter( + pushService = createFakePushService( + registerWithLambda = { _, _, _ -> + Result.failure(Exception("An error")) + }, + ), + ) + presenter.test { + val initialState = awaitLastSequentialItem() + initialState.eventSink.invoke(NotificationSettingsEvents.ChangePushProvider) + val withDialog = awaitItem() + assertThat(withDialog.showChangePushProviderDialog).isTrue() + withDialog.eventSink(NotificationSettingsEvents.SetPushProvider(1)) + val withNewProvider = awaitItem() + assertThat(withNewProvider.showChangePushProviderDialog).isFalse() + assertThat(withNewProvider.currentPushDistributor).isInstanceOf(AsyncData.Loading::class.java) + val lastItem = awaitItem() + assertThat(lastItem.currentPushDistributor).isInstanceOf(AsyncData.Failure::class.java) + } + } + + private fun createFakePushService( + registerWithLambda: (MatrixClient, PushProvider, Distributor) -> Result = { _, _, _ -> + Result.success(Unit) + } + ): PushService { + val pushProvider1 = FakePushProvider( + index = 0, + name = "aFakePushProvider0", + distributors = listOf(Distributor("aDistributorValue0", "aDistributorName0")), + ) + val pushProvider2 = FakePushProvider( + index = 1, + name = "aFakePushProvider1", + distributors = listOf(Distributor("aDistributorValue1", "aDistributorName1")), + ) + return FakePushService( + availablePushProviders = listOf(pushProvider1, pushProvider2), + registerWithLambda = registerWithLambda, + ) + } + + private fun TestScope.createNotificationSettingsPresenter( + notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), + pushService: PushService = FakePushService(), + fullScreenIntentPermissionsStateLambda: () -> FullScreenIntentPermissionsState = { aFullScreenIntentPermissionsState() }, + ): NotificationSettingsPresenter { + val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService) + return NotificationSettingsPresenter( + notificationSettingsService = notificationSettingsService, + userPushStoreFactory = FakeUserPushStoreFactory(), + matrixClient = matrixClient, + pushService = pushService, + systemNotificationsEnabledProvider = FakeSystemNotificationsEnabledProvider(), + fullScreenIntentPermissionsPresenter = { fullScreenIntentPermissionsStateLambda() }, + sessionCoroutineScope = backgroundScope, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt new file mode 100644 index 0000000..ea140ab --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt @@ -0,0 +1,298 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.notifications + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class NotificationSettingsViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invokes the expected callback`() { + val eventsRecorder = EventsRecorder() + ensureCalledOnce { + rule.setNotificationSettingsView( + state = aValidNotificationSettingsState( + eventSink = eventsRecorder + ), + onBackClick = it + ) + rule.pressBack() + } + eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on troubleshoot notification invokes the expected callback`() { + val eventsRecorder = EventsRecorder() + ensureCalledOnce { + rule.setNotificationSettingsView( + state = aValidNotificationSettingsState( + eventSink = eventsRecorder + ), + onTroubleshootNotificationsClick = it + ) + rule.clickOn(R.string.troubleshoot_notifications_entry_point_title) + } + eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on group chats invokes the expected callback`() { + val eventsRecorder = EventsRecorder() + ensureCalledOnceWithParam(false) { + rule.setNotificationSettingsView( + state = aValidNotificationSettingsState( + eventSink = eventsRecorder + ), + onOpenEditDefault = it + ) + rule.clickOn(R.string.screen_notification_settings_group_chats) + } + eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on direct chats invokes the expected callback`() { + val eventsRecorder = EventsRecorder() + ensureCalledOnceWithParam(true) { + rule.setNotificationSettingsView( + state = aValidNotificationSettingsState( + eventSink = eventsRecorder + ), + onOpenEditDefault = it + ) + rule.clickOn(R.string.screen_notification_settings_direct_chats) + } + eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on disable notifications emits the expected events`() { + testNotificationToggle(true) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on enable notifications emits the expected events`() { + testNotificationToggle(false) + } + + private fun testNotificationToggle(initialState: Boolean) { + val eventsRecorder = EventsRecorder() + rule.setNotificationSettingsView( + state = aValidNotificationSettingsState( + appNotificationEnabled = initialState, + eventSink = eventsRecorder + ), + ) + rule.clickOn(R.string.screen_notification_settings_enable_notifications) + eventsRecorder.assertList( + listOf( + NotificationSettingsEvents.RefreshSystemNotificationsEnabled, + NotificationSettingsEvents.SetNotificationsEnabled(!initialState) + ) + ) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on disable notify me on at room emits the expected events`() { + testAtRoomToggle(true) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on enable notify me on at room emits the expected events`() { + testAtRoomToggle(false) + } + + private fun testAtRoomToggle(initialState: Boolean) { + val eventsRecorder = EventsRecorder() + rule.setNotificationSettingsView( + state = aValidNotificationSettingsState( + atRoomNotificationsEnabled = initialState, + eventSink = eventsRecorder + ), + ) + rule.clickOn(R.string.screen_notification_settings_room_mention_label) + eventsRecorder.assertList( + listOf( + NotificationSettingsEvents.RefreshSystemNotificationsEnabled, + NotificationSettingsEvents.SetAtRoomNotificationsEnabled(!initialState) + ) + ) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on disable notify me on invitation emits the expected events`() { + testInvitationToggle(true) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on enable notify me on invitation emits the expected events`() { + testInvitationToggle(false) + } + + private fun testInvitationToggle(initialState: Boolean) { + val eventsRecorder = EventsRecorder() + rule.setNotificationSettingsView( + state = aValidNotificationSettingsState( + inviteForMeNotificationsEnabled = initialState, + eventSink = eventsRecorder + ), + ) + rule.clickOn(R.string.screen_notification_settings_invite_for_me_label) + eventsRecorder.assertList( + listOf( + NotificationSettingsEvents.RefreshSystemNotificationsEnabled, + NotificationSettingsEvents.SetInviteForMeNotificationsEnabled(!initialState) + ) + ) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `with an error configuration, clicking on continue emits the expected events`() { + val eventsRecorder = EventsRecorder() + rule.setNotificationSettingsView( + state = aValidNotificationSettingsState( + changeNotificationSettingAction = AsyncAction.Failure(AN_EXCEPTION), + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.action_ok) + eventsRecorder.assertList( + listOf( + NotificationSettingsEvents.RefreshSystemNotificationsEnabled, + NotificationSettingsEvents.ClearNotificationChangeError + ) + ) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `with invalid configuration, clicking on continue emits the expected events`() { + val eventsRecorder = EventsRecorder() + rule.setNotificationSettingsView( + state = aInvalidNotificationSettingsState( + fixFailed = false, + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.action_continue) + eventsRecorder.assertList( + listOf( + NotificationSettingsEvents.RefreshSystemNotificationsEnabled, + NotificationSettingsEvents.FixConfigurationMismatch + ) + ) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `with invalid configuration and error, clicking on OK emits the expected events`() { + val eventsRecorder = EventsRecorder() + rule.setNotificationSettingsView( + state = aInvalidNotificationSettingsState( + fixFailed = true, + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.action_ok) + eventsRecorder.assertList( + listOf( + NotificationSettingsEvents.RefreshSystemNotificationsEnabled, + NotificationSettingsEvents.ClearConfigurationMismatchError + ) + ) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on Push notification provider emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setNotificationSettingsView( + state = aValidNotificationSettingsState( + eventSink = eventsRecorder + ), + ) + rule.clickOn(R.string.screen_advanced_settings_push_provider_android) + eventsRecorder.assertList( + listOf( + NotificationSettingsEvents.RefreshSystemNotificationsEnabled, + NotificationSettingsEvents.ChangePushProvider, + ) + ) + } + + @Test + fun `clicking on a push provider emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setNotificationSettingsView( + state = aValidNotificationSettingsState( + eventSink = eventsRecorder, + showChangePushProviderDialog = true, + availablePushDistributors = listOf(aDistributor("P1"), aDistributor("P2")) + ), + ) + rule.onNodeWithText("P2").performClick() + eventsRecorder.assertList( + listOf( + NotificationSettingsEvents.RefreshSystemNotificationsEnabled, + NotificationSettingsEvents.SetPushProvider(1), + ) + ) + } +} + +private fun AndroidComposeTestRule.setNotificationSettingsView( + state: NotificationSettingsState, + onOpenEditDefault: (isOneToOne: Boolean) -> Unit = EnsureNeverCalledWithParam(), + onTroubleshootNotificationsClick: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + NotificationSettingsView( + state = state, + onOpenEditDefault = onOpenEditDefault, + onTroubleshootNotificationsClick = onTroubleshootNotificationsClick, + onBackClick = onBackClick, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/FakeVersionFormatter.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/FakeVersionFormatter.kt new file mode 100644 index 0000000..95691da --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/FakeVersionFormatter.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.root + +class FakeVersionFormatter : VersionFormatter { + override fun get(): String { + return "A Version" + } +} 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 0000000..1fa141b --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.preferences.impl.root + +import app.cash.turbine.ReceiveTurbine +import com.google.common.truth.Truth.assertThat +import io.element.android.features.logout.api.direct.aDirectLogoutState +import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider +import io.element.android.features.rageshake.api.RageshakeFeatureAvailability +import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeature +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.indicator.api.IndicatorService +import io.element.android.libraries.indicator.test.FakeIndicatorService +import io.element.android.libraries.matrix.api.oidc.AccountManagementAction +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_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 +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.core.aBuildMeta +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class PreferencesRootPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val accountManagementUrlResult = lambdaRecorder> { action -> + Result.success("$action url") + } + val matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { true }, + accountManagementUrlResult = accountManagementUrlResult, + ) + createPresenter( + matrixClient = matrixClient, + ).test { + val initialState = awaitItem() + assertThat(initialState.myUser).isEqualTo( + MatrixUser( + userId = matrixClient.sessionId, + displayName = A_USER_NAME, + avatarUrl = AN_AVATAR_URL + ) + ) + assertThat(initialState.version).isEqualTo("A Version") + assertThat(initialState.isMultiAccountEnabled).isFalse() + assertThat(initialState.otherSessions).isEmpty() + val loadedState = awaitItem() + assertThat(loadedState.myUser).isEqualTo( + MatrixUser( + userId = matrixClient.sessionId, + displayName = A_USER_NAME, + avatarUrl = AN_AVATAR_URL + ) + ) + assertThat(initialState.version).isEqualTo("A Version") + assertThat(loadedState.showSecureBackup).isFalse() + assertThat(loadedState.showSecureBackupBadge).isFalse() + assertThat(loadedState.accountManagementUrl).isNull() + assertThat(loadedState.devicesManagementUrl).isNull() + assertThat(loadedState.showAnalyticsSettings).isFalse() + assertThat(loadedState.showDeveloperSettings).isTrue() + assertThat(loadedState.canDeactivateAccount).isTrue() + assertThat(loadedState.canReportBug).isTrue() + assertThat(loadedState.directLogoutState).isEqualTo(aDirectLogoutState()) + assertThat(loadedState.snackbarMessage).isNull() + skipItems(1) + val finalState = awaitItem() + accountManagementUrlResult.assertions().isCalledExactly(2) + .withSequence( + listOf(value(AccountManagementAction.Profile)), + listOf(value(AccountManagementAction.SessionsList)), + ) + assertThat(finalState.accountManagementUrl).isEqualTo("Profile url") + assertThat(finalState.devicesManagementUrl).isEqualTo("SessionsList url") + } + } + + @Test + fun `present - cannot report bug`() = runTest { + val matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { true }, + accountManagementUrlResult = { Result.success("") }, + ) + createPresenter( + matrixClient = matrixClient, + rageshakeFeatureAvailability = { flowOf(false) }, + ).test { + val initialState = awaitItem() + assertThat(initialState.canReportBug).isFalse() + skipItems(1) + } + } + + @Test + fun `present - secure backup badge`() = runTest { + val matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { true }, + accountManagementUrlResult = { Result.success("") }, + ) + val indicatorService = FakeIndicatorService() + createPresenter( + matrixClient = matrixClient, + rageshakeFeatureAvailability = { flowOf(false) }, + indicatorService = indicatorService, + ).test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.showSecureBackupBadge).isFalse() + indicatorService.setShowSettingChatBackupIndicator(true) + val finalState = awaitItem() + assertThat(finalState.showSecureBackupBadge).isTrue() + } + } + + @Test + fun `present - can deactivate account is false if the Matrix client say so`() = runTest { + createPresenter( + matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { false }, + accountManagementUrlResult = { Result.success(null) }, + ), + ).test { + val loadedState = awaitFirstItem() + assertThat(loadedState.canDeactivateAccount).isFalse() + } + } + + @Test + fun `present - developer settings is hidden by default in release builds`() = runTest { + createPresenter( + matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { true }, + accountManagementUrlResult = { Result.success(null) }, + ), + showDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.RELEASE)) + ).test { + val loadedState = awaitFirstItem() + assertThat(loadedState.showDeveloperSettings).isFalse() + } + } + + @Test + fun `present - developer settings can be enabled in release builds`() = runTest { + createPresenter( + matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { true }, + accountManagementUrlResult = { Result.success(null) }, + ), + showDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.RELEASE)) + ).test { + val loadedState = awaitFirstItem() + repeat(times = ShowDeveloperSettingsProvider.DEVELOPER_SETTINGS_COUNTER) { + assertThat(loadedState.showDeveloperSettings).isFalse() + loadedState.eventSink(PreferencesRootEvents.OnVersionInfoClick) + } + assertThat(awaitItem().showDeveloperSettings).isTrue() + } + } + + @Test + fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest { + createPresenter( + featureFlagService = FakeFeatureFlagService( + getAvailableFeaturesResult = { _, _ -> + listOf( + FakeFeature( + key = "feature_1", + title = "Feature 1", + isInLabs = true, + isFinished = false, + ) + ) + } + ), + matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { true }, + accountManagementUrlResult = { Result.success(null) }, + ), + ).test { + assertThat(awaitItem().showLabsItem).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - labs can't be shown if all feature flags in labs are finished`() = runTest { + createPresenter( + featureFlagService = FakeFeatureFlagService( + getAvailableFeaturesResult = { _, _ -> + emptyList() + } + ), + matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { true }, + accountManagementUrlResult = { Result.success(null) }, + ), + ).test { + skipItems(1) + assertThat(awaitItem().showLabsItem).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - multiple accounts`() = runTest { + createPresenter( + matrixClient = FakeMatrixClient( + sessionId = A_SESSION_ID, + canDeactivateAccountResult = { true }, + ), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MultiAccount.key to true) + ), + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_SESSION_ID.value), + aSessionData( + sessionId = A_SESSION_ID_2.value, + userDisplayName = "Bob", + userAvatarUrl = "avatarUrl", + ), + ) + ) + ).test { + val state = awaitFirstItem() + assertThat(state.isMultiAccountEnabled).isTrue() + assertThat(state.otherSessions).hasSize(1) + assertThat(state.otherSessions[0]).isEqualTo(MatrixUser(userId = A_SESSION_ID_2, displayName = "Bob", avatarUrl = "avatarUrl")) + } + } + + private suspend fun ReceiveTurbine.awaitFirstItem(): T { + skipItems(1) + return awaitItem() + } + + private fun createPresenter( + matrixClient: FakeMatrixClient = FakeMatrixClient(), + sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), + showDeveloperSettingsProvider: ShowDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.DEBUG)), + rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(true) }, + indicatorService: IndicatorService = FakeIndicatorService(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), + sessionStore: SessionStore = InMemorySessionStore(), + ) = PreferencesRootPresenter( + matrixClient = matrixClient, + sessionVerificationService = sessionVerificationService, + analyticsService = FakeAnalyticsService(), + versionFormatter = FakeVersionFormatter(), + snackbarDispatcher = SnackbarDispatcher(), + indicatorService = indicatorService, + directLogoutPresenter = { aDirectLogoutState() }, + showDeveloperSettingsProvider = showDeveloperSettingsProvider, + rageshakeFeatureAvailability = rageshakeFeatureAvailability, + featureFlagService = featureFlagService, + sessionStore = sessionStore, + ) +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/VersionFormatterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/VersionFormatterTest.kt new file mode 100644 index 0000000..c00dc76 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/VersionFormatterTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.root + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class VersionFormatterTest { + @Test + fun `version formatter should return simplified version for main branch`() = runTest { + val sut = DefaultVersionFormatter( + stringProvider = FakeStringProvider(defaultResult = VERSION), + buildMeta = aBuildMeta( + gitBranchName = "main", + versionName = "versionName", + versionCode = 123 + ) + ) + assertThat(sut.get()).isEqualTo("${VERSION}versionName, 123") + } + + @Test + fun `version formatter should return simplified version for other branch`() = runTest { + val sut = DefaultVersionFormatter( + stringProvider = FakeStringProvider(defaultResult = VERSION), + buildMeta = aBuildMeta( + versionName = "versionName", + versionCode = 123, + gitBranchName = "branch", + gitRevision = "1234567890", + ) + ) + assertThat(sut.get()).isEqualTo("${VERSION}versionName, 123\nbranch (1234567890)") + } + + companion object { + const val VERSION = "version" + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt new file mode 100644 index 0000000..6845ecb --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/DefaultClearCacheUseCaseTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.tasks + +import androidx.test.platform.app.InstrumentationRegistry +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.invite.test.InMemorySeenInvitesStore +import io.element.android.features.preferences.impl.DefaultCacheService +import io.element.android.libraries.matrix.api.core.SessionId +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.FakeJoinedRoom +import io.element.android.libraries.push.test.FakePushService +import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultClearCacheUseCaseTest { + @Test + fun `execute clear cache should do all the expected tasks`() = runTest { + val activeRoomsHolder = DefaultActiveRoomsHolder().apply { addRoom(FakeJoinedRoom()) } + val clearCacheLambda = lambdaRecorder { } + val matrixClient = FakeMatrixClient( + sessionId = A_SESSION_ID, + clearCacheLambda = clearCacheLambda, + ) + val defaultCacheService = DefaultCacheService() + val setIgnoreRegistrationErrorLambda = lambdaRecorder { _, _ -> } + val resetBatteryOptimizationStateResult = lambdaRecorder { } + val pushService = FakePushService( + setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda, + resetBatteryOptimizationStateResult = resetBatteryOptimizationStateResult, + ) + val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID)) + assertThat(seenInvitesStore.seenRoomIds().first()).isNotEmpty() + val sut = DefaultClearCacheUseCase( + context = InstrumentationRegistry.getInstrumentation().context, + matrixClient = matrixClient, + coroutineDispatchers = testCoroutineDispatchers(), + defaultCacheService = defaultCacheService, + okHttpClient = { OkHttpClient.Builder().build() }, + pushService = pushService, + seenInvitesStore = seenInvitesStore, + activeRoomsHolder = activeRoomsHolder, + ) + defaultCacheService.clearedCacheEventFlow.test { + sut.invoke() + clearCacheLambda.assertions().isCalledOnce() + setIgnoreRegistrationErrorLambda.assertions().isCalledOnce() + .with(value(matrixClient.sessionId), value(false)) + resetBatteryOptimizationStateResult.assertions().isCalledOnce() + assertThat(awaitItem()).isEqualTo(matrixClient.sessionId) + assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty() + assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNull() + } + } +} 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 0000000..94dec84 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeClearCacheUseCase.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..455c181 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeComputeCacheSizeUseCase.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt new file mode 100644 index 0000000..3432bac --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt @@ -0,0 +1,524 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.user.editprofile + +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.file.TemporaryUriDeleter +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.MatrixClient +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.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.FakeMediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.consumeItemsUntilTimeout +import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +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.Rule +import org.junit.Test +import java.io.File + +@ExperimentalCoroutinesApi +class EditUserProfilePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private lateinit var fakePickerProvider: FakePickerProvider + private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor + + private val userAvatarUri: 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 userAvatarUri + every { userAvatarUri.toString() } returns AN_AVATAR_URL + every { Uri.parse(ANOTHER_AVATAR_URL) } returns anotherAvatarUri + every { anotherAvatarUri.toString() } returns ANOTHER_AVATAR_URL + } + + @After + fun tearDown() { + unmockkAll() + } + + private fun createEditUserProfilePresenter( + matrixClient: MatrixClient = FakeMatrixClient(), + navigator: EditUserProfileNavigator = FakeEditUserProfileNavigator(), + matrixUser: MatrixUser = aMatrixUser(), + permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), + temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(), + mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), + ): EditUserProfilePresenter { + return EditUserProfilePresenter( + matrixClient = matrixClient, + navigator = navigator, + matrixUser = matrixUser, + mediaPickerProvider = fakePickerProvider, + mediaPreProcessor = fakeMediaPreProcessor, + temporaryUriDeleter = temporaryUriDeleter, + permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter), + mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, + ) + } + + @Test + fun `present - initial state is created from user info`() = runTest { + val user = aMatrixUser(avatarUrl = AN_AVATAR_URL) + val presenter = createEditUserProfilePresenter(matrixUser = user) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.userId).isEqualTo(user.userId) + assertThat(initialState.displayName).isEqualTo(user.displayName) + assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL) + assertThat(initialState.avatarActions).containsExactly( + AvatarAction.ChoosePhoto, + AvatarAction.TakePhoto, + AvatarAction.Remove + ) + assertThat(initialState.saveButtonEnabled).isFalse() + assertThat(initialState.saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + } + } + + @Test + fun `present - exit invokes the expected callback`() = runTest { + val user = aMatrixUser(avatarUrl = AN_AVATAR_URL) + val closeLambda = lambdaRecorder {} + val presenter = createEditUserProfilePresenter( + matrixUser = user, + navigator = FakeEditUserProfileNavigator(closeLambda), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(EditUserProfileEvents.Exit) + closeLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - exit without unsaved changes`() = runTest { + val user = aMatrixUser(avatarUrl = AN_AVATAR_URL) + val closeLambda = lambdaRecorder {} + val presenter = createEditUserProfilePresenter( + matrixUser = user, + navigator = FakeEditUserProfileNavigator(closeLambda), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("New name")) + val withUpdatedName = awaitItem() + withUpdatedName.eventSink(EditUserProfileEvents.Exit) + val withConfirmation = awaitItem() + assertThat(withConfirmation.saveAction).isEqualTo(AsyncAction.ConfirmingCancellation) + // Cancel + withConfirmation.eventSink(EditUserProfileEvents.CloseDialog) + val afterCancel = awaitItem() + assertThat(afterCancel.saveAction).isEqualTo(AsyncAction.Uninitialized) + // Try again and confirm + afterCancel.eventSink(EditUserProfileEvents.Exit) + val withConfirmation2 = awaitItem() + assertThat(withConfirmation2.saveAction).isEqualTo(AsyncAction.ConfirmingCancellation) + closeLambda.assertions().isNeverCalled() + withConfirmation2.eventSink(EditUserProfileEvents.Exit) + // Dialog is closed + val finalState = awaitItem() + assertThat(finalState.saveAction).isEqualTo(AsyncAction.Uninitialized) + closeLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - updates state in response to changes`() = runTest { + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + val presenter = createEditUserProfilePresenter( + matrixUser = user, + temporaryUriDeleter = FakeTemporaryUriDeleter( + deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) } + ), + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.displayName).isEqualTo("Name") + assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL) + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II")) + awaitItem().apply { + assertThat(displayName).isEqualTo("Name II") + assertThat(userAvatarUrl).isEqualTo(AN_AVATAR_URL) + } + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name III")) + awaitItem().apply { + assertThat(displayName).isEqualTo("Name III") + assertThat(userAvatarUrl).isEqualTo(AN_AVATAR_URL) + } + initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) + awaitItem().apply { + assertThat(displayName).isEqualTo("Name III") + assertThat(userAvatarUrl).isNull() + } + } + } + + @Test + fun `present - obtains avatar uris from gallery`() = runTest { + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + fakePickerProvider.givenResult(anotherAvatarUri) + val presenter = createEditUserProfilePresenter( + matrixUser = user, + temporaryUriDeleter = FakeTemporaryUriDeleter( + deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) } + ), + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL) + initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + awaitItem().apply { + assertThat(userAvatarUrl).isEqualTo(ANOTHER_AVATAR_URL) + } + } + } + + @Test + fun `present - obtains avatar uris from camera`() = runTest { + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + fakePickerProvider.givenResult(anotherAvatarUri) + val fakePermissionsPresenter = FakePermissionsPresenter() + val deleteCallback = lambdaRecorder {} + val presenter = createEditUserProfilePresenter( + matrixUser = user, + permissionsPresenter = fakePermissionsPresenter, + temporaryUriDeleter = FakeTemporaryUriDeleter( + deleteLambda = deleteCallback, + ), + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL) + assertThat(initialState.cameraPermissionState.permissionGranted).isFalse() + initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto)) + val stateWithAskingPermission = awaitItem() + assertThat(stateWithAskingPermission.cameraPermissionState.showDialog).isTrue() + fakePermissionsPresenter.setPermissionGranted() + val stateWithPermission = awaitItem() + assertThat(stateWithPermission.cameraPermissionState.permissionGranted).isTrue() + val stateWithNewAvatar = awaitItem() + assertThat(stateWithNewAvatar.userAvatarUrl).isEqualTo(ANOTHER_AVATAR_URL) + // Do it again, no permission is requested + fakePickerProvider.givenResult(userAvatarUri) + stateWithNewAvatar.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto)) + val stateWithNewAvatar2 = awaitItem() + assertThat(stateWithNewAvatar2.userAvatarUrl).isEqualTo(AN_AVATAR_URL) + deleteCallback.assertions().isCalledExactly(2).withSequence( + listOf(value(userAvatarUri)), + listOf(value(anotherAvatarUri)), + ) + } + } + + @Test + fun `present - updates save button state`() = runTest { + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + fakePickerProvider.givenResult(userAvatarUri) + val deleteCallback = lambdaRecorder {} + val presenter = createEditUserProfilePresenter( + matrixUser = user, + temporaryUriDeleter = FakeTemporaryUriDeleter( + deleteLambda = deleteCallback + ), + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.saveButtonEnabled).isFalse() + // Once a change is made, the save button is enabled + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II")) + awaitItem().apply { + assertThat(saveButtonEnabled).isTrue() + } + // If it's reverted then the save disables again + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name")) + awaitItem().apply { + assertThat(saveButtonEnabled).isFalse() + } + // Make a change... + initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) + awaitItem().apply { + assertThat(saveButtonEnabled).isTrue() + } + // Revert it... + initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + awaitItem().apply { + assertThat(saveButtonEnabled).isFalse() + } + deleteCallback.assertions().isCalledExactly(2).withSequence( + listOf(value(userAvatarUri)), + listOf(value(null)), + ) + } + } + + @Test + fun `present - updates save button state when initial values are null`() = runTest { + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = null) + fakePickerProvider.givenResult(userAvatarUri) + val deleteCallback = lambdaRecorder {} + val presenter = createEditUserProfilePresenter( + matrixUser = user, + temporaryUriDeleter = FakeTemporaryUriDeleter( + deleteLambda = deleteCallback + ), + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.saveButtonEnabled).isFalse() + // Once a change is made, the save button is enabled + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II")) + awaitItem().apply { + assertThat(saveButtonEnabled).isTrue() + } + // If it's reverted then the save disables again + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name")) + awaitItem().apply { + assertThat(saveButtonEnabled).isFalse() + } + // Make a change... + initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + awaitItem().apply { + assertThat(saveButtonEnabled).isTrue() + } + // Revert it... + initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) + awaitItem().apply { + assertThat(saveButtonEnabled).isFalse() + } + deleteCallback.assertions().isCalledExactly(2).withSequence( + listOf(value(null)), + listOf(value(userAvatarUri)), + ) + } + } + + @Test + fun `present - save changes room details if different`() = runTest { + val matrixClient = FakeMatrixClient() + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + val presenter = createEditUserProfilePresenter( + matrixClient = matrixClient, + matrixUser = user, + temporaryUriDeleter = FakeTemporaryUriDeleter( + deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) } + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("New name")) + initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) + initialState.eventSink(EditUserProfileEvents.Save) + consumeItemsUntilPredicate { matrixClient.setDisplayNameCalled && matrixClient.removeAvatarCalled && !matrixClient.uploadAvatarCalled } + assertThat(matrixClient.setDisplayNameCalled).isTrue() + assertThat(matrixClient.removeAvatarCalled).isTrue() + assertThat(matrixClient.uploadAvatarCalled).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - save does not change room details if they're the same trimmed`() = runTest { + val matrixClient = FakeMatrixClient() + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + val presenter = createEditUserProfilePresenter( + matrixClient = matrixClient, + matrixUser = user + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(" Name ")) + initialState.eventSink(EditUserProfileEvents.Save) + consumeItemsUntilTimeout() + assertThat(matrixClient.setDisplayNameCalled).isFalse() + assertThat(matrixClient.uploadAvatarCalled).isFalse() + assertThat(matrixClient.removeAvatarCalled).isFalse() + } + } + + @Test + fun `present - save does not change name if it's now empty`() = runTest { + val matrixClient = FakeMatrixClient() + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + val presenter = createEditUserProfilePresenter( + matrixClient = matrixClient, + matrixUser = user + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("")) + initialState.eventSink(EditUserProfileEvents.Save) + assertThat(matrixClient.setDisplayNameCalled).isFalse() + assertThat(matrixClient.uploadAvatarCalled).isFalse() + assertThat(matrixClient.removeAvatarCalled).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - save processes and sets avatar when processor returns successfully`() = runTest { + val matrixClient = FakeMatrixClient() + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + givenPickerReturnsFile() + val presenter = createEditUserProfilePresenter( + matrixClient = matrixClient, + matrixUser = user, + temporaryUriDeleter = FakeTemporaryUriDeleter( + deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) } + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + initialState.eventSink(EditUserProfileEvents.Save) + consumeItemsUntilPredicate { matrixClient.uploadAvatarCalled } + assertThat(matrixClient.uploadAvatarCalled).isTrue() + } + } + + @Test + fun `present - save does not set avatar data if processor fails`() = runTest { + val matrixClient = FakeMatrixClient() + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + val presenter = createEditUserProfilePresenter( + matrixClient = matrixClient, + matrixUser = user, + temporaryUriDeleter = FakeTemporaryUriDeleter( + deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) } + ), + ) + fakePickerProvider.givenResult(anotherAvatarUri) + fakeMediaPreProcessor.givenResult(Result.failure(RuntimeException("Oh no"))) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + initialState.eventSink(EditUserProfileEvents.Save) + skipItems(2) + assertThat(matrixClient.uploadAvatarCalled).isFalse() + assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) + } + } + + @Test + fun `present - sets save action to failure if name update fails`() = runTest { + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + val matrixClient = FakeMatrixClient().apply { + givenSetDisplayNameResult(Result.failure(RuntimeException("!"))) + } + saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.UpdateDisplayName("New name")) + } + + @Test + fun `present - sets save action to failure if removing avatar fails`() = runTest { + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + val matrixClient = FakeMatrixClient().apply { + givenRemoveAvatarResult(Result.failure(RuntimeException("!"))) + } + saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) + } + + @Test + fun `present - sets save action to failure if setting avatar fails`() = runTest { + givenPickerReturnsFile() + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + val matrixClient = FakeMatrixClient().apply { + givenUploadAvatarResult(Result.failure(RuntimeException("!"))) + } + saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + } + + @Test + fun `present - CloseDialog resets save action state`() = runTest { + givenPickerReturnsFile() + val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) + val matrixClient = FakeMatrixClient().apply { + givenSetDisplayNameResult(Result.failure(RuntimeException("!"))) + } + val presenter = createEditUserProfilePresenter(matrixUser = user, matrixClient = matrixClient) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("foo")) + initialState.eventSink(EditUserProfileEvents.Save) + skipItems(2) + assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) + initialState.eventSink(EditUserProfileEvents.CloseDialog) + assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + } + } + + private suspend fun saveAndAssertFailure(matrixUser: MatrixUser, matrixClient: MatrixClient, event: EditUserProfileEvents) { + val presenter = createEditUserProfilePresenter( + matrixUser = matrixUser, + matrixClient = matrixClient, + temporaryUriDeleter = FakeTemporaryUriDeleter( + deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) } + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(event) + initialState.eventSink(EditUserProfileEvents.Save) + skipItems(1) + assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Loading::class.java) + assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.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/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileViewTest.kt new file mode 100644 index 0000000..f4c7144 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileViewTest.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.user.editprofile + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class EditUserProfileViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setEditUserProfileView( + aEditUserProfileState( + eventSink = eventsRecorder, + ), + ) + rule.pressBack() + eventsRecorder.assertSingle(EditUserProfileEvents.Exit) + } + + @Test + fun `clicking on cancel exit emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setEditUserProfileView( + aEditUserProfileState( + saveAction = AsyncAction.ConfirmingCancellation, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(EditUserProfileEvents.CloseDialog) + } + + @Test + fun `clicking on OK exit emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setEditUserProfileView( + aEditUserProfileState( + saveAction = AsyncAction.ConfirmingCancellation, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_ok) + eventsRecorder.assertSingle(EditUserProfileEvents.Exit) + } + + @Test + fun `clicking on save emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setEditUserProfileView( + aEditUserProfileState( + saveButtonEnabled = true, + saveAction = AsyncAction.Uninitialized, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_save) + eventsRecorder.assertSingle(EditUserProfileEvents.Save) + } + + @Test + fun `clicking on avatar opens the bottom sheet dialog`() { + val eventsRecorder = EventsRecorder() + val actions = listOf( + AvatarAction.TakePhoto, + AvatarAction.ChoosePhoto, + AvatarAction.Remove, + ) + rule.setEditUserProfileView( + aEditUserProfileState( + saveAction = AsyncAction.Uninitialized, + avatarActions = actions, + eventSink = eventsRecorder, + ), + ) + val contentDescription = rule.activity.getString(CommonStrings.a11y_avatar) + rule.onNodeWithContentDescription(contentDescription).performClick() + // Assert that the actions are displayed + actions.forEach { action -> + val text = rule.activity.getString(action.titleResId) + rule.onNodeWithText(text).assertExists() + } + } + + @Test + fun `success invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setEditUserProfileView( + aEditUserProfileState( + saveAction = AsyncAction.Success(Unit), + eventSink = eventsRecorder, + ), + onEditProfileSuccess = callback, + ) + } + } +} + +private fun AndroidComposeTestRule.setEditUserProfileView( + state: EditUserProfileState, + onEditProfileSuccess: () -> Unit = EnsureNeverCalled(), +) { + setContent { + EditUserProfileView( + state = state, + onEditProfileSuccess = onEditProfileSuccess, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/FakeEditUserProfileNavigator.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/FakeEditUserProfileNavigator.kt new file mode 100644 index 0000000..7b34e90 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/FakeEditUserProfileNavigator.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.user.editprofile + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeEditUserProfileNavigator( + val closeLambda: () -> Unit = { lambdaError() } +) : EditUserProfileNavigator { + override fun close() = closeLambda() +} diff --git a/features/rageshake/api/build.gradle.kts b/features/rageshake/api/build.gradle.kts new file mode 100644 index 0000000..8a37497 --- /dev/null +++ b/features/rageshake/api/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.rageshake.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.uiStrings) +} diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/RageshakeFeatureAvailability.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/RageshakeFeatureAvailability.kt new file mode 100644 index 0000000..bc72e70 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/RageshakeFeatureAvailability.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.api + +import kotlinx.coroutines.flow.Flow + +fun interface RageshakeFeatureAvailability { + fun isAvailable(): Flow +} 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 0000000..6030c85 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/bugreport/BugReportEntryPoint.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 createNode( + parentNode: Node, + buildContext: BuildContext, + callback: Callback, + ): Node + + interface Callback : Plugin { + fun onDone() + } +} 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 0000000..89c4933 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.api.crash + +sealed interface CrashDetectionEvents { + data object ResetAllCrashData : CrashDetectionEvents + data 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 0000000..085d4c8 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionPresenter.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.api.crash + +import io.element.android.libraries.architecture.Presenter + +interface CrashDetectionPresenter : Presenter 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 0000000..dd08cbf --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionState.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.api.crash + +data class CrashDetectionState( + val appName: String, + 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 0000000..b151673 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionStateProvider.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.api.crash + +fun aCrashDetectionState() = CrashDetectionState( + appName = "Element", + 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 0000000..57d2852 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionView.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.api.crash + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.features.rageshake.api.R +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun CrashDetectionView( + state: CrashDetectionState, + onOpenBugReport: () -> Unit = { }, +) { + fun onPopupDismissed() { + state.eventSink(CrashDetectionEvents.ResetAllCrashData) + } + + if (state.crashDetected) { + CrashDetectionContent( + appName = state.appName, + onYesClick = onOpenBugReport, + onNoClick = ::onPopupDismissed, + onDismiss = ::onPopupDismissed, + ) + } +} + +@Composable +private fun CrashDetectionContent( + appName: String, + onNoClick: () -> Unit = { }, + onYesClick: () -> Unit = { }, + onDismiss: () -> Unit = { }, +) { + ConfirmationDialog( + title = stringResource(id = CommonStrings.action_report_bug), + content = stringResource(id = R.string.crash_detection_dialog_content, appName), + submitText = stringResource(id = CommonStrings.action_yes), + cancelText = stringResource(id = CommonStrings.action_no), + onCancelClick = onNoClick, + onSubmitClick = onYesClick, + onDismiss = onDismiss, + ) +} + +@PreviewsDayNight +@Composable +internal fun CrashDetectionViewPreview() = ElementPreview { + 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 0000000..921ae86 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionEvents.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.api.detection + +import io.element.android.features.rageshake.api.screenshot.ImageResult + +sealed interface RageshakeDetectionEvents { + data object Dismiss : RageshakeDetectionEvents + data object Disable : RageshakeDetectionEvents + data object StartDetection : RageshakeDetectionEvents + data 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 0000000..d371e47 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionPresenter.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.api.detection + +import io.element.android.libraries.architecture.Presenter + +interface RageshakeDetectionPresenter : Presenter 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 0000000..a240fed --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionState.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.api.detection + +import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState + +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 0000000..f79024e --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionStateProvider.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..dff6d9b --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionView.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.api.detection + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun RageshakeDetectionView( + state: RageshakeDetectionState, + onOpenBugReport: () -> Unit = { }, +) { + 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( + onScreenshot = { eventSink(RageshakeDetectionEvents.ProcessScreenshot(it)) } + ) + state.showDialog -> { + LaunchedEffect(Unit) { + context.vibrate() + } + RageshakeDialogContent( + onNoClick = { eventSink(RageshakeDetectionEvents.Dismiss) }, + onDisableClick = { eventSink(RageshakeDetectionEvents.Disable) }, + onYesClick = onOpenBugReport + ) + } + } +} + +@Composable +private fun TakeScreenshot( + onScreenshot: (ImageResult) -> Unit +) { + val view = LocalView.current + val latestOnScreenshot by rememberUpdatedState(onScreenshot) + LaunchedEffect(Unit) { + view.screenshot { + latestOnScreenshot(it) + } + } +} + +@Composable +private fun RageshakeDialogContent( + onNoClick: () -> Unit = { }, + onDisableClick: () -> Unit = { }, + onYesClick: () -> 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), + onCancelClick = onNoClick, + onThirdButtonClick = onDisableClick, + onSubmitClick = onYesClick, + onDismiss = onNoClick, + ) +} + +@PreviewsDayNight +@Composable +internal fun RageshakeDialogContentPreview() = ElementPreview { + RageshakeDialogContent() +} diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/logs/LogFilesRemover.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/logs/LogFilesRemover.kt new file mode 100644 index 0000000..f8eee31 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/logs/LogFilesRemover.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.api.logs + +import java.io.File + +interface LogFilesRemover { + /** + * Perform the log files removal. + * @param predicate a predicate to filter the files to remove. By default, all files are removed. + */ + suspend fun perform(predicate: (File) -> Boolean = { true }) +} diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/logs/WriteToFilesConfigurationFactory.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/logs/WriteToFilesConfigurationFactory.kt new file mode 100644 index 0000000..dafefb0 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/logs/WriteToFilesConfigurationFactory.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.api.logs + +import io.element.android.features.rageshake.api.reporter.BugReporter +import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration + +fun BugReporter.createWriteToFilesConfiguration(): WriteToFilesConfiguration { + return WriteToFilesConfiguration.Enabled( + directory = logDirectory().absolutePath, + filenamePrefix = "logs", + // Keep a maximum of 1 week of log files. + numberOfFiles = 7 * 24, + ) +} 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 0000000..49458c2 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..d4c1e34 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesPresenter.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.api.preferences + +import io.element.android.libraries.architecture.Presenter + +interface RageshakePreferencesPresenter : Presenter 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 0000000..faba80a --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesState.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.api.preferences + +data class RageshakePreferencesState( + val isFeatureEnabled: Boolean, + 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 0000000..d1a8637 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesStateProvider.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.api.preferences + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class RageshakePreferencesStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aRageshakePreferencesState(isEnabled = true, isSupported = true, sensitivity = 0.5f), + aRageshakePreferencesState(isEnabled = true, isSupported = false, sensitivity = 0.5f), + ) +} + +fun aRageshakePreferencesState( + isFeatureEnabled: Boolean = true, + isEnabled: Boolean = false, + isSupported: Boolean = true, + sensitivity: Float = 0.3f, + eventSink: (RageshakePreferencesEvents) -> Unit = {} +) = RageshakePreferencesState( + isFeatureEnabled = isFeatureEnabled, + isEnabled = isEnabled, + isSupported = isSupported, + sensitivity = sensitivity, + eventSink = 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 0000000..e4f3298 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesView.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.PreviewParameter +import io.element.android.features.rageshake.api.R +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.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Text +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) { + if (state.isFeatureEnabled) { + PreferenceCategory(title = stringResource(id = R.string.settings_rageshake)) { + if (state.isSupported) { + PreferenceSwitch( + title = stringResource(id = CommonStrings.preference_rageshake), + isChecked = state.isEnabled, + onCheckedChange = ::onEnabledChanged + ) + PreferenceSlide( + title = stringResource(id = R.string.settings_rageshake_detection_threshold), + // summary = stringResource(id = CommonStrings.settings_rageshake_detection_threshold_summary), + value = state.sensitivity, + enabled = state.isEnabled, + // 5 possible values - steps are in ]0, 1[ + steps = 3, + onValueChange = ::onSensitivityChanged + ) + } else { + ListItem( + headlineContent = { + Text("Rageshaking is not supported by your device") + }, + ) + } + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun RageshakePreferencesViewPreview(@PreviewParameter(RageshakePreferencesStateProvider::class) state: RageshakePreferencesState) = ElementPreview { + RageshakePreferencesView(state) +} 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 0000000..79b05ea --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.api.reporter + +import java.io.File + +interface BugReporter { + /** + * Send a bug report. + * + * @param withDevicesLogs true to include the device log + * @param withCrashLogs true to include the crash logs + * @param withScreenshot true to include the screenshot + * @param problemDescription the bug description + * @param canContact true if the user opt in to be contacted directly + * @param sendPushRules true to include the push rules + * @param listener the listener + */ + suspend fun sendBugReport( + withDevicesLogs: Boolean, + withCrashLogs: Boolean, + withScreenshot: Boolean, + problemDescription: String, + canContact: Boolean = false, + sendPushRules: Boolean = false, + listener: BugReporterListener + ) + + /** + * Provide the log directory. + */ + fun logDirectory(): File + + /** + * Set the current tracing log level. + */ + fun setCurrentTracingLogLevel(logLevel: String) + + /** + * Save the logcat. + */ + fun saveLogCat(): File? +} 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 0000000..b8c2d26 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporterListener.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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() +} 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 0000000..e820a14 --- /dev/null +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/screenshot/Screenshot.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/res/values-be/translations.xml b/features/rageshake/api/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..3d9eec2 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-be/translations.xml @@ -0,0 +1,7 @@ + + + "Пры апошнім выкарыстанні %1$s адбыўся збой. Хочаце падзяліцца справаздачай аб збоі?" + "Падобна, што вы трасеце тэлефон. Хочаце адкрыць экран паведамлення пра памылку?" + "Rageshake" + "Парог выяўлення" + diff --git a/features/rageshake/api/src/main/res/values-bg/translations.xml b/features/rageshake/api/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..c2a8c06 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-bg/translations.xml @@ -0,0 +1,4 @@ + + + "%1$s се срина при последното използване. Искате ли да споделите доклад за срива с нас?" + 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 0000000..7a48cf8 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-cs/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s havaroval při posledním použití. Chcete se s námi podělit o zprávu o selhání?" + "Zdá se, že frustrovaně třesete telefonem. Chcete nahlásit chybu?" + "Rageshake" + "Práh detekce" + diff --git a/features/rageshake/api/src/main/res/values-cy/translations.xml b/features/rageshake/api/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..c576aed --- /dev/null +++ b/features/rageshake/api/src/main/res/values-cy/translations.xml @@ -0,0 +1,7 @@ + + + "Chwalodd %1$s y tro diwethaf iddo gael ei ddefnyddio. Hoffech chi rannu adroddiad gwall gyda ni?" + "Mae\'n ymddangos eich bod yn ysgwyd y ffôn mewn rhwystredigaeth. Hoffech chi agor y sgrin adrodd gwallau?" + "Rageshake" + "Trothwy canfod" + diff --git a/features/rageshake/api/src/main/res/values-da/translations.xml b/features/rageshake/api/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..45486f4 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-da/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s crashede sidste gang den blev brugt. Vil du dele en ulykkesrapport med os?" + "Det ser ud til, at du ryster telefonen i frustration. Vil du åbne fejlrapporteringsskærmen?" + "Ryst enheden i frustration" + "Tærskel for registrering" + 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 0000000..cd8fc2d --- /dev/null +++ b/features/rageshake/api/src/main/res/values-de/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s ist bei der letzten Nutzung abgestürzt. Möchtest du einen Absturzbericht mit uns teilen?" + "Du scheinst das Telefon aus Frustration zu schütteln. Möchtest du den Bildschirm für Fehlerberichte öffnen?" + "Rageshake" + "Erkennungsschwelle" + diff --git a/features/rageshake/api/src/main/res/values-el/translations.xml b/features/rageshake/api/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..6fff38c --- /dev/null +++ b/features/rageshake/api/src/main/res/values-el/translations.xml @@ -0,0 +1,7 @@ + + + "Το %1$s διακόπηκε την τελευταία φορά που χρησιμοποιήθηκε. Θα \'θελες να μοιραστείς μια αναφορά σφάλματος μαζί μας;" + "Φαίνεται να κουνάς το τηλέφωνο με σύγχυση. Θες να ανοίξεις την οθόνη αναφοράς σφαλμάτων;" + "Rageshake" + "Όριο ανίχνευσης" + 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 0000000..cc9b1f8 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-es/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s se cerró inesperadamente la última vez que se lo usaste. ¿Quieres compartir un informe de error con nosotros?" + "Parece que sacudes el teléfono con frustración. ¿Quieres abrir la pantalla de informe de errores?" + "Agitar con fuerza" + "Umbral de detección" + diff --git a/features/rageshake/api/src/main/res/values-et/translations.xml b/features/rageshake/api/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..b7f8772 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-et/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s jooksis kokku viimati, kui seda kasutasid. Kas tahaksid selle kohta meile veateate saata?" + "Tundub, et sa raputad oma nutiseadet ägedalt. Kas sa soovid saata meile veateadet?" + "Seadme äge raputamine" + "Tuvastamise lävi" + diff --git a/features/rageshake/api/src/main/res/values-eu/translations.xml b/features/rageshake/api/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..48f9914 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-eu/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s kraskatu zen azkenekoz erabili zenean. Gurekin partekatu nahi al duzu kraskatzearen txostena?" + "Frustrazioaren eraginez mugikorra astintzen ari zarela dirudi. Erroreen berri emateko pantaila ireki nahi al duzu?" + diff --git a/features/rageshake/api/src/main/res/values-fa/translations.xml b/features/rageshake/api/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..5641d22 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-fa/translations.xml @@ -0,0 +1,7 @@ + + + "%1$sآخرین باری که استفاده شد، از کار افتاد. آیا مایلید گزارش خرابی را با ما به اشتراک بگذارید؟" + "به نظر می‌رسد دارید گوشی خود را به دلیل کار نکردن تکان می‌دهید! آیا می‌خواهید یک اشکال در برنامه گزارش نمایید؟" + "تکان دادن" + "آستانهٔ تشخیص" + diff --git a/features/rageshake/api/src/main/res/values-fi/translations.xml b/features/rageshake/api/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..4e79809 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-fi/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s kaatui edellisellä käyttökerralla. Haluatko jakaa virheraportin kanssamme?" + "Näytät ravistelevan puhelinta turhautuneena. Haluatko avata vikailmoitusnäytön?" + "Raivostunut ravistaminen" + "Havaitsemiskynnys" + 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 0000000..a2d1391 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-fr/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s s’est arrêté la dernière fois qu’il a été utilisé. Souhaitez-vous partager un rapport d’incident avec nous ?" + "Vous semblez secouez votre téléphone avec frustration. Souhaitez-vous ouvrir le formulaire pour reporter un problème ?" + "Rageshake" + "Seuil de détection" + diff --git a/features/rageshake/api/src/main/res/values-hu/translations.xml b/features/rageshake/api/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..e2fc862 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-hu/translations.xml @@ -0,0 +1,7 @@ + + + "Az %1$s összeomlott a legutóbbi használata óta. Megosztja velünk az összeomlás-jelentést?" + "Úgy tűnik, mintha dühösen rázná a telefont. Megnyitja a hibajelentési képernyőt?" + "Ideges rázás" + "Észlelési küszöb" + diff --git a/features/rageshake/api/src/main/res/values-in/translations.xml b/features/rageshake/api/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..270f623 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-in/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s mengalami kemogokan saat terakhir kali digunakan. Apakah Anda ingin berbagi laporan kerusakan dengan kami?" + "Anda tampaknya mengguncang telepon karena frustrasi. Apakah Anda ingin membuka layar laporan kutu?" + "Rageshake" + "Ambang batas deteksi" + 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 0000000..d958006 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-it/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s si è chiuso inaspettatamente l\'ultima volta che è stato usato. Vuoi condividere con noi un rapporto sull\'arresto anomalo?" + "Sembra che tu stia scuotendo il telefono per la frustrazione. Vuoi aprire la schermata di segnalazione dei problemi?" + "Rageshake" + "Soglia di rilevamento" + diff --git a/features/rageshake/api/src/main/res/values-ka/translations.xml b/features/rageshake/api/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..64c22d0 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-ka/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s ავარიულად გაითიშა ბოლოს გამოიყენებისას. გსურთ, გამოგვიგზავნოთ ავარიული გათიშვის ჟურნალი?" + "როგორც ჩანს, იმედგაცრუებით ტელეფონს აჯანჯღალებთ. გსურთ, გახსნათ შეცდომის დარეპორტების ეკრანი?" + "Rageshake" + "გამოვლენის ზღვარი" + diff --git a/features/rageshake/api/src/main/res/values-ko/translations.xml b/features/rageshake/api/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..846350f --- /dev/null +++ b/features/rageshake/api/src/main/res/values-ko/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s이(가) 이전에 마지막으로 사용할 때 충돌했습니다. 충돌 보고서를 공유해주실 수 있나요?" + "휴대폰을 강하게 흔드셨습니다. 버그 보고 화면을 여시겠어요?" + "강하게 흔들기" + "감지 수준" + diff --git a/features/rageshake/api/src/main/res/values-lt/translations.xml b/features/rageshake/api/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..49762d5 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-lt/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s nulūžo paskutinį kartą, kai buvo naudojama. Ar norėtumėte su mumis pasidalyti strigčių ataskaita?" + "Atrodo, kad nusivylęs purtote telefoną. Ar norėtumėte atidaryti pranešimo apie klaidas ekraną?" + "Rageshake" + "Aptikimo riba" + diff --git a/features/rageshake/api/src/main/res/values-nb/translations.xml b/features/rageshake/api/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..dfc2d71 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-nb/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s krasjet sist gang den ble brukt. Vil du dele en krasjrapport med oss?" + "Du ser ut til å riste på telefonen i frustrasjon. Vil du åpne feilrapportskjermen?" + "Rageshake" + "Gjenkjenningsterskel" + diff --git a/features/rageshake/api/src/main/res/values-nl/translations.xml b/features/rageshake/api/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..a6d1d2e --- /dev/null +++ b/features/rageshake/api/src/main/res/values-nl/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s crashte de laatste keer dat het werd gebruikt. Wil je een crashrapport met ons delen?" + "Het lijkt erop dat je gefrustreerd de telefoon hebt geschud. Wil je het scherm openen om een bug te rapporteren?" + "Schudden uit woede" + "Drempel voor detectie" + diff --git a/features/rageshake/api/src/main/res/values-pl/translations.xml b/features/rageshake/api/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..d9b42eb --- /dev/null +++ b/features/rageshake/api/src/main/res/values-pl/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s uległ awarii podczas ostatniego użycia. Czy chcesz przesłać nam raport o awarii?" + "Wygląda na to, że potrząsasz telefonem z frustracji. Czy chcesz otworzyć ekran zgłaszania błędów?" + "Gniewne wstrząsanie" + "Próg wykrywania" + diff --git a/features/rageshake/api/src/main/res/values-pt-rBR/translations.xml b/features/rageshake/api/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..c25eb58 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s falhou inesperadamente na última vez que foi usado. Gostaria de compartilhar um relatório de falhas conosco?" + "Você parece estar sacudindo o telefone com frustração. Você gostaria de abrir a tela de relatório de bugs?" + "Agitar agressivamente" + "Fronteira de detecção" + diff --git a/features/rageshake/api/src/main/res/values-pt/translations.xml b/features/rageshake/api/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..8fc3856 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-pt/translations.xml @@ -0,0 +1,7 @@ + + + "A %1$s teve uma falha da última vez que foi utilizada. Gostarias de partilhar um relatório de acidente connosco?" + "Parece que estás a abanar o telefone em sinal de frustração. Gostarias de abrir o painel de relatório de erros?" + "Rageshake" + "Limiar de deteção" + 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 0000000..67a72e3 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-ro/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s s-a blocat ultima dată când a fost folosit. Doriți să ne trimiteți un raport?" + "Se pare că scuturați telefonul de frustrare. Doriți să deschdeți ecranul de raportare a unei erori?" + "Rageshake" + "Prag de detecție" + diff --git a/features/rageshake/api/src/main/res/values-ru/translations.xml b/features/rageshake/api/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..72bf6fc --- /dev/null +++ b/features/rageshake/api/src/main/res/values-ru/translations.xml @@ -0,0 +1,7 @@ + + + "При последнем использовании %1$s произошел сбой. Хотите поделиться отчетом?" + "Похоже, что вы трясете телефон. Хотите открыть экран сообщения об ошибке?" + "Встряхните" + "Порог обнаружения" + 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 0000000..69eca07 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-sk/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s zlyhal pri poslednom použití. Chcete zdieľať správu o páde s našim tímom?" + "Zdá sa, že zúrivo trasiete telefónom. Chcete otvoriť obrazovku s hlásením chýb?" + "Zúrivé potrasenie" + "Prahová hodnota detekcie" + diff --git a/features/rageshake/api/src/main/res/values-sv/translations.xml b/features/rageshake/api/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..5a6fa19 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-sv/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s kraschade senast den användes. Vill du dela en kraschrapport med oss?" + "Du verkar skaka telefonen i frustration. Vill du öppna felrapporteringsskärmen?" + "Raseriskaka" + "Detektionströskel" + diff --git a/features/rageshake/api/src/main/res/values-tr/translations.xml b/features/rageshake/api/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..c2d0449 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-tr/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s son kullanıldığında çöktü. Bizimle bir çökme raporu paylaşmak ister misiniz?" + "Sinirden telefonu sallıyor gibi görünüyorsunuz. Hata raporu ekranını açmak ister misiniz?" + "Rageshake" + "Algılama eşiği" + diff --git a/features/rageshake/api/src/main/res/values-uk/translations.xml b/features/rageshake/api/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..67c94da --- /dev/null +++ b/features/rageshake/api/src/main/res/values-uk/translations.xml @@ -0,0 +1,7 @@ + + + "Стався збій %1$s під час останнього користування. Хочете поділитися з нами звітом про збій?" + "Здається, ви роздратовано трясете телефоном. Бажаєте запустити вікно для звіту про помилку?" + "Лютострус" + "Поріг виявлення" + diff --git a/features/rageshake/api/src/main/res/values-ur/translations.xml b/features/rageshake/api/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..9532f4e --- /dev/null +++ b/features/rageshake/api/src/main/res/values-ur/translations.xml @@ -0,0 +1,7 @@ + + + "%1$sآخری بار استعمال ہونے پر ٹکرا گیا۔ کیا آپ ہمارے ساتھ ٹکر کی گزارش (رپورٹ) کا اشتراک کرنا چاہیں گے؟" + "لگتا ہے آپ مایوسی میں ہاتف ہلا رہے ہیں۔ کیا آپ گزارش خطاء نمائش کھولنا چاہیں گے؟" + "غصے سے جھٹکانا" + "حد کھوج" + diff --git a/features/rageshake/api/src/main/res/values-uz/translations.xml b/features/rageshake/api/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..96032fd --- /dev/null +++ b/features/rageshake/api/src/main/res/values-uz/translations.xml @@ -0,0 +1,7 @@ + + + "%1$soxirgi marta ishlatilganda qulab tushdi. Biz bilan nosozlik hisobotini baham ko\'rmoqchimisiz?" + "Siz hafsalasi pir bo\'lib telefonni silkitayotganga o\'xshaysiz. Xatolar haqida hisobot ekranini ochmoqchimisiz?" + "G\'azablanish" + "Aniqlash chegarasi" + diff --git a/features/rageshake/api/src/main/res/values-zh-rTW/translations.xml b/features/rageshake/api/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..676c635 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s 上次使用時當機了。您想要與我們分享當機報告嗎?" + "您似乎正在沮喪地搖晃手機。您要開啟臭蟲回報畫面嗎?" + "憤怒搖晃" + "偵測閾值" + diff --git a/features/rageshake/api/src/main/res/values-zh/translations.xml b/features/rageshake/api/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..34a643c --- /dev/null +++ b/features/rageshake/api/src/main/res/values-zh/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s 上次使用时崩溃了。想和我们分享崩溃报告吗?" + "你似乎愤怒地摇晃了手机。想要打开 Bug 报告页面吗?" + "摇一摇" + "检测阈值" + 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 0000000..f2d86ab --- /dev/null +++ b/features/rageshake/api/src/main/res/values/localazy.xml @@ -0,0 +1,7 @@ + + + "%1$s crashed the last time it was used. Would you like to share a crash report with us?" + "You seem to be shaking the phone in frustration. Would you like to open the bug report screen?" + "Rageshake" + "Detection threshold" + diff --git a/features/rageshake/impl/build.gradle.kts b/features/rageshake/impl/build.gradle.kts new file mode 100644 index 0000000..0200f21 --- /dev/null +++ b/features/rageshake/impl/build.gradle.kts @@ -0,0 +1,60 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.rageshake.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.appconfig) + implementation(projects.features.enterprise.api) + implementation(projects.features.viewfolder.api) + implementation(projects.services.toolbox.api) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.network) + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.preferences.api) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.sessionStorage.api) + implementation(projects.libraries.matrix.api) + api(libs.squareup.seismic) + api(projects.features.rageshake.api) + implementation(libs.androidx.datastore.preferences) + implementation(platform(libs.network.okhttp.bom)) + implementation(libs.network.okhttp.okhttp) + implementation(libs.coil) + implementation(libs.coil.compose) + + testCommonDependencies(libs) + testImplementation(projects.features.enterprise.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.sessionStorage.test) + testImplementation(projects.features.rageshake.test) + testImplementation(projects.features.viewfolder.test) + testImplementation(projects.libraries.preferences.test) + testImplementation(projects.services.toolbox.test) + testImplementation(libs.network.mockwebserver) +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/DefaultRageshakeFeatureAvailability.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/DefaultRageshakeFeatureAvailability.kt new file mode 100644 index 0000000..97fe325 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/DefaultRageshakeFeatureAvailability.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.rageshake.api.RageshakeFeatureAvailability +import io.element.android.features.rageshake.impl.reporter.BugReporterUrlProvider +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +@ContributesBinding(AppScope::class) +class DefaultRageshakeFeatureAvailability( + private val bugReporterUrlProvider: BugReporterUrlProvider, +) : RageshakeFeatureAvailability { + override fun isAvailable(): Flow { + return bugReporterUrlProvider.provide() + .map { it != null } + } +} 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 0000000..9075187 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportEvents.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.bugreport + +sealed interface BugReportEvents { + data object SendBugReport : BugReportEvents + data object ResetAll : BugReportEvents + data 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 + data class SetSendPushRules(val sendPushRules: Boolean) : BugReportEvents +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportFlowNode.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportFlowNode.kt new file mode 100644 index 0000000..f843d7d --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportFlowNode.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.bugreport + +import android.os.Parcelable +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.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint +import io.element.android.features.viewfolder.api.ViewFolderEntryPoint +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.createNode +import kotlinx.parcelize.Parcelize + +@ContributesNode(AppScope::class) +@AssistedInject +class BugReportFlowNode( + @Assisted val buildContext: BuildContext, + @Assisted plugins: List, + private val viewFolderEntryPoint: ViewFolderEntryPoint, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +) { + private val callback: BugReportEntryPoint.Callback = callback() + + sealed interface NavTarget : Parcelable { + @Parcelize + data object Root : NavTarget + + @Parcelize + data class ViewLogs( + val rootPath: String, + ) : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Root -> { + val callback = object : BugReportNode.Callback { + override fun onDone() { + callback.onDone() + } + + override fun navigateToViewLogs(basePath: String) { + backstack.push(NavTarget.ViewLogs(rootPath = basePath)) + } + } + createNode(buildContext, listOf(callback)) + } + is NavTarget.ViewLogs -> { + val callback = object : ViewFolderEntryPoint.Callback { + override fun onDone() { + backstack.pop() + } + } + val params = ViewFolderEntryPoint.Params( + rootPath = navTarget.rootPath, + ) + viewFolderEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = params, + callback = callback, + ) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView() + } +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportFormError.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportFormError.kt new file mode 100644 index 0000000..ba3d9f1 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportFormError.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.bugreport + +sealed class BugReportFormError : Exception() { + data object DescriptionTooShort : BugReportFormError() +} 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 0000000..f6d6788 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportNode.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.bugreport + +import androidx.activity.compose.LocalActivity +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.rageshake.api.reporter.BugReporter +import io.element.android.libraries.androidutils.system.toast +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.ui.strings.CommonStrings + +@ContributesNode(AppScope::class) +@AssistedInject +class BugReportNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: BugReportPresenter, + private val bugReporter: BugReporter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onDone() + fun navigateToViewLogs(basePath: String) + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val activity = LocalActivity.current + BugReportView( + state = state, + modifier = modifier, + onBackClick = { navigateUp() }, + onSuccess = { + activity?.toast(CommonStrings.common_report_submitted) + callback.onDone() + }, + onViewLogs = { + // Force a logcat dump + bugReporter.saveLogCat() + callback.navigateToViewLogs(bugReporter.logDirectory().absolutePath) + } + ) + } +} 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 0000000..4985f9b --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.bugreport + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableFloatState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import dev.zacsweers.metro.Inject +import io.element.android.features.rageshake.api.reporter.BugReporter +import io.element.android.features.rageshake.api.reporter.BugReporterListener +import io.element.android.features.rageshake.impl.crash.CrashDataStore +import io.element.android.features.rageshake.impl.screenshot.ScreenshotHolder +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.annotations.AppCoroutineScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Inject +class BugReportPresenter( + private val bugReporter: BugReporter, + private val crashDataStore: CrashDataStore, + private val screenshotHolder: ScreenshotHolder, + @AppCoroutineScope + private val appCoroutineScope: CoroutineScope, +) : Presenter { + private class BugReporterUploadListener( + private val sendingProgress: MutableFloatState, + private val sendingAction: MutableState> + ) : BugReporterListener { + override fun onUploadCancelled() { + sendingProgress.floatValue = 0f + sendingAction.value = AsyncAction.Uninitialized + } + + override fun onUploadFailed(reason: String?) { + sendingProgress.floatValue = 0f + sendingAction.value = AsyncAction.Failure(Exception(reason)) + } + + override fun onProgress(progress: Int) { + sendingProgress.floatValue = progress.toFloat() / 100 + sendingAction.value = AsyncAction.Loading + } + + override fun onUploadSucceed() { + sendingProgress.floatValue = 0f + sendingAction.value = AsyncAction.Success(Unit) + } + } + + @Composable + override fun present(): BugReportState { + val screenshotUri = rememberSaveable { + mutableStateOf( + screenshotHolder.getFileUri() + ) + } + val crashInfo: String by remember { + crashDataStore.crashInfo() + }.collectAsState(initial = "") + + val sendingProgress = remember { + mutableFloatStateOf(0f) + } + val sendingAction: MutableState> = remember { + mutableStateOf(AsyncAction.Uninitialized) + } + val formState: MutableState = rememberSaveable { + mutableStateOf(BugReportFormState.Default) + } + val uploadListener = BugReporterUploadListener(sendingProgress, sendingAction) + + fun handleEvent(event: BugReportEvents) { + when (event) { + BugReportEvents.SendBugReport -> { + if (formState.value.description.length < 10) { + sendingAction.value = AsyncAction.Failure(BugReportFormError.DescriptionTooShort) + } else { + sendingAction.value = AsyncAction.Loading + 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) + } + is BugReportEvents.SetSendPushRules -> updateFormState(formState) { + copy(sendPushRules = event.sendPushRules) + } + BugReportEvents.ClearError -> { + sendingProgress.floatValue = 0f + sendingAction.value = AsyncAction.Uninitialized + } + } + } + + return BugReportState( + hasCrashLogs = crashInfo.isNotEmpty(), + sendingProgress = sendingProgress.floatValue, + sending = sendingAction.value, + formState = formState.value, + screenshotUri = screenshotUri.value, + eventSink = ::handleEvent, + ) + } + + private fun updateFormState(formState: MutableState, operation: BugReportFormState.() -> BugReportFormState) { + formState.value = operation(formState.value) + } + + private fun CoroutineScope.sendBugReport( + formState: BugReportFormState, + hasCrashLogs: Boolean, + listener: BugReporterListener, + ) = launch { + bugReporter.sendBugReport( + withDevicesLogs = formState.sendLogs, + withCrashLogs = hasCrashLogs && formState.sendLogs, + withScreenshot = formState.sendScreenshot, + problemDescription = formState.description, + canContact = formState.canContact, + sendPushRules = formState.sendPushRules, + listener = listener + ) + } + + private fun CoroutineScope.resetAll() = launch { + screenshotHolder.reset() + crashDataStore.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 0000000..65cc055 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportState.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.bugreport + +import android.os.Parcelable +import io.element.android.libraries.architecture.AsyncAction +import kotlinx.parcelize.Parcelize + +data class BugReportState( + val formState: BugReportFormState, + val hasCrashLogs: Boolean, + val screenshotUri: String?, + val sendingProgress: Float, + val sending: AsyncAction, + val eventSink: (BugReportEvents) -> Unit +) { + val submitEnabled = sending !is AsyncAction.Loading + val isDescriptionInError = sending is AsyncAction.Failure && + sending.error is BugReportFormError.DescriptionTooShort +} + +@Parcelize +data class BugReportFormState( + val description: String, + val sendLogs: Boolean, + val canContact: Boolean, + val sendScreenshot: Boolean, + val sendPushRules: Boolean, +) : Parcelable { + companion object { + val Default = BugReportFormState( + description = "", + sendLogs = true, + canContact = false, + sendScreenshot = false, + sendPushRules = 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 0000000..0afd06f --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportStateProvider.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.bugreport + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction + +open class BugReportStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aBugReportState(), + aBugReportState().copy( + formState = BugReportFormState.Default.copy( + description = "A long enough description", + sendScreenshot = true, + ), + hasCrashLogs = true, + screenshotUri = "aUri" + ), + aBugReportState().copy(sending = AsyncAction.Loading), + aBugReportState().copy(sending = AsyncAction.Success(Unit)), + aBugReportState().copy(sending = AsyncAction.Failure(BugReportFormError.DescriptionTooShort)), + ) +} + +fun aBugReportState() = BugReportState( + formState = BugReportFormState.Default, + hasCrashLogs = false, + screenshotUri = null, + sendingProgress = 0F, + sending = AsyncAction.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 0000000..612d3aa --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.bugreport + +import android.content.res.Configuration +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.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +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.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +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 coil3.compose.AsyncImage +import coil3.request.ImageRequest +import io.element.android.features.rageshake.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.form.textFieldState +import io.element.android.libraries.designsystem.components.preferences.PreferenceDivider +import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.components.preferences.PreferenceRow +import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch +import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext +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.ListItem +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextField +import io.element.android.libraries.designsystem.theme.components.TextFieldValidity +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun BugReportView( + state: BugReportState, + onViewLogs: () -> Unit, + onSuccess: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val eventSink = state.eventSink + + Box(modifier = modifier) { + PreferencePage( + title = stringResource(id = CommonStrings.common_report_a_problem), + onBackClick = onBackClick + ) { + val keyboardController = LocalSoftwareKeyboardController.current + val isFormEnabled = state.sending !is AsyncAction.Loading + var descriptionFieldState by textFieldState( + stateValue = state.formState.description + ) + Spacer(modifier = Modifier.height(16.dp)) + PreferenceRow { + TextField( + value = descriptionFieldState, + modifier = Modifier + .fillMaxWidth() + .onTabOrEnterKeyFocusNext(LocalFocusManager.current), + enabled = isFormEnabled, + placeholder = stringResource(id = R.string.screen_bug_report_editor_placeholder), + supportingText = stringResource(id = R.string.screen_bug_report_editor_description), + onValueChange = { + descriptionFieldState = it + eventSink(BugReportEvents.SetDescription(it)) + }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions(onNext = { + keyboardController?.hide() + }), + minLines = 3, + validity = if (state.isDescriptionInError) TextFieldValidity.Invalid else TextFieldValidity.None, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + PreferenceDivider() + ListItem( + headlineContent = { + Text(stringResource(id = R.string.screen_bug_report_view_logs)) + }, + enabled = isFormEnabled, + onClick = onViewLogs, + ) + PreferenceDivider() + 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, + ) + } + } + } + PreferenceSwitch( + isChecked = state.formState.sendPushRules, + onCheckedChange = { eventSink(BugReportEvents.SetSendPushRules(it)) }, + enabled = isFormEnabled, + title = stringResource(R.string.screen_bug_report_send_notification_settings_title), + subtitle = stringResource(R.string.screen_bug_report_send_notification_settings_description), + ) + // Submit + PreferenceRow { + Button( + text = stringResource(id = CommonStrings.action_send), + onClick = { eventSink(BugReportEvents.SendBugReport) }, + enabled = state.submitEnabled, + showProgress = state.sending.isLoading(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp, bottom = 16.dp) + ) + } + } + + AsyncActionView( + async = state.sending, + progressDialog = { }, + onSuccess = { + eventSink(BugReportEvents.ResetAll) + onSuccess() + }, + errorMessage = { error -> + when (error) { + BugReportFormError.DescriptionTooShort -> stringResource(id = R.string.screen_bug_report_error_description_too_short) + else -> error.message ?: error.toString() + } + }, + onErrorDismiss = { eventSink(BugReportEvents.ClearError) }, + ) + } +} + +@Preview(heightDp = 1000) +@Composable +internal fun BugReportViewDayPreview(@PreviewParameter(BugReportStateProvider::class) state: BugReportState) = ElementPreview { + BugReportView( + state = state, + onSuccess = {}, + onBackClick = {}, + onViewLogs = {}, + ) +} + +@Preview(heightDp = 1000, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +internal fun BugReportViewNightPreview(@PreviewParameter(BugReportStateProvider::class) state: BugReportState) = ElementPreview { + BugReportView( + state = state, + onSuccess = {}, + onBackClick = {}, + onViewLogs = {}, + ) +} 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 0000000..615bd37 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/DefaultBugReportEntryPoint.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.bugreport + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultBugReportEntryPoint : BugReportEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: BugReportEntryPoint.Callback, + ): Node { + return parentNode.createNode(buildContext, listOf(callback)) + } +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/CrashDataStore.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/CrashDataStore.kt new file mode 100644 index 0000000..234605d --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/CrashDataStore.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.crash + +import kotlinx.coroutines.flow.Flow + +interface CrashDataStore { + fun setCrashData(crashData: String) + + suspend fun resetAppHasCrashed() + fun appHasCrashed(): Flow + fun crashInfo(): Flow + + suspend fun reset() +} 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 0000000..25afade --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/DefaultCrashDetectionPresenter.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.rageshake.impl.crash + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.rageshake.api.RageshakeFeatureAvailability +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.libraries.core.meta.BuildMeta +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch + +@ContributesBinding(AppScope::class) +class DefaultCrashDetectionPresenter( + private val buildMeta: BuildMeta, + private val crashDataStore: CrashDataStore, + private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, +) : CrashDetectionPresenter { + @Composable + override fun present(): CrashDetectionState { + val localCoroutineScope = rememberCoroutineScope() + val crashDetected by remember { + rageshakeFeatureAvailability.isAvailable() + .flatMapLatest { isAvailable -> + if (isAvailable) { + crashDataStore.appHasCrashed() + } else { + flowOf(false) + } + } + }.collectAsState(false) + + fun handleEvent(event: CrashDetectionEvents) { + when (event) { + CrashDetectionEvents.ResetAllCrashData -> localCoroutineScope.resetAll() + CrashDetectionEvents.ResetAppHasCrashed -> localCoroutineScope.resetAppHasCrashed() + } + } + + return CrashDetectionState( + appName = buildMeta.applicationName, + crashDetected = crashDetected, + eventSink = ::handleEvent, + ) + } + + 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 0000000..91aee0d --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/PreferencesCrashDataStore.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.crash + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking + +private val appHasCrashedKey = booleanPreferencesKey("appHasCrashed") +private val crashDataKey = stringPreferencesKey("crashData") + +@ContributesBinding(AppScope::class) +class PreferencesCrashDataStore( + preferenceDataStoreFactory: PreferenceDataStoreFactory, +) : CrashDataStore { + private val store = preferenceDataStoreFactory.create("elementx_crash") + + 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 { + return store.data.map { prefs -> + prefs[appHasCrashedKey].orFalse() + } + } + + override fun crashInfo(): Flow { + 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 0000000..0eafdf3 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/VectorUncaughtExceptionHandler.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.crash + +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( + private val preferencesCrashDataStore: PreferencesCrashDataStore, +) : Thread.UncaughtExceptionHandler { + 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 / 1_048_576L + " MB\n") + append("freeSize " + freeSize / 1_048_576L + " MB\n") + append("totalSize " + totalSize / 1_048_576L + " 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") + preferencesCrashDataStore.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 0000000..a7813ed --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/detection/DefaultRageshakeDetectionPresenter.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.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.screenshot.ImageResult +import io.element.android.features.rageshake.impl.rageshake.RageShake +import io.element.android.features.rageshake.impl.screenshot.ScreenshotHolder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +@ContributesBinding(AppScope::class) +class DefaultRageshakeDetectionPresenter( + 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 handleEvent(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 = ::handleEvent, + ) + } + + LaunchedEffect(preferencesState.sensitivity) { + rageShake.setSensitivity(preferencesState.sensitivity) + } + val shouldStart = preferencesState.isFeatureEnabled && + 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) { + if (start) { + rageShake.start(state.preferenceState.sensitivity) + rageShake.setInterceptor { + takeScreenshot.value = true + } + } else { + rageShake.stop() + rageShake.setInterceptor(null) + } + } + + private fun CoroutineScope.processScreenshot(takeScreenshot: MutableState, showDialog: MutableState, 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/di/RageshakeBindings.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/di/RageshakeBindings.kt new file mode 100644 index 0000000..59795e5 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/di/RageshakeBindings.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.rageshake.impl.crash.PreferencesCrashDataStore + +@ContributesTo(AppScope::class) +interface RageshakeBindings { + fun preferencesCrashDataStore(): PreferencesCrashDataStore +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/di/RageshakeModule.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/di/RageshakeModule.kt new file mode 100644 index 0000000..475127a --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/di/RageshakeModule.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +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.detection.RageshakeDetectionPresenter +import io.element.android.features.rageshake.api.detection.RageshakeDetectionState +import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter +import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState +import io.element.android.libraries.architecture.Presenter + +@ContributesTo(AppScope::class) +@BindingContainer +interface RageshakeModule { + @Binds + fun bindRageshakePreferencesPresenter(presenter: RageshakePreferencesPresenter): Presenter + + @Binds + fun bindRageshakeDetectionPresenter(presenter: RageshakeDetectionPresenter): Presenter + + @Binds + fun bindCrashDetectionPresenter(presenter: CrashDetectionPresenter): Presenter +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/logs/DefaultLogFilesRemover.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/logs/DefaultLogFilesRemover.kt new file mode 100644 index 0000000..0e1abd3 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/logs/DefaultLogFilesRemover.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.logs + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.rageshake.api.logs.LogFilesRemover +import io.element.android.features.rageshake.impl.reporter.DefaultBugReporter +import java.io.File + +@ContributesBinding(AppScope::class) +class DefaultLogFilesRemover( + private val bugReporter: DefaultBugReporter, +) : LogFilesRemover { + override suspend fun perform(predicate: (File) -> Boolean) { + bugReporter.deleteAllFiles(predicate) + } +} 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 0000000..f16d99c --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/preferences/DefaultRageshakePreferencesPresenter.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.rageshake.api.RageshakeFeatureAvailability +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.impl.rageshake.RageShake +import io.element.android.features.rageshake.impl.rageshake.RageshakeDataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@ContributesBinding(AppScope::class) +class DefaultRageshakePreferencesPresenter( + private val rageshake: RageShake, + private val rageshakeDataStore: RageshakeDataStore, + private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, +) : RageshakePreferencesPresenter { + @Composable + override fun present(): RageshakePreferencesState { + val localCoroutineScope = rememberCoroutineScope() + val isSupported: MutableState = rememberSaveable { + mutableStateOf(rageshake.isAvailable()) + } + val isFeatureAvailable by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false) + val isEnabled by remember { + rageshakeDataStore.isEnabled() + }.collectAsState(initial = false) + + val sensitivity by remember { + rageshakeDataStore.sensitivity() + }.collectAsState(initial = 0f) + + fun handleEvent(event: RageshakePreferencesEvents) { + when (event) { + is RageshakePreferencesEvents.SetIsEnabled -> localCoroutineScope.setIsEnabled(event.isEnabled) + is RageshakePreferencesEvents.SetSensitivity -> localCoroutineScope.setSensitivity(event.sensitivity) + } + } + + return RageshakePreferencesState( + isFeatureEnabled = isFeatureAvailable, + isEnabled = isEnabled, + isSupported = isSupported.value, + sensitivity = sensitivity, + eventSink = ::handleEvent, + ) + } + + 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 0000000..04efd5b --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/DefaultRageShake.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.seismic.ShakeDetector +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import dev.zacsweers.metro.binding +import io.element.android.libraries.di.annotations.ApplicationContext + +@SingleIn(AppScope::class) +@ContributesBinding(scope = AppScope::class, binding = binding()) +class DefaultRageShake( + @ApplicationContext context: Context, +) : ShakeDetector.Listener, RageShake { + private var sensorManager = context.getSystemService() + 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 0000000..04ead6e --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/PreferencesRageshakeDataStore.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.rageshake + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val enabledKey = booleanPreferencesKey("enabled") +private val sensitivityKey = floatPreferencesKey("sensitivity") + +@ContributesBinding(AppScope::class) +class PreferencesRageshakeDataStore( + preferenceDataStoreFactory: PreferenceDataStoreFactory, +) : RageshakeDataStore { + private val store = preferenceDataStoreFactory.create("elementx_rageshake") + + override fun isEnabled(): Flow { + return store.data.map { prefs -> + prefs[enabledKey].orFalse() + } + } + + override suspend fun setIsEnabled(isEnabled: Boolean) { + store.edit { prefs -> + prefs[enabledKey] = isEnabled + } + } + + override fun sensitivity(): Flow { + 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/rageshake/RageShake.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/RageShake.kt new file mode 100644 index 0000000..f1f6e6f --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/RageShake.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.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/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/RageshakeDataStore.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/RageshakeDataStore.kt new file mode 100644 index 0000000..7afaa0d --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/rageshake/RageshakeDataStore.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.rageshake + +import kotlinx.coroutines.flow.Flow + +interface RageshakeDataStore { + fun isEnabled(): Flow + + suspend fun setIsEnabled(isEnabled: Boolean) + + fun sensitivity(): Flow + + suspend fun setSensitivity(sensitivity: Float) + + suspend fun reset() +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/BugReportAppNameProvider.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/BugReportAppNameProvider.kt new file mode 100644 index 0000000..cef434a --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/BugReportAppNameProvider.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.reporter + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.appconfig.RageshakeConfig + +fun interface BugReportAppNameProvider { + fun provide(): String +} + +@ContributesBinding(AppScope::class) +class DefaultBugReportAppNameProvider : BugReportAppNameProvider { + override fun provide(): String = RageshakeConfig.BUG_REPORT_APP_NAME +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/BugReporterMultipartBody.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/BugReporterMultipartBody.kt new file mode 100755 index 0000000..ccdff2e --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/BugReporterMultipartBody.kt @@ -0,0 +1,420 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * Copyright 2014 Square, Inc. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +@file:Suppress( + "unused", + "KDocUnresolvedReference", + "SpellCheckingInspection", +) + +package io.element.android.features.rageshake.impl.reporter + +import kotlinx.collections.immutable.toImmutableList +import okhttp3.Headers +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody +import okio.Buffer +import okio.BufferedSink +import okio.ByteString +import okio.ByteString.Companion.encodeUtf8 +import java.io.IOException +import java.util.UUID + +/** + * Copy of [okhttp3.MultipartBody] with addition of a listener to track progress (Last imported from OkHttp 5.0.0). + * Patches are surrounded by ELEMENT-START and ELEMENT-END + * + * An [RFC 2387][rfc_2387]-compliant request body. + * + * [rfc_2387]: http://www.ietf.org/rfc/rfc2387.txt + */ +@Suppress("NAME_SHADOWING") +class BugReporterMultipartBody internal constructor( + private val boundaryByteString: ByteString, + @get:JvmName("type") val type: MediaType, + @get:JvmName("parts") val parts: List, +) : RequestBody() { + // ELEMENT-START + private var listener: BugReporterMultipartBodyListener? = null + + private fun onWrite(totalWrittenBytes: Long) { + listener + ?.takeIf { contentLength > 0 } + ?.onWrite(totalWrittenBytes, contentLength) + } + + private val contentLengthSize = mutableListOf() + + fun setWriteListener(listener: BugReporterMultipartBodyListener?) { + this.listener = listener + } + // ELEMENT-END + + private val contentType: MediaType = "$type; boundary=$boundary".toMediaType() + private var contentLength = -1L + + @get:JvmName("boundary") + val boundary: String + get() = boundaryByteString.utf8() + + /** The number of parts in this multipart body. */ + @get:JvmName("size") + val size: Int + get() = parts.size + + fun part(index: Int): Part = parts[index] + + override fun isOneShot(): Boolean = parts.any { it.body.isOneShot() } + + /** A combination of [type] and [boundaryByteString]. */ + override fun contentType(): MediaType = contentType + + @JvmName("-deprecated_type") + @Deprecated( + message = "moved to val", + replaceWith = ReplaceWith(expression = "type"), + level = DeprecationLevel.ERROR, + ) + fun type(): MediaType = type + + @JvmName("-deprecated_boundary") + @Deprecated( + message = "moved to val", + replaceWith = ReplaceWith(expression = "boundary"), + level = DeprecationLevel.ERROR, + ) + fun boundary(): String = boundary + + @JvmName("-deprecated_size") + @Deprecated( + message = "moved to val", + replaceWith = ReplaceWith(expression = "size"), + level = DeprecationLevel.ERROR, + ) + fun size(): Int = size + + @JvmName("-deprecated_parts") + @Deprecated( + message = "moved to val", + replaceWith = ReplaceWith(expression = "parts"), + level = DeprecationLevel.ERROR, + ) + fun parts(): List = parts + + @Throws(IOException::class) + override fun contentLength(): Long { + var result = contentLength + if (result == -1L) { + result = writeOrCountBytes(null, true) + contentLength = result + } + return result + } + + @Throws(IOException::class) + override fun writeTo(sink: BufferedSink) { + writeOrCountBytes(sink, false) + } + + /** + * Either writes this request to [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. + */ + @Throws(IOException::class) + private fun writeOrCountBytes( + sink: BufferedSink?, + countBytes: Boolean, + ): Long { + var sink = sink + var byteCount = 0L + + var byteCountBuffer: Buffer? = null + if (countBytes) { + byteCountBuffer = Buffer() + sink = byteCountBuffer + // ELEMENT-START + contentLengthSize.clear() + // ELEMENT-END + } + + for (p in 0 until parts.size) { + val part = parts[p] + val headers = part.headers + val body = part.body + + sink!!.write(DASHDASH) + sink.write(boundaryByteString) + sink.write(CRLF) + + if (headers != null) { + for (h in 0 until headers.size) { + sink + .writeUtf8(headers.name(h)) + .write(COLONSPACE) + .writeUtf8(headers.value(h)) + .write(CRLF) + } + } + + val contentType = body.contentType() + if (contentType != null) { + sink + .writeUtf8("Content-Type: ") + .writeUtf8(contentType.toString()) + .write(CRLF) + } + + // We can't measure the body's size without the sizes of its components. + val contentLength = body.contentLength() + if (contentLength == -1L && countBytes) { + byteCountBuffer!!.clear() + return -1L + } + + sink.write(CRLF) + + if (countBytes) { + byteCount += contentLength + // ELEMENT-START + contentLengthSize.add(byteCount) + // ELEMENT-END + } else { + body.writeTo(sink) + // ELEMENT-START + // warn the listener of upload progress + // sink.buffer().size() does not give the right value + // assume that some data are popped + contentLengthSize.getOrNull(p)?.let { writtenByte -> + onWrite(writtenByte) + } + // ELEMENT-END + } + + sink.write(CRLF) + } + + sink!!.write(DASHDASH) + sink.write(boundaryByteString) + sink.write(DASHDASH) + sink.write(CRLF) + + if (countBytes) { + byteCount += byteCountBuffer!!.size + byteCountBuffer.clear() + } + + return byteCount + } + + class Part private constructor( + @get:JvmName("headers") val headers: Headers?, + @get:JvmName("body") val body: RequestBody, + ) { + @JvmName("-deprecated_headers") + @Deprecated( + message = "moved to val", + replaceWith = ReplaceWith(expression = "headers"), + level = DeprecationLevel.ERROR, + ) + fun headers(): Headers? = headers + + @JvmName("-deprecated_body") + @Deprecated( + message = "moved to val", + replaceWith = ReplaceWith(expression = "body"), + level = DeprecationLevel.ERROR, + ) + fun body(): RequestBody = body + + companion object { + @JvmStatic + fun create(body: RequestBody): Part = create(null, body) + + @JvmStatic + fun create( + headers: Headers?, + body: RequestBody, + ): Part { + require(headers?.get("Content-Type") == null) { "Unexpected header: Content-Type" } + require(headers?.get("Content-Length") == null) { "Unexpected header: Content-Length" } + return Part(headers, body) + } + + @JvmStatic + fun createFormData( + name: String, + value: String, + ): Part = createFormData(name, null, value.toRequestBody()) + + @JvmStatic + fun createFormData( + name: String, + filename: String?, + body: RequestBody, + ): Part { + val disposition = + buildString { + append("form-data; name=") + appendQuotedString(name) + + if (filename != null) { + append("; filename=") + appendQuotedString(filename) + } + } + + val headers = + Headers + .Builder() + .addUnsafeNonAscii("Content-Disposition", disposition) + .build() + + return create(headers, body) + } + } + } + + class Builder + @JvmOverloads + constructor( + boundary: String = UUID.randomUUID().toString(), + ) { + private val boundary: ByteString = boundary.encodeUtf8() + + // ELEMENT-START + // Element: use FORM as default type + private var type = FORM + // ELEMENT-END + + private val parts = mutableListOf() + + /** + * Set the MIME type. Expected values for `type` are [MIXED] (the default), [ALTERNATIVE], + * [DIGEST], [PARALLEL] and [FORM]. + */ + fun setType(type: MediaType) = + apply { + require(type.type == "multipart") { "multipart != $type" } + this.type = type + } + + /** Add a part to the body. */ + fun addPart(body: RequestBody) = + apply { + addPart(Part.create(body)) + } + + /** Add a part to the body. */ + fun addPart( + headers: Headers?, + body: RequestBody, + ) = apply { + addPart(Part.create(headers, body)) + } + + /** Add a form data part to the body. */ + fun addFormDataPart( + name: String, + value: String, + ) = apply { + addPart(Part.createFormData(name, value)) + } + + /** Add a form data part to the body. */ + fun addFormDataPart( + name: String, + filename: String?, + body: RequestBody, + ) = apply { + addPart(Part.createFormData(name, filename, body)) + } + + /** Add a part to the body. */ + fun addPart(part: Part) = + apply { + parts += part + } + + /** Assemble the specified parts into a request body. */ + fun build(): BugReporterMultipartBody { + check(parts.isNotEmpty()) { "Multipart body must have at least one part." } + return BugReporterMultipartBody(boundary, type, parts.toImmutableList()) + } + } + + companion object { + /** + * The "mixed" subtype of "multipart" is intended for use when the body parts are independent + * and need to be bundled in a particular order. Any "multipart" subtypes that an implementation + * does not recognize must be treated as being of subtype "mixed". + */ + @JvmField + val MIXED = "multipart/mixed".toMediaType() + + /** + * The "multipart/alternative" type is syntactically identical to "multipart/mixed", but the + * semantics are different. In particular, each of the body parts is an "alternative" version of + * the same information. + */ + @JvmField + val ALTERNATIVE = "multipart/alternative".toMediaType() + + /** + * This type is syntactically identical to "multipart/mixed", but the semantics are different. + * In particular, in a digest, the default `Content-Type` value for a body part is changed from + * "text/plain" to "message/rfc822". + */ + @JvmField + val DIGEST = "multipart/digest".toMediaType() + + /** + * This type is syntactically identical to "multipart/mixed", but the semantics are different. + * In particular, in a parallel entity, the order of body parts is not significant. + */ + @JvmField + val PARALLEL = "multipart/parallel".toMediaType() + + /** + * The media-type multipart/form-data follows the rules of all multipart MIME data streams as + * outlined in RFC 2046. In forms, there are a series of fields to be supplied by the user who + * fills out the form. Each field has a name. Within a given form, the names are unique. + */ + @JvmField + val FORM = "multipart/form-data".toMediaType() + + private val COLONSPACE = byteArrayOf(':'.code.toByte(), ' '.code.toByte()) + private val CRLF = byteArrayOf('\r'.code.toByte(), '\n'.code.toByte()) + private val DASHDASH = byteArrayOf('-'.code.toByte(), '-'.code.toByte()) + + /** + * Appends a quoted-string to a StringBuilder. + * + * RFC 2388 is rather vague about how one should escape special characters in form-data + * parameters, and as it turns out Firefox and Chrome actually do rather different things, and + * both say in their comments that they're not really sure what the right approach is. We go + * with Chrome's behavior (which also experimentally seems to match what IE does), but if you + * actually want to have a good chance of things working, please avoid double-quotes, newlines, + * percent signs, and the like in your field names. + */ + internal fun StringBuilder.appendQuotedString(key: String) { + append('"') + for (i in 0 until key.length) { + when (val ch = key[i]) { + '\n' -> append("%0A") + '\r' -> append("%0D") + '"' -> append("%22") + else -> append(ch) + } + } + append('"') + } + } +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/BugReporterMultipartBodyListener.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/BugReporterMultipartBodyListener.kt new file mode 100644 index 0000000..8d21b28 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/BugReporterMultipartBodyListener.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.reporter + +fun interface BugReporterMultipartBodyListener { + /** + * Upload listener. + * + * @param totalWritten total written bytes + * @param contentLength content length + */ + fun onWrite(totalWritten: Long, contentLength: Long) +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/BugReporterUrlProvider.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/BugReporterUrlProvider.kt new file mode 100644 index 0000000..d6d74d8 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/BugReporterUrlProvider.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.reporter + +import kotlinx.coroutines.flow.Flow +import okhttp3.HttpUrl + +fun interface BugReporterUrlProvider { + fun provide(): Flow +} 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 0000000..f51d699 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt @@ -0,0 +1,444 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Provider +import dev.zacsweers.metro.SingleIn +import io.element.android.appconfig.RageshakeConfig +import io.element.android.features.rageshake.api.logs.createWriteToFilesConfiguration +import io.element.android.features.rageshake.api.reporter.BugReporter +import io.element.android.features.rageshake.api.reporter.BugReporterListener +import io.element.android.features.rageshake.impl.crash.CrashDataStore +import io.element.android.features.rageshake.impl.screenshot.ScreenshotHolder +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.data.tryOrNull +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.SdkMetadata +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.tracing.TracingService +import io.element.android.libraries.network.useragent.UserAgentProvider +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.api.sessionIdFlow +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +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.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * BugReporter creates and sends the bug reports. + */ +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultBugReporter( + @ApplicationContext private val context: Context, + @AppCoroutineScope + private val appCoroutineScope: CoroutineScope, + private val screenshotHolder: ScreenshotHolder, + private val crashDataStore: CrashDataStore, + private val coroutineDispatchers: CoroutineDispatchers, + private val okHttpClient: Provider, + private val userAgentProvider: UserAgentProvider, + private val sessionStore: SessionStore, + private val buildMeta: BuildMeta, + private val bugReporterUrlProvider: BugReporterUrlProvider, + private val sdkMetadata: SdkMetadata, + private val matrixClientProvider: MatrixClientProvider, + private val tracingService: TracingService, +) : BugReporter { + companion object { + // filenames + private const val LOG_CAT_FILENAME = "logcat.log" + private const val LOG_DIRECTORY_NAME = "logs" + } + + private val logcatCommandDebug = arrayOf("logcat", "-d", "-v", "threadtime", "*:*") + private var currentTracingLogLevel: String? = null + + private val baseLogDirectory = File(context.cacheDir, LOG_DIRECTORY_NAME) + private var currentLogDirectory: File = baseLogDirectory + + init { + if (buildMeta.isEnterpriseBuild) { + val logSubfolder = runBlocking { + sessionStore.getLatestSession() + }?.userId?.let(::UserId)?.domainName + setCurrentLogDirectory(logSubfolder) + sessionStore.sessionIdFlow() + .map { + it?.let(::UserId)?.domainName + } + .distinctUntilChanged() + .onEach { logSubfolder -> + setCurrentLogDirectory(logSubfolder) + tracingService.updateWriteToFilesConfiguration(createWriteToFilesConfiguration()) + } + .launchIn(appCoroutineScope) + } + } + + override suspend fun sendBugReport( + withDevicesLogs: Boolean, + withCrashLogs: Boolean, + withScreenshot: Boolean, + problemDescription: String, + canContact: Boolean, + sendPushRules: Boolean, + listener: BugReporterListener, + ) { + val url = bugReporterUrlProvider.provide().first() + if (url == null) { + // It should not happen, but if the URL is null, we cannot proceed + Timber.e("## sendBugReport() : bug report URL is null") + error("Bug report URL is null, cannot send bug report") + } + // enumerate files to delete + val bugReportFiles: MutableList = ArrayList() + var response: Response? = null + try { + var serverError: String? = null + withContext(coroutineDispatchers.io) { + val crashCallStack = crashDataStore.crashInfo().first() + val bugDescription = buildString { + append(problemDescription) + if (crashCallStack.isNotEmpty() && withCrashLogs) { + append("\n\n\n\n--------------------------------- crash call stack ---------------------------------\n") + append(crashCallStack) + } + } + val gzippedFiles = mutableListOf() + if (withDevicesLogs) { + val files = getLogFiles().sortedByDescending { it.lastModified() } + files.mapNotNullTo(gzippedFiles) { file -> + when { + file.extension == "gz" -> file + else -> compressFile(file) + } + } + } + if (withCrashLogs || withDevicesLogs) { + saveLogCat() + ?.let { logCatFile -> + compressFile(logCatFile).also { + logCatFile.safeDelete() + } + } + ?.let { gzippedLogcat -> + gzippedFiles.add(0, gzippedLogcat) + } + } + val sessionData = sessionStore.getLatestSession() + val numberOfAccounts = sessionStore.numberOfSessions() + val deviceId = sessionData?.deviceId ?: "undefined" + val userId = sessionData?.userId?.let { UserId(it) } + // build the multi part request + val builder = BugReporterMultipartBody.Builder() + .addFormDataPart("text", bugDescription) + .addFormDataPart("app", RageshakeConfig.BUG_REPORT_APP_NAME) + .addFormDataPart("user_agent", userAgentProvider.provide()) + .addFormDataPart("user_id", userId?.toString() ?: "undefined") + .addFormDataPart("number_of_accounts", numberOfAccounts.toString()) + .addFormDataPart("can_contact", canContact.toString()) + .addFormDataPart("device_id", deviceId) + .addFormDataPart("device", Build.MODEL.trim()) + .addFormDataPart("locale", Locale.getDefault().toString()) + .addFormDataPart("sdk_sha", sdkMetadata.sdkGitSha) + .addFormDataPart("local_time", LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)) + .addFormDataPart("utc_time", LocalDateTime.ofInstant(Instant.now(), ZoneOffset.UTC).format(DateTimeFormatter.ISO_DATE_TIME)) + .addFormDataPart("app_id", buildMeta.applicationId) + // Nightly versions have a custom version name suffix that we should remove for the bug report + .addFormDataPart("Version", buildMeta.versionName.replace("-nightly", "")) + .addFormDataPart("label", buildMeta.versionName) + .addFormDataPart("label", buildMeta.flavorDescription) + .addFormDataPart("branch_name", buildMeta.gitBranchName) + userId?.let { + matrixClientProvider.getOrNull(it)?.let { client -> + val curveKey = client.encryptionService.deviceCurve25519() + val edKey = client.encryptionService.deviceEd25519() + if (curveKey != null && edKey != null) { + builder.addFormDataPart("device_keys", "curve25519:$curveKey, ed25519:$edKey") + } + + if (sendPushRules) { + client.notificationSettingsService.getRawPushRules().getOrNull()?.let { pushRules -> + builder.addFormDataPart( + name = "file", + filename = "push_rules.json", + body = pushRules.toByteArray().toRequestBody(MimeTypes.Json.toMediaTypeOrNull()) + ) + } + } + } + } + if (crashCallStack.isNotEmpty() && withCrashLogs) { + builder.addFormDataPart("label", "crash") + } + currentTracingLogLevel?.let { + builder.addFormDataPart("tracing_log_level", it) + } + if (buildMeta.isEnterpriseBuild) { + builder.addFormDataPart("label", "Enterprise") + } + // add the gzipped files, don't cancel the whole upload if only some file failed to upload + var totalUploadedSize = 0L + var uploadedSomeLogs = false + for (file in gzippedFiles) { + try { + val requestBody = file.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull()) + totalUploadedSize += requestBody.contentLength() + // If we are about to upload more than the max request size, stop here + if (totalUploadedSize > RageshakeConfig.MAX_LOG_UPLOAD_SIZE) { + Timber.e("Could not upload file ${file.name} because it would exceed the max request size") + break + } + builder.addFormDataPart("compressed-log", file.name, requestBody) + uploadedSomeLogs = true + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "## sendBugReport() : fail to attach file ${file.name}") + } + } + bugReportFiles.addAll(gzippedFiles) + if (gzippedFiles.isNotEmpty() && !uploadedSomeLogs) { + serverError = "Couldn't upload any logs, please retry." + return@withContext + } + 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") + } + } + } + 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 + } + Timber.v("## onWrite() : $percentage%") + listener.onProgress(percentage) + } + // build the request + val request = Request.Builder() + .url(url) + .post(requestBody) + .build() + var errorMessage: String? = null + // trigger the request + try { + response = okHttpClient() + .newCall(request) + .execute() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Error executing the request") + errorMessage = e.localizedMessage + } + val responseCode = response?.code + // if the upload failed, try to retrieve the reason + if (responseCode != HttpURLConnection.HTTP_OK) { + serverError = if (errorMessage != null) { + "Failed with error $errorMessage" + } else { + val responseBody = response?.body + if (responseBody == null) { + "Failed with error $responseCode" + } else { + try { + val inputStream = responseBody.byteStream() + val serverErrorJson = inputStream.use { + it.readBytes().toString(Charsets.UTF_8) + } + try { + val responseJSON = JSONObject(serverErrorJson) + responseJSON.getString("error") + } catch (e: CancellationException) { + throw e + } catch (e: JSONException) { + Timber.e(e, "Json conversion failed") + "Failed with error $responseCode" + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "## sendBugReport() : failed to parse error") + "Failed with error $responseCode" + } + } + } + } + } + if (serverError == null) { + listener.onUploadSucceed() + } else { + listener.onUploadFailed(serverError) + } + } finally { + withContext(coroutineDispatchers.io) { + // delete the generated files when the bug report process has finished + for (file in bugReportFiles) { + file.safeDelete() + } + response?.close() + } + } + } + + override fun logDirectory(): File { + return currentLogDirectory.apply { + mkdirs() + } + } + + private fun setCurrentLogDirectory(subfolderName: String?) { + currentLogDirectory = if (subfolderName == null) { + baseLogDirectory + } else { + File(baseLogDirectory, subfolderName) + } + } + + suspend fun deleteAllFiles(predicate: (File) -> Boolean) { + withContext(coroutineDispatchers.io) { + deleteAllFilesRecursive(baseLogDirectory, predicate) + } + } + + private fun deleteAllFilesRecursive( + directory: File, + predicate: (File) -> Boolean, + ) { + directory.listFiles()?.forEach { file -> + if (file.isDirectory) { + deleteAllFilesRecursive(file, predicate) + } else { + if (predicate(file)) { + file.safeDelete() + } + } + } + } + + override fun setCurrentTracingLogLevel(logLevel: String) { + currentTracingLogLevel = logLevel + } + + /** + * @return the files on the log directory. + */ + private fun getLogFiles(): List { + return tryOrNull( + onException = { Timber.e(it, "## getLogFiles() failed") } + ) { + val logDirectory = logDirectory() + logDirectory.listFiles() + ?.filter { it.isFile && !it.name.endsWith(LOG_CAT_FILENAME) } + }.orEmpty() + } + + // ============================================================================================================== + // Logcat management + // ============================================================================================================== + + /** + * Save the logcat. + * + * @return the file if the operation succeeds + */ + override fun saveLogCat(): File? { + val file = File(baseLogDirectory, LOG_CAT_FILENAME) + if (file.exists()) { + file.safeDelete() + } + return try { + file.writer().use { + getLogCatContent(it) + } + file + } catch (e: Exception) { + Timber.e(e, "## saveLogCat() : fail to write logcat") + null + } + } + + /** + * Retrieves the logs. + * + * @param streamWriter the stream writer + */ + private fun getLogCatContent(streamWriter: OutputStreamWriter) { + val logcatProcess = tryOrNull { + Runtime.getRuntime().exec(logcatCommandDebug) + } ?: return + try { + val separator = System.lineSeparator() + logcatProcess.inputStream + .reader() + .buffered(RageshakeConfig.MAX_LOG_UPLOAD_SIZE.toInt()) + .forEachLine { line -> + streamWriter.append(line) + streamWriter.append(separator) + } + } catch (e: IOException) { + Timber.e(e, "getLogCatContent fails") + } + } +} diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterUrlProvider.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterUrlProvider.kt new file mode 100644 index 0000000..384d059 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterUrlProvider.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.reporter + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.appconfig.RageshakeConfig +import io.element.android.features.enterprise.api.BugReportUrl +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.api.sessionIdFlow +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl + +@ContributesBinding(AppScope::class) +class DefaultBugReporterUrlProvider( + private val bugReportAppNameProvider: BugReportAppNameProvider, + private val enterpriseService: EnterpriseService, + private val sessionStore: SessionStore, +) : BugReporterUrlProvider { + @OptIn(ExperimentalCoroutinesApi::class) + override fun provide(): Flow { + if (bugReportAppNameProvider.provide().isEmpty()) return flowOf(null) + return sessionStore.sessionIdFlow().flatMapLatest { sessionId -> + enterpriseService.bugReportUrlFlow(sessionId?.let(::SessionId)) + .map { bugReportUrl -> + when (bugReportUrl) { + is BugReportUrl.Custom -> bugReportUrl.url + BugReportUrl.Disabled -> null + BugReportUrl.UseDefault -> RageshakeConfig.BUG_REPORT_URL.takeIf { it.isNotEmpty() } + } + } + .map { it?.toHttpUrl() } + } + } +} 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 0000000..96fb145 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/screenshot/DefaultScreenshotHolder.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.screenshot + +import android.content.Context +import android.graphics.Bitmap +import androidx.core.net.toUri +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.androidutils.bitmap.writeBitmap +import io.element.android.libraries.androidutils.file.safeDelete +import io.element.android.libraries.di.annotations.ApplicationContext +import java.io.File + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultScreenshotHolder( + @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/kotlin/io/element/android/features/rageshake/impl/screenshot/ScreenshotHolder.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/screenshot/ScreenshotHolder.kt new file mode 100644 index 0000000..8ea58f6 --- /dev/null +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/screenshot/ScreenshotHolder.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.screenshot + +import android.graphics.Bitmap + +interface ScreenshotHolder { + fun writeBitmap(data: Bitmap) + fun getFileUri(): String? + fun reset() +} diff --git a/features/rageshake/impl/src/main/res/values-be/translations.xml b/features/rageshake/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..f927ba3 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,17 @@ + + + "Далучыць здымак экрана" + "Вы можаце звязацца са мной, калі ў Вас узнікнуць якія-небудзь дадатковыя пытанні." + "Звяжыцеся са мной" + "Рэдагаваць здымак экрана" + "Калі ласка, апішыце памылку. Што вы зрабілі? Якія паводзіны вы чакалі? Што адбылося насамрэч. Калі ласка, апішыце ўсё як магчыма падрабязней." + "Апішыце праблему…" + "Калі магчыма, калі ласка, напішыце апісанне на англійскай мове." + "Апісанне занадта кароткае. Дайце больш падрабязную інфармацыю аб тым, што адбылося. Дзякуй!" + "Адправіць журналы збояў" + "Дазволіць журналы" + "Адправіць здымак экрана" + "Каб пераканацца, што ўсё працуе правільна, у паведамленне будуць уключаны часопісы. Каб адправіць паведамленне без часопісаў, адключыце гэтую наладу." + "Пры апошнім выкарыстанні %1$s адбыўся збой. Хочаце падзяліцца справаздачай аб збоі?" + "Прагляд журналаў" + diff --git a/features/rageshake/impl/src/main/res/values-bg/translations.xml b/features/rageshake/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..7db9b49 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,17 @@ + + + "Прикачване на екранна снимка" + "Можеш да се свържеш с мен, ако има допълнителни въпроси." + "Свързване с мен" + "Редактиране на екранната снимка" + "Моля, опишете проблема. Какво направихте? Какво очаквахте да се случи? Какво се случи в действителност. Моля, изложете колкото се може повече подробности." + "Опишете проблема…" + "Ако е възможно, моля, напишете описанието на английски език." + "Описанието е твърде кратко, моля, дайте повече подробности за случилото се. Благодаря!" + "Изпращане на дневниците за сривове" + "Разрешаване на дневниците" + "Изпращане на екранна снимка" + "Дневниците ще бъдат включени към вашето съобщение, за да се уверим, че всичко работи правилно. За да изпратите съобщението си без дневници, изключете тази настройка." + "%1$s се срина при последното използване. Искате ли да споделите доклад за срива с нас?" + "Преглед на дневниците" + 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 0000000..93db542 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,20 @@ + + + "Připojit snímek obrazovky" + "V případě dalších dotazů se na mě můžete obrátit." + "Kontaktujte mě" + "Upravit snímek obrazovky" + "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í." + "Popište chybu…" + "Pokud je to možné, prosím, napište popis anglicky." + "Popis je příliš krátký, uveďte prosím více podrobností o tom, co se stalo. Děkujeme!" + "Odeslat záznamy o selhání" + "Povolit protokoly" + "Vaše protokoly jsou příliš velké, proto je nelze zahrnout do této zprávy. Zašlete nám je prosím jiným způsobem." + "Odeslat snímek obrazovky" + "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í." + "%1$s havaroval při posledním použití. Chcete se s námi podělit o zprávu o selhání?" + "Pokud máte problémy s oznámeními, nahrání nastavení oznámení nám může pomoci určit jejich příčinu." + "Nastavení odesílání oznámení" + "Zobrazit protokoly" + diff --git a/features/rageshake/impl/src/main/res/values-cy/translations.xml b/features/rageshake/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..c9cee76 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,20 @@ + + + "Atodwch lun sgrin" + "Gallwch gysylltu â mi os oes gennych unrhyw gwestiynau dilynol." + "Cysylltwch â mi" + "Golygu\'r llun sgrin" + "Disgrifiwch y broblem. Beth wnaethoch chi? Beth oeddech chi\'n disgwyl i ddigwydd? Beth ddigwyddodd mewn gwirionedd. Ewch i gymaint o fanylion ag y gallwch." + "Disgrifiwch y broblem…" + "Os yn bosibl, ysgrifennwch y disgrifiad yn Saesneg." + "Mae\'r disgrifiad yn rhy fyr, rhowch fwy o fanylion am yr hyn ddigwyddodd. Diolch!" + "Anfonwch logiau chwalu" + "Caniatáu logiau" + "Mae eich logiau\'n rhy fawr felly nid oes modd eu cynnwys yn yr adroddiad hwn, anfonwch nhw atom mewn ffordd arall." + "Anfon luniau sgrin" + "Bydd cofnodion yn cael eu cynnwys gyda\'ch neges i wneud yn siŵr bod popeth yn gweithio\'n iawn. I anfon eich neges heb logiau, diffoddwch y gosodiad hwn." + "Chwalodd %1$s y tro diwethaf iddo gael ei ddefnyddio. Hoffech chi rannu adroddiad gwall gyda ni?" + "Os ydych chi\'n cael problemau gyda hysbysiadau, gall llwytho\'r gosodiadau hysbysiadau ein helpu i ddarganfod yr achos gwreiddiol." + "Anfon gosodiadau hysbysiadau" + "Gweld logiau" + diff --git a/features/rageshake/impl/src/main/res/values-da/translations.xml b/features/rageshake/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..035d281 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,20 @@ + + + "Vedhæft skærmbillede" + "Du kan kontakte mig, hvis du har opfølgende spørgsmål." + "Kontakt mig" + "Rediger skærmbillede" + "Beskriv venligst problemet: Hvad gjorde du? Hvad forventede du, at der skulle ske? Hvad skete der faktisk? - Beskriv det med så mange detaljer som du kan." + "Beskriv problemet…" + "Hvis det er muligt, må du meget gerne lave beskrivelsen på engelsk." + "Beskrivelsen er for kort, giv venligst flere detaljer om, hvad der skete. Tak!" + "Send nedbrudslogfiler" + "Tillad logfiler" + "Dine logfiler er for store, så de kan ikke medtages i denne rapport, send dem venligst til os på en anden måde." + "Send skærmbillede" + "Logfiler vil blive inkluderet i din besked for at sikre, at alt fungerer korrekt. Hvis du vil sende din besked uden logfiler, skal du deaktivere denne indstilling." + "%1$s crashede sidste gang den blev brugt. Vil du dele en ulykkesrapport med os?" + "Hvis du har problemer med notifikationer, kan upload af notifikationsindstillingerne hjælpe os med at identificere den grundlæggende årsag." + "Send notifikationsindstillinger" + "Se logfiler" + 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 0000000..33baf29 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,20 @@ + + + "Bildschirmfoto anhängen" + "Du kannst mich kontaktieren, solltest du weitere Fragen haben." + "Kontaktiere mich" + "Bildschirmfoto bearbeiten" + "Bitte beschreibe das Problem. Was hast du getan? Was hast du erwartet, was passiert? Was ist tatsächlich passiert? Bitte gehe so detailliert wie möglich vor." + "Beschreibe den Fehler…" + "Wenn möglich, verfasse die Beschreibung bitte auf Englisch." + "Die Beschreibung ist zu kurz. Bitte gib weitere Informationen darüber an, was passiert ist." + "Absturzprotokolle senden" + "Logdateien mitsenden" + "Deine Logs sind zu groß und können dem Bericht nicht beigefügt werden. Bitte sende sie uns auf einem anderen Weg." + "Bildschirmfoto senden" + "Die Protokolle werden deiner Nachricht beigefügt, um sicherzustellen, dass alles ordnungsgemäß funktioniert. Um deine Nachricht ohne Protokolle zu senden, deaktiviere diese Einstellung." + "%1$s ist bei der letzten Nutzung abgestürzt. Möchtest du einen Absturzbericht mit uns teilen?" + "Wenn du Probleme mit Benachrichtigungen hast, kann das Hochladen der Einstellungen für Benachrichtigungen uns helfen, die Ursache zu finden." + "Einstellungen für Benachrichtigungen senden" + "Logs ansehen" + diff --git a/features/rageshake/impl/src/main/res/values-el/translations.xml b/features/rageshake/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..86400b4 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,17 @@ + + + "Επισύναψη στιγμιοτύπου οθόνης" + "Μπορείς να επικοινωνήσεις μαζί μου εάν έχεις οποιεσδήποτε επιπλέον ερωτήσεις." + "Επικοινώνησε μαζί μου" + "Επεξεργασία στιγμιότυπου οθόνης" + "Παρακαλώ περιέγραψε το πρόβλημα. Τί έκανες; Τί περίμενες να συμβεί; Τι πραγματικά συνέβη. Παρακαλώ μπες σε όσο περισσότερες λεπτομέρειες μπορείς." + "Περιέγραψε το πρόβλημα…" + "Εάν είναι δυνατόν, γράψε την περιγραφή στα αγγλικά." + "Η περιγραφή είναι πολύ σύντομη, δώσε περισσότερες λεπτομέρειες σχετικά με το τί συνέβη. Ευχαριστώ!" + "Αποστολή αρχείων καταγραφής σφαλμάτων" + "Να επιτρέπονται τα αρχεία καταγραφής" + "Αποστολή στιγμιοτύπου οθόνης" + "Τα αρχεία καταγραφής θα συμπεριληφθούν στο μήνυμά σου για να βεβαιωθούμε ότι όλα λειτουργούν σωστά. Για να στείλεις το μήνυμά σου χωρίς αρχεία καταγραφής, απενεργοποίησε αυτήν τη ρύθμιση." + "Το %1$s διακόπηκε την τελευταία φορά που χρησιμοποιήθηκε. Θα \'θελες να μοιραστείς μια αναφορά σφάλματος μαζί μας;" + "Προβολή αρχείων καταγραφής" + 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 0000000..cdeb467 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,17 @@ + + + "Adjuntar captura de pantalla" + "Pueden ponerse en contacto conmigo si tienen alguna pregunta relacionada." + "Contáctame" + "Editar captura de pantalla" + "Describe el problema. ¿Qué has hecho? ¿Qué esperabas que ocurriera? ¿Qué ocurrió realmente? Por favor, detállalo todo lo que puedas." + "Describe el problema…" + "Si es posible, escribe la descripción en inglés." + "La descripción es demasiado corta. Proporciona más detalles sobre lo sucedido. ¡Gracias!" + "Enviar registros de fallos" + "Permitir registros" + "Enviar captura de pantalla" + "Los registros se incluirán con tu mensaje para asegurarse de que todo funciona correctamente. Para enviar tu mensaje sin registros, desactiva esta opción." + "%1$s se cerró inesperadamente la última vez que se lo usaste. ¿Quieres compartir un informe de error con nosotros?" + "Ver los registros" + diff --git a/features/rageshake/impl/src/main/res/values-et/translations.xml b/features/rageshake/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..2f55a9b --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,20 @@ + + + "Lisa ekraanitõmmis" + "Kui sul on täiendavaid küsimusi, siis võid minuga ühendust võtta." + "Võta minuga ühendust" + "Muuda ekraanitõmmist" + "Palun kirjelda juhtunut. Mida sina tegid? Mis sinu arvates pidi juhtuma? Mis tegelikult juhtus? Palun kirjelda kõike seda võimalikult üksikasjalikult." + "Palun kirjelda probleemi…" + "Kui vähegi võimalik, siis kirjuta inglise keeles." + "Kirjeldus on liiga lühike. Palun jaga täpsemat teavet selle kohta, mis juhtus. Tänud juba ette!" + "Saada krahhilogid" + "Luba logide saatmine" + "Sinu logid on väga mahukad ja neid ei saa siia lisada. Palun saada logid meile mõnel muul viisil." + "Saada ekraanitõmmis" + "Tõhusama veaotsingu nimel lisame sinu veateatele logid. Kui sa seda ei soovi, siis lülita antud valik välja." + "%1$s jooksis kokku viimati, kui seda kasutasid. Kas tahaksid selle kohta meile veateate saata?" + "Kui sul teavitused ei toimi päris korralikult, siis teavituste seadistuste üleslaadimine võib aidata meil põhjuse tuvastada." + "Teavituste seadistuste saatmine" + "Vaata logisid" + diff --git a/features/rageshake/impl/src/main/res/values-eu/translations.xml b/features/rageshake/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..1ba3ba9 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,16 @@ + + + "Erantsi pantaila-argazkia" + "Galderarik baduzu, nirekin jar zaitezke harremanetan." + "Jarri nirekin harremanetan" + "Editatu pantaila-argazkia" + "Deskribatu arazoa. Zer egin duzu? Zer espero zenuen gertatzea? Benetan gertatu dena. Eman ahalik eta xehetasun gehien." + "Deskribatu arazoa…" + "Ahal izanez gero, idatzi deskribapena ingelesez." + "Deskribapena laburregia da; eman gertatutakoari buruzko xehetasun gehiago. Eskerrik asko!" + "Bidali kraskaduraren erregistroak" + "Baimendu erregistroak" + "Bidali pantaila-argazkia" + "%1$s kraskatu zen azkenekoz erabili zenean. Gurekin partekatu nahi al duzu kraskatzearen txostena?" + "Ikusi erregistroak" + diff --git a/features/rageshake/impl/src/main/res/values-fa/translations.xml b/features/rageshake/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..ba5ab75 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,16 @@ + + + "پیوست نماگرفت" + "اگر پرسش دیگری دارید، می‌توانید با من در تماس باشید." + "تماس با من" + "ویرایش نماگرفت" + "لطفاً مشکل را شرح دهید. چه‌کار کردید؟ انتظار داشتید چه بشود؟ ولی چه شد؟ لطفاً‌تا جای ممکن وارد جزییات شوید." + "شرح مشکل…" + "ترجیحاً توضیحات را به زبان انگلیسی بنویسید." + "ارسال رخدادنگارهای خطا" + "اجازه به گزارش‌ها" + "ارسال تصویر صفحه" + "برای اطمینان از درست کار کردن همه‌چیز گزارش‌ها در پیامتان قرار خواهد گرفت. برای فرستادن پیام بدون گزارش‌ها این تنظیم را خاموش کنید." + "%1$sآخرین باری که استفاده شد، از کار افتاد. آیا مایلید گزارش خرابی را با ما به اشتراک بگذارید؟" + "دیدن گزارش‌ها" + diff --git a/features/rageshake/impl/src/main/res/values-fi/translations.xml b/features/rageshake/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..1970d53 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,20 @@ + + + "Liitä kuvakaappaus" + "Voitte ottaa minuun yhteyttä, jos teillä on lisäkysymyksiä." + "Ota minuun yhteyttä" + "Muokkaa kuvakaappausta" + "Kuvaile ongelmaasi. Mitä teit? Mitä odotit tapahtuvan? Mitä oikeasti tapahtui? Kerro niin paljon kuin mahdollista." + "Kuvaile ongelmaasi…" + "Jos mahdollista, kirjoita englanniksi." + "Kuvaus on liian lyhyt. Kerro tarkemmin mitä tapahtui, kiitos!" + "Lähetä kaatumislokit" + "Lähetä lokitiedostot" + "Lokitiedostosi ovat liian suuria, joten niitä ei voida sisällyttää tähän raporttiin. Lähetä ne meille toisella tavalla." + "Lähetä kuvakaappaus" + "Lähetä lokitiedostot viestisi kanssa, jotta voimme varmistaa, että kaikki toimii oikein. Jos haluat lähettää viestisi ilman lokeja, jätä tämä asetus valitsematta." + "%1$s kaatui edellisellä käyttökerralla. Haluatko jakaa virheraportin kanssamme?" + "Jos sinulla on ongelmia ilmoitusten kanssa, ilmoitusasetusten lähettäminen voi auttaa meitä selvittämään ongelman syyn." + "Lähetä ilmoitusasetukset" + "Näytä lokitiedostot" + 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 0000000..6685903 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,20 @@ + + + "Joindre une capture d’écran" + "Vous pouvez me contacter si vous avez des questions complémentaires." + "Contactez-moi" + "Modifier la capture d’écran" + "S’il vous plait, veuillez décrire le problème. Qu’avez-vous fait ? À quoi vous attendiez-vous ? Que s’est-il réellement passé ? Veuillez ajouter le plus de détails possible." + "Décrire le problème…" + "Si possible, veuillez rédiger la description en anglais." + "La description est trop courte, veuillez fournir plus de détails sur ce qui s’est passé. Merci !" + "Envoyer des journaux d’incident" + "Autoriser à inclure les journaux techniques" + "Vos fichiers de log sont trop volumineux et ne peuvent donc pas être inclus dans ce rapport, veuillez nous les envoyer par un autre moyen." + "Envoyer une capture d’écran" + "Pour vérifier que les choses fonctionnent comme prévu, des journaux techniques seront envoyés avec votre message. Pour ne pas envoyer ces journaux, désactivez ce paramètre." + "%1$s s’est arrêté la dernière fois qu’il a été utilisé. Souhaitez-vous partager un rapport d’incident avec nous ?" + "Si vous rencontrez des problèmes avec les notifications, l’envoie des paramètres de notification peut nous aider à identifier la cause du problème." + "Envoyer les paramètres de notification" + "Afficher les journaux" + diff --git a/features/rageshake/impl/src/main/res/values-hu/translations.xml b/features/rageshake/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..851d3f0 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,20 @@ + + + "Képernyőkép mellékelése" + "Felveheti velem a kapcsolatot, ha bármilyen további kérdése van." + "Kapcsolatfelvétel" + "Képernyőkép szerkesztése" + "Írja le a hibát. Mit csinált? Mire számított, hogy történni fog? Mi történt valójában? Fogalmazzon a lehető legrészletesebben." + "Írja le a problémát…" + "Ha lehetséges, a leírást angolul írja meg." + "A leírás túl rövid, adjon meg további részleteket a történtekről. Köszönjük!" + "Összeomlásnaplók küldése" + "Naplók engedélyezése" + "A naplófájlok túl nagyok, ezért nem szerepelhetnek ebben a jelentésben. Más módon küldje el őket." + "Képernyőkép küldése" + "A naplók szerepelni fognak az üzenetben, hogy megbizonyosodhassunk arról, hogy minden megfelelően működik-e. Ha naplók nélkül szeretné elküldeni az üzenetet, akkor kapcsolja ki ezt a beállítást." + "Az %1$s összeomlott a legutóbbi használata óta. Megosztja velünk az összeomlás-jelentést?" + "Ha problémákat tapasztal az értesítésekkel, az értesítési beállítások feltöltése segíthet meghatároznunk a kiváltó okát." + "Értesítési beállítások küldése" + "Naplók megtekintése" + diff --git a/features/rageshake/impl/src/main/res/values-in/translations.xml b/features/rageshake/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..dd80d0a --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,18 @@ + + + "Lampirkan tangkapan layar" + "Anda dapat menghubungi saya jika Anda memiliki pertanyaan lebih lanjut." + "Hubungi saya" + "Sunting tangkapan layar" + "Silakan jelaskan masalah tersebut. Apa yang Anda lakukan? Apa yang Anda harapkan untuk terjadi? Apa yang sebenarnya terjadi? Jelaskan sedetail mungkin." + "Jelaskan masalah tersebut…" + "Jika memungkinkan, silakan tulis deskripsi dalam bahasa Inggris." + "Deskripsinya terlalu pendek, silakan menyediakan detail tambahan tentang apa yang terjadi. Terima kasih!" + "Kirim log kerusakan" + "Izinkan log" + "Log Anda terlalu besar sehingga tidak dapat dimasukkan dalam laporan ini, kirimkan kepada kami dengan cara lain." + "Kirim tangkapan layar" + "Log akan disertakan dengan pesan Anda untuk memastikan bahwa semuanya berfungsi dengan baik. Untuk mengirimkan pesan Anda tanpa log, matikan pengaturan ini." + "%1$s mengalami kemogokan saat terakhir kali digunakan. Apakah Anda ingin berbagi laporan kerusakan dengan kami?" + "Tampilkan catatan" + 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 0000000..31fb95f --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,20 @@ + + + "Allega istantanea schermo" + "Potete contattarmi per qualsiasi altra domanda." + "Contattami" + "Cambia istantanea schermo" + "Descrivi il problema. Che cosa hai fatto? Cosa ti aspettavi che accadesse? Cosa è effettivamente accaduto. Si prega di inserire il maggior numero di dettagli possibile." + "Descrivi il problema…" + "Se possibile, scrivere la descrizione in inglese." + "La descrizione è troppo breve, ti preghiamo di fornire maggiori dettagli sull\'accaduto. Grazie!" + "Invia i log degli arresti anomali" + "Consenti i log" + "I tuoi log sono troppo grandi, quindi non possono essere inclusi in questo rapporto, inviaceli in un altro modo." + "Invia istantanea schermo" + "Per verificare che le cose funzionino come previsto, i log verranno inviati con il tuo messaggio. Per inviare solo il tuo messaggio, disattiva questa impostazione." + "%1$s si è chiuso inaspettatamente l\'ultima volta che è stato usato. Vuoi condividere con noi un rapporto sull\'arresto anomalo?" + "Se riscontri problemi con le notifiche, caricare le regole per le notifiche push può aiutarci a individuare la causa principale. Tieni presente che queste regole possono contenere informazioni private, come il tuo nome visualizzato o le parole chiave per cui ricevere notifiche." + "Invia impostazioni di notifica" + "Visualizza i log" + diff --git a/features/rageshake/impl/src/main/res/values-ka/translations.xml b/features/rageshake/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..3fd04f0 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,17 @@ + + + "ეკრანის ანაბეჭდის დართვა" + "შეგიძლიათ, დამიკავშირდეთ თუ გაქვთ შემდგომი კითხვები." + "დამიკავშირდით" + "ეკრანის ანაბეჭდის რედაქტირება" + "გთხოვთ, აღწეროთ პრობლემა. რა გააკეთე? რა შედეგს ელოდებოდით? რა მოხდა სინამდვილეში? გთხოვთ, ყველაფერი დაწვრილებით თქვათ." + "აღწერეთ პრობლემა…" + "თუ შესაძლებელია, გთხოვთ, დაწეროთ აღწერა ინგლისურ ენაზე." + "აღწერა ძალიან მოკლეა, გთხოვთ მოგვაწოდოთ მეტი დეტალი მომხდარის შესახებ. მადლობა!" + "გაუმართაობის ჟურნალის გაგზავნა" + "ჟურნალების დაშვება" + "ეკრანის ანაბეჭდის გაგზავნა" + "ჟურნალები თქვენს შეტყობინებაში შევა იმაში დასარწმუნებლად, რომ ყველაფერი სწორად მუშაობს. ჟურნალების გარეშე გასაგზავნად გათიშეთ ეს პარამეტრი." + "%1$s ავარიულად გაითიშა ბოლოს გამოიყენებისას. გსურთ, გამოგვიგზავნოთ ავარიული გათიშვის ჟურნალი?" + "ჟურნალის ნახვა" + diff --git a/features/rageshake/impl/src/main/res/values-ko/translations.xml b/features/rageshake/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..c3d2b62 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,18 @@ + + + "스크린샷 첨부" + "후속 질문이 있는 경우 저에게 연락하실 수 있습니다." + "문의하기" + "스크린샷 수정" + "문제를 설명해 주세요. 무엇을 했나요? 무슨 일이 일어날 것으로 예상했나요? 실제로 무슨 일이 일어났나요. 가능한 한 자세히 설명해 주세요." + "문제를 설명해 주세요…" + "가능하다면 영어로 설명을 작성해 주십시오." + "설명 내용이 너무 짧습니다. 발생한 상황에 대해 더 자세한 내용을 제공해 주시기 바랍니다. 감사합니다!" + "충돌 로그 보내기" + "로그 허용" + "귀하의 로그가 너무 커서 이 보고서에 포함할 수 없습니다. 다른 방법으로 보내주시기 바랍니다." + "스크린샷 전송" + "모든 기능이 제대로 작동하는지 확인하기 위해 로그애 메시지가 포함됩니다. 로그 없이 메시지를 보내려면 이 설정을 해제하세요." + "%1$s이(가) 이전에 마지막으로 사용할 때 충돌했습니다. 충돌 보고서를 공유해주실 수 있나요?" + "로그 보기" + diff --git a/features/rageshake/impl/src/main/res/values-lt/translations.xml b/features/rageshake/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..9a3889b --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,14 @@ + + + "Pridėti ekrano nuotrauką" + "Jei turite papildomų klausimų, galite susisiekti su manimi." + "Redaguoti ekrano nuotrauką" + "Apibūdinkite problemą. Ką padarėte? Ko tikėjotės? Kas iš tikrųjų įvyko? Pateikite kuo daugiau detalių." + "Apibūdinkite problemą…" + "Jei įmanoma, aprašymą parašykite anglų kalba." + "Siųsti gedimų žurnalus" + "Leisti žurnalus" + "Siųsti ekrano nuotrauką" + "Prie žinutės bus pridėti žurnalai, kad įsitikintumėme, jog viskas veikia tinkamai. Jei norite išsiųsti savo žinutę be žurnalų, išjunkite šį nustatymą." + "%1$s nulūžo paskutinį kartą, kai buvo naudojama. Ar norėtumėte su mumis pasidalyti strigčių ataskaita?" + diff --git a/features/rageshake/impl/src/main/res/values-nb/translations.xml b/features/rageshake/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..996793c --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,20 @@ + + + "Legg ved skjermbilde" + "Du kan kontakte meg hvis du har noen oppfølgingsspørsmål." + "Kontakt meg" + "Rediger skjermbilde" + "Vennligst beskriv problemet. Hva har du gjort? Hva forventet du skulle skje? Hva som faktisk skjedde. Vær så detaljert som mulig." + "Beskriv problemet…" + "Hvis mulig, vennligst skriv beskrivelsen på engelsk." + "Beskrivelsen er for kort, vennligst gi flere detaljer om hva som skjedde. Takk skal du ha!" + "Send krasjlogger" + "Tillat logger" + "Loggene dine er for store og kan derfor ikke inkluderes i denne rapporten. Vennligst send dem til oss på en annen måte." + "Send skjermbilde" + "Logger vil bli inkludert i meldingen din, for å sikre at alt fungerer som det skal. For å sende meldingen uten logger, slå av denne innstillingen." + "%1$s krasjet sist gang den ble brukt. Vil du dele en krasjrapport med oss?" + "Hvis du har problemer med varsler, kan det å laste opp varslingsinnstillingene hjelpe oss med å finne den underliggende årsaken." + "Send varslingsinnstillinger" + "Vis logger" + diff --git a/features/rageshake/impl/src/main/res/values-nl/translations.xml b/features/rageshake/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..dbf62aa --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,17 @@ + + + "Schermafbeelding bijvoegen" + "Je mag contact met mij opnemen als je nog vervolg vragen hebt." + "Neem contact met mij op" + "Schermafbeelding bewerken" + "Beschrijf het probleem. Wat heb je gedaan? Wat had je verwacht? Wat is er daadwerkelijk gebeurd. Beschrijf het zo gedetailleerd mogelijk." + "Beschrijf het probleem…" + "Geeft de beschrijving in het Engels indien mogelijk." + "De beschrijving is te kort, geef meer details over wat er is gebeurd. Bedankt!" + "Crashlogboeken verzenden" + "Logboeken toestaan" + "Schermafbeelding verzenden" + "Er worden logbestanden bij uw bericht gevoegd om er zeker van te zijn dat alles goed werkt. Als u uw bericht zonder logbestanden wilt verzenden, schakelt u deze instelling uit." + "%1$s crashte de laatste keer dat het werd gebruikt. Wil je een crashrapport met ons delen?" + "Logboeken weergeven" + diff --git a/features/rageshake/impl/src/main/res/values-pl/translations.xml b/features/rageshake/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..c433071 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,20 @@ + + + "Załącz zrzut ekranu" + "Zezwól na kontakt ze mną, jeśli są jakiekolwiek dodatkowe pytania." + "Kontakt ze mną" + "Edytuj zrzut ekranu" + "Opisz problem. Co zrobiłeś? Czego oczekiwałeś? Co się stało zamiast tego. Podaj jak najwięcej szczegółów." + "Opisz problem…" + "Jeśli to możliwe, napisz zgłoszenje w języku angielskim." + "Opis jest zbyt krótki, podaj więcej szczegółów na temat tego co się stało. Dzięki!" + "Wyślij logi awarii" + "Zezwól na logi" + "Twoje dzienniki są zbyt duże, więc nie można ich uwzględnić w tym raporcie. Prześlij je do nas w inny sposób." + "Wyślij zrzut ekranu" + "Logi zostaną dołączone do Twojej wiadomości, aby upewnić się, że wszystko działa poprawnie. Aby wysłać wiadomość bez logów, wyłącz to ustawienie." + "%1$s uległ awarii podczas ostatniego użycia. Czy chcesz przesłać nam raport o awarii?" + "Jeśli posiadasz problemy z powiadomieniami, przesłanie nam swoich ustawień pomoże nam ustalić przyczynę usterki." + "Ustawienia powiadomień" + "Wyświetl logi" + diff --git a/features/rageshake/impl/src/main/res/values-pt-rBR/translations.xml b/features/rageshake/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..7495d59 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,20 @@ + + + "Anexar captura de tela" + "Você pode entrar em contato comigo se tiver alguma pergunta adicional." + "Entre em contato comigo" + "Editar captura de tela" + "Descreva o problema. O que você fez? O que você esperava que acontecesse? O que realmente aconteceu? Por favor, forneça o máximo de detalhes possível." + "Descreva o problema…" + "Se possível, escreva a descrição em inglês." + "A descrição é muito curta, por favor, forneça mais detalhes sobre o que aconteceu. Obrigado!" + "Enviar registros de falhas" + "Permitir registros" + "Seus registros são grandes demais portanto não podem serem inclusos no relatório, por favor envie-os para a gente de outra maneira." + "Enviar captura de tela" + "Os registros serão incluídos com sua mensagem para garantir que tudo esteja funcionando corretamente. Para enviar sua mensagem sem registros, desative essa configuração." + "%1$s falhou inesperadamente na última vez que foi usado. Gostaria de compartilhar um relatório de falhas conosco?" + "Se estiver enfrentando problemas com as notificações, enviar as regras de notificações push pode ajudar-nos a descobrir o que está errado. Observe que as regras podem conter informações privadas, como o seu nome de exibição ou palavras chaves de notificação." + "Enviar configurações de notificação" + "Ver registros" + diff --git a/features/rageshake/impl/src/main/res/values-pt/translations.xml b/features/rageshake/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..a5b44ea --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,20 @@ + + + "Anexar captura de ecrã" + "Podem contactar-me se tiverem mais questões." + "Contactar-me" + "Editar captura de ecrã" + "Descreve o problema. O que é que fizeste? O que esperavas que acontecesse? O que realmente aconteceu? Por favor, dá o máximo de detalhes que puderes." + "Descreve o problema…" + "Se possível, escreve a descrição em inglês." + "A descrição é demasiado curta. Por favor dá mais detalhes sobre o que aconteceu. Obrigado!" + "Enviar registos de falha" + "Permitir registos" + "Os teus registos são excessivamente grandes e não podem ser incluídos neste relatório, por favor envia-no-los de outra forma." + "Enviar captura de ecrã" + "Os registos serão incluídos na tua mensagem para garantir que tudo está a funcionar corretamente. Para enviares a tua mensagem sem registos, desativa esta definição." + "A %1$s teve uma falha da última vez que foi utilizada. Gostarias de partilhar um relatório de acidente connosco?" + "Se estiveres a ter problemas com as notificações, enviar as configurações pode ajudar-nos a identificar a causa." + "Enviar configurações de notificação" + "Ver registos" + 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 0000000..f342ffd --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,20 @@ + + + "Atașați o captură de ecran" + "Puteți să mă contactați dacă aveți întrebări suplimentare" + "Contactați-mă" + "Editați captura de ecran" + "Vă rugăm să descrieți problema. Ce ați făcut? Ce vă aşteptați să se întâmple? Ce s-a întâmplat de fapt. Vă rugam să oferiți cât mai multe detalii cu putință." + "Descrieți problema…" + "Dacă posibil, vă rugăm să scrieți descrierea în engleză." + "Descrierea este prea scurtă, vă rugăm să oferiți mai multe detalii despre ceea ce s-a întâmplat. Vă mulțumim!" + "Trimiteți log-uri" + "Permiteți log-uri" + "Jurnalele dumneavoastră sunt prea mari și nu pot fi incluse în acest raport. Vă rugăm să ni le trimiteți prin altă metodă." + "Trimiteți captură de ecran" + "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." + "%1$s s-a blocat ultima dată când a fost folosit. Doriți să ne trimiteți un raport?" + "Dacă întâmpinați probleme cu notificările, încărcarea setărilor notificărilor ne poate ajuta să identificăm cauza principală." + "Trimiteți setările notificărilor" + "Vizualizați log-urile" + diff --git a/features/rageshake/impl/src/main/res/values-ru/translations.xml b/features/rageshake/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..42286ef --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,20 @@ + + + "Приложить снимок экрана" + "Вы можете связаться со мной, если у Вас возникнут какие-либо дополнительные вопросы." + "Связаться со мной" + "Редактировать снимок экрана" + "Пожалуйста, опишите ошибку. Что вы сделали? Какое поведение вы ожидали? Что произошло на самом деле. Пожалуйста, опишите все как можно подробнее." + "Опишите проблему…" + "Если возможно, пожалуйста, напишите описание на английском языке." + "Описание слишком короткое, пожалуйста, расскажите подробнее о том, что произошло. Спасибо!" + "Отправка журналов сбоев" + "Разрешить ведение журналов" + "Ваши журналы слишком большие для включения в этот отчет. Пожалуйста, отправьте их нам другим способом." + "Отправить снимок экрана" + "Чтобы убедиться, что все работает правильно, в сообщение будут включены журналы. Чтобы отправить сообщение без журналов, отключите эту настройку." + "При последнем использовании %1$s произошел сбой. Хотите поделиться отчетом?" + "Если у вас возникли проблемы с уведомлениями, загрузка настроек уведомлений может помочь нам определить основную причину." + "Настройки отправки уведомлений" + "Просмотр журналов" + 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 0000000..9edad56 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,20 @@ + + + "Priložiť snímku obrazovky" + "V prípade ďalších otázok ma môžete kontaktovať." + "Kontaktujte ma" + "Upraviť snímku obrazovky" + "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í." + "Popíšte chybu…" + "Ak je to možné, napíšte popis v angličtine." + "Popis je príliš krátky, uveďte viac podrobností o tom, čo sa stalo. Ďakujeme!" + "Odoslať záznamy o zlyhaní" + "Povoliť záznamy" + "Vaše záznamy sú príliš veľké, takže ich nemožno zahrnúť do tejto správy, pošlite nám ich prosím iným spôsobom." + "Odoslať snímku obrazovky" + "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." + "%1$s zlyhal pri poslednom použití. Chcete zdieľať správu o páde s našim tímom?" + "Ak máte problémy s upozorneniami, nahranie pravidiel pre push upozornenia nám môže pomôcť určiť príčinu. Upozorňujeme, že tieto pravidlá môžu obsahovať súkromné ​​informácie, ako napríklad vaše zobrazované meno alebo kľúčové slová, na ktoré sa majú dostávať upozornenia." + "Nastavenia odosielania upozornení" + "Zobraziť záznamy" + diff --git a/features/rageshake/impl/src/main/res/values-sv/translations.xml b/features/rageshake/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..9542842 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,18 @@ + + + "Bifoga skärmdump" + "Ni kan kontakta mig om ni har några följdfrågor." + "Kontakta mig" + "Redigera skärmdump" + "Vänligen beskriv problemet. Vad gjorde du? Vad förväntade du dig skulle hända? Vad hände istället? Vänligen gå in i så mycket detaljer som möjligt." + "Beskriv problemet …" + "Om möjligt, skriv beskrivningen på engelska." + "Beskrivningen är för kort, vänligen ge mer information om vad som hände. Tack!" + "Skicka kraschloggar" + "Tillåt loggar" + "Dina loggar är alltför stora och kan därför inte inkluderas i den här rapporten. Vänligen skicka dem till oss på annat sätt." + "Skicka skärmdump" + "Loggar kommer att inkluderas i ditt meddelande för att se till att allt fungerar korrekt. Om du vill skicka ditt meddelande utan loggar stänger du av den här inställningen." + "%1$s kraschade senast den användes. Vill du dela en kraschrapport med oss?" + "Se loggar" + diff --git a/features/rageshake/impl/src/main/res/values-tr/translations.xml b/features/rageshake/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..d558126 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,17 @@ + + + "Ekran görüntüsü ekle" + "Herhangi bir sorunuz olursa benimle iletişime geçebilirsiniz." + "Bana ulaş" + "Ekran görüntüsünü düzenle" + "Lütfen sorunu açıklayın. Ne yaptınız? Ne olmasını bekliyordunuz? Gerçekten ne oldu. Lütfen olabildiğince ayrıntılı bilgi verin." + "Sorunu açıklayın…" + "Mümkünse, lütfen açıklamayı İngilizce olarak yazın." + "Açıklama çok kısa, lütfen ne olduğu hakkında daha fazla ayrıntı verin. Teşekkürler!" + "Hata günlüklerini gönder" + "Günlüklere izin ver" + "Ekran görüntüsü gönder" + "Her şeyin düzgün çalıştığından emin olmak için günlükler mesajınıza dahil edilecektir. Mesajınızı kayıt tutmadan göndermek için bu ayarı kapatın." + "%1$s son kullanıldığında çöktü. Bizimle bir çökme raporu paylaşmak ister misiniz?" + "Günlükleri görüntüle" + diff --git a/features/rageshake/impl/src/main/res/values-uk/translations.xml b/features/rageshake/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..3999133 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,18 @@ + + + "Прикріпити знімок екрана" + "Ви можете зв\'язатися зі мною, якщо у вас виникнуть додаткові запитання." + "Звʼязатися зі мною" + "Редагувати знімок екрана" + "Будь ласка, опишіть проблему. Які дії ви виконали? Який очікуваний результат? Що сталося? Будь ласка, опишіть якомога детальніше." + "Опишіть проблему…" + "Якщо можливо, будь ласка, напишіть опис англійською мовою." + "Опис закороткий, будь ласка, надайте докладнішу інформацію про те, що сталося. Дякуємо!" + "Надіслати журнали збоїв" + "Дозволити журнали" + "Ваші журнали надмірно великі, тому їх не можна включити в цей звіт, будь ласка, надішліть їх нам іншим способом." + "Надіслати знімок екрана" + "Журнали будуть додані до вашого повідомлення, щоб переконатися, що все працює належним чином. Щоб надіслати повідомлення без журналів, вимкніть це налаштування." + "Стався збій %1$s під час останнього користування. Хочете поділитися з нами звітом про збій?" + "Переглянути журнали" + diff --git a/features/rageshake/impl/src/main/res/values-ur/translations.xml b/features/rageshake/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..695c4ec --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,17 @@ + + + "پردۂ عکس منسلک کریں" + "اگر آپ کے پاس کوئی متابعہ سوالات ہیں، تو آپ مجھ سے رابطہ کر سکتے ہیں۔" + "مجھ سے رابطہ کریں" + "پردۂ عکس میں ترمیم کریں" + "براہ کرم مسئلہ بیان کریں۔ آپ نے کیا کیا؟ آپ کو کیا ہونے کی امید تھی؟ اصل میں کیا ہوا۔ براہ کرم جتنا ہو سکے اس کی تفصیل میں جائیں۔" + "مسئلہ بیان کریں…" + "اگر ممکن ہو تو، برائے مہربانی بیان انگریزی میں لکھیں۔" + "تفصیل بہت مختصر ہے، براہ کرم مزید تفصیلات فراہم کریں کہ کیا ہوا۔ شکریہ!" + "ٹکر کے نوشتے بھیجیں" + "نوشتہجات کی اجازت دیں۔" + "پردۂ عکس بھیجیں" + "نوشتہ جات آپکے پیغام کیساتھ شامل کیے جائینگے تاکہ یقینی بنایا جا سکے کہ سب کچھ ٹھیک سے کام کر رہا ہے۔ بغیر نوشتہ جات کے اپنا پیغام بھیجنے کے لیے، اس ترتیب کو بند کریں۔" + "%1$sآخری بار استعمال ہونے پر ٹکرا گیا۔ کیا آپ ہمارے ساتھ ٹکر کی گزارش (رپورٹ) کا اشتراک کرنا چاہیں گے؟" + "نوشتہ جات ملحوظ کریں" + diff --git a/features/rageshake/impl/src/main/res/values-uz/translations.xml b/features/rageshake/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..e3aceaa --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,18 @@ + + + "Ekran tasvirini biriktirish" + "Agar sizda keyingi savollar bo\'lsa, men bilan bog\'lanishingiz mumkin." + "Men bilan bog\'laning" + "Ekran tasvirini tahrirlash" + "Iltimos, muammoni tasvirlab bering. Nima qildingiz? Nima bo\'lishini kutgan edingiz? Aslida nima bo\'ldi. Iltimos, iloji boricha batafsilroq ma\'lumot bering." + "Muammoni tasvirlab bering…" + "Iloji bo\'lsa, tavsifni ingliz tilida yozing." + "Tavsif juda qisqa, nima boʻlganligi haqida batafsilroq maʼlumot bering. Rahmat!" + "Buzilish jurnallarini yuboring" + "Jurnallarga ruxsat bering" + "Sizning jurnallaringiz juda katta, shuning uchun bu hisobotga kiritilmaydi, iltimos ularni bizga boshqa usulda yuboring." + "Ekran tasvirini yuboring" + "Har bir narsa to\'ri ishlayotganiga ishonch hosil qilish uchun xabaringizga jurnallar kiritiladi. Xabarni jurnallarsiz yuborish uchun ushbu sozlamani oʻchiring." + "%1$soxirgi marta ishlatilganda qulab tushdi. Biz bilan nosozlik hisobotini baham ko\'rmoqchimisiz?" + "Jurnallarni ko'rish" + diff --git a/features/rageshake/impl/src/main/res/values-zh-rTW/translations.xml b/features/rageshake/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..10252ab --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,20 @@ + + + "附上螢幕截圖" + "如果有其他問題,你可以聯絡我。" + "聯絡我" + "編輯螢幕截圖" + "請描述問題。你做了什麼?你預期的結果是什麼?實際上發生了什麼事情?請盡可能提供越多細節越好。" + "描述問題…" + "如果方便的話,請使用英文。" + "您的描述太短了,請提供更多細節。謝謝!" + "傳送當機紀錄" + "提供日誌" + "您的紀錄檔太大了,無法包含在此報告中,請透過其他方式傳送給我們。" + "傳送螢幕截圖" + "紀錄檔將包含在您的訊息中以確保一切運作正常。要在不包含紀錄檔的情況下傳送訊息,請關閉此設定。" + "%1$s 上次使用時當機了。您想要與我們分享當機報告嗎?" + "若您遇到通知問題,上傳通知推播規則可以協助我們找出根本原因。請注意,這些規則可能包含您的私人資訊,例如您的顯示名稱或要接收通知的關鍵字。" + "傳送通知設定" + "查看日誌" + diff --git a/features/rageshake/impl/src/main/res/values-zh/translations.xml b/features/rageshake/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..527a35c --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,20 @@ + + + "附上截图" + "如果您有任何后续问题,可以与我联系。" + "联系我" + "编辑截图" + "请尽可能详细地描述问题。您做了什么?您预期会发生什么?实际发生了什么?" + "描述问题…" + "请尽可能用英文描述。" + "描述太短,请提供详细情况。谢谢!" + "发送崩溃日志" + "允许日志" + "日志文件过大,无法包含在本报告中,请通过其他方式发送给我们。" + "发送屏幕截图" + "为确认一切正常运行,您的消息中将包含日志。如要发送不带日志的消息,请关闭此设置。" + "%1$s 上次使用时崩溃了。想和我们分享崩溃报告吗?" + "如果您遇到通知问题,上传通知设置可以帮助我们查明根本原因。" + "发送通知设置" + "查看日志" + 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 0000000..85af2af --- /dev/null +++ b/features/rageshake/impl/src/main/res/values/localazy.xml @@ -0,0 +1,20 @@ + + + "Attach screenshot" + "You may contact me if you have any follow up questions." + "Contact me" + "Edit screenshot" + "Please describe the problem. What did you do? What did you expect to happen? What actually happened. Please go into as much detail as you can." + "Describe the problem…" + "If possible, please write the description in English." + "The description is too short, please provide more details about what happened. Thanks!" + "Send crash logs" + "Allow logs" + "Your logs are excessively large so cannot be included in this report, please send them to us another way." + "Send screenshot" + "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." + "%1$s crashed the last time it was used. Would you like to share a crash report with us?" + "If you are having issues with notifications, uploading the notification push rules can help us pinpoint the root cause. Note these rules can contain private information, such as your display name or keywords to be notified for." + "Send notification settings" + "View logs" + diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/DefaultRageshakeFeatureAvailabilityTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/DefaultRageshakeFeatureAvailabilityTest.kt new file mode 100644 index 0000000..b656d14 --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/DefaultRageshakeFeatureAvailabilityTest.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Test + +class DefaultRageshakeFeatureAvailabilityTest { + @Test + fun `test isAvailable returns true when bug reporter URL is provided`() = runTest { + val flow = MutableStateFlow(null) + val sut = DefaultRageshakeFeatureAvailability( + bugReporterUrlProvider = { flow }, + ) + sut.isAvailable().test { + assertThat(awaitItem()).isFalse() + flow.value = "https://example.com/bugreport".toHttpUrl() + assertThat(awaitItem()).isTrue() + flow.value = null + assertThat(awaitItem()).isFalse() + } + } +} 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 0000000..f5b0508 --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.bugreport + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.rageshake.api.reporter.BugReporter +import io.element.android.features.rageshake.impl.crash.A_CRASH_DATA +import io.element.android.features.rageshake.impl.crash.CrashDataStore +import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore +import io.element.android.features.rageshake.impl.screenshot.A_SCREENSHOT_URI +import io.element.android.features.rageshake.impl.screenshot.FakeScreenshotHolder +import io.element.android.features.rageshake.impl.screenshot.ScreenshotHolder +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.test.A_FAILURE_REASON +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +const val A_SHORT_DESCRIPTION = "bug!" +const val A_LONG_DESCRIPTION = "I have seen a bug!" + +class BugReportPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.hasCrashLogs).isFalse() + assertThat(initialState.formState).isEqualTo(BugReportFormState.Default) + assertThat(initialState.sending).isEqualTo(AsyncAction.Uninitialized) + assertThat(initialState.screenshotUri).isNull() + assertThat(initialState.sendingProgress).isEqualTo(0f) + assertThat(initialState.submitEnabled).isTrue() + } + } + + @Test + fun `present - set description`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(BugReportEvents.SetDescription(A_SHORT_DESCRIPTION)) + assertThat(awaitItem().submitEnabled).isTrue() + initialState.eventSink.invoke(BugReportEvents.SetDescription(A_LONG_DESCRIPTION)) + assertThat(awaitItem().submitEnabled).isTrue() + } + } + + @Test + fun `present - can contact`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.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 = createPresenter() + moleculeFlow(RecompositionMode.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 = createPresenter() + moleculeFlow(RecompositionMode.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 - send notification settings`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(BugReportEvents.SetSendPushRules(true)) + assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendPushRules = true)) + initialState.eventSink.invoke(BugReportEvents.SetSendPushRules(false)) + assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendPushRules = false)) + } + } + + @Test + fun `present - reset all`() = runTest { + val presenter = createPresenter( + crashDataStore = FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), + screenshotHolder = FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), + ) + moleculeFlow(RecompositionMode.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 = createPresenter( + FakeBugReporter(mode = FakeBugReporter.Mode.Success), + FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), + FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(BugReportEvents.SetDescription(A_LONG_DESCRIPTION)) + skipItems(1) + initialState.eventSink.invoke(BugReportEvents.SendBugReport) + skipItems(1) + val progressState = awaitItem() + assertThat(progressState.sending).isEqualTo(AsyncAction.Loading) + 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(AsyncAction.Success(Unit)) + } + } + + @Test + fun `present - send failure`() = runTest { + val presenter = createPresenter( + FakeBugReporter(mode = FakeBugReporter.Mode.Failure), + FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), + FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(BugReportEvents.SetDescription(A_LONG_DESCRIPTION)) + skipItems(1) + initialState.eventSink.invoke(BugReportEvents.SendBugReport) + skipItems(1) + val progressState = awaitItem() + assertThat(progressState.sending).isEqualTo(AsyncAction.Loading) + assertThat(progressState.sendingProgress).isEqualTo(0f) + assertThat(awaitItem().sendingProgress).isEqualTo(0.5f) + // Failure + assertThat(awaitItem().sendingProgress).isEqualTo(0f) + assertThat((awaitItem().sending as AsyncAction.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(AsyncAction.Uninitialized::class.java) + } + } + + @Test + fun `present - send failure description too short`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(BugReportEvents.SetDescription(A_SHORT_DESCRIPTION)) + skipItems(1) + initialState.eventSink.invoke(BugReportEvents.SendBugReport) + val errorState = awaitItem() + assertThat(errorState.sending).isEqualTo(AsyncAction.Failure(BugReportFormError.DescriptionTooShort)) + // Reset failure + initialState.eventSink.invoke(BugReportEvents.ClearError) + val lastItem = awaitItem() + assertThat(lastItem.sending).isInstanceOf(AsyncAction.Uninitialized::class.java) + } + } + + @Test + fun `present - send cancel`() = runTest { + val presenter = createPresenter( + FakeBugReporter(mode = FakeBugReporter.Mode.Cancel), + FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), + FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(BugReportEvents.SetDescription(A_LONG_DESCRIPTION)) + skipItems(1) + initialState.eventSink.invoke(BugReportEvents.SendBugReport) + skipItems(1) + val progressState = awaitItem() + assertThat(progressState.sending).isEqualTo(AsyncAction.Loading) + assertThat(progressState.sendingProgress).isEqualTo(0f) + assertThat(awaitItem().sendingProgress).isEqualTo(0.5f) + // Cancelled + assertThat(awaitItem().sendingProgress).isEqualTo(0f) + assertThat(awaitItem().sending).isEqualTo(AsyncAction.Uninitialized) + } + } +} + +internal fun TestScope.createPresenter( + bugReporter: BugReporter = FakeBugReporter(), + crashDataStore: CrashDataStore = FakeCrashDataStore(), + screenshotHolder: ScreenshotHolder = FakeScreenshotHolder(), +) = BugReportPresenter( + bugReporter = bugReporter, + crashDataStore = crashDataStore, + screenshotHolder = screenshotHolder, + appCoroutineScope = this, +) diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/DefaultBugReportEntryPointTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/DefaultBugReportEntryPointTest.kt new file mode 100644 index 0000000..bf45982 --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/DefaultBugReportEntryPointTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.bugreport + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint +import io.element.android.features.viewfolder.test.FakeViewFolderEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DefaultBugReportEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() = runTest { + val entryPoint = DefaultBugReportEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + BugReportFlowNode( + buildContext = buildContext, + plugins = plugins, + viewFolderEntryPoint = FakeViewFolderEntryPoint(), + ) + } + val callback = object : BugReportEntryPoint.Callback { + override fun onDone() = lambdaError() + } + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + callback = callback, + ) + assertThat(result).isInstanceOf(BugReportFlowNode::class.java) + assertThat(result.plugins).contains(callback) + } +} 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 0000000..36cc185 --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.libraries.matrix.test.A_FAILURE_REASON +import kotlinx.coroutines.delay +import java.io.File + +class FakeBugReporter(val mode: Mode = Mode.Success) : BugReporter { + enum class Mode { + Success, + Failure, + Cancel + } + + override suspend fun sendBugReport( + withDevicesLogs: Boolean, + withCrashLogs: Boolean, + withScreenshot: Boolean, + problemDescription: String, + canContact: Boolean, + sendPushRules: Boolean, + listener: BugReporterListener, + ) { + delay(100) + listener.onProgress(0) + delay(100) + listener.onProgress(50) + delay(100) + when (mode) { + Mode.Success -> Unit + Mode.Failure -> { + listener.onUploadFailed(A_FAILURE_REASON) + return + } + Mode.Cancel -> { + listener.onUploadCancelled() + return + } + } + listener.onProgress(100) + delay(100) + listener.onUploadSucceed() + } + + override fun logDirectory(): File { + return File("fake") + } + + override fun setCurrentTracingLogLevel(logLevel: String) { + // No op + } + + override fun saveLogCat(): File? { + return null + } +} diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/FakeCrashDataStore.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/FakeCrashDataStore.kt new file mode 100644 index 0000000..3ddacd3 --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/FakeCrashDataStore.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.crash + +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 + appHasCrashedFlow.value = true + } + + override suspend fun resetAppHasCrashed() { + appHasCrashedFlow.value = false + } + + override fun appHasCrashed(): Flow = appHasCrashedFlow + + override fun crashInfo(): Flow = crashDataFlow + + override suspend fun reset() { + appHasCrashedFlow.value = false + crashDataFlow.value = "" + } +} diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/VectorUncaughtExceptionHandlerTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/VectorUncaughtExceptionHandlerTest.kt new file mode 100644 index 0000000..665e1ce --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/VectorUncaughtExceptionHandlerTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.crash + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class VectorUncaughtExceptionHandlerTest { + @Test + fun `activate should change the default handler`() { + val sut = VectorUncaughtExceptionHandler(PreferencesCrashDataStore(FakePreferenceDataStoreFactory())) + sut.activate() + assertThat(Thread.getDefaultUncaughtExceptionHandler()).isInstanceOf(VectorUncaughtExceptionHandler::class.java) + } + + @Test + fun `uncaught exception`() = runTest { + val crashDataStore = PreferencesCrashDataStore(FakePreferenceDataStoreFactory()) + assertThat(crashDataStore.appHasCrashed().first()).isFalse() + assertThat(crashDataStore.crashInfo().first()).isEmpty() + val sut = VectorUncaughtExceptionHandler(crashDataStore) + sut.uncaughtException(Thread(), AN_EXCEPTION) + assertThat(crashDataStore.appHasCrashed().first()).isTrue() + val crashInfo = crashDataStore.crashInfo().first() + assertThat(crashInfo).isNotEmpty() + assertThat(crashInfo).contains("Memory statuses") + crashDataStore.resetAppHasCrashed() + assertThat(crashDataStore.appHasCrashed().first()).isFalse() + } +} 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 0000000..dcaaa20 --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/crash/ui/CrashDetectionPresenterTest.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.crash.ui + +import app.cash.molecule.RecompositionMode +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.A_CRASH_DATA +import io.element.android.features.rageshake.impl.crash.DefaultCrashDetectionPresenter +import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class CrashDetectionPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state no crash`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.crashDetected).isFalse() + } + } + + @Test + fun `present - initial state crash`() = runTest { + val presenter = createPresenter( + FakeCrashDataStore(appHasCrashed = true) + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.crashDetected).isTrue() + } + } + + @Test + fun `present - initial state crash is ignored if the feature is not available`() = runTest { + val presenter = createPresenter( + FakeCrashDataStore(appHasCrashed = true), + isFeatureAvailableFlow = flowOf(false), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.crashDetected).isFalse() + } + } + + @Test + fun `present - reset app has crashed`() = runTest { + val presenter = createPresenter( + FakeCrashDataStore(appHasCrashed = true) + ) + moleculeFlow(RecompositionMode.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 = createPresenter( + FakeCrashDataStore(appHasCrashed = true, crashData = A_CRASH_DATA) + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.crashDetected).isTrue() + initialState.eventSink.invoke(CrashDetectionEvents.ResetAllCrashData) + assertThat(awaitItem().crashDetected).isFalse() + } + } + + @Test + fun `present - crashDetected is false if the feature is not available`() = runTest { + val isFeatureAvailableFlow = MutableStateFlow(false) + val crashDataStore = FakeCrashDataStore(appHasCrashed = false) + val presenter = createPresenter( + crashDataStore = crashDataStore, + isFeatureAvailableFlow = isFeatureAvailableFlow, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.crashDetected).isFalse() + crashDataStore.setCrashData("Some crash data") + // No new state + crashDataStore.resetAppHasCrashed() + // No new state + isFeatureAvailableFlow.value = true + crashDataStore.setCrashData("Some crash data") + assertThat(awaitItem().crashDetected).isTrue() + crashDataStore.resetAppHasCrashed() + assertThat(awaitItem().crashDetected).isFalse() + crashDataStore.setCrashData("Some crash data") + assertThat(awaitItem().crashDetected).isTrue() + isFeatureAvailableFlow.value = false + assertThat(awaitItem().crashDetected).isFalse() + } + } + + private fun createPresenter( + crashDataStore: FakeCrashDataStore = FakeCrashDataStore(), + buildMeta: BuildMeta = aBuildMeta(), + isFeatureAvailableFlow: Flow = flowOf(true), + ) = DefaultCrashDetectionPresenter( + buildMeta = buildMeta, + crashDataStore = crashDataStore, + rageshakeFeatureAvailability = { isFeatureAvailableFlow }, + ) +} 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 0000000..d76ab73 --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/detection/RageshakeDetectionPresenterTest.kt @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.detection + +import android.graphics.Bitmap +import app.cash.molecule.RecompositionMode +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.impl.rageshake.FakeRageShake +import io.element.android.features.rageshake.impl.rageshake.FakeRageshakeDataStore +import io.element.android.features.rageshake.impl.screenshot.FakeScreenshotHolder +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.tests.testutils.WarmUpRule +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.BeforeClass +import org.junit.Rule +import org.junit.Test + +class RageshakeDetectionPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + 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, + rageshakeFeatureAvailability = { flowOf(true) }, + ) + ) + moleculeFlow(RecompositionMode.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, + rageshakeFeatureAvailability = { flowOf(true) }, + ) + ) + moleculeFlow(RecompositionMode.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, + rageshakeFeatureAvailability = { flowOf(true) }, + ) + ) + moleculeFlow(RecompositionMode.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, + rageshakeFeatureAvailability = { flowOf(true) }, + ) + ) + moleculeFlow(RecompositionMode.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, + rageshakeFeatureAvailability = { flowOf(true) }, + ) + ) + moleculeFlow(RecompositionMode.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 0000000..8fda5c3 --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/preferences/RageshakePreferencesPresenterTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.preferences + +import app.cash.molecule.RecompositionMode +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.impl.rageshake.A_SENSITIVITY +import io.element.android.features.rageshake.impl.rageshake.FakeRageShake +import io.element.android.features.rageshake.impl.rageshake.FakeRageshakeDataStore +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class RageshakePreferencesPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state available`() = runTest { + val presenter = DefaultRageshakePreferencesPresenter( + FakeRageShake(isAvailableValue = true), + FakeRageshakeDataStore(isEnabled = true), + rageshakeFeatureAvailability = { flowOf(true) }, + ) + moleculeFlow(RecompositionMode.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), + rageshakeFeatureAvailability = { flowOf(true) }, + ) + moleculeFlow(RecompositionMode.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), + rageshakeFeatureAvailability = { flowOf(true) }, + ) + moleculeFlow(RecompositionMode.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), + rageshakeFeatureAvailability = { flowOf(true) }, + ) + moleculeFlow(RecompositionMode.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/impl/src/test/kotlin/io/element/android/features/rageshake/impl/rageshake/FakeRageShake.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/rageshake/FakeRageShake.kt new file mode 100644 index 0000000..08dbe91 --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/rageshake/FakeRageShake.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.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/impl/src/test/kotlin/io/element/android/features/rageshake/impl/rageshake/FakeRageshakeDataStore.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/rageshake/FakeRageshakeDataStore.kt new file mode 100644 index 0000000..122706b --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/rageshake/FakeRageshakeDataStore.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.rageshake + +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 = isEnabledFlow + + override suspend fun setIsEnabled(isEnabled: Boolean) { + isEnabledFlow.value = isEnabled + } + + private val sensitivityFlow = MutableStateFlow(sensitivity) + override fun sensitivity(): Flow = sensitivityFlow + + override suspend fun setSensitivity(sensitivity: Float) { + sensitivityFlow.value = sensitivity + } + + override suspend fun reset() = Unit +} diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt new file mode 100755 index 0000000..f82c578 --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt @@ -0,0 +1,541 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.reporter + +import com.google.common.truth.Truth.assertThat +import io.element.android.appconfig.RageshakeConfig +import io.element.android.features.rageshake.api.reporter.BugReporterListener +import io.element.android.features.rageshake.impl.crash.CrashDataStore +import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore +import io.element.android.features.rageshake.impl.screenshot.FakeScreenshotHolder +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.tracing.TracingService +import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration +import io.element.android.libraries.matrix.test.A_DEVICE_ID +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.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.FakeSdkMetadata +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.matrix.test.tracing.FakeTracingService +import io.element.android.libraries.network.useragent.DefaultUserAgentProvider +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import okhttp3.MultipartReader +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okio.buffer +import okio.source +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class DefaultBugReporterTest { + @Test + fun `test sendBugReport success`() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + ) + server.start() + val sut = createDefaultBugReporter(server = server) + var onUploadCancelledCalled = false + var onUploadFailedCalled = false + val progressValues = mutableListOf() + var onUploadSucceedCalled = false + sut.sendBugReport( + withDevicesLogs = true, + withCrashLogs = true, + withScreenshot = true, + sendPushRules = true, + problemDescription = "a bug occurred", + canContact = true, + listener = object : BugReporterListener { + override fun onUploadCancelled() { + onUploadCancelledCalled = true + } + + override fun onUploadFailed(reason: String?) { + onUploadFailedCalled = true + } + + override fun onProgress(progress: Int) { + progressValues.add(progress) + } + + override fun onUploadSucceed() { + onUploadSucceedCalled = true + } + }, + ) + val request = server.takeRequest() + assertThat(request.path).isEqualTo("/") + assertThat(request.method).isEqualTo("POST") + server.shutdown() + assertThat(onUploadCancelledCalled).isFalse() + assertThat(onUploadFailedCalled).isFalse() + assertThat(progressValues.size).isEqualTo(EXPECTED_NUMBER_OF_PROGRESS_VALUE) + assertThat(onUploadSucceedCalled).isTrue() + } + + @Test + fun `test sendBugReport form data`() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + ) + server.start() + + val mockSessionStore = InMemorySessionStore( + initialList = listOf(aSessionData(sessionId = "@foo:example.com", deviceId = "ABCDEFGH")) + ) + + val fakeEncryptionService = FakeEncryptionService() + + val fakePushRules = "{ content: ... }" + val fakeNotificationSettingsService = FakeNotificationSettingsService( + getRawPushRulesResult = { Result.success(fakePushRules) } + ) + val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService, notificationSettingsService = fakeNotificationSettingsService) + + fakeEncryptionService.givenDeviceKeys("CURVECURVECURVE", "EDKEYEDKEYEDKY") + val sut = createDefaultBugReporter( + server = server, + crashDataStore = FakeCrashDataStore(), + sessionStore = mockSessionStore, + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + ) + + val progressValues = mutableListOf() + sut.sendBugReport( + withDevicesLogs = true, + withCrashLogs = true, + withScreenshot = true, + sendPushRules = true, + problemDescription = "a bug occurred", + canContact = true, + listener = object : BugReporterListener { + override fun onUploadCancelled() {} + + override fun onUploadFailed(reason: String?) {} + + override fun onProgress(progress: Int) { + progressValues.add(progress) + } + + override fun onUploadSucceed() {} + }, + ) + val request = server.takeRequest() + + val foundValues = collectValuesFromFormData(request) + + assertThat(foundValues["app"]).isEqualTo(RageshakeConfig.BUG_REPORT_APP_NAME) + assertThat(foundValues["can_contact"]).isEqualTo("true") + assertThat(foundValues["device_id"]).isEqualTo("ABCDEFGH") + assertThat(foundValues["sdk_sha"]).isEqualTo("123456789") + assertThat(foundValues["user_id"]).isEqualTo("@foo:example.com") + assertThat(foundValues["number_of_accounts"]).isEqualTo("1") + assertThat(foundValues["text"]).isEqualTo("a bug occurred") + assertThat(foundValues["device_keys"]).isEqualTo("curve25519:CURVECURVECURVE, ed25519:EDKEYEDKEYEDKY") + assertThat(foundValues["file"]).contains(fakePushRules) + + // device_key now added given they are not null + // so is the file value for the included push_rules + assertThat(progressValues.size).isEqualTo(EXPECTED_NUMBER_OF_PROGRESS_VALUE + 2) + + server.shutdown() + } + + @Test + fun `test sendBugReport multi accounts`() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + ) + server.start() + + val mockSessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = "@foo:example.com", deviceId = "ABCDEFGH"), + aSessionData(sessionId = A_USER_ID.value, deviceId = A_DEVICE_ID.value), + ) + ) + + val fakeEncryptionService = FakeEncryptionService() + val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService) + + fakeEncryptionService.givenDeviceKeys("CURVECURVECURVE", "EDKEYEDKEYEDKY") + val sut = createDefaultBugReporter( + server = server, + crashDataStore = FakeCrashDataStore(), + sessionStore = mockSessionStore, + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + ) + + val progressValues = mutableListOf() + sut.sendBugReport( + withDevicesLogs = true, + withCrashLogs = true, + withScreenshot = true, + problemDescription = "a bug occurred", + canContact = true, + listener = object : BugReporterListener { + override fun onUploadCancelled() {} + + override fun onUploadFailed(reason: String?) {} + + override fun onProgress(progress: Int) { + progressValues.add(progress) + } + + override fun onUploadSucceed() {} + }, + ) + val request = server.takeRequest() + + val foundValues = collectValuesFromFormData(request) + + assertThat(foundValues["app"]).isEqualTo(RageshakeConfig.BUG_REPORT_APP_NAME) + assertThat(foundValues["can_contact"]).isEqualTo("true") + assertThat(foundValues["device_id"]).isEqualTo("ABCDEFGH") + assertThat(foundValues["sdk_sha"]).isEqualTo("123456789") + assertThat(foundValues["user_id"]).isEqualTo("@foo:example.com") + assertThat(foundValues["number_of_accounts"]).isEqualTo("2") + assertThat(foundValues["text"]).isEqualTo("a bug occurred") + assertThat(foundValues["device_keys"]).isEqualTo("curve25519:CURVECURVECURVE, ed25519:EDKEYEDKEYEDKY") + + // device_key now added given they are not null + assertThat(progressValues.size).isEqualTo(EXPECTED_NUMBER_OF_PROGRESS_VALUE + 1) + + server.shutdown() + } + + @Test + fun `test sendBugReport should not report device_keys if not known`() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + ) + server.start() + + val mockSessionStore = InMemorySessionStore( + initialList = listOf(aSessionData("@foo:example.com", "ABCDEFGH")) + ) + + val fakeEncryptionService = FakeEncryptionService() + val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService) + + fakeEncryptionService.givenDeviceKeys(null, null) + val sut = createDefaultBugReporter( + server = server, + sessionStore = mockSessionStore, + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + ) + + sut.sendBugReport( + withDevicesLogs = true, + withCrashLogs = true, + withScreenshot = true, + problemDescription = "a bug occurred", + canContact = true, + listener = NoopBugReporterListener(), + ) + val request = server.takeRequest() + + val foundValues = collectValuesFromFormData(request) + assertThat(foundValues["device_keys"]).isNull() + server.shutdown() + } + + @Test + fun `test sendBugReport no client provider no session data`() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + ) + server.start() + + val fakeEncryptionService = FakeEncryptionService() + + fakeEncryptionService.givenDeviceKeys(null, null) + val sut = createDefaultBugReporter( + server = server, + crashDataStore = FakeCrashDataStore("I did crash", true), + sessionStore = InMemorySessionStore(), + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(Exception("Mock no client")) }) + ) + + sut.sendBugReport( + withDevicesLogs = true, + withCrashLogs = true, + withScreenshot = true, + problemDescription = "a bug occurred", + canContact = true, + listener = NoopBugReporterListener(), + ) + val request = server.takeRequest() + + val foundValues = collectValuesFromFormData(request) + println("## FOUND VALUES $foundValues") + assertThat(foundValues["device_keys"]).isNull() + assertThat(foundValues["device_id"]).isEqualTo("undefined") + assertThat(foundValues["user_id"]).isEqualTo("undefined") + assertThat(foundValues["number_of_accounts"]).isEqualTo("0") + assertThat(foundValues["label"]).isEqualTo("crash") + } + + private fun collectValuesFromFormData(request: RecordedRequest): HashMap { + val boundary = request.headers["Content-Type"]!!.split("=").last() + val foundValues = HashMap() + request.body.inputStream().source().buffer().use { + val multipartReader = MultipartReader(it, boundary) + // Just use simple parsing to detect basic properties + val regex = "form-data; name=\"(\\w*)\".*".toRegex() + multipartReader.use { + var part = multipartReader.nextPart() + while (part != null) { + part.headers["Content-Disposition"]?.let { contentDisposition -> + regex.find(contentDisposition)?.groupValues?.get(1)?.let { name -> + foundValues.put(name, part!!.body.readUtf8()) + } + } + part = multipartReader.nextPart() + } + } + } + return foundValues + } + + @Test + fun `test sendBugReport error`() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(400) + .setBody("""{"error": "An error body"}""") + ) + server.start() + val sut = createDefaultBugReporter(server = server) + var onUploadCancelledCalled = false + var onUploadFailedCalled = false + var onUploadFailedReason: String? = null + val progressValues = mutableListOf() + var onUploadSucceedCalled = false + sut.sendBugReport( + withDevicesLogs = true, + withCrashLogs = true, + withScreenshot = true, + sendPushRules = true, + problemDescription = "a bug occurred", + canContact = true, + listener = object : BugReporterListener { + override fun onUploadCancelled() { + onUploadCancelledCalled = true + } + + override fun onUploadFailed(reason: String?) { + onUploadFailedCalled = true + onUploadFailedReason = reason + } + + override fun onProgress(progress: Int) { + progressValues.add(progress) + } + + override fun onUploadSucceed() { + onUploadSucceedCalled = true + } + }, + ) + val request = server.takeRequest() + assertThat(request.path).isEqualTo("/") + assertThat(request.method).isEqualTo("POST") + server.shutdown() + assertThat(onUploadCancelledCalled).isFalse() + assertThat(onUploadFailedCalled).isTrue() + assertThat(onUploadFailedReason).isEqualTo("An error body") + assertThat(progressValues.size).isEqualTo(EXPECTED_NUMBER_OF_PROGRESS_VALUE) + assertThat(onUploadSucceedCalled).isFalse() + } + + @Test + fun `the log directory is initialized using the last session store data`() = runTest { + val sut = createDefaultBugReporter( + buildMeta = aBuildMeta(isEnterpriseBuild = true), + sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData(sessionId = "@alice:domain.com")) + ) + ) + assertThat(sut.logDirectory().absolutePath).endsWith("/cache/logs/domain.com") + } + + @Test + fun `foss build - the log directory is initialized to the root log directory`() = runTest { + val sut = createDefaultBugReporter( + sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData(sessionId = "@alice:domain.com")) + ) + ) + assertThat(sut.logDirectory().absolutePath).endsWith("/cache/logs") + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `when a session is added, the tracing service is invoked`() = runTest { + var param: WriteToFilesConfiguration? = null + val updateWriteToFilesConfigurationResult = lambdaRecorder { + param = it + } + val sessionStore = InMemorySessionStore() + createDefaultBugReporter( + buildMeta = aBuildMeta(isEnterpriseBuild = true), + sessionStore = sessionStore, + tracingService = FakeTracingService( + updateWriteToFilesConfigurationResult = updateWriteToFilesConfigurationResult, + ), + ) + sessionStore.addSession(aSessionData(sessionId = "@alice:server.org")) + runCurrent() + updateWriteToFilesConfigurationResult.assertions().isCalledOnce() + assertThat(param).isNotNull() + assertThat(param).isInstanceOf(WriteToFilesConfiguration.Enabled::class.java) + assertThat((param as WriteToFilesConfiguration.Enabled).directory).endsWith("/cache/logs/server.org") + assertThat((param as WriteToFilesConfiguration.Enabled).filenamePrefix).isEqualTo("logs") + assertThat((param as WriteToFilesConfiguration.Enabled).numberOfFiles).isEqualTo(168) + assertThat((param as WriteToFilesConfiguration.Enabled).filenameSuffix).isEqualTo("log") + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `when another session is added on same domain, the tracing service is not invoked`() = runTest { + val updateWriteToFilesConfigurationResult = lambdaRecorder {} + val sessionStore = InMemorySessionStore() + createDefaultBugReporter( + buildMeta = aBuildMeta(isEnterpriseBuild = true), + sessionStore = sessionStore, + tracingService = FakeTracingService( + updateWriteToFilesConfigurationResult = updateWriteToFilesConfigurationResult, + ), + ) + sessionStore.addSession(aSessionData(sessionId = "@alice:server.org")) + runCurrent() + updateWriteToFilesConfigurationResult.assertions().isCalledOnce() + sessionStore.addSession(aSessionData(sessionId = "@bob:server.org")) + runCurrent() + updateWriteToFilesConfigurationResult.assertions().isCalledOnce() + } + + @Test + fun `foss build - when a session is added, the tracing service is not invoked`() = runTest { + val updateWriteToFilesConfigurationResult = lambdaRecorder {} + val sessionStore = InMemorySessionStore() + createDefaultBugReporter( + tracingService = FakeTracingService( + updateWriteToFilesConfigurationResult = updateWriteToFilesConfigurationResult, + ), + sessionStore = sessionStore, + ) + sessionStore.addSession(aSessionData(sessionId = "@alice:server.org")) + updateWriteToFilesConfigurationResult.assertions().isNeverCalled() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `when the user signs out, the tracing service is invoked`() = runTest { + var param: WriteToFilesConfiguration? = null + val updateWriteToFilesConfigurationResult = lambdaRecorder { + param = it + } + val sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData(sessionId = "@alice:server.org")), + ) + createDefaultBugReporter( + buildMeta = aBuildMeta(isEnterpriseBuild = true), + tracingService = FakeTracingService( + updateWriteToFilesConfigurationResult = updateWriteToFilesConfigurationResult, + ), + sessionStore = sessionStore, + ) + sessionStore.removeSession("@alice:server.org") + runCurrent() + updateWriteToFilesConfigurationResult.assertions().isCalledOnce() + assertThat(param).isNotNull() + assertThat(param).isInstanceOf(WriteToFilesConfiguration.Enabled::class.java) + assertThat((param as WriteToFilesConfiguration.Enabled).directory).endsWith("/cache/logs") + assertThat((param as WriteToFilesConfiguration.Enabled).filenamePrefix).isEqualTo("logs") + assertThat((param as WriteToFilesConfiguration.Enabled).numberOfFiles).isEqualTo(168) + assertThat((param as WriteToFilesConfiguration.Enabled).filenameSuffix).isEqualTo("log") + } + + @Test + fun `foss build - when the log directory is reset, the tracing service is not invoked`() = runTest { + val updateWriteToFilesConfigurationResult = lambdaRecorder {} + val sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData(sessionId = "@alice:server.org")), + ) + createDefaultBugReporter( + tracingService = FakeTracingService( + updateWriteToFilesConfigurationResult = updateWriteToFilesConfigurationResult, + ), + sessionStore = sessionStore, + ) + sessionStore.removeSession("@alice:server.org") + updateWriteToFilesConfigurationResult.assertions().isNeverCalled() + } + + private fun TestScope.createDefaultBugReporter( + buildMeta: BuildMeta = aBuildMeta(), + sessionStore: SessionStore = InMemorySessionStore(), + matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(), + crashDataStore: CrashDataStore = FakeCrashDataStore(), + server: MockWebServer = MockWebServer(), + tracingService: TracingService = FakeTracingService(), + ): DefaultBugReporter { + return DefaultBugReporter( + context = RuntimeEnvironment.getApplication(), + appCoroutineScope = backgroundScope, + screenshotHolder = FakeScreenshotHolder(), + crashDataStore = crashDataStore, + coroutineDispatchers = testCoroutineDispatchers(), + okHttpClient = { OkHttpClient.Builder().build() }, + userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")), + sessionStore = sessionStore, + buildMeta = buildMeta, + bugReporterUrlProvider = { flowOf(server.url("/")) }, + sdkMetadata = FakeSdkMetadata("123456789"), + matrixClientProvider = matrixClientProvider, + tracingService = tracingService, + ) + } + + companion object { + private const val EXPECTED_NUMBER_OF_PROGRESS_VALUE = 18 + } +} diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterUrlProviderTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterUrlProviderTest.kt new file mode 100644 index 0000000..67af12c --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterUrlProviderTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.reporter + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.appconfig.RageshakeConfig +import io.element.android.features.enterprise.api.BugReportUrl +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import kotlinx.coroutines.test.runTest +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Test + +class DefaultBugReporterUrlProviderTest { + @Test + fun `provide returns values when there is an rageshake app name`() = runTest { + val enterpriseService = FakeEnterpriseService() + val sut = createDefaultBugReporterUrlProvider( + bugReportAppNameProvider = { "rageshakeAppName" }, + enterpriseService = enterpriseService, + ) + sut.provide().test { + assertThat(awaitItem()).isEqualTo( + RageshakeConfig.BUG_REPORT_URL.takeIf { it.isNotEmpty() }?.toHttpUrl() + ) + enterpriseService.bugReportUrlMutableFlow.emit(BugReportUrl.Disabled) + assertThat(awaitItem()).isNull() + enterpriseService.bugReportUrlMutableFlow.emit(BugReportUrl.Custom("https://aURL.org")) + assertThat(awaitItem()).isEqualTo("https://aURL.org".toHttpUrl()) + } + } + + @Test + fun `provide returns null when there is no rageshake app name`() = runTest { + val sut = createDefaultBugReporterUrlProvider() + sut.provide().test { + assertThat(awaitItem()).isNull() + awaitComplete() + } + } +} + +private fun createDefaultBugReporterUrlProvider( + bugReportAppNameProvider: BugReportAppNameProvider = BugReportAppNameProvider { "" }, + enterpriseService: EnterpriseService = FakeEnterpriseService(), + sessionStore: SessionStore = InMemorySessionStore(), +) = DefaultBugReporterUrlProvider( + bugReportAppNameProvider = bugReportAppNameProvider, + enterpriseService = enterpriseService, + sessionStore = sessionStore, +) diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/NoopBugReporterListener.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/NoopBugReporterListener.kt new file mode 100644 index 0000000..557f0c9 --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/NoopBugReporterListener.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.reporter + +import io.element.android.features.rageshake.api.reporter.BugReporterListener + +class NoopBugReporterListener : BugReporterListener { + override fun onUploadCancelled() = Unit + override fun onUploadFailed(reason: String?) = Unit + override fun onProgress(progress: Int) = Unit + override fun onUploadSucceed() = Unit +} diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/screenshot/FakeScreenshotHolder.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/screenshot/FakeScreenshotHolder.kt new file mode 100644 index 0000000..543f8e5 --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/screenshot/FakeScreenshotHolder.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.impl.screenshot + +import android.graphics.Bitmap + +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/rageshake/test/build.gradle.kts b/features/rageshake/test/build.gradle.kts new file mode 100644 index 0000000..afc5e51 --- /dev/null +++ b/features/rageshake/test/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.rageshake.test" +} + +dependencies { + implementation(projects.features.rageshake.api) + implementation(libs.coroutines.core) + implementation(projects.tests.testutils) +} diff --git a/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeLogFilesRemover.kt b/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeLogFilesRemover.kt new file mode 100644 index 0000000..9bf00c8 --- /dev/null +++ b/features/rageshake/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeLogFilesRemover.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rageshake.test.logs + +import io.element.android.features.rageshake.api.logs.LogFilesRemover +import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder +import io.element.android.tests.testutils.lambda.lambdaRecorder +import java.io.File + +class FakeLogFilesRemover( + val performLambda: LambdaOneParamRecorder<(File) -> Boolean, Unit> = lambdaRecorder<(File) -> Boolean, Unit> { }, +) : LogFilesRemover { + override suspend fun perform(predicate: (File) -> Boolean) { + performLambda(predicate) + } +} diff --git a/features/reportroom/api/build.gradle.kts b/features/reportroom/api/build.gradle.kts new file mode 100644 index 0000000..8331848 --- /dev/null +++ b/features/reportroom/api/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.reportroom.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/reportroom/api/src/main/kotlin/io/element/android/features/reportroom/api/ReportRoomEntryPoint.kt b/features/reportroom/api/src/main/kotlin/io/element/android/features/reportroom/api/ReportRoomEntryPoint.kt new file mode 100644 index 0000000..ea99507 --- /dev/null +++ b/features/reportroom/api/src/main/kotlin/io/element/android/features/reportroom/api/ReportRoomEntryPoint.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.reportroom.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.matrix.api.core.RoomId + +fun interface ReportRoomEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + roomId: RoomId, + ): Node +} diff --git a/features/reportroom/impl/build.gradle.kts b/features/reportroom/impl/build.gradle.kts new file mode 100644 index 0000000..c945465 --- /dev/null +++ b/features/reportroom/impl/build.gradle.kts @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +import extension.setupDependencyInjection +import extension.testCommonDependencies + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.reportroom.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + api(projects.features.reportroom.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) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) +} diff --git a/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/DefaultReportRoomEntryPoint.kt b/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/DefaultReportRoomEntryPoint.kt new file mode 100644 index 0000000..1d4ee13 --- /dev/null +++ b/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/DefaultReportRoomEntryPoint.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.reportroom.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.reportroom.api.ReportRoomEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.matrix.api.core.RoomId + +@ContributesBinding(AppScope::class) +class DefaultReportRoomEntryPoint : ReportRoomEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + roomId: RoomId, + ): Node { + return parentNode.createNode(buildContext, plugins = listOf(ReportRoomNode.Inputs(roomId))) + } +} diff --git a/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoom.kt b/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoom.kt new file mode 100644 index 0000000..45f6c30 --- /dev/null +++ b/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoom.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.reportroom.impl + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId + +interface ReportRoom { + suspend operator fun invoke( + roomId: RoomId, + shouldReport: Boolean, + reason: String, + shouldLeave: Boolean, + ): Result + + sealed class Exception : kotlin.Exception() { + data object RoomNotFound : Exception() + data object LeftRoomFailed : Exception() + data object ReportRoomFailed : Exception() + } +} + +@ContributesBinding(SessionScope::class) +class DefaultReportRoom( + private val client: MatrixClient, +) : ReportRoom { + override suspend operator fun invoke( + roomId: RoomId, + shouldReport: Boolean, + reason: String, + shouldLeave: Boolean + ): Result { + val room = client.getRoom(roomId) + ?: return Result.failure(ReportRoom.Exception.RoomNotFound) + + if (shouldReport) { + room + .reportRoom(reason.takeIf { it.isNotBlank() }) + .onFailure { + return Result.failure(ReportRoom.Exception.ReportRoomFailed) + } + } + if (shouldLeave) { + room + .leave() + .onFailure { + return Result.failure(ReportRoom.Exception.LeftRoomFailed) + } + } + return Result.success(Unit) + } +} diff --git a/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomEvents.kt b/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomEvents.kt new file mode 100644 index 0000000..f7185f7 --- /dev/null +++ b/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomEvents.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.reportroom.impl + +sealed interface ReportRoomEvents { + data class UpdateReason(val reason: String) : ReportRoomEvents + data object ToggleLeaveRoom : ReportRoomEvents + data object Report : ReportRoomEvents + data object ClearReportAction : ReportRoomEvents +} diff --git a/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomNode.kt b/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomNode.kt new file mode 100644 index 0000000..ef11730 --- /dev/null +++ b/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomNode.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.reportroom.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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId + +@ContributesNode(SessionScope::class) +@AssistedInject +class ReportRoomNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: ReportRoomPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + data class Inputs(val roomId: RoomId) : NodeInputs + + private val roomId = inputs().roomId + private val presenter: ReportRoomPresenter = presenterFactory.create(roomId = roomId) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + ReportRoomView( + state = state, + modifier = modifier, + onBackClick = ::navigateUp, + ) + } +} diff --git a/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomPresenter.kt b/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomPresenter.kt new file mode 100644 index 0000000..422cf42 --- /dev/null +++ b/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomPresenter.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.reportroom.impl + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@AssistedInject +class ReportRoomPresenter( + @Assisted private val roomId: RoomId, + private val reportRoom: ReportRoom, +) : Presenter { + @AssistedFactory + fun interface Factory { + fun create(roomId: RoomId): ReportRoomPresenter + } + + @Composable + override fun present(): ReportRoomState { + var reason by rememberSaveable { mutableStateOf("") } + var leaveRoom by rememberSaveable { mutableStateOf(false) } + var reportAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + + val coroutineScope = rememberCoroutineScope() + + fun handleEvent(event: ReportRoomEvents) { + when (event) { + ReportRoomEvents.Report -> coroutineScope.reportRoom(reason, leaveRoom, reportAction) + ReportRoomEvents.ToggleLeaveRoom -> { + leaveRoom = !leaveRoom + } + is ReportRoomEvents.UpdateReason -> { + reason = event.reason + } + ReportRoomEvents.ClearReportAction -> { + reportAction.value = AsyncAction.Uninitialized + } + } + } + return ReportRoomState( + reason = reason, + leaveRoom = leaveRoom, + reportAction = reportAction.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.reportRoom( + reason: String, + shouldLeave: Boolean, + action: MutableState> + ) = launch { + val previousFailure = action.value as? AsyncAction.Failure + val shouldReport = previousFailure?.error !is ReportRoom.Exception.LeftRoomFailed + runUpdatingState(action) { + reportRoom( + roomId = roomId, + shouldReport = shouldReport, + reason = reason, + shouldLeave = shouldLeave + ) + } + } +} diff --git a/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomState.kt b/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomState.kt new file mode 100644 index 0000000..fa09306 --- /dev/null +++ b/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomState.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.reportroom.impl + +import io.element.android.libraries.architecture.AsyncAction + +data class ReportRoomState( + val reason: String, + val leaveRoom: Boolean, + val reportAction: AsyncAction, + val eventSink: (ReportRoomEvents) -> Unit +) { + val canReport: Boolean = reason.isNotBlank() +} diff --git a/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomStateProvider.kt b/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomStateProvider.kt new file mode 100644 index 0000000..8baae08 --- /dev/null +++ b/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomStateProvider.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.reportroom.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction + +open class ReportRoomStateProvider : PreviewParameterProvider { + companion object { + private const val A_REPORT_ROOM_REASON = "Inappropriate content" + } + + override val values: Sequence + get() = sequenceOf( + aReportRoomState(), + aReportRoomState(reason = A_REPORT_ROOM_REASON), + aReportRoomState(leaveRoom = true), + aReportRoomState(reason = A_REPORT_ROOM_REASON, reportAction = AsyncAction.Loading), + aReportRoomState(reason = A_REPORT_ROOM_REASON, reportAction = AsyncAction.Failure(Exception("Failed to report"))), + ) +} + +fun aReportRoomState( + reason: String = "", + leaveRoom: Boolean = false, + reportAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (ReportRoomEvents) -> Unit = {} +) = ReportRoomState( + reason = reason, + leaveRoom = leaveRoom, + reportAction = reportAction, + eventSink = eventSink, +) diff --git a/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomView.kt b/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomView.kt new file mode 100644 index 0000000..4ab1a51 --- /dev/null +++ b/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomView.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.reportroom.impl + +import androidx.compose.foundation.layout.Column +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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ListItem +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.TextField +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReportRoomView( + state: ReportRoomState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + + val isReporting = state.reportAction is AsyncAction.Loading + AsyncActionView( + async = state.reportAction, + onSuccess = { onBackClick() }, + errorTitle = { failure -> + when (failure) { + is ReportRoom.Exception.LeftRoomFailed -> stringResource(R.string.screen_report_room_leave_failed_alert_title) + else -> stringResource(CommonStrings.dialog_title_error) + } + }, + errorMessage = { failure -> + when (failure) { + is ReportRoom.Exception.LeftRoomFailed -> stringResource(R.string.screen_report_room_leave_failed_alert_message) + else -> stringResource(CommonStrings.error_unknown) + } + }, + onRetry = { + state.eventSink(ReportRoomEvents.Report) + }, + onErrorDismiss = { state.eventSink(ReportRoomEvents.ClearReportAction) } + ) + + Scaffold( + topBar = { + TopAppBar( + titleStr = stringResource(R.string.screen_report_room_title), + navigationIcon = { + BackButton(onClick = onBackClick) + } + ) + }, + modifier = modifier + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + .imePadding() + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(vertical = 16.dp) + ) { + TextField( + value = state.reason, + onValueChange = { state.eventSink(ReportRoomEvents.UpdateReason(it)) }, + placeholder = stringResource(R.string.screen_report_room_reason_placeholder), + minLines = 3, + enabled = !isReporting, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .heightIn(min = 90.dp), + supportingText = stringResource(R.string.screen_report_room_reason_footer), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + ListItem( + modifier = Modifier.padding(end = 8.dp), + headlineContent = { + Text(text = stringResource(CommonStrings.action_leave_room)) + }, + onClick = { + state.eventSink(ReportRoomEvents.ToggleLeaveRoom) + }, + trailingContent = ListItemContent.Switch(checked = state.leaveRoom) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + text = stringResource(CommonStrings.action_report), + enabled = state.canReport && !isReporting, + destructive = true, + showProgress = isReporting, + onClick = { + focusManager.clearFocus(force = true) + state.eventSink(ReportRoomEvents.Report) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun ReportRoomViewPreview( + @PreviewParameter(ReportRoomStateProvider::class) state: ReportRoomState +) = ElementPreview { + ReportRoomView( + state = state, + onBackClick = {}, + ) +} diff --git a/features/reportroom/impl/src/main/res/values-bg/translations.xml b/features/reportroom/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..aeb51d3 --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,4 @@ + + + "Докладване на стаята" + diff --git a/features/reportroom/impl/src/main/res/values-cs/translations.xml b/features/reportroom/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..809cfb6 --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,8 @@ + + + "Vaše hlášení bylo úspěšně odesláno, ale při pokusu o opuštění místnosti jsme narazili na problém. Zkuste to prosím znovu." + "Nelze opustit místnost" + "Nahlaste tuto místnost svému administrátorovi. Pokud jsou zprávy zašifrované, váš administrátor je nebude moci číst." + "Popište důvod…" + "Nahlásit místnost" + diff --git a/features/reportroom/impl/src/main/res/values-cy/translations.xml b/features/reportroom/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..89c44c2 --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,8 @@ + + + "Cyflwynwyd eich adroddiad yn llwyddiannus, ond cododd problem wrth geisio gadael yr ystafell. Ceisiwch eto." + "Methu Gadael yr Ystafell" + "Adroddwch yr ystafell hon i\'ch gweinyddwr. Os yw\'r negeseuon wedi\'u hamgryptio, fydd eich gweinyddwr ddim yn gallu eu darllen." + "Disgrifiwch y rheswm…" + "Adrodd ar ystafell" + diff --git a/features/reportroom/impl/src/main/res/values-da/translations.xml b/features/reportroom/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..0f2a9b2 --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,8 @@ + + + "Din anmeldelse blev indsendt med succes, men vi stødte på et problem, da vi forsøgte at forlade rummet. Prøv venligst igen." + "Ude af stand til at forlade rummet" + "Anmeld dette rum til din administrator. Hvis meddelelserne er krypteret, kan din administrator ikke læse dem." + "Beskriv årsagen til anmeldelsen…" + "Anmeld rummet" + diff --git a/features/reportroom/impl/src/main/res/values-de/translations.xml b/features/reportroom/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..fe7adff --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,8 @@ + + + "Deine Meldung wurde erfolgreich übermittelt. Beim Versuch, den Chat zu verlassen, ist allerdings ein Problem aufgetreten. Bitte versuche es erneut." + "Der Chat kann nicht verlassen werden" + "Melde diesen Chat deinem Administrator. Wenn die Nachrichten verschlüsselt sind, kann dein Administrator sie nicht lesen." + "Beschreibe den Grund für die Meldung…" + "Chat melden" + diff --git a/features/reportroom/impl/src/main/res/values-el/translations.xml b/features/reportroom/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..274c6bc --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,8 @@ + + + "Η αναφορά σας υποβλήθηκε με επιτυχία, αλλά αντιμετωπίσαμε ένα πρόβλημα κατά την προσπάθεια εξόδου από την αίθουσα. Παρακαλώ προσπαθήστε ξανά." + "Δεν είναι δυνατή η έξοδος από την αίθουσα" + "Αναφέρετε αυτήν την αίθουσα στον διαχειριστή σας. Εάν τα μηνύματα είναι κρυπτογραφημένα, ο διαχειριστής σας δεν θα μπορεί να τα διαβάσει." + "Περιγράψτε τον λόγο αναφοράς…" + "Αναφορά αίθουσας" + diff --git a/features/reportroom/impl/src/main/res/values-es/translations.xml b/features/reportroom/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..7391dc7 --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,8 @@ + + + "Tu denuncia se ha enviado correctamente, pero hemos encontrado un problema al intentar salir de la sala. Inténtalo de nuevo." + "No se pudo salir de la sala" + "Denuncia esta sala a tu administrador. Si los mensajes están cifrados, tu administrador no podrá leerlos." + "Describe el motivo de la denuncia…" + "Denunciar sala" + diff --git a/features/reportroom/impl/src/main/res/values-et/translations.xml b/features/reportroom/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..1cc6e6b --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,8 @@ + + + "Jututoast haldajale teatamine õnnestus, kuid jututost lahkumisel tekkis viga. Palun proovi uuesti lahkuda." + "Pole võimalik lahkuda jututoast" + "Teata sellest jututoast süsteemi haldajale. Kui sõnumid on krüptitud, ei saa haldaja neid lugeda." + "Kirjelda põhjust…" + "Teata jututoast" + diff --git a/features/reportroom/impl/src/main/res/values-eu/translations.xml b/features/reportroom/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..bc8f50e --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,4 @@ + + + "Salatu gela" + diff --git a/features/reportroom/impl/src/main/res/values-fa/translations.xml b/features/reportroom/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..9930e9c --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,6 @@ + + + "ناتوان از ترک اتاق" + "شرح دلیل گزارش…" + "گزارش اتاق" + diff --git a/features/reportroom/impl/src/main/res/values-fi/translations.xml b/features/reportroom/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..cf23910 --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,8 @@ + + + "Ilmoituksesi lähetettiin onnistuneesti, mutta kohtasimme ongelman yrittäessämme poistua huoneesta. Yritä uudelleen." + "Huoneesta poistuminen epäonnistui" + "Ilmoita tästä huoneesta palvelimesi ylläpitäjälle. Jos viestit on salattu, ylläpitäjäsi ei voi lukea niitä." + "Kuvaile syytä…" + "Ilmoita huoneesta" + diff --git a/features/reportroom/impl/src/main/res/values-fr/translations.xml b/features/reportroom/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..4936a4d --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,8 @@ + + + "Votre rapport a été envoyé avec succès, mais nous avons rencontré un problème en essayant de quitter le salon. Veuillez réessayer." + "Impossible de quitter le salon" + "Signaler ce salon à votre admin. Si les messages sont chiffrés, votre admin ne pourra pas les lire." + "Décrivez la raison du signalement…" + "Signaler le salon" + diff --git a/features/reportroom/impl/src/main/res/values-hu/translations.xml b/features/reportroom/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..9e2a389 --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,8 @@ + + + "A jelentése sikeresen el lett küldve, de hibát találtunk a szoba elhagyása során. Próbálja újra." + "Nem tudja elhagyni a szobát" + "A szoba jelentése az adminisztrátoroknak. Ha az üzenetek titkosítva vannak, akkor az adminisztrátor nem fogja tudni elolvasni őket." + "Írja le az okot…" + "Szoba jelentése" + diff --git a/features/reportroom/impl/src/main/res/values-in/translations.xml b/features/reportroom/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..c276158 --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,8 @@ + + + "Laporan Anda berhasil dikirimkan, tetapi kami mengalami masalah saat mencoba meninggalkan ruangan. Silakan coba lagi." + "Tidak Dapat Meninggalkan Ruangan" + "Laporkan ruangan ini ke admin Anda. Jika pesan dienkripsi, admin Anda tidak akan dapat membacanya." + "Jelaskan alasan untuk melaporkan…" + "Laporkan ruangan" + diff --git a/features/reportroom/impl/src/main/res/values-it/translations.xml b/features/reportroom/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..bdb74b9 --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,8 @@ + + + "La tua segnalazione è stata inviata con successo, ma abbiamo riscontrato un problema durante il tentativo di lasciare la stanza. Per favore riprova." + "Impossibile lasciare la stanza" + "Segnala questa stanza al tuo amministratore. Se i messaggi sono cifrati, l\'amministratore non sarà in grado di leggerli." + "Descrivi il motivo della segnalazione…" + "Segnala stanza" + diff --git a/features/reportroom/impl/src/main/res/values-ko/translations.xml b/features/reportroom/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..30096de --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,8 @@ + + + "신고가 성공적으로 제출되었지만, 방을 나가려고 하는 중에 문제가 발생했습니다. 다시 시도해 주세요." + "방을 나갈 수 없습니다" + "이 방을 관리자에게 신고하세요. 메시지가 암호화되어 있는 경우, 관리자는 메시지를 읽을 수 없습니다." + "신고 사유를 설명하세요…" + "방 신고" + diff --git a/features/reportroom/impl/src/main/res/values-nb/translations.xml b/features/reportroom/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..5d82222 --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,8 @@ + + + "Rapporten din ble sendt inn, men vi oppdaget et problem da vi prøvde å forlate rommet. Prøv igjen." + "Kan ikke forlate rommet" + "Rapporter dette rommet til administratoren din. Hvis meldingene er kryptert, vil administratoren ikke kunne lese dem." + "Beskriv årsaken…" + "Rapporter rommet" + diff --git a/features/reportroom/impl/src/main/res/values-nl/translations.xml b/features/reportroom/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..22252f0 --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,4 @@ + + + "Kamer melden" + diff --git a/features/reportroom/impl/src/main/res/values-pl/translations.xml b/features/reportroom/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..dd1b928 --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,8 @@ + + + "Twoje zgłoszenie zostało wysłane pomyślnie, ale napotkaliśmy problem podczas opuszczania pokoju. Spróbuj ponownie." + "Nie można wyjść z pokoju" + "Zgłoś ten pokój swojemu administratorowi. Jeśli wiadomości są zaszyfrowane, administrator nie będzie mógł ich odczytać." + "Opisz powód…" + "Zgłoś pokój" + diff --git a/features/reportroom/impl/src/main/res/values-pt-rBR/translations.xml b/features/reportroom/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..2dd386f --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,8 @@ + + + "Sua denúncia foi enviada com sucesso, mas encontramos um problema ao tentar sair da sala. Tente novamente." + "Não foi possível sair da sala" + "Denuncie esta sala ao seu administrador. Se as mensagens estiverem criptografadas, seu administrador não poderá lê-las." + "Descreva o motivo para denunciar…" + "Denunciar sala" + diff --git a/features/reportroom/impl/src/main/res/values-pt/translations.xml b/features/reportroom/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..9d11026 --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,8 @@ + + + "O teu relatório foi submetido com sucesso, mas houve um problema ao tentar sair da sala. Por favor, tenta novamente." + "Não foi possível sair da sala" + "Denuncia esta sala aos administradores. Se as mensagens estiverem cifradas, os administradores não as poderão ler." + "Descreve a razão para denunciar…" + "Denunciar sala" + diff --git a/features/reportroom/impl/src/main/res/values-ro/translations.xml b/features/reportroom/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..c96d170 --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,8 @@ + + + "Raportul dumneavoastră a fost trimis cu succes, dar am întâmpinat o problemă în timp ce încercam să părăsim camera. Vă rugăm să încercați din nou." + "Nu s-a putut părăsi camera" + "Raportați această cameră administratorului. Dacă mesaje sunt criptate, administratorul nu le va putea citi." + "Descrieți motivul raportării…" + "Raportați camera" + diff --git a/features/reportroom/impl/src/main/res/values-ru/translations.xml b/features/reportroom/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..6ab5600 --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,8 @@ + + + "Ваш отчет был успешно отправлен, но мы столкнулись с проблемой при попытке покинуть комнату. Пожалуйста, попробуйте еще раз." + "Невозможно покинуть комнату" + "Сообщите об этой комнате своему администратору. Если сообщения зашифрованы, ваш администратор не сможет их прочитать." + "Опишите причину жалобы…" + "Комната отчетов" + diff --git a/features/reportroom/impl/src/main/res/values-sk/translations.xml b/features/reportroom/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..87abe2f --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,8 @@ + + + "Vaša správa bola úspešne odoslaná, ale pri pokuse o opustenie miestnosti sme narazili na problém. Skúste to prosím znova." + "Nie je možné opustiť miestnosť" + "Nahláste túto miestnosť svojmu správcovi. Ak sú správy zašifrované, váš správca ich nebude môcť prečítať." + "Popíšte dôvod…" + "Nahlásiť miestnosť" + diff --git a/features/reportroom/impl/src/main/res/values-sv/translations.xml b/features/reportroom/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..8c824b9 --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,8 @@ + + + "Din anmälan skickades in framgångsrikt, men vi stötte på ett problem när vi försökte lämna rummet. Vänligen försök igen." + "Kunde inte lämna rummet" + "Anmäl det här rummet till din administratör. Om meddelandena är krypterade kommer din administratör inte att kunna läsa dem." + "Beskriv anledningen …" + "Anmäl rum" + diff --git a/features/reportroom/impl/src/main/res/values-uk/translations.xml b/features/reportroom/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..e720852 --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,8 @@ + + + "Ваша скарга надіслана, але ми зіткнулися з проблемою під час спроби вийти з кімнати. Повторіть спробу." + "Не вдалося вийти з кімнати" + "Поскаржтеся на цю кімнату своєму адміністратору. Якщо повідомлення зашифровані, ваш адміністратор не зможе їх прочитати." + "Опишіть причину…" + "Поскаржитися на кімнату" + diff --git a/features/reportroom/impl/src/main/res/values-uz/translations.xml b/features/reportroom/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..e7424ad --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,8 @@ + + + "Hisobotingiz muvaffaqiyatli yuborildi, ammo xonadan chiqishda muammo yuzaga keldi. Iltimos, qaytadan urinib ko‘ring." + "Xonani tark etish imkonsiz" + "Bu xona haqida administratoringizga xabar bering. Agar xabarlar shifrlangan bo‘lsa, administratoringiz ularni o‘qiy olmaydi." + "Xabar berish sababini tushuntiring…" + "Xona ustidan shikoyat qilish" + diff --git a/features/reportroom/impl/src/main/res/values-zh-rTW/translations.xml b/features/reportroom/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..1118a72 --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,8 @@ + + + "您的回報已成功遞交,但我們嘗試離開聊天室時遇到了問題。請再試一次。" + "無法離開聊天室" + "將此聊天室回報給您的管理員。若訊息已加密,您的管理員將無法讀取它們。" + "說明回報的原因……" + "回報聊天室" + diff --git a/features/reportroom/impl/src/main/res/values-zh/translations.xml b/features/reportroom/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..40a0b5f --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,8 @@ + + + "您的报告已成功提交,但在尝试离开房间时遇到了问题。请重试。" + "无法离开房间" + "向管理员举报此房间。如果信息已加密,管理员将无法读取。" + "描述举报的原因…" + "举报房间" + diff --git a/features/reportroom/impl/src/main/res/values/localazy.xml b/features/reportroom/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..16183e6 --- /dev/null +++ b/features/reportroom/impl/src/main/res/values/localazy.xml @@ -0,0 +1,8 @@ + + + "Your report was submitted successfully, but we encountered an issue while trying to leave the room. Please try again." + "Unable to Leave Room" + "Report this room to your admin. If the messages are encrypted, your admin will not be able to read them." + "Describe the reason to report…" + "Report room" + diff --git a/features/reportroom/impl/src/test/kotlin/io/element/android/features/reportroom/impl/DefaultReportRoomEntryPointTest.kt b/features/reportroom/impl/src/test/kotlin/io/element/android/features/reportroom/impl/DefaultReportRoomEntryPointTest.kt new file mode 100644 index 0000000..2c8354a --- /dev/null +++ b/features/reportroom/impl/src/test/kotlin/io/element/android/features/reportroom/impl/DefaultReportRoomEntryPointTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.reportroom.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultReportRoomEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultReportRoomEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + ReportRoomNode( + buildContext = buildContext, + plugins = plugins, + presenterFactory = { roomId -> + assertThat(roomId).isEqualTo(A_ROOM_ID) + createReportRoomPresenter() + } + ) + } + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + roomId = A_ROOM_ID, + ) + assertThat(result).isInstanceOf(ReportRoomNode::class.java) + assertThat(result.plugins).contains(ReportRoomNode.Inputs(A_ROOM_ID)) + } +} diff --git a/features/reportroom/impl/src/test/kotlin/io/element/android/features/reportroom/impl/DefaultReportRoomTest.kt b/features/reportroom/impl/src/test/kotlin/io/element/android/features/reportroom/impl/DefaultReportRoomTest.kt new file mode 100644 index 0000000..93cd810 --- /dev/null +++ b/features/reportroom/impl/src/test/kotlin/io/element/android/features/reportroom/impl/DefaultReportRoomTest.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.reportroom.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.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultReportRoomTest { + private val roomId = A_ROOM_ID + private val successLeaveRoomLambda = lambdaRecorder> { Result.success(Unit) } + private val successReportRoomLambda = + lambdaRecorder> { _ -> Result.success(Unit) } + + private val failureLeaveRoomLambda = + lambdaRecorder> { Result.failure(Exception("Leave room error")) } + private val failureReportRoomLambda = + lambdaRecorder> { _ -> Result.failure(Exception("Report room error")) } + + @Test + fun `report room, leave=false, report=false, nothing is called`() = runTest { + val room = FakeBaseRoom( + roomId = roomId, + leaveRoomLambda = successLeaveRoomLambda, + reportRoomResult = successReportRoomLambda + ) + val client = FakeMatrixClient().apply { + givenGetRoomResult(roomId, room) + } + val reportRoom = DefaultReportRoom(client = client) + + val result = reportRoom(roomId, shouldReport = false, reason = "", shouldLeave = false) + + assertThat(result.isSuccess).isTrue() + assert(successLeaveRoomLambda).isNeverCalled() + assert(successReportRoomLambda).isNeverCalled() + } + + @Test + fun `report room, leave=false, report=true, report room success`() = runTest { + val room = FakeBaseRoom( + roomId = roomId, + leaveRoomLambda = successLeaveRoomLambda, + reportRoomResult = successReportRoomLambda + ) + val client = FakeMatrixClient().apply { + givenGetRoomResult(roomId, room) + } + val reportRoom = DefaultReportRoom(client = client) + + val result = reportRoom(roomId, shouldReport = true, reason = "Spam", shouldLeave = false) + + assertThat(result.isSuccess).isTrue() + assert(successLeaveRoomLambda).isNeverCalled() + assert(successReportRoomLambda) + .isCalledOnce() + .with(value("Spam")) + } + + @Test + fun `report room, leave=true, report=false, leave room success`() = runTest { + val room = FakeBaseRoom( + roomId = roomId, + leaveRoomLambda = successLeaveRoomLambda, + reportRoomResult = successReportRoomLambda + ) + val client = FakeMatrixClient().apply { + givenGetRoomResult(roomId, room) + } + val reportRoom = DefaultReportRoom(client = client) + + val result = reportRoom(roomId, shouldReport = false, reason = "", shouldLeave = true) + + assertThat(result.isSuccess).isTrue() + assert(successLeaveRoomLambda).isCalledOnce() + assert(successReportRoomLambda).isNeverCalled() + } + + @Test + fun `report room, leave=true, report=true, leave room success`() = runTest { + val room = FakeBaseRoom( + roomId = roomId, + leaveRoomLambda = successLeaveRoomLambda, + reportRoomResult = successReportRoomLambda + ) + val client = FakeMatrixClient().apply { + givenGetRoomResult(roomId, room) + } + val reportRoom = DefaultReportRoom(client = client) + + val result = reportRoom(roomId, shouldReport = true, reason = "Spam", shouldLeave = true) + + assertThat(result.isSuccess).isTrue() + assert(successLeaveRoomLambda).isCalledOnce() + assert(successReportRoomLambda) + .isCalledOnce() + .with(value("Spam")) + } + + @Test + fun `report room, leave=true, report=true, leave room failed`() = runTest { + val room = FakeBaseRoom( + roomId = roomId, + leaveRoomLambda = failureLeaveRoomLambda, + reportRoomResult = successReportRoomLambda + ) + val client = FakeMatrixClient().apply { + givenGetRoomResult(roomId, room) + } + val reportRoom = DefaultReportRoom(client = client) + + val result = reportRoom(roomId, shouldReport = true, reason = "Spam", shouldLeave = true) + + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isEqualTo(ReportRoom.Exception.LeftRoomFailed) + assert(failureLeaveRoomLambda).isCalledOnce() + assert(successReportRoomLambda).isCalledOnce() + } + + @Test + fun `report room, leave=true, report=true, report room failed`() = runTest { + val room = FakeBaseRoom( + roomId = roomId, + leaveRoomLambda = successLeaveRoomLambda, + reportRoomResult = failureReportRoomLambda + ) + val client = FakeMatrixClient().apply { + givenGetRoomResult(roomId, room) + } + val reportRoom = DefaultReportRoom(client = client) + + val result = reportRoom(roomId, shouldReport = true, reason = "Spam", shouldLeave = true) + + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isEqualTo(ReportRoom.Exception.ReportRoomFailed) + assert(successLeaveRoomLambda).isNeverCalled() + assert(failureReportRoomLambda).isCalledOnce() + } +} diff --git a/features/reportroom/impl/src/test/kotlin/io/element/android/features/reportroom/impl/ReportRoomPresenterTest.kt b/features/reportroom/impl/src/test/kotlin/io/element/android/features/reportroom/impl/ReportRoomPresenterTest.kt new file mode 100644 index 0000000..6bcaeac --- /dev/null +++ b/features/reportroom/impl/src/test/kotlin/io/element/android/features/reportroom/impl/ReportRoomPresenterTest.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.reportroom.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.reportroom.impl.fakes.FakeReportRoom +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ReportRoomPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createReportRoomPresenter() + presenter.test { + awaitItem().also { state -> + assertThat(state.reason).isEmpty() + assertThat(state.leaveRoom).isFalse() + assertThat(state.reportAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(state.canReport).isFalse() + } + } + } + + @Test + fun `present - update form values`() = runTest { + val presenter = createReportRoomPresenter() + presenter.test { + awaitItem().also { state -> + assertThat(state.reason).isEmpty() + assertThat(state.canReport).isFalse() + assertThat(state.leaveRoom).isFalse() + state.eventSink(ReportRoomEvents.UpdateReason("Spam")) + } + awaitItem().also { state -> + assertThat(state.reason).isEqualTo("Spam") + assertThat(state.canReport).isTrue() + assertThat(state.leaveRoom).isFalse() + state.eventSink(ReportRoomEvents.ToggleLeaveRoom) + } + awaitItem().also { state -> + assertThat(state.leaveRoom).isTrue() + assertThat(state.canReport).isTrue() + assertThat(state.canReport).isTrue() + } + } + } + + @Test + fun `present - report room success`() = runTest { + val roomId = A_ROOM_ID + val reportRoomLambda = lambdaRecorder> { _, _, _, _ -> Result.success(Unit) } + val reportRoom = FakeReportRoom( + lambda = reportRoomLambda + ) + val presenter = createReportRoomPresenter(roomId = roomId, reportRoom = reportRoom) + presenter.test { + awaitItem().eventSink(ReportRoomEvents.ToggleLeaveRoom) + awaitItem().eventSink(ReportRoomEvents.Report) + awaitItem().also { state -> + assertThat(state.reportAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.reportAction).isInstanceOf(AsyncAction.Success::class.java) + } + assert(reportRoomLambda) + .isCalledOnce() + .with(value(roomId), value(true), any(), value(true)) + } + } + + @Test + fun `present - report failed`() = runTest { + val roomId = A_ROOM_ID + val reportRoomLambda = lambdaRecorder> { _, _, _, _ -> + Result.failure(ReportRoom.Exception.ReportRoomFailed) + } + val reportRoom = FakeReportRoom( + lambda = reportRoomLambda + ) + val presenter = createReportRoomPresenter(roomId = roomId, reportRoom = reportRoom) + presenter.test { + awaitItem().eventSink(ReportRoomEvents.Report) + awaitItem().also { state -> + assertThat(state.reportAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.reportAction).isInstanceOf(AsyncAction.Failure::class.java) + } + assert(reportRoomLambda) + .isCalledOnce() + .with(value(roomId), value(true), any(), any()) + } + } + + @Test + fun `present - leave room failed after report room success`() = runTest { + val roomId = A_ROOM_ID + val reportRoomLambda = lambdaRecorder> { _, _, _, _ -> + Result.failure(ReportRoom.Exception.LeftRoomFailed) + } + val reportRoom = FakeReportRoom( + lambda = reportRoomLambda + ) + val presenter = createReportRoomPresenter(roomId = roomId, reportRoom = reportRoom) + presenter.test { + awaitItem().eventSink(ReportRoomEvents.ToggleLeaveRoom) + awaitItem().eventSink(ReportRoomEvents.Report) + awaitItem().also { state -> + assertThat(state.reportAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.reportAction).isInstanceOf(AsyncAction.Failure::class.java) + state.eventSink(ReportRoomEvents.Report) + } + awaitItem().also { state -> + assertThat(state.reportAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.reportAction).isInstanceOf(AsyncAction.Failure::class.java) + } + assert(reportRoomLambda) + .isCalledExactly(2) + .withSequence( + // The first call should report the room and try leaving it + listOf(value(roomId), value(true), any(), value(true)), + // The second call should not report the room again + listOf(value(roomId), value(false), any(), value(true)) + ) + } + } +} + +internal fun createReportRoomPresenter( + roomId: RoomId = A_ROOM_ID, + reportRoom: ReportRoom = FakeReportRoom() +): ReportRoomPresenter { + return ReportRoomPresenter(roomId, reportRoom) +} diff --git a/features/reportroom/impl/src/test/kotlin/io/element/android/features/reportroom/impl/ReportRoomViewTest.kt b/features/reportroom/impl/src/test/kotlin/io/element/android/features/reportroom/impl/ReportRoomViewTest.kt new file mode 100644 index 0000000..59d9507 --- /dev/null +++ b/features/reportroom/impl/src/test/kotlin/io/element/android/features/reportroom/impl/ReportRoomViewTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.reportroom.impl + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ReportRoomViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invoke the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setReportRoomView( + aReportRoomState( + eventSink = eventsRecorder, + ), + onBackClick = it + ) + rule.pressBack() + } + } + + @Test + fun `clicking on report when enabled emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setReportRoomView( + aReportRoomState( + reason = "Spam", + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_report) + eventsRecorder.assertSingle(ReportRoomEvents.Report) + } + + @Test + fun `clicking on decline when disabled does not emit event`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setReportRoomView( + aReportRoomState(eventSink = eventsRecorder), + ) + rule.clickOn(CommonStrings.action_report) + } + + @Test + fun `clicking on leave room option emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setReportRoomView( + aReportRoomState(eventSink = eventsRecorder), + ) + rule.clickOn(CommonStrings.action_leave_room) + eventsRecorder.assertSingle(ReportRoomEvents.ToggleLeaveRoom) + } + + @Test + fun `typing text in the reason field emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setReportRoomView( + aReportRoomState( + eventSink = eventsRecorder, + reason = "" + ), + ) + rule.onNodeWithText("").performTextInput("Spam!") + eventsRecorder.assertSingle(ReportRoomEvents.UpdateReason("Spam!")) + } +} + +private fun AndroidComposeTestRule.setReportRoomView( + state: ReportRoomState, + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + ReportRoomView( + state = state, + onBackClick = onBackClick, + ) + } +} diff --git a/features/reportroom/impl/src/test/kotlin/io/element/android/features/reportroom/impl/fakes/FakeReportRoom.kt b/features/reportroom/impl/src/test/kotlin/io/element/android/features/reportroom/impl/fakes/FakeReportRoom.kt new file mode 100644 index 0000000..402ff19 --- /dev/null +++ b/features/reportroom/impl/src/test/kotlin/io/element/android/features/reportroom/impl/fakes/FakeReportRoom.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.reportroom.impl.fakes + +import io.element.android.features.reportroom.impl.ReportRoom +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask + +class FakeReportRoom( + var lambda: (RoomId, Boolean, String, Boolean) -> Result = { _, _, _, _ -> lambdaError() } +) : ReportRoom { + override suspend fun invoke( + roomId: RoomId, + shouldReport: Boolean, + reason: String, + shouldLeave: Boolean + ): Result = simulateLongTask { + lambda(roomId, shouldReport, reason, shouldLeave) + } +} diff --git a/features/reportroom/test/build.gradle.kts b/features/reportroom/test/build.gradle.kts new file mode 100644 index 0000000..049ced1 --- /dev/null +++ b/features/reportroom/test/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.reportroom.test" +} + +dependencies { + implementation(projects.features.reportroom.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.tests.testutils) +} diff --git a/features/reportroom/test/src/main/kotlin/io/element/android/features/reportroom/test/FakeReportRoomEntryPoint.kt b/features/reportroom/test/src/main/kotlin/io/element/android/features/reportroom/test/FakeReportRoomEntryPoint.kt new file mode 100644 index 0000000..02e5020 --- /dev/null +++ b/features/reportroom/test/src/main/kotlin/io/element/android/features/reportroom/test/FakeReportRoomEntryPoint.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.reportroom.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.reportroom.api.ReportRoomEntryPoint +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeReportRoomEntryPoint : ReportRoomEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + roomId: RoomId, + ): Node { + lambdaError() + } +} diff --git a/features/rolesandpermissions/api/build.gradle.kts b/features/rolesandpermissions/api/build.gradle.kts new file mode 100644 index 0000000..ca29972 --- /dev/null +++ b/features/rolesandpermissions/api/build.gradle.kts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.rolesandpermissions.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.core) + implementation(projects.libraries.matrix.api) + api(projects.libraries.usersearch.api) +} diff --git a/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/ChangeRoomMemberRolesEntryPoint.kt b/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/ChangeRoomMemberRolesEntryPoint.kt new file mode 100644 index 0000000..7cfdfef --- /dev/null +++ b/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/ChangeRoomMemberRolesEntryPoint.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.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.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.JoinedRoom + +fun interface ChangeRoomMemberRolesEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + room: JoinedRoom, + listType: ChangeRoomMemberRolesListType, + ): Node + + interface NodeProxy { + val roomId: RoomId + suspend fun waitForCompletion(): Boolean + } +} + +enum class ChangeRoomMemberRolesListType { + SelectNewOwnersWhenLeaving, + Admins, + Moderators +} diff --git a/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/RolesAndPermissionsEntryPoint.kt b/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/RolesAndPermissionsEntryPoint.kt new file mode 100644 index 0000000..1e9fe6a --- /dev/null +++ b/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/RolesAndPermissionsEntryPoint.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.api + +import io.element.android.libraries.architecture.SimpleFeatureEntryPoint + +fun interface RolesAndPermissionsEntryPoint : SimpleFeatureEntryPoint diff --git a/features/rolesandpermissions/impl/build.gradle.kts b/features/rolesandpermissions/impl/build.gradle.kts new file mode 100644 index 0000000..19820a8 --- /dev/null +++ b/features/rolesandpermissions/impl/build.gradle.kts @@ -0,0 +1,45 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.rolesandpermissions.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + api(projects.features.rolesandpermissions.api) + implementation(projects.appnav) + implementation(projects.libraries.architecture) + implementation(projects.libraries.core) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.matrix.api) + // For test fixtures used in previews + implementation(projects.libraries.previewutils) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.uiStrings) + implementation(projects.services.analytics.api) + + testCommonDependencies(libs, true) + testImplementation(projects.services.analytics.test) + testImplementation(projects.libraries.matrix.test) +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/DefaultRolesAndPermissionsEntryPoint.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/DefaultRolesAndPermissionsEntryPoint.kt new file mode 100644 index 0000000..2f281a5 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/DefaultRolesAndPermissionsEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.rolesandpermissions.api.RolesAndPermissionsEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.RoomScope + +@ContributesBinding(RoomScope::class) +class DefaultRolesAndPermissionsEntryPoint : RolesAndPermissionsEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext): Node { + return parentNode.createNode(buildContext) + } +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/RolesAndPermissionsFlowNode.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/RolesAndPermissionsFlowNode.kt new file mode 100644 index 0000000..5966a4f --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/RolesAndPermissionsFlowNode.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl + +import android.os.Parcelable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.coroutineScope +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.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType +import io.element.android.features.rolesandpermissions.impl.permissions.ChangeRoomPermissionsNode +import io.element.android.features.rolesandpermissions.impl.roles.ChangeRolesNode +import io.element.android.features.rolesandpermissions.impl.root.RolesAndPermissionsNode +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.designsystem.components.async.AsyncIndicator +import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost +import io.element.android.libraries.designsystem.components.async.AsyncIndicatorState +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize + +@ContributesNode(RoomScope::class) +@AssistedInject +class RolesAndPermissionsFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + sealed interface NavTarget : Parcelable { + @Parcelize + data object Root : NavTarget + + @Parcelize + data object ChangeAdmins : NavTarget + + @Parcelize + data object ChangeModerators : NavTarget + + @Parcelize + data object ChangeRoomPermissions : NavTarget + } + + private val asyncIndicatorState = AsyncIndicatorState() + + override fun onBuilt() { + super.onBuilt() + whenChildAttached { lifecycle, node: ChangeRolesNode -> + lifecycle.coroutineScope.launch { + val changesSaved = node.waitForCompletion() + onChangeComplete(changesSaved) + } + } + } + + private fun onChangeComplete(changesSaved: Boolean) { + backstack.pop() + if (changesSaved) { + asyncIndicatorState.enqueue(durationMs = AsyncIndicator.DURATION_SHORT) { + AsyncIndicator.Custom(text = stringResource(CommonStrings.common_saved_changes)) + } + } + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.Root -> { + val callback = object : RolesAndPermissionsNode.Callback { + override fun openAdminList() { + backstack.push(NavTarget.ChangeAdmins) + } + + override fun openModeratorList() { + backstack.push(NavTarget.ChangeModerators) + } + + override fun openEditPermissions() { + backstack.push(NavTarget.ChangeRoomPermissions) + } + } + createNode( + buildContext = buildContext, + plugins = listOf(callback), + ) + } + is NavTarget.ChangeAdmins -> { + val inputs = ChangeRolesNode.Inputs(ChangeRoomMemberRolesListType.Admins) + createNode(buildContext = buildContext, plugins = listOf(inputs)) + } + is NavTarget.ChangeModerators -> { + val inputs = ChangeRolesNode.Inputs(ChangeRoomMemberRolesListType.Moderators) + createNode(buildContext = buildContext, plugins = listOf(inputs)) + } + is NavTarget.ChangeRoomPermissions -> { + val callback = object : ChangeRoomPermissionsNode.Callback { + override fun onComplete(changesSaved: Boolean) { + onChangeComplete(changesSaved) + } + } + createNode(buildContext = buildContext, plugins = listOf(callback)) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + Box(modifier = modifier) { + BackstackView() + AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), asyncIndicatorState) + } + } +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/RoomMemberListDataSource.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/RoomMemberListDataSource.kt new file mode 100644 index 0000000..b7a2691 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/RoomMemberListDataSource.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.roomMembers +import kotlinx.coroutines.withContext + +@Inject +class RoomMemberListDataSource( + private val room: BaseRoom, + private val coroutineDispatchers: CoroutineDispatchers, +) { + suspend fun search(query: String): List = withContext(coroutineDispatchers.io) { + val roomMembersState = room.membersStateFlow.value + val activeRoomMembers = roomMembersState.roomMembers() + ?.filter { it.membership.isActive() } + .orEmpty() + val filteredMembers = if (query.isBlank()) { + activeRoomMembers + } else { + activeRoomMembers.filter { member -> + member.userId.value.contains(query, ignoreCase = true) || + member.displayName?.contains(query, ignoreCase = true).orFalse() + } + } + filteredMembers + } +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/analytics/AnalyticUtils.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/analytics/AnalyticUtils.kt new file mode 100644 index 0000000..9963a75 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/analytics/AnalyticUtils.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.analytics + +import im.vector.app.features.analytics.plan.RoomModeration +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues +import io.element.android.services.analytics.api.AnalyticsService + +internal fun RoomMember.Role.toAnalyticsMemberRole(): RoomModeration.Role = when (this) { + is RoomMember.Role.Owner -> RoomModeration.Role.Administrator // TODO - distinguish creator from admin + RoomMember.Role.Admin -> RoomModeration.Role.Administrator + RoomMember.Role.Moderator -> RoomModeration.Role.Moderator + RoomMember.Role.User -> RoomModeration.Role.User +} + +internal fun analyticsMemberRoleForPowerLevel(powerLevel: Long): RoomModeration.Role { + return RoomMember.Role.forPowerLevel(powerLevel).toAnalyticsMemberRole() +} + +internal fun AnalyticsService.trackPermissionChangeAnalytics(initial: RoomPowerLevelsValues?, updated: RoomPowerLevelsValues) { + if (updated.ban != initial?.ban) { + capture(RoomModeration(RoomModeration.Action.ChangePermissionsBanMembers, analyticsMemberRoleForPowerLevel(updated.ban))) + } + if (updated.invite != initial?.invite) { + capture(RoomModeration(RoomModeration.Action.ChangePermissionsInviteUsers, analyticsMemberRoleForPowerLevel(updated.invite))) + } + if (updated.kick != initial?.kick) { + capture(RoomModeration(RoomModeration.Action.ChangePermissionsKickMembers, analyticsMemberRoleForPowerLevel(updated.kick))) + } + if (updated.sendEvents != initial?.sendEvents) { + capture(RoomModeration(RoomModeration.Action.ChangePermissionsSendMessages, analyticsMemberRoleForPowerLevel(updated.sendEvents))) + } + if (updated.redactEvents != initial?.redactEvents) { + capture(RoomModeration(RoomModeration.Action.ChangePermissionsRedactMessages, analyticsMemberRoleForPowerLevel(updated.redactEvents))) + } + if (updated.roomName != initial?.roomName) { + capture(RoomModeration(RoomModeration.Action.ChangePermissionsRoomName, analyticsMemberRoleForPowerLevel(updated.roomName))) + } + if (updated.roomAvatar != initial?.roomAvatar) { + capture(RoomModeration(RoomModeration.Action.ChangePermissionsRoomAvatar, analyticsMemberRoleForPowerLevel(updated.roomAvatar))) + } + if (updated.roomTopic != initial?.roomTopic) { + capture(RoomModeration(RoomModeration.Action.ChangePermissionsRoomTopic, analyticsMemberRoleForPowerLevel(updated.roomTopic))) + } +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsEvent.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsEvent.kt new file mode 100644 index 0000000..beb161f --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsEvent.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.permissions + +interface ChangeRoomPermissionsEvent { + data class ChangeMinimumRoleForAction(val action: RoomPermissionType, val role: SelectableRole) : ChangeRoomPermissionsEvent + data object Save : ChangeRoomPermissionsEvent + data object Exit : ChangeRoomPermissionsEvent + data object ResetPendingActions : ChangeRoomPermissionsEvent +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsNode.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsNode.kt new file mode 100644 index 0000000..b63ffb9 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsNode.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.permissions + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.RoomScope + +@ContributesNode(RoomScope::class) +@AssistedInject +class ChangeRoomPermissionsNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: ChangeRoomPermissionsPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onComplete(changesSaved: Boolean) + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + ChangeRoomPermissionsView( + modifier = modifier, + state = state, + onComplete = callback::onComplete, + ) + } +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenter.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenter.kt new file mode 100644 index 0000000..b356ca3 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenter.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.permissions + +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 dev.zacsweers.metro.Inject +import io.element.android.features.rolesandpermissions.impl.analytics.trackPermissionChangeAnalytics +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Inject +class ChangeRoomPermissionsPresenter( + private val room: JoinedRoom, + private val analyticsService: AnalyticsService, +) : Presenter { + companion object { + private fun itemsForSection(section: RoomPermissionsSection) = when (section) { + RoomPermissionsSection.SpaceDetails, + RoomPermissionsSection.RoomDetails -> persistentListOf( + RoomPermissionType.ROOM_NAME, + RoomPermissionType.ROOM_AVATAR, + RoomPermissionType.ROOM_TOPIC, + ) + RoomPermissionsSection.MessagesAndContent -> persistentListOf( + RoomPermissionType.SEND_EVENTS, + RoomPermissionType.REDACT_EVENTS, + ) + RoomPermissionsSection.MembershipModeration -> persistentListOf( + RoomPermissionType.INVITE, + RoomPermissionType.KICK, + RoomPermissionType.BAN, + ) + } + + private fun RoomPermissionsSection.shouldShow(isSpace: Boolean): Boolean { + return when (this) { + RoomPermissionsSection.RoomDetails -> !isSpace + RoomPermissionsSection.MembershipModeration -> true + RoomPermissionsSection.MessagesAndContent -> !isSpace + RoomPermissionsSection.SpaceDetails -> isSpace + } + } + + internal fun buildItems(isSpace: Boolean) = + RoomPermissionsSection.entries + .filter { section -> section.shouldShow(isSpace) } + .associateWith { itemsForSection(it) } + .toImmutableMap() + } + + private val itemsBySection = buildItems(isSpace = room.info().isSpace) + + private var initialPermissions by mutableStateOf(null) + private var currentPermissions by mutableStateOf(null) + private var saveAction by mutableStateOf>(AsyncAction.Uninitialized) + private var confirmExitAction by mutableStateOf>(AsyncAction.Uninitialized) + + @Composable + override fun present(): ChangeRoomPermissionsState { + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + updatePermissions() + } + + val hasChanges by remember { + derivedStateOf { initialPermissions != currentPermissions } + } + + fun handleEvent(event: ChangeRoomPermissionsEvent) { + when (event) { + is ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction -> { + val powerLevel = when (event.role) { + SelectableRole.Admin -> RoomMember.Role.Admin.powerLevel + SelectableRole.Moderator -> RoomMember.Role.Moderator.powerLevel + SelectableRole.Everyone -> RoomMember.Role.User.powerLevel + } + currentPermissions = when (event.action) { + RoomPermissionType.BAN -> currentPermissions?.copy(ban = powerLevel) + RoomPermissionType.INVITE -> currentPermissions?.copy(invite = powerLevel) + RoomPermissionType.KICK -> currentPermissions?.copy(kick = powerLevel) + RoomPermissionType.SEND_EVENTS -> currentPermissions?.copy(sendEvents = powerLevel) + RoomPermissionType.REDACT_EVENTS -> currentPermissions?.copy(redactEvents = powerLevel) + RoomPermissionType.ROOM_NAME -> currentPermissions?.copy(roomName = powerLevel) + RoomPermissionType.ROOM_AVATAR -> currentPermissions?.copy(roomAvatar = powerLevel) + RoomPermissionType.ROOM_TOPIC -> currentPermissions?.copy(roomTopic = powerLevel) + } + } + is ChangeRoomPermissionsEvent.Save -> coroutineScope.save() + is ChangeRoomPermissionsEvent.Exit -> { + confirmExitAction = if (!hasChanges || confirmExitAction.isConfirming()) { + AsyncAction.Success(Unit) + } else { + AsyncAction.ConfirmingNoParams + } + } + is ChangeRoomPermissionsEvent.ResetPendingActions -> { + saveAction = AsyncAction.Uninitialized + confirmExitAction = AsyncAction.Uninitialized + } + } + } + return ChangeRoomPermissionsState( + currentPermissions = currentPermissions, + itemsBySection = itemsBySection, + hasChanges = hasChanges, + saveAction = saveAction, + confirmExitAction = confirmExitAction, + eventSink = ::handleEvent, + ) + } + + private suspend fun updatePermissions() { + val powerLevels = room.powerLevels().getOrNull() ?: return + initialPermissions = powerLevels + currentPermissions = initialPermissions + } + + private fun CoroutineScope.save() = launch { + saveAction = AsyncAction.Loading + val updatedRoomPowerLevels = currentPermissions ?: run { + saveAction = AsyncAction.Failure(IllegalStateException("Failed to set room power levels")) + return@launch + } + room.updatePowerLevels(updatedRoomPowerLevels) + .onSuccess { + analyticsService.trackPermissionChangeAnalytics(initialPermissions, updatedRoomPowerLevels) + initialPermissions = currentPermissions + saveAction = AsyncAction.Success(Unit) + } + .onFailure { + saveAction = AsyncAction.Failure(it) + } + } +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsState.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsState.kt new file mode 100644 index 0000000..2dc2c81 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsState.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.permissions + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.res.stringResource +import io.element.android.features.rolesandpermissions.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.components.preferences.DropdownOption +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap + +data class ChangeRoomPermissionsState( + val currentPermissions: RoomPowerLevelsValues?, + val itemsBySection: ImmutableMap>, + val hasChanges: Boolean, + val saveAction: AsyncAction, + val confirmExitAction: AsyncAction, + val eventSink: (ChangeRoomPermissionsEvent) -> Unit, +) { + fun selectedRoleForType(type: RoomPermissionType): SelectableRole? { + if (currentPermissions == null) return null + val role = when (type) { + RoomPermissionType.BAN -> RoomMember.Role.forPowerLevel(currentPermissions.ban) + RoomPermissionType.INVITE -> RoomMember.Role.forPowerLevel(currentPermissions.invite) + RoomPermissionType.KICK -> RoomMember.Role.forPowerLevel(currentPermissions.kick) + RoomPermissionType.SEND_EVENTS -> RoomMember.Role.forPowerLevel(currentPermissions.sendEvents) + RoomPermissionType.REDACT_EVENTS -> RoomMember.Role.forPowerLevel(currentPermissions.redactEvents) + RoomPermissionType.ROOM_NAME -> RoomMember.Role.forPowerLevel(currentPermissions.roomName) + RoomPermissionType.ROOM_AVATAR -> RoomMember.Role.forPowerLevel(currentPermissions.roomAvatar) + RoomPermissionType.ROOM_TOPIC -> RoomMember.Role.forPowerLevel(currentPermissions.roomTopic) + } + return when (role) { + is RoomMember.Role.Owner, + RoomMember.Role.Admin -> SelectableRole.Admin + RoomMember.Role.Moderator -> SelectableRole.Moderator + RoomMember.Role.User -> SelectableRole.Everyone + } + } +} + +enum class RoomPermissionsSection { + SpaceDetails, + RoomDetails, + MessagesAndContent, + MembershipModeration, +} + +enum class SelectableRole : DropdownOption { + Admin { + @Composable + @ReadOnlyComposable + override fun getText(): String = stringResource(R.string.screen_room_member_list_role_administrator) + }, + Moderator { + @Composable + @ReadOnlyComposable + override fun getText(): String = stringResource(R.string.screen_room_member_list_role_moderator) + }, + Everyone { + @Composable + @ReadOnlyComposable + override fun getText(): String = stringResource(R.string.screen_room_change_permissions_everyone) + } +} + +enum class RoomPermissionType { + BAN, + INVITE, + KICK, + SEND_EVENTS, + REDACT_EVENTS, + ROOM_NAME, + ROOM_AVATAR, + ROOM_TOPIC +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsStateProvider.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsStateProvider.kt new file mode 100644 index 0000000..d64c85f --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsStateProvider.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.permissions + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableMap + +class ChangeRoomPermissionsStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aChangeRoomPermissionsState(), + aChangeRoomPermissionsState(hasChanges = true), + aChangeRoomPermissionsState(hasChanges = true, saveAction = AsyncAction.Loading), + aChangeRoomPermissionsState( + hasChanges = true, + saveAction = AsyncAction.Failure(IllegalStateException("Failed to save changes")) + ), + aChangeRoomPermissionsState(hasChanges = true, confirmExitAction = AsyncAction.ConfirmingNoParams), + ) +} + +internal fun aChangeRoomPermissionsState( + currentPermissions: RoomPowerLevelsValues = previewPermissions(), + itemsBySection: Map> = ChangeRoomPermissionsPresenter.buildItems(false), + hasChanges: Boolean = false, + saveAction: AsyncAction = AsyncAction.Uninitialized, + confirmExitAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (ChangeRoomPermissionsEvent) -> Unit = {}, +) = ChangeRoomPermissionsState( + currentPermissions = currentPermissions, + itemsBySection = itemsBySection.toImmutableMap(), + hasChanges = hasChanges, + saveAction = saveAction, + confirmExitAction = confirmExitAction, + eventSink = eventSink, +) + +private fun previewPermissions(): RoomPowerLevelsValues { + return RoomPowerLevelsValues( + // MembershipModeration section + invite = RoomMember.Role.Admin.powerLevel, + kick = RoomMember.Role.Moderator.powerLevel, + ban = RoomMember.Role.User.powerLevel, + // MessagesAndContent section + redactEvents = RoomMember.Role.Moderator.powerLevel, + sendEvents = RoomMember.Role.Admin.powerLevel, + // RoomDetails section + roomName = RoomMember.Role.Admin.powerLevel, + roomAvatar = RoomMember.Role.Moderator.powerLevel, + roomTopic = RoomMember.Role.User.powerLevel, + // SpaceManagement section + spaceChild = RoomMember.Role.Moderator.powerLevel, + ) +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsView.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsView.kt new file mode 100644 index 0000000..1e88d09 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsView.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.permissions + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +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.PreviewParameter +import io.element.android.features.rolesandpermissions.impl.R +import io.element.android.libraries.designsystem.components.async.AsyncActionView +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.preferences.PreferenceDropdown +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ListSectionHeader +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChangeRoomPermissionsView( + state: ChangeRoomPermissionsState, + onComplete: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler { + state.eventSink(ChangeRoomPermissionsEvent.Exit) + } + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + titleStr = stringResource(R.string.screen_room_roles_and_permissions_permissions_header), + navigationIcon = { + BackButton(onClick = { state.eventSink(ChangeRoomPermissionsEvent.Exit) }) + }, + actions = { + TextButton( + text = stringResource(CommonStrings.action_save), + onClick = { state.eventSink(ChangeRoomPermissionsEvent.Save) }, + enabled = state.hasChanges, + ) + } + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier + .padding(padding) + .fillMaxSize() + ) { + state.itemsBySection.onEachIndexed { index, (section, items) -> + item { + ListSectionHeader(titleForSection(section), hasDivider = index > 0) + } + for (permissionType in items) { + item { + PreferenceDropdown( + title = titleForType(permissionType), + selectedOption = state.selectedRoleForType(permissionType), + options = SelectableRole.entries.toImmutableList(), + onSelectOption = { role -> + state.eventSink( + ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction( + action = permissionType, + role = role + ) + ) + } + ) + } + } + } + } + } + + AsyncActionView( + async = state.saveAction, + onSuccess = { onComplete(true) }, + onErrorDismiss = { state.eventSink(ChangeRoomPermissionsEvent.ResetPendingActions) } + ) + + AsyncActionView( + async = state.confirmExitAction, + onSuccess = { onComplete(false) }, + confirmationDialog = { + ConfirmationDialog( + title = stringResource(R.string.screen_room_change_role_unsaved_changes_title), + content = stringResource(R.string.screen_room_change_role_unsaved_changes_description), + submitText = stringResource(CommonStrings.action_save), + cancelText = stringResource(CommonStrings.action_discard), + onSubmitClick = { state.eventSink(ChangeRoomPermissionsEvent.Save) }, + onDismiss = { state.eventSink(ChangeRoomPermissionsEvent.Exit) } + ) + }, + onErrorDismiss = {}, + ) +} + +@Composable +private fun titleForSection(section: RoomPermissionsSection): String = when (section) { + RoomPermissionsSection.SpaceDetails -> stringResource(R.string.screen_room_roles_and_permissions_space_details) + RoomPermissionsSection.RoomDetails -> stringResource(R.string.screen_room_roles_and_permissions_room_details) + RoomPermissionsSection.MessagesAndContent -> stringResource(R.string.screen_room_roles_and_permissions_messages_and_content) + RoomPermissionsSection.MembershipModeration -> stringResource(R.string.screen_room_roles_and_permissions_member_moderation) +} + +@Composable +private fun titleForType(type: RoomPermissionType): String = when (type) { + RoomPermissionType.INVITE -> stringResource(R.string.screen_room_change_permissions_invite_people) + RoomPermissionType.KICK -> stringResource(R.string.screen_room_change_permissions_remove_people) + RoomPermissionType.BAN -> stringResource(R.string.screen_room_change_permissions_ban_people) + RoomPermissionType.SEND_EVENTS -> stringResource(R.string.screen_room_change_permissions_send_messages) + RoomPermissionType.REDACT_EVENTS -> stringResource(R.string.screen_room_change_permissions_delete_messages) + RoomPermissionType.ROOM_NAME -> stringResource(R.string.screen_room_change_permissions_room_name) + RoomPermissionType.ROOM_AVATAR -> stringResource(R.string.screen_room_change_permissions_room_avatar) + RoomPermissionType.ROOM_TOPIC -> stringResource(R.string.screen_room_change_permissions_room_topic) +} + +@PreviewsDayNight +@Composable +internal fun ChangeRoomPermissionsViewPreview(@PreviewParameter(ChangeRoomPermissionsStateProvider::class) state: ChangeRoomPermissionsState) { + ElementPreview { + ChangeRoomPermissionsView( + state = state, + onComplete = {}, + ) + } +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesEvent.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesEvent.kt new file mode 100644 index 0000000..2867273 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesEvent.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.roles + +import io.element.android.libraries.matrix.api.user.MatrixUser + +sealed interface ChangeRolesEvent { + data object ToggleSearchActive : ChangeRolesEvent + data class QueryChanged(val query: String?) : ChangeRolesEvent + data class UserSelectionToggled(val matrixUser: MatrixUser) : ChangeRolesEvent + data object Save : ChangeRolesEvent + data object Exit : ChangeRolesEvent + data object CloseDialog : ChangeRolesEvent +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesNode.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesNode.kt new file mode 100644 index 0000000..8baa5f5 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesNode.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.roles + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.appyx.launchMolecule +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.room.RoomMember +import kotlinx.coroutines.flow.first + +@ContributesNode(RoomScope::class) +@AssistedInject +class ChangeRolesNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: ChangeRolesPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + data class Inputs( + val listType: ChangeRoomMemberRolesListType, + ) : NodeInputs + + private val inputs: Inputs = inputs() + private val presenter = presenterFactory.create(inputs.listType.toRoomMemberRole()) + private val stateFlow = launchMolecule { presenter.present() } + + suspend fun waitForCompletion(): Boolean { + val successState = stateFlow.first { it.savingState.isSuccess() } + return successState.savingState.dataOrNull().orFalse() + } + + @Composable + override fun View(modifier: Modifier) { + val state by stateFlow.collectAsState() + ChangeRolesView( + state = state, + modifier = modifier, + ) + } +} + +internal fun ChangeRoomMemberRolesListType.toRoomMemberRole() = when (this) { + ChangeRoomMemberRolesListType.Admins -> RoomMember.Role.Admin + ChangeRoomMemberRolesListType.Moderators -> RoomMember.Role.Moderator + ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving -> RoomMember.Role.Owner(isCreator = false) +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt new file mode 100644 index 0000000..88da850 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.roles + +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.RoomModeration +import io.element.android.features.rolesandpermissions.impl.RoomMemberListDataSource +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.di.annotations.RoomCoroutineScope +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange +import io.element.android.libraries.matrix.api.room.powerlevels.usersWithRole +import io.element.android.libraries.matrix.api.room.toMatrixUser +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.model.roleOf +import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +@AssistedInject +class ChangeRolesPresenter( + @Assisted private val role: RoomMember.Role, + private val room: JoinedRoom, + private val dataSource: RoomMemberListDataSource, + private val analyticsService: AnalyticsService, + @RoomCoroutineScope private val roomCoroutineScope: CoroutineScope, +) : Presenter { + @AssistedFactory + fun interface Factory { + fun create(role: RoomMember.Role): ChangeRolesPresenter + } + + private val powerLevelRoomMemberComparator = PowerLevelRoomMemberComparator() + + @Composable + override fun present(): ChangeRolesState { + var query by rememberSaveable { mutableStateOf(null) } + var searchActive by rememberSaveable { mutableStateOf(false) } + var searchResults by remember { + mutableStateOf>(SearchBarResultState.Initial()) + } + val selectedUsers = remember { + mutableStateOf>(persistentListOf()) + } + val saveState: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + val usersWithRole = produceState>(initialValue = persistentListOf()) { + // If the role is admin, we need to include the owners as well since they implicitly have admin role + val owners = if (role == RoomMember.Role.Admin) { + combine( + room.usersWithRole(RoomMember.Role.Owner(isCreator = true)), + room.usersWithRole(RoomMember.Role.Owner(isCreator = false)), + ) { creators, superAdmins -> + creators + superAdmins + } + } else { + emptyFlow() + } + combine( + owners, + room.usersWithRole(role), + ) { owners, users -> + owners + users + }.map { members -> members.map { it.toMatrixUser() } } + .onEach { users -> + val previous = value + value = users.toImmutableList() + // Users who were selected but didn't have the role, so their role change was pending + val toAdd = selectedUsers.value.filter { user -> users.none { it.userId == user.userId } && previous.none { it.userId == user.userId } } + // Users who no longer have the role + val toRemove = previous.filter { user -> users.none { it.userId == user.userId } }.toSet() + selectedUsers.value = (users + toAdd - toRemove).toImmutableList() + } + .launchIn(this) + } + + val roomMemberState by room.membersStateFlow.collectAsState() + + // Update search results for every query change + LaunchedEffect(query, roomMemberState) { + val results = dataSource + .search(query.orEmpty()) + .groupedByRole() + + searchResults = if (results.isEmpty()) { + SearchBarResultState.NoResultsFound() + } else { + SearchBarResultState.Results(results) + } + } + + val hasPendingChanges by remember { + derivedStateOf { + usersWithRole.value.toSet() != selectedUsers.value.toSet() + } + } + + val roomInfo by room.roomInfoFlow.collectAsState() + fun canChangeMemberRole(userId: UserId): Boolean { + val currentUserRole = roomInfo.roleOf(room.sessionId) + val otherUserRole = roomInfo.roleOf(userId) + return currentUserRole.powerLevel > otherUserRole.powerLevel + } + + fun handleEvent(event: ChangeRolesEvent) { + when (event) { + is ChangeRolesEvent.ToggleSearchActive -> { + searchActive = !searchActive + } + is ChangeRolesEvent.QueryChanged -> { + query = event.query + } + is ChangeRolesEvent.UserSelectionToggled -> { + val newList = selectedUsers.value.toMutableList() + val index = newList.indexOfFirst { it.userId == event.matrixUser.userId } + if (index >= 0) { + newList.removeAt(index) + } else { + newList.add(event.matrixUser) + } + selectedUsers.value = newList.toImmutableList() + } + is ChangeRolesEvent.Save -> { + val currentUserIsAdmin = roomInfo.roleOf(room.sessionId) == RoomMember.Role.Admin + val isModifyingAdmins = role == RoomMember.Role.Admin + val isConfirming = saveState.value.isConfirming() + val modifyingOwners = role is RoomMember.Role.Owner + val confirmationValue = if (hasPendingChanges && !isConfirming) { + when { + modifyingOwners -> ConfirmingModifyingOwners + currentUserIsAdmin && isModifyingAdmins -> ConfirmingModifyingAdmins + else -> null + } + } else { + null + } + when { + confirmationValue != null -> { + saveState.value = confirmationValue + } + !saveState.value.isLoading() -> { + roomCoroutineScope.save(usersWithRole.value, selectedUsers, saveState) + } + } + } + is ChangeRolesEvent.Exit -> { + saveState.value = if (saveState.value.isUninitialized() && hasPendingChanges) { + // Has pending changes, confirm exit + AsyncAction.ConfirmingCancellation + } else { + // No pending changes, exit immediately + AsyncAction.Success(false) + } + } + is ChangeRolesEvent.CloseDialog -> { + saveState.value = AsyncAction.Uninitialized + } + } + } + return ChangeRolesState( + role = role, + query = query, + isSearchActive = searchActive, + searchResults = searchResults, + selectedUsers = selectedUsers.value, + hasPendingChanges = hasPendingChanges, + savingState = saveState.value, + canChangeMemberRole = ::canChangeMemberRole, + eventSink = ::handleEvent, + ) + } + + private fun List.groupedByRole(): MembersByRole { + return MembersByRole(this, powerLevelRoomMemberComparator) + } + + private fun CoroutineScope.save( + usersWithRole: ImmutableList, + selectedUsers: MutableState>, + saveState: MutableState>, + ) = launch { + runUpdatingState(saveState) { + val toAdd = selectedUsers.value - usersWithRole + val toRemove = usersWithRole - selectedUsers.value + val changes: List = buildList { + for (selectedUser in toAdd) { + analyticsService.capture(RoomModeration(RoomModeration.Action.ChangeMemberRole, role.toAnalyticsMemberRole())) + add(UserRoleChange(selectedUser.userId, role)) + } + for (selectedUser in toRemove) { + analyticsService.capture(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.User)) + add(UserRoleChange(selectedUser.userId, RoomMember.Role.User)) + } + } + room.updateUsersRoles(changes).map { true } + } + } + + internal fun RoomMember.Role.toAnalyticsMemberRole(): RoomModeration.Role = when (this) { + is RoomMember.Role.Owner -> RoomModeration.Role.Administrator // TODO - distinguish creator from admin + RoomMember.Role.Admin -> RoomModeration.Role.Administrator + RoomMember.Role.Moderator -> RoomModeration.Role.Moderator + RoomMember.Role.User -> RoomModeration.Role.User + } +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesState.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesState.kt new file mode 100644 index 0000000..71fef01 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesState.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.roles + +import io.element.android.libraries.architecture.AsyncAction +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.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +data class ChangeRolesState( + val role: RoomMember.Role, + val query: String?, + val isSearchActive: Boolean, + val searchResults: SearchBarResultState, + val selectedUsers: ImmutableList, + val hasPendingChanges: Boolean, + val savingState: AsyncAction, + val canChangeMemberRole: (UserId) -> Boolean, + val eventSink: (ChangeRolesEvent) -> Unit, +) + +data class MembersByRole( + val owners: ImmutableList = persistentListOf(), + val admins: ImmutableList = persistentListOf(), + val moderators: ImmutableList = persistentListOf(), + val members: ImmutableList = persistentListOf(), +) { + constructor(members: List, comparator: Comparator) : this( + owners = members.filterAndSort(comparator) { it.role is RoomMember.Role.Owner }, + admins = members.filterAndSort(comparator) { it.role == RoomMember.Role.Admin }, + moderators = members.filterAndSort(comparator) { it.role == RoomMember.Role.Moderator }, + members = members.filterAndSort(comparator) { it.role == RoomMember.Role.User }, + ) + + fun isEmpty() = owners.isEmpty() && admins.isEmpty() && moderators.isEmpty() && members.isEmpty() +} + +private fun Iterable.filterAndSort( + comparator: Comparator, + predicate: (RoomMember) -> Boolean, +): ImmutableList { + return filter(predicate).sortedWith(comparator).toImmutableList() +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesStateProvider.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesStateProvider.kt new file mode 100644 index 0000000..654259c --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesStateProvider.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.roles + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +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 io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator +import io.element.android.libraries.previewutils.room.aRoomMember +import io.element.android.libraries.previewutils.room.aRoomMemberList +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +class ChangeRolesStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aChangeRolesState(), + aChangeRolesStateWithSelectedUsers().copy(role = RoomMember.Role.Moderator), + aChangeRolesStateWithSelectedUsers().copy(hasPendingChanges = false), + aChangeRolesStateWithSelectedUsers(), + aChangeRolesStateWithSelectedUsers().copy( + selectedUsers = aMatrixUserList().take(2).toImmutableList(), + ), + aChangeRolesStateWithSelectedUsers().copy( + query = "Alice", + isSearchActive = true, + searchResults = SearchBarResultState.Results( + MembersByRole( + members = aRoomMemberList().take(1), + comparator = PowerLevelRoomMemberComparator(), + ) + ), + selectedUsers = aMatrixUserList().take(1).toImmutableList(), + ), + aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.ConfirmingCancellation), + aChangeRolesStateWithSelectedUsers().copy(savingState = ConfirmingModifyingAdmins), + aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Loading), + aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Success(true)), + aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Failure(Exception("boom"))), + aChangeRolesStateWithOwners(role = RoomMember.Role.Admin), + aChangeRolesStateWithOwners(role = RoomMember.Role.Owner(isCreator = false)), + aChangeRolesStateWithOwners(role = RoomMember.Role.Owner(isCreator = false)) + .copy(savingState = ConfirmingModifyingOwners), + ) +} + +internal fun aChangeRolesState( + role: RoomMember.Role = RoomMember.Role.Admin, + query: String? = null, + isSearchActive: Boolean = false, + searchResults: SearchBarResultState = SearchBarResultState.NoResultsFound(), + selectedUsers: ImmutableList = persistentListOf(), + hasPendingChanges: Boolean = false, + savingState: AsyncAction = AsyncAction.Uninitialized, + canRemoveMember: (UserId) -> Boolean = { true }, + eventSink: (ChangeRolesEvent) -> Unit = {}, +) = ChangeRolesState( + role = role, + query = query, + isSearchActive = isSearchActive, + searchResults = searchResults, + selectedUsers = selectedUsers, + hasPendingChanges = hasPendingChanges, + savingState = savingState, + canChangeMemberRole = canRemoveMember, + eventSink = eventSink, +) + +internal fun aChangeRolesStateWithSelectedUsers() = aChangeRolesState( + selectedUsers = aMatrixUserList().toImmutableList(), + searchResults = SearchBarResultState.Results( + MembersByRole( + members = aRoomMemberList().mapIndexed { index, roomMember -> + if (index % 2 == 0) { + roomMember.copy(membership = RoomMembershipState.INVITE) + } else { + roomMember + } + }, + comparator = PowerLevelRoomMemberComparator(), + ) + ), + hasPendingChanges = true, + canRemoveMember = { it != UserId("@alice:server.org") }, +) + +internal fun aChangeRolesStateWithOwners( + role: RoomMember.Role = RoomMember.Role.Admin, + selectedUsers: List = listOf( + aMatrixUser(id = "@alice:server.org", displayName = "Alice"), + aMatrixUser(id = "@bob:server.org", displayName = "Bob"), + aMatrixUser(id = "@carol:server.org", displayName = "Carol"), + ), +) = aChangeRolesState( + role = role, + searchResults = SearchBarResultState.Results( + MembersByRole( + members = persistentListOf( + aRoomMember( + userId = UserId("@alice:server.org"), + displayName = "Alice", + role = RoomMember.Role.Owner(isCreator = true), + ), + aRoomMember( + userId = UserId("@bob:server.org"), + displayName = "Bob", + role = RoomMember.Role.Owner(isCreator = false), + ), + aRoomMember( + userId = UserId("@carol:server.org"), + displayName = "Carol", + role = RoomMember.Role.Admin, + ), + aRoomMember( + userId = UserId("@david:server.org"), + displayName = "David", + role = RoomMember.Role.User, + ), + ), + comparator = PowerLevelRoomMemberComparator(), + ), + ), + canRemoveMember = { userId -> + when (userId) { + UserId("@alice:server.org") -> false // Owner - creator + UserId("@bob:server.org") -> false // Owner - super admin + UserId("@carol:server.org") -> true // Admin + UserId("@david:server.org") -> true // User + else -> false + } + }, + selectedUsers = selectedUsers.toImmutableList(), +) diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesView.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesView.kt new file mode 100644 index 0000000..bab24d3 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesView.kt @@ -0,0 +1,424 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.roles + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBarsPadding +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.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.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.rolesandpermissions.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost +import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState +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.avatar.AvatarType +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.SaveChangesDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Checkbox +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +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.utils.CommonDrawables +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 io.element.android.libraries.matrix.api.room.getBestName +import io.element.android.libraries.matrix.api.room.toMatrixUser +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChangeRolesView( + state: ChangeRolesState, + modifier: Modifier = Modifier, +) { + BackHandler(enabled = !state.isSearchActive) { + state.eventSink(ChangeRolesEvent.Exit) + } + Box(modifier = modifier) { + Scaffold( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding(), + topBar = { + AnimatedVisibility(visible = !state.isSearchActive) { + TopAppBar( + titleStr = when (state.role) { + is RoomMember.Role.Owner -> stringResource(R.string.screen_room_change_role_owners_title) + RoomMember.Role.Admin -> stringResource(R.string.screen_room_change_role_administrators_title) + RoomMember.Role.Moderator -> stringResource(R.string.screen_room_change_role_moderators_title) + RoomMember.Role.User -> error("This should never be reached") + }, + navigationIcon = { + BackButton(onClick = { state.eventSink(ChangeRolesEvent.Exit) }) + }, + actions = { + TextButton( + text = stringResource(CommonStrings.action_save), + enabled = state.hasPendingChanges, + onClick = { state.eventSink(ChangeRolesEvent.Save) } + ) + } + ) + } + } + ) { paddingValues -> + Column( + modifier = Modifier.padding(paddingValues), + ) { + val lazyListState = rememberLazyListState() + SearchBar( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + placeHolderTitle = stringResource(CommonStrings.common_search_for_someone), + query = state.query.orEmpty(), + onQueryChange = { state.eventSink(ChangeRolesEvent.QueryChanged(it)) }, + active = state.isSearchActive, + onActiveChange = { state.eventSink(ChangeRolesEvent.ToggleSearchActive) }, + resultState = state.searchResults, + ) { members -> + SearchResultsList( + currentRole = state.role, + lazyListState = lazyListState, + searchResults = members, + selectedUsers = state.selectedUsers, + canRemoveMember = state.canChangeMemberRole, + onToggleSelection = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) }, + selectedUsersList = {}, + ) + } + AnimatedVisibility( + visible = !state.isSearchActive, + enter = fadeIn(), + exit = fadeOut() + ) { + Column { + SearchResultsList( + currentRole = state.role, + lazyListState = lazyListState, + searchResults = (state.searchResults as? SearchBarResultState.Results)?.results ?: MembersByRole(), + selectedUsers = state.selectedUsers, + canRemoveMember = state.canChangeMemberRole, + onToggleSelection = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) }, + selectedUsersList = { users -> + SelectedUsersRowList( + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), + selectedUsers = users, + onUserRemove = { + state.eventSink(ChangeRolesEvent.UserSelectionToggled(it)) + }, + canDeselect = { state.canChangeMemberRole(it.userId) }, + ) + } + ) + } + } + } + } + + val asyncIndicatorState = rememberAsyncIndicatorState() + AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), asyncIndicatorState) + AsyncActionView( + async = state.savingState, + onSuccess = {}, + confirmationDialog = { confirming -> + when (confirming) { + is AsyncAction.ConfirmingCancellation -> { + SaveChangesDialog( + onSubmitClick = { state.eventSink(ChangeRolesEvent.Exit) }, + onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) } + ) + } + is ConfirmingModifyingOwners -> { + ConfirmationDialog( + title = stringResource(R.string.screen_room_change_role_confirm_change_owners_title), + content = stringResource(R.string.screen_room_change_role_confirm_change_owners_description), + submitText = stringResource(CommonStrings.action_continue), + onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) }, + onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) }, + destructiveSubmit = true, + ) + } + is ConfirmingModifyingAdmins -> { + ConfirmationDialog( + title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title), + content = stringResource(R.string.screen_room_change_role_confirm_add_admin_description), + onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) }, + onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) } + ) + } + } + }, + errorMessage = { + stringResource(CommonStrings.error_unknown) + }, + onErrorDismiss = { + state.eventSink(ChangeRolesEvent.CloseDialog) + }, + ) + } +} + +@Composable +private fun SearchResultsList( + currentRole: RoomMember.Role, + searchResults: MembersByRole, + selectedUsers: ImmutableList, + canRemoveMember: (UserId) -> Boolean, + onToggleSelection: (RoomMember) -> Unit, + lazyListState: LazyListState, + selectedUsersList: @Composable (ImmutableList) -> Unit, +) { + LazyColumn( + state = lazyListState, + ) { + item { + selectedUsersList(selectedUsers) + } + if (searchResults.owners.isNotEmpty()) { + stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_roles_and_permissions_owners)) } + item { + Text( + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, bottom = 8.dp), + text = stringResource(R.string.screen_room_change_role_moderators_owner_section_footer), + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmRegular, + ) + } + items(searchResults.owners, key = { it.userId }) { roomMember -> + ListMemberItem( + roomMember = roomMember, + canRemoveMember = canRemoveMember, + onToggleSelection = onToggleSelection, + selectedUsers = selectedUsers + ) + } + } + if (searchResults.admins.isNotEmpty()) { + stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_roles_and_permissions_admins)) } + // Add a footer for the admin section in change role to moderator screen + if (currentRole == RoomMember.Role.Moderator) { + item { + Text( + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, bottom = 8.dp), + text = stringResource(R.string.screen_room_change_role_moderators_admin_section_footer), + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmRegular, + ) + } + } + items(searchResults.admins, key = { it.userId }) { roomMember -> + ListMemberItem( + roomMember = roomMember, + canRemoveMember = canRemoveMember, + onToggleSelection = onToggleSelection, + selectedUsers = selectedUsers + ) + } + } + if (searchResults.moderators.isNotEmpty()) { + stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_roles_and_permissions_moderators)) } + items(searchResults.moderators, key = { it.userId }) { roomMember -> + ListMemberItem( + roomMember = roomMember, + canRemoveMember = canRemoveMember, + onToggleSelection = onToggleSelection, + selectedUsers = selectedUsers + ) + } + } + if (searchResults.members.isNotEmpty()) { + stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_member_list_mode_members)) } + items(searchResults.members, key = { it.userId }) { roomMember -> + ListMemberItem( + roomMember = roomMember, + canRemoveMember = canRemoveMember, + onToggleSelection = onToggleSelection, + selectedUsers = selectedUsers + ) + } + } + } +} + +@Composable +private fun ListSectionHeader(text: String) { + Text( + modifier = Modifier + .background(ElementTheme.colors.bgCanvasDefault) + .padding(horizontal = 16.dp, vertical = 8.dp) + .fillMaxWidth(), + text = text, + style = ElementTheme.typography.fontBodyLgMedium, + ) +} + +@Composable +private fun ListMemberItem( + roomMember: RoomMember, + canRemoveMember: (UserId) -> Boolean, + onToggleSelection: (RoomMember) -> Unit, + selectedUsers: ImmutableList, +) { + val canToggle = canRemoveMember(roomMember.userId) + val trailingContent: @Composable (() -> Unit) = { + if (canToggle) { + Checkbox( + checked = selectedUsers.any { it.userId == roomMember.userId }, + onCheckedChange = { onToggleSelection(roomMember) }, + ) + } + } + Column { + MemberRow( + modifier = Modifier.clickable(enabled = canToggle, onClick = { onToggleSelection(roomMember) }), + avatarData = roomMember.getAvatarData(size = AvatarSize.UserListItem), + name = roomMember.getBestName(), + userId = roomMember.userId.value.takeIf { roomMember.displayName?.isNotBlank() == true }, + isPending = roomMember.membership == RoomMembershipState.INVITE, + trailingContent = trailingContent, + ) + HorizontalDivider() + } +} + +@Composable +private fun MemberRow( + avatarData: AvatarData, + name: String, + userId: String?, + isPending: Boolean, + modifier: Modifier = Modifier, + trailingContent: @Composable (() -> Unit)? = null, +) { + 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 = avatarData, + avatarType = AvatarType.User, + ) + Column( + modifier = Modifier + .padding(start = 12.dp) + .weight(1f), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + // Name + Text( + modifier = Modifier.weight(1f, fill = false), + text = name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyLgRegular, + ) + // Invitation pending marker + if (isPending) { + Text( + modifier = Modifier.padding(start = 8.dp), + text = stringResource(id = R.string.screen_room_member_list_pending_status), + style = ElementTheme.typography.fontBodySmRegular.copy(fontStyle = FontStyle.Italic), + color = ElementTheme.colors.textSecondary + ) + } + } + // Id + userId?.let { + Text( + text = userId, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodySmRegular, + ) + } + } + trailingContent?.invoke() + } +} + +@PreviewsDayNight +@Composable +internal fun ChangeRolesViewPreview(@PreviewParameter(ChangeRolesStateProvider::class) state: ChangeRolesState) { + ElementPreview { + ChangeRolesView( + state = state + ) + } +} + +@PreviewsDayNight +@Composable +internal fun PendingMemberRowWithLongNamePreview() { + ElementPreview( + drawableFallbackForImages = CommonDrawables.sample_avatar, + ) { + MemberRow( + avatarData = AvatarData("userId", "A very long name that should be truncated", "https://example.com/avatar.png", AvatarSize.UserListItem), + name = "A very long name that should be truncated", + userId = "@alice:matrix.org", + isPending = true, + trailingContent = { + Checkbox( + checked = true, + onCheckedChange = {}, + enabled = true, + ) + } + ) + } +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRoomMemberRolesRootNode.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRoomMemberRolesRootNode.kt new file mode 100644 index 0000000..458e8fd --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRoomMemberRolesRootNode.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.roles + +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.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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.appnav.di.RoomGraphFactory +import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint +import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType +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.DependencyInjectionGraphOwner +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +@AssistedInject +class ChangeRoomMemberRolesRootNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + roomGraphFactory: RoomGraphFactory, +) : ParentNode( + navModel = PermanentNavModel( + navTargets = setOf(NavTarget), + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +), DependencyInjectionGraphOwner, ChangeRoomMemberRolesEntryPoint.NodeProxy { + @Parcelize object NavTarget : Parcelable + + data class Inputs( + val joinedRoom: JoinedRoom, + val listType: ChangeRoomMemberRolesListType, + ) : NodeInputs + + private val inputs = inputs() + + override val graph = roomGraphFactory.create(inputs.joinedRoom) + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return createNode( + buildContext = buildContext, + plugins = listOf(ChangeRolesNode.Inputs(listType = inputs.listType)), + ) + } + + @Composable + override fun View(modifier: Modifier) { + Children(modifier = modifier, navModel = navModel) + } + + override val roomId: RoomId = inputs.joinedRoom.roomId + + override suspend fun waitForCompletion(): Boolean { + return waitForChildAttached().waitForCompletion() + } +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ConfirmingModifyingAdmins.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ConfirmingModifyingAdmins.kt new file mode 100644 index 0000000..10d46ab --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ConfirmingModifyingAdmins.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.roles + +import io.element.android.libraries.architecture.AsyncAction + +data object ConfirmingModifyingAdmins : AsyncAction.Confirming diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ConfirmingModifyingOwners.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ConfirmingModifyingOwners.kt new file mode 100644 index 0000000..9799f68 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ConfirmingModifyingOwners.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.roles + +import io.element.android.libraries.architecture.AsyncAction + +data object ConfirmingModifyingOwners : AsyncAction.Confirming diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/DefaultChangeRoomMemberRolesEntyPoint.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/DefaultChangeRoomMemberRolesEntyPoint.kt new file mode 100644 index 0000000..a56c0d5 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/DefaultChangeRoomMemberRolesEntyPoint.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.roles + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint +import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.room.JoinedRoom + +@ContributesBinding(SessionScope::class) +class DefaultChangeRoomMemberRolesEntyPoint : ChangeRoomMemberRolesEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + room: JoinedRoom, + listType: ChangeRoomMemberRolesListType, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf( + ChangeRoomMemberRolesRootNode.Inputs(joinedRoom = room, listType = listType), + ) + ) + } +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsEvents.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsEvents.kt new file mode 100644 index 0000000..69fad02 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsEvents.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.root + +import io.element.android.libraries.matrix.api.room.RoomMember + +sealed interface RolesAndPermissionsEvents { + data object ChangeOwnRole : RolesAndPermissionsEvents + data class DemoteSelfTo(val role: RoomMember.Role) : RolesAndPermissionsEvents + data object ResetPermissions : RolesAndPermissionsEvents + data object CancelPendingAction : RolesAndPermissionsEvents +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsNode.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsNode.kt new file mode 100644 index 0000000..4469eb8 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsNode.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.root + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.ui.model.roleOf +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch + +@ContributesNode(RoomScope::class) +@AssistedInject +class RolesAndPermissionsNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: RolesAndPermissionsPresenter, + private val room: BaseRoom, +) : Node(buildContext, plugins = plugins), RolesAndPermissionsNavigator { + interface Callback : Plugin, RolesAndPermissionsNavigator { + override fun openAdminList() + override fun openModeratorList() + override fun openEditPermissions() + + override fun onBackClick() {} + } + + private val callback: Callback = callback() + + @Stable + private val navigator = object : RolesAndPermissionsNavigator by callback { + override fun onBackClick() { + navigateUp() + } + } + + override fun onBuilt() { + super.onBuilt() + + // If the user is not an admin anymore, exit this section since they won't have permissions to use it + lifecycleScope.launch { + room.roomInfoFlow + .filter { info -> + val role = info.roleOf(room.sessionId) + role != RoomMember.Role.Admin && role !is RoomMember.Role.Owner + } + .take(1) + .onEach { navigateUp() } + .collect() + } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + RolesAndPermissionsView( + state = state, + rolesAndPermissionsNavigator = navigator, + modifier = modifier, + ) + } +} + +interface RolesAndPermissionsNavigator { + fun onBackClick() {} + fun openAdminList() {} + fun openModeratorList() {} + fun openEditPermissions() {} +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsPresenter.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsPresenter.kt new file mode 100644 index 0000000..2ade971 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsPresenter.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.root + +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 dev.zacsweers.metro.Inject +import im.vector.app.features.analytics.plan.RoomModeration +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.activeRoomMembers +import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange +import io.element.android.libraries.matrix.ui.model.roleOf +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Inject +class RolesAndPermissionsPresenter( + private val room: JoinedRoom, + private val dispatchers: CoroutineDispatchers, + private val analyticsService: AnalyticsService, +) : Presenter { + @Composable + override fun present(): RolesAndPermissionsState { + val coroutineScope = rememberCoroutineScope() + val roomInfo by room.roomInfoFlow.collectAsState() + val roomMembers by room.membersStateFlow.collectAsState() + // Get the list of active room members (joined or invited), in order to filter members present in the power + // level state Event. + val activeRoomMemberIds by remember { + derivedStateOf { + roomMembers.activeRoomMembers().map { it.userId } + } + } + val moderatorCount by remember { + derivedStateOf { + roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Moderator) + } + } + val adminCount by remember { + derivedStateOf { + val admins = roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Admin) + val ownersCount = if (roomInfo.privilegedCreatorRole) { + val superAdmins = roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Owner(isCreator = false)) + val creators = roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Owner(isCreator = true)) + superAdmins + creators + } else { + 0 + } + admins + ownersCount + } + } + val canDemoteSelf = remember { derivedStateOf { roomInfo.roleOf(room.sessionId) !is RoomMember.Role.Owner } } + val changeOwnRoleAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + val resetPermissionsAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + + fun handleEvent(event: RolesAndPermissionsEvents) { + when (event) { + is RolesAndPermissionsEvents.ChangeOwnRole -> { + changeOwnRoleAction.value = AsyncAction.ConfirmingNoParams + } + is RolesAndPermissionsEvents.CancelPendingAction -> { + changeOwnRoleAction.value = AsyncAction.Uninitialized + resetPermissionsAction.value = AsyncAction.Uninitialized + } + is RolesAndPermissionsEvents.DemoteSelfTo -> coroutineScope.demoteSelfTo( + role = event.role, + changeOwnRoleAction = changeOwnRoleAction, + ) + is RolesAndPermissionsEvents.ResetPermissions -> if (resetPermissionsAction.value.isConfirming()) { + coroutineScope.resetPermissions(resetPermissionsAction) + } else { + resetPermissionsAction.value = AsyncAction.ConfirmingNoParams + } + } + } + + return RolesAndPermissionsState( + roomSupportsOwnerRole = roomInfo.privilegedCreatorRole, + adminCount = adminCount, + moderatorCount = moderatorCount, + canDemoteSelf = canDemoteSelf.value, + changeOwnRoleAction = changeOwnRoleAction.value, + resetPermissionsAction = resetPermissionsAction.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.demoteSelfTo( + role: RoomMember.Role, + changeOwnRoleAction: MutableState>, + ) = launch(dispatchers.io) { + runUpdatingState(changeOwnRoleAction) { + room.updateUsersRoles(listOf(UserRoleChange(room.sessionId, role))) + } + } + + private fun CoroutineScope.resetPermissions( + resetPermissionsAction: MutableState>, + ) = launch(dispatchers.io) { + runUpdatingState(resetPermissionsAction) { + analyticsService.capture(RoomModeration(RoomModeration.Action.ResetPermissions)) + room.resetPowerLevels() + } + } + + private fun RoomInfo.userCountWithRole(userIds: List, role: RoomMember.Role): Int { + return usersWithRole(role).filter { it in userIds }.size + } +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsState.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsState.kt new file mode 100644 index 0000000..3fc94f9 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsState.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.root + +import io.element.android.libraries.architecture.AsyncAction + +data class RolesAndPermissionsState( + val roomSupportsOwnerRole: Boolean, + val adminCount: Int, + val moderatorCount: Int, + val canDemoteSelf: Boolean, + val changeOwnRoleAction: AsyncAction, + val resetPermissionsAction: AsyncAction, + val eventSink: (RolesAndPermissionsEvents) -> Unit, +) diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsStateProvider.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsStateProvider.kt new file mode 100644 index 0000000..23448c0 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsStateProvider.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.root + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction + +class RolesAndPermissionsStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aRolesAndPermissionsState(roomSupportsOwners = false), + aRolesAndPermissionsState(adminCount = 1, moderatorCount = 2), + aRolesAndPermissionsState( + adminCount = 1, + moderatorCount = 2, + changeOwnRoleAction = AsyncAction.ConfirmingNoParams, + ), + aRolesAndPermissionsState( + adminCount = 1, + moderatorCount = 2, + changeOwnRoleAction = AsyncAction.Loading, + ), + aRolesAndPermissionsState( + adminCount = 1, + moderatorCount = 2, + changeOwnRoleAction = AsyncAction.Failure(IllegalStateException("Failed to change role")), + ), + aRolesAndPermissionsState( + adminCount = 1, + moderatorCount = 2, + resetPermissionsAction = AsyncAction.ConfirmingNoParams, + ), + aRolesAndPermissionsState( + adminCount = 1, + moderatorCount = 2, + resetPermissionsAction = AsyncAction.Loading, + ), + aRolesAndPermissionsState( + adminCount = 1, + moderatorCount = 2, + resetPermissionsAction = AsyncAction.Failure(IllegalStateException("Failed to reset permissions")), + ), + aRolesAndPermissionsState(canDemoteSelf = false), + ) +} + +internal fun aRolesAndPermissionsState( + roomSupportsOwners: Boolean = true, + adminCount: Int = 0, + moderatorCount: Int = 0, + canDemoteSelf: Boolean = true, + changeOwnRoleAction: AsyncAction = AsyncAction.Uninitialized, + resetPermissionsAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (RolesAndPermissionsEvents) -> Unit = {}, +) = RolesAndPermissionsState( + roomSupportsOwnerRole = roomSupportsOwners, + adminCount = adminCount, + canDemoteSelf = canDemoteSelf, + moderatorCount = moderatorCount, + changeOwnRoleAction = changeOwnRoleAction, + resetPermissionsAction = resetPermissionsAction, + eventSink = eventSink, +) diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsView.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsView.kt new file mode 100644 index 0000000..189ad83 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsView.kt @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.root + +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +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 androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.rolesandpermissions.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +import io.element.android.libraries.designsystem.theme.components.ListSectionHeader +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.hide +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun RolesAndPermissionsView( + state: RolesAndPermissionsState, + rolesAndPermissionsNavigator: RolesAndPermissionsNavigator, + modifier: Modifier = Modifier, +) { + PreferencePage( + modifier = modifier, + title = stringResource(R.string.screen_room_roles_and_permissions_title), + onBackClick = rolesAndPermissionsNavigator::onBackClick, + ) { + ListSectionHeader(title = stringResource(R.string.screen_room_roles_and_permissions_roles_header), hasDivider = false) + + val adminsTitle = if (state.roomSupportsOwnerRole) { + stringResource(R.string.screen_room_roles_and_permissions_admins_and_owners) + } else { + stringResource(R.string.screen_room_roles_and_permissions_admins) + } + ListItem( + headlineContent = { Text(adminsTitle) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())), + trailingContent = ListItemContent.Text("${state.adminCount}"), + onClick = { rolesAndPermissionsNavigator.openAdminList() }, + ) + ListItem( + headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_moderators)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())), + trailingContent = ListItemContent.Text("${state.moderatorCount}"), + onClick = { rolesAndPermissionsNavigator.openModeratorList() }, + ) + if (state.canDemoteSelf) { + ListItem( + headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_my_role)) }, + onClick = { state.eventSink(RolesAndPermissionsEvents.ChangeOwnRole) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Edit())) + ) + } + HorizontalDivider() + ListItem( + headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_permissions_header)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Settings())), + onClick = { rolesAndPermissionsNavigator.openEditPermissions() }, + ) + HorizontalDivider() + ListItem( + headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_reset)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Delete())), + onClick = { state.eventSink(RolesAndPermissionsEvents.ResetPermissions) }, + style = ListItemStyle.Destructive, + ) + } + + AsyncActionView( + async = state.resetPermissionsAction, + confirmationDialog = { + ConfirmationDialog( + title = stringResource(R.string.screen_room_roles_and_permissions_reset_confirm_title), + content = stringResource(R.string.screen_room_roles_and_permissions_reset_confirm_description), + submitText = stringResource(CommonStrings.action_reset), + destructiveSubmit = true, + onSubmitClick = { state.eventSink(RolesAndPermissionsEvents.ResetPermissions) }, + onDismiss = { state.eventSink(RolesAndPermissionsEvents.CancelPendingAction) }, + ) + }, + onSuccess = { state.eventSink(RolesAndPermissionsEvents.CancelPendingAction) }, + onErrorDismiss = { state.eventSink(RolesAndPermissionsEvents.CancelPendingAction) } + ) + + when (state.changeOwnRoleAction) { + is AsyncAction.Confirming -> { + ChangeOwnRoleBottomSheet( + eventSink = state.eventSink, + ) + } + is AsyncAction.Loading -> { + ProgressDialog() + } + is AsyncAction.Failure -> { + ErrorDialog( + content = stringResource(CommonStrings.error_unknown), + onSubmit = { state.eventSink(RolesAndPermissionsEvents.CancelPendingAction) } + ) + } + else -> Unit + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChangeOwnRoleBottomSheet( + eventSink: (RolesAndPermissionsEvents) -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + fun dismiss() { + sheetState.hide(coroutineScope) { + eventSink(RolesAndPermissionsEvents.CancelPendingAction) + } + } + ModalBottomSheet( + modifier = Modifier + .systemBarsPadding() + .navigationBarsPadding(), + sheetState = sheetState, + onDismissRequest = ::dismiss, + ) { + Text( + modifier = Modifier.padding(14.dp), + text = stringResource(R.string.screen_room_roles_and_permissions_change_my_role), + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + ) + Text( + modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 16.dp), + text = stringResource(R.string.screen_room_change_role_confirm_demote_self_description), + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textPrimary, + ) + ListItem( + headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_role_demote_to_moderator)) }, + onClick = { + sheetState.hide(coroutineScope) { + eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator)) + } + }, + style = ListItemStyle.Destructive, + ) + ListItem( + headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_role_demote_to_member)) }, + onClick = { + sheetState.hide(coroutineScope) { + eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.User)) + } + }, + style = ListItemStyle.Destructive, + ) + ListItem( + headlineContent = { Text(stringResource(CommonStrings.action_cancel)) }, + onClick = ::dismiss, + style = ListItemStyle.Primary, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun RolesAndPermissionsViewPreview(@PreviewParameter(RolesAndPermissionsStateProvider::class) state: RolesAndPermissionsState) { + ElementPreview { + RolesAndPermissionsView( + state = state, + rolesAndPermissionsNavigator = object : RolesAndPermissionsNavigator {}, + ) + } +} diff --git a/features/rolesandpermissions/impl/src/main/res/values-be/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..44841d1 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,59 @@ + + + "Толькі адміністратары" + "Заблакіраваць людзей" + "Выдаліць паведамленні" + "Запрашайце людзей і прымайце запыты на далучэнне" + "Паведамленні і змест" + "Адміністратары і мадэратары" + "Выдаляйце людзей і адхіляйце запыты на далучэнне" + "Змяніць аватар пакоя" + "Рэдагаваць пакой" + "Змяніць назву пакоя" + "Змяніць тэму пакоя" + "Адправіць паведамленні" + "Рэдагаваць адміністратараў" + "Вы не зможаце адмяніць гэта дзеянне. Вы прасоўваеце карыстальніка да таго ж узроўню магутнасці, што і вы." + "Дадаць адміністратара?" + "Паніжэнне ўзроўню" + "Вы не зможаце адмяніць гэтае змяненне, бо паніжаеце сябе. Калі вы апошні адміністратар у пакоі, вярнуць права будзе немагчыма." + "Панізіць сябе?" + "%1$s (У чаканні)" + "(У чаканні)" + "Адміністратары аўтаматычна маюць права мадэратара" + "Рэдагаваць мадэратараў" + "Адміністратары" + "Мадэратары" + "Удзельнікі" + "У вас ёсць незахаваныя змены." + "Захаваць змены?" + "У гэтым пакоі няма заблакіраваных удзельнікаў." + + "%1$d удзельнік" + "%1$d удзельнікі" + "%1$d удзельнікаў" + + "Выдаліць і заблакіраваць удзельніка" + "Толькі выдаліць удзельніка" + "Разблакіраваць" + "Яны змогуць зноў далучыцца да гэтага пакоя, калі іх запросяць." + "Заблакіраваныя" + "Удзельнікі" + "Толькі адміністратары" + "Адміністратары і мадэратары" + "Удзельнікі пакоя" + "Разблакіроўка %1$s" + "Адміністратары" + "Змяніць маю роль" + "Панізіць да ўдзельніка" + "Панізіць да мадэратара" + "Мадэрацыя ўдзельнікаў" + "Паведамленні і змест" + "Мадэратары" + "Скінуць дазволы" + "Пасля скіду дазволаў вы страціце бягучыя налады." + "Скінуць дазволы?" + "Ролі" + "Дэталі пакоя" + "Ролі і дазволы" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-bg/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..b1ca5f9 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,38 @@ + + + "Само администратори" + "Премахване на съобщения" + "Поканване на хора и приемане на заявки за присъединяване" + "Съобщения и съдържание" + "Администратори и модератори" + "Премахване на хора и отхвърляне на заявки за присъединяване" + "Редактиране на стаята" + "Промяна на името на стаята" + "Промяна на темата на стаята" + "Изпращане на съобщения" + "Редактиране на администраторите" + "Добавяне на администратор?" + "Администраторите автоматично имат модераторски права" + "Редактиране на модераторите" + "Администратори" + "Модератори" + "Членове" + + "%1$d човек" + "%1$d души" + + "Членове" + "Само администратори" + "Администратори и модератори" + "Членове на стаята" + "Администратори" + "Промяна на моята роля" + "Модериране на членове" + "Съобщения и съдържание" + "Модератори" + "Нулиране на разрешенията" + "Роли" + "Подробности за стаята" + "Подробности за пространството" + "Роли и разрешения" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-cs/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..3df8e0a --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,84 @@ + + + "Správce" + "Vykázat lidi" + "Odstranit zprávy" + "Člen" + "Pozvat přátele" + "Spravovat členy" + "Zprávy a obsah" + "Moderátor" + "Odebrat osoby" + "Změnit avatar místnosti" + "Upravit podrobnosti" + "Změnit název místnosti" + "Změnit téma místnosti" + "Odeslat zprávy" + "Upravit správce" + "Tuto akci nebudete moci vrátit zpět. Upravujete oprávnění uživatele, tak aby měl stejnou úroveň jako vy." + "Přidat správce?" + "Tuto akci nebudete moci vrátit zpět. Převádíte vlastnictví na vybrané uživatele. Jakmile tuto akci opustíte, bude tato změna trvalá." + "Převést vlastnictví?" + "Degradovat" + "Tuto změnu nebudete moci vrátit zpět, protože sami degradujete, pokud jste posledním privilegovaným uživatelem v místnosti, nebude možné znovu získat oprávnění." + "Degradovat se?" + "%1$s (čekající)" + "(Čeká na vyřízení)" + "Správci mají automaticky oprávnění moderátora" + "Vlastníci mají automaticky administrátorská oprávnění." + "Upravit moderátory" + "Vyberte vlastníky" + "Správci" + "Moderátoři" + "Členové" + "Máte neuložené změny." + "Uložit změny?" + "V této místnosti nejsou žádní vykázaní uživatelé." + + "%1$d vykázán(a)" + "%1$d vykázáni" + "%1$d vykázáných" + + "Zkontrolujte pravopis nebo zkuste nové vyhledávání" + "Žádné výsledky pro “%1$s”" + + "%1$d osoba" + "%1$d osoby" + "%1$d osob" + + "Odebrat a vykázat člena" + "Pouze odebrat člena" + "Zrušit vykázání" + "Pokud budou pozváni, budou se moci do této místnosti znovu připojit." + "Zrušit vykázání z místnosti" + "Vykázaní" + "Členové" + + "%1$d pozván(a)" + "%1$d pozváni" + "%1$d pozvaných" + + "Čekající" + "Správce" + "Moderátor" + "Vlastník" + "Členové místnosti" + "Rušení vykázání %1$s" + "Správci" + "Správci a vlastníci" + "Změnit moji roli" + "Degradovat na člena" + "Degradovat na moderátora" + "Moderování členů" + "Zprávy a obsah" + "Moderátoři" + "Vlastníci" + "Oprávnění" + "Obnovit oprávnění" + "Po obnovení oprávnění ztratíte aktuální nastavení." + "Obnovit oprávnění?" + "Role" + "Podrobnosti místnosti" + "Detaily prostoru" + "Role a oprávnění" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-cy/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..f75f908 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,72 @@ + + + "Gweinyddwyr yn unig" + "Gwahardd pobl" + "Tynnu negeseuon" + "Pawb" + "Gwahodd pobl a derbyn ceisiadau i ymuno" + "Cymedroli aelodau" + "Negeseuon a chynnwys" + "Gweinyddwyr a chymedrolwyr" + "Dileu pobl a gwrthod ceisiadau i ymuno" + "Newid afatar ystafell" + "Ystafell Golygu" + "Newid enw\'r ystafell" + "Newid pwnc yr ystafell" + "Anfon negeseuon" + "Golygu Gweinyddwyr" + "Fyddwch chi ddim yn gallu dadwneud y weithred hon. Rydych chi\'n hyrwyddo\'r defnyddiwr i gael yr un lefel pŵer â chi." + "Ychwanegu Gweinyddwr?" + "Fyddwch chi ddim yn gallu dadwneud y weithred hon. Rydych yn trosglwyddo\'r berchnogaeth i\'r defnyddwyr a ddewiswyd. Unwaith y byddwch yn gadael bydd hyn yn barhaol." + "Trosglwyddo perchnogaeth?" + "Gostwng" + "Fyddwch chi ddim yn gallu dadwneud y newid hwn gan eich bod yn israddio eich hun, os mai chi yw\'r defnyddiwr breintiedig olaf yn yr ystafell bydd yn amhosibl adennill breintiau." + "Israddio eich hun?" + "%1$s (Yn aros)" + "Yn aros" + "Mae gan weinyddwyr freintiau cymedrolwr yn awtomatig" + "Mae gan berchnogion freintiau gweinyddwr yn awtomatig." + "Golygu Cymedrolwyr" + "Dewiswch Berchnogion" + "Gweinyddwyr" + "Cymedrolwyr" + "Aelodau" + "Mae gennych newidiadau heb eu cadw." + "Cadw\'r newidiadau?" + "Nid oes unrhyw ddefnyddwyr gwaharddedig yn yr ystafell hon." + + "%1$d personau" + "%1$d person" + "%1$d berson" + "%1$d person" + "%1$d pherson" + "%1$d person" + + "Gwahardd o ystafell" + "Dileu aelod yn unig" + "Adfer" + "Fyddan nhw yn gallu ymuno â\'r ystafell hon eto os cân nhw wahoddiad." + "Dad-wahardd o\'r ystafell" + "Wedi\'i wahardd" + "Aelodau" + "Gweinyddwyr yn unig" + "Gweinyddwyr a chymedrolwyr" + "Perchennog" + "Aelodau\'r ystafell" + "Dad-wahardd %1$s" + "Gweinyddwyr" + "Gweinyddwyr a pherchnogion" + "Newid fy rôl" + "Israddio aelod" + "Israddio cymedrolwr" + "Cymedroli aelodau" + "Negeseuon a chynnwys" + "Cymedrolwyr" + "Perchnogion" + "Ailosod caniatâd" + "Ar ôl i chi ailosod caniatâd, byddwch yn colli\'r gosodiadau cyfredol." + "Ailosod caniatâd?" + "Rolau" + "Manylion yr ystafell" + "Rolau a chaniatâd" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-da/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..4a0b5d1 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,66 @@ + + + "Kun admins" + "Spær brugere" + "Fjern beskeder" + "Invitér personer og acceptér anmodninger om at deltage" + "Beskeder og indhold" + "Admins og moderatorer" + "Fjern personer og afvis anmodninger om at deltage" + "Skift rummets avatar" + "Rediger rum" + "Skift rummets navn" + "Skift emne for rummet" + "Send beskeder" + "Redigér admins" + "Du kan ikke fortryde denne handling. Du forfremmer brugeren til at have samme magtniveau som dig." + "Tilføj Admin?" + "Du kan ikke fortryde denne handling. Du overfører ejerskabet til de valgte brugere. Når du forlader siden, vil dette være permanent." + "Overdrag ejerskab?" + "Nedgradering" + "Du vil ikke være i stand til at fortryde denne ændring, da du degraderer dig selv. Hvis du er den sidste privilegerede bruger i rummet, vil det være umuligt at genvinde privilegier." + "Nedgrader dig selv?" + "%1$s (Afventer)" + "(Afventer)" + "Administratorer har automatisk moderatorrettigheder" + "Ejere har automatisk administratorrettigheder." + "Redigér moderatorer" + "Vælg ejere" + "Administratorer" + "Moderatorer" + "Medlemmer" + "Du har ændringer, der ikke er gemt." + "Gem ændringer?" + "Der er ingen spærrede brugere i dette rum." + + "%1$d person" + "%1$d personer" + + "Spær fra rum" + "Fjern kun medlem" + "Fjern spærring af" + "De vil være i stand til at deltage i dette rum igen, hvis de inviteres." + "Fjern brugerens spærring fra rummet" + "Spærret" + "Medlemmer" + "Kun admins" + "Admins og moderatorer" + "Ejeren" + "Medlemmer af rummet" + "Ophæver spærring af %1$s" + "Administratorer" + "Administratorer og ejere" + "Skift min rolle" + "Nedgrader til medlem" + "Nedgradering til moderator" + "Moderation af medlemmer" + "Beskeder og indhold" + "Moderatorer" + "Ejere" + "Nulstil tilladelser" + "Når du nulstiller tilladelserne, mister du de nuværende indstillinger." + "Nulstil tilladelser?" + "Roller" + "Detaljer om rummet" + "Roller og tilladelser" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-de/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..2767781 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,66 @@ + + + "Nur Admins" + "Mitglieder sperren" + "Nachrichten entfernen" + "Personen einladen und Beitrittsanfragen annehmen" + "Nachrichten senden & löschen" + "Admins und Moderatoren" + "Personen entfernen und Beitrittsanfragen ablehnen" + "Avatar ändern" + "Chat bearbeiten" + "Chat-Namen ändern" + "Chat Thema ändern" + "Nachrichten senden" + "Admins bearbeiten" + "Du kannst diese Aktion nicht mehr rückgängig machen. Du vergibst dieselbe Rolle, die du auch hast." + "Als Admin hinzufügen?" + "Du kannst diese Aktion nicht rückgängig machen. Du überträgst die Eigentumsrechte an die ausgewählten Nutzer. Sobald du diesen Vorgang abschließt, ist er endgültig." + "Eigentumsrechte übertragen?" + "Zurückstufen" + "Du stufst dich selbst herab. Diese Änderung kann nicht rückgängig gemacht werden. Wenn du der letzte Nutzer mit dieser Rolle bist, ist es nicht möglich, diese Rolle wiederzuerlangen." + "Möchtest du dich selbst herabstufen?" + "%1$s (Ausstehend)" + "(Ausstehend)" + "Admins haben automatisch Moderatorenrechte" + "Eigentümer haben automatisch Adminrechte." + "Moderatoren bearbeiten" + "Wähle Eigentümer" + "Admins" + "Moderatoren" + "Mitglieder" + "Du hast nicht gespeicherte Änderungen." + "Änderungen speichern?" + "Es gibt keine gesperrten Nutzer." + + "%1$d Person" + "%1$d Personen" + + "Mitglied entfernen und sperren" + "Mitglied nur entfernen" + "Sperre aufheben" + "Die Nutzer können dem Chat wieder beitreten, wenn sie eingeladen werden." + "Sperre für diesen Chat aufheben" + "Gesperrt" + "Mitglieder" + "Nur Admins" + "Admins und Moderatoren" + "Eigentümer" + "Mitglieder" + "%1$s wird entsperrt." + "Admins" + "Admins und Eigentümer" + "Ändere meine Rolle" + "Zum Mitglied herabstufen" + "Zum Moderator herabstufen" + "Moderation der Mitglieder" + "Nachrichten senden & löschen" + "Moderatoren" + "Eigentümer" + "Rollen und Berechtigungen zurücksetzen" + "Sobald du die Berechtigungen zurücksetzt, verlierst du die aktuellen Einstellungen." + "Berechtigungen zurücksetzen?" + "Rollen" + "Chat-Details anpassen" + "Rollen und Berechtigungen" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-el/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..8cd14fe --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,59 @@ + + + "Μόνο διαχειριστές" + "Αποκλεισμός ατόμων" + "Αφαίρεση μηνυμάτων" + "Προσκάλεσε άτομα και αποδέξου αιτήματα συμμετοχής" + "Μηνύματα και περιεχόμενο" + "Διαχειριστές και συντονιστές" + "Αφαίρεση ατόμων και απόρριψη αιτημάτων συμμετοχής" + "Αλλαγή εικόνας προφίλ αίθουσας" + "Επεξεργασία Αίθουσας" + "Αλλαγή ονόματος αίθουσας" + "Αλλαγή θέματος αίθουσας" + "Αποστολή μηνυμάτων" + "Επεξεργασία Διαχειριστών" + "Δεν θα μπορείς να αναιρέσεις αυτήν την ενέργεια. Προβιβάζεις τον χρήστη να έχει το ίδιο επίπεδο ισχύος με σένα." + "Προσθήκη Διαχειριστή;" + "Υποβιβασμός" + "Δεν θα μπορέσετε να αναιρέσετε αυτή την αλλαγή καθώς υποβιβάζετε τον εαυτό σας, αν είστε ο τελευταίος χρήστης με δικαιώματα στην αίθουσα θα είναι αδύνατο να ανακτήσετε δικαιώματα." + "Υποβιβασμός του εαυτού σου;" + "%1$s (Σε αναμονή)" + "(Σε αναμονή)" + "Οι διαχειριστές έχουν αυτόματα δικαιώματα συντονιστή" + "Επεξεργασία Συντονιστών" + "Διαχειριστές" + "Συντονιστές" + "Μέλη" + "Έχεις μη αποθηκευμένες αλλαγές." + "Αποθήκευση αλλαγών;" + "Δεν υπάρχουν αποκλεισμένοι χρήστες σε αυτή την αίθουσα." + + "%1$d άτομο" + "%1$d άτομα" + + "Αφαίρεση και αποκλεισμός μέλους" + "Μόνο αφαίρεση μέλους" + "Αναίρεση αποκλεισμού" + "Θα μπορούν να συμμετάσχουν ξανά σε αυτή την αίθουσα, εάν προσκληθούν." + "Άρση αποκλεισμού από την αίθουσα" + "Αποκλεισμένοι" + "Μέλη" + "Μόνο διαχειριστές" + "Διαχειριστές και συντονιστές" + "Μέλη της αίθουσας" + "Άρση αποκλεισμού %1$s" + "Διαχειριστές" + "Άλλαξε τον ρόλο μου" + "Υποβιβασμός σε μέλος" + "Υποβιβασμός σε συντονιστή" + "Συντονισμός μελών" + "Μηνύματα και περιεχόμενο" + "Συντονιστές" + "Επαναφορά δικαιωμάτων" + "Μόλις επαναφέρεις τα δικαιώματα, θα χάσεις τις τρέχουσες ρυθμίσεις." + "Επαναφορά δικαιωμάτων;" + "Ρόλοι" + "Λεπτομέρειες αίθουσας" + "Ρόλοι και δικαιώματα" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-es/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..383ffba --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,59 @@ + + + "Solo administradores" + "Vetar personas" + "Eliminar mensajes" + "Invitar personas y aceptar solicitudes de unión" + "Mensajes y contenido" + "Administradores y moderadores" + "Eliminar personas y rechazar solicitudes de unión" + "Cambiar el avatar de la sala" + "Editar sala" + "Cambiar el nombre de la sala" + "Cambiar el tema de la sala" + "Enviar mensajes" + "Editar administradores" + "No podrás deshacer esta acción. Estás promocionando al usuario para que tenga el mismo nivel de poder que tú." + "¿Agregar Admin?" + "Degradar" + "No podrás deshacer este cambio ya que te estás degradando. Si eres el último usuario privilegiado en la sala será imposible recuperar los privilegios." + "¿Degradarte?" + "%1$s (Pendiente)" + "(Pendiente)" + "Los administradores tienen privilegios de moderador de forma automática" + "Editar moderadores" + "Administradores" + "Moderadores" + "Miembros" + "Tienes cambios sin guardar." + "¿Guardar cambios?" + "No hay usuarios vetados en esta sala." + + "Una persona" + "%1$d personas" + + "Sacar y vetar a un miembro" + "Solo eliminar miembro" + "Quitar veto" + "Podrá volver a unirse a esta sala si se le invita." + "Eliminar veto en la sala" + "Vetados" + "Miembros" + "Solo administradores" + "Administradores y moderadores" + "Miembros de la sala" + "Levantando veto a %1$s" + "Administradores" + "Cambiar mi rol" + "Degradar a miembro" + "Degradar a moderador" + "Moderación de miembros" + "Mensajes y contenido" + "Moderadores" + "Restablecer permisos" + "Una vez que restablezca los permisos, perderá la configuración actual." + "¿Restablecer los permisos?" + "Roles" + "Detalles de la sala" + "Roles y permisos" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-et/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..7924e7c --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,73 @@ + + + "Peakasutajad" + "Suhtluskeelu seadmine" + "Eemalda sõnumid" + "Liikmed" + "Osalejate kutsumine" + "Liikmete haldus" + "Sõnumid ja sisu" + "Moderaatorid" + "Osalejate eemaldamine" + "Jututoa tunnuspildi muutmine" + "Muuda üksikasju" + "Jututoa nime muutmine" + "Jututoa teema muutmine" + "Sõnumite saatmine" + "Muuda peakasutajaid" + "Kuna sa annad teisele kasutajale sinu õigustega võrreldes samad õigused, siis sa ei saa seda muudatust hiljem tagasi pöörata." + "Lisame peakasutaja?" + "Seda tegevust ei saa tagasi pöörata. Järgnevaga annad jututoa omandi üle valitud kasutajatele. Kui lahkus, siis muutub see muudatus püsivaks." + "Kas soovid omandi üle anda?" + "Vähenda õigusi" + "Kui sa võtad endalt kõik õigused ära ja oled viimane peakasutaja selles jututoas, siis sa ei saa seda muudatust hiljem tagasi pöörata." + "Kas vähendad enda õigusi?" + "%1$s (ootel)" + "(ootel)" + "Peakasutajatel on automaatselt ka moderaatori õigused" + "Omanikel on automaatselt ka peakasutaja õigused." + "Muuda moderaatoreid" + "Vali omanikud" + "Peakasutajad" + "Moderaatorid" + "Liikmed" + "Sul on salvestamata muudatusi" + "Kas salvestame muudatused?" + "Suhtluskeeluga kasutajaid pole" + "Palun kontrolli otsingusõna korrektsust ja proovi siis uuesti" + "Otsingul „%1$s“ pole tulemusi" + + "%1$d osaleja" + "%1$d osalejat" + + "Eemalda ja sea suhtluskeeld" + "Ainult eemalda kasutaja" + "Eemalda suhtluskeeld" + "Kutse olemasolul saab ta nüüd jututoaga uuesti liituda" + "Eemalda suhtluskeeld jututoas" + "Suhtluskeeluga kasutajad" + "Liikmed" + "Ootel" + "Peakasutajad" + "Moderaatorid" + "Omanik" + "Jututoas osalejad" + "Eemaldame suhtluskeelu kasutajalt %1$s" + "Peakasutajad" + "Peakasutajad ja omanikud" + "Muuda minu rolli" + "Muuda tavaliikmeks" + "Muuda moderaatoriks" + "Jututoas osalejate modereerimine" + "Sõnumid ja sisu" + "Moderaatorid" + "Omanikud" + "Õigused" + "Lähtesta õigused" + "Kui lähtestad õigused, siis praegune õiguste kombinatsioon läheb kaotsi." + "Kas lähtestame õigused?" + "Rollid" + "Jututoa üksikasjad" + "Kogukonna üksikasjad" + "Rollid ja õigused" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-eu/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..03766f7 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,61 @@ + + + "Administratzaileak soilik" + "Jarri debekua jendeari" + "Kendu mezuak" + "Gonbidatu jendea" + "Mezuak eta edukiak" + "Administratzaileak eta moderatzaileak" + "Kendu jendea" + "Aldatu gelaren abatarra" + "Editatu gela" + "Aldatu gelaren izena" + "Aldatu gelako mintzagaia" + "Bidali mezuak" + "Editatu administratzaileak" + "Administratzailea gehitu?" + "Jabetza eskualdatu?" + "Jaitsi mailaz" + "Ezin izango duzu hau aldatu zure burua mailaz jaisten ari zarelako, zu bazara gelan baimenak dituen azken erabiltzailea ezin izango dira baimenak berreskuratu." + "Zure burua mailaz jaitsi?" + "%1$s (zain)" + "(Egiteke)" + "Administratzaileek automatikoki dute moderatzaile-pribilegioak" + "Editatu moderatzaileak" + "Aukeratu jabeak" + "Administratzaileak" + "Moderatzaileak" + "Kideak" + "Gorde gabeko aldaketak dituzu." + "Aldaketak gorde?" + "Gela honetan ez dago debekua ezarri zaion erabiltzailerik." + + "Pertsona %1$d" + "%1$d pertsona" + + "Kendu kidea eta ezarri debekua" + "Kendu kidea soilik" + "Kendu debekua" + "Debekatuta" + "Kideak" + "Administratzaileak soilik" + "Administratzaileak eta moderatzaileak" + "Jabea" + "Gelako kideak" + "%1$s(r)i debekua kentzen" + "Administratzaileak" + "Administratzaileak eta jabeak" + "Aldatu nire rola" + "Jaitsi maila, kidera" + "Jaitsi maila, moderatzailera" + "Kideen moderazioa" + "Mezuak eta edukiak" + "Moderatzaileak" + "Jabeak" + "Berrezarri baimenak" + "Baimenak berrezarritakoan, uneko ezarpenak galduko dituzu." + "Baimenak berrezarri?" + "Rolak" + "Gelaren xehetasunak" + "Rolak eta baimenak" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-fa/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..c526d05 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,66 @@ + + + "فقط مدیران" + "تحریم افراد" + "برداشتن پیام‌ها" + "هرکسی" + "دعوت افراد و پذیرش درخواست‌های پیوستن" + "نظارت اعضا" + "پیام‌ها و محتوا" + "مدیرن و ناظران" + "برداشتن افراد و رد درخواست‌های پیوستن" + "تغییر چهرک اتاق" + "ویرایش اتاق" + "تغییر نام اتاق" + "دگرگونی موضوع اتاق" + "فرستادن پیام‌ها" + "ویرایش مدیران" + "قادر نخواهید بود این کنش را بازکردانید. داردید کاربر را به سطح قدرت خودتان ارتقا می‌دهید." + "افزودن مدیر؟" + "انتقال مالکیت؟" + "تنزل بده" + "شما نمی‌توانید این تغییر را بازگردانید زیرا در حال تنزل نقش خود در اتاق هستید، اگر آخرین کاربر ممتاز در اتاق باشید، امکان دستیابی مجدد به دسترسی‌های سطح بالای اتاق غیرممکن است." + "تنزل نقش شما در اتاق؟" + "%1$s (منتظر)" + "(منتظر)" + "مدیران به صورت خودکار اجازه‌های نظارتی را دارند" + "ماکان به صورت خودکار اجازه‌های مدیریتی را دارند." + "ویرایش ناظران" + "گزینش مالکان" + "مدیران" + "ناظم‌ها" + "اعضا" + "تغییراتی ذخیره نشده دارید." + "ذخیرهٔ تغییرات؟" + "هیچ کاربر محرومی در این اتاق نیست." + + "%1$d نفر" + "%1$d نفر" + + "برداشت و تحریم عضو" + "تنها برداشتن عضو" + "رفع انسداد" + "در صورت دعوت می‌تواند دوباره به اتاق بپیوندد." + "تحریم نکردن از اتاق" + "محروم" + "اعضا" + "فقط مدیران" + "مدیرن و ناظران" + "مالک" + "اعضای اتاق" + "رفع تحریم %1$s" + "مدیران" + "مدیران و مالکان" + "تغییر نقشم" + "تنزّل به عضو" + "تنزّل به ناظم" + "نظارت اعضا" + "پیام‌ها و محتوا" + "ناظم‌ها" + "مالکان" + "بازنشانی اجازه‌ها" + "بازنشانی اجازه‌ها؟" + "نقش‌ها" + "جزییات اتاق" + "نقش‌ها و اجازه‌ها" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-fi/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..1322089 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,70 @@ + + + "Ylläpitäjä" + "Porttikieltojen antaminen" + "Viestien poistaminen" + "Jäsen" + "Ihmisten kutsuminen ja liittymispyyntöjen hyväksyminen" + "Jäsenien hallinta" + "Viestit ja sisältö" + "Valvoja" + "Henkilöiden poistaminen ja liittymispyyntöjen hylkääminen" + "Huoneen avatarin vaihtaminen" + "Muokkaa tietoja" + "Huoneen nimen vaihtaminen" + "Huoneen aiheen vaihtaminen" + "Viestien lähettäminen" + "Muokkaa ylläpitäjiä" + "Et voi peruuttaa tätä toimenpidettä. Ylennät käyttäjän samalle oikeustasolle kuin sinä." + "Lisätäänkö ylläpitäjä?" + "Et voi kumota tätä toimintoa. Olet siirtämässä omistajuuden valituille käyttäjille. Kun poistut, muutos on pysyvä." + "Siirretäänkö omistajuus?" + "Alenna" + "Et voi perua tätä muutosta, koska olet alentamassa itseäsi. Jos olet viimeinen oikeutettu henkilö tässä huoneessa, oikeuksia ei voi enää saada takaisin." + "Haluatko alentaa itsesi?" + "%1$s (Kutsuttu)" + "(Kutsuttu)" + "Ylläpitäjillä on automaattisesti valvojan oikeudet" + "Omistajilla on automaattisesti ylläpitäjän oikeudet." + "Muokkaa valvojia" + "Valitse Omistajat" + "Ylläpitäjät" + "Valvojat" + "Jäsenet" + "Sinulla on tallentamattomia muutoksia" + "Tallennetaanko muutokset?" + "Porttikiellettyjä käyttäjiä ei ole." + + "%1$d henkilö" + "%1$d henkilöä" + + "Poista jäsen huoneesta ja anna porttikielto" + "Poista vain jäsen huoneesta" + "Poista porttikielto" + "He voivat liittyä tähän huoneeseen uudelleen, jos heidät kutsutaan." + "Poista porttikielto huoneesta" + "Porttikiellot" + "Jäsenet" + "Ylläpitäjä" + "Valvoja" + "Omistaja" + "Huoneen jäsenet" + "Poistetaan käyttäjän %1$s porttikieltoa" + "Ylläpitäjät" + "Ylläpitäjät ja omistajat" + "Vaihda rooliani" + "Alenna jäseneksi" + "Alenna valvojaksi" + "Jäsenten valvonta" + "Viestit ja sisältö" + "Valvojat" + "Omistajat" + "Oikeudet" + "Nollaa oikeudet" + "Kun nollaat käyttöoikeudet, menetät nykyiset asetukset." + "Nollataanko oikeudet?" + "Roolit" + "Huoneen tiedot" + "Tilan tiedot" + "Roolit ja oikeudet" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-fr/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..f440723 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,81 @@ + + + "Administrateurs" + "Bannir des participants" + "Supprimer des messages" + "Membre" + "Inviter des personnes" + "Gérer les membres" + "Messages et contenus" + "Modérateurs" + "Retirer des personnes" + "Changer l’avatar du salon" + "Modifier les détails" + "Changer le nom du salon" + "Changer le sujet du salon" + "Envoyer des messages" + "Modifier les administrateurs" + "Vous ne pourrez pas annuler cette action. Vous êtes en train de promouvoir l’utilisateur pour qu’il ait le même niveau que vous." + "Ajouter un administrateur ?" + "Vous ne pourrez pas annuler cette action. Vous transférez la propriété aux utilisateurs sélectionnés. Une fois que vous serez parti, l’action sera définitive." + "Transférer la propriété?" + "Rétrograder" + "Vous ne pourrez pas annuler ce changement car vous vous rétrogradez, si vous êtes le dernier utilisateur privilégié du salon il sera impossible de retrouver les privilèges." + "Vous rétrograder ?" + "%1$s (En attente)" + "(En attente)" + "Les administrateurs ont automatiquement les privilèges des modérateurs" + "Les propriétaires disposent automatiquement des privilèges des administrateurs." + "Modifier les modérateurs" + "Choisissez les propriétaires" + "Administrateurs" + "Modérateurs" + "Membres" + "Vous avez des modifications non-enregistrées." + "Enregistrer les changements ?" + "Il n’y a pas d’utilisateur banni." + + "%1$d Banni(e)" + "%1$d Banni(e)s" + + "Vérifiez la saisie ou effectuez une nouvelle recherche" + "Aucun résultat pour «%1$s»" + + "%1$d Personne" + "%1$d Personnes" + + "Bannir du salon" + "Retirer le membre uniquement" + "Débannir" + "Il pourra rejoindre le salon à nouveau si il est invité." + "Débannir du salon" + "Bannis" + "Membres" + + "%1$d Invité(e)" + "%1$d Invité(e)s" + + "En attente" + "Administrateurs" + "Modérateurs" + "Propriétaire" + "Membres du salon" + "Débannissement de %1$s" + "Administrateurs" + "Administrateurs et propriétaires" + "Changer mon rôle" + "Devenir simple membre" + "Devenir simple modérateur" + "Administration des membres" + "Messages et contenus" + "Modérateurs" + "Propriétaires" + "Autorisations" + "Réinitialisation des autorisations" + "La réinitialisation des autorisations entraîne la perte des réglages actuels." + "Réinitialisation des autorisations ?" + "Rôles" + "Détails du salon" + "Détails de l’espace" + "Rôles & autorisations" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-hu/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..77e6e3b --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,71 @@ + + + "Adminisztrátor" + "Emberek kitiltása" + "Üzenetek eltávolítása" + "Tag" + "Emberek meghívása" + "Tagok kezelése" + "Üzenetek és tartalom" + "Moderátor" + "Emberek eltávolítása" + "Szoba profilképének módosítása" + "Részletek szerkesztése" + "Szoba nevének módosítása" + "Szoba témájának módosítása" + "Üzenetek küldése" + "Adminisztrátorok szerkesztése" + "Ezt a műveletet nem fogja tudja visszavonni. Ugyanarra a szintre lépteti elő a felhasználót, mint amellyel Ön is rendelkezik." + "Adminisztrátor hozzáadása?" + "Ezt a műveletet nem lehet visszavonni. A tulajdonjogot a kiválasztott felhasználókra ruházza át. Távozás után a művelet véglegessé válik." + "Átruházza a tulajdonjogot?" + "Lefokozás" + "Ezt a változtatást nem fogja tudni visszavonni, mivel lefokozza magát, ha Ön az utolsó jogosultságokkal rendelkező felhasználó a szobában, akkor lehetetlen lesz visszaszerezni a jogosultságokat." + "Lefokozza magát?" + "%1$s (függőben)" + "(Függőben)" + "Az adminisztrátorok automatikusan moderátori jogosultságokkal rendelkeznek" + "A tulajdonosok automatikusan adminisztrátori jogosultsággal rendelkeznek." + "Moderátorok szerkesztése" + "Tulajdonosok kiválasztása" + "Adminisztrátorok" + "Moderátorok" + "Tagok" + "Mentetlen módosításai vannak." + "Menti a módosításokat?" + "Ebben a szobában nincsenek kitiltott felhasználók." + + "%1$d személy" + "%1$d személy" + + "Eltávolítás és a tag kitiltása" + "Csak a tag eltávolítása" + "Tiltás feloldása" + "Ehhez a szobához is csatlakozhat, ha meghívják." + "Visszaengedés a szobába" + "Kitiltva" + "Tagok" + "Függőben" + "Adminisztrátor" + "Moderátor" + "Tulajdonos" + "Szoba tagjai" + "%1$s tiltásának feloldása" + "Adminisztrátorok" + "Adminisztrátorok és tulajdonosok" + "Saját szerepkör módosítása" + "Lefokozás taggá" + "Lefokozás moderátorrá" + "Tagok moderálása" + "Üzenetek és tartalom" + "Moderátorok" + "Tulajdonosok" + "Jogosultságok" + "Jogosultságok visszaállítása" + "A jogosultságok visszaállítása után a jelenlegi beállítások elvesznek." + "Jogosultságok visszaállítása?" + "Szerepkörök" + "Szoba részletei" + "Tér részletei" + "Szerepkörök és jogosultságok" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-in/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..2a1cd15 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,58 @@ + + + "Hanya admin" + "Cekal orang-orang" + "Hilangkan pesan" + "Undang orang-orang dan terima permintaan untuk bergabung" + "Pesan dan konten" + "Admin dan moderator" + "Keluarkan orang-orang dan tolak permintaan untuk bergabung" + "Ubah avatar ruangan" + "Sunting Ruangan" + "Ubah nama ruangan" + "Ubah topik ruangan" + "Kirim pesan" + "Sunting Admin" + "Anda tidak akan dapat mengurungkan tindakan ini. Anda mempromosikan pengguna untuk memiliki tingkat daya yang sama seperti Anda." + "Tambahkan Admin?" + "Turunkan" + "Anda tidak akan dapat mengurungkan perubahan ini karena Anda sedang menurunkan Anda sendiri, jika Anda merupakan pengguna dengan hak khusus dalam ruangan maka tidak akan memungkinkan untuk mendapatkan hak tersebut lagi." + "Turunkan Anda sendiri?" + "%1$s (Tertunda)" + "(Tertunda)" + "Admin secara otomatis memiliki hak moderator" + "Sunting Moderator" + "Admin" + "Moderator" + "Anggota" + "Anda memiliki perubahan yang belum disimpan." + "Simpan perubahan?" + "Tidak ada pengguna yang dicekal dalam ruangan ini." + + "%1$d orang" + + "Keluarkan dan cekal anggota" + "Hanya keluarkan anggota" + "Batalkan pencekalan" + "Pengguna dapat bergabung ke ruangan ini lagi jika diundang." + "Batalkan cekalan dari ruangan" + "Tercekal" + "Anggota" + "Hanya admin" + "Admin dan moderator" + "Anggota ruangan" + "Membatalkan cekalan %1$s" + "Admin" + "Ubah peran saya" + "Turunkan ke anggota" + "Turunkan ke moderator" + "Moderasi anggota" + "Pesan dan konten" + "Moderator" + "Atur ulang perizinan" + "Setelah Anda mengatur ulang perizinan, Anda akan kehilangan pengaturan Anda saat ini." + "Atur ulang perizinan?" + "Peran" + "Detail ruangan" + "Peran dan perizinan" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-it/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..2b439a6 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,81 @@ + + + "Amministratore" + "Escludi membri" + "Rimuovi messaggi" + "Membro" + "Invita persone" + "Gestisci membri" + "Messaggi e contenuti" + "Moderatore" + "Rimuovi membri" + "Cambia avatar della stanza" + "Modifica dettagli" + "Cambia il nome della stanza" + "Cambiare l\'argomento della stanza" + "Inviare messaggi" + "Modifica amministratori" + "Non potrai annullare questa azione. Stai promuovendo l\'utente al tuo stesso livello di potere." + "Aggiungi amministratore?" + "Non potrai annullare questa azione. Stai trasferendo la proprietà agli utenti selezionati. Una volta abbandonato, questa azione sarà definitiva." + "Trasferire proprietà?" + "Declassa" + "Non potrai annullare questa modifica perché ti stai declassando, se sei l\'ultimo utente privilegiato nella stanza, sarà impossibile riottenere i privilegi." + "Declassare te stesso?" + "%1$s (In attesa)" + "(In attesa)" + "Gli amministratori hanno automaticamente i privilegi di moderatore" + "I proprietari hanno automaticamente privilegi di amministratore." + "Modifica moderatori" + "Scegli i proprietari" + "Amministratori" + "Moderatori" + "Membri" + "Hai delle modifiche non salvate." + "Salvare le modifiche?" + "Non ci sono utenti bannati." + + "%1$d Bannato" + "%1$d Bannati" + + "Controlla l\'ortografia o prova una nuova ricerca" + "Nessun risultato per “%1$s ”" + + "%1$d Persona" + "%1$d Persone" + + "Rimuovi ed escludi" + "Rimuovi soltanto" + "Riammetti" + "Potrà entrare nuovamente in questa stanza se invitato." + "Riammetti nella stanza" + "Esclusi" + "Membri" + + "%1$d Invitato" + "%1$d Invitati" + + "In attesa" + "Amministratore" + "Moderatore" + "Proprietario" + "Membri della stanza" + "Riammissione di %1$s" + "Amministratori" + "Amministratori e proprietari" + "Cambia il mio ruolo" + "Declassa a membro" + "Declassa a moderatore" + "Moderazione dei membri" + "Messaggi e contenuti" + "Moderatori" + "Proprietari" + "Autorizzazioni" + "Reimpostare le autorizzazioni" + "Una volta reimpostate le autorizzazioni, perderai le impostazioni correnti." + "Reimpostare autorizzazioni?" + "Ruoli" + "Dettagli della stanza" + "Dettagli dello spazio" + "Ruoli e autorizzazioni" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-ka/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..8af9ecb --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,57 @@ + + + "მხოლოდ ადმინისტრატორები" + "მომხმარებლების დაბლოკვა" + "შეტყობინებების წაშლა" + "მომხმარებლების მოწვევა და გაწევრიანების მოთხოვნების დადასტურება" + "შეტყობინებები და შინაარსი" + "ადმინისტრატორები და მოდერატორები" + "მომხმარებლების გაგდება და გაწევრიანების მოთხოვნების უარყოფა" + "ოთახის სურათის შეცვლა" + "ოთახის რედაქტირება" + "ოთახის სახელის შეცვლა" + "ოთახის თემის შეცვლა" + "შეტყობინებების გაგზავნა" + "ადმინისტრატორების რედაქტირება" + "ამ მოქმედების გაუქმებას ვერ შეძლებთ. თქვენ ნიშნავთ ამ მომხმარებელს იმავე ძალაუფლების დონეზე, რომელიც გაქვთ თქვენ." + "ადმინისტრატორის დამატება?" + "დაქვეითება" + "იმის გამო, რომ აქვეითებთ თქვენ თავს, ამ მოქმედებას ვერ გააუქმებთ. პრივილეგიების აღდგენა შეუძლებელია თუ თქვენ ბოლო პრივილეგირებული მომხმარებელი ხართ ამ ოთახში." + "გსურთ საკუთარი თავის დაქვეითება?" + "%1$s (მოლოდინი)" + "(მოლოდინში)" + "მოდერატორების რედაქტირება" + "ადმინისტრატორები" + "მოდერატორები" + "წევრები" + "თქვენ გაქვთ შეუნახავი ცვლილებები" + "შენახვა?" + "ამ ოთახში არაა დაბლოკილი მომხმარებლები." + + "%1$d ადამიანი" + "%1$d ადამიანი" + + "წევრის წაშლა და დაბლოკვა" + "მხოლოდ წევრის წაშლა" + "განბლოკვა" + "მოწვევის შემთხვევაში განბლოკილი მომხმარებელი ისევ შეძლებს ოთახს შეუერთდეს." + "დაბლოკილები" + "წევრები" + "მხოლოდ ადმინისტრატორები" + "ადმინისტრატორები და მოდერატორები" + "ოთახის წევრები" + "%1$s-ს განბლოკვა" + "ადმინისტრატორები" + "ჩემი როლის შეცვლა" + "დაქვეითება წევრამდე" + "დაქვეითება მოდერატორამდე" + "წევრების მოდერირება" + "შეტყობინებები და შინაარსი" + "მოდერატორები" + "ნებართვების გადაყენება" + "ნებართვების გადაყენების შემთხვევაში მიმდინარე პარამეტრებს დაკარგავთ." + "გადავაყენოთ ცვლილებები?" + "როლები" + "ოთახის დეტალები" + "როლები და ნებართვები" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-ko/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..89b3e0b --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,65 @@ + + + "관리자 전용" + "사용자 차단" + "메시지 삭제" + "사람들을 초대하고 가입 요청을 수락합니다" + "메시지 및 콘텐츠" + "관리자 및 중재자" + "사람들을 제거하고 가입 요청을 거부합니다" + "방 아바타 변경" + "방 편집" + "방 이름 변경" + "방 화제 변경" + "메시지 보내기" + "관리자 편집" + "이 작업은 실행 취소할 수 없습니다. 해당 사용자에게 당신과 동일한 권한 레벨을 부여하는 것입니다." + "관리자를 추가하시겠습니까?" + "이 작업을 취소할 수 없습니다. 선택한 사용자에게 소유권을 이전합니다. 이 작업을 완료하면 변경 사항은 영구적으로 적용됩니다." + "소유권을 이전하시겠습니까?" + "강등하다" + "이 변경 사항은 자신을 강등하는 것이므로 실행 취소할 수 없습니다. 해당 방에서 권한을 가진 마지막 사용자인 경우 권한을 다시 얻는 것은 불가능합니다." + "자신을 강등하시겠습니까?" + "%1$s (보류 중)" + "(보류 중)" + "관리자는 자동으로 중재자 권한을 갖습니다." + "소유자는 자동으로 관리자 권한을 갖습니다." + "편집 중재자" + "소유자 선택" + "관리자" + "중재자" + "회원들" + "저장되지 않은 변경 사항이 있습니다." + "변경 사항을 저장하시겠습니까?" + "이 방에는 차단된 사용자가 없습니다." + + "%1$d 사람" + + "방에서 차단" + "회원만 삭제할 수 있습니다." + "금지 해제" + "초대받으면 이 방에 다시 들어올 수 있습니다." + "방에서 차단 해제" + "차단됨" + "회원들" + "관리자 전용" + "관리자 및 중재자" + "소유자" + "방 회원들" + "차단 해제 %1$s" + "관리자" + "관리자 및 소유자" + "내 역할 변경" + "회원으로 강등" + "중재자로 강등시키다" + "회원 조정" + "메시지 및 콘텐츠" + "중재자" + "소유자" + "권한 재설정" + "권한을 재설정하면 현재 설정이 모두 삭제됩니다." + "권한을 재설정하시겠습니까?" + "역할" + "방 세부 정보" + "역할 및 권한" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-lt/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..b82e882 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,10 @@ + + + "Redaguoti kambarį" + + "%1$d asmuo" + "%1$d asmenys" + "%1$d asmenų" + + "Kambario nariai" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-nb/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..65664c1 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,66 @@ + + + "Kun for administratorer" + "Forby folk" + "Fjern meldinger" + "Inviter folk og godta forespørsler om å bli med" + "Meldinger og innhold" + "Administratorer og moderatorer" + "Fjern folk og avslå forespørsler om å bli med" + "Endre romavatar" + "Rediger rom" + "Endre romnavn" + "Endre temaet til rommet" + "Send meldinger" + "Rediger administratorer" + "Du vil ikke kunne angre denne handlingen. Du forfremmer brukeren til å ha samme rettighetsnivå som deg." + "Legg til administrator?" + "Du kan ikke angre denne handlingen. Du overfører eierskapet til de valgte brukerne. Når du forlater siden, vil dette være permanent." + "Overføre eierskapet?" + "Degradere" + "Du vil ikke kunne angre denne endringen ettersom du degraderer deg selv, og hvis du er den siste privilegerte brukeren i rommet, vil det være umulig å få tilbake privilegiene." + "Degradere deg selv?" + "%1$s (Venter)" + "(Venter)" + "Administratorer har automatisk moderatorrettigheter" + "Eiere har automatisk administratorrettigheter." + "Rediger moderatorer" + "Velg eiere" + "Administratorer" + "Moderatorer" + "Medlemmer" + "Du har endringer som ikke er lagret." + "Lagre endringer?" + "Det er ingen utestengte brukere i dette rommet." + + "%1$d person" + "%1$d personer" + + "Fjern og utesteng medlem" + "Bare fjern medlem" + "Opphev utestengelse" + "De vil kunne bli med i dette rommet igjen hvis de blir invitert." + "Fjern utestengelsen fra rommet" + "Utestengt" + "Medlemmer" + "Kun for administratorer" + "Administratorer og moderatorer" + "Eier" + "Medlemmer av rommet" + "Oppheve utestengelsen av %1$s" + "Administratorer" + "Administratorer og eiere" + "Endre rollen min" + "Nedgradere til medlem" + "Nedgradere til moderator" + "Moderering av medlemmer" + "Meldinger og innhold" + "Moderatorer" + "Eiere" + "Tilbakestill tillatelser" + "Når du har tilbakestilt tillatelsene, mister du gjeldende innstillinger." + "Vil du tilbakestille tillatelsene?" + "Roller" + "Romdetaljer" + "Roller og tillatelser" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-nl/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..2973341 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,58 @@ + + + "Alleen beheerders" + "Personen verbannen" + "Berichten verwijderen" + "Nodig personen uit en accepteer verzoeken om deel te nemen" + "Berichten en inhoud" + "Beheerders en moderators" + "Verwijder personen en weiger verzoeken om deel te nemen" + "Kamerafbeelding wijzigen" + "Kamer bewerken" + "Kamernaam wijzigen" + "Kameronderwerp wijzigen" + "Berichten verzenden" + "Beheerders bewerken" + "Je kunt deze actie niet ongedaan maken. Je bevordert deze gebruiker tot hetzelfde machtsniveau als jij." + "Beheerder toevoegen?" + "Degraderen" + "Je kunt deze wijziging niet ongedaan maken omdat je jezelf degradeert. Als je de laatste gebruiker met bevoegdheden in de kamer bent, is het onmogelijk om deze bevoegdheden terug te krijgen." + "Jezelf degraderen?" + "%1$s (In behandeling)" + "(In afwachting)" + "Beheerders hebben automatisch moderatorrechten" + "Moderators bewerken" + "Beheerders" + "Moderators" + "Leden" + "Je hebt niet-opgeslagen wijzigingen" + "Wijzigingen opslaan?" + "Er zijn geen verbannen gebruikers in deze kamer." + + "%1$d persoon" + "%1$d personen" + + "Lid verwijderen en verbannen" + "Alleen lid verwijderen" + "Ontbannen" + "Ze kunnen opnieuw tot de kamer toetreden als ze worden uitgenodigd." + "Verbannen" + "Leden" + "Alleen beheerders" + "Beheerders en moderators" + "Kamerleden" + "%1$s ontbannen" + "Beheerders" + "Mijn rol wijzigen" + "Degraderen tot lid" + "Degraderen tot moderator" + "Moderatie van leden" + "Berichten en inhoud" + "Moderators" + "Rechten opnieuw instellen" + "Als je de rechten opnieuw instelt, raak je de huidige instellingen kwijt." + "Rechten opnieuw instellen?" + "Rollen" + "Kamergegevens" + "Rollen en rechten" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-pl/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..44659c4 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,67 @@ + + + "Tylko administratorzy" + "Banowanie osób" + "Usuń wiadomości" + "Zapraszanie osób i akceptowanie próśb o dołączenie" + "Wiadomości i zawartość" + "Administratorzy i moderatorzy" + "Usuwanie osób i odrzucanie próśb o dołączenie" + "Zmień awatar pokoju" + "Edytuj pokój" + "Zmień nazwę pokoju" + "Zmień temat pokoju" + "Wysyłanie wiadomości" + "Edytuj administratorów" + "Tej akcji nie będzie można cofnąć. Promujesz użytkownika, który będzie posiadał takie same uprawnienia jak Ty." + "Dodać administratora?" + "Tej akcji nie będzie można cofnąć. Przenosisz prawa własności na wybranych użytkowników. Po opuszczeniu tej strony zmiana będzie nieodwracalna." + "Przenieść własność?" + "Zdegraduj" + "Nie będzie można cofnąć tej zmiany, jeśli się zdegradujesz. Jeśli jesteś ostatnim uprzywilejowanym użytkownikiem w pokoju, nie będziesz w stanie odzyskać uprawnień." + "Zdegradować siebie?" + "%1$s (Oczekujące)" + "(Oczekujący)" + "Administratorzy automatycznie mają uprawnienia moderatora" + "Właściciele automatycznie mają uprawnienia administratora." + "Edytuj moderatorów" + "Wybierz właścicieli" + "Administratorzy" + "Moderatorzy" + "Członków" + "Masz niezapisane zmiany." + "Zapisać zmiany?" + "W tym pokoju nie ma zbanowanych użytkowników." + + "%1$d osoba" + "%1$d osoby" + "%1$d osób" + + "Usuń i zbanuj członka" + "Tylko usuń członka" + "Odbanuj" + "Będą mogli ponownie dołączyć do tego pokoju, jeśli zostaną zaproszeni." + "Odbanuj z pokoju" + "Zbanowanych" + "Członków" + "Tylko administratorzy" + "Administratorzy i moderatorzy" + "Właściciel" + "Członkowie pokoju" + "Odbanowanie %1$s" + "Administratorzy" + "Administratorzy i właściciele" + "Zmień moją rolę" + "Zdegraduj do członka" + "Zdegraduj do moderatora" + "Moderacja członków" + "Wiadomości i zawartość" + "Moderatorzy" + "Właściciele" + "Resetuj uprawnienia" + "Po zresetowaniu uprawnień utracisz bieżące ustawienia." + "Zresetować uprawnienia?" + "Role" + "Szczegóły pokoju" + "Role i uprawnienia" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-pt-rBR/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..886d3c5 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,81 @@ + + + "Administradores" + "Banir pessoas" + "Remover mensagens" + "Membro" + "Convidar pessoas" + "Gerenciar membros" + "Mensagens e conteúdo" + "Moderador" + "Remover pessoas" + "Alterar avatar da sala" + "Editar detalhes" + "Alterar nome da sala" + "Alterar tópico da sala" + "Enviar mensagens" + "Editar administradores" + "Você não poderá desfazer essa ação. Você está promovendo o usuário a ter o mesmo nível de poder que você." + "Adicionar administrador?" + "Você não poderá desfazer isto. Você está transferindo a posse desta sala para os usuários selecionados. Ao sair, isto será permanente." + "Transferir posse?" + "Rebaixar" + "Você não poderá desfazer essa alteração, pois estará removendo seus próprios privilégios. Se você for o último usuário privilegiado na sala, será impossível recuperar os privilégios." + "Rebaixar seu próprio privilégio?" + "%1$s (pendente)" + "(pendente)" + "Os administradores têm privilégios de moderador automaticamente" + "Proprietários automaticamente têm privilégios de administradores." + "Editar moderadores" + "Escolher Proprietários" + "Administradores" + "Moderadores" + "Membros" + "Você tem alterações não salvas." + "Salvar alterações?" + "Não há usuários banidos." + + "%1$d banido" + "%1$d banidos" + + "Confira a ortografia ou tente uma nova busca" + "Nenhum resultado para “%1$s”" + + "%1$d pessoa" + "%1$d pessoas" + + "Banir da sala" + "Somente remover o membro" + "Desbanir" + "Esta pessoa poderá entrar nesta sala novamente se for convidada." + "Desbanir da sala" + "Banidos" + "Membros" + + "%1$d convidado" + "%1$d convidados" + + "Pendente" + "Administradores" + "Moderador" + "Proprietário" + "Membros da sala" + "Desbanindo %1$s" + "Administradores" + "Administradores e proprietários" + "Alterar meu cargo" + "Rebaixar para membro" + "Rebaixar para moderador" + "Moderação de membros" + "Mensagens e conteúdo" + "Moderadores" + "Proprietários" + "Permissões" + "Redefinir permissões" + "Depois de redefinir as permissões, você perderá as configurações atuais." + "Redefinir permissões?" + "Cargos" + "Detalhes da sala" + "Detalhes do espaço" + "Cargos e permissões" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-pt/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..32db66e --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,66 @@ + + + "Apenas administradores" + "Banir pessoas" + "Remover mensagens" + "Convidar pessoas e aceitar pedidos de entrada" + "Mensagens e conteúdo" + "Administradores e moderadores" + "Remover pessoas e rejeitar pedidos de entrada" + "Alterar o ícone da sala" + "Editar sala" + "Altera o nome da sala" + "Alterar a descrição da sala" + "Enviar mensagens" + "Editar Administradores" + "Não poderás desfazer esta ação. Estás a promover o utilizador para ter o mesmo nível de poder que tu." + "Adicionar administrador?" + "Não será possível reverter esta ação. Estás a transferir a posse para os utilizadores selecionados. Será permanente depois de saíres." + "Transferir posse?" + "Despromover" + "Não poderás desfazer esta alteração, uma vez que te estás a despromover. Se fores o último utilizador privilegiado na sala, será impossível recuperar os privilégios." + "Despromover-te?" + "%1$s (pendente)" + "(pendente)" + "Os administradores têm automaticamente privilégios de moderador" + "Os donos têm permissões de administrador automaticamente" + "Editar Moderadores" + "Escolher donos" + "Administradores" + "Moderadores" + "Participantes" + "Tens alterações por guardar." + "Guardar alterações?" + "Não há nenhum utilizador banido desta sala." + + "%1$d pessoa" + "%1$d pessoas" + + "Remover e banir participante" + "Remover apenas" + "Anular banimento" + "Poderão juntar-se novamente a esta sala se forem convidados." + "Desbanir da sala" + "Banidos" + "Participantes" + "Apenas administradores" + "Administradores e moderadores" + "Dono / Dona" + "Participantes" + "A anular banimento de %1$s" + "Administradores" + "Administradores e donos" + "Alterar o meu cargo" + "Despromover para participante" + "Despromover para moderador" + "Moderação de participantes" + "Mensagens e conteúdo" + "Moderadores" + "Donos" + "Repor permissões" + "Ao repores as permissões, perderás as configurações atuais." + "Repor as permissões?" + "Cargos" + "Detalhes da sala" + "Cargos e permissões" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-ro/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..c2b248f --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,66 @@ + + + "Doar administratori" + "Interziceți persoane" + "Ștergeți mesajele" + "Invitați persoane și acceptați cereri de alaturare" + "Mesaje și conținut" + "Administratori și moderatori" + "Îndepărtați persoane și refuzați cereri de alăturare" + "Schimbați avatarul camerei" + "Editați camera" + "Schimbă numele camerei" + "Schimbați subiectul camerei" + "Trimiteți mesaje" + "Editați administratorii" + "Promovați utilizatorul să aibă același nivel de putere ca dumneavoastră. Nu veți putea anula această acțiune." + "Adăugați administrator?" + "Nu veți putea anula această acțiune. Transferați dreptul de proprietate către utilizatorii selectați. Odată ce părăsiți această pagină, acțiunea va fi definitivă." + "Transferați proprietatea?" + "Retrogradare" + "Nu veți putea anula această modificare, deoarece vă retrogradați. Dacă sunteți ultimul utilizator privilegiat din cameră, va fi imposibil să recâștigați privilegiile." + "Vreți să vă retrogradați?" + "%1$s (În așteptare)" + "(În așteptare)" + "Administratorii au automat privilegii de moderator" + "Proprietarii au automat privilegii de administrator." + "Editați moderatorii" + "Alegeți proprietari" + "Administratori" + "Moderatori" + "Membri" + "Aveți modificări nesalvate." + "Salvați modificările?" + "Nu există utilizatori interziși în această cameră." + + "o persoană" + "%1$d persoane" + + "Îndepărtați și interziceți membrul" + "Doar înlăturare" + "Anulare excludere" + "Se vor putea alătura din nou acestei săli dacă sunt invitați." + "Revocati excluderea din camera" + "Excluși" + "Membri" + "Doar administratori" + "Administratori și moderatori" + "Proprietar" + "Membrii camerei" + "Se anulează interzicerea lui %1$s" + "Administratori" + "Administratori și proprietari" + "Schimbare rol" + "Degradare la membru" + "Degradare la moderator" + "Moderarea membrilor" + "Mesaje și conținut" + "Moderatori" + "Proprietari" + "Resetați permisiunile" + "După ce resetați permisiunile, veți pierde setările curente." + "Resetați permisiunile?" + "Roluri" + "Detaliile camerei" + "Roluri și permisiuni" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-ru/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..f54b984 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,74 @@ + + + "Только администраторы" + "Блокировать людей могут" + "Удалить сообщения" + "Участник" + "Пригласить людей" + "Список участников" + "Сообщения и содержание" + "Модератор" + "Удалять участников" + "Менять изображение комнаты могут" + "Редактировать комнату" + "Менять название комнаты могут" + "Менять тему комнаты могут" + "Отправлять сообщения могут" + "Редактировать роль администраторов" + "Вы не сможете отменить это действие. Вы устанавливаете уровень пользователю соответствующий вашему." + "Добавить администратора?" + "Отменить данное действие будет невозможно. Владение передастся выбранным пользователям. После вашего выхода действие станет необратимым." + "Передать владение?" + "Понизить уровень" + "Вы не сможете отменить это изменение, так как понижаете себя статус. Если вы являетесь последним привилегированным пользователем в комнате, восстановить привилегии будет невозможно." + "Понизить свой уровень?" + "%1$s (Ожидание)" + "(В ожидании)" + "Администраторы автоматически получают права модератора" + "Владельцы автоматически получают права администратора." + "Редактировать роль модераторов" + "Назначить владельцев" + "Администраторы" + "Модераторы" + "Участники" + "У вас есть несохраненные изменения." + "Сохранить изменения?" + "В этой комнате нет заблокированных пользователей." + "Проверьте правописание или попробуйте новый поиск" + "Отсутствует результат по запросу “%1$s”" + + "%1$d пользователь" + "%1$d пользователя" + "%1$d пользователей" + + "Удалить и заблокировать участника" + "Только удалить участника" + "Разблокировать" + "Они снова смогут присоединиться в эту комнату если их пригласят." + "Разблокировать в комнате" + "Заблокированные" + "Участники" + "В ожидании" + "Только администраторы" + "Модератор" + "Владелец" + "Участники комнаты" + "Разблокировка %1$s" + "Администраторы" + "Администраторы и владельцы" + "Изменить мою роль" + "Понизить до участника" + "Понизить до модератора" + "Модерация участников" + "Сообщения и содержание" + "Модераторы" + "Владельцы" + "Разрешения" + "Сбросить разрешения" + "Как только вы сбросите разрешения, все текущие настройки будут утеряны." + "Сбросить разрешения?" + "Роли" + "Информация о комнате" + "Подробности о пространстве" + "Роли и разрешения" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-sk/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..a707b48 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,68 @@ + + + "Iba správcovia" + "Zakázať ľudí" + "Odstrániť správy" + "Pozvite ľudí a prijmite žiadosti o pripojenie" + "Správy a obsah" + "Správcovia a moderátori" + "Odstrániť ľudí a odmietnuť žiadosti o pripojenie" + "Zmeniť obrázok miestnosti" + "Upraviť miestnosť" + "Zmeniť názov miestnosti" + "Zmeniť tému miestnosti" + "Odoslať správy" + "Upraviť správcov" + "Túto akciu nebudete môcť vrátiť späť. Zvyšujete úroveň používateľa na rovnakú úroveň výkonu ako máte vy." + "Pridať správcu?" + "Túto akciu nebude možné vrátiť späť. Prenášate vlastníctvo na vybraných používateľov. Po opustení bude táto akcia trvalá." + "Previesť vlastníctvo?" + "Znížiť" + "Túto zmenu nebudete môcť vrátiť späť, pretože znižujete svoju úroveň. Ak ste posledným privilegovaným používateľom v miestnosti, nebude možné získať znova oprávnenia." + "Znížiť svoju úroveň?" + "%1$s (Čaká sa)" + "(Čaká sa)" + "Správcovia majú automaticky oprávnenia moderátora" + "Vlastníci majú automaticky správcovské oprávnenia." + "Upraviť moderátorov" + "Vybrať vlastníkov" + "Správcovia" + "Moderátori" + "Členovia" + "Máte neuložené zmeny." + "Uložiť zmeny?" + "Neexistujú žiadni zablokovaní používatelia." + + "%1$d osoba" + "%1$d osoby" + "%1$d osôb" + + "Odstrániť a zakázať člena" + "Iba odstrániť člena" + "Zrušiť zákaz" + "V prípade pozvania sa budú môcť znova pripojiť k tejto miestnosti." + "Zrušiť zákaz prístupu do miestnosti" + "Zakázaní" + "Členovia" + "Iba správcovia" + "Správcovia a moderátori" + "Vlastník" + "Členovia miestnosti" + "Zrušenie zákazu %1$s" + "Správcovia" + "Správcovia a vlastníci" + "Zmeniť moje oprávnenia" + "Znížiť úroveň na člena" + "Znížiť úroveň na moderátora" + "Moderovanie členov" + "Správy a obsah" + "Moderátori" + "Vlastníci" + "Obnoviť povolenia" + "Po obnovení oprávnení prídete o aktuálne nastavenia." + "Obnoviť oprávnenia?" + "Roly" + "Podrobnosti o miestnosti" + "Podrobnosti o priestore" + "Roly a povolenia" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-sv/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..d52c85d --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,66 @@ + + + "Endast administratörer" + "Banna personer" + "Ta bort meddelanden" + "Bjuda in personer och acceptera förfrågningar om att gå med" + "Meddelanden och innehåll" + "Administratörer och moderatorer" + "Ta bort personer och avslå förfrågningar om att gå med" + "Byt rumsavatar" + "Redigera rummet" + "Byt rumsnamn" + "Byt rumsämne" + "Skicka meddelanden" + "Redigera administratörer" + "Du kommer inte att kunna ångra den här åtgärden. Du befordrar användaren till att ha samma behörighetsnivå som du." + "Lägg till Admin?" + "Du kommer inte att kunna ångra den här åtgärden. Du överför ägarskapet till de valda användarna. När du lämnar kommer detta att vara permanent." + "Överför ägarskap?" + "Degradera" + "Du kommer inte att kunna ångra denna ändring eftersom du degraderar dig själv, om du är den sista privilegierade användaren i rummet kommer det att vara omöjligt att återfå privilegier." + "Degradera dig själv?" + "%1$s (Väntar)" + "(Väntar)" + "Administratörer har automatiskt moderatorbehörighet" + "Ägare har automatiskt administratörsbehörighet." + "Redigera moderatorer" + "Välj ägare" + "Administratörer" + "Moderatorer" + "Medlemmar" + "Du har osparade ändringar." + "Spara ändringar?" + "Det finns inga bannade användare i det här rummet." + + "%1$d person" + "%1$d personer" + + "Ta bort och banna medlem" + "Ta bara bort medlem" + "Avbanna" + "Denne kommer kunna gå med i rummet igen om denne bjuds in" + "Avbanna från rummet" + "Bannade" + "Medlemmar" + "Endast administratörer" + "Administratörer och moderatorer" + "Ägare" + "Rumsmedlemmar" + "Avbannar %1$s" + "Administratörer" + "Administratörer och ägare" + "Ändra min roll" + "Degradera till medlem" + "Degradera till moderator" + "Medlemsmoderering" + "Meddelanden och innehåll" + "Moderatorer" + "Ägare" + "Återställ behörigheter" + "När du har återställt behörigheterna kommer du att förlora de aktuella inställningarna." + "Återställ behörigheter?" + "Roller" + "Rumsdetaljer" + "Roller och behörigheter" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-tr/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..6e53f48 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,58 @@ + + + "Yalnızca yöneticiler" + "İnsanları yasakla" + "Mesajları kaldır" + "Kişileri davet etme ve katılma isteklerini kabul etme" + "Mesajlar ve içerik" + "Yöneticiler ve moderatörler" + "Kişileri kaldırma ve katılma isteklerini reddetme" + "Oda resmini değiştir" + "Odayı Düzenle" + "Oda adını değiştir" + "Oda konusunu değiştir" + "Mesaj gönder" + "Yöneticileri Düzenle" + "Bu eylemi geri alamazsınız. Kullanıcıyı sizinle aynı güç seviyesine sahip olacak şekilde terfi ettiriyorsunuz." + "Yönetici Ekle?" + "Rütbe Düşür" + "Rütbenizi düşürdüğünüz için bu değişikliği geri alamazsınız, eğer odadaki son ayrıcalıklı kullanıcı sizseniz ayrıcalıkları yeniden kazanmanız mümkün olmayacaktır." + "Rütbeni düşür?" + "%1$s (Beklemede)" + "(Beklemede)" + "Yöneticiler otomatik olarak moderatör ayrıcalıklarına sahiptir" + "Moderatörleri Düzenle" + "Yöneticiler" + "Moderatörler" + "Üyeler" + "Kaydedilmemiş değişiklikleriniz var." + "Değişiklikleri Kaydet?" + "Bu odada yasaklı kullanıcı yok." + + "%1$d kişi" + "%1$d kişi" + + "Üyeyi çıkar ve yasakla" + "Yalnızca üyeyi kaldır" + "Yasağı Kaldır" + "Davet edildikleri takdirde bu odaya tekrar katılabileceklerdir." + "Yasaklandı" + "Üyeler" + "Yalnızca yöneticiler" + "Yöneticiler ve moderatörler" + "Oda üyeleri" + "Yasak kaldırılıyor %1$s" + "Yöneticiler" + "Rolümü değiştir" + "Üyeliğe düşür" + "Moderatörlüğe düşür" + "Üye moderasyonu" + "Mesajlar ve içerik" + "Moderatörler" + "İzinleri sıfırla" + "İzinleri sıfırladığınızda, mevcut ayarları kaybedersiniz." + "İzinleri sıfırla?" + "Roller" + "Oda bilgileri" + "Roller ve izinler" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-uk/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..9eae3a9 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,67 @@ + + + "Тільки для адміністраторів" + "Заблоковувати людей" + "Вилучати повідомлення" + "Запрошувати людей і приймати запити на приєднання" + "Повідомлення та зміст" + "Адміністратори та модератори" + "Вилучати людей і відхиляти запити на приєднання" + "Змінювати аватар кімнати" + "Редагувати кімнату" + "Змінювати назву кімнати" + "Змінювати тему кімнати" + "Надсилати повідомлення" + "Керувати адмінами" + "Ви не зможете скасувати цю дію. Ви просуваєте користувача, щоб він мав такий же рівень прав, як і ви." + "Додати адміністратора?" + "Ви не зможете скасувати цю дію. Ви передаєте право власності вибраним користувачам. Після вашого виходу це буде остаточно." + "Передати право власності?" + "Понизити" + "Ви не зможете скасувати цю зміну, оскільки ви понижуєте себе, якщо ви останній привілейований користувач у кімнаті, відновити повноваження буде неможливо." + "Понизити себе?" + "%1$s (Очікується)" + "(Очікується)" + "Адміністратори автоматично мають повноваження модератора" + "Власники автоматично отримують права адміністратора." + "Керувати модераторами" + "Оберіть власників" + "Адміністратори" + "Модератори" + "Учасники" + "У вас є не збережені зміни." + "Зберегти зміни?" + "У цій кімнаті немає заблокованих користувачів." + + "%1$d особа" + "%1$d особи" + "%1$d осіб" + + "Вилучити й заблокувати учасника" + "Лише вилучити учасника" + "Розблокувати" + "Вони зможуть знову приєднатися до цієї кімнати, якщо їх запросять." + "Розблокувати в кімнаті" + "Заблоковані" + "Учасники" + "Тільки для адміністраторів" + "Адміністратори та модератори" + "Власник" + "Учасники кімнати" + "Розблокування %1$s" + "Адміністратори" + "Адміністратори та власники" + "Змінити мою роль" + "Понизити до учасника" + "Понизити до модератора" + "Модерація учасників" + "Повідомлення та зміст" + "Модератори" + "Власники" + "Скинути дозволи" + "Після скидання дозволів ви втратите поточні налаштування." + "Скинути дозволи?" + "Ролі" + "Деталі кімнати" + "Ролі та дозволи" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-ur/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..8e6e47f --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,58 @@ + + + "صرف منتظمین" + "لوگوں کو محظور کریں" + "پیغامات ہٹائیں" + "لوگوں کو مدعو کریں اور شمولیت کی درخواستیں قبول کریں" + "پیغامات اور مواد" + "منتظمین اور ناظمین" + "لوگوں کو ہٹا دیں اور شمولیت کی درخواستیں مسترد کریں" + "کمرے کا اوتار بدلیں" + "کمرے میں ترمیم کریں" + "کمرے کا نام بدلیں" + "کمرے کا موضوع بدلیں" + "پیغامات بھیجیں" + "منتظمین میں ترمیم کریں" + "آپ اس کارروائی کو کالعدم نہیں کرسکیں گے۔ آپ صارف کو اپنی جیسی طاقت کی سطح رکھنے کے لئے فروغ دے رہے ہیں۔" + "منتظم شمال کریں؟" + "تنزل کریں" + "آپ اس تبدیلی کو کالعدم نہیں کرسکیں گے کیونکہ آپ اپنے آپ کو تنزل کر رہے ہیں، اگر آپ کمرے میں آخری مراعات یافتہ صارف ہیں تو مراعات پھر حاصل کرنا ناممکن ہو جائے گا۔" + "اپنے آپ کو تنزل کریں؟" + "%1$s (زیر التواء)" + "(زیر التواء)" + "منتظمین کے پاس خودکاراً ناظمین مراعات ہوتی ہیں" + "ناظمین میں ترمیم کریں" + "منتظمین" + "ناظمین" + "اراکین" + "آپکے پاس غیر محفوظ تبدیلیاں ہیں" + "تبدیلیاں محفوظ کریں؟" + "اس کمرے میں کوئی محظور صارفین نہیں ہیں۔" + + "%1$d شخص" + "%1$d اشخاص" + + "کمرے سے محظور کریں" + "رکن کو صرف ہٹائیں" + "غیر محظور کریں" + "اگر وہ مدعو کیا جائیں تو وہ دوبارہ اس کمرے میں شامل ہوسکیں گے۔" + "محظور" + "اراکین" + "صرف منتظمین" + "منتظمین اور ناظمین" + "کمرے کے ارکان" + "%1$s کو غیر محظور کر رہا ہے" + "منتظمین" + "میرا کردار تبدیل کریں" + "تا رکن تنزلی کریں" + "تا ناظم تنزلی کریں" + "ارکان کا اعتدال" + "پیغامات اور مواد" + "ناظمین" + "اجازتیں بحال کریں" + "ایک بار جب آپ اجازتیں بحال کردیں گے، آپ موجودہ ترتیبات کھو دیں گے۔" + "اجازتیں بحال کریں؟" + "کردارہا" + "کمرے کی تفصیلات" + "کردارہا اور اجازتیں" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-uz/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..f56210a --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,66 @@ + + + "Faqat adminlar" + "Odamlarni taqiqlash" + "Xabarlarni olib tashlash" + "Odamlarni taklif qiling va qo‘shilish so‘rovlarini qabul qiling" + "Xabarlar va kontent" + "Adminlar va moderatorlar" + "Odamlarni olib tashlash va qoʻshilish soʻrovlarini rad etish" + "Xona avatarini oʻzgartirish" + "Xonani tahrirlash" + "Xona nomini oʻzgartirish" + "Xona mavzusini almashtirish" + "Xabarlar yuborish" + "Administratorlarni tahrirlash" + "Bu amalni bekor qila olmaysiz. Siz foydalanuvchini o‘zingiz bilan bir xil quvvat darajasiga ega bo‘lishga undayapsiz." + "Admin qo‘shilsinmi?" + "Bu amalni bekor qila olmaysiz. Siz egalikni tanlangan foydalanuvchilarga o‘tkazmoqdasiz. Tark etsangiz, bu doimiy bo‘ladi." + "Egalik huquqini o‘tkazasizmi?" + "Pastga tushirish" + "Siz oʻzingizni imtiyozlardan mahrum qilayotganingiz sababli, bu o‘zgarishni bekor qila olmaysiz. Agar xonadagi so‘nggi imtiyozli foydalanuvchi bo‘lsangiz, imtiyozlarni qayta tiklash imkonsiz bo‘ladi." + "O‘z darajangizni pasaytirmoqchimisiz?" + "%1$s (Jarayonda)" + "(Kutilmoqda)" + "Administratorlar avtomatik ravishda moderator imtiyozlariga ega" + "Egalar avtomatik ravishda administrator huquqlariga ega." + "Moderatorlarni tahrirlash" + "Egalarni tanlang" + "Adminlar" + "Moderatorlar" + "Azolar" + "Sizda saqlanmagan oʻzgarishlar bor" + "O‘zgartirishlarni saqlaysizmi?" + "Bu xonada taqiqlangan foydalanuvchilar yoʻq." + + "%1$dodam" + "%1$dodamlar" + + "Xonadan chetlashtirish" + "Faqat aʻzoni olib tashlash" + "Taqiqni bekor qilish" + "Agar taklif qilinsa, ular bu xonaga qayta qo‘shilishlari mumkin." + "Xonadan taqiqni olib tashlash" + "Taqiqlangan" + "Azolar" + "Faqat adminlar" + "Adminlar va moderatorlar" + "Egasi" + "Xona a\'zolari" + "Taqiqni bekor qilish %1$s" + "Adminlar" + "Adminlar va egalari" + "Rolimni o‘zgartirish" + "Aʼzolikka tushirish" + "Moderatorga pasaytirish" + "Aʻzo moderatsiyasi" + "Xabarlar va kontent" + "Moderatorlar" + "Egalari" + "Ruxsatlarni tiklash" + "Ruxsatlarni asliga qaytargach, joriy sozlamalarni yoʻqotasiz." + "Ruxsatlar asliga qaytarilsinmi?" + "Rollar" + "Xona tafsilotlari" + "Rollar va ruxsatlar" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-zh-rTW/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..50555bf --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,72 @@ + + + "管理員" + "管理黑名單" + "移除訊息" + "成員" + "邀請夥伴" + "管理成員" + "訊息與內容" + "版主" + "移除夥伴" + "變更聊天室大頭照" + "編輯詳細資訊" + "變更聊天室名稱" + "變更聊天室主題" + "傳送訊息" + "編輯管理員" + "您將無法復原此動作。您正將使用者提昇至與您相同的權力等級。" + "要新增管理員嗎?" + "您將無法撤銷此動作。您正在將所有權轉移給選定的使用者。一旦您離開,此動作將永久有效。" + "轉移所有權?" + "降級" + "當您自行降級時,您將無法復原此變更,若您是聊天室中的最後一位特權使用者,則無法重新獲得權限。" + "將自己降級?" + "%1$s(擱置中)" + "(擱置中)" + "管理員自動擁有版主權限" + "擁有者自動擁有管理員權限。" + "編輯版主" + "選擇擁有者" + "管理員" + "版主" + "成員" + "您有尚未儲存的變更" + "是否儲存變更?" + "沒有被封鎖的使用者。" + "檢查拼字或嘗試新搜尋" + "找不到「%1$s」" + + "%1$d 個人" + + "踢出並加入黑名單" + "僅移除成員" + "解除黑名單" + "如果收到邀請,他們能再次加入聊天室。" + "從聊天室解除封鎖" + "黑名單" + "成員" + "擱置中" + "管理員" + "版主" + "擁有者" + "聊天室成員" + "正在解除黑名單 %1$s" + "管理員" + "管理員與擁有者" + "變更我的身份" + "降級為普通成員" + "降級為版主" + "成員管理" + "訊息與內容" + "版主" + "擁有者" + "權限" + "重設權限" + "重設之後,您會遺失當前的設定。" + "確定要重設權限嗎?" + "身份" + "聊天室資訊" + "空間詳細資訊" + "角色與權限" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-zh/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..d2882d8 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,66 @@ + + + "仅限管理员" + "封禁成员" + "移除消息" + "邀请他人及接受加入请求" + "消息和内容" + "管理员和协管员" + "移除成员及拒绝加入请求" + "更改聊天室头像" + "编辑聊天室" + "更改聊天室名称" + "更改聊天室主题" + "发送消息" + "编辑管理员" + "您将无法撤消此操作。您正在提升用户的权限,使其拥有与您平权。" + "添加管理员?" + "此操作无法撤销。您正在将所有权转移给所选用户。一旦离开此界面,该操作将永久生效。" + "转让所有权" + "降级" + "您正在降级,此更改将无法撤消。如果您是聊天室中的最后一个特权用户,则无法重新获得权限。" + "降级自己?" + "%1$s(待处理)" + "(已邀请)" + "管理员自动拥有协管员权限" + "所有者自动拥有管理员权限。" + "编辑协管员" + "选择所有者" + "管理员" + "协管员" + "成员" + "您有未保存的更改。" + "保存更改?" + "没有被封禁的用户。" + + "%1$d 人" + + "移除并封禁成员" + "仅移除成员" + "取消封禁" + "如果受到邀请,他们可以重新加入聊天室。" + "从房间取消解封" + "已封禁用户" + "成员" + "仅限管理员" + "管理员和协管员" + "所有者" + "聊天室成员" + "解除封禁 %1$s" + "管理员" + "管理员和所有者" + "更改我的角色" + "降级为成员" + "降级为协管员" + "成员权限" + "消息和内容" + "协管员" + "所有者" + "重置权限" + "重置权限后,您将丢失当前设置。" + "重置权限?" + "角色" + "聊天室详情" + "空间详情" + "角色与权限" + diff --git a/features/rolesandpermissions/impl/src/main/res/values/localazy.xml b/features/rolesandpermissions/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..70efa48 --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values/localazy.xml @@ -0,0 +1,81 @@ + + + "Admin" + "Ban people" + "Remove messages" + "Member" + "Invite people" + "Manage members" + "Messages and content" + "Moderator" + "Remove people" + "Change avatar" + "Edit details" + "Change name" + "Change topic" + "Send messages" + "Edit Admins" + "You will not be able to undo this action. You are promoting the user to have the same power level as you." + "Add Admin?" + "You will not be able to undo this action. You are transferring the ownership to the selected users. Once you leave this will be permanent." + "Transfer ownership?" + "Demote" + "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges." + "Demote yourself?" + "%1$s (Pending)" + "(Pending)" + "Admins automatically have moderator privileges" + "Owners automatically have admin privileges." + "Edit Moderators" + "Choose Owners" + "Admins" + "Moderators" + "Members" + "You have unsaved changes." + "Save changes?" + "There are no banned users." + + "%1$d Banned" + "%1$d Banned" + + "Check the spelling or try a new search" + "No results for “%1$s”" + + "%1$d Person" + "%1$d People" + + "Ban user" + "Only remove member" + "Unban" + "They will be able to join this room again if invited." + "Unban user" + "Banned" + "Members" + + "%1$d Invited" + "%1$d Invited" + + "Pending" + "Admin" + "Moderator" + "Owner" + "Room members" + "Unbanning %1$s" + "Admins" + "Admins and owners" + "Change my role" + "Demote to member" + "Demote to moderator" + "Member moderation" + "Messages and content" + "Moderators" + "Owners" + "Permissions" + "Reset permissions" + "Once you reset permissions, you will lose the current settings." + "Reset permissions?" + "Roles" + "Room details" + "Space details" + "Roles & permissions" + diff --git a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenterTest.kt b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenterTest.kt new file mode 100644 index 0000000..7c328c9 --- /dev/null +++ b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenterTest.kt @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.permissions + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.Event +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.RoomModeration +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.room.RoomMember.Role.Admin +import io.element.android.libraries.matrix.api.room.RoomMember.Role.Moderator +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues +import io.element.android.services.analytics.test.FakeAnalyticsService +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ChangeRoomPermissionsPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createChangeRoomPermissionsPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Initial state, no permissions loaded + awaitItem().run { + assertThat(this.currentPermissions).isNull() + assertThat(this.itemsBySection).isNotEmpty() + assertThat(this.hasChanges).isFalse() + assertThat(this.saveAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(this.confirmExitAction).isEqualTo(AsyncAction.Uninitialized) + } + + // Updated state, permissions loaded + assertThat(awaitItem().currentPermissions).isEqualTo(defaultPermissions()) + } + } + + @Test + fun `present - items by section are correct for room`() = runTest { + val presenter = createChangeRoomPermissionsPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val itemsBySection = awaitUpdatedItem().itemsBySection + assertThat(itemsBySection[RoomPermissionsSection.RoomDetails]).containsExactly( + RoomPermissionType.ROOM_NAME, + RoomPermissionType.ROOM_AVATAR, + RoomPermissionType.ROOM_TOPIC, + ) + assertThat(itemsBySection[RoomPermissionsSection.MessagesAndContent]).containsExactly( + RoomPermissionType.SEND_EVENTS, + RoomPermissionType.REDACT_EVENTS, + ) + assertThat(itemsBySection[RoomPermissionsSection.MembershipModeration]).containsExactly( + RoomPermissionType.INVITE, + RoomPermissionType.KICK, + RoomPermissionType.BAN, + ) + } + } + + @Test + fun `present - ChangeMinimumRoleForAction updates the current permissions and hasChanges`() = runTest { + val presenter = createChangeRoomPermissionsPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val state = awaitUpdatedItem() + assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel) + assertThat(state.hasChanges).isFalse() + + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Moderator)) + + awaitItem().run { + assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel) + assertThat(hasChanges).isTrue() + } + } + } + + @Test + fun `present - ChangeMinimumRoleForAction works for all actions`() = runTest { + val presenter = createChangeRoomPermissionsPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val state = awaitUpdatedItem() + val initialPermissions = defaultPermissions() + + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.INVITE, SelectableRole.Moderator)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.KICK, SelectableRole.Moderator)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.BAN, SelectableRole.Moderator)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.SEND_EVENTS, SelectableRole.Moderator)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.REDACT_EVENTS, SelectableRole.Moderator)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Moderator)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_AVATAR, SelectableRole.Moderator)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_TOPIC, SelectableRole.Moderator)) + + val itemsBySection = cancelAndConsumeRemainingEvents() + + (itemsBySection.last() as? Event.Item)?.value?.run { + assertThat(currentPermissions).isEqualTo( + RoomPowerLevelsValues( + invite = Moderator.powerLevel, + kick = Moderator.powerLevel, + ban = Moderator.powerLevel, + redactEvents = Moderator.powerLevel, + sendEvents = Moderator.powerLevel, + roomName = Moderator.powerLevel, + roomAvatar = Moderator.powerLevel, + roomTopic = Moderator.powerLevel, + spaceChild = initialPermissions.spaceChild + ) + ) + } + } + } + + @Test + fun `present - Save updates the current permissions and resets hasChanges`() = runTest { + val analyticsService = FakeAnalyticsService() + val presenter = createChangeRoomPermissionsPresenter( + analyticsService = analyticsService, + room = FakeJoinedRoom( + updatePowerLevelsResult = { Result.success(Unit) }, + baseRoom = FakeBaseRoom(powerLevelsResult = { Result.success(defaultPermissions()) }), + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val state = awaitUpdatedItem() + assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel) + assertThat(state.hasChanges).isFalse() + + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Moderator)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_AVATAR, SelectableRole.Moderator)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_TOPIC, SelectableRole.Moderator)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.SEND_EVENTS, SelectableRole.Moderator)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.REDACT_EVENTS, SelectableRole.Everyone)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.KICK, SelectableRole.Admin)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.BAN, SelectableRole.Admin)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.INVITE, SelectableRole.Admin)) + skipItems(7) + assertThat(awaitItem().hasChanges).isTrue() + + state.eventSink(ChangeRoomPermissionsEvent.Save) + + assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.Loading) + assertThat(awaitItem().hasChanges).isFalse() + awaitItem().run { + assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel) + assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit)) + } + assertThat(analyticsService.capturedEvents).containsExactlyElementsIn( + listOf( + RoomModeration(RoomModeration.Action.ChangePermissionsRoomName, RoomModeration.Role.Moderator), + RoomModeration(RoomModeration.Action.ChangePermissionsRoomAvatar, RoomModeration.Role.Moderator), + RoomModeration(RoomModeration.Action.ChangePermissionsRoomTopic, RoomModeration.Role.Moderator), + RoomModeration(RoomModeration.Action.ChangePermissionsSendMessages, RoomModeration.Role.Moderator), + RoomModeration(RoomModeration.Action.ChangePermissionsRedactMessages, RoomModeration.Role.User), + RoomModeration(RoomModeration.Action.ChangePermissionsKickMembers, RoomModeration.Role.Administrator), + RoomModeration(RoomModeration.Action.ChangePermissionsBanMembers, RoomModeration.Role.Administrator), + RoomModeration(RoomModeration.Action.ChangePermissionsInviteUsers, RoomModeration.Role.Administrator), + ) + ) + } + } + + @Test + fun `present - Save will fail if there are not current permissions`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom(powerLevelsResult = { Result.failure(IllegalStateException("Failed to load power levels")) }), + ) + val presenter = createChangeRoomPermissionsPresenter(room = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val state = awaitItem() + assertThat(state.currentPermissions).isNull() + + state.eventSink(ChangeRoomPermissionsEvent.Save) + assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) + } + } + + @Test + fun `present - Save can handle failures and they can be cleared`() = runTest { + val room = FakeJoinedRoom( + updatePowerLevelsResult = { Result.failure(IllegalStateException("Failed to update power levels")) }, + baseRoom = FakeBaseRoom(powerLevelsResult = { Result.success(defaultPermissions()) }), + ) + val presenter = createChangeRoomPermissionsPresenter(room = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val state = awaitUpdatedItem() + assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel) + assertThat(state.hasChanges).isFalse() + + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Moderator)) + assertThat(awaitItem().hasChanges).isTrue() + + state.eventSink(ChangeRoomPermissionsEvent.Save) + + assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.Loading) + awaitItem().run { + assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel) + // Couldn't save the changes, so they're still pending + assertThat(hasChanges).isTrue() + assertThat(saveAction).isInstanceOf(AsyncAction.Failure::class.java) + } + + state.eventSink(ChangeRoomPermissionsEvent.ResetPendingActions) + awaitItem().run { + assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel) + assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(hasChanges).isTrue() + } + } + } + + @Test + fun `present - Exit does not need a confirmation when there are no pending changes`() = runTest { + val presenter = createChangeRoomPermissionsPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val state = awaitUpdatedItem() + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Moderator)) + assertThat(awaitItem().hasChanges).isTrue() + + state.eventSink(ChangeRoomPermissionsEvent.Exit) + assertThat(awaitItem().confirmExitAction).isEqualTo(AsyncAction.ConfirmingNoParams) + + state.eventSink(ChangeRoomPermissionsEvent.Exit) + assertThat(awaitItem().confirmExitAction).isEqualTo(AsyncAction.Success(Unit)) + } + } + + @Test + fun `present - Exit needs confirmation when there are pending changes`() = runTest { + val presenter = createChangeRoomPermissionsPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val state = awaitUpdatedItem() + + state.eventSink(ChangeRoomPermissionsEvent.Exit) + + assertThat(awaitItem().confirmExitAction).isEqualTo(AsyncAction.Success(Unit)) + } + } + + private fun createChangeRoomPermissionsPresenter( + room: FakeJoinedRoom = FakeJoinedRoom( + baseRoom = FakeBaseRoom(powerLevelsResult = { Result.success(defaultPermissions()) }), + ), + analyticsService: FakeAnalyticsService = FakeAnalyticsService(), + ) = ChangeRoomPermissionsPresenter( + room = room, + analyticsService = analyticsService, + ) + + private fun defaultPermissions() = defaultRoomPowerLevelValues() + + private suspend fun TurbineTestContext.awaitUpdatedItem(): ChangeRoomPermissionsState { + skipItems(1) + return awaitItem() + } +} diff --git a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsViewTest.kt b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsViewTest.kt new file mode 100644 index 0000000..915359c --- /dev/null +++ b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsViewTest.kt @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.permissions + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.rolesandpermissions.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.clickOnFirst +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.pressBackKey +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ChangeRoomPermissionsViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `click on back icon invokes Exit`() { + val recorder = EventsRecorder() + rule.setChangeRoomPermissionsRule( + state = aChangeRoomPermissionsState( + eventSink = recorder + ) + ) + rule.pressBack() + recorder.assertSingle(ChangeRoomPermissionsEvent.Exit) + } + + @Test + fun `click on back key invokes Exit`() { + val recorder = EventsRecorder() + rule.setChangeRoomPermissionsRule( + state = aChangeRoomPermissionsState( + eventSink = recorder + ) + ) + rule.pressBackKey() + recorder.assertSingle(ChangeRoomPermissionsEvent.Exit) + } + + @Test + fun `when confirming exit with pending changes, using the back key actually exits`() { + val recorder = EventsRecorder() + rule.setChangeRoomPermissionsRule( + state = aChangeRoomPermissionsState( + hasChanges = true, + eventSink = recorder, + ), + ) + rule.pressBackKey() + recorder.assertSingle(ChangeRoomPermissionsEvent.Exit) + } + + @Test + fun `when confirming exit with pending changes, clicking on 'discard' button in the dialog actually exits`() { + val recorder = EventsRecorder() + rule.setChangeRoomPermissionsRule( + state = aChangeRoomPermissionsState( + hasChanges = true, + confirmExitAction = AsyncAction.ConfirmingNoParams, + eventSink = recorder, + ), + ) + rule.clickOn(CommonStrings.action_discard) + recorder.assertSingle(ChangeRoomPermissionsEvent.Exit) + } + + @Test + fun `when confirming exit with pending changes, clicking on 'save' button in the dialog saves the changes`() { + val recorder = EventsRecorder() + rule.setChangeRoomPermissionsRule( + state = aChangeRoomPermissionsState( + hasChanges = true, + confirmExitAction = AsyncAction.ConfirmingNoParams, + eventSink = recorder, + ), + ) + rule.clickOnFirst(CommonStrings.action_save) + recorder.assertSingle(ChangeRoomPermissionsEvent.Save) + } + + @Test + fun `click on a role item triggers ChangeRole event`() { + val recorder = EventsRecorder() + rule.setChangeRoomPermissionsRule( + state = aChangeRoomPermissionsState( + itemsBySection = persistentMapOf( + // Makes sure there is only one item to click on + RoomPermissionsSection.RoomDetails to persistentListOf(RoomPermissionType.ROOM_NAME) + ), + eventSink = recorder, + ) + ) + rule.clickOn(R.string.screen_room_change_permissions_room_name) + rule.clickOn(R.string.screen_room_change_permissions_everyone) + recorder.assertSingle( + ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Everyone), + ) + } + + @Test + fun `click on the Save menu item triggers Save event`() { + val recorder = EventsRecorder() + rule.setChangeRoomPermissionsRule( + state = aChangeRoomPermissionsState( + hasChanges = true, + eventSink = recorder, + ), + ) + rule.clickOn(CommonStrings.action_save) + recorder.assertSingle(ChangeRoomPermissionsEvent.Save) + } + + @Test + fun `a successful save exits the screen`() { + ensureCalledOnceWithParam(true) { callback -> + rule.setChangeRoomPermissionsRule( + state = aChangeRoomPermissionsState( + hasChanges = true, + saveAction = AsyncAction.Success(Unit), + ), + onComplete = callback + ) + rule.clickOn(CommonStrings.action_save) + } + } + + @Test + fun `click on the Ok option in save error dialog triggers ResetPendingAction event`() { + val recorder = EventsRecorder() + rule.setChangeRoomPermissionsRule( + state = aChangeRoomPermissionsState( + hasChanges = true, + saveAction = AsyncAction.Failure(IllegalStateException("Failed to set room power levels")), + eventSink = recorder, + ), + ) + rule.clickOn(CommonStrings.action_ok) + recorder.assertSingle(ChangeRoomPermissionsEvent.ResetPendingActions) + } +} + +private fun AndroidComposeTestRule.setChangeRoomPermissionsRule( + state: ChangeRoomPermissionsState = aChangeRoomPermissionsState(), + onComplete: (Boolean) -> Unit = EnsureNeverCalledWithParam(), +) { + setContent { + ChangeRoomPermissionsView( + state = state, + onComplete = onComplete, + ) + } +} diff --git a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesNodeTest.kt b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesNodeTest.kt new file mode 100644 index 0000000..f47869e --- /dev/null +++ b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesNodeTest.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.roles + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType +import io.element.android.libraries.matrix.api.room.RoomMember +import org.junit.Test + +class ChangeRolesNodeTest { + @Test + fun `test toRoomMemberRole`() { + assertThat(ChangeRoomMemberRolesListType.Admins.toRoomMemberRole()) + .isEqualTo(RoomMember.Role.Admin) + assertThat(ChangeRoomMemberRolesListType.Moderators.toRoomMemberRole()) + .isEqualTo(RoomMember.Role.Moderator) + assertThat(ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving.toRoomMemberRole()) + .isEqualTo(RoomMember.Role.Owner(false)) + } +} diff --git a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenterTest.kt b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenterTest.kt new file mode 100644 index 0000000..1da7ddc --- /dev/null +++ b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenterTest.kt @@ -0,0 +1,584 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.roles + +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.RoomModeration +import io.element.android.features.rolesandpermissions.impl.RoomMemberListDataSource +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +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.RoomMembersState +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels +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_ID_2 +import io.element.android.libraries.matrix.test.A_USER_ID_3 +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues +import io.element.android.libraries.previewutils.room.aRoomMemberList +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ChangeRolesPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createChangeRolesPresenter() + presenter.test { + with(awaitItem()) { + assertThat(role).isEqualTo(RoomMember.Role.Admin) + assertThat(query).isNull() + assertThat(isSearchActive).isFalse() + assertThat(searchResults).isInstanceOf(SearchBarResultState.Initial::class.java) + assertThat(selectedUsers).isEmpty() + assertThat(hasPendingChanges).isFalse() + assertThat(savingState).isEqualTo(AsyncAction.Uninitialized) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - initial results are loaded automatically`() = runTest { + val room = FakeJoinedRoom().apply { + givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) + } + val presenter = createChangeRolesPresenter(room = room) + presenter.test { + skipItems(1) + assertThat(awaitItem().searchResults).isInstanceOf(SearchBarResultState.Results::class.java) + } + } + + @Test + fun `present - canChangeRole of users with lower power level unless they are owners - privilegedCreatorRole is true`() = runTest { + val creatorUserId = UserId("@creator:matrix.org") + val superAdminUserId = UserId("@super_admin:matrix.org") + + val room = FakeJoinedRoom().apply { + // User is a creator, so they can change roles of other members. So is `creatorUserId`. + givenRoomInfo( + aRoomInfo( + privilegedCreatorRole = true, + roomCreators = listOf(sessionId, creatorUserId), + roomPowerLevels = RoomPowerLevels( + defaultRoomPowerLevelValues(), + users = persistentMapOf( + // bob is Admin + A_USER_ID_2 to RoomMember.Role.Admin.powerLevel, + // carol is Moderator + A_USER_ID_3 to RoomMember.Role.Moderator.powerLevel, + // super_admin is Owner - Superadmin + superAdminUserId to RoomMember.Role.Owner(isCreator = false).powerLevel, + ) + ) + ) + ) + + val roomMemberList = aRoomMemberList() + listOf( + // Owner - superadmin + aRoomMember(userId = superAdminUserId, role = RoomMember.Role.Owner(isCreator = false)), + // Owner - creator + aRoomMember(userId = creatorUserId, role = RoomMember.Role.Owner(isCreator = true)) + ) + givenRoomMembersState(RoomMembersState.Ready(roomMemberList.toImmutableList())) + } + val presenter = createChangeRolesPresenter(room = room) + presenter.test { + skipItems(1) + awaitItem().run { + assertThat(canChangeMemberRole(A_USER_ID_2)).isTrue() // Admin + assertThat(canChangeMemberRole(A_USER_ID_3)).isTrue() // Moderator + assertThat(canChangeMemberRole(superAdminUserId)).isTrue() // Super admin + assertThat(canChangeMemberRole(creatorUserId)).isFalse() // Owner + } + } + } + + @Test + fun `present - canChangeRole of users with lower power level unless they are owners - privilegedCreatorRole is false`() = runTest { + val creatorUserId = UserId("@creator:matrix.org") + val superAdminUserId = UserId("@super_admin:matrix.org") + + val room = FakeJoinedRoom().apply { + // User is a creator, so they can change roles of other members. So is `creatorUserId`. + givenRoomInfo( + aRoomInfo( + privilegedCreatorRole = false, + roomCreators = listOf(sessionId, creatorUserId), + roomPowerLevels = RoomPowerLevels( + defaultRoomPowerLevelValues(), + users = persistentMapOf( + // Creator is an admin + sessionId to RoomMember.Role.Admin.powerLevel, + creatorUserId to RoomMember.Role.Admin.powerLevel, + // bob is Admin + A_USER_ID_2 to RoomMember.Role.Admin.powerLevel, + // carol is Moderator + A_USER_ID_3 to RoomMember.Role.Moderator.powerLevel, + // super_admin is Owner - Superadmin + superAdminUserId to RoomMember.Role.Owner(isCreator = false).powerLevel, + ) + ) + ) + ) + + val roomMemberList = aRoomMemberList() + listOf( + // Owner - superadmin + aRoomMember(userId = superAdminUserId, role = RoomMember.Role.Owner(isCreator = false)), + // Owner - creator + aRoomMember(userId = creatorUserId, role = RoomMember.Role.Owner(isCreator = true)) + ) + givenRoomMembersState(RoomMembersState.Ready(roomMemberList.toImmutableList())) + } + val presenter = createChangeRolesPresenter(room = room) + presenter.test { + skipItems(1) + awaitItem().run { + assertThat(canChangeMemberRole(A_USER_ID_2)).isFalse() // Creator cannot update Admin in this case + assertThat(canChangeMemberRole(A_USER_ID_3)).isTrue() // Moderator + assertThat(canChangeMemberRole(creatorUserId)).isFalse() // Owner + } + } + } + + @Test + fun `present - when modifying admins, creators are displayed too`() = runTest { + val room = FakeJoinedRoom().apply { + val creatorUserId = UserId("@creator:matrix.org") + val memberList = aRoomMemberList() + .plus(aRoomMember(displayName = "CREATOR", role = RoomMember.Role.Owner(isCreator = true), userId = creatorUserId)) + .toImmutableList() + givenRoomInfo(aRoomInfo(roomCreators = listOf(creatorUserId))) + givenRoomMembersState(RoomMembersState.Ready(memberList)) + } + val presenter = createChangeRolesPresenter(room = room) + presenter.test { + skipItems(2) + awaitItem().searchResults.run { + assertThat(this).isInstanceOf(SearchBarResultState.Results::class.java) + val results = (this as SearchBarResultState.Results).results + assertThat(results.admins).isNotEmpty() + assertThat(results.owners).isNotEmpty() + assertThat(results.owners.last().role).isEqualTo(RoomMember.Role.Owner(isCreator = true)) + } + } + } + + @Test + fun `present - ToggleSearchActive changes the value`() = runTest { + val room = FakeJoinedRoom().apply { + givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) + } + val presenter = createChangeRolesPresenter(room = room) + presenter.test { + val initialState = awaitItem() + + initialState.eventSink(ChangeRolesEvent.ToggleSearchActive) + assertThat(awaitItem().isSearchActive).isTrue() + + initialState.eventSink(ChangeRolesEvent.ToggleSearchActive) + assertThat(awaitItem().isSearchActive).isFalse() + } + } + + @Test + fun `present - QueryChanged produces new results`() = runTest { + val room = FakeJoinedRoom().apply { + givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) + } + val presenter = createChangeRolesPresenter(room = room) + presenter.test { + val initialState = awaitItem() + val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results + assertThat(initialResults?.members).hasSize(8) + assertThat(initialResults?.moderators).hasSize(1) + assertThat(initialResults?.admins).hasSize(1) + + initialState.eventSink(ChangeRolesEvent.QueryChanged("Alice")) + skipItems(1) + + val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results + assertThat(searchResults?.admins).hasSize(1) + assertThat(searchResults?.moderators).isEmpty() + assertThat(searchResults?.members).isEmpty() + assertThat(searchResults?.admins?.firstOrNull()?.userId).isEqualTo(A_USER_ID) + } + } + + @Test + fun `present - changes in the room members produce new results`() = runTest { + val room = FakeJoinedRoom().apply { + givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) + } + val presenter = createChangeRolesPresenter(room = room) + presenter.test { + skipItems(1) + val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results + assertThat(initialResults?.members).hasSize(8) + assertThat(initialResults?.moderators).hasSize(1) + assertThat(initialResults?.admins).hasSize(1) + + room.givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList().take(1).toImmutableList())) + skipItems(1) + + val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results + assertThat(searchResults?.admins).hasSize(1) + assertThat(searchResults?.moderators).isEmpty() + assertThat(searchResults?.members).isEmpty() + assertThat(searchResults?.admins?.firstOrNull()?.userId).isEqualTo(A_USER_ID) + } + } + + @Test + fun `present - UserSelectionToggle adds and removes users from the selected user list`() = runTest { + val room = FakeJoinedRoom().apply { + givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) + givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) + } + val presenter = createChangeRolesPresenter(room = room) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.selectedUsers).hasSize(1) + + initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) + assertThat(awaitItem().selectedUsers).hasSize(2) + + initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) + assertThat(awaitItem().selectedUsers).hasSize(1) + } + } + + @Test + fun `present - hasPendingChanges is true when the initial selected users don't match the new ones`() = runTest { + val room = FakeJoinedRoom().apply { + givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) + givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) + } + val presenter = createChangeRolesPresenter(room = room) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.hasPendingChanges).isFalse() + assertThat(initialState.selectedUsers).hasSize(1) + + initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) + with(awaitItem()) { + assertThat(selectedUsers).hasSize(2) + assertThat(hasPendingChanges).isTrue() + } + + initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) + with(awaitItem()) { + assertThat(selectedUsers).hasSize(1) + assertThat(hasPendingChanges).isFalse() + } + } + } + + @Test + fun `present - Exit will display success false if no pending changes`() = runTest { + val room = FakeJoinedRoom().apply { + givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) + givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) + } + val presenter = createChangeRolesPresenter(room = room) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.hasPendingChanges).isFalse() + assertThat(initialState.savingState).isEqualTo(AsyncAction.Uninitialized) + + initialState.eventSink(ChangeRolesEvent.Exit) + assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(false)) + } + } + + @Test + fun `present - CloseDialog will remove exit confirmation`() = runTest { + val room = FakeJoinedRoom().apply { + givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) + givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) + } + val presenter = createChangeRolesPresenter(room = room) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.hasPendingChanges).isFalse() + assertThat(initialState.savingState).isEqualTo(AsyncAction.Uninitialized) + + initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) + + awaitItem().eventSink(ChangeRolesEvent.Exit) + val confirmingState = awaitItem() + assertThat(confirmingState.savingState).isEqualTo(AsyncAction.ConfirmingCancellation) + + confirmingState.eventSink(ChangeRolesEvent.CloseDialog) + assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - Exit will display a confirmation dialog if there are pending changes, calling it again will actually exit`() = runTest { + val room = FakeJoinedRoom().apply { + givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) + givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) + } + val presenter = createChangeRolesPresenter(room = room) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.hasPendingChanges).isFalse() + assertThat(initialState.savingState).isEqualTo(AsyncAction.Uninitialized) + + initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) + val updatedState = awaitItem() + assertThat(updatedState.hasPendingChanges).isTrue() + skipItems(1) + + updatedState.eventSink(ChangeRolesEvent.Exit) + assertThat(awaitItem().savingState).isEqualTo(AsyncAction.ConfirmingCancellation) + + updatedState.eventSink(ChangeRolesEvent.Exit) + assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(false)) + } + } + + @Test + fun `present - Save will display a confirmation when adding admins`() = runTest { + val room = FakeJoinedRoom( + updateUserRoleResult = { Result.success(Unit) }, + baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }), + ).apply { + givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) + givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) + } + val presenter = createChangeRolesPresenter(role = RoomMember.Role.Admin, room = room) + presenter.test { + skipItems(2) + val initialState = awaitItem() + assertThat(initialState.selectedUsers).hasSize(1) + initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) + awaitItem().eventSink(ChangeRolesEvent.Save) + val confirmingState = awaitItem() + assertThat(confirmingState.savingState).isEqualTo(ConfirmingModifyingAdmins) + confirmingState.eventSink(ChangeRolesEvent.Save) + assertThat(awaitItem().savingState).isInstanceOf(AsyncAction.Loading::class.java) + assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(true)) + } + } + + @Test + fun `present - CloseDialog will remove the confirmation dialog`() = runTest { + val room = FakeJoinedRoom().apply { + givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) + givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) + } + val presenter = createChangeRolesPresenter(role = RoomMember.Role.Admin, room = room) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.selectedUsers).hasSize(1) + + initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) + + awaitItem().eventSink(ChangeRolesEvent.Save) + val confirmingState = awaitItem() + assertThat(confirmingState.savingState).isEqualTo(ConfirmingModifyingAdmins) + + confirmingState.eventSink(ChangeRolesEvent.CloseDialog) + assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - Save will just save the data for moderators`() = runTest { + val analyticsService = FakeAnalyticsService() + val room = FakeJoinedRoom( + updateUserRoleResult = { Result.success(Unit) }, + baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }), + ).apply { + givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) + givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Moderator))) + } + val presenter = createChangeRolesPresenter( + role = RoomMember.Role.Moderator, + room = room, + analyticsService = analyticsService + ) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.selectedUsers).isEmpty() + initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) + awaitItem().also { + assertThat(it.selectedUsers).hasSize(1) + it.eventSink(ChangeRolesEvent.Save) + } + assertThat(awaitItem().savingState).isInstanceOf(AsyncAction.Loading::class.java) + assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(true)) + assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.Moderator)) + } + } + + @Test + fun `present - Save will ask for confirmation before assigning new owners`() = runTest { + val analyticsService = FakeAnalyticsService() + val room = FakeJoinedRoom( + updateUserRoleResult = { Result.success(Unit) }, + baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }), + ).apply { + givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) + givenRoomInfo( + aRoomInfo( + roomCreators = listOf(sessionId), + roomPowerLevels = roomPowerLevelsWithRoles( + A_USER_ID to RoomMember.Role.Owner(isCreator = false), + A_USER_ID_2 to RoomMember.Role.Admin, + ) + ) + ) + } + val presenter = createChangeRolesPresenter( + role = RoomMember.Role.Owner(isCreator = false), + room = room, + analyticsService = analyticsService + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.selectedUsers).isEmpty() + initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) + awaitItem().also { + assertThat(it.selectedUsers).hasSize(1) + it.eventSink(ChangeRolesEvent.Save) + } + assertThat(awaitItem().savingState.isConfirming()).isTrue() + } + } + + @Test + fun `present - Save will just save the changes if the current user is a room creator and the selected users are not`() = runTest { + val analyticsService = FakeAnalyticsService() + val room = FakeJoinedRoom( + updateUserRoleResult = { Result.success(Unit) }, + baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }), + ).apply { + givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) + givenRoomInfo( + aRoomInfo( + roomCreators = listOf(sessionId), + roomPowerLevels = roomPowerLevelsWithRole(role = RoomMember.Role.Admin, userId = A_USER_ID_2) + ) + ) + } + val presenter = createChangeRolesPresenter( + role = RoomMember.Role.Admin, + room = room, + analyticsService = analyticsService + ) + presenter.test { + skipItems(2) + val initialState = awaitItem() + assertThat(initialState.selectedUsers).hasSize(2) + initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) + awaitItem().also { + assertThat(it.selectedUsers).hasSize(1) + it.eventSink(ChangeRolesEvent.Save) + } + val loadingState = awaitItem() + assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java) + assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(true)) + assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.User)) + } + } + + @Test + fun `present - Save can handle failures and CloseDialog clears them`() = runTest { + val room = FakeJoinedRoom( + updateUserRoleResult = { Result.failure(IllegalStateException("Failed")) } + ).apply { + givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) + givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(role = RoomMember.Role.Moderator, userId = A_USER_ID))) + } + val presenter = createChangeRolesPresenter(role = RoomMember.Role.Moderator, room = room) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.selectedUsers).isEmpty() + initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) + awaitItem().also { + assertThat(it.selectedUsers).hasSize(1) + it.eventSink(ChangeRolesEvent.Save) + } + val loadingState = awaitItem() + assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java) + val failedState = awaitItem() + assertThat(failedState.savingState).isInstanceOf(AsyncAction.Failure::class.java) + failedState.eventSink(ChangeRolesEvent.CloseDialog) + assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `test analytics mapping`() = runTest { + val presenter = createChangeRolesPresenter() + with(presenter) { + assertThat(RoomMember.Role.User.toAnalyticsMemberRole()).isEqualTo(RoomModeration.Role.User) + assertThat(RoomMember.Role.Moderator.toAnalyticsMemberRole()).isEqualTo(RoomModeration.Role.Moderator) + assertThat(RoomMember.Role.Admin.toAnalyticsMemberRole()).isEqualTo(RoomModeration.Role.Administrator) + assertThat(RoomMember.Role.Owner(isCreator = false).toAnalyticsMemberRole()).isEqualTo(RoomModeration.Role.Administrator) + assertThat(RoomMember.Role.Owner(isCreator = true).toAnalyticsMemberRole()).isEqualTo(RoomModeration.Role.Administrator) + } + } + + private fun roomPowerLevelsWithRole( + role: RoomMember.Role, + userId: UserId = A_USER_ID, + ): RoomPowerLevels { + return RoomPowerLevels( + values = defaultRoomPowerLevelValues(), + users = persistentMapOf(userId to role.powerLevel) + ) + } + + private fun roomPowerLevelsWithRoles(vararg pairs: Pair): RoomPowerLevels { + return RoomPowerLevels( + values = defaultRoomPowerLevelValues(), + users = pairs.associate { (userId, role) -> userId to role.powerLevel }.toImmutableMap() + ) + } +} + +internal fun TestScope.createChangeRolesPresenter( + role: RoomMember.Role = RoomMember.Role.Admin, + room: FakeJoinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {})), + dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), + analyticsService: FakeAnalyticsService = FakeAnalyticsService(), +): ChangeRolesPresenter { + return ChangeRolesPresenter( + role = role, + room = room, + dataSource = RoomMemberListDataSource(room, dispatchers), + analyticsService = analyticsService, + roomCoroutineScope = this, + ) +} diff --git a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesViewTest.kt b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesViewTest.kt new file mode 100644 index 0000000..fd45e54 --- /dev/null +++ b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesViewTest.kt @@ -0,0 +1,308 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.roles + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.core.extensions.runCatchingExceptions +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.toMatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.pressBackKey +import kotlinx.collections.immutable.toImmutableList +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class ChangeRolesViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `passing a 'User' role throws an exception`() { + val exception = runCatchingExceptions { + rule.setChangeRolesContent( + state = aChangeRolesState( + role = RoomMember.Role.User, + eventSink = EnsureNeverCalledWithParam(), + ), + ) + }.exceptionOrNull() + assertThat(exception).isNotNull() + } + + @Test + fun `back key - with search active toggles the search`() { + val eventsRecorder = EventsRecorder() + rule.setChangeRolesContent( + state = aChangeRolesState( + isSearchActive = true, + eventSink = eventsRecorder, + ), + ) + rule.pressBackKey() + eventsRecorder.assertSingle(ChangeRolesEvent.ToggleSearchActive) + } + + @Test + fun `back key - with search inactive exits the screen`() { + val eventsRecorder = EventsRecorder() + rule.setChangeRolesContent( + state = aChangeRolesState( + isSearchActive = false, + eventSink = eventsRecorder, + ), + ) + rule.pressBackKey() + eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Exit)) + } + + @Test + fun `back button - exits the screen`() { + val eventsRecorder = EventsRecorder() + rule.setChangeRolesContent( + state = aChangeRolesState( + isSearchActive = false, + eventSink = eventsRecorder, + ), + ) + rule.pressBack() + eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Exit)) + } + + @Test + fun `save button - with changes, it saves them`() { + val eventsRecorder = EventsRecorder() + rule.setChangeRolesContent( + state = aChangeRolesState( + hasPendingChanges = true, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_save) + eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Save)) + } + + @Test + fun `save button - with no changes, does nothing`() { + val eventsRecorder = EventsRecorder() + rule.setChangeRolesContent( + state = aChangeRolesState( + hasPendingChanges = false, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_save) + eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""))) + } + + @Test + fun `exit confirmation dialog - submit exits the screen`() { + val eventsRecorder = EventsRecorder() + rule.setChangeRolesContent( + state = aChangeRolesState( + isSearchActive = true, + savingState = AsyncAction.ConfirmingCancellation, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_ok) + eventsRecorder.assertSingle(ChangeRolesEvent.Exit) + } + + @Test + fun `exit confirmation dialog - cancel removes the dialog`() { + val eventsRecorder = EventsRecorder() + rule.setChangeRolesContent( + state = aChangeRolesState( + isSearchActive = true, + savingState = AsyncAction.ConfirmingCancellation, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog) + } + + @Test + fun `save admins confirmation dialog - submit saves the changes`() { + val eventsRecorder = EventsRecorder() + rule.setChangeRolesContent( + state = aChangeRolesState( + role = RoomMember.Role.Admin, + isSearchActive = true, + savingState = ConfirmingModifyingAdmins, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_ok) + eventsRecorder.assertSingle(ChangeRolesEvent.Save) + } + + @Test + fun `save owners confirmation dialog - continue saves the changes`() { + val eventsRecorder = EventsRecorder() + rule.setChangeRolesContent( + state = aChangeRolesState( + role = RoomMember.Role.Owner(isCreator = false), + isSearchActive = true, + savingState = ConfirmingModifyingOwners, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_continue) + eventsRecorder.assertSingle(ChangeRolesEvent.Save) + } + + @Test + fun `save admins confirmation dialog - cancel removes the dialog`() { + val eventsRecorder = EventsRecorder() + rule.setChangeRolesContent( + state = aChangeRolesState( + role = RoomMember.Role.Admin, + isSearchActive = true, + savingState = ConfirmingModifyingAdmins, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog) + } + + @Test + fun `save owners confirmation dialog - cancel removes the dialog`() { + val eventsRecorder = EventsRecorder() + rule.setChangeRolesContent( + state = aChangeRolesState( + role = RoomMember.Role.Owner(isCreator = false), + isSearchActive = true, + savingState = ConfirmingModifyingOwners, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog) + } + + @Test + fun `error dialog - dismissing removes the dialog`() { + val eventsRecorder = EventsRecorder() + rule.setChangeRolesContent( + state = aChangeRolesState( + isSearchActive = true, + savingState = AsyncAction.Failure(IllegalStateException("boom")), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_ok) + eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog) + } + + @Test + fun `testing removing user from selected list emits the expected event`() { + val eventsRecorder = EventsRecorder() + val selectedUsers = aMatrixUserList().take(2) + val userToDeselect = selectedUsers[1] + assertThat(userToDeselect.displayName).isEqualTo("Bob") + rule.setChangeRolesContent( + state = aChangeRolesStateWithSelectedUsers().copy( + selectedUsers = selectedUsers.toImmutableList(), + eventSink = eventsRecorder, + ), + ) + // Unselect the user from the row list + val contentDescription = rule.activity.getString(CommonStrings.action_remove) + rule.onNodeWithContentDescription( + label = contentDescription, + useUnmergedTree = true, + ).performClick() + eventsRecorder.assertList( + listOf( + ChangeRolesEvent.QueryChanged(""), + ChangeRolesEvent.UserSelectionToggled(userToDeselect), + ) + ) + } + + @Test + @Config(qualifiers = "h1000dp") + fun `testing adding user to the selected list emits the expected event`() { + val eventsRecorder = EventsRecorder() + val selectedUsers = aMatrixUserList().take(2) + val state = aChangeRolesStateWithSelectedUsers().copy( + selectedUsers = selectedUsers.toImmutableList(), + eventSink = eventsRecorder, + ) + val userToSelect = (state.searchResults as SearchBarResultState.Results).results.members.first().toMatrixUser() + assertThat(userToSelect.displayName).isEqualTo("Carol") + rule.setChangeRolesContent( + state = state, + ) + // Select the user from the user list + rule.onNodeWithText("Carol").performClick() + eventsRecorder.assertList( + listOf( + ChangeRolesEvent.QueryChanged(""), + ChangeRolesEvent.UserSelectionToggled(userToSelect), + ) + ) + } + + @Test + fun `testing removing user to the selected list emits the expected event`() { + val eventsRecorder = EventsRecorder() + val selectedUsers = aMatrixUserList().take(2) + val state = aChangeRolesStateWithSelectedUsers().copy( + selectedUsers = selectedUsers.toImmutableList(), + eventSink = eventsRecorder, + ) + val userToSelect = (state.searchResults as SearchBarResultState.Results).results.moderators.first().toMatrixUser() + assertThat(userToSelect.displayName).isEqualTo("Bob") + rule.setChangeRolesContent( + state = state, + ) + // Unselect the user from the user list + rule.onAllNodesWithText( + text = "Bob", + useUnmergedTree = true, + )[1].performClick() + eventsRecorder.assertList( + listOf( + ChangeRolesEvent.QueryChanged(""), + ChangeRolesEvent.UserSelectionToggled(userToSelect), + ) + ) + } + + private fun AndroidComposeTestRule.setChangeRolesContent( + state: ChangeRolesState, + ) { + setContent { + ChangeRolesView( + state = state, + ) + } + } +} diff --git a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/DefaultChangeRoomMemberRolesEntyPointTest.kt b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/DefaultChangeRoomMemberRolesEntyPointTest.kt new file mode 100644 index 0000000..7d190b8 --- /dev/null +++ b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/DefaultChangeRoomMemberRolesEntyPointTest.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.roles + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.tests.testutils.node.TestParentNode +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DefaultChangeRoomMemberRolesEntyPointTest { + @Test + fun `test node builder`() = runTest { + val entryPoint = DefaultChangeRoomMemberRolesEntyPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + ChangeRoomMemberRolesRootNode( + buildContext = buildContext, + plugins = plugins, + roomGraphFactory = { }, + ) + } + val room = FakeJoinedRoom() + val listType = ChangeRoomMemberRolesListType.Admins + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + room = FakeJoinedRoom(), + listType = listType, + ) + assertThat(result).isInstanceOf(ChangeRoomMemberRolesRootNode::class.java) + // Search for the Inputs plugin + val input = result.plugins.filterIsInstance().single() + assertThat(input.joinedRoom.roomId).isEqualTo(room.roomId) + assertThat(input.listType).isEqualTo(listType) + } +} diff --git a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/MembersByRoleTest.kt b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/MembersByRoleTest.kt new file mode 100644 index 0000000..66a06cd --- /dev/null +++ b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/MembersByRoleTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.roles + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.RoomMember +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_ID_3 +import io.element.android.libraries.matrix.test.A_USER_ID_4 +import io.element.android.libraries.matrix.test.A_USER_ID_5 +import io.element.android.libraries.matrix.test.A_USER_ID_6 +import io.element.android.libraries.matrix.test.A_USER_ID_7 +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test + +class MembersByRoleTest { + @Test + fun `constructor - with single member list categorizes and sorts members`() { + val members = listOf( + aRoomMember(A_USER_ID_2, displayName = "Bob", role = RoomMember.Role.Admin), + aRoomMember(A_USER_ID, displayName = "Alice", role = RoomMember.Role.Admin), + aRoomMember(A_USER_ID_3, displayName = "Carol", role = RoomMember.Role.User), + aRoomMember(A_USER_ID_5, displayName = "Eve", role = RoomMember.Role.User), + aRoomMember(A_USER_ID_4, displayName = "David", role = RoomMember.Role.User), + aRoomMember(A_USER_ID_6, displayName = "Justin", role = RoomMember.Role.Owner(isCreator = true)), + aRoomMember(A_USER_ID_7, displayName = "Mallory", role = RoomMember.Role.Owner(isCreator = false)), + ) + val membersByRole = MembersByRole(members = members, comparator = PowerLevelRoomMemberComparator()) + assertThat(membersByRole.owners).containsExactly( + aRoomMember(A_USER_ID_6, displayName = "Justin", role = RoomMember.Role.Owner(isCreator = true)), + aRoomMember(A_USER_ID_7, displayName = "Mallory", role = RoomMember.Role.Owner(isCreator = false)), + ) + assertThat(membersByRole.admins).containsExactly( + aRoomMember(A_USER_ID, displayName = "Alice", role = RoomMember.Role.Admin), + aRoomMember(A_USER_ID_2, displayName = "Bob", role = RoomMember.Role.Admin), + ) + assertThat(membersByRole.moderators).isEmpty() + assertThat(membersByRole.members).containsExactly( + aRoomMember(A_USER_ID_3, displayName = "Carol", role = RoomMember.Role.User), + aRoomMember(A_USER_ID_4, displayName = "David", role = RoomMember.Role.User), + aRoomMember(A_USER_ID_5, displayName = "Eve", role = RoomMember.Role.User), + ) + } + + @Test + fun `isEmpty - only returns true with no members of any role`() { + val emptyMembersByRole = MembersByRole() + assertThat(emptyMembersByRole.isEmpty()).isTrue() + + val membersByRoleWithOwners = MembersByRole( + owners = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.Admin)), + ) + assertThat(membersByRoleWithOwners.isEmpty()).isFalse() + + val membersByRoleWithAdmins = MembersByRole( + admins = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.Admin)), + ) + assertThat(membersByRoleWithAdmins.isEmpty()).isFalse() + + val membersByRoleWithModerators = MembersByRole( + moderators = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.Moderator)), + ) + assertThat(membersByRoleWithModerators.isEmpty()).isFalse() + + val membersByRoleWithMembers = MembersByRole( + members = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.User)), + ) + assertThat(membersByRoleWithMembers.isEmpty()).isFalse() + } +} diff --git a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionPresenterTest.kt b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionPresenterTest.kt new file mode 100644 index 0000000..3eaacb9 --- /dev/null +++ b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionPresenterTest.kt @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.root + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.RoomModeration +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RolesAndPermissionPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createRolesAndPermissionsPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + with(awaitItem()) { + assertThat(adminCount).isEqualTo(0) + assertThat(moderatorCount).isEqualTo(0) + assertThat(changeOwnRoleAction).isEqualTo(AsyncAction.Uninitialized) + } + } + } + + @Test + fun `present - ChangeOwnRole presents a confirmation dialog`() = runTest { + val presenter = createRolesAndPermissionsPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RolesAndPermissionsEvents.ChangeOwnRole) + + assertThat(awaitItem().changeOwnRoleAction).isEqualTo(AsyncAction.ConfirmingNoParams) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - DemoteSelfTo changes own role to the specified one`() = runTest(StandardTestDispatcher()) { + val presenter = createRolesAndPermissionsPresenter( + dispatchers = testCoroutineDispatchers(), + room = FakeJoinedRoom( + updateUserRoleResult = { Result.success(Unit) } + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator)) + + runCurrent() + assertThat(awaitItem().changeOwnRoleAction).isEqualTo(AsyncAction.Loading) + + runCurrent() + assertThat(awaitItem().changeOwnRoleAction).isEqualTo(AsyncAction.Success(Unit)) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - DemoteSelfTo can handle failures and clean them`() = runTest(StandardTestDispatcher()) { + val room = FakeJoinedRoom( + updateUserRoleResult = { Result.failure(Exception("Failed to update role")) } + ) + val presenter = createRolesAndPermissionsPresenter(room = room, dispatchers = testCoroutineDispatchers()) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator)) + + runCurrent() + assertThat(awaitItem().changeOwnRoleAction).isEqualTo(AsyncAction.Loading) + + runCurrent() + assertThat(awaitItem().changeOwnRoleAction).isInstanceOf(AsyncAction.Failure::class.java) + + initialState.eventSink(RolesAndPermissionsEvents.CancelPendingAction) + assertThat(awaitItem().changeOwnRoleAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - CancelPendingAction dismisses confirmation dialog too`() = runTest { + val presenter = createRolesAndPermissionsPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RolesAndPermissionsEvents.ChangeOwnRole) + awaitItem().eventSink(RolesAndPermissionsEvents.CancelPendingAction) + + assertThat(awaitItem().changeOwnRoleAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - ResetPermissions needs confirmation, then resets permissions`() = runTest { + val analyticsService = FakeAnalyticsService() + val presenter = createRolesAndPermissionsPresenter( + analyticsService = analyticsService, + room = FakeJoinedRoom( + resetPowerLevelsResult = { Result.success(Unit) } + ) + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RolesAndPermissionsEvents.ResetPermissions) + // Confirmation + awaitItem().eventSink(RolesAndPermissionsEvents.ResetPermissions) + + assertThat(awaitItem().resetPermissionsAction).isEqualTo(AsyncAction.Loading) + assertThat(awaitItem().resetPermissionsAction).isEqualTo(AsyncAction.Success(Unit)) + assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ResetPermissions)) + } + } + + @Test + fun `present - ResetPermissions confirmation can be cancelled`() = runTest { + val presenter = createRolesAndPermissionsPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RolesAndPermissionsEvents.ResetPermissions) + awaitItem().eventSink(RolesAndPermissionsEvents.CancelPendingAction) + + assertThat(awaitItem().resetPermissionsAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + private fun TestScope.createRolesAndPermissionsPresenter( + room: FakeJoinedRoom = FakeJoinedRoom(), + dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), + analyticsService: FakeAnalyticsService = FakeAnalyticsService() + ): RolesAndPermissionsPresenter { + return RolesAndPermissionsPresenter( + room = room, + dispatchers = dispatchers, + analyticsService = analyticsService + ) + } +} diff --git a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsViewTest.kt b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsViewTest.kt new file mode 100644 index 0000000..e08ae20 --- /dev/null +++ b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsViewTest.kt @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.rolesandpermissions.impl.root + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.rolesandpermissions.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledTimes +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.setSafeContent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class RolesAndPermissionsViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `click on back invokes expected callback`() { + ensureCalledOnce { callback -> + rule.setRolesAndPermissionsView( + goBack = callback, + ) + rule.pressBack() + } + } + + @Test + fun `tapping on Admins opens admin list`() { + ensureCalledOnce { callback -> + rule.setRolesAndPermissionsView( + aRolesAndPermissionsState( + roomSupportsOwners = false, + eventSink = EventsRecorder(expectEvents = false) + ), + openAdminList = callback, + ) + rule.clickOn(R.string.screen_room_roles_and_permissions_admins) + } + } + + @Test + fun `tapping on Admins and Owners opens admin list`() { + ensureCalledOnce { callback -> + rule.setRolesAndPermissionsView( + aRolesAndPermissionsState( + roomSupportsOwners = true, + eventSink = EventsRecorder(expectEvents = false) + ), + openAdminList = callback, + ) + rule.clickOn(R.string.screen_room_roles_and_permissions_admins_and_owners) + } + } + + @Test + fun `tapping on Moderators opens moderators list`() { + ensureCalledOnce { callback -> + rule.setRolesAndPermissionsView( + openModeratorList = callback, + ) + rule.clickOn(R.string.screen_room_roles_and_permissions_moderators) + } + } + + @Test + @Config(qualifiers = "h640dp") + fun `tapping permission item open the change permissions screen`() { + ensureCalledTimes(1) { callback -> + rule.setRolesAndPermissionsView( + openEditPermissions = callback, + ) + rule.clickOn(R.string.screen_room_roles_and_permissions_permissions_header) + } + } + + @Test + @Config(qualifiers = "h640dp") + fun `tapping on reset permissions triggers ResetPermissions event`() { + val recorder = EventsRecorder() + rule.setRolesAndPermissionsView( + state = aRolesAndPermissionsState( + eventSink = recorder, + ), + ) + rule.clickOn(R.string.screen_room_roles_and_permissions_reset) + recorder.assertSingle(RolesAndPermissionsEvents.ResetPermissions) + } + + @Test + fun `tapping on Reset in the reset permissions confirmation dialog triggers ResetPermissions event`() { + val recorder = EventsRecorder() + rule.setRolesAndPermissionsView( + state = aRolesAndPermissionsState( + resetPermissionsAction = AsyncAction.ConfirmingNoParams, + eventSink = recorder, + ), + ) + rule.clickOn(CommonStrings.action_reset) + recorder.assertSingle(RolesAndPermissionsEvents.ResetPermissions) + } + + @Test + fun `tapping on Cancel in the reset permissions confirmation dialog triggers CancelPendingAction event`() { + val recorder = EventsRecorder() + rule.setRolesAndPermissionsView( + state = aRolesAndPermissionsState( + resetPermissionsAction = AsyncAction.ConfirmingNoParams, + eventSink = recorder, + ), + ) + rule.clickOn(CommonStrings.action_cancel) + recorder.assertSingle(RolesAndPermissionsEvents.CancelPendingAction) + } + + @Test + fun `tapping on 'Demote to moderator' in the demote self bottom sheet triggers the right event`() { + val recorder = EventsRecorder() + rule.setRolesAndPermissionsView( + state = aRolesAndPermissionsState( + changeOwnRoleAction = AsyncAction.ConfirmingNoParams, + eventSink = recorder, + ), + ) + rule.clickOn(R.string.screen_room_roles_and_permissions_change_role_demote_to_moderator) + rule.mainClock.advanceTimeBy(1_000L) + recorder.assertSingle(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator)) + } + + @Test + fun `tapping on 'Demote to member' in the demote self bottom sheet triggers the right event`() = runTest { + val recorder = EventsRecorder() + rule.setRolesAndPermissionsView( + state = aRolesAndPermissionsState( + changeOwnRoleAction = AsyncAction.ConfirmingNoParams, + eventSink = recorder, + ), + ) + rule.clickOn(R.string.screen_room_roles_and_permissions_change_role_demote_to_member) + rule.mainClock.advanceTimeBy(1_000L) + recorder.assertSingle(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.User)) + } + + @Test + fun `tapping on 'Cancel' in the demote self bottom sheet triggers the right event`() { + val recorder = EventsRecorder() + rule.setRolesAndPermissionsView( + state = aRolesAndPermissionsState( + changeOwnRoleAction = AsyncAction.ConfirmingNoParams, + eventSink = recorder, + ), + ) + rule.clickOn(CommonStrings.action_cancel) + rule.mainClock.advanceTimeBy(1_000L) + recorder.assertSingle(RolesAndPermissionsEvents.CancelPendingAction) + } +} + +private fun AndroidComposeTestRule.setRolesAndPermissionsView( + state: RolesAndPermissionsState = aRolesAndPermissionsState( + roomSupportsOwners = false, + eventSink = EventsRecorder(expectEvents = false), + ), + goBack: () -> Unit = EnsureNeverCalled(), + openAdminList: () -> Unit = EnsureNeverCalled(), + openModeratorList: () -> Unit = EnsureNeverCalled(), + openEditPermissions: () -> Unit = EnsureNeverCalled(), +) { + setSafeContent { + RolesAndPermissionsView( + state = state, + rolesAndPermissionsNavigator = object : RolesAndPermissionsNavigator { + override fun onBackClick() = goBack() + override fun openAdminList() = openAdminList() + override fun openModeratorList() = openModeratorList() + override fun openEditPermissions() = openEditPermissions() + } + ) + } +} diff --git a/features/rolesandpermissions/test/build.gradle.kts b/features/rolesandpermissions/test/build.gradle.kts new file mode 100644 index 0000000..f2b5d05 --- /dev/null +++ b/features/rolesandpermissions/test/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.rolesandpermissions.test" +} + +dependencies { + implementation(projects.features.rolesandpermissions.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.tests.testutils) +} diff --git a/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeChangeRoomMemberRolesEntryPoint.kt b/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeChangeRoomMemberRolesEntryPoint.kt new file mode 100644 index 0000000..0526afc --- /dev/null +++ b/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeChangeRoomMemberRolesEntryPoint.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.changeroommemberroles.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint +import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeChangeRoomMemberRolesEntryPoint : ChangeRoomMemberRolesEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + room: JoinedRoom, + listType: ChangeRoomMemberRolesListType, + ): Node { + lambdaError() + } +} diff --git a/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeRolesAndPermissionsEntryPoint.kt b/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeRolesAndPermissionsEntryPoint.kt new file mode 100644 index 0000000..01f2787 --- /dev/null +++ b/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeRolesAndPermissionsEntryPoint.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.changeroommemberroles.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.rolesandpermissions.api.RolesAndPermissionsEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeRolesAndPermissionsEntryPoint : RolesAndPermissionsEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext): Node { + lambdaError() + } +} diff --git a/features/roomaliasresolver/api/build.gradle.kts b/features/roomaliasresolver/api/build.gradle.kts new file mode 100644 index 0000000..2c4184e --- /dev/null +++ b/features/roomaliasresolver/api/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.roomaliasresolver.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasesolver/api/RoomAliasResolverEntryPoint.kt b/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasesolver/api/RoomAliasResolverEntryPoint.kt new file mode 100644 index 0000000..ce02700 --- /dev/null +++ b/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasesolver/api/RoomAliasResolverEntryPoint.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomaliasesolver.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.architecture.NodeInputs +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias + +interface RoomAliasResolverEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + callback: Callback, + ): Node + + interface Callback : Plugin { + fun onAliasResolved(data: ResolvedRoomAlias) + } + + data class Params( + val roomAlias: RoomAlias + ) : NodeInputs +} diff --git a/features/roomaliasresolver/impl/build.gradle.kts b/features/roomaliasresolver/impl/build.gradle.kts new file mode 100644 index 0000000..1860c77 --- /dev/null +++ b/features/roomaliasresolver/impl/build.gradle.kts @@ -0,0 +1,40 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.roomaliasresolver.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + api(projects.features.roomaliasresolver.api) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) +} diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPoint.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPoint.kt new file mode 100644 index 0000000..c6f8966 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPoint.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomaliasresolver.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultRoomAliasResolverEntryPoint : RoomAliasResolverEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: RoomAliasResolverEntryPoint.Params, + callback: RoomAliasResolverEntryPoint.Callback, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf(params, callback), + ) + } +} diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverEvents.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverEvents.kt new file mode 100644 index 0000000..11b92ba --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomaliasresolver.impl + +sealed interface RoomAliasResolverEvents { + data object Retry : RoomAliasResolverEvents + data object DismissError : RoomAliasResolverEvents +} diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt new file mode 100644 index 0000000..041d43c --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomaliasresolver.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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class RoomAliasResolverNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: RoomAliasResolverPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + private val callback: RoomAliasResolverEntryPoint.Callback = callback() + private val inputs = inputs() + + private val presenter = presenterFactory.create( + inputs.roomAlias + ) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + RoomAliasResolverView( + state = state, + onSuccess = callback::onAliasResolved, + onBackClick = ::navigateUp, + modifier = modifier + ) + } +} diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt new file mode 100644 index 0000000..78be168 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomaliasresolver.impl + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.jvm.optionals.getOrElse + +@AssistedInject +class RoomAliasResolverPresenter( + @Assisted private val roomAlias: RoomAlias, + private val matrixClient: MatrixClient, +) : Presenter { + fun interface Factory { + fun create( + roomAlias: RoomAlias, + ): RoomAliasResolverPresenter + } + + @Composable + override fun present(): RoomAliasResolverState { + val coroutineScope = rememberCoroutineScope() + val resolveState: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) } + LaunchedEffect(Unit) { + resolveAlias(resolveState) + } + + fun handleEvent(event: RoomAliasResolverEvents) { + when (event) { + RoomAliasResolverEvents.Retry -> coroutineScope.resolveAlias(resolveState) + RoomAliasResolverEvents.DismissError -> resolveState.value = AsyncData.Uninitialized + } + } + + return RoomAliasResolverState( + roomAlias = roomAlias, + resolveState = resolveState.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.resolveAlias(resolveState: MutableState>) = launch { + suspend { + matrixClient.resolveRoomAlias(roomAlias) + .getOrThrow() + .getOrElse { throw RoomAliasResolverFailures.UnknownAlias } + }.runCatchingUpdatingState(resolveState) + } +} diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverState.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverState.kt new file mode 100644 index 0000000..82d13f1 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverState.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomaliasresolver.impl + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias + +data class RoomAliasResolverState( + val roomAlias: RoomAlias, + val resolveState: AsyncData, + val eventSink: (RoomAliasResolverEvents) -> Unit +) + +sealed class RoomAliasResolverFailures : Exception() { + data object UnknownAlias : RoomAliasResolverFailures() +} diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverStateProvider.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverStateProvider.kt new file mode 100644 index 0000000..c3152bc --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverStateProvider.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomaliasresolver.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.exception.ClientException +import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias + +open class RoomAliasResolverStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aRoomAliasResolverState(), + aRoomAliasResolverState( + resolveState = AsyncData.Failure(ClientException.Generic("Something went wrong", null)), + ), + aRoomAliasResolverState( + resolveState = AsyncData.Failure(RoomAliasResolverFailures.UnknownAlias), + ), + ) +} + +fun aRoomAliasResolverState( + roomAlias: RoomAlias = A_ROOM_ALIAS, + resolveState: AsyncData = AsyncData.Uninitialized, + eventSink: (RoomAliasResolverEvents) -> Unit = {} +) = RoomAliasResolverState( + roomAlias = roomAlias, + resolveState = resolveState, + eventSink = eventSink, +) + +private val A_ROOM_ALIAS = RoomAlias("#exa:matrix.org") diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt new file mode 100644 index 0000000..a068ac7 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomaliasresolver.impl + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +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.dp +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom +import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewSubtitleAtom +import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism +import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage +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.RetryDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun RoomAliasResolverView( + state: RoomAliasResolverState, + onBackClick: () -> Unit, + onSuccess: (ResolvedRoomAlias) -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + ) { + HeaderFooterPage( + containerColor = Color.Transparent, + contentPadding = PaddingValues( + horizontal = 16.dp, + vertical = 32.dp + ), + topBar = { + RoomAliasResolverTopBar(onBackClick = onBackClick) + }, + content = { + RoomAliasResolverContent(roomAlias = state.roomAlias, isLoading = state.resolveState.isLoading()) + }, + ) + ResolvedRoomAliasView( + resolvedRoomAlias = state.resolveState, + onSuccess = onSuccess, + onRetry = { state.eventSink(RoomAliasResolverEvents.Retry) }, + onDismissError = { + state.eventSink(RoomAliasResolverEvents.DismissError) + onBackClick() + } + ) + } +} + +@Composable +private fun ResolvedRoomAliasView( + resolvedRoomAlias: AsyncData, + onSuccess: (ResolvedRoomAlias) -> Unit, + onRetry: () -> Unit, + onDismissError: () -> Unit, +) { + when (resolvedRoomAlias) { + is AsyncData.Success -> { + val latestOnSuccess by rememberUpdatedState(onSuccess) + LaunchedEffect(Unit) { + latestOnSuccess(resolvedRoomAlias.data) + } + } + is AsyncData.Failure -> { + if (resolvedRoomAlias.error is RoomAliasResolverFailures.UnknownAlias) { + ErrorDialog( + title = stringResource(id = R.string.screen_join_room_loading_alert_title), + content = stringResource(id = R.string.screen_room_alias_resolver_resolve_alias_failure), + onSubmit = onDismissError + ) + } else { + RetryDialog( + title = stringResource(id = R.string.screen_join_room_loading_alert_title), + content = stringResource(id = CommonStrings.error_network_or_server_issue), + onRetry = onRetry, + onDismiss = onDismissError + ) + } + } + else -> Unit + } +} + +@Composable +private fun RoomAliasResolverContent( + roomAlias: RoomAlias, + isLoading: Boolean, + modifier: Modifier = Modifier, +) { + RoomPreviewOrganism( + modifier = modifier, + avatar = { + PlaceholderAtom(width = AvatarSize.RoomPreviewHeader.dp, height = AvatarSize.RoomPreviewHeader.dp) + }, + title = { + RoomPreviewSubtitleAtom(roomAlias.value) + }, + subtitle = { + if (isLoading) { + Spacer(Modifier.height(8.dp)) + CircularProgressIndicator() + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RoomAliasResolverTopBar( + onBackClick: () -> Unit, +) { + TopAppBar( + navigationIcon = { + BackButton(onClick = onBackClick) + }, + title = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun RoomAliasResolverViewPreview(@PreviewParameter(RoomAliasResolverStateProvider::class) state: RoomAliasResolverState) = ElementPreview { + RoomAliasResolverView( + state = state, + onSuccess = { }, + onBackClick = { } + ) +} diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/di/RoomAliasResolverModule.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/di/RoomAliasResolverModule.kt new file mode 100644 index 0000000..8f5c9d7 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/di/RoomAliasResolverModule.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomaliasresolver.impl.di + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import io.element.android.features.roomaliasresolver.impl.RoomAliasResolverPresenter +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomAlias + +@BindingContainer +@ContributesTo(SessionScope::class) +object RoomAliasResolverModule { + @Provides + fun providesJoinRoomPresenterFactory( + client: MatrixClient, + ): RoomAliasResolverPresenter.Factory { + return object : RoomAliasResolverPresenter.Factory { + override fun create(roomAlias: RoomAlias): RoomAliasResolverPresenter { + return RoomAliasResolverPresenter( + roomAlias = roomAlias, + matrixClient = client, + ) + } + } + } +} diff --git a/features/roomaliasresolver/impl/src/main/res/values-be/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..e9465f6 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,4 @@ + + + "Не ўдалося разабрацца з псеўданімам пакоя." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-cs/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..58bb797 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,5 @@ + + + "Náhled této místnosti jsme nemohli zobrazit" + "Nepodařilo se přeložit alias místnosti." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-cy/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..8e87e3a --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,5 @@ + + + "Doedd dim modd dangos rhagolwg yr ystafell hon" + "Wedi methu â datrys arallenw ystafell." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-da/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..68cde2d --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,5 @@ + + + "Vi kunne ikke forhåndsvise rummet" + "Kunne ikke løse rummets alias." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-de/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..60bd1a2 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,5 @@ + + + "Wir konnten diese Chat-Vorschau nicht anzeigen" + "Der Chat-Alias konnte nicht ermittelt werden." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-el/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..266cb50 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,5 @@ + + + "Δεν μπορέσαμε να εμφανίσουμε αυτή την προεπισκόπηση αίθουσας" + "Αποτυχία επίλυσης του ψευδώνυμου αίθουσας." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-es/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..73cc493 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,5 @@ + + + "No hemos podido mostrar la vista previa de esta sala" + "No se pudo resolver el alias de la sala." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-et/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..b2eb5c0 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,5 @@ + + + "Meil ei õnnestunud selle jututoa eelvaadet kuvada" + "Jututoa aliasele vastava aadressi tuvastamine ei õnnestunud." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-fi/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..84e0615 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,5 @@ + + + "Emme voineet näyttää tämän huoneen esikatselua" + "Huoneen aliaksen ratkaiseminen epäonnistui." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-fr/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..6271998 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,5 @@ + + + "Impossible d’afficher l’aperçu de ce salon" + "Impossible de trouver un salon avec cet alias." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-hu/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..585a3be --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,5 @@ + + + "Nem tudtuk megjeleníteni a szoba előnézetét" + "Nem sikerült a szoba álnevének feloldása." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-in/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..2e84cbf --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,5 @@ + + + "Kami tidak dapat menampilkan pratinjau ruangan ini" + "Gagal menyelesaikan alias ruangan." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-it/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..5860ac2 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,5 @@ + + + "Non è stato possibile visualizzare l\'anteprima di questa stanza" + "Impossibile risolvere l\'alias della stanza." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-ko/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..339fa42 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,5 @@ + + + "이 방 미리보기를 표시할 수 없습니다." + "방 별칭을 확인할 수 없습니다." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-nb/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..1a9a264 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,5 @@ + + + "Vi kunne ikke vise forhåndsvisning av dette rommet" + "Kunne ikke løse romalias." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-nl/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..9bf0f71 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,4 @@ + + + "Kan het kameradres niet vinden." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-pl/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..dabe170 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,5 @@ + + + "Nie udało nam się wyświetlić podglądu tego pokoju" + "Nie udało się uzyskać aliasu pokoju." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-pt-rBR/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..d806132 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,5 @@ + + + "Não foi possível exibir a pré-visualização desta sala" + "Falha ao descobrir o alias da sala." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-pt/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..3ae285d --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,5 @@ + + + "Não foi possível exibir a pré-visualização desta sala" + "Não foi possível encontrar esse endereço de sala" + diff --git a/features/roomaliasresolver/impl/src/main/res/values-ro/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..195a947 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,5 @@ + + + "Nu am putut afișa previzualizarea acestei camere." + "Nu s-a putut rezolva alias-ul camerei." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-ru/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..f7db24f --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,5 @@ + + + "Мы не смогли отобразить предварительный просмотр этой комнаты" + "Не удалось определить псевдоним комнаты." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-sk/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..7131587 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,5 @@ + + + "Ukážku tejto miestnosti sa nepodarilo zobraziť" + "Nepodarilo sa nájsť alias miestnosti." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-sv/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..d2da1ca --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,5 @@ + + + "Vi kunde inte visa förhandsgranskningen av rummet" + "Misslyckades med att slå upp rumsalias." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-tr/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..395799e --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,5 @@ + + + "Bu oda önizlemesini görüntüleyemedik" + "Oda takma adı çözümlenemedi." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-uk/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..91412c2 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,5 @@ + + + "Ми не можемо показати попередній перегляд цієї кімнати" + "Не вдалося розв\'язати псевдонім кімнати." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-ur/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..c415adc --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,4 @@ + + + "کمرے کے عرف کو حل کرنے میں ناکام۔" + diff --git a/features/roomaliasresolver/impl/src/main/res/values-uz/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..d79cdad --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,5 @@ + + + "Biz bu xonani oldindan ko‘rishni ko‘rsata olmadik " + "Xona taxalluslari yechilmadi." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-zh-rTW/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..0e65740 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,5 @@ + + + "我們無法顯示此聊天室的預覽" + "無法解析聊天室別名。" + diff --git a/features/roomaliasresolver/impl/src/main/res/values-zh/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..52934b6 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,5 @@ + + + "无法显示此房间预览" + "无法解析聊天室别名。" + diff --git a/features/roomaliasresolver/impl/src/main/res/values/localazy.xml b/features/roomaliasresolver/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..6ace0d7 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values/localazy.xml @@ -0,0 +1,5 @@ + + + "We couldn’t display this room preview" + "Failed to resolve room alias." + diff --git a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPointTest.kt b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPointTest.kt new file mode 100644 index 0000000..a519032 --- /dev/null +++ b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPointTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomaliasresolver.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint +import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias +import io.element.android.libraries.matrix.test.A_ROOM_ALIAS +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultRoomAliasResolverEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultRoomAliasResolverEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + RoomAliasResolverNode( + buildContext = buildContext, + plugins = plugins, + presenterFactory = { alias -> + assertThat(alias).isEqualTo(A_ROOM_ALIAS) + createPresenter( + alias, + ) + } + ) + } + val callback = object : RoomAliasResolverEntryPoint.Callback { + override fun onAliasResolved(data: ResolvedRoomAlias) = lambdaError() + } + val params = RoomAliasResolverEntryPoint.Params( + roomAlias = A_ROOM_ALIAS + ) + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + params = params, + callback = callback, + ) + assertThat(result).isInstanceOf(RoomAliasResolverNode::class.java) + assertThat(result.plugins).contains(params) + assertThat(result.plugins).contains(callback) + } +} diff --git a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperPresenterTest.kt b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperPresenterTest.kt new file mode 100644 index 0000000..df8b805 --- /dev/null +++ b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperPresenterTest.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomaliasresolver.impl + +import app.cash.molecule.RecompositionMode +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.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_ROOM_ALIAS +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SERVER_LIST +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import java.util.Optional + +class RoomAliasHelperPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + assertThat(awaitItem().resolveState.isUninitialized()).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - resolve alias to roomId`() = runTest { + val result = Optional.of(aResolvedRoomAlias()) + val client = FakeMatrixClient( + resolveRoomAliasResult = { Result.success(result) } + ) + val presenter = createPresenter(matrixClient = client) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + assertThat(awaitItem().resolveState.isUninitialized()).isTrue() + assertThat(awaitItem().resolveState.isLoading()).isTrue() + val resultState = awaitItem() + assertThat(resultState.roomAlias).isEqualTo(A_ROOM_ALIAS) + assertThat(resultState.resolveState.dataOrNull()).isEqualTo(result.get()) + } + } + + @Test + fun `present - resolve alias error and retry`() = runTest { + val client = FakeMatrixClient( + resolveRoomAliasResult = { Result.failure(AN_EXCEPTION) } + ) + val presenter = createPresenter(matrixClient = client) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + assertThat(awaitItem().resolveState.isUninitialized()).isTrue() + assertThat(awaitItem().resolveState.isLoading()).isTrue() + val resultState = awaitItem() + assertThat(resultState.resolveState.errorOrNull()).isEqualTo(AN_EXCEPTION) + resultState.eventSink(RoomAliasResolverEvents.Retry) + val retryLoadingState = awaitItem() + assertThat(retryLoadingState.resolveState.isLoading()).isTrue() + val retryState = awaitItem() + assertThat(retryState.resolveState.errorOrNull()).isEqualTo(AN_EXCEPTION) + } + } +} + +internal fun createPresenter( + roomAlias: RoomAlias = A_ROOM_ALIAS, + matrixClient: MatrixClient = FakeMatrixClient(), +) = RoomAliasResolverPresenter( + roomAlias = roomAlias, + matrixClient = matrixClient, +) + +internal fun aResolvedRoomAlias( + roomId: RoomId = A_ROOM_ID, + servers: List = A_SERVER_LIST, +) = ResolvedRoomAlias( + roomId = roomId, + servers = servers, +) diff --git a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperViewTest.kt b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperViewTest.kt new file mode 100644 index 0000000..4b37f99 --- /dev/null +++ b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasHelperViewTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomaliasresolver.impl + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RoomAliasHelperViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setRoomAliasResolverView( + aRoomAliasResolverState( + eventSink = eventsRecorder, + ), + onBackClick = it + ) + rule.pressBack() + } + } + + @Test + fun `clicking on Retry emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomAliasResolverView( + aRoomAliasResolverState( + resolveState = AsyncData.Failure(Exception("Error")), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_retry) + eventsRecorder.assertSingle(RoomAliasResolverEvents.Retry) + } + + @Test + fun `success state invokes the expected Callback`() { + val result = aResolvedRoomAlias() + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnceWithParam(result) { + rule.setRoomAliasResolverView( + aRoomAliasResolverState( + resolveState = AsyncData.Success(result), + eventSink = eventsRecorder, + ), + onAliasResolved = it, + ) + } + } +} + +private fun AndroidComposeTestRule.setRoomAliasResolverView( + state: RoomAliasResolverState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onAliasResolved: (ResolvedRoomAlias) -> Unit = EnsureNeverCalledWithParam(), +) { + setContent { + RoomAliasResolverView( + state = state, + onBackClick = onBackClick, + onSuccess = onAliasResolved, + ) + } +} diff --git a/features/roomcall/api/build.gradle.kts b/features/roomcall/api/build.gradle.kts new file mode 100644 index 0000000..54bb7bb --- /dev/null +++ b/features/roomcall/api/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.roomcall.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(libs.androidx.compose.ui.tooling.preview) +} diff --git a/features/roomcall/api/src/main/kotlin/io/element/android/features/roomcall/api/RoomCallState.kt b/features/roomcall/api/src/main/kotlin/io/element/android/features/roomcall/api/RoomCallState.kt new file mode 100644 index 0000000..1a6b17e --- /dev/null +++ b/features/roomcall/api/src/main/kotlin/io/element/android/features/roomcall/api/RoomCallState.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomcall.api + +import androidx.compose.runtime.Immutable +import io.element.android.features.roomcall.api.RoomCallState.OnGoing +import io.element.android.features.roomcall.api.RoomCallState.StandBy + +@Immutable +sealed interface RoomCallState { + data object Unavailable : RoomCallState + + data class StandBy( + val canStartCall: Boolean, + ) : RoomCallState + + data class OnGoing( + val canJoinCall: Boolean, + val isUserInTheCall: Boolean, + val isUserLocallyInTheCall: Boolean, + ) : RoomCallState +} + +fun RoomCallState.hasPermissionToJoin() = when (this) { + RoomCallState.Unavailable -> false + is StandBy -> canStartCall + is OnGoing -> canJoinCall +} diff --git a/features/roomcall/api/src/main/kotlin/io/element/android/features/roomcall/api/RoomCallStateProvider.kt b/features/roomcall/api/src/main/kotlin/io/element/android/features/roomcall/api/RoomCallStateProvider.kt new file mode 100644 index 0000000..be86c24 --- /dev/null +++ b/features/roomcall/api/src/main/kotlin/io/element/android/features/roomcall/api/RoomCallStateProvider.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomcall.api + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class RoomCallStateProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + aStandByCallState(), + aStandByCallState(canStartCall = false), + anOngoingCallState(), + anOngoingCallState(canJoinCall = false), + anOngoingCallState(canJoinCall = true, isUserInTheCall = true), + RoomCallState.Unavailable, + ) +} + +fun anOngoingCallState( + canJoinCall: Boolean = true, + isUserInTheCall: Boolean = false, + isUserLocallyInTheCall: Boolean = isUserInTheCall, +) = RoomCallState.OnGoing( + canJoinCall = canJoinCall, + isUserInTheCall = isUserInTheCall, + isUserLocallyInTheCall = isUserLocallyInTheCall, +) + +fun aStandByCallState( + canStartCall: Boolean = true, +) = RoomCallState.StandBy( + canStartCall = canStartCall, +) diff --git a/features/roomcall/impl/build.gradle.kts b/features/roomcall/impl/build.gradle.kts new file mode 100644 index 0000000..069c890 --- /dev/null +++ b/features/roomcall/impl/build.gradle.kts @@ -0,0 +1,35 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.roomcall.impl" +} + +setupDependencyInjection() + +dependencies { + api(projects.features.roomcall.api) + implementation(libs.kotlinx.collections.immutable) + implementation(projects.features.call.api) + implementation(projects.features.enterprise.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.features.call.test) + testImplementation(projects.features.enterprise.test) +} diff --git a/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt b/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt new file mode 100644 index 0000000..5de47f9 --- /dev/null +++ b/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomcall.impl + +import androidx.compose.runtime.Composable +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 dev.zacsweers.metro.Inject +import io.element.android.features.call.api.CurrentCall +import io.element.android.features.call.api.CurrentCallService +import io.element.android.features.enterprise.api.SessionEnterpriseService +import io.element.android.features.roomcall.api.RoomCallState +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.ui.room.canCall + +@Inject +class RoomCallStatePresenter( + private val room: JoinedRoom, + private val currentCallService: CurrentCallService, + private val sessionEnterpriseService: SessionEnterpriseService, +) : Presenter { + @Composable + override fun present(): RoomCallState { + val isAvailable by produceState(false) { + value = sessionEnterpriseService.isElementCallAvailable() + } + val roomInfo by room.roomInfoFlow.collectAsState() + val syncUpdateFlow = room.syncUpdateFlow.collectAsState() + val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value) + val isUserInTheCall by remember { + derivedStateOf { + room.sessionId in roomInfo.activeRoomCallParticipants + } + } + val currentCall by currentCallService.currentCall.collectAsState() + val isUserLocallyInTheCall by remember { + derivedStateOf { + (currentCall as? CurrentCall.RoomCall)?.roomId == room.roomId + } + } + val callState by remember { + derivedStateOf { + when { + isAvailable.not() -> RoomCallState.Unavailable + roomInfo.hasRoomCall -> RoomCallState.OnGoing( + canJoinCall = canJoinCall, + isUserInTheCall = isUserInTheCall, + isUserLocallyInTheCall = isUserLocallyInTheCall, + ) + else -> RoomCallState.StandBy(canStartCall = canJoinCall) + } + } + } + return callState + } +} diff --git a/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/di/RoomCallModule.kt b/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/di/RoomCallModule.kt new file mode 100644 index 0000000..fc649dd --- /dev/null +++ b/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/di/RoomCallModule.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomcall.impl.di + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.roomcall.api.RoomCallState +import io.element.android.features.roomcall.impl.RoomCallStatePresenter +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.RoomScope + +@ContributesTo(RoomScope::class) +@BindingContainer +interface RoomCallModule { + @Binds + fun bindRoomCallStatePresenter(presenter: RoomCallStatePresenter): Presenter +} diff --git a/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt b/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt new file mode 100644 index 0000000..1aceee2 --- /dev/null +++ b/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomcall.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.call.api.CurrentCall +import io.element.android.features.call.api.CurrentCallService +import io.element.android.features.call.test.FakeCurrentCallService +import io.element.android.features.enterprise.test.FakeSessionEnterpriseService +import io.element.android.features.roomcall.api.RoomCallState +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.tests.testutils.test +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RoomCallStatePresenterTest { + @Test + fun `present - initial state`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserJoinCallResult = { Result.success(false) }, + ) + ) + val presenter = createRoomCallStatePresenter(joinedRoom = room) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState).isEqualTo( + RoomCallState.StandBy( + canStartCall = false, + ) + ) + } + } + + @Test + fun `present - element call not available`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserJoinCallResult = { Result.success(false) }, + ) + ) + val presenter = createRoomCallStatePresenter( + joinedRoom = room, + isElementCallAvailable = false, + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo( + RoomCallState.Unavailable + ) + } + } + + @Test + fun `present - initial state - user can join call`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserJoinCallResult = { Result.success(true) }, + ) + ) + val presenter = createRoomCallStatePresenter(joinedRoom = room) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState).isEqualTo( + RoomCallState.StandBy( + canStartCall = true, + ) + ) + } + } + + @Test + fun `present - call is disabled if user cannot join it even if there is an ongoing call`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserJoinCallResult = { Result.success(false) }, + initialRoomInfo = aRoomInfo(hasRoomCall = true), + ) + ) + val presenter = createRoomCallStatePresenter(joinedRoom = room) + presenter.test { + skipItems(1) + assertThat(awaitItem()).isEqualTo( + RoomCallState.OnGoing( + canJoinCall = false, + isUserInTheCall = false, + isUserLocallyInTheCall = false, + ) + ) + } + } + + @Test + fun `present - user has joined the call on another session`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserJoinCallResult = { Result.success(true) }, + ).apply { + givenRoomInfo( + aRoomInfo( + hasRoomCall = true, + activeRoomCallParticipants = listOf(sessionId), + ) + ) + } + ) + val presenter = createRoomCallStatePresenter(joinedRoom = room) + presenter.test { + skipItems(1) + assertThat(awaitItem()).isEqualTo( + RoomCallState.OnGoing( + canJoinCall = true, + isUserInTheCall = true, + isUserLocallyInTheCall = false, + ) + ) + } + } + + @Test + fun `present - user has joined the call locally`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserJoinCallResult = { Result.success(true) }, + ).apply { + givenRoomInfo( + aRoomInfo( + hasRoomCall = true, + activeRoomCallParticipants = listOf(sessionId), + ) + ) + } + ) + val presenter = createRoomCallStatePresenter( + joinedRoom = room, + currentCallService = FakeCurrentCallService(MutableStateFlow(CurrentCall.RoomCall(room.roomId))), + ) + presenter.test { + skipItems(1) + assertThat(awaitItem()).isEqualTo( + RoomCallState.OnGoing( + canJoinCall = true, + isUserInTheCall = true, + isUserLocallyInTheCall = true, + ) + ) + } + } + + @Test + fun `present - user leaves the call`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserJoinCallResult = { Result.success(true) }, + ).apply { + givenRoomInfo( + aRoomInfo( + hasRoomCall = true, + activeRoomCallParticipants = listOf(sessionId), + ) + ) + } + ) + val currentCall = MutableStateFlow(CurrentCall.RoomCall(room.roomId)) + val currentCallService = FakeCurrentCallService(currentCall = currentCall) + val presenter = createRoomCallStatePresenter( + joinedRoom = room, + currentCallService = currentCallService + ) + presenter.test { + skipItems(1) + assertThat(awaitItem()).isEqualTo( + RoomCallState.OnGoing( + canJoinCall = true, + isUserInTheCall = true, + isUserLocallyInTheCall = true, + ) + ) + currentCall.value = CurrentCall.None + assertThat(awaitItem()).isEqualTo( + RoomCallState.OnGoing( + canJoinCall = true, + isUserInTheCall = true, + isUserLocallyInTheCall = false, + ) + ) + room.givenRoomInfo( + aRoomInfo( + hasRoomCall = true, + activeRoomCallParticipants = emptyList(), + ) + ) + assertThat(awaitItem()).isEqualTo( + RoomCallState.OnGoing( + canJoinCall = true, + isUserInTheCall = false, + isUserLocallyInTheCall = false, + ) + ) + room.givenRoomInfo( + aRoomInfo( + hasRoomCall = false, + activeRoomCallParticipants = emptyList(), + ) + ) + assertThat(awaitItem()).isEqualTo( + RoomCallState.StandBy( + canStartCall = true, + ) + ) + } + } + + private fun createRoomCallStatePresenter( + joinedRoom: JoinedRoom, + currentCallService: CurrentCallService = FakeCurrentCallService(), + isElementCallAvailable: Boolean = true, + ): RoomCallStatePresenter { + return RoomCallStatePresenter( + room = joinedRoom, + currentCallService = currentCallService, + sessionEnterpriseService = FakeSessionEnterpriseService( + isElementCallAvailableResult = { isElementCallAvailable }, + ), + ) + } +} diff --git a/features/roomdetails/api/.gitignore b/features/roomdetails/api/.gitignore new file mode 100644 index 0000000..42afabf --- /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 0000000..ce39384 --- /dev/null +++ b/features/roomdetails/api/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..928c083 --- /dev/null +++ b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.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.permalink.PermalinkData +import kotlinx.parcelize.Parcelize + +interface RoomDetailsEntryPoint : FeatureEntryPoint { + sealed interface InitialTarget : Parcelable { + @Parcelize + data object RoomDetails : InitialTarget + + @Parcelize + data object RoomMemberList : InitialTarget + + @Parcelize + data class RoomMemberDetails(val roomMemberId: UserId) : InitialTarget + + @Parcelize + data object RoomNotificationSettings : InitialTarget + } + + data class Params(val initialElement: InitialTarget) : NodeInputs + + interface Callback : Plugin { + fun navigateToGlobalNotificationSettings() + fun navigateToRoom(roomId: RoomId, serverNames: List) + fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) + fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean) + } + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + callback: Callback, + ): Node +} diff --git a/features/roomdetails/impl/.gitignore b/features/roomdetails/impl/.gitignore new file mode 100644 index 0000000..42afabf --- /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 0000000..4ca260b --- /dev/null +++ b/features/roomdetails/impl/build.gradle.kts @@ -0,0 +1,83 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.roomdetails.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.appconfig) + 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) + implementation(projects.libraries.mediaviewer.api) + implementation(projects.libraries.featureflag.api) + implementation(projects.libraries.permissions.api) + implementation(projects.libraries.preferences.api) + implementation(projects.libraries.testtags) + api(projects.features.roomdetails.api) + api(projects.libraries.usersearch.api) + api(projects.services.apperror.api) + implementation(libs.coil.compose) + implementation(projects.features.call.api) + implementation(projects.features.startchat.api) + implementation(projects.features.leaveroom.api) + implementation(projects.features.userprofile.shared) + implementation(projects.services.analytics.compose) + implementation(projects.features.poll.api) + implementation(projects.features.messages.api) + implementation(projects.features.roomcall.api) + implementation(projects.features.knockrequests.api) + implementation(projects.features.verifysession.api) + implementation(projects.features.reportroom.api) + implementation(projects.features.roommembermoderation.api) + implementation(projects.features.rolesandpermissions.api) + implementation(projects.features.securityandprivacy.api) + implementation(projects.features.invitepeople.api) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.mediaupload.test) + testImplementation(projects.libraries.mediapickers.test) + testImplementation(projects.libraries.mediaviewer.test) + testImplementation(projects.libraries.permissions.test) + testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.usersearch.test) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.features.call.test) + testImplementation(projects.features.rolesandpermissions.test) + testImplementation(projects.features.securityandprivacy.test) + testImplementation(projects.features.knockrequests.test) + testImplementation(projects.features.messages.test) + testImplementation(projects.features.poll.test) + testImplementation(projects.features.reportroom.test) + testImplementation(projects.features.startchat.test) + testImplementation(projects.features.verifysession.test) + testImplementation(projects.services.analytics.test) +} diff --git a/features/roomdetails/impl/consumer-rules.pro b/features/roomdetails/impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 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 0000000..7b2269c --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.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 + +@ContributesBinding(AppScope::class) +class DefaultRoomDetailsEntryPoint : RoomDetailsEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: RoomDetailsEntryPoint.Params, + callback: RoomDetailsEntryPoint.Callback, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf(params, callback) + ) + } +} + +internal fun InitialTarget.toNavTarget() = when (this) { + is InitialTarget.RoomDetails -> NavTarget.RoomDetails + is InitialTarget.RoomMemberDetails -> NavTarget.RoomMemberDetails(roomMemberId) + is InitialTarget.RoomNotificationSettings -> NavTarget.RoomNotificationSettings(showUserDefinedSettingStyle = true) + InitialTarget.RoomMemberList -> NavTarget.RoomMemberList +} 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 0000000..262eb37 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsAction.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl + +sealed interface RoomDetailsAction { + data object Edit : RoomDetailsAction + data 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 0000000..de801e8 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl + +sealed interface RoomDetailsEvent { + data class LeaveRoom(val needsConfirmation: Boolean) : RoomDetailsEvent + data object MuteNotification : RoomDetailsEvent + data object UnmuteNotification : RoomDetailsEvent + data class CopyToClipboard(val text: String) : RoomDetailsEvent + data class SetFavorite(val isFavorite: Boolean) : 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 0000000..c8ef605 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt @@ -0,0 +1,438 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +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.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.Interaction +import io.element.android.annotations.ContributesNode +import io.element.android.appconfig.LearnMoreConfig +import io.element.android.features.call.api.CallType +import io.element.android.features.call.api.ElementCallEntryPoint +import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint +import io.element.android.features.messages.api.MessagesEntryPoint +import io.element.android.features.poll.api.history.PollHistoryEntryPoint +import io.element.android.features.reportroom.api.ReportRoomEntryPoint +import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint +import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType +import io.element.android.features.rolesandpermissions.api.RolesAndPermissionsEntryPoint +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.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsNode +import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint +import io.element.android.features.userprofile.shared.UserProfileNodeHelper +import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint +import io.element.android.libraries.architecture.BackstackWithOverlayBox +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.architecture.overlay.operation.hide +import io.element.android.libraries.architecture.overlay.operation.show +import io.element.android.libraries.designsystem.utils.OpenUrlInTabView +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.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.verification.VerificationRequest +import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint +import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize + +@ContributesNode(RoomScope::class) +@AssistedInject +class RoomDetailsFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val pollHistoryEntryPoint: PollHistoryEntryPoint, + private val elementCallEntryPoint: ElementCallEntryPoint, + private val room: JoinedRoom, + private val analyticsService: AnalyticsService, + private val messagesEntryPoint: MessagesEntryPoint, + private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint, + private val mediaViewerEntryPoint: MediaViewerEntryPoint, + private val mediaGalleryEntryPoint: MediaGalleryEntryPoint, + private val outgoingVerificationEntryPoint: OutgoingVerificationEntryPoint, + private val reportRoomEntryPoint: ReportRoomEntryPoint, + private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint, + private val rolesAndPermissionsEntryPoint: RolesAndPermissionsEntryPoint, + private val securityAndPrivacyEntryPoint: SecurityAndPrivacyEntryPoint, +) : BaseFlowNode( + backstack = BackStack( + initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(), + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + sealed interface NavTarget : Parcelable { + @Parcelize + data object RoomDetails : NavTarget + + @Parcelize + data object RoomMemberList : NavTarget + + @Parcelize + data object RoomDetailsEdit : NavTarget + + @Parcelize + data object InviteMembers : NavTarget + + @Parcelize + data class RoomNotificationSettings( + /** + * When presented from outside the context of the room, the rooms settings UI is different. + * Figma designs: https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?type=design&node-id=5199-198932&mode=design&t=fTTvpuxYFjewYQOe-0 + */ + val showUserDefinedSettingStyle: Boolean + ) : NavTarget + + @Parcelize + data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget + + @Parcelize + data class AvatarPreview(val name: String, val avatarUrl: String) : NavTarget + + @Parcelize + data object PollHistory : NavTarget + + @Parcelize + data object MediaGallery : NavTarget + + @Parcelize + data object AdminSettings : NavTarget + + @Parcelize + data object PinnedMessagesList : NavTarget + + @Parcelize + data object KnockRequestsList : NavTarget + + @Parcelize + data object SecurityAndPrivacy : NavTarget + + @Parcelize + data class VerifyUser(val userId: UserId) : NavTarget + + @Parcelize + data object ReportRoom : NavTarget + + @Parcelize + data object SelectNewOwnersWhenLeaving : NavTarget + } + + private val callback: RoomDetailsEntryPoint.Callback = callback() + + override fun onBuilt() { + super.onBuilt() + whenChildrenAttached { + commonLifecycle: Lifecycle, + roomDetailsNode: RoomDetailsNode, + changeRoomMemberRolesNode: ChangeRoomMemberRolesEntryPoint.NodeProxy, + -> + commonLifecycle.coroutineScope.launch { + val isNewOwnerSelected = changeRoomMemberRolesNode.waitForCompletion() + withContext(NonCancellable) { + backstack.pop() + if (isNewOwnerSelected) { + roomDetailsNode.onNewOwnersSelected() + } + } + } + } + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.RoomDetails -> { + val roomDetailsCallback = object : RoomDetailsNode.Callback { + override fun navigateToRoomMemberList() { + backstack.push(NavTarget.RoomMemberList) + } + + override fun navigateToRoomDetailsEdit() { + backstack.push(NavTarget.RoomDetailsEdit) + } + + override fun navigateToInviteMembers() { + backstack.push(NavTarget.InviteMembers) + } + + override fun navigateToRoomNotificationSettings() { + backstack.push(NavTarget.RoomNotificationSettings(showUserDefinedSettingStyle = false)) + } + + override fun navigateToAvatarPreview(name: String, url: String) { + overlay.show(NavTarget.AvatarPreview(name, url)) + } + + override fun navigateToPollHistory() { + backstack.push(NavTarget.PollHistory) + } + + override fun navigateToMediaGallery() { + backstack.push(NavTarget.MediaGallery) + } + + override fun navigateToAdminSettings() { + backstack.push(NavTarget.AdminSettings) + } + + override fun navigateToPinnedMessagesList() { + backstack.push(NavTarget.PinnedMessagesList) + } + + override fun navigateToKnockRequestsList() { + backstack.push(NavTarget.KnockRequestsList) + } + + override fun navigateToSecurityAndPrivacy() { + backstack.push(NavTarget.SecurityAndPrivacy) + } + + override fun navigateToRoomMemberDetails(userId: UserId) { + backstack.push(NavTarget.RoomMemberDetails(userId)) + } + + override fun navigateToRoomCall() { + val inputs = CallType.RoomCall( + sessionId = room.sessionId, + roomId = room.roomId, + ) + analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton) + elementCallEntryPoint.startCall(inputs) + } + + override fun navigateToReportRoom() { + backstack.push(NavTarget.ReportRoom) + } + + override fun navigateToSelectNewOwnersWhenLeaving() { + backstack.push(NavTarget.SelectNewOwnersWhenLeaving) + } + } + createNode(buildContext, listOf(roomDetailsCallback)) + } + + NavTarget.RoomMemberList -> { + val roomMemberListCallback = object : RoomMemberListNode.Callback { + override fun navigateToRoomMemberDetails(roomMemberId: UserId) { + backstack.push(NavTarget.RoomMemberDetails(roomMemberId)) + } + + override fun navigateToInviteMembers() { + backstack.push(NavTarget.InviteMembers) + } + } + createNode(buildContext, listOf(roomMemberListCallback)) + } + + NavTarget.RoomDetailsEdit -> { + createNode(buildContext) + } + + NavTarget.InviteMembers -> { + createNode(buildContext) + } + + is NavTarget.RoomNotificationSettings -> { + val input = RoomNotificationSettingsNode.RoomNotificationSettingInput(navTarget.showUserDefinedSettingStyle) + val callback = object : RoomNotificationSettingsNode.Callback { + override fun navigateToGlobalNotificationSettings() { + callback.navigateToGlobalNotificationSettings() + } + } + createNode(buildContext, listOf(input, callback)) + } + + is NavTarget.RoomMemberDetails -> { + val callback = object : UserProfileNodeHelper.Callback { + override fun navigateToAvatarPreview(username: String, avatarUrl: String) { + overlay.show(NavTarget.AvatarPreview(username, avatarUrl)) + } + + override fun navigateToRoom(roomId: RoomId) { + callback.navigateToRoom(roomId, emptyList()) + } + + override fun startCall(dmRoomId: RoomId) { + elementCallEntryPoint.startCall(CallType.RoomCall(roomId = dmRoomId, sessionId = room.sessionId)) + } + + override fun startVerifyUserFlow(userId: UserId) { + backstack.push(NavTarget.VerifyUser(userId)) + } + } + val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId), callback) + createNode(buildContext, plugins) + } + is NavTarget.AvatarPreview -> { + val callback = object : MediaViewerEntryPoint.Callback { + override fun onDone() { + overlay.hide() + } + + override fun viewInTimeline(eventId: EventId) { + // Cannot happen + } + + override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) { + // Cannot happen + } + } + val params = mediaViewerEntryPoint.createParamsForAvatar( + filename = navTarget.name, + avatarUrl = navTarget.avatarUrl, + ) + mediaViewerEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = params, + callback = callback, + ) + } + is NavTarget.PollHistory -> { + pollHistoryEntryPoint.createNode(this, buildContext) + } + is NavTarget.MediaGallery -> { + val callback = object : MediaGalleryEntryPoint.Callback { + override fun onBackClick() { + backstack.pop() + } + + override fun viewInTimeline(eventId: EventId) { + val permalinkData = PermalinkData.RoomLink( + roomIdOrAlias = room.roomId.toRoomIdOrAlias(), + eventId = eventId, + ) + callback.handlePermalinkClick(permalinkData, pushToBackstack = false) + } + + override fun forward(eventId: EventId, fromPinnedEvents: Boolean) { + callback.startForwardEventFlow(eventId, fromPinnedEvents) + } + } + mediaGalleryEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + callback = callback, + ) + } + + is NavTarget.AdminSettings -> { + rolesAndPermissionsEntryPoint.createNode(this, buildContext) + } + NavTarget.PinnedMessagesList -> { + val params = MessagesEntryPoint.Params( + MessagesEntryPoint.InitialTarget.PinnedMessages + ) + val callback = object : MessagesEntryPoint.Callback { + override fun navigateToRoomDetails() = Unit + + override fun navigateToRoomMemberDetails(userId: UserId) = Unit + + override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) { + callback.handlePermalinkClick(data, pushToBackstack) + } + + override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) { + callback.startForwardEventFlow(eventId, fromPinnedEvents) + } + + override fun navigateToRoom(roomId: RoomId) { + callback.navigateToRoom(roomId, emptyList()) + } + } + return messagesEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = params, + callback = callback, + ) + } + NavTarget.KnockRequestsList -> { + knockRequestsListEntryPoint.createNode(this, buildContext) + } + NavTarget.SecurityAndPrivacy -> { + securityAndPrivacyEntryPoint.createNode(this, buildContext) + } + is NavTarget.VerifyUser -> { + val params = OutgoingVerificationEntryPoint.Params( + showDeviceVerifiedScreen = true, + verificationRequest = VerificationRequest.Outgoing.User(userId = navTarget.userId) + ) + outgoingVerificationEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = params, + callback = object : OutgoingVerificationEntryPoint.Callback { + override fun onDone() { + backstack.pop() + } + + override fun onBack() { + backstack.pop() + } + + override fun navigateToLearnMoreAboutEncryption() { + learnMoreUrl.value = LearnMoreConfig.ENCRYPTION_URL + } + }, + ) + } + is NavTarget.ReportRoom -> { + reportRoomEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + roomId = room.roomId, + ) + } + + is NavTarget.SelectNewOwnersWhenLeaving -> { + changeRoomMemberRolesEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + room = room, + listType = ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving, + ) + } + } + } + + private val learnMoreUrl = mutableStateOf(null) + + @Composable + override fun View(modifier: Modifier) { + BackstackWithOverlayBox(modifier) + + OpenUrlInTabView(learnMoreUrl) + } +} 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 0000000..d7b4e0d --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl + +import android.content.Context +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.lifecycle.lifecycleScope +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.annotations.ContributesNode +import io.element.android.features.leaveroom.api.LeaveRoomRenderer +import io.element.android.libraries.androidutils.system.startSharePlainTextIntent +import io.element.android.libraries.architecture.appyx.launchMolecule +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber +import io.element.android.libraries.androidutils.R as AndroidUtilsR + +@ContributesNode(RoomScope::class) +@AssistedInject +class RoomDetailsNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: RoomDetailsPresenter, + private val room: BaseRoom, + private val analyticsService: AnalyticsService, + private val leaveRoomRenderer: LeaveRoomRenderer, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun navigateToRoomMemberList() + fun navigateToInviteMembers() + fun navigateToRoomDetailsEdit() + fun navigateToRoomNotificationSettings() + fun navigateToAvatarPreview(name: String, url: String) + fun navigateToPollHistory() + fun navigateToMediaGallery() + fun navigateToAdminSettings() + fun navigateToPinnedMessagesList() + fun navigateToKnockRequestsList() + fun navigateToSecurityAndPrivacy() + fun navigateToRoomMemberDetails(userId: UserId) + fun navigateToRoomCall() + fun navigateToReportRoom() + fun navigateToSelectNewOwnersWhenLeaving() + } + + private val callback: Callback = callback() + + init { + lifecycle.subscribe( + onResume = { + analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.RoomDetails)) + } + ) + } + + private fun CoroutineScope.onShareRoom(context: Context) = launch { + room.getPermalink() + .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 val stateFlow = launchMolecule { presenter.present() } + + fun onNewOwnersSelected() { + stateFlow.value.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) + } + + @Composable + override fun View(modifier: Modifier) { + val context = LocalContext.current + val state by stateFlow.collectAsState() + + fun onShareRoom() { + lifecycleScope.onShareRoom(context) + } + + fun onActionClick(action: RoomDetailsAction) { + when (action) { + RoomDetailsAction.Edit -> { + callback.navigateToRoomDetailsEdit() + } + RoomDetailsAction.AddTopic -> { + callback.navigateToRoomDetailsEdit() + } + } + } + + RoomDetailsView( + state = state, + modifier = modifier, + goBack = ::navigateUp, + onActionClick = ::onActionClick, + onShareRoom = ::onShareRoom, + openRoomMemberList = callback::navigateToRoomMemberList, + openRoomNotificationSettings = callback::navigateToRoomNotificationSettings, + invitePeople = callback::navigateToInviteMembers, + openAvatarPreview = callback::navigateToAvatarPreview, + openPollHistory = callback::navigateToPollHistory, + openMediaGallery = callback::navigateToMediaGallery, + openAdminSettings = callback::navigateToAdminSettings, + onJoinCallClick = callback::navigateToRoomCall, + onPinnedMessagesClick = callback::navigateToPinnedMessagesList, + onKnockRequestsClick = callback::navigateToKnockRequestsList, + onSecurityAndPrivacyClick = callback::navigateToSecurityAndPrivacy, + onProfileClick = callback::navigateToRoomMemberDetails, + onReportRoomClick = callback::navigateToReportRoom, + leaveRoomView = { + leaveRoomRenderer.Render( + state = state.leaveRoomState, + onSelectNewOwners = { callback.navigateToSelectNewOwnersWhenLeaving() }, + modifier = Modifier + ) + } + ) + } +} 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 0000000..95c4f1e --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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 androidx.compose.runtime.rememberCoroutineScope +import dev.zacsweers.metro.Inject +import im.vector.app.features.analytics.plan.Interaction +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.features.roomcall.api.RoomCallState +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter +import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissionsAsState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.api.room.join.JoinRule +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.api.room.roomNotificationSettings +import io.element.android.libraries.matrix.ui.room.canHandleKnockRequestsAsState +import io.element.android.libraries.matrix.ui.room.getCurrentRoomMember +import io.element.android.libraries.matrix.ui.room.getDirectRoomMember +import io.element.android.libraries.matrix.ui.room.isDmAsState +import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin +import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +@Inject +class RoomDetailsPresenter( + private val client: MatrixClient, + private val room: JoinedRoom, + private val featureFlagService: FeatureFlagService, + private val notificationSettingsService: NotificationSettingsService, + private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory, + private val leaveRoomPresenter: Presenter, + private val roomCallStatePresenter: Presenter, + private val dispatchers: CoroutineDispatchers, + private val analyticsService: AnalyticsService, + private val clipboardHelper: ClipboardHelper, + private val appPreferencesStore: AppPreferencesStore, +) : Presenter { + @Composable + override fun present(): RoomDetailsState { + val scope = rememberCoroutineScope() + val leaveRoomState = leaveRoomPresenter.present() + val roomInfo by room.roomInfoFlow.collectAsState() + val isUserAdmin = room.isOwnUserAdmin() + val syncUpdateFlow = room.syncUpdateFlow.collectAsState() + val roomAvatar by remember { derivedStateOf { roomInfo.avatarUrl } } + + val roomName by remember { derivedStateOf { roomInfo.name?.trim().orEmpty() } } + val roomTopic by remember { derivedStateOf { roomInfo.topic } } + val isFavorite by remember { derivedStateOf { roomInfo.isFavorite } } + val joinRule by remember { derivedStateOf { roomInfo.joinRule } } + + val pinnedMessagesCount by remember { derivedStateOf { roomInfo.pinnedEventIds.size } } + + LaunchedEffect(Unit) { + room.updateRoomNotificationSettings() + observeNotificationSettings() + } + + val membersState by room.membersStateFlow.collectAsState() + val canInvite by getCanInvite(membersState) + + val canonicalAlias by remember { derivedStateOf { roomInfo.canonicalAlias } } + val isEncrypted by remember { derivedStateOf { roomInfo.isEncrypted == true } } + val isDm by room.isDmAsState() + 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 currentMember by room.getCurrentRoomMember(membersState) + val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember) + val roomType = getRoomType(dmMember, currentMember) + val roomCallState = roomCallStatePresenter.present() + val joinedMemberCount by remember { derivedStateOf { roomInfo.joinedMembersCount } } + + val topicState = remember(canEditTopic, roomTopic, roomType) { + val topic = roomTopic + when { + !topic.isNullOrBlank() -> RoomTopicState.ExistingTopic(topic) + canEditTopic && roomType is RoomDetailsType.Room -> RoomTopicState.CanAddTopic + else -> RoomTopicState.Hidden + } + } + + val canHandleKnockRequests by room.canHandleKnockRequestsAsState(syncUpdateFlow.value) + val isKnockRequestsEnabled by remember { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock) + }.collectAsState(false) + val knockRequestsCount by produceState(null) { + room.knockRequestsFlow.collect { value = it.size } + } + val canShowKnockRequests by remember { + derivedStateOf { isKnockRequestsEnabled && canHandleKnockRequests && joinRule == JoinRule.Knock } + } + val isDeveloperModeEnabled by remember { + appPreferencesStore.isDeveloperModeEnabledFlow() + }.collectAsState(initial = false) + + val roomNotificationSettingsState by room.roomNotificationSettingsStateFlow.collectAsState() + + val snackbarDispatcher = LocalSnackbarDispatcher.current + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + + fun handleEvent(event: RoomDetailsEvent) { + when (event) { + is RoomDetailsEvent.LeaveRoom -> { + leaveRoomState.eventSink(LeaveRoomEvent.LeaveRoom(room.roomId, needsConfirmation = event.needsConfirmation)) + } + RoomDetailsEvent.MuteNotification -> { + scope.launch(dispatchers.io) { + notificationSettingsService.muteRoom(room.roomId) + } + } + RoomDetailsEvent.UnmuteNotification -> { + scope.launch(dispatchers.io) { + notificationSettingsService.unmuteRoom(room.roomId, isEncrypted, room.isOneToOne) + } + } + is RoomDetailsEvent.SetFavorite -> scope.setFavorite(event.isFavorite) + is RoomDetailsEvent.CopyToClipboard -> { + clipboardHelper.copyPlainText(event.text) + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + } + } + } + + val roomMemberDetailsState = roomMemberDetailsPresenter?.present() + + val securityAndPrivacyPermissions = room.securityAndPrivacyPermissionsAsState(syncUpdateFlow.value) + val canShowSecurityAndPrivacy by remember { + derivedStateOf { + roomType is RoomDetailsType.Room && securityAndPrivacyPermissions.value.hasAny + } + } + + val hasMemberVerificationViolations by produceState(false) { + room.roomMemberIdentityStateChange(waitForEncryption = true) + .onEach { identities -> value = identities.any { it.identityState == IdentityState.VerificationViolation } } + .launchIn(this) + } + + val canReportRoom by produceState(false) { value = client.canReportRoom() } + + return RoomDetailsState( + roomId = room.roomId, + roomName = roomName, + roomAlias = canonicalAlias, + roomAvatarUrl = roomAvatar, + roomTopic = topicState, + memberCount = joinedMemberCount, + isEncrypted = isEncrypted, + canInvite = canInvite, + canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room, + roomCallState = roomCallState, + roomType = roomType, + roomMemberDetailsState = roomMemberDetailsState, + leaveRoomState = leaveRoomState, + roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(), + isFavorite = isFavorite, + displayRolesAndPermissionsSettings = !isDm && isUserAdmin, + isPublic = joinRule == JoinRule.Public, + heroes = roomInfo.heroes.toImmutableList(), + pinnedMessagesCount = pinnedMessagesCount, + snackbarMessage = snackbarMessage, + canShowKnockRequests = canShowKnockRequests, + knockRequestsCount = knockRequestsCount, + canShowSecurityAndPrivacy = canShowSecurityAndPrivacy, + hasMemberVerificationViolations = hasMemberVerificationViolations, + canReportRoom = canReportRoom, + isTombstoned = roomInfo.successorRoom != null, + showDebugInfo = isDeveloperModeEnabled, + roomVersion = roomInfo.roomVersion, + eventSink = ::handleEvent, + ) + } + + @Composable + private fun roomMemberDetailsPresenter(dmMemberState: RoomMember?) = remember(dmMemberState) { + dmMemberState?.let { roomMember -> + roomMembersDetailsPresenterFactory.create(roomMember.userId) + } + } + + @Composable + private fun getRoomType( + dmMember: RoomMember?, + currentMember: RoomMember?, + ): RoomDetailsType = remember(dmMember, currentMember) { + if (dmMember != null && currentMember != null) { + RoomDetailsType.Dm( + me = currentMember, + otherMember = dmMember, + ) + } else { + RoomDetailsType.Room + } + } + + @Composable + private fun getCanInvite(membersState: RoomMembersState) = produceState(false, membersState) { + value = room.canInvite().getOrElse { false } + } + + @Composable + private fun getCanSendState(membersState: RoomMembersState, type: StateEventType) = produceState(false, membersState) { + value = room.canSendState(type).getOrElse { false } + } + + private fun CoroutineScope.observeNotificationSettings() { + notificationSettingsService.notificationSettingsChangeFlow.onEach { + room.updateRoomNotificationSettings() + }.launchIn(this) + } + + private fun CoroutineScope.setFavorite(isFavorite: Boolean) = launch { + room.setIsFavorite(isFavorite) + .onSuccess { + analyticsService.captureInteraction(Interaction.Name.MobileRoomFavouriteToggle) + } + } +} 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 0000000..2332776 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl + +import androidx.compose.runtime.Immutable +import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.features.roomcall.api.RoomCallState +import io.element.android.features.userprofile.api.UserProfileState +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.matrix.api.core.RoomAlias +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.RoomNotificationSettings +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +data class RoomDetailsState( + val roomId: RoomId, + val roomName: String, + val roomAlias: RoomAlias?, + val roomAvatarUrl: String?, + val roomTopic: RoomTopicState, + val memberCount: Long, + val isEncrypted: Boolean, + val roomType: RoomDetailsType, + val roomMemberDetailsState: UserProfileState?, + val canEdit: Boolean, + val canInvite: Boolean, + val roomCallState: RoomCallState, + val leaveRoomState: LeaveRoomState, + val roomNotificationSettings: RoomNotificationSettings?, + val isFavorite: Boolean, + val displayRolesAndPermissionsSettings: Boolean, + val isPublic: Boolean, + val heroes: ImmutableList, + val pinnedMessagesCount: Int?, + val snackbarMessage: SnackbarMessage?, + val canShowKnockRequests: Boolean, + val knockRequestsCount: Int?, + val canShowSecurityAndPrivacy: Boolean, + val hasMemberVerificationViolations: Boolean, + val canReportRoom: Boolean, + val isTombstoned: Boolean, + val showDebugInfo: Boolean, + val roomVersion: String?, + val eventSink: (RoomDetailsEvent) -> Unit +) { + val roomBadges = buildList { + if (isEncrypted) { + add(RoomBadge.ENCRYPTED) + } else { + add(RoomBadge.NOT_ENCRYPTED) + } + if (isPublic) { + add(RoomBadge.PUBLIC) + } + }.toImmutableList() +} + +@Immutable +sealed interface RoomDetailsType { + data object Room : RoomDetailsType + data class Dm( + val me: RoomMember, + val otherMember: RoomMember, + ) : RoomDetailsType +} + +@Immutable +sealed interface RoomTopicState { + data object Hidden : RoomTopicState + data object CanAddTopic : RoomTopicState + data class ExistingTopic(val topic: String) : RoomTopicState +} + +enum class RoomBadge { + ENCRYPTED, + NOT_ENCRYPTED, + PUBLIC, +} 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 0000000..783fcfa --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.features.roomcall.api.RoomCallState +import io.element.android.features.roomcall.api.aStandByCallState +import io.element.android.features.roomdetails.impl.members.aRoomMember +import io.element.android.features.userprofile.api.UserProfileState +import io.element.android.features.userprofile.api.UserProfileVerificationState +import io.element.android.features.userprofile.shared.aUserProfileState +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.matrix.api.core.RoomAlias +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.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.RoomNotificationSettings +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import kotlinx.collections.immutable.toImmutableList + +open class RoomDetailsStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aRoomDetailsState(displayAdminSettings = true), + aRoomDetailsState(roomTopic = RoomTopicState.Hidden, showDebugInfo = true), + aRoomDetailsState(roomTopic = RoomTopicState.CanAddTopic), + aRoomDetailsState(isEncrypted = false), + aRoomDetailsState(roomAlias = null), + aDmRoomDetailsState(), + aDmRoomDetailsState(isDmMemberIgnored = true, roomName = "Daniel (ignored and clear)", isEncrypted = false), + aRoomDetailsState(canInvite = true), + aRoomDetailsState(isFavorite = true), + aRoomDetailsState( + canEdit = true, + // Also test the roomNotificationSettings ALL_MESSAGES in the same screenshot. Icon 'Mute' should be displayed + roomNotificationSettings = aRoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES, isDefault = true) + ), + aRoomDetailsState(roomCallState = aStandByCallState(false), canInvite = false), + aRoomDetailsState(isPublic = false), + aRoomDetailsState(heroes = aMatrixUserList()), + aRoomDetailsState(pinnedMessagesCount = 3), + aRoomDetailsState(knockRequestsCount = null, canShowKnockRequests = true), + aRoomDetailsState(knockRequestsCount = 4, canShowKnockRequests = true), + aRoomDetailsState(hasMemberVerificationViolations = true), + aRoomDetailsState(isTombstoned = true), + aDmRoomDetailsState(dmRoomMemberVerificationState = UserProfileVerificationState.VERIFIED), + aDmRoomDetailsState(dmRoomMemberVerificationState = UserProfileVerificationState.VERIFICATION_VIOLATION), + // 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, + isIgnored: Boolean = false, + role: RoomMember.Role = RoomMember.Role.User, + membershipChangeReason: String? = null, +) = RoomMember( + userId = userId, + displayName = displayName, + avatarUrl = avatarUrl, + membership = membership, + isNameAmbiguous = isNameAmbiguous, + powerLevel = powerLevel, + isIgnored = isIgnored, + role = role, + membershipChangeReason = membershipChangeReason +) + +fun aRoomDetailsState( + roomId: RoomId = RoomId("!aRoomId:domain.com"), + roomName: String = "Marketing", + roomAlias: RoomAlias? = RoomAlias("#marketing:domain.com"), + roomAvatarUrl: String? = null, + roomTopic: RoomTopicState = 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: Long = 32, + isEncrypted: Boolean = true, + canInvite: Boolean = false, + canEdit: Boolean = false, + roomCallState: RoomCallState = aStandByCallState(), + roomType: RoomDetailsType = RoomDetailsType.Room, + roomMemberDetailsState: UserProfileState? = null, + leaveRoomState: LeaveRoomState = aLeaveRoomState(), + roomNotificationSettings: RoomNotificationSettings = aRoomNotificationSettings(), + isFavorite: Boolean = false, + displayAdminSettings: Boolean = false, + isPublic: Boolean = true, + heroes: List = emptyList(), + pinnedMessagesCount: Int? = null, + snackbarMessage: SnackbarMessage? = null, + canShowKnockRequests: Boolean = false, + knockRequestsCount: Int? = null, + canShowSecurityAndPrivacy: Boolean = true, + hasMemberVerificationViolations: Boolean = false, + canReportRoom: Boolean = true, + isTombstoned: Boolean = false, + showDebugInfo: Boolean = false, + eventSink: (RoomDetailsEvent) -> Unit = {}, +) = RoomDetailsState( + roomId = roomId, + roomName = roomName, + roomAlias = roomAlias, + roomAvatarUrl = roomAvatarUrl, + roomTopic = roomTopic, + memberCount = memberCount, + isEncrypted = isEncrypted, + canInvite = canInvite, + canEdit = canEdit, + roomCallState = roomCallState, + roomType = roomType, + roomMemberDetailsState = roomMemberDetailsState, + leaveRoomState = leaveRoomState, + roomNotificationSettings = roomNotificationSettings, + isFavorite = isFavorite, + displayRolesAndPermissionsSettings = displayAdminSettings, + isPublic = isPublic, + heroes = heroes.toImmutableList(), + pinnedMessagesCount = pinnedMessagesCount, + snackbarMessage = snackbarMessage, + canShowKnockRequests = canShowKnockRequests, + knockRequestsCount = knockRequestsCount, + canShowSecurityAndPrivacy = canShowSecurityAndPrivacy, + hasMemberVerificationViolations = hasMemberVerificationViolations, + canReportRoom = canReportRoom, + isTombstoned = isTombstoned, + showDebugInfo = showDebugInfo, + roomVersion = "12", + eventSink = eventSink, +) + +internal fun aLeaveRoomState( + eventSink: (LeaveRoomEvent) -> Unit = {} +) = object : LeaveRoomState { + override val eventSink: (LeaveRoomEvent) -> Unit = eventSink +} + +fun aRoomNotificationSettings( + mode: RoomNotificationMode = RoomNotificationMode.MUTE, + isDefault: Boolean = false, +) = RoomNotificationSettings( + mode = mode, + isDefault = isDefault +) + +fun aDmRoomDetailsState( + isDmMemberIgnored: Boolean = false, + roomName: String = "Daniel", + isEncrypted: Boolean = true, + dmRoomMemberVerificationState: UserProfileVerificationState = UserProfileVerificationState.UNKNOWN, +) = aRoomDetailsState( + roomName = roomName, + isPublic = false, + isEncrypted = isEncrypted, + roomType = RoomDetailsType.Dm( + me = aRoomMember(), + otherMember = aDmRoomMember(isIgnored = isDmMemberIgnored), + ), + roomMemberDetailsState = aUserProfileState( + isBlocked = AsyncData.Success(isDmMemberIgnored), + verificationState = dmRoomMemberVerificationState, + ) +) 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 0000000..de0a2cb --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -0,0 +1,793 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl + +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.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.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.platform.LocalContext +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 im.vector.app.features.analytics.plan.Interaction +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.roomcall.api.hasPermissionToJoin +import io.element.android.features.userprofile.api.UserProfileVerificationState +import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs +import io.element.android.features.userprofile.shared.blockuser.BlockUserSection +import io.element.android.libraries.androidutils.system.copyToClipboard +import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage +import io.element.android.libraries.designsystem.atomic.atoms.MatrixBadgeAtom +import io.element.android.libraries.designsystem.atomic.molecules.MatrixBadgeRowMolecule +import io.element.android.libraries.designsystem.components.ClickableLinkText +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.avatar.AvatarType +import io.element.android.libraries.designsystem.components.avatar.DmAvatars +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.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch +import io.element.android.libraries.designsystem.modifiers.niceClickable +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +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.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +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.snackbar.SnackbarHost +import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState +import io.element.android.libraries.matrix.api.core.RoomAlias +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.RoomMember +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.getBestName +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.ui.strings.CommonStrings +import io.element.android.services.analytics.compose.LocalAnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun RoomDetailsView( + state: RoomDetailsState, + goBack: () -> Unit, + onActionClick: (RoomDetailsAction) -> Unit, + onShareRoom: () -> Unit, + openRoomMemberList: () -> Unit, + openRoomNotificationSettings: () -> Unit, + invitePeople: () -> Unit, + openAvatarPreview: (name: String, url: String) -> Unit, + openPollHistory: () -> Unit, + openMediaGallery: () -> Unit, + openAdminSettings: () -> Unit, + onJoinCallClick: () -> Unit, + onPinnedMessagesClick: () -> Unit, + onKnockRequestsClick: () -> Unit, + onSecurityAndPrivacyClick: () -> Unit, + onProfileClick: (UserId) -> Unit, + onReportRoomClick: () -> Unit, + modifier: Modifier = Modifier, + leaveRoomView: @Composable () -> Unit, +) { + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) + Scaffold( + modifier = modifier, + topBar = { + RoomDetailsTopBar( + goBack = goBack, + showEdit = state.canEdit, + onActionClick = onActionClick + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .consumeWindowInsets(padding) + ) { + leaveRoomView() + + when (state.roomType) { + RoomDetailsType.Room -> { + RoomHeaderSection( + avatarUrl = state.roomAvatarUrl, + roomId = state.roomId, + roomName = state.roomName, + roomAlias = state.roomAlias, + heroes = state.heroes, + isTombstoned = state.isTombstoned, + openAvatarPreview = { avatarUrl -> + openAvatarPreview(state.roomName, avatarUrl) + }, + onSubtitleClick = { subtitle -> + state.eventSink(RoomDetailsEvent.CopyToClipboard(subtitle)) + } + ) + } + is RoomDetailsType.Dm -> { + DmHeaderSection( + me = state.roomType.me, + otherMember = state.roomType.otherMember, + roomName = state.roomName, + openAvatarPreview = { name, avatarUrl -> + openAvatarPreview(name, avatarUrl) + }, + onSubtitleClick = { subtitle -> + state.eventSink(RoomDetailsEvent.CopyToClipboard(subtitle)) + } + ) + } + } + BadgeList( + roomBadge = state.roomBadges, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Spacer(Modifier.height(32.dp)) + MainActionsSection( + state = state, + onShareRoom = onShareRoom, + onInvitePeople = invitePeople, + onCall = onJoinCallClick, + ) + Spacer(Modifier.height(12.dp)) + + if (state.roomTopic !is RoomTopicState.Hidden) { + TopicSection( + roomTopic = state.roomTopic, + onActionClick = onActionClick, + ) + } + + PreferenceCategory { + if (state.roomNotificationSettings != null) { + NotificationItem( + isDefaultMode = state.roomNotificationSettings.isDefault, + openRoomNotificationSettings = openRoomNotificationSettings + ) + } + + FavoriteItem( + isFavorite = state.isFavorite, + onFavoriteChanges = { + state.eventSink(RoomDetailsEvent.SetFavorite(it)) + } + ) + + if (state.canShowSecurityAndPrivacy) { + SecurityAndPrivacyItem( + onClick = onSecurityAndPrivacyClick + ) + } + + state.roomMemberDetailsState?.let { dmMemberDetails -> + ProfileItem( + verificationState = dmMemberDetails.verificationState, + onClick = { onProfileClick(dmMemberDetails.userId) } + ) + } + } + + if (state.roomType is RoomDetailsType.Room) { + PreferenceCategory { + MembersItem( + memberCount = state.memberCount, + hasVerificationViolations = state.hasMemberVerificationViolations, + openRoomMemberList = openRoomMemberList, + ) + if (state.canShowKnockRequests) { + KnockRequestsItem( + knockRequestsCount = state.knockRequestsCount, + onKnockRequestsClick = onKnockRequestsClick + ) + } + if (state.displayRolesAndPermissionsSettings) { + ListItem( + headlineContent = { Text(stringResource(R.string.screen_room_details_roles_and_permissions)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())), + onClick = openAdminSettings, + ) + } + } + } + + PreferenceCategory { + PinnedMessagesItem( + pinnedMessagesCount = state.pinnedMessagesCount, + onPinnedMessagesClick = onPinnedMessagesClick + ) + PollsItem( + openPollHistory = openPollHistory + ) + MediaGalleryItem( + onClick = openMediaGallery + ) + } + + if (state.roomType is RoomDetailsType.Dm && state.roomMemberDetailsState != null) { + val roomMemberState = state.roomMemberDetailsState + BlockUserSection(roomMemberState) + BlockUserDialogs(roomMemberState) + } + + OtherActionsSection( + canReportRoom = state.canReportRoom, + onReportRoomClick = onReportRoomClick, + onLeaveRoomClick = { state.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) } + ) + + if (state.showDebugInfo) { + DebugInfoSection( + roomId = state.roomId, + roomVersion = state.roomVersion, + ) + } + } + } +} + +@Composable +private fun KnockRequestsItem(knockRequestsCount: Int?, onKnockRequestsClick: () -> Unit) { + ListItem( + headlineContent = { Text(stringResource(R.string.screen_room_details_requests_to_join_title)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.AskToJoin())), + trailingContent = if (knockRequestsCount == null || knockRequestsCount == 0) { + null + } else { + ListItemContent.Counter(knockRequestsCount) + }, + onClick = onKnockRequestsClick, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RoomDetailsTopBar( + goBack: () -> Unit, + onActionClick: (RoomDetailsAction) -> Unit, + showEdit: Boolean, +) { + var showMenu by remember { mutableStateOf(false) } + + TopAppBar( + title = { }, + navigationIcon = { BackButton(onClick = goBack) }, + actions = { + if (showEdit) { + IconButton(onClick = { showMenu = !showMenu }) { + Icon(CompoundIcons.OverflowVertical(), stringResource(id = CommonStrings.a11y_user_menu)) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { Text(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 + onActionClick(RoomDetailsAction.Edit) + }, + ) + } + } + }, + ) +} + +@Composable +private fun MainActionsSection( + state: RoomDetailsState, + onShareRoom: () -> Unit, + onInvitePeople: () -> Unit, + onCall: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + state.roomNotificationSettings?.let { roomNotificationSettings -> + if (roomNotificationSettings.mode == RoomNotificationMode.MUTE) { + MainActionButton( + title = stringResource(CommonStrings.common_unmute), + imageVector = CompoundIcons.NotificationsOff(), + onClick = { + state.eventSink(RoomDetailsEvent.UnmuteNotification) + }, + ) + } else { + MainActionButton( + title = stringResource(CommonStrings.common_mute), + imageVector = CompoundIcons.Notifications(), + onClick = { + state.eventSink(RoomDetailsEvent.MuteNotification) + }, + ) + } + } + if (state.roomCallState.hasPermissionToJoin()) { + // TODO Improve the view depending on all the cases here? + MainActionButton( + title = stringResource(CommonStrings.action_call), + imageVector = CompoundIcons.VideoCall(), + onClick = onCall, + ) + } + if (state.roomType is RoomDetailsType.Room) { + if (state.canInvite) { + MainActionButton( + title = stringResource(CommonStrings.action_invite), + imageVector = CompoundIcons.UserAdd(), + onClick = onInvitePeople, + ) + } + // Share CTA should be hidden for DMs + MainActionButton( + title = stringResource(CommonStrings.action_share), + imageVector = CompoundIcons.ShareAndroid(), + onClick = onShareRoom + ) + } + } +} + +@Composable +private fun RoomHeaderSection( + avatarUrl: String?, + roomId: RoomId, + roomName: String, + roomAlias: RoomAlias?, + heroes: ImmutableList, + isTombstoned: Boolean, + openAvatarPreview: (url: String) -> Unit, + onSubtitleClick: (String) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Avatar( + avatarData = AvatarData(roomId.value, roomName, avatarUrl, AvatarSize.RoomDetailsHeader), + avatarType = AvatarType.Room( + heroes = heroes.map { user -> + user.getAvatarData(size = AvatarSize.RoomDetailsHeader) + }.toImmutableList(), + isTombstoned = isTombstoned, + ), + contentDescription = avatarUrl?.let { stringResource(CommonStrings.a11y_room_avatar) }, + modifier = Modifier + .clickable( + enabled = avatarUrl != null, + onClickLabel = stringResource(CommonStrings.action_view), + ) { + openAvatarPreview(avatarUrl!!) + } + .testTag(TestTags.roomDetailAvatar) + ) + TitleAndSubtitle( + title = roomName, + subtitle = roomAlias?.value, + onSubtitleClick = onSubtitleClick, + ) + } +} + +@Composable +private fun DmHeaderSection( + me: RoomMember, + otherMember: RoomMember, + roomName: String, + openAvatarPreview: (name: String, url: String) -> Unit, + onSubtitleClick: (String) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DmAvatars( + userAvatarData = me.getAvatarData(size = AvatarSize.DmCluster), + otherUserAvatarData = otherMember.getAvatarData(size = AvatarSize.DmCluster), + openAvatarPreview = { url -> openAvatarPreview(me.getBestName(), url) }, + openOtherAvatarPreview = { url -> openAvatarPreview(roomName, url) }, + ) + TitleAndSubtitle( + title = roomName, + subtitle = otherMember.userId.value, + onSubtitleClick = onSubtitleClick, + ) + } +} + +@Composable +private fun TitleAndSubtitle( + title: String, + subtitle: String?, + onSubtitleClick: (String) -> Unit, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = title, + style = ElementTheme.typography.fontHeadingLgBold, + textAlign = TextAlign.Center, + ) + if (subtitle != null) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + modifier = Modifier.niceClickable { onSubtitleClick(subtitle) }, + text = subtitle, + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +private fun BadgeList( + roomBadge: ImmutableList, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + if (roomBadge.isNotEmpty()) { + MatrixBadgeRowMolecule( + data = roomBadge.map { + it.toMatrixBadgeData() + }.toImmutableList(), + ) + } + } +} + +@Composable +private fun RoomBadge.toMatrixBadgeData(): MatrixBadgeAtom.MatrixBadgeData { + return when (this) { + RoomBadge.ENCRYPTED -> { + MatrixBadgeAtom.MatrixBadgeData( + text = stringResource(R.string.screen_room_details_badge_encrypted), + icon = CompoundIcons.LockSolid(), + type = MatrixBadgeAtom.Type.Positive, + ) + } + RoomBadge.NOT_ENCRYPTED -> { + MatrixBadgeAtom.MatrixBadgeData( + text = stringResource(R.string.screen_room_details_badge_not_encrypted), + icon = CompoundIcons.LockOff(), + type = MatrixBadgeAtom.Type.Info, + ) + } + RoomBadge.PUBLIC -> { + MatrixBadgeAtom.MatrixBadgeData( + text = stringResource(R.string.screen_room_details_badge_public), + icon = CompoundIcons.Public(), + type = MatrixBadgeAtom.Type.Info, + ) + } + } +} + +@Composable +private fun TopicSection( + roomTopic: RoomTopicState, + onActionClick: (RoomDetailsAction) -> Unit, +) { + PreferenceCategory( + title = stringResource(CommonStrings.common_topic), + showTopDivider = false, + ) { + if (roomTopic is RoomTopicState.CanAddTopic) { + ListItem( + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())), + headlineContent = { + Text(stringResource(id = R.string.screen_room_details_add_topic_title)) + }, + onClick = { + onActionClick(RoomDetailsAction.AddTopic) + }, + ) + } else if (roomTopic is RoomTopicState.ExistingTopic) { + ClickableLinkText( + text = roomTopic.topic, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp), + interactionSource = remember { MutableInteractionSource() }, + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.tertiary, + ), + ) + } + } +} + +@Composable +private fun NotificationItem( + isDefaultMode: Boolean, + openRoomNotificationSettings: () -> Unit, +) { + val subtitle = if (isDefaultMode) { + stringResource(R.string.screen_room_details_notification_mode_default) + } else { + stringResource(R.string.screen_room_details_notification_mode_custom) + } + ListItem( + headlineContent = { Text(text = stringResource(R.string.screen_room_details_notification_title)) }, + supportingContent = { Text(text = subtitle) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Notifications())), + onClick = openRoomNotificationSettings, + ) +} + +@Composable +private fun SecurityAndPrivacyItem( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { Text(stringResource(R.string.screen_room_details_security_and_privacy_title)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), + onClick = onClick, + modifier = modifier, + ) +} + +@Composable +private fun FavoriteItem( + isFavorite: Boolean, + onFavoriteChanges: (Boolean) -> Unit, +) { + PreferenceSwitch( + icon = CompoundIcons.Favourite(), + title = stringResource(id = CommonStrings.common_favourite), + isChecked = isFavorite, + onCheckedChange = onFavoriteChanges + ) +} + +@Composable +private fun ProfileItem( + verificationState: UserProfileVerificationState, + onClick: () -> Unit, +) { + ListItem( + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())), + headlineContent = { Text(stringResource(id = R.string.screen_room_details_profile_row_title)) }, + trailingContent = when (verificationState) { + UserProfileVerificationState.VERIFIED -> ListItemContent.Icon( + iconSource = IconSource.Vector(CompoundIcons.Verified()), + tintColor = ElementTheme.colors.iconSuccessPrimary, + ) + UserProfileVerificationState.VERIFICATION_VIOLATION -> ListItemContent.Icon( + iconSource = IconSource.Vector(CompoundIcons.ErrorSolid()), + tintColor = ElementTheme.colors.iconCriticalPrimary, + ) + else -> null + }, + onClick = onClick, + ) +} + +@Composable +private fun MembersItem( + memberCount: Long, + hasVerificationViolations: Boolean, + openRoomMemberList: () -> Unit, +) { + ListItem( + headlineContent = { Text(stringResource(CommonStrings.common_people)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.User())), + trailingContent = if (hasVerificationViolations) { + ListItemContent.Icon( + iconSource = IconSource.Vector(CompoundIcons.ErrorSolid()), + tintColor = ElementTheme.colors.textCriticalPrimary, + ) + } else { + ListItemContent.Text(memberCount.toString()) + }, + onClick = openRoomMemberList, + ) +} + +@Composable +private fun PinnedMessagesItem( + pinnedMessagesCount: Int?, + onPinnedMessagesClick: () -> Unit, +) { + val analyticsService = LocalAnalyticsService.current + ListItem( + headlineContent = { Text(stringResource(R.string.screen_room_details_pinned_events_row_title)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Pin())), + trailingContent = + if (pinnedMessagesCount == null) { + ListItemContent.Custom { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp)) + } + } else { + ListItemContent.Text(pinnedMessagesCount.toString()) + }, + onClick = { + analyticsService.captureInteraction(Interaction.Name.PinnedMessageRoomInfoButton) + onPinnedMessagesClick() + } + ) +} + +@Composable +private fun PollsItem( + openPollHistory: () -> Unit, +) { + ListItem( + headlineContent = { Text(stringResource(R.string.screen_polls_history_title)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Polls())), + onClick = openPollHistory, + ) +} + +@Composable +private fun MediaGalleryItem( + onClick: () -> Unit, +) { + ListItem( + headlineContent = { Text(stringResource(R.string.screen_room_details_media_gallery_title)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())), + onClick = onClick, + ) +} + +@Composable +private fun OtherActionsSection( + canReportRoom: Boolean, + onReportRoomClick: () -> Unit, + onLeaveRoomClick: () -> Unit, +) { + PreferenceCategory(showTopDivider = true) { + if (canReportRoom) { + ListItem( + headlineContent = { + Text(stringResource(CommonStrings.action_report_room)) + }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())), + style = ListItemStyle.Destructive, + onClick = onReportRoomClick, + ) + } + ListItem( + headlineContent = { + Text(stringResource(CommonStrings.action_leave_room)) + }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Leave())), + style = ListItemStyle.Destructive, + onClick = onLeaveRoomClick, + ) + } +} + +@Composable +private fun DebugInfoSection( + roomId: RoomId, + roomVersion: String?, +) { + val context = LocalContext.current + PreferenceCategory(showTopDivider = true) { + ListItem( + headlineContent = { + Text("Internal room ID") + }, + supportingContent = { + Text( + text = roomId.value, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Code())), + trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Copy())), + onClick = { + context.copyToClipboard( + roomId.value, + context.getString(CommonStrings.common_copied_to_clipboard) + ) + }, + ) + ListItem( + headlineContent = { + Text("Room version") + }, + supportingContent = { + Text( + text = roomVersion ?: "Unknown", + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())), + ) + } +} + +@PreviewWithLargeHeight +@Composable +internal fun RoomDetailsPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) = + ElementPreviewLight { ContentToPreview(state) } + +@PreviewWithLargeHeight +@Composable +internal fun RoomDetailsDarkPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) = + ElementPreviewDark { ContentToPreview(state) } + +@ExcludeFromCoverage +@Composable +private fun ContentToPreview(state: RoomDetailsState) { + RoomDetailsView( + state = state, + goBack = {}, + onActionClick = {}, + onShareRoom = {}, + openRoomMemberList = {}, + openRoomNotificationSettings = {}, + invitePeople = {}, + openAvatarPreview = { _, _ -> }, + openPollHistory = {}, + openMediaGallery = {}, + openAdminSettings = {}, + onJoinCallClick = {}, + onPinnedMessagesClick = {}, + onKnockRequestsClick = {}, + onSecurityAndPrivacyClick = {}, + onProfileClick = {}, + onReportRoomClick = {}, + leaveRoomView = {}, + ) +} 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 0000000..479f23f --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.di + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter +import io.element.android.features.userprofile.api.UserProfilePresenterFactory +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.room.JoinedRoom + +@BindingContainer +@ContributesTo(RoomScope::class) +object RoomMemberModule { + @Provides + fun provideRoomMemberDetailsPresenterFactory( + room: JoinedRoom, + userProfilePresenterFactory: UserProfilePresenterFactory, + encryptionService: EncryptionService, + clipboardHelper: ClipboardHelper, + ): RoomMemberDetailsPresenter.Factory { + return object : RoomMemberDetailsPresenter.Factory { + override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter { + return RoomMemberDetailsPresenter( + roomMemberId = roomMemberId, + room = room, + userProfilePresenterFactory = userProfilePresenterFactory, + encryptionService = encryptionService, + clipboardHelper = clipboardHelper, + ) + } + } + } +} 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 0000000..2606d6b --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditEvents.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 + data object OnBackPress : RoomDetailsEditEvents + data object Save : RoomDetailsEditEvents + data object CloseDialog : 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 0000000..dc2ebe8 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditNode.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.di.RoomScope +import io.element.android.services.analytics.api.AnalyticsService + +@ContributesNode(RoomScope::class) +@AssistedInject +class RoomDetailsEditNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + 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, + onDone = ::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 0000000..542b15a --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Inject +import io.element.android.libraries.androidutils.file.TemporaryUriDeleter +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.room.JoinedRoom +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.matrix.ui.room.avatarUrl +import io.element.android.libraries.matrix.ui.room.rawName +import io.element.android.libraries.matrix.ui.room.topic +import io.element.android.libraries.mediapickers.api.PickerProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsPresenter +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +@Inject +class RoomDetailsEditPresenter( + private val room: JoinedRoom, + private val mediaPickerProvider: PickerProvider, + private val mediaPreProcessor: MediaPreProcessor, + private val temporaryUriDeleter: TemporaryUriDeleter, + permissionsPresenterFactory: PermissionsPresenter.Factory, + private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, +) : Presenter { + private val cameraPermissionPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA) + private var pendingPermissionRequest = false + + @Composable + override fun present(): RoomDetailsEditState { + val cameraPermissionState = cameraPermissionPresenter.present() + val roomSyncUpdateFlow = room.syncUpdateFlow.collectAsState() + + val roomAvatarUri = room.avatarUrl() + var roomAvatarUriEdited by rememberSaveable { mutableStateOf(null) } + LaunchedEffect(roomAvatarUri) { + // Every time the roomAvatar change (from sync), we can set the new avatar. + temporaryUriDeleter.delete(roomAvatarUriEdited?.toUri()) + roomAvatarUriEdited = roomAvatarUri + } + + val roomRawNameTrimmed = room.rawName().orEmpty().trim() + var roomRawNameEdited by rememberSaveable { mutableStateOf("") } + LaunchedEffect(roomRawNameTrimmed) { + // Every time the rawName change (from sync), we can set the new name. + roomRawNameEdited = roomRawNameTrimmed + } + val roomTopicTrimmed = room.topic().orEmpty().trim() + var roomTopicEdited by rememberSaveable { mutableStateOf("") } + LaunchedEffect(roomTopicTrimmed) { + // Every time the topic change (from sync), we can set the new topic. + roomTopicEdited = roomTopicTrimmed + } + + val saveButtonEnabled by remember( + roomRawNameTrimmed, + roomTopicTrimmed, + roomAvatarUri, + ) { + derivedStateOf { + roomRawNameTrimmed != roomRawNameEdited.trim() || + roomTopicTrimmed != roomTopicEdited.trim() || + roomAvatarUri != roomAvatarUriEdited + } + } + + var canChangeName by remember { mutableStateOf(false) } + var canChangeTopic by remember { mutableStateOf(false) } + var canChangeAvatar by remember { mutableStateOf(false) } + + LaunchedEffect(roomSyncUpdateFlow.value) { + 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) { + temporaryUriDeleter.delete(roomAvatarUriEdited?.toUri()) + roomAvatarUriEdited = uri.toString() + } + } + ) + val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker( + onResult = { uri -> + if (uri != null) { + temporaryUriDeleter.delete(roomAvatarUriEdited?.toUri()) + roomAvatarUriEdited = uri.toString() + } + } + ) + + LaunchedEffect(cameraPermissionState.permissionGranted) { + if (cameraPermissionState.permissionGranted && pendingPermissionRequest) { + pendingPermissionRequest = false + cameraPhotoPicker.launch() + } + } + + val avatarActions by remember(roomAvatarUriEdited) { + derivedStateOf { + listOfNotNull( + AvatarAction.TakePhoto, + AvatarAction.ChoosePhoto, + AvatarAction.Remove.takeIf { roomAvatarUriEdited != null }, + ).toImmutableList() + } + } + + val saveAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + val localCoroutineScope = rememberCoroutineScope() + fun handleEvent(event: RoomDetailsEditEvents) { + when (event) { + is RoomDetailsEditEvents.Save -> localCoroutineScope.saveChanges( + currentNameTrimmed = roomRawNameTrimmed, + newNameTrimmed = roomRawNameEdited.trim(), + currentTopicTrimmed = roomTopicTrimmed, + newTopicTrimmed = roomTopicEdited.trim(), + currentAvatar = roomAvatarUri?.toUri(), + newAvatarUri = roomAvatarUriEdited?.toUri(), + action = saveAction, + ) + is RoomDetailsEditEvents.HandleAvatarAction -> { + when (event.action) { + AvatarAction.ChoosePhoto -> galleryImagePicker.launch() + AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) { + cameraPhotoPicker.launch() + } else { + pendingPermissionRequest = true + cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) + } + AvatarAction.Remove -> { + temporaryUriDeleter.delete(roomAvatarUriEdited?.toUri()) + roomAvatarUriEdited = null + } + } + } + + is RoomDetailsEditEvents.UpdateRoomName -> roomRawNameEdited = event.name + is RoomDetailsEditEvents.UpdateRoomTopic -> roomTopicEdited = event.topic + RoomDetailsEditEvents.CloseDialog -> saveAction.value = AsyncAction.Uninitialized + RoomDetailsEditEvents.OnBackPress -> if (saveButtonEnabled.not() || saveAction.value == AsyncAction.ConfirmingCancellation) { + // No changes to save or already confirming exit without saving + saveAction.value = AsyncAction.Success(Unit) + } else { + saveAction.value = AsyncAction.ConfirmingCancellation + } + } + } + + return RoomDetailsEditState( + roomId = room.roomId, + roomRawName = roomRawNameEdited, + canChangeName = canChangeName, + roomTopic = roomTopicEdited, + canChangeTopic = canChangeTopic, + roomAvatarUrl = roomAvatarUriEdited, + canChangeAvatar = canChangeAvatar, + avatarActions = avatarActions, + saveButtonEnabled = saveButtonEnabled, + saveAction = saveAction.value, + cameraPermissionState = cameraPermissionState, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.saveChanges( + currentNameTrimmed: String, + newNameTrimmed: String, + currentTopicTrimmed: String, + newTopicTrimmed: String, + currentAvatar: Uri?, + newAvatarUri: Uri?, + action: MutableState>, + ) = launch { + val results = mutableListOf>() + suspend { + if (newTopicTrimmed != currentTopicTrimmed) { + results.add(room.setTopic(newTopicTrimmed).onFailure { + Timber.e(it, "Failed to set room topic") + }) + } + if (newNameTrimmed.isNotEmpty() && newNameTrimmed != currentNameTrimmed) { + results.add(room.setName(newNameTrimmed).onFailure { + Timber.e(it, "Failed to set room name") + }) + } + if (newAvatarUri != currentAvatar) { + results.add(updateAvatar(newAvatarUri).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 { + return runCatchingExceptions { + if (avatarUri != null) { + val preprocessed = mediaPreProcessor.process( + uri = avatarUri, + mimeType = MimeTypes.Jpeg, + deleteOriginal = false, + mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), + ).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 0000000..3c5e87a --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditState.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.edit + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.permissions.api.PermissionsState +import kotlinx.collections.immutable.ImmutableList + +data class RoomDetailsEditState( + val roomId: RoomId, + /** The raw room name (i.e. the room name from the state event `m.room.name`), not the display name. */ + val roomRawName: String, + val canChangeName: Boolean, + val roomTopic: String, + val canChangeTopic: Boolean, + val roomAvatarUrl: String?, + val canChangeAvatar: Boolean, + val avatarActions: ImmutableList, + val saveButtonEnabled: Boolean, + val saveAction: AsyncAction, + val cameraPermissionState: PermissionsState, + 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 0000000..33ed4a9 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditStateProvider.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.edit + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.permissions.api.PermissionsState +import io.element.android.libraries.permissions.api.aPermissionsState +import kotlinx.collections.immutable.toImmutableList + +open class RoomDetailsEditStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aRoomDetailsEditState(), + aRoomDetailsEditState(roomTopic = ""), + aRoomDetailsEditState(roomRawName = ""), + aRoomDetailsEditState(roomAvatarUrl = "example://uri"), + aRoomDetailsEditState(canChangeName = true, canChangeTopic = false, canChangeAvatar = true, saveButtonEnabled = false), + aRoomDetailsEditState(canChangeName = false, canChangeTopic = true, canChangeAvatar = false, saveButtonEnabled = false), + aRoomDetailsEditState(saveAction = AsyncAction.Loading), + aRoomDetailsEditState(saveAction = AsyncAction.Failure(RuntimeException("Whelp"))), + aRoomDetailsEditState(saveAction = AsyncAction.ConfirmingCancellation), + ) +} + +fun aRoomDetailsEditState( + roomId: RoomId = RoomId("!aRoomId:aDomain"), + roomRawName: String = "Marketing", + canChangeName: Boolean = true, + roomTopic: String = "a room topic that is quite long so should wrap onto multiple lines", + canChangeTopic: Boolean = true, + roomAvatarUrl: String? = null, + canChangeAvatar: Boolean = true, + avatarActions: List = emptyList(), + saveButtonEnabled: Boolean = true, + saveAction: AsyncAction = AsyncAction.Uninitialized, + cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false), + eventSink: (RoomDetailsEditEvents) -> Unit = {}, +) = RoomDetailsEditState( + roomId = roomId, + roomRawName = roomRawName, + canChangeName = canChangeName, + roomTopic = roomTopic, + canChangeTopic = canChangeTopic, + roomAvatarUrl = roomAvatarUrl, + canChangeAvatar = canChangeAvatar, + avatarActions = avatarActions.toImmutableList(), + saveButtonEnabled = saveButtonEnabled, + saveAction = saveAction, + cameraPermissionState = cameraPermissionState, + eventSink = 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 0000000..b8f7e00 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.features.roomdetails.impl.edit + +import androidx.activity.compose.BackHandler +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.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +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.AsyncAction +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.SaveChangesDialog +import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TextField +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.EditableAvatarView +import io.element.android.libraries.permissions.api.PermissionsView +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun RoomDetailsEditView( + state: RoomDetailsEditState, + onDone: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + val isAvatarActionsSheetVisible = remember { mutableStateOf(false) } + + fun onAvatarClick() { + focusManager.clearFocus() + isAvatarActionsSheetVisible.value = true + } + + BackHandler { + state.eventSink(RoomDetailsEditEvents.OnBackPress) + } + Scaffold( + modifier = modifier.clearFocusOnTap(focusManager), + topBar = { + TopAppBar( + titleStr = stringResource(id = R.string.screen_room_details_edit_room_title), + navigationIcon = { + BackButton( + onClick = { + state.eventSink(RoomDetailsEditEvents.OnBackPress) + } + ) + }, + actions = { + TextButton( + text = stringResource(CommonStrings.action_save), + enabled = state.saveButtonEnabled, + onClick = { + focusManager.clearFocus() + state.eventSink(RoomDetailsEditEvents.Save) + }, + ) + } + ) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .padding(horizontal = 16.dp) + .navigationBarsPadding() + .imePadding() + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(24.dp)) + EditableAvatarView( + matrixId = state.roomId.value, + // As per Element Web, we use the raw name for the avatar as well + displayName = state.roomRawName, + avatarUrl = state.roomAvatarUrl, + avatarSize = AvatarSize.EditRoomDetails, + avatarType = AvatarType.Room(), + onAvatarClick = ::onAvatarClick, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(60.dp)) + + TextField( + label = stringResource(id = R.string.screen_room_details_room_name_label), + value = state.roomRawName, + placeholder = stringResource(CommonStrings.common_room_name_placeholder), + singleLine = true, + readOnly = !state.canChangeName, + onValueChange = { state.eventSink(RoomDetailsEditEvents.UpdateRoomName(it)) }, + ) + + Spacer(modifier = Modifier.height(28.dp)) + + TextField( + label = stringResource(CommonStrings.common_topic), + value = state.roomTopic, + placeholder = stringResource(CommonStrings.common_topic_placeholder), + maxLines = 10, + readOnly = !state.canChangeTopic, + onValueChange = { state.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(it)) }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + } + } + AvatarActionBottomSheet( + actions = state.avatarActions, + isVisible = isAvatarActionsSheetVisible.value, + onDismiss = { isAvatarActionsSheetVisible.value = false }, + onSelectAction = { state.eventSink(RoomDetailsEditEvents.HandleAvatarAction(it)) } + ) + AsyncActionView( + async = state.saveAction, + progressDialog = { + AsyncActionViewDefaults.ProgressDialog( + progressText = stringResource(R.string.screen_room_details_updating_room), + ) + }, + confirmationDialog = { + if (state.saveAction == AsyncAction.ConfirmingCancellation) { + SaveChangesDialog( + onSubmitClick = { state.eventSink(RoomDetailsEditEvents.OnBackPress) }, + onDismiss = { state.eventSink(RoomDetailsEditEvents.CloseDialog) } + ) + } + }, + onSuccess = { onDone() }, + errorMessage = { stringResource(R.string.screen_room_details_edition_error) }, + onErrorDismiss = { state.eventSink(RoomDetailsEditEvents.CloseDialog) } + ) + + PermissionsView( + state = state.cameraPermissionState, + ) +} + +@PreviewsDayNight +@Composable +internal fun RoomDetailsEditViewPreview(@PreviewParameter(RoomDetailsEditStateProvider::class) state: RoomDetailsEditState) = ElementPreview { + RoomDetailsEditView( + state = state, + onDone = {}, + ) +} 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 0000000..ea0ed1b --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersNode.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.invite + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.annotations.ContributesNode +import io.element.android.features.invitepeople.api.InvitePeoplePresenter +import io.element.android.features.invitepeople.api.InvitePeopleRenderer +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.services.analytics.api.AnalyticsService + +@ContributesNode(RoomScope::class) +@AssistedInject +class RoomInviteMembersNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val analyticsService: AnalyticsService, + private val invitePeopleRenderer: InvitePeopleRenderer, + room: JoinedRoom, + invitePeoplePresenterFactory: InvitePeoplePresenter.Factory, +) : Node(buildContext, plugins = plugins) { + init { + lifecycle.subscribe( + onResume = { + analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.Invites)) + } + ) + } + + private val invitePeoplePresenter = invitePeoplePresenterFactory.create( + joinedRoom = room, + roomId = room.roomId, + ) + + @Composable + override fun View(modifier: Modifier) { + val state = invitePeoplePresenter.present() + + // Once invites have been sent successfully, close the Invite view. + LaunchedEffect(state.sendInvitesAction) { + if (state.sendInvitesAction.isReady()) { + navigateUp() + } + } + + RoomInviteMembersView( + state = state, + modifier = modifier, + onBackClick = { navigateUp() } + ) { + invitePeopleRenderer.Render(state, Modifier) + } + } +} 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 0000000..cb30542 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.invite + +import androidx.compose.foundation.layout.Box +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.padding +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.invitepeople.api.InvitePeopleEvents +import io.element.android.features.invitepeople.api.InvitePeopleState +import io.element.android.features.invitepeople.api.InvitePeopleStateProvider +import io.element.android.features.roomdetails.impl.R +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.ui.strings.CommonStrings + +@Composable +fun RoomInviteMembersView( + state: InvitePeopleState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + invitePeopleView: @Composable () -> Unit, +) { + Scaffold( + modifier = modifier, + topBar = { + RoomInviteMembersTopBar( + onBackClick = { + if (state.isSearchActive) { + state.eventSink(InvitePeopleEvents.CloseSearch) + } else { + onBackClick() + } + }, + onSubmitClick = { + state.eventSink(InvitePeopleEvents.SendInvites) + }, + canSend = state.canInvite, + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxWidth() + .padding(padding) + .consumeWindowInsets(padding), + ) { + invitePeopleView() + } + } + + if (state.sendInvitesAction.isLoading()) { + InviteProgressDialog() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RoomInviteMembersTopBar( + canSend: Boolean, + onBackClick: () -> Unit, + onSubmitClick: () -> Unit, +) { + TopAppBar( + titleStr = stringResource(R.string.screen_room_details_invite_people_title), + navigationIcon = { BackButton(onClick = onBackClick) }, + actions = { + TextButton( + text = stringResource(CommonStrings.action_invite), + onClick = onSubmitClick, + enabled = canSend, + ) + } + ) +} + +@Composable +private fun InviteProgressDialog() { + ProgressDialog { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.screen_room_details_invite_people_preparing), + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontHeadingSmMedium, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.screen_room_details_invite_people_dont_close), + color = ElementTheme.colors.textSecondary, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun RoomInviteMembersViewPreview(@PreviewParameter(InvitePeopleStateProvider::class) state: InvitePeopleState) = ElementPreview { + RoomInviteMembersView( + state = state, + invitePeopleView = {}, + onBackClick = {}, + ) +} 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 0000000..ec1b130 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListEvents.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.members + +import io.element.android.libraries.matrix.api.room.RoomMember + +sealed interface RoomMemberListEvents { + data class ChangeSelectedSection(val section: SelectedSection) : RoomMemberListEvents + data class UpdateSearchQuery(val query: String) : RoomMemberListEvents + data class RoomMemberSelected(val roomMember: RoomMember) : 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 0000000..750b111 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.members + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.annotations.ContributesNode +import io.element.android.features.roommembermoderation.api.ModerationAction +import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents +import io.element.android.features.roommembermoderation.api.RoomMemberModerationRenderer +import io.element.android.libraries.architecture.appyx.launchMolecule +import io.element.android.libraries.architecture.callback +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) +@AssistedInject +class RoomMemberListNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: RoomMemberListPresenter, + private val analyticsService: AnalyticsService, + private val roomMemberModerationRenderer: RoomMemberModerationRenderer, +) : Node(buildContext, plugins = plugins), RoomMemberListNavigator { + interface Callback : Plugin { + fun navigateToRoomMemberDetails(roomMemberId: UserId) + fun navigateToInviteMembers() + } + + private val callback: Callback = callback() + private val stateFlow = launchMolecule { presenter.present() } + + init { + lifecycle.subscribe( + onResume = { + analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.RoomMembers)) + } + ) + } + + override fun openRoomMemberDetails(roomMemberId: UserId) { + callback.navigateToRoomMemberDetails(roomMemberId) + } + + override fun openInviteMembers() { + callback.navigateToInviteMembers() + } + + override fun exitRoomMemberList() { + navigateUp() + } + + @Composable + override fun View(modifier: Modifier) { + val state by stateFlow.collectAsState() + RoomMemberListView( + state = state, + modifier = modifier, + navigator = this, + ) + roomMemberModerationRenderer.Render( + state = state.moderationState, + onSelectAction = { action, target -> + when (action) { + is ModerationAction.DisplayProfile -> openRoomMemberDetails(target.userId) + else -> state.moderationState.eventSink(RoomMemberModerationEvents.ProcessAction(action, target)) + } + }, + modifier = Modifier, + ) + } +} + +interface RoomMemberListNavigator { + fun exitRoomMemberList() {} + fun openRoomMemberDetails(roomMemberId: UserId) {} + fun 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 0000000..f1d3f61 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.members + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +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 dev.zacsweers.metro.Inject +import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents.ShowActionsForUser +import io.element.android.features.roommembermoderation.api.RoomMemberModerationState +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.map +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.room.roomMembers +import io.element.android.libraries.matrix.api.room.toMatrixUser +import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator +import io.element.android.libraries.matrix.ui.room.canInviteAsState +import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext + +@Inject +class RoomMemberListPresenter( + private val room: JoinedRoom, + private val coroutineDispatchers: CoroutineDispatchers, + private val roomMembersModerationPresenter: Presenter, + private val encryptionService: EncryptionService, +) : Presenter { + private val powerLevelRoomMemberComparator = PowerLevelRoomMemberComparator() + + @Composable + override fun present(): RoomMemberListState { + var searchQuery by rememberSaveable { mutableStateOf("") } + val membersState by room.membersStateFlow.collectAsState() + val syncUpdateFlow = room.syncUpdateFlow.collectAsState() + val canInvite by room.canInviteAsState(syncUpdateFlow.value) + val roomModerationState = roomMembersModerationPresenter.present() + + val roomMemberIdentityStates by produceState(persistentMapOf()) { + room.roomMemberIdentityStateChange(waitForEncryption = true) + .onEach { identities -> + value = identities.associateBy({ it.identityRoomMember.userId }, { it.identityState }).toImmutableMap() + } + .launchIn(this) + } + + var selectedSection by remember { mutableStateOf(SelectedSection.MEMBERS) } + var roomMembers: AsyncData by remember { mutableStateOf(AsyncData.Loading()) } + var filteredRoomMembers: AsyncData by remember { mutableStateOf(AsyncData.Loading()) } + + // Update the room members when the screen is loaded + LaunchedEffect(Unit) { + room.updateMembers() + } + + LaunchedEffect(membersState, roomMemberIdentityStates) { + if (membersState is RoomMembersState.Unknown) { + return@LaunchedEffect + } + val finalMembersState = membersState + if (finalMembersState is RoomMembersState.Error && finalMembersState.roomMembers().orEmpty().isEmpty()) { + // Cannot fetch members and no cached members, display the error + roomMembers = AsyncData.Failure(finalMembersState.failure) + return@LaunchedEffect + } + withContext(coroutineDispatchers.io) { + val members = membersState.roomMembers().orEmpty().groupBy { it.membership } + val info = room.info() + if (members.getOrDefault(RoomMembershipState.JOIN, emptyList()).size < info.joinedMembersCount / 2) { + // Don't display initial room member list if we have less than half of the joined members: + // This result will come from the timeline loading membership events and it'll be wrong. + return@withContext + } + val result = RoomMembers( + invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList()) + .map { it.withIdentityState(roomMemberIdentityStates) } + .toImmutableList(), + joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList()) + .sortedWith(powerLevelRoomMemberComparator) + .map { it.withIdentityState(roomMemberIdentityStates) } + .toImmutableList(), + banned = members.getOrDefault(RoomMembershipState.BAN, emptyList()) + .sortedBy { it.userId.value } + .map { it.withIdentityState(roomMemberIdentityStates) } + .toImmutableList(), + ) + roomMembers = if (membersState is RoomMembersState.Pending) { + AsyncData.Loading(result) + } else { + AsyncData.Success(result) + } + } + } + + LaunchedEffect(searchQuery, roomMembers) { + filteredRoomMembers = roomMembers.map { members -> + withContext(coroutineDispatchers.io) { + members.filter(searchQuery) + } + } + } + + fun handleEvent(event: RoomMemberListEvents) { + when (event) { + is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query + is RoomMemberListEvents.RoomMemberSelected -> + roomModerationState.eventSink(ShowActionsForUser(event.roomMember.toMatrixUser())) + is RoomMemberListEvents.ChangeSelectedSection -> selectedSection = event.section + } + } + + val state = RoomMemberListState( + roomMembers = roomMembers, + filteredRoomMembers = filteredRoomMembers, + searchQuery = searchQuery, + canInvite = canInvite, + moderationState = roomModerationState, + selectedSection = selectedSection, + eventSink = ::handleEvent, + ) + if (!state.showBannedSection && selectedSection == SelectedSection.BANNED) { + SideEffect { + selectedSection = SelectedSection.MEMBERS + } + } + return state + } + + private suspend fun RoomMember.withIdentityState(identityStates: ImmutableMap): RoomMemberWithIdentityState { + return if (room.info().isEncrypted != true) { + RoomMemberWithIdentityState(this, null) + } else { + val identityState = identityStates[userId] ?: encryptionService.getUserIdentity(userId, fallbackToServer = false).getOrNull() + RoomMemberWithIdentityState(this, identityState) + } + } +} 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 0000000..007d276 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.members + +import io.element.android.features.roommembermoderation.api.RoomMemberModerationState +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import io.element.android.libraries.matrix.api.room.RoomMember +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +data class RoomMemberListState( + // Only used to know if we can show the banned section + private val roomMembers: AsyncData, + val filteredRoomMembers: AsyncData, + val searchQuery: String, + val canInvite: Boolean, + val selectedSection: SelectedSection, + val moderationState: RoomMemberModerationState, + val eventSink: (RoomMemberListEvents) -> Unit, +) { + val showBannedSection: Boolean = moderationState.canBan && roomMembers.dataOrNull()?.banned?.isNotEmpty() == true +} + +enum class SelectedSection { + MEMBERS, + BANNED +} + +data class RoomMembers( + val invited: ImmutableList, + val joined: ImmutableList, + val banned: ImmutableList, +) { + fun isEmpty(section: SelectedSection): Boolean { + return when (section) { + SelectedSection.MEMBERS -> invited.isEmpty() && joined.isEmpty() + SelectedSection.BANNED -> banned.isEmpty() + } + } + + fun filter(query: String): RoomMembers { + if (query.isBlank()) { + return this + } + val filterPredicate = { member: RoomMemberWithIdentityState -> + member.roomMember.userId.value.contains(query, ignoreCase = true) || + member.roomMember.displayName?.contains(query, ignoreCase = true).orFalse() + } + return RoomMembers( + invited = invited.filter(filterPredicate).toImmutableList(), + joined = joined.filter(filterPredicate).toImmutableList(), + banned = banned.filter(filterPredicate).toImmutableList(), + ) + } +} + +data class RoomMemberWithIdentityState( + val roomMember: RoomMember, + val identityState: IdentityState?, +) 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 0000000..580db76 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.members + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents +import io.element.android.features.roommembermoderation.api.RoomMemberModerationState +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.map +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +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 { + override val values: Sequence + get() = sequenceOf( + aRoomMemberListState( + roomMembers = AsyncData.Loading(), + selectedSection = SelectedSection.MEMBERS, + ), + aRoomMemberListState( + roomMembers = AsyncData.Failure(Exception("Error details")), + selectedSection = SelectedSection.MEMBERS, + ), + aRoomMemberListState( + roomMembers = aLoadedRoomMembers(), + selectedSection = SelectedSection.MEMBERS, + ), + aRoomMemberListState( + roomMembers = aLoadedRoomMembers(), + selectedSection = SelectedSection.BANNED, + moderationState = aRoomMemberModerationState(canBan = true), + ), + aRoomMemberListState( + roomMembers = aLoadedRoomMembers(), + canInvite = true, + selectedSection = SelectedSection.MEMBERS, + ), + aRoomMemberListState( + roomMembers = aLoadedRoomMembers(), + searchQuery = "alice", + selectedSection = SelectedSection.MEMBERS, + ), + aRoomMemberListState( + roomMembers = aLoadedRoomMembers(), + searchQuery = "something-with-no-results", + selectedSection = SelectedSection.MEMBERS, + ), + ) +} + +private fun aLoadedRoomMembers() = AsyncData.Success( + RoomMembers( + invited = persistentListOf( + anInvitedVictor().withIdentity(), + anInvitedWalter().withIdentity(), + ), + joined = persistentListOf( + anAlice().withIdentity(identityState = IdentityState.Verified), + aBob().withIdentity(identityState = IdentityState.PinViolation), + aCarol().withIdentity(), + aDavid().withIdentity(), + anEve().withIdentity(identityState = IdentityState.VerificationViolation) + ), + banned = persistentListOf( + aBannedMallory().withIdentity(), + aBannedSusie().withIdentity() + ), + ) +) + +internal fun aRoomMemberListState( + roomMembers: AsyncData = AsyncData.Loading(), + moderationState: RoomMemberModerationState = aRoomMemberModerationState(), + selectedSection: SelectedSection = SelectedSection.MEMBERS, + searchQuery: String = "", + canInvite: Boolean = false, + eventSink: (RoomMemberListEvents) -> Unit = {}, +) = RoomMemberListState( + roomMembers = roomMembers, + filteredRoomMembers = roomMembers.map { it.filter(searchQuery) }, + searchQuery = searchQuery, + canInvite = canInvite, + moderationState = moderationState, + selectedSection = selectedSection, + eventSink = eventSink +) + +fun aRoomMemberModerationState( + canBan: Boolean = false, + canKick: Boolean = false, +): RoomMemberModerationState { + return object : RoomMemberModerationState { + override val canKick: Boolean = canKick + override val canBan: Boolean = canBan + override val eventSink: (RoomMemberModerationEvents) -> Unit = {} + } +} + +fun aRoomMember( + userId: UserId = UserId("@alice:server.org"), + displayName: String? = null, + avatarUrl: String? = null, + membership: RoomMembershipState = RoomMembershipState.JOIN, + isNameAmbiguous: Boolean = false, + powerLevel: Long = 0L, + isIgnored: Boolean = false, + role: RoomMember.Role = RoomMember.Role.User, + membershipChangeReason: String? = null, +) = RoomMember( + userId = userId, + displayName = displayName, + avatarUrl = avatarUrl, + membership = membership, + isNameAmbiguous = isNameAmbiguous, + powerLevel = powerLevel, + isIgnored = isIgnored, + role = role, + membershipChangeReason = membershipChangeReason, +) + +fun aRoomMemberList() = persistentListOf( + anAlice(), + aBob(), + aCarol(), + aDavid(), + anEve(), + anInvitedVictor(), + anInvitedWalter(), + aBannedSusie(), + aBannedMallory(), +) + +fun anEve(): RoomMember = aRoomMember(UserId("@eve:server.org"), "Eve") + +fun aDavid(): RoomMember = aRoomMember(UserId("@david:server.org"), "David") + +fun aCarol(): RoomMember = aRoomMember(UserId("@carol:server.org"), "Carol") + +fun anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice", role = RoomMember.Role.Admin) +fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob", role = RoomMember.Role.Moderator) + +fun anInvitedVictor() = aRoomMember(UserId("@victor:server.org"), "Victor", membership = RoomMembershipState.INVITE) + +fun anInvitedWalter() = aRoomMember(UserId("@walter:server.org"), "Walter", membership = RoomMembershipState.INVITE) + +fun aBannedSusie(): RoomMember = aRoomMember(UserId("@susie:server.org"), "Susie", membership = RoomMembershipState.BAN) + +fun aBannedMallory(): RoomMember = aRoomMember(UserId("@mallory:server.org"), "Mallory", membership = RoomMembershipState.BAN) + +private fun RoomMember.withIdentity(identityState: IdentityState? = null) = RoomMemberWithIdentityState(this, identityState) 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 0000000..7c83f74 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt @@ -0,0 +1,368 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.members + +import androidx.compose.animation.AnimatedVisibility +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.Row +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.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +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.res.pluralStringResource +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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.roomdetails.impl.R +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon +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.form.textFieldState +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.SearchField +import io.element.android.libraries.designsystem.theme.components.SegmentedButton +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.encryption.identity.IdentityState +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.getBestName +import io.element.android.libraries.matrix.api.room.toMatrixUser +import io.element.android.libraries.matrix.ui.components.MatrixUserRow +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun RoomMemberListView( + state: RoomMemberListState, + navigator: RoomMemberListNavigator, + modifier: Modifier = Modifier, +) { + fun onSelectUser(roomMember: RoomMember) { + state.eventSink(RoomMemberListEvents.RoomMemberSelected(roomMember)) + } + + Scaffold( + modifier = modifier, + topBar = { + RoomMemberListTopBar( + canInvite = state.canInvite, + onBackClick = navigator::exitRoomMemberList, + onInviteClick = navigator::openInviteMembers, + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(padding) + .consumeWindowInsets(padding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + var searchQuery by textFieldState(state.searchQuery) + SearchField( + value = searchQuery, + onValueChange = { newQuery -> + searchQuery = newQuery + state.eventSink(RoomMemberListEvents.UpdateSearchQuery(newQuery)) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + placeholder = stringResource(CommonStrings.common_search_for_someone), + ) + RoomMemberList( + roomMembersData = state.filteredRoomMembers, + selectedSection = state.selectedSection, + showBannedSection = state.showBannedSection, + searchQuery = state.searchQuery, + onSelectedSectionChange = { state.eventSink(RoomMemberListEvents.ChangeSelectedSection(it)) }, + onSelectUser = ::onSelectUser, + ) + } + } +} + +@Composable +private fun RoomMemberList( + roomMembersData: AsyncData, + selectedSection: SelectedSection, + showBannedSection: Boolean, + searchQuery: String, + onSelectedSectionChange: (SelectedSection) -> Unit, + onSelectUser: (RoomMember) -> Unit, +) { + LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) { + stickyHeader { + Column { + AnimatedVisibility(visible = showBannedSection) { + val segmentedButtonTitles = persistentListOf( + stringResource(id = R.string.screen_room_member_list_mode_members), + stringResource(id = R.string.screen_room_member_list_mode_banned), + ) + SingleChoiceSegmentedButtonRow( + modifier = Modifier + .background(ElementTheme.colors.bgCanvasDefault) + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + ) { + for ((index, title) in segmentedButtonTitles.withIndex()) { + SegmentedButton( + index = index, + count = segmentedButtonTitles.size, + selected = selectedSection.ordinal == index, + onClick = { onSelectedSectionChange(SelectedSection.entries[index]) }, + text = title, + ) + } + } + } + AnimatedVisibility(visible = roomMembersData.isLoading()) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } + } + when (roomMembersData) { + is AsyncData.Failure -> failureItem(roomMembersData.error) + is AsyncData.Loading, + is AsyncData.Success -> { + val roomMembers = roomMembersData.dataOrNull() ?: return@LazyColumn + if (roomMembers.isEmpty(selectedSection)) { + emptySearchItem(searchQuery) + } else { + memberItems( + roomMembers = roomMembers, + selectedSection = selectedSection, + onSelectUser = onSelectUser, + ) + } + } + AsyncData.Uninitialized -> Unit + } + } +} + +private fun LazyListScope.memberItems( + roomMembers: RoomMembers, + selectedSection: SelectedSection, + onSelectUser: (RoomMember) -> Unit, +) { + when (selectedSection) { + SelectedSection.MEMBERS -> { + if (roomMembers.invited.isNotEmpty()) { + roomMemberListSectionHeader( + text = { + val memberCount = roomMembers.invited.count() + pluralStringResource(id = R.plurals.screen_room_member_list_pending_header_title, memberCount, memberCount) + }, + ) + roomMemberListSectionItems( + members = roomMembers.invited, + onMemberSelected = { onSelectUser(it) } + ) + } + if (roomMembers.joined.isNotEmpty()) { + roomMemberListSectionHeader( + text = { + val memberCount = roomMembers.joined.count() + pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount) + }, + ) + roomMemberListSectionItems( + members = roomMembers.joined, + onMemberSelected = { onSelectUser(it) } + ) + } + } + SelectedSection.BANNED -> { + if (roomMembers.banned.isNotEmpty()) { + roomMemberListSectionHeader( + text = { + val memberCount = roomMembers.banned.count() + pluralStringResource(id = R.plurals.screen_room_member_list_banned_header_title, memberCount, memberCount) + }, + isCritical = true, + ) + roomMemberListSectionItems( + members = roomMembers.banned, + onMemberSelected = { onSelectUser(it) } + ) + } + } + } +} + +private fun LazyListScope.failureItem(failure: Throwable) { + item { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 32.dp), + text = stringResource(id = CommonStrings.error_unknown) + "\n\n" + failure.localizedMessage, + color = ElementTheme.colors.textCriticalPrimary, + textAlign = TextAlign.Center, + ) + } +} + +private fun LazyListScope.roomMemberListSectionHeader( + text: @Composable (() -> String), + modifier: Modifier = Modifier, + isCritical: Boolean = false, +) { + item { + Text( + modifier = modifier.padding(horizontal = 16.dp, vertical = 12.dp), + text = text(), + style = ElementTheme.typography.fontBodyLgMedium, + color = if (isCritical) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textPrimary, + ) + } +} + +private fun LazyListScope.roomMemberListSectionItems( + members: ImmutableList?, + onMemberSelected: (RoomMember) -> Unit, +) { + items(members.orEmpty()) { matrixUser -> + RoomMemberListItem( + modifier = Modifier.fillMaxWidth(), + roomMemberWithIdentity = matrixUser, + onClick = { onMemberSelected(matrixUser.roomMember) } + ) + } +} + +private fun LazyListScope.emptySearchItem(searchQuery: String) { + item { + IconTitleSubtitleMolecule( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 32.dp), + iconStyle = BigIcon.Style.Default( + vectorIcon = CompoundIcons.Search(), + contentDescription = null, + ), + title = stringResource(R.string.screen_room_member_list_empty_search_title, searchQuery), + subTitle = stringResource(R.string.screen_room_member_list_empty_search_subtitle), + ) + } +} + +@Composable +private fun RoomMemberListItem( + roomMemberWithIdentity: RoomMemberWithIdentityState, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val member = roomMemberWithIdentity.roomMember + val roleText = when (member.role) { + RoomMember.Role.Admin -> stringResource(R.string.screen_room_member_list_role_administrator) + RoomMember.Role.Moderator -> stringResource(R.string.screen_room_member_list_role_moderator) + is RoomMember.Role.Owner -> stringResource(R.string.screen_room_member_list_role_owner) + else -> null + } + + MatrixUserRow( + modifier = modifier.clickable(onClick = onClick), + matrixUser = roomMemberWithIdentity.roomMember.toMatrixUser(), + avatarSize = AvatarSize.UserListItem, + trailingContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + when (roomMemberWithIdentity.identityState) { + IdentityState.Verified -> { + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.Verified(), + contentDescription = stringResource(CommonStrings.common_verified), + tint = ElementTheme.colors.iconSuccessPrimary + ) + } + IdentityState.VerificationViolation -> { + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.ErrorSolid(), + contentDescription = stringResource( + CommonStrings.crypto_identity_change_profile_pin_violation, + roomMemberWithIdentity.roomMember.getBestName() + ), + tint = ElementTheme.colors.iconCriticalPrimary + ) + } + else -> Unit + } + + roleText?.let { + Text( + text = it, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RoomMemberListTopBar( + canInvite: Boolean, + onBackClick: () -> Unit, + onInviteClick: () -> Unit, +) { + TopAppBar( + titleStr = stringResource(CommonStrings.common_people), + navigationIcon = { BackButton(onClick = onBackClick) }, + actions = { + if (canInvite) { + TextButton( + text = stringResource(CommonStrings.action_invite), + onClick = onInviteClick, + ) + } + } + ) +} + +@PreviewsDayNight +@Composable +internal fun RoomMemberListViewPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) = ElementPreview { + RoomMemberListView( + state = state, + navigator = object : RoomMemberListNavigator {}, + ) +} 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 0000000..ce798e5 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.annotations.ContributesNode +import io.element.android.features.userprofile.shared.UserProfileNodeHelper +import io.element.android.features.userprofile.shared.UserProfileView +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.RoomId +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 + +@ContributesNode(RoomScope::class) +@AssistedInject +class RoomMemberDetailsNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val analyticsService: AnalyticsService, + private val permalinkBuilder: PermalinkBuilder, + presenterFactory: RoomMemberDetailsPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + data class RoomMemberDetailsInput( + val roomMemberId: UserId, + ) : NodeInputs + + private val inputs = inputs() + private val callback = inputs() + private val presenter = presenterFactory.create(inputs.roomMemberId) + private val userProfileNodeHelper = UserProfileNodeHelper(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() { + userProfileNodeHelper.onShareUser(context, permalinkBuilder) + } + + fun navigateToRoom(roomId: RoomId) { + callback.navigateToRoom(roomId) + } + + fun onStartCall(roomId: RoomId) { + callback.startCall(roomId) + } + + val state = presenter.present() + + UserProfileView( + state = state, + modifier = modifier, + goBack = this::navigateUp, + onShareUser = ::onShareUser, + onOpenDm = ::navigateToRoom, + onStartCall = ::onStartCall, + openAvatarPreview = callback::navigateToAvatarPreview, + onVerifyClick = callback::startVerifyUserFlow, + ) + } +} 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 0000000..f47ea0e --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.members.details + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.userprofile.api.UserProfileEvents +import io.element.android.features.userprofile.api.UserProfilePresenterFactory +import io.element.android.features.userprofile.api.UserProfileState +import io.element.android.features.userprofile.api.UserProfileVerificationState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState +import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +/** + * Presenter for room member details screen. + * Rely on UserProfilePresenter, but override some fields with room member info when available. + */ +@AssistedInject +class RoomMemberDetailsPresenter( + @Assisted private val roomMemberId: UserId, + private val room: JoinedRoom, + private val encryptionService: EncryptionService, + private val clipboardHelper: ClipboardHelper, + userProfilePresenterFactory: UserProfilePresenterFactory, +) : Presenter { + interface Factory { + fun create(roomMemberId: UserId): RoomMemberDetailsPresenter + } + + private val userProfilePresenter = userProfilePresenterFactory.create(roomMemberId) + + @Composable + override fun present(): UserProfileState { + val coroutineScope = rememberCoroutineScope() + + val snackbarDispatcher = LocalSnackbarDispatcher.current + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + val roomMember by room.getRoomMemberAsState(roomMemberId) + LaunchedEffect(Unit) { + // Update room member info when opening this screen + // We don't need to assign the result as it will be automatically propagated by `room.getRoomMemberAsState` + room.getUpdatedMember(roomMemberId) + } + + val roomUserName: String? by produceState( + initialValue = roomMember?.displayName, + key1 = roomMember, + ) { + value = room.userDisplayName(roomMemberId).getOrNull() ?: roomMember?.displayName + } + + val roomUserAvatar: String? by produceState( + initialValue = roomMember?.avatarUrl, + key1 = roomMember, + ) { + value = room.userAvatarUrl(roomMemberId).getOrNull() ?: roomMember?.avatarUrl + } + + val userProfileState = userProfilePresenter.present() + + val identityStateChanges = produceState(initialValue = null) { + // Fetch the initial identity state manually + val identityState = encryptionService.getUserIdentity(roomMemberId).getOrNull() + value = identityState?.let { IdentityStateChange(roomMemberId, it) } + + // Subscribe to the identity changes + room.roomMemberIdentityStateChange(waitForEncryption = false) + .map { it.find { it.identityRoomMember.userId == roomMemberId } } + .map { roomMemberIdentityStateChange -> + // If we didn't receive any info, manually fetch it + roomMemberIdentityStateChange?.identityState ?: encryptionService.getUserIdentity(roomMemberId).getOrNull() + } + .filterNotNull() + .collect { value = IdentityStateChange(roomMemberId, it) } + } + + val verificationState by remember { + derivedStateOf { + when (identityStateChanges.value?.identityState) { + IdentityState.VerificationViolation -> UserProfileVerificationState.VERIFICATION_VIOLATION + IdentityState.Verified -> UserProfileVerificationState.VERIFIED + IdentityState.Pinned, IdentityState.PinViolation -> UserProfileVerificationState.UNVERIFIED + else -> UserProfileVerificationState.UNKNOWN + } + } + } + + fun handleEvent(event: UserProfileEvents) { + when (event) { + UserProfileEvents.WithdrawVerification -> coroutineScope.launch { + encryptionService.withdrawVerification(roomMemberId) + } + is UserProfileEvents.CopyToClipboard -> { + clipboardHelper.copyPlainText(event.text) + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + } + else -> userProfileState.eventSink(event) + } + } + + return userProfileState.copy( + userName = roomUserName ?: userProfileState.userName, + avatarUrl = roomUserAvatar ?: userProfileState.avatarUrl, + verificationState = verificationState, + snackbarMessage = snackbarMessage, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt new file mode 100644 index 0000000..81bfd86 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsEvents.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.notificationsettings + +import io.element.android.libraries.matrix.api.room.RoomNotificationMode + +sealed interface RoomNotificationSettingsEvents { + data class ChangeRoomNotificationMode(val mode: RoomNotificationMode) : RoomNotificationSettingsEvents + data class SetNotificationMode(val isDefault: Boolean) : RoomNotificationSettingsEvents + data object DeleteCustomNotification : RoomNotificationSettingsEvents + data object ClearSetNotificationError : RoomNotificationSettingsEvents + data object ClearRestoreDefaultError : RoomNotificationSettingsEvents +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsItem.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsItem.kt new file mode 100644 index 0000000..2d5418a --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsItem.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.notificationsettings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.features.roomdetails.impl.R +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +data class RoomNotificationSettingsItem( + val mode: RoomNotificationMode, + val title: String, +) + +@Composable +fun roomNotificationSettingsItems(): ImmutableList { + return RoomNotificationMode.entries + .map { + when (it) { + RoomNotificationMode.ALL_MESSAGES -> RoomNotificationSettingsItem( + mode = it, + title = stringResource(R.string.screen_room_notification_settings_mode_all_messages), + ) + RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> RoomNotificationSettingsItem( + mode = it, + title = stringResource(R.string.screen_room_notification_settings_mode_mentions_and_keywords), + ) + RoomNotificationMode.MUTE -> RoomNotificationSettingsItem( + mode = it, + title = stringResource(CommonStrings.common_mute), + ) + } + } + .toImmutableList() +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt new file mode 100644 index 0000000..fd3ce00 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.notificationsettings + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +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) +@AssistedInject +class RoomNotificationSettingsNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: RoomNotificationSettingsPresenter.Factory, + private val analyticsService: AnalyticsService, +) : Node(buildContext, plugins = plugins) { + data class RoomNotificationSettingInput( + val showUserDefinedSettingStyle: Boolean + ) : NodeInputs + + interface Callback : Plugin { + fun navigateToGlobalNotificationSettings() + } + + private val callback: Callback = callback() + private val inputs = inputs() + + private val presenter = presenterFactory.create(inputs.showUserDefinedSettingStyle) + + init { + lifecycle.subscribe( + onResume = { + analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.RoomNotifications)) + } + ) + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + RoomNotificationSettingsView( + state = state, + modifier = modifier, + onShowGlobalNotifications = callback::navigateToGlobalNotificationSettings, + onBackClick = ::navigateUp, + ) + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt new file mode 100644 index 0000000..8ff1be5 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.notificationsettings + +import androidx.compose.foundation.layout.Column +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.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.room.RoomNotificationMode + +@Composable +fun RoomNotificationSettingsOption( + roomNotificationSettingsItem: RoomNotificationSettingsItem, + onSelectOption: (RoomNotificationSettingsItem) -> Unit, + displayMentionsOnlyDisclaimer: Boolean, + modifier: Modifier = Modifier, + enabled: Boolean = true, + isSelected: Boolean = false, +) { + val mode = roomNotificationSettingsItem.mode + val title = roomNotificationSettingsItem.title + val subtitle = when { + mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY && displayMentionsOnlyDisclaimer -> { + stringResource(id = R.string.screen_notification_settings_mentions_only_disclaimer) + } + else -> null + } + ListItem( + modifier = modifier, + enabled = enabled, + headlineContent = { Text(title) }, + supportingContent = subtitle?.let { { Text(it) } }, + trailingContent = ListItemContent.RadioButton(selected = isSelected), + onClick = { onSelectOption(roomNotificationSettingsItem) }, + ) +} + +@PreviewsDayNight +@Composable +internal fun RoomNotificationSettingsOptionPreview() = ElementPreview { + Column { + for ((index, item) in roomNotificationSettingsItems().withIndex()) { + RoomNotificationSettingsOption( + roomNotificationSettingsItem = item, + onSelectOption = {}, + isSelected = index == 0, + enabled = index != 2, + displayMentionsOnlyDisclaimer = index == 1, + ) + } + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOptions.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOptions.kt new file mode 100644 index 0000000..b721138 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOptions.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.notificationsettings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.libraries.matrix.api.room.RoomNotificationMode + +@Composable +fun RoomNotificationSettingsOptions( + selected: RoomNotificationMode?, + enabled: Boolean, + onSelectOption: (RoomNotificationSettingsItem) -> Unit, + displayMentionsOnlyDisclaimer: Boolean, + modifier: Modifier = Modifier, +) { + val items = roomNotificationSettingsItems() + Column(modifier = modifier.selectableGroup()) { + items.forEach { item -> + RoomNotificationSettingsOption( + roomNotificationSettingsItem = item, + isSelected = selected == item.mode, + onSelectOption = onSelectOption, + displayMentionsOnlyDisclaimer = displayMentionsOnlyDisclaimer, + enabled = enabled + ) + } + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt new file mode 100644 index 0000000..b3a88d3 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenter.kt @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.notificationsettings + +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.core.coroutine.suspendWithMinimumDuration +import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.RoomNotificationSettings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds + +@AssistedInject +class RoomNotificationSettingsPresenter( + private val room: JoinedRoom, + private val notificationSettingsService: NotificationSettingsService, + @Assisted private val showUserDefinedSettingStyle: Boolean, +) : Presenter { + @AssistedFactory + interface Factory { + fun create(showUserDefinedSettingStyle: Boolean): RoomNotificationSettingsPresenter + } + + @Composable + override fun present(): RoomNotificationSettingsState { + var shouldDisplayMentionsOnlyDisclaimer by remember { mutableStateOf(false) } + val defaultRoomNotificationMode: MutableState = rememberSaveable { + mutableStateOf(null) + } + val localCoroutineScope = rememberCoroutineScope() + val setNotificationSettingAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + val restoreDefaultAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + + val roomNotificationSettings: MutableState> = remember { + mutableStateOf(AsyncData.Uninitialized) + } + + // We store state of which mode the user has set via the notification service before the new push settings have been updated. + // We show this state immediately to the user and debounce updates to notification settings to hide some invalid states returned + // by the rust sdk during these two events that cause the radio buttons ot toggle quickly back and forth. + // This is a client side work-around until bulk push rule updates are supported. + // ref: https://github.com/matrix-org/matrix-spec-proposals/pull/3934 + val pendingRoomNotificationMode: MutableState = remember { + mutableStateOf(null) + } + + // We store state of whether the user has set the notifications settings to default or custom via the notification service. + // We show this state immediately to the user and debounce updates to notification settings to hide some invalid states returned + // by the rust sdk during these two events that cause the switch ot toggle quickly back and forth. + // This is a client side work-around until bulk push rule updates are supported. + // ref: https://github.com/matrix-org/matrix-spec-proposals/pull/3934 + val pendingSetDefault: MutableState = remember { + mutableStateOf(null) + } + + val displayName by produceState(room.info().name) { + room.roomInfoFlow.collect { value = it.name } + } + + val isRoomEncrypted by produceState(room.info().isEncrypted) { + room.roomInfoFlow.collect { value = it.isEncrypted } + } + + LaunchedEffect(Unit) { + getDefaultRoomNotificationMode(defaultRoomNotificationMode) + fetchNotificationSettings(pendingRoomNotificationMode, roomNotificationSettings) + observeNotificationSettings(pendingRoomNotificationMode, roomNotificationSettings) + } + + LaunchedEffect(isRoomEncrypted) { + shouldDisplayMentionsOnlyDisclaimer = isRoomEncrypted == true && + !notificationSettingsService.canHomeServerPushEncryptedEventsToDevice().getOrDefault(true) + } + + fun handleEvent(event: RoomNotificationSettingsEvents) { + when (event) { + is RoomNotificationSettingsEvents.ChangeRoomNotificationMode -> { + localCoroutineScope.setRoomNotificationMode(event.mode, pendingRoomNotificationMode, pendingSetDefault, setNotificationSettingAction) + } + is RoomNotificationSettingsEvents.SetNotificationMode -> { + if (event.isDefault) { + localCoroutineScope.restoreDefaultRoomNotificationMode(restoreDefaultAction, pendingSetDefault) + } else { + defaultRoomNotificationMode.value?.let { + localCoroutineScope.setRoomNotificationMode(it, pendingRoomNotificationMode, pendingSetDefault, setNotificationSettingAction) + } + } + } + is RoomNotificationSettingsEvents.DeleteCustomNotification -> { + localCoroutineScope.restoreDefaultRoomNotificationMode(restoreDefaultAction, pendingSetDefault) + } + RoomNotificationSettingsEvents.ClearSetNotificationError -> { + setNotificationSettingAction.value = AsyncAction.Uninitialized + } + RoomNotificationSettingsEvents.ClearRestoreDefaultError -> { + restoreDefaultAction.value = AsyncAction.Uninitialized + } + } + } + + return RoomNotificationSettingsState( + showUserDefinedSettingStyle = showUserDefinedSettingStyle, + roomName = displayName.orEmpty(), + roomNotificationSettings = roomNotificationSettings.value, + pendingRoomNotificationMode = pendingRoomNotificationMode.value, + pendingSetDefault = pendingSetDefault.value, + defaultRoomNotificationMode = defaultRoomNotificationMode.value, + setNotificationSettingAction = setNotificationSettingAction.value, + restoreDefaultAction = restoreDefaultAction.value, + displayMentionsOnlyDisclaimer = shouldDisplayMentionsOnlyDisclaimer, + eventSink = ::handleEvent, + ) + } + + @OptIn(FlowPreview::class) + private fun CoroutineScope.observeNotificationSettings( + pendingModeState: MutableState, + roomNotificationSettings: MutableState> + ) { + notificationSettingsService.notificationSettingsChangeFlow + .debounce(0.5.seconds) + .onEach { + fetchNotificationSettings(pendingModeState, roomNotificationSettings) + } + .launchIn(this) + } + + private fun CoroutineScope.fetchNotificationSettings( + pendingModeState: MutableState, + roomNotificationSettings: MutableState> + ) = launch { + suspend { + val isEncrypted = room.info().isEncrypted ?: room.getUpdatedIsEncrypted().getOrThrow() + pendingModeState.value = null + notificationSettingsService.getRoomNotificationSettings(room.roomId, isEncrypted, room.isOneToOne).getOrThrow() + }.runCatchingUpdatingState(roomNotificationSettings) + } + + private fun CoroutineScope.getDefaultRoomNotificationMode( + defaultRoomNotificationMode: MutableState + ) = launch { + val isEncrypted = room.info().isEncrypted ?: room.getUpdatedIsEncrypted().getOrThrow() + defaultRoomNotificationMode.value = notificationSettingsService.getDefaultRoomNotificationMode( + isEncrypted, + room.isOneToOne + ).getOrThrow() + } + + private fun CoroutineScope.setRoomNotificationMode( + mode: RoomNotificationMode, + pendingModeState: MutableState, + pendingDefaultState: MutableState, + action: MutableState> + ) = launch { + suspendWithMinimumDuration { + pendingModeState.value = mode + pendingDefaultState.value = false + val result = notificationSettingsService.setRoomNotificationMode(room.roomId, mode) + if (result.isFailure) { + pendingModeState.value = null + pendingDefaultState.value = null + } + result.getOrThrow() + }.runCatchingUpdatingState(action) + } + + private fun CoroutineScope.restoreDefaultRoomNotificationMode( + action: MutableState>, + pendingDefaultState: MutableState + ) = launch { + suspendWithMinimumDuration { + pendingDefaultState.value = true + val result = notificationSettingsService.restoreDefaultRoomNotificationMode(room.roomId) + if (result.isFailure) { + pendingDefaultState.value = null + } + result.getOrThrow() + }.runCatchingUpdatingState(action) + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt new file mode 100644 index 0000000..a2ea5e8 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsState.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.notificationsettings + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.RoomNotificationSettings + +data class RoomNotificationSettingsState( + val showUserDefinedSettingStyle: Boolean, + val roomName: String, + val roomNotificationSettings: AsyncData, + val pendingRoomNotificationMode: RoomNotificationMode?, + val pendingSetDefault: Boolean?, + val defaultRoomNotificationMode: RoomNotificationMode?, + val setNotificationSettingAction: AsyncAction, + val restoreDefaultAction: AsyncAction, + val displayMentionsOnlyDisclaimer: Boolean, + val eventSink: (RoomNotificationSettingsEvents) -> Unit +) + +val RoomNotificationSettingsState.displayNotificationMode: RoomNotificationMode? get() { + return pendingRoomNotificationMode ?: roomNotificationSettings.dataOrNull()?.mode +} + +val RoomNotificationSettingsState.displayIsDefault: Boolean? get() { + return pendingSetDefault ?: roomNotificationSettings.dataOrNull()?.isDefault +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt new file mode 100644 index 0000000..603d22b --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsStateProvider.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.notificationsettings + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.roomdetails.impl.aRoomNotificationSettings +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.room.RoomNotificationMode + +internal class RoomNotificationSettingsStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aRoomNotificationSettingsState(), + aRoomNotificationSettingsState(isDefault = false), + aRoomNotificationSettingsState(setNotificationSettingAction = AsyncAction.Loading), + aRoomNotificationSettingsState(setNotificationSettingAction = AsyncAction.Failure(RuntimeException("error"))), + aRoomNotificationSettingsState(restoreDefaultAction = AsyncAction.Loading), + aRoomNotificationSettingsState(restoreDefaultAction = AsyncAction.Failure(RuntimeException("error"))), + aRoomNotificationSettingsState(displayMentionsOnlyDisclaimer = true) + ) + + private fun aRoomNotificationSettingsState( + isDefault: Boolean = true, + setNotificationSettingAction: AsyncAction = AsyncAction.Uninitialized, + restoreDefaultAction: AsyncAction = AsyncAction.Uninitialized, + displayMentionsOnlyDisclaimer: Boolean = false, + ): RoomNotificationSettingsState { + return RoomNotificationSettingsState( + showUserDefinedSettingStyle = false, + roomName = "Room 1", + AsyncData.Success(aRoomNotificationSettings( + mode = RoomNotificationMode.MUTE, + isDefault = isDefault + )), + pendingRoomNotificationMode = null, + pendingSetDefault = null, + defaultRoomNotificationMode = RoomNotificationMode.ALL_MESSAGES, + setNotificationSettingAction = setNotificationSettingAction, + restoreDefaultAction = restoreDefaultAction, + displayMentionsOnlyDisclaimer = displayMentionsOnlyDisclaimer, + eventSink = { }, + ) + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt new file mode 100644 index 0000000..5a33066 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.notificationsettings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +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.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.roomdetails.impl.R +import io.element.android.libraries.core.bool.orTrue +import io.element.android.libraries.designsystem.components.ClickableLinkText +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun RoomNotificationSettingsView( + state: RoomNotificationSettingsState, + onShowGlobalNotifications: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + if (state.showUserDefinedSettingStyle) { + UserDefinedRoomNotificationSettingsView( + state = state, + modifier = modifier, + onBackClick = onBackClick, + ) + } else { + RoomSpecificNotificationSettingsView( + state = state, + modifier = modifier, + onShowGlobalNotifications = onShowGlobalNotifications, + onBackClick = onBackClick, + ) + } +} + +@Composable +private fun RoomSpecificNotificationSettingsView( + state: RoomNotificationSettingsState, + onShowGlobalNotifications: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + RoomNotificationSettingsTopBar( + onBackClick = { onBackClick() } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(padding) + .consumeWindowInsets(padding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + val roomNotificationSettings = state.roomNotificationSettings.dataOrNull() + PreferenceSwitch( + isChecked = !state.displayIsDefault.orTrue(), + onCheckedChange = { + state.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(!it)) + }, + title = stringResource(id = R.string.screen_room_notification_settings_allow_custom), + subtitle = stringResource(id = R.string.screen_room_notification_settings_allow_custom_footnote), + enabled = roomNotificationSettings != null + ) + if (state.displayIsDefault.orTrue()) { + PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_default_setting_title)) { + val text = buildAnnotatedStringWithStyledPart( + R.string.screen_room_notification_settings_default_setting_footnote, + R.string.screen_room_notification_settings_default_setting_footnote_content_link, + color = Color.Unspecified, + underline = false, + bold = true, + ) + ClickableLinkText( + annotatedString = text, + onClick = { + onShowGlobalNotifications() + }, + modifier = Modifier + .padding(start = 16.dp, bottom = 16.dp, end = 16.dp), + style = ElementTheme.typography.fontBodyMdRegular + .copy( + color = ElementTheme.colors.textSecondary, + ) + ) + if (state.defaultRoomNotificationMode != null) { + val defaultModeTitle = when (state.defaultRoomNotificationMode) { + RoomNotificationMode.ALL_MESSAGES -> stringResource(id = R.string.screen_room_notification_settings_mode_all_messages) + RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> { + stringResource(id = R.string.screen_room_notification_settings_mode_mentions_and_keywords) + } + RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute) + } + val displayMentionsOnlyDisclaimer = state.displayMentionsOnlyDisclaimer && + state.defaultRoomNotificationMode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + RoomNotificationSettingsOption( + roomNotificationSettingsItem = RoomNotificationSettingsItem(state.defaultRoomNotificationMode, defaultModeTitle), + isSelected = true, + onSelectOption = { }, + displayMentionsOnlyDisclaimer = displayMentionsOnlyDisclaimer, + enabled = true + ) + } + } + } else { + PreferenceCategory(title = stringResource(id = R.string.screen_room_notification_settings_custom_settings_title)) { + RoomNotificationSettingsOptions( + selected = state.displayNotificationMode, + enabled = !state.displayIsDefault.orTrue(), + displayMentionsOnlyDisclaimer = state.displayMentionsOnlyDisclaimer, + onSelectOption = { + state.eventSink(RoomNotificationSettingsEvents.ChangeRoomNotificationMode(it.mode)) + }, + ) + } + } + + AsyncActionView( + async = state.setNotificationSettingAction, + onSuccess = {}, + errorMessage = { stringResource(R.string.screen_notification_settings_edit_failed_updating_default_mode) }, + onErrorDismiss = { state.eventSink(RoomNotificationSettingsEvents.ClearSetNotificationError) }, + ) + + AsyncActionView( + async = state.restoreDefaultAction, + onSuccess = {}, + errorMessage = { stringResource(R.string.screen_notification_settings_edit_failed_updating_default_mode) }, + onErrorDismiss = { state.eventSink(RoomNotificationSettingsEvents.ClearRestoreDefaultError) }, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RoomNotificationSettingsTopBar( + onBackClick: () -> Unit, +) { + TopAppBar( + titleStr = stringResource(R.string.screen_room_details_notification_title), + navigationIcon = { BackButton(onClick = onBackClick) }, + ) +} + +@PreviewsDayNight +@Composable +internal fun RoomNotificationSettingsViewPreview( + @PreviewParameter(RoomNotificationSettingsStateProvider::class) state: RoomNotificationSettingsState +) = ElementPreview { + RoomNotificationSettingsView( + state = state, + onShowGlobalNotifications = {}, + onBackClick = {}, + ) +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsStateProvider.kt new file mode 100644 index 0000000..039f489 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsStateProvider.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.notificationsettings + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.roomdetails.impl.aRoomNotificationSettings +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.room.RoomNotificationMode + +internal class UserDefinedRoomNotificationSettingsStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + RoomNotificationSettingsState( + showUserDefinedSettingStyle = false, + roomName = "Room 1", + AsyncData.Success( + aRoomNotificationSettings( + mode = RoomNotificationMode.MUTE, + isDefault = false + ) + ), + pendingRoomNotificationMode = null, + pendingSetDefault = null, + defaultRoomNotificationMode = RoomNotificationMode.ALL_MESSAGES, + setNotificationSettingAction = AsyncAction.Uninitialized, + restoreDefaultAction = AsyncAction.Uninitialized, + displayMentionsOnlyDisclaimer = false, + eventSink = { }, + ), + ) +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt new file mode 100644 index 0000000..f66316b --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.notificationsettings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.roomdetails.impl.R +import io.element.android.libraries.core.bool.orTrue +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +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 + +@Composable +fun UserDefinedRoomNotificationSettingsView( + state: RoomNotificationSettingsState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + UserDefinedRoomNotificationSettingsTopBar( + roomName = state.roomName, + onBackClick = { onBackClick() } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(padding) + .consumeWindowInsets(padding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + val roomNotificationSettings = state.roomNotificationSettings.dataOrNull() + if (roomNotificationSettings != null && state.displayNotificationMode != null) { + RoomNotificationSettingsOptions( + selected = state.displayNotificationMode, + enabled = !state.displayIsDefault.orTrue(), + displayMentionsOnlyDisclaimer = state.displayMentionsOnlyDisclaimer, + onSelectOption = { + state.eventSink(RoomNotificationSettingsEvents.ChangeRoomNotificationMode(it.mode)) + }, + ) + } + + ListItem( + headlineContent = { Text(stringResource(R.string.screen_room_notification_settings_edit_remove_setting)) }, + style = ListItemStyle.Destructive, + onClick = { + state.eventSink(RoomNotificationSettingsEvents.DeleteCustomNotification) + } + ) + + AsyncActionView( + async = state.setNotificationSettingAction, + onSuccess = {}, + errorMessage = { stringResource(R.string.screen_notification_settings_edit_failed_updating_default_mode) }, + onErrorDismiss = { state.eventSink(RoomNotificationSettingsEvents.ClearSetNotificationError) }, + ) + + AsyncActionView( + async = state.restoreDefaultAction, + onSuccess = { onBackClick() }, + errorMessage = { stringResource(R.string.screen_notification_settings_edit_failed_updating_default_mode) }, + onErrorDismiss = { state.eventSink(RoomNotificationSettingsEvents.ClearRestoreDefaultError) }, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun UserDefinedRoomNotificationSettingsTopBar( + roomName: String, + onBackClick: () -> Unit, +) { + TopAppBar( + titleStr = roomName, + navigationIcon = { BackButton(onClick = onBackClick) }, + ) +} + +@PreviewsDayNight +@Composable +internal fun UserDefinedRoomNotificationSettingsViewPreview( + @PreviewParameter(UserDefinedRoomNotificationSettingsStateProvider::class) state: RoomNotificationSettingsState +) = ElementPreview { + UserDefinedRoomNotificationSettingsView( + state = state, + onBackClick = {}, + ) +} diff --git a/features/roomdetails/impl/src/main/res/values-be/translations.xml b/features/roomdetails/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..fe33717 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,106 @@ + + + "Пры абнаўленні налад апавяшчэнняў адбылася памылка." + "Ваш хатні сервер не падтрымлівае гэтую опцыю ў зашыфраваных пакоях, вы можаце не атрымаць апавяшчэнне ў некаторых пакоях." + "Апытанні" + "Толькі адміністратары" + "Заблакіраваць людзей" + "Выдаліць паведамленні" + "Запрашайце людзей і прымайце запыты на далучэнне" + "Паведамленні і змест" + "Адміністратары і мадэратары" + "Выдаляйце людзей і адхіляйце запыты на далучэнне" + "Змяніць аватар пакоя" + "Рэдагаваць пакой" + "Змяніць назву пакоя" + "Змяніць тэму пакоя" + "Адправіць паведамленні" + "Рэдагаваць адміністратараў" + "Вы не зможаце адмяніць гэта дзеянне. Вы прасоўваеце карыстальніка да таго ж узроўню магутнасці, што і вы." + "Дадаць адміністратара?" + "Паніжэнне ўзроўню" + "Вы не зможаце адмяніць гэтае змяненне, бо паніжаеце сябе. Калі вы апошні адміністратар у пакоі, вярнуць права будзе немагчыма." + "Панізіць сябе?" + "%1$s (У чаканні)" + "(У чаканні)" + "Адміністратары аўтаматычна маюць права мадэратара" + "Рэдагаваць мадэратараў" + "Адміністратары" + "Мадэратары" + "Удзельнікі" + "У вас ёсць незахаваныя змены." + "Захаваць змены?" + "Дадаць тэму" + "Зашыфраваны" + "Не зашыфраваны" + "Публічны пакой" + "Рэдагаваць пакой" + "Адбылася невядомая памылка, і інфармацыю нельга было змяніць." + "Немагчыма абнавіць пакой" + "Паведамленні зашыфраваны. Толькі ў вас і ў атрымальнікаў ёсць унікальныя ключы для іх разблакіроўкі." + "Шыфраванне паведамленняў уключана" + "Пры загрузцы налад апавяшчэнняў адбылася памылка." + "Не атрымалася адключыць гук у гэтым пакоі, паўтарыце спробу." + "Не ўдалося ўключыць гук у гэтым пакоі. Паўтарыце спробу." + "Запрасіць карыстальнікаў" + "Пакінуць размову" + "Пакінуць пакой" + "Уласныя" + "Стандартныя" + "Апавяшчэнні" + "Замацаваныя паведамленні" + "Профіль" + "Ролі і дазволы" + "Назва пакоя" + "Бяспека" + "Падзяліцца пакоем" + "Інфармацыя аб пакоі" + "Тэма" + "Ідзе абнаўленне пакоя…" + "У гэтым пакоі няма заблакіраваных удзельнікаў." + + "%1$d удзельнік" + "%1$d удзельнікі" + "%1$d удзельнікаў" + + "Выдаліць і заблакіраваць удзельніка" + "Толькі выдаліць удзельніка" + "Разблакіраваць" + "Яны змогуць зноў далучыцца да гэтага пакоя, калі іх запросяць." + "Заблакіраваныя" + "Удзельнікі" + "Толькі адміністратары" + "Адміністратары і мадэратары" + "Удзельнікі пакоя" + "Разблакіроўка %1$s" + "Дазволіць уласную наладу" + "Калі гэта ўключыць, ваша налада прадвызначана будзе адменена" + "Апавяшчаць мяне ў гэтым чаце для" + "Вы можаце змяніць гэта ў %1$s." + "асноўных наладах" + "Стандартная налада" + "Выдаліць карыстальніцкую наладу" + "Падчас загрузкі налад апавяшчэнняў адбылася памылка." + "Не атрымалася аднавіць прадвызначаны рэжым, паспрабуйце яшчэ раз." + "Не ўдалося наладзіць рэжым, паспрабуйце яшчэ раз." + "Ваш хатні сервер не падтрымлівае гэту опцыю ў зашыфраваных пакоях, вы не атрымаеце апавяшчэнне ў гэтым пакоі." + "Усе паведамленні" + "Толькі згадванні і ключавыя словы" + "У гэтым пакоі паведаміце мяне пра" + "Адміністратары" + "Змяніць маю роль" + "Панізіць да ўдзельніка" + "Панізіць да мадэратара" + "Мадэрацыя ўдзельнікаў" + "Паведамленні і змест" + "Мадэратары" + "Скінуць дазволы" + "Пасля скіду дазволаў вы страціце бягучыя налады." + "Скінуць дазволы?" + "Ролі" + "Дэталі пакоя" + "Ролі і дазволы" + "Папрасіце далучыцца" + "Хто заўгодна" + "Хто заўгодна" + diff --git a/features/roomdetails/impl/src/main/res/values-bg/translations.xml b/features/roomdetails/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..4ea6d24 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,101 @@ + + + "Възникна грешка при обновяването на настройките за известия." + "Вашият сървър не поддържа тази опция в шифровани стаи, може да не получавате известия в някои стаи." + "Анкети" + "Само администратори" + "Премахване на съобщения" + "Поканване на хора и приемане на заявки за присъединяване" + "Съобщения и съдържание" + "Администратори и модератори" + "Премахване на хора и отхвърляне на заявки за присъединяване" + "Редактиране на стаята" + "Промяна на името на стаята" + "Промяна на темата на стаята" + "Изпращане на съобщения" + "Редактиране на администраторите" + "Добавяне на администратор?" + "Администраторите автоматично имат модераторски права" + "Редактиране на модераторите" + "Администратори" + "Модератори" + "Членове" + "Добавяне на тема" + "С шифроване" + "Без шифроване" + "Общодостъпна стая" + "Редактиране на стаята" + "Възникна неизвестна грешка и информацията не можа да бъде променена." + "Не може да се обнови стаята" + "Съобщенията са защитени с ключове. Само вие и получателите имате уникалните ключове, за да ги отключите." + "Шифроването на съобщенията е включено" + "Възникна грешка при зареждането на настройките за известия." + "Неуспешно заглушаване на тази стая, моля, опитайте отново." + "Неуспешно раззаглушаване на тази стая, моля, опитайте отново." + "Поканване на хора" + "Напускане на разговора" + "Напускане на стаята" + "Медия и файлове" + "Персонализирани" + "По подразбиране" + "Известия" + "Закачени съобщения" + "Профил" + "Роли и разрешения" + "Име на стаята" + "Защита и поверителност" + "Защита" + "Споделяне на стаята" + "Информация за стаята" + "Тема" + "Обновяване на стаята…" + + "%1$d човек" + "%1$d души" + + "Членове" + "Само администратори" + "Администратори и модератори" + "Членове на стаята" + "Разрешаване на персонализирана настройка" + "Включването на това ще замени вашата настройка по подразбиране" + "Да бъда известяван в този чат за" + "Можете да го промените във вашите %1$s." + "глобални настройки" + "Настройка по подразбиране" + "Премахване на персонализираната настройка" + "Възникна грешка при зареждането на настройките за известия." + "Неуспешно възстановяване на режима по подразбиране, моля, опитайте отново." + "Неуспешно задаване на режима, моля, опитайте отново." + "Всички съобщения" + "Само споменавания и ключови думи" + "В тази стая, да бъда известяван за" + "Администратори" + "Промяна на моята роля" + "Модериране на членове" + "Съобщения и съдържание" + "Модератори" + "Нулиране на разрешенията" + "Роли" + "Подробности за стаята" + "Роли и разрешения" + "Добавяне на адрес на стаята" + "Да, включване на шифроването" + "Да се включи ли шифроването?" + "Веднъж включено, шифроването не може да бъде изключено." + "Шифроване" + "Включване на шифроване от край до край" + "Всеки може да намери и да се присъедини" + "Всеки" + "Хората могат да се присъединят само ако са поканени" + "Само с покана" + "Достъп до стаята" + "Членове на пространството" + "Пространствата в момента не се поддържат" + "Видима в директорията на обществените стаи" + "Всеки" + "Кой може да чете историята" + "Само за членове откакто са поканени" + "Само за членове от избирането на тази опция" + "Защита и поверителност" + 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 0000000..fc19bf4 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,170 @@ + + + "Budete potřebovat adresu místnosti, aby byla viditelná v adresáři místností." + "Upravit adresu" + "Při aktualizaci nastavení oznámení došlo k chybě." + "Váš domovský server tuto možnost v zašifrovaných místnostech nepodporuje, v některých místnostech nemusíte být upozorněni." + "Hlasování" + "Správce" + "Vykázat lidi" + "Odstranit zprávy" + "Člen" + "Pozvat přátele" + "Spravovat členy" + "Zprávy a obsah" + "Moderátor" + "Odebrat osoby" + "Změnit avatar místnosti" + "Upravit podrobnosti" + "Změnit název místnosti" + "Změnit téma místnosti" + "Odeslat zprávy" + "Upravit správce" + "Tuto akci nebudete moci vrátit zpět. Upravujete oprávnění uživatele, tak aby měl stejnou úroveň jako vy." + "Přidat správce?" + "Tuto akci nebudete moci vrátit zpět. Převádíte vlastnictví na vybrané uživatele. Jakmile tuto akci opustíte, bude tato změna trvalá." + "Převést vlastnictví?" + "Degradovat" + "Tuto změnu nebudete moci vrátit zpět, protože sami degradujete, pokud jste posledním privilegovaným uživatelem v místnosti, nebude možné znovu získat oprávnění." + "Degradovat se?" + "%1$s (čekající)" + "(Čeká na vyřízení)" + "Správci mají automaticky oprávnění moderátora" + "Vlastníci mají automaticky administrátorská oprávnění." + "Upravit moderátory" + "Vyberte vlastníky" + "Správci" + "Moderátoři" + "Členové" + "Máte neuložené změny." + "Uložit změny?" + "Přidat téma" + "Šifrováno" + "Není šifrováno" + "Veřejná místnost" + "Upravit podrobnosti" + "Došlo k neznámé chybě a informace nebylo možné změnit." + "Nelze aktualizovat místnost" + "Zprávy jsou zabezpečeny zámky. Pouze vy a příjemci máte jedinečné klíče k jejich odemčení." + "Šifrování zpráv povoleno" + "Při načítání nastavení oznámení došlo k chybě." + "Ztišení této místnosti se nezdařilo, zkuste to prosím znovu." + "Nepodařilo se zrušit ztišení této místnosti, zkuste to prosím znovu." + "Nezavírejte aplikaci, dokud neskončíte." + "Příprava pozvánek…" + "Pozvat přátele" + "Opustit konverzaci" + "Opustit místnost" + "Média a soubory" + "Vlastní" + "Výchozí" + "Oznámení" + "Připnuté zprávy" + "Profil" + "Žádosti o vstup" + "Role a oprávnění" + "Název místnosti" + "Zabezpečení a soukromí" + "Zabezpečení" + "Sdílet místnost" + "Informace o místnosti" + "Téma" + "Aktualizace místnosti…" + "V této místnosti nejsou žádní vykázaní uživatelé." + + "%1$d vykázán(a)" + "%1$d vykázáni" + "%1$d vykázáných" + + "Zkontrolujte pravopis nebo zkuste nové vyhledávání" + "Žádné výsledky pro “%1$s”" + + "%1$d osoba" + "%1$d osoby" + "%1$d osob" + + "Odebrat a vykázat člena" + "Pouze odebrat člena" + "Zrušit vykázání" + "Pokud budou pozváni, budou se moci do této místnosti znovu připojit." + "Zrušit vykázání z místnosti" + "Vykázaní" + "Členové" + + "%1$d pozván(a)" + "%1$d pozváni" + "%1$d pozvaných" + + "Čekající" + "Správce" + "Moderátor" + "Vlastník" + "Členové místnosti" + "Rušení vykázání %1$s" + "Povolit vlastní nastavení" + "Zapnutím této funkce přepíšete výchozí nastavení" + "Upozornit mě v tomto chatu na" + "Můžete změnit ve vašem %1$s." + "globální nastavení" + "Výchozí nastavení" + "Odebrat vlastní nastavení" + "Při načítání nastavení oznámení došlo k chybě." + "Obnovení výchozího režimu se nezdařilo, zkuste to prosím znovu." + "Nastavení režimu se nezdařilo, zkuste to prosím znovu." + "Váš domovský server tuto možnost nepodporuje v šifrovaných místnostech, v této místnosti nebudete dostávat upozornění." + "Všechny zprávy" + "Pouze zmínky a klíčová slova" + "V této místnosti mě upozornit na" + "Správci" + "Správci a vlastníci" + "Změnit moji roli" + "Degradovat na člena" + "Degradovat na moderátora" + "Moderování členů" + "Zprávy a obsah" + "Moderátoři" + "Vlastníci" + "Oprávnění" + "Obnovit oprávnění" + "Po obnovení oprávnění ztratíte aktuální nastavení." + "Obnovit oprávnění?" + "Role" + "Podrobnosti místnosti" + "Role a oprávnění" + "Přidat adresu" + "Všichni musí požádat o přístup." + "Požádat o připojení" + "Ano, povolit šifrování" + "Po aktivaci nelze šifrování místnosti deaktivovat. Historie zpráv bude viditelná pouze pro členy místnosti od doby, kdy byli pozváni nebo od té doby, co do místnosti vstoupili. +Nikdo kromě členů místnosti nebude moci číst zprávy. To může bránit správnému fungování robotů a propojení. +Nedoporučujeme povolovat šifrování pro místnosti, které může kdokoli najít a vstoupit do nich." + "Povolit šifrování?" + "Jakmile je povoleno, šifrování nelze zakázat." + "Šifrování" + "Povolit koncové šifrování" + "Vstoupit může kdokoli." + "Kdokoliv" + "Vyberte, kteří členové prostorů se k této místnosti mohou připojit bez pozvánky. %1$s" + "Vstoupit mohou pouze pozvaní lidé." + "Pouze pro zvané" + "Přístup" + "Vstoupit může kdokoli z autorizovaných prostorů." + "Vstoupit může kdokoli v %1$s." + "Členové prostoru" + "Prostory nejsou aktuálně podporovány" + "Budete potřebovat adresu místnosti, aby byla viditelná v adresáři místností." + "Adresa" + "Umožněte nalezení této místnosti prohledáním adresáře veřejných místností na %1$s" + "Umožnit nalezení vyhledáváním ve veřejném adresáři." + "Viditelné ve veřejném adresáři" + "Kdokoliv" + "Kdo může číst historii" + "Pouze členové od té doby, co byli pozváni" + "Pouze členové od výběru této možnosti" + "Adresy místností představují způsoby, jak najít místnosti a získat k nim přístup. Díky tomu můžete svoji místnost snadno sdílet s ostatními. +Můžete se rozhodnout publikovat svou místnost ve veřejném adresáři místnosti vašeho domovského serveru." + "Publikování místnosti" + "Adresy slouží k vyhledávání a přístupu do místností a prostorů. Díky tomu je také můžete snadno sdílet s ostatními." + "Viditelnost" + "Zabezpečení a soukromí" + diff --git a/features/roomdetails/impl/src/main/res/values-cy/translations.xml b/features/roomdetails/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..97d3950 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,154 @@ + + + "Bydd angen cyfeiriad ystafell arnoch i\'w wneud yn weladwy yn y cyfeiriadur." + "Cyfeiriad yr ystafell" + "Digwyddodd gwall wrth ddiweddaru\'r gosodiad hysbysu." + "Nid yw eich gweinydd cartref yn cefnogi\'r dewis hwn mewn ystafelloedd sydd wedi\'u hamgryptio, efallai fyddwch chi ddim yn cael gwybod mewn rhai ystafelloedd." + "Pleidleisiau" + "Gweinyddwyr yn unig" + "Gwahardd pobl" + "Tynnu negeseuon" + "Pawb" + "Gwahodd pobl a derbyn ceisiadau i ymuno" + "Cymedroli aelodau" + "Negeseuon a chynnwys" + "Gweinyddwyr a chymedrolwyr" + "Dileu pobl a gwrthod ceisiadau i ymuno" + "Newid afatar ystafell" + "Ystafell Golygu" + "Newid enw\'r ystafell" + "Newid pwnc yr ystafell" + "Anfon negeseuon" + "Golygu Gweinyddwyr" + "Fyddwch chi ddim yn gallu dadwneud y weithred hon. Rydych chi\'n hyrwyddo\'r defnyddiwr i gael yr un lefel pŵer â chi." + "Ychwanegu Gweinyddwr?" + "Fyddwch chi ddim yn gallu dadwneud y weithred hon. Rydych yn trosglwyddo\'r berchnogaeth i\'r defnyddwyr a ddewiswyd. Unwaith y byddwch yn gadael bydd hyn yn barhaol." + "Trosglwyddo perchnogaeth?" + "Gostwng" + "Fyddwch chi ddim yn gallu dadwneud y newid hwn gan eich bod yn israddio eich hun, os mai chi yw\'r defnyddiwr breintiedig olaf yn yr ystafell bydd yn amhosibl adennill breintiau." + "Israddio eich hun?" + "%1$s (Yn aros)" + "Yn aros" + "Mae gan weinyddwyr freintiau cymedrolwr yn awtomatig" + "Mae gan berchnogion freintiau gweinyddwr yn awtomatig." + "Golygu Cymedrolwyr" + "Dewiswch Berchnogion" + "Gweinyddwyr" + "Cymedrolwyr" + "Aelodau" + "Mae gennych newidiadau heb eu cadw." + "Cadw\'r newidiadau?" + "Ychwanegu pwnc" + "Wedi\'i amgryptio" + "Heb ei amgryptio" + "Ystafell gyhoeddus" + "Ystafell Golygu" + "Roedd gwall anhysbys ac nid oedd modd newid y manylion." + "Methu diweddaru\'r ystafell" + "Negeseuon yn cael eu diogelu gyda chloeon. Dim ond chi a\'r derbynwyr sydd â\'r allweddi unigryw i\'w datgloi." + "Galluogwyd amgryptio neges" + "Digwyddodd gwall wrth lwytho gosodiadau hysbysu." + "Wedi methu tewi\'r ystafell hon, ceisiwch eto." + "Wedi methu dad-dewi\'r ystafell hon, ceisiwch eto." + "Peidiwch â chau\'r ap nes ei fod wedi gorffen." + "Wrthi\'n paratoi gwahoddiadau…" + "Gwahodd pobl" + "Gadael y sgwrs" + "Gadael yr ystafell" + "Cyfryngau a ffeiliau" + "Cyfaddas" + "Rhagosodiad" + "Hysbysiadau" + "Negeseuon wedi\'u pinio" + "Proffil" + "Ceisiadau i ymuno" + "Rolau a chaniatâd" + "Enw\'r ystafell" + "Diogelwch a phreifatrwydd" + "Diogelwch" + "Rhannu ystafell" + "Manylion ystafell" + "Pwnc" + "Wrthi\'n diweddaru ystafell…" + "Nid oes unrhyw ddefnyddwyr gwaharddedig yn yr ystafell hon." + + "%1$d personau" + "%1$d person" + "%1$d berson" + "%1$d person" + "%1$d pherson" + "%1$d person" + + "Gwahardd o ystafell" + "Dileu aelod yn unig" + "Adfer" + "Fyddan nhw yn gallu ymuno â\'r ystafell hon eto os cân nhw wahoddiad." + "Dad-wahardd o\'r ystafell" + "Wedi\'i wahardd" + "Aelodau" + "Gweinyddwyr yn unig" + "Gweinyddwyr a chymedrolwyr" + "Perchennog" + "Aelodau\'r ystafell" + "Dad-wahardd %1$s" + "Caniatáu gosodiad personol" + "Bydd troi hwn ymlaen yn diystyru eich gosodiad rhagosodedig" + "Rhowch wybod i mi yn y sgwrs hon am" + "Gallwch ei newid yn eich %1$s." + "gosodiadau cyffredinol" + "Gosodiad rhagosodedig" + "Dileu gosodiad personol" + "Digwyddodd gwall wrth lwytho gosodiadau hysbysu." + "Wedi methu ag adfer y modd rhagosodedig, ceisiwch eto." + "Wedi methu gosod y modd, ceisiwch eto." + "Nid yw eich gweinydd cartref yn cefnogi\'r dewis hwn mewn ystafelloedd sydd wedi\'u hamgryptio, chewch chi ddim eich hysbysu yn yr ystafell hon." + "Pob neges" + "Crybwylliadau ac Allweddeiriau\'n unig" + "Yn yr ystafell hon, rhowch wybod i mi am" + "Gweinyddwyr" + "Gweinyddwyr a pherchnogion" + "Newid fy rôl" + "Israddio aelod" + "Israddio cymedrolwr" + "Cymedroli aelodau" + "Negeseuon a chynnwys" + "Cymedrolwyr" + "Perchnogion" + "Ailosod caniatâd" + "Ar ôl i chi ailosod caniatâd, byddwch yn colli\'r gosodiadau cyfredol." + "Ailosod caniatâd?" + "Rolau" + "Manylion yr ystafell" + "Rolau a chaniatâd" + "Ychwanegu cyfeiriad ystafell" + "Gall unrhyw un ofyn am gael ymuno â\'r ystafell ond bydd rhaid i weinyddwr neu gymedrolwr dderbyn y cais." + "Gofyn i gael ymuno" + "Iawn, galluogi amgryptio" + "Unwaith y bydd wedi\'i alluogi, does dim modd analluogi amgryptio ar gyfer ystafell, dim ond ar gyfer aelodau\'r ystafell y bydd hanes neges yn weladwy ers iddyn nhw gael eu gwahodd neu ers iddyn nhw ymuno â\'r ystafell. +Fydd neb ar wahân i aelodau\'r ystafell yn gallu darllen negeseuon. Gall hyn atal botiau a phontydd i weithio\'n gywir. +Nid ydym yn argymell galluogi amgryptio ar gyfer ystafelloedd y gall unrhyw un ddod o hyd iddynt ac ymuno â nhw." + "Galluogi amgryptio?" + "Unwaith y bydd wedi\'i alluogi, does dim modd analluogi amgryptio." + "Amgryptiad" + "Galluogi amgryptio o\'r dechrau i\'r diwedd" + "Gall unrhyw un ddod o hyd iddo ac ymuno" + "Unrhyw un" + "Dim ond os cawn nhw wahoddiad gall pobl ymuno" + "Gwahoddiad yn unig" + "Mynediad ystafell" + "Aelodau gofod" + "Nid yw gofodau\'n cael eu cefnogi ar hyn o bryd" + "Bydd angen cyfeiriad ystafell arnoch i\'w wneud yn weladwy yn y cyfeiriadur." + "Cyfeiriad yr ystafell" + "Caniatáu i\'r ystafell hon gael ei chanfod trwy chwilio cyfeiriadur ystafelloedd cyhoeddus %1$s" + "Gweladwy yn y cyfeiriadur ystafelloedd cyhoeddus" + "Unrhyw un" + "Pwy all ddarllen hanes" + "Yn aelodau ond dim ond ers cael eu gwahodd" + "Yn aelodau dim ond ers dewis y dewis hwn" + "Mae cyfeiriadau ystafelloedd yn ffyrdd o ddod o hyd i ystafelloedd a chael mynediad iddyn nhw. Mae hyn hefyd yn sicrhau y gallwch chi rannu\'ch ystafell yn hawdd ag eraill. +Gallwch ddewis cyhoeddi eich ystafell yng ngweinydd cartref eich cyfeiriadur ystafelloedd cyhoeddus." + "Cyhoeddi ystafell" + "Gwelededd yr ystafell" + "Diogelwch a phreifatrwydd" + diff --git a/features/roomdetails/impl/src/main/res/values-da/translations.xml b/features/roomdetails/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..c2ed3ca --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,146 @@ + + + "Du skal bruge en rum-adresse for at gøre den synlig i kataloget." + "Rummets adresse" + "Der opstod en fejl under opdatering af notifikationsindstillingen." + "Din hjemmeserver understøtter ikke denne mulighed i krypterede rum, og derfor er det muligt at du ikke får besked i alle rum." + "Afstemninger" + "Kun admins" + "Spær brugere" + "Fjern beskeder" + "Invitér personer og acceptér anmodninger om at deltage" + "Beskeder og indhold" + "Admins og moderatorer" + "Fjern personer og afvis anmodninger om at deltage" + "Skift rummets avatar" + "Rediger rum" + "Skift rummets navn" + "Skift emne for rummet" + "Send beskeder" + "Redigér admins" + "Du kan ikke fortryde denne handling. Du forfremmer brugeren til at have samme magtniveau som dig." + "Tilføj Admin?" + "Du kan ikke fortryde denne handling. Du overfører ejerskabet til de valgte brugere. Når du forlader siden, vil dette være permanent." + "Overdrag ejerskab?" + "Nedgradering" + "Du vil ikke være i stand til at fortryde denne ændring, da du degraderer dig selv. Hvis du er den sidste privilegerede bruger i rummet, vil det være umuligt at genvinde privilegier." + "Nedgrader dig selv?" + "%1$s (Afventer)" + "(Afventer)" + "Administratorer har automatisk moderatorrettigheder" + "Ejere har automatisk administratorrettigheder." + "Redigér moderatorer" + "Vælg ejere" + "Administratorer" + "Moderatorer" + "Medlemmer" + "Du har ændringer, der ikke er gemt." + "Gem ændringer?" + "Tilføj emne" + "Krypteret" + "Ikke krypteret" + "Offentligt rum" + "Rediger rum" + "Der opstod en ukendt fejl, og oplysningerne kunne ikke ændres." + "Rummet kunne ikke opdateres" + "Beskeder er sikret med låse. Kun du og modtagerne har de unikke nøgler til at låse dem op." + "Beskedkryptering aktiveret" + "Der opstod en fejl under indlæsning af notifikationsindstillinger." + "Det lykkedes ikke at slå lyden fra for dette rum. Prøv igen." + "Det lykkedes ikke at slå lyden til igen i dette rum. Prøv igen." + "Luk ikke appen, før den er færdig." + "Forbereder invitationer…" + "Invitér andre" + "Forlad samtalen" + "Forlad rum" + "Medier og filer" + "Brugerdefineret" + "Standard" + "Notifikationer" + "Fastgjorte beskeder" + "Profil" + "Anmodninger om at deltage" + "Roller og tilladelser" + "Navn på rum" + "Sikkerhed og privatliv" + "Sikkerhed" + "Del rum" + "Informationer om rummet" + "Emne" + "Opdaterer rum…" + "Der er ingen spærrede brugere i dette rum." + + "%1$d person" + "%1$d personer" + + "Spær fra rum" + "Fjern kun medlem" + "Fjern spærring af" + "De vil være i stand til at deltage i dette rum igen, hvis de inviteres." + "Fjern brugerens spærring fra rummet" + "Spærret" + "Medlemmer" + "Kun admins" + "Admins og moderatorer" + "Ejeren" + "Medlemmer af rummet" + "Ophæver spærring af %1$s" + "Tillad brugerdefineret indstilling" + "Hvis du aktiverer dette, tilsidesættes din standardindstilling" + "Giv mig besked i denne samtale for" + "Du kan ændre det i din %1$s." + "globale indstillinger" + "Standardindstilling" + "Fjern brugerdefineret indstilling" + "Der opstod en fejl under indlæsning af meddelelsesindstillinger." + "Gendannelse af standardtilstanden mislykkedes. Prøv igen." + "Kunne ikke indstille tilstanden. Prøv igen." + "Din hjemmeserver understøtter ikke denne indstilling i krypterede rum, du får ikke besked i dette rum." + "Alle beskeder" + "Kun omtaler og nøgleord" + "Giv mig besked i dette rum for" + "Administratorer" + "Administratorer og ejere" + "Skift min rolle" + "Nedgrader til medlem" + "Nedgradering til moderator" + "Moderation af medlemmer" + "Beskeder og indhold" + "Moderatorer" + "Ejere" + "Nulstil tilladelser" + "Når du nulstiller tilladelserne, mister du de nuværende indstillinger." + "Nulstil tilladelser?" + "Roller" + "Detaljer om rummet" + "Roller og tilladelser" + "Tilføj adresse på rum" + "Alle kan bede om at deltage i lokalet, men en administrator eller moderator skal acceptere anmodningen." + "Spørg om at deltage" + "Ja, aktivér kryptering" + "Når det først er aktiveret, kan kryptering for et rum ikke deaktiveres igen. Beskedhistorik vil kun være synlig for rummedlemmer, siden de blev inviteret, eller siden de blev medlem af rummet. +Ingen udover medlemmer af rummet vil være i stand til at læse beskeder. Dette kan forhindre bots og broer i at fungere korrekt. +Vi anbefaler ikke at aktivere kryptering for rum, som alle kan finde og deltage i." + "Aktivér kryptering?" + "Når kryptering først er aktiveret, kan den ikke deaktiveres igen." + "Kryptering" + "Aktivér end-to-end-kryptering" + "Alle kan finde og deltage" + "Enhver" + "Andre kan kun deltage, hvis de bliver inviteret" + "Kun med invitation" + "Adgang til rummet" + "Medlemmer af gruppen" + "Grupper understøttes ikke i øjeblikket" + "Du skal bruge en rum-adresse for at gøre den synlig i kataloget." + "Tillad, at dette rum kan findes ved at søge i %1$s fortegnelse over offentlige rum" + "Synlig i det offentlige register over rum" + "Enhver" + "Hvem kan læse historikken?" + "Kun medlemmer, efter de blev inviteret" + "Kun medlemmer siden valg af denne mulighed" + "Rum-adresser er en måde at finde og få adgang til værelser på. Dette sikrer også, at du nemt kan dele dit rum med andre. +Du kan vælge at offentliggøre dit rum i din hjemmeservers offentlige katalog over rum." + "Udgivelse af rum" + "Sikkerhed og privatliv" + 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 0000000..89ef69c --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,148 @@ + + + "Du benötigst eine Chat-Adresse, um den Chat im Verzeichnis sichtbar zu machen." + "Chat-Adresse" + "Beim Aktualisieren der Benachrichtigungseinstellungen ist ein Fehler aufgetreten." + "Dein Homeserver unterstützt diese Option in verschlüsselten Chats nicht. In einigen Chats erhältst du möglicherweise keine Benachrichtigungen." + "Umfragen" + "Nur Admins" + "Mitglieder sperren" + "Nachrichten entfernen" + "Personen einladen und Beitrittsanfragen annehmen" + "Nachrichten senden & löschen" + "Admins und Moderatoren" + "Personen entfernen und Beitrittsanfragen ablehnen" + "Avatar ändern" + "Chat bearbeiten" + "Chat-Namen ändern" + "Chat Thema ändern" + "Nachrichten senden" + "Admins bearbeiten" + "Du kannst diese Aktion nicht mehr rückgängig machen. Du vergibst dieselbe Rolle, die du auch hast." + "Als Admin hinzufügen?" + "Du kannst diese Aktion nicht rückgängig machen. Du überträgst die Eigentumsrechte an die ausgewählten Nutzer. Sobald du diesen Vorgang abschließt, ist er endgültig." + "Eigentumsrechte übertragen?" + "Zurückstufen" + "Du stufst dich selbst herab. Diese Änderung kann nicht rückgängig gemacht werden. Wenn du der letzte Nutzer mit dieser Rolle bist, ist es nicht möglich, diese Rolle wiederzuerlangen." + "Möchtest du dich selbst herabstufen?" + "%1$s (Ausstehend)" + "(Ausstehend)" + "Admins haben automatisch Moderatorenrechte" + "Eigentümer haben automatisch Adminrechte." + "Moderatoren bearbeiten" + "Wähle Eigentümer" + "Admins" + "Moderatoren" + "Mitglieder" + "Du hast nicht gespeicherte Änderungen." + "Änderungen speichern?" + "Thema hinzufügen" + "Verschlüsselt" + "Nicht verschlüsselt" + "Öffentlicher Chat" + "Chat bearbeiten" + "Es ist ein unbekannter Fehler aufgetreten und die Informationen konnten nicht geändert werden." + "Chat kann nicht aktualisiert werden" + "Nachrichten und Anrufe sind Ende-zu-Ende verschlüsselt. Nur du und die Empfänger haben die eindeutigen Schlüssel, um sie zu entsperren." + "Nachrichtenverschlüsselung aktiviert" + "Beim Laden der Benachrichtigungseinstellungen ist ein Fehler aufgetreten." + "Die Stummschaltung ist fehlgeschlagen, bitte versuche es erneut." + "Die Deaktivierung der Stummschaltung ist fehlgeschlagen, bitte versuche es erneut." + "Schließ die App erst, wenn du fertig bist." + "Einladungen werden vorbereitet…" + "Nutzer einladen" + "Unterhaltung verlassen" + "Verlassen" + "Medien und Dateien" + "Benutzerdefiniert" + "Standard" + "Benachrichtigungen" + "Fixierte Nachrichten" + "Profil" + "Beitrittsanfragen" + "Rollen und Berechtigungen" + "Chat-Name" + "Sicherheit & Datenschutz" + "Sicherheit" + "Teilen" + "Informationen" + "Thema" + "Chat wird aktualisiert…" + "Es gibt keine gesperrten Nutzer." + + "%1$d Person" + "%1$d Personen" + + "Mitglied entfernen und sperren" + "Mitglied nur entfernen" + "Sperre aufheben" + "Die Nutzer können dem Chat wieder beitreten, wenn sie eingeladen werden." + "Sperre für diesen Chat aufheben" + "Gesperrt" + "Mitglieder" + "Nur Admins" + "Admins und Moderatoren" + "Eigentümer" + "Mitglieder" + "%1$s wird entsperrt." + "Benutzerdefinierte Einstellungen verwenden" + "Wenn du dies einschaltest, werden deine Standardeinstellungen außer Kraft setzen." + "Benachrichtige mich in diesem Chat bei" + "Zum Anpassen der Standardeinstellungen gehe zu: %1$s" + "Globale Einstellungen" + "Standardeinstellung" + "Benutzerdefinierte Einstellung entfernen" + "Beim Laden der Benachrichtigungseinstellungen ist ein Fehler aufgetreten." + "Fehler beim Wiederherstellen des Standardmodus. Bitte erneut versuchen." + "Fehler beim Einstellen des Modus. Bitte erneut versuchen." + "Dein Homeserver unterstützt diese Option in verschlüsselten Chats nicht. Du erhältst in diesem Chat keine Benachrichtigungen." + "Alle Nachrichten" + "Nur Erwähnungen und Schlüsselwörter" + "Benachrichtige mich bei" + "Admins" + "Admins und Eigentümer" + "Ändere meine Rolle" + "Zum Mitglied herabstufen" + "Zum Moderator herabstufen" + "Moderation der Mitglieder" + "Nachrichten senden & löschen" + "Moderatoren" + "Eigentümer" + "Rollen und Berechtigungen zurücksetzen" + "Sobald du die Berechtigungen zurücksetzt, verlierst du die aktuellen Einstellungen." + "Berechtigungen zurücksetzen?" + "Rollen" + "Chat-Details anpassen" + "Rollen und Berechtigungen" + "Chat-Adresse hinzufügen" + "Jeder kann den Beitritt zum Chat anfragen, aber ein Admin oder Moderator müssen die Anfrage akzeptieren." + "Beitritt beantragen" + "Ja, Verschlüsselung aktivieren" + "Einmal angeschaltet kann die Verschlüsselung für einen Chat nicht mehr deaktiviert werden. Der Nachrichtenverlauf ist für Mitglieder nur sichtbar, seit sie eingeladen wurden oder dem Chat beigetreten sind. +Niemand außer den Chat Mitgliedern kann Nachrichten lesen. Dies kann verhindern, dass Bots und Bridges richtig funktionieren. +Wir empfehlen keine Verschlüsselung für Chats zu aktivieren, die jeder finden und denen jeder beitreten darf." + "Verschlüsselung aktivieren?" + "Einmal angeschaltet kann die Verschlüsselung nicht mehr deaktiviert werden." + "Verschlüsselung" + "Ende-zu-Ende-Verschlüsselung aktivieren" + "Jeder kann diesen Chat finden und ihm beitreten" + "Jeder" + "Personen können nur beitreten, wenn sie eingeladen werden." + "Nur auf Einladung" + "Chat Zugang" + "Spacemitglieder" + "Spaces werden zur Zeit nicht unterstützt." + "Du benötigst eine Chat-Adresse, um den Chat im Verzeichnis sichtbar zu machen." + "Chatroomadresse" + "Erlaube das Auffinden dieses Chats durch Suche im öffentlichen Verzeichnis von %1$s" + "Sichtbar im öffentlichen Verzeichnis" + "Jeder" + "Wer hat Zugriff auf den Nachrichtenverlauf" + "Nur Mitglieder, aber erst seit deren Einladung" + "Nur Mitglieder seit Auswahl dieser Option" + "Chat-Adressen machen es möglich, Chats zu finden und ihnen beizutreten. Dies erleichtert es, Chats mit anderen zu teilen. +Auf Wunsch kannst du deinen Chat im öffentlichen Verzeichnis deines Homeservers veröffentlichen." + "Veröffentlichung von Chats" + "Chatroomsichtbarkeit." + "Sicherheit & Datenschutz" + diff --git a/features/roomdetails/impl/src/main/res/values-el/translations.xml b/features/roomdetails/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..a2deb1c --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,137 @@ + + + "Θα χρειαστείτε μια διεύθυνση αίθουσας για να την κάνετε ορατή στον κατάλογο." + "Διεύθυνση αίθουσας" + "Παρουσιάστηκε σφάλμα κατά την ενημέρωση της ρύθμισης ειδοποίησης." + "Ο αρχικός διακομιστής σας δεν υποστηρίζει αυτή την επιλογή σε κρυπτογραφημένες αίθουσες, ενδέχεται να μην λαμβάνετε ειδοποιήσεις σε ορισμένες αίθουσες." + "Δημοσκοπήσεις" + "Μόνο διαχειριστές" + "Αποκλεισμός ατόμων" + "Αφαίρεση μηνυμάτων" + "Προσκάλεσε άτομα και αποδέξου αιτήματα συμμετοχής" + "Μηνύματα και περιεχόμενο" + "Διαχειριστές και συντονιστές" + "Αφαίρεση ατόμων και απόρριψη αιτημάτων συμμετοχής" + "Αλλαγή εικόνας προφίλ αίθουσας" + "Επεξεργασία Αίθουσας" + "Αλλαγή ονόματος αίθουσας" + "Αλλαγή θέματος αίθουσας" + "Αποστολή μηνυμάτων" + "Επεξεργασία Διαχειριστών" + "Δεν θα μπορείς να αναιρέσεις αυτήν την ενέργεια. Προβιβάζεις τον χρήστη να έχει το ίδιο επίπεδο ισχύος με σένα." + "Προσθήκη Διαχειριστή;" + "Υποβιβασμός" + "Δεν θα μπορέσετε να αναιρέσετε αυτή την αλλαγή καθώς υποβιβάζετε τον εαυτό σας, αν είστε ο τελευταίος χρήστης με δικαιώματα στην αίθουσα θα είναι αδύνατο να ανακτήσετε δικαιώματα." + "Υποβιβασμός του εαυτού σου;" + "%1$s (Σε αναμονή)" + "(Σε αναμονή)" + "Οι διαχειριστές έχουν αυτόματα δικαιώματα συντονιστή" + "Επεξεργασία Συντονιστών" + "Διαχειριστές" + "Συντονιστές" + "Μέλη" + "Έχεις μη αποθηκευμένες αλλαγές." + "Αποθήκευση αλλαγών;" + "Προσθήκη θέματος" + "Κρυπτογραφημένο" + "Μη κρυπτογραφημένο" + "Δημόσια αίθουσα" + "Επεξεργασία Αίθουσας" + "Υπήρξε ένα άγνωστο σφάλμα και οι πληροφορίες δεν μπορούσαν να αλλάξουν." + "Αδυναμία ενημέρωσης αίθουσας" + "Τα μηνύματα ασφαλίζονται με κλειδαριές. Μόνο εσύ και οι παραλήπτες έχετε τα μοναδικά κλειδιά για να τα ξεκλειδώσετε." + "Ενεργοποιημένη κρυπτογράφηση μηνυμάτων" + "Παρουσιάστηκε σφάλμα κατά τη φόρτωση των ρυθμίσεων ειδοποίησης." + "Η σίγαση αυτής της αίθουσας απέτυχε, δοκιμάστε ξανά." + "Η κατάργηση σίγασης αυτής της αίθουσας απέτυχε, δοκιμάστε ξανά." + "Πρόσκληση ατόμων" + "Αποχώρηση από τη συζήτηση" + "Αποχώρηση από την αίθουσα" + "Πολυμέσα και αρχεία" + "Προσαρμοσμένο" + "Προεπιλογή" + "Ειδοποιήσεις" + "Καρφιτσωμένα μηνύματα" + "Προφίλ" + "Αιτήματα συμμετοχής" + "Ρόλοι και δικαιώματα" + "Όνομα αίθουσας" + "Ασφάλεια & απόρρητο" + "Ασφάλεια" + "Κοινή χρήση αίθουσας" + "Πληροφορίες αίθουσας" + "Θέμα" + "Ενημέρωση αίθουσας…" + "Δεν υπάρχουν αποκλεισμένοι χρήστες σε αυτή την αίθουσα." + + "%1$d άτομο" + "%1$d άτομα" + + "Αφαίρεση και αποκλεισμός μέλους" + "Μόνο αφαίρεση μέλους" + "Αναίρεση αποκλεισμού" + "Θα μπορούν να συμμετάσχουν ξανά σε αυτή την αίθουσα, εάν προσκληθούν." + "Άρση αποκλεισμού από την αίθουσα" + "Αποκλεισμένοι" + "Μέλη" + "Μόνο διαχειριστές" + "Διαχειριστές και συντονιστές" + "Μέλη της αίθουσας" + "Άρση αποκλεισμού %1$s" + "Να επιτρέπεται η προσαρμοσμένη ρύθμιση" + "Η ενεργοποίηση αυτής της ρύθμισης θα παρακάμψει την προεπιλεγμένη ρύθμιση" + "Ειδοποιήσε με σε αυτήν τη συνομιλία για" + "Μπορείς να το αλλάξεις στο δικό σου %1$s." + "καθολικές ρυθμίσεις" + "Προεπιλεγμένη ρύθμιση" + "Κατάργηση προσαρμοσμένης ρύθμισης" + "Παρουσιάστηκε σφάλμα κατά τη φόρτωση των ρυθμίσεων ειδοποίησης." + "Αποτυχία επαναφοράς της προεπιλεγμένης λειτουργίας, δοκίμασε ξανά." + "Αποτυχία ρύθμισης της λειτουργίας, δοκίμασε ξανά." + "Ο αρχικός διακομιστής σας δεν υποστηρίζει αυτή την επιλογή σε κρυπτογραφημένες αίθουσες, δεν θα λάβετε ειδοποιήσεις σε αυτή την αίθουσα." + "Όλα τα μηνύματα" + "Μόνο αναφορές και λέξεις-κλειδιά" + "Σε αυτήν την αίθουσα, ειδοποιήστε με για" + "Διαχειριστές" + "Άλλαξε τον ρόλο μου" + "Υποβιβασμός σε μέλος" + "Υποβιβασμός σε συντονιστή" + "Συντονισμός μελών" + "Μηνύματα και περιεχόμενο" + "Συντονιστές" + "Επαναφορά δικαιωμάτων" + "Μόλις επαναφέρεις τα δικαιώματα, θα χάσεις τις τρέχουσες ρυθμίσεις." + "Επαναφορά δικαιωμάτων;" + "Ρόλοι" + "Λεπτομέρειες αίθουσας" + "Ρόλοι και δικαιώματα" + "Προσθήκη διεύθυνσης αίθουσας" + "Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στην αίθουσα, αλλά ένας διαχειριστής ή συντονιστής θα πρέπει να αποδεχτεί το αίτημα." + "Αίτημα συμμετοχής" + "Ναι, ενεργοποιήστε την κρυπτογράφηση" + "Μόλις ενεργοποιηθεί, η κρυπτογράφηση για μια αίθουσα δεν μπορεί να απενεργοποιηθεί, το ιστορικό μηνυμάτων θα είναι ορατό μόνο για τα μέλη της αίθουσας από τότε που προσκλήθηκαν ή από τότε που συμμετείχαν στην αίθουσα. +Κανείς άλλος εκτός από τα μέλη της αίθουσας δεν θα μπορεί να διαβάσει τα μηνύματα. Αυτό μπορεί να εμποδίσει τη σωστή λειτουργία των bots και των γεφυρών. +Δεν συνιστούμε την ενεργοποίηση της κρυπτογράφησης για αίθουσες που μπορεί να βρει και να συμμετάσχει ο καθένας." + "Ενεργοποίηση κρυπτογράφησης;" + "Μόλις ενεργοποιηθεί, η κρυπτογράφηση δεν μπορεί να απενεργοποιηθεί." + "Κρυπτογράφηση" + "Ενεργοποίηση κρυπτογράφησης από άκρο σε άκρο" + "Οποιοσδήποτε μπορεί να βρει και να συμμετάσχει" + "Οποιοσδήποτε" + "Τα άτομα μπορούν να συμμετάσχουν μόνο εάν έχουν προσκληθεί" + "Μόνο πρόσκληση" + "Πρόσβαση στην αίθουσα" + "Μέλη χώρου" + "Οι χώροι δεν υποστηρίζονται προς το παρόν" + "Θα χρειαστείτε μια διεύθυνση αίθουσας για να την κάνετε ορατή στον κατάλογο." + "Επιστρέψτε την εύρεση αυτής της αίθουσας με αναζήτηση στον κατάλογο %1$s δημοσίων αιθουσών" + "Ορατή στον κατάλογο δημόσιων αιθουσών" + "Οποιοσδήποτε" + "Ποιος μπορεί να διαβάσει το ιστορικό" + "Μόνο μέλη από τη στιγμή που προσκλήθηκαν" + "Μόνο για μέλη μετά από αυτήν την επιλογή" + "Οι διευθύνσεις αιθουσών είναι τρόποι εύρεσης και πρόσβασης σε αίθουσες. Αυτό διασφαλίζει επίσης ότι μπορείτε εύκολα να μοιραστείτε την αίθουσα με άλλους. +Μπορείτε να επιλέξετε να δημοσιεύσετε την αίθουσά σας στον δημόσιο κατάλογο αιθουσών του αρχικού διακομιστή σας." + "Δημοσίευση αίθουσας" + "Ασφάλεια & απόρρητο" + 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 0000000..c179a4d --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,137 @@ + + + "Necesitarás una dirección de sala para que sea visible en el directorio." + "Dirección de la sala" + "Se ha producido un error al actualizar la configuración de notificaciones." + "Tu servidor base no admite esta opción en salas cifradas, puede que no recibas notificaciones de algunas salas." + "Encuestas" + "Solo administradores" + "Vetar personas" + "Eliminar mensajes" + "Invitar personas y aceptar solicitudes de unión" + "Mensajes y contenido" + "Administradores y moderadores" + "Eliminar personas y rechazar solicitudes de unión" + "Cambiar el avatar de la sala" + "Editar sala" + "Cambiar el nombre de la sala" + "Cambiar el tema de la sala" + "Enviar mensajes" + "Editar administradores" + "No podrás deshacer esta acción. Estás promocionando al usuario para que tenga el mismo nivel de poder que tú." + "¿Agregar Admin?" + "Degradar" + "No podrás deshacer este cambio ya que te estás degradando. Si eres el último usuario privilegiado en la sala será imposible recuperar los privilegios." + "¿Degradarte?" + "%1$s (Pendiente)" + "(Pendiente)" + "Los administradores tienen privilegios de moderador de forma automática" + "Editar moderadores" + "Administradores" + "Moderadores" + "Miembros" + "Tienes cambios sin guardar." + "¿Guardar cambios?" + "Añadir tema" + "Cifrada" + "No cifrada" + "Sala pública" + "Editar sala" + "Se ha producido un error desconocido y no se ha podido cambiar la información." + "No se puede actualizar la sala" + "Los mensajes están protegidos con \"candados\". Sólo tú y los destinatarios tenéis las llaves únicas para abrirlos." + "Cifrado de mensajes activado" + "Se ha producido un error al cargar la configuración de las notificaciones." + "No se ha podido silenciar esta sala, inténtalo de nuevo." + "Error al dejar de silenciar esta sala, por favor inténtalo de nuevo." + "Invitar personas" + "Salir de la conversación" + "Salir de la sala" + "Medios y archivos" + "Personalizado" + "Por defecto" + "Notificaciones" + "Mensajes fijados" + "Perfil" + "Solicitudes de unión" + "Roles y permisos" + "Nombre de la sala" + "Seguridad y privacidad" + "Seguridad" + "Compartir sala" + "Información de la sala" + "Tema" + "Actualizando la sala…" + "No hay usuarios vetados en esta sala." + + "Una persona" + "%1$d personas" + + "Sacar y vetar a un miembro" + "Solo eliminar miembro" + "Quitar veto" + "Podrá volver a unirse a esta sala si se le invita." + "Eliminar veto en la sala" + "Vetados" + "Miembros" + "Solo administradores" + "Administradores y moderadores" + "Miembros de la sala" + "Levantando veto a %1$s" + "Permitir configuración personalizada" + "Si activas esta opción, anularás tu configuración por defecto" + "Notificarme en este chat para" + "Puedes cambiarlo en tus %1$s." + "ajustes globales" + "Ajustes predeterminados" + "Eliminar configuración personalizada" + "Se ha producido un error al cargar la configuración de las notificaciones." + "No se pudo restaurar el modo predeterminado, por favor vuelve a intentarlo de nuevo." + "No se pudo cambiar el modo, por favor inténtalo de nuevo." + "Tu servidor base no admite esta opción en salas cifradas, no recibirás notificaciones de esta sala." + "Todos los mensajes" + "Únicamente Menciones y Palabras clave" + "En esta sala, notificarme por" + "Administradores" + "Cambiar mi rol" + "Degradar a miembro" + "Degradar a moderador" + "Moderación de miembros" + "Mensajes y contenido" + "Moderadores" + "Restablecer permisos" + "Una vez que restablezca los permisos, perderá la configuración actual." + "¿Restablecer los permisos?" + "Roles" + "Detalles de la sala" + "Roles y permisos" + "Agregar dirección de sala" + "Cualquiera puede solicitar unirse a la sala, pero un administrador o moderador tendrá que aceptar la solicitud." + "Solicitud para unirse" + "Sí, activar cifrado" + "Una vez activado, el cifrado de una sala no se puede desactivar. El historial de mensajes solo será visible para los miembros de la sala desde que fueron invitados o desde que se unieron a la sala. +Nadie más que los miembros de la sala podrán leer los mensajes. Esto puede impedir que los bots y los puentes funcionen correctamente. +No recomendamos habilitar el cifrado para las salas que cualquiera pueda encontrar y unirse." + "¿Activar cifrado?" + "Una vez activado, el cifrado no se puede desactivar." + "Cifrado" + "Activar el cifrado de extremo a extremo" + "Cualquiera puede encontrarla y unirse" + "Cualquiera" + "Las personas solo pueden unirse si están invitadas" + "Solo por invitación" + "Acceso a la sala" + "Miembros del espacio" + "No se admiten los espacios por el momento." + "Necesitarás una dirección de sala para que sea visible en el directorio." + "Permite encontrar esta sala buscando en el directorio de salas públicas de %1$s" + "Visible en el directorio de salas públicas" + "Cualquiera" + "Quién puede leer el historial" + "Solo participantes desde que fueron invitados" + "Solo participantes desde que se selecciona esta opción" + "Las direcciones de sala son formas de buscar salas y acceder a ellas. Esto también garantiza que puedas compartir fácilmente tu sala con otras personas. +Puedes optar por publicar tu sala en el directorio de salas públicas de tu servidor base." + "Publicación de la sala" + "Seguridad y privacidad" + diff --git a/features/roomdetails/impl/src/main/res/values-et/translations.xml b/features/roomdetails/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..7a315de --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,155 @@ + + + "Selleks, et jututuba oleks nähtav jututubade avalikus kataloogis, vajab ta aadressi." + "Muuda aadressi" + "Teavituste seadistamisel tekkis viga" + "Sinu koduserver ei toeta seda funktsionaalsust krüptitud jututubades ja seega ei pruugi kõik teavitused sinuni jõuda." + "Küsitlused" + "Peakasutajad" + "Suhtluskeelu seadmine" + "Eemalda sõnumid" + "Liikmed" + "Osalejate kutsumine" + "Liikmete haldus" + "Sõnumid ja sisu" + "Moderaatorid" + "Osalejate eemaldamine" + "Jututoa tunnuspildi muutmine" + "Muuda üksikasju" + "Jututoa nime muutmine" + "Jututoa teema muutmine" + "Sõnumite saatmine" + "Muuda peakasutajaid" + "Kuna sa annad teisele kasutajale sinu õigustega võrreldes samad õigused, siis sa ei saa seda muudatust hiljem tagasi pöörata." + "Lisame peakasutaja?" + "Seda tegevust ei saa tagasi pöörata. Järgnevaga annad jututoa omandi üle valitud kasutajatele. Kui lahkus, siis muutub see muudatus püsivaks." + "Kas soovid omandi üle anda?" + "Vähenda õigusi" + "Kui sa võtad endalt kõik õigused ära ja oled viimane peakasutaja selles jututoas, siis sa ei saa seda muudatust hiljem tagasi pöörata." + "Kas vähendad enda õigusi?" + "%1$s (ootel)" + "(ootel)" + "Peakasutajatel on automaatselt ka moderaatori õigused" + "Omanikel on automaatselt ka peakasutaja õigused." + "Muuda moderaatoreid" + "Vali omanikud" + "Peakasutajad" + "Moderaatorid" + "Liikmed" + "Sul on salvestamata muudatusi" + "Kas salvestame muudatused?" + "Lisa teema" + "Krüptitud jututuba" + "Krüptimata jututuba" + "Avalik jututuba" + "Muuda üksikasju" + "Tekkis tundmatu viga ja andmed jäid muutmata." + "Jututoa andmete muutmine ei õnnestu" + "Sõnumid on turvatud krüptimise abil. Ainult sinul ja sõnumite saajatel on vajalikud võtmed nende lugemiseks." + "Sõnumite krüptimine on kasutusel" + "Teavituste seadistuste laadimisel tekkis viga." + "Selle jututoa summutamine ei õnnestunud. Palun proovi uuesti." + "Selle jututoa summutamise eemaldamine ei õnnestunud. Palun proovi uuesti." + "Ära sulge rakendust enne, kui tegevus on lõppenud." + "Valmistan kutseid ette…" + "Kutsu osalejaid" + "Lahku vestlusest" + "Lahku jututoast" + "Meedia ja failid" + "Kohandatud" + "Vaikimisi" + "Teavitused" + "Esiletõstetud sõnumid" + "Profiil" + "Liitumispalved" + "Rollid ja õigused" + "Jututoa nimi" + "Turvalisus ja privaatsus" + "Turvalisus" + "Jaga jututuba" + "Jututoa teave" + "Teema" + "Uuendame jututuba…" + "Suhtluskeeluga kasutajaid pole" + "Palun kontrolli otsingusõna korrektsust ja proovi siis uuesti" + "Otsingul „%1$s“ pole tulemusi" + + "%1$d osaleja" + "%1$d osalejat" + + "Eemalda ja sea suhtluskeeld" + "Ainult eemalda kasutaja" + "Eemalda suhtluskeeld" + "Kutse olemasolul saab ta nüüd jututoaga uuesti liituda" + "Eemalda suhtluskeeld jututoas" + "Suhtluskeeluga kasutajad" + "Liikmed" + "Ootel" + "Peakasutajad" + "Moderaatorid" + "Omanik" + "Jututoas osalejad" + "Eemaldame suhtluskeelu kasutajalt %1$s" + "Kasuta kohandatud seadistusi" + "Selle eelistuse valimine asendab vaikimisi seadistused" + "Selles vestluses teavita mind" + "Sa saad seda muuta siin: %1$s" + "üldised teavitused" + "Vaikimisi teavitused" + "Eemalda kohandatud seadistused" + "Teavituste seadistuste laadimisel tekkis viga." + "Vaikimisi seadistuste taastamine ei õnnestunud. Palun proovi uuesti." + "Seadistuste muutmine ei õnnestunud. Palun proovi uuesti." + "Sinu koduserver ei toeta seda võimalust krüptitud jututubades, seega sa ei saa selle jututoa kohta teavitusi." + "Kõikide sõnumite korral" + "Mainimiste ja võtmesõnade alusel" + "Selles jututoas teavita mind" + "Peakasutajad" + "Peakasutajad ja omanikud" + "Muuda minu rolli" + "Muuda tavaliikmeks" + "Muuda moderaatoriks" + "Jututoas osalejate modereerimine" + "Sõnumid ja sisu" + "Moderaatorid" + "Omanikud" + "Õigused" + "Lähtesta õigused" + "Kui lähtestad õigused, siis praegune õiguste kombinatsioon läheb kaotsi." + "Kas lähtestame õigused?" + "Rollid" + "Jututoa üksikasjad" + "Rollid ja õigused" + "Lisa aadress" + "Kõik võivad paluda jututoaga liitumist." + "Küsi võimalust liitumiseks" + "Jah, lülita krüptimine sisse" + "Kui jututoa krüptimine on kord sisse lülitatud, siis seda välja lülitada ei saa. Sõnumite ajalugu on nähtav vaid jututoa liikmetele alates kutse saamise või liitumise hetkest. +Keegi teine peale jututoa liikmete ei saa sõnumeid lugeda. See võib takistada suhtlusrobotite ja/või võrgusildade toimimist. +Me ei soovita krüptimise kasutamist selliste avalike jututubade puhul, millega kõik võivad liituda." + "Kas võtame krüptimise kasutusele?" + "Kui krüptimine on kasutusel, siis seda enam väljalülitada ei saa." + "Krüptimine" + "Võta läbiv krüptimine kasutusele" + "Kõik võivad jututoaga liituda" + "Kõik" + "Liituda saab vaid kutse olemasolul" + "Vaid kutsega" + "Ligipääs" + "Kogukonna liikmed" + "Kogukondade tugi veel puudub" + "Selleks, et jututuba oleks nähtav jututubade avalikus kataloogis, vajab ta aadressi." + "Aadress" + "Võimalda leida seda jututuba avalikust kataloogist otsides „%1$s“" + "Luba leitavus avaliku kataloogi otsingust." + "Nähtav avalikus kataloogis" + "Kõik" + "Kes võivad lugeda jututoa ajalugu" + "Liikmed peale kutse saamist" + "Liikmed peale selle valiku sisselülitamist" + "Jututoa aadressid annavad võimaluse neid leida ning saada neile ligi. Samuti võimaldab see jututuba teistele huvilistele jagada. +Lisaks võid sa jututoa avaldada oma koduserveri avalikus jututubade kataloogis." + "Jututoa avaldamine" + "Nähtavus" + "Turvalisus ja privaatsus" + diff --git a/features/roomdetails/impl/src/main/res/values-eu/translations.xml b/features/roomdetails/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..5d0f61f --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,121 @@ + + + "Gelaren helbidea" + "Errorea gertatu da jakinarazpen-ezarpena eguneratzean." + "Zure zerbitzaria ez da bateragarria enkriptatutako gelen aukerarekin; litekeena da gela batzuetako jakinarazpenak ez jasotzea." + "Inkestak" + "Administratzaileak soilik" + "Jarri debekua jendeari" + "Kendu mezuak" + "Gonbidatu jendea" + "Mezuak eta edukiak" + "Administratzaileak eta moderatzaileak" + "Kendu jendea" + "Aldatu gelaren abatarra" + "Editatu gela" + "Aldatu gelaren izena" + "Aldatu gelako mintzagaia" + "Bidali mezuak" + "Editatu administratzaileak" + "Administratzailea gehitu?" + "Jabetza eskualdatu?" + "Jaitsi mailaz" + "Ezin izango duzu hau aldatu zure burua mailaz jaisten ari zarelako, zu bazara gelan baimenak dituen azken erabiltzailea ezin izango dira baimenak berreskuratu." + "Zure burua mailaz jaitsi?" + "%1$s (zain)" + "(Egiteke)" + "Administratzaileek automatikoki dute moderatzaile-pribilegioak" + "Editatu moderatzaileak" + "Aukeratu jabeak" + "Administratzaileak" + "Moderatzaileak" + "Kideak" + "Gorde gabeko aldaketak dituzu." + "Aldaketak gorde?" + "Gehitu hizketagaia" + "Zifratuta" + "Zifratu gabe" + "Gela publikoa" + "Editatu gela" + "Errore ezezaguna gertatu da eta ezin izan da informazioa aldatu." + "Ezin da gela eguneratu" + "Mezuen enkriptazioa gaituta dago" + "Errorea gertatu da jakinarazpen-ezarpenak kargatzean." + "Ezin izan da gela mututu; saiatu berriro." + "Ezin izan da gela mututzeari utzi; saiatu berriro." + "Gonbidatu jendea" + "Utzi elkarrizketa" + "Atera gelatik" + "Multimedia eta fitxategiak" + "Lehenetsia" + "Jakinarazpenak" + "Finkatutako mezuak" + "Profila" + "Sartzeko eskaerak" + "Rolak eta baimenak" + "Gelaren izena" + "Segurtasuna eta pribatutasuna" + "Segurtasuna" + "Partekatu gela" + "Gelaren informazioa" + "Gaia" + "Gela eguneratzen…" + "Gela honetan ez dago debekua ezarri zaion erabiltzailerik." + + "Pertsona %1$d" + "%1$d pertsona" + + "Kendu kidea eta ezarri debekua" + "Kendu kidea soilik" + "Kendu debekua" + "Debekatuta" + "Kideak" + "Administratzaileak soilik" + "Administratzaileak eta moderatzaileak" + "Jabea" + "Gelako kideak" + "%1$s(r)i debekua kentzen" + "Aktibatuz gero, defektuzko ezarpena gainidatziko du" + "Jakinarazi txat honetan" + "%1$s alda dezakezu." + "Ezarpen orokorretan" + "Defektuzko ezarpena" + "Errorea gertatu da jakinarazpen-ezarpenak kargatzean." + "Zure zerbitzaria ez da bateragarria enkriptatutako gelen aukerarekin; litekeena da gela batzuetako jakinarazpenak ez jasotzea." + "Mezu guztiak" + "Aipamenak eta hitz gakoak soilik" + "Gela honetan, jakinarazi" + "Administratzaileak" + "Administratzaileak eta jabeak" + "Aldatu nire rola" + "Jaitsi maila, kidera" + "Jaitsi maila, moderatzailera" + "Kideen moderazioa" + "Mezuak eta edukiak" + "Moderatzaileak" + "Jabeak" + "Berrezarri baimenak" + "Baimenak berrezarritakoan, uneko ezarpenak galduko dituzu." + "Baimenak berrezarri?" + "Rolak" + "Gelaren xehetasunak" + "Rolak eta baimenak" + "Gehitu gelaren helbidea" + "Bai, gaitu zifratzea" + "Zifratzea" + "Edonork aurkitu eta bat egin dezake" + "Edonork" + "Gonbidatutako pertsonak bakarrik sartu ahal izango dira" + "Gonbidapen bidez" + "Gelarako sarbidea" + "Guneko kideak" + "Gaur-gaurkoz ez da guneekin bateragarria" + "Gelaren helbidea" + "Gela publikoen direktorioan ikusgai" + "Edonork" + "Nork irakur dezake historia" + "Kideek bakarrik, gonbidatu zituztenetik" + "Kideek bakarrik, aukera hau hautatu zenetik" + "Gelaren ikusgarritasuna" + "Segurtasuna eta pribatutasuna" + diff --git a/features/roomdetails/impl/src/main/res/values-fa/translations.xml b/features/roomdetails/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..c046504 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,134 @@ + + + "نشانی اتاق" + "هنگام به‌روز کردن تنظیمات آگاهی خطایی رخ داد." + "کارساز خانگیتان از این گزینه در اتاق‌های رمز شده پشتیبانی نمی‌کند. ممکن است در برخی اتاق‌ها آگاه نشوید." + "نظرسنجی‌ها" + "فقط مدیران" + "تحریم افراد" + "برداشتن پیام‌ها" + "هرکسی" + "دعوت افراد و پذیرش درخواست‌های پیوستن" + "نظارت اعضا" + "پیام‌ها و محتوا" + "مدیرن و ناظران" + "برداشتن افراد و رد درخواست‌های پیوستن" + "تغییر چهرک اتاق" + "ویرایش اتاق" + "تغییر نام اتاق" + "دگرگونی موضوع اتاق" + "فرستادن پیام‌ها" + "ویرایش مدیران" + "قادر نخواهید بود این کنش را بازکردانید. داردید کاربر را به سطح قدرت خودتان ارتقا می‌دهید." + "افزودن مدیر؟" + "انتقال مالکیت؟" + "تنزل بده" + "شما نمی‌توانید این تغییر را بازگردانید زیرا در حال تنزل نقش خود در اتاق هستید، اگر آخرین کاربر ممتاز در اتاق باشید، امکان دستیابی مجدد به دسترسی‌های سطح بالای اتاق غیرممکن است." + "تنزل نقش شما در اتاق؟" + "%1$s (منتظر)" + "(منتظر)" + "مدیران به صورت خودکار اجازه‌های نظارتی را دارند" + "ماکان به صورت خودکار اجازه‌های مدیریتی را دارند." + "ویرایش ناظران" + "گزینش مالکان" + "مدیران" + "ناظم‌ها" + "اعضا" + "تغییراتی ذخیره نشده دارید." + "ذخیرهٔ تغییرات؟" + "افزودن موضوع" + "رمز شده" + "رمزنگاری نشده" + "اتاق عمومی" + "ویرایش اتاق" + "خطایی ناشناخته رخ داد و اطّلاعات قابل تغییر نبودند." + "ناتوان در به‌روز رسانی اتاق" + "پیام‌ها با قفل محافظت می‌شوند. فقط شما و گیرندگان، کلیدهای منحصر به فرد برای باز کردن قفل آنها را دارید." + "رمزنگاری پیام به کار افتاد" + "هنگام بارگیری تنظیمات اعلان خطایی رخ داد." + "بی صدا کردن این اتاق ناموفق بود، لطفا دوباره امتحان کنید." + "بی صدا کردن این اتاق ناموفق بود، لطفا دوباره امتحان کنید." + "کاره را تا زمان پایانش نبندید." + "آماده سازی دعوت‌ها…" + "دعوت افراد" + "ترک گفت‌وگو" + "ترک اتاق" + "رسانه‌ها و پرونده‌ها" + "سفارشی" + "پیش‌گزیده" + "آگاهی‌ها" + "پیام‌های سنجاق شده" + "نمایه" + "درخواست‌های پیوستن" + "نقش‌ها و اجازه‌ها" + "نام اتاق" + "امنیت و محرمانگی" + "امنیت" + "هم‌رسانی اتاق" + "اطّلاعات اتاق" + "موضوع" + "به‌روز کردن اتاق…" + "هیچ کاربر محرومی در این اتاق نیست." + + "%1$d نفر" + "%1$d نفر" + + "برداشت و تحریم عضو" + "تنها برداشتن عضو" + "رفع انسداد" + "در صورت دعوت می‌تواند دوباره به اتاق بپیوندد." + "تحریم نکردن از اتاق" + "محروم" + "اعضا" + "فقط مدیران" + "مدیرن و ناظران" + "مالک" + "اعضای اتاق" + "رفع تحریم %1$s" + "اجازه به تنظیمت شخصی" + "روشن کردنش تنظیمات پیش‌گزیده‌تان را پایمال می‌کند" + "آگاهی من در این گپ برای" + "می‌توانید در %1$sتان تغییرش دهید." + "تنظیمات جهانی" + "تنظیمات پیش‌گزیده" + "برداشتن تنظیمات سفارشی" + "هنگام بارگیری تنظیمات اعلان خطایی رخ داد." + "بازیابی حالت پیش فرض ناموفق بود، لطفا دوباره امتحان کنید." + "تنظیم حالت ناموفق است، لطفا دوباره امتحان کنید." + "کارساز خانگیتان از این گزینه در اتاق‌های رمز شده پشتیبانی نمی‌کند. ممکن است در این اتاق آگاه نشوید." + "همهٔ پیام‌ها" + "فقط اشاره‌ها و کلیدواژگان" + "آگاهی من در این اتاق برای" + "مدیران" + "مدیران و مالکان" + "تغییر نقشم" + "تنزّل به عضو" + "تنزّل به ناظم" + "نظارت اعضا" + "پیام‌ها و محتوا" + "ناظم‌ها" + "مالکان" + "بازنشانی اجازه‌ها" + "بازنشانی اجازه‌ها؟" + "نقش‌ها" + "جزییات اتاق" + "نقش‌ها و اجازه‌ها" + "افزودن نشانی اتاق" + "درخواست دعوت" + "بله. به کار انداختن رمزنگاری" + "رمزگذاری فعال شود؟" + "پس از به کار افتادن، رمزنگاری قابل از کار انداختن نیست." + "رمزنگاری" + "هرکسی می‌تواند یافته و بپیوندد" + "هرکسی" + "افراد فقط در صورت دعوت می‌توانند بپیوندند" + "فقط دعوتی" + "دسترسی اتاق" + "اعضای فضا" + "در حال حاضر فضاها پشتیبانی نمی‌شوند" + "نشانی اتاق" + "هرکسی" + "انتشار اتاق" + "نمایانی اتاق" + "امنیت و محرمانگی" + diff --git a/features/roomdetails/impl/src/main/res/values-fi/translations.xml b/features/roomdetails/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..b696660 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,152 @@ + + + "Tarvitset osoitteen, jotta se näkyy julkisessa hakemistossa." + "Muokkaa osoitetta" + "Ilmoitusasetusten muokkaamisessa tapahtui virhe." + "Kotipalvelimesi ei tue tätä vaihtoehtoa salatuissa huoneissa, joten et ehkä saa ilmoitusta joissakin huoneissa." + "Kyselyt" + "Ylläpitäjä" + "Porttikieltojen antaminen" + "Viestien poistaminen" + "Jäsen" + "Ihmisten kutsuminen ja liittymispyyntöjen hyväksyminen" + "Jäsenien hallinta" + "Viestit ja sisältö" + "Valvoja" + "Henkilöiden poistaminen ja liittymispyyntöjen hylkääminen" + "Huoneen avatarin vaihtaminen" + "Muokkaa tietoja" + "Huoneen nimen vaihtaminen" + "Huoneen aiheen vaihtaminen" + "Viestien lähettäminen" + "Muokkaa ylläpitäjiä" + "Et voi peruuttaa tätä toimenpidettä. Ylennät käyttäjän samalle oikeustasolle kuin sinä." + "Lisätäänkö ylläpitäjä?" + "Et voi kumota tätä toimintoa. Olet siirtämässä omistajuuden valituille käyttäjille. Kun poistut, muutos on pysyvä." + "Siirretäänkö omistajuus?" + "Alenna" + "Et voi perua tätä muutosta, koska olet alentamassa itseäsi. Jos olet viimeinen oikeutettu henkilö tässä huoneessa, oikeuksia ei voi enää saada takaisin." + "Haluatko alentaa itsesi?" + "%1$s (Kutsuttu)" + "(Kutsuttu)" + "Ylläpitäjillä on automaattisesti valvojan oikeudet" + "Omistajilla on automaattisesti ylläpitäjän oikeudet." + "Muokkaa valvojia" + "Valitse Omistajat" + "Ylläpitäjät" + "Valvojat" + "Jäsenet" + "Sinulla on tallentamattomia muutoksia" + "Tallennetaanko muutokset?" + "Lisää aihe" + "Salattu" + "Ei salattu" + "Julkinen huone" + "Muokkaa tietoja" + "Tuntematon virhe tapahtui, eikä tietoja voitu muuttaa." + "Huoneen muokkaaminen ei onnistunut" + "Viestisi suojataan lukoilla. Vain sinulla ja viesiten vastaanottajilla on uniikit avaimet niiden avaamiseen." + "Viestien salaus käytössä" + "Ilmoitusasetuksia ladattaessa tapahtui virhe." + "Tämän huoneen mykistäminen epäonnistui, yritä uudelleen." + "Tämän huoneen mykistyksen poistaminen epäonnistui, yritä uudelleen." + "Älä sulje sovellusta ennen kuin se on valmis." + "Valmistellaan kutsuja…" + "Kutsu henkilöitä" + "Poistu keskustelusta" + "Poistu huoneesta" + "Media ja tiedostot" + "Mukautettu" + "Oletus" + "Ilmoitukset" + "Kiinnitetyt viestit" + "Profiili" + "Liittymispyynnöt" + "Roolit ja oikeudet" + "Huoneen nimi" + "Turvallisuus ja yksityisyys" + "Turvallisuus" + "Jaa huone" + "Huoneen tiedot" + "Aihe" + "Muokataan huonetta…" + "Porttikiellettyjä käyttäjiä ei ole." + + "%1$d henkilö" + "%1$d henkilöä" + + "Poista jäsen huoneesta ja anna porttikielto" + "Poista vain jäsen huoneesta" + "Poista porttikielto" + "He voivat liittyä tähän huoneeseen uudelleen, jos heidät kutsutaan." + "Poista porttikielto huoneesta" + "Porttikiellot" + "Jäsenet" + "Ylläpitäjä" + "Valvoja" + "Omistaja" + "Huoneen jäsenet" + "Poistetaan käyttäjän %1$s porttikieltoa" + "Salli mukautettu asetus" + "Tämän ottaminen käyttöön ohittaa oletusasetuksesi" + "Ilmoita minulle tässä keskustelussa" + "Voit muuttaa sen %1$s." + "yleisissä asetuksissa" + "Oletusasetus" + "Poista mukautettu asetus" + "Ilmoitusasetusten lataamisessa tapahtui virhe." + "Oletustilan palauttaminen epäonnistui, yritä uudelleen." + "Tilan asettaminen epäonnistui, yritä uudelleen." + "Kotipalvelimesi ei tue tätä vaihtoehtoa salatuissa huoneissa, joten et saa ilmoituksia tästä huoneesta." + "Kaikista viesteistä" + "Vain maininnoista ja avainsanoista" + "Ilmoita minulle tässä huoneessa" + "Ylläpitäjät" + "Ylläpitäjät ja omistajat" + "Vaihda rooliani" + "Alenna jäseneksi" + "Alenna valvojaksi" + "Jäsenten valvonta" + "Viestit ja sisältö" + "Valvojat" + "Omistajat" + "Oikeudet" + "Nollaa oikeudet" + "Kun nollaat käyttöoikeudet, menetät nykyiset asetukset." + "Nollataanko oikeudet?" + "Roolit" + "Huoneen tiedot" + "Roolit ja oikeudet" + "Lisää osoite" + "Kaikkien on pyydettävä pääsyä." + "Pyydä liittymistä" + "Kyllä, ota salaus käyttöön" + "Kun salaus on kerran otettu käyttöön, sitä ei voi poistaa käytöstä. Viestihistoria näkyy vain huoneen jäsenille kutsusta tai liittymisestä lähtien. +Kukaan muu kuin huoneen jäsenet eivät pysty lukemaan viestejä. Tämä voi estää botteja tai siltoja toimimasta oikein. +Emme suosittele salauksen ottamista käyttöön huoneissa, jotka kuka tahansa voi löytää ja joihin kuka tahansa voi liittyä." + "Otetaanko salaus käyttöön?" + "Kun salaus on kerran otettu käyttöön, sitä ei voi poistaa käytöstä." + "Salaus" + "Ota päästä päähän -salaus käyttöön" + "Kuka tahansa voi liittyä." + "Kuka tahansa" + "Vain kutsutut henkilöt voivat liittyä." + "Vain kutsutut" + "Pääsy" + "Tilan jäsenet" + "Tiloja ei tällä hetkellä tueta" + "Tarvitset osoitteen, jotta se näkyy julkisessa hakemistossa." + "Osoite" + "Salli tämän huoneen löytäminen hakemalla %1$s -palvelimen julkisesta huonehakemistosta." + "Anna muiden löytää tämä julkisen hakemiston kautta." + "Näkyy julkisessa hakemistossa" + "Kuka tahansa" + "Kuka voi lukea viestihistoriaa" + "Jäsenet vasta kutsusta lähtien" + "Jäsenet tämän vaihtoehdon valinnan jälkeen" + "Huoneosoitteet ovat tapoja löytää ja käyttää huoneita. Näin voit myös helposti jakaa huoneesi muiden kanssa. +Voit halutessasi julkaista huoneesi kotipalvelimesi julkisessa huonehakemistossa." + "Huoneen julkaiseminen" + "Näkyvyys" + "Turvallisuus ja yksityisyys" + 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 0000000..79af58f --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,164 @@ + + + "Vous aurez besoin d’une adresse pour le rendre visible dans l’annuaire public." + "Modifier l’adresse" + "Une erreur s’est produite lors de la mise à jour du paramètre de notification." + "Votre serveur d’accueil ne supporte pas cette option pour les salons chiffrés, vous pourriez ne pas être notifié(e) dans certains salons." + "Sondages" + "Administrateurs" + "Bannir des participants" + "Supprimer des messages" + "Membre" + "Inviter des personnes" + "Gérer les membres" + "Messages et contenus" + "Modérateurs" + "Retirer des personnes" + "Changer l’avatar du salon" + "Modifier les détails" + "Changer le nom du salon" + "Changer le sujet du salon" + "Envoyer des messages" + "Modifier les administrateurs" + "Vous ne pourrez pas annuler cette action. Vous êtes en train de promouvoir l’utilisateur pour qu’il ait le même niveau que vous." + "Ajouter un administrateur ?" + "Vous ne pourrez pas annuler cette action. Vous transférez la propriété aux utilisateurs sélectionnés. Une fois que vous serez parti, l’action sera définitive." + "Transférer la propriété?" + "Rétrograder" + "Vous ne pourrez pas annuler ce changement car vous vous rétrogradez, si vous êtes le dernier utilisateur privilégié du salon il sera impossible de retrouver les privilèges." + "Vous rétrograder ?" + "%1$s (En attente)" + "(En attente)" + "Les administrateurs ont automatiquement les privilèges des modérateurs" + "Les propriétaires disposent automatiquement des privilèges des administrateurs." + "Modifier les modérateurs" + "Choisissez les propriétaires" + "Administrateurs" + "Modérateurs" + "Membres" + "Vous avez des modifications non-enregistrées." + "Enregistrer les changements ?" + "Ajouter un sujet" + "Chiffré" + "Non chiffré" + "Salon public" + "Modifier les détails" + "Une erreur inconnue s’est produite et les informations n’ont pas pu être modifiées." + "Impossible de mettre à jour le salon" + "Les messages sont sécurisés par des clés de chiffrement. Seuls vous et les destinataires possédez les clés uniques pour les déverrouiller." + "Chiffrement des messages activé" + "Une erreur s’est produite lors du chargement des paramètres de notification." + "Échec de la mise en sourdine de ce salon, veuillez réessayer." + "Échec de la désactivation de la mise en sourdine de ce salon, veuillez réessayer." + "Ne fermez pas l’application avant que l’opération soit terminée." + "Préparation des invitations…" + "Inviter des amis" + "Quitter la discussion" + "Quitter le salon" + "Médias et fichiers" + "Personnalisé" + "Défaut" + "Notifications" + "Messages épinglés" + "Profil" + "Demandes en attente" + "Rôles & autorisations" + "Nom du salon" + "Sécurité & confidentialité" + "Sécurité" + "Partager le salon" + "Informations du salon" + "Sujet" + "Mise à jour du salon…" + "Il n’y a pas d’utilisateur banni." + + "%1$d Banni(e)" + "%1$d Banni(e)s" + + "Vérifiez la saisie ou effectuez une nouvelle recherche" + "Aucun résultat pour «%1$s»" + + "%1$d Personne" + "%1$d Personnes" + + "Bannir du salon" + "Retirer le membre uniquement" + "Débannir" + "Il pourra rejoindre le salon à nouveau si il est invité." + "Débannir du salon" + "Bannis" + "Membres" + + "%1$d Invité(e)" + "%1$d Invité(e)s" + + "En attente" + "Administrateurs" + "Modérateurs" + "Propriétaire" + "Membres du salon" + "Débannissement de %1$s" + "Autoriser les paramètres personnalisés" + "L’activation de cette option annulera votre paramètre par défaut" + "Prévenez-moi dans ce salon pour" + "Vous pouvez le modifier dans vos %1$s." + "paramètres globaux" + "Paramètre par défaut" + "Supprimer le paramètre personnalisé" + "Une erreur s’est produite lors du chargement des paramètres de notification." + "Échec de la restauration du mode par défaut, veuillez réessayer." + "Échec de la configuration du mode, veuillez réessayer." + "Votre serveur d’accueil ne supporte pas cette option pour les salons chiffrés, vous ne serez pas notifié(e) dans ce salon." + "Tous les messages" + "Mentions et mots clés uniquement" + "Dans ce salon, prévenez-moi pour" + "Administrateurs" + "Administrateurs et propriétaires" + "Changer mon rôle" + "Devenir simple membre" + "Devenir simple modérateur" + "Administration des membres" + "Messages et contenus" + "Modérateurs" + "Propriétaires" + "Autorisations" + "Réinitialisation des autorisations" + "La réinitialisation des autorisations entraîne la perte des réglages actuels." + "Réinitialisation des autorisations ?" + "Rôles" + "Détails du salon" + "Rôles & autorisations" + "Ajouter une adresse" + "Tout le monde doit demander un accès." + "Demander à rejoindre" + "Oui, activer le chiffrement" + "Une fois activé, le chiffrement d’un salon ne peut pas être désactivé. L’historique des messages ne sera visible que pour les membres depuis qu’ils ont été invités ou depuis qu’ils ont rejoint le salon. +Personne d’autre que les membres du salon ne pourra lire les messages. Cela peut empêcher les bots et les bridges de fonctionner correctement. +Nous ne recommandons pas d’activer le chiffrement pour les salons que tout le monde peut trouver et rejoindre." + "Activer le chiffrement ?" + "Une fois activé, le chiffrement ne peut pas être désactivé." + "Chiffrement" + "Activer le chiffrement de bout en bout" + "Tout le monde peut rejoindre." + "Tout le monde" + "Seules les personnes invitées peuvent rejoindre." + "Sur invitation uniquement" + "Accès" + "Membres de l’espace" + "Les Espaces ne sont pas encore supportés" + "Vous aurez besoin d’une adresse pour le rendre visible dans l’annuaire public." + "Adresse" + "Autoriser le salon à apparaître dans les résultats de recherche dans le répertoire %1$s des salons publics" + "Permet d’être trouvé en recherchant dans l’annuaire public." + "Visible dans l’annuaire public" + "Tout le monde" + "Qui peux lire l’historique" + "Les membres uniquement depuis qu’ils ont été invités" + "Les membres uniquement depuis la sélection de cette option" + "Les adresses de salon sont un moyen de trouver et d’accéder aux salons. Cela vous permet également de partager facilement votre salon avec d’autres personnes. +Vous pouvez choisir de publier votre salon dans l’annuaire des salons publics de votre serveur d’accueil." + "Publication du salon" + "Les adresses permettent de trouver et d’accéder aux salons et aux espaces. Elles facilitent également leur partage avec d’autres personnes." + "Visibilité" + "Sécurité & confidentialité" + diff --git a/features/roomdetails/impl/src/main/res/values-hu/translations.xml b/features/roomdetails/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..bbcf93f --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,153 @@ + + + "Szüksége lesz egy szobacímre, hogy láthatóvá tegye a szobakatalógusban." + "Cím szerkesztése" + "Hiba történt az értesítési beállítás frissítésekor." + "A Matrix-kiszolgálója nem támogatja ezt a beállítást a titkosított szobákban, előfordulhat, hogy egyes szobákban nem kap értesítést." + "Szavazások" + "Adminisztrátor" + "Emberek kitiltása" + "Üzenetek eltávolítása" + "Tag" + "Emberek meghívása" + "Tagok kezelése" + "Üzenetek és tartalom" + "Moderátor" + "Emberek eltávolítása" + "Szoba profilképének módosítása" + "Részletek szerkesztése" + "Szoba nevének módosítása" + "Szoba témájának módosítása" + "Üzenetek küldése" + "Adminisztrátorok szerkesztése" + "Ezt a műveletet nem fogja tudja visszavonni. Ugyanarra a szintre lépteti elő a felhasználót, mint amellyel Ön is rendelkezik." + "Adminisztrátor hozzáadása?" + "Ezt a műveletet nem lehet visszavonni. A tulajdonjogot a kiválasztott felhasználókra ruházza át. Távozás után a művelet véglegessé válik." + "Átruházza a tulajdonjogot?" + "Lefokozás" + "Ezt a változtatást nem fogja tudni visszavonni, mivel lefokozza magát, ha Ön az utolsó jogosultságokkal rendelkező felhasználó a szobában, akkor lehetetlen lesz visszaszerezni a jogosultságokat." + "Lefokozza magát?" + "%1$s (függőben)" + "(Függőben)" + "Az adminisztrátorok automatikusan moderátori jogosultságokkal rendelkeznek" + "A tulajdonosok automatikusan adminisztrátori jogosultsággal rendelkeznek." + "Moderátorok szerkesztése" + "Tulajdonosok kiválasztása" + "Adminisztrátorok" + "Moderátorok" + "Tagok" + "Mentetlen módosításai vannak." + "Menti a módosításokat?" + "Téma hozzáadása" + "Titkosított" + "Nem titkosított" + "Nyilvános szoba" + "Részletek szerkesztése" + "Ismeretlen hiba történt, és az információkat nem lehetett megváltoztatni." + "Nem sikerült frissíteni a szobát" + "Az üzeneteket kulcsok védik. Csak Ön és a címzett rendelkezik a feloldásukhoz szükséges egyedi kulcsokkal." + "Az üzenettitkosítás engedélyezve" + "Hiba történt az értesítési beállítások betöltésekor." + "Nem sikerült elnémítani ezt a szobát, próbálja újra." + "Nem sikerült feloldani a szoba némítását, próbálja újra." + "Ne zárja be az alkalmazást, amíg nem végzett." + "Meghívók előkészítése…" + "Ismerősök meghívása" + "Beszélgetés elhagyása" + "Szoba elhagyása" + "Média és fájlok" + "Egyéni" + "Alapértelmezett" + "Értesítések" + "Kitűzött üzenetek" + "Profil" + "Csatlakozási kérelem" + "Szerepkörök és jogosultságok" + "Szoba neve" + "Biztonság és adatvédelem" + "Biztonság" + "Szoba megosztása" + "Szobainformációk" + "Téma" + "Szoba frissítése…" + "Ebben a szobában nincsenek kitiltott felhasználók." + + "%1$d személy" + "%1$d személy" + + "Eltávolítás és a tag kitiltása" + "Csak a tag eltávolítása" + "Tiltás feloldása" + "Ehhez a szobához is csatlakozhat, ha meghívják." + "Visszaengedés a szobába" + "Kitiltva" + "Tagok" + "Függőben" + "Adminisztrátor" + "Moderátor" + "Tulajdonos" + "Szoba tagjai" + "%1$s tiltásának feloldása" + "Egyéni beállítás engedélyezése" + "Ennek bekapcsolása felülírja az alapértelmezett beállítást" + "Értesítések kérése ebben a csevegésben ezekről:" + "Megváltoztathatja a %1$s." + "globális beállításokban" + "Alapértelmezett beállítás" + "Egyéni beállítás eltávolítása" + "Hiba történt az értesítési beállítások betöltésekor." + "Nem sikerült visszaállítani az alapértelmezett módot, próbálja újra." + "Nem sikerült a mód beállítása, próbálja újra." + "A Matrix-kiszolgálója nem támogatja ezt a beállítást a titkosított szobákban, egyes szobákban nem fog értesítéseket kapni." + "Összes üzenet" + "Csak említések és kulcsszavak" + "Ebben a szobában, értesítés ezekről:" + "Adminisztrátorok" + "Adminisztrátorok és tulajdonosok" + "Saját szerepkör módosítása" + "Lefokozás taggá" + "Lefokozás moderátorrá" + "Tagok moderálása" + "Üzenetek és tartalom" + "Moderátorok" + "Tulajdonosok" + "Jogosultságok" + "Jogosultságok visszaállítása" + "A jogosultságok visszaállítása után a jelenlegi beállítások elvesznek." + "Jogosultságok visszaállítása?" + "Szerepkörök" + "Szoba részletei" + "Szerepkörök és jogosultságok" + "Cím hozzáadása" + "Mindenkinek hozzáférést kell kérnie." + "Csatlakozás kérése" + "Igen, engedélyezze a titkosítást" + "Az engedélyezés után a szoba titkosítása nem tiltható le. Az üzenetek előzményei csak a szobatagok számára láthatók, amikor meghívást kaptak, vagy mióta csatlakoztak a szobához. +A szobatagokon kívül senki sem tudja olvasni az üzeneteket. Ez megakadályozhatja a botok és a hidak megfelelő működését. +Nem javasoljuk a titkosítás engedélyezését az olyan szobákban, amelyeket bárki megtalálhat és csatlakozhat." + "Engedélyezi a titkosítást?" + "Engedélyezés után a titkosítás nem tiltható le." + "Titkosítás" + "Végpontok közötti titkosítás engedélyezése" + "Bárki csatlakozhat." + "Bárki" + "Csak a meghívott emberek léphetnek be." + "Csak meghívásos" + "Hozzáférés" + "A tér tagjai" + "A terek jelenleg nem támogatottak" + "Szüksége lesz egy szobacímre, hogy láthatóvá tegye a szobakatalógusban." + "Cím" + "A szoba megtalálhatóvá tétele a(z) %1$s nyilvános szobakatalógusában való kereséssel." + "Lehetővé teszi, hogy a nyilvános szobakatalógusban megtalálható legyen." + "Látható a nyilvános szobakatalógusban" + "Bárki" + "Ki olvashatja az előzményeket" + "Csak a tagok, a meghívásuktól kezdődően" + "Csak a tagok, a beállítás választásától kezdődően" + "A szobacímek a szobák megtalálásának és elérésnek módjai. Ez azt is biztosítja, hogy könnyen megoszthatja a szobáját másokkal. +Kiválaszthatja, hogy szobáját közzéteszi-e a Matrix-kiszolgáló nyilvános szobakatalógusában." + "Szoba közzététele" + "Láthatóság" + "Biztonság és adatvédelem" + diff --git a/features/roomdetails/impl/src/main/res/values-in/translations.xml b/features/roomdetails/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..097cd37 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,135 @@ + + + "Anda akan memerlukan alamat ruangan untuk membuatnya terlihat dalam direktori." + "Alamat ruangan" + "Terjadi kesalahan saat memperbarui pengaturan pemberitahuan." + "Homeserver Anda tidak mendukung opsi ini dalam ruangan terenkripsi, Anda mungkin tidak diberi tahu dalam beberapa ruangan." + "Pemungutan suara" + "Hanya admin" + "Cekal orang-orang" + "Hilangkan pesan" + "Undang orang-orang dan terima permintaan untuk bergabung" + "Pesan dan konten" + "Admin dan moderator" + "Keluarkan orang-orang dan tolak permintaan untuk bergabung" + "Ubah avatar ruangan" + "Sunting Ruangan" + "Ubah nama ruangan" + "Ubah topik ruangan" + "Kirim pesan" + "Sunting Admin" + "Anda tidak akan dapat mengurungkan tindakan ini. Anda mempromosikan pengguna untuk memiliki tingkat daya yang sama seperti Anda." + "Tambahkan Admin?" + "Turunkan" + "Anda tidak akan dapat mengurungkan perubahan ini karena Anda sedang menurunkan Anda sendiri, jika Anda merupakan pengguna dengan hak khusus dalam ruangan maka tidak akan memungkinkan untuk mendapatkan hak tersebut lagi." + "Turunkan Anda sendiri?" + "%1$s (Tertunda)" + "(Tertunda)" + "Admin secara otomatis memiliki hak moderator" + "Sunting Moderator" + "Admin" + "Moderator" + "Anggota" + "Anda memiliki perubahan yang belum disimpan." + "Simpan perubahan?" + "Tambahkan topik" + "Terenkripsi" + "Tidak terenkripsi" + "Ruangan publik" + "Sunting Ruangan" + "Terjadi kesalahan yang tidak diketahui dan informasinya tidak dapat diubah." + "Tidak dapat memperbarui ruangan" + "Pesan diamankan dengan kunci. Hanya Anda dan penerima yang memiliki kunci unik untuk membukanya." + "Enkripsi pesan diaktifkan" + "Terjadi kesalahan saat memuat pengaturan notifikasi." + "Gagal membisukan ruangan ini, silakan coba lagi." + "Gagal membunyikan ruangan ini, silakan coba lagi." + "Undang orang-orang" + "Tinggalkan percakapan" + "Tinggalkan ruangan" + "Media dan berkas" + "Khusus" + "Bawaan" + "Notifikasi" + "Pesan yang disematkan" + "Profil" + "Permintaan untuk bergabung" + "Peran dan perizinan" + "Nama ruangan" + "Keamanan & privasi" + "Keamanan" + "Bagikan ruangan" + "Info ruangan" + "Topik" + "Memperbarui ruangan…" + "Tidak ada pengguna yang dicekal dalam ruangan ini." + + "%1$d orang" + + "Keluarkan dan cekal anggota" + "Hanya keluarkan anggota" + "Batalkan pencekalan" + "Pengguna dapat bergabung ke ruangan ini lagi jika diundang." + "Batalkan cekalan dari ruangan" + "Tercekal" + "Anggota" + "Hanya admin" + "Admin dan moderator" + "Anggota ruangan" + "Membatalkan cekalan %1$s" + "Izinkan pengaturan khusus" + "Mengaktifkan ini akan mengganti pengaturan bawaan Anda" + "Beri tahu saya di obrolan ini tentang" + "Anda dapat mengubahnya di %1$s Anda." + "pengaturan global" + "Pengaturan bawaan" + "Hapus pengaturan khusus" + "Terjadi kesalahan saat memuat pengaturan pemberitahuan." + "Gagal memulihkan mode bawaan, silakan coba lagi." + "Gagal mengatur mode, silakan coba lagi." + "Homeserver Anda tidak mendukung opsi ini dalam ruangan terenkripsi, Anda tidak akan diberi tahu dalam ruangan ini." + "Semua pesan" + "Sebutan dan Kata Kunci saja" + "Di ruangan ini, beri tahu saya tentang" + "Admin" + "Ubah peran saya" + "Turunkan ke anggota" + "Turunkan ke moderator" + "Moderasi anggota" + "Pesan dan konten" + "Moderator" + "Atur ulang perizinan" + "Setelah Anda mengatur ulang perizinan, Anda akan kehilangan pengaturan Anda saat ini." + "Atur ulang perizinan?" + "Peran" + "Detail ruangan" + "Peran dan perizinan" + "Tambahkan alamat ruangan" + "Siapa pun dapat meminta untuk bergabung dengan ruangan tetapi administrator atau moderator harus menerima permintaan tersebut." + "Minta untuk bergabung" + "Ya, aktifkan enkripsi" + "Setelah diaktifkan, encryption untuk sebuah ruangan tidak dapat dinonaktifkan, Riwayat pesan hanya akan terlihat oleh anggota ruangan sejak mereka diundang atau sejak mereka bergabung dengan ruangan tersebut. +Tidak ada orang lain selain anggota ruangan yang dapat membaca pesan. Hal ini dapat mencegah bot dan jembatan bekerja dengan benar. +Kami tidak menyarankan untuk mengaktifkan enkripsi untuk ruangan yang dapat ditemukan dan diikuti oleh siapa pun." + "Aktifkan enkripsi?" + "Setelah diaktifkan, enkripsi tidak dapat dinonaktifkan." + "Enkripsi" + "Aktifkan enkripsi ujung ke ujung" + "Siapa pun dapat menemukan dan bergabung" + "Siapa pun" + "Orang hanya dapat bergabung jika mereka diundang" + "Hanya undangan" + "Akses ruangan" + "Anggota space" + "Space saat ini tidak didukung" + "Anda akan memerlukan alamat ruangan untuk membuatnya terlihat dalam direktori." + "Izinkan ruangan ini ditemukan dengan mencari direktori ruangan %1$s publik" + "Terlihat di direktori ruangan publik" + "Siapa pun" + "Siapa yang bisa membaca riwayat" + "Hanya anggota sejak mereka diundang" + "Hanya anggota sejak memilih opsi ini" + "Alamat ruangan adalah cara untuk menemukan dan mengakses ruangan. Ini juga memastikan Anda dapat dengan mudah berbagi ruangan dengan orang lain. Anda dapat memilih untuk menerbitkan ruangan Anda di direktori ruangan publik homeserver Anda." + "Penerbitan ruangan" + "Keamanan & privasi" + 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 0000000..3d83dba --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,164 @@ + + + "Per renderlo visibile nell\'elenco pubblico, avrai bisogno di un indirizzo." + "Modifica indirizzo" + "Si è verificato un errore durante l\'aggiornamento delle impostazioni di notifica." + "Il tuo homeserver non supporta questa opzione nelle stanze crifrate, quindi potresti non ricevere notifiche in alcune stanze." + "Sondaggi" + "Amministratore" + "Escludi membri" + "Rimuovi messaggi" + "Membro" + "Invita persone" + "Gestisci membri" + "Messaggi e contenuti" + "Moderatore" + "Rimuovi membri" + "Cambia avatar della stanza" + "Modifica dettagli" + "Cambia il nome della stanza" + "Cambiare l\'argomento della stanza" + "Inviare messaggi" + "Modifica amministratori" + "Non potrai annullare questa azione. Stai promuovendo l\'utente al tuo stesso livello di potere." + "Aggiungi amministratore?" + "Non potrai annullare questa azione. Stai trasferendo la proprietà agli utenti selezionati. Una volta abbandonato, questa azione sarà definitiva." + "Trasferire proprietà?" + "Declassa" + "Non potrai annullare questa modifica perché ti stai declassando, se sei l\'ultimo utente privilegiato nella stanza, sarà impossibile riottenere i privilegi." + "Declassare te stesso?" + "%1$s (In attesa)" + "(In attesa)" + "Gli amministratori hanno automaticamente i privilegi di moderatore" + "I proprietari hanno automaticamente privilegi di amministratore." + "Modifica moderatori" + "Scegli i proprietari" + "Amministratori" + "Moderatori" + "Membri" + "Hai delle modifiche non salvate." + "Salvare le modifiche?" + "Aggiungi argomento" + "Cifrata" + "Non cifrata" + "Stanza pubblica" + "Modifica dettagli" + "Si è verificato un errore sconosciuto e non è stato possibile modificare le informazioni." + "Impossibile aggiornare la stanza" + "I messaggi sono protetti da lucchetti. Solo tu e i destinatari avete le chiavi univoche per sbloccarli." + "Crittografia messaggi abilitata" + "Si è verificato un errore durante il caricamento delle impostazioni di notifica." + "Impostazione del silenzioso fallita per questa stanza, riprova." + "Disattivazione del silenzioso di questa stanza fallita, riprova." + "Non chiudere l\'app fino al completamento." + "Preparazione degli inviti…" + "Invita persone" + "Abbandona la conversazione" + "Esci dalla stanza" + "File e contenuti multimediali" + "Personalizzato" + "Predefinito" + "Notifiche" + "Messaggi fissati" + "Profilo" + "Richieste di accesso" + "Ruoli e autorizzazioni" + "Nome stanza" + "Sicurezza e privacy" + "Sicurezza" + "Condividi stanza" + "Informazioni sulla stanza" + "Argomento" + "Aggiornamento della stanza…" + "Non ci sono utenti bannati." + + "%1$d Bannato" + "%1$d Bannati" + + "Controlla l\'ortografia o prova una nuova ricerca" + "Nessun risultato per “%1$s ”" + + "%1$d Persona" + "%1$d Persone" + + "Rimuovi ed escludi" + "Rimuovi soltanto" + "Riammetti" + "Potrà entrare nuovamente in questa stanza se invitato." + "Riammetti nella stanza" + "Esclusi" + "Membri" + + "%1$d Invitato" + "%1$d Invitati" + + "In attesa" + "Amministratore" + "Moderatore" + "Proprietario" + "Membri della stanza" + "Riammissione di %1$s" + "Consenti impostazione personalizzata" + "L\'attivazione di questa opzione sovrascriverà l\'impostazione predefinita" + "Avvisami in questa chat per" + "Puoi cambiarlo nelle tue %1$s." + "impostazioni globali" + "Impostazione predefinita" + "Rimuovi l\'impostazione personalizzata" + "Si è verificato un errore durante il caricamento delle impostazioni di notifica." + "Ripristino della modalità predefinita fallito, riprova." + "Impossibile impostare la modalità, riprova." + "Il tuo homeserver non supporta questa opzione nelle stanze cifrate, quindi non riceverai notifiche in questa stanza." + "Tutti i messaggi" + "Solo menzioni e parole chiave" + "In questa stanza, avvisami per" + "Amministratori" + "Amministratori e proprietari" + "Cambia il mio ruolo" + "Declassa a membro" + "Declassa a moderatore" + "Moderazione dei membri" + "Messaggi e contenuti" + "Moderatori" + "Proprietari" + "Autorizzazioni" + "Reimpostare le autorizzazioni" + "Una volta reimpostate le autorizzazioni, perderai le impostazioni correnti." + "Reimpostare autorizzazioni?" + "Ruoli" + "Dettagli della stanza" + "Ruoli e autorizzazioni" + "Aggiungi indirizzo" + "Chiunque deve richiedere l\'accesso." + "Chiedi di entrare" + "Sì, attiva la crittografia" + "Una volta attivata, la crittografia di una stanza non può essere disattivata, la cronologia dei messaggi sarà visibile solo ai membri della stanza da quando sono stati invitati o da quando sono entrati nella stanza. +Nessuno, oltre ai membri della stanza, sarà in grado di leggere i messaggi. Ciò potrebbe impedire ai bot e ai bridge di funzionare correttamente. +Non consigliamo di attivare la crittografia per le stanze che chiunque può trovare e in cui può entrare." + "Attivare la crittografia?" + "Una volta attivata, la crittografia non può essere disattivata." + "Crittografia" + "Attiva la crittografia end-to-end" + "Chiunque può partecipare." + "Chiunque" + "Solo le persone invitate possono entrare." + "Solo su invito" + "Accesso" + "Membri dello spazio" + "Gli spazi non sono attualmente supportati" + "Per renderlo visibile nell\'elenco pubblico, avrai bisogno di un indirizzo." + "Indirizzo" + "Consenti la ricerca di questa stanza effettuando una ricerca nell\'elenco delle stanze pubbliche di %1$s" + "Consenti di essere trovato effettuando una ricerca nell\'elenco pubblico." + "Visibile nell\'elenco pubblico" + "Chiunque" + "Chi può leggere la cronologia messaggi" + "Solo membri da quando sono stati invitati" + "Solo membri da dopo aver selezionato questa opzione" + "Gli indirizzi delle stanze sono modi per trovare e accedervi. In questo modo puoi anche condividere facilmente la tua stanze con altri. +Puoi scegliere di pubblicare la tua stanza nell\'elenco delle stanza pubbliche dell\'homeserver." + "Pubblicazione della stanza" + "Gli indirizzi sono un modo per trovare e accedere a stanze e spazi. Questo ti consente anche di condividerli facilmente con altri." + "Visibilità" + "Sicurezza e privacy" + diff --git a/features/roomdetails/impl/src/main/res/values-ka/translations.xml b/features/roomdetails/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..d9c56aa --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,97 @@ + + + "შეტყობინებების პარამეტრის განახლებისას მოხდა შეცდომა." + "თქვენი სახლის სერვერი არ უჭერს მხარს ამ პარამეტრს დაშიფრულ ოთახებში, ზოგიერთ ოთახში შეიძლება არ მიიღოთ შეტყობინება." + "გამოკითხვები" + "მხოლოდ ადმინისტრატორები" + "მომხმარებლების დაბლოკვა" + "შეტყობინებების წაშლა" + "მომხმარებლების მოწვევა და გაწევრიანების მოთხოვნების დადასტურება" + "შეტყობინებები და შინაარსი" + "ადმინისტრატორები და მოდერატორები" + "მომხმარებლების გაგდება და გაწევრიანების მოთხოვნების უარყოფა" + "ოთახის სურათის შეცვლა" + "ოთახის რედაქტირება" + "ოთახის სახელის შეცვლა" + "ოთახის თემის შეცვლა" + "შეტყობინებების გაგზავნა" + "ადმინისტრატორების რედაქტირება" + "ამ მოქმედების გაუქმებას ვერ შეძლებთ. თქვენ ნიშნავთ ამ მომხმარებელს იმავე ძალაუფლების დონეზე, რომელიც გაქვთ თქვენ." + "ადმინისტრატორის დამატება?" + "დაქვეითება" + "იმის გამო, რომ აქვეითებთ თქვენ თავს, ამ მოქმედებას ვერ გააუქმებთ. პრივილეგიების აღდგენა შეუძლებელია თუ თქვენ ბოლო პრივილეგირებული მომხმარებელი ხართ ამ ოთახში." + "გსურთ საკუთარი თავის დაქვეითება?" + "%1$s (მოლოდინი)" + "(მოლოდინში)" + "მოდერატორების რედაქტირება" + "ადმინისტრატორები" + "მოდერატორები" + "წევრები" + "თქვენ გაქვთ შეუნახავი ცვლილებები" + "შენახვა?" + "თემის დამატება" + "ოთახის რედაქტირება" + "უცნობი შეცდომა მოხდა. ინფორმაციის შეცვლა ვერ მოხერხდა." + "ოთახის განახლება შეუძლებელია" + "შეტყობინებები დაცულია საკეტებით. მხოლოდ თქვენ და მიმღებებს გაქვთ მათი განშიფვრის უნიკალური გასაღებები." + "შეტყობინების დაშიფვრა ჩართულია" + "შეტყობინებების პარამეტრების ჩატვირთვისას მოხდა შეცდომა." + "ამ ოთახის დადუმება ვერ მოხერხდა. გთხოვთ, სცადოთ ხელახლა." + "ამ ოთახის დადუმების მოხსნა ვერ მოხერხდა. გთხოვთ, სცადოთ ხელახლა." + "ხალხის მოწვევა" + "საუბრის დატოვება" + "ოთახის დატოვება" + "მორგებული" + "ნაგულისხმევი" + "შეტყობინებები" + "პროფილი" + "როლები და ნებართვები" + "ოთახის სახელი" + "უსაფრთხოება" + "ოთახის გაზიარება" + "ოთახის ინფორმაცია" + "თემა" + "ოთახის განახლება…" + "ამ ოთახში არაა დაბლოკილი მომხმარებლები." + + "%1$d ადამიანი" + "%1$d ადამიანი" + + "წევრის წაშლა და დაბლოკვა" + "მხოლოდ წევრის წაშლა" + "განბლოკვა" + "მოწვევის შემთხვევაში განბლოკილი მომხმარებელი ისევ შეძლებს ოთახს შეუერთდეს." + "დაბლოკილები" + "წევრები" + "მხოლოდ ადმინისტრატორები" + "ადმინისტრატორები და მოდერატორები" + "ოთახის წევრები" + "%1$s-ს განბლოკვა" + "მორგებული პარამეტრის დაშვება" + "ამის ჩართვა უგულებელყოფს თქვენს ნაგულისხმევ პარამეტრს" + "ამ ჩატში ჩემი შეტყობინება:" + "თქვენ შეგიძლიათ შეცვალოთ იგი თქვენს %1$s." + "გლობალური პარამეტრები" + "Სტანდარტული პარამეტრები" + "მორგებული პარამეტრის წაშლა" + "შეტყობინებების პარამეტრების ჩატვირთვისას მოხდა შეცდომა." + "ნაგულისხმევი რეჟიმის აღდგენა ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა." + "რეჟიმის დაყენება ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა." + "თქვენი სახლის სერვერი არ უჭერს მხარს ამ პარამეტრს დაშიფრულ ოთახებში, თქვენ არ მიიღებთ შეტყობინებას ამ ოთახში." + "ყველა შეტყობინება" + "მხოლოდ ხსენებები და საკვანძო სიტყვები" + "ამ ოთახში, შემატყობინეთ:" + "ადმინისტრატორები" + "ჩემი როლის შეცვლა" + "დაქვეითება წევრამდე" + "დაქვეითება მოდერატორამდე" + "წევრების მოდერირება" + "შეტყობინებები და შინაარსი" + "მოდერატორები" + "ნებართვების გადაყენება" + "ნებართვების გადაყენების შემთხვევაში მიმდინარე პარამეტრებს დაკარგავთ." + "გადავაყენოთ ცვლილებები?" + "როლები" + "ოთახის დეტალები" + "როლები და ნებართვები" + diff --git a/features/roomdetails/impl/src/main/res/values-ko/translations.xml b/features/roomdetails/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..22f3fe9 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,143 @@ + + + "디렉토리에 표시하려면 방 주소가 필요합니다." + "방 주소" + "알림 설정 업데이트 중 오류가 발생했습니다." + "귀하의 홈서버는 암호화된 방에서 이 옵션을 지원하지 않으므로, 일부 방에서는 알림이 표시되지 않을 수 있습니다." + "투표" + "관리자 전용" + "사용자 차단" + "메시지 삭제" + "사람들을 초대하고 가입 요청을 수락합니다" + "메시지 및 콘텐츠" + "관리자 및 중재자" + "사람들을 제거하고 가입 요청을 거부합니다" + "방 아바타 변경" + "방 편집" + "방 이름 변경" + "방 화제 변경" + "메시지 보내기" + "관리자 편집" + "이 작업은 실행 취소할 수 없습니다. 해당 사용자에게 당신과 동일한 권한 레벨을 부여하는 것입니다." + "관리자를 추가하시겠습니까?" + "이 작업을 취소할 수 없습니다. 선택한 사용자에게 소유권을 이전합니다. 이 작업을 완료하면 변경 사항은 영구적으로 적용됩니다." + "소유권을 이전하시겠습니까?" + "강등하다" + "이 변경 사항은 자신을 강등하는 것이므로 실행 취소할 수 없습니다. 해당 방에서 권한을 가진 마지막 사용자인 경우 권한을 다시 얻는 것은 불가능합니다." + "자신을 강등하시겠습니까?" + "%1$s (보류 중)" + "(보류 중)" + "관리자는 자동으로 중재자 권한을 갖습니다." + "소유자는 자동으로 관리자 권한을 갖습니다." + "편집 중재자" + "소유자 선택" + "관리자" + "중재자" + "회원들" + "저장되지 않은 변경 사항이 있습니다." + "변경 사항을 저장하시겠습니까?" + "화제 추가" + "암호화됨" + "암호화되지 않음" + "공개 방" + "방 편집" + "알 수 없는 오류가 발생하여 정보를 변경할 수 없습니다." + "방을 업데이트할 수 없습니다." + "메시지는 잠금으로 보호됩니다. 귀하와 수신자만 잠금을 해제할 수 있는 고유한 키를 가지고 있습니다." + "메시지 암호화 활성화됨" + "알림 설정 로딩 중 오류가 발생했습니다." + "이 방의 음소거에 실패했습니다. 다시 시도하세요." + "이 방의 음소거를 해제하지 못했습니다. 다시 시도하세요." + "사람 초대하기" + "대화에서 나가기" + "방 떠나기" + "미디어 및 파일" + "맞춤형" + "기본값" + "알림" + "고정된 메세지" + "프로필" + "참여 요청" + "역할 및 권한" + "방 이름" + "보안 및 개인정보 보호" + "보안" + "방 공유하기" + "방 정보" + "주제" + "방 업데이트 중…" + "이 방에는 차단된 사용자가 없습니다." + + "%1$d 사람" + + "방에서 차단" + "회원만 삭제할 수 있습니다." + "금지 해제" + "초대받으면 이 방에 다시 들어올 수 있습니다." + "방에서 차단 해제" + "차단됨" + "회원들" + "관리자 전용" + "관리자 및 중재자" + "소유자" + "방 회원들" + "차단 해제 %1$s" + "맞춤 설정 허용" + "이 기능을 활성화하면 기본 설정이 변경됩니다." + "이 채팅에서 알림 받기" + "%1$s 에서 변경할 수 있습니다." + "전역 설정" + "기본 설정" + "맞춤 설정 제거" + "알림 설정 로딩 중 오류가 발생했습니다." + "기본 모드를 복원하는 데 실패했습니다. 다시 시도하세요." + "모드 설정이 실패했습니다. 다시 시도해 주세요." + "귀하의 홈 서버는 암호화된 방에서 이 옵션을 지원하지 않으므로, 이 방에서 알림을 받지 못합니다." + "모든 메시지" + "언급 및 키워드만" + "이 방에서, 알림을 주세요" + "관리자" + "관리자 및 소유자" + "내 역할 변경" + "회원으로 강등" + "중재자로 강등시키다" + "회원 조정" + "메시지 및 콘텐츠" + "중재자" + "소유자" + "권한 재설정" + "권한을 재설정하면 현재 설정이 모두 삭제됩니다." + "권한을 재설정하시겠습니까?" + "역할" + "방 세부 정보" + "역할 및 권한" + "방 주소 추가" + "누구나 방에 참여 요청을 할 수 있지만, 관리자나 운영자가 요청을 수락해야 합니다." + "참가 요청" + "예, 암호화 활성화" + "일단 활성화되면, 방의 암호화는 비활성화할 수 없습니다. 메시지 기록은 방에 초대된 후 또는 방에 참여한 이후부터 방 구성원만 볼 수 있습니다. +방 구성원 외에는 아무도 메시지를 읽을 수 없습니다. 이로 인해 봇과 브리지가 제대로 작동하지 않을 수 있습니다. +누구나 찾고 참여할 수 있는 방에는 암호화를 활성화하지 않는 것이 좋습니다." + "암호화 활성화?" + "일단 활성화되면, 암호화는 비활성화할 수 없습니다." + "암호화" + "종단간 암호화 활성화" + "누구나 찾을 수 있고 참여할 수 있습니다." + "누구나" + "초대받은 사용자만 가입할 수 있습니다." + "초대 전용" + "방 액세스" + "스페이스 멤버들" + "스페이스는 현재 지원되지 않습니다" + "디렉토리에 표시하려면 방 주소가 필요합니다." + "%1$s 공개 방 디렉토리에서 이 방을 검색할 수 있도록 허용합니다" + "공개 룸 디렉토리에 표시됨" + "누구나" + "누가 기록을 읽을 수 있는가" + "초대받은 회원만 이용 가능합니다" + "이 옵션을 선택한 회원만 이용 가능합니다." + "방 주소는 방을 찾고 액세스하는 방법입니다. 이를 통해 다른 사람들과 방을 쉽게 공유할 수 있습니다. +홈서버의 공개 방 디렉토리에 방을 공개할지 여부를 선택할 수 있습니다." + "방 게시" + "보안 및 개인정보 보호" + diff --git a/features/roomdetails/impl/src/main/res/values-lt/translations.xml b/features/roomdetails/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..727875d --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,24 @@ + + + "Redaguoti kambarį" + "Pridėti temą" + "Redaguoti kambarį" + "Įvyko nežinoma klaida ir informacijos pakeisti nepavyko." + "Nepavyko atnaujinti kambario" + "Žinutės yra užrakintos. Tik Jūs ir gavėjai turite unikalius raktus joms atrakinti." + "Įjungtas žinučių šifravimas" + "Pakviesti žmonių" + "Palikti pokalbį" + "Palikti kambarį" + "Kambario pavadinimas" + "Saugumas" + "Bendrinti kambarį" + "Tema" + "Atnaujinamas kambarys…" + + "%1$d asmuo" + "%1$d asmenys" + "%1$d asmenų" + + "Kambario nariai" + diff --git a/features/roomdetails/impl/src/main/res/values-nb/translations.xml b/features/roomdetails/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..7d23507 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,146 @@ + + + "Du trenger en adresse til rommet for å gjøre det synlig i katalogen." + "Romadresse" + "Det oppstod en feil under oppdatering av varslingsinnstillingen." + "Hjemmeserveren din støtter ikke dette alternativet i krypterte rom, og det kan hende at du ikke blir varslet i enkelte rom." + "Avstemninger" + "Kun for administratorer" + "Forby folk" + "Fjern meldinger" + "Inviter folk og godta forespørsler om å bli med" + "Meldinger og innhold" + "Administratorer og moderatorer" + "Fjern folk og avslå forespørsler om å bli med" + "Endre romavatar" + "Rediger rom" + "Endre romnavn" + "Endre temaet til rommet" + "Send meldinger" + "Rediger administratorer" + "Du vil ikke kunne angre denne handlingen. Du forfremmer brukeren til å ha samme rettighetsnivå som deg." + "Legg til administrator?" + "Du kan ikke angre denne handlingen. Du overfører eierskapet til de valgte brukerne. Når du forlater siden, vil dette være permanent." + "Overføre eierskapet?" + "Degradere" + "Du vil ikke kunne angre denne endringen ettersom du degraderer deg selv, og hvis du er den siste privilegerte brukeren i rommet, vil det være umulig å få tilbake privilegiene." + "Degradere deg selv?" + "%1$s (Venter)" + "(Venter)" + "Administratorer har automatisk moderatorrettigheter" + "Eiere har automatisk administratorrettigheter." + "Rediger moderatorer" + "Velg eiere" + "Administratorer" + "Moderatorer" + "Medlemmer" + "Du har endringer som ikke er lagret." + "Lagre endringer?" + "Legg til emne" + "Kryptert" + "Ikke kryptert" + "Offentlig rom" + "Rediger rom" + "Det oppstod en ukjent feil, og informasjonen kunne ikke endres." + "Kan ikke oppdatere rommet" + "Meldingene er krypterte. Det er bare du og mottakerne som har de unike nøklene til å låse dem opp." + "Meldingskryptering aktivert" + "Det oppstod en feil ved lasting av varslingsinnstillinger." + "Mislyktes i å dempe dette rommet, prøv igjen." + "Mislyktes i å oppheve dempingen av dette rommet, prøv igjen." + "Ikke lukk appen før den er ferdig." + "Forbereder invitasjoner…" + "Inviter folk" + "Forlat samtalen" + "Forlat rommet" + "Medier og filer" + "Tilpasset" + "Standard" + "Varslinger" + "Festede meldinger" + "Profil" + "Forespørsler om å bli med" + "Roller og tillatelser" + "Romnavn" + "Sikkerhet og personvern" + "Sikkerhet" + "Del rom" + "Informasjon om rommet" + "Emne" + "Oppdaterer rommet …" + "Det er ingen utestengte brukere i dette rommet." + + "%1$d person" + "%1$d personer" + + "Fjern og utesteng medlem" + "Bare fjern medlem" + "Opphev utestengelse" + "De vil kunne bli med i dette rommet igjen hvis de blir invitert." + "Fjern utestengelsen fra rommet" + "Utestengt" + "Medlemmer" + "Kun for administratorer" + "Administratorer og moderatorer" + "Eier" + "Medlemmer av rommet" + "Oppheve utestengelsen av %1$s" + "Tillat egendefinert innstilling" + "Hvis du slår på dette, overstyrer du standardinnstillingen" + "Varsle meg i denne chatten om" + "Du kan endre det i din %1$s." + "globale innstillinger" + "Standard innstilling" + "Fjern egendefinert innstilling" + "Det oppstod en feil ved innlasting av varslingsinnstillinger." + "Gjenoppretting av standardmodus mislyktes, prøv igjen." + "Innstilling av modus mislyktes, prøv igjen." + "Hjemmeserveren din støtter ikke dette alternativet i krypterte rom, og du vil ikke bli varslet i dette rommet." + "Alle meldinger" + "Bare omtaler og nøkkelord" + "I dette rommet, varsle meg om" + "Administratorer" + "Administratorer og eiere" + "Endre rollen min" + "Nedgradere til medlem" + "Nedgradere til moderator" + "Moderering av medlemmer" + "Meldinger og innhold" + "Moderatorer" + "Eiere" + "Tilbakestill tillatelser" + "Når du har tilbakestilt tillatelsene, mister du gjeldende innstillinger." + "Vil du tilbakestille tillatelsene?" + "Roller" + "Romdetaljer" + "Roller og tillatelser" + "Legg til romadresse" + "Alle kan be om å bli med i rommet, men en administrator eller moderator må godta forespørselen." + "Be om å bli med" + "Ja, aktiver kryptering" + "Når kryptering for et rom er aktivert, kan den ikke deaktiveres. Meldingshistorikken vil bare være synlig for rommedlemmer siden de ble invitert eller siden de ble med i rommet. +Ingen andre enn rommedlemmene vil kunne lese meldingene. Dette kan føre til at bots og broer ikke fungerer som de skal. +Vi anbefaler ikke å aktivere kryptering for rom som hvem som helst kan finne og bli med i." + "Vil du aktivere kryptering?" + "Når kryptering er aktivert, kan det ikke deaktiveres." + "Kryptering" + "Aktiver ende-til-ende-kryptering" + "Alle kan finne og bli med" + "Alle" + "Folk kan bare bli med hvis de er invitert" + "Kun for inviterte" + "Tilgang til rom" + "Medlemmer av område" + "Områder støttes ikke for øyeblikket" + "Du trenger en adresse til rommet for å gjøre det synlig i katalogen." + "Tillat at dette rommet blir funnet ved å søke %1$s offentlig romkatalog" + "Synlig i offentlig romkatalog" + "Alle" + "Hvem kan lese historikk" + "Medlemmer bare siden de ble invitert" + "Kun medlemmer siden du valgte dette alternativet" + "Romadresser er måter å finne og få tilgang til rom på. Dette sikrer også at du enkelt kan dele rommet ditt med andre. +Du kan velge å publisere rommet ditt i hjemeserverens offentlige romkatalog." + "Publisering av rom" + "Sikkerhet og personvern" + diff --git a/features/roomdetails/impl/src/main/res/values-nl/translations.xml b/features/roomdetails/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..73e7b7f --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,105 @@ + + + "Er is een fout opgetreden bij het bijwerken van de meldingsinstelling." + "Je homeserver ondersteunt deze optie niet in versleutelde kamers; in sommige kamers krijg je mogelijk geen meldingen." + "Peilingen" + "Alleen beheerders" + "Personen verbannen" + "Berichten verwijderen" + "Nodig personen uit en accepteer verzoeken om deel te nemen" + "Berichten en inhoud" + "Beheerders en moderators" + "Verwijder personen en weiger verzoeken om deel te nemen" + "Kamerafbeelding wijzigen" + "Kamer bewerken" + "Kamernaam wijzigen" + "Kameronderwerp wijzigen" + "Berichten verzenden" + "Beheerders bewerken" + "Je kunt deze actie niet ongedaan maken. Je bevordert deze gebruiker tot hetzelfde machtsniveau als jij." + "Beheerder toevoegen?" + "Degraderen" + "Je kunt deze wijziging niet ongedaan maken omdat je jezelf degradeert. Als je de laatste gebruiker met bevoegdheden in de kamer bent, is het onmogelijk om deze bevoegdheden terug te krijgen." + "Jezelf degraderen?" + "%1$s (In behandeling)" + "(In afwachting)" + "Beheerders hebben automatisch moderatorrechten" + "Moderators bewerken" + "Beheerders" + "Moderators" + "Leden" + "Je hebt niet-opgeslagen wijzigingen" + "Wijzigingen opslaan?" + "Onderwerp toevoegen" + "Versleuteld" + "Niet versleuteld" + "Openbare kamer" + "Kamer bewerken" + "Er is een onbekende fout opgetreden en de informatie kon niet worden gewijzigd." + "Kan kamer niet bijwerken" + "Berichten zijn beveiligd met sloten. Alleen jij en de ontvangers hebben de unieke sleutels om ze te ontgrendelen." + "Berichtversleuteling ingeschakeld" + "Er is een fout opgetreden bij het laden van de meldingsinstellingen." + "Het dempen van deze kamer is mislukt. Probeer het opnieuw." + "Het dempen opheffen voor deze kamer is mislukt. Probeer het opnieuw." + "Mensen uitnodigen" + "Gesprek verlaten" + "Kamer verlaten" + "Aangepast" + "Standaard" + "Meldingen" + "Vastgezette berichten" + "Profiel" + "Rollen en rechten" + "Naam van de kamer" + "Beveiliging" + "Kamer delen" + "Kamer info" + "Onderwerp" + "Kamer bijwerken…" + "Er zijn geen verbannen gebruikers in deze kamer." + + "%1$d persoon" + "%1$d personen" + + "Lid verwijderen en verbannen" + "Alleen lid verwijderen" + "Ontbannen" + "Ze kunnen opnieuw tot de kamer toetreden als ze worden uitgenodigd." + "Verbannen" + "Leden" + "Alleen beheerders" + "Beheerders en moderators" + "Kamerleden" + "%1$s ontbannen" + "Aanpassen toestaan" + "Als je dit inschakelt, wordt je standaardinstelling overschreven" + "Stuur me een melding in deze chat voor" + "Je kunt het wijzigen in je %1$s." + "algemene instellingen" + "Standaardinstelling" + "Aanpassingen verwijderen" + "Er is een fout opgetreden bij het laden van de meldingsinstellingen." + "Het herstellen van de standaardmeldingen is mislukt. Probeer het opnieuw." + "Het instellen van de meldingen is mislukt. Probeer het opnieuw." + "Je homeserver ondersteunt deze optie niet in versleutelde kamers; in deze kamer krijg je geen meldingen." + "Alle berichten" + "Alleen vermeldingen en trefwoorden" + "In deze kamer, stuur me een melding voor" + "Beheerders" + "Mijn rol wijzigen" + "Degraderen tot lid" + "Degraderen tot moderator" + "Moderatie van leden" + "Berichten en inhoud" + "Moderators" + "Rechten opnieuw instellen" + "Als je de rechten opnieuw instelt, raak je de huidige instellingen kwijt." + "Rechten opnieuw instellen?" + "Rollen" + "Kamergegevens" + "Rollen en rechten" + "Vraag om toe te treden" + "Iedereen" + "Iedereen" + diff --git a/features/roomdetails/impl/src/main/res/values-pl/translations.xml b/features/roomdetails/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..a24774c --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,149 @@ + + + "Aby pokój był widoczny w katalogu, potrzebny jest adres pokoju." + "Adres pokoju" + "Wystąpił błąd podczas aktualizacji ustawienia powiadomień." + "Twój serwer domowy nie wspiera tej opcji w pokojach szyfrowanych, możesz nie otrzymać powiadomień z niektórych pokoi." + "Ankiety" + "Tylko administratorzy" + "Banowanie osób" + "Usuń wiadomości" + "Zapraszanie osób i akceptowanie próśb o dołączenie" + "Wiadomości i zawartość" + "Administratorzy i moderatorzy" + "Usuwanie osób i odrzucanie próśb o dołączenie" + "Zmień awatar pokoju" + "Edytuj pokój" + "Zmień nazwę pokoju" + "Zmień temat pokoju" + "Wysyłanie wiadomości" + "Edytuj administratorów" + "Tej akcji nie będzie można cofnąć. Promujesz użytkownika, który będzie posiadał takie same uprawnienia jak Ty." + "Dodać administratora?" + "Tej akcji nie będzie można cofnąć. Przenosisz prawa własności na wybranych użytkowników. Po opuszczeniu tej strony zmiana będzie nieodwracalna." + "Przenieść własność?" + "Zdegraduj" + "Nie będzie można cofnąć tej zmiany, jeśli się zdegradujesz. Jeśli jesteś ostatnim uprzywilejowanym użytkownikiem w pokoju, nie będziesz w stanie odzyskać uprawnień." + "Zdegradować siebie?" + "%1$s (Oczekujące)" + "(Oczekujący)" + "Administratorzy automatycznie mają uprawnienia moderatora" + "Właściciele automatycznie mają uprawnienia administratora." + "Edytuj moderatorów" + "Wybierz właścicieli" + "Administratorzy" + "Moderatorzy" + "Członków" + "Masz niezapisane zmiany." + "Zapisać zmiany?" + "Dodaj temat" + "Szyfrowany" + "Nieszyfrowany" + "Pokój publiczny" + "Edytuj pokój" + "Wystąpił nieznany błąd i nie można było zmienić informacji." + "Nie można zaktualizować pokoju" + "Wiadomości są zabezpieczone kłódkami. Tylko Ty i odbiorcy macie unikalne klucze do ich odblokowania." + "Szyfrowanie wiadomości włączone" + "Wystąpił błąd podczas ładowania ustawień powiadomień." + "Wyciszenie tego pokoju nie powiodło się, spróbuj ponownie." + "Nie udało się wyłączyć wyciszenia tego pokoju. Spróbuj ponownie." + "Nie zamykaj aplikacji przed zakończeniem." + "Przygotowywanie zaproszeń…" + "Zaproś znajomych" + "Opuść rozmowę" + "Opuść pokój" + "Media i pliki" + "Niestandardowe" + "Domyślny" + "Powiadomienia" + "Przypięte wiadomości" + "Profil" + "Prośby o dołączenie" + "Role i uprawnienia" + "Nazwa pokoju" + "Bezpieczeństwo i prywatność" + "Bezpieczeństwo" + "Udostępnij pokój" + "Informacje pokoju" + "Temat" + "Aktualizuję pokój…" + "W tym pokoju nie ma zbanowanych użytkowników." + + "%1$d osoba" + "%1$d osoby" + "%1$d osób" + + "Usuń i zbanuj członka" + "Tylko usuń członka" + "Odbanuj" + "Będą mogli ponownie dołączyć do tego pokoju, jeśli zostaną zaproszeni." + "Odbanuj z pokoju" + "Zbanowanych" + "Członków" + "Tylko administratorzy" + "Administratorzy i moderatorzy" + "Właściciel" + "Członkowie pokoju" + "Odbanowanie %1$s" + "Zezwalaj na ustawienia niestandardowe" + "Włączenie tej opcji nadpisze ustawienie domyślne" + "Powiadamiaj mnie o tym czacie przez" + "Możesz to zmienić w swoim %1$s." + "ustawienia globalne" + "Ustawienie domyślne" + "Usuń ustawienia własne" + "Wystąpił błąd podczas ładowania ustawień powiadomień." + "Nie udało się przywrócić trybu domyślnego, spróbuj ponownie." + "Nie udało się ustawić trybu, spróbuj ponownie." + "Twój serwer domowy nie wspiera tej opcji w pokojach szyfrowanych, możesz nie otrzymać powiadomień z tego pokoju." + "Wszystkie wiadomości" + "Tylko wzmianki i słowa kluczowe" + "W tym pokoju, powiadamiaj mnie przez" + "Administratorzy" + "Administratorzy i właściciele" + "Zmień moją rolę" + "Zdegraduj do członka" + "Zdegraduj do moderatora" + "Moderacja członków" + "Wiadomości i zawartość" + "Moderatorzy" + "Właściciele" + "Resetuj uprawnienia" + "Po zresetowaniu uprawnień utracisz bieżące ustawienia." + "Zresetować uprawnienia?" + "Role" + "Szczegóły pokoju" + "Role i uprawnienia" + "Dodaj adres pokoju" + "Każdy może poprosić o dołączenie do pokoju, ale administrator lub moderator będzie musiał zatwierdzić żądanie." + "Poproś o dołączenie" + "Tak, włącz szyfrowanie" + "Po włączeniu szyfrowanie pokoju nie może zostać wyłączone, a historia wiadomości będzie widoczna tylko dla członków od momentu, w którym dołączyli lub zostali zaproszeni. +Nikt poza członkami pokoju nie będzie mógł czytać wiadomości. Może to wpłynąć na prawidłowe działanie botów lub mostków. +Odradzamy włączanie szyfrowania dla pokoi, które każdy może znaleźć i do których każdy może dołączyć." + "Włączyć szyfrowanie?" + "Po włączeniu szyfrowania nie można wyłączyć." + "Szyfrowanie" + "Włącz szyfrowanie end-to-end" + "Każdy może znaleźć i dołączyć" + "Wszyscy" + "Tylko osoby z zaproszeniem mogą dołączyć" + "Tylko zaproszenie" + "Dostęp do pokoju" + "Członkowie przestrzeni" + "Przestrzenie nie są obecnie wspierane" + "Aby pokój był widoczny w katalogu, potrzebny jest adres pokoju." + "Adres pokoju" + "Zezwól na znalezienie tego pokoju wyszukując %1$s w katalogu pokoi publicznych" + "Widoczny w katalogu pokoi publicznych" + "Wszyscy" + "Kto może czytać historię" + "Od momentu kiedy członkowie zostali zaproszeni" + "Członkowie od momentu włączenia tej opcji" + "Adresy pokoju umożliwiają łatwe znalezienie i dołączenie do pokojów. +Również możesz się zdecydować na upublicznienie Twojego serwera w katalogu pokoi publicznych." + "Publikowanie pokoju" + "Widoczność pokoju" + "Bezpieczeństwo i prywatność" + diff --git a/features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml b/features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..da19da9 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,164 @@ + + + "Você precisará de um endereço para torná-la visível no diretório." + "Editar endereço" + "Ocorreu um erro ao atualizar a configuração de notificação." + "Seu servidor-casa não suporta esta opção em salas criptografadas. Você pode não ser notificado em algumas salas." + "Enquetes" + "Administradores" + "Banir pessoas" + "Remover mensagens" + "Membro" + "Convidar pessoas" + "Gerenciar membros" + "Mensagens e conteúdo" + "Moderador" + "Remover pessoas" + "Alterar avatar da sala" + "Editar detalhes" + "Alterar nome da sala" + "Alterar tópico da sala" + "Enviar mensagens" + "Editar administradores" + "Você não poderá desfazer essa ação. Você está promovendo o usuário a ter o mesmo nível de poder que você." + "Adicionar administrador?" + "Você não poderá desfazer isto. Você está transferindo a posse desta sala para os usuários selecionados. Ao sair, isto será permanente." + "Transferir posse?" + "Rebaixar" + "Você não poderá desfazer essa alteração, pois estará removendo seus próprios privilégios. Se você for o último usuário privilegiado na sala, será impossível recuperar os privilégios." + "Rebaixar seu próprio privilégio?" + "%1$s (pendente)" + "(pendente)" + "Os administradores têm privilégios de moderador automaticamente" + "Proprietários automaticamente têm privilégios de administradores." + "Editar moderadores" + "Escolher Proprietários" + "Administradores" + "Moderadores" + "Membros" + "Você tem alterações não salvas." + "Salvar alterações?" + "Adicionar tópico" + "Criptografado" + "Não criptografado" + "Sala pública" + "Editar detalhes" + "Ocorreu um erro desconhecido e as informações não puderam ser alteradas." + "Não foi possível atualizar a sala" + "As mensagens são protegidas com cadeados. Somente você e os destinatários têm as chaves exclusivas para desbloqueá-los." + "Criptografia de mensagens ativada" + "Ocorreu um erro ao carregar as configurações de notificação." + "Falha ao silenciar esta sala, tente novamente." + "Falha ao desilenciar esta sala. Tente novamente." + "Não feche o aplicativo até terminar." + "Preparando convites…" + "Convidar pessoas" + "Sair da conversa" + "Sair da sala" + "Mídia e arquivos" + "Personalizado" + "Padrão" + "Notificações" + "Mensagens fixadas" + "Perfil" + "Pedidos de entrada" + "Cargos e permissões" + "Nome da sala" + "Segurança e privacidade" + "Segurança" + "Compartilhar sala" + "Informação da sala" + "Tópico" + "Atualizando a sala…" + "Não há usuários banidos." + + "%1$d banido" + "%1$d banidos" + + "Confira a ortografia ou tente uma nova busca" + "Nenhum resultado para “%1$s”" + + "%1$d pessoa" + "%1$d pessoas" + + "Banir da sala" + "Somente remover o membro" + "Desbanir" + "Esta pessoa poderá entrar nesta sala novamente se for convidada." + "Desbanir da sala" + "Banidos" + "Membros" + + "%1$d convidado" + "%1$d convidados" + + "Pendente" + "Administradores" + "Moderador" + "Proprietário" + "Membros da sala" + "Desbanindo %1$s" + "Permitir configuração personalizada" + "Ativar isso substituirá sua configuração padrão" + "Me notifique nesta conversa de" + "Você pode alterá-la nas suas %1$s." + "configurações globais" + "Configuração padrão" + "Remover configuração personalizada" + "Ocorreu um erro ao carregar as configurações de notificação." + "Falha ao restaurar o modo padrão, tente novamente." + "Falha ao definir o modo, tente novamente." + "Seu servidor-casa não suporta esta opção em salas criptografadas, você não será notificado nesta sala." + "Todas as mensagens" + "Somente menções e palavras-chave" + "Nesta sala, notifique-me de" + "Administradores" + "Administradores e proprietários" + "Alterar meu cargo" + "Rebaixar para membro" + "Rebaixar para moderador" + "Moderação de membros" + "Mensagens e conteúdo" + "Moderadores" + "Proprietários" + "Permissões" + "Redefinir permissões" + "Depois de redefinir as permissões, você perderá as configurações atuais." + "Redefinir permissões?" + "Cargos" + "Detalhes da sala" + "Cargos e permissões" + "Adicionar endereço" + "Qualquer um pode pedir acesso, mas um administrador terá que aceitar o pedido." + "Pedir para entrar" + "Sim, ativar a criptografia" + "Uma vez ativada, a criptografia de uma sala não pode ser desativada. O histórico de mensagens só será visível para os membros da sala desde que foram convidados ou desde que entraram na sala. +Ninguém além dos membros da sala poderá ler as mensagens. Isso pode impedir que os bots e as pontes funcionem corretamente. +Não recomendamos que você ative a criptografia para salas que qualquer pessoa possa encontrar e participar." + "Ativar a criptografia?" + "Uma vez ativada, a criptografia não poderá ser desativada." + "Criptografia" + "Ativar a criptografia de ponta a ponta" + "Qualquer um pode entrar" + "Qualquer pessoa" + "Apenas pessoas convidadas podem entrar." + "Privado" + "Acesso" + "Membros do espaço" + "No momento, não há suporte aos espaços" + "Você precisará de um endereço para torná-la visível no diretório." + "Endereço publicado" + "Permitir que esta sala seja encontrada pesquisando diretório de salas públicas de %1$s" + "Permite que seja encontrada ao buscar no diretório público." + "Visível no diretório público" + "Qualquer pessoa" + "Quem pode ler o histórico" + "Somente membros, desde que foram convidados" + "Somente para membros após selecionar esta opção" + "Os endereços das salas são formas de encontrar e acessar as salas. Isso também garante que você possa compartilhar facilmente sua sala com outras pessoas. +Você pode optar por publicar sua sala no diretório público de salas do seu servidor-casa." + "Publicação da sala" + "Os endereços das salas são formas de encontrar e acessar as salas. Isso também garante que você possa compartilhá-las facilmente com outras pessoas." + "Visibilidade" + "Segurança e privacidade" + diff --git a/features/roomdetails/impl/src/main/res/values-pt/translations.xml b/features/roomdetails/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..b682c05 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,148 @@ + + + "É necessário um endereço para tornar a sala visível no diretório." + "Endereço da sala" + "Erro ao atualizar a configuração de notificação." + "O teu servidor não suporta esta opção em salas cifradas, pelo que poderás não ser notificado em algumas salas." + "Sondagens" + "Apenas administradores" + "Banir pessoas" + "Remover mensagens" + "Convidar pessoas e aceitar pedidos de entrada" + "Mensagens e conteúdo" + "Administradores e moderadores" + "Remover pessoas e rejeitar pedidos de entrada" + "Alterar o ícone da sala" + "Editar sala" + "Altera o nome da sala" + "Alterar a descrição da sala" + "Enviar mensagens" + "Editar Administradores" + "Não poderás desfazer esta ação. Estás a promover o utilizador para ter o mesmo nível de poder que tu." + "Adicionar administrador?" + "Não será possível reverter esta ação. Estás a transferir a posse para os utilizadores selecionados. Será permanente depois de saíres." + "Transferir posse?" + "Despromover" + "Não poderás desfazer esta alteração, uma vez que te estás a despromover. Se fores o último utilizador privilegiado na sala, será impossível recuperar os privilégios." + "Despromover-te?" + "%1$s (pendente)" + "(pendente)" + "Os administradores têm automaticamente privilégios de moderador" + "Os donos têm permissões de administrador automaticamente" + "Editar Moderadores" + "Escolher donos" + "Administradores" + "Moderadores" + "Participantes" + "Tens alterações por guardar." + "Guardar alterações?" + "Adicionar descrição" + "Cifrada" + "Não cifrada" + "Sala pública" + "Editar sala" + "Ocorreu um erro desconhecido e não foi possível alterar a informação." + "Não foi possível atualizar a sala" + "As mensagens são protegidas por cadeados. Apenas tu e os destinatários têm as chaves únicas para os desbloquear." + "Cifragem de mensagens ativada" + "Erro ao carregar as configurações de notificação." + "Não foi possível silenciar esta sala, por favor tenta novamente." + "Não foi possível dessilenciar esta sala, por favor tenta novamente." + "Não feches a aplicação até concluir." + "A preparar convites…" + "Convidar pessoas" + "Sair da conversa" + "Sair da sala" + "Multimédia e ficheiros" + "Personalizado" + "Predefinição" + "Notificações" + "Mensagens afixadas" + "Perfil" + "Pedidos de entrada" + "Cargos e permissões" + "Nome da sala" + "Segurança e privacidade" + "Segurança" + "Partilhar sala" + "Informação da sala" + "Descrição" + "A atualizar sala…" + "Não há nenhum utilizador banido desta sala." + + "%1$d pessoa" + "%1$d pessoas" + + "Remover e banir participante" + "Remover apenas" + "Anular banimento" + "Poderão juntar-se novamente a esta sala se forem convidados." + "Desbanir da sala" + "Banidos" + "Participantes" + "Apenas administradores" + "Administradores e moderadores" + "Dono / Dona" + "Participantes" + "A anular banimento de %1$s" + "Permitir configuração personalizada" + "Ativar esta opção substitui a tua configuração predefinida" + "Nesta conversa, notifica-me se" + "Podes alterá-lo nas tuas %1$s." + "configurações globais" + "Predefinição" + "Remover configuração personalizada" + "Erro ao carregar as configurações de notificação." + "Falha ao restaurar o modo predefinido, tenta novamente." + "Falha ao definir o modo, tenta novamente." + "O teu servidor não suporta esta opção em salas cifradas, pelo que não serás notificado nesta sala." + "Qualquer mensagem" + "Menções ou palavras-chave" + "Nesta sala, notifica-me se" + "Administradores" + "Administradores e donos" + "Alterar o meu cargo" + "Despromover para participante" + "Despromover para moderador" + "Moderação de participantes" + "Mensagens e conteúdo" + "Moderadores" + "Donos" + "Repor permissões" + "Ao repores as permissões, perderás as configurações atuais." + "Repor as permissões?" + "Cargos" + "Detalhes da sala" + "Cargos e permissões" + "Adicionar endereço de sala" + "Qualquer pessoa pode pedir para entrar na sala, mas um administrador ou moderador tem que aceitar o pedido." + "Pedir para participar" + "Sim, ativar cifragem" + "Uma vez ativada, a cifragem não pode ser desativada. O histórico de mensagens só será visível a membros a partir do momento em que foram convidados ou que entraram na sala. +Ninguém além dos membros poderão ler quaisquer mensagens. Isto pode impedir que robôs (\"bots\") e pontes (\"bridges\") funcionem devidamente. +Não recomendamos ativar a cifragem em salas que qualquer pessoa possa encontrar e entrar." + "Ativar cifragem?" + "Uma vez ativada, a cifragem não pode ser desativada." + "Cifragem" + "Ativar cifragem ponta-a-ponta" + "Qualquer pessoa pode encontrar a sala e entrar" + "Qualquer pessoa" + "Só é possível entrar tendo um convite" + "Apenas por convite" + "Acesso à sala" + "Membros do espaço" + "Os espaços ainda não estão implementados" + "É necessário um endereço para tornar a sala visível no diretório." + "Endereço da sala" + "Permite que esta sala seja encontrada através do diretório público do %1$s." + "Visível no diretório público de salas" + "Qualquer pessoa" + "Quem pode ler o histórico de mensagens" + "Apenas membros, desde o momento em que forem convidados" + "Apenas membros, desde o memento em que esta opção for selecionada" + "Estes endereços permitem encontrar e aceder a sala, bem como a sua fácil partilha com outros. +Podes escolher publicar a sala no diretório público do teu servidor." + "Publicar sala" + "Visibilidade da sala" + "Segurança e privacidade" + 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 0000000..f0c43e5 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,146 @@ + + + "Veți avea nevoie de o adresă de cameră pentru a o face vizibilă în director." + "Adresa camerei" + "A apărut o eroare în timpul actualizării setărilor pentru notificari." + "Serverul dumneavoastră nu acceptă această opțiune în camerele criptate, este posibil să nu primiți notificări în unele camere." + "Sondaje" + "Doar administratori" + "Interziceți persoane" + "Ștergeți mesajele" + "Invitați persoane și acceptați cereri de alaturare" + "Mesaje și conținut" + "Administratori și moderatori" + "Îndepărtați persoane și refuzați cereri de alăturare" + "Schimbați avatarul camerei" + "Editați camera" + "Schimbă numele camerei" + "Schimbați subiectul camerei" + "Trimiteți mesaje" + "Editați administratorii" + "Promovați utilizatorul să aibă același nivel de putere ca dumneavoastră. Nu veți putea anula această acțiune." + "Adăugați administrator?" + "Nu veți putea anula această acțiune. Transferați dreptul de proprietate către utilizatorii selectați. Odată ce părăsiți această pagină, acțiunea va fi definitivă." + "Transferați proprietatea?" + "Retrogradare" + "Nu veți putea anula această modificare, deoarece vă retrogradați. Dacă sunteți ultimul utilizator privilegiat din cameră, va fi imposibil să recâștigați privilegiile." + "Vreți să vă retrogradați?" + "%1$s (În așteptare)" + "(În așteptare)" + "Administratorii au automat privilegii de moderator" + "Proprietarii au automat privilegii de administrator." + "Editați moderatorii" + "Alegeți proprietari" + "Administratori" + "Moderatori" + "Membri" + "Aveți modificări nesalvate." + "Salvați modificările?" + "Adăugare subiect" + "Criptat" + "Necriptat" + "Cameră publică" + "Editați camera" + "A apărut o eroare la actualizarea detaliilor camerei" + "Nu s-a putut actualiza camera" + "Mesajele sunt securizate cu încuietori. Doar dumneavoastră și destinatarii aveți cheile unice pentru a le debloca." + "Criptarea mesajelor este activată" + "A apărut o eroare la încărcarea setărilor pentru notificari." + "Dezactivarea notificarilor pentru această cameră a eșuat, încercați din nou." + "Activarea notificarilor pentru această cameră a eșuat, încercați din nou." + "Nu închideți aplicația până nu se termină." + "Se pregătesc invitațiile…" + "Invitați prieteni" + "Părăsiți conversația" + "Părăsiți camera" + "Media și fișiere" + "Personalizat" + "Implicit" + "Notificări" + "Mesaje fixate" + "Profil" + "Cereri de alăturare" + "Roluri și permisiuni" + "Numele camerei" + "Securitate & confidențialitate" + "Securitate" + "Partajați camera" + "Informatii camera" + "Subiect" + "Se actualizează camera…" + "Nu există utilizatori interziși în această cameră." + + "o persoană" + "%1$d persoane" + + "Îndepărtați și interziceți membrul" + "Doar înlăturare" + "Anulare excludere" + "Se vor putea alătura din nou acestei săli dacă sunt invitați." + "Revocati excluderea din camera" + "Excluși" + "Membri" + "Doar administratori" + "Administratori și moderatori" + "Proprietar" + "Membrii camerei" + "Se anulează interzicerea lui %1$s" + "Permiteți setări personalizate" + "Activarea acestei opțiuni va anula setările implicite." + "Anunțați-mă în acestă cameră pentru" + "Îl puteți schimba în %1$s." + "Setări generale" + "Setare implicită" + "Ștergeți setarea personalizată" + "A apărut o eroare la încărcarea setărilor pentry notificari." + "Nu s-a reușit restaurarea modului implicit, vă rugăm să încercați din nou." + "Nu s-a reușit setarea modului, vă rugăm să încercați din nou." + "Serverul dumneavoastră nu acceptă această opțiune în camerele criptate, nu veți primi notificări în această cameră." + "Toate mesajele" + "Numai mențiuni și cuvinte cheie" + "În această cameră, anunțați-mă pentru" + "Administratori" + "Administratori și proprietari" + "Schimbare rol" + "Degradare la membru" + "Degradare la moderator" + "Moderarea membrilor" + "Mesaje și conținut" + "Moderatori" + "Proprietari" + "Resetați permisiunile" + "După ce resetați permisiunile, veți pierde setările curente." + "Resetați permisiunile?" + "Roluri" + "Detaliile camerei" + "Roluri și permisiuni" + "Adăugați adresa camerei" + "Oricine poate cere să se alăture camerei, dar un administrator sau moderator va trebui să accepte cererea." + "Cereți să vă alăturați" + "Da, activați criptarea" + "Odată activată, criptarea pentru o cameră nu poate fi dezactivată. Mesajele anterioare vor fi vizibile numai pentru membrii camerei de la momentul la care au fost invitați sau de la momentul la care s-au alăturat camerei. +Nimeni în afară de membrii camerei nu va putea citi messaje. Acest lucru poate împiedica funcționarea corectă a boților și a punților. +Nu recomandăm activarea criptării pentru camerele pe care oricine le poate găsi și la care se poate alătura." + "Activați criptarea?" + "Odată activată, criptarea nu poate fi dezactivată." + "Criptare" + "Activați criptarea end-to-end" + "Oricine poate găsi și alătura camerei" + "Oricine" + "Persoanele se pot alătura numai dacă invitate" + "Doar pe bază de invitație" + "Acces la cameră" + "Membrii spațiului" + "Spațiile nu sunt momentan suportate." + "Veți avea nevoie de o adresă de cameră pentru a o face vizibilă în director." + "Permiteți găsirea acestei camere prin căutarea în directorul de camere publice al %1$s" + "Vizibilă în directorul de camere publice" + "Oricine" + "Cine poate citi mesajele anterioare" + "Doar pentru membri, de la momentul în care au fost invitați" + "Doar pentru membri, după selectarea acestei opțiuni" + "Adresele camerelor sunt modalități de a găsi și accesa camere. Acest lucru vă asigură, de asemenea, că puteți partaja cu ușurință camera dumneavoastră cu alte persoane. +Puteți alege să publicați camera în directorul public al camerelor serverului dumneavoastră." + "Publicare cameră" + "Securitate & confidențialitate" + diff --git a/features/roomdetails/impl/src/main/res/values-ru/translations.xml b/features/roomdetails/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..47de4c3 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,156 @@ + + + "Вам понадобится адрес комнаты, чтобы сделать ее видимой в каталоге." + "Редактировать адрес комнаты" + "Произошла ошибка при обновлении настройки уведомления." + "Ваш домашний сервер не поддерживает эту опцию в зашифрованных комнатах, в некоторых комнатах вы можете не получать уведомления." + "Опросы" + "Только администраторы" + "Блокировать людей могут" + "Удалить сообщения" + "Участник" + "Пригласить людей" + "Список участников" + "Сообщения и содержание" + "Модератор" + "Удалять участников" + "Менять изображение комнаты могут" + "Редактировать комнату" + "Менять название комнаты могут" + "Менять тему комнаты могут" + "Отправлять сообщения могут" + "Редактировать роль администраторов" + "Вы не сможете отменить это действие. Вы устанавливаете уровень пользователю соответствующий вашему." + "Добавить администратора?" + "Отменить данное действие будет невозможно. Владение передастся выбранным пользователям. После вашего выхода действие станет необратимым." + "Передать владение?" + "Понизить уровень" + "Вы не сможете отменить это изменение, так как понижаете себя статус. Если вы являетесь последним привилегированным пользователем в комнате, восстановить привилегии будет невозможно." + "Понизить свой уровень?" + "%1$s (Ожидание)" + "(В ожидании)" + "Администраторы автоматически получают права модератора" + "Владельцы автоматически получают права администратора." + "Редактировать роль модераторов" + "Назначить владельцев" + "Администраторы" + "Модераторы" + "Участники" + "У вас есть несохраненные изменения." + "Сохранить изменения?" + "Добавить тему" + "Зашифровано" + "Шифрования нет" + "Общедоступная комната" + "Редактировать комнату" + "Произошла неизвестная ошибка и информацию не удалось изменить." + "Не удалось обновить комнату" + "Сообщения зашифрованы. Только у вас и у получателей есть уникальные ключи для их разблокировки." + "Шифрование сообщений включено" + "Произошла ошибка при загрузке настроек уведомлений." + "Не удалось отключить звук в этой комнате, попробуйте еще раз." + "Не удалось включить звук в эту комнату, попробуйте еще раз." + "Не закрывайте приложение, пока не закончите." + "Подготовка приглашений…" + "Пригласить в комнату" + "Покинуть беседу" + "Покинуть комнату" + "Медиа и файлы" + "Пользовательский" + "По умолчанию" + "Уведомления" + "Закрепленные сообщения" + "Профиль" + "Запросы на вступление" + "Роли и разрешения" + "Название комнаты" + "Безопасность и конфиденциальность" + "Безопасность" + "Поделиться комнатой" + "Информация о комнате" + "Тема" + "Обновление комнаты…" + "В этой комнате нет заблокированных пользователей." + "Проверьте правописание или попробуйте новый поиск" + "Отсутствует результат по запросу “%1$s”" + + "%1$d пользователь" + "%1$d пользователя" + "%1$d пользователей" + + "Удалить и заблокировать участника" + "Только удалить участника" + "Разблокировать" + "Они снова смогут присоединиться в эту комнату если их пригласят." + "Разблокировать в комнате" + "Заблокированные" + "Участники" + "В ожидании" + "Только администраторы" + "Модератор" + "Владелец" + "Участники комнаты" + "Разблокировка %1$s" + "Разрешить пользовательские настройки" + "Включение этого параметра отменяет настройки по умолчанию" + "Уведомлять меня в этом чате" + "Вы можете изменить его в своем %1$s." + "основные настройки" + "Настройка по умолчанию" + "Удалить пользовательскую настройку" + "Произошла ошибка при загрузке настроек уведомлений." + "Не удалось восстановить режим по умолчанию, попробуйте еще раз." + "Не удалось настроить режим, попробуйте еще раз." + "Ваш домашний сервер не поддерживает эту опцию в зашифрованных комнатах, вы не будете получать уведомления в этой комнате." + "О всех сообщениях" + "Только упоминания и ключевые слова" + "В этой комнате уведомлять меня" + "Администраторы" + "Администраторы и владельцы" + "Изменить мою роль" + "Понизить до участника" + "Понизить до модератора" + "Модерация участников" + "Сообщения и содержание" + "Модераторы" + "Владельцы" + "Разрешения" + "Сбросить разрешения" + "Как только вы сбросите разрешения, все текущие настройки будут утеряны." + "Сбросить разрешения?" + "Роли" + "Информация о комнате" + "Роли и разрешения" + "Добавить адрес" + "Каждый должен запросить доступ." + "Попросить присоединиться" + "Да, включить шифрование" + "Шифрование комнаты нельзя будет отключить, история сообщений будет видна только участникам комнаты с момента их приглашения или с момента присоединения к комнате. +Никто, кроме членов комнаты, не сможет читать сообщения. Это может помешать ботам и мостам работать корректно. +Мы не рекомендуем включать шифрование для комнат, в которые может найти и присоединиться любой желающий." + "Включить шифрование?" + "После включения, шифрование не может быть отключено." + "Шифрование" + "Включить сквозное шифрование" + "Любой желающий может найти и присоединиться" + "Любой" + "Присоединиться могут только приглашенные люди." + "Только по приглашению" + "Доступ" + "Участники пространства" + "Пространства в настоящее время не поддерживаются." + "Вам понадобится адрес комнаты, чтобы сделать ее видимой в каталоге." + "Адрес" + "Опубликовать %1$s в каталоге публичных комнат" + "Разрешить поиск в публичном каталоге." + "Доступна в списке публичных комнат" + "Любой" + "Кто может читать историю" + "Участники только с тех пор, как они были приглашены" + "Только для участников с момента выбора этой опции" + "Адреса комнат — это способ найти комнату и получить к ней доступ. Это также гарантирует, что вы сможете легко поделиться своей комнатой с другими. +Вы можете опубликовать свою комнату в каталоге общедоступных комнат на домашнем сервере." + "Публикация комнат" + "Видимость" + "Безопасность и конфиденциальность" + 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 0000000..477b419 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,149 @@ + + + "Budete potrebovať adresu miestnosti, aby bola viditeľná v adresári." + "Adresa miestnosti" + "Pri aktualizácii nastavenia oznámenia došlo k chybe." + "Váš domovský server nepodporuje túto možnosť v šifrovaných miestnostiach, v niektorých miestnostiach nemusíte dostať upozornenie." + "Ankety" + "Iba správcovia" + "Zakázať ľudí" + "Odstrániť správy" + "Pozvite ľudí a prijmite žiadosti o pripojenie" + "Správy a obsah" + "Správcovia a moderátori" + "Odstrániť ľudí a odmietnuť žiadosti o pripojenie" + "Zmeniť obrázok miestnosti" + "Upraviť miestnosť" + "Zmeniť názov miestnosti" + "Zmeniť tému miestnosti" + "Odoslať správy" + "Upraviť správcov" + "Túto akciu nebudete môcť vrátiť späť. Zvyšujete úroveň používateľa na rovnakú úroveň výkonu ako máte vy." + "Pridať správcu?" + "Túto akciu nebude možné vrátiť späť. Prenášate vlastníctvo na vybraných používateľov. Po opustení bude táto akcia trvalá." + "Previesť vlastníctvo?" + "Znížiť" + "Túto zmenu nebudete môcť vrátiť späť, pretože znižujete svoju úroveň. Ak ste posledným privilegovaným používateľom v miestnosti, nebude možné získať znova oprávnenia." + "Znížiť svoju úroveň?" + "%1$s (Čaká sa)" + "(Čaká sa)" + "Správcovia majú automaticky oprávnenia moderátora" + "Vlastníci majú automaticky správcovské oprávnenia." + "Upraviť moderátorov" + "Vybrať vlastníkov" + "Správcovia" + "Moderátori" + "Členovia" + "Máte neuložené zmeny." + "Uložiť zmeny?" + "Pridať tému" + "Zašifrované" + "Nešifrované" + "Verejná miestnosť" + "Upraviť miestnosť" + "Vyskytla sa neznáma chyba a informácie nebolo možné zmeniť." + "Nepodarilo sa aktualizovať miestnosť" + "Správy sú zabezpečené zámkami. Jedine vy a príjemcovia máte jedinečné kľúče na ich odomknutie." + "Šifrovanie správ je zapnuté" + "Pri načítaní nastavení oznámení došlo k chybe." + "Nepodarilo sa stlmiť túto miestnosť, skúste to prosím znova." + "Nepodarilo sa zrušiť stlmenie tejto miestnosti, skúste to prosím znova." + "Nezatvárajte aplikáciu, kým sa neukončí pozývanie." + "Príprava pozvánok…" + "Pozvať ľudí" + "Opustiť konverzáciu" + "Opustiť miestnosť" + "Médiá a súbory" + "Vlastné" + "Predvolené" + "Oznámenia" + "Pripnuté správy" + "Profil" + "Žiadosti o vstup" + "Roly a povolenia" + "Názov miestnosti" + "Bezpečnosť a súkromie" + "Bezpečnosť" + "Zdieľať miestnosť" + "Informácie o miestnosti" + "Téma" + "Aktualizácia miestnosti…" + "Neexistujú žiadni zablokovaní používatelia." + + "%1$d osoba" + "%1$d osoby" + "%1$d osôb" + + "Odstrániť a zakázať člena" + "Iba odstrániť člena" + "Zrušiť zákaz" + "V prípade pozvania sa budú môcť znova pripojiť k tejto miestnosti." + "Zrušiť zákaz prístupu do miestnosti" + "Zakázaní" + "Členovia" + "Iba správcovia" + "Správcovia a moderátori" + "Vlastník" + "Členovia miestnosti" + "Zrušenie zákazu %1$s" + "Povoliť vlastné nastavenie" + "Zapnutím tohto nastavenia sa prepíše vaše predvolené nastavenie" + "Upozorniť ma v tejto konverzácii na" + "Môžete to zmeniť vo svojich %1$s." + "všeobecných nastaveniach" + "Predvolené nastavenie" + "Odstrániť vlastné nastavenie" + "Pri načítavaní nastavení oznámení došlo k chybe." + "Nepodarilo sa obnoviť predvolený režim, skúste to prosím znova." + "Nepodarilo sa nastaviť režim, skúste to prosím znova." + "Váš domovský server nepodporuje túto možnosť v šifrovaných miestnostiach, v tejto miestnosti nedostanete upozornenie." + "Všetky správy" + "Iba zmienky a kľúčové slová" + "V tejto miestnosti ma upozorniť na" + "Správcovia" + "Správcovia a vlastníci" + "Zmeniť moje oprávnenia" + "Znížiť úroveň na člena" + "Znížiť úroveň na moderátora" + "Moderovanie členov" + "Správy a obsah" + "Moderátori" + "Vlastníci" + "Obnoviť povolenia" + "Po obnovení oprávnení prídete o aktuálne nastavenia." + "Obnoviť oprávnenia?" + "Roly" + "Podrobnosti o miestnosti" + "Roly a povolenia" + "Pridať adresu miestnosti" + "Ktokoľvek môže požiadať o pripojenie do miestnosti, ale správca alebo moderátor bude musieť žiadosť prijať." + "Požiadať o pripojenie" + "Áno, povoliť šifrovanie" + "Po aktivácii nie je možné zakázať šifrovanie pre miestnosť. História správ bude viditeľná len pre členov miestnosti, odkedy boli pozvaní alebo keď vstúpili do miestnosti. +Nikto okrem členov miestnosti nebude môcť čítať správy. +To môže brániť správnemu fungovaniu robotov a premostení. Neodporúčame povoliť šifrovanie pre miestnosti, ktoré môže ktokoľvek nájsť a pripojiť sa k nim." + "Povoliť šifrovanie?" + "Po zapnutí už šifrovanie nie je možné vypnúť." + "Šifrovanie" + "Povoliť end-to-end šifrovanie" + "Ktokoľvek môže nájsť a pripojiť sa" + "Ktokoľvek" + "Ľudia sa môžu pripojiť len vtedy, ak sú pozvaní" + "Iba na pozvánku" + "Prístup do miestnosti" + "Členovia priestoru" + "Priestory momentálne nie sú podporované" + "Budete potrebovať adresu miestnosti, aby bola viditeľná v adresári." + "Adresa miestnosti" + "Umožniť vyhľadanie tejto miestnosti v adresári verejných miestností %1$s" + "Viditeľné v adresári verejných miestností" + "Ktokoľvek" + "Kto môže čítať históriu" + "Len pre členov, odkedy boli pozvaní" + "Len členovia od zvolenia tejto možnosti" + "Adresy miestností predstavujú spôsoby, ako nájsť a získať prístup k miestnostiam. To tiež zaisťuje, že môžete jednoducho zdieľať svoju miestnosť s ostatnými. +Môžete sa rozhodnúť zverejniť svoju miestnosť v adresári verejných miestností vášho domovského servera." + "Zverejnenie miestnosti" + "Viditeľnosť miestnosti" + "Bezpečnosť a súkromie" + diff --git a/features/roomdetails/impl/src/main/res/values-sv/translations.xml b/features/roomdetails/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..2dc590c --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,144 @@ + + + "Du behöver en rumsadress för att göra den synlig i katalogen." + "Rumsadress" + "Ett fel uppstod vid uppdatering av aviseringsinställningen." + "Din hemserver stöder inte det här alternativet i krypterade rum, du kanske inte aviseras i vissa rum." + "Omröstningar" + "Endast administratörer" + "Banna personer" + "Ta bort meddelanden" + "Bjuda in personer och acceptera förfrågningar om att gå med" + "Meddelanden och innehåll" + "Administratörer och moderatorer" + "Ta bort personer och avslå förfrågningar om att gå med" + "Byt rumsavatar" + "Redigera rummet" + "Byt rumsnamn" + "Byt rumsämne" + "Skicka meddelanden" + "Redigera administratörer" + "Du kommer inte att kunna ångra den här åtgärden. Du befordrar användaren till att ha samma behörighetsnivå som du." + "Lägg till Admin?" + "Du kommer inte att kunna ångra den här åtgärden. Du överför ägarskapet till de valda användarna. När du lämnar kommer detta att vara permanent." + "Överför ägarskap?" + "Degradera" + "Du kommer inte att kunna ångra denna ändring eftersom du degraderar dig själv, om du är den sista privilegierade användaren i rummet kommer det att vara omöjligt att återfå privilegier." + "Degradera dig själv?" + "%1$s (Väntar)" + "(Väntar)" + "Administratörer har automatiskt moderatorbehörighet" + "Ägare har automatiskt administratörsbehörighet." + "Redigera moderatorer" + "Välj ägare" + "Administratörer" + "Moderatorer" + "Medlemmar" + "Du har osparade ändringar." + "Spara ändringar?" + "Lägg till ämne" + "Krypterat" + "Inte krypterat" + "Offentligt rum" + "Redigera rummet" + "Ett okänt fel uppstod och informationen kunde inte ändras." + "Kunde inte uppdatera rummet" + "Meddelanden är säkrade med lås. Bara du och mottagarna har de unika nycklarna för att låsa upp dem." + "Meddelandekryptering aktiverad" + "Ett fel uppstod vid laddning av aviseringsinställningar." + "Misslyckades att tysta det här rummet, vänligen pröva igen." + "Misslyckades att avtysta det här rummet, vänligen pröva igen." + "Bjud in personer" + "Lämna konversation" + "Lämna rum" + "Media och filer" + "Anpassad" + "Förval" + "Aviseringar" + "Fästa meddelanden" + "Profil" + "Begäran om att gå med" + "Roller och behörigheter" + "Rumsnamn" + "Säkerhet och sekretess" + "Säkerhet" + "Dela rum" + "Rumsinfo" + "Ämne" + "Uppdaterar rummet …" + "Det finns inga bannade användare i det här rummet." + + "%1$d person" + "%1$d personer" + + "Ta bort och banna medlem" + "Ta bara bort medlem" + "Avbanna" + "Denne kommer kunna gå med i rummet igen om denne bjuds in" + "Avbanna från rummet" + "Bannade" + "Medlemmar" + "Endast administratörer" + "Administratörer och moderatorer" + "Ägare" + "Rumsmedlemmar" + "Avbannar %1$s" + "Tillåt anpassad inställning" + "Om du aktiverar detta åsidosätts din standardinställning" + "Meddela mig i den här chatten för" + "Du kan ändra det i dina %1$s." + "globala inställningar" + "Standardinställning" + "Ta bort anpassad inställning" + "Ett fel uppstod vid laddning av aviseringsinställningarna." + "Misslyckades att återställa standardläget, vänligen försök igen." + "Misslyckades att ställa in läget, vänligen pröva igen." + "Din hemserver stöder inte det här alternativet i krypterade rum, du blir inte aviserad i det här rummet." + "Alla meddelanden" + "Endast omnämnanden och nyckelord" + "I det här rummet, meddela mig för" + "Administratörer" + "Administratörer och ägare" + "Ändra min roll" + "Degradera till medlem" + "Degradera till moderator" + "Medlemsmoderering" + "Meddelanden och innehåll" + "Moderatorer" + "Ägare" + "Återställ behörigheter" + "När du har återställt behörigheterna kommer du att förlora de aktuella inställningarna." + "Återställ behörigheter?" + "Roller" + "Rumsdetaljer" + "Roller och behörigheter" + "Lägg till rumsadress" + "Vem som helst kan be om att gå med i rummet men en administratör eller moderator måste acceptera begäran." + "Be om att gå med" + "Ja, aktivera kryptering" + "När det är aktiverat kan kryptering för ett rum inte inaktiveras, meddelandehistoriken visas bara för rumsmedlemmar sedan de blev inbjudna eller sedan de gick med i rummet. +Ingen förutom rumsmedlemmarna kommer att kunna läsa meddelanden. Detta kan förhindra att bots och bridges fungerar korrekt. +Vi rekommenderar inte att aktivera kryptering för rum som vem som helst kan hitta och gå med i." + "Aktivera kryptering?" + "Efter aktivering kan kryptering inte inaktiveras." + "Kryptering" + "Aktivera totalsträckskryptering" + "Vem som helst kan hitta och gå med" + "Vem som helst" + "Användare kan bara gå med om de är inbjudna" + "Endast inbjudan" + "Tillgång till rum" + "Utrymmesmedlemmar" + "Utrymmen stöds för närvarande inte" + "Du behöver en rumsadress för att göra den synlig i katalogen." + "Tillåt att detta rum hittas genom att söka i den offentliga rumskatalogen på %1$s" + "Synlig i katalogen för offentliga rum" + "Vem som helst" + "Vem kan läsa historik" + "Endast medlemmar sedan de bjöds in" + "Endast medlemmar sedan det här alternativet har valts" + "Rumsadresser är sätt att hitta och komma åt rum. Detta säkerställer också att du enkelt kan dela ditt rum med andra. +Du kan välja att publicera ditt rum i din hemservers offentliga rumskatalog." + "Rumspublicering" + "Säkerhet och sekretess" + diff --git a/features/roomdetails/impl/src/main/res/values-tr/translations.xml b/features/roomdetails/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..01cac3a --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,138 @@ + + + "Dizinde görünür hale getirmek için bir oda adresine ihtiyacınız olacak." + "Oda adresi" + "Bildirim ayarı güncellenirken bir hata oluştu." + "Ana sunucunuz şifreli odalarda bu seçeneği desteklemiyor, bazı odalarda bildirim almayabilirsiniz." + "Anketler" + "Yalnızca yöneticiler" + "İnsanları yasakla" + "Mesajları kaldır" + "Kişileri davet etme ve katılma isteklerini kabul etme" + "Mesajlar ve içerik" + "Yöneticiler ve moderatörler" + "Kişileri kaldırma ve katılma isteklerini reddetme" + "Oda resmini değiştir" + "Odayı Düzenle" + "Oda adını değiştir" + "Oda konusunu değiştir" + "Mesaj gönder" + "Yöneticileri Düzenle" + "Bu eylemi geri alamazsınız. Kullanıcıyı sizinle aynı güç seviyesine sahip olacak şekilde terfi ettiriyorsunuz." + "Yönetici Ekle?" + "Rütbe Düşür" + "Rütbenizi düşürdüğünüz için bu değişikliği geri alamazsınız, eğer odadaki son ayrıcalıklı kullanıcı sizseniz ayrıcalıkları yeniden kazanmanız mümkün olmayacaktır." + "Rütbeni düşür?" + "%1$s (Beklemede)" + "(Beklemede)" + "Yöneticiler otomatik olarak moderatör ayrıcalıklarına sahiptir" + "Moderatörleri Düzenle" + "Yöneticiler" + "Moderatörler" + "Üyeler" + "Kaydedilmemiş değişiklikleriniz var." + "Değişiklikleri Kaydet?" + "Konu ekle" + "Şifrelenmiş" + "Şifrelenmemiş" + "Herkese açık oda" + "Odayı Düzenle" + "Bilinmeyen bir hata oluştu ve bilgiler değiştirilemedi." + "Oda güncellenemiyor" + "Mesajlar kilitlerle güvence altına alınır. Yalnızca siz ve alıcılar, bunların kilidini açmak için benzersiz anahtarlara sahipsiniz." + "Mesaj şifrelemesi etkinleştirildi" + "Bildirim ayarları yüklenirken bir hata oluştu." + "Bu odayı sessize alma başarısız oldu, lütfen tekrar deneyin." + "Bu odanın sesi açılamadı, lütfen tekrar deneyin." + "İnsanları davet et" + "Sohbeti bırak" + "Odadan ayrıl" + "Medya ve dosyalar" + "Özel" + "Varsayılan" + "Bildirimler" + "Sabitlenmiş mesajlar" + "Profil" + "Katılma istekleri" + "Roller ve izinler" + "Oda adı" + "Güvenlik ve gizlilik" + "Güvenlik" + "Oda paylaş" + "Oda bilgisi" + "Konu" + "Oda güncelleniyor…" + "Bu odada yasaklı kullanıcı yok." + + "%1$d kişi" + "%1$d kişi" + + "Üyeyi çıkar ve yasakla" + "Yalnızca üyeyi kaldır" + "Yasağı Kaldır" + "Davet edildikleri takdirde bu odaya tekrar katılabileceklerdir." + "Yasaklandı" + "Üyeler" + "Yalnızca yöneticiler" + "Yöneticiler ve moderatörler" + "Oda üyeleri" + "Yasak kaldırılıyor %1$s" + "Özel ayarlara izin ver" + "Bunu açmak varsayılan ayarlarınızı geçersiz kılacaktır" + "Bu sohbette bana bildir" + "Bunu %1$s içinde değiştirebilirsiniz." + "genel ayarlar" + "Varsayılan ayar" + "Özel ayarı kaldır" + "Bildirim ayarları yüklenirken bir hata oluştu." + "Varsayılan ayarlar geri yüklenemedi, lütfen tekrar deneyin." + "Ayarlanamadı, lütfen tekrar deneyin." + "Ana sunucunuz şifreli odalarda bu seçeneği desteklemiyor, bu odada bildirim almayacaksınız." + "Tüm mesajlar" + "Yalnızca Bahsetmeler ve Anahtar Kelimeler" + "Bu odada, bana bildir" + "Yöneticiler" + "Rolümü değiştir" + "Üyeliğe düşür" + "Moderatörlüğe düşür" + "Üye moderasyonu" + "Mesajlar ve içerik" + "Moderatörler" + "İzinleri sıfırla" + "İzinleri sıfırladığınızda, mevcut ayarları kaybedersiniz." + "İzinleri sıfırla?" + "Roller" + "Oda bilgileri" + "Roller ve izinler" + "Oda adresi ekle" + "Herkes odaya katılma isteğinde bulunabilir ancak bir yönetici veya moderatörün isteği kabul etmesi gerekir." + "Katılmak için sor" + "Evet, şifrelemeyi etkinleştir" + "Etkinleştirildikten sonra, bir oda için şifreleme devre dışı bırakılamaz, Mesaj geçmişi yalnızca davet edildiklerinden veya odaya katıldıklarından beri oda üyeleri için görünür olacaktır. +Oda üyeleri dışında hiç kimse mesajları okuyamayacaktır. Bu, botların ve köprülerin düzgün çalışmasını engelleyebilir. +Herkesin bulabileceği ve katılabileceği odalar için şifrelemenin etkinleştirilmesini önermiyoruz." + "Şifrelemeyi etkinleştir?" + "Açıldıktan donra şifreleme kapatılamaz." + "Şifreleme" + "Uçtan uca şifrelemeyi etkinleştir" + "Herkes bulabilir ve katılabilir" + "Herkes" + "İnsanlar yalnızca davet edildiklerinde katılabilirler" + "Yalnızca davet" + "Oda Erişimi" + "Alan üyeleri" + "Alanlar şu anda desteklenmiyor" + "Dizinde görünür hale getirmek için bir oda adresine ihtiyacınız olacak." + "Oda adresi" + "Bu odanın %1$s genel oda dizininde arama yapılarak bulunmasına izin verin" + "Genel oda dizininde görünür" + "Herkes" + "Geçmişi kimler okuyabilir ?" + "Sadece üyeler (davet edildiklerinden beri)" + "Bu seçeneği seçtiğinden beri yalnızca üyeler" + "Oda adresleri, odaları bulmanın ve odalara erişmenin yoludur. Bu aynı zamanda odanızı başkalarıyla kolayca paylaşabilmenizi sağlar. +Odanızı ana sunucunuzun genel oda dizininde yayınlamayı seçebilirsiniz." + "Oda yayınlama" + "Oda görünürlüğü" + "Güvenlik ve gizlilik" + diff --git a/features/roomdetails/impl/src/main/res/values-uk/translations.xml b/features/roomdetails/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..57a604b --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,147 @@ + + + "Вам знадобиться адреса кімнати, щоб зробити її видимою в каталозі." + "Адреса кімнати" + "Під час оновлення налаштувань сповіщень сталася помилка." + "Ваш домашній сервер не підтримує цю опцію в зашифрованих кімнатах, ви можете не отримати сповіщення в деяких кімнатах." + "Опитування" + "Тільки для адміністраторів" + "Заблоковувати людей" + "Вилучати повідомлення" + "Запрошувати людей і приймати запити на приєднання" + "Повідомлення та зміст" + "Адміністратори та модератори" + "Вилучати людей і відхиляти запити на приєднання" + "Змінювати аватар кімнати" + "Редагувати кімнату" + "Змінювати назву кімнати" + "Змінювати тему кімнати" + "Надсилати повідомлення" + "Керувати адмінами" + "Ви не зможете скасувати цю дію. Ви просуваєте користувача, щоб він мав такий же рівень прав, як і ви." + "Додати адміністратора?" + "Ви не зможете скасувати цю дію. Ви передаєте право власності вибраним користувачам. Після вашого виходу це буде остаточно." + "Передати право власності?" + "Понизити" + "Ви не зможете скасувати цю зміну, оскільки ви понижуєте себе, якщо ви останній привілейований користувач у кімнаті, відновити повноваження буде неможливо." + "Понизити себе?" + "%1$s (Очікується)" + "(Очікується)" + "Адміністратори автоматично мають повноваження модератора" + "Власники автоматично отримують права адміністратора." + "Керувати модераторами" + "Оберіть власників" + "Адміністратори" + "Модератори" + "Учасники" + "У вас є не збережені зміни." + "Зберегти зміни?" + "Додати тему" + "Зашифровано" + "Не зашифровано" + "Загальнодоступна кімната" + "Редагувати кімнату" + "Сталася невідома помилка, й інформацію не вдалося змінити." + "Не вдалося оновити кімнату" + "Повідомлення захищені замками. Тільки ви та одержувачі маєте унікальні ключі для їх розблокування." + "Шифрування повідомлень увімкнено" + "Виникла помилка при завантаженні налаштувань сповіщень." + "Не вдалося вимкнути цю кімнату. Будь ласка, спробуйте ще раз." + "Не вдалося ввімкнути звук цієї кімнати. Повторіть спробу." + "Запросити людей" + "Залишити розмову" + "Вийти з кімнати" + "Медіа та файли" + "Власні" + "Типово" + "Сповіщення" + "Закріплені повідомлення" + "Профіль" + "Запити на приєднання" + "Ролі та дозволи" + "Назва кімнати" + "Безпека й приватність" + "Безпека" + "Поділитися кімнатою" + "Інформація про кімнату" + "Тема" + "Оновлення кімнати…" + "У цій кімнаті немає заблокованих користувачів." + + "%1$d особа" + "%1$d особи" + "%1$d осіб" + + "Вилучити й заблокувати учасника" + "Лише вилучити учасника" + "Розблокувати" + "Вони зможуть знову приєднатися до цієї кімнати, якщо їх запросять." + "Розблокувати в кімнаті" + "Заблоковані" + "Учасники" + "Тільки для адміністраторів" + "Адміністратори та модератори" + "Власник" + "Учасники кімнати" + "Розблокування %1$s" + "Дозволити користувальницькі налаштування" + "Увімкнення цього параметра змінить типові налаштування" + "Сповіщати мене в цій бесіді про" + "Ви можете змінити це у своїх %1$s." + "глобальних налаштуваннях" + "Типові налаштування" + "Вилучити користувальницькі налаштування" + "Під час завантаження налаштувань сповіщень сталася помилка." + "Не вдалося відновити типовий режим, спробуйте ще раз." + "Не вдалося встановити режим, спробуйте ще раз." + "Ваш домашній сервер не підтримує цю опцію в зашифрованих кімнатах, ви не отримаєте сповіщення в цій кімнаті." + "Всі повідомлення" + "Тільки згадки та ключові слова" + "У цій кімнаті сповіщати мене про" + "Адміністратори" + "Адміністратори та власники" + "Змінити мою роль" + "Понизити до учасника" + "Понизити до модератора" + "Модерація учасників" + "Повідомлення та зміст" + "Модератори" + "Власники" + "Скинути дозволи" + "Після скидання дозволів ви втратите поточні налаштування." + "Скинути дозволи?" + "Ролі" + "Деталі кімнати" + "Ролі та дозволи" + "Додати адресу кімнати" + "Будь-хто може надіслати запит приєднатися до кімнати, але адміністратор або модератор повинні прийняти запит." + "Запросити приєднатися" + "Так, увімкнути шифрування" + "Після ввімкнення шифрування кімнати, його неможливо вимкнути, історію повідомлень бачитимуть лише учасники кімнати, яких було запрошено або які приєдналися до кімнати. +Ніхто, крім учасників кімнати, не зможе прочитати повідомлення. Це може перешкоджати коректній роботі ботів і мостів. +Ми не радимо вмикати шифрування для кімнат, які будь-хто може знайти та до яких може приєднатися всі." + "Увімкнути шифрування?" + "Після ввімкнення шифрування неможливо вимкнути." + "Шифрування" + "Увімкнути наскрізне шифрування" + "Будь-хто може знайти та приєднатися." + "Кожний" + "Люди можуть приєднатися, лише якщо їх запросили" + "Лише запрошені" + "Доступ до кімнати" + "Учасники простору" + "Простори наразі не підтримуються" + "Вам знадобиться адреса кімнати, щоб зробити її видимою в каталозі." + "Адреса кімнати" + "Дозвольте, щоб цю кімнату можна було знайти за допомогою пошуку в каталозі загальнодоступних кімнат %1$s " + "Видима в каталозі загальнодоступних кімнат" + "Кожний" + "Хто може читати історію" + "Лише учасники з моменту запрошення" + "Лише учасники після вибору цього параметра" + "Адреси кімнат — це спосіб знайти кімнату та отримати до неї доступ. Це також гарантує, що ви можете легко поділитися своєю кімнатою з іншими. +Ви можете опублікувати свою кімнату в каталозі загальнодоступних кімнат вашого домашнього сервера." + "Публікація в кімнаті" + "Видимість кімнати" + "Безпека й приватність" + diff --git a/features/roomdetails/impl/src/main/res/values-ur/translations.xml b/features/roomdetails/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..524afe5 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,102 @@ + + + "اطلاع کی ترتیب کی تجدید کرتے ہوئے ایک نقص واقع ہوا۔" + "آپ کا منزلی خادم مرموز کردہ کمروں میں اس اختیار کی حمایت نہیں کرت، ہوسکتا ہے کچھ کمروں میں آپ کو مطلع نہ کیا جائے۔" + "رائے شماری ہا" + "صرف منتظمین" + "لوگوں کو محظور کریں" + "پیغامات ہٹائیں" + "لوگوں کو مدعو کریں اور شمولیت کی درخواستیں قبول کریں" + "پیغامات اور مواد" + "منتظمین اور ناظمین" + "لوگوں کو ہٹا دیں اور شمولیت کی درخواستیں مسترد کریں" + "کمرے کا اوتار بدلیں" + "کمرے میں ترمیم کریں" + "کمرے کا نام بدلیں" + "کمرے کا موضوع بدلیں" + "پیغامات بھیجیں" + "منتظمین میں ترمیم کریں" + "آپ اس کارروائی کو کالعدم نہیں کرسکیں گے۔ آپ صارف کو اپنی جیسی طاقت کی سطح رکھنے کے لئے فروغ دے رہے ہیں۔" + "منتظم شمال کریں؟" + "تنزل کریں" + "آپ اس تبدیلی کو کالعدم نہیں کرسکیں گے کیونکہ آپ اپنے آپ کو تنزل کر رہے ہیں، اگر آپ کمرے میں آخری مراعات یافتہ صارف ہیں تو مراعات پھر حاصل کرنا ناممکن ہو جائے گا۔" + "اپنے آپ کو تنزل کریں؟" + "%1$s (زیر التواء)" + "(زیر التواء)" + "منتظمین کے پاس خودکاراً ناظمین مراعات ہوتی ہیں" + "ناظمین میں ترمیم کریں" + "منتظمین" + "ناظمین" + "اراکین" + "آپکے پاس غیر محفوظ تبدیلیاں ہیں" + "تبدیلیاں محفوظ کریں؟" + "موضوع شامل کریں" + "مرموز کردہ" + "رموز کردہ نہیں" + "عوامی کمرہ" + "کمرے میں ترمیم کریں" + "ایک نامعلوم خلل تھا اور معلومات تبدیل نہیں ہوسکی۔" + "کمرے کی تجدید کرنے سے قاصر" + "پیغامات قفلوں کے ساتھ محفوظ ہیں۔ ان کو غیر مقفل کرنے کے لیے صرف آپ اور وصول کنندگان کے پاس منفرد چابیاں ہیں۔" + "پیغام کی مرموزکاری فعال ہے۔" + "اطلاع کی ترتیبات لادتے ہوئے ایک نقص واقع ہوا۔" + "اس کمرے کو خاموش کرنے میں ناکام، برائے مہربانی دوبارہ کوشش کریں۔" + "اس کمرے کو غیر خاموش کرنے میں ناکام، برائے مہربانی دوبارہ کوشش کریں۔" + "لوگوں کو مدعو کریں" + "گفتگو چھوڑیں" + "کمرہ چھوڑ دیں" + "حسب ضرورت" + "طے شدہ" + "اطلاعات" + "مثبوتہ پیغامات" + "نمایہ" + "کردارہا اور اجازتیں" + "کمرے کا نام" + "حفاظت" + "کمرے کا اشتراک کریں" + "کمرے کی معلومات" + "موضوع" + "کمرے کی تجدید کر رہا ہے…" + "اس کمرے میں کوئی محظور صارفین نہیں ہیں۔" + + "%1$d شخص" + "%1$d اشخاص" + + "کمرے سے محظور کریں" + "رکن کو صرف ہٹائیں" + "غیر محظور کریں" + "اگر وہ مدعو کیا جائیں تو وہ دوبارہ اس کمرے میں شامل ہوسکیں گے۔" + "محظور" + "اراکین" + "صرف منتظمین" + "منتظمین اور ناظمین" + "کمرے کے ارکان" + "%1$s کو غیر محظور کر رہا ہے" + "حسب ضرورت ترتیب کی اجازت دیں" + "اسے چالو کرنے سے آپکی متعینہ ترتیبات تجاوز گی جائیں گی" + "اس گفتگو میں مجھے مطلع کریں برائے" + "آپ اسے اپنے %1$s میں بدل سکتے ہیں۔" + "عالمی ترتیبات" + "متعین ترتیب" + "حسب ضرورت ترتیب کو ہٹائیں" + "اطلاع کی ترتیبات لادتے ہوئے ایک نقص واقع ہوا۔" + "متعین وضع کو بحال کرنے میں ناکام، برائے مہربانی دوبارہ کوشش کریں۔" + "وضع ترتیب دینے میں ناکام، برائے مہربانی دوبارہ کوشش کریں۔" + "آپ کا منزلی خادم مرموز کردہ کمروں میں اس اختیار کی حمایت نہیں کرتا ہے، آپ کو اس کمرے میں مطلع کیا جائے گا۔" + "تمام پیغامات" + "صرف تذکرے اور کلیدی الفاظ" + "اس کمرے میں، مجھے مطلع کریں برائے" + "منتظمین" + "میرا کردار تبدیل کریں" + "تا رکن تنزلی کریں" + "تا ناظم تنزلی کریں" + "ارکان کا اعتدال" + "پیغامات اور مواد" + "ناظمین" + "اجازتیں بحال کریں" + "ایک بار جب آپ اجازتیں بحال کردیں گے، آپ موجودہ ترتیبات کھو دیں گے۔" + "اجازتیں بحال کریں؟" + "کردارہا" + "کمرے کی تفصیلات" + "کردارہا اور اجازتیں" + diff --git a/features/roomdetails/impl/src/main/res/values-uz/translations.xml b/features/roomdetails/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..401d015 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,143 @@ + + + "Katalogda ko‘rinadigan qilish uchun xona manzili kerak bo‘ladi." + "Xona manzili" + "Bildirishnoma sozlamalarini yangilashda xatolik yuz berdi." + "Uy serveringiz shifrlangan xonalarda ushbu imkoniyatni qoʻllab-quvvatlamaydi, shuning uchun baʼzi xonalardagi xabarlarni olmasligingiz mumkin." + "Soʻrovnomalar" + "Faqat adminlar" + "Odamlarni taqiqlash" + "Xabarlarni olib tashlash" + "Odamlarni taklif qiling va qo‘shilish so‘rovlarini qabul qiling" + "Xabarlar va kontent" + "Adminlar va moderatorlar" + "Odamlarni olib tashlash va qoʻshilish soʻrovlarini rad etish" + "Xona avatarini oʻzgartirish" + "Xonani tahrirlash" + "Xona nomini oʻzgartirish" + "Xona mavzusini almashtirish" + "Xabarlar yuborish" + "Administratorlarni tahrirlash" + "Bu amalni bekor qila olmaysiz. Siz foydalanuvchini o‘zingiz bilan bir xil quvvat darajasiga ega bo‘lishga undayapsiz." + "Admin qo‘shilsinmi?" + "Bu amalni bekor qila olmaysiz. Siz egalikni tanlangan foydalanuvchilarga o‘tkazmoqdasiz. Tark etsangiz, bu doimiy bo‘ladi." + "Egalik huquqini o‘tkazasizmi?" + "Pastga tushirish" + "Siz oʻzingizni imtiyozlardan mahrum qilayotganingiz sababli, bu o‘zgarishni bekor qila olmaysiz. Agar xonadagi so‘nggi imtiyozli foydalanuvchi bo‘lsangiz, imtiyozlarni qayta tiklash imkonsiz bo‘ladi." + "O‘z darajangizni pasaytirmoqchimisiz?" + "%1$s (Jarayonda)" + "(Kutilmoqda)" + "Administratorlar avtomatik ravishda moderator imtiyozlariga ega" + "Egalar avtomatik ravishda administrator huquqlariga ega." + "Moderatorlarni tahrirlash" + "Egalarni tanlang" + "Adminlar" + "Moderatorlar" + "Azolar" + "Sizda saqlanmagan oʻzgarishlar bor" + "O‘zgartirishlarni saqlaysizmi?" + "Mavzu qo\'shish" + "Shifrlangan" + "Shifrlanmagan" + "Jamoat xonasi" + "Xonani tahrirlash" + "Nomaʼlum xatolik yuz berdi va maʼlumotni oʻzgartirib boʻlmadi." + "Xonani yangilab bo‘lmadi" + "Xabarlar qulflar bilan himoyalangan. Faqat siz va qabul qiluvchilar ularni qulfdan chiqarish uchun noyob kalitlarga ega." + "Xabarni shifrlash yoqilgan" + "Bildirishnoma sozlamalarini yuklashda xatolik yuz berdi." + "Bu xona ovozini o‘chirib bo‘lmadi, qayta urinib ko‘ring." + "Bu xonaning ovozi yoqilmadi, qayta urinib ko‘ring." + "Odamlarni taklif qiling" + "Suhbatni tark etish" + "Xonani tark etish" + "Media va fayllar" + "Maxsus" + "Standart" + "Bildirishnomalar" + "Qadalgan xabarlar" + "Profil" + "Qo‘shilish uchun so‘rovlar" + "Rollar va ruxsatlar" + "Xona nomi" + "Xavfsizlik va maxfiylik" + "Xavfsizlik" + "Xonani baham ko\'ring" + "Xona haqida maʼlumot" + "Mavzu" + "Xona yangilanmoqda…" + "Bu xonada taqiqlangan foydalanuvchilar yoʻq." + + "%1$dodam" + "%1$dodamlar" + + "Xonadan chetlashtirish" + "Faqat aʻzoni olib tashlash" + "Taqiqni bekor qilish" + "Agar taklif qilinsa, ular bu xonaga qayta qo‘shilishlari mumkin." + "Xonadan taqiqni olib tashlash" + "Taqiqlangan" + "Azolar" + "Faqat adminlar" + "Adminlar va moderatorlar" + "Egasi" + "Xona a\'zolari" + "Taqiqni bekor qilish %1$s" + "Moslashtirilgan sozlamalarga ruxsat bering" + "Buni yoqsangiz, standart sozlamalaringiz bekor qilinadi" + "Bu chatda menga xabar bering" + "Siz buni o\'zgartira olasiz o\'zingizning %1$sda." + "global sozlamalar" + "Standart sozlama" + "Maxsus sozlamani olib tashlang" + "Bildirishnoma sozlamalarini yuklashda xatolik yuz berdi." + "Standart rejimni tiklab bo‘lmadi, qaytadan urinib ko‘ring." + "Rejimni o‘rnatib bo‘lmadi, qayta urinib ko‘ring." + "Uy serveringiz shifrlangan xonalarda ushbu imkoniyatni qoʻllab-quvvatlamaydi, shuning uchun bu xonadan bildirishnomalar olmaysiz." + "Barcha xabarlar" + "Faqat eslatmalar va kalit so\'zlar" + "Bu xonada menga xabar bering" + "Adminlar" + "Adminlar va egalari" + "Rolimni o‘zgartirish" + "Aʼzolikka tushirish" + "Moderatorga pasaytirish" + "Aʻzo moderatsiyasi" + "Xabarlar va kontent" + "Moderatorlar" + "Egalari" + "Ruxsatlarni tiklash" + "Ruxsatlarni asliga qaytargach, joriy sozlamalarni yoʻqotasiz." + "Ruxsatlar asliga qaytarilsinmi?" + "Rollar" + "Xona tafsilotlari" + "Rollar va ruxsatlar" + "Xona manzilini kiritish" + "Xonaga qo‘shilishni istalgan kishi so‘rashi mumkin, lekin administrator yoki moderator so‘rovni qabul qilishi kerak" + "Qo‘shilishni so‘rang" + "Ha, shifrlashni yoqish" + "Yoqilgandan so‘ng, xona uchun shifrlashni o‘chirib bo‘lmaydi. Xabarlar tarixi faqat xona a’zolari taklif qilinganidan yoki xonaga qo‘shilganidan keyingi davrdan boshlab ko‘rinadi. Xona a’zolaridan tashqari hech kim xabarlarni o‘qiy olmaydi. Bu botlar va ko‘priklarning to‘g‘ri ishlashiga to‘sqinlik qilishi mumkin. +Shu sababli, har kim topishi va qo‘shilishi mumkin bo‘lgan xonalar uchun shifrlashni yoqishni tavsiya etmaymiz." + "Shifrlash yoqilsinmi?" + "Yoqilgandan keyin shifrlashni faolsizlantirish imkonsiz." + "Shifrlash" + "End-to-end shifrlashni yoqish" + "Istalgan kishi topishi va qo‘shilishi mumkin" + "Har kim" + "Odamlar faqat taklif qilingan taqdirdagina qo‘shilishi mumkin" + "Faqat taklif qilish" + "Xonaga kirish huquqi" + "Maydon a’zolari" + "Hozirda maydonlar qo‘llab-quvvatlanmaydi" + "Katalogda ko‘rinadigan qilish uchun xona manzili kerak bo‘ladi." + "Bu xonani %1$s umumiy xonalar ro‘yxatidan qidirib topish imkoniyatini berish" + "Umumiy xona ro‘yxatida ko‘rinadi" + "Har kim" + "Tarixni kim o‘qiy oladi" + "Taklif qilinganidan buyon faqat a’zolar" + "A’zolar faqat bu parametr tanlanganidan keyin" + "Xona manzillari xonalarni topish va ularga kirish usullaridir. Bu shuningdek xonangizni boshqalar bilan oson ulashish imkonini beradi. +Xonangizni o‘z homeserveringizning ommaviy xonalar ro‘yxatida e’lon qilishni tanlashingiz mumkin." + "xona nashriyoti" + "Xavfsizlik va maxfiylik" + diff --git a/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml b/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..0332c0f --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,154 @@ + + + "您需要地址才能在公開目錄中顯示。" + "編輯地址" + "更新通知設定時發生錯誤。" + "您的家伺服器在加密聊天室中不支援此選項,可能無法收到部份聊天室的通知。" + "所有投票" + "管理員" + "管理黑名單" + "移除訊息" + "成員" + "邀請夥伴" + "管理成員" + "訊息與內容" + "版主" + "移除夥伴" + "變更聊天室大頭照" + "編輯詳細資訊" + "變更聊天室名稱" + "變更聊天室主題" + "傳送訊息" + "編輯管理員" + "您將無法復原此動作。您正將使用者提昇至與您相同的權力等級。" + "要新增管理員嗎?" + "您將無法撤銷此動作。您正在將所有權轉移給選定的使用者。一旦您離開,此動作將永久有效。" + "轉移所有權?" + "降級" + "當您自行降級時,您將無法復原此變更,若您是聊天室中的最後一位特權使用者,則無法重新獲得權限。" + "將自己降級?" + "%1$s(擱置中)" + "(擱置中)" + "管理員自動擁有版主權限" + "擁有者自動擁有管理員權限。" + "編輯版主" + "選擇擁有者" + "管理員" + "版主" + "成員" + "您有尚未儲存的變更" + "是否儲存變更?" + "新增主題" + "已加密" + "未加密" + "公開的聊天室" + "編輯詳細資訊" + "發生未知錯誤,無法變更資訊。" + "無法更新聊天室" + "訊息透過鎖定保護。只有您與收件者才有解鎖它們的唯一金鑰。" + "訊息已加密" + "載入通知設定時發生錯誤。" + "無法關閉聊天室通知,請再試一次。" + "無法開啟聊天室通知,請再試一次。" + "完成前請勿關閉應用程式。" + "正在準備邀請……" + "邀請夥伴" + "離開對話" + "離開聊天室" + "媒體與檔案" + "自訂" + "預設" + "通知" + "釘選訊息" + "個人檔案" + "請求加入" + "角色與權限" + "聊天室名稱" + "安全與隱私" + "安全性" + "分享聊天室" + "聊天室資訊" + "主題" + "正在更新聊天室…" + "沒有被封鎖的使用者。" + "檢查拼字或嘗試新搜尋" + "找不到「%1$s」" + + "%1$d 個人" + + "踢出並加入黑名單" + "僅移除成員" + "解除黑名單" + "如果收到邀請,他們能再次加入聊天室。" + "從聊天室解除封鎖" + "黑名單" + "成員" + "擱置中" + "管理員" + "版主" + "擁有者" + "聊天室成員" + "正在解除黑名單 %1$s" + "允許自訂設定" + "啟用此功能將會覆寫您的預設設定" + "在此聊天中通知我" + "您可以在您的 %1$s 中變更它。" + "全域設定" + "預設" + "移除自訂設定" + "載入通知設定時發生錯誤。" + "無法重設為預設模式,請再試一次。" + "無法設定模式,請再試一次。" + "您的家伺服器在加密聊天室中不支援此選項,您將不會收到此聊天室的通知。" + "所有訊息" + "僅限提及與關鍵字" + "在此聊天適中,通知我" + "管理員" + "管理員與擁有者" + "變更我的身份" + "降級為普通成員" + "降級為版主" + "成員管理" + "訊息與內容" + "版主" + "擁有者" + "權限" + "重設權限" + "重設之後,您會遺失當前的設定。" + "確定要重設權限嗎?" + "身份" + "聊天室資訊" + "角色與權限" + "新增地址" + "所有人都必須申請存取權。" + "要求加入" + "是的,啟用加密" + "啟用後就無法停用聊天室的加密,只有受邀的聊天室成員或加入聊天室後才能看到訊息歷史紀錄。 +除了聊天室成員以外,任何人都不能讀取訊息。這可能會讓機器人與橋接無法正常運作。 +我們不建議對任何人都可以找到並加入的聊天室啟用加密。" + "啟用加密?" + "一旦啟用就無法停用加密。" + "加密" + "啟用端到端加密" + "任何人都可以加入。" + "任何人" + "僅受邀者才能加入。" + "僅限邀請" + "存取權" + "空間成員" + "目前不支援空間" + "您需要地址才能在公開目錄中顯示。" + "地址" + "允許透過搜尋 %1$s 公開聊天室目錄找到此聊天室" + "允許其他人透過公開目錄找到。" + "在公開目錄中可見" + "任何人" + "誰可以讀取歷史紀錄" + "僅在成員被邀請後" + "選取此選項後僅限成員" + "聊天室地址是尋找與存取聊天室的方法。也確保您可以輕鬆與其他人分享聊天室。 +您可以選擇在家伺服器公開聊天室目錄中發佈您的聊天室。" + "聊天室發佈" + "能見度" + "安全與隱私" + diff --git a/features/roomdetails/impl/src/main/res/values-zh/translations.xml b/features/roomdetails/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..f925bdc --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,147 @@ + + + "你需要房间地址才能使其在目录中可见。" + "房间地址" + "更新通知设置时出错。" + "服务器在加密聊天室中不支持此选项,因此在某些聊天室可能无法收到通知。" + "投票" + "仅限管理员" + "封禁成员" + "移除消息" + "邀请他人及接受加入请求" + "消息和内容" + "管理员和协管员" + "移除成员及拒绝加入请求" + "更改聊天室头像" + "编辑聊天室" + "更改聊天室名称" + "更改聊天室主题" + "发送消息" + "编辑管理员" + "您将无法撤消此操作。您正在提升用户的权限,使其拥有与您平权。" + "添加管理员?" + "此操作无法撤销。您正在将所有权转移给所选用户。一旦离开此界面,该操作将永久生效。" + "转让所有权" + "降级" + "您正在降级,此更改将无法撤消。如果您是聊天室中的最后一个特权用户,则无法重新获得权限。" + "降级自己?" + "%1$s(待处理)" + "(已邀请)" + "管理员自动拥有协管员权限" + "所有者自动拥有管理员权限。" + "编辑协管员" + "选择所有者" + "管理员" + "协管员" + "成员" + "您有未保存的更改。" + "保存更改?" + "添加主题" + "加密的" + "未加密的" + "公共聊天室" + "编辑聊天室" + "出现未知错误,无法更改信息。" + "无法更新聊天室" + "消息已加密,只有你和消息接收者拥有唯一解密密钥。" + "消息加密已启用" + "加载通知设置时出错。" + "无法将此聊天室静音,请重试。" + "无法取消此聊天室的静音,请重试。" + "完成之前请勿关闭应用程序。" + "准备邀请…" + "邀请朋友" + "离开聊天" + "离开聊天室" + "媒体和文件" + "自定义" + "默认" + "通知" + "置顶消息" + "个人资料" + "申请加入" + "角色与权限" + "聊天室名称" + "安全与隐私" + "安全" + "分享聊天室" + "聊天室信息" + "主题" + "正在更新聊天室……" + "没有被封禁的用户。" + + "%1$d 人" + + "移除并封禁成员" + "仅移除成员" + "取消封禁" + "如果受到邀请,他们可以重新加入聊天室。" + "从房间取消解封" + "已封禁用户" + "成员" + "仅限管理员" + "管理员和协管员" + "所有者" + "聊天室成员" + "解除封禁 %1$s" + "允许自定义设置" + "开启此功能将覆盖您的默认设置" + "在此聊天中通知我以下内容" + "你可以在你的 %1$s 中更改这一项。" + "全局设置" + "默认设置" + "撤销独立设置" + "加载通知设置时出错。" + "恢复默认模式失败,请重试。" + "设置模式失败,请重试。" + "服务器在加密聊天室中不支持此选项,无法在此聊天室收到通知。" + "全部消息" + "仅限提及和关键词" + "在这个聊天室,通知我:" + "管理员" + "管理员和所有者" + "更改我的角色" + "降级为成员" + "降级为协管员" + "成员权限" + "消息和内容" + "协管员" + "所有者" + "重置权限" + "重置权限后,您将丢失当前设置。" + "重置权限?" + "角色" + "聊天室详情" + "角色与权限" + "添加房间地址" + "任何人都可以请求加入房间,但必须由管理员或版主接受请求。" + "请求加入" + "是的,启用加密" + "一旦启用,就不能再禁用房间的加密功能。消息历史记录只能在房间成员被邀请或加入房间后才可见。 +除房间成员外,任何人都无法阅读信息。这可能会妨碍机器人和网桥正常工作。 +我们不建议对任何人都能找到并加入的房间启用加密。" + "启用加密?" + "加密一旦启用,就无法禁用。" + "加密" + "启用端到端加密" + "任何人都可以找到并加入" + "任何人" + "只有受邀者才能加入" + "仅限邀请" + "房间访问权限" + "空间成员" + "目前不支持空间" + "你需要房间地址才能使其在目录中可见。" + "房间地址" + "允许通过搜索 %1$s 的公共房间目录来发现此房间" + "在公共房间目录中可见" + "任何人" + "谁可以读取历史记录" + "仅限被邀请的成员" + "仅自选择此选项以来的成员" + "房间地址是查找和访问房间的方式。这也确保你可以轻松地向他人分享房间。 +你可以选择在你服务器的公共房间目录中发布你的房间。" + "房间发布" + "房间可见性" + "安全与隐私" + 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 0000000..c582f6f --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values/localazy.xml @@ -0,0 +1,168 @@ + + + "You’ll need an address in order to make it visible in the public directory." + "Edit address" + "An error occurred while updating the notification setting." + "Your homeserver does not support this option in encrypted rooms, you may not get notified in some rooms." + "Polls" + "Admin" + "Ban people" + "Remove messages" + "Member" + "Invite people" + "Manage members" + "Messages and content" + "Moderator" + "Remove people" + "Change avatar" + "Edit details" + "Change name" + "Change topic" + "Send messages" + "Edit Admins" + "You will not be able to undo this action. You are promoting the user to have the same power level as you." + "Add Admin?" + "You will not be able to undo this action. You are transferring the ownership to the selected users. Once you leave this will be permanent." + "Transfer ownership?" + "Demote" + "You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges." + "Demote yourself?" + "%1$s (Pending)" + "(Pending)" + "Admins automatically have moderator privileges" + "Owners automatically have admin privileges." + "Edit Moderators" + "Choose Owners" + "Admins" + "Moderators" + "Members" + "You have unsaved changes." + "Save changes?" + "Add topic" + "Encrypted" + "Not encrypted" + "Public room" + "Edit details" + "There was an unknown error and the information couldn\'t be changed." + "Unable to update room" + "Messages are secured with locks. Only you and the recipients have the unique keys to unlock them." + "Message encryption enabled" + "An error occurred when loading notification settings." + "Failed muting this room, please try again." + "Failed unmuting this room, please try again." + "Don\'t close the app until finished." + "Preparing invitations…" + "Invite people" + "Leave conversation" + "Leave room" + "Media and files" + "Custom" + "Default" + "Notifications" + "Pinned messages" + "Profile" + "Requests to join" + "Roles & permissions" + "Room name" + "Security & privacy" + "Security" + "Share room" + "Room info" + "Topic" + "Updating room…" + "There are no banned users." + + "%1$d Banned" + "%1$d Banned" + + "Check the spelling or try a new search" + "No results for “%1$s”" + + "%1$d Person" + "%1$d People" + + "Ban user" + "Only remove member" + "Unban" + "They will be able to join this room again if invited." + "Unban user" + "Banned" + "Members" + + "%1$d Invited" + "%1$d Invited" + + "Pending" + "Admin" + "Moderator" + "Owner" + "Room members" + "Unbanning %1$s" + "Allow custom setting" + "Turning this on will override your default setting" + "Notify me in this chat for" + "You can change it in your %1$s." + "global settings" + "Default setting" + "Remove custom setting" + "An error occurred while loading notification settings." + "Failed restoring the default mode, please try again." + "Failed setting the mode, please try again." + "Your homeserver does not support this option in encrypted rooms, you won\'t get notified in this room." + "All messages" + "Mentions and Keywords only" + "In this room, notify me for" + "Admins" + "Admins and owners" + "Change my role" + "Demote to member" + "Demote to moderator" + "Member moderation" + "Messages and content" + "Moderators" + "Owners" + "Permissions" + "Reset permissions" + "Once you reset permissions, you will lose the current settings." + "Reset permissions?" + "Roles" + "Room details" + "Roles & permissions" + "Add address" + "Everyone must request access." + "Ask to join" + "Yes, enable encryption" + "Once enabled, encryption for a room cannot be disabled, Message history will only be visible for room members since they were invited or since they joined the room. +No one besides the room members will be able to read messages. This may prevent bots and bridges to work correctly. +We do not recommend enabling encryption for rooms that anyone can find and join." + "Enable encryption?" + "Once enabled, encryption cannot be disabled." + "Encryption" + "Enable end-to-end encryption" + "Anyone can join." + "Anyone" + "Choose which spaces’ members can join this room without an invitation. %1$s" + "Manage spaces" + "Only invited people can join." + "Invite only" + "Access" + "Anyone in authorized spaces can join." + "Anyone in %1$s can join." + "Space members" + "Spaces are not currently supported" + "You’ll need an address in order to make it visible in the public directory." + "Address" + "Allow for this room to be found by searching %1$s public room directory" + "Allow to be found by searching the public directory." + "Visible in public directory" + "Anyone" + "Who can read history" + "Members only since they were invited" + "Members only since selecting this option" + "Room addresses are ways to find and access rooms. This also ensures you can easily share your room with others. +You can choose to publish your room in your homeserver public room directory." + "Room publishing" + "Addresses are a way to find and access rooms and spaces. This also ensures you can easily share them with others." + "Visibility" + "Security & privacy" + diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt new file mode 100644 index 0000000..cd2af11 --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.features.call.test.FakeElementCallEntryPoint +import io.element.android.features.changeroommemberroles.test.FakeChangeRoomMemberRolesEntryPoint +import io.element.android.features.changeroommemberroles.test.FakeRolesAndPermissionsEntryPoint +import io.element.android.features.knockrequests.test.FakeKnockRequestsListEntryPoint +import io.element.android.features.messages.test.FakeMessagesEntryPoint +import io.element.android.features.poll.test.history.FakePollHistoryEntryPoint +import io.element.android.features.reportroom.test.FakeReportRoomEntryPoint +import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint +import io.element.android.features.securityandprivacy.test.FakeSecurityAndPrivacyEntryPoint +import io.element.android.features.verifysession.test.FakeOutgoingVerificationEntryPoint +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.permalink.PermalinkData +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.mediaviewer.test.FakeMediaGalleryEntryPoint +import io.element.android.libraries.mediaviewer.test.FakeMediaViewerEntryPoint +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultRoomDetailsEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultRoomDetailsEntryPoint() + + val parentNode = TestParentNode.create { buildContext, plugins -> + RoomDetailsFlowNode( + buildContext = buildContext, + plugins = plugins, + pollHistoryEntryPoint = FakePollHistoryEntryPoint(), + elementCallEntryPoint = FakeElementCallEntryPoint(), + room = FakeJoinedRoom(), + analyticsService = FakeAnalyticsService(), + messagesEntryPoint = FakeMessagesEntryPoint(), + knockRequestsListEntryPoint = FakeKnockRequestsListEntryPoint(), + mediaViewerEntryPoint = FakeMediaViewerEntryPoint(), + mediaGalleryEntryPoint = FakeMediaGalleryEntryPoint(), + outgoingVerificationEntryPoint = FakeOutgoingVerificationEntryPoint(), + reportRoomEntryPoint = FakeReportRoomEntryPoint(), + changeRoomMemberRolesEntryPoint = FakeChangeRoomMemberRolesEntryPoint(), + rolesAndPermissionsEntryPoint = FakeRolesAndPermissionsEntryPoint(), + securityAndPrivacyEntryPoint = FakeSecurityAndPrivacyEntryPoint(), + ) + } + val callback = object : RoomDetailsEntryPoint.Callback { + override fun navigateToGlobalNotificationSettings() = lambdaError() + override fun navigateToRoom(roomId: RoomId, serverNames: List) = lambdaError() + override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError() + override fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError() + } + val params = RoomDetailsEntryPoint.Params( + initialElement = RoomDetailsEntryPoint.InitialTarget.RoomDetails, + ) + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + params = params, + callback = callback, + ) + assertThat(result).isInstanceOf(RoomDetailsFlowNode::class.java) + assertThat(result.plugins).contains(params) + assertThat(result.plugins).contains(callback) + } + + @Test + fun `test initial target to nav target mapping`() { + assertThat(RoomDetailsEntryPoint.InitialTarget.RoomDetails.toNavTarget()) + .isEqualTo(RoomDetailsFlowNode.NavTarget.RoomDetails) + assertThat(RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(A_USER_ID).toNavTarget()) + .isEqualTo(RoomDetailsFlowNode.NavTarget.RoomMemberDetails(A_USER_ID)) + assertThat(RoomDetailsEntryPoint.InitialTarget.RoomNotificationSettings.toNavTarget()) + .isEqualTo(RoomDetailsFlowNode.NavTarget.RoomNotificationSettings(showUserDefinedSettingStyle = true)) + } +} diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/MatrixRoomFixture.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/MatrixRoomFixture.kt new file mode 100644 index 0000000..5043aea --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/MatrixRoomFixture.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl + +import io.element.android.libraries.matrix.api.core.RoomAlias +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.room.RoomMember +import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.A_ROOM_ALIAS +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_ROOM_TOPIC +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.tests.testutils.lambda.lambdaError + +fun aRoom( + sessionId: SessionId = A_SESSION_ID, + roomId: RoomId = A_ROOM_ID, + displayName: String = A_ROOM_NAME, + rawName: String? = displayName, + topic: String? = A_ROOM_TOPIC, + avatarUrl: String? = AN_AVATAR_URL, + canonicalAlias: RoomAlias? = A_ROOM_ALIAS, + isEncrypted: Boolean = true, + isPublic: Boolean = true, + isDirect: Boolean = false, + joinRule: JoinRule? = null, + activeMemberCount: Long = 1, + joinedMemberCount: Long = 1, + invitedMemberCount: Long = 0, + canInviteResult: (UserId) -> Result = { lambdaError() }, + canBanResult: (UserId) -> Result = { lambdaError() }, + canKickResult: (UserId) -> Result = { lambdaError() }, + canSendStateResult: (UserId, StateEventType) -> Result = { _, _ -> lambdaError() }, + userDisplayNameResult: (UserId) -> Result = { lambdaError() }, + userAvatarUrlResult: () -> Result = { lambdaError() }, + canUserJoinCallResult: (UserId) -> Result = { lambdaError() }, + getUpdatedMemberResult: (UserId) -> Result = { lambdaError() }, + userRoleResult: () -> Result = { lambdaError() }, + setIsFavoriteResult: (Boolean) -> Result = { lambdaError() }, +) = FakeBaseRoom( + sessionId = sessionId, + roomId = roomId, + canInviteResult = canInviteResult, + canBanResult = canBanResult, + canKickResult = canKickResult, + canSendStateResult = canSendStateResult, + userDisplayNameResult = userDisplayNameResult, + userAvatarUrlResult = userAvatarUrlResult, + canUserJoinCallResult = canUserJoinCallResult, + getUpdatedMemberResult = getUpdatedMemberResult, + userRoleResult = userRoleResult, + setIsFavoriteResult = setIsFavoriteResult, + initialRoomInfo = aRoomInfo( + name = displayName, + rawName = rawName, + topic = topic, + avatarUrl = avatarUrl, + canonicalAlias = canonicalAlias, + isDirect = isDirect, + isPublic = isPublic, + isEncrypted = isEncrypted, + joinRule = joinRule, + joinedMembersCount = joinedMemberCount, + activeMembersCount = activeMemberCount, + invitedMembersCount = invitedMemberCount, + ) +) + +fun aJoinedRoom( + sessionId: SessionId = A_SESSION_ID, + roomId: RoomId = A_ROOM_ID, + displayName: String = A_ROOM_NAME, + rawName: String? = displayName, + topic: String? = A_ROOM_TOPIC, + avatarUrl: String? = AN_AVATAR_URL, + canonicalAlias: RoomAlias? = A_ROOM_ALIAS, + isEncrypted: Boolean = true, + isPublic: Boolean = true, + isDirect: Boolean = false, + joinRule: JoinRule? = null, + activeMemberCount: Long = 1, + joinedMemberCount: Long = 1, + invitedMemberCount: Long = 0, + notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), + canInviteResult: (UserId) -> Result = { lambdaError() }, + canBanResult: (UserId) -> Result = { lambdaError() }, + canKickResult: (UserId) -> Result = { lambdaError() }, + canSendStateResult: (UserId, StateEventType) -> Result = { _, _ -> lambdaError() }, + userDisplayNameResult: (UserId) -> Result = { lambdaError() }, + userAvatarUrlResult: () -> Result = { lambdaError() }, + setNameResult: (String) -> Result = { lambdaError() }, + setTopicResult: (String) -> Result = { lambdaError() }, + updateAvatarResult: (String, ByteArray) -> Result = { _, _ -> lambdaError() }, + removeAvatarResult: () -> Result = { lambdaError() }, + canUserJoinCallResult: (UserId) -> Result = { lambdaError() }, + getUpdatedMemberResult: (UserId) -> Result = { lambdaError() }, + userRoleResult: () -> Result = { lambdaError() }, + kickUserResult: (UserId, String?) -> Result = { _, _ -> lambdaError() }, + banUserResult: (UserId, String?) -> Result = { _, _ -> lambdaError() }, + unBanUserResult: (UserId, String?) -> Result = { _, _ -> lambdaError() }, + updateCanonicalAliasResult: (RoomAlias?, List) -> Result = { _, _ -> lambdaError() }, + publishRoomAliasInRoomDirectoryResult: (RoomAlias) -> Result = { lambdaError() }, + removeRoomAliasFromRoomDirectoryResult: (RoomAlias) -> Result = { lambdaError() }, + setIsFavoriteResult: (Boolean) -> Result = { lambdaError() }, +) = FakeJoinedRoom( + roomNotificationSettingsService = notificationSettingsService, + setNameResult = setNameResult, + setTopicResult = setTopicResult, + updateAvatarResult = updateAvatarResult, + removeAvatarResult = removeAvatarResult, + kickUserResult = kickUserResult, + banUserResult = banUserResult, + unBanUserResult = unBanUserResult, + updateCanonicalAliasResult = updateCanonicalAliasResult, + publishRoomAliasInRoomDirectoryResult = publishRoomAliasInRoomDirectoryResult, + removeRoomAliasFromRoomDirectoryResult = removeRoomAliasFromRoomDirectoryResult, + baseRoom = aRoom( + sessionId = sessionId, + roomId = roomId, + canInviteResult = canInviteResult, + canBanResult = canBanResult, + canKickResult = canKickResult, + canSendStateResult = canSendStateResult, + userDisplayNameResult = userDisplayNameResult, + userAvatarUrlResult = userAvatarUrlResult, + canUserJoinCallResult = canUserJoinCallResult, + getUpdatedMemberResult = getUpdatedMemberResult, + userRoleResult = userRoleResult, + setIsFavoriteResult = setIsFavoriteResult, + displayName = displayName, + rawName = rawName, + topic = topic, + avatarUrl = avatarUrl, + canonicalAlias = canonicalAlias, + isDirect = isDirect, + isPublic = isPublic, + isEncrypted = isEncrypted, + joinRule = joinRule, + joinedMemberCount = joinedMemberCount, + activeMemberCount = activeMemberCount, + invitedMemberCount = invitedMemberCount, + ) +) diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt new file mode 100644 index 0000000..9cf46c4 --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt @@ -0,0 +1,747 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl + +import androidx.lifecycle.Lifecycle +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.Interaction +import io.element.android.features.leaveroom.api.LeaveRoomEvent +import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.features.roomcall.api.aStandByCallState +import io.element.android.features.roomdetails.impl.members.aRoomMember +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter +import io.element.android.features.userprofile.shared.aUserProfileState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +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.UserId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.api.room.join.JoinRule +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_NAME +import io.element.android.libraries.matrix.test.A_ROOM_TOPIC +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.A_USER_NAME +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.FakeLifecycleOwner +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.testCoroutineDispatchers +import io.element.android.tests.testutils.testWithLifecycleOwner +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds + +@Suppress("LargeClass") +@ExperimentalCoroutinesApi +class RoomDetailsPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private val fakeLifecycleOwner = FakeLifecycleOwner().apply { + givenState(Lifecycle.State.RESUMED) + } + + private fun TestScope.createRoomDetailsPresenter( + room: JoinedRoom = aJoinedRoom(), + leaveRoomState: LeaveRoomState = aLeaveRoomState(), + dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), + notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), + analyticsService: AnalyticsService = FakeAnalyticsService(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService( + mapOf( + FeatureFlags.Knock.key to false, + ) + ), + encryptionService: FakeEncryptionService = FakeEncryptionService(), + clipboardHelper: ClipboardHelper = FakeClipboardHelper(), + appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore() + ): RoomDetailsPresenter { + val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService) + val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory { + override fun create(roomMemberId: UserId): RoomMemberDetailsPresenter { + return RoomMemberDetailsPresenter( + roomMemberId = roomMemberId, + room = room, + userProfilePresenterFactory = { + Presenter { aUserProfileState() } + }, + encryptionService = encryptionService, + clipboardHelper = clipboardHelper, + ) + } + } + return RoomDetailsPresenter( + client = matrixClient, + room = room, + featureFlagService = featureFlagService, + notificationSettingsService = matrixClient.notificationSettingsService, + roomMembersDetailsPresenterFactory = roomMemberDetailsPresenterFactory, + leaveRoomPresenter = { leaveRoomState }, + roomCallStatePresenter = { aStandByCallState() }, + dispatchers = dispatchers, + analyticsService = analyticsService, + clipboardHelper = clipboardHelper, + appPreferencesStore = appPreferencesStore, + ) + } + + @Test + fun `present - initial state is created from initial room info`() = runTest { + val room = aJoinedRoom( + canInviteResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canSendStateResult = { _, _ -> Result.success(true) }, + ) + val presenter = createRoomDetailsPresenter(room) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + val initialState = awaitItem() + assertThat(initialState.roomId).isEqualTo(room.roomId) + assertThat(initialState.roomName).isEqualTo(room.info().name) + assertThat(initialState.roomAvatarUrl).isEqualTo(room.info().avatarUrl) + assertThat(initialState.roomTopic).isEqualTo(RoomTopicState.ExistingTopic(room.info().topic!!)) + assertThat(initialState.memberCount).isEqualTo(room.info().joinedMembersCount) + assertThat(initialState.pinnedMessagesCount).isEqualTo(0) + assertThat(initialState.canShowSecurityAndPrivacy).isFalse() + assertThat(initialState.showDebugInfo).isFalse() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - initial state is updated with a new roomInfo`() = runTest { + val roomInfo = aRoomInfo( + name = A_ROOM_NAME, + topic = A_ROOM_TOPIC, + avatarUrl = AN_AVATAR_URL, + pinnedEventIds = listOf(AN_EVENT_ID), + ) + val room = aJoinedRoom( + canInviteResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canSendStateResult = { _, _ -> Result.success(true) }, + ).apply { + givenRoomInfo(roomInfo) + } + val presenter = createRoomDetailsPresenter(room) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + skipItems(1) + val updatedState = awaitItem() + assertThat(updatedState.roomName).isEqualTo(roomInfo.name) + assertThat(updatedState.roomAvatarUrl).isEqualTo(roomInfo.avatarUrl) + assertThat(updatedState.roomTopic).isEqualTo(RoomTopicState.ExistingTopic(roomInfo.topic!!)) + assertThat(updatedState.pinnedMessagesCount).isEqualTo(roomInfo.pinnedEventIds.size) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - initial state with no room name`() = runTest { + val room = aJoinedRoom( + displayName = "", + canInviteResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canSendStateResult = { _, _ -> Result.success(true) }, + ) + val presenter = createRoomDetailsPresenter(room) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + val initialState = awaitItem() + assertThat(initialState.roomName).isEqualTo(room.info().name) + + 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 = aJoinedRoom( + canInviteResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canSendStateResult = { _, _ -> Result.success(true) }, + getUpdatedMemberResult = { userId -> + when (userId) { + A_SESSION_ID -> Result.success(myRoomMember) + A_USER_ID_2 -> Result.success(otherRoomMember) + else -> lambdaError() + } + }, + ).apply { + val roomMembers = persistentListOf(myRoomMember, otherRoomMember) + givenRoomMembersState(RoomMembersState.Ready(roomMembers)) + + givenRoomInfo( + aRoomInfo( + isEncrypted = true, + isDirect = true, + ) + ) + } + val presenter = createRoomDetailsPresenter(room) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + val initialState = awaitItem() + assertThat(initialState.roomType).isEqualTo( + RoomDetailsType.Dm( + me = myRoomMember, + otherMember = otherRoomMember, + ) + ) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - initial state when user can invite others to room`() = runTest { + val room = aJoinedRoom( + canInviteResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canSendStateResult = { _, _ -> Result.success(true) }, + ) + val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers()) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + // 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 = aJoinedRoom( + canInviteResult = { Result.success(false) }, + canKickResult = { Result.success(false) }, + canBanResult = { Result.success(false) }, + canUserJoinCallResult = { Result.success(true) }, + canSendStateResult = { _, _ -> Result.success(true) }, + ) + val presenter = createRoomDetailsPresenter(room) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + assertThat(awaitItem().canInvite).isFalse() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - initial state when canInvite errors`() = runTest { + val room = aJoinedRoom( + canInviteResult = { Result.failure(RuntimeException("Whoops")) }, + canUserJoinCallResult = { Result.success(true) }, + canSendStateResult = { _, _ -> Result.success(true) }, + ) + val presenter = createRoomDetailsPresenter(room) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + assertThat(awaitItem().canInvite).isFalse() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - initial state when user can edit one attribute`() = runTest { + val room = aJoinedRoom( + canSendStateResult = { _, stateEventType -> + when (stateEventType) { + StateEventType.ROOM_TOPIC -> Result.success(true) + StateEventType.ROOM_NAME -> Result.success(false) + else -> Result.failure(RuntimeException("Whelp")) + } + }, + canBanResult = { Result.success(false) }, + canKickResult = { Result.success(false) }, + canInviteResult = { Result.success(false) }, + canUserJoinCallResult = { Result.success(true) }, + ) + val presenter = createRoomDetailsPresenter(room) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + // 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 = aJoinedRoom( + canSendStateResult = { _, stateEventType -> + when (stateEventType) { + StateEventType.ROOM_TOPIC, + StateEventType.ROOM_NAME, + StateEventType.ROOM_AVATAR -> Result.success(true) + else -> Result.failure(RuntimeException("Whelp")) + } + }, + canKickResult = { Result.success(false) }, + canBanResult = { Result.success(false) }, + canInviteResult = { Result.success(false) }, + canUserJoinCallResult = { Result.success(true) }, + getUpdatedMemberResult = { userId -> + when (userId) { + A_SESSION_ID -> Result.success(myRoomMember) + A_USER_ID_2 -> Result.success(otherRoomMember) + else -> lambdaError() + } + }, + ).apply { + val roomMembers = persistentListOf(myRoomMember, otherRoomMember) + givenRoomMembersState(RoomMembersState.Ready(roomMembers)) + + givenRoomInfo( + aRoomInfo( + isEncrypted = true, + isDirect = true, + ) + ) + } + val presenter = createRoomDetailsPresenter(room) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + // 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.info().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 = aJoinedRoom( + isDirect = true, + topic = null, + canSendStateResult = { _, stateEventType -> + when (stateEventType) { + StateEventType.ROOM_AVATAR, + StateEventType.ROOM_TOPIC, + StateEventType.ROOM_NAME -> Result.success(true) + else -> Result.failure(RuntimeException("Whelp")) + } + }, + userDisplayNameResult = { Result.success(A_USER_NAME) }, + userAvatarUrlResult = { Result.success(AN_AVATAR_URL) }, + canInviteResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + getUpdatedMemberResult = { userId -> + when (userId) { + A_SESSION_ID -> Result.success(myRoomMember) + A_USER_ID_2 -> Result.success(otherRoomMember) + else -> lambdaError() + } + }, + ).apply { + val roomMembers = persistentListOf(myRoomMember, otherRoomMember) + givenRoomMembersState(RoomMembersState.Ready(roomMembers)) + + givenRoomInfo( + aRoomInfo( + isDirect = true, + activeMembersCount = 2, + topic = null, + ) + ) + } + + val presenter = createRoomDetailsPresenter(room) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + skipItems(2) + + // 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 = aJoinedRoom( + canSendStateResult = { _, stateEventType -> + when (stateEventType) { + StateEventType.ROOM_TOPIC, + StateEventType.ROOM_NAME, + StateEventType.ROOM_AVATAR -> Result.success(true) + else -> Result.failure(RuntimeException("Whelp")) + } + }, + canKickResult = { + Result.success(false) + }, + canBanResult = { + Result.success(false) + }, + canInviteResult = { + Result.success(false) + }, + canUserJoinCallResult = { Result.success(true) }, + ) + val presenter = createRoomDetailsPresenter(room) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + // 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 = aJoinedRoom( + canSendStateResult = { _, stateEventType -> + when (stateEventType) { + StateEventType.ROOM_TOPIC, + StateEventType.ROOM_NAME, + StateEventType.ROOM_AVATAR -> Result.success(false) + else -> Result.failure(RuntimeException("Whelp")) + } + }, + canBanResult = { + Result.success(false) + }, + canKickResult = { + Result.success(false) + }, + canInviteResult = { + Result.success(false) + }, + canUserJoinCallResult = { Result.success(true) }, + ) + val presenter = createRoomDetailsPresenter(room) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + // Initially false, and no further events + assertThat(awaitItem().canEdit).isFalse() + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - topic state is hidden when no topic and user has no permission`() = runTest { + val room = aJoinedRoom( + topic = null, + canSendStateResult = { _, stateEventType -> + when (stateEventType) { + StateEventType.ROOM_AVATAR, + StateEventType.ROOM_NAME -> Result.success(true) + StateEventType.ROOM_TOPIC -> Result.success(false) + else -> Result.failure(RuntimeException("Whelp")) + } + }, + canKickResult = { + Result.success(false) + }, + canBanResult = { + Result.success(false) + }, + canInviteResult = { + Result.success(false) + }, + canUserJoinCallResult = { Result.success(true) }, + ) + val presenter = createRoomDetailsPresenter(room) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + // The initial state is "hidden" and no further state changes happen + assertThat(awaitItem().roomTopic).isEqualTo(RoomTopicState.Hidden) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - topic state is 'can add topic' when no topic and user has permission`() = runTest { + val room = aJoinedRoom( + topic = null, + canSendStateResult = { _, stateEventType -> + when (stateEventType) { + StateEventType.ROOM_AVATAR, + StateEventType.ROOM_TOPIC, + StateEventType.ROOM_NAME -> Result.success(true) + else -> Result.failure(RuntimeException("Whelp")) + } + }, + canKickResult = { + Result.success(false) + }, + canBanResult = { + Result.success(false) + }, + canInviteResult = { + Result.success(false) + }, + canUserJoinCallResult = { Result.success(true) }, + ).apply { + givenRoomInfo(aRoomInfo(topic = null)) + } + val presenter = createRoomDetailsPresenter(room) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + // 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 leaveRoomEventRecorder = EventsRecorder() + val room = aJoinedRoom( + canInviteResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canSendStateResult = { _, _ -> Result.success(true) }, + ) + val presenter = createRoomDetailsPresenter( + room = room, + leaveRoomState = aLeaveRoomState(eventSink = leaveRoomEventRecorder), + dispatchers = testCoroutineDispatchers() + ) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + awaitItem().eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) + leaveRoomEventRecorder.assertSingle(LeaveRoomEvent.LeaveRoom(room.roomId, needsConfirmation = true)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - notification mode changes`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService() + val room = aJoinedRoom( + notificationSettingsService = notificationSettingsService, + canInviteResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canSendStateResult = { _, _ -> Result.success(true) }, + ) + val presenter = createRoomDetailsPresenter( + room = room, + notificationSettingsService = notificationSettingsService, + ) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + notificationSettingsService.setRoomNotificationMode( + room.roomId, + RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + ) + val updatedState = consumeItemsUntilPredicate { + it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + }.last() + assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo( + RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + ) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - mute room notifications`() = runTest { + val notificationSettingsService = + FakeNotificationSettingsService(initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + val room = aJoinedRoom( + notificationSettingsService = notificationSettingsService, + canInviteResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canSendStateResult = { _, _ -> Result.success(true) }, + ) + val presenter = createRoomDetailsPresenter( + room = room, + notificationSettingsService = notificationSettingsService + ) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + awaitItem().eventSink(RoomDetailsEvent.MuteNotification) + val updatedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) { + it.roomNotificationSettings?.mode == RoomNotificationMode.MUTE + }.last() + assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo( + RoomNotificationMode.MUTE + ) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - unmute room notifications`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService( + initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, + initialEncryptedGroupDefaultMode = RoomNotificationMode.ALL_MESSAGES + ) + val room = aJoinedRoom( + notificationSettingsService = notificationSettingsService, + canInviteResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canSendStateResult = { _, _ -> Result.success(true) }, + ) + val presenter = createRoomDetailsPresenter( + room = room, + notificationSettingsService = notificationSettingsService + ) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + awaitItem().eventSink(RoomDetailsEvent.UnmuteNotification) + val updatedState = consumeItemsUntilPredicate { + it.roomNotificationSettings?.mode == RoomNotificationMode.ALL_MESSAGES + }.last() + assertThat(updatedState.roomNotificationSettings?.mode).isEqualTo( + RoomNotificationMode.ALL_MESSAGES + ) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - when set is favorite event is emitted, then the action is called`() = runTest { + val setIsFavoriteResult = lambdaRecorder> { _ -> Result.success(Unit) } + val room = aJoinedRoom( + setIsFavoriteResult = setIsFavoriteResult, + canInviteResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canSendStateResult = { _, _ -> Result.success(true) }, + ) + val analyticsService = FakeAnalyticsService() + val presenter = + createRoomDetailsPresenter(room = room, analyticsService = analyticsService) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + val initialState = awaitItem() + initialState.eventSink(RoomDetailsEvent.SetFavorite(true)) + setIsFavoriteResult.assertions().isCalledOnce().with(value(true)) + initialState.eventSink(RoomDetailsEvent.SetFavorite(false)) + setIsFavoriteResult.assertions().isCalledExactly(2) + .withSequence( + listOf(value(true)), + listOf(value(false)), + ) + assertThat(analyticsService.capturedEvents).containsExactly( + Interaction(name = Interaction.Name.MobileRoomFavouriteToggle), + Interaction(name = Interaction.Name.MobileRoomFavouriteToggle) + ) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - changes in room info updates the is favorite flag`() = runTest { + val room = aJoinedRoom( + canInviteResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canSendStateResult = { _, _ -> Result.success(true) }, + ) + val presenter = createRoomDetailsPresenter(room = room) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + room.givenRoomInfo(aRoomInfo(isFavorite = true)) + consumeItemsUntilPredicate { it.isFavorite }.last().let { state -> + assertThat(state.isFavorite).isTrue() + } + room.givenRoomInfo(aRoomInfo(isFavorite = false)) + consumeItemsUntilPredicate { !it.isFavorite }.last().let { state -> + assertThat(state.isFavorite).isFalse() + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - show knock requests`() = runTest { + val room = aJoinedRoom( + canInviteResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canSendStateResult = { _, _ -> Result.success(true) }, + joinRule = JoinRule.Knock, + ) + val featureFlagService = FakeFeatureFlagService( + mapOf(FeatureFlags.Knock.key to false) + ) + val presenter = createRoomDetailsPresenter( + room = room, + featureFlagService = featureFlagService, + ) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + skipItems(1) + assertThat(awaitItem().canShowKnockRequests).isFalse() + featureFlagService.setFeatureEnabled(FeatureFlags.Knock, true) + assertThat(awaitItem().canShowKnockRequests).isTrue() + room.givenRoomInfo(aRoomInfo(joinRule = JoinRule.Private)) + assertThat(awaitItem().canShowKnockRequests).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - show security and privacy`() = runTest { + val room = aJoinedRoom( + canInviteResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canSendStateResult = { _, _ -> Result.success(true) }, + ) + val featureFlagService = FakeFeatureFlagService() + val presenter = createRoomDetailsPresenter(room = room, featureFlagService = featureFlagService) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + skipItems(1) + with(awaitItem()) { + assertThat(canShowSecurityAndPrivacy).isTrue() + } + } + } + + @Test + fun `present - show debug info`() = runTest { + val room = aJoinedRoom( + canInviteResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canSendStateResult = { _, _ -> Result.success(true) }, + ) + val inMemoryAppPreferencesStore = InMemoryAppPreferencesStore( + isDeveloperModeEnabled = true, + ) + val presenter = createRoomDetailsPresenter(room = room, appPreferencesStore = inMemoryAppPreferencesStore) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + skipItems(1) + with(awaitItem()) { + assertThat(showDebugInfo).isTrue() + } + } + } +} diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateTest.kt new file mode 100644 index 0000000..68889c1 --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl + +import com.google.common.truth.Truth.assertThat +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test + +class RoomDetailsStateTest { + @Test + fun `room not public not encrypted should have not encrypted badge`() { + val sut = aRoomDetailsState( + isPublic = false, + isEncrypted = false, + ) + assertThat(sut.roomBadges).isEqualTo( + persistentListOf(RoomBadge.NOT_ENCRYPTED) + ) + } + + @Test + fun `room public not encrypted should have not encrypted and public badges`() { + val sut = aRoomDetailsState( + isPublic = true, + isEncrypted = false, + ) + assertThat(sut.roomBadges).isEqualTo( + persistentListOf(RoomBadge.NOT_ENCRYPTED, RoomBadge.PUBLIC) + ) + } + + @Test + fun `room public encrypted should have encrypted and public badges`() { + val sut = aRoomDetailsState( + isPublic = true, + isEncrypted = true, + ) + assertThat(sut.roomBadges).isEqualTo( + persistentListOf(RoomBadge.ENCRYPTED, RoomBadge.PUBLIC) + ) + } + + @Test + fun `room not public encrypted should have encrypted badges`() { + val sut = aRoomDetailsState( + isPublic = false, + isEncrypted = true, + ) + assertThat(sut.roomBadges).isEqualTo( + persistentListOf(RoomBadge.ENCRYPTED) + ) + } +} diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt new file mode 100644 index 0000000..b31fb32 --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt @@ -0,0 +1,375 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.roomdetails.impl.members.aRoomMember +import io.element.android.features.userprofile.shared.aUserProfileState +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParams +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class RoomDetailsViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `click on back invokes expected callback`() { + ensureCalledOnce { callback -> + rule.setRoomDetailView( + goBack = callback, + ) + rule.pressBack() + } + } + + @Test + fun `click on share invokes expected callback`() { + ensureCalledOnce { callback -> + rule.setRoomDetailView( + onShareRoom = callback, + ) + rule.clickOn(CommonStrings.action_share) + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `click on room members invokes expected callback`() { + ensureCalledOnce { callback -> + rule.setRoomDetailView( + openRoomMemberList = callback, + ) + rule.clickOn(CommonStrings.common_people) + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `click on polls invokes expected callback`() { + ensureCalledOnce { callback -> + rule.setRoomDetailView( + openPollHistory = callback, + ) + rule.clickOn(R.string.screen_polls_history_title) + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `click on media gallery invokes expected callback`() { + ensureCalledOnce { callback -> + rule.setRoomDetailView( + openMediaGallery = callback, + ) + rule.clickOn(R.string.screen_room_details_media_gallery_title) + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `click on notification invokes expected callback`() { + ensureCalledOnce { callback -> + rule.setRoomDetailView( + openRoomNotificationSettings = callback, + ) + rule.clickOn(R.string.screen_room_details_notification_title) + } + } + + @Test + fun `click on invite invokes expected callback`() { + ensureCalledOnce { callback -> + rule.setRoomDetailView( + state = aRoomDetailsState( + eventSink = EventsRecorder(expectEvents = false), + canInvite = true, + ), + invitePeople = callback, + ) + rule.clickOn(CommonStrings.action_invite) + } + } + + @Test + fun `click on call invokes expected callback`() { + ensureCalledOnce { callback -> + rule.setRoomDetailView( + state = aRoomDetailsState( + eventSink = EventsRecorder(expectEvents = false), + canInvite = true, + ), + onJoinCallClick = callback, + ) + rule.clickOn(CommonStrings.action_call) + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `click on pinned messages invokes expected callback`() { + ensureCalledOnce { callback -> + rule.setRoomDetailView( + state = aRoomDetailsState( + eventSink = EventsRecorder(expectEvents = false), + canInvite = true, + ), + onPinnedMessagesClick = callback, + ) + rule.clickOn(R.string.screen_room_details_pinned_events_row_title) + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `click on security and privacy invokes expected callback`() { + ensureCalledOnce { callback -> + rule.setRoomDetailView( + state = aRoomDetailsState( + eventSink = EventsRecorder(expectEvents = false), + canShowSecurityAndPrivacy = true, + ), + onSecurityAndPrivacyClick = callback, + ) + rule.clickOn(R.string.screen_room_details_security_and_privacy_title) + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `click on add topic emit expected event`() { + ensureCalledOnceWithParam(RoomDetailsAction.AddTopic) { callback -> + rule.setRoomDetailView( + state = aRoomDetailsState( + eventSink = EventsRecorder(expectEvents = false), + roomTopic = RoomTopicState.CanAddTopic, + ), + onActionClick = callback, + ) + rule.clickOn(R.string.screen_room_details_add_topic_title) + } + } + + @Test + fun `click on menu edit emit expected event`() { + ensureCalledOnceWithParam(RoomDetailsAction.Edit) { callback -> + rule.setRoomDetailView( + state = aRoomDetailsState( + eventSink = EventsRecorder(expectEvents = false), + canEdit = true, + ), + onActionClick = callback, + ) + val menuContentDescription = rule.activity.getString(CommonStrings.a11y_user_menu) + rule.onNodeWithContentDescription(menuContentDescription).performClick() + rule.clickOn(CommonStrings.action_edit) + } + } + + @Test + fun `click on avatar test`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aRoomDetailsState( + eventSink = eventsRecorder, + roomAvatarUrl = "an_avatar_url", + ) + val callback = EnsureCalledOnceWithTwoParams(state.roomName, "an_avatar_url") + rule.setRoomDetailView( + state = state, + openAvatarPreview = callback, + ) + rule.onNodeWithTag(TestTags.roomDetailAvatar.value).performClick() + callback.assertSuccess() + } + + @Test + fun `click on avatar test on DM`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aRoomDetailsState( + roomType = RoomDetailsType.Dm( + aRoomMember(), + aDmRoomMember(avatarUrl = "an_avatar_url"), + ), + roomName = "Daniel", + eventSink = eventsRecorder, + ) + val callback = EnsureCalledOnceWithTwoParams("Daniel", "an_avatar_url") + rule.setRoomDetailView( + state = state, + openAvatarPreview = callback, + ) + rule.onNodeWithTag(TestTags.memberDetailAvatar.value).performClick() + callback.assertSuccess() + } + + @Test + fun `click on mute emit expected event`() { + val eventsRecorder = EventsRecorder() + val state = aRoomDetailsState( + eventSink = eventsRecorder, + roomNotificationSettings = aRoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES), + ) + rule.setRoomDetailView( + state = state, + ) + rule.clickOn(CommonStrings.common_mute) + eventsRecorder.assertSingle(RoomDetailsEvent.MuteNotification) + } + + @Test + fun `click on unmute emit expected event`() { + val eventsRecorder = EventsRecorder() + val state = aRoomDetailsState( + eventSink = eventsRecorder, + roomNotificationSettings = aRoomNotificationSettings(mode = RoomNotificationMode.MUTE), + ) + rule.setRoomDetailView( + state = state, + ) + rule.clickOn(CommonStrings.common_unmute) + eventsRecorder.assertSingle(RoomDetailsEvent.UnmuteNotification) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `click on favorite emit expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomDetailView( + state = aRoomDetailsState( + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.common_favourite) + eventsRecorder.assertSingle(RoomDetailsEvent.SetFavorite(true)) + } + + @Config(qualifiers = "h1500dp") + @Test + fun `click on leave emit expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomDetailView( + state = aRoomDetailsState( + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_room_details_leave_room_title) + eventsRecorder.assertSingle(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) + } + + @Config(qualifiers = "h1500dp") + @Test + fun `click on report room invokes expected callback`() { + ensureCalledOnce { callback -> + rule.setRoomDetailView( + state = aRoomDetailsState( + eventSink = EventsRecorder(expectEvents = false), + ), + onReportRoomClick = callback, + ) + rule.clickOn(CommonStrings.action_report_room) + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `click on knock requests invokes expected callback`() { + ensureCalledOnce { callback -> + rule.setRoomDetailView( + state = aRoomDetailsState( + eventSink = EventsRecorder(expectEvents = false), + canShowKnockRequests = true, + ), + onKnockRequestsClick = callback, + ) + rule.clickOn(R.string.screen_room_details_requests_to_join_title) + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `click on profile invokes the expected callback`() { + ensureCalledOnceWithParam(A_USER_ID) { callback -> + rule.setRoomDetailView( + state = aRoomDetailsState( + eventSink = EventsRecorder(expectEvents = false), + roomMemberDetailsState = aUserProfileState(userId = A_USER_ID), + ), + onProfileClick = callback, + ) + rule.clickOn(R.string.screen_room_details_profile_row_title) + } + } +} + +private fun AndroidComposeTestRule.setRoomDetailView( + state: RoomDetailsState = aRoomDetailsState( + eventSink = EventsRecorder(expectEvents = false), + ), + goBack: () -> Unit = EnsureNeverCalled(), + onActionClick: (RoomDetailsAction) -> Unit = EnsureNeverCalledWithParam(), + onShareRoom: () -> Unit = EnsureNeverCalled(), + openRoomMemberList: () -> Unit = EnsureNeverCalled(), + openRoomNotificationSettings: () -> Unit = EnsureNeverCalled(), + invitePeople: () -> Unit = EnsureNeverCalled(), + openAvatarPreview: (name: String, url: String) -> Unit = EnsureNeverCalledWithTwoParams(), + openPollHistory: () -> Unit = EnsureNeverCalled(), + openMediaGallery: () -> Unit = EnsureNeverCalled(), + openAdminSettings: () -> Unit = EnsureNeverCalled(), + onJoinCallClick: () -> Unit = EnsureNeverCalled(), + onPinnedMessagesClick: () -> Unit = EnsureNeverCalled(), + onKnockRequestsClick: () -> Unit = EnsureNeverCalled(), + onSecurityAndPrivacyClick: () -> Unit = EnsureNeverCalled(), + onProfileClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), + onReportRoomClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + RoomDetailsView( + state = state, + goBack = goBack, + onActionClick = onActionClick, + onShareRoom = onShareRoom, + openRoomMemberList = openRoomMemberList, + openRoomNotificationSettings = openRoomNotificationSettings, + invitePeople = invitePeople, + openAvatarPreview = openAvatarPreview, + openPollHistory = openPollHistory, + openMediaGallery = openMediaGallery, + openAdminSettings = openAdminSettings, + onJoinCallClick = onJoinCallClick, + onPinnedMessagesClick = onPinnedMessagesClick, + onKnockRequestsClick = onKnockRequestsClick, + onSecurityAndPrivacyClick = onSecurityAndPrivacyClick, + onProfileClick = onProfileClick, + onReportRoomClick = onReportRoomClick, + leaveRoomView = {}, + ) + } +} diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenterTest.kt new file mode 100644 index 0000000..f66091d --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenterTest.kt @@ -0,0 +1,780 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.edit + +import android.net.Uri +import app.cash.turbine.ReceiveTurbine +import com.google.common.truth.Truth.assertThat +import io.element.android.features.roomdetails.impl.aJoinedRoom +import io.element.android.libraries.androidutils.file.TemporaryUriDeleter +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.room.JoinedRoom +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.test.A_ROOM_NAME +import io.element.android.libraries.matrix.test.A_ROOM_RAW_NAME +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.FakeMediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +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.Rule +import org.junit.Test +import java.io.File + +@Suppress("LargeClass") +@ExperimentalCoroutinesApi +class RoomDetailsEditPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + 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 { roomAvatarUri.toString() } returns AN_AVATAR_URL + every { Uri.parse(ANOTHER_AVATAR_URL) } returns anotherAvatarUri + every { anotherAvatarUri.toString() } returns ANOTHER_AVATAR_URL + } + + @After + fun tearDown() { + unmockkAll() + } + + private fun createRoomDetailsEditPresenter( + room: JoinedRoom, + permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), + temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(), + mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), + ): RoomDetailsEditPresenter { + return RoomDetailsEditPresenter( + room = room, + mediaPickerProvider = fakePickerProvider, + mediaPreProcessor = fakeMediaPreProcessor, + permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter), + temporaryUriDeleter = temporaryUriDeleter, + mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, + ) + } + + @Test + fun `present - initial state is created from room info`() = runTest { + val room = aJoinedRoom( + avatarUrl = AN_AVATAR_URL, + displayName = A_ROOM_NAME, + rawName = A_ROOM_RAW_NAME, + canSendStateResult = { _, _ -> Result.success(true) } + ) + val deleteCallback = lambdaRecorder {} + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.roomId).isEqualTo(room.roomId) + assertThat(initialState.roomRawName).isEqualTo(A_ROOM_RAW_NAME) + assertThat(initialState.roomAvatarUrl).isEqualTo(AN_AVATAR_URL) + assertThat(initialState.roomTopic).isEqualTo(room.info().topic.orEmpty()) + assertThat(initialState.avatarActions).containsExactly( + AvatarAction.ChoosePhoto, + AvatarAction.TakePhoto, + AvatarAction.Remove + ) + assertThat(initialState.saveButtonEnabled).isFalse() + assertThat(initialState.saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + } + } + + @Test + fun `present - sets canChangeName if user has permission`() = runTest { + val room = aJoinedRoom( + avatarUrl = AN_AVATAR_URL, + canSendStateResult = { _, stateEventType -> + when (stateEventType) { + StateEventType.ROOM_NAME -> Result.success(true) + StateEventType.ROOM_AVATAR -> Result.success(false) + StateEventType.ROOM_TOPIC -> Result.failure(RuntimeException("Oops")) + else -> lambdaError() + } + }, + ) + val deleteCallback = lambdaRecorder {} + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + ) + presenter.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() + deleteCallback.assertions().isCalledOnce().with(value(null)) + } + } + + @Test + fun `present - sets canChangeAvatar if user has permission`() = runTest { + val room = aJoinedRoom( + avatarUrl = AN_AVATAR_URL, + canSendStateResult = { _, stateEventType -> + when (stateEventType) { + StateEventType.ROOM_NAME -> Result.success(false) + StateEventType.ROOM_AVATAR -> Result.success(true) + StateEventType.ROOM_TOPIC -> Result.failure(RuntimeException("Oops")) + else -> lambdaError() + } + } + ) + val deleteCallback = lambdaRecorder {} + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + ) + presenter.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 = aJoinedRoom( + avatarUrl = AN_AVATAR_URL, + canSendStateResult = { _, stateEventType -> + when (stateEventType) { + StateEventType.ROOM_NAME -> Result.success(false) + StateEventType.ROOM_AVATAR -> Result.failure(RuntimeException("Oops")) + StateEventType.ROOM_TOPIC -> Result.success(true) + else -> lambdaError() + } + } + ) + val deleteCallback = lambdaRecorder {} + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + ) + presenter.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 = aJoinedRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + canSendStateResult = { _, _ -> Result.success(true) } + ) + val deleteCallback = lambdaRecorder {} + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.roomTopic).isEqualTo("My topic") + assertThat(initialState.roomRawName).isEqualTo("Name") + assertThat(initialState.roomAvatarUrl).isEqualTo(AN_AVATAR_URL) + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II")) + awaitItem().apply { + assertThat(roomTopic).isEqualTo("My topic") + assertThat(roomRawName).isEqualTo("Name II") + assertThat(roomAvatarUrl).isEqualTo(AN_AVATAR_URL) + } + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name III")) + awaitItem().apply { + assertThat(roomTopic).isEqualTo("My topic") + assertThat(roomRawName).isEqualTo("Name III") + assertThat(roomAvatarUrl).isEqualTo(AN_AVATAR_URL) + } + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic")) + awaitItem().apply { + assertThat(roomTopic).isEqualTo("Another topic") + assertThat(roomRawName).isEqualTo("Name III") + assertThat(roomAvatarUrl).isEqualTo(AN_AVATAR_URL) + } + initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) + awaitItem().apply { + assertThat(roomTopic).isEqualTo("Another topic") + assertThat(roomRawName).isEqualTo("Name III") + assertThat(roomAvatarUrl).isNull() + } + } + } + + @Test + fun `present - obtains avatar uris from gallery`() = runTest { + val room = aJoinedRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + canSendStateResult = { _, _ -> Result.success(true) } + ) + fakePickerProvider.givenResult(anotherAvatarUri) + val deleteCallback = lambdaRecorder {} + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.roomAvatarUrl).isEqualTo(AN_AVATAR_URL) + initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + awaitItem().apply { + assertThat(roomAvatarUrl).isEqualTo(anotherAvatarUri.toString()) + } + } + } + + @Test + fun `present - obtains avatar uris from camera`() = runTest { + val room = aJoinedRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + canSendStateResult = { _, _ -> Result.success(true) } + ) + fakePickerProvider.givenResult(anotherAvatarUri) + val fakePermissionsPresenter = FakePermissionsPresenter() + val deleteCallback = lambdaRecorder {} + val presenter = createRoomDetailsEditPresenter( + room = room, + permissionsPresenter = fakePermissionsPresenter, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.roomAvatarUrl).isEqualTo(AN_AVATAR_URL) + assertThat(initialState.cameraPermissionState.permissionGranted).isFalse() + initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto)) + val stateWithAskingPermission = awaitItem() + assertThat(stateWithAskingPermission.cameraPermissionState.showDialog).isTrue() + fakePermissionsPresenter.setPermissionGranted() + val stateWithPermission = awaitItem() + assertThat(stateWithPermission.cameraPermissionState.permissionGranted).isTrue() + val stateWithNewAvatar = awaitItem() + assertThat(stateWithNewAvatar.roomAvatarUrl).isEqualTo(anotherAvatarUri.toString()) + // Do it again, no permission is requested + fakePickerProvider.givenResult(roomAvatarUri) + stateWithNewAvatar.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto)) + val stateWithNewAvatar2 = awaitItem() + assertThat(stateWithNewAvatar2.roomAvatarUrl).isEqualTo(AN_AVATAR_URL) + deleteCallback.assertions().isCalledExactly(3).withSequence( + listOf(value(null)), + listOf(value(roomAvatarUri)), + listOf(value(anotherAvatarUri)), + ) + } + } + + @Test + fun `present - updates save button state`() = runTest { + val room = aJoinedRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + canSendStateResult = { _, _ -> Result.success(true) } + ) + fakePickerProvider.givenResult(roomAvatarUri) + val deleteCallback = lambdaRecorder {} + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.saveButtonEnabled).isFalse() + // Once a change is made, the save button is enabled + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II")) + awaitItem().apply { + assertThat(saveButtonEnabled).isTrue() + } + // If it's reverted then the save disables again + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name")) + awaitItem().apply { + assertThat(saveButtonEnabled).isFalse() + } + // Make a change... + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic")) + awaitItem().apply { + assertThat(saveButtonEnabled).isTrue() + } + // Revert it... + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("My topic")) + awaitItem().apply { + assertThat(saveButtonEnabled).isFalse() + } + // Make a change... + initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) + awaitItem().apply { + assertThat(saveButtonEnabled).isTrue() + } + // Revert it... + initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + awaitItem().apply { + assertThat(saveButtonEnabled).isFalse() + } + } + } + + @Test + fun `present - updates save button state when initial values are null`() = runTest { + val room = aJoinedRoom( + topic = null, + displayName = "fallback", + avatarUrl = null, + canSendStateResult = { _, _ -> Result.success(true) } + ) + fakePickerProvider.givenResult(roomAvatarUri) + val deleteCallback = lambdaRecorder {} + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.saveButtonEnabled).isFalse() + // Once a change is made, the save button is enabled + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II")) + awaitItem().apply { + assertThat(saveButtonEnabled).isTrue() + } + // If it's reverted then the save disables again + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("fallback")) + awaitItem().apply { + assertThat(saveButtonEnabled).isFalse() + } + // Make a change... + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic")) + awaitItem().apply { + assertThat(saveButtonEnabled).isTrue() + } + // Revert it... + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("")) + awaitItem().apply { + assertThat(saveButtonEnabled).isFalse() + } + // Make a change... + initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + awaitItem().apply { + assertThat(saveButtonEnabled).isTrue() + } + // Revert it... + initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) + awaitItem().apply { + assertThat(saveButtonEnabled).isFalse() + } + } + } + + @Test + fun `present - save changes room details if different`() = runTest { + val setNameResult = lambdaRecorder { _: String -> Result.success(Unit) } + val setTopicResult = lambdaRecorder { _: String -> Result.success(Unit) } + val removeAvatarResult = lambdaRecorder> { Result.success(Unit) } + val room = aJoinedRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + setNameResult = setNameResult, + setTopicResult = setTopicResult, + removeAvatarResult = removeAvatarResult, + canSendStateResult = { _, _ -> Result.success(true) } + ) + val deleteCallback = lambdaRecorder {} + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("New name")) + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("New topic")) + initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) + initialState.eventSink(RoomDetailsEditEvents.Save) + skipItems(5) + setNameResult.assertions().isCalledOnce().with(value("New name")) + setTopicResult.assertions().isCalledOnce().with(value("New topic")) + removeAvatarResult.assertions().isCalledOnce() + } + } + + @Test + fun `present - save doesn't change room details if they're the same trimmed`() = runTest { + val room = aJoinedRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + canSendStateResult = { _, _ -> Result.success(true) } + ) + val deleteCallback = lambdaRecorder {} + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(" Name ")) + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(" My topic ")) + initialState.eventSink(RoomDetailsEditEvents.Save) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - save doesn't change topic if it was unset and is now blank`() = runTest { + val room = aJoinedRoom( + topic = null, + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + canSendStateResult = { _, _ -> Result.success(true) } + ) + val deleteCallback = lambdaRecorder {} + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("")) + initialState.eventSink(RoomDetailsEditEvents.Save) + cancelAndIgnoreRemainingEvents() + deleteCallback.assertions().isCalledOnce().with(value(null)) + } + } + + @Test + fun `present - save doesn't change name if it's now empty`() = runTest { + val room = aJoinedRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + canSendStateResult = { _, _ -> Result.success(true) } + ) + val deleteCallback = lambdaRecorder {} + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("")) + initialState.eventSink(RoomDetailsEditEvents.Save) + cancelAndIgnoreRemainingEvents() + deleteCallback.assertions().isCalledOnce().with(value(null)) + } + } + + @Test + fun `present - save processes and sets avatar when processor returns successfully`() = runTest { + val updateAvatarResult = lambdaRecorder { _: String, _: ByteArray -> Result.success(Unit) } + val room = aJoinedRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + updateAvatarResult = updateAvatarResult, + canSendStateResult = { _, _ -> Result.success(true) } + ) + givenPickerReturnsFile() + val deleteCallback = lambdaRecorder {} + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + initialState.eventSink(RoomDetailsEditEvents.Save) + skipItems(4) + updateAvatarResult.assertions().isCalledOnce().with(value(MimeTypes.Jpeg), value(fakeFileContents)) + deleteCallback.assertions().isCalledExactly(2).withSequence( + listOf(value(null)), + listOf(value(roomAvatarUri)), + ) + } + } + + @Test + fun `present - save does not set avatar data if processor fails`() = runTest { + val room = aJoinedRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + canSendStateResult = { _, _ -> Result.success(true) } + ) + fakePickerProvider.givenResult(anotherAvatarUri) + fakeMediaPreProcessor.givenResult(Result.failure(RuntimeException("Oh no"))) + val deleteCallback = lambdaRecorder {} + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + initialState.eventSink(RoomDetailsEditEvents.Save) + skipItems(3) + assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) + } + } + + @Test + fun `present - sets save action to failure if name update fails`() = runTest { + val room = aJoinedRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + setNameResult = { Result.failure(RuntimeException("!")) }, + canSendStateResult = { _, _ -> Result.success(true) } + ) + saveAndAssertFailure(room, RoomDetailsEditEvents.UpdateRoomName("New name"), deleteCallbackNumberOfInvocation = 1) + } + + @Test + fun `present - sets save action to failure if topic update fails`() = runTest { + val room = aJoinedRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + setTopicResult = { Result.failure(RuntimeException("!")) }, + canSendStateResult = { _, _ -> Result.success(true) } + ) + saveAndAssertFailure(room, RoomDetailsEditEvents.UpdateRoomTopic("New topic"), deleteCallbackNumberOfInvocation = 1) + } + + @Test + fun `present - sets save action to failure if removing avatar fails`() = runTest { + val room = aJoinedRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + removeAvatarResult = { Result.failure(RuntimeException("!")) }, + canSendStateResult = { _, _ -> Result.success(true) } + ) + saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove), deleteCallbackNumberOfInvocation = 2) + } + + @Test + fun `present - sets save action to failure if setting avatar fails`() = runTest { + givenPickerReturnsFile() + val room = aJoinedRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + updateAvatarResult = { _, _ -> Result.failure(RuntimeException("!")) }, + canSendStateResult = { _, _ -> Result.success(true) } + ) + saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto), deleteCallbackNumberOfInvocation = 2) + } + + @Test + fun `present - CancelSaveChanges resets save action state`() = runTest { + givenPickerReturnsFile() + val room = aJoinedRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + setTopicResult = { Result.failure(RuntimeException("!")) }, + canSendStateResult = { _, _ -> Result.success(true) } + ) + val deleteCallback = lambdaRecorder {} + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("foo")) + initialState.eventSink(RoomDetailsEditEvents.Save) + skipItems(3) + assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) + initialState.eventSink(RoomDetailsEditEvents.CloseDialog) + assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + } + } + + @Test + fun `present - leave without saving - cancel`() = runTest { + val room = aJoinedRoom( + displayName = "Name", + canSendStateResult = { _, _ -> Result.success(true) } + ) + val deleteCallback = lambdaRecorder {} + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.saveButtonEnabled).isFalse() + // Once a change is made, the save button is enabled + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name edited")) + awaitItem().apply { + assertThat(saveButtonEnabled).isTrue() + eventSink(RoomDetailsEditEvents.OnBackPress) + } + awaitItem().apply { + assertThat(saveAction).isEqualTo(AsyncAction.ConfirmingCancellation) + eventSink(RoomDetailsEditEvents.CloseDialog) + } + awaitItem().apply { + assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized) + } + } + } + + @Test + fun `present - leave no changes, no confirmation`() = runTest { + val room = aJoinedRoom( + displayName = "Name", + canSendStateResult = { _, _ -> Result.success(true) } + ) + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter {}, + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.saveButtonEnabled).isFalse() + initialState.eventSink(RoomDetailsEditEvents.OnBackPress) + assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.Success(Unit)) + } + } + + @Test + fun `present - leave without saving - confirm`() = runTest { + val room = aJoinedRoom( + displayName = "Name", + canSendStateResult = { _, _ -> Result.success(true) } + ) + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter({}), + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.saveButtonEnabled).isFalse() + // Once a change is made, the save button is enabled + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name edited")) + awaitItem().apply { + assertThat(saveButtonEnabled).isTrue() + eventSink(RoomDetailsEditEvents.OnBackPress) + } + awaitItem().apply { + assertThat(saveAction).isEqualTo(AsyncAction.ConfirmingCancellation) + eventSink(RoomDetailsEditEvents.OnBackPress) + } + awaitItem().apply { + assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit)) + } + } + } + + private suspend fun saveAndAssertFailure( + room: JoinedRoom, + event: RoomDetailsEditEvents, + deleteCallbackNumberOfInvocation: Int = 2, + ) { + val deleteCallback = lambdaRecorder {} + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(event) + initialState.eventSink(RoomDetailsEditEvents.Save) + skipItems(1) + assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Loading::class.java) + assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) + deleteCallback.assertions().isCalledExactly(deleteCallbackNumberOfInvocation) + } + } + + 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" + } +} + +private suspend fun ReceiveTurbine.awaitFirstItem(): T { + skipItems(1) + return awaitItem() +} diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditViewTest.kt new file mode 100644 index 0000000..c8475cb --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditViewTest.kt @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.edit + +import androidx.activity.ComponentActivity +import androidx.annotation.StringRes +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.isEditable +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RoomDetailsEditViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder + ), + ) + rule.pressBack() + eventsRecorder.assertSingle(RoomDetailsEditEvents.OnBackPress) + } + + @Test + fun `clicking on OK when confirming exit emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + saveAction = AsyncAction.ConfirmingCancellation, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_ok) + eventsRecorder.assertSingle(RoomDetailsEditEvents.OnBackPress) + } + + @Test + fun `clicking on cancel when confirming exit emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + saveAction = AsyncAction.ConfirmingCancellation, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(RoomDetailsEditEvents.CloseDialog) + } + + @Test + fun `when edition is successful, the expected callback is invoked`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder, + saveAction = AsyncAction.Success(Unit) + ), + onDone = callback, + ) + } + } + + @Test + fun `when name is changed, the expected Event is emitted`() { + val eventsRecorder = EventsRecorder() + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder, + roomRawName = "Marketing", + ), + ) + rule.onNodeWithText("Marketing").performTextInput("A") + eventsRecorder.assertSingle(RoomDetailsEditEvents.UpdateRoomName("AMarketing")) + } + + @Test + fun `when user cannot change name, nothing happen`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder, + roomRawName = "Marketing", + canChangeName = false, + ), + ) + rule.onNodeWithText("Marketing").assert(!isEditable()) + } + + @Test + fun `when topic is changed, the expected Event is emitted`() { + val eventsRecorder = EventsRecorder() + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder, + roomTopic = "My Topic", + ), + ) + rule.onNodeWithText("My Topic").performTextInput("A") + eventsRecorder.assertSingle(RoomDetailsEditEvents.UpdateRoomTopic("AMy Topic")) + } + + @Test + fun `when user cannot change topic, nothing happen`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder, + roomTopic = "My Topic", + canChangeTopic = false, + ), + ) + rule.onNodeWithText("My Topic").assert(!isEditable()) + } + + @Ignore("This test is failing because the bottom sheet does not open") + @Test + fun `when avatar is changed with action to take photo, the expected Event is emitted`() { + testAvatarChange( + stringActionRes = CommonStrings.action_take_photo, + expectedEvent = RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto), + ) + } + + @Ignore("This test is failing because the bottom sheet does not open") + @Test + fun `when avatar is changed with action to choose photo, the expected Event is emitted`() { + testAvatarChange( + stringActionRes = CommonStrings.action_choose_photo, + expectedEvent = RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto), + ) + } + + @Ignore("This test is failing because the bottom sheet does not open") + @Test + fun `when avatar is changed with action to remove photo, the expected Event is emitted`() { + testAvatarChange( + stringActionRes = CommonStrings.action_remove, + expectedEvent = RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove), + ) + } + + private fun testAvatarChange( + @StringRes stringActionRes: Int, + expectedEvent: RoomDetailsEditEvents.HandleAvatarAction, + ) { + val eventsRecorder = EventsRecorder() + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder, + ), + ) + // Open the bottom sheet + rule.onNode(hasTestTag(TestTags.editAvatar.value)).performClick() + rule.onNodeWithText(rule.activity.getString(stringActionRes)).assertExists() + rule.clickOn(stringActionRes) + eventsRecorder.assertSingle(expectedEvent) + } + + @Test + fun `when user cannot change avatar, nothing happen`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder, + canChangeAvatar = false, + ), + ) + rule.onNode(hasTestTag(TestTags.editAvatar.value)).performClick() + rule.onNodeWithText(rule.activity.getString(CommonStrings.action_take_photo)).assertDoesNotExist() + } + + @Test + fun `when save is clicked, the expected Event is emitted`() { + val eventsRecorder = EventsRecorder() + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder, + saveButtonEnabled = true, + ), + ) + rule.clickOn(CommonStrings.action_save) + eventsRecorder.assertSingle(RoomDetailsEditEvents.Save) + } + + @Test + fun `when save is clicked, but nothing need to be saved, nothing happens`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder, + saveButtonEnabled = false, + ), + ) + rule.clickOn(CommonStrings.action_save) + } + + @Test + fun `when error is shown, closing the dialog emit the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder, + saveAction = AsyncAction.Failure(RuntimeException("Whelp")), + ), + ) + rule.clickOn(CommonStrings.action_ok) + eventsRecorder.assertSingle(RoomDetailsEditEvents.CloseDialog) + } +} + +private fun AndroidComposeTestRule.setRoomDetailsEditView( + state: RoomDetailsEditState, + onDone: () -> Unit = EnsureNeverCalled(), +) { + setContent { + RoomDetailsEditView( + state = state, + onDone = onDone, + ) + } +} diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt new file mode 100644 index 0000000..8d3d0e3 --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.members + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.roommembermoderation.api.RoomMemberModerationState +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class RoomMemberListPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `initial state is loading`() = runTest { + val presenter = createPresenter() + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.filteredRoomMembers.isLoading()).isTrue() + assertThat(initialState.searchQuery).isEmpty() + assertThat(initialState.selectedSection).isEqualTo(SelectedSection.MEMBERS) + } + } + + @Test + fun `hide banned section when there is no banned users`() = runTest { + val allRoomMembers = aRoomMemberList() + val noBannedMembers = allRoomMembers + .filterNot { it.membership == RoomMembershipState.BAN } + .toImmutableList() + val room = createFakeJoinedRoom() + .apply { + givenRoomMembersState(RoomMembersState.Ready(allRoomMembers)) + } + val presenter = createPresenter( + joinedRoom = room, + roomMemberModerationState = aRoomMemberModerationState(canBan = true), + ) + presenter.test { + skipItems(1) + val loadedState = awaitItem() + assertThat(loadedState.showBannedSection).isTrue() + loadedState.eventSink(RoomMemberListEvents.ChangeSelectedSection(SelectedSection.BANNED)) + val bannedSectionState = awaitItem() + assertThat(bannedSectionState.selectedSection).isEqualTo(SelectedSection.BANNED) + // Now update the room members to have no banned users + room.givenRoomMembersState(RoomMembersState.Ready(noBannedMembers)) + skipItems(1) + val noBannedMembersState = awaitItem() + assertThat(noBannedMembersState.showBannedSection).isFalse() + skipItems(1) + val finalState = awaitItem() + assertThat(finalState.selectedSection).isEqualTo(SelectedSection.MEMBERS) + } + } + + @Test + fun `member loading is done automatically on start, but is async`() = runTest { + val room = createFakeJoinedRoom() + val presenter = createPresenter(joinedRoom = room) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.filteredRoomMembers.isLoading()).isTrue() + assertThat(initialState.searchQuery).isEmpty() + room.givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) + // Skip items while the new members state is processed + skipItems(2) + val loadedState = awaitItem() + val loadedRoomMembers = loadedState.filteredRoomMembers.dataOrNull()!! + assertThat(loadedRoomMembers.joined).isNotEmpty() + assertThat(loadedRoomMembers.banned).isNotEmpty() + assertThat(loadedRoomMembers.invited).isNotEmpty() + assertThat(loadedRoomMembers.isEmpty(SelectedSection.MEMBERS)).isFalse() + assertThat(loadedRoomMembers.isEmpty(SelectedSection.BANNED)).isFalse() + } + } + + @Test + fun `search for something which is not found`() = runTest { + val room = createFakeJoinedRoom().apply { + givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) + } + val presenter = createPresenter(joinedRoom = room) + presenter.test { + skipItems(1) + val loadedState = awaitItem() + val loadedRoomMembers = loadedState.filteredRoomMembers.dataOrNull()!! + assertThat(loadedRoomMembers.joined).isNotEmpty() + assertThat(loadedRoomMembers.banned).isNotEmpty() + assertThat(loadedRoomMembers.invited).isNotEmpty() + assertThat(loadedRoomMembers.isEmpty(SelectedSection.MEMBERS)).isFalse() + assertThat(loadedRoomMembers.isEmpty(SelectedSection.BANNED)).isFalse() + loadedState.eventSink(RoomMemberListEvents.UpdateSearchQuery("something")) + val searchQueryUpdatedState = awaitItem() + assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("something") + val searchSearchResultDelivered = awaitItem() + val emptyRoomMembers = searchSearchResultDelivered.filteredRoomMembers.dataOrNull()!! + assertThat(emptyRoomMembers.joined).isEmpty() + assertThat(emptyRoomMembers.banned).isEmpty() + assertThat(emptyRoomMembers.invited).isEmpty() + assertThat(emptyRoomMembers.isEmpty(SelectedSection.MEMBERS)).isTrue() + assertThat(emptyRoomMembers.isEmpty(SelectedSection.BANNED)).isTrue() + } + } + + @Test + fun `search for something which is found`() = runTest { + val room = createFakeJoinedRoom().apply { + givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) + } + val presenter = createPresenter(joinedRoom = room) + presenter.test { + skipItems(1) + val loadedState = awaitItem() + val loadedRoomMembers = loadedState.filteredRoomMembers.dataOrNull()!! + assertThat(loadedRoomMembers.joined).isNotEmpty() + assertThat(loadedRoomMembers.banned).isNotEmpty() + assertThat(loadedRoomMembers.invited).isNotEmpty() + assertThat(loadedRoomMembers.isEmpty(SelectedSection.MEMBERS)).isFalse() + assertThat(loadedRoomMembers.isEmpty(SelectedSection.BANNED)).isFalse() + loadedState.eventSink(RoomMemberListEvents.UpdateSearchQuery("alice")) + val searchQueryUpdatedState = awaitItem() + assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("alice") + val searchSearchResultDelivered = awaitItem() + val emptyRoomMembers = searchSearchResultDelivered.filteredRoomMembers.dataOrNull()!! + assertThat(emptyRoomMembers.joined).isNotEmpty() + assertThat(emptyRoomMembers.banned).isEmpty() + assertThat(emptyRoomMembers.invited).isEmpty() + assertThat(emptyRoomMembers.isEmpty(SelectedSection.MEMBERS)).isFalse() + assertThat(emptyRoomMembers.isEmpty(SelectedSection.BANNED)).isTrue() + } + } + + @Test + fun `present - asynchronously sets canInvite when user has correct power level`() = runTest { + val presenter = createPresenter() + presenter.test { + skipItems(1) + val loadedState = awaitItem() + assertThat(loadedState.canInvite).isTrue() + } + } + + @Test + fun `present - asynchronously sets canInvite when user does not have correct power level`() = runTest { + val presenter = createPresenter( + joinedRoom = createFakeJoinedRoom( + canInviteResult = { Result.success(false) }, + ) + ) + presenter.test { + val loadedState = awaitItem() + assertThat(loadedState.canInvite).isFalse() + } + } + + @Test + fun `present - asynchronously sets canInvite when power level check fails`() = runTest { + val presenter = createPresenter( + joinedRoom = createFakeJoinedRoom( + canInviteResult = { Result.failure(RuntimeException("Eek")) }, + ) + ) + presenter.test { + val loadedState = awaitItem() + assertThat(loadedState.canInvite).isFalse() + } + } + + @Test + fun `present - RoomMemberSelected will open the moderation options`() = runTest { + val presenter = createPresenter( + roomMemberModerationState = aRoomMemberModerationState(canBan = true, canKick = true) + ) + presenter.test { + skipItems(1) + awaitItem().eventSink(RoomMemberListEvents.RoomMemberSelected(anInvitedVictor())) + } + } +} + +private fun createFakeJoinedRoom( + updateMembersResult: () -> Unit = { }, + canInviteResult: (UserId) -> Result = { Result.success(true) }, +): FakeJoinedRoom { + return FakeJoinedRoom( + baseRoom = FakeBaseRoom( + updateMembersResult = updateMembersResult, + canInviteResult = canInviteResult, + ).apply { + // Needed to avoid discarding the loaded members as a partial and invalid result + givenRoomInfo(aRoomInfo(joinedMembersCount = 2)) + } + ) +} + +@ExperimentalCoroutinesApi +private fun TestScope.createPresenter( + coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + joinedRoom: JoinedRoom = createFakeJoinedRoom(), + encryptedService: FakeEncryptionService = FakeEncryptionService(), + roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(), +) = RoomMemberListPresenter( + room = joinedRoom, + coroutineDispatchers = coroutineDispatchers, + roomMembersModerationPresenter = Presenter { + roomMemberModerationState + }, + encryptionService = encryptedService, +) diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt new file mode 100644 index 0000000..4dbf21c --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenterTest.kt @@ -0,0 +1,388 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.members.details + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.roomdetails.impl.aJoinedRoom +import io.element.android.features.roomdetails.impl.members.aRoomMember +import io.element.android.features.userprofile.api.UserProfileEvents +import io.element.android.features.userprofile.api.UserProfilePresenterFactory +import io.element.android.features.userprofile.api.UserProfileVerificationState +import io.element.android.features.userprofile.shared.aUserProfileState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class RoomMemberDetailsPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - returns the room member's data, then updates it if needed`() = runTest { + val roomMember = aRoomMember(displayName = "Alice") + val room = aJoinedRoom( + userDisplayNameResult = { Result.success("A custom name") }, + userAvatarUrlResult = { Result.success("A custom avatar") }, + getUpdatedMemberResult = { Result.success(roomMember) }, + ).apply { + givenRoomMembersState(RoomMembersState.Ready(persistentListOf(roomMember))) + } + val presenter = createRoomMemberDetailsPresenter( + room = room, + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.userName).isEqualTo("Alice") + assertThat(initialState.avatarUrl).isEqualTo("Profile avatar url") + skipItems(1) + val nextState = awaitItem() + assertThat(nextState.userName).isEqualTo("A custom name") + assertThat(nextState.avatarUrl).isEqualTo("A custom avatar") + } + } + + @Test + fun `present - will recover when retrieving room member details fails`() = runTest { + val roomMember = aRoomMember( + displayName = "Alice", + avatarUrl = "Alice Avatar url", + ) + val room = aJoinedRoom( + userDisplayNameResult = { Result.failure(RuntimeException()) }, + userAvatarUrlResult = { Result.failure(RuntimeException()) }, + getUpdatedMemberResult = { Result.failure(AN_EXCEPTION) }, + ).apply { + givenRoomMembersState(RoomMembersState.Ready(persistentListOf(roomMember))) + } + + val presenter = createRoomMemberDetailsPresenter( + room = room, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.userName).isEqualTo("Alice") + assertThat(initialState.avatarUrl).isEqualTo("Alice Avatar url") + } + } + + @Test + fun `present - will fallback to original data if the updated data is null`() = runTest { + val roomMember = aRoomMember(displayName = "Alice") + val room = aJoinedRoom( + userDisplayNameResult = { Result.success(null) }, + userAvatarUrlResult = { Result.success(null) }, + getUpdatedMemberResult = { Result.success(roomMember) } + ).apply { + givenRoomMembersState(RoomMembersState.Ready(persistentListOf(roomMember))) + } + val presenter = createRoomMemberDetailsPresenter( + room = room, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.userName).isEqualTo("Alice") + assertThat(initialState.avatarUrl).isEqualTo("Profile avatar url") + } + } + + @Test + fun `present - will fallback to user profile if user is not a member of the room`() = runTest { + val room = aJoinedRoom( + userDisplayNameResult = { Result.failure(Exception("Not a member!")) }, + userAvatarUrlResult = { Result.failure(Exception("Not a member!")) }, + getUpdatedMemberResult = { Result.failure(AN_EXCEPTION) }, + ) + val presenter = createRoomMemberDetailsPresenter( + room = room, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.userName).isEqualTo("Profile user name") + assertThat(initialState.avatarUrl).isEqualTo("Profile avatar url") + } + } + + @Test + fun `present - null cases`() = runTest { + val roomMember = aRoomMember( + displayName = null, + avatarUrl = null, + ) + val room = aJoinedRoom( + userDisplayNameResult = { Result.success(null) }, + userAvatarUrlResult = { Result.success(null) }, + getUpdatedMemberResult = { Result.success(roomMember) }, + ) + val presenter = createRoomMemberDetailsPresenter( + room = room, + userProfilePresenterFactory = { + Presenter { + aUserProfileState( + userName = null, + avatarUrl = null, + ) + } + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.userName).isNull() + assertThat(initialState.avatarUrl).isNull() + } + } + + @Test + fun `present - when user's identity is verified, the value in the state is VERIFIED`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) }, + userDisplayNameResult = { Result.success("A custom name") }, + userAvatarUrlResult = { Result.success("A custom avatar") }, + initialRoomInfo = aRoomInfo(isEncrypted = true), + ) + ) + val encryptionService = FakeEncryptionService( + getUserIdentityResult = { Result.success(IdentityState.Verified) }, + ) + val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService) + presenter.test { + // Initial state, then the verification state is updated + assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN) + consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.VERIFIED } + } + } + + @Test + fun `present - when user's identity is unknown, the value in the state is UNKNOWN`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) }, + userDisplayNameResult = { Result.success("A custom name") }, + userAvatarUrlResult = { Result.success("A custom avatar") }, + initialRoomInfo = aRoomInfo(isEncrypted = true), + ) + ) + val encryptionService = FakeEncryptionService( + getUserIdentityResult = { Result.success(null) }, + ) + val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService) + presenter.test { + // Initial state, then the verification state is updated + assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN) + ensureAllEventsConsumed() + } + } + + @Test + fun `present - when user's identity is pinned, the value in the state is UNVERIFIED`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) }, + userDisplayNameResult = { Result.success("A custom name") }, + userAvatarUrlResult = { Result.success("A custom avatar") }, + initialRoomInfo = aRoomInfo(isEncrypted = true), + ) + ) + val encryptionService = FakeEncryptionService( + getUserIdentityResult = { Result.success(IdentityState.Pinned) }, + ) + val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService) + presenter.test { + // Initial state, then the verification state is updated + assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN) + consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.UNVERIFIED } + } + } + + @Test + fun `present - when user's identity is pin violation, the value in the state is UNVERIFIED`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) }, + userDisplayNameResult = { Result.success("A custom name") }, + userAvatarUrlResult = { Result.success("A custom avatar") }, + initialRoomInfo = aRoomInfo(isEncrypted = true), + ) + ) + val encryptionService = FakeEncryptionService( + getUserIdentityResult = { Result.success(IdentityState.PinViolation) }, + ) + val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService) + presenter.test { + // Initial state, then the verification state is updated + assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN) + consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.UNVERIFIED } + } + } + + @Test + fun `present - when user's identity has a verification violation, the value in the state is VERIFICATION_VIOLATION`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) }, + userDisplayNameResult = { Result.success("A custom name") }, + userAvatarUrlResult = { Result.success("A custom avatar") }, + initialRoomInfo = aRoomInfo(isEncrypted = true), + ) + ) + val encryptionService = FakeEncryptionService( + getUserIdentityResult = { Result.success(IdentityState.VerificationViolation) }, + ) + val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService) + presenter.test { + // Initial state, then the verification state is updated + assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN) + consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.VERIFICATION_VIOLATION } + } + } + + @Test + fun `present - user identity updates in real time if the room is encrypted`() = runTest { + val identityStateChanges = MutableStateFlow(emptyList()) + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) }, + userDisplayNameResult = { Result.success("A custom name") }, + userAvatarUrlResult = { Result.success("A custom avatar") }, + initialRoomInfo = aRoomInfo(isEncrypted = true), + ), + identityStateChangesFlow = identityStateChanges, + ) + val encryptionService = FakeEncryptionService( + getUserIdentityResult = { Result.success(null) }, + ) + val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService) + presenter.test { + // Initial state, then the verification state is updated + assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN) + + room.emitSyncUpdate() + + identityStateChanges.emit(listOf(IdentityStateChange(A_USER_ID, IdentityState.Pinned))) + consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.UNVERIFIED } + + identityStateChanges.emit(listOf(IdentityStateChange(A_USER_ID, IdentityState.Verified))) + consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.VERIFIED } + + identityStateChanges.emit(listOf(IdentityStateChange(A_USER_ID, IdentityState.VerificationViolation))) + consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.VERIFICATION_VIOLATION } + } + } + + @Test + fun `present - user identity can't update in real time if the room is not encrypted`() = runTest { + val identityStateChanges = MutableStateFlow(emptyList()) + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) }, + userDisplayNameResult = { Result.success("A custom name") }, + userAvatarUrlResult = { Result.success("A custom avatar") }, + initialRoomInfo = aRoomInfo(isEncrypted = false), + ), + identityStateChangesFlow = identityStateChanges, + ) + val encryptionService = FakeEncryptionService( + getUserIdentityResult = { Result.success(null) }, + ) + val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService) + presenter.test { + // Initial state, then the verification state is updated + assertThat(awaitItem().verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN) + + room.emitSyncUpdate() + identityStateChanges.emit(listOf(IdentityStateChange(A_USER_ID, IdentityState.Pinned))) + + // No new events emitted + ensureAllEventsConsumed() + } + } + + @Test + fun `present - handles WithdrawVerification action`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + getUpdatedMemberResult = { Result.success(aRoomMember(A_USER_ID)) }, + userDisplayNameResult = { Result.success("A custom name") }, + userAvatarUrlResult = { Result.success("A custom avatar") }, + initialRoomInfo = aRoomInfo(isEncrypted = true), + ) + ) + val withdrawVerificationResult = lambdaRecorder> { Result.success(Unit) } + val encryptionService = FakeEncryptionService( + getUserIdentityResult = { Result.success(IdentityState.VerificationViolation) }, + withdrawVerificationResult = withdrawVerificationResult, + ) + val presenter = createRoomMemberDetailsPresenter(room = room, encryptionService = encryptionService) + presenter.test { + // Initial state, then the verification state is updated + val initialState = awaitItem() + assertThat(initialState.verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN) + + consumeItemsUntilPredicate { it.verificationState == UserProfileVerificationState.VERIFICATION_VIOLATION } + + initialState.eventSink(UserProfileEvents.WithdrawVerification) + withdrawVerificationResult.assertions().isCalledOnce() + } + } + + private fun createRoomMemberDetailsPresenter( + room: JoinedRoom, + userProfilePresenterFactory: UserProfilePresenterFactory = UserProfilePresenterFactory { + Presenter { + aUserProfileState( + userName = "Profile user name", + avatarUrl = "Profile avatar url", + ) + } + }, + encryptionService: FakeEncryptionService = FakeEncryptionService(getUserIdentityResult = { Result.success(null) }), + clipboardHelper: ClipboardHelper = FakeClipboardHelper(), + ): RoomMemberDetailsPresenter { + return RoomMemberDetailsPresenter( + roomMemberId = UserId("@alice:server.org"), + room = room, + userProfilePresenterFactory = userProfilePresenterFactory, + encryptionService = encryptionService, + clipboardHelper = clipboardHelper, + ) + } +} diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenterTest.kt new file mode 100644 index 0000000..c14dca9 --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsPresenterTest.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetails.impl.notificationsettings + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.roomdetails.impl.aJoinedRoom +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +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.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.tests.testutils.awaitLastSequentialItem +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RoomNotificationSettingsPresenterTest { + @Test + fun `present - initial state is created from room info`() = runTest { + val presenter = createRoomNotificationSettingsPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.roomNotificationSettings.dataOrNull()).isNull() + assertThat(initialState.defaultRoomNotificationMode).isNull() + val loadedState = awaitItem() + assertThat(loadedState.displayMentionsOnlyDisclaimer).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - notification mode changed`() = runTest { + val presenter = createRoomNotificationSettingsPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(RoomNotificationSettingsEvents.ChangeRoomNotificationMode(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)) + val updatedState = consumeItemsUntilPredicate { + it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + }.last() + assertThat(updatedState.roomNotificationSettings.dataOrNull()?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - observe notification mode changed`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService() + val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + notificationSettingsService.setRoomNotificationMode(A_ROOM_ID, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + val updatedState = consumeItemsUntilPredicate { + it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + }.last() + assertThat(updatedState.roomNotificationSettings.dataOrNull()?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + } + } + + @Test + fun `present - notification settings set custom failed`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService() + notificationSettingsService.givenSetNotificationModeError(AN_EXCEPTION) + val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(false)) + val failedState = consumeItemsUntilPredicate { + it.setNotificationSettingAction.isFailure() + }.last() + + assertThat(failedState.roomNotificationSettings.dataOrNull()?.isDefault).isTrue() + assertThat(failedState.pendingSetDefault).isNull() + assertThat(failedState.setNotificationSettingAction.isFailure()).isTrue() + + failedState.eventSink(RoomNotificationSettingsEvents.ClearSetNotificationError) + + val errorClearedState = consumeItemsUntilPredicate { + it.setNotificationSettingAction.isUninitialized() + }.last() + assertThat(errorClearedState.setNotificationSettingAction.isUninitialized()).isTrue() + } + } + + @Test + fun `present - notification settings set custom`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService() + val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(false)) + skipItems(3) + val defaultState = awaitItem() + assertThat(defaultState.roomNotificationSettings.dataOrNull()?.isDefault).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - notification settings restore default`() = runTest { + val presenter = createRoomNotificationSettingsPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RoomNotificationSettingsEvents.ChangeRoomNotificationMode(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)) + initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true)) + val defaultState = consumeItemsUntilPredicate { + it.roomNotificationSettings.dataOrNull()?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + }.last() + assertThat(defaultState.roomNotificationSettings.dataOrNull()?.mode).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - notification settings restore default failed`() = runTest { + val notificationSettingsService = FakeNotificationSettingsService() + notificationSettingsService.givenRestoreDefaultNotificationModeError(AN_EXCEPTION) + val presenter = createRoomNotificationSettingsPresenter(notificationSettingsService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RoomNotificationSettingsEvents.ChangeRoomNotificationMode(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)) + initialState.eventSink(RoomNotificationSettingsEvents.SetNotificationMode(true)) + val failedState = consumeItemsUntilPredicate { + it.restoreDefaultAction.isFailure() + }.last() + assertThat(failedState.restoreDefaultAction.isFailure()).isTrue() + failedState.eventSink(RoomNotificationSettingsEvents.ClearRestoreDefaultError) + + val errorClearedState = consumeItemsUntilPredicate { + it.restoreDefaultAction.isUninitialized() + }.last() + assertThat(errorClearedState.restoreDefaultAction.isUninitialized()).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - display mentions only warning for a room if homeserver does not support it and it's encrypted`() = runTest { + val notificationService = FakeNotificationSettingsService().apply { + givenCanHomeServerPushEncryptedEventsToDeviceResult(Result.success(false)) + } + val room = aJoinedRoom(notificationSettingsService = notificationService, isEncrypted = true) + val presenter = createRoomNotificationSettingsPresenter(notificationService, room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + assertThat(awaitLastSequentialItem().displayMentionsOnlyDisclaimer).isTrue() + } + } + + @Test + fun `present - do not display mentions only warning for a room it's not encrypted`() = runTest { + val notificationService = FakeNotificationSettingsService().apply { + givenCanHomeServerPushEncryptedEventsToDeviceResult(Result.success(false)) + } + val room = aJoinedRoom(notificationSettingsService = notificationService, isEncrypted = false) + val presenter = createRoomNotificationSettingsPresenter(notificationService, room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + assertThat(awaitLastSequentialItem().displayMentionsOnlyDisclaimer).isFalse() + } + } + + private fun createRoomNotificationSettingsPresenter( + notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), + room: FakeJoinedRoom = aJoinedRoom(notificationSettingsService = notificationSettingsService), + ): RoomNotificationSettingsPresenter { + return RoomNotificationSettingsPresenter( + room = room, + notificationSettingsService = notificationSettingsService, + showUserDefinedSettingStyle = false, + ) + } +} diff --git a/features/roomdirectory/api/build.gradle.kts b/features/roomdirectory/api/build.gradle.kts new file mode 100644 index 0000000..0f1be0e --- /dev/null +++ b/features/roomdirectory/api/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.roomdirectory.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.designsystem) +} diff --git a/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDescription.kt b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDescription.kt new file mode 100644 index 0000000..86767fd --- /dev/null +++ b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDescription.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdirectory.api + +import android.os.Parcelable +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.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize + +@Parcelize +data class RoomDescription( + val roomId: RoomId, + val name: String?, + val alias: RoomAlias?, + val topic: String?, + val avatarUrl: String?, + val joinRule: JoinRule, + val numberOfMembers: Long, +) : Parcelable { + enum class JoinRule { + PUBLIC, + KNOCK, + RESTRICTED, + KNOCK_RESTRICTED, + INVITE, + UNKNOWN + } + + @IgnoredOnParcel + val computedName = name ?: alias?.value ?: roomId.value + + @IgnoredOnParcel + val computedDescription: String + get() { + return when { + topic != null -> topic + name != null && alias != null -> alias.value + name == null && alias == null -> "" + else -> roomId.value + } + } + + @IgnoredOnParcel + val canJoinOrKnock = joinRule == JoinRule.PUBLIC || joinRule == JoinRule.KNOCK + + fun avatarData(size: AvatarSize) = AvatarData( + id = roomId.value, + name = name, + url = avatarUrl, + size = size, + ) +} diff --git a/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt new file mode 100644 index 0000000..db57b2e --- /dev/null +++ b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdirectory.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 RoomDirectoryEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: Callback, + ): Node + + interface Callback : Plugin { + fun navigateToRoom(roomDescription: RoomDescription) + } +} diff --git a/features/roomdirectory/impl/build.gradle.kts b/features/roomdirectory/impl/build.gradle.kts new file mode 100644 index 0000000..1c63872 --- /dev/null +++ b/features/roomdirectory/impl/build.gradle.kts @@ -0,0 +1,42 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.roomdirectory.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + api(projects.features.roomdirectory.api) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.testtags) + implementation(projects.services.analytics.api) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) +} diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/DefaultRoomDirectoryEntryPoint.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/DefaultRoomDirectoryEntryPoint.kt new file mode 100644 index 0000000..f12c31c --- /dev/null +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/DefaultRoomDirectoryEntryPoint.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdirectory.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint +import io.element.android.features.roomdirectory.impl.root.RoomDirectoryNode +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultRoomDirectoryEntryPoint : RoomDirectoryEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: RoomDirectoryEntryPoint.Callback, + ): Node { + return parentNode.createNode(buildContext, listOf(callback)) + } +} diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryEvents.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryEvents.kt new file mode 100644 index 0000000..20e866f --- /dev/null +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdirectory.impl.root + +sealed interface RoomDirectoryEvents { + data class Search(val query: String) : RoomDirectoryEvents + data object LoadMore : RoomDirectoryEvents +} diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt new file mode 100644 index 0000000..0e0ef50 --- /dev/null +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdirectory.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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class RoomDirectoryNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: RoomDirectoryPresenter, +) : Node(buildContext, plugins = plugins) { + private val callback: RoomDirectoryEntryPoint.Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + RoomDirectoryView( + state = state, + onResultClick = callback::navigateToRoom, + onBackClick = ::navigateUp, + modifier = modifier + ) + } +} diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt new file mode 100644 index 0000000..9833844 --- /dev/null +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdirectory.impl.root + +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.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.features.roomdirectory.impl.root.model.RoomDirectoryListState +import io.element.android.features.roomdirectory.impl.root.model.toFeatureModel +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList +import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +private const val SEARCH_BATCH_SIZE = 20 + +@Inject +class RoomDirectoryPresenter( + private val dispatchers: CoroutineDispatchers, + private val roomDirectoryService: RoomDirectoryService, +) : Presenter { + @Composable + override fun present(): RoomDirectoryState { + var loadingMore by remember { + mutableStateOf(false) + } + var searchQuery by rememberSaveable { + mutableStateOf(null) + } + val coroutineScope = rememberCoroutineScope() + val roomDirectoryList = remember { + roomDirectoryService.createRoomDirectoryList(coroutineScope) + } + val listState by roomDirectoryList.collectState() + LaunchedEffect(searchQuery) { + if (searchQuery == null) return@LaunchedEffect + // cancel load more right away + loadingMore = false + // debounce search query + delay(300) + roomDirectoryList.filter(filter = searchQuery, batchSize = SEARCH_BATCH_SIZE, viaServerName = null) + } + LaunchedEffect(loadingMore) { + if (loadingMore) { + roomDirectoryList.loadMore() + loadingMore = false + } + } + fun handleEvent(event: RoomDirectoryEvents) { + when (event) { + RoomDirectoryEvents.LoadMore -> { + loadingMore = true + } + is RoomDirectoryEvents.Search -> { + searchQuery = event.query + } + } + } + + return RoomDirectoryState( + query = searchQuery.orEmpty(), + roomDescriptions = listState.items, + displayLoadMoreIndicator = listState.hasMoreToLoad, + eventSink = ::handleEvent, + ) + } + + @Composable + private fun RoomDirectoryList.collectState() = remember { + state.map { + val items = it.items + .map { roomDescription -> roomDescription.toFeatureModel() } + .toImmutableList() + RoomDirectoryListState(items = items, hasMoreToLoad = it.hasMoreToLoad) + }.flowOn(dispatchers.computation) + }.collectAsState(RoomDirectoryListState.Default) +} diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryState.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryState.kt new file mode 100644 index 0000000..a364738 --- /dev/null +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryState.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdirectory.impl.root + +import io.element.android.features.roomdirectory.api.RoomDescription +import kotlinx.collections.immutable.ImmutableList + +data class RoomDirectoryState( + val query: String, + val roomDescriptions: ImmutableList, + val displayLoadMoreIndicator: Boolean, + val eventSink: (RoomDirectoryEvents) -> Unit +) { + val displayEmptyState = roomDescriptions.isEmpty() && !displayLoadMoreIndicator +} diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt new file mode 100644 index 0000000..4ee90fd --- /dev/null +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdirectory.impl.root + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.roomdirectory.api.RoomDescription +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +open class RoomDirectoryStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aRoomDirectoryState(), + aRoomDirectoryState( + query = "Element", + roomDescriptions = aRoomDescriptionList(), + ), + aRoomDirectoryState( + query = "Element", + roomDescriptions = aRoomDescriptionList(), + displayLoadMoreIndicator = true, + ), + ) +} + +fun aRoomDirectoryState( + query: String = "", + displayLoadMoreIndicator: Boolean = false, + roomDescriptions: ImmutableList = persistentListOf(), + eventSink: (RoomDirectoryEvents) -> Unit = {}, +) = RoomDirectoryState( + query = query, + roomDescriptions = roomDescriptions, + displayLoadMoreIndicator = displayLoadMoreIndicator, + eventSink = eventSink, +) + +fun aRoomDescriptionList(): ImmutableList { + return persistentListOf( + RoomDescription( + roomId = RoomId("!exa:matrix.org"), + name = "Element X Android", + topic = "Element X is a secure, private and decentralized messenger.", + alias = RoomAlias("#element-x-android:matrix.org"), + avatarUrl = null, + joinRule = RoomDescription.JoinRule.PUBLIC, + numberOfMembers = 2765, + ), + RoomDescription( + roomId = RoomId("!exi:matrix.org"), + name = "Element X iOS", + topic = "Element X is a secure, private and decentralized messenger.", + alias = RoomAlias("#element-x-ios:matrix.org"), + avatarUrl = null, + joinRule = RoomDescription.JoinRule.UNKNOWN, + numberOfMembers = 356, + ) + ) +} diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt new file mode 100644 index 0000000..f2e4ae4 --- /dev/null +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdirectory.impl.root + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +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.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +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.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.roomdirectory.api.RoomDescription +import io.element.android.features.roomdirectory.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.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.FilledTextField +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.testtags.TestTags +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun RoomDirectoryView( + state: RoomDirectoryState, + onResultClick: (RoomDescription) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + RoomDirectoryTopBar(onBackClick = onBackClick) + }, + content = { padding -> + RoomDirectoryContent( + state = state, + onResultClick = onResultClick, + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + ) + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RoomDirectoryTopBar( + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + modifier = modifier, + navigationIcon = { + BackButton(onClick = onBackClick) + }, + titleStr = stringResource(id = R.string.screen_room_directory_search_title), + ) +} + +@Composable +private fun RoomDirectoryContent( + state: RoomDirectoryState, + onResultClick: (RoomDescription) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + SearchTextField( + query = state.query, + onQueryChange = { state.eventSink(RoomDirectoryEvents.Search(it)) }, + placeholder = stringResource(id = CommonStrings.action_search), + modifier = Modifier.fillMaxWidth(), + ) + RoomDirectoryRoomList( + roomDescriptions = state.roomDescriptions, + displayLoadMoreIndicator = state.displayLoadMoreIndicator, + displayEmptyState = state.displayEmptyState, + onResultClick = onResultClick, + onReachedLoadMore = { state.eventSink(RoomDirectoryEvents.LoadMore) }, + ) + } +} + +@Composable +private fun RoomDirectoryRoomList( + roomDescriptions: ImmutableList, + displayLoadMoreIndicator: Boolean, + displayEmptyState: Boolean, + onResultClick: (RoomDescription) -> Unit, + onReachedLoadMore: () -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier = modifier) { + items(roomDescriptions) { roomDescription -> + RoomDirectoryRoomRow( + roomDescription = roomDescription, + onClick = { + onResultClick(roomDescription) + }, + ) + } + if (displayEmptyState) { + item { + Text( + text = stringResource(id = CommonStrings.common_no_results), + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textSecondary, + modifier = Modifier.padding(16.dp) + ) + } + } + if (displayLoadMoreIndicator) { + item { + LoadMoreIndicator(modifier = Modifier.fillMaxWidth()) + LaunchedEffect(onReachedLoadMore) { + onReachedLoadMore() + } + } + } + } +} + +@Composable +private fun LoadMoreIndicator(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + ) + } +} + +@Composable +private fun SearchTextField( + query: String, + onQueryChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + colors: TextFieldColors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + unfocusedPlaceholderColor = ElementTheme.colors.textSecondary, + focusedPlaceholderColor = ElementTheme.colors.textSecondary, + focusedTextColor = ElementTheme.colors.textPrimary, + unfocusedTextColor = ElementTheme.colors.textPrimary, + focusedIndicatorColor = ElementTheme.colors.borderInteractiveSecondary, + unfocusedIndicatorColor = ElementTheme.colors.borderInteractiveSecondary, + ), +) { + val focusManager = LocalFocusManager.current + FilledTextField( + modifier = modifier.testTag(TestTags.searchTextField.value), + textStyle = ElementTheme.typography.fontBodyLgRegular, + singleLine = true, + value = query, + onValueChange = onQueryChange, + keyboardActions = KeyboardActions( + onSearch = { + focusManager.clearFocus() + } + ), + colors = colors, + placeholder = { Text(placeholder) }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton( + onClick = { + onQueryChange("") + } + ) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_clear), + ) + } + } else { + Icon( + imageVector = CompoundIcons.Search(), + contentDescription = stringResource(CommonStrings.action_search), + ) + } + }, + ) +} + +@Composable +private fun RoomDirectoryRoomRow( + roomDescription: RoomDescription, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding( + top = 12.dp, + bottom = 12.dp, + start = 16.dp, + ) + .height(IntrinsicSize.Min), + ) { + Avatar( + avatarData = roomDescription.avatarData(AvatarSize.RoomDirectoryItem), + avatarType = AvatarType.Room(), + modifier = Modifier.align(Alignment.CenterVertically), + ) + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp) + ) { + Text( + text = roomDescription.computedName, + maxLines = 1, + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textPrimary, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = roomDescription.computedDescription, + maxLines = 1, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun RoomDirectoryViewPreview(@PreviewParameter(RoomDirectoryStateProvider::class) state: RoomDirectoryState) = ElementPreview { + RoomDirectoryView( + state = state, + onResultClick = {}, + onBackClick = {}, + ) +} diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDescription.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDescription.kt new file mode 100644 index 0000000..e6027c5 --- /dev/null +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDescription.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdirectory.impl.root.model + +import io.element.android.features.roomdirectory.api.RoomDescription +import io.element.android.libraries.matrix.api.roomdirectory.RoomDescription as MatrixRoomDescription + +fun MatrixRoomDescription.toFeatureModel(): RoomDescription { + return RoomDescription( + roomId = roomId, + name = name, + alias = alias, + topic = topic, + avatarUrl = avatarUrl, + numberOfMembers = numberOfMembers, + joinRule = when (joinRule) { + MatrixRoomDescription.JoinRule.PUBLIC -> RoomDescription.JoinRule.PUBLIC + MatrixRoomDescription.JoinRule.KNOCK -> RoomDescription.JoinRule.KNOCK + MatrixRoomDescription.JoinRule.RESTRICTED -> RoomDescription.JoinRule.RESTRICTED + MatrixRoomDescription.JoinRule.KNOCK_RESTRICTED -> RoomDescription.JoinRule.KNOCK_RESTRICTED + MatrixRoomDescription.JoinRule.INVITE -> RoomDescription.JoinRule.INVITE + MatrixRoomDescription.JoinRule.UNKNOWN -> RoomDescription.JoinRule.UNKNOWN + } + ) +} diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDirectoryListState.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDirectoryListState.kt new file mode 100644 index 0000000..cd31d0f --- /dev/null +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/model/RoomDirectoryListState.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdirectory.impl.root.model + +import io.element.android.features.roomdirectory.api.RoomDescription +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal data class RoomDirectoryListState( + val hasMoreToLoad: Boolean, + val items: ImmutableList, +) { + companion object { + val Default = RoomDirectoryListState( + hasMoreToLoad = true, + items = persistentListOf() + ) + } +} diff --git a/features/roomdirectory/impl/src/main/res/values-be/translations.xml b/features/roomdirectory/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..16b52ab --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,5 @@ + + + "Памылка загрузкі" + "Каталог пакояў" + diff --git a/features/roomdirectory/impl/src/main/res/values-cs/translations.xml b/features/roomdirectory/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..49ae689 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,5 @@ + + + "Načítání se nezdařilo" + "Adresář místností" + diff --git a/features/roomdirectory/impl/src/main/res/values-cy/translations.xml b/features/roomdirectory/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..7e5572c --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,5 @@ + + + "Wedi methu llwytho" + "Cyfeiriadur ystafelloedd" + diff --git a/features/roomdirectory/impl/src/main/res/values-da/translations.xml b/features/roomdirectory/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..3471723 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,5 @@ + + + "Indlæsning mislykkedes" + "Register over rum" + diff --git a/features/roomdirectory/impl/src/main/res/values-de/translations.xml b/features/roomdirectory/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..d62411a --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,5 @@ + + + "Fehler beim Laden" + "Chat-Verzeichnis" + diff --git a/features/roomdirectory/impl/src/main/res/values-el/translations.xml b/features/roomdirectory/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..c2dad69 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,5 @@ + + + "Αποτυχία φόρτωσης" + "Κατάλογος αιθουσών" + diff --git a/features/roomdirectory/impl/src/main/res/values-es/translations.xml b/features/roomdirectory/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..341799c --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,5 @@ + + + "Carga fallida" + "Directorio de salas" + diff --git a/features/roomdirectory/impl/src/main/res/values-et/translations.xml b/features/roomdirectory/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..9c6c660 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,5 @@ + + + "Andmeid ei õnnestunud laadida" + "Jututubade kataloog" + diff --git a/features/roomdirectory/impl/src/main/res/values-eu/translations.xml b/features/roomdirectory/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..63c2cda --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,5 @@ + + + "Ezin izan da kargatu" + "Gelen direktorioa" + diff --git a/features/roomdirectory/impl/src/main/res/values-fa/translations.xml b/features/roomdirectory/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..10a66d4 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,5 @@ + + + "شکست در بار کردن" + "فهرست اتاق‌ها" + diff --git a/features/roomdirectory/impl/src/main/res/values-fi/translations.xml b/features/roomdirectory/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..e5f2d91 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,5 @@ + + + "Lataus epäonnistui" + "Huoneluettelo" + diff --git a/features/roomdirectory/impl/src/main/res/values-fr/translations.xml b/features/roomdirectory/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..25c8064 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,5 @@ + + + "Échec du chargement" + "Annuaire des salons" + diff --git a/features/roomdirectory/impl/src/main/res/values-hu/translations.xml b/features/roomdirectory/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..fcaa3eb --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,5 @@ + + + "Sikertelen betöltés" + "Szobakatalógus" + diff --git a/features/roomdirectory/impl/src/main/res/values-in/translations.xml b/features/roomdirectory/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..3cf6342 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,5 @@ + + + "Gagal memuat" + "Direktori ruangan" + diff --git a/features/roomdirectory/impl/src/main/res/values-it/translations.xml b/features/roomdirectory/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..04a9602 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,5 @@ + + + "Caricamento fallito" + "Elenco delle stanze" + diff --git a/features/roomdirectory/impl/src/main/res/values-ka/translations.xml b/features/roomdirectory/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..86290f0 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,5 @@ + + + "ჩატვირთვა წარუმატებელია" + "ოთახის კატალოგი" + diff --git a/features/roomdirectory/impl/src/main/res/values-ko/translations.xml b/features/roomdirectory/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..d168863 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,5 @@ + + + "로드에 실패했습니다" + "방 디렉토리" + diff --git a/features/roomdirectory/impl/src/main/res/values-nb/translations.xml b/features/roomdirectory/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..14efc48 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,5 @@ + + + "Kunne ikke laste inn" + "Romkatalog" + diff --git a/features/roomdirectory/impl/src/main/res/values-nl/translations.xml b/features/roomdirectory/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..a2c6da3 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,5 @@ + + + "Laden mislukt" + "Kamergids" + diff --git a/features/roomdirectory/impl/src/main/res/values-pl/translations.xml b/features/roomdirectory/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..80bbffe --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,5 @@ + + + "Błąd wczytywania" + "Katalog pokoi" + diff --git a/features/roomdirectory/impl/src/main/res/values-pt-rBR/translations.xml b/features/roomdirectory/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..ab2e51a --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,5 @@ + + + "Falha no carregamento" + "Diretório de salas" + diff --git a/features/roomdirectory/impl/src/main/res/values-pt/translations.xml b/features/roomdirectory/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..a6a24f3 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,5 @@ + + + "Falha ao carregar" + "Diretório de salas" + diff --git a/features/roomdirectory/impl/src/main/res/values-ro/translations.xml b/features/roomdirectory/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..c996f87 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,5 @@ + + + "Încărcare eșuată" + "Director de camere" + diff --git a/features/roomdirectory/impl/src/main/res/values-ru/translations.xml b/features/roomdirectory/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..92e16fa --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,5 @@ + + + "Сбой загрузки" + "Каталог комнат" + diff --git a/features/roomdirectory/impl/src/main/res/values-sk/translations.xml b/features/roomdirectory/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..9d4e0bf --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,5 @@ + + + "Načítanie zlyhalo" + "Adresár miestností" + diff --git a/features/roomdirectory/impl/src/main/res/values-sv/translations.xml b/features/roomdirectory/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..94e4acb --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,5 @@ + + + "Misslyckades att ladda" + "Rumskatalog" + diff --git a/features/roomdirectory/impl/src/main/res/values-tr/translations.xml b/features/roomdirectory/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..22a7693 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,5 @@ + + + "Yükleme başarısız" + "Oda dizini" + diff --git a/features/roomdirectory/impl/src/main/res/values-uk/translations.xml b/features/roomdirectory/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..4201f04 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,5 @@ + + + "Не вдалося завантажити" + "Каталог кімнат" + diff --git a/features/roomdirectory/impl/src/main/res/values-ur/translations.xml b/features/roomdirectory/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..a6b7a12 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,5 @@ + + + "لادنا ناکام" + "کمرے کا راہنامچہ" + diff --git a/features/roomdirectory/impl/src/main/res/values-uz/translations.xml b/features/roomdirectory/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..9f4cbe6 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,5 @@ + + + "Yuklab bo‘lmadi" + "Xona katalogi" + diff --git a/features/roomdirectory/impl/src/main/res/values-zh-rTW/translations.xml b/features/roomdirectory/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..3cd1e90 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,5 @@ + + + "無法載入" + "聊天室目錄" + diff --git a/features/roomdirectory/impl/src/main/res/values-zh/translations.xml b/features/roomdirectory/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..742a762 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,5 @@ + + + "加载失败" + "聊天室目录" + diff --git a/features/roomdirectory/impl/src/main/res/values/localazy.xml b/features/roomdirectory/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..d3fb9d1 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values/localazy.xml @@ -0,0 +1,5 @@ + + + "Failed loading" + "Room directory" + diff --git a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/DefaultRoomDirectoryEntryPointTest.kt b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/DefaultRoomDirectoryEntryPointTest.kt new file mode 100644 index 0000000..2108c9a --- /dev/null +++ b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/DefaultRoomDirectoryEntryPointTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdirectory.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.features.roomdirectory.api.RoomDescription +import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint +import io.element.android.features.roomdirectory.impl.root.RoomDirectoryNode +import io.element.android.features.roomdirectory.impl.root.createRoomDirectoryPresenter +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DefaultRoomDirectoryEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `test node builder`() = runTest { + val entryPoint = DefaultRoomDirectoryEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + RoomDirectoryNode( + buildContext = buildContext, + plugins = plugins, + presenter = createRoomDirectoryPresenter(), + ) + } + val callback = object : RoomDirectoryEntryPoint.Callback { + override fun navigateToRoom(roomDescription: RoomDescription) = lambdaError() + } + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + callback = callback, + ) + assertThat(result).isInstanceOf(RoomDirectoryNode::class.java) + assertThat(result.plugins).contains(callback) + } +} diff --git a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt new file mode 100644 index 0000000..6295e4b --- /dev/null +++ b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdirectory.impl.root + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList +import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService +import io.element.android.libraries.matrix.test.roomdirectory.FakeRoomDirectoryList +import io.element.android.libraries.matrix.test.roomdirectory.FakeRoomDirectoryService +import io.element.android.libraries.matrix.test.roomdirectory.aRoomDescription +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RoomDirectoryPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createRoomDirectoryPresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.query).isEmpty() + assertThat(initialState.displayEmptyState).isFalse() + assertThat(initialState.roomDescriptions).isEmpty() + assertThat(initialState.displayLoadMoreIndicator).isTrue() + } + } + + @Test + fun `present - room directory list emits empty state`() = runTest { + val directoryListStateFlow = MutableSharedFlow(replay = 1) + val roomDirectoryList = FakeRoomDirectoryList(directoryListStateFlow) + val roomDirectoryService = FakeRoomDirectoryService { roomDirectoryList } + val presenter = createRoomDirectoryPresenter(roomDirectoryService = roomDirectoryService) + presenter.test { + skipItems(1) + directoryListStateFlow.emit( + RoomDirectoryList.SearchResult(false, emptyList()) + ) + awaitItem().also { state -> + assertThat(state.displayEmptyState).isTrue() + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - room directory list emits non-empty state`() = runTest { + val directoryListStateFlow = MutableSharedFlow(replay = 1) + val roomDirectoryList = FakeRoomDirectoryList(directoryListStateFlow) + val roomDirectoryService = FakeRoomDirectoryService { roomDirectoryList } + val presenter = createRoomDirectoryPresenter(roomDirectoryService = roomDirectoryService) + presenter.test { + skipItems(1) + directoryListStateFlow.emit( + RoomDirectoryList.SearchResult( + hasMoreToLoad = true, + items = listOf(aRoomDescription()) + ) + ) + awaitItem().also { state -> + assertThat(state.displayEmptyState).isFalse() + assertThat(state.roomDescriptions).hasSize(1) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - emit search event`() = runTest { + val filterLambda = lambdaRecorder { _: String?, _: Int, _: String? -> + Result.success(Unit) + } + val roomDirectoryList = FakeRoomDirectoryList(filterLambda = filterLambda) + val roomDirectoryService = FakeRoomDirectoryService { roomDirectoryList } + val presenter = createRoomDirectoryPresenter(roomDirectoryService = roomDirectoryService) + presenter.test { + awaitItem().also { state -> + state.eventSink(RoomDirectoryEvents.Search("test")) + } + awaitItem().also { state -> + assertThat(state.query).isEqualTo("test") + } + advanceUntilIdle() + cancelAndIgnoreRemainingEvents() + } + assert(filterLambda) + .isCalledOnce() + .with(value("test"), any(), value(null)) + } + + @Test + fun `present - emit load more event`() = runTest { + val loadMoreLambda = lambdaRecorder> { Result.success(Unit) } + val roomDirectoryList = FakeRoomDirectoryList(loadMoreLambda = loadMoreLambda) + val roomDirectoryService = FakeRoomDirectoryService { roomDirectoryList } + val presenter = createRoomDirectoryPresenter(roomDirectoryService = roomDirectoryService) + presenter.test { + awaitItem().also { state -> + state.eventSink(RoomDirectoryEvents.LoadMore) + } + advanceUntilIdle() + cancelAndIgnoreRemainingEvents() + } + assert(loadMoreLambda) + .isCalledOnce() + .withNoParameter() + } +} + +internal fun TestScope.createRoomDirectoryPresenter( + roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService( + createRoomDirectoryListFactory = { FakeRoomDirectoryList() } + ), +): RoomDirectoryPresenter { + return RoomDirectoryPresenter( + dispatchers = testCoroutineDispatchers(), + roomDirectoryService = roomDirectoryService, + ) +} diff --git a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt new file mode 100644 index 0000000..a50ad6a --- /dev/null +++ b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdirectory.impl.root + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.roomdirectory.api.RoomDescription +import io.element.android.libraries.testtags.TestTags +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RoomDirectoryViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `typing text in search field emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomDirectoryView( + state = aRoomDirectoryState( + eventSink = eventsRecorder, + ) + ) + rule.onNodeWithTag(TestTags.searchTextField.value).performTextInput( + text = "Test" + ) + eventsRecorder.assertSingle(RoomDirectoryEvents.Search("Test")) + } + + @Test + fun `clicking on room item then onResultClick lambda is called once`() { + val eventsRecorder = EventsRecorder() + val state = aRoomDirectoryState( + roomDescriptions = aRoomDescriptionList(), + eventSink = eventsRecorder, + ) + val clickedRoom = state.roomDescriptions.first() + ensureCalledOnceWithParam(clickedRoom) { callback -> + rule.setRoomDirectoryView( + state = state, + onResultClick = callback, + ) + rule.onNodeWithText(clickedRoom.computedName).performClick() + } + } + + @Test + fun `composing load more indicator emits expected Event`() { + val eventsRecorder = EventsRecorder() + val state = aRoomDirectoryState( + displayLoadMoreIndicator = true, + eventSink = eventsRecorder, + ) + rule.setRoomDirectoryView(state = state) + eventsRecorder.assertSingle(RoomDirectoryEvents.LoadMore) + } +} + +private fun AndroidComposeTestRule.setRoomDirectoryView( + state: RoomDirectoryState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onResultClick: (RoomDescription) -> Unit = EnsureNeverCalledWithParam(), +) { + setContent { + RoomDirectoryView( + state = state, + onResultClick = onResultClick, + onBackClick = onBackClick, + ) + } +} diff --git a/features/roommembermoderation/api/build.gradle.kts b/features/roommembermoderation/api/build.gradle.kts new file mode 100644 index 0000000..e168d2c --- /dev/null +++ b/features/roommembermoderation/api/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.roommembermoderation.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.matrix.api) +} diff --git a/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationEvents.kt b/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationEvents.kt new file mode 100644 index 0000000..2238ff4 --- /dev/null +++ b/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationEvents.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roommembermoderation.api + +import io.element.android.libraries.matrix.api.user.MatrixUser + +interface RoomMemberModerationEvents { + data class ShowActionsForUser(val user: MatrixUser) : RoomMemberModerationEvents + data class ProcessAction(val action: ModerationAction, val targetUser: MatrixUser) : RoomMemberModerationEvents +} diff --git a/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationRenderer.kt b/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationRenderer.kt new file mode 100644 index 0000000..8fb1ae0 --- /dev/null +++ b/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationRenderer.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roommembermoderation.api + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.libraries.matrix.api.user.MatrixUser + +interface RoomMemberModerationRenderer { + @Composable + fun Render( + state: RoomMemberModerationState, + onSelectAction: (ModerationAction, MatrixUser) -> Unit, + modifier: Modifier, + ) +} diff --git a/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationState.kt b/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationState.kt new file mode 100644 index 0000000..85f3e8e --- /dev/null +++ b/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationState.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roommembermoderation.api + +import androidx.compose.runtime.Immutable + +@Immutable +interface RoomMemberModerationState { + val canKick: Boolean + val canBan: Boolean + val eventSink: (RoomMemberModerationEvents) -> Unit +} + +data class ModerationActionState( + val action: ModerationAction, + val isEnabled: Boolean, +) + +sealed interface ModerationAction { + data object DisplayProfile : ModerationAction + data object KickUser : ModerationAction + data object BanUser : ModerationAction + data object UnbanUser : ModerationAction +} diff --git a/features/roommembermoderation/impl/build.gradle.kts b/features/roommembermoderation/impl/build.gradle.kts new file mode 100644 index 0000000..3157552 --- /dev/null +++ b/features/roommembermoderation/impl/build.gradle.kts @@ -0,0 +1,41 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.roommembermoderation.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + api(projects.features.roommembermoderation.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(projects.services.analytics.compose) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.services.analytics.test) + testImplementation(projects.libraries.testtags) +} diff --git a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/DefaultRoomMemberModerationRenderer.kt b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/DefaultRoomMemberModerationRenderer.kt new file mode 100644 index 0000000..05bf00c --- /dev/null +++ b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/DefaultRoomMemberModerationRenderer.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roommembermoderation.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.Modifier +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.roommembermoderation.api.ModerationAction +import io.element.android.features.roommembermoderation.api.RoomMemberModerationRenderer +import io.element.android.features.roommembermoderation.api.RoomMemberModerationState +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.user.MatrixUser +import timber.log.Timber + +@ContributesBinding(RoomScope::class) +class DefaultRoomMemberModerationRenderer : RoomMemberModerationRenderer { + @Composable + override fun Render( + state: RoomMemberModerationState, + onSelectAction: (ModerationAction, MatrixUser) -> Unit, + modifier: Modifier + ) { + if (state is InternalRoomMemberModerationState) { + RoomMemberModerationView(state, onSelectAction, modifier) + } else { + SideEffect { + Timber.d("RoomMemberModerationRenderer: Render called with unsupported state: $state") + } + } + } +} diff --git a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationEvents.kt b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationEvents.kt new file mode 100644 index 0000000..2bc76db --- /dev/null +++ b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationEvents.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roommembermoderation.impl + +import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents + +sealed interface InternalRoomMemberModerationEvents : RoomMemberModerationEvents { + data class DoKickUser(val reason: String) : InternalRoomMemberModerationEvents + data class DoBanUser(val reason: String) : InternalRoomMemberModerationEvents + data class DoUnbanUser(val reason: String) : InternalRoomMemberModerationEvents + data object Reset : InternalRoomMemberModerationEvents +} diff --git a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationState.kt b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationState.kt new file mode 100644 index 0000000..fe48ece --- /dev/null +++ b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roommembermoderation.impl + +import io.element.android.features.roommembermoderation.api.ModerationActionState +import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents +import io.element.android.features.roommembermoderation.api.RoomMemberModerationState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList + +data class InternalRoomMemberModerationState( + override val canKick: Boolean, + override val canBan: Boolean, + val selectedUser: MatrixUser?, + val actions: ImmutableList, + val kickUserAsyncAction: AsyncAction, + val banUserAsyncAction: AsyncAction, + val unbanUserAsyncAction: AsyncAction, + override val eventSink: (RoomMemberModerationEvents) -> Unit, +) : RoomMemberModerationState { + val canDisplayActions = actions.isNotEmpty() +} diff --git a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationStateProvider.kt b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationStateProvider.kt new file mode 100644 index 0000000..d90f352 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationStateProvider.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roommembermoderation.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.roommembermoderation.api.ModerationAction +import io.element.android.features.roommembermoderation.api.ModerationActionState +import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.toImmutableList + +class InternalRoomMemberModerationStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aRoomMembersModerationState( + selectedUser = anAlice(), + actions = listOf( + ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true), + ), + ), + aRoomMembersModerationState( + selectedUser = anAlice(), + actions = listOf( + ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true), + ModerationActionState(action = ModerationAction.KickUser, isEnabled = true), + ), + ), + aRoomMembersModerationState( + selectedUser = anAlice(), + actions = listOf( + ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true), + ModerationActionState(action = ModerationAction.KickUser, isEnabled = false), + ModerationActionState(action = ModerationAction.BanUser, isEnabled = true), + ), + ), + aRoomMembersModerationState( + selectedUser = anAlice(), + actions = listOf( + ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true), + ModerationActionState(action = ModerationAction.KickUser, isEnabled = false), + ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = true), + ), + ), + aRoomMembersModerationState( + selectedUser = anAlice(), + kickUserAsyncAction = AsyncAction.ConfirmingNoParams, + ), + aRoomMembersModerationState( + selectedUser = anAlice(), + kickUserAsyncAction = AsyncAction.Loading, + ), + aRoomMembersModerationState( + selectedUser = anAlice(), + banUserAsyncAction = AsyncAction.ConfirmingNoParams, + ), + aRoomMembersModerationState( + selectedUser = anAlice(), + banUserAsyncAction = AsyncAction.Loading, + ), + aRoomMembersModerationState( + selectedUser = anAlice(), + unbanUserAsyncAction = AsyncAction.ConfirmingNoParams, + ), + aRoomMembersModerationState( + selectedUser = anAlice(), + unbanUserAsyncAction = AsyncAction.Loading, + ), + ) +} + +fun anAlice() = MatrixUser( + UserId(value = "@alice:server.org"), + displayName = "Alice", + avatarUrl = null, +) + +fun aRoomMembersModerationState( + canKick: Boolean = false, + canBan: Boolean = false, + selectedUser: MatrixUser? = null, + actions: List = emptyList(), + kickUserAsyncAction: AsyncAction = AsyncAction.Uninitialized, + banUserAsyncAction: AsyncAction = AsyncAction.Uninitialized, + unbanUserAsyncAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (RoomMemberModerationEvents) -> Unit = {}, +) = InternalRoomMemberModerationState( + canKick = canKick, + canBan = canBan, + selectedUser = selectedUser, + actions = actions.toImmutableList(), + kickUserAsyncAction = kickUserAsyncAction, + banUserAsyncAction = banUserAsyncAction, + unbanUserAsyncAction = unbanUserAsyncAction, + eventSink = eventSink, +) diff --git a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt new file mode 100644 index 0000000..31ff2da --- /dev/null +++ b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roommembermoderation.impl + +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.setValue +import dev.zacsweers.metro.Inject +import im.vector.app.features.analytics.plan.RoomModeration +import io.element.android.features.roommembermoderation.api.ModerationAction +import io.element.android.features.roommembermoderation.api.ModerationActionState +import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents +import io.element.android.features.roommembermoderation.api.RoomMemberModerationState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.JoinedRoom +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.roomMembers +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.room.canBanAsState +import io.element.android.libraries.matrix.ui.room.canKickAsState +import io.element.android.libraries.matrix.ui.room.userPowerLevelAsState +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds + +@Inject +class RoomMemberModerationPresenter( + private val room: JoinedRoom, + private val dispatchers: CoroutineDispatchers, + private val analyticsService: AnalyticsService, +) : Presenter { + @Composable + override fun present(): RoomMemberModerationState { + val coroutineScope = rememberCoroutineScope() + val syncUpdateFlow = room.syncUpdateFlow.collectAsState() + val canBan = room.canBanAsState(syncUpdateFlow.value) + val canKick = room.canKickAsState(syncUpdateFlow.value) + val currentUserMemberPowerLevel = room.userPowerLevelAsState(syncUpdateFlow.value) + + val kickUserAsyncAction = + remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction) } + val banUserAsyncAction = + remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction) } + val unbanUserAsyncAction = + remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction) } + var selectedUser by remember { + mutableStateOf(null) + } + val moderationActions = remember { mutableStateOf>(persistentListOf()) } + + fun handleEvent(event: RoomMemberModerationEvents) { + when (event) { + is RoomMemberModerationEvents.ShowActionsForUser -> { + selectedUser = event.user + val member = room.membersStateFlow.value.roomMembers()?.firstOrNull { + it.userId == event.user.userId + } + moderationActions.value = computeModerationActions( + member = member, + canKick = canKick.value, + canBan = canBan.value, + currentUserMemberPowerLevel = currentUserMemberPowerLevel.value, + ) + } + is RoomMemberModerationEvents.ProcessAction -> { + // First, hide any list of existing actions that could be displayed + moderationActions.value = persistentListOf() + + when (event.action) { + is ModerationAction.DisplayProfile -> Unit + is ModerationAction.KickUser -> { + selectedUser = event.targetUser + kickUserAsyncAction.value = AsyncAction.ConfirmingNoParams + } + is ModerationAction.BanUser -> { + selectedUser = event.targetUser + banUserAsyncAction.value = AsyncAction.ConfirmingNoParams + } + is ModerationAction.UnbanUser -> { + selectedUser = event.targetUser + unbanUserAsyncAction.value = AsyncAction.ConfirmingNoParams + } + } + } + is InternalRoomMemberModerationEvents.DoKickUser -> { + selectedUser?.let { + coroutineScope.kickUser(it.userId, event.reason, kickUserAsyncAction) + } + selectedUser = null + } + is InternalRoomMemberModerationEvents.DoBanUser -> { + selectedUser?.let { + coroutineScope.banUser(it.userId, event.reason, banUserAsyncAction) + } + selectedUser = null + } + is InternalRoomMemberModerationEvents.DoUnbanUser -> { + selectedUser?.let { + coroutineScope.unbanUser(it.userId, event.reason, unbanUserAsyncAction) + } + selectedUser = null + } + is InternalRoomMemberModerationEvents.Reset -> { + selectedUser = null + moderationActions.value = persistentListOf() + kickUserAsyncAction.value = AsyncAction.Uninitialized + banUserAsyncAction.value = AsyncAction.Uninitialized + unbanUserAsyncAction.value = AsyncAction.Uninitialized + } + } + } + + return InternalRoomMemberModerationState( + canKick = canKick.value, + canBan = canBan.value, + selectedUser = selectedUser, + actions = moderationActions.value, + kickUserAsyncAction = kickUserAsyncAction.value, + banUserAsyncAction = banUserAsyncAction.value, + unbanUserAsyncAction = unbanUserAsyncAction.value, + eventSink = ::handleEvent, + ) + } + + private fun computeModerationActions( + member: RoomMember?, + canKick: Boolean, + canBan: Boolean, + currentUserMemberPowerLevel: Long, + ): ImmutableList { + return buildList { + add(ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true)) + // Assume the member is a regular user when it's unknown + val targetMemberPowerLevel = member?.powerLevel ?: 0 + val canModerateThisUser = currentUserMemberPowerLevel > targetMemberPowerLevel + // Assume the member is joined when it's unknown + val membership = member?.membership ?: RoomMembershipState.JOIN + if (canKick) { + val isKickEnabled = canModerateThisUser && membership.isActive() + add(ModerationActionState(action = ModerationAction.KickUser, isEnabled = isKickEnabled)) + } + if (canBan) { + if (membership == RoomMembershipState.BAN) { + add(ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = canModerateThisUser)) + } else { + add(ModerationActionState(action = ModerationAction.BanUser, isEnabled = canModerateThisUser)) + } + } + }.toImmutableList() + } + + private fun CoroutineScope.kickUser( + userId: UserId, + reason: String, + kickUserAction: MutableState>, + ) = runActionAndWaitForMembershipChange(kickUserAction) { + analyticsService.capture(RoomModeration(RoomModeration.Action.KickMember)) + room.kickUser( + userId = userId, + reason = reason.takeIf { it.isNotBlank() }, + ) + } + + private fun CoroutineScope.banUser( + userId: UserId, + reason: String, + banUserAction: MutableState>, + ) = runActionAndWaitForMembershipChange(banUserAction) { + analyticsService.capture(RoomModeration(RoomModeration.Action.BanMember)) + room.banUser( + userId = userId, + reason = reason.takeIf { it.isNotBlank() }, + ) + } + + private fun CoroutineScope.unbanUser( + userId: UserId, + reason: String, + unbanUserAction: MutableState>, + ) = runActionAndWaitForMembershipChange(unbanUserAction) { + analyticsService.capture(RoomModeration(RoomModeration.Action.UnbanMember)) + room.unbanUser( + userId = userId, + reason = reason.takeIf { it.isNotBlank() }, + ) + } + + private fun CoroutineScope.runActionAndWaitForMembershipChange( + action: MutableState>, + block: suspend () -> Result + ) { + launch(dispatchers.io) { + action.runUpdatingState { + val result = block() + if (result.isSuccess) { + // We wait a bit to ensure the server has processed the membership change + delay(50.milliseconds) + + // Update the members to ensure we have the latest state + launch { room.updateMembers() } + + // Wait for the membership change to be processed and returned + // We drop the first emission as it's the current state + room.membersStateFlow.drop(1).first() + } + result + } + } + } +} diff --git a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationView.kt b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationView.kt new file mode 100644 index 0000000..ac6c5bc --- /dev/null +++ b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationView.kt @@ -0,0 +1,345 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roommembermoderation.impl + +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState +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.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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.roommembermoderation.api.ModerationAction +import io.element.android.features.roommembermoderation.api.ModerationActionState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.components.async.AsyncIndicator +import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost +import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.dialogs.TextFieldDialog +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +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.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 +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.launch +import timber.log.Timber + +@Composable +fun RoomMemberModerationView( + state: InternalRoomMemberModerationState, + onSelectAction: (ModerationAction, MatrixUser) -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + val selectedUser = state.selectedUser + if (selectedUser != null && state.canDisplayActions) { + RoomMemberActionsBottomSheet( + user = selectedUser, + actions = state.actions, + onSelectAction = onSelectAction, + onDismiss = { state.eventSink(InternalRoomMemberModerationEvents.Reset) }, + ) + } + RoomMemberAsyncActions(state = state) + } +} + +@Composable +private fun RoomMemberAsyncActions( + state: InternalRoomMemberModerationState, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + val selectedUser = state.selectedUser + val asyncIndicatorState = rememberAsyncIndicatorState() + AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), state = asyncIndicatorState) + + when (val action = state.kickUserAsyncAction) { + is AsyncAction.Confirming -> { + TextFieldDialog( + title = stringResource(R.string.screen_bottom_sheet_manage_room_member_kick_member_confirmation_title), + submitText = stringResource(R.string.screen_bottom_sheet_manage_room_member_kick_member_confirmation_action), + destructiveSubmit = true, + minLines = 2, + onSubmit = { reason -> + state.eventSink(InternalRoomMemberModerationEvents.DoKickUser(reason = reason)) + }, + onDismissRequest = { state.eventSink(InternalRoomMemberModerationEvents.Reset) }, + placeholder = stringResource(id = CommonStrings.common_reason), + content = stringResource(R.string.screen_bottom_sheet_manage_room_member_kick_member_confirmation_description), + value = "", + ) + } + is AsyncAction.Loading -> { + LaunchedEffect(action) { + val userDisplayName = selectedUser?.getBestName().orEmpty() + asyncIndicatorState.enqueue { + AsyncIndicator.Loading(text = stringResource(R.string.screen_bottom_sheet_manage_room_member_removing_user, userDisplayName)) + } + } + } + is AsyncAction.Failure -> { + Timber.e(action.error, "Failed to kick user.") + LaunchedEffect(action) { + asyncIndicatorState.enqueue(AsyncIndicator.DURATION_SHORT) { + AsyncIndicator.Failure( + text = stringResource(CommonStrings.common_failed), + ) + } + } + } + is AsyncAction.Success -> { + LaunchedEffect(action) { asyncIndicatorState.clear() } + } + else -> Unit + } + + when (val action = state.banUserAsyncAction) { + is AsyncAction.Confirming -> { + TextFieldDialog( + title = stringResource(R.string.screen_bottom_sheet_manage_room_member_ban_member_confirmation_title), + submitText = stringResource(R.string.screen_bottom_sheet_manage_room_member_ban_member_confirmation_action), + destructiveSubmit = true, + minLines = 2, + onSubmit = { reason -> + state.eventSink(InternalRoomMemberModerationEvents.DoBanUser(reason = reason)) + }, + onDismissRequest = { state.eventSink(InternalRoomMemberModerationEvents.Reset) }, + placeholder = stringResource(id = CommonStrings.common_reason), + content = stringResource(R.string.screen_bottom_sheet_manage_room_member_ban_member_confirmation_description), + value = "", + ) + } + is AsyncAction.Loading -> { + LaunchedEffect(action) { + val userDisplayName = selectedUser?.getBestName().orEmpty() + asyncIndicatorState.enqueue { + AsyncIndicator.Loading(text = stringResource(R.string.screen_bottom_sheet_manage_room_member_banning_user, userDisplayName)) + } + } + } + is AsyncAction.Failure -> { + Timber.e(action.error, "Failed to ban user.") + LaunchedEffect(action) { + asyncIndicatorState.enqueue(AsyncIndicator.DURATION_SHORT) { + AsyncIndicator.Failure( + text = stringResource(CommonStrings.common_failed), + ) + } + } + } + is AsyncAction.Success -> { + LaunchedEffect(action) { asyncIndicatorState.clear() } + } + else -> Unit + } + when (val action = state.unbanUserAsyncAction) { + is AsyncAction.Confirming -> { + TextFieldDialog( + title = stringResource(R.string.screen_bottom_sheet_manage_room_member_unban_member_confirmation_title), + submitText = stringResource(R.string.screen_bottom_sheet_manage_room_member_unban_member_confirmation_action), + destructiveSubmit = true, + minLines = 2, + onSubmit = { reason -> + val userDisplayName = selectedUser?.getBestName().orEmpty() + asyncIndicatorState.enqueue { + AsyncIndicator.Loading(text = stringResource(R.string.screen_bottom_sheet_manage_room_member_unbanning_user, userDisplayName)) + } + state.eventSink(InternalRoomMemberModerationEvents.DoUnbanUser(reason = reason)) + }, + onDismissRequest = { state.eventSink(InternalRoomMemberModerationEvents.Reset) }, + placeholder = stringResource(id = CommonStrings.common_reason), + content = stringResource(R.string.screen_bottom_sheet_manage_room_member_unban_member_confirmation_description), + value = "", + ) + } + is AsyncAction.Failure -> { + Timber.e(action.error, "Failed to unban user.") + LaunchedEffect(action) { + asyncIndicatorState.enqueue(AsyncIndicator.DURATION_SHORT) { + AsyncIndicator.Failure( + text = stringResource(CommonStrings.common_failed), + ) + } + } + } + is AsyncAction.Success -> { + LaunchedEffect(action) { asyncIndicatorState.clear() } + } + is AsyncAction.Loading, + AsyncAction.Uninitialized -> Unit + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RoomMemberActionsBottomSheet( + user: MatrixUser, + actions: ImmutableList, + onSelectAction: (ModerationAction, MatrixUser) -> Unit, + onDismiss: () -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + modifier = Modifier.systemBarsPadding(), + sheetState = bottomSheetState, + onDismissRequest = { + coroutineScope.launch { + bottomSheetState.hide() + onDismiss() + } + }, + ) { + Column( + modifier = Modifier.padding(vertical = 16.dp) + ) { + Avatar( + avatarData = user.getAvatarData(size = AvatarSize.RoomListManageUser), + avatarType = AvatarType.User, + modifier = Modifier + .padding(bottom = 24.dp) + .align(Alignment.CenterHorizontally) + ) + val bestName = user.getBestName() + Text( + text = bestName, + style = ElementTheme.typography.fontHeadingLgBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + .fillMaxWidth() + ) + // Show user ID only if it's different from the display name + if (bestName != user.userId.value) { + Text( + text = user.userId.value, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + ) + } + Spacer(modifier = Modifier.height(32.dp)) + + for (actionState in actions) { + when (val action = actionState.action) { + is ModerationAction.DisplayProfile -> { + ListItem( + style = ListItemStyle.Primary, + headlineContent = { Text(stringResource(R.string.screen_bottom_sheet_manage_room_member_member_user_info)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())), + onClick = { + coroutineScope.launch { + onSelectAction(action, user) + bottomSheetState.hide() + } + }, + enabled = actionState.isEnabled + ) + } + is ModerationAction.KickUser -> { + ListItem( + headlineContent = { Text(stringResource(R.string.screen_bottom_sheet_manage_room_member_remove)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Close())), + style = ListItemStyle.Destructive, + onClick = { + coroutineScope.launch { + bottomSheetState.hide() + onSelectAction(action, user) + } + }, + enabled = actionState.isEnabled + ) + } + is ModerationAction.BanUser -> { + ListItem( + headlineContent = { Text(stringResource(R.string.screen_bottom_sheet_manage_room_member_ban)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())), + style = ListItemStyle.Destructive, + onClick = { + coroutineScope.launch { + bottomSheetState.hide() + onSelectAction(action, user) + } + }, + enabled = actionState.isEnabled + ) + } + is ModerationAction.UnbanUser -> { + ListItem( + headlineContent = { Text(stringResource(R.string.screen_bottom_sheet_manage_room_member_unban)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Restart())), + style = ListItemStyle.Destructive, + onClick = { + coroutineScope.launch { + bottomSheetState.hide() + onSelectAction(action, user) + } + }, + enabled = actionState.isEnabled + ) + } + } + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun RoomMemberModerationViewPreview(@PreviewParameter(InternalRoomMemberModerationStateProvider::class) state: InternalRoomMemberModerationState) { + ElementPreview { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 64.dp) + ) { + RoomMemberModerationView( + state = state, + onSelectAction = { _, _ -> + }, + ) + } + } +} diff --git a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/di/RoomMemberModerationModule.kt b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/di/RoomMemberModerationModule.kt new file mode 100644 index 0000000..ab82f6c --- /dev/null +++ b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/di/RoomMemberModerationModule.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roommembermoderation.impl.di + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import io.element.android.features.roommembermoderation.api.RoomMemberModerationState +import io.element.android.features.roommembermoderation.impl.RoomMemberModerationPresenter +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.RoomScope + +@ContributesTo(RoomScope::class) +@BindingContainer +interface RoomMemberModerationModule { + @Binds + fun bindRoomMemberModerationPresenter(presenter: RoomMemberModerationPresenter): Presenter +} diff --git a/features/roommembermoderation/impl/src/main/res/values-be/translations.xml b/features/roommembermoderation/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..b4b1f35 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,15 @@ + + + "Выдаліць і заблакіраваць удзельніка" + "Заблакіраваць" + "Яны не змогуць зноў далучыцца да гэтага пакоя, калі іх запросяць." + "Вы ўпэўнены, што хочаце заблакіраваць гэтага карыстальніка?" + "Блакіроўка %1$s" + "Яны змогуць зноў далучыцца да гэтага пакоя, калі іх запросяць." + "Прагляд профілю" + "Выдаліць удзельніка з пакоя" + "Выдаліць удзельніка і забараніць далучацца ў будучыні?" + "Выдаленне %1$s…" + "Разблакіраваць" + "Разблакіроўка %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-bg/translations.xml b/features/roommembermoderation/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..53955d5 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,4 @@ + + + "Преглед на профила" + diff --git a/features/roommembermoderation/impl/src/main/res/values-cs/translations.xml b/features/roommembermoderation/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..a7d8966 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,22 @@ + + + "Odebrat a vykázat člena" + "Vykázat" + "Nebudou se moci znovu připojit k této místnosti, pokud budou pozváni." + "Jste si jisti, že chcete vykázat tohoto člena?" + "Pokud budou pozváni, nebudou moci vstoupit do tohoto prostoru, ale stále si zachovají členství ve všech místnostech nebo podprostorech." + "Vykazování %1$s" + "Odebrat" + "Pokud budou pozváni, budou se moci do této místnosti znovu připojit." + "Opravdu chcete tohoto člena odebrat?" + "Pokud budou pozváni, budou moci vstoupit do tohoto prostoru a zachovají si členství ve všech místnostech nebo podprostorech." + "Zobrazit profil" + "Odebrat uživatele" + "Odebrat člena a zakázat mu připojení v budoucnu?" + "Odstraňování %1$s…" + "Zrušit vykázání z místnosti" + "Zrušit vykázání" + "Pokud by byli pozváni, mohli by se znovu připojit do místnosti" + "Opravdu chcete zrušit vykázání tohoto člena?" + "Rušení vykázání %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-cy/translations.xml b/features/roommembermoderation/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..2d7667a --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,20 @@ + + + "Gwahardd o ystafell" + "Atal" + "Fyddan nhw ddim yn gallu ymuno â\'r ystafell hon eto os cân nhw wahoddiad." + "Ydych chi\'n siŵr eich bod am wahardd yr aelod hwn?" + "Yn gwahardd %1$s" + "Tynnu" + "Fyddan nhw yn gallu ymuno â\'r ystafell hon eto os cân nhw wahoddiad." + "Ydych chi\'n siŵr eich bod am ddileu\'r aelod hwn?" + "Gweld proffil" + "Tynnu o\'r ystafell" + "Dileu aelod a\'u gwahardd rhag ymuno yn y dyfodol?" + "Wrthi\'n dileu %1$s…" + "Dad-wahardd o\'r ystafell" + "Adfer" + "Bydden nhw\'n gallu ymuno â\'r ystafell eto os fydd rhywun yn eu gwahodd" + "Ydych chi\'n siŵr eich bod chi eisiau dadwahardd yr aelod hwn?" + "Dad-wahardd %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-da/translations.xml b/features/roommembermoderation/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..392c1e8 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,22 @@ + + + "Spær fra rum" + "Spær" + "De vil ikke være i stand til at deltage i dette rum igen, selv om de inviteres." + "Er du stikker på, at du ønsker at spærre dette medlem?" + "De vil ikke kunne deltage i gruppen igen, selv hvis de bliver inviteret. Men vil beholde deres medlemskaber i rum og undergrupper." + "Spærrer %1$s" + "Fjern" + "De vil være i stand til at deltage i dette rum igen, hvis de inviteres." + "Er du sikker på, at du vil fjerne dette medlem?" + "De vil kunne deltage i dette rum igen, hvis de bliver inviteret, og de vil stadig beholde deres medlemskaber af eventuelle rum eller sub-grupper." + "Se profil" + "Fjern bruger" + "Fjern medlem og udeluk dem fra at deltage i fremtiden?" + "Fjerner %1$s…" + "Fjern brugerens spærring fra rummet" + "Fjern spærring af" + "De ville være i stand til at deltage i rummet igen, hvis de blev inviteret" + "Er du sikker på, at du vil fjerne spærringen af dette medlem?" + "Ophæver spærring af %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-de/translations.xml b/features/roommembermoderation/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..6c6f9dc --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,20 @@ + + + "Mitglied entfernen und sperren" + "Sperren" + "Sie können diesem Chat auch auf Einladung nicht erneut beitreten." + "Möchtest du diesen Nutzer wirklich sperren?" + "%1$s wird gesperrt." + "Entfernen" + "Die Nutzer können dem Chat wieder beitreten, wenn sie eingeladen werden." + "Möchtest du dieses Mitglied wirklich entfernen?" + "Nutzerprofil anzeigen" + "Mitglied entfernen" + "Mitglied entfernen und für die Zukunft sperren?" + "%1$s wird entfernt." + "Sperre für diesen Chat aufheben" + "Sperre aufheben" + "Sie können dann diesem Chat auf Einladung wieder beitreten." + "Möchtest du die Sperre dieses Mitglieds wirklich aufheben?" + "%1$s wird entsperrt." + diff --git a/features/roommembermoderation/impl/src/main/res/values-el/translations.xml b/features/roommembermoderation/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..89bf97f --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,20 @@ + + + "Αφαίρεση και αποκλεισμός μέλους" + "Αποκλεισμός" + "Δεν θα μπορούν να ενταχθούν ξανά σε αυτή την αίθουσα, αν προσκληθούν." + "Θες σίγουρα να αποκλείσεις αυτό το μέλος;" + "Αποκλεισμός %1$s" + "Αφαίρεση" + "Θα μπορούν να συμμετάσχουν ξανά σε αυτή την αίθουσα, εάν προσκληθούν." + "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε αυτό το μέλος;" + "Προβολή προφίλ" + "Αφαίρεση από την αίθουσα" + "Αφαίρεση μέλους και απαγόρευση συμμετοχής στο μέλλον;" + "Αφαίρεση %1$s…" + "Άρση αποκλεισμού από την αίθουσα" + "Αναίρεση αποκλεισμού" + "Θα μπορούν να συμμετάσχουν και πάλι στην αίθουσα αν προσκληθούν" + "Σίγουρα θες να καταργήσεις τον αποκλεισμό αυτού του μέλους;" + "Άρση αποκλεισμού %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-es/translations.xml b/features/roommembermoderation/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..7f83f75 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,20 @@ + + + "Sacar y vetar a un miembro" + "Vetar" + "No podrán volver a unirse a esta sala si son invitados." + "¿Estás seguro de que quieres vetar a este miembro?" + "Vetando a %1$s" + "Echar" + "Podrá volver a unirse a esta sala si se le invita." + "¿Seguro que quieres echar a este miembro?" + "Ver perfil" + "Sacar de la sala" + "¿Sacar al miembro y prohibirle unirse en el futuro?" + "Eliminando %1$s…" + "Eliminar veto en la sala" + "Quitar veto" + "Podría volver a unirse a la sala si se le invita" + "¿Seguro que quieres levantarle el veto a este miembro?" + "Levantando veto a %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-et/translations.xml b/features/roommembermoderation/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..fd04616 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,22 @@ + + + "Eemalda ja sea suhtluskeeld" + "Sea suhtluskeeld" + "Ta ei saa selle jututoaga liituda isegi kutse olemasolul." + "Kas sa oled kindel, et soovid sellele kasutajale seada suhtluskeelu?" + "Ta ei saa ka kutsumise puhul selle kogukonnaga uuesti liituda, kuid liikmelisus jututubades või alamkogukondades säilib." + "Seame kasutajale %1$s suhtluskeelu" + "Eemalda" + "Kutse olemasolul saab ta nüüd jututoaga uuesti liituda" + "Kas sa oled kindel, et soovid selle osaleja eemaldada?" + "Ta saab kutsumise puhul selle kogukonnaga uuesti liituda ning liikmelisus jututubades või alamkogukondades säilib." + "Vaata profiili" + "Eemalda kasutaja jututoast" + "Kas eemaldama kasutaja ja seame talle tulevikuks suhtluskeelu?" + "Eemaldame kasutajat %1$s…" + "Eemalda suhtluskeeld jututoas" + "Eemalda suhtluskeeld" + "Ta võib kutse saamisel liituda jututoaga uuesti" + "Kas oled kindel, et soovid selle liikme suhtluskeelu eemaldada?" + "Eemaldame suhtluskeelu kasutajalt %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-eu/translations.xml b/features/roommembermoderation/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..560e0a6 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,14 @@ + + + "Kendu kidea eta ezarri debekua" + "Ezarri debekua" + "Ziur kide honi debekua ezarri nahi diozula?" + "%1$s(r)i debekua ezartzen" + "Kendu" + "Ikusi profila" + "Kendu gelatik" + "Kidea kendu eta etorkizunean sartzea debekatu?" + "%1$s kentzen…" + "Kendu debekua" + "%1$s(r)i debekua kentzen" + diff --git a/features/roommembermoderation/impl/src/main/res/values-fa/translations.xml b/features/roommembermoderation/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..f17660f --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,18 @@ + + + "برداشت و تحریم عضو" + "تحریم" + "در صورت دعوت نمی‌تواند دوباره به اتاق بپیوندد." + "مطمئنید می‌خواهید این عضو را تحریم کنید؟" + "تحریم کردن %1$s" + "برداشتن" + "در صورت دعوت می‌تواند دوباره به اتاق بپیوندد." + "مطمئنید می‌خواهید این عضو را بردارید؟" + "دیدن نمایه" + "برداشتن از اتاق" + "برداشتن عضو و تحریم پیوستن در آینده؟" + "برداشتن %1$s…" + "تحریم نکردن از اتاق" + "رفع انسداد" + "رفع تحریم %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-fi/translations.xml b/features/roommembermoderation/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..1eb78cd --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,22 @@ + + + "Poista jäsen huoneesta ja anna porttikielto" + "Anna porttikielto" + "He eivät voi enää liittyä tähän huoneeseen, jos heidät kutsutaan." + "Haluatko varmasti antaa tälle jäsenelle porttikiellon?" + "He eivät voi liittyä tähän tilaan uudelleen, vaikka heidät kutsuttaisiin, mutta he säilyttävät jäsenyytensä muissa huoneissa tai alitiloissa." + "Annetaan porttikieltoa käyttäjälle %1$s" + "Poista" + "He voivat liittyä tähän huoneeseen uudelleen, jos heidät kutsutaan." + "Haluatko varmasti poistaa tämän jäsenen?" + "He voivat liittyä tähän tilaan uudelleen, jos heidät kutsutaan, ja he säilyttävät jäsenyytensä kaikissa huoneissa tai alitiloissa." + "Näytä profiili" + "Poista käyttäjä" + "Poistetaanko jäsen huoneesta ja kielletäänkö heitä liittymästä tulevaisuudessa?" + "Poistetaan käyttäjää %1$s huoneesta…" + "Poista porttikielto huoneesta" + "Poista porttikielto" + "He voivat liittyä huoneeseen uudelleen, jos heidät kutsutaan" + "Haluatko varmasti poistaa tämän jäsenen porttikiellon?" + "Poistetaan käyttäjän %1$s porttikieltoa" + diff --git a/features/roommembermoderation/impl/src/main/res/values-fr/translations.xml b/features/roommembermoderation/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..427d6e3 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,22 @@ + + + "Bannir du salon" + "Bannir" + "Ce compte ne pourra pas rejoindre le salon à nouveau, même si il est invité." + "Êtes-vous certain de vouloir bannir ce membre ?" + "L’utilisateur ne pourra plus accéder à cet espace même sur invitation, mais il restera membre de tous les salons et sous-espaces dont il est déjà membre." + "Bannissement de %1$s" + "Retirer" + "Il pourra rejoindre le salon à nouveau si il est invité." + "Voulez-vous vraiment supprimer ce membre ?" + "L’utilisateur pourra rejoindre cet espace à nouveau s’il y est invité, et il restera membre de tous les salons et sous-espaces." + "Voir le profil" + "Exclure ce membre" + "Retirer le membre et interdire l’adhésion à l’avenir ?" + "Enlever %1$s…" + "Débannir du salon" + "Débannir" + "Ce compte pourra à nouveau rejoindre le salon s’il est invité." + "Êtes-vous sûr de vouloir débannir ce compte?" + "Débannissement de %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-hu/translations.xml b/features/roommembermoderation/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..63fc984 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,22 @@ + + + "Eltávolítás és a tag kitiltása" + "Kitiltás" + "Többé nem csatlakozhat ehhez a szobához, akkor sem, ha meghívják." + "Biztos, hogy kitiltja ezt a tagot?" + "Ha meghívják őket akkor sem tudnak újra csatlakozni ehhez a térhez, de továbbra is megtartják tagságukat a szobáikban vagy altereikben." + "%1$s kitiltása" + "Eltávolítás" + "Ehhez a szobához is csatlakozhat, ha meghívják." + "Biztos, hogy eltávolítja ezt a tagot?" + "Ha meghívják őket újra csatlakozhatnak ehhez a térhez, és továbbra is megtartják a tagságukat a szobáikban vagy altereikben." + "Profil megtekintése" + "Felhasználó eltávolítása" + "Eltávolítja a tagot, és megtiltja a jövőbeni csatlakozást?" + "%1$s eltávolítása…" + "Visszaengedés a szobába" + "Tiltás feloldása" + "Újra beléphetnek a szobába, ha meghívják őket." + "Biztos, hogy feloldja a felhasználó kitiltását?" + "%1$s tiltásának feloldása" + diff --git a/features/roommembermoderation/impl/src/main/res/values-in/translations.xml b/features/roommembermoderation/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..bddbdb0 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,20 @@ + + + "Keluarkan dan cekal anggota" + "Cekal" + "Mereka tidak akan dapat bergabung ke ruangan ini lagi jika diundang." + "Apakah Anda yakin ingin mencekal anggota ini?" + "Mencekal %1$s" + "Hapus" + "Pengguna dapat bergabung ke ruangan ini lagi jika diundang." + "Apakah Anda yakin ingin menghapus anggota ini?" + "Tampilkan profil" + "Keluarkan dari ruangan" + "Keluarkan pengguna dan cekal pengguna bergabung lagi di masa mendatang?" + "Mengeluarkan %1$s…" + "Batalkan cekalan dari ruangan" + "Batalkan pencekalan" + "Mereka akan dapat bergabung dengan ruangan lagi jika diundang" + "Apakah Anda yakin ingin membatalkan pencekalan anggota ini?" + "Membatalkan cekalan %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-it/translations.xml b/features/roommembermoderation/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..4e423e2 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,22 @@ + + + "Rimuovi ed escludi" + "Escludi" + "Non potrà entrare nuovamente in questa stanza se invitato." + "Vuoi davvero escludere questo membro?" + "Se invitati, non potranno più unirsi a questo spazio, ma manterranno comunque la loro iscrizione a tutte le stanze o sottospazi." + "Esclusione di %1$s" + "Rimuovi" + "Potrà entrare nuovamente in questa stanza se invitato." + "Sei sicuro di voler rimuovere questo membro?" + "Potranno unirsi nuovamente a questo spazio se invitati e manterranno comunque la loro iscrizione a tutte le stanze o sottospazi." + "Visualizza profilo" + "Rimuovi utente" + "Rimuovere e vietare l\'accesso in futuro?" + "Rimozione di %1$s…" + "Riammetti nella stanza" + "Riammetti" + "Potranno unirsi di nuovo alla stanza se invitati" + "Sei sicuro di voler sbloccare questo membro?" + "Riammissione di %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-ka/translations.xml b/features/roommembermoderation/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..adfdde6 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,15 @@ + + + "წევრის წაშლა და დაბლოკვა" + "დაბლოკვა" + "მოწვევის შემთხვევაში ამ ოთახში კვლავ გაწევრიანებას ვერ შეძლებენ." + "დარწმუნებული ხართ, რომ ამ წევრის დაბლოკვა გსურთ?" + "%1$s-ს დაბლოკვა" + "მოწვევის შემთხვევაში განბლოკილი მომხმარებელი ისევ შეძლებს ოთახს შეუერთდეს." + "პროფილის ნახვა" + "ოთახიდან გაგდება" + "გსურთ წევრის გაგდება და მომავალში გაწევრიანების აკრძალვა?" + "%1$s-ს გაგდება…" + "განბლოკვა" + "%1$s-ს განბლოკვა" + diff --git a/features/roommembermoderation/impl/src/main/res/values-ko/translations.xml b/features/roommembermoderation/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..0675c39 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,20 @@ + + + "방에서 차단" + "차단" + "초대하더라도 그들은 이 방에 다시 참여할 수 없습니다." + "정말로 이 회원을 차단하시겠습니까?" + "차단 %1$s" + "제거" + "초대받으면 이 방에 다시 들어올 수 있습니다." + "이 회원을 정말로 제거하시겠습니까?" + "프로필 보기" + "방에서 제거" + "회원을 삭제하고 앞으로 가입을 금지하시겠습니까?" + "%1$s 제거 중…" + "방에서 차단 해제" + "금지 해제" + "초대되면 다시 방에 참여할 수 있습니다." + "이 회원을 정말로 차단해제 하시겠습니까?" + "차단 해제 %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-nb/translations.xml b/features/roommembermoderation/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..4507fce --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,20 @@ + + + "Fjern og utesteng medlem" + "Utesteng" + "De vil ikke kunne bli med i dette rommet igjen hvis de blir invitert." + "Er du sikker på at du vil utestenge dette medlemmet?" + "Utestenger %1$s" + "Fjern" + "De vil kunne bli med i dette rommet igjen hvis de blir invitert." + "Er du sikker på at du vil fjerne dette medlemmet?" + "Vis profil" + "Fjern fra rommet" + "Fjerne medlem og utestenge fra å bli med i fremtiden?" + "Fjerner %1$s…" + "Fjern utestengelsen fra rommet" + "Opphev utestengelse" + "De vil kunne bli med i rommet igjen hvis de blir invitert" + "Er du sikker på at du vil oppheve utestengelsen av dette medlemmet?" + "Oppheve utestengelsen av %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-nl/translations.xml b/features/roommembermoderation/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..55aca0f --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,15 @@ + + + "Lid verwijderen en verbannen" + "Verbannen" + "Ze kunnen niet meer toetreden tot deze kamer als ze worden uitgenodigd." + "Weet je zeker dat je dit lid wilt verbannen?" + "%1$s verbannen" + "Ze kunnen opnieuw tot de kamer toetreden als ze worden uitgenodigd." + "Profiel bekijken" + "Verwijderen uit kamer" + "Lid verwijderen en toekomstige deelname verbieden?" + "%1$s wordt verwijderd…" + "Ontbannen" + "%1$s ontbannen" + diff --git a/features/roommembermoderation/impl/src/main/res/values-pl/translations.xml b/features/roommembermoderation/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..20246af --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,20 @@ + + + "Usuń i zbanuj członka" + "Zbanuj" + "Nie będą mogli ponownie dołączyć do tego pokoju, jeśli zostaną zaproszeni." + "Czy na pewno chcesz zbanować tego członka?" + "Banowanie %1$s" + "Usuń" + "Będą mogli ponownie dołączyć do tego pokoju, jeśli zostaną zaproszeni." + "Czy na pewno chcesz usunąć tego członka?" + "Wyświetl profil" + "Usuń z pokoju" + "Usunąć członka i zablokować możliwość dołączenia w przyszłości?" + "Usuwanie %1$s…" + "Odbanuj z pokoju" + "Odbanuj" + "Mogą ponownie dołączyć do pokoju, po otrzymaniu zaproszenia" + "Czy na pewno chcesz odbanować tego członka?" + "Odbanowanie %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-pt-rBR/translations.xml b/features/roommembermoderation/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..5a356e1 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,22 @@ + + + "Banir da sala" + "Banir" + "Essa pessoa não poderá entrar nesta sala novamente se for convidada." + "Tem certeza de que quer banir este membro?" + "Eles não poderão mais entrar no espaço novamente se forem convidados, mas manterão sua participação em quaisquer salas e sub-espaços." + "Banindo %1$s" + "Remover" + "Esta pessoa poderá entrar nesta sala novamente se for convidada." + "Tem certeza de que deseja remover este membro?" + "Eles poderão entrar no espaço novamente se convidados, e manterão sua participação em quaisquer salas e sub-espaços." + "Ver perfil" + "Remover usuário" + "Remover membro e banir de entrar novamente no futuro?" + "Removendo %1$s…" + "Desbanir da sala" + "Desbanir" + "Essa pessoa poderia entrar na sala novamente se for convidada" + "Tem certeza que quer desbanir esse membro?" + "Desbanindo %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-pt/translations.xml b/features/roommembermoderation/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..2711359 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,20 @@ + + + "Remover e banir participante" + "Banir" + "Não poderão voltar a entrar nesta sala, mesmo se forem convidados." + "Tens a certeza que queres banir este participante?" + "A banir %1$s" + "Remover" + "Poderão juntar-se novamente a esta sala se forem convidados." + "Tens certeza que queres remover este membro?" + "Ver perfil" + "Remover utilizador" + "Remover participante e proibir que entre no futuro?" + "A remover %1$s…" + "Desbanir da sala" + "Anular banimento" + "Eles poderão entrar novamente na sala se forem convidados" + "Tens certeza que queres desbanir este membro?" + "A anular banimento de %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-ro/translations.xml b/features/roommembermoderation/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..4811b52 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,20 @@ + + + "Îndepărtați și interziceți membrul" + "Interzicere" + "Nu se vor putea alătura din nou acestei camere dacă sunt invitați." + "Sunteți sigur că doriți să interziceți acest membru?" + "Se interzice %1$s" + "Îndepărtați" + "Se vor putea alătura din nou acestei săli dacă sunt invitați." + "Sunteți sigur că doriți să îndepărtați acest membru?" + "Vizualizare profil" + "Înlăturați membrul" + "Înlăturați membrul și interziceți-i să se alăture în viitor?" + "Se îndepărtează %1$s" + "Revocati excluderea din camera" + "Anulare excludere" + "Aceștia se vor putea alătura din nou camerei dacă sunt invitați." + "Sunteți sigur că doriți să dezactivați excluderea impusă acestui membru?" + "Se anulează interzicerea lui %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-ru/translations.xml b/features/roommembermoderation/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..7e1691a --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,22 @@ + + + "Удалить и заблокировать участника" + "Заблокировать" + "Они не смогут снова присоединиться к этой комнате, если их пригласят." + "Вы уверены, что хотите заблокировать этого участника?" + "Они не смогут снова присоединиться к этому пространству, если их пригласят, но они по-прежнему сохранят свое членство в любых комнатах или подпространствах." + "Блокировка %1$s" + "Удалить" + "Они снова смогут присоединиться в эту комнату если их пригласят." + "Вы действительно хотите удалить этого участника?" + "Они смогут снова присоединиться к этому пространству, если их пригласят и сохранят свое членство во всех комнатах или подпространствах." + "Посмотреть профиль" + "Удалить участника из комнаты" + "Удалить участника и запретить присоединяться в будущем?" + "Удаление %1$s…" + "Разблокировать в комнате" + "Разблокировать" + "Они смогут снова войти в комнату, если их пригласят." + "Вы действительно хотите разблокировать этого участника?" + "Разблокировка %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-sk/translations.xml b/features/roommembermoderation/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..c852e7a --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,22 @@ + + + "Odstrániť a zakázať člena" + "Zakázať" + "Nebudú sa môcť pripojiť k tejto miestnosti znova ani ak budú pozvaní." + "Ste si istý, že chcete zakázať tohto člena?" + "Ak dostanú pozvánku, nebudú sa môcť k tomuto priestoru znova pripojiť, ale stále si ponechajú členstvo vo všetkých miestnostiach alebo podpriestoroch." + "Zakazuje sa %1$s" + "Odstrániť" + "V prípade pozvania sa budú môcť znova pripojiť k tejto miestnosti." + "Ste si istý, že chcete odstrániť tohto člena?" + "Ak dostanú pozvánku, budú sa môcť k tomuto priestoru znova pripojiť a stále si ponechajú členstvo vo všetkých miestnostiach alebo podpriestoroch." + "Zobraziť profil" + "Odstrániť používateľa" + "Odstrániť člena a zakázať vstup v budúcnosti?" + "Odstraňuje sa %1$s…" + "Zrušiť zákaz prístupu do miestnosti" + "Zrušiť zákaz" + "V prípade pozvania by sa mohli opäť pripojiť k miestnosti" + "Naozaj chcete zrušiť zablokovanie tohto člena?" + "Zrušenie zákazu %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-sv/translations.xml b/features/roommembermoderation/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..9f3aabe --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,20 @@ + + + "Ta bort och banna medlem" + "Banna" + "Denne kommer inte att kunna gå med i det här rummet igen om denne bjuds in." + "Är du säker på att du vill banna den här medlemmen?" + "Bannar %1$s" + "Ta bort" + "Denne kommer kunna gå med i rummet igen om denne bjuds in" + "Är du säker på att du vill ta bort den här medlemmen?" + "Visa profil" + "Ta bort från rummet" + "Ta bort medlem och banna från att gå med i framtiden?" + "Tar bort %1$s …" + "Avbanna från rummet" + "Avbanna" + "De skulle kunna gå med i rummet igen om de blev inbjudna" + "Är du säker på att du vill avbanna den här medlemmen?" + "Avbannar %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-tr/translations.xml b/features/roommembermoderation/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..a63e4eb --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,15 @@ + + + "Üyeyi çıkar ve yasakla" + "Yasakla" + "Davet edilseler bile bu odaya tekrar katılamazlar." + "Bu üyeyi yasaklamak istediğinize emin misiniz?" + "Yasaklanıyor %1$s" + "Davet edildikleri takdirde bu odaya tekrar katılabileceklerdir." + "Profili görüntüle" + "Odadan çıkar" + "Üyeyi çıkarın ve gelecekte katılmasını yasaklayın?" + "Kaldırılıyor %1$s…" + "Yasağı Kaldır" + "Yasak kaldırılıyor %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-uk/translations.xml b/features/roommembermoderation/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..6c48b0b --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,20 @@ + + + "Вилучити й заблокувати учасника" + "Заблокувати" + "Він не зможе приєднатися до цієї кімнати знову, якщо його запросять." + "Ви точно хочете заблокувати цього користувача?" + "Блокування %1$s" + "Вилучити" + "Вони зможуть знову приєднатися до цієї кімнати, якщо їх запросять." + "Ви дійсно хочете вилучити цього учасника?" + "Переглянути профіль" + "Вилучити з кімнати" + "Вилучити учасника та заборонити приєднання в майбутньому?" + "Вилучення %1$s…" + "Розблокувати в кімнаті" + "Розблокувати" + "Вони зможуть знову приєднатися до кімнати, якщо їх запросять" + "Ви впевнені, що хочете розблокувати цього учасника?" + "Розблокування %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-ur/translations.xml b/features/roommembermoderation/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..2ea1262 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,15 @@ + + + "کمرے سے محظور کریں" + "محظور کریں" + "اگر وہ مدعو کیا گیا تو وہ دوبارہ اس کمرے میں شامل نہیں ہوسکیں گے۔" + "کیا آپ کو یقین ہے کہ آپ اس رکن کو محظور کرنا چاہتے ہیں؟" + "%1$s کو محظور کر رہا ہے" + "اگر وہ مدعو کیا جائیں تو وہ دوبارہ اس کمرے میں شامل ہوسکیں گے۔" + "نمایہ ملاحظہ کریں" + "کمرے سے ہٹائیں" + "رکن کو ہٹائیں اور مستقبل میں شمولیت پر پابندی لگائیں؟" + "%1$s کو ہٹا رہا ہے…" + "غیر محظور کریں" + "%1$s کو غیر محظور کر رہا ہے" + diff --git a/features/roommembermoderation/impl/src/main/res/values-uz/translations.xml b/features/roommembermoderation/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..90dfae8 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,20 @@ + + + "Xonadan chetlashtirish" + "Taqiqlash" + "Taklif qilingan taqdirda ham, ular bu xonaga boshqa qo‘shila olmaydilar." + "Haqiqatan ham bu aʼzoni taqiqlamoqchimisiz?" + "Taqiqlash %1$s" + "Oʻchirish" + "Agar taklif qilinsa, ular bu xonaga qayta qo‘shilishlari mumkin." + "Haqiqatan ham bu a’zoni olib tashlaysizmi?" + "Profilni koʻrish" + "Xonadan olib tashlash" + "Aʻzo oʻchirilsinmi va kelgusida qoʻshilish taqiqlansinmi?" + "Oʻchirish %1$s …" + "Xonadan taqiqni olib tashlash" + "Taqiqni bekor qilish" + "Agar taklif qilinsa, ular xonaga yana qo‘shilishlari mumkin" + "Haqiqatan ham bu a’zoni blokdan chiqarmoqchimisiz?" + "Taqiqni bekor qilish %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-zh-rTW/translations.xml b/features/roommembermoderation/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..14a044a --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,22 @@ + + + "踢出並加入黑名單" + "加入黑名單" + "即使收到邀請,他們仍然無法加入聊天室。" + "您確定要將此成員加入黑名單?" + "即使被邀請,他們也無法再次加入此空間,但他們仍將保留其在任何聊天室或子空間的成員資格。" + "正在將 %1$s 加入黑名單" + "移除" + "如果收到邀請,他們能再次加入聊天室。" + "您真的想要移除此成員嗎?" + "若受邀,他們將可以再次加入此空間,並保留所有聊天室與子空間的成員資格。" + "查看個人檔案" + "移除使用者" + "移除成員並禁止未來再度加入?" + "正在踢出 %1$s…" + "從聊天室解除封鎖" + "解除黑名單" + "若受到邀請,他們仍可再次加入聊天室" + "您確定您想要取消封鎖此成員嗎?" + "正在解除黑名單 %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-zh/translations.xml b/features/roommembermoderation/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..aa8b2e3 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,22 @@ + + + "移除并封禁成员" + "封禁" + "即使受到邀请,他们也无法再次加入聊天室。" + "您确定要封禁该成员吗?" + "即使再次受邀,他们也无法加入这个空间,但他们仍将保留其在任何房间或子空间的成员资格。" + "封禁 %1$s" + "移除" + "如果受到邀请,他们可以重新加入聊天室。" + "您确定要移除此成员吗?" + "如果受到邀请,他们将能够再次加入这个空间,并且他们仍将保留其在任何房间或子空间的成员资格。" + "查看个人资料" + "移除用户" + "删除成员并禁止重新加入?" + "正在移除 %1$s……" + "从房间取消解封" + "取消封禁" + "如果再次收到邀请,他们可以重新加入该聊天室" + "确定要解除该成员的封禁吗?" + "解除封禁 %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values/localazy.xml b/features/roommembermoderation/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..3d23c87 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values/localazy.xml @@ -0,0 +1,22 @@ + + + "Ban user" + "Ban" + "They won’t be able to join again if invited." + "Are you sure you want to ban this member?" + "They won’t be able to join this space again if invited, but they’ll still keep their memberships of any rooms or subspaces." + "Banning %1$s" + "Remove" + "They will be able to join this room again if invited." + "Are you sure you want to remove this member?" + "They will be able to join this space again if invited, and they’ll still keep their memberships of any rooms or subspaces." + "View profile" + "Remove user" + "Remove member and ban from joining in the future?" + "Removing %1$s…" + "Unban user" + "Unban" + "They would be able to join again if invited" + "Are you sure you want to unban this member?" + "Unbanning %1$s" + diff --git a/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenterTest.kt b/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenterTest.kt new file mode 100644 index 0000000..2b3f71e --- /dev/null +++ b/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenterTest.kt @@ -0,0 +1,384 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roommembermoderation.impl + +import app.cash.turbine.TurbineTestContext +import com.google.common.truth.Truth.assertThat +import io.element.android.features.roommembermoderation.api.ModerationAction +import io.element.android.features.roommembermoderation.api.ModerationActionState +import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents +import io.element.android.features.roommembermoderation.api.RoomMemberModerationState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.RoomMembershipState +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.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class RoomMemberModerationPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private val targetUser = MatrixUser(userId = A_USER_ID) + + @Test + fun `present - initial state`() = runTest { + val room = aJoinedRoom() + createRoomMemberModerationPresenter(room = room).test { + val initialState = awaitState() + assertThat(initialState.canKick).isFalse() + assertThat(initialState.canBan).isFalse() + assertThat(initialState.selectedUser).isNull() + assertThat(initialState.banUserAsyncAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(initialState.kickUserAsyncAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(initialState.unbanUserAsyncAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(initialState.actions).isEmpty() + } + } + + @Test + fun `present - show actions when canBan=false, canKick=false`() = runTest { + val room = aJoinedRoom( + canBan = false, + canKick = false, + myUserRole = RoomMember.Role.User, + targetRoomMember = aRoomMember(userId = A_USER_ID, powerLevel = RoomMember.Role.User.powerLevel) + ) + createRoomMemberModerationPresenter(room = room).test { + val initialState = awaitState() + initialState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(targetUser)) + skipItems(1) + val updatedState = awaitState() + assertThat(updatedState.selectedUser).isEqualTo(targetUser) + assertThat(updatedState.actions).containsExactly( + ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true), + ) + } + } + + @Test + fun `present - show actions when canBan=true, canKick=true, userRole=Admin and target member is unknown`() = runTest { + val room = aJoinedRoom( + canBan = true, + canKick = true, + myUserRole = RoomMember.Role.Admin, + targetRoomMember = null + ) + createRoomMemberModerationPresenter(room = room).test { + val initialState = awaitState() + initialState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(targetUser)) + skipItems(2) + val updatedState = awaitState() + assertThat(updatedState.selectedUser).isEqualTo(targetUser) + assertThat(updatedState.actions).containsExactly( + ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true), + ModerationActionState(action = ModerationAction.KickUser, isEnabled = true), + ModerationActionState(action = ModerationAction.BanUser, isEnabled = true), + ) + } + } + + @Test + fun `show actions when canBan=true, canKick=true, userRole=Admin and target is User`() = runTest { + val room = aJoinedRoom( + canBan = true, + canKick = true, + myUserRole = RoomMember.Role.Admin, + targetRoomMember = aRoomMember(userId = A_USER_ID, powerLevel = RoomMember.Role.User.powerLevel) + ) + createRoomMemberModerationPresenter(room = room).test { + val initialState = awaitState() + initialState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(targetUser)) + skipItems(2) + val updatedState = awaitState() + assertThat(updatedState.selectedUser).isEqualTo(targetUser) + assertThat(updatedState.actions).containsExactly( + ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true), + ModerationActionState(action = ModerationAction.KickUser, isEnabled = true), + ModerationActionState(action = ModerationAction.BanUser, isEnabled = true), + ) + } + } + + @Test + fun `show actions when canBan=true, canKick=true, userRole=Moderator and target is Admin`() = runTest { + val room = aJoinedRoom( + canBan = true, + canKick = true, + myUserRole = RoomMember.Role.Moderator, + targetRoomMember = aRoomMember(userId = A_USER_ID, powerLevel = RoomMember.Role.Admin.powerLevel) + ) + createRoomMemberModerationPresenter(room = room).test { + val initialState = awaitState() + initialState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(targetUser)) + skipItems(2) + val updatedState = awaitState() + assertThat(updatedState.selectedUser).isEqualTo(targetUser) + assertThat(updatedState.actions).containsExactly( + ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true), + ModerationActionState(action = ModerationAction.KickUser, isEnabled = false), + ModerationActionState(action = ModerationAction.BanUser, isEnabled = false), + ) + } + } + + @Test + fun `show actions when canBan=true, canKick=true, userRole=Moderator and target is Banned`() = runTest { + val room = aJoinedRoom( + canBan = true, + canKick = true, + myUserRole = RoomMember.Role.Moderator, + targetRoomMember = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.BAN) + ) + createRoomMemberModerationPresenter(room = room).test { + val initialState = awaitState() + initialState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(targetUser)) + skipItems(2) + val updatedState = awaitState() + assertThat(updatedState.selectedUser).isEqualTo(targetUser) + assertThat(updatedState.actions).containsExactly( + ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true), + ModerationActionState(action = ModerationAction.KickUser, isEnabled = false), + ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = true), + ) + } + } + + @Test + fun `present - process kick action sets confirming state`() = runTest { + createRoomMemberModerationPresenter(room = aJoinedRoom()).test { + val initialState = awaitState() + initialState.eventSink( + RoomMemberModerationEvents.ProcessAction( + targetUser = targetUser, + action = ModerationAction.KickUser + ) + ) + skipItems(1) + val updatedState = awaitState() + assertThat(updatedState.selectedUser).isEqualTo(targetUser) + assertThat(updatedState.kickUserAsyncAction).isEqualTo(AsyncAction.ConfirmingNoParams) + } + } + + @Test + fun `present - process ban action sets confirming state`() = runTest { + createRoomMemberModerationPresenter(room = aJoinedRoom()).test { + val initialState = awaitState() + initialState.eventSink( + RoomMemberModerationEvents.ProcessAction( + targetUser = targetUser, + action = ModerationAction.BanUser + ) + ) + skipItems(1) + val updatedState = awaitState() + assertThat(updatedState.selectedUser).isEqualTo(targetUser) + assertThat(updatedState.banUserAsyncAction).isEqualTo(AsyncAction.ConfirmingNoParams) + } + } + + @Test + fun `present - process unban action sets confirming state`() = runTest { + createRoomMemberModerationPresenter(room = aJoinedRoom()).test { + val initialState = awaitState() + initialState.eventSink( + RoomMemberModerationEvents.ProcessAction( + targetUser = targetUser, + action = ModerationAction.UnbanUser + ) + ) + skipItems(1) + val updatedState = awaitState() + assertThat(updatedState.selectedUser).isEqualTo(targetUser) + assertThat(updatedState.unbanUserAsyncAction).isEqualTo(AsyncAction.ConfirmingNoParams) + } + } + + @Test + fun `present - do kick user with success`() = runTest { + val room = aJoinedRoom() + room.baseRoom.givenUpdateMembersResult { + // Simulate the member list being updated + room.givenRoomMembersState(RoomMembersState.Ready( + persistentListOf(aRoomMember()) + )) + } + createRoomMemberModerationPresenter(room = room).test { + val initialState = awaitState() + initialState.eventSink( + RoomMemberModerationEvents.ProcessAction( + targetUser = targetUser, + action = ModerationAction.KickUser + ) + ) + skipItems(2) + initialState.eventSink(InternalRoomMemberModerationEvents.DoKickUser("Reason")) + skipItems(1) + val loadingState = awaitState() + assertThat(loadingState.kickUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java) + val successState = awaitState() + assertThat(successState.kickUserAsyncAction).isInstanceOf(AsyncAction.Success::class.java) + assertThat(successState.selectedUser).isNull() + } + } + + @Test + fun `present - do ban user with success`() = runTest { + val room = aJoinedRoom() + room.baseRoom.givenUpdateMembersResult { + // Simulate the member list being updated + room.givenRoomMembersState(RoomMembersState.Ready( + persistentListOf(aRoomMember()) + )) + } + createRoomMemberModerationPresenter(room = room).test { + val initialState = awaitState() + initialState.eventSink( + RoomMemberModerationEvents.ProcessAction( + targetUser = targetUser, + action = ModerationAction.BanUser + ) + ) + skipItems(2) + initialState.eventSink(InternalRoomMemberModerationEvents.DoBanUser("Reason")) + skipItems(1) + val loadingState = awaitState() + assertThat(loadingState.banUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java) + val successState = awaitState() + assertThat(successState.banUserAsyncAction).isInstanceOf(AsyncAction.Success::class.java) + assertThat(successState.selectedUser).isNull() + } + } + + @Test + fun `present - do unban user with success`() = runTest { + val room = aJoinedRoom() + room.baseRoom.givenUpdateMembersResult { + // Simulate the member list being updated + room.givenRoomMembersState(RoomMembersState.Ready( + persistentListOf(aRoomMember()) + )) + } + createRoomMemberModerationPresenter(room = room).test { + val initialState = awaitState() + initialState.eventSink( + RoomMemberModerationEvents.ProcessAction( + targetUser = targetUser, + action = ModerationAction.UnbanUser + ) + ) + skipItems(2) + initialState.eventSink(InternalRoomMemberModerationEvents.DoUnbanUser("Reason")) + skipItems(1) + val loadingState = awaitState() + assertThat(loadingState.unbanUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java) + val successState = awaitState() + assertThat(successState.unbanUserAsyncAction).isInstanceOf(AsyncAction.Success::class.java) + assertThat(successState.selectedUser).isNull() + } + } + + @Test + fun `present - do kick user with failure`() = runTest { + val error = RuntimeException("Test error") + val room = aJoinedRoom( + kickUserResult = Result.failure(error), + ) + createRoomMemberModerationPresenter(room = room).test { + val initialState = awaitState() + initialState.eventSink( + RoomMemberModerationEvents.ProcessAction( + targetUser = targetUser, + action = ModerationAction.KickUser + ) + ) + skipItems(2) + initialState.eventSink(InternalRoomMemberModerationEvents.DoKickUser("Reason")) + skipItems(1) + val loadingState = awaitState() + assertThat(loadingState.kickUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java) + val failureState = awaitState() + assertThat(failureState.kickUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java) + } + } + + @Test + fun `present - reset clears all async actions and selected user`() = runTest { + createRoomMemberModerationPresenter(room = aJoinedRoom()).test { + val initialState = awaitState() + initialState.eventSink( + RoomMemberModerationEvents.ProcessAction(targetUser = targetUser, action = ModerationAction.BanUser) + ) + skipItems(2) + initialState.eventSink(InternalRoomMemberModerationEvents.Reset) + skipItems(1) + val resetState = awaitState() + assertThat(resetState.selectedUser).isNull() + assertThat(resetState.banUserAsyncAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + private fun aJoinedRoom( + canKick: Boolean = false, + canBan: Boolean = false, + myUserRole: RoomMember.Role = RoomMember.Role.User, + kickUserResult: Result = Result.success(Unit), + banUserResult: Result = Result.success(Unit), + unBanUserResult: Result = Result.success(Unit), + targetRoomMember: RoomMember? = null, + ): FakeJoinedRoom { + return FakeJoinedRoom( + kickUserResult = { _, _ -> kickUserResult }, + banUserResult = { _, _ -> banUserResult }, + unBanUserResult = { _, _ -> unBanUserResult }, + baseRoom = FakeBaseRoom( + canBanResult = { _ -> Result.success(canBan) }, + canKickResult = { _ -> Result.success(canKick) }, + userRoleResult = { Result.success(myUserRole) }, + updateMembersResult = { Result.success(Unit) } + ), + ).apply { + val roomMembers = listOfNotNull(targetRoomMember).toImmutableList() + givenRoomMembersState(state = RoomMembersState.Ready(roomMembers)) + } + } + + private fun TestScope.createRoomMemberModerationPresenter( + room: JoinedRoom, + dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), + analyticsService: AnalyticsService = FakeAnalyticsService(), + ): RoomMemberModerationPresenter { + return RoomMemberModerationPresenter( + room = room, + dispatchers = dispatchers, + analyticsService = analyticsService, + ) + } + + private suspend fun TurbineTestContext.awaitState(): InternalRoomMemberModerationState { + return awaitItem() as InternalRoomMemberModerationState + } +} diff --git a/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationViewTest.kt b/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationViewTest.kt new file mode 100644 index 0000000..6508b28 --- /dev/null +++ b/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationViewTest.kt @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roommembermoderation.impl + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.roommembermoderation.api.ModerationAction +import io.element.android.features.roommembermoderation.api.ModerationActionState +import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.testtags.TestTags +import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnceWithTwoParams +import io.element.android.tests.testutils.pressTag +import io.element.android.tests.testutils.setSafeContent +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RoomMemberModerationViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on display profile action calls onSelectAction`() { + val user = anAlice() + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnceWithTwoParams(ModerationAction.DisplayProfile, user) { callback -> + rule.setRoomMemberModerationView( + aRoomMembersModerationState( + selectedUser = user, + actions = listOf( + ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true), + ), + eventSink = eventsRecorder + ), + onSelectAction = callback + ) + rule.clickOn(R.string.screen_bottom_sheet_manage_room_member_member_user_info) + } + } + + @Test + fun `clicking on kick user action calls onSelectAction`() { + val user = anAlice() + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnceWithTwoParams(ModerationAction.KickUser, user) { callback -> + rule.setRoomMemberModerationView( + aRoomMembersModerationState( + selectedUser = user, + actions = listOf( + ModerationActionState(action = ModerationAction.KickUser, isEnabled = true), + ), + eventSink = eventsRecorder + ), + onSelectAction = callback + ) + rule.clickOn(R.string.screen_bottom_sheet_manage_room_member_remove) + // Gives time for bottomsheet to hide + rule.mainClock.advanceTimeBy(1_000) + } + } + + @Test + fun `clicking on ban user action calls onSelectAction`() { + val user = anAlice() + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnceWithTwoParams(ModerationAction.BanUser, user) { callback -> + rule.setRoomMemberModerationView( + aRoomMembersModerationState( + selectedUser = user, + actions = listOf( + ModerationActionState(action = ModerationAction.BanUser, isEnabled = true), + ), + eventSink = eventsRecorder + ), + onSelectAction = callback + ) + rule.clickOn(R.string.screen_bottom_sheet_manage_room_member_ban) + // Gives time for bottomsheet to hide + rule.mainClock.advanceTimeBy(1_000) + } + } + + @Test + fun `clicking on unban user action calls onSelectAction`() { + val user = anAlice() + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnceWithTwoParams(ModerationAction.UnbanUser, user) { callback -> + rule.setRoomMemberModerationView( + aRoomMembersModerationState( + selectedUser = user, + actions = listOf( + ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = true), + ), + eventSink = eventsRecorder + ), + onSelectAction = callback + ) + rule.clickOn(R.string.screen_bottom_sheet_manage_room_member_unban) + // Gives time for bottomsheet to hide + rule.mainClock.advanceTimeBy(1_000) + } + } + + @Test + fun `clicking submit on kick confirmation dialog sends DoKickUser event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomMemberModerationView( + aRoomMembersModerationState( + selectedUser = anAlice(), + kickUserAsyncAction = AsyncAction.ConfirmingNoParams, + eventSink = eventsRecorder + ), + ) + rule.pressTag(TestTags.dialogPositive.value) + eventsRecorder.assertSingle(InternalRoomMemberModerationEvents.DoKickUser(reason = "")) + } + + @Test + fun `clicking dismiss on kick confirmation dialog sends Reset event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomMemberModerationView( + aRoomMembersModerationState( + selectedUser = anAlice(), + kickUserAsyncAction = AsyncAction.ConfirmingNoParams, + eventSink = eventsRecorder + ), + ) + rule.pressTag(TestTags.dialogNegative.value) + eventsRecorder.assertSingle(InternalRoomMemberModerationEvents.Reset) + } + + @Test + fun `clicking submit on ban confirmation dialog sends DoBanUser event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomMemberModerationView( + aRoomMembersModerationState( + selectedUser = anAlice(), + banUserAsyncAction = AsyncAction.ConfirmingNoParams, + eventSink = eventsRecorder + ), + ) + rule.pressTag(TestTags.dialogPositive.value) + eventsRecorder.assertSingle(InternalRoomMemberModerationEvents.DoBanUser(reason = "")) + } + + @Test + fun `clicking dismiss on ban confirmation dialog sends Reset event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomMemberModerationView( + aRoomMembersModerationState( + selectedUser = anAlice(), + banUserAsyncAction = AsyncAction.ConfirmingNoParams, + eventSink = eventsRecorder + ), + ) + rule.pressTag(TestTags.dialogNegative.value) + eventsRecorder.assertSingle(InternalRoomMemberModerationEvents.Reset) + } + + @Test + fun `clicking confirm on unban confirmation dialog sends DoUnbanUser event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomMemberModerationView( + aRoomMembersModerationState( + selectedUser = anAlice(), + unbanUserAsyncAction = AsyncAction.ConfirmingNoParams, + eventSink = eventsRecorder + ), + ) + rule.pressTag(TestTags.dialogPositive.value) + eventsRecorder.assertSingle(InternalRoomMemberModerationEvents.DoUnbanUser("")) + } + + @Test + fun `clicking dismiss on unban confirmation dialog sends Reset event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomMemberModerationView( + aRoomMembersModerationState( + selectedUser = anAlice(), + unbanUserAsyncAction = AsyncAction.ConfirmingNoParams, + eventSink = eventsRecorder + ), + ) + rule.pressTag(TestTags.dialogNegative.value) + eventsRecorder.assertSingle(InternalRoomMemberModerationEvents.Reset) + } + + @Test + fun `disabled actions are not clickable`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setRoomMemberModerationView( + aRoomMembersModerationState( + selectedUser = anAlice(), + actions = listOf( + ModerationActionState(action = ModerationAction.KickUser, isEnabled = false), + ), + eventSink = eventsRecorder + ), + ) + rule.clickOn(R.string.screen_bottom_sheet_manage_room_member_remove) + } +} + +private fun AndroidComposeTestRule.setRoomMemberModerationView( + state: InternalRoomMemberModerationState, + onSelectAction: (ModerationAction, MatrixUser) -> Unit = EnsureNeverCalledWithTwoParams(), +) { + setSafeContent { + RoomMemberModerationView( + state = state, + onSelectAction = onSelectAction, + ) + } +} diff --git a/features/securebackup/api/build.gradle.kts b/features/securebackup/api/build.gradle.kts new file mode 100644 index 0000000..2c8fa42 --- /dev/null +++ b/features/securebackup/api/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.securebackup.api" +} + +dependencies { + implementation(projects.libraries.architecture) +} diff --git a/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt b/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt new file mode 100644 index 0000000..9c9d612 --- /dev/null +++ b/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.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 kotlinx.parcelize.Parcelize + +interface SecureBackupEntryPoint : FeatureEntryPoint { + sealed interface InitialTarget : Parcelable { + @Parcelize + data object Root : InitialTarget + + @Parcelize + data object SetUpRecovery : InitialTarget + + @Parcelize + data object EnterRecoveryKey : InitialTarget + + @Parcelize + data object ResetIdentity : InitialTarget + } + + data class Params(val initialElement: InitialTarget) : NodeInputs + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + callback: Callback, + ): Node + + interface Callback : Plugin { + fun onDone() + } +} diff --git a/features/securebackup/impl/build.gradle.kts b/features/securebackup/impl/build.gradle.kts new file mode 100644 index 0000000..b611727 --- /dev/null +++ b/features/securebackup/impl/build.gradle.kts @@ -0,0 +1,45 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.securebackup.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.appconfig) + 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.oidc.api) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.testtags) + api(libs.statemachine) + api(projects.features.securebackup.api) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPoint.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPoint.kt new file mode 100644 index 0000000..46d93da --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPoint.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.securebackup.api.SecureBackupEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultSecureBackupEntryPoint : SecureBackupEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: SecureBackupEntryPoint.Params, + callback: SecureBackupEntryPoint.Callback, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf(params, callback) + ) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/LoggerTag.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/LoggerTag.kt new file mode 100644 index 0000000..fe24607 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/LoggerTag.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl + +import io.element.android.libraries.core.log.logger.LoggerTag + +private val loggerTag = LoggerTag("SecureBackup") +val loggerTagRoot = LoggerTag("Root", loggerTag) +val loggerTagSetup = LoggerTag("Setup", loggerTag) +val loggerTagDisable = LoggerTag("Disable", loggerTag) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt new file mode 100644 index 0000000..d9fd8a1 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl + +import android.os.Parcelable +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.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.securebackup.api.SecureBackupEntryPoint +import io.element.android.features.securebackup.impl.disable.SecureBackupDisableNode +import io.element.android.features.securebackup.impl.enter.SecureBackupEnterRecoveryKeyNode +import io.element.android.features.securebackup.impl.reset.ResetIdentityFlowNode +import io.element.android.features.securebackup.impl.root.SecureBackupRootNode +import io.element.android.features.securebackup.impl.setup.SecureBackupSetupNode +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.appyx.canPop +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +@AssistedInject +class SecureBackupFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : BaseFlowNode( + backstack = BackStack( + initialElement = when (plugins.filterIsInstance().first().initialElement) { + SecureBackupEntryPoint.InitialTarget.Root -> NavTarget.Root + SecureBackupEntryPoint.InitialTarget.SetUpRecovery -> NavTarget.Setup + SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey -> NavTarget.EnterRecoveryKey + is SecureBackupEntryPoint.InitialTarget.ResetIdentity -> NavTarget.ResetIdentity + }, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +) { + sealed interface NavTarget : Parcelable { + @Parcelize + data object Root : NavTarget + + @Parcelize + data object Setup : NavTarget + + @Parcelize + data object Change : NavTarget + + @Parcelize + data object Disable : NavTarget + + @Parcelize + data object EnterRecoveryKey : NavTarget + + @Parcelize + data object ResetIdentity : NavTarget + } + + private val callback: SecureBackupEntryPoint.Callback = callback() + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Root -> { + val callback = object : SecureBackupRootNode.Callback { + override fun navigateToSetup() { + backstack.push(NavTarget.Setup) + } + + override fun navigateToChange() { + backstack.push(NavTarget.Change) + } + + override fun navigateToDisable() { + backstack.push(NavTarget.Disable) + } + + override fun navigateToEnterRecoveryKey() { + backstack.push(NavTarget.EnterRecoveryKey) + } + } + createNode(buildContext, listOf(callback)) + } + NavTarget.Setup -> { + val inputs = SecureBackupSetupNode.Inputs( + isChangeRecoveryKeyUserStory = false, + ) + createNode(buildContext, listOf(inputs)) + } + NavTarget.Change -> { + val inputs = SecureBackupSetupNode.Inputs( + isChangeRecoveryKeyUserStory = true, + ) + createNode(buildContext, listOf(inputs)) + } + NavTarget.Disable -> { + createNode(buildContext) + } + NavTarget.EnterRecoveryKey -> { + val callback = object : SecureBackupEnterRecoveryKeyNode.Callback { + override fun onEnterRecoveryKeySuccess() { + if (backstack.canPop()) { + backstack.pop() + } else { + callback.onDone() + } + } + } + createNode(buildContext, plugins = listOf(callback)) + } + is NavTarget.ResetIdentity -> { + val callback = object : ResetIdentityFlowNode.Callback { + override fun onDone() { + callback.onDone() + } + } + createNode(buildContext, listOf(callback)) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView() + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableEvents.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableEvents.kt new file mode 100644 index 0000000..05d05a4 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.disable + +sealed interface SecureBackupDisableEvents { + data object DisableBackup : SecureBackupDisableEvents + data object DismissDialogs : SecureBackupDisableEvents +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableNode.kt new file mode 100644 index 0000000..6877893 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableNode.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.disable + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class SecureBackupDisableNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: SecureBackupDisablePresenter, +) : Node(buildContext, plugins = plugins) { + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + SecureBackupDisableView( + state = state, + modifier = modifier, + onSuccess = ::navigateUp, + onBackClick = ::navigateUp, + ) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisablePresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisablePresenter.kt new file mode 100644 index 0000000..67b6816 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisablePresenter.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.disable + +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 dev.zacsweers.metro.Inject +import io.element.android.features.securebackup.impl.loggerTagDisable +import io.element.android.libraries.architecture.AsyncAction +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.encryption.EncryptionService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +@Inject +class SecureBackupDisablePresenter( + private val encryptionService: EncryptionService, + private val buildMeta: BuildMeta, +) : Presenter { + @Composable + override fun present(): SecureBackupDisableState { + val backupState by encryptionService.backupStateStateFlow.collectAsState() + Timber.tag(loggerTagDisable.value).d("backupState: $backupState") + val disableAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + val coroutineScope = rememberCoroutineScope() + fun handleEvent(event: SecureBackupDisableEvents) { + when (event) { + is SecureBackupDisableEvents.DisableBackup -> coroutineScope.disableBackup(disableAction) + SecureBackupDisableEvents.DismissDialogs -> { + disableAction.value = AsyncAction.Uninitialized + } + } + } + + return SecureBackupDisableState( + backupState = backupState, + disableAction = disableAction.value, + appName = buildMeta.applicationName, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.disableBackup(disableAction: MutableState>) = launch { + suspend { + Timber.tag(loggerTagDisable.value).d("Calling encryptionService.disableRecovery()") + encryptionService.disableRecovery().getOrThrow() + }.runCatchingUpdatingState(disableAction) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableState.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableState.kt new file mode 100644 index 0000000..a4f6b21 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableState.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.disable + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.encryption.BackupState + +data class SecureBackupDisableState( + val backupState: BackupState, + val disableAction: AsyncAction, + val appName: String, + val eventSink: (SecureBackupDisableEvents) -> Unit +) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableStateProvider.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableStateProvider.kt new file mode 100644 index 0000000..6aa10d9 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableStateProvider.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.disable + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.encryption.BackupState + +open class SecureBackupDisableStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aSecureBackupDisableState(), + aSecureBackupDisableState(disableAction = AsyncAction.ConfirmingNoParams), + aSecureBackupDisableState(disableAction = AsyncAction.Loading), + aSecureBackupDisableState(disableAction = AsyncAction.Failure(Exception("Failed to disable"))), + // Add other states here + ) +} + +fun aSecureBackupDisableState( + backupState: BackupState = BackupState.UNKNOWN, + disableAction: AsyncAction = AsyncAction.Uninitialized, +) = SecureBackupDisableState( + backupState = backupState, + disableAction = disableAction, + appName = "Element", + eventSink = {} +) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableView.kt new file mode 100644 index 0000000..2467814 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableView.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.disable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.securebackup.impl.R +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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 + +@Composable +fun SecureBackupDisableView( + state: SecureBackupDisableState, + onSuccess: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + FlowStepPage( + modifier = modifier, + onBackClick = onBackClick, + title = stringResource(id = R.string.screen_key_backup_disable_title), + subTitle = stringResource(id = R.string.screen_key_backup_disable_description), + iconStyle = BigIcon.Style.AlertSolid, + buttons = { Buttons(state = state) }, + ) { + Content(state = state) + } + + AsyncActionView( + async = state.disableAction, + progressDialog = {}, + errorMessage = { it.message ?: it.toString() }, + onErrorDismiss = { state.eventSink.invoke(SecureBackupDisableEvents.DismissDialogs) }, + onSuccess = { onSuccess() }, + ) +} + +@Composable +private fun ColumnScope.Buttons( + state: SecureBackupDisableState, +) { + Button( + text = stringResource(id = R.string.screen_chat_backup_key_backup_action_disable), + showProgress = state.disableAction.isLoading(), + destructive = true, + modifier = Modifier.fillMaxWidth(), + onClick = { state.eventSink.invoke(SecureBackupDisableEvents.DisableBackup) } + ) +} + +@Composable +private fun Content(state: SecureBackupDisableState) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 18.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + SecureBackupDisableItem(stringResource(id = R.string.screen_key_backup_disable_description_point_1)) + SecureBackupDisableItem(stringResource(id = R.string.screen_key_backup_disable_description_point_2, state.appName)) + } +} + +@Composable +private fun SecureBackupDisableItem(text: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(color = ElementTheme.colors.bgActionSecondaryHovered) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = null, + tint = ElementTheme.colors.iconCriticalPrimary, + modifier = Modifier.size(24.dp) + ) + Text( + text = text, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun SecureBackupDisableViewPreview( + @PreviewParameter(SecureBackupDisableStateProvider::class) state: SecureBackupDisableState +) = ElementPreview { + SecureBackupDisableView( + state = state, + onSuccess = {}, + onBackClick = {}, + ) +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyEvents.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyEvents.kt new file mode 100644 index 0000000..644756b --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyEvents.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.enter + +sealed interface SecureBackupEnterRecoveryKeyEvents { + data class OnRecoveryKeyChange(val recoveryKey: String) : SecureBackupEnterRecoveryKeyEvents + data class ChangeRecoveryKeyFieldContentsVisibility(val visible: Boolean) : SecureBackupEnterRecoveryKeyEvents + data object Submit : SecureBackupEnterRecoveryKeyEvents + data object ClearDialog : SecureBackupEnterRecoveryKeyEvents +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt new file mode 100644 index 0000000..81b06e1 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.enter + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class SecureBackupEnterRecoveryKeyNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: SecureBackupEnterRecoveryKeyPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onEnterRecoveryKeySuccess() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + SecureBackupEnterRecoveryKeyView( + state = state, + modifier = modifier, + onSuccess = callback::onEnterRecoveryKeySuccess, + onBackClick = ::navigateUp, + ) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyPresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyPresenter.kt new file mode 100644 index 0000000..9a71cf1 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyPresenter.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.enter + +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 dev.zacsweers.metro.Inject +import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory +import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState +import io.element.android.features.securebackup.impl.tools.RecoveryKeyTools +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Inject +class SecureBackupEnterRecoveryKeyPresenter( + private val encryptionService: EncryptionService, + private val recoveryKeyTools: RecoveryKeyTools, +) : Presenter { + @Composable + override fun present(): SecureBackupEnterRecoveryKeyState { + val coroutineScope = rememberCoroutineScope() + var displayRecoveryKeyFieldContents by rememberSaveable { + mutableStateOf(false) + } + var recoveryKey by rememberSaveable { + mutableStateOf("") + } + val submitAction: MutableState> = remember { + mutableStateOf(AsyncAction.Uninitialized) + } + + fun handleEvent(event: SecureBackupEnterRecoveryKeyEvents) { + when (event) { + SecureBackupEnterRecoveryKeyEvents.ClearDialog -> { + submitAction.value = AsyncAction.Uninitialized + } + is SecureBackupEnterRecoveryKeyEvents.OnRecoveryKeyChange -> { + val previousRecoveryKey = recoveryKey + recoveryKey = if (previousRecoveryKey.isEmpty() && recoveryKeyTools.isRecoveryKeyFormatValid(event.recoveryKey)) { + // A Recovery key has been entered, remove the spaces for a better rendering + event.recoveryKey.replace("\\s+".toRegex(), "") + } else { + // Keep the recovery key as entered by the user. May contains spaces. + event.recoveryKey + } + } + SecureBackupEnterRecoveryKeyEvents.Submit -> { + // No need to remove the spaces, the SDK will do it. + coroutineScope.submitRecoveryKey(recoveryKey, submitAction) + } + is SecureBackupEnterRecoveryKeyEvents.ChangeRecoveryKeyFieldContentsVisibility -> { + displayRecoveryKeyFieldContents = event.visible + } + } + } + + return SecureBackupEnterRecoveryKeyState( + recoveryKeyViewState = RecoveryKeyViewState( + recoveryKeyUserStory = RecoveryKeyUserStory.Enter, + formattedRecoveryKey = recoveryKey, + displayTextFieldContents = displayRecoveryKeyFieldContents, + inProgress = submitAction.value.isLoading(), + ), + isSubmitEnabled = recoveryKey.isNotEmpty() && submitAction.value.isUninitialized(), + submitAction = submitAction.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.submitRecoveryKey( + recoveryKey: String, + action: MutableState> + ) = launch { + suspend { + encryptionService.recover(recoveryKey).getOrThrow() + }.runCatchingUpdatingState(action) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyState.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyState.kt new file mode 100644 index 0000000..45a6989 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyState.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.enter + +import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState +import io.element.android.libraries.architecture.AsyncAction + +data class SecureBackupEnterRecoveryKeyState( + val recoveryKeyViewState: RecoveryKeyViewState, + val isSubmitEnabled: Boolean, + val submitAction: AsyncAction, + val eventSink: (SecureBackupEnterRecoveryKeyEvents) -> Unit +) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyStateProvider.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyStateProvider.kt new file mode 100644 index 0000000..5e95b57 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyStateProvider.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.enter + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory +import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState +import io.element.android.features.securebackup.impl.setup.views.aFormattedRecoveryKey +import io.element.android.libraries.architecture.AsyncAction + +open class SecureBackupEnterRecoveryKeyStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aSecureBackupEnterRecoveryKeyState(recoveryKey = ""), + aSecureBackupEnterRecoveryKeyState(), + aSecureBackupEnterRecoveryKeyState(submitAction = AsyncAction.Loading), + aSecureBackupEnterRecoveryKeyState(submitAction = AsyncAction.Failure(Exception("A Failure"))), + aSecureBackupEnterRecoveryKeyState(displayTextFieldContents = false), + ) +} + +fun aSecureBackupEnterRecoveryKeyState( + recoveryKey: String = aFormattedRecoveryKey(), + isSubmitEnabled: Boolean = recoveryKey.isNotEmpty(), + displayTextFieldContents: Boolean = true, + submitAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (SecureBackupEnterRecoveryKeyEvents) -> Unit = {}, +) = SecureBackupEnterRecoveryKeyState( + recoveryKeyViewState = RecoveryKeyViewState( + recoveryKeyUserStory = RecoveryKeyUserStory.Enter, + formattedRecoveryKey = recoveryKey, + displayTextFieldContents = displayTextFieldContents, + inProgress = submitAction.isLoading(), + ), + isSubmitEnabled = isSubmitEnabled, + submitAction = submitAction, + eventSink = eventSink, +) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt new file mode 100644 index 0000000..8b2bc4d --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.enter + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.securebackup.impl.R +import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds + +@Composable +fun SecureBackupEnterRecoveryKeyView( + state: SecureBackupEnterRecoveryKeyState, + onSuccess: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + AsyncActionView( + async = state.submitAction, + onSuccess = { onSuccess() }, + progressDialog = { }, + errorTitle = { stringResource(id = R.string.screen_recovery_key_confirm_error_title) }, + errorMessage = { stringResource(id = R.string.screen_recovery_key_confirm_error_content) }, + onErrorDismiss = { state.eventSink(SecureBackupEnterRecoveryKeyEvents.ClearDialog) }, + ) + + FlowStepPage( + modifier = modifier, + isScrollable = true, + onBackClick = onBackClick, + iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()), + title = stringResource(id = R.string.screen_recovery_key_confirm_title), + subTitle = stringResource(id = R.string.screen_recovery_key_confirm_description), + buttons = { Buttons(state = state) } + ) { + Content(state = state) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun Content( + state: SecureBackupEnterRecoveryKeyState, +) { + val bringIntoViewRequester = remember { BringIntoViewRequester() } + var isFocused by remember { mutableStateOf(false) } + val isImeVisible = WindowInsets.isImeVisible + val coroutineScope = rememberCoroutineScope() + LaunchedEffect(isImeVisible, isFocused) { + // When the keyboard is shown, we want to scroll the text field into view + if (isImeVisible && isFocused) { + coroutineScope.launch { + // Delay to ensure the keyboard is fully shown + delay(100.milliseconds) + bringIntoViewRequester.bringIntoView() + } + } + } + RecoveryKeyView( + modifier = Modifier + .onFocusChanged { isFocused = it.isFocused } + .bringIntoViewRequester(bringIntoViewRequester) + .padding(top = 52.dp, bottom = 32.dp), + state = state.recoveryKeyViewState, + onClick = null, + onChange = { + state.eventSink.invoke(SecureBackupEnterRecoveryKeyEvents.OnRecoveryKeyChange(it)) + }, + onSubmit = { + state.eventSink.invoke(SecureBackupEnterRecoveryKeyEvents.Submit) + }, + toggleRecoveryKeyVisibility = { + state.eventSink(SecureBackupEnterRecoveryKeyEvents.ChangeRecoveryKeyFieldContentsVisibility(it)) + } + ) +} + +@Composable +private fun ColumnScope.Buttons( + state: SecureBackupEnterRecoveryKeyState, +) { + Button( + text = stringResource(id = CommonStrings.action_continue), + enabled = state.isSubmitEnabled, + showProgress = state.submitAction.isLoading(), + modifier = Modifier.fillMaxWidth(), + onClick = { + state.eventSink.invoke(SecureBackupEnterRecoveryKeyEvents.Submit) + } + ) +} + +@PreviewsDayNight +@Composable +internal fun SecureBackupEnterRecoveryKeyViewPreview( + @PreviewParameter(SecureBackupEnterRecoveryKeyStateProvider::class) state: SecureBackupEnterRecoveryKeyState +) = ElementPreview { + SecureBackupEnterRecoveryKeyView( + state = state, + onSuccess = {}, + onBackClick = {}, + ) +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowManager.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowManager.kt new file mode 100644 index 0000000..dce1a35 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowManager.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.reset + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle +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.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +@Inject +class ResetIdentityFlowManager( + private val encryptionService: EncryptionService, + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, + private val sessionVerificationService: SessionVerificationService, +) { + private val resetHandleFlow: MutableStateFlow> = MutableStateFlow(AsyncData.Uninitialized) + val currentHandleFlow: StateFlow> = resetHandleFlow + private var whenResetIsDoneWaitingJob: Job? = null + + fun whenResetIsDone(block: () -> Unit) { + whenResetIsDoneWaitingJob = sessionCoroutineScope.launch { + sessionVerificationService.sessionVerifiedStatus.filterIsInstance().first() + block() + } + } + + fun getResetHandle(): StateFlow> { + return if (resetHandleFlow.value.isLoading() || resetHandleFlow.value.isSuccess()) { + resetHandleFlow + } else { + resetHandleFlow.value = AsyncData.Loading() + + sessionCoroutineScope.launch { + encryptionService.startIdentityReset() + .onSuccess { handle -> + resetHandleFlow.value = AsyncData.Success(handle) + } + .onFailure { + resetHandleFlow.value = AsyncData.Failure(it) + } + } + + resetHandleFlow + } + } + + suspend fun cancel() { + currentHandleFlow.value.dataOrNull()?.cancel() + resetHandleFlow.value = AsyncData.Uninitialized + + whenResetIsDoneWaitingJob?.cancel() + whenResetIsDoneWaitingJob = null + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowNode.kt new file mode 100644 index 0000000..297fd53 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowNode.kt @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.reset + +import android.app.Activity +import android.os.Parcelable +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.securebackup.impl.reset.password.ResetIdentityPasswordNode +import io.element.android.features.securebackup.impl.reset.root.ResetIdentityRootNode +import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.encryption.IdentityOidcResetHandle +import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetHandle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import timber.log.Timber + +@ContributesNode(SessionScope::class) +@AssistedInject +class ResetIdentityFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val resetIdentityFlowManager: ResetIdentityFlowManager, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, +) : BaseFlowNode( + backstack = BackStack(initialElement = NavTarget.Root, savedStateMap = buildContext.savedStateMap), + buildContext = buildContext, + plugins = plugins, +) { + interface Callback : Plugin { + fun onDone() + } + + private val callback: Callback = callback() + + sealed interface NavTarget : Parcelable { + @Parcelize + data object Root : NavTarget + + @Parcelize + data object ResetPassword : NavTarget + } + + private lateinit var activity: Activity + private var darkTheme: Boolean = false + private var resetJob: Job? = null + + override fun onBuilt() { + super.onBuilt() + + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + // If the custom tab / Web browser was opened, we need to cancel the reset job + // when we come back to the node if the reset wasn't successful + sessionCoroutineScope.launch { + cancelResetJob() + + resetIdentityFlowManager.whenResetIsDone { + callback.onDone() + } + } + } + + override fun onDestroy(owner: LifecycleOwner) { + // Make sure we cancel the reset job when the node is destroyed, just in case + sessionCoroutineScope.launch { cancelResetJob() } + } + }) + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.Root -> { + val callback = object : ResetIdentityRootNode.Callback { + override fun onContinue() { + sessionCoroutineScope.startReset() + } + } + createNode(buildContext, listOf(callback)) + } + is NavTarget.ResetPassword -> { + val handle = resetIdentityFlowManager.currentHandleFlow.value.dataOrNull() as? IdentityPasswordResetHandle ?: error("No password handle found") + createNode( + buildContext, + listOf(ResetIdentityPasswordNode.Inputs(handle)) + ) + } + } + } + + private fun CoroutineScope.startReset() = launch { + resetIdentityFlowManager.getResetHandle() + .collectLatest { state -> + when (state) { + is AsyncData.Failure -> { + cancelResetJob() + Timber.e(state.error, "Could not load the reset identity handle.") + } + is AsyncData.Success -> { + when (val handle = state.data) { + null -> { + Timber.d("No reset handle return, the reset is done.") + } + is IdentityOidcResetHandle -> { + activity.openUrlInChromeCustomTab(null, darkTheme, handle.url) + resetJob = launch { handle.resetOidc() } + } + is IdentityPasswordResetHandle -> backstack.push(NavTarget.ResetPassword) + } + } + else -> Unit + } + } + } + + private suspend fun cancelResetJob() { + resetJob?.cancel() + resetJob = null + resetIdentityFlowManager.cancel() + } + + @Composable + override fun View(modifier: Modifier) { + // Workaround to get the current activity + if (!this::activity.isInitialized) { + activity = requireNotNull(LocalActivity.current) + } + darkTheme = !ElementTheme.isLightTheme + val startResetState by resetIdentityFlowManager.currentHandleFlow.collectAsState() + if (startResetState.isLoading()) { + ProgressDialog( + properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true), + onDismissRequest = { sessionCoroutineScope.launch { cancelResetJob() } } + ) + } + + BackstackView(modifier) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordEvent.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordEvent.kt new file mode 100644 index 0000000..8fd008f --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordEvent.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.reset.password + +sealed interface ResetIdentityPasswordEvent { + data class Reset(val password: String) : ResetIdentityPasswordEvent + data object DismissError : ResetIdentityPasswordEvent +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordNode.kt new file mode 100644 index 0000000..539b185 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordNode.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.reset.password + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetHandle + +@ContributesNode(SessionScope::class) +@AssistedInject +class ResetIdentityPasswordNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + coroutineDispatchers: CoroutineDispatchers, +) : Node(buildContext, plugins = plugins) { + data class Inputs(val handle: IdentityPasswordResetHandle) : NodeInputs + + private val presenter = ResetIdentityPasswordPresenter( + identityPasswordResetHandle = inputs().handle, + dispatchers = coroutineDispatchers + ) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + ResetIdentityPasswordView( + state = state, + onBack = ::navigateUp + ) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordPresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordPresenter.kt new file mode 100644 index 0000000..8f99fc2 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordPresenter.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.reset.password + +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.libraries.architecture.AsyncAction +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.matrix.api.encryption.IdentityPasswordResetHandle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +class ResetIdentityPasswordPresenter( + private val identityPasswordResetHandle: IdentityPasswordResetHandle, + private val dispatchers: CoroutineDispatchers, +) : Presenter { + @Composable + override fun present(): ResetIdentityPasswordState { + val coroutineScope = rememberCoroutineScope() + + val resetAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + + fun handleEvent(event: ResetIdentityPasswordEvent) { + when (event) { + is ResetIdentityPasswordEvent.Reset -> coroutineScope.reset(event.password, resetAction) + ResetIdentityPasswordEvent.DismissError -> resetAction.value = AsyncAction.Uninitialized + } + } + + return ResetIdentityPasswordState( + resetAction = resetAction.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.reset(password: String, action: MutableState>) = launch(dispatchers.io) { + suspend { + identityPasswordResetHandle.resetPassword(password).getOrThrow() + }.runCatchingUpdatingState(action) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordState.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordState.kt new file mode 100644 index 0000000..87ee5e1 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordState.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.reset.password + +import io.element.android.libraries.architecture.AsyncAction + +data class ResetIdentityPasswordState( + val resetAction: AsyncAction, + val eventSink: (ResetIdentityPasswordEvent) -> Unit, +) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordStateProvider.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordStateProvider.kt new file mode 100644 index 0000000..fc4ce24 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordStateProvider.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.reset.password + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction + +class ResetIdentityPasswordStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aResetIdentityPasswordState(), + aResetIdentityPasswordState(resetAction = AsyncAction.Loading), + aResetIdentityPasswordState(resetAction = AsyncAction.Success(Unit)), + aResetIdentityPasswordState(resetAction = AsyncAction.Failure(IllegalStateException("Failed"))), + ) +} + +private fun aResetIdentityPasswordState( + resetAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (ResetIdentityPasswordEvent) -> Unit = {}, +) = ResetIdentityPasswordState( + resetAction = resetAction, + eventSink = eventSink, +) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordView.kt new file mode 100644 index 0000000..8d32433 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordView.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.reset.password + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +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.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.securebackup.impl.R +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.form.textFieldState +import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.TextField +import io.element.android.libraries.designsystem.theme.components.TextFieldValidity +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun ResetIdentityPasswordView( + state: ResetIdentityPasswordState, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val passwordState = textFieldState(stateValue = "") + FlowStepPage( + modifier = modifier, + iconStyle = BigIcon.Style.Default(CompoundIcons.LockSolid()), + title = stringResource(R.string.screen_reset_encryption_password_title), + subTitle = stringResource(R.string.screen_reset_encryption_password_subtitle), + onBackClick = onBack, + content = { + Content( + text = passwordState.value, + onTextChange = { newText -> + if (state.resetAction.isFailure()) { + state.eventSink(ResetIdentityPasswordEvent.DismissError) + } + passwordState.value = newText + }, + hasError = state.resetAction.isFailure(), + ) + }, + buttons = { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(CommonStrings.action_reset_identity), + onClick = { state.eventSink(ResetIdentityPasswordEvent.Reset(passwordState.value)) }, + destructive = true, + enabled = passwordState.value.isNotEmpty(), + ) + } + ) + + // On success we need to wait until the screen is automatically dismissed, so we keep the progress dialog + if (state.resetAction.isLoading() || state.resetAction.isSuccess()) { + ProgressDialog() + } +} + +@Composable +private fun Content(text: String, onTextChange: (String) -> Unit, hasError: Boolean) { + var showPassword by remember { mutableStateOf(false) } + TextField( + modifier = Modifier + .fillMaxWidth() + .onTabOrEnterKeyFocusNext(LocalFocusManager.current), + value = text, + onValueChange = onTextChange, + placeholder = stringResource(CommonStrings.common_password), + singleLine = true, + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + val image = + if (showPassword) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff() + val description = + if (showPassword) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password) + + Box(Modifier.clickable { showPassword = !showPassword }) { + Icon(imageVector = image, description) + } + }, + validity = if (hasError) TextFieldValidity.Invalid else TextFieldValidity.None, + supportingText = if (hasError) { + stringResource(R.string.screen_reset_encryption_password_error) + } else { + null + } + ) +} + +@PreviewsDayNight +@Composable +internal fun ResetIdentityPasswordViewPreview(@PreviewParameter(ResetIdentityPasswordStateProvider::class) state: ResetIdentityPasswordState) { + ElementPreview { + ResetIdentityPasswordView( + state = state, + onBack = {} + ) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootEvent.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootEvent.kt new file mode 100644 index 0000000..8da89e5 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootEvent.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.reset.root + +sealed interface ResetIdentityRootEvent { + data object Continue : ResetIdentityRootEvent + data object DismissDialog : ResetIdentityRootEvent +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootNode.kt new file mode 100644 index 0000000..057360c --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootNode.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.reset.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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class ResetIdentityRootNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onContinue() + } + + private val callback: Callback = callback() + private val presenter = ResetIdentityRootPresenter() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + ResetIdentityRootView( + modifier = modifier, + state = state, + onContinue = callback::onContinue, + onBack = ::navigateUp, + ) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootPresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootPresenter.kt new file mode 100644 index 0000000..90fe89b --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootPresenter.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.reset.root + +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 + +class ResetIdentityRootPresenter : Presenter { + @Composable + override fun present(): ResetIdentityRootState { + var displayConfirmDialog by remember { mutableStateOf(false) } + + fun handleEvent(event: ResetIdentityRootEvent) { + displayConfirmDialog = when (event) { + ResetIdentityRootEvent.Continue -> true + ResetIdentityRootEvent.DismissDialog -> false + } + } + + return ResetIdentityRootState( + displayConfirmationDialog = displayConfirmDialog, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootState.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootState.kt new file mode 100644 index 0000000..994f106 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootState.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.reset.root + +data class ResetIdentityRootState( + val displayConfirmationDialog: Boolean, + val eventSink: (ResetIdentityRootEvent) -> Unit, +) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootStateProvider.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootStateProvider.kt new file mode 100644 index 0000000..6974013 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootStateProvider.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.reset.root + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +class ResetIdentityRootStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + ResetIdentityRootState( + displayConfirmationDialog = false, + eventSink = {} + ), + ResetIdentityRootState( + displayConfirmationDialog = true, + eventSink = {} + ) + ) +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootView.kt new file mode 100644 index 0000000..97c82f4 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootView.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.reset.root + +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.foundation.layout.size +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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.securebackup.impl.R +import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem +import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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 kotlinx.collections.immutable.persistentListOf + +@Composable +fun ResetIdentityRootView( + state: ResetIdentityRootState, + onContinue: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + FlowStepPage( + modifier = modifier, + iconStyle = BigIcon.Style.AlertSolid, + title = stringResource(R.string.screen_encryption_reset_title), + isScrollable = true, + content = { Content() }, + buttons = { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.screen_encryption_reset_action_continue_reset), + onClick = { state.eventSink(ResetIdentityRootEvent.Continue) }, + destructive = true, + ) + }, + onBackClick = onBack, + ) + + if (state.displayConfirmationDialog) { + ConfirmationDialog( + title = stringResource(R.string.screen_reset_encryption_confirmation_alert_title), + content = stringResource(R.string.screen_reset_encryption_confirmation_alert_subtitle), + submitText = stringResource(R.string.screen_reset_encryption_confirmation_alert_action), + onSubmitClick = { + state.eventSink(ResetIdentityRootEvent.DismissDialog) + onContinue() + }, + destructiveSubmit = true, + onDismiss = { state.eventSink(ResetIdentityRootEvent.DismissDialog) } + ) + } +} + +@Composable +private fun Content() { + Column( + modifier = Modifier.padding(top = 8.dp, bottom = 40.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + InfoListOrganism( + modifier = Modifier.fillMaxWidth(), + items = persistentListOf( + InfoListItem( + message = stringResource(R.string.screen_encryption_reset_bullet_1), + iconComposable = { + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.Check(), + contentDescription = null, + tint = ElementTheme.colors.iconSuccessPrimary, + ) + }, + ), + InfoListItem( + message = stringResource(R.string.screen_encryption_reset_bullet_2), + iconComposable = { + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.Info(), + contentDescription = null, + tint = ElementTheme.colors.iconSecondary, + ) + }, + ), + InfoListItem( + message = stringResource(R.string.screen_encryption_reset_bullet_3), + iconComposable = { + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.Info(), + contentDescription = null, + tint = ElementTheme.colors.iconSecondary, + ) + }, + ), + ), + ) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.screen_encryption_reset_footer), + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textActionPrimary, + textAlign = TextAlign.Center, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun ResetIdentityRootViewPreview(@PreviewParameter(ResetIdentityRootStateProvider::class) state: ResetIdentityRootState) { + ElementPreview { + ResetIdentityRootView( + state = state, + onContinue = {}, + onBack = {}, + ) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootEvents.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootEvents.kt new file mode 100644 index 0000000..9d371e3 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootEvents.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.root + +sealed interface SecureBackupRootEvents { + data object RetryKeyBackupState : SecureBackupRootEvents + data object EnableKeyStorage : SecureBackupRootEvents + data object DisplayKeyStorageDisabledError : SecureBackupRootEvents + data object DismissDialog : SecureBackupRootEvents +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt new file mode 100644 index 0000000..329a406 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.root + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.appconfig.LearnMoreConfig +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class SecureBackupRootNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: SecureBackupRootPresenter, +) : Node( + buildContext = buildContext, + plugins = plugins +) { + interface Callback : Plugin { + fun navigateToSetup() + fun navigateToChange() + fun navigateToDisable() + fun navigateToEnterRecoveryKey() + } + + private val callback: Callback = callback() + + private fun onLearnMoreClick(uriHandler: UriHandler) { + uriHandler.openUri(LearnMoreConfig.SECURE_BACKUP_URL) + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val uriHandler = LocalUriHandler.current + SecureBackupRootView( + state = state, + onBackClick = ::navigateUp, + onSetupClick = callback::navigateToSetup, + onChangeClick = callback::navigateToChange, + onDisableClick = callback::navigateToDisable, + onConfirmRecoveryKeyClick = callback::navigateToEnterRecoveryKey, + onLearnMoreClick = { onLearnMoreClick(uriHandler) }, + modifier = modifier, + ) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootPresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootPresenter.kt new file mode 100644 index 0000000..9a97bfa --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootPresenter.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.features.securebackup.impl.loggerTagDisable +import io.element.android.features.securebackup.impl.loggerTagRoot +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +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.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.matrix.api.encryption.BackupState +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +@Inject +class SecureBackupRootPresenter( + private val encryptionService: EncryptionService, + private val buildMeta: BuildMeta, + private val snackbarDispatcher: SnackbarDispatcher, +) : Presenter { + @Composable + override fun present(): SecureBackupRootState { + val localCoroutineScope = rememberCoroutineScope() + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + + val backupState by encryptionService.backupStateStateFlow.collectAsState() + val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() + val enableAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + var displayKeyStorageDisabledError by remember { mutableStateOf(false) } + Timber.tag(loggerTagRoot.value).d("backupState: $backupState") + Timber.tag(loggerTagRoot.value).d("recoveryState: $recoveryState") + + val doesBackupExistOnServerAction: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) } + + LaunchedEffect(backupState) { + if (backupState == BackupState.UNKNOWN) { + getKeyBackupStatus(doesBackupExistOnServerAction) + } + } + + fun handleEvent(event: SecureBackupRootEvents) { + when (event) { + SecureBackupRootEvents.RetryKeyBackupState -> localCoroutineScope.getKeyBackupStatus(doesBackupExistOnServerAction) + SecureBackupRootEvents.EnableKeyStorage -> localCoroutineScope.enableBackup(enableAction) + SecureBackupRootEvents.DismissDialog -> { + enableAction.value = AsyncAction.Uninitialized + displayKeyStorageDisabledError = false + } + SecureBackupRootEvents.DisplayKeyStorageDisabledError -> displayKeyStorageDisabledError = true + } + } + + return SecureBackupRootState( + enableAction = enableAction.value, + backupState = backupState, + doesBackupExistOnServer = doesBackupExistOnServerAction.value, + recoveryState = recoveryState, + appName = buildMeta.applicationName, + displayKeyStorageDisabledError = displayKeyStorageDisabledError, + snackbarMessage = snackbarMessage, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.getKeyBackupStatus(action: MutableState>) = launch { + suspend { + encryptionService.doesBackupExistOnServer().getOrThrow() + }.runCatchingUpdatingState(action) + } + + private fun CoroutineScope.enableBackup(action: MutableState>) = launch { + suspend { + Timber.tag(loggerTagDisable.value).d("Calling encryptionService.enableBackups()") + encryptionService.enableBackups().getOrThrow() + }.runCatchingUpdatingState(action) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootState.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootState.kt new file mode 100644 index 0000000..1cbf917 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootState.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.root + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.matrix.api.encryption.BackupState +import io.element.android.libraries.matrix.api.encryption.RecoveryState + +data class SecureBackupRootState( + val enableAction: AsyncAction, + val backupState: BackupState, + val doesBackupExistOnServer: AsyncData, + val recoveryState: RecoveryState, + val appName: String, + val displayKeyStorageDisabledError: Boolean, + val snackbarMessage: SnackbarMessage?, + val eventSink: (SecureBackupRootEvents) -> Unit, +) { + val isKeyStorageEnabled: Boolean + get() = when (backupState) { + BackupState.UNKNOWN -> doesBackupExistOnServer.dataOrNull() == true + BackupState.CREATING, + BackupState.ENABLING, + BackupState.RESUMING, + BackupState.DOWNLOADING, + BackupState.ENABLED -> true + BackupState.WAITING_FOR_SYNC, + BackupState.DISABLING -> false + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootStateProvider.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootStateProvider.kt new file mode 100644 index 0000000..5dc3bea --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootStateProvider.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.root + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.matrix.api.encryption.BackupState +import io.element.android.libraries.matrix.api.encryption.RecoveryState + +open class SecureBackupRootStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aSecureBackupRootState(backupState = BackupState.UNKNOWN, doesBackupExistOnServer = AsyncData.Uninitialized), + aSecureBackupRootState(backupState = BackupState.UNKNOWN, doesBackupExistOnServer = AsyncData.Success(true)), + aSecureBackupRootState(backupState = BackupState.UNKNOWN, doesBackupExistOnServer = AsyncData.Success(false)), + aSecureBackupRootState(backupState = BackupState.UNKNOWN, doesBackupExistOnServer = AsyncData.Failure(Exception("An error"))), + aSecureBackupRootState(backupState = BackupState.WAITING_FOR_SYNC), + aSecureBackupRootState(backupState = BackupState.CREATING), + aSecureBackupRootState( + backupState = BackupState.CREATING, + enableAction = AsyncAction.Failure(Exception("Error")), + ), + aSecureBackupRootState(backupState = BackupState.ENABLING), + aSecureBackupRootState(backupState = BackupState.RESUMING), + aSecureBackupRootState(backupState = BackupState.DOWNLOADING), + aSecureBackupRootState(backupState = BackupState.DISABLING), + aSecureBackupRootState(backupState = BackupState.ENABLED), + aSecureBackupRootState(backupState = BackupState.ENABLED, recoveryState = RecoveryState.UNKNOWN), + aSecureBackupRootState(backupState = BackupState.ENABLED, recoveryState = RecoveryState.ENABLED), + aSecureBackupRootState(backupState = BackupState.ENABLED, recoveryState = RecoveryState.DISABLED), + aSecureBackupRootState(backupState = BackupState.ENABLED, recoveryState = RecoveryState.INCOMPLETE), + aSecureBackupRootState( + backupState = BackupState.UNKNOWN, + doesBackupExistOnServer = AsyncData.Success(false), + recoveryState = RecoveryState.ENABLED, + ), + aSecureBackupRootState( + backupState = BackupState.UNKNOWN, + doesBackupExistOnServer = AsyncData.Success(false), + recoveryState = RecoveryState.ENABLED, + displayKeyStorageDisabledError = true, + ), + ) +} + +fun aSecureBackupRootState( + enableAction: AsyncAction = AsyncAction.Uninitialized, + backupState: BackupState = BackupState.UNKNOWN, + doesBackupExistOnServer: AsyncData = AsyncData.Uninitialized, + recoveryState: RecoveryState = RecoveryState.UNKNOWN, + displayKeyStorageDisabledError: Boolean = false, + snackbarMessage: SnackbarMessage? = null, +) = SecureBackupRootState( + enableAction = enableAction, + backupState = backupState, + doesBackupExistOnServer = doesBackupExistOnServer, + recoveryState = recoveryState, + appName = "Element", + displayKeyStorageDisabledError = displayKeyStorageDisabledError, + snackbarMessage = snackbarMessage, + eventSink = {}, +) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt new file mode 100644 index 0000000..0aff81a --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.root + +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.runtime.Composable +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.compound.theme.ElementTheme +import io.element.android.features.securebackup.impl.R +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost +import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState +import io.element.android.libraries.matrix.api.encryption.BackupState +import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun SecureBackupRootView( + state: SecureBackupRootState, + onBackClick: () -> Unit, + onSetupClick: () -> Unit, + onChangeClick: () -> Unit, + onDisableClick: () -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, + onLearnMoreClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) + + PreferencePage( + modifier = modifier, + onBackClick = onBackClick, + title = stringResource(id = CommonStrings.common_encryption), + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { + ListItem( + headlineContent = { + Text( + text = stringResource(id = R.string.screen_chat_backup_key_backup_title), + ) + }, + supportingContent = { + Text( + text = buildAnnotatedStringWithStyledPart( + fullTextRes = R.string.screen_chat_backup_key_backup_description, + coloredTextRes = CommonStrings.action_learn_more, + color = ElementTheme.colors.textPrimary, + underline = false, + bold = true, + ), + ) + }, + onClick = onLearnMoreClick, + ) + + // Disable / Enable key storage + ListItem( + headlineContent = { + Text( + text = stringResource(id = R.string.screen_chat_backup_key_storage_toggle_title), + ) + }, + trailingContent = when (state.backupState) { + BackupState.WAITING_FOR_SYNC, + BackupState.DISABLING -> ListItemContent.Custom { LoadingView() } + BackupState.UNKNOWN -> { + when (state.doesBackupExistOnServer) { + is AsyncData.Success -> { + ListItemContent.Switch(checked = state.doesBackupExistOnServer.data) + } + is AsyncData.Loading, + AsyncData.Uninitialized -> ListItemContent.Custom { LoadingView() } + is AsyncData.Failure -> ListItemContent.Custom { + Text( + text = stringResource(id = CommonStrings.action_retry) + ) + } + } + } + BackupState.CREATING, + BackupState.ENABLING, + BackupState.RESUMING, + BackupState.ENABLED, + BackupState.DOWNLOADING -> ListItemContent.Switch(checked = true) + }, + onClick = { + when (state.backupState) { + BackupState.WAITING_FOR_SYNC, + BackupState.DISABLING -> Unit + BackupState.UNKNOWN -> { + when (state.doesBackupExistOnServer) { + is AsyncData.Success -> { + if (state.doesBackupExistOnServer.data) { + onDisableClick() + } else { + state.eventSink.invoke(SecureBackupRootEvents.EnableKeyStorage) + } + } + is AsyncData.Loading, + AsyncData.Uninitialized -> Unit + is AsyncData.Failure -> state.eventSink.invoke(SecureBackupRootEvents.RetryKeyBackupState) + } + } + BackupState.CREATING, + BackupState.ENABLING, + BackupState.RESUMING, + BackupState.ENABLED, + BackupState.DOWNLOADING -> onDisableClick() + } + }, + ) + HorizontalDivider() + // Setup recovery + when (state.recoveryState) { + RecoveryState.UNKNOWN, + RecoveryState.WAITING_FOR_SYNC -> Unit + RecoveryState.DISABLED -> { + ListItem( + headlineContent = { + Text( + text = stringResource(id = R.string.screen_chat_backup_recovery_action_setup), + ) + }, + supportingContent = { + Text( + text = stringResource(id = R.string.screen_chat_backup_recovery_action_setup_description, state.appName), + ) + }, + trailingContent = ListItemContent.Badge, + enabled = state.isKeyStorageEnabled, + alwaysClickable = true, + onClick = { + if (state.isKeyStorageEnabled) { + onSetupClick() + } else { + state.eventSink.invoke(SecureBackupRootEvents.DisplayKeyStorageDisabledError) + } + }, + ) + } + RecoveryState.ENABLED -> { + ListItem( + headlineContent = { + Text( + text = stringResource(id = R.string.screen_chat_backup_recovery_action_change), + ) + }, + supportingContent = { + Text( + text = stringResource(id = R.string.screen_chat_backup_recovery_action_change_description), + ) + }, + enabled = state.isKeyStorageEnabled, + alwaysClickable = true, + onClick = { + if (state.isKeyStorageEnabled) { + onChangeClick() + } else { + state.eventSink.invoke(SecureBackupRootEvents.DisplayKeyStorageDisabledError) + } + }, + ) + } + RecoveryState.INCOMPLETE -> + ListItem( + headlineContent = { + Text( + text = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm), + ) + }, + supportingContent = { + Text( + text = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm_description), + ) + }, + trailingContent = ListItemContent.Badge, + enabled = state.isKeyStorageEnabled, + alwaysClickable = true, + onClick = { + if (state.isKeyStorageEnabled) { + onConfirmRecoveryKeyClick() + } else { + state.eventSink.invoke(SecureBackupRootEvents.DisplayKeyStorageDisabledError) + } + }, + ) + } + } + + AsyncActionView( + async = state.enableAction, + progressDialog = { }, + onSuccess = { }, + onErrorDismiss = { state.eventSink.invoke(SecureBackupRootEvents.DismissDialog) } + ) + if (state.displayKeyStorageDisabledError) { + ErrorDialog( + title = null, + content = stringResource(id = R.string.screen_chat_backup_key_storage_disabled_error), + onSubmit = { state.eventSink.invoke(SecureBackupRootEvents.DismissDialog) }, + ) + } +} + +@Composable +private fun LoadingView() { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(24.dp), + strokeWidth = 2.dp + ) +} + +@PreviewsDayNight +@Composable +internal fun SecureBackupRootViewPreview( + @PreviewParameter(SecureBackupRootStateProvider::class) state: SecureBackupRootState +) = ElementPreview { + SecureBackupRootView( + state = state, + onBackClick = {}, + onSetupClick = {}, + onChangeClick = {}, + onDisableClick = {}, + onConfirmRecoveryKeyClick = {}, + onLearnMoreClick = {}, + ) +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvents.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvents.kt new file mode 100644 index 0000000..f61e65b --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvents.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.setup + +sealed interface SecureBackupSetupEvents { + data object CreateRecoveryKey : SecureBackupSetupEvents + data object RecoveryKeyHasBeenSaved : SecureBackupSetupEvents + data object Done : SecureBackupSetupEvents + data object DismissDialog : SecureBackupSetupEvents +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupNode.kt new file mode 100644 index 0000000..e0f57cb --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupNode.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.setup + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.securebackup.impl.R +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class SecureBackupSetupNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: SecureBackupSetupPresenter.Factory, + private val snackbarDispatcher: SnackbarDispatcher, +) : Node(buildContext, plugins = plugins) { + data class Inputs( + val isChangeRecoveryKeyUserStory: Boolean, + ) : NodeInputs + + private val inputs = inputs() + + private val presenter = presenterFactory.create(inputs.isChangeRecoveryKeyUserStory) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + SecureBackupSetupView( + state = state, + onSuccess = { + postSuccessSnackbar() + navigateUp() + }, + onBackClick = ::navigateUp, + modifier = modifier, + ) + } + + private fun postSuccessSnackbar() { + snackbarDispatcher.post( + SnackbarMessage( + messageResId = if (inputs.isChangeRecoveryKeyUserStory) { + R.string.screen_recovery_key_change_success + } else { + R.string.screen_recovery_key_setup_success + } + ) + ) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt new file mode 100644 index 0000000..9f27bc9 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.securebackup.impl.setup + +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import com.freeletics.flowredux.compose.StateAndDispatch +import com.freeletics.flowredux.compose.rememberStateAndDispatch +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.securebackup.impl.loggerTagSetup +import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory +import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import timber.log.Timber + +@AssistedInject +class SecureBackupSetupPresenter( + @Assisted private val isChangeRecoveryKeyUserStory: Boolean, + private val stateMachine: SecureBackupSetupStateMachine, + private val encryptionService: EncryptionService, +) : Presenter { + @AssistedFactory + interface Factory { + fun create(isChangeRecoveryKeyUserStory: Boolean): SecureBackupSetupPresenter + } + + @Composable + override fun present(): SecureBackupSetupState { + val coroutineScope = rememberCoroutineScope() + val stateAndDispatch = stateMachine.rememberStateAndDispatch() + val setupState by remember { + derivedStateOf { stateAndDispatch.state.value.toSetupState() } + } + var showSaveConfirmationDialog by remember { mutableStateOf(false) } + + fun handleEvent(event: SecureBackupSetupEvents) { + when (event) { + SecureBackupSetupEvents.CreateRecoveryKey -> { + coroutineScope.createOrChangeRecoveryKey(stateAndDispatch) + } + SecureBackupSetupEvents.RecoveryKeyHasBeenSaved -> + stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey) + SecureBackupSetupEvents.DismissDialog -> { + showSaveConfirmationDialog = false + stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.ClearError) + } + SecureBackupSetupEvents.Done -> { + showSaveConfirmationDialog = true + } + } + } + + val recoveryKeyViewState = RecoveryKeyViewState( + recoveryKeyUserStory = if (isChangeRecoveryKeyUserStory) RecoveryKeyUserStory.Change else RecoveryKeyUserStory.Setup, + formattedRecoveryKey = setupState.recoveryKey(), + displayTextFieldContents = true, + inProgress = setupState is SetupState.Creating, + ) + + return SecureBackupSetupState( + isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory, + recoveryKeyViewState = recoveryKeyViewState, + setupState = setupState, + showSaveConfirmationDialog = showSaveConfirmationDialog, + eventSink = ::handleEvent, + ) + } + + private fun SecureBackupSetupStateMachine.State?.toSetupState(): SetupState { + return when (this) { + null, + SecureBackupSetupStateMachine.State.Initial -> SetupState.Init + SecureBackupSetupStateMachine.State.CreatingKey -> SetupState.Creating + is SecureBackupSetupStateMachine.State.KeyCreated -> SetupState.Created(formattedRecoveryKey = key) + is SecureBackupSetupStateMachine.State.KeyCreatedAndSaved -> SetupState.CreatedAndSaved(formattedRecoveryKey = key) + is SecureBackupSetupStateMachine.State.Error -> SetupState.Error(exception) + } + } + + private fun CoroutineScope.createOrChangeRecoveryKey( + stateAndDispatch: StateAndDispatch + ) = launch { + stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserCreatesKey) + if (isChangeRecoveryKeyUserStory) { + Timber.tag(loggerTagSetup.value).d("Calling encryptionService.resetRecoveryKey()") + encryptionService.resetRecoveryKey().fold( + onSuccess = { + stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(it)) + }, + onFailure = { + if (it is Exception) { + stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(it)) + } + } + ) + } else { + observeEncryptionService(stateAndDispatch) + Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery()") + encryptionService.enableRecovery(waitForBackupsToUpload = false).onFailure { + Timber.tag(loggerTagSetup.value).e(it, "Failed to enable recovery") + if (it is Exception) { + stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(it)) + } + } + } + } + + private fun CoroutineScope.observeEncryptionService( + stateAndDispatch: StateAndDispatch + ) = launch { + encryptionService.enableRecoveryProgressStateFlow.collect { enableRecoveryProgress -> + Timber.tag(loggerTagSetup.value).d("New enableRecoveryProgress: ${enableRecoveryProgress.javaClass.simpleName}") + when (enableRecoveryProgress) { + is EnableRecoveryProgress.Starting, + is EnableRecoveryProgress.CreatingBackup, + is EnableRecoveryProgress.CreatingRecoveryKey, + is EnableRecoveryProgress.BackingUp, + is EnableRecoveryProgress.RoomKeyUploadError -> Unit + is EnableRecoveryProgress.Done -> + stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(enableRecoveryProgress.recoveryKey)) + } + } + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupState.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupState.kt new file mode 100644 index 0000000..752b5e4 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupState.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.setup + +import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState + +data class SecureBackupSetupState( + val isChangeRecoveryKeyUserStory: Boolean, + val recoveryKeyViewState: RecoveryKeyViewState, + val showSaveConfirmationDialog: Boolean, + val setupState: SetupState, + val eventSink: (SecureBackupSetupEvents) -> Unit +) + +sealed interface SetupState { + data object Init : SetupState + data object Creating : SetupState + data class Created(val formattedRecoveryKey: String) : SetupState + data class CreatedAndSaved(val formattedRecoveryKey: String) : SetupState + data class Error(val exception: Exception) : SetupState +} + +fun SetupState.recoveryKey(): String? = when (this) { + is SetupState.Created -> formattedRecoveryKey + is SetupState.CreatedAndSaved -> formattedRecoveryKey + else -> null +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateMachine.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateMachine.kt new file mode 100644 index 0000000..150aeed --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateMachine.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:Suppress("WildcardImport") +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.securebackup.impl.setup + +import com.freeletics.flowredux.dsl.FlowReduxStateMachine +import dev.zacsweers.metro.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import com.freeletics.flowredux.dsl.State as MachineState + +@Inject +class SecureBackupSetupStateMachine : FlowReduxStateMachine( + initialState = State.Initial +) { + init { + spec { + inState { + on { _: Event.UserCreatesKey, state: MachineState -> + state.override { State.CreatingKey } + } + } + inState { + on { event: Event.SdkError, state: MachineState -> + state.override { State.Error(event.exception) } + } + on { event: Event.SdkHasCreatedKey, state: MachineState -> + state.override { State.KeyCreated(event.key) } + } + } + inState { + on { _: Event.UserSavedKey, state: MachineState -> + state.override { State.KeyCreatedAndSaved(state.snapshot.key) } + } + } + inState { + on { _: Event.ClearError, state: MachineState -> + state.override { State.Initial } + } + } + inState { + } + } + } + + sealed interface State { + data object Initial : State + data object CreatingKey : State + data class KeyCreated(val key: String) : State + data class KeyCreatedAndSaved(val key: String) : State + data class Error(val exception: Exception) : State + } + + sealed interface Event { + data object UserCreatesKey : Event + data class SdkHasCreatedKey(val key: String) : Event + data class SdkError(val exception: Exception) : Event + data object UserSavedKey : Event + data object ClearError : Event + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateProvider.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateProvider.kt new file mode 100644 index 0000000..a780fa0 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateProvider.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.setup + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory +import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState +import io.element.android.features.securebackup.impl.setup.views.aFormattedRecoveryKey + +open class SecureBackupSetupStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aSecureBackupSetupState(setupState = SetupState.Init), + aSecureBackupSetupState(setupState = SetupState.Creating), + aSecureBackupSetupState(setupState = SetupState.Created(aFormattedRecoveryKey())), + aSecureBackupSetupState(setupState = SetupState.CreatedAndSaved(aFormattedRecoveryKey())), + aSecureBackupSetupState( + setupState = SetupState.CreatedAndSaved(aFormattedRecoveryKey()), + showSaveConfirmationDialog = true, + ), + aSecureBackupSetupState(setupState = SetupState.Error(Exception("Test error"))), + // Add other states here + ) +} + +fun aSecureBackupSetupState( + setupState: SetupState = SetupState.Init, + showSaveConfirmationDialog: Boolean = false, +) = SecureBackupSetupState( + isChangeRecoveryKeyUserStory = false, + setupState = setupState, + showSaveConfirmationDialog = showSaveConfirmationDialog, + recoveryKeyViewState = setupState.toRecoveryKeyViewState(), + eventSink = {} +) + +private fun SetupState.toRecoveryKeyViewState(): RecoveryKeyViewState { + return RecoveryKeyViewState( + recoveryKeyUserStory = RecoveryKeyUserStory.Setup, + formattedRecoveryKey = recoveryKey(), + displayTextFieldContents = true, + inProgress = this is SetupState.Creating, + ) +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt new file mode 100644 index 0000000..8a87fc8 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.setup + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.securebackup.impl.R +import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView +import io.element.android.libraries.androidutils.system.copyToClipboard +import io.element.android.libraries.androidutils.system.startSharePlainTextIntent +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun SecureBackupSetupView( + state: SecureBackupSetupState, + onSuccess: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + FlowStepPage( + modifier = modifier, + onBackClick = onBackClick.takeIf { state.canGoBack() }, + title = title(state), + subTitle = subtitle(state), + iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()), + buttons = { Buttons(state, onFinish = onSuccess) }, + ) { + Content(state = state) + } + + if (state.setupState is SetupState.Error) { + ErrorDialog( + title = stringResource(id = CommonStrings.common_something_went_wrong), + content = stringResource(id = CommonStrings.common_something_went_wrong_message), + onSubmit = { + state.eventSink.invoke(SecureBackupSetupEvents.DismissDialog) + }, + ) + } + + if (state.showSaveConfirmationDialog) { + ConfirmationDialog( + title = stringResource(id = R.string.screen_recovery_key_setup_confirmation_title), + content = stringResource(id = R.string.screen_recovery_key_setup_confirmation_description), + submitText = stringResource(id = CommonStrings.action_continue), + onSubmitClick = onSuccess, + onDismiss = { + state.eventSink.invoke(SecureBackupSetupEvents.DismissDialog) + } + ) + } +} + +private fun SecureBackupSetupState.canGoBack(): Boolean { + return recoveryKeyViewState.formattedRecoveryKey == null +} + +@Composable +private fun title(state: SecureBackupSetupState): String { + return when (state.setupState) { + SetupState.Init, + SetupState.Creating, + is SetupState.Error -> if (state.isChangeRecoveryKeyUserStory) { + stringResource(id = R.string.screen_recovery_key_change_title) + } else { + stringResource(id = R.string.screen_recovery_key_setup_title) + } + is SetupState.Created, + is SetupState.CreatedAndSaved -> + stringResource(id = R.string.screen_recovery_key_save_title) + } +} + +@Composable +private fun subtitle(state: SecureBackupSetupState): String { + return when (state.setupState) { + SetupState.Init, + SetupState.Creating, + is SetupState.Error -> if (state.isChangeRecoveryKeyUserStory) { + stringResource(id = R.string.screen_recovery_key_change_description) + } else { + stringResource(id = R.string.screen_recovery_key_setup_description) + } + is SetupState.Created, + is SetupState.CreatedAndSaved -> + stringResource(id = R.string.screen_recovery_key_save_description) + } +} + +@Composable +private fun Content( + state: SecureBackupSetupState, +) { + val context = LocalContext.current + val formattedRecoveryKey = state.recoveryKeyViewState.formattedRecoveryKey + val clickLambda = if (formattedRecoveryKey != null) { + { + context.copyToClipboard( + formattedRecoveryKey, + context.getString(R.string.screen_recovery_key_copied_to_clipboard) + ) + state.eventSink.invoke(SecureBackupSetupEvents.RecoveryKeyHasBeenSaved) + } + } else { + if (!state.recoveryKeyViewState.inProgress) { + { + state.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey) + } + } else { + null + } + } + RecoveryKeyView( + modifier = Modifier.padding(top = 52.dp), + state = state.recoveryKeyViewState, + onClick = clickLambda, + onChange = null, + onSubmit = null, + toggleRecoveryKeyVisibility = {}, + ) +} + +@Composable +private fun ColumnScope.Buttons( + state: SecureBackupSetupState, + onFinish: () -> Unit, +) { + val context = LocalContext.current + val chooserTitle = stringResource(id = R.string.screen_recovery_key_save_action) + when (state.setupState) { + SetupState.Init, + SetupState.Creating, + is SetupState.Error -> { + Button( + text = stringResource(id = CommonStrings.action_done), + enabled = false, + modifier = Modifier.fillMaxWidth(), + onClick = onFinish + ) + } + is SetupState.Created, + is SetupState.CreatedAndSaved -> { + OutlinedButton( + text = stringResource(id = R.string.screen_recovery_key_save_action), + leadingIcon = IconSource.Vector(CompoundIcons.Download()), + modifier = Modifier.fillMaxWidth(), + onClick = { + context.startSharePlainTextIntent( + activityResultLauncher = null, + chooserTitle = chooserTitle, + text = state.setupState.recoveryKey()!!, + ) + state.eventSink.invoke(SecureBackupSetupEvents.RecoveryKeyHasBeenSaved) + }, + ) + Button( + text = stringResource(id = CommonStrings.action_done), + modifier = Modifier.fillMaxWidth(), + onClick = { + if (state.setupState is SetupState.CreatedAndSaved) { + onFinish() + } else { + state.eventSink.invoke(SecureBackupSetupEvents.Done) + } + }, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun SecureBackupSetupViewPreview( + @PreviewParameter(SecureBackupSetupStateProvider::class) state: SecureBackupSetupState +) = ElementPreview { + SecureBackupSetupView( + state = state, + onSuccess = {}, + onBackClick = {}, + ) +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupViewChangePreview.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupViewChangePreview.kt new file mode 100644 index 0000000..39fa7de --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupViewChangePreview.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.setup + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +@PreviewsDayNight +@Composable +internal fun SecureBackupSetupViewChangePreview( + @PreviewParameter(SecureBackupSetupStateProvider::class) state: SecureBackupSetupState +) = ElementPreview { + SecureBackupSetupView( + state = state.copy( + isChangeRecoveryKeyUserStory = true, + recoveryKeyViewState = state.recoveryKeyViewState.copy(recoveryKeyUserStory = RecoveryKeyUserStory.Change), + ), + onSuccess = {}, + onBackClick = {}, + ) +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt new file mode 100644 index 0000000..1988914 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.setup.views + +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.Row +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.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +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.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.securebackup.impl.R +import io.element.android.features.securebackup.impl.tools.RecoveryKeyVisualTransformation +import io.element.android.libraries.designsystem.modifiers.clickableIfNotNull +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.designsystem.theme.components.TextField +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun RecoveryKeyView( + state: RecoveryKeyViewState, + onClick: (() -> Unit)?, + onChange: ((String) -> Unit)?, + onSubmit: (() -> Unit)?, + toggleRecoveryKeyVisibility: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(id = CommonStrings.common_recovery_key), + style = ElementTheme.typography.fontBodyMdRegular, + ) + RecoveryKeyContent(state, onClick, onChange, onSubmit, toggleRecoveryKeyVisibility) + RecoveryKeyFooter(state) + } +} + +@Composable +private fun RecoveryKeyContent( + state: RecoveryKeyViewState, + onClick: (() -> Unit)?, + onChange: ((String) -> Unit)?, + onSubmit: (() -> Unit)?, + toggleRecoveryKeyVisibility: (Boolean) -> Unit, +) { + when (state.recoveryKeyUserStory) { + RecoveryKeyUserStory.Setup, + RecoveryKeyUserStory.Change -> RecoveryKeyStaticContent(state, onClick) + RecoveryKeyUserStory.Enter -> RecoveryKeyFormContent( + state = state, + toggleRecoveryKeyVisibility = toggleRecoveryKeyVisibility, + onChange = onChange, + onSubmit = onSubmit, + ) + } +} + +@Composable +private fun RecoveryKeyStaticContent( + state: RecoveryKeyViewState, + onClick: (() -> Unit)?, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background( + color = ElementTheme.colors.bgSubtleSecondary, + ) + .clickableIfNotNull(onClick) + .padding(horizontal = 16.dp, vertical = 11.dp), + contentAlignment = Alignment.Center, + ) { + if (state.formattedRecoveryKey != null) { + RecoveryKeyWithCopy( + recoveryKey = state.formattedRecoveryKey, + alpha = 1f, + ) + } else { + // Use an invisible recovery key to ensure that the Box size is correct. + val fakeFormattedRecoveryKey = List(12) { "XXXX" }.joinToString(" ") + RecoveryKeyWithCopy( + recoveryKey = fakeFormattedRecoveryKey, + alpha = 0f, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (state.inProgress) { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .padding(end = 8.dp) + .size(16.dp), + color = ElementTheme.colors.textPrimary, + strokeWidth = 1.5.dp, + ) + } + Text( + text = stringResource( + id = when { + state.inProgress -> R.string.screen_recovery_key_generating_key + state.recoveryKeyUserStory == RecoveryKeyUserStory.Change -> R.string.screen_recovery_key_change_generate_key + else -> R.string.screen_recovery_key_setup_generate_key + } + ), + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodyLgMedium, + ) + } + } + } +} + +@Composable +private fun RecoveryKeyWithCopy( + recoveryKey: String, + alpha: Float, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .alpha(alpha), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = recoveryKey, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyLgRegular.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = CompoundIcons.Copy(), + contentDescription = stringResource(id = CommonStrings.action_copy), + tint = ElementTheme.colors.iconSecondary, + ) + } +} + +@Composable +private fun RecoveryKeyFormContent( + state: RecoveryKeyViewState, + toggleRecoveryKeyVisibility: (Boolean) -> Unit, + onChange: ((String) -> Unit)?, + onSubmit: (() -> Unit)?, +) { + onChange ?: error("onChange should not be null") + onSubmit ?: error("onSubmit should not be null") + if (state.inProgress) { + // Ensure recovery key is hidden when user submits the form + toggleRecoveryKeyVisibility(false) + } + val keyHasSpace = state.formattedRecoveryKey.orEmpty().contains(" ") + val recoveryKeyVisualTransformation = remember(keyHasSpace, state.displayTextFieldContents) { + if (state.displayTextFieldContents) { + // Do not apply a visual transformation if the key has spaces, to let user enter passphrase + if (keyHasSpace) VisualTransformation.None else RecoveryKeyVisualTransformation() + } else { + PasswordVisualTransformation() + } + } + TextField( + modifier = Modifier + .fillMaxWidth() + .testTag(TestTags.recoveryKey) + .semantics { + contentType = ContentType.Password + }, + minLines = 2, + value = state.formattedRecoveryKey.orEmpty(), + onValueChange = onChange, + enabled = state.inProgress.not(), + visualTransformation = recoveryKeyVisualTransformation, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { onSubmit() } + ), + placeholder = stringResource(id = R.string.screen_recovery_key_confirm_key_placeholder), + trailingIcon = { + val image = + if (state.displayTextFieldContents) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff() + val description = + if (state.displayTextFieldContents) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password) + Box(Modifier.clickable { toggleRecoveryKeyVisibility(!state.displayTextFieldContents) }) { + Icon( + imageVector = image, + contentDescription = description, + ) + } + }, + ) +} + +@Composable +private fun RecoveryKeyFooter(state: RecoveryKeyViewState) { + when (state.recoveryKeyUserStory) { + RecoveryKeyUserStory.Setup, + RecoveryKeyUserStory.Change -> { + if (state.formattedRecoveryKey == null) { + Text( + text = stringResource( + id = if (state.recoveryKeyUserStory == RecoveryKeyUserStory.Change) { + R.string.screen_recovery_key_change_generate_key_description + } else { + R.string.screen_recovery_key_setup_generate_key_description + } + ), + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmRegular, + ) + } else { + Text( + text = stringResource(id = R.string.screen_recovery_key_save_key_description), + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmRegular, + ) + } + } + RecoveryKeyUserStory.Enter -> { + Text( + text = stringResource(id = R.string.screen_recovery_key_confirm_key_description), + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmRegular, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun RecoveryKeyViewPreview( + @PreviewParameter(RecoveryKeyViewStateProvider::class) state: RecoveryKeyViewState +) = ElementPreview { + RecoveryKeyView( + state = state, + onClick = {}, + onChange = {}, + onSubmit = {}, + toggleRecoveryKeyVisibility = {}, + ) +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyViewState.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyViewState.kt new file mode 100644 index 0000000..8a8be03 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyViewState.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.setup.views + +data class RecoveryKeyViewState( + val recoveryKeyUserStory: RecoveryKeyUserStory, + val formattedRecoveryKey: String?, + val displayTextFieldContents: Boolean, + val inProgress: Boolean, +) + +enum class RecoveryKeyUserStory { + Setup, + Change, + Enter, +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyViewStateProvider.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyViewStateProvider.kt new file mode 100644 index 0000000..c1a841a --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyViewStateProvider.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.setup.views + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class RecoveryKeyViewStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf(RecoveryKeyUserStory.Setup, RecoveryKeyUserStory.Change, RecoveryKeyUserStory.Enter) + .flatMap { + sequenceOf( + aRecoveryKeyViewState(recoveryKeyUserStory = it), + aRecoveryKeyViewState(recoveryKeyUserStory = it, inProgress = true), + aRecoveryKeyViewState(recoveryKeyUserStory = it, formattedRecoveryKey = aFormattedRecoveryKey()), + aRecoveryKeyViewState(recoveryKeyUserStory = it, formattedRecoveryKey = aFormattedRecoveryKey(), inProgress = true), + ) + } + sequenceOf( + aRecoveryKeyViewState(recoveryKeyUserStory = RecoveryKeyUserStory.Enter, formattedRecoveryKey = aFormattedRecoveryKey().replace(" ", "")), + aRecoveryKeyViewState(recoveryKeyUserStory = RecoveryKeyUserStory.Enter, formattedRecoveryKey = "This is a passphrase with spaces"), + aRecoveryKeyViewState( + recoveryKeyUserStory = RecoveryKeyUserStory.Enter, + formattedRecoveryKey = aFormattedRecoveryKey().replace(" ", ""), + displayTextFieldContents = false + ), + ) +} + +fun aRecoveryKeyViewState( + recoveryKeyUserStory: RecoveryKeyUserStory = RecoveryKeyUserStory.Setup, + formattedRecoveryKey: String? = null, + inProgress: Boolean = false, + displayTextFieldContents: Boolean = true, +) = RecoveryKeyViewState( + recoveryKeyUserStory = recoveryKeyUserStory, + formattedRecoveryKey = formattedRecoveryKey, + displayTextFieldContents = displayTextFieldContents, + inProgress = inProgress, +) + +internal fun aFormattedRecoveryKey(): String { + return "Estm dfyU adhD h8y6 Estm dfyU adhD h8y6 Estm dfyU adhD h8y6" +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/tools/RecoveryKeyTools.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/tools/RecoveryKeyTools.kt new file mode 100644 index 0000000..76afd45 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/tools/RecoveryKeyTools.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.tools + +import dev.zacsweers.metro.Inject + +private const val RECOVERY_KEY_LENGTH = 48 +private const val BASE_58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +@Inject +class RecoveryKeyTools { + fun isRecoveryKeyFormatValid(recoveryKey: String): Boolean { + val recoveryKeyWithoutSpace = recoveryKey.replace("\\s+".toRegex(), "") + return recoveryKeyWithoutSpace.length == RECOVERY_KEY_LENGTH && recoveryKeyWithoutSpace.all { BASE_58_ALPHABET.contains(it) } + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/tools/RecoveryKeyVisualTransformation.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/tools/RecoveryKeyVisualTransformation.kt new file mode 100644 index 0000000..5447b79 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/tools/RecoveryKeyVisualTransformation.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.tools + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation + +class RecoveryKeyVisualTransformation : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + return TransformedText( + text = AnnotatedString( + text.text + .chunked(4) + .joinToString(separator = " ") + ), + offsetMapping = RecoveryKeyOffsetMapping(text.text), + ) + } + + class RecoveryKeyOffsetMapping(private val text: String) : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + if (offset == 0) return 0 + val numberOfChunks = offset / 4 + return if (offset == text.length && offset % 4 == 0) { + offset + numberOfChunks - 1 + } else { + offset + numberOfChunks + } + } + + override fun transformedToOriginal(offset: Int): Int { + val numberOfChunks = offset / 5 + return offset - numberOfChunks + } + } +} diff --git a/features/securebackup/impl/src/main/res/values-be/translations.xml b/features/securebackup/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..781ca5a --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,65 @@ + + + "Выключыць рэзервовае капіраванне" + "Уключыце рэзервовае капіраванне" + "Надзейна захоўвайце вашу крыптаграфічную ідэнтыфікацыю і ключы паведамленняў на серверы. Гэта дазволіць вам праглядаць гісторыю паведамленняў на любых новых прыладах.%1$s ." + "Сховішча ключоў" + "Змяніць ключ аднаўлення" + "Увядзіце ключ аднаўлення" + "Ваша сховішча ключоў зараз не сінхранізавана." + "Наладзьце аднаўленне" + "Атрымайце доступ да зашыфраваных паведамленняў, калі вы страціце ўсе свае прылады або выйдзеце з сістэмы %1$s усюды." + "Адкрыйце %1$s на настольнай прыладзе" + "Увайдзіце ў свой уліковы запіс яшчэ раз" + "Калі будзе прапанавана пацвердзіць вашу прыладу, выберыце %1$s" + "“Скінуць усе”" + "Выконвайце інструкцыі, каб стварыць новы ключ аднаўлення" + "Захавайце новы ключ аднаўлення ў ме́неджэры пароляў або ў зашыфраванай нататке" + "Скіньце шыфраванне для вашага ўліковага запісу з дапамогай іншай прылады" + "Працягнуць скід" + "Дадзеныя вашага ўліковага запісу, кантакты, налады і спіс чатаў будуць захаваны" + "Вы страціце існуючую гісторыю паведамленняў" + "Вам трэба будзе зноў запэўніць ўсе вашы існуючыя прылады і кантакты" + "Працягвайце, толькі калі вы ўпэўненыя, што страцілі ўсе астатнія прылады і ключ аднаўлення." + "Скіньце ключы пацверджання, калі вы не можаце пацвердзіць яго іншым спосабам" + "Адключыць" + "Вы страціце зашыфраваныя паведамленні, калі выйдзеце з усіх прылад." + "Вы ўпэўнены, што хочаце адключыць рэзервовае капіраванне?" + "Адключэнне рэзервовага капіравання прывядзе да выдалення бягучай рэзервовай копіі ключа шыфравання і адключэння іншых функцый бяспекі. У гэтым выпадку вы:" + "Не будзеце мець зашыфраванай гісторыі паведамленняў на новых прыладах" + "Страціце доступ да зашыфраваных паведамленняў, калі вы выйдзеце з усіх сеансаў %1$s" + "Вы ўпэўнены, што хочаце адключыць рэзервовае капіраванне?" + "Атрымайце новы ключ аднаўлення, калі вы страцілі існуючы. Пасля змены ключа аднаўлення ваш стары больш не будзе працаваць." + "Стварыць новы ключ аднаўлення" + "Ні з кім не дзяліцеся гэтым!" + "Ключ аднаўлення зменены" + "Змяніць ключ аднаўлення?" + "Стварыць новы ключ аднаўлення" + "Пераканайцеся, што ніхто не бачыць гэты экран!" + "Паўтарыце спробу, каб пацвердзіць доступ да сховішча ключоў." + "Няправільны ключ аднаўлення" + "Калі ў вас ёсць ключ аднаўлення або парольная фраза, гэта таксама будзе працаваць." + "Увесці…" + "Страцілі ключ аднаўлення?" + "Ключ аднаўлення пацверджаны" + "Ключ аднаўлення скапіраваны" + "Стварэнне…" + "Захаваць ключ аднаўлення" + "Запішыце гэты ключ аднаўлення ў бяспечным месцы, напрыклад, у менеджэры пароляў, у зашыфраванай нататцы або ў фізічным сейфе." + "Націсніце, каб скапіяваць ключ аднаўлення" + "Захавайце ключ аднаўлення" + "Пасля гэтага кроку вы не зможаце атрымаць доступ да новага ключа аднаўлення." + "Вы захавалі свой ключ аднаўлення?" + "Рэзервовая копія чата абаронена ключом аднаўлення. Калі пасля настройкі вам спатрэбіцца новы ключ аднаўлення, вы можаце стварыць яго нанова, выбраўшы \"Змяніць ключ аднаўлення\"." + "Стварыце ключ аднаўлення" + "Ні з кім не дзяліцеся гэтым!" + "Наладка аднаўлення прайшла паспяхова" + "Наладзьце аднаўленне" + "Так, скінуць зараз" + "Гэты працэс незваротны." + "Вы ўпэўнены, што хочаце скінуць шыфраванне?" + "Адбылася невядомая памылка. Калі ласка, праверце правільнасць пароля вашага ўліковага запісу і паўтарыце спробу." + "Увесці…" + "Пацвердзіце, што вы хочаце скінуць шыфраванне" + "Каб працягнуць, увядзіце пароль уліковага запісу" + diff --git a/features/securebackup/impl/src/main/res/values-bg/translations.xml b/features/securebackup/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..fe84ddc --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,32 @@ + + + "Изтриване на хранилището за ключове" + "Включване на резервните копия" + "Съхранявайте сигурно криптографската си самоличност и ключовете за съобщения на сървъра. Това ще ви позволи да преглеждате историята на съобщенията си на всички нови устройства.%1$s ." + "Съхранение на ключове" + "За да настроите възстановяването, трябва да включите съхранението на ключове." + "Разрешаване на съхранението на ключове" + "Промяна на ключа за възстановяване" + "Възстановете криптографската си самоличност и историята на съобщенията с ключ за възстановяване, ако сте загубили всичките си съществуващи устройства." + "Въвеждане на ключ за възстановяване" + "Хранилището ви за ключове в момента не е синхронизирано." + "Изключване" + "Изтриването на хранилището за ключове ще премахне вашата криптографска самоличност и ключове за съобщения от сървъра и ще изключи следните функции за сигурност:" + "Сигурни ли сте, че искате да изключите хранилището на ключове и да го изтриете?" + "Вземете нов ключ за възстановяване, ако сте загубили съществуващия си. След като промените ключа си за възстановяване, старият ви вече няма да работи." + "Генериране на нов ключ за възстановяване" + "Не споделяйте това с никого!" + "Промяна на ключа за възстановяване?" + "Уверете се, че никой не може да види този екран!" + "Моля, опитайте отново, за да потвърдите достъпа до хранилището за ключове." + "Неправилен ключ за възстановяване" + "Ако имате ключ за сигурност или фраза за сигурност, това също ще работи." + "Въведете…" + "Ключът за възстановяване е потвърден" + "Въведете ключа си за възстановяване" + "Копиран ключ за възстановяване" + "Запазване на ключа за възстановяване" + "Вашето хранилище за ключове е защитено с ключ за възстановяване. Ако имате нужда от нов ключ за възстановяване след настройката, можете да го създадете отново, като изберете „Промяна на ключа за възстановяване“." + "Не споделяйте това с никого!" + "Въведете…" + diff --git a/features/securebackup/impl/src/main/res/values-cs/translations.xml b/features/securebackup/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..f03a7d6 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,70 @@ + + + "Vypnout zálohování" + "Zapnout zálohování" + "Bezpečně uložte svou kryptografickou identitu a klíče zpráv na serveru. To vám umožní zobrazit historii zpráv na všech nových zařízeních. %1$s." + "Úložiště klíčů" + "Pro nastavení obnovení musí být zapnuto úložiště klíčů." + "Nahrát klíče z tohoto zařízení" + "Povolit ukládání klíčů" + "Změnit klíč pro obnovení" + "Obnovte svou kryptografickou identitu a historii zpráv pomocí klíče pro obnovení, pokud jste ztratili všechna stávající zařízení." + "Zadejte klíč pro obnovení" + "Vaše úložiště klíčů je momentálně nesynchronizované." + "Nastavení obnovy" + "Získejte přístup ke svým zašifrovaným zprávám, pokud ztratíte všechna zařízení nebo jste všude odhlášeni z %1$s." + "Otevřít %1$s na stolním počítači" + "Znovu se přihlaste ke svému účtu" + "Když budete vyzváni k ověření vašeho zařízení, vyberte %1$s" + "\"Resetovat vše\"" + "Postupujte podle pokynů k vytvoření nového obnovovacího klíče" + "Uložte nový klíč pro obnovení do správce hesel nebo do zašifrované poznámky" + "Obnovte šifrování účtu pomocí jiného zařízení" + "Pokračovat v resetování" + "Podrobnosti o vašem účtu, kontaktech, preferencích a seznamu chatu budou zachovány" + "Ztratíte svou stávající historii zpráv" + "Budete muset znovu ověřit všechna stávající zařízení a kontakty" + "Obnovte svou identitu pouze v případě, že nemáte přístup k jinému přihlášenému zařízení a ztratili jste klíč pro obnovení." + "Obnovte svou identitu v případě, že nemůžete potvrdit jiným způsobem" + "Vypnout" + "Pokud se odhlásíte ze všech zařízení, přijdete o zašifrované zprávy." + "Opravdu chcete vypnout zálohování?" + "Vypnutím zálohování odstraníte zálohu aktuálního šifrovacího klíče a vypnete další bezpečnostní funkce. V tomto případě budete:" + "Nemít v nových zařízeních šifrovanou historii zpráv" + "Ztratíte přístup k šifrovaným zprávám, pokud jste všude odhlášeni z %1$s" + "Opravdu chcete vypnout zálohování?" + "Získejte nový klíč pro obnovení, pokud jste ztratili stávající klíč. Po změně klíče pro obnovení již váš starý klíč nebude fungovat." + "Vygenerovat nový klíč pro obnovení" + "Toto s nikým nesdílejte!" + "Klíč pro obnovení byl změněn" + "Změnit klíč pro obnovení?" + "Vytvořit nový klíč pro obnovení" + "Ujistěte se, že tuto obrazovku nikdo nevidí!" + "Zkuste prosím znovu potvrdit přístup k úložišti klíčů." + "Nesprávný klíč pro obnovení" + "Pokud máte bezpečnostní klíč nebo bezpečnostní frázi, bude to fungovat také." + "Zadejte…" + "Ztratili jste klíč pro obnovení?" + "Klíč pro obnovení potvrzen" + "Zadejte klíč pro obnovení" + "Klíč pro obnovení zkopírován" + "Generování…" + "Uložit klíč pro obnovení" + "Zapište si tento obnovovací klíč na bezpečné místo, jako je správce hesel, zašifrovaná poznámka nebo fyzický trezor." + "Klepnutím zkopírujte klíč pro obnovení" + "Uložte si klíč pro obnovení" + "Po tomto kroku nebudete mít přístup k novému klíči pro obnovení." + "Uložili jste si klíč pro obnovení?" + "Záloha chatu je chráněna klíčem pro obnovení. Pokud potřebujete nový klíč pro obnovení po nastavení, můžete jej znovu vytvořit výběrem možnosti „Změnit klíč pro obnovení“." + "Vygenerovat klíč pro obnovení" + "Toto s nikým nesdílejte!" + "Nastavení obnovení bylo úspěšné" + "Nastavení obnovy" + "Ano, resetovat nyní" + "Tento proces je nevratný." + "Opravdu chcete obnovit svou identitu?" + "Došlo k neznámé chybě. Zkontrolujte, zda je heslo k účtu správné a zkuste to znovu." + "Zadejte…" + "Potvrďte, že chcete obnovit svou identitu." + "Pro pokračování zadejte heslo k účtu" + diff --git a/features/securebackup/impl/src/main/res/values-cy/translations.xml b/features/securebackup/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..fdff84c --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,70 @@ + + + "Dileu storfa allweddi" + "Trowch y copi wrth gefn ymlaen" + "Cadwch eich hunaniaeth cryptograffig a\'ch allweddi neges yn ddiogel ar y gweinydd. Bydd hyn yn caniatáu ichi weld hanes eich neges ar unrhyw ddyfeisiau newydd. %1$s." + "Storio allweddi" + "Rhaid troi storio allweddi ymlaen i osod adferiad." + "Llwythwch allweddi i fyny o\'r ddyfais hon" + "Caniatáu storio allweddi" + "Newid yr allwedd adfer" + "Adferwch eich hunaniaeth cryptograffig a hanes negeseuon gydag allwedd adfer os ydych chi wedi colli\'ch holl ddyfeisiau presennol." + "Rhowch eich allwedd adfer" + "Nid yw eich storfa allweddi wedi\'i chydweddu ar hyn o bryd." + "Gosod adfer" + "Cael mynediad i\'ch negeseuon wedi\'u hamgryptio os byddwch yn colli\'ch holl ddyfeisiau neu\'n cael eich allgofnodi o %1$s ym mhobman." + "Agor %1$s mewn dyfais bwrdd gwaith" + "Mewngofnodwch i\'ch cyfrif eto" + "Pan fydd gofyn i chi ddilysu\'ch dyfais, dewiswch %1$s" + "“Ailosod y cyfan“" + "Dilynwch y cyfarwyddiadau i greu allwedd adfer newydd" + "Cadwch eich allwedd adfer newydd mewn rheolwr cyfrinair neu mewn nodyn wedi\'i amgryptio" + "Ailosodwch yr amgryptio ar gyfer eich cyfrif gan ddefnyddio dyfais arall" + "Parhau i ailosod" + "Bydd manylion eich cyfrif, eich cysylltiadau, eich dewisiadau a\'ch rhestr sgwrsio yn cael eu cadw" + "Byddwch yn colli unrhyw hanes neges sydd wedi\'i gadw dim ond ar y gweinydd" + "Bydd angen i chi wirio\'ch holl ddyfeisiau a chysylltiadau presennol eto" + "Ailosodwch eich hunaniaeth dim ond os nad oes gennych fynediad i ddyfais arall sydd wedi\'i mewngofnodi a\'ch bod wedi colli\'ch allwedd adfer." + "Methu cadarnhau? Bydd angen i chi ailosod eich hunaniaeth." + "Diffodd" + "Byddwch yn colli eich negeseuon wedi\'u hamgryptio os ydych wedi\'ch allgofnodi o bob dyfais." + "Ydych chi\'n siŵr eich bod am ddiffodd copi wrth gefn?" + "Bydd dileu storfa allweddi\'n tynnu eich hunaniaeth cryptograffig a\'ch allweddi neges o\'r gweinydd ac yn diffodd y nodweddion diogelwch canlynol:" + "Bydd gennych chi ddim hanes negeseuon wedi\'i amgryptio ar ddyfeisiau newydd" + "Byddwch yn colli mynediad i\'ch negeseuon wedi\'u hamgryptio os ydych wedi\'ch allgofnodi o %1$s ym mhobman" + "Ydych chi\'n siŵr eich bod am ddiffodd storfa allweddi a\'i dileu?" + "Cael allwedd adfer newydd os ydych chi wedi colli\'ch un presennol. Ar ôl newid eich allwedd adfer, fydd eich hen un ddim yn gweithio mwyach." + "Cynhyrchu allwedd adfer newydd" + "Peidiwch â rhannu hwn gyda neb!" + "Newidwyd yr allwedd adfer" + "Newid yr allwedd adfer?" + "Creu allwedd adfer newydd" + "Gwnewch yn siŵr nad oes neb yn gallu gweld y sgrin hon!" + "Ceisiwch eto i gadarnhau mynediad i\'ch storfa allweddi." + "Allwedd adfer anghywir" + "Os oes gennych allwedd ddiogelwch neu ymadrodd diogelwch, bydd hyn yn gweithio hefyd." + "Rhowch…" + "Wedi colli eich allwedd adfer?" + "Wedi cadarnhau\'r allwedd adfer" + "Rhowch eich allwedd adfer" + "Allwedd adfer wedi\'i chopïo" + "Yn cynhyrchu…" + "Cadw allwedd adfer" + "Ysgrifennwch yr allwedd adfer hon yn rhywle diogel, fel rheolwr cyfrinair, nodyn wedi\'i amgryptio, neu mewn man dan glo." + "Tapiwch i gopïo\'r allwedd adfer" + "Cadwch eich allwedd adfer yn rhywle diogel" + "Fyddwch chi ddim yn gallu cyrchu\'ch allwedd adfer newydd ar ôl y cam hwn." + "Ydych chi wedi cadw\'ch allwedd adfer?" + "Mae eich storfa allweddi wedi\'i diogelu gan allwedd adfer. Os oes angen allwedd adfer newydd arnoch ar ôl gosod, gallwch ei hail-greu trwy ddewis \'Newid allwedd adfer\'." + "Cynhyrchwch eich allwedd adfer" + "Peidiwch â rhannu hwn gyda neb!" + "Llwyddiant wrth osod adferiad" + "Gosod adfer" + "Iawn, ailosod nawr" + "Does dim posib dadwneud y broses hon." + "Ydych chi\'n siŵr eich bod am ailosod eich hunaniaeth?" + "Digwyddodd gwall anhysbys. Gwiriwch fod cyfrinair eich cyfrif yn gywir a cheisio eto." + "Rhowch…" + "Cadarnhewch eich bod am ailosod eich hunaniaeth." + "Rhowch eich cyfrinair cyfrif i barhau" + diff --git a/features/securebackup/impl/src/main/res/values-da/translations.xml b/features/securebackup/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..c77e194 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,70 @@ + + + "Slet nøglelager" + "Aktivér sikkerhedskopiering" + "Gem din kryptografiske identitet og meddelelsesnøgler sikkert på serveren. Dette giver dig mulighed for at se din meddelelseshistorik på alle nye enheder. %1$s." + "Nøgleopbevaring" + "Nøglelagring skal være slået til for at konfigurere gendannelse." + "Upload nøgler fra denne enhed" + "Tillad lagring af nøgler" + "Skift gendannelsesnøgle" + "Gendan din kryptografiske identitet og beskedhistorik med en gendannelsesnøgle, hvis du har mistet alle dine eksisterende enheder." + "Indtast gendannelsesnøgle" + "Din nøglelagring er i øjeblikket ikke synkroniseret." + "Opsæt gendannelse" + "Få adgang til dine krypterede meddelelser, hvis du mister alle dine enheder eller er logget ud af %1$s overalt." + "Åbn %1$s på en stationær enhed" + "Log ind på din konto igen" + "Når du bliver bedt om at verificere din enhed, skal du vælge %1$s" + "\"Nulstil alle\"" + "Følg instruktionerne for at oprette en ny gendannelsesnøgle" + "Gem din nye gendannelsesnøgle i en adgangskodeadministrator eller i en krypteret note" + "Nulstil krypteringen for din konto ved hjælp af en anden enhed" + "Fortsæt nulstilling" + "Dine kontodetaljer, kontakter, personlige indstilliger og samtaler vil blive gemt" + "Du mister al beskedhistorik, der kun er gemt på serveren." + "Du bliver nødt til at verificere alle dine eksisterende enheder og kontakter påny" + "Nulstil kun din identitet, hvis du ikke har adgang til en anden enhed, der er logget ind, og du har mistet din gendannelsesnøgle." + "Kan du ikke bekræfte? Du skal nulstille din identitet." + "Slå fra" + "Du mister dine krypterede meddelelser, hvis du er logget ud af alle enheder." + "Er du sikker på, at du vil slå sikkerhedskopiering fra?" + "Hvis du sletter nøglelageret, fjernes din kryptografiske identitet og meddelelsesnøgler fra serveren og følgende sikkerhedsfunktioner deaktiveres:" + "Du vil ikke kunne se historikken for krypterede beskeder på nye enheder" + "Du mister adgangen til dine krypterede meddelelser, hvis du er logget ud %1$s overalt" + "Er du sikker på, at du vil deaktivere nøglelagring og slette lageret?" + "Få en ny gendannelsesnøgle, hvis du har mistet din eksisterende. Når du har ændret din gendannelsesnøgle, fungerer din gamle ikke længere." + "Generer en ny gendannelsesnøgle" + "Del ikke dette med nogen!" + "Gendannelsesnøgle ændret" + "Skift gendannelsesnøgle?" + "Opret ny gendannelsesnøgle" + "Sørg for, at ingen kan se denne skærm!" + "Prøv igen for at bekræfte adgangen til dit nøglelager." + "Forkert gendannelsesnøgle" + "Hvis du har en sikkerhedsnøgle eller sikkerhedssætning, kan en af dem også bruges." + "Indtast…" + "Mistet din gendannelsesnøgle?" + "Gendannelsesnøgle bekræftet" + "Indtast din gendannelsesnøgle" + "Kopieret gendannelsesnøgle" + "Genererer…" + "Gem gendannelsesnøgle" + "Skriv denne gendannelsesnøgle et sikkert sted, som en adgangskodeadministrator, en krypteret note eller på papir, som du lægger i et fysisk pengeskab." + "Tryk for at kopiere gendannelsesnøglen" + "Gem din gendannelsesnøgle et sikkert sted" + "Du vil ikke kunne få adgang til din nye gendannelsesnøgle efter dette trin." + "Har du gemt din gendannelsesnøgle?" + "Din nøglelager er beskyttet af en gendannelsesnøgle. Hvis du har brug for en ny gendannelsesnøgle efter installationen, kan du oprette den ved at vælge \'Skift gendannelsesnøgle\'." + "Generer din gendannelsesnøgle" + "Del ikke dette med nogen!" + "Opsætning af gendannelse lykkedes" + "Opsæt gendannelse" + "Ja, nulstil nu" + "Denne proces er irreversibel." + "Er du sikker på, at du ønsker at nulstille din identitet?" + "Der opstod en ukendt fejl. Kontroller, at adgangskoden til din konto er korrekt, og prøv igen." + "Indtast…" + "Bekræft, at du ønsker at nulstille din identitet." + "Indtast adgangskoden til din konto for at fortsætte" + diff --git a/features/securebackup/impl/src/main/res/values-de/translations.xml b/features/securebackup/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..487ec98 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,79 @@ + + + "Backup deaktivieren" + "Backup aktivieren" + "Speichere deine kryptographische Identität und die Nachrichtenschlüssel auf dem Server. Auf diese Weise kannst du deinen Nachrichtenverlauf auf neuen Geräten einsehen. %1$s." + "Schlüsselspeicher" + "Der Schlüsselspeicher muss aktiviert sein, um Datenwiederherstellung zu ermöglichen." + "Schlüssel von diesem Gerät hochladen" + "Schlüsselspeicherung zulassen" + "Wiederherstellungsschlüssel ändern" + "Stelle deine kryptographische Identität und deinen Nachrichtenverlauf mit einem Wiederherstellungsschlüssel wieder her, falls du deine Geräte verloren hast." + "Wiederherstellungsschlüssel eingeben" + "Dein Schlüssel ist derzeit nicht synchronisiert." + "Wiederherstellung einrichten" + "Erhalte Zugriff auf deine verschlüsselten Nachrichten, wenn du alle deine Geräte verloren hast oder überall von %1$s abgemeldet bist." + + "Öffne " + "%1$s" + " auf einem " + "Desktop-Gerät" + + "Melde dich erneut bei deinem Konto an" + "Bei der Aufforderung, dein Gerät zu verifizieren, wähle %1$s" + "Alles zurücksetzen" + "Folge den Anweisungen, um einen neuen Wiederherstellungsschlüssel zu erstellen" + "Verwahre deinen neuen Wiederherstellungsschlüssel in einem Passwortmanager oder einer verschlüsselten Datei" + + "Erstelle einen neuen " + "Wiederherstellungsschlüssel" + " mit einem anderen Gerät" + + "Zurücksetzen fortsetzen" + "Deine Kontodaten, Kontakte, Einstellungen und die Liste der Chats bleiben erhalten" + "Du verlierst alle bisherigen Nachrichten, wenn sie ausschließlich auf dem Server gespeichert sein sollten." + "Du musst alle deine bestehenden Geräte und Kontakte erneut verifizieren." + "Setze deine Identität nur dann zurück, wenn du keinen Zugriff mehr auf ein anderes angemeldetes Gerät hast und auch deinen Wiederherstellungsschlüssel verloren hast." + "Bestätigung unmöglich? Dann musst du deine Identität zurücksetzen." + "Ausschalten" + "Du verlierst deine verschlüsselten Nachrichten, wenn du auf allen Geräten abgemeldet bist." + "Bist du sicher, dass du das Backup deaktivieren willst?" + "Das Löschen des Schlüsselspeichers entfernt deine kryptografische Identität und deine Nachrichtenschlüssel vom Server. Die folgenden Sicherheitsfunktionen werden deaktiviert:" + "Kein Nachrichtenverlauf für verschlüsselte Nachrichten auf neuen Geräten" + "Kein Zugriff auf verschlüsselten Nachrichten, wenn du überall von %1$s abgemeldet bist" + "Möchtest du die Speicherung der Schlüssel wirklich deaktivieren und entfernen?" + "Erhalte einen neuen Wiederherstellungsschlüssel wenn du deinen bisherigen verloren hast. Danach funktioniert dein alter Schlüssel nicht mehr." + "Wiederherstellungsschlüssel erstellen" + "Teile das mit niemandem!" + "Wiederherstellungsschlüssel geändert" + "Wiederherstellungsschlüssel ändern?" + "Neuen Wiederherstellungsschlüssel erstellen" + "Sorge dafür, dass niemand diesen Bildschirm sehen kann!" + "Bitte versuche erneut, den Zugriff auf deinen Schlüsselspeicher zu bestätigen." + "Falscher Wiederherstellungsschlüssel" + "Dies funktioniert auch mit einem Sicherheitsschlüssel oder Sicherheitsphrase." + "Eingeben…" + "Wiederherstellungschlüssel vergessen?" + "Wiederherstellungsschlüssel bestätigt" + "Gib deinen Wiederherstellungsschlüssel ein" + "Wiederherstellungsschlüssel kopiert" + "Generieren…" + "Wiederherstellungsschlüssel speichern" + "Bewahre den Wiederherstellungsschlüssel an einer sicheren Stelle auf, wie zum Beispiel in einem Passwort-Manager, in einer verschlüsselten Datei oder in einem Safe. " + "Tippe, um den Wiederherstellungsschlüssel zu kopieren" + "Speichere deinen Wiederherstellungsschlüssel" + "Nach diesem Schritt kannst du nicht mehr auf deinen neuen Wiederherstellungsschlüssel zugreifen." + "Hast du deinen Wiederherstellungsschlüssel gespeichert?" + "Dein Schlüsselspeicher wird durch einen Wiederherstellungsschlüssel geschützt. Wenn du nach der Einrichtung einen neuen Wiederherstellungsschlüssel benötigst, kannst du durch Auswahl von „Wiederherstellungsschlüssel ändern“ einen neuen erzeugen." + "Wiederherstellungsschlüssel erstellen" + "Teile das mit niemandem!" + "Einrichtung der Wiederherstellung erfolgreich" + "Wiederherstellung einrichten" + "Ja, zurücksetzen" + "Das Zurücksetzen kann nicht rückgängig gemacht werden." + "Bist du sicher, dass du deine Identität zurücksetzen möchtest?" + "Es ist ein unbekannter Fehler aufgetreten. Bitte überprüfe das Passwort deines Kontos und versuche es erneut." + "Eingeben…" + "Bestätige, dass du deine Identität zurücksetzen möchtest." + "Gib dein Passwort ein, um fortzufahren" + diff --git a/features/securebackup/impl/src/main/res/values-el/translations.xml b/features/securebackup/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..e31621c --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,70 @@ + + + "Απενεργοποίηση αντιγράφων ασφαλείας" + "Ενεργοποίηση αντιγράφων ασφαλείας" + "Αποθήκευσε την κρυπτογραφική σου ταυτότητα και τα κλειδιά μηνυμάτων με ασφάλεια στον διακομιστή. Αυτό θα σου επιτρέψει να δεις το ιστορικό μηνυμάτων σου σε οποιεσδήποτε νέες συσκευές. %1$s." + "Χώρος αποθήκευσης κλειδιού" + "Η αποθήκευση κλειδιών πρέπει να είναι ενεργοποιημένη για να ρυθμίσεις την ανάκτηση." + "Μεταφόρτωση κλειδιών από αυτήν τη συσκευή" + "Να επιτρέπεται η αποθήκευση κλειδιών" + "Αλλαγή κλειδιού ανάκτησης" + "Ανάκτησε την κρυπτογραφική σου ταυτότητα και το ιστορικό μηνυμάτων με ένα κλειδί ανάκτησης εάν έχεις χάσει όλες τις υπάρχουσες συσκευές σου." + "Εισαγωγή κλειδιού ανάκτησης" + "Ο αποθηκευτικός χώρος κλειδιών σου δεν είναι συγχρονισμένος αυτήν τη στιγμή." + "Ρύθμιση ανάκτησης" + "Απόκτησε πρόσβαση στα κρυπτογραφημένα σου μηνύματα εάν χάσεις όλες τις συσκευές σου ή έχεις αποσυνδεθεί από το %1$s παντού." + "Άνοιγμα %1$s σε συσκευή υπολογιστή" + "Συνδέσου ξανά στο λογαριασμό σου" + "Όταν σου ζητηθεί να επαληθεύσεις τη συσκευή σου, επέλεξε %1$s" + "«Επαναφορά όλων»" + "Ακολούθησε τις οδηγίες για να δημιουργήσεις ένα νέο κλειδί ανάκτησης" + "Αποθήκευσε το νέο κλειδί ανάκτησης σε έναν διαχειριστή κωδικών πρόσβασης ή σε κρυπτογραφημένη σημείωση" + "Επανάφερε την κρυπτογράφηση για το λογαριασμό σου χρησιμοποιώντας άλλη συσκευή" + "Συνέχιση επαναφοράς" + "Τα στοιχεία του λογαριασμού σου, οι επαφές, οι προτιμήσεις και η λίστα συνομιλιών θα διατηρηθούν" + "Θα χάσεις το υπάρχον ιστορικό μηνυμάτων σου" + "Θα χρειαστεί να επαληθεύσεις ξανά όλες τις υπάρχουσες συσκευές και επαφές σου" + "Επανάφερε την ταυτότητά σου μόνο εάν δεν έχεις πρόσβαση σε άλλη συνδεδεμένη συσκευή και έχεις χάσει το κλειδί ανάκτησης." + "Δεν μπορείς να επιβεβαιώσεις; Θα χρειαστεί να επαναφέρεις την ταυτότητά σου." + "Απενεργοποίηση" + "Θα χάσεις τα κρυπτογραφημένα μηνύματά σου εάν αποσυνδεθείς από όλες τις συσκευές." + "Σίγουρα θες να απενεργοποιήσεις τα αντίγραφα ασφαλείας;" + "Η απενεργοποίηση του αντιγράφου ασφαλείας θα καταργήσει το τρέχον αντίγραφο ασφαλείας κλειδιού κρυπτογράφησης και θα απενεργοποιήσει άλλες δυνατότητες ασφαλείας. Σε αυτή την περίπτωση, θα:" + "Να μην έχεις κρυπτογραφημένο ιστορικό μηνυμάτων στις νέες συσκευές" + "Χάσεις την πρόσβαση στα κρυπτογραφημένα μηνύματά σου εάν είσαι αποσυνδεδεμένος από %1$s παντού" + "Σίγουρα θες να απενεργοποιήσεις τα αντίγραφα ασφαλείας;" + "Απόκτησε ένα νέο κλειδί ανάκτησης εάν έχεις χάσει το υπάρχον. Αφού αλλάξεις το κλειδί ανάκτησης, το παλιό δεν θα λειτουργεί πλέον." + "Δημιουργία νέου κλειδιού ανάκτησης" + "Μην το μοιραστείς με κανέναν!" + "Το κλειδί ανάκτησης άλλαξε" + "Αλλαγή κλειδιού ανάκτησης;" + "Δημιουργία νέου κλειδιού ανάκτησης" + "Βεβαιώσου ότι κανείς δεν μπορεί να δει αυτήν την οθόνη!" + "Προσπάθησε ξανά για να επιβεβαιώσεις την πρόσβαση στον αποθηκευτικό χώρο κλειδιών σου." + "Λανθασμένο κλειδί ανάκτησης" + "Εάν έχεις ένα κλειδί ασφαλείας ή μια φράση ασφαλείας, θα λειτουργήσει επίσης." + "Εισαγωγή…" + "Έχασες το κλειδί ανάκτησης;" + "Επιβεβαιώθηκε το κλειδί ανάκτησης" + "Εισήγαγε το κλειδί ανάκτησης" + "Αντιγράφηκε το κλειδί ανάκτησης" + "Δημιουργία…" + "Αποθήκευση κλειδιού ανάκτησης" + "Γράψε αυτό το κλειδί ανάκτησης κάπου ασφαλές, όπως έναν διαχειριστή κωδικών πρόσβασης, μια κρυπτογραφημένη σημείωση ή ένα φυσικό χρηματοκιβώτιο." + "Πάτα για να αντιγράψεις το κλειδί ανάκτησης" + "Αποθήκευσε το κλειδί ανάκτησης" + "Δεν θα μπορείς να αποκτήσεις πρόσβαση στο νέο κλειδί ανάκτησης μετά από αυτό το βήμα." + "Έχεις αποθηκεύσει το κλειδί ανάκτησης;" + "Το αντίγραφο ασφαλείας της συνομιλίας σου προστατεύεται από ένα κλειδί ανάκτησης. Εάν χρειαστείς ένα νέο κλειδί ανάκτησης μετά την εγκατάσταση, μπορείς να δημιουργήσεις ξανά επιλέγοντας «Αλλαγή κλειδιού ανάκτησης»." + "Δημιουργία κλειδιού ανάκτησης" + "Μην το μοιραστείς με κανέναν!" + "Επιτυχής ρύθμιση ανάκτησης" + "Ρύθμιση ανάκτησης" + "Ναι, επαναφορά τώρα" + "Η διαδικασία είναι μη αναστρέψιμη." + "Σίγουρα θες να επαναφέρεις την ταυτότητά σου;" + "Συνέβη ένα άγνωστο σφάλμα. Έλεγξε ότι ο κωδικός πρόσβασης του λογαριασμού σου είναι σωστός και δοκίμασε ξανά." + "Εισαγωγή…" + "Επιβεβαίωσε ότι θες να επαναφέρεις την ταυτότητά σου." + "Εισήγαγε τον κωδικό πρόσβασης του λογαριασμού σου για να συνεχίσεις" + diff --git a/features/securebackup/impl/src/main/res/values-es/translations.xml b/features/securebackup/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..da6f4f2 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,70 @@ + + + "Borrar almacén de claves" + "Activar copia de seguridad" + "Almacena tu identidad criptográfica y las claves de tus mensajes de forma segura en el servidor. Esto te permitirá ver tu historial de mensajes en cualquier dispositivo nuevo. %1$s." + "Almacenamiento de claves" + "El almacenamiento de claves debe estar activado para configurar la recuperación." + "Subir claves desde este dispositivo" + "Permitir almacenamiento de claves" + "Cambiar la clave de recuperación" + "Recupera tu identidad criptográfica y tu historial de mensajes con una clave de recuperación si has perdido todos tus dispositivos actuales." + "Introduce la clave de recuperación" + "Tu almacén de claves no está sincronizado actualmente." + "Configurar la recuperación" + "Accede a tus mensajes cifrados si pierdes todos tus dispositivos o cierras sesión de %1$s en cualquier lugar." + "Abre %1$s en un dispositivo de escritorio" + "Vuelve a iniciar sesión en tu cuenta" + "Cuando se te pida que verifiques tu dispositivo, selecciona %1$s" + "«Restablecer todo»" + "Sigue las instrucciones para crear una nueva clave de recuperación" + "Guarda tu nueva clave de recuperación en un administrador de contraseñas o en una nota cifrada" + "Restablece el cifrado de tu cuenta usando otro dispositivo" + "Continuar con el restablecimiento" + "Se conservarán los detalles de tu cuenta, tus contactos, tus preferencias y tu lista de chats" + "Perderás cualquier historial de mensajes que solo esté almacenado en el servidor" + "Tendrás que verificar de nuevo todos tus dispositivos y contactos existentes" + "Restablece tu identidad solo si no tienes acceso a otro dispositivo en el que hayas iniciado sesión y has perdido tu clave de recuperación." + "¿No puedes confirmar? Tendrás que restablecer tu identidad." + "Desactivar" + "Perderás tus mensajes cifrados si cierras sesión en todos los dispositivos." + "¿Estás seguro de que quieres desactivar la copia de seguridad?" + "Al borrar el almacén de claves, se eliminarán del servidor tu identidad criptográfica y claves de los mensajes, y se desactivarán las siguientes funciones de seguridad:" + "No tendrás un historial de mensajes cifrados en nuevos dispositivos" + "Perderás el acceso a tus mensajes cifrados si cierras sesión en %1$s en todas partes" + "¿Estás seguro de que quieres desactivar el almacenamiento de claves y borrarlo?" + "Obtén una nueva clave de recuperación si has perdido la que tenías. Después de cambiar la clave de recuperación, la anterior dejará de funcionar." + "Generar una nueva clave de recuperación" + "¡No la compartas con nadie!" + "Clave de recuperación cambiada" + "¿Cambiar la clave de recuperación?" + "Crear nueva clave de recuperación" + "¡Asegúrate de que nadie pueda ver esta pantalla!" + "Inténtalo de nuevo para confirmar el acceso a tu almacén de claves." + "Clave de recuperación incorrecta" + "Si tienes una clave o frase de seguridad, también funcionará." + "Introducir…" + "¿Perdiste tu clave de recuperación?" + "Clave de recuperación confirmada" + "Introduce tu clave de recuperación" + "Clave de recuperación copiada" + "Generando…" + "Guardar clave de recuperación" + "Anota esta clave de recuperación en un lugar seguro, como un administrador de contraseñas, una nota cifrada o una caja fuerte física." + "Pulsa para copiar la clave de recuperación" + "Guardar tu clave de recuperación" + "No podrás acceder a tu nueva clave de recuperación después de este paso." + "¿Has guardado tu clave de recuperación?" + "Tu almacén de claves está protegido por una clave de recuperación. Si necesitas una nueva clave de recuperación después de la configuración, puedes volver a crearla seleccionando «Cambiar la clave de recuperación»." + "Generar tu clave de recuperación" + "¡No la compartas con nadie!" + "Configuración de recuperación terminada" + "Configurar la recuperación" + "Sí, restablecer ahora" + "Este proceso es irreversible." + "¿Estás seguro de que quieres restablecer tu identidad?" + "Se ha producido un error desconocido. Comprueba que la contraseña de tu cuenta sea correcta y vuelve a intentarlo." + "Introducir…" + "Confirma que quieres restablecer tu identidad." + "Introduce la contraseña de tu cuenta para continuar" + diff --git a/features/securebackup/impl/src/main/res/values-et/translations.xml b/features/securebackup/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..9a7b092 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,70 @@ + + + "Lülita võtmete varundamine välja" + "Lülita võtmete varundamine sisse" + "Salvesta oma krüptoidentiteet ja sõnumite krüptovõtmed turvaliselt serveris. See tagab, et sinu sõnumite ajalugu on alati loetav, ka kõikides uutes seadmetes. %1$s." + "Krüptovõtmete varundus" + "Taastamise seadistamiseks peab võtmehoidla olema sisselülitatud." + "Laadi siin seadmes leiduvad võtmed üles" + "Luba krüptovõtmete salvestamine" + "Muuda taastevõtit" + "Kui sa oled kaotanud ligipääsu kõikidele oma olemasolevatele seadmetele, siis sa saad taastevõtme abil taastada ligipääsu oma krüptoidentiteedile ja sõnumite ajaloole." + "Sisesta taastevõti" + "Sinu krüptovõtmete varundus pole hetkel enam sünkroonis." + "Seadista andmete taastamine" + "Säilita ligipääs oma krüptitud sõnumitele ka siis, kui sa kaotad kõik oma seadmed ja/või logid kõikjal välja rakendusest %1$s." + "Ava %1$s töölauaga seadmes" + "Logi uuesti sisse oma kasutajakontole" + "Kui sul palutakse seadet verifitseerida, vali %1$s" + "„Lähtesta kõik“" + "Uue taastevõtme loomiseks palun järgi juhendit" + "Salvesta oma uus taastevõti kas salasõnahalduris, krüptitud failis või mõnel muul turvalisel viisil" + "Lähtesta oma konto krüptimine mõnest muust oma seadmest" + "Jätka lähtestamisega" + "Sinu kasutajakonto andmed, kontaktid, eelistused ja vestluste loend säiluvad" + "Sa kaotad seniste sõnumite ajaloo" + "Sa pead kõik oma olemasolevad seadmed ja kontaktid uuesti verifitseerima" + "Lähtesta oma identiteet vaid siis, kui sul pole ligipääsu mitte ühelegi oma seadmele ja sa oled kaotanud oma taastevõtme." + "Kui sa ühtegi muud võimalust ei leia, siis lähtesta oma identiteet." + "Lülita välja" + "Kui sa logid välja kõikidest oma seadmetest, siis sa kaotad ligipääsu oma krüptitud sõnumitele." + "Kas sa oled kindel, et soovid varukoopiate tegemise välja lülitada?" + "Varunduse väljalülitamisel kustutatakse hetkel olemasolev sinu krüptovõtmete varukoopia ning lülituvad välja veel mõned turvafunktsionaalsused. Sellisel juhul sul:" + "sul ei ole krüptitud sõnumite ajalugu uutes seadmetes" + "sa kaotad ligipääsu oma krüptitud sõnumitele, kui sa logid kõikjal välja rakendusest %1$s" + "Kas sa oled kindel, et soovid varunduse välja lülitada?" + "Kui oled vana taastevõtme kaotanud, siis loo uus. Peale seda muudatust vana taastevõti enam ei tööta." + "Loo uus taastevõti" + "Ära jaga seda kellegagi" + "Taastevõti on muudetud" + "Kas muudame taastevõtme?" + "Loo uus taastevõti" + "Palun vaata, et keegi teine ei näeks seda ekraanivaadet!" + "Kinnitamaks ligipääsu sinu krüptovõtmete varundusele, palun proovi uuesti" + "Vigane taastevõti" + "Kui sul on turvavõti või turvafraas, siis need toimivad ka." + "Sisesta…" + "Kas sa oled taastevõtme kaotanud?" + "Taastevõti on kinnitatud" + "Sisesta oma taastevõti" + "Taastevõti on kopeeritud lõikelauale" + "Loome…" + "Salvesta taastevõti" + "Palun märgi taastevõti üles ja hoia seda turvaliselt, näiteks digitaalses salasõnalaekas, krüptitud märkmetes või vana kooli seifis." + "Taastevõtme kopeerimiseks puuduta" + "Salvesta oma taastevõti" + "Peale seda sammu sul pole enam ligipääsu oma taastevõtmele." + "Kas sa oled oma taastevõtme talletanud?" + "Sinu vestluste varundus on krüptitud taastevõtmega. Kui peale muutusi peaks vaja olema uut taastevõtit, siis palun kasuta valikut „Loo taastevõti“" + "Loo oma taastevõti" + "Ära jaga seda kellegagi" + "Andmete taastamise seadistamine õnnestus" + "Seadista andmete taastamine" + "Jah, lähtesta nüüd" + "See tegevus on tagasipöördumatu." + "Kas sa oled kindel, et soovid oma andmete krüptimist lähtestada?" + "Tekkis teadmata viga. Palun kontrolli, kas sinu kasutajakonto salasõna on õige ja proovi uuesti." + "Sisesta…" + "Palun kinnita, et soovid oma andmete krüptimist lähtestada." + "Jätkamaks sisesta oma kasutajakonto salasõna" + diff --git a/features/securebackup/impl/src/main/res/values-eu/translations.xml b/features/securebackup/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..86b1f31 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,61 @@ + + + "Ezabatu gakoen biltegia" + "Aktibatu babeskopia" + "Gorde zure identitate-kriptografikoa eta mezuen gakoak modu seguruan zerbitzarian. Horrek zure mezuen historia edozein gailu berritan ikusteko aukera emango dizu. %1$s." + "Gakoen biltegiratzea" + "Aldatu berreskuratze-gakoa" + "Sartu berreskuratze-gakoa" + "Zure giltzen-biltegiratzea gaur-gaurkoz sinkronizatu gabe dago." + "Konfiguratu berreskurapena" + "Ireki %1$s mahaigaineko gailu batean" + "Hasi saioa berriro zure kontuan" + "Gailua egiaztatzeko eskatzen zaizunean, hautatu %1$s" + "\"Berrezarri guztia\"" + "Jarraitu argibideak berreskuratze-gako berri bat sortzeko" + "Gorde berreskuratze-gako berria pasahitz-kudeatzaile batean edo enkriptatutako ohar batean" + "Berrezarri zure kontuaren enkriptazioa beste gailu bat erabiliz" + "Jarraitu berrezarpenarekin" + "Zure kontuaren xehetasunak, kontaktuak, hobespenak eta txat-zerrenda gordeko dira" + "Zerbitzarian soilik gordeta dagoen mezuen historia galduko duzu" + "Zure gailu eta kontaktu guztiak berriro egiaztatu beharko dituzu" + "Ezin duzu baieztatu? Zure identitatea berrezarri beharko duzu." + "Desaktibatu" + "Enkriptatutako mezuak galduko dituzu gailu guztietan saioa amaitzen baduzu." + "Ziur babeskopia desaktibatu nahi duzula?" + "Gakoen biltegiratzea ezabatuz gero, zure identitate kriptografikoa eta mezuen gakoak zerbitzaritik kenduko dira eta honako segurtasun ezaugarriak desaktibatu egingo dira:" + "Aurrerantzean ez duzu mezuen historia enkriptatuta izango gailu berrietan" + "Gailu guztietan amaitzen baduzu %1$s saioa, enkriptatutako mezuetarako sarbidea galduko duzu" + "Ziur gakoen biltegia desaktibatu eta ezabatu nahi duzula?" + "Lortu berreskuratze-gako berri bat lehendik duzuna galdu baduzu. Berreskuratze-gakoa aldatu ondoren, zaharrak ez du funtzionatuko." + "Sortu berreskuratze-gako berri bat" + "Ez partekatu inorekin!" + "Berreskuratze-gakoa aldatu da" + "Berreskuratze-gakoa aldatu?" + "Sortu berreskuratze-gako berria" + "Egiaztatu inork ezin duela pantaila hau ikusi!" + "Berreskuratze-gako okerra" + "Segurtasun-gako edo -esaldi bat baduzu, honek ere balio du." + "Sartu…" + "Berreskuratze-gakoa galdu duzu?" + "Berreskuratze-gakoa berretsi da" + "Sartu zure berreskuratze-gakoa" + "Berreskuratze-gakoa kopiatu da" + "Sortzen…" + "Gorde berreskuratze-gakoa" + "Idatzi berreskuratze-gako hau leku seguru batean, esate baterako, pasahitzen kudeatzaile, enkriptatutako ohar, edo kutxa gotor fisiko batean." + "Sakatu berreskuratze-gakoa kopiatzeko" + "Gorde berreskuratze-gakoa leku seguru batean" + "Ezingo duzu berreskuratze-gako berria atzitu urrats honen ondoren." + "Gorde al duzu berreskuratze-gakoa?" + "Sortu berreskuratze-gakoa" + "Ez partekatu inorekin!" + "Berreskuratzea ondo konfiguratu da" + "Konfiguratu berreskurapena" + "Bai, berrezarri orain" + "Ezin da desegin." + "Ziur zure identitatea berrezarri nahi duzula?" + "Sartu…" + "Berretsi zure identitatea berrezarri nahi duzula." + "Idatzi kontuaren pasahitza aurrera egiteko" + diff --git a/features/securebackup/impl/src/main/res/values-fa/translations.xml b/features/securebackup/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..8d30613 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,64 @@ + + + "خاموش کردن پشتیبان" + "روشن کردن پشتیبان" + "ذخیرهٔ کلیدهای پیام‌ها و هویت رمزنگاریتان به صورت امن روی کارساز. این کار می‌گذارد تاریخچهٔ پیام‌هایتان را روی هر افزارهٔ جدیدی ببینید. %1$s." + "ذخیره‌ساز کلید" + "بارگذاری کلیدها از ین افزاره" + "اجازهٔ ذخیرهٔ کلید" + "تغییر کلید بازیابی" + "ورود کلید بازیابی" + "ذخیره‌ساز کلیدتان از هم‌گام بودن در آمده." + "برپایی بازیابی" + "اگر همه دستگاه‌هایتان را گم کردید یا از سیستم خارج شدید، به پیام‌های رمزگذاری‌شده‌تان دسترسی پیدا کنید%1$s همه جا." + "گشودن %1$s در افزارهٔ میزکار" + "ورود دوباره به حسابتان" + "گزینش %1$s هنگام درخواست تأیید افزاره‌تان" + "«بازنشانی همه»" + "پیروی از دستورالعمل‌ها برای ایجاد کلید بازیابی جدید" + "ذخیرهٔ کلید بازیابی جدیدتان در مدیر گذرواژه یا یادداشت رمز شده" + "بازنشانی رمزنگاری برای حسابتان با استفاده از افزاره‌ای دیگر" + "ادامهٔ بازنشانی" + "جزییات حساب، آشنایان، ترجیحات و سیاههٔ گپ‌هایتان حفظ خواهند شد" + "تاریخچهٔ گپ‌هایتان را از دست خواهید داد" + "لازم است دوباره همهٔ آشنایان و افزاره‌های موجودتان را تأیید کنید" + "فقط اگر به افزاره‌ای وارد شده از پیش دسترسی ندارید و کلید بازیابیتان را گم کرده‌اید بازنشانی کنید." + "نمی‌توانید تأیید کنید؟ لازم است هویتتان را بازنشانی کنید." + "خاموش کردن" + "اگر از سیستم همه دستگاه ها خارج شده باشید، پیام های رمزگذاری شده خود را از دست خواهید داد." + "مطمئنید که می‌خواهید پشتیبان گیری را خاموش کنید؟" + "حذف فضای ذخیره سازی کلید، هویت رمزنگاری و کلیدهای پیام شما را از کارساز حذف می کند و ویژگی های امنیتی زیر را خاموش می کند:" + "سابقه پیام رمزگذاری شده در دستگاه های جدید نخواهید داشت" + "اگر از %1$s در همه جا خارج شده باشید، دسترسی به پیام های رمزگذاری شده خود را از دست خواهید داد" + "مطمئنید که می‌خواهید فضای ذخیره سازی کلید را خاموش کرده و آن را حذف کنید؟" + "گرفتن کلید بازیابی جدید در صورت فراموشی کلید کنونی. پس از تغییر دادن کلید بازیابیتان، کلید پیشین دیگر کار نخواهد کرد." + "تولید کلید بازیابی جدید" + "با کسی هم‌رسانیش نکنید!" + "کلید بازیابی تغییر کرد" + "تغییر کلید بازیابی؟" + "ایجاد کلید بازیابی جدید" + "اطمینان از این که کسی نمی‌تواند این صفحه را ببیند!" + "لطفاً برای دسترسی به ذخیره‌ساز کلیدتان دوباره تلاش کنید." + "کلید بازیابی اشتباه" + "کلید امنیتی یا عبارت امنیتی نیز باید کار کنند." + "ورود…" + "گم کردن کلید بازیابیتان؟" + "کلید بازیابی تأیید شد" + "ورود کلید بازیابیتان" + "کلید بازیابی رونوشت شد" + "تولید کردن…" + "ذخیرهٔ کلید بازیابی" + "این کلید بازیابی را در جایی امن چون مدیر گذرواژه، یادداشت رمز شده یا گاوصندوق فیزیکی بنویسید." + "زدن برای رونوشت از کلید بازیابی" + "ذخیرهٔ کلید بازیابیتان" + "پس از این برپایی قادر به دسترسی به کلید بازیابی جدیدتان نخواهید بود." + "کلید بازیابیتان را ذخیره کرده‌اید؟" + "پشتیبان گپتان با کلید بازیابی محافظت می‌شود. اگر پس از برپایی نیاز به کلید بازیابی جدیدی داشتید می‌توانید با گزینش «دگرگونی کلید بازیابی» دوباره ایجادش کنید." + "تولید کلید بازیابیتان" + "با کسی هم‌رسانیش نکنید!" + "برپایی بازیابی موفّق بود" + "برپایی بازیابی" + "بله. اکنون بازنشانی شود" + "این فرایند بازگشت‌ناپذیر است." + "ورود…" + diff --git a/features/securebackup/impl/src/main/res/values-fi/translations.xml b/features/securebackup/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..4a0cc4e --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,70 @@ + + + "Ota avainten säilytys pois käytöstä" + "Ota varmuuskopiointi käyttöön" + "Säilytä kryptografinen identiteettisi ja viestien avaimet turvallisesti palvelimellasi. Tämän avulla pääset käsiksi viestihistoriaan uusillakin laitteilla. %1$s." + "Avainten säilytys" + "Avainten säilytys on oltava käytössä, jotta palautus voidaan ottaa käyttöön." + "Lataa avaimet tästä laitteesta" + "Salli avainten säilytys" + "Vaihda palautusavain" + "Palauta kryptografinen identiteettisi ja viestihistoriasi palautusavaimella, jos olet menettänyt kaikki nykyiset laitteesi." + "Syötä palautusavain" + "Avainten säilytys ei ole tällä hetkellä synkronoitu." + "Ota palautus käyttöön" + "Pääset käsiksi salattuihin viesteihisi, jos menetät kaikki laitteesi tai olet kirjautunut ulos %1$s -sovelluksesta kaikkialla." + "Avaa %1$s tietokoneella" + "Kirjaudu tilillesi uudelleen" + "Kun sinua pyydetään vahvistamaan laitteesi, valitse %1$s" + "“Nollaa kaikki”" + "Seuraa ohjeita uuden palautusavaimen luomiseksi" + "Tallenna uusi palautusavaimesi salasanojen hallintaohjelmaan tai salattuun muistiinpanoon" + "Nollaa tilisi salaus toisella laitteella" + "Jatka nollausta" + "Tilitietosi, yhteystiedot, asetukset ja keskustelulista säilytetään" + "Menetät kaiken viestihistorian, joka on tallella vain palvelimella" + "Sinun on vahvistettava kaikki olemassa olevat laitteesi ja yhteystietosi uudelleen" + "Nollaa identiteettisi vain, jos et voi käyttää toista laitetta, johon olet kirjautunut, ja olet kadottanut palautusavaimesi." + "Etkö voi vahvistaa? Sinun on nollattava identiteettisi." + "Poista käytöstä" + "Menetät salatut viestisi, jos kirjaudut ulos kaikista laitteista." + "Haluatko varmasti poistaa varmuuskopioinnin käytöstä?" + "Avainten säilytyksen poistaminen poistaa sinun kryptografisen identiteetin ja viestien avaimet palvelimeltasi ja poistaa seuraavat suojausominausuudet käytöstä:" + "Et saa salattua viestihistoriaa uusilla laitteilla" + "Menetät pääsyn salattuihin viestihisi, jos kirjaudut ulos %1$s -sovelluksesta kaikkialla." + "Haluatko varmasti ottaa avainten säilytyksen pois käytöstä ja poistaa sen?" + "Hanki uusi palautusavain, jos olet kadottanut nykyisen avaimen. Palautusavaimen vaihtamisen jälkeen vanha avaimesi ei enää toimi." + "Luo uusi palautusavain" + "Älä jaa tätä kenenkään kanssa!" + "Palautusavain vaihdettu" + "Vaihdetaanko palautusavain?" + "Luo uusi palautusavain" + "Varmista, ettei kukaan näe tätä ruutua!" + "Yritä uudelleen vahvistaaksesi pääsyn avainten säilytykseen." + "Väärä palautusavain" + "Jos sinulla on turva-avain tai turvalause, sekin toimii." + "Syötä…" + "Hukkasitko palautusavaimesi?" + "Palautusavain vahvistettu" + "Syötä palautusavaimesi" + "Palautusavain kopioitu" + "Luodaan…" + "Tallenna palautusavain" + "Kirjoita tämä palautusavain turvalliseen paikkaan, kuten salasanojen hallintaohjelmaan, salattuun muistiinpanoon tai fyysiseen kassakaappiin." + "Kopioi palautusavain napauttamalla" + "Tallenna palautusavain turvalliseen paikkaan" + "Et voi palata katsomaan uutta palautusavaintasi uudelleen tämän vaiheen jälkeen." + "Oletko tallentanut palautusavaimesi?" + "Avainten säilytys on suojattu palautusavaimella. Jos tarvitset uuden palautusavaimen tämän jälkeen, voit luoda uuden valitsemalla ‘Vaihda palautusavain’." + "Luo palautusavaimesi" + "Älä jaa tätä kenenkään kanssa!" + "Palautuksen käyttöönotto onnistui" + "Ota palautus käyttöön" + "Kyllä, nollaa nyt" + "Tätä prosessia ei voi peruuttaa." + "Haluatko varmasti nollata identiteettisi?" + "Tapahtui tuntematon virhe. Tarkista, että tilisi salasana on oikein ja yritä uudelleen." + "Syötä…" + "Vahvista, että haluat nollata identiteettisi." + "Kirjoita tilisi salasana jatkaaksesi" + diff --git a/features/securebackup/impl/src/main/res/values-fr/translations.xml b/features/securebackup/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..3b3f209 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,70 @@ + + + "Désactiver la sauvegarde" + "Activer la sauvegarde" + "Stockez votre identité cryptographique et vos clés de message en toute sécurité sur le serveur. Cela vous permettra de consulter l’historique de vos messages sur tous les nouveaux appareils. %1$s." + "Stockage des clés" + "Le stockage des clés doit être activé pour configurer la restauration." + "Télécharger les clés depuis cet appareil" + "Autoriser le stockage des clés" + "Changer la clé de récupération" + "Récupérez votre identité cryptographique et l’historique de vos messages à l’aide d’une clé de récupération si vous avez perdu tous vos appareils existants." + "Utiliser la clé de récupération" + "Le stockage de vos clés est actuellement désynchronisé." + "Configurer la sauvegarde" + "Accédez à vos messages chiffrés si vous perdez tous vos appareils ou que vous êtes déconnecté de %1$s partout." + "Ouvrez %1$s sur un ordinateur" + "Connectez-vous à nouveau à votre compte" + "Lorsque vous devrez vérifier la session, choisissez %1$s" + "“Réinitialiser”" + "Suivez les instructions pour créer une nouvelle clé de récupération" + "Enregistrez votre nouvelle clé dans un gestionnaire de mots de passe ou dans une note chiffrée" + "Réinitialisez le chiffrement de votre compte en utilisant un autre appareil" + "Continuer la réinitialisation" + "Les détails de votre compte, vos contacts, vos préférences et votre liste de discussions seront conservés" + "Vous perdrez l’historique de vos messages" + "Vous devrez vérifier à nouveau tous vos appareils et tous vos contacts" + "Ne réinitialisez votre identité que si vous n’avez plus accès à aucune autre session et que vous avez perdu votre clé de récupération." + "Vous ne pouvez pas confirmer ? Vous devez réinitialiser votre identité." + "Désactiver" + "Vous perdrez vos messages chiffrés si vous vous déconnectez de toutes vos sessions." + "Êtes-vous certain de vouloir désactiver la sauvegarde ?" + "Désactiver la sauvegarde supprimera votre clé de récupération actuelle et désactivera d’autres mesures de sécurité. Dans ce cas :" + "Pas d’accès à l’historique des discussions chiffrées sur vos nouveaux appareils" + "Perte de l’accès à vos messages chiffrés si vous êtes déconnecté de %1$s partout" + "Êtes-vous certain de vouloir désactiver la sauvegarde ?" + "Obtenez une nouvelle clé de récupération dans le cas où vous avez oublié l’ancienne. Après le changement, l’ancienne clé ne sera plus utilisable." + "Générer une nouvelle clé" + "Ne partagez cela avec personne !" + "Clé de récupération modifée" + "Changer la clé de récupération ?" + "Créer une nouvelle clé de récupération" + "Assurez vous que personne d’autre ne regarde votre écran !" + "Veuillez réessayer pour confirmer l’accès à votre stockage de clés." + "Clé de récupération incorrecte" + "Si vous avez une clé de sécurité ou une phrase de sécurité, cela fonctionnera également." + "Saisissez la clé ici…" + "Clé de récupération perdue ?" + "Clé de récupération confirmée" + "Saisissez votre clé de récupération" + "Clé de récupération copiée" + "Génération…" + "Enregistrer la clé" + "Recopier cette clé de récupération dans un endroit sûr, comme un gestionnaire de mots de passe, une note chiffrée ou un coffre-fort physique." + "Taper pour copier la clé" + "Sauvegarder la clé" + "La clé ne pourra plus être affichée après cette étape." + "Avez-vous sauvegardé votre clé de récupération ?" + "Votre sauvegarde est protégée par votre clé de récupération. Si vous avez besoin d’une nouvelle clé après la configuration, vous pourrez en créer une nouvelle en cliquant sur \"Changer la clé de récupération\"." + "Générer la clé de récupération" + "Ne partagez cela avec personne !" + "Sauvegarde mise en place avec succès" + "Configurer la sauvegarde" + "Oui, réinitialisez maintenant" + "Cette opération ne peut pas être annulée." + "Êtes-vous sûr de vouloir réinitialiser votre identité ?" + "Une erreur s’est produite. Vérifiez que le mot de passe de votre compte est correct et réessayez." + "Saisissez la clé ici…" + "Veuillez confirmer que vous souhaitez réinitialiser votre identité." + "Saisissez le mot de passe de votre compte pour continuer" + diff --git a/features/securebackup/impl/src/main/res/values-hu/translations.xml b/features/securebackup/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..92212a4 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,70 @@ + + + "Biztonsági mentés kikapcsolása" + "Biztonsági mentés bekapcsolása" + "Tárolja kriptográfiai személyazonosságát és üzenetkulcsait biztonságosan a kiszolgálón. Ez lehetővé teszi, hogy bármilyen új eszközön megtekinthesse üzenetelőzményeit. %1$s." + "Kulcstároló" + "A helyreállítás beállításához be kell kapcsolni a kulcstárolást." + "Kulcsok feltöltése erről az eszközről" + "Kulcstárolás engedélyezése" + "Helyreállítási kulcs módosítása" + "Ha az összes meglévő eszközét elvesztette, akkor egy helyreállítási kulccsal visszaszerezheti a kriptográfiai személyazonosságát és az üzenetelőzményeit." + "Adja meg a helyreállítási kulcsot" + "A kulcstároló jelenleg nincs szinkronizálva." + "Helyreállítás beállítása" + "Szerezzen hozzáférést a titkosított üzeneteihez, ha elvesztette az összes eszközét, vagy ha mindenütt kijelentkezett az %1$sből." + "Nyissa meg az %1$set egy asztali eszközön" + "Jelentkezzen be újra a fiókjába" + "Amikor az eszköz ellenőrzését kéri, válassza ezt a lehetőséget: %1$s" + "„Minden visszaállítása”" + "Kövesse az utasításokat egy új helyreállítási kulcs létrehozásához" + "Mentse az új helyreállítási kulcsot egy jelszókezelőbe vagy egy titkosított jegyzetbe." + "A fiók titkosításának visszaállítása egy másik eszköz használatával" + "Visszaállítás folytatása" + "A fiókadatok, a kapcsolatok, a beállítások és a csevegéslista megmarad" + "Elveszíti meglévő üzenetelőzményeit" + "Újból ellenőriznie kell az összes meglévő eszközét és csevegőpartnerét" + "Csak akkor állítsa vissza a személyazonosságát, ha nem fér hozzá másik bejelentkezett eszközhöz, és elvesztette a helyreállítási kulcsot." + "Állítsa alaphelyzetbe a személyazonosságát, ha más módon nem tudja megerősíteni" + "Kikapcsolás" + "Ha kijelentkezik az összes eszközéről, akkor elveszti a titkosított üzeneteit." + "Biztos, hogy kikapcsolja a biztonsági mentéseket?" + "A biztonsági mentés kikapcsolása eltávolítja a jelenlegi titkosítási kulcsának mentését, és kikapcsol más biztonsági funkciókat is. Ebben az esetben:" + "Nem lesznek meg a titkosított üzenetek előzményei az új eszközein" + "Elveszti a hozzáférését a titkosított üzeneteihez, ha mindenhol kilép az %1$sből" + "Biztos, hogy kikapcsolja a biztonsági mentéseket?" + "Szerezzen új helyreállítási kulcsot, ha elvesztette a meglévőt. A helyreállítása kulcsa módosítása után a régi már nem fog működni." + "Új helyreállítási kulcs előállítása" + "Ezt ne ossza meg senkivel!" + "Helyreállítási kulcs lecserélve" + "Módosítja a helyreállítási kulcsot?" + "Új helyreállítási kulcs létrehozása" + "Győződjön meg arról, hogy senki sem látja ezt a képernyőt!" + "Próbálja újra megerősíteni a kulcstárolóhoz való hozzáférést." + "Helytelen helyreállítási kulcs" + "Ha van biztonsági kulcsa vagy biztonsági jelmondata, akkor ez is fog működni." + "Megadás…" + "Elvesztette a helyreállítási kulcsát?" + "Helyreállítási kulcs megerősítve" + "Adja meg a helyreállítási kulcsot" + "Helyreállítási kulcs másolva" + "Előállítás…" + "Helyreállítási kulcs mentése" + "Írja le a helyreállítási kulcsát valami biztonságos helyre, például mentse egy jelszókezelőbe, egy titkosított jegyzetbe vagy egy fizikai széfbe." + "Koppintson a helyreállítási kulcs másolásához" + "Mentse el a helyreállítási kulcsát" + "Ezután a lépés után nem fog tudni hozzáférni az új helyreállítási kulcsához." + "Mentette a helyreállítási kulcsát?" + "A csevegései biztonsági mentését a helyreállítási kulcsa védi. Ha új helyreállítási kulcsra van szüksége a beállítás után, akkor a „Helyreállítási kulcs módosítása” választásával újból létrehozhat egyet." + "Helyreállítási kulcs előállítása" + "Ezt ne ossza meg senkivel!" + "A helyreállítás beállítása sikeres" + "Helyreállítás beállítása" + "Igen, visszaállítás most" + "Ez a folyamat visszafordíthatatlan." + "Biztos, hogy visszaállítja a titkosítást?" + "Ismeretlen hiba történt. Ellenőrizze, hogy a fiókja jelszava helyes-e, és próbálja meg újra." + "Megadás…" + "Erősítse meg, hogy vissza szeretné állítani a titkosítást." + "A folytatáshoz adja meg fiókja jelszavát" + diff --git a/features/securebackup/impl/src/main/res/values-in/translations.xml b/features/securebackup/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..bf88293 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,70 @@ + + + "Matikan pencadangan" + "Nyalakan pencadangan" + "Simpan identitas kriptografi Anda dan kunci-kunci pesan secara aman di server. Ini akan memungkinkan Anda untuk melihat riwayat pesan Anda di perangkat yang baru. %1$s." + "Penyimpanan kunci" + "Penyimpanan kunci harus diaktifkan untuk menyiapkan pemulihan." + "Unggah kunci dari perangkat ini" + "Izinkan penyimpanan kunci" + "Ubah kunci pemulihan" + "Pulihkan identitas kriptografi dan riwayat pesan Anda dengan kunci pemulihan jika Anda kehilangan semua perangkat yang ada." + "Masukkan kunci pemulihan" + "Penyimpanan kunci Anda saat ini tidak sinkron." + "Siapkan pemulihan" + "Dapatkan akses ke pesan terenkripsi Anda jika Anda kehilangan semua perangkat Anda atau keluar dari %1$s di mana pun." + "Buka %1$s di perangkat desktop" + "Masuk ke akun Anda lagi" + "Saat diminta untuk memverifikasi perangkat Anda, pilih %1$s" + "“Atur ulang semua”" + "Ikuti petunjuk untuk membuat kunci pemulihan baru" + "Simpan kunci pemulihan baru Anda dalam pengelola kata sandi atau catatan terenkripsi" + "Atur ulang enkripsi untuk akun Anda menggunakan perangkat lain" + "Lanjutkan pengaturan ulang" + "Detail akun, kontak, preferensi, dan daftar obrolan Anda akan disimpan" + "Anda akan kehilangan riwayat pesan yang hanya disimpan di server" + "Anda perlu memverifikasi semua perangkat dan kontak yang ada lagi" + "Hanya atur ulang identitas Anda jika Anda tidak memiliki akses ke perangkat lain yang masuk dan Anda kehilangan kunci pemulihan." + "Tidak dapat mengonfirmasi? Anda perlu mengatur ulang identitas Anda." + "Matikan" + "Anda akan kehilangan pesan terenkripsi jika Anda keluar dari semua perangkat." + "Apakah Anda yakin ingin mematikan pencadangan?" + "Mematikan pencadangan akan menghapus pencadangan kunci terenkripsi saat ini dan mematikan fitur keamanan lainnya. Dalam hal ini, Anda akan:" + "Tidak memiliki riwayat pesan terenkripsi di perangkat baru" + "Kehilangan akses ke pesan terenkripsi Anda jika keluar dari %1$s di mana pun" + "Apakah Anda yakin ingin mematikan pencadangan?" + "Dapatkan kunci pemulihan yang baru jika Anda kehilangan kunci pemulihan saat ini. Setelah mengganti kunci pemulihan Anda, yang lama tidak akan bekerja lagi." + "Buat kunci pemulihan baru" + "Jangan bagikan ini kepada siapa pun!" + "Kunci pemulihan diganti" + "Ubah kunci pemulihan?" + "Buat kunci pemulihan baru" + "Pastikan tidak ada yang bisa melihat layar ini!" + "Silakan coba lagi untuk mengonfirmasi akses ke penyimpanan kunci Anda." + "Kunci pemulihan salah" + "Jika Anda memiliki kunci keamanan atau frasa keamanan, ini juga bisa digunakan." + "Masukkan…" + "Kehilangan kunci pemulihan Anda?" + "Kunci pemulihan dikonfirmasi" + "Masukkan kunci pemulihan Anda" + "Kunci pemulihan disalin" + "Membuat…" + "Simpan kunci pemulihan" + "Tuliskan kunci pemulihan ini di tempat yang aman, seperti pengelola kata sandi, catatan terenkripsi, atau brankas fisik." + "Ketuk untuk menyalin kunci pemulihan" + "Simpan kunci pemulihan Anda" + "Anda tidak akan dapat mengakses kunci pemulihan Anda setelah langkah ini." + "Apakah Anda sudah menyimpan kunci pemulihan Anda?" + "Pencadangan percakapan Anda sedang dilindungi oleh sebuah kunci pemulihan. Jika Anda perlu kunci pemulihan yang baru setelah penyiapan, Anda dapat membuat ulang dengan memilih \'Ubah kunci pemulihan\'." + "Buat kunci pemulihan Anda" + "Jangan bagikan ini kepada siapa pun!" + "Penyiapan pemulihan berhasil" + "Siapkan pemulihan" + "Ya, atur ulang sekarang" + "Proses ini tidak dapat diurungkan." + "Apakah Anda yakin ingin mengatur ulang identitas Anda?" + "Terjadi kesalahan yang tidak diketahui. Harap periksa apakah kata sandi akun Anda sudah benar dan coba lagi." + "Masukkan…" + "Konfirmasikan bahwa Anda ingin mengatur ulang identitas Anda." + "Masukkan kata sandi akun Anda untuk melanjutkan" + diff --git a/features/securebackup/impl/src/main/res/values-it/translations.xml b/features/securebackup/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..fbe2c87 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,70 @@ + + + "Disattiva il backup" + "Attiva il backup" + "Archivia la tua identità crittografica e le chiavi dei messaggi in modo sicuro sul server. Ciò ti consentirà di visualizzare la cronologia dei messaggi su tutti i nuovi dispositivi. %1$s." + "Archiviazione chiavi" + "L\'archiviazione delle chiavi deve essere attivata per configurare il ripristino." + "Carica le chiavi da questo dispositivo" + "Consenti l\'archiviazione delle chiavi" + "Cambia la chiave di recupero" + "Recupera la tua identità crittografica e la cronologia dei messaggi con una chiave di recupero se hai perso tutti i dispositivi esistenti." + "Inserisci la chiave di recupero" + "L\'archiviazione delle chiavi non è sincronizzata." + "Configura il recupero" + "Ottieni l\'accesso ai tuoi messaggi criptati nel caso perdi tutti i dispositivi o vieni disconnesso da %1$s su tutti i dispositivi." + "Apri %1$s in un dispositivo desktop" + "Accedi nuovamente al tuo account" + "Quando ti viene chiesto di verificare il tuo dispositivo, seleziona %1$s" + "“Reimposta tutto”" + "Segui le istruzioni per creare una nuova chiave di recupero" + "Salva la tua nuova chiave di recupero in un gestore di password o in una nota criptata" + "Reimposta la crittografia del tuo account utilizzando un altro dispositivo" + "Continua il ripristino" + "I dettagli del tuo account, i contatti, le preferenze e l\'elenco delle conversazioni verranno conservati" + "Perderai la cronologia dei messaggi esistente" + "Dovrai verificare nuovamente tutti i dispositivi e i contatti esistenti" + "Reimposta la tua identità solo se non hai accesso a un altro dispositivo su cui hai effettuato l\'accesso e hai perso la chiave di recupero." + "Reimposta la tua identità nel caso in cui non riesci a confermare in un altro modo" + "Disattiva" + "Perderai i tuoi messaggi cifrati se sei disconnesso da tutti i dispositivi." + "Vuoi davvero disattivare il backup?" + "La disattivazione del backup rimuoverà il backup dell\'attuale chiave crittografica e disattiverà altre funzioni di sicurezza. In questo caso:" + "Non avrai la cronologia dei messaggi cifrati su nuovi dispositivi" + "Perderai l\'accesso ai tuoi messaggi cifrati se ti sei disconnesso da %1$s ovunque" + "Vuoi davvero disattivare il backup?" + "Ottieni una nuova chiave di recupero se hai perso quella esistente. Dopo averla cambiata, quella vecchia non funzionerà più." + "Genera una nuova chiave di recupero" + "Non condividerla con nessuno!" + "Chiave di recupero cambiata" + "Cambiare la chiave di recupero?" + "Crea una nuova chiave di recupero" + "Assicurati che nessuno possa vedere questa schermata!" + "Riprova per confermare l\'accesso all\'archivio delle chiavi." + "Chiave di recupero errata" + "Se hai una chiave di sicurezza o una password, andrà bene anche questo." + "Inserisci…" + "Hai perso la chiave di recupero?" + "Chiave di recupero confermata" + "Inserisci la tua chiave di recupero" + "Chiave di recupero copiata" + "Generazione…" + "Salva la chiave di recupero" + "Annota questa chiave di recupero in un posto sicuro, come un gestore di password, una nota criptata o una cassaforte fisica." + "Tocca per copiare la chiave di recupero" + "Salva la tua chiave di recupero" + "Dopo questo passaggio non potrai accedere alla nuova chiave di recupero." + "Hai salvato la chiave di recupero?" + "Il backup della chat è protetto da una chiave di recupero. Se hai bisogno di una nuova chiave di recupero dopo la configurazione, puoi ricrearla selezionando \"Cambia chiave di recupero\"." + "Genera la tua chiave di recupero" + "Non condividerla con nessuno!" + "Configurazione del recupero completata" + "Configura il recupero" + "Sì, reimposta ora" + "Questo processo è irreversibile." + "Sei sicuro di voler reimpostare la crittografia?" + "Si è verificato un errore sconosciuto. Controlla che la password del tuo account sia corretta e riprova." + "Inserisci…" + "Conferma di voler reimpostare la crittografia." + "Inserisci la password del tuo account per continuare" + diff --git a/features/securebackup/impl/src/main/res/values-ka/translations.xml b/features/securebackup/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..3e17cb4 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,47 @@ + + + "სარეზერვო ასლის გამორთვა" + "სარეზერვო ასლის ჩართვა" + "სარეზერვო ასლი უზრუნველყოფს იმას, რომ თქვენ შეტყობინებების ისტორიას არ დაკარგავთ. %1$s" + "გასაღების საცავი" + "აღდგენის გასაღების შეცვლა" + "შეიყვანეთ აღდგენის გასაღები" + "თქვენი ჩატის სარეზერვო ასლი ამჟამად არ არის სინქრონიზებული." + "აღდგენის დაყენება" + "მიიღეთ წვდომა თქვენს დაშიფრულ შეტყობინებებზე, თუ დაკარგავთ თქვენს ყველა მოწყობილობას ან გამოხვალთ სისტემიდან %1$s-დან ყველგან." + "გახსენით %1$s კომპიუტერზე" + "შედით თქვენს ანგარიშში კიდევ ერთხელ" + "გაანულეთ თქვენი ანგარიშის დაშიფვრა სხვა მოწყობილობის დახმარებით" + "გამორთვა" + "თქვენ დაკარგავთ დაშიფრულ შეტყობინებებს, თუ ყველა მოწყობილობიდან გამოხვალთ." + "დარწმუნებული ხართ, რომ გსურთ გამორთოთ სარეზერვო ასლი?" + "სარეზერვო ასლის გამორთვა წაშლის თქვენი მიმდინარე დაშიფვრის გასაღების სარეზერვო ასლს და გამორთავს უსაფრთხოების სხვა ფუნქციებს. ამ შემთხვევაში, თქვენ:" + "არ გექნებათ დაშიფვრული შეტყობინებების ისტორია ახალ მოწყობილობებზე" + "დაკარვავთ წვდომას დაშიფრულ შეტყობინებებზე თუ ყველგან გამოხვალთ %1$s-დან" + "დარწმუნებული ხართ, რომ გსურთ გამორთოთ სარეზერვო ასლი?" + "მიიღეთ ახალი აღდგენის გასაღები, თუ დაკარგეთ არსებული. აღდგენის გასაღების შეცვლის შემდეგ, ძველი აღარ იმუშავებს." + "ახალი აღდგენის გასაღების შექმნა" + "არავის გაუზიაროთ!" + "აღდგენის გასაღები შეიცვალა" + "გსურთ აღდგენის გასაღების შეცვლა?" + "დარწმუნდით, რომ ვერავინ ხედავს ამ ეკრანს!" + "გთხოვთ, სცადოთ წვდომის დადასტურება გასაღებების დამგროვებელთან კვლავ." + "აღდგენის არასწორი გასაღები" + "თუ თქვენ გაქვთ უსაფრთხოების გასაღები ან უსაფრთხოების ფრაზა, ეს ასევე იმუშავებს." + "შეყვანა" + "აღდგენის გასაღები დადასტურებულია" + "დაკოპირებულია აღდგენის გასაღები" + "გენერირება…" + "აღდგენის გასაღების შენახვა" + "ჩაწერეთ ეს აღდგენის გასაღები სადმე უსაფრთხო ადგილას, მაგალითად პაროლების მენეჯერში, დაშიფრულ შენიშვნაში ან ფიზიკურ სეიფში." + "აღდგენის გასაღების დასაკოპირებლად, დააწკაპუნეთ" + "შეინახეთ აღდგენის გასაღები" + "თქვენ ვერ შეძლებთ წვდომას თქვენი ახალი აღდგენის გასაღებზე ამ ნაბიჯის შემდეგ." + "შეინახეთ თქვენი აღდგენის გასაღები?" + "თქვენი ჩატის სარეზერვო ასლი დაცულია აღდგენის გასაღებით. თუ დაყენების შემდეგ გჭირდებათ ახალი აღდგენის გასაღები, შეგიძლიათ ხელახლა შექმნათ „აღდგენის გასაღების შეცვლის“ არჩევით." + "შექმენით აღდგენის გასაღები" + "არავის გაუზიაროთ!" + "აღდგენის დაყენება წარმატებით დასრულდა" + "აღდგენის დაყენება" + "შეყვანა" + diff --git a/features/securebackup/impl/src/main/res/values-ko/translations.xml b/features/securebackup/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..804a907 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,70 @@ + + + "백업 비활성화" + "백업 활성화" + "암호화 신원 및 메시지 키를 서버에 안전하게 저장하세요. 이로써 새로운 기기에서 메시지 이력을 확인할 수 있습니다. %1$s." + "키 저장소" + "복구 설정을 하려면 키 저장을 켜야 합니다." + "이 장치에서 키 업로드" + "키 저장 허용" + "복구 키 변경" + "기존의 모든 기기를 분실한 경우, 복구 키를 사용하여 암호화 ID와 메시지 기록을 복구할 수 있습니다." + "복구 키를 입력하세요" + "현재 키 저장소가 동기화되지 않았습니다." + "복구 설정" + "모든 기기를 분실하거나 %1$s 에서 로그아웃된 경우에도 암호화된 메시지에 액세스할 수 있습니다." + "데스크톱 장치에서 %1$s 을 엽니다." + "계정에 다시 로그인하세요" + "장치를 확인하라는 메시지가 표시되면, %1$s 을 선택하세요" + "“모든 항목을 초기화합니다”" + "지침에 따라 새 복구 키를 만드세요." + "새 복구 키를 암호 관리자 또는 암호화된 메모에 저장하세요." + "다른 기기를 사용하여 계정의 암호화를 재설정하세요." + "계속 재설정" + "귀하의 계정 정보, 연락처, 기본 설정 및 채팅 목록은 보관됩니다" + "서버에만 저장된 모든 메시지 기록이 손실됩니다." + "기존 장치와 연락처를 모두 다시 확인해야 합니다." + "다른 로그인 기기에 액세스할 수 없고 복구 키를 분실한 경우에만 ID를 재설정하세요." + "확인할 수 없나요? 신원을 재설정해야 합니다." + "비활성화" + "모든 장치에서 로그아웃하면 암호화된 메시지가 삭제됩니다." + "정말로 백업을 비활성화하시겠어요?" + "키 저장소를 삭제하면 서버에서 암호화 신원 및 메시지 키가 삭제되며 다음과 같은 보안 기능이 비활성화됩니다:" + "새 장치에는 암호화된 메시지 기록이 남아 있지 않습니다." + "%1$s 에서 모든 세션이 종료되면 암호화된 메시지에 액세스할 수 없게 됩니다." + "정말로 키 저장소를 비활성화하고 삭제하시겠습니까?" + "기존 복구 키를 분실한 경우 새 복구 키를 받으세요. 복구 키를 변경하면 이전 키는 더 이상 사용할 수 없습니다." + "새로운 복구 키 생성" + "이 내용을 누구와도 공유하지 마십시오!" + "복구 키가 변경되었습니다." + "복구 키 변경하시겠습니까?" + "새 복구 키 만들기" + "아무도 이 화면을 볼 수 없도록 하세요!" + "키 저장소에 대한 액세스를 확인하시려면 다시 시도해 주세요." + "잘못된 복구 키" + "보안 키나 보안 문구를 가지고 있다면 이 방법도 작동합니다." + "입력…" + "복구 키를 분실하셨나요?" + "복구 키 확인됨" + "복구 키를 입력하세요" + "복사된 복구 키" + "생성 중…" + "복구 키 저장" + "이 복구 키를 암호 관리자, 암호화된 메모 또는 물리적 금고와 같은 안전한 곳에 기록해 두십시오." + "탭하여 복구 키 복사" + "복구 키를 안전한 곳에 보관하세요." + "이 단계를 완료하면 새 recovery key에 액세스할 수 없습니다." + "복구 키를 저장하셨습니까?" + "키 저장소는 복구 키로 보호됩니다. 설정 후 새로운 복구 키가 필요한 경우 \'복구 키 변경\'을 선택하여 재작성할 수 있습니다." + "복구 키 생성" + "이 내용을 누구와도 공유하지 마십시오!" + "복구 설정 성공" + "복구 설정" + "네, 지금 재설정하세요" + "이 과정은 되돌릴 수 없습니다." + "정말로 신원을 재설정하시겠습니까?" + "알 수 없는 오류가 발생했습니다. 계정 비밀번호가 올바른지 확인하고 다시 시도하십시오." + "입력…" + "신원 재설정을 확인하시겠습니까?" + "계정 비밀번호를 입력하여 진행하세요" + diff --git a/features/securebackup/impl/src/main/res/values-nb/translations.xml b/features/securebackup/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..518e23e --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,70 @@ + + + "Slett nøkkellagring" + "Slå på sikkerhetskopiering" + "Lagre din kryptografiske identitet og meldingsnøkler sikkert på serveren. Dette gjør at du kan se meldingshistorikken din på alle nye enheter. %1$s." + "Nøkkellagring" + "Nøkkellagring må være slått på for å konfigurere gjenoppretting." + "Last opp nøkler fra denne enheten" + "Tillat nøkkellagring" + "Endre gjenopprettingsnøkkel" + "Gjenopprett din kryptografiske identitet og meldingshistorikk med en gjenopprettingsnøkkel hvis du har mistet alle dine brukte enheter." + "Skriv inn gjenopprettingsnøkkel" + "Nøkkellagringen din er for øyeblikket ikke synkronisert." + "Konfigurer gjenoppretting" + "Få tilgang til de krypterte meldingene dine hvis du mister alle enhetene dine eller blir logget ut av %1$s overalt." + "Åpne %1$s på en datamaskin" + "Logg på kontoen din igjen" + "Når du blir bedt om å verifisere din enhet, velger du %1$s" + "Tilbakestill alt" + "Følg instruksjonene for å opprette en ny gjenopprettingsnøkkel" + "Du bør lagre din nye gjenopprettingsnøkkel i en passordbehandler eller i et kryptert notat" + "Tilbakestill krypteringen for kontoen din ved hjelp av en annen enhet" + "Fortsett tilbakestillingen" + "Dine kontodetaljer, kontakter, innstillinger og chatteliste vil bli beholdt" + "Du mister all meldingshistorikk som bare er lagret på serveren" + "Du må verifisere alle eksisterende enheter og kontakter på nytt" + "Tilbakestill identiteten din bare hvis du ikke har tilgang til en annen pålogget enhet og du har mistet gjenopprettingsnøkkelen." + "Kan du ikke bekrefte? Du må tilbakestille identiteten din." + "Slå av" + "Du mister de krypterte meldingene dine hvis du er logget ut av alle enheter." + "Er du sikker på at du vil slå av sikkerhetskopiering?" + "Sletting av nøkkellagring vil fjerne din kryptografiske identitet og meldingsnøkler fra serveren og deaktivere følgende sikkerhetsfunksjoner:" + "Du vil ikke ha kryptert meldingshistorikk på nye enheter" + "Du mister tilgangen til de krypterte meldingene dine hvis du er logget ut av %1$s overalt" + "Er du sikker på at du vil slå av nøkkellagring og slette den?" + "Få en ny gjenopprettingsnøkkel hvis du har mistet den eksisterende. Etter at du har endret gjenopprettingsnøkkelen, vil den gamle ikke lenger fungere." + "Generer en ny gjenopprettingsnøkkel" + "Ikke del dette med noen!" + "Gjenopprettingsnøkkel endret" + "Endre gjenopprettingsnøkkelen?" + "Opprett ny gjenopprettingsnøkkel" + "Sørg for at ingen kan se denne skjermen!" + "Prøv igjen for å verifisere tilgangen til nøkkellageret ditt." + "Feil gjenopprettingsnøkkel" + "Hvis du har en sikkerhetsnøkkel eller sikkerhetsfrase, vil dette også fungere." + "Angi…" + "Har du mistet gjenopprettingsnøkkelen?" + "Gjenopprettingsnøkkel bekreftet" + "Skriv inn gjenopprettingsnøkkelen din" + "Kopiert gjenopprettingsnøkkel" + "Genererer…" + "Lagre gjenopprettingsnøkkelen" + "Skriv ned denne gjenopprettingsnøkkelen på et trygt sted, for eksempel i en passordbehandler, på en kryptert lapp eller i en fysisk safe." + "Trykk for å kopiere gjenopprettingsnøkkelen" + "Lagre gjenopprettingsnøkkelen på et trygt sted" + "Du vil ikke ha tilgang til den nye gjenopprettingsnøkkelen etter dette trinnet." + "Har du lagret gjenopprettingsnøkkelen din?" + "Nøkkellagringen din er beskyttet av en gjenopprettingsnøkkel. Hvis du trenger en ny gjenopprettingsnøkkel etter konfigureringen, kan du opprette den på nytt ved å velge «Endre gjenopprettingsnøkkel»." + "Generer gjenopprettingsnøkkelen din" + "Ikke del dette med noen!" + "Gjenopprettingsoppsett vellykket" + "Konfigurer gjenoppretting" + "Ja, tilbakestill nå" + "Denne prosessen kan ikke angres." + "Er du sikker på at du vil tilbakestille identiteten din?" + "En ukjent feil har oppstått. Vennligst sjekk at passordet ditt er riktig og prøv igjen." + "Angi…" + "Bekreft at du vil tilbakestille identiteten din." + "Skriv inn passordet til kontoen din for å fortsette" + diff --git a/features/securebackup/impl/src/main/res/values-nl/translations.xml b/features/securebackup/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..047f06d --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,65 @@ + + + "Back-up uitschakelen" + "Back-up inschakelen" + "Sla je cryptografische identiteit en berichtsleutels veilig op de server op. Zo kun je je berichtgeschiedenis bekijken op nieuwe apparaten. %1$s." + "Sleutelopslag" + "Herstelsleutel wijzigen" + "Voer herstelsleutel in" + "Je sleutelopslag is momenteel niet gesynchroniseerd." + "Herstelmogelijkheid instellen" + "Krijg toegang tot je versleutelde berichten als je al je apparaten kwijtraakt of overal uit %1$s bent uitgelogd." + "Open %1$s op een desktopapparaat" + "Log opnieuw in op je account" + "Wanneer je wordt gevraagd om je apparaat te verifiëren, selecteer %1$s" + "“Alles opnieuw instellen”" + "Volg de instructies om een nieuwe herstelsleutel te maken" + "Sla je nieuwe herstelsleutel op in een wachtwoordmanager of versleutelde notitie" + "Stel de versleuteling voor je account opnieuw in met een ander apparaat" + "Doorgaan met opnieuw instellen" + "Je accountgegevens, contacten, voorkeuren en chatlijst worden bewaard" + "Je verliest alle berichtgeschiedenis die alleen op de server is opgeslagen" + "Je moet al je bestaande apparaten en contacten opnieuw verifiëren" + "Stel je identiteit alleen opnieuw in als je geen toegang hebt tot een ander aangemeld apparaat en je je herstelsleutel kwijt bent." + "Kun je dit niet bevestigen? Je zult je identiteit opnieuw moeten instellen." + "Uitschakelen" + "Je verliest je versleutelde berichten als je bent uitgelogd op alle apparaten." + "Weet je zeker dat je de back-up wilt uitschakelen?" + "Als je de back-up uitschakelt, verwijder je de back-up van je huidige versleuteling en schakel je andere beveiligingsfuncties uit. In dit geval zul je:" + "Geen berichtgeschiedenis hebben van versleutelde berichten op nieuwe apparaten" + "Toegang verliezen tot je versleutelde berichten als je overal uit %1$s bent uitgelogd." + "Weet je zeker dat je de back-up wilt uitschakelen?" + "Maak een nieuwe herstelsleutel aan als je je bestaande kwijt bent. Nadat je je herstelsleutel hebt gewijzigd, werkt je oude herstelsleutel niet meer." + "Genereer een nieuwe herstelsleutel" + "Deel dit met niemand!" + "Herstelsleutel gewijzigd" + "Herstelsleutel wijzigen?" + "Maak een nieuwe herstelsleutel" + "Zorg ervoor dat niemand dit scherm kan zien!" + "Probeer het opnieuw om toegang tot je sleutelopslag te bevestigen." + "Onjuiste herstelsleutel" + "Als je een beveiligingssleutel of beveiligingszin hebt, werkt dit ook." + "Voer in…" + "Herstelsleutel kwijt?" + "Herstelsleutel bevestigd" + "Herstelsleutel gekopieerd" + "Genereren…" + "Herstelsleutel opslaan" + "Bewaar je herstelsleutel op een veilige plek, zoals in een wachtwoordbeheerder, een versleutelde notitie of in een fysieke kluis." + "Tik om de herstelsleutel te kopiëren" + "Sla je herstelsleutel op" + "Na deze stap kun je je nieuwe herstelsleutel niet meer inzien." + "Heb je je herstelsleutel opgeslagen?" + "Je chatback-up wordt beschermd door een herstelsleutel. Als je na de installatie een nieuwe herstelsleutel nodig hebt, kun je deze opnieuw aanmaken door \'Herstelsleutel wijzigen\' te selecteren." + "Genereer je herstelsleutel" + "Deel dit met niemand!" + "Herstelmogelijkheid succesvol ingesteld" + "Herstelmogelijkheid instellen" + "Ja, nu opnieuw instellen" + "Dit proces is onomkeerbaar." + "Weet je zeker dat je je identiteit opnieuw wilt instellen?" + "Er is een onbekende fout opgetreden. Controleer of het wachtwoord van je account juist is en probeer het opnieuw." + "Voer in…" + "Bevestig dat je je identiteit opnieuw wilt instellen." + "Voer het wachtwoord van je account in om verder te gaan" + diff --git a/features/securebackup/impl/src/main/res/values-pl/translations.xml b/features/securebackup/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..90bee60 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,70 @@ + + + "Wyłącz backup" + "Włącz backup" + "Bezpiecznie przechowuj swoją tożsamość kryptograficzną i klucze wiadomości na serwerze. Umożliwi to przeglądanie historii wiadomości na każdym nowym urządzeniu. %1$s" + "Magazyn kluczy" + "Magazyn kluczy musi być włączony, aby włączyć przywracanie." + "Prześlij klucze z tego urządzenia" + "Zezwól na magazynowanie kluczy" + "Zmień klucz przywracania" + "Odzyskaj swoją tożsamość kryptograficzną i historię wiadomości za pomocą klucza przywracania, jeśli utraciłeś dostęp do wszystkich swoich urządzeń." + "Wprowadź klucz przywracania" + "Magazyn kluczy nie jest zsynchronizowany." + "Skonfiguruj przywracanie" + "Uzyskaj dostęp do swoich wiadomości szyfrowanych, jeśli utracisz wszystkie swoje urządzenia lub zostaniesz wylogowany z %1$s." + "Otwórz %1$s na urządzeniu stacjonarnym" + "Zaloguj się ponownie na swoje konto" + "Gdy pojawi się prośba o weryfikację urządzenia, wybierz %1$s" + "“Resetuj wszystko”" + "Postępuj zgodnie z instrukcjami, aby utworzyć nowy klucz przywracania" + "Zapisz nowy klucz przywracania w menedżerze haseł lub notatce szyfrowanej" + "Resetuj szyfrowanie swojego konta za pomocą drugiego urządzenia" + "Kontynuuj resetowanie" + "Szczegóły konta, kontakty, preferencje i lista czatów zostaną zachowane" + "Utracisz istniejącą historię wiadomości" + "Wymagana będzie ponowna weryfikacja istniejących urządzeń i kontaktów" + "Zresetuj swoją tożsamość tylko wtedy, gdy nie jesteś zalogowany na żadnym urządzeniu i straciłeś swój klucz przywracania." + "Zresetuj swoją tożsamość, jeśli nie możesz jej potwierdzić w inny sposób" + "Wyłącz" + "Jeśli wylogujesz się ze wszystkich urządzeń, stracisz wszystkie wiadomości szyfrowane." + "Czy na pewno chcesz wyłączyć backup?" + "Wyłączenie backupu spowoduje usunięcie kopii klucza szyfrowania i wyłączenie innych funkcji bezpieczeństwa. W takim przypadku będziesz:" + "Posiadał historii wiadomości szyfrowanych na nowych urządzeniach" + "Utracisz dostęp do wiadomości szyfrowanych, jeśli zostaniesz wszędzie wylogowany z %1$s" + "Czy na pewno chcesz wyłączyć backup?" + "Uzyskaj nowy klucz przywracania, jeśli straciłeś dostęp do obecnego. Po zmianie klucza przywracania stary nie będzie już działał." + "Generuj nowy klucz przywracania" + "Nie udostępniaj tego nikomu!" + "Zmieniono klucz przywracania" + "Zmienić klucz przywracania?" + "Utwórz nowy klucz przywracania" + "Upewnij się, że nikt nie widzi tego ekranu!" + "Spróbuj ponownie potwierdzić dostęp do magazynu kluczy." + "Nieprawidłowy klucz przywracania" + "To też zadziała, jeśli posiadasz klucz lub frazę bezpieczeństwa." + "Wprowadź…" + "Zgubiłeś swój kod przywracania?" + "Potwierdzono klucz przywracania" + "Wprowadź klucz przywracania" + "Skopiowano klucz przywracania" + "Generuję…" + "Zapisz klucz przywracania" + "Zapisz klucz przywracania w bezpiecznym miejscu, np. w menedżerze haseł, notatce szyfrowanej lub sejfie." + "Stuknij, by skopiować klucz przywracania" + "Zapisz klucz przywracania" + "Po tym kroku nie będziesz mieć dostępu do nowego klucza przywracania." + "Czy zapisałeś swój klucz przywracania?" + "Backup czatu jest chroniony przez klucz przywracania. Jeśli potrzebujesz utworzyć nowy klucz, możesz to zrobić wybierając `Zmień klucz przywracania`." + "Wygeneruj klucz przywracania" + "Nie udostępniaj tego nikomu!" + "Skonfigurowano przywracanie pomyślnie" + "Skonfiguruj przywracanie" + "Tak, zresetuj teraz" + "Tego procesu nie można odwrócić." + "Czy na pewno chcesz zresetować szyfrowanie?" + "Wystąpił nieznany błąd. Sprawdź, czy hasło jest poprawne i spróbuj ponownie." + "Wprowadź…" + "Potwierdź, że chcesz zresetować szyfrowanie." + "Wprowadź hasło, aby kontynuować" + diff --git a/features/securebackup/impl/src/main/res/values-pt-rBR/translations.xml b/features/securebackup/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..f613729 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,70 @@ + + + "Apagar o armazenamento de chaves" + "Ativar o backup" + "Armazene sua identidade criptográfica e chaves de mensagem com segurança no servidor. Isso permitirá que você visualize seu histórico de mensagens em dispositivos futuros. %1$s." + "Armazenamento de chaves" + "O armazenamento de chaves deve ser ativado para configurar a recuperação." + "Enviar chaves a partir deste dispositivo" + "Permitir o armazenamento de chaves" + "Alterar chave de recuperação" + "Recupere sua identidade criptográfica e o histórico de mensagens com uma chave de recuperação, caso você tenha perdido todos os dispositivos existentes." + "Digitar chave de recuperação" + "Seu armazenamento de chaves está fora de sincronia no momento." + "Configurar a recuperação" + "Tenha acesso às suas mensagens criptografadas se você perder todos os seus dispositivos ou for desconectado do %1$s em todos os dispositivos." + "Abra o %1$s em um computador" + "Entre na sua conta novamente" + "Ao ser solicitado para verificar o seu dispositivo, selecione %1$s" + "\"Redefinir tudo\"" + "Siga as instruções para criar uma nova chave de recuperação" + "Salve sua nova chave de recuperação em um gerenciador de senhas ou em uma nota criptografada" + "Redefinir a criptografia da sua conta usando outro dispositivo" + "Continuar a redefinição" + "Os detalhes da sua conta, contatos, preferências e lista de conversas serão mantidos" + "Você perderá qualquer histórico de mensagens armazenado somente no servidor" + "Você precisará verificar todos os seus dispositivos e contatos existentes novamente." + "Redefina sua identidade somente se você não tiver acesso a outro dispositivo conectado e se tiver perdido sua chave de recuperação." + "Não consegue confirmar? Você precisará redefinir sua identidade." + "Desativar" + "Você perderá suas mensagens criptografadas se for desconectado de todos os dispositivos." + "Tem certeza de que deseja desativar o backup?" + "Ao apagar o armazenamento de chaves, a sua identidade criptográfica e as chaves das mensagens serão apagadas do servidor e os seguintes recursos de segurança serão desativados:" + "Você não terá o histórico de mensagens criptografadas em dispositivos novos" + "Você perderá o acesso às suas mensagens criptografadas se for desconectado de %1$s em todos os dispositivos" + "Tem certeza de que deseja desativar o armazenamento de chaves e apagá-lo?" + "Obtenha uma nova chave de recuperação caso tenha perdido a existente. Depois de alterar sua chave de recuperação, a antiga não funcionará mais." + "Gerar uma nova chave de recuperação" + "Não compartilhe isso com ninguém!" + "Chave de recuperação alterada" + "Alterar chave de recuperação?" + "Criar uma nova chave de recuperação" + "Certifique-se de que ninguém possa ver essa tela!" + "Tente novamente para confirmar o acesso ao seu armazenamento de chaves." + "Chave de recuperação incorreta" + "Se você tiver uma chave de segurança ou frase de segurança, isso também funcionará." + "Digite…" + "Perdeu sua chave de recuperação?" + "Chave de recuperação confirmada" + "Digite sua chave de recuperação" + "Chave de recuperação copiada" + "Gerando…" + "Salvar chave de recuperação" + "Anote essa chave de recuperação em algum lugar seguro, como um gerenciador de senhas, uma nota criptografada ou um cofre físico." + "Toque para copiar a chave de recuperação" + "Guarde sua chave de recuperação em um lugar seguro" + "Você não poderá acessar sua nova chave de recuperação após essa etapa." + "Você salvou sua chave de recuperação?" + "Seu armazenamento de chaves é protegido por uma chave de recuperação. Se precisar de uma nova chave de recuperação após a configuração, você pode recriá-la selecionando “Alterar chave de recuperação”." + "Gere sua chave de recuperação" + "Não compartilhe isso com ninguém!" + "Configuração de recuperação bem-sucedida" + "Configurar a recuperação" + "Sim, redefinir agora" + "Esse processo é irreversível." + "Você tem certeza de que deseja redefinir sua identidade?" + "Ocorreu um erro desconhecido. Verifique se a senha da sua conta está correta e tente novamente." + "Digite…" + "Confirme que você deseja redefinir sua identidade." + "Digite a senha de sua conta para continuar" + diff --git a/features/securebackup/impl/src/main/res/values-pt/translations.xml b/features/securebackup/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..a6a58ac --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,70 @@ + + + "Desativar a cópia de segurança" + "Ativar a cópia de segurança" + "Guarda a tua identidade criptográfica e as chaves de mensagens de forma segura no servidor. Isto permitir-te-á ver o teu histórico de mensagens em qualquer dispositivo novo. %1$s." + "Armazenamento de chaves" + "O armazenamento de chaves deve ser ativado para configurar a recuperação." + "Carrega chaves a partir deste dispositivo" + "Permite o armazenamento de chaves" + "Alterar chave de recuperação" + "Recupera a tua identidade criptográfica e o histórico de mensagens com uma chave de recuperação, caso tenhas perdido todos os teus dispositivos existentes." + "Insere a chave de recuperação" + "O teu armazenamento de chaves está atualmente dessincronizado." + "Configurar recuperação" + "Obtém acesso às tuas mensagens cifradas mesmo se perderes todos os teus dispositivos ou se terminares todas as tuas sessões %1$s." + "Abre a %1$s num computador" + "Iniciar sessão novamente" + "Quando te for pedido para verificares o teu dispositivo, seleciona %1$s" + "“Repor tudo”" + "Segue as instruções para criar uma nova chave de recuperação" + "Guarda a tua nova chave de recuperação num gestor de senhas ou numa nota cifrada" + "Repor a cifragem da tua conta utilizando outro dispositivo" + "Continuar a reposição" + "Os detalhes da tua conta, contactos, preferências e lista de conversas serão mantidos." + "Perderás o acesso ao teu histórico de mensagens existente" + "Necessitarás de verificar todos os teus dispositivos e contactos novamente." + "Repõe a tua identidade apenas se não tiveres acesso a mais nenhum dispositivo com sessão iniciada e se tiveres perdido a tua chave de recuperação." + "Repõe a tua identidade caso não consigas confirmar de outra forma" + "Desligar" + "Perderás as tuas mensagens cifradas se tiveres terminado a sessão em todos os teus dispositivos." + "Tens a certeza que queres desativar a cópia de segurança?" + "Desativar a cópia de segurança irá remover a atual cópia da chave de cifragem e desativar outras funcionalidades de segurança. Neste caso, irás:" + "Não ter o histórico de mensagens cifradas em novos dispositivos" + "Perder o acesso às tuas mensagens cifradas se terminares todas as sessões %1$s" + "Tens a certeza que queres desativar a cópia de segurança?" + "Obtém uma nova chave de recuperação se tiveres perdido a atual. Depois de a alterares, a antiga deixará de funcionar." + "Gerar uma nova chave de recuperação" + "Não partilhes isto com ninguém!" + "Chave de recuperação alterada" + "Alterar a chave de recuperação?" + "Criar nova chave de recuperação" + "Certifica-te de que ninguém consegue ver esta página!" + "Tenta novamente para confirmar o acesso ao teu armazenamento de chaves." + "Chave de recuperação incorreta" + "Também funciona se tiveres uma chave ou frase de segurança." + "Inserir…" + "Perdeste a tua chave?" + "Chave de recuperação confirmada" + "Introduz a tua chave de recuperação" + "Chave de recuperação copiada" + "A gerar…" + "Guardar chave" + "Anota esta chave de recuperação num local seguro, como um gestor de palavras-passe, uma nota encriptada ou um cofre físico." + "Toca para copiar a chave de recuperação" + "Guarda a tua chave de recuperação" + "Não poderás aceder à tua nova chave de recuperação após este passo." + "Guardaste a tua chave de recuperação?" + "A tua cópia de segurança das conversas está protegida por uma chave de recuperação. Se precisares de uma nova chave após a configuração, podes recriá-la selecionando \"Alterar chave de recuperação\"." + "Gerar a tua chave de recuperação" + "Não partilhes isto com ninguém!" + "Recuperação configurada com sucesso" + "Configurar recuperação" + "Sim, repor agora" + "Este processo é irreversível." + "Tens a certeza que pretendes repor a tua cifra?" + "Um erro desconhecido aconteceu. Verifique se a senha da sua conta está correta e tente novamente." + "Inserir…" + "Confirma que pretendes realmente repor a tua cifra." + "Insere a tua palavra-passe para continuares" + diff --git a/features/securebackup/impl/src/main/res/values-ro/translations.xml b/features/securebackup/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..2eda1a5 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,70 @@ + + + "Dezactivați backupul" + "Activați backupul" + "Stocați identitatea criptografică și cheile de mesaje în siguranță pe server. Acest lucru vă va permite să vizualizați mesajele anterioare pe orice dispozitiv nou. %1$s." + "Backup" + "Stocarea cheilor trebuie activată pentru a configura recuperarea." + "Încărcați cheile de pe acest dispozitiv" + "Permiteți stocarea cheilor" + "Schimbați cheia de recuperare" + "Recuperați-vă identitatea criptografică și mesajele anterioare cu o cheie de recuperare dacă ați pierdut toate dispozitivele existente." + "Introduceți cheia de recuperare" + "Backup-ul pentru chat nu este sincronizat în prezent." + "Configurați recuperarea" + "Obțineți acces la mesajele dumneavoastră criptate dacă vă pierdeți toate dispozitivele sau sunteți deconectat de la %1$s peste tot." + "Deschideți %1$s pe un dispozitiv desktop" + "Conectați-vă din nou la contul dumneavoastră" + "Când vi se cere să vă verificați dispozitivul, selectați%1$s" + "„Resetați tot”" + "Urmați instrucțiunile pentru a crea o nouă cheie de recuperare" + "Salvați noua cheie de recuperare într-un manager de parole sau o notă criptată" + "Resetați criptarea contului dumneavoastră folosind un alt dispozitiv" + "Continuați resetarea" + "Detaliile contului, contactele, preferințele și lista de chat vor fi păstrate" + "Veți pierde mesajele anterioare care au fost stocate doar pe server" + "Va trebui să verificați din nou toate dispozitivele și contactele existente" + "Resetați-vă identitatea numai dacă nu aveți acces la un alt dispozitiv conectat și ați pierdut cheia de recuperare." + "Nu puteți confirma? Va trebui să vă resetați identitatea." + "Dezactivare" + "Veți pierde mesajele criptate dacă sunteți deconectat de pe toate dispozitivele." + "Sunteți sigur că doriți să dezactivați backup-ul?" + "Dezactivarea backup-ului va șterge backup-ul curent și va dezactiva alte măsuri de securitate. În acest caz, veți:" + "Nu veți avea mesajele anterioare criptate pe dispozitive noi" + "Veți pierde accesul la mesajele criptate dacă sunteți deconectat de pe %1$s peste tot" + "Sunteți sigur că doriți să dezactivați backup-ul?" + "Obțineți o nouă cheie de recuperare dacă ați pierdut-o pe cea existentă. După schimbarea cheii de recuperare, cea veche nu va mai funcționa." + "Generați o nouă cheie de recuperare" + "Nu împărtășiți cheia cu nimeni!" + "Cheia de recuperare a fost schimbată" + "Schimbați cheia de recuperare?" + "Creați o nouă cheie de recuperare" + "Asigurați-vă că nimeni nu poate vedea acest ecran!" + "Vă rugăm să încercați din nou să confirmați accesul la backup." + "Cheie de recuperare incorectă" + "Dacă aveți o cheie de securitate sau o frază de securitate, aceasta va funcționa și ea." + "Introduceți…" + "Ați pierdut cheia de recuperare?" + "Cheia de recuperare confirmată" + "Introduceți cheia de recuperare" + "Cheia de recuperare copiată" + "Se generează…" + "Salvați cheia de recuperare" + "Notați cheia de recuperare undeva în siguranță sau salvați-o într-un manager de parole." + "Apăsați pentru a copia cheia de recuperare" + "Salvați cheia de recuperare" + "Nu veți putea accesa noua cheie de recuperare după acest pas." + "Ați salvat cheia de recuperare?" + "Backup-ul pentru chat este protejat de o cheie de recuperare. Dacă aveți nevoie de o nouă cheie de recuperare după configurare, puteți să o recreați selectând „Schimbați cheia de recuperare”." + "Generați cheia de recuperare" + "Nu împărtășiți cheia cu nimeni!" + "Configurarea recuperării a reușit" + "Configurați recuperarea" + "Da, resetați acum" + "Acest proces este ireversibil." + "Sunteți sigur că doriți să vă resetați identitatea?" + "S-a produs o eroare necunoscută. Vă rugăm să verificați dacă parola contului dvs. este corectă și să încercați din nou." + "Introduceți…" + "Confirmați că doriți să vă resetați identitatea." + "Introduceți parola contului pentru a continua" + diff --git a/features/securebackup/impl/src/main/res/values-ru/translations.xml b/features/securebackup/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..1f3161d --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,70 @@ + + + "Удалить хранилище ключей" + "Включить резервное копирование" + "Сохраните вашу криптографическую идентификацию и ключи сообщений в безопасности на сервере. Это позволит вам просматривать историю сообщений на любых новых устройствах.%1$s." + "Хранилище ключей" + "Для настройки восстановления необходимо включить хранилище ключей." + "Загрузить ключи с этого устройства" + "Разрешить хранение ключей" + "Изменить ключ восстановления" + "Если вы потеряли все существующие устройства, то сможете восстановить свою криптографическую идентификацию и историю сообщений с помощью ключа восстановления" + "Введите ключ восстановления" + "В настоящее время резервная копия ваших чатов не синхронизирована." + "Настроить восстановление" + "Получите доступ к зашифрованным сообщениям, если вы потеряете все свои устройства или выйдете из системы %1$s отовсюду." + "Откройте %1$s на компьютере" + "Войдите в свой аккаунт еще раз" + "Когда вас попросят подтвердить устройство, выберите %1$s" + "“Сбросить все”" + "Следуйте инструкциям, чтобы создать новый ключ восстановления" + "Сохраните новый ключ восстановления в менеджере паролей или зашифрованной заметке" + "Сбросьте шифрование вашей учетной записи с помощью другого устройства." + "Продолжить сброс" + "Данные вашей учетной записи, контакты, настройки и список чатов будут сохранены" + "Вы потеряете существующую историю сообщений" + "Вам нужно будет заново подтвердить все существующие устройства и контакты." + "Сбрасывайте данные только в том случае, если у вас нет доступа к другому устройству, на котором выполнен вход, и вы потеряли ключ восстановления." + "Сбросьте ключи подтверждения, если вы не можете подтвердить свою личность другим способом." + "Выключить" + "Вы потеряете зашифрованные сообщения, если выйдете из всех устройств." + "Вы действительно хотите отключить резервное копирование?" + "Удаление хранилища ключей приведёт к удалению вашей криптографической идентификации и ключей сообщений с сервера, а также отключению следующих функций безопасности:" + "Нет зашифрованной истории сообщений на новых устройствах" + "Вы потеряете доступ к зашифрованным сообщениям, если выйдете из %1$s везде" + "Вы уверены, что хотите отключить хранение ключей и удалить их?" + "Получите новый ключ восстановления, если вы потеряли существующий. После смены ключа восстановления старый ключ больше не будет работать." + "Создать новый ключ восстановления" + "Не сообщайте эту информацию никому!" + "Ключ восстановления изменен" + "Изменить ключ восстановления?" + "Создать новый ключ восстановления" + "Убедитесь, что никто не видит этот экран!" + "Пожалуйста, попробуйте еще раз, чтобы подтвердить доступ к резервной копии чата." + "Неверный ключ восстановления" + "Если у вас есть пароль для восстановления или секретный пароль/ключ, это тоже сработает." + "Вход…" + "Потеряли ключ восстановления?" + "Ключ восстановления подтвержден" + "Введите ключ восстановления" + "Ключ восстановления скопирован" + "Генерация…" + "Сохранить ключ восстановления" + "Запишите данный ключ восстановления в безопасном месте, например в диспетчере паролей, зашифрованной заметке или физическом сейфе." + "Нажмите, чтобы скопировать ключ восстановления" + "Сохраните ключ восстановления" + "После этого шага вы не сможете получить доступ к новому ключу восстановления." + "Вы сохранили ключ восстановления?" + "Ваше хранилище ключей защищено ключом восстановления. Если после настройки вам понадобится новый ключ восстановления, вы можете его пересоздать, выбрав «Изменить ключ восстановления»." + "Создайте ключ восстановления" + "Не сообщайте эту информацию никому!" + "Настройка восстановления выполнена успешно" + "Настроить восстановление" + "Да, сбросить сейчас" + "Этот процесс необратим." + "Вы действительно хотите сбросить шифрование?" + "Произошла неизвестная ошибка. Проверьте правильность пароля учетной записи и повторите попытку." + "Вход…" + "Подтвердите, что вы хотите сбросить шифрование." + "Введите пароль своей учетной записи, чтобы продолжить" + diff --git a/features/securebackup/impl/src/main/res/values-sk/translations.xml b/features/securebackup/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..7d73522 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,70 @@ + + + "Vypnúť zálohovanie" + "Zapnúť zálohovanie" + "Uložte svoju kryptografickú identitu a kľúče správ bezpečne na server. To vám umožní zobraziť históriu správ na všetkých nových zariadeniach. %1$s." + "Úložisko kľúčov" + "Úložisko kľúčov musí byť zapnuté, aby bolo možné nastaviť obnovenie." + "Nahrať kľúče z tohto zariadenia" + "Povoliť úložisko kľúčov" + "Zmeniť kľúč na obnovenie" + "Obnovte svoju kryptografickú totožnosť a históriu správ pomocou kľúča na obnovenie, ak ste stratili všetky svoje existujúce zariadenia." + "Zadajte kľúč na obnovenie" + "Vaše úložisko kľúčov nie je momentálne synchronizované." + "Nastaviť obnovenie" + "Získajte prístup k vašim šifrovaným správam aj keď stratíte všetky svoje zariadenia alebo sa odhlásite zo všetkých %1$s zariadení." + "Otvoriť %1$s v stolnom počítači" + "Znova sa prihláste do svojho účtu" + "Keď sa zobrazí výzva na overenie vášho zariadenia, vyberte %1$s" + "\"Obnoviť všetko\"" + "Postupujte podľa pokynov na vytvorenie nového kľúča na obnovenie" + "Uložte si nový kľúč na obnovenie do správcu hesiel alebo do zašifrovanej poznámky" + "Obnovte šifrovanie vášho účtu pomocou iného zariadenia" + "Pokračovať v obnovovaní" + "Údaje o vašom účte, kontakty, predvoľby a zoznam konverzácií budú zachované" + "Stratíte svoju existujúcu históriu správ" + "Budete musieť znova overiť všetky existujúce zariadenia a kontakty" + "Obnovte svoju totožnosť iba vtedy, ak nemáte prístup k inému prihlásenému zariadeniu a stratili ste kľúč na obnovenie." + "Znovu nastavte svoju totožnosť v prípade, že ju nemôžete potvrdiť iným spôsobom" + "Vypnúť" + "Stratíte prístup k svojim zašifrovaným správam, ak sa odhlásite zo všetkých zariadení" + "Ste si istí, že chcete vypnúť zálohovanie?" + "Vypnutím zálohovania sa odstráni aktuálna záloha šifrovacích kľúčov a vypnú sa ďalšie bezpečnostné funkcie. V tomto prípade:" + "Na nových zariadeniach nebudete mať zašifrovanú históriu správ" + "Stratíte prístup k svojim zašifrovaným správam, ak sa odhlásite z aplikácie %1$s na všetkých zariadeniach" + "Ste si istí, že chcete vypnúť zálohovanie?" + "Získajte nový kľúč na obnovenie, ak ste stratili svoj existujúci. Po zmene kľúča na obnovenie už starý kľúč nebude fungovať." + "Vygenerovať nový kľúč na obnovenie" + "Nezdieľajte to s nikým!" + "Kľúč na obnovenie bol zmenený" + "Zmeniť kľúč na obnovenie?" + "Vytvoriť nový kľúč na obnovenie" + "Uistite sa, že túto obrazovku nikto nevidí!" + "Skúste prosím znova potvrdiť prístup k úložisku kľúčov." + "Nesprávny kľúč na obnovenie" + "Ak máte bezpečnostný kľúč alebo bezpečnostnú frázu, bude to fungovať tiež." + "Zadať…" + "Stratili ste kľúč na obnovenie?" + "Kľúč na obnovu potvrdený" + "Zadajte kľúč na obnovenie" + "Skopírovaný kľúč na obnovenie" + "Generovanie…" + "Uložiť kľúč na obnovenie" + "Zapíšte si tento kľúč na obnovenie na bezpečné miesto, napríklad do správcu hesiel, šifrovanej poznámky alebo fyzického trezoru." + "Ťuknutím skopírujte kľúč na obnovenie" + "Uložte svoj kľúč na obnovenie" + "Po tomto kroku nebudete mať prístup k novému kľúču na obnovenie." + "Uložili ste kľúč na obnovenie?" + "Vaša záloha konverzácie je chránená kľúčom na obnovenie. Ak potrebujete nový kľúč na obnovenie, po nastavení si ho môžete znova vytvoriť výberom položky „Zmeniť kľúč na obnovenie“." + "Vygenerujte si váš kľúč na obnovenie" + "Nezdieľajte to s nikým!" + "Úspešné nastavenie obnovy" + "Nastaviť obnovenie" + "Áno, znovu nastaviť teraz" + "Tento proces je nezvratný." + "Naozaj chcete obnoviť svoje šifrovanie?" + "Nastala neznáma chyba. Skontrolujte, či je heslo vášho účtu správne a skúste to znova." + "Zadať…" + "Potvrďte, že chcete obnoviť svoje šifrovanie." + "Ak chcete pokračovať, zadajte heslo účtu" + diff --git a/features/securebackup/impl/src/main/res/values-sv/translations.xml b/features/securebackup/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..501182b --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,70 @@ + + + "Stäng av säkerhetskopiering" + "Slå på säkerhetskopiering" + "Lagra din kryptografiska identitet och dina meddelandenycklar säkert på servern. Detta gör att du kan se din meddelandehistorik på alla nya enheter. %1$s." + "Nyckellagring" + "Nyckellagring måste vara aktiverat för att konfigurera återställning." + "Ladda upp nycklar från den här enheten" + "Tillåt lagring av nycklar" + "Byt återställningsnyckel" + "Återställ din kryptografiska identitet och meddelandehistorik med en återställningsnyckel om du har tappat bort alla dina befintliga enheter." + "Ange återställningsnyckel" + "Din nyckellagring är för närvarande osynkroniserad." + "Ställ in återställning" + "Få tillgång till dina krypterade meddelanden om du tappar bort alla dina enheter eller blir utloggad ur %1$s överallt." + "Öppna %1$s på en skrivbordsenhet" + "Logga in på ditt konto igen" + "När du ombeds att verifiera din enhet, välj %1$s" + "”Återställ alla”" + "Följ anvisningarna för att skapa en ny återställningsnyckel" + "Spara din nya återställningsnyckel i en lösenordshanterare eller krypterad anteckning" + "Återställ krypteringen för ditt konto med en annan enhet" + "Fortsätt återställning" + "Dina kontouppgifter, kontakter, inställningar och chattlistor kommer bevaras" + "Du kommer att förlora din befintliga meddelandehistorik" + "Du måste verifiera alla dina befintliga enheter och kontakter igen" + "Återställ bara din identitet om du inte har tillgång till en annan inloggad enhet och du har tappat bort din återställningsnyckel." + "Återställ din identitet ifall du inte kan bekräfta på annat sätt" + "Stäng av" + "Du kommer att förlora dina krypterade meddelanden om du loggas ut från alla enheter." + "Är du säker på att du vill stänga av säkerhetskopiering?" + "Om du stänger av säkerhetskopiering tas din nuvarande säkerhetskopiering av krypteringsnycklar bort och andra säkerhetsfunktioner stängs av. I det här fallet kommer du att:" + "Inte ha krypterad meddelandehistorik på nya enheter" + "Förlora åtkomsten till dina krypterade meddelanden om du loggas ut ur %1$s överallt" + "Är du säker på att du vill stänga av säkerhetskopiering?" + "Få en ny återställningsnyckel om du har tappat bort din befintliga. När du har bytt din återställningsnyckel fungerar din gamla inte längre." + "Generera en ny återställningsnyckel" + "Dela inte detta med någon!" + "Återställningsnyckel ändrad" + "Byt återställningsnyckel?" + "Skapa ny återställningsnyckel" + "Se till att ingen kan se den här skärmen" + "Vänligen pröva igen för att bekräfta åtkomsten till din nyckellagring." + "Felaktig återställningsnyckel" + "Om du har en säkerhetsnyckel eller säkerhetsfras så funkar den också." + "Ange …" + "Blivit av med din återställningsnyckel?" + "Återställningsnyckel bekräftad" + "Ange din återställningsnyckel" + "Kopierade återställningsnyckel" + "Genererar …" + "Spara återställningsnyckeln" + "Skriv ner den här återställningsnyckeln någonstans säkert, som en lösenordshanterare, en krypterad anteckning eller ett kassaskåp." + "Tryck för att kopiera återställningsnyckeln" + "Spara din återställningsnyckel" + "Du kommer inte att kunna komma åt din nya återställningsnyckel efter det här steget." + "Har du sparat din återställningsnyckel?" + "Din chattsäkerhetskopia skyddas av en återställningsnyckel. Om du behöver en ny återställningsnyckel efter installationen kan du återskapa genom att välja ”Byt återställningsnyckel”." + "Generera din återställningsnyckel" + "Dela inte detta med någon!" + "Konfiguration av återställning lyckades" + "Ställ in återställning" + "Ja, återställ nu" + "Denna process är irreversibel." + "Är du säker på att du vill återställa din kryptering?" + "Ett okänt fel inträffade. Kontrollera att ditt kontolösenord är korrekt och försök igen." + "Ange …" + "Bekräfta att du vill återställa din kryptering." + "Ange ditt kontolösenord för att fortsätta" + diff --git a/features/securebackup/impl/src/main/res/values-tr/translations.xml b/features/securebackup/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..b3a2acc --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,70 @@ + + + "Anahtar depolamasını sil" + "Yedeklemeyi aç" + "Kriptografik kimliğinizi ve mesaj anahtarlarınızı sunucuda güvenli bir şekilde saklayın. Bu, mesaj geçmişinizi herhangi bir yeni cihazda görüntülemenize olanak tanır. %1$s." + "Anahtar Depolama" + "Kurtarmayı ayarlamak için anahtar depolama alanı açık olmalıdır." + "Anahtarları bu cihazdan yükle" + "Anahtar depolamaya izin ver" + "Kurtarma anahtarını değiştir" + "Mevcut tüm cihazlarınızı kaybettiyseniz, kurtarma anahtarıyla şifreleme kimliğinizi ve mesaj geçmişinizi kurtarın." + "Kurtarma anahtarını girin" + "Anahtar depolama alanınız şu anda senkronize değil." + "Kurtarmayı ayarlayın" + "Tüm cihazlarınızı kaybettiğinizde veya %1$s adresinden çıkış yaptığınızda şifrelenmiş mesajlarınıza her yerden erişin." + "Masaüstü cihazında aç %1$s" + "Hesabınızda tekrar oturum açın" + "Cihazınızı doğrulamanız istendiğinde %1$s öğesini seçin" + "\"Tümünü sıfırla\"" + "Yeni bir kurtarma anahtarı oluşturmak için talimatları izleyin" + "Yeni kurtarma anahtarınızı bir parola yöneticisine veya şifreli bir nota kaydedin" + "Hesabınızın şifrelemesini başka bir cihaz kullanarak sıfırlayın" + "Sıfırlamaya devam et" + "Hesap bilgileriniz, kişileriniz, tercihleriniz ve sohbet listeniz saklanacaktır" + "Yalnızca sunucuda depolanan mesaj geçmişlerini kaybedeceksiniz" + "Mevcut tüm cihazlarınızı ve kişilerinizi tekrar doğrulamanız gerekecek" + "Kimliğinizi yalnızca oturum açtığınız başka bir cihaza erişiminiz yoksa ve kurtarma anahtarınızı kaybettiyseniz sıfırlayın." + "Onaylayamıyor musunuz? Kimliğinizi sıfırlamanız gerekecek." + "Kapat" + "Tüm cihazlardan çıkış yaparsanız şifrelenmiş mesajlarınızı kaybedersiniz." + "Yedeklemeyi kapatmak istediğinizden emin misiniz?" + "Anahtar depolamanın silinmesi kriptografik kimliğinizi ve mesaj anahtarlarınızı sunucudan kaldıracak ve aşağıdaki güvenlik özelliklerini kapatacaktır:" + "Yeni cihazlarda şifrelenmiş mesaj geçmişine sahip olmayacaksınız" + "%1$s adresinden her yerde oturumunuzu kapatırsanız şifrelenmiş mesajlarınıza erişiminizi kaybedersiniz" + "Anahtar depolamayı kapatmak ve silmek istediğinizden emin misiniz?" + "Mevcut kurtarma anahtarınızı kaybettiyseniz yeni bir kurtarma anahtarı alın. Kurtarma anahtarınızı değiştirdikten sonra eski anahtarınız artık çalışmayacaktır." + "Yeni bir kurtarma anahtarı oluştur" + "Bunu kimseyle paylaşmayın!" + "Kurtarma anahtarı değiştirildi" + "Kurtarma anahtarını değiştir?" + "Yeni kurtarma anahtarı oluştur" + "Bu ekranı kimsenin göremediğinden emin olun!" + "Anahtar depolama alanınıza erişimi onaylamak için lütfen tekrar deneyin." + "Yanlış kurtarma anahtarı" + "Bir güvenlik anahtarınız veya güvenlik ifadeniz varsa, bu da işe yarayacaktır." + "Gir…" + "Kurtarma anahtarınızı mı kaybettiniz?" + "Kurtarma anahtarı onaylandı" + "Kurtarma anahtarınızı girin" + "Kurtarma anahtarı kopyalandı" + "Oluşturuluyor…" + "Kurtarma anahtarını kaydet" + "Bu kurtarma anahtarını şifre yöneticisi, şifreli not veya fiziksel kasa gibi güvenli bir yere kaydedin." + "Kurtarma anahtarını kopyalamak için dokunun" + "Kurtarma anahtarınızı güvenli bir yere kaydedin" + "Bu adımdan sonra yeni kurtarma anahtarınıza erişemeyeceksiniz." + "Kurtarma anahtarınızı kaydettiniz mi?" + "Anahtar depolama alanınız bir kurtarma anahtarıyla korunmaktadır. Kurulumdan sonra yeni bir kurtarma anahtarına ihtiyacınız olursa, \"Kurtarma anahtarını değiştir\"i seçerek yeniden oluşturabilirsiniz." + "Kurtarma anahtarınızı oluşturun" + "Bunu kimseyle paylaşmayın!" + "Kurtarma kurulumu başarılı" + "Kurtarmayı ayarlayın" + "Evet, şimdi sıfırla" + "Bu işlem geri alınamaz." + "Kimliğinizi sıfırlamak istediğinizden emin misiniz?" + "Bilinmeyen bir hata oluştu. Lütfen hesap şifrenizin doğru olup olmadığını kontrol edin ve tekrar deneyin." + "Gir…" + "Kimliğinizi sıfırlamak istediğinizi onaylayın." + "Devam etmek için hesap şifrenizi girin" + diff --git a/features/securebackup/impl/src/main/res/values-uk/translations.xml b/features/securebackup/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..db669fe --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,70 @@ + + + "Вимкнути резервне копіювання" + "Увімкнути резервне копіювання" + "Зберігайте свій криптографічний ідентифікатор і ключі повідомлень на сервері. Це дозволить вам переглядати історію повідомлень на будь-яких нових пристроях. %1$s." + "Резервне копіювання" + "Щоб налаштувати відновлення, потрібно ввімкнути зберігання ключів." + "Завантажте ключі з цього пристрою" + "Дозволити зберігання ключів" + "Змінити ключ відновлення" + "Відновіть криптографічну ідентичність та історію повідомлень за допомогою ключа відновлення, якщо ви втратили всі наявні пристрої." + "Введіть ключ відновлення" + "Сховище ключів наразі не синхронізовано." + "Налаштувати відновлення" + "Отримайте доступ до своїх зашифрованих повідомлень, якщо ви втратите всі свої пристрої або вийшли з %1$s на всіх пристроях." + "Відкрийте %1$s на комп\'ютері" + "Увійдіть до вашого облікового запису знову" + "Коли вас попросять підтвердити пристрій, виберіть %1$s" + "“Скинути все”" + "Дотримуйтесь інструкцій, щоб створити новий ключ відновлення" + "Збережіть новий ключ відновлення у менеджері паролів або зашифрованій нотатці" + "Скинути шифрування облікового запису за допомогою іншого пристрою" + "Продовжити скидання налаштувань" + "Дані вашого облікового запису, контакти, налаштування й бесіди будуть збережені" + "Ви втратите свою наявну історію повідомлень" + "Вам доведеться верифікувати всі наявні пристрої та контакти повторно" + "Скидайте ідентичність тільки якщо ви не маєте доступу до інших пристроїв в обліковому записі та втратили свій ключ відновлення." + "Не можете підтвердити? Вам доведеться скинути свою ідентичність." + "Вимкнути" + "Ви втратите зашифровані повідомлення, якщо вийдете з усіх пристроїв." + "Ви впевнені, що хочете вимкнути резервне копіювання?" + "Вимкнення резервного копіювання призведе до видалення поточної резервної копії ключа шифрування та вимкнення інших функцій безпеки. В такому разі ви:" + "Не матимете історії зашифрованих повідомлень на нових пристроях" + "Втратите доступ до зашифрованих повідомлень, якщо вийдете з усіх сеансів %1$s" + "Ви впевнені, що хочете вимкнути резервне копіювання?" + "Отримайте новий ключ відновлення, якщо ви втратили наявний ключ. Після зміни ключа відновлення ваш попередній більше не працюватиме." + "Згенерувати новий ключ відновлення" + "Не діліться цим ні з ким!" + "Ключ відновлення змінено" + "Змінити ключ відновлення?" + "Створити новий ключ відновлення" + "Впевніться, що ніхто не дивиться!" + "Будь ласка, спробуйте ще раз, щоб підтвердити доступ до сховища ключів." + "Неправильний ключ відновлення" + "Якщо у вас є ключ безпеки або фраза безпеки, це теж спрацює." + "Входимо…" + "Загубили ключ відновлення?" + "Ключ відновлення підтверджено" + "Введіть ключ відновлення" + "Скопійовано ключ відновлення" + "Створення…" + "Зберегти ключ відновлення" + "Запишіть цей ключ відновлення в безпечне місце, наприклад, у менеджер паролей, зашифровану записку або власноруч у фізично безпечному місці." + "Торкніться, щоб скопіювати ключ відновлення" + "Збережіть ключ відновлення" + "Після цього кроку ви не зможете отримати доступ до нового ключа відновлення." + "Ви зберегли ключ відновлення?" + "Ваша резервна копія чату захищена ключем відновлення. Якщо вам потрібен новий ключ відновлення після налаштування, ви можете відтворити, вибравши «Змінити ключ відновлення»." + "Згенеруйте ключ відновлення" + "Не діліться цим ні з ким!" + "Налаштування відновлення виконано успішно" + "Налаштувати відновлення" + "Так, скинути зараз" + "Цей процес незворотний." + "Ви впевнені, що хочете скинути шифрування?" + "Сталася невідома помилка. Будь ласка, перевірте правильність пароля свого облікового запису та повторіть спробу." + "Входимо…" + "Підтвердьте, що ви хочете скинути шифрування." + "Введіть пароль облікового запису, щоб продовжити" + diff --git a/features/securebackup/impl/src/main/res/values-ur/translations.xml b/features/securebackup/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..f1098de --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,65 @@ + + + "کلید کا ذخیرہ حذف کریں" + "پشتارہ چالو کریں" + "خادم پر اپنی تشفیری شناخت اور پیغام کی کلیدیں محفوظ طریقے سے ذخیرہ کریں۔ اس سے آپ کسی بھی نئے آلات پر اپنے پیغام کی سرگزشت دیکھ سکیں گے۔ %1$s۔" + "کلید ذخیرہ" + "بازیابی کی کلید تبدیل کریں" + "بازیابی کلید درج کریں" + "آپ کا کلیدی ذخیرہ فی الحال غیر ہم وقت ساز ہے۔" + "بازیابی مرتب کریں" + "اگر آپ اپنے تمام آلات کھو دیتے ہیں یا ہر جگہ %1$s سے خارج ہیں تو اپنے مرموز کردہ پیغامات تک رسائی حاصل کریں۔" + "%1$s کو برمیز آلے میں کھولیں" + "اپنے کھاتہ میں چوبارہ داخل ہوں" + "جب اپنے آلے کی توثیق کا کہا جائے، %1$s منتخب کریں" + "”تمام بحال کریں“" + "نئی بازیابی کلید بنانے کیلئے ہدایات پر عمل کریں" + "اپنی نئی بازیابی کلید کو لفظ عبور منتظم یا مرکوز کردہ ملحوظہ میں محفوظ کریں" + "کسی دوسرے آلے کا استعمال کرتے ہوئے اپنے کھاتہ کیلئے مرموز کاری کو بحال کریں" + "ری سیٹ جاری رکھیں" + "آپ کے کھاتہ کی تفصیلات، رابطے، ترجیحات اور گفتگو کی فہرست رکھی جائے گی۔" + "آپ کسی بھی پیغام کی سرگزشت کو کھو دیں گے جو صرف خادم پر محفوظ ہے۔" + "آپ کو اپنے تمام موجودہ آلات اور رابطوں کی دوبارہ توثیق کرنی ہوگی۔" + "اپنی شناخت صرف اس صورت میں بحال کر دیں جب آپ کو کسی دوسرے دخول کردہ آلے تک رسائی حاصل نہ ہو اور آپ اپنی بازیابی کلید کھو چکے ہوں۔" + "تصدیق نہیں کر سکتے؟ آپ کو اپنی شناخت بحال کر دینے کی ضرورت ہوگی۔" + "بند کریں" + "آپ اپنے مرموز کردہ پیغامات کھو جائیں گے اگر آپ تمام آلات سے خارج ہو جائیں۔" + "کیا آپ کو یقین ہے کہ آپ بپشتارہ بند کرنا چاہتے ہیں؟" + "کلید کے ذخیرہ کو حذف کرنے سے خادم سے آپ کی تشفیری شناخت اور پیغام کی کلیدیں ہٹ جائیں گی اور درج ذیل حفاظتی خصوصیات بند ہو جائیں گی:" + "آپکے پاس نئے آلات پر مرموز کردہ پیغامات کی سرگزشت نہیں ہوگی" + "اگر آپ ہر جگہ %1$s سے خارج ہیں تو آپ اپنے مرموز کردہ پیغامات تک رسائی کھو دیں گے۔" + "کیا آپ کو یقین ہے کہ آپ جلد کے ذخیرہ کو بند کرنا اور اسے حذف کرنا چاہتے ہیں؟" + "اگر آپ نے اپنی موجودہ کھو دی ہے تو نئی بازیابی کلید حاصل کریں۔ اپنی بازیابی کلید کو تبدیل کرنے کے بعد، آپ کی پرانی اب کام نہیں کرے گی۔" + "نئی بازیابی کلید تولید کریں" + "کسی کے ساتھ اس کا اشتراک نہ کریں!" + "بازیابی کلید بدل دی" + "بازیابی کلید بدلیں؟" + "نئی بازیابی کلید تخلیق کریں" + "یقینی بنائیں کہ کوئی بھی اس صفحۂ نمائش کو نہیں دیکھ سکتا!" + "برائے مہربانی اپنے کلید کے ذخیرے تک رسائی کی تصدیق کرنے کے لیے دوبارہ کوشش کریں۔" + "غلط بازیابی کلید" + "اگر آپ کے پاس حفاظتی کلید یا حفاظتی فقرہ ہے، یہ بھی کام کرے گا۔" + "درج کریں…" + "اپنی بازیابی کلید کھو دی؟" + "بازیابی کلید کی تصدیق ہوگئی" + "بازیابی کلید نقل شدہ" + "تولید کر رہ ہے…" + "بازیابی کلید محفوظ کریں" + "اس بازیابی کی کلید کو کہیں محفوظ جگہ لکھیں ، جیسے لفظ عبور منتظم، مرموز کردہ ملحوظہ، یا جسمانی سیف۔" + "ریکوری کلید نقل کرنے کے لیے تھپتھپائیں۔" + "اپنی بازیابی کلید کو محفوظ جگہ پر محفوظ کریں" + "اس مرحلے کے بعد آپ اپنی نئی بازیابی کلید تک رسائی حاصل نہیں کرسکیں گے۔" + "کیا آپ نے اپنی بازیابی کی کلید محفوظ کی ہے؟" + "آپ کا کلید کا ذخیرہ بازیابی کلید کے ذریعہ محفوظ ہے۔ اگر آپ کو مرتب کرنے کے بعد ایک نئی بازیابی کلید کی ضرورت ہے تو ، آپ \'بازیابی کلید بدلیں\' منتخب کرکے اسے دوبارہ تخلیق کرسکتے ہیں۔" + "اپنی بازیابی کلید تولید کریں" + "کسی کے ساتھ اس کا اشتراک نہ کریں!" + "ریکوری مرتب کامیاب" + "بازیابی مرتب کریں" + "ہاں، اب بحال کر دیں" + "یہ عملیہ ناقابل تلافی ہے۔" + "کیا آپ کو یقین ہے کہ آپ اپنی شناخت بحال کر دینا چاہتے ہیں؟" + "ایک نامعلوم غلطی ہو گئی۔ براہ کرم چیک کریں کہ آپ کے اکاؤنٹ کا پاسورڈ درست ہے اور دوبارہ کوشش کریں۔" + "درج کریں…" + "تصدیق کریں کہ آپ اپنی شناخت بحال کر دینا چاہتے ہیں۔" + "جاری رکھنے کے لیے اپنے کھاتہ کا لفظ عبور درج کریں۔" + diff --git a/features/securebackup/impl/src/main/res/values-uz/translations.xml b/features/securebackup/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..88dff88 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,70 @@ + + + "Zaxiralashni o\'chirib qo\'ying" + "Zaxiralashni yoqing" + "Kryptografik shaxsiyatingizni va xabar kalitlaringizni serverda xavfsiz saqlang. Bu sizga har qanday yangi qurilmalarda xabar tarixingizni ko\'rish imkonini beradi. %1$s." + "Kalitlar ombori" + "Tiklashni sozlash uchun kalitlar xotirasini yoqish kerak." + "Bu qurilmadan kalitlarni yuklash" + "Kalit saqlashga ruxsat berish" + "Qayta tiklash kalitini o\'zgartiring" + "Agar barcha mavjud qurilmalaringizni yoʻqotgan boʻlsangiz, tiklash kaliti yordamida kriptografik shaxsingizni va xabarlar tarixingizni qayta tiklang." + "Tiklash kalitini kiriting" + "Kalit xotirasi hozirda sinxronlanmagan." + "Qayta tiklashni sozlang" + "Agar barcha qurilmalaringizni yo‘qotib qo‘ysangiz yoki tizimdan chiqqan bo‘lsangiz, shifrlangan xabarlaringizga ruxsat oling%1$s hamma joyda." + "%1$s ni kompyuterda oching" + "Hisobingizga qaytadan kiring" + "Qurilmangizni tasdiqlash soʻralganda, %1$s ni tanlang" + "ʻʻHammasini asliga qaytarishʼʼ" + "Yangi tiklash kalitini yaratish uchun koʻrsatmalarga amal qiling" + "Yangi tiklash kalitingizni parol menejeriga yoki shifrlangan yozuvga saqlab qoʻying" + "Hisobingiz shifrini boshqa qurilma orqali asliga qaytaring" + "Qayta tiklashda davom eting" + "Hisob maʼlumotlaringiz, kontaktlaringiz, sozlamalaringiz va suhbatlar roʻyxatingiz saqlanib qoladi" + "Faqat serverda saqlangan har qanday xabarlar tarixi oʻchib ketadi" + "Barcha mavjud qurilma va kontaktlarni qayta tasdiqlashingiz kerak boʻladi" + "Agar boshqa hisobga kirilgan qurilmaga kira olmasangiz va tiklash kaliti yo‘qolgan bo‘lsa, shaxsingizni tiklang." + "Tasdiqlanmadimi? Shaxsingizni tiklashingiz kerak." + "O\'chirish" + "Agar barcha qurilmalardan chiqqan boʻlsangiz, shifrlangan xabarlaringizni yoʻqotasiz." + "Haqiqatan ham zaxiralashni o‘chirib qo‘ymoqchimisiz?" + "Zaxiralashni o‘chirib qo‘ysangiz, joriy shifrlash kaliti zaxira nusxasi o‘chiriladi va boshqa xavfsizlik funksiyalari o‘chiriladi. Bunday holda siz:" + "Yangi qurilmalarda shifrlangan xabarlar tarixi mavjud emas" + "Agar tizimdan chiqqan boʻlsangiz, shifrlangan xabarlaringizga kirish huquqini yoʻqotasiz%1$s hamma joyda" + "Haqiqatan ham zaxiralashni o‘chirib qo‘ymoqchimisiz?" + "Mavjud kalitingizni yo\'qotgan bo\'lsangiz, yangi tiklash kalitini oling. Qayta tiklash kalitini almashtirganingizdan so\'ng, eski kalitingiz ishlamaydi." + "Yangi tiklash kalitini yarating" + "Buni hech kimga ulashmang!" + "Qayta tiklash kaliti oʻzgartirildi" + "Qayta tiklash kaliti almashtirilsinmi?" + "Yangi tiklash kalitini yaratish" + "Hech kim bu ekranni kora olmasligiga ishonch hosil qiling!" + "Kalit xotirasiga kirishni tasdiqlash uchun qayta urinib koʻring." + "Notoʻgʻri tiklash kaliti" + "Agar sizda xavfsizlik kaliti yoki xavfsizlik iborasi bolsa, bu ham ishlaydi." + "Kirish…" + "Tiklanish kalitingizni yoʻqotdingizmi?" + "Qayta tiklash kaliti tasdiqlandi" + "Qayta tiklash kalitingizni kiriting" + "Qayta tiklash kaliti nusxalandi" + "Yaratilmoqda…" + "Qayta tiklash kalitini saqlang" + "Qayta tiklash kalitingizni xavfsiz joyga yozing yoki parol menejerida saqlang." + "Qayta tiklash kalitidan nusxa olish uchun bosing" + "Zaxira kalitingizni saqlang" + "Ushbu qadamdan so‘ng siz yangi tiklash kalitingizga kira olmaysiz." + "Zaxira kalitingizni saqladingizmi?" + "Suhbatingiz zaxira nusxasi tiklash kaliti bilan himoyalangan. Agar sozlashdan keyin sizga yangi tiklash kaliti kerak boʻlsa, “Qayta tiklash kalitini oʻzgartirish”ni tanlash orqali qayta yaratishingiz mumkin." + "Qayta tiklash kalitini yarating" + "Buni hech kimga ulashmang!" + "Qayta tiklash muvaffaqiyatli sozlandi" + "Qayta tiklashni sozlang" + "Ha, hozir asliga qaytarish" + "Bu jarayonni ortga qaytarib boʻlmaydi." + "Haqiqatan ham shaxsingizni qayta tiklamoqchimisiz?" + "Noma’lum xato yuz berdi. Iltimos, hisobingiz parolining to‘g‘riligini tekshiring va qaytadan urinib ko‘ring." + "Kirish…" + "Shaxsingizni tiklashni tasdiqlang." + "Davom etish uchun hisobingiz parolini kiriting" + diff --git a/features/securebackup/impl/src/main/res/values-zh-rTW/translations.xml b/features/securebackup/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..4e31bbb --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,70 @@ + + + "關閉備份功能" + "開啟備份功能" + "在伺服器上安全地儲存您的密碼學身份與訊息金鑰。這將讓您可以在任何新裝置上檢視訊息歷史紀錄。%1$s" + "金鑰儲存空間" + "必須開啟金鑰儲存空間才能設定復原。" + "從此裝置上傳金鑰" + "允許金鑰儲存空間" + "變更復原金鑰" + "若您遺失了您現有的所有裝置,請使用復原金鑰來還原您的密碼學身份與訊息歷史紀錄。" + "輸入復原金鑰" + "您的金鑰儲存空間目前並未同步。" + "設定復原" + "若您遺失所有裝置,或是徹底登出了 %1$s,就可以存取您的加密訊息。" + "在桌上型裝置中開啟 %1$s" + "再次登入您的帳號" + "當要求驗證您的裝置時,請選取 %1$s" + "「重設全部」" + "按照說明建立新復原金鑰" + "將您的新復原金鑰儲存在密碼管理程式或加密筆記中" + "使用其他裝置重設您帳號的加密" + "繼續重設" + "您的帳號詳細資訊、聯絡人、偏好設定與聊天清單都會保留" + "您將會遺失僅儲存在伺服器上的任何訊息歷史紀錄" + "您將需要再次驗證所有現有裝置與聯絡人" + "僅當您無法存取其他已登入裝置且遺失復原金鑰時才重設您的身份。" + "無法確認?您需要重設身份。" + "關閉" + "若您登出所有裝置,您將失去加密訊息。" + "您確定您要關閉備份嗎?" + "刪除金鑰儲存空間會從伺服器移除您的密碼學身份與訊息金鑰,並關閉以下安全性功能:" + "您將無法在新裝置上存取加密訊息歷史紀錄" + "若您徹底登出 %1$s,您將無法存取加密訊息" + "您確定要關閉金鑰儲存空間並刪除它嗎?" + "若您遺失現有的復原金鑰,請產生新的復原金鑰。變更復原金鑰後,舊金鑰將不再有效。" + "產生新的復原金鑰" + "不要與任何人分享!" + "復原金鑰已變更" + "變更復原金鑰?" + "建立新復原金鑰" + "確保沒有人可以看到此畫面!" + "請再試一次確認對您金鑰儲存空間的存取權。" + "復原金鑰不正確" + "若您有安全金鑰或安全密語也可以正常運作。" + "輸入……" + "遺失了您的復原金鑰?" + "復原金鑰已確認" + "輸入您的復原金鑰" + "已複製復原金鑰" + "正在產生……" + "儲存復原金鑰" + "將此復原金鑰記在安全的地方,例如密碼管理程式、加密筆記或實體保險箱中。" + "點擊以複製復原金鑰" + "將復原金鑰儲存在安全的地方" + "在此步驟後,您將無法存取新的復原金鑰。" + "您儲存復原金鑰了嗎?" + "您的金鑰儲存空間由復原金鑰保護。若您在設定後需要新的復原金鑰,您可以透過選取「變更復原金鑰」來重新建立。" + "產生您的復原金鑰" + "不要與任何人分享!" + "復原設定成功" + "設定復原" + "是的,立刻重設" + "此過程不可逆。" + "您確定您想要重設您的身份嗎?" + "發生了未知錯誤。請檢查您帳號的密碼是否正確,然後再試一次。" + "輸入……" + "確認您要重設您的身份。" + "輸入您帳號的密碼以繼續" + diff --git a/features/securebackup/impl/src/main/res/values-zh/translations.xml b/features/securebackup/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..dc70389 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,70 @@ + + + "关闭备份" + "开启备份" + "将您的密码学身份和消息密钥安全地存储在服务器上。这样您就可以在任何新设备上查看您的消息历史记录。%1$s。" + "密钥存储" + "必须打开密钥存储才能设置恢复。" + "从此设备上传密钥" + "允许密钥存储" + "更改恢复密钥" + "如果您丢失了所有现有设备,使用恢复密钥恢复您的密码学身份和消息历史记录。" + "输入恢复密钥" + "您的密钥存储当前不同步。" + "设置恢复" + "在丢失或从 %1$s 登出所有设备的情况下访问加密消息。" + "在桌面设备中打开 %1$s" + "再次登录您的账户" + "当要求验证您的设备时,选择 %1$s" + "「全部重置」" + "按照说明创建新的恢复密钥" + "将新的恢复密钥保存在密码管理器或加密备忘录中" + "使用其他设备重置账户的加密" + "继续重置" + "您的账户信息、联系人、偏好设置和聊天列表将被保留" + "您将丢失现有的消息历史记录" + "您将需要再次验证所有您的现有设备和联系人" + "仅当您无法访问其他已登录设备并且丢失了恢复密钥时才重置您的身份。" + "如果您无法通过其他方式确认,请重置您的身份" + "关闭" + "如果您登出所有设备,您的加密消息将丢失。" + "您确定要关闭备份吗?" + "关闭备份将删除您当前的加密密钥备份并关闭其他安全功能。在这种情况下,你将:" + "新设备上没有加密消息的历史记录" + "如果您在所有设备上登出了 %1$s,那将无法访问加密消息" + "您确定要关闭备份吗?" + "如果您丢失了现有的恢复密钥,请获取新的恢复密钥。更改恢复密钥后,您的旧密钥将不再起作用。" + "生成新的恢复密钥" + "不要告诉任何人!" + "恢复密钥已更改" + "更改恢复密钥?" + "创建新的恢复密钥" + "确保没有人能看到这个界面!" + "请重试以确认访问您的密钥存储。" + "恢复密钥不正确" + "如果您有安全密钥或安全短语,也可以用。" + "输入……" + "丢失了恢复密钥?" + "恢复密钥已确认" + "输入恢复密钥" + "恢复密钥已复制" + "正在生成……" + "保存恢复密钥" + "将此恢复密钥保存在安全的地方,例如密码管理器、加密笔记或物理保险箱。" + "点击复制恢复密钥" + "保存您的恢复密钥" + "完成此步骤后,您将无法访问新的恢复密钥。" + "您保存了恢复密钥吗?" + "您的聊天备份受恢复密钥保护。如果您在安装后需要新的恢复密钥,则可以通过选择「更改恢复密钥」来重新创建。" + "生成恢复密钥" + "不要告诉任何人!" + "恢复设置成功" + "设置恢复" + "是的,立即重置" + "此过程不可逆。" + "您确定要重置加密吗?" + "发生未知错误。请检查您的帐户密码是否正确,然后重试。" + "输入……" + "确认您要重置加密。" + "输入您的账户密码以继续" + diff --git a/features/securebackup/impl/src/main/res/values/localazy.xml b/features/securebackup/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..0113c90 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values/localazy.xml @@ -0,0 +1,70 @@ + + + "Delete key storage" + "Turn on backup" + "Store your cryptographic identity and message keys securely on the server. This will allow you to view your message history on any new devices. %1$s." + "Key storage" + "Key storage must be turned on to set up recovery." + "Upload keys from this device" + "Allow key storage" + "Change recovery key" + "Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices." + "Enter recovery key" + "Your key storage is currently out of sync." + "Set up recovery" + "Get access to your encrypted messages if you lose all your devices or are signed out of %1$s everywhere." + "Open %1$s in a desktop device" + "Sign into your account again" + "When asked to verify your device, select %1$s" + "“Reset all”" + "Follow the instructions to create a new recovery key" + "Save your new recovery key in a password manager or encrypted note" + "Reset the encryption for your account using another device" + "Continue reset" + "Your account details, contacts, preferences, and chat list will be kept" + "You will lose any message history that’s stored only on the server" + "You will need to verify all your existing devices and contacts again" + "Only reset your identity if you don’t have access to another signed-in device and you’ve lost your recovery key." + "Can\'t confirm? You’ll need to reset your identity." + "Turn off" + "You will lose your encrypted messages if you are signed out of all devices." + "Are you sure you want to turn off backup?" + "Deleting key storage will remove your cryptographic identity and message keys from the server and turn off the following security features:" + "You will not have encrypted message history on new devices" + "You will lose access to your encrypted messages if you are signed out of %1$s everywhere" + "Are you sure you want to turn off key storage and delete it?" + "Get a new recovery key if you\'ve lost your existing one. After changing your recovery key, your old one will no longer work." + "Generate a new recovery key" + "Do not share this with anyone!" + "Recovery key changed" + "Change recovery key?" + "Create new recovery key" + "Make sure nobody can see this screen!" + "Please try again to confirm access to your key storage." + "Incorrect recovery key" + "If you have a security key or security phrase, this will work too." + "Enter…" + "Lost your recovery key?" + "Recovery key confirmed" + "Enter your recovery key" + "Copied recovery key" + "Generating…" + "Save recovery key" + "Write down this recovery key somewhere safe, like a password manager, encrypted note, or a physical safe." + "Tap to copy recovery key" + "Save your recovery key somewhere safe" + "You will not be able to access your new recovery key after this step." + "Have you saved your recovery key?" + "Your key storage is protected by a recovery key. If you need a new recovery key after setup, you can recreate it by selecting ‘Change recovery key’." + "Generate your recovery key" + "Do not share this with anyone!" + "Recovery setup successful" + "Set up recovery" + "Yes, reset now" + "This process is irreversible." + "Are you sure you want to reset your identity?" + "An unknown error happened. Please check your account password is correct and try again." + "Enter…" + "Confirm that you want to reset your identity." + "Enter your account password to continue" + diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPointTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPointTest.kt new file mode 100644 index 0000000..9e984f1 --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPointTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.features.securebackup.api.SecureBackupEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultSecureBackupEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultSecureBackupEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + SecureBackupFlowNode( + buildContext = buildContext, + plugins = plugins, + ) + } + val callback = object : SecureBackupEntryPoint.Callback { + override fun onDone() = lambdaError() + } + val params = SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.ResetIdentity) + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + params = params, + callback = callback, + ) + assertThat(result).isInstanceOf(SecureBackupFlowNode::class.java) + assertThat(result.plugins).contains(params) + assertThat(result.plugins).contains(callback) + } +} diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisablePresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisablePresenterTest.kt new file mode 100644 index 0000000..899726d --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisablePresenterTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.disable + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.encryption.BackupState +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class SecureBackupDisablePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createSecureBackupDisablePresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.backupState).isEqualTo(BackupState.UNKNOWN) + assertThat(initialState.disableAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(initialState.appName).isEqualTo("Element") + } + } + + @Test + fun `present - user delete backup success`() = runTest { + val presenter = createSecureBackupDisablePresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.disableAction).isEqualTo(AsyncAction.Uninitialized) + initialState.eventSink(SecureBackupDisableEvents.DisableBackup) + val loadingState = awaitItem() + assertThat(loadingState.disableAction).isInstanceOf(AsyncAction.Loading::class.java) + val finalState = awaitItem() + assertThat(finalState.disableAction).isEqualTo(AsyncAction.Success(Unit)) + } + } + + @Test + fun `present - user delete backup error`() = runTest { + val encryptionService = FakeEncryptionService().apply { + givenDisableRecoveryFailure(Exception("failure")) + } + val presenter = createSecureBackupDisablePresenter( + encryptionService = encryptionService + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.disableAction).isEqualTo(AsyncAction.Uninitialized) + initialState.eventSink(SecureBackupDisableEvents.DisableBackup) + val loadingState = awaitItem() + assertThat(loadingState.disableAction).isInstanceOf(AsyncAction.Loading::class.java) + val errorState = awaitItem() + assertThat(errorState.disableAction).isInstanceOf(AsyncAction.Failure::class.java) + errorState.eventSink(SecureBackupDisableEvents.DismissDialogs) + val finalState = awaitItem() + assertThat(finalState.disableAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + private fun createSecureBackupDisablePresenter( + encryptionService: EncryptionService = FakeEncryptionService(), + appName: String = "Element", + ): SecureBackupDisablePresenter { + return SecureBackupDisablePresenter( + encryptionService = encryptionService, + buildMeta = aBuildMeta( + applicationName = appName, + ) + ) + } +} diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyPresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyPresenterTest.kt new file mode 100644 index 0000000..a6ffb57 --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyPresenterTest.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.enter + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory +import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState +import io.element.android.features.securebackup.impl.tools.RecoveryKeyTools +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class SecureBackupEnterRecoveryKeyPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.isSubmitEnabled).isFalse() + assertThat(initialState.submitAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(initialState.recoveryKeyViewState).isEqualTo( + RecoveryKeyViewState( + recoveryKeyUserStory = RecoveryKeyUserStory.Enter, + formattedRecoveryKey = "", + displayTextFieldContents = false, + inProgress = false, + ) + ) + } + } + + @Test + fun `present - enter recovery key`() = runTest { + val encryptionService = FakeEncryptionService() + val presenter = createPresenter(encryptionService) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(SecureBackupEnterRecoveryKeyEvents.OnRecoveryKeyChange("1234")) + val withRecoveryKeyState = awaitItem() + assertThat(withRecoveryKeyState.isSubmitEnabled).isTrue() + assertThat(withRecoveryKeyState.recoveryKeyViewState).isEqualTo( + RecoveryKeyViewState( + recoveryKeyUserStory = RecoveryKeyUserStory.Enter, + formattedRecoveryKey = "1234", + displayTextFieldContents = false, + inProgress = false, + ) + ) + encryptionService.givenRecoverFailure(AN_EXCEPTION) + withRecoveryKeyState.eventSink(SecureBackupEnterRecoveryKeyEvents.Submit) + val loadingState = awaitItem() + assertThat(loadingState.submitAction).isEqualTo(AsyncAction.Loading) + assertThat(loadingState.isSubmitEnabled).isFalse() + val errorState = awaitItem() + assertThat(errorState.submitAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) + assertThat(errorState.isSubmitEnabled).isFalse() + errorState.eventSink(SecureBackupEnterRecoveryKeyEvents.ClearDialog) + val clearedState = awaitItem() + assertThat(clearedState.submitAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(clearedState.isSubmitEnabled).isTrue() + encryptionService.givenRecoverFailure(null) + clearedState.eventSink(SecureBackupEnterRecoveryKeyEvents.Submit) + val loadingState2 = awaitItem() + assertThat(loadingState2.submitAction).isEqualTo(AsyncAction.Loading) + assertThat(loadingState2.isSubmitEnabled).isFalse() + val finalState = awaitItem() + assertThat(finalState.submitAction).isEqualTo(AsyncAction.Success(Unit)) + assertThat(finalState.isSubmitEnabled).isFalse() + } + } + + private fun createPresenter( + encryptionService: EncryptionService = FakeEncryptionService(), + ) = SecureBackupEnterRecoveryKeyPresenter( + encryptionService = encryptionService, + recoveryKeyTools = RecoveryKeyTools(), + ) +} diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyViewTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyViewTest.kt new file mode 100644 index 0000000..d9324fd --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyViewTest.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.enter + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.securebackup.impl.setup.views.aFormattedRecoveryKey +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class SecureBackupEnterRecoveryKeyViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `back key pressed - calls onBackClick`() { + ensureCalledOnce { callback -> + rule.setSecureBackupEnterRecoveryKeyView( + aSecureBackupEnterRecoveryKeyState(), + onBackClick = callback, + ) + rule.pressBackKey() + } + } + + @Test + fun `back button clicked - calls onBackClick`() { + ensureCalledOnce { callback -> + rule.setSecureBackupEnterRecoveryKeyView( + aSecureBackupEnterRecoveryKeyState(), + onBackClick = callback, + ) + rule.pressBack() + } + } + + @Test + @Config(qualifiers = "h1024dp") + fun `tapping on Continue when key is valid - calls expected action`() { + val recorder = EventsRecorder() + rule.setSecureBackupEnterRecoveryKeyView( + aSecureBackupEnterRecoveryKeyState(isSubmitEnabled = true, eventSink = recorder), + ) + rule.clickOn(CommonStrings.action_continue) + + recorder.assertSingle(SecureBackupEnterRecoveryKeyEvents.Submit) + } + + @Test + fun `entering a char emits the expected event`() { + val recorder = EventsRecorder() + val keyValue = aFormattedRecoveryKey() + rule.setSecureBackupEnterRecoveryKeyView( + aSecureBackupEnterRecoveryKeyState(isSubmitEnabled = true, eventSink = recorder), + ) + rule.onNodeWithText(keyValue).performTextInput("X") + recorder.assertSingle( + SecureBackupEnterRecoveryKeyEvents.OnRecoveryKeyChange("X$keyValue") + ) + } + + @Test + @Config(qualifiers = "h1024dp") + fun `toggling the visibility of the textfield changes it`() { + val recorder = EventsRecorder() + val keyValue = aFormattedRecoveryKey() + rule.setSecureBackupEnterRecoveryKeyView(aSecureBackupEnterRecoveryKeyState(isSubmitEnabled = true, eventSink = recorder)) + + // Initially, the text field should be visible + rule.onNodeWithText(keyValue).assertExists() + + rule.onNodeWithContentDescription(rule.activity.getString(CommonStrings.a11y_hide_password)).performClick() + + rule.waitForIdle() + + recorder.assertSingle(SecureBackupEnterRecoveryKeyEvents.ChangeRecoveryKeyFieldContentsVisibility(false)) + } + + @Test + fun `validating from keyboard emits the expected event`() { + val recorder = EventsRecorder() + val keyValue = aFormattedRecoveryKey() + rule.setSecureBackupEnterRecoveryKeyView( + aSecureBackupEnterRecoveryKeyState(isSubmitEnabled = true, eventSink = recorder), + ) + rule.onNodeWithText(keyValue).performImeAction() + recorder.assertSingle(SecureBackupEnterRecoveryKeyEvents.Submit) + } + + @Test + fun `when submit action succeeds - calls onDone`() { + ensureCalledOnce { callback -> + rule.setSecureBackupEnterRecoveryKeyView( + aSecureBackupEnterRecoveryKeyState(submitAction = AsyncAction.Success(Unit)), + onDone = callback, + ) + } + } + + private fun AndroidComposeTestRule.setSecureBackupEnterRecoveryKeyView( + state: SecureBackupEnterRecoveryKeyState, + onDone: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), + ) { + setContent { + SecureBackupEnterRecoveryKeyView( + state = state, + onSuccess = onDone, + onBackClick = onBackClick, + ) + } + } +} diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowManagerTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowManagerTest.kt new file mode 100644 index 0000000..0fbb729 --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/ResetIdentityFlowManagerTest.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.reset + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.libraries.matrix.test.encryption.FakeIdentityPasswordResetHandle +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ResetIdentityFlowManagerTest { + @Test + fun `getResetHandle - emits a reset handle`() = runTest { + val startResetLambda = lambdaRecorder> { Result.success(FakeIdentityPasswordResetHandle()) } + val encryptionService = FakeEncryptionService(startIdentityResetLambda = startResetLambda) + val flowManager = createFlowManager(encryptionService = encryptionService) + + flowManager.getResetHandle().test { + assertThat(awaitItem().isLoading()).isTrue() + assertThat(awaitItem().isSuccess()).isTrue() + startResetLambda.assertions().isCalledOnce() + } + } + + @Test + fun `getResetHandle - om successful handle retrieval returns that same handle`() = runTest { + val startResetLambda = lambdaRecorder> { Result.success(FakeIdentityPasswordResetHandle()) } + val encryptionService = FakeEncryptionService(startIdentityResetLambda = startResetLambda) + val flowManager = createFlowManager(encryptionService = encryptionService) + + var result: AsyncData.Success? = null + flowManager.getResetHandle().test { + assertThat(awaitItem().isLoading()).isTrue() + @Suppress("UNCHECKED_CAST") + result = awaitItem() as? AsyncData.Success + assertThat(result).isNotNull() + } + + flowManager.getResetHandle().test { + assertThat(awaitItem()).isSameInstanceAs(result) + } + } + + @Test + fun `getResetHandle - will success if it receives a null reset handle`() = runTest { + val startResetLambda = lambdaRecorder> { Result.success(null) } + val encryptionService = FakeEncryptionService(startIdentityResetLambda = startResetLambda) + val flowManager = createFlowManager(encryptionService = encryptionService) + + flowManager.getResetHandle().test { + assertThat(awaitItem().isLoading()).isTrue() + val finalItem = awaitItem() + assertThat(finalItem.isSuccess()).isTrue() + assertThat(finalItem.dataOrNull()).isNull() + startResetLambda.assertions().isCalledOnce() + } + } + + @Test + fun `getResetHandle - fails gracefully when receiving an exception from the encryption service`() = runTest { + val startResetLambda = lambdaRecorder> { Result.failure(IllegalStateException("Failure")) } + val encryptionService = FakeEncryptionService(startIdentityResetLambda = startResetLambda) + val flowManager = createFlowManager(encryptionService = encryptionService) + + flowManager.getResetHandle().test { + assertThat(awaitItem().isLoading()).isTrue() + assertThat(awaitItem().isFailure()).isTrue() + startResetLambda.assertions().isCalledOnce() + } + } + + @Test + fun `cancel - resets the state and calls cancel on the reset handle`() = runTest { + val cancelLambda = lambdaRecorder { } + val resetHandle = FakeIdentityPasswordResetHandle(cancelLambda = cancelLambda) + val startResetLambda = lambdaRecorder> { Result.success(resetHandle) } + val encryptionService = FakeEncryptionService(startIdentityResetLambda = startResetLambda) + val flowManager = createFlowManager(encryptionService = encryptionService) + + flowManager.getResetHandle().test { + assertThat(awaitItem().isLoading()).isTrue() + assertThat(awaitItem().isSuccess()).isTrue() + + flowManager.cancel() + cancelLambda.assertions().isCalledOnce() + assertThat(awaitItem().isUninitialized()).isTrue() + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `whenResetIsDone - will trigger the lambda when verification status is verified`() = runTest { + val verificationService = FakeSessionVerificationService() + val flowManager = createFlowManager(sessionVerificationService = verificationService) + var isDone = false + + flowManager.whenResetIsDone { + isDone = true + } + + assertThat(isDone).isFalse() + + verificationService.emitVerifiedStatus(SessionVerifiedStatus.Unknown) + advanceUntilIdle() + assertThat(isDone).isFalse() + + verificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified) + advanceUntilIdle() + assertThat(isDone).isFalse() + + verificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified) + advanceUntilIdle() + assertThat(isDone).isTrue() + } + + private fun TestScope.createFlowManager( + encryptionService: FakeEncryptionService = FakeEncryptionService(), + sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), + ) = ResetIdentityFlowManager( + encryptionService = encryptionService, + sessionCoroutineScope = this, + sessionVerificationService = sessionVerificationService, + ) +} diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordPresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordPresenterTest.kt new file mode 100644 index 0000000..3c2269e --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordPresenterTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.reset.password + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.encryption.FakeIdentityPasswordResetHandle +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ResetIdentityPasswordPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.resetAction.isUninitialized()).isTrue() + } + } + + @Test + fun `present - Reset event succeeds`() = runTest { + val resetLambda = lambdaRecorder> { _ -> Result.success(Unit) } + val resetHandle = FakeIdentityPasswordResetHandle(resetPasswordLambda = resetLambda) + val presenter = createPresenter(identityResetHandle = resetHandle) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(ResetIdentityPasswordEvent.Reset("password")) + assertThat(awaitItem().resetAction.isLoading()).isTrue() + assertThat(awaitItem().resetAction.isSuccess()).isTrue() + } + } + + @Test + fun `present - Reset event can fail gracefully`() = runTest { + val resetLambda = lambdaRecorder> { _ -> Result.failure(IllegalStateException("Failed")) } + val resetHandle = FakeIdentityPasswordResetHandle(resetPasswordLambda = resetLambda) + val presenter = createPresenter(identityResetHandle = resetHandle) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(ResetIdentityPasswordEvent.Reset("password")) + assertThat(awaitItem().resetAction.isLoading()).isTrue() + assertThat(awaitItem().resetAction.isFailure()).isTrue() + } + } + + @Test + fun `present - DismissError event resets the state`() = runTest { + val resetLambda = lambdaRecorder> { _ -> Result.failure(IllegalStateException("Failed")) } + val resetHandle = FakeIdentityPasswordResetHandle(resetPasswordLambda = resetLambda) + val presenter = createPresenter(identityResetHandle = resetHandle) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(ResetIdentityPasswordEvent.Reset("password")) + assertThat(awaitItem().resetAction.isLoading()).isTrue() + assertThat(awaitItem().resetAction.isFailure()).isTrue() + + initialState.eventSink(ResetIdentityPasswordEvent.DismissError) + assertThat(awaitItem().resetAction.isUninitialized()).isTrue() + } + } + + private fun TestScope.createPresenter( + identityResetHandle: FakeIdentityPasswordResetHandle = FakeIdentityPasswordResetHandle(), + ) = ResetIdentityPasswordPresenter( + identityPasswordResetHandle = identityResetHandle, + dispatchers = testCoroutineDispatchers(), + ) +} diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordViewTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordViewTest.kt new file mode 100644 index 0000000..6cfd061 --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordViewTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.reset.password + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ResetIdentityPasswordViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `pressing the back HW button invokes the expected callback`() { + ensureCalledOnce { + rule.setResetPasswordView( + ResetIdentityPasswordState(resetAction = AsyncAction.Uninitialized, eventSink = {}), + onBack = it, + ) + rule.pressBackKey() + } + } + + @Test + fun `clicking on the back navigation button invokes the expected callback`() { + ensureCalledOnce { + rule.setResetPasswordView( + ResetIdentityPasswordState(resetAction = AsyncAction.Uninitialized, eventSink = {}), + onBack = it, + ) + rule.pressBack() + } + } + + @Test + fun `clicking 'Reset identity' confirms the reset`() { + val eventsRecorder = EventsRecorder() + rule.setResetPasswordView( + ResetIdentityPasswordState(resetAction = AsyncAction.Uninitialized, eventSink = eventsRecorder), + ) + rule.onNodeWithText("Password").performTextInput("A password") + + rule.clickOn(CommonStrings.action_reset_identity) + + eventsRecorder.assertSingle(ResetIdentityPasswordEvent.Reset("A password")) + } + + @Test + fun `modifying the password dismisses the error state`() { + val eventsRecorder = EventsRecorder() + rule.setResetPasswordView( + ResetIdentityPasswordState(resetAction = AsyncAction.Failure(IllegalStateException("A failure")), eventSink = eventsRecorder), + ) + rule.onNodeWithText("Password").performTextInput("A password") + + eventsRecorder.assertSingle(ResetIdentityPasswordEvent.DismissError) + } +} + +private fun AndroidComposeTestRule.setResetPasswordView( + state: ResetIdentityPasswordState, + onBack: () -> Unit = EnsureNeverCalled(), +) { + setContent { + ResetIdentityPasswordView(state = state, onBack = onBack) + } +} diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootPresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootPresenterTest.kt new file mode 100644 index 0000000..9bff023 --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootPresenterTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.reset.root + +import app.cash.molecule.RecompositionMode +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 ResetIdentityRootPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = ResetIdentityRootPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.displayConfirmationDialog).isFalse() + } + } + + @Test + fun `present - Continue event displays the confirmation dialog`() = runTest { + val presenter = ResetIdentityRootPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(ResetIdentityRootEvent.Continue) + + assertThat(awaitItem().displayConfirmationDialog).isTrue() + } + } + + @Test + fun `present - DismissDialog event hides the confirmation dialog`() = runTest { + val presenter = ResetIdentityRootPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(ResetIdentityRootEvent.Continue) + assertThat(awaitItem().displayConfirmationDialog).isTrue() + + initialState.eventSink(ResetIdentityRootEvent.DismissDialog) + assertThat(awaitItem().displayConfirmationDialog).isFalse() + } + } +} diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootViewTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootViewTest.kt new file mode 100644 index 0000000..a913a9a --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/reset/root/ResetIdentityRootViewTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.reset.root + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.securebackup.impl.R +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class ResetIdentityRootViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `pressing the back HW button invokes the expected callback`() { + ensureCalledOnce { + rule.setResetRootView( + ResetIdentityRootState(displayConfirmationDialog = false, eventSink = {}), + onBack = it, + ) + rule.pressBackKey() + } + } + + @Test + fun `clicking on the back navigation button invokes the expected callback`() { + ensureCalledOnce { + rule.setResetRootView( + ResetIdentityRootState(displayConfirmationDialog = false, eventSink = {}), + onBack = it, + ) + rule.pressBack() + } + } + + @Test + @Config(qualifiers = "h720dp") + fun `clicking Continue displays the confirmation dialog`() { + val eventsRecorder = EventsRecorder() + rule.setResetRootView( + ResetIdentityRootState(displayConfirmationDialog = false, eventSink = eventsRecorder), + ) + + rule.clickOn(R.string.screen_encryption_reset_action_continue_reset) + + eventsRecorder.assertSingle(ResetIdentityRootEvent.Continue) + } + + @Test + fun `clicking 'Yes, reset now' confirms the reset`() { + ensureCalledOnce { + rule.setResetRootView( + ResetIdentityRootState(displayConfirmationDialog = true, eventSink = {}), + onContinue = it, + ) + rule.clickOn(R.string.screen_reset_encryption_confirmation_alert_action) + } + } + + @Test + fun `clicking Cancel dismisses the dialog`() { + val eventsRecorder = EventsRecorder() + rule.setResetRootView( + ResetIdentityRootState(displayConfirmationDialog = true, eventSink = eventsRecorder), + ) + + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(ResetIdentityRootEvent.DismissDialog) + } +} + +private fun AndroidComposeTestRule.setResetRootView( + state: ResetIdentityRootState, + onBack: () -> Unit = EnsureNeverCalled(), + onContinue: () -> Unit = EnsureNeverCalled(), +) { + setContent { + ResetIdentityRootView(state = state, onContinue = onContinue, onBack = onBack) + } +} diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootPresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootPresenterTest.kt new file mode 100644 index 0000000..63adfc7 --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootPresenterTest.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.root + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.matrix.api.encryption.BackupState +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class SecureBackupRootPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createSecureBackupRootPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(2) + val initialState = awaitItem() + assertThat(initialState.backupState).isEqualTo(BackupState.UNKNOWN) + assertThat(initialState.doesBackupExistOnServer.dataOrNull()).isTrue() + assertThat(initialState.enableAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(initialState.displayKeyStorageDisabledError).isFalse() + assertThat(initialState.recoveryState).isEqualTo(RecoveryState.UNKNOWN) + assertThat(initialState.appName).isEqualTo("Element") + assertThat(initialState.snackbarMessage).isNull() + } + } + + @Test + fun `present - Unknown state`() = runTest { + val encryptionService = FakeEncryptionService() + val presenter = createSecureBackupRootPresenter( + encryptionService = encryptionService, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + encryptionService.givenDoesBackupExistOnServerResult(Result.failure(AN_EXCEPTION)) + assertThat(initialState.backupState).isEqualTo(BackupState.UNKNOWN) + assertThat(initialState.doesBackupExistOnServer).isEqualTo(AsyncData.Uninitialized) + val loadingState1 = awaitItem() + assertThat(loadingState1.doesBackupExistOnServer).isInstanceOf(AsyncData.Loading::class.java) + val errorState = awaitItem() + assertThat(errorState.doesBackupExistOnServer).isEqualTo(AsyncData.Failure(AN_EXCEPTION)) + encryptionService.givenDoesBackupExistOnServerResult(Result.success(false)) + errorState.eventSink.invoke(SecureBackupRootEvents.RetryKeyBackupState) + val loadingState2 = awaitItem() + assertThat(loadingState2.doesBackupExistOnServer).isInstanceOf(AsyncData.Loading::class.java) + val finalState = awaitItem() + assertThat(finalState.doesBackupExistOnServer.dataOrNull()).isFalse() + } + } + + @Test + fun `present - setting up encryption when key storage is disabled should emit a state to render a dialog`() = runTest { + val presenter = createSecureBackupRootPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(2) + val initialState = awaitItem() + initialState.eventSink(SecureBackupRootEvents.DisplayKeyStorageDisabledError) + assertThat(awaitItem().displayKeyStorageDisabledError).isTrue() + initialState.eventSink(SecureBackupRootEvents.DismissDialog) + assertThat(awaitItem().displayKeyStorageDisabledError).isFalse() + } + } + + @Test + fun `present - enable key storage invoke the expected API`() = runTest { + val presenter = createSecureBackupRootPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(2) + val initialState = awaitItem() + initialState.eventSink(SecureBackupRootEvents.EnableKeyStorage) + assertThat(awaitItem().enableAction.isLoading()).isTrue() + assertThat(awaitItem().enableAction.isSuccess()).isTrue() + } + } + + private fun createSecureBackupRootPresenter( + encryptionService: EncryptionService = FakeEncryptionService(), + appName: String = "Element", + ): SecureBackupRootPresenter { + return SecureBackupRootPresenter( + encryptionService = encryptionService, + buildMeta = aBuildMeta(applicationName = appName), + snackbarDispatcher = SnackbarDispatcher(), + ) + } +} diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootStateTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootStateTest.kt new file mode 100644 index 0000000..10b7a9d --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootStateTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.root + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.encryption.BackupState +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import org.junit.Test + +class SecureBackupRootStateTest { + @Test + fun `isKeyStorageEnabled should be true for all these backup states`() { + listOf( + BackupState.CREATING, + BackupState.ENABLING, + BackupState.RESUMING, + BackupState.DOWNLOADING, + BackupState.ENABLED, + ).forEach { backupState -> + assertThat(aSecureBackupRootState(backupState = backupState).isKeyStorageEnabled).isTrue() + } + } + + @Test + fun `isKeyStorageEnabled should be false for all these backup states`() { + listOf( + BackupState.WAITING_FOR_SYNC, + BackupState.DISABLING, + ).forEach { backupState -> + assertThat(aSecureBackupRootState(backupState = backupState).isKeyStorageEnabled).isFalse() + } + } + + @Test + fun `isKeyStorageEnabled should have value depending on doesBackupExistOnServer when state is UNKNOWN`() { + assertThat( + aSecureBackupRootState( + backupState = BackupState.UNKNOWN, + doesBackupExistOnServer = AsyncData.Success(true), + ).isKeyStorageEnabled + ).isTrue() + + listOf( + AsyncData.Uninitialized, + AsyncData.Loading(), + AsyncData.Failure(AN_EXCEPTION), + AsyncData.Success(false), + ).forEach { doesBackupExistOnServer -> + assertThat( + aSecureBackupRootState( + backupState = BackupState.UNKNOWN, + doesBackupExistOnServer = doesBackupExistOnServer, + ).isKeyStorageEnabled + ).isFalse() + } + } +} diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt new file mode 100644 index 0000000..a3cf920 --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.setup + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory +import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState +import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.test.A_RECOVERY_KEY +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class SecureBackupSetupPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createSecureBackupSetupPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.isChangeRecoveryKeyUserStory).isFalse() + assertThat(initialState.setupState).isEqualTo(SetupState.Init) + assertThat(initialState.showSaveConfirmationDialog).isFalse() + assertThat(initialState.recoveryKeyViewState).isEqualTo( + RecoveryKeyViewState( + recoveryKeyUserStory = RecoveryKeyUserStory.Setup, + formattedRecoveryKey = null, + displayTextFieldContents = true, + inProgress = false, + ) + ) + } + } + + @Test + fun `present - create recovery key and save it`() = runTest { + val encryptionService = FakeEncryptionService() + val presenter = createSecureBackupSetupPresenter( + encryptionService = encryptionService + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey) + val creatingState = awaitItem() + assertThat(creatingState.setupState).isEqualTo(SetupState.Creating) + assertThat(creatingState.recoveryKeyViewState).isEqualTo( + RecoveryKeyViewState( + recoveryKeyUserStory = RecoveryKeyUserStory.Setup, + formattedRecoveryKey = null, + displayTextFieldContents = true, + inProgress = true, + ) + ) + encryptionService.emitEnableRecoveryProgress(EnableRecoveryProgress.Done(A_RECOVERY_KEY)) + val createdState = awaitItem() + assertThat(createdState.setupState).isEqualTo(SetupState.Created(A_RECOVERY_KEY)) + assertThat(createdState.recoveryKeyViewState).isEqualTo( + RecoveryKeyViewState( + recoveryKeyUserStory = RecoveryKeyUserStory.Setup, + formattedRecoveryKey = A_RECOVERY_KEY, + displayTextFieldContents = true, + inProgress = false, + ) + ) + createdState.eventSink.invoke(SecureBackupSetupEvents.RecoveryKeyHasBeenSaved) + val createdAndSaveState = awaitItem() + assertThat(createdAndSaveState.setupState).isInstanceOf(SetupState.CreatedAndSaved::class.java) + createdAndSaveState.eventSink.invoke(SecureBackupSetupEvents.Done) + val doneState = awaitItem() + assertThat(doneState.showSaveConfirmationDialog).isTrue() + doneState.eventSink.invoke(SecureBackupSetupEvents.DismissDialog) + val doneStateCancelled = awaitItem() + assertThat(doneStateCancelled.showSaveConfirmationDialog).isFalse() + } + } + + @Test + fun `present - initial state change key`() = runTest { + val presenter = createSecureBackupSetupPresenter( + isChangeRecoveryKeyUserStory = true, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.isChangeRecoveryKeyUserStory).isTrue() + assertThat(initialState.setupState).isEqualTo(SetupState.Init) + assertThat(initialState.recoveryKeyViewState).isEqualTo( + RecoveryKeyViewState( + recoveryKeyUserStory = RecoveryKeyUserStory.Change, + formattedRecoveryKey = null, + displayTextFieldContents = true, + inProgress = false, + ) + ) + } + } + + @Test + fun `present - handle errors`() = runTest { + val encryptionService = FakeEncryptionService( + enableRecoveryLambda = { Result.failure(IllegalStateException("Test error")) } + ) + val presenter = createSecureBackupSetupPresenter( + isChangeRecoveryKeyUserStory = false, + encryptionService = encryptionService + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.isChangeRecoveryKeyUserStory).isFalse() + assertThat(initialState.setupState).isEqualTo(SetupState.Init) + + initialState.eventSink(SecureBackupSetupEvents.CreateRecoveryKey) + val creatingState = awaitItem() + assertThat(creatingState.setupState).isEqualTo(SetupState.Creating) + val failedState = awaitItem() + assertThat(failedState.setupState).isInstanceOf(SetupState.Error::class.java) + failedState.eventSink(SecureBackupSetupEvents.DismissDialog) + + val finalState = awaitItem() + assertThat(finalState.setupState).isEqualTo(SetupState.Init) + } + } + + @Test + fun `present - change recovery key and save it`() = runTest { + val encryptionService = FakeEncryptionService() + val presenter = createSecureBackupSetupPresenter( + isChangeRecoveryKeyUserStory = true, + encryptionService = encryptionService + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey) + val creatingState = awaitItem() + assertThat(creatingState.setupState).isEqualTo(SetupState.Creating) + assertThat(creatingState.recoveryKeyViewState).isEqualTo( + RecoveryKeyViewState( + recoveryKeyUserStory = RecoveryKeyUserStory.Change, + formattedRecoveryKey = null, + displayTextFieldContents = true, + inProgress = true, + ) + ) + val createdState = awaitItem() + assertThat(createdState.setupState).isEqualTo(SetupState.Created(FakeEncryptionService.FAKE_RECOVERY_KEY)) + assertThat(createdState.recoveryKeyViewState).isEqualTo( + RecoveryKeyViewState( + recoveryKeyUserStory = RecoveryKeyUserStory.Change, + formattedRecoveryKey = FakeEncryptionService.FAKE_RECOVERY_KEY, + displayTextFieldContents = true, + inProgress = false, + ) + ) + createdState.eventSink.invoke(SecureBackupSetupEvents.RecoveryKeyHasBeenSaved) + val createdAndSaveState = awaitItem() + assertThat(createdAndSaveState.setupState).isInstanceOf(SetupState.CreatedAndSaved::class.java) + createdAndSaveState.eventSink.invoke(SecureBackupSetupEvents.Done) + val doneState = awaitItem() + assertThat(doneState.showSaveConfirmationDialog).isTrue() + doneState.eventSink.invoke(SecureBackupSetupEvents.DismissDialog) + val doneStateCancelled = awaitItem() + assertThat(doneStateCancelled.showSaveConfirmationDialog).isFalse() + } + } + + private fun createSecureBackupSetupPresenter( + isChangeRecoveryKeyUserStory: Boolean = false, + encryptionService: EncryptionService = FakeEncryptionService( + enableRecoveryLambda = { Result.success(Unit) }, + ), + ): SecureBackupSetupPresenter { + return SecureBackupSetupPresenter( + isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory, + stateMachine = SecureBackupSetupStateMachine(), + encryptionService = encryptionService, + ) + } +} diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/tools/RecoveryKeyToolsTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/tools/RecoveryKeyToolsTest.kt new file mode 100644 index 0000000..ce66a06 --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/tools/RecoveryKeyToolsTest.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.tools + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class RecoveryKeyToolsTest { + @Test + fun `isRecoveryKeyFormatValid return false for invalid key`() { + val sut = RecoveryKeyTools() + assertThat(sut.isRecoveryKeyFormatValid("")).isFalse() + // Wrong size + assertThat(sut.isRecoveryKeyFormatValid("abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabc")).isFalse() + assertThat(sut.isRecoveryKeyFormatValid("abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcda")).isFalse() + // Wrong alphabet 0 + assertThat(sut.isRecoveryKeyFormatValid("0bcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd")).isFalse() + // Wrong alphabet O + assertThat(sut.isRecoveryKeyFormatValid("Obcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd")).isFalse() + // Wrong alphabet l + assertThat(sut.isRecoveryKeyFormatValid("lbcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd")).isFalse() + } + + @Test + fun `isRecoveryKeyFormatValid return true for valid key`() { + val sut = RecoveryKeyTools() + assertThat(sut.isRecoveryKeyFormatValid("abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd")).isTrue() + // Spaces does not count + assertThat(sut.isRecoveryKeyFormatValid("abcd abcd abcd abcd abcd abcd abcd abcd abcd abcd abcd abcd")).isTrue() + } +} diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/tools/RecoveryKeyVisualTransformationTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/tools/RecoveryKeyVisualTransformationTest.kt new file mode 100644 index 0000000..98605c0 --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/tools/RecoveryKeyVisualTransformationTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securebackup.impl.tools + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class RecoveryKeyVisualTransformationTest { + @Test + fun `RecoveryKeyOffsetMapping computes correct originalToTransformed values`() { + var sut = RecoveryKeyVisualTransformation.RecoveryKeyOffsetMapping("") + assertThat(sut.originalToTransformed(0)).isEqualTo(0) + + sut = RecoveryKeyVisualTransformation.RecoveryKeyOffsetMapping("a") + assertThat(sut.originalToTransformed(0)).isEqualTo(0) + assertThat(sut.originalToTransformed(1)).isEqualTo(1) + + sut = RecoveryKeyVisualTransformation.RecoveryKeyOffsetMapping("ab") + assertThat(sut.originalToTransformed(0)).isEqualTo(0) + assertThat(sut.originalToTransformed(1)).isEqualTo(1) + assertThat(sut.originalToTransformed(2)).isEqualTo(2) + + sut = RecoveryKeyVisualTransformation.RecoveryKeyOffsetMapping("abc") + assertThat(sut.originalToTransformed(0)).isEqualTo(0) + assertThat(sut.originalToTransformed(1)).isEqualTo(1) + assertThat(sut.originalToTransformed(2)).isEqualTo(2) + assertThat(sut.originalToTransformed(3)).isEqualTo(3) + + sut = RecoveryKeyVisualTransformation.RecoveryKeyOffsetMapping("abcd") + assertThat(sut.originalToTransformed(0)).isEqualTo(0) + assertThat(sut.originalToTransformed(1)).isEqualTo(1) + assertThat(sut.originalToTransformed(2)).isEqualTo(2) + assertThat(sut.originalToTransformed(3)).isEqualTo(3) + assertThat(sut.originalToTransformed(4)).isEqualTo(4) + + sut = RecoveryKeyVisualTransformation.RecoveryKeyOffsetMapping("abcde") + assertThat(sut.originalToTransformed(0)).isEqualTo(0) + assertThat(sut.originalToTransformed(1)).isEqualTo(1) + assertThat(sut.originalToTransformed(2)).isEqualTo(2) + assertThat(sut.originalToTransformed(3)).isEqualTo(3) + assertThat(sut.originalToTransformed(4)).isEqualTo(5) + assertThat(sut.originalToTransformed(5)).isEqualTo(6) + } + + @Test + fun `RecoveryKeyOffsetMapping computes correct transformedToOriginal values`() { + // text parameter is not used by transformedToOriginal + val sut = RecoveryKeyVisualTransformation.RecoveryKeyOffsetMapping("") + assertThat(sut.transformedToOriginal(0)).isEqualTo(0) + assertThat(sut.transformedToOriginal(1)).isEqualTo(1) + assertThat(sut.transformedToOriginal(2)).isEqualTo(2) + assertThat(sut.transformedToOriginal(3)).isEqualTo(3) + assertThat(sut.transformedToOriginal(4)).isEqualTo(4) + assertThat(sut.transformedToOriginal(5)).isEqualTo(4) + assertThat(sut.transformedToOriginal(6)).isEqualTo(5) + assertThat(sut.transformedToOriginal(7)).isEqualTo(6) + assertThat(sut.transformedToOriginal(8)).isEqualTo(7) + assertThat(sut.transformedToOriginal(9)).isEqualTo(8) + assertThat(sut.transformedToOriginal(10)).isEqualTo(8) + } +} diff --git a/features/securityandprivacy/api/build.gradle.kts b/features/securityandprivacy/api/build.gradle.kts new file mode 100644 index 0000000..4260bea --- /dev/null +++ b/features/securityandprivacy/api/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.securityandprivacy.api" +} +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.core) + implementation(projects.libraries.matrix.api) +} diff --git a/features/securityandprivacy/api/src/main/kotlin/io/element/android/features/securityandprivacy/api/SecurityAndPrivacyEntryPoint.kt b/features/securityandprivacy/api/src/main/kotlin/io/element/android/features/securityandprivacy/api/SecurityAndPrivacyEntryPoint.kt new file mode 100644 index 0000000..2c7c1cf --- /dev/null +++ b/features/securityandprivacy/api/src/main/kotlin/io/element/android/features/securityandprivacy/api/SecurityAndPrivacyEntryPoint.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.api + +import io.element.android.libraries.architecture.SimpleFeatureEntryPoint + +fun interface SecurityAndPrivacyEntryPoint : SimpleFeatureEntryPoint diff --git a/features/securityandprivacy/api/src/main/kotlin/io/element/android/features/securityandprivacy/api/SecurityAndPrivacyPermissions.kt b/features/securityandprivacy/api/src/main/kotlin/io/element/android/features/securityandprivacy/api/SecurityAndPrivacyPermissions.kt new file mode 100644 index 0000000..82ab305 --- /dev/null +++ b/features/securityandprivacy/api/src/main/kotlin/io/element/android/features/securityandprivacy/api/SecurityAndPrivacyPermissions.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.api + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.produceState +import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions.Companion.DEFAULT +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.api.room.powerlevels.canSendState + +data class SecurityAndPrivacyPermissions( + val canChangeRoomAccess: Boolean, + val canChangeHistoryVisibility: Boolean, + val canChangeEncryption: Boolean, + val canChangeRoomVisibility: Boolean, +) { + val hasAny = canChangeRoomAccess || + canChangeHistoryVisibility || + canChangeEncryption || + canChangeRoomVisibility + + companion object { + val DEFAULT = SecurityAndPrivacyPermissions( + canChangeRoomAccess = false, + canChangeHistoryVisibility = false, + canChangeEncryption = false, + canChangeRoomVisibility = false, + ) + } +} + +@Composable +fun BaseRoom.securityAndPrivacyPermissionsAsState(updateKey: Long): State { + return produceState(DEFAULT, key1 = updateKey) { + value = SecurityAndPrivacyPermissions( + canChangeRoomAccess = canSendState(type = StateEventType.ROOM_JOIN_RULES).getOrElse { false }, + canChangeHistoryVisibility = canSendState(type = StateEventType.ROOM_HISTORY_VISIBILITY).getOrElse { false }, + canChangeEncryption = canSendState(type = StateEventType.ROOM_ENCRYPTION).getOrElse { false }, + canChangeRoomVisibility = canSendState(type = StateEventType.ROOM_CANONICAL_ALIAS).getOrElse { false }, + ) + } +} diff --git a/features/securityandprivacy/impl/build.gradle.kts b/features/securityandprivacy/impl/build.gradle.kts new file mode 100644 index 0000000..5a83ceb --- /dev/null +++ b/features/securityandprivacy/impl/build.gradle.kts @@ -0,0 +1,48 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.securityandprivacy.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + api(projects.features.securityandprivacy.api) + implementation(projects.appnav) + implementation(projects.libraries.architecture) + implementation(projects.libraries.core) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.matrix.api) + // For test fixtures used in previews + implementation(projects.libraries.previewutils) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.uiStrings) + implementation(projects.services.analytics.api) + implementation(projects.libraries.featureflag.api) + + testCommonDependencies(libs, true) + testImplementation(projects.services.analytics.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.libraries.testtags) +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/DefaultSecurityAndPrivacyEntryPoint.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/DefaultSecurityAndPrivacyEntryPoint.kt new file mode 100644 index 0000000..2d01ed4 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/DefaultSecurityAndPrivacyEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.RoomScope + +@ContributesBinding(RoomScope::class) +class DefaultSecurityAndPrivacyEntryPoint : SecurityAndPrivacyEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext): Node { + return parentNode.createNode(buildContext) + } +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNode.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNode.kt new file mode 100644 index 0000000..5dbc8db --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNode.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.impl + +import android.os.Parcelable +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.navmodel.backstack.BackStack +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.securityandprivacy.impl.editroomaddress.EditRoomAddressNode +import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyNode +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.RoomScope +import kotlinx.parcelize.Parcelize + +@ContributesNode(RoomScope::class) +@AssistedInject +class SecurityAndPrivacyFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.SecurityAndPrivacy, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + sealed interface NavTarget : Parcelable { + @Parcelize + data object SecurityAndPrivacy : NavTarget + + @Parcelize + data object EditRoomAddress : NavTarget + } + + private val navigator = BackstackSecurityAndPrivacyNavigator(backstack) + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.SecurityAndPrivacy -> { + createNode(buildContext, plugins = listOf(navigator)) + } + NavTarget.EditRoomAddress -> { + createNode(buildContext, plugins = listOf(navigator)) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView(modifier) + } +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyNavigator.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyNavigator.kt new file mode 100644 index 0000000..3b71868 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyNavigator.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.impl + +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.pop +import com.bumble.appyx.navmodel.backstack.operation.push + +interface SecurityAndPrivacyNavigator : Plugin { + fun openEditRoomAddress() + fun closeEditRoomAddress() +} + +class BackstackSecurityAndPrivacyNavigator( + private val backStack: BackStack +) : SecurityAndPrivacyNavigator { + override fun openEditRoomAddress() { + backStack.push(SecurityAndPrivacyFlowNode.NavTarget.EditRoomAddress) + } + + override fun closeEditRoomAddress() { + backStack.pop() + } +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressEvents.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressEvents.kt new file mode 100644 index 0000000..e42236d --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressEvents.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.impl.editroomaddress + +sealed interface EditRoomAddressEvents { + data object Save : EditRoomAddressEvents + data object DismissError : EditRoomAddressEvents + data class RoomAddressChanged(val roomAddress: String) : EditRoomAddressEvents +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressNode.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressNode.kt new file mode 100644 index 0000000..ba92cfb --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressNode.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.impl.editroomaddress + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator +import io.element.android.libraries.di.RoomScope + +@ContributesNode(RoomScope::class) +@AssistedInject +class EditRoomAddressNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: EditRoomAddressPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + private val navigator = plugins().first() + private val presenter = presenterFactory.create(navigator) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + EditRoomAddressView( + state = state, + onBackClick = ::navigateUp, + modifier = modifier + ) + } +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressPresenter.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressPresenter.kt new file mode 100644 index 0000000..8dcea0d --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressPresenter.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.impl.editroomaddress + +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 androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper +import io.element.android.libraries.matrix.api.roomAliasFromName +import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity +import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidityEffect +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@AssistedInject +class EditRoomAddressPresenter( + @Assisted private val navigator: SecurityAndPrivacyNavigator, + private val client: MatrixClient, + private val room: JoinedRoom, + private val roomAliasHelper: RoomAliasHelper, +) : Presenter { + @AssistedFactory + interface Factory { + fun create(navigator: SecurityAndPrivacyNavigator): EditRoomAddressPresenter + } + + @Composable + override fun present(): EditRoomAddressState { + val coroutineScope = rememberCoroutineScope() + val roomInfo by room.roomInfoFlow.collectAsState() + val homeserverName = remember { client.userIdServerName() } + val roomAddressValidity = remember { + mutableStateOf(RoomAddressValidity.Unknown) + } + val savedRoomAddress by remember { derivedStateOf { roomInfo.firstAliasMatching(homeserverName)?.addressName() } } + val saveAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + var newRoomAddress by remember { + mutableStateOf( + savedRoomAddress ?: roomAliasHelper.roomAliasNameFromRoomDisplayName(roomInfo.name.orEmpty()) + ) + } + + fun handleEvent(event: EditRoomAddressEvents) { + when (event) { + EditRoomAddressEvents.Save -> coroutineScope.save( + saveAction = saveAction, + serverName = homeserverName, + newRoomAddress = newRoomAddress + ) + is EditRoomAddressEvents.RoomAddressChanged -> { + newRoomAddress = event.roomAddress + } + EditRoomAddressEvents.DismissError -> { + saveAction.value = AsyncAction.Uninitialized + } + } + } + + RoomAddressValidityEffect( + client = client, + roomAliasHelper = roomAliasHelper, + newRoomAddress = newRoomAddress, + knownRoomAddress = savedRoomAddress + ) { newRoomAddressValidity -> + roomAddressValidity.value = newRoomAddressValidity + } + + return EditRoomAddressState( + homeserverName = homeserverName, + roomAddressValidity = roomAddressValidity.value, + roomAddress = newRoomAddress, + saveAction = saveAction.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.save( + saveAction: MutableState>, + serverName: String, + newRoomAddress: String, + ) = launch { + suspend { + val roomInfo = room.info() + val savedCanonicalAlias = roomInfo.canonicalAlias + val savedAliasFromHomeserver = roomInfo.firstAliasMatching(serverName) + val newRoomAlias = client.roomAliasFromName(newRoomAddress) ?: throw IllegalArgumentException("Invalid room address") + + // First publish the new alias in the room directory + room.publishRoomAliasInRoomDirectory(newRoomAlias).getOrThrow() + // Then try remove the old alias from the room directory + if (savedAliasFromHomeserver != null) { + room.removeRoomAliasFromRoomDirectory(savedAliasFromHomeserver).getOrThrow() + } + + // Finally update the canonical alias state + when { + // Allow to update the canonical alias only if the saved canonical alias matches the homeserver or if there is no canonical alias + savedCanonicalAlias == null || savedCanonicalAlias.matchesServer(serverName) -> { + val newAlternativeAliases = roomInfo.alternativeAliases.filter { it != savedAliasFromHomeserver } + room.updateCanonicalAlias(newRoomAlias, newAlternativeAliases).getOrThrow() + } + // Otherwise, only update the alternative aliases and keep the current canonical alias + else -> { + val newAlternativeAliases = buildList { + // New alias is added first, so we make sure we pick it first + add(newRoomAlias) + // Add all other aliases, except the one we just removed from the room directory + addAll(roomInfo.alternativeAliases.filter { it != savedAliasFromHomeserver }) + } + room.updateCanonicalAlias(savedCanonicalAlias, newAlternativeAliases).getOrThrow() + } + } + navigator.closeEditRoomAddress() + }.runCatchingUpdatingState(saveAction) + } +} + +/** + * Returns the first alias that matches the given server name, or null if none match. + */ +private fun RoomInfo.firstAliasMatching(serverName: String): RoomAlias? { + // Check if the canonical alias matches the homeserver + if (canonicalAlias?.matchesServer(serverName) == true) { + return canonicalAlias + } + return alternativeAliases.firstOrNull { it.matchesServer(serverName) } +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressState.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressState.kt new file mode 100644 index 0000000..89315ab --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressState.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.impl.editroomaddress + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity + +data class EditRoomAddressState( + val homeserverName: String, + val roomAddress: String, + val roomAddressValidity: RoomAddressValidity, + val saveAction: AsyncAction, + val eventSink: (EditRoomAddressEvents) -> Unit +) { + val canBeSaved = roomAddressValidity == RoomAddressValidity.Valid +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressStateProvider.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressStateProvider.kt new file mode 100644 index 0000000..7b82175 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressStateProvider.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.impl.editroomaddress + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity + +open class EditRoomAddressStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anEditRoomAddressState(), + anEditRoomAddressState(roomAddressValidity = RoomAddressValidity.NotAvailable), + anEditRoomAddressState(roomAddressValidity = RoomAddressValidity.InvalidSymbols), + anEditRoomAddressState(roomAddressValidity = RoomAddressValidity.Valid), + anEditRoomAddressState(roomAddressValidity = RoomAddressValidity.Valid, saveAction = AsyncAction.Loading), + ) +} + +fun anEditRoomAddressState( + roomAddress: String = "therapy", + roomAddressValidity: RoomAddressValidity = RoomAddressValidity.Unknown, + homeserverName: String = ":myserver.org", + saveAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (EditRoomAddressEvents) -> Unit = {} +) = EditRoomAddressState( + roomAddress = roomAddress, + roomAddressValidity = roomAddressValidity, + homeserverName = homeserverName, + saveAction = saveAction, + eventSink = eventSink +) diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressView.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressView.kt new file mode 100644 index 0000000..da18b96 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressView.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.impl.editroomaddress + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +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.runtime.Composable +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.securityandprivacy.impl.R +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Scaffold +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.room.address.RoomAddressField +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun EditRoomAddressView( + state: EditRoomAddressState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + EditRoomAddressTopBar( + isSaveActionEnabled = state.canBeSaved, + onBackClick = onBackClick, + onSaveClick = { + state.eventSink(EditRoomAddressEvents.Save) + }, + ) + } + ) { padding -> + Box( + modifier = Modifier + .padding(padding) + .imePadding() + .verticalScroll(rememberScrollState()) + .consumeWindowInsets(padding) + ) { + RoomAddressField( + address = state.roomAddress, + homeserverName = state.homeserverName, + addressValidity = state.roomAddressValidity, + onAddressChange = { + state.eventSink(EditRoomAddressEvents.RoomAddressChanged(it)) + }, + label = stringResource(R.string.screen_edit_room_address_title), + supportingText = stringResource(R.string.screen_edit_room_address_room_address_section_footer), + modifier = Modifier + .fillMaxWidth() + .padding(all = 16.dp) + ) + } + AsyncActionView( + async = state.saveAction, + progressDialog = { + AsyncActionViewDefaults.ProgressDialog( + progressText = stringResource(CommonStrings.common_saving), + ) + }, + onSuccess = {}, + errorMessage = { stringResource(CommonStrings.error_unknown) }, + onRetry = { state.eventSink(EditRoomAddressEvents.Save) }, + onErrorDismiss = { state.eventSink(EditRoomAddressEvents.DismissError) }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EditRoomAddressTopBar( + isSaveActionEnabled: Boolean, + onBackClick: () -> Unit, + onSaveClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + modifier = modifier, + titleStr = stringResource(R.string.screen_edit_room_address_title), + navigationIcon = { BackButton(onClick = onBackClick) }, + actions = { + TextButton( + text = stringResource(CommonStrings.action_save), + enabled = isSaveActionEnabled, + onClick = onSaveClick, + ) + } + ) +} + +@PreviewsDayNight +@Composable +internal fun EditRoomAddressViewPreview( + @PreviewParameter(EditRoomAddressStateProvider::class) state: EditRoomAddressState +) = ElementPreview { + EditRoomAddressView( + state = state, + onBackClick = {}, + ) +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/RoomAlias.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/RoomAlias.kt new file mode 100644 index 0000000..f9ae4bf --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/RoomAlias.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.impl.editroomaddress + +import io.element.android.libraries.matrix.api.core.RoomAlias + +/** + * Returns the local part of the alias. + */ +fun RoomAlias.addressName(): String { + return value.drop(1).split(":").first() +} + +/** + * Checks if the room alias matches the given server name. + */ +fun RoomAlias.matchesServer(serverName: String): Boolean { + return value.split(":").last() == serverName +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyEvents.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyEvents.kt new file mode 100644 index 0000000..b1d739c --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyEvents.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.impl.root + +sealed interface SecurityAndPrivacyEvents { + data object EditRoomAddress : SecurityAndPrivacyEvents + data object Save : SecurityAndPrivacyEvents + data object Exit : SecurityAndPrivacyEvents + data object DismissExitConfirmation : SecurityAndPrivacyEvents + data class ChangeRoomAccess(val roomAccess: SecurityAndPrivacyRoomAccess) : SecurityAndPrivacyEvents + data object ToggleEncryptionState : SecurityAndPrivacyEvents + data object CancelEnableEncryption : SecurityAndPrivacyEvents + data object ConfirmEnableEncryption : SecurityAndPrivacyEvents + data class ChangeHistoryVisibility(val historyVisibility: SecurityAndPrivacyHistoryVisibility) : SecurityAndPrivacyEvents + data object ToggleRoomVisibility : SecurityAndPrivacyEvents + data object DismissSaveError : SecurityAndPrivacyEvents +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyNode.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyNode.kt new file mode 100644 index 0000000..5e329a0 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyNode.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.impl.root + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator +import io.element.android.libraries.architecture.appyx.launchMolecule +import io.element.android.libraries.di.RoomScope + +@ContributesNode(RoomScope::class) +@AssistedInject +class SecurityAndPrivacyNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: SecurityAndPrivacyPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + private val navigator = plugins().first() + private val presenter = presenterFactory.create(navigator) + + private val stateFlow = launchMolecule { presenter.present() } + + @Composable + override fun View(modifier: Modifier) { + val state by stateFlow.collectAsState() + SecurityAndPrivacyView( + state = state, + onBackClick = this::navigateUp, + modifier = modifier + ) + } +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt new file mode 100644 index 0000000..43d8383 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt @@ -0,0 +1,306 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissionsAsState +import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator +import io.element.android.features.securityandprivacy.impl.editroomaddress.matchesServer +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@AssistedInject +class SecurityAndPrivacyPresenter( + @Assisted private val navigator: SecurityAndPrivacyNavigator, + private val matrixClient: MatrixClient, + private val room: JoinedRoom, + private val featureFlagService: FeatureFlagService, +) : Presenter { + @AssistedFactory + interface Factory { + fun create(navigator: SecurityAndPrivacyNavigator): SecurityAndPrivacyPresenter + } + + @Composable + override fun present(): SecurityAndPrivacyState { + val coroutineScope = rememberCoroutineScope() + + val isKnockEnabled by remember { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock) + }.collectAsState(false) + val saveAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + var confirmExitAction by remember { mutableStateOf>(AsyncAction.Uninitialized) } + val homeserverName = remember { matrixClient.userIdServerName() } + val syncUpdateFlow = room.syncUpdateFlow.collectAsState() + val roomInfo by room.roomInfoFlow.collectAsState() + + val savedIsVisibleInRoomDirectory = remember { mutableStateOf>(AsyncData.Uninitialized) } + LaunchedEffect(Unit) { + isRoomVisibleInRoomDirectory(savedIsVisibleInRoomDirectory) + } + + val savedSettings by remember { + derivedStateOf { + SecurityAndPrivacySettings( + roomAccess = roomInfo.joinRule.map(), + isEncrypted = roomInfo.isEncrypted == true, + isVisibleInRoomDirectory = savedIsVisibleInRoomDirectory.value, + historyVisibility = roomInfo.historyVisibility.map(), + address = roomInfo.firstDisplayableAlias(homeserverName)?.value, + ) + } + } + + var editedRoomAccess by remember(savedSettings.roomAccess) { + mutableStateOf(savedSettings.roomAccess) + } + var editedHistoryVisibility by remember(savedSettings.historyVisibility) { + mutableStateOf(savedSettings.historyVisibility) + } + var editedIsEncrypted by remember(savedSettings.isEncrypted) { + mutableStateOf(savedSettings.isEncrypted) + } + var editedVisibleInRoomDirectory by remember(savedIsVisibleInRoomDirectory.value) { + mutableStateOf(savedIsVisibleInRoomDirectory.value) + } + val editedSettings = SecurityAndPrivacySettings( + roomAccess = editedRoomAccess, + isEncrypted = editedIsEncrypted, + isVisibleInRoomDirectory = editedVisibleInRoomDirectory, + historyVisibility = editedHistoryVisibility, + address = savedSettings.address, + ) + + var showEnableEncryptionConfirmation by remember(savedSettings.isEncrypted) { mutableStateOf(false) } + val permissions by room.securityAndPrivacyPermissionsAsState(syncUpdateFlow.value) + + fun handleEvent(event: SecurityAndPrivacyEvents) { + when (event) { + SecurityAndPrivacyEvents.Save -> { + coroutineScope.save( + saveAction = saveAction, + isVisibleInRoomDirectory = savedIsVisibleInRoomDirectory, + savedSettings = savedSettings, + editedSettings = editedSettings + ) + } + is SecurityAndPrivacyEvents.ChangeRoomAccess -> { + editedRoomAccess = event.roomAccess + } + is SecurityAndPrivacyEvents.ToggleEncryptionState -> { + if (editedIsEncrypted) { + editedIsEncrypted = false + } else { + showEnableEncryptionConfirmation = true + } + } + is SecurityAndPrivacyEvents.ChangeHistoryVisibility -> { + editedHistoryVisibility = event.historyVisibility + } + SecurityAndPrivacyEvents.ToggleRoomVisibility -> { + editedVisibleInRoomDirectory = when (val edited = editedVisibleInRoomDirectory) { + is AsyncData.Success -> AsyncData.Success(!edited.data) + else -> edited + } + } + SecurityAndPrivacyEvents.EditRoomAddress -> navigator.openEditRoomAddress() + SecurityAndPrivacyEvents.CancelEnableEncryption -> { + showEnableEncryptionConfirmation = false + } + SecurityAndPrivacyEvents.ConfirmEnableEncryption -> { + showEnableEncryptionConfirmation = false + editedIsEncrypted = true + } + SecurityAndPrivacyEvents.DismissSaveError -> { + saveAction.value = AsyncAction.Uninitialized + } + SecurityAndPrivacyEvents.Exit -> { + confirmExitAction = if (savedSettings == editedSettings || confirmExitAction.isConfirming()) { + AsyncAction.Success(Unit) + } else { + AsyncAction.ConfirmingNoParams + } + } + SecurityAndPrivacyEvents.DismissExitConfirmation -> { + confirmExitAction = AsyncAction.Uninitialized + } + } + } + + val state = SecurityAndPrivacyState( + savedSettings = savedSettings, + editedSettings = editedSettings, + homeserverName = homeserverName, + showEnableEncryptionConfirmation = showEnableEncryptionConfirmation, + isKnockEnabled = isKnockEnabled, + saveAction = saveAction.value, + permissions = permissions, + isSpace = roomInfo.isSpace, + confirmExitAction = confirmExitAction, + eventSink = ::handleEvent, + ) + + // If the history visibility is not available for the current access, use the fallback. + LaunchedEffect(state.availableHistoryVisibilities) { + if (editedSettings.historyVisibility !in state.availableHistoryVisibilities) { + editedHistoryVisibility = editedSettings.historyVisibility.fallback() + } + } + return state + } + + private fun CoroutineScope.isRoomVisibleInRoomDirectory(isRoomVisible: MutableState>) = launch { + isRoomVisible.runUpdatingState { + room.getRoomVisibility().map { it == RoomVisibility.Public } + } + } + + private fun CoroutineScope.save( + saveAction: MutableState>, + isVisibleInRoomDirectory: MutableState>, + savedSettings: SecurityAndPrivacySettings, + editedSettings: SecurityAndPrivacySettings, + ) = launch { + suspend { + val enableEncryption = async { + if (editedSettings.isEncrypted && !savedSettings.isEncrypted) { + room.enableEncryption() + } else { + Result.success(Unit) + } + } + val updateHistoryVisibility = async { + if (editedSettings.historyVisibility != savedSettings.historyVisibility) { + room.updateHistoryVisibility(editedSettings.historyVisibility.map()) + } else { + Result.success(Unit) + } + } + val updateJoinRule = async { + val joinRule = editedSettings.roomAccess.map() + if (editedSettings.roomAccess != savedSettings.roomAccess && joinRule != null) { + room.updateJoinRule(joinRule) + } else { + Result.success(Unit) + } + } + val updateRoomVisibility = async { + // When a user changes join rules to something other than knock or public, + // the room should be automatically made invisible (private) in the room directory. + val editedIsVisibleInRoomDirectory = when (editedSettings.roomAccess) { + SecurityAndPrivacyRoomAccess.AskToJoin, + SecurityAndPrivacyRoomAccess.Anyone -> editedSettings.isVisibleInRoomDirectory.dataOrNull() + else -> false + } + val savedIsVisibleInRoomDirectory = savedSettings.isVisibleInRoomDirectory.dataOrNull() + if (editedIsVisibleInRoomDirectory != null && editedIsVisibleInRoomDirectory != savedIsVisibleInRoomDirectory) { + val roomVisibility = if (editedIsVisibleInRoomDirectory) RoomVisibility.Public else RoomVisibility.Private + room + .updateRoomVisibility(roomVisibility) + .onSuccess { + isVisibleInRoomDirectory.value = AsyncData.Success(editedIsVisibleInRoomDirectory) + } + } else { + Result.success(Unit) + } + } + val artificialDelay = async { + // Artificial delay to make sure the user sees the loading state + delay(500) + Result.success(Unit) + } + val results = awaitAll( + enableEncryption, + updateHistoryVisibility, + updateJoinRule, + updateRoomVisibility, + artificialDelay + ) + if (results.any { it.isFailure }) { + throw SecurityAndPrivacyFailures.SaveFailed + } + }.runCatchingUpdatingState(saveAction) + } +} + +private fun JoinRule?.map(): SecurityAndPrivacyRoomAccess { + return when (this) { + JoinRule.Public -> SecurityAndPrivacyRoomAccess.Anyone + JoinRule.Knock, is JoinRule.KnockRestricted -> SecurityAndPrivacyRoomAccess.AskToJoin + is JoinRule.Restricted -> SecurityAndPrivacyRoomAccess.SpaceMember + JoinRule.Invite -> SecurityAndPrivacyRoomAccess.InviteOnly + // All other cases are not supported so we default to InviteOnly + is JoinRule.Custom, + JoinRule.Private, + null -> SecurityAndPrivacyRoomAccess.InviteOnly + } +} + +private fun SecurityAndPrivacyRoomAccess.map(): JoinRule? { + return when (this) { + SecurityAndPrivacyRoomAccess.Anyone -> JoinRule.Public + SecurityAndPrivacyRoomAccess.AskToJoin -> JoinRule.Knock + SecurityAndPrivacyRoomAccess.InviteOnly -> JoinRule.Private + // SpaceMember can't be selected in the ui + SecurityAndPrivacyRoomAccess.SpaceMember -> null + } +} + +private fun RoomHistoryVisibility?.map(): SecurityAndPrivacyHistoryVisibility { + return when (this) { + RoomHistoryVisibility.WorldReadable -> SecurityAndPrivacyHistoryVisibility.Anyone + RoomHistoryVisibility.Joined, + RoomHistoryVisibility.Invited -> SecurityAndPrivacyHistoryVisibility.SinceInvite + RoomHistoryVisibility.Shared -> SecurityAndPrivacyHistoryVisibility.SinceSelection + // All other cases are not supported so we default to SinceSelection + is RoomHistoryVisibility.Custom, + null -> SecurityAndPrivacyHistoryVisibility.SinceSelection + } +} + +private fun SecurityAndPrivacyHistoryVisibility.map(): RoomHistoryVisibility { + return when (this) { + SecurityAndPrivacyHistoryVisibility.SinceSelection -> RoomHistoryVisibility.Shared + SecurityAndPrivacyHistoryVisibility.SinceInvite -> RoomHistoryVisibility.Invited + SecurityAndPrivacyHistoryVisibility.Anyone -> RoomHistoryVisibility.WorldReadable + } +} + +private fun RoomInfo.firstDisplayableAlias(serverName: String): RoomAlias? { + return aliases.firstOrNull { it.matchesServer(serverName) } ?: aliases.firstOrNull() +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt new file mode 100644 index 0000000..0671bbf --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.impl.root + +import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import kotlinx.collections.immutable.toImmutableSet + +data class SecurityAndPrivacyState( + // the settings that are currently applied on the room. + val savedSettings: SecurityAndPrivacySettings, + // the settings the user wants to apply. + val editedSettings: SecurityAndPrivacySettings, + val homeserverName: String, + val showEnableEncryptionConfirmation: Boolean, + val isKnockEnabled: Boolean, + val saveAction: AsyncAction, + val confirmExitAction: AsyncAction, + val isSpace: Boolean, + private val permissions: SecurityAndPrivacyPermissions, + val eventSink: (SecurityAndPrivacyEvents) -> Unit +) { + val canBeSaved = savedSettings != editedSettings + + val availableHistoryVisibilities = buildSet { + add(SecurityAndPrivacyHistoryVisibility.SinceSelection) + if (editedSettings.roomAccess == SecurityAndPrivacyRoomAccess.Anyone && !editedSettings.isEncrypted) { + add(SecurityAndPrivacyHistoryVisibility.Anyone) + } else { + add(SecurityAndPrivacyHistoryVisibility.SinceInvite) + } + }.toImmutableSet() + + val showRoomAccessSection = permissions.canChangeRoomAccess + + val showRoomVisibilitySections = permissions.canChangeRoomVisibility && + editedSettings.roomAccess.canConfigureRoomVisibility() + + val showHistoryVisibilitySection = permissions.canChangeHistoryVisibility && !isSpace + val showEncryptionSection = permissions.canChangeEncryption && !isSpace +} + +data class SecurityAndPrivacySettings( + val roomAccess: SecurityAndPrivacyRoomAccess, + val isEncrypted: Boolean, + val historyVisibility: SecurityAndPrivacyHistoryVisibility, + val address: String?, + val isVisibleInRoomDirectory: AsyncData +) + +enum class SecurityAndPrivacyHistoryVisibility { + SinceSelection, + SinceInvite, + Anyone; + + /** + * Returns the fallback visibility when the current visibility is not available. + */ + fun fallback(): SecurityAndPrivacyHistoryVisibility { + return when (this) { + SinceSelection, + SinceInvite -> SinceSelection + Anyone -> SinceInvite + } + } +} + +enum class SecurityAndPrivacyRoomAccess { + InviteOnly, + AskToJoin, + Anyone, + SpaceMember; + + fun canConfigureRoomVisibility(): Boolean { + return when (this) { + InviteOnly, SpaceMember -> false + AskToJoin, Anyone -> true + } + } +} + +sealed class SecurityAndPrivacyFailures : Exception() { + data object SaveFailed : SecurityAndPrivacyFailures() +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStateProvider.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStateProvider.kt new file mode 100644 index 0000000..11e5665 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStateProvider.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.impl.root + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData + +open class SecurityAndPrivacyStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = commonSecurityAndPrivacyStates(isSpace = false) + + commonSecurityAndPrivacyStates(isSpace = true) + + sequenceOf( + aSecurityAndPrivacyState( + saveAction = AsyncAction.Loading, + isSpace = false, + ), + aSecurityAndPrivacyState( + saveAction = AsyncAction.Failure(SecurityAndPrivacyFailures.SaveFailed), + isSpace = false, + ), + aSecurityAndPrivacyState( + confirmExitAction = AsyncAction.ConfirmingCancellation, + isSpace = false, + ), + aSecurityAndPrivacyState( + showEncryptionConfirmation = true, + isSpace = false, + ), + ) +} + +private fun commonSecurityAndPrivacyStates(isSpace: Boolean): Sequence = sequenceOf( + aSecurityAndPrivacyState(isSpace = isSpace), + aSecurityAndPrivacyState( + editedSettings = aSecurityAndPrivacySettings( + roomAccess = SecurityAndPrivacyRoomAccess.AskToJoin, + ), + isSpace = isSpace, + ), + aSecurityAndPrivacyState( + savedSettings = aSecurityAndPrivacySettings( + roomAccess = SecurityAndPrivacyRoomAccess.AskToJoin + ), + isSpace = isSpace, + isKnockEnabled = false, + ), + aSecurityAndPrivacyState( + editedSettings = aSecurityAndPrivacySettings( + roomAccess = SecurityAndPrivacyRoomAccess.Anyone, + isEncrypted = false, + ), + isSpace = isSpace, + ), + aSecurityAndPrivacyState( + savedSettings = aSecurityAndPrivacySettings( + roomAccess = SecurityAndPrivacyRoomAccess.SpaceMember + ), + isSpace = isSpace, + isKnockEnabled = false, + ), + aSecurityAndPrivacyState( + editedSettings = aSecurityAndPrivacySettings( + roomAccess = SecurityAndPrivacyRoomAccess.Anyone, + address = "#therapy:myserver.xyz" + ), + isSpace = isSpace, + ), + aSecurityAndPrivacyState( + editedSettings = aSecurityAndPrivacySettings( + roomAccess = SecurityAndPrivacyRoomAccess.Anyone, + isVisibleInRoomDirectory = AsyncData.Loading() + ), + isSpace = isSpace, + ), + aSecurityAndPrivacyState( + editedSettings = aSecurityAndPrivacySettings( + roomAccess = SecurityAndPrivacyRoomAccess.Anyone, + isVisibleInRoomDirectory = AsyncData.Success(true) + ), + isSpace = isSpace, + ), +) + +fun aSecurityAndPrivacySettings( + roomAccess: SecurityAndPrivacyRoomAccess = SecurityAndPrivacyRoomAccess.InviteOnly, + isEncrypted: Boolean = true, + address: String? = null, + historyVisibility: SecurityAndPrivacyHistoryVisibility = SecurityAndPrivacyHistoryVisibility.SinceSelection, + isVisibleInRoomDirectory: AsyncData = AsyncData.Uninitialized, +) = SecurityAndPrivacySettings( + roomAccess = roomAccess, + isEncrypted = isEncrypted, + address = address, + historyVisibility = historyVisibility, + isVisibleInRoomDirectory = isVisibleInRoomDirectory +) + +fun aSecurityAndPrivacyState( + savedSettings: SecurityAndPrivacySettings = aSecurityAndPrivacySettings(), + editedSettings: SecurityAndPrivacySettings = savedSettings, + homeserverName: String = "myserver.xyz", + showEncryptionConfirmation: Boolean = false, + saveAction: AsyncAction = AsyncAction.Uninitialized, + confirmExitAction: AsyncAction = AsyncAction.Uninitialized, + permissions: SecurityAndPrivacyPermissions = SecurityAndPrivacyPermissions( + canChangeRoomAccess = true, + canChangeHistoryVisibility = true, + canChangeEncryption = true, + canChangeRoomVisibility = true + ), + isKnockEnabled: Boolean = true, + isSpace: Boolean = false, + eventSink: (SecurityAndPrivacyEvents) -> Unit = {} +) = SecurityAndPrivacyState( + editedSettings = editedSettings, + savedSettings = savedSettings, + homeserverName = homeserverName, + showEnableEncryptionConfirmation = showEncryptionConfirmation, + saveAction = saveAction, + confirmExitAction = confirmExitAction, + isKnockEnabled = isKnockEnabled, + permissions = permissions, + isSpace = isSpace, + eventSink = eventSink, +) diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt new file mode 100644 index 0000000..ad75580 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt @@ -0,0 +1,430 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.impl.root + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ListItemDefaults +import androidx.compose.runtime.Composable +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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.securityandprivacy.impl.R +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults +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.SaveChangesDialog +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +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.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableSet + +@Composable +fun SecurityAndPrivacyView( + state: SecurityAndPrivacyState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler { + state.eventSink(SecurityAndPrivacyEvents.Exit) + } + Scaffold( + modifier = modifier, + topBar = { + SecurityAndPrivacyToolbar( + isSaveActionEnabled = state.canBeSaved, + onBackClick = { + state.eventSink(SecurityAndPrivacyEvents.Exit) + }, + onSaveClick = { + state.eventSink(SecurityAndPrivacyEvents.Save) + }, + ) + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .imePadding() + .verticalScroll(rememberScrollState()) + .consumeWindowInsets(padding), + verticalArrangement = Arrangement.spacedBy(32.dp), + ) { + if (state.showRoomAccessSection) { + RoomAccessSection( + modifier = Modifier.padding(top = 24.dp), + edited = state.editedSettings.roomAccess, + saved = state.savedSettings.roomAccess, + isKnockEnabled = state.isKnockEnabled, + onSelectOption = { state.eventSink(SecurityAndPrivacyEvents.ChangeRoomAccess(it)) }, + ) + } + if (state.showRoomVisibilitySections) { + RoomVisibilitySection(state.homeserverName) + RoomAddressSection( + roomAddress = state.editedSettings.address, + homeserverName = state.homeserverName, + onRoomAddressClick = { state.eventSink(SecurityAndPrivacyEvents.EditRoomAddress) }, + isVisibleInRoomDirectory = state.editedSettings.isVisibleInRoomDirectory, + onVisibilityChange = { + state.eventSink(SecurityAndPrivacyEvents.ToggleRoomVisibility) + }, + ) + } + if (state.showEncryptionSection) { + EncryptionSection( + isRoomEncrypted = state.editedSettings.isEncrypted, + // encryption can't be disabled once enabled + canToggleEncryption = !state.savedSettings.isEncrypted, + onToggleEncryption = { state.eventSink(SecurityAndPrivacyEvents.ToggleEncryptionState) }, + showConfirmation = state.showEnableEncryptionConfirmation, + onDismissConfirmation = { state.eventSink(SecurityAndPrivacyEvents.CancelEnableEncryption) }, + onConfirmEncryption = { state.eventSink(SecurityAndPrivacyEvents.ConfirmEnableEncryption) }, + ) + } + if (state.showHistoryVisibilitySection) { + HistoryVisibilitySection( + editedOption = state.editedSettings.historyVisibility, + savedOptions = state.savedSettings.historyVisibility, + availableOptions = state.availableHistoryVisibilities, + onSelectOption = { state.eventSink(SecurityAndPrivacyEvents.ChangeHistoryVisibility(it)) }, + ) + } + } + } + AsyncActionView( + async = state.saveAction, + onSuccess = { }, + onErrorDismiss = { state.eventSink(SecurityAndPrivacyEvents.DismissSaveError) }, + errorMessage = { stringResource(CommonStrings.error_unknown) }, + progressDialog = { + AsyncActionViewDefaults.ProgressDialog( + progressText = stringResource(CommonStrings.common_saving), + ) + }, + onRetry = { state.eventSink(SecurityAndPrivacyEvents.Save) }, + ) + AsyncActionView( + async = state.confirmExitAction, + onSuccess = { onBackClick() }, + onErrorDismiss = { }, + confirmationDialog = { + SaveChangesDialog( + onSubmitClick = { state.eventSink(SecurityAndPrivacyEvents.Exit) }, + onDismiss = { state.eventSink(SecurityAndPrivacyEvents.DismissExitConfirmation) } + ) + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SecurityAndPrivacyToolbar( + isSaveActionEnabled: Boolean, + onBackClick: () -> Unit, + onSaveClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + modifier = modifier, + titleStr = stringResource(R.string.screen_security_and_privacy_title), + navigationIcon = { BackButton(onClick = onBackClick) }, + actions = { + TextButton( + text = stringResource(CommonStrings.action_save), + enabled = isSaveActionEnabled, + onClick = onSaveClick, + ) + } + ) +} + +@Composable +private fun SecurityAndPrivacySection( + title: String, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier.selectableGroup() + ) { + Text( + text = title, + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + modifier = Modifier.padding(horizontal = 16.dp), + ) + content() + } +} + +@Composable +private fun RoomAccessSection( + edited: SecurityAndPrivacyRoomAccess, + saved: SecurityAndPrivacyRoomAccess, + isKnockEnabled: Boolean, + onSelectOption: (SecurityAndPrivacyRoomAccess) -> Unit, + modifier: Modifier = Modifier, +) { + SecurityAndPrivacySection( + title = stringResource(R.string.screen_security_and_privacy_room_access_section_header), + modifier = modifier, + ) { + ListItem( + headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_title)) }, + supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_description)) }, + trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.Anyone), + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Public())), + onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) }, + ) + // Show space member option, but disabled as we don't support this option for now. + if (saved == SecurityAndPrivacyRoomAccess.SpaceMember) { + ListItem( + headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_title)) }, + supportingContent = { + Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_unavailable_description)) + }, + trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.SpaceMember, enabled = false), + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Workspace())), + enabled = false, + ) + } + // Show Ask to join option in two cases: + // - the Knock FF is enabled + // - AskToJoin is the current saved value + if (saved == SecurityAndPrivacyRoomAccess.AskToJoin || isKnockEnabled) { + ListItem( + headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_title)) }, + supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_description)) }, + trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.AskToJoin), + onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.AskToJoin) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserAdd())), + enabled = isKnockEnabled, + ) + } + ListItem( + headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_invite_only_option_title)) }, + supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_invite_only_option_description)) }, + trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.InviteOnly), + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), + onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.InviteOnly) }, + ) + } +} + +@Composable +private fun RoomVisibilitySection( + homeserverName: String, + modifier: Modifier = Modifier, +) { + SecurityAndPrivacySection( + title = stringResource(R.string.screen_security_and_privacy_room_visibility_section_header), + modifier = modifier, + ) { + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.screen_security_and_privacy_room_visibility_section_footer, homeserverName), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } +} + +@Composable +private fun RoomAddressSection( + roomAddress: String?, + homeserverName: String, + isVisibleInRoomDirectory: AsyncData, + onRoomAddressClick: () -> Unit, + onVisibilityChange: () -> Unit, + modifier: Modifier = Modifier, +) { + SecurityAndPrivacySection( + title = stringResource(R.string.screen_security_and_privacy_room_address_section_header), + modifier = modifier, + ) { + ListItem( + headlineContent = { + Text(text = roomAddress ?: stringResource(R.string.screen_security_and_privacy_add_room_address_action)) + }, + trailingContent = if (roomAddress.isNullOrEmpty()) ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())) else null, + supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_address_section_footer)) }, + onClick = onRoomAddressClick, + colors = ListItemDefaults.colors(trailingIconColor = ElementTheme.colors.iconAccentPrimary), + ) + + ListItem( + headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_directory_visibility_toggle_title)) }, + supportingContent = { + Text(text = stringResource(R.string.screen_security_and_privacy_room_directory_visibility_toggle_description, homeserverName)) + }, + onClick = if (isVisibleInRoomDirectory.isSuccess()) onVisibilityChange else null, + trailingContent = when (isVisibleInRoomDirectory) { + is AsyncData.Uninitialized, is AsyncData.Loading -> { + ListItemContent.Custom { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(20.dp), + strokeWidth = 2.dp + ) + } + } + is AsyncData.Failure -> { + ListItemContent.Switch( + checked = false, + enabled = false, + ) + } + is AsyncData.Success -> { + ListItemContent.Switch( + checked = isVisibleInRoomDirectory.data, + ) + } + } + ) + } +} + +@Composable +private fun EncryptionSection( + isRoomEncrypted: Boolean, + canToggleEncryption: Boolean, + showConfirmation: Boolean, + onToggleEncryption: () -> Unit, + onConfirmEncryption: () -> Unit, + onDismissConfirmation: () -> Unit, + modifier: Modifier = Modifier, +) { + SecurityAndPrivacySection( + title = stringResource(R.string.screen_security_and_privacy_encryption_section_header), + modifier = modifier, + ) { + ListItem( + headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_encryption_toggle_title)) }, + supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_encryption_section_footer)) }, + trailingContent = ListItemContent.Switch( + checked = isRoomEncrypted, + enabled = canToggleEncryption, + ), + onClick = if (canToggleEncryption) onToggleEncryption else null + ) + } + if (showConfirmation) { + ConfirmationDialog( + title = stringResource(R.string.screen_security_and_privacy_enable_encryption_alert_title), + content = stringResource(R.string.screen_security_and_privacy_enable_encryption_alert_description), + submitText = stringResource(R.string.screen_security_and_privacy_enable_encryption_alert_confirm_button_title), + onSubmitClick = onConfirmEncryption, + onDismiss = onDismissConfirmation, + ) + } +} + +@Composable +private fun HistoryVisibilitySection( + editedOption: SecurityAndPrivacyHistoryVisibility?, + savedOptions: SecurityAndPrivacyHistoryVisibility?, + availableOptions: ImmutableSet, + onSelectOption: (SecurityAndPrivacyHistoryVisibility) -> Unit, + modifier: Modifier = Modifier, +) { + SecurityAndPrivacySection( + title = stringResource(R.string.screen_security_and_privacy_room_history_section_header), + modifier = modifier, + ) { + for (availableOption in availableOptions) { + val isSelected = availableOption == editedOption + HistoryVisibilityItem( + option = availableOption, + isSelected = isSelected, + onSelectOption = onSelectOption, + ) + } + // Also show the saved option if it's not in the available options, but disabled + if (savedOptions != null && !availableOptions.contains(savedOptions)) { + HistoryVisibilityItem( + option = savedOptions, + isSelected = true, + isEnabled = false, + onSelectOption = {}, + ) + } + } +} + +@Composable +private fun HistoryVisibilityItem( + option: SecurityAndPrivacyHistoryVisibility, + isSelected: Boolean, + onSelectOption: (SecurityAndPrivacyHistoryVisibility) -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, +) { + val headlineText = when (option) { + SecurityAndPrivacyHistoryVisibility.SinceSelection -> stringResource(R.string.screen_security_and_privacy_room_history_since_selecting_option_title) + SecurityAndPrivacyHistoryVisibility.SinceInvite -> stringResource(R.string.screen_security_and_privacy_room_history_since_invite_option_title) + SecurityAndPrivacyHistoryVisibility.Anyone -> stringResource(R.string.screen_security_and_privacy_room_history_anyone_option_title) + } + ListItem( + headlineContent = { Text(text = headlineText) }, + trailingContent = ListItemContent.RadioButton(selected = isSelected, enabled = isEnabled), + onClick = { onSelectOption(option) }, + enabled = isEnabled, + modifier = modifier, + ) +} + +@PreviewWithLargeHeight +@Composable +internal fun SecurityAndPrivacyViewLightPreview(@PreviewParameter(SecurityAndPrivacyStateProvider::class) state: SecurityAndPrivacyState) = + ElementPreviewLight { ContentToPreview(state) } + +@PreviewWithLargeHeight +@Composable +internal fun SecurityAndPrivacyViewDarkPreview(@PreviewParameter(SecurityAndPrivacyStateProvider::class) state: SecurityAndPrivacyState) = + ElementPreviewDark { ContentToPreview(state) } + +@ExcludeFromCoverage +@Composable +private fun ContentToPreview(state: SecurityAndPrivacyState) { + SecurityAndPrivacyView( + state = state, + onBackClick = {}, + ) +} diff --git a/features/securityandprivacy/impl/src/main/res/values-be/translations.xml b/features/securityandprivacy/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..a1e1745 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,6 @@ + + + "Папрасіце далучыцца" + "Хто заўгодна" + "Хто заўгодна" + diff --git a/features/securityandprivacy/impl/src/main/res/values-bg/translations.xml b/features/securityandprivacy/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..9917a71 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,22 @@ + + + "Добавяне на адрес на стаята" + "Да, включване на шифроването" + "Да се включи ли шифроването?" + "Веднъж включено, шифроването не може да бъде изключено." + "Шифроване" + "Включване на шифроване от край до край" + "Всеки може да намери и да се присъедини" + "Всеки" + "Хората могат да се присъединят само ако са поканени" + "Само с покана" + "Достъп до стаята" + "Членове на пространството" + "Пространствата в момента не се поддържат" + "Видима в директорията на обществените стаи" + "Всеки" + "Кой може да чете историята" + "Само за членове откакто са поканени" + "Само за членове от избирането на тази опция" + "Защита и поверителност" + diff --git a/features/securityandprivacy/impl/src/main/res/values-cs/translations.xml b/features/securityandprivacy/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..a70ccdb --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,41 @@ + + + "Budete potřebovat adresu místnosti, aby byla viditelná v adresáři místností." + "Upravit adresu" + "Přidat adresu" + "Všichni musí požádat o přístup." + "Požádat o připojení" + "Ano, povolit šifrování" + "Po aktivaci nelze šifrování místnosti deaktivovat. Historie zpráv bude viditelná pouze pro členy místnosti od doby, kdy byli pozváni nebo od té doby, co do místnosti vstoupili. +Nikdo kromě členů místnosti nebude moci číst zprávy. To může bránit správnému fungování robotů a propojení. +Nedoporučujeme povolovat šifrování pro místnosti, které může kdokoli najít a vstoupit do nich." + "Povolit šifrování?" + "Jakmile je povoleno, šifrování nelze zakázat." + "Šifrování" + "Povolit koncové šifrování" + "Vstoupit může kdokoli." + "Kdokoliv" + "Vyberte, kteří členové prostorů se k této místnosti mohou připojit bez pozvánky. %1$s" + "Vstoupit mohou pouze pozvaní lidé." + "Pouze pro zvané" + "Přístup" + "Vstoupit může kdokoli z autorizovaných prostorů." + "Vstoupit může kdokoli v %1$s." + "Členové prostoru" + "Prostory nejsou aktuálně podporovány" + "Budete potřebovat adresu místnosti, aby byla viditelná v adresáři místností." + "Adresa" + "Umožněte nalezení této místnosti prohledáním adresáře veřejných místností na %1$s" + "Umožnit nalezení vyhledáváním ve veřejném adresáři." + "Viditelné ve veřejném adresáři" + "Kdokoliv" + "Kdo může číst historii" + "Pouze členové od té doby, co byli pozváni" + "Pouze členové od výběru této možnosti" + "Adresy místností představují způsoby, jak najít místnosti a získat k nim přístup. Díky tomu můžete svoji místnost snadno sdílet s ostatními. +Můžete se rozhodnout publikovat svou místnost ve veřejném adresáři místnosti vašeho domovského serveru." + "Publikování místnosti" + "Adresy slouží k vyhledávání a přístupu do místností a prostorů. Díky tomu je také můžete snadno sdílet s ostatními." + "Viditelnost" + "Zabezpečení a soukromí" + diff --git a/features/securityandprivacy/impl/src/main/res/values-cy/translations.xml b/features/securityandprivacy/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..ce812b0 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,36 @@ + + + "Bydd angen cyfeiriad ystafell arnoch i\'w wneud yn weladwy yn y cyfeiriadur." + "Cyfeiriad yr ystafell" + "Ychwanegu cyfeiriad ystafell" + "Gall unrhyw un ofyn am gael ymuno â\'r ystafell ond bydd rhaid i weinyddwr neu gymedrolwr dderbyn y cais." + "Gofyn i gael ymuno" + "Iawn, galluogi amgryptio" + "Unwaith y bydd wedi\'i alluogi, does dim modd analluogi amgryptio ar gyfer ystafell, dim ond ar gyfer aelodau\'r ystafell y bydd hanes neges yn weladwy ers iddyn nhw gael eu gwahodd neu ers iddyn nhw ymuno â\'r ystafell. +Fydd neb ar wahân i aelodau\'r ystafell yn gallu darllen negeseuon. Gall hyn atal botiau a phontydd i weithio\'n gywir. +Nid ydym yn argymell galluogi amgryptio ar gyfer ystafelloedd y gall unrhyw un ddod o hyd iddynt ac ymuno â nhw." + "Galluogi amgryptio?" + "Unwaith y bydd wedi\'i alluogi, does dim modd analluogi amgryptio." + "Amgryptiad" + "Galluogi amgryptio o\'r dechrau i\'r diwedd" + "Gall unrhyw un ddod o hyd iddo ac ymuno" + "Unrhyw un" + "Dim ond os cawn nhw wahoddiad gall pobl ymuno" + "Gwahoddiad yn unig" + "Mynediad ystafell" + "Aelodau gofod" + "Nid yw gofodau\'n cael eu cefnogi ar hyn o bryd" + "Bydd angen cyfeiriad ystafell arnoch i\'w wneud yn weladwy yn y cyfeiriadur." + "Cyfeiriad yr ystafell" + "Caniatáu i\'r ystafell hon gael ei chanfod trwy chwilio cyfeiriadur ystafelloedd cyhoeddus %1$s" + "Gweladwy yn y cyfeiriadur ystafelloedd cyhoeddus" + "Unrhyw un" + "Pwy all ddarllen hanes" + "Yn aelodau ond dim ond ers cael eu gwahodd" + "Yn aelodau dim ond ers dewis y dewis hwn" + "Mae cyfeiriadau ystafelloedd yn ffyrdd o ddod o hyd i ystafelloedd a chael mynediad iddyn nhw. Mae hyn hefyd yn sicrhau y gallwch chi rannu\'ch ystafell yn hawdd ag eraill. +Gallwch ddewis cyhoeddi eich ystafell yng ngweinydd cartref eich cyfeiriadur ystafelloedd cyhoeddus." + "Cyhoeddi ystafell" + "Gwelededd yr ystafell" + "Diogelwch a phreifatrwydd" + diff --git a/features/securityandprivacy/impl/src/main/res/values-da/translations.xml b/features/securityandprivacy/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..46a2098 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,34 @@ + + + "Du skal bruge en rum-adresse for at gøre den synlig i kataloget." + "Rummets adresse" + "Tilføj adresse på rum" + "Alle kan bede om at deltage i lokalet, men en administrator eller moderator skal acceptere anmodningen." + "Spørg om at deltage" + "Ja, aktivér kryptering" + "Når det først er aktiveret, kan kryptering for et rum ikke deaktiveres igen. Beskedhistorik vil kun være synlig for rummedlemmer, siden de blev inviteret, eller siden de blev medlem af rummet. +Ingen udover medlemmer af rummet vil være i stand til at læse beskeder. Dette kan forhindre bots og broer i at fungere korrekt. +Vi anbefaler ikke at aktivere kryptering for rum, som alle kan finde og deltage i." + "Aktivér kryptering?" + "Når kryptering først er aktiveret, kan den ikke deaktiveres igen." + "Kryptering" + "Aktivér end-to-end-kryptering" + "Alle kan finde og deltage" + "Enhver" + "Andre kan kun deltage, hvis de bliver inviteret" + "Kun med invitation" + "Adgang til rummet" + "Medlemmer af gruppen" + "Grupper understøttes ikke i øjeblikket" + "Du skal bruge en rum-adresse for at gøre den synlig i kataloget." + "Tillad, at dette rum kan findes ved at søge i %1$s fortegnelse over offentlige rum" + "Synlig i det offentlige register over rum" + "Enhver" + "Hvem kan læse historikken?" + "Kun medlemmer, efter de blev inviteret" + "Kun medlemmer siden valg af denne mulighed" + "Rum-adresser er en måde at finde og få adgang til værelser på. Dette sikrer også, at du nemt kan dele dit rum med andre. +Du kan vælge at offentliggøre dit rum i din hjemmeservers offentlige katalog over rum." + "Udgivelse af rum" + "Sikkerhed og privatliv" + diff --git a/features/securityandprivacy/impl/src/main/res/values-de/translations.xml b/features/securityandprivacy/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..c374815 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,36 @@ + + + "Du benötigst eine Chat-Adresse, um den Chat im Verzeichnis sichtbar zu machen." + "Chat-Adresse" + "Chat-Adresse hinzufügen" + "Jeder kann den Beitritt zum Chat anfragen, aber ein Admin oder Moderator müssen die Anfrage akzeptieren." + "Beitritt beantragen" + "Ja, Verschlüsselung aktivieren" + "Einmal angeschaltet kann die Verschlüsselung für einen Chat nicht mehr deaktiviert werden. Der Nachrichtenverlauf ist für Mitglieder nur sichtbar, seit sie eingeladen wurden oder dem Chat beigetreten sind. +Niemand außer den Chat Mitgliedern kann Nachrichten lesen. Dies kann verhindern, dass Bots und Bridges richtig funktionieren. +Wir empfehlen keine Verschlüsselung für Chats zu aktivieren, die jeder finden und denen jeder beitreten darf." + "Verschlüsselung aktivieren?" + "Einmal angeschaltet kann die Verschlüsselung nicht mehr deaktiviert werden." + "Verschlüsselung" + "Ende-zu-Ende-Verschlüsselung aktivieren" + "Jeder kann diesen Chat finden und ihm beitreten" + "Jeder" + "Personen können nur beitreten, wenn sie eingeladen werden." + "Nur auf Einladung" + "Chat Zugang" + "Spacemitglieder" + "Spaces werden zur Zeit nicht unterstützt." + "Du benötigst eine Chat-Adresse, um den Chat im Verzeichnis sichtbar zu machen." + "Chatroomadresse" + "Erlaube das Auffinden dieses Chats durch Suche im öffentlichen Verzeichnis von %1$s" + "Sichtbar im öffentlichen Verzeichnis" + "Jeder" + "Wer hat Zugriff auf den Nachrichtenverlauf" + "Nur Mitglieder, aber erst seit deren Einladung" + "Nur Mitglieder seit Auswahl dieser Option" + "Chat-Adressen machen es möglich, Chats zu finden und ihnen beizutreten. Dies erleichtert es, Chats mit anderen zu teilen. +Auf Wunsch kannst du deinen Chat im öffentlichen Verzeichnis deines Homeservers veröffentlichen." + "Veröffentlichung von Chats" + "Chatroomsichtbarkeit." + "Sicherheit & Datenschutz" + diff --git a/features/securityandprivacy/impl/src/main/res/values-el/translations.xml b/features/securityandprivacy/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..1946bae --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,34 @@ + + + "Θα χρειαστείτε μια διεύθυνση αίθουσας για να την κάνετε ορατή στον κατάλογο." + "Διεύθυνση αίθουσας" + "Προσθήκη διεύθυνσης αίθουσας" + "Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στην αίθουσα, αλλά ένας διαχειριστής ή συντονιστής θα πρέπει να αποδεχτεί το αίτημα." + "Αίτημα συμμετοχής" + "Ναι, ενεργοποιήστε την κρυπτογράφηση" + "Μόλις ενεργοποιηθεί, η κρυπτογράφηση για μια αίθουσα δεν μπορεί να απενεργοποιηθεί, το ιστορικό μηνυμάτων θα είναι ορατό μόνο για τα μέλη της αίθουσας από τότε που προσκλήθηκαν ή από τότε που συμμετείχαν στην αίθουσα. +Κανείς άλλος εκτός από τα μέλη της αίθουσας δεν θα μπορεί να διαβάσει τα μηνύματα. Αυτό μπορεί να εμποδίσει τη σωστή λειτουργία των bots και των γεφυρών. +Δεν συνιστούμε την ενεργοποίηση της κρυπτογράφησης για αίθουσες που μπορεί να βρει και να συμμετάσχει ο καθένας." + "Ενεργοποίηση κρυπτογράφησης;" + "Μόλις ενεργοποιηθεί, η κρυπτογράφηση δεν μπορεί να απενεργοποιηθεί." + "Κρυπτογράφηση" + "Ενεργοποίηση κρυπτογράφησης από άκρο σε άκρο" + "Οποιοσδήποτε μπορεί να βρει και να συμμετάσχει" + "Οποιοσδήποτε" + "Τα άτομα μπορούν να συμμετάσχουν μόνο εάν έχουν προσκληθεί" + "Μόνο πρόσκληση" + "Πρόσβαση στην αίθουσα" + "Μέλη χώρου" + "Οι χώροι δεν υποστηρίζονται προς το παρόν" + "Θα χρειαστείτε μια διεύθυνση αίθουσας για να την κάνετε ορατή στον κατάλογο." + "Επιστρέψτε την εύρεση αυτής της αίθουσας με αναζήτηση στον κατάλογο %1$s δημοσίων αιθουσών" + "Ορατή στον κατάλογο δημόσιων αιθουσών" + "Οποιοσδήποτε" + "Ποιος μπορεί να διαβάσει το ιστορικό" + "Μόνο μέλη από τη στιγμή που προσκλήθηκαν" + "Μόνο για μέλη μετά από αυτήν την επιλογή" + "Οι διευθύνσεις αιθουσών είναι τρόποι εύρεσης και πρόσβασης σε αίθουσες. Αυτό διασφαλίζει επίσης ότι μπορείτε εύκολα να μοιραστείτε την αίθουσα με άλλους. +Μπορείτε να επιλέξετε να δημοσιεύσετε την αίθουσά σας στον δημόσιο κατάλογο αιθουσών του αρχικού διακομιστή σας." + "Δημοσίευση αίθουσας" + "Ασφάλεια & απόρρητο" + diff --git a/features/securityandprivacy/impl/src/main/res/values-es/translations.xml b/features/securityandprivacy/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..2799d9d --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,34 @@ + + + "Necesitarás una dirección de sala para que sea visible en el directorio." + "Dirección de la sala" + "Agregar dirección de sala" + "Cualquiera puede solicitar unirse a la sala, pero un administrador o moderador tendrá que aceptar la solicitud." + "Solicitud para unirse" + "Sí, activar cifrado" + "Una vez activado, el cifrado de una sala no se puede desactivar. El historial de mensajes solo será visible para los miembros de la sala desde que fueron invitados o desde que se unieron a la sala. +Nadie más que los miembros de la sala podrán leer los mensajes. Esto puede impedir que los bots y los puentes funcionen correctamente. +No recomendamos habilitar el cifrado para las salas que cualquiera pueda encontrar y unirse." + "¿Activar cifrado?" + "Una vez activado, el cifrado no se puede desactivar." + "Cifrado" + "Activar el cifrado de extremo a extremo" + "Cualquiera puede encontrarla y unirse" + "Cualquiera" + "Las personas solo pueden unirse si están invitadas" + "Solo por invitación" + "Acceso a la sala" + "Miembros del espacio" + "No se admiten los espacios por el momento." + "Necesitarás una dirección de sala para que sea visible en el directorio." + "Permite encontrar esta sala buscando en el directorio de salas públicas de %1$s" + "Visible en el directorio de salas públicas" + "Cualquiera" + "Quién puede leer el historial" + "Solo participantes desde que fueron invitados" + "Solo participantes desde que se selecciona esta opción" + "Las direcciones de sala son formas de buscar salas y acceder a ellas. Esto también garantiza que puedas compartir fácilmente tu sala con otras personas. +Puedes optar por publicar tu sala en el directorio de salas públicas de tu servidor base." + "Publicación de la sala" + "Seguridad y privacidad" + diff --git a/features/securityandprivacy/impl/src/main/res/values-et/translations.xml b/features/securityandprivacy/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..7e6cdec --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,37 @@ + + + "Selleks, et jututuba oleks nähtav jututubade avalikus kataloogis, vajab ta aadressi." + "Muuda aadressi" + "Lisa aadress" + "Kõik võivad paluda jututoaga liitumist." + "Küsi võimalust liitumiseks" + "Jah, lülita krüptimine sisse" + "Kui jututoa krüptimine on kord sisse lülitatud, siis seda välja lülitada ei saa. Sõnumite ajalugu on nähtav vaid jututoa liikmetele alates kutse saamise või liitumise hetkest. +Keegi teine peale jututoa liikmete ei saa sõnumeid lugeda. See võib takistada suhtlusrobotite ja/või võrgusildade toimimist. +Me ei soovita krüptimise kasutamist selliste avalike jututubade puhul, millega kõik võivad liituda." + "Kas võtame krüptimise kasutusele?" + "Kui krüptimine on kasutusel, siis seda enam väljalülitada ei saa." + "Krüptimine" + "Võta läbiv krüptimine kasutusele" + "Kõik võivad jututoaga liituda" + "Kõik" + "Liituda saab vaid kutse olemasolul" + "Vaid kutsega" + "Ligipääs" + "Kogukonna liikmed" + "Kogukondade tugi veel puudub" + "Selleks, et jututuba oleks nähtav jututubade avalikus kataloogis, vajab ta aadressi." + "Aadress" + "Võimalda leida seda jututuba avalikust kataloogist otsides „%1$s“" + "Luba leitavus avaliku kataloogi otsingust." + "Nähtav avalikus kataloogis" + "Kõik" + "Kes võivad lugeda jututoa ajalugu" + "Liikmed peale kutse saamist" + "Liikmed peale selle valiku sisselülitamist" + "Jututoa aadressid annavad võimaluse neid leida ning saada neile ligi. Samuti võimaldab see jututuba teistele huvilistele jagada. +Lisaks võid sa jututoa avaldada oma koduserveri avalikus jututubade kataloogis." + "Jututoa avaldamine" + "Nähtavus" + "Turvalisus ja privaatsus" + diff --git a/features/securityandprivacy/impl/src/main/res/values-eu/translations.xml b/features/securityandprivacy/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..c66b676 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,22 @@ + + + "Gelaren helbidea" + "Gehitu gelaren helbidea" + "Bai, gaitu zifratzea" + "Zifratzea" + "Edonork aurkitu eta bat egin dezake" + "Edonork" + "Gonbidatutako pertsonak bakarrik sartu ahal izango dira" + "Gonbidapen bidez" + "Gelarako sarbidea" + "Guneko kideak" + "Gaur-gaurkoz ez da guneekin bateragarria" + "Gelaren helbidea" + "Gela publikoen direktorioan ikusgai" + "Edonork" + "Nork irakur dezake historia" + "Kideek bakarrik, gonbidatu zituztenetik" + "Kideek bakarrik, aukera hau hautatu zenetik" + "Gelaren ikusgarritasuna" + "Segurtasuna eta pribatutasuna" + diff --git a/features/securityandprivacy/impl/src/main/res/values-fa/translations.xml b/features/securityandprivacy/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..8cfa414 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,22 @@ + + + "نشانی اتاق" + "افزودن نشانی اتاق" + "درخواست دعوت" + "بله. به کار انداختن رمزنگاری" + "رمزگذاری فعال شود؟" + "پس از به کار افتادن، رمزنگاری قابل از کار انداختن نیست." + "رمزنگاری" + "هرکسی می‌تواند یافته و بپیوندد" + "هرکسی" + "افراد فقط در صورت دعوت می‌توانند بپیوندند" + "فقط دعوتی" + "دسترسی اتاق" + "اعضای فضا" + "در حال حاضر فضاها پشتیبانی نمی‌شوند" + "نشانی اتاق" + "هرکسی" + "انتشار اتاق" + "نمایانی اتاق" + "امنیت و محرمانگی" + diff --git a/features/securityandprivacy/impl/src/main/res/values-fi/translations.xml b/features/securityandprivacy/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..0f42d87 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,37 @@ + + + "Tarvitset osoitteen, jotta se näkyy julkisessa hakemistossa." + "Muokkaa osoitetta" + "Lisää osoite" + "Kaikkien on pyydettävä pääsyä." + "Pyydä liittymistä" + "Kyllä, ota salaus käyttöön" + "Kun salaus on kerran otettu käyttöön, sitä ei voi poistaa käytöstä. Viestihistoria näkyy vain huoneen jäsenille kutsusta tai liittymisestä lähtien. +Kukaan muu kuin huoneen jäsenet eivät pysty lukemaan viestejä. Tämä voi estää botteja tai siltoja toimimasta oikein. +Emme suosittele salauksen ottamista käyttöön huoneissa, jotka kuka tahansa voi löytää ja joihin kuka tahansa voi liittyä." + "Otetaanko salaus käyttöön?" + "Kun salaus on kerran otettu käyttöön, sitä ei voi poistaa käytöstä." + "Salaus" + "Ota päästä päähän -salaus käyttöön" + "Kuka tahansa voi liittyä." + "Kuka tahansa" + "Vain kutsutut henkilöt voivat liittyä." + "Vain kutsutut" + "Pääsy" + "Tilan jäsenet" + "Tiloja ei tällä hetkellä tueta" + "Tarvitset osoitteen, jotta se näkyy julkisessa hakemistossa." + "Osoite" + "Salli tämän huoneen löytäminen hakemalla %1$s -palvelimen julkisesta huonehakemistosta." + "Anna muiden löytää tämä julkisen hakemiston kautta." + "Näkyy julkisessa hakemistossa" + "Kuka tahansa" + "Kuka voi lukea viestihistoriaa" + "Jäsenet vasta kutsusta lähtien" + "Jäsenet tämän vaihtoehdon valinnan jälkeen" + "Huoneosoitteet ovat tapoja löytää ja käyttää huoneita. Näin voit myös helposti jakaa huoneesi muiden kanssa. +Voit halutessasi julkaista huoneesi kotipalvelimesi julkisessa huonehakemistossa." + "Huoneen julkaiseminen" + "Näkyvyys" + "Turvallisuus ja yksityisyys" + diff --git a/features/securityandprivacy/impl/src/main/res/values-fr/translations.xml b/features/securityandprivacy/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..754ef4c --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,38 @@ + + + "Vous aurez besoin d’une adresse pour le rendre visible dans l’annuaire public." + "Modifier l’adresse" + "Ajouter une adresse" + "Tout le monde doit demander un accès." + "Demander à rejoindre" + "Oui, activer le chiffrement" + "Une fois activé, le chiffrement d’un salon ne peut pas être désactivé. L’historique des messages ne sera visible que pour les membres depuis qu’ils ont été invités ou depuis qu’ils ont rejoint le salon. +Personne d’autre que les membres du salon ne pourra lire les messages. Cela peut empêcher les bots et les bridges de fonctionner correctement. +Nous ne recommandons pas d’activer le chiffrement pour les salons que tout le monde peut trouver et rejoindre." + "Activer le chiffrement ?" + "Une fois activé, le chiffrement ne peut pas être désactivé." + "Chiffrement" + "Activer le chiffrement de bout en bout" + "Tout le monde peut rejoindre." + "Tout le monde" + "Seules les personnes invitées peuvent rejoindre." + "Sur invitation uniquement" + "Accès" + "Membres de l’espace" + "Les Espaces ne sont pas encore supportés" + "Vous aurez besoin d’une adresse pour le rendre visible dans l’annuaire public." + "Adresse" + "Autoriser le salon à apparaître dans les résultats de recherche dans le répertoire %1$s des salons publics" + "Permet d’être trouvé en recherchant dans l’annuaire public." + "Visible dans l’annuaire public" + "Tout le monde" + "Qui peux lire l’historique" + "Les membres uniquement depuis qu’ils ont été invités" + "Les membres uniquement depuis la sélection de cette option" + "Les adresses de salon sont un moyen de trouver et d’accéder aux salons. Cela vous permet également de partager facilement votre salon avec d’autres personnes. +Vous pouvez choisir de publier votre salon dans l’annuaire des salons publics de votre serveur d’accueil." + "Publication du salon" + "Les adresses permettent de trouver et d’accéder aux salons et aux espaces. Elles facilitent également leur partage avec d’autres personnes." + "Visibilité" + "Sécurité & confidentialité" + diff --git a/features/securityandprivacy/impl/src/main/res/values-hu/translations.xml b/features/securityandprivacy/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..503fdc8 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,37 @@ + + + "Szüksége lesz egy szobacímre, hogy láthatóvá tegye a szobakatalógusban." + "Cím szerkesztése" + "Cím hozzáadása" + "Mindenkinek hozzáférést kell kérnie." + "Csatlakozás kérése" + "Igen, engedélyezze a titkosítást" + "Az engedélyezés után a szoba titkosítása nem tiltható le. Az üzenetek előzményei csak a szobatagok számára láthatók, amikor meghívást kaptak, vagy mióta csatlakoztak a szobához. +A szobatagokon kívül senki sem tudja olvasni az üzeneteket. Ez megakadályozhatja a botok és a hidak megfelelő működését. +Nem javasoljuk a titkosítás engedélyezését az olyan szobákban, amelyeket bárki megtalálhat és csatlakozhat." + "Engedélyezi a titkosítást?" + "Engedélyezés után a titkosítás nem tiltható le." + "Titkosítás" + "Végpontok közötti titkosítás engedélyezése" + "Bárki csatlakozhat." + "Bárki" + "Csak a meghívott emberek léphetnek be." + "Csak meghívásos" + "Hozzáférés" + "A tér tagjai" + "A terek jelenleg nem támogatottak" + "Szüksége lesz egy szobacímre, hogy láthatóvá tegye a szobakatalógusban." + "Cím" + "A szoba megtalálhatóvá tétele a(z) %1$s nyilvános szobakatalógusában való kereséssel." + "Lehetővé teszi, hogy a nyilvános szobakatalógusban megtalálható legyen." + "Látható a nyilvános szobakatalógusban" + "Bárki" + "Ki olvashatja az előzményeket" + "Csak a tagok, a meghívásuktól kezdődően" + "Csak a tagok, a beállítás választásától kezdődően" + "A szobacímek a szobák megtalálásának és elérésnek módjai. Ez azt is biztosítja, hogy könnyen megoszthatja a szobáját másokkal. +Kiválaszthatja, hogy szobáját közzéteszi-e a Matrix-kiszolgáló nyilvános szobakatalógusában." + "Szoba közzététele" + "Láthatóság" + "Biztonság és adatvédelem" + diff --git a/features/securityandprivacy/impl/src/main/res/values-in/translations.xml b/features/securityandprivacy/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..17f1405 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,33 @@ + + + "Anda akan memerlukan alamat ruangan untuk membuatnya terlihat dalam direktori." + "Alamat ruangan" + "Tambahkan alamat ruangan" + "Siapa pun dapat meminta untuk bergabung dengan ruangan tetapi administrator atau moderator harus menerima permintaan tersebut." + "Minta untuk bergabung" + "Ya, aktifkan enkripsi" + "Setelah diaktifkan, encryption untuk sebuah ruangan tidak dapat dinonaktifkan, Riwayat pesan hanya akan terlihat oleh anggota ruangan sejak mereka diundang atau sejak mereka bergabung dengan ruangan tersebut. +Tidak ada orang lain selain anggota ruangan yang dapat membaca pesan. Hal ini dapat mencegah bot dan jembatan bekerja dengan benar. +Kami tidak menyarankan untuk mengaktifkan enkripsi untuk ruangan yang dapat ditemukan dan diikuti oleh siapa pun." + "Aktifkan enkripsi?" + "Setelah diaktifkan, enkripsi tidak dapat dinonaktifkan." + "Enkripsi" + "Aktifkan enkripsi ujung ke ujung" + "Siapa pun dapat menemukan dan bergabung" + "Siapa pun" + "Orang hanya dapat bergabung jika mereka diundang" + "Hanya undangan" + "Akses ruangan" + "Anggota space" + "Space saat ini tidak didukung" + "Anda akan memerlukan alamat ruangan untuk membuatnya terlihat dalam direktori." + "Izinkan ruangan ini ditemukan dengan mencari direktori ruangan %1$s publik" + "Terlihat di direktori ruangan publik" + "Siapa pun" + "Siapa yang bisa membaca riwayat" + "Hanya anggota sejak mereka diundang" + "Hanya anggota sejak memilih opsi ini" + "Alamat ruangan adalah cara untuk menemukan dan mengakses ruangan. Ini juga memastikan Anda dapat dengan mudah berbagi ruangan dengan orang lain. Anda dapat memilih untuk menerbitkan ruangan Anda di direktori ruangan publik homeserver Anda." + "Penerbitan ruangan" + "Keamanan & privasi" + diff --git a/features/securityandprivacy/impl/src/main/res/values-it/translations.xml b/features/securityandprivacy/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..2854a69 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,38 @@ + + + "Per renderlo visibile nell\'elenco pubblico, avrai bisogno di un indirizzo." + "Modifica indirizzo" + "Aggiungi indirizzo" + "Chiunque deve richiedere l\'accesso." + "Chiedi di entrare" + "Sì, attiva la crittografia" + "Una volta attivata, la crittografia di una stanza non può essere disattivata, la cronologia dei messaggi sarà visibile solo ai membri della stanza da quando sono stati invitati o da quando sono entrati nella stanza. +Nessuno, oltre ai membri della stanza, sarà in grado di leggere i messaggi. Ciò potrebbe impedire ai bot e ai bridge di funzionare correttamente. +Non consigliamo di attivare la crittografia per le stanze che chiunque può trovare e in cui può entrare." + "Attivare la crittografia?" + "Una volta attivata, la crittografia non può essere disattivata." + "Crittografia" + "Attiva la crittografia end-to-end" + "Chiunque può partecipare." + "Chiunque" + "Solo le persone invitate possono entrare." + "Solo su invito" + "Accesso" + "Membri dello spazio" + "Gli spazi non sono attualmente supportati" + "Per renderlo visibile nell\'elenco pubblico, avrai bisogno di un indirizzo." + "Indirizzo" + "Consenti la ricerca di questa stanza effettuando una ricerca nell\'elenco delle stanze pubbliche di %1$s" + "Consenti di essere trovato effettuando una ricerca nell\'elenco pubblico." + "Visibile nell\'elenco pubblico" + "Chiunque" + "Chi può leggere la cronologia messaggi" + "Solo membri da quando sono stati invitati" + "Solo membri da dopo aver selezionato questa opzione" + "Gli indirizzi delle stanze sono modi per trovare e accedervi. In questo modo puoi anche condividere facilmente la tua stanze con altri. +Puoi scegliere di pubblicare la tua stanza nell\'elenco delle stanza pubbliche dell\'homeserver." + "Pubblicazione della stanza" + "Gli indirizzi sono un modo per trovare e accedere a stanze e spazi. Questo ti consente anche di condividerli facilmente con altri." + "Visibilità" + "Sicurezza e privacy" + diff --git a/features/securityandprivacy/impl/src/main/res/values-ko/translations.xml b/features/securityandprivacy/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..8afa198 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,34 @@ + + + "디렉토리에 표시하려면 방 주소가 필요합니다." + "방 주소" + "방 주소 추가" + "누구나 방에 참여 요청을 할 수 있지만, 관리자나 운영자가 요청을 수락해야 합니다." + "참가 요청" + "예, 암호화 활성화" + "일단 활성화되면, 방의 암호화는 비활성화할 수 없습니다. 메시지 기록은 방에 초대된 후 또는 방에 참여한 이후부터 방 구성원만 볼 수 있습니다. +방 구성원 외에는 아무도 메시지를 읽을 수 없습니다. 이로 인해 봇과 브리지가 제대로 작동하지 않을 수 있습니다. +누구나 찾고 참여할 수 있는 방에는 암호화를 활성화하지 않는 것이 좋습니다." + "암호화 활성화?" + "일단 활성화되면, 암호화는 비활성화할 수 없습니다." + "암호화" + "종단간 암호화 활성화" + "누구나 찾을 수 있고 참여할 수 있습니다." + "누구나" + "초대받은 사용자만 가입할 수 있습니다." + "초대 전용" + "방 액세스" + "스페이스 멤버들" + "스페이스는 현재 지원되지 않습니다" + "디렉토리에 표시하려면 방 주소가 필요합니다." + "%1$s 공개 방 디렉토리에서 이 방을 검색할 수 있도록 허용합니다" + "공개 룸 디렉토리에 표시됨" + "누구나" + "누가 기록을 읽을 수 있는가" + "초대받은 회원만 이용 가능합니다" + "이 옵션을 선택한 회원만 이용 가능합니다." + "방 주소는 방을 찾고 액세스하는 방법입니다. 이를 통해 다른 사람들과 방을 쉽게 공유할 수 있습니다. +홈서버의 공개 방 디렉토리에 방을 공개할지 여부를 선택할 수 있습니다." + "방 게시" + "보안 및 개인정보 보호" + diff --git a/features/securityandprivacy/impl/src/main/res/values-nb/translations.xml b/features/securityandprivacy/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..2d0f8db --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,34 @@ + + + "Du trenger en adresse til rommet for å gjøre det synlig i katalogen." + "Romadresse" + "Legg til romadresse" + "Alle kan be om å bli med i rommet, men en administrator eller moderator må godta forespørselen." + "Be om å bli med" + "Ja, aktiver kryptering" + "Når kryptering for et rom er aktivert, kan den ikke deaktiveres. Meldingshistorikken vil bare være synlig for rommedlemmer siden de ble invitert eller siden de ble med i rommet. +Ingen andre enn rommedlemmene vil kunne lese meldingene. Dette kan føre til at bots og broer ikke fungerer som de skal. +Vi anbefaler ikke å aktivere kryptering for rom som hvem som helst kan finne og bli med i." + "Vil du aktivere kryptering?" + "Når kryptering er aktivert, kan det ikke deaktiveres." + "Kryptering" + "Aktiver ende-til-ende-kryptering" + "Alle kan finne og bli med" + "Alle" + "Folk kan bare bli med hvis de er invitert" + "Kun for inviterte" + "Tilgang til rom" + "Medlemmer av område" + "Områder støttes ikke for øyeblikket" + "Du trenger en adresse til rommet for å gjøre det synlig i katalogen." + "Tillat at dette rommet blir funnet ved å søke %1$s offentlig romkatalog" + "Synlig i offentlig romkatalog" + "Alle" + "Hvem kan lese historikk" + "Medlemmer bare siden de ble invitert" + "Kun medlemmer siden du valgte dette alternativet" + "Romadresser er måter å finne og få tilgang til rom på. Dette sikrer også at du enkelt kan dele rommet ditt med andre. +Du kan velge å publisere rommet ditt i hjemeserverens offentlige romkatalog." + "Publisering av rom" + "Sikkerhet og personvern" + diff --git a/features/securityandprivacy/impl/src/main/res/values-nl/translations.xml b/features/securityandprivacy/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..5fdc946 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,6 @@ + + + "Vraag om toe te treden" + "Iedereen" + "Iedereen" + diff --git a/features/securityandprivacy/impl/src/main/res/values-pl/translations.xml b/features/securityandprivacy/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..11b8189 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,36 @@ + + + "Aby pokój był widoczny w katalogu, potrzebny jest adres pokoju." + "Adres pokoju" + "Dodaj adres pokoju" + "Każdy może poprosić o dołączenie do pokoju, ale administrator lub moderator będzie musiał zatwierdzić żądanie." + "Poproś o dołączenie" + "Tak, włącz szyfrowanie" + "Po włączeniu szyfrowanie pokoju nie może zostać wyłączone, a historia wiadomości będzie widoczna tylko dla członków od momentu, w którym dołączyli lub zostali zaproszeni. +Nikt poza członkami pokoju nie będzie mógł czytać wiadomości. Może to wpłynąć na prawidłowe działanie botów lub mostków. +Odradzamy włączanie szyfrowania dla pokoi, które każdy może znaleźć i do których każdy może dołączyć." + "Włączyć szyfrowanie?" + "Po włączeniu szyfrowania nie można wyłączyć." + "Szyfrowanie" + "Włącz szyfrowanie end-to-end" + "Każdy może znaleźć i dołączyć" + "Wszyscy" + "Tylko osoby z zaproszeniem mogą dołączyć" + "Tylko zaproszenie" + "Dostęp do pokoju" + "Członkowie przestrzeni" + "Przestrzenie nie są obecnie wspierane" + "Aby pokój był widoczny w katalogu, potrzebny jest adres pokoju." + "Adres pokoju" + "Zezwól na znalezienie tego pokoju wyszukując %1$s w katalogu pokoi publicznych" + "Widoczny w katalogu pokoi publicznych" + "Wszyscy" + "Kto może czytać historię" + "Od momentu kiedy członkowie zostali zaproszeni" + "Członkowie od momentu włączenia tej opcji" + "Adresy pokoju umożliwiają łatwe znalezienie i dołączenie do pokojów. +Również możesz się zdecydować na upublicznienie Twojego serwera w katalogu pokoi publicznych." + "Publikowanie pokoju" + "Widoczność pokoju" + "Bezpieczeństwo i prywatność" + diff --git a/features/securityandprivacy/impl/src/main/res/values-pt-rBR/translations.xml b/features/securityandprivacy/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..584e94d --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,38 @@ + + + "Você precisará de um endereço para torná-la visível no diretório." + "Editar endereço" + "Adicionar endereço" + "Qualquer um pode pedir acesso, mas um administrador terá que aceitar o pedido." + "Pedir para entrar" + "Sim, ativar a criptografia" + "Uma vez ativada, a criptografia de uma sala não pode ser desativada. O histórico de mensagens só será visível para os membros da sala desde que foram convidados ou desde que entraram na sala. +Ninguém além dos membros da sala poderá ler as mensagens. Isso pode impedir que os bots e as pontes funcionem corretamente. +Não recomendamos que você ative a criptografia para salas que qualquer pessoa possa encontrar e participar." + "Ativar a criptografia?" + "Uma vez ativada, a criptografia não poderá ser desativada." + "Criptografia" + "Ativar a criptografia de ponta a ponta" + "Qualquer um pode entrar" + "Qualquer pessoa" + "Apenas pessoas convidadas podem entrar." + "Privado" + "Acesso" + "Membros do espaço" + "No momento, não há suporte aos espaços" + "Você precisará de um endereço para torná-la visível no diretório." + "Endereço publicado" + "Permitir que esta sala seja encontrada pesquisando diretório de salas públicas de %1$s" + "Permite que seja encontrada ao buscar no diretório público." + "Visível no diretório público" + "Qualquer pessoa" + "Quem pode ler o histórico" + "Somente membros, desde que foram convidados" + "Somente para membros após selecionar esta opção" + "Os endereços das salas são formas de encontrar e acessar as salas. Isso também garante que você possa compartilhar facilmente sua sala com outras pessoas. +Você pode optar por publicar sua sala no diretório público de salas do seu servidor-casa." + "Publicação da sala" + "Os endereços das salas são formas de encontrar e acessar as salas. Isso também garante que você possa compartilhá-las facilmente com outras pessoas." + "Visibilidade" + "Segurança e privacidade" + diff --git a/features/securityandprivacy/impl/src/main/res/values-pt/translations.xml b/features/securityandprivacy/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..26585dc --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,36 @@ + + + "É necessário um endereço para tornar a sala visível no diretório." + "Endereço da sala" + "Adicionar endereço de sala" + "Qualquer pessoa pode pedir para entrar na sala, mas um administrador ou moderador tem que aceitar o pedido." + "Pedir para participar" + "Sim, ativar cifragem" + "Uma vez ativada, a cifragem não pode ser desativada. O histórico de mensagens só será visível a membros a partir do momento em que foram convidados ou que entraram na sala. +Ninguém além dos membros poderão ler quaisquer mensagens. Isto pode impedir que robôs (\"bots\") e pontes (\"bridges\") funcionem devidamente. +Não recomendamos ativar a cifragem em salas que qualquer pessoa possa encontrar e entrar." + "Ativar cifragem?" + "Uma vez ativada, a cifragem não pode ser desativada." + "Cifragem" + "Ativar cifragem ponta-a-ponta" + "Qualquer pessoa pode encontrar a sala e entrar" + "Qualquer pessoa" + "Só é possível entrar tendo um convite" + "Apenas por convite" + "Acesso à sala" + "Membros do espaço" + "Os espaços ainda não estão implementados" + "É necessário um endereço para tornar a sala visível no diretório." + "Endereço da sala" + "Permite que esta sala seja encontrada através do diretório público do %1$s." + "Visível no diretório público de salas" + "Qualquer pessoa" + "Quem pode ler o histórico de mensagens" + "Apenas membros, desde o momento em que forem convidados" + "Apenas membros, desde o memento em que esta opção for selecionada" + "Estes endereços permitem encontrar e aceder a sala, bem como a sua fácil partilha com outros. +Podes escolher publicar a sala no diretório público do teu servidor." + "Publicar sala" + "Visibilidade da sala" + "Segurança e privacidade" + diff --git a/features/securityandprivacy/impl/src/main/res/values-ro/translations.xml b/features/securityandprivacy/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..213e355 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,34 @@ + + + "Veți avea nevoie de o adresă de cameră pentru a o face vizibilă în director." + "Adresa camerei" + "Adăugați adresa camerei" + "Oricine poate cere să se alăture camerei, dar un administrator sau moderator va trebui să accepte cererea." + "Cereți să vă alăturați" + "Da, activați criptarea" + "Odată activată, criptarea pentru o cameră nu poate fi dezactivată. Mesajele anterioare vor fi vizibile numai pentru membrii camerei de la momentul la care au fost invitați sau de la momentul la care s-au alăturat camerei. +Nimeni în afară de membrii camerei nu va putea citi messaje. Acest lucru poate împiedica funcționarea corectă a boților și a punților. +Nu recomandăm activarea criptării pentru camerele pe care oricine le poate găsi și la care se poate alătura." + "Activați criptarea?" + "Odată activată, criptarea nu poate fi dezactivată." + "Criptare" + "Activați criptarea end-to-end" + "Oricine poate găsi și alătura camerei" + "Oricine" + "Persoanele se pot alătura numai dacă invitate" + "Doar pe bază de invitație" + "Acces la cameră" + "Membrii spațiului" + "Spațiile nu sunt momentan suportate." + "Veți avea nevoie de o adresă de cameră pentru a o face vizibilă în director." + "Permiteți găsirea acestei camere prin căutarea în directorul de camere publice al %1$s" + "Vizibilă în directorul de camere publice" + "Oricine" + "Cine poate citi mesajele anterioare" + "Doar pentru membri, de la momentul în care au fost invitați" + "Doar pentru membri, după selectarea acestei opțiuni" + "Adresele camerelor sunt modalități de a găsi și accesa camere. Acest lucru vă asigură, de asemenea, că puteți partaja cu ușurință camera dumneavoastră cu alte persoane. +Puteți alege să publicați camera în directorul public al camerelor serverului dumneavoastră." + "Publicare cameră" + "Securitate & confidențialitate" + diff --git a/features/securityandprivacy/impl/src/main/res/values-ru/translations.xml b/features/securityandprivacy/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..1a138fb --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,37 @@ + + + "Вам понадобится адрес комнаты, чтобы сделать ее видимой в каталоге." + "Редактировать адрес комнаты" + "Добавить адрес" + "Каждый должен запросить доступ." + "Попросить присоединиться" + "Да, включить шифрование" + "Шифрование комнаты нельзя будет отключить, история сообщений будет видна только участникам комнаты с момента их приглашения или с момента присоединения к комнате. +Никто, кроме членов комнаты, не сможет читать сообщения. Это может помешать ботам и мостам работать корректно. +Мы не рекомендуем включать шифрование для комнат, в которые может найти и присоединиться любой желающий." + "Включить шифрование?" + "После включения, шифрование не может быть отключено." + "Шифрование" + "Включить сквозное шифрование" + "Любой желающий может найти и присоединиться" + "Любой" + "Присоединиться могут только приглашенные люди." + "Только по приглашению" + "Доступ" + "Участники пространства" + "Пространства в настоящее время не поддерживаются." + "Вам понадобится адрес комнаты, чтобы сделать ее видимой в каталоге." + "Адрес" + "Опубликовать %1$s в каталоге публичных комнат" + "Разрешить поиск в публичном каталоге." + "Доступна в списке публичных комнат" + "Любой" + "Кто может читать историю" + "Участники только с тех пор, как они были приглашены" + "Только для участников с момента выбора этой опции" + "Адреса комнат — это способ найти комнату и получить к ней доступ. Это также гарантирует, что вы сможете легко поделиться своей комнатой с другими. +Вы можете опубликовать свою комнату в каталоге общедоступных комнат на домашнем сервере." + "Публикация комнат" + "Видимость" + "Безопасность и конфиденциальность" + diff --git a/features/securityandprivacy/impl/src/main/res/values-sk/translations.xml b/features/securityandprivacy/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..a9e6b1d --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,36 @@ + + + "Budete potrebovať adresu miestnosti, aby bola viditeľná v adresári." + "Adresa miestnosti" + "Pridať adresu miestnosti" + "Ktokoľvek môže požiadať o pripojenie do miestnosti, ale správca alebo moderátor bude musieť žiadosť prijať." + "Požiadať o pripojenie" + "Áno, povoliť šifrovanie" + "Po aktivácii nie je možné zakázať šifrovanie pre miestnosť. História správ bude viditeľná len pre členov miestnosti, odkedy boli pozvaní alebo keď vstúpili do miestnosti. +Nikto okrem členov miestnosti nebude môcť čítať správy. +To môže brániť správnemu fungovaniu robotov a premostení. Neodporúčame povoliť šifrovanie pre miestnosti, ktoré môže ktokoľvek nájsť a pripojiť sa k nim." + "Povoliť šifrovanie?" + "Po zapnutí už šifrovanie nie je možné vypnúť." + "Šifrovanie" + "Povoliť end-to-end šifrovanie" + "Ktokoľvek môže nájsť a pripojiť sa" + "Ktokoľvek" + "Ľudia sa môžu pripojiť len vtedy, ak sú pozvaní" + "Iba na pozvánku" + "Prístup do miestnosti" + "Členovia priestoru" + "Priestory momentálne nie sú podporované" + "Budete potrebovať adresu miestnosti, aby bola viditeľná v adresári." + "Adresa miestnosti" + "Umožniť vyhľadanie tejto miestnosti v adresári verejných miestností %1$s" + "Viditeľné v adresári verejných miestností" + "Ktokoľvek" + "Kto môže čítať históriu" + "Len pre členov, odkedy boli pozvaní" + "Len členovia od zvolenia tejto možnosti" + "Adresy miestností predstavujú spôsoby, ako nájsť a získať prístup k miestnostiam. To tiež zaisťuje, že môžete jednoducho zdieľať svoju miestnosť s ostatnými. +Môžete sa rozhodnúť zverejniť svoju miestnosť v adresári verejných miestností vášho domovského servera." + "Zverejnenie miestnosti" + "Viditeľnosť miestnosti" + "Bezpečnosť a súkromie" + diff --git a/features/securityandprivacy/impl/src/main/res/values-sv/translations.xml b/features/securityandprivacy/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..4fbfe47 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,34 @@ + + + "Du behöver en rumsadress för att göra den synlig i katalogen." + "Rumsadress" + "Lägg till rumsadress" + "Vem som helst kan be om att gå med i rummet men en administratör eller moderator måste acceptera begäran." + "Be om att gå med" + "Ja, aktivera kryptering" + "När det är aktiverat kan kryptering för ett rum inte inaktiveras, meddelandehistoriken visas bara för rumsmedlemmar sedan de blev inbjudna eller sedan de gick med i rummet. +Ingen förutom rumsmedlemmarna kommer att kunna läsa meddelanden. Detta kan förhindra att bots och bridges fungerar korrekt. +Vi rekommenderar inte att aktivera kryptering för rum som vem som helst kan hitta och gå med i." + "Aktivera kryptering?" + "Efter aktivering kan kryptering inte inaktiveras." + "Kryptering" + "Aktivera totalsträckskryptering" + "Vem som helst kan hitta och gå med" + "Vem som helst" + "Användare kan bara gå med om de är inbjudna" + "Endast inbjudan" + "Tillgång till rum" + "Utrymmesmedlemmar" + "Utrymmen stöds för närvarande inte" + "Du behöver en rumsadress för att göra den synlig i katalogen." + "Tillåt att detta rum hittas genom att söka i den offentliga rumskatalogen på %1$s" + "Synlig i katalogen för offentliga rum" + "Vem som helst" + "Vem kan läsa historik" + "Endast medlemmar sedan de bjöds in" + "Endast medlemmar sedan det här alternativet har valts" + "Rumsadresser är sätt att hitta och komma åt rum. Detta säkerställer också att du enkelt kan dela ditt rum med andra. +Du kan välja att publicera ditt rum i din hemservers offentliga rumskatalog." + "Rumspublicering" + "Säkerhet och sekretess" + diff --git a/features/securityandprivacy/impl/src/main/res/values-tr/translations.xml b/features/securityandprivacy/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..940fdc9 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,36 @@ + + + "Dizinde görünür hale getirmek için bir oda adresine ihtiyacınız olacak." + "Oda adresi" + "Oda adresi ekle" + "Herkes odaya katılma isteğinde bulunabilir ancak bir yönetici veya moderatörün isteği kabul etmesi gerekir." + "Katılmak için sor" + "Evet, şifrelemeyi etkinleştir" + "Etkinleştirildikten sonra, bir oda için şifreleme devre dışı bırakılamaz, Mesaj geçmişi yalnızca davet edildiklerinden veya odaya katıldıklarından beri oda üyeleri için görünür olacaktır. +Oda üyeleri dışında hiç kimse mesajları okuyamayacaktır. Bu, botların ve köprülerin düzgün çalışmasını engelleyebilir. +Herkesin bulabileceği ve katılabileceği odalar için şifrelemenin etkinleştirilmesini önermiyoruz." + "Şifrelemeyi etkinleştir?" + "Açıldıktan donra şifreleme kapatılamaz." + "Şifreleme" + "Uçtan uca şifrelemeyi etkinleştir" + "Herkes bulabilir ve katılabilir" + "Herkes" + "İnsanlar yalnızca davet edildiklerinde katılabilirler" + "Yalnızca davet" + "Oda Erişimi" + "Alan üyeleri" + "Alanlar şu anda desteklenmiyor" + "Dizinde görünür hale getirmek için bir oda adresine ihtiyacınız olacak." + "Oda adresi" + "Bu odanın %1$s genel oda dizininde arama yapılarak bulunmasına izin verin" + "Genel oda dizininde görünür" + "Herkes" + "Geçmişi kimler okuyabilir ?" + "Sadece üyeler (davet edildiklerinden beri)" + "Bu seçeneği seçtiğinden beri yalnızca üyeler" + "Oda adresleri, odaları bulmanın ve odalara erişmenin yoludur. Bu aynı zamanda odanızı başkalarıyla kolayca paylaşabilmenizi sağlar. +Odanızı ana sunucunuzun genel oda dizininde yayınlamayı seçebilirsiniz." + "Oda yayınlama" + "Oda görünürlüğü" + "Güvenlik ve gizlilik" + diff --git a/features/securityandprivacy/impl/src/main/res/values-uk/translations.xml b/features/securityandprivacy/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..55c7d4f --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,36 @@ + + + "Вам знадобиться адреса кімнати, щоб зробити її видимою в каталозі." + "Адреса кімнати" + "Додати адресу кімнати" + "Будь-хто може надіслати запит приєднатися до кімнати, але адміністратор або модератор повинні прийняти запит." + "Запросити приєднатися" + "Так, увімкнути шифрування" + "Після ввімкнення шифрування кімнати, його неможливо вимкнути, історію повідомлень бачитимуть лише учасники кімнати, яких було запрошено або які приєдналися до кімнати. +Ніхто, крім учасників кімнати, не зможе прочитати повідомлення. Це може перешкоджати коректній роботі ботів і мостів. +Ми не радимо вмикати шифрування для кімнат, які будь-хто може знайти та до яких може приєднатися всі." + "Увімкнути шифрування?" + "Після ввімкнення шифрування неможливо вимкнути." + "Шифрування" + "Увімкнути наскрізне шифрування" + "Будь-хто може знайти та приєднатися." + "Кожний" + "Люди можуть приєднатися, лише якщо їх запросили" + "Лише запрошені" + "Доступ до кімнати" + "Учасники простору" + "Простори наразі не підтримуються" + "Вам знадобиться адреса кімнати, щоб зробити її видимою в каталозі." + "Адреса кімнати" + "Дозвольте, щоб цю кімнату можна було знайти за допомогою пошуку в каталозі загальнодоступних кімнат %1$s " + "Видима в каталозі загальнодоступних кімнат" + "Кожний" + "Хто може читати історію" + "Лише учасники з моменту запрошення" + "Лише учасники після вибору цього параметра" + "Адреси кімнат — це спосіб знайти кімнату та отримати до неї доступ. Це також гарантує, що ви можете легко поділитися своєю кімнатою з іншими. +Ви можете опублікувати свою кімнату в каталозі загальнодоступних кімнат вашого домашнього сервера." + "Публікація в кімнаті" + "Видимість кімнати" + "Безпека й приватність" + diff --git a/features/securityandprivacy/impl/src/main/res/values-uz/translations.xml b/features/securityandprivacy/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..06f0337 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,33 @@ + + + "Katalogda ko‘rinadigan qilish uchun xona manzili kerak bo‘ladi." + "Xona manzili" + "Xona manzilini kiritish" + "Xonaga qo‘shilishni istalgan kishi so‘rashi mumkin, lekin administrator yoki moderator so‘rovni qabul qilishi kerak" + "Qo‘shilishni so‘rang" + "Ha, shifrlashni yoqish" + "Yoqilgandan so‘ng, xona uchun shifrlashni o‘chirib bo‘lmaydi. Xabarlar tarixi faqat xona a’zolari taklif qilinganidan yoki xonaga qo‘shilganidan keyingi davrdan boshlab ko‘rinadi. Xona a’zolaridan tashqari hech kim xabarlarni o‘qiy olmaydi. Bu botlar va ko‘priklarning to‘g‘ri ishlashiga to‘sqinlik qilishi mumkin. +Shu sababli, har kim topishi va qo‘shilishi mumkin bo‘lgan xonalar uchun shifrlashni yoqishni tavsiya etmaymiz." + "Shifrlash yoqilsinmi?" + "Yoqilgandan keyin shifrlashni faolsizlantirish imkonsiz." + "Shifrlash" + "End-to-end shifrlashni yoqish" + "Istalgan kishi topishi va qo‘shilishi mumkin" + "Har kim" + "Odamlar faqat taklif qilingan taqdirdagina qo‘shilishi mumkin" + "Faqat taklif qilish" + "Xonaga kirish huquqi" + "Maydon a’zolari" + "Hozirda maydonlar qo‘llab-quvvatlanmaydi" + "Katalogda ko‘rinadigan qilish uchun xona manzili kerak bo‘ladi." + "Bu xonani %1$s umumiy xonalar ro‘yxatidan qidirib topish imkoniyatini berish" + "Umumiy xona ro‘yxatida ko‘rinadi" + "Har kim" + "Tarixni kim o‘qiy oladi" + "Taklif qilinganidan buyon faqat a’zolar" + "A’zolar faqat bu parametr tanlanganidan keyin" + "Xona manzillari xonalarni topish va ularga kirish usullaridir. Bu shuningdek xonangizni boshqalar bilan oson ulashish imkonini beradi. +Xonangizni o‘z homeserveringizning ommaviy xonalar ro‘yxatida e’lon qilishni tanlashingiz mumkin." + "xona nashriyoti" + "Xavfsizlik va maxfiylik" + diff --git a/features/securityandprivacy/impl/src/main/res/values-zh-rTW/translations.xml b/features/securityandprivacy/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..93e5880 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,37 @@ + + + "您需要地址才能在公開目錄中顯示。" + "編輯地址" + "新增地址" + "所有人都必須申請存取權。" + "要求加入" + "是的,啟用加密" + "啟用後就無法停用聊天室的加密,只有受邀的聊天室成員或加入聊天室後才能看到訊息歷史紀錄。 +除了聊天室成員以外,任何人都不能讀取訊息。這可能會讓機器人與橋接無法正常運作。 +我們不建議對任何人都可以找到並加入的聊天室啟用加密。" + "啟用加密?" + "一旦啟用就無法停用加密。" + "加密" + "啟用端到端加密" + "任何人都可以加入。" + "任何人" + "僅受邀者才能加入。" + "僅限邀請" + "存取權" + "空間成員" + "目前不支援空間" + "您需要地址才能在公開目錄中顯示。" + "地址" + "允許透過搜尋 %1$s 公開聊天室目錄找到此聊天室" + "允許其他人透過公開目錄找到。" + "在公開目錄中可見" + "任何人" + "誰可以讀取歷史紀錄" + "僅在成員被邀請後" + "選取此選項後僅限成員" + "聊天室地址是尋找與存取聊天室的方法。也確保您可以輕鬆與其他人分享聊天室。 +您可以選擇在家伺服器公開聊天室目錄中發佈您的聊天室。" + "聊天室發佈" + "能見度" + "安全與隱私" + diff --git a/features/securityandprivacy/impl/src/main/res/values-zh/translations.xml b/features/securityandprivacy/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..ef9051a --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,36 @@ + + + "你需要房间地址才能使其在目录中可见。" + "房间地址" + "添加房间地址" + "任何人都可以请求加入房间,但必须由管理员或版主接受请求。" + "请求加入" + "是的,启用加密" + "一旦启用,就不能再禁用房间的加密功能。消息历史记录只能在房间成员被邀请或加入房间后才可见。 +除房间成员外,任何人都无法阅读信息。这可能会妨碍机器人和网桥正常工作。 +我们不建议对任何人都能找到并加入的房间启用加密。" + "启用加密?" + "加密一旦启用,就无法禁用。" + "加密" + "启用端到端加密" + "任何人都可以找到并加入" + "任何人" + "只有受邀者才能加入" + "仅限邀请" + "房间访问权限" + "空间成员" + "目前不支持空间" + "你需要房间地址才能使其在目录中可见。" + "房间地址" + "允许通过搜索 %1$s 的公共房间目录来发现此房间" + "在公共房间目录中可见" + "任何人" + "谁可以读取历史记录" + "仅限被邀请的成员" + "仅自选择此选项以来的成员" + "房间地址是查找和访问房间的方式。这也确保你可以轻松地向他人分享房间。 +你可以选择在你服务器的公共房间目录中发布你的房间。" + "房间发布" + "房间可见性" + "安全与隐私" + diff --git a/features/securityandprivacy/impl/src/main/res/values/localazy.xml b/features/securityandprivacy/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..859be76 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values/localazy.xml @@ -0,0 +1,42 @@ + + + "You’ll need an address in order to make it visible in the public directory." + "Edit address" + "Add address" + "Everyone must request access." + "Ask to join" + "Yes, enable encryption" + "Once enabled, encryption for a room cannot be disabled, Message history will only be visible for room members since they were invited or since they joined the room. +No one besides the room members will be able to read messages. This may prevent bots and bridges to work correctly. +We do not recommend enabling encryption for rooms that anyone can find and join." + "Enable encryption?" + "Once enabled, encryption cannot be disabled." + "Encryption" + "Enable end-to-end encryption" + "Anyone can join." + "Anyone" + "Choose which spaces’ members can join this room without an invitation. %1$s" + "Manage spaces" + "Only invited people can join." + "Invite only" + "Access" + "Anyone in authorized spaces can join." + "Anyone in %1$s can join." + "Space members" + "Spaces are not currently supported" + "You’ll need an address in order to make it visible in the public directory." + "Address" + "Allow for this room to be found by searching %1$s public room directory" + "Allow to be found by searching the public directory." + "Visible in public directory" + "Anyone" + "Who can read history" + "Members only since they were invited" + "Members only since selecting this option" + "Room addresses are ways to find and access rooms. This also ensures you can easily share your room with others. +You can choose to publish your room in your homeserver public room directory." + "Room publishing" + "Addresses are a way to find and access rooms and spaces. This also ensures you can easily share them with others." + "Visibility" + "Security & privacy" + diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/FakeSecurityAndPrivacyNavigator.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/FakeSecurityAndPrivacyNavigator.kt new file mode 100644 index 0000000..9ca3cc5 --- /dev/null +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/FakeSecurityAndPrivacyNavigator.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.impl + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeSecurityAndPrivacyNavigator( + private val openEditRoomAddressLambda: () -> Unit = { lambdaError() }, + private val closeEditRoomAddressLambda: () -> Unit = { lambdaError() }, +) : SecurityAndPrivacyNavigator { + override fun openEditRoomAddress() { + openEditRoomAddressLambda() + } + + override fun closeEditRoomAddress() { + closeEditRoomAddressLambda() + } +} diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyPresenterTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyPresenterTest.kt new file mode 100644 index 0000000..bdae527 --- /dev/null +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyPresenterTest.kt @@ -0,0 +1,390 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyEvents +import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyHistoryVisibility +import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyPresenter +import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyRoomAccess +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +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.room.history.RoomHistoryVisibility +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility +import io.element.android.libraries.matrix.test.A_ROOM_ALIAS +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SecurityAndPrivacyPresenterTest { + @Test + fun `present - initial states`() = runTest { + val presenter = createSecurityAndPrivacyPresenter() + presenter.test { + with(awaitItem()) { + assertThat(editedSettings).isEqualTo(savedSettings) + assertThat(canBeSaved).isFalse() + assertThat(showEnableEncryptionConfirmation).isFalse() + assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(showRoomAccessSection).isFalse() + assertThat(showRoomVisibilitySections).isFalse() + assertThat(showHistoryVisibilitySection).isFalse() + assertThat(showEncryptionSection).isFalse() + assertThat(isKnockEnabled).isFalse() + } + with(awaitItem()) { + assertThat(editedSettings).isEqualTo(savedSettings) + assertThat(canBeSaved).isFalse() + assertThat(showEnableEncryptionConfirmation).isFalse() + assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(showRoomAccessSection).isTrue() + assertThat(showRoomVisibilitySections).isFalse() + assertThat(showHistoryVisibilitySection).isTrue() + assertThat(showEncryptionSection).isTrue() + assertThat(isKnockEnabled).isFalse() + } + } + } + + @Test + fun `present - room info change updates saved and edited settings`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canSendStateResult = { _, _ -> Result.success(true) }, + initialRoomInfo = aRoomInfo( + joinRule = JoinRule.Public, + historyVisibility = RoomHistoryVisibility.WorldReadable, + canonicalAlias = A_ROOM_ALIAS, + ) + ) + ) + val presenter = createSecurityAndPrivacyPresenter(room = room) + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings).isEqualTo(savedSettings) + assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.Anyone) + assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Anyone) + assertThat(editedSettings.address).isEqualTo(A_ROOM_ALIAS.value) + assertThat(canBeSaved).isFalse() + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - change room access`() = runTest { + val presenter = createSecurityAndPrivacyPresenter() + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly) + assertThat(showRoomVisibilitySections).isFalse() + eventSink(SecurityAndPrivacyEvents.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.Anyone)) + } + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.Anyone) + assertThat(showRoomVisibilitySections).isTrue() + assertThat(canBeSaved).isTrue() + eventSink(SecurityAndPrivacyEvents.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.InviteOnly)) + } + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly) + assertThat(showRoomVisibilitySections).isFalse() + assertThat(canBeSaved).isFalse() + } + } + } + + @Test + fun `present - change history visibility`() = runTest { + val presenter = createSecurityAndPrivacyPresenter() + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.SinceSelection) + eventSink(SecurityAndPrivacyEvents.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.SinceInvite)) + } + with(awaitItem()) { + assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.SinceInvite) + assertThat(canBeSaved).isTrue() + eventSink(SecurityAndPrivacyEvents.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.SinceSelection)) + } + with(awaitItem()) { + assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.SinceSelection) + assertThat(canBeSaved).isFalse() + } + } + } + + @Test + fun `present - enable encryption`() = runTest { + val presenter = createSecurityAndPrivacyPresenter() + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.isEncrypted).isFalse() + eventSink(SecurityAndPrivacyEvents.ToggleEncryptionState) + } + with(awaitItem()) { + assertThat(showEnableEncryptionConfirmation).isTrue() + eventSink(SecurityAndPrivacyEvents.CancelEnableEncryption) + } + with(awaitItem()) { + assertThat(showEnableEncryptionConfirmation).isFalse() + eventSink(SecurityAndPrivacyEvents.ToggleEncryptionState) + } + with(awaitItem()) { + assertThat(showEnableEncryptionConfirmation).isTrue() + eventSink(SecurityAndPrivacyEvents.ConfirmEnableEncryption) + } + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.isEncrypted).isTrue() + assertThat(showEnableEncryptionConfirmation).isFalse() + assertThat(canBeSaved).isTrue() + eventSink(SecurityAndPrivacyEvents.ToggleEncryptionState) + } + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.isEncrypted).isFalse() + assertThat(canBeSaved).isFalse() + } + } + } + + @Test + fun `present - room visibility loading and change`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canSendStateResult = { _, _ -> Result.success(true) }, + getRoomVisibilityResult = { Result.success(RoomVisibility.Private) }, + initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared) + ) + ) + val presenter = createSecurityAndPrivacyPresenter(room = room) + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Loading()) + } + with(awaitItem()) { + assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(false)) + eventSink(SecurityAndPrivacyEvents.ToggleRoomVisibility) + } + with(awaitItem()) { + assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(true)) + assertThat(canBeSaved).isTrue() + eventSink(SecurityAndPrivacyEvents.ToggleRoomVisibility) + } + with(awaitItem()) { + assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(false)) + assertThat(canBeSaved).isFalse() + } + } + } + + @Test + fun `present - edit room address`() = runTest { + val openEditRoomAddressLambda = lambdaRecorder { } + val navigator = FakeSecurityAndPrivacyNavigator(openEditRoomAddressLambda) + val presenter = createSecurityAndPrivacyPresenter(navigator = navigator) + presenter.test { + skipItems(1) + with(awaitItem()) { + eventSink(SecurityAndPrivacyEvents.EditRoomAddress) + } + assert(openEditRoomAddressLambda).isCalledOnce() + } + } + + @Test + fun `present - save success`() = runTest { + val enableEncryptionLambda = lambdaRecorder> { Result.success(Unit) } + val updateJoinRuleLambda = lambdaRecorder> { Result.success(Unit) } + val updateRoomVisibilityLambda = lambdaRecorder> { Result.success(Unit) } + val updateRoomHistoryVisibilityLambda = lambdaRecorder> { Result.success(Unit) } + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canSendStateResult = { _, _ -> Result.success(true) }, + getRoomVisibilityResult = { Result.success(RoomVisibility.Private) }, + initialRoomInfo = aRoomInfo(joinRule = JoinRule.Invite, historyVisibility = RoomHistoryVisibility.Shared) + ), + enableEncryptionResult = enableEncryptionLambda, + updateJoinRuleResult = updateJoinRuleLambda, + updateRoomVisibilityResult = updateRoomVisibilityLambda, + updateRoomHistoryVisibilityResult = updateRoomHistoryVisibilityLambda, + ) + val presenter = createSecurityAndPrivacyPresenter(room = room) + presenter.test { + skipItems(2) + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly) + eventSink(SecurityAndPrivacyEvents.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.Anyone)) + } + with(awaitItem()) { + eventSink(SecurityAndPrivacyEvents.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.Anyone)) + } + with(awaitItem()) { + assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Anyone) + eventSink(SecurityAndPrivacyEvents.ConfirmEnableEncryption) + } + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.isEncrypted).isTrue() + eventSink(SecurityAndPrivacyEvents.ToggleRoomVisibility) + } + with(awaitItem()) { + assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(true)) + eventSink(SecurityAndPrivacyEvents.Save) + } + with(awaitItem()) { + assertThat(saveAction).isEqualTo(AsyncAction.Loading) + } + + room.givenRoomInfo( + aRoomInfo( + joinRule = JoinRule.Public, + historyVisibility = RoomHistoryVisibility.WorldReadable, + isEncrypted = true, + ) + ) + // Saved settings are updated 3 times to match the edited settings + skipItems(3) + with(awaitItem()) { + assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit)) + assertThat(savedSettings).isEqualTo(editedSettings) + assertThat(canBeSaved).isFalse() + } + assert(enableEncryptionLambda).isCalledOnce() + assert(updateJoinRuleLambda).isCalledOnce() + assert(updateRoomVisibilityLambda).isCalledOnce() + assert(updateRoomHistoryVisibilityLambda).isCalledOnce() + } + } + + @Test + fun `present - save failure`() = runTest { + val enableEncryptionLambda = lambdaRecorder> { Result.success(Unit) } + val updateJoinRuleLambda = lambdaRecorder> { Result.success(Unit) } + val updateRoomVisibilityLambda = lambdaRecorder> { + Result.failure(Exception("Failed to update room visibility")) + } + val updateRoomHistoryVisibilityLambda = lambdaRecorder> { Result.success(Unit) } + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canSendStateResult = { _, _ -> Result.success(true) }, + getRoomVisibilityResult = { Result.success(RoomVisibility.Private) }, + initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Private) + ), + enableEncryptionResult = enableEncryptionLambda, + updateJoinRuleResult = updateJoinRuleLambda, + updateRoomVisibilityResult = updateRoomVisibilityLambda, + updateRoomHistoryVisibilityResult = updateRoomHistoryVisibilityLambda, + ) + val presenter = createSecurityAndPrivacyPresenter(room = room) + presenter.test { + skipItems(2) + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly) + eventSink(SecurityAndPrivacyEvents.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.Anyone)) + } + with(awaitItem()) { + eventSink(SecurityAndPrivacyEvents.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.Anyone)) + } + with(awaitItem()) { + assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Anyone) + eventSink(SecurityAndPrivacyEvents.ConfirmEnableEncryption) + } + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.isEncrypted).isTrue() + eventSink(SecurityAndPrivacyEvents.ToggleRoomVisibility) + } + with(awaitItem()) { + assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(true)) + eventSink(SecurityAndPrivacyEvents.Save) + } + with(awaitItem()) { + assertThat(saveAction).isEqualTo(AsyncAction.Loading) + } + + room.givenRoomInfo( + aRoomInfo( + joinRule = JoinRule.Public, + historyVisibility = RoomHistoryVisibility.WorldReadable, + ) + ) + // Saved settings are updated 2 times to match the edited settings + skipItems(3) + val state = awaitItem() + with(state) { + assertThat(saveAction).isInstanceOf(AsyncAction.Failure::class.java) + assertThat(savedSettings.isVisibleInRoomDirectory).isNotEqualTo(editedSettings.isVisibleInRoomDirectory) + assertThat(canBeSaved).isTrue() + } + assert(enableEncryptionLambda).isCalledOnce() + assert(updateJoinRuleLambda).isCalledOnce() + assert(updateRoomVisibilityLambda).isCalledOnce() + assert(updateRoomHistoryVisibilityLambda).isCalledOnce() + // Clear error + state.eventSink(SecurityAndPrivacyEvents.DismissSaveError) + with(awaitItem()) { + assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized) + } + } + } + + @Test + fun `present - isKnockEnabled is true if the Knock feature flag is enabled`() = runTest { + val presenter = createSecurityAndPrivacyPresenter( + featureFlagService = FakeFeatureFlagService( + initialState = mapOf( + FeatureFlags.Knock.key to true, + ) + ) + ) + presenter.test { + assertThat(awaitItem().isKnockEnabled).isFalse() + assertThat(awaitItem().isKnockEnabled).isTrue() + } + } + + private fun createSecurityAndPrivacyPresenter( + serverName: String = "matrix.org", + room: FakeJoinedRoom = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canSendStateResult = { _, _ -> Result.success(true) }, + getRoomVisibilityResult = { Result.success(RoomVisibility.Private) }, + initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Private) + ), + ), + navigator: SecurityAndPrivacyNavigator = FakeSecurityAndPrivacyNavigator(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), + ): SecurityAndPrivacyPresenter { + return SecurityAndPrivacyPresenter( + room = room, + matrixClient = FakeMatrixClient( + userIdServerNameLambda = { serverName }, + ), + navigator = navigator, + featureFlagService = featureFlagService, + ) + } +} diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyViewTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyViewTest.kt new file mode 100644 index 0000000..78b840d --- /dev/null +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyViewTest.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.impl + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyEvents +import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyHistoryVisibility +import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyRoomAccess +import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyState +import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyView +import io.element.android.features.securityandprivacy.impl.root.aSecurityAndPrivacySettings +import io.element.android.features.securityandprivacy.impl.root.aSecurityAndPrivacyState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class SecurityAndPrivacyViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `click on back invokes emits the expected event`() { + val recorder = EventsRecorder() + val state = aSecurityAndPrivacyState( + eventSink = recorder, + ) + rule.setSecurityAndPrivacyView(state) + rule.pressBack() + recorder.assertSingle(SecurityAndPrivacyEvents.Exit) + } + + @Test + fun `confirm cancellation emits the expected event`() { + val recorder = EventsRecorder() + val state = aSecurityAndPrivacyState( + confirmExitAction = AsyncAction.ConfirmingCancellation, + eventSink = recorder, + ) + rule.setSecurityAndPrivacyView(state) + rule.clickOn(CommonStrings.action_ok) + recorder.assertSingle(SecurityAndPrivacyEvents.Exit) + } + + @Test + fun `dismiss cancellation confirmation emits the expected event`() { + val recorder = EventsRecorder() + val state = aSecurityAndPrivacyState( + confirmExitAction = AsyncAction.ConfirmingCancellation, + eventSink = recorder, + ) + rule.setSecurityAndPrivacyView(state) + rule.clickOn(CommonStrings.action_cancel) + recorder.assertSingle(SecurityAndPrivacyEvents.DismissExitConfirmation) + } + + @Test + fun `click on room access item emits the expected event`() { + val recorder = EventsRecorder() + val state = aSecurityAndPrivacyState( + eventSink = recorder, + ) + rule.setSecurityAndPrivacyView(state) + rule.clickOn(R.string.screen_security_and_privacy_room_access_invite_only_option_title) + recorder.assertSingle(SecurityAndPrivacyEvents.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.InviteOnly)) + } + + @Test + fun `click on disabled save doesn't emit event`() { + val recorder = EventsRecorder(expectEvents = false) + val state = aSecurityAndPrivacyState(eventSink = recorder) + rule.setSecurityAndPrivacyView(state) + rule.clickOn(CommonStrings.action_save) + recorder.assertEmpty() + } + + @Test + fun `click on enabled save emits the expected event`() { + val recorder = EventsRecorder() + val state = aSecurityAndPrivacyState( + eventSink = recorder, + editedSettings = aSecurityAndPrivacySettings( + roomAccess = SecurityAndPrivacyRoomAccess.Anyone, + ) + ) + rule.setSecurityAndPrivacyView(state) + rule.clickOn(CommonStrings.action_save) + recorder.assertSingle(SecurityAndPrivacyEvents.Save) + } + + @Test + @Config(qualifiers = "h640dp") + fun `click on room address item emits the expected event`() { + val address = "@alias:matrix.org" + val recorder = EventsRecorder() + val state = aSecurityAndPrivacyState( + eventSink = recorder, + editedSettings = aSecurityAndPrivacySettings( + address = address, + roomAccess = SecurityAndPrivacyRoomAccess.Anyone, + ), + ) + rule.setSecurityAndPrivacyView(state) + rule.onNodeWithText(address).performClick() + recorder.assertSingle(SecurityAndPrivacyEvents.EditRoomAddress) + } + + @Test + @Config(qualifiers = "h1024dp") + fun `click on room visibility item emits the expected event`() { + val recorder = EventsRecorder() + val state = aSecurityAndPrivacyState( + eventSink = recorder, + editedSettings = aSecurityAndPrivacySettings( + roomAccess = SecurityAndPrivacyRoomAccess.Anyone, + isVisibleInRoomDirectory = AsyncData.Success(false), + ), + ) + rule.setSecurityAndPrivacyView(state) + rule.clickOn(R.string.screen_security_and_privacy_room_directory_visibility_toggle_title) + recorder.assertSingle(SecurityAndPrivacyEvents.ToggleRoomVisibility) + } + + @Test + @Config(qualifiers = "h640dp") + fun `click on history visibility item emits the expected event`() { + val recorder = EventsRecorder() + val state = aSecurityAndPrivacyState( + eventSink = recorder, + editedSettings = aSecurityAndPrivacySettings( + historyVisibility = SecurityAndPrivacyHistoryVisibility.SinceSelection, + ), + ) + rule.setSecurityAndPrivacyView(state) + rule.clickOn(R.string.screen_security_and_privacy_room_history_since_selecting_option_title) + recorder.assertSingle(SecurityAndPrivacyEvents.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.SinceSelection)) + } + + @Test + @Config(qualifiers = "h640dp") + fun `click on encryption item emits the expected event`() { + val recorder = EventsRecorder() + val state = aSecurityAndPrivacyState( + eventSink = recorder, + savedSettings = aSecurityAndPrivacySettings(isEncrypted = false), + ) + rule.setSecurityAndPrivacyView(state) + rule.clickOn(R.string.screen_security_and_privacy_encryption_toggle_title) + recorder.assertSingle(SecurityAndPrivacyEvents.ToggleEncryptionState) + } + + @Test + fun `click on encryption confirm emits the expected event`() { + val recorder = EventsRecorder() + val state = aSecurityAndPrivacyState( + eventSink = recorder, + showEncryptionConfirmation = true, + ) + rule.setSecurityAndPrivacyView(state) + rule.clickOn(R.string.screen_security_and_privacy_enable_encryption_alert_confirm_button_title) + recorder.assertSingle(SecurityAndPrivacyEvents.ConfirmEnableEncryption) + } +} + +private fun AndroidComposeTestRule.setSecurityAndPrivacyView( + state: SecurityAndPrivacyState = aSecurityAndPrivacyState( + eventSink = EventsRecorder(expectEvents = false), + ), + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + SecurityAndPrivacyView( + state = state, + onBackClick = onBackClick, + ) + } +} diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressPresenterTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressPresenterTest.kt new file mode 100644 index 0000000..aac25ec --- /dev/null +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressPresenterTest.kt @@ -0,0 +1,380 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.impl.editroomaddress + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.securityandprivacy.impl.FakeSecurityAndPrivacyNavigator +import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias +import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper +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.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper +import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.util.Optional + +class EditRoomAddressPresenterTest { + @Test + fun `present - initial state no address`() = runTest { + val presenter = createEditRoomAddressPresenter( + room = FakeJoinedRoom().apply { + givenRoomInfo(aRoomInfo(name = "")) + } + ) + presenter.test { + with(awaitItem()) { + assertThat(homeserverName).isEqualTo("matrix.org") + assertThat(canBeSaved).isFalse() + assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Unknown) + assertThat(roomAddress).isEmpty() + } + } + } + + @Test + fun `present - initial state address matching own homeserver`() = runTest { + val room = FakeJoinedRoom().apply { + givenRoomInfo(aRoomInfo(canonicalAlias = RoomAlias("#canonical:matrix.org"))) + } + val presenter = createEditRoomAddressPresenter(room = room) + presenter.test { + with(awaitItem()) { + assertThat(homeserverName).isEqualTo("matrix.org") + assertThat(canBeSaved).isFalse() + assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Unknown) + assertThat(roomAddress).isEqualTo("canonical") + } + } + } + + @Test + fun `present - initial state address not matching own homeserver`() = runTest { + val room = FakeJoinedRoom().apply { + givenRoomInfo( + aRoomInfo( + name = "", + canonicalAlias = RoomAlias("#canonical:notmatrix.org") + ) + ) + } + val presenter = createEditRoomAddressPresenter(room = room) + presenter.test { + with(awaitItem()) { + assertThat(homeserverName).isEqualTo("matrix.org") + assertThat(canBeSaved).isFalse() + assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Unknown) + assertThat(roomAddress).isEmpty() + } + } + } + + @Test + fun `present - room address change invalid state`() = runTest { + val roomAliasHelper = FakeRoomAliasHelper( + isRoomAliasValidLambda = { false } + ) + val presenter = createEditRoomAddressPresenter(roomAliasHelper = roomAliasHelper) + presenter.test { + with(awaitItem()) { + eventSink(EditRoomAddressEvents.RoomAddressChanged("invalid")) + } + with(awaitItem()) { + assertThat(roomAddress).isEqualTo("invalid") + assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Unknown) + } + with(awaitItem()) { + assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.InvalidSymbols) + assertThat(canBeSaved).isFalse() + } + } + } + + @Test + fun `present - room address change valid state`() = runTest { + val presenter = createEditRoomAddressPresenter() + presenter.test { + with(awaitItem()) { + eventSink(EditRoomAddressEvents.RoomAddressChanged("valid")) + } + with(awaitItem()) { + assertThat(roomAddress).isEqualTo("valid") + assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Unknown) + } + with(awaitItem()) { + assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Valid) + assertThat(canBeSaved).isTrue() + } + } + } + + @Test + fun `present - room address change alias unavailable`() = runTest { + val client = createMatrixClient(isAliasAvailable = false) + val presenter = createEditRoomAddressPresenter(client = client) + presenter.test { + with(awaitItem()) { + eventSink(EditRoomAddressEvents.RoomAddressChanged("valid")) + } + with(awaitItem()) { + assertThat(roomAddress).isEqualTo("valid") + assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Unknown) + } + with(awaitItem()) { + assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.NotAvailable) + assertThat(canBeSaved).isFalse() + } + } + } + + @Test + fun `present - save success no current alias`() = runTest { + val publishAliasInRoomDirectoryResult = lambdaRecorder> { _ -> Result.success(true) } + val updateCanonicalAliasResult = lambdaRecorder, Result> { _, _ -> Result.success(Unit) } + val removeAliasFromRoomDirectoryResult = lambdaRecorder> { _ -> Result.success(true) } + val closeEditAddressLambda = lambdaRecorder { } + val navigator = FakeSecurityAndPrivacyNavigator( + closeEditRoomAddressLambda = closeEditAddressLambda + ) + val room = FakeJoinedRoom( + updateCanonicalAliasResult = updateCanonicalAliasResult, + publishRoomAliasInRoomDirectoryResult = publishAliasInRoomDirectoryResult + ) + val presenter = createEditRoomAddressPresenter(room = room, navigator = navigator) + presenter.test { + with(awaitItem()) { + eventSink(EditRoomAddressEvents.RoomAddressChanged("valid")) + } + skipItems(1) + with(awaitItem()) { + assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Valid) + assertThat(canBeSaved).isTrue() + eventSink(EditRoomAddressEvents.Save) + } + with(awaitItem()) { + assertThat(saveAction).isEqualTo(AsyncAction.Loading) + } + with(awaitItem()) { + assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit)) + } + + val createdAlias = RoomAlias("#valid:matrix.org") + assert(updateCanonicalAliasResult) + .isCalledOnce() + .with(value(createdAlias), value(emptyList())) + + assert(publishAliasInRoomDirectoryResult) + .isCalledOnce() + .with(value(createdAlias)) + + assert(removeAliasFromRoomDirectoryResult).isNeverCalled() + + assert(closeEditAddressLambda).isCalledOnce() + } + } + + @Test + fun `present - save success current canonical alias from own homeserver`() = runTest { + val publishAliasInRoomDirectoryResult = lambdaRecorder> { _ -> Result.success(true) } + val removeAliasFromRoomDirectoryResult = lambdaRecorder> { _ -> Result.success(true) } + val updateCanonicalAliasResult = lambdaRecorder, Result> { _, _ -> Result.success(Unit) } + val closeEditAddressLambda = lambdaRecorder { } + + val navigator = FakeSecurityAndPrivacyNavigator(closeEditRoomAddressLambda = closeEditAddressLambda) + val canonicalAlias = RoomAlias("#canonical:matrix.org") + val room = FakeJoinedRoom( + updateCanonicalAliasResult = updateCanonicalAliasResult, + publishRoomAliasInRoomDirectoryResult = publishAliasInRoomDirectoryResult, + removeRoomAliasFromRoomDirectoryResult = removeAliasFromRoomDirectoryResult + ).apply { + givenRoomInfo(aRoomInfo(canonicalAlias = canonicalAlias)) + } + val presenter = createEditRoomAddressPresenter(room = room, navigator = navigator) + presenter.test { + with(awaitItem()) { + eventSink(EditRoomAddressEvents.RoomAddressChanged("valid")) + } + skipItems(1) + with(awaitItem()) { + assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Valid) + assertThat(canBeSaved).isTrue() + eventSink(EditRoomAddressEvents.Save) + } + with(awaitItem()) { + assertThat(saveAction).isEqualTo(AsyncAction.Loading) + } + with(awaitItem()) { + assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit)) + } + + val createdAlias = RoomAlias("#valid:matrix.org") + assert(updateCanonicalAliasResult) + .isCalledOnce() + .with(value(createdAlias), value(emptyList())) + + assert(publishAliasInRoomDirectoryResult) + .isCalledOnce() + .with(value(createdAlias)) + + assert(removeAliasFromRoomDirectoryResult) + .isCalledOnce() + .with(value(canonicalAlias)) + + assert(closeEditAddressLambda).isCalledOnce() + } + } + + @Test + fun `present - save success current canonical alias from other homeserver`() = runTest { + val publishAliasInRoomDirectoryResult = lambdaRecorder> { _ -> Result.success(true) } + val removeAliasFromRoomDirectoryResult = lambdaRecorder> { _ -> Result.success(true) } + val updateCanonicalAliasResult = lambdaRecorder, Result> { _, _ -> Result.success(Unit) } + val closeEditAddressLambda = lambdaRecorder { } + + val navigator = FakeSecurityAndPrivacyNavigator(closeEditRoomAddressLambda = closeEditAddressLambda) + val canonicalAlias = RoomAlias("#canonical:notmatrix.org") + val room = FakeJoinedRoom( + updateCanonicalAliasResult = updateCanonicalAliasResult, + publishRoomAliasInRoomDirectoryResult = publishAliasInRoomDirectoryResult, + removeRoomAliasFromRoomDirectoryResult = removeAliasFromRoomDirectoryResult + ).apply { + givenRoomInfo(aRoomInfo(canonicalAlias = canonicalAlias)) + } + val presenter = createEditRoomAddressPresenter(room = room, navigator = navigator) + presenter.test { + with(awaitItem()) { + eventSink(EditRoomAddressEvents.RoomAddressChanged("valid")) + } + skipItems(1) + with(awaitItem()) { + assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Valid) + assertThat(canBeSaved).isTrue() + eventSink(EditRoomAddressEvents.Save) + } + with(awaitItem()) { + assertThat(saveAction).isEqualTo(AsyncAction.Loading) + } + with(awaitItem()) { + assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit)) + } + + val createdAlias = RoomAlias("#valid:matrix.org") + assert(updateCanonicalAliasResult) + .isCalledOnce() + .with(value(canonicalAlias), value(listOf(createdAlias))) + + assert(publishAliasInRoomDirectoryResult) + .isCalledOnce() + .with(value(createdAlias)) + + assert(removeAliasFromRoomDirectoryResult).isNeverCalled() + + assert(closeEditAddressLambda).isCalledOnce() + } + } + + @Test + fun `present - save failure`() = runTest { + val closeEditAddressLambda = lambdaRecorder { } + val navigator = FakeSecurityAndPrivacyNavigator( + closeEditRoomAddressLambda = closeEditAddressLambda + ) + val presenter = createEditRoomAddressPresenter( + navigator = navigator, + room = FakeJoinedRoom( + publishRoomAliasInRoomDirectoryResult = { + Result.failure(AN_EXCEPTION) + }, + ) + ) + presenter.test { + with(awaitItem()) { + eventSink(EditRoomAddressEvents.RoomAddressChanged("valid")) + } + skipItems(1) + with(awaitItem()) { + assertThat(roomAddressValidity).isEqualTo(RoomAddressValidity.Valid) + assertThat(canBeSaved).isTrue() + eventSink(EditRoomAddressEvents.Save) + } + with(awaitItem()) { + assertThat(saveAction).isEqualTo(AsyncAction.Loading) + } + with(awaitItem()) { + assertThat(saveAction).isInstanceOf(AsyncAction.Failure::class.java) + } + + assert(closeEditAddressLambda).isNeverCalled() + } + } + + @Test + fun `present - dismiss error`() = runTest { + val presenter = createEditRoomAddressPresenter( + room = FakeJoinedRoom( + publishRoomAliasInRoomDirectoryResult = { + Result.failure(AN_EXCEPTION) + }, + ) + ) + presenter.test { + with(awaitItem()) { + eventSink(EditRoomAddressEvents.Save) + } + assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Loading::class.java) + with(awaitItem()) { + assertThat(saveAction).isInstanceOf(AsyncAction.Failure::class.java) + eventSink(EditRoomAddressEvents.DismissError) + } + with(awaitItem()) { + assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized) + } + } + } + + private fun createMatrixClient(isAliasAvailable: Boolean = true) = FakeMatrixClient( + userIdServerNameLambda = { "matrix.org" }, + resolveRoomAliasResult = { + val resolvedRoomAlias = if (isAliasAvailable) { + Optional.empty() + } else { + Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList())) + } + Result.success(resolvedRoomAlias) + } + ) + + private fun createEditRoomAddressPresenter( + client: FakeMatrixClient = createMatrixClient(), + room: JoinedRoom = FakeJoinedRoom(), + navigator: SecurityAndPrivacyNavigator = FakeSecurityAndPrivacyNavigator(), + roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper() + ): EditRoomAddressPresenter { + return EditRoomAddressPresenter( + room = room, + client = client, + roomAliasHelper = roomAliasHelper, + navigator = navigator + ) + } +} diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressViewTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressViewTest.kt new file mode 100644 index 0000000..17d6f3a --- /dev/null +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressViewTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.impl.editroomaddress + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class EditRoomAddressViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `click on back invokes expected callback`() { + ensureCalledOnce { callback -> + rule.setEditRoomAddressView(onBackClick = callback) + rule.pressBack() + } + } + + @Test + fun `click on disabled save doesn't emit event`() { + val recorder = EventsRecorder(expectEvents = false) + val state = anEditRoomAddressState(eventSink = recorder) + rule.setEditRoomAddressView(state) + rule.clickOn(CommonStrings.action_save) + recorder.assertEmpty() + } + + @Test + fun `click on enabled save emits the expected event`() { + val recorder = EventsRecorder() + val state = anEditRoomAddressState( + roomAddress = "room", + roomAddressValidity = RoomAddressValidity.Valid, + eventSink = recorder + ) + rule.setEditRoomAddressView(state) + rule.clickOn(CommonStrings.action_save) + recorder.assertSingle(EditRoomAddressEvents.Save) + } + + @Test + fun `text changes on text field emits the expected event`() { + val recorder = EventsRecorder() + val state = anEditRoomAddressState( + roomAddress = "", + eventSink = recorder + ) + rule.setEditRoomAddressView(state) + + rule.onNodeWithTag(TestTags.roomAddressField.value).performTextInput("alias") + recorder.assertSingle(EditRoomAddressEvents.RoomAddressChanged("alias")) + } + + @Test + fun `click on dismiss error emits the expected event`() { + val recorder = EventsRecorder() + val state = anEditRoomAddressState( + roomAddress = "", + saveAction = AsyncAction.Failure(IllegalStateException()), + eventSink = recorder + ) + rule.setEditRoomAddressView(state) + rule.clickOn(CommonStrings.action_cancel) + recorder.assertSingle(EditRoomAddressEvents.DismissError) + } + + @Test + fun `click on retry error emits the expected event`() { + val recorder = EventsRecorder() + val state = anEditRoomAddressState( + roomAddress = "", + saveAction = AsyncAction.Failure(IllegalStateException()), + eventSink = recorder + ) + rule.setEditRoomAddressView(state) + rule.clickOn(CommonStrings.action_retry) + recorder.assertSingle(EditRoomAddressEvents.Save) + } +} + +private fun AndroidComposeTestRule.setEditRoomAddressView( + state: EditRoomAddressState = anEditRoomAddressState( + eventSink = EventsRecorder(expectEvents = false), + ), + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + EditRoomAddressView( + state = state, + onBackClick = onBackClick, + ) + } +} diff --git a/features/securityandprivacy/test/build.gradle.kts b/features/securityandprivacy/test/build.gradle.kts new file mode 100644 index 0000000..903ef6d --- /dev/null +++ b/features/securityandprivacy/test/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.securityandprivacy.test" +} + +dependencies { + implementation(projects.features.securityandprivacy.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.tests.testutils) +} diff --git a/features/securityandprivacy/test/src/main/kotlin/io/element/android/features/securityandprivacy/test/FakeSecurityAndPrivacyEntryPoint.kt b/features/securityandprivacy/test/src/main/kotlin/io/element/android/features/securityandprivacy/test/FakeSecurityAndPrivacyEntryPoint.kt new file mode 100644 index 0000000..f316b2f --- /dev/null +++ b/features/securityandprivacy/test/src/main/kotlin/io/element/android/features/securityandprivacy/test/FakeSecurityAndPrivacyEntryPoint.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.securityandprivacy.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeSecurityAndPrivacyEntryPoint : SecurityAndPrivacyEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext): Node { + lambdaError() + } +} diff --git a/features/share/api/build.gradle.kts b/features/share/api/build.gradle.kts new file mode 100644 index 0000000..06e2b94 --- /dev/null +++ b/features/share/api/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.share.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt new file mode 100644 index 0000000..4d8eef9 --- /dev/null +++ b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.api + +import android.content.Intent +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 ShareEntryPoint : FeatureEntryPoint { + data class Params(val intent: Intent) + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + callback: Callback, + ): Node + + interface Callback : Plugin { + fun onDone(roomIds: List) + } +} diff --git a/features/share/impl/build.gradle.kts b/features/share/impl/build.gradle.kts new file mode 100644 index 0000000..7374809 --- /dev/null +++ b/features/share/impl/build.gradle.kts @@ -0,0 +1,52 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.share.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.appconfig) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.featureflag.api) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.mediaupload.api) + implementation(projects.libraries.roomselect.api) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.testtags) + implementation(projects.services.appnavstate.api) + api(libs.statemachine) + api(projects.features.share.api) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.mediaupload.test) + testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.roomselect.test) + testImplementation(projects.services.appnavstate.impl) +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt new file mode 100644 index 0000000..a8ae4d7 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.share.api.ShareEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope + +@ContributesBinding(SessionScope::class) +class DefaultShareEntryPoint : ShareEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: ShareEntryPoint.Params, + callback: ShareEntryPoint.Callback, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf( + ShareNode.Inputs(intent = params.intent), + callback, + ) + ) + } +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareEvents.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareEvents.kt new file mode 100644 index 0000000..d0e246f --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareEvents.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +sealed interface ShareEvents { + data object ClearError : ShareEvents +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareIntentHandler.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareIntentHandler.kt new file mode 100644 index 0000000..9342ef6 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareIntentHandler.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.net.Uri +import android.os.Build +import androidx.core.content.IntentCompat +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.compat.queryIntentActivitiesCompat +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAny +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeApplication +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeFile +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeText +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +import io.element.android.libraries.di.annotations.ApplicationContext +import timber.log.Timber + +interface ShareIntentHandler { + data class UriToShare( + val uri: Uri, + val mimeType: String, + ) + + /** + * This methods aims to handle incoming share intents. + * + * @return true if it can handle the intent data, false otherwise + */ + suspend fun handleIncomingShareIntent( + intent: Intent, + onUris: suspend (List) -> Boolean, + onPlainText: suspend (String) -> Boolean, + ): Boolean +} + +@ContributesBinding(AppScope::class) +class DefaultShareIntentHandler( + @ApplicationContext private val context: Context, +) : ShareIntentHandler { + override suspend fun handleIncomingShareIntent( + intent: Intent, + onUris: suspend (List) -> Boolean, + onPlainText: suspend (String) -> Boolean, + ): Boolean { + val type = intent.resolveType(context) ?: return false + val uris = getIncomingUris(intent, type) + return when { + uris.isEmpty() && type == MimeTypes.PlainText -> handlePlainText(intent, onPlainText) + type.isMimeTypeImage() || + type.isMimeTypeVideo() || + type.isMimeTypeAudio() || + type.isMimeTypeApplication() || + type.isMimeTypeFile() || + type.isMimeTypeText() || + type.isMimeTypeAny() -> { + val result = onUris(uris) + revokeUriPermissions(uris.map { it.uri }) + result + } + else -> false + } + } + + private suspend fun handlePlainText(intent: Intent, onPlainText: suspend (String) -> Boolean): Boolean { + val content = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString() + return if (content?.isNotEmpty() == true) { + onPlainText(content) + } else { + false + } + } + + /** + * Use this function to retrieve files which are shared from another application or internally + * by using android.intent.action.SEND or android.intent.action.SEND_MULTIPLE actions. + */ + private fun getIncomingUris(intent: Intent, fallbackMimeType: String): List { + val uriList = mutableListOf() + if (intent.action == Intent.ACTION_SEND) { + IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) + ?.let { uriList.add(it) } + } else if (intent.action == Intent.ACTION_SEND_MULTIPLE) { + IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) + ?.let { uriList.addAll(it) } + } + val resInfoList: List = context.packageManager.queryIntentActivitiesCompat(intent, PackageManager.MATCH_DEFAULT_ONLY) + uriList.forEach { uri -> + resInfoList.forEach resolve@{ resolveInfo -> + val packageName: String = resolveInfo.activityInfo.packageName + // Replace implicit intent by an explicit to fix crash on some devices like Xiaomi. + // see https://juejin.cn/post/7031736325422186510 + try { + context.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (e: Exception) { + Timber.w(e, "Unable to grant Uri permission") + return@resolve + } + intent.action = null + intent.component = ComponentName(packageName, resolveInfo.activityInfo.name) + } + } + return uriList.map { uri -> + // The value in fallbackMimeType can be wrong, especially if several uris were received + // in the same intent (i.e. 'image/*'). We need to check the mime type of each uri. + val mimeType = context.contentResolver.getType(uri) ?: fallbackMimeType + ShareIntentHandler.UriToShare( + uri = uri, + mimeType = mimeType, + ) + } + } + + private fun revokeUriPermissions(uris: List) { + uris.forEach { uri -> + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.revokeUriPermission(context.packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } else { + context.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } catch (e: Exception) { + Timber.w(e, "Unable to revoke Uri permission") + } + } + } +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt new file mode 100644 index 0000000..b91c484 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +import android.content.Intent +import android.os.Parcelable +import androidx.compose.foundation.layout.Box +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.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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.share.api.ShareEntryPoint +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +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.roomselect.api.RoomSelectEntryPoint +import io.element.android.libraries.roomselect.api.RoomSelectMode +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +@AssistedInject +class ShareNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: SharePresenter.Factory, + private val roomSelectEntryPoint: RoomSelectEntryPoint, +) : ParentNode( + navModel = PermanentNavModel( + navTargets = setOf(NavTarget), + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + @Parcelize + object NavTarget : Parcelable + + data class Inputs(val intent: Intent) : NodeInputs + + private val inputs = inputs() + private val presenter = presenterFactory.create(inputs.intent) + private val callback: ShareEntryPoint.Callback = callback() + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + val callback = object : RoomSelectEntryPoint.Callback { + override fun onRoomSelected(roomIds: List) { + presenter.onRoomSelected(roomIds) + } + + override fun onCancel() { + callback.onDone(emptyList()) + } + } + + return roomSelectEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = RoomSelectEntryPoint.Params(mode = RoomSelectMode.Share), + callback = callback, + ) + } + + @Composable + override fun View(modifier: Modifier) { + Box(modifier = modifier) { + // Will render to room select screen + Children( + navModel = navModel, + ) + + val state = presenter.present() + ShareView( + state = state, + onShareSuccess = callback::onDone, + ) + } + } +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt new file mode 100644 index 0000000..4a4086e --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.architecture.AsyncAction +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.di.annotations.SessionCoroutineScope +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.JoinedRoom +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory +import io.element.android.services.appnavstate.api.ActiveRoomsHolder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +@AssistedInject +class SharePresenter( + @Assisted private val intent: Intent, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, + private val shareIntentHandler: ShareIntentHandler, + private val matrixClient: MatrixClient, + private val mediaSenderRoomFactory: MediaSenderRoomFactory, + private val activeRoomsHolder: ActiveRoomsHolder, + private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, +) : Presenter { + @AssistedFactory + fun interface Factory { + fun create(intent: Intent): SharePresenter + } + + private val shareActionState: MutableState>> = mutableStateOf(AsyncAction.Uninitialized) + + fun onRoomSelected(roomIds: List) { + sessionCoroutineScope.share(intent, roomIds) + } + + @Composable + override fun present(): ShareState { + fun handleEvent(event: ShareEvents) { + when (event) { + ShareEvents.ClearError -> shareActionState.value = AsyncAction.Uninitialized + } + } + + return ShareState( + shareAction = shareActionState.value, + eventSink = ::handleEvent, + ) + } + + private suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom? { + return activeRoomsHolder.getActiveRoom(matrixClient.sessionId) + ?.takeIf { it.roomId == roomId } + ?: matrixClient.getJoinedRoom(roomId) + } + + private fun CoroutineScope.share( + intent: Intent, + roomIds: List, + ) = launch { + suspend { + val result = shareIntentHandler.handleIncomingShareIntent( + intent, + onUris = { filesToShare -> + if (filesToShare.isEmpty()) { + false + } else { + roomIds + .map { roomId -> + val room = getJoinedRoom(roomId) ?: return@map false + val mediaSender = mediaSenderRoomFactory.create(room = room) + filesToShare + .map { fileToShare -> + val result = mediaSender.sendMedia( + uri = fileToShare.uri, + mimeType = fileToShare.mimeType, + mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), + ) + // If the coroutine was cancelled, destroy the room and rethrow the exception + val cancellationException = result.exceptionOrNull() as? CancellationException + if (cancellationException != null) { + if (activeRoomsHolder.getActiveRoomMatching(matrixClient.sessionId, roomId) == null) { + room.destroy() + } + throw cancellationException + } + result.isSuccess + } + .all { isSuccess -> isSuccess } + .also { + if (activeRoomsHolder.getActiveRoomMatching(matrixClient.sessionId, roomId) == null) { + room.destroy() + } + } + } + .all { it } + } + }, + onPlainText = { text -> + roomIds + .map { roomId -> + getJoinedRoom(roomId)?.liveTimeline?.sendMessage( + body = text, + htmlBody = null, + intentionalMentions = emptyList(), + )?.isSuccess.orFalse() + } + .all { it } + } + ) + if (!result) { + error("Failed to handle incoming share intent") + } + roomIds + }.runCatchingUpdatingState(shareActionState) + } +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt new file mode 100644 index 0000000..fd98d58 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId + +data class ShareState( + val shareAction: AsyncAction>, + val eventSink: (ShareEvents) -> Unit +) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStateProvider.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStateProvider.kt new file mode 100644 index 0000000..88f9d42 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStateProvider.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId + +open class ShareStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aShareState(), + aShareState( + shareAction = AsyncAction.Loading, + ), + aShareState( + shareAction = AsyncAction.Success( + listOf(RoomId("!room2:domain")), + ) + ), + aShareState( + shareAction = AsyncAction.Failure(RuntimeException("error")), + ), + ) +} + +fun aShareState( + shareAction: AsyncAction> = AsyncAction.Uninitialized, + eventSink: (ShareEvents) -> Unit = {} +) = ShareState( + shareAction = shareAction, + eventSink = eventSink +) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareView.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareView.kt new file mode 100644 index 0000000..ec5897b --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareView.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.core.RoomId + +@Composable +fun ShareView( + state: ShareState, + onShareSuccess: (List) -> Unit, +) { + AsyncActionView( + async = state.shareAction, + onSuccess = { + onShareSuccess(it) + }, + onErrorDismiss = { + state.eventSink(ShareEvents.ClearError) + }, + ) +} + +@PreviewsDayNight +@Composable +internal fun ShareViewPreview(@PreviewParameter(ShareStateProvider::class) state: ShareState) = ElementPreview { + ShareView( + state = state, + onShareSuccess = {} + ) +} diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt new file mode 100644 index 0000000..83a3292 --- /dev/null +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +import android.content.Intent +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.features.share.api.ShareEntryPoint +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DefaultShareEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() = runTest { + val entryPoint = DefaultShareEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + ShareNode( + buildContext = buildContext, + plugins = plugins, + presenterFactory = { createSharePresenter() }, + roomSelectEntryPoint = FakeRoomSelectEntryPoint(), + ) + } + val callback = object : ShareEntryPoint.Callback { + override fun onDone(roomIds: List) = lambdaError() + } + val params = ShareEntryPoint.Params( + intent = Intent(), + ) + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + params = params, + callback = callback, + ) + assertThat(result).isInstanceOf(ShareNode::class.java) + assertThat(result.plugins).contains(ShareNode.Inputs(params.intent)) + assertThat(result.plugins).contains(callback) + } +} diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareIntentHandler.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareIntentHandler.kt new file mode 100644 index 0000000..dbb9d1c --- /dev/null +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareIntentHandler.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +import android.content.Intent + +class FakeShareIntentHandler( + private val onIncomingShareIntent: suspend ( + Intent, + suspend (List) -> Boolean, + suspend (String) -> Boolean, + ) -> Boolean = { _, _, _ -> false }, +) : ShareIntentHandler { + override suspend fun handleIncomingShareIntent( + intent: Intent, + onUris: suspend (List) -> Boolean, + onPlainText: suspend (String) -> Boolean, + ): Boolean { + return onIncomingShareIntent(intent, onUris, onPlainText) + } +} diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt new file mode 100644 index 0000000..0df1ed7 --- /dev/null +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +import android.content.Intent +import android.net.Uri +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.test.A_MESSAGE +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.FakeJoinedRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory +import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.test.FakeMediaSender +import io.element.android.services.appnavstate.api.ActiveRoomsHolder +import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SharePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createSharePresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.shareAction.isUninitialized()).isTrue() + } + } + + @Test + fun `present - on room selected error then clear error`() = runTest { + val presenter = createSharePresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.shareAction.isUninitialized()).isTrue() + presenter.onRoomSelected(listOf(A_ROOM_ID)) + assertThat(awaitItem().shareAction.isLoading()).isTrue() + val failure = awaitItem() + assertThat(failure.shareAction.isFailure()).isTrue() + failure.eventSink.invoke(ShareEvents.ClearError) + assertThat(awaitItem().shareAction.isUninitialized()).isTrue() + } + } + + @Test + fun `present - on room selected ok`() = runTest { + val presenter = createSharePresenter( + shareIntentHandler = FakeShareIntentHandler { _, _, _ -> true } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.shareAction.isUninitialized()).isTrue() + presenter.onRoomSelected(listOf(A_ROOM_ID)) + assertThat(awaitItem().shareAction.isLoading()).isTrue() + val success = awaitItem() + assertThat(success.shareAction.isSuccess()).isTrue() + assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID))) + } + } + + @Test + fun `present - send text ok`() = runTest { + val joinedRoom = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendMessageLambda = { _, _, _ -> Result.success(Unit) } + }, + ) + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, joinedRoom) + } + val presenter = createSharePresenter( + matrixClient = matrixClient, + shareIntentHandler = FakeShareIntentHandler { _, _, onText -> + onText(A_MESSAGE) + } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.shareAction.isUninitialized()).isTrue() + presenter.onRoomSelected(listOf(A_ROOM_ID)) + assertThat(awaitItem().shareAction.isLoading()).isTrue() + val success = awaitItem() + assertThat(success.shareAction.isSuccess()).isTrue() + assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID))) + } + } + + @Test + fun `present - send media ok`() = runTest { + val sendMediaResult = lambdaRecorder> { Result.success(Unit) } + val joinedRoom = FakeJoinedRoom( + liveTimeline = FakeTimeline(), + ) + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, joinedRoom) + } + val mediaSender = FakeMediaSender( + sendMediaResult = sendMediaResult, + ) + val presenter = createSharePresenter( + matrixClient = matrixClient, + shareIntentHandler = FakeShareIntentHandler { _, onFile, _ -> + onFile( + listOf( + ShareIntentHandler.UriToShare( + uri = Uri.parse("content://image.jpg"), + mimeType = MimeTypes.Jpeg, + ) + ) + ) + }, + mediaSenderRoomFactory = MediaSenderRoomFactory { mediaSender }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.shareAction.isUninitialized()).isTrue() + presenter.onRoomSelected(listOf(A_ROOM_ID)) + assertThat(awaitItem().shareAction.isLoading()).isTrue() + val success = awaitItem() + assertThat(success.shareAction.isSuccess()).isTrue() + assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID))) + sendMediaResult.assertions().isCalledOnce() + } + } +} + +internal fun TestScope.createSharePresenter( + intent: Intent = Intent(), + shareIntentHandler: ShareIntentHandler = FakeShareIntentHandler(), + matrixClient: MatrixClient = FakeMatrixClient(), + activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(), + mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { FakeMediaSender() }, + mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), +): SharePresenter { + return SharePresenter( + intent = intent, + sessionCoroutineScope = this, + shareIntentHandler = shareIntentHandler, + matrixClient = matrixClient, + activeRoomsHolder = activeRoomsHolder, + mediaSenderRoomFactory = mediaSenderRoomFactory, + mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, + ) +} diff --git a/features/signedout/api/build.gradle.kts b/features/signedout/api/build.gradle.kts new file mode 100644 index 0000000..5503104 --- /dev/null +++ b/features/signedout/api/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.signedout.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/signedout/api/src/main/kotlin/io/element/android/features/signedout/api/SignedOutEntryPoint.kt b/features/signedout/api/src/main/kotlin/io/element/android/features/signedout/api/SignedOutEntryPoint.kt new file mode 100644 index 0000000..b5e490d --- /dev/null +++ b/features/signedout/api/src/main/kotlin/io/element/android/features/signedout/api/SignedOutEntryPoint.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.signedout.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.matrix.api.core.SessionId + +interface SignedOutEntryPoint : FeatureEntryPoint { + data class Params( + val sessionId: SessionId, + ) + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + ): Node +} diff --git a/features/signedout/impl/build.gradle.kts b/features/signedout/impl/build.gradle.kts new file mode 100644 index 0000000..3c8aac5 --- /dev/null +++ b/features/signedout/impl/build.gradle.kts @@ -0,0 +1,35 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.signedout.impl" +} + +setupDependencyInjection() + +dependencies { + api(projects.features.signedout.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) + + testCommonDependencies(libs) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.sessionStorage.test) +} diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/DefaultSignedOutEntryPoint.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/DefaultSignedOutEntryPoint.kt new file mode 100644 index 0000000..1a73c06 --- /dev/null +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/DefaultSignedOutEntryPoint.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.signedout.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.signedout.api.SignedOutEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultSignedOutEntryPoint : SignedOutEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: SignedOutEntryPoint.Params, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf(SignedOutNode.Inputs(params.sessionId)) + ) + } +} diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutEvents.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutEvents.kt new file mode 100644 index 0000000..3b78af9 --- /dev/null +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutEvents.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.signedout.impl + +sealed interface SignedOutEvents { + data object SignInAgain : SignedOutEvents +} diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutNode.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutNode.kt new file mode 100644 index 0000000..deebde3 --- /dev/null +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutNode.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.signedout.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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.matrix.api.core.SessionId + +@ContributesNode(AppScope::class) +@AssistedInject +class SignedOutNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: SignedOutPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + data class Inputs( + val sessionId: SessionId, + ) : NodeInputs + + private val inputs: Inputs = inputs() + private val presenter = presenterFactory.create(inputs.sessionId) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + SignedOutView( + state = state, + modifier = modifier + ) + } +} diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt new file mode 100644 index 0000000..1aa13d9 --- /dev/null +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.signedout.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +@AssistedInject +class SignedOutPresenter( + @Assisted private val sessionId: SessionId, + private val sessionStore: SessionStore, + private val buildMeta: BuildMeta, +) : Presenter { + @AssistedFactory + fun interface Factory { + fun create(sessionId: SessionId): SignedOutPresenter + } + + @Composable + override fun present(): SignedOutState { + val signedOutSession by remember { + sessionStore.sessionsFlow().map { sessions -> + sessions.firstOrNull { it.userId == sessionId.value } + } + }.collectAsState(initial = null) + val coroutineScope = rememberCoroutineScope() + + fun handleEvent(event: SignedOutEvents) { + when (event) { + SignedOutEvents.SignInAgain -> coroutineScope.launch { + sessionStore.removeSession(sessionId.value) + } + } + } + + return SignedOutState( + appName = buildMeta.applicationName, + signedOutSession = signedOutSession, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutState.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutState.kt new file mode 100644 index 0000000..bc1ee18 --- /dev/null +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutState.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.signedout.impl + +import io.element.android.libraries.sessionstorage.api.SessionData + +data class SignedOutState( + val appName: String, + val signedOutSession: SessionData?, + val eventSink: (SignedOutEvents) -> Unit, +) diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt new file mode 100644 index 0000000..396339a --- /dev/null +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStateProvider.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.signedout.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.sessionstorage.api.LoginType +import io.element.android.libraries.sessionstorage.api.SessionData + +open class SignedOutStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aSignedOutState(), + // Add other states here + ) +} + +private fun aSignedOutState() = SignedOutState( + appName = "AppName", + signedOutSession = aSessionData(), + eventSink = {}, +) + +private fun aSessionData( + sessionId: String = "@alice:server.org", + isTokenValid: Boolean = false, +): SessionData { + return SessionData( + userId = sessionId, + deviceId = "aDeviceId", + accessToken = "anAccessToken", + refreshToken = "aRefreshToken", + homeserverUrl = "aHomeserverUrl", + oidcData = null, + loginTimestamp = null, + isTokenValid = isTokenValid, + loginType = LoginType.UNKNOWN, + passphrase = null, + sessionPath = "/a/path/to/a/session", + cachePath = "/a/path/to/a/cache", + position = 0, + lastUsageIndex = 0, + userDisplayName = null, + userAvatarUrl = null, + ) +} diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutView.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutView.kt new file mode 100644 index 0000000..f89b36e --- /dev/null +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutView.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.signedout.impl + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +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.systemBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.BiasAlignment +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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +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.organisms.InfoListItem +import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism +import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun SignedOutView( + state: SignedOutState, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = { state.eventSink(SignedOutEvents.SignInAgain) }) + HeaderFooterPage( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + .imePadding(), + header = { SignedOutHeader(state) }, + content = { SignedOutContent() }, + footer = { + SignedOutFooter( + onSignInAgain = { state.eventSink(SignedOutEvents.SignInAgain) }, + ) + } + ) +} + +@Composable +private fun SignedOutHeader(state: SignedOutState) { + IconTitleSubtitleMolecule( + modifier = Modifier.padding(top = 60.dp, bottom = 12.dp), + title = stringResource(id = R.string.screen_signed_out_title), + subTitle = stringResource(id = R.string.screen_signed_out_subtitle, state.appName), + iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), + ) +} + +@Composable +private fun SignedOutContent() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = BiasAlignment( + horizontalBias = 0f, + verticalBias = -0.4f + ) + ) { + InfoListOrganism( + items = persistentListOf( + InfoListItem( + message = stringResource(id = R.string.screen_signed_out_reason_1), + iconVector = CompoundIcons.Lock(), + ), + InfoListItem( + message = stringResource(id = R.string.screen_signed_out_reason_2), + iconVector = CompoundIcons.Devices(), + ), + InfoListItem( + message = stringResource(id = R.string.screen_signed_out_reason_3), + iconVector = CompoundIcons.Block(), + ), + ), + textStyle = ElementTheme.typography.fontBodyMdMedium, + iconTint = ElementTheme.colors.iconSecondary, + ) + } +} + +@Composable +private fun SignedOutFooter( + onSignInAgain: () -> Unit, +) { + ButtonColumnMolecule { + Button( + text = stringResource(id = CommonStrings.action_sign_in_again), + onClick = onSignInAgain, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun SignedOutViewPreview( + @PreviewParameter(SignedOutStateProvider::class) state: SignedOutState, +) = ElementPreview { + SignedOutView( + state = state, + ) +} diff --git a/features/signedout/impl/src/main/res/values-be/translations.xml b/features/signedout/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..d95aa0d --- /dev/null +++ b/features/signedout/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,8 @@ + + + "Вы змянілі свой пароль у іншым сеансе" + "Вы выдалілі сеанс з іншага сеансу" + "Адміністратар вашага сервера ануляваў ваш доступ" + "Магчыма, вы выйшлі з сістэмы па адной з прычын, пералічаных ніжэй. Калі ласка, увайдзіце яшчэ раз, каб працягнуць выкарыстанне %s." + "Вы выйшлі з сістэмы" + diff --git a/features/signedout/impl/src/main/res/values-bg/translations.xml b/features/signedout/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..ca7b068 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,5 @@ + + + "Променили сте паролата си в друга сесия" + "Излезли сте" + diff --git a/features/signedout/impl/src/main/res/values-cs/translations.xml b/features/signedout/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..06419f3 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,8 @@ + + + "Změnili jste heslo v jiné relaci" + "Odstranili jste relaci z jiné relace" + "Správce vašeho serveru zrušil váš přístup" + "Je možné, že jste byli odhlášeni z některého z níže uvedených důvodů. Chcete-li pokračovat v používání %s, přihlaste se znovu." + "Jste odhlášeni" + diff --git a/features/signedout/impl/src/main/res/values-cy/translations.xml b/features/signedout/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..520a22d --- /dev/null +++ b/features/signedout/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,8 @@ + + + "Rydych chi wedi newid eich cyfrinair yn ystod sesiwn arall" + "Rydych chi wedi dileu\'r sesiwn o sesiwn arall" + "Mae gweinyddwr eich gweinydd wedi annilysu eich mynediad" + "Efallai eich bod wedi cael eich allgofnodi am un o\'r rhesymau sy\'n cael eu rhestru isod. Mewngofnodwch eto i barhau i ddefnyddio %s." + "Rydych wedi\'ch allgofnodi" + diff --git a/features/signedout/impl/src/main/res/values-da/translations.xml b/features/signedout/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..3bfc9f4 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,8 @@ + + + "Du har ændret din adgangskode på en anden session" + "Du har slettet sessionen fra en anden session" + "Din serveradministrator har lukket for din adgang" + "Du er muligvis blevet logget ud på grund af en af nedenstående årsager. Log ind igen for at fortsætte med at bruge%s." + "Du er logget ud" + diff --git a/features/signedout/impl/src/main/res/values-de/translations.xml b/features/signedout/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..d73c34d --- /dev/null +++ b/features/signedout/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,8 @@ + + + "Du hast dein Passwort in einer anderen Sitzung geändert" + "Du hast diese Sitzung aus einer anderen Sitzung gelöscht" + "Der Administrator deines Servers hat deinen Zugang ungültig gemacht" + "Möglicherweise wurdest du aus einem der folgenden Gründe abgemeldet. Bitte melde dich erneut an, um %s weiter zu nutzen." + "Du bist abgemeldet" + diff --git a/features/signedout/impl/src/main/res/values-el/translations.xml b/features/signedout/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..3df0bbd --- /dev/null +++ b/features/signedout/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,8 @@ + + + "Άλλαξες τον κωδικό πρόσβασής σου σε άλλη συνεδρία" + "Έχεις διαγράψει τη συνεδρία από άλλη συνεδρία" + "Ο διαχειριστής του διακομιστή σου έχει ακυρώσει την πρόσβασή σου" + "Μπορεί να έχεις αποσυνδεθεί για έναν από τους λόγους που αναφέρονται παρακάτω. Συνδέσου ξανά για να συνεχίσεις να χρησιμοποιείς %s." + "Έχεις αποσυνδεθεί" + diff --git a/features/signedout/impl/src/main/res/values-es/translations.xml b/features/signedout/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..2044f70 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,8 @@ + + + "Has cambiado tu contraseña en otra sesión" + "Has eliminado la sesión desde otra sesión" + "El administrador de tu servidor ha invalidado su acceso" + "Es posible que haya cerrado sesión por uno de los motivos que se enumeran a continuación. Por favor inicia sesión nuevamente para continuar usando %s." + "Has cerrado sesión" + diff --git a/features/signedout/impl/src/main/res/values-et/translations.xml b/features/signedout/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..5b51d99 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,8 @@ + + + "Sa oled muutnud oma salasõna ühes teises sessioonis" + "Sa oled kustutanud selle sessiooni ühest teisest oma sessioonist" + "Sinu koduserveri haldaja on sinu ligipääsu keelanud" + "Sa oled välja logitud ühel alltoodud põhjusel. Teenuse %s kasutamiseks palun logi uuesti sisse." + "Sa oled välja loginud" + diff --git a/features/signedout/impl/src/main/res/values-eu/translations.xml b/features/signedout/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..678a775 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,8 @@ + + + "Pasahitza beste saio batean aldatu duzu" + "Saioa beste saio batetik ezabatu duzu" + "Zerbitzariaren administratzaileak zure sarbidea baliogabetu du" + "Litekeena da saioa amaitu izana ondorengo arrazoietako bat dela eta. Hasi saioa berriro %s erabiltzen jarraitzeko." + "Saioa amaitu duzu" + diff --git a/features/signedout/impl/src/main/res/values-fa/translations.xml b/features/signedout/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..db3c1df --- /dev/null +++ b/features/signedout/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,8 @@ + + + "گذرواژه‌تان را در نشستی دیگر تغییر داده‌اید" + "این نشست را از نشستی دیگر حذف کرده‌اید" + "مدیر کارسازتان دسترسیتان را نامعتبر کرده است" + "ممکن است به یکی از دلایل ذکر شده در زیر از سیستم خارج شده باشید. لطفا برای ادامه استفاده از %s دوباره وارد شوید." + "خارج شده‌اید" + diff --git a/features/signedout/impl/src/main/res/values-fi/translations.xml b/features/signedout/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..023a2e8 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,8 @@ + + + "Olet vaihtanut salasanasi toisessa istunnossa" + "Olet poistanut istunnon toisesta istunnosta" + "Palvelimesi ylläpitäjä on mitätöinyt käyttöoikeutesi" + "Sinut on saatettu kirjata ulos jostakin alla luetellusta syystä. Kirjaudu uudelleen sisään jatkaaksesi %s -sovelluksen käyttöä." + "Sinut on kirjattu ulos" + diff --git a/features/signedout/impl/src/main/res/values-fr/translations.xml b/features/signedout/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..e093df8 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,8 @@ + + + "Le mot de passe de votre compte a été modifié sur un autre appareil" + "Cette session a été supprimée depuis un autre appareil" + "L’administrateur de votre serveur a révoqué votre accès." + "La déconnexion peut être due à une des raisons ci-dessous. Veuillez vous connecter à nouveau pour continuer à utiliser %s." + "Vous avez été déconnecté" + diff --git a/features/signedout/impl/src/main/res/values-hu/translations.xml b/features/signedout/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..2b8bd35 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,8 @@ + + + "Megváltoztatta a jelszavát egy másik munkamenetben" + "Törölte a munkamenetet egy másik munkamenetből" + "A kiszolgáló adminisztrátora érvénytelenítette a hozzáférését" + "Előfordulhat, hogy az alábbiakban felsorolt okok valamelyike miatt került kijelentkeztetésre. Jelentkezzen be újra, hogy folytatni tudja az %s használatát." + "Kijelentkezett" + diff --git a/features/signedout/impl/src/main/res/values-in/translations.xml b/features/signedout/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..7feb4b4 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,8 @@ + + + "Anda telah mengubah kata sandi Anda di sesi yang lain" + "Anda telah menghapus sesi dari sesi lain" + "Administrator homeserver Anda telah membatalkan akses Anda" + "Anda mungkin dikeluarkan karena alasan berikut. Silakan masuk lagi untuk melanjutkan menggunakan %s." + "Anda telah keluar" + diff --git a/features/signedout/impl/src/main/res/values-it/translations.xml b/features/signedout/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..fa6ade1 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,8 @@ + + + "Hai cambiato la password in un\'altra sessione" + "Hai eliminato la sessione da un\'altra" + "L\'amministratore del tuo server ha invalidato il tuo accesso" + "Potresti essere stato disconnesso per uno dei motivi elencati di seguito. Accedi di nuovo per continuare a usare %s." + "Sei disconnesso" + diff --git a/features/signedout/impl/src/main/res/values-ka/translations.xml b/features/signedout/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..37c8614 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,8 @@ + + + "თქვენ პაროლი შეცვალეთ სხვა სესიაში" + "თქვენ სესია წაშალეთ სხვა სესიიდან" + "თქვენი სერვერის ადმინისტრატორმა გააუქმა თქვენი წვდომა" + "ალბათ, თქვენ გამოხვედით ქვემოთ ჩამოთვლილი ერთ-ერთი მიზეზის გამო. გთხოვთ, შეხვიდეთ ანგარიშში, რათა გააგრძელოთ %s-ს გამოყენება." + "თქვენ ანგარიშიდან გამოსული ხართ" + diff --git a/features/signedout/impl/src/main/res/values-ko/translations.xml b/features/signedout/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..bb46d3a --- /dev/null +++ b/features/signedout/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,8 @@ + + + "다른 세션에서 비밀번호를 변경하셨습니다." + "다른 세션에서 세션을 삭제했습니다." + "귀하의 서버 관리자가 귀하의 액세스를 무효화했습니다." + "아래 나열된 이유 중 하나로 인해 로그아웃되었을 수 있습니다. 계속 사용하려면 다시 로그인하세요 %s ." + "로그아웃 되었습니다" + diff --git a/features/signedout/impl/src/main/res/values-lt/translations.xml b/features/signedout/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..4d29d11 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,4 @@ + + + "Esate atsijungę" + diff --git a/features/signedout/impl/src/main/res/values-nb/translations.xml b/features/signedout/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..58aa259 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,8 @@ + + + "Du har endret passordet ditt i en annen sesjon" + "Du har slettet sesjonen fra en annen sesjon" + "Serveradministratoren har ugyldiggjort tilgangen din" + "Du kan ha blitt logget ut av en av årsakene som er oppført nedenfor. Logg på igjen for å fortsette å bruke %s." + "Du er logget ut" + diff --git a/features/signedout/impl/src/main/res/values-nl/translations.xml b/features/signedout/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..90ee4ef --- /dev/null +++ b/features/signedout/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,8 @@ + + + "Je hebt je wachtwoord gewijzigd in een andere sessie" + "Je hebt deze sessie verwijderd in een andere sessie" + "De beheerder van je server heeft je toegang ongeldig gemaakt" + "Je bent mogelijk uitgelogd om een van de onderstaande redenen. Meld je opnieuw aan om %s te blijven gebruiken." + "Je bent uitgelogd" + diff --git a/features/signedout/impl/src/main/res/values-pl/translations.xml b/features/signedout/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..9b83e7d --- /dev/null +++ b/features/signedout/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,8 @@ + + + "Zmieniono hasło w innej sesji" + "Sesja została usunięta z innej sesji" + "Administrator serwera unieważnił Twój dostęp" + "Mogłeś zostać wylogowany z powodów wymienionych poniżej. Zaloguj się ponownie, aby dalej korzystać z %s." + "Zostałeś wylogowany" + diff --git a/features/signedout/impl/src/main/res/values-pt-rBR/translations.xml b/features/signedout/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..7c920f1 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,8 @@ + + + "Você alterou sua senha em outra sessão" + "Você apagou essa sessão através de outra sessão" + "O administrador do seu servidor invalidou seu acesso" + "Você pode ter sido desconectado por um dos motivos listados abaixo. Entre novamente para continuar usando o %s." + "Você está desconectado" + diff --git a/features/signedout/impl/src/main/res/values-pt/translations.xml b/features/signedout/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..95f0c98 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,8 @@ + + + "Alteraste a tua senha noutra sessão" + "Eliminaste esta sessão a partir de outra" + "O administrador do teu servidor invalidou o teu acesso" + "A tua sessão pode ter sido terminada por um dos motivos indicados abaixo. Inicia sessão novamente para continuares a utilizar a %s." + "Não tens sessão iniciada" + diff --git a/features/signedout/impl/src/main/res/values-ro/translations.xml b/features/signedout/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..4ece7a1 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,8 @@ + + + "V-ați schimbat parola într-o altă sesiune" + "Ați șters sesiunea dintr-o altă sesiune" + "Administratorul serverului dumneavoastra v-a invalidat accesul" + "Este posibil să fi fost deconectat din unul dintre motivele enumerate mai jos. Vă rugăm să vă conectați din nou pentru a continua utilizarea%s." + "Sunteți deconectat" + diff --git a/features/signedout/impl/src/main/res/values-ru/translations.xml b/features/signedout/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..d3fd510 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,8 @@ + + + "Вы изменили свой пароль в другой сессии" + "Вы удалили сессию из другой сессии" + "Администратор вашего сервера аннулировал ваш доступ" + "Возможно, вы вышли из системы по одной из причин, перечисленных ниже. Пожалуйста, войдите в систему еще раз, чтобы продолжить использование %s." + "Вы вышли из системы" + diff --git a/features/signedout/impl/src/main/res/values-sk/translations.xml b/features/signedout/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..1c0720b --- /dev/null +++ b/features/signedout/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,8 @@ + + + "Zmenili ste heslo pri inej relácii" + "Odstránili ste reláciu z inej relácie" + "Správca vášho servera vám zrušil váš prístup" + "Možno ste boli odhlásení z jedného z nižšie uvedených dôvodov. Ak chcete pokračovať v používaní %s, prihláste sa znova." + "Ste odhlásený" + diff --git a/features/signedout/impl/src/main/res/values-sv/translations.xml b/features/signedout/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..1b157b3 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,8 @@ + + + "Du har ändrat ditt lösenord på en annan session" + "Du har raderat sessionen från en annan session" + "Din servers administratör har ogiltigförklarat din åtkomst" + "Du kan ha loggats ut av någon av anledningarna nedan. Vänligen logga in igen för att fortsätta använda %s." + "Du är utloggad" + diff --git a/features/signedout/impl/src/main/res/values-tr/translations.xml b/features/signedout/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..0da098f --- /dev/null +++ b/features/signedout/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,8 @@ + + + "Başka bir oturumda şifrenizi değiştirdiniz" + "Oturumu başka bir oturumdan sildiniz" + "Sunucunuzun yöneticisi erişiminizi geçersiz kıldı" + "Aşağıda listelenen nedenlerden biri nedeniyle oturumunuz kapatılmış olabilir. %s kullanmaya devam etmek için lütfen tekrar oturum açın." + "Oturumunuz kapatıldı" + diff --git a/features/signedout/impl/src/main/res/values-uk/translations.xml b/features/signedout/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..ebf3645 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,8 @@ + + + "Ви змінили пароль під час іншого сеансу" + "Ви видалили сеанс з іншого сеансу" + "Адміністратор вашого сервера визнав ваш доступ недійсним" + "Можливо, ви вийшли з системи з однієї з причин, наведених нижче. Будь ласка, увійдіть знову, щоб продовжити використання %s." + "Ви вийшли з системи" + diff --git a/features/signedout/impl/src/main/res/values-ur/translations.xml b/features/signedout/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..3227c69 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,8 @@ + + + "آپ نے دوسرے جلسے میں اپنا لفظِ عبور تبدیل کر لیا ہے" + "آپ نے جلسہ کو دوسرے جلسے سے حذف کر دیا ہے" + "آپ کے خادم کے منتظم نے آپکی رسائی کو باطل کردیا" + "ہوسکتا ہے کہ آپ کو ذیل میں درج وجوہات میں سے کسی ایک کی وجہ سے خارج کیا گیا ہو۔ برائے مہربانی %s کا استعمال جاری رکھنے کے لئے دوبارہ داخل ہوں۔" + "آپ خارج ہوگئے ہیں" + diff --git a/features/signedout/impl/src/main/res/values-uz/translations.xml b/features/signedout/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..3d25b05 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,8 @@ + + + "Siz boshqa seansda parolingizni o\'zgartirdingiz" + "Siz seansni boshqa seansdan o\'chirib tashladingiz" + "Serveringiz administratori ruxsatingizni bekor qildi" + "Siz quyida sanab o‘tilgan sabablardan biri tufayli tizimdan chiqqan bo‘lishingiz mumkin. Foydalanishni davom ettirish uchun qayta kiring%s." + "Hisobingizdan chiqdingiz" + diff --git a/features/signedout/impl/src/main/res/values-zh-rTW/translations.xml b/features/signedout/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..4e9ddbd --- /dev/null +++ b/features/signedout/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,8 @@ + + + "在其他工作階段變更密碼" + "在另一個工作階段刪除了此工作階段" + "伺服器管理員撤銷了您的存取權限" + "您可能因為下列某個原因被登出了。請重新登入以繼續使用 %s。" + "您登出了" + diff --git a/features/signedout/impl/src/main/res/values-zh/translations.xml b/features/signedout/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..87c7620 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,8 @@ + + + "你在另一个会话中更改了密码" + "你已从其他会话中删除本会话" + "您的服务器管理员已禁止您访问" + "您可能因下列原因而被登出。请重新登录以继续使用 %s。" + "你已登出" + diff --git a/features/signedout/impl/src/main/res/values/localazy.xml b/features/signedout/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..f70b004 --- /dev/null +++ b/features/signedout/impl/src/main/res/values/localazy.xml @@ -0,0 +1,8 @@ + + + "You’ve changed your password on another session" + "You have deleted the session from another session" + "Your server’s administrator has invalidated your access" + "You might have been signed out for one of the reasons listed below. Please sign in again to continue using %s." + "You’re signed out" + diff --git a/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/DefaultSignedOutEntryPointTest.kt b/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/DefaultSignedOutEntryPointTest.kt new file mode 100644 index 0000000..9392563 --- /dev/null +++ b/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/DefaultSignedOutEntryPointTest.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.signedout.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.features.signedout.api.SignedOutEntryPoint +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultSignedOutEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultSignedOutEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + SignedOutNode( + buildContext = buildContext, + plugins = plugins, + presenterFactory = { sessionId -> + assertThat(sessionId).isEqualTo(A_SESSION_ID) + createSignedOutPresenter() + } + ) + } + val params = SignedOutEntryPoint.Params(A_SESSION_ID) + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + params = params, + ) + assertThat(result).isInstanceOf(SignedOutNode::class.java) + assertThat(result.plugins).contains(SignedOutNode.Inputs(params.sessionId)) + } +} diff --git a/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt b/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt new file mode 100644 index 0000000..519016b --- /dev/null +++ b/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.signedout.impl + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +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.test.AN_APPLICATION_NAME +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class SignedOutPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val aSessionData = aSessionData() + val sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData) + ) + val presenter = createSignedOutPresenter(sessionStore = sessionStore) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.appName).isEqualTo(AN_APPLICATION_NAME) + assertThat(initialState.signedOutSession).isEqualTo(aSessionData) + } + } + + @Test + fun `present - sign in again`() = runTest { + val aSessionData = aSessionData() + val sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData) + ) + val presenter = createSignedOutPresenter(sessionStore = sessionStore) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.signedOutSession).isEqualTo(aSessionData) + assertThat(sessionStore.getAllSessions()).isNotEmpty() + assertThat(sessionStore.numberOfSessions()).isEqualTo(1) + initialState.eventSink(SignedOutEvents.SignInAgain) + assertThat(awaitItem().signedOutSession).isNull() + assertThat(sessionStore.getAllSessions()).isEmpty() + assertThat(sessionStore.numberOfSessions()).isEqualTo(0) + } + } +} + +internal fun createSignedOutPresenter( + sessionId: SessionId = A_SESSION_ID, + sessionStore: SessionStore = InMemorySessionStore(), +): SignedOutPresenter { + return SignedOutPresenter( + sessionId = sessionId, + sessionStore = sessionStore, + buildMeta = aBuildMeta(applicationName = AN_APPLICATION_NAME), + ) +} diff --git a/features/space/api/build.gradle.kts b/features/space/api/build.gradle.kts new file mode 100644 index 0000000..e2b7bb9 --- /dev/null +++ b/features/space/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.space.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt b/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt new file mode 100644 index 0000000..48a0a55 --- /dev/null +++ b/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.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.architecture.NodeInputs +import io.element.android.libraries.matrix.api.core.RoomId + +interface SpaceEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + inputs: Inputs, + callback: Callback + ): Node + + data class Inputs( + val roomId: RoomId + ) : NodeInputs + + interface Callback : Plugin { + fun navigateToRoom(roomId: RoomId, viaParameters: List) + fun navigateToRoomMemberList() + } +} diff --git a/features/space/impl/build.gradle.kts b/features/space/impl/build.gradle.kts new file mode 100644 index 0000000..d212bac --- /dev/null +++ b/features/space/impl/build.gradle.kts @@ -0,0 +1,50 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.space.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + 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.api) + implementation(projects.services.analytics.api) + implementation(libs.coil.compose) + implementation(projects.libraries.featureflag.api) + implementation(projects.features.invite.api) + implementation(projects.libraries.previewutils) + api(projects.features.space.api) + + testCommonDependencies(libs, true) + testImplementation(projects.services.analytics.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.features.invite.test) +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt new file mode 100644 index 0000000..2dc32eb --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPoint.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.space.api.SpaceEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope + +@ContributesBinding(SessionScope::class) +class DefaultSpaceEntryPoint : SpaceEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + inputs: SpaceEntryPoint.Inputs, + callback: SpaceEntryPoint.Callback, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf(inputs, callback), + ) + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt new file mode 100644 index 0000000..036eab1 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.features.space.impl + +import android.os.Parcelable +import androidx.compose.material3.ExperimentalMaterial3Api +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.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.space.api.SpaceEntryPoint +import io.element.android.features.space.impl.di.SpaceFlowGraph +import io.element.android.features.space.impl.leave.LeaveSpaceNode +import io.element.android.features.space.impl.root.SpaceNode +import io.element.android.features.space.impl.settings.SpaceSettingsNode +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.DependencyInjectionGraphOwner +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.spaces.SpaceService +import kotlinx.parcelize.Parcelize + +@ContributesNode(RoomScope::class) +@AssistedInject +class SpaceFlowNode( + @Assisted val buildContext: BuildContext, + @Assisted plugins: List, + room: JoinedRoom, + spaceService: SpaceService, + graphFactory: SpaceFlowGraph.Factory, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +), DependencyInjectionGraphOwner { + private val callback: SpaceEntryPoint.Callback = callback() + private val spaceRoomList = spaceService.spaceRoomList(room.roomId) + override val graph = graphFactory.create(spaceRoomList) + + sealed interface NavTarget : Parcelable { + @Parcelize + data object Root : NavTarget + + @Parcelize + data object Settings : NavTarget + + @Parcelize + data object Leave : NavTarget + } + + override fun onBuilt() { + super.onBuilt() + lifecycle.subscribe( + onDestroy = { + spaceRoomList.destroy() + } + ) + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Leave -> { + val callback = object : LeaveSpaceNode.Callback { + override fun closeLeaveSpaceFlow() { + backstack.pop() + } + + override fun navigateToRolesAndPermissions() { + // TODO + } + } + createNode(buildContext, listOf(callback)) + } + NavTarget.Root -> { + val callback = object : SpaceNode.Callback { + override fun navigateToRoom(roomId: RoomId, viaParameters: List) { + callback.navigateToRoom(roomId, viaParameters) + } + + override fun navigateToSpaceSettings() { + backstack.push(NavTarget.Settings) + } + + override fun navigateToRoomMemberList() { + callback.navigateToRoomMemberList() + } + + override fun startLeaveSpaceFlow() { + backstack.push(NavTarget.Leave) + } + } + createNode(buildContext, listOf(callback)) + } + NavTarget.Settings -> { + val callback = object : SpaceSettingsNode.Callback { + override fun closeSettings() { + backstack.pop() + } + + override fun navigateToSpaceInfo() { + // TODO + } + + override fun navigateToSpaceMembers() { + callback.navigateToRoomMemberList() + } + + override fun navigateToRolesAndPermissions() { + // TODO + } + + override fun navigateToSecurityAndPrivacy() { + // TODO + } + + override fun startLeaveSpaceFlow() { + backstack.push(NavTarget.Leave) + } + } + createNode(buildContext, listOf(callback)) + } + } + } + + @Composable + override fun View(modifier: Modifier) = BackstackView() +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/di/SpaceFlowGraph.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/di/SpaceFlowGraph.kt new file mode 100644 index 0000000..449c1eb --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/di/SpaceFlowGraph.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.di + +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.GraphExtension +import dev.zacsweers.metro.Provides +import io.element.android.libraries.architecture.NodeFactoriesBindings +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList + +@GraphExtension(SpaceFlowScope::class) +interface SpaceFlowGraph : NodeFactoriesBindings { + @ContributesTo(RoomScope::class) + @GraphExtension.Factory + interface Factory { + fun create(@Provides spaceRoomList: SpaceRoomList): SpaceFlowGraph + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/di/SpaceFlowScope.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/di/SpaceFlowScope.kt new file mode 100644 index 0000000..e3dce49 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/di/SpaceFlowScope.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.di + +abstract class SpaceFlowScope private constructor() diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceEvents.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceEvents.kt new file mode 100644 index 0000000..8bc96c4 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceEvents.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.leave + +import io.element.android.libraries.matrix.api.core.RoomId + +sealed interface LeaveSpaceEvents { + data object Retry : LeaveSpaceEvents + data object SelectAllRooms : LeaveSpaceEvents + data object DeselectAllRooms : LeaveSpaceEvents + data class ToggleRoomSelection(val roomId: RoomId) : LeaveSpaceEvents + data object LeaveSpace : LeaveSpaceEvents + data object CloseError : LeaveSpaceEvents +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt new file mode 100644 index 0000000..c41acc0 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceNode.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.leave + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.space.impl.di.SpaceFlowScope +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.room.JoinedRoom + +@ContributesNode(SpaceFlowScope::class) +@AssistedInject +class LeaveSpaceNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + matrixClient: MatrixClient, + room: JoinedRoom, + presenterFactory: LeaveSpacePresenter.Factory, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun closeLeaveSpaceFlow() + fun navigateToRolesAndPermissions() + } + + private val leaveSpaceHandle = matrixClient.spaceService.getLeaveSpaceHandle(room.roomId) + private val presenter: LeaveSpacePresenter = presenterFactory.create(leaveSpaceHandle) + + private val callback: Callback = callback() + + override fun onBuilt() { + super.onBuilt() + lifecycle.subscribe( + onDestroy = { + leaveSpaceHandle.close() + } + ) + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + LeaveSpaceView( + state = state, + onCancel = callback::closeLeaveSpaceFlow, + onRolesAndPermissionsClick = callback::navigateToRolesAndPermissions, + modifier = modifier + ) + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenter.kt new file mode 100644 index 0000000..a331aef --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenter.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.leave + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.map +import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle +import io.element.android.libraries.matrix.api.spaces.LeaveSpaceRoom +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@AssistedInject +class LeaveSpacePresenter( + @Assisted private val leaveSpaceHandle: LeaveSpaceHandle, +) : Presenter { + @AssistedFactory + fun interface Factory { + fun create(leaveSpaceHandle: LeaveSpaceHandle): LeaveSpacePresenter + } + + data class LeaveSpaceRooms( + val current: LeaveSpaceRoom?, + val others: List, + ) + + @Composable + override fun present(): LeaveSpaceState { + val coroutineScope = rememberCoroutineScope() + var retryCount by remember { mutableIntStateOf(0) } + val leaveSpaceAction = remember { + mutableStateOf>(AsyncAction.Uninitialized) + } + var selectedRoomIds by remember { + mutableStateOf>(setOf()) + } + var leaveSpaceRooms by remember { + mutableStateOf>(AsyncData.Loading()) + } + LaunchedEffect(retryCount) { + val rooms = leaveSpaceHandle.rooms() + val (currentRoom, otherRooms) = rooms.getOrNull() + .orEmpty() + .partition { it.spaceRoom.roomId == leaveSpaceHandle.id } + // By default select all rooms that can be left + val otherRoomsExcludingDm = otherRooms.filter { it.spaceRoom.isDirect != true } + selectedRoomIds = otherRoomsExcludingDm + .filter { it.isLastAdmin.not() } + .map { it.spaceRoom.roomId } + leaveSpaceRooms = rooms.fold( + onSuccess = { + AsyncData.Success( + LeaveSpaceRooms( + current = currentRoom.firstOrNull(), + others = otherRoomsExcludingDm.toImmutableList(), + ) + ) + }, + onFailure = { AsyncData.Failure(it) } + ) + } + var selectableSpaceRooms by remember { + mutableStateOf>>(AsyncData.Loading()) + } + LaunchedEffect(selectedRoomIds, leaveSpaceRooms) { + selectableSpaceRooms = leaveSpaceRooms.map { + it.others.map { room -> + SelectableSpaceRoom( + spaceRoom = room.spaceRoom, + isLastAdmin = room.isLastAdmin, + isSelected = selectedRoomIds.contains(room.spaceRoom.roomId), + ) + }.toImmutableList() + } + } + + fun handleEvent(event: LeaveSpaceEvents) { + when (event) { + LeaveSpaceEvents.Retry -> { + leaveSpaceRooms = AsyncData.Loading() + retryCount += 1 + } + LeaveSpaceEvents.DeselectAllRooms -> { + selectedRoomIds = persistentSetOf() + } + LeaveSpaceEvents.SelectAllRooms -> { + selectedRoomIds = selectableSpaceRooms.dataOrNull() + .orEmpty() + .filter { it.isLastAdmin.not() } + .map { it.spaceRoom.roomId } + } + is LeaveSpaceEvents.ToggleRoomSelection -> { + selectedRoomIds = if (selectedRoomIds.contains(event.roomId)) { + selectedRoomIds - event.roomId + } else { + selectedRoomIds + event.roomId + } + } + LeaveSpaceEvents.LeaveSpace -> coroutineScope.leaveSpace( + leaveSpaceAction = leaveSpaceAction, + selectedRoomIds = selectedRoomIds, + ) + LeaveSpaceEvents.CloseError -> { + leaveSpaceAction.value = AsyncAction.Uninitialized + } + } + } + + return LeaveSpaceState( + spaceName = leaveSpaceRooms.dataOrNull()?.current?.spaceRoom?.displayName, + isLastAdmin = leaveSpaceRooms.dataOrNull()?.current?.isLastAdmin == true, + selectableSpaceRooms = selectableSpaceRooms, + leaveSpaceAction = leaveSpaceAction.value, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.leaveSpace( + leaveSpaceAction: MutableState>, + selectedRoomIds: Collection, + ) = launch { + runUpdatingState(leaveSpaceAction) { + leaveSpaceHandle.leave(selectedRoomIds.toList()) + } + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceState.kt new file mode 100644 index 0000000..1e85014 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceState.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.leave + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +data class LeaveSpaceState( + val spaceName: String?, + val isLastAdmin: Boolean, + val selectableSpaceRooms: AsyncData>, + val leaveSpaceAction: AsyncAction, + val eventSink: (LeaveSpaceEvents) -> Unit, +) { + private val rooms = selectableSpaceRooms.dataOrNull().orEmpty().toImmutableList() + private val lastAdminRooms: ImmutableList + private val selectableRooms: ImmutableList + + init { + val partition = rooms.partition { it.isLastAdmin } + lastAdminRooms = partition.first.toImmutableList() + selectableRooms = partition.second.toImmutableList() + } + + /** + * True if we should show the quick action to select/deselect all rooms. + */ + val showQuickAction = isLastAdmin.not() && selectableRooms.isNotEmpty() + + /** + * True if we should show the leave button. + */ + val showLeaveButton = isLastAdmin.not() && selectableSpaceRooms is AsyncData.Success + + /** + * True if there all the selectable rooms are selected. + */ + val areAllSelected = selectableRooms.all { it.isSelected } + + /** + * True if there are rooms but the user is the last admin in all of them. + */ + val hasOnlyLastAdminRoom = lastAdminRooms.isNotEmpty() && selectableRooms.isEmpty() + + /** + * Number of selected rooms. + */ + val selectedRoomsCount = selectableRooms.count { it.isSelected } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateProvider.kt new file mode 100644 index 0000000..46d3e53 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateProvider.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.leave + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.previewutils.room.aSpaceRoom +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +class LeaveSpaceStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aLeaveSpaceState(), + aLeaveSpaceState( + spaceName = null, + selectableSpaceRooms = AsyncData.Success(persistentListOf()), + ), + aLeaveSpaceState( + selectableSpaceRooms = AsyncData.Success( + persistentListOf( + aSelectableSpaceRoom( + spaceRoom = aSpaceRoom( + displayName = "A long space name that should be truncated", + worldReadable = true, + ), + isLastAdmin = true, + ), + aSelectableSpaceRoom( + spaceRoom = aSpaceRoom( + joinRule = JoinRule.Private, + ), + isSelected = false, + ), + ) + ) + ), + aLeaveSpaceState( + selectableSpaceRooms = AsyncData.Success( + persistentListOf( + aSelectableSpaceRoom( + spaceRoom = aSpaceRoom( + worldReadable = true, + ), + isLastAdmin = true, + ), + aSelectableSpaceRoom( + spaceRoom = aSpaceRoom( + joinRule = JoinRule.Private, + ), + isSelected = true, + ), + ) + ) + ), + aLeaveSpaceState( + selectableSpaceRooms = AsyncData.Success( + persistentListOf( + aSelectableSpaceRoom( + spaceRoom = aSpaceRoom( + worldReadable = true, + ), + isLastAdmin = true, + ), + ) + ), + ), + aLeaveSpaceState( + selectableSpaceRooms = AsyncData.Success( + persistentListOf( + aSelectableSpaceRoom( + spaceRoom = aSpaceRoom( + worldReadable = true, + ), + isLastAdmin = true, + ), + aSelectableSpaceRoom( + spaceRoom = aSpaceRoom(), + isLastAdmin = true, + ), + ) + ), + ), + aLeaveSpaceState( + selectableSpaceRooms = AsyncData.Success( + List(10) { aSelectableSpaceRoom() }.toImmutableList() + ), + leaveSpaceAction = AsyncAction.Loading, + ), + aLeaveSpaceState( + selectableSpaceRooms = AsyncData.Success( + List(10) { aSelectableSpaceRoom() }.toImmutableList() + ), + leaveSpaceAction = AsyncAction.Failure(Exception("An error")), + ), + aLeaveSpaceState( + selectableSpaceRooms = AsyncData.Failure(Exception("An error")), + ), + aLeaveSpaceState( + isLastAdmin = true, + ), + ) +} + +fun aLeaveSpaceState( + spaceName: String? = "Space name", + isLastAdmin: Boolean = false, + selectableSpaceRooms: AsyncData> = AsyncData.Uninitialized, + leaveSpaceAction: AsyncAction = AsyncAction.Uninitialized, +) = LeaveSpaceState( + spaceName = spaceName, + isLastAdmin = isLastAdmin, + selectableSpaceRooms = selectableSpaceRooms, + leaveSpaceAction = leaveSpaceAction, + eventSink = { } +) + +fun aSelectableSpaceRoom( + spaceRoom: SpaceRoom = aSpaceRoom(), + isLastAdmin: Boolean = false, + isSelected: Boolean = false, +) = SelectableSpaceRoom( + spaceRoom = spaceRoom, + isLastAdmin = isLastAdmin, + isSelected = isSelected, +) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt new file mode 100644 index 0000000..02598f2 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt @@ -0,0 +1,363 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.features.space.impl.leave + +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.selection.toggleable +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.pluralStringResource +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.space.impl.R +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.async.AsyncFailure +import io.element.android.libraries.designsystem.components.async.AsyncLoading +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Checkbox +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconSource +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.room.join.JoinRule +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.ui.strings.CommonPlurals +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=3947-68767&t=GTf1cLkAf6UCQDan-0 + */ +@Composable +fun LeaveSpaceView( + state: LeaveSpaceState, + onCancel: () -> Unit, + onRolesAndPermissionsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + LeaveSpaceHeader( + state = state, + onBackClick = onCancel, + ) + }, + containerColor = ElementTheme.colors.bgCanvasDefault, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .imePadding() + .consumeWindowInsets(padding) + .fillMaxSize() + ) { + LazyColumn( + modifier = Modifier + .weight(1f), + ) { + if (state.isLastAdmin.not()) { + when (state.selectableSpaceRooms) { + is AsyncData.Success -> { + // List rooms where the user is the only admin + state.selectableSpaceRooms.data.forEach { selectableSpaceRoom -> + item { + SpaceItem( + selectableSpaceRoom = selectableSpaceRoom, + showCheckBox = state.hasOnlyLastAdminRoom.not(), + onClick = { + state.eventSink(LeaveSpaceEvents.ToggleRoomSelection(selectableSpaceRoom.spaceRoom.roomId)) + } + ) + } + } + } + is AsyncData.Failure -> item { + AsyncFailure( + throwable = state.selectableSpaceRooms.error, + onRetry = { + state.eventSink(LeaveSpaceEvents.Retry) + }, + ) + } + is AsyncData.Loading, + AsyncData.Uninitialized -> item { + AsyncLoading() + } + } + } + } + LeaveSpaceButtons( + showLeaveButton = state.showLeaveButton, + selectedRoomsCount = state.selectedRoomsCount, + onLeaveSpace = { + state.eventSink(LeaveSpaceEvents.LeaveSpace) + }, + onCancel = onCancel, + // TODO enable when navigation is ready + showRolesAndPermissionsButton = false, // state.isLastAdmin, + onRolesAndPermissionsClick = onRolesAndPermissionsClick, + ) + } + } + + AsyncActionView( + async = state.leaveSpaceAction, + onSuccess = { /* Nothing to do, the screen will be dismissed automatically */ }, + errorMessage = { stringResource(CommonStrings.error_unknown) }, + onErrorDismiss = { state.eventSink(LeaveSpaceEvents.CloseError) }, + ) +} + +@Composable +private fun LeaveSpaceHeader( + state: LeaveSpaceState, + onBackClick: () -> Unit, +) { + Column { + TopAppBar( + navigationIcon = { + BackButton(onClick = onBackClick) + }, + title = {}, + ) + IconTitleSubtitleMolecule( + modifier = Modifier.padding(top = 0.dp, bottom = 8.dp, start = 24.dp, end = 24.dp), + iconStyle = BigIcon.Style.AlertSolid, + title = stringResource( + if (state.isLastAdmin) R.string.screen_leave_space_title_last_admin else R.string.screen_leave_space_title, + state.spaceName ?: stringResource(CommonStrings.common_space) + ), + subTitle = + if (state.isLastAdmin) { + stringResource(R.string.screen_leave_space_subtitle_last_admin) + } else if (state.selectableSpaceRooms is AsyncData.Success && state.selectableSpaceRooms.data.isNotEmpty()) { + if (state.hasOnlyLastAdminRoom) { + stringResource(R.string.screen_leave_space_subtitle_only_last_admin) + } else { + stringResource(R.string.screen_leave_space_subtitle) + } + } else { + null + }, + ) + if (state.showQuickAction) { + if (state.areAllSelected) { + QuickActionButton(CommonStrings.action_deselect_all) { + state.eventSink(LeaveSpaceEvents.DeselectAllRooms) + } + } else { + QuickActionButton(resId = CommonStrings.action_select_all) { + state.eventSink(LeaveSpaceEvents.SelectAllRooms) + } + } + } + } +} + +@Composable +private fun ColumnScope.QuickActionButton( + @StringRes resId: Int, + onClick: () -> Unit, +) { + Text( + modifier = Modifier + .align(Alignment.End) + .padding(end = 8.dp) + .clickable(onClick = onClick) + .padding(8.dp), + text = stringResource(resId), + color = ElementTheme.colors.textActionPrimary, + style = ElementTheme.typography.fontBodyMdMedium, + ) +} + +@Composable +private fun LeaveSpaceButtons( + showLeaveButton: Boolean, + selectedRoomsCount: Int, + onLeaveSpace: () -> Unit, + showRolesAndPermissionsButton: Boolean, + onRolesAndPermissionsClick: () -> Unit, + onCancel: () -> Unit, +) { + ButtonColumnMolecule( + modifier = Modifier.padding(16.dp) + ) { + if (showLeaveButton) { + val text = if (selectedRoomsCount > 0) { + pluralStringResource(R.plurals.screen_leave_space_submit, selectedRoomsCount, selectedRoomsCount) + } else { + stringResource(CommonStrings.action_leave_space) + } + Button( + modifier = Modifier.fillMaxWidth(), + text = text, + leadingIcon = IconSource.Vector(CompoundIcons.Leave()), + onClick = onLeaveSpace, + destructive = true, + ) + } + if (showRolesAndPermissionsButton) { + Button( + text = stringResource(CommonStrings.action_go_to_roles_and_permissions), + onClick = onRolesAndPermissionsClick, + modifier = Modifier.fillMaxWidth(), + leadingIcon = IconSource.Vector(CompoundIcons.Settings()), + ) + } + TextButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(CommonStrings.action_cancel), + onClick = onCancel, + ) + } +} + +@Composable +private fun SpaceItem( + selectableSpaceRoom: SelectableSpaceRoom, + showCheckBox: Boolean, + onClick: () -> Unit, +) { + val room = selectableSpaceRoom.spaceRoom + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 66.dp) + .toggleable( + value = selectableSpaceRoom.isSelected, + role = Role.Checkbox, + enabled = selectableSpaceRoom.isLastAdmin.not(), + onValueChange = { onClick() } + ) + .clickable( + enabled = selectableSpaceRoom.isLastAdmin.not(), + // TODO + onClickLabel = null, + role = Role.Checkbox, + onClick = onClick, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + modifier = Modifier.padding(horizontal = 16.dp), + avatarData = room.getAvatarData(AvatarSize.LeaveSpaceRoom), + avatarType = if (room.isSpace) AvatarType.Space() else AvatarType.Room(), + ) + Column( + modifier = Modifier.weight(1f), + ) { + Text( + modifier = Modifier + .padding(end = 16.dp), + text = room.displayName, + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyLgMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (room.joinRule == JoinRule.Private) { + // Picto for private + Icon( + modifier = Modifier + .size(16.dp) + .padding(end = 4.dp), + imageVector = CompoundIcons.LockSolid(), + contentDescription = null, + tint = ElementTheme.colors.iconTertiary, + ) + } else if (room.worldReadable) { + // Picto for world readable + Icon( + modifier = Modifier + .size(16.dp) + .padding(end = 4.dp), + imageVector = CompoundIcons.Public(), + contentDescription = null, + tint = ElementTheme.colors.iconTertiary, + ) + } + // Number of members + val membersCount = pluralStringResource( + CommonPlurals.common_member_count, + room.numJoinedMembers, + room.numJoinedMembers + ) + val subTitle = if (selectableSpaceRoom.isLastAdmin) { + stringResource(R.string.screen_leave_space_last_admin_info, membersCount) + } else { + membersCount + } + Text( + modifier = Modifier.padding(end = 16.dp), + text = subTitle, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdRegular, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (showCheckBox) { + Checkbox( + checked = selectableSpaceRoom.isSelected, + onCheckedChange = null, + enabled = selectableSpaceRoom.isLastAdmin.not(), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun LeaveSpaceViewPreview( + @PreviewParameter(LeaveSpaceStateProvider::class) state: LeaveSpaceState, +) = ElementPreview { + LeaveSpaceView( + state = state, + onCancel = {}, + onRolesAndPermissionsClick = {}, + ) +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/SelectableSpaceRoom.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/SelectableSpaceRoom.kt new file mode 100644 index 0000000..d7ed243 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/SelectableSpaceRoom.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.leave + +import io.element.android.libraries.matrix.api.spaces.SpaceRoom + +data class SelectableSpaceRoom( + val spaceRoom: SpaceRoom, + val isLastAdmin: Boolean, + val isSelected: Boolean, +) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceEvents.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceEvents.kt new file mode 100644 index 0000000..16a6ad1 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceEvents.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.root + +import io.element.android.libraries.matrix.api.spaces.SpaceRoom + +sealed interface SpaceEvents { + data object LoadMore : SpaceEvents + data class Join(val spaceRoom: SpaceRoom) : SpaceEvents + data object ClearFailures : SpaceEvents + data class AcceptInvite(val spaceRoom: SpaceRoom) : SpaceEvents + data class DeclineInvite(val spaceRoom: SpaceRoom) : SpaceEvents + + data class ShowTopicViewer(val topic: String) : SpaceEvents + data object HideTopicViewer : SpaceEvents +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt new file mode 100644 index 0000000..40fa127 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.root + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.lifecycleScope +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView +import io.element.android.features.space.impl.di.SpaceFlowScope +import io.element.android.libraries.androidutils.R +import io.element.android.libraries.androidutils.system.startSharePlainTextIntent +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.launch +import timber.log.Timber + +@ContributesNode(SpaceFlowScope::class) +@AssistedInject +class SpaceNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: SpacePresenter, + private val matrixClient: MatrixClient, + private val spaceRoomList: SpaceRoomList, + private val acceptDeclineInviteView: AcceptDeclineInviteView, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun navigateToRoom(roomId: RoomId, viaParameters: List) + fun navigateToSpaceSettings() + fun navigateToRoomMemberList() + fun startLeaveSpaceFlow() + } + + private val callback: Callback = callback() + + private fun onShareRoom(context: Context) = lifecycleScope.launch { + matrixClient.getRoom(spaceRoomList.roomId)?.use { room -> + room.getPermalink() + .onSuccess { permalink -> + context.startSharePlainTextIntent( + activityResultLauncher = null, + chooserTitle = context.getString(CommonStrings.common_share_space), + text = permalink, + noActivityFoundMessage = context.getString(R.string.error_no_compatible_app_found) + ) + } + .onFailure { + Timber.e(it) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val context = LocalContext.current + SpaceView( + state = state, + onBackClick = ::navigateUp, + onLeaveSpaceClick = { + callback.startLeaveSpaceFlow() + }, + onRoomClick = { spaceRoom -> + callback.navigateToRoom(spaceRoom.roomId, spaceRoom.via) + }, + onDetailsClick = { + callback.navigateToSpaceSettings() + }, + onShareSpace = { + onShareRoom(context) + }, + onViewMembersClick = { + callback.navigateToRoomMemberList() + }, + acceptDeclineInviteView = { + acceptDeclineInviteView.Render( + state = state.acceptDeclineInviteState, + onAcceptInviteSuccess = { roomId -> + callback.navigateToRoom(roomId, emptyList()) + }, + onDeclineInviteSuccess = { roomId -> + // No action needed + }, + modifier = Modifier + ) + }, + modifier = modifier + ) + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt new file mode 100644 index 0000000..ce76aa4 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.root + +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState +import io.element.android.features.invite.api.toInviteData +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.mapState +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.join.JoinRoom +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlin.jvm.optionals.getOrNull + +@Inject +class SpacePresenter( + private val spaceRoomList: SpaceRoomList, + private val client: MatrixClient, + private val seenInvitesStore: SeenInvitesStore, + private val joinRoom: JoinRoom, + private val acceptDeclineInvitePresenter: Presenter, + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, +) : Presenter { + private var children by mutableStateOf>(persistentListOf()) + + @Composable + override fun present(): SpaceState { + LaunchedEffect(Unit) { + paginate() + spaceRoomList.spaceRoomsFlow.collect { children = it.toImmutableList() } + } + + val hideInvitesAvatar by client.rememberHideInvitesAvatar() + val seenSpaceInvites by remember { + seenInvitesStore.seenRoomIds().map { it.toImmutableSet() } + }.collectAsState(persistentSetOf()) + + val localCoroutineScope = rememberCoroutineScope() + + val hasMoreToLoad by remember { + spaceRoomList.paginationStatusFlow.mapState { status -> + when (status) { + is SpaceRoomList.PaginationStatus.Idle -> status.hasMoreToLoad + SpaceRoomList.PaginationStatus.Loading -> true + } + } + }.collectAsState() + + val currentSpace by spaceRoomList.currentSpaceFlow.collectAsState() + val (joinActions, setJoinActions) = remember { mutableStateOf(emptyMap>()) } + + var topicViewerState: TopicViewerState by remember { mutableStateOf(TopicViewerState.Hidden) } + + LaunchedEffect(children) { + // Remove joined children from the join actions + val joinedChildren = children + .filter { it.state == CurrentUserMembership.JOINED } + .map { it.roomId } + setJoinActions(joinActions - joinedChildren) + } + + val acceptDeclineInviteState = acceptDeclineInvitePresenter.present() + + fun handleEvent(event: SpaceEvents) { + when (event) { + SpaceEvents.LoadMore -> localCoroutineScope.paginate() + is SpaceEvents.Join -> { + sessionCoroutineScope.joinRoom(event.spaceRoom, joinActions, setJoinActions) + } + SpaceEvents.ClearFailures -> { + val failedActions = joinActions + .filterValues { it is AsyncAction.Failure } + .mapValues { AsyncAction.Uninitialized } + setJoinActions(joinActions + failedActions) + } + is SpaceEvents.AcceptInvite -> { + acceptDeclineInviteState.eventSink( + AcceptDeclineInviteEvents.AcceptInvite(event.spaceRoom.toInviteData()) + ) + } + is SpaceEvents.DeclineInvite -> { + acceptDeclineInviteState.eventSink( + AcceptDeclineInviteEvents.DeclineInvite(invite = event.spaceRoom.toInviteData(), shouldConfirm = true, blockUser = false) + ) + } + SpaceEvents.HideTopicViewer -> topicViewerState = TopicViewerState.Hidden + is SpaceEvents.ShowTopicViewer -> topicViewerState = TopicViewerState.Shown(event.topic) + } + } + return SpaceState( + currentSpace = currentSpace.getOrNull(), + children = children, + seenSpaceInvites = seenSpaceInvites, + hideInvitesAvatar = hideInvitesAvatar, + hasMoreToLoad = hasMoreToLoad, + joinActions = joinActions.toImmutableMap(), + acceptDeclineInviteState = acceptDeclineInviteState, + topicViewerState = topicViewerState, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.joinRoom( + spaceRoom: SpaceRoom, + joinActions: Map>, + setJoinActions: (Map>) -> Unit + ) = launch { + setJoinActions(joinActions + mapOf(spaceRoom.roomId to AsyncAction.Loading)) + joinRoom.invoke( + roomIdOrAlias = spaceRoom.roomId.toRoomIdOrAlias(), + serverNames = spaceRoom.via, + trigger = JoinedRoom.Trigger.SpaceHierarchy, + ).onFailure { + setJoinActions(joinActions + mapOf(spaceRoom.roomId to AsyncAction.Failure(it))) + } + } + + private fun CoroutineScope.paginate() = launch { + spaceRoomList.paginate() + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt new file mode 100644 index 0000000..031721e --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.root + +import androidx.compose.runtime.Immutable +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.ImmutableSet + +data class SpaceState( + val currentSpace: SpaceRoom?, + val children: ImmutableList, + val seenSpaceInvites: ImmutableSet, + val hideInvitesAvatar: Boolean, + val hasMoreToLoad: Boolean, + val joinActions: ImmutableMap>, + val acceptDeclineInviteState: AcceptDeclineInviteState, + val topicViewerState: TopicViewerState, + val eventSink: (SpaceEvents) -> Unit +) { + fun isJoining(spaceId: RoomId): Boolean = joinActions[spaceId] == AsyncAction.Loading + val hasAnyFailure: Boolean = joinActions.values.any { + it is AsyncAction.Failure + } +} + +@Immutable +sealed interface TopicViewerState { + data object Hidden : TopicViewerState + data class Shown(val topic: String) : TopicViewerState +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt new file mode 100644 index 0000000..bfb63c6 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.root + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState +import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.previewutils.room.aSpaceRoom +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.collections.immutable.toImmutableSet + +open class SpaceStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aSpaceState(), + aSpaceState(parentSpace = aParentSpace(joinRule = JoinRule.Public)), + aSpaceState(parentSpace = aParentSpace(joinRule = JoinRule.Restricted(persistentListOf()))), + aSpaceState(children = aListOfSpaceRooms()), + aSpaceState( + parentSpace = aParentSpace(), + children = aListOfSpaceRooms(), + joiningRooms = setOf(RoomId("!spaceId0:example.com")), + hasMoreToLoad = false + ), + aSpaceState( + topicViewerState = TopicViewerState.Shown(topic = "Space description goes here." + LoremIpsum(20).values.first()), + ), + // Add other states here + ) +} + +fun aSpaceState( + parentSpace: SpaceRoom? = aParentSpace(), + children: List = emptyList(), + seenSpaceInvites: Set = emptySet(), + joiningRooms: Set = emptySet(), + joinActions: Map> = joiningRooms.associateWith { AsyncAction.Loading }, + hideInvitesAvatar: Boolean = false, + hasMoreToLoad: Boolean = true, + acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(), + topicViewerState: TopicViewerState = TopicViewerState.Hidden, + eventSink: (SpaceEvents) -> Unit = { }, +) = SpaceState( + currentSpace = parentSpace, + children = children.toImmutableList(), + seenSpaceInvites = seenSpaceInvites.toImmutableSet(), + hideInvitesAvatar = hideInvitesAvatar, + hasMoreToLoad = hasMoreToLoad, + joinActions = joinActions.toImmutableMap(), + acceptDeclineInviteState = acceptDeclineInviteState, + topicViewerState = topicViewerState, + eventSink = eventSink, +) + +private fun aParentSpace( + joinRule: JoinRule? = null, +): SpaceRoom { + return aSpaceRoom( + numJoinedMembers = 5, + childrenCount = 10, + worldReadable = true, + joinRule = joinRule, + roomId = RoomId("!spaceId0:example.com"), + topic = "Space description goes here. " + LoremIpsum(20).values.first(), + ) +} + +private fun aListOfSpaceRooms(): List { + return listOf( + aSpaceRoom( + roomId = RoomId("!spaceId0:example.com"), + state = null, + ), + aSpaceRoom( + roomId = RoomId("!spaceId1:example.com"), + state = CurrentUserMembership.JOINED, + ), + aSpaceRoom( + roomId = RoomId("!spaceId2:example.com"), + state = CurrentUserMembership.INVITED, + ), + ) +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt new file mode 100644 index 0000000..cd1b3de --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt @@ -0,0 +1,427 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.root + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +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.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +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.remember +import androidx.compose.runtime.rememberUpdatedState +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.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule +import io.element.android.libraries.designsystem.components.ClickableLinkText +import io.element.android.libraries.designsystem.components.SimpleModalBottomSheet +import io.element.android.libraries.designsystem.components.async.AsyncIndicator +import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost +import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState +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.avatar.AvatarType +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +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.HorizontalDivider +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.CurrentUserMembership +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.ui.components.JoinButton +import io.element.android.libraries.matrix.ui.components.SpaceHeaderView +import io.element.android.libraries.matrix.ui.components.SpaceRoomItemView +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SpaceView( + state: SpaceState, + onBackClick: () -> Unit, + onRoomClick: (spaceRoom: SpaceRoom) -> Unit, + onShareSpace: () -> Unit, + onLeaveSpaceClick: () -> Unit, + onDetailsClick: () -> Unit, + onViewMembersClick: () -> Unit, + modifier: Modifier = Modifier, + acceptDeclineInviteView: @Composable () -> Unit, +) { + Scaffold( + modifier = modifier, + topBar = { + SpaceViewTopBar( + currentSpace = state.currentSpace, + onBackClick = onBackClick, + onLeaveSpaceClick = onLeaveSpaceClick, + onShareSpace = onShareSpace, + onDetailsClick = onDetailsClick, + onViewMembersClick = onViewMembersClick, + ) + }, + content = { padding -> + Box( + modifier = Modifier.padding(padding) + ) { + SpaceViewContent( + state = state, + onRoomClick = onRoomClick, + onTopicClick = { topic -> + state.eventSink(SpaceEvents.ShowTopicViewer(topic)) + } + ) + JoinRoomFailureEffect( + hasAnyFailure = state.hasAnyFailure, + eventSink = state.eventSink + ) + acceptDeclineInviteView() + } + }, + ) + if (state.topicViewerState is TopicViewerState.Shown) { + TopicViewerBottomSheet( + topicViewerState = state.topicViewerState, + onDismiss = { + state.eventSink(SpaceEvents.HideTopicViewer) + } + ) + } +} + +@Composable +private fun JoinRoomFailureEffect( + hasAnyFailure: Boolean, + eventSink: (SpaceEvents) -> Unit, +) { + val asyncIndicatorState = rememberAsyncIndicatorState() + val updatedEventSink by rememberUpdatedState(eventSink) + AsyncIndicatorHost(modifier = Modifier, asyncIndicatorState) + LaunchedEffect(hasAnyFailure) { + if (hasAnyFailure) { + asyncIndicatorState.enqueue { + AsyncIndicator.Failure(text = stringResource(CommonStrings.common_something_went_wrong)) + } + delay(AsyncIndicator.DURATION_SHORT) + updatedEventSink(SpaceEvents.ClearFailures) + } else { + asyncIndicatorState.clear() + } + } +} + +@Composable +private fun TopicViewerBottomSheet( + topicViewerState: TopicViewerState.Shown, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + SimpleModalBottomSheet( + title = stringResource(CommonStrings.common_description), + onDismiss = onDismiss, + modifier = modifier + ) { + ClickableLinkText( + text = topicViewerState.topic, + interactionSource = remember { MutableInteractionSource() }, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } +} + +@Composable +private fun SpaceViewContent( + state: SpaceState, + onRoomClick: (spaceRoom: SpaceRoom) -> Unit, + onTopicClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier.fillMaxSize()) { + val currentSpace = state.currentSpace + if (currentSpace != null) { + item { + SpaceHeaderView( + avatarData = currentSpace.getAvatarData(AvatarSize.SpaceHeader), + name = currentSpace.displayName, + topic = currentSpace.topic, + topicMaxLines = 2, + visibility = currentSpace.visibility, + heroes = currentSpace.heroes.toImmutableList(), + numberOfMembers = currentSpace.numJoinedMembers, + onTopicClick = onTopicClick + ) + } + item { + HorizontalDivider() + } + } + itemsIndexed( + items = state.children, + key = { _, spaceRoom -> spaceRoom.roomId } + ) { index, spaceRoom -> + val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED + val isCurrentlyJoining = state.isJoining(spaceRoom.roomId) + SpaceRoomItemView( + spaceRoom = spaceRoom, + showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites, + hideAvatars = isInvitation && state.hideInvitesAvatar, + onClick = { + onRoomClick(spaceRoom) + }, + onLongClick = { + // TODO + }, + trailingAction = spaceRoom.trailingAction(isCurrentlyJoining = isCurrentlyJoining) { + state.eventSink(SpaceEvents.Join(spaceRoom)) + }, + bottomAction = spaceRoom.inviteButtons( + onAcceptClick = { + state.eventSink(SpaceEvents.AcceptInvite(spaceRoom)) + }, + onDeclineClick = { + state.eventSink(SpaceEvents.DeclineInvite(spaceRoom)) + } + ) + ) + if (index != state.children.lastIndex) { + HorizontalDivider() + } + } + if (state.hasMoreToLoad) { + item { + LoadingMoreIndicator(eventSink = state.eventSink) + } + } + } +} + +@Composable +private fun LoadingMoreIndicator( + eventSink: (SpaceEvents) -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.padding(vertical = 8.dp) + ) + val latestEventSink by rememberUpdatedState(eventSink) + LaunchedEffect(Unit) { + latestEventSink(SpaceEvents.LoadMore) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SpaceViewTopBar( + currentSpace: SpaceRoom?, + onBackClick: () -> Unit, + onLeaveSpaceClick: () -> Unit, + onDetailsClick: () -> Unit, + onShareSpace: () -> Unit, + onViewMembersClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + modifier = modifier, + navigationIcon = { + BackButton(onClick = onBackClick) + }, + title = { + if (currentSpace != null) { + val roundedCornerShape = RoundedCornerShape(8.dp) + SpaceAvatarAndNameRow( + name = currentSpace.displayName, + avatarData = currentSpace.getAvatarData(AvatarSize.TimelineRoom), + modifier = Modifier + .clip(roundedCornerShape) + // TODO enable when screen ready for space + .clickable(enabled = false, onClick = onDetailsClick) + ) + } + }, + actions = { + var showMenu by remember { mutableStateOf(false) } + IconButton( + onClick = { showMenu = !showMenu } + ) { + Icon( + imageVector = CompoundIcons.OverflowVertical(), + contentDescription = null, + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + onClick = { + showMenu = false + onShareSpace() + }, + text = { Text(stringResource(id = CommonStrings.action_share)) }, + leadingIcon = { + Icon( + imageVector = CompoundIcons.ShareAndroid(), + tint = ElementTheme.colors.iconSecondary, + contentDescription = null, + ) + } + ) + DropdownMenuItem( + onClick = { + showMenu = false + onViewMembersClick() + }, + text = { Text(stringResource(id = CommonStrings.screen_space_menu_action_members)) }, + leadingIcon = { + Icon( + imageVector = CompoundIcons.User(), + tint = ElementTheme.colors.iconSecondary, + contentDescription = null, + ) + } + ) + DropdownMenuItem( + onClick = { + showMenu = false + onLeaveSpaceClick() + }, + text = { + Text( + text = stringResource(id = CommonStrings.action_leave_space), + color = ElementTheme.colors.textCriticalPrimary, + ) + }, + leadingIcon = { + Icon( + imageVector = CompoundIcons.Leave(), + tint = ElementTheme.colors.iconCriticalPrimary, + contentDescription = null, + ) + } + ) + } + }, + ) +} + +@Composable +private fun SpaceAvatarAndNameRow( + name: String?, + avatarData: AvatarData, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Avatar( + avatarData = avatarData, + avatarType = AvatarType.Space(), + ) + Text( + modifier = Modifier + .padding(horizontal = 8.dp) + .semantics { + heading() + }, + text = name ?: stringResource(CommonStrings.common_no_space_name), + style = ElementTheme.typography.fontBodyLgMedium, + fontStyle = FontStyle.Italic.takeIf { name == null }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +private fun SpaceRoom.trailingAction( + isCurrentlyJoining: Boolean, + onClick: () -> Unit +): @Composable (() -> Unit)? { + return when (state) { + null, CurrentUserMembership.LEFT -> { + { + JoinButton( + showProgress = isCurrentlyJoining, + onClick = onClick, + ) + } + } + else -> null + } +} + +private fun SpaceRoom.inviteButtons( + onAcceptClick: () -> Unit, + onDeclineClick: () -> Unit, +): @Composable (() -> Unit)? { + return when (state) { + CurrentUserMembership.INVITED -> { + @Composable { + InviteButtonsRowMolecule( + onAcceptClick = onAcceptClick, + onDeclineClick = onDeclineClick, + ) + } + } + else -> null + } +} + +@PreviewsDayNight +@Composable +internal fun SpaceViewPreview( + @PreviewParameter(SpaceStateProvider::class) state: SpaceState +) = ElementPreview { + SpaceView( + state = state, + onRoomClick = {}, + onShareSpace = {}, + onLeaveSpaceClick = {}, + acceptDeclineInviteView = {}, + onDetailsClick = {}, + onViewMembersClick = {}, + onBackClick = {}, + ) +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsEvents.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsEvents.kt new file mode 100644 index 0000000..3c2b1e6 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsEvents.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.settings + +sealed interface SpaceSettingsEvents diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsNode.kt new file mode 100644 index 0000000..ae2f485 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsNode.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.space.impl.di.SpaceFlowScope +import io.element.android.libraries.architecture.appyx.launchMolecule +import io.element.android.libraries.architecture.callback + +@ContributesNode(SpaceFlowScope::class) +@AssistedInject +class SpaceSettingsNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: SpaceSettingsPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun closeSettings() + + fun navigateToSpaceInfo() + fun navigateToSpaceMembers() + fun navigateToRolesAndPermissions() + fun navigateToSecurityAndPrivacy() + fun startLeaveSpaceFlow() + } + + private val callback: Callback = callback() + private val stateFlow = launchMolecule { presenter.present() } + + @Composable + override fun View(modifier: Modifier) { + val state by stateFlow.collectAsState() + SpaceSettingsView( + state = state, + modifier = modifier, + onSpaceInfoClick = callback::navigateToSpaceInfo, + onBackClick = callback::closeSettings, + onMembersClick = callback::navigateToSpaceMembers, + onRolesAndPermissionsClick = callback::navigateToRolesAndPermissions, + onSecurityAndPrivacyClick = callback::navigateToSecurityAndPrivacy, + onLeaveSpaceClick = callback::startLeaveSpaceFlow, + ) + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPresenter.kt new file mode 100644 index 0000000..5238914 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPresenter.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin + +@Inject +class SpaceSettingsPresenter( + private val room: JoinedRoom, +) : Presenter { + @Composable + override fun present(): SpaceSettingsState { + val roomInfo by room.roomInfoFlow.collectAsState() + val isUserAdmin = room.isOwnUserAdmin() + return SpaceSettingsState( + roomId = room.roomId, + name = roomInfo.name.orEmpty(), + canonicalAlias = roomInfo.canonicalAlias, + avatarUrl = roomInfo.avatarUrl, + memberCount = roomInfo.activeMembersCount, + showRolesAndPermissions = isUserAdmin, + showSecurityAndPrivacy = isUserAdmin, + eventSink = {}, + ) + } +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsState.kt new file mode 100644 index 0000000..40ceadc --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsState.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.settings + +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId + +data class SpaceSettingsState( + val roomId: RoomId, + val name: String, + val canonicalAlias: RoomAlias?, + val avatarUrl: String?, + val memberCount: Long, + val showRolesAndPermissions: Boolean, + val showSecurityAndPrivacy: Boolean, + val eventSink: (SpaceSettingsEvents) -> Unit +) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsStateProvider.kt new file mode 100644 index 0000000..2abe7ef --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsStateProvider.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.settings + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId + +open class SpaceSettingsStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aSpaceSettingsState(), + aSpaceSettingsState(alias = null), + aSpaceSettingsState(showSecurityAndPrivacy = true), + aSpaceSettingsState(showRolesAndPermissions = true), + ) +} + +fun aSpaceSettingsState( + roomId: RoomId = RoomId("!aRoomId:element.io"), + name: String = "Space name", + alias: RoomAlias? = RoomAlias("#spacename:element.io"), + avatarUrl: String? = null, + memberCount: Long = 100, + showRolesAndPermissions: Boolean = false, + showSecurityAndPrivacy: Boolean = false, + eventSink: (SpaceSettingsEvents) -> Unit = {}, +) = SpaceSettingsState( + roomId = roomId, + name = name, + canonicalAlias = alias, + avatarUrl = avatarUrl, + memberCount = memberCount, + showRolesAndPermissions = showRolesAndPermissions, + showSecurityAndPrivacy = showSecurityAndPrivacy, + eventSink = eventSink, +) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsView.kt new file mode 100644 index 0000000..379d75f --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsView.kt @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.space.impl.R +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.avatar.AvatarType +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +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.ui.strings.CommonStrings + +@Composable +fun SpaceSettingsView( + state: SpaceSettingsState, + onBackClick: () -> Unit, + onSpaceInfoClick: () -> Unit, + onMembersClick: () -> Unit, + onRolesAndPermissionsClick: () -> Unit, + onSecurityAndPrivacyClick: () -> Unit, + onLeaveSpaceClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + SpaceSettingsTopBar(onBackClick = onBackClick) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + SpaceInfoSection( + roomId = state.roomId, + name = state.name, + avatarUrl = state.avatarUrl, + canonicalAlias = state.canonicalAlias?.value, + onSpaceInfoClick = onSpaceInfoClick, + ) + Section(isVisible = state.showSecurityAndPrivacy, content = { + SecurityAndPrivacyItem( + onClick = onSecurityAndPrivacyClick + ) + }) + Section(content = { + MembersItem(state.memberCount, onClick = onMembersClick) + if (state.showRolesAndPermissions) { + RolesAndPermissionsItem(onClick = onRolesAndPermissionsClick) + } + }) + Section(content = { + LeaveSpaceItem( + onClick = onLeaveSpaceClick + ) + }) + } + } +} + +@Composable +private fun SpaceInfoSection( + roomId: RoomId, + name: String, + avatarUrl: String?, + canonicalAlias: String?, + onSpaceInfoClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onSpaceInfoClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + avatarData = AvatarData(roomId.value, name, avatarUrl, AvatarSize.SpaceListItem), + avatarType = AvatarType.Space(), + contentDescription = avatarUrl?.let { stringResource(CommonStrings.a11y_avatar) }, + ) + Spacer(Modifier.width(16.dp)) + Column { + Text( + text = name, + style = ElementTheme.typography.fontHeadingMdRegular, + color = ElementTheme.colors.textPrimary, + ) + if (canonicalAlias != null) { + Text( + text = canonicalAlias, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } + } +} + +@Composable +private fun Section( + modifier: Modifier = Modifier, + isVisible: Boolean = true, + content: @Composable ColumnScope.() -> Unit, +) { + if (isVisible) { + PreferenceCategory(content = content, modifier = modifier) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SpaceSettingsTopBar( + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + titleStr = stringResource(CommonStrings.common_settings), + navigationIcon = { BackButton(onClick = onBackClick) }, + modifier = modifier, + ) +} + +@Composable +private fun SecurityAndPrivacyItem( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { Text(stringResource(R.string.screen_space_settings_security_and_privacy)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), + onClick = onClick, + modifier = modifier, + ) +} + +@Composable +private fun MembersItem( + memberCount: Long, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { Text(stringResource(CommonStrings.common_people)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.User())), + trailingContent = ListItemContent.Text(memberCount.toString()), + onClick = onClick, + modifier = modifier, + ) +} + +@Composable +private fun RolesAndPermissionsItem( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { Text(stringResource(R.string.screen_space_settings_roles_and_permissions)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())), + onClick = onClick, + modifier = modifier, + ) +} + +@Composable +private fun LeaveSpaceItem( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { + Text(stringResource(CommonStrings.action_leave_space)) + }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Leave())), + style = ListItemStyle.Destructive, + onClick = onClick, + modifier = modifier, + ) +} + +@PreviewsDayNight +@Composable +internal fun SpaceSettingsViewPreview( + @PreviewParameter(SpaceSettingsStateProvider::class) state: SpaceSettingsState +) = ElementPreview { + SpaceSettingsView( + state = state, + onBackClick = {}, + onSpaceInfoClick = {}, + onMembersClick = {}, + onRolesAndPermissionsClick = {}, + onSecurityAndPrivacyClick = {}, + onLeaveSpaceClick = {}, + modifier = Modifier, + ) +} diff --git a/features/space/impl/src/main/res/values-be/translations.xml b/features/space/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..dbd39ab --- /dev/null +++ b/features/space/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,4 @@ + + + "Ролі і дазволы" + diff --git a/features/space/impl/src/main/res/values-bg/translations.xml b/features/space/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..0759934 --- /dev/null +++ b/features/space/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,6 @@ + + + "Напускане на пространството" + "Роли и разрешения" + "Защита и поверителност" + diff --git a/features/space/impl/src/main/res/values-cs/translations.xml b/features/space/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..d4730fc --- /dev/null +++ b/features/space/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,17 @@ + + + "%1$s (Správce)" + + "Opustit %1$d místnost a prostor" + "Opustit %1$d místnosti a prostor" + "Opustit %1$d místností a prostor" + + "Tím budete také odstraněni ze všech místností v tomto prostoru." + "Než budete moci odejít, musíte pro tento prostor přiřadit jiného správce." + "Z následujících místností nebudete odstraněni, protože jste jediným administrátorem:" + "Opustit %1$s?" + "Jste jediným administrátorem pro %1$s" + "Opustit prostor" + "Role a oprávnění" + "Zabezpečení a soukromí" + diff --git a/features/space/impl/src/main/res/values-cy/translations.xml b/features/space/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..987a41a --- /dev/null +++ b/features/space/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,17 @@ + + + "%1$s (Gweinyddwr)" + + "Gadael %1$d ystafelloedd a gofodau" + "Gadael %1$d ystafell a gofod" + "Gadael %1$d ystafell a gofod" + "Gadael %1$d ystafell a gofod" + "Gadael %1$d ystafell a gofod" + "Gadael %1$d ystafell a gofod" + + "Dewiswch yr ystafelloedd yr hoffech chi eu gadael nad chi yw\'r unig weinyddwr ar eu cyfer:" + "Gadael %1$s ?" + "Gadael y gofod" + "Rolau a chaniatâd" + "Diogelwch a phreifatrwydd" + diff --git a/features/space/impl/src/main/res/values-da/translations.xml b/features/space/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..6422b96 --- /dev/null +++ b/features/space/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,16 @@ + + + "%1$s (Admin)" + + "Forlad %1$d rum og gruppe" + "Forlad %1$d rum og Grupper" + + "Vælg de rum, du vil forlade, som du ikke er den eneste administrator for:" + "Du skal tildele en anden administrator til denne gruppe, før du kan forlade den." + "Du vil ikke blive fjernet fra følgende rum, fordi du er den eneste administrator:" + "Forlad %1$s?" + "Du er den eneste administrator for %1$s" + "Forlad gruppe" + "Roller og tilladelser" + "Sikkerhed og privatliv" + diff --git a/features/space/impl/src/main/res/values-de/translations.xml b/features/space/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..a001756 --- /dev/null +++ b/features/space/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,16 @@ + + + "%1$s (Admin)" + + "%1$d Chat und Space verlassen" + "%1$d Chats und Space verlassen" + + "Dadurch wirst du auch aus allen Chats in diesem Space entfernt." + "Du musst einen anderen Admin für diesen Space zuweisen, bevor du ihn verlassen kannst." + "Du wirst aus den folgenden Chats nicht entfernt, weil du der einzige Admin bist:" + "%1$s verlassen?" + "Du bist der einzige Administrator für %1$s" + "Space verlassen" + "Rollen und Berechtigungen" + "Sicherheit & Datenschutz" + diff --git a/features/space/impl/src/main/res/values-el/translations.xml b/features/space/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..144aae7 --- /dev/null +++ b/features/space/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,5 @@ + + + "Ρόλοι και δικαιώματα" + "Ασφάλεια & απόρρητο" + diff --git a/features/space/impl/src/main/res/values-es/translations.xml b/features/space/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..4188708 --- /dev/null +++ b/features/space/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,5 @@ + + + "Roles y permisos" + "Seguridad y privacidad" + diff --git a/features/space/impl/src/main/res/values-et/translations.xml b/features/space/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..43eaade --- /dev/null +++ b/features/space/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,16 @@ + + + "%1$s (Peakasutaja)" + + "Lahku %1$d-st jututoast ja kogukonnast" + "Lahku %1$d-st jututoast ja kogukonnast" + + "Sellega eemaldad end ka kõikidest antud kogukonna jututubadest." + "Enne lahkumist pead sa selle kogukonna jaoks lisama vähemalt ühe täiendava peakasutaja." + "Sind ei saa järgnevatest jututubadest eemaldada, kuna oled seal/neis ainus peakasutaja:" + "Kas lahkud %1$s kogukonnast?" + "Sa oled siin ainus peakasutaja: %1$s" + "Lahku kogukonnast" + "Rollid ja õigused" + "Turvalisus ja privaatsus" + diff --git a/features/space/impl/src/main/res/values-eu/translations.xml b/features/space/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..6124d56 --- /dev/null +++ b/features/space/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,5 @@ + + + "Rolak eta baimenak" + "Segurtasuna eta pribatutasuna" + diff --git a/features/space/impl/src/main/res/values-fa/translations.xml b/features/space/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..bda53d0 --- /dev/null +++ b/features/space/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,11 @@ + + + "‏%1$s (مدیر)" + "پیش از ترک باید مدیری دیگر به این فضا تخصیص دهید." + "از اتاق(های) زیر برداشته نخواهید شد؛ چرا که تنها مدیر هستید:" + "ترک %1$s؟" + "تنها مدیر %1$s هستید" + "ترک فضا" + "نقش‌ها و اجازه‌ها" + "امنیت و محرمانگی" + diff --git a/features/space/impl/src/main/res/values-fi/translations.xml b/features/space/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..e43a4ae --- /dev/null +++ b/features/space/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,16 @@ + + + "%1$s (Ylläpitäjä)" + + "Poistu %1$d huoneesta ja tilasta" + "Poistu %1$d huoneesta ja tilasta" + + "Tämä poistaa sinut myös kaikista tämän tilan huoneista." + "Sinun on valittava tälle tilalle toinen ylläpitäjä ennen kuin voit poistua." + "Sinua ei poisteta seuraavista huoneista, koska olet ainoa ylläpitäjä:" + "Haluatko poistua tilasta %1$s?" + "Olet ainoa ylläpitäjä tilassa %1$s" + "Poistu tilasta" + "Roolit ja oikeudet" + "Turvallisuus ja yksityisyys" + diff --git a/features/space/impl/src/main/res/values-fr/translations.xml b/features/space/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..befd4a7 --- /dev/null +++ b/features/space/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,16 @@ + + + "%1$s (Admin)" + + "Quitter %1$d salon et l’espace" + "Quitter %1$d salons et l’espace" + + "Sélectionnez les salons que vous souhaitez quitter et dont vous n’êtes pas le seul administrateur:" + "Vous devez désigner un autre administrateur pour cet espace avant de pouvoir partir." + "Vous ne quitterez pas le ou les salons suivants car vous y êtes le seul administrateur:" + "Quitter %1$s?" + "Vous êtes le seul administrateur de %1$s" + "Quitter l’espace" + "Rôles & autorisations" + "Sécurité & confidentialité" + diff --git a/features/space/impl/src/main/res/values-hu/translations.xml b/features/space/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..3ddbe6c --- /dev/null +++ b/features/space/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,16 @@ + + + "%1$s (Adminisztrátor)" + + "%1$d szoba és tér elhagyása" + "%1$d szoba és tér elhagyása" + + "Ez a tér összes szobájából is eltávolítja." + "Mielőtt elhagyhatná ezt a teret, ki kell jelölnie egy másik adminisztrátort." + "Nem lesz eltávolítva a következő szobá(k)ból, mert ön az egyetlen adminisztrátor:" + "Kilép innen: %1$s?" + "Ön az egyetlen adminisztrátor itt: %1$s" + "Tér elhagyása" + "Szerepkörök és jogosultságok" + "Biztonság és adatvédelem" + diff --git a/features/space/impl/src/main/res/values-in/translations.xml b/features/space/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..d505c16 --- /dev/null +++ b/features/space/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,5 @@ + + + "Peran dan perizinan" + "Keamanan & privasi" + diff --git a/features/space/impl/src/main/res/values-it/translations.xml b/features/space/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..e483f98 --- /dev/null +++ b/features/space/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,16 @@ + + + "%1$s (Amministratore)" + + "Lascia %1$d stanza e spazio" + "Lascia %1$d stanze e spazi" + + "Seleziona le stanze che desideri abbandonare e di cui non sei l\'unico amministratore:" + "Prima di poter uscire, devi assegnare un altro amministratore a questo spazio." + "Non verrai rimosso dalle seguenti stanze perché sei l\'unico amministratore:" + "Uscire da %1$s?" + "Sei l\'unico amministratore di %1$s" + "Esci dallo spazio" + "Ruoli e autorizzazioni" + "Sicurezza e privacy" + diff --git a/features/space/impl/src/main/res/values-ka/translations.xml b/features/space/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..6674642 --- /dev/null +++ b/features/space/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,4 @@ + + + "როლები და ნებართვები" + diff --git a/features/space/impl/src/main/res/values-ko/translations.xml b/features/space/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..7855147 --- /dev/null +++ b/features/space/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,5 @@ + + + "역할 및 권한" + "보안 및 개인정보 보호" + diff --git a/features/space/impl/src/main/res/values-nb/translations.xml b/features/space/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..0e0709f --- /dev/null +++ b/features/space/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,16 @@ + + + "%1$s (Admin)" + + "Forlat %1$d rom og område" + "Forlat %1$d rom og område" + + "Velg rommene du vil forlate, som du ikke er den eneste administratoren for:" + "Du må tildele en annen administrator for dette området før du kan forlate det." + "Du vil ikke bli fjernet fra følgende rom fordi du er den eneste administratoren:" + "Forlat %1$s?" + "Du er den eneste administratoren for %1$s" + "Forlat område" + "Roller og tillatelser" + "Sikkerhet og personvern" + diff --git a/features/space/impl/src/main/res/values-nl/translations.xml b/features/space/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..e5b9cf7 --- /dev/null +++ b/features/space/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,4 @@ + + + "Rollen en rechten" + diff --git a/features/space/impl/src/main/res/values-pl/translations.xml b/features/space/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..0c34e56 --- /dev/null +++ b/features/space/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,17 @@ + + + "%1$s (Administrator)" + + "Opuść %1$d pokój i przestrzeń" + "Opuść %1$d pokoje i przestrzeń" + "Opuść %1$d pokojów i przestrzeń" + + "Wybierz pokoje, które chcesz opuścić, a których nie jesteś jedynym administratorem:" + "Aby opuścić tę przestrzeń, musisz przypisać do niej innego administratora." + "Nie zostaniesz usunięty z następujących pokoi, ponieważ jesteś ich jedynym administratorem:" + "Opuścić %1$s?" + "Jesteś jedynym administratorem %1$s" + "Opuść przestrzeń" + "Role i uprawnienia" + "Bezpieczeństwo i prywatność" + diff --git a/features/space/impl/src/main/res/values-pt-rBR/translations.xml b/features/space/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..3329be1 --- /dev/null +++ b/features/space/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,16 @@ + + + "%1$s (Administrador)" + + "Sair de %1$d sala e espaço" + "Sair de %1$d salas e do espaço" + + "Selecione as salas que gostaria de sair nas quais você não é o único administrador:" + "Você precisa atribuir outro administrador para este espaço antes de sair." + "Você não será removido das seguintes salas porque você é o único administrador:" + "Sair de %1$s?" + "Você é o único administrador de %1$s" + "Sair do espaço" + "Cargos e permissões" + "Segurança e privacidade" + diff --git a/features/space/impl/src/main/res/values-pt/translations.xml b/features/space/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..71d4836 --- /dev/null +++ b/features/space/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,13 @@ + + + "%1$s (admin)" + + "Sair do espaço e de %1$d sala" + "Sair do espaço e de %1$d salas" + + "Também irás sair de todas as salas deste espaço." + "Sair de %1$s?" + "Sair do espaço" + "Cargos e permissões" + "Segurança e privacidade" + diff --git a/features/space/impl/src/main/res/values-ro/translations.xml b/features/space/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..588518a --- /dev/null +++ b/features/space/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,17 @@ + + + "%1$s (Admin)" + + "Părăsiți %1$d cameră și spațiul" + "Părăsiți %1$d camere și spațiul" + "Părăsiți %1$d camere și spațiul" + + "Selectați camerele pe care doriți să le părăsiți și în care nu sunteți singurul administrator:" + "Trebuie să desemnați un alt administrator pentru acest spațiu înainte de a-l părăsi." + "Nu veți părăsi următoarele camere deoarece sunteți singurul administrator:" + "Părăsiți %1$s?" + "Sunteți singurul administrator pentru %1$s" + "Părăsiți spațiul" + "Roluri și permisiuni" + "Securitate & confidențialitate" + diff --git a/features/space/impl/src/main/res/values-ru/translations.xml b/features/space/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..47cd467 --- /dev/null +++ b/features/space/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,17 @@ + + + "%1$s (Администратор)" + + "Покинуть %1$d комнату и пространство" + "Покинуть %1$d комнат и пространство" + "Покинуть %1$d комнат и пространство" + + "Выберите комнаты, которые вы хотите покинуть и в которых вы не являетесь единственным администратором:" + "Прежде чем покинуть это пространство, вам необходимо назначить другого администратора." + "Вы не будете удалены из следующих комнат, поскольку вы являетесь единственным администратором:" + "Выйти из %1$s?" + "Вы единственный администратор для %1$s" + "Покинуть пространство" + "Роли и разрешения" + "Безопасность и конфиденциальность" + diff --git a/features/space/impl/src/main/res/values-sk/translations.xml b/features/space/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..2fd11ba --- /dev/null +++ b/features/space/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,17 @@ + + + "%1$s (Správca)" + + "Opustiť %1$d miestnosť a priestor" + "Opustiť %1$d miestnosti a priestory" + "Opustiť %1$d miestností a priestorov" + + "Vyberte miestnosti, ktoré chcete opustiť a pre ktoré nie ste jediným správcom:" + "Pred odchodom musíte pre tento priestor určiť iného správcu." + "Z nasledujúcich miestností nebudete odstránený/á, pretože ste jediným správcom:" + "Opustiť %1$s?" + "Ste jediným administrátorom pre %1$s" + "Opustiť priestor" + "Roly a povolenia" + "Bezpečnosť a súkromie" + diff --git a/features/space/impl/src/main/res/values-sv/translations.xml b/features/space/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..d2dcf89 --- /dev/null +++ b/features/space/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,5 @@ + + + "Roller och behörigheter" + "Säkerhet och sekretess" + diff --git a/features/space/impl/src/main/res/values-tr/translations.xml b/features/space/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..99e4a98 --- /dev/null +++ b/features/space/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,5 @@ + + + "Roller ve izinler" + "Güvenlik ve gizlilik" + diff --git a/features/space/impl/src/main/res/values-uk/translations.xml b/features/space/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..d765264 --- /dev/null +++ b/features/space/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,5 @@ + + + "Ролі та дозволи" + "Безпека й приватність" + diff --git a/features/space/impl/src/main/res/values-ur/translations.xml b/features/space/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..827417f --- /dev/null +++ b/features/space/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,4 @@ + + + "کردارہا اور اجازتیں" + diff --git a/features/space/impl/src/main/res/values-uz/translations.xml b/features/space/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..8354009 --- /dev/null +++ b/features/space/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,5 @@ + + + "Rollar va ruxsatlar" + "Xavfsizlik va maxfiylik" + diff --git a/features/space/impl/src/main/res/values-zh-rTW/translations.xml b/features/space/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..abf4958 --- /dev/null +++ b/features/space/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,15 @@ + + + "%1$s(管理員)" + + "離開 %1$d 個聊天室與空間" + + "這也會將您從此空間中的所有聊天室移除。" + "您必須為此空間另外指定一位管理員後才能離開。" + "您不會被從以下聊天室移除,因為您是唯一的管理員:" + "離開 %1$s?" + "您是 %1$s 唯一的管理員" + "離開空間" + "角色與權限" + "安全與隱私" + diff --git a/features/space/impl/src/main/res/values-zh/translations.xml b/features/space/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..f0afff0 --- /dev/null +++ b/features/space/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,15 @@ + + + "%1$s (管理员)" + + "离开 %1$d 个房间和空间" + + "选择您想要离开且您不是其唯一管理员的房间:" + "您需要为该空间指定另一位管理员才能离开。" + "您不会从以下房间中被移除,因为您是唯一的管理员:" + "离开%1$s?" + "您是 %1$s 的唯一管理员" + "离开空间" + "角色与权限" + "安全与隐私" + diff --git a/features/space/impl/src/main/res/values/localazy.xml b/features/space/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..a4df5e7 --- /dev/null +++ b/features/space/impl/src/main/res/values/localazy.xml @@ -0,0 +1,16 @@ + + + "%1$s (Admin)" + + "Leave %1$d room and space" + "Leave %1$d rooms and space" + + "Select the rooms you’d like to leave which you\'re not the only administrator for:" + "You need to assign another admin for this space before you can leave." + "You will not be removed from the following room(s) because you\'re the only administrator:" + "Leave %1$s?" + "You are the only admin for %1$s" + "Leave space" + "Roles & permissions" + "Security & privacy" + diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt new file mode 100644 index 0000000..0e2717c --- /dev/null +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.features.space.api.SpaceEntryPoint +import io.element.android.features.space.impl.di.FakeSpaceFlowGraph +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList +import io.element.android.libraries.matrix.test.spaces.FakeSpaceService +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultSpaceEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultSpaceEntryPoint() + val nodeInputs = SpaceEntryPoint.Inputs(A_ROOM_ID) + val parentNode = TestParentNode.create { buildContext, plugins -> + SpaceFlowNode( + buildContext = buildContext, + plugins = plugins, + spaceService = FakeSpaceService( + spaceRoomListResult = { _: RoomId -> FakeSpaceRoomList(A_ROOM_ID) } + ), + room = FakeJoinedRoom(), + graphFactory = FakeSpaceFlowGraph.Factory + ) + } + val callback = object : SpaceEntryPoint.Callback { + override fun navigateToRoom(roomId: RoomId, viaParameters: List) = lambdaError() + override fun navigateToRoomMemberList() = lambdaError() + } + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + inputs = nodeInputs, + callback = callback, + ) + assertThat(result).isInstanceOf(SpaceFlowNode::class.java) + assertThat(result.plugins).contains(nodeInputs) + assertThat(result.plugins).contains(callback) + } +} diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/di/FakeSpaceFlowGraph.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/di/FakeSpaceFlowGraph.kt new file mode 100644 index 0000000..436e9ac --- /dev/null +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/di/FakeSpaceFlowGraph.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.di + +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.architecture.AssistedNodeFactory +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import kotlin.reflect.KClass + +class FakeSpaceFlowGraph : SpaceFlowGraph { + object Factory : SpaceFlowGraph.Factory { + override fun create(spaceRoomList: SpaceRoomList): SpaceFlowGraph { + return FakeSpaceFlowGraph() + } + } + + override fun nodeFactories(): Map, AssistedNodeFactory<*>> { + return emptyMap() + } +} diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenterTest.kt new file mode 100644 index 0000000..495f0b3 --- /dev/null +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpacePresenterTest.kt @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.leave + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle +import io.element.android.libraries.matrix.api.spaces.LeaveSpaceRoom +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +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_ID_2 +import io.element.android.libraries.matrix.test.A_ROOM_ID_3 +import io.element.android.libraries.matrix.test.A_SPACE_ID +import io.element.android.libraries.matrix.test.A_SPACE_NAME +import io.element.android.libraries.matrix.test.spaces.FakeLeaveSpaceHandle +import io.element.android.libraries.previewutils.room.aSpaceRoom +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LeaveSpacePresenterTest { + private val aSpace = aSpaceRoom( + roomId = A_SPACE_ID, + displayName = A_SPACE_NAME, + ) + + @Test + fun `present - initial state`() = runTest { + val presenter = createLeaveSpacePresenter( + leaveSpaceHandle = FakeLeaveSpaceHandle( + roomsResult = { Result.success(emptyList()) }, + ), + ) + presenter.test { + val state = awaitItem() + assertThat(state.spaceName).isNull() + assertThat(state.isLastAdmin).isFalse() + assertThat(state.selectableSpaceRooms.isLoading()).isTrue() + assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - fail to load rooms`() = runTest { + val presenter = createLeaveSpacePresenter( + leaveSpaceHandle = FakeLeaveSpaceHandle( + roomsResult = { Result.failure(AN_EXCEPTION) }, + ) + ) + presenter.test { + val state = awaitItem() + assertThat(state.selectableSpaceRooms.isLoading()).isTrue() + assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized) + skipItems(2) + val stateError = awaitItem() + assertThat(stateError.selectableSpaceRooms.isFailure()).isTrue() + // Retry + stateError.eventSink(LeaveSpaceEvents.Retry) + skipItems(1) + val stateLoadingAgain = awaitItem() + assertThat(stateLoadingAgain.selectableSpaceRooms.isLoading()).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - current space name and is last admin`() = runTest { + val presenter = createLeaveSpacePresenter( + leaveSpaceHandle = FakeLeaveSpaceHandle( + roomsResult = { Result.success(listOf(aLeaveSpaceRoom(spaceRoom = aSpace, isLastAdmin = true))) }, + ) + ) + presenter.test { + val state = awaitItem() + assertThat(state.spaceName).isNull() + skipItems(2) + val finalState = awaitItem() + assertThat(finalState.spaceName).isEqualTo(A_SPACE_NAME) + assertThat(finalState.isLastAdmin).isTrue() + // The current state is not in the sub room list + assertThat(finalState.selectableSpaceRooms.dataOrNull()!!).isEmpty() + } + } + + @Test + fun `present - direct rooms are filtered out`() = runTest { + val leaveResult = lambdaRecorder, Result> { Result.success(Unit) } + val presenter = createLeaveSpacePresenter( + leaveSpaceHandle = FakeLeaveSpaceHandle( + roomsResult = { + Result.success( + listOf( + aLeaveSpaceRoom(spaceRoom = aSpace), + aLeaveSpaceRoom( + spaceRoom = aSpaceRoom(roomId = A_ROOM_ID, isDirect = false) + ), + aLeaveSpaceRoom( + spaceRoom = aSpaceRoom(roomId = A_ROOM_ID_2, isDirect = true) + ), + aLeaveSpaceRoom( + spaceRoom = aSpaceRoom(roomId = A_ROOM_ID_3, isDirect = null) + ), + ) + ) + }, + leaveResult = leaveResult, + ) + ) + presenter.test { + val state = awaitItem() + assertThat(state.spaceName).isNull() + skipItems(2) + val finalState = awaitItem() + // The current state is not in the sub room list + assertThat(finalState.selectableSpaceRooms.dataOrNull()!!.map { it.spaceRoom.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_3) + assertThat(finalState.selectedRoomsCount).isEqualTo(2) + // Leaving the space will not include the DM + finalState.eventSink(LeaveSpaceEvents.LeaveSpace) + val stateLeaving = awaitItem() + assertThat(stateLeaving.leaveSpaceAction).isEqualTo(AsyncAction.Loading) + val stateLeft = awaitItem() + assertThat(stateLeft.leaveSpaceAction.isSuccess()).isTrue() + leaveResult.assertions().isCalledOnce().with( + value(listOf(A_ROOM_ID, A_ROOM_ID_3)) + ) + } + } + + @Test + fun `present - leave space and sub rooms`() = runTest { + val leaveResult = lambdaRecorder, Result> { Result.success(Unit) } + val presenter = createLeaveSpacePresenter( + leaveSpaceHandle = FakeLeaveSpaceHandle( + roomsResult = { + Result.success( + listOf( + LeaveSpaceRoom(aSpaceRoom(roomId = A_ROOM_ID), isLastAdmin = false), + LeaveSpaceRoom(aSpaceRoom(roomId = A_ROOM_ID_2), isLastAdmin = true), + ) + ) + }, + leaveResult = leaveResult, + ) + ) + presenter.test { + skipItems(3) + val state = awaitItem() + assertThat(state.spaceName).isNull() + assertThat(state.isLastAdmin).isFalse() + val data = state.selectableSpaceRooms.dataOrNull()!! + assertThat(data.size).isEqualTo(2) + // Only one room is selectable as the user is the last admin in the other one + val room1 = data[0] + assertThat(room1.spaceRoom.roomId).isEqualTo(A_ROOM_ID) + assertThat(room1.isSelected).isTrue() + assertThat(room1.isLastAdmin).isFalse() + val room2 = data[1] + assertThat(room2.spaceRoom.roomId).isEqualTo(A_ROOM_ID_2) + assertThat(room2.isSelected).isFalse() + assertThat(room2.isLastAdmin).isTrue() + // Deselect all + state.eventSink(LeaveSpaceEvents.DeselectAllRooms) + skipItems(1) + val stateAllDeselected = awaitItem() + val dataAllDeselected = stateAllDeselected.selectableSpaceRooms.dataOrNull()!! + assertThat(dataAllDeselected.any { it.isSelected }).isFalse() + // Select all + stateAllDeselected.eventSink(LeaveSpaceEvents.SelectAllRooms) + skipItems(1) + val stateAllSelected = awaitItem() + val dataAllSelected = stateAllSelected.selectableSpaceRooms.dataOrNull()!! + // The last admin room should not be selected + assertThat(dataAllSelected.count { it.isSelected }).isEqualTo(1) + // Toggle selection of the first room + stateAllSelected.eventSink(LeaveSpaceEvents.ToggleRoomSelection(A_ROOM_ID)) + skipItems(1) + val stateOneDeselected = awaitItem() + val dataOneDeselected = stateOneDeselected.selectableSpaceRooms.dataOrNull()!! + assertThat(dataOneDeselected[0].isSelected).isFalse() + // Toggle selection of the first room + stateOneDeselected.eventSink(LeaveSpaceEvents.ToggleRoomSelection(A_ROOM_ID)) + skipItems(1) + val stateOneSelected = awaitItem() + val dataOneSelected = stateOneSelected.selectableSpaceRooms.dataOrNull()!! + assertThat(dataOneSelected[0].isSelected).isTrue() + // Leave space + stateOneSelected.eventSink(LeaveSpaceEvents.LeaveSpace) + val stateLeaving = awaitItem() + assertThat(stateLeaving.leaveSpaceAction).isEqualTo(AsyncAction.Loading) + val stateLeft = awaitItem() + assertThat(stateLeft.leaveSpaceAction.isSuccess()).isTrue() + leaveResult.assertions().isCalledOnce().with( + value(listOf(A_ROOM_ID)) + ) + } + } + + @Test + fun `present - leave space error and close`() = runTest { + val leaveResult = lambdaRecorder, Result> { + Result.failure(AN_EXCEPTION) + } + val presenter = createLeaveSpacePresenter( + leaveSpaceHandle = FakeLeaveSpaceHandle( + roomsResult = { Result.success(emptyList()) }, + leaveResult = leaveResult, + ) + ) + presenter.test { + skipItems(3) + val state = awaitItem() + state.eventSink(LeaveSpaceEvents.LeaveSpace) + val stateLeaving = awaitItem() + assertThat(stateLeaving.leaveSpaceAction).isEqualTo(AsyncAction.Loading) + val stateError = awaitItem() + assertThat(stateError.leaveSpaceAction.isFailure()).isTrue() + // Close error + stateError.eventSink(LeaveSpaceEvents.CloseError) + val stateErrorClosed = awaitItem() + assertThat(stateErrorClosed.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + private fun createLeaveSpacePresenter( + leaveSpaceHandle: LeaveSpaceHandle = FakeLeaveSpaceHandle(), + ): LeaveSpacePresenter { + return LeaveSpacePresenter( + leaveSpaceHandle = leaveSpaceHandle, + ) + } +} + +private fun aLeaveSpaceRoom( + spaceRoom: SpaceRoom = aSpaceRoom( + roomId = A_SPACE_ID, + displayName = A_SPACE_NAME, + ), + isLastAdmin: Boolean = false, +) = LeaveSpaceRoom( + spaceRoom = spaceRoom, + isLastAdmin = isLastAdmin, +) diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateTest.kt new file mode 100644 index 0000000..d3b3f44 --- /dev/null +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceStateTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.leave + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncData +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import org.junit.Test + +class LeaveSpaceStateTest { + @Test + fun `test loading`() { + val sut = aLeaveSpaceState( + selectableSpaceRooms = AsyncData.Loading() + ) + assertThat(sut.showQuickAction).isFalse() + assertThat(sut.showLeaveButton).isFalse() + assertThat(sut.areAllSelected).isTrue() + assertThat(sut.hasOnlyLastAdminRoom).isFalse() + assertThat(sut.selectedRoomsCount).isEqualTo(0) + } + + @Test + fun `test no rooms`() { + val sut = aLeaveSpaceState( + selectableSpaceRooms = AsyncData.Success( + persistentListOf() + ) + ) + assertThat(sut.showQuickAction).isFalse() + assertThat(sut.showLeaveButton).isTrue() + assertThat(sut.areAllSelected).isTrue() + assertThat(sut.hasOnlyLastAdminRoom).isFalse() + assertThat(sut.selectedRoomsCount).isEqualTo(0) + } + + @Test + fun `test last admin`() { + val sut = aLeaveSpaceState( + isLastAdmin = true, + selectableSpaceRooms = AsyncData.Success( + persistentListOf( + aSelectableSpaceRoom(isLastAdmin = false, isSelected = false), + ) + ) + ) + assertThat(sut.showQuickAction).isFalse() + assertThat(sut.showLeaveButton).isFalse() + assertThat(sut.areAllSelected).isFalse() + assertThat(sut.hasOnlyLastAdminRoom).isFalse() + assertThat(sut.selectedRoomsCount).isEqualTo(0) + } + + @Test + fun `test no last admin, 1 selected, 1 not selected`() { + val sut = aLeaveSpaceState( + selectableSpaceRooms = AsyncData.Success( + listOf( + aSelectableSpaceRoom(isLastAdmin = false, isSelected = true), + aSelectableSpaceRoom(isLastAdmin = false, isSelected = false), + ).toImmutableList() + ) + ) + assertThat(sut.showQuickAction).isTrue() + assertThat(sut.showLeaveButton).isTrue() + assertThat(sut.areAllSelected).isFalse() + assertThat(sut.hasOnlyLastAdminRoom).isFalse() + assertThat(sut.selectedRoomsCount).isEqualTo(1) + } + + @Test + fun `test no last admin, 2 selected`() { + val sut = aLeaveSpaceState( + selectableSpaceRooms = AsyncData.Success( + listOf( + aSelectableSpaceRoom(isLastAdmin = false, isSelected = true), + aSelectableSpaceRoom(isLastAdmin = false, isSelected = true), + ).toImmutableList() + ) + ) + assertThat(sut.showQuickAction).isTrue() + assertThat(sut.showLeaveButton).isTrue() + assertThat(sut.areAllSelected).isTrue() + assertThat(sut.hasOnlyLastAdminRoom).isFalse() + assertThat(sut.selectedRoomsCount).isEqualTo(2) + } + + @Test + fun `test 1 last admin, 2 selected`() { + val sut = aLeaveSpaceState( + selectableSpaceRooms = AsyncData.Success( + persistentListOf( + aSelectableSpaceRoom(isLastAdmin = true, isSelected = false), + aSelectableSpaceRoom(isLastAdmin = false, isSelected = true), + aSelectableSpaceRoom(isLastAdmin = false, isSelected = true), + ) + ) + ) + assertThat(sut.showQuickAction).isTrue() + assertThat(sut.showLeaveButton).isTrue() + assertThat(sut.areAllSelected).isTrue() + assertThat(sut.hasOnlyLastAdminRoom).isFalse() + assertThat(sut.selectedRoomsCount).isEqualTo(2) + } + + @Test + fun `test only last admin`() { + val sut = aLeaveSpaceState( + selectableSpaceRooms = AsyncData.Success( + listOf( + aSelectableSpaceRoom(isLastAdmin = true, isSelected = false), + aSelectableSpaceRoom(isLastAdmin = true, isSelected = false), + ).toImmutableList() + ) + ) + assertThat(sut.showQuickAction).isFalse() + assertThat(sut.showLeaveButton).isTrue() + assertThat(sut.areAllSelected).isTrue() + assertThat(sut.hasOnlyLastAdminRoom).isTrue() + assertThat(sut.selectedRoomsCount).isEqualTo(0) + } +} diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt new file mode 100644 index 0000000..51e3634 --- /dev/null +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt @@ -0,0 +1,341 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.space.impl.root + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.invite.api.SeenInvitesStore +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents +import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState +import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState +import io.element.android.features.invite.api.toInviteData +import io.element.android.features.invite.test.InMemorySeenInvitesStore +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.join.JoinRoom +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +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_ID_2 +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom +import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList +import io.element.android.libraries.previewutils.room.aSpaceRoom +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import im.vector.app.features.analytics.plan.JoinedRoom as AnalyticsJoinedRoom + +class SpacePresenterTest { + @Test + fun `present - initial state`() = runTest { + val paginateResult = lambdaRecorder> { + Result.success(Unit) + } + val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult) + val presenter = createSpacePresenter(spaceRoomList = spaceRoomList) + presenter.test { + val state = awaitItem() + assertThat(state.currentSpace).isNull() + assertThat(state.children).isEmpty() + assertThat(state.seenSpaceInvites).isEmpty() + assertThat(state.hideInvitesAvatar).isFalse() + assertThat(state.hasMoreToLoad).isTrue() + assertThat(state.joinActions).isEmpty() + assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState()) + assertThat(state.topicViewerState).isEqualTo(TopicViewerState.Hidden) + advanceUntilIdle() + paginateResult.assertions().isCalledOnce() + } + } + + @Test + fun `present - load more`() = runTest { + val paginateResult = lambdaRecorder> { + Result.success(Unit) + } + val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult) + val presenter = createSpacePresenter(spaceRoomList = spaceRoomList) + presenter.test { + val state = awaitItem() + advanceUntilIdle() + paginateResult.assertions().isCalledOnce() + state.eventSink(SpaceEvents.LoadMore) + advanceUntilIdle() + paginateResult.assertions().isCalledExactly(2) + } + } + + @Test + fun `present - has more to load value`() = runTest { + val paginateResult = lambdaRecorder> { + Result.success(Unit) + } + val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult) + val presenter = createSpacePresenter(spaceRoomList = spaceRoomList) + presenter.test { + val state = awaitItem() + advanceUntilIdle() + assertThat(state.hasMoreToLoad).isTrue() + spaceRoomList.emitPaginationStatus( + SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false) + ) + assertThat(awaitItem().hasMoreToLoad).isFalse() + spaceRoomList.emitPaginationStatus( + SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = true) + ) + assertThat(awaitItem().hasMoreToLoad).isTrue() + } + } + + @Test + fun `present - current space value`() = runTest { + val paginateResult = lambdaRecorder> { + Result.success(Unit) + } + val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult) + val presenter = createSpacePresenter(spaceRoomList = spaceRoomList) + presenter.test { + val state = awaitItem() + advanceUntilIdle() + assertThat(state.currentSpace).isNull() + val aSpace = aSpaceRoom() + spaceRoomList.emitCurrentSpace(aSpace) + assertThat(awaitItem().currentSpace).isEqualTo(aSpace) + } + } + + @Test + fun `present - children value`() = runTest { + val paginateResult = lambdaRecorder> { + Result.success(Unit) + } + val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult) + val presenter = createSpacePresenter(spaceRoomList = spaceRoomList) + presenter.test { + val state = awaitItem() + advanceUntilIdle() + assertThat(state.children).isEmpty() + val aSpace = aSpaceRoom() + spaceRoomList.emitSpaceRooms(listOf(aSpace)) + assertThat(awaitItem().children).containsExactly(aSpace) + } + } + + @Test + fun `present - join a room success`() = runTest { + val joinRoom = lambdaRecorder, AnalyticsJoinedRoom.Trigger, Result> { _, _, _ -> + Result.success(Unit) + } + val serverNames = listOf("via1", "via2") + val aNotJoinedRoom = aSpaceRoom( + roomId = A_ROOM_ID_2, + via = serverNames, + state = null, + ) + val fakeSpaceRoomList = FakeSpaceRoomList( + initialSpaceRoomsValue = listOf( + aSpaceRoom( + roomId = A_ROOM_ID, + state = CurrentUserMembership.JOINED, + ), + aNotJoinedRoom, + ), + paginateResult = { Result.success(Unit) }, + ) + val presenter = createSpacePresenter( + spaceRoomList = fakeSpaceRoomList, + joinRoom = FakeJoinRoom( + lambda = joinRoom, + ), + ) + presenter.test { + skipItems(1) + val state = awaitItem() + assertThat(state.joinActions[A_ROOM_ID_2]).isNull() + state.eventSink(SpaceEvents.Join(aNotJoinedRoom)) + val joiningState = awaitItem() + assertThat(joiningState.joinActions[A_ROOM_ID_2]).isEqualTo(AsyncAction.Loading) + // Let the joinRoom call complete + advanceUntilIdle() + runCurrent() + // The room is joined + fakeSpaceRoomList.emitSpaceRooms( + listOf( + aSpaceRoom( + roomId = A_ROOM_ID, + state = CurrentUserMembership.JOINED, + ), + aNotJoinedRoom.copy(state = CurrentUserMembership.JOINED), + ) + ) + skipItems(1) + val joinedState = awaitItem() + // Joined room is removed from the join actions + assertThat(joinedState.joinActions).doesNotContainKey(A_ROOM_ID_2) + joinRoom.assertions().isCalledOnce().with( + value(A_ROOM_ID_2.toRoomIdOrAlias()), + value(serverNames), + value(AnalyticsJoinedRoom.Trigger.SpaceHierarchy), + ) + } + } + + @Test + fun `present - join a room failure`() = runTest { + val aNotJoinedRoom = aSpaceRoom( + roomId = A_ROOM_ID_2, + state = null, + ) + val fakeSpaceRoomList = FakeSpaceRoomList( + initialSpaceRoomsValue = listOf( + aSpaceRoom( + roomId = A_ROOM_ID, + state = CurrentUserMembership.JOINED, + ), + aNotJoinedRoom, + ), + paginateResult = { Result.success(Unit) }, + ) + val presenter = createSpacePresenter( + spaceRoomList = fakeSpaceRoomList, + joinRoom = FakeJoinRoom( + lambda = { _, _, _ -> Result.failure(AN_EXCEPTION) }, + ), + ) + presenter.test { + skipItems(1) + val state = awaitItem() + assertThat(state.joinActions[A_ROOM_ID_2]).isNull() + state.eventSink(SpaceEvents.Join(aNotJoinedRoom)) + val joiningState = awaitItem() + assertThat(joiningState.joinActions[A_ROOM_ID_2]).isEqualTo(AsyncAction.Loading) + val errorState = awaitItem() + // Joined room is removed from the join actions + assertThat(errorState.joinActions[A_ROOM_ID_2]!!.isFailure()).isTrue() + // Clear error + errorState.eventSink(SpaceEvents.ClearFailures) + val clearedState = awaitItem() + assertThat(clearedState.joinActions[A_ROOM_ID_2]).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - topic viewer state`() = runTest { + val paginateResult = lambdaRecorder> { + Result.success(Unit) + } + val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult) + val presenter = createSpacePresenter(spaceRoomList = spaceRoomList) + presenter.test { + val state = awaitItem() + assertThat(state.topicViewerState).isEqualTo(TopicViewerState.Hidden) + advanceUntilIdle() + state.eventSink(SpaceEvents.ShowTopicViewer("topic")) + assertThat(awaitItem().topicViewerState).isEqualTo(TopicViewerState.Shown("topic")) + state.eventSink(SpaceEvents.HideTopicViewer) + assertThat(awaitItem().topicViewerState).isEqualTo(TopicViewerState.Hidden) + } + } + + @Test + fun `present - accept invite is transmitted to acceptDeclineInviteState`() { + `invite action is transmitted to acceptDeclineInviteState`( + acceptInvite = true, + ) + } + + @Test + fun `present - decline invite is transmitted to acceptDeclineInviteState`() { + `invite action is transmitted to acceptDeclineInviteState`( + acceptInvite = false, + ) + } + + private fun `invite action is transmitted to acceptDeclineInviteState`( + acceptInvite: Boolean, + ) = runTest { + val eventRecorder = EventsRecorder() + val anInvitedRoom = aSpaceRoom( + roomId = A_ROOM_ID_2, + state = CurrentUserMembership.INVITED, + ) + val fakeSpaceRoomList = FakeSpaceRoomList( + initialSpaceRoomsValue = listOf( + aSpaceRoom( + roomId = A_ROOM_ID, + state = CurrentUserMembership.JOINED, + ), + anInvitedRoom, + ), + paginateResult = { Result.success(Unit) }, + ) + val presenter = createSpacePresenter( + spaceRoomList = fakeSpaceRoomList, + acceptDeclineInvitePresenter = { + anAcceptDeclineInviteState( + eventSink = eventRecorder, + ) + }, + ) + presenter.test { + skipItems(1) + val state = awaitItem() + assertThat(state.joinActions[A_ROOM_ID_2]).isNull() + if (acceptInvite) { + state.eventSink(SpaceEvents.AcceptInvite(anInvitedRoom)) + eventRecorder.assertSingle( + AcceptDeclineInviteEvents.AcceptInvite( + invite = anInvitedRoom.toInviteData(), + ) + ) + } else { + state.eventSink(SpaceEvents.DeclineInvite(anInvitedRoom)) + eventRecorder.assertSingle( + AcceptDeclineInviteEvents.DeclineInvite( + invite = anInvitedRoom.toInviteData(), + shouldConfirm = true, + blockUser = false, + ) + ) + } + } + } + + private fun TestScope.createSpacePresenter( + client: MatrixClient = FakeMatrixClient(), + spaceRoomList: SpaceRoomList = FakeSpaceRoomList(), + seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), + joinRoom: JoinRoom = FakeJoinRoom( + lambda = { _, _, _ -> Result.success(Unit) }, + ), + acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() }, + ): SpacePresenter { + return SpacePresenter( + client = client, + spaceRoomList = spaceRoomList, + seenInvitesStore = seenInvitesStore, + joinRoom = joinRoom, + acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, + sessionCoroutineScope = backgroundScope, + ) + } +} diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt new file mode 100644 index 0000000..440ec1b --- /dev/null +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.root + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +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_ID_2 +import io.element.android.libraries.matrix.test.A_ROOM_ID_3 +import org.junit.Test + +class SpaceStateTest { + @Test + fun `test default state`() { + val state = aSpaceState() + assertThat(state.hasAnyFailure).isFalse() + assertThat(state.isJoining(A_ROOM_ID)).isFalse() + } + + @Test + fun `test has failure`() { + val state = aSpaceState( + joinActions = mapOf( + A_ROOM_ID to AsyncAction.Uninitialized, + A_ROOM_ID_2 to AsyncAction.Failure(AN_EXCEPTION), + A_ROOM_ID_3 to AsyncAction.Success(Unit), + ) + ) + assertThat(state.hasAnyFailure).isTrue() + } + + @Test + fun `test isJoining`() { + val state = aSpaceState( + joinActions = mapOf( + A_ROOM_ID to AsyncAction.Loading, + ) + ) + assertThat(state.isJoining(A_ROOM_ID)).isTrue() + } +} diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt new file mode 100644 index 0000000..59e323a --- /dev/null +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.space.impl.root + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +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_ROOM_TOPIC +import io.element.android.libraries.previewutils.room.aSpaceRoom +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class SpaceViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setSpaceView( + aSpaceState( + hasMoreToLoad = false, + eventSink = eventsRecorder, + ), + onBackClick = it, + ) + rule.pressBack() + } + } + + @Test + fun `clicking on a room name invokes the expected callback`() { + val aSpaceRoom = aSpaceRoom(roomId = A_ROOM_ID, displayName = A_ROOM_NAME) + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnceWithParam(aSpaceRoom) { + rule.setSpaceView( + aSpaceState( + children = listOf(aSpaceRoom), + hasMoreToLoad = false, + eventSink = eventsRecorder, + ), + onRoomClick = it, + ) + rule.onNodeWithText(A_ROOM_NAME).performClick() + } + } + + @Test + fun `clicking on Join room emits the expected Event`() { + val aSpaceRoom = aSpaceRoom(roomId = A_ROOM_ID, state = null) + val eventsRecorder = EventsRecorder() + rule.setSpaceView( + aSpaceState( + children = listOf(aSpaceRoom), + hasMoreToLoad = false, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_join) + eventsRecorder.assertSingle(SpaceEvents.Join(aSpaceRoom)) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on accept invite emits the expected Event`() { + val aSpaceRoom = aSpaceRoom(roomId = A_ROOM_ID, state = CurrentUserMembership.INVITED) + val eventsRecorder = EventsRecorder() + rule.setSpaceView( + aSpaceState( + hasMoreToLoad = false, + children = listOf(aSpaceRoom), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_accept) + eventsRecorder.assertSingle(SpaceEvents.AcceptInvite(aSpaceRoom)) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on decline invite emits the expected Event`() { + val aSpaceRoom = aSpaceRoom(roomId = A_ROOM_ID, state = CurrentUserMembership.INVITED) + val eventsRecorder = EventsRecorder() + rule.setSpaceView( + aSpaceState( + hasMoreToLoad = false, + children = listOf(aSpaceRoom), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_decline) + eventsRecorder.assertSingle(SpaceEvents.DeclineInvite(aSpaceRoom)) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on topic emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setSpaceView( + aSpaceState( + parentSpace = aSpaceRoom(topic = A_ROOM_TOPIC), + hasMoreToLoad = false, + eventSink = eventsRecorder, + ) + ) + rule.onNodeWithText(A_ROOM_TOPIC).performClick() + eventsRecorder.assertSingle(SpaceEvents.ShowTopicViewer(A_ROOM_TOPIC)) + } +} + +private fun AndroidComposeTestRule.setSpaceView( + state: SpaceState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onRoomClick: (SpaceRoom) -> Unit = EnsureNeverCalledWithParam(), + onShareSpace: () -> Unit = EnsureNeverCalled(), + onLeaveSpaceClick: () -> Unit = EnsureNeverCalled(), + onDetailsClick: () -> Unit = EnsureNeverCalled(), + onViewMembersClick: () -> Unit = EnsureNeverCalled(), + acceptDeclineInviteView: @Composable () -> Unit = {}, +) { + setContent { + SpaceView( + state = state, + onBackClick = onBackClick, + onRoomClick = onRoomClick, + onShareSpace = onShareSpace, + onLeaveSpaceClick = onLeaveSpaceClick, + onDetailsClick = onDetailsClick, + onViewMembersClick = onViewMembersClick, + acceptDeclineInviteView = acceptDeclineInviteView, + ) + } +} diff --git a/features/startchat/api/build.gradle.kts b/features/startchat/api/build.gradle.kts new file mode 100644 index 0000000..890ae26 --- /dev/null +++ b/features/startchat/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.startchat.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/ConfirmingStartDmWithMatrixUser.kt b/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/ConfirmingStartDmWithMatrixUser.kt new file mode 100644 index 0000000..5bf015c --- /dev/null +++ b/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/ConfirmingStartDmWithMatrixUser.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.api + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.user.MatrixUser + +data class ConfirmingStartDmWithMatrixUser( + val matrixUser: MatrixUser, +) : AsyncAction.Confirming diff --git a/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/StartChatEntryPoint.kt b/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/StartChatEntryPoint.kt new file mode 100644 index 0000000..b560cda --- /dev/null +++ b/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/StartChatEntryPoint.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.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.RoomIdOrAlias + +interface StartChatEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: Callback, + ): Node + + interface Callback : Plugin { + fun onRoomCreated(roomIdOrAlias: RoomIdOrAlias, serverNames: List) + fun navigateToRoomDirectory() + } +} diff --git a/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/StartDMAction.kt b/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/StartDMAction.kt new file mode 100644 index 0000000..6aa2ad5 --- /dev/null +++ b/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/StartDMAction.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.api + +import androidx.compose.runtime.MutableState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.user.MatrixUser + +interface StartDMAction { + /** + * Try to find an existing DM with the given user, or create one if none exists. + * @param matrixUser The user to start a DM with. + * @param createIfDmDoesNotExist If true, create a DM if one does not exist. If false and the DM + * does not exist, the action will fail with the value [ConfirmingStartDmWithMatrixUser]. + * @param actionState The state to update with the result of the action. + */ + suspend fun execute( + matrixUser: MatrixUser, + createIfDmDoesNotExist: Boolean, + actionState: MutableState>, + ) +} diff --git a/features/startchat/impl/build.gradle.kts b/features/startchat/impl/build.gradle.kts new file mode 100644 index 0000000..6ab1a36 --- /dev/null +++ b/features/startchat/impl/build.gradle.kts @@ -0,0 +1,58 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.startchat.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + 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.api) + implementation(projects.libraries.mediapickers.api) + implementation(projects.libraries.mediaupload.api) + implementation(projects.libraries.permissions.api) + implementation(projects.libraries.usersearch.impl) + implementation(projects.services.analytics.api) + implementation(libs.coil.compose) + implementation(projects.libraries.featureflag.api) + implementation(projects.features.createroom.api) + api(projects.features.startchat.api) + + testCommonDependencies(libs, true) + testImplementation(projects.services.analytics.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.mediapickers.test) + testImplementation(projects.libraries.mediaupload.test) + testImplementation(projects.libraries.permissions.test) + testImplementation(projects.libraries.usersearch.test) + testImplementation(projects.features.createroom.test) + testImplementation(projects.features.startchat.test) + testImplementation(projects.libraries.featureflag.test) +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/StartChatNavigator.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/StartChatNavigator.kt new file mode 100644 index 0000000..125dc2c --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/StartChatNavigator.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat + +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.push +import io.element.android.features.startchat.impl.StartChatFlowNode.NavTarget +import io.element.android.libraries.architecture.overlay.Overlay +import io.element.android.libraries.architecture.overlay.operation.hide +import io.element.android.libraries.architecture.overlay.operation.show +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias + +interface StartChatNavigator : Plugin { + fun onRoomCreated(roomIdOrAlias: RoomIdOrAlias, serverNames: List) + fun onCreateNewRoom() + fun onShowJoinRoomByAddress() + fun onDismissJoinRoomByAddress() + fun onOpenRoomDirectory() +} + +class DefaultStartChatNavigator( + private val backstack: BackStack, + private val overlay: Overlay, + private val openRoom: (RoomIdOrAlias, List) -> Unit, + private val openRoomDirectory: () -> Unit, +) : StartChatNavigator { + override fun onRoomCreated(roomIdOrAlias: RoomIdOrAlias, serverNames: List) = + openRoom(roomIdOrAlias, serverNames) + + override fun onOpenRoomDirectory() = openRoomDirectory() + + override fun onCreateNewRoom() { + backstack.push(NavTarget.NewRoom) + } + + override fun onShowJoinRoomByAddress() { + overlay.show(NavTarget.JoinByAddress) + } + + override fun onDismissJoinRoomByAddress() { + overlay.hide() + } +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/DefaultStartChatEntryPoint.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/DefaultStartChatEntryPoint.kt new file mode 100644 index 0000000..6e70153 --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/DefaultStartChatEntryPoint.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.startchat.api.StartChatEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultStartChatEntryPoint : StartChatEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: StartChatEntryPoint.Callback, + ): Node { + return parentNode.createNode(buildContext, listOf(callback)) + } +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/DefaultStartDMAction.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/DefaultStartDMAction.kt new file mode 100644 index 0000000..a484fe2 --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/DefaultStartDMAction.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl + +import androidx.compose.runtime.MutableState +import dev.zacsweers.metro.ContributesBinding +import im.vector.app.features.analytics.plan.CreatedRoom +import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser +import io.element.android.features.startchat.api.StartDMAction +import io.element.android.libraries.architecture.AsyncAction +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.StartDMResult +import io.element.android.libraries.matrix.api.room.startDM +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.services.analytics.api.AnalyticsService + +@ContributesBinding(SessionScope::class) +class DefaultStartDMAction( + private val matrixClient: MatrixClient, + private val analyticsService: AnalyticsService, +) : StartDMAction { + override suspend fun execute( + matrixUser: MatrixUser, + createIfDmDoesNotExist: Boolean, + actionState: MutableState>, + ) { + actionState.value = AsyncAction.Loading + when (val result = matrixClient.startDM(matrixUser.userId, createIfDmDoesNotExist)) { + is StartDMResult.Success -> { + if (result.isNew) { + analyticsService.capture(CreatedRoom(isDM = true)) + } + actionState.value = AsyncAction.Success(result.roomId) + } + is StartDMResult.Failure -> { + actionState.value = AsyncAction.Failure(result.throwable) + } + StartDMResult.DmDoesNotExist -> { + actionState.value = ConfirmingStartDmWithMatrixUser(matrixUser = matrixUser) + } + } + } +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatFlowNode.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatFlowNode.kt new file mode 100644 index 0000000..236d92f --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatFlowNode.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl + +import android.os.Parcelable +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.transition.JumpToEndTransitionHandler +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.navmodel.backstack.BackStack +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.createroom.api.CreateRoomEntryPoint +import io.element.android.features.startchat.DefaultStartChatNavigator +import io.element.android.features.startchat.api.StartChatEntryPoint +import io.element.android.features.startchat.impl.joinbyaddress.JoinRoomByAddressNode +import io.element.android.features.startchat.impl.root.StartChatNode +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.OverlayView +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +@AssistedInject +class StartChatFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val createRoomEntryPoint: CreateRoomEntryPoint, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +) { + sealed interface NavTarget : Parcelable { + @Parcelize + data object Root : NavTarget + + @Parcelize + data object NewRoom : NavTarget + + @Parcelize + data object JoinByAddress : NavTarget + } + + private val callback: StartChatEntryPoint.Callback = callback() + private val navigator = DefaultStartChatNavigator( + backstack = backstack, + overlay = overlay, + openRoom = callback::onRoomCreated, + openRoomDirectory = callback::navigateToRoomDirectory, + ) + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Root -> { + createNode(buildContext = buildContext, plugins = listOf(navigator)) + } + NavTarget.NewRoom -> { + val callback = object : CreateRoomEntryPoint.Callback { + override fun onRoomCreated(roomId: RoomId) { + navigator.onRoomCreated(roomId.toRoomIdOrAlias(), emptyList()) + } + } + createRoomEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + callback = callback, + ) + } + NavTarget.JoinByAddress -> { + createNode(buildContext = buildContext, plugins = listOf(navigator)) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + Box(modifier = modifier) { + BackstackView() + OverlayView(transitionHandler = remember { JumpToEndTransitionHandler() }) + } + } +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/components/SearchMultipleUsersResultItem.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/components/SearchMultipleUsersResultItem.kt new file mode 100644 index 0000000..622a0ac --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/components/SearchMultipleUsersResultItem.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.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.HorizontalDivider +import io.element.android.libraries.matrix.ui.components.CheckableUserRow +import io.element.android.libraries.matrix.ui.components.CheckableUserRowData +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.matrix.ui.model.getBestName +import io.element.android.libraries.usersearch.api.UserSearchResult + +@Composable +fun SearchMultipleUsersResultItem( + searchResult: UserSearchResult, + isUserSelected: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val data = if (searchResult.isUnresolved) { + CheckableUserRowData.Unresolved( + avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.UserListItem), + id = searchResult.matrixUser.userId.value, + ) + } else { + CheckableUserRowData.Resolved( + name = searchResult.matrixUser.getBestName(), + subtext = if (searchResult.matrixUser.displayName.isNullOrEmpty()) null else searchResult.matrixUser.userId.value, + avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.UserListItem), + ) + } + CheckableUserRow( + checked = isUserSelected, + modifier = modifier, + data = data, + onCheckedChange = onCheckedChange, + ) +} + +@Preview +@Composable +internal fun SearchMultipleUsersResultItemPreview() = ElementThemedPreview { + Column { + SearchMultipleUsersResultItem( + searchResult = UserSearchResult( + aMatrixUser(), + isUnresolved = false + ), + isUserSelected = false, + onCheckedChange = {} + ) + HorizontalDivider() + SearchMultipleUsersResultItem( + searchResult = UserSearchResult( + aMatrixUser(), + isUnresolved = false + ), + isUserSelected = true, + onCheckedChange = {} + ) + HorizontalDivider() + SearchMultipleUsersResultItem( + searchResult = UserSearchResult( + aMatrixUser(), + isUnresolved = true + ), + isUserSelected = false, + onCheckedChange = {} + ) + HorizontalDivider() + SearchMultipleUsersResultItem( + searchResult = UserSearchResult( + aMatrixUser(), + isUnresolved = true + ), + isUserSelected = true, + onCheckedChange = {} + ) + } +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/components/SearchSingleUserResultItem.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/components/SearchSingleUserResultItem.kt new file mode 100644 index 0000000..448ed86 --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/components/SearchSingleUserResultItem.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.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.HorizontalDivider +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, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + 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 { + Column { + SearchSingleUserResultItem( + searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), + onClick = {}, + ) + HorizontalDivider() + SearchSingleUserResultItem( + searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true), + onClick = {}, + ) + } +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/components/SearchUserBar.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/components/SearchUserBar.kt new file mode 100644 index 0000000..a664ad0 --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/components/SearchUserBar.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.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.components.async.AsyncLoading +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +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.SelectedUsersRowList +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>, + showLoader: Boolean, + selectedUsers: ImmutableList, + active: Boolean, + isMultiSelectionEnable: Boolean, + onActiveChange: (Boolean) -> Unit, + onTextChange: (String) -> Unit, + onUserSelect: (MatrixUser) -> Unit, + onUserDeselect: (MatrixUser) -> Unit, + modifier: Modifier = Modifier, + showBackButton: Boolean = true, + placeHolderTitle: String = stringResource(CommonStrings.common_search_for_someone), +) { + val columnState = rememberLazyListState() + + SearchBar( + query = query, + onQueryChange = onTextChange, + active = active, + onActiveChange = onActiveChange, + modifier = modifier, + placeHolderTitle = placeHolderTitle, + showBackButton = showBackButton, + contentPrefix = { + if (isMultiSelectionEnable && 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) + ) + + SelectedUsersRowList( + contentPadding = PaddingValues(16.dp), + selectedUsers = selectedUsers, + autoScroll = true, + onUserRemove = onUserDeselect, + modifier = Modifier.background(appBarContainerColor) + ) + } + }, + contentSuffix = { + if (showLoader) { + AsyncLoading() + } + }, + resultState = state, + resultHandler = { users -> + LazyColumn(state = columnState) { + if (isMultiSelectionEnable) { + itemsIndexed(users) { index, searchResult -> + SearchMultipleUsersResultItem( + modifier = Modifier.fillMaxWidth(), + searchResult = searchResult, + isUserSelected = selectedUsers.contains(searchResult.matrixUser), + onCheckedChange = { checked -> + if (checked) { + onUserSelect(searchResult.matrixUser) + } else { + onUserDeselect(searchResult.matrixUser) + } + } + ) + if (index < users.lastIndex) { + HorizontalDivider() + } + } + } else { + itemsIndexed(users) { index, searchResult -> + SearchSingleUserResultItem( + modifier = Modifier.fillMaxWidth(), + searchResult = searchResult, + onClick = { onUserSelect(searchResult.matrixUser) } + ) + if (index < users.lastIndex) { + HorizontalDivider() + } + } + } + } + }, + ) +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/components/UserListView.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/components/UserListView.kt new file mode 100644 index 0000000..f35c628 --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/components/UserListView.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +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.startchat.impl.userlist.UserListEvents +import io.element.android.features.startchat.impl.userlist.UserListState +import io.element.android.features.startchat.impl.userlist.UserListStateProvider +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.ListSectionHeader +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.CheckableUserRow +import io.element.android.libraries.matrix.ui.components.CheckableUserRowData +import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList +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 UserListView( + state: UserListState, + onSelectUser: (MatrixUser) -> Unit, + onDeselectUser: (MatrixUser) -> Unit, + modifier: Modifier = Modifier, + showBackButton: Boolean = true, +) { + Column( + modifier = modifier, + ) { + SearchUserBar( + modifier = Modifier.fillMaxWidth(), + query = state.searchQuery, + state = state.searchResults, + selectedUsers = state.selectedUsers, + active = state.isSearchActive, + showLoader = state.showSearchLoader, + isMultiSelectionEnable = state.isMultiSelectionEnabled, + showBackButton = showBackButton, + onActiveChange = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) }, + onTextChange = { state.eventSink(UserListEvents.UpdateSearchQuery(it)) }, + onUserSelect = { + state.eventSink(UserListEvents.AddToSelection(it)) + onSelectUser(it) + }, + onUserDeselect = { + state.eventSink(UserListEvents.RemoveFromSelection(it)) + onDeselectUser(it) + }, + ) + + if (state.isMultiSelectionEnabled && !state.isSearchActive && state.selectedUsers.isNotEmpty()) { + SelectedUsersRowList( + contentPadding = PaddingValues(16.dp), + selectedUsers = state.selectedUsers, + autoScroll = true, + onUserRemove = { + state.eventSink(UserListEvents.RemoveFromSelection(it)) + onDeselectUser(it) + }, + ) + } + if (!state.isSearchActive && state.recentDirectRooms.isNotEmpty()) { + LazyColumn { + item { + ListSectionHeader( + title = stringResource(id = CommonStrings.common_suggestions), + hasDivider = false, + ) + } + state.recentDirectRooms.forEachIndexed { index, recentDirectRoom -> + item { + val isSelected = state.selectedUsers.any { + recentDirectRoom.matrixUser.userId == it.userId + } + CheckableUserRow( + checked = isSelected, + onCheckedChange = { + if (isSelected) { + state.eventSink(UserListEvents.RemoveFromSelection(recentDirectRoom.matrixUser)) + onDeselectUser(recentDirectRoom.matrixUser) + } else { + state.eventSink(UserListEvents.AddToSelection(recentDirectRoom.matrixUser)) + onSelectUser(recentDirectRoom.matrixUser) + } + }, + data = CheckableUserRowData.Resolved( + avatarData = recentDirectRoom.matrixUser.getAvatarData(AvatarSize.UserListItem), + name = recentDirectRoom.matrixUser.getBestName(), + subtext = recentDirectRoom.matrixUser.userId.value, + ), + ) + if (index < state.recentDirectRooms.lastIndex) { + HorizontalDivider() + } + } + } + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun UserListViewPreview(@PreviewParameter(UserListStateProvider::class) state: UserListState) = ElementPreview { + UserListView( + state = state, + onSelectUser = {}, + onDeselectUser = {}, + ) +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressEvents.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressEvents.kt new file mode 100644 index 0000000..4a5c2de --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressEvents.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.joinbyaddress + +sealed interface JoinRoomByAddressEvents { + data object Dismiss : JoinRoomByAddressEvents + data object Continue : JoinRoomByAddressEvents + data class UpdateAddress(val address: String) : JoinRoomByAddressEvents +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressNode.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressNode.kt new file mode 100644 index 0000000..c81cb1a --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressNode.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.joinbyaddress + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.startchat.StartChatNavigator +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class JoinRoomByAddressNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: JoinRoomByAddressPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + private val navigator = plugins().first() + private val presenter = presenterFactory.create(navigator) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + JoinRoomByAddressView( + state = state, + modifier = modifier + ) + } +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressPresenter.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressPresenter.kt new file mode 100644 index 0000000..bda1e05 --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressPresenter.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.joinbyaddress + +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.rememberUpdatedState +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.startchat.StartChatNavigator +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration.Companion.seconds + +private const val ADDRESS_RESOLVE_TIMEOUT_IN_SECONDS = 10 + +@AssistedInject +class JoinRoomByAddressPresenter( + @Assisted private val navigator: StartChatNavigator, + private val client: MatrixClient, + private val roomAliasHelper: RoomAliasHelper, +) : Presenter { + @AssistedFactory + interface Factory { + fun create(navigator: StartChatNavigator): JoinRoomByAddressPresenter + } + + @Composable + override fun present(): JoinRoomByAddressState { + var address by remember { mutableStateOf("") } + var internalAddressState by remember { mutableStateOf(RoomAddressState.Unknown) } + var validateAddress: Boolean by remember { mutableStateOf(false) } + + fun handleEvent(event: JoinRoomByAddressEvents) { + when (event) { + JoinRoomByAddressEvents.Continue -> { + when (val currentState = internalAddressState) { + is RoomAddressState.RoomFound -> onRoomFound(currentState) + else -> validateAddress = true + } + } + JoinRoomByAddressEvents.Dismiss -> navigator.onDismissJoinRoomByAddress() + is JoinRoomByAddressEvents.UpdateAddress -> { + validateAddress = false + address = event.address.trim() + } + } + } + + RoomAddressStateEffect( + fullAddress = address, + onRoomAddressStateChange = { addressState -> + internalAddressState = addressState + if (addressState is RoomAddressState.RoomFound && validateAddress) { + onRoomFound(addressState) + } + } + ) + + val addressState by remember { + derivedStateOf { + // We only want to show the "RoomFound" state as long as the user didn't validate the address. + if (validateAddress || internalAddressState is RoomAddressState.RoomFound) { + internalAddressState + } else { + RoomAddressState.Unknown + } + } + } + + return JoinRoomByAddressState( + address = address, + addressState = addressState, + eventSink = ::handleEvent, + ) + } + + private fun onRoomFound(state: RoomAddressState.RoomFound) { + navigator.onDismissJoinRoomByAddress() + navigator.onRoomCreated( + roomIdOrAlias = state.resolved.roomId.toRoomIdOrAlias(), + serverNames = state.resolved.servers + ) + } + + @Composable + private fun RoomAddressStateEffect( + fullAddress: String, + onRoomAddressStateChange: (RoomAddressState) -> Unit, + ) { + val onChange by rememberUpdatedState(onRoomAddressStateChange) + LaunchedEffect(fullAddress) { + // Whenever the address changes, reset the state to unknown + onChange(RoomAddressState.Unknown) + // debounce the room address resolution + delay(300) + val roomAlias = tryOrNull { RoomAlias(fullAddress) } + if (roomAlias != null && roomAliasHelper.isRoomAliasValid(roomAlias)) { + onChange(RoomAddressState.Resolving) + onChange(client.resolveRoomAddress(roomAlias)) + } else { + onChange(RoomAddressState.Invalid) + } + } + } + + private suspend fun MatrixClient.resolveRoomAddress(roomAlias: RoomAlias): RoomAddressState { + return withTimeoutOrNull(ADDRESS_RESOLVE_TIMEOUT_IN_SECONDS.seconds) { + resolveRoomAlias(roomAlias) + .fold( + onSuccess = { resolved -> + if (resolved.isPresent) { + RoomAddressState.RoomFound(resolved.get()) + } else { + RoomAddressState.RoomNotFound + } + }, + onFailure = { _ -> RoomAddressState.RoomNotFound } + ) + } ?: RoomAddressState.RoomNotFound + } +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressState.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressState.kt new file mode 100644 index 0000000..4749b14 --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressState.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.joinbyaddress + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias + +data class JoinRoomByAddressState( + val address: String, + val addressState: RoomAddressState, + val eventSink: (JoinRoomByAddressEvents) -> Unit +) + +@Immutable +sealed interface RoomAddressState { + data object Unknown : RoomAddressState + data object Invalid : RoomAddressState + data object Resolving : RoomAddressState + data object RoomNotFound : RoomAddressState + data class RoomFound(val resolved: ResolvedRoomAlias) : RoomAddressState +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressStateProvider.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressStateProvider.kt new file mode 100644 index 0000000..eb1305e --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressStateProvider.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.joinbyaddress + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias + +open class JoinRoomByAddressStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aJoinRoomByAddressState(), + aJoinRoomByAddressState(address = "#room-"), + aJoinRoomByAddressState(address = "#room-", addressState = RoomAddressState.Invalid), + aJoinRoomByAddressState(address = "#room-name:matrix.org", addressState = RoomAddressState.Resolving), + aJoinRoomByAddressState(address = "#room-name-none:matrix.org", addressState = RoomAddressState.RoomNotFound), + aJoinRoomByAddressState( + address = "#room-name:matrix.org", + addressState = RoomAddressState.RoomFound(ResolvedRoomAlias(RoomId("!aRoom:id"), emptyList())), + ), + ) +} + +fun aJoinRoomByAddressState( + address: String = "", + addressState: RoomAddressState = RoomAddressState.Unknown, + eventSink: (JoinRoomByAddressEvents) -> Unit = {}, +) = JoinRoomByAddressState( + address = address, + addressState = addressState, + eventSink = eventSink +) diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressView.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressView.kt new file mode 100644 index 0000000..0627a78 --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressView.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.joinbyaddress + +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.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.startchat.impl.R +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.TextField +import io.element.android.libraries.designsystem.theme.components.TextFieldValidity +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun JoinRoomByAddressView( + state: JoinRoomByAddressState, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + modifier = modifier, + sheetState = sheetState, + onDismissRequest = { + state.eventSink(JoinRoomByAddressEvents.Dismiss) + }, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(all = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + RoomAddressField( + address = state.address, + addressState = state.addressState, + requestFocus = sheetState.isVisible, + onAddressChange = { + state.eventSink(JoinRoomByAddressEvents.UpdateAddress(it)) + }, + onContinue = { + state.eventSink(JoinRoomByAddressEvents.Continue) + }, + ) + Spacer(modifier = Modifier.height(24.dp)) + Button( + text = stringResource(CommonStrings.action_continue), + modifier = Modifier.fillMaxWidth(), + showProgress = state.addressState is RoomAddressState.Resolving, + onClick = { + state.eventSink(JoinRoomByAddressEvents.Continue) + } + ) + } + } +} + +@Composable +private fun RoomAddressField( + address: String, + addressState: RoomAddressState, + requestFocus: Boolean, + onAddressChange: (String) -> Unit, + onContinue: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + if (requestFocus) { + LaunchedEffect(Unit) { focusRequester.requestFocus() } + } + TextField( + modifier = modifier.focusRequester(focusRequester), + value = address, + label = stringResource(R.string.screen_start_chat_join_room_by_address_action), + placeholder = stringResource(R.string.screen_start_chat_join_room_by_address_placeholder), + supportingText = when (addressState) { + RoomAddressState.Invalid -> stringResource(R.string.screen_start_chat_join_room_by_address_invalid_address) + is RoomAddressState.RoomFound -> stringResource(R.string.screen_start_chat_join_room_by_address_room_found) + RoomAddressState.RoomNotFound -> stringResource(R.string.screen_start_chat_join_room_by_address_room_not_found) + RoomAddressState.Unknown, RoomAddressState.Resolving -> stringResource(R.string.screen_start_chat_join_room_by_address_supporting_text) + }, + validity = when (addressState) { + RoomAddressState.Unknown, RoomAddressState.Resolving -> TextFieldValidity.None + RoomAddressState.Invalid, RoomAddressState.RoomNotFound -> TextFieldValidity.Invalid + is RoomAddressState.RoomFound -> TextFieldValidity.Valid + }, + onValueChange = onAddressChange, + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go + ), + keyboardActions = KeyboardActions( + onGo = { onContinue() } + ) + ) +} + +@PreviewsDayNight +@Composable +internal fun JoinRoomByAddressViewPreview( + @PreviewParameter(JoinRoomByAddressStateProvider::class) state: JoinRoomByAddressState +) = ElementPreview { + JoinRoomByAddressView(state = state) +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatEvents.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatEvents.kt new file mode 100644 index 0000000..66e135b --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatEvents.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.root + +import io.element.android.libraries.matrix.api.user.MatrixUser + +sealed interface StartChatEvents { + data class StartDM(val matrixUser: MatrixUser) : StartChatEvents + data object CancelStartDM : StartChatEvents +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatNode.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatNode.kt new file mode 100644 index 0000000..60ef9ee --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatNode.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.root + +import android.app.Activity +import androidx.activity.compose.LocalActivity +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.annotations.ContributesNode +import io.element.android.features.startchat.StartChatNavigator +import io.element.android.libraries.deeplink.api.usecase.InviteFriendsUseCase +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.services.analytics.api.AnalyticsService + +@ContributesNode(SessionScope::class) +@AssistedInject +class StartChatNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: StartChatPresenter, + private val analyticsService: AnalyticsService, + private val inviteFriendsUseCase: InviteFriendsUseCase, +) : Node(buildContext, plugins = plugins) { + private val navigator = plugins().first() + + init { + lifecycle.subscribe( + onResume = { analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.StartChat)) } + ) + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val activity = requireNotNull(LocalActivity.current) + StartChatView( + state = state, + modifier = modifier, + onCloseClick = this::navigateUp, + onNewRoomClick = navigator::onCreateNewRoom, + onOpenDM = { + navigator.onRoomCreated(roomIdOrAlias = it.toRoomIdOrAlias(), serverNames = emptyList()) + }, + onJoinByAddressClick = navigator::onShowJoinRoomByAddress, + onInviteFriendsClick = { invitePeople(activity) }, + onRoomDirectorySearchClick = navigator::onOpenRoomDirectory + ) + } + + private fun invitePeople(activity: Activity) { + inviteFriendsUseCase.execute(activity) + } +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenter.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenter.kt new file mode 100644 index 0000000..e176f20 --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenter.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.root + +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 dev.zacsweers.metro.Inject +import io.element.android.features.startchat.api.StartDMAction +import io.element.android.features.startchat.impl.userlist.SelectionMode +import io.element.android.features.startchat.impl.userlist.UserListDataStore +import io.element.android.features.startchat.impl.userlist.UserListPresenter +import io.element.android.features.startchat.impl.userlist.UserListPresenterArgs +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.usersearch.api.UserRepository +import kotlinx.coroutines.launch + +@Inject +class StartChatPresenter( + presenterFactory: UserListPresenter.Factory, + userRepository: UserRepository, + userListDataStore: UserListDataStore, + private val startDMAction: StartDMAction, + private val buildMeta: BuildMeta, + private val featureFlagService: FeatureFlagService, +) : Presenter { + private val presenter = presenterFactory.create( + UserListPresenterArgs( + selectionMode = SelectionMode.Single, + ), + userRepository, + userListDataStore, + ) + + @Composable + override fun present(): StartChatState { + val userListState = presenter.present() + + val localCoroutineScope = rememberCoroutineScope() + val startDmActionState: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + + val isRoomDirectorySearchEnabled by remember { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomDirectorySearch) + }.collectAsState(initial = false) + + fun handleEvent(event: StartChatEvents) { + when (event) { + is StartChatEvents.StartDM -> localCoroutineScope.launch { + startDMAction.execute( + matrixUser = event.matrixUser, + createIfDmDoesNotExist = startDmActionState.value is AsyncAction.Confirming, + actionState = startDmActionState, + ) + } + StartChatEvents.CancelStartDM -> startDmActionState.value = AsyncAction.Uninitialized + } + } + + return StartChatState( + applicationName = buildMeta.applicationName, + userListState = userListState, + startDmAction = startDmActionState.value, + isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatState.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatState.kt new file mode 100644 index 0000000..65f977d --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatState.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.root + +import io.element.android.features.startchat.impl.userlist.UserListState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId + +data class StartChatState( + val applicationName: String, + val userListState: UserListState, + val startDmAction: AsyncAction, + val isRoomDirectorySearchEnabled: Boolean, + val eventSink: (StartChatEvents) -> Unit, +) diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatStateProvider.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatStateProvider.kt new file mode 100644 index 0000000..1c82ae3 --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatStateProvider.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.root + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser +import io.element.android.features.startchat.impl.userlist.UserListState +import io.element.android.features.startchat.impl.userlist.aRecentDirectRoomList +import io.element.android.features.startchat.impl.userlist.aUserListState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.usersearch.api.UserSearchResult +import kotlinx.collections.immutable.persistentListOf + +open class StartChatStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aCreateRoomRootState(), + aCreateRoomRootState( + startDmAction = AsyncAction.Loading, + userListState = aMatrixUser().let { + aUserListState().copy( + searchQuery = it.userId.value, + searchResults = SearchBarResultState.Results(persistentListOf(UserSearchResult(it, false))), + selectedUsers = persistentListOf(it), + isSearchActive = true, + ) + } + ), + aCreateRoomRootState( + startDmAction = AsyncAction.Failure(RuntimeException("error")), + userListState = aMatrixUser().let { + aUserListState().copy( + searchQuery = it.userId.value, + searchResults = SearchBarResultState.Results(persistentListOf(UserSearchResult(it, false))), + selectedUsers = persistentListOf(it), + isSearchActive = true, + ) + } + ), + aCreateRoomRootState( + userListState = aUserListState( + recentDirectRooms = aRecentDirectRoomList() + ) + ), + aCreateRoomRootState( + startDmAction = ConfirmingStartDmWithMatrixUser(aMatrixUser()), + ), + aCreateRoomRootState( + isRoomDirectorySearchEnabled = true, + ), + ) +} + +fun aCreateRoomRootState( + applicationName: String = "Element X Preview", + userListState: UserListState = aUserListState(), + startDmAction: AsyncAction = AsyncAction.Uninitialized, + isRoomDirectorySearchEnabled: Boolean = false, + eventSink: (StartChatEvents) -> Unit = {}, +) = StartChatState( + applicationName = applicationName, + userListState = userListState, + startDmAction = startDmAction, + isRoomDirectorySearchEnabled = isRoomDirectorySearchEnabled, + eventSink = eventSink, +) diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatView.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatView.kt new file mode 100644 index 0000000..ca308e4 --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatView.kt @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.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.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.foundation.lazy.LazyColumn +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser +import io.element.android.features.startchat.impl.R +import io.element.android.features.startchat.impl.components.UserListView +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.ListSectionHeader +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.matrix.ui.components.CreateDmConfirmationBottomSheet +import io.element.android.libraries.matrix.ui.components.MatrixUserRow +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun StartChatView( + state: StartChatState, + onCloseClick: () -> Unit, + onNewRoomClick: () -> Unit, + onOpenDM: (RoomId) -> Unit, + onInviteFriendsClick: () -> Unit, + onJoinByAddressClick: () -> Unit, + onRoomDirectorySearchClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxWidth(), + topBar = { + if (!state.userListState.isSearchActive) { + CreateRoomRootViewTopBar(onCloseClick = onCloseClick) + } + } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + UserListView( + modifier = Modifier.fillMaxWidth(), + // Do not render suggestions in this case, the suggestion will be rendered + // by CreateRoomActionButtonsList + state = state.userListState.copy( + recentDirectRooms = persistentListOf(), + ), + onSelectUser = { + state.eventSink(StartChatEvents.StartDM(it)) + }, + onDeselectUser = { }, + ) + + if (!state.userListState.isSearchActive) { + CreateRoomActionButtonsList( + state = state, + onNewRoomClick = onNewRoomClick, + onInvitePeopleClick = onInviteFriendsClick, + onJoinByAddressClick = onJoinByAddressClick, + onRoomDirectorySearchClick = onRoomDirectorySearchClick, + onDmClick = onOpenDM, + ) + } + } + } + + AsyncActionView( + async = state.startDmAction, + progressDialog = { + AsyncActionViewDefaults.ProgressDialog( + progressText = stringResource(CommonStrings.common_starting_chat), + ) + }, + onSuccess = { onOpenDM(it) }, + errorMessage = { stringResource(R.string.screen_start_chat_error_starting_chat) }, + onRetry = { + state.userListState.selectedUsers.firstOrNull() + ?.let { state.eventSink(StartChatEvents.StartDM(it)) } + // Cancel start DM if there is no more selected user (should not happen) + ?: state.eventSink(StartChatEvents.CancelStartDM) + }, + onErrorDismiss = { state.eventSink(StartChatEvents.CancelStartDM) }, + confirmationDialog = { data -> + if (data is ConfirmingStartDmWithMatrixUser) { + CreateDmConfirmationBottomSheet( + matrixUser = data.matrixUser, + onSendInvite = { + state.eventSink(StartChatEvents.StartDM(data.matrixUser)) + }, + onDismiss = { + state.eventSink(StartChatEvents.CancelStartDM) + }, + ) + } + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CreateRoomRootViewTopBar( + onCloseClick: () -> Unit, +) { + TopAppBar( + titleStr = stringResource(id = CommonStrings.action_start_chat), + navigationIcon = { + BackButton( + imageVector = CompoundIcons.Close(), + onClick = onCloseClick, + ) + } + ) +} + +@Composable +private fun CreateRoomActionButtonsList( + state: StartChatState, + onNewRoomClick: () -> Unit, + onInvitePeopleClick: () -> Unit, + onJoinByAddressClick: () -> Unit, + onRoomDirectorySearchClick: () -> Unit, + onDmClick: (RoomId) -> Unit, +) { + LazyColumn { + item { + CreateRoomActionButton( + iconRes = CompoundDrawables.ic_compound_plus, + text = stringResource(id = R.string.screen_create_room_action_create_room), + onClick = onNewRoomClick, + ) + } + if (state.isRoomDirectorySearchEnabled) { + item { + CreateRoomActionButton( + iconRes = CompoundDrawables.ic_compound_list_bulleted, + text = stringResource(id = R.string.screen_room_directory_search_title), + onClick = onRoomDirectorySearchClick, + ) + } + } + item { + CreateRoomActionButton( + iconRes = CompoundDrawables.ic_compound_share_android, + text = stringResource(id = CommonStrings.action_invite_friends_to_app, state.applicationName), + onClick = onInvitePeopleClick, + ) + } + item { + CreateRoomActionButton( + iconRes = CompoundDrawables.ic_compound_room, + text = stringResource(R.string.screen_start_chat_join_room_by_address_action), + onClick = onJoinByAddressClick, + ) + } + if (state.userListState.recentDirectRooms.isNotEmpty()) { + item { + ListSectionHeader( + title = stringResource(id = CommonStrings.common_suggestions), + hasDivider = false, + ) + } + state.userListState.recentDirectRooms.forEach { recentDirectRoom -> + item { + MatrixUserRow( + modifier = Modifier.clickable( + onClick = { + onDmClick(recentDirectRoom.roomId) + } + ), + matrixUser = recentDirectRoom.matrixUser, + ) + } + } + } + } +} + +@Composable +private fun CreateRoomActionButton( + @DrawableRes iconRes: Int, + text: String, + 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 = ElementTheme.colors.iconSecondary, + resourceId = iconRes, + contentDescription = null, + ) + Text( + text = text, + style = ElementTheme.typography.fontBodyLgRegular, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun StartChatViewPreview(@PreviewParameter(StartChatStateProvider::class) state: StartChatState) = + ElementPreview { + StartChatView( + state = state, + onCloseClick = {}, + onNewRoomClick = {}, + onOpenDM = {}, + onJoinByAddressClick = {}, + onInviteFriendsClick = {}, + onRoomDirectorySearchClick = {}, + ) + } diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/DefaultUserListPresenter.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/DefaultUserListPresenter.kt new file mode 100644 index 0000000..4e85dd0 --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/DefaultUserListPresenter.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.room.recent.RecentDirectRoom +import io.element.android.libraries.matrix.api.room.recent.getRecentDirectRooms +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 +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@AssistedInject +class DefaultUserListPresenter( + @Assisted val args: UserListPresenterArgs, + @Assisted val userRepository: UserRepository, + @Assisted val userListDataStore: UserListDataStore, + private val matrixClient: MatrixClient, +) : 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 recentDirectRooms by remember { mutableStateOf(emptyList()) } + LaunchedEffect(Unit) { + recentDirectRooms = matrixClient.getRecentDirectRooms() + } + var isSearchActive by rememberSaveable { mutableStateOf(false) } + val selectedUsers by userListDataStore.selectedUsers.collectAsState(emptyList()) + var searchQuery by rememberSaveable { mutableStateOf("") } + var searchResults: SearchBarResultState> by remember { + mutableStateOf(SearchBarResultState.Initial()) + } + var showSearchLoader by remember { mutableStateOf(false) } + + LaunchedEffect(searchQuery) { + searchResults = SearchBarResultState.Initial() + showSearchLoader = false + userRepository.search(searchQuery).onEach { state -> + showSearchLoader = state.isSearching + searchResults = when { + state.results.isEmpty() && state.isSearching -> SearchBarResultState.Initial() + state.results.isEmpty() && !state.isSearching -> SearchBarResultState.NoResultsFound() + else -> SearchBarResultState.Results(state.results.toImmutableList()) + } + }.launchIn(this) + } + + fun handleEvent(event: UserListEvents) { + 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) + } + } + + return UserListState( + searchQuery = searchQuery, + searchResults = searchResults, + selectedUsers = selectedUsers.toImmutableList(), + isSearchActive = isSearchActive, + showSearchLoader = showSearchLoader, + selectionMode = args.selectionMode, + recentDirectRooms = recentDirectRooms.toImmutableList(), + eventSink = ::handleEvent, + ) + } +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/UserListDataStore.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/UserListDataStore.kt new file mode 100644 index 0000000..f7ca98a --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/UserListDataStore.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.userlist + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +@Inject +class UserListDataStore { + private val _selectedUsers: MutableStateFlow> = MutableStateFlow(emptyList()) + + fun selectUser(user: MatrixUser) { + if (!_selectedUsers.value.contains(user)) { + _selectedUsers.tryEmit(_selectedUsers.value.plus(user)) + } + } + + fun removeUserFromSelection(user: MatrixUser) { + _selectedUsers.tryEmit(_selectedUsers.value.minus(user)) + } + + val selectedUsers = _selectedUsers.asStateFlow() +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/UserListEvents.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/UserListEvents.kt new file mode 100644 index 0000000..99e910b --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/UserListEvents.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.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/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/UserListPresenter.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/UserListPresenter.kt new file mode 100644 index 0000000..797938b --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/UserListPresenter.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.userlist + +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.usersearch.api.UserRepository + +interface UserListPresenter : Presenter { + interface Factory { + fun create( + args: UserListPresenterArgs, + userRepository: UserRepository, + userListDataStore: UserListDataStore, + ): UserListPresenter + } +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/UserListPresenterArgs.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/UserListPresenterArgs.kt new file mode 100644 index 0000000..d1dbf8a --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/UserListPresenterArgs.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.userlist + +data class UserListPresenterArgs( + val selectionMode: SelectionMode, +) + +enum class SelectionMode { + Single, + Multiple, +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/UserListState.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/UserListState.kt new file mode 100644 index 0000000..33b74d2 --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/UserListState.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.userlist + +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.room.recent.RecentDirectRoom +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>, + val showSearchLoader: Boolean, + val selectedUsers: ImmutableList, + val isSearchActive: Boolean, + val selectionMode: SelectionMode, + val recentDirectRooms: ImmutableList, + val eventSink: (UserListEvents) -> Unit, +) { + val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple +} diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/UserListStateProvider.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/UserListStateProvider.kt new file mode 100644 index 0000000..cd43f96 --- /dev/null +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/userlist/UserListStateProvider.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.userlist + +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.recent.RecentDirectRoom +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import io.element.android.libraries.usersearch.api.UserSearchResult +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +open class UserListStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aUserListState(), + aUserListState( + isSearchActive = false, + selectedUsers = aListOfSelectedUsers(), + selectionMode = SelectionMode.Multiple, + ), + aUserListState(isSearchActive = true), + aUserListState(isSearchActive = true, searchQuery = "someone"), + aUserListState(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple), + aUserListState( + isSearchActive = true, + searchQuery = "@someone:matrix.org", + selectedUsers = aMatrixUserList().toImmutableList(), + searchResults = SearchBarResultState.Results(aListOfUserSearchResults()), + ), + aUserListState( + isSearchActive = true, + searchQuery = "@someone:matrix.org", + selectionMode = SelectionMode.Multiple, + selectedUsers = aMatrixUserList().toImmutableList(), + searchResults = SearchBarResultState.Results(aListOfUserSearchResults()), + ), + aUserListState( + isSearchActive = true, + searchQuery = "something-with-no-results", + searchResults = SearchBarResultState.NoResultsFound() + ), + aUserListState( + isSearchActive = true, + searchQuery = "someone", + selectionMode = SelectionMode.Single, + ), + aUserListState( + recentDirectRooms = aRecentDirectRoomList(), + ), + ) +} + +fun aUserListState( + searchQuery: String = "", + isSearchActive: Boolean = false, + searchResults: SearchBarResultState> = SearchBarResultState.Initial(), + selectedUsers: List = emptyList(), + showSearchLoader: Boolean = false, + selectionMode: SelectionMode = SelectionMode.Single, + recentDirectRooms: List = emptyList(), + eventSink: (UserListEvents) -> Unit = {}, +) = UserListState( + searchQuery = searchQuery, + isSearchActive = isSearchActive, + searchResults = searchResults, + selectedUsers = selectedUsers.toImmutableList(), + showSearchLoader = showSearchLoader, + selectionMode = selectionMode, + recentDirectRooms = recentDirectRooms.toImmutableList(), + eventSink = eventSink +) + +fun aListOfSelectedUsers() = aMatrixUserList().take(6).toImmutableList() +fun aListOfUserSearchResults() = aMatrixUserList().take(6).map { UserSearchResult(it) }.toImmutableList() + +fun aRecentDirectRoomList( + count: Int = 5 +): List = aMatrixUserList() + .take(count) + .map { + RecentDirectRoom(RoomId("!aRoom:id"), it) + } diff --git a/features/startchat/impl/src/main/res/values-be/translations.xml b/features/startchat/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..106159a --- /dev/null +++ b/features/startchat/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,6 @@ + + + "Новы пакой" + "Каталог пакояў" + "Пры спробе пачаць чат адбылася памылка" + diff --git a/features/startchat/impl/src/main/res/values-bg/translations.xml b/features/startchat/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..113ca0b --- /dev/null +++ b/features/startchat/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,10 @@ + + + "Нова стая" + "Възникна грешка при опита за започване на чат" + "Присъединяване към стая по адрес" + "Не е валиден адрес" + "Въведете…" + "Стаята не е намерена" + "напр. #room-name:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-cs/translations.xml b/features/startchat/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..b89f27f --- /dev/null +++ b/features/startchat/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,12 @@ + + + "Nová místnost" + "Adresář místností" + "Při pokusu o zahájení chatu došlo k chybě" + "Vstoupit do místnosti pomocí adresy" + "Neplatná adresa" + "Zadejte…" + "Odpovídající místnost nalezena" + "Místnost nebyla nalezena" + "např. #room-name:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-cy/translations.xml b/features/startchat/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..47faa4c --- /dev/null +++ b/features/startchat/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,12 @@ + + + "Ystafell newydd" + "Cyfeiriadur ystafelloedd" + "Digwyddodd gwall wrth geisio cychwyn sgwrs" + "Ymuno â\'r ystafell yn ôl cyfeiriad" + "Ddim yn gyfeiriad dilys" + "Ewch i mewn…" + "Cafwyd hyd i ystafell gyfatebol" + "Heb ganfod yr ystafell" + "e.e. #enw-ystafell:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-da/translations.xml b/features/startchat/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..8909204 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,12 @@ + + + "Nyt rum" + "Register over rum" + "Der opstod en fejl under forsøget på at starte en samtale" + "Tilslut dig rummet med adressen" + "Ikke en gyldig adresse" + "Indtast…" + "Matchende rum fundet" + "Rum ikke fundet" + "f.eks. #rummets-navn:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-de/translations.xml b/features/startchat/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..7608517 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,12 @@ + + + "Neuer Chat" + "Chat-Verzeichnis" + "Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten" + "Chat per Adresse beitreten" + "Keine gültige Adresse" + "Eintreten…" + "Passender Chat gefunden" + "Chat nicht gefunden" + "z. B. #room -name:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-el/translations.xml b/features/startchat/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..19e6250 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,12 @@ + + + "Νέα αίθουσα" + "Κατάλογος αιθουσών" + "Παρουσιάστηκε σφάλμα κατά την προσπάθεια έναρξης μιας συνομιλίας" + "Συμμετοχή σε αίθουσα μέσω διεύθυνσης" + "Μη έγκυρη διεύθυνση" + "Εισάγετε…" + "Βρέθηκε η αντίστοιχη αίθουσα" + "Η αίθουσα δεν βρέθηκε" + "π.χ. #όνομα-αίθουσας:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-es/translations.xml b/features/startchat/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..64a9f02 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,12 @@ + + + "Nueva sala" + "Directorio de salas" + "Se ha producido un error al intentar iniciar un chat" + "Unirse a una sala por su dirección" + "Dirección no válida" + "Introducir…" + "Sala encontrada" + "No se encontró la sala" + "p. ej., #nombre-de-la-sala:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-et/translations.xml b/features/startchat/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..6545947 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,12 @@ + + + "Uus jututuba" + "Jututubade kataloog" + "Vestluse alustamisel tekkis viga" + "Liitu jututoaga aadressi alusel" + "See pole kehtiv aadress" + "Sisene…" + "Leidsime vastava jututoa" + "Jututuba ei leidu" + "nt. #jututoa-nimi:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-eu/translations.xml b/features/startchat/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..403db09 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,9 @@ + + + "Gela berria" + "Gelen direktorioa" + "Errorea gertatu da txata hasten saiatzean" + "Ez da baliozko helbidea" + "Sartu…" + "Ez da gela aurkitu" + diff --git a/features/startchat/impl/src/main/res/values-fa/translations.xml b/features/startchat/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..7cb1e1b --- /dev/null +++ b/features/startchat/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,12 @@ + + + "اتاق جدید" + "فهرست اتاق‌ها" + "هنگام تلاش برای شروع چت خطایی روی داد" + "پیوستن به اتاق با نشانی" + "نشانی معتبری نیست" + "ورود…" + "اتاق مطابق پیدا شد" + "اتاق پیدا نشد" + "نمونه: ‪#room-name:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-fi/translations.xml b/features/startchat/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..1365957 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,12 @@ + + + "Uusi huone" + "Huoneluettelo" + "Keskustelun aloituksessa tapahtui virhe" + "Liity huoneeseen osoitteella" + "Osoite ei ole kelvollinen" + "Syötä…" + "Täsmäävä huone löytyi" + "Huonetta ei löytynyt" + "esim. #huoneen-nimi:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-fr/translations.xml b/features/startchat/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..9aaebd0 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,12 @@ + + + "Nouveau salon" + "Annuaire des salons" + "Une erreur s’est produite lors de la tentative de création de la discussion" + "Saisir une adresse de salon" + "Ce n’est pas une adresse valide" + "Saisir…" + "Ce salon existe" + "Salon non trouvé" + "ex: #nom-du-salon:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-hu/translations.xml b/features/startchat/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..014f7ba --- /dev/null +++ b/features/startchat/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,12 @@ + + + "Új szoba" + "Szobakatalógus" + "Hiba történt a csevegés indításakor" + "Csatlakozás a szobához cím szerint" + "Nem érvényes cím" + "Írja be…" + "Megfelelő szoba található" + "Szoba nem található" + "pl. #szoba-neve:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-in/translations.xml b/features/startchat/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..5f796bb --- /dev/null +++ b/features/startchat/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,12 @@ + + + "Ruangan baru" + "Direktori ruangan" + "Terjadi kesalahan saat mencoba memulai obrolan" + "Bergabung dalam ruangan berdasarkan alamat" + "Bukan alamat yang valid" + "Masuk…" + "Ruangan yang cocok ditemukan" + "Ruangan tidak ditemukan" + "mis. #nama-ruangan:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-it/translations.xml b/features/startchat/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..94818d7 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,12 @@ + + + "Nuova stanza" + "Elenco delle stanze" + "Si è verificato un errore durante il tentativo di avviare una chat" + "Accedi alla stanza tramite indirizzo" + "Indirizzo non valido" + "Inserisci…" + "Stanza trovata" + "Stanza non trovata" + "ad esempio #room -name:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-ka/translations.xml b/features/startchat/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..c5ccd79 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,6 @@ + + + "ახალი ოთახი" + "ოთახის კატალოგი" + "ჩატის დაწყების მცდელობისას შეცდომა მოხდა" + diff --git a/features/startchat/impl/src/main/res/values-ko/translations.xml b/features/startchat/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..2ea09bd --- /dev/null +++ b/features/startchat/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,12 @@ + + + "새 방" + "방 디렉토리" + "채팅을 시작하는 동안 오류가 발생했습니다." + "주소로 방에 참가하기" + "유효한 주소가 아닙니다" + "입력하다…" + "일치하는 방이 발견되었습니다" + "방을 찾을 수 없습니다" + "예: #room-name:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-lt/translations.xml b/features/startchat/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..699e070 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,5 @@ + + + "Naujas kambarys" + "Bandant pradėti pokalbį įvyko klaida" + diff --git a/features/startchat/impl/src/main/res/values-nb/translations.xml b/features/startchat/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..ffd8fe9 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,12 @@ + + + "Nytt rom" + "Romkatalog" + "Det oppstod en feil når du prøvde å starte en chat" + "Bli med i rommet med adresse" + "Ikke en gyldig adresse" + "Gå inn…" + "Matchende rom funnet" + "Rom ikke funnet" + "f.eks. #rom-navn:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-nl/translations.xml b/features/startchat/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..244ffdd --- /dev/null +++ b/features/startchat/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,6 @@ + + + "Nieuwe kamer" + "Kamergids" + "Er is een fout opgetreden bij het starten van een chat" + diff --git a/features/startchat/impl/src/main/res/values-pl/translations.xml b/features/startchat/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..fe6b4a2 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,12 @@ + + + "Nowy pokój" + "Katalog pokoi" + "Wystąpił błąd podczas próby rozpoczęcia czatu" + "Dołącz do pokoju za pomocą adresu" + "Nieprawidłowy adres" + "Wprowadź…" + "Znaleziono pasujący pokój" + "Nie znaleziono pokoju" + "np. #room-name:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-pt-rBR/translations.xml b/features/startchat/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..421ce1f --- /dev/null +++ b/features/startchat/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,12 @@ + + + "Nova sala" + "Diretório de salas" + "Ocorreu um erro ao tentar iniciar uma conversa" + "Entrar na sala pelo endereço" + "Não é um endereço válido" + "Digite…" + "Foi encontrada uma sala correspondente" + "Sala não encontrada" + "Por exemplo, #nome-da-sala:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-pt/translations.xml b/features/startchat/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..3efcbf7 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,12 @@ + + + "Nova sala" + "Diretório de salas" + "Ocorreu um erro ao tentar iniciar uma conversa" + "Entrar na sala pelo endereço" + "Não é um endereço válido" + "Entrar…" + "Sala correspondente encontrado" + "Sala não encontrada" + "por exemplo, #sala:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-ro/translations.xml b/features/startchat/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..315191a --- /dev/null +++ b/features/startchat/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,12 @@ + + + "Cameră nouă" + "Director de camere" + "A apărut o eroare la încercarea începerii conversației" + "Gasiți o cameră după adresă" + "Adresa nu e este validă" + "Introduceți…" + "S-a găsit o cameră" + "Nu a putut fi găsită nici o cameră" + "de exemplu #nume-camera:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-ru/translations.xml b/features/startchat/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..6b148a7 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,12 @@ + + + "Создать новую комнату" + "Каталог комнат" + "Произошла ошибка при попытке начать чат" + "Присоединиться к комнате по адресу" + "Недействительный адрес" + "Ввести…" + "Соответствующая комната найдена" + "Комната не найдена" + "прим. #room-name:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-sk/translations.xml b/features/startchat/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..16a1548 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,12 @@ + + + "Nová miestnosť" + "Adresár miestností" + "Pri pokuse o spustenie konverzácie sa vyskytla chyba" + "Pripojte sa do miestnosti podľa adresy" + "Neplatná adresa" + "Zadajte…" + "Nájdená zodpovedajúca miestnosť" + "Miestnosť sa nenašla" + "napr. #nazov-miestnosti:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-sv/translations.xml b/features/startchat/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..21cfb47 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,12 @@ + + + "Nytt rum" + "Rumskatalog" + "Ett fel uppstod när du försökte starta en chatt" + "Gå med i rum med adress" + "Inte en giltig adress" + "Ange …" + "Matchande rum hittades" + "Rummet hittades inte" + "t.ex. #rumsnamn:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-tr/translations.xml b/features/startchat/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..9612a16 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,11 @@ + + + "Yeni oda" + "Oda dizini" + "Sohbet başlatmaya çalışırken bir hata oluştu" + "Bir adres ile odaya katılın" + "Geçerli bir adres değil" + "Eşleşen oda bulundu" + "Oda bulunamadı" + "örn. #room-isim:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-uk/translations.xml b/features/startchat/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..3c2b099 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,12 @@ + + + "Нова кімната" + "Каталог кімнат" + "Під час спроби почати бесіду сталася помилка" + "Приєднатися до кімнати за адресою" + "Недійсна адреса" + "Введіть…" + "Знайдено відповідну кімнату" + "Кімната не знайдена" + "наприклад, #room-name:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-ur/translations.xml b/features/startchat/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..394f6aa --- /dev/null +++ b/features/startchat/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,6 @@ + + + "نیا کمرہ" + "کمرے کا راہنامچہ" + "گفتگو شروع کرنے کی کوشش کرتے وقت ایک خرابی واقع ہوگئی" + diff --git a/features/startchat/impl/src/main/res/values-uz/translations.xml b/features/startchat/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..b2523ab --- /dev/null +++ b/features/startchat/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,12 @@ + + + "Yangi xona" + "Xona katalogi" + "Suhbatni boshlashda xatolik yuz berdi" + "Xonaga manzil orqali kirish" + "Yaroqli manzil emas" + "Kirish…" + "Mos xona topildi" + "Xona topilmadi" + "masalan #xona-nomi:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-zh-rTW/translations.xml b/features/startchat/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..c6dcd50 --- /dev/null +++ b/features/startchat/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,12 @@ + + + "建立聊天室" + "聊天室目錄" + "嘗試開始聊天時發生錯誤" + "按地址加入聊天室" + "不是有效的位址" + "輸入……" + "找到相符的聊天室" + "找不到聊天室" + "例如 #room-name:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values-zh/translations.xml b/features/startchat/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..fcbb6af --- /dev/null +++ b/features/startchat/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,12 @@ + + + "新聊天室" + "聊天室目录" + "在开始聊天时发生了错误" + "输入地址加入房间" + "地址无效" + "输入…" + "找到匹配的房间" + "未找到房间" + "例如 #room-name:matrix.org" + diff --git a/features/startchat/impl/src/main/res/values/localazy.xml b/features/startchat/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..48b6449 --- /dev/null +++ b/features/startchat/impl/src/main/res/values/localazy.xml @@ -0,0 +1,12 @@ + + + "New room" + "Room directory" + "An error occurred when trying to start a chat" + "Join room by address" + "Not a valid address" + "Enter…" + "Matching room found" + "Room not found" + "e.g. #room-name:matrix.org" + diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartChatEntryPointTest.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartChatEntryPointTest.kt new file mode 100644 index 0000000..b00a595 --- /dev/null +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartChatEntryPointTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.features.createroom.api.FakeCreateRoomEntryPoint +import io.element.android.features.startchat.api.StartChatEntryPoint +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultStartChatEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultStartChatEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + StartChatFlowNode( + buildContext = buildContext, + plugins = plugins, + createRoomEntryPoint = FakeCreateRoomEntryPoint(), + ) + } + val callback = object : StartChatEntryPoint.Callback { + override fun onRoomCreated(roomIdOrAlias: RoomIdOrAlias, serverNames: List) = lambdaError() + override fun navigateToRoomDirectory() = lambdaError() + } + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + callback = callback, + ) + assertThat(result).isInstanceOf(StartChatFlowNode::class.java) + assertThat(result.plugins).contains(callback) + } +} diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartDMActionTest.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartDMActionTest.kt new file mode 100644 index 0000000..122775f --- /dev/null +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartDMActionTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl + +import androidx.compose.runtime.mutableStateOf +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.CreatedRoom +import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +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.FakeMatrixClient +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.test.FakeAnalyticsService +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultStartDMActionTest { + @Test + fun `when dm is found, assert state is updated with given room id`() = runTest { + val matrixClient = FakeMatrixClient().apply { + givenFindDmResult(Result.success(A_ROOM_ID)) + } + val analyticsService = FakeAnalyticsService() + val action = createStartDMAction(matrixClient, analyticsService) + val state = mutableStateOf>(AsyncAction.Uninitialized) + action.execute(aMatrixUser(), true, state) + assertThat(state.value).isEqualTo(AsyncAction.Success(A_ROOM_ID)) + assertThat(analyticsService.capturedEvents).isEmpty() + } + + @Test + fun `when finding the dm fails, assert state is updated with given error`() = runTest { + val matrixClient = FakeMatrixClient().apply { + givenFindDmResult(Result.failure(AN_EXCEPTION)) + } + val analyticsService = FakeAnalyticsService() + val action = createStartDMAction(matrixClient, analyticsService) + val state = mutableStateOf>(AsyncAction.Uninitialized) + action.execute(aMatrixUser(), true, state) + assertThat(state.value).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) + assertThat(analyticsService.capturedEvents).isEmpty() + } + + @Test + fun `when dm is not found, assert dm is created, state is updated with given room id and analytics get called`() = runTest { + val matrixClient = FakeMatrixClient().apply { + givenFindDmResult(Result.success(null)) + givenCreateDmResult(Result.success(A_ROOM_ID)) + } + val analyticsService = FakeAnalyticsService() + val action = createStartDMAction(matrixClient, analyticsService) + val state = mutableStateOf>(AsyncAction.Uninitialized) + action.execute(aMatrixUser(), true, state) + assertThat(state.value).isEqualTo(AsyncAction.Success(A_ROOM_ID)) + assertThat(analyticsService.capturedEvents).containsExactly(CreatedRoom(isDM = true)) + } + + @Test + fun `when dm is not found, and createIfDmDoesNotExist is false, assert dm is not created and state is updated to confirmation state`() = runTest { + val matrixClient = FakeMatrixClient().apply { + givenFindDmResult(Result.success(null)) + givenCreateDmResult(Result.success(A_ROOM_ID)) + } + val analyticsService = FakeAnalyticsService() + val action = createStartDMAction(matrixClient, analyticsService) + val state = mutableStateOf>(AsyncAction.Uninitialized) + val matrixUser = aMatrixUser() + action.execute(matrixUser, false, state) + assertThat(state.value).isEqualTo(ConfirmingStartDmWithMatrixUser(matrixUser)) + assertThat(analyticsService.capturedEvents).isEmpty() + } + + @Test + fun `when dm creation fails, assert state is updated with given error`() = runTest { + val matrixClient = FakeMatrixClient().apply { + givenFindDmResult(Result.success(null)) + givenCreateDmResult(Result.failure(AN_EXCEPTION)) + } + val analyticsService = FakeAnalyticsService() + val action = createStartDMAction(matrixClient, analyticsService) + val state = mutableStateOf>(AsyncAction.Uninitialized) + action.execute(aMatrixUser(), true, state) + assertThat(state.value).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) + assertThat(analyticsService.capturedEvents).isEmpty() + } + + private fun createStartDMAction( + matrixClient: MatrixClient = FakeMatrixClient(), + analyticsService: AnalyticsService = FakeAnalyticsService(), + ): DefaultStartDMAction { + return DefaultStartDMAction( + matrixClient = matrixClient, + analyticsService = analyticsService, + ) + } +} diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/FakeStartChatNavigator.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/FakeStartChatNavigator.kt new file mode 100644 index 0000000..ba1b6a4 --- /dev/null +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/FakeStartChatNavigator.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl + +import io.element.android.features.startchat.StartChatNavigator +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias + +class FakeStartChatNavigator( + private val openRoomLambda: (roomIdOrAlias: RoomIdOrAlias, serverNames: List) -> Unit = { _, _ -> }, + private val createNewRoomLambda: () -> Unit = {}, + private val showJoinRoomByAddressLambda: () -> Unit = {}, + private val dismissJoinRoomByAddressLambda: () -> Unit = {}, + private val openRoomDirectoryLambda: () -> Unit = {}, +) : StartChatNavigator { + override fun onRoomCreated(roomIdOrAlias: RoomIdOrAlias, serverNames: List) { + openRoomLambda(roomIdOrAlias, serverNames) + } + + override fun onCreateNewRoom() { + createNewRoomLambda() + } + + override fun onShowJoinRoomByAddress() { + showJoinRoomByAddressLambda() + } + + override fun onDismissJoinRoomByAddress() { + dismissJoinRoomByAddressLambda() + } + + override fun onOpenRoomDirectory() { + openRoomDirectoryLambda() + } +} diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinBaseRoomByAddressPresenterTest.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinBaseRoomByAddressPresenterTest.kt new file mode 100644 index 0000000..74c43d6 --- /dev/null +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinBaseRoomByAddressPresenterTest.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.joinbyaddress + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.startchat.StartChatNavigator +import io.element.android.features.startchat.impl.FakeStartChatNavigator +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class JoinBaseRoomByAddressPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createJoinRoomByAddressPresenter() + presenter.test { + with(awaitItem()) { + assertThat(address).isEmpty() + assertThat(addressState).isEqualTo(RoomAddressState.Unknown) + } + } + } + + @Test + fun `present - invalid address`() = runTest { + val presenter = createJoinRoomByAddressPresenter( + roomAliasHelper = FakeRoomAliasHelper( + isRoomAliasValidLambda = { false } + ) + ) + presenter.test { + with(awaitItem()) { + eventSink(JoinRoomByAddressEvents.UpdateAddress("invalid_address")) + } + with(awaitItem()) { + assertThat(address).isEqualTo("invalid_address") + assertThat(addressState).isEqualTo(RoomAddressState.Unknown) + eventSink(JoinRoomByAddressEvents.Continue) + } + // The address should be marked as invalid only after the user tries to continue + with(awaitItem()) { + assertThat(address).isEqualTo("invalid_address") + assertThat(addressState).isEqualTo(RoomAddressState.Invalid) + } + } + } + + @Test + fun `present - room found`() = runTest { + val openRoomLambda = lambdaRecorder, Unit> { _, _ -> } + val dismissJoinRoomByAddressLambda = lambdaRecorder { } + val navigator = FakeStartChatNavigator( + openRoomLambda = openRoomLambda, + dismissJoinRoomByAddressLambda = dismissJoinRoomByAddressLambda + ) + val presenter = createJoinRoomByAddressPresenter(navigator = navigator) + presenter.test { + with(awaitItem()) { + eventSink(JoinRoomByAddressEvents.UpdateAddress("#room_found:matrix.org")) + } + with(awaitItem()) { + assertThat(address).isEqualTo("#room_found:matrix.org") + assertThat(addressState).isEqualTo(RoomAddressState.Unknown) + } + with(awaitItem()) { + assertThat(address).isEqualTo("#room_found:matrix.org") + assertThat(addressState).isInstanceOf(RoomAddressState.RoomFound::class.java) + eventSink(JoinRoomByAddressEvents.Continue) + } + assert(openRoomLambda).isCalledOnce() + assert(dismissJoinRoomByAddressLambda).isCalledOnce() + } + } + + @Test + fun `present - room not found`() = runTest { + val presenter = createJoinRoomByAddressPresenter( + matrixClient = FakeMatrixClient( + resolveRoomAliasResult = { Result.failure(RuntimeException()) } + ) + ) + presenter.test { + with(awaitItem()) { + eventSink(JoinRoomByAddressEvents.UpdateAddress("#room_not_found:matrix.org")) + } + with(awaitItem()) { + assertThat(address).isEqualTo("#room_not_found:matrix.org") + assertThat(addressState).isEqualTo(RoomAddressState.Unknown) + eventSink(JoinRoomByAddressEvents.Continue) + } + with(awaitItem()) { + assertThat(address).isEqualTo("#room_not_found:matrix.org") + assertThat(addressState).isEqualTo(RoomAddressState.Resolving) + } + with(awaitItem()) { + assertThat(address).isEqualTo("#room_not_found:matrix.org") + assertThat(addressState).isEqualTo(RoomAddressState.RoomNotFound) + } + } + } + + @Test + fun `present - dismiss`() = runTest { + val dismissJoinRoomByAddressLambda = lambdaRecorder { } + val navigator = FakeStartChatNavigator( + dismissJoinRoomByAddressLambda = dismissJoinRoomByAddressLambda + ) + val presenter = createJoinRoomByAddressPresenter(navigator = navigator) + presenter.test { + with(awaitItem()) { + eventSink(JoinRoomByAddressEvents.Dismiss) + } + assert(dismissJoinRoomByAddressLambda).isCalledOnce() + } + } + + private fun createJoinRoomByAddressPresenter( + navigator: StartChatNavigator = FakeStartChatNavigator(), + matrixClient: MatrixClient = FakeMatrixClient(), + roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper(), + ): JoinRoomByAddressPresenter { + return JoinRoomByAddressPresenter( + navigator = navigator, + client = matrixClient, + roomAliasHelper = roomAliasHelper, + ) + } +} diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinBaseRoomByAddressViewTest.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinBaseRoomByAddressViewTest.kt new file mode 100644 index 0000000..eeea85c --- /dev/null +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinBaseRoomByAddressViewTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.joinbyaddress + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.startchat.impl.R +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.setSafeContent +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class JoinBaseRoomByAddressViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `entering text emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setJoinRoomByAddressView( + aJoinRoomByAddressState( + eventSink = eventsRecorder, + ) + ) + val text = rule.activity.getString(R.string.screen_start_chat_join_room_by_address_action) + rule.onNodeWithText(text).performTextInput("#address:matrix.org") + eventsRecorder.assertSingle(JoinRoomByAddressEvents.UpdateAddress("#address:matrix.org")) + } + + @Test + fun `clicking on continue emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setJoinRoomByAddressView( + aJoinRoomByAddressState( + eventSink = eventsRecorder, + ) + ) + rule.clickOn(CommonStrings.action_continue) + eventsRecorder.assertSingle(JoinRoomByAddressEvents.Continue) + } +} + +private fun AndroidComposeTestRule.setJoinRoomByAddressView( + state: JoinRoomByAddressState, +) { + setSafeContent { + JoinRoomByAddressView(state = state) + } +} diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt new file mode 100644 index 0000000..7b34b8b --- /dev/null +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.root + +import androidx.compose.runtime.MutableState +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.invitepeople.test.FakeStartDMAction +import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser +import io.element.android.features.startchat.api.StartDMAction +import io.element.android.features.startchat.impl.userlist.FakeUserListPresenter +import io.element.android.features.startchat.impl.userlist.FakeUserListPresenterFactory +import io.element.android.features.startchat.impl.userlist.UserListDataStore +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +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.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.usersearch.test.FakeUserRepository +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class StartChatPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - start DM action failure scenario`() = runTest { + val startDMFailureResult = AsyncAction.Failure(AN_EXCEPTION) + val executeResult = lambdaRecorder>, Unit> { _, _, actionState -> + actionState.value = startDMFailureResult + } + val startDMAction = FakeStartDMAction(executeResult = executeResult) + val presenter = createStartChatPresenter(startDMAction) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName) + assertThat(initialState.userListState.selectedUsers).isEmpty() + assertThat(initialState.userListState.isSearchActive).isFalse() + assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse() + val matrixUser = MatrixUser(UserId("@name:domain")) + initialState.eventSink(StartChatEvents.StartDM(matrixUser)) + awaitItem().also { state -> + assertThat(state.startDmAction).isEqualTo(startDMFailureResult) + executeResult.assertions().isCalledOnce().with( + value(matrixUser), + value(false), + any(), + ) + state.eventSink(StartChatEvents.CancelStartDM) + } + awaitItem().also { state -> + assertThat(state.startDmAction.isUninitialized()).isTrue() + } + } + } + + @Test + fun `present - start DM action success scenario`() = runTest { + val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID) + val executeResult = lambdaRecorder>, Unit> { _, _, actionState -> + actionState.value = startDMSuccessResult + } + val startDMAction = FakeStartDMAction(executeResult = executeResult) + val presenter = createStartChatPresenter(startDMAction) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(initialState.applicationName).isEqualTo(aBuildMeta().applicationName) + assertThat(initialState.userListState.selectedUsers).isEmpty() + assertThat(initialState.userListState.isSearchActive).isFalse() + assertThat(initialState.userListState.isMultiSelectionEnabled).isFalse() + val matrixUser = MatrixUser(UserId("@name:domain")) + initialState.eventSink(StartChatEvents.StartDM(matrixUser)) + awaitItem().also { state -> + assertThat(state.startDmAction).isEqualTo(startDMSuccessResult) + executeResult.assertions().isCalledOnce().with( + value(matrixUser), + value(false), + any(), + ) + } + } + } + + @Test + fun `present - start DM action confirmation scenario - cancel`() = runTest { + val matrixUser = MatrixUser(UserId("@name:domain")) + val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser) + val executeResult = lambdaRecorder>, Unit> { _, _, actionState -> + actionState.value = startDMConfirmationResult + } + val startDMAction = FakeStartDMAction(executeResult = executeResult) + val presenter = createStartChatPresenter(startDMAction) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + initialState.eventSink(StartChatEvents.StartDM(matrixUser)) + val confirmingState = awaitItem() + assertThat(confirmingState.startDmAction).isEqualTo(startDMConfirmationResult) + executeResult.assertions().isCalledOnce().with( + value(matrixUser), + value(false), + any(), + ) + // Cancelling should not create the DM + confirmingState.eventSink(StartChatEvents.CancelStartDM) + val finalState = awaitItem() + assertThat(finalState.startDmAction.isUninitialized()).isTrue() + executeResult.assertions().isCalledExactly(1) + } + } + + @Test + fun `present - start DM action confirmation scenario - confirm`() = runTest { + val matrixUser = MatrixUser(UserId("@name:domain")) + val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser) + val executeResult = lambdaRecorder>, Unit> { _, _, actionState -> + actionState.value = startDMConfirmationResult + } + val startDMAction = FakeStartDMAction(executeResult = executeResult) + val presenter = createStartChatPresenter(startDMAction) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.startDmAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + initialState.eventSink(StartChatEvents.StartDM(matrixUser)) + val confirmingState = awaitItem() + assertThat(confirmingState.startDmAction).isEqualTo(startDMConfirmationResult) + executeResult.assertions().isCalledOnce().with( + value(matrixUser), + value(false), + any(), + ) + // Start DM again should invoke the action with createIfDmDoesNotExist = true + confirmingState.eventSink(StartChatEvents.StartDM(matrixUser)) + executeResult.assertions().isCalledExactly(2).withSequence( + listOf(value(matrixUser), value(false), any()), + listOf(value(matrixUser), value(true), any()), + ) + } + } + + @Test + fun `present - room directory search`() = runTest { + val presenter = createStartChatPresenter(isRoomDirectorySearchEnabled = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + awaitItem().let { state -> + assertThat(state.isRoomDirectorySearchEnabled).isTrue() + } + } + } +} + +internal fun createStartChatPresenter( + startDMAction: StartDMAction = FakeStartDMAction(), + isRoomDirectorySearchEnabled: Boolean = false, +): StartChatPresenter { + val featureFlagService = FakeFeatureFlagService( + initialState = mapOf( + FeatureFlags.RoomDirectorySearch.key to isRoomDirectorySearchEnabled, + ), + ) + return StartChatPresenter( + presenterFactory = FakeUserListPresenterFactory(FakeUserListPresenter()), + userRepository = FakeUserRepository(), + userListDataStore = UserListDataStore(), + startDMAction = startDMAction, + featureFlagService = featureFlagService, + buildMeta = aBuildMeta(), + ) +} diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatViewTest.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatViewTest.kt new file mode 100644 index 0000000..9237f34 --- /dev/null +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatViewTest.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.root + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.startchat.impl.R +import io.element.android.features.startchat.impl.userlist.aRecentDirectRoomList +import io.element.android.features.startchat.impl.userlist.aUserListState +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.ui.model.getBestName +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class StartChatViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setStartChatView( + aCreateRoomRootState( + eventSink = eventsRecorder, + ), + onCloseClick = it + ) + rule.pressBack() + } + } + + @Test + fun `clicking on New room invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setStartChatView( + aCreateRoomRootState( + eventSink = eventsRecorder, + ), + onNewRoomClick = it + ) + rule.clickOn(R.string.screen_create_room_action_create_room) + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on Invite people invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setStartChatView( + aCreateRoomRootState( + applicationName = "test", + eventSink = eventsRecorder, + ), + onInviteFriendsClick = it + ) + val text = rule.activity.getString(CommonStrings.action_invite_friends_to_app, "test") + rule.onNodeWithText(text).performClick() + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on a user suggestion invokes the expected callback`() { + val recentDirectRoomList = aRecentDirectRoomList() + val firstRoom = recentDirectRoomList[0] + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnceWithParam(firstRoom.roomId) { + rule.setStartChatView( + aCreateRoomRootState( + userListState = aUserListState( + recentDirectRooms = recentDirectRoomList + ), + eventSink = eventsRecorder, + ), + onOpenDM = it + ) + rule.onNodeWithText(firstRoom.matrixUser.getBestName()).performClick() + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on Join room by address invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setStartChatView( + aCreateRoomRootState( + eventSink = eventsRecorder, + ), + onJoinRoomByAddressClick = it + ) + rule.clickOn(R.string.screen_start_chat_join_room_by_address_action) + } + } + + @Test + fun `clicking on room directory invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setStartChatView( + aCreateRoomRootState( + eventSink = eventsRecorder, + isRoomDirectorySearchEnabled = true + ), + onRoomDirectorySearchClick = it + ) + rule.clickOn(R.string.screen_room_directory_search_title) + } + } +} + +private fun AndroidComposeTestRule.setStartChatView( + state: StartChatState, + onCloseClick: () -> Unit = EnsureNeverCalled(), + onNewRoomClick: () -> Unit = EnsureNeverCalled(), + onOpenDM: (RoomId) -> Unit = EnsureNeverCalledWithParam(), + onInviteFriendsClick: () -> Unit = EnsureNeverCalled(), + onJoinRoomByAddressClick: () -> Unit = EnsureNeverCalled(), + onRoomDirectorySearchClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + StartChatView( + state = state, + onCloseClick = onCloseClick, + onNewRoomClick = onNewRoomClick, + onOpenDM = onOpenDM, + onInviteFriendsClick = onInviteFriendsClick, + onJoinByAddressClick = onJoinRoomByAddressClick, + onRoomDirectorySearchClick = onRoomDirectorySearchClick, + ) + } +} diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/userlist/DefaultUserListPresenterTest.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/userlist/DefaultUserListPresenterTest.kt new file mode 100644 index 0000000..c02b59f --- /dev/null +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/userlist/DefaultUserListPresenterTest.kt @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.impl.userlist + +import app.cash.molecule.RecompositionMode +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.test.FakeMatrixClient +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.api.UserSearchResultState +import io.element.android.libraries.usersearch.test.FakeUserRepository +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DefaultUserListPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private val userRepository = FakeUserRepository() + + @Test + fun `present - initial state for single selection`() = runTest { + val presenter = + DefaultUserListPresenter( + UserListPresenterArgs(selectionMode = SelectionMode.Single), + userRepository, + UserListDataStore(), + FakeMatrixClient(), + ) + moleculeFlow(RecompositionMode.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.Initial::class.java) + } + } + + @Test + fun `present - initial state for multiple selection`() = runTest { + val presenter = + DefaultUserListPresenter( + UserListPresenterArgs(selectionMode = SelectionMode.Multiple), + userRepository, + UserListDataStore(), + FakeMatrixClient(), + ) + moleculeFlow(RecompositionMode.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.Initial::class.java) + } + } + + @Test + fun `present - update search query`() = runTest { + val presenter = + DefaultUserListPresenter( + UserListPresenterArgs(selectionMode = SelectionMode.Single), + userRepository, + UserListDataStore(), + FakeMatrixClient(), + ) + moleculeFlow(RecompositionMode.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(), + FakeMatrixClient(), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + + initialState.eventSink(UserListEvents.UpdateSearchQuery("alice")) + assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java) + assertThat(userRepository.providedQuery).isEqualTo("alice") + skipItems(2) + + // When the user repository emits a result, it's copied to the state + val result = UserSearchResultState( + results = listOf(UserSearchResult(aMatrixUser())), + isSearching = false, + ) + userRepository.emitState(result) + awaitItem().also { state -> + assertThat(state.searchResults).isEqualTo( + SearchBarResultState.Results( + persistentListOf(UserSearchResult(aMatrixUser())) + ) + ) + assertThat(state.showSearchLoader).isFalse() + } + // When the user repository emits another result, it replaces the previous value + val newResult = UserSearchResultState( + results = aMatrixUserList().map { UserSearchResult(it) }, + isSearching = false, + ) + userRepository.emitState(newResult) + awaitItem().also { state -> + assertThat(state.searchResults).isEqualTo( + SearchBarResultState.Results( + aMatrixUserList().map { UserSearchResult(it) } + ) + ) + assertThat(state.showSearchLoader).isFalse() + } + } + } + + @Test + fun `present - presents search results when not found`() = runTest { + val presenter = + DefaultUserListPresenter( + UserListPresenterArgs( + selectionMode = SelectionMode.Single, + ), + userRepository, + UserListDataStore(), + FakeMatrixClient(), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + + initialState.eventSink(UserListEvents.UpdateSearchQuery("alice")) + assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java) + assertThat(userRepository.providedQuery).isEqualTo("alice") + skipItems(2) + + // When the results list is empty, the state is set to NoResults + userRepository.emitState(UserSearchResultState(results = emptyList(), isSearching = false)) + assertThat(awaitItem().searchResults).isInstanceOf(SearchBarResultState.NoResultsFound::class.java) + } + } + + @Test + fun `present - select a user`() = runTest { + val presenter = + DefaultUserListPresenter( + UserListPresenterArgs(selectionMode = SelectionMode.Single), + userRepository, + UserListDataStore(), + FakeMatrixClient(), + ) + moleculeFlow(RecompositionMode.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/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/userlist/FakeUserListPresenter.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/userlist/FakeUserListPresenter.kt new file mode 100644 index 0000000..960f596 --- /dev/null +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/userlist/FakeUserListPresenter.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.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/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/userlist/FakeUserListPresenterFactory.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/userlist/FakeUserListPresenterFactory.kt new file mode 100644 index 0000000..d61ae25 --- /dev/null +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/userlist/FakeUserListPresenterFactory.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.startchat.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/startchat/test/build.gradle.kts b/features/startchat/test/build.gradle.kts new file mode 100644 index 0000000..d6d1471 --- /dev/null +++ b/features/startchat/test/build.gradle.kts @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.invitepeople.test" +} + +dependencies { + implementation(libs.coroutines.core) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrix.test) + implementation(projects.libraries.architecture) + implementation(projects.tests.testutils) + api(projects.features.startchat.api) +} diff --git a/features/startchat/test/src/main/kotlin/io/element/android/features/invitepeople/test/FakeStartDMAction.kt b/features/startchat/test/src/main/kotlin/io/element/android/features/invitepeople/test/FakeStartDMAction.kt new file mode 100644 index 0000000..c4969df --- /dev/null +++ b/features/startchat/test/src/main/kotlin/io/element/android/features/invitepeople/test/FakeStartDMAction.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.invitepeople.test + +import androidx.compose.runtime.MutableState +import io.element.android.features.startchat.api.StartDMAction +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeStartDMAction( + private val executeResult: (MatrixUser, Boolean, MutableState>) -> Unit = { _, _, _ -> + lambdaError() + } +) : StartDMAction { + override suspend fun execute( + matrixUser: MatrixUser, + createIfDmDoesNotExist: Boolean, + actionState: MutableState>, + ) { + executeResult(matrixUser, createIfDmDoesNotExist, actionState) + } +} diff --git a/features/userprofile/api/build.gradle.kts b/features/userprofile/api/build.gradle.kts new file mode 100644 index 0000000..4527869 --- /dev/null +++ b/features/userprofile/api/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.userprofile.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.matrix.api) +} diff --git a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEntryPoint.kt b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEntryPoint.kt new file mode 100644 index 0000000..6d02280 --- /dev/null +++ b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEntryPoint.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.userprofile.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.architecture.NodeInputs +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId + +interface UserProfileEntryPoint : FeatureEntryPoint { + data class Params(val userId: UserId) : NodeInputs + + interface Callback : Plugin { + fun navigateToRoom(roomId: RoomId) + } + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + callback: Callback, + ): Node +} diff --git a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEvents.kt b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEvents.kt new file mode 100644 index 0000000..55c9c40 --- /dev/null +++ b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileEvents.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.userprofile.api + +sealed interface UserProfileEvents { + data object StartDM : UserProfileEvents + data object ClearStartDMState : UserProfileEvents + data class BlockUser(val needsConfirmation: Boolean = false) : UserProfileEvents + data class UnblockUser(val needsConfirmation: Boolean = false) : UserProfileEvents + data object ClearBlockUserError : UserProfileEvents + data object ClearConfirmationDialog : UserProfileEvents + data object WithdrawVerification : UserProfileEvents + data class CopyToClipboard(val text: String) : UserProfileEvents +} diff --git a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfilePresenterFactory.kt b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfilePresenterFactory.kt new file mode 100644 index 0000000..4bf68ae --- /dev/null +++ b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfilePresenterFactory.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.userprofile.api + +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.core.UserId + +fun interface UserProfilePresenterFactory { + fun create(userId: UserId): Presenter +} diff --git a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt new file mode 100644 index 0000000..0e0016e --- /dev/null +++ b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.userprofile.api + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId + +data class UserProfileState( + val userId: UserId, + val userName: String?, + val avatarUrl: String?, + val verificationState: UserProfileVerificationState, + val isBlocked: AsyncData, + val startDmActionState: AsyncAction, + val displayConfirmationDialog: ConfirmationDialog?, + val isCurrentUser: Boolean, + val dmRoomId: RoomId?, + val canCall: Boolean, + val snackbarMessage: SnackbarMessage?, + val eventSink: (UserProfileEvents) -> Unit +) { + enum class ConfirmationDialog { + Block, + Unblock + } +} + +enum class UserProfileVerificationState { + UNKNOWN, + VERIFIED, + UNVERIFIED, + VERIFICATION_VIOLATION, +} diff --git a/features/userprofile/impl/build.gradle.kts b/features/userprofile/impl/build.gradle.kts new file mode 100644 index 0000000..0b65441 --- /dev/null +++ b/features/userprofile/impl/build.gradle.kts @@ -0,0 +1,53 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.userprofile.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + 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.mediaviewer.api) + implementation(projects.features.call.api) + implementation(projects.features.enterprise.api) + implementation(projects.features.verifysession.api) + api(projects.features.userprofile.api) + api(projects.features.userprofile.shared) + implementation(libs.coil.compose) + implementation(projects.features.startchat.api) + implementation(projects.services.analytics.api) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.mediaviewer.test) + testImplementation(projects.features.call.test) + testImplementation(projects.features.verifysession.test) + testImplementation(projects.features.startchat.test) + testImplementation(projects.features.enterprise.test) +} diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/DefaultUserProfileEntryPoint.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/DefaultUserProfileEntryPoint.kt new file mode 100644 index 0000000..e1b1519 --- /dev/null +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/DefaultUserProfileEntryPoint.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.userprofile.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.userprofile.api.UserProfileEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultUserProfileEntryPoint : UserProfileEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: UserProfileEntryPoint.Params, + callback: UserProfileEntryPoint.Callback, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf(params, callback), + ) + } +} diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/DefaultUserProfilePresenterFactory.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/DefaultUserProfilePresenterFactory.kt new file mode 100644 index 0000000..03d803f --- /dev/null +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/DefaultUserProfilePresenterFactory.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.userprofile.impl + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.userprofile.api.UserProfilePresenterFactory +import io.element.android.features.userprofile.api.UserProfileState +import io.element.android.features.userprofile.impl.root.UserProfilePresenter +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.UserId + +@ContributesBinding(SessionScope::class) +class DefaultUserProfilePresenterFactory( + private val factory: UserProfilePresenter.Factory, +) : UserProfilePresenterFactory { + override fun create(userId: UserId): Presenter = factory.create(userId) +} diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt new file mode 100644 index 0000000..f95ccce --- /dev/null +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.userprofile.impl + +import android.os.Parcelable +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.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.call.api.CallType +import io.element.android.features.call.api.ElementCallEntryPoint +import io.element.android.features.userprofile.api.UserProfileEntryPoint +import io.element.android.features.userprofile.impl.root.UserProfileNode +import io.element.android.features.userprofile.shared.UserProfileNodeHelper +import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.callback +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.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.UserId +import io.element.android.libraries.matrix.api.verification.VerificationRequest +import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +@AssistedInject +class UserProfileFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val elementCallEntryPoint: ElementCallEntryPoint, + private val sessionId: SessionId, + private val mediaViewerEntryPoint: MediaViewerEntryPoint, + private val outgoingVerificationEntryPoint: OutgoingVerificationEntryPoint, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + sealed interface NavTarget : Parcelable { + @Parcelize + data object Root : NavTarget + + @Parcelize + data class AvatarPreview(val name: String, val avatarUrl: String) : NavTarget + + @Parcelize + data class VerifyUser(val userId: UserId) : NavTarget + } + + private val callback: UserProfileEntryPoint.Callback = callback() + private val inputs = inputs() + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Root -> { + val callback = object : UserProfileNodeHelper.Callback { + override fun navigateToAvatarPreview(username: String, avatarUrl: String) { + backstack.push(NavTarget.AvatarPreview(username, avatarUrl)) + } + + override fun navigateToRoom(roomId: RoomId) { + callback.navigateToRoom(roomId) + } + + override fun startCall(dmRoomId: RoomId) { + elementCallEntryPoint.startCall(CallType.RoomCall(sessionId = sessionId, roomId = dmRoomId)) + } + + override fun startVerifyUserFlow(userId: UserId) { + backstack.push(NavTarget.VerifyUser(userId)) + } + } + val params = UserProfileNode.UserProfileInputs(userId = inputs.userId) + createNode(buildContext, listOf(callback, params)) + } + is NavTarget.AvatarPreview -> { + val callback = object : MediaViewerEntryPoint.Callback { + override fun onDone() { + backstack.pop() + } + + override fun viewInTimeline(eventId: EventId) { + // Cannot happen + } + + override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) { + // Cannot happen + } + } + val params = mediaViewerEntryPoint.createParamsForAvatar( + filename = navTarget.name, + avatarUrl = navTarget.avatarUrl, + ) + mediaViewerEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = params, + callback = callback, + ) + } + is NavTarget.VerifyUser -> { + val params = OutgoingVerificationEntryPoint.Params( + showDeviceVerifiedScreen = false, + verificationRequest = VerificationRequest.Outgoing.User(userId = navTarget.userId) + ) + outgoingVerificationEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = params, + callback = object : OutgoingVerificationEntryPoint.Callback { + override fun navigateToLearnMoreAboutEncryption() { + // No op + } + + override fun onBack() { + // No op + } + + override fun onDone() { + // No op + } + } + ) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView() + } +} diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfileNode.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfileNode.kt new file mode 100644 index 0000000..71f95a2 --- /dev/null +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfileNode.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.userprofile.impl.root + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.annotations.ContributesNode +import io.element.android.features.userprofile.shared.UserProfileNodeHelper +import io.element.android.features.userprofile.shared.UserProfileView +import io.element.android.libraries.architecture.NodeInputs +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.permalink.PermalinkBuilder +import io.element.android.services.analytics.api.AnalyticsService + +@ContributesNode(SessionScope::class) +@AssistedInject +class UserProfileNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val analyticsService: AnalyticsService, + private val permalinkBuilder: PermalinkBuilder, + presenterFactory: UserProfilePresenter.Factory, +) : Node(buildContext, plugins = plugins) { + data class UserProfileInputs( + val userId: UserId + ) : NodeInputs + + private val inputs = inputs() + private val callback = inputs() + private val presenter = presenterFactory.create(userId = inputs.userId) + private val userProfileNodeHelper = UserProfileNodeHelper(inputs.userId) + + init { + lifecycle.subscribe( + onResume = { + analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.User)) + } + ) + } + + @Composable + override fun View(modifier: Modifier) { + val context = LocalContext.current + + fun onShareUser() { + userProfileNodeHelper.onShareUser(context, permalinkBuilder) + } + + fun onStartDM(roomId: RoomId) { + callback.navigateToRoom(roomId) + } + + val state = presenter.present() + + UserProfileView( + state = state, + modifier = modifier, + goBack = this::navigateUp, + onShareUser = ::onShareUser, + onOpenDm = ::onStartDM, + onStartCall = callback::startCall, + openAvatarPreview = callback::navigateToAvatarPreview, + onVerifyClick = callback::startVerifyUserFlow, + ) + } +} diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt new file mode 100644 index 0000000..9bf31e0 --- /dev/null +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.userprofile.impl.root + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.enterprise.api.SessionEnterpriseService +import io.element.android.features.startchat.api.StartDMAction +import io.element.android.features.userprofile.api.UserProfileEvents +import io.element.android.features.userprofile.api.UserProfileState +import io.element.android.features.userprofile.api.UserProfileState.ConfirmationDialog +import io.element.android.features.userprofile.api.UserProfileVerificationState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +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.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +@AssistedInject +class UserProfilePresenter( + @Assisted private val userId: UserId, + private val client: MatrixClient, + private val startDMAction: StartDMAction, + private val sessionEnterpriseService: SessionEnterpriseService, +) : Presenter { + @AssistedFactory + interface Factory { + fun create(userId: UserId): UserProfilePresenter + } + + @Composable + private fun getDmRoomId(): State { + return produceState(initialValue = null) { + value = client.findDM(userId).getOrNull() + } + } + + @Composable + private fun getCanCall(roomId: RoomId?): State { + val isElementCallAvailable by produceState(initialValue = false, roomId) { + value = sessionEnterpriseService.isElementCallAvailable() + } + + return produceState(initialValue = false, isElementCallAvailable, roomId) { + value = when { + isElementCallAvailable.not() -> false + client.isMe(userId) -> false + else -> + roomId + ?.let { client.getRoom(it) } + ?.use { room -> + room.canUserJoinCall(client.sessionId).getOrNull() + } + .orFalse() + } + } + } + + @Composable + override fun present(): UserProfileState { + val coroutineScope = rememberCoroutineScope() + val isCurrentUser = remember { client.isMe(userId) } + var confirmationDialog by remember { mutableStateOf(null) } + val startDmActionState: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + val isBlocked: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) } + val dmRoomId by getDmRoomId() + val canCall by getCanCall(dmRoomId) + LaunchedEffect(Unit) { + client.ignoredUsersFlow + .map { ignoredUsers -> userId in ignoredUsers } + .distinctUntilChanged() + .onEach { isBlocked.value = AsyncData.Success(it) } + .launchIn(this) + } + val userProfile by produceState(null) { value = client.getProfile(userId).getOrNull() } + + fun handleEvent(event: UserProfileEvents) { + when (event) { + is UserProfileEvents.BlockUser -> { + if (event.needsConfirmation) { + confirmationDialog = ConfirmationDialog.Block + } else { + confirmationDialog = null + coroutineScope.blockUser(isBlocked) + } + } + is UserProfileEvents.UnblockUser -> { + if (event.needsConfirmation) { + confirmationDialog = ConfirmationDialog.Unblock + } else { + confirmationDialog = null + coroutineScope.unblockUser(isBlocked) + } + } + UserProfileEvents.ClearConfirmationDialog -> confirmationDialog = null + UserProfileEvents.ClearBlockUserError -> { + isBlocked.value = AsyncData.Success(isBlocked.value.dataOrNull().orFalse()) + } + UserProfileEvents.StartDM -> { + coroutineScope.launch { + startDMAction.execute( + matrixUser = userProfile ?: MatrixUser(userId), + createIfDmDoesNotExist = startDmActionState.value is AsyncAction.Confirming, + actionState = startDmActionState, + ) + } + } + UserProfileEvents.ClearStartDMState -> { + startDmActionState.value = AsyncAction.Uninitialized + } + // Do nothing for other event as they are handled by the RoomMemberDetailsPresenter if needed + UserProfileEvents.WithdrawVerification, + is UserProfileEvents.CopyToClipboard -> Unit + } + } + + return UserProfileState( + userId = userId, + userName = userProfile?.displayName, + avatarUrl = userProfile?.avatarUrl, + isBlocked = isBlocked.value, + verificationState = UserProfileVerificationState.UNKNOWN, + startDmActionState = startDmActionState.value, + displayConfirmationDialog = confirmationDialog, + isCurrentUser = isCurrentUser, + dmRoomId = dmRoomId, + canCall = canCall, + snackbarMessage = null, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.blockUser( + isBlockedState: MutableState>, + ) = launch { + isBlockedState.value = AsyncData.Loading(false) + client.ignoreUser(userId) + .onFailure { + isBlockedState.value = AsyncData.Failure(it, false) + } + // Note: on success, ignoredUsersFlow will emit new item. + } + + private fun CoroutineScope.unblockUser( + isBlockedState: MutableState>, + ) = launch { + isBlockedState.value = AsyncData.Loading(true) + client.unignoreUser(userId) + .onFailure { + isBlockedState.value = AsyncData.Failure(it, true) + } + // Note: on success, ignoredUsersFlow will emit new item. + } +} diff --git a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/DefaultUserProfileEntryPointTest.kt b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/DefaultUserProfileEntryPointTest.kt new file mode 100644 index 0000000..652bbc6 --- /dev/null +++ b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/DefaultUserProfileEntryPointTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.userprofile.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.features.call.test.FakeElementCallEntryPoint +import io.element.android.features.userprofile.api.UserProfileEntryPoint +import io.element.android.features.verifysession.test.FakeOutgoingVerificationEntryPoint +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.mediaviewer.test.FakeMediaViewerEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultUserProfileEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultUserProfileEntryPoint() + + val parentNode = TestParentNode.create { buildContext, plugins -> + UserProfileFlowNode( + buildContext = buildContext, + plugins = plugins, + sessionId = A_SESSION_ID, + elementCallEntryPoint = FakeElementCallEntryPoint(), + mediaViewerEntryPoint = FakeMediaViewerEntryPoint(), + outgoingVerificationEntryPoint = FakeOutgoingVerificationEntryPoint(), + ) + } + val callback = object : UserProfileEntryPoint.Callback { + override fun navigateToRoom(roomId: RoomId) { + lambdaError() + } + } + val params = UserProfileEntryPoint.Params( + userId = A_USER_ID, + ) + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + params = params, + callback = callback, + ) + assertThat(result).isInstanceOf(UserProfileFlowNode::class.java) + assertThat(result.plugins).contains(params) + assertThat(result.plugins).contains(callback) + } +} diff --git a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt new file mode 100644 index 0000000..aa984f0 --- /dev/null +++ b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt @@ -0,0 +1,417 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.userprofile.impl + +import androidx.compose.runtime.MutableState +import app.cash.molecule.RecompositionMode +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.enterprise.test.FakeSessionEnterpriseService +import io.element.android.features.invitepeople.test.FakeStartDMAction +import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser +import io.element.android.features.startchat.api.StartDMAction +import io.element.android.features.userprofile.api.UserProfileEvents +import io.element.android.features.userprofile.api.UserProfileState +import io.element.android.features.userprofile.api.UserProfileVerificationState +import io.element.android.features.userprofile.impl.root.UserProfilePresenter +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.MatrixClient +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.encryption.identity.IdentityState +import io.element.android.libraries.matrix.api.user.MatrixUser +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_USER_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.encryption.FakeEncryptionService +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class UserProfilePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - returns the user profile data`() = runTest { + val matrixUser = aMatrixUser(A_USER_ID.value, "Alice", "anAvatarUrl") + val client = createFakeMatrixClient().apply { + givenGetProfileResult(A_USER_ID, Result.success(matrixUser)) + } + val presenter = createUserProfilePresenter( + client = client, + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.userId).isEqualTo(matrixUser.userId) + assertThat(initialState.userName).isEqualTo(matrixUser.displayName) + assertThat(initialState.avatarUrl).isEqualTo(matrixUser.avatarUrl) + assertThat(initialState.isBlocked).isEqualTo(AsyncData.Success(false)) + assertThat(initialState.verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN) + assertThat(initialState.dmRoomId).isEqualTo(A_ROOM_ID) + assertThat(initialState.canCall).isFalse() + } + } + + @Test + fun `present - canCall is true when all the conditions are met`() { + testCanCall( + expectedResult = true, + skipItems = 3, + checkThatRoomIsDestroyed = true, + ) + } + + @Test + fun `present - canCall is false when canUserJoinCall returns false`() { + testCanCall( + canUserJoinCallResult = Result.success(false), + expectedResult = false, + ) + } + + @Test + fun `present - canCall is false when canUserJoinCall fails`() { + testCanCall( + canUserJoinCallResult = Result.failure(AN_EXCEPTION), + expectedResult = false, + ) + } + + @Test + fun `present - canCall is false when there is no DM`() { + testCanCall( + dmRoom = null, + expectedResult = false, + ) + } + + @Test + fun `present - canCall is false when room is not found`() { + testCanCall( + canFindRoom = false, + expectedResult = false, + ) + } + + @Test + fun `present - canCall is false when call is not available`() { + testCanCall( + isElementCallAvailable = false, + expectedResult = false, + ) + } + + private fun testCanCall( + isElementCallAvailable: Boolean = true, + canUserJoinCallResult: Result = Result.success(true), + dmRoom: RoomId? = A_ROOM_ID, + canFindRoom: Boolean = true, + expectedResult: Boolean, + skipItems: Int = 1, + checkThatRoomIsDestroyed: Boolean = false, + ) = runTest { + val room = FakeBaseRoom( + canUserJoinCallResult = { canUserJoinCallResult }, + ) + val client = createFakeMatrixClient().apply { + if (canFindRoom) { + givenGetRoomResult(A_ROOM_ID, room) + } + givenFindDmResult(Result.success(dmRoom)) + } + val presenter = createUserProfilePresenter( + userId = A_USER_ID_2, + client = client, + isElementCallAvailable = isElementCallAvailable, + ) + presenter.test { + val initialState = awaitFirstItem(skipItems) + assertThat(initialState.canCall).isEqualTo(expectedResult) + } + if (checkThatRoomIsDestroyed) { + room.assertDestroyed() + } + } + + @Test + fun `present - returns empty data in case of failure`() = runTest { + val client = createFakeMatrixClient().apply { + givenGetProfileResult(A_USER_ID, Result.failure(AN_EXCEPTION)) + } + val presenter = createUserProfilePresenter( + client = client, + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.userId).isEqualTo(A_USER_ID) + assertThat(initialState.userName).isNull() + assertThat(initialState.avatarUrl).isNull() + assertThat(initialState.isBlocked).isEqualTo(AsyncData.Success(false)) + } + } + + @Test + fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest { + val presenter = createUserProfilePresenter() + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = true)) + + val dialogState = awaitItem() + assertThat(dialogState.displayConfirmationDialog).isEqualTo(UserProfileState.ConfirmationDialog.Block) + + dialogState.eventSink(UserProfileEvents.ClearConfirmationDialog) + assertThat(awaitItem().displayConfirmationDialog).isNull() + } + } + + @Test + fun `present - BlockUser and UnblockUser without confirmation change the 'blocked' state`() = runTest { + val ignoredUsersFlow = MutableStateFlow(persistentListOf()) + val client = createFakeMatrixClient(ignoredUsersFlow = ignoredUsersFlow) + val presenter = createUserProfilePresenter( + client = client, + userId = A_USER_ID + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false)) + assertThat(awaitItem().isBlocked.isLoading()).isTrue() + ignoredUsersFlow.emit(persistentListOf(A_USER_ID)) + assertThat(awaitItem().isBlocked.dataOrNull()).isTrue() + + initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = false)) + assertThat(awaitItem().isBlocked.isLoading()).isTrue() + ignoredUsersFlow.emit(persistentListOf()) + assertThat(awaitItem().isBlocked.dataOrNull()).isFalse() + } + } + + @Test + fun `present - BlockUser with error`() = runTest { + val matrixClient = createFakeMatrixClient( + ignoreUserResult = { Result.failure(AN_EXCEPTION) } + ) + val presenter = createUserProfilePresenter(client = matrixClient) + presenter.test { + val initialState = awaitFirstItem(count = 2) + initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false)) + assertThat(awaitItem().isBlocked.isLoading()).isTrue() + val errorState = awaitItem() + assertThat(errorState.isBlocked.errorOrNull()).isEqualTo(AN_EXCEPTION) + // Clear error + initialState.eventSink(UserProfileEvents.ClearBlockUserError) + assertThat(awaitItem().isBlocked).isEqualTo(AsyncData.Success(false)) + } + } + + @Test + fun `present - UnblockUser with error`() = runTest { + val matrixClient = createFakeMatrixClient( + unIgnoreUserResult = { Result.failure(AN_EXCEPTION) } + ) + val presenter = createUserProfilePresenter(client = matrixClient) + presenter.test { + val initialState = awaitFirstItem(count = 2) + initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = false)) + assertThat(awaitItem().isBlocked.isLoading()).isTrue() + val errorState = awaitItem() + assertThat(errorState.isBlocked.errorOrNull()).isEqualTo(AN_EXCEPTION) + // Clear error + initialState.eventSink(UserProfileEvents.ClearBlockUserError) + assertThat(awaitItem().isBlocked).isEqualTo(AsyncData.Success(true)) + } + } + + @Test + fun `present - UnblockUser needing confirmation displays confirmation dialog`() = runTest { + val presenter = createUserProfilePresenter() + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = true)) + + val dialogState = awaitItem() + assertThat(dialogState.displayConfirmationDialog).isEqualTo(UserProfileState.ConfirmationDialog.Unblock) + + dialogState.eventSink(UserProfileEvents.ClearConfirmationDialog) + assertThat(awaitItem().displayConfirmationDialog).isNull() + } + } + + @Test + fun `present - start DM action failure scenario`() = runTest { + val startDMFailureResult = AsyncAction.Failure(AN_EXCEPTION) + val executeResult = lambdaRecorder>, Unit> { _, _, actionState -> + actionState.value = startDMFailureResult + } + val startDMAction = FakeStartDMAction(executeResult = executeResult) + val presenter = createUserProfilePresenter(startDMAction = startDMAction) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java) + val matrixUser = MatrixUser(UserId("@alice:server.org")) + initialState.eventSink(UserProfileEvents.StartDM) + awaitItem().also { state -> + assertThat(state.startDmActionState).isEqualTo(startDMFailureResult) + executeResult.assertions().isCalledOnce().with( + value(matrixUser), + value(false), + any(), + ) + state.eventSink(UserProfileEvents.ClearStartDMState) + } + awaitItem().also { state -> + assertThat(state.startDmActionState.isUninitialized()).isTrue() + } + } + } + + @Test + fun `present - start DM action success scenario`() = runTest { + val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID) + val executeResult = lambdaRecorder>, Unit> { _, _, actionState -> + actionState.value = startDMSuccessResult + } + val startDMAction = FakeStartDMAction(executeResult = executeResult) + val presenter = createUserProfilePresenter(startDMAction = startDMAction) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java) + val matrixUser = MatrixUser(UserId("@alice:server.org")) + initialState.eventSink(UserProfileEvents.StartDM) + awaitItem().also { state -> + assertThat(state.startDmActionState).isEqualTo(startDMSuccessResult) + executeResult.assertions().isCalledOnce().with( + value(matrixUser), + value(false), + any(), + ) + } + } + } + + @Test + fun `present - start DM action confirmation scenario - cancel`() = runTest { + val matrixUser = MatrixUser(UserId("@alice:server.org")) + val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser) + val executeResult = lambdaRecorder>, Unit> { _, _, actionState -> + actionState.value = startDMConfirmationResult + } + val startDMAction = FakeStartDMAction(executeResult = executeResult) + val presenter = createUserProfilePresenter(startDMAction = startDMAction) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java) + initialState.eventSink(UserProfileEvents.StartDM) + val confirmingState = awaitItem() + assertThat(confirmingState.startDmActionState).isEqualTo(startDMConfirmationResult) + executeResult.assertions().isCalledOnce().with( + value(matrixUser), + value(false), + any(), + ) + // Cancelling should not create the DM + confirmingState.eventSink(UserProfileEvents.ClearStartDMState) + val finalState = awaitItem() + assertThat(finalState.startDmActionState.isUninitialized()).isTrue() + executeResult.assertions().isCalledExactly(1) + } + } + + @Test + fun `present - start DM action confirmation scenario - confirm`() = runTest { + val matrixUser = MatrixUser(UserId("@alice:server.org")) + val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser) + val executeResult = lambdaRecorder>, Unit> { _, _, actionState -> + actionState.value = startDMConfirmationResult + } + val startDMAction = FakeStartDMAction(executeResult = executeResult) + val presenter = createUserProfilePresenter(startDMAction = startDMAction) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java) + initialState.eventSink(UserProfileEvents.StartDM) + val confirmingState = awaitItem() + assertThat(confirmingState.startDmActionState).isEqualTo(startDMConfirmationResult) + executeResult.assertions().isCalledOnce().with( + value(matrixUser), + value(false), + any(), + ) + // Start DM again should invoke the action with createIfDmDoesNotExist = true + confirmingState.eventSink(UserProfileEvents.StartDM) + executeResult.assertions().isCalledExactly(2).withSequence( + listOf(value(matrixUser), value(false), any()), + listOf(value(matrixUser), value(true), any()), + ) + } + } + + private suspend fun ReceiveTurbine.awaitFirstItem(count: Int = 1): T { + skipItems(count) + return awaitItem() + } + + private fun createFakeMatrixClient( + userIdentityState: IdentityState? = null, + ignoreUserResult: (UserId) -> Result = { Result.success(Unit) }, + unIgnoreUserResult: (UserId) -> Result = { Result.success(Unit) }, + ignoredUsersFlow: StateFlow> = MutableStateFlow(persistentListOf()) + ) = FakeMatrixClient( + encryptionService = FakeEncryptionService( + getUserIdentityResult = { Result.success(userIdentityState) } + ), + ignoreUserResult = ignoreUserResult, + unIgnoreUserResult = unIgnoreUserResult, + ignoredUsersFlow = ignoredUsersFlow, + ) + + private fun createUserProfilePresenter( + client: MatrixClient = createFakeMatrixClient(), + userId: UserId = UserId("@alice:server.org"), + startDMAction: StartDMAction = FakeStartDMAction(), + isElementCallAvailable: Boolean = true, + ): UserProfilePresenter { + return UserProfilePresenter( + userId = userId, + client = client, + startDMAction = startDMAction, + sessionEnterpriseService = FakeSessionEnterpriseService( + isElementCallAvailableResult = { isElementCallAvailable }, + ), + ) + } +} diff --git a/features/userprofile/shared/build.gradle.kts b/features/userprofile/shared/build.gradle.kts new file mode 100644 index 0000000..7f4d96c --- /dev/null +++ b/features/userprofile/shared/build.gradle.kts @@ -0,0 +1,46 @@ +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.userprofile.shared" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +dependencies { + 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.mediaviewer.api) + implementation(projects.libraries.featureflag.api) + implementation(projects.libraries.permissions.api) + implementation(projects.libraries.preferences.api) + implementation(projects.libraries.testtags) + api(projects.features.userprofile.api) + api(projects.services.apperror.api) + implementation(libs.coil.compose) + implementation(projects.features.startchat.api) + implementation(projects.services.analytics.api) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) +} diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt new file mode 100644 index 0000000..b27d1ad --- /dev/null +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.userprofile.shared + +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.padding +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.draw.clipToBounds +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.userprofile.api.UserProfileVerificationState +import io.element.android.libraries.designsystem.atomic.atoms.MatrixBadgeAtom +import io.element.android.libraries.designsystem.atomic.molecules.MatrixBadgeRowMolecule +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.avatar.AvatarType +import io.element.android.libraries.designsystem.modifiers.niceClickable +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ButtonSize +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun UserProfileHeaderSection( + avatarUrl: String?, + userId: UserId, + userName: String?, + verificationState: UserProfileVerificationState, + openAvatarPreview: (url: String) -> Unit, + onUserIdClick: () -> Unit, + withdrawVerificationClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Avatar( + avatarData = AvatarData(userId.value, userName, avatarUrl, AvatarSize.UserHeader), + avatarType = AvatarType.User, + contentDescription = avatarUrl?.let { stringResource(CommonStrings.a11y_user_avatar) }, + modifier = Modifier + .clip(CircleShape) + .clickable( + enabled = avatarUrl != null, + onClickLabel = stringResource(CommonStrings.action_view), + ) { + openAvatarPreview(avatarUrl!!) + } + .testTag(TestTags.memberDetailAvatar) + ) + Spacer(modifier = Modifier.height(24.dp)) + if (userName != null) { + Text( + modifier = Modifier + .clipToBounds() + .semantics { + heading() + }, + text = userName, + style = ElementTheme.typography.fontHeadingLgBold, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(6.dp)) + } + Text( + modifier = Modifier.niceClickable { onUserIdClick() }, + text = userId.value, + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + when (verificationState) { + UserProfileVerificationState.UNKNOWN, UserProfileVerificationState.UNVERIFIED -> Unit + UserProfileVerificationState.VERIFIED -> { + MatrixBadgeRowMolecule( + data = listOf( + MatrixBadgeAtom.MatrixBadgeData( + text = stringResource(CommonStrings.common_verified), + icon = CompoundIcons.Verified(), + type = MatrixBadgeAtom.Type.Positive, + ) + ).toImmutableList(), + ) + } + UserProfileVerificationState.VERIFICATION_VIOLATION -> { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(CommonStrings.crypto_identity_change_profile_pin_violation, userName ?: userId.value), + color = ElementTheme.colors.textCriticalPrimary, + style = ElementTheme.typography.fontBodyMdMedium, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + size = ButtonSize.MediumLowPadding, + text = stringResource(CommonStrings.crypto_identity_change_withdraw_verification_action), + onClick = withdrawVerificationClick, + ) + } + } + Spacer(Modifier.height(40.dp)) + } +} + +@PreviewsDayNight +@Composable +internal fun UserProfileHeaderSectionPreview() = ElementPreview { + UserProfileHeaderSection( + avatarUrl = null, + userId = UserId("@alice:example.com"), + userName = "Alice", + verificationState = UserProfileVerificationState.VERIFIED, + openAvatarPreview = {}, + onUserIdClick = {}, + withdrawVerificationClick = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun UserProfileHeaderSectionWithVerificationViolationPreview() = ElementPreview { + UserProfileHeaderSection( + avatarUrl = null, + userId = UserId("@alice:example.com"), + userName = "Alice", + verificationState = UserProfileVerificationState.VERIFICATION_VIOLATION, + openAvatarPreview = {}, + onUserIdClick = {}, + withdrawVerificationClick = {}, + ) +} diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileMainActionsSection.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileMainActionsSection.kt new file mode 100644 index 0000000..0ed6d14 --- /dev/null +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileMainActionsSection.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.userprofile.shared + +import androidx.compose.foundation.layout.Arrangement +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.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.button.MainActionButton +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun UserProfileMainActionsSection( + isCurrentUser: Boolean, + canCall: Boolean, + onShareUser: () -> Unit, + onStartDM: () -> Unit, + onCall: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier.fillMaxWidth().padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + if (!isCurrentUser) { + MainActionButton( + title = stringResource(CommonStrings.action_message), + imageVector = CompoundIcons.Chat(), + onClick = onStartDM, + ) + } + if (canCall) { + MainActionButton( + title = stringResource(CommonStrings.action_call), + imageVector = CompoundIcons.VideoCall(), + onClick = onCall, + ) + } + MainActionButton( + title = stringResource(CommonStrings.action_share), + imageVector = CompoundIcons.ShareAndroid(), + onClick = onShareUser + ) + } +} diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileNodeHelper.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileNodeHelper.kt new file mode 100644 index 0000000..6f2266e --- /dev/null +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileNodeHelper.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.userprofile.shared + +import android.content.Context +import io.element.android.libraries.androidutils.R +import io.element.android.libraries.androidutils.system.startSharePlainTextIntent +import io.element.android.libraries.architecture.NodeInputs +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.permalink.PermalinkBuilder +import io.element.android.libraries.ui.strings.CommonStrings +import timber.log.Timber + +class UserProfileNodeHelper( + private val userId: UserId, +) { + interface Callback : NodeInputs { + fun navigateToAvatarPreview(username: String, avatarUrl: String) + fun navigateToRoom(roomId: RoomId) + fun startCall(dmRoomId: RoomId) + fun startVerifyUserFlow(userId: UserId) + } + + fun onShareUser( + context: Context, + permalinkBuilder: PermalinkBuilder, + ) { + val permalinkResult = permalinkBuilder.permalinkForUser(userId) + permalinkResult.onSuccess { permalink -> + context.startSharePlainTextIntent( + activityResultLauncher = null, + chooserTitle = context.getString(CommonStrings.action_share), + text = permalink, + noActivityFoundMessage = context.getString(R.string.error_no_compatible_app_found) + ) + }.onFailure { + Timber.e(it) + } + } +} diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt new file mode 100644 index 0000000..49a2fee --- /dev/null +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.userprofile.shared + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser +import io.element.android.features.userprofile.api.UserProfileEvents +import io.element.android.features.userprofile.api.UserProfileState +import io.element.android.features.userprofile.api.UserProfileVerificationState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.utils.snackbar.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.ui.components.aMatrixUser + +open class UserProfileStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aUserProfileState(), + aUserProfileState(userName = null), + aUserProfileState(isBlocked = AsyncData.Success(true), verificationState = UserProfileVerificationState.VERIFIED), + aUserProfileState(displayConfirmationDialog = UserProfileState.ConfirmationDialog.Block), + aUserProfileState(displayConfirmationDialog = UserProfileState.ConfirmationDialog.Unblock), + aUserProfileState(isBlocked = AsyncData.Loading(true), verificationState = UserProfileVerificationState.UNKNOWN), + aUserProfileState(startDmActionState = AsyncAction.Loading), + aUserProfileState(canCall = true), + aUserProfileState(startDmActionState = ConfirmingStartDmWithMatrixUser(aMatrixUser())), + aUserProfileState(verificationState = UserProfileVerificationState.VERIFICATION_VIOLATION), + ) +} + +fun aUserProfileState( + userId: UserId = UserId("@daniel:domain.com"), + userName: String? = "Daniel", + avatarUrl: String? = null, + isBlocked: AsyncData = AsyncData.Success(false), + verificationState: UserProfileVerificationState = UserProfileVerificationState.UNVERIFIED, + startDmActionState: AsyncAction = AsyncAction.Uninitialized, + displayConfirmationDialog: UserProfileState.ConfirmationDialog? = null, + isCurrentUser: Boolean = false, + dmRoomId: RoomId? = null, + canCall: Boolean = false, + snackbarMessage: SnackbarMessage? = null, + eventSink: (UserProfileEvents) -> Unit = {}, +) = UserProfileState( + userId = userId, + userName = userName, + avatarUrl = avatarUrl, + isBlocked = isBlocked, + verificationState = verificationState, + startDmActionState = startDmActionState, + displayConfirmationDialog = displayConfirmationDialog, + isCurrentUser = isCurrentUser, + dmRoomId = dmRoomId, + canCall = canCall, + snackbarMessage = snackbarMessage, + eventSink = eventSink, +) diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt new file mode 100644 index 0000000..48f8939 --- /dev/null +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.userprofile.shared + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser +import io.element.android.features.userprofile.api.UserProfileEvents +import io.element.android.features.userprofile.api.UserProfileState +import io.element.android.features.userprofile.api.UserProfileVerificationState +import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs +import io.element.android.features.userprofile.shared.blockuser.BlockUserSection +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +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.snackbar.SnackbarHost +import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UserProfileView( + state: UserProfileState, + onShareUser: () -> Unit, + onOpenDm: (RoomId) -> Unit, + onStartCall: (RoomId) -> Unit, + goBack: () -> Unit, + openAvatarPreview: (username: String, url: String) -> Unit, + onVerifyClick: (UserId) -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) + Scaffold( + modifier = modifier, + topBar = { + TopAppBar(title = { }, navigationIcon = { BackButton(onClick = goBack) }) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + .verticalScroll(rememberScrollState()) + ) { + UserProfileHeaderSection( + avatarUrl = state.avatarUrl, + userId = state.userId, + userName = state.userName, + verificationState = state.verificationState, + openAvatarPreview = { avatarUrl -> + openAvatarPreview(state.userName ?: state.userId.value, avatarUrl) + }, + onUserIdClick = { + state.eventSink(UserProfileEvents.CopyToClipboard(state.userId.value)) + }, + withdrawVerificationClick = { state.eventSink(UserProfileEvents.WithdrawVerification) }, + ) + UserProfileMainActionsSection( + isCurrentUser = state.isCurrentUser, + canCall = state.canCall, + onShareUser = onShareUser, + onStartDM = { state.eventSink(UserProfileEvents.StartDM) }, + onCall = { state.dmRoomId?.let { onStartCall(it) } } + ) + Spacer(modifier = Modifier.height(26.dp)) + if (!state.isCurrentUser) { + VerifyUserSection(state, onVerifyClick = { onVerifyClick(state.userId) }) + BlockUserSection(state) + BlockUserDialogs(state) + } + AsyncActionView( + async = state.startDmActionState, + progressDialog = { + AsyncActionViewDefaults.ProgressDialog( + progressText = stringResource(CommonStrings.common_starting_chat), + ) + }, + onSuccess = onOpenDm, + errorMessage = { stringResource(R.string.screen_start_chat_error_starting_chat) }, + onRetry = { state.eventSink(UserProfileEvents.StartDM) }, + onErrorDismiss = { state.eventSink(UserProfileEvents.ClearStartDMState) }, + confirmationDialog = { data -> + if (data is ConfirmingStartDmWithMatrixUser) { + CreateDmConfirmationBottomSheet( + matrixUser = data.matrixUser, + onSendInvite = { + state.eventSink(UserProfileEvents.StartDM) + }, + onDismiss = { + state.eventSink(UserProfileEvents.ClearStartDMState) + }, + ) + } + }, + ) + } + } +} + +@Composable +private fun VerifyUserSection( + state: UserProfileState, + onVerifyClick: () -> Unit, +) { + if (state.verificationState == UserProfileVerificationState.UNVERIFIED) { + ListItem( + headlineContent = { Text(stringResource(CommonStrings.common_verify_user)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), + onClick = onVerifyClick, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun UserProfileViewPreview( + @PreviewParameter(UserProfileStateProvider::class) state: UserProfileState +) = ElementPreview { + UserProfileView( + state = state, + onShareUser = {}, + goBack = {}, + onOpenDm = {}, + onStartCall = {}, + openAvatarPreview = { _, _ -> }, + onVerifyClick = {}, + ) +} diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserDialogs.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserDialogs.kt new file mode 100644 index 0000000..2ba17b9 --- /dev/null +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserDialogs.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.userprofile.shared.blockuser + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.features.userprofile.api.UserProfileEvents +import io.element.android.features.userprofile.api.UserProfileState +import io.element.android.features.userprofile.shared.R +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog + +@Composable +fun BlockUserDialogs(state: UserProfileState) { + when (state.displayConfirmationDialog) { + null -> Unit + UserProfileState.ConfirmationDialog.Block -> { + BlockConfirmationDialog( + onBlockAction = { + state.eventSink( + UserProfileEvents.BlockUser( + needsConfirmation = false + ) + ) + }, + onDismiss = { state.eventSink(UserProfileEvents.ClearConfirmationDialog) } + ) + } + UserProfileState.ConfirmationDialog.Unblock -> { + UnblockConfirmationDialog( + onUnblockAction = { + state.eventSink( + UserProfileEvents.UnblockUser( + needsConfirmation = false + ) + ) + }, + onDismiss = { state.eventSink(UserProfileEvents.ClearConfirmationDialog) } + ) + } + } +} + +@Composable +private 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), + onSubmitClick = onBlockAction, + onDismiss = onDismiss + ) +} + +@Composable +private 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), + onSubmitClick = onUnblockAction, + onDismiss = onDismiss + ) +} diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt new file mode 100644 index 0000000..7a73c60 --- /dev/null +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.userprofile.shared.blockuser + +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.userprofile.api.UserProfileEvents +import io.element.android.features.userprofile.api.UserProfileState +import io.element.android.features.userprofile.shared.R +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.designsystem.components.dialogs.RetryDialog +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun BlockUserSection( + state: UserProfileState, + modifier: Modifier = Modifier, +) { + val isBlocked = state.isBlocked + PreferenceCategory( + modifier = modifier, + showTopDivider = false, + ) { + when (isBlocked) { + is AsyncData.Failure -> PreferenceBlockUser(isBlocked = isBlocked.prevData, isLoading = false, eventSink = state.eventSink) + is AsyncData.Loading -> PreferenceBlockUser(isBlocked = isBlocked.prevData, isLoading = true, eventSink = state.eventSink) + is AsyncData.Success -> PreferenceBlockUser(isBlocked = isBlocked.data, isLoading = false, eventSink = state.eventSink) + AsyncData.Uninitialized -> PreferenceBlockUser(isBlocked = null, isLoading = true, eventSink = state.eventSink) + } + } + if (isBlocked is AsyncData.Failure) { + RetryDialog( + content = stringResource(CommonStrings.error_unknown), + onDismiss = { state.eventSink(UserProfileEvents.ClearBlockUserError) }, + onRetry = { + val event = when (isBlocked.prevData) { + true -> UserProfileEvents.UnblockUser(needsConfirmation = false) + false -> UserProfileEvents.BlockUser(needsConfirmation = false) + // null case Should not happen + null -> UserProfileEvents.ClearBlockUserError + } + state.eventSink(event) + }, + ) + } +} + +@Composable +private fun PreferenceBlockUser( + isBlocked: Boolean?, + isLoading: Boolean, + eventSink: (UserProfileEvents) -> Unit, +) { + val loadingCurrentValue = @Composable { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(20.dp), + strokeWidth = 2.dp + ) + } + if (isBlocked.orFalse()) { + ListItem( + headlineContent = { Text(stringResource(R.string.screen_dm_details_unblock_user)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())), + onClick = { if (!isLoading) eventSink(UserProfileEvents.UnblockUser(needsConfirmation = true)) }, + trailingContent = if (isLoading) ListItemContent.Custom(loadingCurrentValue) else null, + style = ListItemStyle.Primary, + ) + } else { + ListItem( + headlineContent = { Text(stringResource(R.string.screen_dm_details_block_user)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())), + style = ListItemStyle.Destructive, + onClick = { if (!isLoading) eventSink(UserProfileEvents.BlockUser(needsConfirmation = true)) }, + trailingContent = if (isLoading) ListItemContent.Custom(loadingCurrentValue) else null, + ) + } +} diff --git a/features/userprofile/shared/src/main/res/values-be/translations.xml b/features/userprofile/shared/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..6466c3b --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-be/translations.xml @@ -0,0 +1,17 @@ + + + "Заблакіраваць" + "Заблакіраваныя карыстальнікі не змогуць адпраўляць вам паведамленні, і ўсе іх паведамленні будуць схаваны. Вы можаце разблакіраваць іх у любы час." + "Заблакіраваць карыстальніка" + "Разблакіраваць" + "Вы зноў зможаце ўбачыць усе паведамленні." + "Разблакіраваць карыстальніка" + "Заблакіраваць" + "Заблакіраваныя карыстальнікі не змогуць адпраўляць вам паведамленні, і ўсе іх паведамленні будуць схаваны. Вы можаце разблакіраваць іх у любы час." + "Заблакіраваць карыстальніка" + "Профіль" + "Разблакіраваць" + "Вы зноў зможаце ўбачыць усе паведамленні." + "Разблакіраваць карыстальніка" + "Пры спробе пачаць чат адбылася памылка" + diff --git a/features/userprofile/shared/src/main/res/values-bg/translations.xml b/features/userprofile/shared/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..b2e8611 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-bg/translations.xml @@ -0,0 +1,18 @@ + + + "Блокиране" + "Блокираните потребители няма да могат да ви изпращат съобщения и всички техни съобщения ще бъдат скрити. Можете да ги отблокирате по всяко време." + "Блокиране на потребителя" + "Отблокиране" + "Ще можете да виждате отново всички съобщения от тях." + "Отблокиране на потребителя" + "Блокиране" + "Блокираните потребители няма да могат да ви изпращат съобщения и всички техни съобщения ще бъдат скрити. Можете да ги отблокирате по всяко време." + "Блокиране на потребителя" + "Профил" + "Отблокиране" + "Ще можете да виждате отново всички съобщения от тях." + "Отблокиране на потребителя" + "Потвърждаване на %1$s" + "Възникна грешка при опита за започване на чат" + diff --git a/features/userprofile/shared/src/main/res/values-cs/translations.xml b/features/userprofile/shared/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..9296f10 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-cs/translations.xml @@ -0,0 +1,19 @@ + + + "Zablokovat" + "Blokovaní uživatelé vám nebudou moci posílat zprávy a všechny jejich zprávy budou skryty. Můžete je kdykoli odblokovat." + "Zablokovat uživatele" + "Odblokovat" + "Znovu uvidíte všechny zprávy od nich." + "Odblokovat uživatele" + "Zablokovat" + "Blokovaní uživatelé vám nebudou moci posílat zprávy a všechny jejich zprávy budou skryty. Můžete je kdykoli odblokovat." + "Zablokovat uživatele" + "Profil" + "Odblokovat" + "Znovu uvidíte všechny zprávy od nich." + "Odblokovat uživatele" + "K ověření tohoto uživatele použijte webovou aplikaci." + "Ověřit %1$s" + "Při pokusu o zahájení chatu došlo k chybě" + diff --git a/features/userprofile/shared/src/main/res/values-cy/translations.xml b/features/userprofile/shared/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..5fe1fa7 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-cy/translations.xml @@ -0,0 +1,19 @@ + + + "Rhwystro" + "Fydd defnyddwyr sydd wedi\'u rhwystro ddim yn gallu anfon negeseuon atoch a bydd eu holl negeseuon yn cael eu cuddio. Gallwch eu dadrwystro unrhyw bryd." + "Rhwystro defnyddiwr" + "Dad-rwystro" + "Byddwch yn gallu gweld pob neges oddi wrthyn nhw eto." + "Dadrwystro defnyddiwr" + "Rhwystro" + "Fydd defnyddwyr sydd wedi\'u rhwystro ddim yn gallu anfon negeseuon atoch a bydd eu holl negeseuon yn cael eu cuddio. Gallwch eu dadrwystro unrhyw bryd." + "Rhwystro defnyddiwr" + "Proffil" + "Dad-rwystro" + "Byddwch yn gallu gweld pob neges oddi wrthyn nhw eto." + "Dadrwystro defnyddiwr" + "Defnyddiwch yr ap gwe i ddilysu\'r defnyddiwr hwn." + "Dilysu %1$s" + "Digwyddodd gwall wrth geisio cychwyn sgwrs" + diff --git a/features/userprofile/shared/src/main/res/values-da/translations.xml b/features/userprofile/shared/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..7af21eb --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-da/translations.xml @@ -0,0 +1,19 @@ + + + "Bloker" + "Blokerede brugere vil ikke være i stand til at sende dig beskeder, og alle deres beskeder vil blive skjult. Du kan fjerne blokeringen af dem når som helst." + "Bloker bruger" + "Fjern blokering" + "Du vil være i stand til at se alle beskeder fra dem igen." + "Fjern blokering af bruger" + "Bloker" + "Blokerede brugere vil ikke være i stand til at sende dig beskeder, og alle deres beskeder vil blive skjult. Du kan fjerne blokeringen af dem når som helst." + "Bloker bruger" + "Profil" + "Fjern blokering" + "Du vil være i stand til at se alle beskeder fra dem igen." + "Fjern blokering af bruger" + "Brug webappen til at verificere denne bruger." + "Verificér %1$s" + "Der opstod en fejl under forsøget på at starte en samtale" + diff --git a/features/userprofile/shared/src/main/res/values-de/translations.xml b/features/userprofile/shared/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..b3b7f1f --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-de/translations.xml @@ -0,0 +1,19 @@ + + + "Blockieren" + "Blockierte Nutzer können Ihnen keine Nachrichten senden und alle ihre Nachrichten werden ausgeblendet. Die Blockierung kann jederzeit aufgehoben werden." + "Nutzer blockieren" + "Blockierung aufheben" + "Der Nutzer kann dir wieder Nachrichten senden & alle Nachrichten des Nutzers werden wieder angezeigt." + "Blockierung aufheben" + "Blockieren" + "Blockierte Nutzer können Ihnen keine Nachrichten senden und alle ihre Nachrichten werden ausgeblendet. Die Blockierung kann jederzeit aufgehoben werden." + "Nutzer blockieren" + "Profil" + "Blockierung aufheben" + "Der Nutzer kann dir wieder Nachrichten senden & alle Nachrichten des Nutzers werden wieder angezeigt." + "Blockierung aufheben" + "Verwende die Web-App, um diesen Nutzer zu verifizieren." + "Verifiziere %1$s" + "Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten" + diff --git a/features/userprofile/shared/src/main/res/values-el/translations.xml b/features/userprofile/shared/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..7d36240 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-el/translations.xml @@ -0,0 +1,19 @@ + + + "Αποκλεισμός" + "Οι αποκλεισμένοι χρήστες δεν θα μπορούν να σου στέλνουν μηνύματα και όλα τα μηνύματά τους θα είναι κρυμμένα. Μπορείς να τα ξεμπλοκάρεις ανά πάσα στιγμή." + "Αποκλεισμός χρήστη" + "Άρση αποκλεισμού" + "Θα μπορείς να δεις ξανά όλα τα μηνύματα του." + "Κατάργηση αποκλεισμού χρήστη" + "Αποκλεισμός" + "Οι αποκλεισμένοι χρήστες δεν θα μπορούν να σου στέλνουν μηνύματα και όλα τα μηνύματά τους θα είναι κρυμμένα. Μπορείς να τα ξεμπλοκάρεις ανά πάσα στιγμή." + "Αποκλεισμός χρήστη" + "Προφίλ" + "Άρση αποκλεισμού" + "Θα μπορείς να δεις ξανά όλα τα μηνύματα του." + "Κατάργηση αποκλεισμού χρήστη" + "Χρησιμοποίησε την εφαρμογή ιστού για να επαληθεύσεις αυτόν τον χρήστη." + "Επαλήθευση %1$s" + "Παρουσιάστηκε σφάλμα κατά την προσπάθεια έναρξης μιας συνομιλίας" + diff --git a/features/userprofile/shared/src/main/res/values-es/translations.xml b/features/userprofile/shared/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..c65165c --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-es/translations.xml @@ -0,0 +1,19 @@ + + + "Bloquear" + "Los usuarios bloqueados no podrán enviarte mensajes y todos sus mensajes se ocultarán. Puedes desbloquearlos cuando quieras." + "Bloquear usuario" + "Desbloquear" + "Podrás ver todos sus mensajes de nuevo." + "Desbloquear usuario" + "Bloquear" + "Los usuarios bloqueados no podrán enviarte mensajes y todos sus mensajes se ocultarán. Puedes desbloquearlos cuando quieras." + "Bloquear usuario" + "Perfil" + "Desbloquear" + "Podrás ver todos sus mensajes de nuevo." + "Desbloquear usuario" + "Utiliza la aplicación web para verificar a este usuario." + "Verificar a %1$s" + "Se ha producido un error al intentar iniciar un chat" + diff --git a/features/userprofile/shared/src/main/res/values-et/translations.xml b/features/userprofile/shared/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..ce57e09 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-et/translations.xml @@ -0,0 +1,19 @@ + + + "Blokeeri" + "Blokeeritud kasutajad ei saa sulle kirjutada ja kõik nende sõnumid on sinu eest peidetud. Sa saad alati blokeeringu eemaldada." + "Blokeeri kasutaja" + "Eemalda blokeering" + "Nüüd näed sa jälle kõiki tema sõnumeid" + "Eemalda kasutajalt blokeering" + "Blokeeri" + "Blokeeritud kasutajad ei saa sulle kirjutada ja kõik nende sõnumid on sinu eest peidetud. Sa saad alati blokeeringu eemaldada." + "Blokeeri kasutaja" + "Profiil" + "Eemalda blokeering" + "Nüüd näed sa jälle kõiki tema sõnumeid" + "Eemalda kasutajalt blokeering" + "Kasutaja verifitseerimiseks kasuta veebirakendust." + "Verifitseeri kasutaja %1$s" + "Vestluse alustamisel tekkis viga" + diff --git a/features/userprofile/shared/src/main/res/values-eu/translations.xml b/features/userprofile/shared/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..42050a5 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-eu/translations.xml @@ -0,0 +1,19 @@ + + + "Blokeatu" + "Blokeatutako erabiltzaileek ezingo dizute mezurik bidali eta beren mezuak ezkutatuko dira. Edozein unetan desblokeatu ditzakezu." + "Blokeatu erabiltzailea" + "Desblokeatu" + "Beraien mezu guztiak berriro ikusteko aukera izango duzu." + "Desblokeatu erabiltzailea" + "Blokeatu" + "Blokeatutako erabiltzaileek ezingo dizute mezurik bidali eta beren mezuak ezkutatuko dira. Edozein unetan desblokeatu ditzakezu." + "Blokeatu erabiltzailea" + "Profila" + "Desblokeatu" + "Beraien mezu guztiak berriro ikusteko aukera izango duzu." + "Desblokeatu erabiltzailea" + "Erabili web-aplikazioa erabiltzaile hau egiaztatzeko." + "Egiaztatu %1$s" + "Errorea gertatu da txata hasten saiatzean" + diff --git a/features/userprofile/shared/src/main/res/values-fa/translations.xml b/features/userprofile/shared/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..4eca6a0 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-fa/translations.xml @@ -0,0 +1,19 @@ + + + "بلوک" + "کاربران مسدود شده نمی‌توانند برای شما پیام ارسال کنند و تمام پیام‌های آنها پنهان خواهد شد. می‌توانید هر زمان که بخواهید آنها را از حالت مسدود خارج کنید." + "انسداد کاربر" + "رفع انسداد" + "قادر خواهید بود دوباره همهٔ پیام‌هایش را ببینید." + "رفع انسداد کاربر" + "بلوک" + "کاربران مسدود شده نمی‌توانند برای شما پیام ارسال کنند و تمام پیام‌های آنها پنهان خواهد شد. می‌توانید هر زمان که بخواهید آنها را از حالت مسدود خارج کنید." + "انسداد کاربر" + "نمایه" + "رفع انسداد" + "قادر خواهید بود دوباره همهٔ پیام‌هایش را ببینید." + "رفع انسداد کاربر" + "استفاده از کارهٔ وب برای تأیید این کاربر." + "تأیید %1$s" + "هنگام تلاش برای شروع چت خطایی روی داد" + diff --git a/features/userprofile/shared/src/main/res/values-fi/translations.xml b/features/userprofile/shared/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..13e101b --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-fi/translations.xml @@ -0,0 +1,19 @@ + + + "Estä" + "Estetyt käyttäjät eivät voi lähettää sinulle viestejä ja kaikki heidän viestit piilotetaan. Voit poistaa eston milloin tahansa." + "Estä käyttäjä" + "Poista esto" + "Näet jälleen kaikki heidän lähettämänsä viestit." + "Poista käyttäjän esto" + "Estä" + "Estetyt käyttäjät eivät voi lähettää sinulle viestejä ja kaikki heidän viestit piilotetaan. Voit poistaa eston milloin tahansa." + "Estä käyttäjä" + "Profiili" + "Poista esto" + "Näet jälleen kaikki heidän lähettämänsä viestit." + "Poista käyttäjän esto" + "Vahvista tämä käyttäjä verkkosovelluksen avulla." + "Vahvista %1$s" + "Keskustelun aloituksessa tapahtui virhe" + diff --git a/features/userprofile/shared/src/main/res/values-fr/translations.xml b/features/userprofile/shared/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..e8fae88 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-fr/translations.xml @@ -0,0 +1,19 @@ + + + "Bloquer" + "Les utilisateurs bloqués ne pourront pas vous envoyer de messages et tous leurs messages seront masqués. Vous pouvez les débloquer à tout moment." + "Bloquer l’utilisateur" + "Débloquer" + "Vous pourrez à nouveau voir tous ses messages." + "Débloquer l’utilisateur" + "Bloquer" + "Les utilisateurs bloqués ne pourront pas vous envoyer de messages et tous leurs messages seront masqués. Vous pouvez les débloquer à tout moment." + "Bloquer l’utilisateur" + "Profil" + "Débloquer" + "Vous pourrez à nouveau voir tous ses messages." + "Débloquer l’utilisateur" + "Utilisez l’application Web pour vérifier cet utilisateur." + "Vérifier %1$s" + "Une erreur s’est produite lors de la tentative de création de la discussion" + diff --git a/features/userprofile/shared/src/main/res/values-hu/translations.xml b/features/userprofile/shared/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..eeccf83 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-hu/translations.xml @@ -0,0 +1,19 @@ + + + "Letiltás" + "A letiltott felhasználók nem fognak tudni üzeneteket küldeni, és az összes üzenetük rejtve lesz. Bármikor feloldhatja a letiltásukat." + "Felhasználó letiltása" + "Letiltás feloldása" + "Újra látni fogja az összes üzenetét." + "Felhasználó letiltásának feloldása" + "Letiltás" + "A letiltott felhasználók nem fognak tudni üzeneteket küldeni, és az összes üzenetük rejtve lesz. Bármikor feloldhatja a letiltásukat." + "Felhasználó letiltása" + "Profil" + "Letiltás feloldása" + "Újra látni fogja az összes üzenetét." + "Felhasználó letiltásának feloldása" + "Használja a webes alkalmazást a felhasználó ellenőrzéséhez." + "A(z) %1$s ellenőrzése" + "Hiba történt a csevegés indításakor" + diff --git a/features/userprofile/shared/src/main/res/values-in/translations.xml b/features/userprofile/shared/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..e368bc9 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-in/translations.xml @@ -0,0 +1,19 @@ + + + "Blokir" + "Pengguna yang diblokir tidak akan dapat mengirim Anda pesan dan semua pesan mereka akan disembunyikan. Anda dapat membuka blokirnya kapan saja." + "Blokir pengguna" + "Buka blokir" + "Anda akan dapat melihat semua pesan dari mereka lagi." + "Buka blokir pengguna" + "Blokir" + "Pengguna yang diblokir tidak akan dapat mengirim Anda pesan dan semua pesan mereka akan disembunyikan. Anda dapat membuka blokirnya kapan saja." + "Blokir pengguna" + "Profil" + "Buka blokir" + "Anda akan dapat melihat semua pesan dari mereka lagi." + "Buka blokir pengguna" + "Gunakan aplikasi web untuk memverifikasi pengguna ini." + "Verifikasi %1$s" + "Terjadi kesalahan saat mencoba memulai obrolan" + diff --git a/features/userprofile/shared/src/main/res/values-it/translations.xml b/features/userprofile/shared/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..277ed7e --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-it/translations.xml @@ -0,0 +1,19 @@ + + + "Blocca" + "Gli utenti bloccati non saranno in grado di inviarti messaggi e tutti quelli già ricevuti saranno nascosti. Puoi sbloccarli in qualsiasi momento." + "Blocca utente" + "Sblocca" + "Potrai vedere di nuovo tutti i suoi messaggi." + "Sblocca utente" + "Blocca" + "Gli utenti bloccati non saranno in grado di inviarti messaggi e tutti quelli già ricevuti saranno nascosti. Puoi sbloccarli in qualsiasi momento." + "Blocca utente" + "Profilo" + "Sblocca" + "Potrai vedere di nuovo tutti i suoi messaggi." + "Sblocca utente" + "Usa l\'app web per verificare questo utente." + "Verifica %1$s" + "Si è verificato un errore durante il tentativo di avviare una chat" + diff --git a/features/userprofile/shared/src/main/res/values-ka/translations.xml b/features/userprofile/shared/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..ca90f0e --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-ka/translations.xml @@ -0,0 +1,17 @@ + + + "დაბლოკვა" + "დაბლოკილი მომხმარებლები ვერ შეძლებენ თქვენთვის შეტყობინების გაგზავნას და ყველა მათი შეტყობინება თქვენთვის დამალული იქნება. თქვენ მათი განბლოკვა ნებისმეირ დროს შეგიძლიათ." + "მომხმარებლის დაბლოკვა" + "განბლოკვა" + "თქვენ კვლავ შეძლებთ მათგან ყველა შეტყობინების ნახვას." + "Მომხმარებლის განბლოკვა" + "დაბლოკვა" + "დაბლოკილი მომხმარებლები ვერ შეძლებენ თქვენთვის შეტყობინების გაგზავნას და ყველა მათი შეტყობინება თქვენთვის დამალული იქნება. თქვენ მათი განბლოკვა ნებისმეირ დროს შეგიძლიათ." + "მომხმარებლის დაბლოკვა" + "პროფილი" + "განბლოკვა" + "თქვენ კვლავ შეძლებთ მათგან ყველა შეტყობინების ნახვას." + "Მომხმარებლის განბლოკვა" + "ჩატის დაწყების მცდელობისას შეცდომა მოხდა" + diff --git a/features/userprofile/shared/src/main/res/values-ko/translations.xml b/features/userprofile/shared/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..a5518a9 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-ko/translations.xml @@ -0,0 +1,19 @@ + + + "차단" + "차단된 사용자는 메시지를 보낼 수 없으며, 그들의 모든 메시지는 숨겨집니다. 언제든지 차단 해제할 수 있습니다." + "사용자 차단하기" + "차단 해제" + "그들로부터 보낸 모든 메시지를 다시 볼 수 있게 됩니다." + "사용자 차단 해제" + "차단" + "차단된 사용자는 메시지를 보낼 수 없으며, 그들의 모든 메시지는 숨겨집니다. 언제든지 차단 해제할 수 있습니다." + "사용자 차단하기" + "프로필" + "차단 해제" + "그들로부터 보낸 모든 메시지를 다시 볼 수 있게 됩니다." + "사용자 차단 해제" + "웹 앱을 사용하여 이 사용자를 확인하세요." + "확인 %1$s" + "채팅을 시작하는 동안 오류가 발생했습니다." + diff --git a/features/userprofile/shared/src/main/res/values-lt/translations.xml b/features/userprofile/shared/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..327d8ff --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-lt/translations.xml @@ -0,0 +1,16 @@ + + + "Blokuoti" + "Užblokuoti vartotojai negalės siųsti Jums žinučių, ir visos jų žinutės bus paslėptos. Galėsite juos atblokuoti bet kuriuo metu." + "Blokuoti vartotoją" + "Atblokuoti" + "Vėl galėsite matyti visas iš jų gautas žinutes." + "Atblokuoti vartotoją" + "Blokuoti" + "Užblokuoti vartotojai negalės siųsti Jums žinučių, ir visos jų žinutės bus paslėptos. Galėsite juos atblokuoti bet kuriuo metu." + "Blokuoti vartotoją" + "Atblokuoti" + "Vėl galėsite matyti visas iš jų gautas žinutes." + "Atblokuoti vartotoją" + "Bandant pradėti pokalbį įvyko klaida" + diff --git a/features/userprofile/shared/src/main/res/values-nb/translations.xml b/features/userprofile/shared/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..dda1840 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-nb/translations.xml @@ -0,0 +1,19 @@ + + + "Blokker" + "Blokkerte brukere vil ikke kunne sende deg meldinger, og alle meldingene deres vil være skjult. Du kan oppheve blokkeringen når som helst." + "Blokker bruker" + "Fjern blokkering" + "Du vil kunne se alle meldingene fra dem igjen." + "Fjern blokkering av bruker" + "Blokker" + "Blokkerte brukere vil ikke kunne sende deg meldinger, og alle meldingene deres vil være skjult. Du kan oppheve blokkeringen når som helst." + "Blokker bruker" + "Profil" + "Fjern blokkering" + "Du vil kunne se alle meldingene fra dem igjen." + "Fjern blokkering av bruker" + "Bruk webappen til å verifisere denne brukeren." + "Verifiser %1$s" + "Det oppstod en feil når du prøvde å starte en chat" + diff --git a/features/userprofile/shared/src/main/res/values-nl/translations.xml b/features/userprofile/shared/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..ce8beba --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-nl/translations.xml @@ -0,0 +1,18 @@ + + + "Blokkeren" + "Geblokkeerde gebruikers kunnen je geen berichten sturen en al hun berichten worden verborgen. Je kunt ze op elk moment deblokkeren." + "Gebruiker blokkeren" + "Deblokkeren" + "Je zult alle berichten van hen weer kunnen zien." + "Gebruiker deblokkeren" + "Blokkeren" + "Geblokkeerde gebruikers kunnen je geen berichten sturen en al hun berichten worden verborgen. Je kunt ze op elk moment deblokkeren." + "Gebruiker blokkeren" + "Profiel" + "Deblokkeren" + "Je zult alle berichten van hen weer kunnen zien." + "Gebruiker deblokkeren" + "Verifieer %1$s" + "Er is een fout opgetreden bij het starten van een chat" + diff --git a/features/userprofile/shared/src/main/res/values-pl/translations.xml b/features/userprofile/shared/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..58a067e --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-pl/translations.xml @@ -0,0 +1,19 @@ + + + "Zablokuj" + "Zablokowani użytkownicy nie będą mogli wysyłać Ci wiadomości, a wszystkie ich wiadomości zostaną ukryte. Możesz odblokować ich w dowolnym momencie." + "Zablokuj użytkownika" + "Odblokuj" + "Będziesz mógł ponownie zobaczyć wszystkie wiadomości od tego użytkownika." + "Odblokuj użytkownika" + "Zablokuj" + "Zablokowani użytkownicy nie będą mogli wysyłać Ci wiadomości, a wszystkie ich wiadomości zostaną ukryte. Możesz odblokować ich w dowolnym momencie." + "Zablokuj użytkownika" + "Profil" + "Odblokuj" + "Będziesz mógł ponownie zobaczyć wszystkie wiadomości od tego użytkownika." + "Odblokuj użytkownika" + "Użyj aplikacji internetowej, aby zweryfikować tego użytkownika." + "Zweryfikuj %1$s" + "Wystąpił błąd podczas próby rozpoczęcia czatu" + diff --git a/features/userprofile/shared/src/main/res/values-pt-rBR/translations.xml b/features/userprofile/shared/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..1f722c1 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,19 @@ + + + "Bloquear" + "Usuários bloqueados não poderão enviar mensagens para você e todas as mensagens deles serão ocultadas. Você pode desbloqueá-los a qualquer momento." + "Bloquear usuário" + "Desbloquear" + "Você poderá ver todas as mensagens desta pessoa novamente." + "Desbloquear usuário" + "Bloquear" + "Usuários bloqueados não poderão enviar mensagens para você e todas as mensagens deles serão ocultadas. Você pode desbloqueá-los a qualquer momento." + "Bloquear usuário" + "Perfil" + "Desbloquear" + "Você poderá ver todas as mensagens desta pessoa novamente." + "Desbloquear usuário" + "Use o web app para verificar este usuário." + "Verificar %1$s" + "Ocorreu um erro ao tentar iniciar uma conversa" + diff --git a/features/userprofile/shared/src/main/res/values-pt/translations.xml b/features/userprofile/shared/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..ec919a4 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-pt/translations.xml @@ -0,0 +1,19 @@ + + + "Bloquear" + "Os utilizadores bloqueados não poderão enviar-te mensagens e todas as suas mensagens ficarão ocultas. Podes desbloqueá-los em qualquer altura." + "Bloquear utilizador" + "Desbloquear" + "Poderás voltar a ver todas as suas mensagens." + "Desbloquear utilizador" + "Bloquear" + "Os utilizadores bloqueados não poderão enviar-te mensagens e todas as suas mensagens ficarão ocultas. Podes desbloqueá-los em qualquer altura." + "Bloquear utilizador" + "Perfil" + "Desbloquear" + "Poderás voltar a ver todas as suas mensagens." + "Desbloquear utilizador" + "Utiliza a aplicação Web para verificar este utilizador." + "Verifica %1$s" + "Ocorreu um erro ao tentar iniciar uma conversa" + diff --git a/features/userprofile/shared/src/main/res/values-ro/translations.xml b/features/userprofile/shared/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..38c544d --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-ro/translations.xml @@ -0,0 +1,19 @@ + + + "Blocați" + "Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând." + "Blocați utilizatorul" + "Deblocați" + "La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta." + "Deblocați utilizatorul" + "Blocați" + "Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând." + "Blocați utilizatorul" + "Profil" + "Deblocați" + "La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta." + "Deblocați utilizatorul" + "Utilizați aplicația web pentru a verifica acest utilizator." + "Verificare %1$s" + "A apărut o eroare la încercarea începerii conversației" + diff --git a/features/userprofile/shared/src/main/res/values-ru/translations.xml b/features/userprofile/shared/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..fde62b2 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-ru/translations.xml @@ -0,0 +1,19 @@ + + + "Заблокировать" + "Заблокированные пользователи не смогут отправлять вам сообщения, а все их сообщения будут скрыты. Вы можете разблокировать их в любое время." + "Заблокировать пользователя" + "Разблокировать" + "Вы снова сможете увидеть все сообщения." + "Разблокировать пользователя" + "Заблокировать" + "Заблокированные пользователи не смогут отправлять вам сообщения, а все их сообщения будут скрыты. Вы можете разблокировать их в любое время." + "Заблокировать пользователя" + "Профиль" + "Разблокировать" + "Вы снова сможете увидеть все сообщения." + "Разблокировать пользователя" + "Используйте веб-приложение для проверки этого пользователя." + "Верифицировать %1$s" + "Произошла ошибка при попытке начать чат" + diff --git a/features/userprofile/shared/src/main/res/values-sk/translations.xml b/features/userprofile/shared/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..285560f --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-sk/translations.xml @@ -0,0 +1,19 @@ + + + "Zablokovať" + "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ť." + "Zablokovať používateľa" + "Odblokovať" + "Všetky správy od nich budete môcť opäť vidieť." + "Odblokovať používateľa" + "Zablokovať" + "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ť." + "Zablokovať používateľa" + "Profil" + "Odblokovať" + "Všetky správy od nich budete môcť opäť vidieť." + "Odblokovať používateľa" + "Použite webovú aplikáciu na overenie tohto používateľa." + "Overiť %1$s" + "Pri pokuse o spustenie konverzácie sa vyskytla chyba" + diff --git a/features/userprofile/shared/src/main/res/values-sv/translations.xml b/features/userprofile/shared/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..1133ef1 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-sv/translations.xml @@ -0,0 +1,19 @@ + + + "Blockera" + "Blockerade användare kommer inte att kunna skicka meddelanden till dig och alla deras meddelanden kommer att döljas. Du kan avblockera dem när som helst." + "Blockera användare" + "Avblockera" + "Du kommer att kunna se alla meddelanden från dem igen." + "Avblockera användare" + "Blockera" + "Blockerade användare kommer inte att kunna skicka meddelanden till dig och alla deras meddelanden kommer att döljas. Du kan avblockera dem när som helst." + "Blockera användare" + "Profil" + "Avblockera" + "Du kommer att kunna se alla meddelanden från dem igen." + "Avblockera användare" + "Använd webbappen för att verifiera den här användaren." + "Verifiera %1$s" + "Ett fel uppstod när du försökte starta en chatt" + diff --git a/features/userprofile/shared/src/main/res/values-tr/translations.xml b/features/userprofile/shared/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..9f80d33 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-tr/translations.xml @@ -0,0 +1,19 @@ + + + "Engelle" + "Engellenen kullanıcılar size mesaj gönderemez ve tüm mesajları gizlenir. İstediğiniz zaman engellemelerini kaldırabilirsiniz." + "Kullanıcıyı engelle" + "Engellemeyi kaldır" + "Onlardan gelen tüm mesajları tekrar görebileceksiniz." + "Kullanıcının engelini kaldır" + "Engelle" + "Engellenen kullanıcılar size mesaj gönderemez ve tüm mesajları gizlenir. İstediğiniz zaman engellemelerini kaldırabilirsiniz." + "Kullanıcıyı engelle" + "Profil" + "Engellemeyi kaldır" + "Onlardan gelen tüm mesajları tekrar görebileceksiniz." + "Kullanıcının engelini kaldır" + "Bu kullanıcıyı doğrulamak için web uygulamasını kullan." + "Doğrula %1$s" + "Sohbet başlatmaya çalışırken bir hata oluştu" + diff --git a/features/userprofile/shared/src/main/res/values-uk/translations.xml b/features/userprofile/shared/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..40dfa19 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-uk/translations.xml @@ -0,0 +1,19 @@ + + + "Заблокувати" + "Заблоковані користувачі не зможуть надсилати вам повідомлення, і всі їхні повідомлення будуть приховані. Ви можете розблокувати їх у будь-який час." + "Заблокувати користувача" + "Розблокувати" + "Ви знову зможете бачити всі повідомлення від них." + "Розблокувати користувача" + "Заблокувати" + "Заблоковані користувачі не зможуть надсилати вам повідомлення, і всі їхні повідомлення будуть приховані. Ви можете розблокувати їх у будь-який час." + "Заблокувати користувача" + "Профіль" + "Розблокувати" + "Ви знову зможете бачити всі повідомлення від них." + "Розблокувати користувача" + "Використовуйте веб-додаток, щоб верифікувати цього користувача." + "Перевірте %1$s" + "Під час спроби почати бесіду сталася помилка" + diff --git a/features/userprofile/shared/src/main/res/values-ur/translations.xml b/features/userprofile/shared/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..244b5b8 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-ur/translations.xml @@ -0,0 +1,17 @@ + + + "مسدود کریں" + "مسدود کردہ صارفین آپ کو پیغامات نہیں بھیج سکیں گے اور انکے تمام پیغامات چھپ جائیں گے۔ آپ انھیں کسی بھی وقت غیر مسدود کر سکتے ہیں۔" + "صارف کو مسدود کریں" + "غیر مسدود کریں" + "آپ انکی جانب سے تمام پیغامات دوبارہ دیکھ سکیں گے۔" + "صارف کو غیر مسدود کریں" + "مسدود کریں" + "مسدود کردہ صارفین آپ کو پیغامات نہیں بھیج سکیں گے اور انکے تمام پیغامات چھپ جائیں گے۔ آپ انھیں کسی بھی وقت غیر مسدود کر سکتے ہیں۔" + "صارف کو مسدود کریں" + "نمایہ" + "غیر مسدود کریں" + "آپ انکی جانب سے تمام پیغامات دوبارہ دیکھ سکیں گے۔" + "صارف کو غیر مسدود کریں" + "گفتگو شروع کرنے کی کوشش کرتے وقت ایک خرابی واقع ہوگئی" + diff --git a/features/userprofile/shared/src/main/res/values-uz/translations.xml b/features/userprofile/shared/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..42edf2f --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-uz/translations.xml @@ -0,0 +1,19 @@ + + + "Bloklash" + "Bloklangan foydalanuvchilar sizga xabar yubora olmaydi va ularning barcha xabarlari yashiriladi. Ularni istalgan vaqtda blokdan chiqarishingiz mumkin." + "Foydalanuvchini bloklash" + "Blokdan chiqarish" + "Ulardan kelgan barcha xabarlarni yana koʻrishingiz mumkin boʻladi." + "Foydalanuvchini blokdan chiqarish" + "Bloklash" + "Bloklangan foydalanuvchilar sizga xabar yubora olmaydi va ularning barcha xabarlari yashiriladi. Ularni istalgan vaqtda blokdan chiqarishingiz mumkin." + "Foydalanuvchini bloklash" + "Profil" + "Blokdan chiqarish" + "Ulardan kelgan barcha xabarlarni yana koʻrishingiz mumkin boʻladi." + "Foydalanuvchini blokdan chiqarish" + "Bu foydalanuvchini tasdiqlash uchun veb-ilovadan foydalaning." + "Tasdiqlash %1$s" + "Suhbatni boshlashda xatolik yuz berdi" + diff --git a/features/userprofile/shared/src/main/res/values-zh-rTW/translations.xml b/features/userprofile/shared/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..8aa75fe --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,19 @@ + + + "封鎖" + "被封鎖的使用者無法傳訊息給您,他們的訊息會被隱藏。您可以在任何時候解除封鎖。" + "封鎖使用者" + "解除封鎖" + "您將無法看到任何來自他們的訊息。" + "解除封鎖使用者" + "封鎖" + "被封鎖的使用者無法傳訊息給您,他們的訊息會被隱藏。您可以在任何時候解除封鎖。" + "封鎖使用者" + "個人檔案" + "解除封鎖" + "您將無法看到任何來自他們的訊息。" + "解除封鎖使用者" + "使用網頁應用程式以驗證此使用者。" + "驗證 %1$s" + "嘗試開始聊天時發生錯誤" + diff --git a/features/userprofile/shared/src/main/res/values-zh/translations.xml b/features/userprofile/shared/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..b1b37bd --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-zh/translations.xml @@ -0,0 +1,19 @@ + + + "封禁" + "被封禁的用户无法给你发消息,并且他们的消息会被隐藏。你可以随时解封。" + "封禁用户" + "解封" + "可以重新接收他们的消息。" + "解封用户" + "封禁" + "被封禁的用户无法给你发消息,并且他们的消息会被隐藏。你可以随时解封。" + "封禁用户" + "个人资料" + "解封" + "可以重新接收他们的消息。" + "解封用户" + "使用 Web 应用程序验证此用户。" + "验证 %1$s" + "在开始聊天时发生了错误" + diff --git a/features/userprofile/shared/src/main/res/values/localazy.xml b/features/userprofile/shared/src/main/res/values/localazy.xml new file mode 100644 index 0000000..29f6169 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values/localazy.xml @@ -0,0 +1,19 @@ + + + "Block" + "Blocked users won\'t be able to send you messages and all their messages will be hidden. You can unblock them anytime." + "Block user" + "Unblock" + "You\'ll be able to see all messages from them again." + "Unblock user" + "Block" + "Blocked users won\'t be able to send you messages and all their messages will be hidden. You can unblock them anytime." + "Block user" + "Profile" + "Unblock" + "You\'ll be able to see all messages from them again." + "Unblock user" + "Use the web app to verify this user." + "Verify %1$s" + "An error occurred when trying to start a chat" + diff --git a/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt b/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt new file mode 100644 index 0000000..dc41fb3 --- /dev/null +++ b/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.userprofile + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.userprofile.api.UserProfileEvents +import io.element.android.features.userprofile.api.UserProfileState +import io.element.android.features.userprofile.api.UserProfileVerificationState +import io.element.android.features.userprofile.shared.R +import io.element.android.features.userprofile.shared.UserProfileView +import io.element.android.features.userprofile.shared.aUserProfileState +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId +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_USER_ID +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.ensureCalledOnceWithTwoParams +import io.element.android.tests.testutils.pressBack +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class UserProfileViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `on back button click - the expected callback is called`() = runTest { + ensureCalledOnce { callback -> + rule.setUserProfileView( + goBack = callback, + ) + rule.pressBack() + } + } + + @Test + fun `on avatar clicked - the expected callback is called`() = runTest { + ensureCalledOnceWithTwoParams(A_USER_NAME, AN_AVATAR_URL) { callback -> + rule.setUserProfileView( + state = aUserProfileState(userName = A_USER_NAME, avatarUrl = AN_AVATAR_URL), + openAvatarPreview = callback, + ) + rule.onNode(hasTestTag(TestTags.memberDetailAvatar.value)).performClick() + } + } + + @Test + fun `on avatar clicked with no avatar - nothing happens`() = runTest { + val callback = EnsureNeverCalledWithTwoParams() + rule.setUserProfileView( + state = aUserProfileState(userName = A_USER_NAME, avatarUrl = null), + openAvatarPreview = callback, + ) + rule.onNode(hasTestTag(TestTags.memberDetailAvatar.value)).performClick() + } + + @Test + fun `on Share clicked - the expected callback is called`() = runTest { + ensureCalledOnce { callback -> + rule.setUserProfileView( + onShareUser = callback, + ) + rule.clickOn(CommonStrings.action_share) + } + } + + @Test + fun `on Message clicked - the StartDm event is emitted`() = runTest { + val eventsRecorder = EventsRecorder() + rule.setUserProfileView( + state = aUserProfileState( + dmRoomId = A_ROOM_ID, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_message) + eventsRecorder.assertSingle(UserProfileEvents.StartDM) + } + + @Test + fun `on Call clicked - the expected callback is called`() = runTest { + ensureCalledOnceWithParam(A_ROOM_ID) { callback -> + rule.setUserProfileView( + state = aUserProfileState( + dmRoomId = A_ROOM_ID, + canCall = true, + ), + onStartCall = callback, + ) + rule.clickOn(CommonStrings.action_call) + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `on Block user clicked - a BlockUser event is emitted with needsConfirmation`() = runTest { + val eventsRecorder = EventsRecorder() + rule.setUserProfileView( + state = aUserProfileState( + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_dm_details_block_user) + eventsRecorder.assertSingle(UserProfileEvents.BlockUser(needsConfirmation = true)) + } + + @Test + fun `on confirming block user - a BlockUser event is emitted without needsConfirmation`() = runTest { + val eventsRecorder = EventsRecorder() + rule.setUserProfileView( + state = aUserProfileState( + displayConfirmationDialog = UserProfileState.ConfirmationDialog.Block, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_dm_details_block_alert_action) + eventsRecorder.assertSingle(UserProfileEvents.BlockUser(needsConfirmation = false)) + } + + @Test + fun `on canceling blocking a user - a ClearConfirmationDialog event is emitted`() = runTest { + val eventsRecorder = EventsRecorder() + rule.setUserProfileView( + state = aUserProfileState( + displayConfirmationDialog = UserProfileState.ConfirmationDialog.Block, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(UserProfileEvents.ClearConfirmationDialog) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `on Unblock user clicked - an UnblockUser event is emitted with needsConfirmation`() = runTest { + val eventsRecorder = EventsRecorder() + rule.setUserProfileView( + state = aUserProfileState( + isBlocked = AsyncData.Success(true), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_dm_details_unblock_user) + eventsRecorder.assertSingle(UserProfileEvents.UnblockUser(needsConfirmation = true)) + } + + @Test + fun `on confirming Unblock user - an UnblockUser event is emitted without needsConfirmation`() = runTest { + val eventsRecorder = EventsRecorder() + rule.setUserProfileView( + state = aUserProfileState( + isBlocked = AsyncData.Success(true), + displayConfirmationDialog = UserProfileState.ConfirmationDialog.Unblock, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_dm_details_unblock_alert_action) + eventsRecorder.assertSingle(UserProfileEvents.UnblockUser(needsConfirmation = false)) + } + + @Test + fun `on canceling unblocking a user - a ClearConfirmationDialog event is emitted`() = runTest { + val eventsRecorder = EventsRecorder() + rule.setUserProfileView( + state = aUserProfileState( + isBlocked = AsyncData.Success(true), + displayConfirmationDialog = UserProfileState.ConfirmationDialog.Unblock, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(UserProfileEvents.ClearConfirmationDialog) + } + + @Test + fun `on verify user clicked - the right callback is called`() = runTest { + ensureCalledOnceWithParam(A_USER_ID) { callback -> + rule.setUserProfileView( + state = aUserProfileState(userId = A_USER_ID, verificationState = UserProfileVerificationState.UNVERIFIED), + onVerifyClick = callback, + ) + rule.clickOn(CommonStrings.common_verify_user) + } + } +} + +private fun AndroidComposeTestRule.setUserProfileView( + state: UserProfileState = aUserProfileState( + eventSink = EventsRecorder(expectEvents = false), + ), + onShareUser: () -> Unit = EnsureNeverCalled(), + onDmStarted: (RoomId) -> Unit = EnsureNeverCalledWithParam(), + onStartCall: (RoomId) -> Unit = EnsureNeverCalledWithParam(), + onVerifyClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), + goBack: () -> Unit = EnsureNeverCalled(), + openAvatarPreview: (String, String) -> Unit = EnsureNeverCalledWithTwoParams(), +) { + setContent { + UserProfileView( + state = state, + onShareUser = onShareUser, + onOpenDm = onDmStarted, + onStartCall = onStartCall, + goBack = goBack, + openAvatarPreview = openAvatarPreview, + onVerifyClick = onVerifyClick, + ) + } +} diff --git a/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserDialogsTest.kt b/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserDialogsTest.kt new file mode 100644 index 0000000..3219658 --- /dev/null +++ b/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserDialogsTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.userprofile.shared.blockuser + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.userprofile.api.UserProfileEvents +import io.element.android.features.userprofile.api.UserProfileState +import io.element.android.features.userprofile.shared.R +import io.element.android.features.userprofile.shared.aUserProfileState +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BlockUserDialogsTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `confirm block user emit expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setContent { + BlockUserDialogs( + state = aUserProfileState( + displayConfirmationDialog = UserProfileState.ConfirmationDialog.Block, + eventSink = eventsRecorder, + ) + ) + } + rule.clickOn(R.string.screen_dm_details_block_alert_action) + eventsRecorder.assertSingle(UserProfileEvents.BlockUser(false)) + } + + @Test + fun `cancel block user emit expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setContent { + BlockUserDialogs( + state = aUserProfileState( + displayConfirmationDialog = UserProfileState.ConfirmationDialog.Block, + eventSink = eventsRecorder, + ) + ) + } + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(UserProfileEvents.ClearConfirmationDialog) + } + + @Test + fun `confirm unblock user emit expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setContent { + BlockUserDialogs( + state = aUserProfileState( + displayConfirmationDialog = UserProfileState.ConfirmationDialog.Unblock, + eventSink = eventsRecorder, + ) + ) + } + rule.clickOn(R.string.screen_dm_details_unblock_alert_action) + eventsRecorder.assertSingle(UserProfileEvents.UnblockUser(false)) + } + + @Test + fun `cancel unblock user emit expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setContent { + BlockUserDialogs( + state = aUserProfileState( + displayConfirmationDialog = UserProfileState.ConfirmationDialog.Unblock, + eventSink = eventsRecorder, + ) + ) + } + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(UserProfileEvents.ClearConfirmationDialog) + } +} diff --git a/features/verifysession/api/build.gradle.kts b/features/verifysession/api/build.gradle.kts new file mode 100644 index 0000000..0f174f3 --- /dev/null +++ b/features/verifysession/api/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.verifysession.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/IncomingVerificationEntryPoint.kt b/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/IncomingVerificationEntryPoint.kt new file mode 100644 index 0000000..0330bf4 --- /dev/null +++ b/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/IncomingVerificationEntryPoint.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.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.architecture.NodeInputs +import io.element.android.libraries.matrix.api.verification.VerificationRequest + +interface IncomingVerificationEntryPoint : FeatureEntryPoint { + data class Params( + val verificationRequest: VerificationRequest.Incoming, + ) : NodeInputs + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + callback: Callback, + ): Node + + interface Callback : Plugin { + fun onDone() + } +} diff --git a/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/OutgoingVerificationEntryPoint.kt b/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/OutgoingVerificationEntryPoint.kt new file mode 100644 index 0000000..d7608ca --- /dev/null +++ b/features/verifysession/api/src/main/kotlin/io/element/android/features/verifysession/api/OutgoingVerificationEntryPoint.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.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.architecture.NodeInputs +import io.element.android.libraries.matrix.api.verification.VerificationRequest + +interface OutgoingVerificationEntryPoint : FeatureEntryPoint { + data class Params( + val showDeviceVerifiedScreen: Boolean, + val verificationRequest: VerificationRequest.Outgoing, + ) : NodeInputs + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + callback: Callback, + ): Node + + interface Callback : Plugin { + fun navigateToLearnMoreAboutEncryption() + fun onBack() + fun onDone() + } +} diff --git a/features/verifysession/impl/.gitignore b/features/verifysession/impl/.gitignore new file mode 100644 index 0000000..42afabf --- /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 0000000..89a87ed --- /dev/null +++ b/features/verifysession/impl/build.gradle.kts @@ -0,0 +1,47 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.verifysession.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.appconfig) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.dateformatter.api) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.preferences.api) + implementation(projects.libraries.uiStrings) + implementation(projects.features.logout.api) + api(libs.statemachine) + api(projects.features.verifysession.api) + + testCommonDependencies(libs, true) + testImplementation(projects.features.logout.test) + testImplementation(projects.libraries.dateformatter.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.preferences.test) +} diff --git a/features/verifysession/impl/consumer-rules.pro b/features/verifysession/impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/emoji/EmojiResource.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/emoji/EmojiResource.kt new file mode 100644 index 0000000..3957107 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/emoji/EmojiResource.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +package io.element.android.features.verifysession.impl.emoji + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import io.element.android.features.verifysession.impl.R + +internal data class EmojiResource( + @DrawableRes val drawableRes: Int, + @StringRes val nameRes: Int +) + +internal fun Int.toEmojiResource(): EmojiResource { + return when (this % 64) { + 0 -> EmojiResource(R.drawable.ic_verification_00, R.string.verification_emoji_00) + 1 -> EmojiResource(R.drawable.ic_verification_01, R.string.verification_emoji_01) + 2 -> EmojiResource(R.drawable.ic_verification_02, R.string.verification_emoji_02) + 3 -> EmojiResource(R.drawable.ic_verification_03, R.string.verification_emoji_03) + 4 -> EmojiResource(R.drawable.ic_verification_04, R.string.verification_emoji_04) + 5 -> EmojiResource(R.drawable.ic_verification_05, R.string.verification_emoji_05) + 6 -> EmojiResource(R.drawable.ic_verification_06, R.string.verification_emoji_06) + 7 -> EmojiResource(R.drawable.ic_verification_07, R.string.verification_emoji_07) + 8 -> EmojiResource(R.drawable.ic_verification_08, R.string.verification_emoji_08) + 9 -> EmojiResource(R.drawable.ic_verification_09, R.string.verification_emoji_09) + 10 -> EmojiResource(R.drawable.ic_verification_10, R.string.verification_emoji_10) + 11 -> EmojiResource(R.drawable.ic_verification_11, R.string.verification_emoji_11) + 12 -> EmojiResource(R.drawable.ic_verification_12, R.string.verification_emoji_12) + 13 -> EmojiResource(R.drawable.ic_verification_13, R.string.verification_emoji_13) + 14 -> EmojiResource(R.drawable.ic_verification_14, R.string.verification_emoji_14) + 15 -> EmojiResource(R.drawable.ic_verification_15, R.string.verification_emoji_15) + 16 -> EmojiResource(R.drawable.ic_verification_16, R.string.verification_emoji_16) + 17 -> EmojiResource(R.drawable.ic_verification_17, R.string.verification_emoji_17) + 18 -> EmojiResource(R.drawable.ic_verification_18, R.string.verification_emoji_18) + 19 -> EmojiResource(R.drawable.ic_verification_19, R.string.verification_emoji_19) + 20 -> EmojiResource(R.drawable.ic_verification_20, R.string.verification_emoji_20) + 21 -> EmojiResource(R.drawable.ic_verification_21, R.string.verification_emoji_21) + 22 -> EmojiResource(R.drawable.ic_verification_22, R.string.verification_emoji_22) + 23 -> EmojiResource(R.drawable.ic_verification_23, R.string.verification_emoji_23) + 24 -> EmojiResource(R.drawable.ic_verification_24, R.string.verification_emoji_24) + 25 -> EmojiResource(R.drawable.ic_verification_25, R.string.verification_emoji_25) + 26 -> EmojiResource(R.drawable.ic_verification_26, R.string.verification_emoji_26) + 27 -> EmojiResource(R.drawable.ic_verification_27, R.string.verification_emoji_27) + 28 -> EmojiResource(R.drawable.ic_verification_28, R.string.verification_emoji_28) + 29 -> EmojiResource(R.drawable.ic_verification_29, R.string.verification_emoji_29) + 30 -> EmojiResource(R.drawable.ic_verification_30, R.string.verification_emoji_30) + 31 -> EmojiResource(R.drawable.ic_verification_31, R.string.verification_emoji_31) + 32 -> EmojiResource(R.drawable.ic_verification_32, R.string.verification_emoji_32) + 33 -> EmojiResource(R.drawable.ic_verification_33, R.string.verification_emoji_33) + 34 -> EmojiResource(R.drawable.ic_verification_34, R.string.verification_emoji_34) + 35 -> EmojiResource(R.drawable.ic_verification_35, R.string.verification_emoji_35) + 36 -> EmojiResource(R.drawable.ic_verification_36, R.string.verification_emoji_36) + 37 -> EmojiResource(R.drawable.ic_verification_37, R.string.verification_emoji_37) + 38 -> EmojiResource(R.drawable.ic_verification_38, R.string.verification_emoji_38) + 39 -> EmojiResource(R.drawable.ic_verification_39, R.string.verification_emoji_39) + 40 -> EmojiResource(R.drawable.ic_verification_40, R.string.verification_emoji_40) + 41 -> EmojiResource(R.drawable.ic_verification_41, R.string.verification_emoji_41) + 42 -> EmojiResource(R.drawable.ic_verification_42, R.string.verification_emoji_42) + 43 -> EmojiResource(R.drawable.ic_verification_43, R.string.verification_emoji_43) + 44 -> EmojiResource(R.drawable.ic_verification_44, R.string.verification_emoji_44) + 45 -> EmojiResource(R.drawable.ic_verification_45, R.string.verification_emoji_45) + 46 -> EmojiResource(R.drawable.ic_verification_46, R.string.verification_emoji_46) + 47 -> EmojiResource(R.drawable.ic_verification_47, R.string.verification_emoji_47) + 48 -> EmojiResource(R.drawable.ic_verification_48, R.string.verification_emoji_48) + 49 -> EmojiResource(R.drawable.ic_verification_49, R.string.verification_emoji_49) + 50 -> EmojiResource(R.drawable.ic_verification_50, R.string.verification_emoji_50) + 51 -> EmojiResource(R.drawable.ic_verification_51, R.string.verification_emoji_51) + 52 -> EmojiResource(R.drawable.ic_verification_52, R.string.verification_emoji_52) + 53 -> EmojiResource(R.drawable.ic_verification_53, R.string.verification_emoji_53) + 54 -> EmojiResource(R.drawable.ic_verification_54, R.string.verification_emoji_54) + 55 -> EmojiResource(R.drawable.ic_verification_55, R.string.verification_emoji_55) + 56 -> EmojiResource(R.drawable.ic_verification_56, R.string.verification_emoji_56) + 57 -> EmojiResource(R.drawable.ic_verification_57, R.string.verification_emoji_57) + 58 -> EmojiResource(R.drawable.ic_verification_58, R.string.verification_emoji_58) + 59 -> EmojiResource(R.drawable.ic_verification_59, R.string.verification_emoji_59) + 60 -> EmojiResource(R.drawable.ic_verification_60, R.string.verification_emoji_60) + 61 -> EmojiResource(R.drawable.ic_verification_61, R.string.verification_emoji_61) + 62 -> EmojiResource(R.drawable.ic_verification_62, R.string.verification_emoji_62) + 63 -> EmojiResource(R.drawable.ic_verification_63, R.string.verification_emoji_63) + else -> error("Cannot happen ($this)!") + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/emoji/SasEmojisPreview.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/emoji/SasEmojisPreview.kt new file mode 100644 index 0000000..9facf31 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/emoji/SasEmojisPreview.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.emoji + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +@PreviewsDayNight +internal fun SasEmojisPreview() = ElementPreview { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + List(64) { it to it.toEmojiResource() } + .chunked(8) + .forEach { + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + it.forEach { emoji -> + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(id = emoji.second.drawableRes), + contentDescription = null, + modifier = Modifier + .size(32.dp) + ) + Text( + text = emoji.first.toString() + ":" + stringResource(id = emoji.second.nameRes), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodySmRegular.copy( + fontSize = 8.sp + ) + ) + } + } + } + } + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/DefaultIncomingVerificationEntryPoint.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/DefaultIncomingVerificationEntryPoint.kt new file mode 100644 index 0000000..6567594 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/DefaultIncomingVerificationEntryPoint.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.incoming + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultIncomingVerificationEntryPoint : IncomingVerificationEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: IncomingVerificationEntryPoint.Params, + callback: IncomingVerificationEntryPoint.Callback, + ): Node { + return parentNode.createNode(buildContext, listOf(params, callback)) + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNavigator.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNavigator.kt new file mode 100644 index 0000000..8572030 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNavigator.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.incoming + +fun interface IncomingVerificationNavigator { + fun onFinish() +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNode.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNode.kt new file mode 100644 index 0000000..3eb8735 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationNode.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.incoming + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class IncomingVerificationNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: IncomingVerificationPresenter.Factory, +) : Node(buildContext, plugins = plugins), + IncomingVerificationNavigator { + private val callback: IncomingVerificationEntryPoint.Callback = callback() + private val presenter = presenterFactory.create( + verificationRequest = inputs().verificationRequest, + navigator = this, + ) + + override fun onFinish() { + callback.onDone() + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + IncomingVerificationView( + state = state, + modifier = modifier, + ) + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt new file mode 100644 index 0000000..aa991a5 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.verifysession.impl.incoming + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import com.freeletics.flowredux.compose.rememberStateAndDispatch +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails +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.VerificationRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber +import io.element.android.features.verifysession.impl.incoming.IncomingVerificationStateMachine.Event as StateMachineEvent +import io.element.android.features.verifysession.impl.incoming.IncomingVerificationStateMachine.State as StateMachineState + +@AssistedInject +class IncomingVerificationPresenter( + @Assisted private val verificationRequest: VerificationRequest.Incoming, + @Assisted private val navigator: IncomingVerificationNavigator, + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, + private val sessionVerificationService: SessionVerificationService, + private val stateMachine: IncomingVerificationStateMachine, + private val dateFormatter: DateFormatter, +) : Presenter { + @AssistedFactory + fun interface Factory { + fun create( + verificationRequest: VerificationRequest.Incoming, + navigator: IncomingVerificationNavigator, + ): IncomingVerificationPresenter + } + + @Composable + override fun present(): IncomingVerificationState { + val coroutineScope = rememberCoroutineScope() + + val stateAndDispatch = stateMachine.rememberStateAndDispatch() + + DisposableEffect(Unit) { + coroutineScope.launch { + // Force reset, just in case the service was left in a broken state + sessionVerificationService.reset( + cancelAnyPendingVerificationAttempt = false + ) + + // Start this after observing state machine + observeVerificationService() + + // Acknowledge the request right now + sessionVerificationService.acknowledgeVerificationRequest(verificationRequest) + } + + onDispose { + sessionCoroutineScope.launch { + val currentState = stateAndDispatch.state.value + sessionVerificationService.reset( + cancelAnyPendingVerificationAttempt = currentState?.isPending() == true, + ) + } + } + } + + val formattedSignInTime = remember { + dateFormatter.format( + timestamp = verificationRequest.details.firstSeenTimestamp, + mode = DateFormatterMode.TimeOrDate, + ) + } + val step by remember { + derivedStateOf { + stateAndDispatch.state.value.toVerificationStep( + sessionVerificationRequestDetails = verificationRequest.details, + formattedSignInTime = formattedSignInTime, + ) + } + } + + LaunchedEffect(stateAndDispatch.state.value) { + if ((stateAndDispatch.state.value as? StateMachineState.Initial)?.isCancelled == true) { + // The verification was canceled before it was started, maybe because another session accepted it + navigator.onFinish() + } + } + + fun handleEvent(event: IncomingVerificationViewEvents) { + Timber.d("Verification user action: ${event::class.simpleName}") + when (event) { + IncomingVerificationViewEvents.StartVerification -> + stateAndDispatch.dispatchAction(StateMachineEvent.AcceptIncomingRequest) + IncomingVerificationViewEvents.IgnoreVerification -> + navigator.onFinish() + IncomingVerificationViewEvents.ConfirmVerification -> + stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge) + IncomingVerificationViewEvents.DeclineVerification -> + stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge) + IncomingVerificationViewEvents.GoBack -> { + when (val verificationStep = step) { + is Step.Initial -> if (verificationStep.isWaiting) { + stateAndDispatch.dispatchAction(StateMachineEvent.Cancel) + } else { + navigator.onFinish() + } + is Step.Verifying -> if (verificationStep.isWaiting) { + // What do we do in this case? + } else { + stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge) + } + Step.Canceled, + Step.Completed, + Step.Failure -> navigator.onFinish() + } + } + } + } + + return IncomingVerificationState( + step = step, + request = verificationRequest, + eventSink = ::handleEvent, + ) + } + + private fun StateMachineState?.toVerificationStep( + sessionVerificationRequestDetails: SessionVerificationRequestDetails, + formattedSignInTime: String, + ): Step = + when (val machineState = this) { + is StateMachineState.Initial, + StateMachineState.AcceptingIncomingVerification, + StateMachineState.RejectingIncomingVerification, + null -> { + Step.Initial( + deviceDisplayName = sessionVerificationRequestDetails.deviceDisplayName, + deviceId = sessionVerificationRequestDetails.deviceId, + formattedSignInTime = formattedSignInTime, + isWaiting = machineState == StateMachineState.AcceptingIncomingVerification || + machineState == StateMachineState.RejectingIncomingVerification, + ) + } + is StateMachineState.ChallengeReceived -> + Step.Verifying( + data = machineState.data, + isWaiting = false, + ) + StateMachineState.Completed -> Step.Completed + StateMachineState.Canceling, + StateMachineState.Failure -> Step.Failure + is StateMachineState.AcceptingChallenge -> + Step.Verifying( + data = machineState.data, + isWaiting = true, + ) + is StateMachineState.RejectingChallenge -> + Step.Verifying( + data = machineState.data, + isWaiting = true, + ) + StateMachineState.Canceled -> Step.Canceled + } + + private fun CoroutineScope.observeVerificationService() { + sessionVerificationService.verificationFlowState + .onEach { Timber.d("Verification flow state: ${it::class.simpleName}") } + .onEach { verificationAttemptState -> + when (verificationAttemptState) { + VerificationFlowState.Initial, + VerificationFlowState.DidAcceptVerificationRequest, + VerificationFlowState.DidStartSasVerification -> Unit + is VerificationFlowState.DidReceiveVerificationData -> { + stateMachine.dispatch(StateMachineEvent.DidReceiveChallenge(verificationAttemptState.data)) + } + VerificationFlowState.DidFinish -> { + stateMachine.dispatch(StateMachineEvent.DidAcceptChallenge) + } + VerificationFlowState.DidCancel -> { + // Can happen when: + // - the remote party cancel the verification (before it is started) + // - another session has accepted the incoming verification request + // - the user reject the challenge from this application (I think this is an error). In this case, the state + // machine will ignore this event and change state to Failure. + stateMachine.dispatch(StateMachineEvent.DidCancel) + } + VerificationFlowState.DidFail -> { + stateMachine.dispatch(StateMachineEvent.DidFail) + } + } + } + .launchIn(this) + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt new file mode 100644 index 0000000..311fc13 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.incoming + +import androidx.compose.runtime.Stable +import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.verification.SessionVerificationData +import io.element.android.libraries.matrix.api.verification.VerificationRequest + +data class IncomingVerificationState( + val step: Step, + val request: VerificationRequest.Incoming, + val eventSink: (IncomingVerificationViewEvents) -> Unit, +) { + @Stable + sealed interface Step { + data class Initial( + val deviceDisplayName: String?, + val deviceId: DeviceId, + val formattedSignInTime: String, + val isWaiting: Boolean, + ) : Step + + data class Verifying( + val data: SessionVerificationData, + val isWaiting: Boolean, + ) : Step + + data object Canceled : Step + data object Completed : Step + data object Failure : Step + + val isTimeLimited: Boolean + get() = this is Initial || + this is Verifying + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateMachine.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateMachine.kt new file mode 100644 index 0000000..babb12f --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateMachine.kt @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.verifysession.impl.incoming + +import com.freeletics.flowredux.dsl.FlowReduxStateMachine +import dev.zacsweers.metro.Inject +import io.element.android.features.verifysession.impl.util.andLogStateChange +import io.element.android.features.verifysession.impl.util.logReceivedEvents +import io.element.android.libraries.matrix.api.verification.SessionVerificationData +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import kotlinx.coroutines.ExperimentalCoroutinesApi +import com.freeletics.flowredux.dsl.State as MachineState + +@Inject +class IncomingVerificationStateMachine( + private val sessionVerificationService: SessionVerificationService, +) : FlowReduxStateMachine( + initialState = State.Initial(isCancelled = false) +) { + init { + spec { + inState { + on { _, state -> + state.override { State.AcceptingIncomingVerification.andLogStateChange() } + } + } + inState { + onEnterEffect { + sessionVerificationService.acceptVerificationRequest() + } + on { event: Event.DidReceiveChallenge, state -> + state.override { State.ChallengeReceived(event.data).andLogStateChange() } + } + } + inState { + on { _, state -> + state.override { State.AcceptingChallenge(state.snapshot.data).andLogStateChange() } + } + on { _, state -> + state.override { State.RejectingChallenge(state.snapshot.data).andLogStateChange() } + } + } + inState { + onEnterEffect { + sessionVerificationService.approveVerification() + } + on { _, state -> + state.override { State.Completed.andLogStateChange() } + } + } + inState { + onEnterEffect { + sessionVerificationService.declineVerification() + } + } + inState { + onEnterEffect { + sessionVerificationService.cancelVerification() + } + } + inState { + logReceivedEvents() + on { _, state: MachineState -> + when (state.snapshot) { + State.Completed, State.Canceled -> state.noChange() + else -> { + sessionVerificationService.cancelVerification() + state.override { State.Canceled.andLogStateChange() } + } + } + } + on { _, state: MachineState -> + when (state.snapshot) { + is State.RejectingChallenge -> { + state.override { State.Failure.andLogStateChange() } + } + is State.Initial -> state.mutate { State.Initial(isCancelled = true).andLogStateChange() } + State.AcceptingIncomingVerification, + State.RejectingIncomingVerification, + is State.ChallengeReceived, + is State.AcceptingChallenge, + State.Canceling -> state.override { State.Canceled.andLogStateChange() } + State.Canceled, + State.Completed, + State.Failure -> state.noChange() + } + } + on { _, state: MachineState -> + state.override { State.Failure.andLogStateChange() } + } + } + } + } + + sealed interface State { + /** The initial state, before verification started. */ + data class Initial(val isCancelled: Boolean) : State + + /** User is accepting the incoming verification. */ + data object AcceptingIncomingVerification : State + + /** User is rejecting the incoming verification. */ + data object RejectingIncomingVerification : State + + /** Verification accepted and emojis received. */ + data class ChallengeReceived(val data: SessionVerificationData) : State + + /** Accepting the verification challenge. */ + data class AcceptingChallenge(val data: SessionVerificationData) : State + + /** Rejecting the verification challenge. */ + data class RejectingChallenge(val data: SessionVerificationData) : State + + /** The verification is being canceled. */ + data object Canceling : State + + /** The verification has been canceled, remotely or locally. */ + data object Canceled : State + + /** Verification successful. */ + data object Completed : State + + /** Verification failure. */ + data object Failure : State + + fun isPending(): Boolean = when (this) { + AcceptingIncomingVerification, RejectingIncomingVerification, Failure, is ChallengeReceived, is AcceptingChallenge, is RejectingChallenge -> true + is Initial, Canceling, Canceled, Completed -> false + } + } + + sealed interface Event { + /** User accepts the incoming request. */ + data object AcceptIncomingRequest : Event + + /** Has received data. */ + data class DidReceiveChallenge(val data: SessionVerificationData) : Event + + /** Emojis match. */ + data object AcceptChallenge : Event + + /** Emojis do not match. */ + data object DeclineChallenge : Event + + /** Remote accepted challenge. */ + data object DidAcceptChallenge : Event + + /** Request cancellation. */ + data object Cancel : Event + + /** Verification cancelled. */ + data object DidCancel : Event + + /** Request failed. */ + data object DidFail : Event + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateProvider.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateProvider.kt new file mode 100644 index 0000000..77f5af4 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStateProvider.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.incoming + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step +import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData +import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData +import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.core.FlowId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails +import io.element.android.libraries.matrix.api.verification.VerificationRequest + +open class IncomingVerificationStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anIncomingVerificationState(), + anIncomingVerificationState(step = aStepInitial(isWaiting = false), verificationRequest = anIncomingSessionVerificationRequest()), + anIncomingVerificationState(step = aStepInitial(isWaiting = false), verificationRequest = anIncomingUserVerificationRequest()), + anIncomingVerificationState(step = aStepInitial(isWaiting = true), verificationRequest = anIncomingSessionVerificationRequest()), + anIncomingVerificationState(step = aStepInitial(isWaiting = true), verificationRequest = anIncomingUserVerificationRequest()), + anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = false)), + anIncomingVerificationState( + step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = false), + verificationRequest = anIncomingUserVerificationRequest() + ), + anIncomingVerificationState(step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = true)), + anIncomingVerificationState( + step = Step.Verifying(data = aEmojisSessionVerificationData(), isWaiting = true), + verificationRequest = anIncomingUserVerificationRequest() + ), + anIncomingVerificationState(step = Step.Verifying(data = aDecimalsSessionVerificationData(), isWaiting = false)), + anIncomingVerificationState(step = Step.Completed), + anIncomingVerificationState(step = Step.Completed, verificationRequest = anIncomingUserVerificationRequest()), + anIncomingVerificationState(step = Step.Failure), + anIncomingVerificationState(step = Step.Canceled), + // Add other state here + ) +} + +internal fun aStepInitial( + isWaiting: Boolean = false, +) = Step.Initial( + deviceDisplayName = "Element X Android", + deviceId = DeviceId("ILAKNDNASDLK"), + formattedSignInTime = "12:34", + isWaiting = isWaiting, +) + +internal fun anIncomingSessionVerificationRequest() = VerificationRequest.Incoming.OtherSession( + details = SessionVerificationRequestDetails( + senderProfile = MatrixUser( + userId = UserId("@alice:example.com"), + displayName = "Alice", + avatarUrl = null, + ), + flowId = FlowId("1234"), + deviceId = DeviceId("ILAKNDNASDLK"), + deviceDisplayName = "a device name", + firstSeenTimestamp = 0, + ) +) + +internal fun anIncomingUserVerificationRequest() = VerificationRequest.Incoming.User( + details = SessionVerificationRequestDetails( + senderProfile = MatrixUser( + userId = UserId("@alice:example.com"), + displayName = "Alice", + avatarUrl = null, + ), + flowId = FlowId("1234"), + deviceId = DeviceId("ILAKNDNASDLK"), + deviceDisplayName = "a device name", + firstSeenTimestamp = 0, + ) +) + +internal fun anIncomingVerificationState( + step: Step = aStepInitial(), + verificationRequest: VerificationRequest.Incoming = anIncomingSessionVerificationRequest(), + eventSink: (IncomingVerificationViewEvents) -> Unit = {}, +) = IncomingVerificationState( + step = step, + request = verificationRequest, + eventSink = eventSink, +) diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt new file mode 100644 index 0000000..286d826 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt @@ -0,0 +1,302 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.incoming + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.focusable +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.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults.topAppBarColors +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.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.focused +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.verifysession.impl.R +import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step +import io.element.android.features.verifysession.impl.incoming.ui.SessionDetailsView +import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu +import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying +import io.element.android.features.verifysession.impl.ui.VerificationUserProfileContent +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.BigIcon +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.InvisibleButton +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.verification.SessionVerificationData +import io.element.android.libraries.matrix.api.verification.VerificationRequest +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * [Figma](https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=819-7324). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun IncomingVerificationView( + state: IncomingVerificationState, + modifier: Modifier = Modifier, +) { + val step = state.step + + BackHandler { + state.eventSink(IncomingVerificationViewEvents.GoBack) + } + HeaderFooterPage( + modifier = modifier, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + when { + step is Step.Initial && !step.isWaiting -> Unit + step is Step.Completed -> Unit + else -> BackButton(onClick = { state.eventSink(IncomingVerificationViewEvents.GoBack) }) + } + }, + colors = topAppBarColors(containerColor = Color.Transparent), + ) + }, + header = { + IncomingVerificationHeader(step = step, request = state.request) + }, + footer = { + IncomingVerificationBottomMenu( + state = state, + ) + }, + isScrollable = true, + ) { + IncomingVerificationContent( + step = step, + request = state.request, + ) + } +} + +@Composable +private fun IncomingVerificationHeader(step: Step, request: VerificationRequest.Incoming) { + val iconStyle = when (step) { + Step.Canceled -> BigIcon.Style.AlertSolid + is Step.Initial -> if (step.isWaiting) { + BigIcon.Style.Loading + } else { + when (request) { + is VerificationRequest.Incoming.OtherSession -> BigIcon.Style.Default(CompoundIcons.LockSolid()) + is VerificationRequest.Incoming.User -> BigIcon.Style.Default(CompoundIcons.UserProfileSolid()) + } + } + is Step.Verifying -> if (step.isWaiting) { + BigIcon.Style.Loading + } else { + BigIcon.Style.Default(CompoundIcons.ReactionSolid()) + } + Step.Completed -> BigIcon.Style.SuccessSolid + Step.Failure -> BigIcon.Style.AlertSolid + } + val titleTextId = when (step) { + Step.Canceled -> CommonStrings.common_verification_failed + is Step.Initial -> R.string.screen_session_verification_request_title + is Step.Verifying -> when (step.data) { + is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title + is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title + } + Step.Completed -> CommonStrings.common_verification_complete + Step.Failure -> R.string.screen_session_verification_request_failure_title + } + val subtitleTextId = when (step) { + Step.Canceled -> R.string.screen_session_verification_request_failure_subtitle + is Step.Initial -> when (request) { + is VerificationRequest.Incoming.OtherSession -> R.string.screen_session_verification_request_subtitle + is VerificationRequest.Incoming.User -> R.string.screen_session_verification_user_responder_subtitle + } + is Step.Verifying -> when (step.data) { + is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle + is SessionVerificationData.Emojis -> when (request) { + is VerificationRequest.Incoming.OtherSession -> R.string.screen_session_verification_compare_emojis_subtitle + is VerificationRequest.Incoming.User -> R.string.screen_session_verification_compare_emojis_user_subtitle + } + } + Step.Completed -> when (request) { + is VerificationRequest.Incoming.OtherSession -> R.string.screen_session_verification_complete_subtitle + is VerificationRequest.Incoming.User -> R.string.screen_session_verification_complete_user_subtitle + } + Step.Failure -> R.string.screen_session_verification_request_failure_subtitle + } + val timeLimitMessage = if (step.isTimeLimited) { + stringResource(CommonStrings.a11y_session_verification_time_limited_action_required) + } else { + "" + } + IconTitleSubtitleMolecule( + modifier = Modifier + .padding(bottom = 16.dp) + .semantics(mergeDescendants = true) { + contentDescription = timeLimitMessage + focused = true + if (iconStyle == BigIcon.Style.Loading) { + // Same code than Modifier.progressSemantics() + progressBarRangeInfo = ProgressBarRangeInfo.Indeterminate + } + } + .focusable(), + iconStyle = iconStyle, + title = stringResource(id = titleTextId), + subTitle = stringResource(id = subtitleTextId), + ) +} + +@Composable +private fun IncomingVerificationContent( + step: Step, + request: VerificationRequest.Incoming, +) { + when (step) { + is Step.Initial -> ContentInitial(step, request) + is Step.Verifying -> VerificationContentVerifying(step.data) + else -> Unit + } +} + +@Composable +private fun ContentInitial( + initialIncoming: Step.Initial, + request: VerificationRequest.Incoming, +) { + when (request) { + is VerificationRequest.Incoming.OtherSession -> { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + SessionDetailsView( + deviceName = initialIncoming.deviceDisplayName, + deviceId = initialIncoming.deviceId, + signInFormattedTimestamp = initialIncoming.formattedSignInTime, + ) + Text( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = 16.dp), + text = stringResource(R.string.screen_session_verification_request_footer), + style = ElementTheme.typography.fontBodyMdMedium, + textAlign = TextAlign.Center, + ) + } + } + is VerificationRequest.Incoming.User -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp), + ) { + VerificationUserProfileContent( + user = request.details.senderProfile, + ) + } + } + } +} + +@Composable +private fun IncomingVerificationBottomMenu( + state: IncomingVerificationState, +) { + val step = state.step + val eventSink = state.eventSink + + when (step) { + is Step.Initial -> { + if (step.isWaiting) { + // Show nothing + } else { + VerificationBottomMenu { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(CommonStrings.action_start_verification), + onClick = { eventSink(IncomingVerificationViewEvents.StartVerification) }, + ) + TextButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(CommonStrings.action_ignore), + onClick = { eventSink(IncomingVerificationViewEvents.IgnoreVerification) }, + ) + } + } + } + is Step.Verifying -> { + if (step.isWaiting) { + // Add invisible buttons to keep the same screen layout + VerificationBottomMenu { + InvisibleButton() + InvisibleButton() + } + } else { + VerificationBottomMenu { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.screen_session_verification_they_match), + onClick = { eventSink(IncomingVerificationViewEvents.ConfirmVerification) }, + ) + TextButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.screen_session_verification_they_dont_match), + onClick = { eventSink(IncomingVerificationViewEvents.DeclineVerification) }, + ) + } + } + } + Step.Canceled, + is Step.Completed, + is Step.Failure -> { + VerificationBottomMenu { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(CommonStrings.action_done), + onClick = { eventSink(IncomingVerificationViewEvents.GoBack) }, + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun IncomingVerificationViewPreview(@PreviewParameter(IncomingVerificationStateProvider::class) state: IncomingVerificationState) = ElementPreview { + IncomingVerificationView( + state = state, + ) +} + +@Preview +@Composable +internal fun IncomingVerificationViewA11yPreview() = ElementPreview { + IncomingVerificationView( + state = anIncomingVerificationState(), + ) +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewEvents.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewEvents.kt new file mode 100644 index 0000000..3833548 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewEvents.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.incoming + +sealed interface IncomingVerificationViewEvents { + data object GoBack : IncomingVerificationViewEvents + data object StartVerification : IncomingVerificationViewEvents + data object IgnoreVerification : IncomingVerificationViewEvents + data object ConfirmVerification : IncomingVerificationViewEvents + data object DeclineVerification : IncomingVerificationViewEvents +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt new file mode 100644 index 0000000..308410f --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.incoming.ui + +import androidx.compose.foundation.border +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.verifysession.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.atomic.molecules.TextWithLabelMolecule +import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun SessionDetailsView( + deviceName: String?, + deviceId: DeviceId, + signInFormattedTimestamp: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = ElementTheme.colors.borderDisabled, + shape = RoundedCornerShape(8.dp) + ) + .padding(24.dp) + .semantics(mergeDescendants = true) {}, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RoundedIconAtom( + modifier = Modifier, + size = RoundedIconAtomSize.Big, + resourceId = CompoundDrawables.ic_compound_devices + ) + Text( + text = deviceName ?: deviceId.value, + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textPrimary, + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextWithLabelMolecule( + label = stringResource(R.string.screen_session_verification_request_details_timestamp), + text = signInFormattedTimestamp, + modifier = Modifier.weight(2f), + ) + TextWithLabelMolecule( + label = stringResource(CommonStrings.common_device_id), + text = deviceId.value, + modifier = Modifier.weight(5f), + spellText = true, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun SessionDetailsViewPreview() = ElementPreview { + Column { + SessionDetailsView( + deviceName = "Element X Android", + deviceId = DeviceId("ILAKNDNASDLK"), + signInFormattedTimestamp = "12:34", + ) + SessionDetailsView( + deviceName = null, + deviceId = DeviceId("ILAKNDNASDLK"), + signInFormattedTimestamp = "12:34", + ) + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/DefaultOutgoingVerificationEntryPoint.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/DefaultOutgoingVerificationEntryPoint.kt new file mode 100644 index 0000000..9667c77 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/DefaultOutgoingVerificationEntryPoint.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.outgoing + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultOutgoingVerificationEntryPoint : OutgoingVerificationEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: OutgoingVerificationEntryPoint.Params, + callback: OutgoingVerificationEntryPoint.Callback, + ): Node { + return parentNode.createNode(buildContext, listOf(params, callback)) + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationNode.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationNode.kt new file mode 100644 index 0000000..f6ccf8b --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationNode.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.outgoing + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class OutgoingVerificationNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: OutgoingVerificationPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + private val callback: OutgoingVerificationEntryPoint.Callback = callback() + private val inputs = inputs() + + private val presenter = presenterFactory.create( + showDeviceVerifiedScreen = inputs.showDeviceVerifiedScreen, + verificationRequest = inputs.verificationRequest, + ) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + OutgoingVerificationView( + state = state, + modifier = modifier, + onLearnMoreClick = callback::navigateToLearnMoreAboutEncryption, + onFinish = callback::onDone, + onBack = callback::onBack, + ) + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationPresenter.kt new file mode 100644 index 0000000..90e8a3e --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationPresenter.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.verifysession.impl.outgoing + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import com.freeletics.flowredux.compose.rememberStateAndDispatch +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.encryption.EncryptionService +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.VerificationFlowState +import io.element.android.libraries.matrix.api.verification.VerificationRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import timber.log.Timber +import io.element.android.features.verifysession.impl.outgoing.OutgoingVerificationStateMachine.Event as StateMachineEvent +import io.element.android.features.verifysession.impl.outgoing.OutgoingVerificationStateMachine.State as StateMachineState + +@AssistedInject +class OutgoingVerificationPresenter( + @Assisted private val showDeviceVerifiedScreen: Boolean, + @Assisted private val verificationRequest: VerificationRequest.Outgoing, + private val sessionVerificationService: SessionVerificationService, + private val encryptionService: EncryptionService, +) : Presenter { + @AssistedFactory + fun interface Factory { + fun create( + verificationRequest: VerificationRequest.Outgoing, + showDeviceVerifiedScreen: Boolean, + ): OutgoingVerificationPresenter + } + + private val stateMachine = OutgoingVerificationStateMachine( + sessionVerificationService = sessionVerificationService, + encryptionService = encryptionService, + ) + + @Composable + override fun present(): OutgoingVerificationState { + val stateAndDispatch = stateMachine.rememberStateAndDispatch() + + val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState() + val step by remember { + derivedStateOf { + when (verificationRequest) { + is VerificationRequest.Outgoing.CurrentSession -> { + when (sessionVerifiedStatus) { + SessionVerifiedStatus.Unknown -> OutgoingVerificationState.Step.Loading + SessionVerifiedStatus.NotVerified -> { + stateAndDispatch.state.value.toVerificationStep() + } + SessionVerifiedStatus.Verified -> { + if (stateAndDispatch.state.value != StateMachineState.Initial || showDeviceVerifiedScreen) { + // The user has verified the session, we need to show the success screen + OutgoingVerificationState.Step.Completed + } else { + // Automatic verification, which can happen on freshly created account, in this case, skip the screen + OutgoingVerificationState.Step.Exit + } + } + } + } + is VerificationRequest.Outgoing.User -> stateAndDispatch.state.value.toVerificationStep() + } + } + } + + // Start this after observing state machine + LaunchedEffect(Unit) { + // Force reset, just in case the service was left in a broken state + sessionVerificationService.reset(cancelAnyPendingVerificationAttempt = true) + + observeVerificationService() + } + + fun handleEvent(event: OutgoingVerificationViewEvents) { + Timber.d("Verification user action: ${event::class.simpleName}") + when (event) { + // Just relay the event to the state machine + OutgoingVerificationViewEvents.RequestVerification -> StateMachineEvent.RequestVerification(verificationRequest) + OutgoingVerificationViewEvents.StartSasVerification -> StateMachineEvent.StartSasVerification + OutgoingVerificationViewEvents.ConfirmVerification -> StateMachineEvent.AcceptChallenge + OutgoingVerificationViewEvents.DeclineVerification -> StateMachineEvent.DeclineChallenge + OutgoingVerificationViewEvents.Cancel -> StateMachineEvent.Cancel + OutgoingVerificationViewEvents.Reset -> StateMachineEvent.Reset + }.let { stateMachineEvent -> + stateAndDispatch.dispatchAction(stateMachineEvent) + } + } + return OutgoingVerificationState( + step = step, + request = verificationRequest, + eventSink = ::handleEvent, + ) + } + + private fun StateMachineState?.toVerificationStep(): OutgoingVerificationState.Step = + when (val machineState = this) { + StateMachineState.Initial, null -> { + OutgoingVerificationState.Step.Initial + } + is StateMachineState.RequestingVerification, + is StateMachineState.StartingSasVerification, + StateMachineState.SasVerificationStarted -> { + OutgoingVerificationState.Step.AwaitingOtherDeviceResponse + } + + StateMachineState.VerificationRequestAccepted -> { + OutgoingVerificationState.Step.Ready + } + + is StateMachineState.Canceled -> { + OutgoingVerificationState.Step.Canceled + } + + is StateMachineState.Verifying -> { + val async = when (machineState) { + is StateMachineState.Verifying.Replying -> AsyncData.Loading() + else -> AsyncData.Uninitialized + } + OutgoingVerificationState.Step.Verifying(machineState.data, async) + } + + StateMachineState.Completed -> { + OutgoingVerificationState.Step.Completed + } + + StateMachineState.Exit -> { + OutgoingVerificationState.Step.Exit + } + } + + private fun CoroutineScope.observeVerificationService() { + sessionVerificationService.verificationFlowState + .onEach { Timber.d("Verification flow state: ${it::class.simpleName}") } + .onEach { verificationAttemptState -> + when (verificationAttemptState) { + VerificationFlowState.Initial -> stateMachine.dispatch(StateMachineEvent.Reset) + VerificationFlowState.DidAcceptVerificationRequest -> { + stateMachine.dispatch(StateMachineEvent.DidAcceptVerificationRequest) + } + VerificationFlowState.DidStartSasVerification -> { + stateMachine.dispatch(StateMachineEvent.DidStartSasVerification) + } + is VerificationFlowState.DidReceiveVerificationData -> { + stateMachine.dispatch(StateMachineEvent.DidReceiveChallenge(verificationAttemptState.data)) + } + VerificationFlowState.DidFinish -> { + stateMachine.dispatch(StateMachineEvent.DidAcceptChallenge) + } + VerificationFlowState.DidCancel -> { + stateMachine.dispatch(StateMachineEvent.DidCancel) + } + VerificationFlowState.DidFail -> { + stateMachine.dispatch(StateMachineEvent.DidFail) + } + } + } + .launchIn(this) + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationState.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationState.kt new file mode 100644 index 0000000..6ba8a03 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationState.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.outgoing + +import androidx.compose.runtime.Stable +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.verification.SessionVerificationData +import io.element.android.libraries.matrix.api.verification.VerificationRequest + +data class OutgoingVerificationState( + val step: Step, + val request: VerificationRequest.Outgoing, + val eventSink: (OutgoingVerificationViewEvents) -> Unit, +) { + @Stable + sealed interface Step { + data object Loading : Step + data object Initial : Step + data object Canceled : Step + data object AwaitingOtherDeviceResponse : Step + data object Ready : Step + data class Verifying(val data: SessionVerificationData, val state: AsyncData) : Step + data object Completed : Step + data object Exit : Step + + val isTimeLimited: Boolean + get() = this is Initial || + this is AwaitingOtherDeviceResponse || + this is Ready || + this is Verifying + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationStateMachine.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationStateMachine.kt new file mode 100644 index 0000000..7932ccc --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationStateMachine.kt @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:Suppress("WildcardImport") +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.verifysession.impl.outgoing + +import com.freeletics.flowredux.dsl.FlowReduxStateMachine +import io.element.android.features.verifysession.impl.util.andLogStateChange +import io.element.android.features.verifysession.impl.util.logReceivedEvents +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.api.verification.SessionVerificationData +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.verification.VerificationRequest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.timeout +import kotlin.time.Duration.Companion.seconds +import com.freeletics.flowredux.dsl.State as MachineState + +@OptIn(FlowPreview::class) +class OutgoingVerificationStateMachine( + private val sessionVerificationService: SessionVerificationService, + private val encryptionService: EncryptionService, +) : FlowReduxStateMachine( + initialState = State.Initial, +) { + init { + spec { + inState { + on { event, state -> + state.override { State.RequestingVerification(event.verificationRequest).andLogStateChange() } + } + } + inState { + onEnterEffect { event -> + when (event.verificationRequest) { + is VerificationRequest.Outgoing.CurrentSession -> sessionVerificationService.requestCurrentSessionVerification() + is VerificationRequest.Outgoing.User -> sessionVerificationService.requestUserVerification(event.verificationRequest.userId) + } + } + on { _, state -> + state.override { State.VerificationRequestAccepted.andLogStateChange() } + } + } + inState { + onEnterEffect { + sessionVerificationService.startVerification() + } + } + inState { + on { _, state -> + state.override { State.StartingSasVerification.andLogStateChange() } + } + } + inState { + on { _, state -> + sessionVerificationService.reset(cancelAnyPendingVerificationAttempt = false) + state.override { State.Initial.andLogStateChange() } + } + } + inState { + on { event, state -> + state.override { State.Verifying.ChallengeReceived(event.data).andLogStateChange() } + } + } + inState { + on { _, state -> + state.override { State.Verifying.Replying(state.snapshot.data, accept = true).andLogStateChange() } + } + on { _, state -> + state.override { State.Verifying.Replying(state.snapshot.data, accept = false).andLogStateChange() } + } + } + inState { + onEnterEffect { state -> + if (state.accept) { + sessionVerificationService.approveVerification() + } else { + sessionVerificationService.declineVerification() + } + } + on { _, state -> + // If a key backup exists, wait until it's restored or a timeout happens + val hasBackup = encryptionService.doesBackupExistOnServer().getOrNull().orFalse() + if (hasBackup) { + tryOrNull { + encryptionService.recoveryStateStateFlow.filter { it == RecoveryState.ENABLED } + .timeout(10.seconds) + .first() + } + } + state.override { State.Completed.andLogStateChange() } + } + } + inState { + logReceivedEvents() + on { _, state: MachineState -> + state.override { State.SasVerificationStarted.andLogStateChange() } + } + on { event, state: MachineState -> + when (state.snapshot) { + State.Initial, State.Completed, is State.Canceled -> state.override { State.Exit } + // For some reason `cancelVerification` is not calling its delegate `didCancel` method so we don't pass from + // `Canceling` state to `Canceled` automatically anymore + else -> { + sessionVerificationService.cancelVerification() + state.override { State.Canceled.andLogStateChange() } + } + } + } + on { event, state: MachineState -> + state.override { State.Canceled.andLogStateChange() } + } + on { event, state: MachineState -> + state.override { State.Canceled.andLogStateChange() } + } + } + } + } + + sealed interface State { + /** Let the user know that they need to get ready on their other session. */ + data object Initial : State + + /** Waiting for verification acceptance. */ + data class RequestingVerification(val verificationRequest: VerificationRequest.Outgoing) : State + + /** Verification request accepted. Waiting for start. */ + data object VerificationRequestAccepted : State + + /** Waiting for SaS verification start. */ + data object StartingSasVerification : State + + /** A SaS verification flow has been started. */ + data object SasVerificationStarted : State + + sealed class Verifying(open val data: SessionVerificationData) : State { + /** Verification accepted and emojis received. */ + data class ChallengeReceived(override val data: SessionVerificationData) : Verifying(data) + + /** Replying to a verification challenge. */ + data class Replying(override val data: SessionVerificationData, val accept: Boolean) : Verifying(data) + } + + /** The verification has been canceled, remotely or locally. */ + data object Canceled : State + + /** Verification successful. */ + data object Completed : State + + data object Exit : State + } + + sealed interface Event { + /** Request verification. */ + data class RequestVerification(val verificationRequest: VerificationRequest.Outgoing) : Event + + /** The current verification request has been accepted. */ + data object DidAcceptVerificationRequest : Event + + /** Start a SaS verification flow. */ + data object StartSasVerification : Event + + /** Started a SaS verification flow. */ + data object DidStartSasVerification : Event + + /** Has received data. */ + data class DidReceiveChallenge(val data: SessionVerificationData) : Event + + /** Emojis match. */ + data object AcceptChallenge : Event + + /** Emojis do not match. */ + data object DeclineChallenge : Event + + /** Remote accepted challenge. */ + data object DidAcceptChallenge : Event + + /** Request cancellation. */ + data object Cancel : Event + + /** Verification cancelled. */ + data object DidCancel : Event + + /** Request failed. */ + data object DidFail : Event + + /** Reset the verification flow to the initial state. */ + data object Reset : Event + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationStateProvider.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationStateProvider.kt new file mode 100644 index 0000000..2da099d --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationStateProvider.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.outgoing + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.verifysession.impl.outgoing.OutgoingVerificationState.Step +import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData +import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.verification.VerificationRequest + +open class OutgoingVerificationStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anOutgoingVerificationState( + step = Step.Initial, + request = anOutgoingSessionVerificationRequest(), + ), + anOutgoingVerificationState( + step = Step.Initial, + request = anOutgoingUserVerificationRequest(), + ), + anOutgoingVerificationState( + step = Step.AwaitingOtherDeviceResponse, + request = anOutgoingSessionVerificationRequest(), + ), + anOutgoingVerificationState( + step = Step.AwaitingOtherDeviceResponse, + request = anOutgoingUserVerificationRequest(), + ), + anOutgoingVerificationState( + step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized), + request = anOutgoingSessionVerificationRequest(), + ), + anOutgoingVerificationState( + step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Uninitialized), + request = anOutgoingUserVerificationRequest(), + ), + anOutgoingVerificationState( + step = Step.Verifying(aEmojisSessionVerificationData(), AsyncData.Loading()) + ), + anOutgoingVerificationState( + step = Step.Canceled + ), + anOutgoingVerificationState( + step = Step.Ready + ), + anOutgoingVerificationState( + step = Step.Verifying(aDecimalsSessionVerificationData(), AsyncData.Uninitialized) + ), + anOutgoingVerificationState( + step = Step.Completed, + request = anOutgoingSessionVerificationRequest(), + ), + anOutgoingVerificationState( + step = Step.Completed, + request = anOutgoingUserVerificationRequest(), + ), + anOutgoingVerificationState( + step = Step.Loading + ), + anOutgoingVerificationState( + step = Step.Exit + ), + // Add other state here + ) +} + +internal fun anOutgoingUserVerificationRequest() = VerificationRequest.Outgoing.User(userId = UserId("@alice:example.com")) +internal fun anOutgoingSessionVerificationRequest() = VerificationRequest.Outgoing.CurrentSession + +internal fun anOutgoingVerificationState( + step: Step = Step.Initial, + request: VerificationRequest.Outgoing = anOutgoingSessionVerificationRequest(), + eventSink: (OutgoingVerificationViewEvents) -> Unit = {}, +) = OutgoingVerificationState( + step = step, + request = request, + eventSink = eventSink, +) diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationView.kt new file mode 100644 index 0000000..f2d3ccf --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationView.kt @@ -0,0 +1,350 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.outgoing + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +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.material3.TopAppBarDefaults.topAppBarColors +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.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.focused +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.verifysession.impl.R +import io.element.android.features.verifysession.impl.outgoing.OutgoingVerificationState.Step +import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu +import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying +import io.element.android.libraries.architecture.AsyncData +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.BigIcon +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.InvisibleButton +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.verification.SessionVerificationData +import io.element.android.libraries.matrix.api.verification.VerificationRequest +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OutgoingVerificationView( + state: OutgoingVerificationState, + onLearnMoreClick: () -> Unit, + onFinish: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val step = state.step + fun cancelOrResetFlow() { + when (step) { + is Step.Canceled -> state.eventSink(OutgoingVerificationViewEvents.Reset) + Step.Initial, Step.Completed -> onBack() + Step.Ready, is Step.AwaitingOtherDeviceResponse -> state.eventSink(OutgoingVerificationViewEvents.Cancel) + is Step.Verifying -> { + if (!step.state.isLoading()) { + state.eventSink(OutgoingVerificationViewEvents.DeclineVerification) + } + } + else -> Unit + } + } + + BackHandler { + cancelOrResetFlow() + } + + if (step is Step.Loading) { + // Just display a loader in this case, to avoid UI glitch. + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + HeaderFooterPage( + modifier = modifier, + topBar = { + TopAppBar( + title = {}, + navigationIcon = if (step != Step.Completed) { + { BackButton(onClick = ::cancelOrResetFlow) } + } else { + {} + }, + colors = topAppBarColors(containerColor = Color.Transparent) + ) + }, + header = { + OutgoingVerificationHeader(step = step, request = state.request) + }, + footer = { + OutgoingVerificationViewBottomMenu( + screenState = state, + onCancelClick = ::cancelOrResetFlow, + onContinueClick = onFinish, + ) + }, + isScrollable = true, + ) { + OutgoingVerificationContent( + flowState = step, + request = state.request, + onLearnMoreClick = onLearnMoreClick, + ) + } + } +} + +@Composable +private fun OutgoingVerificationHeader(step: Step, request: VerificationRequest.Outgoing) { + val iconStyle = when (step) { + Step.Loading -> error("Should not happen") + Step.Initial -> when (request) { + is VerificationRequest.Outgoing.CurrentSession -> BigIcon.Style.Default(CompoundIcons.Devices()) + is VerificationRequest.Outgoing.User -> BigIcon.Style.Default(CompoundIcons.LockSolid()) + } + Step.AwaitingOtherDeviceResponse -> BigIcon.Style.Loading + Step.Canceled -> BigIcon.Style.AlertSolid + Step.Ready -> BigIcon.Style.Default(CompoundIcons.ReactionSolid()) + Step.Completed -> BigIcon.Style.SuccessSolid + is Step.Verifying -> { + if (step.state is AsyncData.Loading) { + BigIcon.Style.Loading + } else { + BigIcon.Style.Default(CompoundIcons.ReactionSolid()) + } + } + is Step.Exit -> return + } + val titleTextId = when (step) { + Step.Loading -> error("Should not happen") + Step.Initial -> when (request) { + is VerificationRequest.Outgoing.CurrentSession -> R.string.screen_session_verification_use_another_device_title + is VerificationRequest.Outgoing.User -> R.string.screen_session_verification_user_initiator_title + } + Step.AwaitingOtherDeviceResponse -> when (request) { + is VerificationRequest.Outgoing.CurrentSession -> R.string.screen_session_verification_waiting_another_device_title + is VerificationRequest.Outgoing.User -> R.string.screen_session_verification_waiting_other_user_title + } + Step.Canceled -> CommonStrings.common_verification_failed + Step.Ready -> R.string.screen_session_verification_compare_emojis_title + Step.Completed -> when (request) { + is VerificationRequest.Outgoing.CurrentSession -> R.string.screen_session_verification_device_verified + is VerificationRequest.Outgoing.User -> CommonStrings.common_verification_complete + } + is Step.Verifying -> when (step.data) { + is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title + is SessionVerificationData.Emojis -> R.string.screen_session_verification_compare_emojis_title + } + is Step.Exit -> return + } + val subtitleTextId = when (step) { + Step.Loading -> error("Should not happen") + Step.Initial -> when (request) { + is VerificationRequest.Outgoing.CurrentSession -> R.string.screen_session_verification_use_another_device_subtitle + is VerificationRequest.Outgoing.User -> R.string.screen_session_verification_user_initiator_subtitle + } + Step.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_subtitle + Step.Canceled -> R.string.screen_session_verification_failed_subtitle + Step.Ready -> R.string.screen_session_verification_ready_subtitle + Step.Completed -> when (request) { + is VerificationRequest.Outgoing.CurrentSession -> R.string.screen_identity_confirmed_subtitle + is VerificationRequest.Outgoing.User -> R.string.screen_session_verification_complete_user_subtitle + } + is Step.Verifying -> when (step.data) { + is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle + is SessionVerificationData.Emojis -> when (request) { + is VerificationRequest.Outgoing.CurrentSession -> R.string.screen_session_verification_compare_emojis_subtitle + is VerificationRequest.Outgoing.User -> R.string.screen_session_verification_compare_emojis_user_subtitle + } + } + is Step.Exit -> return + } + val timeLimitMessage = if (step.isTimeLimited) { + stringResource(CommonStrings.a11y_session_verification_time_limited_action_required) + } else { + "" + } + IconTitleSubtitleMolecule( + modifier = Modifier + .padding(bottom = 16.dp) + .semantics(mergeDescendants = true) { + contentDescription = timeLimitMessage + focused = true + if (iconStyle == BigIcon.Style.Loading) { + // Same code than Modifier.progressSemantics() + progressBarRangeInfo = ProgressBarRangeInfo.Indeterminate + } + } + .focusable(), + iconStyle = iconStyle, + title = stringResource(id = titleTextId), + subTitle = stringResource(id = subtitleTextId), + ) +} + +@Composable +private fun OutgoingVerificationContent( + flowState: Step, + request: VerificationRequest.Outgoing, + onLearnMoreClick: () -> Unit, +) { + when (flowState) { + is Step.Initial -> { + when (request) { + is VerificationRequest.Outgoing.CurrentSession -> Unit + is VerificationRequest.Outgoing.User -> ContentInitial(onLearnMoreClick) + } + } + is Step.Verifying -> { + VerificationContentVerifying(flowState.data) + } + else -> Unit + } +} + +@Composable +private fun ContentInitial( + onLearnMoreClick: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Text( + modifier = Modifier + .clickable { onLearnMoreClick() } + .padding(vertical = 4.dp, horizontal = 16.dp), + text = stringResource(CommonStrings.action_learn_more), + style = ElementTheme.typography.fontBodyLgMedium + ) + } +} + +@Composable +private fun OutgoingVerificationViewBottomMenu( + screenState: OutgoingVerificationState, + onCancelClick: () -> Unit, + onContinueClick: () -> Unit, +) { + val verificationViewState = screenState.step + val eventSink = screenState.eventSink + + val isVerifying = (verificationViewState as? Step.Verifying)?.state is AsyncData.Loading + + when (verificationViewState) { + Step.Loading -> error("Should not happen") + is Step.Initial -> { + VerificationBottomMenu { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(CommonStrings.action_start_verification), + onClick = { eventSink(OutgoingVerificationViewEvents.RequestVerification) }, + ) + InvisibleButton() + } + } + is Step.Canceled -> { + VerificationBottomMenu { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(CommonStrings.action_done), + onClick = onCancelClick, + ) + InvisibleButton() + } + } + is Step.Ready -> { + VerificationBottomMenu { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(CommonStrings.action_start), + onClick = { eventSink(OutgoingVerificationViewEvents.StartSasVerification) }, + ) + TextButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(CommonStrings.action_cancel), + onClick = onCancelClick, + ) + } + } + is Step.AwaitingOtherDeviceResponse -> Unit + is Step.Verifying -> { + if (isVerifying) { + // Add invisible buttons to keep the same screen layout + VerificationBottomMenu { + InvisibleButton() + InvisibleButton() + } + } else { + VerificationBottomMenu { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.screen_session_verification_they_match), + onClick = { + eventSink(OutgoingVerificationViewEvents.ConfirmVerification) + }, + ) + + TextButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.screen_session_verification_they_dont_match), + onClick = { eventSink(OutgoingVerificationViewEvents.DeclineVerification) }, + ) + } + } + } + is Step.Completed -> { + VerificationBottomMenu { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(CommonStrings.action_continue), + onClick = onContinueClick, + ) + InvisibleButton() + } + } + is Step.Exit -> return + } +} + +@PreviewsDayNight +@Composable +internal fun OutgoingVerificationViewPreview(@PreviewParameter(OutgoingVerificationStateProvider::class) state: OutgoingVerificationState) = ElementPreview { + OutgoingVerificationView( + state = state, + onLearnMoreClick = {}, + onFinish = {}, + onBack = {}, + ) +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationViewEvents.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationViewEvents.kt new file mode 100644 index 0000000..9faab7e --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationViewEvents.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.outgoing + +sealed interface OutgoingVerificationViewEvents { + data object RequestVerification : OutgoingVerificationViewEvents + data object StartSasVerification : OutgoingVerificationViewEvents + data object ConfirmVerification : OutgoingVerificationViewEvents + data object DeclineVerification : OutgoingVerificationViewEvents + data object Cancel : OutgoingVerificationViewEvents + data object Reset : OutgoingVerificationViewEvents +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/Common.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/Common.kt new file mode 100644 index 0000000..665a5e4 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/Common.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.ui + +import io.element.android.libraries.matrix.api.verification.SessionVerificationData +import io.element.android.libraries.matrix.api.verification.VerificationEmoji + +internal fun aEmojisSessionVerificationData( + emojiList: List = aVerificationEmojiList(), +): SessionVerificationData { + return SessionVerificationData.Emojis(emojiList) +} + +internal fun aDecimalsSessionVerificationData( + decimals: List = listOf(123, 456, 789), +): SessionVerificationData { + return SessionVerificationData.Decimals(decimals) +} + +private fun aVerificationEmojiList() = listOf( + VerificationEmoji(number = 27), + VerificationEmoji(number = 54), + VerificationEmoji(number = 54), + VerificationEmoji(number = 42), + VerificationEmoji(number = 48), + VerificationEmoji(number = 48), + VerificationEmoji(number = 63), +) diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationBottomMenu.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationBottomMenu.kt new file mode 100644 index 0000000..123a54e --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationBottomMenu.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.ui + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule + +@Composable +internal fun VerificationBottomMenu( + modifier: Modifier = Modifier, + buttons: @Composable ColumnScope.() -> Unit, +) { + ButtonColumnMolecule( + modifier = modifier.padding(bottom = 16.dp) + ) { + buttons() + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationContentVerifying.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationContentVerifying.kt new file mode 100644 index 0000000..6fc593f --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationContentVerifying.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.ui + +import androidx.compose.foundation.Image +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.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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.verifysession.impl.emoji.toEmojiResource +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.verification.SessionVerificationData +import io.element.android.libraries.matrix.api.verification.VerificationEmoji + +@Composable +internal fun VerificationContentVerifying( + data: SessionVerificationData, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .padding(bottom = 20.dp), + contentAlignment = Alignment.Center + ) { + when (data) { + is SessionVerificationData.Decimals -> { + val text = data.decimals.joinToString(separator = " - ") + Text( + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = data.decimals.joinToString() + }, + text = text, + style = ElementTheme.typography.fontHeadingLgBold, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + } + is SessionVerificationData.Emojis -> { + // We want each row to have up to 4 emojis + val rows = data.emojis.chunked(4) + Column( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {}, + verticalArrangement = Arrangement.spacedBy(40.dp), + ) { + rows.forEach { emojis -> + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + for (emoji in emojis) { + EmojiItemView(emoji = emoji, modifier = Modifier.widthIn(max = 60.dp)) + } + } + } + } + } + } + } +} + +@Composable +private fun EmojiItemView(emoji: VerificationEmoji, modifier: Modifier = Modifier) { + val emojiResource = emoji.number.toEmojiResource() + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) { + Image( + modifier = Modifier.size(48.dp), + painter = painterResource(id = emojiResource.drawableRes), + contentDescription = null, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(id = emojiResource.nameRes), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + overflow = TextOverflow.Visible, + textAlign = TextAlign.Center, + ) + } +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationUserProfileContent.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationUserProfileContent.kt new file mode 100644 index 0000000..edaadc5 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationUserProfileContent.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.ui + +import androidx.compose.foundation.background +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.fillMaxWidth +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.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.CommonDrawables +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.matrix.ui.model.getBestName + +/** + * Ref: https://www.figma.com/design/lMrKOhS8BEb75GXVq7FnNI/ER-96--User-Verification-by-Emoji?node-id=116-52049 + */ +@Composable +fun VerificationUserProfileContent( + user: MatrixUser, + modifier: Modifier = Modifier, +) { + val avatarData = remember(user) { + user.getAvatarData(AvatarSize.UserVerification) + } + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(ElementTheme.colors.bgSubtleSecondary) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + avatarData = avatarData, + avatarType = AvatarType.User, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = user.getBestName(), + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + ) + + if (user.displayName.isNullOrEmpty().not()) { + Text( + text = user.userId.value, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun VerificationUserProfileContentPreview() = ElementPreview( + drawableFallbackForImages = CommonDrawables.sample_avatar +) { + VerificationUserProfileContent( + user = MatrixUser( + userId = UserId("@alice:example.com"), + displayName = "Alice", + avatarUrl = "https://example.com/avatar.png", + ) + ) +} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/util/StateMachineUtil.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/util/StateMachineUtil.kt new file mode 100644 index 0000000..3739af6 --- /dev/null +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/util/StateMachineUtil.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.util + +import com.freeletics.flowredux.dsl.InStateBuilderBlock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import timber.log.Timber +import com.freeletics.flowredux.dsl.State as MachineState + +internal fun T.andLogStateChange() = also { + Timber.w("Verification: state machine state moved to [${this::class.simpleName}]") +} + +@OptIn(ExperimentalCoroutinesApi::class) +inline fun InStateBuilderBlock.logReceivedEvents() { + on { event: Event, state: MachineState -> + Timber.w("Verification in state [${state.snapshot::class.simpleName}] receiving event [${event::class.simpleName}]") + state.noChange() + } +} diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_00.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_00.xml new file mode 100644 index 0000000..8346a5e --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_00.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_01.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_01.xml new file mode 100644 index 0000000..b34cf63 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_01.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_02.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_02.xml new file mode 100644 index 0000000..b97a508 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_02.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_03.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_03.xml new file mode 100644 index 0000000..bedf0f6 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_03.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_04.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_04.xml new file mode 100644 index 0000000..19cef5d --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_04.xml @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_05.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_05.xml new file mode 100644 index 0000000..c31bd06 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_05.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_06.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_06.xml new file mode 100644 index 0000000..d0a2de4 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_06.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_07.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_07.xml new file mode 100644 index 0000000..c8ff75c --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_07.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_08.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_08.xml new file mode 100644 index 0000000..ab1e718 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_08.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_09.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_09.xml new file mode 100644 index 0000000..cb7ad56 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_09.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_10.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_10.xml new file mode 100644 index 0000000..fb2e057 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_10.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_11.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_11.xml new file mode 100644 index 0000000..1cedc1b --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_11.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_12.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_12.xml new file mode 100644 index 0000000..30907f2 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_12.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_13.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_13.xml new file mode 100644 index 0000000..054760f --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_13.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_14.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_14.xml new file mode 100644 index 0000000..d4b557a --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_14.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_15.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_15.xml new file mode 100644 index 0000000..8a91221 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_15.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_16.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_16.xml new file mode 100644 index 0000000..c5acc19 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_16.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_17.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_17.xml new file mode 100644 index 0000000..ce8aff0 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_17.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_18.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_18.xml new file mode 100644 index 0000000..72f7036 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_18.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_19.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_19.xml new file mode 100644 index 0000000..2a07829 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_19.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_20.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_20.xml new file mode 100644 index 0000000..3f5abe6 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_20.xml @@ -0,0 +1,12 @@ + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_21.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_21.xml new file mode 100644 index 0000000..d390bd6 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_21.xml @@ -0,0 +1,12 @@ + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_22.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_22.xml new file mode 100644 index 0000000..ebf4203 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_22.xml @@ -0,0 +1,12 @@ + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_23.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_23.xml new file mode 100644 index 0000000..cdd3cb1 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_23.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_24.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_24.xml new file mode 100644 index 0000000..54e0f9a --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_24.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_25.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_25.xml new file mode 100644 index 0000000..0eeb290 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_25.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_26.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_26.xml new file mode 100644 index 0000000..d863d03 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_26.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_27.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_27.xml new file mode 100644 index 0000000..a514aeb --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_27.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_28.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_28.xml new file mode 100644 index 0000000..9ebb3c0 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_28.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_29.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_29.xml new file mode 100644 index 0000000..d37bcc3 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_29.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_30.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_30.xml new file mode 100644 index 0000000..28c2394 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_30.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_31.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_31.xml new file mode 100644 index 0000000..a53cfe9 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_31.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_32.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_32.xml new file mode 100644 index 0000000..15f980b --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_32.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_33.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_33.xml new file mode 100644 index 0000000..8913d1f --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_33.xml @@ -0,0 +1,12 @@ + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_34.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_34.xml new file mode 100644 index 0000000..ba3c431 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_34.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_35.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_35.xml new file mode 100644 index 0000000..4f7bc1a --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_35.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_36.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_36.xml new file mode 100644 index 0000000..9761204 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_36.xml @@ -0,0 +1,12 @@ + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_37.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_37.xml new file mode 100644 index 0000000..ac1267c --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_37.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_38.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_38.xml new file mode 100644 index 0000000..8bb37a3 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_38.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_39.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_39.xml new file mode 100644 index 0000000..48d7150 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_39.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_40.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_40.xml new file mode 100644 index 0000000..d18c6e8 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_40.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_41.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_41.xml new file mode 100644 index 0000000..18f3149 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_41.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_42.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_42.xml new file mode 100644 index 0000000..8e3ecc0 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_42.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_43.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_43.xml new file mode 100644 index 0000000..3b9f51f --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_43.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_44.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_44.xml new file mode 100644 index 0000000..e8f8985 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_44.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_45.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_45.xml new file mode 100644 index 0000000..98e68c2 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_45.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_46.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_46.xml new file mode 100644 index 0000000..de39794 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_46.xml @@ -0,0 +1,12 @@ + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_47.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_47.xml new file mode 100644 index 0000000..4cd1d03 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_47.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_48.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_48.xml new file mode 100644 index 0000000..7b70654 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_48.xml @@ -0,0 +1,12 @@ + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_49.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_49.xml new file mode 100644 index 0000000..7beda09 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_49.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_50.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_50.xml new file mode 100644 index 0000000..250388d --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_50.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_51.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_51.xml new file mode 100644 index 0000000..e317ce1 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_51.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_52.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_52.xml new file mode 100644 index 0000000..1427e79 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_52.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_53.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_53.xml new file mode 100644 index 0000000..72026cd --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_53.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_54.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_54.xml new file mode 100644 index 0000000..4097ed9 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_54.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_55.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_55.xml new file mode 100644 index 0000000..631da73 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_55.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_56.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_56.xml new file mode 100644 index 0000000..b12c6d2 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_56.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_57.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_57.xml new file mode 100644 index 0000000..2622fbe --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_57.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_58.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_58.xml new file mode 100644 index 0000000..84f95a8 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_58.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_59.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_59.xml new file mode 100644 index 0000000..2f29828 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_59.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_60.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_60.xml new file mode 100644 index 0000000..b89d033 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_60.xml @@ -0,0 +1,12 @@ + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_61.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_61.xml new file mode 100644 index 0000000..cbc43e7 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_61.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_62.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_62.xml new file mode 100644 index 0000000..9320766 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_62.xml @@ -0,0 +1,12 @@ + + + + diff --git a/features/verifysession/impl/src/main/res/drawable/ic_verification_63.xml b/features/verifysession/impl/src/main/res/drawable/ic_verification_63.xml new file mode 100644 index 0000000..f10e460 --- /dev/null +++ b/features/verifysession/impl/src/main/res/drawable/ic_verification_63.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/features/verifysession/impl/src/main/res/values-ar/strings_sas.xml b/features/verifysession/impl/src/main/res/values-ar/strings_sas.xml new file mode 100644 index 0000000..7bab8ee --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-ar/strings_sas.xml @@ -0,0 +1,68 @@ + + + + كلب + هِرَّة + أَسَد + حِصَان + حصان وحيد القرن + خِنزِير + فِيل + أَرنَب + باندَا + دِيك + بطريق + سُلحفاة + سَمَكة + أُخطُبُوط + فَرَاشَة + زَهرَة + شَجَرَة + صبار + فُطر + كُرَةٌ أرضِيَّة + قَمَر + سَحابَة + نار + مَوزَة + تُفَّاحَة + فَراوِلَة + ذُرَة + بِيتزا + كَعكَة + قَلب + اِبتِسَامَة + رُوبُوت + قُبَّعَة + نَظَّارَة + مِفتَاحُ رَبط + سانتا + رَفعُ إِبهَام + مِظَلَّة + سَاعَةٌ رَملِيَّة + سَاعَة + هَدِيَّة + مِصبَاح + كِتَاب + قَلَمُ رَصاص + مِشبَكُ وَرَق + مِقَصّ + قُفل + مِفتَاح + مِطرَقَة + تِلِفُون + عَلَم + قِطَار + دَرّاجَة + طَائِرة + صَارُوخ + كَأسُ النَّصر + كُرَة + غيتار + بُوق + جَرَس + مِرسَاة + سَمّاعَة رَأس + مُجَلَّد + دَبُّوس + diff --git a/features/verifysession/impl/src/main/res/values-be/translations.xml b/features/verifysession/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..a11cb7d --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,35 @@ + + + "Не можаце пацвердзіць?" + "Стварыць новы ключ аднаўлення" + "Пацвердзіце гэтую прыладу, каб наладзіць бяспечны абмен паведамленнямі." + "Пацвердзіце, што гэта вы" + "Выкарыстоўвайце іншую прыладу" + "Выкарыстоўваць ключ аднаўлення" + "Цяпер вы можаце бяспечна чытаць і адпраўляць паведамленні, і ўсе, з кім вы маеце зносіны ў чаце, таксама могуць давяраць гэтай прыладзе." + "Прылада праверана" + "Выкарыстоўвайце іншую прыладу" + "Чаканне на іншай прыладзе…" + "Здаецца, нешта не так. Альбо час чакання запыту скончыўся, альбо запыт быў адхілены." + "Пераканайцеся, што прыведзеныя ніжэй эмодзі супадаюць з эмодзі, паказанымі ў вашым іншым сеансе." + "Параўнайце эмодзі" + "Пераканайцеся, што прыведзеныя ніжэй лічбы супадаюць з лічбамі, паказанымі ў іншым сеансе." + "Параўнайце лічбы" + "Ваш новы сеанс пацверджаны. Ён мае доступ да вашых зашыфраваных паведамленняў, і іншыя карыстальнікі будуць лічыць яго давераным." + "Прылада праверана" + "Увядзіце ключ аднаўлення" + "Дакажыце, што гэта вы, каб атрымаць доступ да вашай зашыфраванай гісторыі паведамленняў." + "Адкрыйце існуючы сеанс" + "Паўтарыце праверку" + "Я гатовы" + "Чаканне супадзення" + "Параўнайце ўнікальны набор эмодзі." + "Параўнайце ўнікальныя эмодзі, пераканаўшыся, што яны размешчаны ў тым жа парадку." + "Ваш новы сеанс пацверджаны. Ён мае доступ да вашых зашыфраваных паведамленняў, і іншыя карыстальнікі будуць лічыць яго давераным." + "Прылада праверана" + "Яны не супадаюць" + "Яны супадаюць" + "Для працягу працы прыміце запыт на запуск працэсу праверкі ў іншым сеансе." + "Чаканне прыняцця запыту" + "Выхад…" + diff --git a/features/verifysession/impl/src/main/res/values-bg/strings_sas.xml b/features/verifysession/impl/src/main/res/values-bg/strings_sas.xml new file mode 100644 index 0000000..cae2628 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-bg/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Куче + Котка + Лъв + Кон + Еднорог + Прасе + Слон + Заек + Панда + Петел + Пингвин + Костенурка + Риба + Октопод + Пеперуда + Цвете + Дърво + Кактус + Гъба + Глобус + Луна + Облак + Огън + Банан + Ябълка + Ягода + Царевица + Пица + Торта + Сърце + Усмивка + Робот + Шапка + Очила + Гаечен ключ + Дядо Коледа + Палец нагоре + Чадър + Пясъчен часовник + Часовник + Подарък + Лампа + Книга + Молив + Кламер + Ножици + Катинар + Ключ + Чук + Телефон + Флаг + Влак + Колело + Самолет + Ракета + Трофей + Топка + Китара + Тромпет + Звънец + Котва + Слушалки + Папка + Кабърче + diff --git a/features/verifysession/impl/src/main/res/values-bg/translations.xml b/features/verifysession/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..357600c --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,42 @@ + + + "Не можете да потвърдите?" + "Потвърдете това устройство, за да настроите защитени съобщения." + "Потвърдете самоличността си" + "Използване на друго устройство" + "Използване на ключ за възстановяване" + "Устройството е потвърдено" + "Използване на друго устройство" + "Нещо не изглежда наред. Или времето за изчакване на заявката е изтекло, или заявката е отхвърлена." + "Потвърдете, че емоджитата по-долу съвпадат с показаните в другото ви устройство." + "Сравнете емоджита" + "Уверете се, че емоджитата по-долу съвпадат с показаните на другото устройство." + "Уверете се, че числата по-долу съвпадат с показаните на другата ви сесия." + "Сравнете числа" + "Сега можете да четете или изпращате съобщения сигурно на другото си устройство." + "Устройството е потвърдено" + "Въвеждане на ключ за възстановяване" + "Докажете, че сте вие, за да получите достъп до хронологията на шифрованите си съобщения." + "Отворете съществуваща сесия" + "Повторен опит за потвърждаване" + "Готов съм" + "В очакване на съвпадение" + "Сравнете уникален набор от емоджита." + "Сравнете уникалните емоджита, като се уверите, че се появяват в същия ред." + "Влезли" + "Неуспешно потвърждаване" + "Продължете само ако вие сте инициирали това потвърждаване." + "Сега можете да четете или изпращате съобщения сигурно на другото си устройство." + "Устройството е потвърдено" + "Поискано е потвърждение" + "Те не съвпадат" + "Те съвпадат" + "Уверете се, че приложението е отворено на другото устройство, преди да започнете потвърждението оттук." + "Отворете приложението на друго потвърдено устройство" + "Потвърждаване на този потребител?" + "Чака се другият потребител" + "След като бъдете приети, ще можете да продължите потвърждението." + "Приемете заявката, за да започнете процеса на потвърждаване в другата си сесия, за да продължите." + "В очакване на приемане на заявка" + "Излизане…" + diff --git a/features/verifysession/impl/src/main/res/values-ca/strings_sas.xml b/features/verifysession/impl/src/main/res/values-ca/strings_sas.xml new file mode 100644 index 0000000..0720148 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-ca/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Gos + Gat + Lleó + Cavall + Unicorn + Porc + Elefant + Conill + Panda + Gall + Pingüí + Tortuga + Peix + Pop + Papallona + Flor + Arbre + Cactus + Bolet + Globus terraqüi + Lluna + Núvol + Foc + Plàtan + Poma + Maduixa + Blat de moro + Pizza + Pastís + Cor + Somrient + Robot + Barret + Ulleres + Clau anglesa + Pare Noél + Polzes amunt + Paraigües + Rellotge de sorra + Rellotge + Regal + Bombeta + Llibre + Llapis + Clip + Tisores + Cadenat + Clau + Martell + Telèfon + Bandera + Tren + Bicicleta + Avió + Coet + Trofeu + Pilota + Guitarra + Trompeta + Campana + Àncora + Auriculars + Carpeta + Xinxeta + diff --git a/features/verifysession/impl/src/main/res/values-cs/strings_sas.xml b/features/verifysession/impl/src/main/res/values-cs/strings_sas.xml new file mode 100644 index 0000000..6f0eaa0 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-cs/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Pes + Kočka + Lev + Kůň + Jednorožec + Prase + Slon + Králík + Panda + Kohout + Tučňák + Želva + Ryba + Chobotnice + Motýl + Květina + Strom + Kaktus + Houba + Zeměkoule + Měsíc + Mrak + Oheň + Banán + Jablko + Jahoda + Kukuřice + Pizza + Dort + Srdce + Smajlík + Robot + Klobouk + Brýle + Klíč + Mikuláš + Palec nahoru + Deštník + Přesýpací hodiny + Hodiny + Dárek + Žárovka + Kniha + Tužka + Sponka + Nůžky + Zámek + Klíč ke dveřím + Kladivo + Telefon + Vlajka + Vlak + Kolo + Letadlo + Raketa + Pohár + Míč + Kytara + Trumpeta + Zvonek + Kotva + Sluchátka + Složka + Špendlík + 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 0000000..5d0b28b --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,54 @@ + + + "Nemůžete potvrdit?" + "Vytvoření nového klíče pro obnovení" + "Ověřte toto zařízení a nastavte zabezpečené zasílání zpráv." + "Potvrďte, že jste to vy" + "Použít jiné zařízení" + "Použít klíč pro obnovení" + "Nyní můžete bezpečně číst nebo odesílat zprávy, a kdokoli, s kým chatujete, může tomuto zařízení důvěřovat." + "Zařízení ověřeno" + "Použít jiné zařízení" + "Čekání na jiném zařízení…" + "Něco není v pořádku. Buď vypršel časový limit požadavku, nebo byl požadavek zamítnut." + "Zkontrolujte, zda se níže uvedené emotikony shodují s emotikony zobrazenými na vašem druhém zařízení." + "Porovnání emotikonů" + "Zkontrolujte, zda se níže uvedené emotikony shodují s emotikony zobrazenými v zařízení druhého uživatele." + "Potvrďte, že níže uvedená čísla odpovídají číslům zobrazeným na vaší druhé relaci." + "Porovnejte čísla" + "Nyní můžete bezpečně číst nebo odesílat zprávy na svém druhém zařízení." + "Nyní můžete důvěřovat identitě tohoto uživatele při odesílání nebo přijímání zpráv." + "Zařízení ověřeno" + "Zadejte klíč pro obnovení" + "Buď vypršel časový limit požadavku, požadavek byl zamítnut, nebo došlo k nesouladu ověření." + "Pro přístup k historii zašifrovaných zpráv prokažte, že jste to vy." + "Otevřete existující relaci" + "Opakovat ověření" + "Jsem připraven" + "Čekání na shodu" + "Porovnejte jedinečnou sadu emotikonů." + "Porovnejte jedinečné emotikony a ujistěte se, že jsou zobrazeny ve stejném pořadí." + "Přihlášen" + "Buď vypršel časový limit požadavku, požadavek byl zamítnut, nebo došlo k nesouladu ověření." + "Ověření se nezdařilo" + "Pokračujte, pouze pokud jste toto ověření zahájili." + "Ověřte druhé zařízení, aby byla vaše historie zpráv zabezpečená." + "Nyní můžete bezpečně číst nebo odesílat zprávy na svém druhém zařízení." + "Zařízení ověřeno" + "Požadováno ověření" + "Neshodují se" + "Shodují se" + "Před zahájením ověřování se ujistěte, že máte aplikaci otevřenou na druhém zařízení." + "Otevřete aplikaci na jiném ověřeném zařízení" + "Pro větší bezpečnost ověřte tohoto uživatele porovnáním sady emotikonů na svých zařízeních. Proveďte to pomocí důvěryhodného způsobu komunikace." + "Ověřte tohoto uživatele?" + "Pro větší bezpečnost chce jiný uživatel ověřit vaši identitu. Zobrazí se vám sada emotikonů k porovnání." + "Na druhém zařízení byste měli vidět vyskakovací okno. Začněte s ověrením tam." + "Spusťte ověření na druhém zařízení" + "Spusťte ověření na druhém zařízení" + "Čekání na druhého uživatele" + "Po přijetí budete moci pokračovat v ověřování." + "Pro pokračování přijměte požadavek na zahájení ověření v jiné relaci." + "Čekání na přijetí žádosti" + "Odhlašování…" + diff --git a/features/verifysession/impl/src/main/res/values-cy/translations.xml b/features/verifysession/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..2b26322 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,54 @@ + + + "Methu cadarnhau?" + "Crëwch allwedd adfer newydd" + "Dilyswch y ddyfais hon er mwyn gosod negeseuon diogel." + "Cadarnhewch eich hunaniaeth" + "Defnyddiwch ddyfais arall" + "Defnyddiwch allwedd adfer" + "Nawr gallwch chi ddarllen neu anfon negeseuon yn ddiogel, a gall unrhyw un rydych chi\'n sgwrsio â nhw ymddiried yn y ddyfais hon hefyd." + "Dyfais wedi\'i dilysu" + "Defnyddiwch ddyfais arall" + "Yn aros ar ddyfais arall…" + "Mae rhywbeth i weld o\'i le. Naill ai daeth y cais i ben neu cafodd y cais ei wrthod." + "Cadarnhewch fod yr emojis isod yn cyd-fynd â\'r rhai sy\'n cael eu dangos ar eich dyfais arall." + "Cymharwch emojis" + "Cadarnhewch fod yr emojis isod yn cyd-fynd â\'r rhai sy\'n cael eu dangos ar ddyfais y defnyddiwr arall." + "Cadarnhewch fod y rhifau isod yn cyfateb i\'r rhai sy\'n cael eu dangos ar eich sesiwn arall." + "Cymharwch rifau" + "Nawr gallwch chi ddarllen neu anfon negeseuon yn ddiogel ar eich dyfais arall." + "Nawr gallwch ymddiried yn hunaniaeth y defnyddiwr hwn wrth anfon neu dderbyn negeseuon." + "Dyfais wedi\'i dilysu" + "Rhowch eich allwedd adfer" + "Naill ai daeth y cais i ben, gwrthodwyd y cais, neu roedd diffyg cyfatebiaeth dilysu." + "Profwch mai chi sydd yno i gael mynediad at eich hanes negeseuon wedi\'u hamgryptio." + "Agor sesiwn sy\'n bodoli eisoes" + "Rhowch gynnig arall ar ddilysu" + "Rwy\'n barod" + "Aros i gyfateb…" + "Cymharwch set unigryw o emojis." + "Cymharwch yr emoji unigryw, gan sicrhau eu bod yn ymddangos yn yr un drefn." + "Mewngofnodwyd" + "Naill ai daeth y cais i ben, gwrthodwyd y cais, neu roedd diffyg cyfatebiaeth dilysu." + "Methodd y gwirio" + "Parhewch dim ond os taw chi gychwynodd y dilysiad hwn." + "Dilyswch y ddyfais arall i gadw hanes eich neges yn ddiogel." + "Nawr gallwch chi ddarllen neu anfon negeseuon yn ddiogel ar eich dyfais arall." + "Dyfais wedi\'i dilysu" + "Gofynnwyd am ddilysiad" + "Dy\'n nhw ddim yn cyfateb" + "Maen nhw\'n cyfateb" + "Gwnewch yn siŵr fod gennych yr ap ar agor yn y ddyfais arall cyn dechrau dilysu o\'r fan hon." + "Agorwch yr ap ar ddyfais arall sydd wedi\'i gwirio" + "Er mwyn sicrhau diogelwch ychwanegol, gwiriwch y defnyddiwr hwn trwy gymharu set o emojis ar eich dyfeisiau. Gwnewch hyn trwy ddefnyddio ffordd ddibynadwy o gyfathrebu." + "Dilysu\'r defnyddiwr hwn?" + "Er mwyn diogelwch ychwanegol, mae defnyddiwr arall eisiau gwirio pwy ydych chi. Bydd set o emojis yn cael eu dangos i chi eu cymharu." + "Dylech weld llamlen ar y ddyfais arall. Dechreuwch y dilysiad o\'r fan honno nawr." + "Cychwyn dilysu ar y ddyfais arall" + "Cychwyn dilysu ar y ddyfais arall" + "Yn aros am y defnyddiwr arall" + "Unwaith y byddwch wedi\'ch derbyn, byddwch yn gallu parhau â\'r dilysu." + "Derbyniwch y cais i gychwyn y broses ddilysu yn eich sesiwn arall i barhau." + "Yn aros i dderbyn cais" + "Yn allgofnodi…" + diff --git a/features/verifysession/impl/src/main/res/values-da/translations.xml b/features/verifysession/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..32cdf15 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,54 @@ + + + "Kan ikke bekræfte?" + "Opret en ny gendannelsesnøgle" + "Verificér denne enhed for at konfigurere sikre meddelelser." + "Bekræft din identitet" + "Brug en anden enhed" + "Brug gendannelsesnøgle" + "Nu kan du læse eller sende beskeder sikkert, og enhver du samtaler med kan også stole på denne enhed." + "Enhed verificeret" + "Brug en anden enhed" + "Venter på en anden enhed…" + "Et ellervandet virker ikke rigtigt. Enten udløb anmodningen, eller anmodningen blev afvist." + "Bekræft, at emojierne nedenfor matcher dem, der vises på din anden enhed." + "Sammenlign emojier" + "Bekræft, at emojierne nedenfor matcher dem, der vises på den anden brugers enhed." + "Bekræft, at numrene nedenfor stemmer overens med dem, der vises på din anden session." + "Sammenlign tal" + "Nu kan du læse eller sende beskeder sikkert med din anden enhed." + "Nu kan du stole på identiteten af denne bruger, når I sender og modtager beskeder fra hinanden." + "Enhed verificeret" + "Indtast gendannelsesnøgle" + "Enten udløb anmodningen, den blev afvist, eller der var en fejl i verifikationen." + "Bevis, at det er dig, for at få adgang til din krypterede beskedhistorik." + "Åbn en eksisterende session" + "Prøv bekræftelsen igen" + "Jeg er klar" + "Venter på at matche…" + "Sammenlign et unikt sæt af emojis." + "Sammenlign de unikke emoji, og vær opmærksom på, at de vises i den samme rækkefølge." + "Logget ind" + "Enten udløb anmodningen, den blev afvist, eller der var en fejl i verifikationen." + "Verifikation mislykkedes" + "Fortsæt kun, hvis du selv har startet denne verifikation." + "Verificér den anden enhed for at holde din meddelelseshistorik sikker." + "Nu kan du læse eller sende beskeder sikkert med din anden enhed." + "Enhed verificeret" + "Anmodet om verifikation" + "De matcher ikke" + "De matcher" + "Sørg for, at du har appen åben på den anden enhed, før du starter verifikationen herfra." + "Åbn appen på en anden bekræftet enhed" + "For ekstra sikkerhed, verificér denne bruger ved at sammenligne et sæt emojier på jeres enheder. Gør dette ved at bruge en kommunikationsmetode i stoler på." + "Verificér denne bruger?" + "For ekstra sikkerhed ønsker en anden bruger at bekræfte din identitet. Du får vist et sæt emojier til sammenligning." + "Du burde se en popup på den anden enhed. Start verifikationen derfra nu." + "Start verifikation på den anden enhed" + "Start verifikation på den anden enhed" + "Venter på den anden bruger" + "Når du er blevet accepteret, kan du fortsætte med verifikationen." + "Accepter anmodningen om at starte bekræftelsesprocessen i din anden session for at fortsætte." + "Venter på at acceptere anmodningen" + "Logger ud…" + diff --git a/features/verifysession/impl/src/main/res/values-de/strings_sas.xml b/features/verifysession/impl/src/main/res/values-de/strings_sas.xml new file mode 100644 index 0000000..fc81895 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-de/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Hund + Katze + Löwe + Pferd + Einhorn + Schwein + Elefant + Hase + Panda + Hahn + Pinguin + Schildkröte + Fisch + Oktopus + Schmetterling + Blume + Baum + Kaktus + Pilz + Globus + Mond + Wolke + Feuer + Banane + Apfel + Erdbeere + Mais + Pizza + Kuchen + Herz + Lächeln + Roboter + Hut + Brille + Schraubenschlüssel + Weihnachtsmann + Daumen Hoch + Regenschirm + Sanduhr + Uhr + Geschenk + Glühbirne + Buch + Bleistift + Büroklammer + Schere + Schloss + Schlüssel + Hammer + Telefon + Flagge + Zug + Fahrrad + Flugzeug + Rakete + Pokal + Ball + Gitarre + Trompete + Glocke + Anker + Kopfhörer + Ordner + Stecknadel + 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 0000000..377da35 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,54 @@ + + + "Bestätigung unmöglich?" + "Erstelle einen neuen Wiederherstellungsschlüssel" + "Verifiziere dieses Gerät, um sichere Chats einzurichten." + "Bestätige deine Identität" + "Ein anderes Gerät verwenden" + "Wiederherstellungsschlüssel verwenden" + "Du kannst jetzt verschlüsselte Nachrichten lesen und versenden. Dein Chatpartner vertraut nun diesem Gerät." + "Gerät verifiziert" + "Ein anderes Gerät verwenden" + "Bitte warten bis das andere Gerät bereit ist." + "Etwas scheint nicht zu stimmen. Entweder ist das Zeitlimit für die Anfrage abgelaufen oder die Anfrage wurde abgelehnt." + "Bestätige, dass die folgenden Emojis mit denen auf deinem anderen Gerät übereinstimmen." + "Emojis vergleichen" + "Bestätige, dass die folgenden Emojis mit denen auf dem Gerät des anderen Nutzers übereinstimmen." + "Bestätige, dass die folgenden Zahlen mit denen in deiner anderen Sitzung übereinstimmen." + "Vergleiche die Zahlen" + "Jetzt kannst du verschlüsselte Nachrichten sicher auf deinem anderen Gerät schreiben und lesen." + "Jetzt kannst du der Identität dieses Nutzers vertrauen, wenn du Nachrichten sendest oder empfängst." + "Gerät verifiziert" + "Wiederherstellungsschlüssel eingeben" + "Entweder ist die Anfrage abgelaufen, oder die Anfrage wurde abgelehnt, oder es gab eine Unstimmigkeit bei der Überprüfung." + "Beweise deine Identität, um auf deinen verschlüsselten Nachrichtenverlauf zuzugreifen." + "Öffne eine bestehende Sitzung" + "Verifizierung wiederholen" + "Ich bin bereit" + "Warten auf eine Übereinstimmung" + "Vergleiche eine spezielle Reihe von Emojis." + "Vergleiche die einzelnen Emojis und stelle sicher, dass sie in der gleichen Reihenfolge erscheinen." + "Angemeldet" + "Entweder ist die Anfrage abgelaufen, oder die Anfrage wurde abgelehnt, oder es gab eine Unstimmigkeit bei der Überprüfung." + "Verifizierung fehlgeschlagen" + "Fahre nur fort, falls du diese Verifizierung selbst gestartet hast." + "Verifiziere das andere Gerät, um deinen Nachrichtenverlauf sicher zu halten." + "Jetzt kannst du verschlüsselte Nachrichten sicher auf deinem anderen Gerät schreiben und lesen." + "Gerät verifiziert" + "Verifizierung angefordert" + "Sie stimmen nicht überein" + "Sie stimmen überein" + "Öffne die App auf dem anderen Gerät, bevor du die Verifizierung auf diesem Gerät startest." + "Öffne die App auf einem anderen verifizierten Gerät" + "Verifiziere diesen Nutzer für zusätzliche Sicherheit durch den Vergleich einer Reihe von Emojis auf den Geräten. Verwende dazu einen vertraulichen Kommunikationskanal." + "Diesen Nutzer verifizieren?" + "Für zusätzliche Sicherheit möchte ein anderer Nutzer deine Identität verifizieren. Es werden dir einige Emojis zum Vergleich angezeigt." + "Du solltest ein Popup-Fenster auf dem anderen Gerät sehen. Starte die Verifizierung von dort aus." + "Starte die Verifizierung auf dem anderen Gerät" + "Starte die Verifizierung auf dem anderen Gerät" + "Warten auf den anderen Nutzer" + "Nach der Bestätigung kannst du mit der Verifizierung fortfahren." + "Akzeptiere die Anfrage für die Verifizierung in deiner anderen Sitzung um fortzufahren." + "Warten auf die Annahme der Anfrage" + "Abmelden…" + diff --git a/features/verifysession/impl/src/main/res/values-el/translations.xml b/features/verifysession/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..1cbb7d3 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,54 @@ + + + "Δεν μπορείς να επιβεβαιώσεις;" + "Δημιουργία νέου κλειδιού ανάκτησης" + "Επαλήθευσε αυτήν τη συσκευή για να ρυθμίσεις την ασφαλή επικοινωνία." + "Επιβεβαίωσε ότι είσαι εσύ" + "Χρήση άλλης συσκευής" + "Χρήση κλειδιού ανάκτησης" + "Τώρα μπορείς να διαβάζεις ή να στέλνεις μηνύματα με ασφάλεια και επίσης μπορεί να εμπιστευτεί αυτήν τη συσκευή οποιοσδήποτε με τον οποίο συνομιλείς." + "Επαληθευμένη συσκευή" + "Χρήση άλλης συσκευής" + "Αναμονή σε άλλη συσκευή…" + "Κάτι δεν πάει καλά. Είτε το αίτημα έληξε είτε απορρίφθηκε." + "Επιβεβαίωσε ότι τα παρακάτω emoji ταιριάζουν με αυτά που εμφανίζονται στην άλλη συνεδρία σου." + "Σύγκριση emoji" + "Επιβεβαιώστε ότι τα παρακάτω emoji ταιριάζουν με αυτά που εμφανίζονται στη συσκευή του άλλου χρήστη." + "Επιβεβαίωσε ότι οι παρακάτω αριθμοί ταιριάζουν με αυτούς που εμφανίζονται στην άλλη συνεδρία σου." + "Σύγκριση αριθμών" + "Η νέα σου συνεδρία έχει πλέον επαληθευτεί. Έχει πρόσβαση στα κρυπτογραφημένα μηνύματά σας και άλλοι χρήστες θα το βλέπουν ως αξιόπιστο." + "Τώρα μπορείτε να εμπιστευτείτε την ταυτότητα αυτού του χρήστη κατά την αποστολή ή τη λήψη μηνυμάτων." + "Επαληθευμένη συσκευή" + "Εισαγωγή κλειδιού ανάκτησης" + "Είτε το αίτημα έληξε είτε απορρίφθηκε είτε υπήρξε αναντιστοιχία επαλήθευσης." + "Απέδειξε ότι είσαι εσύ για να αποκτήσεις πρόσβαση στο κρυπτογραφημένο ιστορικό μηνυμάτων σου." + "Άνοιξε μια υπάρχουσα συνεδρία" + "Επανάληψη επαλήθευσης" + "Είμαι έτοιμος/η" + "Αναμονή για αντιστοίχιση" + "Σύγκρινε ένα μοναδικό σύνολο emojis." + "Σύγκρινε τα μοναδικά emoji και σιγουρέψου ότι εμφανίζονται με την ίδια σειρά." + "Έχεις συνδεθεί" + "Είτε το αίτημα έληξε είτε απορρίφθηκε είτε υπήρξε αναντιστοιχία επαλήθευσης." + "Αποτυχία επαλήθευσης" + "Συνέχισε μόνο εάν ξεκίνησες εσύ αυτήν την επαλήθευση." + "Επαλήθευσε την άλλη συσκευή για να διατηρήσεις το ιστορικό μηνυμάτων σου ασφαλές." + "Η νέα σου συνεδρία έχει πλέον επαληθευτεί. Έχει πρόσβαση στα κρυπτογραφημένα μηνύματά σας και άλλοι χρήστες θα το βλέπουν ως αξιόπιστο." + "Επαληθευμένη συσκευή" + "Ζητήθηκε επαλήθευση" + "Δεν ταιριάζουν" + "Ταιριάζουν" + "Βεβαιώσου ότι έχεις ανοιχτή την εφαρμογή στην άλλη συσκευή πριν ξεκινήσεις την επαλήθευση από εδώ." + "Άνοιξε την εφαρμογή σε άλλη επαληθευμένη συσκευή" + "Για επιπλέον ασφάλεια, επαληθεύστε αυτόν το χρήστη συγκρίνοντας ένα σύνολο emoji στις συσκευές σας. Κάντε το αυτό χρησιμοποιώντας έναν αξιόπιστο τρόπο επικοινωνίας." + "Επαλήθευση αυτού του χρήστη;" + "Για επιπλέον ασφάλεια, ένας άλλος χρήστης θέλει να επαληθεύσει την ταυτότητά σας. Θα σας εμφανιστεί μια σειρά από emojis για να τα συγκρίνετε." + "Πρόκειται να δεις ένα αναδυόμενο παράθυρο στην άλλη συσκευή. Ξεκίνα την επαλήθευση από εκεί τώρα." + "Έναρξη επαλήθευσης στην άλλη συσκευή" + "Έναρξη επαλήθευσης στην άλλη συσκευή" + "Αναμονή για τον άλλο χρήστη" + "Μόλις γίνει αποδεκτό, θα μπορείτε να συνεχίσετε με την επαλήθευση." + "Αποδέξου το αίτημα για να ξεκινήσεις τη διαδικασία επαλήθευσης στην άλλη συνεδρία σου για να συνεχίσεις." + "Αναμονή για αποδοχή αιτήματος" + "Αποσύνδεση…" + diff --git a/features/verifysession/impl/src/main/res/values-eo/strings_sas.xml b/features/verifysession/impl/src/main/res/values-eo/strings_sas.xml new file mode 100644 index 0000000..d9efa50 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-eo/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Hundo + Kato + Leono + Ĉevalo + Unukorno + Porko + Elefanto + Kuniklo + Pando + Virkoko + Pingveno + Testudo + Fiŝo + Polpo + Papilio + Floro + Arbo + Kakto + Fungo + Globo + Luno + Nubo + Fajro + Banano + Pomo + Frago + Maizo + Pico + Torto + Koro + Rideto + Roboto + Ĉapelo + Okulvitroj + Ŝraŭbŝlosilo + Kristnaska viro + Dikfingro supren + Ombrelo + Sablohorloĝo + Horloĝo + Donaco + Lampo + Libro + Krajono + Paperkuntenilo + Tondilo + Seruro + Ŝlosilo + Martelo + Telefono + Flago + Vagonaro + Biciklo + Aviadilo + Raketo + Trofeo + Pilko + Gitaro + Trumpeto + Sonorilo + Ankro + Kapaŭdilo + Dosierujo + Pinglo + diff --git a/features/verifysession/impl/src/main/res/values-es/strings_sas.xml b/features/verifysession/impl/src/main/res/values-es/strings_sas.xml new file mode 100644 index 0000000..cc78a46 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-es/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Perro + Gato + León + Caballo + Unicornio + Cerdo + Elefante + Conejo + Panda + Gallo + Pingüino + Tortuga + Pez + Pulpo + Mariposa + Flor + Árbol + Cactus + Seta + Globo + Luna + Nube + Fuego + Plátano + Manzana + Fresa + Maíz + Pizza + Tarta + Corazón + Emoticono + Robot + Sombrero + Gafas + Llave inglesa + Papá Noel + Pulgar arriba + Paraguas + Reloj de arena + Reloj + Regalo + Bombilla + Libro + Lápiz + Clip + Tijeras + Candado + Llave + Martillo + Teléfono + Bandera + Tren + Bicicleta + Avión + Cohete + Trofeo + Bola + Guitarra + Trompeta + Campana + Ancla + Cascos + Carpeta + Alfiler + 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 0000000..19d471c --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,54 @@ + + + "¿No puedes confirmar?" + "Crear una nueva clave de recuperación" + "Verifica este dispositivo para configurar la mensajería segura." + "Confirma que eres tú" + "Usar otro dispositivo" + "Usar clave de recuperación" + "Ahora puedes leer o enviar mensajes de forma segura y cualquier persona con la que chatees también puede confiar en este dispositivo." + "Dispositivo verificado" + "Usar otro dispositivo" + "Esperando en otro dispositivo…" + "Algo no fue bien. Se agotó el tiempo de espera de la solicitud o se rechazó." + "Confirma que los emojis que aparecen a continuación coinciden con los que aparecen en tu otra sesión." + "Comparar emojis" + "Confirma que los emojis que aparecen a continuación coinciden con los mostrados en el dispositivo del otro usuario." + "Confirma que los números que aparecen a continuación coinciden con los mostrados en tu otra sesión." + "Comparar números" + "Tu nueva sesión ya está verificada. Tienes acceso a tus mensajes cifrados y otros usuarios lo considerarán de confianza." + "Ahora puedes confiar en la identidad de este usuario al enviar o recibir mensajes." + "Dispositivo verificado" + "Introduce la clave de recuperación" + "O bien se agotó el tiempo de solicitud, se rechazó la solicitud o hubo una discrepancia en la verificación." + "Demuestra que eres tú para acceder a tu historial de mensajes cifrados." + "Abrir una sesión existente" + "Reintentar la verificación" + "Estoy listo" + "A la espera de que coincida…" + "Compara un conjunto único de emojis." + "Compara los emoji, asegurándote de que aparecen en el mismo orden." + "Sesión iniciada" + "O bien se agotó el tiempo de solicitud, se rechazó la solicitud o hubo una discrepancia en la verificación." + "Verificación fallida" + "Continúa solo si has iniciado esta verificación." + "Verifica el otro dispositivo para mantener seguro tu historial de mensajes." + "Tu nueva sesión ya está verificada. Tienes acceso a tus mensajes cifrados y otros usuarios lo considerarán de confianza." + "Dispositivo verificado" + "Verificación solicitada" + "No coinciden" + "Coinciden" + "Asegúrate de tener la aplicación abierta en el otro dispositivo antes de iniciar la verificación desde aquí." + "Abre la aplicación en otro dispositivo verificado" + "Para mayor seguridad, verifica a este usuario comparando un conjunto de emojis en vuestros dispositivos. Hazlo utilizando una forma de comunicación de confianza." + "¿Verificar a este usuario?" + "Para mayor seguridad, otro usuario quiere verificar tu identidad. Se te mostrará un conjunto de emojis para compararlos." + "Deberías ver una ventana emergente en el otro dispositivo. Inicia ahora la verificación desde allí." + "Iniciar la verificación en el otro dispositivo" + "Iniciar la verificación en el otro dispositivo" + "A la espera del otro usuario" + "Una vez aceptada, podrás continuar con la verificación." + "Acepta la solicitud para iniciar el proceso de verificación en tu otra sesión para continuar." + "A la espera de aceptar la solicitud" + "Cerrando sesión…" + diff --git a/features/verifysession/impl/src/main/res/values-et/strings_sas.xml b/features/verifysession/impl/src/main/res/values-et/strings_sas.xml new file mode 100644 index 0000000..1223b67 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-et/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Koer + Kass + Lõvi + Hobune + Ükssarvik + Siga + Elevant + Jänes + Panda + Kukk + Pingviin + Kilpkonn + Kala + Kaheksajalg + Liblikas + Lill + Puu + Kaktus + Seen + Maakera + Kuu + Pilv + Tuli + Banaan + Õun + Maasikas + Mais + Pitsa + Kook + Süda + Smaili + Robot + Kübar + Prillid + Mutrivõti + Jõuluvana + Pöidlad püsti + Vihmavari + Liivakell + Kell + Kingitus + Lambipirn + Raamat + Pliiats + Kirjaklamber + Käärid + Lukk + Võti + Haamer + Telefon + Lipp + Rong + Jalgratas + Lennuk + Rakett + Auhind + Pall + Kitarr + Trompet + Kelluke + Ankur + Kõrvaklapid + Kaust + Nööpnõel + diff --git a/features/verifysession/impl/src/main/res/values-et/translations.xml b/features/verifysession/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..2c43794 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,54 @@ + + + "Kas kinnitamine pole võimalik?" + "Loo uus taastevõti" + "Krüptitud sõnumivahetuse tagamiseks verifitseeri see seade." + "Kinnita, et see oled sina" + "Kasuta teist seadet" + "Kasuta taastevõtit" + "Nüüd saad saata või lugeda sõnumeid turvaliselt ning kõik sinu vestluspartnerid võivad usaldada seda seadet." + "Seade on verifitseeritud" + "Kasuta teist seadet" + "Ootame teise seadme järgi…" + "Olukord pole päris õige. Päring kas aegus või teine osapool keeldus päringule vastamast." + "Kinnita, et kõik järgnevalt kuvatud emojid on täpselt samad, mida sa näed oma teises sessioonis." + "Võrdle emojisid" + "Palun kinnita, et allpool näidatud emojid vastavad täpselt neile, mida kuvatakse teise kasutaja seadmes." + "Kinnita, et kõik järgnevalt kuvatud numbrid on täpselt samad, mida sa näed oma teises sessioonis." + "Võrdle numbreid" + "Võid nüüd sõnumeid oma teises seadmes turvaliselt saata ja vastu võtta." + "Nüüd sa võid sõnumite vastuvõtmisel ja saatmisel selle kasutaja identiteeti usaldada." + "Seade on verifitseeritud" + "Sisesta taastevõti" + "Kas verifitseerimine aegus, teine osapool keeldus vastamast või tekkis vastuste mittevastavus." + "Saamaks ligipääsu krüptitud sõnumite ajaloole tõesta et tegemist on sinuga." + "Ava olemasolev sessioon" + "Proovi verifitseerimist uuesti" + "Ma olen valmis alustama" + "Ootame kinnitust sobivusele" + "Võrdle unikaalset emojide kombinatsiooni" + "Võrdle unikaalset emojide kombinatsiooni ning kontrolli, et nad on täpselt samas järjekorras." + "Sisselogitud" + "Kas verifitseerimine aegus, teine osapool keeldus vastamast või tekkis vastuste mittevastavus." + "Verifitseerimine ei õnnestunud" + "Jätka vaid siis, kui sina algatasid verifitseerimise." + "Hoidmaks oma sõnumiajalugu turvatuna verifitseeri teine seade." + "Võid nüüd sõnumeid oma teises seadmes turvaliselt saata ja vastu võtta." + "Seade on verifitseeritud" + "Verifitseerimispäring" + "Nad ei klapi omavahel" + "Nad klapivad omavahel" + "Enne kui alustad siin verifitseerimist, palun ava rakendus teises seadmes." + "Ava rakendus teises verifitseeritud seadmes" + "Lisaturvalisuse nimel verifitseeri seee kasutaja, võrreldes oma seadmetes olevaid emojisid. Tee seda, kasutades usaldusväärset suhtlusviisi." + "Kas verifitseerime selle kasutaja?" + "Lisaturvalisuse nimel soovib teine kasutaja sinu identiteeti verifitseerida. Järgmiseks näed sa emojisid, mida peate omavahel võrdlema." + "Sa peaksid teises seadmes nägema hüpikakent. Palun alusta sealt verifitseerimist." + "Alusta verifitseerimist teises seadmes" + "Alusta verifitseerimist teises seadmes" + "Ootame teise kasutaja järgi" + "Kui oled nõustunud, siis saad sa verifitseerimist jätkata." + "Jätkamaks nõustu verifitseerimisprotsessi alustamisega oma teises sessioonis." + "Ootame nõustumist verifitseerimispäringuga" + "Logime välja…" + diff --git a/features/verifysession/impl/src/main/res/values-eu/translations.xml b/features/verifysession/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..c6c821a --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,48 @@ + + + "Ezin duzu baieztatu?" + "Sortu berreskuratze-gako berria" + "Egiaztatu gailua mezularitza segurua konfiguratzeko." + "Berretsi zure identitatea" + "Erabili beste gailu bat" + "Erabili berreskuratze-gakoa" + "Orain mezuak modu seguruan irakurri edo bidal ditzakezu, eta txateatzen duzun edonor ere fida daiteke gailu honetaz." + "Gailua egiaztatu da" + "Erabili beste gailu bat" + "Beste gailuaren zain…" + "Egiaztatu ondorengo emojiak bat datozela beste saioan erakusten direnekin." + "Alderatu emojiak" + "Egiaztatu beheko zenbakiak zure beste saioan erakutsitakoekin bat datozela." + "Konparatu zenbakiak" + "Saio berria egiaztatu da. Zifratutako mezu guztiak atzitu ditzake, eta gainerako erabiltzaileek fidagarritzat izango dute." + "Gailua egiaztatu da" + "Sartu berreskuratze-gakoa" + "Frogatu zeu zarela zifratutako mezuen historia atzitzeko." + "Ireki lehendik hasita dagoen saio bat" + "Saiatu berriro egiaztatzen" + "Prest nago" + "Bat etorriko zain…" + "Alderatu emojiak eta egiaztatu ordena berean ageri direla." + "Saioa hasita" + "Egiaztapenak huts egin du" + "Egiaztapen hau zeuk hasi baduzu bakarrik jarraitu." + "Egiaztatu beste gailua zure mezuen historia seguru mantentzeko." + "Saio berria egiaztatu da. Zifratutako mezu guztiak atzitu ditzake, eta gainerako erabiltzaileek fidagarritzat izango dute." + "Gailua egiaztatu da" + "Egiaztapena eskatu da" + "Ez datoz bat" + "Bat datoz" + "Ziurtatu aplikazioa irekita duzula beste gailuan hemendik egiaztatzea hasi aurretik." + "Ireki aplikazioa egiaztatutako beste gailu batean" + "Segurtasun handiagorako, egiaztatu erabiltzailea emoji multzo bat alderatuz. Egin hau komunikatzeko modu fidagarri bat erabiliz." + "Erabiltzailea egiaztatu?" + "Segurtasun handiagorako, beste erabiltzaile batek zure identitatea egiaztatu nahi du. Emoji sorta bat erakutsiko zaizu konparatzeko." + "Beste gailuan laster-menu bat ikusi beharko zenuke. Hasi egiaztapena hortik orain." + "Hasi egiaztapena beste gailuan" + "Hasi egiaztapena beste gailuan" + "Beste erabiltzailearen zain" + "Onartutakoan egiaztapenarekin jarraitu ahal izango duzu." + "Jarraitzeko, onartu zure beste saioan egiaztapen-prozesua hasteko eskaera." + "Eskaera onartzeko zain" + "Saioa amaitzen…" + diff --git a/features/verifysession/impl/src/main/res/values-fa/strings_sas.xml b/features/verifysession/impl/src/main/res/values-fa/strings_sas.xml new file mode 100644 index 0000000..42efb24 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-fa/strings_sas.xml @@ -0,0 +1,68 @@ + + + + سگ + گربه + شیر + اسب + تک شاخ + خوک + فیل + خرگوش + پاندا + خروس + پنگوئن + لاک‌پشت + ماهی + اختاپوس + پروانه + گل + درخت + کاکتوس + قارچ + زمین + ماه + ابر + آتش + موز + سیب + توت فرنگی + ذرت + پیتزا + کیک + قلب + خنده + ربات + کلاه + عینک + آچار + بابا نوئل + لایک + چتر + ساعت شنی + ساعت + هدیه + لامپ + کتاب + مداد + گیره کاغذ + قیچی + قفل + کلید + چکش + تلفن + پرچم + قطار + دوچرخه + هواپیما + موشک + جام + توپ + گیتار + شیپور + زنگ + لنگر + هدفون + پوشه + سنجاق + diff --git a/features/verifysession/impl/src/main/res/values-fa/translations.xml b/features/verifysession/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..ffc03e1 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,36 @@ + + + "نمی‌توانید تأیید کنید؟" + "ایجاد کلید بازیابی جدید" + "تأیید این افزاره برای برپایی پیام‌رسانی امن." + "تأیید هویتتان" + "استفاده از افزاره‌ای دیگر" + "استفاده از کلید بازیابی" + "اکنون می‌توانید پیام‌ها را به صورت امن فرستاده و بگیرید و هرکسی که با او گپ می‌زنید نیز می‌تواند به این افزاره اعتماد کند." + "افزاره تأیید شده" + "استفاده از افزاره‌ای دیگر" + "منتظر افزارهٔ دیگر…" + "يه چيزي درست به نظر نمياد یا زمان درخواست به پایان رسید یا درخواست رد شد." + "تأیید تطابق شکلک‌های زیر با شکلک‌های نشان داده شده روی افزارهٔ دیگرتان." + "مقایسهٔ شکلک‌ها" + "مقایسهٔ اعداد" + "اکنون می‌توانید روی افزارهٔ دیگرتان با امنیت پیام فرستاده و بخوانید." + "افزاره تأیید شده" + "ورود کلید بازیابی" + "برای دسترسی به تاریخچه پیام‌های رمزگذاری‌شده‌تان، ثابت کنید که خودتان هستید." + "گشودن نشستی موجود" + "تلاش برای تأیید دوباره" + "آماده‌ام" + "منتظر تطابق…" + "مقایسهٔ مجموعه‌ای یکتا از شکلک‌ها." + "شکلک‌ها را مقایسه کنید، از ترتیب نمایش آنان نیز مطمئن شوید." + "وارد شده" + "صحت‌سنجی شکست خورد" + "اکنون می‌توانید روی افزارهٔ دیگرتان با امنیت پیام فرستاده و بخوانید." + "افزاره تأیید شده" + "مطابق نیستند" + "مطابقند" + "برای ادامه، درخواست شروع فرآیند تأیید را در جلسه دیگر خود بپذیرید." + "منظر پذیرش درخواست" + "خارج شدن…" + diff --git a/features/verifysession/impl/src/main/res/values-fi/strings_sas.xml b/features/verifysession/impl/src/main/res/values-fi/strings_sas.xml new file mode 100644 index 0000000..c514477 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-fi/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Koira + Kissa + Leijona + Hevonen + Yksisarvinen + Sika + Norsu + Kani + Panda + Kukko + Pingviini + Kilpikonna + Kala + Tursas + Perhonen + Kukka + Puu + Kaktus + Sieni + Maapallo + Kuu + Pilvi + Tuli + Banaani + Omena + Mansikka + Maissi + Pizza + Kakku + Sydän + Hymynaama + Robotti + Hattu + Silmälasit + Kiintoavain + Joulupukki + Peukalo ylös + Sateenvarjo + Tiimalasi + Pöytäkello + Lahja + Hehkulamppu + Kirja + Lyijykynä + Paperiliitin + Sakset + Lukko + Avain + Vasara + Puhelin + Lippu + Juna + Polkupyörä + Lentokone + Raketti + Palkinto + Pallo + Kitara + Trumpetti + Soittokello + Ankkuri + Kuulokkeet + Kansio + Nuppineula + diff --git a/features/verifysession/impl/src/main/res/values-fi/translations.xml b/features/verifysession/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..4ee897b --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,54 @@ + + + "Etkö voi vahvistaa?" + "Luo uusi palautusavain" + "Vahvista tämä laite suojattua viestintää varten." + "Vahvista identiteettisi" + "Käytä toista laitetta" + "Käytä palautusavainta" + "Nyt voit lukea ja lähettää viestejä turvallisesti, ja kaikki, joiden kanssa keskustelet, voivat myös luottaa tähän laitteeseen." + "Laite vahvistettu" + "Käytä toista laitetta" + "Odotetaan toista laitetta…" + "Jokin ei vaikuta oikealta. Joko pyyntö aikakatkaistiin tai hylättiin." + "Vahvista, että alla olevat emojit vastaavat toisella laitteella näkyviä emojeja." + "Vertaa emojeja" + "Vahvista, että alla olevat hymiöt vastaavat toisen käyttäjän laitteessa näkyviä hymiöitä." + "Varmista, että alla olevat numerot vastaavat toisessa istunnossa näkyviä numeroita." + "Vertaa numeroita" + "Nyt voit lukea tai lähettää viestejä turvallisesti toisella laitteellasi." + "Nyt voit luottaa tämän käyttäjän identiteettiin, kun lähetät tai vastaanotat viestejä." + "Laite vahvistettu" + "Syötä palautusavain" + "Joko pyyntö aikakatkaistiin, pyyntö hylättiin tai vahvistus ei täsmännyt." + "Vahvista, että se olet sinä, jotta näet aiemmat salatut viestisi." + "Avaa laite, jossa olet jo kirjautuneena" + "Yritä vahvistusta uudelleen" + "Olen valmis" + "Odotetaan vahvistusta…" + "Vertaa emojisarjaa." + "Vertaa emojeja, varmistaen että ne ovat samassa järjestyksessä." + "Kirjautui sisään" + "Joko pyyntö aikakatkaistiin, pyyntö hylättiin tai vahvistus ei täsmännyt." + "Vahvistus epäonnistui" + "Jatka vain, jos sinä aloitit tämän vahvistuksen." + "Vahvista toinen laite pitääksesi viestihistoriasi turvassa." + "Nyt voit lukea tai lähettää viestejä turvallisesti toisella laitteellasi." + "Laite vahvistettu" + "Vahvistus pyydetty" + "Ne eivät täsmää" + "Ne täsmäävät" + "Varmista, että sovellus on avoinna toisessa laitteessa, ennen kuin aloitat vahvistuksen tästä." + "Avaa sovellus toisella vahvistetulla laitteella" + "Vahvista tämä käyttäjä turvallisuuden lisäämiseksi vertaamalla emojeja laitteillanne. Tee tämä käyttämällä luotettavaa viestintätapaa." + "Vahvistetaanko tämä käyttäjä?" + "Toinen käyttäjä haluaa vahvistaa identiteettisi turvallisuuden lisäämiseksi. Sinulle näytetään joukko emojeja vertailtavaksi." + "Sinun pitäisi nähdä ponnahdusikkuna toisessa laitteessa. Aloita vahvistus nyt sieltä." + "Aloita vahvistus toisella laitteella" + "Aloita vahvistus toisella laitteella" + "Odotetaan toista käyttäjää" + "Kun pyyntö on hyväksytty, voit jatkaa vahvistusta." + "Hyväksy vahvistuspyyntö toisella laitteella jatkaaksesi." + "Odotetaan pyynnön hyväksymistä" + "Kirjaudutaan ulos…" + diff --git a/features/verifysession/impl/src/main/res/values-fr/strings_sas.xml b/features/verifysession/impl/src/main/res/values-fr/strings_sas.xml new file mode 100644 index 0000000..3edd4b2 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-fr/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Chien + Chat + Lion + Cheval + Licorne + Cochon + Éléphant + Lapin + Panda + Coq + Manchot + Tortue + Poisson + Poulpe + Papillon + Fleur + Arbre + Cactus + Champignon + Globe + Lune + Nuage + Feu + Banane + Pomme + Fraise + Maïs + Pizza + Gâteau + Cœur + Sourire + Robot + Chapeau + Lunettes + Clé à molette + Père Noël + Pouce en l’air + Parapluie + Sablier + Réveil + Cadeau + Ampoule + Livre + Crayon + Trombone + Ciseaux + Cadenas + Clé + Marteau + Téléphone + Drapeau + Train + Vélo + Avion + Fusée + Trophée + Ballon + Guitare + Trompette + Cloche + Ancre + Casque audio + Dossier + Punaise + 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 0000000..7417a10 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,54 @@ + + + "Confirmation impossible ?" + "Créer une nouvelle clé de récupération" + "Vérifier cette session pour configurer votre messagerie sécurisée." + "Confirmez votre identité" + "Utiliser une autre session" + "Utiliser la clé de récupération" + "Vous pouvez désormais lire ou envoyer des messages en toute sécurité, et toute personne avec qui vous discutez peut également faire confiance à cette session." + "Session vérifiée" + "Utiliser une autre session" + "En attente d’une autre session…" + "Quelque chose ne va pas. Soit la demande a expiré, soit elle a été refusée." + "Confirmez que les émojis ci-dessous correspondent à ceux affichés sur votre autre appareil." + "Comparez les émojis" + "Vérifiez que les émojis ci-dessous correspondent à ceux affichés sur l’appareil de l’autre utilisateur." + "Confirmez que les nombres ci-dessous correspondent à ceux affichés sur votre autre session." + "Comparez les nombres" + "Vous pouvez désormais lire ou envoyer des messages en toute sécurité sur votre autre appareil." + "Vous pouvez désormais avoir confiance en l’identité de cet utilisateur lorsque vous lui envoyez des messages ou que vous recevez des messages de sa part." + "Session vérifiée" + "Utiliser la clé de récupération" + "Soit la demande a expiré, soit elle a été refusée, soit les éléments à comparer ne correspondaient pas." + "Prouvez qu’il s’agit bien de vous pour accéder à l’historique de vos messages chiffrés." + "Ouvrir une session existante" + "Réessayer la vérification" + "Je suis prêt.e" + "En attente de correspondance…" + "Comparer un groupe unique d’émojis." + "Comparez les émojis uniques en veillant à ce qu’ils apparaissent dans le même ordre." + "Connecté" + "Soit la demande a expiré, soit elle a été refusée, soit les éléments à comparer ne correspondaient pas." + "Échec de la vérification" + "Continuez uniquement si c’est vous qui avez commencé cette vérification." + "Vérifiez l’autre appareil pour sécuriser l’historique de vos messages." + "Vous pouvez désormais lire ou envoyer des messages en toute sécurité sur votre autre appareil." + "Session vérifiée" + "Vérification demandée" + "Ils ne correspondent pas" + "Ils correspondent" + "Assurez-vous que l’application est ouverte sur un autre appareil avant de commencer la vérification." + "Ouvrez l’application sur un autre appareil vérifié" + "Pour plus de sécurité, vérifiez cet utilisateur en comparant des émojis sur vos appareils. Pour ce faire, utilisez un moyen de communication fiable." + "Vérifier cet utilisateur ?" + "Pour plus de sécurité, cet autre utilisateur souhaite vérifier votre identité. Des émojis à comparer vous seront présentés." + "Vous devriez voir une alerte sur l’autre appareil. Démarrez la vérification à partir de là dès maintenant." + "Démarrer la vérification sur l’autre appareil" + "Démarrer la vérification sur l’autre appareil" + "En attente de l’autre utilisateur" + "Une fois acceptée, vous pourrez poursuivre la vérification." + "Pour continuer, acceptez la demande de lancement de la procédure de vérification dans votre autre session." + "En attente d’acceptation de la demande" + "Déconnexion…" + diff --git a/features/verifysession/impl/src/main/res/values-hr/strings_sas.xml b/features/verifysession/impl/src/main/res/values-hr/strings_sas.xml new file mode 100644 index 0000000..22f3e62 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-hr/strings_sas.xml @@ -0,0 +1,68 @@ + + + + pas + mačka + lav + konj + jednorog + svinja + slon + zec + panda + kokot + pingvin + kornjača + riba + hobotnica + leptir + svijet + drvo + kaktus + gljiva + Globus + mjesec + oblak + vatra + banana + jabuka + jagoda + kukuruza + pizza + torta + srca + smajlića + robot + kapa + naočale + ključ + deda Mraz + palac gore + kišobran + pješčani sat + sat + poklon + žarulja + knjiga + olovka + spajalica + škare + zaključati + ključ + čekić + telefon + zastava + vlak + bicikl + avion + raketa + trofej + lopta + gitara + truba + zvono + sidro + slušalice + mapu + pribadača + diff --git a/features/verifysession/impl/src/main/res/values-hu/strings_sas.xml b/features/verifysession/impl/src/main/res/values-hu/strings_sas.xml new file mode 100644 index 0000000..8137042 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-hu/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Kutya + Macska + Oroszlán + + Egyszarvú + Malac + Elefánt + Nyúl + Panda + Kakas + Pingvin + Teknős + Hal + Polip + Pillangó + Virág + Fa + Kaktusz + Gomba + Földgömb + Hold + Felhő + Tűz + Banán + Alma + Eper + Kukorica + Pizza + Süti + Szív + Mosoly + Robot + Kalap + Szemüveg + Csavarkulcs + Télapó + Hüvelykujj fel + Esernyő + Homokóra + Óra + Ajándék + Égő + Könyv + Ceruza + Gémkapocs + Olló + Lakat + Kulcs + Kalapács + Telefon + Zászló + Vonat + Kerékpár + Repülő + Rakáta + Trófea + Labda + Gitár + Trombita + Harang + Horgony + Fejhallgató + Mappa + Rajszeg + diff --git a/features/verifysession/impl/src/main/res/values-hu/translations.xml b/features/verifysession/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..a86a22f --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,54 @@ + + + "Nem tudja megerősíteni?" + "Új helyreállítási kulcs létrehozása" + "A biztonságos üzenetkezelés beállításához ellenőrizze ezt az eszközt." + "Erősítse meg, hogy Ön az" + "Másik eszköz használata" + "Helyreállítási kulcs használata" + "Mostantól biztonságosan olvashat vagy küldhet üzeneteket, és bármelyik csevegőpartnere megbízhat ebben az eszközben." + "Eszköz ellenőrizve" + "Másik eszköz használata" + "Várakozás a másik eszközre…" + "Valami hibásnak tűnik. A kérés vagy időtúllépésre futott, vagy elutasították." + "Erősítse meg, hogy a lenti emodzsik megegyeznek a másik eszközön megjelenítettekkel." + "Emodzsik összehasonlítása" + "Ellenőrizze, hogy az alábbi emodzsik megegyeznek-e a másik felhasználó eszközén látható emodzsikkal." + "Ellenőrizze, hogy az alábbi számok megegyeznek-e a másik munkamenetben feltüntetett számokkal." + "Számok összehasonlítása" + "Mostantól biztonságosan olvashat vagy küldhet üzeneteket a másik eszközén." + "Mostantól megbízhat a felhasználó személyazonosságában, amikor üzeneteket küld vagy fogad." + "Eszköz ellenőrizve" + "Adja meg a helyreállítási kulcsot" + "A kérés túllépte az időkorlátot, el lett utasítva, vagy ellenőrzési eltérés történt." + "Bizonyítsa, hogy valóban Ön az, hogy elérje a titkosított üzeneteinek előzményeit." + "Meglévő munkamenet megnyitása" + "Ellenőrzés újrapróbálása" + "Kész vagyok" + "Várakozás az egyezésre…" + "Egyedi emodzsik összehasonlítása." + "Hasonlítsa össze az egyedi emodzsikat, meggyőződve arról, hogy azonos a sorrendjük." + "Bejelentkezve" + "A kérés túllépte az időkorlátot, el lett utasítva, vagy ellenőrzési eltérés történt." + "Az ellenőrzés sikertelen" + "Csak akkor folytassa, ha Ön kezdeményezte ezt az ellenőrzést." + "Az üzenetelőzmények biztonságának megőrzése érdekében ellenőrizze a másik eszközt." + "Mostantól biztonságosan olvashat vagy küldhet üzeneteket a másik eszközén." + "Eszköz ellenőrizve" + "Ellenőrzés kérve" + "Nem egyeznek" + "Megegyeznek" + "Győződjön meg róla, hogy az alkalmazás nyitva van a másik eszközön, mielőtt innen elindítja az ellenőrzést." + "Nyissa meg az alkalmazást egy másik ellenőrzött eszközön" + "A fokozott biztonság érdekében ellenőrizze ezt a felhasználót az eszközökön megjelenő emodzsik összehasonlításával. Ehhez használjon megbízható kommunikációs csatornát." + "Ellenőrzi ezt a felhasználót?" + "A további biztonság érdekében egy másik felhasználó ellenőrizni szeretné személyazonosságát. Meg fog jelenni egy sor emodzsi, melyeket össze kell majd hasonlítania." + "A másik eszközön egy felugró ablaknak kell megjelennie. Kezdje el az ellenőrzést onnan." + "Ellenőrzés megkezdése a másik eszközön" + "Ellenőrzés megkezdése a másik eszközön" + "Várakozás a másik felhasználóra" + "Az elfogadása után folytathatja az ellenőrzést." + "A folytatáshoz fogadja el az ellenőrzési folyamat indítási kérését a másik munkamenetében." + "Várakozás a kérés elfogadására" + "Kijelentkezés…" + diff --git a/features/verifysession/impl/src/main/res/values-in/strings_sas.xml b/features/verifysession/impl/src/main/res/values-in/strings_sas.xml new file mode 100644 index 0000000..1b283a7 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-in/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Anjing + Kucing + Singa + Kuda + Unicorn + Babi + Gajah + Kelinci + Panda + Ayam + Penguin + Kura-Kura + Ikan + Gurita + Kupu-Kupu + Bunga + Pohon + Kaktus + Jamur + Bola Dunia + Bulan + Awan + Api + Pisang + Apel + Stroberi + Jagung + Pizza + Kue + Hati + Senyuman + Robot + Topi + Kacamata + Kunci Bengkel + Santa + Jempol + Payung + Jam Pasir + Jam + Kado + Bohlam Lampu + Buku + Pensil + Klip Kertas + Gunting + Gembok + Kunci + Palu + Telepon + Bendera + Kereta Api + Sepeda + Pesawat + Roket + Piala + Bola + Gitar + Terompet + Lonceng + Jangkar + Headphone + Map + Pin + diff --git a/features/verifysession/impl/src/main/res/values-in/translations.xml b/features/verifysession/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..e53c333 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,54 @@ + + + "Tidak dapat mengonfirmasi?" + "Buat kunci pemulihan baru" + "Verifikasi perangkat ini untuk menyiapkan perpesanan aman." + "Konfirmasi bahwa ini Anda" + "Gunakan perangkat lain" + "Gunakan kunci pemulihan" + "Sekarang Anda dapat membaca atau mengirim pesan dengan aman, dan siapa pun yang mengobrol dengan Anda juga dapat mempercayai perangkat ini." + "Perangkat terverifikasi" + "Gunakan perangkat lain" + "Menunggu di perangkat lain…" + "Sepertinya ada yang tidak beres. Entah permintaan sudah habis masa berlakunya atau permintaan ditolak." + "Konfirmasikan bahwa emoji di bawah ini sesuai dengan yang ditampilkan pada sesi Anda yang lain." + "Bandingkan emoji" + "Konfirmasikan bahwa emoji di bawah ini cocok dengan yang ditampilkan di perangkat pengguna lain." + "Konfirmasikan bahwa angka-angka di bawah ini sesuai dengan yang ditampilkan pada sesi Anda yang lain." + "Bandingkan angka" + "Sesi baru Anda sekarang diverifikasi. Ini memiliki akses ke pesan terenkripsi Anda, dan pengguna lain akan melihatnya sebagai tepercaya." + "Sekarang Anda dapat mempercayai identitas pengguna ini saat mengirim atau menerima pesan." + "Perangkat terverifikasi" + "Masukkan kunci pemulihan" + "Entah permintaan habis waktu, permintaan ditolak, atau ada ketidakcocokan verifikasi." + "Buktikan bahwa ini memang Anda untuk mengakses riwayat pesan terenkripsi Anda." + "Buka sesi yang sudah ada" + "Verifikasi ulang" + "Saya siap" + "Menunggu untuk mencocokkan" + "Bandingkan satu set emoji yang unik." + "Bandingkan emoji unik, dan pastikan emoji tersebut muncul dalam urutan yang sama." + "Sudah masuk" + "Entah permintaan habis waktu, permintaan ditolak, atau ada ketidakcocokan verifikasi." + "Verifikasi gagal" + "Lanjutkan hanya jika Anda memulai verifikasi ini." + "Verifikasi perangkat lain untuk menjaga riwayat pesan Anda tetap aman." + "Sesi baru Anda sekarang diverifikasi. Ini memiliki akses ke pesan terenkripsi Anda, dan pengguna lain akan melihatnya sebagai tepercaya." + "Perangkat terverifikasi" + "Verifikasi diminta" + "Mereka tidak cocok" + "Mereka cocok" + "Pastikan Anda membuka aplikasi di perangkat lain sebelum memulai verifikasi dari sini." + "Buka aplikasi di perangkat terverifikasi lain" + "Untuk keamanan tambahan, verifikasi pengguna ini dengan membandingkan satu set emoji di perangkat Anda. Lakukan ini dengan menggunakan cara tepercaya untuk berkomunikasi." + "Verifikasi pengguna ini?" + "Untuk keamanan tambahan, pengguna lain ingin memverifikasi identitas Anda. Anda akan ditampilkan satu set emoji untuk dibandingkan." + "Anda akan melihat popup di perangkat lain. Mulai verifikasi dari sana sekarang." + "Mulai verifikasi di perangkat lain" + "Mulai verifikasi di perangkat lain" + "Menunggu pengguna lain" + "Setelah diterima, Anda akan dapat melanjutkan verifikasi." + "Terima permintaan untuk memulai proses verifikasi di sesi Anda yang lain untuk melanjutkan." + "Menunggu untuk menerima permintaan" + "Mengeluarkan dari akun…" + diff --git a/features/verifysession/impl/src/main/res/values-it/strings_sas.xml b/features/verifysession/impl/src/main/res/values-it/strings_sas.xml new file mode 100644 index 0000000..d0dca4b --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-it/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Cane + Gatto + Leone + Cavallo + Unicorno + Maiale + Elefante + Coniglio + Panda + Gallo + Pinguino + Tartaruga + Pesce + Polpo + Farfalla + Fiore + Albero + Cactus + Fungo + Globo + Luna + Nuvola + Fuoco + Banana + Mela + Fragola + Mais + Pizza + Torta + Cuore + Faccina sorridente + Robot + Cappello + Occhiali + Chiave inglese + Babbo Natale + Pollice alzato + Ombrello + Clessidra + Orologio + Regalo + Lampadina + Libro + Matita + Graffetta + Forbici + Lucchetto + Chiave + Martello + Telefono + Bandiera + Treno + Bicicletta + Aeroplano + Razzo + Trofeo + Palla + Chitarra + Trombetta + Campana + Ancora + Cuffie + Cartella + Puntina + 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 0000000..5b11c27 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,54 @@ + + + "Non puoi confermare?" + "Crea una nuova chiave di recupero" + "Verifica questo dispositivo per segnare i tuoi messaggi come sicuri." + "Conferma la tua identità" + "Usa un altro dispositivo" + "Usa la chiave di recupero" + "Ora puoi leggere o inviare messaggi in tutta sicurezza e anche chi chatta con te può fidarsi di questo dispositivo." + "Dispositivo verificato" + "Usa un altro dispositivo" + "In attesa sull\'altro dispositivo…" + "C\'è qualcosa che non va. La richiesta è scaduta o è stata rifiutata." + "Verifica che le emoji sottostanti corrispondano a quelle mostrate sull\'altro dispositivo." + "Confronta le emoji" + "Conferma che le emoji qui sotto corrispondano a quelle visualizzate sul dispositivo dell\'altro utente." + "Conferma che i numeri seguenti corrispondano a quelli mostrati nell\'altra sessione." + "Confronta i numeri" + "Ora puoi leggere o inviare messaggi in modo sicuro sul tuo altro dispositivo." + "Ora puoi fidarti dell\'identità di questo utente quando invii o ricevi messaggi." + "Dispositivo verificato" + "Inserisci la chiave di recupero" + "La richiesta è scaduta, è stata rifiutata o c\'è stata una mancata corrispondenza nella verifica." + "Dimostra la tua identità per accedere alla cronologia dei messaggi crittografati." + "Apri una sessione esistente" + "Riprova la verifica" + "Sono pronto" + "In attesa di un riscontro" + "Confronta un set unico di emoji." + "Confronta le emoji uniche, assicurandoti che appaiano nello stesso ordine." + "Accesso effettuato" + "La richiesta è scaduta, è stata rifiutata o c\'è stata una mancata corrispondenza nella verifica." + "Verifica fallita" + "Continua solo se tu hai avviato questa verifica." + "Verifica l\'altro dispositivo per proteggere la cronologia dei messaggi." + "Ora puoi leggere o inviare messaggi in modo sicuro sul tuo altro dispositivo." + "Dispositivo verificato" + "Richiesta di verifica" + "Non corrispondono" + "Corrispondono" + "Assicurati di avere l\'app aperta sull\'altro dispositivo prima di iniziare la verifica da qui." + "Apri l\'app su un altro dispositivo verificato" + "Per una maggiore sicurezza, verifica questo utente confrontando un set di emoji sui tuoi dispositivi. A tale scopo, utilizza un metodo di comunicazione affidabile." + "Verificare questo utente?" + "Per una maggiore sicurezza, un altro utente desidera verificare la tua identità. Ti verrà mostrato un set di emoji da confrontare." + "Dovresti vedere un popup sull\'altro dispositivo. Inizia subito la verifica da lì." + "Avvia la verifica sull\'altro dispositivo" + "Avvia la verifica sull\'altro dispositivo" + "In attesa dell\'altro utente" + "Una volta accettata, potrai proseguire con la verifica." + "Accetta la richiesta di avviare il processo di verifica nell\'altra sessione per continuare." + "In attesa di accettare la richiesta" + "Disconnessione in corso…" + diff --git a/features/verifysession/impl/src/main/res/values-ja/strings_sas.xml b/features/verifysession/impl/src/main/res/values-ja/strings_sas.xml new file mode 100644 index 0000000..9791b68 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-ja/strings_sas.xml @@ -0,0 +1,68 @@ + + + + + + ライオン + + ユニコーン + ブタ + ゾウ + うさぎ + パンダ + ニワトリ + ペンギン + + + たこ + ちょうちょ + + + サボテン + きのこ + 地球 + + + + バナナ + リンゴ + いちご + とうもろこし + ピザ + ケーキ + ハート + スマイル + ロボット + 帽子 + めがね + スパナ + サンタ + いいね + + 砂時計 + 時計 + ギフト + 電球 + + 鉛筆 + クリップ + はさみ + 錠前 + + 金槌 + 電話機 + + 電車 + 自転車 + 飛行機 + ロケット + トロフィー + ボール + ギター + トランペット + ベル + いかり + ヘッドホン + フォルダー + ピン + diff --git a/features/verifysession/impl/src/main/res/values-ka/translations.xml b/features/verifysession/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..eed71a3 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,31 @@ + + + "ახალი აღდგენის გასაღების შექმნა" + "დაადასტურეთ ეს მოწყობილობა უსაფრთხო მიმოწერისათვის." + "დაამტკიცეთ თქვენი პიროვნება" + "ახლა თქვენ შეძლებთ შეტყობინებების წაკითხვას ან გაგზავნას უსაფრთხოდ, სხვა მომხმარებლებსაც შეუძლიათ ამ მოწყობილობას ენდონ." + "მოწყობილობა დადასტურებულია" + "ველოდებით სხვა მოწყობილობას…" + "რაღაცა არასწორადაა. ან მოთხოვნის ვადაა ამოწურული, ან მოთხოვნა უარყოფილი იყო." + "დაადასტურეთ, რომ ქვემოთ მოყვანილი ემოჯიები შეესაბამება თქვენს სხვა სესიაზე ნაჩვენებს." + "შეადარეთ ემოჯიები" + "დაადასტურეთ, რომ ქვემოთ მოცემული ნომრები ემთხვევა თქვენს სხვა სესიაზე ნაჩვენები ნომრებს." + "შეადარეთ რიცხვები" + "თქვენი ახალი სესია დადასტურებულია. მას აქვს წვდომა დაშიფრულ შეტყობინებებზე და სხვა მომხმარებლები მას სანდოდ ხედავენ." + "მოწყობილობა დადასტურებულია" + "შეიყვანეთ აღდგენის გასაღები" + "დაამტკიცეთ, რომ ეს თქვენ ხართ, რათა მიიღოთ წვდომა თქვენი დაშიფრული შეტყობინებების ისტორიასთან." + "არსებული სესიის გახსნა" + "დადასტურების ხელახლა ცდა" + "მზად ვარ" + "ველოდებით დამთხვევას" + "შეადარეთ ემოციების უნიკალური ნაკრები." + "შეადარეთ უნიკალური ემოჯი, დარწმუნდით, რომ ისინი ერთი დ იმავე თანმიმდევრობით გამოჩნდნენ." + "თქვენი ახალი სესია დადასტურებულია. მას აქვს წვდომა დაშიფრულ შეტყობინებებზე და სხვა მომხმარებლები მას სანდოდ ხედავენ." + "მოწყობილობა დადასტურებულია" + "ისინი არ ემთხვევიან ერთმანეთს" + "ისინი ემთხვევიან ერთმანეთს" + "მიიღეთ დადასტურების მოთხოვნა თქვენს სხვა სესიაში ამ პროცესის გასაგრძელებლად." + "მოთხოვნის მიღებას ველოდებით" + "გასვლა…" + diff --git a/features/verifysession/impl/src/main/res/values-ko/translations.xml b/features/verifysession/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..1dfe3f7 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,54 @@ + + + "확인할 수 없나요?" + "새로운 복구 키 만들기" + "보안 메시징을 설정하려면 이 장치를 확인하세요." + "본인 확인" + "다른 기기 사용" + "복구 키 사용" + "이제 메시지를 안전하게 읽거나 보낼 수 있으며, 채팅 상대도 이 기기를 신뢰할 수 있습니다." + "기기 검증됨" + "다른 기기 사용" + "다른 기기에서 대기 중…" + "무언가 잘못된 것 같습니다. 요청이 시간 초과되었거나 요청이 거부되었습니다." + "아래 이모티콘이 다른 세션에 표시된 이모티콘과 일치하는지 확인하세요." + "이모지 비교" + "아래 이모티콘이 다른 사용자의 기기에 표시된 이모티콘과 동일한지 확인하십시오." + "아래 숫자가 다른 세션에 표시된 숫자와 일치하는지 확인하세요." + "숫자 비교" + "새로운 세션이 확인되었습니다. 이 세션은 귀하의 암호화된 메시지에 액세스할 수 있으며, 다른 사용자는 이 세션을 신뢰할 수 있는 세션으로 인식합니다." + "이제 메시지를 보내거나 받을 때 이 사용자의 신원을 신뢰할 수 있습니다." + "기기 검증됨" + "복구 키를 입력하세요" + "요청이 시간 초과되었거나, 요청이 거부되었거나, 검증 불일치가 발생했습니다." + "암호화된 메시지 기록에 액세스하기 위해 본인임을 증명하세요." + "기존 세션 열기" + "검증 재시도" + "준비되었습니다" + "매칭을 기다리는 중…" + "고유한 이모지 세트를 비교하세요." + "고유한 이모지를 비교하여 동일한 순서로 표시되도록 확인하세요." + "로그인됨" + "요청이 시간 초과되었거나, 요청이 거부되었거나, 검증 불일치가 발생했습니다." + "검증 실패" + "본인이 이 검증을 시작한 경우에만 계속 진행하세요." + "다른 기기를 확인하여 메시지 기록을 안전하게 보호하세요." + "새로운 세션이 확인되었습니다. 이 세션은 귀하의 암호화된 메시지에 액세스할 수 있으며, 다른 사용자는 이 세션을 신뢰할 수 있는 세션으로 인식합니다." + "기기 검증됨" + "검증 요청" + "일치하지 않습니다" + "일치합니다" + "여기에서 검증을 시작하기 전에 다른 기기에서 앱이 실행되어 있는지 확인하십시오." + "다른 검증된 장치에서 앱을 실행하세요" + "보안을 강화하려면, 기기에 표시된 이모티콘을 비교하여 이 사용자를 확인하세요. 신뢰할 수 있는 통신 수단을 사용하여 확인하시기 바랍니다." + "이 사용자를 검증하시겠습니까?" + "추가 보안 위해 다른 사용자가 귀하의 신원을 확인하고자 합니다. 비교할 이모티콘 세트가 표시됩니다." + "다른 기기에 팝업이 표시될 것입니다. 지금 그곳에서 확인을 시작하세요." + "다른 장치에서 검증 시작" + "다른 장치에서 검증 시작" + "다른 사용자를 기다리는 중" + "승인 후에는 검증 과정을 계속 진행할 수 있습니다." + "계속하려면 다른 세션에서 검증 과정을 시작하라는 요청을 수락하세요." + "요청 수락을 기다리는 중" + "로그아웃 중…" + diff --git a/features/verifysession/impl/src/main/res/values-lt/translations.xml b/features/verifysession/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..34ea11d --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,19 @@ + + + "Kažkas atrodo ne taip. Baigėsi užklausos skirtasis laikas arba užklausa buvo atmesta." + "Patvirtinkite, kad žemiau esantys jaustukai atitinka tuos, kurie rodomi kitoje sesijoje." + "Palyginkite jaustukus" + "Jūsų nauja sesija dabar patvirtinta. Ji turi prieigą prie jūsų užšifruotų pranešimų, o kiti vartotojai matys ją kaip patikimą." + "Įrodykite, kad tai Jūs, norėdami pasiekti savo užšifruotų pranešimų istoriją." + "Atidaryti esamą sesiją" + "Pakartoti patvirtinimą" + "Aš pasiruošęs" + "Laukiama atitikimo…" + "Palyginkite unikalius jaustukus, įsitikindami, kad jie rodomi ta pačia tvarka." + "Jūsų nauja sesija dabar patvirtinta. Ji turi prieigą prie jūsų užšifruotų pranešimų, o kiti vartotojai matys ją kaip patikimą." + "Jie nesutampa" + "Jie sutampa" + "Kitoje sesijoje priimkite prašymą pradėti tikrinimo procesą, kad galėtumėte tęsti." + "Laukiama prašymo priėmimo" + "Atsijungiama…" + diff --git a/features/verifysession/impl/src/main/res/values-nb-rNO/strings_sas.xml b/features/verifysession/impl/src/main/res/values-nb-rNO/strings_sas.xml new file mode 100644 index 0000000..48c830a --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-nb-rNO/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Hund + Katt + Løve + Hest + Enhjørning + Gris + Elefant + Kanin + Panda + Hane + Pingvin + Skilpadde + Fisk + Blekksprut + Sommerfugl + Blomst + Tre + Kaktus + Sopp + Globus + Måne + Sky + Flamme + Banan + Eple + Jordbær + Mais + Pizza + Kake + Hjerte + Smilefjes + Robot + Hatt + Briller + Fastnøkkel + Julenisse + Tommel Opp + Paraply + Timeglass + Klokke + Gave + Lyspære + Bok + Blyant + BInders + Saks + Lås + Nøkkel + Hammer + Telefon + Flagg + Tog + Sykkel + Fly + Rakett + Pokal + Ball + Gitar + Trompet + Bjelle + Anker + Hodetelefoner + Mappe + Tegnestift + diff --git a/features/verifysession/impl/src/main/res/values-nb/translations.xml b/features/verifysession/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..533a0fd --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,54 @@ + + + "Kan du ikke bekrefte?" + "Opprett en ny gjenopprettingsnøkkel" + "Verifiser denne enheten for å sette opp sikker meldingsutveksling." + "Bekreft identiteten din" + "Bruk en annen enhet" + "Bruk gjenopprettingsnøkkel" + "Nå kan du lese eller sende meldinger på en sikker måte, og alle du chatter med kan også stole på denne enheten." + "Enhet verifisert" + "Bruk en annen enhet" + "Venter på en annen enhet…" + "Noe virker ikke riktig. Enten ble forespørselen tidsavbrutt eller forespørselen ble avslått." + "Bekreft at emojiene nedenfor samsvarer med de som vises på den andre enheten din." + "Sammenlign emojier" + "Bekreft at emojiene nedenfor stemmer overens med de som vises på den andre brukerens enhet." + "Kontroller at tallene nedenfor stemmer overens med dem som vises på den andre sesjonen." + "Sammenlign tallene" + "Nå kan du lese eller sende meldinger sikkert på den andre enheten din." + "Nå kan du stole på identiteten til denne brukeren når du sender eller mottar meldinger." + "Enhet verifisert" + "Skriv inn gjenopprettingsnøkkel" + "Enten ble forespørselen tidsavbrutt, forespørselen ble avslått eller det var en feil i verifiseringen." + "Bevis at det er deg for å få tilgang til den krypterte meldingshistorikken din." + "Åpne en eksisterende sesjon" + "Prøv verifisering på nytt" + "Jeg er klar" + "Venter på å matche…" + "Sammenlign et unikt sett med emojier." + "Sammenlign de unike emojiene, og sørg for at de vises i samme rekkefølge." + "Logget inn" + "Enten ble forespørselen tidsavbrutt, forespørselen ble avslått eller det var en feil i verifiseringen." + "Verifisering mislyktes" + "Bare fortsett hvis du startet denne verifiseringen." + "Bekreft den andre enheten for å holde meldingsloggen din sikker." + "Nå kan du lese eller sende meldinger sikkert på den andre enheten din." + "Enhet verifisert" + "Verifisering forespurt" + "De matcher ikke" + "De matcher" + "Sørg for at du har appen åpen på den andre enheten før du starter verifiseringen herfra." + "Åpne appen på en annen bekreftet enhet" + "For ekstra sikkerhet, verifiser denne brukeren ved å sammenligne et sett med emojier på enhetene dine. Gjør dette ved å bruke en pålitelig måte å kommunisere på." + "Vil du verifisere denne brukeren?" + "For ekstra sikkerhet vil en annen bruker bekrefte identiteten din. Du får se et sett med emojier som du må sammenligne." + "Du skal se en popup på den andre enheten. Start bekreftelsen derfra nå." + "Start verifiseringen på den andre enheten" + "Start verifiseringen på den andre enheten" + "Venter på den andre brukeren" + "Når du er akseptert, kan du fortsette med verifiseringen." + "Godta forespørselen om å starte bekreftelsesprosessen i den andre sesjonen for å fortsette." + "Venter på å godta forespørselen" + "Logger ut…" + diff --git a/features/verifysession/impl/src/main/res/values-nl/strings_sas.xml b/features/verifysession/impl/src/main/res/values-nl/strings_sas.xml new file mode 100644 index 0000000..24a889f --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-nl/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Hond + Kat + Leeuw + Paard + Eenhoorn + Varken + Olifant + Konijn + Panda + Haan + Pinguïn + Schildpad + Vis + Octopus + Vlinder + Bloem + Boom + Cactus + Paddenstoel + Wereldbol + Maan + Wolk + Vuur + Banaan + Appel + Aardbei + Maïs + Pizza + Taart + Hart + Smiley + Robot + Hoed + Bril + Moersleutel + Kerstman + Duim omhoog + Paraplu + Zandloper + Wekker + Geschenk + Gloeilamp + Boek + Potlood + Papierklemmetje + Schaar + Slot + Sleutel + Hamer + Telefoon + Vlag + Trein + Fiets + Vliegtuig + Raket + Trofee + Bal + Gitaar + Trompet + Bel + Anker + Koptelefoon + Map + Duimspijker + diff --git a/features/verifysession/impl/src/main/res/values-nl/translations.xml b/features/verifysession/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..d7bc1d4 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,39 @@ + + + "Kan ik dit niet bevestigen?" + "Maak een nieuwe herstelsleutel" + "Verifieer dit apparaat om beveiligde berichten in te stellen." + "Bevestig dat jij het bent" + "Gebruik een ander apparaat" + "Gebruik de herstelsleutel" + "Nu kun je veilig berichten lezen of verzenden, en iedereen met wie je chat kan dit apparaat ook vertrouwen." + "Apparaat geverifieerd" + "Gebruik een ander apparaat" + "Wachten op ander apparaat…" + "Er lijkt iets niet goed te gaan. Of er is een time-out opgetreden of het verzoek is geweigerd." + "Bevestig dat de emoji\'s hieronder overeenkomen met de emoji\'s in je andere sessie." + "Vergelijk emoji\'s" + "Bevestig dat de onderstaande cijfers overeenkomen met de cijfers die worden weergegeven in je andere sessie." + "Vergelijk getallen" + "Je nieuwe sessie is nu geverifieerd. Het heeft toegang tot je versleutelde berichten en andere gebruikers zullen het als vertrouwd beschouwen." + "Apparaat geverifieerd" + "Voer herstelsleutel in" + "Bewijs dat jij het bent om toegang te krijgen tot je versleutelde berichtgeschiedenis." + "Open een bestaande sessie" + "Verificatie opnieuw proberen" + "Ik ben er klaar voor" + "Wachten om te vergelijken" + "Vergelijk een unieke combinatie van emoji\'s." + "Vergelijk de unieke emoji\'s, ze dienen in dezelfde volgorde te worden weergegeven." + "Ingelogd" + "Ga alleen verder als je deze verificatie hebt gestart." + "Verifieer het andere apparaat om je berichtengeschiedenis veilig te houden." + "Je nieuwe sessie is nu geverifieerd. Het heeft toegang tot je versleutelde berichten en andere gebruikers zullen het als vertrouwd beschouwen." + "Apparaat geverifieerd" + "Verificatieverzocht" + "Ze komen niet overeen" + "Ze komen overeen" + "Accepteer het verzoek tot verificatie in je andere sessie om door te gaan." + "Wachten om verzoek te accepteren" + "Uitloggen…" + diff --git a/features/verifysession/impl/src/main/res/values-pl/translations.xml b/features/verifysession/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..92b0572 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,54 @@ + + + "Nie możesz potwierdzić?" + "Utwórz nowy klucz przywracania" + "Zweryfikuj to urządzenie, aby skonfigurować bezpieczne przesyłanie wiadomości." + "Potwierdź, że to Ty" + "Użyj innego urządzenia" + "Użyj klucza przywracania" + "Teraz możesz bezpiecznie czytać i wysyłać wiadomości, każdy z kim czatujesz również może ufać temu urządzeniu." + "Urządzenie zweryfikowane" + "Użyj innego urządzenia" + "Oczekiwanie na inne urządzenie…" + "Coś tu nie gra. Albo upłynął limit czasu, albo żądanie zostało odrzucone." + "Upewnij się, że emoji poniżej pasują do tych pokazanych na innej sesji." + "Porównaj emoji" + "Upewnij się, że emoji poniżej odpowiadają tym na urządzeniu drugiego użytkownika." + "Upewnij się, że liczby poniżej pasują do tych wyświetlanych na innej sesji." + "Porównaj liczby" + "Teraz możesz bezpiecznie czytać i wysyłać wiadomości na swoim drugim urządzeniu." + "Teraz możesz zaufać tożsamości tego użytkownika podczas wysyłania lub odbierania wiadomości." + "Urządzenie zweryfikowane" + "Wprowadź klucz przywracania" + "Albo upłynął limit czasu żądania, albo żądanie zostało odrzucone, albo wystąpił błąd weryfikacji." + "Udowodnij, że to ty, aby uzyskać dostęp do historii zaszyfrowanych wiadomości." + "Otwórz istniejącą sesję" + "Ponów weryfikację" + "Jestem gotowy(a)" + "Oczekiwanie na dopasowanie" + "Porównaj unikalny zestaw emoji." + "Porównaj unikalny zestaw emoji i upewnij się, że są w tej samej kolejności." + "Zalogowano" + "Albo upłynął limit czasu żądania, albo żądanie zostało odrzucone, albo wystąpił błąd weryfikacji." + "Weryfikacja nie powiodła się" + "Kontynuuj tylko, jeśli to Ty zainicjowałeś tę weryfikację." + "Zweryfikuj drugie urządzenie, aby zabezpieczyć historię wiadomości." + "Teraz możesz bezpiecznie czytać i wysyłać wiadomości na swoim drugim urządzeniu." + "Urządzenie zweryfikowane" + "Zażądano weryfikacji" + "Nie pasują do siebie" + "Pasują do siebie" + "Upewnij się, że aplikacja jest otwarta na drugim urządzeniu przed rozpoczęciem weryfikacji." + "Otwórz aplikację na drugim zweryfikowanym urządzeniu" + "Dla dodatkowej ochrony, zweryfikuj tego użytkownika, porównując zestaw emoji na obu urządzeniach. Zrób to, korzystając z zaufanej metody komunikacji." + "Zweryfikować tego użytkownika?" + "Dla dodatkowej ochrony, inny użytkownik chce zweryfikować Twoją tożsamość. Pojawi się unikalny zestaw emoji do porównania." + "Powinno wyskoczyć okno na drugim urządzeniu. Rozpocznij tam weryfikację." + "Rozpocznij weryfikację na drugim urządzeniu" + "Rozpocznij weryfikację na drugim urządzeniu" + "Oczekiwanie na drugiego użytkownika" + "Po zaakceptowaniu będziesz mógł kontynuować weryfikację." + "Zaakceptuj prośbę o rozpoczęcie procesu weryfikacji w innej sesji, aby kontynuować." + "Oczekiwanie na zaakceptowanie prośby" + "Wylogowywanie…" + diff --git a/features/verifysession/impl/src/main/res/values-pt-rBR/strings_sas.xml b/features/verifysession/impl/src/main/res/values-pt-rBR/strings_sas.xml new file mode 100644 index 0000000..34add98 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-pt-rBR/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Cachorro + Gato + Leão + Cavalo + Unicórnio + Porco + Elefante + Coelho + Panda + Galo + Pinguim + Tartaruga + Peixe + Polvo + Borboleta + Flor + Árvore + Cacto + Cogumelo + Globo + Lua + Nuvem + Fogo + Banana + Maçã + Morango + Milho + Pizza + Bolo + Coração + Sorriso + Robô + Chapéu + Óculos + Chave inglesa + Papai-noel + Joinha + Guarda-chuva + Ampulheta + Relógio + Presente + Lâmpada + Livro + Lápis + Clipe de papel + Tesoura + Cadeado + Chave + Martelo + Telefone + Bandeira + Trem + Bicicleta + Avião + Foguete + Troféu + Bola + Guitarra + Trombeta + Sino + Âncora + Fones de ouvido + Pasta + Alfinete + diff --git a/features/verifysession/impl/src/main/res/values-pt-rBR/translations.xml b/features/verifysession/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..591c482 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,54 @@ + + + "Não consegue confirmar?" + "Criar uma nova chave de recuperação" + "Verifique este dispositivo para configurar as mensagens seguras." + "Confirme sua identidade" + "Usar outro dispositivo" + "Usar chave de recuperação" + "Agora você pode ler ou enviar mensagens com segurança, e qualquer pessoa com quem você conversa também pode confiar neste dispositivo." + "Dispositivo verificado" + "Usar outro dispositivo" + "Aguardando o outro dispositivo…" + "Algo não parece certo. Ou a solicitação atingiu o tempo limite ou a solicitação foi negada." + "Confirme que os emojis abaixo correspondem aos mostrados no seu outro dispositivo." + "Compare os emojis" + "Confirme se os emojis abaixo correspondem aos exibidos no dispositivo do outro usuário." + "Confirme se os números abaixo correspondem aos mostrados na sua outra sessão." + "Comparar números" + "Agora você pode enviar ou receber mensagens com segurança no seu outro dispositivo." + "Agora você pode confiar na identidade desse usuário ao enviar ou receber mensagens." + "Dispositivo verificado" + "Digitar chave de recuperação" + "Ou a solicitação expirou, a solicitação foi negada ou houve uma não correspondência na verificação." + "Prove que é você para acessar seu histórico de mensagens criptografadas." + "Abrir uma sessão existente" + "Repetir verificação" + "Estou pronto" + "Aguardando a correspondência…" + "Compare um conjunto de emojis único." + "Compare os emojis únicos, garantindo que apareçam na mesma ordem." + "Conectado" + "Ou a solicitação expirou, a solicitação foi negada ou houve uma não correspondência na verificação." + "A verificação falhou" + "Continue somente se você iniciou esta verificação." + "Verifique o outro dispositivo para manter seu histórico de mensagens seguro." + "Agora você pode enviar ou receber mensagens com segurança no seu outro dispositivo." + "Dispositivo verificado" + "Verificação solicitada" + "Eles não combinam" + "Eles combinam" + "Certifique-se de que você tenha o app aberto no outro dispositivo antes de iniciar a verificação por aqui." + "Abra o aplicativo em outro dispositivo verificado" + "Para maior segurança, verifique esse usuário comparando um conjunto de emojis em seus dispositivos. Faça isso usando uma maneira confiável de se comunicar." + "Verificar este usuário?" + "Para maior segurança, outro usuário deseja verificar sua identidade. Você verá um conjunto de emojis para comparar." + "Você deverá ver um pop-up no outro dispositivo. Você deverá ver uma janela pop-up no outro dispositivo e iniciar a verificação a partir daí." + "Inicie a verificação no outro dispositivo" + "Inicie a verificação no outro dispositivo" + "Aguardando o outro usuário" + "Depois de aceito, você poderá continuar com a verificação." + "Aceite a solicitação para iniciar o processo de verificação na sua outra sessão para continuar." + "Aguardando para aceitar a solicitação" + "Saindo…" + diff --git a/features/verifysession/impl/src/main/res/values-pt/strings_sas.xml b/features/verifysession/impl/src/main/res/values-pt/strings_sas.xml new file mode 100644 index 0000000..c422b2a --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-pt/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Cão + Gato + Leão + Cavalo + Unicórnio + Porco + Elefante + Coelho + Panda + Galo + Pinguim + Tartaruga + Peixe + Polvo + Borboleta + Flor + Árvore + Cato + Cogumelo + Globo + Lua + Nuvem + Fogo + Banana + Maçã + Morango + Milho + Piza + Bolo + Coração + Sorriso + Robô + Chapéu + Óculos + Chave inglesa + Pai Natal + Polegar para cima + Guarda-chuva + Ampulheta + Relógio + Presente + Lâmpada + Livro + Lápis + Clipe + Tesoura + Cadeado + Chave + Martelo + Telefone + Bandeira + Comboio + Bicicleta + Avião + Foguetão + Troféu + Bola + Guitarra + Trompete + Sino + Âncora + Fones + Pasta + Pionés + diff --git a/features/verifysession/impl/src/main/res/values-pt/translations.xml b/features/verifysession/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..bf32d3c --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,54 @@ + + + "Não é possível confirmar?" + "Criar uma nova chave de recuperação" + "Verifica este dispositivo para configurar o envio seguro de mensagens." + "Confirma que és tu" + "Utilizar outro dispositivo" + "Utilizar chave de recuperação" + "Agora podes ler ou enviar mensagens de forma segura, e qualquer pessoa com quem converses também pode confiar neste dispositivo." + "Dispositivo verificado" + "Utilizar outro dispositivo" + "A aguardar por outros dispositivos…" + "Algo não bateu certo. O pedido ou demorou demasiado tempo ou foi rejeitado." + "Confirma que os emojis abaixo correspondem aos apresentados no teu outro dispositivo." + "Compara os emojis" + "Confirma se os emojis abaixo correspondem aos apresentados no dispositivo do outro utilizador." + "Confirma se os números abaixo correspondem aos números apresentados na tua outra sessão." + "Comparar números" + "Agora já podes ler ou enviar mensagens com segurança a partir do teu outro dispositivo." + "Agora podes confiar na identidade deste utilizador quando envias ou recebes mensagens." + "Dispositivo verificado" + "Insere a chave de recuperação" + "O pedido expirou, o pedido foi recusado ou houve um erro de verificação." + "Prova que és tu para acederes ao teu histórico de mensagens cifradas." + "Abrir sessão existente" + "Repetir verificação" + "Estou pronto" + "A aguardar correspondência" + "Compara um conjunto único de emojis." + "Compara os emojis únicos, certificando-te de que aparecem pela mesma ordem." + "Sessão iniciada" + "O pedido expirou, o pedido foi recusado ou houve um erro de verificação." + "A verificação falhou" + "Continua apenas se tiveres iniciado esta verificação." + "Verifique o outro dispositivo para manter o histórico de mensagens seguro." + "Agora já podes ler ou enviar mensagens com segurança a partir do teu outro dispositivo." + "Dispositivo verificado" + "Verificação solicitada" + "Não correspondem" + "Correspondem" + "Certifica-te de que tens a aplicação aberta no outro dispositivo antes de iniciares a verificação aqui." + "Abre a aplicação noutro dispositivo verificado" + "Para maior segurança, verifica este utilizador comparando um conjunto de emojis nos teus dispositivos. Faz isto utilizando uma forma de comunicação de confiança." + "Verificar este utilizador?" + "Para maior segurança, outro utilizador quer verificar a tua identidade. Ser-te-á mostrado um conjunto de emojis para comparares." + "Deves ver uma notificação no outro dispositivo. Inicia a verificação a partir daí." + "Inicia a verificação no outro dispositivo" + "Inicia a verificação no outro dispositivo" + "A espera do outro utilizador" + "Uma vez aceite, poderás continuar com a verificação." + "Para continuar, aceita o pedido de verificação na tua outra sessão." + "À aguardar a aceitação do pedido" + "A terminar sessão…" + 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 0000000..0d1ddd0 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,54 @@ + + + "Nu puteți confirma?" + "Creați o nouă cheie de recuperare" + "Verificați acest dispozitiv pentru a configura mesagerie securizată." + "Confirmați că sunteți dumneavoastră" + "Utilizați un alt dispozitiv" + "Utilizați cheia de recuperare" + "Acum puteți citi sau trimite mesaje în siguranță, iar oricine cu care conversați poate avea încredere în acest dispozitiv." + "Dispozitiv verificat" + "Utilizați un alt dispozitiv" + "Se așteaptă celălalt dispozitiv…" + "Ceva nu este în regulă. Fie cererea a expirat, fie a fost respinsă." + "Confirmați că emoticoanele de mai jos se potrivesc cu cele afișate în cealaltă sesiune." + "Comparați emoticoanele" + "Confirmați că emoji-urile de mai jos corespund cu cele afișate pe dispozitivul celuilalt utilizator." + "Confirmați că numerele de mai jos se potrivesc cu cele afișate în cealaltă sesiune." + "Comparați numerele" + "Noua dumneavoastră sesiune este acum verificată. Are acces la mesajele dumneavoastră criptate, iar ceilalti utilizatori vă vor vedea ca fiind de încredere." + "Acum puteți avea încredere în identitatea acestui utilizator atunci când trimiteți sau primiți mesaje." + "Dispozitiv verificat" + "Introduceți cheia de recuperare" + "Fie cererea a expirat, cererea a fost respinsă, fie a existat o nepotrivire de verificare." + "Demonstrați-vă identitatea pentru a accesa mesaje anterioare criptate." + "Deschideți o sesiune existentă" + "Reîncercați verificarea" + "Sunt pregătit" + "Se așteaptă confirmarea" + "Comparați un set unic de emoji-uri." + "Comparăți emoticoalene asigurându-vă că apar în aceeași ordine." + "Conectat" + "Fie cererea a expirat, cererea a fost respinsă, fie a existat o nepotrivire de verificare." + "Verificarea a eșuat" + "Continuați numai dacă dumneavoastră ați inițiat această verificare." + "Verificați celălalt dispozitiv pentru a vă păstra mesajele anterioare în siguranță." + "Noua dumneavoastră sesiune este acum verificată. Are acces la mesajele dumneavoastră criptate, iar ceilalti utilizatori vă vor vedea ca fiind de încredere." + "Dispozitiv verificat" + "Verificare cerută" + "Nu se potrivesc" + "Se potrivesc" + "Asigurați-vă că aplicația este deschisă pe celălalt dispozitiv înainte de a începe verificarea de aici." + "Deschideți aplicația pe un alt dispozitiv verificat" + "Pentru securitate suplimentară, verificați acest utilizator comparând un set de emoji-uri pe dispozitivele dvs. Faceți acest lucru utilizând o metodă de comunicare de încredere." + "Verificați acest utilizator?" + "Pentru o securitate suplimentară, un alt utilizator dorește să vă verifice identitatea. Vi se va afișa un set de emoji-uri pentru comparație." + "Ar trebui să vedeți o fereastră pop-up pe celălalt dispozitiv. Începeți verificarea de acolo acum." + "Începeți verificarea pe celălalt dispozitiv" + "Începeți verificarea pe celălalt dispozitiv" + "Se așteaptă celălalt utilizator" + "După acceptare, veți putea continua verificarea." + "Acceptați cererea de a începe procesul de verificare în cealaltă sesiune pentru a continua." + "Se așteptă acceptarea cererii" + "Deconectare în curs…" + diff --git a/features/verifysession/impl/src/main/res/values-ru/strings_sas.xml b/features/verifysession/impl/src/main/res/values-ru/strings_sas.xml new file mode 100644 index 0000000..9a26caf --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-ru/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Собака + Кошка + Лев + Лошадь + Единорог + Свинья + Слон + Кролик + Панда + Петух + Пингвин + Черепаха + Рыба + Осьминог + Бабочка + Цветок + Дерево + Кактус + Гриб + Глобус + Луна + Облако + Огонь + Банан + Яблоко + Клубника + Кукуруза + Пицца + Торт + Сердце + Улыбка + Робот + Шляпа + Очки + Ключ + Санта + Большой палец вверх + Зонт + Песочные часы + Часы + Подарок + Лампочка + Книга + Карандаш + Скрепка + Ножницы + Замок + Ключ + Молоток + Телефон + Флаг + Поезд + Велосипед + Самолет + Ракета + Кубок + Мяч + Гитара + Труба + Колокол + Якорь + Наушники + Папка + Булавка + diff --git a/features/verifysession/impl/src/main/res/values-ru/translations.xml b/features/verifysession/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..0c2347a --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,54 @@ + + + "Не можете подтвердить?" + "Создайте новый ключ восстановления" + "Подтвердите это устройство, чтобы настроить безопасный обмен сообщениями." + "Подтвердите, что это вы" + "Использовать другое устройство" + "Используйте recovery key" + "Теперь вы можете безопасно читать и отправлять сообщения, и все, с кем вы общаетесь в чате, также могут доверять этому устройству." + "Устройство проверено" + "Использовать другое устройство" + "Ожидание на другом устройстве…" + "Похоже, что-то не так. Время ожидания запроса либо истекло, либо запрос был отклонен." + "Убедитесь, что приведенные ниже эмодзи совпадают с эмодзи показанными на другом устройстве." + "Сравните емодзи" + "Убедитесь, что указанные ниже эмодзи соответствуют тем, которые отображаются на устройстве другого пользователя." + "Убедитесь, что приведенные ниже числа совпадают с цифрами, показанными в другом сеансе." + "Сравните числа" + "Теперь вы можете читать или отправлять сообщения безопасно в другом вашем устройстве." + "Теперь вы можете доверять этому пользователя при отправке или получении сообщений." + "Устройство проверено" + "Введите ключ восстановления" + "Время ожидания подтверждения истекло, запрос был отклонён, или при подтверждении произошло несоответствие." + "Чтобы получить доступ к зашифрованной истории сообщений, докажите, что это вы." + "Открыть существующий сеанс" + "Повторить подтверждение" + "Я готов" + "Ожидание соответствия…" + "Сравните уникальный набор эмодзи." + "Сравните уникальные смайлики, убедившись, что они расположены в том же порядке." + "Вход выполнен" + "Время ожидания подтверждения истекло, запрос был отклонён, или при подтверждении произошло несоответствие." + "Сбой проверки" + "Продолжайте только если вы ожидали данное подтверждение." + "Чтобы сохранить историю сообщений в безопасности, проверьте другое устройство." + "Теперь вы можете читать или отправлять сообщения безопасно в другом вашем устройстве." + "Устройство проверено" + "Запрошено подтверждение" + "Они не совпадают" + "Они совпадают" + "Прежде чем начать проверку, убедитесь, что приложение открыто на другом устройстве." + "Откройте приложение на другом проверенном устройстве" + "Для дополнительной безопасности проверьте этого пользователя, сравнив набор эмодзи на ваших устройствах. Сделайте это, используя надежный способ коммуникации." + "Проверить этого пользователя?" + "Для дополнительной безопасности другой пользователь хочет проверить вашу личность. Вам будет показан набор эмодзи для сравнения." + "Вы должны увидеть всплывающее окно на другом устройстве. Начните проверку оттуда прямо сейчас." + "Начать проверку на другом устройстве" + "Начать проверку на другом устройстве" + "Ожидание другого пользователя" + "После одобрения вы сможете продолжить проверку." + "Чтобы продолжить, примите запрос на запуск процесса подтверждения в другом сеансе." + "Ожидание принятия запроса" + "Выполняется выход…" + diff --git a/features/verifysession/impl/src/main/res/values-si/strings_sas.xml b/features/verifysession/impl/src/main/res/values-si/strings_sas.xml new file mode 100644 index 0000000..67d0775 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-si/strings_sas.xml @@ -0,0 +1,8 @@ + + + + බල්ලා + පූසා + සිංහයා + අශ්වයා + diff --git a/features/verifysession/impl/src/main/res/values-sk/strings_sas.xml b/features/verifysession/impl/src/main/res/values-sk/strings_sas.xml new file mode 100644 index 0000000..5e1eba9 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-sk/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Pes + Mačka + Lev + Kôň + Jednorožec + Prasa + Slon + Zajac + Panda + Kohút + Tučniak + Korytnačka + Ryba + Chobotnica + Motýľ + Kvet + Strom + Kaktus + Huba + Zemeguľa + Mesiac + Oblak + Oheň + Banán + Jablko + Jahoda + Kukurica + Pizza + Torta + Srdce + Smajlík + Robot + Klobúk + Okuliare + Vidlicový kľúč + Mikuláš + Palec nahor + Dáždnik + Presýpacie hodiny + Budík + Darček + Žiarovka + Kniha + Ceruzka + Kancelárska sponka + Nožnice + Zámka + Kľúč + Kladivo + Telefón + Zástava + Vlak + Bicykel + Lietadlo + Raketa + Trofej + Lopta + Gitara + Trúbka + Zvonec + Kotva + Slúchadlá + Fascikel + Špendlík + 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 0000000..932b855 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,54 @@ + + + "Nemôžete potvrdiť?" + "Vytvoriť nový kľúč na obnovenie" + "Ak chcete nastaviť zabezpečené správy, overte toto zariadenie." + "Potvrďte, že ste to vy" + "Použite iné zariadenie" + "Použiť kľúč na obnovenie" + "Teraz môžete bezpečne čítať alebo odosielať správy a tomuto zariadeniu môže dôverovať aj ktokoľvek, s kým konverzujete." + "Zariadenie overené" + "Použite iné zariadenie" + "Čaká sa na druhom zariadení…" + "Zdá sa, že niečo nie je v poriadku. Časový limit žiadosti vypršal alebo bola žiadosť zamietnutá." + "Potvrďte, že nižšie uvedené emotikony sa zhodujú s emotikonmi zobrazenými na vašom druhom zariadení." + "Porovnajte emotikony" + "Potvrďte, že emotikony uvedené nižšie zodpovedajú emotikonom zobrazeným na zariadení druhého používateľa." + "Skontrolujte, či sa nižšie uvedené čísla zhodujú s číslami zobrazenými na vašej druhej relácii." + "Porovnať čísla" + "Teraz môžete bezpečne čítať alebo odosielať správy na svojom druhom zariadení." + "Teraz môžete dôverovať identite tohto používateľa pri odosielaní alebo prijímaní správ." + "Zariadenie overené" + "Zadajte kľúč na obnovenie" + "Buď žiadosť vypršala, žiadosť bola zamietnutá, alebo došlo k nesúladu overovania." + "Dokážte, že ste to vy, aby ste získali prístup k histórii vašich zašifrovaných správ." + "Otvoriť existujúcu reláciu" + "Zopakovať overenie" + "Som pripravený/á" + "Čaká sa na zhodu" + "Porovnajte jedinečnú sadu emotikonov." + "Porovnajte jedinečné emotikony a uistite sa, že sú zobrazené v rovnakom poradí." + "Prihlásený" + "Buď žiadosť vypršala, žiadosť bola zamietnutá, alebo došlo k nesúladu overovania." + "Overenie zlyhalo" + "Pokračujte iba vtedy, ak ste toto overenie začali." + "Overte druhé zariadenie, aby bola vaša história správ zabezpečená." + "Teraz môžete bezpečne čítať alebo odosielať správy na svojom druhom zariadení." + "Zariadenie overené" + "Vyžadované overenie" + "Nezhodujú sa" + "Zhodujú sa" + "Pred začatím overovania odtiaľto sa uistite, že máte aplikáciu otvorenú v inom zariadení." + "Otvorte aplikáciu na inom overenom zariadení" + "Pre väčšiu bezpečnosť overte tohto používateľa porovnaním sady emotikonov vo vašich zariadeniach. Urobte to pomocou dôveryhodného spôsobu komunikácie." + "Overiť tohto používateľa?" + "Kvôli vyššej bezpečnosti chce druhý používateľ overiť vašu identitu. Zobrazí sa vám sada emotikonov na porovnanie." + "Na druhom zariadení by sa malo zobraziť vyskakovacie okno. Začnite teraz overovanie odtiaľ." + "Spustiť overovanie na druhom zariadení" + "Spustiť overovanie na druhom zariadení" + "Čaká sa na druhého používateľa" + "Po prijatí budete môcť pokračovať v overovaní." + "Ak chcete pokračovať, prijmite žiadosť o spustenie procesu overenia vo vašej druhej relácii." + "Čaká sa na prijatie žiadosti" + "Prebieha odhlasovanie…" + diff --git a/features/verifysession/impl/src/main/res/values-sq/strings_sas.xml b/features/verifysession/impl/src/main/res/values-sq/strings_sas.xml new file mode 100644 index 0000000..b305c97 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-sq/strings_sas.xml @@ -0,0 +1,67 @@ + + + + Qen + Mace + Luan + Kalë + Njëbrirësh + Derr + Elefant + Lepur + Panda + Këndes + Pinguin + Breshkë + Peshk + Oktapod + Flutur + Lule + Pemë + Kaktus + Kërpudhë + Rruzull + Hënë + Re + Zjarr + Banane + Mollë + Luleshtrydhe + Misër + Picë + Tortë + Zemër + Emotikon + Robot + Kapë + Syze + Çelës + Babagjyshi i Vitit të Ri + Ombrellë + Klepsidër + Sahat + Dhuratë + Llambë + Libër + Laps + Kapëse + Gërshërë + Dry + Çelës + Çekiç + Telefon + Flamur + Tren + Biçikletë + Avion + Raketë + Trofe + Top + Kitarë + Trombë + Kambanë + Spirancë + Kufje + Dosje + Karficë + diff --git a/features/verifysession/impl/src/main/res/values-sr/strings_sas.xml b/features/verifysession/impl/src/main/res/values-sr/strings_sas.xml new file mode 100644 index 0000000..f9e1c9d --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-sr/strings_sas.xml @@ -0,0 +1,68 @@ + + + + пас + мачка + лав + коњ + једнорог + прасе + слон + зец + панда + петао + пингвин + корњача + риба + октопод + лептир + цвет + дрво + кактус + печурка + глобус + месец + облак + ватра + банана + јабука + јагода + кукуруз + пица + торта + срце + смајли + робот + шешир + наочаре + кључ + деда Мраз + палчић горе + кишобран + пешчаник + сат + поклон + сијалица + књига + оловка + спајалица + маказе + катанац + кључ + чекић + телефон + застава + воз + бицикл + авион + ракета + пехар + лопта + гитара + труба + звоно + сидро + слушалице + фасцикла + чиода + diff --git a/features/verifysession/impl/src/main/res/values-sv/strings_sas.xml b/features/verifysession/impl/src/main/res/values-sv/strings_sas.xml new file mode 100644 index 0000000..02cf63c --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-sv/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Hund + Katt + Lejon + Häst + Enhörning + Gris + Elefant + Kanin + Panda + Tupp + Pingvin + Sköldpadda + Fisk + Bläckfisk + Fjäril + Blomma + Träd + Kaktus + Svamp + Jordklot + Måne + Moln + Eld + Banan + Äpple + Jordgubbe + Majs + Pizza + Tårta + Hjärta + Smiley + Robot + Hatt + Glasögon + Skruvnyckel + Tomte + Tummen upp + Paraply + Timglas + Klocka + Present + Lampa + Bok + Penna + Gem + Sax + Lås + Nyckel + Hammare + Telefon + Flagga + Tåg + Cykel + Flygplan + Raket + Trofé + Boll + Gitarr + Trumpet + Bjällra + Ankare + Hörlurar + Mapp + Häftstift + diff --git a/features/verifysession/impl/src/main/res/values-sv/translations.xml b/features/verifysession/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..53b26d7 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,54 @@ + + + "Kan du inte bekräfta?" + "Skapa en ny återställningsnyckel" + "Verifiera den här enheten för att konfigurera säkra meddelanden." + "Bekräfta att det är du" + "Använd en annan enhet" + "Använd återställningsnyckel" + "Nu kan du läsa eller skicka meddelanden säkert, och alla du chattar med kan också lita på den här enheten." + "Enhet verifierad" + "Använd en annan enhet" + "Väntar på annan enhet …" + "Något verkar inte stämma. Antingen gick tidsgränsen för begäran ut eller så avvisades begäran." + "Bekräfta att emojierna nedan matchar de som visas på din andra enhet." + "Jämför emojis" + "Bekräfta att emojierna nedan matchar de som visas på den andra användarens enhet." + "Bekräfta att siffrorna nedan matchar de som visas på din andra session." + "Jämför siffror" + "Din nya session är nu verifierad. Den har tillgång till dina krypterade meddelanden, och andra användare kommer att se den som betrodd." + "Nu kan du lita på användarens identitet när du skickar eller tar emot meddelanden." + "Enhet verifierad" + "Ange återställningsnyckel" + "Antingen överskreds tidsgränsen för begäran, begäran nekades eller så fanns det ett matchningsfel för verifieringen." + "Bevisa att det är du för att komma åt din krypterade meddelandehistorik." + "Öppna en befintlig session" + "Försök att verifiera igen" + "Jag är redo" + "Väntar på att matcha" + "Jämför en unik uppsättning emojis." + "Jämför de unika emojierna och se till att de visas i samma ordning." + "Inloggad" + "Antingen överskreds tidsgränsen för begäran, begäran nekades eller så fanns det ett matchningsfel för verifieringen." + "Verifiering misslyckades" + "Fortsätt bara om du initierade denna verifiering." + "Verifiera den andra enheten för att hålla din meddelandehistorik säker." + "Din nya session är nu verifierad. Den har tillgång till dina krypterade meddelanden, och andra användare kommer att se den som betrodd." + "Enhet verifierad" + "Verifiering begärd" + "De matchar inte" + "De matchar" + "Se till att du har appen öppen på den andra enheten innan du startar verifieringen härifrån." + "Öppna appen på en annan verifierad enhet" + "För extra säkerhet, verifiera den här användaren genom att jämföra en uppsättning emojier på dina enheter. Gör detta med hjälp av ett betrott kommunikationssätt." + "Verifiera den här användaren?" + "För extra säkerhet vill en annan användare verifiera din identitet. Du kommer att visas en uppsättning emojier att jämföra." + "Du bör se en popup på den andra enheten. Starta verifieringen därifrån nu." + "Starta verifieringen på den andra enheten" + "Starta verifieringen på den andra enheten" + "Väntar på den andra användaren" + "När det har accepterats kommer du kunna fortsätta verifieringen." + "Godkänn begäran om att starta verifieringsprocessen på din andra session för att fortsätta." + "Väntar på att acceptera begäran" + "Loggar ut …" + diff --git a/features/verifysession/impl/src/main/res/values-szl/strings_sas.xml b/features/verifysession/impl/src/main/res/values-szl/strings_sas.xml new file mode 100644 index 0000000..9769ad7 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-szl/strings_sas.xml @@ -0,0 +1,4 @@ + + + + diff --git a/features/verifysession/impl/src/main/res/values-tr/translations.xml b/features/verifysession/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..e718ee2 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,54 @@ + + + "Onaylayamıyor musunuz?" + "Yeni bir kurtarma anahtarı oluştur" + "Güvenli mesajlaşmayı ayarlamak için bu cihazı doğrulayın." + "Kimliğinizi doğrulayın" + "Başka bir cihaz kullan" + "Kurtarma anahtarı kullan" + "Artık mesajları güvenli bir şekilde okuyabilir veya gönderebilirsiniz ve sohbet ettiğiniz herkes de bu cihaza güvenebilir." + "Cihaz doğrulandı" + "Başka bir cihaz kullan" + "Diğer cihazda bekleniyor…" + "Bir şeyler doğru görünmüyor. İstek zaman aşımına uğradı veya istek reddedildi." + "Aşağıdaki emojilerin diğer oturumunuzda gösterilenlerle eşleştiğini onaylayın." + "Emojileri karşılaştırın" + "Aşağıdaki emojilerin diğer kullanıcının cihazında gösterilenlerle eşleştiğini onaylayın." + "Aşağıdaki sayıların diğer oturumunuzda gösterilen sayılarla eşleştiğini onaylayın." + "Sayıları karşılaştır" + "Yeni oturumunuz artık doğrulandı. Şifrelenmiş mesajlarınıza erişebilir ve diğer kullanıcılar oturumu güvenilir olarak görecektir." + "Artık mesaj gönderirken veya alırken bu kullanıcının kimliğine güvenebilirsiniz." + "Cihaz doğrulandı" + "Kurtarma anahtarını girin" + "İstek zaman aşımına uğradı, istek reddedildi veya bir doğrulama uyuşmazlığı vardı." + "Şifrelenmiş mesaj geçmişinize erişmek için siz olduğunuzu kanıtlayın." + "Mevcut bir oturumu aç" + "Doğrulamayı yeniden dene" + "Hazırım" + "Eşleşme bekleniyor…" + "Benzersiz bir emoji setini karşılaştır." + "Benzersiz emojileri karşılaştırın ve aynı sırayla göründüklerinden emin olun." + "Oturum açıldı" + "İstek zaman aşımına uğradı, istek reddedildi veya bir doğrulama uyuşmazlığı vardı." + "Doğrulama başarısız" + "Yalnızca bu doğrulamayı siz başlattıysanız devam edin." + "Mesaj geçmişinizi güvende tutmak için diğer cihazı doğrulayın." + "Yeni oturumunuz artık doğrulandı. Şifrelenmiş mesajlarınıza erişebilir ve diğer kullanıcılar oturumu güvenilir olarak görecektir." + "Cihaz doğrulandı" + "Doğrulama talep edildi" + "Eşleşmiyorlar" + "Eşleşiyorlar" + "Buradan doğrulamaya başlamadan önce uygulamanın diğer cihazda açık olduğundan emin olun." + "Uygulamayı doğrulanmış başka bir cihazda açın" + "Daha fazla güvenlik için, cihazlarınızdaki bir dizi emojiyi karşılaştırarak bu kullanıcıyı doğrulayın. Bunu, iletişim kurmak için güvenilir bir yol kullanarak yapın." + "Bu kullanıcıyı doğrula?" + "Ekstra güvenlik için, başka bir kullanıcı kimliğinizi doğrulamak istiyor. Karşılaştırmanız için size bir dizi emoji gösterilecektir." + "Diğer cihazda bir açılır pencere görmelisiniz. Doğrulamayı şimdi oradan başlatın." + "Diğer cihazda doğrulamayı başlat" + "Diğer cihazda doğrulamayı başlat" + "Diğer kullanıcı bekleniyor" + "Kabul edildikten sonra doğrulama işlemine devam edebileceksiniz." + "Devam etmek için diğer oturumunuzda doğrulama işlemini başlatma isteğini kabul edin." + "İsteğin kabul edilmesi bekleniyor" + "Oturum kapatılıyor…" + diff --git a/features/verifysession/impl/src/main/res/values-tzm/strings_sas.xml b/features/verifysession/impl/src/main/res/values-tzm/strings_sas.xml new file mode 100644 index 0000000..6d1567d --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-tzm/strings_sas.xml @@ -0,0 +1,30 @@ + + + + Aydi + Amuc + Izem + Ayyis + Ilef + Ilu + Agnin + Ayaẓiḍ + Ifker + Aselm + Aseklu + Agursel + Ayyur + Timessi + Tabanant + Tadeffuyt + Ul + Aṛubu + Taraza + Adlis + Tasarut + Atilifun + Acenyal + Tcama + Agiṭaṛ + Asdaw + diff --git a/features/verifysession/impl/src/main/res/values-uk/strings_sas.xml b/features/verifysession/impl/src/main/res/values-uk/strings_sas.xml new file mode 100644 index 0000000..bfeaa3a --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-uk/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Пес + Кіт + Лев + Кінь + Єдиноріг + Свиня + Слон + Кріль + Панда + Когут + Пінгвін + Черепаха + Риба + Восьминіг + Метелик + Квітка + Дерево + Кактус + Гриб + Глобус + Місяць + Хмара + Вогонь + Банан + Яблуко + Полуниця + Кукурудза + Піца + Пиріг + Серце + Посмішка + Робот + Капелюх + Окуляри + Гайковий ключ + Санта Клаус + Великий палець вгору + Парасолька + Пісковий годинник + Годинник + Подарунок + Лампочка + Книга + Олівець + Спиначка + Ножиці + Замок + Ключ + Молоток + Телефон + Прапор + Потяг + Велосипед + Літак + Ракета + Приз + М\'яч + Гітара + Труба + Дзвін + Якір + Навушники + Тека + Кнопка + diff --git a/features/verifysession/impl/src/main/res/values-uk/translations.xml b/features/verifysession/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..e28304d --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,54 @@ + + + "Не можете підтвердити?" + "Створити новий ключ відновлення" + "Верифікуйте цей пристрій, щоб налаштувати безпечний обмін повідомленнями." + "Підтвердьте, що це ви" + "Використовуйте інший пристрій" + "Використовуйте ключ відновлення" + "Тепер ви можете безпечно читати або надсилати повідомлення, і кожен, з ким ви спілкуєтесь, також може довіряти цьому пристрою." + "Пристрій перевірено" + "Використовуйте інший пристрій" + "Чекає на інше пристрій…" + "Щось не так. Або час очікування запиту минув, або в запиті було відмовлено." + "Переконайтеся, що емодзі нижче збігаються з тими, що відображаються під час іншого сеансу." + "Порівняти емодзі" + "Переконайтеся, що наведені емоджі збігаються з тими, що показані на пристрої іншого користувача." + "Переконайтеся, що наведені нижче цифри збігаються з тими, що показані під час вашого іншого сеансу." + "Порівняйте цифри" + "Ваш новий сеанс підтверджено. Він матиме доступ до ваших зашифрованих повідомлень, й інші користувачі вважатимуть його надійним." + "Тепер ви можете довіряти особистості цього користувача під час надсилання або отримання повідомлень." + "Пристрій перевірено" + "Введіть ключ відновлення" + "Або час очікування запиту минув, або запит було відхилено, або виникла розбіжність у верифікації." + "Доведіть, що це ви, щоб отримати доступ до історії зашифрованих повідомлень." + "Відкрийте активний сеанс" + "Повторити верифікацію" + "У мене все готово" + "Очікування збігу" + "Порівняйте унікальний набір емоджі." + "Порівняйте унікальні емодзі, переконавшись, що вони показані в однаковому порядку." + "Увійшов" + "Або час очікування запиту минув, або запит було відхилено, або виникла розбіжність у верифікації." + "Перевірка не вдалася" + "Продовжуйте, лише якщо ви ініціювали цю перевірку." + "Перевірте інший пристрій, щоб захистити історію повідомлень." + "Ваш новий сеанс підтверджено. Він матиме доступ до ваших зашифрованих повідомлень, й інші користувачі вважатимуть його надійним." + "Пристрій перевірено" + "Запитано на верифікацію" + "Вони не збігаються" + "Вони збігаються" + "Перш ніж починати перевірку звідси, переконайтеся, що програму відкрито на іншому пристрої." + "Відкрийте додаток на іншому перевіреному пристрої" + "Для додаткової безпеки верифікуйте цього користувача, порівнявши набір емоджі на ваших пристроях. Зробіть це, використовуючи надійний спосіб спілкування." + "Верифікувати цього користувача?" + "Для додаткової безпеки інший користувач хоче верифікувати вашу особистість. Вам буде показано набір емоджі для порівняння." + "Ви повинні побачити спливаюче вікно на іншому пристрої. Почніть перевірку звідти." + "Почніть перевірку на іншому пристрої" + "Почніть перевірку на іншому пристрої" + "Очікування іншого користувача" + "Після погодження ви зможете продовжити верифікацію." + "Щоб продовжити, прийміть запит на початок процесу верифікації в іншому сеансі." + "Очікування на прийняття запиту" + "Вихід…" + diff --git a/features/verifysession/impl/src/main/res/values-ur/translations.xml b/features/verifysession/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..0697e10 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,35 @@ + + + "تصدیق نہیں کر سکتے؟" + "ایک نئی بازیابی کلید تخلیق کریں" + "محفوظ پیغام رسانی ترتیب دینے کیلئے اس آلے کی توثیق کریں۔" + "اپنی شناخت کی تصدیق کریں" + "دوسرا آلہ استعمال کریں" + "بازیابی کلید استعمال کریں" + "اب آپ محفوظ طریقے سے پیغامات پڑھ یا بھیج سکتے ہیں، اور جسکے ساتھ آپ گفتگو کرتے ہیں وہ بھی اس آلہ پر بھروسہ کر سکتا ہے۔" + "آلہ توثیق شدہ" + "دوسرا آلہ استعمال کریں" + "دوسرے آلہ پر منتظر…" + "کچھ ٹھیک نہیں لگتا۔ یا تو درخواست کا وقت ختم ہو گیا یا درخواست مسترد کر دی گئی۔" + "تصدیق کریں کہ نیچے ایموجی آپ کے دوسرے جلسے میں دکھائے گئے ایموجیوں سے مماثل ہیں۔" + "ایموجیوں کا موازنہ کریں۔" + "تصدیق کریں کہ نیچے دیے گئے اعداد آپکے دوسرے جلسے میں دکھائے گئے اعداد سے مماثل ہیں۔" + "اعداد کا موازنہ کریں" + "آپ کا نیا جلسہ اب توثیق شدہ ہے۔ اسے آپ کے مرموزکردہ پیغامات تک رسائی حاصل ہے، اور دوسرے صارفین اسے بھروسہ مند کے طور پر دیکھیں گے۔" + "آلہ توثیق شدہ" + "بازیابی کلید درج کریں" + "اپنی مرموزکردہ پیغام کی سرگزشت تک رسائی حاصل کرنے کے لیے ثابت کریں کہ یہ آپ ہی ہیں۔" + "ایک موجودہ جلسہ کھولیں" + "توثیق کی پھر کوشش کریں" + "میں تیار ہوں" + "ملانے کا انتظار" + "رموز تعبیری کے منفرد مجموعہ کا موازنہ کریں۔" + "منفرد ایموجی کا موازنہ کریں، یقینی بناتے ہوئے کہ وہ ایک ہی ترتیب میں دکھائی دیں۔" + "آپ کا نیا جلسہ اب توثیق شدہ ہے۔ اسے آپ کے مرموزکردہ پیغامات تک رسائی حاصل ہے، اور دوسرے صارفین اسے بھروسہ مند کے طور پر دیکھیں گے۔" + "آلہ توثیق شدہ" + "وہ مماثل نہیں ہیں" + "وہ مماثل ہیں" + "جاری رکھنے کیلئے اپنے دوسرے جلسے میں توثیقی عمل شروع کرنے کی درخواست کو قبول کریں۔" + "درخواست قبول کرنے کا منتظر" + "خارج ہورہاہے…" + diff --git a/features/verifysession/impl/src/main/res/values-uz/translations.xml b/features/verifysession/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..04169a7 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,54 @@ + + + "Tasdiqlay olmayapsizmi?" + "Yangi tiklash kalitini yarating" + "Xavfsiz xabarlashuvni sozlash uchun ushbu qurilmani tasdiqlang." + "Shaxsingizni tasdiqlang" + "Boshqa qurilmadan foydalanish" + "Qayta tiklash kalitidan foydalaning" + "Endi xabarlarni xavfsiz tarzda o‘qish yoki yuborish imkoniyatiga egasiz, shuningdek, siz bilan muloqot qilayotgan har qanday kishi ham bu qurilmaga ishonch bildirishi mumkin." + "Qurilma tasdiqlandi" + "Boshqa qurilmadan foydalanish" + "Boshqa qurilmada kutilmoqda…" + "Nimadir noto‘g‘ri ko‘rinadi. Yoki so‘rov muddati tugadi yoki so‘rov rad etildi." + "Quyidagi kulgichlar boshqa seansda ko‘rsatilganlarga mos kelishini tasdiqlang." + "Emojilarni solishtiring" + "Quyidagi emojilar narigi foydalanuvchining qurilmasida ko‘rsatilgan emojilarga mos kelishini tasdiqlang." + "Quyidagi raqamlarning boshqa sessiyangizda koʻrsatilgan raqamlarga mos kelishini tasdiqlang." + "Sonlarni taqqoslash" + "Yangi seansingiz tasdiqlandi. U sizning shifrlangan xabarlaringizga kirish huquqiga ega va boshqa foydalanuvchilar uni ishonchli deb bilishadi." + "Endi xabarlarni yuborish yoki qabul qilishda bu foydalanuvchining shaxsiga ishonishingiz mumkin." + "Qurilma tasdiqlandi" + "Tiklash kalitini kiriting" + "So‘rov vaqti tugab qoldi, so‘rov rad etildi yoki tekshiruv mos kelmadi." + "Shifrlangan xabarlar tarixiga kirish uchun shaxsingizni tasdiqlang." + "Mavjud seansni oching" + "Tasdiqlashni qaytadan urining" + "Men tayyorman" + "Mos kelishi kutilmoqda" + "Emojilarning noyob toʻplamini solishtiring." + "Noyob emojilarni solishtiring, ular bir xil tartibda paydo bo\'lishiga ishonch hosil qiling." + "Tizimga kirildi" + "So‘rov vaqti tugab qoldi, so‘rov rad etildi yoki tekshiruv mos kelmadi." + "Tasdiqlanmadi" + "Bu tekshiruvni boshlagan bo‘lsangizgina davom eting." + "Xabarlaringiz tarixini xavfsiz saqlash uchun narigi qurilmani tasdiqlang." + "Yangi seansingiz tasdiqlandi. U sizning shifrlangan xabarlaringizga kirish huquqiga ega va boshqa foydalanuvchilar uni ishonchli deb bilishadi." + "Qurilma tasdiqlandi" + "Tasdiqlash talab qilindi" + "Ular mos kelmaydi" + "Ular mos keladi" + "Bu yerdan tasdiqlashni boshlashdan oldin, boshqa qurilmada ilovaning ochiq ekanligiga ishonch hosil qiling." + "Ilovani boshqa tasdiqlangan qurilmada oching" + "Qo‘shimcha xavfsizlik chorasi sifatida, qurilmalaringizdagi emojilar to‘plamini solishtirish orqali ushbu foydalanuvchini tasdiqlang. Buni ishonchli aloqa usuli yordamida amalga oshiring." + "Bu foydalanuvchi tasdiqlansinmi?" + "Qo‘shimcha xavfsizlik maqsadida, boshqa foydalanuvchi sizning shaxsingizni tasdiqlashni xohlaydi. Taqqoslash uchun sizga bir qator emojilar ko‘rsatiladi." + "Boshqa qurilmangizda qalqib chiquvchi oyna paydo bo‘lishi kerak. Tekshirish jarayonini o‘sha yerdan boshlang." + "Boshqa qurilmada tekshirishni boshlang" + "Boshqa qurilmada tekshirishni boshlang" + "Boshqa foydalanuvchi kutilmoqda" + "Qabul qilinganingizdan so‘ng, tasdiqlash jarayonini davom ettirishingiz mumkin bo‘ladi." + "Davom etish uchun boshqa seansda tekshirish jarayonini boshlash soʻrovini qabul qiling." + "Soʻrovni qabul qilish kutilmoqda" + "Chiqish…" + diff --git a/features/verifysession/impl/src/main/res/values-vi/strings_sas.xml b/features/verifysession/impl/src/main/res/values-vi/strings_sas.xml new file mode 100644 index 0000000..dbf7db0 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-vi/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Chó + Mèo + Sư tử + Ngựa + Kỳ lân + Heo + Voi + Thỏ + Gấu trúc + Gà trống + Chim cánh cụt + Rùa + + Bạch tuộc + Bướm + Hoa + Cây + Xương rồng + Nấm + Địa cầu + Mặt trăng + Mây + Lửa + Chuối + Táo + Dâu tây + Bắp + Pizza + Bánh + Tim + Mặt cười + Rô-bô + + Kính mắt + Cờ-lê + ông già Nô-en + Thích + Cái ô + Đồng hồ cát + Đồng hồ + Quà tặng + Bóng đèn tròn + Sách + Viết chì + Kẹp giấy + Cái kéo + Ổ khóa + Chìa khóa + Búa + Điện thoại + Lá cờ + Xe lửa + Xe đạp + Máy bay + Tên lửa + Cúp + Banh + Ghi-ta + Kèn + Chuông + Mỏ neo + Tai nghe + Thư mục + Ghim + diff --git a/features/verifysession/impl/src/main/res/values-zh-rCN/strings_sas.xml b/features/verifysession/impl/src/main/res/values-zh-rCN/strings_sas.xml new file mode 100644 index 0000000..39c2ff1 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-zh-rCN/strings_sas.xml @@ -0,0 +1,68 @@ + + + + + + 狮子 + + 独角兽 + + 大象 + 兔子 + 熊猫 + 公鸡 + 企鹅 + 乌龟 + + 章鱼 + 蝴蝶 + + + 仙人掌 + 蘑菇 + 地球 + 月亮 + + + 香蕉 + 苹果 + 草莓 + 玉米 + 披萨 + 蛋糕 + + 笑脸 + 机器人 + 帽子 + 眼镜 + 扳手 + 圣诞老人 + + + 沙漏 + 时钟 + 礼物 + 灯泡 + + 铅笔 + 回形针 + 剪刀 + + 钥匙 + 锤子 + 电话 + 旗帜 + 火车 + 自行车 + 飞机 + 火箭 + 奖杯 + + 吉他 + 喇叭 + 铃铛 + + 耳机 + 文件夹 + 图钉 + diff --git a/features/verifysession/impl/src/main/res/values-zh-rTW/strings_sas.xml b/features/verifysession/impl/src/main/res/values-zh-rTW/strings_sas.xml new file mode 100644 index 0000000..b7068b7 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-zh-rTW/strings_sas.xml @@ -0,0 +1,68 @@ + + + + + + 獅子 + + 獨角獸 + + 大象 + 兔子 + 熊貓 + 公雞 + 企鵝 + 烏龜 + + 章魚 + 蝴蝶 + + + 仙人掌 + 蘑菇 + 地球 + 月亮 + 雲朵 + + 香蕉 + 蘋果 + 草莓 + 玉米 + 披薩 + 蛋糕 + 愛心 + 笑臉 + 機器人 + 帽子 + 眼鏡 + 扳手 + 聖誕老人 + + 雨傘 + 沙漏 + 時鐘 + 禮物 + 燈泡 + + 鉛筆 + 迴紋針 + 剪刀 + 鎖頭 + 鑰匙 + 鎚子 + 電話 + 旗幟 + 火車 + 腳踏車 + 飛機 + 火箭 + 獎盃 + 足球 + 吉他 + 喇叭 + 鈴鐺 + 船錨 + 耳機 + 資料夾 + 圖釘 + diff --git a/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml b/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..658e524 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,54 @@ + + + "無法確認?" + "建立新的復原金鑰" + "驗證這部裝置以設定安全通訊。" + "確認這是你本人" + "使用另一部裝置" + "使用復原金鑰" + "您可以安全地讀取和發送訊息了,與您聊天的人也可以信任這部裝置。" + "裝置已驗證" + "使用另一部裝置" + "正在等待其他裝置…" + "似乎出了一點問題。有可能是因為等候逾時,或是請求被拒絕。" + "請確認以下表情符號是否與您其他裝置上顯示的符號相符。" + "比對表情符號" + "確認下方的表情符號與其他使用者裝置上顯示的表情符號相符。" + "確認以下數字是否與其他作業階段中顯示的數字相符。" + "比較數字" + "現在您可以在其他裝置上安全地閱讀或傳送訊息。" + "現在,您可以在傳送或接收訊息時信任此使用者的身份。" + "裝置已驗證" + "輸入復原金鑰" + "請求逾時、請求被拒或是驗證不符。" + "為了存取被加密的歷史訊息,您需要證明這是您本人。" + "開啟一個現存的工作階段" + "重新嘗試驗證" + "我準備好了" + "等待比對" + "比對一組唯一的表情符號。" + "表情符號是唯一的,請相互比對,確認它們的排列順序是否相同。" + "已登入" + "請求逾時、請求被拒或是驗證不符。" + "驗證失敗。" + "僅當您啟動此驗證時才繼續。" + "驗證其他裝置以保護您的訊息歷史紀錄安全。" + "現在您可以在其他裝置上安全地閱讀或傳送訊息。" + "裝置已驗證" + "已請求驗證" + "不一樣" + "一樣" + "從這裡開始驗證之前,請確保您在其他裝置中開啟了應用程式。" + "在另外一個已驗證的裝置上開啟應用程式" + "為了提昇安全性,請透過比較您裝置上的一組表情符號來驗證此使用者。請透過可信的通訊方式來執行此動作。" + "驗證此使用者?" + "為了提昇安全性,另一個使用者希望驗證您的身份。您將會看到一組表情符號以進行比較。" + "您應該會在其他裝置上看到一個彈出式視窗。立刻從那裡開始驗證。" + "在其他裝置上開始驗證" + "在其他裝置上開始驗證" + "正在等帶齊他使用者" + "接受後,您就可以繼續進行驗證。" + "準備開始驗證,請到您的其他工作階段接受請求。" + "等待接受請求" + "正在登出…" + diff --git a/features/verifysession/impl/src/main/res/values-zh/translations.xml b/features/verifysession/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..54b6de5 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,54 @@ + + + "无法确认?" + "创建新的恢复密钥" + "验证此设备以开始安全地收发消息。" + "确认这是你" + "使用其他设备" + "使用恢复密钥" + "现在,您可以安全地阅读或发送消息,与您聊天的人也会信任此设备。" + "设备已验证" + "使用其他设备" + "正在等待其他设备……" + "发生了一些错误。网络请求超时,或者被服务器拒绝。" + "确认下面的表情符号与您其他设备上显示的表情符号相匹配。" + "比较表情符号" + "请验证下方表情是否与对方设备显示一致" + "确认以下数字与其他会话中显示的一致。" + "比较数字" + "现在您可以在其他设备上安全地阅读或发送消息。" + "现在您可以在发送或接收消息时信任该用户的身份。" + "设备已验证" + "输入恢复密钥" + "要么请求超时,要么请求被拒绝,要么验证不匹配。" + "证明自己的身份以访问加密历史消息。" + "打开已有会话" + "重试验证" + "准备就绪" + "等待比对……" + "比较一组表情符号。" + "比较表情符号,确保它们以相同顺序排列。" + "已登录" + "要么请求超时,要么请求被拒绝,要么验证不匹配。" + "验证失败" + "仅在你发起此验证后才继续。" + "验证另一台设备以确保您的消息历史记录保密。" + "现在您可以在其他设备上安全地阅读或发送消息。" + "设备已验证" + "已请求验证" + "不匹配" + "匹配" + "从此处开始验证之前,请确保您已在其他设备上打开了该应用程序。" + "在另一台验证的设备上打开应用" + "为了提高安全性,请通过比较设备上的一组表情符号来验证此用户。通过使用安全方式来做到这一点,如面对面。" + "验证此用户?" + "为了提高安全性,另一位用户想要验证您的身份。您将看到一组表情符号供您比较。" + "您应该会在另一台设备上看到一个弹出窗口。现在从那里开始验证。" + "在另一台设备上开始验证" + "在另一台设备上开始验证" + "等待其他用户" + "一旦被接受,您将能够继续进行验证。" + "请在其他会话中接受验证请求。" + "等待接受请求" + "正在登出…" + 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 0000000..6bab676 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values/localazy.xml @@ -0,0 +1,54 @@ + + + "Can\'t confirm?" + "Create a new recovery key" + "Verify this device to set up secure messaging." + "Confirm your identity" + "Use another device" + "Use recovery key" + "Now you can read or send messages securely, and anyone you chat with can also trust this device." + "Device verified" + "Use another device" + "Waiting on other device…" + "Something doesn’t seem right. Either the request timed out or the request was denied." + "Confirm that the emojis below match those shown on your other device." + "Compare emojis" + "Confirm that the emojis below match those shown on the other user’s device." + "Confirm that the numbers below match those shown on your other session." + "Compare numbers" + "Now you can read or send messages securely on your other device." + "Now you can trust the identity of this user when sending or receiving messages." + "Device verified" + "Enter recovery key" + "Either the request timed out, the request was denied, or there was a verification mismatch." + "Prove it’s you in order to access your encrypted message history." + "Open an existing session" + "Retry verification" + "I am ready" + "Waiting to match…" + "Compare a unique set of emojis." + "Compare the unique emoji, ensuring they appear in the same order." + "Signed in" + "Either the request timed out, the request was denied, or there was a verification mismatch." + "Verification failed" + "Only continue if you initiated this verification." + "Verify the other device to keep your message history secure." + "Now you can read or send messages securely on your other device." + "Device verified" + "Verification requested" + "They don’t match" + "They match" + "Make sure you have the app open in the other device before starting verification from here." + "Open the app on another verified device" + "For extra security, verify this user by comparing a set of emojis on your devices. Do this by using a trusted way to communicate." + "Verify this user?" + "For extra security, another user wants to verify your identity. You’ll be shown a set of emojis to compare." + "You should see a popup on the other device. Start the verification from there now." + "Start verification on the other device" + "Start verification on the other device" + "Waiting for the other user" + "Once accepted you’ll be able to continue with the verification." + "Accept the request to start the verification process in your other session to continue." + "Waiting to accept request" + "Signing out…" + diff --git a/features/verifysession/impl/src/main/res/values/strings_sas.xml b/features/verifysession/impl/src/main/res/values/strings_sas.xml new file mode 100644 index 0000000..db6b545 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values/strings_sas.xml @@ -0,0 +1,68 @@ + + + + Dog + Cat + Lion + Horse + Unicorn + Pig + Elephant + Rabbit + Panda + Rooster + Penguin + Turtle + Fish + Octopus + Butterfly + Flower + Tree + Cactus + Mushroom + Globe + Moon + Cloud + Fire + Banana + Apple + Strawberry + Corn + Pizza + Cake + Heart + Smiley + Robot + Hat + Glasses + Spanner + Santa + Thumbs Up + Umbrella + Hourglass + Clock + Gift + Light Bulb + Book + Pencil + Paperclip + Scissors + Lock + Key + Hammer + Telephone + Flag + Train + Bicycle + Aeroplane + Rocket + Trophy + Ball + Guitar + Trumpet + Bell + Anchor + Headphones + Folder + Pin + diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/DefaultIncomingVerificationEntryPointTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/DefaultIncomingVerificationEntryPointTest.kt new file mode 100644 index 0000000..9fc7858 --- /dev/null +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/DefaultIncomingVerificationEntryPointTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.incoming + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DefaultIncomingVerificationEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `test node builder`() = runTest { + val entryPoint = DefaultIncomingVerificationEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + IncomingVerificationNode( + buildContext = buildContext, + plugins = plugins, + presenterFactory = { _, _ -> createPresenter() } + ) + } + val callback = object : IncomingVerificationEntryPoint.Callback { + override fun onDone() = lambdaError() + } + val params = IncomingVerificationEntryPoint.Params( + verificationRequest = anIncomingSessionVerificationRequest() + ) + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + params = params, + callback = callback, + ) + assertThat(result).isInstanceOf(IncomingVerificationNode::class.java) + assertThat(result.plugins).contains(params) + assertThat(result.plugins).contains(callback) + } +} diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt new file mode 100644 index 0000000..c9b9e25 --- /dev/null +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt @@ -0,0 +1,322 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.incoming + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.test.FakeDateFormatter +import io.element.android.libraries.matrix.api.core.FlowId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails +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.VerificationRequest +import io.element.android.libraries.matrix.test.A_DEVICE_ID +import io.element.android.libraries.matrix.test.A_TIMESTAMP +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +@ExperimentalCoroutinesApi +class IncomingVerificationPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - nominal case - incoming verification successful`() = runTest { + val acknowledgeVerificationRequestLambda = lambdaRecorder { _ -> } + val acceptVerificationRequestLambda = lambdaRecorder { } + val approveVerificationLambda = lambdaRecorder { } + val resetLambda = lambdaRecorder { } + val fakeSessionVerificationService = FakeSessionVerificationService( + acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda, + acceptVerificationRequestLambda = acceptVerificationRequestLambda, + approveVerificationLambda = approveVerificationLambda, + resetLambda = resetLambda, + ) + createPresenter( + service = fakeSessionVerificationService, + ).test { + val initialState = awaitItem() + assertThat(initialState.step).isEqualTo( + IncomingVerificationState.Step.Initial( + deviceDisplayName = "a device name", + deviceId = A_DEVICE_ID, + formattedSignInTime = "567 TimeOrDate false", + isWaiting = false, + ) + ) + + advanceTimeBy(1.seconds) + + resetLambda.assertions().isCalledOnce().with(value(false)) + acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(anIncomingSessionVerificationRequest)) + acceptVerificationRequestLambda.assertions().isNeverCalled() + // User accept the incoming verification + initialState.eventSink(IncomingVerificationViewEvents.StartVerification) + skipItems(1) + val initialWaitingState = awaitItem() + assertThat((initialWaitingState.step as IncomingVerificationState.Step.Initial).isWaiting).isTrue() + + advanceTimeBy(1.seconds) + + acceptVerificationRequestLambda.assertions().isCalledOnce() + // Remote sent the data + fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest) + fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification) + fakeSessionVerificationService.emitVerificationFlowState( + VerificationFlowState.DidReceiveVerificationData( + data = aEmojisSessionVerificationData() + ) + ) + val emojiState = awaitItem() + assertThat(emojiState.step).isEqualTo( + IncomingVerificationState.Step.Verifying( + data = aEmojisSessionVerificationData(), + isWaiting = false + ) + ) + // User claims that the emoji matches + emojiState.eventSink(IncomingVerificationViewEvents.ConfirmVerification) + val emojiWaitingItem = awaitItem() + assertThat((emojiWaitingItem.step as IncomingVerificationState.Step.Verifying).isWaiting).isTrue() + approveVerificationLambda.assertions().isCalledOnce() + // Remote confirm that the emojis match + fakeSessionVerificationService.emitVerificationFlowState( + VerificationFlowState.DidFinish + ) + val finalItem = awaitItem() + assertThat(finalItem.step).isEqualTo(IncomingVerificationState.Step.Completed) + } + } + + @Test + fun `present - emoji not matching case - incoming verification failure`() = runTest { + val acknowledgeVerificationRequestLambda = lambdaRecorder { _ -> } + val acceptVerificationRequestLambda = lambdaRecorder { } + val declineVerificationLambda = lambdaRecorder { } + val resetLambda = lambdaRecorder { } + val fakeSessionVerificationService = FakeSessionVerificationService( + acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda, + acceptVerificationRequestLambda = acceptVerificationRequestLambda, + declineVerificationLambda = declineVerificationLambda, + resetLambda = resetLambda, + ) + createPresenter( + service = fakeSessionVerificationService, + ).test { + val initialState = awaitItem() + assertThat(initialState.step).isEqualTo( + IncomingVerificationState.Step.Initial( + deviceDisplayName = "a device name", + deviceId = A_DEVICE_ID, + formattedSignInTime = "567 TimeOrDate false", + isWaiting = false, + ) + ) + + advanceTimeBy(1.seconds) + + resetLambda.assertions().isCalledOnce().with(value(false)) + acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(anIncomingSessionVerificationRequest)) + acceptVerificationRequestLambda.assertions().isNeverCalled() + // User accept the incoming verification + initialState.eventSink(IncomingVerificationViewEvents.StartVerification) + skipItems(1) + val initialWaitingState = awaitItem() + assertThat((initialWaitingState.step as IncomingVerificationState.Step.Initial).isWaiting).isTrue() + + advanceTimeBy(1.seconds) + + acceptVerificationRequestLambda.assertions().isCalledOnce() + // Remote sent the data + fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest) + fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification) + fakeSessionVerificationService.emitVerificationFlowState( + VerificationFlowState.DidReceiveVerificationData( + data = aEmojisSessionVerificationData() + ) + ) + val emojiState = awaitItem() + // User claims that the emojis do not match + emojiState.eventSink(IncomingVerificationViewEvents.DeclineVerification) + val emojiWaitingItem = awaitItem() + assertThat((emojiWaitingItem.step as IncomingVerificationState.Step.Verifying).isWaiting).isTrue() + declineVerificationLambda.assertions().isCalledOnce() + // Remote confirm that there is a failure + fakeSessionVerificationService.emitVerificationFlowState( + VerificationFlowState.DidFail + ) + val finalItem = awaitItem() + assertThat(finalItem.step).isEqualTo(IncomingVerificationState.Step.Failure) + } + } + + @Test + fun `present - incoming verification is remotely canceled`() = runTest { + val acknowledgeVerificationRequestLambda = lambdaRecorder { _ -> } + val acceptVerificationRequestLambda = lambdaRecorder { } + val declineVerificationLambda = lambdaRecorder { } + val resetLambda = lambdaRecorder { } + val onFinishLambda = lambdaRecorder { } + val fakeSessionVerificationService = FakeSessionVerificationService( + acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda, + acceptVerificationRequestLambda = acceptVerificationRequestLambda, + declineVerificationLambda = declineVerificationLambda, + resetLambda = resetLambda, + ) + createPresenter( + service = fakeSessionVerificationService, + navigator = IncomingVerificationNavigator(onFinishLambda), + ).test { + val initialState = awaitItem() + assertThat(initialState.step).isEqualTo( + IncomingVerificationState.Step.Initial( + deviceDisplayName = "a device name", + deviceId = A_DEVICE_ID, + formattedSignInTime = "567 TimeOrDate false", + isWaiting = false, + ) + ) + // Remote cancel the verification request + fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidCancel) + // The screen is dismissed + skipItems(2) + + advanceUntilIdle() + + onFinishLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - user goes back when comparing emoji - incoming verification failure`() = runTest { + val acknowledgeVerificationRequestLambda = lambdaRecorder { _ -> } + val acceptVerificationRequestLambda = lambdaRecorder { } + val declineVerificationLambda = lambdaRecorder { } + val resetLambda = lambdaRecorder { } + val fakeSessionVerificationService = FakeSessionVerificationService( + acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda, + acceptVerificationRequestLambda = acceptVerificationRequestLambda, + declineVerificationLambda = declineVerificationLambda, + resetLambda = resetLambda, + ) + createPresenter( + service = fakeSessionVerificationService, + ).test { + val initialState = awaitItem() + assertThat(initialState.step).isEqualTo( + IncomingVerificationState.Step.Initial( + deviceDisplayName = "a device name", + deviceId = A_DEVICE_ID, + formattedSignInTime = "567 TimeOrDate false", + isWaiting = false, + ) + ) + + advanceTimeBy(1.seconds) + + resetLambda.assertions().isCalledOnce().with(value(false)) + acknowledgeVerificationRequestLambda.assertions().isCalledOnce().with(value(anIncomingSessionVerificationRequest)) + acceptVerificationRequestLambda.assertions().isNeverCalled() + // User accept the incoming verification + initialState.eventSink(IncomingVerificationViewEvents.StartVerification) + skipItems(1) + val initialWaitingState = awaitItem() + assertThat((initialWaitingState.step as IncomingVerificationState.Step.Initial).isWaiting).isTrue() + + advanceTimeBy(1.seconds) + + acceptVerificationRequestLambda.assertions().isCalledOnce() + // Remote sent the data + fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest) + fakeSessionVerificationService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification) + fakeSessionVerificationService.emitVerificationFlowState( + VerificationFlowState.DidReceiveVerificationData( + data = aEmojisSessionVerificationData() + ) + ) + val emojiState = awaitItem() + // User goes back + emojiState.eventSink(IncomingVerificationViewEvents.GoBack) + val emojiWaitingItem = awaitItem() + assertThat((emojiWaitingItem.step as IncomingVerificationState.Step.Verifying).isWaiting).isTrue() + declineVerificationLambda.assertions().isCalledOnce() + // Remote confirm that there is a failure + fakeSessionVerificationService.emitVerificationFlowState( + VerificationFlowState.DidFail + ) + val finalItem = awaitItem() + assertThat(finalItem.step).isEqualTo(IncomingVerificationState.Step.Failure) + } + } + + @Test + fun `present - user ignores incoming request`() = runTest { + val acknowledgeVerificationRequestLambda = lambdaRecorder { _ -> } + val acceptVerificationRequestLambda = lambdaRecorder { } + val resetLambda = lambdaRecorder { } + val fakeSessionVerificationService = FakeSessionVerificationService( + acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda, + acceptVerificationRequestLambda = acceptVerificationRequestLambda, + resetLambda = resetLambda, + ) + val navigatorLambda = lambdaRecorder { } + createPresenter( + service = fakeSessionVerificationService, + navigator = IncomingVerificationNavigator(navigatorLambda), + ).test { + val initialState = awaitItem() + initialState.eventSink(IncomingVerificationViewEvents.IgnoreVerification) + skipItems(1) + navigatorLambda.assertions().isCalledOnce() + } + } +} + +private val anIncomingSessionVerificationRequest = VerificationRequest.Incoming.OtherSession( + details = SessionVerificationRequestDetails( + senderProfile = MatrixUser( + userId = A_USER_ID, + displayName = "a user name", + avatarUrl = null, + ), + flowId = FlowId("flowId"), + deviceId = A_DEVICE_ID, + deviceDisplayName = "a device name", + firstSeenTimestamp = A_TIMESTAMP, + ) +) + +internal fun TestScope.createPresenter( + verificationRequest: VerificationRequest.Incoming = anIncomingSessionVerificationRequest, + navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() }, + service: SessionVerificationService = FakeSessionVerificationService(), + dateFormatter: DateFormatter = FakeDateFormatter(), +) = IncomingVerificationPresenter( + verificationRequest = verificationRequest, + navigator = navigator, + sessionVerificationService = service, + stateMachine = IncomingVerificationStateMachine(service), + dateFormatter = dateFormatter, + sessionCoroutineScope = backgroundScope, +) diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewTest.kt new file mode 100644 index 0000000..4aa63f3 --- /dev/null +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationViewTest.kt @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.incoming + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.verifysession.impl.R +import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class IncomingVerificationViewTest { + @get:Rule val rule = createAndroidComposeRule() + + // region step Initial + @Test + fun `back key pressed - ignore the verification`() { + val eventsRecorder = EventsRecorder() + rule.setIncomingVerificationView( + anIncomingVerificationState( + step = aStepInitial(), + eventSink = eventsRecorder + ), + ) + rule.pressBackKey() + eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack) + } + + @Test + fun `ignore incoming verification emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setIncomingVerificationView( + anIncomingVerificationState( + step = aStepInitial(), + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.action_ignore) + eventsRecorder.assertSingle(IncomingVerificationViewEvents.IgnoreVerification) + } + + @Test + fun `start incoming verification emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setIncomingVerificationView( + anIncomingVerificationState( + step = aStepInitial(), + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.action_start_verification) + eventsRecorder.assertSingle(IncomingVerificationViewEvents.StartVerification) + } + + @Test + fun `back key pressed - when awaiting response cancels the verification`() { + val eventsRecorder = EventsRecorder() + rule.setIncomingVerificationView( + anIncomingVerificationState( + step = aStepInitial( + isWaiting = true, + ), + eventSink = eventsRecorder + ), + ) + rule.pressBackKey() + eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack) + } + // endregion step Initial + + // region step Verifying + @Test + fun `back key pressed - when ready to verify cancels the verification`() { + val eventsRecorder = EventsRecorder() + rule.setIncomingVerificationView( + anIncomingVerificationState( + step = IncomingVerificationState.Step.Verifying( + data = aEmojisSessionVerificationData(), + isWaiting = false, + ), + eventSink = eventsRecorder + ), + ) + rule.pressBackKey() + eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack) + } + + @Test + fun `back key pressed - when verifying and loading emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setIncomingVerificationView( + anIncomingVerificationState( + step = IncomingVerificationState.Step.Verifying( + data = aEmojisSessionVerificationData(), + isWaiting = true, + ), + eventSink = eventsRecorder + ), + ) + rule.pressBackKey() + eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack) + } + + @Test + fun `clicking on they do not match emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setIncomingVerificationView( + anIncomingVerificationState( + step = IncomingVerificationState.Step.Verifying( + data = aEmojisSessionVerificationData(), + isWaiting = false, + ), + eventSink = eventsRecorder + ), + ) + rule.clickOn(R.string.screen_session_verification_they_dont_match) + eventsRecorder.assertSingle(IncomingVerificationViewEvents.DeclineVerification) + } + + @Test + fun `clicking on they match emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setIncomingVerificationView( + anIncomingVerificationState( + step = IncomingVerificationState.Step.Verifying( + data = aEmojisSessionVerificationData(), + isWaiting = false, + ), + eventSink = eventsRecorder + ), + ) + rule.clickOn(R.string.screen_session_verification_they_match) + eventsRecorder.assertSingle(IncomingVerificationViewEvents.ConfirmVerification) + } + // endregion + + // region step Failure + @Test + fun `back key pressed - when failure resets the flow`() { + val eventsRecorder = EventsRecorder() + rule.setIncomingVerificationView( + anIncomingVerificationState( + step = IncomingVerificationState.Step.Failure, + eventSink = eventsRecorder + ), + ) + rule.pressBackKey() + eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack) + } + + @Test + fun `click on done - when failure resets the flow`() { + val eventsRecorder = EventsRecorder() + rule.setIncomingVerificationView( + anIncomingVerificationState( + step = IncomingVerificationState.Step.Failure, + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.action_done) + eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack) + } + + // endregion + + // region step Completed + @Test + fun `back key pressed - on Completed step emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setIncomingVerificationView( + anIncomingVerificationState( + step = IncomingVerificationState.Step.Completed, + eventSink = eventsRecorder + ), + ) + rule.pressBackKey() + eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack) + } + + @Test + fun `when flow is completed and the user clicks on the done button, the expected event is emitted`() { + val eventsRecorder = EventsRecorder() + rule.setIncomingVerificationView( + anIncomingVerificationState( + step = IncomingVerificationState.Step.Completed, + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.action_done) + eventsRecorder.assertSingle(IncomingVerificationViewEvents.GoBack) + } + // endregion + + private fun AndroidComposeTestRule.setIncomingVerificationView( + state: IncomingVerificationState, + ) { + setContent { + IncomingVerificationView( + state = state, + ) + } + } +} diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/DefaultOutgoingVerificationEntryPointTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/DefaultOutgoingVerificationEntryPointTest.kt new file mode 100644 index 0000000..33da9cc --- /dev/null +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/DefaultOutgoingVerificationEntryPointTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.outgoing + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultOutgoingVerificationEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultOutgoingVerificationEntryPoint() + + val parentNode = TestParentNode.create { buildContext, plugins -> + OutgoingVerificationNode( + buildContext = buildContext, + plugins = plugins, + presenterFactory = { _, _ -> + createOutgoingVerificationPresenter() + } + ) + } + val callback = object : OutgoingVerificationEntryPoint.Callback { + override fun navigateToLearnMoreAboutEncryption() = lambdaError() + override fun onBack() = lambdaError() + override fun onDone() = lambdaError() + } + val params = OutgoingVerificationEntryPoint.Params( + showDeviceVerifiedScreen = true, + verificationRequest = anOutgoingSessionVerificationRequest(), + ) + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + params = params, + callback = callback, + ) + assertThat(result).isInstanceOf(OutgoingVerificationNode::class.java) + assertThat(result.plugins).contains(params) + assertThat(result.plugins).contains(callback) + } +} diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationPresenterTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationPresenterTest.kt new file mode 100644 index 0000000..f02f24c --- /dev/null +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationPresenterTest.kt @@ -0,0 +1,339 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.outgoing + +import app.cash.turbine.ReceiveTurbine +import com.google.common.truth.Truth.assertThat +import io.element.android.features.verifysession.impl.outgoing.OutgoingVerificationState.Step +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.verification.SessionVerificationData +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 io.element.android.libraries.matrix.api.verification.VerificationRequest +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class OutgoingVerificationPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - Initial state is received`() = runTest { + val presenter = createOutgoingVerificationPresenter( + service = unverifiedSessionService(), + ) + presenter.test { + awaitItem().run { + assertThat(step).isEqualTo(Step.Initial) + } + } + } + + @Test + fun `present - Handles requestVerification for session verification`() = runTest { + val requestSessionVerificationRecorder = lambdaRecorder {} + val startVerificationRecorder = lambdaRecorder {} + val service = unverifiedSessionService( + requestSessionVerificationLambda = requestSessionVerificationRecorder, + startVerificationLambda = startVerificationRecorder, + ) + val presenter = createOutgoingVerificationPresenter( + service = service, + verificationRequest = anOutgoingSessionVerificationRequest(), + ) + presenter.test { + requestVerificationAndAwaitVerifyingState(service) + + requestSessionVerificationRecorder.assertions().isCalledOnce() + startVerificationRecorder.assertions().isCalledOnce() + } + } + + @Test + fun `present - Handles requestVerification for user verification`() = runTest { + val requestUserVerificationRecorder = lambdaRecorder {} + val startVerificationRecorder = lambdaRecorder {} + val service = unverifiedSessionService( + requestUserVerificationLambda = requestUserVerificationRecorder, + startVerificationLambda = startVerificationRecorder, + ) + val presenter = createOutgoingVerificationPresenter( + service = service, + verificationRequest = anOutgoingUserVerificationRequest(), + ) + presenter.test { + requestVerificationAndAwaitVerifyingState(service) + + requestUserVerificationRecorder.assertions().isCalledOnce() + startVerificationRecorder.assertions().isCalledOnce() + } + } + + @Test + fun `present - Cancellation on initial state moves to Exit state`() = runTest { + val presenter = createOutgoingVerificationPresenter( + service = unverifiedSessionService(), + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.step).isEqualTo(Step.Initial) + val eventSink = initialState.eventSink + eventSink(OutgoingVerificationViewEvents.Cancel) + + assertThat(awaitItem().step).isEqualTo(Step.Exit) + } + } + + @Test + fun `present - A failure when verifying cancels it`() = runTest { + val service = unverifiedSessionService( + requestSessionVerificationLambda = { }, + startVerificationLambda = { }, + approveVerificationLambda = { }, + ) + val presenter = createOutgoingVerificationPresenter(service) + presenter.test { + val state = requestVerificationAndAwaitVerifyingState(service) + state.eventSink(OutgoingVerificationViewEvents.ConfirmVerification) + // Cancelling + assertThat(awaitItem().step).isInstanceOf(Step.Verifying::class.java) + service.emitVerificationFlowState(VerificationFlowState.DidFail) + // Cancelled + assertThat(awaitItem().step).isEqualTo(Step.Canceled) + } + } + + @Test + fun `present - A fail when requesting verification resets the state to the canceled one`() = runTest { + val service = unverifiedSessionService( + requestSessionVerificationLambda = { }, + ) + val presenter = createOutgoingVerificationPresenter(service) + presenter.test { + awaitItem().eventSink(OutgoingVerificationViewEvents.RequestVerification) + service.emitVerificationFlowState(VerificationFlowState.DidFail) + assertThat(awaitItem().step).isInstanceOf(Step.AwaitingOtherDeviceResponse::class.java) + assertThat(awaitItem().step).isEqualTo(Step.Canceled) + } + } + + @Test + fun `present - Canceling the flow once it's verifying cancels it`() = runTest { + val service = unverifiedSessionService( + requestSessionVerificationLambda = { }, + startVerificationLambda = { }, + cancelVerificationLambda = { }, + ) + val presenter = createOutgoingVerificationPresenter(service) + presenter.test { + val state = requestVerificationAndAwaitVerifyingState(service) + state.eventSink(OutgoingVerificationViewEvents.Cancel) + assertThat(awaitItem().step).isEqualTo(Step.Canceled) + } + } + + @Test + fun `present - When verifying, if we receive another challenge we ignore it`() = runTest { + val service = unverifiedSessionService( + requestSessionVerificationLambda = { }, + startVerificationLambda = { }, + ) + val presenter = createOutgoingVerificationPresenter(service) + presenter.test { + requestVerificationAndAwaitVerifyingState(service) + service.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(SessionVerificationData.Emojis(emptyList()))) + ensureAllEventsConsumed() + } + } + + @Test + fun `present - Go back after cancellation returns to initial state`() = runTest { + val service = unverifiedSessionService( + requestSessionVerificationLambda = { }, + startVerificationLambda = { }, + ) + val presenter = createOutgoingVerificationPresenter(service) + presenter.test { + val state = requestVerificationAndAwaitVerifyingState(service) + service.emitVerificationFlowState(VerificationFlowState.DidCancel) + assertThat(awaitItem().step).isEqualTo(Step.Canceled) + state.eventSink(OutgoingVerificationViewEvents.Reset) + // Went back to initial state + assertThat(awaitItem().step).isEqualTo(Step.Initial) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - When verification is approved, the flow completes if there is no error`() = runTest { + val emojis = listOf( + VerificationEmoji(number = 30) + ) + val service = unverifiedSessionService( + requestSessionVerificationLambda = { }, + startVerificationLambda = { }, + approveVerificationLambda = { }, + ) + val presenter = createOutgoingVerificationPresenter(service) + presenter.test { + val state = requestVerificationAndAwaitVerifyingState( + service, + SessionVerificationData.Emojis(emojis) + ) + state.eventSink(OutgoingVerificationViewEvents.ConfirmVerification) + assertThat(awaitItem().step).isEqualTo( + Step.Verifying( + SessionVerificationData.Emojis(emojis), + AsyncData.Loading(), + ) + ) + service.emitVerificationFlowState(VerificationFlowState.DidFinish) + service.emitVerifiedStatus(SessionVerifiedStatus.Verified) + assertThat(awaitItem().step).isEqualTo(Step.Completed) + } + } + + @Test + fun `present - When verification is declined, the flow is canceled`() = runTest { + val service = unverifiedSessionService( + requestSessionVerificationLambda = { }, + startVerificationLambda = { }, + declineVerificationLambda = { }, + ) + val presenter = createOutgoingVerificationPresenter(service) + presenter.test { + val state = requestVerificationAndAwaitVerifyingState(service) + state.eventSink(OutgoingVerificationViewEvents.DeclineVerification) + assertThat(awaitItem().step).isEqualTo( + Step.Verifying( + SessionVerificationData.Emojis(emptyList()), + AsyncData.Loading(), + ) + ) + service.emitVerificationFlowState(VerificationFlowState.DidCancel) + assertThat(awaitItem().step).isEqualTo(Step.Canceled) + } + } + + @Test + fun `present - When verification is done using recovery key, the flow is completed`() = runTest { + val service = FakeSessionVerificationService( + resetLambda = { }, + ).apply { + emitNeedsSessionVerification(false) + emitVerifiedStatus(SessionVerifiedStatus.Verified) + emitVerificationFlowState(VerificationFlowState.DidFinish) + } + val presenter = createOutgoingVerificationPresenter( + service = service, + showDeviceVerifiedScreen = true, + ) + presenter.test { + assertThat(awaitItem().step).isEqualTo(Step.Completed) + } + } + + @Test + fun `present - When verification is not needed, the flow is skipped`() = runTest { + val service = FakeSessionVerificationService( + resetLambda = { }, + ).apply { + emitNeedsSessionVerification(false) + emitVerifiedStatus(SessionVerifiedStatus.Verified) + emitVerificationFlowState(VerificationFlowState.DidFinish) + } + val presenter = createOutgoingVerificationPresenter( + service = service, + showDeviceVerifiedScreen = false, + ) + presenter.test { + skipItems(1) + assertThat(awaitItem().step).isEqualTo(Step.Exit) + } + } + + private suspend fun ReceiveTurbine.requestVerificationAndAwaitVerifyingState( + fakeService: FakeSessionVerificationService, + sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()), + ): OutgoingVerificationState { + var state = awaitItem() + assertThat(state.step).isEqualTo(Step.Initial) + state.eventSink(OutgoingVerificationViewEvents.RequestVerification) + // Await for other device response: + fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest) + state = awaitItem() + assertThat(state.step).isEqualTo(Step.AwaitingOtherDeviceResponse) + // Await for the state to be Ready + state = awaitItem() + assertThat(state.step).isEqualTo(Step.Ready) + state.eventSink(OutgoingVerificationViewEvents.StartSasVerification) + // Await for other device response (again): + fakeService.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification) + state = awaitItem() + assertThat(state.step).isEqualTo(Step.AwaitingOtherDeviceResponse) + // Finally, ChallengeReceived: + fakeService.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(sessionVerificationData)) + state = awaitItem() + assertThat(state.step).isInstanceOf(Step.Verifying::class.java) + return state + } + + private suspend fun unverifiedSessionService( + requestSessionVerificationLambda: () -> Unit = { lambdaError() }, + requestUserVerificationLambda: (UserId) -> Unit = { lambdaError() }, + cancelVerificationLambda: () -> Unit = { lambdaError() }, + approveVerificationLambda: () -> Unit = { lambdaError() }, + declineVerificationLambda: () -> Unit = { lambdaError() }, + startVerificationLambda: () -> Unit = { lambdaError() }, + resetLambda: (Boolean) -> Unit = { }, + acknowledgeVerificationRequestLambda: (VerificationRequest.Incoming) -> Unit = { lambdaError() }, + acceptVerificationRequestLambda: () -> Unit = { lambdaError() }, + ): FakeSessionVerificationService { + return FakeSessionVerificationService( + requestCurrentSessionVerificationLambda = requestSessionVerificationLambda, + requestUserVerificationLambda = requestUserVerificationLambda, + cancelVerificationLambda = cancelVerificationLambda, + approveVerificationLambda = approveVerificationLambda, + declineVerificationLambda = declineVerificationLambda, + startVerificationLambda = startVerificationLambda, + resetLambda = resetLambda, + acknowledgeVerificationRequestLambda = acknowledgeVerificationRequestLambda, + acceptVerificationRequestLambda = acceptVerificationRequestLambda, + ).apply { + emitVerifiedStatus(SessionVerifiedStatus.NotVerified) + } + } +} + +internal fun createOutgoingVerificationPresenter( + service: SessionVerificationService = FakeSessionVerificationService(), + verificationRequest: VerificationRequest.Outgoing = anOutgoingSessionVerificationRequest(), + encryptionService: EncryptionService = FakeEncryptionService(), + showDeviceVerifiedScreen: Boolean = false, +): OutgoingVerificationPresenter { + return OutgoingVerificationPresenter( + showDeviceVerifiedScreen = showDeviceVerifiedScreen, + verificationRequest = verificationRequest, + sessionVerificationService = service, + encryptionService = encryptionService, + ) +} diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationViewTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationViewTest.kt new file mode 100644 index 0000000..71b55fa --- /dev/null +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationViewTest.kt @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.impl.outgoing + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.verifysession.impl.R +import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class OutgoingVerificationViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `back key pressed - when canceled resets the flow`() { + val eventsRecorder = EventsRecorder() + rule.setOutgoingVerificationView( + anOutgoingVerificationState( + step = OutgoingVerificationState.Step.Canceled, + eventSink = eventsRecorder + ), + ) + rule.pressBackKey() + eventsRecorder.assertSingle(OutgoingVerificationViewEvents.Reset) + } + + @Test + fun `back key pressed - when awaiting response cancels the verification`() { + val eventsRecorder = EventsRecorder() + rule.setOutgoingVerificationView( + anOutgoingVerificationState( + step = OutgoingVerificationState.Step.AwaitingOtherDeviceResponse, + eventSink = eventsRecorder + ), + ) + rule.pressBackKey() + eventsRecorder.assertSingle(OutgoingVerificationViewEvents.Cancel) + } + + @Test + fun `back key pressed - when ready to verify cancels the verification`() { + val eventsRecorder = EventsRecorder() + rule.setOutgoingVerificationView( + anOutgoingVerificationState( + step = OutgoingVerificationState.Step.Ready, + eventSink = eventsRecorder + ), + ) + rule.pressBackKey() + eventsRecorder.assertSingle(OutgoingVerificationViewEvents.Cancel) + } + + @Test + fun `back key pressed - when verifying and not loading declines the verification`() { + val eventsRecorder = EventsRecorder() + rule.setOutgoingVerificationView( + anOutgoingVerificationState( + step = OutgoingVerificationState.Step.Verifying( + data = aEmojisSessionVerificationData(), + state = AsyncData.Uninitialized, + ), + eventSink = eventsRecorder + ), + ) + rule.pressBackKey() + eventsRecorder.assertSingle(OutgoingVerificationViewEvents.DeclineVerification) + } + + @Test + fun `back key pressed - when verifying and loading does nothing`() { + val eventsRecorder = EventsRecorder() + rule.setOutgoingVerificationView( + anOutgoingVerificationState( + step = OutgoingVerificationState.Step.Verifying( + data = aEmojisSessionVerificationData(), + state = AsyncData.Loading(), + ), + eventSink = eventsRecorder + ), + ) + rule.pressBackKey() + eventsRecorder.assertEmpty() + } + + @Test + fun `back key pressed - on Completed exits the flow`() { + ensureCalledOnce { callback -> + rule.setOutgoingVerificationView( + onBack = callback, + state = anOutgoingVerificationState( + step = OutgoingVerificationState.Step.Completed, + ), + ) + rule.pressBackKey() + } + } + + @Test + fun `when flow is completed and the user clicks on the continue button, the expected callback is invoked`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setOutgoingVerificationView( + anOutgoingVerificationState( + step = OutgoingVerificationState.Step.Completed, + eventSink = eventsRecorder + ), + onFinished = callback, + ) + rule.clickOn(CommonStrings.action_continue) + } + } + + @Test + fun `clicking on they match emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setOutgoingVerificationView( + anOutgoingVerificationState( + step = OutgoingVerificationState.Step.Verifying( + data = aEmojisSessionVerificationData(), + state = AsyncData.Uninitialized, + ), + eventSink = eventsRecorder + ), + ) + rule.clickOn(R.string.screen_session_verification_they_match) + eventsRecorder.assertSingle(OutgoingVerificationViewEvents.ConfirmVerification) + } + + @Test + fun `clicking on they do not match emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setOutgoingVerificationView( + anOutgoingVerificationState( + step = OutgoingVerificationState.Step.Verifying( + data = aEmojisSessionVerificationData(), + state = AsyncData.Uninitialized, + ), + eventSink = eventsRecorder + ), + ) + rule.clickOn(R.string.screen_session_verification_they_dont_match) + eventsRecorder.assertSingle(OutgoingVerificationViewEvents.DeclineVerification) + } + + private fun AndroidComposeTestRule.setOutgoingVerificationView( + state: OutgoingVerificationState, + onLearnMoreClick: () -> Unit = EnsureNeverCalled(), + onFinished: () -> Unit = EnsureNeverCalled(), + onBack: () -> Unit = EnsureNeverCalled(), + ) { + setContent { + OutgoingVerificationView( + state = state, + onLearnMoreClick = onLearnMoreClick, + onFinish = onFinished, + onBack = onBack, + ) + } + } +} diff --git a/features/verifysession/test/build.gradle.kts b/features/verifysession/test/build.gradle.kts new file mode 100644 index 0000000..01ce23e --- /dev/null +++ b/features/verifysession/test/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.verifysession.test" +} + +dependencies { + implementation(projects.features.verifysession.api) + implementation(projects.libraries.architecture) + implementation(projects.tests.testutils) +} diff --git a/features/verifysession/test/src/main/kotlin/io/element/android/features/verifysession/test/FakeIncomingVerificationEntryPoint.kt b/features/verifysession/test/src/main/kotlin/io/element/android/features/verifysession/test/FakeIncomingVerificationEntryPoint.kt new file mode 100644 index 0000000..0637e13 --- /dev/null +++ b/features/verifysession/test/src/main/kotlin/io/element/android/features/verifysession/test/FakeIncomingVerificationEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeIncomingVerificationEntryPoint : IncomingVerificationEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: IncomingVerificationEntryPoint.Params, + callback: IncomingVerificationEntryPoint.Callback, + ): Node = lambdaError() +} diff --git a/features/verifysession/test/src/main/kotlin/io/element/android/features/verifysession/test/FakeOutgoingVerificationEntryPoint.kt b/features/verifysession/test/src/main/kotlin/io/element/android/features/verifysession/test/FakeOutgoingVerificationEntryPoint.kt new file mode 100644 index 0000000..c05aa2a --- /dev/null +++ b/features/verifysession/test/src/main/kotlin/io/element/android/features/verifysession/test/FakeOutgoingVerificationEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.verifysession.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeOutgoingVerificationEntryPoint : OutgoingVerificationEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: OutgoingVerificationEntryPoint.Params, + callback: OutgoingVerificationEntryPoint.Callback, + ): Node = lambdaError() +} diff --git a/features/viewfolder/api/build.gradle.kts b/features/viewfolder/api/build.gradle.kts new file mode 100644 index 0000000..4763c24 --- /dev/null +++ b/features/viewfolder/api/build.gradle.kts @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.viewfolder.api" +} + +dependencies { + implementation(projects.libraries.architecture) +} diff --git a/features/viewfolder/api/src/main/kotlin/io/element/android/features/viewfolder/api/TextFileViewer.kt b/features/viewfolder/api/src/main/kotlin/io/element/android/features/viewfolder/api/TextFileViewer.kt new file mode 100644 index 0000000..29a7c9f --- /dev/null +++ b/features/viewfolder/api/src/main/kotlin/io/element/android/features/viewfolder/api/TextFileViewer.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.api + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlinx.collections.immutable.ImmutableList + +fun interface TextFileViewer { + @Composable + fun Render( + lines: ImmutableList, + modifier: Modifier, + ) +} diff --git a/features/viewfolder/api/src/main/kotlin/io/element/android/features/viewfolder/api/ViewFolderEntryPoint.kt b/features/viewfolder/api/src/main/kotlin/io/element/android/features/viewfolder/api/ViewFolderEntryPoint.kt new file mode 100644 index 0000000..6f9fc50 --- /dev/null +++ b/features/viewfolder/api/src/main/kotlin/io/element/android/features/viewfolder/api/ViewFolderEntryPoint.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.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 ViewFolderEntryPoint : FeatureEntryPoint { + data class Params( + val rootPath: String, + ) + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + callback: Callback, + ): Node + + interface Callback : Plugin { + fun onDone() + } +} diff --git a/features/viewfolder/impl/build.gradle.kts b/features/viewfolder/impl/build.gradle.kts new file mode 100644 index 0000000..4d6ad05 --- /dev/null +++ b/features/viewfolder/impl/build.gradle.kts @@ -0,0 +1,33 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.viewfolder.impl" +} + +setupDependencyInjection() + +dependencies { + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.core) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + api(projects.features.viewfolder.api) + + testCommonDependencies(libs) + testImplementation(projects.libraries.matrix.test) +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultTextFileViewer.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultTextFileViewer.kt new file mode 100644 index 0000000..ae3e693 --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultTextFileViewer.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.viewfolder.api.TextFileViewer +import io.element.android.features.viewfolder.impl.file.ColorationMode +import io.element.android.features.viewfolder.impl.file.FileContent +import kotlinx.collections.immutable.ImmutableList + +@ContributesBinding(AppScope::class) +class DefaultTextFileViewer : TextFileViewer { + @Composable + override fun Render( + lines: ImmutableList, + modifier: Modifier + ) { + FileContent( + lines = lines, + colorationMode = ColorationMode.None, + modifier = modifier + ) + } +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultViewFolderEntryPoint.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultViewFolderEntryPoint.kt new file mode 100644 index 0000000..3ef8317 --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultViewFolderEntryPoint.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.viewfolder.api.ViewFolderEntryPoint +import io.element.android.features.viewfolder.impl.root.ViewFolderFlowNode +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultViewFolderEntryPoint : ViewFolderEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: ViewFolderEntryPoint.Params, + callback: ViewFolderEntryPoint.Callback, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf( + ViewFolderFlowNode.Inputs(params.rootPath), + callback, + ), + ) + } +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContent.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContent.kt new file mode 100644 index 0000000..614e45a --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContent.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl.file + +import androidx.compose.foundation.clickable +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.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +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.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.androidutils.system.copyToClipboard +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +@Composable +internal fun FileContent( + lines: ImmutableList, + colorationMode: ColorationMode, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier + ) { + if (lines.isEmpty()) { + item { + Spacer(Modifier.size(80.dp)) + Text( + text = stringResource(CommonStrings.common_empty_file), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.fillMaxWidth() + ) + } + } else { + itemsIndexed( + items = lines, + ) { index, line -> + LineRow( + lineNumber = index + 1, + line = line, + colorationMode = colorationMode, + ) + } + } + } +} + +@Composable +private fun LineRow( + lineNumber: Int, + line: String, + colorationMode: ColorationMode, +) { + val context = LocalContext.current + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = { + context.copyToClipboard( + text = line, + toastMessage = context.getString(CommonStrings.common_line_copied_to_clipboard), + ) + }) + ) { + Text( + modifier = Modifier + .widthIn(min = 36.dp) + .padding(horizontal = 4.dp), + text = "$lineNumber", + textAlign = TextAlign.End, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdMedium, + ) + val color = ElementTheme.colors.textSecondary + val width = 0.5.dp.value + Text( + modifier = Modifier + .weight(1f) + .drawWithContent { + // Using .height(IntrinsicSize.Min) on the Row does not work well inside LazyColumn + drawLine( + color = color, + start = Offset(0f, 0f), + end = Offset(0f, size.height), + strokeWidth = width + ) + drawContent() + } + .padding(horizontal = 4.dp), + text = line, + color = line.toColor(colorationMode), + style = ElementTheme.typography.fontBodyMdRegular + ) + } +} + +/** + * Convert a line to a color. + * Ex for logcat: + * `01-23 13:14:50.740 25818 25818 D org.matrix.rust.sdk: elementx: SyncIndicator = Hide | RustRoomListService.kt:81` + * ^ use this char to determine the color + * Ex for Rust logs: + * `2024-01-26T10:22:26.947416Z WARN elementx: Restore with non-empty map | MatrixClientsHolder.kt:68` + * ^ use this char to determine the color, see [LogLevel] + */ +@Composable +private fun String.toColor(colorationMode: ColorationMode): Color { + return when (colorationMode) { + ColorationMode.Logcat -> when (getOrNull(31)) { + 'D' -> colorDebug + 'I' -> colorInfo + 'W' -> colorWarning + 'E' -> colorError + 'A' -> colorError + else -> ElementTheme.colors.textPrimary + } + ColorationMode.RustLogs -> when (getOrNull(32)) { + 'E' -> ElementTheme.colors.textPrimary + 'G' -> colorDebug + 'O' -> colorInfo + 'N' -> colorWarning + 'R' -> colorError + else -> ElementTheme.colors.textPrimary + } + ColorationMode.None -> ElementTheme.colors.textPrimary + } +} + +private val colorDebug = Color(0xFF299999) +private val colorInfo = Color(0xFFABC023) +private val colorWarning = Color(0xFFBBB529) +private val colorError = Color(0xFFFF6B68) diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContentReader.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContentReader.kt new file mode 100644 index 0000000..08b7b50 --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContentReader.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl.file + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.runCatchingExceptions +import kotlinx.coroutines.withContext +import java.io.File + +interface FileContentReader { + suspend fun getLines(path: String): Result> +} + +@ContributesBinding(AppScope::class) +class DefaultFileContentReader( + private val dispatchers: CoroutineDispatchers, +) : FileContentReader { + override suspend fun getLines(path: String): Result> = withContext(dispatchers.io) { + runCatchingExceptions { + File(path).readLines() + } + } +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileSave.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileSave.kt new file mode 100644 index 0000000..03f442f --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileSave.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl.file + +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.annotation.RequiresApi +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.system.toast +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.di.annotations.ApplicationContext +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream + +interface FileSave { + suspend fun save( + path: String, + ) +} + +@ContributesBinding(AppScope::class) +class DefaultFileSave( + @ApplicationContext private val context: Context, + private val dispatchers: CoroutineDispatchers, +) : FileSave { + override suspend fun save( + path: String, + ) { + withContext(dispatchers.io) { + runCatchingExceptions { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveOnDiskUsingMediaStore(path) + } else { + saveOnDiskUsingExternalStorageApi(path) + } + }.onSuccess { + Timber.v("Save on disk succeed") + withContext(dispatchers.main) { + context.toast("Save on disk succeed") + } + }.onFailure { + Timber.e(it, "Save on disk failed") + } + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun saveOnDiskUsingMediaStore(path: String) { + val file = File(path) + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, file.name) + put(MediaStore.MediaColumns.MIME_TYPE, MimeTypes.OctetStream) + 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) { + file.inputStream().use { input -> + resolver.openOutputStream(outputUri).use { output -> + input.copyTo(output!!, DEFAULT_BUFFER_SIZE) + } + } + } + } + + private fun saveOnDiskUsingExternalStorageApi(path: String) { + val file = File(path) + val target = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + file.name + ) + file.inputStream().use { input -> + FileOutputStream(target).use { output -> + input.copyTo(output) + } + } + } +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileShare.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileShare.kt new file mode 100644 index 0000000..9a43933 --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileShare.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl.file + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.core.content.FileProvider +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.di.annotations.ApplicationContext +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File + +interface FileShare { + suspend fun share( + path: String + ) +} + +@ContributesBinding(AppScope::class) +class DefaultFileShare( + @ApplicationContext private val context: Context, + private val dispatchers: CoroutineDispatchers, + private val buildMeta: BuildMeta, +) : FileShare { + override suspend fun share( + path: String, + ) { + runCatchingExceptions { + val file = File(path) + val shareableUri = file.toShareableUri() + val shareMediaIntent = Intent(Intent.ACTION_SEND) + .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .putExtra(Intent.EXTRA_STREAM, shareableUri) + .setTypeAndNormalize(MimeTypes.OctetStream) + withContext(dispatchers.main) { + val intent = Intent.createChooser(shareMediaIntent, null) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } + }.onSuccess { + Timber.v("Share file succeed") + }.onFailure { + Timber.e(it, "Share file failed") + } + } + + private fun File.toShareableUri(): Uri { + val authority = "${buildMeta.applicationId}.fileprovider" + return FileProvider.getUriForFile(context, authority, this).normalizeScheme() + } +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvents.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvents.kt new file mode 100644 index 0000000..2cc4f3a --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl.file + +sealed interface ViewFileEvents { + data object SaveOnDisk : ViewFileEvents + data object Share : ViewFileEvents +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileNode.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileNode.kt new file mode 100644 index 0000000..6c6a024 --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileNode.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl.file + +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs + +@ContributesNode(AppScope::class) +@AssistedInject +class ViewFileNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: ViewFilePresenter.Factory, +) : Node(buildContext, plugins = plugins) { + data class Inputs( + val path: String, + val name: String, + ) : NodeInputs + + interface Callback : Plugin { + fun onBackClick() + } + + private val callback: Callback = callback() + private val inputs: Inputs = inputs() + + private val presenter = presenterFactory.create( + path = inputs.path, + name = inputs.name, + ) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + ViewFileView( + state = state, + modifier = modifier, + onBackClick = callback::onBackClick, + ) + } +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt new file mode 100644 index 0000000..615b77e --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl.file + +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@AssistedInject +class ViewFilePresenter( + @Assisted("path") val path: String, + @Assisted("name") val name: String, + private val fileContentReader: FileContentReader, + private val fileShare: FileShare, + private val fileSave: FileSave, +) : Presenter { + @AssistedFactory + interface Factory { + fun create( + @Assisted("path") path: String, + @Assisted("name") name: String, + ): ViewFilePresenter + } + + @Composable + override fun present(): ViewFileState { + val coroutineScope = rememberCoroutineScope() + val colorationMode = remember { name.toColorationMode() } + + fun handleEvent(event: ViewFileEvents) { + when (event) { + ViewFileEvents.Share -> coroutineScope.share(path) + ViewFileEvents.SaveOnDisk -> coroutineScope.save(path) + } + } + + var lines: AsyncData> by remember { mutableStateOf(AsyncData.Loading()) } + LaunchedEffect(Unit) { + lines = fileContentReader.getLines(path).fold( + onSuccess = { AsyncData.Success(it) }, + onFailure = { AsyncData.Failure(it) } + ) + } + return ViewFileState( + name = name, + lines = lines, + colorationMode = colorationMode, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.share(path: String) = launch { + fileShare.share(path) + } + + private fun CoroutineScope.save(path: String) = launch { + fileSave.save(path) + } +} + +private fun String.toColorationMode(): ColorationMode { + return when { + equals("logcat.log") -> ColorationMode.Logcat + startsWith("logs.") -> ColorationMode.RustLogs + else -> ColorationMode.None + } +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileState.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileState.kt new file mode 100644 index 0000000..979e188 --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileState.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl.file + +import io.element.android.libraries.architecture.AsyncData + +data class ViewFileState( + val name: String, + val lines: AsyncData>, + val colorationMode: ColorationMode, + val eventSink: (ViewFileEvents) -> Unit, +) + +enum class ColorationMode { + Logcat, + RustLogs, + None, +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileStateProvider.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileStateProvider.kt new file mode 100644 index 0000000..1b20b5c --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileStateProvider.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl.file + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncData + +open class ViewFileStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aViewFileState(), + aViewFileState(lines = AsyncData.Loading()), + aViewFileState(lines = AsyncData.Failure(Exception("A failure"))), + aViewFileState(lines = AsyncData.Success(emptyList())), + aViewFileState( + name = "logcat.log", + lines = AsyncData.Success( + listOf( + "Line 1", + "Line 2", + "Line 3 lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor" + + " incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,", + "01-23 13:14:50.740 25818 25818 V verbose", + "01-23 13:14:50.740 25818 25818 D debug", + "01-23 13:14:50.740 25818 25818 I info", + "01-23 13:14:50.740 25818 25818 W warning", + "01-23 13:14:50.740 25818 25818 E error", + "01-23 13:14:50.740 25818 25818 A assertion", + ) + ), + colorationMode = ColorationMode.Logcat, + ), + aViewFileState( + name = "logs.2024-01-26", + lines = AsyncData.Success( + listOf( + "Line 1", + "Line 2", + "Line 3 lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor" + + " incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,", + "2024-01-26T10:22:26.947416Z TRACE trace", + "2024-01-26T10:22:26.947416Z DEBUG debug", + "2024-01-26T10:22:26.947416Z INFO info", + "2024-01-26T10:22:26.947416Z WARN warn", + "2024-01-26T10:22:26.947416Z ERROR error", + ) + ), + colorationMode = ColorationMode.RustLogs, + ) + ) +} + +fun aViewFileState( + name: String = "aName", + lines: AsyncData> = AsyncData.Uninitialized, + colorationMode: ColorationMode = ColorationMode.None, +) = ViewFileState( + name = name, + lines = lines, + colorationMode = colorationMode, + eventSink = {}, +) diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt new file mode 100644 index 0000000..23f92fc --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl.file + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +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.PreviewParameter +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.async.AsyncFailure +import io.element.android.libraries.designsystem.components.async.AsyncLoading +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.ui.strings.CommonStrings +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ViewFileView( + state: ViewFileState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + navigationIcon = { + BackButton(onClick = onBackClick) + }, + titleStr = state.name, + actions = { + IconButton( + onClick = { + state.eventSink(ViewFileEvents.Share) + }, + ) { + Icon( + imageVector = CompoundIcons.ShareAndroid(), + contentDescription = stringResource(id = CommonStrings.action_share), + ) + } + IconButton( + onClick = { + state.eventSink(ViewFileEvents.SaveOnDisk) + }, + ) { + Icon( + imageVector = CompoundIcons.Download(), + contentDescription = stringResource(id = CommonStrings.action_save), + ) + } + } + ) + }, + content = { padding -> + Column( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + ) { + when (state.lines) { + AsyncData.Uninitialized, + is AsyncData.Loading -> AsyncLoading() + is AsyncData.Success -> FileContent( + modifier = Modifier.weight(1f), + lines = state.lines.data.toImmutableList(), + colorationMode = state.colorationMode, + ) + is AsyncData.Failure -> AsyncFailure(throwable = state.lines.error, onRetry = null) + } + } + } + ) +} + +@PreviewsDayNight +@Composable +internal fun ViewFileViewPreview(@PreviewParameter(ViewFileStateProvider::class) state: ViewFileState) = ElementPreview { + ViewFileView( + state = state, + onBackClick = {}, + ) +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/FolderExplorer.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/FolderExplorer.kt new file mode 100644 index 0000000..c1a2f60 --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/FolderExplorer.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl.folder + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.viewfolder.impl.model.Item +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import kotlinx.coroutines.withContext +import java.io.File + +interface FolderExplorer { + suspend fun getItems(path: String): List +} + +@ContributesBinding(AppScope::class) +class DefaultFolderExplorer( + private val fileSizeFormatter: FileSizeFormatter, + private val dispatchers: CoroutineDispatchers, +) : FolderExplorer { + override suspend fun getItems(path: String): List = withContext(dispatchers.io) { + val current = File(path) + if (current.isFile) { + error("Not a folder") + } + val folderContent = current.listFiles().orEmpty().map { file -> + if (file.isDirectory) { + Item.Folder( + path = file.path, + name = file.name + ) + } else { + Item.File( + path = file.path, + name = file.name, + formattedSize = fileSizeFormatter.format(file.length()), + ) + } + } + buildList { + addAll(folderContent.filterIsInstance().sortedBy(Item.Folder::name)) + addAll(folderContent.filterIsInstance().sortedBy(Item.File::name)) + } + } +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderNode.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderNode.kt new file mode 100644 index 0000000..4eac71f --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderNode.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl.folder + +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.viewfolder.impl.model.Item +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs + +@ContributesNode(AppScope::class) +@AssistedInject +class ViewFolderNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: ViewFolderPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + data class Inputs( + val canGoUp: Boolean, + val path: String, + ) : NodeInputs + + interface Callback : Plugin { + fun onBackClick() + fun navigateToItem(item: Item) + } + + private val callback: Callback = callback() + private val inputs: Inputs = inputs() + + private val presenter = presenterFactory.create( + canGoUp = inputs.canGoUp, + path = inputs.path, + ) + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + ViewFolderView( + state = state, + modifier = modifier, + onNavigateTo = callback::navigateToItem, + onBackClick = callback::onBackClick, + ) + } +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderPresenter.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderPresenter.kt new file mode 100644 index 0000000..6c91675 --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderPresenter.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl.folder + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.viewfolder.impl.model.Item +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@AssistedInject +class ViewFolderPresenter( + @Assisted val canGoUp: Boolean, + @Assisted val path: String, + private val folderExplorer: FolderExplorer, + private val buildMeta: BuildMeta, +) : Presenter { + @AssistedFactory + interface Factory { + fun create(canGoUp: Boolean, path: String): ViewFolderPresenter + } + + @Composable + override fun present(): ViewFolderState { + var content by remember { mutableStateOf>(persistentListOf()) } + val title = remember { + buildString { + if (path.contains(buildMeta.applicationId)) { + append("…") + } + append(path.substringAfter(buildMeta.applicationId)) + } + } + LaunchedEffect(Unit) { + content = buildList { + if (canGoUp) add(Item.Parent) + addAll(folderExplorer.getItems(path)) + }.toImmutableList() + } + return ViewFolderState( + title = title, + content = content, + ) + } +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderState.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderState.kt new file mode 100644 index 0000000..f6e62be --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderState.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl.folder + +import io.element.android.features.viewfolder.impl.model.Item +import kotlinx.collections.immutable.ImmutableList + +data class ViewFolderState( + val title: String, + val content: ImmutableList, +) diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderStateProvider.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderStateProvider.kt new file mode 100644 index 0000000..2debe4c --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderStateProvider.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl.folder + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.viewfolder.impl.model.Item +import kotlinx.collections.immutable.toImmutableList + +open class ViewFolderStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aViewFolderState(), + aViewFolderState( + content = listOf( + Item.Parent, + Item.Folder("aPath", "aFolder"), + Item.File("aPath", "aFile", "12kB"), + ) + ) + ) +} + +fun aViewFolderState( + title: String = "aPath", + content: List = emptyList(), +) = ViewFolderState( + title = title, + content = content.toImmutableList(), +) diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt new file mode 100644 index 0000000..2a6b403 --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl.folder + +import androidx.compose.foundation.layout.Column +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.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.SubdirectoryArrowLeft +import androidx.compose.material3.ExperimentalMaterial3Api +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.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.viewfolder.impl.model.Item +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +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 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ViewFolderView( + state: ViewFolderState, + onNavigateTo: (Item) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + navigationIcon = { + BackButton(onClick = onBackClick) + }, + titleStr = state.title, + ) + }, + content = { padding -> + Column( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding) + ) { + LazyColumn( + modifier = Modifier.weight(1f) + ) { + items( + items = state.content, + ) { item -> + ItemRow( + item = item, + onItemClick = { onNavigateTo(item) }, + ) + } + if (state.content.none { it !is Item.Parent }) { + item { + Spacer(Modifier.size(80.dp)) + Text( + text = "Empty folder", + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + } + ) +} + +@Composable +private fun ItemRow( + item: Item, + onItemClick: () -> Unit, +) { + when (item) { + Item.Parent -> { + ListItem( + leadingContent = ListItemContent.Icon(IconSource.Vector(Icons.Outlined.SubdirectoryArrowLeft)), + headlineContent = { + Text( + text = "..", + modifier = Modifier.padding(16.dp), + style = ElementTheme.typography.fontBodyMdMedium, + ) + }, + onClick = onItemClick, + ) + } + is Item.Folder -> { + ListItem( + leadingContent = ListItemContent.Icon(IconSource.Vector(Icons.Outlined.Folder)), + headlineContent = { + Text( + text = item.name, + modifier = Modifier.padding(16.dp), + style = ElementTheme.typography.fontBodyMdMedium, + ) + }, + onClick = onItemClick, + ) + } + is Item.File -> { + ListItem( + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Document())), + headlineContent = { + Text( + text = item.name, + modifier = Modifier.padding(16.dp), + style = ElementTheme.typography.fontBodyMdMedium, + ) + }, + trailingContent = ListItemContent.Text(item.formattedSize), + onClick = onItemClick, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun ViewFolderViewPreview(@PreviewParameter(ViewFolderStateProvider::class) state: ViewFolderState) = ElementPreview { + ViewFolderView( + state = state, + onNavigateTo = {}, + onBackClick = {}, + ) +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/model/Item.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/model/Item.kt new file mode 100644 index 0000000..571ac20 --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/model/Item.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl.model + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface Item { + data object Parent : Item + + data class Folder( + val path: String, + val name: String, + ) : Item + + data class File( + val path: String, + val name: String, + val formattedSize: String, + ) : Item +} diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/root/ViewFolderFlowNode.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/root/ViewFolderFlowNode.kt new file mode 100644 index 0000000..7418d78 --- /dev/null +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/root/ViewFolderFlowNode.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl.root + +import android.os.Parcelable +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.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.viewfolder.api.ViewFolderEntryPoint +import io.element.android.features.viewfolder.impl.file.ViewFileNode +import io.element.android.features.viewfolder.impl.folder.ViewFolderNode +import io.element.android.features.viewfolder.impl.model.Item +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.architecture.inputs +import kotlinx.parcelize.Parcelize + +@ContributesNode(AppScope::class) +@AssistedInject +class ViewFolderFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +) { + sealed interface NavTarget : Parcelable { + @Parcelize + data object Root : NavTarget + + @Parcelize + data class Folder( + val path: String, + ) : NavTarget + + @Parcelize + data class File( + val path: String, + val name: String, + ) : NavTarget + } + + data class Inputs( + val rootPath: String, + ) : NodeInputs + + private val callback: ViewFolderEntryPoint.Callback = callback() + private val inputs: Inputs = inputs() + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.Root -> { + createViewFolderNode( + buildContext, + inputs = ViewFolderNode.Inputs( + canGoUp = false, + path = inputs.rootPath, + ) + ) + } + is NavTarget.Folder -> { + createViewFolderNode( + buildContext, + inputs = ViewFolderNode.Inputs( + canGoUp = true, + path = navTarget.path, + ) + ) + } + is NavTarget.File -> { + val callback: ViewFileNode.Callback = object : ViewFileNode.Callback { + override fun onBackClick() { + backstack.pop() + } + } + val inputs = ViewFileNode.Inputs( + path = navTarget.path, + name = navTarget.name, + ) + createNode(buildContext, plugins = listOf(inputs, callback)) + } + } + } + + private fun createViewFolderNode( + buildContext: BuildContext, + inputs: ViewFolderNode.Inputs, + ): Node { + val callback: ViewFolderNode.Callback = object : ViewFolderNode.Callback { + override fun onBackClick() { + callback.onDone() + } + + override fun navigateToItem(item: Item) { + when (item) { + Item.Parent -> { + // Should not happen when in Root since parent is not accessible from root (canGoUp set to false) + backstack.pop() + } + is Item.Folder -> { + backstack.push(NavTarget.Folder(path = item.path)) + } + is Item.File -> { + backstack.push(NavTarget.File(path = item.path, name = item.name)) + } + } + } + } + return createNode(buildContext, plugins = listOf(inputs, callback)) + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView() + } +} diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/DefaultViewFolderEntryPointTest.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/DefaultViewFolderEntryPointTest.kt new file mode 100644 index 0000000..3ac0a66 --- /dev/null +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/DefaultViewFolderEntryPointTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.features.viewfolder.api.ViewFolderEntryPoint +import io.element.android.features.viewfolder.impl.root.ViewFolderFlowNode +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultViewFolderEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultViewFolderEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + ViewFolderFlowNode( + buildContext = buildContext, + plugins = plugins, + ) + } + val callback = object : ViewFolderEntryPoint.Callback { + override fun onDone() = lambdaError() + } + val params = ViewFolderEntryPoint.Params( + rootPath = "path", + ) + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + params = params, + callback = callback, + ) + assertThat(result).isInstanceOf(ViewFolderFlowNode::class.java) + assertThat(result.plugins).contains(ViewFolderFlowNode.Inputs(params.rootPath)) + assertThat(result.plugins).contains(callback) + } +} diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileContentReader.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileContentReader.kt new file mode 100644 index 0000000..6dd621a --- /dev/null +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileContentReader.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.test.file + +import io.element.android.features.viewfolder.impl.file.FileContentReader + +class FakeFileContentReader : FileContentReader { + private var result: Result> = Result.success(emptyList()) + + fun givenResult(result: Result>) { + this.result = result + } + + override suspend fun getLines(path: String): Result> = result +} diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileSave.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileSave.kt new file mode 100644 index 0000000..3649d30 --- /dev/null +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileSave.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.test.file + +import io.element.android.features.viewfolder.impl.file.FileSave + +class FakeFileSave : FileSave { + var hasBeenCalled = false + private set + + override suspend fun save(path: String) { + hasBeenCalled = true + } +} diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileShare.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileShare.kt new file mode 100644 index 0000000..7348e4b --- /dev/null +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileShare.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.test.file + +import io.element.android.features.viewfolder.impl.file.FileShare + +class FakeFileShare : FileShare { + var hasBeenCalled = false + private set + + override suspend fun share(path: String) { + hasBeenCalled = true + } +} diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt new file mode 100644 index 0000000..1a031ad --- /dev/null +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.test.file + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.viewfolder.impl.file.ColorationMode +import io.element.android.features.viewfolder.impl.file.FileContentReader +import io.element.android.features.viewfolder.impl.file.FileSave +import io.element.android.features.viewfolder.impl.file.FileShare +import io.element.android.features.viewfolder.impl.file.ViewFileEvents +import io.element.android.features.viewfolder.impl.file.ViewFilePresenter +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class ViewFilePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val fileContentReader = FakeFileContentReader().apply { + givenResult(Result.success(listOf("aLine"))) + } + val presenter = createPresenter(fileContentReader = fileContentReader) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.name).isEqualTo("aName") + assertThat(initialState.lines).isInstanceOf(AsyncData.Loading::class.java) + assertThat(initialState.colorationMode).isEqualTo(ColorationMode.None) + val loadedState = awaitItem() + val lines = (loadedState.lines as AsyncData.Success).data + assertThat(lines.size).isEqualTo(1) + assertThat(lines.first()).isEqualTo("aLine") + } + } + + @Test + fun `present - coloration mode for logcat`() = runTest { + val presenter = createPresenter(name = "logcat.log") + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.colorationMode).isEqualTo(ColorationMode.Logcat) + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun `present - coloration mode for logs`() = runTest { + val presenter = createPresenter(name = "logs.date") + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.colorationMode).isEqualTo(ColorationMode.RustLogs) + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun `present - share should not have any side effect`() = runTest { + val fileContentReader = FakeFileContentReader().apply { + givenResult(Result.success(listOf("aLine"))) + } + val fileShare = FakeFileShare() + val fileSave = FakeFileSave() + val presenter = createPresenter(fileContentReader = fileContentReader, fileShare = fileShare, fileSave = fileSave) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(ViewFileEvents.Share) + assertThat(fileShare.hasBeenCalled).isTrue() + assertThat(fileSave.hasBeenCalled).isFalse() + } + } + + @Test + fun `present - with error loading file`() = runTest { + val fileContentReader = FakeFileContentReader().apply { + givenResult(Result.failure(AN_EXCEPTION)) + } + val presenter = createPresenter(fileContentReader = fileContentReader) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val errorState = awaitItem() + assertThat(errorState.lines).isInstanceOf(AsyncData.Failure::class.java) + } + } + + @Test + fun `present - save should not have any side effect`() = runTest { + val fileContentReader = FakeFileContentReader().apply { + givenResult(Result.success(listOf("aLine"))) + } + val fileShare = FakeFileShare() + val fileSave = FakeFileSave() + val presenter = createPresenter(fileContentReader = fileContentReader, fileShare = fileShare, fileSave = fileSave) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(ViewFileEvents.SaveOnDisk) + assertThat(fileShare.hasBeenCalled).isFalse() + assertThat(fileSave.hasBeenCalled).isTrue() + } + } + + private fun createPresenter( + path: String = "aPath", + name: String = "aName", + fileContentReader: FileContentReader = FakeFileContentReader(), + fileShare: FileShare = FakeFileShare(), + fileSave: FileSave = FakeFileSave(), + ) = ViewFilePresenter( + path = path, + name = name, + fileContentReader = fileContentReader, + fileShare = fileShare, + fileSave = fileSave, + ) +} diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/FakeFolderExplorer.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/FakeFolderExplorer.kt new file mode 100644 index 0000000..7761bfb --- /dev/null +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/FakeFolderExplorer.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.test.folder + +import io.element.android.features.viewfolder.impl.folder.FolderExplorer +import io.element.android.features.viewfolder.impl.model.Item + +class FakeFolderExplorer : FolderExplorer { + private var result: List = emptyList() + + fun givenResult(result: List) { + this.result = result + } + + override suspend fun getItems(path: String): List = result +} diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/ViewFolderPresenterTest.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/ViewFolderPresenterTest.kt new file mode 100644 index 0000000..31fd91e --- /dev/null +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/ViewFolderPresenterTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.test.folder + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.viewfolder.impl.folder.FolderExplorer +import io.element.android.features.viewfolder.impl.folder.ViewFolderPresenter +import io.element.android.features.viewfolder.impl.model.Item +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class ViewFolderPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.title).isEqualTo("aPath") + assertThat(initialState.content).isEmpty() + } + } + + @Test + fun `present - title is built regarding the applicationId`() = runTest { + val presenter = createPresenter( + path = "/data/user/O/appId/cache/logs", + buildMeta = aBuildMeta( + applicationId = "appId", + ) + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.title).isEqualTo("…/cache/logs") + } + } + + @Test + fun `present - list items from root`() = runTest { + val items = listOf( + Item.Folder("aFilePath", "aFilename"), + Item.File("aFolderPath", "aFolderName", "aSize"), + ) + val folderExplorer = FakeFolderExplorer().apply { + givenResult(items) + } + val presenter = createPresenter(folderExplorer = folderExplorer) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.title).isEqualTo("aPath") + assertThat(initialState.content.toList()).isEqualTo(items) + } + } + + @Test + fun `present - list items from a folder`() = runTest { + val items = listOf( + Item.Folder("aFilePath", "aFilename"), + Item.File("aFolderPath", "aFolderName", "aSize"), + ) + val folderExplorer = FakeFolderExplorer().apply { + givenResult(items) + } + val presenter = createPresenter( + canGoUp = true, + folderExplorer = folderExplorer + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.title).isEqualTo("aPath") + assertThat(initialState.content.toList()).isEqualTo(listOf(Item.Parent) + items) + } + } + + private fun createPresenter( + canGoUp: Boolean = false, + path: String = "aPath", + folderExplorer: FolderExplorer = FakeFolderExplorer(), + buildMeta: BuildMeta = aBuildMeta( + applicationId = "appId", + ), + ) = ViewFolderPresenter( + path = path, + canGoUp = canGoUp, + folderExplorer = folderExplorer, + buildMeta = buildMeta, + ) +} diff --git a/features/viewfolder/test/build.gradle.kts b/features/viewfolder/test/build.gradle.kts new file mode 100644 index 0000000..6fe5420 --- /dev/null +++ b/features/viewfolder/test/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.viewfolder.test" +} + +dependencies { + implementation(projects.features.viewfolder.api) + implementation(projects.libraries.architecture) + implementation(projects.tests.testutils) +} diff --git a/features/viewfolder/test/src/main/kotlin/io/element/android/features/viewfolder/test/FakeViewFolderEntryPoint.kt b/features/viewfolder/test/src/main/kotlin/io/element/android/features/viewfolder/test/FakeViewFolderEntryPoint.kt new file mode 100644 index 0000000..11f009f --- /dev/null +++ b/features/viewfolder/test/src/main/kotlin/io/element/android/features/viewfolder/test/FakeViewFolderEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.viewfolder.api.ViewFolderEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeViewFolderEntryPoint : ViewFolderEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: ViewFolderEntryPoint.Params, + callback: ViewFolderEntryPoint.Callback, + ): Node = lambdaError() +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..ce03899 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,64 @@ +# +# Copyright (c) 2025 Element Creations Ltd. +# Copyright 2022 New Vector Ltd. +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +# Please see LICENSE files in the repository root for full details. + +# 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=-Xmx4g -Dfile.encoding=UTF-8 -XX:+UseG1GC + +# 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.configureondemand=true +org.gradle.parallel=true + +# Caching +org.gradle.caching=true +org.gradle.configuration-cache=true +org.gradle.configuration-cache.parallel=true +kotlin.incremental=true + +# 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.12.2 + +# 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 + +# Only apply KSP to main sources +ksp.allow.all.target.configuration=false + +# Used to prevent detekt from reusing invalid cached rules +detekt.use.worker.api=true + +# Let test include roborazzi verification +# https://github.com/takahirom/roborazzi?tab=readme-ov-file#roborazzitest +roborazzi.test.verify=true + +# Needed after enabling proguard on AGP 8.13.1 +android.r8.gradual.support=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..b910cb9 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,270 @@ +# 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.13.1" +# When updateing this, please also update the version in the file ./idea/kotlinc.xml +kotlin = "2.2.20" +kotlinpoet = "2.2.0" +ksp = "2.2.20-2.0.2" +firebaseAppDistribution = "5.2.0" + +# AndroidX +core = "1.17.0" +datastore = "1.2.0" +constraintlayout = "2.2.1" +constraintlayout_compose = "1.1.1" +lifecycle = "2.9.2" +activity = "1.11.0" +media3 = "1.8.0" +camera = "1.5.1" +work = "2.11.0" + +# Compose +compose_bom = "2025.07.00" + +# Coroutines +coroutines = "1.10.2" + +# Accompanist +accompanist = "0.37.3" + +# Test +test_core = "1.7.0" +roborazzi = "1.52.0" + +# Jetbrain +datetime = "0.7.1" +serialization_json = "1.9.0" + +#other +coil = "3.3.0" +# Rollback to 1.0.4, 1.0.5 has this issue: https://github.com/airbnb/Showkase/issues/420 +showkase = "1.0.4" +appyx = "1.7.1" +sqldelight = "2.2.1" +wysiwyg = "2.40.0" +telephoto = "0.18.0" +haze = "1.6.10" + +# Dependency analysis +dependencyAnalysis = "3.5.1" + +# DI +metro = "0.7.7" + +# Auto service +autoservice = "1.1.1" + +# quality +detekt = "1.23.8" +# See https://github.com/pinterest/ktlint/releases/ +ktlint = "1.8.0" +androidx-test-ext-junit = "1.3.0" +kover = "0.9.1" + +[libraries] +# Project +android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" } +compose_compiler_plugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } +# https://developer.android.com/studio/write/java8-support#library-desugaring-versions +android_desugar = "com.android.tools:desugar_jdk_libs:2.1.5" +kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +metro_gradle_plugin = { module = "dev.zacsweers.metro:gradle-plugin", version.ref = "metro" } +kotlin_compiler = { module = "org.jetbrains.kotlin:kotlin-compiler", version.ref = "kotlin" } +kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinpoet" } +kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlinpoet" } +kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } +ksp_gradle_plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } +# https://firebase.google.com/docs/android/setup#available-libraries +google_firebase_bom = "com.google.firebase:firebase-bom:34.6.0" +firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" } +autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" } +ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } +google_tink = "com.google.crypto.tink:tink-android:1.19.0" + +# AndroidX +androidx_core = { module = "androidx.core:core", version.ref = "core" } +androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" } +androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.9.1" +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.4.1" +androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } +androidx_constraintlayout_compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintlayout_compose" } +androidx_camera_lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } +androidx_camera_view = { module = "androidx.camera:camera-view", version.ref = "camera" } +androidx_camera_camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } +androidx_javascriptengine = "androidx.javascriptengine:javascriptengine:1.0.0" +androidx_workmanager_runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } + +androidx_recyclerview = "androidx.recyclerview:recyclerview:1.4.0" +androidx_browser = "androidx.browser:browser:1.9.0" +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.2.0" +androidx_media3_exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } +androidx_media3_ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } +androidx_media3_transformer = { module = "androidx.media3:media3-transformer", version.ref = "media3" } +androidx_media3_effect = { module = "androidx.media3:media3-effect", version.ref = "media3" } +androidx_media3_common = { module = "androidx.media3:media3-common", version.ref = "media3" } +androidx_biometric = "androidx.biometric:biometric-ktx:1.2.0-alpha05" + +androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" } +androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } +androidx_startup = "androidx.startup:startup-runtime:1.2.0" +androidx_preference = "androidx.preference:preference:1.2.1" +androidx_webkit = "androidx.webkit:webkit:1.14.0" + +androidx_compose_bom = { module = "androidx.compose:compose-bom", version.ref = "compose_bom" } +androidx_compose_material3 = { module = "androidx.compose.material3:material3" } +androidx_compose_material3_windowsizeclass = { module = "androidx.compose.material3:material3-window-size-class" } +androidx_compose_material3_adaptive = "androidx.compose.material3:material3-adaptive-android:1.0.0-alpha06" +androidx_compose_ui = { module = "androidx.compose.ui:ui" } +androidx_compose_ui_tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx_compose_ui_tooling_preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx_compose_ui_test_manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx_compose_ui_test_junit = { module = "androidx.compose.ui:ui-test-junit4-android" } +androidx_compose_material = { module = "androidx.compose.material:material" } +androidx_compose_material_icons = { module = "androidx.compose.material:material-icons-extended" } + +# Coroutines +coroutines_core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +coroutines_guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "coroutines" } +coroutines_test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } + +# Accompanist +accompanist_permission = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } + +# Libraries +squareup_seismic = "com.squareup:seismic:1.0.3" + +# network +network_okhttp_bom = "com.squareup.okhttp3:okhttp-bom:5.3.2" +network_okhttp_logging = { module = "com.squareup.okhttp3:logging-interceptor" } +network_okhttp_okhttp = { module = "com.squareup.okhttp3:okhttp" } +network_okhttp = { module = "com.squareup.okhttp3:okhttp" } +network_mockwebserver = { module = "com.squareup.okhttp3:mockwebserver" } +network_retrofit_bom = "com.squareup.retrofit2:retrofit-bom:3.0.0" +network_retrofit = { module = "com.squareup.retrofit2:retrofit" } +network_retrofit_converter_serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization" } + +# Quality +# Reference ktlint-cli so that Renovate can check if a new version is available. +ktlint-cli = { module = "com.pinterest.ktlint:ktlint-cli", version.ref = "ktlint" } + +# 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.7.0" +test_mockk = "io.mockk:mockk:1.14.6" +test_konsist = "com.lemonappdev:konsist:0.17.3" +test_turbine = "app.cash.turbine:turbine:1.2.1" +test_truth = "com.google.truth:truth:1.4.5" +test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.20" +test_robolectric = "org.robolectric:robolectric:4.15.1" +test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" } +test_composable_preview_scanner = "io.github.sergio-sastre.ComposablePreviewScanner:android:0.7.2" +test_detekt_api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" } +test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version.ref = "detekt" } + +# Matrix SDK +# When upgrading the library, you may want to check what's new in the FFI layer by having a look to the +# latest commits from the history of this file: +# https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt +# All new features should not be implemented in the pull request that upgrades the version, developers should +# only fix API breaks and may add some TODOs. +matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.12.2" + +# Others +coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } +coil_network_okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } +coil_compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil_gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } +coil_test = { module = "io.coil-kt.coil3:coil-test", 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" } +kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0" +showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" } +showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" } +jsoup = "org.jsoup:jsoup:1.21.2" +appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } +molecule-runtime = "app.cash.molecule:molecule-runtime:2.2.0" +timber = "com.jakewharton.timber:timber:5.0.1" +matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" } +matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" } +sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } +sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" } +sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } +sqlcipher = "net.zetetic:sqlcipher-android:4.11.0" +sqlite = "androidx.sqlite:sqlite-ktx:2.6.2" +unifiedpush = "org.unifiedpush.android:connector:3.1.2" +vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" +telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } +telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" } +statemachine = "com.freeletics.flowredux:compose:1.2.2" +maplibre = "org.maplibre.gl:android-sdk:12.2.0" +maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2" +maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2" +opusencoder = "io.element.android:opusencoder:1.2.0" +zxing_cpp = "io.github.zxing-cpp:android:2.3.0" +haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } +haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } +color_picker = "io.mhssn:colorpicker:1.0.0" + +# Analytics +posthog = "com.posthog:posthog-android:3.26.0" +sentry = "io.sentry:sentry-android:8.27.1" +# main branch can be tested replacing the version with main-SNAPSHOT +matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.29.2" + +# Emojibase +matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.5.0" +sigpwned_emoji4j = "com.sigpwned:emoji4j-core:16.0.0" + +# Di +metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" } + +# Element Call +element_call_embedded = "io.element.android:element-call-embedded:0.16.3" + +# 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 +androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } + +# Test +test_roborazzi = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi" } +test_roborazzi_compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi" } +test_roborazzi_junit = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi" } + +[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" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +# Note: used in DependencyInjectionExtensions.kt +metro = { id = "dev.zacsweers.metro", version.ref = "metro" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +ktlint = "org.jlleitschuh.gradle.ktlint:14.0.1" +dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12" +dependencycheck = "org.owasp.dependencycheck:12.1.9" +dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" } +paparazzi = "app.cash.paparazzi:2.0.0-alpha02" +roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } +firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" } +knit = { id = "org.jetbrains.kotlinx.knit", version = "0.5.0" } +sonarqube = "org.sonarqube:7.1.0.6387" +licensee = "app.cash.licensee:1.14.1" +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +gms_google_services = { id = "com.google.gms.google-services", version = "4.4.4" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f8e1ee3 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 0000000..8a84887 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=72f44c9f8ebcb1af43838f45ee5c4aa9c5444898b3468ab3f4af7b6076c5bc3f +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# 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/platforms/jvm/plugins-application/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##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || 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 + + + +# 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=SC2039,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=SC2039,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" ) + + 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 optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# 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 0000000..c4bdd3a --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@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 +@rem SPDX-License-Identifier: Apache-2.0 +@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. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +: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/accountselect/api/build.gradle.kts b/libraries/accountselect/api/build.gradle.kts new file mode 100644 index 0000000..c7f5a97 --- /dev/null +++ b/libraries/accountselect/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.accountselect.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/libraries/accountselect/api/src/main/kotlin/io/element/android/libraries/accountselect/api/AccountSelectEntryPoint.kt b/libraries/accountselect/api/src/main/kotlin/io/element/android/libraries/accountselect/api/AccountSelectEntryPoint.kt new file mode 100644 index 0000000..0af756a --- /dev/null +++ b/libraries/accountselect/api/src/main/kotlin/io/element/android/libraries/accountselect/api/AccountSelectEntryPoint.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.accountselect.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.SessionId + +interface AccountSelectEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: Callback, + ): Node + + interface Callback : Plugin { + fun onAccountSelected(sessionId: SessionId) + fun onCancel() + } +} diff --git a/libraries/accountselect/impl/build.gradle.kts b/libraries/accountselect/impl/build.gradle.kts new file mode 100644 index 0000000..2497299 --- /dev/null +++ b/libraries/accountselect/impl/build.gradle.kts @@ -0,0 +1,36 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.accountselect.impl" +} + +setupDependencyInjection() + +dependencies { + implementation(projects.libraries.core) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.sessionStorage.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + api(projects.libraries.accountselect.api) + + testCommonDependencies(libs) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.sessionStorage.test) +} diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt new file mode 100644 index 0000000..1f52143 --- /dev/null +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectNode.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.accountselect.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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint +import io.element.android.libraries.architecture.callback + +@ContributesNode(AppScope::class) +@AssistedInject +class AccountSelectNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: AccountSelectPresenter, +) : Node(buildContext, plugins = plugins) { + private val callback: AccountSelectEntryPoint.Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + AccountSelectView( + state = state, + onDismiss = callback::onCancel, + onSelectAccount = callback::onAccountSelected, + modifier = modifier, + ) + } +} diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt new file mode 100644 index 0000000..be6a8be --- /dev/null +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.accountselect.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Inject +class AccountSelectPresenter( + private val sessionStore: SessionStore, +) : Presenter { + @Composable + override fun present(): AccountSelectState { + val accounts by produceState>(persistentListOf()) { + // Do not use sessionStore.sessionsFlow() to not make it change when an account is selected. + value = sessionStore.getAllSessions() + .map { + MatrixUser( + userId = UserId(it.userId), + displayName = it.userDisplayName, + avatarUrl = it.userAvatarUrl, + ) + } + .toImmutableList() + } + + return AccountSelectState( + accounts = accounts, + ) + } +} diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectState.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectState.kt new file mode 100644 index 0000000..518c49f --- /dev/null +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectState.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.accountselect.impl + +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList + +data class AccountSelectState( + val accounts: ImmutableList, +) diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectStateProvider.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectStateProvider.kt new file mode 100644 index 0000000..0c12d34 --- /dev/null +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectStateProvider.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.accountselect.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import kotlinx.collections.immutable.toImmutableList + +open class AccountSelectStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anAccountSelectState(), + anAccountSelectState(accounts = aMatrixUserList()), + ) +} + +private fun anAccountSelectState( + accounts: List = listOf(), +) = AccountSelectState( + accounts = accounts.toImmutableList(), +) diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt new file mode 100644 index 0000000..bfa1bf4 --- /dev/null +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.accountselect.impl + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +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.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +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.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.ui.components.MatrixUserRow +import io.element.android.libraries.ui.strings.CommonStrings + +@Suppress("MultipleEmitters") // False positive +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountSelectView( + state: AccountSelectState, + onSelectAccount: (SessionId) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = { onDismiss() }) + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + titleStr = stringResource(CommonStrings.common_select_account), + navigationIcon = { + BackButton(onClick = { onDismiss() }) + }, + ) + } + ) { paddingValues -> + Column( + Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + ) { + LazyColumn { + items(state.accounts, key = { it.userId }) { matrixUser -> + Column { + MatrixUserRow( + modifier = Modifier + .fillMaxWidth() + .clickable { + onSelectAccount(matrixUser.userId) + } + .padding(vertical = 8.dp), + matrixUser = matrixUser, + ) + HorizontalDivider() + } + } + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun AccountSelectViewPreview(@PreviewParameter(AccountSelectStateProvider::class) state: AccountSelectState) = ElementPreview { + AccountSelectView( + state = state, + onSelectAccount = {}, + onDismiss = {}, + ) +} diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/DefaultAccountSelectEntryPoint.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/DefaultAccountSelectEntryPoint.kt new file mode 100644 index 0000000..6d601f7 --- /dev/null +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/DefaultAccountSelectEntryPoint.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.accountselect.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultAccountSelectEntryPoint : AccountSelectEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: AccountSelectEntryPoint.Callback, + ): Node { + return parentNode.createNode(buildContext, listOf(callback)) + } +} diff --git a/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt new file mode 100644 index 0000000..8b0f581 --- /dev/null +++ b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.accountselect.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.user.MatrixUser +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.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class AccountSelectPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createAccountSelectPresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.accounts).isEmpty() + } + } + + @Test + fun `present - multiple accounts case`() = runTest { + val presenter = createAccountSelectPresenter( + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_SESSION_ID.value), + aSessionData( + sessionId = A_SESSION_ID_2.value, + userDisplayName = "Bob", + userAvatarUrl = "avatarUrl", + ), + ) + ) + ) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.accounts).hasSize(2) + val firstAccount = initialState.accounts[0] + assertThat(firstAccount).isEqualTo( + MatrixUser( + userId = A_SESSION_ID, + displayName = null, + avatarUrl = null, + ) + ) + val secondAccount = initialState.accounts[1] + assertThat(secondAccount).isEqualTo( + MatrixUser( + userId = A_SESSION_ID_2, + displayName = "Bob", + avatarUrl = "avatarUrl", + ) + ) + } + } +} + +internal fun createAccountSelectPresenter( + sessionStore: SessionStore = InMemorySessionStore(), +) = AccountSelectPresenter( + sessionStore = sessionStore, +) diff --git a/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/DefaultAccountSelectEntryPointTest.kt b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/DefaultAccountSelectEntryPointTest.kt new file mode 100644 index 0000000..870c43e --- /dev/null +++ b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/DefaultAccountSelectEntryPointTest.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.accountselect.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultAccountSelectEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultAccountSelectEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + AccountSelectNode( + buildContext = buildContext, + plugins = plugins, + presenter = createAccountSelectPresenter(), + ) + } + val callback = object : AccountSelectEntryPoint.Callback { + override fun onAccountSelected(sessionId: SessionId) = lambdaError() + override fun onCancel() = lambdaError() + } + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + callback = callback, + ) + assertThat(result).isInstanceOf(AccountSelectNode::class.java) + assertThat(result.plugins).contains(callback) + } +} diff --git a/libraries/androidutils/.gitignore b/libraries/androidutils/.gitignore new file mode 100644 index 0000000..42afabf --- /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 0000000..90aa4bd --- /dev/null +++ b/libraries/androidutils/build.gradle.kts @@ -0,0 +1,42 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.androidutils" + + buildFeatures { + buildConfig = true + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.libraries.di) + + implementation(projects.libraries.core) + implementation(projects.services.toolbox.api) + implementation(libs.timber) + implementation(libs.androidx.corektx) + implementation(libs.androidx.activity.activity) + implementation(libs.androidx.recyclerview) + implementation(libs.androidx.exifinterface) + implementation(libs.androidx.datastore.preferences) + implementation(libs.serialization.json) + api(libs.androidx.browser) + + testCommonDependencies(libs) + testImplementation(libs.coroutines.core) + testImplementation(projects.services.toolbox.test) +} diff --git a/libraries/androidutils/consumer-rules.pro b/libraries/androidutils/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/libraries/androidutils/src/main/AndroidManifest.xml b/libraries/androidutils/src/main/AndroidManifest.xml new file mode 100644 index 0000000..446c460 --- /dev/null +++ b/libraries/androidutils/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/assets/AssetReader.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/assets/AssetReader.kt new file mode 100644 index 0000000..da44ff4 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/assets/AssetReader.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.assets + +import android.content.Context +import dev.zacsweers.metro.Inject +import io.element.android.libraries.di.annotations.ApplicationContext +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap + +/** + * Read asset files. + */ +@Inject +class AssetReader( + @ApplicationContext private val context: Context, +) { + private val cache = ConcurrentHashMap() + + /** + * Read an asset from resource and return a String or null in case of error. + * + * @param assetFilename Asset filename + * @return the content of the asset file, or null in case of error + */ + fun readAssetFile(assetFilename: String): String? { + return cache.getOrPut(assetFilename, { + return try { + context.assets.open(assetFilename).use { it.bufferedReader().readText() } + } catch (e: Exception) { + Timber.e(e, "## readAssetFile() failed") + null + } + }) + } + + fun clearCache() { + cache.clear() + } +} 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 0000000..0ef4c94 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/bitmap/Bitmap.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 kotlin.math.min + +fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int) { + outputStream().use { out -> + bitmap.compress(format, quality, out) + out.flush() + } +} + +/** + * 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 +} + +/** + * Decodes the [inputStream] into a [Bitmap] and applies the needed rotation based on [orientation]. + * This orientation value must be one of `ExifInterface.ORIENTATION_*` constants. + */ +fun Bitmap.rotateToExifMetadataOrientation(orientation: Int): Bitmap { + 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 this + } + + return Bitmap.createBitmap(this, 0, 0, width, 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 0000000..1985184 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.browser + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.os.Bundle +import android.provider.Browser +import androidx.browser.customtabs.CustomTabColorSchemeParams +import androidx.browser.customtabs.CustomTabsIntent +import androidx.browser.customtabs.CustomTabsSession +import androidx.core.net.toUri +import io.element.android.libraries.androidutils.system.openUrlInExternalApp +import java.util.Locale + +/** + * 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 + } + ) + .setShareIdentityEnabled(false) + // 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() + .apply { + // Disable download button + intent.putExtra("org.chromium.chrome.browser.customtabs.EXTRA_DISABLE_DOWNLOAD_BUTTON", true) + // Disable bookmark button + intent.putExtra("org.chromium.chrome.browser.customtabs.EXTRA_DISABLE_STAR_BUTTON", true) + intent.putExtra(Browser.EXTRA_HEADERS, Bundle().apply { + putString("Accept-Language", Locale.getDefault().toLanguageTag()) + }) + } + .launchUrl(this, url.toUri()) + } catch (activityNotFoundException: ActivityNotFoundException) { + openUrlInExternalApp(url) + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ConsoleMessageLogger.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ConsoleMessageLogger.kt new file mode 100644 index 0000000..d165da8 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ConsoleMessageLogger.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.browser + +import android.util.Log +import android.webkit.ConsoleMessage +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import timber.log.Timber + +interface ConsoleMessageLogger { + fun log( + tag: String, + consoleMessage: ConsoleMessage, + ) +} + +@ContributesBinding(AppScope::class) +class DefaultConsoleMessageLogger : ConsoleMessageLogger { + override fun log( + tag: String, + consoleMessage: ConsoleMessage, + ) { + val priority = when (consoleMessage.messageLevel()) { + ConsoleMessage.MessageLevel.ERROR -> Log.ERROR + ConsoleMessage.MessageLevel.WARNING -> Log.WARN + else -> Log.DEBUG + } + + val message = buildString { + append(consoleMessage.sourceId()) + append(":") + append(consoleMessage.lineNumber()) + append(" ") + append(consoleMessage.message()) + } + + // Avoid logging any messages that contain "password" to prevent leaking sensitive information + if (message.contains("password=")) { + return + } + + Timber.tag(tag).log( + priority = priority, + message = buildString { + append(consoleMessage.sourceId()) + append(":") + append(consoleMessage.lineNumber()) + append(" ") + append(consoleMessage.message()) + }, + ) + } +} 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 0000000..ce6f61f --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.annotations.ApplicationContext + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class AndroidClipboardHelper( + @ApplicationContext private val context: Context, +) : ClipboardHelper { + private val clipboardManager = requireNotNull(context.getSystemService()) + + 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 0000000..1d0ab8e --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..9f91b3a --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..3abb7dc --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/Compat.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.compat + +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.os.Build + +fun PackageManager.queryIntentActivitiesCompat(data: Intent, flags: Int): List { + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> queryIntentActivities( + data, + PackageManager.ResolveInfoFlags.of(flags.toLong()) + ) + else -> @Suppress("DEPRECATION") queryIntentActivities(data, flags) + } +} + +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 -> getApplicationInfo(packageName, flags) + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DefaultDiffCallback.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DefaultDiffCallback.kt new file mode 100644 index 0000000..3940eda --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DefaultDiffCallback.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.diff + +import androidx.recyclerview.widget.DiffUtil + +/** + * Default implementation of [DiffUtil.Callback] that uses [areItemsTheSame] to compare items. + */ +internal class DefaultDiffCallback( + private val oldList: List, + private val newList: List, + private val areItemsTheSame: (oldItem: T?, newItem: T?) -> Boolean, +) : 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 areItemsTheSame(oldItem, newItem) + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldList.getOrNull(oldItemPosition) + val newItem = newList.getOrNull(newItemPosition) + return oldItem == newItem + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCache.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCache.kt new file mode 100644 index 0000000..6737a9b --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCache.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.diff + +/** + * A cache that can be used to store some data that can be invalidated when a diff is applied. + * The cache is invalidated by the [DiffCacheInvalidator]. + */ +interface DiffCache { + fun get(index: Int): E? + fun indices(): IntRange + fun isEmpty(): Boolean +} + +/** + * A [DiffCache] that can be mutated by adding, removing or updating elements. + */ +interface MutableDiffCache : DiffCache { + fun removeAt(index: Int): E? + fun add(index: Int, element: E?) + operator fun set(index: Int, element: E?) +} + +/** + * A [MutableDiffCache] backed by a [MutableList]. + * + */ +class MutableListDiffCache(private val mutableList: MutableList = ArrayList()) : MutableDiffCache { + override fun removeAt(index: Int): E? { + return mutableList.removeAt(index) + } + + override fun get(index: Int): E? { + return mutableList.getOrNull(index) + } + + override fun indices(): IntRange { + return mutableList.indices + } + + override fun isEmpty(): Boolean { + return mutableList.isEmpty() + } + + override operator fun set(index: Int, element: E?) { + mutableList[index] = element + } + + override fun add(index: Int, element: E?) { + mutableList.add(index, element) + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCacheInvalidator.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCacheInvalidator.kt new file mode 100644 index 0000000..f8d43b7 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCacheInvalidator.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.diff + +/** + * [DiffCacheInvalidator] is used to invalidate the cache when the list is updated. + * It is used by [DiffCacheUpdater]. + * Check the default implementation [DefaultDiffCacheInvalidator]. + */ +interface DiffCacheInvalidator { + fun onChanged(position: Int, count: Int, cache: MutableDiffCache) + + fun onMoved(fromPosition: Int, toPosition: Int, cache: MutableDiffCache) + + fun onInserted(position: Int, count: Int, cache: MutableDiffCache) + + fun onRemoved(position: Int, count: Int, cache: MutableDiffCache) +} + +/** + * Default implementation of [DiffCacheInvalidator]. + * It invalidates the cache by setting values to null. + */ +class DefaultDiffCacheInvalidator : DiffCacheInvalidator { + override fun onChanged(position: Int, count: Int, cache: MutableDiffCache) { + for (i in position until position + count) { + // Invalidate cache + cache[i] = null + } + } + + override fun onMoved(fromPosition: Int, toPosition: Int, cache: MutableDiffCache) { + val model = cache.removeAt(fromPosition) + cache.add(toPosition, model) + } + + override fun onInserted(position: Int, count: Int, cache: MutableDiffCache) { + repeat(count) { + cache.add(position, null) + } + } + + override fun onRemoved(position: Int, count: Int, cache: MutableDiffCache) { + repeat(count) { + cache.removeAt(position) + } + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCacheUpdater.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCacheUpdater.kt new file mode 100644 index 0000000..fce510f --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCacheUpdater.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.diff + +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListUpdateCallback +import timber.log.Timber +import kotlin.system.measureTimeMillis + +/** + * Class in charge of updating a [MutableDiffCache] according to the cache invalidation rules provided by the [DiffCacheInvalidator]. + * @param ListItem the type of the items in the list + * @param CachedItem the type of the items in the cache + * @param diffCache the cache to update + * @param detectMoves true if DiffUtil should try to detect moved items, false otherwise + * @param cacheInvalidator the invalidator to use to update the cache + * @param areItemsTheSame the function to use to compare items + */ +class DiffCacheUpdater( + private val diffCache: MutableDiffCache, + private val detectMoves: Boolean = false, + private val cacheInvalidator: DiffCacheInvalidator = DefaultDiffCacheInvalidator(), + private val areItemsTheSame: (oldItem: ListItem?, newItem: ListItem?) -> Boolean, +) { + private val lock = Object() + private var prevOriginalList: List = emptyList() + + private val listUpdateCallback = object : ListUpdateCallback { + override fun onInserted(position: Int, count: Int) { + cacheInvalidator.onInserted(position, count, diffCache) + } + + override fun onRemoved(position: Int, count: Int) { + cacheInvalidator.onRemoved(position, count, diffCache) + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + cacheInvalidator.onMoved(fromPosition, toPosition, diffCache) + } + + override fun onChanged(position: Int, count: Int, payload: Any?) { + cacheInvalidator.onChanged(position, count, diffCache) + } + } + + fun updateWith(newOriginalList: List) = synchronized(lock) { + val timeToDiff = measureTimeMillis { + val diffCallback = DefaultDiffCallback(prevOriginalList, newOriginalList, areItemsTheSame) + val diffResult = DiffUtil.calculateDiff(diffCallback, detectMoves) + prevOriginalList = newOriginalList + diffResult.dispatchUpdatesTo(listUpdateCallback) + } + Timber.v("Time to apply diff on new list of ${newOriginalList.size} items: $timeToDiff ms") + } +} 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 0000000..65e56a3 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/Context.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 +import io.element.android.libraries.core.extensions.runCatchingExceptions + +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? = runCatchingExceptions { + 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? = runCatchingExceptions { + 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/File.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt new file mode 100644 index 0000000..21306ff --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.UUID + +fun File.safeDelete() { + if (exists().not()) return + tryOrNull( + onException = { + 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( + onException = { + 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() } +} + +/* ========================================================================================== + * 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/FileCompression.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/FileCompression.kt new file mode 100644 index 0000000..1dbf87a --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/FileCompression.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/TemporaryUriDeleter.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/TemporaryUriDeleter.kt new file mode 100644 index 0000000..beef6c0 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/TemporaryUriDeleter.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.file + +import android.content.Context +import android.net.Uri +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.annotations.ApplicationContext +import timber.log.Timber + +interface TemporaryUriDeleter { + /** + * Delete the Uri only if it is a temporary one. + */ + fun delete(uri: Uri?) +} + +@ContributesBinding(AppScope::class) +class DefaultTemporaryUriDeleter( + @ApplicationContext private val context: Context, +) : TemporaryUriDeleter { + private val baseCacheUri = "content://${context.packageName}.fileprovider/cache" + + override fun delete(uri: Uri?) { + uri ?: return + if (uri.toString().startsWith(baseCacheUri)) { + context.contentResolver.delete(uri, null, null) + } else { + Timber.d("Do not delete the uri") + } + } +} 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 0000000..100fdcd --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/AndroidFileSizeFormatter.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.filesize + +import android.content.Context +import android.os.Build +import android.text.format.Formatter +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider + +@ContributesBinding(AppScope::class) +class AndroidFileSizeFormatter( + @ApplicationContext private val context: Context, + private val sdkIntProvider: BuildVersionSdkIntProvider, +) : 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 (sdkIntProvider.get() <= 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 0000000..1a3e540 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/FakeFileSizeFormatter.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..579c34b --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/FileSizeFormatter.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/VibratorTools.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/hardware/VibratorTools.kt new file mode 100644 index 0000000..b6e01aa --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/hardware/VibratorTools.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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() ?: 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/hash/Hash.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/hash/Hash.kt new file mode 100644 index 0000000..211b60a --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/hash/Hash.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.hash + +import java.security.MessageDigest +import java.util.Locale + +/** + * Compute a Hash of a String, using SHA-512 algorithm. + */ +fun String.hash() = try { + val digest = MessageDigest.getInstance("SHA-512") + digest.update(toByteArray()) + digest.digest() + .joinToString("") { String.format(Locale.ROOT, "%02X", it) } + .lowercase(Locale.ROOT) +} catch (exc: Exception) { + // Should not happen, but just in case + hashCode().toString() +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/json/JsonProvider.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/json/JsonProvider.kt new file mode 100644 index 0000000..1876bbd --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/json/JsonProvider.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.json + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Provider +import dev.zacsweers.metro.SingleIn +import kotlinx.serialization.json.Json + +/** + * Provides a Json instance configured to ignore unknown keys. + */ +fun interface JsonProvider : Provider + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class DefaultJsonProvider : JsonProvider { + private val json: Json by lazy { Json { ignoreUnknownKeys = true } } + override fun invoke() = json +} 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 0000000..1b0f783 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 MediaMetadataRetriever.runAndRelease(block: MediaMetadataRetriever.() -> T): T { + return try { + block() + } finally { + release() + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/VideoCompressorHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/VideoCompressorHelper.kt new file mode 100644 index 0000000..6262d00 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/VideoCompressorHelper.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.media + +import android.util.Size +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * Helper class to calculate the resulting output size and optimal bitrate for video compression. + */ +class VideoCompressorHelper( + /** + * The maximum size (in pixels) for the output video. + * The output will maintain the aspect ratio of the input video. + */ + val maxSize: Int, +) { + /** + * Calculates the output size for video compression based on the input size and [maxSize]. + */ + fun getOutputSize(inputSize: Size): Size { + val resultMajor = min(inputSize.major(), maxSize) + val aspectRatio = inputSize.major().toFloat() / inputSize.minor().toFloat() + return if (inputSize.isLandscape()) { + Size(resultMajor, (resultMajor / aspectRatio).roundToInt()) + } else { + Size((resultMajor / aspectRatio).roundToInt(), resultMajor) + } + } + + /** + * Calculates the optimal bitrate for video compression based on the input size and frame rate. + */ + fun calculateOptimalBitrate(inputSize: Size, frameRate: Int): Long { + val outputSize = getOutputSize(inputSize) + val pixelsPerFrame = outputSize.width * outputSize.height + // Apparently, 0.1 bits per pixel is a sweet spot for video compression + val bitsPerPixel = 0.1f + return (pixelsPerFrame * bitsPerPixel * frameRate).toLong() + } +} + +private fun Size.isLandscape(): Boolean = width > height +private fun Size.major(): Int = if (isLandscape()) width else height +private fun Size.minor(): Int = if (isLandscape()) height else width diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/metadata/IsInDebug.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/metadata/IsInDebug.kt new file mode 100644 index 0000000..658a28c --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/metadata/IsInDebug.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.metadata + +import io.element.android.libraries.androidutils.BuildConfig + +/** + * true if the app is built in debug mode. + * For testing purpose, this can be changed with [withReleaseBehavior]. + */ +var isInDebug: Boolean = BuildConfig.DEBUG + private set + +/** + * Run the lambda simulating the app is in release mode. + * + * **IMPORTANT**: this should **ONLY** be used for testing purposes. + */ +fun withReleaseBehavior(lambda: () -> Unit) { + isInDebug = false + lambda() + isInDebug = BuildConfig.DEBUG +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/preferences/DefaultPreferencesCorruptionHandlerFactory.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/preferences/DefaultPreferencesCorruptionHandlerFactory.kt new file mode 100644 index 0000000..b78e8ee --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/preferences/DefaultPreferencesCorruptionHandlerFactory.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.preferences + +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences + +object DefaultPreferencesCorruptionHandlerFactory { + /** + * Creates a [ReplaceFileCorruptionHandler] that will replace the corrupted preferences file with an empty preferences object. + */ + fun replaceWithEmpty(): ReplaceFileCorruptionHandler { + return ReplaceFileCorruptionHandler( + produceNewData = { + // If the preferences file is corrupted, we return an empty preferences object + emptyPreferences() + }, + ) + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/Accessibility.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/Accessibility.kt new file mode 100644 index 0000000..35ebd89 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/Accessibility.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.system + +import android.content.Context +import android.provider.Settings + +fun Context.getAnimationScale(): Float { + return Settings.Global.getFloat(contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f) +} + +fun Context.areAnimationsEnabled(): Boolean { + return getAnimationScale() > 0f +} 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 0000000..53a1c33 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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() + ?.setPrimaryClip(ClipData.newPlainText("", text)) + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/DateTimeObserver.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/DateTimeObserver.kt new file mode 100644 index 0000000..3723eac --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/DateTimeObserver.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.system + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.androidutils.system.DateTimeObserver.Event +import io.element.android.libraries.di.annotations.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import java.time.Instant + +interface DateTimeObserver { + val changes: Flow + + sealed interface Event { + data object TimeZoneChanged : Event + data class DateChanged(val previous: Instant, val new: Instant) : Event + } +} + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class DefaultDateTimeObserver( + @ApplicationContext context: Context +) : DateTimeObserver { + private val dateTimeReceiver = object : BroadcastReceiver() { + private var lastTime = Instant.now() + + override fun onReceive(context: Context, intent: Intent) { + val newDate = Instant.now() + when (intent.action) { + Intent.ACTION_TIMEZONE_CHANGED -> changes.tryEmit(Event.TimeZoneChanged) + Intent.ACTION_DATE_CHANGED -> changes.tryEmit(Event.DateChanged(lastTime, newDate)) + Intent.ACTION_TIME_CHANGED -> changes.tryEmit(Event.DateChanged(lastTime, newDate)) + } + lastTime = newDate + } + } + + override val changes = MutableSharedFlow(extraBufferCapacity = 10) + + init { + context.registerReceiver(dateTimeReceiver, IntentFilter().apply { + addAction(Intent.ACTION_TIMEZONE_CHANGED) + addAction(Intent.ACTION_DATE_CHANGED) + addAction(Intent.ACTION_TIME_CHANGED) + }) + } +} 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 0000000..354b68a --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.system + +import android.app.Activity +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.provider.Settings +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.RequiresApi +import androidx.core.content.pm.PackageInfoCompat +import androidx.core.net.toUri +import io.element.android.libraries.androidutils.R +import io.element.android.libraries.androidutils.compat.getApplicationInfoCompat +import io.element.android.libraries.core.mimetype.MimeTypes + +/** + * 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 + } +} + +/** + * Retrieve the versionCode from the Manifest. + * The value is more accurate than BuildConfig.VERSION_CODE, as it is correct according to the + * computation in the `androidComponents` block of the app build.gradle.kts file. + * In other words, the last digit (for the architecture) will be set, whereas BuildConfig.VERSION_CODE + * last digit will always be 0. + */ +fun Context.getVersionCodeFromManifest(): Long { + return PackageInfoCompat.getLongVersionCode( + packageManager.getPackageInfo(packageName, 0) + ) +} + +// ============================================================================================================== +// 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? = null, + noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found), +) { + val intent = Intent() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS + if (this !is Activity && activityResultLauncher == null) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + 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) + } + + try { + if (activityResultLauncher != null) { + activityResultLauncher.launch(intent) + } else { + startActivity(intent) + } + } catch (activityNotFoundException: ActivityNotFoundException) { + toast(noActivityFoundMessage) + } +} + +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) + } +} + +@RequiresApi(Build.VERSION_CODES.O) +fun Context.startInstallFromSourceIntent( + activityResultLauncher: ActivityResultLauncher, + noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found), +) { + val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES) + .setData("package:$packageName".toUri()) + try { + activityResultLauncher.launch(intent) + } catch (activityNotFoundException: ActivityNotFoundException) { + toast(noActivityFoundMessage) + } +} + +fun Context.startSharePlainTextIntent( + activityResultLauncher: ActivityResultLauncher?, + 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 = MimeTypes.PlainText + 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) + } +} + +@Suppress("SwallowedException") +fun Context.openUrlInExternalApp( + url: String, + errorMessage: String = getString(R.string.error_no_compatible_app_found), + throwInCaseOfError: Boolean = false, +) { + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + if (this !is Activity) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + startActivity(intent) + } catch (activityNotFoundException: ActivityNotFoundException) { + if (throwInCaseOfError) throw activityNotFoundException + toast(errorMessage) + } +} + +/** + * Open Google Play on the provided application Id. + */ +fun Context.openGooglePlay( + appId: String, +) { + try { + openUrlInExternalApp( + url = "market://details?id=$appId", + throwInCaseOfError = true, + ) + } catch (_: ActivityNotFoundException) { + openUrlInExternalApp("https://play.google.com/store/apps/details?id=$appId") + } +} + +// 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/text/LinkifyHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt new file mode 100644 index 0000000..916f365 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.text + +import android.text.Spannable +import android.text.style.URLSpan +import android.text.util.Linkify +import androidx.core.text.getSpans +import androidx.core.text.toSpannable +import androidx.core.text.util.LinkifyCompat +import io.element.android.libraries.core.extensions.runCatchingExceptions +import timber.log.Timber +import kotlin.collections.component1 +import kotlin.collections.component2 + +/** + * Helper class to linkify text while preserving existing URL spans. + * + * It also checks the linkified results to make sure URLs spans are not including trailing punctuation. + */ +object LinkifyHelper { + fun linkify( + text: CharSequence, + @LinkifyCompat.LinkifyMask linkifyMask: Int = Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES, + ): CharSequence { + // Convert the text to a Spannable to be able to add URL spans, return the original text if it's not possible (in tests, i.e.) + val spannable = text.toSpannable() ?: return text + + // Get all URL spans, as they will be removed by LinkifyCompat.addLinks + val oldURLSpans = spannable.getSpans(0, text.length).associateWith { + val start = spannable.getSpanStart(it) + val end = spannable.getSpanEnd(it) + Pair(start, end) + } + // Find and set as URLSpans any links present in the text + val addedNewLinks = LinkifyCompat.addLinks(spannable, linkifyMask) + + // Process newly added URL spans + if (addedNewLinks) { + val newUrlSpans = spannable.getSpans(0, spannable.length) + for (urlSpan in newUrlSpans) { + val start = spannable.getSpanStart(urlSpan) + val end = spannable.getSpanEnd(urlSpan) + + // Try to avoid including trailing punctuation in the link. + // Since this might fail in some edge cases, we catch the exception and just use the original end index. + val newEnd = runCatchingExceptions { + adjustLinkifiedUrlSpanEndIndex(spannable, start, end) + }.onFailure { + Timber.e(it, "Failed to adjust end index for link span") + }.getOrNull() ?: end + + // Adapt the url in the URL span to the new end index too if needed + if (end != newEnd) { + val url = spannable.subSequence(start, newEnd).toString() + spannable.removeSpan(urlSpan) + spannable.setSpan(URLSpan(url), start, newEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } else { + spannable.setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + } + + // Restore old spans, remove new ones if there is a conflict + for ((urlSpan, location) in oldURLSpans) { + val (start, end) = location + val addedConflictingSpans = spannable.getSpans(start, end) + if (addedConflictingSpans.isNotEmpty()) { + for (span in addedConflictingSpans) { + spannable.removeSpan(span) + } + } + + spannable.setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + return spannable + } + + private fun adjustLinkifiedUrlSpanEndIndex(spannable: Spannable, start: Int, end: Int): Int { + var end = end + + // Trailing punctuation found, adjust the end index + while (spannable[end - 1] in sequenceOf('.', ',', ';', ':', '!', '?', '…') && end > start) { + end-- + } + + // If the last character is a closing parenthesis, check if it's part of a pair + if (spannable[end - 1] == ')' && end > start) { + val linkifiedTextLastPath = spannable.substring(start, end).substringAfterLast('/') + val closingParenthesisCount = linkifiedTextLastPath.count { it == ')' } + val openingParenthesisCount = linkifiedTextLastPath.count { it == '(' } + // If it's not part of a pair, remove it from the link span by adjusting the end index + end -= closingParenthesisCount - openingParenthesisCount + } + return end + } +} + +/** + * Linkify the text with the default mask (WEB_URLS, PHONE_NUMBERS, EMAIL_ADDRESSES). + */ +fun CharSequence.safeLinkify(): CharSequence { + return LinkifyHelper.linkify(this, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES) +} 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 0000000..ebd4a5c --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/throttler/FirstThrottler.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +package io.element.android.libraries.androidutils.throttler + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Simple ThrottleFirst + * See https://raw.githubusercontent.com/wiki/ReactiveX/RxJava/images/rx-operators/throttleFirst.png + */ +class FirstThrottler( + private val minimumInterval: Long = 800, + private val coroutineScope: CoroutineScope, +) { + private val canHandle = AtomicBoolean(true) + + fun canHandle(): Boolean { + return canHandle.getAndSet(false).also { result -> + if (result) { + coroutineScope.launch { + delay(minimumInterval) + canHandle.set(true) + } + } + } + } +} 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 0000000..96a1e5f --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/ui/View.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.ui + +import android.os.Build +import android.view.View +import android.view.ViewTreeObserver +import android.view.WindowInsets +import android.view.inputmethod.InputMethodManager +import androidx.core.content.getSystemService +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +fun View.hideKeyboard() { + val imm = context?.getSystemService() + imm?.hideSoftInputFromWindow(windowToken, 0) +} + +fun View.showKeyboard(andRequestFocus: Boolean = false) { + if (andRequestFocus) { + requestFocus() + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + windowInsetsController?.show(WindowInsets.Type.ime()) + } else { + val imm = context?.getSystemService() + imm?.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) + } +} + +fun View.isKeyboardVisible(): Boolean { + val imm = context?.getSystemService() + return imm?.isAcceptingText == true +} + +suspend fun View.awaitWindowFocus() = suspendCancellableCoroutine { continuation -> + if (hasWindowFocus()) { + continuation.resume(Unit) + } else { + val listener = object : ViewTreeObserver.OnWindowFocusChangeListener { + override fun onWindowFocusChanged(hasFocus: Boolean) { + if (hasFocus) { + viewTreeObserver.removeOnWindowFocusChangeListener(this) + continuation.resume(Unit) + } + } + } + + viewTreeObserver.addOnWindowFocusChangeListener(listener) + + continuation.invokeOnCancellation { + viewTreeObserver.removeOnWindowFocusChangeListener(listener) + } + } +} 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 0000000..1c18c22 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/uri/UriExtensions.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2021-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.uri + +import android.net.Uri +import androidx.core.net.toUri + +const val IGNORED_SCHEMA = "ignored" + +fun createIgnoredUri(path: String): Uri = "$IGNORED_SCHEMA://$path".toUri() diff --git a/libraries/androidutils/src/main/res/values-be/translations.xml b/libraries/androidutils/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..699c253 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-be/translations.xml @@ -0,0 +1,4 @@ + + + "Не знойдзена сумяшчальная праграма для выканання гэтага дзеяння." + 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 0000000..345812c --- /dev/null +++ b/libraries/androidutils/src/main/res/values-cs/translations.xml @@ -0,0 +1,4 @@ + + + "Nebyla nalezena žádná kompatibilní aplikace, která by tuto akci zpracovala." + diff --git a/libraries/androidutils/src/main/res/values-cy/translations.xml b/libraries/androidutils/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..edd5b61 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-cy/translations.xml @@ -0,0 +1,4 @@ + + + "Heb ganfod ap cydnaws i drin y weithred hon." + diff --git a/libraries/androidutils/src/main/res/values-da/translations.xml b/libraries/androidutils/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..73a3ca6 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-da/translations.xml @@ -0,0 +1,4 @@ + + + "Der blev ikke fundet nogen kompatibel app til at håndtere denne handling." + 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 0000000..99b7294 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-de/translations.xml @@ -0,0 +1,4 @@ + + + "Für diese Aktion wurde keine kompatible App gefunden." + diff --git a/libraries/androidutils/src/main/res/values-el/translations.xml b/libraries/androidutils/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..ef86d3c --- /dev/null +++ b/libraries/androidutils/src/main/res/values-el/translations.xml @@ -0,0 +1,4 @@ + + + "Δεν βρέθηκε συμβατή εφαρμογή για να χειριστεί αυτήν την ενέργεια." + 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 0000000..d953732 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-es/translations.xml @@ -0,0 +1,4 @@ + + + "No se encontró ninguna aplicación compatible con esta acción." + diff --git a/libraries/androidutils/src/main/res/values-et/translations.xml b/libraries/androidutils/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..80c50f1 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-et/translations.xml @@ -0,0 +1,4 @@ + + + "Ei õnnestunud leida selle tegevuse jaoks vajalikku välist rakendust." + diff --git a/libraries/androidutils/src/main/res/values-eu/translations.xml b/libraries/androidutils/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..3ea09b6 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-eu/translations.xml @@ -0,0 +1,4 @@ + + + "Ez da ekintza hau kudeatzeko aplikazio bateragarririk aurkitu." + diff --git a/libraries/androidutils/src/main/res/values-fa/translations.xml b/libraries/androidutils/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..b6212e7 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-fa/translations.xml @@ -0,0 +1,4 @@ + + + "هیچ برنامه سازگاری برای انجام این عمل یافت نشد." + diff --git a/libraries/androidutils/src/main/res/values-fi/translations.xml b/libraries/androidutils/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..cbf6ef5 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-fi/translations.xml @@ -0,0 +1,4 @@ + + + "Yhteensopivaa sovellusta ei löytynyt käsittelemään tätä toimintoa." + 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 0000000..a575424 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-fr/translations.xml @@ -0,0 +1,4 @@ + + + "Aucune application compatible n’a été trouvée pour gérer cette action." + diff --git a/libraries/androidutils/src/main/res/values-hu/translations.xml b/libraries/androidutils/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..dbbc088 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-hu/translations.xml @@ -0,0 +1,4 @@ + + + "Nem található kompatibilis alkalmazás a művelet kezeléséhez." + diff --git a/libraries/androidutils/src/main/res/values-in/translations.xml b/libraries/androidutils/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..b593b49 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-in/translations.xml @@ -0,0 +1,4 @@ + + + "Tidak ada aplikasi yang kompatibel yang ditemukan untuk menangani tindakan ini." + 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 0000000..fcafd9f --- /dev/null +++ b/libraries/androidutils/src/main/res/values-it/translations.xml @@ -0,0 +1,4 @@ + + + "Non è stata trovata alcuna app compatibile per gestire questa azione." + diff --git a/libraries/androidutils/src/main/res/values-ka/translations.xml b/libraries/androidutils/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..ee284f3 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-ka/translations.xml @@ -0,0 +1,4 @@ + + + "თავსებადი აპლიკაცია ვერ მოიძებნა ამ მოქმედების შესასრულებლად." + diff --git a/libraries/androidutils/src/main/res/values-ko/translations.xml b/libraries/androidutils/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..8e1a6fa --- /dev/null +++ b/libraries/androidutils/src/main/res/values-ko/translations.xml @@ -0,0 +1,4 @@ + + + "이 동작을 수행할 수 있는 앱을 찾지 못했습니다." + 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 0000000..2f963a6 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-ldrtl/integers.xml @@ -0,0 +1,13 @@ + + + + -1 + 180 + + diff --git a/libraries/androidutils/src/main/res/values-lt/translations.xml b/libraries/androidutils/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..69e6db1 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-lt/translations.xml @@ -0,0 +1,4 @@ + + + "Nerasta suderinamos programos, kuri galėtų atlikti šį veiksmą." + diff --git a/libraries/androidutils/src/main/res/values-nb/translations.xml b/libraries/androidutils/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..5b80750 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-nb/translations.xml @@ -0,0 +1,4 @@ + + + "Ingen kompatibel app ble funnet for å håndtere denne handlingen." + diff --git a/libraries/androidutils/src/main/res/values-nl/translations.xml b/libraries/androidutils/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..318e578 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-nl/translations.xml @@ -0,0 +1,4 @@ + + + "Er is geen compatibele app gevonden om deze actie uit te voeren." + diff --git a/libraries/androidutils/src/main/res/values-pl/translations.xml b/libraries/androidutils/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..6302f19 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-pl/translations.xml @@ -0,0 +1,4 @@ + + + "Nie znaleziono kompatybilnej aplikacji do obsługi tej akcji." + diff --git a/libraries/androidutils/src/main/res/values-pt-rBR/translations.xml b/libraries/androidutils/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..cde5b74 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,4 @@ + + + "Nenhum aplicativo compatível foi encontrado para lidar com essa ação." + diff --git a/libraries/androidutils/src/main/res/values-pt/translations.xml b/libraries/androidutils/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..9795e1b --- /dev/null +++ b/libraries/androidutils/src/main/res/values-pt/translations.xml @@ -0,0 +1,4 @@ + + + "Nenhuma aplicação encontrada capaz de continuar esta ação." + 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 0000000..eac7dd0 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-ro/translations.xml @@ -0,0 +1,4 @@ + + + "Nu a fost găsită nicio aplicație capabilă să gestioneze această acțiune." + diff --git a/libraries/androidutils/src/main/res/values-ru/translations.xml b/libraries/androidutils/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..bb236dc --- /dev/null +++ b/libraries/androidutils/src/main/res/values-ru/translations.xml @@ -0,0 +1,4 @@ + + + "Не найдено совместимое приложение для обработки этого действия." + 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 0000000..9ef1993 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-sk/translations.xml @@ -0,0 +1,4 @@ + + + "Nebola nájdená žiadna kompatibilná aplikácia, ktorá by túto akciu dokázala spracovať." + diff --git a/libraries/androidutils/src/main/res/values-sv/translations.xml b/libraries/androidutils/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..0d1cbd5 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-sv/translations.xml @@ -0,0 +1,4 @@ + + + "Ingen kompatibel app hittades för att hantera den här åtgärden." + diff --git a/libraries/androidutils/src/main/res/values-tr/translations.xml b/libraries/androidutils/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..070ef9d --- /dev/null +++ b/libraries/androidutils/src/main/res/values-tr/translations.xml @@ -0,0 +1,4 @@ + + + "Bu eylemi gerçekleştirecek uyumlu bir uygulama bulunamadı." + diff --git a/libraries/androidutils/src/main/res/values-uk/translations.xml b/libraries/androidutils/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..d9f7c8c --- /dev/null +++ b/libraries/androidutils/src/main/res/values-uk/translations.xml @@ -0,0 +1,4 @@ + + + "Не знайдено сумісного застосунку для виконання цієї дії." + diff --git a/libraries/androidutils/src/main/res/values-ur/translations.xml b/libraries/androidutils/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..69f18db --- /dev/null +++ b/libraries/androidutils/src/main/res/values-ur/translations.xml @@ -0,0 +1,4 @@ + + + "اس کارروائی کو سنبھالنے کے لیے کوئی مطابقت پذیر اطلاقیہ نہیں ملا۔" + diff --git a/libraries/androidutils/src/main/res/values-uz/translations.xml b/libraries/androidutils/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..a41abbd --- /dev/null +++ b/libraries/androidutils/src/main/res/values-uz/translations.xml @@ -0,0 +1,4 @@ + + + "Bu amalni bajarish uchun mos ilova topilmadi." + diff --git a/libraries/androidutils/src/main/res/values-zh-rTW/translations.xml b/libraries/androidutils/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..5a33806 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,4 @@ + + + "找不到相容的應用程式來執行此動作。" + diff --git a/libraries/androidutils/src/main/res/values-zh/translations.xml b/libraries/androidutils/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..00f50d4 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-zh/translations.xml @@ -0,0 +1,4 @@ + + + "找不到完成此项操作的合适应用。" + 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 0000000..c6598e8 --- /dev/null +++ b/libraries/androidutils/src/main/res/values/integers.xml @@ -0,0 +1,13 @@ + + + + 1 + 0 + + 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 0000000..741c1b2 --- /dev/null +++ b/libraries/androidutils/src/main/res/values/localazy.xml @@ -0,0 +1,4 @@ + + + "No compatible app was found to handle this action." + diff --git a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/filesize/AndroidFileSizeFormatterTest.kt b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/filesize/AndroidFileSizeFormatterTest.kt new file mode 100644 index 0000000..3726000 --- /dev/null +++ b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/filesize/AndroidFileSizeFormatterTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.filesize + +import android.os.Build +import com.google.common.truth.Truth.assertThat +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class AndroidFileSizeFormatterTest { + @Test + fun `test api 24 long format`() { + val sut = createAndroidFileSizeFormatter(sdkLevel = Build.VERSION_CODES.N) + assertThat(sut.format(1, useShortFormat = false)).isEqualTo("1.00B") + assertThat(sut.format(1000, useShortFormat = false)).isEqualTo("0.98KB") + assertThat(sut.format(1024, useShortFormat = false)).isEqualTo("1.00KB") + assertThat(sut.format(1024 * 1024, useShortFormat = false)).isEqualTo("1.00MB") + assertThat(sut.format(1024 * 1024 * 1024, useShortFormat = false)).isEqualTo("1.00GB") + } + + @Test + fun `test api 26 long format`() { + val sut = createAndroidFileSizeFormatter(sdkLevel = Build.VERSION_CODES.O) + assertThat(sut.format(1, useShortFormat = false)).isEqualTo("1.00B") + assertThat(sut.format(1000, useShortFormat = false)).isEqualTo("0.98KB") + assertThat(sut.format(1024 * 1024, useShortFormat = false)).isEqualTo("0.95MB") + assertThat(sut.format(1024 * 1024 * 1024, useShortFormat = false)).isEqualTo("0.93GB") + } + + @Test + fun `test api 24 short format`() { + val sut = createAndroidFileSizeFormatter(sdkLevel = Build.VERSION_CODES.N) + assertThat(sut.format(1, useShortFormat = true)).isEqualTo("1.0B") + assertThat(sut.format(1000, useShortFormat = true)).isEqualTo("0.98KB") + assertThat(sut.format(1024, useShortFormat = true)).isEqualTo("1.0KB") + assertThat(sut.format(1024 * 1024, useShortFormat = true)).isEqualTo("1.0MB") + assertThat(sut.format(1024 * 1024 * 1024, useShortFormat = true)).isEqualTo("1.0GB") + } + + @Test + fun `test api 26 short format`() { + val sut = createAndroidFileSizeFormatter(sdkLevel = Build.VERSION_CODES.O) + assertThat(sut.format(1, useShortFormat = true)).isEqualTo("1.0B") + assertThat(sut.format(1000, useShortFormat = true)).isEqualTo("0.98KB") + assertThat(sut.format(1024 * 1024, useShortFormat = true)).isEqualTo("0.95MB") + assertThat(sut.format(1024 * 1024 * 1024, useShortFormat = true)).isEqualTo("0.93GB") + } + + private fun createAndroidFileSizeFormatter(sdkLevel: Int) = AndroidFileSizeFormatter( + context = RuntimeEnvironment.getApplication(), + sdkIntProvider = FakeBuildVersionSdkIntProvider(sdkInt = sdkLevel) + ) +} diff --git a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/media/VideoCompressorHelperTest.kt b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/media/VideoCompressorHelperTest.kt new file mode 100644 index 0000000..158dd63 --- /dev/null +++ b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/media/VideoCompressorHelperTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.media + +import android.util.Size +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class VideoCompressorHelperTest { + @Test + fun `test getOutputSize`() { + val helper = VideoCompressorHelper(maxSize = 720) + + // Landscape input + var inputSize = Size(1920, 1080) + var outputSize = helper.getOutputSize(inputSize) + assertThat(outputSize).isEqualTo(Size(720, 405)) + + // Landscape input small height + inputSize = Size(1920, 200) + outputSize = helper.getOutputSize(inputSize) + assertThat(outputSize).isEqualTo(Size(720, 75)) + + // Portrait input + inputSize = Size(1080, 1920) + outputSize = helper.getOutputSize(inputSize) + assertThat(outputSize).isEqualTo(Size(405, 720)) + + // Portrait input small width + inputSize = Size(200, 1920) + outputSize = helper.getOutputSize(inputSize) + assertThat(outputSize).isEqualTo(Size(75, 720)) + + // Square input + inputSize = Size(1000, 1000) + outputSize = helper.getOutputSize(inputSize) + assertThat(outputSize).isEqualTo(Size(720, 720)) + + // Square input same size + inputSize = Size(720, 720) + outputSize = helper.getOutputSize(inputSize) + assertThat(outputSize).isEqualTo(Size(720, 720)) + + // Square input no downscaling + inputSize = Size(240, 240) + outputSize = helper.getOutputSize(inputSize) + assertThat(outputSize).isEqualTo(Size(240, 240)) + + // Small input landscape (no downscaling) + inputSize = Size(640, 480) + outputSize = helper.getOutputSize(inputSize) + assertThat(outputSize).isEqualTo(Size(640, 480)) + + // Small input portrait (no downscaling) + inputSize = Size(480, 640) + outputSize = helper.getOutputSize(inputSize) + assertThat(outputSize).isEqualTo(Size(480, 640)) + } + + @Test + fun `test calculateOptimalBitrate`() { + val helper = VideoCompressorHelper(maxSize = 720) + val inputSize = Size(1920, 1080) + var bitrate = helper.calculateOptimalBitrate(inputSize, frameRate = 30) + // Output size will be 720x405, so bitrate = 720*405*0.1*30 = 874800 + assertThat(bitrate).isEqualTo(874_800L) + // Half frame rate, half bitrate + bitrate = helper.calculateOptimalBitrate(inputSize, frameRate = 15) + assertThat(bitrate).isEqualTo(437_400L) + } +} diff --git a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt new file mode 100644 index 0000000..4722994 --- /dev/null +++ b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.androidutils.text + +import android.telephony.TelephonyManager +import android.text.style.URLSpan +import androidx.core.text.getSpans +import androidx.core.text.toSpannable +import com.google.common.truth.Truth.assertThat +import io.element.android.tests.testutils.WarmUpRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow.newInstanceOf + +@RunWith(RobolectricTestRunner::class) +class LinkifierHelperTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `linkification finds URL`() { + val text = "A url https://matrix.org" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("https://matrix.org") + } + + @Test + fun `linkification finds partial URL`() { + val text = "A partial url matrix.org/test" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("http://matrix.org/test") + } + + @Test + fun `linkification finds domain`() { + val text = "A domain matrix.org" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("http://matrix.org") + } + + @Test + fun `linkification finds email`() { + val text = "An email address john@doe.com" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("mailto:john@doe.com") + } + + @Test + @Config(sdk = [30]) + fun `linkification finds phone`() { + val text = "Test phone number +34950123456" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("tel:+34950123456") + } + + @Test + @Config(sdk = [30]) + fun `linkification finds phone in Germany`() { + // For some reason the linkification of phone numbers in Germany is very lenient and any number will fit here + val telephonyManager = shadowOf(newInstanceOf(TelephonyManager::class.java)) + telephonyManager.setSimCountryIso("DE") + + val text = "Test phone number 1234" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("tel:1234") + } + + @Test + fun `linkification handles trailing dot`() { + val text = "A url https://matrix.org." + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("https://matrix.org") + } + + @Test + fun `linkification handles trailing punctuation`() { + val text = "A url https://matrix.org!?; Check it out!" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("https://matrix.org") + } + + @Test + fun `linkification handles parenthesis surrounding URL`() { + val text = "A url (this one (https://github.com/element-hq/element-android/issues/1234))" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("https://github.com/element-hq/element-android/issues/1234") + } + + @Test + fun `linkification handles parenthesis in URL`() { + val text = "A url: (https://github.com/element-hq/element-android/READ(ME))" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("https://github.com/element-hq/element-android/READ(ME)") + } +} diff --git a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/throttler/FirstThrottlerTest.kt b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/throttler/FirstThrottlerTest.kt new file mode 100644 index 0000000..ef5132d --- /dev/null +++ b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/throttler/FirstThrottlerTest.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.androidutils.throttler + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class FirstThrottlerTest { + @Test + fun `throttle canHandle returns the expected result`() = runTest { + val throttler = FirstThrottler( + minimumInterval = 300, + coroutineScope = backgroundScope, + ) + assertThat(throttler.canHandle()).isTrue() + assertThat(throttler.canHandle()).isFalse() + advanceTimeBy(200) + assertThat(throttler.canHandle()).isFalse() + advanceTimeBy(110) + assertThat(throttler.canHandle()).isTrue() + } +} diff --git a/libraries/architecture/.gitignore b/libraries/architecture/.gitignore new file mode 100644 index 0000000..42afabf --- /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 0000000..c745bec --- /dev/null +++ b/libraries/architecture/build.gradle.kts @@ -0,0 +1,31 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.libraries.architecture" +} + +setupDependencyInjection() + +dependencies { + api(projects.libraries.di) + api(projects.libraries.core) + api(libs.metro.runtime) + api(libs.appyx.core) + api(libs.androidx.lifecycle.runtime) + api(libs.molecule.runtime) + + testCommonDependencies(libs) +} 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 0000000..5dfda2a --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AssistedNodeFactory.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 + +fun interface AssistedNodeFactory { + fun create(buildContext: BuildContext, plugins: List): NODE +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt new file mode 100644 index 0000000..bb83316 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.architecture + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import io.element.android.libraries.core.extensions.runCatchingExceptions +import kotlinx.coroutines.TimeoutCancellationException +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +/** + * Sealed type that allows to model an asynchronous operation triggered by the user. + */ +@Stable +sealed interface AsyncAction { + /** + * Represents an uninitialized operation (i.e. yet to be run by the user). + */ + data object Uninitialized : AsyncAction + + /** + * Represents an operation that is currently waiting for user confirmation. + */ + interface Confirming : AsyncAction + + data object ConfirmingNoParams : Confirming + + /** + * User cancels the action, use this object to ask for confirmation. + */ + data object ConfirmingCancellation : Confirming + + /** + * Represents an operation that is currently ongoing. + */ + data object Loading : AsyncAction + + /** + * Represents a failed operation. + * + * @property error the error that caused the operation to fail. + */ + data class Failure( + val error: Throwable, + ) : AsyncAction + + /** + * 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( + val data: T, + ) : AsyncAction + + /** + * Returns the data returned by the operation, or null otherwise. + */ + fun dataOrNull(): T? = when (this) { + is Success -> data + else -> null + } + + /** + * Returns the error that caused the operation to fail, or null otherwise. + */ + fun errorOrNull(): Throwable? = when (this) { + is Failure -> error + else -> null + } + + fun isUninitialized(): Boolean = this == Uninitialized + + fun isConfirming(): Boolean = this is Confirming + + fun isLoading(): Boolean = this == Loading + + fun isFailure(): Boolean = this is Failure + + fun isSuccess(): Boolean = this is Success + + fun isReady() = isSuccess() || isFailure() +} + +suspend inline fun MutableState>.runCatchingUpdatingState( + errorTransform: (Throwable) -> Throwable = { it }, + block: () -> T, +): Result = runUpdatingState( + state = this, + errorTransform = errorTransform, + resultBlock = { + runCatchingExceptions { + block() + } + }, +) + +suspend inline fun (suspend () -> T).runCatchingUpdatingState( + state: MutableState>, + errorTransform: (Throwable) -> Throwable = { it }, +): Result = runUpdatingState( + state = state, + errorTransform = errorTransform, + resultBlock = { + runCatchingExceptions { + this() + } + }, +) + +suspend inline fun MutableState>.runUpdatingState( + errorTransform: (Throwable) -> Throwable = { it }, + resultBlock: () -> Result, +): Result = runUpdatingState( + state = this, + errorTransform = errorTransform, + resultBlock = resultBlock, +) + +/** + * Run the given block and update the state accordingly, using only Loading and Failure states. + * It's up to the caller to manage the Success state. + */ +@OptIn(ExperimentalContracts::class) +inline fun MutableState>.runUpdatingStateNoSuccess( + resultBlock: () -> Result, +): Result { + contract { + callsInPlace(resultBlock, InvocationKind.EXACTLY_ONCE) + } + value = AsyncAction.Loading + return resultBlock() + .onFailure { failure -> + value = AsyncAction.Failure(failure) + } +} + +/** + * Calls the specified [Result]-returning function [resultBlock] + * encapsulating its progress and return value into an [AsyncAction] 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]. + */ +@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE") +suspend inline fun runUpdatingState( + state: MutableState>, + errorTransform: (Throwable) -> Throwable = { it }, + resultBlock: suspend () -> Result, +): Result { + // Restore when the issue with contracts and AGP 8.13.x is fixed +// contract { +// callsInPlace(resultBlock, InvocationKind.EXACTLY_ONCE) +// } + state.value = AsyncAction.Loading + return try { + resultBlock() + } catch (e: TimeoutCancellationException) { + state.value = AsyncAction.Failure(errorTransform(e)) + throw e + }.fold( + onSuccess = { + state.value = AsyncAction.Success(it) + Result.success(it) + }, + onFailure = { + val error = errorTransform(it) + state.value = AsyncAction.Failure(error) + Result.failure(error) + } + ) +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncData.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncData.kt new file mode 100644 index 0000000..734ff16 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncData.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.architecture + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import io.element.android.libraries.core.extensions.runCatchingExceptions + +/** + * Sealed type that allows to model an asynchronous operation. + */ +@Stable +sealed interface AsyncData { + /** + * 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( + val error: Throwable, + val prevData: T? = null, + ) : AsyncData + + /** + * 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( + val prevData: T? = null, + ) : AsyncData + + /** + * 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( + val data: T, + ) : AsyncData + + /** + * Represents an uninitialized operation (i.e. yet to be run). + */ + data object Uninitialized : AsyncData + + /** + * 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 + + fun isLoading(): Boolean = this is Loading + + fun isSuccess(): Boolean = this is Success + + fun isUninitialized(): Boolean = this == Uninitialized + + fun isReady() = isSuccess() || isFailure() +} + +suspend inline fun MutableState>.runCatchingUpdatingState( + errorTransform: (Throwable) -> Throwable = { it }, + block: () -> T, +): Result = runUpdatingState( + state = this, + errorTransform = errorTransform, + resultBlock = { + runCatchingExceptions { + block() + } + }, +) + +suspend inline fun (suspend () -> T).runCatchingUpdatingState( + state: MutableState>, + errorTransform: (Throwable) -> Throwable = { it }, +): Result = runUpdatingState( + state = state, + errorTransform = errorTransform, + resultBlock = { + runCatchingExceptions { + this() + } + }, +) + +suspend inline fun MutableState>.runUpdatingState( + errorTransform: (Throwable) -> Throwable = { it }, + resultBlock: () -> Result, +): Result = runUpdatingState( + state = this, + errorTransform = errorTransform, + resultBlock = resultBlock, +) + +/** + * Calls the specified [Result]-returning function [resultBlock] + * encapsulating its progress and return value into an [AsyncData] 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]. + */ +@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE") +suspend inline fun runUpdatingState( + state: MutableState>, + errorTransform: (Throwable) -> Throwable = { it }, + resultBlock: suspend () -> Result, +): Result { + // Restore when the issue with contracts and AGP 8.13.x is fixed +// contract { +// callsInPlace(resultBlock, InvocationKind.EXACTLY_ONCE) +// } + val prevData = state.value.dataOrNull() + state.value = AsyncData.Loading(prevData = prevData) + return resultBlock().fold( + onSuccess = { + state.value = AsyncData.Success(it) + Result.success(it) + }, + onFailure = { + val error = errorTransform(it) + state.value = AsyncData.Failure( + error = error, + prevData = prevData, + ) + Result.failure(error) + } + ) +} + +inline fun AsyncData.map( + transform: (T) -> R, +): AsyncData { + return when (this) { + is AsyncData.Failure -> AsyncData.Failure( + error = error, + prevData = prevData?.let { transform(prevData) } + ) + is AsyncData.Loading -> AsyncData.Loading(prevData?.let { transform(prevData) }) + is AsyncData.Success -> AsyncData.Success(transform(data)) + AsyncData.Uninitialized -> AsyncData.Uninitialized + } +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/BaseFlowNode.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/BaseFlowNode.kt new file mode 100644 index 0000000..0ec0c1d --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/BaseFlowNode.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.architecture + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.children.ChildEntry +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.model.combined.plus +import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel +import com.bumble.appyx.core.navigation.transition.TransitionHandler +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 +import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader +import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider +import io.element.android.libraries.architecture.overlay.Overlay + +/** + * This class is a [ParentNode] that contains a [BackStack] and an [Overlay]. It is used to represent a flow in the app. + * Should be used instead of [ParentNode] in flow nodes. + */ +@Stable +abstract class BaseFlowNode( + val backstack: BackStack, + buildContext: BuildContext, + plugins: List, + val overlay: Overlay = Overlay(null), + val permanentNavModel: PermanentNavModel = PermanentNavModel(emptySet(), null), + childKeepMode: ChildEntry.KeepMode = ChildEntry.KeepMode.KEEP, +) : ParentNode( + navModel = overlay + backstack + permanentNavModel, + buildContext = buildContext, + plugins = plugins, + childKeepMode = childKeepMode, +) { + override fun onBuilt() { + super.onBuilt() + lifecycle.logLifecycle(this::class.java.simpleName) + whenChildAttached { _, child -> + // BackstackNode will be logged by their parent. + if (child !is BaseFlowNode<*>) { + child.lifecycle.logLifecycle(child::class.java.simpleName) + } + } + } +} + +@Composable +inline fun BaseFlowNode.BackstackView( + modifier: Modifier = Modifier, + transitionHandler: TransitionHandler = rememberBackstackSlider( + transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) }, + ), +) { + Children( + modifier = modifier, + navModel = backstack, + transitionHandler = transitionHandler, + ) +} + +@Composable +inline fun BaseFlowNode.OverlayView( + modifier: Modifier = Modifier, + transitionHandler: TransitionHandler = rememberBackstackFader(), +) { + Children( + modifier = modifier, + navModel = overlay, + transitionHandler = transitionHandler, + ) +} + +@Composable +inline fun BaseFlowNode.BackstackWithOverlayBox( + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit = {}, +) { + Box(modifier = modifier) { + BackstackView() + OverlayView() + content() + } +} 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 0000000..8ff4a3a --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Bindings.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.DependencyInjectionGraphOwner + +inline fun Node.bindings() = bindings(T::class.java) +inline fun Context.bindings() = bindings(T::class.java) + +fun Context.bindings(klass: Class): T { + // search the components in the dependency injection graph + return generateSequence(this) { (it as? ContextWrapper)?.baseContext } + .plus(applicationContext) + .filterIsInstance() + .map { it.graph } + .flatMap { it as? Collection<*> ?: listOf(it) } + .filterIsInstance(klass) + .firstOrNull() + ?: error("Unable to find bindings for ${klass.name}") +} + +fun Node.bindings(klass: Class): T { + // search the components in the node hierarchy + return generateSequence(this, Node::parent) + .filterIsInstance() + .map { it.graph } + .flatMap { it as? Collection<*> ?: 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 0000000..ad4f2c9 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/FeatureEntryPoint.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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. + */ +fun 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 0000000..1f948b1 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/LifecycleExt.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/NodeCallback.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeCallback.kt new file mode 100644 index 0000000..4a35f99 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeCallback.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 + +inline fun Node.callback(): I { + return requireNotNull(plugins().singleOrNull()) { "Make sure to actually pass a Callback plugin to your node" } +} 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 0000000..5a19118 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeFactories.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 +import dev.zacsweers.metro.Multibinds +import kotlin.reflect.KClass + +inline fun Node.createNode( + buildContext: BuildContext, + plugins: List = emptyList() +): N { + val bindings: NodeFactoriesBindings = bindings() + return bindings.createNode(buildContext, plugins) +} + +inline fun NodeFactoriesBindings.createNode( + buildContext: BuildContext, + plugins: List, +): N { + val nodeClass = N::class + 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 codegen generate the NodeFactory. + val nodeFactory = nodeFactoryMap[nodeClass] ?: error("Cannot find NodeFactory for ${nodeClass.java.name}.") + + @Suppress("UNCHECKED_CAST") + val castedNodeFactory = nodeFactory as? AssistedNodeFactory + val node = castedNodeFactory?.create(buildContext, plugins) + return node as N +} + +fun interface NodeFactoriesBindings { + @Multibinds + fun nodeFactories(): Map, 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 0000000..fe9a9e1 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeInputs.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 Node.inputs(): I { + return requireNotNull(plugins().firstOrNull()) { "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 0000000..636e90e --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeKey.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.architecture + +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.MapKey +import kotlin.reflect.KClass + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) +@MapKey +annotation class NodeKey(val value: KClass) 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 0000000..f79ef02 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/ParentNodeExt.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +fun ParentNode.childNode(navTarget: NavTarget): Node? { + val childMap = children.value + val key = childMap.keys.find { it.navTarget == navTarget } + return childMap[key]?.nodeOrNull +} + +suspend inline fun ParentNode.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() + } + } + +/** + * Wait for a child to be attached to the parent node, only using the NavTarget. + */ +suspend inline fun ParentNode.waitForNavTargetAttached(crossinline predicate: (NavTarget) -> Boolean) = + suspendCancellableCoroutine { continuation -> + lifecycleScope.launch { + children.collect { childMap -> + val node = childMap.entries + .map { it.key.navTarget } + .lastOrNull(predicate) + if (node != null && !continuation.isCompleted) { + continuation.resume(Unit) + cancel() + } + } + }.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 0000000..8d69214 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Presenter.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.architecture + +import androidx.compose.runtime.Composable + +fun interface Presenter { + @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 0000000..19c6004 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/animation/ScreenTransition.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 rememberDefaultTransitionHandler(): ModifierTransitionHandler { + return rememberBackstackSlider( + transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) }, + ) +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/BackStackExt.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/BackStackExt.kt new file mode 100644 index 0000000..9fceaa8 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/BackStackExt.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.architecture.appyx + +import com.bumble.appyx.navmodel.backstack.BackStack + +fun BackStack.canPop(): Boolean { + val elements = elements.value + return elements.any { it.targetState == BackStack.State.ACTIVE } && + elements.any { it.targetState == BackStack.State.STASHED } +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/DelegateTransitionHandler.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/DelegateTransitionHandler.kt new file mode 100644 index 0000000..157e7cc --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/DelegateTransitionHandler.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.architecture.appyx + +import android.annotation.SuppressLint +import androidx.compose.animation.core.Transition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.navigation.transition.ModifierTransitionHandler +import com.bumble.appyx.core.navigation.transition.TransitionDescriptor + +/** + * A [ModifierTransitionHandler] that delegates the creation of the modifier to another handler + * based on the [NavTarget]. The idea is to allow different transitions for different [NavTarget]s. + */ +class DelegateTransitionHandler( + private val handlerProvider: (NavTarget) -> ModifierTransitionHandler, +) : ModifierTransitionHandler() { + @SuppressLint("ModifierFactoryExtensionFunction") + override fun createModifier(modifier: Modifier, transition: Transition, descriptor: TransitionDescriptor): Modifier { + return handlerProvider(descriptor.element).createModifier(modifier, transition, descriptor) + } +} + +@Composable +fun rememberDelegateTransitionHandler( + handlerProvider: (NavTarget) -> ModifierTransitionHandler, +): ModifierTransitionHandler = + remember(handlerProvider) { DelegateTransitionHandler(handlerProvider = handlerProvider) } diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/NodeExt.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/NodeExt.kt new file mode 100644 index 0000000..2763596 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/NodeExt.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(InternalComposeApi::class) + +package io.element.android.libraries.architecture.appyx + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.InternalComposeApi +import androidx.compose.runtime.currentComposer +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.lifecycleScope +import app.cash.molecule.AndroidUiDispatcher +import app.cash.molecule.RecompositionMode +import app.cash.molecule.launchMolecule +import com.bumble.appyx.core.node.Node +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +fun Node.launchMolecule(body: @Composable () -> State): StateFlow { + val scope = CoroutineScope(lifecycleScope.coroutineContext + AndroidUiDispatcher.Main) + return scope.launchMolecule(mode = RecompositionMode.ContextClock) { + currentComposer.startProviders( + values = arrayOf(LocalLifecycleOwner provides this), + ) + val state = body() + currentComposer.endProviders() + state + } +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/coverage/ExcludeFromCoverage.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/coverage/ExcludeFromCoverage.kt new file mode 100644 index 0000000..7e0e709 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/coverage/ExcludeFromCoverage.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.architecture.coverage + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) +annotation class ExcludeFromCoverage diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/overlay/HideOverlayBackPressHandler.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/overlay/HideOverlayBackPressHandler.kt new file mode 100644 index 0000000..528e143 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/overlay/HideOverlayBackPressHandler.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.architecture.overlay + +import com.bumble.appyx.core.navigation.backpresshandlerstrategies.BaseBackPressHandlerStrategy +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.BackStackElements +import io.element.android.libraries.architecture.overlay.operation.Hide +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class HideOverlayBackPressHandler : + BaseBackPressHandlerStrategy() { + override val canHandleBackPressFlow: Flow by lazy { + navModel.elements.map(::areThereElements) + } + + private fun areThereElements(elements: BackStackElements) = + elements.isNotEmpty() + + override fun onBackPressed() { + navModel.accept(Hide()) + } +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/overlay/Overlay.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/overlay/Overlay.kt new file mode 100644 index 0000000..2c6cd24 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/overlay/Overlay.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.architecture.overlay + +import com.bumble.appyx.core.navigation.BaseNavModel +import com.bumble.appyx.core.navigation.NavElements +import com.bumble.appyx.core.navigation.backpresshandlerstrategies.BackPressHandlerStrategy +import com.bumble.appyx.core.navigation.onscreen.OnScreenStateResolver +import com.bumble.appyx.core.navigation.operationstrategies.ExecuteImmediately +import com.bumble.appyx.core.navigation.operationstrategies.OperationStrategy +import com.bumble.appyx.core.state.SavedStateMap +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.BackStackOnScreenResolver + +class Overlay( + savedStateMap: SavedStateMap?, + key: String = requireNotNull(Overlay::class.qualifiedName), + backPressHandler: BackPressHandlerStrategy = HideOverlayBackPressHandler(), + operationStrategy: OperationStrategy = ExecuteImmediately(), + screenResolver: OnScreenStateResolver = BackStackOnScreenResolver, +) : BaseNavModel( + backPressHandler = backPressHandler, + screenResolver = screenResolver, + operationStrategy = operationStrategy, + finalState = BackStack.State.DESTROYED, + savedStateMap = savedStateMap, + key = key, +) { + override val initialElements: NavElements + get() = emptyList() +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/overlay/operation/Hide.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/overlay/operation/Hide.kt new file mode 100644 index 0000000..142f965 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/overlay/operation/Hide.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.architecture.overlay.operation + +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.BackStackElements +import com.bumble.appyx.navmodel.backstack.activeIndex +import io.element.android.libraries.architecture.overlay.Overlay +import kotlinx.parcelize.Parcelize + +@Parcelize +class Hide : OverlayOperation { + override fun isApplicable(elements: BackStackElements): Boolean = + elements.any { it.targetState == BackStack.State.ACTIVE } + + override fun invoke( + elements: BackStackElements + ): BackStackElements { + val hideIndex = elements.activeIndex + require(hideIndex != -1) { "Nothing to hide, state=$elements" } + return elements.mapIndexed { index, element -> + when (index) { + hideIndex -> element.transitionTo( + newTargetState = BackStack.State.DESTROYED, + operation = this + ) + else -> element + } + } + } + + override fun equals(other: Any?): Boolean = this.javaClass == other?.javaClass + + override fun hashCode(): Int = this.javaClass.hashCode() +} + +fun Overlay.hide() { + accept(Hide()) +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/overlay/operation/OverlayOperation.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/overlay/operation/OverlayOperation.kt new file mode 100644 index 0000000..0659080 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/overlay/operation/OverlayOperation.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.architecture.overlay.operation + +import com.bumble.appyx.core.navigation.Operation +import com.bumble.appyx.navmodel.backstack.BackStack + +interface OverlayOperation : Operation diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/overlay/operation/Show.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/overlay/operation/Show.kt new file mode 100644 index 0000000..bece75b --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/overlay/operation/Show.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.architecture.overlay.operation + +import com.bumble.appyx.core.navigation.NavKey +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.BackStackElement +import com.bumble.appyx.navmodel.backstack.BackStackElements +import com.bumble.appyx.navmodel.backstack.activeElement +import io.element.android.libraries.architecture.overlay.Overlay +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue + +@Parcelize +data class Show( + private val element: @RawValue T +) : OverlayOperation { + override fun isApplicable(elements: BackStackElements): Boolean = + element != elements.activeElement + + override fun invoke(elements: BackStackElements): BackStackElements = listOf( + BackStackElement( + key = NavKey(element), + fromState = BackStack.State.CREATED, + targetState = BackStack.State.ACTIVE, + operation = this + ) + ) +} + +fun Overlay.show(element: T) { + accept(Show(element)) +} diff --git a/libraries/architecture/src/test/kotlin/io/element/android/libraries/architecture/AsyncActionTest.kt b/libraries/architecture/src/test/kotlin/io/element/android/libraries/architecture/AsyncActionTest.kt new file mode 100644 index 0000000..9af8af9 --- /dev/null +++ b/libraries/architecture/src/test/kotlin/io/element/android/libraries/architecture/AsyncActionTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.architecture + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds + +class AsyncActionTest { + @Test + fun `updates state on timeout`() = runTest { + val state: MutableState> = mutableStateOf(AsyncAction.Uninitialized) + val timeoutMillis = 500L + val operationTimeMillis = 1000L + + try { + runUpdatingState(state = state) { + withTimeout(timeoutMillis.milliseconds) { + delay(operationTimeMillis) + } + Result.success(0) + } + fail("Expected TimeoutCancellationException, but nothing was thrown") + } catch (e: TimeoutCancellationException) { + assertTrue(state.value.isFailure()) + assertSame(e, state.value.errorOrNull()) + } + } +} diff --git a/libraries/architecture/src/test/kotlin/io/element/android/libraries/architecture/AsyncDataKtTest.kt b/libraries/architecture/src/test/kotlin/io/element/android/libraries/architecture/AsyncDataKtTest.kt new file mode 100644 index 0000000..39969c0 --- /dev/null +++ b/libraries/architecture/src/test/kotlin/io/element/android/libraries/architecture/AsyncDataKtTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 AsyncDataKtTest { + @Test + fun `updates state when block returns success`() = runTest { + val state = TestableMutableState>(AsyncData.Uninitialized) + + val result = runUpdatingState(state) { + delay(1) + Result.success(1) + } + + assertThat(result.isSuccess).isTrue() + assertThat(result.getOrNull()).isEqualTo(1) + + assertThat(state.popFirst()).isEqualTo(AsyncData.Uninitialized) + assertThat(state.popFirst()).isEqualTo(AsyncData.Loading(null)) + assertThat(state.popFirst()).isEqualTo(AsyncData.Success(1)) + state.assertNoMoreValues() + } + + @Test + fun `updates state when block returns failure`() = runTest { + val state = TestableMutableState>(AsyncData.Uninitialized) + + val result = runUpdatingState(state) { + delay(1) + Result.failure(MyException("hello")) + } + + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isEqualTo(MyException("hello")) + + assertThat(state.popFirst()).isEqualTo(AsyncData.Uninitialized) + assertThat(state.popFirst()).isEqualTo(AsyncData.Loading(null)) + assertThat(state.popFirst()).isEqualTo(AsyncData.Failure(MyException("hello"))) + state.assertNoMoreValues() + } + + @Test + fun `updates state when block returns failure transforming the error`() = runTest { + val state = TestableMutableState>(AsyncData.Uninitialized) + + val result = runUpdatingState(state, { MyException(it.message + " world") }) { + delay(1) + Result.failure(MyException("hello")) + } + + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isEqualTo(MyException("hello world")) + + assertThat(state.popFirst()).isEqualTo(AsyncData.Uninitialized) + assertThat(state.popFirst()).isEqualTo(AsyncData.Loading(null)) + assertThat(state.popFirst()).isEqualTo(AsyncData.Failure(MyException("hello world"))) + state.assertNoMoreValues() + } +} + +/** + * A fake [MutableState] that allows to record all the states that were set. + */ +private class TestableMutableState( + value: T +) : MutableState { + private val deque = ArrayDeque(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 MyException(val myMessage: String) : Exception(myMessage) diff --git a/libraries/audio/api/build.gradle.kts b/libraries/audio/api/build.gradle.kts new file mode 100644 index 0000000..208979f --- /dev/null +++ b/libraries/audio/api/build.gradle.kts @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.audio.api" +} diff --git a/libraries/audio/api/src/main/kotlin/io/element/android/libraries/audio/api/AudioFocus.kt b/libraries/audio/api/src/main/kotlin/io/element/android/libraries/audio/api/AudioFocus.kt new file mode 100644 index 0000000..9a3c178 --- /dev/null +++ b/libraries/audio/api/src/main/kotlin/io/element/android/libraries/audio/api/AudioFocus.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.audio.api + +enum class AudioFocusRequester { + ElementCall, + VoiceMessage, + MediaViewer, +} + +interface AudioFocus { + /** + * Request audio focus for the given requester. + * @param requester The mode for which to request audio focus. + * @param onFocusLost Callback to be invoked when the audio focus is lost. + * @return true if the audio focus was successfully requested, false otherwise. + */ + fun requestAudioFocus( + requester: AudioFocusRequester, + onFocusLost: () -> Unit, + ) + + /** + * Release the audio focus. + */ + fun releaseAudioFocus() +} diff --git a/libraries/audio/impl/build.gradle.kts b/libraries/audio/impl/build.gradle.kts new file mode 100644 index 0000000..419e819 --- /dev/null +++ b/libraries/audio/impl/build.gradle.kts @@ -0,0 +1,25 @@ +import extension.setupDependencyInjection + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.audio.impl" +} + +setupDependencyInjection() + +dependencies { + api(projects.libraries.audio.api) + + implementation(libs.androidx.corektx) + implementation(projects.libraries.di) +} diff --git a/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt b/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt new file mode 100644 index 0000000..aa945ea --- /dev/null +++ b/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.audio.impl + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Build +import androidx.core.content.getSystemService +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.audio.api.AudioFocus +import io.element.android.libraries.audio.api.AudioFocusRequester +import io.element.android.libraries.di.annotations.ApplicationContext + +@ContributesBinding(AppScope::class) +class DefaultAudioFocus( + @ApplicationContext private val context: Context, +) : AudioFocus { + private val audioManager = requireNotNull(context.getSystemService()) + + private var audioFocusRequest: AudioFocusRequest? = null + private var audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null + + @Suppress("DEPRECATION") + override fun requestAudioFocus( + requester: AudioFocusRequester, + onFocusLost: () -> Unit, + ) { + val listener = AudioManager.OnAudioFocusChangeListener { + when (it) { + AudioManager.AUDIOFOCUS_GAIN -> { + // Do nothing + } + AudioManager.AUDIOFOCUS_LOSS, + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + onFocusLost() + } + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val audioAttributes = AudioAttributes.Builder() + .setUsage(requester.toAudioUsage()) + .build() + val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes(audioAttributes) + .setOnAudioFocusChangeListener(listener) + .setWillPauseWhenDucked(requester.willPausedWhenDucked()) + .build() + audioManager.requestAudioFocus(request) + audioFocusRequest = request + } else { + audioManager.requestAudioFocus( + listener, + requester.toAudioStream(), + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE, + ) + audioFocusChangeListener = listener + } + } + + @Suppress("DEPRECATION") + override fun releaseAudioFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) } + } else { + audioFocusChangeListener?.let { audioManager.abandonAudioFocus(it) } + } + } +} + +private fun AudioFocusRequester.toAudioUsage(): Int { + return when (this) { + AudioFocusRequester.ElementCall, + AudioFocusRequester.VoiceMessage -> AudioAttributes.USAGE_VOICE_COMMUNICATION + AudioFocusRequester.MediaViewer -> AudioAttributes.USAGE_MEDIA + } +} + +private fun AudioFocusRequester.toAudioStream(): Int { + return when (this) { + AudioFocusRequester.ElementCall, + AudioFocusRequester.VoiceMessage -> AudioManager.STREAM_VOICE_CALL + AudioFocusRequester.MediaViewer -> AudioManager.STREAM_MUSIC + } +} + +private fun AudioFocusRequester.willPausedWhenDucked(): Boolean { + return when (this) { + // (note that for Element Call, there is no action when the focus is lost) + AudioFocusRequester.ElementCall, + AudioFocusRequester.VoiceMessage -> true + // For the MediaViewer, we let the system automatically handle the ducking + // https://developer.android.com/media/optimize/audio-focus#automatic-ducking + AudioFocusRequester.MediaViewer -> false + } +} diff --git a/libraries/audio/test/build.gradle.kts b/libraries/audio/test/build.gradle.kts new file mode 100644 index 0000000..2270881 --- /dev/null +++ b/libraries/audio/test/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.audio.test" +} + +dependencies { + api(projects.libraries.audio.api) + implementation(projects.tests.testutils) +} diff --git a/libraries/audio/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeAudioFocus.kt b/libraries/audio/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeAudioFocus.kt new file mode 100644 index 0000000..ecb263d --- /dev/null +++ b/libraries/audio/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeAudioFocus.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaplayer.test + +import io.element.android.libraries.audio.api.AudioFocus +import io.element.android.libraries.audio.api.AudioFocusRequester +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeAudioFocus( + private val requestAudioFocusResult: (AudioFocusRequester, () -> Unit) -> Unit = { _, _ -> lambdaError() }, + private val releaseAudioFocusResult: () -> Unit = { lambdaError() }, +) : AudioFocus { + override fun requestAudioFocus( + requester: AudioFocusRequester, + onFocusLost: () -> Unit, + ) { + requestAudioFocusResult(requester, onFocusLost) + } + + override fun releaseAudioFocus() { + releaseAudioFocusResult() + } +} diff --git a/libraries/compound/build.gradle.kts b/libraries/compound/build.gradle.kts new file mode 100644 index 0000000..ce8479c --- /dev/null +++ b/libraries/compound/build.gradle.kts @@ -0,0 +1,30 @@ +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.roborazzi) +} + +android { + namespace = "io.element.android.compound" + + testOptions { + unitTests.isIncludeAndroidResources = true + } +} + +dependencies { + implementation(libs.showkase) + testCommonDependencies(libs) + testImplementation(libs.test.roborazzi) + testImplementation(libs.test.roborazzi.compose) + testImplementation(libs.test.roborazzi.junit) +} diff --git a/libraries/compound/screenshots/Avatar Colors - Dark.png b/libraries/compound/screenshots/Avatar Colors - Dark.png new file mode 100644 index 0000000..5cf6cbd --- /dev/null +++ b/libraries/compound/screenshots/Avatar Colors - Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:caf2de32caf0fa5368da899297e2eabd5a0c6891dd94f81295ef8a933d79ce16 +size 10751 diff --git a/libraries/compound/screenshots/Avatar Colors - Light.png b/libraries/compound/screenshots/Avatar Colors - Light.png new file mode 100644 index 0000000..7069762 --- /dev/null +++ b/libraries/compound/screenshots/Avatar Colors - Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80e44e94d7b23af2ec4fd1c5a871851ae2567b40e478b30145de199076f20e95 +size 11296 diff --git a/libraries/compound/screenshots/Compound Icons - Dark.png b/libraries/compound/screenshots/Compound Icons - Dark.png new file mode 100644 index 0000000..52e6ba2 --- /dev/null +++ b/libraries/compound/screenshots/Compound Icons - Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c0f668bcd8d511bc80daa1320bdcc1fe6a8f82cd53d91dbab9ffd0d09d72934 +size 210897 diff --git a/libraries/compound/screenshots/Compound Icons - Light.png b/libraries/compound/screenshots/Compound Icons - Light.png new file mode 100644 index 0000000..189864a --- /dev/null +++ b/libraries/compound/screenshots/Compound Icons - Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:85eb26db4406a921c45f143c8ccb59214b2b70cb19359fe9e7eeeecfb733ca74 +size 222592 diff --git a/libraries/compound/screenshots/Compound Icons - Rtl.png b/libraries/compound/screenshots/Compound Icons - Rtl.png new file mode 100644 index 0000000..aa1bdac --- /dev/null +++ b/libraries/compound/screenshots/Compound Icons - Rtl.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9acd4fdec8deddbf723184ce5f373ed54e64a68d5b572419059e3feab3a508be +size 223915 diff --git a/libraries/compound/screenshots/Compound Semantic Colors - Dark HC.png b/libraries/compound/screenshots/Compound Semantic Colors - Dark HC.png new file mode 100644 index 0000000..4890b91 --- /dev/null +++ b/libraries/compound/screenshots/Compound Semantic Colors - Dark HC.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:578e9b5a38791e2686a7b9ba5c461eb1d1fb29dfbe950bf46c113ad75ceac175 +size 327758 diff --git a/libraries/compound/screenshots/Compound Semantic Colors - Dark.png b/libraries/compound/screenshots/Compound Semantic Colors - Dark.png new file mode 100644 index 0000000..4cc125b --- /dev/null +++ b/libraries/compound/screenshots/Compound Semantic Colors - Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4cab40fc0506c8f2a2efafb1199e85f1da3ebacb49b176e9105e3f95175f85ee +size 325565 diff --git a/libraries/compound/screenshots/Compound Semantic Colors - Light HC.png b/libraries/compound/screenshots/Compound Semantic Colors - Light HC.png new file mode 100644 index 0000000..5a8f5a6 --- /dev/null +++ b/libraries/compound/screenshots/Compound Semantic Colors - Light HC.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:174f9d4ee70a29c0c8c2a01a15daeb14281530678ff7d7fb19a208bfd789533a +size 309210 diff --git a/libraries/compound/screenshots/Compound Semantic Colors - Light.png b/libraries/compound/screenshots/Compound Semantic Colors - Light.png new file mode 100644 index 0000000..f010626 --- /dev/null +++ b/libraries/compound/screenshots/Compound Semantic Colors - Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7598b98462c015f2bf74b3ea3ad95fc0220b2efb9bb81ac56025cf6a158e3f8a +size 308976 diff --git a/libraries/compound/screenshots/Compound Typography.png b/libraries/compound/screenshots/Compound Typography.png new file mode 100644 index 0000000..095ad6c --- /dev/null +++ b/libraries/compound/screenshots/Compound Typography.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac84a7175c4a4897aa28eddcf722b7997c6576f612eb38fa09ffabcf7be11e00 +size 119496 diff --git a/libraries/compound/screenshots/Compound Vector Icons - Dark.png b/libraries/compound/screenshots/Compound Vector Icons - Dark.png new file mode 100644 index 0000000..30eac13 --- /dev/null +++ b/libraries/compound/screenshots/Compound Vector Icons - Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9f2584ffd8e3a4746937cdc3e0baf04a89839061f15d00342e6150c21bf13228 +size 83228 diff --git a/libraries/compound/screenshots/Compound Vector Icons - Light.png b/libraries/compound/screenshots/Compound Vector Icons - Light.png new file mode 100644 index 0000000..41fac03 --- /dev/null +++ b/libraries/compound/screenshots/Compound Vector Icons - Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb1854bf504fcab7752c4d51f5fc6cae65511581fb64a1adcdcf6f912d4aa15a +size 89148 diff --git a/libraries/compound/screenshots/ForcedDarkElementTheme.png b/libraries/compound/screenshots/ForcedDarkElementTheme.png new file mode 100644 index 0000000..d7182aa --- /dev/null +++ b/libraries/compound/screenshots/ForcedDarkElementTheme.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03bdb2f3de01d40b8f85ba87b395cdab2e5225aded2f40a0892077798bca6066 +size 22328 diff --git a/libraries/compound/screenshots/Legacy Colors.png b/libraries/compound/screenshots/Legacy Colors.png new file mode 100644 index 0000000..2ecdda2 --- /dev/null +++ b/libraries/compound/screenshots/Legacy Colors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e9d676a1ef20a7228e985f62d346265ff9c31d1860a219540f012c063c9345e +size 33652 diff --git a/libraries/compound/screenshots/Material Typography.png b/libraries/compound/screenshots/Material Typography.png new file mode 100644 index 0000000..6c22c36 --- /dev/null +++ b/libraries/compound/screenshots/Material Typography.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d27f813acc9e8dc3960f5205809c8c0d2ba0fa51fd9e3ca07964b866b125e87d +size 110171 diff --git a/libraries/compound/screenshots/Material3 Colors - Dark HC.png b/libraries/compound/screenshots/Material3 Colors - Dark HC.png new file mode 100644 index 0000000..4cc7609 --- /dev/null +++ b/libraries/compound/screenshots/Material3 Colors - Dark HC.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:85969829577e158bdd1d0f21c8b3a2334dcde79cb50d5e2331d06d5423332be2 +size 160754 diff --git a/libraries/compound/screenshots/Material3 Colors - Dark.png b/libraries/compound/screenshots/Material3 Colors - Dark.png new file mode 100644 index 0000000..3738cf6 --- /dev/null +++ b/libraries/compound/screenshots/Material3 Colors - Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca64da1dc373dc49503ee525ae3331e73d0ab12053993a1ef19dcab1e67b08c4 +size 159123 diff --git a/libraries/compound/screenshots/Material3 Colors - Light HC.png b/libraries/compound/screenshots/Material3 Colors - Light HC.png new file mode 100644 index 0000000..8d64857 --- /dev/null +++ b/libraries/compound/screenshots/Material3 Colors - Light HC.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01622bca20a132ec5a874fc1a2d0ffd45e7ce6d7849c4d607d79c7bc51d6c6a9 +size 163322 diff --git a/libraries/compound/screenshots/Material3 Colors - Light.png b/libraries/compound/screenshots/Material3 Colors - Light.png new file mode 100644 index 0000000..a1d5d1f --- /dev/null +++ b/libraries/compound/screenshots/Material3 Colors - Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:87c0c4ff42d17137d554708ce33f40f214ec608eca4ca87af0b2adab63de6bb7 +size 162891 diff --git a/libraries/compound/screenshots/MaterialText Colors.png b/libraries/compound/screenshots/MaterialText Colors.png new file mode 100644 index 0000000..f8f77cc --- /dev/null +++ b/libraries/compound/screenshots/MaterialText Colors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4be10c3bb9900d27a3b406eca0cb902b0ff9cdf90e8e3cf1ae7760aa7c5d47d9 +size 377446 diff --git a/libraries/compound/screenshots/MaterialYou Theme - Dark.png b/libraries/compound/screenshots/MaterialYou Theme - Dark.png new file mode 100644 index 0000000..8c5bc9d --- /dev/null +++ b/libraries/compound/screenshots/MaterialYou Theme - Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c166e5371bb1922a9c016438a3cdfd0d68197237969d53a04f92baf6d53c4ac0 +size 164925 diff --git a/libraries/compound/screenshots/MaterialYou Theme - Light.png b/libraries/compound/screenshots/MaterialYou Theme - Light.png new file mode 100644 index 0000000..70cccac --- /dev/null +++ b/libraries/compound/screenshots/MaterialYou Theme - Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d936948cbad69d6935f2d2738d33682a55f044dfb1af8b5c9b8323c5f4318971 +size 163558 diff --git a/libraries/compound/src/main/assets/theme.iife.js b/libraries/compound/src/main/assets/theme.iife.js new file mode 100644 index 0000000..4be509b --- /dev/null +++ b/libraries/compound/src/main/assets/theme.iife.js @@ -0,0 +1,38 @@ +var CompoundTheme=(function(Fe){"use strict";const Xe=(e,t=0,r=1)=>Zt(Jt(t,e),r),Yt=e=>{e._clipped=!1,e._unclipped=e.slice(0);for(let t=0;t<=3;t++)t<3?((e[t]<0||e[t]>255)&&(e._clipped=!0),e[t]=Xe(e[t],0,255)):t===3&&(e[t]=Xe(e[t],0,1));return e},co={};for(let e of["Boolean","Number","String","Function","Array","Date","RegExp","Undefined","Null"])co[`[object ${e}]`]=e.toLowerCase();function S(e){return co[Object.prototype.toString.call(e)]||"object"}const P=(e,t=null)=>e.length>=3?Array.prototype.slice.call(e):S(e[0])=="object"&&t?t.split("").filter(r=>e[0][r]!==void 0).map(r=>e[0][r]):e[0],pt=e=>{if(e.length<2)return null;const t=e.length-1;return S(e[t])=="string"?e[t].toLowerCase():null},{PI:mt,min:Zt,max:Jt}=Math,we=mt*2,Wt=mt/3,Ac=mt/180,Lc=180/mt,L={format:{},autodetect:[]};let C=class{constructor(...t){const r=this;if(S(t[0])==="object"&&t[0].constructor&&t[0].constructor===this.constructor)return t[0];let n=pt(t),o=!1;if(!n){o=!0,L.sorted||(L.autodetect=L.autodetect.sort((a,s)=>s.p-a.p),L.sorted=!0);for(let a of L.autodetect)if(n=a.test(...t),n)break}if(L.format[n]){const a=L.format[n].apply(null,o?t:t.slice(0,-1));r._rgb=Yt(a)}else throw new Error("unknown format: "+t);r._rgb.length===3&&r._rgb.push(1)}toString(){return S(this.hex)=="function"?this.hex():`[${this._rgb.join(",")}]`}};const Ec="2.6.0",O=(...e)=>new O.Color(...e);O.Color=C,O.version=Ec;const Tc=(...e)=>{e=P(e,"cmyk");const[t,r,n,o]=e,a=e.length>4?e[4]:1;return o===1?[0,0,0,a]:[t>=1?0:255*(1-t)*(1-o),r>=1?0:255*(1-r)*(1-o),n>=1?0:255*(1-n)*(1-o),a]},{max:io}=Math,Sc=(...e)=>{let[t,r,n]=P(e,"rgb");t=t/255,r=r/255,n=n/255;const o=1-io(t,io(r,n)),a=o<1?1/(1-o):0,s=(1-t-o)*a,c=(1-r-o)*a,i=(1-n-o)*a;return[s,c,i,o]};C.prototype.cmyk=function(){return Sc(this._rgb)},O.cmyk=(...e)=>new C(...e,"cmyk"),L.format.cmyk=Tc,L.autodetect.push({p:2,test:(...e)=>{if(e=P(e,"cmyk"),S(e)==="array"&&e.length===4)return"cmyk"}});const Ut=e=>Math.round(e*100)/100,Pc=(...e)=>{const t=P(e,"hsla");let r=pt(e)||"lsa";return t[0]=Ut(t[0]||0),t[1]=Ut(t[1]*100)+"%",t[2]=Ut(t[2]*100)+"%",r==="hsla"||t.length>3&&t[3]<1?(t[3]=t.length>3?t[3]:1,r="hsla"):t.length=3,`${r}(${t.join(",")})`},uo=(...e)=>{e=P(e,"rgba");let[t,r,n]=e;t/=255,r/=255,n/=255;const o=Zt(t,r,n),a=Jt(t,r,n),s=(a+o)/2;let c,i;return a===o?(c=0,i=Number.NaN):c=s<.5?(a-o)/(a+o):(a-o)/(2-a-o),t==a?i=(r-n)/(a-o):r==a?i=2+(n-t)/(a-o):n==a&&(i=4+(t-r)/(a-o)),i*=60,i<0&&(i+=360),e.length>3&&e[3]!==void 0?[i,c,s,e[3]]:[i,c,s]},{round:Qt}=Math,jc=(...e)=>{const t=P(e,"rgba");let r=pt(e)||"rgb";return r.substr(0,3)=="hsl"?Pc(uo(t),r):(t[0]=Qt(t[0]),t[1]=Qt(t[1]),t[2]=Qt(t[2]),(r==="rgba"||t.length>3&&t[3]<1)&&(t[3]=t.length>3?t[3]:1,r="rgba"),`${r}(${t.slice(0,r==="rgb"?3:4).join(",")})`)},{round:er}=Math,tr=(...e)=>{e=P(e,"hsl");const[t,r,n]=e;let o,a,s;if(r===0)o=a=s=n*255;else{const c=[0,0,0],i=[0,0,0],u=n<.5?n*(1+r):n+r-n*r,f=2*n-u,l=t/360;c[0]=l+1/3,c[1]=l,c[2]=l-1/3;for(let h=0;h<3;h++)c[h]<0&&(c[h]+=1),c[h]>1&&(c[h]-=1),6*c[h]<1?i[h]=f+(u-f)*6*c[h]:2*c[h]<1?i[h]=u:3*c[h]<2?i[h]=f+(u-f)*(2/3-c[h])*6:i[h]=f;[o,a,s]=[er(i[0]*255),er(i[1]*255),er(i[2]*255)]}return e.length>3?[o,a,s,e[3]]:[o,a,s,1]},fo=/^rgb\(\s*(-?\d+),\s*(-?\d+)\s*,\s*(-?\d+)\s*\)$/,lo=/^rgba\(\s*(-?\d+),\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*([01]|[01]?\.\d+)\)$/,ho=/^rgb\(\s*(-?\d+(?:\.\d+)?)%,\s*(-?\d+(?:\.\d+)?)%\s*,\s*(-?\d+(?:\.\d+)?)%\s*\)$/,bo=/^rgba\(\s*(-?\d+(?:\.\d+)?)%,\s*(-?\d+(?:\.\d+)?)%\s*,\s*(-?\d+(?:\.\d+)?)%\s*,\s*([01]|[01]?\.\d+)\)$/,po=/^hsl\(\s*(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)%\s*,\s*(-?\d+(?:\.\d+)?)%\s*\)$/,mo=/^hsla\(\s*(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)%\s*,\s*(-?\d+(?:\.\d+)?)%\s*,\s*([01]|[01]?\.\d+)\)$/,{round:go}=Math,rr=e=>{e=e.toLowerCase().trim();let t;if(L.format.named)try{return L.format.named(e)}catch{}if(t=e.match(fo)){const r=t.slice(1,4);for(let n=0;n<3;n++)r[n]=+r[n];return r[3]=1,r}if(t=e.match(lo)){const r=t.slice(1,5);for(let n=0;n<4;n++)r[n]=+r[n];return r}if(t=e.match(ho)){const r=t.slice(1,4);for(let n=0;n<3;n++)r[n]=go(r[n]*2.55);return r[3]=1,r}if(t=e.match(bo)){const r=t.slice(1,5);for(let n=0;n<3;n++)r[n]=go(r[n]*2.55);return r[3]=+r[3],r}if(t=e.match(po)){const r=t.slice(1,4);r[1]*=.01,r[2]*=.01;const n=tr(r);return n[3]=1,n}if(t=e.match(mo)){const r=t.slice(1,4);r[1]*=.01,r[2]*=.01;const n=tr(r);return n[3]=+t[4],n}};rr.test=e=>fo.test(e)||lo.test(e)||ho.test(e)||bo.test(e)||po.test(e)||mo.test(e),C.prototype.css=function(e){return jc(this._rgb,e)},O.css=(...e)=>new C(...e,"css"),L.format.css=rr,L.autodetect.push({p:5,test:(e,...t)=>{if(!t.length&&S(e)==="string"&&rr.test(e))return"css"}}),L.format.gl=(...e)=>{const t=P(e,"rgba");return t[0]*=255,t[1]*=255,t[2]*=255,t},O.gl=(...e)=>new C(...e,"gl"),C.prototype.gl=function(){const e=this._rgb;return[e[0]/255,e[1]/255,e[2]/255,e[3]]};const{floor:Bc}=Math,Gc=(...e)=>{e=P(e,"hcg");let[t,r,n]=e,o,a,s;n=n*255;const c=r*255;if(r===0)o=a=s=n;else{t===360&&(t=0),t>360&&(t-=360),t<0&&(t+=360),t/=60;const i=Bc(t),u=t-i,f=n*(1-r),l=f+c*(1-u),h=f+c*u,d=f+c;switch(i){case 0:[o,a,s]=[d,h,f];break;case 1:[o,a,s]=[l,d,f];break;case 2:[o,a,s]=[f,d,h];break;case 3:[o,a,s]=[f,l,d];break;case 4:[o,a,s]=[h,f,d];break;case 5:[o,a,s]=[d,f,l];break}}return[o,a,s,e.length>3?e[3]:1]},Ic=(...e)=>{const[t,r,n]=P(e,"rgb"),o=Zt(t,r,n),a=Jt(t,r,n),s=a-o,c=s*100/255,i=o/(255-s)*100;let u;return s===0?u=Number.NaN:(t===a&&(u=(r-n)/s),r===a&&(u=2+(n-t)/s),n===a&&(u=4+(t-r)/s),u*=60,u<0&&(u+=360)),[u,c,i]};C.prototype.hcg=function(){return Ic(this._rgb)},O.hcg=(...e)=>new C(...e,"hcg"),L.format.hcg=Gc,L.autodetect.push({p:1,test:(...e)=>{if(e=P(e,"hcg"),S(e)==="array"&&e.length===3)return"hcg"}});const zc=/^#?([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,Fc=/^#?([A-Fa-f0-9]{8}|[A-Fa-f0-9]{4})$/,vo=e=>{if(e.match(zc)){(e.length===4||e.length===7)&&(e=e.substr(1)),e.length===3&&(e=e.split(""),e=e[0]+e[0]+e[1]+e[1]+e[2]+e[2]);const t=parseInt(e,16),r=t>>16,n=t>>8&255,o=t&255;return[r,n,o,1]}if(e.match(Fc)){(e.length===5||e.length===9)&&(e=e.substr(1)),e.length===4&&(e=e.split(""),e=e[0]+e[0]+e[1]+e[1]+e[2]+e[2]+e[3]+e[3]);const t=parseInt(e,16),r=t>>24&255,n=t>>16&255,o=t>>8&255,a=Math.round((t&255)/255*100)/100;return[r,n,o,a]}throw new Error(`unknown hex color: ${e}`)},{round:gt}=Math,_o=(...e)=>{let[t,r,n,o]=P(e,"rgba"),a=pt(e)||"auto";o===void 0&&(o=1),a==="auto"&&(a=o<1?"rgba":"rgb"),t=gt(t),r=gt(r),n=gt(n);let c="000000"+(t<<16|r<<8|n).toString(16);c=c.substr(c.length-6);let i="0"+gt(o*255).toString(16);switch(i=i.substr(i.length-2),a.toLowerCase()){case"rgba":return`#${c}${i}`;case"argb":return`#${i}${c}`;default:return`#${c}`}};C.prototype.hex=function(e){return _o(this._rgb,e)},O.hex=(...e)=>new C(...e,"hex"),L.format.hex=vo,L.autodetect.push({p:4,test:(e,...t)=>{if(!t.length&&S(e)==="string"&&[3,4,5,6,7,8,9].indexOf(e.length)>=0)return"hex"}});const{cos:Ke}=Math,Xc=(...e)=>{e=P(e,"hsi");let[t,r,n]=e,o,a,s;return isNaN(t)&&(t=0),isNaN(r)&&(r=0),t>360&&(t-=360),t<0&&(t+=360),t/=360,t<1/3?(s=(1-r)/3,o=(1+r*Ke(we*t)/Ke(Wt-we*t))/3,a=1-(s+o)):t<2/3?(t-=1/3,o=(1-r)/3,a=(1+r*Ke(we*t)/Ke(Wt-we*t))/3,s=1-(o+a)):(t-=2/3,a=(1-r)/3,s=(1+r*Ke(we*t)/Ke(Wt-we*t))/3,o=1-(a+s)),o=Xe(n*o*3),a=Xe(n*a*3),s=Xe(n*s*3),[o*255,a*255,s*255,e.length>3?e[3]:1]},{min:Kc,sqrt:Dc,acos:Vc}=Math,Yc=(...e)=>{let[t,r,n]=P(e,"rgb");t/=255,r/=255,n/=255;let o;const a=Kc(t,r,n),s=(t+r+n)/3,c=s>0?1-a/s:0;return c===0?o=NaN:(o=(t-r+(t-n))/2,o/=Dc((t-r)*(t-r)+(t-n)*(r-n)),o=Vc(o),n>r&&(o=we-o),o/=we),[o*360,c,s]};C.prototype.hsi=function(){return Yc(this._rgb)},O.hsi=(...e)=>new C(...e,"hsi"),L.format.hsi=Xc,L.autodetect.push({p:2,test:(...e)=>{if(e=P(e,"hsi"),S(e)==="array"&&e.length===3)return"hsi"}}),C.prototype.hsl=function(){return uo(this._rgb)},O.hsl=(...e)=>new C(...e,"hsl"),L.format.hsl=tr,L.autodetect.push({p:2,test:(...e)=>{if(e=P(e,"hsl"),S(e)==="array"&&e.length===3)return"hsl"}});const{floor:Zc}=Math,Jc=(...e)=>{e=P(e,"hsv");let[t,r,n]=e,o,a,s;if(n*=255,r===0)o=a=s=n;else{t===360&&(t=0),t>360&&(t-=360),t<0&&(t+=360),t/=60;const c=Zc(t),i=t-c,u=n*(1-r),f=n*(1-r*i),l=n*(1-r*(1-i));switch(c){case 0:[o,a,s]=[n,l,u];break;case 1:[o,a,s]=[f,n,u];break;case 2:[o,a,s]=[u,n,l];break;case 3:[o,a,s]=[u,f,n];break;case 4:[o,a,s]=[l,u,n];break;case 5:[o,a,s]=[n,u,f];break}}return[o,a,s,e.length>3?e[3]:1]},{min:Wc,max:Uc}=Math,Qc=(...e)=>{e=P(e,"rgb");let[t,r,n]=e;const o=Wc(t,r,n),a=Uc(t,r,n),s=a-o;let c,i,u;return u=a/255,a===0?(c=Number.NaN,i=0):(i=s/a,t===a&&(c=(r-n)/s),r===a&&(c=2+(n-t)/s),n===a&&(c=4+(t-r)/s),c*=60,c<0&&(c+=360)),[c,i,u]};C.prototype.hsv=function(){return Qc(this._rgb)},O.hsv=(...e)=>new C(...e,"hsv"),L.format.hsv=Jc,L.autodetect.push({p:2,test:(...e)=>{if(e=P(e,"hsv"),S(e)==="array"&&e.length===3)return"hsv"}});const se={Kn:18,Xn:.95047,Yn:1,Zn:1.08883,t0:.137931034,t1:.206896552,t2:.12841855,t3:.008856452},{pow:e0}=Math,yo=(...e)=>{e=P(e,"lab");const[t,r,n]=e;let o,a,s,c,i,u;return a=(t+16)/116,o=isNaN(r)?a:a+r/500,s=isNaN(n)?a:a-n/200,a=se.Yn*or(a),o=se.Xn*or(o),s=se.Zn*or(s),c=nr(3.2404542*o-1.5371385*a-.4985314*s),i=nr(-.969266*o+1.8760108*a+.041556*s),u=nr(.0556434*o-.2040259*a+1.0572252*s),[c,i,u,e.length>3?e[3]:1]},nr=e=>255*(e<=.00304?12.92*e:1.055*e0(e,1/2.4)-.055),or=e=>e>se.t1?e*e*e:se.t2*(e-se.t0),{pow:wo}=Math,ko=(...e)=>{const[t,r,n]=P(e,"rgb"),[o,a,s]=t0(t,r,n),c=116*a-16;return[c<0?0:c,500*(o-a),200*(a-s)]},sr=e=>(e/=255)<=.04045?e/12.92:wo((e+.055)/1.055,2.4),ar=e=>e>se.t3?wo(e,1/3):e/se.t2+se.t0,t0=(e,t,r)=>{e=sr(e),t=sr(t),r=sr(r);const n=ar((.4124564*e+.3575761*t+.1804375*r)/se.Xn),o=ar((.2126729*e+.7151522*t+.072175*r)/se.Yn),a=ar((.0193339*e+.119192*t+.9503041*r)/se.Zn);return[n,o,a]};C.prototype.lab=function(){return ko(this._rgb)},O.lab=(...e)=>new C(...e,"lab"),L.format.lab=yo,L.autodetect.push({p:2,test:(...e)=>{if(e=P(e,"lab"),S(e)==="array"&&e.length===3)return"lab"}});const{sin:r0,cos:n0}=Math,$o=(...e)=>{let[t,r,n]=P(e,"lch");return isNaN(n)&&(n=0),n=n*Ac,[t,n0(n)*r,r0(n)*r]},Co=(...e)=>{e=P(e,"lch");const[t,r,n]=e,[o,a,s]=$o(t,r,n),[c,i,u]=yo(o,a,s);return[c,i,u,e.length>3?e[3]:1]},o0=(...e)=>{const t=P(e,"hcl").reverse();return Co(...t)},{sqrt:s0,atan2:a0,round:c0}=Math,xo=(...e)=>{const[t,r,n]=P(e,"lab"),o=s0(r*r+n*n);let a=(a0(n,r)*Lc+360)%360;return c0(o*1e4)===0&&(a=Number.NaN),[t,o,a]},Ro=(...e)=>{const[t,r,n]=P(e,"rgb"),[o,a,s]=ko(t,r,n);return xo(o,a,s)};C.prototype.lch=function(){return Ro(this._rgb)},C.prototype.hcl=function(){return Ro(this._rgb).reverse()},O.lch=(...e)=>new C(...e,"lch"),O.hcl=(...e)=>new C(...e,"hcl"),L.format.lch=Co,L.format.hcl=o0,["lch","hcl"].forEach(e=>L.autodetect.push({p:2,test:(...t)=>{if(t=P(t,e),S(t)==="array"&&t.length===3)return e}}));const De={aliceblue:"#f0f8ff",antiquewhite:"#faebd7",aqua:"#00ffff",aquamarine:"#7fffd4",azure:"#f0ffff",beige:"#f5f5dc",bisque:"#ffe4c4",black:"#000000",blanchedalmond:"#ffebcd",blue:"#0000ff",blueviolet:"#8a2be2",brown:"#a52a2a",burlywood:"#deb887",cadetblue:"#5f9ea0",chartreuse:"#7fff00",chocolate:"#d2691e",coral:"#ff7f50",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cyan:"#00ffff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",darkgreen:"#006400",darkgrey:"#a9a9a9",darkkhaki:"#bdb76b",darkmagenta:"#8b008b",darkolivegreen:"#556b2f",darkorange:"#ff8c00",darkorchid:"#9932cc",darkred:"#8b0000",darksalmon:"#e9967a",darkseagreen:"#8fbc8f",darkslateblue:"#483d8b",darkslategray:"#2f4f4f",darkslategrey:"#2f4f4f",darkturquoise:"#00ced1",darkviolet:"#9400d3",deeppink:"#ff1493",deepskyblue:"#00bfff",dimgray:"#696969",dimgrey:"#696969",dodgerblue:"#1e90ff",firebrick:"#b22222",floralwhite:"#fffaf0",forestgreen:"#228b22",fuchsia:"#ff00ff",gainsboro:"#dcdcdc",ghostwhite:"#f8f8ff",gold:"#ffd700",goldenrod:"#daa520",gray:"#808080",green:"#008000",greenyellow:"#adff2f",grey:"#808080",honeydew:"#f0fff0",hotpink:"#ff69b4",indianred:"#cd5c5c",indigo:"#4b0082",ivory:"#fffff0",khaki:"#f0e68c",laserlemon:"#ffff54",lavender:"#e6e6fa",lavenderblush:"#fff0f5",lawngreen:"#7cfc00",lemonchiffon:"#fffacd",lightblue:"#add8e6",lightcoral:"#f08080",lightcyan:"#e0ffff",lightgoldenrod:"#fafad2",lightgoldenrodyellow:"#fafad2",lightgray:"#d3d3d3",lightgreen:"#90ee90",lightgrey:"#d3d3d3",lightpink:"#ffb6c1",lightsalmon:"#ffa07a",lightseagreen:"#20b2aa",lightskyblue:"#87cefa",lightslategray:"#778899",lightslategrey:"#778899",lightsteelblue:"#b0c4de",lightyellow:"#ffffe0",lime:"#00ff00",limegreen:"#32cd32",linen:"#faf0e6",magenta:"#ff00ff",maroon:"#800000",maroon2:"#7f0000",maroon3:"#b03060",mediumaquamarine:"#66cdaa",mediumblue:"#0000cd",mediumorchid:"#ba55d3",mediumpurple:"#9370db",mediumseagreen:"#3cb371",mediumslateblue:"#7b68ee",mediumspringgreen:"#00fa9a",mediumturquoise:"#48d1cc",mediumvioletred:"#c71585",midnightblue:"#191970",mintcream:"#f5fffa",mistyrose:"#ffe4e1",moccasin:"#ffe4b5",navajowhite:"#ffdead",navy:"#000080",oldlace:"#fdf5e6",olive:"#808000",olivedrab:"#6b8e23",orange:"#ffa500",orangered:"#ff4500",orchid:"#da70d6",palegoldenrod:"#eee8aa",palegreen:"#98fb98",paleturquoise:"#afeeee",palevioletred:"#db7093",papayawhip:"#ffefd5",peachpuff:"#ffdab9",peru:"#cd853f",pink:"#ffc0cb",plum:"#dda0dd",powderblue:"#b0e0e6",purple:"#800080",purple2:"#7f007f",purple3:"#a020f0",rebeccapurple:"#663399",red:"#ff0000",rosybrown:"#bc8f8f",royalblue:"#4169e1",saddlebrown:"#8b4513",salmon:"#fa8072",sandybrown:"#f4a460",seagreen:"#2e8b57",seashell:"#fff5ee",sienna:"#a0522d",silver:"#c0c0c0",skyblue:"#87ceeb",slateblue:"#6a5acd",slategray:"#708090",slategrey:"#708090",snow:"#fffafa",springgreen:"#00ff7f",steelblue:"#4682b4",tan:"#d2b48c",teal:"#008080",thistle:"#d8bfd8",tomato:"#ff6347",turquoise:"#40e0d0",violet:"#ee82ee",wheat:"#f5deb3",white:"#ffffff",whitesmoke:"#f5f5f5",yellow:"#ffff00",yellowgreen:"#9acd32"};C.prototype.name=function(){const e=_o(this._rgb,"rgb");for(let t of Object.keys(De))if(De[t]===e)return t.toLowerCase();return e},L.format.named=e=>{if(e=e.toLowerCase(),De[e])return vo(De[e]);throw new Error("unknown color name: "+e)},L.autodetect.push({p:5,test:(e,...t)=>{if(!t.length&&S(e)==="string"&&De[e.toLowerCase()])return"named"}});const i0=e=>{if(S(e)=="number"&&e>=0&&e<=16777215){const t=e>>16,r=e>>8&255,n=e&255;return[t,r,n,1]}throw new Error("unknown num color: "+e)},u0=(...e)=>{const[t,r,n]=P(e,"rgb");return(t<<16)+(r<<8)+n};C.prototype.num=function(){return u0(this._rgb)},O.num=(...e)=>new C(...e,"num"),L.format.num=i0,L.autodetect.push({p:5,test:(...e)=>{if(e.length===1&&S(e[0])==="number"&&e[0]>=0&&e[0]<=16777215)return"num"}});const{round:Ho}=Math;C.prototype.rgb=function(e=!0){return e===!1?this._rgb.slice(0,3):this._rgb.slice(0,3).map(Ho)},C.prototype.rgba=function(e=!0){return this._rgb.slice(0,4).map((t,r)=>r<3?e===!1?t:Ho(t):t)},O.rgb=(...e)=>new C(...e,"rgb"),L.format.rgb=(...e)=>{const t=P(e,"rgba");return t[3]===void 0&&(t[3]=1),t},L.autodetect.push({p:3,test:(...e)=>{if(e=P(e,"rgba"),S(e)==="array"&&(e.length===3||e.length===4&&S(e[3])=="number"&&e[3]>=0&&e[3]<=1))return"rgb"}});const{log:vt}=Math,qo=e=>{const t=e/100;let r,n,o;return t<66?(r=255,n=t<6?0:-155.25485562709179-.44596950469579133*(n=t-2)+104.49216199393888*vt(n),o=t<20?0:-254.76935184120902+.8274096064007395*(o=t-10)+115.67994401066147*vt(o)):(r=351.97690566805693+.114206453784165*(r=t-55)-40.25366309332127*vt(r),n=325.4494125711974+.07943456536662342*(n=t-50)-28.0852963507957*vt(n),o=255),[r,n,o,1]},{round:f0}=Math,l0=(...e)=>{const t=P(e,"rgb"),r=t[0],n=t[2];let o=1e3,a=4e4;const s=.4;let c;for(;a-o>s;){c=(a+o)*.5;const i=qo(c);i[2]/i[0]>=n/r?a=c:o=c}return f0(c)};C.prototype.temp=C.prototype.kelvin=C.prototype.temperature=function(){return l0(this._rgb)},O.temp=O.kelvin=O.temperature=(...e)=>new C(...e,"temp"),L.format.temp=L.format.kelvin=L.format.temperature=qo;const{pow:_t,sign:h0}=Math,Mo=(...e)=>{e=P(e,"lab");const[t,r,n]=e,o=_t(t+.3963377774*r+.2158037573*n,3),a=_t(t-.1055613458*r-.0638541728*n,3),s=_t(t-.0894841775*r-1.291485548*n,3);return[255*cr(4.0767416621*o-3.3077115913*a+.2309699292*s),255*cr(-1.2684380046*o+2.6097574011*a-.3413193965*s),255*cr(-.0041960863*o-.7034186147*a+1.707614701*s),e.length>3?e[3]:1]};function cr(e){const t=Math.abs(e);return t>.0031308?(h0(e)||1)*(1.055*_t(t,.4166666666666667)-.055):e*12.92}const{cbrt:ir,pow:d0,sign:b0}=Math,Oo=(...e)=>{const[t,r,n]=P(e,"rgb"),[o,a,s]=[ur(t/255),ur(r/255),ur(n/255)],c=ir(.4122214708*o+.5363325363*a+.0514459929*s),i=ir(.2119034982*o+.6806995451*a+.1073969566*s),u=ir(.0883024619*o+.2817188376*a+.6299787005*s);return[.2104542553*c+.793617785*i-.0040720468*u,1.9779984951*c-2.428592205*i+.4505937099*u,.0259040371*c+.7827717662*i-.808675766*u]};function ur(e){const t=Math.abs(e);return t<.04045?e/12.92:(b0(e)||1)*d0((t+.055)/1.055,2.4)}C.prototype.oklab=function(){return Oo(this._rgb)},O.oklab=(...e)=>new C(...e,"oklab"),L.format.oklab=Mo,L.autodetect.push({p:3,test:(...e)=>{if(e=P(e,"oklab"),S(e)==="array"&&e.length===3)return"oklab"}});const p0=(...e)=>{e=P(e,"lch");const[t,r,n]=e,[o,a,s]=$o(t,r,n),[c,i,u]=Mo(o,a,s);return[c,i,u,e.length>3?e[3]:1]},m0=(...e)=>{const[t,r,n]=P(e,"rgb"),[o,a,s]=Oo(t,r,n);return xo(o,a,s)};C.prototype.oklch=function(){return m0(this._rgb)},O.oklch=(...e)=>new C(...e,"oklch"),L.format.oklch=p0,L.autodetect.push({p:3,test:(...e)=>{if(e=P(e,"oklch"),S(e)==="array"&&e.length===3)return"oklch"}}),C.prototype.alpha=function(e,t=!1){return e!==void 0&&S(e)==="number"?t?(this._rgb[3]=e,this):new C([this._rgb[0],this._rgb[1],this._rgb[2],e],"rgb"):this._rgb[3]},C.prototype.clipped=function(){return this._rgb._clipped||!1},C.prototype.darken=function(e=1){const t=this,r=t.lab();return r[0]-=se.Kn*e,new C(r,"lab").alpha(t.alpha(),!0)},C.prototype.brighten=function(e=1){return this.darken(-e)},C.prototype.darker=C.prototype.darken,C.prototype.brighter=C.prototype.brighten,C.prototype.get=function(e){const[t,r]=e.split("."),n=this[t]();if(r){const o=t.indexOf(r)-(t.substr(0,2)==="ok"?2:0);if(o>-1)return n[o];throw new Error(`unknown channel ${r} in mode ${t}`)}else return n};const{pow:g0}=Math,v0=1e-7,_0=20;C.prototype.luminance=function(e,t="rgb"){if(e!==void 0&&S(e)==="number"){if(e===0)return new C([0,0,0,this._rgb[3]],"rgb");if(e===1)return new C([255,255,255,this._rgb[3]],"rgb");let r=this.luminance(),n=_0;const o=(s,c)=>{const i=s.interpolate(c,.5,t),u=i.luminance();return Math.abs(e-u)e?o(s,i):o(i,c)},a=(r>e?o(new C([0,0,0]),this):o(this,new C([255,255,255]))).rgb();return new C([...a,this._rgb[3]])}return y0(...this._rgb.slice(0,3))};const y0=(e,t,r)=>(e=fr(e),t=fr(t),r=fr(r),.2126*e+.7152*t+.0722*r),fr=e=>(e/=255,e<=.03928?e/12.92:g0((e+.055)/1.055,2.4)),re={},ct=(e,t,r=.5,...n)=>{let o=n[0]||"lrgb";if(!re[o]&&!n.length&&(o=Object.keys(re)[0]),!re[o])throw new Error(`interpolation mode ${o} is not defined`);return S(e)!=="object"&&(e=new C(e)),S(t)!=="object"&&(t=new C(t)),re[o](e,t,r).alpha(e.alpha()+r*(t.alpha()-e.alpha()))};C.prototype.mix=C.prototype.interpolate=function(e,t=.5,...r){return ct(this,e,t,...r)},C.prototype.premultiply=function(e=!1){const t=this._rgb,r=t[3];return e?(this._rgb=[t[0]*r,t[1]*r,t[2]*r,r],this):new C([t[0]*r,t[1]*r,t[2]*r,r],"rgb")},C.prototype.saturate=function(e=1){const t=this,r=t.lch();return r[1]+=se.Kn*e,r[1]<0&&(r[1]=0),new C(r,"lch").alpha(t.alpha(),!0)},C.prototype.desaturate=function(e=1){return this.saturate(-e)},C.prototype.set=function(e,t,r=!1){const[n,o]=e.split("."),a=this[n]();if(o){const s=n.indexOf(o)-(n.substr(0,2)==="ok"?2:0);if(s>-1){if(S(t)=="string")switch(t.charAt(0)){case"+":a[s]+=+t;break;case"-":a[s]+=+t;break;case"*":a[s]*=+t.substr(1);break;case"/":a[s]/=+t.substr(1);break;default:a[s]=+t}else if(S(t)==="number")a[s]=t;else throw new Error("unsupported value for Color.set");const c=new C(a,n);return r?(this._rgb=c._rgb,this):c}throw new Error(`unknown channel ${o} in mode ${n}`)}else return a},C.prototype.tint=function(e=.5,...t){return ct(this,"white",e,...t)},C.prototype.shade=function(e=.5,...t){return ct(this,"black",e,...t)};const w0=(e,t,r)=>{const n=e._rgb,o=t._rgb;return new C(n[0]+r*(o[0]-n[0]),n[1]+r*(o[1]-n[1]),n[2]+r*(o[2]-n[2]),"rgb")};re.rgb=w0;const{sqrt:lr,pow:Ve}=Math,k0=(e,t,r)=>{const[n,o,a]=e._rgb,[s,c,i]=t._rgb;return new C(lr(Ve(n,2)*(1-r)+Ve(s,2)*r),lr(Ve(o,2)*(1-r)+Ve(c,2)*r),lr(Ve(a,2)*(1-r)+Ve(i,2)*r),"rgb")};re.lrgb=k0;const $0=(e,t,r)=>{const n=e.lab(),o=t.lab();return new C(n[0]+r*(o[0]-n[0]),n[1]+r*(o[1]-n[1]),n[2]+r*(o[2]-n[2]),"lab")};re.lab=$0;const Ye=(e,t,r,n)=>{let o,a;n==="hsl"?(o=e.hsl(),a=t.hsl()):n==="hsv"?(o=e.hsv(),a=t.hsv()):n==="hcg"?(o=e.hcg(),a=t.hcg()):n==="hsi"?(o=e.hsi(),a=t.hsi()):n==="lch"||n==="hcl"?(n="hcl",o=e.hcl(),a=t.hcl()):n==="oklch"&&(o=e.oklch().reverse(),a=t.oklch().reverse());let s,c,i,u,f,l;(n.substr(0,1)==="h"||n==="oklch")&&([s,i,f]=o,[c,u,l]=a);let h,d,p,v;return!isNaN(s)&&!isNaN(c)?(c>s&&c-s>180?v=c-(s+360):c180?v=c+360-s:v=c-s,d=s+r*v):isNaN(s)?isNaN(c)?d=Number.NaN:(d=c,(f==1||f==0)&&n!="hsv"&&(h=u)):(d=s,(l==1||l==0)&&n!="hsv"&&(h=i)),h===void 0&&(h=i+r*(u-i)),p=f+r*(l-f),n==="oklch"?new C([p,h,d],n):new C([d,h,p],n)},No=(e,t,r)=>Ye(e,t,r,"lch");re.lch=No,re.hcl=No;const C0=(e,t,r)=>{const n=e.num(),o=t.num();return new C(n+r*(o-n),"num")};re.num=C0;const x0=(e,t,r)=>Ye(e,t,r,"hcg");re.hcg=x0;const R0=(e,t,r)=>Ye(e,t,r,"hsi");re.hsi=R0;const H0=(e,t,r)=>Ye(e,t,r,"hsl");re.hsl=H0;const q0=(e,t,r)=>Ye(e,t,r,"hsv");re.hsv=q0;const M0=(e,t,r)=>{const n=e.oklab(),o=t.oklab();return new C(n[0]+r*(o[0]-n[0]),n[1]+r*(o[1]-n[1]),n[2]+r*(o[2]-n[2]),"oklab")};re.oklab=M0;const O0=(e,t,r)=>Ye(e,t,r,"oklch");re.oklch=O0;const{pow:hr,sqrt:dr,PI:br,cos:Ao,sin:Lo,atan2:N0}=Math,A0=(e,t="lrgb",r=null)=>{const n=e.length;r||(r=Array.from(new Array(n)).map(()=>1));const o=n/r.reduce(function(l,h){return l+h});if(r.forEach((l,h)=>{r[h]*=o}),e=e.map(l=>new C(l)),t==="lrgb")return L0(e,r);const a=e.shift(),s=a.get(t),c=[];let i=0,u=0;for(let l=0;l{const d=l.get(t);f+=l.alpha()*r[h+1];for(let p=0;p=360;)h-=360;s[l]=h}else s[l]=s[l]/c[l];return f/=n,new C(s,t).alpha(f>.99999?1:f,!0)},L0=(e,t)=>{const r=e.length,n=[0,0,0,0];for(let o=0;o.9999999&&(n[3]=1),new C(Yt(n))},{pow:E0}=Math;function yt(e){let t="rgb",r=O("#ccc"),n=0,o=[0,1],a=[],s=[0,0],c=!1,i=[],u=!1,f=0,l=1,h=!1,d={},p=!0,v=1;const m=function(b){if(b=b||["#fff","#000"],b&&S(b)==="string"&&O.brewer&&O.brewer[b.toLowerCase()]&&(b=O.brewer[b.toLowerCase()]),S(b)==="array"){b.length===1&&(b=[b[0],b[0]]),b=b.slice(0);for(let _=0;_=c[k];)k++;return k-1}return 0};let R=b=>b,x=b=>b;const M=function(b,_){let k,y;if(_==null&&(_=!1),isNaN(b)||b===null)return r;_?y=b:c&&c.length>2?y=g(b)/(c.length-2):l!==f?y=(b-f)/(l-f):y=1,y=x(y),_||(y=R(y)),v!==1&&(y=E0(y,v)),y=s[0]+y*(1-s[0]-s[1]),y=Xe(y,0,1);const q=Math.floor(y*1e4);if(p&&d[q])k=d[q];else{if(S(i)==="array")for(let H=0;H=A&&H===a.length-1){k=i[H];break}if(y>A&&yd={};m(e);const w=function(b){const _=O(M(b));return u&&_[u]?_[u]():_};return w.classes=function(b){if(b!=null){if(S(b)==="array")c=b,o=[b[0],b[b.length-1]];else{const _=O.analyze(o);b===0?c=[_.min,_.max]:c=O.limits(_,"e",b)}return w}return c},w.domain=function(b){if(!arguments.length)return o;f=b[0],l=b[b.length-1],a=[];const _=i.length;if(b.length===_&&f!==l)for(let k of Array.from(b))a.push((k-f)/(l-f));else{for(let k=0;k<_;k++)a.push(k/(_-1));if(b.length>2){const k=b.map((q,H)=>H/(b.length-1)),y=b.map(q=>(q-f)/(l-f));y.every((q,H)=>k[H]===q)||(x=q=>{if(q<=0||q>=1)return q;let H=0;for(;q>=y[H+1];)H++;const A=(q-y[H])/(y[H+1]-y[H]);return k[H]+A*(k[H+1]-k[H])})}}return o=[f,l],w},w.mode=function(b){return arguments.length?(t=b,N(),w):t},w.range=function(b,_){return m(b),w},w.out=function(b){return u=b,w},w.spread=function(b){return arguments.length?(n=b,w):n},w.correctLightness=function(b){return b==null&&(b=!0),h=b,N(),h?R=function(_){const k=M(0,!0).lab()[0],y=M(1,!0).lab()[0],q=k>y;let H=M(_,!0).lab()[0];const A=k+(y-k)*_;let I=H-A,K=0,D=1,U=20;for(;Math.abs(I)>.01&&U-- >0;)(function(){return q&&(I*=-1),I<0?(K=_,_+=(D-_)*.5):(D=_,_+=(K-_)*.5),H=M(_,!0).lab()[0],I=H-A})();return _}:R=_=>_,w},w.padding=function(b){return b!=null?(S(b)==="number"&&(b=[b,b]),s=b,w):s},w.colors=function(b,_){arguments.length<2&&(_="hex");let k=[];if(arguments.length===0)k=i.slice(0);else if(b===1)k=[w(.5)];else if(b>1){const y=o[0],q=o[1]-y;k=T0(0,b).map(H=>w(y+H/(b-1)*q))}else{e=[];let y=[];if(c&&c.length>2)for(let q=1,H=c.length,A=1<=H;A?qH;A?q++:q--)y.push((c[q-1]+c[q])*.5);else y=o;k=y.map(q=>w(q))}return O[_]&&(k=k.map(y=>y[_]())),k},w.cache=function(b){return b!=null?(p=b,w):p},w.gamma=function(b){return b!=null?(v=b,w):v},w.nodata=function(b){return b!=null?(r=O(b),w):r},w}function T0(e,t,r){let n=[],o=ea;o?s++:s--)n.push(s);return n}const S0=function(e){let t=[1,1];for(let r=1;rnew C(a)),e.length===2)[r,n]=e.map(a=>a.lab()),t=function(a){const s=[0,1,2].map(c=>r[c]+a*(n[c]-r[c]));return new C(s,"lab")};else if(e.length===3)[r,n,o]=e.map(a=>a.lab()),t=function(a){const s=[0,1,2].map(c=>(1-a)*(1-a)*r[c]+2*(1-a)*a*n[c]+a*a*o[c]);return new C(s,"lab")};else if(e.length===4){let a;[r,n,o,a]=e.map(s=>s.lab()),t=function(s){const c=[0,1,2].map(i=>(1-s)*(1-s)*(1-s)*r[i]+3*(1-s)*(1-s)*s*n[i]+3*(1-s)*s*s*o[i]+s*s*s*a[i]);return new C(c,"lab")}}else if(e.length>=5){let a,s,c;a=e.map(i=>i.lab()),c=e.length-1,s=S0(c),t=function(i){const u=1-i,f=[0,1,2].map(l=>a.reduce((h,d,p)=>h+s[p]*u**(c-p)*i**p*d[l],0));return new C(f,"lab")}}else throw new RangeError("No point in running bezier with only one color.");return t},j0=e=>{const t=P0(e);return t.scale=()=>yt(t),t},he=(e,t,r)=>{if(!he[r])throw new Error("unknown blend mode "+r);return he[r](e,t)},Ne=e=>(t,r)=>{const n=O(r).rgb(),o=O(t).rgb();return O.rgb(e(n,o))},Ae=e=>(t,r)=>{const n=[];return n[0]=e(t[0],r[0]),n[1]=e(t[1],r[1]),n[2]=e(t[2],r[2]),n},B0=e=>e,G0=(e,t)=>e*t/255,I0=(e,t)=>e>t?t:e,z0=(e,t)=>e>t?e:t,F0=(e,t)=>255*(1-(1-e/255)*(1-t/255)),X0=(e,t)=>t<128?2*e*t/255:255*(1-2*(1-e/255)*(1-t/255)),K0=(e,t)=>255*(1-(1-t/255)/(e/255)),D0=(e,t)=>e===255?255:(e=255*(t/255)/(1-e/255),e>255?255:e);he.normal=Ne(Ae(B0)),he.multiply=Ne(Ae(G0)),he.screen=Ne(Ae(F0)),he.overlay=Ne(Ae(X0)),he.darken=Ne(Ae(I0)),he.lighten=Ne(Ae(z0)),he.dodge=Ne(Ae(D0)),he.burn=Ne(Ae(K0));const{pow:V0,sin:Y0,cos:Z0}=Math;function J0(e=300,t=-1.5,r=1,n=1,o=[0,1]){let a=0,s;S(o)==="array"?s=o[1]-o[0]:(s=0,o=[o,o]);const c=function(i){const u=we*((e+120)/360+t*i),f=V0(o[0]+s*i,n),h=(a!==0?r[0]+i*a:r)*f*(1-f)/2,d=Z0(u),p=Y0(u),v=f+h*(-.14861*d+1.78277*p),m=f+h*(-.29227*d-.90649*p),g=f+h*(1.97294*d);return O(Yt([v*255,m*255,g*255,1]))};return c.start=function(i){return i==null?e:(e=i,c)},c.rotations=function(i){return i==null?t:(t=i,c)},c.gamma=function(i){return i==null?n:(n=i,c)},c.hue=function(i){return i==null?r:(r=i,S(r)==="array"?(a=r[1]-r[0],a===0&&(r=r[1])):a=0,c)},c.lightness=function(i){return i==null?o:(S(i)==="array"?(o=i,s=i[1]-i[0]):(o=[i,i],s=0),c)},c.scale=()=>O.scale(c),c.hue(r),c}const W0="0123456789abcdef",{floor:U0,random:Q0}=Math,ei=()=>{let e="#";for(let t=0;t<6;t++)e+=W0.charAt(U0(Q0()*16));return new C(e,"hex")},{log:Eo,pow:ti,floor:ri,abs:ni}=Math;function To(e,t=null){const r={min:Number.MAX_VALUE,max:Number.MAX_VALUE*-1,sum:0,values:[],count:0};return S(e)==="object"&&(e=Object.values(e)),e.forEach(n=>{t&&S(n)==="object"&&(n=n[t]),n!=null&&!isNaN(n)&&(r.values.push(n),r.sum+=n,nr.max&&(r.max=n),r.count+=1)}),r.domain=[r.min,r.max],r.limits=(n,o)=>So(r,n,o),r}function So(e,t="equal",r=7){S(e)=="array"&&(e=To(e));const{min:n,max:o}=e,a=e.values.sort((c,i)=>c-i);if(r===1)return[n,o];const s=[];if(t.substr(0,1)==="c"&&(s.push(n),s.push(o)),t.substr(0,1)==="e"){s.push(n);for(let c=1;c 0");const c=Math.LOG10E*Eo(n),i=Math.LOG10E*Eo(o);s.push(n);for(let u=1;u200&&(l=!1)}const p={};for(let m=0;mm-g),s.push(v[0]);for(let m=1;m{e=new C(e),t=new C(t);const r=e.luminance(),n=t.luminance();return r>n?(r+.05)/(n+.05):(n+.05)/(r+.05)},{sqrt:ke,pow:V,min:si,max:ai,atan2:Po,abs:jo,cos:wt,sin:Bo,exp:ci,PI:Go}=Math;function ii(e,t,r=1,n=1,o=1){var a=function(le){return 360*le/(2*Go)},s=function(le){return 2*Go*le/360};e=new C(e),t=new C(t);const[c,i,u]=Array.from(e.lab()),[f,l,h]=Array.from(t.lab()),d=(c+f)/2,p=ke(V(i,2)+V(u,2)),v=ke(V(l,2)+V(h,2)),m=(p+v)/2,g=.5*(1-ke(V(m,7)/(V(m,7)+V(25,7)))),R=i*(1+g),x=l*(1+g),M=ke(V(R,2)+V(u,2)),N=ke(V(x,2)+V(h,2)),w=(M+N)/2,b=a(Po(u,R)),_=a(Po(h,x)),k=b>=0?b:b+360,y=_>=0?_:_+360,q=jo(k-y)>180?(k+y+360)/2:(k+y)/2,H=1-.17*wt(s(q-30))+.24*wt(s(2*q))+.32*wt(s(3*q+6))-.2*wt(s(4*q-63));let A=y-k;A=jo(A)<=180?A:y<=k?A+360:A-360,A=2*ke(M*N)*Bo(s(A)/2);const I=f-c,K=N-M,D=1+.015*V(d-50,2)/ke(20+V(d-50,2)),U=1+.045*w,ge=1+.015*w*H,ye=30*ci(-V((q-275)/25,2)),fe=-(2*ke(V(w,7)/(V(w,7)+V(25,7))))*Bo(2*s(ye)),qe=ke(V(I/(r*D),2)+V(K/(n*U),2)+V(A/(o*ge),2)+fe*(K/(n*U))*(A/(o*ge)));return ai(0,si(100,qe))}function ui(e,t,r="lab"){e=new C(e),t=new C(t);const n=e.get(r),o=t.get(r);let a=0;for(let s in n){const c=(n[s]||0)-(o[s]||0);a+=c*c}return Math.sqrt(a)}const fi=(...e)=>{try{return new C(...e),!0}catch{return!1}},li={cool(){return yt([O.hsl(180,1,.9),O.hsl(250,.7,.4)])},hot(){return yt(["#000","#f00","#ff0","#fff"]).mode("rgb")}},kt={OrRd:["#fff7ec","#fee8c8","#fdd49e","#fdbb84","#fc8d59","#ef6548","#d7301f","#b30000","#7f0000"],PuBu:["#fff7fb","#ece7f2","#d0d1e6","#a6bddb","#74a9cf","#3690c0","#0570b0","#045a8d","#023858"],BuPu:["#f7fcfd","#e0ecf4","#bfd3e6","#9ebcda","#8c96c6","#8c6bb1","#88419d","#810f7c","#4d004b"],Oranges:["#fff5eb","#fee6ce","#fdd0a2","#fdae6b","#fd8d3c","#f16913","#d94801","#a63603","#7f2704"],BuGn:["#f7fcfd","#e5f5f9","#ccece6","#99d8c9","#66c2a4","#41ae76","#238b45","#006d2c","#00441b"],YlOrBr:["#ffffe5","#fff7bc","#fee391","#fec44f","#fe9929","#ec7014","#cc4c02","#993404","#662506"],YlGn:["#ffffe5","#f7fcb9","#d9f0a3","#addd8e","#78c679","#41ab5d","#238443","#006837","#004529"],Reds:["#fff5f0","#fee0d2","#fcbba1","#fc9272","#fb6a4a","#ef3b2c","#cb181d","#a50f15","#67000d"],RdPu:["#fff7f3","#fde0dd","#fcc5c0","#fa9fb5","#f768a1","#dd3497","#ae017e","#7a0177","#49006a"],Greens:["#f7fcf5","#e5f5e0","#c7e9c0","#a1d99b","#74c476","#41ab5d","#238b45","#006d2c","#00441b"],YlGnBu:["#ffffd9","#edf8b1","#c7e9b4","#7fcdbb","#41b6c4","#1d91c0","#225ea8","#253494","#081d58"],Purples:["#fcfbfd","#efedf5","#dadaeb","#bcbddc","#9e9ac8","#807dba","#6a51a3","#54278f","#3f007d"],GnBu:["#f7fcf0","#e0f3db","#ccebc5","#a8ddb5","#7bccc4","#4eb3d3","#2b8cbe","#0868ac","#084081"],Greys:["#ffffff","#f0f0f0","#d9d9d9","#bdbdbd","#969696","#737373","#525252","#252525","#000000"],YlOrRd:["#ffffcc","#ffeda0","#fed976","#feb24c","#fd8d3c","#fc4e2a","#e31a1c","#bd0026","#800026"],PuRd:["#f7f4f9","#e7e1ef","#d4b9da","#c994c7","#df65b0","#e7298a","#ce1256","#980043","#67001f"],Blues:["#f7fbff","#deebf7","#c6dbef","#9ecae1","#6baed6","#4292c6","#2171b5","#08519c","#08306b"],PuBuGn:["#fff7fb","#ece2f0","#d0d1e6","#a6bddb","#67a9cf","#3690c0","#02818a","#016c59","#014636"],Viridis:["#440154","#482777","#3f4a8a","#31678e","#26838f","#1f9d8a","#6cce5a","#b6de2b","#fee825"],Spectral:["#9e0142","#d53e4f","#f46d43","#fdae61","#fee08b","#ffffbf","#e6f598","#abdda4","#66c2a5","#3288bd","#5e4fa2"],RdYlGn:["#a50026","#d73027","#f46d43","#fdae61","#fee08b","#ffffbf","#d9ef8b","#a6d96a","#66bd63","#1a9850","#006837"],RdBu:["#67001f","#b2182b","#d6604d","#f4a582","#fddbc7","#f7f7f7","#d1e5f0","#92c5de","#4393c3","#2166ac","#053061"],PiYG:["#8e0152","#c51b7d","#de77ae","#f1b6da","#fde0ef","#f7f7f7","#e6f5d0","#b8e186","#7fbc41","#4d9221","#276419"],PRGn:["#40004b","#762a83","#9970ab","#c2a5cf","#e7d4e8","#f7f7f7","#d9f0d3","#a6dba0","#5aae61","#1b7837","#00441b"],RdYlBu:["#a50026","#d73027","#f46d43","#fdae61","#fee090","#ffffbf","#e0f3f8","#abd9e9","#74add1","#4575b4","#313695"],BrBG:["#543005","#8c510a","#bf812d","#dfc27d","#f6e8c3","#f5f5f5","#c7eae5","#80cdc1","#35978f","#01665e","#003c30"],RdGy:["#67001f","#b2182b","#d6604d","#f4a582","#fddbc7","#ffffff","#e0e0e0","#bababa","#878787","#4d4d4d","#1a1a1a"],PuOr:["#7f3b08","#b35806","#e08214","#fdb863","#fee0b6","#f7f7f7","#d8daeb","#b2abd2","#8073ac","#542788","#2d004b"],Set2:["#66c2a5","#fc8d62","#8da0cb","#e78ac3","#a6d854","#ffd92f","#e5c494","#b3b3b3"],Accent:["#7fc97f","#beaed4","#fdc086","#ffff99","#386cb0","#f0027f","#bf5b17","#666666"],Set1:["#e41a1c","#377eb8","#4daf4a","#984ea3","#ff7f00","#ffff33","#a65628","#f781bf","#999999"],Set3:["#8dd3c7","#ffffb3","#bebada","#fb8072","#80b1d3","#fdb462","#b3de69","#fccde5","#d9d9d9","#bc80bd","#ccebc5","#ffed6f"],Dark2:["#1b9e77","#d95f02","#7570b3","#e7298a","#66a61e","#e6ab02","#a6761d","#666666"],Paired:["#a6cee3","#1f78b4","#b2df8a","#33a02c","#fb9a99","#e31a1c","#fdbf6f","#ff7f00","#cab2d6","#6a3d9a","#ffff99","#b15928"],Pastel2:["#b3e2cd","#fdcdac","#cbd5e8","#f4cae4","#e6f5c9","#fff2ae","#f1e2cc","#cccccc"],Pastel1:["#fbb4ae","#b3cde3","#ccebc5","#decbe4","#fed9a6","#ffffcc","#e5d8bd","#fddaec","#f2f2f2"]};for(let e of Object.keys(kt))kt[e.toLowerCase()]=kt[e];Object.assign(O,{average:A0,bezier:j0,blend:he,cubehelix:J0,mix:ct,interpolate:ct,random:ei,scale:yt,analyze:To,contrast:oi,deltaE:ii,distance:ui,limits:So,valid:fi,scales:li,input:L,colors:De,brewer:kt});function pr(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var mr,Io;function hi(){if(Io)return mr;Io=1;var e=e||{};e.Geometry=function(){},e.Geometry.intersectLineLine=function(r,n){var o=(r.intercept-n.intercept)/(n.slope-r.slope),a=r.slope*o+r.intercept;return{x:o,y:a}},e.Geometry.distanceFromOrigin=function(r){return Math.sqrt(Math.pow(r.x,2)+Math.pow(r.y,2))},e.Geometry.distanceLineFromOrigin=function(r){return Math.abs(r.intercept)/Math.sqrt(Math.pow(r.slope,2)+1)},e.Geometry.perpendicularThroughPoint=function(r,n){var o=-1/r.slope,a=n.y-o*n.x;return{slope:o,intercept:a}},e.Geometry.angleFromOrigin=function(r){return Math.atan2(r.y,r.x)},e.Geometry.normalizeAngle=function(r){var n=2*Math.PI;return(r%n+n)%n},e.Geometry.lengthOfRayUntilIntersect=function(r,n){return n.intercept/(Math.sin(r)-n.slope*Math.cos(r))},e.Hsluv=function(){},e.Hsluv.getBounds=function(r){for(var n=[],o=Math.pow(r+16,3)/1560896,a=o>e.Hsluv.epsilon?o:r/e.Hsluv.kappa,s=0;s<3;)for(var c=s++,i=e.Hsluv.m[c][0],u=e.Hsluv.m[c][1],f=e.Hsluv.m[c][2],l=0;l<2;){var h=l++,d=(284517*i-94839*f)*a,p=(838422*f+769860*u+731718*i)*r*a-769860*h*r,v=(632260*f-126452*u)*a+126452*h;n.push({slope:d/v,intercept:p/v})}return n},e.Hsluv.maxSafeChromaForL=function(r){for(var n=e.Hsluv.getBounds(r),o=1/0,a=0;a=0&&(s=Math.min(s,u))}return s},e.Hsluv.dotProduct=function(r,n){for(var o=0,a=0,s=r.length;a.04045?Math.pow((r+.055)/1.055,2.4):r/12.92},e.Hsluv.xyzToRgb=function(r){return[e.Hsluv.fromLinear(e.Hsluv.dotProduct(e.Hsluv.m[0],r)),e.Hsluv.fromLinear(e.Hsluv.dotProduct(e.Hsluv.m[1],r)),e.Hsluv.fromLinear(e.Hsluv.dotProduct(e.Hsluv.m[2],r))]},e.Hsluv.rgbToXyz=function(r){var n=[e.Hsluv.toLinear(r[0]),e.Hsluv.toLinear(r[1]),e.Hsluv.toLinear(r[2])];return[e.Hsluv.dotProduct(e.Hsluv.minv[0],n),e.Hsluv.dotProduct(e.Hsluv.minv[1],n),e.Hsluv.dotProduct(e.Hsluv.minv[2],n)]},e.Hsluv.yToL=function(r){return r<=e.Hsluv.epsilon?r/e.Hsluv.refY*e.Hsluv.kappa:116*Math.pow(r/e.Hsluv.refY,.3333333333333333)-16},e.Hsluv.lToY=function(r){return r<=8?e.Hsluv.refY*r/e.Hsluv.kappa:e.Hsluv.refY*Math.pow((r+16)/116,3)},e.Hsluv.xyzToLuv=function(r){var n=r[0],o=r[1],a=r[2],s=n+15*o+3*a,c=4*n,i=9*o;s!=0?(c/=s,i/=s):(c=NaN,i=NaN);var u=e.Hsluv.yToL(o);if(u==0)return[0,0,0];var f=13*u*(c-e.Hsluv.refU),l=13*u*(i-e.Hsluv.refV);return[u,f,l]},e.Hsluv.luvToXyz=function(r){var n=r[0],o=r[1],a=r[2];if(n==0)return[0,0,0];var s=o/(13*n)+e.Hsluv.refU,c=a/(13*n)+e.Hsluv.refV,i=e.Hsluv.lToY(n),u=0-9*i*s/((s-4)*c-s*c),f=(9*i-15*c*i-c*u)/(3*c);return[u,i,f]},e.Hsluv.luvToLch=function(r){var n=r[0],o=r[1],a=r[2],s=Math.sqrt(o*o+a*a),c;if(s<1e-8)c=0;else{var i=Math.atan2(a,o);c=i*180/Math.PI,c<0&&(c=360+c)}return[n,s,c]},e.Hsluv.lchToLuv=function(r){var n=r[0],o=r[1],a=r[2],s=a/360*2*Math.PI,c=Math.cos(s)*o,i=Math.sin(s)*o;return[n,c,i]},e.Hsluv.hsluvToLch=function(r){var n=r[0],o=r[1],a=r[2];if(a>99.9999999)return[100,0,n];if(a<1e-8)return[0,0,n];var s=e.Hsluv.maxChromaForLH(a,n),c=s/100*o;return[a,c,n]},e.Hsluv.lchToHsluv=function(r){var n=r[0],o=r[1],a=r[2];if(n>99.9999999)return[a,0,100];if(n<1e-8)return[a,0,0];var s=e.Hsluv.maxChromaForLH(n,a),c=o/s*100;return[a,c,n]},e.Hsluv.hpluvToLch=function(r){var n=r[0],o=r[1],a=r[2];if(a>99.9999999)return[100,0,n];if(a<1e-8)return[0,0,n];var s=e.Hsluv.maxSafeChromaForL(a),c=s/100*o;return[a,c,n]},e.Hsluv.lchToHpluv=function(r){var n=r[0],o=r[1],a=r[2];if(n>99.9999999)return[a,0,100];if(n<1e-8)return[a,0,0];var s=e.Hsluv.maxSafeChromaForL(n),c=o/s*100;return[a,c,n]},e.Hsluv.rgbToHex=function(r){for(var n="#",o=0;o<3;){var a=o++,s=r[a],c=Math.round(s*255),i=c%16,u=(c-i)/16|0;n+=e.Hsluv.hexChars.charAt(u)+e.Hsluv.hexChars.charAt(i)}return n},e.Hsluv.hexToRgb=function(r){r=r.toLowerCase();for(var n=[],o=0;o<3;){var a=o++,s=e.Hsluv.hexChars.indexOf(r.charAt(a*2+1)),c=e.Hsluv.hexChars.indexOf(r.charAt(a*2+2)),i=s*16+c;n.push(i/255)}return n},e.Hsluv.lchToRgb=function(r){return e.Hsluv.xyzToRgb(e.Hsluv.luvToXyz(e.Hsluv.lchToLuv(r)))},e.Hsluv.rgbToLch=function(r){return e.Hsluv.luvToLch(e.Hsluv.xyzToLuv(e.Hsluv.rgbToXyz(r)))},e.Hsluv.hsluvToRgb=function(r){return e.Hsluv.lchToRgb(e.Hsluv.hsluvToLch(r))},e.Hsluv.rgbToHsluv=function(r){return e.Hsluv.lchToHsluv(e.Hsluv.rgbToLch(r))},e.Hsluv.hpluvToRgb=function(r){return e.Hsluv.lchToRgb(e.Hsluv.hpluvToLch(r))},e.Hsluv.rgbToHpluv=function(r){return e.Hsluv.lchToHpluv(e.Hsluv.rgbToLch(r))},e.Hsluv.hsluvToHex=function(r){return e.Hsluv.rgbToHex(e.Hsluv.hsluvToRgb(r))},e.Hsluv.hpluvToHex=function(r){return e.Hsluv.rgbToHex(e.Hsluv.hpluvToRgb(r))},e.Hsluv.hexToHsluv=function(r){return e.Hsluv.rgbToHsluv(e.Hsluv.hexToRgb(r))},e.Hsluv.hexToHpluv=function(r){return e.Hsluv.rgbToHpluv(e.Hsluv.hexToRgb(r))},e.Hsluv.m=[[3.240969941904521,-1.537383177570093,-.498610760293],[-.96924363628087,1.87596750150772,.041555057407175],[.055630079696993,-.20397695888897,1.056971514242878]],e.Hsluv.minv=[[.41239079926595,.35758433938387,.18048078840183],[.21263900587151,.71516867876775,.072192315360733],[.019330818715591,.11919477979462,.95053215224966]],e.Hsluv.refY=1,e.Hsluv.refU=.19783000664283,e.Hsluv.refV=.46831999493879,e.Hsluv.kappa=903.2962962,e.Hsluv.epsilon=.0088564516,e.Hsluv.hexChars="0123456789abcdef";var t={hsluvToRgb:e.Hsluv.hsluvToRgb,rgbToHsluv:e.Hsluv.rgbToHsluv,hpluvToRgb:e.Hsluv.hpluvToRgb,rgbToHpluv:e.Hsluv.rgbToHpluv,hsluvToHex:e.Hsluv.hsluvToHex,hexToHsluv:e.Hsluv.hexToHsluv,hpluvToHex:e.Hsluv.hpluvToHex,hexToHpluv:e.Hsluv.hexToHpluv,lchToHpluv:e.Hsluv.lchToHpluv,hpluvToLch:e.Hsluv.hpluvToLch,lchToHsluv:e.Hsluv.lchToHsluv,hsluvToLch:e.Hsluv.hsluvToLch,lchToLuv:e.Hsluv.lchToLuv,luvToLch:e.Hsluv.luvToLch,xyzToLuv:e.Hsluv.xyzToLuv,luvToXyz:e.Hsluv.luvToXyz,xyzToRgb:e.Hsluv.xyzToRgb,rgbToXyz:e.Hsluv.rgbToXyz,lchToRgb:e.Hsluv.lchToRgb,rgbToLch:e.Hsluv.rgbToLch};return mr=t,mr}var di=hi();const gr=pr(di);var $t={exports:{}},vr,zo;function it(){if(zo)return vr;zo=1;function e(t,r){return Object.prototype.hasOwnProperty.call(t,r)}return vr=e,vr}var _r,Fo;function yr(){if(Fo)return _r;Fo=1;var e=it(),t,r;function n(){r=["toString","toLocaleString","valueOf","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","constructor"],t=!0;for(var s in{toString:null})t=!1}function o(s,c,i){var u,f=0;t==null&&n();for(u in s)if(a(c,s,u,i)===!1)break;if(t)for(var l=s.constructor,h=!!l&&s===l.prototype;(u=r[f++])&&!((u!=="constructor"||!h&&e(s,u))&&s[u]!==Object.prototype[u]&&a(c,s,u,i)===!1););}function a(s,c,i,u){return s.call(u,c[i],i,c)}return _r=o,_r}var wr,Xo;function Ko(){if(Xo)return wr;Xo=1;var e=yr();function t(r){var n=[];return e(r,function(o,a){typeof o=="function"&&n.push(a)}),n.sort()}return wr=t,wr}var kr,Do;function ut(){if(Do)return kr;Do=1;function e(t,r,n){var o=t.length;r==null?r=0:r<0?r=Math.max(o+r,0):r=Math.min(r,o),n==null?n=o:n<0?n=Math.max(o+n,0):n=Math.min(n,o);for(var a=[];r1?n(arguments,1):e(a);r(c,function(i){a[i]=t(a[i],a)})}return Rr=o,Rr}var Hr,Jo;function Q(){if(Jo)return Hr;Jo=1;var e=it(),t=yr();function r(n,o,a){t(n,function(s,c){if(e(n,c))return o.call(a,n[c],c,n)})}return Hr=r,Hr}var qr,Wo;function mi(){if(Wo)return qr;Wo=1;function e(t){return t}return qr=e,qr}var Mr,Uo;function Qo(){if(Uo)return Mr;Uo=1;function e(t){return function(r){return r[t]}}return Mr=e,Mr}var Or,es;function Nr(){if(es)return Or;es=1;var e=/^\[object (.*)\]$/,t=Object.prototype.toString,r;function n(o){return o===null?"Null":o===r?"Undefined":e.exec(t.call(o))[1]}return Or=n,Or}var Ar,ts;function Lr(){if(ts)return Ar;ts=1;var e=Nr();function t(r,n){return e(r)===n}return Ar=t,Ar}var Er,rs;function gi(){if(rs)return Er;rs=1;var e=Lr(),t=Array.isArray||function(r){return e(r,"Array")};return Er=t,Er}var Tr,ns;function os(){if(ns)return Tr;ns=1;var e=Q(),t=gi();function r(s,c){for(var i=-1,u=s.length;++is&&(s=i,a=c);return a}return rn=t,rn}var nn,Ns;function on(){if(Ns)return nn;Ns=1;var e=Q();function t(r){var n=[];return e(r,function(o,a){n.push(o)}),n}return nn=t,nn}var sn,As;function Mi(){if(As)return sn;As=1;var e=qi(),t=on();function r(n,o){return e(t(n),o)}return sn=r,sn}var an,Ls;function Es(){if(Ls)return an;Ls=1;var e=Q();function t(n,o){for(var a=0,s=arguments.length,c;++a2;if(!t(n)&&!c)throw new Error("reduce of empty object with no initial value");return e(n,function(i,u,f){c?a=o.call(s,a,i,u,f):(a=i,c=!0)}),a}return yn=r,yn}var wn,Js;function Ii(){if(Js)return wn;Js=1;var e=_s(),t=Le();function r(n,o,a){return o=t(o,a),e(n,function(s,c,i){return!o(s,c,i)},a)}return wn=r,wn}var kn,Ws;function zi(){if(Ws)return kn;Ws=1;var e=Lr();function t(r){return e(r,"Function")}return kn=t,kn}var $n,Us;function Fi(){if(Us)return $n;Us=1;var e=zi();function t(r,n){var o=r[n];if(o!==void 0)return e(o)?o.call(r):o}return $n=t,$n}var Cn,Qs;function Xi(){if(Qs)return Cn;Qs=1;var e=Is();function t(r,n,o){var a=/^(.+)\.(.+)$/.exec(n);a?e(r,a[1])[a[2]]=o:r[n]=o}return Cn=t,Cn}var xn,ea;function Ki(){if(ea)return xn;ea=1;var e=xs();function t(r,n){if(e(r,n)){for(var o=n.split("."),a=o.pop();n=o.shift();)r=r[n];return delete r[a]}else return!0}return xn=t,xn}var Rn,ta;function Hn(){return ta||(ta=1,Rn={bindAll:pi(),contains:vi(),deepFillIn:_i(),deepMatches:os(),deepMixIn:yi(),equals:ki(),every:hs(),fillIn:$i(),filter:_s(),find:Ci(),flatten:xi(),forIn:yr(),forOwn:Q(),functions:Ko(),get:$s(),has:xs(),hasOwn:it(),keys:Ri(),map:qs(),matches:Hi(),max:Mi(),merge:Ai(),min:Ei(),mixIn:Es(),namespace:Is(),omit:Pi(),pick:ji(),pluck:Bi(),reduce:Gi(),reject:Ii(),result:Fi(),set:Xi(),size:Ys(),some:jr(),unset:Ki(),values:on()}),Rn}var ra;function na(){return ra||(ra=1,(function(e,t){Object.defineProperty(t,"__esModule",{value:!0});var r=Hn(),n={A:{x:.44758,y:.40745},C:{x:.31006,y:.31616},D50:{x:.34567,y:.35851},D65:{x:.31272,y:.32903},D55:{x:.33243,y:.34744},D75:{x:.29903,y:.31488}},o=(0,r.map)(n,function(a){var s=100*(a.x/a.y),c=100,i=100*(1-a.x-a.y)/a.y;return[s,c,i]});t.default=o,e.exports=t.default})($t,$t.exports)),$t.exports}var Ct={exports:{}},oa;function sa(){return oa||(oa=1,(function(e,t){Object.defineProperty(t,"__esModule",{value:!0});var r=Math,n=r.pow,o=r.sign,a=r.abs,s={decode:function(l){return l<=.04045?l/12.92:n((l+.055)/1.055,2.4)},encode:function(l){return l<=.0031308?12.92*l:1.055*n(l,1/2.4)-.055}},c={encode:function(l){return l<.001953125?16*l:n(l,1/1.8)},decode:function(l){return l<16*.001953125?l/16:n(l,1.8)}};function i(f){return{decode:function(h){return o(h)*n(a(h),f)},encode:function(h){return o(h)*n(a(h),1/f)}}}var u={sRGB:{r:{x:.64,y:.33},g:{x:.3,y:.6},b:{x:.15,y:.06},gamma:s},"Adobe RGB":{r:{x:.64,y:.33},g:{x:.21,y:.71},b:{x:.15,y:.06},gamma:i(2.2)},"Wide Gamut RGB":{r:{x:.7347,y:.2653},g:{x:.1152,y:.8264},b:{x:.1566,y:.0177},gamma:i(563/256)},"ProPhoto RGB":{r:{x:.7347,y:.2653},g:{x:.1596,y:.8404},b:{x:.0366,y:1e-4},gamma:c}};t.default=u,e.exports=t.default})(Ct,Ct.exports)),Ct.exports}var $e={},aa;function ca(){if(aa)return $e;aa=1,Object.defineProperty($e,"__esModule",{value:!0});function e(s){return[[s[0][0],s[1][0],s[2][0]],[s[0][1],s[1][1],s[2][1]],[s[0][2],s[1][2],s[2][2]]]}function t(s){return s[0][0]*(s[2][2]*s[1][1]-s[2][1]*s[1][2])+s[1][0]*(s[2][1]*s[0][2]-s[2][2]*s[0][1])+s[2][0]*(s[1][2]*s[0][1]-s[1][1]*s[0][2])}function r(s){var c=1/t(s);return[[(s[2][2]*s[1][1]-s[2][1]*s[1][2])*c,(s[2][1]*s[0][2]-s[2][2]*s[0][1])*c,(s[1][2]*s[0][1]-s[1][1]*s[0][2])*c],[(s[2][0]*s[1][2]-s[2][2]*s[1][0])*c,(s[2][2]*s[0][0]-s[2][0]*s[0][2])*c,(s[1][0]*s[0][2]-s[1][2]*s[0][0])*c],[(s[2][1]*s[1][0]-s[2][0]*s[1][1])*c,(s[2][0]*s[0][1]-s[2][1]*s[0][0])*c,(s[1][1]*s[0][0]-s[1][0]*s[0][1])*c]]}function n(s,c){return[s[0][0]*c[0]+s[0][1]*c[1]+s[0][2]*c[2],s[1][0]*c[0]+s[1][1]*c[1]+s[1][2]*c[2],s[2][0]*c[0]+s[2][1]*c[1]+s[2][2]*c[2]]}function o(s,c){return[[s[0][0]*c[0],s[0][1]*c[1],s[0][2]*c[2]],[s[1][0]*c[0],s[1][1]*c[1],s[1][2]*c[2]],[s[2][0]*c[0],s[2][1]*c[1],s[2][2]*c[2]]]}function a(s,c){return[[s[0][0]*c[0][0]+s[0][1]*c[1][0]+s[0][2]*c[2][0],s[0][0]*c[0][1]+s[0][1]*c[1][1]+s[0][2]*c[2][1],s[0][0]*c[0][2]+s[0][1]*c[1][2]+s[0][2]*c[2][2]],[s[1][0]*c[0][0]+s[1][1]*c[1][0]+s[1][2]*c[2][0],s[1][0]*c[0][1]+s[1][1]*c[1][1]+s[1][2]*c[2][1],s[1][0]*c[0][2]+s[1][1]*c[1][2]+s[1][2]*c[2][2]],[s[2][0]*c[0][0]+s[2][1]*c[1][0]+s[2][2]*c[2][0],s[2][0]*c[0][1]+s[2][1]*c[1][1]+s[2][2]*c[2][1],s[2][0]*c[0][2]+s[2][1]*c[1][2]+s[2][2]*c[2][2]]]}return $e.transpose=e,$e.determinant=t,$e.inverse=r,$e.multiply=n,$e.scalar=o,$e.product=a,$e}var lt={},ia;function Di(){if(ia)return lt;ia=1,Object.defineProperty(lt,"__esModule",{value:!0});var e=Math,t=e.PI;function r(o){for(var a=o*180/t;a<0;)a+=360;for(;a>360;)a-=360;return a}function n(o){for(var a=t*o/180;a<0;)a+=2*t;for(;a>2*t;)a-=2*t;return a}return lt.fromRadian=r,lt.toRadian=n,lt}var ht={},ua;function Vi(){if(ua)return ht;ua=1,Object.defineProperty(ht,"__esModule",{value:!0});var e=Math,t=e.round;function r(o){return o[0]=="#"&&(o=o.slice(1)),o.length<6&&(o=o.split("").map(function(a){return a+a}).join("")),o.match(/../g).map(function(a){return parseInt(a,16)/255})}function n(o){var a=o.map(function(s){return s=t(255*s).toString(16),s.length<2&&(s="0"+s),s}).join("");return"#"+a}return ht.fromHex=r,ht.toHex=n,ht}var xt={exports:{}},fa;function Yi(){return fa||(fa=1,(function(e,t){Object.defineProperty(t,"__esModule",{value:!0});var r=ca(),n=u(r),o=na(),a=i(o),s=sa(),c=i(s);function i(l){return l&&l.__esModule?l:{default:l}}function u(l){if(l&&l.__esModule)return l;var h={};if(l!=null)for(var d in l)Object.prototype.hasOwnProperty.call(l,d)&&(h[d]=l[d]);return h.default=l,h}function f(){var l=arguments.length<=0||arguments[0]===void 0?c.default.sRGB:arguments[0],h=arguments.length<=1||arguments[1]===void 0?a.default.D65:arguments[1],d=[l.r,l.g,l.b],p=n.transpose(d.map(function(x){var M=x.x/x.y,N=1,w=(1-x.x-x.y)/x.y;return[M,N,w]})),v=l.gamma,m=n.multiply(n.inverse(p),h),g=n.scalar(p,m),R=n.inverse(g);return{fromRgb:function(M){return n.multiply(g,M.map(v.decode))},toRgb:function(M){return n.multiply(R,M).map(v.encode)}}}t.default=f,e.exports=t.default})(xt,xt.exports)),xt.exports}var qn,la;function Rt(){if(la)return qn;la=1;var e=na(),t=sa(),r=ca(),n=Di(),o=Vi(),a=Yi();return qn={illuminant:e,workspace:t,matrix:r,degree:n,rgb:o,xyz:a},qn}var Zi=Rt();const Ht=pr(Zi);var de={},ha;function qt(){if(ha)return de;ha=1,Object.defineProperty(de,"__esModule",{value:!0}),de.cfs=de.distance=de.lerp=de.corLerp=void 0;var e=Hn();function t(h,d,p){return d in h?Object.defineProperty(h,d,{value:p,enumerable:!0,configurable:!0,writable:!0}):h[d]=p,h}function r(h){if(Array.isArray(h)){for(var d=0,p=Array(h.length);dm/2&&(h>d?d+=m:h+=m)}return((1-p)*h+p*d)%(m||1/0)}function u(h,d,p){var v={};for(var m in h)v[m]=i(h[m],d[m],p,m);return v}function f(h,d){var p=0;for(var v in h)p+=a(h[v]-d[v],2);return s(p)}function l(h){return e.merge.apply(void 0,r(h.split("").map(function(d){return t({},d,!0)})))}return de.corLerp=i,de.lerp=u,de.distance=f,de.cfs=l,de}var Mt={exports:{}},da;function Ji(){return da||(da=1,(function(e,t){var r=(function(){function s(c,i){var u=[],f=!0,l=!1,h=void 0;try{for(var d=c[Symbol.iterator](),p;!(f=(p=d.next()).done)&&(u.push(p.value),!(i&&u.length===i));f=!0);}catch(v){l=!0,h=v}finally{try{!f&&d.return&&d.return()}finally{if(l)throw h}}return u}return function(c,i){if(Array.isArray(c))return c;if(Symbol.iterator in Object(c))return s(c,i);throw new TypeError("Invalid attempt to destructure non-iterable instance")}})();Object.defineProperty(t,"__esModule",{value:!0});var n=Rt(),o=qt();function a(s,c){var i=arguments.length<=2||arguments[2]===void 0?1e-6:arguments[2],u=-i,f=1+i,l=Math,h=l.min,d=l.max,p=["000","fff"].map(function(w){return c.fromXyz(s.fromRgb(n.rgb.fromHex(w)))}),v=r(p,2),m=v[0],g=v[1];function R(w){var b=s.toRgb(c.toXyz(w)),_=b.map(function(k){return k>=u&&k<=f}).reduce(function(k,y){return k&&y},!0);return[_,b]}function x(w,b){for(var _=arguments.length<=2||arguments[2]===void 0?.001:arguments[2];(0,o.distance)(w,b)>_;){var k=(0,o.lerp)(w,b,.5),y=R(k),q=r(y,1),H=q[0];H?w=k:b=k}return w}function M(w){return(0,o.lerp)(m,g,w)}function N(w){return w.map(function(b){return d(u,h(f,b))})}return{contains:R,limit:x,spine:M,crop:N}}t.default=a,e.exports=t.default})(Mt,Mt.exports)),Mt.exports}var Ot={exports:{}},be={},ba;function pa(){if(ba)return be;ba=1;var e=(function(){function l(h,d){var p=[],v=!0,m=!1,g=void 0;try{for(var R=h[Symbol.iterator](),x;!(v=(x=R.next()).done)&&(p.push(x.value),!(d&&p.length===d));v=!0);}catch(M){m=!0,g=M}finally{try{!v&&R.return&&R.return()}finally{if(m)throw g}}return p}return function(h,d){if(Array.isArray(h))return h;if(Symbol.iterator in Object(h))return l(h,d);throw new TypeError("Invalid attempt to destructure non-iterable instance")}})();Object.defineProperty(be,"__esModule",{value:!0}),be.toNotation=be.fromNotation=be.toHue=be.fromHue=void 0;var t=qt(),r=Math,n=r.floor,o=[{s:"R",h:20.14,e:.8,H:0},{s:"Y",h:90,e:.7,H:100},{s:"G",h:164.25,e:1,H:200},{s:"B",h:237.53,e:1.2,H:300},{s:"R",h:380.14,e:.8,H:400}],a=o.map(function(l){return l.s}).slice(0,-1).join("");function s(l){l50){var v=[d,h];h=v[0],d=v[1],p=100-p}return p<1?a[h]:a[h]+p.toFixed()+a[d]}return be.fromHue=s,be.toHue=c,be.fromNotation=u,be.toNotation=f,be}var ma;function Wi(){return ma||(ma=1,(function(e,t){var r=(function(){function I(K,D){var U=[],ge=!0,ye=!1,st=void 0;try{for(var fe=K[Symbol.iterator](),qe;!(ge=(qe=fe.next()).done)&&(U.push(qe.value),!(D&&U.length===D));ge=!0);}catch(le){ye=!0,st=le}finally{try{!ge&&fe.return&&fe.return()}finally{if(ye)throw st}}return U}return function(K,D){if(Array.isArray(K))return K;if(Symbol.iterator in Object(K))return I(K,D);throw new TypeError("Invalid attempt to destructure non-iterable instance")}})();Object.defineProperty(t,"__esModule",{value:!0});var n=Rt(),o=pa(),a=i(o),s=qt(),c=Hn();function i(I){if(I&&I.__esModule)return I;var K={};if(I!=null)for(var D in I)Object.prototype.hasOwnProperty.call(I,D)&&(K[D]=I[D]);return K.default=I,K}var u=Math,f=u.pow,l=u.sqrt,h=u.exp,d=u.abs,p=u.sign,v=Math,m=v.sin,g=v.cos,R=v.atan2,x={average:{F:1,c:.69,N_c:1},dim:{F:.9,c:.59,N_c:.9},dark:{F:.8,c:.535,N_c:.8}},M=[[.7328,.4296,-.1624],[-.7036,1.6975,.0061],[.003,.0136,.9834]],N=[[.38971,.68898,-.07868],[-.22981,1.1834,.04641],[0,0,1]],w=M,b=n.matrix.inverse(M),_=n.matrix.product(N,n.matrix.inverse(M)),k=n.matrix.product(M,n.matrix.inverse(N)),y={whitePoint:n.illuminant.D65,adaptingLuminance:40,backgroundLuminance:20,surroundType:"average",discounting:!1},q=(0,s.cfs)("QJMCshH"),H=(0,s.cfs)("JCh");function A(){var I=arguments.length<=0||arguments[0]===void 0?{}:arguments[0],K=arguments.length<=1||arguments[1]===void 0?q:arguments[1];I=(0,c.merge)(y,I);var D=I.whitePoint,U=I.adaptingLuminance,ge=I.backgroundLuminance,ye=x[I.surroundType],st=ye.F,fe=ye.c,qe=ye.N_c,le=D[1],wc=1/(5*U+1),Ge=.2*f(wc,4)*5*U+.1*f(1-f(wc,4),2)*f(5*U,1/3),Xt=ge/le,no=.725*f(1/Xt,.2),kc=no,$c=1.48+l(Xt),Cc=I.discounting?1:st*(1-1/3.6*h(-(U+42)/92)),$l=n.matrix.multiply(M,D),Cl=$l.map(function(G){return Cc*le/G+1-Cc}),oo=r(Cl,3),xc=oo[0],Rc=oo[1],Hc=oo[2],xl=qc(D),Rl=Mc(xl),Kt=Oc(Rl);function qc(G){var z=n.matrix.multiply(w,G),F=r(z,3),ee=F[0],W=F[1],ce=F[2];return[xc*ee,Rc*W,Hc*ce]}function Hl(G){var z=r(G,3),F=z[0],ee=z[1],W=z[2];return n.matrix.multiply(b,[F/xc,ee/Rc,W/Hc])}function Mc(G){return n.matrix.multiply(_,G).map(function(z){var F=f(Ge*d(z)/100,.42);return p(z)*400*F/(27.13+F)+.1})}function ql(G){return n.matrix.multiply(k,G.map(function(z){var F=z-.1;return p(F)*100/Ge*f(27.13*d(F)/(400-d(F)),2.380952380952381)}))}function Oc(G){var z=r(G,3),F=z[0],ee=z[1],W=z[2];return(F*2+ee+W/20-.305)*no}function so(G){return 4/fe*l(G/100)*(Kt+4)*f(Ge,.25)}function Ml(G){return 6.25*f(fe*G/((Kt+4)*f(Ge,.25)),2)}function Nc(G){return G*f(Ge,.25)}function Ol(G,z){return f(G/100,2)*z/f(Ge,.25)}function Nl(G){return G/f(Ge,.25)}function Al(G,z){return 100*l(G/z)}function ao(G,z){var F=z.Q,ee=z.J,W=z.M,ce=z.C,ve=z.s,Me=z.h,Oe=z.H,te={};return G.J&&(te.J=isNaN(ee)?Ml(F):ee),G.C&&(isNaN(ce)?isNaN(W)?(F=isNaN(F)?so(ee):F,te.C=Ol(ve,F)):te.C=Nl(W):te.C=z.C),G.h&&(te.h=isNaN(Me)?a.toHue(Oe):Me),G.Q&&(te.Q=isNaN(F)?so(ee):F),G.M&&(te.M=isNaN(W)?Nc(ce):W),G.s&&(isNaN(ve)?(F=isNaN(F)?so(ee):F,W=isNaN(W)?Nc(ce):W,te.s=Al(W,F)):te.s=ve),G.H&&(te.H=isNaN(Oe)?a.fromHue(Me):Oe),te}function Ll(G){var z=qc(G),F=Mc(z),ee=r(F,3),W=ee[0],ce=ee[1],ve=ee[2],Me=W-ce*12/11+ve/11,Oe=(W+ce-2*ve)/9,te=R(Oe,Me),at=n.degree.fromRadian(te),Dt=1/4*(g(te+2)+3.8),Vt=Oc(F),bt=100*f(Vt/Kt,fe*$c),Pe=5e4/13*qe*kc*Dt*l(Me*Me+Oe*Oe)/(W+ce+21/20*ve),je=f(Pe,.9)*l(bt/100)*f(1.64-f(.29,Xt),.73);return ao(K,{J:bt,C:je,h:at})}function El(G){var z=ao(H,G),F=z.J,ee=z.C,W=z.h,ce=n.degree.toRadian(W),ve=f(ee/(l(F/100)*f(1.64-f(.29,Xt),.73)),10/9),Me=1/4*(g(ce+2)+3.8),Oe=Kt*f(F/100,1/fe/$c),te=5e4/13*qe*kc*Me/ve,at=Oe/no+.305,Dt=at*61/20*460/1403,Vt=61/20*220/1403,bt=21/20*6300/1403-27/1403,Pe=m(ce),je=g(ce),Ie,ze;ve===0||isNaN(ve)?Ie=ze=0:d(Pe)>=d(je)?(ze=Dt/(te/Pe+Vt*je/Pe+bt),Ie=ze*je/Pe):(Ie=Dt/(te/je+Vt+bt*Pe/je),ze=Ie*Pe/je);var Tl=[20/61*at+451/1403*Ie+288/1403*ze,20/61*at-891/1403*Ie-261/1403*ze,20/61*at-220/1403*Ie-6300/1403*ze],Sl=ql(Tl),Pl=Hl(Sl);return Pl}return{fromXyz:Ll,toXyz:El,fillOut:ao}}t.default=A,e.exports=t.default})(Ot,Ot.exports)),Ot.exports}var Nt={exports:{}},ga;function Ui(){return ga||(ga=1,(function(e,t){Object.defineProperty(t,"__esModule",{value:!0});var r=Rt(),n=Math,o=n.sqrt,a=n.pow,s=n.exp,c=n.log,i=n.cos,u=n.sin,f=n.atan2,l={LCD:{K_L:.77,c_1:.007,c_2:.0053},SCD:{K_L:1.24,c_1:.007,c_2:.0363},UCS:{K_L:1,c_1:.007,c_2:.0228}};function h(){var d=arguments.length<=0||arguments[0]===void 0?"UCS":arguments[0],p=l[d],v=p.K_L,m=p.c_1,g=p.c_2;function R(N){var w=N.J,b=N.M,_=N.h,k=r.degree.toRadian(_),y=(1+100*m)*w/(1+m*w),q=1/g*c(1+g*b),H=q*i(k),A=q*u(k);return{J_p:y,a_p:H,b_p:A}}function x(N){var w=N.J_p,b=N.a_p,_=N.b_p,k=-w/(m*w-100*m-1),y=o(a(b,2)+a(_,2)),q=(s(g*y)-1)/g,H=f(_,b),A=r.degree.fromRadian(H);return{J:k,M:q,h:A}}function M(N,w){return o(a((N.J_p-w.J_p)/v,2)+a(N.a_p-w.a_p,2)+a(N.b_p-w.b_p,2))}return{fromCam:R,toCam:x,distance:M}}t.default=h,e.exports=t.default})(Nt,Nt.exports)),Nt.exports}var Mn,va;function Qi(){if(va)return Mn;va=1;var e=qt(),t=Ji(),r=Wi(),n=Ui(),o=pa();return Mn={gamut:t,cfs:e.cfs,lerp:e.lerp,cam:r,ucs:n,hq:o},Mn}var eu=Qi();const _a=pr(eu),ya=_a.cam({whitePoint:Ht.illuminant.D65,adaptingLuminance:40,backgroundLuminance:20,surroundType:"average",discounting:!1},_a.cfs("JCh")),wa=Ht.xyz(Ht.workspace.sRGB,Ht.illuminant.D65),ka=e=>wa.toRgb(ya.toXyz({J:e[0],C:e[1],h:e[2]})),On=e=>{const t=ya.fromXyz(wa.fromRgb(e));return[t.J,t.C,t.h]},[tu,ru]=(()=>{const e={k_l:1,c1:.007,c2:.0228},t=Math.PI,r=64/t/5,n=1/(5*r+1),o=.2*n**4*(5*r)+.1*(1-n**4)**2*(5*r)**(1/3);return[a=>{const[s,c,i]=a,u=c*o**.25;let f=(1+100*e.c1)*s/(1+e.c1*s);f/=e.k_l;const l=1/e.c2*Math.log(1+e.c2*u),h=l*Math.cos(i*(t/180)),d=l*Math.sin(i*(t/180));return[f,h,d]},a=>{const[s,c,i]=a,u=Math.sqrt(c*c+i*i),f=(Math.exp(u*e.c2)-1)/e.c2,l=(180/t*Math.atan2(i,c)+360)%360,h=f/o**.25;return[s/(1+e.c1*(100-s)),h,l]}]})(),nu=e=>ka(ru(e)),$a=e=>tu(On(e)),At=console;At.color=(e,t="")=>{const n=O(e).luminance();At.log(`%c${e} ${t}`,`background-color: ${e};padding: 5px; border-radius: 5px; color: ${n>.5?"#000":"#fff"}`)},At.ramp=(e,t=1)=>{At.log("%c ",`font-size: 1px;line-height: 16px;background: ${O.getCSSGradient(e,t)};padding: 0 0 0 200px; border-radius: 2px;`)};const Ca=(e,t,r,n,o,a,s=.1)=>{if(e===r||t===n)return!0;const c=(n-t)/(r-e),i=(a+o/c-t+c*e)/(c+1/c),u=a+o/c-i/c;return(o-i)**2+(a-u)**2{const o=(t[0]+r[0])/2,a=e(o);return Ca(...t,...r,o,a,n)?null:[o,a]},Nn=(e,t,r,n=.1)=>{const o=(r-t)/10,a=[];for(let s=t;sMath.round(e*10**t)/10**t,su=(e,t=1,r=90,n=.005)=>{const o=Nn(i=>e(i).gl()[0],0,t,n),a=Nn(i=>e(i).gl()[1],0,t,n),s=Nn(i=>e(i).gl()[2],0,t,n),c=Array.from(new Set([...o.map(i=>Lt(i[0])),...a.map(i=>Lt(i[0])),...s.map(i=>Lt(i[0]))].sort((i,u)=>i-u)));return`linear-gradient(${r}deg, ${c.map(i=>`${e(i).hex()} ${Lt(i*100)}%`).join()});`},au=e=>{e.Color.prototype.jch=function(){return On(this._rgb.slice(0,3).map(o=>o/255))},e.jch=(...o)=>new e.Color(...ka(o).map(a=>Math.floor(a*255)),"rgb"),e.Color.prototype.jab=function(){return $a(this._rgb.slice(0,3).map(o=>o/255))},e.jab=(...o)=>new e.Color(...nu(o).map(a=>Math.floor(a*255)),"rgb"),e.Color.prototype.hsluv=function(){return gr.rgbToHsluv(this._rgb.slice(0,3).map(o=>o/255))},e.hsluv=(...o)=>new e.Color(...gr.hsluvToRgb(o).map(a=>Math.floor(a*255)),"rgb");const t=e.interpolate,r={jch:On,jab:$a,hsluv:gr.rgbToHsluv},n=(o,a,s)=>(Math.abs(o-a)>360/2&&(o>a?a+=360:o+=360),((1-s)*o+s*a)%360);e.interpolate=(o,a,s=.5,c="lrgb")=>{if(r[c]){typeof o!="object"&&(o=new e.Color(o)),typeof a!="object"&&(a=new e.Color(a));const i=r[c](o.gl()),u=r[c](a.gl()),f=Number.isNaN(o.hsl()[0]),l=Number.isNaN(a.hsl()[0]);let h,d,p;switch(c){case"hsluv":i[1]<1e-10&&(i[0]=u[0]),i[1]===0&&(i[1]=u[1]),u[1]<1e-10&&(u[0]=i[0]),u[1]===0&&(u[1]=i[1]),h=n(i[0],u[0],s),d=i[1]+(u[1]-i[1])*s,p=i[2]+(u[2]-i[2])*s;break;case"jch":f&&(i[2]=u[2]),l&&(u[2]=i[2]),h=i[0]+(u[0]-i[0])*s,d=i[1]+(u[1]-i[1])*s,p=n(i[2],u[2],s);break;default:h=i[0]+(u[0]-i[0])*s,d=i[1]+(u[1]-i[1])*s,p=i[2]+(u[2]-i[2])*s}return e[c](h,d,p).alpha(o.alpha()+s*(a.alpha()-o.alpha()))}return t(o,a,s,c)},e.getCSSGradient=su};/** @preserve +///// SAPC APCA - Advanced Perceptual Contrast Algorithm +///// Beta 0.1.9 W3 • contrast function only +///// DIST: W3 • Revision date: July 3, 2022 +///// Function to parse color values and determine Lc contrast +///// Copyright © 2019-2022 by Andrew Somers. All Rights Reserved. +///// LICENSE: W3 LICENSE +///// CONTACT: Please use the ISSUES or DISCUSSIONS tab at: +///// https://github.com/Myndex/SAPC-APCA/ +///// +/////////////////////////////////////////////////////////////////////////////// +///// +///// MINIMAL IMPORTS: +///// import { APCAcontrast, sRGBtoY, displayP3toY, +///// calcAPCA, fontLookupAPCA } from 'apca-w3'; +///// import { colorParsley } from 'colorparsley'; +///// +///// FORWARD CONTRAST USAGE: +///// Lc = APCAcontrast( sRGBtoY( TEXTcolor ) , sRGBtoY( BACKGNDcolor ) ); +///// Where the colors are sent as an rgba array [255,255,255,1] +///// +///// Retrieving an array of font sizes for the contrast: +///// fontArray = fontLookupAPCA(Lc); +///// +///// Live Demonstrator at https://www.myndex.com/APCA/ +// */const Y={mainTRC:2.4,sRco:.2126729,sGco:.7151522,sBco:.072175,normBG:.56,normTXT:.57,revTXT:.62,revBG:.65,blkThrs:.022,blkClmp:1.414,scaleBoW:1.14,scaleWoB:1.14,loBoWoffset:.027,loWoBoffset:.027,deltaYmin:5e-4,loClip:.1};function xa(e,t,r=-1){const n=[0,1.1];if(isNaN(e)||isNaN(t)||Math.min(e,t)n[1])return 0;let o=0,a=0,s="BoW";return e=e>Y.blkThrs?e:e+Math.pow(Y.blkThrs-e,Y.blkClmp),t=t>Y.blkThrs?t:t+Math.pow(Y.blkThrs-t,Y.blkClmp),Math.abs(t-e)e?(o=(Math.pow(t,Y.normBG)-Math.pow(e,Y.normTXT))*Y.scaleBoW,a=o-.1?0:o+Y.loWoBoffset),r<0?a*100:r==0?Math.round(Math.abs(a)*100)+""+s+"":Number.isInteger(r)?(a*100).toFixed(r):0)}function Et(e=[0,0,0]){function t(r){return Math.pow(r/255,Y.mainTRC)}return Y.sRco*t(e[0])+Y.sGco*t(e[1])+Y.sBco*t(e[2])}const Ra=(e,t,r,n,o,a,s,c,i)=>{const u=1-i,f=u*u,l=f*u,d=i*i*i,p=l*e+f*3*i*r+u*3*i*i*o+d*s,v=l*t+f*3*i*n+u*3*i*i*a+d*c;return{x:p,y:v}},cu=(e,t)=>{const r=[];let n={x:+e[0],y:+e[1]};for(let o=0,a=e.length;a-2*!0>o;o+=2){const s=[{x:+e[o-2],y:+e[o-1]},{x:+e[o],y:+e[o+1]},{x:+e[o+2],y:+e[o+3]},{x:+e[o+4],y:+e[o+5]}];a-4===o?s[3]=s[2]:o||(s[0]={x:+e[o],y:+e[o+1]}),r.push([n.x,n.y,(-s[0].x+6*s[1].x+s[2].x)/6,(-s[0].y+6*s[1].y+s[2].y)/6,(s[1].x+6*s[2].x-s[3].x)/6,(s[1].y+6*s[2].y-s[3].y)/6,s[2].x,s[2].y]),n=s[2]}return r},iu=(e,t,r,n,o,a,s,c)=>{let u=e,f=t,l=0;for(let h=1;h<5;h++){const{x:d,y:p}=Ra(e,t,r,n,o,a,s,c,h/5);l+=Math.hypot(d-u,p-f),u=d,f=p}return l+=Math.hypot(s-u,c-f),l},uu=(e,t,r,n,o,a,s,c)=>{const i=Math.floor(iu(e,t,r,n,o,a,s,c)*.75),u=[];let f=0;for(let l=0;l<=i;l++){const h=l/i,d=Ra(e,t,r,n,o,a,s,c,h),p=Math.round(d.x);if(u[p]=d.y,p-f>1){const v=u[f],m=u[p];for(let g=f+1;gu[Math.round(l)]||null},Ze={CAM02:"jab",CAM02p:"jch",HEX:"hex",HSL:"hsl",HSLuv:"hsluv",HSV:"hsv",LAB:"lab",LCH:"lch",RGB:"rgb",OKLAB:"oklab",OKLCH:"oklch"};function Ee(e,t=0){const r=10**t;return Math.round(e*r)/r}function fu(e,t){let r;return e>1?r=(e-1)*t+1:e<-1?r=(e+1)*t-1:r=1,Ee(r,2)}function lu(e){return O(String(e)).jch()}function hu(e){return O(String(e)).hsluv()}function du(e,t,r){const n=[[],[],[]];if(e.forEach((a,s)=>n.forEach((c,i)=>c.push(t[s],a[i]))),r==="hcl"){const a=n[1];for(let s=1;s{const s=[];for(let c=1;c{a[i]=a[c]}),s.length=0;break}if(s.length){const c=O("#ccc").jch()[2];s.forEach(i=>{a[i]=c})}s.length=0;for(let c=a.length-1;c>0;c-=2)if(Number.isNaN(a[c]))s.push(c);else{s.forEach(i=>{a[i]=a[c]});break}for(let c=1;ccu(a).map(s=>uu(...s)));return a=>{const s=o.map(c=>{for(let i=0;in*a**e+o}function An({swatches:e,colorKeys:t,colorspace:r="LAB",shift:n=1,fullScale:o=!0,smooth:a=!1,distributeLightness:s="linear",sortColor:c=!0,asFun:i=!1}={}){const u=Ze[r];if(!u)throw new Error(`Colorspace “${r}” not supported`);if(!t)throw new Error(`Colorkeys missing: returned “${t}”`);let f;if(o)f=t.map(R=>e-e*(O(R).jch()[0]/100)).sort((R,x)=>R-x).concat(e),f.unshift(0);else{let R=t.map(N=>O(N).jch()[0]/100),x=Math.min(...R),M=Math.max(...R);f=R.map(N=>N===0||isNaN((N-x)/(M-x))?0:e-(N-x)/(M-x)*e).sort((N,w)=>N-w)}let l=bu(n,[1,e],[1,e]);if(l=f.map(R=>Math.max(0,l(R))),f=l,s==="polynomial"){const R=N=>Math.sqrt(Math.sqrt((Math.pow(N,2.25)+Math.pow(N,4))/2));f=l.map(N=>N/e).map(N=>R(N)*e)}const h=t.map((R,x)=>({colorKeys:lu(R),index:x})).sort((R,x)=>x.colorKeys[0]-R.colorKeys[0]).map(R=>t[R.index]);let d=[],p;if(o){const R=u==="lch"?O.lch(...O("#fff").lch()):"#ffffff",x=u==="lch"?O.lch(...O("#000").lch()):"#000000";d=[R,...h,x]}else c?d=h:d=t;let v;if(a){const R=d;if(d=d.map(x=>O(String(x))[u]()),u==="hcl"&&d.forEach(x=>{x[1]=Number.isNaN(x[1])?0:x[1]}),u==="jch")for(let x=0;xp(M))}else p=O.scale(d.map(R=>typeof R=="object"&&R.constructor===O.Color?R:String(R))).domain(f).mode(u);return i?p:(!a||a===!1?p.colors(e):v).filter(R=>R!=null)}function pu(e,t){const r=[],n={};return Object.keys(e).forEach(s=>{n[e[s][t]]=e[s]}),Object.keys(n).forEach(s=>r.push(n[s])),r}function mu(e){return Number.isNaN(e)?0:e}function Ln(e,t,r=!1){if(!e)throw new Error(`Cannot convert color value of “${e}”`);if(!Ze[t])throw new Error(`Cannot convert to colorspace “${t}”`);const n=Ze[t],o=O(String(e))[n]();if(t==="HSL"&&o.pop(),t==="HEX"){if(r){const u=O(String(e)).rgb();return{r:u[0],g:u[1],b:u[2]}}return o}const a={};let s=o.map(mu);s=s.map((u,f)=>{let l=Ee(u),h=f;n==="hsluv"&&(h+=2);let d=n.charAt(h);return n==="jch"&&d==="c"&&(d="C"),a[d==="j"?"J":d]=l,n in{lab:1,lch:1,jab:1,jch:1}?r||((d==="l"||d==="j")&&(l+="%"),d==="h"&&(l+="deg")):n!=="hsluv"&&(d==="s"||d==="l"||d==="v"?(a[d]=Ee(u,2),r||(l=Ee(u*100),l+="%")):d==="h"&&!r&&(l+="deg")),l});const i=`${n}(${s.join(", ")})`;return r?a:i}function Ha(e,t,r){const n=[e,t,r].map(o=>(o/=255,o<=.03928?o/12.92:((o+.055)/1.055)**2.4));return n[0]*.2126+n[1]*.7152+n[2]*.0722}function gu(e,t,r,n="wcag2"){if(r===void 0){const o=O.rgb(...t).hsluv()[2];r=Ee(o/100,2)}if(n==="wcag2"){const o=Ha(e[0],e[1],e[2]),a=Ha(t[0],t[1],t[2]),s=(o+.05)/(a+.05),c=(a+.05)/(o+.05);return r<.5?s>=1?s:-c:s<1?c:s===1?s:-s}else{if(n==="wcag3")return r<.5?xa(Et(e),Et(t))*-1:xa(Et(e),Et(t));throw new Error(`Contrast calculation method ${n} unsupported; use 'wcag2' or 'wcag3'`)}}function vu(e,t){if(!e)throw new Error("Array undefined");if(!Array.isArray(e))throw new Error("Passed object is not an array");const r=t==="wcag2"?0:1;return Math.min(...e.filter(n=>n>=r))}function _u(e,t){if(!e)throw new Error("Ratios undefined");e=e.sort((c,i)=>c-i);const r=vu(e,t),n=e.indexOf(r),o=[],a=e.slice(0,n),s=e.slice(n,e.length);for(let c=0;cc-i),o}const yu=(e,t,r,n,o)=>{const s=An({swatches:3e3,colorKeys:e._modifiedKeys,colorspace:e._colorspace,shift:1,smooth:e._smooth,asFun:!0}),c={},i=l=>{if(c[l])return c[l];const h=O(s(l)).rgb(),d=gu(h,t,r,o);return c[l]=d,d},u=l=>{const h=i(0),d=i(3e3),p=hv&&x;)x--,m/=2,Rf.push(s(u(+l)))),f};let ae=class{constructor({name:t,colorKeys:r,colorspace:n="RGB",ratios:o,smooth:a=!1,output:s="HEX",saturation:c=100}){if(this._name=t,this._colorKeys=r,this._modifiedKeys=r,this._colorspace=n,this._ratios=o,this._smooth=a,this._output=s,this._saturation=c,!this._name)throw new Error("Color missing name");if(!this._colorKeys)throw new Error("Color Keys are undefined");if(!Ze[this._colorspace])throw new Error(`Colorspace “${n}” not supported`);if(!Ze[this._output])throw new Error(`Output “${n}” not supported`);for(let i=0;i{let n=O(`${r}`).oklch(),a=n[1]*(this._saturation/100),s=O.oklch(n[0],a,n[2]),c=O.rgb(s).hex();t.push(c)}),this._modifiedKeys=t,this._generateColorScale()}_generateColorScale(){this._colorScale=An({swatches:3e3,colorKeys:this._modifiedKeys,colorspace:this._colorspace,shift:1,smooth:this._smooth,asFun:!0})}};class qa extends ae{get backgroundColorScale(){return this._backgroundColorScale||this._generateColorScale(),this._backgroundColorScale}_generateColorScale(){ae.prototype._generateColorScale.call(this);const t=An({swatches:1e3,colorKeys:this._colorKeys,colorspace:this._colorspace,shift:1,smooth:this._smooth});t.push(...this.colorKeys);const r=t.map((a,s)=>({value:Math.round(hu(a)[2]),index:s})),o=pu(r,"value").map(a=>t[a.index]);return o.length>=101&&(o.length=100,o.push("#ffffff")),this._backgroundColorScale=o.map(a=>Ln(a,this._output)),this._backgroundColorScale}}class wu{constructor({colors:t,backgroundColor:r,lightness:n,contrast:o=1,saturation:a=100,output:s="HEX",formula:c="wcag2"}){if(this._output=s,this._colors=t,this._lightness=n,this._saturation=a,this._formula=c,this._setBackgroundColor(r),this._setBackgroundColorValue(),this._contrast=o,!this._colors)throw new Error("No colors are defined");if(!this._backgroundColor)throw new Error("Background color is undefined");if(t.forEach(i=>{if(!i.ratios)throw new Error(`Color ${i.name}'s ratios are undefined`)}),!Ze[this._output])throw new Error(`Output “${s}” not supported`);this._saturation<100&&this._updateColorSaturation(this._saturation),this._findContrastColors(),this._findContrastColorPairs(),this._findContrastColorValues()}set formula(t){this._formula=t,this._findContrastColors()}get formula(){return this._formula}set contrast(t){this._contrast=t,this._findContrastColors()}get contrast(){return this._contrast}set lightness(t){this._lightness=t,this._setBackgroundColor(this._backgroundColor),this._findContrastColors()}get lightness(){return this._lightness}set saturation(t){this._saturation=t,this._updateColorSaturation(t),this._findContrastColors()}get saturation(){return this._saturation}set backgroundColor(t){this._setBackgroundColor(t),this._findContrastColors()}get backgroundColorValue(){return this._backgroundColorValue}get backgroundColor(){return this._backgroundColor}set colors(t){this._colors=t,this._findContrastColors()}get colors(){return this._colors}set addColor(t){this._colors.push(t),this._findContrastColors()}set removeColor(t){const r=this._colors.filter(n=>n.name!==t.name);this._colors=r,this._findContrastColors()}set updateColor(t){if(Array.isArray(t))for(let r=0;rs.name===t[r].color);n=n[0];let o=this._colors.indexOf(n);const a=this._colors.filter(s=>s.name!==t[r].color);t[r].name&&(n.name=t[r].name),t[r].colorKeys&&(n.colorKeys=t[r].colorKeys),t[r].ratios&&(n.ratios=t[r].ratios),t[r].colorspace&&(n.colorspace=t[r].colorspace),t[r].smooth&&(n.smooth=t[r].smooth),n._generateColorScale(),a.splice(o,0,n),this._colors=a}else{let r=this._colors.filter(a=>a.name===t.color);r=r[0];let n=this._colors.indexOf(r);const o=this._colors.filter(a=>a.name!==t.color);t.name&&(r.name=t.name),t.colorKeys&&(r.colorKeys=t.colorKeys),t.ratios&&(r.ratios=t.ratios),t.colorspace&&(r.colorspace=t.colorspace),t.smooth&&(r.smooth=t.smooth),r._generateColorScale(),o.splice(n,0,r),this._colors=o}this._findContrastColors()}set output(t){this._output=t,this._colors.forEach(r=>{r.output=this._output}),this._backgroundColor.output=this._output,this._findContrastColors()}get output(){return this._output}get contrastColors(){return this._contrastColors}get contrastColorPairs(){return this._contrastColorPairs}get contrastColorValues(){return this._contrastColorValues}_setBackgroundColor(t){if(typeof t=="string"){const r=new qa({name:"background",colorKeys:[t],output:"RGB"}),n=Ee(O(String(t)).hsluv()[2]);this._backgroundColor=r,this._lightness=n,this._backgroundColorValue=r[this._lightness]}else{t.output="RGB";const r=t.backgroundColorScale[this._lightness];this._backgroundColor=t,this._backgroundColorValue=r}}_setBackgroundColorValue(){this._backgroundColorValue=this._backgroundColor.backgroundColorScale[this._lightness]}_updateColorSaturation(t){this._colors.map(r=>{r.saturation=t})}_findContrastColors(){const t=O(String(this._backgroundColorValue)).rgb(),r=this._lightness/100,o={background:Ln(this._backgroundColorValue,this._output)},a=[],s=[],c={...o};return a.push(o),this._colors.map(i=>{if(i.ratios!==void 0){let u;const f=[],l={name:i.name,values:f};let h;Array.isArray(i.ratios)?h=i.ratios:Array.isArray(i.ratios)||(u=Object.keys(i.ratios),h=Object.values(i.ratios)),h=h.map(p=>fu(+p,this._contrast));const d=yu(i,t,r,h,this._formula).map(p=>Ln(p,this._output));for(let p=0;pku($u(t,e),r),En=e=>{e._clipped=!1,e._unclipped=e.slice(0);for(let t=0;t<=3;t++)t<3?((e[t]<0||e[t]>255)&&(e._clipped=!0),e[t]=Be(e[t],0,255)):t===3&&(e[t]=Be(e[t],0,1));return e},Ma={};for(let e of["Boolean","Number","String","Function","Array","Date","RegExp","Undefined","Null"])Ma[`[object ${e}]`]=e.toLowerCase();function j(e){return Ma[Object.prototype.toString.call(e)]||"object"}const T=(e,t=null)=>e.length>=3?Array.prototype.slice.call(e):j(e[0])=="object"&&t?t.split("").filter(r=>e[0][r]!==void 0).map(r=>e[0][r]):e[0].slice(0),Je=e=>{if(e.length<2)return null;const t=e.length-1;return j(e[t])=="string"?e[t].toLowerCase():null},{PI:Tt,min:Oa,max:Na}=Math,ie=e=>Math.round(e*100)/100,Tn=e=>Math.round(e*100)/100,Ce=Tt*2,Sn=Tt/3,Cu=Tt/180,xu=180/Tt;function Aa(e){return[...e.slice(0,3).reverse(),...e.slice(3)]}const E={format:{},autodetect:[]};class ${constructor(...t){const r=this;if(j(t[0])==="object"&&t[0].constructor&&t[0].constructor===this.constructor)return t[0];let n=Je(t),o=!1;if(!n){o=!0,E.sorted||(E.autodetect=E.autodetect.sort((a,s)=>s.p-a.p),E.sorted=!0);for(let a of E.autodetect)if(n=a.test(...t),n)break}if(E.format[n]){const a=E.format[n].apply(null,o?t:t.slice(0,-1));r._rgb=En(a)}else throw new Error("unknown format: "+t);r._rgb.length===3&&r._rgb.push(1)}toString(){return j(this.hex)=="function"?this.hex():`[${this._rgb.join(",")}]`}}const Ru="3.1.2",B=(...e)=>new $(...e);B.version=Ru;const We={aliceblue:"#f0f8ff",antiquewhite:"#faebd7",aqua:"#00ffff",aquamarine:"#7fffd4",azure:"#f0ffff",beige:"#f5f5dc",bisque:"#ffe4c4",black:"#000000",blanchedalmond:"#ffebcd",blue:"#0000ff",blueviolet:"#8a2be2",brown:"#a52a2a",burlywood:"#deb887",cadetblue:"#5f9ea0",chartreuse:"#7fff00",chocolate:"#d2691e",coral:"#ff7f50",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cyan:"#00ffff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",darkgreen:"#006400",darkgrey:"#a9a9a9",darkkhaki:"#bdb76b",darkmagenta:"#8b008b",darkolivegreen:"#556b2f",darkorange:"#ff8c00",darkorchid:"#9932cc",darkred:"#8b0000",darksalmon:"#e9967a",darkseagreen:"#8fbc8f",darkslateblue:"#483d8b",darkslategray:"#2f4f4f",darkslategrey:"#2f4f4f",darkturquoise:"#00ced1",darkviolet:"#9400d3",deeppink:"#ff1493",deepskyblue:"#00bfff",dimgray:"#696969",dimgrey:"#696969",dodgerblue:"#1e90ff",firebrick:"#b22222",floralwhite:"#fffaf0",forestgreen:"#228b22",fuchsia:"#ff00ff",gainsboro:"#dcdcdc",ghostwhite:"#f8f8ff",gold:"#ffd700",goldenrod:"#daa520",gray:"#808080",green:"#008000",greenyellow:"#adff2f",grey:"#808080",honeydew:"#f0fff0",hotpink:"#ff69b4",indianred:"#cd5c5c",indigo:"#4b0082",ivory:"#fffff0",khaki:"#f0e68c",laserlemon:"#ffff54",lavender:"#e6e6fa",lavenderblush:"#fff0f5",lawngreen:"#7cfc00",lemonchiffon:"#fffacd",lightblue:"#add8e6",lightcoral:"#f08080",lightcyan:"#e0ffff",lightgoldenrod:"#fafad2",lightgoldenrodyellow:"#fafad2",lightgray:"#d3d3d3",lightgreen:"#90ee90",lightgrey:"#d3d3d3",lightpink:"#ffb6c1",lightsalmon:"#ffa07a",lightseagreen:"#20b2aa",lightskyblue:"#87cefa",lightslategray:"#778899",lightslategrey:"#778899",lightsteelblue:"#b0c4de",lightyellow:"#ffffe0",lime:"#00ff00",limegreen:"#32cd32",linen:"#faf0e6",magenta:"#ff00ff",maroon:"#800000",maroon2:"#7f0000",maroon3:"#b03060",mediumaquamarine:"#66cdaa",mediumblue:"#0000cd",mediumorchid:"#ba55d3",mediumpurple:"#9370db",mediumseagreen:"#3cb371",mediumslateblue:"#7b68ee",mediumspringgreen:"#00fa9a",mediumturquoise:"#48d1cc",mediumvioletred:"#c71585",midnightblue:"#191970",mintcream:"#f5fffa",mistyrose:"#ffe4e1",moccasin:"#ffe4b5",navajowhite:"#ffdead",navy:"#000080",oldlace:"#fdf5e6",olive:"#808000",olivedrab:"#6b8e23",orange:"#ffa500",orangered:"#ff4500",orchid:"#da70d6",palegoldenrod:"#eee8aa",palegreen:"#98fb98",paleturquoise:"#afeeee",palevioletred:"#db7093",papayawhip:"#ffefd5",peachpuff:"#ffdab9",peru:"#cd853f",pink:"#ffc0cb",plum:"#dda0dd",powderblue:"#b0e0e6",purple:"#800080",purple2:"#7f007f",purple3:"#a020f0",rebeccapurple:"#663399",red:"#ff0000",rosybrown:"#bc8f8f",royalblue:"#4169e1",saddlebrown:"#8b4513",salmon:"#fa8072",sandybrown:"#f4a460",seagreen:"#2e8b57",seashell:"#fff5ee",sienna:"#a0522d",silver:"#c0c0c0",skyblue:"#87ceeb",slateblue:"#6a5acd",slategray:"#708090",slategrey:"#708090",snow:"#fffafa",springgreen:"#00ff7f",steelblue:"#4682b4",tan:"#d2b48c",teal:"#008080",thistle:"#d8bfd8",tomato:"#ff6347",turquoise:"#40e0d0",violet:"#ee82ee",wheat:"#f5deb3",white:"#ffffff",whitesmoke:"#f5f5f5",yellow:"#ffff00",yellowgreen:"#9acd32"},Hu=/^#?([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,qu=/^#?([A-Fa-f0-9]{8}|[A-Fa-f0-9]{4})$/,La=e=>{if(e.match(Hu)){(e.length===4||e.length===7)&&(e=e.substr(1)),e.length===3&&(e=e.split(""),e=e[0]+e[0]+e[1]+e[1]+e[2]+e[2]);const t=parseInt(e,16),r=t>>16,n=t>>8&255,o=t&255;return[r,n,o,1]}if(e.match(qu)){(e.length===5||e.length===9)&&(e=e.substr(1)),e.length===4&&(e=e.split(""),e=e[0]+e[0]+e[1]+e[1]+e[2]+e[2]+e[3]+e[3]);const t=parseInt(e,16),r=t>>24&255,n=t>>16&255,o=t>>8&255,a=Math.round((t&255)/255*100)/100;return[r,n,o,a]}throw new Error(`unknown hex color: ${e}`)},{round:St}=Math,Ea=(...e)=>{let[t,r,n,o]=T(e,"rgba"),a=Je(e)||"auto";o===void 0&&(o=1),a==="auto"&&(a=o<1?"rgba":"rgb"),t=St(t),r=St(r),n=St(n);let c="000000"+(t<<16|r<<8|n).toString(16);c=c.substr(c.length-6);let i="0"+St(o*255).toString(16);switch(i=i.substr(i.length-2),a.toLowerCase()){case"rgba":return`#${c}${i}`;case"argb":return`#${i}${c}`;default:return`#${c}`}};$.prototype.name=function(){const e=Ea(this._rgb,"rgb");for(let t of Object.keys(We))if(We[t]===e)return t.toLowerCase();return e},E.format.named=e=>{if(e=e.toLowerCase(),We[e])return La(We[e]);throw new Error("unknown color name: "+e)},E.autodetect.push({p:5,test:(e,...t)=>{if(!t.length&&j(e)==="string"&&We[e.toLowerCase()])return"named"}}),$.prototype.alpha=function(e,t=!1){return e!==void 0&&j(e)==="number"?t?(this._rgb[3]=e,this):new $([this._rgb[0],this._rgb[1],this._rgb[2],e],"rgb"):this._rgb[3]},$.prototype.clipped=function(){return this._rgb._clipped||!1};const _e={Kn:18,labWhitePoint:"d65",Xn:.95047,Yn:1,Zn:1.08883,kE:216/24389,kKE:8,kK:24389/27,RefWhiteRGB:{X:.95047,Y:1,Z:1.08883},MtxRGB2XYZ:{m00:.4124564390896922,m01:.21267285140562253,m02:.0193338955823293,m10:.357576077643909,m11:.715152155287818,m12:.11919202588130297,m20:.18043748326639894,m21:.07217499330655958,m22:.9503040785363679},MtxXYZ2RGB:{m00:3.2404541621141045,m01:-.9692660305051868,m02:.055643430959114726,m10:-1.5371385127977166,m11:1.8760108454466942,m12:-.2040259135167538,m20:-.498531409556016,m21:.041556017530349834,m22:1.0572251882231791},As:.9414285350000001,Bs:1.040417467,Cs:1.089532651,MtxAdaptMa:{m00:.8951,m01:-.7502,m02:.0389,m10:.2664,m11:1.7135,m12:-.0685,m20:-.1614,m21:.0367,m22:1.0296},MtxAdaptMaI:{m00:.9869929054667123,m01:.43230526972339456,m02:-.008528664575177328,m10:-.14705425642099013,m11:.5183602715367776,m12:.04004282165408487,m20:.15996265166373125,m21:.0492912282128556,m22:.9684866957875502}},Mu=new Map([["a",[1.0985,.35585]],["b",[1.0985,.35585]],["c",[.98074,1.18232]],["d50",[.96422,.82521]],["d55",[.95682,.92149]],["d65",[.95047,1.08883]],["e",[1,1,1]],["f2",[.99186,.67393]],["f7",[.95041,1.08747]],["f11",[1.00962,.6435]],["icc",[.96422,.82521]]]);function xe(e){const t=Mu.get(String(e).toLowerCase());if(!t)throw new Error("unknown Lab illuminant "+e);_e.labWhitePoint=e,_e.Xn=t[0],_e.Zn=t[1]}function dt(){return _e.labWhitePoint}const Pn=(...e)=>{e=T(e,"lab");const[t,r,n]=e,[o,a,s]=Ou(t,r,n),[c,i,u]=Ta(o,a,s);return[c,i,u,e.length>3?e[3]:1]},Ou=(e,t,r)=>{const{kE:n,kK:o,kKE:a,Xn:s,Yn:c,Zn:i}=_e,u=(e+16)/116,f=.002*t+u,l=u-.005*r,h=f*f*f,d=l*l*l,p=h>n?h:(116*f-16)/o,v=e>a?Math.pow((e+16)/116,3):e/o,m=d>n?d:(116*l-16)/o,g=p*s,R=v*c,x=m*i;return[g,R,x]},jn=e=>{const t=Math.sign(e);return e=Math.abs(e),(e<=.0031308?e*12.92:1.055*Math.pow(e,1/2.4)-.055)*t},Ta=(e,t,r)=>{const{MtxAdaptMa:n,MtxAdaptMaI:o,MtxXYZ2RGB:a,RefWhiteRGB:s,Xn:c,Yn:i,Zn:u}=_e,f=c*n.m00+i*n.m10+u*n.m20,l=c*n.m01+i*n.m11+u*n.m21,h=c*n.m02+i*n.m12+u*n.m22,d=s.X*n.m00+s.Y*n.m10+s.Z*n.m20,p=s.X*n.m01+s.Y*n.m11+s.Z*n.m21,v=s.X*n.m02+s.Y*n.m12+s.Z*n.m22,m=(e*n.m00+t*n.m10+r*n.m20)*(d/f),g=(e*n.m01+t*n.m11+r*n.m21)*(p/l),R=(e*n.m02+t*n.m12+r*n.m22)*(v/h),x=m*o.m00+g*o.m10+R*o.m20,M=m*o.m01+g*o.m11+R*o.m21,N=m*o.m02+g*o.m12+R*o.m22,w=jn(x*a.m00+M*a.m10+N*a.m20),b=jn(x*a.m01+M*a.m11+N*a.m21),_=jn(x*a.m02+M*a.m12+N*a.m22);return[w*255,b*255,_*255]},Bn=(...e)=>{const[t,r,n,...o]=T(e,"rgb"),[a,s,c]=Sa(t,r,n),[i,u,f]=Nu(a,s,c);return[i,u,f,...o.length>0&&o[0]<1?[o[0]]:[]]};function Nu(e,t,r){const{Xn:n,Yn:o,Zn:a,kE:s,kK:c}=_e,i=e/n,u=t/o,f=r/a,l=i>s?Math.pow(i,1/3):(c*i+16)/116,h=u>s?Math.pow(u,1/3):(c*u+16)/116,d=f>s?Math.pow(f,1/3):(c*f+16)/116;return[116*h-16,500*(l-h),200*(h-d)]}function Gn(e){const t=Math.sign(e);return e=Math.abs(e),(e<=.04045?e/12.92:Math.pow((e+.055)/1.055,2.4))*t}const Sa=(e,t,r)=>{e=Gn(e/255),t=Gn(t/255),r=Gn(r/255);const{MtxRGB2XYZ:n,MtxAdaptMa:o,MtxAdaptMaI:a,Xn:s,Yn:c,Zn:i,As:u,Bs:f,Cs:l}=_e;let h=e*n.m00+t*n.m10+r*n.m20,d=e*n.m01+t*n.m11+r*n.m21,p=e*n.m02+t*n.m12+r*n.m22;const v=s*o.m00+c*o.m10+i*o.m20,m=s*o.m01+c*o.m11+i*o.m21,g=s*o.m02+c*o.m12+i*o.m22;let R=h*o.m00+d*o.m10+p*o.m20,x=h*o.m01+d*o.m11+p*o.m21,M=h*o.m02+d*o.m12+p*o.m22;return R*=v/u,x*=m/f,M*=g/l,h=R*a.m00+x*a.m10+M*a.m20,d=R*a.m01+x*a.m11+M*a.m21,p=R*a.m02+x*a.m12+M*a.m22,[h,d,p]};$.prototype.lab=function(){return Bn(this._rgb)},Object.assign(B,{lab:(...e)=>new $(...e,"lab"),getLabWhitePoint:dt,setLabWhitePoint:xe}),E.format.lab=Pn,E.autodetect.push({p:2,test:(...e)=>{if(e=T(e,"lab"),j(e)==="array"&&e.length===3)return"lab"}}),$.prototype.darken=function(e=1){const t=this,r=t.lab();return r[0]-=_e.Kn*e,new $(r,"lab").alpha(t.alpha(),!0)},$.prototype.brighten=function(e=1){return this.darken(-e)},$.prototype.darker=$.prototype.darken,$.prototype.brighter=$.prototype.brighten,$.prototype.get=function(e){const[t,r]=e.split("."),n=this[t]();if(r){const o=t.indexOf(r)-(t.substr(0,2)==="ok"?2:0);if(o>-1)return n[o];throw new Error(`unknown channel ${r} in mode ${t}`)}else return n};const{pow:Au}=Math,Lu=1e-7,Eu=20;$.prototype.luminance=function(e,t="rgb"){if(e!==void 0&&j(e)==="number"){if(e===0)return new $([0,0,0,this._rgb[3]],"rgb");if(e===1)return new $([255,255,255,this._rgb[3]],"rgb");let r=this.luminance(),n=Eu;const o=(s,c)=>{const i=s.interpolate(c,.5,t),u=i.luminance();return Math.abs(e-u)e?o(s,i):o(i,c)},a=(r>e?o(new $([0,0,0]),this):o(this,new $([255,255,255]))).rgb();return new $([...a,this._rgb[3]])}return Tu(...this._rgb.slice(0,3))};const Tu=(e,t,r)=>(e=In(e),t=In(t),r=In(r),.2126*e+.7152*t+.0722*r),In=e=>(e/=255,e<=.03928?e/12.92:Au((e+.055)/1.055,2.4)),ne={},Ue=(e,t,r=.5,...n)=>{let o=n[0]||"lrgb";if(!ne[o]&&!n.length&&(o=Object.keys(ne)[0]),!ne[o])throw new Error(`interpolation mode ${o} is not defined`);return j(e)!=="object"&&(e=new $(e)),j(t)!=="object"&&(t=new $(t)),ne[o](e,t,r).alpha(e.alpha()+r*(t.alpha()-e.alpha()))};$.prototype.mix=$.prototype.interpolate=function(e,t=.5,...r){return Ue(this,e,t,...r)},$.prototype.premultiply=function(e=!1){const t=this._rgb,r=t[3];return e?(this._rgb=[t[0]*r,t[1]*r,t[2]*r,r],this):new $([t[0]*r,t[1]*r,t[2]*r,r],"rgb")};const{sin:Su,cos:Pu}=Math,Pa=(...e)=>{let[t,r,n]=T(e,"lch");return isNaN(n)&&(n=0),n=n*Cu,[t,Pu(n)*r,Su(n)*r]},zn=(...e)=>{e=T(e,"lch");const[t,r,n]=e,[o,a,s]=Pa(t,r,n),[c,i,u]=Pn(o,a,s);return[c,i,u,e.length>3?e[3]:1]},ju=(...e)=>{const t=Aa(T(e,"hcl"));return zn(...t)},{sqrt:Bu,atan2:Gu,round:Iu}=Math,ja=(...e)=>{const[t,r,n]=T(e,"lab"),o=Bu(r*r+n*n);let a=(Gu(n,r)*xu+360)%360;return Iu(o*1e4)===0&&(a=Number.NaN),[t,o,a]},Fn=(...e)=>{const[t,r,n,...o]=T(e,"rgb"),[a,s,c]=Bn(t,r,n),[i,u,f]=ja(a,s,c);return[i,u,f,...o.length>0&&o[0]<1?[o[0]]:[]]};$.prototype.lch=function(){return Fn(this._rgb)},$.prototype.hcl=function(){return Aa(Fn(this._rgb))},Object.assign(B,{lch:(...e)=>new $(...e,"lch"),hcl:(...e)=>new $(...e,"hcl")}),E.format.lch=zn,E.format.hcl=ju,["lch","hcl"].forEach(e=>E.autodetect.push({p:2,test:(...t)=>{if(t=T(t,e),j(t)==="array"&&t.length===3)return e}})),$.prototype.saturate=function(e=1){const t=this,r=t.lch();return r[1]+=_e.Kn*e,r[1]<0&&(r[1]=0),new $(r,"lch").alpha(t.alpha(),!0)},$.prototype.desaturate=function(e=1){return this.saturate(-e)},$.prototype.set=function(e,t,r=!1){const[n,o]=e.split("."),a=this[n]();if(o){const s=n.indexOf(o)-(n.substr(0,2)==="ok"?2:0);if(s>-1){if(j(t)=="string")switch(t.charAt(0)){case"+":a[s]+=+t;break;case"-":a[s]+=+t;break;case"*":a[s]*=+t.substr(1);break;case"/":a[s]/=+t.substr(1);break;default:a[s]=+t}else if(j(t)==="number")a[s]=t;else throw new Error("unsupported value for Color.set");const c=new $(a,n);return r?(this._rgb=c._rgb,this):c}throw new Error(`unknown channel ${o} in mode ${n}`)}else return a},$.prototype.tint=function(e=.5,...t){return Ue(this,"white",e,...t)},$.prototype.shade=function(e=.5,...t){return Ue(this,"black",e,...t)};const zu=(e,t,r)=>{const n=e._rgb,o=t._rgb;return new $(n[0]+r*(o[0]-n[0]),n[1]+r*(o[1]-n[1]),n[2]+r*(o[2]-n[2]),"rgb")};ne.rgb=zu;const{sqrt:Xn,pow:Qe}=Math,Fu=(e,t,r)=>{const[n,o,a]=e._rgb,[s,c,i]=t._rgb;return new $(Xn(Qe(n,2)*(1-r)+Qe(s,2)*r),Xn(Qe(o,2)*(1-r)+Qe(c,2)*r),Xn(Qe(a,2)*(1-r)+Qe(i,2)*r),"rgb")};ne.lrgb=Fu;const Xu=(e,t,r)=>{const n=e.lab(),o=t.lab();return new $(n[0]+r*(o[0]-n[0]),n[1]+r*(o[1]-n[1]),n[2]+r*(o[2]-n[2]),"lab")};ne.lab=Xu;const et=(e,t,r,n)=>{let o,a;n==="hsl"?(o=e.hsl(),a=t.hsl()):n==="hsv"?(o=e.hsv(),a=t.hsv()):n==="hcg"?(o=e.hcg(),a=t.hcg()):n==="hsi"?(o=e.hsi(),a=t.hsi()):n==="lch"||n==="hcl"?(n="hcl",o=e.hcl(),a=t.hcl()):n==="oklch"&&(o=e.oklch().reverse(),a=t.oklch().reverse());let s,c,i,u,f,l;(n.substr(0,1)==="h"||n==="oklch")&&([s,i,f]=o,[c,u,l]=a);let h,d,p,v;return!isNaN(s)&&!isNaN(c)?(c>s&&c-s>180?v=c-(s+360):c180?v=c+360-s:v=c-s,d=s+r*v):isNaN(s)?isNaN(c)?d=Number.NaN:(d=c,(f==1||f==0)&&n!="hsv"&&(h=u)):(d=s,(l==1||l==0)&&n!="hsv"&&(h=i)),h===void 0&&(h=i+r*(u-i)),p=f+r*(l-f),n==="oklch"?new $([p,h,d],n):new $([d,h,p],n)},Ba=(e,t,r)=>et(e,t,r,"lch");ne.lch=Ba,ne.hcl=Ba;const Ku=e=>{if(j(e)=="number"&&e>=0&&e<=16777215){const t=e>>16,r=e>>8&255,n=e&255;return[t,r,n,1]}throw new Error("unknown num color: "+e)},Du=(...e)=>{const[t,r,n]=T(e,"rgb");return(t<<16)+(r<<8)+n};$.prototype.num=function(){return Du(this._rgb)},Object.assign(B,{num:(...e)=>new $(...e,"num")}),E.format.num=Ku,E.autodetect.push({p:5,test:(...e)=>{if(e.length===1&&j(e[0])==="number"&&e[0]>=0&&e[0]<=16777215)return"num"}});const Vu=(e,t,r)=>{const n=e.num(),o=t.num();return new $(n+r*(o-n),"num")};ne.num=Vu;const{floor:Yu}=Math,Zu=(...e)=>{e=T(e,"hcg");let[t,r,n]=e,o,a,s;n=n*255;const c=r*255;if(r===0)o=a=s=n;else{t===360&&(t=0),t>360&&(t-=360),t<0&&(t+=360),t/=60;const i=Yu(t),u=t-i,f=n*(1-r),l=f+c*(1-u),h=f+c*u,d=f+c;switch(i){case 0:[o,a,s]=[d,h,f];break;case 1:[o,a,s]=[l,d,f];break;case 2:[o,a,s]=[f,d,h];break;case 3:[o,a,s]=[f,l,d];break;case 4:[o,a,s]=[h,f,d];break;case 5:[o,a,s]=[d,f,l];break}}return[o,a,s,e.length>3?e[3]:1]},Ju=(...e)=>{const[t,r,n]=T(e,"rgb"),o=Oa(t,r,n),a=Na(t,r,n),s=a-o,c=s*100/255,i=o/(255-s)*100;let u;return s===0?u=Number.NaN:(t===a&&(u=(r-n)/s),r===a&&(u=2+(n-t)/s),n===a&&(u=4+(t-r)/s),u*=60,u<0&&(u+=360)),[u,c,i]};$.prototype.hcg=function(){return Ju(this._rgb)};const Wu=(...e)=>new $(...e,"hcg");B.hcg=Wu,E.format.hcg=Zu,E.autodetect.push({p:1,test:(...e)=>{if(e=T(e,"hcg"),j(e)==="array"&&e.length===3)return"hcg"}});const Uu=(e,t,r)=>et(e,t,r,"hcg");ne.hcg=Uu;const{cos:tt}=Math,Qu=(...e)=>{e=T(e,"hsi");let[t,r,n]=e,o,a,s;return isNaN(t)&&(t=0),isNaN(r)&&(r=0),t>360&&(t-=360),t<0&&(t+=360),t/=360,t<1/3?(s=(1-r)/3,o=(1+r*tt(Ce*t)/tt(Sn-Ce*t))/3,a=1-(s+o)):t<2/3?(t-=1/3,o=(1-r)/3,a=(1+r*tt(Ce*t)/tt(Sn-Ce*t))/3,s=1-(o+a)):(t-=2/3,a=(1-r)/3,s=(1+r*tt(Ce*t)/tt(Sn-Ce*t))/3,o=1-(a+s)),o=Be(n*o*3),a=Be(n*a*3),s=Be(n*s*3),[o*255,a*255,s*255,e.length>3?e[3]:1]},{min:ef,sqrt:tf,acos:rf}=Math,nf=(...e)=>{let[t,r,n]=T(e,"rgb");t/=255,r/=255,n/=255;let o;const a=ef(t,r,n),s=(t+r+n)/3,c=s>0?1-a/s:0;return c===0?o=NaN:(o=(t-r+(t-n))/2,o/=tf((t-r)*(t-r)+(t-n)*(r-n)),o=rf(o),n>r&&(o=Ce-o),o/=Ce),[o*360,c,s]};$.prototype.hsi=function(){return nf(this._rgb)};const of=(...e)=>new $(...e,"hsi");B.hsi=of,E.format.hsi=Qu,E.autodetect.push({p:2,test:(...e)=>{if(e=T(e,"hsi"),j(e)==="array"&&e.length===3)return"hsi"}});const sf=(e,t,r)=>et(e,t,r,"hsi");ne.hsi=sf;const Kn=(...e)=>{e=T(e,"hsl");const[t,r,n]=e;let o,a,s;if(r===0)o=a=s=n*255;else{const c=[0,0,0],i=[0,0,0],u=n<.5?n*(1+r):n+r-n*r,f=2*n-u,l=t/360;c[0]=l+1/3,c[1]=l,c[2]=l-1/3;for(let h=0;h<3;h++)c[h]<0&&(c[h]+=1),c[h]>1&&(c[h]-=1),6*c[h]<1?i[h]=f+(u-f)*6*c[h]:2*c[h]<1?i[h]=u:3*c[h]<2?i[h]=f+(u-f)*(2/3-c[h])*6:i[h]=f;[o,a,s]=[i[0]*255,i[1]*255,i[2]*255]}return e.length>3?[o,a,s,e[3]]:[o,a,s,1]},Ga=(...e)=>{e=T(e,"rgba");let[t,r,n]=e;t/=255,r/=255,n/=255;const o=Oa(t,r,n),a=Na(t,r,n),s=(a+o)/2;let c,i;return a===o?(c=0,i=Number.NaN):c=s<.5?(a-o)/(a+o):(a-o)/(2-a-o),t==a?i=(r-n)/(a-o):r==a?i=2+(n-t)/(a-o):n==a&&(i=4+(t-r)/(a-o)),i*=60,i<0&&(i+=360),e.length>3&&e[3]!==void 0?[i,c,s,e[3]]:[i,c,s]};$.prototype.hsl=function(){return Ga(this._rgb)};const af=(...e)=>new $(...e,"hsl");B.hsl=af,E.format.hsl=Kn,E.autodetect.push({p:2,test:(...e)=>{if(e=T(e,"hsl"),j(e)==="array"&&e.length===3)return"hsl"}});const cf=(e,t,r)=>et(e,t,r,"hsl");ne.hsl=cf;const{floor:uf}=Math,ff=(...e)=>{e=T(e,"hsv");let[t,r,n]=e,o,a,s;if(n*=255,r===0)o=a=s=n;else{t===360&&(t=0),t>360&&(t-=360),t<0&&(t+=360),t/=60;const c=uf(t),i=t-c,u=n*(1-r),f=n*(1-r*i),l=n*(1-r*(1-i));switch(c){case 0:[o,a,s]=[n,l,u];break;case 1:[o,a,s]=[f,n,u];break;case 2:[o,a,s]=[u,n,l];break;case 3:[o,a,s]=[u,f,n];break;case 4:[o,a,s]=[l,u,n];break;case 5:[o,a,s]=[n,u,f];break}}return[o,a,s,e.length>3?e[3]:1]},{min:lf,max:hf}=Math,df=(...e)=>{e=T(e,"rgb");let[t,r,n]=e;const o=lf(t,r,n),a=hf(t,r,n),s=a-o;let c,i,u;return u=a/255,a===0?(c=Number.NaN,i=0):(i=s/a,t===a&&(c=(r-n)/s),r===a&&(c=2+(n-t)/s),n===a&&(c=4+(t-r)/s),c*=60,c<0&&(c+=360)),[c,i,u]};$.prototype.hsv=function(){return df(this._rgb)};const bf=(...e)=>new $(...e,"hsv");B.hsv=bf,E.format.hsv=ff,E.autodetect.push({p:2,test:(...e)=>{if(e=T(e,"hsv"),j(e)==="array"&&e.length===3)return"hsv"}});const pf=(e,t,r)=>et(e,t,r,"hsv");ne.hsv=pf;function Pt(e,t){let r=e.length;Array.isArray(e[0])||(e=[e]),Array.isArray(t[0])||(t=t.map(s=>[s]));let n=t[0].length,o=t[0].map((s,c)=>t.map(i=>i[c])),a=e.map(s=>o.map(c=>Array.isArray(s)?s.reduce((i,u,f)=>i+u*(c[f]||0),0):c.reduce((i,u)=>i+u*s,0)));return r===1&&(a=a[0]),n===1?a.map(s=>s[0]):a}const Dn=(...e)=>{e=T(e,"lab");const[t,r,n,...o]=e,[a,s,c]=mf([t,r,n]),[i,u,f]=Ta(a,s,c);return[i,u,f,...o.length>0&&o[0]<1?[o[0]]:[]]};function mf(e){var t=[[1.2268798758459243,-.5578149944602171,.2813910456659647],[-.0405757452148008,1.112286803280317,-.0717110580655164],[-.0763729366746601,-.4214933324022432,1.5869240198367816]],r=[[1,.3963377773761749,.2158037573099136],[1,-.1055613458156586,-.0638541728258133],[1,-.0894841775298119,-1.2914855480194092]],n=Pt(r,e);return Pt(t,n.map(o=>o**3))}const Vn=(...e)=>{const[t,r,n,...o]=T(e,"rgb"),a=Sa(t,r,n);return[...gf(a),...o.length>0&&o[0]<1?[o[0]]:[]]};function gf(e){const t=[[.819022437996703,.3619062600528904,-.1288737815209879],[.0329836539323885,.9292868615863434,.0361446663506424],[.0481771893596242,.2642395317527308,.6335478284694309]],r=[[.210454268309314,.7936177747023054,-.0040720430116193],[1.9779985324311684,-2.42859224204858,.450593709617411],[.0259040424655478,.7827717124575296,-.8086757549230774]],n=Pt(t,e);return Pt(r,n.map(o=>Math.cbrt(o)))}$.prototype.oklab=function(){return Vn(this._rgb)},Object.assign(B,{oklab:(...e)=>new $(...e,"oklab")}),E.format.oklab=Dn,E.autodetect.push({p:2,test:(...e)=>{if(e=T(e,"oklab"),j(e)==="array"&&e.length===3)return"oklab"}});const vf=(e,t,r)=>{const n=e.oklab(),o=t.oklab();return new $(n[0]+r*(o[0]-n[0]),n[1]+r*(o[1]-n[1]),n[2]+r*(o[2]-n[2]),"oklab")};ne.oklab=vf;const _f=(e,t,r)=>et(e,t,r,"oklch");ne.oklch=_f;const{pow:Yn,sqrt:Zn,PI:Jn,cos:Ia,sin:za,atan2:yf}=Math,wf=(e,t="lrgb",r=null)=>{const n=e.length;r||(r=Array.from(new Array(n)).map(()=>1));const o=n/r.reduce(function(l,h){return l+h});if(r.forEach((l,h)=>{r[h]*=o}),e=e.map(l=>new $(l)),t==="lrgb")return kf(e,r);const a=e.shift(),s=a.get(t),c=[];let i=0,u=0;for(let l=0;l{const d=l.get(t);f+=l.alpha()*r[h+1];for(let p=0;p=360;)h-=360;s[l]=h}else s[l]=s[l]/c[l];return f/=n,new $(s,t).alpha(f>.99999?1:f,!0)},kf=(e,t)=>{const r=e.length,n=[0,0,0,0];for(let o=0;o.9999999&&(n[3]=1),new $(En(n))},{pow:$f}=Math;function jt(e){let t="rgb",r=B("#ccc"),n=0,o=[0,1],a=[],s=[0,0],c=!1,i=[],u=!1,f=0,l=1,h=!1,d={},p=!0,v=1;const m=function(b){if(b=b||["#fff","#000"],b&&j(b)==="string"&&B.brewer&&B.brewer[b.toLowerCase()]&&(b=B.brewer[b.toLowerCase()]),j(b)==="array"){b.length===1&&(b=[b[0],b[0]]),b=b.slice(0);for(let _=0;_=c[k];)k++;return k-1}return 0};let R=b=>b,x=b=>b;const M=function(b,_){let k,y;if(_==null&&(_=!1),isNaN(b)||b===null)return r;_?y=b:c&&c.length>2?y=g(b)/(c.length-2):l!==f?y=(b-f)/(l-f):y=1,y=x(y),_||(y=R(y)),v!==1&&(y=$f(y,v)),y=s[0]+y*(1-s[0]-s[1]),y=Be(y,0,1);const q=Math.floor(y*1e4);if(p&&d[q])k=d[q];else{if(j(i)==="array")for(let H=0;H=A&&H===a.length-1){k=i[H];break}if(y>A&&yd={};m(e);const w=function(b){const _=B(M(b));return u&&_[u]?_[u]():_};return w.classes=function(b){if(b!=null){if(j(b)==="array")c=b,o=[b[0],b[b.length-1]];else{const _=B.analyze(o);b===0?c=[_.min,_.max]:c=B.limits(_,"e",b)}return w}return c},w.domain=function(b){if(!arguments.length)return o;f=b[0],l=b[b.length-1],a=[];const _=i.length;if(b.length===_&&f!==l)for(let k of Array.from(b))a.push((k-f)/(l-f));else{for(let k=0;k<_;k++)a.push(k/(_-1));if(b.length>2){const k=b.map((q,H)=>H/(b.length-1)),y=b.map(q=>(q-f)/(l-f));y.every((q,H)=>k[H]===q)||(x=q=>{if(q<=0||q>=1)return q;let H=0;for(;q>=y[H+1];)H++;const A=(q-y[H])/(y[H+1]-y[H]);return k[H]+A*(k[H+1]-k[H])})}}return o=[f,l],w},w.mode=function(b){return arguments.length?(t=b,N(),w):t},w.range=function(b,_){return m(b),w},w.out=function(b){return u=b,w},w.spread=function(b){return arguments.length?(n=b,w):n},w.correctLightness=function(b){return b==null&&(b=!0),h=b,N(),h?R=function(_){const k=M(0,!0).lab()[0],y=M(1,!0).lab()[0],q=k>y;let H=M(_,!0).lab()[0];const A=k+(y-k)*_;let I=H-A,K=0,D=1,U=20;for(;Math.abs(I)>.01&&U-- >0;)(function(){return q&&(I*=-1),I<0?(K=_,_+=(D-_)*.5):(D=_,_+=(K-_)*.5),H=M(_,!0).lab()[0],I=H-A})();return _}:R=_=>_,w},w.padding=function(b){return b!=null?(j(b)==="number"&&(b=[b,b]),s=b,w):s},w.colors=function(b,_){arguments.length<2&&(_="hex");let k=[];if(arguments.length===0)k=i.slice(0);else if(b===1)k=[w(.5)];else if(b>1){const y=o[0],q=o[1]-y;k=Cf(0,b).map(H=>w(y+H/(b-1)*q))}else{e=[];let y=[];if(c&&c.length>2)for(let q=1,H=c.length,A=1<=H;A?qH;A?q++:q--)y.push((c[q-1]+c[q])*.5);else y=o;k=y.map(q=>w(q))}return B[_]&&(k=k.map(y=>y[_]())),k},w.cache=function(b){return b!=null?(p=b,w):p},w.gamma=function(b){return b!=null?(v=b,w):v},w.nodata=function(b){return b!=null?(r=B(b),w):r},w}function Cf(e,t,r){let n=[],o=ea;o?s++:s--)n.push(s);return n}const xf=function(e){let t=[1,1];for(let r=1;rnew $(a)),e.length===2)[r,n]=e.map(a=>a.lab()),t=function(a){const s=[0,1,2].map(c=>r[c]+a*(n[c]-r[c]));return new $(s,"lab")};else if(e.length===3)[r,n,o]=e.map(a=>a.lab()),t=function(a){const s=[0,1,2].map(c=>(1-a)*(1-a)*r[c]+2*(1-a)*a*n[c]+a*a*o[c]);return new $(s,"lab")};else if(e.length===4){let a;[r,n,o,a]=e.map(s=>s.lab()),t=function(s){const c=[0,1,2].map(i=>(1-s)*(1-s)*(1-s)*r[i]+3*(1-s)*(1-s)*s*n[i]+3*(1-s)*s*s*o[i]+s*s*s*a[i]);return new $(c,"lab")}}else if(e.length>=5){let a,s,c;a=e.map(i=>i.lab()),c=e.length-1,s=xf(c),t=function(i){const u=1-i,f=[0,1,2].map(l=>a.reduce((h,d,p)=>h+s[p]*u**(c-p)*i**p*d[l],0));return new $(f,"lab")}}else throw new RangeError("No point in running bezier with only one color.");return t},Hf=e=>{const t=Rf(e);return t.scale=()=>jt(t),t},{round:Fa}=Math;$.prototype.rgb=function(e=!0){return e===!1?this._rgb.slice(0,3):this._rgb.slice(0,3).map(Fa)},$.prototype.rgba=function(e=!0){return this._rgb.slice(0,4).map((t,r)=>r<3?e===!1?t:Fa(t):t)},Object.assign(B,{rgb:(...e)=>new $(...e,"rgb")}),E.format.rgb=(...e)=>{const t=T(e,"rgba");return t[3]===void 0&&(t[3]=1),t},E.autodetect.push({p:3,test:(...e)=>{if(e=T(e,"rgba"),j(e)==="array"&&(e.length===3||e.length===4&&j(e[3])=="number"&&e[3]>=0&&e[3]<=1))return"rgb"}});const pe=(e,t,r)=>{if(!pe[r])throw new Error("unknown blend mode "+r);return pe[r](e,t)},Te=e=>(t,r)=>{const n=B(r).rgb(),o=B(t).rgb();return B.rgb(e(n,o))},Se=e=>(t,r)=>{const n=[];return n[0]=e(t[0],r[0]),n[1]=e(t[1],r[1]),n[2]=e(t[2],r[2]),n},qf=e=>e,Mf=(e,t)=>e*t/255,Of=(e,t)=>e>t?t:e,Nf=(e,t)=>e>t?e:t,Af=(e,t)=>255*(1-(1-e/255)*(1-t/255)),Lf=(e,t)=>t<128?2*e*t/255:255*(1-2*(1-e/255)*(1-t/255)),Ef=(e,t)=>255*(1-(1-t/255)/(e/255)),Tf=(e,t)=>e===255?255:(e=255*(t/255)/(1-e/255),e>255?255:e);pe.normal=Te(Se(qf)),pe.multiply=Te(Se(Mf)),pe.screen=Te(Se(Af)),pe.overlay=Te(Se(Lf)),pe.darken=Te(Se(Of)),pe.lighten=Te(Se(Nf)),pe.dodge=Te(Se(Tf)),pe.burn=Te(Se(Ef));const{pow:Sf,sin:Pf,cos:jf}=Math;function Bf(e=300,t=-1.5,r=1,n=1,o=[0,1]){let a=0,s;j(o)==="array"?s=o[1]-o[0]:(s=0,o=[o,o]);const c=function(i){const u=Ce*((e+120)/360+t*i),f=Sf(o[0]+s*i,n),h=(a!==0?r[0]+i*a:r)*f*(1-f)/2,d=jf(u),p=Pf(u),v=f+h*(-.14861*d+1.78277*p),m=f+h*(-.29227*d-.90649*p),g=f+h*(1.97294*d);return B(En([v*255,m*255,g*255,1]))};return c.start=function(i){return i==null?e:(e=i,c)},c.rotations=function(i){return i==null?t:(t=i,c)},c.gamma=function(i){return i==null?n:(n=i,c)},c.hue=function(i){return i==null?r:(r=i,j(r)==="array"?(a=r[1]-r[0],a===0&&(r=r[1])):a=0,c)},c.lightness=function(i){return i==null?o:(j(i)==="array"?(o=i,s=i[1]-i[0]):(o=[i,i],s=0),c)},c.scale=()=>B.scale(c),c.hue(r),c}const Gf="0123456789abcdef",{floor:If,random:zf}=Math,Ff=()=>{let e="#";for(let t=0;t<6;t++)e+=Gf.charAt(If(zf()*16));return new $(e,"hex")},{log:Xa,pow:Xf,floor:Kf,abs:Df}=Math;function Ka(e,t=null){const r={min:Number.MAX_VALUE,max:Number.MAX_VALUE*-1,sum:0,values:[],count:0};return j(e)==="object"&&(e=Object.values(e)),e.forEach(n=>{t&&j(n)==="object"&&(n=n[t]),n!=null&&!isNaN(n)&&(r.values.push(n),r.sum+=n,nr.max&&(r.max=n),r.count+=1)}),r.domain=[r.min,r.max],r.limits=(n,o)=>Da(r,n,o),r}function Da(e,t="equal",r=7){j(e)=="array"&&(e=Ka(e));const{min:n,max:o}=e,a=e.values.sort((c,i)=>c-i);if(r===1)return[n,o];const s=[];if(t.substr(0,1)==="c"&&(s.push(n),s.push(o)),t.substr(0,1)==="e"){s.push(n);for(let c=1;c 0");const c=Math.LOG10E*Xa(n),i=Math.LOG10E*Xa(o);s.push(n);for(let u=1;u200&&(l=!1)}const p={};for(let m=0;mm-g),s.push(v[0]);for(let m=1;m{e=new $(e),t=new $(t);const r=e.luminance(),n=t.luminance();return r>n?(r+.05)/(n+.05):(n+.05)/(r+.05)};/** + * @license + * + * The APCA contrast prediction algorithm is based of the formulas published + * in the APCA-1.0.98G specification by Myndex. The specification is available at: + * https://raw.githubusercontent.com/Myndex/apca-w3/master/images/APCAw3_0.1.17_APCA0.0.98G.svg + * + * Note that the APCA implementation is still beta, so please update to + * future versions of chroma.js when they become available. + * + * You can read more about the APCA Readability Criterion at + * https://readtech.org/ARC/ + */const Va=.027,Yf=5e-4,Zf=.1,Ya=1.14,Bt=.022,Za=1.414,Jf=(e,t)=>{e=new $(e),t=new $(t),e.alpha()<1&&(e=Ue(t,e,e.alpha(),"rgb"));const r=Ja(...e.rgb()),n=Ja(...t.rgb()),o=r>=Bt?r:r+Math.pow(Bt-r,Za),a=n>=Bt?n:n+Math.pow(Bt-n,Za),s=Math.pow(a,.56)-Math.pow(o,.57),c=Math.pow(a,.65)-Math.pow(o,.62),i=Math.abs(a-o)0?i-Va:i+Va)*100};function Ja(e,t,r){return .2126729*Math.pow(e/255,2.4)+.7151522*Math.pow(t/255,2.4)+.072175*Math.pow(r/255,2.4)}const{sqrt:Re,pow:Z,min:Wf,max:Uf,atan2:Wa,abs:Ua,cos:Gt,sin:Qa,exp:Qf,PI:ec}=Math;function el(e,t,r=1,n=1,o=1){var a=function(le){return 360*le/(2*ec)},s=function(le){return 2*ec*le/360};e=new $(e),t=new $(t);const[c,i,u]=Array.from(e.lab()),[f,l,h]=Array.from(t.lab()),d=(c+f)/2,p=Re(Z(i,2)+Z(u,2)),v=Re(Z(l,2)+Z(h,2)),m=(p+v)/2,g=.5*(1-Re(Z(m,7)/(Z(m,7)+Z(25,7)))),R=i*(1+g),x=l*(1+g),M=Re(Z(R,2)+Z(u,2)),N=Re(Z(x,2)+Z(h,2)),w=(M+N)/2,b=a(Wa(u,R)),_=a(Wa(h,x)),k=b>=0?b:b+360,y=_>=0?_:_+360,q=Ua(k-y)>180?(k+y+360)/2:(k+y)/2,H=1-.17*Gt(s(q-30))+.24*Gt(s(2*q))+.32*Gt(s(3*q+6))-.2*Gt(s(4*q-63));let A=y-k;A=Ua(A)<=180?A:y<=k?A+360:A-360,A=2*Re(M*N)*Qa(s(A)/2);const I=f-c,K=N-M,D=1+.015*Z(d-50,2)/Re(20+Z(d-50,2)),U=1+.045*w,ge=1+.015*w*H,ye=30*Qf(-Z((q-275)/25,2)),fe=-(2*Re(Z(w,7)/(Z(w,7)+Z(25,7))))*Qa(2*s(ye)),qe=Re(Z(I/(r*D),2)+Z(K/(n*U),2)+Z(A/(o*ge),2)+fe*(K/(n*U))*(A/(o*ge)));return Uf(0,Wf(100,qe))}function tl(e,t,r="lab"){e=new $(e),t=new $(t);const n=e.get(r),o=t.get(r);let a=0;for(let s in n){const c=(n[s]||0)-(o[s]||0);a+=c*c}return Math.sqrt(a)}const rl=(...e)=>{try{return new $(...e),!0}catch{return!1}},nl={cool(){return jt([B.hsl(180,1,.9),B.hsl(250,.7,.4)])},hot(){return jt(["#000","#f00","#ff0","#fff"]).mode("rgb")}},Wn={OrRd:["#fff7ec","#fee8c8","#fdd49e","#fdbb84","#fc8d59","#ef6548","#d7301f","#b30000","#7f0000"],PuBu:["#fff7fb","#ece7f2","#d0d1e6","#a6bddb","#74a9cf","#3690c0","#0570b0","#045a8d","#023858"],BuPu:["#f7fcfd","#e0ecf4","#bfd3e6","#9ebcda","#8c96c6","#8c6bb1","#88419d","#810f7c","#4d004b"],Oranges:["#fff5eb","#fee6ce","#fdd0a2","#fdae6b","#fd8d3c","#f16913","#d94801","#a63603","#7f2704"],BuGn:["#f7fcfd","#e5f5f9","#ccece6","#99d8c9","#66c2a4","#41ae76","#238b45","#006d2c","#00441b"],YlOrBr:["#ffffe5","#fff7bc","#fee391","#fec44f","#fe9929","#ec7014","#cc4c02","#993404","#662506"],YlGn:["#ffffe5","#f7fcb9","#d9f0a3","#addd8e","#78c679","#41ab5d","#238443","#006837","#004529"],Reds:["#fff5f0","#fee0d2","#fcbba1","#fc9272","#fb6a4a","#ef3b2c","#cb181d","#a50f15","#67000d"],RdPu:["#fff7f3","#fde0dd","#fcc5c0","#fa9fb5","#f768a1","#dd3497","#ae017e","#7a0177","#49006a"],Greens:["#f7fcf5","#e5f5e0","#c7e9c0","#a1d99b","#74c476","#41ab5d","#238b45","#006d2c","#00441b"],YlGnBu:["#ffffd9","#edf8b1","#c7e9b4","#7fcdbb","#41b6c4","#1d91c0","#225ea8","#253494","#081d58"],Purples:["#fcfbfd","#efedf5","#dadaeb","#bcbddc","#9e9ac8","#807dba","#6a51a3","#54278f","#3f007d"],GnBu:["#f7fcf0","#e0f3db","#ccebc5","#a8ddb5","#7bccc4","#4eb3d3","#2b8cbe","#0868ac","#084081"],Greys:["#ffffff","#f0f0f0","#d9d9d9","#bdbdbd","#969696","#737373","#525252","#252525","#000000"],YlOrRd:["#ffffcc","#ffeda0","#fed976","#feb24c","#fd8d3c","#fc4e2a","#e31a1c","#bd0026","#800026"],PuRd:["#f7f4f9","#e7e1ef","#d4b9da","#c994c7","#df65b0","#e7298a","#ce1256","#980043","#67001f"],Blues:["#f7fbff","#deebf7","#c6dbef","#9ecae1","#6baed6","#4292c6","#2171b5","#08519c","#08306b"],PuBuGn:["#fff7fb","#ece2f0","#d0d1e6","#a6bddb","#67a9cf","#3690c0","#02818a","#016c59","#014636"],Viridis:["#440154","#482777","#3f4a8a","#31678e","#26838f","#1f9d8a","#6cce5a","#b6de2b","#fee825"],Spectral:["#9e0142","#d53e4f","#f46d43","#fdae61","#fee08b","#ffffbf","#e6f598","#abdda4","#66c2a5","#3288bd","#5e4fa2"],RdYlGn:["#a50026","#d73027","#f46d43","#fdae61","#fee08b","#ffffbf","#d9ef8b","#a6d96a","#66bd63","#1a9850","#006837"],RdBu:["#67001f","#b2182b","#d6604d","#f4a582","#fddbc7","#f7f7f7","#d1e5f0","#92c5de","#4393c3","#2166ac","#053061"],PiYG:["#8e0152","#c51b7d","#de77ae","#f1b6da","#fde0ef","#f7f7f7","#e6f5d0","#b8e186","#7fbc41","#4d9221","#276419"],PRGn:["#40004b","#762a83","#9970ab","#c2a5cf","#e7d4e8","#f7f7f7","#d9f0d3","#a6dba0","#5aae61","#1b7837","#00441b"],RdYlBu:["#a50026","#d73027","#f46d43","#fdae61","#fee090","#ffffbf","#e0f3f8","#abd9e9","#74add1","#4575b4","#313695"],BrBG:["#543005","#8c510a","#bf812d","#dfc27d","#f6e8c3","#f5f5f5","#c7eae5","#80cdc1","#35978f","#01665e","#003c30"],RdGy:["#67001f","#b2182b","#d6604d","#f4a582","#fddbc7","#ffffff","#e0e0e0","#bababa","#878787","#4d4d4d","#1a1a1a"],PuOr:["#7f3b08","#b35806","#e08214","#fdb863","#fee0b6","#f7f7f7","#d8daeb","#b2abd2","#8073ac","#542788","#2d004b"],Set2:["#66c2a5","#fc8d62","#8da0cb","#e78ac3","#a6d854","#ffd92f","#e5c494","#b3b3b3"],Accent:["#7fc97f","#beaed4","#fdc086","#ffff99","#386cb0","#f0027f","#bf5b17","#666666"],Set1:["#e41a1c","#377eb8","#4daf4a","#984ea3","#ff7f00","#ffff33","#a65628","#f781bf","#999999"],Set3:["#8dd3c7","#ffffb3","#bebada","#fb8072","#80b1d3","#fdb462","#b3de69","#fccde5","#d9d9d9","#bc80bd","#ccebc5","#ffed6f"],Dark2:["#1b9e77","#d95f02","#7570b3","#e7298a","#66a61e","#e6ab02","#a6761d","#666666"],Paired:["#a6cee3","#1f78b4","#b2df8a","#33a02c","#fb9a99","#e31a1c","#fdbf6f","#ff7f00","#cab2d6","#6a3d9a","#ffff99","#b15928"],Pastel2:["#b3e2cd","#fdcdac","#cbd5e8","#f4cae4","#e6f5c9","#fff2ae","#f1e2cc","#cccccc"],Pastel1:["#fbb4ae","#b3cde3","#ccebc5","#decbe4","#fed9a6","#ffffcc","#e5d8bd","#fddaec","#f2f2f2"]},tc=Object.keys(Wn),rc=new Map(tc.map(e=>[e.toLowerCase(),e])),ol=typeof Proxy=="function"?new Proxy(Wn,{get(e,t){const r=t.toLowerCase();if(rc.has(r))return e[rc.get(r)]},getOwnPropertyNames(){return Object.getOwnPropertyNames(tc)}}):Wn,sl=(...e)=>{e=T(e,"cmyk");const[t,r,n,o]=e,a=e.length>4?e[4]:1;return o===1?[0,0,0,a]:[t>=1?0:255*(1-t)*(1-o),r>=1?0:255*(1-r)*(1-o),n>=1?0:255*(1-n)*(1-o),a]},{max:nc}=Math,al=(...e)=>{let[t,r,n]=T(e,"rgb");t=t/255,r=r/255,n=n/255;const o=1-nc(t,nc(r,n)),a=o<1?1/(1-o):0,s=(1-t-o)*a,c=(1-r-o)*a,i=(1-n-o)*a;return[s,c,i,o]};$.prototype.cmyk=function(){return al(this._rgb)},Object.assign(B,{cmyk:(...e)=>new $(...e,"cmyk")}),E.format.cmyk=sl,E.autodetect.push({p:2,test:(...e)=>{if(e=T(e,"cmyk"),j(e)==="array"&&e.length===4)return"cmyk"}});const cl=(...e)=>{const t=T(e,"hsla");let r=Je(e)||"lsa";return t[0]=ie(t[0]||0)+"deg",t[1]=ie(t[1]*100)+"%",t[2]=ie(t[2]*100)+"%",r==="hsla"||t.length>3&&t[3]<1?(t[3]="/ "+(t.length>3?t[3]:1),r="hsla"):t.length=3,`${r.substr(0,3)}(${t.join(" ")})`},il=(...e)=>{const t=T(e,"lab");let r=Je(e)||"lab";return t[0]=ie(t[0])+"%",t[1]=ie(t[1]),t[2]=ie(t[2]),r==="laba"||t.length>3&&t[3]<1?t[3]="/ "+(t.length>3?t[3]:1):t.length=3,`lab(${t.join(" ")})`},ul=(...e)=>{const t=T(e,"lch");let r=Je(e)||"lab";return t[0]=ie(t[0])+"%",t[1]=ie(t[1]),t[2]=isNaN(t[2])?"none":ie(t[2])+"deg",r==="lcha"||t.length>3&&t[3]<1?t[3]="/ "+(t.length>3?t[3]:1):t.length=3,`lch(${t.join(" ")})`},fl=(...e)=>{const t=T(e,"lab");return t[0]=ie(t[0]*100)+"%",t[1]=Tn(t[1]),t[2]=Tn(t[2]),t.length>3&&t[3]<1?t[3]="/ "+(t.length>3?t[3]:1):t.length=3,`oklab(${t.join(" ")})`},oc=(...e)=>{const[t,r,n,...o]=T(e,"rgb"),[a,s,c]=Vn(t,r,n),[i,u,f]=ja(a,s,c);return[i,u,f,...o.length>0&&o[0]<1?[o[0]]:[]]},ll=(...e)=>{const t=T(e,"lch");return t[0]=ie(t[0]*100)+"%",t[1]=Tn(t[1]),t[2]=isNaN(t[2])?"none":ie(t[2])+"deg",t.length>3&&t[3]<1?t[3]="/ "+(t.length>3?t[3]:1):t.length=3,`oklch(${t.join(" ")})`},{round:Un}=Math,hl=(...e)=>{const t=T(e,"rgba");let r=Je(e)||"rgb";if(r.substr(0,3)==="hsl")return cl(Ga(t),r);if(r.substr(0,3)==="lab"){const n=dt();xe("d50");const o=il(Bn(t),r);return xe(n),o}if(r.substr(0,3)==="lch"){const n=dt();xe("d50");const o=ul(Fn(t),r);return xe(n),o}return r.substr(0,5)==="oklab"?fl(Vn(t)):r.substr(0,5)==="oklch"?ll(oc(t)):(t[0]=Un(t[0]),t[1]=Un(t[1]),t[2]=Un(t[2]),(r==="rgba"||t.length>3&&t[3]<1)&&(t[3]="/ "+(t.length>3?t[3]:1),r="rgba"),`${r.substr(0,3)}(${t.slice(0,r==="rgb"?3:4).join(" ")})`)},sc=(...e)=>{e=T(e,"lch");const[t,r,n,...o]=e,[a,s,c]=Pa(t,r,n),[i,u,f]=Dn(a,s,c);return[i,u,f,...o.length>0&&o[0]<1?[o[0]]:[]]},He=/((?:-?\d+)|(?:-?\d+(?:\.\d+)?)%|none)/.source,me=/((?:-?(?:\d+(?:\.\d*)?|\.\d+)%?)|none)/.source,It=/((?:-?(?:\d+(?:\.\d*)?|\.\d+)%)|none)/.source,ue=/\s*/.source,rt=/\s+/.source,Qn=/\s*,\s*/.source,zt=/((?:-?(?:\d+(?:\.\d*)?|\.\d+)(?:deg)?)|none)/.source,nt=/\s*(?:\/\s*((?:[01]|[01]?\.\d+)|\d+(?:\.\d+)?%))?/.source,ac=new RegExp("^rgba?\\("+ue+[He,He,He].join(rt)+nt+"\\)$"),cc=new RegExp("^rgb\\("+ue+[He,He,He].join(Qn)+ue+"\\)$"),ic=new RegExp("^rgba\\("+ue+[He,He,He,me].join(Qn)+ue+"\\)$"),uc=new RegExp("^hsla?\\("+ue+[zt,It,It].join(rt)+nt+"\\)$"),fc=new RegExp("^hsl?\\("+ue+[zt,It,It].join(Qn)+ue+"\\)$"),lc=/^hsla\(\s*(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)%\s*,\s*(-?\d+(?:\.\d+)?)%\s*,\s*([01]|[01]?\.\d+)\)$/,hc=new RegExp("^lab\\("+ue+[me,me,me].join(rt)+nt+"\\)$"),dc=new RegExp("^lch\\("+ue+[me,me,zt].join(rt)+nt+"\\)$"),bc=new RegExp("^oklab\\("+ue+[me,me,me].join(rt)+nt+"\\)$"),pc=new RegExp("^oklch\\("+ue+[me,me,zt].join(rt)+nt+"\\)$"),{round:mc}=Math,ot=e=>e.map((t,r)=>r<=2?Be(mc(t),0,255):t),J=(e,t=0,r=100,n=!1)=>(typeof e=="string"&&e.endsWith("%")&&(e=parseFloat(e.substring(0,e.length-1))/100,n?e=t+(e+1)*.5*(r-t):e=t+e*(r-t)),+e),oe=(e,t)=>e==="none"?t:e,eo=e=>{if(e=e.toLowerCase().trim(),e==="transparent")return[0,0,0,0];let t;if(E.format.named)try{return E.format.named(e)}catch{}if((t=e.match(ac))||(t=e.match(cc))){let r=t.slice(1,4);for(let o=0;o<3;o++)r[o]=+J(oe(r[o],0),0,255);r=ot(r);const n=t[4]!==void 0?+J(t[4],0,1):1;return r[3]=n,r}if(t=e.match(ic)){const r=t.slice(1,5);for(let n=0;n<4;n++)r[n]=+J(r[n],0,255);return r}if((t=e.match(uc))||(t=e.match(fc))){const r=t.slice(1,4);r[0]=+oe(r[0].replace("deg",""),0),r[1]=+J(oe(r[1],0),0,100)*.01,r[2]=+J(oe(r[2],0),0,100)*.01;const n=ot(Kn(r)),o=t[4]!==void 0?+J(t[4],0,1):1;return n[3]=o,n}if(t=e.match(lc)){const r=t.slice(1,4);r[1]*=.01,r[2]*=.01;const n=Kn(r);for(let o=0;o<3;o++)n[o]=mc(n[o]);return n[3]=+t[4],n}if(t=e.match(hc)){const r=t.slice(1,4);r[0]=J(oe(r[0],0),0,100),r[1]=J(oe(r[1],0),-125,125,!0),r[2]=J(oe(r[2],0),-125,125,!0);const n=dt();xe("d50");const o=ot(Pn(r));xe(n);const a=t[4]!==void 0?+J(t[4],0,1):1;return o[3]=a,o}if(t=e.match(dc)){const r=t.slice(1,4);r[0]=J(r[0],0,100),r[1]=J(oe(r[1],0),0,150,!1),r[2]=+oe(r[2].replace("deg",""),0);const n=dt();xe("d50");const o=ot(zn(r));xe(n);const a=t[4]!==void 0?+J(t[4],0,1):1;return o[3]=a,o}if(t=e.match(bc)){const r=t.slice(1,4);r[0]=J(oe(r[0],0),0,1),r[1]=J(oe(r[1],0),-.4,.4,!0),r[2]=J(oe(r[2],0),-.4,.4,!0);const n=ot(Dn(r)),o=t[4]!==void 0?+J(t[4],0,1):1;return n[3]=o,n}if(t=e.match(pc)){const r=t.slice(1,4);r[0]=J(oe(r[0],0),0,1),r[1]=J(oe(r[1],0),0,.4,!1),r[2]=+oe(r[2].replace("deg",""),0);const n=ot(sc(r)),o=t[4]!==void 0?+J(t[4],0,1):1;return n[3]=o,n}};eo.test=e=>ac.test(e)||uc.test(e)||hc.test(e)||dc.test(e)||bc.test(e)||pc.test(e)||cc.test(e)||ic.test(e)||fc.test(e)||lc.test(e)||e==="transparent",$.prototype.css=function(e){return hl(this._rgb,e)};const dl=(...e)=>new $(...e,"css");B.css=dl,E.format.css=eo,E.autodetect.push({p:5,test:(e,...t)=>{if(!t.length&&j(e)==="string"&&eo.test(e))return"css"}}),E.format.gl=(...e)=>{const t=T(e,"rgba");return t[0]*=255,t[1]*=255,t[2]*=255,t};const bl=(...e)=>new $(...e,"gl");B.gl=bl,$.prototype.gl=function(){const e=this._rgb;return[e[0]/255,e[1]/255,e[2]/255,e[3]]},$.prototype.hex=function(e){return Ea(this._rgb,e)};const pl=(...e)=>new $(...e,"hex");B.hex=pl,E.format.hex=La,E.autodetect.push({p:4,test:(e,...t)=>{if(!t.length&&j(e)==="string"&&[3,4,5,6,7,8,9].indexOf(e.length)>=0)return"hex"}});const{log:Ft}=Math,gc=e=>{const t=e/100;let r,n,o;return t<66?(r=255,n=t<6?0:-155.25485562709179-.44596950469579133*(n=t-2)+104.49216199393888*Ft(n),o=t<20?0:-254.76935184120902+.8274096064007395*(o=t-10)+115.67994401066147*Ft(o)):(r=351.97690566805693+.114206453784165*(r=t-55)-40.25366309332127*Ft(r),n=325.4494125711974+.07943456536662342*(n=t-50)-28.0852963507957*Ft(n),o=255),[r,n,o,1]},{round:ml}=Math,gl=(...e)=>{const t=T(e,"rgb"),r=t[0],n=t[2];let o=1e3,a=4e4;const s=.4;let c;for(;a-o>s;){c=(a+o)*.5;const i=gc(c);i[2]/i[0]>=n/r?a=c:o=c}return ml(c)};$.prototype.temp=$.prototype.kelvin=$.prototype.temperature=function(){return gl(this._rgb)};const to=(...e)=>new $(...e,"temp");Object.assign(B,{temp:to,kelvin:to,temperature:to}),E.format.temp=E.format.kelvin=E.format.temperature=gc,$.prototype.oklch=function(){return oc(this._rgb)},Object.assign(B,{oklch:(...e)=>new $(...e,"oklch")}),E.format.oklch=sc,E.autodetect.push({p:2,test:(...e)=>{if(e=T(e,"oklch"),j(e)==="array"&&e.length===3)return"oklch"}}),Object.assign(B,{analyze:Ka,average:wf,bezier:Hf,blend:pe,brewer:ol,Color:$,colors:We,contrast:Vf,contrastAPCA:Jf,cubehelix:Bf,deltaE:el,distance:tl,input:E,interpolate:Ue,limits:Da,mix:Ue,random:Ff,scale:jt,scales:nl,valid:rl});function vl(e){return+`${Math.ceil(`${e}e+2`)}e-2`}const vc=e=>{const t=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(e);return[Number.parseInt(t[1],16),Number.parseInt(t[2],16),Number.parseInt(t[3],16)]},_c=(e,t,r)=>{const n=e/255,o=t/255,a=r/255,s=Math.min(n,o,a),c=Math.max(n,o,a),i=c-s;let u=0,f=0,l=0;return i===0?u=0:c===n?u=(o-a)/i%6:c===o?u=(a-n)/i+2:u=(n-o)/i+4,u=Math.round(u*60),u<0&&(u+=360),l=(c+s)/2,f=i===0?0:i/(1-Math.abs(2*l-1)),f=+(f*100).toFixed(1),l=+(l*100).toFixed(1),[u,f,Math.round(l)]},_l=(e,t,r,n)=>{const o=r/100,a=t*Math.min(o,1-o)/100,s=d=>{const p=(d+e/30)%12,v=o-a*Math.max(Math.min(p-3,9-p,1),-1);return Math.round(255*v).toString(16).padStart(2,"0").toUpperCase()},c=s(0),i=s(8),u=s(4),l=((d,p,v)=>Math.min(Math.max(d,p),v))(n,0,1),h=Math.round(l*255).toString(16).padStart(2,"0").toUpperCase();return`#${c}${i}${u}${h}`},yl=(e,t,r=1)=>{const n=vc(e),o=vc(t==="white"?"#FFFFFF":t==="black"?"#000000":t),a=n.map((u,f)=>[(u-o[f])/(255-o[f]),(u-o[f])/(0-o[f])]),s=vl(Math.max(...a.flat().filter(u=>/^-?\d+\.?\d*$/.test(u)))),c=n.map((u,f)=>Math.round((u-o[f]+o[f]*s)/s));if(c.includes(Number.NaN)){const u=_c(n[0],n[1],n[2]);return{h:u[0],s:Math.round(u[1]*r),l:u[2],a:1}}const i=_c(c[0],c[1],c[2]);return{h:i[0],s:Math.round(i[1]*r),l:i[2],a:s}},ro={backgroundColor:"gray",colorSpace:"OKLCH",colorSmoothing:!1,formula:"wcag2",output:"HEX",colors:{gray:[X(215,20,90),X(215,8,50),X(215,6,25)],red:[X(358,100,58),X(350,100,30)],orange:[X(32,100,48),X(12,100,30)],yellow:[X(50,100,50),X(25,100,20)],lime:[X(100,68,50),X(115,86,25)],green:[X(163,87,42),X(168,100,25)],cyan:[X(185,80,45),X(200,98,35)],blue:[X(212,98,46),X(222,95,25)],purple:[X(258,94,64),X(265,100,35)],fuchsia:[X(295,56,50),X(285,80,25)],pink:[X(334,90,50),X(330,91,25)]},themes:{light:{ratios:[1.03,1.06,1.12,1.25,1.5,1.75,2.25,3.5,5.25,6.5,8,10.5,13.75,16.75],contrast:1,lightness:100,saturation:100},dark:{ratios:[1.03,1.06,1.12,1.25,1.5,1.75,2.25,3.5,5.25,6.5,8,10.5,13.75,16],contrast:1,lightness:6,saturation:97},lightHc:{ratios:[1.06,1.12,1.25,1.37,1.75,2.25,3.25,4.75,8.87,10,11.75,13.25,16,17],contrast:1,lightness:100,saturation:100},darkHc:{ratios:[1.06,1.12,1.25,1.37,1.75,2.25,3.25,4.75,8.87,10,11.75,13.25,16,17],contrast:1,lightness:6,saturation:97}}};function X(e,t,r){return B.hsl(e,t/100,r/100).hex()}function wl(e,t){const r=e.colorSpace,n=e.colorSmoothing,o=e.themes[t].ratios,a=new qa({name:"gray",colorKeys:e.colors.gray,colorspace:r,ratios:o,smooth:n}),s=new ae({name:"blue",colorKeys:e.colors.blue,colorspace:r,ratios:o,smooth:n}),c=new ae({name:"cyan",colorKeys:e.colors.cyan,colorspace:r,ratios:o,smooth:n}),i=new ae({name:"fuchsia",colorKeys:e.colors.fuchsia,colorspace:r,ratios:o,smooth:n}),u=new ae({name:"green",colorKeys:e.colors.green,colorspace:r,ratios:o,smooth:n}),f=new ae({name:"lime",colorKeys:e.colors.lime,colorspace:r,ratios:o,smooth:n}),l=new ae({name:"orange",colorKeys:e.colors.orange,colorspace:r,ratios:o,smooth:n}),h=new ae({name:"pink",colorKeys:e.colors.pink,colorspace:r,ratios:o,smooth:n}),d=new ae({name:"purple",colorKeys:e.colors.purple,colorspace:r,ratios:o,smooth:n}),p=new ae({name:"red",colorKeys:e.colors.red,colorspace:r,ratios:o,smooth:n}),v=new ae({name:"yellow",colorKeys:e.colors.yellow,colorspace:r,ratios:o,smooth:n}),m={gray:a,red:p,orange:l,yellow:v,lime:f,green:u,cyan:c,blue:s,purple:d,fuchsia:i,pink:h};return e.colors.custom&&(m.custom=new ae({name:"custom",colorKeys:e.colors.custom,colorspace:r,ratios:o,smooth:n})),new wu({colors:Object.values(m),backgroundColor:m[e.backgroundColor],contrast:e.themes[t].contrast,lightness:e.themes[t].lightness,saturation:e.themes[t].saturation,output:e.output,formula:e.formula}).contrastColors}function yc(e){const t={};for(const r of Object.keys(e.themes))t[r]=wl(e,r);return t}function kl(e){ro.colors.custom=[e];const t=yc(ro);return Object.fromEntries(Object.entries(t).map(([r,n])=>{const o=n.find(s=>s&&s.name==="custom"),a=Object.fromEntries(o.values.map(({name:s,value:c})=>[s,c]));for(const[s,c]of Object.entries(a)){const i=yl(c,n[0].background);a[`alpha${s.charAt(0).toUpperCase()+s.slice(1)}`]=_l(i.h,i.s,i.l,i.a)}return[r,a]}))}return Fe.generateCustomColors=kl,Fe.generateThemesJson=yc,Fe.hslToHex=X,Fe.leonardoConfig=ro,Object.defineProperty(Fe,Symbol.toStringTag,{value:"Module"}),Fe})({}); diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/annotations/CoreColorToken.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/annotations/CoreColorToken.kt new file mode 100644 index 0000000..ad9e1ea --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/annotations/CoreColorToken.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.annotations + +@RequiresOptIn( + message = "This is a Core color token, which should only be used to declare semantic colors, otherwise it" + + " would look the same on both light and dark modes. Only use it as is if you know what you are doing." +) +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS) +annotation class CoreColorToken diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/colors/SemanticColorsLightDark.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/colors/SemanticColorsLightDark.kt new file mode 100644 index 0000000..0906410 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/colors/SemanticColorsLightDark.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.colors + +import io.element.android.compound.tokens.generated.SemanticColors +import io.element.android.compound.tokens.generated.compoundColorsDark +import io.element.android.compound.tokens.generated.compoundColorsLight + +data class SemanticColorsLightDark( + val light: SemanticColors, + val dark: SemanticColors, +) { + companion object { + val default = SemanticColorsLightDark( + light = compoundColorsLight, + dark = compoundColorsDark, + ) + } +} diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/previews/ColorListPreview.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/previews/ColorListPreview.kt new file mode 100644 index 0000000..7613488 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/previews/ColorListPreview.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.previews + +import androidx.compose.foundation.background +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.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 +import kotlin.math.ceil + +@Composable +fun ColorListPreview( + backgroundColor: Color, + foregroundColor: Color, + colors: ImmutableMap, + modifier: Modifier = Modifier, + numColumns: Int = 1, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + colors.keys + .chunked(ceil(colors.keys.size / numColumns.toDouble()).toInt()) + .forEach { subList -> + Column( + modifier = Modifier + .background(color = backgroundColor) + .weight(1f) + ) { + subList.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/compound/src/main/kotlin/io/element/android/compound/previews/ColorPreview.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/previews/ColorPreview.kt new file mode 100644 index 0000000..396fd9e --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/previews/ColorPreview.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.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.material3.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.compound.utils.toHrf + +@Composable +fun ColorPreview( + backgroundColor: Color, + foregroundColor: Color, + name: String, + color: Color, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + modifier = Modifier.padding(horizontal = 10.dp), + 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.White) + .background(color = color) + .height(10.dp) + .weight(1f) + ) + } + } + } +} diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/previews/ColorsSchemePreview.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/previews/ColorsSchemePreview.kt new file mode 100644 index 0000000..f44e247 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/previews/ColorsSchemePreview.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.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/compound/src/main/kotlin/io/element/android/compound/previews/CompoundIconsPreview.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/previews/CompoundIconsPreview.kt new file mode 100644 index 0000000..236e362 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/previews/CompoundIconsPreview.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.previews + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Preview(widthDp = 730, heightDp = 1920) +@Composable +internal fun IconsCompoundPreviewLight() = ElementTheme { + IconsCompoundPreview() +} + +@Preview(widthDp = 730, heightDp = 1920) +@Composable +internal fun IconsCompoundPreviewRtl() = ElementTheme { + CompositionLocalProvider( + LocalLayoutDirection provides LayoutDirection.Rtl, + ) { + IconsCompoundPreview( + title = "Compound Icons Rtl", + ) + } +} + +@Preview(widthDp = 730, heightDp = 1920) +@Composable +internal fun IconsCompoundPreviewDark() = ElementTheme(darkTheme = true) { + IconsCompoundPreview() +} + +@Composable +private fun IconsCompoundPreview( + title: String = "Compound Icons", +) { + val context = LocalContext.current + val content: Sequence<@Composable ColumnScope.() -> Unit> = sequence { + for (icon in CompoundIcons.allResIds) { + yield { + Icon( + modifier = Modifier.size(32.dp), + imageVector = ImageVector.vectorResource(icon), + contentDescription = null, + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = context.resources.getResourceEntryName(icon) + .removePrefix("ic_compound_") + .replace("_", " "), + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodyXsMedium, + color = ElementTheme.colors.textSecondary, + ) + } + } + } + IconsPreview( + title = title, + content = content.toImmutableList(), + ) +} + +@Composable +internal fun IconsPreview( + title: String, + content: ImmutableList<@Composable ColumnScope.() -> Unit>, +) = Surface { + Column( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(16.dp) + .width(IntrinsicSize.Max), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + style = ElementTheme.typography.fontHeadingSmMedium, + text = title, + textAlign = TextAlign.Center, + ) + content.chunked(10).forEach { chunk -> + Row( + modifier = Modifier.height(IntrinsicSize.Max), + // Keep same order of icons for an easier comparison of previews + horizontalArrangement = Arrangement.Absolute.Left, + ) { + chunk.forEachIndexed { index, icon -> + Column( + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxHeight() + .width(64.dp) + .padding(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + icon() + } + if (index < chunk.size - 1) { + Spacer(modifier = Modifier.width(6.dp)) + } + } + } + } + } +} diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/previews/SemanticColorsPreview.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/previews/SemanticColorsPreview.kt new file mode 100644 index 0000000..3f5fb42 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/previews/SemanticColorsPreview.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.previews + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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 io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.compoundColorsHcDark +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf + +@Preview(heightDp = 2000) +@Composable +internal fun CompoundSemanticColorsLight() = ElementTheme { + Surface { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text("Compound Semantic Colors - Light") + ColorListPreview( + backgroundColor = Color.White, + foregroundColor = Color.Black, + colors = getSemanticColors(), + numColumns = 2, + ) + } + } +} + +@Preview(heightDp = 2000) +@Composable +internal fun CompoundSemanticColorsLightHc() = ElementTheme( + compoundDark = compoundColorsHcDark, +) { + Surface { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text("Compound Semantic Colors - Light HC") + ColorListPreview( + backgroundColor = Color.White, + foregroundColor = Color.Black, + colors = getSemanticColors(), + numColumns = 2, + ) + } + } +} + +@Preview(heightDp = 2000) +@Composable +internal fun CompoundSemanticColorsDark() = ElementTheme(darkTheme = true) { + Surface { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text("Compound Semantic Colors - Dark") + ColorListPreview( + backgroundColor = Color.White, + foregroundColor = Color.Black, + colors = getSemanticColors(), + numColumns = 2, + ) + } + } +} + +@Preview(heightDp = 2000) +@Composable +internal fun CompoundSemanticColorsDarkHc() = ElementTheme( + darkTheme = true, + compoundDark = compoundColorsHcDark, +) { + Surface { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text("Compound Semantic Colors - Dark HC") + ColorListPreview( + backgroundColor = Color.White, + foregroundColor = Color.Black, + colors = getSemanticColors(), + numColumns = 2, + ) + } + } +} + +@Composable +private fun getSemanticColors(): ImmutableMap { + return with(ElementTheme.colors) { + persistentMapOf( + "bgAccentHovered" to bgAccentHovered, + "bgAccentPressed" to bgAccentPressed, + "bgAccentRest" to bgAccentRest, + "bgAccentSelected" to bgAccentSelected, + "bgActionPrimaryDisabled" to bgActionPrimaryDisabled, + "bgActionPrimaryHovered" to bgActionPrimaryHovered, + "bgActionPrimaryPressed" to bgActionPrimaryPressed, + "bgActionPrimaryRest" to bgActionPrimaryRest, + "bgActionSecondaryHovered" to bgActionSecondaryHovered, + "bgActionSecondaryPressed" to bgActionSecondaryPressed, + "bgActionSecondaryRest" to bgActionSecondaryRest, + "bgBadgeAccent" to bgBadgeAccent, + "bgBadgeDefault" to bgBadgeDefault, + "bgBadgeInfo" to bgBadgeInfo, + "bgCanvasDefault" to bgCanvasDefault, + "bgCanvasDefaultLevel1" to bgCanvasDefaultLevel1, + "bgCanvasDisabled" to bgCanvasDisabled, + "bgCriticalHovered" to bgCriticalHovered, + "bgCriticalPrimary" to bgCriticalPrimary, + "bgCriticalSubtle" to bgCriticalSubtle, + "bgCriticalSubtleHovered" to bgCriticalSubtleHovered, + "bgDecorative1" to bgDecorative1, + "bgDecorative2" to bgDecorative2, + "bgDecorative3" to bgDecorative3, + "bgDecorative4" to bgDecorative4, + "bgDecorative5" to bgDecorative5, + "bgDecorative6" to bgDecorative6, + "bgInfoSubtle" to bgInfoSubtle, + "bgSubtlePrimary" to bgSubtlePrimary, + "bgSubtleSecondary" to bgSubtleSecondary, + "bgSubtleSecondaryLevel0" to bgSubtleSecondaryLevel0, + "bgSuccessSubtle" to bgSuccessSubtle, + "borderAccentSubtle" to borderAccentSubtle, + "borderCriticalHovered" to borderCriticalHovered, + "borderCriticalPrimary" to borderCriticalPrimary, + "borderCriticalSubtle" to borderCriticalSubtle, + "borderDisabled" to borderDisabled, + "borderFocused" to borderFocused, + "borderInfoSubtle" to borderInfoSubtle, + "borderInteractiveHovered" to borderInteractiveHovered, + "borderInteractivePrimary" to borderInteractivePrimary, + "borderInteractiveSecondary" to borderInteractiveSecondary, + "borderSuccessSubtle" to borderSuccessSubtle, + "gradientActionStop1" to gradientActionStop1, + "gradientActionStop2" to gradientActionStop2, + "gradientActionStop3" to gradientActionStop3, + "gradientActionStop4" to gradientActionStop4, + "gradientInfoStop1" to gradientInfoStop1, + "gradientInfoStop2" to gradientInfoStop2, + "gradientInfoStop3" to gradientInfoStop3, + "gradientInfoStop4" to gradientInfoStop4, + "gradientInfoStop5" to gradientInfoStop5, + "gradientInfoStop6" to gradientInfoStop6, + "gradientSubtleStop1" to gradientSubtleStop1, + "gradientSubtleStop2" to gradientSubtleStop2, + "gradientSubtleStop3" to gradientSubtleStop3, + "gradientSubtleStop4" to gradientSubtleStop4, + "gradientSubtleStop5" to gradientSubtleStop5, + "gradientSubtleStop6" to gradientSubtleStop6, + "iconAccentPrimary" to iconAccentPrimary, + "iconAccentTertiary" to iconAccentTertiary, + "iconCriticalPrimary" to iconCriticalPrimary, + "iconDisabled" to iconDisabled, + "iconInfoPrimary" to iconInfoPrimary, + "iconOnSolidPrimary" to iconOnSolidPrimary, + "iconPrimary" to iconPrimary, + "iconPrimaryAlpha" to iconPrimaryAlpha, + "iconQuaternary" to iconQuaternary, + "iconQuaternaryAlpha" to iconQuaternaryAlpha, + "iconSecondary" to iconSecondary, + "iconSecondaryAlpha" to iconSecondaryAlpha, + "iconSuccessPrimary" to iconSuccessPrimary, + "iconTertiary" to iconTertiary, + "iconTertiaryAlpha" to iconTertiaryAlpha, + "textActionAccent" to textActionAccent, + "textActionPrimary" to textActionPrimary, + "textBadgeAccent" to textBadgeAccent, + "textBadgeInfo" to textBadgeInfo, + "textCriticalPrimary" to textCriticalPrimary, + "textDecorative1" to textDecorative1, + "textDecorative2" to textDecorative2, + "textDecorative3" to textDecorative3, + "textDecorative4" to textDecorative4, + "textDecorative5" to textDecorative5, + "textDecorative6" to textDecorative6, + "textDisabled" to textDisabled, + "textInfoPrimary" to textInfoPrimary, + "textLinkExternal" to textLinkExternal, + "textOnSolidPrimary" to textOnSolidPrimary, + "textPrimary" to textPrimary, + "textSecondary" to textSecondary, + "textSuccessPrimary" to textSuccessPrimary, + "isLight" to if (isLight) Color.White else Color.Black, + ) + } +} diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/previews/Typography.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/previews/Typography.kt new file mode 100644 index 0000000..761bf12 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/previews/Typography.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.previews + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme + +@Preview +@Composable +internal fun TypographyPreview() = ElementTheme { + Surface { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + with(ElementTheme.materialTypography) { + TypographyTokenPreview(displayLarge, "Display large") + TypographyTokenPreview(displayMedium, "Display medium") + TypographyTokenPreview(displaySmall, "Display small") + TypographyTokenPreview(headlineLarge, "Headline large") + TypographyTokenPreview(headlineMedium, "Headline medium") + TypographyTokenPreview(headlineSmall, "Headline small") + TypographyTokenPreview(titleLarge, "Title large") + TypographyTokenPreview(titleMedium, "Title medium") + TypographyTokenPreview(titleSmall, "Title small") + TypographyTokenPreview(bodyLarge, "Body large") + TypographyTokenPreview(bodyMedium, "Body medium") + TypographyTokenPreview(bodySmall, "Body small") + TypographyTokenPreview(labelLarge, "Label large") + TypographyTokenPreview(labelMedium, "Label medium") + TypographyTokenPreview(labelSmall, "Label small") + } + } + } +} + +@Composable +private fun TypographyTokenPreview(style: TextStyle, text: String) { + Text(text = text, style = style) +} diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/showkase/CompoundShowkaseRootModule.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/showkase/CompoundShowkaseRootModule.kt new file mode 100644 index 0000000..4f36d0c --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/showkase/CompoundShowkaseRootModule.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.showkase + +import com.airbnb.android.showkase.annotation.ShowkaseRoot +import com.airbnb.android.showkase.annotation.ShowkaseRootModule + +@ShowkaseRoot +class CompoundShowkaseRootModule : ShowkaseRootModule diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/theme/AvatarColors.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/AvatarColors.kt new file mode 100644 index 0000000..763f422 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/AvatarColors.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.theme + +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.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +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 kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/** + * Data class to hold avatar colors. + */ +data class AvatarColors( + /** Background color for the avatar. */ + val background: Color, + /** Foreground color for the avatar. */ + val foreground: Color, +) + +/** + * Avatar colors using semantic tokens. + */ +@Composable +fun avatarColors(): List { + return listOf( + AvatarColors(background = ElementTheme.colors.bgDecorative1, foreground = ElementTheme.colors.textDecorative1), + AvatarColors(background = ElementTheme.colors.bgDecorative2, foreground = ElementTheme.colors.textDecorative2), + AvatarColors(background = ElementTheme.colors.bgDecorative3, foreground = ElementTheme.colors.textDecorative3), + AvatarColors(background = ElementTheme.colors.bgDecorative4, foreground = ElementTheme.colors.textDecorative4), + AvatarColors(background = ElementTheme.colors.bgDecorative5, foreground = ElementTheme.colors.textDecorative5), + AvatarColors(background = ElementTheme.colors.bgDecorative6, foreground = ElementTheme.colors.textDecorative6), + ) +} + +@Preview +@Composable +internal fun AvatarColorsPreviewLight() { + ElementTheme { + val chunks = avatarColors().chunked(4) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + for (chunk in chunks) { + AvatarColorRow(chunk.toImmutableList()) + } + } + } +} + +@Preview +@Composable +internal fun AvatarColorsPreviewDark() { + ElementTheme(darkTheme = true) { + val chunks = avatarColors().chunked(4) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + for (chunk in chunks) { + AvatarColorRow(chunk.toImmutableList()) + } + } + } +} + +@Composable +private fun AvatarColorRow(colors: ImmutableList) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + colors.forEach { color -> + Box( + modifier = Modifier.size(48.dp) + .background(color.background), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = "A", + color = color.foreground, + ) + } + } + } +} diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/theme/ElementTheme.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/ElementTheme.kt new file mode 100644 index 0000000..0f8e77a --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/ElementTheme.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.theme + +import android.os.Build +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.LocalActivity +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.LocalContentColor +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.LaunchedEffect +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import io.element.android.compound.tokens.compoundTypography +import io.element.android.compound.tokens.generated.SemanticColors +import io.element.android.compound.tokens.generated.TypographyTokens +import io.element.android.compound.tokens.generated.compoundColorsDark +import io.element.android.compound.tokens.generated.compoundColorsLight + +/** + * 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 + + /** + * Material 3 [Typography] tokens. In Figma, these have the `M3 Typography/` prefix. + */ + val materialTypography: Typography + @Composable + @ReadOnlyComposable + get() = MaterialTheme.typography + + /** + * 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 } + +/** + * Sets up the theme for the application, or a part of it. + * + * @param darkTheme whether to use the dark theme or not. If `true`, the dark theme will be used. + * @param applySystemBarsUpdate whether to update the system bars color scheme or not when the theme changes. It's `true` by default. + * This is specially useful when you want to apply an alternate theme to a part of the app but don't want it to affect the system bars. + * @param lightStatusBar whether to use a light status bar color scheme or not. By default, it's the opposite of [darkTheme]. + * @param dynamicColor whether to enable MaterialYou or not. It's `false` by default. + * @param compoundLight the [SemanticColors] to use in light theme. + * @param compoundDark the [SemanticColors] to use in dark theme. + * @param materialColorsLight the Material 3 [ColorScheme] to use in light theme. + * @param materialColorsDark the Material 3 [ColorScheme] to use in dark theme. + * @param typography the Material 3 [Typography] tokens to use. It'll use [compoundTypography] by default. + * @param content the content to apply the theme to. + */ +@Composable +fun ElementTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + applySystemBarsUpdate: Boolean = true, + lightStatusBar: Boolean = !darkTheme, + // true to enable MaterialYou + dynamicColor: Boolean = false, + compoundLight: SemanticColors = compoundColorsLight, + compoundDark: SemanticColors = compoundColorsDark, + materialColorsLight: ColorScheme = compoundLight.toMaterialColorScheme(), + materialColorsDark: ColorScheme = compoundDark.toMaterialColorScheme(), + typography: Typography = compoundTypography, + content: @Composable () -> Unit, +) { + val currentCompoundColor = when { + darkTheme -> compoundDark + else -> compoundLight + } + + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> materialColorsDark + else -> materialColorsLight + } + + val statusBarColorScheme = if (dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val context = LocalContext.current + if (lightStatusBar) { + dynamicDarkColorScheme(context) + } else { + dynamicLightColorScheme(context) + } + } else { + colorScheme + } + + if (applySystemBarsUpdate) { + val activity = LocalActivity.current as? ComponentActivity + LaunchedEffect(statusBarColorScheme, darkTheme, lightStatusBar) { + activity?.enableEdgeToEdge( + // For Status bar use the background color of the app + statusBarStyle = SystemBarStyle.auto( + lightScrim = statusBarColorScheme.background.toArgb(), + darkScrim = statusBarColorScheme.background.toArgb(), + detectDarkMode = { !lightStatusBar } + ), + // For Navigation bar use a transparent color so the content can be seen through it + navigationBarStyle = if (darkTheme) { + SystemBarStyle.dark(Color.Transparent.toArgb()) + } else { + SystemBarStyle.light(Color.Transparent.toArgb(), Color.Transparent.toArgb()) + } + ) + } + } + CompositionLocalProvider( + LocalCompoundColors provides currentCompoundColor, + LocalContentColor provides colorScheme.onSurface, + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = typography, + content = content + ) + } +} diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/theme/ForcedDarkElementTheme.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/ForcedDarkElementTheme.kt new file mode 100644 index 0000000..272245f --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/ForcedDarkElementTheme.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.theme + +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.LocalActivity +import androidx.activity.enableEdgeToEdge +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import io.element.android.compound.colors.SemanticColorsLightDark + +/** + * 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( + colors: SemanticColorsLightDark, + lightStatusBar: Boolean = false, + content: @Composable () -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + val wasDarkTheme = !ElementTheme.colors.isLight + val activity = LocalActivity.current as? ComponentActivity + DisposableEffect(Unit) { + onDispose { + activity?.enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto( + lightScrim = colorScheme.background.toArgb(), + darkScrim = colorScheme.background.toArgb(), + ), + navigationBarStyle = if (wasDarkTheme) { + SystemBarStyle.dark(Color.Transparent.toArgb()) + } else { + SystemBarStyle.light( + scrim = Color.Transparent.toArgb(), + darkScrim = Color.Transparent.toArgb() + ) + } + ) + } + } + ElementTheme( + darkTheme = true, + compoundLight = colors.light, + compoundDark = colors.dark, + lightStatusBar = lightStatusBar, + content = content, + ) +} diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/theme/LegacyColors.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/LegacyColors.kt new file mode 100644 index 0000000..503804f --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/LegacyColors.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.theme + +import androidx.compose.ui.graphics.Color +import io.element.android.compound.annotations.CoreColorToken +import io.element.android.compound.tokens.generated.internal.DarkColorTokens +import io.element.android.compound.tokens.generated.internal.LightColorTokens + +// ================================================================================================= +// 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) + +@OptIn(CoreColorToken::class) +val SnackBarLabelColorLight = LightColorTokens.colorGray700 +@OptIn(CoreColorToken::class) +val SnackBarLabelColorDark = DarkColorTokens.colorGray700 diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/theme/MaterialColorSchemeDark.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/MaterialColorSchemeDark.kt new file mode 100644 index 0000000..6510b61 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/MaterialColorSchemeDark.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import io.element.android.compound.annotations.CoreColorToken +import io.element.android.compound.tokens.generated.SemanticColors +import io.element.android.compound.tokens.generated.internal.DarkColorTokens + +/** + * See the mapping in + * https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=311-14&p=f&t=QcVyNaPEZMDA6RFK-0 + */ +@OptIn(CoreColorToken::class) +fun SemanticColors.toMaterialColorSchemeDark(): ColorScheme = darkColorScheme( + primary = bgActionPrimaryRest, + onPrimary = textOnSolidPrimary, + primaryContainer = bgCanvasDefault, + onPrimaryContainer = textPrimary, + inversePrimary = textOnSolidPrimary, + secondary = textSecondary, + onSecondary = textOnSolidPrimary, + secondaryContainer = bgSubtlePrimary, + onSecondaryContainer = textPrimary, + tertiary = textSecondary, + onTertiary = textOnSolidPrimary, + tertiaryContainer = bgActionPrimaryRest, + onTertiaryContainer = textOnSolidPrimary, + background = bgCanvasDefault, + onBackground = textPrimary, + surface = bgCanvasDefault, + onSurface = textPrimary, + surfaceVariant = bgSubtleSecondary, + onSurfaceVariant = textSecondary, + surfaceTint = DarkColorTokens.colorGray1000, + inverseSurface = DarkColorTokens.colorGray1300, + inverseOnSurface = textOnSolidPrimary, + error = textCriticalPrimary, + onError = textOnSolidPrimary, + errorContainer = DarkColorTokens.colorRed400, + onErrorContainer = textCriticalPrimary, + outline = borderInteractivePrimary, + outlineVariant = DarkColorTokens.colorAlphaGray400, + // Note: for light it will be colorGray1400 + scrim = DarkColorTokens.colorGray300, +) diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/theme/MaterialColorSchemeLight.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/MaterialColorSchemeLight.kt new file mode 100644 index 0000000..df8fa57 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/MaterialColorSchemeLight.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.lightColorScheme +import io.element.android.compound.annotations.CoreColorToken +import io.element.android.compound.tokens.generated.SemanticColors +import io.element.android.compound.tokens.generated.internal.LightColorTokens + +/** + * See the mapping in + * https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=311-14&p=f&t=QcVyNaPEZMDA6RFK-0 + */ +@OptIn(CoreColorToken::class) +fun SemanticColors.toMaterialColorSchemeLight(): ColorScheme = lightColorScheme( + primary = bgActionPrimaryRest, + onPrimary = textOnSolidPrimary, + primaryContainer = bgCanvasDefault, + onPrimaryContainer = textPrimary, + inversePrimary = textOnSolidPrimary, + secondary = textSecondary, + onSecondary = textOnSolidPrimary, + secondaryContainer = bgSubtlePrimary, + onSecondaryContainer = textPrimary, + tertiary = textSecondary, + onTertiary = textOnSolidPrimary, + tertiaryContainer = bgActionPrimaryRest, + onTertiaryContainer = textOnSolidPrimary, + background = bgCanvasDefault, + onBackground = textPrimary, + surface = bgCanvasDefault, + onSurface = textPrimary, + surfaceVariant = bgSubtleSecondary, + onSurfaceVariant = textSecondary, + surfaceTint = LightColorTokens.colorGray1000, + inverseSurface = LightColorTokens.colorGray1300, + inverseOnSurface = textOnSolidPrimary, + error = textCriticalPrimary, + onError = textOnSolidPrimary, + errorContainer = LightColorTokens.colorRed400, + onErrorContainer = textCriticalPrimary, + outline = borderInteractivePrimary, + outlineVariant = LightColorTokens.colorAlphaGray400, + // Note: for dark it will be colorGray300 + scrim = LightColorTokens.colorGray1400, +) diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/theme/MaterialTextPreview.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/MaterialTextPreview.kt new file mode 100644 index 0000000..792c7fb --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/MaterialTextPreview.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.theme + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +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.sp +import io.element.android.compound.utils.toHrf + +@Preview(heightDp = 1200, widthDp = 420) +@Composable +internal fun MaterialTextPreview() = Row( + modifier = Modifier.background(Color.Yellow) +) { + MaterialPreview( + modifier = Modifier.weight(1f), + darkTheme = false, + ) + MaterialPreview( + modifier = Modifier.weight(1f), + darkTheme = true, + ) +} + +private data class Model( + val name: String, + val bgColor: Color, + val textColor: Color, +) + +@Composable +private fun MaterialPreview( + darkTheme: Boolean, + modifier: Modifier = Modifier, +) = Column(modifier = modifier) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + textAlign = TextAlign.Center, + text = if (darkTheme) "Dark" else "Light", + color = Color.Black, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ) + ElementTheme( + darkTheme = darkTheme, + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + listOf( + Model("Background", MaterialTheme.colorScheme.background, MaterialTheme.colorScheme.onBackground), + Model("Primary", MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary), + Model("PrimaryContainer", MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer), + Model("Secondary", MaterialTheme.colorScheme.secondary, MaterialTheme.colorScheme.onSecondary), + Model("SecondaryContainer", MaterialTheme.colorScheme.secondaryContainer, MaterialTheme.colorScheme.onSecondaryContainer), + Model("Tertiary", MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.onTertiary), + Model("TertiaryContainer", MaterialTheme.colorScheme.tertiaryContainer, MaterialTheme.colorScheme.onTertiaryContainer), + Model("Surface", MaterialTheme.colorScheme.surface, MaterialTheme.colorScheme.onSurface), + Model("SurfaceVariant", MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant), + Model("InverseSurface", MaterialTheme.colorScheme.inverseSurface, MaterialTheme.colorScheme.inverseOnSurface), + Model("Error", MaterialTheme.colorScheme.error, MaterialTheme.colorScheme.onError), + Model("ErrorContainer", MaterialTheme.colorScheme.errorContainer, MaterialTheme.colorScheme.onErrorContainer), + ).forEach { + TextPreview( + name = it.name, + bgColor = it.bgColor, + textColor = it.textColor, + ) + } + Box( + modifier = Modifier + .padding(1.dp) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + ) { + Text( + text = "Below\n".repeat(3), + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + modifier = Modifier + .padding(12.dp) + .fillMaxWidth() + // the alpha applied to the scrim color does not seem to be mandatory. + // The library ignores the alpha level provided and apply it's own. + // For testing the color, manually set an alpha. + .background(color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)) + .padding(16.dp), + text = "${"Scrim"}\n${MaterialTheme.colorScheme.scrim.toHrf()}", + style = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + } +} + +@Composable +private fun TextPreview( + name: String, + bgColor: Color, + textColor: Color, + modifier: Modifier = Modifier, +) = Text( + modifier = modifier + .padding(1.dp) + .fillMaxWidth() + .background(bgColor) + .padding(horizontal = 16.dp, vertical = 8.dp), + text = "$name\n${textColor.toHrf()}\n${bgColor.toHrf()}", + style = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace), + textAlign = TextAlign.Center, + color = textColor, +) diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/theme/MaterialThemeColors.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/MaterialThemeColors.kt new file mode 100644 index 0000000..c2a923e --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/MaterialThemeColors.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.compound.previews.ColorsSchemePreview +import io.element.android.compound.tokens.generated.SemanticColors +import io.element.android.compound.tokens.generated.compoundColorsHcDark +import io.element.android.compound.tokens.generated.compoundColorsHcLight + +fun SemanticColors.toMaterialColorScheme(): ColorScheme { + return if (isLight) { + toMaterialColorSchemeLight() + } else { + toMaterialColorSchemeDark() + } +} + +@Preview(heightDp = 1200) +@Composable +internal fun ColorsSchemeLightPreview() = ElementTheme { + ColorsSchemePreview( + Color.Black, + Color.White, + ElementTheme.materialColors, + ) +} + +@Preview(heightDp = 1200) +@Composable +internal fun ColorsSchemeLightHcPreview() = ElementTheme( + compoundLight = compoundColorsHcLight, +) { + ColorsSchemePreview( + Color.Black, + Color.White, + ElementTheme.materialColors, + ) +} + +@Preview(heightDp = 1200) +@Composable +internal fun ColorsSchemeDarkPreview() = ElementTheme( + darkTheme = true, +) { + ColorsSchemePreview( + Color.White, + Color.Black, + ElementTheme.materialColors, + ) +} + +@Preview(heightDp = 1200) +@Composable +internal fun ColorsSchemeDarkHcPreview() = ElementTheme( + darkTheme = true, + compoundDark = compoundColorsHcDark, +) { + ColorsSchemePreview( + Color.White, + Color.Black, + ElementTheme.materialColors, + ) +} diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/theme/Theme.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/Theme.kt new file mode 100644 index 0000000..131b144 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/Theme.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +enum class Theme { + System, + Dark, + Light, +} + +@Composable +fun Theme.isDark(): Boolean { + return when (this) { + Theme.System -> isSystemInDarkTheme() + Theme.Dark -> true + Theme.Light -> false + } +} + +fun Flow.mapToTheme(): Flow = map { + when (it) { + null -> Theme.System + else -> Theme.valueOf(it) + } +} diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/CompoundTypography.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/CompoundTypography.kt new file mode 100644 index 0000000..9417a30 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/CompoundTypography.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.tokens + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import com.airbnb.android.showkase.annotation.ShowkaseTypography +import io.element.android.compound.tokens.generated.TypographyTokens + +// 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, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) +) + +// 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/compound/src/main/kotlin/io/element/android/compound/tokens/generated/CompoundIcons.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/CompoundIcons.kt new file mode 100644 index 0000000..a8ae1cf --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/CompoundIcons.kt @@ -0,0 +1,1035 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + + +/** + * !!! WARNING !!! + * + * THIS IS AN AUTOGENERATED FILE. + * DO NOT EDIT MANUALLY. + */ + + + +@file:Suppress("all") +package io.element.android.compound.tokens.generated + +import io.element.android.compound.R +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import kotlinx.collections.immutable.persistentListOf + +object CompoundIcons { + @Composable fun Admin(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_admin) + } + @Composable fun ArrowDown(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_arrow_down) + } + @Composable fun ArrowLeft(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_arrow_left) + } + @Composable fun ArrowRight(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_arrow_right) + } + @Composable fun ArrowUp(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_arrow_up) + } + @Composable fun ArrowUpRight(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_arrow_up_right) + } + @Composable fun AskToJoin(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_ask_to_join) + } + @Composable fun AskToJoinSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_ask_to_join_solid) + } + @Composable fun Attachment(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_attachment) + } + @Composable fun Audio(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_audio) + } + @Composable fun Block(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_block) + } + @Composable fun Bold(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_bold) + } + @Composable fun Calendar(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_calendar) + } + @Composable fun Chart(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_chart) + } + @Composable fun Chat(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_chat) + } + @Composable fun ChatNew(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_chat_new) + } + @Composable fun ChatProblem(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_chat_problem) + } + @Composable fun ChatSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_chat_solid) + } + @Composable fun Check(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_check) + } + @Composable fun CheckCircle(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_check_circle) + } + @Composable fun CheckCircleSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_check_circle_solid) + } + @Composable fun ChevronDown(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_chevron_down) + } + @Composable fun ChevronLeft(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_chevron_left) + } + @Composable fun ChevronRight(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_chevron_right) + } + @Composable fun ChevronUp(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_chevron_up) + } + @Composable fun ChevronUpDown(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_chevron_up_down) + } + @Composable fun Circle(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_circle) + } + @Composable fun Close(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_close) + } + @Composable fun Cloud(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_cloud) + } + @Composable fun CloudSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_cloud_solid) + } + @Composable fun Code(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_code) + } + @Composable fun Collapse(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_collapse) + } + @Composable fun Company(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_company) + } + @Composable fun Compose(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_compose) + } + @Composable fun Computer(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_computer) + } + @Composable fun Copy(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_copy) + } + @Composable fun DarkMode(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_dark_mode) + } + @Composable fun Delete(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_delete) + } + @Composable fun Devices(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_devices) + } + @Composable fun DialPad(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_dial_pad) + } + @Composable fun Document(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_document) + } + @Composable fun Download(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_download) + } + @Composable fun DownloadIos(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_download_ios) + } + @Composable fun DragGrid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_drag_grid) + } + @Composable fun DragList(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_drag_list) + } + @Composable fun Earpiece(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_earpiece) + } + @Composable fun Edit(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_edit) + } + @Composable fun EditSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_edit_solid) + } + @Composable fun Email(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_email) + } + @Composable fun EmailSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_email_solid) + } + @Composable fun EndCall(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_end_call) + } + @Composable fun Error(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_error) + } + @Composable fun ErrorSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_error_solid) + } + @Composable fun Expand(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_expand) + } + @Composable fun Explore(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_explore) + } + @Composable fun ExportArchive(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_export_archive) + } + @Composable fun Extensions(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_extensions) + } + @Composable fun ExtensionsSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_extensions_solid) + } + @Composable fun Favourite(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_favourite) + } + @Composable fun FavouriteSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_favourite_solid) + } + @Composable fun FileError(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_file_error) + } + @Composable fun Files(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_files) + } + @Composable fun Filter(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_filter) + } + @Composable fun Forward(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_forward) + } + @Composable fun Grid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_grid) + } + @Composable fun Group(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_group) + } + @Composable fun Guest(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_guest) + } + @Composable fun HeadphonesOffSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_headphones_off_solid) + } + @Composable fun HeadphonesSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_headphones_solid) + } + @Composable fun Help(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_help) + } + @Composable fun HelpSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_help_solid) + } + @Composable fun History(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_history) + } + @Composable fun Home(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_home) + } + @Composable fun HomeSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_home_solid) + } + @Composable fun Host(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_host) + } + @Composable fun Image(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_image) + } + @Composable fun ImageError(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_image_error) + } + @Composable fun IndentDecrease(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_indent_decrease) + } + @Composable fun IndentIncrease(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_indent_increase) + } + @Composable fun Info(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_info) + } + @Composable fun InfoSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_info_solid) + } + @Composable fun InlineCode(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_inline_code) + } + @Composable fun Italic(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_italic) + } + @Composable fun Key(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_key) + } + @Composable fun KeyOff(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_key_off) + } + @Composable fun KeyOffSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_key_off_solid) + } + @Composable fun KeySolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_key_solid) + } + @Composable fun Keyboard(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_keyboard) + } + @Composable fun Labs(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_labs) + } + @Composable fun Leave(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_leave) + } + @Composable fun Link(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_link) + } + @Composable fun Linux(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_linux) + } + @Composable fun ListBulleted(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_list_bulleted) + } + @Composable fun ListNumbered(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_list_numbered) + } + @Composable fun ListView(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_list_view) + } + @Composable fun LocationNavigator(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_location_navigator) + } + @Composable fun LocationNavigatorCentred(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_location_navigator_centred) + } + @Composable fun LocationPin(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_location_pin) + } + @Composable fun LocationPinSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_location_pin_solid) + } + @Composable fun Lock(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_lock) + } + @Composable fun LockOff(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_lock_off) + } + @Composable fun LockSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_lock_solid) + } + @Composable fun Mac(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_mac) + } + @Composable fun MarkAsRead(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_mark_as_read) + } + @Composable fun MarkAsUnread(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_mark_as_unread) + } + @Composable fun MarkThreadsAsRead(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_mark_threads_as_read) + } + @Composable fun MarkerReadReceipts(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_marker_read_receipts) + } + @Composable fun Mention(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_mention) + } + @Composable fun Menu(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_menu) + } + @Composable fun MicOff(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_mic_off) + } + @Composable fun MicOffSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_mic_off_solid) + } + @Composable fun MicOn(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_mic_on) + } + @Composable fun MicOnSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_mic_on_solid) + } + @Composable fun Minus(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_minus) + } + @Composable fun Mobile(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_mobile) + } + @Composable fun Notifications(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_notifications) + } + @Composable fun NotificationsOff(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_notifications_off) + } + @Composable fun NotificationsOffSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_notifications_off_solid) + } + @Composable fun NotificationsSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_notifications_solid) + } + @Composable fun Offline(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_offline) + } + @Composable fun OverflowHorizontal(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_overflow_horizontal) + } + @Composable fun OverflowVertical(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_overflow_vertical) + } + @Composable fun Pause(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_pause) + } + @Composable fun PauseSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_pause_solid) + } + @Composable fun Pin(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_pin) + } + @Composable fun PinSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_pin_solid) + } + @Composable fun Play(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_play) + } + @Composable fun PlaySolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_play_solid) + } + @Composable fun Plus(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_plus) + } + @Composable fun Polls(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_polls) + } + @Composable fun PollsEnd(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_polls_end) + } + @Composable fun PopOut(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_pop_out) + } + @Composable fun Preferences(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_preferences) + } + @Composable fun PresenceOutline8X8(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_presence_outline_8_x_8) + } + @Composable fun PresenceSolid8X8(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_presence_solid_8_x_8) + } + @Composable fun PresenceStrikethrough8X8(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_presence_strikethrough_8_x_8) + } + @Composable fun Public(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_public) + } + @Composable fun QrCode(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_qr_code) + } + @Composable fun Quote(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_quote) + } + @Composable fun RaisedHandSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_raised_hand_solid) + } + @Composable fun Reaction(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_reaction) + } + @Composable fun ReactionAdd(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_reaction_add) + } + @Composable fun ReactionSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_reaction_solid) + } + @Composable fun Reply(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_reply) + } + @Composable fun Restart(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_restart) + } + @Composable fun Room(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_room) + } + @Composable fun Search(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_search) + } + @Composable fun Send(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_send) + } + @Composable fun SendSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_send_solid) + } + @Composable fun Settings(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_settings) + } + @Composable fun SettingsSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_settings_solid) + } + @Composable fun Share(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_share) + } + @Composable fun ShareAndroid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_share_android) + } + @Composable fun ShareIos(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_share_ios) + } + @Composable fun ShareScreen(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_share_screen) + } + @Composable fun ShareScreenSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_share_screen_solid) + } + @Composable fun Shield(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_shield) + } + @Composable fun Sidebar(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_sidebar) + } + @Composable fun SignOut(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_sign_out) + } + @Composable fun Spinner(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_spinner) + } + @Composable fun Spotlight(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_spotlight) + } + @Composable fun SpotlightView(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_spotlight_view) + } + @Composable fun Strikethrough(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_strikethrough) + } + @Composable fun SwitchCameraSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_switch_camera_solid) + } + @Composable fun TakePhoto(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_take_photo) + } + @Composable fun TakePhotoSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_take_photo_solid) + } + @Composable fun TextFormatting(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_text_formatting) + } + @Composable fun Threads(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_threads) + } + @Composable fun ThreadsSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_threads_solid) + } + @Composable fun Time(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_time) + } + @Composable fun Underline(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_underline) + } + @Composable fun Unknown(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_unknown) + } + @Composable fun UnknownSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_unknown_solid) + } + @Composable fun Unpin(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_unpin) + } + @Composable fun User(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_user) + } + @Composable fun UserAdd(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_user_add) + } + @Composable fun UserAddSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_user_add_solid) + } + @Composable fun UserProfile(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_user_profile) + } + @Composable fun UserProfileSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_user_profile_solid) + } + @Composable fun UserSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_user_solid) + } + @Composable fun Verified(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_verified) + } + @Composable fun VideoCall(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_video_call) + } + @Composable fun VideoCallDeclinedSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_video_call_declined_solid) + } + @Composable fun VideoCallMissedSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_video_call_missed_solid) + } + @Composable fun VideoCallOff(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_video_call_off) + } + @Composable fun VideoCallOffSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_video_call_off_solid) + } + @Composable fun VideoCallSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_video_call_solid) + } + @Composable fun VisibilityOff(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_visibility_off) + } + @Composable fun VisibilityOn(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_visibility_on) + } + @Composable fun VoiceCall(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_voice_call) + } + @Composable fun VoiceCallSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_solid) + } + @Composable fun VolumeOff(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_volume_off) + } + @Composable fun VolumeOffSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_volume_off_solid) + } + @Composable fun VolumeOn(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_volume_on) + } + @Composable fun VolumeOnSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_volume_on_solid) + } + @Composable fun Warning(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_warning) + } + @Composable fun WebBrowser(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_web_browser) + } + @Composable fun Windows(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_windows) + } + @Composable fun Workspace(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_workspace) + } + @Composable fun WorkspaceSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_workspace_solid) + } + + val all @Composable get() = persistentListOf( + Admin(), + ArrowDown(), + ArrowLeft(), + ArrowRight(), + ArrowUp(), + ArrowUpRight(), + AskToJoin(), + AskToJoinSolid(), + Attachment(), + Audio(), + Block(), + Bold(), + Calendar(), + Chart(), + Chat(), + ChatNew(), + ChatProblem(), + ChatSolid(), + Check(), + CheckCircle(), + CheckCircleSolid(), + ChevronDown(), + ChevronLeft(), + ChevronRight(), + ChevronUp(), + ChevronUpDown(), + Circle(), + Close(), + Cloud(), + CloudSolid(), + Code(), + Collapse(), + Company(), + Compose(), + Computer(), + Copy(), + DarkMode(), + Delete(), + Devices(), + DialPad(), + Document(), + Download(), + DownloadIos(), + DragGrid(), + DragList(), + Earpiece(), + Edit(), + EditSolid(), + Email(), + EmailSolid(), + EndCall(), + Error(), + ErrorSolid(), + Expand(), + Explore(), + ExportArchive(), + Extensions(), + ExtensionsSolid(), + Favourite(), + FavouriteSolid(), + FileError(), + Files(), + Filter(), + Forward(), + Grid(), + Group(), + Guest(), + HeadphonesOffSolid(), + HeadphonesSolid(), + Help(), + HelpSolid(), + History(), + Home(), + HomeSolid(), + Host(), + Image(), + ImageError(), + IndentDecrease(), + IndentIncrease(), + Info(), + InfoSolid(), + InlineCode(), + Italic(), + Key(), + KeyOff(), + KeyOffSolid(), + KeySolid(), + Keyboard(), + Labs(), + Leave(), + Link(), + Linux(), + ListBulleted(), + ListNumbered(), + ListView(), + LocationNavigator(), + LocationNavigatorCentred(), + LocationPin(), + LocationPinSolid(), + Lock(), + LockOff(), + LockSolid(), + Mac(), + MarkAsRead(), + MarkAsUnread(), + MarkThreadsAsRead(), + MarkerReadReceipts(), + Mention(), + Menu(), + MicOff(), + MicOffSolid(), + MicOn(), + MicOnSolid(), + Minus(), + Mobile(), + Notifications(), + NotificationsOff(), + NotificationsOffSolid(), + NotificationsSolid(), + Offline(), + OverflowHorizontal(), + OverflowVertical(), + Pause(), + PauseSolid(), + Pin(), + PinSolid(), + Play(), + PlaySolid(), + Plus(), + Polls(), + PollsEnd(), + PopOut(), + Preferences(), + PresenceOutline8X8(), + PresenceSolid8X8(), + PresenceStrikethrough8X8(), + Public(), + QrCode(), + Quote(), + RaisedHandSolid(), + Reaction(), + ReactionAdd(), + ReactionSolid(), + Reply(), + Restart(), + Room(), + Search(), + Send(), + SendSolid(), + Settings(), + SettingsSolid(), + Share(), + ShareAndroid(), + ShareIos(), + ShareScreen(), + ShareScreenSolid(), + Shield(), + Sidebar(), + SignOut(), + Spinner(), + Spotlight(), + SpotlightView(), + Strikethrough(), + SwitchCameraSolid(), + TakePhoto(), + TakePhotoSolid(), + TextFormatting(), + Threads(), + ThreadsSolid(), + Time(), + Underline(), + Unknown(), + UnknownSolid(), + Unpin(), + User(), + UserAdd(), + UserAddSolid(), + UserProfile(), + UserProfileSolid(), + UserSolid(), + Verified(), + VideoCall(), + VideoCallDeclinedSolid(), + VideoCallMissedSolid(), + VideoCallOff(), + VideoCallOffSolid(), + VideoCallSolid(), + VisibilityOff(), + VisibilityOn(), + VoiceCall(), + VoiceCallSolid(), + VolumeOff(), + VolumeOffSolid(), + VolumeOn(), + VolumeOnSolid(), + Warning(), + WebBrowser(), + Windows(), + Workspace(), + WorkspaceSolid(), + ) + + val allResIds get() = persistentListOf( + R.drawable.ic_compound_admin, + R.drawable.ic_compound_arrow_down, + R.drawable.ic_compound_arrow_left, + R.drawable.ic_compound_arrow_right, + R.drawable.ic_compound_arrow_up, + R.drawable.ic_compound_arrow_up_right, + R.drawable.ic_compound_ask_to_join, + R.drawable.ic_compound_ask_to_join_solid, + R.drawable.ic_compound_attachment, + R.drawable.ic_compound_audio, + R.drawable.ic_compound_block, + R.drawable.ic_compound_bold, + R.drawable.ic_compound_calendar, + R.drawable.ic_compound_chart, + R.drawable.ic_compound_chat, + R.drawable.ic_compound_chat_new, + R.drawable.ic_compound_chat_problem, + R.drawable.ic_compound_chat_solid, + R.drawable.ic_compound_check, + R.drawable.ic_compound_check_circle, + R.drawable.ic_compound_check_circle_solid, + R.drawable.ic_compound_chevron_down, + R.drawable.ic_compound_chevron_left, + R.drawable.ic_compound_chevron_right, + R.drawable.ic_compound_chevron_up, + R.drawable.ic_compound_chevron_up_down, + R.drawable.ic_compound_circle, + R.drawable.ic_compound_close, + R.drawable.ic_compound_cloud, + R.drawable.ic_compound_cloud_solid, + R.drawable.ic_compound_code, + R.drawable.ic_compound_collapse, + R.drawable.ic_compound_company, + R.drawable.ic_compound_compose, + R.drawable.ic_compound_computer, + R.drawable.ic_compound_copy, + R.drawable.ic_compound_dark_mode, + R.drawable.ic_compound_delete, + R.drawable.ic_compound_devices, + R.drawable.ic_compound_dial_pad, + R.drawable.ic_compound_document, + R.drawable.ic_compound_download, + R.drawable.ic_compound_download_ios, + R.drawable.ic_compound_drag_grid, + R.drawable.ic_compound_drag_list, + R.drawable.ic_compound_earpiece, + R.drawable.ic_compound_edit, + R.drawable.ic_compound_edit_solid, + R.drawable.ic_compound_email, + R.drawable.ic_compound_email_solid, + R.drawable.ic_compound_end_call, + R.drawable.ic_compound_error, + R.drawable.ic_compound_error_solid, + R.drawable.ic_compound_expand, + R.drawable.ic_compound_explore, + R.drawable.ic_compound_export_archive, + R.drawable.ic_compound_extensions, + R.drawable.ic_compound_extensions_solid, + R.drawable.ic_compound_favourite, + R.drawable.ic_compound_favourite_solid, + R.drawable.ic_compound_file_error, + R.drawable.ic_compound_files, + R.drawable.ic_compound_filter, + R.drawable.ic_compound_forward, + R.drawable.ic_compound_grid, + R.drawable.ic_compound_group, + R.drawable.ic_compound_guest, + R.drawable.ic_compound_headphones_off_solid, + R.drawable.ic_compound_headphones_solid, + R.drawable.ic_compound_help, + R.drawable.ic_compound_help_solid, + R.drawable.ic_compound_history, + R.drawable.ic_compound_home, + R.drawable.ic_compound_home_solid, + R.drawable.ic_compound_host, + R.drawable.ic_compound_image, + R.drawable.ic_compound_image_error, + R.drawable.ic_compound_indent_decrease, + R.drawable.ic_compound_indent_increase, + R.drawable.ic_compound_info, + R.drawable.ic_compound_info_solid, + R.drawable.ic_compound_inline_code, + R.drawable.ic_compound_italic, + R.drawable.ic_compound_key, + R.drawable.ic_compound_key_off, + R.drawable.ic_compound_key_off_solid, + R.drawable.ic_compound_key_solid, + R.drawable.ic_compound_keyboard, + R.drawable.ic_compound_labs, + R.drawable.ic_compound_leave, + R.drawable.ic_compound_link, + R.drawable.ic_compound_linux, + R.drawable.ic_compound_list_bulleted, + R.drawable.ic_compound_list_numbered, + R.drawable.ic_compound_list_view, + R.drawable.ic_compound_location_navigator, + R.drawable.ic_compound_location_navigator_centred, + R.drawable.ic_compound_location_pin, + R.drawable.ic_compound_location_pin_solid, + R.drawable.ic_compound_lock, + R.drawable.ic_compound_lock_off, + R.drawable.ic_compound_lock_solid, + R.drawable.ic_compound_mac, + R.drawable.ic_compound_mark_as_read, + R.drawable.ic_compound_mark_as_unread, + R.drawable.ic_compound_mark_threads_as_read, + R.drawable.ic_compound_marker_read_receipts, + R.drawable.ic_compound_mention, + R.drawable.ic_compound_menu, + R.drawable.ic_compound_mic_off, + R.drawable.ic_compound_mic_off_solid, + R.drawable.ic_compound_mic_on, + R.drawable.ic_compound_mic_on_solid, + R.drawable.ic_compound_minus, + R.drawable.ic_compound_mobile, + R.drawable.ic_compound_notifications, + R.drawable.ic_compound_notifications_off, + R.drawable.ic_compound_notifications_off_solid, + R.drawable.ic_compound_notifications_solid, + R.drawable.ic_compound_offline, + R.drawable.ic_compound_overflow_horizontal, + R.drawable.ic_compound_overflow_vertical, + R.drawable.ic_compound_pause, + R.drawable.ic_compound_pause_solid, + R.drawable.ic_compound_pin, + R.drawable.ic_compound_pin_solid, + R.drawable.ic_compound_play, + R.drawable.ic_compound_play_solid, + R.drawable.ic_compound_plus, + R.drawable.ic_compound_polls, + R.drawable.ic_compound_polls_end, + R.drawable.ic_compound_pop_out, + R.drawable.ic_compound_preferences, + R.drawable.ic_compound_presence_outline_8_x_8, + R.drawable.ic_compound_presence_solid_8_x_8, + R.drawable.ic_compound_presence_strikethrough_8_x_8, + R.drawable.ic_compound_public, + R.drawable.ic_compound_qr_code, + R.drawable.ic_compound_quote, + R.drawable.ic_compound_raised_hand_solid, + R.drawable.ic_compound_reaction, + R.drawable.ic_compound_reaction_add, + R.drawable.ic_compound_reaction_solid, + R.drawable.ic_compound_reply, + R.drawable.ic_compound_restart, + R.drawable.ic_compound_room, + R.drawable.ic_compound_search, + R.drawable.ic_compound_send, + R.drawable.ic_compound_send_solid, + R.drawable.ic_compound_settings, + R.drawable.ic_compound_settings_solid, + R.drawable.ic_compound_share, + R.drawable.ic_compound_share_android, + R.drawable.ic_compound_share_ios, + R.drawable.ic_compound_share_screen, + R.drawable.ic_compound_share_screen_solid, + R.drawable.ic_compound_shield, + R.drawable.ic_compound_sidebar, + R.drawable.ic_compound_sign_out, + R.drawable.ic_compound_spinner, + R.drawable.ic_compound_spotlight, + R.drawable.ic_compound_spotlight_view, + R.drawable.ic_compound_strikethrough, + R.drawable.ic_compound_switch_camera_solid, + R.drawable.ic_compound_take_photo, + R.drawable.ic_compound_take_photo_solid, + R.drawable.ic_compound_text_formatting, + R.drawable.ic_compound_threads, + R.drawable.ic_compound_threads_solid, + R.drawable.ic_compound_time, + R.drawable.ic_compound_underline, + R.drawable.ic_compound_unknown, + R.drawable.ic_compound_unknown_solid, + R.drawable.ic_compound_unpin, + R.drawable.ic_compound_user, + R.drawable.ic_compound_user_add, + R.drawable.ic_compound_user_add_solid, + R.drawable.ic_compound_user_profile, + R.drawable.ic_compound_user_profile_solid, + R.drawable.ic_compound_user_solid, + R.drawable.ic_compound_verified, + R.drawable.ic_compound_video_call, + R.drawable.ic_compound_video_call_declined_solid, + R.drawable.ic_compound_video_call_missed_solid, + R.drawable.ic_compound_video_call_off, + R.drawable.ic_compound_video_call_off_solid, + R.drawable.ic_compound_video_call_solid, + R.drawable.ic_compound_visibility_off, + R.drawable.ic_compound_visibility_on, + R.drawable.ic_compound_voice_call, + R.drawable.ic_compound_voice_call_solid, + R.drawable.ic_compound_volume_off, + R.drawable.ic_compound_volume_off_solid, + R.drawable.ic_compound_volume_on, + R.drawable.ic_compound_volume_on_solid, + R.drawable.ic_compound_warning, + R.drawable.ic_compound_web_browser, + R.drawable.ic_compound_windows, + R.drawable.ic_compound_workspace, + R.drawable.ic_compound_workspace_solid, + ) +} diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/DO_NOT_MODIFY.txt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/DO_NOT_MODIFY.txt new file mode 100644 index 0000000..4c88fb5 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/DO_NOT_MODIFY.txt @@ -0,0 +1 @@ +Files inside this package are generated automatically from the Compound project (https://github.com/element-hq/compound-design-tokens) and will be batch-replaced when new tokens are generated. diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColors.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColors.kt new file mode 100644 index 0000000..fe04e21 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColors.kt @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + + +@file:Suppress("all") +package io.element.android.compound.tokens.generated + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + + +/** + * !!! WARNING !!! + * + * THIS IS AN AUTOGENERATED FILE. + * DO NOT EDIT MANUALLY. + */ + + + + +/** + * This class holds all the semantic tokens of the Compound theme. + */ +data class SemanticColors( + /** Background colour for accent or brand actions. State: Hover */ + val bgAccentHovered: Color, + /** Background colour for accent or brand actions. State: Pressed */ + val bgAccentPressed: Color, + /** Background colour for accent or brand actions. State: Rest. */ + val bgAccentRest: Color, + /** Background colour for accent or brand actions. State: Selected */ + val bgAccentSelected: Color, + /** Background colour for primary actions. State: Disabled. */ + val bgActionPrimaryDisabled: Color, + /** Background colour for primary actions. State: Hover. */ + val bgActionPrimaryHovered: Color, + /** Background colour for primary actions. State: Pressed. */ + val bgActionPrimaryPressed: Color, + /** Background colour for primary actions. State: Rest. */ + val bgActionPrimaryRest: Color, + /** Background colour for secondary actions. State: Hover. */ + val bgActionSecondaryHovered: Color, + /** Background colour for secondary actions. State: Pressed. */ + val bgActionSecondaryPressed: Color, + /** Background colour for secondary actions. State: Rest. */ + val bgActionSecondaryRest: Color, + /** Badge accent background colour */ + val bgBadgeAccent: Color, + /** Badge default background colour */ + val bgBadgeDefault: Color, + /** Badge info background colour */ + val bgBadgeInfo: Color, + /** Default global background for the user interface. +Elevation: Default (Level 0) */ + val bgCanvasDefault: Color, + /** Default global background for the user interface. +Elevation: Level 1. */ + val bgCanvasDefaultLevel1: Color, + /** Default background for disabled elements. There's no minimum contrast requirement. */ + val bgCanvasDisabled: Color, + /** High-contrast background color for critical state. State: Hover. */ + val bgCriticalHovered: Color, + /** High-contrast background color for critical state. State: Rest. */ + val bgCriticalPrimary: Color, + /** Default subtle critical surfaces. State: Rest. */ + val bgCriticalSubtle: Color, + /** Default subtle critical surfaces. State: Hover. */ + val bgCriticalSubtleHovered: Color, + /** Decorative background (1, Lime) for avatars and usernames. */ + val bgDecorative1: Color, + /** Decorative background (2, Cyan) for avatars and usernames. */ + val bgDecorative2: Color, + /** Decorative background (3, Fuchsia) for avatars and usernames. */ + val bgDecorative3: Color, + /** Decorative background (4, Purple) for avatars and usernames. */ + val bgDecorative4: Color, + /** Decorative background (5, Pink) for avatars and usernames. */ + val bgDecorative5: Color, + /** Decorative background (6, Orange) for avatars and usernames. */ + val bgDecorative6: Color, + /** Subtle background colour for informational elements. State: Rest. */ + val bgInfoSubtle: Color, + /** Medium contrast surfaces. +Elevation: Default (Level 2). */ + val bgSubtlePrimary: Color, + /** Low contrast surfaces. +Elevation: Default (Level 1). */ + val bgSubtleSecondary: Color, + /** Lower contrast surfaces. +Elevation: Level 0. */ + val bgSubtleSecondaryLevel0: Color, + /** Subtle background colour for success state elements. State: Rest. */ + val bgSuccessSubtle: Color, + /** accent border intended for keylines on message highlights */ + val borderAccentSubtle: Color, + /** High-contrast border for critical state. State: Hover. */ + val borderCriticalHovered: Color, + /** High-contrast border for critical state. State: Rest. */ + val borderCriticalPrimary: Color, + /** Subtle border colour for critical state elements. */ + val borderCriticalSubtle: Color, + /** Used for borders of disabled elements. There's no minimum contrast requirement. */ + val borderDisabled: Color, + /** Used for the focus state outline. */ + val borderFocused: Color, + /** Subtle border colour for informational elements. */ + val borderInfoSubtle: Color, + /** Default contrast for accessible interactive element borders. State: Hover. */ + val borderInteractiveHovered: Color, + /** Default contrast for accessible interactive element borders. State: Rest. */ + val borderInteractivePrimary: Color, + /** ⚠️ Lowest contrast for non-accessible interactive element borders, <3:1. Only use for non-essential borders. Do not rely exclusively on them. State: Rest. */ + val borderInteractiveSecondary: Color, + /** Subtle border colour for success state elements. */ + val borderSuccessSubtle: Color, + /** Background gradient stop for super and send buttons */ + val gradientActionStop1: Color, + /** Background gradient stop for super and send buttons */ + val gradientActionStop2: Color, + /** Background gradient stop for super and send buttons */ + val gradientActionStop3: Color, + /** Background gradient stop for super and send buttons */ + val gradientActionStop4: Color, + /** Subtle background gradient stop for info */ + val gradientInfoStop1: Color, + /** Subtle background gradient stop for info */ + val gradientInfoStop2: Color, + /** Subtle background gradient stop for info */ + val gradientInfoStop3: Color, + /** Subtle background gradient stop for info */ + val gradientInfoStop4: Color, + /** Subtle background gradient stop for info */ + val gradientInfoStop5: Color, + /** Subtle background gradient stop for info */ + val gradientInfoStop6: Color, + /** Subtle background gradient stop for message highlight and bloom */ + val gradientSubtleStop1: Color, + /** Subtle background gradient stop for message highlight and bloom */ + val gradientSubtleStop2: Color, + /** Subtle background gradient stop for message highlight and bloom */ + val gradientSubtleStop3: Color, + /** Subtle background gradient stop for message highlight and bloom */ + val gradientSubtleStop4: Color, + /** Subtle background gradient stop for message highlight and bloom */ + val gradientSubtleStop5: Color, + /** Subtle background gradient stop for message highlight and bloom */ + val gradientSubtleStop6: Color, + /** Highest contrast accessible accent icons. */ + val iconAccentPrimary: Color, + /** Lowest contrast accessible accent icons. */ + val iconAccentTertiary: Color, + /** High-contrast icon for critical state. State: Rest. */ + val iconCriticalPrimary: Color, + /** Use for icons in disabled elements. There's no minimum contrast requirement. */ + val iconDisabled: Color, + /** High-contrast icon for informational elements. */ + val iconInfoPrimary: Color, + /** Highest contrast icon color on top of high-contrast solid backgrounds like primary, accent, or destructive actions. */ + val iconOnSolidPrimary: Color, + /** Highest contrast icons. */ + val iconPrimary: Color, + /** Translucent version of primary icon. Refer to it for intended use. */ + val iconPrimaryAlpha: Color, + /** ⚠️ Lowest contrast non-accessible icons, <3:1. Only use for non-essential icons. Do not rely exclusively on them. */ + val iconQuaternary: Color, + /** Translucent version of quaternary icon. Refer to it for intended use. */ + val iconQuaternaryAlpha: Color, + /** Lower contrast icons. */ + val iconSecondary: Color, + /** Translucent version of secondary icon. Refer to it for intended use. */ + val iconSecondaryAlpha: Color, + /** High-contrast icon for success state elements. */ + val iconSuccessPrimary: Color, + /** Lowest contrast accessible icons. */ + val iconTertiary: Color, + /** Translucent version of tertiary icon. Refer to it for intended use. */ + val iconTertiaryAlpha: Color, + /** Accent text colour for plain actions. */ + val textActionAccent: Color, + /** Default text colour for plain actions. */ + val textActionPrimary: Color, + /** Badge accent text colour */ + val textBadgeAccent: Color, + /** Badge info text colour */ + val textBadgeInfo: Color, + /** Text colour for destructive plain actions. */ + val textCriticalPrimary: Color, + /** Decorative text colour (1, Lime) for avatars and usernames. */ + val textDecorative1: Color, + /** Decorative text colour (2, Cyan) for avatars and usernames. */ + val textDecorative2: Color, + /** Decorative text colour (3, Fuchsia) for avatars and usernames. */ + val textDecorative3: Color, + /** Decorative text colour (4, Purple) for avatars and usernames. */ + val textDecorative4: Color, + /** Decorative text colour (5, Pink) for avatars and usernames. */ + val textDecorative5: Color, + /** Decorative text colour (6, Orange) for avatars and usernames. */ + val textDecorative6: Color, + /** Use for regular text in disabled elements. There's no minimum contrast requirement. */ + val textDisabled: Color, + /** Accent text colour for informational elements. */ + val textInfoPrimary: Color, + /** Text colour for external links. */ + val textLinkExternal: Color, + /** For use as text color on top of high-contrast solid backgrounds like primary, accent, or destructive actions. */ + val textOnSolidPrimary: Color, + /** Highest contrast text. */ + val textPrimary: Color, + /** Lowest contrast text. */ + val textSecondary: Color, + /** Accent text colour for success state elements. */ + val textSuccessPrimary: Color, + /** True for light theme, false for dark theme. */ + val isLight: Boolean, +) diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDark.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDark.kt new file mode 100644 index 0000000..6cbfd5f --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDark.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +/** + * !!! WARNING !!! + * + * THIS IS AN AUTOGENERATED FILE. + * DO NOT EDIT MANUALLY. + */ + + + +@file:Suppress("all") +package io.element.android.compound.tokens.generated + +import io.element.android.compound.annotations.CoreColorToken +import io.element.android.compound.tokens.generated.internal.DarkColorTokens + +/** + * Semantic colors for the dark Compound theme. + */ +@OptIn(CoreColorToken::class) +val compoundColorsDark = SemanticColors( + bgAccentHovered = DarkColorTokens.colorGreen1000, + bgAccentPressed = DarkColorTokens.colorGreen1100, + bgAccentRest = DarkColorTokens.colorGreen900, + bgAccentSelected = DarkColorTokens.colorAlphaGreen300, + bgActionPrimaryDisabled = DarkColorTokens.colorGray700, + bgActionPrimaryHovered = DarkColorTokens.colorGray1200, + bgActionPrimaryPressed = DarkColorTokens.colorGray1100, + bgActionPrimaryRest = DarkColorTokens.colorGray1400, + bgActionSecondaryHovered = DarkColorTokens.colorAlphaGray200, + bgActionSecondaryPressed = DarkColorTokens.colorAlphaGray300, + bgActionSecondaryRest = DarkColorTokens.colorThemeBg, + bgBadgeAccent = DarkColorTokens.colorAlphaGreen300, + bgBadgeDefault = DarkColorTokens.colorAlphaGray300, + bgBadgeInfo = DarkColorTokens.colorAlphaBlue300, + bgCanvasDefault = DarkColorTokens.colorThemeBg, + bgCanvasDefaultLevel1 = DarkColorTokens.colorGray300, + bgCanvasDisabled = DarkColorTokens.colorGray200, + bgCriticalHovered = DarkColorTokens.colorRed1000, + bgCriticalPrimary = DarkColorTokens.colorRed900, + bgCriticalSubtle = DarkColorTokens.colorRed200, + bgCriticalSubtleHovered = DarkColorTokens.colorRed300, + bgDecorative1 = DarkColorTokens.colorLime300, + bgDecorative2 = DarkColorTokens.colorCyan300, + bgDecorative3 = DarkColorTokens.colorFuchsia300, + bgDecorative4 = DarkColorTokens.colorPurple300, + bgDecorative5 = DarkColorTokens.colorPink300, + bgDecorative6 = DarkColorTokens.colorOrange300, + bgInfoSubtle = DarkColorTokens.colorBlue200, + bgSubtlePrimary = DarkColorTokens.colorGray400, + bgSubtleSecondary = DarkColorTokens.colorGray300, + bgSubtleSecondaryLevel0 = DarkColorTokens.colorThemeBg, + bgSuccessSubtle = DarkColorTokens.colorGreen200, + borderAccentSubtle = DarkColorTokens.colorGreen700, + borderCriticalHovered = DarkColorTokens.colorRed1000, + borderCriticalPrimary = DarkColorTokens.colorRed900, + borderCriticalSubtle = DarkColorTokens.colorRed500, + borderDisabled = DarkColorTokens.colorGray500, + borderFocused = DarkColorTokens.colorBlue900, + borderInfoSubtle = DarkColorTokens.colorBlue700, + borderInteractiveHovered = DarkColorTokens.colorGray1100, + borderInteractivePrimary = DarkColorTokens.colorGray800, + borderInteractiveSecondary = DarkColorTokens.colorGray600, + borderSuccessSubtle = DarkColorTokens.colorGreen500, + gradientActionStop1 = DarkColorTokens.colorGreen1100, + gradientActionStop2 = DarkColorTokens.colorGreen900, + gradientActionStop3 = DarkColorTokens.colorGreen700, + gradientActionStop4 = DarkColorTokens.colorGreen500, + gradientInfoStop1 = DarkColorTokens.colorAlphaBlue500, + gradientInfoStop2 = DarkColorTokens.colorAlphaBlue400, + gradientInfoStop3 = DarkColorTokens.colorAlphaBlue300, + gradientInfoStop4 = DarkColorTokens.colorAlphaBlue200, + gradientInfoStop5 = DarkColorTokens.colorAlphaBlue100, + gradientInfoStop6 = DarkColorTokens.colorTransparent, + gradientSubtleStop1 = DarkColorTokens.colorAlphaGreen500, + gradientSubtleStop2 = DarkColorTokens.colorAlphaGreen400, + gradientSubtleStop3 = DarkColorTokens.colorAlphaGreen300, + gradientSubtleStop4 = DarkColorTokens.colorAlphaGreen200, + gradientSubtleStop5 = DarkColorTokens.colorAlphaGreen100, + gradientSubtleStop6 = DarkColorTokens.colorTransparent, + iconAccentPrimary = DarkColorTokens.colorGreen900, + iconAccentTertiary = DarkColorTokens.colorGreen800, + iconCriticalPrimary = DarkColorTokens.colorRed900, + iconDisabled = DarkColorTokens.colorGray700, + iconInfoPrimary = DarkColorTokens.colorBlue900, + iconOnSolidPrimary = DarkColorTokens.colorThemeBg, + iconPrimary = DarkColorTokens.colorGray1400, + iconPrimaryAlpha = DarkColorTokens.colorAlphaGray1400, + iconQuaternary = DarkColorTokens.colorGray700, + iconQuaternaryAlpha = DarkColorTokens.colorAlphaGray700, + iconSecondary = DarkColorTokens.colorGray900, + iconSecondaryAlpha = DarkColorTokens.colorAlphaGray900, + iconSuccessPrimary = DarkColorTokens.colorGreen900, + iconTertiary = DarkColorTokens.colorGray800, + iconTertiaryAlpha = DarkColorTokens.colorAlphaGray800, + textActionAccent = DarkColorTokens.colorGreen900, + textActionPrimary = DarkColorTokens.colorGray1400, + textBadgeAccent = DarkColorTokens.colorGreen1100, + textBadgeInfo = DarkColorTokens.colorBlue1100, + textCriticalPrimary = DarkColorTokens.colorRed900, + textDecorative1 = DarkColorTokens.colorLime1100, + textDecorative2 = DarkColorTokens.colorCyan1100, + textDecorative3 = DarkColorTokens.colorFuchsia1100, + textDecorative4 = DarkColorTokens.colorPurple1100, + textDecorative5 = DarkColorTokens.colorPink1100, + textDecorative6 = DarkColorTokens.colorOrange1100, + textDisabled = DarkColorTokens.colorGray800, + textInfoPrimary = DarkColorTokens.colorBlue900, + textLinkExternal = DarkColorTokens.colorBlue900, + textOnSolidPrimary = DarkColorTokens.colorThemeBg, + textPrimary = DarkColorTokens.colorGray1400, + textSecondary = DarkColorTokens.colorGray900, + textSuccessPrimary = DarkColorTokens.colorGreen900, + isLight = false, +) diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDarkHc.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDarkHc.kt new file mode 100644 index 0000000..94bcdab --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDarkHc.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +/** + * !!! WARNING !!! + * + * THIS IS AN AUTOGENERATED FILE. + * DO NOT EDIT MANUALLY. + */ + + + +@file:Suppress("all") +package io.element.android.compound.tokens.generated + +import io.element.android.compound.annotations.CoreColorToken +import io.element.android.compound.tokens.generated.internal.DarkHcColorTokens + +/** + * Semantic colors for the high contrast dark Compound theme. + */ +@OptIn(CoreColorToken::class) +val compoundColorsHcDark = SemanticColors( + bgAccentHovered = DarkHcColorTokens.colorGreen1000, + bgAccentPressed = DarkHcColorTokens.colorGreen1100, + bgAccentRest = DarkHcColorTokens.colorGreen900, + bgAccentSelected = DarkHcColorTokens.colorAlphaGreen300, + bgActionPrimaryDisabled = DarkHcColorTokens.colorGray700, + bgActionPrimaryHovered = DarkHcColorTokens.colorGray1200, + bgActionPrimaryPressed = DarkHcColorTokens.colorGray1100, + bgActionPrimaryRest = DarkHcColorTokens.colorGray1400, + bgActionSecondaryHovered = DarkHcColorTokens.colorAlphaGray200, + bgActionSecondaryPressed = DarkHcColorTokens.colorAlphaGray300, + bgActionSecondaryRest = DarkHcColorTokens.colorThemeBg, + bgBadgeAccent = DarkHcColorTokens.colorAlphaGreen300, + bgBadgeDefault = DarkHcColorTokens.colorAlphaGray300, + bgBadgeInfo = DarkHcColorTokens.colorAlphaBlue300, + bgCanvasDefault = DarkHcColorTokens.colorThemeBg, + bgCanvasDefaultLevel1 = DarkHcColorTokens.colorGray300, + bgCanvasDisabled = DarkHcColorTokens.colorGray200, + bgCriticalHovered = DarkHcColorTokens.colorRed1000, + bgCriticalPrimary = DarkHcColorTokens.colorRed900, + bgCriticalSubtle = DarkHcColorTokens.colorRed200, + bgCriticalSubtleHovered = DarkHcColorTokens.colorRed300, + bgDecorative1 = DarkHcColorTokens.colorLime300, + bgDecorative2 = DarkHcColorTokens.colorCyan300, + bgDecorative3 = DarkHcColorTokens.colorFuchsia300, + bgDecorative4 = DarkHcColorTokens.colorPurple300, + bgDecorative5 = DarkHcColorTokens.colorPink300, + bgDecorative6 = DarkHcColorTokens.colorOrange300, + bgInfoSubtle = DarkHcColorTokens.colorBlue200, + bgSubtlePrimary = DarkHcColorTokens.colorGray400, + bgSubtleSecondary = DarkHcColorTokens.colorGray300, + bgSubtleSecondaryLevel0 = DarkHcColorTokens.colorThemeBg, + bgSuccessSubtle = DarkHcColorTokens.colorGreen200, + borderAccentSubtle = DarkHcColorTokens.colorGreen700, + borderCriticalHovered = DarkHcColorTokens.colorRed1000, + borderCriticalPrimary = DarkHcColorTokens.colorRed900, + borderCriticalSubtle = DarkHcColorTokens.colorRed500, + borderDisabled = DarkHcColorTokens.colorGray500, + borderFocused = DarkHcColorTokens.colorBlue900, + borderInfoSubtle = DarkHcColorTokens.colorBlue700, + borderInteractiveHovered = DarkHcColorTokens.colorGray1100, + borderInteractivePrimary = DarkHcColorTokens.colorGray800, + borderInteractiveSecondary = DarkHcColorTokens.colorGray600, + borderSuccessSubtle = DarkHcColorTokens.colorGreen500, + gradientActionStop1 = DarkHcColorTokens.colorGreen1100, + gradientActionStop2 = DarkHcColorTokens.colorGreen900, + gradientActionStop3 = DarkHcColorTokens.colorGreen700, + gradientActionStop4 = DarkHcColorTokens.colorGreen500, + gradientInfoStop1 = DarkHcColorTokens.colorAlphaBlue500, + gradientInfoStop2 = DarkHcColorTokens.colorAlphaBlue400, + gradientInfoStop3 = DarkHcColorTokens.colorAlphaBlue300, + gradientInfoStop4 = DarkHcColorTokens.colorAlphaBlue200, + gradientInfoStop5 = DarkHcColorTokens.colorAlphaBlue100, + gradientInfoStop6 = DarkHcColorTokens.colorTransparent, + gradientSubtleStop1 = DarkHcColorTokens.colorAlphaGreen500, + gradientSubtleStop2 = DarkHcColorTokens.colorAlphaGreen400, + gradientSubtleStop3 = DarkHcColorTokens.colorAlphaGreen300, + gradientSubtleStop4 = DarkHcColorTokens.colorAlphaGreen200, + gradientSubtleStop5 = DarkHcColorTokens.colorAlphaGreen100, + gradientSubtleStop6 = DarkHcColorTokens.colorTransparent, + iconAccentPrimary = DarkHcColorTokens.colorGreen900, + iconAccentTertiary = DarkHcColorTokens.colorGreen800, + iconCriticalPrimary = DarkHcColorTokens.colorRed900, + iconDisabled = DarkHcColorTokens.colorGray700, + iconInfoPrimary = DarkHcColorTokens.colorBlue900, + iconOnSolidPrimary = DarkHcColorTokens.colorThemeBg, + iconPrimary = DarkHcColorTokens.colorGray1400, + iconPrimaryAlpha = DarkHcColorTokens.colorAlphaGray1400, + iconQuaternary = DarkHcColorTokens.colorGray700, + iconQuaternaryAlpha = DarkHcColorTokens.colorAlphaGray700, + iconSecondary = DarkHcColorTokens.colorGray900, + iconSecondaryAlpha = DarkHcColorTokens.colorAlphaGray900, + iconSuccessPrimary = DarkHcColorTokens.colorGreen900, + iconTertiary = DarkHcColorTokens.colorGray800, + iconTertiaryAlpha = DarkHcColorTokens.colorAlphaGray800, + textActionAccent = DarkHcColorTokens.colorGreen900, + textActionPrimary = DarkHcColorTokens.colorGray1400, + textBadgeAccent = DarkHcColorTokens.colorGreen1100, + textBadgeInfo = DarkHcColorTokens.colorBlue1100, + textCriticalPrimary = DarkHcColorTokens.colorRed900, + textDecorative1 = DarkHcColorTokens.colorLime1100, + textDecorative2 = DarkHcColorTokens.colorCyan1100, + textDecorative3 = DarkHcColorTokens.colorFuchsia1100, + textDecorative4 = DarkHcColorTokens.colorPurple1100, + textDecorative5 = DarkHcColorTokens.colorPink1100, + textDecorative6 = DarkHcColorTokens.colorOrange1100, + textDisabled = DarkHcColorTokens.colorGray800, + textInfoPrimary = DarkHcColorTokens.colorBlue900, + textLinkExternal = DarkHcColorTokens.colorBlue900, + textOnSolidPrimary = DarkHcColorTokens.colorThemeBg, + textPrimary = DarkHcColorTokens.colorGray1400, + textSecondary = DarkHcColorTokens.colorGray900, + textSuccessPrimary = DarkHcColorTokens.colorGreen900, + isLight = false, +) diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLight.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLight.kt new file mode 100644 index 0000000..cc77975 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLight.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +/** + * !!! WARNING !!! + * + * THIS IS AN AUTOGENERATED FILE. + * DO NOT EDIT MANUALLY. + */ + + + +@file:Suppress("all") +package io.element.android.compound.tokens.generated + +import io.element.android.compound.annotations.CoreColorToken +import io.element.android.compound.tokens.generated.internal.LightColorTokens + +/** + * Semantic colors for the light Compound theme. + */ +@OptIn(CoreColorToken::class) +val compoundColorsLight = SemanticColors( + bgAccentHovered = LightColorTokens.colorGreen1000, + bgAccentPressed = LightColorTokens.colorGreen1100, + bgAccentRest = LightColorTokens.colorGreen900, + bgAccentSelected = LightColorTokens.colorAlphaGreen300, + bgActionPrimaryDisabled = LightColorTokens.colorGray700, + bgActionPrimaryHovered = LightColorTokens.colorGray1200, + bgActionPrimaryPressed = LightColorTokens.colorGray1100, + bgActionPrimaryRest = LightColorTokens.colorGray1400, + bgActionSecondaryHovered = LightColorTokens.colorAlphaGray200, + bgActionSecondaryPressed = LightColorTokens.colorAlphaGray300, + bgActionSecondaryRest = LightColorTokens.colorThemeBg, + bgBadgeAccent = LightColorTokens.colorAlphaGreen300, + bgBadgeDefault = LightColorTokens.colorAlphaGray300, + bgBadgeInfo = LightColorTokens.colorAlphaBlue300, + bgCanvasDefault = LightColorTokens.colorThemeBg, + bgCanvasDefaultLevel1 = LightColorTokens.colorThemeBg, + bgCanvasDisabled = LightColorTokens.colorGray200, + bgCriticalHovered = LightColorTokens.colorRed1000, + bgCriticalPrimary = LightColorTokens.colorRed900, + bgCriticalSubtle = LightColorTokens.colorRed200, + bgCriticalSubtleHovered = LightColorTokens.colorRed300, + bgDecorative1 = LightColorTokens.colorLime300, + bgDecorative2 = LightColorTokens.colorCyan300, + bgDecorative3 = LightColorTokens.colorFuchsia300, + bgDecorative4 = LightColorTokens.colorPurple300, + bgDecorative5 = LightColorTokens.colorPink300, + bgDecorative6 = LightColorTokens.colorOrange300, + bgInfoSubtle = LightColorTokens.colorBlue200, + bgSubtlePrimary = LightColorTokens.colorGray400, + bgSubtleSecondary = LightColorTokens.colorGray300, + bgSubtleSecondaryLevel0 = LightColorTokens.colorGray300, + bgSuccessSubtle = LightColorTokens.colorGreen200, + borderAccentSubtle = LightColorTokens.colorGreen700, + borderCriticalHovered = LightColorTokens.colorRed1000, + borderCriticalPrimary = LightColorTokens.colorRed900, + borderCriticalSubtle = LightColorTokens.colorRed500, + borderDisabled = LightColorTokens.colorGray500, + borderFocused = LightColorTokens.colorBlue900, + borderInfoSubtle = LightColorTokens.colorBlue700, + borderInteractiveHovered = LightColorTokens.colorGray1100, + borderInteractivePrimary = LightColorTokens.colorGray800, + borderInteractiveSecondary = LightColorTokens.colorGray600, + borderSuccessSubtle = LightColorTokens.colorGreen500, + gradientActionStop1 = LightColorTokens.colorGreen500, + gradientActionStop2 = LightColorTokens.colorGreen700, + gradientActionStop3 = LightColorTokens.colorGreen900, + gradientActionStop4 = LightColorTokens.colorGreen1100, + gradientInfoStop1 = LightColorTokens.colorAlphaBlue500, + gradientInfoStop2 = LightColorTokens.colorAlphaBlue400, + gradientInfoStop3 = LightColorTokens.colorAlphaBlue300, + gradientInfoStop4 = LightColorTokens.colorAlphaBlue200, + gradientInfoStop5 = LightColorTokens.colorAlphaBlue100, + gradientInfoStop6 = LightColorTokens.colorTransparent, + gradientSubtleStop1 = LightColorTokens.colorAlphaGreen500, + gradientSubtleStop2 = LightColorTokens.colorAlphaGreen400, + gradientSubtleStop3 = LightColorTokens.colorAlphaGreen300, + gradientSubtleStop4 = LightColorTokens.colorAlphaGreen200, + gradientSubtleStop5 = LightColorTokens.colorAlphaGreen100, + gradientSubtleStop6 = LightColorTokens.colorTransparent, + iconAccentPrimary = LightColorTokens.colorGreen900, + iconAccentTertiary = LightColorTokens.colorGreen800, + iconCriticalPrimary = LightColorTokens.colorRed900, + iconDisabled = LightColorTokens.colorGray700, + iconInfoPrimary = LightColorTokens.colorBlue900, + iconOnSolidPrimary = LightColorTokens.colorThemeBg, + iconPrimary = LightColorTokens.colorGray1400, + iconPrimaryAlpha = LightColorTokens.colorAlphaGray1400, + iconQuaternary = LightColorTokens.colorGray700, + iconQuaternaryAlpha = LightColorTokens.colorAlphaGray700, + iconSecondary = LightColorTokens.colorGray900, + iconSecondaryAlpha = LightColorTokens.colorAlphaGray900, + iconSuccessPrimary = LightColorTokens.colorGreen900, + iconTertiary = LightColorTokens.colorGray800, + iconTertiaryAlpha = LightColorTokens.colorAlphaGray800, + textActionAccent = LightColorTokens.colorGreen900, + textActionPrimary = LightColorTokens.colorGray1400, + textBadgeAccent = LightColorTokens.colorGreen1100, + textBadgeInfo = LightColorTokens.colorBlue1100, + textCriticalPrimary = LightColorTokens.colorRed900, + textDecorative1 = LightColorTokens.colorLime1100, + textDecorative2 = LightColorTokens.colorCyan1100, + textDecorative3 = LightColorTokens.colorFuchsia1100, + textDecorative4 = LightColorTokens.colorPurple1100, + textDecorative5 = LightColorTokens.colorPink1100, + textDecorative6 = LightColorTokens.colorOrange1100, + textDisabled = LightColorTokens.colorGray800, + textInfoPrimary = LightColorTokens.colorBlue900, + textLinkExternal = LightColorTokens.colorBlue900, + textOnSolidPrimary = LightColorTokens.colorThemeBg, + textPrimary = LightColorTokens.colorGray1400, + textSecondary = LightColorTokens.colorGray900, + textSuccessPrimary = LightColorTokens.colorGreen900, + isLight = true, +) diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLightHc.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLightHc.kt new file mode 100644 index 0000000..5956b80 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLightHc.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +/** + * !!! WARNING !!! + * + * THIS IS AN AUTOGENERATED FILE. + * DO NOT EDIT MANUALLY. + */ + + + +@file:Suppress("all") +package io.element.android.compound.tokens.generated + +import io.element.android.compound.annotations.CoreColorToken +import io.element.android.compound.tokens.generated.internal.LightHcColorTokens + +/** + * Semantic colors for the high contrast light Compound theme. + */ +@OptIn(CoreColorToken::class) +val compoundColorsHcLight = SemanticColors( + bgAccentHovered = LightHcColorTokens.colorGreen1000, + bgAccentPressed = LightHcColorTokens.colorGreen1100, + bgAccentRest = LightHcColorTokens.colorGreen900, + bgAccentSelected = LightHcColorTokens.colorAlphaGreen300, + bgActionPrimaryDisabled = LightHcColorTokens.colorGray700, + bgActionPrimaryHovered = LightHcColorTokens.colorGray1200, + bgActionPrimaryPressed = LightHcColorTokens.colorGray1100, + bgActionPrimaryRest = LightHcColorTokens.colorGray1400, + bgActionSecondaryHovered = LightHcColorTokens.colorAlphaGray200, + bgActionSecondaryPressed = LightHcColorTokens.colorAlphaGray300, + bgActionSecondaryRest = LightHcColorTokens.colorThemeBg, + bgBadgeAccent = LightHcColorTokens.colorAlphaGreen300, + bgBadgeDefault = LightHcColorTokens.colorAlphaGray300, + bgBadgeInfo = LightHcColorTokens.colorAlphaBlue300, + bgCanvasDefault = LightHcColorTokens.colorThemeBg, + bgCanvasDefaultLevel1 = LightHcColorTokens.colorThemeBg, + bgCanvasDisabled = LightHcColorTokens.colorGray200, + bgCriticalHovered = LightHcColorTokens.colorRed1000, + bgCriticalPrimary = LightHcColorTokens.colorRed900, + bgCriticalSubtle = LightHcColorTokens.colorRed200, + bgCriticalSubtleHovered = LightHcColorTokens.colorRed300, + bgDecorative1 = LightHcColorTokens.colorLime300, + bgDecorative2 = LightHcColorTokens.colorCyan300, + bgDecorative3 = LightHcColorTokens.colorFuchsia300, + bgDecorative4 = LightHcColorTokens.colorPurple300, + bgDecorative5 = LightHcColorTokens.colorPink300, + bgDecorative6 = LightHcColorTokens.colorOrange300, + bgInfoSubtle = LightHcColorTokens.colorBlue200, + bgSubtlePrimary = LightHcColorTokens.colorGray400, + bgSubtleSecondary = LightHcColorTokens.colorGray300, + bgSubtleSecondaryLevel0 = LightHcColorTokens.colorGray300, + bgSuccessSubtle = LightHcColorTokens.colorGreen200, + borderAccentSubtle = LightHcColorTokens.colorGreen700, + borderCriticalHovered = LightHcColorTokens.colorRed1000, + borderCriticalPrimary = LightHcColorTokens.colorRed900, + borderCriticalSubtle = LightHcColorTokens.colorRed500, + borderDisabled = LightHcColorTokens.colorGray500, + borderFocused = LightHcColorTokens.colorBlue900, + borderInfoSubtle = LightHcColorTokens.colorBlue700, + borderInteractiveHovered = LightHcColorTokens.colorGray1100, + borderInteractivePrimary = LightHcColorTokens.colorGray800, + borderInteractiveSecondary = LightHcColorTokens.colorGray600, + borderSuccessSubtle = LightHcColorTokens.colorGreen500, + gradientActionStop1 = LightHcColorTokens.colorGreen500, + gradientActionStop2 = LightHcColorTokens.colorGreen700, + gradientActionStop3 = LightHcColorTokens.colorGreen900, + gradientActionStop4 = LightHcColorTokens.colorGreen1100, + gradientInfoStop1 = LightHcColorTokens.colorAlphaBlue500, + gradientInfoStop2 = LightHcColorTokens.colorAlphaBlue400, + gradientInfoStop3 = LightHcColorTokens.colorAlphaBlue300, + gradientInfoStop4 = LightHcColorTokens.colorAlphaBlue200, + gradientInfoStop5 = LightHcColorTokens.colorAlphaBlue100, + gradientInfoStop6 = LightHcColorTokens.colorTransparent, + gradientSubtleStop1 = LightHcColorTokens.colorAlphaGreen500, + gradientSubtleStop2 = LightHcColorTokens.colorAlphaGreen400, + gradientSubtleStop3 = LightHcColorTokens.colorAlphaGreen300, + gradientSubtleStop4 = LightHcColorTokens.colorAlphaGreen200, + gradientSubtleStop5 = LightHcColorTokens.colorAlphaGreen100, + gradientSubtleStop6 = LightHcColorTokens.colorTransparent, + iconAccentPrimary = LightHcColorTokens.colorGreen900, + iconAccentTertiary = LightHcColorTokens.colorGreen800, + iconCriticalPrimary = LightHcColorTokens.colorRed900, + iconDisabled = LightHcColorTokens.colorGray700, + iconInfoPrimary = LightHcColorTokens.colorBlue900, + iconOnSolidPrimary = LightHcColorTokens.colorThemeBg, + iconPrimary = LightHcColorTokens.colorGray1400, + iconPrimaryAlpha = LightHcColorTokens.colorAlphaGray1400, + iconQuaternary = LightHcColorTokens.colorGray700, + iconQuaternaryAlpha = LightHcColorTokens.colorAlphaGray700, + iconSecondary = LightHcColorTokens.colorGray900, + iconSecondaryAlpha = LightHcColorTokens.colorAlphaGray900, + iconSuccessPrimary = LightHcColorTokens.colorGreen900, + iconTertiary = LightHcColorTokens.colorGray800, + iconTertiaryAlpha = LightHcColorTokens.colorAlphaGray800, + textActionAccent = LightHcColorTokens.colorGreen900, + textActionPrimary = LightHcColorTokens.colorGray1400, + textBadgeAccent = LightHcColorTokens.colorGreen1100, + textBadgeInfo = LightHcColorTokens.colorBlue1100, + textCriticalPrimary = LightHcColorTokens.colorRed900, + textDecorative1 = LightHcColorTokens.colorLime1100, + textDecorative2 = LightHcColorTokens.colorCyan1100, + textDecorative3 = LightHcColorTokens.colorFuchsia1100, + textDecorative4 = LightHcColorTokens.colorPurple1100, + textDecorative5 = LightHcColorTokens.colorPink1100, + textDecorative6 = LightHcColorTokens.colorOrange1100, + textDisabled = LightHcColorTokens.colorGray800, + textInfoPrimary = LightHcColorTokens.colorBlue900, + textLinkExternal = LightHcColorTokens.colorBlue900, + textOnSolidPrimary = LightHcColorTokens.colorThemeBg, + textPrimary = LightHcColorTokens.colorGray1400, + textSecondary = LightHcColorTokens.colorGray900, + textSuccessPrimary = LightHcColorTokens.colorGreen900, + isLight = true, +) diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/TypographyTokens.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/TypographyTokens.kt new file mode 100644 index 0000000..27ab3e9 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/TypographyTokens.kt @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + + +/** + * !!! WARNING !!! + * + * THIS IS AN AUTOGENERATED FILE. + * DO NOT EDIT MANUALLY. + */ + + + +@file:Suppress("all") +package io.element.android.compound.tokens.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 +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.style.LineHeightStyle + +object TypographyTokens { + val fontBodyLgMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W500, + lineHeight = 22.sp, + fontSize = 16.sp, + letterSpacing = 0.015625.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + ) + val fontBodyLgRegular = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W400, + lineHeight = 22.sp, + fontSize = 16.sp, + letterSpacing = 0.015625.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + ) + val fontBodyMdMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W500, + lineHeight = 20.sp, + fontSize = 14.sp, + letterSpacing = 0.017857.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + ) + val fontBodyMdRegular = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W400, + lineHeight = 20.sp, + fontSize = 14.sp, + letterSpacing = 0.017857.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + ) + val fontBodySmMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W500, + lineHeight = 17.sp, + fontSize = 12.sp, + letterSpacing = 0.033333.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + ) + val fontBodySmRegular = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W400, + lineHeight = 17.sp, + fontSize = 12.sp, + letterSpacing = 0.033333.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + ) + val fontBodyXsMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W500, + lineHeight = 15.sp, + fontSize = 11.sp, + letterSpacing = 0.045454.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + ) + val fontBodyXsRegular = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W400, + lineHeight = 15.sp, + fontSize = 11.sp, + letterSpacing = 0.045454.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + ) + val fontHeadingLgBold = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W700, + lineHeight = 34.sp, + fontSize = 28.sp, + letterSpacing = 0.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + ) + val fontHeadingLgRegular = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W400, + lineHeight = 34.sp, + fontSize = 28.sp, + letterSpacing = 0.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + ) + val fontHeadingMdBold = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W700, + lineHeight = 27.sp, + fontSize = 22.sp, + letterSpacing = 0.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + ) + val fontHeadingMdRegular = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W400, + lineHeight = 27.sp, + fontSize = 22.sp, + letterSpacing = 0.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + ) + val fontHeadingSmMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W500, + lineHeight = 25.sp, + fontSize = 20.sp, + letterSpacing = 0.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + ) + val fontHeadingSmRegular = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W400, + lineHeight = 25.sp, + fontSize = 20.sp, + letterSpacing = 0.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + ) + val fontHeadingXlBold = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W700, + lineHeight = 41.sp, + fontSize = 34.sp, + letterSpacing = 0.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + ) + val fontHeadingXlRegular = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W400, + lineHeight = 41.sp, + fontSize = 34.sp, + letterSpacing = 0.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + ) +} diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkColorTokens.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkColorTokens.kt new file mode 100644 index 0000000..10b31b5 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkColorTokens.kt @@ -0,0 +1,336 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +/** + * !!! WARNING !!! + * + * THIS IS AN AUTOGENERATED FILE. + * DO NOT EDIT MANUALLY. + */ + + + +@file:Suppress("all") +package io.element.android.compound.tokens.generated.internal + +import androidx.compose.ui.graphics.Color +import io.element.android.compound.annotations.CoreColorToken + +@CoreColorToken +object DarkColorTokens { + val colorAlphaBlue100 = Color(0xff00055c) + val colorAlphaBlue1000 = Color(0xf062a0fe) + val colorAlphaBlue1100 = Color(0xf57cb2fd) + val colorAlphaBlue1200 = Color(0xf7a3c8ff) + val colorAlphaBlue1300 = Color(0xfccde1fe) + val colorAlphaBlue1400 = Color(0xffe6effe) + val colorAlphaBlue200 = Color(0xff00095c) + val colorAlphaBlue300 = Color(0xff001366) + val colorAlphaBlue400 = Color(0xff001e70) + val colorAlphaBlue500 = Color(0xa1003cbd) + val colorAlphaBlue600 = Color(0x87015afe) + val colorAlphaBlue700 = Color(0xa30665fe) + val colorAlphaBlue800 = Color(0xd61077fe) + val colorAlphaBlue900 = Color(0xeb4491fd) + val colorAlphaCyan100 = Color(0xff001142) + val colorAlphaCyan1000 = Color(0xe000bfe0) + val colorAlphaCyan1100 = Color(0xc926e7fd) + val colorAlphaCyan1200 = Color(0xd98af1ff) + val colorAlphaCyan1300 = Color(0xebc9f7fd) + val colorAlphaCyan1400 = Color(0xf5e1fbfe) + val colorAlphaCyan200 = Color(0xff001447) + val colorAlphaCyan300 = Color(0xff001b4d) + val colorAlphaCyan400 = Color(0xff00265c) + val colorAlphaCyan500 = Color(0xff003366) + val colorAlphaCyan600 = Color(0xff003f75) + val colorAlphaCyan700 = Color(0xff00538a) + val colorAlphaCyan800 = Color(0xe0007ebd) + val colorAlphaCyan900 = Color(0xff0091bd) + val colorAlphaFuchsia100 = Color(0xff28003d) + val colorAlphaFuchsia1000 = Color(0xd4f790fe) + val colorAlphaFuchsia1100 = Color(0xdbfaa4fe) + val colorAlphaFuchsia1200 = Color(0xe8fac3fe) + val colorAlphaFuchsia1300 = Color(0xf2fde0ff) + val colorAlphaFuchsia1400 = Color(0xfafdecfe) + val colorAlphaFuchsia200 = Color(0xff2d0042) + val colorAlphaFuchsia300 = Color(0xff36004d) + val colorAlphaFuchsia400 = Color(0xff45005c) + val colorAlphaFuchsia500 = Color(0x61ca0aff) + val colorAlphaFuchsia600 = Color(0x70d21fff) + val colorAlphaFuchsia700 = Color(0x8ad82ffe) + val colorAlphaFuchsia800 = Color(0xb5eb44fd) + val colorAlphaFuchsia900 = Color(0xccf172fd) + val colorAlphaGray100 = Color(0x05d8dbdf) + val colorAlphaGray1000 = Color(0x9ce1eefe) + val colorAlphaGray1100 = Color(0xade7f0fe) + val colorAlphaGray1200 = Color(0xc9edf4fc) + val colorAlphaGray1300 = Color(0xe3f2f7fd) + val colorAlphaGray1400 = Color(0xf2f6f9fe) + val colorAlphaGray200 = Color(0x0ad9c3df) + val colorAlphaGray300 = Color(0x0fe9dbf0) + val colorAlphaGray400 = Color(0x1aede7f4) + val colorAlphaGray500 = Color(0x26f4f7fa) + val colorAlphaGray600 = Color(0x33eceff8) + val colorAlphaGray700 = Color(0x45e7f1fd) + val colorAlphaGray800 = Color(0x69e0edff) + val colorAlphaGray900 = Color(0x8ae1effe) + val colorAlphaGreen100 = Color(0xff001f0c) + val colorAlphaGreen1000 = Color(0xa61bfebd) + val colorAlphaGreen1100 = Color(0xbd26fdbc) + val colorAlphaGreen1200 = Color(0xd486fdce) + val colorAlphaGreen1300 = Color(0xe8c4fde2) + val colorAlphaGreen1400 = Color(0xf5e2fdf1) + val colorAlphaGreen200 = Color(0xff001f0e) + val colorAlphaGreen300 = Color(0xff002412) + val colorAlphaGreen400 = Color(0xff002e1b) + val colorAlphaGreen500 = Color(0xff003d29) + val colorAlphaGreen600 = Color(0xff004732) + val colorAlphaGreen700 = Color(0xff005c45) + val colorAlphaGreen800 = Color(0xff007a62) + val colorAlphaGreen900 = Color(0x9412fdbe) + val colorAlphaLime100 = Color(0xff001a00) + val colorAlphaLime1000 = Color(0xa860fc2c) + val colorAlphaLime1100 = Color(0xbd71fd35) + val colorAlphaLime1200 = Color(0xd68dff5c) + val colorAlphaLime1300 = Color(0xebc3ffad) + val colorAlphaLime1400 = Color(0xf7e1fdd8) + val colorAlphaLime200 = Color(0xff001f00) + val colorAlphaLime300 = Color(0xff002900) + val colorAlphaLime400 = Color(0xff002e00) + val colorAlphaLime500 = Color(0xff003d00) + val colorAlphaLime600 = Color(0xff004d00) + val colorAlphaLime700 = Color(0xff005c00) + val colorAlphaLime800 = Color(0x732dfd0d) + val colorAlphaLime900 = Color(0x9454fd26) + val colorAlphaOrange100 = Color(0xff380000) + val colorAlphaOrange1000 = Color(0xebfe8310) + val colorAlphaOrange1100 = Color(0xf7fd953f) + val colorAlphaOrange1200 = Color(0xfcfdb781) + val colorAlphaOrange1300 = Color(0xffffd4b8) + val colorAlphaOrange1400 = Color(0xffffeadb) + val colorAlphaOrange200 = Color(0xff3d0000) + val colorAlphaOrange300 = Color(0xff470000) + val colorAlphaOrange400 = Color(0xff570000) + val colorAlphaOrange500 = Color(0xff700000) + val colorAlphaOrange600 = Color(0xff850400) + val colorAlphaOrange700 = Color(0xbdc72800) + val colorAlphaOrange800 = Color(0xb5ff5900) + val colorAlphaOrange900 = Color(0xd9fe740b) + val colorAlphaPink100 = Color(0xff38000f) + val colorAlphaPink1000 = Color(0xfaff6691) + val colorAlphaPink1100 = Color(0xfffe86a4) + val colorAlphaPink1200 = Color(0xffffadc0) + val colorAlphaPink1300 = Color(0xffffd1db) + val colorAlphaPink1400 = Color(0xffffebef) + val colorAlphaPink200 = Color(0xff3d0012) + val colorAlphaPink300 = Color(0xff470019) + val colorAlphaPink400 = Color(0xff570024) + val colorAlphaPink500 = Color(0xff6b0036) + val colorAlphaPink600 = Color(0x75fb0473) + val colorAlphaPink700 = Color(0x94fd1277) + val colorAlphaPink800 = Color(0xccfe1b79) + val colorAlphaPink900 = Color(0xf5fe4382) + val colorAlphaPurple100 = Color(0xff1a0057) + val colorAlphaPurple1000 = Color(0xfca28bfe) + val colorAlphaPurple1100 = Color(0xffab9afe) + val colorAlphaPurple1200 = Color(0xffc7bdff) + val colorAlphaPurple1300 = Color(0xffdfdbff) + val colorAlphaPurple1400 = Color(0xffeeebff) + val colorAlphaPurple200 = Color(0xff1d005c) + val colorAlphaPurple300 = Color(0xff22006b) + val colorAlphaPurple400 = Color(0xff2d0080) + val colorAlphaPurple500 = Color(0xff3d009e) + val colorAlphaPurple600 = Color(0xab690dfd) + val colorAlphaPurple700 = Color(0xc2712bfd) + val colorAlphaPurple800 = Color(0xeb7f4dff) + val colorAlphaPurple900 = Color(0xfa9271fe) + val colorAlphaRed100 = Color(0xff380000) + val colorAlphaRed1000 = Color(0xffff645c) + val colorAlphaRed1100 = Color(0xffff857a) + val colorAlphaRed1200 = Color(0xffffaea3) + val colorAlphaRed1300 = Color(0xffffd3cc) + val colorAlphaRed1400 = Color(0xffffe8e5) + val colorAlphaRed200 = Color(0xff3d0000) + val colorAlphaRed300 = Color(0xff470000) + val colorAlphaRed400 = Color(0xff5c0000) + val colorAlphaRed500 = Color(0xff700000) + val colorAlphaRed600 = Color(0xff850009) + val colorAlphaRed700 = Color(0x99fe0b24) + val colorAlphaRed800 = Color(0xcffe2530) + val colorAlphaRed900 = Color(0xfffd3d3a) + val colorAlphaYellow100 = Color(0xff380000) + val colorAlphaYellow1000 = Color(0xffcc8b00) + val colorAlphaYellow1100 = Color(0xffdba100) + val colorAlphaYellow1200 = Color(0xf0fdc50d) + val colorAlphaYellow1300 = Color(0xfffeda58) + val colorAlphaYellow1400 = Color(0xffffedb3) + val colorAlphaYellow200 = Color(0xff380300) + val colorAlphaYellow300 = Color(0xff420900) + val colorAlphaYellow400 = Color(0xff4d1400) + val colorAlphaYellow500 = Color(0xff5c2300) + val colorAlphaYellow600 = Color(0xde753300) + val colorAlphaYellow700 = Color(0xeb854200) + val colorAlphaYellow800 = Color(0xff9e5c00) + val colorAlphaYellow900 = Color(0xffbd7b00) + val colorBlue100 = Color(0xff00055a) + val colorBlue1000 = Color(0xff5e99f0) + val colorBlue1100 = Color(0xff7aacf4) + val colorBlue1200 = Color(0xffa1c4f8) + val colorBlue1300 = Color(0xffcbdffc) + val colorBlue1400 = Color(0xffe4eefe) + val colorBlue200 = Color(0xff00095d) + val colorBlue300 = Color(0xff001264) + val colorBlue400 = Color(0xff001e6f) + val colorBlue500 = Color(0xff062d80) + val colorBlue600 = Color(0xff083891) + val colorBlue700 = Color(0xff0b49ab) + val colorBlue800 = Color(0xff0e67d9) + val colorBlue900 = Color(0xff4187eb) + val colorCyan100 = Color(0xff001144) + val colorCyan1000 = Color(0xff02a7c6) + val colorCyan1100 = Color(0xff21bacd) + val colorCyan1200 = Color(0xff78d0dc) + val colorCyan1300 = Color(0xffb8e5eb) + val colorCyan1400 = Color(0xffdbf2f5) + val colorCyan200 = Color(0xff001448) + val colorCyan300 = Color(0xff001b4e) + val colorCyan400 = Color(0xff002559) + val colorCyan500 = Color(0xff003468) + val colorCyan600 = Color(0xff003f75) + val colorCyan700 = Color(0xff005188) + val colorCyan800 = Color(0xff0271aa) + val colorCyan900 = Color(0xff0093be) + val colorFuchsia100 = Color(0xff28003d) + val colorFuchsia1000 = Color(0xffcf78d7) + val colorFuchsia1100 = Color(0xffd991de) + val colorFuchsia1200 = Color(0xffe5b1e9) + val colorFuchsia1300 = Color(0xfff1d4f3) + val colorFuchsia1400 = Color(0xfff8e9f9) + val colorFuchsia200 = Color(0xff2e0044) + val colorFuchsia300 = Color(0xff37004e) + val colorFuchsia400 = Color(0xff46005e) + val colorFuchsia500 = Color(0xff560f6f) + val colorFuchsia600 = Color(0xff65177d) + val colorFuchsia700 = Color(0xff7d2394) + val colorFuchsia800 = Color(0xffaa36ba) + val colorFuchsia900 = Color(0xffc560cf) + val colorGray100 = Color(0xff14171b) + val colorGray1000 = Color(0xff9199a4) + val colorGray1100 = Color(0xffa3aab4) + val colorGray1200 = Color(0xffbdc3cc) + val colorGray1300 = Color(0xffd9dee4) + val colorGray1400 = Color(0xffebeef2) + val colorGray200 = Color(0xff181a1f) + val colorGray300 = Color(0xff1d1f24) + val colorGray400 = Color(0xff26282d) + val colorGray500 = Color(0xff323539) + val colorGray600 = Color(0xff3c3f44) + val colorGray700 = Color(0xff4a4f55) + val colorGray800 = Color(0xff656c76) + val colorGray900 = Color(0xff808994) + val colorGreen100 = Color(0xff001c0b) + val colorGreen1000 = Color(0xff17ac84) + val colorGreen1100 = Color(0xff1fc090) + val colorGreen1200 = Color(0xff72d5ae) + val colorGreen1300 = Color(0xffb5e8d1) + val colorGreen1400 = Color(0xffd9f4e7) + val colorGreen200 = Color(0xff001f0e) + val colorGreen300 = Color(0xff002513) + val colorGreen400 = Color(0xff002e1b) + val colorGreen500 = Color(0xff003d29) + val colorGreen600 = Color(0xff004832) + val colorGreen700 = Color(0xff005a43) + val colorGreen800 = Color(0xff007a62) + val colorGreen900 = Color(0xff129a78) + val colorLime100 = Color(0xff001b00) + val colorLime1000 = Color(0xff47ad26) + val colorLime1100 = Color(0xff56c02c) + val colorLime1200 = Color(0xff77d94f) + val colorLime1300 = Color(0xffb6eca3) + val colorLime1400 = Color(0xffdaf6d0) + val colorLime200 = Color(0xff002000) + val colorLime300 = Color(0xff002600) + val colorLime400 = Color(0xff003000) + val colorLime500 = Color(0xff003e00) + val colorLime600 = Color(0xff004a00) + val colorLime700 = Color(0xff005c00) + val colorLime800 = Color(0xff1d7c13) + val colorLime900 = Color(0xff389b20) + val colorOrange100 = Color(0xff380000) + val colorOrange1000 = Color(0xffeb7a12) + val colorOrange1100 = Color(0xfff6913d) + val colorOrange1200 = Color(0xfffbb37e) + val colorOrange1300 = Color(0xffffd5b9) + val colorOrange1400 = Color(0xffffeadb) + val colorOrange200 = Color(0xff3c0000) + val colorOrange300 = Color(0xff470000) + val colorOrange400 = Color(0xff580000) + val colorOrange500 = Color(0xff710000) + val colorOrange600 = Color(0xff830500) + val colorOrange700 = Color(0xff972206) + val colorOrange800 = Color(0xffb94607) + val colorOrange900 = Color(0xffda670d) + val colorPink100 = Color(0xff37000f) + val colorPink1000 = Color(0xfffa658f) + val colorPink1100 = Color(0xfffe84a2) + val colorPink1200 = Color(0xffffabbe) + val colorPink1300 = Color(0xffffd2dc) + val colorPink1400 = Color(0xffffe8ed) + val colorPink200 = Color(0xff3c0012) + val colorPink300 = Color(0xff450018) + val colorPink400 = Color(0xff550024) + val colorPink500 = Color(0xff6d0036) + val colorPink600 = Color(0xff7c0c41) + val colorPink700 = Color(0xff99114f) + val colorPink800 = Color(0xffce1865) + val colorPink900 = Color(0xfff4427d) + val colorPurple100 = Color(0xff1a0055) + val colorPurple1000 = Color(0xff9e87fc) + val colorPurple1100 = Color(0xffad9cfe) + val colorPurple1200 = Color(0xffc4baff) + val colorPurple1300 = Color(0xffdedaff) + val colorPurple1400 = Color(0xffeeebff) + val colorPurple200 = Color(0xff1c005a) + val colorPurple300 = Color(0xff22006a) + val colorPurple400 = Color(0xff2c0080) + val colorPurple500 = Color(0xff3d009e) + val colorPurple600 = Color(0xff4a0db1) + val colorPurple700 = Color(0xff5a27c6) + val colorPurple800 = Color(0xff7849ec) + val colorPurple900 = Color(0xff9171f9) + val colorRed100 = Color(0xff370000) + val colorRed1000 = Color(0xffff665d) + val colorRed1100 = Color(0xffff877c) + val colorRed1200 = Color(0xffffaea4) + val colorRed1300 = Color(0xffffd4cd) + val colorRed1400 = Color(0xffffe9e6) + val colorRed200 = Color(0xff3e0000) + val colorRed300 = Color(0xff470000) + val colorRed400 = Color(0xff590000) + val colorRed500 = Color(0xff710000) + val colorRed600 = Color(0xff830009) + val colorRed700 = Color(0xff9f0d1e) + val colorRed800 = Color(0xffd1212a) + val colorRed900 = Color(0xfffd3e3c) + val colorThemeBg = Color(0xff101317) + val colorTransparent = Color(0x00000000) + val colorYellow100 = Color(0xff360000) + val colorYellow1000 = Color(0xffcc8c00) + val colorYellow1100 = Color(0xffdb9f00) + val colorYellow1200 = Color(0xffefbb0b) + val colorYellow1300 = Color(0xfffedb58) + val colorYellow1400 = Color(0xffffedb1) + val colorYellow200 = Color(0xff3a0300) + val colorYellow300 = Color(0xff410900) + val colorYellow400 = Color(0xff4c1400) + val colorYellow500 = Color(0xff5c2400) + val colorYellow600 = Color(0xff682e03) + val colorYellow700 = Color(0xff7c3e02) + val colorYellow800 = Color(0xff9d5b00) + val colorYellow900 = Color(0xffbc7a00) +} diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkHcColorTokens.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkHcColorTokens.kt new file mode 100644 index 0000000..fa55eaf --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkHcColorTokens.kt @@ -0,0 +1,336 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +/** + * !!! WARNING !!! + * + * THIS IS AN AUTOGENERATED FILE. + * DO NOT EDIT MANUALLY. + */ + + + +@file:Suppress("all") +package io.element.android.compound.tokens.generated.internal + +import androidx.compose.ui.graphics.Color +import io.element.android.compound.annotations.CoreColorToken + +@CoreColorToken +object DarkHcColorTokens { + val colorAlphaBlue100 = Color(0xff00095c) + val colorAlphaBlue1000 = Color(0xf79ec5ff) + val colorAlphaBlue1100 = Color(0xfab8d4ff) + val colorAlphaBlue1200 = Color(0xfcc8defe) + val colorAlphaBlue1300 = Color(0xffe6effe) + val colorAlphaBlue1400 = Color(0xfff1f6fe) + val colorAlphaBlue200 = Color(0xff001366) + val colorAlphaBlue300 = Color(0xff001e70) + val colorAlphaBlue400 = Color(0xd1002b8f) + val colorAlphaBlue500 = Color(0x87015afe) + val colorAlphaBlue600 = Color(0xa30665fe) + val colorAlphaBlue700 = Color(0xcf0d71fd) + val colorAlphaBlue800 = Color(0xe83488fe) + val colorAlphaBlue900 = Color(0xf78bb9fd) + val colorAlphaCyan100 = Color(0xff001447) + val colorAlphaCyan1000 = Color(0xd67beffe) + val colorAlphaCyan1100 = Color(0xe0a4f4fe) + val colorAlphaCyan1200 = Color(0xe8bef5fe) + val colorAlphaCyan1300 = Color(0xf5e1fbfe) + val colorAlphaCyan1400 = Color(0xfaf1fdfe) + val colorAlphaCyan200 = Color(0xff001b4d) + val colorAlphaCyan300 = Color(0xff00265c) + val colorAlphaCyan400 = Color(0xff002d61) + val colorAlphaCyan500 = Color(0xff003f75) + val colorAlphaCyan600 = Color(0xff00538a) + val colorAlphaCyan700 = Color(0xff006da3) + val colorAlphaCyan800 = Color(0xff008ebd) + val colorAlphaCyan900 = Color(0xcf52edfe) + val colorAlphaFuchsia100 = Color(0xff2d0042) + val colorAlphaFuchsia1000 = Color(0xe6fabefe) + val colorAlphaFuchsia1100 = Color(0xedfacefd) + val colorAlphaFuchsia1200 = Color(0xf2fcd7fe) + val colorAlphaFuchsia1300 = Color(0xfafdecfe) + val colorAlphaFuchsia1400 = Color(0xfcfdf2fd) + val colorAlphaFuchsia200 = Color(0xff36004d) + val colorAlphaFuchsia300 = Color(0xff45005c) + val colorAlphaFuchsia400 = Color(0xd95a0075) + val colorAlphaFuchsia500 = Color(0x70d21fff) + val colorAlphaFuchsia600 = Color(0x8ad82ffe) + val colorAlphaFuchsia700 = Color(0xade640fc) + val colorAlphaFuchsia800 = Color(0xc7f467fe) + val colorAlphaFuchsia900 = Color(0xe0f9b3ff) + val colorAlphaGray100 = Color(0x0ad9c3df) + val colorAlphaGray1000 = Color(0xc2f0f7ff) + val colorAlphaGray1100 = Color(0xd1f0f7ff) + val colorAlphaGray1200 = Color(0xe0f1f6fd) + val colorAlphaGray1300 = Color(0xf2f6f9fe) + val colorAlphaGray1400 = Color(0xf7fbfdfe) + val colorAlphaGray200 = Color(0x0fe9dbf0) + val colorAlphaGray300 = Color(0x1aede7f4) + val colorAlphaGray400 = Color(0x21e1e4ef) + val colorAlphaGray500 = Color(0x33eceff8) + val colorAlphaGray600 = Color(0x45e7f1fd) + val colorAlphaGray700 = Color(0x63dfebfb) + val colorAlphaGray800 = Color(0x82dceafe) + val colorAlphaGray900 = Color(0xb8ecf4fe) + val colorAlphaGreen100 = Color(0xff001f0e) + val colorAlphaGreen1000 = Color(0xcf75ffc8) + val colorAlphaGreen1100 = Color(0xdba4fed7) + val colorAlphaGreen1200 = Color(0xe6bffde1) + val colorAlphaGreen1300 = Color(0xf5e2fdf1) + val colorAlphaGreen1400 = Color(0xfaedfdf5) + val colorAlphaGreen200 = Color(0xff002412) + val colorAlphaGreen300 = Color(0xff002e1b) + val colorAlphaGreen400 = Color(0xff003824) + val colorAlphaGreen500 = Color(0xff004732) + val colorAlphaGreen600 = Color(0xff005c45) + val colorAlphaGreen700 = Color(0xff00755e) + val colorAlphaGreen800 = Color(0x8a12fdc2) + val colorAlphaGreen900 = Color(0xc740fcba) + val colorAlphaLime100 = Color(0xff001f00) + val colorAlphaLime1000 = Color(0xd47bfe3e) + val colorAlphaLime1100 = Color(0xe0a4fd81) + val colorAlphaLime1200 = Color(0xe8c1fea9) + val colorAlphaLime1300 = Color(0xf7e1fdd8) + val colorAlphaLime1400 = Color(0xfaedfee7) + val colorAlphaLime200 = Color(0xff002900) + val colorAlphaLime300 = Color(0xff002e00) + val colorAlphaLime400 = Color(0xff003800) + val colorAlphaLime500 = Color(0xff004d00) + val colorAlphaLime600 = Color(0xff005c00) + val colorAlphaLime700 = Color(0x6b23ff0a) + val colorAlphaLime800 = Color(0x8c4dfe25) + val colorAlphaLime900 = Color(0xc774fe34) + val colorAlphaOrange100 = Color(0xff3d0000) + val colorAlphaOrange1000 = Color(0xfaffb175) + val colorAlphaOrange1100 = Color(0xfffdc196) + val colorAlphaOrange1200 = Color(0xfffed1b3) + val colorAlphaOrange1300 = Color(0xffffeadb) + val colorAlphaOrange1400 = Color(0xfffff2eb) + val colorAlphaOrange200 = Color(0xff470000) + val colorAlphaOrange300 = Color(0xff570000) + val colorAlphaOrange400 = Color(0xff660000) + val colorAlphaOrange500 = Color(0xff850400) + val colorAlphaOrange600 = Color(0xbdc72800) + val colorAlphaOrange700 = Color(0xb3fa5300) + val colorAlphaOrange800 = Color(0xcffe7206) + val colorAlphaOrange900 = Color(0xfafda058) + val colorAlphaPink100 = Color(0xff3d0012) + val colorAlphaPink1000 = Color(0xffffa3b9) + val colorAlphaPink1100 = Color(0xffffbdcb) + val colorAlphaPink1200 = Color(0xffffccd7) + val colorAlphaPink1300 = Color(0xffffebef) + val colorAlphaPink1400 = Color(0xfffff0f3) + val colorAlphaPink200 = Color(0xff470019) + val colorAlphaPink300 = Color(0xff570024) + val colorAlphaPink400 = Color(0xff61002d) + val colorAlphaPink500 = Color(0x75fb0473) + val colorAlphaPink600 = Color(0x94fd1277) + val colorAlphaPink700 = Color(0xc2fe1b79) + val colorAlphaPink800 = Color(0xf2fd2b78) + val colorAlphaPink900 = Color(0xffff94ad) + val colorAlphaPurple100 = Color(0xff1d005c) + val colorAlphaPurple1000 = Color(0xffc2b8ff) + val colorAlphaPurple1100 = Color(0xffcec7ff) + val colorAlphaPurple1200 = Color(0xffdbd6ff) + val colorAlphaPurple1300 = Color(0xffeeebff) + val colorAlphaPurple1400 = Color(0xfff6f5ff) + val colorAlphaPurple200 = Color(0xff22006b) + val colorAlphaPurple300 = Color(0xff2d0080) + val colorAlphaPurple400 = Color(0xff34008f) + val colorAlphaPurple500 = Color(0xab690dfd) + val colorAlphaPurple600 = Color(0xc2712bfd) + val colorAlphaPurple700 = Color(0xe67f49fd) + val colorAlphaPurple800 = Color(0xf7906bff) + val colorAlphaPurple900 = Color(0xffb7a8ff) + val colorAlphaRed100 = Color(0xff3d0000) + val colorAlphaRed1000 = Color(0xffffa89e) + val colorAlphaRed1100 = Color(0xffffbfb8) + val colorAlphaRed1200 = Color(0xffffcec7) + val colorAlphaRed1300 = Color(0xffffe8e5) + val colorAlphaRed1400 = Color(0xfffff3f0) + val colorAlphaRed200 = Color(0xff470000) + val colorAlphaRed300 = Color(0xff5c0000) + val colorAlphaRed400 = Color(0xff660000) + val colorAlphaRed500 = Color(0xff850009) + val colorAlphaRed600 = Color(0x99fe0b24) + val colorAlphaRed700 = Color(0xc4ff242f) + val colorAlphaRed800 = Color(0xf5ff2e31) + val colorAlphaRed900 = Color(0xffff988f) + val colorAlphaYellow100 = Color(0xff380300) + val colorAlphaYellow1000 = Color(0xebfec406) + val colorAlphaYellow1100 = Color(0xf7fecf16) + val colorAlphaYellow1200 = Color(0xfffed634) + val colorAlphaYellow1300 = Color(0xffffedb3) + val colorAlphaYellow1400 = Color(0xfffff4d1) + val colorAlphaYellow200 = Color(0xff420900) + val colorAlphaYellow300 = Color(0xff4d1400) + val colorAlphaYellow400 = Color(0xff571e00) + val colorAlphaYellow500 = Color(0xde753300) + val colorAlphaYellow600 = Color(0xeb854200) + val colorAlphaYellow700 = Color(0xff995700) + val colorAlphaYellow800 = Color(0xffb37100) + val colorAlphaYellow900 = Color(0xffe6ac00) + val colorBlue100 = Color(0xff00095d) + val colorBlue1000 = Color(0xff9ac0f8) + val colorBlue1100 = Color(0xffb2cffa) + val colorBlue1200 = Color(0xffc5dbfc) + val colorBlue1300 = Color(0xffe4eefe) + val colorBlue1400 = Color(0xffeff5fe) + val colorBlue200 = Color(0xff001264) + val colorBlue300 = Color(0xff001e6f) + val colorBlue400 = Color(0xff032677) + val colorBlue500 = Color(0xff083891) + val colorBlue600 = Color(0xff0b49ab) + val colorBlue700 = Color(0xff0e61d1) + val colorBlue800 = Color(0xff337fe9) + val colorBlue900 = Color(0xff89b5f6) + val colorCyan100 = Color(0xff001448) + val colorCyan1000 = Color(0xff6bccd9) + val colorCyan1100 = Color(0xff93d9e2) + val colorCyan1200 = Color(0xffafe2e9) + val colorCyan1300 = Color(0xffdbf2f5) + val colorCyan1400 = Color(0xffeaf7f9) + val colorCyan200 = Color(0xff001b4e) + val colorCyan300 = Color(0xff002559) + val colorCyan400 = Color(0xff002d61) + val colorCyan500 = Color(0xff003f75) + val colorCyan600 = Color(0xff005188) + val colorCyan700 = Color(0xff006ca4) + val colorCyan800 = Color(0xff008aba) + val colorCyan900 = Color(0xff46c3d2) + val colorFuchsia100 = Color(0xff2e0044) + val colorFuchsia1000 = Color(0xffe3abe7) + val colorFuchsia1100 = Color(0xffeac0ed) + val colorFuchsia1200 = Color(0xfff0cff2) + val colorFuchsia1300 = Color(0xfff8e9f9) + val colorFuchsia1400 = Color(0xfffbf1fb) + val colorFuchsia200 = Color(0xff37004e) + val colorFuchsia300 = Color(0xff46005e) + val colorFuchsia400 = Color(0xff4f0368) + val colorFuchsia500 = Color(0xff65177d) + val colorFuchsia600 = Color(0xff7d2394) + val colorFuchsia700 = Color(0xffa233b3) + val colorFuchsia800 = Color(0xffc153cb) + val colorFuchsia900 = Color(0xffdd9de3) + val colorGray100 = Color(0xff181a1f) + val colorGray1000 = Color(0xffb8bfc7) + val colorGray1100 = Color(0xffc8ced5) + val colorGray1200 = Color(0xffd5dae1) + val colorGray1300 = Color(0xffebeef2) + val colorGray1400 = Color(0xfff2f5f7) + val colorGray200 = Color(0xff1d1f24) + val colorGray300 = Color(0xff26282d) + val colorGray400 = Color(0xff2b2e33) + val colorGray500 = Color(0xff3c3f44) + val colorGray600 = Color(0xff4a4f55) + val colorGray700 = Color(0xff606770) + val colorGray800 = Color(0xff79818d) + val colorGray900 = Color(0xffacb4bd) + val colorGreen100 = Color(0xff001f0e) + val colorGreen1000 = Color(0xff61d2a6) + val colorGreen1100 = Color(0xff8fddbc) + val colorGreen1200 = Color(0xfface6cc) + val colorGreen1300 = Color(0xffd9f4e7) + val colorGreen1400 = Color(0xffe9f8f1) + val colorGreen200 = Color(0xff002513) + val colorGreen300 = Color(0xff002e1b) + val colorGreen400 = Color(0xff003622) + val colorGreen500 = Color(0xff004832) + val colorGreen600 = Color(0xff005a43) + val colorGreen700 = Color(0xff00745c) + val colorGreen800 = Color(0xff109173) + val colorGreen900 = Color(0xff37c998) + val colorLime100 = Color(0xff002000) + val colorLime1000 = Color(0xff6ad639) + val colorLime1100 = Color(0xff92e175) + val colorLime1200 = Color(0xffafe99a) + val colorLime1300 = Color(0xffdaf6d0) + val colorLime1400 = Color(0xffe9f9e3) + val colorLime200 = Color(0xff002600) + val colorLime300 = Color(0xff003000) + val colorLime400 = Color(0xff003700) + val colorLime500 = Color(0xff004a00) + val colorLime600 = Color(0xff005c00) + val colorLime700 = Color(0xff187611) + val colorLime800 = Color(0xff31941d) + val colorLime900 = Color(0xff5eca2f) + val colorOrange100 = Color(0xff3c0000) + val colorOrange1000 = Color(0xfffaad73) + val colorOrange1100 = Color(0xfffdc197) + val colorOrange1200 = Color(0xfffed0b1) + val colorOrange1300 = Color(0xffffeadb) + val colorOrange1400 = Color(0xfffff2ea) + val colorOrange200 = Color(0xff470000) + val colorOrange300 = Color(0xff580000) + val colorOrange400 = Color(0xff650000) + val colorOrange500 = Color(0xff830500) + val colorOrange600 = Color(0xff972206) + val colorOrange700 = Color(0xffb44007) + val colorOrange800 = Color(0xffd15f0b) + val colorOrange900 = Color(0xfff89d58) + val colorPink100 = Color(0xff3c0012) + val colorPink1000 = Color(0xffffa4b9) + val colorPink1100 = Color(0xffffbbca) + val colorPink1200 = Color(0xffffccd7) + val colorPink1300 = Color(0xffffe8ed) + val colorPink1400 = Color(0xfffff1f4) + val colorPink200 = Color(0xff450018) + val colorPink300 = Color(0xff550024) + val colorPink400 = Color(0xff61002d) + val colorPink500 = Color(0xff7c0c41) + val colorPink600 = Color(0xff99114f) + val colorPink700 = Color(0xffc51761) + val colorPink800 = Color(0xfff12c75) + val colorPink900 = Color(0xffff92ac) + val colorPurple100 = Color(0xff1c005a) + val colorPurple1000 = Color(0xffc0b5ff) + val colorPurple1100 = Color(0xffcec7ff) + val colorPurple1200 = Color(0xffdad5ff) + val colorPurple1300 = Color(0xffeeebff) + val colorPurple1400 = Color(0xfff5f3ff) + val colorPurple200 = Color(0xff22006a) + val colorPurple300 = Color(0xff2c0080) + val colorPurple400 = Color(0xff350090) + val colorPurple500 = Color(0xff4a0db1) + val colorPurple600 = Color(0xff5a27c6) + val colorPurple700 = Color(0xff7343e6) + val colorPurple800 = Color(0xff8b66f8) + val colorPurple900 = Color(0xffb6a7ff) + val colorRed100 = Color(0xff3e0000) + val colorRed1000 = Color(0xffffa79d) + val colorRed1100 = Color(0xffffbdb5) + val colorRed1200 = Color(0xffffcfc8) + val colorRed1300 = Color(0xffffe9e6) + val colorRed1400 = Color(0xfffff2ef) + val colorRed200 = Color(0xff470000) + val colorRed300 = Color(0xff590000) + val colorRed400 = Color(0xff640000) + val colorRed500 = Color(0xff830009) + val colorRed600 = Color(0xff9f0d1e) + val colorRed700 = Color(0xffc81e28) + val colorRed800 = Color(0xfff52f33) + val colorRed900 = Color(0xffff968c) + val colorThemeBg = Color(0xff101317) + val colorTransparent = Color(0x00000000) + val colorYellow100 = Color(0xff3a0300) + val colorYellow1000 = Color(0xffebb607) + val colorYellow1100 = Color(0xfff7c816) + val colorYellow1200 = Color(0xfffed632) + val colorYellow1300 = Color(0xffffedb1) + val colorYellow1400 = Color(0xfffff4d0) + val colorYellow200 = Color(0xff410900) + val colorYellow300 = Color(0xff4c1400) + val colorYellow400 = Color(0xff541d00) + val colorYellow500 = Color(0xff682e03) + val colorYellow600 = Color(0xff7c3e02) + val colorYellow700 = Color(0xff985600) + val colorYellow800 = Color(0xffb47200) + val colorYellow900 = Color(0xffe3aa00) +} diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightColorTokens.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightColorTokens.kt new file mode 100644 index 0000000..dd42c82 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightColorTokens.kt @@ -0,0 +1,336 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +/** + * !!! WARNING !!! + * + * THIS IS AN AUTOGENERATED FILE. + * DO NOT EDIT MANUALLY. + */ + + + +@file:Suppress("all") +package io.element.android.compound.tokens.generated.internal + +import androidx.compose.ui.graphics.Color +import io.element.android.compound.annotations.CoreColorToken + +@CoreColorToken +object LightColorTokens { + val colorAlphaBlue100 = Color(0x08389cff) + val colorAlphaBlue1000 = Color(0xfc0256c5) + val colorAlphaBlue1100 = Color(0xfa0148b2) + val colorAlphaBlue1200 = Color(0xfc013693) + val colorAlphaBlue1300 = Color(0xff012579) + val colorAlphaBlue1400 = Color(0xff000e66) + val colorAlphaBlue200 = Color(0x0d2474ff) + val colorAlphaBlue300 = Color(0x170a70ff) + val colorAlphaBlue400 = Color(0x290b6af9) + val colorAlphaBlue500 = Color(0x47096cf6) + val colorAlphaBlue600 = Color(0x5e0663ef) + val colorAlphaBlue700 = Color(0x820264ed) + val colorAlphaBlue800 = Color(0xbf0062eb) + val colorAlphaBlue900 = Color(0xfc0165df) + val colorAlphaCyan100 = Color(0x0816bbbb) + val colorAlphaCyan1000 = Color(0xff00649e) + val colorAlphaCyan1100 = Color(0xff00568f) + val colorAlphaCyan1200 = Color(0xff003f75) + val colorAlphaCyan1300 = Color(0xff002c61) + val colorAlphaCyan1400 = Color(0xff001a52) + val colorAlphaCyan200 = Color(0x0f16abbb) + val colorAlphaCyan300 = Color(0x1c00a8c2) + val colorAlphaCyan400 = Color(0x3800aabd) + val colorAlphaCyan500 = Color(0x6605abbd) + val colorAlphaCyan600 = Color(0x8a01aac1) + val colorAlphaCyan700 = Color(0xeb01b7cb) + val colorAlphaCyan800 = Color(0xff0095c2) + val colorAlphaCyan900 = Color(0xff0074ad) + val colorAlphaFuchsia100 = Color(0x05cc05cc) + val colorAlphaFuchsia1000 = Color(0xd6820198) + val colorAlphaFuchsia1100 = Color(0xe073038c) + val colorAlphaFuchsia1200 = Color(0xed5d0279) + val colorAlphaFuchsia1300 = Color(0xff4d0066) + val colorAlphaFuchsia1400 = Color(0xff34004d) + val colorAlphaFuchsia200 = Color(0x0ab505cc) + val colorAlphaFuchsia300 = Color(0x12b60cc6) + val colorAlphaFuchsia400 = Color(0x21bd09c3) + val colorAlphaFuchsia500 = Color(0x3bb407c0) + val colorAlphaFuchsia600 = Color(0x4fb207bb) + val colorAlphaFuchsia700 = Color(0x6eaa04b9) + val colorAlphaFuchsia800 = Color(0xa3ab03ba) + val colorAlphaFuchsia900 = Color(0xcc9900ad) + val colorAlphaGray100 = Color(0x0536699b) + val colorAlphaGray1000 = Color(0xa8030c1b) + val colorAlphaGray1100 = Color(0xb5030b16) + val colorAlphaGray1200 = Color(0xc402070d) + val colorAlphaGray1300 = Color(0xd603050c) + val colorAlphaGray1400 = Color(0xe6020408) + val colorAlphaGray200 = Color(0x0a366881) + val colorAlphaGray300 = Color(0x0f052657) + val colorAlphaGray400 = Color(0x1f052e61) + val colorAlphaGray500 = Color(0x33052448) + val colorAlphaGray600 = Color(0x42011d3c) + val colorAlphaGray700 = Color(0x59011532) + val colorAlphaGray800 = Color(0x8003152b) + val colorAlphaGray900 = Color(0x9c031021) + val colorAlphaGreen100 = Color(0x0816bb79) + val colorAlphaGreen1000 = Color(0xff006b52) + val colorAlphaGreen1100 = Color(0xff005c45) + val colorAlphaGreen1200 = Color(0xff004732) + val colorAlphaGreen1300 = Color(0xff00331f) + val colorAlphaGreen1400 = Color(0xff002411) + val colorAlphaGreen200 = Color(0x0f16bb69) + val colorAlphaGreen300 = Color(0x1c00b85c) + val colorAlphaGreen400 = Color(0x3b07b661) + val colorAlphaGreen500 = Color(0x6904b96a) + val colorAlphaGreen600 = Color(0x8f01b76e) + val colorAlphaGreen700 = Color(0xf501c18a) + val colorAlphaGreen800 = Color(0xff009975) + val colorAlphaGreen900 = Color(0xff007a62) + val colorAlphaLime100 = Color(0x0a4fcd1d) + val colorAlphaLime1000 = Color(0xff007000) + val colorAlphaLime1100 = Color(0xff006100) + val colorAlphaLime1200 = Color(0xff004d00) + val colorAlphaLime1300 = Color(0xff003800) + val colorAlphaLime1400 = Color(0xff002400) + val colorAlphaLime200 = Color(0x1238d40c) + val colorAlphaLime300 = Color(0x262ecf02) + val colorAlphaLime400 = Color(0x473ace09) + val colorAlphaLime500 = Color(0x8237ca02) + val colorAlphaLime600 = Color(0xb540ce03) + val colorAlphaLime700 = Color(0xdb39bd00) + val colorAlphaLime800 = Color(0xe8209301) + val colorAlphaLime900 = Color(0xf5107902) + val colorAlphaOrange100 = Color(0x0aff8138) + val colorAlphaOrange1000 = Color(0xffad3400) + val colorAlphaOrange1100 = Color(0xff992100) + val colorAlphaOrange1200 = Color(0xff850000) + val colorAlphaOrange1300 = Color(0xff610000) + val colorAlphaOrange1400 = Color(0xff470000) + val colorAlphaOrange200 = Color(0x12ff7d1a) + val colorAlphaOrange300 = Color(0x1cff6c0a) + val colorAlphaOrange400 = Color(0x38ff6d05) + val colorAlphaOrange500 = Color(0x5eff6a00) + val colorAlphaOrange600 = Color(0x85fc6f03) + val colorAlphaOrange700 = Color(0xbff56e00) + val colorAlphaOrange800 = Color(0xffdb6600) + val colorAlphaOrange900 = Color(0xffbd4500) + val colorAlphaPink100 = Color(0x05ff0537) + val colorAlphaPink1000 = Color(0xf7b60256) + val colorAlphaPink1100 = Color(0xf79e004c) + val colorAlphaPink1200 = Color(0xfa79013d) + val colorAlphaPink1300 = Color(0xff61002c) + val colorAlphaPink1400 = Color(0xff420017) + val colorAlphaPink200 = Color(0x0aff0537) + val colorAlphaPink300 = Color(0x14ff1447) + val colorAlphaPink400 = Color(0x21ff0037) + val colorAlphaPink500 = Color(0x3dff0037) + val colorAlphaPink600 = Color(0x54ff053f) + val colorAlphaPink700 = Color(0x78ff0040) + val colorAlphaPink800 = Color(0xbff50052) + val colorAlphaPink900 = Color(0xf5cf025e) + val colorAlphaPurple100 = Color(0x053838ff) + val colorAlphaPurple1000 = Color(0xc94502d4) + val colorAlphaPurple1100 = Color(0xdb4303c4) + val colorAlphaPurple1200 = Color(0xfc4a02b6) + val colorAlphaPurple1300 = Color(0xff34008f) + val colorAlphaPurple1400 = Color(0xff200066) + val colorAlphaPurple200 = Color(0x0a5338ff) + val colorAlphaPurple300 = Color(0x12381aff) + val colorAlphaPurple400 = Color(0x1f2f0fff) + val colorAlphaPurple500 = Color(0x332605ff) + val colorAlphaPurple600 = Color(0x452b05ff) + val colorAlphaPurple700 = Color(0x613305ff) + val colorAlphaPurple800 = Color(0x8f3b01f9) + val colorAlphaPurple900 = Color(0xba4902ed) + val colorAlphaRed100 = Color(0x08ff5938) + val colorAlphaRed1000 = Color(0xf2bb0217) + val colorAlphaRed1100 = Color(0xfca2011c) + val colorAlphaRed1200 = Color(0xff850007) + val colorAlphaRed1300 = Color(0xff610000) + val colorAlphaRed1400 = Color(0xff470000) + val colorAlphaRed200 = Color(0x0aff391f) + val colorAlphaRed300 = Color(0x14ff3814) + val colorAlphaRed400 = Color(0x26ff2b0a) + val colorAlphaRed500 = Color(0x45ff2605) + val colorAlphaRed600 = Color(0x5cff2205) + val colorAlphaRed700 = Color(0x80ff1a05) + val colorAlphaRed800 = Color(0xc4ff0505) + val colorAlphaRed900 = Color(0xe8cf0213) + val colorAlphaYellow100 = Color(0x0fffcd05) + val colorAlphaYellow1000 = Color(0xff8f4c00) + val colorAlphaYellow1100 = Color(0xff804000) + val colorAlphaYellow1200 = Color(0xff6b2e00) + val colorAlphaYellow1300 = Color(0xff571b00) + val colorAlphaYellow1400 = Color(0xff420700) + val colorAlphaYellow200 = Color(0x21ffc70f) + val colorAlphaYellow300 = Color(0x40ffc905) + val colorAlphaYellow400 = Color(0x7dffc905) + val colorAlphaYellow500 = Color(0xfffacc00) + val colorAlphaYellow600 = Color(0xfff0bc00) + val colorAlphaYellow700 = Color(0xffe0a500) + val colorAlphaYellow800 = Color(0xffbd7b00) + val colorAlphaYellow900 = Color(0xff9e5a00) + val colorBlue100 = Color(0xfff9fcff) + val colorBlue1000 = Color(0xff0558c7) + val colorBlue1100 = Color(0xff064ab1) + val colorBlue1200 = Color(0xff043894) + val colorBlue1300 = Color(0xff012478) + val colorBlue1400 = Color(0xff000e65) + val colorBlue200 = Color(0xfff4f8ff) + val colorBlue300 = Color(0xffe9f2ff) + val colorBlue400 = Color(0xffd8e7fe) + val colorBlue500 = Color(0xffbad5fc) + val colorBlue600 = Color(0xffa3c6fa) + val colorBlue700 = Color(0xff7eaff6) + val colorBlue800 = Color(0xff4088ee) + val colorBlue900 = Color(0xff0467dd) + val colorCyan100 = Color(0xfff8fdfd) + val colorCyan1000 = Color(0xff00629c) + val colorCyan1100 = Color(0xff00548c) + val colorCyan1200 = Color(0xff004077) + val colorCyan1300 = Color(0xff002b61) + val colorCyan1400 = Color(0xff00194f) + val colorCyan200 = Color(0xfff1fafb) + val colorCyan300 = Color(0xffe3f5f8) + val colorCyan400 = Color(0xffc7ecf0) + val colorCyan500 = Color(0xff9bdde5) + val colorCyan600 = Color(0xff76d1dd) + val colorCyan700 = Color(0xff15becf) + val colorCyan800 = Color(0xff0094c0) + val colorCyan900 = Color(0xff0072ac) + val colorFuchsia100 = Color(0xfffefafe) + val colorFuchsia1000 = Color(0xff972aaa) + val colorFuchsia1100 = Color(0xff822198) + val colorFuchsia1200 = Color(0xff671481) + val colorFuchsia1300 = Color(0xff4e0068) + val colorFuchsia1400 = Color(0xff34004c) + val colorFuchsia200 = Color(0xfffcf5fd) + val colorFuchsia300 = Color(0xfffaeefb) + val colorFuchsia400 = Color(0xfff6dff7) + val colorFuchsia500 = Color(0xffedc6f0) + val colorFuchsia600 = Color(0xffe7b2ea) + val colorFuchsia700 = Color(0xffdb93e1) + val colorFuchsia800 = Color(0xffc85ed1) + val colorFuchsia900 = Color(0xffad33bd) + val colorGray100 = Color(0xfffbfcfd) + val colorGray1000 = Color(0xff595e67) + val colorGray1100 = Color(0xff4c5158) + val colorGray1200 = Color(0xff3c4045) + val colorGray1300 = Color(0xff2b2d32) + val colorGray1400 = Color(0xff1b1d22) + val colorGray200 = Color(0xfff7f9fa) + val colorGray300 = Color(0xfff0f2f5) + val colorGray400 = Color(0xffe1e6ec) + val colorGray500 = Color(0xffcdd3da) + val colorGray600 = Color(0xffbdc4cc) + val colorGray700 = Color(0xffa6adb7) + val colorGray800 = Color(0xff818a95) + val colorGray900 = Color(0xff656d77) + val colorGreen100 = Color(0xfff8fdfb) + val colorGreen1000 = Color(0xff006b52) + val colorGreen1100 = Color(0xff005c45) + val colorGreen1200 = Color(0xff004933) + val colorGreen1300 = Color(0xff003420) + val colorGreen1400 = Color(0xff002311) + val colorGreen200 = Color(0xfff1fbf6) + val colorGreen300 = Color(0xffe3f7ed) + val colorGreen400 = Color(0xffc6eedb) + val colorGreen500 = Color(0xff98e1c1) + val colorGreen600 = Color(0xff71d7ae) + val colorGreen700 = Color(0xff0bc491) + val colorGreen800 = Color(0xff009b78) + val colorGreen900 = Color(0xff007a61) + val colorLime100 = Color(0xfff8fdf6) + val colorLime1000 = Color(0xff006e00) + val colorLime1100 = Color(0xff005f00) + val colorLime1200 = Color(0xff004b00) + val colorLime1300 = Color(0xff003600) + val colorLime1400 = Color(0xff002400) + val colorLime200 = Color(0xfff1fcee) + val colorLime300 = Color(0xffe0f8d9) + val colorLime400 = Color(0xffc8f1ba) + val colorLime500 = Color(0xff99e57e) + val colorLime600 = Color(0xff76db4c) + val colorLime700 = Color(0xff54c424) + val colorLime800 = Color(0xff359d18) + val colorLime900 = Color(0xff197d0c) + val colorOrange100 = Color(0xfffffaf7) + val colorOrange1000 = Color(0xffac3300) + val colorOrange1100 = Color(0xff9b2200) + val colorOrange1200 = Color(0xff850000) + val colorOrange1300 = Color(0xff620000) + val colorOrange1400 = Color(0xff450000) + val colorOrange200 = Color(0xfffff6ef) + val colorOrange300 = Color(0xffffefe4) + val colorOrange400 = Color(0xffffdfc8) + val colorOrange500 = Color(0xffffc8a1) + val colorOrange600 = Color(0xfffdb37c) + val colorOrange700 = Color(0xfff89440) + val colorOrange800 = Color(0xffdc6700) + val colorOrange900 = Color(0xffbc4500) + val colorPink100 = Color(0xfffffafb) + val colorPink1000 = Color(0xffb80a5b) + val colorPink1100 = Color(0xff9f0850) + val colorPink1200 = Color(0xff7e0642) + val colorPink1300 = Color(0xff5f002b) + val colorPink1400 = Color(0xff430017) + val colorPink200 = Color(0xfffff5f7) + val colorPink300 = Color(0xffffecf0) + val colorPink400 = Color(0xffffdee5) + val colorPink500 = Color(0xffffc2cf) + val colorPink600 = Color(0xffffadc0) + val colorPink700 = Color(0xffff88a6) + val colorPink800 = Color(0xfff7407d) + val colorPink900 = Color(0xffd20c65) + val colorPurple100 = Color(0xfffbfbff) + val colorPurple1000 = Color(0xff6b37de) + val colorPurple1100 = Color(0xff5d26cd) + val colorPurple1200 = Color(0xff4c05b5) + val colorPurple1300 = Color(0xff33008d) + val colorPurple1400 = Color(0xff200066) + val colorPurple200 = Color(0xfff8f7ff) + val colorPurple300 = Color(0xfff1efff) + val colorPurple400 = Color(0xffe6e2ff) + val colorPurple500 = Color(0xffd4cdff) + val colorPurple600 = Color(0xffc5bbff) + val colorPurple700 = Color(0xffb1a0ff) + val colorPurple800 = Color(0xff9271fd) + val colorPurple900 = Color(0xff7a47f1) + val colorRed100 = Color(0xfffffaf9) + val colorRed1000 = Color(0xffbc0f22) + val colorRed1100 = Color(0xffa4041d) + val colorRed1200 = Color(0xff850006) + val colorRed1300 = Color(0xff620000) + val colorRed1400 = Color(0xff450000) + val colorRed200 = Color(0xfffff7f6) + val colorRed300 = Color(0xffffefec) + val colorRed400 = Color(0xffffdfda) + val colorRed500 = Color(0xffffc5bc) + val colorRed600 = Color(0xffffafa5) + val colorRed700 = Color(0xffff8c81) + val colorRed800 = Color(0xffff3d3d) + val colorRed900 = Color(0xffd51928) + val colorThemeBg = Color(0xffffffff) + val colorTransparent = Color(0x00000000) + val colorYellow100 = Color(0xfffffcf0) + val colorYellow1000 = Color(0xff8f4d00) + val colorYellow1100 = Color(0xff803f00) + val colorYellow1200 = Color(0xff692e00) + val colorYellow1300 = Color(0xff541a00) + val colorYellow1400 = Color(0xff410600) + val colorYellow200 = Color(0xfffff8e0) + val colorYellow300 = Color(0xfffff2c1) + val colorYellow400 = Color(0xffffe484) + val colorYellow500 = Color(0xfffbce00) + val colorYellow600 = Color(0xfff1bd00) + val colorYellow700 = Color(0xffdea200) + val colorYellow800 = Color(0xffbe7a00) + val colorYellow900 = Color(0xff9f5b00) +} diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightHcColorTokens.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightHcColorTokens.kt new file mode 100644 index 0000000..0a68018 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightHcColorTokens.kt @@ -0,0 +1,336 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +/** + * !!! WARNING !!! + * + * THIS IS AN AUTOGENERATED FILE. + * DO NOT EDIT MANUALLY. + */ + + + +@file:Suppress("all") +package io.element.android.compound.tokens.generated.internal + +import androidx.compose.ui.graphics.Color +import io.element.android.compound.annotations.CoreColorToken + +@CoreColorToken +object LightHcColorTokens { + val colorAlphaBlue100 = Color(0x0d2474ff) + val colorAlphaBlue1000 = Color(0xfc023997) + val colorAlphaBlue1100 = Color(0xfc012e89) + val colorAlphaBlue1200 = Color(0xfc00257a) + val colorAlphaBlue1300 = Color(0xff00156b) + val colorAlphaBlue1400 = Color(0xff000b61) + val colorAlphaBlue200 = Color(0x170a70ff) + val colorAlphaBlue300 = Color(0x290b6af9) + val colorAlphaBlue400 = Color(0x380565f5) + val colorAlphaBlue500 = Color(0x5e0663ef) + val colorAlphaBlue600 = Color(0x820264ed) + val colorAlphaBlue700 = Color(0xb50062eb) + val colorAlphaBlue800 = Color(0xfc016ee9) + val colorAlphaBlue900 = Color(0xfc0241a7) + val colorAlphaCyan100 = Color(0x0f16abbb) + val colorAlphaCyan1000 = Color(0xff00437a) + val colorAlphaCyan1100 = Color(0xff003870) + val colorAlphaCyan1200 = Color(0xff003066) + val colorAlphaCyan1300 = Color(0xff001e52) + val colorAlphaCyan1400 = Color(0xff00174d) + val colorAlphaCyan200 = Color(0x1c00a8c2) + val colorAlphaCyan300 = Color(0x3800aabd) + val colorAlphaCyan400 = Color(0x4f03a9bf) + val colorAlphaCyan500 = Color(0x8a01aac1) + val colorAlphaCyan600 = Color(0xeb01b7cb) + val colorAlphaCyan700 = Color(0xff0098c2) + val colorAlphaCyan800 = Color(0xff007ab3) + val colorAlphaCyan900 = Color(0xff004d85) + val colorAlphaFuchsia100 = Color(0x0ab505cc) + val colorAlphaFuchsia1000 = Color(0xe85e007a) + val colorAlphaFuchsia1100 = Color(0xf253026f) + val colorAlphaFuchsia1200 = Color(0xff53026e) + val colorAlphaFuchsia1300 = Color(0xff3a0052) + val colorAlphaFuchsia1400 = Color(0xff34004d) + val colorAlphaFuchsia200 = Color(0x12b60cc6) + val colorAlphaFuchsia300 = Color(0x21bd09c3) + val colorAlphaFuchsia400 = Color(0x2eb105bd) + val colorAlphaFuchsia500 = Color(0x4fb207bb) + val colorAlphaFuchsia600 = Color(0x6eaa04b9) + val colorAlphaFuchsia700 = Color(0x99ab03ba) + val colorAlphaFuchsia800 = Color(0xc9a402b6) + val colorAlphaFuchsia900 = Color(0xe66a0387) + val colorAlphaGray100 = Color(0x0a366881) + val colorAlphaGray1000 = Color(0xc202060d) + val colorAlphaGray1100 = Color(0xcc03060c) + val colorAlphaGray1200 = Color(0xd4020509) + val colorAlphaGray1300 = Color(0xe000040a) + val colorAlphaGray1400 = Color(0xe6010309) + val colorAlphaGray200 = Color(0x0f052657) + val colorAlphaGray300 = Color(0x1f052e61) + val colorAlphaGray400 = Color(0x29052551) + val colorAlphaGray500 = Color(0x42011d3c) + val colorAlphaGray600 = Color(0x59011532) + val colorAlphaGray700 = Color(0x7a05152e) + val colorAlphaGray800 = Color(0x94020e22) + val colorAlphaGray900 = Color(0xba030711) + val colorAlphaGreen100 = Color(0x0f16bb69) + val colorAlphaGreen1000 = Color(0xff004d36) + val colorAlphaGreen1100 = Color(0xff00422c) + val colorAlphaGreen1200 = Color(0xff003824) + val colorAlphaGreen1300 = Color(0xff002916) + val colorAlphaGreen1400 = Color(0xff002410) + val colorAlphaGreen200 = Color(0x1c00b85c) + val colorAlphaGreen300 = Color(0x3b07b661) + val colorAlphaGreen400 = Color(0x5205b867) + val colorAlphaGreen500 = Color(0x8f01b76e) + val colorAlphaGreen600 = Color(0xf501c18a) + val colorAlphaGreen700 = Color(0xff00a37d) + val colorAlphaGreen800 = Color(0xff00856a) + val colorAlphaGreen900 = Color(0xff00573e) + val colorAlphaLime100 = Color(0x1238d40c) + val colorAlphaLime1000 = Color(0xff005200) + val colorAlphaLime1100 = Color(0xff004200) + val colorAlphaLime1200 = Color(0xff003800) + val colorAlphaLime1300 = Color(0xff002900) + val colorAlphaLime1400 = Color(0xff002400) + val colorAlphaLime200 = Color(0x262ecf02) + val colorAlphaLime300 = Color(0x473ace09) + val colorAlphaLime400 = Color(0x6637cc05) + val colorAlphaLime500 = Color(0xb540ce03) + val colorAlphaLime600 = Color(0xdb39bd00) + val colorAlphaLime700 = Color(0xe6249801) + val colorAlphaLime800 = Color(0xf2127e02) + val colorAlphaLime900 = Color(0xff005700) + val colorAlphaOrange100 = Color(0x12ff7d1a) + val colorAlphaOrange1000 = Color(0xff8a0900) + val colorAlphaOrange1100 = Color(0xff750000) + val colorAlphaOrange1200 = Color(0xff660000) + val colorAlphaOrange1300 = Color(0xff4d0000) + val colorAlphaOrange1400 = Color(0xff420000) + val colorAlphaOrange200 = Color(0x1cff6c0a) + val colorAlphaOrange300 = Color(0x38ff6d05) + val colorAlphaOrange400 = Color(0x4dff700a) + val colorAlphaOrange500 = Color(0x85fc6f03) + val colorAlphaOrange600 = Color(0xbff56e00) + val colorAlphaOrange700 = Color(0xffe06c00) + val colorAlphaOrange800 = Color(0xffc24e00) + val colorAlphaOrange900 = Color(0xff941600) + val colorAlphaPink100 = Color(0x0aff0537) + val colorAlphaPink1000 = Color(0xfa830242) + val colorAlphaPink1100 = Color(0xff70003a) + val colorAlphaPink1200 = Color(0xff660030) + val colorAlphaPink1300 = Color(0xff4d001d) + val colorAlphaPink1400 = Color(0xff420015) + val colorAlphaPink200 = Color(0x14ff1447) + val colorAlphaPink300 = Color(0x21ff0037) + val colorAlphaPink400 = Color(0x30ff0a3f) + val colorAlphaPink500 = Color(0x54ff053f) + val colorAlphaPink600 = Color(0x78ff0040) + val colorAlphaPink700 = Color(0xb3f70250) + val colorAlphaPink800 = Color(0xf5de0265) + val colorAlphaPink900 = Color(0xf78f0045) + val colorAlphaPurple100 = Color(0x0a5338ff) + val colorAlphaPurple1000 = Color(0xf24600b8) + val colorAlphaPurple1100 = Color(0xff4300a8) + val colorAlphaPurple1200 = Color(0xff360094) + val colorAlphaPurple1300 = Color(0xff240070) + val colorAlphaPurple1400 = Color(0xff1f0061) + val colorAlphaPurple200 = Color(0x12381aff) + val colorAlphaPurple300 = Color(0x1f2f0fff) + val colorAlphaPurple400 = Color(0x292b0aff) + val colorAlphaPurple500 = Color(0x452b05ff) + val colorAlphaPurple600 = Color(0x613305ff) + val colorAlphaPurple700 = Color(0x873c00ff) + val colorAlphaPurple800 = Color(0xb34c02f7) + val colorAlphaPurple900 = Color(0xe64503bf) + val colorAlphaRed100 = Color(0x0aff391f) + val colorAlphaRed1000 = Color(0xff8a000b) + val colorAlphaRed1100 = Color(0xff750000) + val colorAlphaRed1200 = Color(0xff660000) + val colorAlphaRed1300 = Color(0xff4d0000) + val colorAlphaRed1400 = Color(0xff420000) + val colorAlphaRed200 = Color(0x14ff3814) + val colorAlphaRed300 = Color(0x26ff2b0a) + val colorAlphaRed400 = Color(0x36ff2605) + val colorAlphaRed500 = Color(0x5cff2205) + val colorAlphaRed600 = Color(0x80ff1a05) + val colorAlphaRed700 = Color(0xb8ff0900) + val colorAlphaRed800 = Color(0xe3de0211) + val colorAlphaRed900 = Color(0xff99001a) + val colorAlphaYellow100 = Color(0x21ffc70f) + val colorAlphaYellow1000 = Color(0xff703200) + val colorAlphaYellow1100 = Color(0xff612700) + val colorAlphaYellow1200 = Color(0xff571d00) + val colorAlphaYellow1300 = Color(0xff470c00) + val colorAlphaYellow1400 = Color(0xff3d0500) + val colorAlphaYellow200 = Color(0x40ffc905) + val colorAlphaYellow300 = Color(0x7dffc905) + val colorAlphaYellow400 = Color(0xb8ffcc00) + val colorAlphaYellow500 = Color(0xfff0bc00) + val colorAlphaYellow600 = Color(0xffe0a500) + val colorAlphaYellow700 = Color(0xffc28100) + val colorAlphaYellow800 = Color(0xffa86500) + val colorAlphaYellow900 = Color(0xff753700) + val colorBlue100 = Color(0xfff4f8ff) + val colorBlue1000 = Color(0xff053b9a) + val colorBlue1100 = Color(0xff043088) + val colorBlue1200 = Color(0xff03277b) + val colorBlue1300 = Color(0xff001569) + val colorBlue1400 = Color(0xff000c63) + val colorBlue200 = Color(0xffe9f2ff) + val colorBlue300 = Color(0xffd8e7fe) + val colorBlue400 = Color(0xffc8ddfd) + val colorBlue500 = Color(0xffa3c6fa) + val colorBlue600 = Color(0xff7eaff6) + val colorBlue700 = Color(0xff4a8ef0) + val colorBlue800 = Color(0xff046ee8) + val colorBlue900 = Color(0xff0543a7) + val colorCyan100 = Color(0xfff1fafb) + val colorCyan1000 = Color(0xff00447b) + val colorCyan1100 = Color(0xff00376e) + val colorCyan1200 = Color(0xff002e64) + val colorCyan1300 = Color(0xff001e53) + val colorCyan1400 = Color(0xff00174d) + val colorCyan200 = Color(0xffe3f5f8) + val colorCyan300 = Color(0xffc7ecf0) + val colorCyan400 = Color(0xffb1e4eb) + val colorCyan500 = Color(0xff76d1dd) + val colorCyan600 = Color(0xff15becf) + val colorCyan700 = Color(0xff009ac3) + val colorCyan800 = Color(0xff007ab3) + val colorCyan900 = Color(0xff004c84) + val colorFuchsia100 = Color(0xfffcf5fd) + val colorFuchsia1000 = Color(0xff6c1785) + val colorFuchsia1100 = Color(0xff5c0f76) + val colorFuchsia1200 = Color(0xff52026c) + val colorFuchsia1300 = Color(0xff3b0053) + val colorFuchsia1400 = Color(0xff32004a) + val colorFuchsia200 = Color(0xfffaeefb) + val colorFuchsia300 = Color(0xfff6dff7) + val colorFuchsia400 = Color(0xfff1d2f3) + val colorFuchsia500 = Color(0xffe7b2ea) + val colorFuchsia600 = Color(0xffdb93e1) + val colorFuchsia700 = Color(0xffcb68d4) + val colorFuchsia800 = Color(0xffb937c6) + val colorFuchsia900 = Color(0xff781c90) + val colorGray100 = Color(0xfff7f9fa) + val colorGray1000 = Color(0xff3f4248) + val colorGray1100 = Color(0xff35383d) + val colorGray1200 = Color(0xff2d3034) + val colorGray1300 = Color(0xff1f2126) + val colorGray1400 = Color(0xff1a1c21) + val colorGray200 = Color(0xfff0f2f5) + val colorGray300 = Color(0xffe1e6ec) + val colorGray400 = Color(0xffd7dce3) + val colorGray500 = Color(0xffbdc4cc) + val colorGray600 = Color(0xffa6adb7) + val colorGray700 = Color(0xff878f9b) + val colorGray800 = Color(0xff6c737e) + val colorGray900 = Color(0xff474a51) + val colorGreen100 = Color(0xfff1fbf6) + val colorGreen1000 = Color(0xff004d36) + val colorGreen1100 = Color(0xff00402b) + val colorGreen1200 = Color(0xff003723) + val colorGreen1300 = Color(0xff002715) + val colorGreen1400 = Color(0xff00210f) + val colorGreen200 = Color(0xffe3f7ed) + val colorGreen300 = Color(0xffc6eedb) + val colorGreen400 = Color(0xffafe8ce) + val colorGreen500 = Color(0xff71d7ae) + val colorGreen600 = Color(0xff0bc491) + val colorGreen700 = Color(0xff00a27c) + val colorGreen800 = Color(0xff008268) + val colorGreen900 = Color(0xff00553d) + val colorLime100 = Color(0xfff1fcee) + val colorLime1000 = Color(0xff004f00) + val colorLime1100 = Color(0xff004200) + val colorLime1200 = Color(0xff003900) + val colorLime1300 = Color(0xff002900) + val colorLime1400 = Color(0xff002200) + val colorLime200 = Color(0xffe0f8d9) + val colorLime300 = Color(0xffc8f1ba) + val colorLime400 = Color(0xffafeb9b) + val colorLime500 = Color(0xff76db4c) + val colorLime600 = Color(0xff54c424) + val colorLime700 = Color(0xff3aa31a) + val colorLime800 = Color(0xff1f850f) + val colorLime900 = Color(0xff005700) + val colorOrange100 = Color(0xfffff6ef) + val colorOrange1000 = Color(0xff890800) + val colorOrange1100 = Color(0xff770000) + val colorOrange1200 = Color(0xff670000) + val colorOrange1300 = Color(0xff4c0000) + val colorOrange1400 = Color(0xff420000) + val colorOrange200 = Color(0xffffefe4) + val colorOrange300 = Color(0xffffdfc8) + val colorOrange400 = Color(0xffffd4b5) + val colorOrange500 = Color(0xfffdb37c) + val colorOrange600 = Color(0xfff89440) + val colorOrange700 = Color(0xffe26e00) + val colorOrange800 = Color(0xffc44d00) + val colorOrange900 = Color(0xff931700) + val colorPink100 = Color(0xfffff5f7) + val colorPink1000 = Color(0xff840745) + val colorPink1100 = Color(0xff72003a) + val colorPink1200 = Color(0xff64002f) + val colorPink1300 = Color(0xff4a001c) + val colorPink1400 = Color(0xff410015) + val colorPink200 = Color(0xffffecf0) + val colorPink300 = Color(0xffffdee5) + val colorPink400 = Color(0xffffd0da) + val colorPink500 = Color(0xffffadc0) + val colorPink600 = Color(0xffff88a6) + val colorPink700 = Color(0xfff94e84) + val colorPink800 = Color(0xffe00c6a) + val colorPink900 = Color(0xff92084b) + val colorPurple100 = Color(0xfff8f7ff) + val colorPurple1000 = Color(0xff4f0dba) + val colorPurple1100 = Color(0xff4200a6) + val colorPurple1200 = Color(0xff360094) + val colorPurple1300 = Color(0xff240070) + val colorPurple1400 = Color(0xff1f0062) + val colorPurple200 = Color(0xfff1efff) + val colorPurple300 = Color(0xffe6e2ff) + val colorPurple400 = Color(0xffddd8ff) + val colorPurple500 = Color(0xffc5bbff) + val colorPurple600 = Color(0xffb1a0ff) + val colorPurple700 = Color(0xff9778fe) + val colorPurple800 = Color(0xff824ef9) + val colorPurple900 = Color(0xff571cc4) + val colorRed100 = Color(0xfffff7f6) + val colorRed1000 = Color(0xff8b000c) + val colorRed1100 = Color(0xff770000) + val colorRed1200 = Color(0xff670000) + val colorRed1300 = Color(0xff4c0000) + val colorRed1400 = Color(0xff420000) + val colorRed200 = Color(0xffffefec) + val colorRed300 = Color(0xffffdfda) + val colorRed400 = Color(0xffffd1ca) + val colorRed500 = Color(0xffffafa5) + val colorRed600 = Color(0xffff8c81) + val colorRed700 = Color(0xffff4e49) + val colorRed800 = Color(0xffe11e2a) + val colorRed900 = Color(0xff99001a) + val colorThemeBg = Color(0xffffffff) + val colorTransparent = Color(0x00000000) + val colorYellow100 = Color(0xfffff8e0) + val colorYellow1000 = Color(0xff6e3100) + val colorYellow1100 = Color(0xff612600) + val colorYellow1200 = Color(0xff571d00) + val colorYellow1300 = Color(0xff450c00) + val colorYellow1400 = Color(0xff3f0500) + val colorYellow200 = Color(0xfffff2c1) + val colorYellow300 = Color(0xffffe484) + val colorYellow400 = Color(0xffffda49) + val colorYellow500 = Color(0xfff1bd00) + val colorYellow600 = Color(0xffdea200) + val colorYellow700 = Color(0xffc38100) + val colorYellow800 = Color(0xffa76300) + val colorYellow900 = Color(0xff773800) +} diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/utils/ColorUtils.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/utils/ColorUtils.kt new file mode 100644 index 0000000..778f5d7 --- /dev/null +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/utils/ColorUtils.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.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/compound/src/main/res/drawable/ic_compound_admin.xml b/libraries/compound/src/main/res/drawable/ic_compound_admin.xml new file mode 100644 index 0000000..6cedd3a --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_admin.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_arrow_down.xml b/libraries/compound/src/main/res/drawable/ic_compound_arrow_down.xml new file mode 100644 index 0000000..ceb5dbf --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_arrow_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_arrow_left.xml b/libraries/compound/src/main/res/drawable/ic_compound_arrow_left.xml new file mode 100644 index 0000000..0c87601 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_arrow_left.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_arrow_right.xml b/libraries/compound/src/main/res/drawable/ic_compound_arrow_right.xml new file mode 100644 index 0000000..5703b5e --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_arrow_right.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_arrow_up.xml b/libraries/compound/src/main/res/drawable/ic_compound_arrow_up.xml new file mode 100644 index 0000000..8591911 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_arrow_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_arrow_up_right.xml b/libraries/compound/src/main/res/drawable/ic_compound_arrow_up_right.xml new file mode 100644 index 0000000..9f04df4 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_arrow_up_right.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_ask_to_join.xml b/libraries/compound/src/main/res/drawable/ic_compound_ask_to_join.xml new file mode 100644 index 0000000..3d92b4c --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_ask_to_join.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_ask_to_join_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_ask_to_join_solid.xml new file mode 100644 index 0000000..5bd208e --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_ask_to_join_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_attachment.xml b/libraries/compound/src/main/res/drawable/ic_compound_attachment.xml new file mode 100644 index 0000000..7d96cfc --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_attachment.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_audio.xml b/libraries/compound/src/main/res/drawable/ic_compound_audio.xml new file mode 100644 index 0000000..adb53a9 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_audio.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_block.xml b/libraries/compound/src/main/res/drawable/ic_compound_block.xml new file mode 100644 index 0000000..4519a8b --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_block.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_bold.xml b/libraries/compound/src/main/res/drawable/ic_compound_bold.xml new file mode 100644 index 0000000..ca87120 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_bold.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_calendar.xml b/libraries/compound/src/main/res/drawable/ic_compound_calendar.xml new file mode 100644 index 0000000..490317c --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_calendar.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_chart.xml b/libraries/compound/src/main/res/drawable/ic_compound_chart.xml new file mode 100644 index 0000000..c729cf8 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_chart.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_chat.xml b/libraries/compound/src/main/res/drawable/ic_compound_chat.xml new file mode 100644 index 0000000..f27c3d9 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_chat.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_chat_new.xml b/libraries/compound/src/main/res/drawable/ic_compound_chat_new.xml new file mode 100644 index 0000000..73e8488 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_chat_new.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_chat_problem.xml b/libraries/compound/src/main/res/drawable/ic_compound_chat_problem.xml new file mode 100644 index 0000000..ada411c --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_chat_problem.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_chat_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_chat_solid.xml new file mode 100644 index 0000000..28b82f2 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_chat_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_check.xml b/libraries/compound/src/main/res/drawable/ic_compound_check.xml new file mode 100644 index 0000000..8a823ed --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_check_circle.xml b/libraries/compound/src/main/res/drawable/ic_compound_check_circle.xml new file mode 100644 index 0000000..6522854 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_check_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_check_circle_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_check_circle_solid.xml new file mode 100644 index 0000000..7c35f57 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_check_circle_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_chevron_down.xml b/libraries/compound/src/main/res/drawable/ic_compound_chevron_down.xml new file mode 100644 index 0000000..1adf6f4 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_chevron_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_chevron_left.xml b/libraries/compound/src/main/res/drawable/ic_compound_chevron_left.xml new file mode 100644 index 0000000..8251b03 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_chevron_left.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_chevron_right.xml b/libraries/compound/src/main/res/drawable/ic_compound_chevron_right.xml new file mode 100644 index 0000000..6e22300 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_chevron_right.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_chevron_up.xml b/libraries/compound/src/main/res/drawable/ic_compound_chevron_up.xml new file mode 100644 index 0000000..063b1cf --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_chevron_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_chevron_up_down.xml b/libraries/compound/src/main/res/drawable/ic_compound_chevron_up_down.xml new file mode 100644 index 0000000..6add4ed --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_chevron_up_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_circle.xml b/libraries/compound/src/main/res/drawable/ic_compound_circle.xml new file mode 100644 index 0000000..ef828f6 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_close.xml b/libraries/compound/src/main/res/drawable/ic_compound_close.xml new file mode 100644 index 0000000..c655fbd --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_cloud.xml b/libraries/compound/src/main/res/drawable/ic_compound_cloud.xml new file mode 100644 index 0000000..689d7d7 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_cloud.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_cloud_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_cloud_solid.xml new file mode 100644 index 0000000..784aef7 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_cloud_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_code.xml b/libraries/compound/src/main/res/drawable/ic_compound_code.xml new file mode 100644 index 0000000..08b2c47 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_code.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_collapse.xml b/libraries/compound/src/main/res/drawable/ic_compound_collapse.xml new file mode 100644 index 0000000..55f2545 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_collapse.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_company.xml b/libraries/compound/src/main/res/drawable/ic_compound_company.xml new file mode 100644 index 0000000..d8397ef --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_company.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_compose.xml b/libraries/compound/src/main/res/drawable/ic_compound_compose.xml new file mode 100644 index 0000000..fff7a75 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_compose.xml @@ -0,0 +1,14 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_computer.xml b/libraries/compound/src/main/res/drawable/ic_compound_computer.xml new file mode 100644 index 0000000..e3483f5 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_computer.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_copy.xml b/libraries/compound/src/main/res/drawable/ic_compound_copy.xml new file mode 100644 index 0000000..b163c0c --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_copy.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_dark_mode.xml b/libraries/compound/src/main/res/drawable/ic_compound_dark_mode.xml new file mode 100644 index 0000000..74a9123 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_dark_mode.xml @@ -0,0 +1,11 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_delete.xml b/libraries/compound/src/main/res/drawable/ic_compound_delete.xml new file mode 100644 index 0000000..e072ce7 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_devices.xml b/libraries/compound/src/main/res/drawable/ic_compound_devices.xml new file mode 100644 index 0000000..e2bb7c1 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_devices.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_dial_pad.xml b/libraries/compound/src/main/res/drawable/ic_compound_dial_pad.xml new file mode 100644 index 0000000..a6cbc94 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_dial_pad.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_document.xml b/libraries/compound/src/main/res/drawable/ic_compound_document.xml new file mode 100644 index 0000000..ad96267 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_document.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_download.xml b/libraries/compound/src/main/res/drawable/ic_compound_download.xml new file mode 100644 index 0000000..04fa025 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_download.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_download_ios.xml b/libraries/compound/src/main/res/drawable/ic_compound_download_ios.xml new file mode 100644 index 0000000..7d17468 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_download_ios.xml @@ -0,0 +1,12 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_drag_grid.xml b/libraries/compound/src/main/res/drawable/ic_compound_drag_grid.xml new file mode 100644 index 0000000..6f2825d --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_drag_grid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_drag_list.xml b/libraries/compound/src/main/res/drawable/ic_compound_drag_list.xml new file mode 100644 index 0000000..f1b4a98 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_drag_list.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_earpiece.xml b/libraries/compound/src/main/res/drawable/ic_compound_earpiece.xml new file mode 100644 index 0000000..d16021d --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_earpiece.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_edit.xml b/libraries/compound/src/main/res/drawable/ic_compound_edit.xml new file mode 100644 index 0000000..756c3f0 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_edit.xml @@ -0,0 +1,11 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_edit_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_edit_solid.xml new file mode 100644 index 0000000..15bae10 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_edit_solid.xml @@ -0,0 +1,11 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_email.xml b/libraries/compound/src/main/res/drawable/ic_compound_email.xml new file mode 100644 index 0000000..a63a85b --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_email.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_email_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_email_solid.xml new file mode 100644 index 0000000..f69732b --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_email_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_end_call.xml b/libraries/compound/src/main/res/drawable/ic_compound_end_call.xml new file mode 100644 index 0000000..129af0a --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_end_call.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_error.xml b/libraries/compound/src/main/res/drawable/ic_compound_error.xml new file mode 100644 index 0000000..97422a0 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_error.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_error_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_error_solid.xml new file mode 100644 index 0000000..1a3f07f --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_error_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_expand.xml b/libraries/compound/src/main/res/drawable/ic_compound_expand.xml new file mode 100644 index 0000000..50520c0 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_expand.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_explore.xml b/libraries/compound/src/main/res/drawable/ic_compound_explore.xml new file mode 100644 index 0000000..51cf8c3 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_explore.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_export_archive.xml b/libraries/compound/src/main/res/drawable/ic_compound_export_archive.xml new file mode 100644 index 0000000..94ed347 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_export_archive.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_extensions.xml b/libraries/compound/src/main/res/drawable/ic_compound_extensions.xml new file mode 100644 index 0000000..d4c6aaa --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_extensions.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_extensions_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_extensions_solid.xml new file mode 100644 index 0000000..1b2a265 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_extensions_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_favourite.xml b/libraries/compound/src/main/res/drawable/ic_compound_favourite.xml new file mode 100644 index 0000000..2d56178 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_favourite.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_favourite_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_favourite_solid.xml new file mode 100644 index 0000000..21a77a1 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_favourite_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_file_error.xml b/libraries/compound/src/main/res/drawable/ic_compound_file_error.xml new file mode 100644 index 0000000..b1c60ef --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_file_error.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_files.xml b/libraries/compound/src/main/res/drawable/ic_compound_files.xml new file mode 100644 index 0000000..16b71ff --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_files.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_filter.xml b/libraries/compound/src/main/res/drawable/ic_compound_filter.xml new file mode 100644 index 0000000..4fcbec8 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_filter.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_forward.xml b/libraries/compound/src/main/res/drawable/ic_compound_forward.xml new file mode 100644 index 0000000..c489c15 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_forward.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_grid.xml b/libraries/compound/src/main/res/drawable/ic_compound_grid.xml new file mode 100644 index 0000000..023f954 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_grid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_group.xml b/libraries/compound/src/main/res/drawable/ic_compound_group.xml new file mode 100644 index 0000000..800a941 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_group.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_guest.xml b/libraries/compound/src/main/res/drawable/ic_compound_guest.xml new file mode 100644 index 0000000..cdba0d5 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_guest.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_headphones_off_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_headphones_off_solid.xml new file mode 100644 index 0000000..551f0fc --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_headphones_off_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_headphones_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_headphones_solid.xml new file mode 100644 index 0000000..e8fdf3a --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_headphones_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_help.xml b/libraries/compound/src/main/res/drawable/ic_compound_help.xml new file mode 100644 index 0000000..667f268 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_help.xml @@ -0,0 +1,12 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_help_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_help_solid.xml new file mode 100644 index 0000000..29b11c6 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_help_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_history.xml b/libraries/compound/src/main/res/drawable/ic_compound_history.xml new file mode 100644 index 0000000..10e4f22 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_history.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_home.xml b/libraries/compound/src/main/res/drawable/ic_compound_home.xml new file mode 100644 index 0000000..8cdb4fc --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_home.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_home_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_home_solid.xml new file mode 100644 index 0000000..ce94991 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_home_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_host.xml b/libraries/compound/src/main/res/drawable/ic_compound_host.xml new file mode 100644 index 0000000..048d4c1 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_host.xml @@ -0,0 +1,14 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_image.xml b/libraries/compound/src/main/res/drawable/ic_compound_image.xml new file mode 100644 index 0000000..5572875 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_image.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_image_error.xml b/libraries/compound/src/main/res/drawable/ic_compound_image_error.xml new file mode 100644 index 0000000..6f660f7 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_image_error.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_indent_decrease.xml b/libraries/compound/src/main/res/drawable/ic_compound_indent_decrease.xml new file mode 100644 index 0000000..5fba2cd --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_indent_decrease.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_indent_increase.xml b/libraries/compound/src/main/res/drawable/ic_compound_indent_increase.xml new file mode 100644 index 0000000..2cad469 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_indent_increase.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_info.xml b/libraries/compound/src/main/res/drawable/ic_compound_info.xml new file mode 100644 index 0000000..02808d1 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_info.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_info_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_info_solid.xml new file mode 100644 index 0000000..7155a38 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_info_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_inline_code.xml b/libraries/compound/src/main/res/drawable/ic_compound_inline_code.xml new file mode 100644 index 0000000..2b047fa --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_inline_code.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_italic.xml b/libraries/compound/src/main/res/drawable/ic_compound_italic.xml new file mode 100644 index 0000000..e47a097 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_italic.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_key.xml b/libraries/compound/src/main/res/drawable/ic_compound_key.xml new file mode 100644 index 0000000..0bd5d8d --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_key.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_key_off.xml b/libraries/compound/src/main/res/drawable/ic_compound_key_off.xml new file mode 100644 index 0000000..ce8ed0d --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_key_off.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_key_off_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_key_off_solid.xml new file mode 100644 index 0000000..4dbf8c9 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_key_off_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_key_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_key_solid.xml new file mode 100644 index 0000000..79253ea --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_key_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_keyboard.xml b/libraries/compound/src/main/res/drawable/ic_compound_keyboard.xml new file mode 100644 index 0000000..19ebbab --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_keyboard.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_labs.xml b/libraries/compound/src/main/res/drawable/ic_compound_labs.xml new file mode 100644 index 0000000..9d64b76 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_labs.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_leave.xml b/libraries/compound/src/main/res/drawable/ic_compound_leave.xml new file mode 100644 index 0000000..f1c1869 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_leave.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_link.xml b/libraries/compound/src/main/res/drawable/ic_compound_link.xml new file mode 100644 index 0000000..fc3e504 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_link.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_linux.xml b/libraries/compound/src/main/res/drawable/ic_compound_linux.xml new file mode 100644 index 0000000..9b766da --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_linux.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_list_bulleted.xml b/libraries/compound/src/main/res/drawable/ic_compound_list_bulleted.xml new file mode 100644 index 0000000..3e50a2b --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_list_bulleted.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_list_numbered.xml b/libraries/compound/src/main/res/drawable/ic_compound_list_numbered.xml new file mode 100644 index 0000000..a350089 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_list_numbered.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_list_view.xml b/libraries/compound/src/main/res/drawable/ic_compound_list_view.xml new file mode 100644 index 0000000..8af526a --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_list_view.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_location_navigator.xml b/libraries/compound/src/main/res/drawable/ic_compound_location_navigator.xml new file mode 100644 index 0000000..54d07dc --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_location_navigator.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_location_navigator_centred.xml b/libraries/compound/src/main/res/drawable/ic_compound_location_navigator_centred.xml new file mode 100644 index 0000000..b6afaa6 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_location_navigator_centred.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_location_pin.xml b/libraries/compound/src/main/res/drawable/ic_compound_location_pin.xml new file mode 100644 index 0000000..08101cd --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_location_pin.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_location_pin_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_location_pin_solid.xml new file mode 100644 index 0000000..a03272a --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_location_pin_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_lock.xml b/libraries/compound/src/main/res/drawable/ic_compound_lock.xml new file mode 100644 index 0000000..2225361 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_lock.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_lock_off.xml b/libraries/compound/src/main/res/drawable/ic_compound_lock_off.xml new file mode 100644 index 0000000..3f34eb5 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_lock_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_lock_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_lock_solid.xml new file mode 100644 index 0000000..d39e85a --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_lock_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_mac.xml b/libraries/compound/src/main/res/drawable/ic_compound_mac.xml new file mode 100644 index 0000000..47a4d69 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_mac.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_mark_as_read.xml b/libraries/compound/src/main/res/drawable/ic_compound_mark_as_read.xml new file mode 100644 index 0000000..7798596 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_mark_as_read.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_mark_as_unread.xml b/libraries/compound/src/main/res/drawable/ic_compound_mark_as_unread.xml new file mode 100644 index 0000000..0ec3991 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_mark_as_unread.xml @@ -0,0 +1,14 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_mark_threads_as_read.xml b/libraries/compound/src/main/res/drawable/ic_compound_mark_threads_as_read.xml new file mode 100644 index 0000000..82bf433 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_mark_threads_as_read.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_marker_read_receipts.xml b/libraries/compound/src/main/res/drawable/ic_compound_marker_read_receipts.xml new file mode 100644 index 0000000..6205a6a --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_marker_read_receipts.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_mention.xml b/libraries/compound/src/main/res/drawable/ic_compound_mention.xml new file mode 100644 index 0000000..0ec8a49 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_mention.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_menu.xml b/libraries/compound/src/main/res/drawable/ic_compound_menu.xml new file mode 100644 index 0000000..87f9617 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_menu.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_mic_off.xml b/libraries/compound/src/main/res/drawable/ic_compound_mic_off.xml new file mode 100644 index 0000000..8ce70e5 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_mic_off.xml @@ -0,0 +1,14 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_mic_off_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_mic_off_solid.xml new file mode 100644 index 0000000..ca3bd70 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_mic_off_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_mic_on.xml b/libraries/compound/src/main/res/drawable/ic_compound_mic_on.xml new file mode 100644 index 0000000..2c6cdd5 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_mic_on.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_mic_on_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_mic_on_solid.xml new file mode 100644 index 0000000..8b7027c --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_mic_on_solid.xml @@ -0,0 +1,12 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_minus.xml b/libraries/compound/src/main/res/drawable/ic_compound_minus.xml new file mode 100644 index 0000000..85f71f8 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_minus.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_mobile.xml b/libraries/compound/src/main/res/drawable/ic_compound_mobile.xml new file mode 100644 index 0000000..48c11f6 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_mobile.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_notifications.xml b/libraries/compound/src/main/res/drawable/ic_compound_notifications.xml new file mode 100644 index 0000000..b7af3f0 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_notifications.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_notifications_off.xml b/libraries/compound/src/main/res/drawable/ic_compound_notifications_off.xml new file mode 100644 index 0000000..6542c70 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_notifications_off.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_notifications_off_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_notifications_off_solid.xml new file mode 100644 index 0000000..502f899 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_notifications_off_solid.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_notifications_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_notifications_solid.xml new file mode 100644 index 0000000..1e025f9 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_notifications_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_offline.xml b/libraries/compound/src/main/res/drawable/ic_compound_offline.xml new file mode 100644 index 0000000..66862b5 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_offline.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_overflow_horizontal.xml b/libraries/compound/src/main/res/drawable/ic_compound_overflow_horizontal.xml new file mode 100644 index 0000000..5de8de3 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_overflow_horizontal.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_overflow_vertical.xml b/libraries/compound/src/main/res/drawable/ic_compound_overflow_vertical.xml new file mode 100644 index 0000000..09af569 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_overflow_vertical.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_pause.xml b/libraries/compound/src/main/res/drawable/ic_compound_pause.xml new file mode 100644 index 0000000..7ccfb01 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_pause_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_pause_solid.xml new file mode 100644 index 0000000..94545f8 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_pause_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_pin.xml b/libraries/compound/src/main/res/drawable/ic_compound_pin.xml new file mode 100644 index 0000000..343ca14 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_pin.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_pin_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_pin_solid.xml new file mode 100644 index 0000000..4f27751 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_pin_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_play.xml b/libraries/compound/src/main/res/drawable/ic_compound_play.xml new file mode 100644 index 0000000..372cdfe --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_play.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_play_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_play_solid.xml new file mode 100644 index 0000000..e20b1e5 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_play_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_plus.xml b/libraries/compound/src/main/res/drawable/ic_compound_plus.xml new file mode 100644 index 0000000..c4b3d11 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_plus.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_polls.xml b/libraries/compound/src/main/res/drawable/ic_compound_polls.xml new file mode 100644 index 0000000..0877d2c --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_polls.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_polls_end.xml b/libraries/compound/src/main/res/drawable/ic_compound_polls_end.xml new file mode 100644 index 0000000..eab0f9a --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_polls_end.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_pop_out.xml b/libraries/compound/src/main/res/drawable/ic_compound_pop_out.xml new file mode 100644 index 0000000..0feace4 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_pop_out.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_preferences.xml b/libraries/compound/src/main/res/drawable/ic_compound_preferences.xml new file mode 100644 index 0000000..d7089e0 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_preferences.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_presence_outline_8_x_8.xml b/libraries/compound/src/main/res/drawable/ic_compound_presence_outline_8_x_8.xml new file mode 100644 index 0000000..603bc8a --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_presence_outline_8_x_8.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_presence_solid_8_x_8.xml b/libraries/compound/src/main/res/drawable/ic_compound_presence_solid_8_x_8.xml new file mode 100644 index 0000000..3be8cc0 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_presence_solid_8_x_8.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_presence_strikethrough_8_x_8.xml b/libraries/compound/src/main/res/drawable/ic_compound_presence_strikethrough_8_x_8.xml new file mode 100644 index 0000000..6bd7114 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_presence_strikethrough_8_x_8.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_public.xml b/libraries/compound/src/main/res/drawable/ic_compound_public.xml new file mode 100644 index 0000000..7ed4a22 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_public.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_qr_code.xml b/libraries/compound/src/main/res/drawable/ic_compound_qr_code.xml new file mode 100644 index 0000000..75b3f9f --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_qr_code.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_quote.xml b/libraries/compound/src/main/res/drawable/ic_compound_quote.xml new file mode 100644 index 0000000..550d7c8 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_quote.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_raised_hand_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_raised_hand_solid.xml new file mode 100644 index 0000000..aa16cae --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_raised_hand_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_reaction.xml b/libraries/compound/src/main/res/drawable/ic_compound_reaction.xml new file mode 100644 index 0000000..f0dc9f2 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_reaction.xml @@ -0,0 +1,12 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_reaction_add.xml b/libraries/compound/src/main/res/drawable/ic_compound_reaction_add.xml new file mode 100644 index 0000000..34716db --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_reaction_add.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_reaction_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_reaction_solid.xml new file mode 100644 index 0000000..3604031 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_reaction_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_reply.xml b/libraries/compound/src/main/res/drawable/ic_compound_reply.xml new file mode 100644 index 0000000..a4f7a6d --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_reply.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_restart.xml b/libraries/compound/src/main/res/drawable/ic_compound_restart.xml new file mode 100644 index 0000000..6f862bd --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_restart.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_room.xml b/libraries/compound/src/main/res/drawable/ic_compound_room.xml new file mode 100644 index 0000000..3f57015 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_room.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_search.xml b/libraries/compound/src/main/res/drawable/ic_compound_search.xml new file mode 100644 index 0000000..76638ca --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_search.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_send.xml b/libraries/compound/src/main/res/drawable/ic_compound_send.xml new file mode 100644 index 0000000..a37b499 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_send.xml @@ -0,0 +1,11 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_send_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_send_solid.xml new file mode 100644 index 0000000..0d41f4f --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_send_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_settings.xml b/libraries/compound/src/main/res/drawable/ic_compound_settings.xml new file mode 100644 index 0000000..3606bf2 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_settings.xml @@ -0,0 +1,12 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_settings_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_settings_solid.xml new file mode 100644 index 0000000..b7f6bb4 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_settings_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_share.xml b/libraries/compound/src/main/res/drawable/ic_compound_share.xml new file mode 100644 index 0000000..58bb31b --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_share.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_share_android.xml b/libraries/compound/src/main/res/drawable/ic_compound_share_android.xml new file mode 100644 index 0000000..3c82973 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_share_android.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_share_ios.xml b/libraries/compound/src/main/res/drawable/ic_compound_share_ios.xml new file mode 100644 index 0000000..9027271 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_share_ios.xml @@ -0,0 +1,12 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_share_screen.xml b/libraries/compound/src/main/res/drawable/ic_compound_share_screen.xml new file mode 100644 index 0000000..10ef391 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_share_screen.xml @@ -0,0 +1,12 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_share_screen_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_share_screen_solid.xml new file mode 100644 index 0000000..255e373 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_share_screen_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_shield.xml b/libraries/compound/src/main/res/drawable/ic_compound_shield.xml new file mode 100644 index 0000000..a75afcb --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_shield.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_sidebar.xml b/libraries/compound/src/main/res/drawable/ic_compound_sidebar.xml new file mode 100644 index 0000000..1f5b355 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_sidebar.xml @@ -0,0 +1,11 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_sign_out.xml b/libraries/compound/src/main/res/drawable/ic_compound_sign_out.xml new file mode 100644 index 0000000..641cac9 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_sign_out.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_spinner.xml b/libraries/compound/src/main/res/drawable/ic_compound_spinner.xml new file mode 100644 index 0000000..d3577cb --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_spinner.xml @@ -0,0 +1,11 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_spotlight.xml b/libraries/compound/src/main/res/drawable/ic_compound_spotlight.xml new file mode 100644 index 0000000..df9e43f --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_spotlight.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_spotlight_view.xml b/libraries/compound/src/main/res/drawable/ic_compound_spotlight_view.xml new file mode 100644 index 0000000..5a33b46 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_spotlight_view.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_strikethrough.xml b/libraries/compound/src/main/res/drawable/ic_compound_strikethrough.xml new file mode 100644 index 0000000..010baa2 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_strikethrough.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_switch_camera_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_switch_camera_solid.xml new file mode 100644 index 0000000..8d275c6 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_switch_camera_solid.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_take_photo.xml b/libraries/compound/src/main/res/drawable/ic_compound_take_photo.xml new file mode 100644 index 0000000..fdd8ebd --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_take_photo.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_take_photo_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_take_photo_solid.xml new file mode 100644 index 0000000..bc3b070 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_take_photo_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_text_formatting.xml b/libraries/compound/src/main/res/drawable/ic_compound_text_formatting.xml new file mode 100644 index 0000000..c87c152 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_text_formatting.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_threads.xml b/libraries/compound/src/main/res/drawable/ic_compound_threads.xml new file mode 100644 index 0000000..4fa1e87 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_threads.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_threads_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_threads_solid.xml new file mode 100644 index 0000000..4db2922 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_threads_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_time.xml b/libraries/compound/src/main/res/drawable/ic_compound_time.xml new file mode 100644 index 0000000..7fc0404 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_time.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_underline.xml b/libraries/compound/src/main/res/drawable/ic_compound_underline.xml new file mode 100644 index 0000000..3c8e5b7 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_underline.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_unknown.xml b/libraries/compound/src/main/res/drawable/ic_compound_unknown.xml new file mode 100644 index 0000000..606e20b --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_unknown.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_unknown_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_unknown_solid.xml new file mode 100644 index 0000000..5aec9fa --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_unknown_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_unpin.xml b/libraries/compound/src/main/res/drawable/ic_compound_unpin.xml new file mode 100644 index 0000000..361c4be --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_unpin.xml @@ -0,0 +1,14 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_user.xml b/libraries/compound/src/main/res/drawable/ic_compound_user.xml new file mode 100644 index 0000000..b0d51f5 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_user.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_user_add.xml b/libraries/compound/src/main/res/drawable/ic_compound_user_add.xml new file mode 100644 index 0000000..9965dad --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_user_add.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_user_add_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_user_add_solid.xml new file mode 100644 index 0000000..685d751 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_user_add_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_user_profile.xml b/libraries/compound/src/main/res/drawable/ic_compound_user_profile.xml new file mode 100644 index 0000000..f66ebd0 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_user_profile.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_user_profile_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_user_profile_solid.xml new file mode 100644 index 0000000..54ada8e --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_user_profile_solid.xml @@ -0,0 +1,12 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_user_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_user_solid.xml new file mode 100644 index 0000000..64409fc --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_user_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_verified.xml b/libraries/compound/src/main/res/drawable/ic_compound_verified.xml new file mode 100644 index 0000000..510e386 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_verified.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_video_call.xml b/libraries/compound/src/main/res/drawable/ic_compound_video_call.xml new file mode 100644 index 0000000..bee275a --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_video_call.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_video_call_declined_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_video_call_declined_solid.xml new file mode 100644 index 0000000..b9a8090 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_video_call_declined_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_video_call_missed_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_video_call_missed_solid.xml new file mode 100644 index 0000000..4313355 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_video_call_missed_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_video_call_off.xml b/libraries/compound/src/main/res/drawable/ic_compound_video_call_off.xml new file mode 100644 index 0000000..ab9cbf5 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_video_call_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_video_call_off_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_video_call_off_solid.xml new file mode 100644 index 0000000..10dd687 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_video_call_off_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_video_call_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_video_call_solid.xml new file mode 100644 index 0000000..dc78198 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_video_call_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_visibility_off.xml b/libraries/compound/src/main/res/drawable/ic_compound_visibility_off.xml new file mode 100644 index 0000000..8151848 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_visibility_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_visibility_on.xml b/libraries/compound/src/main/res/drawable/ic_compound_visibility_on.xml new file mode 100644 index 0000000..59b23e3 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_visibility_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_voice_call.xml b/libraries/compound/src/main/res/drawable/ic_compound_voice_call.xml new file mode 100644 index 0000000..258a541 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_voice_call.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_voice_call_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_voice_call_solid.xml new file mode 100644 index 0000000..6aed590 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_voice_call_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_volume_off.xml b/libraries/compound/src/main/res/drawable/ic_compound_volume_off.xml new file mode 100644 index 0000000..884d3c3 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_volume_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_volume_off_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_volume_off_solid.xml new file mode 100644 index 0000000..b4d75ae --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_volume_off_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_volume_on.xml b/libraries/compound/src/main/res/drawable/ic_compound_volume_on.xml new file mode 100644 index 0000000..bbc3eff --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_volume_on.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_volume_on_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_volume_on_solid.xml new file mode 100644 index 0000000..2041eab --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_volume_on_solid.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_warning.xml b/libraries/compound/src/main/res/drawable/ic_compound_warning.xml new file mode 100644 index 0000000..3b196aa --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_warning.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_web_browser.xml b/libraries/compound/src/main/res/drawable/ic_compound_web_browser.xml new file mode 100644 index 0000000..173b24a --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_web_browser.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_windows.xml b/libraries/compound/src/main/res/drawable/ic_compound_windows.xml new file mode 100644 index 0000000..0aa8b49 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_windows.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_workspace.xml b/libraries/compound/src/main/res/drawable/ic_compound_workspace.xml new file mode 100644 index 0000000..e5629d8 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_workspace.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_workspace_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_workspace_solid.xml new file mode 100644 index 0000000..12c582e --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_workspace_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/AvatarColorsTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/AvatarColorsTest.kt new file mode 100644 index 0000000..01c3e78 --- /dev/null +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/AvatarColorsTest.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.screenshot + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.captureRoboImage +import io.element.android.compound.screenshot.utils.screenshotFile +import io.element.android.compound.theme.AvatarColorsPreviewDark +import io.element.android.compound.theme.AvatarColorsPreviewLight +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class AvatarColorsTest { + @Test + @Config(sdk = [35], qualifiers = "xxhdpi") + fun screenshots() { + captureRoboImage(file = screenshotFile("Avatar Colors - Light.png")) { + AvatarColorsPreviewLight() + } + captureRoboImage(file = screenshotFile("Avatar Colors - Dark.png")) { + AvatarColorsPreviewDark() + } + } +} diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundIconTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundIconTest.kt new file mode 100644 index 0000000..6e15233 --- /dev/null +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundIconTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.screenshot + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.captureRoboImage +import io.element.android.compound.previews.IconsCompoundPreviewDark +import io.element.android.compound.previews.IconsCompoundPreviewLight +import io.element.android.compound.previews.IconsCompoundPreviewRtl +import io.element.android.compound.previews.IconsPreview +import io.element.android.compound.screenshot.utils.screenshotFile +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import kotlinx.collections.immutable.toImmutableList +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class CompoundIconTest { + @Test + @Config(sdk = [35], qualifiers = "w1024dp-h2048dp") + fun screenshots() { + captureRoboImage(file = screenshotFile("Compound Icons - Light.png")) { + IconsCompoundPreviewLight() + } + captureRoboImage(file = screenshotFile("Compound Icons - Rtl.png")) { + IconsCompoundPreviewRtl() + } + captureRoboImage(file = screenshotFile("Compound Icons - Dark.png")) { + IconsCompoundPreviewDark() + } + captureRoboImage(file = screenshotFile("Compound Vector Icons - Light.png")) { + val content: List<@Composable ColumnScope.() -> Unit> = CompoundIcons.all.map { + @Composable { Icon(imageVector = it, contentDescription = null) } + } + ElementTheme { + IconsPreview( + title = "Compound Vector Icons", + content = content.toImmutableList() + ) + } + } + captureRoboImage(file = screenshotFile("Compound Vector Icons - Dark.png")) { + val content: List<@Composable ColumnScope.() -> Unit> = CompoundIcons.all.map { + @Composable { Icon(imageVector = it, contentDescription = null) } + } + ElementTheme(darkTheme = true) { + IconsPreview( + title = "Compound Vector Icons", + content = content.toImmutableList() + ) + } + } + } +} diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundTypographyTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundTypographyTest.kt new file mode 100644 index 0000000..902bab6 --- /dev/null +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundTypographyTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.screenshot + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.captureRoboImage +import io.element.android.compound.screenshot.utils.screenshotFile +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.TypographyTokens +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class CompoundTypographyTest { + @Test + @Config(sdk = [35], qualifiers = "h2048dp-xxhdpi") + fun screenshots() { + captureRoboImage(file = screenshotFile("Compound Typography.png")) { + ElementTheme { + Surface { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + with(TypographyTokens) { + TypographyTokenPreview(fontHeadingXlBold, "Heading XL Bold") + TypographyTokenPreview(fontHeadingXlRegular, "Heading XL Regular") + TypographyTokenPreview(fontHeadingLgBold, "Heading LG Bold") + TypographyTokenPreview(fontHeadingLgRegular, "Heading LG Regular") + TypographyTokenPreview(fontHeadingMdBold, "Heading MD Bold") + TypographyTokenPreview(fontHeadingMdRegular, "Heading MD Regular") + TypographyTokenPreview(fontHeadingSmMedium, "Heading SM Medium") + TypographyTokenPreview(fontHeadingSmRegular, "Heading SM Regular") + TypographyTokenPreview(fontBodyLgMedium, "Body LG Medium") + TypographyTokenPreview(fontBodyLgRegular, "Body LG Regular") + TypographyTokenPreview(fontBodyMdMedium, "Body MD Medium") + TypographyTokenPreview(fontBodyMdRegular, "Body MD Regular") + TypographyTokenPreview(fontBodySmMedium, "Body SM Medium") + TypographyTokenPreview(fontBodySmRegular, "Body SM Regular") + TypographyTokenPreview(fontBodyXsMedium, "Body XS Medium") + TypographyTokenPreview(fontBodyXsRegular, "Body XS Regular") + } + } + } + } + } + } + + @Composable + private fun TypographyTokenPreview(style: TextStyle, text: String) { + Text(text = text, style = style) + } +} diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/ForcedDarkElementThemeTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/ForcedDarkElementThemeTest.kt new file mode 100644 index 0000000..74e6f30 --- /dev/null +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/ForcedDarkElementThemeTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.screenshot + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.captureRoboImage +import io.element.android.compound.colors.SemanticColorsLightDark +import io.element.android.compound.screenshot.utils.screenshotFile +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.theme.ForcedDarkElementTheme +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ForcedDarkElementThemeTest { + @Test + @Config(sdk = [35], qualifiers = "xxhdpi") + fun screenshots() { + captureRoboImage(file = screenshotFile("ForcedDarkElementTheme.png")) { + ElementTheme { + Surface { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text(text = "Outside") + ForcedDarkElementTheme( + colors = SemanticColorsLightDark.default, + ) { + Surface { + Box(modifier = Modifier.fillMaxSize()) { + Text(text = "Inside ForcedDarkElementTheme", modifier = Modifier.align(Alignment.Center)) + } + } + } + } + } + } + } + } +} diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/LegacyColorsTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/LegacyColorsTest.kt new file mode 100644 index 0000000..deb27c6 --- /dev/null +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/LegacyColorsTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.screenshot + +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.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.captureRoboImage +import io.element.android.compound.previews.ColorPreview +import io.element.android.compound.screenshot.utils.screenshotFile +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.theme.LinkColor +import io.element.android.compound.theme.SnackBarLabelColorDark +import io.element.android.compound.theme.SnackBarLabelColorLight +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class LegacyColorsTest { + @Test + @Config(sdk = [35], qualifiers = "xxhdpi") + fun screenshots() { + captureRoboImage(file = screenshotFile("Legacy Colors.png")) { + ElementTheme { + Surface { + Column(modifier = Modifier.padding(16.dp)) { + Text(text = "Legacy Colors") + Spacer(modifier = Modifier.height(10.dp)) + LegacyColorPreview( + color = LinkColor, + name = "Link" + ) + LegacyColorPreview( + color = SnackBarLabelColorLight, + name = "SnackBar Label - Light" + ) + LegacyColorPreview( + color = SnackBarLabelColorDark, + name = "SnackBar Label - Dark" + ) + } + } + } + } + } + + @Composable + private fun LegacyColorPreview(color: Color, name: String) { + ColorPreview( + backgroundColor = Color.White, + foregroundColor = Color.Black, + name = name, + color = color + ) + } +} diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialColorSchemeTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialColorSchemeTest.kt new file mode 100644 index 0000000..282ca0e --- /dev/null +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialColorSchemeTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.screenshot + +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.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.captureRoboImage +import io.element.android.compound.screenshot.utils.screenshotFile +import io.element.android.compound.theme.ColorsSchemeDarkHcPreview +import io.element.android.compound.theme.ColorsSchemeDarkPreview +import io.element.android.compound.theme.ColorsSchemeLightHcPreview +import io.element.android.compound.theme.ColorsSchemeLightPreview +import io.element.android.compound.theme.ElementTheme +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class MaterialColorSchemeTest { + @Test + @Config(sdk = [35], qualifiers = "h2048dp-xhdpi") + fun screenshots() { + captureRoboImage(file = screenshotFile("Material3 Colors - Light.png")) { + ElementTheme { + Surface { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "M3 Light colors", + style = TextStyle.Default.copy(fontSize = 18.sp), + ) + Spacer(modifier = Modifier.height(12.dp)) + ColorsSchemeLightPreview() + } + } + } + } + captureRoboImage(file = screenshotFile("Material3 Colors - Light HC.png")) { + ElementTheme { + Surface { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "M3 Light HC colors", + style = TextStyle.Default.copy(fontSize = 18.sp), + ) + Spacer(modifier = Modifier.height(12.dp)) + ColorsSchemeLightHcPreview() + } + } + } + } + captureRoboImage(file = screenshotFile("Material3 Colors - Dark.png")) { + ElementTheme { + Surface { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "M3 Dark colors", + style = TextStyle.Default.copy(fontSize = 18.sp), + ) + Spacer(modifier = Modifier.height(12.dp)) + ColorsSchemeDarkPreview() + } + } + } + } + captureRoboImage(file = screenshotFile("Material3 Colors - Dark HC.png")) { + ElementTheme { + Surface { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "M3 Dark HC colors", + style = TextStyle.Default.copy(fontSize = 18.sp), + ) + Spacer(modifier = Modifier.height(12.dp)) + ColorsSchemeDarkHcPreview() + } + } + } + } + } +} diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTextTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTextTest.kt new file mode 100644 index 0000000..2aaeed4 --- /dev/null +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTextTest.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.screenshot + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.captureRoboImage +import io.element.android.compound.screenshot.utils.screenshotFile +import io.element.android.compound.theme.MaterialTextPreview +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class MaterialTextTest { + @Test + @Config(sdk = [35], qualifiers = "w480dp-h1200dp-xxhdpi") + fun screenshots() { + captureRoboImage(file = screenshotFile("MaterialText Colors.png")) { + MaterialTextPreview() + } + } +} diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTypographyTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTypographyTest.kt new file mode 100644 index 0000000..3d8256e --- /dev/null +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTypographyTest.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.screenshot + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.captureRoboImage +import io.element.android.compound.previews.TypographyPreview +import io.element.android.compound.screenshot.utils.screenshotFile +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class MaterialTypographyTest { + @Test + @Config(sdk = [35], qualifiers = "h2048dp-xxhdpi") + fun screenshots() { + captureRoboImage(file = screenshotFile("Material Typography.png")) { + TypographyPreview() + } + } +} diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialYouThemeTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialYouThemeTest.kt new file mode 100644 index 0000000..2fe3167 --- /dev/null +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialYouThemeTest.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.screenshot + +import androidx.compose.foundation.layout.Arrangement +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.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.captureRoboImage +import io.element.android.compound.previews.ColorsSchemePreview +import io.element.android.compound.screenshot.utils.screenshotFile +import io.element.android.compound.theme.ElementTheme +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class MaterialYouThemeTest { + @Test + @Config(sdk = [35], qualifiers = "h2048dp-xhdpi") + fun screenshots() { + captureRoboImage(file = screenshotFile("MaterialYou Theme - Light.png")) { + ElementTheme(dynamicColor = true) { + Surface { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text(text = "Material You Theme - Light") + Spacer(modifier = Modifier.height(12.dp)) + ColorsSchemePreview(Color.White, Color.Black, ElementTheme.materialColors) + } + } + } + } + captureRoboImage(file = screenshotFile("MaterialYou Theme - Dark.png")) { + ElementTheme(dynamicColor = true, darkTheme = true) { + Surface { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text(text = "Material You Theme - Dark") + Spacer(modifier = Modifier.height(12.dp)) + ColorsSchemePreview(Color.White, Color.Black, ElementTheme.materialColors) + } + } + } + } + } +} diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/SemanticColorsTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/SemanticColorsTest.kt new file mode 100644 index 0000000..f36d0d2 --- /dev/null +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/SemanticColorsTest.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.screenshot + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.captureRoboImage +import io.element.android.compound.previews.CompoundSemanticColorsDark +import io.element.android.compound.previews.CompoundSemanticColorsDarkHc +import io.element.android.compound.previews.CompoundSemanticColorsLight +import io.element.android.compound.previews.CompoundSemanticColorsLightHc +import io.element.android.compound.screenshot.utils.screenshotFile +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class SemanticColorsTest { + @Config(sdk = [35], qualifiers = "h2000dp-xhdpi") + @Test + fun screenshots() { + captureRoboImage(file = screenshotFile("Compound Semantic Colors - Light.png")) { + CompoundSemanticColorsLight() + } + + captureRoboImage(file = screenshotFile("Compound Semantic Colors - Light HC.png")) { + CompoundSemanticColorsLightHc() + } + + captureRoboImage(file = screenshotFile("Compound Semantic Colors - Dark.png")) { + CompoundSemanticColorsDark() + } + + captureRoboImage(file = screenshotFile("Compound Semantic Colors - Dark HC.png")) { + CompoundSemanticColorsDarkHc() + } + } +} diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/utils/ScreenshotUtils.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/utils/ScreenshotUtils.kt new file mode 100644 index 0000000..082a9b2 --- /dev/null +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/utils/ScreenshotUtils.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.screenshot.utils + +import java.io.File + +/** + * Returns a [File] object for a screenshot with the given [filename]. + * This is to ensure we have a consistent location for all screenshots. + */ +fun screenshotFile(filename: String): File { + return File("screenshots", filename) +} diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/theme/ThemeTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/theme/ThemeTest.kt new file mode 100644 index 0000000..8fd7c5f --- /dev/null +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/theme/ThemeTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.compound.theme + +import android.content.res.Configuration +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalConfiguration +import app.cash.molecule.RecompositionMode +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 ThemeTest { + @Test + fun `isDark for System dark returns true`() { + `isDark for System`( + uiMode = Configuration.UI_MODE_NIGHT_YES, + expected = true, + ) + } + + @Test + fun `isDark for System light return false`() { + `isDark for System`( + uiMode = Configuration.UI_MODE_NIGHT_NO, + expected = false, + ) + } + + fun `isDark for System`( + uiMode: Int, + expected: Boolean, + ) = runTest { + moleculeFlow(RecompositionMode.Immediate) { + var result: Boolean? = null + CompositionLocalProvider( + // Let set the system to dark + LocalConfiguration provides Configuration().apply { + this.uiMode = uiMode + }, + ) { + result = Theme.System.isDark() + } + result + }.test { + assertThat(awaitItem()).isEqualTo(expected) + } + } + + @Test + fun `isDark for Light returns false`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + Theme.Light.isDark() + }.test { + assertThat(awaitItem()).isFalse() + } + } + + @Test + fun `isDark for Dark returns true`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + Theme.Dark.isDark() + }.test { + assertThat(awaitItem()).isTrue() + } + } +} diff --git a/libraries/core/build.gradle.kts b/libraries/core/build.gradle.kts new file mode 100644 index 0000000..e25ae62 --- /dev/null +++ b/libraries/core/build.gradle.kts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("java-library") + id("com.android.lint") + alias(libs.plugins.kotlin.jvm) +} + +java { + sourceCompatibility = Versions.javaVersion + targetCompatibility = Versions.javaVersion +} + +kotlin { + jvmToolchain { + languageVersion = Versions.javaLanguageVersion + } +} + +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 0000000..703bc92 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/bool/Booleans.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..57edd84 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/cache/CircularCache.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.cache + +/** + * A FIFO circular buffer of T. + * This class is not thread safe. + */ +class CircularCache(cacheSize: Int, factory: (Int) -> Array) { + companion object { + inline fun create(cacheSize: Int) = CircularCache(cacheSize) { Array(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 0000000..1fba021 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ChildScopeOf.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..6b0ceb2 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/CoroutineDispatchers.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.coroutine + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +data class CoroutineDispatchers( + val io: CoroutineDispatcher, + val computation: CoroutineDispatcher, + val main: CoroutineDispatcher, +) { + companion object { + val Default = CoroutineDispatchers( + io = Dispatchers.IO, + computation = Dispatchers.Default, + main = Dispatchers.Main, + ) + } +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/DerivedStateFlow.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/DerivedStateFlow.kt new file mode 100644 index 0000000..b186e6f --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/DerivedStateFlow.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.coroutine + +import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** + * A [StateFlow] that derives its value from a [Flow]. + * Useful when you want to apply transformations to a [Flow] and expose it as a [StateFlow]. + */ +@OptIn(ExperimentalForInheritanceCoroutinesApi::class) +class DerivedStateFlow( + private val getValue: () -> T, + private val flow: Flow +) : StateFlow { + override val replayCache: List + get() = listOf(value) + + override val value: T + get() = getValue() + + override suspend fun collect(collector: FlowCollector): Nothing { + coroutineScope { flow.distinctUntilChanged().stateIn(this).collect(collector) } + } +} + +/** + * Maps the value of a [StateFlow] to a new value and returns a new [StateFlow] with the mapped value. + */ +fun StateFlow.mapState(transform: (a: T1) -> R): StateFlow { + return DerivedStateFlow( + getValue = { transform(this.value) }, + flow = this.map { a -> transform(a) } + ) +} 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 0000000..5498d52 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ErrorFlow.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 errorFlow(throwable: Throwable) = flow { throw throwable } diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/Flow.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/Flow.kt new file mode 100644 index 0000000..64dbe7f --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/Flow.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.coroutine + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.runningFold + +/** + * Returns the first element of the flow that is an instance of [T], waiting for it if necessary. + */ +suspend inline fun Flow<*>.firstInstanceOf(): T { + return first { it is T } as T +} + +/** + * Returns a flow that emits pairs of the previous and current values. + * The first emission will be a pair of `null` and the first value emitted by the source flow. + */ +fun Flow.withPreviousValue(): Flow> { + return runningFold(null) { prev: Pair?, current -> + prev?.second to current + } + .filterNotNull() +} 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 0000000..109d492 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ParallelMap.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 Iterable.parallelMap(f: suspend (A) -> B): List = coroutineScope { + map { async { f(it) } }.awaitAll() +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/Suspend.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/Suspend.kt new file mode 100644 index 0000000..a2113c6 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/Suspend.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.coroutine + +import kotlinx.coroutines.delay +import kotlin.system.measureTimeMillis + +fun suspendWithMinimumDuration( + minimumDurationMillis: Long = 500, + block: suspend () -> Unit +) = suspend { + val duration = measureTimeMillis { + block() + } + delay(minimumDurationMillis - duration) +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/SuspendLazy.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/SuspendLazy.kt new file mode 100644 index 0000000..f6068d2 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/SuspendLazy.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.coroutine + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +fun suspendLazy(coroutineContext: CoroutineContext = EmptyCoroutineContext, block: suspend () -> T): Lazy> { + return lazy(LazyThreadSafetyMode.NONE) { + val deferred = CompletableDeferred() + CoroutineScope(coroutineContext).launch { + deferred.complete(block()) + } + deferred + } +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/ByteSize.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/ByteSize.kt new file mode 100644 index 0000000..fe72866 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/ByteSize.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.data + +enum class ByteUnit(val bitShift: Int) { + BYTES(0), + KB(10), + MB(20), + GB(30) +} + +class ByteSize internal constructor(val value: Long, val unit: ByteUnit) { + fun to(dest: ByteUnit): Long { + if (unit == dest) return value + return value shl unit.bitShift shr dest.bitShift + } +} + +val Number.gigaBytes get() = ByteSize(toLong(), ByteUnit.GB) +val Number.megaBytes get() = ByteSize(toLong(), ByteUnit.MB) +val Number.kiloBytes get() = ByteSize(toLong(), ByteUnit.KB) +val Number.bytes get() = ByteSize(toLong(), ByteUnit.BYTES) diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/FilterUpTo.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/FilterUpTo.kt new file mode 100644 index 0000000..a691c06 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/FilterUpTo.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.data + +/** + * Returns a list containing first [count] elements matching the given [predicate]. + * If the list contains less elements matching the [predicate], then all of them are returned. + * + * @param T the type of elements contained in the list. + * @param count the maximum number of elements to take. + * @param predicate the predicate used to match elements. + * @return a list containing first [count] elements matching the given [predicate]. + */ +inline fun Iterable.filterUpTo(count: Int, predicate: (T) -> Boolean): List { + val result = mutableListOf() + for (element in this) { + if (predicate(element)) { + result.add(element) + if (result.size == count) { + break + } + } + } + return result +} 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 0000000..34c5eb6 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/Try.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.data + +import kotlin.coroutines.cancellation.CancellationException + +/** + * Can be used to catch [Exception]s in a block of code, returning `null` if an exception occurs. + * + * If the block throws a [CancellationException], it will be rethrown. + */ +inline fun tryOrNull(onException: ((Exception) -> Unit) = { }, operation: () -> A): A? { + return try { + operation() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + onException.invoke(e) + 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 0000000..d3a2805 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.extensions + +import java.text.Normalizer +import java.util.Locale + +fun Boolean.toOnOff() = if (this) "ON" else "OFF" +fun Boolean.to01() = if (this) "1" else "0" + +inline fun T.ooi(block: (T) -> Unit): T = also(block) + +/** + * Return empty CharSequence if the CharSequence is null. + */ +fun CharSequence?.orEmpty() = this ?: "" + +/** + * 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)}…" +} + +/** + * Replace the old prefix with the new prefix. + * If the string does not start with the old prefix, the string is returned as is. + */ +fun String.replacePrefix(oldPrefix: String, newPrefix: String): String { + return if (startsWith(oldPrefix)) { + newPrefix + substring(oldPrefix.length) + } else { + this + } +} + +/** + * Surround with brackets. + */ +fun String.withBrackets(prefix: String = "(", suffix: String = ")"): String { + return "$prefix$this$suffix" +} + +/** + * Capitalize the string. + */ +fun String.safeCapitalize(): String { + return replaceFirstChar { + if (it.isLowerCase()) { + it.titlecase(Locale.getDefault()) + } else { + it.toString() + } + } +} + +fun String.withoutAccents(): String { + return Normalizer.normalize(this, Normalizer.Form.NFD) + .replace("\\p{Mn}+".toRegex(), "") +} + +private const val RTL_OVERRIDE_CHAR = '\u202E' +private const val LTR_OVERRIDE_CHAR = '\u202D' + +fun String.ensureEndsLeftToRight() = if (containsRtLOverride()) "$this$LTR_OVERRIDE_CHAR" else this + +fun String.containsRtLOverride() = contains(RTL_OVERRIDE_CHAR) + +fun String.filterDirectionOverrides() = filterNot { it == RTL_OVERRIDE_CHAR || it == LTR_OVERRIDE_CHAR } + +/** + * This works around https://github.com/element-hq/element-x-android/issues/2105. + * @param maxLength Max characters to retrieve. Defaults to `500`. + * @param ellipsize Whether to add an ellipsis (`…`) char at the end or not. Defaults to `false`. + * @return The string truncated to [maxLength] characters, with an optional ellipsis if larger. + */ +fun String.toSafeLength( + maxLength: Int = 500, + ellipsize: Boolean = false, +): String { + return if (ellipsize) { + ellipsize(maxLength) + } else if (length > maxLength) { + take(maxLength) + } else { + this + } +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BuildMeta.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BuildMeta.kt new file mode 100644 index 0000000..1130702 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BuildMeta.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.extensions + +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType + +fun BuildMeta.isElement(): Boolean { + return when (buildType) { + BuildType.RELEASE -> applicationId == "io.element.android.x" + BuildType.NIGHTLY -> applicationId == "io.element.android.x.nightly" + BuildType.DEBUG -> applicationId == "io.element.android.x.debug" + } +} 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 0000000..614dc6d --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/Result.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.extensions + +import kotlin.coroutines.cancellation.CancellationException + +/** + * Can be used to catch exceptions in a block of code and return a [Result]. + * If the block throws a [CancellationException], it will be rethrown. + * If it throws any other exception, it will be wrapped in a [Result.failure]. + * + * [Error]s are not caught by this function, as they are not meant to be caught in normal application flow. + */ +inline fun runCatchingExceptions( + block: () -> T +): Result { + return try { + Result.success(block()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } +} + +/** + * Can be used to catch exceptions in a block of code and return a [Result]. + * If the block throws a [CancellationException], it will be rethrown. + * If it throws any other exception, it will be wrapped in a [Result.failure]. + * + * [Error]s are not caught by this function, as they are not meant to be caught in normal application flow. + */ +inline fun T.runCatchingExceptions( + block: T.() -> R +): Result { + return try { + Result.success(block()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } +} + +/** + * Can be used to transform a [Result] into another [Result] by applying a [block] to the value if it is successful. + * If the original [Result] is a failure, the exception will be wrapped in a new [Result.failure]. + * + * This is a safer version of [Result.mapCatching]. + */ +inline fun Result.mapCatchingExceptions( + block: (T) -> R, +): Result { + return fold( + onSuccess = { value -> runCatchingExceptions { block(value) } }, + onFailure = { exception -> Result.failure(exception) } + ) +} + +/** + * Can be used to transform some Throwable into some other. + */ +inline fun Result.mapFailure(transform: (exception: Throwable) -> Throwable): Result { + 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 Result.flatMap(transform: (T) -> Result): Result { + 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 Result.flatMapCatching(transform: (T) -> Result): Result { + return mapCatchingExceptions(transform).fold( + onSuccess = { it }, + onFailure = { Result.failure(it) } + ) +} + +/** + * Can be used to execute a block of code after the [Result] has been processed, regardless of whether it was successful or not. + * The block receives the exception if there was one, or `null` if the result was successful. + */ +inline fun Result.finally(block: (exception: Throwable?) -> Unit): Result { + onSuccess { block(null) } + onFailure(block) + return this +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/hash/Hash.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/hash/Hash.kt new file mode 100644 index 0000000..944889b --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/hash/Hash.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.hash + +import java.security.MessageDigest +import java.util.Locale + +/** + * Compute a Hash of a String, using md5 algorithm. + */ +fun String.md5() = try { + val digest = MessageDigest.getInstance("md5") + val locale = Locale.ROOT + digest.update(toByteArray()) + digest.digest() + .joinToString("") { String.format(locale, "%02X", it) } + .lowercase(locale) +} catch (exc: Exception) { + // Should not happen, but just in case + hashCode().toString() +} 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 0000000..1c1d5cd --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/log/logger/LoggerTag.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 PushLoggerTag : LoggerTag("Push") + object NotificationLoggerTag : LoggerTag("Notification", PushLoggerTag) + + 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 0000000..8781c71 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/meta/BuildMeta.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.meta + +data class BuildMeta( + val buildType: BuildType, + val isDebuggable: Boolean, + val applicationName: String, + val productionApplicationName: String, + val desktopApplicationName: String, + val applicationId: String, + val isEnterpriseBuild: Boolean, + val lowPrivacyLoggingEnabled: Boolean, + val versionName: String, + val versionCode: Long, + val gitRevision: 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 0000000..04b6f7d --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/meta/BuildType.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..e7962f8 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 +@Suppress("ktlint:standard:property-naming") +object MimeTypes { + const val Any: String = "*/*" + const val Json = "application/json" + 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 WebP = "image/webp" + const val Svg = "image/svg+xml" + + 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?.isMimeTypeAnimatedImage() = this == Gif || this == WebP + 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() + + fun fromFileExtension(fileExtension: String): String { + return when (fileExtension.lowercase()) { + "apk" -> Apk + "pdf" -> Pdf + else -> OctetStream + } + } + + fun hasSubtype(mimeType: String): Boolean { + val components = mimeType.split("/") + if (components.size != 2) return false + val subType = components.last() + return subType.isNotBlank() && subType != "*" + } +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/preview/PreviewUtil.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/preview/PreviewUtil.kt new file mode 100644 index 0000000..0bb7617 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/preview/PreviewUtil.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.preview + +val loremIpsum = """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut la + bore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate v + elit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proide + nt, sunt in culpa qui officia deserunt mollit anim id est laborum. + """.trimIndent() 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 0000000..a086a6f --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.uri + +import java.net.URI + +fun String.isValidUrl(): Boolean { + return try { + URI(this).toURL() + 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 0000000..367acd9 --- /dev/null +++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/cache/CircularCacheTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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, Array> { + var internalData: Array? = null + val factory: (Int) -> Array = { + Array(it) { null }.also { array -> internalData = array } + } + return CircularCache(cacheSize, factory) to internalData!! + } + + private fun CircularCache.putInOrder(vararg values: Int) { + values.forEach { put(it) } + } +} diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/data/ByteSizeTest.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/data/ByteSizeTest.kt new file mode 100644 index 0000000..0d1ec87 --- /dev/null +++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/data/ByteSizeTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.data + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ByteSizeTest { + @Test + fun testSizeConversions() { + // Check bytes to other units + val bytes = 10_000_000.bytes + assertThat(bytes.to(ByteUnit.BYTES)).isEqualTo(bytes.value) + assertThat(bytes.to(ByteUnit.KB)).isEqualTo(bytes.value / 1024L) + assertThat(bytes.to(ByteUnit.MB)).isEqualTo(bytes.value / 1024L / 1024L) + assertThat(bytes.to(ByteUnit.GB)).isEqualTo(bytes.value / 1024L / 1024L / 1024L) + + // Now check for values too small to be converted + assertThat(100.bytes.to(ByteUnit.KB)).isEqualTo(0) + assertThat(100.bytes.to(ByteUnit.MB)).isEqualTo(0) + assertThat(100.bytes.to(ByteUnit.GB)).isEqualTo(0) + + // Check for KBs + val kiloBytes = 10_000.kiloBytes + assertThat(kiloBytes.to(ByteUnit.BYTES)).isEqualTo(kiloBytes.value * 1024L) + assertThat(kiloBytes.to(ByteUnit.KB)).isEqualTo(kiloBytes.value) + assertThat(kiloBytes.to(ByteUnit.MB)).isEqualTo(kiloBytes.value / 1024L) + assertThat(kiloBytes.to(ByteUnit.GB)).isEqualTo(kiloBytes.value / 1024L / 1024L) + + // Check for MBs + val megaBytes = 10_000.megaBytes + assertThat(megaBytes.to(ByteUnit.BYTES)).isEqualTo(megaBytes.value * 1024L * 1024L) + assertThat(megaBytes.to(ByteUnit.KB)).isEqualTo(megaBytes.value * 1024L) + assertThat(megaBytes.to(ByteUnit.MB)).isEqualTo(megaBytes.value) + assertThat(megaBytes.to(ByteUnit.GB)).isEqualTo(megaBytes.value / 1024L) + + // Check for GBs + val gigaBytes = 10.gigaBytes + assertThat(gigaBytes.to(ByteUnit.BYTES)).isEqualTo(gigaBytes.value * 1024L * 1024L * 1024L) + assertThat(gigaBytes.to(ByteUnit.KB)).isEqualTo(gigaBytes.value * 1024L * 1024L) + assertThat(gigaBytes.to(ByteUnit.MB)).isEqualTo(gigaBytes.value * 1024L) + assertThat(gigaBytes.to(ByteUnit.GB)).isEqualTo(gigaBytes.value) + } +} 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 0000000..0099a67 --- /dev/null +++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/BasicExtensionsTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.extensions + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +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) + } + + @Test + fun `given text with RtL unicode override, when checking contains RtL Override, then returns true`() { + val textWithRtlOverride = "hello\u202Eworld" + val result = textWithRtlOverride.containsRtLOverride() + assertTrue(result) + } + + @Test + fun `given text without RtL unicode override, when checking contains RtL Override, then returns false`() { + val textWithRtlOverride = "hello world" + val result = textWithRtlOverride.containsRtLOverride() + assertFalse(result) + } + + @Test + fun `given text with RtL unicode override, when ensuring ends LtR, then appends a LtR unicode override`() { + val textWithRtlOverride = "123\u202E456" + val result = textWithRtlOverride.ensureEndsLeftToRight() + assertEquals("$textWithRtlOverride\u202D", result) + } + + @Test + fun `given text with unicode direction overrides, when filtering direction overrides, then removes all overrides`() { + val textWithDirectionOverrides = "123\u202E456\u202d789" + val result = textWithDirectionOverrides.filterDirectionOverrides() + assertEquals("123456789", result) + } +} diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/ResultTest.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/ResultTest.kt new file mode 100644 index 0000000..6c6340e --- /dev/null +++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/extensions/ResultTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.extensions + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ResultTest { + @Test + fun testFlatMap() { + val initial = Result.success("initial") + val otherResult = initial.flatMap { Result.success("other") } + val errorResult = initial.flatMap { Result.failure(IllegalStateException("error")) } + + assertThat(otherResult.getOrNull()).isEqualTo("other") + assertThat(errorResult.exceptionOrNull()?.message).isEqualTo("error") + try { + initial.flatMap { error("caught error") } + } catch (e: IllegalStateException) { + assertThat(e.message).isEqualTo("caught error") + } + + val initialError = Result.failure(IllegalStateException("initial error")) + val mapErrorToSuccess = initialError.flatMap { Result.success("other") } + val mapErrorToError = initialError.flatMap { Result.failure(IllegalStateException("error")) } + val mapErrorAndCatch: Result = 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(IllegalStateException("error")) } + val caughtExceptionResult: Result = 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(IllegalStateException("initial error")) + val mapErrorToSuccess = initialError.flatMapCatching { Result.success("other") } + val mapErrorToError = initialError.flatMapCatching { Result.failure(IllegalStateException("error")) } + val mapErrorAndCatch: Result = 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/cryptography/api/build.gradle.kts b/libraries/cryptography/api/build.gradle.kts new file mode 100644 index 0000000..9ce2641 --- /dev/null +++ b/libraries/cryptography/api/build.gradle.kts @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.cryptography.api" +} diff --git a/libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/AESEncryptionSpecs.kt b/libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/AESEncryptionSpecs.kt new file mode 100644 index 0000000..ea1309f --- /dev/null +++ b/libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/AESEncryptionSpecs.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.cryptography.api + +import android.security.keystore.KeyProperties + +object AESEncryptionSpecs { + const val BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM + const val PADDINGS = KeyProperties.ENCRYPTION_PADDING_NONE + const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES + const val KEY_SIZE = 128 + const val CIPHER_TRANSFORMATION = "$ALGORITHM/$BLOCK_MODE/$PADDINGS" +} diff --git a/libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/EncryptionDecryptionService.kt b/libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/EncryptionDecryptionService.kt new file mode 100644 index 0000000..fc882bc --- /dev/null +++ b/libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/EncryptionDecryptionService.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.cryptography.api + +import javax.crypto.Cipher +import javax.crypto.SecretKey + +/** + * Simple service to provide encryption and decryption operations. + */ +interface EncryptionDecryptionService { + fun createEncryptionCipher(key: SecretKey): Cipher + fun createDecryptionCipher(key: SecretKey, initializationVector: ByteArray): Cipher + fun encrypt(key: SecretKey, input: ByteArray): EncryptionResult + fun decrypt(key: SecretKey, encryptionResult: EncryptionResult): ByteArray +} diff --git a/libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/EncryptionResult.kt b/libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/EncryptionResult.kt new file mode 100644 index 0000000..776cd2d --- /dev/null +++ b/libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/EncryptionResult.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalEncodingApi::class) + +package io.element.android.libraries.cryptography.api + +import java.nio.ByteBuffer +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Holds the result of an encryption operation. + */ +class EncryptionResult( + val encryptedByteArray: ByteArray, + val initializationVector: ByteArray +) { + fun toBase64(): String { + val initializationVectorSize = ByteBuffer.allocate(Int.SIZE_BYTES).putInt(initializationVector.size).array() + val cipherTextWithIv: ByteArray = + ByteBuffer.allocate(Int.SIZE_BYTES + initializationVector.size + encryptedByteArray.size) + .put(initializationVectorSize) + .put(initializationVector) + .put(encryptedByteArray) + .array() + return Base64.encode(cipherTextWithIv) + } + + companion object { + /** + * @param base64 the base64 representation of the encrypted data. + * @return the [EncryptionResult] from the base64 representation. + */ + fun fromBase64(base64: String): EncryptionResult { + val cipherTextWithIv = Base64.decode(base64) + val buffer = ByteBuffer.wrap(cipherTextWithIv) + val initializationVectorSize = buffer.int + val initializationVector = ByteArray(initializationVectorSize) + buffer.get(initializationVector) + val encryptedByteArray = ByteArray(buffer.remaining()) + buffer.get(encryptedByteArray) + return EncryptionResult(encryptedByteArray, initializationVector) + } + } +} diff --git a/libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/SecretKeyRepository.kt b/libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/SecretKeyRepository.kt new file mode 100644 index 0000000..ba6c10d --- /dev/null +++ b/libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/SecretKeyRepository.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.cryptography.api + +import javax.crypto.SecretKey + +/** + * Simple interface to get, create and delete a secret key for a given alias. + * Implementation should be able to store the generated key securely. + */ +interface SecretKeyRepository { + /** + * Get or create a secret key for a given alias. + * @param alias the alias to use + * @param requiresUserAuthentication true if the key should be protected by user authentication + */ + fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey + + /** + * Delete the secret key for a given alias. + * @param alias the alias to use + */ + fun deleteKey(alias: String) +} diff --git a/libraries/cryptography/impl/build.gradle.kts b/libraries/cryptography/impl/build.gradle.kts new file mode 100644 index 0000000..454432d --- /dev/null +++ b/libraries/cryptography/impl/build.gradle.kts @@ -0,0 +1,27 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.cryptography.impl" +} + +setupDependencyInjection() + +dependencies { + implementation(projects.libraries.di) + api(projects.libraries.cryptography.api) + + testCommonDependencies(libs) +} diff --git a/libraries/cryptography/impl/src/main/kotlin/io/element/android/libraries/cryptography/impl/AESEncryptionDecryptionService.kt b/libraries/cryptography/impl/src/main/kotlin/io/element/android/libraries/cryptography/impl/AESEncryptionDecryptionService.kt new file mode 100644 index 0000000..cf5c2c6 --- /dev/null +++ b/libraries/cryptography/impl/src/main/kotlin/io/element/android/libraries/cryptography/impl/AESEncryptionDecryptionService.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.cryptography.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.cryptography.api.AESEncryptionSpecs +import io.element.android.libraries.cryptography.api.EncryptionDecryptionService +import io.element.android.libraries.cryptography.api.EncryptionResult +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Default implementation of [EncryptionDecryptionService] using AES encryption. + */ +@ContributesBinding(AppScope::class) +class AESEncryptionDecryptionService : EncryptionDecryptionService { + override fun createEncryptionCipher(key: SecretKey): Cipher { + return Cipher.getInstance(AESEncryptionSpecs.CIPHER_TRANSFORMATION).apply { + init(Cipher.ENCRYPT_MODE, key) + } + } + + override fun createDecryptionCipher(key: SecretKey, initializationVector: ByteArray): Cipher { + val spec = GCMParameterSpec(128, initializationVector) + return Cipher.getInstance(AESEncryptionSpecs.CIPHER_TRANSFORMATION).apply { + init(Cipher.DECRYPT_MODE, key, spec) + } + } + + override fun encrypt(key: SecretKey, input: ByteArray): EncryptionResult { + val cipher = createEncryptionCipher(key) + val encryptedData = cipher.doFinal(input) + return EncryptionResult(encryptedData, cipher.iv) + } + + override fun decrypt(key: SecretKey, encryptionResult: EncryptionResult): ByteArray { + val cipher = createDecryptionCipher(key, encryptionResult.initializationVector) + return cipher.doFinal(encryptionResult.encryptedByteArray) + } +} diff --git a/libraries/cryptography/impl/src/main/kotlin/io/element/android/libraries/cryptography/impl/CryptographyModule.kt b/libraries/cryptography/impl/src/main/kotlin/io/element/android/libraries/cryptography/impl/CryptographyModule.kt new file mode 100644 index 0000000..e9a9eb7 --- /dev/null +++ b/libraries/cryptography/impl/src/main/kotlin/io/element/android/libraries/cryptography/impl/CryptographyModule.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.cryptography.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import java.security.KeyStore + +internal const val ANDROID_KEYSTORE = "AndroidKeyStore" + +@ContributesTo(AppScope::class) +@BindingContainer +object CryptographyModule { + @Provides + fun providesAndroidKeyStore(): KeyStore { + return KeyStore.getInstance(ANDROID_KEYSTORE).apply { + load(null) + } + } +} diff --git a/libraries/cryptography/impl/src/main/kotlin/io/element/android/libraries/cryptography/impl/KeyStoreSecretKeyRepository.kt b/libraries/cryptography/impl/src/main/kotlin/io/element/android/libraries/cryptography/impl/KeyStoreSecretKeyRepository.kt new file mode 100644 index 0000000..46572ef --- /dev/null +++ b/libraries/cryptography/impl/src/main/kotlin/io/element/android/libraries/cryptography/impl/KeyStoreSecretKeyRepository.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.cryptography.impl + +import android.annotation.SuppressLint +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.cryptography.api.AESEncryptionSpecs +import io.element.android.libraries.cryptography.api.SecretKeyRepository +import timber.log.Timber +import java.security.KeyStore +import java.security.KeyStoreException +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey + +/** + * Default implementation of [SecretKeyRepository] that uses the Android Keystore to store the keys. + * The generated key uses AES algorithm, with a key size of 128 bits, and the GCM block mode. + */ +@ContributesBinding(AppScope::class) +class KeyStoreSecretKeyRepository( + private val keyStore: KeyStore, +) : SecretKeyRepository { + // False positive lint issue + @SuppressLint("WrongConstant") + override fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey { + val secretKeyEntry = (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry) + ?.secretKey + return if (secretKeyEntry == null) { + val generator = KeyGenerator.getInstance(AESEncryptionSpecs.ALGORITHM, ANDROID_KEYSTORE) + val keyGenSpec = KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(AESEncryptionSpecs.BLOCK_MODE) + .setEncryptionPaddings(AESEncryptionSpecs.PADDINGS) + .setKeySize(AESEncryptionSpecs.KEY_SIZE) + .setUserAuthenticationRequired(requiresUserAuthentication) + .build() + generator.init(keyGenSpec) + generator.generateKey() + } else { + secretKeyEntry + } + } + + override fun deleteKey(alias: String) { + try { + keyStore.deleteEntry(alias) + } catch (e: KeyStoreException) { + Timber.e(e) + } + } +} diff --git a/libraries/cryptography/impl/src/test/kotlin/io/element/android/libraries/cryptography/impl/AESEncryptionDecryptionServiceTest.kt b/libraries/cryptography/impl/src/test/kotlin/io/element/android/libraries/cryptography/impl/AESEncryptionDecryptionServiceTest.kt new file mode 100644 index 0000000..d449409 --- /dev/null +++ b/libraries/cryptography/impl/src/test/kotlin/io/element/android/libraries/cryptography/impl/AESEncryptionDecryptionServiceTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.cryptography.impl + +import android.security.keystore.KeyProperties +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertThrows +import org.junit.Test +import java.security.GeneralSecurityException +import javax.crypto.KeyGenerator + +class AESEncryptionDecryptionServiceTest { + private val encryptionDecryptionService = AESEncryptionDecryptionService() + + @Test + fun `given a valid key then encrypt decrypt work`() { + val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES) + keyGenerator.init(128) + val key = keyGenerator.generateKey() + val input = "Hello World".toByteArray() + val encryptionResult = encryptionDecryptionService.encrypt(key, input) + val decrypted = encryptionDecryptionService.decrypt(key, encryptionResult) + assertThat(decrypted).isEqualTo(input) + } + + @Test + fun `given a wrong key then decrypt fail`() { + val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES) + keyGenerator.init(128) + val encryptionKey = keyGenerator.generateKey() + val input = "Hello World".toByteArray() + val encryptionResult = encryptionDecryptionService.encrypt(encryptionKey, input) + val decryptionKey = keyGenerator.generateKey() + assertThrows(GeneralSecurityException::class.java) { + encryptionDecryptionService.decrypt(decryptionKey, encryptionResult) + } + } +} diff --git a/libraries/cryptography/test/build.gradle.kts b/libraries/cryptography/test/build.gradle.kts new file mode 100644 index 0000000..eaa621d --- /dev/null +++ b/libraries/cryptography/test/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.cryptography.test" +} + +dependencies { + api(projects.libraries.cryptography.api) +} diff --git a/libraries/cryptography/test/src/main/kotlin/io/element/android/libraries/cryptography/test/SimpleSecretKeyRepository.kt b/libraries/cryptography/test/src/main/kotlin/io/element/android/libraries/cryptography/test/SimpleSecretKeyRepository.kt new file mode 100644 index 0000000..0e30155 --- /dev/null +++ b/libraries/cryptography/test/src/main/kotlin/io/element/android/libraries/cryptography/test/SimpleSecretKeyRepository.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.cryptography.test + +import io.element.android.libraries.cryptography.api.AESEncryptionSpecs +import io.element.android.libraries.cryptography.api.SecretKeyRepository +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey + +class SimpleSecretKeyRepository : SecretKeyRepository { + private var secretKeyForAlias = HashMap() + + override fun getOrCreateKey(alias: String, requiresUserAuthentication: Boolean): SecretKey { + return secretKeyForAlias.getOrPut(alias) { + generateKey() + } + } + + override fun deleteKey(alias: String) { + secretKeyForAlias.remove(alias) + } + + private fun generateKey(): SecretKey { + val keyGenerator = KeyGenerator.getInstance(AESEncryptionSpecs.ALGORITHM) + keyGenerator.init(AESEncryptionSpecs.KEY_SIZE) + return keyGenerator.generateKey() + } +} diff --git a/libraries/dateformatter/api/.gitignore b/libraries/dateformatter/api/.gitignore new file mode 100644 index 0000000..42afabf --- /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 0000000..33194e7 --- /dev/null +++ b/libraries/dateformatter/api/build.gradle.kts @@ -0,0 +1,21 @@ +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.dateformatter.api" +} + +dependencies { + testCommonDependencies(libs) +} diff --git a/libraries/dateformatter/api/consumer-rules.pro b/libraries/dateformatter/api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DateFormatter.kt b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DateFormatter.kt new file mode 100644 index 0000000..1f5c88f --- /dev/null +++ b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DateFormatter.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.api + +interface DateFormatter { + fun format( + timestamp: Long?, + mode: DateFormatterMode = DateFormatterMode.Full, + useRelative: Boolean = false, + ): String +} + +enum class DateFormatterMode { + /** + * Full date and time. + * Example: + * "April 6, 1980 at 6:35 PM" + * Format can be shorter when useRelative is true. + * Example: + * "6:35 PM" + */ + Full, + + /** + * Only month and year. + * Example: + * "April 1980" + * "This month" can be returned when useRelative is true. + * Example: + * "This month" + */ + Month, + + /** + * Only day. + * Example: + * "Sunday 6 April" + * "Today", "Yesterday" and day of week can be returned when useRelative is true. + */ + Day, + + /** + * Time if same day, else date. + */ + TimeOrDate, + + /** + * Only time whatever the day. + */ + TimeOnly, +} diff --git a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt new file mode 100644 index 0000000..8ecf01c --- /dev/null +++ b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.api + +import java.util.Locale +import kotlin.time.Duration + +/** + * Convert milliseconds to human readable duration. + * Hours in 1 digit or more. + * Minutes in 2 digits when hours are available. + * Seconds always on 2 digits. + * Example: + * - when the duration is longer than 1 hour: + * - "10:23:34" + * - "1:23:34" + * - "1:03:04" + * - when the duration is shorter: + * - "4:56" + * - "14:06" + * - Less than one minute: + * - "0:00" + * - "0:01" + * - "0:59" + */ +fun Long.toHumanReadableDuration(): String { + val inSeconds = this / 1_000 + val hours = inSeconds / 3_600 + val minutes = inSeconds % 3_600 / 60 + val seconds = inSeconds % 60 + return if (hours > 0) { + String.format(Locale.US, "%d:%02d:%02d", hours, minutes, seconds) + } else { + String.format(Locale.US, "%d:%02d", minutes, seconds) + } +} + +fun Duration.toHumanReadableDuration() = inWholeMilliseconds.toHumanReadableDuration() diff --git a/libraries/dateformatter/api/src/test/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatterTest.kt b/libraries/dateformatter/api/src/test/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatterTest.kt new file mode 100644 index 0000000..e3a5f2b --- /dev/null +++ b/libraries/dateformatter/api/src/test/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatterTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.api + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class DurationFormatterTest { + @Test + fun `format seconds only`() { + assertThat(buildDuration().toHumanReadableDuration()).isEqualTo("0:00") + assertThat(buildDuration(seconds = 1).toHumanReadableDuration()).isEqualTo("0:01") + assertThat(buildDuration(seconds = 59).toHumanReadableDuration()).isEqualTo("0:59") + } + + @Test + fun `format minutes and seconds`() { + assertThat(buildDuration(minutes = 1).toHumanReadableDuration()).isEqualTo("1:00") + assertThat(buildDuration(minutes = 1, seconds = 30).toHumanReadableDuration()).isEqualTo("1:30") + assertThat(buildDuration(minutes = 59, seconds = 59).toHumanReadableDuration()).isEqualTo("59:59") + } + + @Test + fun `format hours, minutes and seconds`() { + assertThat(buildDuration(hours = 1).toHumanReadableDuration()).isEqualTo("1:00:00") + assertThat(buildDuration(hours = 1, minutes = 1, seconds = 1).toHumanReadableDuration()).isEqualTo("1:01:01") + assertThat(buildDuration(hours = 24, minutes = 59, seconds = 59).toHumanReadableDuration()).isEqualTo("24:59:59") + assertThat(buildDuration(hours = 25, minutes = 0, seconds = 0).toHumanReadableDuration()).isEqualTo("25:00:00") + } + + private fun buildDuration( + hours: Int = 0, + minutes: Int = 0, + seconds: Int = 0 + ): Long { + return (hours * 60 * 60 + minutes * 60 + seconds) * 1000L + } +} diff --git a/libraries/dateformatter/impl/.gitignore b/libraries/dateformatter/impl/.gitignore new file mode 100644 index 0000000..42afabf --- /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 0000000..c9ddf45 --- /dev/null +++ b/libraries/dateformatter/impl/build.gradle.kts @@ -0,0 +1,49 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +setupDependencyInjection() + +android { + namespace = "io.element.android.libraries.dateformatter.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + + kotlin { + compilerOptions { + optIn = listOf( + "kotlin.time.ExperimentalTime" + ) + } + } +} + +dependencies { + implementation(projects.libraries.core) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.di) + implementation(projects.libraries.uiStrings) + implementation(projects.services.toolbox.api) + + api(projects.libraries.dateformatter.api) + api(libs.datetime) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.dateformatter.test) + testImplementation(projects.services.toolbox.test) +} diff --git a/libraries/dateformatter/impl/consumer-rules.pro b/libraries/dateformatter/impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatterDay.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatterDay.kt new file mode 100644 index 0000000..39d2ba8 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatterDay.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.safeCapitalize + +interface DateFormatterDay { + fun format( + timestamp: Long, + useRelative: Boolean, + ): String +} + +@ContributesBinding(AppScope::class) +class DefaultDateFormatterDay( + private val localDateTimeProvider: LocalDateTimeProvider, + private val dateFormatters: DateFormatters, +) : DateFormatterDay { + override fun format( + timestamp: Long, + useRelative: Boolean, + ): String { + val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp) + val today = localDateTimeProvider.providesNow() + return if (useRelative) { + val dayDiff = today.date.toEpochDays() - dateToFormat.date.toEpochDays() + when (dayDiff) { + 0L -> dateFormatters.getRelativeDay(timestamp, "Today") + 1L -> dateFormatters.getRelativeDay(timestamp, "Yesterday") + else -> if (dayDiff < 7) { + dateFormatters.formatDateWithDay(dateToFormat) + } else { + if (today.year == dateToFormat.year) { + dateFormatters.formatDateWithFullFormatNoYear(dateToFormat) + } else { + dateFormatters.formatDateWithFullFormat(dateToFormat) + } + } + } + } else { + if (today.year == dateToFormat.year) { + dateFormatters.formatDateWithFullFormatNoYear(dateToFormat) + } else { + dateFormatters.formatDateWithFullFormat(dateToFormat) + } + } + .safeCapitalize() + } +} diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatterFull.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatterFull.kt new file mode 100644 index 0000000..11a1532 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatterFull.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl + +import dev.zacsweers.metro.Inject +import io.element.android.services.toolbox.api.strings.StringProvider + +@Inject +class DateFormatterFull( + private val stringProvider: StringProvider, + private val localDateTimeProvider: LocalDateTimeProvider, + private val dateFormatters: DateFormatters, + private val dateFormatterDay: DateFormatterDay, +) { + fun format( + timestamp: Long, + useRelative: Boolean, + ): String { + val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp) + val time = dateFormatters.formatTime(dateToFormat) + return if (useRelative) { + val now = localDateTimeProvider.providesNow() + if (now.date == dateToFormat.date) { + time + } else { + val dateStr = dateFormatterDay.format(timestamp, true) + stringProvider.getString(R.string.common_date_date_at_time, dateStr, time) + } + } else { + val dateStr = dateFormatters.formatDateWithFullFormat(dateToFormat) + stringProvider.getString(R.string.common_date_date_at_time, dateStr, time) + } + } +} diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatterMonth.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatterMonth.kt new file mode 100644 index 0000000..f391648 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatterMonth.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.core.extensions.safeCapitalize +import io.element.android.services.toolbox.api.strings.StringProvider + +@Inject +class DateFormatterMonth( + private val stringProvider: StringProvider, + private val localDateTimeProvider: LocalDateTimeProvider, + private val dateFormatters: DateFormatters, +) { + fun format( + timestamp: Long, + useRelative: Boolean, + ): String { + val today = localDateTimeProvider.providesNow() + val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp) + return if (useRelative && dateToFormat.month == today.month && dateToFormat.year == today.year) { + stringProvider.getString(R.string.common_date_this_month) + } else { + dateFormatters.formatDateWithMonthAndYear(dateToFormat) + } + .safeCapitalize() + } +} diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatterTime.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatterTime.kt new file mode 100644 index 0000000..eadf0e0 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatterTime.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl + +import dev.zacsweers.metro.Inject + +@Inject +class DateFormatterTime( + private val localDateTimeProvider: LocalDateTimeProvider, + private val dateFormatters: DateFormatters, +) { + fun format( + timestamp: Long, + useRelative: Boolean, + ): String { + 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 = useRelative, + ) + } + } + } +} diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatterTimeOnly.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatterTimeOnly.kt new file mode 100644 index 0000000..2889235 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatterTimeOnly.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl + +import dev.zacsweers.metro.Inject + +@Inject +class DateFormatterTimeOnly( + private val localDateTimeProvider: LocalDateTimeProvider, + private val dateFormatters: DateFormatters, +) { + fun format( + timestamp: Long, + ): String { + val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp) + return dateFormatters.formatTime(dateToFormat) + } +} 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 0000000..cf6b302 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateFormatters.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl + +import android.text.format.DateUtils +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.toInstant +import kotlinx.datetime.toJavaLocalDate +import kotlinx.datetime.toJavaLocalDateTime +import timber.log.Timber +import java.time.Period +import java.util.Locale +import kotlin.math.absoluteValue +import kotlin.time.Clock + +@SingleIn(AppScope::class) +@Inject +class DateFormatters( + localeChangeObserver: LocaleChangeObserver, + private val clock: Clock, + private val timeZoneProvider: TimezoneProvider, + locale: Locale, +) : LocaleChangeListener { + init { + localeChangeObserver.addListener(this) + } + + private var dateTimeFormatters: DateTimeFormatters = DateTimeFormatters(locale) + + override fun onLocaleChange() { + Timber.w("Locale changed, updating formatters") + dateTimeFormatters = DateTimeFormatters(Locale.getDefault()) + } + + internal fun formatTime(localDateTime: LocalDateTime): String { + return dateTimeFormatters.onlyTimeFormatter.format(localDateTime.toJavaLocalDateTime()) + } + + internal fun formatDateWithMonthAndYear(localDateTime: LocalDateTime): String { + return dateTimeFormatters.dateWithMonthAndYearFormatter.format(localDateTime.toJavaLocalDateTime()) + } + + internal fun formatDateWithMonth(localDateTime: LocalDateTime): String { + return dateTimeFormatters.dateWithMonthFormatter.format(localDateTime.toJavaLocalDateTime()) + } + + internal fun formatDateWithDay(localDateTime: LocalDateTime): String { + return dateTimeFormatters.dateWithDayFormatter.format(localDateTime.toJavaLocalDateTime()) + } + + internal fun formatDateWithYear(localDateTime: LocalDateTime): String { + return dateTimeFormatters.dateWithYearFormatter.format(localDateTime.toJavaLocalDateTime()) + } + + internal fun formatDateWithFullFormat(localDateTime: LocalDateTime): String { + return dateTimeFormatters.dateWithFullFormatFormatter.format(localDateTime.toJavaLocalDateTime()) + } + + internal fun formatDateWithFullFormatNoYear(localDateTime: LocalDateTime): String { + return dateTimeFormatters.dateWithFullFormatNoYearFormatter.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(timeZoneProvider.provide()).toEpochMilliseconds()) + } else { + formatDateWithMonth(dateToFormat) + } + } + + internal fun getRelativeDay(ts: Long, default: String = ""): String { + return DateUtils.getRelativeTimeSpanString( + ts, + clock.now().toEpochMilliseconds(), + DateUtils.DAY_IN_MILLIS, + DateUtils.FORMAT_SHOW_WEEKDAY + )?.toString() ?: default + } +} diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateTimeFormatters.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateTimeFormatters.kt new file mode 100644 index 0000000..0a7e683 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DateTimeFormatters.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl + +import android.text.format.DateFormat +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +class DateTimeFormatters( + private val locale: Locale, +) { + val onlyTimeFormatter: DateTimeFormatter by lazy { + DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale) + } + + val dateWithMonthAndYearFormatter: DateTimeFormatter by lazy { + val pattern = bestDateTimePattern("MMMM YYYY") + DateTimeFormatter.ofPattern(pattern, locale) + } + + val dateWithMonthFormatter: DateTimeFormatter by lazy { + val pattern = bestDateTimePattern("d MMM") + DateTimeFormatter.ofPattern(pattern, locale) + } + + val dateWithDayFormatter: DateTimeFormatter by lazy { + val pattern = bestDateTimePattern("EEEE") + DateTimeFormatter.ofPattern(pattern, locale) + } + + val dateWithYearFormatter: DateTimeFormatter by lazy { + val pattern = bestDateTimePattern("dd.MM.yyyy") + DateTimeFormatter.ofPattern(pattern, locale) + } + + val dateWithFullFormatFormatter: DateTimeFormatter by lazy { + DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(locale) + } + + val dateWithFullFormatNoYearFormatter: DateTimeFormatter by lazy { + val pattern = DateFormat.getBestDateTimePattern(locale, "EEEE d MMMM") ?: "EEEE d MMMM" + DateTimeFormatter.ofPattern(pattern, locale) + } + + private fun bestDateTimePattern(pattern: String): String { + return DateFormat.getBestDateTimePattern(locale, pattern) ?: pattern + } +} diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDateFormatter.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDateFormatter.kt new file mode 100644 index 0000000..8b2bcc5 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDateFormatter.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode + +@ContributesBinding(AppScope::class) +class DefaultDateFormatter( + private val dateFormatterFull: DateFormatterFull, + private val dateFormatterMonth: DateFormatterMonth, + private val dateFormatterDay: DateFormatterDay, + private val dateFormatterTime: DateFormatterTime, + private val dateFormatterTimeOnly: DateFormatterTimeOnly, +) : DateFormatter { + override fun format( + timestamp: Long?, + mode: DateFormatterMode, + useRelative: Boolean, + ): String { + timestamp ?: return "" + return when (mode) { + DateFormatterMode.Full -> { + dateFormatterFull.format(timestamp, useRelative) + } + DateFormatterMode.Month -> { + dateFormatterMonth.format(timestamp, useRelative) + } + DateFormatterMode.Day -> { + dateFormatterDay.format(timestamp, useRelative) + } + DateFormatterMode.TimeOrDate -> { + dateFormatterTime.format(timestamp, useRelative) + } + DateFormatterMode.TimeOnly -> { + dateFormatterTimeOnly.format(timestamp) + } + } + } +} 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 0000000..a205c84 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/LocalDateTimeProvider.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl + +import dev.zacsweers.metro.Inject +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.Instant + +@Inject +class LocalDateTimeProvider( + private val clock: Clock, + private val timezoneProvider: TimezoneProvider, +) { + fun providesNow(): LocalDateTime { + val now: Instant = clock.now() + return now.toLocalDateTime(timezoneProvider.provide()) + } + + fun providesFromTimestamp(timestamp: Long): LocalDateTime { + val tsInstant = Instant.fromEpochMilliseconds(timestamp) + return tsInstant.toLocalDateTime(timezoneProvider.provide()) + } +} diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/LocaleChangeObserver.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/LocaleChangeObserver.kt new file mode 100644 index 0000000..bb2b1e0 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/LocaleChangeObserver.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.annotations.ApplicationContext + +fun interface LocaleChangeObserver { + fun addListener(listener: LocaleChangeListener) +} + +interface LocaleChangeListener { + fun onLocaleChange() +} + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultLocaleChangeObserver( + @ApplicationContext private val context: Context, +) : LocaleChangeObserver { + init { + registerReceiver(object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + listeners.forEach(LocaleChangeListener::onLocaleChange) + } + }) + } + + private val listeners = mutableSetOf() + + override fun addListener(listener: LocaleChangeListener) { + listeners.add(listener) + } + + private fun registerReceiver(receiver: BroadcastReceiver) { + val filter = IntentFilter() + filter.addAction(Intent.ACTION_LOCALE_CHANGED) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + filter.addAction(Intent.ACTION_APPLICATION_LOCALE_CHANGED) + } + context.registerReceiver(receiver, filter) + } +} diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/TimezoneProvider.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/TimezoneProvider.kt new file mode 100644 index 0000000..77eba1a --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/TimezoneProvider.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl + +import kotlinx.datetime.TimeZone + +fun interface TimezoneProvider { + fun provide(): 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 0000000..c80324c --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/di/DateFormatterModule.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import io.element.android.libraries.dateformatter.impl.TimezoneProvider +import kotlinx.datetime.TimeZone +import java.util.Locale +import kotlin.time.Clock + +@BindingContainer +@ContributesTo(AppScope::class) +object DateFormatterModule { + @Provides + fun providesClock(): Clock = Clock.System + + @Provides + fun providesLocale(): Locale = Locale.getDefault() + + @Provides + fun providesTimezone(): TimezoneProvider = TimezoneProvider { TimeZone.currentSystemDefault() } +} diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/previews/DateForPreview.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/previews/DateForPreview.kt new file mode 100644 index 0000000..79fe3ac --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/previews/DateForPreview.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl.previews + +data class DateForPreview( + val semantic: String, + val date: String, +) + +val dateForPreviewToday = DateForPreview( + semantic = "Today", + date = "1980-04-06T18:35:24.00Z", +) + +val dateForPreviews = listOf( + DateForPreview( + semantic = "Now", + date = dateForPreviewToday.date, + ), + DateForPreview( + semantic = "One second ago", + date = "1980-04-06T18:35:23.00Z", + ), + DateForPreview( + semantic = "One minute ago", + date = "1980-04-06T18:34:24.00Z", + ), + DateForPreview( + semantic = "One hour ago", + date = "1980-04-06T17:35:24.00Z", + ), + DateForPreview( + semantic = "One day ago", + date = "1980-04-05T18:35:24.00Z", + ), + DateForPreview( + semantic = "Two days ago", + date = "1980-04-04T18:35:24.00Z", + ), + DateForPreview( + semantic = "One month ago", + date = "1980-03-06T18:35:24.00Z", + ), + DateForPreview( + semantic = "One year ago", + date = "1979-04-06T18:35:24.00Z", + ), +) diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/previews/DateFormatterModeProvider.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/previews/DateFormatterModeProvider.kt new file mode 100644 index 0000000..48856ca --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/previews/DateFormatterModeProvider.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl.previews + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.dateformatter.api.DateFormatterMode + +class DateFormatterModeProvider : PreviewParameterProvider { + override val values: Sequence + get() = DateFormatterMode.entries.asSequence() +} diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/previews/DateFormatterModeViewPreview.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/previews/DateFormatterModeViewPreview.kt new file mode 100644 index 0000000..e3cd956 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/previews/DateFormatterModeViewPreview.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl.previews + +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.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.dateformatter.api.DateFormatterMode +import io.element.android.libraries.dateformatter.impl.DefaultDateFormatter +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.allBooleans +import kotlin.time.Instant + +@Preview +@Composable +internal fun DateFormatterModeViewPreview( + @PreviewParameter(DateFormatterModeProvider::class) dateFormatterMode: DateFormatterMode, +) = ElementPreview { + DateFormatterModeView(dateFormatterMode) +} + +@Composable +private fun DateFormatterModeView( + mode: DateFormatterMode, +) { + val context = LocalContext.current + val composeLocale = Locale.current + val dateFormatter = remember { + createFormatter( + context = context, + currentDate = dateForPreviewToday.date, + locale = java.util.Locale.Builder() + .setLanguageTag(composeLocale.toLanguageTag()) + .build(), + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Mode $mode / $composeLocale", + style = ElementTheme.typography.fontHeadingSmMedium + ) + val today = Instant.parse(dateForPreviewToday.date).toEpochMilliseconds() + Text( + text = "Today is: ${dateFormatter.format(today, DateFormatterMode.Full, useRelative = false)}", + style = ElementTheme.typography.fontHeadingSmMedium, + ) + dateForPreviews.forEach { dateForPreview -> + DateForPreviewItem( + dateForPreview = dateForPreview, + dateFormatter = dateFormatter, + mode = mode, + ) + } + } +} + +@Composable +private fun DateForPreviewItem( + dateForPreview: DateForPreview, + dateFormatter: DefaultDateFormatter, + mode: DateFormatterMode, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp), + text = dateForPreview.semantic, + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textSecondary, + ) + val ts = Instant.parse(dateForPreview.date).toEpochMilliseconds() + Row { + Column { + listOf("Absolute:", "Relative:").forEach { label -> + Text( + text = label, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textPrimary, + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + Column { + allBooleans.forEach { useRelative -> + Text( + modifier = Modifier.fillMaxWidth(), + text = dateFormatter.format(ts, mode, useRelative), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textPrimary, + ) + } + } + } + } +} diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/previews/Factory.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/previews/Factory.kt new file mode 100644 index 0000000..6028a04 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/previews/Factory.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl.previews + +import android.content.Context +import io.element.android.libraries.dateformatter.impl.DateFormatterFull +import io.element.android.libraries.dateformatter.impl.DateFormatterMonth +import io.element.android.libraries.dateformatter.impl.DateFormatterTime +import io.element.android.libraries.dateformatter.impl.DateFormatterTimeOnly +import io.element.android.libraries.dateformatter.impl.DateFormatters +import io.element.android.libraries.dateformatter.impl.DefaultDateFormatter +import io.element.android.libraries.dateformatter.impl.DefaultDateFormatterDay +import io.element.android.libraries.dateformatter.impl.LocalDateTimeProvider +import kotlinx.datetime.TimeZone +import java.util.Locale +import kotlin.time.Instant + +/** + * Create DefaultDateFormatter and set current time to the provided date. + */ +fun createFormatter( + context: Context, + currentDate: String, + locale: Locale, +): DefaultDateFormatter { + val clock = PreviewClock().apply { givenInstant(Instant.parse(currentDate)) } + val localDateTimeProvider = LocalDateTimeProvider(clock) { TimeZone.UTC } + val dateFormatters = DateFormatters( + localeChangeObserver = {}, + clock = clock, + timeZoneProvider = { TimeZone.UTC }, + locale = locale, + ) + val stringProvider = PreviewStringProvider(context.resources) + val dateFormatterDay = DefaultDateFormatterDay( + localDateTimeProvider = localDateTimeProvider, + dateFormatters = dateFormatters, + ) + return DefaultDateFormatter( + dateFormatterFull = DateFormatterFull( + stringProvider = stringProvider, + localDateTimeProvider = localDateTimeProvider, + dateFormatters = dateFormatters, + dateFormatterDay = dateFormatterDay, + ), + dateFormatterMonth = DateFormatterMonth( + stringProvider = stringProvider, + localDateTimeProvider = localDateTimeProvider, + dateFormatters = dateFormatters, + ), + dateFormatterDay = dateFormatterDay, + dateFormatterTime = DateFormatterTime( + localDateTimeProvider = localDateTimeProvider, + dateFormatters = dateFormatters, + ), + dateFormatterTimeOnly = DateFormatterTimeOnly( + localDateTimeProvider = localDateTimeProvider, + dateFormatters = dateFormatters, + ), + ) +} diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/previews/PreviewClock.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/previews/PreviewClock.kt new file mode 100644 index 0000000..feecf10 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/previews/PreviewClock.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl.previews + +import kotlin.time.Clock +import kotlin.time.Instant + +class PreviewClock : 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/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/previews/PreviewStringProvider.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/previews/PreviewStringProvider.kt new file mode 100644 index 0000000..3cd004f --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/previews/PreviewStringProvider.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl.previews + +import android.content.res.Resources +import androidx.annotation.PluralsRes +import androidx.annotation.StringRes +import io.element.android.services.toolbox.api.strings.StringProvider + +class PreviewStringProvider( + 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/libraries/dateformatter/impl/src/main/res/values-bg/translations.xml b/libraries/dateformatter/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..b03020f --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,4 @@ + + + "Този месец" + diff --git a/libraries/dateformatter/impl/src/main/res/values-cs/translations.xml b/libraries/dateformatter/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..578211b --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s v %2$s" + "Tento měsíc" + diff --git a/libraries/dateformatter/impl/src/main/res/values-cy/translations.xml b/libraries/dateformatter/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..0535f86 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s am %2$s" + "Y mis hwn" + diff --git a/libraries/dateformatter/impl/src/main/res/values-da/translations.xml b/libraries/dateformatter/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..b74fc5a --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s kl. %2$s" + "Denne måned" + diff --git a/libraries/dateformatter/impl/src/main/res/values-de/translations.xml b/libraries/dateformatter/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..e98c51e --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s um %2$s" + "Diesen Monat" + diff --git a/libraries/dateformatter/impl/src/main/res/values-el/translations.xml b/libraries/dateformatter/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..63df4a0 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s στις %2$s" + "Αυτό το μήνα" + diff --git a/libraries/dateformatter/impl/src/main/res/values-es/translations.xml b/libraries/dateformatter/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..04d9da0 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s a las %2$s" + "Este mes" + diff --git a/libraries/dateformatter/impl/src/main/res/values-et/translations.xml b/libraries/dateformatter/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..d198578 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s, %2$s" + "Sel kuul" + diff --git a/libraries/dateformatter/impl/src/main/res/values-eu/translations.xml b/libraries/dateformatter/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..5e690a7 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,4 @@ + + + "Hilabete hau" + diff --git a/libraries/dateformatter/impl/src/main/res/values-fa/translations.xml b/libraries/dateformatter/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..da6566e --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s در %2$s" + "این ماه" + diff --git a/libraries/dateformatter/impl/src/main/res/values-fi/translations.xml b/libraries/dateformatter/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..2a00130 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s klo %2$s" + "Tässä kuussa" + diff --git a/libraries/dateformatter/impl/src/main/res/values-fr/translations.xml b/libraries/dateformatter/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..f263536 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s à %2$s" + "Ce mois-ci" + diff --git a/libraries/dateformatter/impl/src/main/res/values-hu/translations.xml b/libraries/dateformatter/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..0f4e767 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s, %2$s" + "Ebben a hónapban" + diff --git a/libraries/dateformatter/impl/src/main/res/values-in/translations.xml b/libraries/dateformatter/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..4771c4e --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s pada %2$s" + "Bulan ini" + diff --git a/libraries/dateformatter/impl/src/main/res/values-it/translations.xml b/libraries/dateformatter/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..8c53426 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s alle %2$s" + "Questo mese" + diff --git a/libraries/dateformatter/impl/src/main/res/values-ko/translations.xml b/libraries/dateformatter/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..6712955 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,5 @@ + + + "%2$s 에 %1$s" + "이번 달" + diff --git a/libraries/dateformatter/impl/src/main/res/values-nb/translations.xml b/libraries/dateformatter/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..a31d53c --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s kl. %2$s" + "Denne måneden" + diff --git a/libraries/dateformatter/impl/src/main/res/values-pl/translations.xml b/libraries/dateformatter/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..86a62e1 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s o %2$s" + "W tym miesiącu" + diff --git a/libraries/dateformatter/impl/src/main/res/values-pt-rBR/translations.xml b/libraries/dateformatter/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..7c72997 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s às %2$s" + "Este mês" + diff --git a/libraries/dateformatter/impl/src/main/res/values-pt/translations.xml b/libraries/dateformatter/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..7c72997 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s às %2$s" + "Este mês" + diff --git a/libraries/dateformatter/impl/src/main/res/values-ro/translations.xml b/libraries/dateformatter/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..232b0ae --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s la %2$s" + "Luna aceasta" + diff --git a/libraries/dateformatter/impl/src/main/res/values-ru/translations.xml b/libraries/dateformatter/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..985ff9c --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s в %2$s" + "В этом месяце" + diff --git a/libraries/dateformatter/impl/src/main/res/values-sk/translations.xml b/libraries/dateformatter/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..af0a60f --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s o %2$s" + "Tento mesiac" + diff --git a/libraries/dateformatter/impl/src/main/res/values-sv/translations.xml b/libraries/dateformatter/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..58f22f8 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,5 @@ + + + "%1$svid %2$s" + "Denna månad" + diff --git a/libraries/dateformatter/impl/src/main/res/values-tr/translations.xml b/libraries/dateformatter/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..5276e6c --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s %2$s" + "Bu ay" + diff --git a/libraries/dateformatter/impl/src/main/res/values-uk/translations.xml b/libraries/dateformatter/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..4d831d6 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s о %2$s" + "Цього місяця" + diff --git a/libraries/dateformatter/impl/src/main/res/values-uz/translations.xml b/libraries/dateformatter/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..204e29b --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,5 @@ + + + "%1$sda %2$s" + "Bu oy" + diff --git a/libraries/dateformatter/impl/src/main/res/values-zh-rTW/translations.xml b/libraries/dateformatter/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..0f3c953 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s 在 %2$s" + "本月" + diff --git a/libraries/dateformatter/impl/src/main/res/values-zh/translations.xml b/libraries/dateformatter/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..9fab311 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s在 %2$s" + "本月" + diff --git a/libraries/dateformatter/impl/src/main/res/values/localazy.xml b/libraries/dateformatter/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..8b0dab8 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values/localazy.xml @@ -0,0 +1,5 @@ + + + "%1$s at %2$s" + "This month" + diff --git a/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDateFormatterFrTest.kt b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDateFormatterFrTest.kt new file mode 100644 index 0000000..b480072 --- /dev/null +++ b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDateFormatterFrTest.kt @@ -0,0 +1,262 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl + +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.dateformatter.api.DateFormatterMode +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.time.Instant + +@RunWith(AndroidJUnit4::class) +@Config(qualifiers = "fr", sdk = [Build.VERSION_CODES.TIRAMISU]) +class DefaultDateFormatterFrTest { + @Test + fun `test null`() { + val now = "1980-04-06T18:35:24.00Z" + val ts: Long? = null + val formatter = createFormatter(now) + assertThat(formatter.format(ts)).isEmpty() + } + + @Test + fun `test epoch`() { + val now = "1980-04-06T18:35:24.00Z" + val ts = 0L + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("1 janvier 1970 à 00:00") + assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("Janvier 1970") + assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("1 janvier 1970") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("01/01/1970") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("00:00") + } + + @Test + fun `test epoch relative`() { + val now = "1980-04-06T18:35:24.00Z" + val ts = 0L + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("1 janvier 1970 à 00:00") + assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("Janvier 1970") + assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("1 janvier 1970") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("01/01/1970") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("00:00") + } + + @Test + fun `test now`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T18:35:24.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("6 avril 1980 à 18:35") + assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("Avril 1980") + assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Dimanche 6 avril") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("18:35") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("18:35") + } + + @Test + fun `test now relative`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T18:35:24.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("18:35") + assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("Ce mois-ci") + assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Aujourd’hui") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("18:35") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).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 ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("6 avril 1980 à 18:35") + assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("Avril 1980") + assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Dimanche 6 avril") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("18:35") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("18:35") + } + + @Test + fun `test one second before relative`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T18:35:23.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("18:35") + assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("Ce mois-ci") + assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Aujourd’hui") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("18:35") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).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 ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("6 avril 1980 à 18:34") + assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("Avril 1980") + assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Dimanche 6 avril") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("18:34") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("18:34") + } + + @Test + fun `test one minute before relative`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T18:34:24.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("18:34") + assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("Ce mois-ci") + assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Aujourd’hui") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("18:34") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).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 ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("6 avril 1980 à 17:35") + assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("Avril 1980") + assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Dimanche 6 avril") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("17:35") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("17:35") + } + + @Test + fun `test one hour before relative`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T17:35:24.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("17:35") + assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("Ce mois-ci") + assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Aujourd’hui") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("17:35") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).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 ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("5 avril 1980 à 18:35") + assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("Avril 1980") + assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Samedi 5 avril") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("5 avr.") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("18:35") + } + + @Test + fun `test one day before same time relative`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-05T18:35:24.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("Hier à 18:35") + assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("Ce mois-ci") + assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Hier") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("Hier") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("18:35") + } + + @Test + fun `test two days before same time`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-04T18:35:24.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("4 avril 1980 à 18:35") + assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("Avril 1980") + assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Vendredi 4 avril") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("4 avr.") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("18:35") + } + + @Test + fun `test two days before same time relative`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-04T18:35:24.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("Vendredi à 18:35") + assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("Ce mois-ci") + assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Vendredi") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("4 avr.") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("18:35") + } + + @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 ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("6 mars 1980 à 18:35") + assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("Mars 1980") + assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Jeudi 6 mars") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("6 mars") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("18:35") + } + + @Test + fun `test one month before same time relative`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-03-06T18:35:24.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("Jeudi 6 mars à 18:35") + assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("Mars 1980") + assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Jeudi 6 mars") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("6 mars") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("18:35") + } + + @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 ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("6 avril 1979 à 18:35") + assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("Avril 1979") + assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("6 avril 1979") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("06/04/1979") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("18:35") + } + + @Test + fun `test one year before same time relative`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1979-04-06T18:35:24.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("6 avril 1979 à 18:35") + assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("Avril 1979") + assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("6 avril 1979") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("06/04/1979") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("18:35") + } +} diff --git a/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDateFormatterTest.kt b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDateFormatterTest.kt new file mode 100644 index 0000000..94b6fe9 --- /dev/null +++ b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDateFormatterTest.kt @@ -0,0 +1,262 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl + +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.dateformatter.api.DateFormatterMode +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.time.Instant + +@RunWith(AndroidJUnit4::class) +@Config(qualifiers = "en", sdk = [Build.VERSION_CODES.TIRAMISU]) +class DefaultDateFormatterTest { + @Test + fun `test null`() { + val now = "1980-04-06T18:35:24.00Z" + val ts: Long? = null + val formatter = createFormatter(now) + assertThat(formatter.format(ts)).isEmpty() + } + + @Test + fun `test epoch`() { + val now = "1980-04-06T18:35:24.00Z" + val ts = 0L + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("January 1, 1970 at 12:00 AM") + assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("January 1970") + assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("January 1, 1970") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("01/01/1970") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("12:00 AM") + } + + @Test + fun `test epoch relative`() { + val now = "1980-04-06T18:35:24.00Z" + val ts = 0L + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("January 1, 1970 at 12:00 AM") + assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("January 1970") + assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("January 1, 1970") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("01/01/1970") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("12:00 AM") + } + + @Test + fun `test now`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T18:35:24.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("April 6, 1980 at 6:35 PM") + assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("April 1980") + assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Sunday, April 6") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("6:35 PM") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("6:35 PM") + } + + @Test + fun `test now relative`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T18:35:24.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("6:35 PM") + assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("This month") + assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Today") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("6:35 PM") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("6:35 PM") + } + + @Test + fun `test one second before`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T18:35:23.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("April 6, 1980 at 6:35 PM") + assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("April 1980") + assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Sunday, April 6") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("6:35 PM") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("6:35 PM") + } + + @Test + fun `test one second before relative`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T18:35:23.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("6:35 PM") + assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("This month") + assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Today") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("6:35 PM") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("6:35 PM") + } + + @Test + fun `test one minute before`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T18:34:24.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("April 6, 1980 at 6:34 PM") + assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("April 1980") + assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Sunday, April 6") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("6:34 PM") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("6:34 PM") + } + + @Test + fun `test one minute before relative`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T18:34:24.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("6:34 PM") + assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("This month") + assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Today") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("6:34 PM") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("6:34 PM") + } + + @Test + fun `test one hour before`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T17:35:24.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("April 6, 1980 at 5:35 PM") + assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("April 1980") + assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Sunday, April 6") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("5:35 PM") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("5:35 PM") + } + + @Test + fun `test one hour before relative`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T17:35:24.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("5:35 PM") + assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("This month") + assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Today") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("5:35 PM") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("5:35 PM") + } + + @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 ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("April 5, 1980 at 6:35 PM") + assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("April 1980") + assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Saturday, April 5") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("Apr 5") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("6:35 PM") + } + + @Test + fun `test one day before same time relative`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-05T18:35:24.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("Yesterday at 6:35 PM") + assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("This month") + assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Yesterday") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("Yesterday") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("6:35 PM") + } + + @Test + fun `test two days before same time`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-04T18:35:24.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("April 4, 1980 at 6:35 PM") + assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("April 1980") + assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Friday, April 4") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("Apr 4") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("6:35 PM") + } + + @Test + fun `test two days before same time relative`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-04T18:35:24.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("Friday at 6:35 PM") + assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("This month") + assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Friday") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("Apr 4") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("6:35 PM") + } + + @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 ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("March 6, 1980 at 6:35 PM") + assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("March 1980") + assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Thursday, March 6") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("Mar 6") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("6:35 PM") + } + + @Test + fun `test one month before same time relative`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-03-06T18:35:24.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("Thursday, March 6 at 6:35 PM") + assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("March 1980") + assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Thursday, March 6") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("Mar 6") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("6:35 PM") + } + + @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 ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("April 6, 1979 at 6:35 PM") + assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("April 1979") + assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("April 6, 1979") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("04/06/1979") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("6:35 PM") + } + + @Test + fun `test one year before same time relative`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1979-04-06T18:35:24.00Z" + val ts = Instant.parse(dat).toEpochMilliseconds() + val formatter = createFormatter(now) + assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("April 6, 1979 at 6:35 PM") + assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("April 1979") + assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("April 6, 1979") + assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("04/06/1979") + assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("6:35 PM") + } +} diff --git a/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/Factory.kt b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/Factory.kt new file mode 100644 index 0000000..0ec6377 --- /dev/null +++ b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/Factory.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl + +import io.element.android.tests.testutils.InstrumentationStringProvider +import kotlinx.datetime.TimeZone +import java.util.Locale +import kotlin.time.Instant + +/** + * Create DefaultDateFormatter and set current time to the provided date. + */ +fun createFormatter(currentDate: String): DefaultDateFormatter { + val clock = FakeClock().apply { givenInstant(Instant.parse(currentDate)) } + val localDateTimeProvider = LocalDateTimeProvider(clock) { TimeZone.UTC } + val dateFormatters = DateFormatters( + localeChangeObserver = {}, + clock = clock, + timeZoneProvider = { TimeZone.UTC }, + locale = Locale.getDefault(), + ) + val stringProvider = InstrumentationStringProvider() + val dateFormatterDay = DefaultDateFormatterDay( + localDateTimeProvider = localDateTimeProvider, + dateFormatters = dateFormatters, + ) + return DefaultDateFormatter( + dateFormatterFull = DateFormatterFull( + stringProvider = stringProvider, + localDateTimeProvider = localDateTimeProvider, + dateFormatters = dateFormatters, + dateFormatterDay = dateFormatterDay, + ), + dateFormatterMonth = DateFormatterMonth( + stringProvider = stringProvider, + localDateTimeProvider = localDateTimeProvider, + dateFormatters = dateFormatters, + ), + dateFormatterDay = dateFormatterDay, + dateFormatterTime = DateFormatterTime( + localDateTimeProvider = localDateTimeProvider, + dateFormatters = dateFormatters, + ), + dateFormatterTimeOnly = DateFormatterTimeOnly( + localDateTimeProvider = localDateTimeProvider, + dateFormatters = dateFormatters, + ), + ) +} diff --git a/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/FakeClock.kt b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/FakeClock.kt new file mode 100644 index 0000000..d2305e6 --- /dev/null +++ b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/FakeClock.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.impl + +import kotlin.time.Clock +import kotlin.time.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/.gitignore b/libraries/dateformatter/test/.gitignore new file mode 100644 index 0000000..42afabf --- /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 0000000..0367030 --- /dev/null +++ b/libraries/dateformatter/test/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..e69de29 diff --git a/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeDateFormatter.kt b/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeDateFormatter.kt new file mode 100644 index 0000000..617fa76 --- /dev/null +++ b/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeDateFormatter.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.dateformatter.test + +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode + +class FakeDateFormatter( + private val formatLambda: (Long?, DateFormatterMode, Boolean) -> String = { timestamp, mode, useRelative -> + "$timestamp $mode $useRelative" + }, +) : DateFormatter { + override fun format( + timestamp: Long?, + mode: DateFormatterMode, + useRelative: Boolean, + ): String { + return formatLambda(timestamp, mode, useRelative) + } +} diff --git a/libraries/deeplink/api/build.gradle.kts b/libraries/deeplink/api/build.gradle.kts new file mode 100644 index 0000000..dded8dc --- /dev/null +++ b/libraries/deeplink/api/build.gradle.kts @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.deeplink.api" +} + +dependencies { + implementation(projects.libraries.matrix.api) +} diff --git a/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/DeepLinkCreator.kt b/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/DeepLinkCreator.kt new file mode 100644 index 0000000..2b46b15 --- /dev/null +++ b/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/DeepLinkCreator.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.deeplink.api + +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 + +fun interface DeepLinkCreator { + fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?, eventId: EventId?): String +} diff --git a/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/DeeplinkData.kt b/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/DeeplinkData.kt new file mode 100644 index 0000000..aac1a7d --- /dev/null +++ b/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/DeeplinkData.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.deeplink.api + +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 + +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] and [eventId]. */ + data class Room(override val sessionId: SessionId, val roomId: RoomId, val threadId: ThreadId?, val eventId: EventId?) : DeeplinkData +} diff --git a/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/DeeplinkParser.kt b/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/DeeplinkParser.kt new file mode 100644 index 0000000..f0f8640 --- /dev/null +++ b/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/DeeplinkParser.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.deeplink.api + +import android.content.Intent + +fun interface DeeplinkParser { + fun getFromIntent(intent: Intent): DeeplinkData? +} diff --git a/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/usecase/InviteFriendsUseCase.kt b/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/usecase/InviteFriendsUseCase.kt new file mode 100644 index 0000000..64287f9 --- /dev/null +++ b/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/usecase/InviteFriendsUseCase.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.deeplink.api.usecase + +import android.app.Activity + +fun interface InviteFriendsUseCase { + fun execute(activity: Activity) +} diff --git a/libraries/deeplink/impl/build.gradle.kts b/libraries/deeplink/impl/build.gradle.kts new file mode 100644 index 0000000..e07df72 --- /dev/null +++ b/libraries/deeplink/impl/build.gradle.kts @@ -0,0 +1,35 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.deeplink.impl" +} + +setupDependencyInjection() + +dependencies { + api(projects.libraries.deeplink.api) + implementation(projects.libraries.di) + 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) + + testCommonDependencies(libs) + testImplementation(projects.libraries.matrix.test) +} diff --git a/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/Constants.kt b/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/Constants.kt new file mode 100644 index 0000000..5bea982 --- /dev/null +++ b/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/Constants.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.deeplink.impl + +internal const val SCHEME = "elementx" +internal const val HOST = "open" diff --git a/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreator.kt b/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreator.kt new file mode 100644 index 0000000..97c6eed --- /dev/null +++ b/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreator.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.deeplink.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.deeplink.api.DeepLinkCreator +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 + +@ContributesBinding(AppScope::class) +class DefaultDeepLinkCreator : DeepLinkCreator { + override fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?, eventId: EventId?): String { + return buildString { + append("$SCHEME://$HOST/") + append(sessionId.value) + append("/") + append(roomId?.value.orEmpty()) + append("/") + append(threadId?.value.orEmpty()) + append("/") + append(eventId?.value.orEmpty()) + } + // Remove all possible trailing '/' characters: + // No event id + .removeSuffix("/") + // No thread id + .removeSuffix("/") + // No room id + .removeSuffix("/") + } +} diff --git a/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParser.kt b/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParser.kt new file mode 100644 index 0000000..ca1a39d --- /dev/null +++ b/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParser.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.deeplink.impl + +import android.content.Intent +import android.net.Uri +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.deeplink.api.DeeplinkData +import io.element.android.libraries.deeplink.api.DeeplinkParser +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 + +@ContributesBinding(AppScope::class) +class DefaultDeeplinkParser : DeeplinkParser { + override 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) + else -> { + val roomId = screenPathComponent.let(::RoomId) + val threadId = pathBits.elementAtOrNull(2)?.takeIf { it.isNotBlank() }?.let(::ThreadId) + val eventId = pathBits.elementAtOrNull(3)?.takeIf { it.isNotBlank() }?.let(::EventId) + DeeplinkData.Room(sessionId, roomId, threadId, eventId) + } + } + } +} diff --git a/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/usecase/DefaultInviteFriendsUseCase.kt b/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/usecase/DefaultInviteFriendsUseCase.kt new file mode 100644 index 0000000..d3ba7e7 --- /dev/null +++ b/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/usecase/DefaultInviteFriendsUseCase.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.deeplink.impl.usecase + +import android.app.Activity +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.system.startSharePlainTextIntent +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.deeplink.api.usecase.InviteFriendsUseCase +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.toolbox.api.strings.StringProvider +import timber.log.Timber +import io.element.android.libraries.androidutils.R as AndroidUtilsR + +@ContributesBinding(SessionScope::class) +class DefaultInviteFriendsUseCase( + private val stringProvider: StringProvider, + private val matrixClient: MatrixClient, + private val buildMeta: BuildMeta, + private val permalinkBuilder: PermalinkBuilder, +) : InviteFriendsUseCase { + override 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/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreatorTest.kt b/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreatorTest.kt new file mode 100644 index 0000000..4e3a10e --- /dev/null +++ b/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreatorTest.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.deeplink.impl + +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.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_THREAD_ID +import org.junit.Test + +class DefaultDeepLinkCreatorTest { + @Test + fun create() { + val sut = DefaultDeepLinkCreator() + assertThat(sut.create(A_SESSION_ID, null, null, null)) + .isEqualTo("elementx://open/@alice:server.org") + assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, null, null)) + .isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain") + assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID, null)) + .isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId") + assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID, AN_EVENT_ID)) + .isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId/\$anEventId") + assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, null, AN_EVENT_ID)) + .isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain//\$anEventId") + } +} diff --git a/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParserTest.kt b/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParserTest.kt new file mode 100644 index 0000000..4b79f2b --- /dev/null +++ b/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParserTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.deeplink.impl + +import android.content.Intent +import androidx.core.net.toUri +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.deeplink.api.DeeplinkData +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_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 DefaultDeeplinkParserTest { + 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_WITH_ROOM_WITH_THREAD_AND_EVENT = + "elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId/\$anEventId" + const val A_URI_WITH_ROOM_WITH_EVENT_AND_NO_THREAD = + "elementx://open/@alice:server.org/!aRoomId:domain//\$anEventId" + } + + @Test + fun `nominal cases`() { + val sut = DefaultDeeplinkParser() + 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, null)) + assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_THREAD))) + .isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID, null)) + assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_THREAD_AND_EVENT))) + .isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID, AN_EVENT_ID)) + assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_EVENT_AND_NO_THREAD))) + .isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, null, AN_EVENT_ID)) + } + + @Test + fun `error cases`() { + val sut = DefaultDeeplinkParser() + // 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 0000000..42afabf --- /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 0000000..bdb9a32 --- /dev/null +++ b/libraries/designsystem/build.gradle.kts @@ -0,0 +1,49 @@ +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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") { + consumerProguardFiles("consumer-rules.pro") + } + } +} + +dependencies { + api(projects.libraries.compound) + + implementation(libs.androidx.compose.material3.windowsizeclass) + implementation(libs.androidx.compose.material3.adaptive) + implementation(libs.coil.compose) + implementation(libs.vanniktech.blurhash) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.core) + implementation(projects.libraries.preferences.api) + implementation(projects.libraries.testtags) + implementation(projects.libraries.uiStrings) + + ksp(libs.showkase.processor) + implementation(libs.showkase) + + testCommonDependencies(libs) +} diff --git a/libraries/designsystem/consumer-rules.pro b/libraries/designsystem/consumer-rules.pro new file mode 100644 index 0000000..dabf566 --- /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/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 0000000..925d66e --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ColorUtil.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import io.element.android.compound.theme.ElementTheme + +@Composable +fun Boolean.toEnabledColor(): Color { + return if (this) { + ElementTheme.colors.textPrimary + } else { + ElementTheme.colors.textDisabled + } +} + +@Composable +fun Boolean.toSecondaryEnabledColor(): Color { + return if (this) { + ElementTheme.colors.textSecondary + } else { + ElementTheme.colors.textDisabled + } +} + +@Composable +fun Boolean.toIconEnabledColor(): Color { + return if (this) { + ElementTheme.colors.iconPrimary + } else { + ElementTheme.colors.iconDisabled + } +} + +@Composable +fun Boolean.toIconSecondaryEnabledColor(): Color { + return if (this) { + ElementTheme.colors.iconSecondary + } else { + ElementTheme.colors.iconDisabled + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/animation/AlphaAnimation.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/animation/AlphaAnimation.kt new file mode 100644 index 0000000..1da74b3 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/animation/AlphaAnimation.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.animation + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalInspectionMode + +@Composable +fun alphaAnimation( + fromAlpha: Float = 0f, + toAlpha: Float = 1f, + delayMillis: Int = 150, + durationMillis: Int = 150, + label: String = "AlphaAnimation", +): State { + val firstAlpha = if (LocalInspectionMode.current) 1f else fromAlpha + var alpha by remember { mutableFloatStateOf(firstAlpha) } + LaunchedEffect(Unit) { alpha = toAlpha } + return animateFloatAsState( + targetValue = alpha, + animationSpec = tween( + delayMillis = delayMillis, + durationMillis = durationMillis, + ), + label = label + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/BetaLabel.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/BetaLabel.kt new file mode 100644 index 0000000..9663c18 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/BetaLabel.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.atoms + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun BetaLabel( + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(size = 6.dp) + Text( + modifier = modifier + .border( + width = 1.dp, + color = ElementTheme.colors.borderInfoSubtle, + shape = shape, + ) + .background( + color = ElementTheme.colors.bgInfoSubtle, + shape = shape, + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + text = stringResource(CommonStrings.common_beta).uppercase(), + style = ElementTheme.typography.fontBodySmMedium, + color = ElementTheme.colors.textInfoPrimary, + ) +} + +@PreviewsDayNight +@Composable +internal fun BetaLabelPreview() = ElementPreview { + BetaLabel() +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt new file mode 100644 index 0000000..a051fd4 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.atoms + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.toDp +import io.element.android.libraries.designsystem.theme.components.Text + +private const val MAX_COUNT = 99 +private const val MAX_COUNT_STRING = "+$MAX_COUNT" + +/** + * A counter atom that displays a number in a circle. + * Figma link : https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2805-2649&m=dev + * + * @param count The number to display. If the number is greater than [MAX_COUNT], the counter will display [MAX_COUNT_STRING]. + * If the number is less than 1, the counter will not be displayed. + * @param modifier The modifier to apply to this layout. + * @param textStyle The style to apply to the text inside the counter. + * @param isCritical If true, the counter will use a critical color scheme, otherwise it will use an accent color scheme. + */ +@Composable +fun CounterAtom( + count: Int, + modifier: Modifier = Modifier, + textStyle: TextStyle = CounterAtomDefaults.textStyle, + isCritical: Boolean = false, +) { + if (count < 1) return + val countAsText = when (count) { + in 0..MAX_COUNT -> count.toString() + else -> MAX_COUNT_STRING + } + val textMeasurer = rememberTextMeasurer() + // Measure the maximum count string size + val textLayoutResult = textMeasurer.measure( + text = MAX_COUNT_STRING, + style = textStyle + ) + val textSize = textLayoutResult.size + val squareSize = maxOf(textSize.width, textSize.height) + Box( + modifier = modifier + .size(squareSize.toDp() + 1.dp) + .clip(CircleShape) + .background( + if (isCritical) { + ElementTheme.colors.iconCriticalPrimary + } else { + ElementTheme.colors.iconAccentPrimary + } + ) + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = countAsText, + style = textStyle, + color = ElementTheme.colors.textOnSolidPrimary, + ) + } +} + +object CounterAtomDefaults { + val textStyle: TextStyle + @Composable get() = ElementTheme.typography.fontBodyMdMedium +} + +@PreviewsDayNight +@Composable +internal fun CounterAtomPreview() = ElementPreview { + Column(verticalArrangement = spacedBy(2.dp)) { + CounterAtom(count = 0) + CounterAtom(count = 4) + CounterAtom(count = 99) + CounterAtom(count = 100) + CounterAtom(count = 4, isCritical = true) + } +} 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 0000000..85cf642 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.atoms + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage +import io.element.android.libraries.designsystem.R +import io.element.android.libraries.designsystem.modifiers.blurCompat +import io.element.android.libraries.designsystem.modifiers.blurredShapeShadow +import io.element.android.libraries.designsystem.modifiers.canUseBlurMaskFilter +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +@Composable +fun ElementLogoAtom( + size: ElementLogoAtomSize, + modifier: Modifier = Modifier, + useBlurredShadow: Boolean = canUseBlurMaskFilter(), + darkTheme: Boolean = ElementTheme.isLightTheme.not(), +) { + val blur = if (darkTheme) 160.dp else 24.dp + val shadowColor = if (darkTheme) size.shadowColorDark else size.shadowColorLight + val logoShadowColor = if (darkTheme) size.logoShadowColorDark else size.logoShadowColorLight + 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.89f) else Color.White + Box( + modifier = modifier + .size(size.outerSize) + .border(size.borderWidth, borderColor, RoundedCornerShape(size.cornerRadius)), + contentAlignment = Alignment.Center, + ) { + if (useBlurredShadow) { + Box( + Modifier + .size(size.outerSize) + .blurredShapeShadow( + color = shadowColor, + cornerRadius = size.cornerRadius, + blurRadius = size.shadowRadius, + offsetY = 8.dp, + ) + ) + } else { + Box( + Modifier + .size(size.outerSize) + .shadow( + elevation = size.shadowRadius, + shape = RoundedCornerShape(size.cornerRadius), + clip = false, + ambientColor = shadowColor + ) + ) + } + Box( + Modifier + .clip(RoundedCornerShape(size.cornerRadius)) + .size(size.outerSize) + .background(backgroundColor) + .blurCompat(blur) + ) + Image( + modifier = Modifier + .size(size.logoSize) + // Do the same double shadow than on Figma... + .shadow( + elevation = 35.dp, + clip = false, + shape = CircleShape, + ambientColor = logoShadowColor, + ) + .shadow( + elevation = 35.dp, + clip = false, + shape = CircleShape, + ambientColor = Color(0x80000000), + ), + 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 logoShadowColorDark: Color, + val logoShadowColorLight: Color, + val shadowColorDark: Color, + val shadowColorLight: Color, + val shadowRadius: Dp, +) { + data object Medium : ElementLogoAtomSize( + outerSize = 120.dp, + logoSize = 83.5.dp, + cornerRadius = 33.dp, + borderWidth = 0.38.dp, + logoShadowColorDark = Color(0x4D000000), + logoShadowColorLight = Color(0x66000000), + shadowColorDark = Color.Black.copy(alpha = 0.4f), + shadowColorLight = Color(0x401B1D22), + shadowRadius = 32.dp, + ) + + data object Large : ElementLogoAtomSize( + outerSize = 158.dp, + logoSize = 110.dp, + cornerRadius = 44.dp, + borderWidth = 0.5.dp, + logoShadowColorDark = Color(0x4D000000), + logoShadowColorLight = Color(0x66000000), + shadowColorDark = Color.Black, + shadowColorLight = Color(0x801B1D22), + shadowRadius = 60.dp, + ) +} + +@Composable +@PreviewsDayNight +internal fun ElementLogoAtomMediumPreview() = ElementPreview { + ContentToPreview(ElementLogoAtomSize.Medium) +} + +@Composable +@PreviewsDayNight +internal fun ElementLogoAtomLargePreview() = ElementPreview { + ContentToPreview(ElementLogoAtomSize.Large) +} + +@Composable +@PreviewsDayNight +internal fun ElementLogoAtomMediumNoBlurShadowPreview() = ElementPreview { + ContentToPreview(ElementLogoAtomSize.Medium, useBlurredShadow = false) +} + +@Composable +@PreviewsDayNight +internal fun ElementLogoAtomLargeNoBlurShadowPreview() = ElementPreview { + ContentToPreview(ElementLogoAtomSize.Large, useBlurredShadow = false) +} + +@ExcludeFromCoverage +@Composable +private fun ContentToPreview(elementLogoAtomSize: ElementLogoAtomSize, useBlurredShadow: Boolean = true) { + Box( + Modifier + .size(elementLogoAtomSize.outerSize + elementLogoAtomSize.shadowRadius * 2) + .background(ElementTheme.colors.bgSubtlePrimary), + contentAlignment = Alignment.Center + ) { + ElementLogoAtom(elementLogoAtomSize, useBlurredShadow = useBlurredShadow) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/MatrixBadgeAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/MatrixBadgeAtom.kt new file mode 100644 index 0000000..c96fb63 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/MatrixBadgeAtom.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.atoms + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.Badge +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +object MatrixBadgeAtom { + data class MatrixBadgeData( + val text: String, + val icon: ImageVector, + val type: Type, + ) + + enum class Type { + Positive, + Neutral, + Negative, + Info, + } + + @Composable + fun View( + data: MatrixBadgeData, + ) { + val backgroundColor = when (data.type) { + Type.Positive -> ElementTheme.colors.bgBadgeAccent + Type.Neutral -> ElementTheme.colors.bgBadgeDefault + Type.Negative -> ElementTheme.colors.bgCriticalSubtle + Type.Info -> ElementTheme.colors.bgBadgeInfo + } + val textColor = when (data.type) { + Type.Positive -> ElementTheme.colors.textBadgeAccent + Type.Neutral -> ElementTheme.colors.textPrimary + Type.Negative -> ElementTheme.colors.textCriticalPrimary + Type.Info -> ElementTheme.colors.textBadgeInfo + } + val iconColor = when (data.type) { + Type.Positive -> ElementTheme.colors.iconAccentPrimary + Type.Neutral -> ElementTheme.colors.iconPrimary + Type.Negative -> ElementTheme.colors.iconCriticalPrimary + Type.Info -> ElementTheme.colors.iconInfoPrimary + } + Badge( + text = data.text, + icon = data.icon, + backgroundColor = backgroundColor, + iconColor = iconColor, + textColor = textColor, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun MatrixBadgeAtomPositivePreview() = ElementPreview { + MatrixBadgeAtom.View( + MatrixBadgeAtom.MatrixBadgeData( + text = "Trusted", + icon = CompoundIcons.Verified(), + type = MatrixBadgeAtom.Type.Positive, + ) + ) +} + +@PreviewsDayNight +@Composable +internal fun MatrixBadgeAtomNeutralPreview() = ElementPreview { + MatrixBadgeAtom.View( + MatrixBadgeAtom.MatrixBadgeData( + text = "Public room", + icon = CompoundIcons.Public(), + type = MatrixBadgeAtom.Type.Neutral, + ) + ) +} + +@PreviewsDayNight +@Composable +internal fun MatrixBadgeAtomNegativePreview() = ElementPreview { + MatrixBadgeAtom.View( + MatrixBadgeAtom.MatrixBadgeData( + text = "Not trusted", + icon = CompoundIcons.ErrorSolid(), + type = MatrixBadgeAtom.Type.Negative, + ) + ) +} + +@PreviewsDayNight +@Composable +internal fun MatrixBadgeAtomInfoPreview() = ElementPreview { + MatrixBadgeAtom.View( + MatrixBadgeAtom.MatrixBadgeData( + text = "Not encrypted", + icon = CompoundIcons.LockOff(), + type = MatrixBadgeAtom.Type.Info, + ) + ) +} 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 0000000..4d5a9b8 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/PlaceholderAtom.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.placeholderBackground + +@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) + ) + ) +} + +@PreviewsDayNight +@Composable +internal fun PlaceholderAtomPreview() = ElementPreview { + // 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/RedIndicatorAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RedIndicatorAtom.kt new file mode 100644 index 0000000..99100fd --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RedIndicatorAtom.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.atoms + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +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.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +@Composable +fun RedIndicatorAtom( + modifier: Modifier = Modifier, + size: Dp = 10.dp, + borderSize: Dp = 1.dp, + color: Color = ElementTheme.colors.bgCriticalPrimary, +) { + Box( + modifier = modifier + .size(size) + .border(borderSize, ElementTheme.colors.bgCanvasDefault, CircleShape) + .padding(borderSize / 2) + .clip(CircleShape) + .background(color) + ) +} + +@PreviewsDayNight +@Composable +internal fun RedIndicatorAtomPreview() = ElementPreview { + RedIndicatorAtom() +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewDescriptionAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewDescriptionAtom.kt new file mode 100644 index 0000000..e4e250f --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewDescriptionAtom.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.atoms + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun RoomPreviewDescriptionAtom( + description: String, + modifier: Modifier = Modifier, + maxLines: Int = Int.MAX_VALUE, +) { + Text( + modifier = modifier, + text = description, + style = ElementTheme.typography.fontBodyMdRegular, + textAlign = TextAlign.Center, + color = ElementTheme.colors.textPrimary, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewSubtitleAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewSubtitleAtom.kt new file mode 100644 index 0000000..b076bb0 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewSubtitleAtom.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.atoms + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun RoomPreviewSubtitleAtom(subtitle: String, modifier: Modifier = Modifier) { + Text( + modifier = modifier, + text = subtitle, + style = ElementTheme.typography.fontBodyLgRegular, + textAlign = TextAlign.Center, + color = ElementTheme.colors.textSecondary, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewTitleAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewTitleAtom.kt new file mode 100644 index 0000000..740605f --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoomPreviewTitleAtom.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.atoms + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextAlign +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun RoomPreviewTitleAtom( + title: String, + modifier: Modifier = Modifier, + fontStyle: FontStyle? = null, +) { + Text( + modifier = modifier, + text = title, + style = ElementTheme.typography.fontHeadingLgBold, + textAlign = TextAlign.Center, + fontStyle = fontStyle, + color = ElementTheme.colors.textPrimary, + ) +} 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 0000000..7393002 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/RoundedIconAtom.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.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.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial + +/** + * 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 + * @param backgroundTint the tint to apply to the icon background + */ +@Composable +fun RoundedIconAtom( + modifier: Modifier = Modifier, + size: RoundedIconAtomSize = RoundedIconAtomSize.Big, + resourceId: Int? = null, + imageVector: ImageVector? = null, + tint: Color = ElementTheme.colors.iconSecondary, + backgroundTint: Color = ElementTheme.colors.temporaryColorBgSpecial, +) { + Box( + modifier = modifier + .size(size.toContainerSize()) + .background( + color = backgroundTint, + shape = RoundedCornerShape(size.toCornerSize()) + ) + ) { + Icon( + modifier = Modifier + .align(Alignment.Center) + .size(size.toIconSize()), + tint = tint, + resourceId = resourceId, + imageVector = imageVector, + contentDescription = null, + ) + } +} + +private fun RoundedIconAtomSize.toContainerSize(): Dp { + return when (this) { + RoundedIconAtomSize.Medium -> 30.dp + RoundedIconAtomSize.Big -> 36.dp + } +} + +private fun RoundedIconAtomSize.toCornerSize(): Dp { + return when (this) { + RoundedIconAtomSize.Medium -> 8.dp + RoundedIconAtomSize.Big -> 8.dp + } +} + +private fun RoundedIconAtomSize.toIconSize(): Dp { + return when (this) { + RoundedIconAtomSize.Medium -> 16.dp + RoundedIconAtomSize.Big -> 24.dp + } +} + +@PreviewsDayNight +@Composable +internal fun RoundedIconAtomPreview() = ElementPreview { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + RoundedIconAtom( + size = RoundedIconAtomSize.Medium, + imageVector = CompoundIcons.HomeSolid(), + ) + RoundedIconAtom( + size = RoundedIconAtomSize.Big, + imageVector = CompoundIcons.HomeSolid(), + ) + } +} + +enum class RoundedIconAtomSize { + Medium, + Big, +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/SelectedIndicatorAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/SelectedIndicatorAtom.kt new file mode 100644 index 0000000..b2e25af --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/SelectedIndicatorAtom.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.atoms + +import androidx.compose.foundation.layout.Arrangement +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.selection.toggleable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon + +@Composable +fun SelectedIndicatorAtom( + checked: Boolean, + enabled: Boolean, + modifier: Modifier = Modifier, +) { + if (checked) { + Icon( + modifier = modifier.toggleable( + value = true, + role = Role.Companion.Checkbox, + enabled = enabled, + onValueChange = {}, + ), + imageVector = CompoundIcons.CheckCircleSolid(), + contentDescription = null, + tint = if (enabled) { + ElementTheme.colors.iconAccentPrimary + } else { + ElementTheme.colors.iconDisabled + }, + ) + } else { + Box(modifier) + } +} + +@Composable +@PreviewsDayNight +internal fun SelectedIndicatorAtomPreview() = ElementPreview { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + SelectedIndicatorAtom( + modifier = Modifier.size(24.dp), + checked = false, + enabled = false, + ) + SelectedIndicatorAtom( + modifier = Modifier.size(24.dp), + checked = true, + enabled = false, + ) + SelectedIndicatorAtom( + modifier = Modifier.size(24.dp), + checked = false, + enabled = true, + ) + SelectedIndicatorAtom( + modifier = Modifier.size(24.dp), + checked = true, + enabled = true, + ) + } +} 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 0000000..d2db3ae --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/UnreadIndicatorAtom.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.unreadIndicator + +@Composable +fun UnreadIndicatorAtom( + modifier: Modifier = Modifier, + size: Dp = 12.dp, + color: Color = ElementTheme.colors.unreadIndicator, + isVisible: Boolean = true, + contentDescription: String? = null, +) { + Box( + modifier = modifier + .semantics { + contentDescription?.let { this.contentDescription = it } + } + .size(size) + .clip(CircleShape) + .background(if (isVisible) color else Color.Transparent) + ) +} + +@PreviewsDayNight +@Composable +internal fun UnreadIndicatorAtomPreview() = ElementPreview { + 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 0000000..eb03eff --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ButtonColumnMolecule.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.TextButton + +@Composable +fun ButtonColumnMolecule( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + content() + } +} + +@PreviewsDayNight +@Composable +internal fun ButtonColumnMoleculePreview() = ElementPreview { + ButtonColumnMolecule { + Button(text = "Button", onClick = {}, modifier = Modifier.fillMaxWidth()) + OutlinedButton(text = "OutlinedButton", onClick = {}, modifier = Modifier.fillMaxWidth()) + TextButton(text = "TextButton", onClick = {}, modifier = Modifier.fillMaxWidth()) + } +} 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 0000000..502f921 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ButtonRowMolecule.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.Alignment +import androidx.compose.ui.Modifier +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.TextButton + +@Composable +fun ButtonRowMolecule( + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = Arrangement.SpaceBetween, + verticalAlignment: Alignment.Vertical = Alignment.Top, + content: @Composable RowScope.() -> Unit +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = horizontalArrangement, + verticalAlignment = verticalAlignment, + ) { + content() + } +} + +@PreviewsDayNight +@Composable +internal fun ButtonRowMoleculePreview() = ElementPreview { + ButtonRowMolecule { + TextButton(text = "Button 1", onClick = {}) + TextButton(text = "Button 2", onClick = {}) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMolecule.kt new file mode 100644 index 0000000..72994fe --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMolecule.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.molecules + +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.Row +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.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +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.AvatarType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.toAnnotatedString +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ButtonSize +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 +fun ComposerAlertMolecule( + avatar: AvatarData?, + content: AnnotatedString, + onSubmitClick: () -> Unit, + modifier: Modifier = Modifier, + level: ComposerAlertLevel = ComposerAlertLevel.Default, + showIcon: Boolean = false, + submitText: String = stringResource(CommonStrings.action_ok), +) { + Column( + modifier.fillMaxWidth() + ) { + val lineColor = when (level) { + ComposerAlertLevel.Default -> ElementTheme.colors.borderInfoSubtle + ComposerAlertLevel.Info -> ElementTheme.colors.borderInfoSubtle + ComposerAlertLevel.Critical -> ElementTheme.colors.borderCriticalSubtle + } + + val startColor = when (level) { + ComposerAlertLevel.Default -> ElementTheme.colors.bgInfoSubtle + ComposerAlertLevel.Info -> ElementTheme.colors.bgInfoSubtle + ComposerAlertLevel.Critical -> ElementTheme.colors.bgCriticalSubtle + } + + val textColor = when (level) { + ComposerAlertLevel.Default -> ElementTheme.colors.textPrimary + ComposerAlertLevel.Info -> ElementTheme.colors.textInfoPrimary + ComposerAlertLevel.Critical -> ElementTheme.colors.textCriticalPrimary + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(lineColor) + ) + val brush = Brush.verticalGradient( + listOf(startColor, ElementTheme.colors.bgCanvasDefault), + ) + Box( + modifier = Modifier + .background(brush) + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (avatar != null) { + Avatar( + avatarData = avatar, + avatarType = AvatarType.User, + ) + } else if (showIcon) { + val icon = when (level) { + ComposerAlertLevel.Default -> CompoundIcons.Info() + ComposerAlertLevel.Info -> CompoundIcons.Info() + ComposerAlertLevel.Critical -> CompoundIcons.Error() + } + val iconTint = when (level) { + ComposerAlertLevel.Default -> ElementTheme.colors.iconPrimary + ComposerAlertLevel.Info -> ElementTheme.colors.iconInfoPrimary + ComposerAlertLevel.Critical -> ElementTheme.colors.iconCriticalPrimary + } + Icon( + imageVector = icon, + tint = iconTint, + contentDescription = null, + ) + } + Text( + text = content, + modifier = Modifier.weight(1f), + style = ElementTheme.typography.fontBodyMdRegular, + color = textColor, + textAlign = TextAlign.Start, + ) + } + Button( + text = submitText, + size = ButtonSize.Medium, + modifier = Modifier.fillMaxWidth(), + onClick = onSubmitClick, + ) + } + } + } +} + +enum class ComposerAlertLevel { + Default, + Info, + Critical +} + +@PreviewsDayNight +@Composable +internal fun ComposerAlertMoleculePreview( + @PreviewParameter(ComposerAlertMoleculeParamsProvider::class) params: ComposerAlertMoleculeParams, +) = ElementPreview { + ComposerAlertMolecule( + avatar = params.avatar, + content = "Alice’s verified identity has changed. Learn more".toAnnotatedString(), + level = params.level, + showIcon = params.showIcon, + onSubmitClick = {}, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMoleculeParamsProvider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMoleculeParamsProvider.kt new file mode 100644 index 0000000..09027e0 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ComposerAlertMoleculeParamsProvider.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.molecules + +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.designsystem.components.avatar.anAvatarData + +internal data class ComposerAlertMoleculeParams( + val level: ComposerAlertLevel, + val avatar: AvatarData? = null, + val showIcon: Boolean = false, +) + +internal class ComposerAlertMoleculeParamsProvider : PreviewParameterProvider { + private val allLevels = sequenceOf( + ComposerAlertLevel.Default, + ComposerAlertLevel.Info, + ComposerAlertLevel.Critical + ) + + override val values: Sequence + get() = allLevels.flatMap { level -> + sequenceOf( + ComposerAlertMoleculeParams(level = level), + ComposerAlertMoleculeParams(level = level, avatar = anAvatarData(size = AvatarSize.ComposerAlert)), + ComposerAlertMoleculeParams(level = level, showIcon = true), + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitlePlaceholdersRowMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitlePlaceholdersRowMolecule.kt new file mode 100644 index 0000000..1d2e8ae --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitlePlaceholdersRowMolecule.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.molecules + +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.Spacer +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.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.placeholderBackground + +@Composable +fun IconTitlePlaceholdersRowMolecule( + iconSize: Dp, + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, +) { + Row( + modifier = modifier, + horizontalArrangement = horizontalArrangement, + verticalAlignment = verticalAlignment, + ) { + Box( + modifier = Modifier + .size(iconSize) + .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) + Spacer(modifier = Modifier.width(8.dp)) + } +} + +@PreviewsDayNight +@Composable +internal fun IconTitlePlaceholdersRowMoleculePreview() = ElementPreview { + IconTitlePlaceholdersRowMolecule( + iconSize = AvatarSize.TimelineRoom.dp, + ) +} 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 0000000..56255d7 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.FlowRow +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.atomic.atoms.BetaLabel +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text + +/** + * 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 iconStyle the style of the [BigIcon] to display + * @param modifier the modifier to apply to this layout + * @param showBetaLabel whether to show a "BETA" label next to the title + */ +@Composable +fun IconTitleSubtitleMolecule( + title: String, + subTitle: String?, + iconStyle: BigIcon.Style, + modifier: Modifier = Modifier, + showBetaLabel: Boolean = false, +) { + Column(modifier) { + BigIcon( + modifier = Modifier.align(Alignment.CenterHorizontally), + style = iconStyle, + ) + Spacer(modifier = Modifier.height(16.dp)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + itemVerticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + modifier = Modifier + .semantics { + heading() + }, + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontHeadingMdBold, + color = ElementTheme.colors.textPrimary, + ) + if (showBetaLabel) { + BetaLabel() + } + } + if (subTitle != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = subTitle, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun IconTitleSubtitleMoleculePreview() = ElementPreview { + IconTitleSubtitleMolecule( + iconStyle = BigIcon.Style.Default(CompoundIcons.Chat()), + title = "Title", + subTitle = "Subtitle", + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListItemMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListItemMolecule.kt new file mode 100644 index 0000000..46d8029 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListItemMolecule.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.molecules + +import androidx.compose.foundation.background +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.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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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 = 18.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + icon() + message() + } +} + +@PreviewsDayNight +@Composable +internal fun InfoListItemMoleculePreview() { + ElementPreview { + val color = ElementTheme.colors.bgSubtleSecondary + Column( + modifier = Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + InfoListItemMolecule( + message = { Text("A single item") }, + icon = { Icon(imageVector = CompoundIcons.InfoSolid(), contentDescription = null) }, + position = InfoListItemPosition.Single, + backgroundColor = color, + ) + InfoListItemMolecule( + message = { Text("A top item") }, + icon = { Icon(imageVector = CompoundIcons.InfoSolid(), contentDescription = null) }, + position = InfoListItemPosition.Top, + backgroundColor = color, + ) + InfoListItemMolecule( + message = { Text("A middle item") }, + icon = { Icon(imageVector = CompoundIcons.InfoSolid(), contentDescription = null) }, + position = InfoListItemPosition.Middle, + backgroundColor = color, + ) + InfoListItemMolecule( + message = { Text("A bottom item") }, + icon = { Icon(imageVector = CompoundIcons.InfoSolid(), 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/molecules/InviteButtonsRowMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InviteButtonsRowMolecule.kt new file mode 100644 index 0000000..96075dd --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InviteButtonsRowMolecule.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.molecules + +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +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.Button +import io.element.android.libraries.designsystem.theme.components.ButtonSize +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun InviteButtonsRowMolecule( + onAcceptClick: () -> Unit, + onDeclineClick: () -> Unit, + modifier: Modifier = Modifier, + declineText: String = stringResource(CommonStrings.action_decline), + acceptText: String = stringResource(CommonStrings.action_accept), +) { + Row( + modifier = modifier, + horizontalArrangement = spacedBy(12.dp) + ) { + OutlinedButton( + text = declineText, + onClick = onDeclineClick, + size = ButtonSize.MediumLowPadding, + modifier = Modifier.weight(1f), + ) + Button( + text = acceptText, + onClick = onAcceptClick, + size = ButtonSize.MediumLowPadding, + modifier = Modifier.weight(1f), + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/MatrixBadgeRowMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/MatrixBadgeRowMolecule.kt new file mode 100644 index 0000000..48f8d79 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/MatrixBadgeRowMolecule.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.atomic.atoms.MatrixBadgeAtom +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun MatrixBadgeRowMolecule( + data: ImmutableList, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .padding(start = 16.dp, end = 16.dp, top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (badge in data) { + MatrixBadgeAtom.View(badge) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/MembersCountMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/MembersCountMolecule.kt new file mode 100644 index 0000000..4df1a5a --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/MembersCountMolecule.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.molecules + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +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.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun MembersCountMolecule( + memberCount: Int, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .background(color = ElementTheme.colors.bgSubtleSecondary, shape = CircleShape) + .padding(start = 2.dp, end = 8.dp, top = 2.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = CompoundIcons.UserProfile(), + contentDescription = null, + tint = ElementTheme.colors.iconSecondary, + ) + Text( + text = "$memberCount", + style = ElementTheme.typography.fontBodySmMedium, + color = ElementTheme.colors.textSecondary, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun MembersCountMoleculePreview() = ElementPreview { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + MembersCountMolecule(memberCount = 1) + MembersCountMolecule(memberCount = 888) + MembersCountMolecule(memberCount = 123_456) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/NumberedListMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/NumberedListMolecule.kt new file mode 100644 index 0000000..70ad04a --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/NumberedListMolecule.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.molecules + +import androidx.compose.foundation.border +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.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.modifiers.squareSize +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun NumberedListMolecule( + index: Int, + text: AnnotatedString, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ItemNumber(index = index) + Text(text = text, style = ElementTheme.typography.fontBodyMdRegular, color = ElementTheme.colors.textPrimary) + } +} + +@Composable +private fun ItemNumber( + index: Int, +) { + val color = ElementTheme.colors.textSecondary + Box( + modifier = Modifier + .border(1.dp, color, CircleShape) + .squareSize() + ) { + Text( + modifier = Modifier.padding(1.5.dp), + text = index.toString(), + style = ElementTheme.typography.fontBodySmRegular, + color = color, + textAlign = TextAlign.Center, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/TextWithLabelMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/TextWithLabelMolecule.kt new file mode 100644 index 0000000..fdc7ae5 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/TextWithLabelMolecule.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.molecules + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.Text + +/** + * Display a label and a text in a column. + * @param label the label to display + * @param text the text to display + * @param modifier the modifier to apply to this layout + * @param spellText if true, the text will be spelled out in the content description for accessibility. + * Useful for deviceId for instance, that the screen reader will read as a list of letters instead of trying to read a + * word of random characters. + */ +@Composable +fun TextWithLabelMolecule( + label: String, + text: String, + modifier: Modifier = Modifier, + spellText: Boolean = false, +) { + Column(modifier = modifier) { + Text( + text = label, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + Text( + modifier = Modifier.semantics { + if (spellText) { + contentDescription = text.toList().joinToString() + } + }, + text = text, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textPrimary, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/InfoListOrganism.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/InfoListOrganism.kt new file mode 100644 index 0000000..f52ee25 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/InfoListOrganism.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.organisms + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +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.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.atomic.molecules.InfoListItemMolecule +import io.element.android.libraries.designsystem.atomic.molecules.InfoListItemPosition +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun InfoListOrganism( + items: ImmutableList, + modifier: Modifier = Modifier, + backgroundColor: Color = ElementTheme.colors.bgSubtleSecondary, + iconTint: Color = LocalContentColor.current, + iconSize: Dp = 20.dp, + textStyle: TextStyle = LocalTextStyle.current, + textColor: Color = ElementTheme.colors.textPrimary, + 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 = { + if (item.message is AnnotatedString) { + Text( + text = item.message, + style = textStyle, + color = textColor, + ) + } else { + Text( + text = item.message.toString(), + style = textStyle, + color = textColor, + ) + } + }, + icon = { + if (item.iconId != null) { + Icon( + modifier = Modifier.size(iconSize), + resourceId = item.iconId, + contentDescription = null, + tint = iconTint, + ) + } else if (item.iconVector != null) { + Icon( + modifier = Modifier.size(iconSize), + imageVector = item.iconVector, + contentDescription = null, + tint = iconTint, + ) + } else { + item.iconComposable() + } + }, + position = position, + backgroundColor = backgroundColor, + ) + } + } +} + +data class InfoListItem( + val message: CharSequence, + @DrawableRes val iconId: Int? = null, + val iconVector: ImageVector? = null, + val iconComposable: @Composable () -> Unit = {}, +) + +@PreviewsDayNight +@Composable +internal fun InfoListOrganismPreview() = ElementPreview { + val items = persistentListOf( + InfoListItem(message = "A top item"), + InfoListItem(message = "A middle item"), + InfoListItem(message = "A bottom item"), + ) + InfoListOrganism( + items = items, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/NumberedListOrganism.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/NumberedListOrganism.kt new file mode 100644 index 0000000..df841da --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/NumberedListOrganism.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.organisms + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.atomic.molecules.NumberedListMolecule +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun NumberedListOrganism( + items: ImmutableList, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + itemsIndexed(items) { index, item -> + NumberedListMolecule(index = index + 1, text = item) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/RoomPreviewOrganism.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/RoomPreviewOrganism.kt new file mode 100644 index 0000000..c8b064a --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/RoomPreviewOrganism.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.organisms + +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun RoomPreviewOrganism( + avatar: @Composable () -> Unit, + title: @Composable () -> Unit, + subtitle: @Composable () -> Unit, + modifier: Modifier = Modifier, + description: @Composable (() -> Unit)? = null, + memberCount: @Composable (() -> Unit)? = null, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + avatar() + Spacer(modifier = Modifier.height(16.dp)) + title() + Spacer(modifier = Modifier.height(8.dp)) + subtitle() + if (memberCount != null) { + Spacer(modifier = Modifier.height(8.dp)) + memberCount() + } + if (description != null) { + Spacer(modifier = Modifier.height(16.dp)) + description() + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/FlowStepPage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/FlowStepPage.kt new file mode 100644 index 0000000..b62f634 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/FlowStepPage.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.pages + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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 +import io.element.android.libraries.designsystem.theme.components.TopAppBar + +/** + * A Page with: + * - a top bar as TobAppBar with optional back button (displayed if [onBackClick] is not null) + * - a header, as IconTitleSubtitleMolecule + * - a content. + * - a footer, as ButtonColumnMolecule + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FlowStepPage( + iconStyle: BigIcon.Style, + title: String, + modifier: Modifier = Modifier, + isScrollable: Boolean = false, + onBackClick: (() -> Unit)? = null, + subTitle: String? = null, + buttons: @Composable ColumnScope.() -> Unit = {}, + content: @Composable () -> Unit = {}, +) { + BackHandler(enabled = onBackClick != null) { + onBackClick?.invoke() + } + HeaderFooterPage( + modifier = modifier, + isScrollable = isScrollable, + topBar = { + TopAppBar( + navigationIcon = { + if (onBackClick != null) { + BackButton(onClick = onBackClick) + } + }, + title = {}, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) + ) + }, + header = { + IconTitleSubtitleMolecule( + modifier = Modifier.padding(bottom = 16.dp), + title = title, + subTitle = subTitle, + iconStyle = iconStyle, + ) + }, + content = content, + footer = { + ButtonColumnMolecule( + modifier = Modifier.padding(bottom = 20.dp) + ) { + buttons() + } + } + ) +} + +@PreviewsDayNight +@Composable +internal fun FlowStepPagePreview() = ElementPreview { + FlowStepPage( + onBackClick = {}, + title = "Title", + subTitle = "Subtitle", + iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()), + buttons = { + TextButton(text = "A button", onClick = { }) + Button(text = "Continue", onClick = { }) + } + ) { + Box( + Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Content", + style = ElementTheme.typography.fontHeadingXlBold + ) + } + } +} 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 0000000..f09dc89 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/HeaderFooterPage.kt @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +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.verticalScroll +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.Color +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text + +/** + * @param modifier Classical modifier. + * @param contentPadding padding values to apply to the content. + * @param containerColor color of the container. Set to [Color.Transparent] if you provide a background in the [modifier]. + * @param isScrollable if the whole content should be scrollable. + * @param background optional background component. + * @param topBar optional topBar. + * @param header optional header. + * @param footer optional footer. + * @param content main content. + */ +@Suppress("NAME_SHADOWING") +@Composable +fun HeaderFooterPage( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(20.dp), + containerColor: Color = ElementTheme.colors.bgCanvasDefault, + isScrollable: Boolean = false, + background: @Composable () -> Unit = {}, + topBar: @Composable () -> Unit = {}, + header: @Composable () -> Unit = {}, + footer: @Composable () -> Unit = {}, + content: @Composable () -> Unit = {}, +) { + Scaffold( + modifier = modifier, + topBar = topBar, + containerColor = containerColor, + ) { insetsPadding -> + val layoutDirection = LocalLayoutDirection.current + val contentInsetsPadding = remember(insetsPadding, layoutDirection) { + PaddingValues( + start = insetsPadding.calculateStartPadding(layoutDirection), + top = insetsPadding.calculateTopPadding(), + end = insetsPadding.calculateEndPadding(layoutDirection), + ) + } + val footerInsetsPadding = remember(insetsPadding, layoutDirection) { + PaddingValues( + start = insetsPadding.calculateStartPadding(layoutDirection), + end = insetsPadding.calculateEndPadding(layoutDirection), + bottom = insetsPadding.calculateBottomPadding(), + ) + } + Box { + background() + + // Render in a Column + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues = contentPadding) + .consumeWindowInsets(insetsPadding) + .imePadding(), + ) { + // Content + Column( + modifier = Modifier + .fillMaxWidth() + .run { + if (isScrollable) { + verticalScroll(rememberScrollState()) + // Make sure the scrollable content takes the full available height + .height(IntrinsicSize.Max) + } else { + Modifier + } + } + // Apply insets here so if the content is scrollable it can get below the top app bar if needed + .padding(contentInsetsPadding) + .weight(1f, fill = true), + ) { + // Header + header() + Box { + content() + } + } + + // Footer + Box( + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, top = 16.dp) + .fillMaxWidth() + .padding(footerInsetsPadding) + ) { + footer() + } + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun HeaderFooterPagePreview() = ElementPreview { + HeaderFooterPage( + content = { + Box( + modifier = Modifier.fillMaxWidth(), + 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 + ) + } + } + ) +} + +@PreviewsDayNight +@Composable +internal fun HeaderFooterPageScrollablePreview() = ElementPreview { + HeaderFooterPage( + content = { + Box( + modifier = Modifier.fillMaxWidth(), + 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 + ) + } + }, + isScrollable = true, + ) +} 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 0000000..db0bb69 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/OnBoardingPage.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.R +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text + +/** + * 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 renderBackground whether to render the background image or not. + * @param contentAlignment horizontal alignment of the contents. + * @param footer optional footer. + * @param content main content. + */ +@Composable +fun OnBoardingPage( + modifier: Modifier = Modifier, + renderBackground: Boolean = true, + contentAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + footer: @Composable () -> Unit = {}, + content: @Composable () -> Unit = {}, +) { + Box( + modifier = modifier + .fillMaxSize() + ) { + // BG + if (renderBackground) { + Image( + modifier = Modifier + .fillMaxSize(), + painter = painterResource(id = R.drawable.onboarding_bg), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + } + Column( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + .padding(all = 20.dp), + ) { + // Content + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + horizontalAlignment = contentAlignment, + ) { + content() + } + // Footer + Box(modifier = Modifier.padding(horizontal = 16.dp)) { + footer() + } + } + } +} + +@PreviewsDayNight +@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/atomic/pages/SunsetPage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/SunsetPage.kt new file mode 100644 index 0000000..c2ee980 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/SunsetPage.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.atomic.pages + +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.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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.compound.annotations.CoreColorToken +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.internal.DarkColorTokens +import io.element.android.compound.tokens.generated.internal.LightColorTokens +import io.element.android.libraries.designsystem.R +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.withColoredPeriod +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun SunsetPage( + isLoading: Boolean, + title: String, + subtitle: String, + modifier: Modifier = Modifier, + overallContent: @Composable () -> Unit, +) { + ElementTheme( + // Always use the opposite value of the current theme + darkTheme = ElementTheme.isLightTheme, + applySystemBarsUpdate = false, + ) { + Box( + modifier = modifier.fillMaxSize() + ) { + SunsetBackground() + Box( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = BiasAbsoluteAlignment( + horizontalBias = 0f, + verticalBias = -0.05f + ) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = ElementTheme.colors.iconPrimary + ) + } else { + Spacer(modifier = Modifier.height(24.dp)) + } + Spacer(modifier = Modifier.height(18.dp)) + Text( + text = withColoredPeriod(title), + style = ElementTheme.typography.fontHeadingXlBold, + textAlign = TextAlign.Center, + color = ElementTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + modifier = Modifier.widthIn(max = 360.dp), + text = subtitle, + style = ElementTheme.typography.fontBodyLgRegular, + textAlign = TextAlign.Center, + color = ElementTheme.colors.textPrimary, + ) + } + } + overallContent() + } + } + } +} + +@OptIn(CoreColorToken::class) +@Composable +private fun SunsetBackground() { + Column(modifier = Modifier.fillMaxSize()) { + // The top background colors are the opposite of the current theme ones + val topBackgroundColor = if (ElementTheme.isLightTheme) { + DarkColorTokens.colorThemeBg + } else { + LightColorTokens.colorThemeBg + } + // The bottom background colors follow the current theme + val bottomBackgroundColor = if (ElementTheme.isLightTheme) { + LightColorTokens.colorThemeBg + } else { + // The dark background color doesn't 100% match the image, so we use a custom color + Color(0xFF121418) + } + Box( + modifier = Modifier + .fillMaxWidth() + .weight(0.3f) + .background(topBackgroundColor) + ) + Image( + modifier = Modifier.fillMaxWidth(), + painter = painterResource(id = R.drawable.bg_migration), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(0.7f) + .background(bottomBackgroundColor) + ) + } +} + +@PreviewsDayNight +@Composable +internal fun SunsetPagePreview() = ElementPreview { + SunsetPage( + isLoading = true, + title = "Title with a green period.", + subtitle = "Subtitle", + overallContent = {} + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/background/LightGradientBackground.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/background/LightGradientBackground.kt new file mode 100644 index 0000000..776c52f --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/background/LightGradientBackground.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.background + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RadialGradientShader +import androidx.compose.ui.graphics.ShaderBrush +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +/** + * Light gradient background for Join room screens. + */ +@Composable +fun LightGradientBackground( + modifier: Modifier = Modifier, + backgroundColor: Color = ElementTheme.colors.bgCanvasDefault, + firstColor: Color = Color(0x1E0DBD8B), + secondColor: Color = Color(0x001273EB), + ratio: Float = 642 / 775f, +) { + Canvas( + modifier = modifier.fillMaxSize() + ) { + val biggerDimension = size.width * 1.98f + val gradientShaderBrush = ShaderBrush( + RadialGradientShader( + colors = listOf(firstColor, secondColor), + center = size.center.copy(x = size.width * ratio, y = size.height * ratio), + radius = biggerDimension / 2f, + colorStops = listOf(0f, 0.95f) + ) + ) + drawRect(backgroundColor, size = size) + drawRect(brush = gradientShaderBrush, size = size) + } +} + +@PreviewsDayNight +@Composable +internal fun LightGradientBackgroundPreview() = ElementPreview { + LightGradientBackground() +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/background/OnboardingBackground.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/background/OnboardingBackground.kt new file mode 100644 index 0000000..2a3b790 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/background/OnboardingBackground.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.background + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +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.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.LinearGradientShader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.utils.drawWithLayer + +/** + * Gradient background for FTUE (onboarding) screens. + */ +@Suppress("ModifierMissing") +@Composable +fun OnboardingBackground() { + Box( + modifier = Modifier + .fillMaxSize() + .background(ElementTheme.colors.bgCanvasDefault) + ) { + val isLightTheme = ElementTheme.isLightTheme + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(220.dp) + .align(Alignment.BottomCenter) + ) { + val gradientBrush = ShaderBrush( + LinearGradientShader( + from = Offset(0f, size.height / 2f), + to = Offset(size.width, size.height / 2f), + colors = listOf( + Color(0xFF0DBDA8), + if (isLightTheme) Color(0xC90D5CBD) else Color(0xFF0D5CBD), + ) + ) + ) + val eraseBrush = ShaderBrush( + LinearGradientShader( + from = Offset(size.width / 2f, 0f), + to = Offset(size.width / 2f, size.height * 2f), + colors = listOf( + Color(0xFF000000), + Color(0x00000000), + ) + ) + ) + drawWithLayer { + drawRect(brush = gradientBrush, size = size) + drawRect(brush = gradientBrush, size = size, blendMode = BlendMode.Overlay) + drawRect(brush = eraseBrush, size = size, blendMode = BlendMode.DstOut) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun OnboardingBackgroundPreview() { + ElementPreview { + OnboardingBackground() + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/colors/AvatarColorsProvider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/colors/AvatarColorsProvider.kt new file mode 100644 index 0000000..d12bfe3 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/colors/AvatarColorsProvider.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.colors + +import androidx.compose.runtime.Composable +import io.element.android.compound.theme.AvatarColors +import io.element.android.compound.theme.avatarColors + +object AvatarColorsProvider { + @Composable + fun provide(id: String): AvatarColors { + return avatarColors().let { colors -> + colors[id.toHash(colors.size)] + } + } +} + +internal fun String.toHash(maxSize: Int): Int { + return toList().sumOf { it.code } % maxSize +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/colors/Gradient.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/colors/Gradient.kt new file mode 100644 index 0000000..1faed13 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/colors/Gradient.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.colors + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color +import io.element.android.compound.theme.ElementTheme + +@Composable +@ReadOnlyComposable +fun gradientActionColors(): List = listOf( + ElementTheme.colors.gradientActionStop1, + ElementTheme.colors.gradientActionStop2, + ElementTheme.colors.gradientActionStop3, + ElementTheme.colors.gradientActionStop4, +) + +@Composable +@ReadOnlyComposable +fun gradientSubtleColors(): List = listOf( + ElementTheme.colors.gradientSubtleStop1, + ElementTheme.colors.gradientSubtleStop2, + ElementTheme.colors.gradientSubtleStop3, + ElementTheme.colors.gradientSubtleStop4, + ElementTheme.colors.gradientSubtleStop5, + ElementTheme.colors.gradientSubtleStop6, +) + +@Composable +@ReadOnlyComposable +fun gradientInfoColors(): List = listOf( + ElementTheme.colors.gradientInfoStop1, + ElementTheme.colors.gradientInfoStop2, + ElementTheme.colors.gradientInfoStop3, + ElementTheme.colors.gradientInfoStop4, + ElementTheme.colors.gradientInfoStop5, + ElementTheme.colors.gradientInfoStop6, +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Announcement.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Announcement.kt new file mode 100644 index 0000000..dcd3f8f --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Announcement.kt @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement.spacedBy +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.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ButtonSize +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.ui.strings.CommonStrings + +/** + * Announcement component following design system https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2002-2154. + */ +@Composable +fun Announcement( + title: String, + description: String?, + type: AnnouncementType, + modifier: Modifier = Modifier, +) { + when (type) { + is AnnouncementType.Informative -> InformativeAnnouncement( + title = title, + description = description, + isError = type.isCritical, + modifier = modifier, + ) + is AnnouncementType.Actionable -> ActionableAnnouncement( + title = title, + description = description, + actionText = type.actionText, + onActionClick = type.onActionClick, + onDismissClick = type.onDismissClick, + modifier = modifier, + ) + } +} + +@Immutable +sealed interface AnnouncementType { + data class Informative(val isCritical: Boolean = false) : AnnouncementType + data class Actionable( + val actionText: String, + val onActionClick: () -> Unit, + val onDismissClick: (() -> Unit)?, + ) : AnnouncementType +} + +@Composable +private fun ActionableAnnouncement( + title: String, + description: String?, + actionText: String, + onActionClick: () -> Unit, + onDismissClick: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + AnnouncementSurface(modifier) { + Column { + TitleAndDescription( + title = title, + description = description, + trailingContent = onDismissClick?.let { + { + Icon( + modifier = Modifier.clickable(onClick = onDismissClick), + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_close) + ) + } + } + ) + Spacer(Modifier.height(16.dp)) + Button( + text = actionText, + size = ButtonSize.Medium, + onClick = onActionClick, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun InformativeAnnouncement( + title: String, + description: String?, + isError: Boolean, + modifier: Modifier = Modifier, +) { + AnnouncementSurface(modifier = modifier) { + Row { + Icon( + imageVector = if (isError) CompoundIcons.ErrorSolid() else CompoundIcons.Info(), + tint = if (isError) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconPrimary, + contentDescription = null, + ) + Spacer(Modifier.width(12.dp)) + TitleAndDescription( + title = title, + description = description, + titleColor = if (isError) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textPrimary, + ) + } + } +} + +@Composable +private fun TitleAndDescription( + title: String, + description: String?, + modifier: Modifier = Modifier, + titleColor: Color = ElementTheme.colors.textPrimary, + descriptionColor: Color = ElementTheme.colors.textSecondary, + trailingContent: (@Composable () -> Unit)? = null, +) { + Column(modifier = modifier) { + Row { + Text( + text = title, + style = ElementTheme.typography.fontBodyLgMedium, + color = titleColor, + modifier = Modifier.weight(1f), + ) + if (trailingContent != null) { + Spacer(Modifier.width(12.dp)) + trailingContent() + } + } + if (description != null) { + Spacer(Modifier.height(4.dp)) + Text( + text = description, + style = ElementTheme.typography.fontBodyMdRegular, + color = descriptionColor, + ) + } + } +} + +@Composable +private fun AnnouncementSurface( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(size = 12.dp), + color = ElementTheme.colors.bgSubtleSecondary + ) { + Box(modifier = Modifier.padding(16.dp)) { + content() + } + } +} + +@PreviewsDayNight +@Composable +internal fun AnnouncementPreview() = ElementPreview { + Column( + verticalArrangement = spacedBy(16.dp), + modifier = Modifier.padding(16.dp) + ) { + Announcement( + title = "Headline", + description = "Text description goes here.", + type = AnnouncementType.Informative(isCritical = false), + ) + Announcement( + title = "Headline", + description = "Text description goes here.", + type = AnnouncementType.Informative(isCritical = true), + ) + Announcement( + title = "Headline", + description = "Text description goes here.", + type = AnnouncementType.Actionable( + actionText = "Label", + onActionClick = {}, + onDismissClick = {}, + ), + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Badge.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Badge.kt new file mode 100644 index 0000000..315e75e --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Badge.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +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.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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 + +@Suppress("ModifierMissing") +@Composable +fun Badge( + text: String, + icon: ImageVector, + backgroundColor: Color, + textColor: Color, + iconColor: Color, + shape: Shape = RoundedCornerShape(50), + borderStroke: BorderStroke? = null, + tintIcon: Boolean = true, +) { + Surface( + color = backgroundColor, + contentColor = textColor, + border = borderStroke, + shape = shape, + ) { + Row( + modifier = Modifier.padding(start = 8.dp, end = 12.dp, top = 4.5.dp, bottom = 4.5.dp), + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = icon, + contentDescription = null, + tint = if (tintIcon) iconColor else LocalContentColor.current, + ) + Text( + text = text, + style = ElementTheme.typography.fontBodySmRegular, + color = textColor, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun BadgePreview() { + ElementPreview { + Badge( + text = "Trusted", + icon = CompoundIcons.Verified(), + backgroundColor = ElementTheme.colors.bgBadgeAccent, + textColor = ElementTheme.colors.textBadgeAccent, + iconColor = ElementTheme.colors.textBadgeAccent, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BigIcon.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BigIcon.kt new file mode 100644 index 0000000..40e84b3 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BigIcon.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CatchingPokemon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +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.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * Compound component that display a big icon centered in a rounded square. + * Figma: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=1960-553&node-type=frame&m=dev + */ +object BigIcon { + /** + * The style of the [BigIcon]. + */ + @Immutable + sealed interface Style { + /** + * The default style. + * + * @param vectorIcon the [ImageVector] to display + * @param contentDescription the content description of the icon, if any. It defaults to `null` + * @param useCriticalTint whether the icon and background should be rendered using critical tint + * @param usePrimaryTint whether the icon should be rendered using primary tint + */ + data class Default( + val vectorIcon: ImageVector, + val contentDescription: String? = null, + val useCriticalTint: Boolean = false, + val usePrimaryTint: Boolean = false, + ) : Style + + /** + * An alert style with a transparent background. + */ + data object Alert : Style + + /** + * An alert style with a tinted background. + */ + data object AlertSolid : Style + + /** + * A success style with a transparent background. + */ + data object Success : Style + + /** + * A success style with a tinted background. + */ + data object SuccessSolid : Style + + /** + * A loading style with the default background color. + */ + data object Loading : Style + } + + /** + * Display a [BigIcon]. + * + * @param style the style of the icon + * @param modifier the modifier to apply to this layout + */ + @Composable + operator fun invoke( + style: Style, + modifier: Modifier = Modifier, + ) { + val backgroundColor = when (style) { + is Style.Default -> if (style.useCriticalTint) { + ElementTheme.colors.bgCriticalSubtle + } else { + ElementTheme.colors.bgSubtleSecondary + } + Style.Alert, + Style.Success -> Color.Transparent + Style.AlertSolid -> ElementTheme.colors.bgCriticalSubtle + Style.SuccessSolid -> ElementTheme.colors.bgSuccessSubtle + Style.Loading -> ElementTheme.colors.bgSubtleSecondary + } + Box( + modifier = modifier + .size(64.dp) + .clip(RoundedCornerShape(14.dp)) + .background(backgroundColor), + contentAlignment = Alignment.Center, + ) { + if (style is Style.Loading) { + CircularProgressIndicator( + modifier = Modifier.size(27.dp), + color = ElementTheme.colors.iconSecondary, + trackColor = Color.Transparent, + strokeWidth = 3.dp, + ) + } else { + val icon = when (style) { + is Style.Default -> style.vectorIcon + Style.Alert, + Style.AlertSolid -> CompoundIcons.ErrorSolid() + Style.Success, + Style.SuccessSolid -> CompoundIcons.CheckCircleSolid() + Style.Loading -> error("This should never be reached") + } + val contentDescription = when (style) { + is Style.Default -> style.contentDescription + Style.Alert, + Style.AlertSolid -> stringResource(CommonStrings.common_error) + Style.Success, + Style.SuccessSolid -> stringResource(CommonStrings.common_success) + Style.Loading -> error("This should never be reached") + } + val iconTint = when (style) { + is Style.Default -> if (style.useCriticalTint) { + ElementTheme.colors.iconCriticalPrimary + } else if (style.usePrimaryTint) { + ElementTheme.colors.iconPrimary + } else { + ElementTheme.colors.iconSecondary + } + Style.Alert, + Style.AlertSolid -> ElementTheme.colors.iconCriticalPrimary + Style.Success, + Style.SuccessSolid -> ElementTheme.colors.iconSuccessPrimary + Style.Loading -> error("This should never be reached") + } + + Icon( + modifier = Modifier.size(32.dp), + tint = iconTint, + imageVector = icon, + contentDescription = contentDescription + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun BigIconPreview() = ElementPreview { + LazyVerticalGrid( + modifier = Modifier + .fillMaxSize() + .padding(10.dp), + columns = GridCells.Adaptive(minSize = 64.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(BigIconStyleProvider().values.toList()) { style -> + Box( + contentAlignment = Alignment.Center + ) { + BigIcon(style = style) + } + } + } +} + +internal class BigIconStyleProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + BigIcon.Style.Default(Icons.Filled.CatchingPokemon), + BigIcon.Style.Alert, + BigIcon.Style.AlertSolid, + BigIcon.Style.Default(Icons.Filled.CatchingPokemon, useCriticalTint = true), + BigIcon.Style.Success, + BigIcon.Style.SuccessSolid, + BigIcon.Style.Loading, + ) +} 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 0000000..a70aaff --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components + +import android.text.SpannableString +import android.text.style.URLSpan +import android.text.util.Linkify +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.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.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.text.util.LinkifyCompat +import io.element.android.compound.theme.LinkColor +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 +import timber.log.Timber + +const val LINK_TAG = "URL" + +@Composable +fun ClickableLinkText( + text: String, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + linkify: Boolean = true, + linkAnnotationTag: String = LINK_TAG, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = {}, + style: TextStyle = LocalTextStyle.current, + color: Color = Color.Unspecified, + inlineContent: ImmutableMap = persistentMapOf(), +) { + ClickableLinkText( + annotatedString = AnnotatedString(text), + interactionSource = interactionSource, + modifier = modifier, + linkify = linkify, + linkAnnotationTag = linkAnnotationTag, + onClick = onClick, + onLongClick = onLongClick, + style = style, + color = color, + inlineContent = inlineContent, + ) +} + +@Composable +fun ClickableLinkText( + annotatedString: AnnotatedString, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + linkify: Boolean = true, + linkAnnotationTag: String = LINK_TAG, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = {}, + style: TextStyle = LocalTextStyle.current, + color: Color = Color.Unspecified, + inlineContent: ImmutableMap = persistentMapOf(), +) { + @Suppress("NAME_SHADOWING") + val annotatedString = remember(annotatedString) { + if (linkify) { + annotatedString.linkify(SpanStyle(color = LinkColor)) + } else { + annotatedString + } + } + val uriHandler = LocalUriHandler.current + val layoutResult = remember { mutableStateOf(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 = annotatedString.getLinkAnnotations(position, position) + .map { AnnotatedString.Range(it.item, it.start, it.end, linkAnnotationTag) } + val linkStringAnnotations = linkUrlAnnotations + + annotatedString.getStringAnnotations(linkAnnotationTag, position, position) + if (linkStringAnnotations.isEmpty()) { + onClick() + } else { + when (val annotation = linkStringAnnotations.first().item) { + is LinkAnnotation.Url -> uriHandler.openUri(annotation.url) + is String -> uriHandler.openUri(annotation) + else -> Timber.e("Unknown link annotation: $annotation") + } + } + } + } + } + Text( + text = annotatedString, + modifier = modifier.then(pressIndicator), + style = style, + color = color, + onTextLayout = { + layoutResult.value = it + }, + inlineContent = inlineContent, + ) +} + +fun AnnotatedString.linkify(linkStyle: SpanStyle): AnnotatedString { + val original = this + val spannable = SpannableString.valueOf(this.text) + LinkifyCompat.addLinks(spannable, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES) + + val spans = spannable.getSpans(0, spannable.length, URLSpan::class.java) + return buildAnnotatedString { + append(original) + for (span in spans) { + val start = spannable.getSpanStart(span) + val end = spannable.getSpanEnd(span) + if (original.getLinkAnnotations(start, end).isEmpty() && original.getStringAnnotations("URL", start, end).isEmpty()) { + // Prevent linkifying domains in user or room handles (@user:domain.com, #room:domain.com) + if (start > 0 && !spannable[start - 1].isWhitespace()) continue + + addStyle( + start = start, + end = end, + style = linkStyle, + ) + addStringAnnotation( + tag = LINK_TAG, + annotation = span.url, + start = start, + end = end + ) + } + } + } +} + +@Preview(group = PreviewGroup.Text) +@Composable +internal fun ClickableLinkTextPreview() = ElementThemedPreview { + ClickableLinkText( + annotatedString = AnnotatedString("Hello", ParagraphStyle()), + linkAnnotationTag = "", + onClick = {}, + onLongClick = {}, + interactionSource = remember { 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 0000000..14fd284 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/EqualWidthColumn.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/ExpandableBottomSheetLayout.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayout.kt new file mode 100644 index 0000000..433c381 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayout.kt @@ -0,0 +1,293 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components + +import android.annotation.SuppressLint +import android.content.Context +import android.view.MotionEvent +import android.view.ViewGroup +import android.widget.EditText +import androidx.appcompat.app.ActionBar.LayoutParams +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import kotlinx.coroutines.launch +import timber.log.Timber +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +@Composable +fun ExpandableBottomSheetLayout( + sheetDragHandle: @Composable BoxScope.(toggleAction: () -> Unit) -> Unit, + bottomSheetContent: @Composable ColumnScope.() -> Unit, + state: ExpandableBottomSheetLayoutState, + maxBottomSheetContentHeight: Dp, + isSwipeGestureEnabled: Boolean, + modifier: Modifier = Modifier, + sheetShape: Shape = RectangleShape, + backgroundColor: Color = Color.Transparent, + content: @Composable () -> Unit, +) { + var minBottomContentHeightPx by remember { mutableIntStateOf(0) } + var currentBottomContentHeightPx by remember { mutableIntStateOf(minBottomContentHeightPx) } + val maxBottomContentHeightPx = with(LocalDensity.current) { maxBottomSheetContentHeight.roundToPx() } + var calculatedMaxBottomContentHeightPx by remember(maxBottomContentHeightPx) { mutableIntStateOf(maxBottomContentHeightPx) } + val animatable = remember { Animatable(0f) } + + fun calculatePercentage(currentPos: Int, minPos: Int, maxPos: Int): Float { + val currentProgress = currentPos - minPos + if (currentProgress < 0) { + Timber.e("Invalid current progress: $currentProgress, minPos: $minPos, maxPos: $maxPos") + return 0f + } + val total = (maxPos - minPos).toFloat() + if (total <= 0) { + Timber.e("Invalid total space: $total, minPos: $minPos, maxPos: $maxPos") + return 0f + } + return currentProgress / total + } + + LaunchedEffect(animatable.value) { + if (animatable.isRunning && animatable.value != animatable.targetValue) { + currentBottomContentHeightPx = animatable.value.roundToInt() + state.internalDraggingPercentage = calculatePercentage( + currentPos = currentBottomContentHeightPx, + minPos = minBottomContentHeightPx, + maxPos = calculatedMaxBottomContentHeightPx, + ) + } + } + + val coroutineScope = rememberCoroutineScope() + + val composables = @Composable { + content() + Column( + modifier = Modifier + .clip(sheetShape) + .background(backgroundColor) + .run { + if (isSwipeGestureEnabled) { + pointerInput(maxBottomSheetContentHeight) { + detectVerticalDragGestures( + onVerticalDrag = { _, dragAmount -> + val calculatedHeight = max(minBottomContentHeightPx, currentBottomContentHeightPx - dragAmount.roundToInt()) + val newHeight = min(calculatedMaxBottomContentHeightPx, calculatedHeight) + state.internalPosition = when (newHeight) { + calculatedMaxBottomContentHeightPx -> ExpandableBottomSheetLayoutState.Position.EXPANDED + minBottomContentHeightPx -> ExpandableBottomSheetLayoutState.Position.COLLAPSED + else -> ExpandableBottomSheetLayoutState.Position.DRAGGING + } + state.internalDraggingPercentage = calculatePercentage( + currentPos = newHeight, + minPos = minBottomContentHeightPx, + maxPos = calculatedMaxBottomContentHeightPx, + ) + currentBottomContentHeightPx = newHeight + }, + onDragEnd = { + coroutineScope.launch { + val middle = (calculatedMaxBottomContentHeightPx + minBottomContentHeightPx) / 2 + animatable.snapTo(currentBottomContentHeightPx.toFloat()) + + val destination = if (currentBottomContentHeightPx > middle) { + state.internalPosition = ExpandableBottomSheetLayoutState.Position.EXPANDED + calculatedMaxBottomContentHeightPx + } else { + state.internalPosition = ExpandableBottomSheetLayoutState.Position.COLLAPSED + minBottomContentHeightPx + }.toFloat() + + animatable.animateTo(destination) + } + } + ) + } + } else { + this + } + } + ) { + Box(Modifier.fillMaxWidth()) { + sheetDragHandle { + coroutineScope.launch { + val destination = if (state.position == ExpandableBottomSheetLayoutState.Position.EXPANDED) { + state.internalPosition = ExpandableBottomSheetLayoutState.Position.COLLAPSED + minBottomContentHeightPx.toFloat() + } else { + state.internalPosition = ExpandableBottomSheetLayoutState.Position.EXPANDED + calculatedMaxBottomContentHeightPx.toFloat() + } + animatable.snapTo(currentBottomContentHeightPx.toFloat()) + animatable.animateTo(destination) + } + } + } + bottomSheetContent() + } + } + Layout( + content = composables, + modifier = modifier, + measurePolicy = { measurables, constraints -> + calculatedMaxBottomContentHeightPx = min(constraints.maxHeight, maxBottomContentHeightPx) + + val contentMeasurables = measurables[0] + val bottomContentMeasurables = measurables[1] + + val minIntrinsicHeight = bottomContentMeasurables.minIntrinsicHeight(constraints.maxWidth) + val lastMinBottomContentHeightPx = minBottomContentHeightPx + minBottomContentHeightPx = min(minIntrinsicHeight, calculatedMaxBottomContentHeightPx) + + val isExpanded = state.position == ExpandableBottomSheetLayoutState.Position.EXPANDED + if (lastMinBottomContentHeightPx != minBottomContentHeightPx && !isExpanded) { + currentBottomContentHeightPx = minBottomContentHeightPx + } + + val measuredBottomContent = bottomContentMeasurables.measure( + Constraints.fixed( + constraints.maxWidth, + max(minBottomContentHeightPx, currentBottomContentHeightPx) + ) + ) + + var remainingHeight = constraints.maxHeight - currentBottomContentHeightPx + if (remainingHeight < 0) { + Timber.e("Remaining height is negative: $remainingHeight, resetting to 0") + remainingHeight = 0 + } + + val contentPlaceable = contentMeasurables.measure( + Constraints.fixed(constraints.maxWidth, remainingHeight) + ) + + layout(constraints.maxWidth, constraints.maxHeight) { + contentPlaceable.place(0, 0) + measuredBottomContent.place(IntOffset(0, constraints.maxHeight - currentBottomContentHeightPx), zIndex = 10f) + } + } + ) +} + +@Preview(showBackground = true) +@Composable +@Suppress("UnusedPrivateMember") +internal fun ExpandableBottomSheetLayoutPreview() { + ExpandableBottomSheetLayout( + sheetDragHandle = { + Box( + modifier = + Modifier + .padding(vertical = 6.dp) + .clip(RoundedCornerShape(6.dp)) + .align(Alignment.Center) + .size(100.dp, 8.dp) + .background(Color.Gray) + ) + }, + content = { + Box(Modifier.fillMaxWidth()) { + Text("This is the main content", modifier = Modifier.padding(16.dp).align(Alignment.Center)) + } + }, + bottomSheetContent = { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = true) + .padding(horizontal = 10.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Color.Blue) + ) { + AndroidView( + modifier = Modifier + .fillMaxWidth() + .background(Color.LightGray), + factory = { context -> + PreviewEditText(context).apply { + val initialText = "1111\n2222\n3333\n4444\n5555\n6666" + setText(initialText) + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + } + } + ) + } + Text("A footer", modifier = Modifier.padding(vertical = 6.dp, horizontal = 16.dp)) + }, + maxBottomSheetContentHeight = 1800.dp, + isSwipeGestureEnabled = true, + backgroundColor = Color.White, + state = rememberExpandableBottomSheetLayoutState(), + sheetShape = RoundedCornerShape(20.dp, 20.dp, 0.dp, 0.dp), + modifier = Modifier + .windowInsetsPadding(WindowInsets.statusBars) + .windowInsetsPadding(WindowInsets.ime) + .fillMaxSize() + .background(Color.Red.copy(alpha = 0.2f)), + ) +} + +// This is just for preview purposes +@SuppressLint("AppCompatCustomView") +private class PreviewEditText(context: Context) : EditText(context) { + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + parent?.requestDisallowInterceptTouchEvent(true) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent?): Boolean { + super.onTouchEvent(event) + parent?.requestDisallowInterceptTouchEvent(true) + return true + } + + override fun dispatchTouchEvent(event: MotionEvent?): Boolean { + return super.dispatchTouchEvent(event) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayoutState.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayoutState.kt new file mode 100644 index 0000000..1b17ec5 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayoutState.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +/** + * Creates and remembers an [ExpandableBottomSheetLayoutState]. + */ +@Composable +fun rememberExpandableBottomSheetLayoutState(): ExpandableBottomSheetLayoutState { + return remember { ExpandableBottomSheetLayoutState() } +} + +/** + * State for the [ExpandableBottomSheetLayout]. + * + * This state holds the current position of the bottom sheet layout and the percentage of the layout that is being dragged. + */ +@Stable +class ExpandableBottomSheetLayoutState { + internal var internalPosition: Position by mutableStateOf(Position.COLLAPSED) + internal var internalDraggingPercentage: Float by mutableFloatStateOf( + if (internalPosition == Position.EXPANDED) 1f else 0f + ) + + /** + * The current position of the bottom sheet layout. + */ + val position get() = internalPosition + + /** + * The percentage of the bottom sheet layout that is currently being dragged. + * This value ranges from `0f` for [Position.COLLAPSED] to `1f` for [Position.EXPANDED]. + */ + val draggingPercentage = internalDraggingPercentage + + /** + * The position of the bottom sheet layout. + */ + enum class Position { + /** The bottom sheet is collapsed to its minimum visible height. */ + COLLAPSED, + + /** The bottom sheet is being dragged by user input. */ + DRAGGING, + + /** The bottom sheet is expanded to its maximum visible height. */ + EXPANDED + } +} 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 0000000..5e23306 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledCheckbox.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components + +import androidx.compose.foundation.layout.Row +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 io.element.android.compound.theme.ElementTheme +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, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + ) + Text( + text = text, + color = ElementTheme.colors.textPrimary, + ) + } +} + +@Preview(group = PreviewGroup.Toggles) +@Composable +internal fun LabelledCheckboxPreview() = ElementThemedPreview { + LabelledCheckbox( + checked = true, + onCheckedChange = {}, + text = "Some text", + ) +} 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 0000000..88287ef --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PinIcon.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.R +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon + +@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, + ) + } +} + +@PreviewsDayNight +@Composable +internal 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 0000000..571a0b9 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ProgressDialog.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.DialogPreview +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 + +/** + * A progress dialog, with a spinner, and optional text content. + * + * @param modifier + * @param text Optional text to show under the spinner. + * @param type + * @param properties + * @param showCancelButton + * @param onDismissRequest + * @param content Optional additional content to show under the spinner, and above the cancel button (if shown). If both `text` and `content` are supplied, + * `text` is shown above `content`. + */ +@Composable +fun ProgressDialog( + modifier: Modifier = Modifier, + text: String? = null, + type: ProgressDialogType = ProgressDialogType.Indeterminate, + properties: DialogProperties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), + showCancelButton: Boolean = false, + onDismissRequest: () -> Unit = {}, + content: @Composable () -> Unit = {}, +) { + DisposableEffect(Unit) { + onDispose { + Timber.v("OnDispose progressDialog") + } + } + Dialog( + onDismissRequest = onDismissRequest, + properties = properties, + ) { + ProgressDialogContent( + modifier = modifier, + text = text, + showCancelButton = showCancelButton, + onCancelClick = onDismissRequest, + progressIndicator = { + when (type) { + is ProgressDialogType.Indeterminate -> { + CircularProgressIndicator( + color = ElementTheme.colors.iconPrimary + ) + } + is ProgressDialogType.Determinate -> { + CircularProgressIndicator( + progress = { type.progress }, + color = ElementTheme.colors.iconPrimary + ) + } + } + }, + content, + ) + } +} + +@Immutable +sealed interface ProgressDialogType { + data class Determinate(val progress: Float) : ProgressDialogType + data object Indeterminate : ProgressDialogType +} + +@Composable +private fun ProgressDialogContent( + modifier: Modifier = Modifier, + text: String? = null, + showCancelButton: Boolean = false, + onCancelClick: () -> Unit = {}, + progressIndicator: @Composable () -> Unit = { + CircularProgressIndicator( + color = ElementTheme.colors.iconPrimary + ) + }, + content: @Composable () -> Unit, +) { + 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 = ElementTheme.colors.textPrimary, + ) + } + content() + if (showCancelButton) { + Spacer(modifier = Modifier.height(24.dp)) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.BottomEnd + ) { + TextButton( + text = stringResource(id = CommonStrings.action_cancel), + onClick = onCancelClick, + ) + } + } + } + } +} + +@Preview(group = PreviewGroup.Dialogs) +@Composable +internal fun ProgressDialogContentPreview() = ElementThemedPreview { + DialogPreview { + ProgressDialogContent(text = "test dialog content", showCancelButton = true, content = {}) + } +} + +@PreviewsDayNight +@Composable +internal fun ProgressDialogPreview() = ElementPreview { + ProgressDialog(text = "test dialog content", showCancelButton = true) +} + +@PreviewsDayNight +@Composable +internal fun ProgressDialogWithContentPreview() = ElementPreview { + ProgressDialog(showCancelButton = true) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Heading", + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontHeadingSmMedium, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Subtext", + color = ElementTheme.colors.textSecondary, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun ProgressDialogWithTextAndContentPreview() = ElementPreview { + ProgressDialog(text = "Text Content") { + Text( + text = "blah blah", + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontHeadingSmMedium, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/SimpleModalBottomSheet.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/SimpleModalBottomSheet.kt new file mode 100644 index 0000000..34d1195 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/SimpleModalBottomSheet.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components + +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.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.Text + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SimpleModalBottomSheet( + title: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + modifier = modifier, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + title, + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + ) + Spacer(Modifier.height(8.dp)) + content() + } + } +} + +@PreviewsDayNight +@Composable +internal fun SimpleModalBottomSheetPreview() = ElementPreview { + SimpleModalBottomSheet(title = "A title", onDismiss = {}) { + Text( + text = LoremIpsum(20).values.first(), + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/TopAppBarScrollBehaviorLayout.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/TopAppBarScrollBehaviorLayout.kt new file mode 100644 index 0000000..36e9547 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/TopAppBarScrollBehaviorLayout.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Surface +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.UiComposable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import io.element.android.compound.theme.ElementTheme + +/** + * A layout that measures its content to set the height offset limit of a [TopAppBarScrollBehavior]. + * It places the content according to the current height offset of the scroll behavior. + * + */ +@ExperimentalMaterial3Api +@Composable +fun TopAppBarScrollBehaviorLayout( + scrollBehavior: TopAppBarScrollBehavior, + modifier: Modifier = Modifier, + backgroundColor: Color = ElementTheme.colors.bgCanvasDefault, + contentColor: Color = contentColorFor(backgroundColor), + content: @Composable @UiComposable () -> Unit, +) { + Surface( + modifier = modifier, + color = backgroundColor, + contentColor = contentColor + ) { + Layout( + content = content, + measurePolicy = { measurables, constraints -> + val placeable = measurables.first().measure(constraints) + val contentHeight = placeable.height.toFloat() + scrollBehavior.state.heightOffsetLimit = -contentHeight + val heightOffset = scrollBehavior.state.heightOffset + val layoutHeight = (contentHeight + heightOffset).toInt() + layout(placeable.width, layoutHeight) { + placeable.place(0, heightOffset.toInt()) + } + } + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncActionProvider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncActionProvider.kt new file mode 100644 index 0000000..2f7439e --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncActionProvider.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.async + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction + +open class AsyncActionProvider : PreviewParameterProvider> { + override val values: Sequence> + get() = sequenceOf( + AsyncAction.Uninitialized, + AsyncAction.ConfirmingNoParams, + AsyncAction.Loading, + AsyncAction.Failure(Exception("An error occurred")), + AsyncAction.Success(Unit), + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncActionView.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncActionView.kt new file mode 100644 index 0000000..da1b0fc --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncActionView.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.async + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.architecture.AsyncAction +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.components.dialogs.ErrorDialogDefaults +import io.element.android.libraries.designsystem.components.dialogs.RetryDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +/** + * Render an AsyncAction object. + * - If Success, invoke the callback [onSuccess], only once. + * - If Failure, display a dialog with the error, which can be transformed, using [errorMessage]. When + * closed, [onErrorDismiss] will be invoked. If [onRetry] is not null, a retry button will be displayed. + * - When loading, display a loading dialog using [progressDialog]. Pass empty lambda to disable. + */ +@Suppress("ContentSlotReused") // False positive, the lambdas don't add composable views +@Composable +fun AsyncActionView( + async: AsyncAction, + onSuccess: (T) -> Unit, + onErrorDismiss: () -> Unit, + confirmationDialog: @Composable (AsyncAction.Confirming) -> Unit = { }, + progressDialog: @Composable () -> Unit = { AsyncActionViewDefaults.ProgressDialog() }, + errorTitle: @Composable (Throwable) -> String = { ErrorDialogDefaults.title }, + errorMessage: @Composable (Throwable) -> String = { it.message ?: it.toString() }, + onRetry: (() -> Unit)? = null, +) { + when (async) { + AsyncAction.Uninitialized -> Unit + is AsyncAction.Confirming -> confirmationDialog(async) + is AsyncAction.Loading -> progressDialog() + is AsyncAction.Failure -> { + if (onRetry == null) { + ErrorDialog( + title = errorTitle(async.error), + content = errorMessage(async.error), + onSubmit = onErrorDismiss + ) + } else { + RetryDialog( + title = errorTitle(async.error), + content = errorMessage(async.error), + onDismiss = onErrorDismiss, + onRetry = onRetry, + ) + } + } + is AsyncAction.Success -> { + val latestOnSuccess by rememberUpdatedState(onSuccess) + LaunchedEffect(async) { + latestOnSuccess(async.data) + } + } + } +} + +object AsyncActionViewDefaults { + @Composable + fun ProgressDialog(progressText: String? = null) { + ProgressDialog( + text = progressText, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun AsyncActionViewPreview( + @PreviewParameter(AsyncActionProvider::class) async: AsyncAction, +) = ElementPreview { + AsyncActionView( + async = async, + onSuccess = {}, + onErrorDismiss = {}, + confirmationDialog = { + ConfirmationDialog( + title = "Confirmation", + content = "Are you sure?", + onSubmitClick = {}, + onDismiss = {}, + ) + }, + ) +} 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 0000000..6b303a4 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncFailure.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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( + text = stringResource(id = CommonStrings.action_retry), + onClick = onRetry + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun AsyncFailurePreview() = ElementPreview { + AsyncFailure( + throwable = IllegalStateException("An error occurred"), + onRetry = {} + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncIndicator.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncIndicator.kt new file mode 100644 index 0000000..b488052 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncIndicator.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.async + +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Icon + +/** + * A helper to create [AsyncIndicatorView] with some defaults. + */ +@Stable +object AsyncIndicator { + /** + * A loading async indicator. + * @param text The text to display. + * @param modifier The modifier to apply to the indicator. + */ + @Composable + fun Loading( + text: String, + modifier: Modifier = Modifier, + ) { + AsyncIndicatorView( + modifier = modifier, + text = text, + spacing = 10.dp, + ) { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(12.dp), + color = ElementTheme.colors.textPrimary, + strokeWidth = 1.5.dp, + ) + } + } + + /** + * A failure async indicator. + * @param text The text to display. + * @param modifier The modifier to apply to the indicator. + */ + @Composable + fun Failure( + text: String, + modifier: Modifier = Modifier, + ) { + AsyncIndicatorView( + modifier = modifier, + text = text, + spacing = defaultSpacing + ) { + Icon( + modifier = Modifier.size(18.dp), + imageVector = CompoundIcons.Close(), + contentDescription = null, + ) + } + } + + /** + * A custom async indicator. + * @param text The text to display. + * @param modifier The modifier to apply to the indicator. + * @param spacing The spacing between the leading content and the text. + * @param leadingContent The leading content to display. + */ + @Composable + fun Custom( + text: String, + modifier: Modifier = Modifier, + spacing: Dp = defaultSpacing, + leadingContent: @Composable (() -> Unit)? = null, + ) { + AsyncIndicatorView( + modifier = modifier, + text = text, + spacing = spacing, + leadingContent = leadingContent, + ) + } + + /** + * A short duration to display indicators. + */ + const val DURATION_SHORT = 3000L + + /** + * A long duration to display indicators. + */ + const val DURATION_LONG = 5000L + + private val defaultSpacing = 4.dp +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncIndicatorHost.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncIndicatorHost.kt new file mode 100644 index 0000000..abfbe77 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncIndicatorHost.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.async + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Stable +class AsyncIndicatorState { + private val queue = SnapshotStateList() + val currentItem = mutableStateOf(null) + val currentAnimationState = MutableTransitionState(false) + + /** + * Enqueue a new indicator to be displayed. + * @param durationMs The duration to display the indicator, if `null` (the default value) it will be displayed indefinitely, until the next indicator is + * displayed or the current one is manually cleared. + * @param composable The composable to display. + */ + fun enqueue(durationMs: Long? = null, composable: @Composable () -> Unit) { + queue.add(AsyncIndicatorItem(composable, durationMs)) + if (currentItem.value == null || currentItem.value?.durationMs == null) { + nextState() + } + } + + internal fun nextState() { + if (!currentAnimationState.isIdle) return + + if (currentItem.value != null && currentAnimationState.currentState && currentAnimationState.isIdle) { + // Is visible and not animating, start the exit animation + currentAnimationState.targetState = false + } else if (currentItem.value == null || !currentAnimationState.currentState && currentAnimationState.isIdle) { + // Not visible or present, start the enter animation for the next item + val newItem = queue.removeFirstOrNull() + if (newItem != null) { + currentItem.value = null + currentAnimationState.targetState = true + } + currentItem.value = newItem + } + } + + /** + * Clear the current indicator using its exit animation. + */ + fun clear() { + currentAnimationState.targetState = false + } +} + +/** + * An item to be displayed in the [AsyncIndicatorHost]. + */ +data class AsyncIndicatorItem( + val composable: @Composable () -> Unit, + val durationMs: Long? = null, +) + +/** + * Remember an [AsyncIndicatorState] instance. + */ +@Composable +fun rememberAsyncIndicatorState(): AsyncIndicatorState { + return remember { AsyncIndicatorState() } +} + +/** + * A host for displaying async indicators. + * @param modifier The modifier to apply. + * @param state The [AsyncIndicatorState] which values this component will display. + * @param enterTransition The enter transition to use for the displayed indicators. + * @param exitTransition The exit transition to use for the hiding indicators. + */ +@Composable +fun AsyncIndicatorHost( + modifier: Modifier = Modifier, + state: AsyncIndicatorState = rememberAsyncIndicatorState(), + enterTransition: EnterTransition = fadeIn(spring(stiffness = 500F)) + slideInVertically(), + exitTransition: ExitTransition = fadeOut(spring(stiffness = 500F)) + slideOutVertically(), +) { + val coroutineScope = rememberCoroutineScope() + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.TopCenter, + ) { + if (LocalInspectionMode.current) { + state.currentItem.value?.composable?.invoke() + } else { + state.currentItem.value?.let { item -> + AnimatedVisibility( + visibleState = state.currentAnimationState, + enter = enterTransition, + exit = exitTransition, + ) { + item.composable() + } + + if (state.currentAnimationState.hasEntered() && item.durationMs != null) { + SideEffect { + coroutineScope.launch { + delay(item.durationMs) + state.nextState() + } + } + } else if (state.currentAnimationState.hasExited()) { + SideEffect { + state.nextState() + } + } + } + } + } +} + +internal fun MutableTransitionState.hasEntered() = currentState && isIdle +internal fun MutableTransitionState.hasExited() = !currentState && isIdle diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncIndicatorView.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncIndicatorView.kt new file mode 100644 index 0000000..521ed68 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncIndicatorView.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.async + +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.shape.RoundedCornerShape +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.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +internal fun AsyncIndicatorView( + text: String, + spacing: Dp, + modifier: Modifier = Modifier, + elevation: Dp = 8.dp, + leadingContent: @Composable (() -> Unit)?, +) { + Box( + modifier = modifier + .padding(horizontal = 32.dp) + .padding(elevation) + ) { + Surface( + shape = RoundedCornerShape(24.dp), + shadowElevation = elevation, + ) { + Row( + modifier = Modifier + .background(color = ElementTheme.colors.bgSubtleSecondary) + .padding(horizontal = 24.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing) + ) { + leadingContent?.let { view -> + view() + } + Text( + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyMdMedium + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun AsyncIndicatorLoadingPreview() { + ElementPreview { + AsyncIndicator.Loading(text = "Loading") + } +} + +@PreviewsDayNight +@Composable +internal fun AsyncIndicatorFailurePreview() { + ElementPreview { + AsyncIndicator.Failure(text = "Failed") + } +} 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 0000000..9a2faa4 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncLoading.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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() + } +} + +@PreviewsDayNight +@Composable +internal fun AsyncLoadingPreview() = ElementPreview { + 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 0000000..57dcb60 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.avatar + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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 androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.avatar.internal.RoomAvatar +import io.element.android.libraries.designsystem.components.avatar.internal.SpaceAvatar +import io.element.android.libraries.designsystem.components.avatar.internal.UserAvatar +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.utils.CommonDrawables +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun Avatar( + avatarData: AvatarData, + avatarType: AvatarType, + modifier: Modifier = Modifier, + contentDescription: String? = null, + // If not null, will be used instead of the size from avatarData + forcedAvatarSize: Dp? = null, + // If true, will show initials even if avatarData.url is not null + hideImage: Boolean = false, +) { + when (avatarType) { + is AvatarType.Room -> RoomAvatar( + avatarData = avatarData, + avatarType = avatarType, + modifier = modifier, + hideAvatarImage = hideImage, + forcedAvatarSize = forcedAvatarSize, + contentDescription = contentDescription, + ) + AvatarType.User -> UserAvatar( + avatarData = avatarData, + modifier = modifier, + contentDescription = contentDescription, + forcedAvatarSize = forcedAvatarSize, + hideImage = hideImage, + ) + is AvatarType.Space -> SpaceAvatar( + avatarData = avatarData, + avatarType = avatarType, + modifier = modifier, + hideAvatarImage = hideImage, + forcedAvatarSize = forcedAvatarSize, + contentDescription = contentDescription, + ) + } +} + +@Preview(group = PreviewGroup.Avatars) +@Composable +internal fun AvatarPreview() = ElementThemedPreview( + drawableFallbackForImages = CommonDrawables.sample_background, +) { + Column( + modifier = Modifier.padding(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + listOf( + anAvatarData(size = AvatarSize.UserListItem), + anAvatarData(size = AvatarSize.UserListItem, name = null), + anAvatarData(size = AvatarSize.UserListItem, url = "aUrl"), + ).forEach { avatarData -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Avatar( + avatarData = avatarData, + avatarType = AvatarType.User, + ) + Avatar( + avatarData = avatarData, + avatarType = AvatarType.Room(isTombstoned = false), + ) + Avatar( + avatarData = avatarData, + avatarType = AvatarType.Room( + heroes = persistentListOf( + anAvatarData("@carol:server.org", "Carol", size = AvatarSize.UserListItem), + anAvatarData("@david:server.org", "David", size = AvatarSize.UserListItem), + anAvatarData("@eve:server.org", "Eve", size = AvatarSize.UserListItem), + anAvatarData("@justin:server.org", "Justin", size = AvatarSize.UserListItem), + ) + ) + ) + Avatar( + avatarData = avatarData, + avatarType = AvatarType.Room(isTombstoned = true), + ) + Avatar( + avatarData = avatarData, + avatarType = AvatarType.Space(isTombstoned = false), + ) + Avatar( + avatarData = avatarData, + avatarType = AvatarType.Space(isTombstoned = true), + ) + } + } + } +} 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 0000000..ac7e426 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.avatar + +import io.element.android.libraries.core.data.tryOrNull +import java.text.BreakIterator + +data class AvatarData( + val id: String, + val name: String?, + val url: String? = null, + val size: AvatarSize, +) { + val initialLetter by lazy { + // For roomIds, use "#" as initial + (name?.takeIf { it.isNotBlank() } ?: id.takeIf { !it.startsWith("!") } ?: "#") + .let { dn -> + var startIndex = 0 + val initial = dn[startIndex] + + if (initial in listOf('@', '#', '+') && dn.length > 1) { + startIndex++ + } + + var next = dn[startIndex] + + // LEFT-TO-RIGHT MARK + if (dn.length >= 2 && 0x200e == next.code) { + startIndex++ + next = dn[startIndex] + } + + while (next.isWhitespace()) { + if (dn.length > startIndex + 1) { + startIndex++ + next = dn[startIndex] + } else { + break + } + } + + val fullCharacterIterator = BreakIterator.getCharacterInstance() + fullCharacterIterator.setText(dn) + val glyphBoundary = tryOrNull { fullCharacterIterator.following(startIndex) } + ?.takeIf { it in startIndex..dn.length } + + when { + // Use the found boundary + glyphBoundary != null -> dn.substring(startIndex, glyphBoundary) + // If no boundary was found, default to the next char if possible + startIndex + 1 < dn.length -> dn.substring(startIndex, startIndex + 1) + // Return a fallback character otherwise + else -> "#" + } + } + .uppercase() + } +} + +fun AvatarData.getBestName(): String { + return name?.takeIf { it.isNotEmpty() } ?: id +} 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 0000000..870ffe4 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.avatar + +fun anAvatarData( + // Let's the id not start with a 'a'. + id: String = "@id_of_alice:server.org", + name: String? = "Alice", + url: String? = null, + size: AvatarSize = AvatarSize.RoomListItem, +) = AvatarData( + id = id, + name = name, + url = url, + size = size, +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarRow.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarRow.kt new file mode 100644 index 0000000..25784aa --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarRow.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.avatar + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.avatar.internal.OverlapRatioProvider +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.toPx +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/** + * Draw a row of avatars (they must all have the same size), from start to end. + * @param avatarDataList the avatars to render. Note: they will all be rendered, the caller may + * want to limit the list size + * @param avatarType the type of avatars to render + * @param modifier Jetpack Compose modifier + * @param overlapRatio the overlap ration. When 0f, avatars will render without overlap, when 1f + * only the first avatar will be visible + * @param lastOnTop if true, the last visible avatar will be rendered on top. + */ +@Composable +fun AvatarRow( + avatarDataList: ImmutableList, + avatarType: AvatarType, + modifier: Modifier = Modifier, + overlapRatio: Float = 0.5f, + lastOnTop: Boolean = false, +) { + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + Box( + modifier = modifier, + ) { + val lastItemIndex = avatarDataList.size - 1 + val avatarSize = avatarDataList.firstOrNull()?.size?.dp ?: return + val avatarSizePx = avatarSize.toPx() + avatarDataList + .let { + if (lastOnTop) { + it + } else { + it.reversed() + } + } + .forEachIndexed { index, avatarData -> + val startPadding = if (lastOnTop) { + avatarSize * (1 - overlapRatio) * index + } else { + avatarSize * (1 - overlapRatio) * (lastItemIndex - index) + } + Avatar( + modifier = Modifier + .padding(start = startPadding) + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + } + .drawWithContent { + // Draw content and clear the pixels for the avatar on the left (right in RTL) or when lastOnTop is true on + // the right (left in RTL). + drawContent() + if (index < lastItemIndex) { + val xOffset = if (isRtl == lastOnTop) { + avatarSizePx * (overlapRatio - 0.5f) + } else { + size.width - avatarSizePx * (overlapRatio - 0.5f) + } + drawCircle( + color = Color.Black, + center = Offset( + x = xOffset, + y = size.height / 2, + ), + radius = avatarSizePx / 2, + blendMode = BlendMode.Clear, + ) + } + } + .size(size = avatarSize) + // Keep internal padding, it has the advantage to not reduce the size of the Avatar image, + // which is already small in our use case. + .padding(2.dp), + avatarData = avatarData, + avatarType = avatarType, + ) + } + } +} + +@Composable +@PreviewsDayNight +internal fun AvatarRowPreview(@PreviewParameter(OverlapRatioProvider::class) overlapRatio: Float) { + ElementPreview { + ContentToPreview(overlapRatio) + } +} + +@Composable +@PreviewsDayNight +internal fun AvatarRowLastOnTopPreview(@PreviewParameter(OverlapRatioProvider::class) overlapRatio: Float) { + ElementPreview { + ContentToPreview( + overlapRatio = overlapRatio, + lastOnTop = true, + ) + } +} + +@Composable +@PreviewsDayNight +internal fun AvatarRowRtlPreview(@PreviewParameter(OverlapRatioProvider::class) overlapRatio: Float) { + CompositionLocalProvider( + LocalLayoutDirection provides LayoutDirection.Rtl, + ) { + ElementPreview { + ContentToPreview(overlapRatio) + } + } +} + +@Composable +@PreviewsDayNight +internal fun AvatarRowLastOnTopRtlPreview(@PreviewParameter(OverlapRatioProvider::class) overlapRatio: Float) { + CompositionLocalProvider( + LocalLayoutDirection provides LayoutDirection.Rtl, + ) { + ElementPreview { + ContentToPreview( + overlapRatio = overlapRatio, + lastOnTop = true, + ) + } + } +} + +@Composable +private fun ContentToPreview( + overlapRatio: Float, + lastOnTop: Boolean = false, +) { + AvatarRow( + avatarDataList = listOf("A", "B", "C").map { + AvatarData( + id = it, + name = it, + size = AvatarSize.RoomListItem, + ) + }.toImmutableList(), + avatarType = AvatarType.User, + overlapRatio = overlapRatio, + lastOnTop = lastOnTop, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarShape.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarShape.kt new file mode 100644 index 0000000..fc9f31d --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarShape.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.avatar + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp + +@Composable +fun AvatarType.User.avatarShape() = CircleShape + +@Composable +fun AvatarType.Room.avatarShape() = CircleShape + +@Composable +fun AvatarType.Space.avatarShape(avatarSize: Dp) = RoundedCornerShape(avatarSize * 0.25f) + +@Composable +fun AvatarType.avatarShape(avatarSize: Dp): Shape { + return when (this) { + is AvatarType.Space -> avatarShape(avatarSize) + is AvatarType.Room -> avatarShape() + is AvatarType.User -> avatarShape() + } +} 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 0000000..0f99349 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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(32.dp), + + IncomingCall(140.dp), + RoomDetailsHeader(96.dp), + RoomListItem(52.dp), + + SpaceListItem(52.dp), + + RoomSelectRoomListItem(36.dp), + + UserPreference(56.dp), + + UserHeader(96.dp), + UserListItem(36.dp), + + SelectedUser(52.dp), + SelectedRoom(56.dp), + + DmCluster(75.dp), + + TimelineRoom(32.dp), + TimelineSender(32.dp), + TimelineReadReceipt(16.dp), + TimelineThreadLatestEventSender(24.dp), + + ComposerAlert(32.dp), + + ReadReceiptList(32.dp), + + MessageActionSender(32.dp), + + RoomInviteItem(52.dp), + InviteSender(16.dp), + + EditRoomDetails(70.dp), + RoomListManageUser(96.dp), + + NotificationsOptIn(32.dp), + + CustomRoomNotificationSetting(36.dp), + + RoomDirectoryItem(36.dp), + + EditProfileDetails(96.dp), + + Suggestion(32.dp), + + KnockRequestItem(52.dp), + KnockRequestBanner(32.dp), + + MediaSender(32.dp), + + DmCreationConfirmation(64.dp), + + UserVerification(52.dp), + + OrganizationHeader(64.dp), + SpaceHeader(64.dp), + RoomPreviewHeader(64.dp), + RoomPreviewInviter(56.dp), + SpaceMember(24.dp), + LeaveSpaceRoom(32.dp), + + AccountItem(32.dp), +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarType.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarType.kt new file mode 100644 index 0000000..f7a8fee --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarType.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.avatar + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +sealed interface AvatarType { + data object User : AvatarType + + data class Room( + val isTombstoned: Boolean = false, + val heroes: ImmutableList = persistentListOf(), + ) : AvatarType + + data class Space( + val isTombstoned: Boolean = false, + ) : AvatarType +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/DmAvatars.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/DmAvatars.kt new file mode 100644 index 0000000..1cd0245 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/DmAvatars.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.avatar + +import androidx.compose.foundation.clickable +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.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.text.toPx +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.strings.CommonStrings + +/** Ratio between the box size (120 on Figma) and the avatar size (75 on Figma). */ +private const val SIZE_RATIO = 1.6f + +/** + * https://www.figma.com/design/A2pAEvTEpJZBiOPUlcMnKi/Settings-%2B-Room-Details-(new)?node-id=1787-56333 + */ +@Composable +fun DmAvatars( + userAvatarData: AvatarData, + otherUserAvatarData: AvatarData, + openAvatarPreview: (url: String) -> Unit, + openOtherAvatarPreview: (url: String) -> Unit, + modifier: Modifier = Modifier, +) { + val boxSize = userAvatarData.size.dp * SIZE_RATIO + val boxSizePx = boxSize.toPx() + val otherAvatarRadius = otherUserAvatarData.size.dp.toPx() / 2 + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + Box( + modifier = modifier.size(boxSize), + ) { + // Draw user avatar and cut top end corner + Avatar( + avatarData = userAvatarData, + avatarType = AvatarType.User, + contentDescription = userAvatarData.url?.let { stringResource(CommonStrings.a11y_your_avatar) }, + modifier = Modifier + .align(Alignment.BottomStart) + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + } + .drawWithContent { + drawContent() + val xOffset = if (isRtl) { + size.width - boxSizePx + otherAvatarRadius + } else { + boxSizePx - otherAvatarRadius + } + drawCircle( + color = Color.Black, + center = Offset( + x = xOffset, + y = size.height - (boxSizePx - otherAvatarRadius), + ), + radius = otherAvatarRadius / 0.9f, + blendMode = BlendMode.Clear, + ) + } + .clip(CircleShape) + .clickable( + enabled = userAvatarData.url != null, + onClickLabel = stringResource(CommonStrings.action_view), + ) { + userAvatarData.url?.let { openAvatarPreview(it) } + } + ) + // Draw other user avatar + Avatar( + avatarData = otherUserAvatarData, + avatarType = AvatarType.User, + contentDescription = otherUserAvatarData.url?.let { stringResource(CommonStrings.a11y_other_user_avatar) }, + modifier = Modifier + .align(Alignment.TopEnd) + .clip(CircleShape) + .clickable( + enabled = otherUserAvatarData.url != null, + onClickLabel = stringResource(CommonStrings.action_view), + ) { + otherUserAvatarData.url?.let { openOtherAvatarPreview(it) } + } + .testTag(TestTags.memberDetailAvatar) + ) + } +} + +@Preview(group = PreviewGroup.Avatars) +@Composable +internal fun DmAvatarsPreview() = ElementThemedPreview { + val size = AvatarSize.DmCluster + DmAvatars( + userAvatarData = anAvatarData( + id = "Alice", + name = "Alice", + size = size, + ), + otherUserAvatarData = anAvatarData( + id = "Bob", + name = "Bob", + size = size, + ), + openAvatarPreview = {}, + openOtherAvatarPreview = {}, + ) +} + +@Preview(group = PreviewGroup.Avatars) +@Composable +internal fun DmAvatarsRtlPreview() { + CompositionLocalProvider( + LocalLayoutDirection provides LayoutDirection.Rtl, + ) { + DmAvatarsPreview() + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/AvatarCluster.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/AvatarCluster.kt new file mode 100644 index 0000000..bb696c2 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/AvatarCluster.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.avatar.internal + +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.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.avatar.anAvatarData +import io.element.android.libraries.designsystem.components.avatar.avatarShape +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import java.util.Collections +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +private const val MAX_AVATAR_COUNT = 4 + +@Composable +internal fun AvatarCluster( + avatars: ImmutableList, + avatarType: AvatarType, + modifier: Modifier = Modifier, + hideAvatarImages: Boolean = false, + contentDescription: String? = null, +) { + val limitedAvatars = avatars.take(MAX_AVATAR_COUNT) + val numberOfAvatars = limitedAvatars.size + if (numberOfAvatars == 4) { + // Swap 2 and 3 so that the 4th avatar is at the bottom right + Collections.swap(limitedAvatars, 2, 3) + } + when (numberOfAvatars) { + 0 -> { + error("Unsupported number of avatars: 0") + } + 1 -> { + InitialOrImageAvatar( + avatarData = limitedAvatars[0], + hideAvatarImage = hideAvatarImages, + avatarShape = avatarType.avatarShape(limitedAvatars[0].size.dp), + forcedAvatarSize = null, + modifier = modifier, + contentDescription = contentDescription, + ) + } + else -> { + val size = limitedAvatars.first().size + val angle = 2 * Math.PI / numberOfAvatars + val offsetRadius = when (numberOfAvatars) { + 2 -> size.dp.value / 4.2 + 3 -> size.dp.value / 4.0 + 4 -> size.dp.value / 3.1 + else -> error("Unsupported number of heroes: $numberOfAvatars") + } + val heroAvatarSize = when (numberOfAvatars) { + 2 -> size.dp / 2.2f + 3 -> size.dp / 2.4f + 4 -> size.dp / 2.2f + else -> error("Unsupported number of heroes: $numberOfAvatars") + } + val angleOffset = when (numberOfAvatars) { + 2 -> PI + 3 -> 7 * PI / 6 + 4 -> 13 * PI / 4 + else -> error("Unsupported number of heroes: $numberOfAvatars") + } + Box( + modifier = modifier + .size(size.dp) + .semantics { + this.contentDescription = contentDescription.orEmpty() + }, + contentAlignment = Alignment.Center, + ) { + limitedAvatars.forEachIndexed { index, heroAvatar -> + val xOffset = (offsetRadius * cos(angle * index.toDouble() + angleOffset)).dp + val yOffset = (offsetRadius * sin(angle * index.toDouble() + angleOffset)).dp + Box( + modifier = Modifier + .size(heroAvatarSize) + .offset( + x = xOffset, + y = yOffset, + ) + ) { + InitialOrImageAvatar( + avatarData = heroAvatar, + hideAvatarImage = hideAvatarImages, + avatarShape = avatarType.avatarShape(heroAvatarSize), + forcedAvatarSize = heroAvatarSize, + modifier = Modifier, + contentDescription = contentDescription, + ) + } + } + } + } + } +} + +@Preview(group = PreviewGroup.Avatars) +@Composable +internal fun AvatarClusterPreview() = ElementThemedPreview { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + listOf( + AvatarType.User, + AvatarType.Room(), + AvatarType.Space(), + ).forEach { avatarType -> + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + for (ngOfAvatars in 1..5) { + AvatarCluster( + avatars = List(ngOfAvatars) { anAvatarData(it) }.toImmutableList(), + avatarType = avatarType, + ) + } + } + } + } +} + +private fun anAvatarData(i: Int) = anAvatarData( + id = ('A' + i).toString(), + name = ('A' + i).toString() +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt new file mode 100644 index 0000000..ebd81f6 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.avatar.internal + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import coil3.compose.AsyncImagePainter +import coil3.compose.SubcomposeAsyncImage +import coil3.compose.SubcomposeAsyncImageContent +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import timber.log.Timber + +@Composable +internal fun ImageAvatar( + avatarData: AvatarData, + avatarShape: Shape, + forcedAvatarSize: Dp?, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { + val size = forcedAvatarSize ?: avatarData.size.dp + SubcomposeAsyncImage( + model = avatarData, + contentDescription = contentDescription, + contentScale = ContentScale.Companion.Crop, + modifier = modifier + .size(size) + .clip(avatarShape) + ) { + val collectedState by painter.state.collectAsState() + when (val state = collectedState) { + is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent() + is AsyncImagePainter.State.Error -> { + SideEffect { + Timber.e( + state.result.throwable, + "Error loading avatar $state\n${state.result}" + ) + } + InitialLetterAvatar( + avatarData = avatarData, + avatarShape = avatarShape, + forcedAvatarSize = forcedAvatarSize, + contentDescription = contentDescription, + ) + } + else -> InitialLetterAvatar( + avatarData = avatarData, + avatarShape = avatarShape, + forcedAvatarSize = forcedAvatarSize, + contentDescription = contentDescription, + ) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/InitialLetterAvatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/InitialLetterAvatar.kt new file mode 100644 index 0000000..05508b8 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/InitialLetterAvatar.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.avatar.internal + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import io.element.android.libraries.designsystem.colors.AvatarColorsProvider +import io.element.android.libraries.designsystem.components.avatar.AvatarData + +@Composable +internal fun InitialLetterAvatar( + avatarData: AvatarData, + avatarShape: Shape, + forcedAvatarSize: Dp?, + contentDescription: String?, + modifier: Modifier = Modifier, +) { + val avatarColors = AvatarColorsProvider.provide(avatarData.id) + TextAvatar( + text = avatarData.initialLetter, + size = forcedAvatarSize ?: avatarData.size.dp, + avatarShape = avatarShape, + colors = avatarColors, + contentDescription = contentDescription, + modifier = modifier + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/InitialOrImageAvatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/InitialOrImageAvatar.kt new file mode 100644 index 0000000..4e5a4f0 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/InitialOrImageAvatar.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.avatar.internal + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import io.element.android.libraries.designsystem.components.avatar.AvatarData + +@Composable +internal fun InitialOrImageAvatar( + avatarData: AvatarData, + hideAvatarImage: Boolean, + forcedAvatarSize: Dp?, + avatarShape: Shape, + contentDescription: String?, + modifier: Modifier = Modifier, +) { + when { + avatarData.url.isNullOrBlank() || hideAvatarImage -> InitialLetterAvatar( + avatarData = avatarData, + avatarShape = avatarShape, + forcedAvatarSize = forcedAvatarSize, + modifier = modifier, + contentDescription = contentDescription, + ) + else -> ImageAvatar( + avatarData = avatarData, + avatarShape = avatarShape, + forcedAvatarSize = forcedAvatarSize, + modifier = modifier, + contentDescription = contentDescription, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/OverlapRatioProvider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/OverlapRatioProvider.kt new file mode 100644 index 0000000..26a76c2 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/OverlapRatioProvider.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.avatar.internal + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +class OverlapRatioProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + 0f, + 0.25f, + 0.5f, + 0.75f, + 1f + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/RoomAvatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/RoomAvatar.kt new file mode 100644 index 0000000..e5dc24f --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/RoomAvatar.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.avatar.internal + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.avatar.avatarShape +import kotlinx.collections.immutable.toImmutableList + +@Composable +internal fun RoomAvatar( + avatarData: AvatarData, + avatarType: AvatarType.Room, + modifier: Modifier = Modifier, + hideAvatarImage: Boolean = false, + forcedAvatarSize: Dp? = null, + contentDescription: String? = null, +) { + when { + avatarType.isTombstoned -> { + TombstonedRoomAvatar( + size = forcedAvatarSize ?: avatarData.size.dp, + modifier = modifier, + avatarShape = avatarType.avatarShape(), + contentDescription = contentDescription + ) + } + avatarData.url != null || avatarType.heroes.isEmpty() -> { + InitialOrImageAvatar( + avatarData = avatarData, + hideAvatarImage = hideAvatarImage, + avatarShape = avatarType.avatarShape(), + forcedAvatarSize = forcedAvatarSize, + modifier = modifier, + contentDescription = contentDescription, + ) + } + else -> { + AvatarCluster( + // Keep only the first hero for now + avatars = avatarType.heroes.take(1).toImmutableList(), + // Note: even for a room avatar, we use AvatarType.User here to display the avatar of heroes + avatarType = AvatarType.User, + modifier = modifier, + hideAvatarImages = hideAvatarImage, + contentDescription = contentDescription + ) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/SpaceAvatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/SpaceAvatar.kt new file mode 100644 index 0000000..c301a49 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/SpaceAvatar.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.avatar.internal + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.avatar.anAvatarData +import io.element.android.libraries.designsystem.components.avatar.avatarShape +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.utils.CommonDrawables + +@Composable +internal fun SpaceAvatar( + avatarData: AvatarData, + avatarType: AvatarType.Space, + modifier: Modifier = Modifier, + forcedAvatarSize: Dp? = null, + hideAvatarImage: Boolean = false, + contentDescription: String? = null, +) { + val size = forcedAvatarSize ?: avatarData.size.dp + when { + avatarType.isTombstoned -> TombstonedRoomAvatar( + size = size, + avatarShape = avatarType.avatarShape(size), + modifier = modifier, + contentDescription = contentDescription, + ) + else -> InitialOrImageAvatar( + avatarData = avatarData, + hideAvatarImage = hideAvatarImage, + avatarShape = avatarType.avatarShape(size), + forcedAvatarSize = forcedAvatarSize, + modifier = modifier, + contentDescription = contentDescription, + ) + } +} + +@Preview(group = PreviewGroup.Avatars) +@Composable +internal fun SpaceAvatarPreview() = + ElementThemedPreview( + drawableFallbackForImages = CommonDrawables.sample_avatar, + ) { + Row( + modifier = Modifier.padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SpaceAvatar( + avatarData = anAvatarData(), + avatarType = AvatarType.Space(), + ) + SpaceAvatar( + avatarData = anAvatarData(), + avatarType = AvatarType.Space( + isTombstoned = true, + ), + ) + } + } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/TextAvatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/TextAvatar.kt new file mode 100644 index 0000000..8867735 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/TextAvatar.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.avatar.internal + +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.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.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.compound.theme.AvatarColors +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.avatar.avatarShape +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.text.toSp +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +internal fun TextAvatar( + text: String, + size: Dp, + colors: AvatarColors, + contentDescription: String?, + avatarShape: Shape, + modifier: Modifier = Modifier, +) { + Box( + modifier + .size(size) + .clip(avatarShape) + .background(color = colors.background) + ) { + val fontSize = size.toSp() / 2 + val originalFont = ElementTheme.typography.fontHeadingMdBold + val ratio = fontSize.value / originalFont.fontSize.value + val lineHeight = originalFont.lineHeight * ratio + Text( + modifier = Modifier + .clearAndSetSemantics { + contentDescription?.let { + this.contentDescription = it + } + } + .align(Alignment.Center), + text = text, + style = originalFont.copy(fontSize = fontSize, lineHeight = lineHeight, letterSpacing = 0.sp), + color = colors.foreground, + ) + } +} + +@Preview(group = PreviewGroup.Avatars) +@Composable +internal fun TextAvatarPreview() = ElementPreview { + Row( + modifier = Modifier.padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + listOf( + AvatarType.User, + AvatarType.Room(), + AvatarType.Space(), + ).forEach { avatarType -> + TextAvatar( + text = "AB", + size = 40.dp, + colors = AvatarColors( + background = ElementTheme.colors.bgSubtlePrimary, + foreground = ElementTheme.colors.iconPrimary, + ), + avatarShape = avatarType.avatarShape(40.dp), + contentDescription = null, + ) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/TombstonedRoomAvatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/TombstonedRoomAvatar.kt new file mode 100644 index 0000000..413ca3b --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/TombstonedRoomAvatar.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.avatar.internal + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +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 androidx.compose.ui.unit.dp +import io.element.android.compound.theme.AvatarColors +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@Composable +internal fun TombstonedRoomAvatar( + size: Dp, + avatarShape: Shape, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { + TextAvatar( + text = "!", + size = size, + colors = AvatarColors( + background = ElementTheme.colors.bgSubtlePrimary, + foreground = ElementTheme.colors.iconTertiary + ), + modifier = modifier, + avatarShape = avatarShape, + contentDescription = contentDescription, + ) +} + +@Preview(group = PreviewGroup.Avatars) +@Composable +internal fun TombstonedRoomAvatarPreview() = ElementPreview { + TombstonedRoomAvatar( + size = 52.dp, + avatarShape = CircleShape, + contentDescription = null, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/UserAvatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/UserAvatar.kt new file mode 100644 index 0000000..c6aa75b --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/UserAvatar.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.avatar.internal + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.avatar.avatarShape + +@Composable +internal fun UserAvatar( + avatarData: AvatarData, + modifier: Modifier = Modifier, + contentDescription: String? = null, + forcedAvatarSize: Dp? = null, + hideImage: Boolean = false, +) { + InitialOrImageAvatar( + avatarData = avatarData, + hideAvatarImage = hideImage, + avatarShape = AvatarType.User.avatarShape(), + modifier = modifier, + contentDescription = contentDescription, + forcedAvatarSize = forcedAvatarSize, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/UserAvatarPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/UserAvatarPreview.kt new file mode 100644 index 0000000..a9c1f95 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/UserAvatarPreview.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.avatar.internal + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.unit.dp +import io.element.android.compound.theme.avatarColors +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.avatar.anAvatarData +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text + +@PreviewsDayNight +@Composable +internal fun UserAvatarColorsPreview() = ElementPreview { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + repeat(avatarColors().size) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Note: it's OK, since the hash of "0" is 0, the hash of "1" is 1, etc. + Avatar( + avatarData = anAvatarData(id = "$it"), + avatarType = AvatarType.User, + ) + Text(text = "Color index $it") + } + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/blurhash/BlurHashAsyncImage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/blurhash/BlurHashAsyncImage.kt new file mode 100644 index 0000000..f3da71b --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/blurhash/BlurHashAsyncImage.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.blurhash + +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.fillMaxSize +import androidx.compose.runtime.Composable +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage + +@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, + ) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/blurhash/BlurHashBackgroundModifier.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/blurhash/BlurHashBackgroundModifier.kt new file mode 100644 index 0000000..a6f04a2 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/blurhash/BlurHashBackgroundModifier.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.blurhash + +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.unit.IntSize + +fun Modifier.blurHashBackground(blurHash: String?, alpha: Float = 1f) = this.composed { + val blurHashBitmap = rememberBlurHashImage(blurHash) + if (blurHashBitmap != null) { + Modifier.drawBehind { + drawImage(blurHashBitmap, dstSize = IntSize(size.width.toInt(), size.height.toInt()), alpha = alpha) + } + } else { + this + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/blurhash/BlurHashImage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/blurhash/BlurHashImage.kt new file mode 100644 index 0000000..7285322 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/blurhash/BlurHashImage.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.blurhash + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInspectionMode +import com.vanniktech.blurhash.BlurHash + +@Suppress("ModifierMissing") +@Composable +fun BlurHashImage( + blurHash: String?, + contentDescription: String? = null, + contentScale: ContentScale = ContentScale.Fit, +) { + if (blurHash == null) return + val blurHashImage = rememberBlurHashImage(blurHash) + blurHashImage?.let { bitmap -> + Image( + modifier = Modifier.fillMaxSize(), + bitmap = bitmap, + contentScale = contentScale, + contentDescription = contentDescription + ) + } +} + +@Composable +fun rememberBlurHashImage(blurHash: String?): ImageBitmap? { + return if (LocalInspectionMode.current) { + blurHash?.let { BlurHash.decode(it, 10, 10)?.asImageBitmap() } + } else { + produceState(initialValue = null, blurHash) { + blurHash?.let { value = BlurHash.decode(it, 10, 10)?.asImageBitmap() } + }.value + } +} 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 0000000..52a97ba --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/BackButton.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.button + +import androidx.compose.foundation.layout.Column +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.compound.tokens.generated.CompoundIcons +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, + // TODO Handle RTL languages + imageVector: ImageVector = CompoundIcons.ArrowLeft(), + contentDescription: String = stringResource(CommonStrings.action_back), + enabled: Boolean = true, +) { + IconButton( + modifier = modifier, + onClick = onClick, + enabled = enabled, + ) { + Icon(imageVector, contentDescription = contentDescription) + } +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun BackButtonPreview() = ElementThemedPreview { + Column { + BackButton(onClick = { }, enabled = true) + BackButton(onClick = { }, enabled = false) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/ButtonVisuals.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/ButtonVisuals.kt new file mode 100644 index 0000000..9d7a0fe --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/ButtonVisuals.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.button + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.TextButton + +/** + * A sealed interface that represents the different visual styles that a button can have. + */ +@Immutable +sealed interface ButtonVisuals { + val action: () -> Unit + + /** + * Creates a [Button] composable based on the visual state. + */ + @Composable + fun Composable() + + data class Text(val text: String, override val action: () -> Unit) : ButtonVisuals { + @Composable + override fun Composable() { + TextButton(text = text, onClick = action) + } + } + data class Icon(val iconSource: IconSource, override val action: () -> Unit) : ButtonVisuals { + @Composable + override fun Composable() { + IconButton(onClick = action) { + Icon(iconSource.getPainter(), iconSource.contentDescription) + } + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/GradientFloatingActionButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/GradientFloatingActionButton.kt new file mode 100644 index 0000000..ff82f3f --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/GradientFloatingActionButton.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.button + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.LinearGradientShader +import androidx.compose.ui.graphics.RadialGradientShader +import androidx.compose.ui.graphics.Shader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import io.element.android.compound.annotations.CoreColorToken +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.colors.gradientActionColors +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon + +@OptIn(CoreColorToken::class) +@Composable +fun GradientFloatingActionButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(25), + content: @Composable () -> Unit, +) { + val colors = gradientActionColors() + val linearShaderBrush = remember { + object : ShaderBrush() { + override fun createShader(size: Size): Shader { + return LinearGradientShader( + from = Offset(size.width, size.height), + to = Offset(size.width, 0f), + colors = colors, + ) + } + } + } + val radialShaderBrush = remember { + object : ShaderBrush() { + override fun createShader(size: Size): Shader { + return RadialGradientShader( + center = size.center, + radius = size.width / 2, + colors = colors, + ) + } + } + } + + Box( + modifier = modifier + .minimumInteractiveComponentSize() + .graphicsLayer(shape = shape, clip = false) + .clip(shape) + .drawBehind { + drawRect(brush = linearShaderBrush) + drawRect(brush = radialShaderBrush, alpha = 0.4f, blendMode = BlendMode.Overlay) + } + .clickable( + enabled = true, + onClick = onClick, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(color = Color.White) + ), + contentAlignment = Alignment.Center + ) { + CompositionLocalProvider(LocalContentColor provides Color.White) { + content() + } + } +} + +@PreviewsDayNight +@Composable +internal fun GradientFloatingActionButtonPreview() { + ElementPreview { + Box(modifier = Modifier.padding(20.dp)) { + GradientFloatingActionButton( + modifier = Modifier.size(48.dp), + onClick = {}, + ) { + Icon(imageVector = CompoundIcons.ChatNew(), contentDescription = null) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun GradientFloatingActionButtonCircleShapePreview() { + ElementPreview { + Box(modifier = Modifier.padding(20.dp)) { + GradientFloatingActionButton( + shape = CircleShape, + modifier = Modifier.size(48.dp), + onClick = {}, + ) { + Icon( + modifier = Modifier.padding(start = 2.dp), + imageVector = CompoundIcons.SendSolid(), + contentDescription = null + ) + } + } + } +} 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 0000000..9893620 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/MainActionButton.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.button + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.ripple +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.vector.ImageVector +import androidx.compose.ui.text.style.Hyphens +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.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +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 + +@Composable +fun MainActionButton( + title: String, + imageVector: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val ripple = ripple(bounded = false) + val interactionSource = remember { MutableInteractionSource() } + Column( + modifier + .clickable( + enabled = enabled, + interactionSource = interactionSource, + onClick = onClick, + indication = ripple + ) + .widthIn(min = 76.dp, max = 96.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + contentDescription = null, + imageVector = imageVector, + tint = if (enabled) LocalContentColor.current else ElementTheme.colors.iconDisabled, + ) + Spacer(modifier = Modifier.height(14.dp)) + Text( + title, + style = ElementTheme.typography.fontBodyMdMedium.copy(hyphens = Hyphens.Auto), + color = if (enabled) LocalContentColor.current else ElementTheme.colors.textDisabled, + overflow = TextOverflow.Visible, + textAlign = TextAlign.Center, + ) + } +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun MainActionButtonPreview() { + ElementThemedPreview { + ContentsToPreview() + } +} + +@Composable +private fun ContentsToPreview() { + Row( + modifier = Modifier.padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp) + ) { + MainActionButton( + title = "Share", + imageVector = CompoundIcons.ShareAndroid(), + onClick = { }, + ) + MainActionButton( + title = "Share with a long text", + imageVector = CompoundIcons.ShareAndroid(), + onClick = { }, + ) + MainActionButton( + title = "Share", + imageVector = CompoundIcons.ShareAndroid(), + onClick = { }, + enabled = false, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/SuperButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/SuperButton.kt new file mode 100644 index 0000000..694e739 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/SuperButton.kt @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.button + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +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.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.LinearGradientShader +import androidx.compose.ui.graphics.Shader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.colors.gradientActionColors +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ButtonSize +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.lowHorizontalPaddingValue + +@Composable +fun SuperButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(50), + buttonSize: ButtonSize = ButtonSize.Large, + enabled: Boolean = true, + content: @Composable () -> Unit, +) { + val contentPadding = remember(buttonSize) { + when (buttonSize) { + ButtonSize.Large -> PaddingValues(horizontal = 24.dp, vertical = 13.dp) + ButtonSize.LargeLowPadding -> PaddingValues(horizontal = lowHorizontalPaddingValue, vertical = 13.dp) + ButtonSize.Medium -> PaddingValues(horizontal = 20.dp, vertical = 9.dp) + ButtonSize.MediumLowPadding -> PaddingValues(horizontal = lowHorizontalPaddingValue, vertical = 9.dp) + ButtonSize.Small -> PaddingValues(horizontal = 16.dp, vertical = 5.dp) + } + } + val colors = gradientActionColors() + val shaderBrush = remember(colors) { + object : ShaderBrush() { + override fun createShader(size: Size): Shader { + return LinearGradientShader( + from = Offset(0f, 0f), + to = Offset(0f, size.height), + colors = colors, + ) + } + } + } + val border = if (enabled) { + BorderStroke(1.dp, shaderBrush) + } else { + BorderStroke(1.dp, ElementTheme.colors.borderDisabled) + } + val backgroundColor = ElementTheme.colors.bgCanvasDefault + Box( + modifier = modifier + .minimumInteractiveComponentSize() + .graphicsLayer(shape = shape, clip = false) + .clip(shape) + .border(border, shape) + .drawBehind { + drawRect(backgroundColor) + drawRect(brush = shaderBrush, alpha = 0.04f) + } + .clickable( + enabled = enabled, + onClick = onClick, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple() + ) + .padding(contentPadding), + contentAlignment = Alignment.Center + ) { + CompositionLocalProvider( + LocalContentColor provides if (enabled) ElementTheme.colors.textPrimary else ElementTheme.colors.textDisabled, + LocalTextStyle provides ElementTheme.typography.fontBodyLgMedium, + ) { + content() + } + } +} + +@PreviewsDayNight +@Composable +internal fun SuperButtonPreview() { + ElementPreview { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + SuperButton( + modifier = Modifier.padding(10.dp), + buttonSize = ButtonSize.Large, + onClick = {}, + ) { + Text("Super button!") + } + + SuperButton( + modifier = Modifier.padding(10.dp), + buttonSize = ButtonSize.LargeLowPadding, + onClick = {}, + ) { + Text("Super LargeLowPadding") + } + + SuperButton( + modifier = Modifier.padding(10.dp), + buttonSize = ButtonSize.Medium, + onClick = {}, + ) { + Text("Super button!") + } + + SuperButton( + modifier = Modifier.padding(10.dp), + buttonSize = ButtonSize.MediumLowPadding, + onClick = {}, + ) { + Text("Super MediumLowPadding") + } + + SuperButton( + modifier = Modifier.padding(10.dp), + buttonSize = ButtonSize.Small, + onClick = {}, + ) { + Text("Super button!") + } + + HorizontalDivider() + + SuperButton( + modifier = Modifier.padding(10.dp), + buttonSize = ButtonSize.Large, + enabled = false, + onClick = {}, + ) { + Text("Super button!") + } + + SuperButton( + modifier = Modifier.padding(10.dp), + buttonSize = ButtonSize.Medium, + enabled = false, + onClick = {}, + ) { + Text("Super button!") + } + + SuperButton( + modifier = Modifier.padding(10.dp), + buttonSize = ButtonSize.Small, + enabled = false, + onClick = {}, + ) { + Text("Super button!") + } + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/AlertDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/AlertDialog.kt new file mode 100644 index 0000000..3c9204a --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/AlertDialog.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.dialogs + +import androidx.compose.material3.BasicAlertDialog +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 io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.DialogPreview +import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AlertDialog( + content: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + title: String? = null, + submitText: String = AlertDialogDefaults.submitText, +) { + BasicAlertDialog(modifier = modifier, onDismissRequest = onDismiss) { + AlertDialogContent( + title = title, + content = content, + submitText = submitText, + onSubmitClick = onDismiss, + ) + } +} + +@Composable +private fun AlertDialogContent( + content: String, + onSubmitClick: () -> Unit, + title: String? = AlertDialogDefaults.title, + submitText: String = AlertDialogDefaults.submitText, +) { + SimpleAlertDialogContent( + title = title, + content = content, + submitText = submitText, + onSubmitClick = onSubmitClick, + ) +} + +object AlertDialogDefaults { + val title: String? @Composable get() = null + val submitText: String @Composable get() = stringResource(id = CommonStrings.action_ok) +} + +@Preview(group = PreviewGroup.Dialogs) +@Composable +internal fun AlertDialogContentPreview() { + ElementThemedPreview(showBackground = false) { + DialogPreview { + AlertDialogContent( + content = "Content", + onSubmitClick = {}, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun AlertDialogPreview() = ElementPreview { + AlertDialog( + content = "Content", + onDismiss = {}, + ) +} 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 0000000..9d217da --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.dialogs + +import androidx.compose.material3.BasicAlertDialog +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 io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.DialogPreview +import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConfirmationDialog( + content: String, + onSubmitClick: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + title: String? = null, + submitText: String = stringResource(id = CommonStrings.action_ok), + cancelText: String = stringResource(id = CommonStrings.action_cancel), + destructiveSubmit: Boolean = false, + thirdButtonText: String? = null, + onCancelClick: () -> Unit = onDismiss, + onThirdButtonClick: () -> Unit = {}, + icon: @Composable (() -> Unit)? = null, +) { + BasicAlertDialog(modifier = modifier, onDismissRequest = onDismiss) { + ConfirmationDialogContent( + title = title, + content = content, + submitText = submitText, + cancelText = cancelText, + thirdButtonText = thirdButtonText, + destructiveSubmit = destructiveSubmit, + onSubmitClick = onSubmitClick, + onCancelClick = onCancelClick, + onThirdButtonClick = onThirdButtonClick, + icon = icon, + ) + } +} + +@Composable +private fun ConfirmationDialogContent( + content: String, + submitText: String, + cancelText: String, + onSubmitClick: () -> Unit, + onCancelClick: () -> Unit, + title: String? = null, + thirdButtonText: String? = null, + onThirdButtonClick: () -> Unit = {}, + destructiveSubmit: Boolean = false, + icon: @Composable (() -> Unit)? = null, +) { + SimpleAlertDialogContent( + title = title, + content = content, + submitText = submitText, + onSubmitClick = onSubmitClick, + cancelText = cancelText, + onCancelClick = onCancelClick, + thirdButtonText = thirdButtonText, + onThirdButtonClick = onThirdButtonClick, + destructiveSubmit = destructiveSubmit, + icon = icon, + ) +} + +@Preview(group = PreviewGroup.Dialogs) +@Composable +internal fun ConfirmationDialogContentPreview() = + ElementThemedPreview(showBackground = false) { + DialogPreview { + ConfirmationDialogContent( + content = "Content", + title = "Title", + submitText = "OK", + cancelText = "Cancel", + thirdButtonText = "Disable", + onSubmitClick = {}, + onCancelClick = {}, + onThirdButtonClick = {}, + ) + } + } + +@PreviewsDayNight +@Composable +internal fun ConfirmationDialogPreview() = ElementPreview { + ConfirmationDialog( + content = "Content", + title = "Title", + submitText = "OK", + cancelText = "Cancel", + thirdButtonText = "Disable", + onSubmitClick = {}, + onDismiss = {} + ) +} 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 0000000..3c79dd7 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.dialogs + +import androidx.compose.material3.BasicAlertDialog +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.window.DialogProperties +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.DialogPreview +import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ErrorDialog( + content: String, + onSubmit: () -> Unit, + modifier: Modifier = Modifier, + title: String? = ErrorDialogDefaults.title, + submitText: String = ErrorDialogDefaults.submitText, + onDismiss: () -> Unit = onSubmit, + canDismiss: Boolean = true, +) { + BasicAlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + properties = DialogProperties(dismissOnClickOutside = canDismiss, dismissOnBackPress = canDismiss) + ) { + ErrorDialogContent( + title = title, + content = content, + submitText = submitText, + onSubmitClick = onSubmit, + ) + } +} + +@Composable +private fun ErrorDialogContent( + content: String, + onSubmitClick: () -> Unit, + title: String? = ErrorDialogDefaults.title, + submitText: String = ErrorDialogDefaults.submitText, +) { + SimpleAlertDialogContent( + title = title, + content = content, + submitText = submitText, + onSubmitClick = onSubmitClick, + ) +} + +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 ErrorDialogContentPreview() { + ElementThemedPreview(showBackground = false) { + DialogPreview { + ErrorDialogContent( + content = "Content", + onSubmitClick = {}, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun ErrorDialogPreview() = ElementPreview { + ErrorDialog( + content = "Content", + onSubmit = {}, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialogWithDoNotShowAgain.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialogWithDoNotShowAgain.kt new file mode 100644 index 0000000..37c9dae --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialogWithDoNotShowAgain.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.dialogs + +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.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +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.res.stringResource +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Checkbox +import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ErrorDialogWithDoNotShowAgain( + content: String, + onDismiss: (Boolean) -> Unit, + modifier: Modifier = Modifier, + title: String = ErrorDialogDefaults.title, + submitText: String = ErrorDialogDefaults.submitText, + cancelText: String? = null, + onCancel: () -> Unit = {}, +) { + var doNotShowAgain by remember { mutableStateOf(false) } + BasicAlertDialog( + modifier = modifier, + onDismissRequest = { onDismiss(doNotShowAgain) } + ) { + SimpleAlertDialogContent( + title = title, + submitText = submitText, + cancelText = cancelText, + onSubmitClick = { onDismiss(doNotShowAgain) }, + onCancelClick = onCancel, + ) { + Column { + Text( + text = content, + style = ElementTheme.materialTypography.bodyMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = doNotShowAgain, onCheckedChange = { doNotShowAgain = it }) + Text( + text = stringResource(id = CommonStrings.common_do_not_show_this_again), + style = ElementTheme.materialTypography.bodyMedium, + ) + } + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun ErrorDialogWithDoNotShowAgainPreview() = ElementPreview { + ErrorDialogWithDoNotShowAgain( + content = "Content", + onDismiss = {}, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt new file mode 100644 index 0000000..ce1afae --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.BasicAlertDialog +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.unit.dp +import io.element.android.libraries.designsystem.components.list.TextFieldListItem +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.DialogPreview +import io.element.android.libraries.designsystem.theme.components.ListSupportingText +import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ListDialog( + onSubmit: () -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + title: String? = null, + subtitle: String? = null, + cancelText: String = stringResource(CommonStrings.action_cancel), + submitText: String = stringResource(CommonStrings.action_ok), + enabled: Boolean = true, + applyPaddingToContents: Boolean = true, + destructiveSubmit: Boolean = false, + listItems: LazyListScope.() -> Unit, +) { + val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let { + @Composable { + ListSupportingText( + text = it, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + BasicAlertDialog( + modifier = modifier, + onDismissRequest = onDismissRequest, + ) { + ListDialogContent( + title = title, + subtitle = decoratedSubtitle, + cancelText = cancelText, + submitText = submitText, + onDismissRequest = onDismissRequest, + onSubmitClick = onSubmit, + enabled = enabled, + listItems = listItems, + applyPaddingToContents = applyPaddingToContents, + destructiveSubmit = destructiveSubmit, + ) + } +} + +@Composable +private fun ListDialogContent( + listItems: LazyListScope.() -> Unit, + onDismissRequest: () -> Unit, + onSubmitClick: () -> Unit, + cancelText: String, + submitText: String, + title: String?, + enabled: Boolean, + applyPaddingToContents: Boolean, + destructiveSubmit: Boolean, + subtitle: @Composable (() -> Unit)? = null, +) { + SimpleAlertDialogContent( + title = title, + subtitle = subtitle, + cancelText = cancelText, + submitText = submitText, + onCancelClick = onDismissRequest, + onSubmitClick = onSubmitClick, + enabled = enabled, + applyPaddingToContents = applyPaddingToContents, + destructiveSubmit = destructiveSubmit, + ) { + // No start padding if padding is already applied to the content + val horizontalPadding = if (applyPaddingToContents) 0.dp else 8.dp + LazyColumn( + modifier = Modifier.padding(horizontal = horizontalPadding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { listItems() } + } +} + +@Preview(group = PreviewGroup.Dialogs) +@Composable +internal fun ListDialogContentPreview() { + ElementThemedPreview(showBackground = false) { + DialogPreview { + ListDialogContent( + listItems = { + item { + TextFieldListItem(placeholder = "Text input", text = "", onTextChange = {}) + } + item { + TextFieldListItem(placeholder = "Another text input", text = "", onTextChange = {}) + } + }, + title = "Dialog title", + onDismissRequest = {}, + onSubmitClick = {}, + cancelText = "Cancel", + submitText = "Save", + enabled = true, + destructiveSubmit = false, + applyPaddingToContents = true, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun ListDialogPreview() = ElementPreview { + ListDialog( + listItems = { + item { + TextFieldListItem(placeholder = "Text input", text = "", onTextChange = {}) + } + item { + TextFieldListItem(placeholder = "Another text input", text = "", onTextChange = {}) + } + }, + title = "Dialog title", + onDismissRequest = {}, + onSubmit = {}, + cancelText = "Cancel", + submitText = "Save", + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListOption.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListOption.kt new file mode 100644 index 0000000..5b84b60 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListOption.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.dialogs + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/** + * Used to store the visual data for a list option. + */ +data class ListOption( + val title: String, + val subtitle: String? = null, +) + +/** Creates an immutable list of [ListOption]s from the given [values], using them as titles. */ +fun listOptionOf(vararg values: String): ImmutableList { + return values.map { ListOption(it) }.toImmutableList() +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/MultipleSelectionDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/MultipleSelectionDialog.kt new file mode 100644 index 0000000..6fe8992 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/MultipleSelectionDialog.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.dialogs + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.toMutableStateList +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.components.list.CheckboxListItem +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.DialogPreview +import io.element.android.libraries.designsystem.theme.components.ListSupportingText +import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MultipleSelectionDialog( + options: ImmutableList, + onConfirmClick: (List) -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + confirmButtonTitle: String = stringResource(CommonStrings.action_confirm), + dismissButtonTitle: String = stringResource(CommonStrings.action_cancel), + title: String? = null, + subtitle: String? = null, + initialSelection: ImmutableList = persistentListOf(), +) { + val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let { + @Composable { + ListSupportingText( + text = it, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + BasicAlertDialog( + modifier = modifier, + onDismissRequest = onDismissRequest, + ) { + MultipleSelectionDialogContent( + title = title, + subtitle = decoratedSubtitle, + options = options, + confirmButtonTitle = confirmButtonTitle, + onConfirmClick = onConfirmClick, + dismissButtonTitle = dismissButtonTitle, + onDismissRequest = onDismissRequest, + initialSelected = initialSelection, + ) + } +} + +@Composable +private fun MultipleSelectionDialogContent( + options: ImmutableList, + confirmButtonTitle: String, + onConfirmClick: (List) -> Unit, + dismissButtonTitle: String, + onDismissRequest: () -> Unit, + title: String? = null, + initialSelected: ImmutableList = persistentListOf(), + subtitle: @Composable (() -> Unit)? = null, +) { + val selectedOptionIndexes = remember { initialSelected.toMutableStateList() } + + fun isSelected(index: Int) = selectedOptionIndexes.any { it == index } + + SimpleAlertDialogContent( + title = title, + subtitle = subtitle, + submitText = confirmButtonTitle, + onSubmitClick = { + onConfirmClick(selectedOptionIndexes.toList()) + }, + cancelText = dismissButtonTitle, + onCancelClick = onDismissRequest, + applyPaddingToContents = false, + ) { + LazyColumn { + itemsIndexed(options) { index, option -> + CheckboxListItem( + headline = option.title, + checked = isSelected(index), + onChange = { + if (isSelected(index)) { + selectedOptionIndexes.remove(index) + } else { + selectedOptionIndexes.add(index) + } + }, + supportingText = option.subtitle, + compactLayout = true, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + } +} + +@Preview(group = PreviewGroup.Dialogs) +@Composable +internal fun MultipleSelectionDialogContentPreview() { + ElementThemedPreview(showBackground = false) { + DialogPreview { + val options = persistentListOf( + ListOption("Option 1", "Supporting line text lorem ipsum dolor sit amet, consectetur."), + ListOption("Option 2"), + ListOption("Option 3"), + ) + MultipleSelectionDialogContent( + title = "Dialog title", + options = options, + onConfirmClick = {}, + onDismissRequest = {}, + confirmButtonTitle = "Save", + dismissButtonTitle = "Cancel", + initialSelected = persistentListOf(0), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun MultipleSelectionDialogPreview() = ElementPreview { + val options = persistentListOf( + ListOption("Option 1", "Supporting line text lorem ipsum dolor sit amet, consectetur."), + ListOption("Option 2"), + ListOption("Option 3"), + ) + MultipleSelectionDialog( + title = "Dialog title", + options = options, + onConfirmClick = {}, + onDismissRequest = {}, + confirmButtonTitle = "Save", + dismissButtonTitle = "Cancel", + initialSelection = persistentListOf(0), + ) +} 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 0000000..0b313e1 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.dialogs + +import androidx.compose.material3.BasicAlertDialog +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 io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.DialogPreview +import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RetryDialog( + content: String, + onRetry: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + title: String = RetryDialogDefaults.title, + retryText: String = RetryDialogDefaults.retryText, + dismissText: String = RetryDialogDefaults.dismissText, +) { + BasicAlertDialog(modifier = modifier, onDismissRequest = onDismiss) { + RetryDialogContent( + title = title, + content = content, + retryText = retryText, + dismissText = dismissText, + onRetry = onRetry, + onDismiss = onDismiss, + ) + } +} + +@Composable +private fun RetryDialogContent( + content: String, + onRetry: () -> Unit, + onDismiss: () -> Unit, + title: String = RetryDialogDefaults.title, + retryText: String = RetryDialogDefaults.retryText, + dismissText: String = RetryDialogDefaults.dismissText, +) { + SimpleAlertDialogContent( + title = title, + content = content, + submitText = retryText, + onSubmitClick = onRetry, + cancelText = dismissText, + onCancelClick = onDismiss, + ) +} + +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 RetryDialogContentPreview() { + ElementThemedPreview(showBackground = false) { + DialogPreview { + RetryDialogContent( + content = "Content", + onRetry = {}, + onDismiss = {}, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun RetryDialogPreview() = ElementPreview { + RetryDialog( + content = "Content", + onRetry = {}, + onDismiss = {}, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SaveChangesDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SaveChangesDialog.kt new file mode 100644 index 0000000..b722480 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SaveChangesDialog.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.dialogs + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun SaveChangesDialog( + onSubmitClick: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + title: String = stringResource(CommonStrings.dialog_unsaved_changes_title), + content: String = stringResource(CommonStrings.dialog_unsaved_changes_description_android), +) = ConfirmationDialog( + modifier = modifier, + title = title, + content = content, + onSubmitClick = onSubmitClick, + onDismiss = onDismiss, +) + +@PreviewsDayNight +@Composable +internal fun SaveChangesDialogPreview() = ElementPreview { + SaveChangesDialog( + onSubmitClick = {}, + onDismiss = {} + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SingleSelectionDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SingleSelectionDialog.kt new file mode 100644 index 0000000..8a64fce --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SingleSelectionDialog.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.dialogs + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.BasicAlertDialog +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.unit.dp +import io.element.android.libraries.designsystem.components.list.RadioButtonListItem +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.DialogPreview +import io.element.android.libraries.designsystem.theme.components.ListSupportingText +import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SingleSelectionDialog( + options: ImmutableList, + onSelectOption: (Int) -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + title: String? = null, + subtitle: String? = null, + dismissButtonTitle: String = stringResource(CommonStrings.action_cancel), + initialSelection: Int? = null, +) { + val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let { + @Composable { + ListSupportingText( + text = it, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + BasicAlertDialog( + modifier = modifier, + onDismissRequest = onDismissRequest, + ) { + SingleSelectionDialogContent( + title = title, + subtitle = decoratedSubtitle, + options = options, + onOptionClick = onSelectOption, + dismissButtonTitle = dismissButtonTitle, + onDismissRequest = onDismissRequest, + initialSelection = initialSelection, + ) + } +} + +@Composable +private fun SingleSelectionDialogContent( + options: ImmutableList, + onOptionClick: (Int) -> Unit, + dismissButtonTitle: String, + onDismissRequest: () -> Unit, + title: String? = null, + initialSelection: Int? = null, + subtitle: @Composable (() -> Unit)? = null, +) { + SimpleAlertDialogContent( + title = title, + subtitle = subtitle, + submitText = dismissButtonTitle, + onSubmitClick = onDismissRequest, + applyPaddingToContents = false, + ) { + LazyColumn { + itemsIndexed(options) { index, option -> + RadioButtonListItem( + headline = option.title, + supportingText = option.subtitle, + selected = index == initialSelection, + onSelect = { onOptionClick(index) }, + compactLayout = true, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + } +} + +@Preview(group = PreviewGroup.Dialogs) +@Composable +internal fun SingleSelectionDialogContentPreview() { + ElementPreview(showBackground = false) { + DialogPreview { + val options = persistentListOf( + ListOption("Option 1"), + ListOption("Option 2"), + ListOption("Option 3"), + ) + SingleSelectionDialogContent( + title = "Dialog title", + options = options, + onOptionClick = {}, + onDismissRequest = {}, + dismissButtonTitle = "Cancel", + initialSelection = 0 + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun SingleSelectionDialogPreview() = ElementPreview { + val options = persistentListOf( + ListOption("Option 1"), + ListOption("Option 2"), + ListOption("Option 3"), + ) + SingleSelectionDialog( + title = "Dialog title", + options = options, + onSelectOption = {}, + onDismissRequest = {}, + dismissButtonTitle = "Cancel", + initialSelection = 0 + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt new file mode 100644 index 0000000..aeaaa9f --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.dialogs + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.components.list.TextFieldListItem +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun TextFieldDialog( + title: String, + onSubmit: (String) -> Unit, + onDismissRequest: () -> Unit, + value: String?, + placeholder: String?, + modifier: Modifier = Modifier, + validation: (String?) -> Boolean = { true }, + onValidationErrorMessage: String? = null, + autoSelectOnDisplay: Boolean = true, + minLines: Int = 1, + maxLines: Int = minLines, + content: String? = null, + label: String? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + submitText: String = stringResource(CommonStrings.action_ok), + destructiveSubmit: Boolean = false, +) { + val focusRequester = remember { FocusRequester() } + var textFieldContents by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue( + value.orEmpty(), + selection = TextRange(value.orEmpty().length) + ) + ) + } + var error by rememberSaveable { mutableStateOf(if (!validation(value.orEmpty())) onValidationErrorMessage else null) } + var canRequestFocus by rememberSaveable { mutableStateOf(false) } + val canSubmit by remember { derivedStateOf { validation(textFieldContents.text) } } + ListDialog( + title = title, + onSubmit = { onSubmit(textFieldContents.text) }, + onDismissRequest = onDismissRequest, + enabled = canSubmit, + submitText = submitText, + destructiveSubmit = destructiveSubmit, + modifier = modifier, + ) { + if (content != null) { + item { + Text( + text = content, + style = ElementTheme.materialTypography.bodyMedium, + ) + } + } + item { + TextFieldListItem( + placeholder = placeholder.orEmpty(), + label = label, + text = textFieldContents, + onTextChange = { + error = if (!validation(it.text)) onValidationErrorMessage else null + textFieldContents = it + }, + error = error, + keyboardOptions = keyboardOptions, + keyboardActions = KeyboardActions(onAny = { + if (validation(textFieldContents.text)) { + onSubmit(textFieldContents.text) + } + }), + minLines = minLines, + maxLines = maxLines, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + ) + canRequestFocus = true + } + } + + if (autoSelectOnDisplay && canRequestFocus) { + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } +} + +@PreviewsDayNight +@Composable +internal fun TextFieldDialogPreview() = ElementPreview { + TextFieldDialog( + title = "Title", + value = "", + placeholder = "Placeholder", + onSubmit = {}, + onDismissRequest = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TextFieldDialogWithErrorPreview() = ElementPreview { + TextFieldDialog( + title = "Title", + content = "Some content", + onSubmit = {}, + validation = { false }, + onDismissRequest = {}, + value = "Value", + placeholder = "Placeholder", + label = "Label", + onValidationErrorMessage = "Error message", + ) +} 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 0000000..3911f0e --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/form/TextFieldLocalState.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 = + remember(stateValue) { mutableStateOf(stateValue) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/CheckboxListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/CheckboxListItem.kt new file mode 100644 index 0000000..4775b91 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/CheckboxListItem.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.list + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun CheckboxListItem( + headline: String, + checked: Boolean, + onChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + supportingText: String? = null, + trailingContent: ListItemContent? = null, + enabled: Boolean = true, + style: ListItemStyle = ListItemStyle.Default, + compactLayout: Boolean = false, +) { + ListItem( + modifier = modifier, + headlineContent = { Text(headline) }, + supportingContent = supportingText?.let { @Composable { Text(it) } }, + leadingContent = ListItemContent.Checkbox( + checked = checked, + enabled = enabled, + compact = compactLayout, + ), + trailingContent = trailingContent, + style = style, + enabled = enabled, + onClick = { onChange(!checked) }, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/ListItemContent.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/ListItemContent.kt new file mode 100644 index 0000000..022100a --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/ListItemContent.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.list + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.atomic.atoms.CounterAtom +import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Checkbox as CheckboxComponent +import io.element.android.libraries.designsystem.theme.components.Icon as IconComponent +import io.element.android.libraries.designsystem.theme.components.RadioButton as RadioButtonComponent +import io.element.android.libraries.designsystem.theme.components.Switch as SwitchComponent +import io.element.android.libraries.designsystem.theme.components.Text as TextComponent + +/** + * This is a helper to set default leading and trailing content for [ListItem]s. + */ +@Immutable +sealed interface ListItemContent { + /** + * Default Switch content for [ListItem]. + * @param checked The current state of the switch. + * @param enabled Whether the switch is enabled or not. + */ + data class Switch( + val checked: Boolean, + val enabled: Boolean = true + ) : ListItemContent + + /** + * Default Checkbox content for [ListItem]. + * @param checked The current state of the checkbox. + * @param enabled Whether the checkbox is enabled or not. + * @param compact Reduces the size of the component to make the wrapping [ListItem] smaller. + * This is especially useful when the [ListItem] is used inside a Dialog. `false` by default. + */ + data class Checkbox( + val checked: Boolean, + val enabled: Boolean = true, + val compact: Boolean = false + ) : ListItemContent + + /** + * Default RadioButton content for [ListItem]. + * @param selected The current state of the radio button. + * @param enabled Whether the radio button is enabled or not. + * @param compact Reduces the size of the component to make the wrapping [ListItem] smaller. + * This is especially useful when the [ListItem] is used inside a Dialog. `false` by default. + */ + data class RadioButton( + val selected: Boolean, + val enabled: Boolean = true, + val compact: Boolean = false + ) : ListItemContent + + /** + * Default Icon content for [ListItem]. Sets the Icon component to a predefined size. + * @param iconSource The icon to display, using [IconSource.getPainter]. + * @param tintColor The tint color for the icon, if any. Defaults to `null`. + */ + data class Icon(val iconSource: IconSource, val tintColor: Color? = null) : ListItemContent + + /** + * Default Text content for [ListItem]. Sets the Text component to a max size and clips overflow. + * @param text The text to display. + */ + data class Text(val text: String) : ListItemContent + + /** Displays any custom content. */ + data class Custom(val content: @Composable () -> Unit) : ListItemContent + + /** Displays a badge. */ + data object Badge : ListItemContent + + /** Displays a counter. */ + data class Counter(val count: Int) : ListItemContent + + @Composable + fun View(isItemEnabled: Boolean) { + when (this) { + is Switch -> SwitchComponent( + checked = checked, + onCheckedChange = null, + enabled = enabled && isItemEnabled, + ) + is Checkbox -> CheckboxComponent( + modifier = if (compact) Modifier.size(maxCompactSize) else Modifier, + checked = checked, + onCheckedChange = null, + enabled = enabled && isItemEnabled, + ) + is RadioButton -> RadioButtonComponent( + modifier = if (compact) Modifier.size(maxCompactSize) else Modifier, + selected = selected, + onClick = null, + enabled = enabled && isItemEnabled, + ) + is Icon -> { + IconComponent( + modifier = Modifier.size(maxCompactSize), + painter = iconSource.getPainter(), + contentDescription = iconSource.contentDescription, + tint = tintColor ?: LocalContentColor.current, + ) + } + is Text -> TextComponent(modifier = Modifier.widthIn(max = 128.dp), text = text, maxLines = 1, overflow = TextOverflow.Ellipsis) + is Badge -> Box( + modifier = Modifier.size(maxCompactSize), + contentAlignment = Alignment.Center, + ) { + RedIndicatorAtom() + } + is Counter -> { + CounterAtom(count = count) + } + is Custom -> content() + } + } +} + +private val maxCompactSize = DpSize(24.dp, 24.dp) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/MultipleSelectionListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/MultipleSelectionListItem.kt new file mode 100644 index 0000000..f314f17 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/MultipleSelectionListItem.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.list + +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.runtime.toMutableStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.components.dialogs.ListOption +import io.element.android.libraries.designsystem.components.dialogs.MultipleSelectionDialog +import io.element.android.libraries.designsystem.components.dialogs.listOptionOf +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Text +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun MultipleSelectionListItem( + headline: String, + options: ImmutableList, + onSelectionChange: (List) -> Unit, + resultFormatter: (List) -> String?, + modifier: Modifier = Modifier, + supportingText: String? = null, + leadingContent: ListItemContent? = null, + selected: ImmutableList = persistentListOf(), + displayResultInTrailingContent: Boolean = false, +) { + val selectedIndexes = remember(selected) { selected.toMutableStateList() } + val selectedItemsText by remember { derivedStateOf { resultFormatter(selectedIndexes) } } + + val decoratedSupportedText: @Composable (() -> Unit)? = when { + !selectedItemsText.isNullOrBlank() && !displayResultInTrailingContent -> { + @Composable { + Text(selectedItemsText!!) + } + } + supportingText != null -> { + @Composable { + Text(supportingText) + } + } + else -> null + } + + val trailingContent: ListItemContent? = if (!selectedItemsText.isNullOrBlank() && displayResultInTrailingContent) { + ListItemContent.Text(selectedItemsText!!) + } else { + null + } + + var displaySelectionDialog by rememberSaveable { mutableStateOf(false) } + + ListItem( + modifier = modifier, + headlineContent = { Text(text = headline) }, + supportingContent = decoratedSupportedText, + leadingContent = leadingContent, + trailingContent = trailingContent, + onClick = { displaySelectionDialog = true } + ) + + if (displaySelectionDialog) { + MultipleSelectionDialog( + title = headline, + options = options, + onConfirmClick = { newSelectedIndexes -> + if (newSelectedIndexes != selectedIndexes.toList()) { + onSelectionChange(newSelectedIndexes) + selectedIndexes.clear() + selectedIndexes.addAll(newSelectedIndexes) + } + displaySelectionDialog = false + }, + onDismissRequest = { displaySelectionDialog = false }, + initialSelection = selectedIndexes.toImmutableList(), + ) + } +} + +@Preview("Multiple selection List item - no selection", group = PreviewGroup.ListItems) +@Composable +internal fun MutipleSelectionListItemPreview() { + ElementThemedPreview { + val options = listOptionOf("Option 1", "Option 2", "Option 3") + MultipleSelectionListItem( + headline = "Headline", + options = options, + onSelectionChange = {}, + supportingText = "Supporting text", + resultFormatter = { result -> formatResult(result, options) }, + ) + } +} + +@Preview("Multiple selection List item - selection in supporting text", group = PreviewGroup.ListItems) +@Composable +internal fun MutipleSelectionListItemSelectedPreview() { + ElementThemedPreview { + val options = listOptionOf("Option 1", "Option 2", "Option 3") + val selected = persistentListOf(0, 2) + MultipleSelectionListItem( + headline = "Headline", + options = options, + onSelectionChange = {}, + supportingText = "Supporting text", + resultFormatter = { + val selectedValues = formatResult(it, options) + "Selected: $selectedValues" + }, + selected = selected, + ) + } +} + +@Preview("Multiple selection List item - selection in trailing content", group = PreviewGroup.ListItems) +@Composable +internal fun MutipleSelectionListItemSelectedTrailingContentPreview() { + ElementThemedPreview { + val options = listOptionOf("Option 1", "Option 2", "Option 3") + val selected = persistentListOf(0, 2) + MultipleSelectionListItem( + headline = "Headline", + options = options, + onSelectionChange = {}, + supportingText = "Supporting text", + resultFormatter = { selected.size.toString() }, + displayResultInTrailingContent = true, + selected = selected, + ) + } +} + +private fun formatResult(result: List, options: ImmutableList): String? { + return options.mapIndexedNotNull { index, value -> value.title.takeIf { result.contains(index) } }.joinToString(", ").takeIf { it.isNotEmpty() } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/RadioButtonListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/RadioButtonListItem.kt new file mode 100644 index 0000000..70fb91e --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/RadioButtonListItem.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.list + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun RadioButtonListItem( + headline: String, + selected: Boolean, + onSelect: () -> Unit, + modifier: Modifier = Modifier, + supportingText: String? = null, + trailingContent: ListItemContent? = null, + style: ListItemStyle = ListItemStyle.Default, + enabled: Boolean = true, + compactLayout: Boolean = false, +) { + ListItem( + modifier = modifier, + headlineContent = { Text(headline) }, + supportingContent = supportingText?.let { @Composable { Text(it) } }, + leadingContent = ListItemContent.RadioButton( + selected = selected, + enabled = enabled, + compact = compactLayout, + ), + trailingContent = trailingContent, + style = style, + enabled = enabled, + onClick = onSelect, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SingleSelectionListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SingleSelectionListItem.kt new file mode 100644 index 0000000..e07cda5 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SingleSelectionListItem.kt @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.list + +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.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.components.dialogs.ListOption +import io.element.android.libraries.designsystem.components.dialogs.SingleSelectionDialog +import io.element.android.libraries.designsystem.components.dialogs.listOptionOf +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Text +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds + +@Composable +fun SingleSelectionListItem( + headline: String, + options: ImmutableList, + onSelectionChange: (Int) -> Unit, + modifier: Modifier = Modifier, + supportingText: String? = null, + leadingContent: ListItemContent? = null, + resultFormatter: (Int) -> String? = { options.getOrNull(it)?.title }, + selected: Int? = null, + displayResultInTrailingContent: Boolean = false, +) { + val coroutineScope = rememberCoroutineScope() + + var selectedIndex by rememberSaveable(selected) { mutableStateOf(selected) } + val selectedItem by remember { derivedStateOf { selectedIndex?.let { resultFormatter(it) } } } + val decoratedSupportedText: @Composable (() -> Unit)? = if (!selectedItem.isNullOrBlank() && !displayResultInTrailingContent) { + @Composable { + Text(selectedItem!!) + } + } else { + supportingText?.let { + @Composable { + Text(it) + } + } + } + val trailingContent: ListItemContent? = if (!selectedItem.isNullOrBlank() && displayResultInTrailingContent) { + ListItemContent.Text(selectedItem!!) + } else { + null + } + + var displaySelectionDialog by rememberSaveable { mutableStateOf(false) } + + ListItem( + modifier = modifier, + headlineContent = { Text(text = headline) }, + supportingContent = decoratedSupportedText, + leadingContent = leadingContent, + trailingContent = trailingContent, + onClick = { displaySelectionDialog = true } + ) + + if (displaySelectionDialog) { + SingleSelectionDialog( + title = headline, + options = options, + onSelectOption = { index -> + if (index != selectedIndex) { + onSelectionChange(index) + selectedIndex = index + } + // Delay hiding the dialog for a bit so the new state is displayed in it before being dismissed + coroutineScope.launch { + delay(0.5.seconds) + displaySelectionDialog = false + } + }, + onDismissRequest = { displaySelectionDialog = false }, + initialSelection = selectedIndex, + ) + } +} + +@Preview("Single selection List item - no selection", group = PreviewGroup.ListItems) +@Composable +internal fun SingleSelectionListItemPreview() { + ElementThemedPreview { + SingleSelectionListItem( + headline = "Headline", + options = listOptionOf("Option 1", "Option 2", "Option 3"), + onSelectionChange = {}, + ) + } +} + +@Preview("Single selection List item - no selection, supporting text", group = PreviewGroup.ListItems) +@Composable +internal fun SingleSelectionListItemUnselectedWithSupportingTextPreview() { + ElementThemedPreview { + SingleSelectionListItem( + headline = "Headline", + options = listOptionOf("Option 1", "Option 2", "Option 3"), + supportingText = "Supporting text", + onSelectionChange = {}, + ) + } +} + +@Preview("Single selection List item - selection in supporting text", group = PreviewGroup.ListItems) +@Composable +internal fun SingleSelectionListItemSelectedInSupportingTextPreview() { + ElementThemedPreview { + SingleSelectionListItem( + headline = "Headline", + options = listOptionOf("Option 1", "Option 2", "Option 3"), + supportingText = "Supporting text", + onSelectionChange = {}, + selected = 1, + ) + } +} + +@Preview("Single selection List item - selection in trailing content", group = PreviewGroup.ListItems) +@Composable +internal fun SingleSelectionListItemSelectedInTrailingContentPreview() { + ElementThemedPreview { + SingleSelectionListItem( + headline = "Headline", + options = listOptionOf("Option 1", "Option 2", "Option 3"), + supportingText = "Supporting text", + onSelectionChange = {}, + selected = 1, + displayResultInTrailingContent = true, + ) + } +} + +@Preview("Single selection List item - custom formatter", group = PreviewGroup.ListItems) +@Composable +internal fun SingleSelectionListItemCustomFormattertPreview() { + ElementThemedPreview { + SingleSelectionListItem( + headline = "Headline", + options = listOptionOf("Option 1", "Option 2", "Option 3"), + supportingText = "Supporting text", + onSelectionChange = {}, + resultFormatter = { "Selected index: $it" }, + selected = 1, + displayResultInTrailingContent = true, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SwitchListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SwitchListItem.kt new file mode 100644 index 0000000..c5016bc --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SwitchListItem.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.list + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun SwitchListItem( + headline: String, + value: Boolean, + onChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + supportingText: String? = null, + leadingContent: ListItemContent? = null, + enabled: Boolean = true, + style: ListItemStyle = ListItemStyle.Default, +) { + ListItem( + modifier = modifier, + headlineContent = { Text(headline) }, + supportingContent = supportingText?.let { @Composable { Text(it) } }, + leadingContent = leadingContent, + trailingContent = ListItemContent.Switch( + checked = value, + enabled = enabled, + ), + style = style, + enabled = enabled, + onClick = { onChange(!value) }, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt new file mode 100644 index 0000000..e149af5 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.list + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.TextFieldValue +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.TextField +import io.element.android.libraries.designsystem.theme.components.TextFieldValidity + +@Composable +fun TextFieldListItem( + placeholder: String?, + text: String, + onTextChange: (String) -> Unit, + modifier: Modifier = Modifier, + error: String? = null, + minLines: Int = 1, + maxLines: Int = minLines, + label: String? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, +) { + TextField( + value = text, + onValueChange = { onTextChange(it) }, + placeholder = placeholder, + label = label, + validity = if (error != null) TextFieldValidity.Invalid else TextFieldValidity.None, + supportingText = error, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + maxLines = maxLines, + singleLine = maxLines == 1, + modifier = modifier, + ) +} + +@Composable +fun TextFieldListItem( + placeholder: String?, + text: TextFieldValue, + onTextChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + error: String? = null, + minLines: Int = 1, + maxLines: Int = minLines, + label: String? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, +) { + TextField( + value = text, + onValueChange = { onTextChange(it) }, + placeholder = placeholder, + label = label, + validity = if (error != null) TextFieldValidity.Invalid else TextFieldValidity.None, + supportingText = error, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + maxLines = maxLines, + minLines = minLines, + singleLine = maxLines == 1, + modifier = modifier, + ) +} + +@Preview("Text field List item - empty", group = PreviewGroup.ListItems) +@Composable +internal fun TextFieldListItemEmptyPreview() { + ElementThemedPreview { + TextFieldListItem( + placeholder = "Placeholder", + text = "", + onTextChange = {}, + ) + } +} + +@Preview("Text field List item - text", group = PreviewGroup.ListItems) +@Composable +internal fun TextFieldListItemPreview() { + ElementThemedPreview { + TextFieldListItem( + placeholder = "Placeholder", + text = "Text", + onTextChange = {}, + ) + } +} + +@Preview("Text field List item - textfieldvalue", group = PreviewGroup.ListItems) +@Composable +internal fun TextFieldListItemTextFieldValuePreview() { + ElementThemedPreview { + TextFieldListItem( + placeholder = "Placeholder", + text = TextFieldValue("Text field value"), + onTextChange = {}, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/DrawScopeWaveformExtensions.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/DrawScopeWaveformExtensions.kt new file mode 100644 index 0000000..14c9564 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/DrawScopeWaveformExtensions.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.media + +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.ImmutableList +import kotlin.math.max + +fun DrawScope.drawWaveform( + waveformData: ImmutableList, + canvasSizePx: Size, + brush: Brush, + minimumGraphAmplitude: Float = 2F, + lineWidth: Dp = 2.dp, + linePadding: Dp = 2.dp, +) { + val centerY = canvasSizePx.height / 2 + val cornerRadius = lineWidth / 2 + waveformData.forEachIndexed { index, amplitude -> + val drawingAmplitude = max(minimumGraphAmplitude, amplitude * (canvasSizePx.height - 2)) + drawRoundRect( + brush = brush, + topLeft = Offset( + x = index * (linePadding + lineWidth).toPx(), + y = centerY - drawingAmplitude / 2 + ), + size = Size( + width = lineWidth.toPx(), + height = drawingAmplitude + ), + cornerRadius = CornerRadius(cornerRadius.toPx(), cornerRadius.toPx()), + style = Fill + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveFormSamples.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveFormSamples.kt new file mode 100644 index 0000000..ddbbb9d --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveFormSamples.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.media + +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +object WaveFormSamples { + val allRangeWaveForm = List(100) { it.toFloat() / 100 }.toImmutableList() + + @Suppress("ktlint:standard:argument-list-wrapping") + val realisticWaveForm = persistentListOf( + 0.000f, 0.000f, 0.000f, 0.003f, 0.354f, + 0.353f, 0.365f, 0.790f, 0.787f, 0.167f, + 0.333f, 0.975f, 0.000f, 0.102f, 0.003f, + 0.531f, 0.584f, 0.317f, 0.140f, 0.475f, + 0.496f, 0.561f, 0.042f, 0.263f, 0.169f, + 0.829f, 0.349f, 0.010f, 0.000f, 0.000f, + 1.000f, 0.334f, 0.321f, 0.011f, 0.000f, + 0.000f, 0.003f, + ) + + val longRealisticWaveForm = List(4) { realisticWaveForm }.flatten().toImmutableList() +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt new file mode 100644 index 0000000..f91cf96 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.media + +import android.view.MotionEvent +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.RequestDisallowInterceptTouchEvent +import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlin.math.roundToInt + +private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F + +/** + * A view that displays a waveform and a cursor to indicate the current playback progress. + * + * @param playbackProgress The current playback progress, between 0 and 1. + * @param showCursor Whether to show the cursor or not. + * @param waveform The waveform to display. + * @param onSeek Callback when the user seeks the waveform. Called with a value between 0 and 1. + * @param modifier The modifier to be applied to the view. + * @param seekEnabled Whether the user can seek the waveform or not. + * @param brush The brush to use to draw the waveform. + * @param progressBrush The brush to use to draw the progress. + * @param cursorBrush The brush to use to draw the cursor. + * @param lineWidth The width of the waveform lines. + * @param linePadding The padding between waveform lines. + */ +@Composable +fun WaveformPlaybackView( + playbackProgress: Float, + showCursor: Boolean, + waveform: ImmutableList, + onSeek: (progress: Float) -> Unit, + modifier: Modifier = Modifier, + seekEnabled: Boolean = true, + brush: Brush = SolidColor(ElementTheme.colors.iconQuaternary), + progressBrush: Brush = SolidColor(ElementTheme.colors.iconSecondary), + cursorBrush: Brush = SolidColor(ElementTheme.colors.iconAccentTertiary), + lineWidth: Dp = 2.dp, + linePadding: Dp = 2.dp, +) { + val seekProgress = remember { mutableStateOf(null) } + var canvasSize by remember { mutableStateOf(DpSize(0.dp, 0.dp)) } + var canvasSizePx by remember { mutableStateOf(Size(0f, 0f)) } + val progress by remember(playbackProgress, seekProgress.value) { + derivedStateOf { + seekProgress.value ?: playbackProgress + } + } + val progressAnimated = animateFloatAsState(targetValue = progress, label = "progressAnimation") + val amplitudeDisplayCount by remember(canvasSize, lineWidth, linePadding) { + derivedStateOf { + (canvasSize.width.value / (lineWidth.value + linePadding.value)).toInt() + } + } + val normalizedWaveformData by remember(amplitudeDisplayCount) { + derivedStateOf { + waveform.normalisedData(amplitudeDisplayCount) + } + } + + val density = LocalDensity.current + val waveformWidthPx by remember { + derivedStateOf { with(density) { normalizedWaveformData.size * (lineWidth + linePadding).roundToPx().toFloat() } } + } + + val requestDisallowInterceptTouchEvent = remember { RequestDisallowInterceptTouchEvent() } + Canvas( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer(alpha = DEFAULT_GRAPHICS_LAYER_ALPHA) + .let { + if (!seekEnabled) return@let it + it.pointerInteropFilter(requestDisallowInterceptTouchEvent = requestDisallowInterceptTouchEvent) { e -> + return@pointerInteropFilter when (e.action) { + MotionEvent.ACTION_DOWN -> { + if (e.x in 0F..waveformWidthPx) { + requestDisallowInterceptTouchEvent.invoke(true) + seekProgress.value = e.x / waveformWidthPx + true + } else { + false + } + } + MotionEvent.ACTION_MOVE -> { + if (e.x in 0F..waveformWidthPx) { + seekProgress.value = e.x / waveformWidthPx + } + true + } + MotionEvent.ACTION_UP -> { + requestDisallowInterceptTouchEvent.invoke(false) + seekProgress.value?.let(onSeek) + seekProgress.value = null + true + } + else -> false + } + } + } + .then(modifier) + ) { + canvasSize = size.toDpSize() + canvasSizePx = size + val cornerRadius = lineWidth / 2 + // Calculate the size of the waveform by summing the width of all the lines and paddings + drawWaveform( + waveformData = normalizedWaveformData, + canvasSizePx = canvasSizePx, + brush = brush, + lineWidth = lineWidth, + linePadding = linePadding + ) + drawRect( + brush = progressBrush, + size = Size( + width = progressAnimated.value * waveformWidthPx, + height = canvasSizePx.height + ), + blendMode = BlendMode.SrcAtop + ) + if (showCursor || seekProgress.value != null) { + drawRoundRect( + brush = cursorBrush, + topLeft = Offset( + x = progressAnimated.value * waveformWidthPx, + y = 1f + ), + size = Size( + width = lineWidth.toPx(), + height = canvasSizePx.height - 2 + ), + cornerRadius = CornerRadius(cornerRadius.toPx(), cornerRadius.toPx()), + style = Fill + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun WaveformPlaybackViewPreview() = ElementPreview { + Column { + WaveformPlaybackView( + modifier = Modifier.height(34.dp), + showCursor = false, + playbackProgress = 0.5f, + onSeek = {}, + waveform = persistentListOf(), + ) + WaveformPlaybackView( + modifier = Modifier.height(34.dp), + showCursor = false, + playbackProgress = 0.5f, + onSeek = {}, + waveform = WaveFormSamples.realisticWaveForm, + ) + WaveformPlaybackView( + modifier = Modifier.height(34.dp), + showCursor = true, + playbackProgress = 0.5f, + onSeek = {}, + waveform = WaveFormSamples.allRangeWaveForm, + ) + } +} + +private fun ImmutableList.normalisedData(maxSamplesCount: Int): ImmutableList { + if (maxSamplesCount <= 0) { + return persistentListOf() + } + + // Filter the data to keep only the expected number of samples + val result = if (this.size > maxSamplesCount) { + (0.. + val targetIndex = (index.toDouble() * (this.count().toDouble() / maxSamplesCount.toDouble())).roundToInt() + this[targetIndex] + } + } else { + this + } + + return result.toImmutableList() +} 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 0000000..452c9d8 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCategory.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.ListSectionHeader + +@Composable +fun PreferenceCategory( + modifier: Modifier = Modifier, + title: String? = null, + showTopDivider: Boolean = true, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + ) { + if (title != null) { + ListSectionHeader( + title = title, + hasDivider = showTopDivider, + ) + } else if (showTopDivider) { + PreferenceDivider() + } + content() + } +} + +@Preview(group = PreviewGroup.Preferences) +@Composable +internal fun PreferenceCategoryPreview() = ElementThemedPreview { + PreferenceCategory( + title = "Category title", + ) { + PreferenceSwitch( + title = "Switch", + icon = CompoundIcons.Threads(), + isChecked = true, + onCheckedChange = {}, + ) + PreferenceSlide( + title = "Slide", + summary = "Summary", + value = 0.75F, + showIconAreaIfNoIcon = true, + onValueChange = {}, + ) + } +} 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 0000000..5277aca --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCheckbox.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.preferences + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Column +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 io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.components.preferenceIcon +import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.toEnabledColor +import io.element.android.libraries.designsystem.toSecondaryEnabledColor + +@Composable +fun PreferenceCheckbox( + title: String, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + supportingText: String? = null, + enabled: Boolean = true, + icon: ImageVector? = null, + @DrawableRes iconResourceId: Int? = null, + showIconAreaIfNoIcon: Boolean = false, +) { + ListItem( + modifier = modifier, + onClick = onCheckedChange.takeIf { enabled }?.let { { onCheckedChange(!isChecked) } }, + leadingContent = preferenceIcon( + icon = icon, + iconResourceId = iconResourceId, + enabled = enabled, + showIconAreaIfNoIcon = showIconAreaIfNoIcon, + ), + headlineContent = { + Text( + style = ElementTheme.typography.fontBodyLgRegular, + text = title, + color = enabled.toEnabledColor(), + ) + }, + supportingContent = supportingText?.let { + { + Text( + style = ElementTheme.typography.fontBodyMdRegular, + text = it, + color = enabled.toSecondaryEnabledColor(), + ) + } + }, + trailingContent = ListItemContent.Checkbox( + checked = isChecked, + enabled = enabled, + ), + enabled = enabled, + ) +} + +@Preview(group = PreviewGroup.Preferences) +@Composable +internal fun PreferenceCheckboxPreview() = ElementThemedPreview { + Column { + PreferenceCheckbox( + title = "Checkbox", + iconResourceId = CompoundDrawables.ic_compound_threads, + enabled = true, + isChecked = true, + onCheckedChange = {}, + ) + PreferenceCheckbox( + title = "Checkbox with supporting text", + supportingText = "Supporting text", + iconResourceId = CompoundDrawables.ic_compound_threads, + enabled = true, + isChecked = true, + onCheckedChange = {}, + ) + PreferenceCheckbox( + title = "Checkbox with supporting text", + supportingText = "Supporting text", + iconResourceId = CompoundDrawables.ic_compound_threads, + enabled = false, + isChecked = true, + onCheckedChange = {}, + ) + PreferenceCheckbox( + title = "Checkbox with supporting text", + supportingText = "Supporting text", + iconResourceId = null, + showIconAreaIfNoIcon = true, + enabled = true, + isChecked = true, + onCheckedChange = {}, + ) + PreferenceCheckbox( + title = "Checkbox with supporting text", + supportingText = "Supporting text", + iconResourceId = null, + showIconAreaIfNoIcon = false, + enabled = true, + isChecked = true, + onCheckedChange = {}, + ) + } +} 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 0000000..139aea3 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDivider.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.HorizontalDivider + +@Composable +fun PreferenceDivider( + modifier: Modifier = Modifier, +) { + HorizontalDivider(modifier = modifier) +} + +@Preview(group = PreviewGroup.Preferences) +@Composable +internal fun PreferenceDividerPreview() = ElementThemedPreview { + PreferenceDivider() +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDropdown.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDropdown.kt new file mode 100644 index 0000000..23fdec9 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDropdown.kt @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.libraries.designsystem.components.preferences + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.ExperimentalMaterial3Api +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.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.list.ListItemContent +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.DropdownMenu +import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.toEnabledColor +import io.element.android.libraries.designsystem.toSecondaryEnabledColor +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun PreferenceDropdown( + title: String, + selectedOption: T?, + options: ImmutableList, + onSelectOption: (T) -> Unit, + modifier: Modifier = Modifier, + supportingText: String? = null, + enabled: Boolean = true, + icon: ImageVector? = null, + @DrawableRes iconResourceId: Int? = null, + showIconAreaIfNoIcon: Boolean = false, +) { + var isDropdownExpanded by remember { mutableStateOf(false) } + ListItem( + modifier = modifier, + leadingContent = preferenceIcon( + icon = icon, + iconResourceId = iconResourceId, + enabled = enabled, + showIconAreaIfNoIcon = showIconAreaIfNoIcon, + ), + headlineContent = { + Text( + style = ElementTheme.typography.fontBodyLgRegular, + modifier = Modifier.fillMaxWidth(), + text = title, + color = enabled.toEnabledColor(), + ) + }, + supportingContent = supportingText?.let { + { + Text( + style = ElementTheme.typography.fontBodyMdRegular, + text = it, + color = enabled.toSecondaryEnabledColor(), + ) + } + }, + trailingContent = ListItemContent.Custom( + content = { + DropdownTrailingContent( + selectedOption = selectedOption, + options = options, + onSelectOption = onSelectOption, + expanded = isDropdownExpanded, + onExpandedChange = { isDropdownExpanded = it }, + modifier = Modifier.fillMaxSize(0.3f) + ) + } + ), + onClick = { isDropdownExpanded = true }.takeIf { !isDropdownExpanded }, + ) +} + +/** + * A dropdown option that can be used in a [PreferenceDropdown]. + */ +interface DropdownOption { + /** + * Returns the text to be displayed for this option. + */ + @Composable + fun getText(): String +} + +@Composable +private fun DropdownTrailingContent( + selectedOption: T?, + options: ImmutableList, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onSelectOption: (T) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + ) { + Text( + text = selectedOption?.getText().orEmpty(), + maxLines = 1, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = CompoundIcons.ChevronDown(), + contentDescription = null, + tint = ElementTheme.colors.iconSecondary, + ) + DropdownMenu( + expanded = expanded, + minWidth = 0.dp, + onDismissRequest = { onExpandedChange(false) }, + ) { + options.forEach { option -> + DropdownMenuItem( + text = { + Text( + text = option.getText(), + style = ElementTheme.typography.fontBodyMdRegular + ) + }, + trailingIcon = { + if (option == selectedOption) { + Icon( + imageVector = CompoundIcons.Check(), + contentDescription = null, + tint = ElementTheme.colors.iconAccentPrimary, + ) + } + }, + onClick = { + onSelectOption(option) + onExpandedChange(false) + }, + ) + } + } + } +} + +@Preview(group = PreviewGroup.Preferences) +@Composable +internal fun PreferenceDropdownPreview() = ElementThemedPreview { + val options = listOf( + object : DropdownOption { + @Composable + override fun getText(): String = "Option 1" + }, + object : DropdownOption { + @Composable + override fun getText(): String = "Option 2" + }, + object : DropdownOption { + @Composable + override fun getText(): String = "Option 3" + }, + ).toImmutableList() + + Column { + PreferenceDropdown( + title = "Dropdown", + supportingText = "Options for dropdown", + icon = CompoundIcons.Threads(), + selectedOption = null, + options = options, + onSelectOption = {}, + ) + PreferenceDropdown( + title = "Dropdown", + supportingText = "Options for dropdown", + icon = CompoundIcons.Threads(), + selectedOption = options.first(), + options = options, + onSelectOption = {}, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt new file mode 100644 index 0000000..24477b4 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.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.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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 + +@Composable +fun PreferencePage( + title: String, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + snackbarHost: @Composable () -> Unit = {}, + content: @Composable ColumnScope.() -> Unit, +) { + Scaffold( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + .imePadding(), + contentWindowInsets = WindowInsets.statusBars, + topBar = { + PreferenceTopAppBar( + title = title, + onBackClick = onBackClick, + ) + }, + snackbarHost = snackbarHost, + content = { + Column( + modifier = Modifier + .padding(it) + .consumeWindowInsets(it) + .verticalScroll(state = rememberScrollState()) + ) { + content() + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PreferenceTopAppBar( + title: String, + onBackClick: () -> Unit, +) { + TopAppBar( + navigationIcon = { + BackButton(onClick = onBackClick) + }, + title = { + Text( + modifier = Modifier.semantics { + heading() + }, + text = title, + style = ElementTheme.typography.aliasScreenTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + ) +} + +@PreviewsDayNight +@Composable +internal fun PreferencePagePreview() = ElementPreview { + PreferencePage( + title = "Preference screen", + onBackClick = {}, + ) { + PreferenceCategory( + title = "Category title", + ) { + PreferenceDivider() + PreferenceSwitch( + title = "Switch", + icon = CompoundIcons.Threads(), + isChecked = true, + onCheckedChange = {}, + ) + PreferenceDivider() + PreferenceCheckbox( + title = "Checkbox", + icon = CompoundIcons.Notifications(), + isChecked = true, + onCheckedChange = {}, + ) + PreferenceDivider() + PreferenceSlide( + title = "Slide", + summary = "Summary", + value = 0.75F, + showIconAreaIfNoIcon = true, + onValueChange = {}, + ) + } + } +} 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 0000000..efc3654 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceRow.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.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.ListItem +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, +) { + ListItem( + modifier = modifier, + headlineContent = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + content() + } + } + ) +} + +@Preview(group = PreviewGroup.Preferences) +@Composable +internal fun PreferenceRowPreview() = ElementThemedPreview { + PreferenceRow { + Text(text = "Content") + } +} 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 0000000..a509260 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSlide.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.preferences + +import androidx.annotation.DrawableRes +import androidx.annotation.FloatRange +import androidx.compose.foundation.layout.Column +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 io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +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.ListItem +import io.element.android.libraries.designsystem.theme.components.Slider +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun PreferenceSlide( + title: String, + @FloatRange(0.0, 1.0) + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + icon: ImageVector? = null, + @DrawableRes iconResourceId: Int? = null, + showIconAreaIfNoIcon: Boolean = false, + enabled: Boolean = true, + summary: String? = null, + steps: Int = 0, +) { + ListItem( + modifier = modifier, + enabled = enabled, + leadingContent = preferenceIcon( + icon = icon, + iconResourceId = iconResourceId, + enabled = enabled, + showIconAreaIfNoIcon = showIconAreaIfNoIcon, + ), + headlineContent = { + Column { + Text( + style = ElementTheme.typography.fontBodyLgRegular, + text = title, + ) + summary?.let { + Text( + style = ElementTheme.typography.fontBodyMdRegular, + text = summary, + ) + } + Slider( + value = value, + steps = steps, + onValueChange = onValueChange, + enabled = enabled, + ) + } + } + ) +} + +@Preview(group = PreviewGroup.Preferences) +@Composable +internal fun PreferenceSlidePreview() = ElementThemedPreview { + Column { + PreferenceSlide( + icon = CompoundIcons.UserProfile(), + title = "Slide", + summary = "Summary", + enabled = true, + value = 0.75F, + onValueChange = {}, + ) + PreferenceSlide( + icon = CompoundIcons.UserProfile(), + title = "Slide", + summary = "Summary", + enabled = false, + value = 0.75F, + onValueChange = {}, + ) + } +} 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 0000000..404a267 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.preferences + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Column +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 io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.list.ListItemContent +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.ListItem +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun PreferenceSwitch( + title: String, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + subtitle: String? = null, + enabled: Boolean = true, + icon: ImageVector? = null, + @DrawableRes iconResourceId: Int? = null, + showIconAreaIfNoIcon: Boolean = false, +) { + ListItem( + modifier = modifier, + enabled = enabled, + onClick = onCheckedChange.takeIf { enabled }?.let { { onCheckedChange(!isChecked) } }, + leadingContent = preferenceIcon( + icon = icon, + iconResourceId = iconResourceId, + enabled = enabled, + showIconAreaIfNoIcon = showIconAreaIfNoIcon, + ), + headlineContent = { + Text( + style = ElementTheme.typography.fontBodyLgRegular, + text = title, + ) + }, + supportingContent = subtitle?.let { + { + Text( + style = ElementTheme.typography.fontBodyMdRegular, + text = subtitle, + ) + } + }, + trailingContent = ListItemContent.Switch( + checked = isChecked, + enabled = enabled, + ) + ) +} + +@Preview(group = PreviewGroup.Preferences) +@Composable +internal fun PreferenceSwitchPreview() = ElementThemedPreview { + Column { + PreferenceSwitch( + title = "Switch", + subtitle = "Subtitle Switch", + icon = CompoundIcons.Threads(), + enabled = true, + isChecked = true, + onCheckedChange = {}, + ) + PreferenceSwitch( + title = "Switch", + subtitle = "Subtitle Switch", + icon = CompoundIcons.Threads(), + enabled = false, + isChecked = true, + onCheckedChange = {}, + ) + PreferenceSwitch( + title = "Switch no subtitle", + subtitle = null, + icon = CompoundIcons.Threads(), + enabled = false, + isChecked = true, + onCheckedChange = {}, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt new file mode 100644 index 0000000..c67b4c8 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.preferences + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +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 io.element.android.libraries.designsystem.components.dialogs.TextFieldDialog +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun PreferenceTextField( + headline: String, + onChange: (String?) -> Unit, + modifier: Modifier = Modifier, + placeholder: String? = null, + value: String? = null, + supportingText: String? = null, + displayValue: (String?) -> Boolean = { !it.isNullOrBlank() }, + trailingContent: ListItemContent? = null, + validation: (String?) -> Boolean = { true }, + onValidationErrorMessage: String? = null, + enabled: Boolean = true, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + style: ListItemStyle = ListItemStyle.Default, +) { + var displayTextFieldDialog by rememberSaveable { mutableStateOf(false) } + val valueToDisplay = if (displayValue(value)) value else supportingText + + ListItem( + modifier = modifier, + headlineContent = { Text(headline) }, + supportingContent = valueToDisplay?.let { @Composable { Text(it) } }, + trailingContent = trailingContent, + style = style, + enabled = enabled, + onClick = { displayTextFieldDialog = true } + ) + + if (displayTextFieldDialog) { + TextFieldDialog( + title = headline, + onSubmit = { + onChange(it.takeIf { it.isNotBlank() }) + displayTextFieldDialog = false + }, + onDismissRequest = { displayTextFieldDialog = false }, + placeholder = placeholder.orEmpty(), + value = value.orEmpty(), + validation = validation, + onValidationErrorMessage = onValidationErrorMessage, + keyboardOptions = keyboardOptions, + ) + } +} 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 0000000..c1bf2b9 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/components/ImageVectorProvider.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 { + override val values: Sequence + 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 0000000..b3e1227 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/components/PreferenceIcon.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.preferences.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +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.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.atomic.atoms.RedIndicatorAtom +import io.element.android.libraries.designsystem.components.list.ListItemContent +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.toIconSecondaryEnabledColor + +@Composable +fun preferenceIcon( + icon: ImageVector? = null, + @DrawableRes iconResourceId: Int? = null, + showIconBadge: Boolean = false, + tintColor: Color? = null, + enabled: Boolean = true, + showIconAreaIfNoIcon: Boolean = false, +): ListItemContent.Custom? { + return if (icon != null || iconResourceId != null || showIconAreaIfNoIcon) { + ListItemContent.Custom { + PreferenceIcon( + icon = icon, + iconResourceId = iconResourceId, + showIconBadge = showIconBadge, + enabled = enabled, + isVisible = showIconAreaIfNoIcon, + tintColor = tintColor, + ) + } + } else { + null + } +} + +@Composable +private fun PreferenceIcon( + modifier: Modifier = Modifier, + icon: ImageVector? = null, + @DrawableRes iconResourceId: Int? = null, + showIconBadge: Boolean = false, + tintColor: Color? = null, + enabled: Boolean = true, + isVisible: Boolean = true, +) { + if (icon != null || iconResourceId != null) { + Box(modifier = modifier) { + Icon( + imageVector = icon, + resourceId = iconResourceId, + contentDescription = null, + tint = tintColor ?: enabled.toIconSecondaryEnabledColor(), + modifier = Modifier + .size(24.dp), + ) + if (showIconBadge) { + RedIndicatorAtom( + modifier = Modifier + .align(Alignment.TopEnd) + ) + } + } + } else if (isVisible) { + Spacer(modifier = modifier.width(24.dp)) + } +} + +@Preview(group = PreviewGroup.Preferences) +@Composable +internal fun PreferenceIconPreview(@PreviewParameter(ImageVectorProvider::class) content: ImageVector?) = + ElementThemedPreview { + PreferenceIcon( + icon = content, + showIconBadge = false, + ) + } + +@Preview(group = PreviewGroup.Preferences) +@Composable +internal fun PreferenceIconWithBadgePreview(@PreviewParameter(ImageVectorProvider::class) content: ImageVector?) = + ElementThemedPreview { + PreferenceIcon( + icon = content, + showIconBadge = true, + ) + } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/tooltip/ElementTooltipDefaults.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/tooltip/ElementTooltipDefaults.kt new file mode 100644 index 0000000..b21c555 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/tooltip/ElementTooltipDefaults.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.tooltip + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TooltipDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupPositionProvider + +object ElementTooltipDefaults { + /** + * Creates a [PopupPositionProvider] that allows adding padding between the edge of the + * window and the tooltip. + * + * It is a wrapper around [TooltipDefaults.rememberPlainTooltipPositionProvider] and is + * designed for use with a [PlainTooltip]. + * + * @param spacingBetweenTooltipAndAnchor the spacing between the tooltip and the anchor. + * @param windowPadding the padding between the tooltip and the edge of the window. + * + * @return a [PopupPositionProvider]. + */ + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun rememberPlainTooltipPositionProvider( + spacingBetweenTooltipAndAnchor: Dp = 8.dp, + windowPadding: Dp = 12.dp, + ): PopupPositionProvider { + val windowPaddingPx = with(LocalDensity.current) { windowPadding.roundToPx() } + val plainTooltipPositionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider( + spacingBetweenTooltipAndAnchor = spacingBetweenTooltipAndAnchor, + ) + return remember(windowPaddingPx, plainTooltipPositionProvider) { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset = plainTooltipPositionProvider + .calculatePosition( + anchorBounds = anchorBounds, + windowSize = windowSize, + layoutDirection = layoutDirection, + popupContentSize = popupContentSize + ) + .let { + val maxX = windowSize.width - popupContentSize.width - windowPaddingPx + val maxY = windowSize.height - popupContentSize.height - windowPaddingPx + if (maxX <= windowPaddingPx || maxY <= windowPaddingPx) { + return@let it + } + IntOffset( + x = it.x.coerceIn( + minimumValue = windowPaddingPx, + maximumValue = maxX, + ), + y = it.y.coerceIn( + minimumValue = windowPaddingPx, + maximumValue = maxY, + ) + ) + } + } + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/tooltip/PlainTooltip.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/tooltip/PlainTooltip.kt new file mode 100644 index 0000000..f0fb57f --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/tooltip/PlainTooltip.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.tooltip + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.TooltipScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import io.element.android.compound.theme.ElementTheme +import androidx.compose.material3.PlainTooltip as M3PlainTooltip + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TooltipScope.PlainTooltip( + modifier: Modifier = Modifier, + contentColor: Color = ElementTheme.colors.textOnSolidPrimary, + containerColor: Color = ElementTheme.colors.bgActionPrimaryRest, + shape: Shape = TooltipDefaults.plainTooltipContainerShape, + content: @Composable () -> Unit, +) = M3PlainTooltip( + modifier = modifier, + contentColor = contentColor, + containerColor = containerColor, + shape = shape, + content = content, +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/tooltip/TooltipBox.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/tooltip/TooltipBox.kt new file mode 100644 index 0000000..3bca8b8 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/tooltip/TooltipBox.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.tooltip + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TooltipScope +import androidx.compose.material3.TooltipState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.material3.TooltipBox as M3TooltipBox + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TooltipBox( + positionProvider: PopupPositionProvider, + tooltip: @Composable TooltipScope.() -> Unit, + state: TooltipState, + modifier: Modifier = Modifier, + focusable: Boolean = true, + enableUserInput: Boolean = true, + content: @Composable () -> Unit, +) = M3TooltipBox( + positionProvider = positionProvider, + tooltip = tooltip, + state = state, + modifier = modifier, + focusable = focusable, + enableUserInput = enableUserInput, + content = content, +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/icons/CompoundDrawables.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/icons/CompoundDrawables.kt new file mode 100644 index 0000000..4aeba02 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/icons/CompoundDrawables.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.icons + +typealias CompoundDrawables = io.element.android.compound.R.drawable diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/icons/IconsList.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/icons/IconsList.kt new file mode 100644 index 0000000..f0a6fc8 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/icons/IconsList.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.icons + +import io.element.android.libraries.designsystem.R + +// This list and all the drawable it contains should be removed at some point. +// All the icons should be defined in Compound. +internal val iconsOther = listOf( + R.drawable.ic_notification, + R.drawable.ic_stop, + R.drawable.pin, + R.drawable.ic_winner, +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/icons/IconsPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/icons/IconsPreview.kt new file mode 100644 index 0000000..f40c4ea --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/icons/IconsPreview.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.icons + +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.layout.width +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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@PreviewsDayNight +@Composable +internal fun IconsOtherPreview() = ElementPreview { + IconsPreview( + title = "Other icons", + iconsList = iconsOther.toImmutableList(), + iconNameTransform = { name -> + name.removePrefix("ic_") + .replace("_", " ") + } + ) +} + +@Composable +private fun IconsPreview( + title: String, + iconsList: ImmutableList, + iconNameTransform: (String) -> String, +) = ElementPreview { + val context = LocalContext.current + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + modifier = Modifier.fillMaxWidth(), + style = ElementTheme.typography.fontHeadingSmMedium, + text = title, + textAlign = TextAlign.Center, + ) + iconsList.chunked(6).forEach { iconsRow -> + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + iconsRow.forEach { icon -> + Column( + modifier = Modifier.width(48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier.padding(2.dp), + resourceId = icon, + contentDescription = null, + ) + Text( + text = iconNameTransform( + context.resources + .getResourceEntryName(icon) + ), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodyXsMedium, + color = ElementTheme.colors.textSecondary, + ) + } + } + } + } + } +} 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 0000000..0d1bece --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/ApplyIf.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.modifiers + +import androidx.compose.ui.Modifier + +/** + * Applies the [ifTrue] modifier when the [condition] is true, [ifFalse] otherwise. + */ +fun Modifier.applyIf( + condition: Boolean, + ifTrue: Modifier.() -> Modifier, + ifFalse: (Modifier.() -> Modifier)? = null +): Modifier = this then when { + condition -> ifTrue(Modifier) + ifFalse != null -> ifFalse(Modifier) + else -> Modifier +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Blur.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Blur.kt new file mode 100644 index 0000000..43c07ad --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Blur.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.modifiers + +import android.graphics.BlurMaskFilter +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +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.platform.LocalView +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * @return true if the blur modifier is supported on the current OS version. + * + * The docs say the `blur` modifier is only supported on Android 12+: + * https://developer.android.com/reference/kotlin/androidx/compose/ui/Modifier#(androidx.compose.ui.Modifier).blur(androidx.compose.ui.unit.Dp,androidx.compose.ui.draw.BlurredEdgeTreatment) + * */ +@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) +fun canUseBlur(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + +@Composable +fun canUseBlurMaskFilter() = !LocalView.current.isHardwareAccelerated + +fun Modifier.blurredShapeShadow( + color: Color = Color.Black, + cornerRadius: Dp = 0.dp, + offsetX: Dp = 0.dp, + offsetY: Dp = 0.dp, + blurRadius: Dp = 0.dp, +) = drawBehind { + drawIntoCanvas { canvas -> + val path = Path().apply { + addRoundRect(RoundRect(Rect(Offset.Zero, size), CornerRadius(cornerRadius.toPx()))) + } + + // Draw the blurred shadow, then cut out the shape from it + 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, + ) + } + } +} + +fun Modifier.blurCompat( + radius: Dp, + edgeTreatment: BlurredEdgeTreatment = BlurredEdgeTreatment.Rectangle +): Modifier { + return when { + radius.value == 0f -> this + canUseBlur() -> blur(radius, edgeTreatment) + else -> this // Added in case we find a way to make this work on older devices + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/ClearFocusOnTap.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/ClearFocusOnTap.kt new file mode 100644 index 0000000..c9ab328 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/ClearFocusOnTap.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.modifiers + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.input.pointer.pointerInput + +fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier = pointerInput(Unit) { + detectTapGestures(onTap = { + focusManager.clearFocus() + }) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Clickable.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Clickable.kt new file mode 100644 index 0000000..7350c54 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Clickable.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.modifiers + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp + +fun Modifier.clickableIfNotNull(onClick: (() -> Unit)? = null): Modifier = this.then( + if (onClick != null) { + Modifier.clickable { onClick() } + } else { + Modifier + } +) + +fun Modifier.niceClickable( + onClick: () -> Unit, +): Modifier { + return clip(RoundedCornerShape(4.dp)) + .clickable { onClick() } + .padding(horizontal = 4.dp) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/CornerBorder.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/CornerBorder.kt new file mode 100644 index 0000000..ad42616 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/CornerBorder.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.modifiers + +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import io.element.android.libraries.designsystem.text.toPx + +/** + * Draw a border on corners around the content. + */ +@Suppress("ModifierComposed") +fun Modifier.cornerBorder( + strokeWidth: Dp, + color: Color, + cornerSizeDp: Dp, +) = composed( + factory = { + val strokeWidthPx = strokeWidth.toPx() + val cornerSize = cornerSizeDp.toPx() + drawWithContent { + drawContent() + val width = size.width + val height = size.height + drawPath( + path = Path().apply { + // Top left corner + moveTo(0f, cornerSize) + lineTo(0f, 0f) + lineTo(cornerSize, 0f) + // Top right corner + moveTo(width - cornerSize, 0f) + lineTo(width, 0f) + lineTo(width, cornerSize) + // Bottom right corner + moveTo(width, height - cornerSize) + lineTo(width, height) + lineTo(width - cornerSize, height) + // Bottom left corner + moveTo(cornerSize, height) + lineTo(0f, height) + lineTo(0f, height - cornerSize) + }, + color = color, + style = Stroke( + width = strokeWidthPx, + pathEffect = PathEffect.cornerPathEffect(strokeWidthPx / 2), + cap = StrokeCap.Round, + ), + ) + } + } +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/FadingEdge.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/FadingEdge.kt new file mode 100644 index 0000000..72167a1 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/FadingEdge.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.modifiers + +import androidx.compose.animation.animateColorAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer + +@Composable +fun horizontalFadingEdgesBrush( + showLeft: Boolean, + showRight: Boolean, + percent: Float = 0.1f, +): Brush { + val leftColor by animateColorAsState( + targetValue = if (showLeft) Color.Transparent else Color.White, + label = "AnimateLeftColor", + ) + val rightColor by animateColorAsState( + targetValue = if (showRight) Color.Transparent else Color.White, + label = "AnimateRightColor", + ) + return Brush.horizontalGradient( + 0f to leftColor, + percent to Color.White, + 1f - percent to Color.White, + 1f to rightColor + ) +} + +fun Modifier.fadingEdge(brush: Brush) = this + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .drawWithContent { + drawContent() + drawRect(brush = brush, blendMode = BlendMode.DstIn) + } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Gradient.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Gradient.kt new file mode 100644 index 0000000..115c567 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Gradient.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.modifiers + +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.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.colors.gradientSubtleColors +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +/** + * Ref: https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Workspaces-V1?node-id=1141-24692 + */ +@Stable +@Composable +fun Modifier.backgroundVerticalGradient( + isVisible: Boolean = true, +): Modifier { + if (!isVisible) return this + return background( + brush = Brush.verticalGradient( + colors = gradientSubtleColors(), + ), + ) +} + +@PreviewsDayNight +@Composable +internal fun BackgroundVerticalGradientPreview() = ElementPreview { + Box( + modifier = Modifier + .fillMaxWidth() + .height(height = 100.dp) + .backgroundVerticalGradient() + ) +} + +@PreviewsDayNight +@Composable +internal fun BackgroundVerticalGradientDisabledPreview() = ElementPreview { + Box( + modifier = Modifier + .fillMaxWidth() + .height(height = 100.dp) + .backgroundVerticalGradient( + isVisible = false, + ) + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Keyboard.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Keyboard.kt new file mode 100644 index 0000000..8d961ec --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Keyboard.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.modifiers + +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type + +/** + * Modifier to handle Shift + F10 key events. + * This is typically used to trigger context menus in desktop applications. + * + * @param action The callback to invoke when Shift + F10 is pressed. + */ +fun Modifier.onKeyboardContextMenuAction( + action: (() -> Unit)?, +): Modifier = then( + if (action == null) { + Modifier + } else { + Modifier.onKeyEvent { keyEvent -> + // invoke the callback when the user presses Shift + F10 + if (keyEvent.type == KeyEventType.KeyUp && + keyEvent.isShiftPressed && + keyEvent.key == Key.F10) { + action() + true + } else { + false + } + } + } +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/OnTabOrEnterKeyFocusNext.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/OnTabOrEnterKeyFocusNext.kt new file mode 100644 index 0000000..08cf1a0 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/OnTabOrEnterKeyFocusNext.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.modifiers + +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusManager +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 + +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 + } +} 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 0000000..e315f69 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/RoundedBackground.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/modifiers/SquareSizeModifier.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/SquareSizeModifier.kt new file mode 100644 index 0000000..a7dae1e --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/SquareSizeModifier.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.modifiers + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.runtime.Stable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.LayoutModifier +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.platform.InspectorValueInfo +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreview +import kotlin.math.max +import kotlin.math.min + +/** + * Makes the content square in size. + * + * This is achieved by cropping incoming max constraints to the largest possible square size + * and measuring the content using resulting constraints. + * Next the size of layout is decided based on largest dimension of the measured content. + * Finally the content is placed inside the square layout according to specified [position]. + * + * If no square exists that falls within the size range of the incoming constraints, + * the content will be laid out as usual, as if the modifier was not applied. + * + * @param position The fraction of the content's position inside its square layout. + * It determines the point on the axis that was extended to make a square. + * Typically you'd want to use values between `0` and `1`, inclusive, where `0` + * will place the content at the "start" of the square, `0.5` in the middle, and `1` at the "end". + */ +@Stable +fun Modifier.squareSize( + position: Float = 0.5f, +): Modifier = + this.then( + when { + position == 0.5f -> SquareSizeCenter + else -> createSquareSizeModifier(position = position) + } + ) + +private val SquareSizeCenter = createSquareSizeModifier(position = 0.5f) + +private class SquareSizeModifier( + private val position: Float, + inspectorInfo: InspectorInfo.() -> Unit, +) : LayoutModifier, InspectorValueInfo(inspectorInfo) { + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + val maxSquare = min(constraints.maxWidth, constraints.maxHeight) + val minSquare = max(constraints.minWidth, constraints.minHeight) + val squareExists = minSquare <= maxSquare + + val resolvedConstraints = constraints + .takeUnless { squareExists } + ?: constraints.copy(maxWidth = maxSquare, maxHeight = maxSquare) + + val placeable = measurable.measure(resolvedConstraints) + + return if (squareExists) { + val size = max(placeable.width, placeable.height) + layout(size, size) { + val x = ((size - placeable.width) * position).toInt() + val y = ((size - placeable.height) * position).toInt() + placeable.placeRelative(x, y) + } + } else { + layout(placeable.width, placeable.height) { + placeable.placeRelative(0, 0) + } + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + if (other !is SquareSizeModifier) return false + + if (position != other.position) return false + + return true + } + + override fun hashCode(): Int { + return position.hashCode() + } +} + +@Suppress("ModifierFactoryExtensionFunction", "ModifierFactoryReturnType") +private fun createSquareSizeModifier( + position: Float, +) = + SquareSizeModifier( + position = position, + inspectorInfo = debugInspectorInfo { + name = "squareSize" + properties["position"] = position + }, + ) + +@Preview +@Composable +internal fun SquareSizeModifierLargeWidthPreview() { + ElementPreview { + Box( + modifier = Modifier + .padding(32.dp) + .background(Color.Gray) + .squareSize(position = 0.25f) + ) { + Box( + modifier = Modifier + .background(Color.Black) + .size(100.dp, 10.dp) + ) + } + } +} + +@Preview +@Composable +internal fun SquareSizeModifierLargeHeightPreview() { + ElementPreview { + Box( + modifier = Modifier + .padding(32.dp) + .background(Color.Gray) + .squareSize(position = 0.75f) + ) { + Box( + modifier = Modifier + .background(Color.Black) + .size(10.dp, 100.dp) + ) + } + } +} + +@Preview +@Composable +internal fun SquareSizeModifierInsideSquarePreview() { + ElementPreview { + Box( + modifier = Modifier + .padding(32.dp) + .size(120.dp) + .background(Color.Gray), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .background(Color.Black) + .width(100.dp) + .squareSize(position = 0.75f) + ) + } + } +} 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 0000000..b0ef41e --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreview.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.preview + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.res.ResourcesCompat +import coil3.annotation.ExperimentalCoilApi +import coil3.asImage +import coil3.compose.AsyncImagePreviewHandler +import coil3.compose.LocalAsyncImagePreviewHandler +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.utils.CommonDrawables + +@OptIn(ExperimentalCoilApi::class) +@Composable +@Suppress("ModifierMissing") +fun ElementPreview( + darkTheme: Boolean = isSystemInDarkTheme(), + showBackground: Boolean = true, + @DrawableRes + drawableFallbackForImages: Int = CommonDrawables.sample_background, + content: @Composable () -> Unit +) { + val context = LocalContext.current + CompositionLocalProvider( + LocalAsyncImagePreviewHandler provides AsyncImagePreviewHandler { + ResourcesCompat.getDrawable(context.resources, drawableFallbackForImages, null)!!.asImage() + } + ) { + 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/ElementPreviewDark.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreviewDark.kt new file mode 100644 index 0000000..c054b31 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreviewDark.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.preview + +import androidx.compose.runtime.Composable + +@Composable +fun ElementPreviewDark( + showBackground: Boolean = true, + content: @Composable () -> Unit +) { + ElementPreview( + darkTheme = true, + showBackground = showBackground, + content = content + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreviewLight.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreviewLight.kt new file mode 100644 index 0000000..1c2bdf3 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreviewLight.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.preview + +import androidx.compose.runtime.Composable + +@Composable +fun ElementPreviewLight( + showBackground: Boolean = true, + content: @Composable () -> Unit +) { + ElementPreview( + darkTheme = false, + showBackground = showBackground, + content = content + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementThemedPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementThemedPreview.kt new file mode 100644 index 0000000..7b29757 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementThemedPreview.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.preview + +import androidx.annotation.DrawableRes +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.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.utils.CommonDrawables + +@Composable +@Suppress("ModifierMissing") +fun ElementThemedPreview( + showBackground: Boolean = true, + vertical: Boolean = true, + @DrawableRes + drawableFallbackForImages: Int = CommonDrawables.sample_background, + content: @Composable () -> Unit, +) { + Box( + modifier = Modifier + .background(Color.Gray) + .padding(4.dp) + ) { + if (vertical) { + Column { + ElementPreview( + darkTheme = false, + showBackground = showBackground, + drawableFallbackForImages = drawableFallbackForImages, + content = content, + ) + Spacer(modifier = Modifier.height(4.dp)) + ElementPreview( + darkTheme = true, + showBackground = showBackground, + drawableFallbackForImages = drawableFallbackForImages, + content = content + ) + } + } else { + Row { + ElementPreview( + darkTheme = false, + showBackground = showBackground, + drawableFallbackForImages = drawableFallbackForImages, + content = content, + ) + Spacer(modifier = Modifier.width(4.dp)) + ElementPreview( + darkTheme = true, + showBackground = showBackground, + drawableFallbackForImages = drawableFallbackForImages, + content = content + ) + } + } + } +} 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 0000000..0f40021 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewGroup.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.preview + +@Suppress("ktlint:standard:property-naming") +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 ListItems = "List items" + const val ListSections = "List sections" + const val Menus = "Menus" + const val Preferences = "Preferences" + const val Progress = "Progress Indicators" + const val Search = "Search views" + const val Snackbars = "Snackbars" + 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/preview/PreviewWithLargeHeight.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewWithLargeHeight.kt new file mode 100644 index 0000000..163ee21 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewWithLargeHeight.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 PreviewWithLargeHeight diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewsDayNight.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewsDayNight.kt new file mode 100644 index 0000000..4b084b1 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewsDayNight.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 = "Night" + +/** + * 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 = "Day" + +/** + * 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, + fontScale = 1f, +) +@Preview( + name = NIGHT_MODE_NAME, + uiMode = Configuration.UI_MODE_NIGHT_YES, + fontScale = 1f, +) +annotation class PreviewsDayNight diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/SheetState.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/SheetState.kt new file mode 100644 index 0000000..d14d614 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/SheetState.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.preview + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun sheetStateForPreview() = SheetState( + skipPartiallyExpanded = true, + initialValue = SheetValue.Expanded, + density = LocalDensity.current, +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/WithFontScale.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/WithFontScale.kt new file mode 100644 index 0000000..5967b66 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/WithFontScale.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.preview + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density + +/** + * Showkase does not take into account the `fontScale` parameter of the Preview annotation, so alter the + * LocalDensity in the CompositionLocalProvider. + */ +@Composable +fun WithFontScale(fontScale: Float, content: @Composable () -> Unit) { + CompositionLocalProvider( + LocalDensity provides Density( + density = LocalDensity.current.density, + fontScale = fontScale + ) + ) { + content() + } +} 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 0000000..f88a5a6 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ruler/HorizontalRuler.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +/** + * 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) + ) +} + +@PreviewsDayNight +@Composable +internal fun HorizontalRulerPreview() = ElementPreview { + 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 0000000..171a290 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ruler/VerticalRuler.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +/** + * 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) + ) +} + +@PreviewsDayNight +@Composable +internal fun VerticalRulerPreview() = ElementPreview { + 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 0000000..1248c76 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ruler/WithRulers.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ButtonSize +import io.element.android.libraries.designsystem.theme.components.OutlinedButton + +/** + * 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()) + } + } + } + } + ) +} + +@PreviewsDayNight +@Composable +internal fun WithRulersPreview() = ElementPreview { + WithRulers(xRulersOffset = 20.dp, yRulersOffset = 15.dp) { + OutlinedButton( + text = "A Button with rulers on it!", + size = ButtonSize.Medium, + onClick = {}, + ) + } +} 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 0000000..abb420a --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/showkase/DesignSystemShowkaseRootModule.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..418c1d1 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/swipe/SwipeableActionsState.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.FloatState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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: FloatState get() = offsetState + private var offsetState = mutableFloatStateOf(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.floatValue + delta + val isAllowed = isResettingOnRelease || targetOffset > 0f + + offsetState.floatValue += if (isAllowed) delta else 0f + } + + suspend fun resetOffset() { + draggableState.drag(MutatePriority.PreventUserInput) { + isResettingOnRelease = true + try { + Animatable(offsetState.floatValue).animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = 300), + ) { + dragBy(value - offsetState.floatValue) + } + } 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 0000000..43fd80a --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/AnnotatedStrings.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.text + +import android.graphics.Typeface +import android.text.SpannedString +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.compound.theme.LinkColor + +fun String.toAnnotatedString(): AnnotatedString = buildAnnotatedString { + append(this@toAnnotatedString) + val spannable = SpannedString.valueOf(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 + * @param tagAndLink an optional pair of tag and link to add to the styled part of the string, as StringAnnotation + */ +@Composable +fun buildAnnotatedStringWithStyledPart( + @StringRes fullTextRes: Int, + @StringRes coloredTextRes: Int, + color: Color = LinkColor, + underline: Boolean = true, + bold: Boolean = false, + tagAndLink: Pair? = null, +) = 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, + ) + if (tagAndLink != null) { + addStringAnnotation( + tag = tagAndLink.first, + annotation = tagAndLink.second, + start = startIndex, + end = startIndex + coloredPart.length + ) + } +} + +/** + * Convert a string to an [AnnotatedString] with colored end period if present. + */ +fun withColoredPeriod( + text: String, +) = buildAnnotatedString { + append(text) + if (text.endsWith(".")) { + addStyle( + style = SpanStyle( + // Light.colorGreen700 + color = Color(0xff0bc491), + ), + start = text.length - 1, + end = text.length, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/DpScale.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/DpScale.kt new file mode 100644 index 0000000..3fe720e --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/DpScale.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.text + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.WithFontScale +import io.element.android.libraries.designsystem.theme.components.Text + +/** + * Return the maximum value between the receiver value and the value with fontScale applied. + * So if fontScale is >= 1f, the same value is returned, and if fontScale is < 1f, so returned value + * will be smaller. + */ +@Composable +@ReadOnlyComposable +fun Dp.applyScaleDown(): Dp = with(LocalDensity.current) { + return this@applyScaleDown * fontScale.coerceAtMost(1f) +} + +/** + * Return the minimum value between the receiver value and the value with fontScale applied. + * So if fontScale is <= 1f, the same value is returned, and if fontScale is > 1f, so returned value + * will be bigger. + */ +@Composable +@ReadOnlyComposable +fun Dp.applyScaleUp(): Dp = with(LocalDensity.current) { + return this@applyScaleUp * fontScale.coerceAtLeast(1f) +} + +@Preview +@Composable +internal fun DpScale_0_75f_Preview() = WithFontScale(0.75f) { + ElementPreviewLight { + val fontSizeInDp = 16.dp + Column( + modifier = Modifier.padding(4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = "Text with size of 16.sp", + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSizeInDp.toSp()) + ) + Text( + text = "Text with the same size (applyScaleUp)", + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSizeInDp.applyScaleUp().toSp()) + ) + Text( + text = "Text with a smaller size (applyScaleDown)", + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSizeInDp.applyScaleDown().toSp()) + ) + } + } +} + +@Preview +@Composable +internal fun DpScale_1_0f_Preview() = WithFontScale(1f) { + ElementPreviewLight { + val fontSizeInDp = 16.dp + Column( + modifier = Modifier.padding(4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = "Text with size of 16.sp", + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSizeInDp.toSp()) + ) + Text( + text = "Text with the same size (applyScaleUp)", + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSizeInDp.applyScaleUp().toSp()) + ) + Text( + text = "Text with the same size (applyScaleDown)", + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSizeInDp.applyScaleDown().toSp()) + ) + } + } +} + +@Preview +@Composable +internal fun DpScale_1_5f_Preview() = WithFontScale(1.5f) { + ElementPreviewLight { + val fontSizeInDp = 16.dp + Column( + modifier = Modifier.padding(4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = "Text with size of 16.sp", + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSizeInDp.toSp()) + ) + Text( + text = "Text with a bigger size (applyScaleUp)", + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSizeInDp.applyScaleUp().toSp()) + ) + Text( + text = "Text with the same size (applyScaleDown)", + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSizeInDp.applyScaleDown().toSp()) + ) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/TextSyleToTypeface.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/TextSyleToTypeface.kt new file mode 100644 index 0000000..6261c88 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/TextSyleToTypeface.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.text + +import android.graphics.Typeface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalFontFamilyResolver +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.FontSynthesis +import androidx.compose.ui.text.font.FontWeight + +@Composable +fun TextStyle.rememberTypeface(): State { + val resolver: FontFamily.Resolver = LocalFontFamilyResolver.current + @Suppress("UNCHECKED_CAST") + return remember(resolver, this) { + resolver.resolve( + fontFamily = fontFamily, + fontWeight = fontWeight ?: FontWeight.Normal, + fontStyle = fontStyle ?: FontStyle.Normal, + fontSynthesis = fontSynthesis ?: FontSynthesis.All, + ) + } as State +} 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 0000000..d0c7074 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/UnitConverters.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.text + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +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 +@ReadOnlyComposable +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 +@ReadOnlyComposable +fun TextUnit.toDp(): Dp = with(LocalDensity.current) { toDp() } + +/** + * Convert Px value to Dp, regarding current density. + */ +@Composable +@ReadOnlyComposable +fun Int.toDp(): Dp = with(LocalDensity.current) { toDp() } + +/** + * Convert Dp value to pixels, regarding current density. + */ +@Composable +@ReadOnlyComposable +fun Dp.toPx(): Float = with(LocalDensity.current) { toPx() } + +/** + * Convert Dp value to pixels, regarding current density. + */ +@Composable +@ReadOnlyComposable +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 0000000..06827fb --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import io.element.android.compound.annotations.CoreColorToken +import io.element.android.compound.previews.ColorListPreview +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.SemanticColors +import io.element.android.compound.tokens.generated.internal.DarkColorTokens +import io.element.android.compound.tokens.generated.internal.LightColorTokens +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import kotlinx.collections.immutable.persistentMapOf + +/** + * Room list. + */ +val SemanticColors.roomListRoomName + get() = textPrimary + +val SemanticColors.roomListRoomMessage + get() = textSecondary + +val SemanticColors.roomListRoomMessageDate + get() = textSecondary + +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 +@OptIn(CoreColorToken::class) +val SemanticColors.messageFromMeBackground + get() = if (isLight) LightColorTokens.colorGray400 else DarkColorTokens.colorGray500 + +// This color is not present in Semantic color, so put hard-coded value for now +@OptIn(CoreColorToken::class) +val SemanticColors.messageFromOtherBackground + get() = if (isLight) LightColorTokens.colorGray300 else DarkColorTokens.colorGray400 + +// This color is not present in Semantic color, so put hard-coded value for now +@OptIn(CoreColorToken::class) +val SemanticColors.progressIndicatorTrackColor + get() = if (isLight) LightColorTokens.colorAlphaGray500 else DarkColorTokens.colorAlphaGray500 + +// This color is not present in Semantic color, so put hard-coded value for now +@OptIn(CoreColorToken::class) +val SemanticColors.bgSubtleTertiary + get() = if (isLight) LightColorTokens.colorGray100 else DarkColorTokens.colorGray100 + +// Temporary color, which is not in the token right now +val SemanticColors.temporaryColorBgSpecial + get() = if (isLight) Color(0xFFE4E8F0) else Color(0xFF3A4048) + +// This color is not present in Semantic color, so put hard-coded value for now +@OptIn(CoreColorToken::class) +val SemanticColors.pinDigitBg + get() = if (isLight) LightColorTokens.colorGray300 else DarkColorTokens.colorGray400 + +@OptIn(CoreColorToken::class) +val SemanticColors.pinnedMessageBannerIndicator + get() = if (isLight) LightColorTokens.colorAlphaGray600 else DarkColorTokens.colorAlphaGray600 + +@OptIn(CoreColorToken::class) +val SemanticColors.pinnedMessageBannerBorder + get() = if (isLight) LightColorTokens.colorAlphaGray400 else DarkColorTokens.colorAlphaGray400 + +@PreviewsDayNight +@Composable +internal fun ColorAliasesPreview() = ElementPreview { + ColorListPreview( + backgroundColor = Color.Black, + foregroundColor = Color.White, + colors = persistentMapOf( + "roomListRoomName" to ElementTheme.colors.roomListRoomName, + "roomListRoomMessage" to ElementTheme.colors.roomListRoomMessage, + "roomListRoomMessageDate" to ElementTheme.colors.roomListRoomMessageDate, + "unreadIndicator" to ElementTheme.colors.unreadIndicator, + "placeholderBackground" to ElementTheme.colors.placeholderBackground, + "messageFromMeBackground" to ElementTheme.colors.messageFromMeBackground, + "messageFromOtherBackground" to ElementTheme.colors.messageFromOtherBackground, + "progressIndicatorTrackColor" to ElementTheme.colors.progressIndicatorTrackColor, + "temporaryColorBgSpecial" to ElementTheme.colors.temporaryColorBgSpecial, + ) + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementThemeApp.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementThemeApp.kt new file mode 100644 index 0000000..7aa0ab7 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementThemeApp.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme + +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.theme.Theme +import io.element.android.compound.theme.isDark +import io.element.android.compound.theme.mapToTheme +import io.element.android.compound.tokens.generated.SemanticColors +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.preferences.api.store.AppPreferencesStore + +val LocalBuildMeta = staticCompositionLocalOf { + BuildMeta( + isDebuggable = true, + buildType = BuildType.DEBUG, + applicationName = "MyApp", + productionApplicationName = "MyAppProd", + desktopApplicationName = "MyAppDesktop", + applicationId = "AppId", + isEnterpriseBuild = false, + lowPrivacyLoggingEnabled = false, + versionName = "aVersion", + versionCode = 123, + gitRevision = "aRevision", + gitBranchName = "aBranch", + flavorDescription = "aFlavor", + flavorShortDescription = "aFlavorShort", + ) +} + +/** + * Theme to use for all the regular screens of the application. + * Will manage the light / dark theme based on the user preference. + * Will also ensure that the system is applying the correct global theme + * to the application, especially when the system is light and the application + * is forced to use dark theme. + */ +@Composable +fun ElementThemeApp( + appPreferencesStore: AppPreferencesStore, + compoundLight: SemanticColors, + compoundDark: SemanticColors, + buildMeta: BuildMeta, + content: @Composable () -> Unit, +) { + val theme by remember { + appPreferencesStore.getThemeFlow().mapToTheme() + } + .collectAsState(initial = Theme.System) + LaunchedEffect(theme) { + AppCompatDelegate.setDefaultNightMode( + when (theme) { + Theme.System -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + Theme.Light -> AppCompatDelegate.MODE_NIGHT_NO + Theme.Dark -> AppCompatDelegate.MODE_NIGHT_YES + } + ) + } + CompositionLocalProvider( + LocalBuildMeta provides buildMeta, + ) { + ElementTheme( + darkTheme = theme.isDark(), + content = content, + compoundLight = compoundLight, + compoundDark = compoundDark, + ) + } +} 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 0000000..6c3296d --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ElementTypography.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..750e4d0 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/TypographyAliases.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme + +import androidx.compose.ui.text.TextStyle +import io.element.android.compound.tokens.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/AlertDialogContent.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt new file mode 100644 index 0000000..ddc235b --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt @@ -0,0 +1,553 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +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.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +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.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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import kotlin.math.max + +// Figma designs: https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?type=design&node-id=911%3A343492&mode=design&t=jeyd1bXKOOx8y10r-1 + +@Composable +internal fun SimpleAlertDialogContent( + content: String, + submitText: String, + onSubmitClick: () -> Unit, + title: String? = null, + subtitle: @Composable (() -> Unit)? = null, + destructiveSubmit: Boolean = false, + cancelText: String? = null, + onCancelClick: () -> Unit = {}, + thirdButtonText: String? = null, + onThirdButtonClick: () -> Unit = {}, + applyPaddingToContents: Boolean = true, + icon: @Composable (() -> Unit)? = null, +) { + SimpleAlertDialogContent( + icon = icon, + title = title, + subtitle = subtitle, + content = { + Text( + text = content, + style = ElementTheme.materialTypography.bodyMedium, + ) + }, + submitText = submitText, + destructiveSubmit = destructiveSubmit, + onSubmitClick = onSubmitClick, + cancelText = cancelText, + onCancelClick = onCancelClick, + thirdButtonText = thirdButtonText, + onThirdButtonClick = onThirdButtonClick, + applyPaddingToContents = applyPaddingToContents, + ) +} + +@Composable +internal fun SimpleAlertDialogContent( + submitText: String, + onSubmitClick: () -> Unit, + title: String? = null, + subtitle: @Composable (() -> Unit)? = null, + destructiveSubmit: Boolean = false, + cancelText: String? = null, + onCancelClick: () -> Unit = {}, + thirdButtonText: String? = null, + onThirdButtonClick: () -> Unit = {}, + applyPaddingToContents: Boolean = true, + enabled: Boolean = true, + icon: @Composable (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + 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( + modifier = Modifier.testTag(TestTags.dialogNeutral), + text = thirdButtonText, + size = ButtonSize.Medium, + onClick = onThirdButtonClick, + ) + } + if (cancelText != null) { + TextButton( + modifier = Modifier.testTag(TestTags.dialogNegative), + text = cancelText, + size = ButtonSize.Medium, + onClick = onCancelClick, + ) + Button( + modifier = Modifier.testTag(TestTags.dialogPositive), + text = submitText, + enabled = enabled, + size = ButtonSize.Medium, + onClick = onSubmitClick, + destructive = destructiveSubmit, + ) + } else { + TextButton( + modifier = Modifier.testTag(TestTags.dialogPositive), + text = submitText, + enabled = enabled, + size = ButtonSize.Medium, + onClick = onSubmitClick, + destructive = destructiveSubmit, + ) + } + } + }, + title = title?.let { titleText -> + @Composable { + Text( + text = titleText, + style = ElementTheme.typography.fontHeadingSmMedium, + textAlign = if (icon != null) TextAlign.Center else TextAlign.Start, + ) + } + }, + subtitle = subtitle, + content = content, + shape = DialogContentDefaults.shape, + containerColor = DialogContentDefaults.containerColor, + iconContentColor = DialogContentDefaults.iconContentColor, + titleContentColor = DialogContentDefaults.titleContentColor, + textContentColor = DialogContentDefaults.textContentColor, + tonalElevation = 0.dp, + 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 = ElementTheme.colors.textPrimary, + applyPaddingToContents = applyPaddingToContents, + ) +} + +/** + * Copy of M3's `AlertDialogContent` so we can use it for previews. + */ +@Suppress("ContentTrailingLambda") +@Composable +internal fun AlertDialogContent( + buttons: @Composable () -> Unit, + icon: (@Composable () -> Unit)?, + title: (@Composable () -> Unit)?, + subtitle: @Composable (() -> Unit)?, + content: @Composable (() -> Unit)?, + shape: Shape, + containerColor: Color, + tonalElevation: Dp, + buttonContentColor: Color, + iconContentColor: Color, + titleContentColor: Color, + textContentColor: Color, + applyPaddingToContents: Boolean = true, +) { + Surface( + shape = shape, + color = containerColor, + tonalElevation = tonalElevation, + ) { + Column( + modifier = Modifier.padding( + if (applyPaddingToContents) { + // We can just apply the same padding to the whole dialog contents + DialogContentDefaults.externalPadding + } else { + // We should only apply vertical padding in this case, every component will apply the horizontal content individually + DialogContentDefaults.externalVerticalPadding + } + ) + ) { + icon?.let { + CompositionLocalProvider(LocalContentColor provides iconContentColor) { + Box( + Modifier + .then(if (applyPaddingToContents) Modifier else Modifier.padding(DialogContentDefaults.externalHorizontalPadding)) + .padding(DialogContentDefaults.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 + .then( + if (applyPaddingToContents) { + Modifier + } else { + Modifier.padding(DialogContentDefaults.externalHorizontalPadding) + } + ) + .padding(DialogContentDefaults.titlePadding) + .align( + if (icon == null) { + Alignment.Start + } else { + Alignment.CenterHorizontally + } + ) + ) { + title() + } + } + } + } + subtitle?.invoke() + content?.let { + CompositionLocalProvider(LocalContentColor provides textContentColor) { + val textStyle = MaterialTheme.typography.bodyMedium + ProvideTextStyle(textStyle) { + Box( + Modifier + .weight(weight = 1f, fill = false) + // We don't apply padding here if it wasn't applied to the root component, this allows us to have a full width content + .padding(DialogContentDefaults.textPadding) + .align(Alignment.Start) + ) { + content() + } + } + } + } + Box( + modifier = Modifier + .then(if (applyPaddingToContents) Modifier else Modifier.padding(DialogContentDefaults.externalHorizontalPadding)) + .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 +private fun AlertDialogFlowRow( + mainAxisSpacing: Dp, + crossAxisSpacing: Dp, + content: @Composable () -> Unit +) { + Layout(content) { measurables, constraints -> + val sequences = mutableListOf>() + val crossAxisSizes = mutableListOf() + val crossAxisPositions = mutableListOf() + + var mainAxisSpace = 0 + var crossAxisSpace = 0 + + val currentSequence = mutableListOf() + 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() + } + // Ensures that confirming actions appear above dismissive actions. + sequences.add(0, 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.End + val mainAxisPositions = IntArray(childrenMainAxisSizes.size) { 0 } + with(arrangement) { + arrange( + mainAxisLayoutSize, + childrenMainAxisSizes, + layoutDirection, + mainAxisPositions + ) + } + placeables.forEachIndexed { j, placeable -> + placeable.place( + x = mainAxisPositions[j], + y = crossAxisPositions[i] + ) + } + } + } + } +} + +@Composable +internal fun DialogPreview(content: @Composable () -> Unit) { + Box( + modifier = Modifier + .background(ElementTheme.materialColors.onSurfaceVariant) + .sizeIn(minWidth = DialogMinWidth, maxWidth = DialogMaxWidth) + .padding(20.dp), + propagateMinConstraints = true + ) { + content() + } +} + +internal object DialogContentDefaults { + private val externalPaddingDp = 24.dp + val shape = RoundedCornerShape(12.dp) + val externalPadding = PaddingValues(all = externalPaddingDp) + val externalHorizontalPadding = PaddingValues(horizontal = externalPaddingDp) + val externalVerticalPadding = PaddingValues(vertical = externalPaddingDp) + val titlePadding = PaddingValues(bottom = 16.dp) + val iconPadding = PaddingValues(bottom = 8.dp) + val textPadding = PaddingValues(bottom = 16.dp) + + val containerColor: Color + @Composable + @ReadOnlyComposable + get() = ElementTheme.colors.bgCanvasDefault + + val textContentColor: Color + @Composable + @ReadOnlyComposable + get() = ElementTheme.materialColors.onSurfaceVariant + + val titleContentColor: Color + @Composable + @ReadOnlyComposable + get() = ElementTheme.materialColors.onSurface + + val iconContentColor: Color + @Composable + @ReadOnlyComposable + get() = ElementTheme.materialColors.primary +} + +// Paddings for each of the dialog's parts. Taken from M3 source code. +internal val ButtonsMainAxisSpacing = 8.dp +internal val ButtonsCrossAxisSpacing = 12.dp + +internal val DialogMinWidth = 280.dp +internal val DialogMaxWidth = 560.dp + +@Preview(group = PreviewGroup.Dialogs, name = "Dialog with title, icon and ok button") +@Composable +@Suppress("MaxLineLength") +internal fun DialogWithTitleIconAndOkButtonPreview() { + ElementThemedPreview(showBackground = false) { + DialogPreview { + SimpleAlertDialogContent( + icon = { + Icon( + imageVector = CompoundIcons.NotificationsSolid(), + contentDescription = null + ) + }, + title = "Dialog Title", + content = "A dialog is a type of modal window that appears in front of app content to provide critical information," + + " or prompt for a decision to be made. Learn more", + submitText = "OK", + onSubmitClick = {}, + ) + } + } +} + +@Preview(group = PreviewGroup.Dialogs, name = "Dialog with title and ok button") +@Composable +@Suppress("MaxLineLength") +internal fun DialogWithTitleAndOkButtonPreview() { + ElementThemedPreview(showBackground = false) { + DialogPreview { + SimpleAlertDialogContent( + title = "Dialog Title", + content = "A dialog is a type of modal window that appears in front of app content to provide critical information," + + " or prompt for a decision to be made. Learn more", + submitText = "OK", + onSubmitClick = {}, + ) + } + } +} + +@Preview(group = PreviewGroup.Dialogs, name = "Dialog with only message and ok button") +@Composable +@Suppress("MaxLineLength") +internal fun DialogWithOnlyMessageAndOkButtonPreview() { + ElementThemedPreview(showBackground = false) { + DialogPreview { + SimpleAlertDialogContent( + content = "A dialog is a type of modal window that appears in front of app content to provide critical information," + + " or prompt for a decision to be made. Learn more", + submitText = "OK", + onSubmitClick = {}, + ) + } + } +} + +@Preview(group = PreviewGroup.Dialogs, name = "Dialog with destructive button") +@Composable +internal fun DialogWithDestructiveButtonPreview() { + ElementThemedPreview(showBackground = false) { + DialogPreview { + SimpleAlertDialogContent( + title = "Dialog Title", + content = "A dialog with a destructive action", + cancelText = "Cancel", + submitText = "Delete", + destructiveSubmit = true, + onSubmitClick = {}, + ) + } + } +} + +@Preview(group = PreviewGroup.Dialogs, name = "Dialog with third button") +@Composable +internal fun DialogWithThirdButtonPreview() { + ElementThemedPreview(showBackground = false) { + DialogPreview { + SimpleAlertDialogContent( + title = "Dialog Title", + content = "A dialog with a third button", + cancelText = "Cancel", + submitText = "Delete", + thirdButtonText = "Other", + onSubmitClick = {}, + ) + } + } +} + +@Preview(group = PreviewGroup.Dialogs, name = "Dialog with a very long title") +@Composable +@Suppress("MaxLineLength") +internal fun DialogWithVeryLongTitlePreview() { + ElementThemedPreview(showBackground = false) { + DialogPreview { + SimpleAlertDialogContent( + title = "Dialog Title that takes more than one line", + content = "A dialog is a type of modal window that appears in front of app content to provide critical information," + + " or prompt for a decision to be made. Learn more", + submitText = "OK", + onSubmitClick = {}, + ) + } + } +} + +@Preview(group = PreviewGroup.Dialogs, name = "Dialog with a very long title and icon") +@Composable +@Suppress("MaxLineLength") +internal fun DialogWithVeryLongTitleAndIconPreview() { + ElementThemedPreview(showBackground = false) { + DialogPreview { + SimpleAlertDialogContent( + icon = { + Icon( + imageVector = CompoundIcons.NotificationsSolid(), + contentDescription = null + ) + }, + title = "Dialog Title that takes more than one line", + content = "A dialog is a type of modal window that appears in front of app content to provide critical information," + + " or prompt for a decision to be made. Learn more", + submitText = "OK", + onSubmitClick = {}, + ) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/BottomSheetDragHandle.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/BottomSheetDragHandle.kt new file mode 100644 index 0000000..3f8ca01 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/BottomSheetDragHandle.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +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.RectangleShape +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +@Composable +fun BottomSheetDragHandle( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .height(36.dp) + .background(Color.Transparent) + .fillMaxWidth() + .clip(RectangleShape), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .requiredHeight(72.dp) + .offset(y = 18.dp) + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.surface) + .border(0.5.dp, ElementTheme.colors.borderDisabled, MaterialTheme.shapes.extraLarge) + ) + + Box( + modifier = Modifier + .width(32.dp) + .height(4.dp) + .background(ElementTheme.colors.iconQuaternary, RoundedCornerShape(2.dp)) + ) + } +} + +@PreviewsDayNight +@Composable +internal fun BottomSheetDragHandlePreview() = ElementPreview { + BottomSheetDragHandle() +} 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 0000000..721350e --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/BottomSheetScaffold.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 = MaterialTheme.colorScheme.surface, + 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 0000000..676d34e --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt @@ -0,0 +1,557 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.ColumnScope +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.progressSemantics +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +// Designs: https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?type=design&mode=design&t=U03tOFZz5FSLVUMa-1 + +// Horizontal padding for button with low padding +internal val lowHorizontalPaddingValue = 4.dp + +@Composable +fun Button( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + size: ButtonSize = ButtonSize.Large, + showProgress: Boolean = false, + destructive: Boolean = false, + leadingIcon: IconSource? = null, +) = ButtonInternal( + text = text, + onClick = onClick, + style = ButtonStyle.Filled, + modifier = modifier, + enabled = enabled, + size = size, + showProgress = showProgress, + destructive = destructive, + leadingIcon = leadingIcon +) + +@Composable +fun OutlinedButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + size: ButtonSize = ButtonSize.Large, + showProgress: Boolean = false, + destructive: Boolean = false, + leadingIcon: IconSource? = null, +) = ButtonInternal( + text = text, + onClick = onClick, + style = ButtonStyle.Outlined, + modifier = modifier, + enabled = enabled, + size = size, + showProgress = showProgress, + destructive = destructive, + leadingIcon = leadingIcon +) + +@Composable +fun TextButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + size: ButtonSize = ButtonSize.Large, + showProgress: Boolean = false, + destructive: Boolean = false, + leadingIcon: IconSource? = null, +) = ButtonInternal( + text = text, + onClick = onClick, + style = ButtonStyle.Text, + modifier = modifier, + enabled = enabled, + size = size, + showProgress = showProgress, + destructive = destructive, + leadingIcon = leadingIcon +) + +@Composable +fun InvisibleButton( + modifier: Modifier = Modifier, + size: ButtonSize = ButtonSize.Large, +) { + Spacer(modifier = modifier.height(size.toMinHeight())) +} + +@Composable +private fun ButtonInternal( + text: String, + onClick: () -> Unit, + style: ButtonStyle, + modifier: Modifier = Modifier, + destructive: Boolean = false, + colors: ButtonColors = style.getColors(destructive), + enabled: Boolean = true, + size: ButtonSize = ButtonSize.Large, + showProgress: Boolean = false, + leadingIcon: IconSource? = null, +) { + val minHeight = size.toMinHeight() + val hasStartDrawable = showProgress || leadingIcon != null + + val contentPadding = when (size) { + ButtonSize.Small -> { + if (hasStartDrawable) { + PaddingValues(start = 8.dp, top = 5.dp, end = 16.dp, bottom = 5.dp) + } else { + PaddingValues(start = 16.dp, top = 5.dp, end = 16.dp, bottom = 5.dp) + } + } + ButtonSize.Medium -> when (style) { + ButtonStyle.Filled, + ButtonStyle.Outlined -> if (hasStartDrawable) { + PaddingValues(start = 16.dp, top = 10.dp, end = 24.dp, bottom = 10.dp) + } else { + PaddingValues(start = 24.dp, top = 10.dp, end = 24.dp, bottom = 10.dp) + } + ButtonStyle.Text -> if (hasStartDrawable) { + PaddingValues(start = 12.dp, top = 10.dp, end = 16.dp, bottom = 10.dp) + } else { + PaddingValues(start = 12.dp, top = 10.dp, end = 12.dp, bottom = 10.dp) + } + } + ButtonSize.MediumLowPadding -> PaddingValues(horizontal = lowHorizontalPaddingValue, vertical = 10.dp) + ButtonSize.Large -> when (style) { + ButtonStyle.Filled, + ButtonStyle.Outlined -> if (hasStartDrawable) { + PaddingValues(start = 24.dp, top = 13.dp, end = 32.dp, bottom = 13.dp) + } else { + PaddingValues(start = 32.dp, top = 13.dp, end = 32.dp, bottom = 13.dp) + } + ButtonStyle.Text -> if (hasStartDrawable) { + PaddingValues(start = 12.dp, top = 13.dp, end = 16.dp, bottom = 13.dp) + } else { + PaddingValues(start = 16.dp, top = 13.dp, end = 16.dp, bottom = 13.dp) + } + } + ButtonSize.LargeLowPadding -> PaddingValues(horizontal = lowHorizontalPaddingValue, vertical = 13.dp) + } + + val shape = when (style) { + ButtonStyle.Filled, + ButtonStyle.Outlined -> RoundedCornerShape(percent = 50) + ButtonStyle.Text -> RectangleShape + } + + val border = when (style) { + ButtonStyle.Filled -> null + ButtonStyle.Outlined -> BorderStroke( + width = 1.dp, + color = if (destructive) { + ElementTheme.colors.borderCriticalPrimary.copy( + alpha = if (enabled) 1f else 0.5f + ) + } else { + ElementTheme.colors.borderInteractiveSecondary + } + ) + ButtonStyle.Text -> null + } + + androidx.compose.material3.Button( + onClick = { + if (!showProgress) { + onClick() + } + }, + modifier = modifier.heightIn(min = minHeight), + enabled = enabled, + shape = shape, + colors = colors, + elevation = null, + border = border, + contentPadding = contentPadding, + interactionSource = remember { MutableInteractionSource() }, + ) { + when { + showProgress -> { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(20.dp), + color = LocalContentColor.current, + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + leadingIcon != null -> { + androidx.compose.material.Icon( + painter = leadingIcon.getPainter(), + contentDescription = null, + tint = LocalContentColor.current, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + } + } + Text( + text = text, + style = ElementTheme.typography.fontBodyLgMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private fun ButtonSize.toMinHeight() = when (this) { + ButtonSize.Small -> 32.dp + ButtonSize.Medium, + ButtonSize.MediumLowPadding -> 40.dp + ButtonSize.Large, + ButtonSize.LargeLowPadding -> 48.dp +} + +@Immutable +sealed interface IconSource { + val contentDescription: String? + + data class Resource(val id: Int, override val contentDescription: String? = null) : IconSource + data class Vector(val vector: ImageVector, override val contentDescription: String? = null) : IconSource + + @Composable + fun getPainter(): Painter = when (this) { + is Resource -> rememberVectorPainter(image = ImageVector.vectorResource(id)) + is Vector -> rememberVectorPainter(image = vector) + } +} + +enum class ButtonSize { + Small, + Medium, + + /** + * Like [Medium] but with minimal horizontal padding, so that large texts have less risk to get truncated. + * To be used for instance for button with weight which ensures a maximal width. + */ + MediumLowPadding, + Large, + + /** + * Like [Large] but with minimal horizontal padding, so that large texts have less risk to get truncated. + * To be used for instance for button with weight which ensures a maximal width. + */ + LargeLowPadding, +} + +internal enum class ButtonStyle { + Filled, + Outlined, + Text; + + @Composable + fun getColors(destructive: Boolean): ButtonColors = when (this) { + Filled -> ButtonDefaults.buttonColors( + containerColor = getPrimaryColor(destructive), + contentColor = ElementTheme.materialColors.onPrimary, + disabledContainerColor = if (destructive) { + ElementTheme.colors.bgCriticalPrimary.copy(alpha = 0.5f) + } else { + ElementTheme.colors.bgActionPrimaryDisabled + }, + disabledContentColor = ElementTheme.colors.textOnSolidPrimary + ) + Outlined -> ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = getPrimaryColor(destructive), + disabledContainerColor = Color.Transparent, + disabledContentColor = getDisabledContentColor(destructive), + ) + Text -> ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = if (destructive) { + ElementTheme.colors.textCriticalPrimary + } else { + if (LocalContentColor.current.isSpecified) LocalContentColor.current else ElementTheme.colors.textPrimary + }, + disabledContainerColor = Color.Transparent, + disabledContentColor = getDisabledContentColor(destructive), + ) + } + + @Composable + private fun getPrimaryColor(destructive: Boolean): Color { + return if (destructive) { + ElementTheme.colors.bgCriticalPrimary + } else { + ElementTheme.materialColors.primary + } + } + + @Composable + private fun getDisabledContentColor(destructive: Boolean): Color { + return if (destructive) { + ElementTheme.colors.textCriticalPrimary.copy(alpha = 0.5f) + } else { + ElementTheme.colors.textDisabled + } + } +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun FilledButtonSmallPreview() { + ButtonCombinationPreview( + style = ButtonStyle.Filled, + size = ButtonSize.Small, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun FilledButtonMediumPreview() { + ButtonCombinationPreview( + style = ButtonStyle.Filled, + size = ButtonSize.Medium, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun FilledButtonMediumLowPaddingPreview() { + ButtonCombinationPreview( + style = ButtonStyle.Filled, + size = ButtonSize.MediumLowPadding, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun FilledButtonLargePreview() { + ButtonCombinationPreview( + style = ButtonStyle.Filled, + size = ButtonSize.Large, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun FilledButtonLargeLowPaddingPreview() { + ButtonCombinationPreview( + style = ButtonStyle.Filled, + size = ButtonSize.LargeLowPadding, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun OutlinedButtonSmallPreview() { + ButtonCombinationPreview( + style = ButtonStyle.Outlined, + size = ButtonSize.Small, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun OutlinedButtonMediumPreview() { + ButtonCombinationPreview( + style = ButtonStyle.Outlined, + size = ButtonSize.Medium, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun OutlinedButtonMediumLowPaddingPreview() { + ButtonCombinationPreview( + style = ButtonStyle.Outlined, + size = ButtonSize.MediumLowPadding, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun OutlinedButtonLargePreview() { + ButtonCombinationPreview( + style = ButtonStyle.Outlined, + size = ButtonSize.Large, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun OutlinedButtonLargeLowPaddingPreview() { + ButtonCombinationPreview( + style = ButtonStyle.Outlined, + size = ButtonSize.LargeLowPadding, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun TextButtonSmallPreview() { + ButtonCombinationPreview( + style = ButtonStyle.Text, + size = ButtonSize.Small, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun TextButtonMediumPreview() { + ButtonCombinationPreview( + style = ButtonStyle.Text, + size = ButtonSize.Medium, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun TextButtonMediumLowPaddingPreview() { + ButtonCombinationPreview( + style = ButtonStyle.Text, + size = ButtonSize.MediumLowPadding, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun TextButtonLargePreview() { + ButtonCombinationPreview( + style = ButtonStyle.Text, + size = ButtonSize.Large, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun TextButtonLargeLowPaddingPreview() { + ButtonCombinationPreview( + style = ButtonStyle.Text, + size = ButtonSize.LargeLowPadding, + ) +} + +@Composable +private fun ButtonCombinationPreview( + style: ButtonStyle, + size: ButtonSize, +) { + ElementThemedPreview { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .padding(16.dp) + .width(IntrinsicSize.Max), + ) { + ButtonMatrixPreview(style = style, size = size, destructive = false) + ButtonMatrixPreview(style = style, size = size, destructive = true) + } + } +} + +@Composable +private fun ColumnScope.ButtonMatrixPreview( + style: ButtonStyle, + size: ButtonSize, + destructive: Boolean, +) { + // Normal + ButtonRowPreview( + style = style, + size = size, + destructive = destructive, + ) + // With icon + ButtonRowPreview( + leadingIcon = IconSource.Vector(CompoundIcons.ShareAndroid()), + style = style, + size = size, + destructive = destructive, + ) + // With progress + ButtonRowPreview( + showProgress = true, + style = style, + size = size, + destructive = destructive, + ) +} + +@Composable +private fun ButtonRowPreview( + style: ButtonStyle, + size: ButtonSize, + leadingIcon: IconSource? = null, + showProgress: Boolean = false, + destructive: Boolean = false, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + ) { + ButtonInternal( + text = "A button", + showProgress = showProgress, + destructive = destructive, + onClick = {}, + style = style, + size = size, + leadingIcon = leadingIcon, + ) + ButtonInternal( + text = "A button", + showProgress = showProgress, + destructive = destructive, + enabled = false, + onClick = {}, + style = style, + size = size, + leadingIcon = leadingIcon, + ) + } +} 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 0000000..a825738 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Checkbox.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.CheckboxColors +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.minimumInteractiveComponentSize +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.state.ToggleableState +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +// Designs in https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?type=design&mode=design&t=qb99xBP5mwwCtGkN-1 + +@Composable +fun Checkbox( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + hasError: Boolean = false, + indeterminate: Boolean = false, + colors: CheckboxColors = if (hasError) compoundErrorCheckBoxColors() else compoundCheckBoxColors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) { + var indeterminateState by remember { mutableStateOf(indeterminate) } + androidx.compose.material3.TriStateCheckbox( + state = if (!checked && indeterminateState) ToggleableState.Indeterminate else ToggleableState(checked), + onClick = onCheckedChange?.let { + { + indeterminateState = false + onCheckedChange(!checked) + } + }, + modifier = modifier.minimumInteractiveComponentSize(), + enabled = enabled, + colors = colors, + interactionSource = interactionSource, + ) +} + +@Composable +private fun compoundCheckBoxColors(): CheckboxColors { + return CheckboxDefaults.colors( + checkedColor = ElementTheme.colors.bgAccentRest, + uncheckedColor = ElementTheme.colors.borderInteractivePrimary, + checkmarkColor = ElementTheme.materialColors.onPrimary, + disabledUncheckedColor = ElementTheme.colors.borderDisabled, + disabledCheckedColor = ElementTheme.colors.iconDisabled, + disabledIndeterminateColor = ElementTheme.colors.iconDisabled, + ) +} + +@Composable +private fun compoundErrorCheckBoxColors(): CheckboxColors { + return CheckboxDefaults.colors( + checkedColor = ElementTheme.materialColors.error, + uncheckedColor = ElementTheme.materialColors.error, + checkmarkColor = ElementTheme.materialColors.onPrimary, + disabledUncheckedColor = ElementTheme.colors.borderDisabled, + disabledCheckedColor = ElementTheme.colors.iconDisabled, + disabledIndeterminateColor = ElementTheme.colors.iconDisabled, + ) +} + +@Preview(group = PreviewGroup.Toggles) +@Composable +internal fun CheckboxesPreview() = ElementThemedPreview(vertical = false) { + Column { + // Unchecked + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Checkbox(onCheckedChange = {}, enabled = true, checked = false) + Checkbox(onCheckedChange = {}, enabled = false, checked = false) + } + // Checked + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Checkbox(onCheckedChange = {}, enabled = true, checked = true) + Checkbox(onCheckedChange = {}, enabled = false, checked = true) + } + // Indeterminate + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Checkbox(onCheckedChange = {}, enabled = true, checked = false, indeterminate = true) + Checkbox(onCheckedChange = {}, enabled = false, checked = false, indeterminate = true) + } + // Error + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Checkbox(hasError = true, onCheckedChange = {}, checked = false) + Checkbox(hasError = true, onCheckedChange = {}, enabled = false, checked = false) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Checkbox(hasError = true, onCheckedChange = {}, enabled = true, checked = true) + Checkbox(hasError = true, onCheckedChange = {}, enabled = false, checked = true) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Checkbox(onCheckedChange = {}, enabled = true, checked = false, indeterminate = true, hasError = true) + Checkbox(onCheckedChange = {}, enabled = false, checked = false, indeterminate = true, hasError = true) + } + } +} 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 0000000..9bca6e5 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/CircularProgressIndicator.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ProgressIndicatorDefaults +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.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, + trackColor: Color = ProgressIndicatorDefaults.circularDeterminateTrackColor, + strokeWidth: Dp = ProgressIndicatorDefaults.CircularStrokeWidth +) { + androidx.compose.material3.CircularProgressIndicator( + modifier = modifier, + progress = progress, + color = color, + trackColor = trackColor, + strokeWidth = strokeWidth, + ) +} + +@Composable +fun CircularProgressIndicator( + modifier: Modifier = Modifier, + color: Color = ProgressIndicatorDefaults.circularColor, + trackColor: Color = ProgressIndicatorDefaults.circularIndeterminateTrackColor, + 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, + trackColor = trackColor, + strokeWidth = strokeWidth, + ) + } else { + androidx.compose.material3.CircularProgressIndicator( + modifier = modifier, + color = color, + trackColor = trackColor, + strokeWidth = strokeWidth, + ) + } +} + +@Preview(group = PreviewGroup.Progress) +@Composable +internal fun CircularProgressIndicatorPreview() = ElementThemedPreview(vertical = false) { + Column( + modifier = Modifier.padding(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Indeterminate progress + Text("Indeterminate") + CircularProgressIndicator() + // Fixed progress + Text("Fixed progress") + CircularProgressIndicator( + progress = { 0.50F } + ) + } +} 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 0000000..9a3e359 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/DropdownMenu.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties +import io.element.android.compound.theme.ElementTheme + +// Figma designs: https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?type=design&node-id=1032%3A44063&mode=design&t=rsNegTbEVLYAXL76-1 + +@Composable +fun DropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + offset: DpOffset = DpOffset(x = 0.dp, y = 0.dp), + properties: PopupProperties = PopupProperties(focusable = true), + minWidth: Dp = DropdownMenuDefaults.minWidth, + content: @Composable ColumnScope.() -> Unit +) { + androidx.compose.material3.DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + modifier = modifier + .background(color = ElementTheme.colors.bgCanvasDefaultLevel1) + .widthIn(min = minWidth), + shape = RoundedCornerShape(8.dp), + offset = offset, + properties = properties, + content = content + ) +} + +object DropdownMenuDefaults { + val minWidth = 200.dp +} 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 0000000..31c3f3c --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/DropdownMenuItem.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.PaddingValues +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +// Figma designs: https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?type=design&node-id=1032%3A44063&mode=design&t=rsNegTbEVLYAXL76-1 + +@Composable +fun DropdownMenuItem( + text: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + androidx.compose.material3.DropdownMenuItem( + text = { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) { + text() + } + }, + onClick = onClick, + modifier = modifier, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + enabled = enabled, + colors = DropDownMenuItemDefaults.colors(), + contentPadding = DropDownMenuItemDefaults.contentPadding, + interactionSource = interactionSource + ) +} + +internal object DropDownMenuItemDefaults { + @Composable + fun colors() = MenuDefaults.itemColors( + textColor = ElementTheme.colors.textPrimary, + leadingIconColor = ElementTheme.colors.iconPrimary, + trailingIconColor = ElementTheme.colors.iconSecondary, + disabledTextColor = ElementTheme.colors.textDisabled, + disabledLeadingIconColor = ElementTheme.colors.iconDisabled, + disabledTrailingIconColor = ElementTheme.colors.iconDisabled, + ) + + val contentPadding = PaddingValues(all = 12.dp) +} + +@Preview(group = PreviewGroup.Menus) +@Composable +internal fun DropdownMenuItemPreview() = ElementThemedPreview { + Column { + DropdownMenuItem( + text = { Text(text = "Item") }, + onClick = {}, + trailingIcon = { Icon(imageVector = CompoundIcons.ChevronRight(), contentDescription = null) }, + ) + HorizontalDivider() + DropdownMenuItem( + text = { Text(text = "Item") }, + onClick = {}, + leadingIcon = { Icon(imageVector = CompoundIcons.ChatProblem(), contentDescription = null) }, + ) + DropdownMenuItem( + text = { Text(text = "Item") }, + onClick = {}, + leadingIcon = { Icon(imageVector = CompoundIcons.ChatProblem(), contentDescription = null) }, + trailingIcon = { Icon(imageVector = CompoundIcons.ChevronRight(), contentDescription = null) }, + ) + DropdownMenuItem( + text = { Text(text = "Item") }, + onClick = {}, + enabled = false, + leadingIcon = { Icon(imageVector = CompoundIcons.ChatProblem(), contentDescription = null) }, + trailingIcon = { Icon(imageVector = CompoundIcons.ChevronRight(), contentDescription = null) }, + ) + HorizontalDivider() + DropdownMenuItem( + text = { Text(text = "Multiline\nItem") }, + onClick = {}, + trailingIcon = { Icon(imageVector = CompoundIcons.ChevronRight(), contentDescription = null) }, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FilledTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FilledTextField.kt new file mode 100644 index 0000000..cbd25c1 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FilledTextField.kt @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextRange +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.compound.theme.ElementTheme +import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage +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 FilledTextField( + 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( + unfocusedContainerColor = ElementTheme.colors.bgSubtleSecondary, + focusedContainerColor = ElementTheme.colors.bgSubtleSecondary, + disabledContainerColor = ElementTheme.colors.bgSubtleSecondary, + errorContainerColor = ElementTheme.colors.bgSubtleSecondary, + ) +) { + 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 FilledTextField( + 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 = 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, + ) +} + +@Preview(group = PreviewGroup.TextFields) +@Composable +internal fun FilledTextFieldLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview(group = PreviewGroup.TextFields) +@Composable +internal fun FilledTextFieldDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@ExcludeFromCoverage +@Composable +private fun ContentToPreview() { + Column(modifier = Modifier.padding(4.dp)) { + allBooleans.forEach { isError -> + allBooleans.forEach { enabled -> + allBooleans.forEach { readonly -> + FilledTextField( + value = "Hello er=${isError.asInt()}, en=${enabled.asInt()}, ro=${readonly.asInt()}", + onValueChange = {}, + label = { Text(text = "label") }, + isError = isError, + enabled = enabled, + readOnly = readonly, + ) + Spacer(modifier = Modifier.height(2.dp)) + } + } + } + } +} + +@Preview(group = PreviewGroup.TextFields) +@Composable +internal fun FilledTextFieldValueLightPreview() = + ElementPreviewLight { FilledTextFieldValueContentToPreview() } + +@Preview(group = PreviewGroup.TextFields) +@Composable +internal fun FilledTextFieldValueTextFieldDarkPreview() = + ElementPreviewDark { FilledTextFieldValueContentToPreview() } + +@ExcludeFromCoverage +@Composable +private fun FilledTextFieldValueContentToPreview() { + Column(modifier = Modifier.padding(4.dp)) { + allBooleans.forEach { isError -> + allBooleans.forEach { enabled -> + allBooleans.forEach { readonly -> + FilledTextField( + value = TextFieldValue( + text = "Hello er=${isError.asInt()}, en=${enabled.asInt()}, ro=${readonly.asInt()}", + selection = TextRange(0, "Hello".length), + ), + onValueChange = {}, + label = { Text(text = "label") }, + 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/FloatingActionButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt new file mode 100644 index 0000000..38eada2 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.material3.FloatingActionButtonDefaults +import androidx.compose.material3.FloatingActionButtonElevation +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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag + +@Composable +fun FloatingActionButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + shape: Shape = FloatingActionButtonDefaults.shape, + containerColor: Color = ElementTheme.colors.textActionAccent, + contentColor: Color = ElementTheme.colors.iconOnSolidPrimary, + elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable () -> Unit, +) { + androidx.compose.material3.FloatingActionButton( + onClick = onClick, + modifier = modifier.testTag(TestTags.floatingActionButton), + shape = shape, + containerColor = containerColor, + contentColor = contentColor, + elevation = elevation, + interactionSource = interactionSource, + content = content, + ) +} + +@Preview(group = PreviewGroup.FABs) +@Composable +internal fun FloatingActionButtonPreview() = ElementThemedPreview { + Box(modifier = Modifier.padding(8.dp)) { + FloatingActionButton(onClick = {}) { + Icon(imageVector = CompoundIcons.Close(), contentDescription = null) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalDivider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalDivider.kt new file mode 100644 index 0000000..b4c7edd --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalDivider.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 HorizontalDivider( + modifier: Modifier = Modifier, + thickness: Dp = ElementDividerDefaults.thickness, + color: Color = DividerDefaults.color, +) { + androidx.compose.material3.HorizontalDivider( + modifier = modifier, + thickness = thickness, + color = color, + ) +} + +object ElementDividerDefaults { + val thickness = 0.5.dp +} + +@Preview(group = PreviewGroup.Dividers) +@Composable +internal fun HorizontalDividerPreview() = ElementThemedPreview { + Box(Modifier.padding(vertical = 10.dp), contentAlignment = Alignment.Center) { + HorizontalDivider() + } +} 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 0000000..f409761 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Icon.kt @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +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.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +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( + imageVector = ImageVector.vectorResource(id = resourceId), + contentDescription = contentDescription, + modifier = modifier, + tint = tint + ) +} + +@Composable +fun Icon( + painter: Painter, + contentDescription: String?, + modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current, +) { + androidx.compose.material3.Icon( + painter = painter, + contentDescription = contentDescription, + modifier = modifier, + tint = tint + ) +} + +@Preview(group = PreviewGroup.Icons) +@Composable +internal fun IconImageVectorPreview() = ElementThemedPreview { + Icon(imageVector = CompoundIcons.Close(), contentDescription = null) +} + +@Preview(group = PreviewGroup.Icons) +@Composable +internal fun AllIconsPreview() = ElementPreview { + LazyVerticalGrid( + modifier = Modifier.fillMaxWidth(), + columns = GridCells.Adaptive(32.dp), + contentPadding = PaddingValues(2.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + CompoundIcons.allResIds.forEach { icon -> + item { + Icon( + painter = painterResource(icon), + contentDescription = null, + ) + } + } + } +} 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 0000000..023d798 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/IconButton.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.Row +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +// Figma designs: https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?type=design&node-id=1182%3A48861&mode=design&t=Shlcvznm1oUyqGC2-1 + +@Composable +fun IconButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: IconButtonColors = IconButtonDefaults.iconButtonColors( + contentColor = LocalContentColor.current, + disabledContentColor = ElementTheme.colors.iconDisabled, + ), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable () -> Unit +) { + androidx.compose.material3.IconButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + colors = colors, + interactionSource = interactionSource, + content = content, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun IconButtonPreview() = ElementThemedPreview { + Column { + CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.iconPrimary) { + Row { + IconButton(onClick = {}) { + Icon(imageVector = CompoundIcons.Close(), contentDescription = null) + } + IconButton(enabled = false, onClick = {}) { + Icon(imageVector = CompoundIcons.Close(), contentDescription = null) + } + } + } + CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.iconSecondary) { + Row { + IconButton(onClick = {}) { + Icon(imageVector = CompoundIcons.Close(), contentDescription = null) + } + IconButton(enabled = false, onClick = {}) { + Icon(imageVector = CompoundIcons.Close(), contentDescription = null) + } + } + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/IconColorButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/IconColorButton.kt new file mode 100644 index 0000000..1425d59 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/IconColorButton.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +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.vector.ImageVector +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +/** + * Button with colored background. + * Figma: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=1956-37586 + */ +@Composable +fun IconColorButton( + onClick: () -> Unit, + imageVector: ImageVector, + contentDescription: String?, + modifier: Modifier = Modifier, + buttonSize: ButtonSize = ButtonSize.Large, + iconColorButtonStyle: IconColorButtonStyle = IconColorButtonStyle.Primary, +) { + val bgColor = when (iconColorButtonStyle) { + IconColorButtonStyle.Primary -> ElementTheme.colors.iconPrimary + IconColorButtonStyle.Secondary -> ElementTheme.colors.iconSecondary + IconColorButtonStyle.Disabled -> ElementTheme.colors.iconDisabled + } + IconButton( + modifier = modifier.size(48.dp), + onClick = onClick, + ) { + Icon( + modifier = Modifier + .clip(CircleShape) + .size(buttonSize.toContainerSize()) + .background(bgColor) + .padding(buttonSize.toContainerPadding()), + imageVector = imageVector, + contentDescription = contentDescription, + tint = ElementTheme.colors.iconOnSolidPrimary + ) + } +} + +enum class IconColorButtonStyle { + Primary, + Secondary, + Disabled, +} + +private fun ButtonSize.toContainerSize() = when (this) { + ButtonSize.Small -> 20.dp + ButtonSize.Medium -> 24.dp + ButtonSize.Large, + ButtonSize.MediumLowPadding, + ButtonSize.LargeLowPadding -> 30.dp +} + +private fun ButtonSize.toContainerPadding() = when (this) { + ButtonSize.Small -> 2.dp + ButtonSize.Medium -> 2.dp + ButtonSize.Large, + ButtonSize.MediumLowPadding, + ButtonSize.LargeLowPadding -> 3.dp +} + +@PreviewsDayNight +@Composable +internal fun IconColorButtonPreview() = ElementPreview { + Column { + listOf( + IconColorButtonStyle.Primary, + IconColorButtonStyle.Secondary, + IconColorButtonStyle.Disabled, + ).forEach { style -> + Row( + modifier = Modifier.padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + listOf(ButtonSize.Large, ButtonSize.Medium, ButtonSize.Small).forEach { size -> + IconColorButton( + onClick = {}, + imageVector = CompoundIcons.Close(), + contentDescription = null, + buttonSize = size, + iconColorButtonStyle = style, + ) + } + } + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/IconToggleButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/IconToggleButton.kt new file mode 100644 index 0000000..af14f35 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/IconToggleButton.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.IconToggleButtonColors +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 androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@Composable +fun IconToggleButton( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: IconToggleButtonColors = IconButtonDefaults.iconToggleButtonColors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable () -> Unit +) { + androidx.compose.material3.IconToggleButton( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = modifier, + enabled = enabled, + colors = colors, + interactionSource = interactionSource, + content = content, + ) +} + +@Preview(group = PreviewGroup.Toggles) +@Composable +internal fun IconToggleButtonPreview() = ElementThemedPreview(vertical = false) { + var checked by remember { mutableStateOf(false) } + Column { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + val icon: @Composable () -> Unit = { + Icon( + imageVector = if (checked) CompoundIcons.CheckCircleSolid() else CompoundIcons.Circle(), + contentDescription = null + ) + } + IconToggleButton(checked = checked, enabled = true, onCheckedChange = { checked = !checked }, content = icon) + IconToggleButton(checked = checked, enabled = false, onCheckedChange = { checked = !checked }, content = icon) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + val icon: @Composable () -> Unit = { + Icon( + imageVector = if (!checked) CompoundIcons.CheckCircleSolid() else CompoundIcons.Circle(), + contentDescription = null + ) + } + IconToggleButton(checked = !checked, enabled = true, onCheckedChange = { checked = !checked }, content = icon) + IconToggleButton(checked = !checked, enabled = false, onCheckedChange = { checked = !checked }, content = icon) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/LinearProgressIndicator.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/LinearProgressIndicator.kt new file mode 100644 index 0000000..04f0816 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/LinearProgressIndicator.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.graphics.StrokeCap +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 LinearProgressIndicator( + progress: () -> Float, + modifier: Modifier = Modifier, + gapSize: Dp = 0.dp, + color: Color = ProgressIndicatorDefaults.linearColor, + trackColor: Color = ProgressIndicatorDefaults.linearTrackColor, + strokeCap: StrokeCap = ProgressIndicatorDefaults.LinearStrokeCap, +) { + androidx.compose.material3.LinearProgressIndicator( + modifier = modifier, + progress = progress, + gapSize = gapSize, + color = color, + trackColor = trackColor, + strokeCap = strokeCap, + drawStopIndicator = {}, + ) +} + +@Composable +fun LinearProgressIndicator( + modifier: Modifier = Modifier, + color: Color = ProgressIndicatorDefaults.linearColor, + gapSize: Dp = 0.dp, + trackColor: Color = ProgressIndicatorDefaults.linearTrackColor, + strokeCap: StrokeCap = ProgressIndicatorDefaults.LinearStrokeCap, +) { + if (LocalInspectionMode.current) { + // Use a determinate progress indicator to improve the preview rendering + androidx.compose.material3.LinearProgressIndicator( + modifier = modifier, + progress = { 0.75F }, + gapSize = gapSize, + color = color, + trackColor = trackColor, + strokeCap = strokeCap, + drawStopIndicator = {}, + ) + } else { + androidx.compose.material3.LinearProgressIndicator( + modifier = modifier, + color = color, + gapSize = gapSize, + trackColor = trackColor, + strokeCap = strokeCap, + ) + } +} + +@Preview(group = PreviewGroup.Progress) +@Composable +internal fun LinearProgressIndicatorPreview() = ElementThemedPreview(vertical = false) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + // Indeterminate progress + LinearProgressIndicator() + // Fixed progress + LinearProgressIndicator( + progress = { 0.90F } + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListItem.kt new file mode 100644 index 0000000..8bc09ce --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListItem.kt @@ -0,0 +1,610 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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 io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +// Designs: https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?type=design&node-id=425%3A24208&mode=design&t=G5hCfkLB6GgXDuWe-1 + +/** + * A List Item component to be used in lists and menus with simple layouts, matching the Material 3 guidelines. + * @param headlineContent The main content of the list item, usually a text. + * @param modifier The modifier to be applied to the list item. + * @param supportingContent The content to be displayed below the headline content. + * @param leadingContent The content to be displayed before the headline content. + * @param trailingContent The content to be displayed after the headline content. + * @param style The style to use for the list item. This may change the color and text styles of the contents. [ListItemStyle.Default] is used by default. + * @param enabled Whether the list item is enabled. When disabled, will change the color of the headline content and the leading content to use disabled tokens. + * @param alwaysClickable Whether the list item should always be clickable, even when disabled. + * @param onClick The callback to be called when the list item is clicked. + */ +@Suppress("LongParameterList") +@Composable +fun ListItem( + headlineContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + supportingContent: @Composable (() -> Unit)? = null, + leadingContent: ListItemContent? = null, + trailingContent: ListItemContent? = null, + style: ListItemStyle = ListItemStyle.Default, + enabled: Boolean = true, + alwaysClickable: Boolean = false, + onClick: (() -> Unit)? = null, +) { + val colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + headlineColor = style.headlineColor(), + leadingIconColor = style.leadingIconColor(), + trailingIconColor = style.trailingIconColor(), + supportingColor = style.supportingTextColor(), + disabledHeadlineColor = ListItemDefaultColors.headlineDisabled, + disabledLeadingIconColor = ListItemDefaultColors.iconDisabled, + disabledTrailingIconColor = ListItemDefaultColors.iconDisabled, + ) + ListItem( + headlineContent = headlineContent, + modifier = modifier, + supportingContent = supportingContent, + leadingContent = leadingContent, + trailingContent = trailingContent, + colors = colors, + enabled = enabled, + alwaysClickable = alwaysClickable, + onClick = onClick, + ) +} + +/** + * A List Item component to be used in lists and menus with simple layouts, matching the Material 3 guidelines. + * @param headlineContent The main content of the list item, usually a text. + * @param colors The colors to use for the list item. You can use [ListItemDefaults.colors] to create this. + * @param modifier The modifier to be applied to the list item. + * @param supportingContent The content to be displayed below the headline content. + * @param leadingContent The content to be displayed before the headline content. + * @param trailingContent The content to be displayed after the headline content. + * @param enabled Whether the list item is enabled. When disabled, will change the color of the headline content and the leading content to use disabled tokens. + * @param alwaysClickable Whether the list item should always be clickable, even when disabled. + * @param onClick The callback to be called when the list item is clicked. + */ +@Suppress("LongParameterList") +@Composable +fun ListItem( + headlineContent: @Composable () -> Unit, + colors: ListItemColors, + modifier: Modifier = Modifier, + supportingContent: @Composable (() -> Unit)? = null, + leadingContent: ListItemContent? = null, + trailingContent: ListItemContent? = null, + enabled: Boolean = true, + alwaysClickable: Boolean = false, + onClick: (() -> Unit)? = null, +) { + // We cannot just pass the disabled colors, they must be set manually: https://issuetracker.google.com/issues/280480132 + val headlineColor = if (enabled) colors.headlineColor else colors.disabledHeadlineColor + val supportingColor = if (enabled) colors.supportingTextColor else colors.disabledHeadlineColor.copy(alpha = 0.80f) + val leadingContentColor = if (enabled) colors.leadingIconColor else colors.disabledLeadingIconColor + val trailingContentColor = if (enabled) colors.trailingIconColor else colors.disabledTrailingIconColor + + val decoratedHeadlineContent: @Composable () -> Unit = { + CompositionLocalProvider( + LocalTextStyle provides ElementTheme.materialTypography.bodyLarge, + LocalContentColor provides headlineColor, + ) { + headlineContent() + } + } + val decoratedSupportingContent: (@Composable () -> Unit)? = supportingContent?.let { content -> + { + CompositionLocalProvider( + LocalTextStyle provides ElementTheme.materialTypography.bodyMedium, + LocalContentColor provides supportingColor, + ) { + content() + } + } + } + val decoratedLeadingContent: (@Composable () -> Unit)? = leadingContent?.let { content -> + { + CompositionLocalProvider( + LocalContentColor provides leadingContentColor, + ) { + content.View(isItemEnabled = enabled) + } + } + } + val decoratedTrailingContent: (@Composable () -> Unit)? = trailingContent?.let { content -> + { + CompositionLocalProvider( + LocalTextStyle provides ElementTheme.typography.fontBodyMdRegular, + LocalContentColor provides trailingContentColor, + ) { + content.View(isItemEnabled = enabled) + } + } + } + + androidx.compose.material3.ListItem( + headlineContent = decoratedHeadlineContent, + modifier = if (onClick != null) { + Modifier + .clickable(enabled = enabled || alwaysClickable, onClick = onClick) + .then(modifier) + } else { + modifier + } + .withAccessibilityModifier( + content = trailingContent ?: leadingContent, + enabled = enabled || alwaysClickable, + onClick = onClick, + ), + overlineContent = null, + supportingContent = decoratedSupportingContent, + leadingContent = decoratedLeadingContent, + trailingContent = decoratedTrailingContent, + colors = colors, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) +} + +private fun Modifier.withAccessibilityModifier( + content: ListItemContent?, + enabled: Boolean, + onClick: (() -> Unit)?, +): Modifier = this.then( + when (content) { + is ListItemContent.Checkbox -> { + Modifier.toggleable( + value = content.checked, + role = Role.Checkbox, + enabled = content.enabled && enabled, + onValueChange = { onClick?.invoke() } + ) + } + is ListItemContent.Switch -> { + Modifier.toggleable( + value = content.checked, + role = Role.Switch, + enabled = content.enabled && enabled, + onValueChange = { onClick?.invoke() } + ) + } + is ListItemContent.RadioButton -> { + Modifier.selectable( + selected = content.selected, + role = Role.RadioButton, + enabled = content.enabled && enabled, + onClick = { onClick?.invoke() } + ) + } + ListItemContent.Badge, + is ListItemContent.Custom, + is ListItemContent.Icon, + is ListItemContent.Text, + is ListItemContent.Counter, + null -> Modifier + } +) + +/** + * The style to use for a [ListItem]. + */ +@Immutable +sealed interface ListItemStyle { + data object Default : ListItemStyle + data object Primary : ListItemStyle + data object Destructive : ListItemStyle + + @Composable + fun headlineColor() = when (this) { + Default, Primary -> ListItemDefaultColors.headline + Destructive -> ElementTheme.colors.textCriticalPrimary + } + + @Composable + fun supportingTextColor() = when (this) { + Default, Primary -> ListItemDefaultColors.supportingText + // FIXME once we have a defined color for this value + Destructive -> ElementTheme.colors.textCriticalPrimary.copy(alpha = 0.8f) + } + + @Composable + fun leadingIconColor() = when (this) { + Default -> ListItemDefaultColors.leadingIcon + Primary -> ElementTheme.colors.iconPrimary + Destructive -> ElementTheme.colors.iconCriticalPrimary + } + + @Composable + fun trailingIconColor() = when (this) { + Default -> ListItemDefaultColors.trailingIcon + Primary -> ElementTheme.colors.iconPrimary + Destructive -> ElementTheme.colors.iconCriticalPrimary + } +} + +object ListItemDefaultColors { + val headline: Color @Composable get() = ElementTheme.colors.textPrimary + val headlineDisabled: Color @Composable get() = ElementTheme.colors.textDisabled + val supportingText: Color @Composable get() = ElementTheme.materialColors.onSurfaceVariant + val leadingIcon: Color @Composable get() = ElementTheme.colors.iconSecondary + val trailingIcon: Color @Composable get() = ElementTheme.colors.iconPrimary + val iconDisabled: Color @Composable get() = ElementTheme.colors.iconDisabled + + val colors: ListItemColors + @Composable get() = ListItemDefaults.colors( + headlineColor = headline, + supportingColor = supportingText, + leadingIconColor = leadingIcon, + trailingIconColor = trailingIcon, + disabledHeadlineColor = headlineDisabled, + disabledLeadingIconColor = iconDisabled, + disabledTrailingIconColor = iconDisabled, + ) +} + +// region: Simple list item +@Preview(name = "List item (3 lines) - Simple", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemThreeLinesSimplePreview() = PreviewItems.ThreeLinesListItemPreview() + +@Preview(name = "List item (2 lines) - Simple", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemTwoLinesSimplePreview() = PreviewItems.TwoLinesListItemPreview() + +@Preview(name = "List item (1 line) - Simple", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemSingleLineSimplePreview() = PreviewItems.OneLineListItemPreview() +// endregion + +// region: Trailing Checkbox +@Preview(name = "List item (3 lines) - Trailing Checkbox", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemThreeLinesTrailingCheckBoxPreview() = PreviewItems.ThreeLinesListItemPreview(trailingContent = PreviewItems.checkbox()) + +@Preview(name = "List item (2 lines) - Trailing Checkbox", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemTwoLinesTrailingCheckBoxPreview() = PreviewItems.TwoLinesListItemPreview(trailingContent = PreviewItems.checkbox()) + +@Preview(name = "List item (1 line) - Trailing Checkbox", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemSingleLineTrailingCheckBoxPreview() = PreviewItems.OneLineListItemPreview(trailingContent = PreviewItems.checkbox()) +// endregion + +// region: Trailing RadioButton +@Preview(name = "List item (3 lines) - Trailing RadioButton", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemThreeLinesTrailingRadioButtonPreview() = PreviewItems.ThreeLinesListItemPreview(trailingContent = PreviewItems.radioButton()) + +@Preview(name = "List item (2 lines) - Trailing RadioButton", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemTwoLinesTrailingRadioButtonPreview() = PreviewItems.TwoLinesListItemPreview(trailingContent = PreviewItems.radioButton()) + +@Preview(name = "List item (1 line) - Trailing RadioButton", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemSingleLineTrailingRadioButtonPreview() = PreviewItems.OneLineListItemPreview(trailingContent = PreviewItems.radioButton()) +// endregion + +// region: Trailing Switch +@Preview(name = "List item (3 lines) - Trailing Switch", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemThreeLinesTrailingSwitchPreview() = PreviewItems.ThreeLinesListItemPreview(trailingContent = PreviewItems.switch()) + +@Preview(name = "List item (2 lines) - Trailing Switch", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemTwoLinesTrailingSwitchPreview() = PreviewItems.TwoLinesListItemPreview(trailingContent = PreviewItems.switch()) + +@Preview(name = "List item (1 line) - Trailing Switch", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemSingleLineTrailingSwitchPreview() = PreviewItems.OneLineListItemPreview(trailingContent = PreviewItems.switch()) +// endregion + +// region: Trailing Icon +@Preview(name = "List item (3 lines) - Trailing Icon", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemThreeLinesTrailingIconPreview() = PreviewItems.ThreeLinesListItemPreview(trailingContent = PreviewItems.icon()) + +@Preview(name = "List item (2 lines) - Trailing Icon", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemTwoLinesTrailingIconPreview() = PreviewItems.TwoLinesListItemPreview(trailingContent = PreviewItems.icon()) + +@Preview(name = "List item (1 line) - Trailing Icon", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemSingleLineTrailingIconPreview() = PreviewItems.OneLineListItemPreview(trailingContent = PreviewItems.icon()) +// endregion + +// region: Leading Checkbox +@Preview(name = "List item (3 lines) - Leading Checkbox", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemThreeLinesLeadingCheckboxPreview() = PreviewItems.ThreeLinesListItemPreview(leadingContent = PreviewItems.checkbox()) + +@Preview(name = "List item (2 lines) - Leading Checkbox", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemTwoLinesLeadingCheckboxPreview() = PreviewItems.TwoLinesListItemPreview(leadingContent = PreviewItems.checkbox()) + +@Preview(name = "List item (1 line) - Leading Checkbox", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemSingleLineLeadingCheckboxPreview() = PreviewItems.OneLineListItemPreview(leadingContent = PreviewItems.checkbox()) +// endregion + +// region: Leading RadioButton +@Preview(name = "List item (3 lines) - Leading RadioButton", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemThreeLinesLeadingRadioButtonPreview() = PreviewItems.ThreeLinesListItemPreview(leadingContent = PreviewItems.radioButton()) + +@Preview(name = "List item (2 lines) - Leading RadioButton", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemTwoLinesLeadingRadioButtonPreview() = PreviewItems.TwoLinesListItemPreview(leadingContent = PreviewItems.radioButton()) + +@Preview(name = "List item (1 line) - Leading RadioButton", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemSingleLineLeadingRadioButtonPreview() = PreviewItems.OneLineListItemPreview(leadingContent = PreviewItems.radioButton()) +// endregion + +// region: Leading Switch +@Preview(name = "List item (3 lines) - Leading Switch", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemThreeLinesLeadingSwitchPreview() = PreviewItems.ThreeLinesListItemPreview(leadingContent = PreviewItems.switch()) + +@Preview(name = "List item (2 lines) - Leading Switch", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemTwoLinesLeadingSwitchPreview() = PreviewItems.TwoLinesListItemPreview(leadingContent = PreviewItems.switch()) + +@Preview(name = "List item (1 line) - Leading Switch", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemSingleLineLeadingSwitchPreview() = PreviewItems.OneLineListItemPreview(leadingContent = PreviewItems.switch()) +// endregion + +// region: Leading Icon +@Preview(name = "List item (3 lines) - Leading Icon", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemThreeLinesLeadingIconPreview() = PreviewItems.ThreeLinesListItemPreview(leadingContent = PreviewItems.icon()) + +@Preview(name = "List item (2 lines) - Leading Icon", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemTwoLinesLeadingIconPreview() = PreviewItems.TwoLinesListItemPreview(leadingContent = PreviewItems.icon()) + +@Preview(name = "List item (1 line) - Leading Icon", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemSingleLineLeadingIconPreview() = PreviewItems.OneLineListItemPreview(leadingContent = PreviewItems.icon()) +// endregion + +// region: Both Icons +@Preview(name = "List item (3 lines) - Both Icons", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemThreeLinesBothIconsPreview() = PreviewItems.ThreeLinesListItemPreview( + leadingContent = PreviewItems.icon(), + trailingContent = PreviewItems.icon() +) + +@Preview(name = "List item (2 lines) - Both Icons", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemTwoLinesBothIconsPreview() = PreviewItems.TwoLinesListItemPreview( + leadingContent = PreviewItems.icon(), + trailingContent = PreviewItems.icon() +) + +@Preview(name = "List item (1 line) - Both Icons", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemSingleLineBothIconsPreview() = PreviewItems.OneLineListItemPreview( + leadingContent = PreviewItems.icon(), + trailingContent = PreviewItems.icon() +) +// endregion + +// region: Primary action +@Preview(name = "List item - Primary action & Icon", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemPrimaryActionWithIconPreview() = PreviewItems.OneLineListItemPreview( + style = ListItemStyle.Primary, + leadingContent = PreviewItems.icon(), +) +// endregion + +// region: Error state +@Preview(name = "List item (2 lines) - Simple - Error", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemTwoLinesSimpleErrorPreview() = PreviewItems.TwoLinesListItemPreview( + style = ListItemStyle.Destructive +) + +@Preview(name = "List item (2 lines) - Trailing Checkbox - Error", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemTwoLinesTrailingCheckBoxErrorPreview() = PreviewItems.TwoLinesListItemPreview( + trailingContent = PreviewItems.checkbox(), + style = ListItemStyle.Destructive, +) + +@Preview(name = "List item (2 lines) - Trailing RadioButton - Error", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemTwoLinesTrailingRadioButtonErrorPreview() = PreviewItems.TwoLinesListItemPreview( + trailingContent = PreviewItems.radioButton(), + style = ListItemStyle.Destructive, +) + +@Preview(name = "List item (2 lines) - Trailing Switch - Error", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemTwoLinesTrailingSwitchErrorPreview() = PreviewItems.TwoLinesListItemPreview( + trailingContent = PreviewItems.switch(), + style = ListItemStyle.Destructive, +) + +@Preview(name = "List item (2 lines) - Trailing Icon - Error", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemTwoLinesTrailingIconErrorPreview() = PreviewItems.TwoLinesListItemPreview( + trailingContent = PreviewItems.icon(), + style = ListItemStyle.Destructive, +) + +// region: Leading Checkbox +@Preview(name = "List item (2 lines) - Leading Checkbox - Error", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemTwoLinesLeadingCheckboxErrorPreview() = PreviewItems.TwoLinesListItemPreview( + leadingContent = PreviewItems.checkbox(), + style = ListItemStyle.Destructive, +) + +@Preview(name = "List item (2 lines) - Leading RadioButton - Error", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemTwoLinesLeadingRadioButtonErrorPreview() = PreviewItems.TwoLinesListItemPreview( + leadingContent = PreviewItems.radioButton(), + style = ListItemStyle.Destructive, +) + +@Preview(name = "List item (2 lines) - Leading Switch - Error", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemTwoLinesLeadingSwitchErrorPreview() = PreviewItems.TwoLinesListItemPreview( + leadingContent = PreviewItems.switch(), + style = ListItemStyle.Destructive, +) + +@Preview(name = "List item (2 lines) - Leading Icon - Error", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemTwoLinesLeadingIconErrorPreview() = PreviewItems.TwoLinesListItemPreview( + leadingContent = PreviewItems.icon(), + style = ListItemStyle.Destructive, +) + +@Preview(name = "List item (2 lines) - Both Icons - Error", group = PreviewGroup.ListItems) +@Composable +internal fun ListItemTwoLinesBothIconsErrorPreview() = PreviewItems.TwoLinesListItemPreview( + leadingContent = PreviewItems.icon(), + trailingContent = PreviewItems.icon(), + style = ListItemStyle.Destructive, +) +// endregion + +@Suppress("ModifierMissing") +private object PreviewItems { + @Composable + private fun EnabledDisabledElementThemedPreview( + content: @Composable (Boolean) -> Unit, + ) = ElementThemedPreview { + Column { + sequenceOf(true, false).forEach { + content(it) + } + } + } + + @Composable + fun ThreeLinesListItemPreview( + modifier: Modifier = Modifier, + style: ListItemStyle = ListItemStyle.Default, + leadingContent: ListItemContent? = null, + trailingContent: ListItemContent? = null, + ) { + EnabledDisabledElementThemedPreview { + ListItem( + headlineContent = headline(), + supportingContent = text(), + leadingContent = leadingContent, + trailingContent = trailingContent, + enabled = it, + style = style, + modifier = modifier, + ) + } + } + + @Composable + fun TwoLinesListItemPreview( + modifier: Modifier = Modifier, + style: ListItemStyle = ListItemStyle.Default, + leadingContent: ListItemContent? = null, + trailingContent: ListItemContent? = null, + ) { + EnabledDisabledElementThemedPreview { + ListItem( + headlineContent = headline(), + supportingContent = textSingleLine(), + leadingContent = leadingContent, + trailingContent = trailingContent, + enabled = it, + style = style, + modifier = modifier, + ) + } + } + + @Composable + fun OneLineListItemPreview( + modifier: Modifier = Modifier, + style: ListItemStyle = ListItemStyle.Default, + leadingContent: ListItemContent? = null, + trailingContent: ListItemContent? = null, + ) { + EnabledDisabledElementThemedPreview { + ListItem( + headlineContent = headline(), + leadingContent = leadingContent, + trailingContent = trailingContent, + enabled = it, + style = style, + modifier = modifier, + ) + } + } + + @Composable + fun headline() = @Composable { + Text("List item") + } + + @Composable + fun text() = @Composable { + Text("Supporting line text lorem ipsum dolor sit amet, consectetur.") + } + + @Composable + fun textSingleLine() = @Composable { + Text("Supporting line text lorem ipsum dolor sit amet, consectetur.", overflow = TextOverflow.Ellipsis, maxLines = 1) + } + + @Composable + fun checkbox(): ListItemContent { + return ListItemContent.Checkbox(checked = false) + } + + @Composable + fun radioButton(): ListItemContent { + return ListItemContent.RadioButton(selected = false) + } + + @Composable + fun switch(): ListItemContent { + return ListItemContent.Switch(checked = false) + } + + @Composable + fun icon() = ListItemContent.Icon( + iconSource = IconSource.Vector(CompoundIcons.ShareAndroid()) + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListSectionHeader.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListSectionHeader.kt new file mode 100644 index 0000000..403ed6d --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListSectionHeader.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.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.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +// Designs: https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?type=design&node-id=425%3A24208&mode=design&t=G5hCfkLB6GgXDuWe-1 + +/** + * List section header. + * @param title The title of the section. + * @param modifier The modifier to be applied to the section. + * @param hasDivider Whether to show a divider above the section or not. Default is `true`. + * @param description A description for the section. It's empty by default. + */ +@Composable +fun ListSectionHeader( + title: String, + modifier: Modifier = Modifier, + hasDivider: Boolean = true, + description: @Composable () -> Unit = {}, +) { + Column(modifier.fillMaxWidth()) { + if (hasDivider) { + HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) + } + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + ) + CompositionLocalProvider( + LocalTextStyle provides ElementTheme.typography.fontBodySmRegular, + LocalContentColor provides ElementTheme.colors.textSecondary, + ) { + description() + } + } + } +} + +@Preview(group = PreviewGroup.ListSections, name = "List section header") +@Composable +internal fun ListSectionHeaderPreview() { + ElementThemedPreview { + ListSectionHeader( + title = "List section", + hasDivider = false, + ) + } +} + +@Preview(group = PreviewGroup.ListSections, name = "List section header with divider") +@Composable +internal fun ListSectionHeaderWithDividerPreview() { + ElementThemedPreview { + ListSectionHeader( + title = "List section", + hasDivider = true, + ) + } +} + +@Preview(group = PreviewGroup.ListSections, name = "List section header with description") +@Composable +internal fun ListSectionHeaderWithDescriptionPreview() { + ElementThemedPreview { + ListSectionHeader( + title = "List section", + description = { + ListSupportingText( + text = "Supporting line text lorem ipsum dolor sit amet, consectetur. Read more", + contentPadding = ListSupportingTextDefaults.Padding.None, + ) + }, + hasDivider = false, + ) + } +} + +@Preview(group = PreviewGroup.ListSections, name = "List section header with description and divider") +@Composable +internal fun ListSectionHeaderWithDescriptionAndDividerPreview() { + ElementThemedPreview { + ListSectionHeader( + title = "List section", + description = { + ListSupportingText( + text = "Supporting line text lorem ipsum dolor sit amet, consectetur. Read more", + contentPadding = ListSupportingTextDefaults.Padding.None, + ) + }, + hasDivider = true, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListSupportingText.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListSupportingText.kt new file mode 100644 index 0000000..4be53dd --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListSupportingText.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.ClickableLinkText +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +// Designs: https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?type=design&node-id=425%3A24208&mode=design&t=G5hCfkLB6GgXDuWe-1 + +/** + * List supporting text item. Used to display an explanation in the list with a pre-formatted style. + * @param text The text to display. + * @param modifier The modifier to be applied to the text. + * @param contentPadding The padding to apply to the text. Default is [ListSupportingTextDefaults.Padding.Default]. + */ +@Composable +fun ListSupportingText( + text: String, + modifier: Modifier = Modifier, + contentPadding: ListSupportingTextDefaults.Padding = ListSupportingTextDefaults.Padding.Default, +) { + Text( + text = text, + modifier = modifier.padding(contentPadding.paddingValues()), + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) +} + +/** + * List supporting text item. Used to display an explanation in the list with a pre-formatted style. + * @param annotatedString The annotated string to display. + * @param modifier The modifier to be applied to the text. + * @param contentPadding The padding to apply to the text. Default is [ListSupportingTextDefaults.Padding.Default]. + */ +@OptIn(ExperimentalTextApi::class) +@Composable +fun ListSupportingText( + annotatedString: AnnotatedString, + modifier: Modifier = Modifier, + contentPadding: ListSupportingTextDefaults.Padding = ListSupportingTextDefaults.Padding.Default, +) { + val style = ElementTheme.typography.fontBodySmRegular + .copy(color = ElementTheme.colors.textSecondary) + val paddedModifier = modifier.padding(contentPadding.paddingValues()) + ClickableLinkText( + annotatedString = annotatedString, + modifier = paddedModifier, + style = style, + linkify = false, + ) +} + +object ListSupportingTextDefaults { + /** Specifies the padding to use for the supporting text. */ + @Immutable + sealed interface Padding { + /** No padding. */ + data object None : Padding + + /** Default padding, it will align fine with a [ListItem] with no leading content. */ + data object Default : Padding + + /** It will align to a [ListItem] with an [Icon] or [Checkbox] as leading content. */ + data object SmallLeadingContent : Padding + + /** It will align to with a [ListItem] with a [Switch] as leading content. */ + data object LargeLeadingContent : Padding + + /** It will align to with a [ListItem] with a custom start [padding]. */ + data class Custom(val padding: Dp) : Padding + + private fun startPadding(): Dp = when (this) { + None -> 0.dp + Default -> 16.dp + SmallLeadingContent -> 56.dp + LargeLeadingContent -> 84.dp + is Custom -> padding + } + + private fun endPadding(): Dp = when (this) { + None -> 0.dp + else -> 24.dp + } + + private fun bottomPadding(): Dp = when (this) { + None -> 0.dp + else -> 12.dp + } + + fun paddingValues() = PaddingValues( + top = 0.dp, + bottom = bottomPadding(), + start = startPadding(), + end = endPadding() + ) + } +} + +@Preview(group = PreviewGroup.ListSections, name = "List supporting text - no padding") +@Composable +internal fun ListSupportingTextNoPaddingPreview() { + ElementThemedPreview { + ListSupportingText( + text = "Supporting line text lorem ipsum dolor sit amet, consectetur. Read more", + contentPadding = ListSupportingTextDefaults.Padding.None, + ) + } +} + +@Preview(group = PreviewGroup.ListSections, name = "List supporting text - default padding") +@Composable +internal fun ListSupportingTextDefaultPaddingPreview() { + ElementThemedPreview { + Column { + ListItem(headlineContent = { Text("A title") }) + ListSupportingText( + text = "Supporting line text lorem ipsum dolor sit amet, consectetur. Read more", + contentPadding = ListSupportingTextDefaults.Padding.Default, + ) + } + } +} + +@Preview(group = PreviewGroup.ListSections, name = "List supporting text - small padding") +@Composable +internal fun ListSupportingTextSmallPaddingPreview() { + ElementThemedPreview { + Column { + ListItem( + headlineContent = { Text("A title") }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ShareAndroid())) + ) + ListSupportingText( + text = "Supporting line text lorem ipsum dolor sit amet, consectetur. Read more", + contentPadding = ListSupportingTextDefaults.Padding.SmallLeadingContent, + ) + } + } +} + +@Preview(group = PreviewGroup.ListSections, name = "List supporting text - large padding") +@Composable +internal fun ListSupportingTextLargePaddingPreview() { + ElementThemedPreview { + Column { + ListItem(headlineContent = { Text("A title") }, leadingContent = ListItemContent.Switch(checked = true)) + ListSupportingText( + text = "Supporting line text lorem ipsum dolor sit amet, consectetur. Read more", + contentPadding = ListSupportingTextDefaults.Padding.LargeLeadingContent, + ) + } + } +} + +@Preview(group = PreviewGroup.ListSections, name = "List supporting text - custom padding") +@Composable +internal fun ListSupportingTextCustomPaddingPreview() { + ElementThemedPreview { + Column { + ListItem(headlineContent = { Text("A title") }) + ListSupportingText( + text = "Supporting line text lorem ipsum dolor sit amet, consectetur. Read more", + contentPadding = ListSupportingTextDefaults.Padding.Custom(24.dp), + ) + } + } +} 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 0000000..e4c8ca7 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/MediumTopAppBar.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.LocalContentColor +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +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 + +@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 = { + CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textActionPrimary) { + actions() + } + }, + windowInsets = windowInsets, + colors = colors, + scrollBehavior = scrollBehavior, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(group = PreviewGroup.AppBars) +@Composable +internal fun MediumTopAppBarPreview() = ElementThemedPreview { + MediumTopAppBar( + title = { Text(text = "Title") }, + navigationIcon = { BackButton(onClick = {}) }, + actions = { + TextButton(text = "Action", onClick = {}) + IconButton(onClick = {}) { + Icon( + imageVector = CompoundIcons.ShareAndroid(), + contentDescription = null, + ) + } + } + ) +} 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 0000000..3f70aab --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.MaterialTheme +import androidx.compose.material3.SheetState +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.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +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.compound.theme.ElementTheme +import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage +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.preview.sheetStateForPreview +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 = MaterialTheme.colorScheme.surface, + contentColor: Color = contentColorFor(containerColor), + tonalElevation: Dp = if (ElementTheme.isLightTheme) 0.dp else BottomSheetDefaults.Elevation, + scrimColor: Color = BottomSheetDefaults.ScrimColor, + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets }, + content: @Composable ColumnScope.() -> Unit, +) { + val safeSheetState = if (LocalInspectionMode.current) sheetStateForPreview() else sheetState + androidx.compose.material3.ModalBottomSheet( + onDismissRequest = onDismissRequest, + modifier = modifier.onKeyEvent { keyEvent -> + // It seems that on some devices, we have to handle the Escape key manually to close the bottom sheet. + // This is not the case using an emulator, but is necessary on some physical devices. + if (keyEvent.type == KeyEventType.KeyUp && + keyEvent.key == Key.Escape) { + onDismissRequest() + true + } else { + false + } + }, + sheetState = safeSheetState, + shape = shape, + containerColor = containerColor, + contentColor = contentColor, + tonalElevation = tonalElevation, + scrimColor = scrimColor, + dragHandle = dragHandle, + contentWindowInsets = contentWindowInsets, + 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) +@ExcludeFromCoverage +@Composable +private fun ContentToPreview() { + Box( + modifier = Modifier.fillMaxSize(), + ) { + ModalBottomSheet( + onDismissRequest = {}, + ) { + 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/NavigationBar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBar.kt new file mode 100644 index 0000000..b983d10 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBar.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBarDefaults +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.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@Composable +fun NavigationBar( + modifier: Modifier = Modifier, + containerColor: Color = ElementNavigationBarDefaults.containerColor, + contentColor: Color = MaterialTheme.colorScheme.contentColorFor(containerColor), + tonalElevation: Dp = ElementNavigationBarDefaults.tonalElevation, + windowInsets: WindowInsets = ElementNavigationBarDefaults.windowInsets, + content: @Composable RowScope.() -> Unit +) { + androidx.compose.material3.NavigationBar( + modifier = modifier, + containerColor = containerColor, + contentColor = contentColor, + tonalElevation = tonalElevation, + windowInsets = windowInsets, + content = content + ) +} + +object ElementNavigationBarDefaults { + val containerColor: Color + @Composable get() = if (ElementTheme.isLightTheme) { + ElementTheme.colors.bgSubtlePrimary + } else { + ElementTheme.colors.textOnSolidPrimary + } + + val tonalElevation: Dp = NavigationBarDefaults.Elevation + + val windowInsets: WindowInsets + @Composable get() = NavigationBarDefaults.windowInsets +} + +@Preview(group = PreviewGroup.AppBars) +@Composable +internal fun NavigationBarPreview() = ElementThemedPreview { + NavigationBar { + NavigationBarItem( + icon = { + NavigationBarIcon( + imageVector = CompoundIcons.ChatSolid(), + count = 5, + isCritical = false, + ) + }, + label = { + NavigationBarText( + text = "Chats" + ) + }, + selected = true, + onClick = {}, + ) + NavigationBarItem( + icon = { + NavigationBarIcon( + imageVector = CompoundIcons.ChatSolid(), + count = 5, + isCritical = true, + ) + }, + label = { + NavigationBarText( + text = "Teams" + ) + }, + selected = false, + onClick = {}, + ) + NavigationBarItem( + icon = { + NavigationBarIcon( + imageVector = CompoundIcons.ChatSolid(), + count = 0, + isCritical = false, + ) + }, + label = { + NavigationBarText( + text = "Other" + ) + }, + selected = false, + onClick = {}, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarIcon.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarIcon.kt new file mode 100644 index 0000000..7c6332b --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarIcon.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.atomic.atoms.CounterAtom + +@Composable +fun NavigationBarIcon( + imageVector: ImageVector, + modifier: Modifier = Modifier, + count: Int = 0, + isCritical: Boolean = false, +) { + Box(modifier) { + Icon( + imageVector = imageVector, + contentDescription = null, + ) + CounterAtom( + modifier = Modifier.offset(11.dp, (-11).dp), + textStyle = ElementTheme.typography.fontBodyXsMedium, + count = count, + isCritical = isCritical, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarItem.kt new file mode 100644 index 0000000..4040760 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarItem.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemColors +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import io.element.android.compound.theme.ElementTheme + +@Composable +fun RowScope.NavigationBarItem( + selected: Boolean, + onClick: () -> Unit, + icon: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + label: @Composable (() -> Unit)? = null, + alwaysShowLabel: Boolean = true, + colors: NavigationBarItemColors = ElementNavigationBarItemDefaults.colors(), + interactionSource: MutableInteractionSource? = null +) { + NavigationBarItem( + selected = selected, + onClick = onClick, + icon = icon, + modifier = modifier, + enabled = enabled, + label = label, + alwaysShowLabel = alwaysShowLabel, + colors = colors, + interactionSource = interactionSource, + ) +} + +object ElementNavigationBarItemDefaults { + @Composable + fun colors() = NavigationBarItemDefaults.colors().copy( + selectedIconColor = ElementTheme.colors.iconPrimary, + selectedTextColor = ElementTheme.colors.textPrimary, + unselectedIconColor = ElementTheme.colors.iconTertiary, + unselectedTextColor = ElementTheme.colors.textDisabled, + selectedIndicatorColor = Color.Companion.Transparent, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarText.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarText.kt new file mode 100644 index 0000000..d84fddd --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarText.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.compound.theme.ElementTheme + +@Composable +fun NavigationBarText( + text: String, + modifier: Modifier = Modifier, +) { + Text( + modifier = modifier, + text = text, + style = ElementTheme.typography.fontBodySmMedium, + ) +} 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 0000000..dfb4758 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/RadioButton.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.RadioButtonColors +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.minimumInteractiveComponentSize +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 androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +// Designs in https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?type=design&node-id=425%3A24202&mode=design&t=qb99xBP5mwwCtGkN-1 + +@Composable +fun RadioButton( + selected: Boolean, + onClick: (() -> Unit)?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: RadioButtonColors = compoundRadioButtonColors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) { + androidx.compose.material3.RadioButton( + selected = selected, + onClick = onClick, + modifier = modifier.minimumInteractiveComponentSize(), + enabled = enabled, + colors = colors, + interactionSource = interactionSource, + ) +} + +@Composable +internal fun compoundRadioButtonColors(): RadioButtonColors { + return RadioButtonDefaults.colors( + unselectedColor = ElementTheme.colors.borderInteractivePrimary, + selectedColor = ElementTheme.colors.bgAccentRest, + disabledUnselectedColor = ElementTheme.colors.borderDisabled, + disabledSelectedColor = ElementTheme.colors.iconDisabled, + ) +} + +@Preview(group = PreviewGroup.Toggles) +@Composable +internal fun RadioButtonPreview() = ElementThemedPreview(vertical = false) { + var checked by remember { mutableStateOf(false) } + Column { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + RadioButton(selected = checked, enabled = true, onClick = { checked = !checked }) + RadioButton(selected = checked, enabled = false, onClick = { checked = !checked }) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + RadioButton(selected = !checked, enabled = true, onClick = { checked = !checked }) + RadioButton(selected = !checked, enabled = false, onClick = { checked = !checked }) + } + } +} 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 0000000..16d5f62 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Scaffold.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.ScaffoldDefaults +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import io.element.android.compound.theme.ElementTheme + +@Composable +fun Scaffold( + modifier: Modifier = Modifier, + topBar: @Composable () -> Unit = {}, + bottomBar: @Composable () -> Unit = {}, + snackbarHost: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + floatingActionButtonPosition: FabPosition = FabPosition.End, + containerColor: Color = ElementTheme.colors.bgCanvasDefault, + 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 0000000..924b778 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchBar.kt @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarColors +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage +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.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchBar( + query: String, + onQueryChange: (String) -> Unit, + active: Boolean, + onActiveChange: (Boolean) -> Unit, + placeHolderTitle: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + showBackButton: Boolean = true, + resultState: SearchBarResultState = SearchBarResultState.Initial(), + shape: Shape = SearchBarDefaults.inputFieldShape, + tonalElevation: Dp = SearchBarDefaults.TonalElevation, + windowInsets: WindowInsets = SearchBarDefaults.windowInsets, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + inactiveBarColors: SearchBarColors = ElementSearchBarDefaults.inactiveColors(), + activeBarColors: SearchBarColors = ElementSearchBarDefaults.activeColors(), + inactiveTextInputColors: TextFieldColors = ElementSearchBarDefaults.inactiveInputFieldColors(), + activeTextInputColors: TextFieldColors = ElementSearchBarDefaults.activeInputFieldColors(), + contentPrefix: @Composable ColumnScope.() -> Unit = {}, + contentSuffix: @Composable ColumnScope.() -> Unit = {}, + resultHandler: @Composable ColumnScope.(T) -> Unit = {}, +) { + val focusManager = LocalFocusManager.current + + val updatedOnQueryChange by rememberUpdatedState(onQueryChange) + LaunchedEffect(active) { + if (!active) { + updatedOnQueryChange("") + focusManager.clearFocus() + } + } + + SearchBar( + inputField = { + SearchBarDefaults.InputField( + query = query, + onQueryChange = updatedOnQueryChange, + onSearch = { focusManager.clearFocus() }, + expanded = active, + onExpandedChange = onActiveChange, + 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 = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_clear), + ) + } + } + } + + !active -> { + { + Icon( + imageVector = CompoundIcons.Search(), + contentDescription = stringResource(CommonStrings.action_search), + tint = ElementTheme.colors.iconTertiary, + ) + } + } + + else -> null + }, + interactionSource = interactionSource, + colors = if (active) activeTextInputColors else inactiveTextInputColors, + ) + }, + expanded = active, + onExpandedChange = onActiveChange, + modifier = modifier.padding(horizontal = if (!active) 16.dp else 0.dp), + shape = shape, + colors = if (active) activeBarColors else inactiveBarColors, + tonalElevation = tonalElevation, + windowInsets = windowInsets, + content = { + contentPrefix() + when (resultState) { + is SearchBarResultState.Results -> { + resultHandler(resultState.results) + } + + is SearchBarResultState.NoResultsFound -> { + // No results found, show a message + Spacer(Modifier.size(80.dp)) + + Text( + text = stringResource(CommonStrings.common_no_results), + textAlign = TextAlign.Center, + color = ElementTheme.colors.textSecondary, + modifier = Modifier.fillMaxWidth() + ) + } + + else -> { + // Not searching - nothing to show. + } + } + contentSuffix() + }, + ) +} + +object ElementSearchBarDefaults { + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun inactiveColors() = SearchBarDefaults.colors( + containerColor = ElementTheme.materialColors.surfaceVariant, + dividerColor = ElementTheme.materialColors.outline, + ) + + @Composable + fun inactiveInputFieldColors() = TextFieldDefaults.colors( + unfocusedPlaceholderColor = ElementTheme.colors.textDisabled, + focusedPlaceholderColor = ElementTheme.colors.textDisabled, + unfocusedLeadingIconColor = ElementTheme.materialColors.primary, + focusedLeadingIconColor = ElementTheme.materialColors.primary, + unfocusedTrailingIconColor = ElementTheme.materialColors.primary, + focusedTrailingIconColor = ElementTheme.materialColors.primary, + ) + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun activeColors() = SearchBarDefaults.colors( + containerColor = Color.Transparent, + dividerColor = ElementTheme.materialColors.outline, + ) + + @Composable + fun activeInputFieldColors() = TextFieldDefaults.colors( + unfocusedPlaceholderColor = ElementTheme.colors.textDisabled, + focusedPlaceholderColor = ElementTheme.colors.textDisabled, + unfocusedLeadingIconColor = ElementTheme.materialColors.primary, + focusedLeadingIconColor = ElementTheme.materialColors.primary, + unfocusedTrailingIconColor = ElementTheme.materialColors.primary, + focusedTrailingIconColor = ElementTheme.materialColors.primary, + ) +} + +@Immutable +sealed interface SearchBarResultState { + /** No search results are available yet (e.g. because the user hasn't entered a search term). */ + class Initial : SearchBarResultState + + /** The search has completed, but no results were found. */ + class NoResultsFound : SearchBarResultState + + /** The search has completed, and some matching users were found. */ + data class Results(val results: T) : SearchBarResultState +} + +@Preview(group = PreviewGroup.Search) +@Composable +internal fun SearchBarInactivePreview() = ElementThemedPreview { ContentToPreview() } + +@Preview(group = PreviewGroup.Search) +@Composable +internal fun SearchBarActiveNoneQueryPreview() = ElementThemedPreview { + ContentToPreview( + query = "", + active = true, + ) +} + +@Preview(group = PreviewGroup.Search) +@Composable +internal fun SearchBarActiveWithQueryPreview() = ElementThemedPreview { + ContentToPreview( + query = "search term", + active = true, + ) +} + +@Preview(group = PreviewGroup.Search) +@Composable +internal fun SearchBarActiveWithQueryNoBackButtonPreview() = ElementThemedPreview { + ContentToPreview( + query = "search term", + active = true, + showBackButton = false, + ) +} + +@Preview(group = PreviewGroup.Search) +@Composable +internal fun SearchBarActiveWithNoResultsPreview() = ElementThemedPreview { + ContentToPreview( + query = "search term", + active = true, + resultState = SearchBarResultState.NoResultsFound(), + ) +} + +@Preview(group = PreviewGroup.Search) +@Composable +internal fun SearchBarActiveWithContentPreview() = 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() + ) + } + ) { + Text( + text = "Results go here", + modifier = Modifier + .background(color = Color.Green) + .fillMaxWidth() + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +@ExcludeFromCoverage +private fun ContentToPreview( + query: String = "", + active: Boolean = false, + showBackButton: Boolean = true, + resultState: SearchBarResultState = SearchBarResultState.Initial(), + 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/SearchField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchField.kt new file mode 100644 index 0000000..fc84abe --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchField.kt @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement.spacedBy +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.padding +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.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage +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.ui.strings.CommonStrings + +/** + * https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=1985-3223 + */ +@Composable +fun SearchField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + placeholder: String? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val focusManager = LocalFocusManager.current + val isFocused by interactionSource.collectIsFocusedAsState() + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + textStyle = textFieldStyle(), + singleLine = true, + interactionSource = interactionSource, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Search, + ), + keyboardActions = KeyboardActions( + onSearch = { + focusManager.clearFocus() + } + ), + cursorBrush = SolidColor(ElementTheme.colors.textActionAccent), + ) { innerTextField -> + DecorationBox( + isFocused = isFocused, + placeholder = placeholder, + isTextEmpty = value.isEmpty(), + innerTextField = innerTextField, + onClear = { onValueChange("") }, + ) + } +} + +@Composable +fun SearchField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + placeholder: String? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val focusManager = LocalFocusManager.current + val isFocused by interactionSource.collectIsFocusedAsState() + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + textStyle = textFieldStyle(), + singleLine = true, + interactionSource = interactionSource, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Search, + ), + keyboardActions = KeyboardActions( + onSearch = { + focusManager.clearFocus() + } + ), + cursorBrush = SolidColor(ElementTheme.colors.textActionAccent), + ) { innerTextField -> + DecorationBox( + isFocused = isFocused, + placeholder = placeholder, + isTextEmpty = value.text.isEmpty(), + innerTextField = innerTextField, + onClear = { TextFieldValue() } + ) + } +} + +@Composable +private fun DecorationBox( + isFocused: Boolean, + placeholder: String?, + isTextEmpty: Boolean, + onClear: () -> Unit, + innerTextField: @Composable () -> Unit, +) { + SearchFieldContainer( + isFocused = isFocused, + ) { + Row(modifier = Modifier.padding(start = 16.dp), verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.weight(1f)) { + if (placeholder != null && isTextEmpty) { + Text( + text = placeholder, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyLgRegular, + ) + } + innerTextField() + } + Spacer(modifier = Modifier.width(16.dp)) + val showClearIcon = isFocused && !isTextEmpty + IconButton(onClick = onClear, enabled = showClearIcon) { + if (showClearIcon) { + Icon( + modifier = Modifier.background(ElementTheme.colors.iconSecondary, CircleShape), + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_clear), + tint = ElementTheme.colors.iconOnSolidPrimary, + ) + } else { + Icon( + imageVector = CompoundIcons.Search(), + contentDescription = null, + tint = ElementTheme.colors.iconTertiary, + ) + } + } + } + } +} + +@Composable +private fun SearchFieldContainer( + isFocused: Boolean, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(99.dp), + border = BorderStroke( + width = 1.dp, + color = if (isFocused) { + ElementTheme.colors.borderInteractiveHovered + } else { + ElementTheme.colors.borderInteractiveSecondary + } + ), + color = ElementTheme.colors.bgSubtleSecondary, + content = content + ) +} + +@Composable +private fun textFieldStyle(): TextStyle { + return ElementTheme.typography.fontBodyLgRegular.copy( + color = ElementTheme.colors.textPrimary + ) +} + +@Preview(group = PreviewGroup.Search, heightDp = 1000) +@Composable +internal fun SearchFieldsLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview(group = PreviewGroup.Search, heightDp = 1000) +@Composable +internal fun SearchFieldsDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +@ExcludeFromCoverage +private fun ContentToPreview() { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = spacedBy(8.dp) + ) { + SearchField( + onValueChange = {}, + placeholder = "Search", + value = "", + ) + SearchField( + onValueChange = {}, + placeholder = "Search", + value = "Search term", + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SegmentedButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SegmentedButton.kt new file mode 100644 index 0000000..7fd195b --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SegmentedButton.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRowScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import io.element.android.compound.theme.ElementTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SingleChoiceSegmentedButtonRowScope.SegmentedButton( + index: Int, + count: Int, + selected: Boolean, + onClick: () -> Unit, + text: String, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + enabled: Boolean = true, +) { + SegmentedButton( + selected = selected, + onClick = onClick, + modifier = modifier, + interactionSource = interactionSource, + enabled = enabled, + shape = SegmentedButtonDefaults.itemShape(index = index, count = count), + label = { + Text( + text = text, + style = ElementTheme.typography.fontBodyMdMedium, + ) + }, + colors = SegmentedButtonDefaults.colors( + activeContainerColor = ElementTheme.materialColors.primary, + activeContentColor = ElementTheme.materialColors.onPrimary, + activeBorderColor = ElementTheme.materialColors.primary, + inactiveContainerColor = ElementTheme.materialColors.surface, + inactiveContentColor = ElementTheme.materialColors.onSurface, + inactiveBorderColor = ElementTheme.materialColors.primary, + disabledActiveContainerColor = ElementTheme.colors.bgActionPrimaryDisabled, + disabledActiveContentColor = ElementTheme.colors.textOnSolidPrimary, + disabledActiveBorderColor = ElementTheme.colors.bgActionPrimaryDisabled, + disabledInactiveContainerColor = ElementTheme.materialColors.surface, + disabledInactiveContentColor = ElementTheme.colors.textDisabled, + disabledInactiveBorderColor = Color.Transparent, + ) + ) +} 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 0000000..7b6e6f3 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SliderColors +import androidx.compose.material3.SliderDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +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 = 0f..1f, + // @IntRange(from = 0) + steps: Int = 0, + onValueChangeFinish: (() -> Unit)? = null, + colors: SliderColors = SliderDefaults.colors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + useCustomLayout: Boolean = false, +) { + val thumbColor = ElementTheme.colors.iconOnSolidPrimary + var isUserInteracting by remember { mutableStateOf(false) } + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interaction -> + isUserInteracting = when (interaction) { + is DragInteraction.Start, + is PressInteraction.Press -> true + else -> false + } + } + } + androidx.compose.material3.Slider( + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + valueRange = valueRange, + steps = steps, + onValueChangeFinished = onValueChangeFinish, + colors = colors, + thumb = { + if (useCustomLayout) { + SliderDefaults.Thumb( + modifier = Modifier.drawWithContent { + drawContent() + if (isUserInteracting.not()) { + drawCircle(thumbColor, radius = 8.dp.toPx()) + } + }, + interactionSource = interactionSource, + colors = colors.copy( + thumbColor = ElementTheme.colors.iconPrimary, + ), + enabled = enabled, + thumbSize = DpSize( + if (isUserInteracting) 44.dp else 22.dp, + 22.dp, + ), + ) + } else { + SliderDefaults.Thumb( + interactionSource = interactionSource, + colors = colors, + enabled = enabled + ) + } + }, + track = { sliderState -> + if (useCustomLayout) { + SliderDefaults.Track( + modifier = Modifier.height(8.dp), + colors = colors.copy( + activeTrackColor = Color(0x66E0EDFF), + inactiveTrackColor = Color(0x66E0EDFF), + ), + enabled = enabled, + sliderState = sliderState, + thumbTrackGapSize = 0.dp, + drawStopIndicator = { }, + ) + } else { + SliderDefaults.Track( + colors = colors, + enabled = enabled, + sliderState = sliderState, + ) + } + }, + interactionSource = interactionSource, + ) +} + +@Preview(group = PreviewGroup.Sliders) +@Composable +internal fun SlidersPreview() = ElementThemedPreview { + var value by remember { mutableFloatStateOf(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) + Slider(onValueChange = { value = it }, value = value, enabled = true, useCustomLayout = true) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Snackbar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Snackbar.kt new file mode 100644 index 0000000..8c246a6 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Snackbar.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.SnackbarDefaults +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 io.element.android.compound.theme.ElementTheme +import io.element.android.compound.theme.SnackBarLabelColorDark +import io.element.android.compound.theme.SnackBarLabelColorLight +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.button.ButtonVisuals +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@Composable +fun Snackbar( + message: String, + modifier: Modifier = Modifier, + action: ButtonVisuals? = null, + dismissAction: ButtonVisuals? = null, + actionOnNewLine: Boolean = false, + shape: Shape = RoundedCornerShape(8.dp), + containerColor: Color = SnackbarDefaults.color, + contentColor: Color = ElementTheme.materialColors.inverseOnSurface, + actionContentColor: Color = actionContentColor(), + dismissActionContentColor: Color = SnackbarDefaults.dismissActionContentColor, +) { + Snackbar( + modifier = modifier, + action = action?.let { @Composable { it.Composable() } }, + dismissAction = dismissAction?.let { @Composable { it.Composable() } }, + actionOnNewLine = actionOnNewLine, + shape = shape, + containerColor = containerColor, + contentColor = contentColor, + actionContentColor = actionContentColor, + dismissActionContentColor = dismissActionContentColor, + content = { Text(text = message) }, + ) +} + +@Composable +fun Snackbar( + modifier: Modifier = Modifier, + action: @Composable (() -> Unit)? = null, + dismissAction: @Composable (() -> Unit)? = null, + actionOnNewLine: Boolean = false, + shape: Shape = RoundedCornerShape(8.dp), + containerColor: Color = SnackbarDefaults.color, + contentColor: Color = ElementTheme.materialColors.inverseOnSurface, + actionContentColor: Color = actionContentColor(), + dismissActionContentColor: Color = SnackbarDefaults.dismissActionContentColor, + content: @Composable () -> Unit +) { + androidx.compose.material3.Snackbar( + modifier = modifier, + action = action, + dismissAction = dismissAction, + actionOnNewLine = actionOnNewLine, + shape = shape, + containerColor = containerColor, + contentColor = contentColor, + actionContentColor = actionContentColor, + dismissActionContentColor = dismissActionContentColor, + content = content, + ) +} + +// TODO this color is temporary, an `inverse` version should be added to the semantic colors instead +@Composable +private fun actionContentColor(): Color { + return if (ElementTheme.isLightTheme) { + SnackBarLabelColorLight + } else { + SnackBarLabelColorDark + } +} + +@Preview(name = "Snackbar", group = PreviewGroup.Snackbars) +@Composable +internal fun SnackbarPreview() { + ElementThemedPreview { + Snackbar(message = "Snackbar supporting text") + } +} + +@Preview(name = "Snackbar with action", group = PreviewGroup.Snackbars) +@Composable +internal fun SnackbarWithActionPreview() { + ElementThemedPreview { + Snackbar(message = "Snackbar supporting text", action = ButtonVisuals.Text("Action", {})) + } +} + +@Preview(name = "Snackbar with action and close button", group = PreviewGroup.Snackbars) +@Composable +internal fun SnackbarWithActionAndCloseButtonPreview() { + ElementThemedPreview { + Snackbar( + message = "Snackbar supporting text", + action = ButtonVisuals.Text("Action") {}, + dismissAction = ButtonVisuals.Icon( + IconSource.Vector(CompoundIcons.Close()) + ) {} + ) + } +} + +@Preview(name = "Snackbar with action on new line", group = PreviewGroup.Snackbars) +@Composable +internal fun SnackbarWithActionOnNewLinePreview() { + ElementThemedPreview { + Snackbar(message = "Snackbar supporting text", action = ButtonVisuals.Text("Action", {}), actionOnNewLine = true) + } +} + +@Preview(name = "Snackbar with action and close button on new line", group = PreviewGroup.Snackbars) +@Composable +internal fun SnackbarWithActionOnNewLineAndCloseButtonPreview() { + ElementThemedPreview { + Snackbar( + message = "Snackbar supporting text", + action = ButtonVisuals.Text("Action", {}), + dismissAction = ButtonVisuals.Icon( + IconSource.Vector(CompoundIcons.Close()) + ) {}, + actionOnNewLine = true + ) + } +} 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 0000000..134435f --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Surface.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 { + Surface { + Spacer(modifier = Modifier.size(64.dp)) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Switch.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Switch.kt new file mode 100644 index 0000000..f4bbdce --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Switch.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.SwitchColors +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.minimumInteractiveComponentSize +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.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import androidx.compose.material3.Switch as Material3Switch + +// Designs in https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?type=design&node-id=425%3A24203&mode=design&t=qb99xBP5mwwCtGkN-1 + +@Composable +fun Switch( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: SwitchColors = compoundSwitchColors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + thumbContent: (@Composable () -> Unit)? = null, +) { + Material3Switch( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = modifier.minimumInteractiveComponentSize(), + enabled = enabled, + colors = colors, + interactionSource = interactionSource, + thumbContent = thumbContent + ) +} + +@Composable +internal fun compoundSwitchColors() = SwitchDefaults.colors( + uncheckedThumbColor = ElementTheme.colors.iconSecondary, + uncheckedBorderColor = ElementTheme.colors.borderInteractivePrimary, + uncheckedTrackColor = Color.Transparent, + checkedTrackColor = ElementTheme.colors.bgAccentRest, + disabledUncheckedBorderColor = ElementTheme.colors.borderDisabled, + disabledUncheckedThumbColor = ElementTheme.colors.iconDisabled, + disabledCheckedTrackColor = ElementTheme.colors.iconDisabled, + disabledCheckedBorderColor = ElementTheme.colors.iconDisabled, +) + +@Preview(group = PreviewGroup.Toggles) +@Composable +internal fun SwitchPreview() { + var checked by remember { mutableStateOf(false) } + ElementThemedPreview { + Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Switch(checked = checked, onCheckedChange = { checked = !checked }) + Switch(enabled = false, checked = checked, onCheckedChange = { checked = !checked }) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Switch(checked = !checked, onCheckedChange = { checked = !checked }) + Switch(enabled = false, checked = !checked, onCheckedChange = { checked = !checked }) + } + } + } +} 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 0000000..e7f2a66 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Text.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.FontStyle +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.dp +import io.element.android.compound.utils.toHrf +import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage +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 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, + fontStyle: FontStyle? = null, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + minLines: Int = 1, + maxLines: Int = Int.MAX_VALUE, + inlineContent: ImmutableMap = persistentMapOf(), + 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, + 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() } + +@ExcludeFromCoverage +@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/TextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt new file mode 100644 index 0000000..5e0a81b --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextLayoutResult +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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage +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 + +/** + * https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2008-37137 + */ +@Composable +fun TextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + supportingText: String? = null, + placeholder: String? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + validity: TextFieldValidity = TextFieldValidity.None, + enabled: Boolean = true, + readOnly: Boolean = false, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + onTextLayout: (TextLayoutResult) -> Unit = {}, +) { + val isFocused by interactionSource.collectIsFocusedAsState() + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + textStyle = textFieldStyle(enabled), + interactionSource = interactionSource, + enabled = enabled, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + readOnly = readOnly, + cursorBrush = SolidColor(ElementTheme.colors.textPrimary), + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + visualTransformation = visualTransformation, + onTextLayout = onTextLayout, + ) { innerTextField -> + DecorationBox( + label = label, + readOnly = readOnly, + enabled = enabled, + isFocused = isFocused, + validity = validity, + leadingIcon = leadingIcon, + placeholder = placeholder, + isTextEmpty = value.isEmpty(), + innerTextField = innerTextField, + trailingIcon = trailingIcon, + supportingText = supportingText + ) + } +} + +@Composable +fun TextField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + supportingText: String? = null, + placeholder: String? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + validity: TextFieldValidity? = null, + enabled: Boolean = true, + readOnly: Boolean = false, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + onTextLayout: (TextLayoutResult) -> Unit = {}, +) { + val isFocused by interactionSource.collectIsFocusedAsState() + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + textStyle = textFieldStyle(enabled), + interactionSource = interactionSource, + enabled = enabled, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + readOnly = readOnly, + cursorBrush = SolidColor(ElementTheme.colors.textPrimary), + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + visualTransformation = visualTransformation, + onTextLayout = onTextLayout, + ) { innerTextField -> + DecorationBox( + label = label, + readOnly = readOnly, + enabled = enabled, + isFocused = isFocused, + validity = validity, + leadingIcon = leadingIcon, + placeholder = placeholder, + isTextEmpty = value.text.isEmpty(), + innerTextField = innerTextField, + trailingIcon = trailingIcon, + supportingText = supportingText + ) + } +} + +@Composable +private fun DecorationBox( + label: String?, + enabled: Boolean, + readOnly: Boolean, + isFocused: Boolean, + validity: TextFieldValidity?, + placeholder: String?, + isTextEmpty: Boolean, + supportingText: String?, + leadingIcon: @Composable (() -> Unit)?, + trailingIcon: @Composable (() -> Unit)?, + innerTextField: @Composable () -> Unit, +) { + Column { + if (label != null) { + Text( + text = label, + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyMdRegular, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + TextFieldContainer( + enabled = enabled, + readOnly = readOnly, + isFocused = isFocused, + isError = validity == TextFieldValidity.Invalid + ) { + Row(modifier = Modifier.padding(16.dp)) { + if (leadingIcon != null) { + CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.iconSecondary) { + leadingIcon() + } + Spacer(modifier = Modifier.width(8.dp)) + } + Box(modifier = Modifier.weight(1f)) { + if (placeholder != null && isTextEmpty) { + Text( + text = placeholder, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyLgRegular, + ) + } + innerTextField() + } + if (trailingIcon != null) { + Spacer(modifier = Modifier.width(8.dp)) + CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.iconSecondary) { + trailingIcon() + } + } + } + } + if (supportingText != null) { + Spacer(modifier = Modifier.height(4.dp)) + SupportingTextLayout(validity, supportingText) + } + } +} + +@Composable +private fun TextFieldContainer( + enabled: Boolean, + readOnly: Boolean, + isFocused: Boolean, + isError: Boolean, + content: @Composable () -> Unit +) { + Surface( + shape = RoundedCornerShape(4.dp), + border = if (readOnly) { + null + } else { + BorderStroke( + width = if (isFocused) 2.dp else 1.dp, + color = when { + !enabled -> ElementTheme.colors.borderDisabled + isError -> ElementTheme.colors.borderCriticalPrimary + isFocused -> ElementTheme.colors.borderInteractiveHovered + else -> ElementTheme.colors.borderInteractiveSecondary + } + ) + }, + color = when { + readOnly -> ElementTheme.colors.bgSubtleSecondary + !enabled -> ElementTheme.colors.bgCanvasDisabled + else -> ElementTheme.colors.bgCanvasDefault + }, + content = content + ) +} + +@Composable +private fun SupportingTextLayout(validity: TextFieldValidity?, supportingText: String) { + Row(horizontalArrangement = spacedBy(4.dp)) { + when (validity) { + TextFieldValidity.Invalid -> { + Icon( + imageVector = CompoundIcons.ErrorSolid(), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = ElementTheme.colors.iconCriticalPrimary + ) + } + TextFieldValidity.Valid -> { + Icon( + imageVector = CompoundIcons.CheckCircleSolid(), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = ElementTheme.colors.iconSuccessPrimary + ) + } + else -> Unit + } + Text( + text = supportingText, + color = when (validity) { + TextFieldValidity.Invalid -> ElementTheme.colors.textCriticalPrimary + TextFieldValidity.Valid -> ElementTheme.colors.textSuccessPrimary + else -> ElementTheme.colors.textSecondary + }, + style = ElementTheme.typography.fontBodySmRegular, + ) + } +} + +enum class TextFieldValidity { + None, + Invalid, + Valid +} + +@Composable +private fun textFieldStyle(enabled: Boolean): TextStyle { + return ElementTheme.typography.fontBodyLgRegular.copy( + color = if (enabled) { + ElementTheme.colors.textPrimary + } else { + ElementTheme.colors.textSecondary + } + ) +} + +@Preview(group = PreviewGroup.TextFields, heightDp = 1000) +@Composable +internal fun TextFieldsLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview(group = PreviewGroup.TextFields, heightDp = 1000) +@Composable +internal fun TextFieldsDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +@ExcludeFromCoverage +private fun ContentToPreview() { + Column(modifier = Modifier.padding(4.dp)) { + TextFieldValidity.entries.forEach { validity -> + allBooleans.forEach { enabled -> + allBooleans.forEach { readonly -> + TextField( + onValueChange = {}, + label = "Label", + value = "Hello val=$validity, en=${enabled.asInt()}, ro=${readonly.asInt()}", + supportingText = "Supporting text", + validity = validity, + 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/TopAppBar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TopAppBar.kt new file mode 100644 index 0000000..d6c876d --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TopAppBar.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.LocalContentColor +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +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.designsystem.theme.aliasScreenTitle + +/** + * A top app bar that displays a title string, navigation icon, and actions. + * @param titleStr The title string to display in the top app bar. + * @param modifier The [Modifier] to be applied to this top app bar. + * @param navigationIcon The content to display as the navigation icon. + * @param actions The content to display in the action area of the top app bar. + * @param windowInsets The window insets to apply to this top app bar. + * @param colors The colors used for this top app bar. + * @param scrollBehavior Optional scroll behavior for this top app bar. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopAppBar( + titleStr: String, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, + colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(), + scrollBehavior: TopAppBarScrollBehavior? = null +) { + TopAppBar( + title = { + Text( + text = titleStr, + modifier = Modifier.semantics { heading() }, + style = ElementTheme.typography.aliasScreenTitle, + ) + }, + modifier = modifier, + navigationIcon = navigationIcon, + actions = actions, + windowInsets = windowInsets, + colors = colors, + scrollBehavior = scrollBehavior, + ) +} + +/** + * A top app bar that displays a title, navigation icon, and actions. + * + * @param title The content to display as the title of the top app bar. Do not forget to apply `heading()` to + * the semantics of the title to ensure it is announced correctly by accessibility services. + * @param modifier The [Modifier] to be applied to this top app bar. + * @param navigationIcon The content to display as the navigation icon. + * @param actions The content to display in the action area of the top app bar. + * @param windowInsets The window insets to apply to this top app bar. + * @param colors The colors used for this top app bar. + * @param scrollBehavior Optional scroll behavior for this top app bar. + */ +@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 = { + CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textActionPrimary) { + actions() + } + }, + windowInsets = windowInsets, + colors = colors, + scrollBehavior = scrollBehavior, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(group = PreviewGroup.AppBars) +@Composable +internal fun TopAppBarPreview() = ElementThemedPreview { + TopAppBar( + title = { Text(text = "Title") }, + navigationIcon = { BackButton(onClick = {}) }, + actions = { + TextButton(text = "Action", onClick = {}) + IconButton(onClick = {}) { + Icon( + imageVector = CompoundIcons.ShareAndroid(), + contentDescription = null, + ) + } + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(group = PreviewGroup.AppBars) +@Composable +internal fun TopAppBarStrPreview() = ElementThemedPreview { + TopAppBar( + titleStr = "Title string", + navigationIcon = { BackButton(onClick = {}) }, + actions = { + TextButton(text = "Action", onClick = {}) + IconButton(onClick = {}) { + Icon( + imageVector = CompoundIcons.ShareAndroid(), + contentDescription = null, + ) + } + } + ) +} 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 0000000..b5dfe42 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/DatePickerPreview.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage +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.theme.components.AlertDialogContent + +@Preview(group = PreviewGroup.DateTimePickers) +@Composable +internal fun DatePickerLightPreview() { + ElementPreviewLight { ContentToPreview() } +} + +@Preview(group = PreviewGroup.DateTimePickers) +@Composable +internal fun DatePickerDarkPreview() { + ElementPreviewDark { ContentToPreview() } +} + +@OptIn(ExperimentalMaterial3Api::class) +@ExcludeFromCoverage +@Composable +private fun ContentToPreview() { + val state = rememberDatePickerState( + initialSelectedDateMillis = 1_672_578_000_000L, + ) + AlertDialogContent( + buttons = { /*TODO*/ }, + icon = { /*TODO*/ }, + title = { /*TODO*/ }, + subtitle = null, + content = { DatePicker(state = state, showModeToggle = true) }, + shape = AlertDialogDefaults.shape, + containerColor = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + buttonContentColor = ElementTheme.colors.textPrimary, + 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 0000000..f0bc0ef --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/MenuPreview.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components.previews + +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.compound.tokens.generated.CompoundIcons +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.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(text = "Toggle", onClick = { isExpanded = !isExpanded }) + DropdownMenu(expanded = isExpanded, onDismissRequest = { isExpanded = false }) { + for (i in 0..5) { + val leadingIcon: @Composable (() -> Unit)? = if (i in 2..3) { + @Composable { + Icon( + imageVector = CompoundIcons.Favourite(), + contentDescription = null + ) + } + } else { + null + } + + val trailingIcon: @Composable (() -> Unit)? = if (i in 3..4) { + @Composable { + Icon( + imageVector = CompoundIcons.ChevronRight(), + contentDescription = null, + ) + } + } else { + null + } + DropdownMenuItem( + text = { Text(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/TimePickerPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/TimePickerPreview.kt new file mode 100644 index 0000000..f4562f1 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/TimePickerPreview.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.theme.components.previews + +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +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.compound.theme.ElementTheme +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 +import io.element.android.libraries.designsystem.theme.components.AlertDialogContent + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(widthDp = 600, group = PreviewGroup.DateTimePickers) +@Composable +internal fun TimePickerHorizontalPreview() { + ElementThemedPreview { + AlertDialogContent( + buttons = { /*TODO*/ }, + icon = { /*TODO*/ }, + title = { /*TODO*/ }, + subtitle = null, + content = { TimePicker(state = rememberTimePickerState(), layoutType = TimePickerLayoutType.Horizontal) }, + shape = AlertDialogDefaults.shape, + containerColor = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + buttonContentColor = ElementTheme.colors.textPrimary, + iconContentColor = AlertDialogDefaults.iconContentColor, + titleContentColor = AlertDialogDefaults.titleContentColor, + textContentColor = AlertDialogDefaults.textContentColor, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(group = PreviewGroup.DateTimePickers) +@Composable +internal fun TimePickerVerticalLightPreview() { + ElementPreviewLight { + AlertDialogContent( + buttons = { /*TODO*/ }, + icon = { /*TODO*/ }, + title = { /*TODO*/ }, + subtitle = null, + content = { TimePicker(state = rememberTimePickerState(), layoutType = TimePickerLayoutType.Vertical) }, + shape = AlertDialogDefaults.shape, + containerColor = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + buttonContentColor = ElementTheme.colors.textPrimary, + iconContentColor = AlertDialogDefaults.iconContentColor, + titleContentColor = AlertDialogDefaults.titleContentColor, + textContentColor = AlertDialogDefaults.textContentColor, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(group = PreviewGroup.DateTimePickers) +@Composable +internal fun TimePickerVerticalDarkPreview() { + val pickerState = rememberTimePickerState( + initialHour = 12, + initialMinute = 0, + ) + ElementPreviewDark { + AlertDialogContent( + buttons = { /*TODO*/ }, + icon = { /*TODO*/ }, + title = { /*TODO*/ }, + subtitle = null, + content = { TimePicker(state = pickerState, layoutType = TimePickerLayoutType.Vertical) }, + shape = AlertDialogDefaults.shape, + containerColor = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + buttonContentColor = ElementTheme.colors.textPrimary, + iconContentColor = AlertDialogDefaults.iconContentColor, + titleContentColor = AlertDialogDefaults.titleContentColor, + textContentColor = AlertDialogDefaults.textContentColor, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/AnnotatedString.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/AnnotatedString.kt new file mode 100644 index 0000000..977262e --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/AnnotatedString.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils + +import androidx.compose.runtime.Composable +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 + +@Composable +fun annotatedTextWithBold(text: String, boldText: String): AnnotatedString { + return buildAnnotatedString { + append(text) + val start = text.indexOf(boldText) + val end = start + boldText.length + val textRange = 0..text.length + if (start in textRange && end in textRange) { + addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + } + } +} 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 0000000..c50e20d --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/BooleanProvider.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class BooleanProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf(true, false) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/CommonDrawables.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/CommonDrawables.kt new file mode 100644 index 0000000..5a95f91 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/CommonDrawables.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils + +import io.element.android.libraries.designsystem.R + +typealias CommonDrawables = R.drawable diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/DelayedVisibility.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/DelayedVisibility.kt new file mode 100644 index 0000000..ce5a43a --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/DelayedVisibility.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalInspectionMode +import kotlinx.coroutines.delay +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * Displays the content of [block] after a delay of [duration]. + */ +@Composable +fun DelayedVisibility( + duration: Duration = 300.milliseconds, + block: @Composable () -> Unit, +) { + // Technically this shouldn't be needed because `LocalInspectionMode` won't change, but let's make the linter happy + val movableBlock = remember { movableContentOf { block() } } + if (LocalInspectionMode.current) { + // Just allow the contents to be displayed in the previews/screenshot tests + movableBlock() + } else { + var shouldDisplay by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(duration) + shouldDisplay = true + } + AnimatedVisibility(shouldDisplay) { + movableBlock() + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/DrawScope.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/DrawScope.kt new file mode 100644 index 0000000..ea6d653 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/DrawScope.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils + +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.nativeCanvas + +/** + * Helper to draw to a canvas using layers. This allows us to apply clipping to those layers only. + */ +fun DrawScope.drawWithLayer(block: DrawScope.() -> Unit) { + with(drawContext.canvas.nativeCanvas) { + val checkPoint = saveLayer(null, null) + block() + restoreToCount(checkPoint) + } +} 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 0000000..b6b84f4 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Extensions.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/ForceOrientation.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceOrientation.kt new file mode 100644 index 0000000..fb07ee1 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceOrientation.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils + +import android.content.pm.ActivityInfo +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect + +@Composable +fun ForceOrientation(orientation: ScreenOrientation) { + val activity = LocalActivity.current ?: return + val orientationFlags = when (orientation) { + ScreenOrientation.PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + ScreenOrientation.LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + DisposableEffect(orientation) { + activity.requestedOrientation = orientationFlags + onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } + } +} + +enum class ScreenOrientation { + PORTRAIT, + LANDSCAPE +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceOrientationInMobileDevices.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceOrientationInMobileDevices.kt new file mode 100644 index 0000000..1445c38 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceOrientationInMobileDevices.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils + +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun ForceOrientationInMobileDevices(orientation: ScreenOrientation) { + val windowAdaptiveInfo = currentWindowAdaptiveInfo() + if (windowAdaptiveInfo.windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact || + windowAdaptiveInfo.windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact + ) { + ForceOrientation(orientation = orientation) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/HideKeyboardWhenDisposed.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/HideKeyboardWhenDisposed.kt new file mode 100644 index 0000000..1c9213e --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/HideKeyboardWhenDisposed.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalSoftwareKeyboardController + +@Composable +fun HideKeyboardWhenDisposed() { + val keyboardController = LocalSoftwareKeyboardController.current + DisposableEffect(Unit) { + onDispose { + keyboardController?.hide() + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/KeepScreenOn.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/KeepScreenOn.kt new file mode 100644 index 0000000..689154e --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/KeepScreenOn.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalView + +@Composable +fun KeepScreenOn( + keepScreenOn: Boolean = true +) { + if (keepScreenOn) { + val currentView = LocalView.current + DisposableEffect(Unit) { + currentView.keepScreenOn = true + onDispose { + currentView.keepScreenOn = false + } + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LazyListState.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LazyListState.kt new file mode 100644 index 0000000..f252c69 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LazyListState.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +/** + * Returns whether the lazy list is currently scrolling up. + */ +@Composable +fun LazyListState.isScrollingUp(): Boolean { + var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) } + var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) } + return remember(this) { + derivedStateOf { + if (previousIndex != firstVisibleItemIndex) { + previousIndex > firstVisibleItemIndex + } else { + previousScrollOffset >= firstVisibleItemScrollOffset + }.also { + previousIndex = firstVisibleItemIndex + previousScrollOffset = firstVisibleItemScrollOffset + } + } + }.value +} + +suspend fun LazyListState.animateScrollToItemCenter(index: Int) { + fun LazyListLayoutInfo.containerSize(): Int { + return if (orientation == Orientation.Vertical) { + viewportSize.height + } else { + viewportSize.width + } - beforeContentPadding - afterContentPadding + } + + fun LazyListLayoutInfo.resolveItemOffsetToCenter(index: Int): Int? { + val itemInfo = visibleItemsInfo.firstOrNull { it.index == index } ?: return null + val containerSize = containerSize() + val itemSize = itemInfo.size + return if (itemSize > containerSize) { + itemSize - containerSize / 2 + } else { + -(containerSize() - itemInfo.size) / 2 + } + } + + // await for the first layout. + scroll { } + layoutInfo.resolveItemOffsetToCenter(index)?.let { offset -> + // Item is already visible, just scroll to center. + animateScrollToItem(index, offset) + return + } + // Item is not visible, jump to it... + scrollToItem(index) + // and then adjust according to the actual item size. + layoutInfo.resolveItemOffsetToCenter(index)?.let { offset -> + animateScrollToItem(index, offset) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LocalUiTestMode.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LocalUiTestMode.kt new file mode 100644 index 0000000..6ea781d --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LocalUiTestMode.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils + +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * A composition local that indicates whether the app is running in UI test mode. + */ +val LocalUiTestMode = staticCompositionLocalOf { false } 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 0000000..1ad235d --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/OnLifecycleEvent.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner + +@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/OpenUrlInTabView.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/OpenUrlInTabView.kt new file mode 100644 index 0000000..5ea182d --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/OpenUrlInTabView.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils + +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab + +@Suppress("MutableStateParam") +@Composable +fun OpenUrlInTabView(url: MutableState) { + val activity = requireNotNull(LocalActivity.current) + val darkTheme = ElementTheme.isLightTheme.not() + + LaunchedEffect(url.value) { + url.value?.let { + activity.openUrlInChromeCustomTab(null, darkTheme, it) + url.value = null + } + } +} 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 0000000..f7d2635 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/WindowInsetsExtension.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection + +@Composable +@ReadOnlyComposable +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/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcher.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcher.kt new file mode 100644 index 0000000..56bb070 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcher.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils.snackbar + +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 io.element.android.libraries.designsystem.theme.components.Snackbar +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.sync.Mutex + +/** + * A global dispatcher of [SnackbarMessage] to be displayed in [Snackbar] via a [SnackbarHostState]. + */ +class SnackbarDispatcher { + private val queueMutex = Mutex() + private val snackBarMessageQueue = ArrayDeque() + val snackbarMessage: Flow = flow { + while (currentCoroutineContext().isActive) { + queueMutex.lock() + emit(snackBarMessageQueue.firstOrNull()) + } + } + + fun post(message: SnackbarMessage) { + if (snackBarMessageQueue.isEmpty()) { + snackBarMessageQueue.add(message) + if (queueMutex.isLocked) queueMutex.unlock() + } else { + snackBarMessageQueue.add(message) + } + } + + fun clear() { + if (snackBarMessageQueue.isNotEmpty()) { + snackBarMessageQueue.removeFirstOrNull() + if (queueMutex.isLocked) queueMutex.unlock() + } + } +} + +/** Used to provide a [SnackbarDispatcher] to composable functions, it's needed for [rememberSnackbarHostState]. */ +val LocalSnackbarDispatcher = compositionLocalOf { SnackbarDispatcher() } + +@Composable +fun SnackbarDispatcher.collectSnackbarMessageAsState(): State { + return snackbarMessage.collectAsState(initial = null) +} + +/** + * Helper method to display a [SnackbarMessage] in a [SnackbarHostState] handling cancellations. + */ +@Composable +fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostState { + val snackbarHostState = remember { SnackbarHostState() } + val snackbarMessageText = snackbarMessage?.let { + stringResource(id = snackbarMessage.messageResId) + } ?: return snackbarHostState + + val dispatcher = LocalSnackbarDispatcher.current + LaunchedEffect(snackbarMessage.id) { + // If the message wasn't already displayed, do it now, and mark it as displayed + // This will prevent the message from appearing in any other active SnackbarHosts + if (snackbarMessage.isDisplayed.getAndSet(true).not()) { + try { + snackbarHostState.showSnackbar( + message = snackbarMessageText, + duration = snackbarMessage.duration, + ) + // The snackbar item was displayed and dismissed, clear its message + dispatcher.clear() + } catch (e: CancellationException) { + // The snackbar was being displayed when the coroutine was cancelled, + // so we need to clear its message + dispatcher.clear() + throw e + } + } + } + return snackbarHostState +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarHost.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarHost.kt new file mode 100644 index 0000000..a4cab70 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarHost.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils.snackbar + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.button.ButtonVisuals +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.Snackbar + +@Composable +fun SnackbarHost(hostState: SnackbarHostState, modifier: Modifier = Modifier) { + androidx.compose.material3.SnackbarHost(hostState, modifier) { data -> + Snackbar( + // Add default padding + modifier = Modifier.padding(12.dp), + message = data.visuals.message, + action = data.visuals.actionLabel?.let { ButtonVisuals.Text(it, data::performAction) }, + dismissAction = if (data.visuals.withDismissAction) { + ButtonVisuals.Icon( + IconSource.Vector(CompoundIcons.Close()), + data::dismiss + ) + } else { + null + }, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarMessage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarMessage.kt new file mode 100644 index 0000000..18b1fb6 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarMessage.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils.snackbar + +import androidx.annotation.StringRes +import androidx.compose.material3.SnackbarDuration +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.random.Random + +/** + * A message to be displayed in a [Snackbar]. + * @param messageResId The message to be displayed. + * @param duration The duration of the message. The default value is [SnackbarDuration.Short]. + * @param actionResId The action text to be displayed. The default value is `null`. + * @param isDisplayed Used to track if the current message is already displayed or not. + * @param id The unique identifier of the message. The default value is a random long. + * @param action The action to be performed when the action is clicked. + */ +data class SnackbarMessage( + @StringRes val messageResId: Int, + val duration: SnackbarDuration = SnackbarDuration.Short, + @StringRes val actionResId: Int? = null, + val isDisplayed: AtomicBoolean = AtomicBoolean(false), + val id: Long = Random.nextLong(), + val action: () -> Unit = {}, +) diff --git a/libraries/designsystem/src/main/res/drawable-night-xxhdpi/element_logo.png b/libraries/designsystem/src/main/res/drawable-night-xxhdpi/element_logo.png new file mode 100644 index 0000000..d65c54d Binary files /dev/null and b/libraries/designsystem/src/main/res/drawable-night-xxhdpi/element_logo.png differ diff --git a/libraries/designsystem/src/main/res/drawable-night/bg_migration.png b/libraries/designsystem/src/main/res/drawable-night/bg_migration.png new file mode 100644 index 0000000..442628a Binary files /dev/null and b/libraries/designsystem/src/main/res/drawable-night/bg_migration.png differ 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 0000000..2f51442 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 0000000..b527ef7 --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable-night/pin.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/libraries/designsystem/src/main/res/drawable-xxhdpi/element_logo.png b/libraries/designsystem/src/main/res/drawable-xxhdpi/element_logo.png new file mode 100644 index 0000000..a5e24f3 Binary files /dev/null and b/libraries/designsystem/src/main/res/drawable-xxhdpi/element_logo.png differ diff --git a/libraries/designsystem/src/main/res/drawable/bg_migration.png b/libraries/designsystem/src/main/res/drawable/bg_migration.png new file mode 100644 index 0000000..4d88959 Binary files /dev/null and b/libraries/designsystem/src/main/res/drawable/bg_migration.png differ diff --git a/libraries/designsystem/src/main/res/drawable/ic_notification.xml b/libraries/designsystem/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..cf84d67 --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/libraries/designsystem/src/main/res/drawable/ic_stop.xml b/libraries/designsystem/src/main/res/drawable/ic_stop.xml new file mode 100644 index 0000000..e4cd150 --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_stop.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/designsystem/src/main/res/drawable/ic_winner.xml b/libraries/designsystem/src/main/res/drawable/ic_winner.xml new file mode 100644 index 0000000..6393f87 --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_winner.xml @@ -0,0 +1,19 @@ + + + + + + + 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 0000000..3b9468e 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 0000000..7f26c5a --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/pin.xml @@ -0,0 +1,19 @@ + + + + + + + + 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 0000000..3e2436c --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/sample_avatar.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + 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 0000000..b05f3b3 Binary files /dev/null and b/libraries/designsystem/src/main/res/drawable/sample_background.webp differ diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/colors/AvatarColorsTest.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/colors/AvatarColorsTest.kt new file mode 100644 index 0000000..cbfe74b --- /dev/null +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/colors/AvatarColorsTest.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.colors + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class AvatarColorsTest { + private val maxSize = 6 + @Test + fun `compute string hash`() { + assertThat("@alice:domain.org".toHash(maxSize)).isEqualTo(0) + assertThat("@bob:domain.org".toHash(maxSize)).isEqualTo(1) + assertThat("@charlie:domain.org".toHash(maxSize)).isEqualTo(2) + } + + @Test + fun `compute string hash reverse`() { + assertThat("0".toHash(maxSize)).isEqualTo(0) + assertThat("1".toHash(maxSize)).isEqualTo(1) + assertThat("2".toHash(maxSize)).isEqualTo(2) + assertThat("3".toHash(maxSize)).isEqualTo(3) + assertThat("4".toHash(maxSize)).isEqualTo(4) + assertThat("5".toHash(maxSize)).isEqualTo(5) + assertThat("6".toHash(maxSize)).isEqualTo(0) + assertThat("7".toHash(maxSize)).isEqualTo(1) + } +} diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/component/async/AsyncIndicatorTest.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/component/async/AsyncIndicatorTest.kt new file mode 100644 index 0000000..d53b28d --- /dev/null +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/component/async/AsyncIndicatorTest.kt @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.component.async + +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.rememberTransition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.rememberCoroutineScope +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.designsystem.components.async.AsyncIndicatorItem +import io.element.android.libraries.designsystem.components.async.AsyncIndicatorState +import io.element.android.libraries.designsystem.components.async.hasEntered +import io.element.android.libraries.designsystem.components.async.hasExited +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AsyncIndicatorTest { + @Test + fun `initial state`() = runTest { + val state = AsyncIndicatorState() + moleculeFlow(RecompositionMode.Immediate) { + val transitionState = fakeAsyncIndicatorHost(state = state) + val item = state.currentItem.value + Snapshot( + currentItem = item, + currentAnimationState = TransitionStateSnapshot(transitionState), + ) + }.test { + with(awaitItem()) { + assertThat(currentItem).isNull() + assertThat(currentAnimationState.currentState).isFalse() + assertThat(currentAnimationState.targetState).isFalse() + } + } + } + + @Test + fun `add item with timeout`() = runTest(StandardTestDispatcher()) { + val state = AsyncIndicatorState() + moleculeFlow(RecompositionMode.Immediate) { + val transitionState = fakeAsyncIndicatorHost(state = state) + val item = state.currentItem.value + Snapshot( + currentItem = item, + currentAnimationState = TransitionStateSnapshot(transitionState), + ) + }.test { + skipItems(1) + state.enqueue(durationMs = 1000, composable = {}) + // Give it some time to pre-load the events + advanceTimeBy(1000) + runCurrent() + // First, item is invisible but the target state is visible (will start animating) + with(awaitItem()) { + assertThat(currentItem).isNotNull() + assertThat(currentAnimationState.currentState).isFalse() + assertThat(currentAnimationState.targetState).isTrue() + } + // Then, item is visible and the target state is visible (stopped animating) + with(awaitItem()) { + assertThat(currentItem).isNotNull() + assertThat(currentAnimationState.currentState).isTrue() + assertThat(currentAnimationState.targetState).isTrue() + } + // Then, item is visible and the target state is not visible (will start animating) + with(awaitItem()) { + assertThat(currentItem).isNotNull() + assertThat(currentAnimationState.currentState).isTrue() + assertThat(currentAnimationState.targetState).isFalse() + } + // Then, item is not visible and the target state is not visible (stopped animating) + with(awaitItem()) { + assertThat(currentItem).isNotNull() + assertThat(currentAnimationState.currentState).isFalse() + assertThat(currentAnimationState.targetState).isFalse() + } + // Finally, the current item is removed + with(awaitItem()) { + assertThat(currentItem).isNull() + } + } + } + + @Test + fun `add item without timeout`() = runTest(StandardTestDispatcher()) { + val state = AsyncIndicatorState() + moleculeFlow(RecompositionMode.Immediate) { + val transitionState = fakeAsyncIndicatorHost(state = state) + val item = state.currentItem.value + Snapshot( + currentItem = item, + currentAnimationState = TransitionStateSnapshot(transitionState), + ) + }.test { + skipItems(1) + state.enqueue(composable = {}) + // First, item is invisible but the target state is visible (will start animating) + with(awaitItem()) { + assertThat(currentItem).isNotNull() + assertThat(currentAnimationState.currentState).isFalse() + assertThat(currentAnimationState.targetState).isTrue() + } + // Then, item is visible and the target state is visible (stopped animating) + with(awaitItem()) { + assertThat(currentItem).isNotNull() + assertThat(currentAnimationState.currentState).isTrue() + assertThat(currentAnimationState.targetState).isTrue() + } + // That's all, the current item will be displayed indefinitely + ensureAllEventsConsumed() + } + } + + @Test + fun `add item without timeout then clear`() = runTest(StandardTestDispatcher()) { + val state = AsyncIndicatorState() + moleculeFlow(RecompositionMode.Immediate) { + val transitionState = fakeAsyncIndicatorHost(state = state) + val item = state.currentItem.value + Snapshot( + currentItem = item, + currentAnimationState = TransitionStateSnapshot(transitionState), + ) + }.test { + skipItems(1) + state.enqueue(composable = {}) + // First, item is invisible but the target state is visible (will start animating) + with(awaitItem()) { + assertThat(currentItem).isNotNull() + assertThat(currentAnimationState.currentState).isFalse() + assertThat(currentAnimationState.targetState).isTrue() + } + // Then, item is visible and the target state is visible (stopped animating) + with(awaitItem()) { + assertThat(currentItem).isNotNull() + assertThat(currentAnimationState.currentState).isTrue() + assertThat(currentAnimationState.targetState).isTrue() + } + // Clear the current item + state.clear() + // Animating the exit animation + with(awaitItem()) { + assertThat(currentItem).isNotNull() + assertThat(currentAnimationState.currentState).isTrue() + assertThat(currentAnimationState.targetState).isFalse() + } + // Current item is no longer visible + with(awaitItem()) { + assertThat(currentItem).isNotNull() + assertThat(currentAnimationState.currentState).isFalse() + assertThat(currentAnimationState.targetState).isFalse() + } + // Finally, the current item is removed + with(awaitItem()) { + assertThat(currentItem).isNull() + } + } + } + + @Test + fun `add item without timeout, then another one`() = runTest(StandardTestDispatcher()) { + val state = AsyncIndicatorState() + moleculeFlow(RecompositionMode.Immediate) { + val transitionState = fakeAsyncIndicatorHost(state = state) + val item = state.currentItem.value + Snapshot( + currentItem = item, + currentAnimationState = TransitionStateSnapshot(transitionState), + ) + }.test { + var firstItem: Any? + skipItems(1) + state.enqueue(composable = {}) + state.enqueue(composable = {}) + // First, item is invisible but the target state is visible (will start animating) + with(awaitItem()) { + firstItem = currentItem + assertThat(currentItem).isNotNull() + assertThat(currentAnimationState.currentState).isFalse() + assertThat(currentAnimationState.targetState).isTrue() + } + // Then, item is visible and the target state is visible (stopped animating) + with(awaitItem()) { + assertThat(currentItem).isNotNull() + assertThat(currentAnimationState.currentState).isTrue() + assertThat(currentAnimationState.targetState).isTrue() + } + // Then, item is visible and the target state is not visible (will start animating) + with(awaitItem()) { + assertThat(currentItem).isNotNull() + assertThat(currentAnimationState.currentState).isTrue() + assertThat(currentAnimationState.targetState).isFalse() + } + // Then, item is not visible and the target state is not visible (stopped animating) + with(awaitItem()) { + assertThat(currentItem).isNotNull() + assertThat(currentAnimationState.currentState).isFalse() + assertThat(currentAnimationState.targetState).isFalse() + } + // Then a new item will be not visible and its target animation visible + with(awaitItem()) { + assertThat(currentItem).isNotNull() + assertThat(firstItem).isNotEqualTo(currentItem) + assertThat(currentAnimationState.currentState).isFalse() + assertThat(currentAnimationState.targetState).isTrue() + } + // Finally, the second item is visible and not animating + with(awaitItem()) { + assertThat(currentItem).isNotNull() + assertThat(firstItem).isNotEqualTo(currentItem) + assertThat(currentAnimationState.currentState).isTrue() + assertThat(currentAnimationState.targetState).isTrue() + } + // That's all, the current item will be displayed indefinitely + ensureAllEventsConsumed() + } + } + + @Composable + private fun fakeAsyncIndicatorHost(state: AsyncIndicatorState): Transition? { + val coroutineScope = rememberCoroutineScope() + val transition = state.currentItem.value?.let { + // If there is an item, update its transition state to simulate an animation + rememberTransition(state.currentAnimationState, label = "") + } + if (state.currentAnimationState.hasEntered() && state.currentItem.value?.durationMs != null) { + SideEffect { + coroutineScope.launch { + delay(state.currentItem.value!!.durationMs!!) + state.nextState() + } + } + } else if (state.currentItem.value != null && state.currentAnimationState.hasExited()) { + SideEffect { + state.nextState() + } + } + return transition + } + + private data class Snapshot( + val currentItem: AsyncIndicatorItem?, + val currentAnimationState: TransitionStateSnapshot, + ) + + private data class TransitionStateSnapshot( + val currentState: Boolean, + val targetState: Boolean, + ) { + constructor(transition: Transition?) : this( + currentState = transition?.currentState ?: false, + targetState = transition?.targetState ?: false, + ) + } +} diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataTest.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataTest.kt new file mode 100644 index 0000000..8b76b29 --- /dev/null +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.components.avatar + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class AvatarDataTest { + @Test + fun `initial with text should get the first char, uppercased`() { + val data = AvatarData("id", "test", null, AvatarSize.InviteSender) + assertThat(data.initialLetter).isEqualTo("T") + } + + @Test + fun `initial with leading whitespace should get the first non-whitespace char, uppercased`() { + val data = AvatarData("id", " test", null, AvatarSize.InviteSender) + assertThat(data.initialLetter).isEqualTo("T") + } + + @Test + fun `initial with long emoji should get the full emoji`() { + val data = AvatarData("id", "\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08 Test", null, AvatarSize.InviteSender) + assertThat(data.initialLetter).isEqualTo("\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08") + } + + @Test + fun `initial with short emoji should get the emoji`() { + val data = AvatarData("id", "✂ Test", null, AvatarSize.InviteSender) + assertThat(data.initialLetter).isEqualTo("✂") + } + + @Test + fun `initial with a single letter should take that letter`() { + val data = AvatarData("id", "T", null, AvatarSize.InviteSender) + assertThat(data.initialLetter).isEqualTo("T") + } +} diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcherTest.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcherTest.kt new file mode 100644 index 0000000..91c32b1 --- /dev/null +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcherTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils.snackbar + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SnackbarDispatcherTest { + @Test + fun `given an empty queue the flow emits a null item`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + snackbarDispatcher.snackbarMessage.test { + assertThat(awaitItem()).isNull() + } + } + + @Test + fun `given an empty queue calling clear does nothing`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + snackbarDispatcher.snackbarMessage.test { + assertThat(awaitItem()).isNull() + snackbarDispatcher.clear() + expectNoEvents() + } + } + + @Test + fun `given a non-empty queue the flow emits an item`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + snackbarDispatcher.snackbarMessage.test { + snackbarDispatcher.post(SnackbarMessage(0)) + val result = expectMostRecentItem() + assertThat(result).isNotNull() + } + } + + @Test + fun `given a call to clear, the current message is cleared`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + snackbarDispatcher.snackbarMessage.test { + snackbarDispatcher.post(SnackbarMessage(0)) + val item = expectMostRecentItem() + assertThat(item).isNotNull() + snackbarDispatcher.clear() + assertThat(awaitItem()).isNull() + } + } + + @Test + fun `given 2 message emissions, the next message is displayed only after a call to clear`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + snackbarDispatcher.snackbarMessage.test { + val messageA = SnackbarMessage(0) + val messageB = SnackbarMessage(1) + + // Send message A - it is the most recent item + snackbarDispatcher.post(messageA) + assertThat(expectMostRecentItem()).isEqualTo(messageA) + + // Send message B - message A is still the most recent item + snackbarDispatcher.post(messageB) + expectNoEvents() + + // Clear the last message - message B is now the most recent item + snackbarDispatcher.clear() + assertThat(expectMostRecentItem()).isEqualTo(messageB) + + // Clear again - the queue is empty + snackbarDispatcher.clear() + assertThat(awaitItem()).isNull() + } + } +} diff --git a/libraries/di/.gitignore b/libraries/di/.gitignore new file mode 100644 index 0000000..42afabf --- /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 0000000..2444392 --- /dev/null +++ b/libraries/di/build.gradle.kts @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + alias(libs.plugins.kotlin.jvm) + id("com.android.lint") +} + +dependencies { + api(libs.metro.runtime) +} diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/BaseDirectory.kt b/libraries/di/src/main/kotlin/io/element/android/libraries/di/BaseDirectory.kt new file mode 100644 index 0000000..37c4bbc --- /dev/null +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/BaseDirectory.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.di + +import dev.zacsweers.metro.Qualifier + +/** + * Qualifies a [File] object which represents the application base directory. + */ +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +@Qualifier +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FIELD, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.VALUE_PARAMETER, + AnnotationTarget.TYPE, +) +public annotation class BaseDirectory diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/CacheDirectory.kt b/libraries/di/src/main/kotlin/io/element/android/libraries/di/CacheDirectory.kt new file mode 100644 index 0000000..9664a40 --- /dev/null +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/CacheDirectory.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.di + +import dev.zacsweers.metro.Qualifier + +/** + * Qualifies a [File] object which represents the application cache directory. + */ +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +@Qualifier +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FIELD, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.VALUE_PARAMETER, + AnnotationTarget.TYPE, +) +public annotation class CacheDirectory diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/DependencyInjectionGraphOwner.kt b/libraries/di/src/main/kotlin/io/element/android/libraries/di/DependencyInjectionGraphOwner.kt new file mode 100644 index 0000000..21482bd --- /dev/null +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/DependencyInjectionGraphOwner.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.di + +/** + * A [DependencyInjectionGraphOwner] is anything that "owns" a DI Graph. + * + */ +interface DependencyInjectionGraphOwner { + /** This is either a graph, or a list of graphs. */ + val graph: Any +} 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 0000000..8ca1f4b --- /dev/null +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/RoomScope.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..5952b18 --- /dev/null +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/SessionScope.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.di + +abstract class SessionScope private constructor() diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/annotations/AppCoroutineScope.kt b/libraries/di/src/main/kotlin/io/element/android/libraries/di/annotations/AppCoroutineScope.kt new file mode 100644 index 0000000..5838bba --- /dev/null +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/annotations/AppCoroutineScope.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.di.annotations + +import dev.zacsweers.metro.Qualifier + +/** + * Qualifies a [CoroutineScope] object which represents the base coroutine scope to use for the application. + */ +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +@Qualifier +annotation class AppCoroutineScope diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/annotations/ApplicationContext.kt b/libraries/di/src/main/kotlin/io/element/android/libraries/di/annotations/ApplicationContext.kt new file mode 100644 index 0000000..d823ac7 --- /dev/null +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/annotations/ApplicationContext.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.di.annotations + +import dev.zacsweers.metro.Qualifier + +/** + * Qualifies a [Context] object that represents the application context. + */ +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +@Qualifier +annotation class ApplicationContext diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/annotations/RoomCoroutineScope.kt b/libraries/di/src/main/kotlin/io/element/android/libraries/di/annotations/RoomCoroutineScope.kt new file mode 100644 index 0000000..96ef331 --- /dev/null +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/annotations/RoomCoroutineScope.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.di.annotations + +import dev.zacsweers.metro.Qualifier + +/** + * Qualifies a [CoroutineScope] object which represents the base coroutine scope to use for an active room. + */ +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +@Qualifier +annotation class RoomCoroutineScope diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/annotations/SessionCoroutineScope.kt b/libraries/di/src/main/kotlin/io/element/android/libraries/di/annotations/SessionCoroutineScope.kt new file mode 100644 index 0000000..e93b238 --- /dev/null +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/annotations/SessionCoroutineScope.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.di.annotations + +import dev.zacsweers.metro.Qualifier + +/** + * Qualifies a [CoroutineScope] object which represents the base coroutine scope to use for an active session. + */ +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +@Qualifier +annotation class SessionCoroutineScope diff --git a/libraries/encrypted-db/build.gradle.kts b/libraries/encrypted-db/build.gradle.kts new file mode 100644 index 0000000..a215094 --- /dev/null +++ b/libraries/encrypted-db/build.gradle.kts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.encrypteddb" + + buildTypes { + release { + isMinifyEnabled = false + consumerProguardFiles("consumer-proguard-rules.pro") + } + } +} + +dependencies { + implementation(libs.sqldelight.driver.android) + implementation(libs.sqlcipher) + implementation(libs.sqlite) + implementation(libs.google.tink) + + 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 0000000..f1b4245 --- /dev/null +++ b/libraries/encrypted-db/consumer-proguard-rules.pro @@ -0,0 +1,21 @@ +# 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 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 0000000..e4ac1a9 --- /dev/null +++ b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/SqlCipherDriverFactory.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.encrypteddb + +import android.content.Context +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.db.SqlSchema +import app.cash.sqldelight.driver.android.AndroidSqliteDriver +import io.element.encrypteddb.passphrase.PassphraseProvider +import net.zetetic.database.sqlcipher.SupportOpenHelperFactory + +/** + * Creates an encrypted version of the [SqlDriver] using SQLCipher's [SupportOpenHelperFactory]. + * @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: SqlSchema>, name: String, context: Context): SqlDriver { + System.loadLibrary("sqlcipher") + val passphrase = passphraseProvider.getPassphrase() + val factory = SupportOpenHelperFactory(passphrase) + return AndroidSqliteDriver(schema = schema, context = context, name = name, factory = factory) + } +} diff --git a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/crypto/EncryptedFile.kt b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/crypto/EncryptedFile.kt new file mode 100644 index 0000000..ee6b381 --- /dev/null +++ b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/crypto/EncryptedFile.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.encrypteddb.crypto + +import android.content.Context +import com.google.crypto.tink.KeyTemplates +import com.google.crypto.tink.RegistryConfiguration +import com.google.crypto.tink.StreamingAead +import com.google.crypto.tink.integration.android.AndroidKeysetManager +import com.google.crypto.tink.streamingaead.StreamingAeadConfig +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream + +/** + * This class is used to write and read encrypted data to/from a file. + * + * It's a simplified version of the same class in [androidx.security.crypto](https://developer.android.com/reference/androidx/security/crypto/package-summary). + * + * It uses hardcoded constants that are used in that library, for backwards compatibility reasons. + */ +internal class EncryptedFile( + private val context: Context, + private val file: File +) { + companion object { + /** + * The file content is encrypted using StreamingAead with AES-GCM, with the file name as associated data. + */ + private const val KEYSET_ENCRYPTION_SCHEME = "AES256_GCM_HKDF_4KB" + + /** + * The keyset is stored in a shared preference file with this name. + */ + private const val KEYSET_PREF_FILE_NAME = "__androidx_security_crypto_encrypted_file_pref__" + + /** + * The keyset is stored in a shared preference with this key, inside the specified file. + */ + private const val KEYSET_ALIAS = "__androidx_security_crypto_encrypted_file_keyset__" + + /** + * The URI referencing the master key in the Android Keystore used to encrypt/decrypt the keyset. + */ + private const val MASTER_KEY_URI = "android-keystore://_androidx_security_master_key_" + } + + private val androidKeysetManager by lazy { + val keysetManagerBuilder = AndroidKeysetManager.Builder() + .withKeyTemplate(KeyTemplates.get(KEYSET_ENCRYPTION_SCHEME)) + .withSharedPref(context, KEYSET_ALIAS, KEYSET_PREF_FILE_NAME) + .withMasterKeyUri(MASTER_KEY_URI) + + keysetManagerBuilder.build() + } + + private val streamingAead: StreamingAead by lazy { + val streamingAeadKeysetHandle = androidKeysetManager.keysetHandle + streamingAeadKeysetHandle.getPrimitive(RegistryConfiguration.get(), StreamingAead::class.java) + } + + init { + StreamingAeadConfig.register() + } + + fun openFileOutput(): FileOutputStream { + val fos = FileOutputStream(file) + val stream = streamingAead.newEncryptingStream(fos, file.name.toByteArray()) + return EncryptedFileOutputStream(fos.fd, stream) + } + + fun openFileInput(): FileInputStream { + val fis = FileInputStream(file) + val stream = streamingAead.newDecryptingStream(fis, file.name.toByteArray()) + return EncryptedFileInputStream(fis.fd, stream) + } +} diff --git a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/crypto/EncryptedFileInputStream.kt b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/crypto/EncryptedFileInputStream.kt new file mode 100644 index 0000000..3914402 --- /dev/null +++ b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/crypto/EncryptedFileInputStream.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.encrypteddb.crypto + +import android.os.Build +import androidx.annotation.RequiresApi +import java.io.FileDescriptor +import java.io.FileInputStream +import java.io.InputStream + +/** + * This class is used to read encrypted data from a file. + * + * It comes directly from [androidx.security.crypto](https://developer.android.com/reference/androidx/security/crypto/package-summary). + */ +internal class EncryptedFileInputStream( + fileDescriptor: FileDescriptor, + private val inputStream: InputStream, +) : FileInputStream(fileDescriptor) { + private val lock = Any() + + override fun read(): Int = inputStream.read() + + override fun read(b: ByteArray?): Int = inputStream.read(b) + + override fun read(b: ByteArray?, off: Int, len: Int): Int = inputStream.read(b, off, len) + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + override fun readAllBytes(): ByteArray? = inputStream.readAllBytes() + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + override fun readNBytes(b: ByteArray?, off: Int, len: Int): Int = inputStream.readNBytes(b, off, len) + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + override fun readNBytes(len: Int): ByteArray? = inputStream.readNBytes(len) + + override fun skip(n: Long): Long = inputStream.skip(n) + + override fun available(): Int = inputStream.available() + + override fun mark(readlimit: Int) = synchronized(lock) { inputStream.mark(readlimit) } + + override fun markSupported(): Boolean = inputStream.markSupported() + + override fun reset() = synchronized(lock) { inputStream.reset() } + + override fun close() = inputStream.close() +} diff --git a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/crypto/EncryptedFileOutputStream.kt b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/crypto/EncryptedFileOutputStream.kt new file mode 100644 index 0000000..6d0d19a --- /dev/null +++ b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/crypto/EncryptedFileOutputStream.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.encrypteddb.crypto + +import java.io.FileDescriptor +import java.io.FileOutputStream +import java.io.OutputStream + +/** + * This class is used to write encrypted data to a file. + * + * It comes directly from [androidx.security.crypto](https://developer.android.com/reference/androidx/security/crypto/package-summary). + */ +internal class EncryptedFileOutputStream( + fileDescriptor: FileDescriptor, + private val outputStream: OutputStream +) : FileOutputStream(fileDescriptor) { + override fun write(b: ByteArray?) = outputStream.write(b) + + override fun write(b: ByteArray?, off: Int, len: Int) = outputStream.write(b, off, len) + + override fun write(b: Int) = outputStream.write(b) + + override fun flush() = outputStream.flush() + + override fun close() = outputStream.close() +} 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 0000000..a72dc73 --- /dev/null +++ b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/PassphraseProvider.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..fe78a4a --- /dev/null +++ b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/RandomSecretPassphraseProvider.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.encrypteddb.passphrase + +import android.content.Context +import io.element.encrypteddb.crypto.EncryptedFile +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 = EncryptedFile(context, 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 0000000..6e97cf5 --- /dev/null +++ b/libraries/eventformatter/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/PinnedMessagesBannerFormatter.kt b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/PinnedMessagesBannerFormatter.kt new file mode 100644 index 0000000..67994f1 --- /dev/null +++ b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/PinnedMessagesBannerFormatter.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.eventformatter.api + +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem + +interface PinnedMessagesBannerFormatter { + fun format(event: EventTimelineItem): CharSequence +} diff --git a/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/RoomLatestEventFormatter.kt b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/RoomLatestEventFormatter.kt new file mode 100644 index 0000000..abb5cc9 --- /dev/null +++ b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/RoomLatestEventFormatter.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.eventformatter.api + +import io.element.android.libraries.matrix.api.roomlist.LatestEventValue + +interface RoomLatestEventFormatter { + fun format(latestEvent: LatestEventValue.Local, isDmRoom: Boolean): CharSequence? + fun format(latestEvent: LatestEventValue.Remote, 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 0000000..be601fb --- /dev/null +++ b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/TimelineEventFormatter.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.eventformatter.api + +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.EventTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName + +interface TimelineEventFormatter { + fun format(event: EventTimelineItem): CharSequence? { + return format( + content = event.content, + isOutgoing = event.isOwn, + sender = event.sender, + senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender), + ) + } + fun format(content: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): CharSequence? +} diff --git a/libraries/eventformatter/impl/build.gradle.kts b/libraries/eventformatter/impl/build.gradle.kts new file mode 100644 index 0000000..939344d --- /dev/null +++ b/libraries/eventformatter/impl/build.gradle.kts @@ -0,0 +1,40 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.eventformatter.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.uiStrings) + implementation(projects.services.toolbox.api) + api(projects.libraries.eventformatter.api) + + testCommonDependencies(libs) + testImplementation(projects.services.toolbox.impl) + testImplementation(projects.libraries.matrix.test) +} diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt new file mode 100644 index 0000000..7878245 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.eventformatter.impl + +import androidx.annotation.StringRes +import androidx.compose.ui.text.AnnotatedString +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.eventformatter.api.PinnedMessagesBannerFormatter +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +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.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.OtherMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType +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.VideoMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName +import io.element.android.libraries.matrix.ui.messages.toPlainText +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.toolbox.api.strings.StringProvider + +@ContributesBinding(SessionScope::class) +class DefaultPinnedMessagesBannerFormatter( + private val sp: StringProvider, + private val permalinkParser: PermalinkParser, +) : PinnedMessagesBannerFormatter { + override fun format(event: EventTimelineItem): CharSequence { + return when (val content = event.content) { + is MessageContent -> processMessageContents(event, content) + is StickerContent -> { + val text = content.body ?: content.filename + text.prefixWith(CommonStrings.common_sticker) + } + is UnableToDecryptContent -> { + sp.getString(CommonStrings.common_waiting_for_decryption_key) + } + is PollContent -> { + content.question.prefixWith(CommonStrings.a11y_poll) + } + RedactedContent -> { + sp.getString(CommonStrings.common_message_removed) + } + else -> { + sp.getString(CommonStrings.common_unsupported_event) + } + } + } + + private fun processMessageContents( + event: EventTimelineItem, + messageContent: MessageContent, + ): CharSequence { + return when (val messageType: MessageType = messageContent.type) { + is EmoteMessageType -> { + val senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender) + "* $senderDisambiguatedDisplayName ${messageType.body}" + } + is TextMessageType -> { + messageType.toPlainText(permalinkParser) + } + is VideoMessageType -> { + messageType.bestDescription.prefixWith(CommonStrings.common_video) + } + is ImageMessageType -> { + messageType.bestDescription.prefixWith(CommonStrings.common_image) + } + is StickerMessageType -> { + messageType.bestDescription.prefixWith(CommonStrings.common_sticker) + } + is LocationMessageType -> { + messageType.body.prefixWith(CommonStrings.common_shared_location) + } + is FileMessageType -> { + messageType.bestDescription.prefixWith(CommonStrings.common_file) + } + is AudioMessageType -> { + messageType.bestDescription.prefixWith(CommonStrings.common_audio) + } + is VoiceMessageType -> { + // In this case, do not use bestDescription, because the filename is useless, only use the caption if available. + messageType.caption?.prefixWith(sp.getString(CommonStrings.common_voice_message)) + ?: sp.getString(CommonStrings.common_voice_message) + } + is OtherMessageType -> { + messageType.body + } + is NoticeMessageType -> { + messageType.body + } + } + } + + private fun CharSequence.prefixWith(@StringRes res: Int): AnnotatedString { + val prefix = sp.getString(res) + return prefixWith(prefix) + } +} diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt new file mode 100644 index 0000000..7e1ffc3 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.eventformatter.impl + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter +import io.element.android.libraries.eventformatter.impl.mode.RenderingMode +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.roomlist.LatestEventValue +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent +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.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.LegacyCallInviteContent +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.OtherMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +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.StickerMessageType +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.VideoMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName +import io.element.android.libraries.matrix.ui.messages.toPlainText +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.toolbox.api.strings.StringProvider + +@ContributesBinding(SessionScope::class) +class DefaultRoomLatestEventFormatter( + private val sp: StringProvider, + private val roomMembershipContentFormatter: RoomMembershipContentFormatter, + private val profileChangeContentFormatter: ProfileChangeContentFormatter, + private val stateContentFormatter: StateContentFormatter, + private val permalinkParser: PermalinkParser, +) : RoomLatestEventFormatter { + companion object { + // Max characters to display in the last message. This works around https://github.com/element-hq/element-x-android/issues/2105 + private const val MAX_SAFE_LENGTH = 500 + } + + override fun format( + latestEvent: LatestEventValue.Local, + isDmRoom: Boolean, + ): CharSequence? = formatContent( + content = latestEvent.content, + isDmRoom = isDmRoom, + isOutgoing = true, + senderId = latestEvent.senderId, + senderDisambiguatedDisplayName = latestEvent.senderProfile.getDisambiguatedDisplayName(latestEvent.senderId) + ) + + override fun format( + latestEvent: LatestEventValue.Remote, + isDmRoom: Boolean, + ): CharSequence? = formatContent( + content = latestEvent.content, + isDmRoom = isDmRoom, + isOutgoing = latestEvent.isOwn, + senderId = latestEvent.senderId, + senderDisambiguatedDisplayName = latestEvent.senderProfile.getDisambiguatedDisplayName(latestEvent.senderId) + ) + + private fun formatContent( + content: EventContent, + isDmRoom: Boolean, + isOutgoing: Boolean, + senderId: UserId, + senderDisambiguatedDisplayName: String + ): CharSequence? { + return when (content) { + is MessageContent -> content.process(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) + RedactedContent -> { + val message = sp.getString(CommonStrings.common_message_removed) + message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) + } + is StickerContent -> { + val message = sp.getString(CommonStrings.common_sticker) + " (" + content.bestDescription + ")" + message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) + } + is UnableToDecryptContent -> { + val message = sp.getString(CommonStrings.common_waiting_for_decryption_key) + message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) + } + is RoomMembershipContent -> { + roomMembershipContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing) + } + is ProfileChangeContent -> { + profileChangeContentFormatter.format(content, senderId, senderDisambiguatedDisplayName, isOutgoing) + } + is StateContent -> { + stateContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing, RenderingMode.RoomList) + } + is PollContent -> { + val message = sp.getString(CommonStrings.common_poll_summary, content.question) + message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) + } + is FailedToParseMessageLikeContent, is FailedToParseStateContent, is UnknownContent -> { + val message = sp.getString(CommonStrings.common_unsupported_event) + message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) + } + is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call) + is CallNotifyContent -> sp.getString(CommonStrings.common_call_started) + }?.take(MAX_SAFE_LENGTH) + } + + private fun MessageContent.process( + senderDisambiguatedDisplayName: String, + isDmRoom: Boolean, + isOutgoing: Boolean + ): CharSequence { + val message = when (val messageType: MessageType = type) { + // Doesn't need a prefix + is EmoteMessageType -> { + return "* $senderDisambiguatedDisplayName ${messageType.body}" + } + is TextMessageType -> { + messageType.toPlainText(permalinkParser) + } + is VideoMessageType -> { + messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_video)) + } + is ImageMessageType -> { + messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_image)) + } + is StickerMessageType -> { + messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_sticker)) + } + is LocationMessageType -> { + sp.getString(CommonStrings.common_shared_location) + } + is FileMessageType -> { + messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_file)) + } + is AudioMessageType -> { + messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_audio)) + } + is VoiceMessageType -> { + // In this case, do not use bestDescription, because the filename is useless, only use the caption if available. + messageType.caption?.prefixWith(sp.getString(CommonStrings.common_voice_message)) + ?: sp.getString(CommonStrings.common_voice_message) + } + is OtherMessageType -> { + messageType.body + } + is NoticeMessageType -> { + messageType.body + } + } + return message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) + } + + private fun CharSequence.prefixIfNeeded( + senderDisambiguatedDisplayName: String, + isDmRoom: Boolean, + isOutgoing: Boolean, + ): CharSequence = if (isDmRoom) { + this + } else { + prefixWith( + if (isOutgoing) { + sp.getString(CommonStrings.common_you) + } else { + senderDisambiguatedDisplayName + } + ) + } +} 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 0000000..9657f87 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.eventformatter.impl + +import dev.zacsweers.metro.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.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent +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.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +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.api.timeline.item.event.getDisambiguatedDisplayName +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.toolbox.api.strings.StringProvider + +@ContributesBinding(SessionScope::class) +class DefaultTimelineEventFormatter( + private val sp: StringProvider, + 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 = event.isOwn + val senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender) + return format(event.content, isOutgoing, event.sender, senderDisambiguatedDisplayName) + } + + override fun format(content: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): CharSequence? { + return when (content) { + is RoomMembershipContent -> { + roomMembershipContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing) + } + is ProfileChangeContent -> { + profileChangeContentFormatter.format(content, sender, senderDisambiguatedDisplayName, isOutgoing) + } + is StateContent -> { + stateContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing, RenderingMode.Timeline) + } + is CallNotifyContent -> { + sp.getString(CommonStrings.common_call_started) + } + RedactedContent, + is LegacyCallInviteContent, + is StickerContent, + is PollContent, + is UnableToDecryptContent, + is MessageContent, + is FailedToParseMessageLikeContent, + is FailedToParseStateContent, + is UnknownContent -> { + if (buildMeta.isDebuggable) { + error("You should not use this formatter for this event content: $content") + } + sp.getString(CommonStrings.common_unsupported_event) + } + } + } +} diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/PrefixWith.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/PrefixWith.kt new file mode 100644 index 0000000..51fdccf --- /dev/null +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/PrefixWith.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 + +internal fun CharSequence.prefixWith(prefix: String): AnnotatedString { + return buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(prefix) + } + append(": ") + append(this@prefixWith) + } +} 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 0000000..aa08fb9 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/ProfileChangeContentFormatter.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.eventformatter.impl + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent +import io.element.android.services.toolbox.api.strings.StringProvider + +@Inject +class ProfileChangeContentFormatter( + private val sp: StringProvider, +) { + fun format( + profileChangeContent: ProfileChangeContent, + senderId: UserId, + senderDisambiguatedDisplayName: String, + senderIsYou: Boolean, + ): String? = profileChangeContent.run { + val displayNameChanged = displayName != prevDisplayName + val avatarChanged = avatarUrl != prevAvatarUrl + return when { + avatarChanged && displayNameChanged -> { + val message = format( + profileChangeContent = profileChangeContent.copy(avatarUrl = null, prevAvatarUrl = null), + senderId = senderId, + senderDisambiguatedDisplayName = senderDisambiguatedDisplayName, + senderIsYou = 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, senderId.value, 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, senderId.value, 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, senderId.value, 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, senderDisambiguatedDisplayName) + } + } + 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 0000000..9434d7e --- /dev/null +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.eventformatter.impl + +import dev.zacsweers.metro.Inject +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 + +@Inject +class RoomMembershipContentFormatter( + private val matrixClient: MatrixClient, + private val sp: StringProvider, +) { + fun format( + membershipContent: RoomMembershipContent, + senderDisambiguatedDisplayName: String, + senderIsYou: Boolean, + ): CharSequence? { + val userId = membershipContent.userId + val memberIsYou = matrixClient.isMe(userId) + val userDisplayNameOrId = membershipContent.userDisplayName ?: userId.value + val reason = membershipContent.reason?.takeIf { it.isNotBlank() } + 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, senderDisambiguatedDisplayName) + } + MembershipChange.LEFT -> if (memberIsYou) { + sp.getString(R.string.state_event_room_leave_by_you) + } else { + sp.getString(R.string.state_event_room_leave, senderDisambiguatedDisplayName) + } + MembershipChange.BANNED, MembershipChange.KICKED_AND_BANNED -> if (senderIsYou) { + if (reason != null) { + sp.getString(R.string.state_event_room_ban_by_you_with_reason, userDisplayNameOrId, reason) + } else { + sp.getString(R.string.state_event_room_ban_by_you, userDisplayNameOrId) + } + } else { + if (reason != null) { + sp.getString(R.string.state_event_room_ban_with_reason, senderDisambiguatedDisplayName, userDisplayNameOrId, reason) + } else { + sp.getString(R.string.state_event_room_ban, senderDisambiguatedDisplayName, userDisplayNameOrId) + } + } + MembershipChange.UNBANNED -> if (senderIsYou) { + sp.getString(R.string.state_event_room_unban_by_you, userDisplayNameOrId) + } else { + sp.getString(R.string.state_event_room_unban, senderDisambiguatedDisplayName, userDisplayNameOrId) + } + MembershipChange.KICKED -> if (senderIsYou) { + if (reason != null) { + sp.getString(R.string.state_event_room_remove_by_you_with_reason, userDisplayNameOrId, reason) + } else { + sp.getString(R.string.state_event_room_remove_by_you, userDisplayNameOrId) + } + } else { + if (reason != null) { + sp.getString(R.string.state_event_room_remove_with_reason, senderDisambiguatedDisplayName, userDisplayNameOrId, reason) + } else { + sp.getString(R.string.state_event_room_remove, senderDisambiguatedDisplayName, userDisplayNameOrId) + } + } + MembershipChange.INVITED -> if (senderIsYou) { + sp.getString(R.string.state_event_room_invite_by_you, userDisplayNameOrId) + } else if (memberIsYou) { + sp.getString(R.string.state_event_room_invite_you, senderDisambiguatedDisplayName) + } else { + sp.getString(R.string.state_event_room_invite, senderDisambiguatedDisplayName, userDisplayNameOrId) + } + 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, userDisplayNameOrId) + } + MembershipChange.INVITATION_REJECTED -> if (memberIsYou) { + sp.getString(R.string.state_event_room_reject_by_you) + } else { + sp.getString(R.string.state_event_room_reject, userDisplayNameOrId) + } + MembershipChange.INVITATION_REVOKED -> if (senderIsYou) { + sp.getString(R.string.state_event_room_third_party_revoked_invite_by_you, userDisplayNameOrId) + } else { + sp.getString(R.string.state_event_room_third_party_revoked_invite, senderDisambiguatedDisplayName, userDisplayNameOrId) + } + MembershipChange.KNOCKED -> if (memberIsYou) { + sp.getString(R.string.state_event_room_knock_by_you) + } else { + sp.getString(R.string.state_event_room_knock, senderDisambiguatedDisplayName) + } + MembershipChange.KNOCK_ACCEPTED -> if (senderIsYou) { + sp.getString(R.string.state_event_room_knock_accepted_by_you, userDisplayNameOrId) + } else { + sp.getString(R.string.state_event_room_knock_accepted, senderDisambiguatedDisplayName, userDisplayNameOrId) + } + 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, senderDisambiguatedDisplayName) + } + MembershipChange.KNOCK_DENIED -> if (senderIsYou) { + sp.getString(R.string.state_event_room_knock_denied_by_you, userDisplayNameOrId) + } else if (memberIsYou) { + sp.getString(R.string.state_event_room_knock_denied_you, senderDisambiguatedDisplayName) + } else { + sp.getString(R.string.state_event_room_knock_denied, senderDisambiguatedDisplayName, userDisplayNameOrId) + } + MembershipChange.NONE -> if (senderIsYou) { + sp.getString(R.string.state_event_room_none_by_you) + } else { + sp.getString(R.string.state_event_room_none, senderDisambiguatedDisplayName) + } + MembershipChange.ERROR -> { + Timber.v("Filtering timeline item for room membership: $membershipContent") + null + } + MembershipChange.NOT_IMPLEMENTED -> { + Timber.v("Filtering timeline item for room membership: $membershipContent") + null + } + null -> { + 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 0000000..f9d38fd --- /dev/null +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.eventformatter.impl + +import dev.zacsweers.metro.Inject +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 + +@Inject +class StateContentFormatter( + private val sp: StringProvider, +) { + fun format( + stateContent: StateContent, + senderDisambiguatedDisplayName: 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, senderDisambiguatedDisplayName) + else -> sp.getString(R.string.state_event_room_avatar_removed, senderDisambiguatedDisplayName) + } + } + is OtherState.RoomCreate -> { + if (senderIsYou) { + sp.getString(R.string.state_event_room_created_by_you) + } else { + sp.getString(R.string.state_event_room_created, senderDisambiguatedDisplayName) + } + } + 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, senderDisambiguatedDisplayName, content.name) + else -> sp.getString(R.string.state_event_room_name_removed, senderDisambiguatedDisplayName) + } + } + 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, senderDisambiguatedDisplayName, content.displayName) + } + } + is OtherState.RoomTopic -> { + val hasRoomTopic = content.topic?.isNotBlank() == true + 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, senderDisambiguatedDisplayName, content.topic) + else -> sp.getString(R.string.state_event_room_topic_removed, senderDisambiguatedDisplayName) + } + } + is OtherState.RoomPinnedEvents -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + formatRoomPinnedEvents(content, senderIsYou, senderDisambiguatedDisplayName) + } + } + 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" + } + } + is OtherState.RoomJoinRules -> when (renderingMode) { + RenderingMode.RoomList -> { + Timber.v("Filtering timeline item for room state change: $content") + null + } + RenderingMode.Timeline -> { + "RoomJoinRules" + } + } + is OtherState.RoomUserPowerLevels -> 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" + } + } + } + } + + private fun formatRoomPinnedEvents( + content: OtherState.RoomPinnedEvents, + senderIsYou: Boolean, + senderDisambiguatedDisplayName: String + ) = when (content.change) { + OtherState.RoomPinnedEvents.Change.ADDED -> when { + senderIsYou -> sp.getString(R.string.state_event_room_pinned_events_pinned_by_you) + else -> sp.getString(R.string.state_event_room_pinned_events_pinned, senderDisambiguatedDisplayName) + } + OtherState.RoomPinnedEvents.Change.REMOVED -> when { + senderIsYou -> sp.getString(R.string.state_event_room_pinned_events_unpinned_by_you) + else -> sp.getString(R.string.state_event_room_pinned_events_unpinned, senderDisambiguatedDisplayName) + } + OtherState.RoomPinnedEvents.Change.CHANGED -> when { + senderIsYou -> sp.getString(R.string.state_event_room_pinned_events_changed_by_you) + else -> sp.getString(R.string.state_event_room_pinned_events_changed, senderDisambiguatedDisplayName) + } + } +} 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 0000000..22bde7d --- /dev/null +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/mode/RenderingMode.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.eventformatter.impl.mode + +enum class RenderingMode { + RoomList, + Timeline, +} diff --git a/libraries/eventformatter/impl/src/main/res/values-be/translations.xml b/libraries/eventformatter/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..78ca6d5 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,69 @@ + + + "(аватар таксама быў зменены)" + "%1$s змяніў(-ла) аватар" + "Вы змянілі свой аватар" + "%1$s быў паніжаны(-на) да ўдзельніка" + "%1$s быў паніжаны(-на) да мадэратара" + "%1$s змяніў(-ла) сваё бачнае імя з %2$s на %3$s" + "Вы змянілі сваё бачнае імя з %1$s на %2$s" + "%1$s выдаліў(-ла) сваё бачнае імя (яно было %2$s)" + "Вы выдалілі сваё бачнае імя (яно было %1$s)" + "%1$s усталявалі сваё бачнае імя на %2$s" + "Вы ўстанавілі бачнае імя на %1$s" + "%1$s быў(-ла) павышаны(-на) да адміністратара" + "%1$s быў(-ла) павышаны(-на) да мадэратара" + "%1$s змяніў(-ла) аватар пакоя" + "Вы змянілі аватар пакоя" + "%1$s выдаліў(-ла) аватар пакоя" + "Вы выдалілі аватар пакоя" + "%1$s заблакіраваў(-ла) %2$s" + "Вы заблакіравалі %1$s" + "%1$s стварыў(-ла) пакой" + "Вы стварылі пакой" + "%1$s запрасіў(-ла) %2$s" + "%1$s прыняў(-ла) запрашэнне" + "Вы прынялі запрашэнне" + "Вы запрасілі %1$s" + "%1$s запрасіў(-ла) вас" + "%1$s далучыўся(-лась) да пакоя" + "Вы далучыліся да пакоя" + "%1$sпросіць далучыцца" + "%1$s дазволіў(-ла) %2$s далучыцца" + "Вы дазволілі %1$s далучыцца" + "Вы прасілі далучыцца" + "%1$s адхіліў(-ла) %2$s запыт на далучэнне" + "Вы адхілілі %1$s запыт на далучэнне" + "%1$s адхіліў(-ла) ваш запыт на далучэнне" + "%1$s больш не зацікаўлены(-на) у далучэнні" + "Вы адмянілі запыт на далучэнне" + "%1$s выйшаў(-ла) з пакоя" + "Вы выйшлі з пакоя" + "%1$s змяніў(-ла) назву пакоя на: %2$s" + "Вы змянілі назву пакоя на: %1$s" + "%1$s выдаліў(-ла) назву пакоя" + "Вы выдалілі назву пакоя" + "%1$s не зрабіў(-ла) ніякіх змен" + "Вы не зрабілі ніякіх змен" + "%1$s змяніў(-ла) замацаваныя паведамленні" + "Вы змянілі замацаваныя паведамленні" + "%1$s замацаваў(-ла) паведамленне" + "Вы замацавалі паведамленне" + "%1$s адмацаваў(-ла) паведамленне" + "Вы адмацавалі паведамленне" + "%1$s адхіліў(-ла) запрашэнне" + "Вы адхілілі запрашэнне" + "%1$s выдаліў(-ла) %2$s" + "Вы выдалілі %1$s" + "%1$s адправіў(-ла) запрашэнне %2$s далучыцца да пакоя" + "Вы адправілі запрашэнне %1$s далучыцца да пакоя" + "%1$s адклікаў(-ла) запрашэнне для %2$s далучыцца да пакоя" + "Вы адклікалі запрашэнне для %1$s далучыцца да пакоя" + "%1$s змяніў тэму на: %2$s" + "Вы змянілі тэму на: %1$s" + "%1$s выдаліў(-ла) тэму пакоя" + "Вы выдалілі тэму пакоя" + "%1$s разблакіраваў(-ла) %2$s" + "Вы разблакіравалі %1$s" + "%1$s унеслі невядомую змену ў сяброўства" + diff --git a/libraries/eventformatter/impl/src/main/res/values-bg/translations.xml b/libraries/eventformatter/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..9bfcdfe --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,61 @@ + + + "(профилната снимка също е променена)" + "%1$s промени своята профилна снимка" + "Вие променихте своята профилна снимка" + "%1$s промени своето име от %2$s на %3$s" + "Вие променихте своето име от %1$s на %2$s" + "%1$s премахна своето име (то беше %2$s)" + "Вие премахнахте своето име (it was %1$s)" + "%1$s си зададе името %2$s" + "Вие си зададохте името %1$s" + "%1$s промени снимката на стаята" + "Вие променихте снимката на стаята" + "%1$s премахна снимката на стаята" + "Вие премахнахте снимката на стаята" + "%1$s създаде стаята" + "Вие създадохте стаята" + "%1$s покани %2$s" + "%1$s прие поканата" + "Вие приехте поканата" + "Вие поканихте %1$s" + "%1$s ви покани" + "%1$s се присъедини към стаята" + "Вие се присъединихте към стаята" + "%1$s иска да се присъедини" + "%1$s получи достъп до %2$s" + "Вие позволихте на %1$s да се присъедини" + "Вие поискахте да се присъедините" + "%1$s отхвърли заявката на %2$s за присъединяване" + "Вие отхвърлихте заявката на %1$s за присъединяване" + "%1$s отхвърли вашата заявка за присъединяване" + "%1$s вече не се интересува от присъединяване" + "Вие отменихте заявката си за присъединяване" + "%1$s напусна стаята" + "Вие напуснахте стаята" + "%1$s промени името на стаята на: %2$s" + "Вие променихте името на стаята на: %1$s" + "%1$s премахна името на стаята" + "Вие премахнахте името на стаята" + "%1$s не направи промени" + "Не направихте промени" + "%1$s промени закачените съобщения" + "Вие променихте закачените съобщения" + "%1$s закачи съобщение" + "Вие закачихте съобщение" + "%1$s откачи съобщение" + "Вие откачихте съобщение" + "%1$s отхвърли поканата" + "Вие отхвърлихте поканата" + "%1$s премахна %2$s" + "Вие премахнахте %1$s" + "%1$s изпрати покана на %2$s за присъединяване към стаята" + "Вие изпратихте покана на %1$s за присъединяване към стаята" + "%1$s отмени поканата на %2$s за присъединяване към стаята" + "Вие отменихте поканата на %1$s за присъединяване към стаята" + "%1$s промени темата на: %2$s" + "Вие променихте темата на: %1$s" + "%1$s премахна темата на стаята" + "Вие премахнахте темата на стаята" + "%1$s направи неизвестна промяна в членството си" + 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 0000000..98a5bd2 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,73 @@ + + + "(avatar byl také změněn)" + "%1$s změnil(a) svůj profilový obrázek" + "Změnili jste svůj profilový obrázek" + "%1$s byl(a) degradován(a) na člena" + "%1$s byl(a) degradován(a) na moderátora" + "%1$s změnil(a) své zobrazované jméno z %2$s na %3$s" + "Změnili jste své zobrazované jméno z %1$s na %2$s" + "%1$s odstranil(a) své zobrazované jméno (%2$s)" + "Odstranili jste své zobrazované jméno (%1$s)" + "%1$s nastavil(a) své zobrazované jméno na %2$s" + "Změnili jste své zobrazované jméno na %1$s" + "%1$s byl(a) povýšen(a) na administrátora" + "%1$s byl(a) povýšen(a) na moderátora" + "%1$s změnil(a) obrázek místnosti" + "Změnili jste obrázek místnosti" + "%1$s odstranili obrázek místnosti" + "Odstranili jste obrázek místnosti" + "%1$s vykázal(a) %2$s" + "Vykázali jste %1$s" + "Vykázali jste %1$s: %2$s" + "%1$s vykázal(a) %2$s: %3$s" + "%1$s založil(a) místnost" + "Založili jste místnost" + "%1$s pozval(a) %2$s" + "%1$s přijal(a) pozvání" + "Přijali jste pozvání" + "Pozvali jste %1$s" + "Pozvali jste %1$s" + "%1$s vstoupil(a) do místnosti" + "Vstoupili jste do místnosti" + "%1$s žádá o vstup" + "%1$s povolil(a) vstoupit %2$s" + "Povolili jste %1$s vstoupit" + "Požádali jste o vstup" + "%1$s zamítl(a) žádost %2$s o vstup" + "Zamítli jste žádost %1$s o vstup" + "%1$s zamítl(a) vaši žádost o vstup" + "%1$s již nemá zájem vstoupit" + "Zrušili jste svou žádost vstoupit" + "%1$s opustil(a) místnost" + "Opustili jste místnost" + "%1$s změnil(a) název místnosti na: %2$s" + "Změnili jste název místnosti na: %1$s" + "%1$s odstranil(a) název místnosti" + "Odstranili jste název místnosti" + "%1$s neprovedl(a) žádné změny" + "Neprovedli jste žádné změny" + "%1$s změnil(a) připnuté zprávy" + "Změnili jste připnuté zprávy" + "%1$s připnul(a) zprávu" + "Připnuli jste zprávu" + "%1$s odepnul(a) zprávu" + "Odepnuli jste zprávu" + "%1$s pozvánku odmítl(a)" + "Odmítli jste pozvání" + "%1$s odebral(a) %2$s" + "Odebrali jste %1$s" + "Odstranili jste %1$s: %2$s" + "%1$s odstranil(a) %2$s: %3$s" + "%1$s do této místnosti pozval(a) %2$s" + "Poslali jste %1$s pozvání do místnosti" + "%1$s zrušil(a) pozvánku do místnosti pro %2$s" + "Zrušili jste pozvánku do místnosti pro %1$s" + "%1$s změnil(a) téma na: %2$s" + "Změnili jste téma na: %1$s" + "%1$s odstranil(a) téma místnosti" + "Odstranili jste téma místnosti" + "%1$s zrušil(a) vykázání %2$s" + "Zrušili jste vykázání pro %1$s" + "%1$s provedl(a) neznámou změnu svého členství" + diff --git a/libraries/eventformatter/impl/src/main/res/values-cy/translations.xml b/libraries/eventformatter/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..e4aec60 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,73 @@ + + + "(newidiwyd yr afatar hefyd)" + "Mae %1$s wedi newid eu afatar" + "Rydych chi wedi newid eich afatar" + "Cafodd %1$s ei israddio i aelod" + "Cafodd %1$s ei israddio i gymedrolwr" + "Newidiodd %1$s ei enw dangos o %2$s i %3$s" + "Rydych chi wedi newid eich enw dangos o %1$s i %2$s" + "Mae %1$s wedi tynnu ei enw dangos (%2$s ydoedd)" + "Rydych wedi dileu eich enw dangos (%1$s ydoedd)" + "Mae %1$s wedi gosod ei enw dangos i %2$s" + "Rydych chi wedi gosod eich enw dangos i %1$s" + "Cafodd %1$s ei godi i fod yn weinyddol" + "Cafodd %1$s ei godi i fod yn gymedrolwr" + "Mae %1$s wedi newid afatar yr ystafell" + "Rydych chi wedi newid afatar yr ystafell" + "Mae %1$s wedi tynnu afatar yr ystafell" + "Rydych chi wedi tynnu afatar yr ystafell" + "Mae %1$s wedi gwahardd %2$s" + "Rydych wedi gwahardd %1$s" + "Rydych wedi gwahardd %1$s: %2$s" + "Mae %1$s wedi gwahardd %2$s: %3$s" + "%1$s greodd yr ystafell" + "Chi greodd yr ystafell" + "Mae %1$s wedi gwahodd %2$s" + "Derbyniodd %1$s y gwahoddiad" + "Rydych chi wedi derbyn y gwahoddiad" + "Rydych wedi gwahodd %1$s" + "Mae %1$s wedi eich gwahodd" + "Ymunodd %1$s â\'r ystafell" + "Rydych chi wedi ymuno â\'r ystafell" + "Mae %1$s yn gofyn i gael ymuno" + "Mae %1$s wedi rhoi mynediad i %2$s" + "Rydych chi wedi caniatáu i %1$s ymuno" + "Rydych chi wedi gwneud cais i ymuno" + "Mae %1$s wedi gwrthod cais %2$s i ymuno" + "Rydych wedi gwrthod cais %1$s i ymuno" + "Mae %1$s wedi gwrthod eich cais i ymuno" + "Does gan %1$s ddim diddordeb mewn ymuno bellach" + "Rydych wedi diddymu\'ch cais i ymuno" + "Mae %1$s wedi gadael yr ystafell" + "Rydych wedi gadael yr ystafell" + "Mae %1$s wedi newid enw\'r ystafell i: %2$s" + "Rydych chi wedi newid enw\'r ystafell i: %1$s" + "Mae %1$s wedi tynnu enw\'r ystafell" + "Rydych wedi dileu enw\'r ystafell" + "Gwnaeth %1$s dim newidiadau" + "Rydych heb wneud unrhyw newidiadau" + "Mae %1$s wedi newid y negeseuon sydd wedi\'u pinio" + "Rydych wedi newid y negeseuon sydd wedi\'u pinio" + "Mae %1$s wedi pinio neges" + "Rydych chi wedi pinio neges" + "Mae %1$s wedi\'i dad-binio neges" + "Rydych chi wedi dad-binio neges" + "Mae %1$s wedi gwrthod y gwahoddiad" + "Rydych chi wedi gwrthod y gwahoddiad" + "Mae %1$s wedi tynnu %2$s" + "Rydych wedi dileu %1$s" + "Rydych wedi dileu %1$s: %2$s" + "Mae %1$s wedi tynnu %2$s: %3$s" + "Anfonodd %1$s wahoddiad at %2$s i ymuno â\'r ystafell" + "Rydych wedi anfon gwahoddiad i %1$s i ymuno â\'r ystafell" + "Dirymodd %1$s y gwahoddiad i %2$s ymuno â\'r ystafell" + "Rydych wedi dirymu\'r gwahoddiad i %1$s ymuno â\'r ystafell" + "Newidiodd %1$s y pwnc i: %2$s" + "Rydych wedi newid y pwnc i: %1$s" + "Mae %1$s wedi dileu pwnc yr ystafell" + "Rydych wedi dileu pwnc yr ystafell" + "Mae %1$s wedi dad-wahardd %2$s" + "Rydych wedi dad-wahardd %1$s" + "Mae %1$s wedi gwneud newid anhysbys i\'w aelodaeth" + diff --git a/libraries/eventformatter/impl/src/main/res/values-da/translations.xml b/libraries/eventformatter/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..db3be21 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,73 @@ + + + "(avataren blev også ændret)" + "%1$s ændrede sin avatar" + "Du har ændret din avatar" + "%1$s blev nedgraderet til medlem" + "%1$s blev nedgraderet til moderator" + "%1$s ændrede sit viste navn fra %2$s til %3$s" + "Du har ændret dit viste navn fra %1$s til %2$s" + "%1$s fjernede sit viste navn (det var %2$s )" + "Du fjernede dit viste navn (det var%1$s)" + "%1$s har sat deres visningsnavn til %2$s" + "Du har indstillet dit viste navn til %1$s" + "%1$s blev forfremmet til admin" + "%1$s blev forfremmet til moderator" + "%1$s ændrede rummets avatar" + "Du ændrede rummets avatar" + "%1$s fjernede rummets avatar" + "Du fjernede rummets avatar" + "%1$s bortviste %2$s" + "Du bortviste %1$s" + "Du spærrede %1$s: %2$s" + "%1$s spærrede %2$s: %3$s" + "%1$s skabte rummet" + "Du skabte rummet" + "%1$sinviterede %2$s" + "%1$s accepterede invitationen" + "Du har accepteret invitationen" + "Du inviterede %1$s" + "%1$s inviterede dig" + "%1$s sluttede sig til rummet" + "Du sluttede dig til rummet" + "%1$s anmoder om at deltage" + "%1$s har givet adgang til %2$s" + "Du tillod %1$s at være med" + "Du har anmodet om at deltage" + "%1$s har afvist %2$ss anmodning om at deltage" + "Du har afvist %1$ss anmodning om at deltage" + "%1$s afviste din anmodning om at deltage" + "%1$s er ikke længere interesseret i at deltage" + "Du har annulleret din anmodning om at deltage" + "%1$s forlod rummet" + "Du forlod rummet" + "%1$s ændrede rummets navn til: %2$s" + "Du ændrede rummets navn til: %1$s" + "%1$s fjernede rummets navn" + "Du fjernede rummets navn" + "%1$s foretog ingen ændringer" + "Du har ikke foretaget nogen ændringer" + "%1$s ændrede de fastgjorte beskeder" + "Du har ændret de fastgjorte beskeder" + "%1$s fastgjorde en besked" + "Du har fastgjort en besked" + "%1$s frigjorde en besked" + "Du frigjorde en besked" + "%1$s afviste invitationen" + "Du afviste invitationen" + "%1$s fjernede %2$s" + "Du fjernede %1$s" + "Du fjernede %1$s :%2$s" + "%1$s fjernede %2$s: %3$s" + "%1$s har sendt en invitation til %2$s om at deltage i rummet" + "Du har sendt en invitation til %1$s om at deltage i rummet" + "%1$s tilbagekaldte invitationen til %2$s om at være med i rummet" + "Du tilbagekaldte invitationen til %1$s om at være med i rummet" + "%1$s ændrede emnet til: %2$s" + "Du har ændret emnet til: %1$s" + "%1$s fjernede rummets emne" + "Du har fjernet rummets emne" + "%1$s ophævede bortvisningen af %2$s" + "Du har fjernet bortvisningen af %1$s" + "%1$s har foretaget en ukendt ændring af deres medlemskab" + 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 0000000..75e123c --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,73 @@ + + + "(Avatar wurde auch geändert)" + "%1$s hat den Avatar geändert" + "Du hast deinen Avatar geändert" + "%1$s wurde zum Mitglied herabgestuft" + "%1$s wurde zum Moderator herabgestuft" + "%1$s hat den Anzeigenamen von %2$s auf %3$s geändert" + "Du hast deinen Anzeigenamen von %1$s auf %2$s geändert" + "%1$s hat den Anzeigenamen entfernt (war %2$s)" + "Du hast deinen Anzeigenamen entfernt (war %1$s)" + "%1$s hat den Anzeigenamen auf %2$s geändert" + "Du hast deinen Anzeigenamen zu %1$s geändert" + "%1$s ist jetzt Admin" + "%1$s ist jetzt Moderator*in" + "%1$s hat den Chat -Avatar geändert" + "Du hast den Chat-Avatar geändert" + "%1$s hat den Chat-Avatar entfernt" + "Du hast den Chat-Avatar entfernt" + "%1$s hat %2$s gesperrt" + "Du hast %1$s gesperrt" + "Du hast %1$s gesperrt: %2$s" + "%1$s sperrte %2$s: %3$s" + "%1$s hat den Chat erstellt" + "Du hast den Chat erstellt" + "%1$s hat %2$s eingeladen" + "%1$s hat die Einladung angenommen" + "Du hast die Einladung angenommen" + "Du hast %1$s eingeladen" + "%1$s hat dich eingeladen" + "%1$s ist dem Chat beigetreten" + "Du bist dem Chat beigetreten" + "%1$s fragt den Beitritt an" + "%1$s hat %2$s den Beitritt erlaubt" + "Du hast %1$s den Beitritt erlaubt" + "Du hast angefragt beizutreten" + "%1$s hat die Beitrittsanfrage von %2$s abgelehnt" + "Du hast die Beitrittsanfrage von %1$s abgelehnt" + "%1$s hat deine Beitrittsanfrage abgelehnt" + "%1$s ist nicht mehr an einem Beitritt interessiert" + "Du hast deine Beitrittsanfrage abgebrochen" + "%1$s hat den Chat verlassen" + "Du hast den Chat verlassen" + "%1$s hat den Chat-Namen geändert in: %2$s" + "Du hast den Chat-Namen geändert in: %1$s" + "%1$s hat den Chat-Namen entfernt" + "Du hast den Chat-Namen entfernt" + "%1$s hat keine Änderungen vorgenommen" + "Du hast keine Änderungen vorgenommen" + "%1$s hat die fixierten Nachrichten geändert" + "Du hast die fixierten Nachrichten geändert" + "%1$s fixierte eine Nachricht" + "Du hast eine Nachricht fixiert" + "%1$s löste eine Nachricht" + "Du hast eine Nachricht gelöst" + "%1$s lehnte die Einladung ab" + "Du hast die Einladung abgelehnt" + "%1$s hat %2$s entfernt" + "Du hast %1$s entfernt" + "Du hast %1$s entfernt: %2$s" + "%1$s entfernt %2$s: %3$s" + "%1$s hat %2$s eingeladen, den Chat zu beizutreten" + "Du hast eine Einladung an %1$s gesendet, dem Chat beizutreten" + "%1$s hat die Einladung an %2$s zum Beitritt des Chat zurückgezogen" + "Du hast die Einladung an %1$s zum Beitritt des Chat zurückgezogen" + "%1$s hat das Thema geändert in: %2$s" + "Du hast das Thema geändert in: %1$s" + "%1$s hat das Chat-Thema entfernt" + "Du hast das Chat-Thema entfernt" + "%1$s hat die Sperre für %2$s aufgehoben" + "Du hast die Sperre für %1$s aufgehoben" + "%1$s hat eine unbekannte Änderung vorgenommen" + diff --git a/libraries/eventformatter/impl/src/main/res/values-el/translations.xml b/libraries/eventformatter/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..f34016c --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,73 @@ + + + "(άλλαξε και το άβαταρ)" + "Ο χρήστης %1$s άλλαξε το άβατάρ του" + "Άλλαξες το άβατάρ σου" + "Ο χρήστης %1$s υποβιβάστηκε σε μέλος" + "Ο χρήστης %1$s υποβιβάστηκε σε συντονιστής" + "Ο χρήστης %1$s άλλαξε το εμφανιζόμενο όνομά του από %2$s σε %3$s" + "Άλλαξες το εμφανιζόμενο όνομα σου από %1$s σε %2$s" + "Ο χρήστης %1$s αφαίρεσε το εμφανιζόμενο όνομά του (ήταν %2$s)" + "Αφαίρεσες το εμφανιζόμενο όνομά σου (ήταν %1$s)" + "Ο χρήστης %1$s όρισε το εμφανιζόμενο όνομά του σε %2$s" + "Όρισες το εμφανιζόμενο όνομά σου σε %1$s" + "Ο χρήστης %1$s προήχθη σε διαχειριστής" + "Ο χρήστης %1$s προήχθη σε συντονιστής" + "%1$s άλλαξε την εικόνα προφίλ της αίθουσας" + "Αλλάξατε την εικόνα προφίλ της αίθουσας" + "%1$s αφαίρεσε την εικόνα προφίλ της αίθουσας" + "Αφαιρέσατε την εικόνα προφίλ της αίθουσας" + "Ο χρήστης %1$s απέκλεισε τον χρήστη %2$s" + "Απέκλεισες τον χρήστη %1$s" + "Απέκλεισες %1$s: %2$s" + "%1$s απέκλεισε %2$s: %3$s" + "%1$s δημιούργησε την αίθουσα" + "Δημιουργήσατε την αίθουσα" + "Ο χρήστης %1$s προσκάλεσε τον χρήστη %2$s" + "Ο χρήστης %1$s αποδέχτηκε την πρόσκληση" + "Αποδέχτηκες την πρόσκληση" + "Προσκάλεσες τον χρήστη %1$s" + "Ο χρήστης %1$s σέ προσκάλεσε" + "%1$s εντάχθηκε στην αίθουσα" + "Ενταχθήκατε στην αίθουσα" + "Ο χρήστης %1$s ζητάει να συμμετάσχει" + "Ο χρήστης %1$s επέτρεψε τον χρήστη %2$s" + "Επέστρεψες στον χρήστη%1$s να συμμετάσχει" + "Ζήτησες να συμμετάσχεις" + "Ο χρήστης %1$s απέρριψε το αίτημα του χρήστη %2$s να συμμετάσχει" + "Απορρίψατε το αίτημα συμμετοχής του χρήστη %1$s" + "Ο χρήστης %1$s απέρριψε το αίτημά σου για συμμετοχή" + "Ο χρήστης %1$s δεν ενδιαφέρεται πλέον να συμμετάσχει" + "Ακύρωσες το αίτημά σου για συμμετοχή" + "%1$s αποχώρησε από την αίθουσα" + "Αποχωρήσατε από την αίθουσα" + "%1$s άλλαξε το όνομα της αίθουσας σε: %2$s" + "Αλλάξατε το όνομα της αίθουσας σε: %1$s" + "%1$s αφαίρεσε το όνομα της αίθουσας" + "Αφαιρέσατε το όνομα της αίθουσας" + "Ο χρήστης %1$s δεν έκανε καμία αλλαγή" + "Δεν έκανες καμία αλλαγή" + "Ο χρήστης %1$s άλλαξε τα καρφιτσωμένα μηνύματα" + "Άλλαξες τα καρφιτσωμένα μηνύματα" + "Ο χρήστης %1$s καρφίτσωσε ένα μήνυμα" + "Καρφίτσωσες ένα μήνυμα" + "Ο χρήστης %1$s ξεκαρφίτσωσε ένα μήνυμα" + "Ξεκαρφίτσωσες ένα μήνυμα" + "Ο χρήστης %1$s απέρριψε την πρόσκληση" + "Απέρριψες την πρόσκληση" + "Ο χρήστης %1$s αφαίρεσε τον χρήστη %2$s" + "Αφαίρεσες τον χρήστη %1$s" + "Αφαίρεσες %1$s: %2$s" + "%1$s αφαιρέθηκε %2$s: %3$s" + "%1$s έστειλε πρόσκληση στον χρήστη %2$s για ένταξη στην αίθουσα" + "Στείλατε πρόσκληση στον χρήστη %1$s για να ενταχθεί στην αίθουσα" + "%1$s ανακάλεσε την πρόσκληση στον χρήστη %2$s για να ενταχθεί στην αίθουσα" + "Ανακαλέσατε την πρόσκληση για ένταξη του χρήστη %1$s στην αίθουσα" + "Ο χρήστης %1$s άλλαξε το θέμα σε: %2$s" + "Άλλαξες το θέμα σε: %1$s" + "%1$s αφαίρεσε το θέμα της αίθουσας" + "Αφαιρέσατε το θέμα της αίθουσας" + "Ο χρήστης %1$s έκανε άρση αποκλεισμού στον χρήστη %2$s" + "Έκανες άρση αποκλεισμού στον χρήστη %1$s" + "Ο χρήστης %1$s έκανε μια άγνωστη αλλαγή στην ιδιότητα μέλους του." + 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 0000000..186aad5 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,73 @@ + + + "(el avatar también cambió)" + "%1$s cambió su avatar" + "Cambiaste tu avatar" + "%1$s fue degradado a miembro" + "%1$s fue degradado a moderador" + "%1$s cambió su nombre de %2$s a %3$s" + "Cambiaste tu nombre de %1$s a %2$s" + "%1$s eliminó su nombre (era %2$s)" + "Eliminaste tu nombre (era %1$s)" + "%1$s cambió su nombre a %2$s" + "Cambiaste tu nombre a %1$s" + "%1$s fue ascendido a administrador" + "%1$s fue ascendido a moderador" + "%1$s cambió el avatar de la sala" + "Cambiaste el avatar de la sala" + "%1$s eliminó el avatar de la sala" + "Eliminaste el avatar de la sala" + "%1$s expulsó permanentemente a %2$s" + "Expulsaste permanentemente a %1$s" + "Vetaste a %1$s: %2$s" + "%1$s vetó a %2$s: %3$s" + "%1$s creó la sala" + "Tú creaste la sala" + "%1$s invitó a %2$s" + "%1$s aceptó la invitación" + "Aceptaste la invitación" + "Invitaste a %1$s" + "%1$s te invitó." + "%1$s se unió a la sala" + "Te uniste a la sala" + "%1$s solicitó unirse" + "%1$s permitió que %2$s se uniera" + "Permitiste que %1$s se uniera" + "Solicitaste unirte" + "%1$s rechazó la solicitud de %2$s para unirse" + "Rechazaste la solicitud de %1$s para unirte" + "%1$s rechazó su solicitud para unirte" + "%1$s ya no está interesado en unirse" + "Cancelaste tu solicitud de unirte" + "%1$s salió de la sala" + "Saliste de la sala" + "%1$s cambió el nombre de la sala a: %2$s" + "Cambiaste el nombre de la sala a: %1$s" + "%1$s eliminó el nombre de la sala" + "Eliminaste el nombre de la sala" + "%1$s no hizo cambios" + "No has hecho ningún cambio" + "%1$s cambió los mensajes fijados" + "Has cambiado los mensajes fijados" + "%1$s fijó un mensaje" + "Has fijado un mensaje" + "%1$s desprendió un mensaje" + "Desprendiste un mensaje" + "%1$s rechazó la invitación" + "Rechazaste la invitación" + "%1$s echó a %2$s" + "Echaste a %1$s" + "Echaste a %1$s: %2$s" + "%1$s echó a %2$s: %3$s" + "%1$s envió una invitación a %2$s para unirse a la sala" + "Enviaste una invitación a %1$s para unirse a la sala" + "%1$s revocó la invitación a %2$s para unirse a la sala" + "Revocaste la invitación de %1$s para unirse a la sala" + "%1$s cambió el tema a: %2$s" + "Cambiaste el tema a: %1$s" + "%1$s eliminó el tema de la sala" + "Eliminaste el tema de la sala" + "%1$s readmitió a %2$s" + "Readmitiste a %1$s" + "%1$s realizó un cambio desconocido en su membresía" + diff --git a/libraries/eventformatter/impl/src/main/res/values-et/translations.xml b/libraries/eventformatter/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..497d413 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,73 @@ + + + "(tunnuspilt muutus ka)" + "%1$s muutis oma tunnuspilti" + "Sina muutsid oma tunnuspilti" + "%1$s on nüüd tavakasutaja rollis" + "%1$s on nüüd moderaatori rollis" + "%1$s muutis senise kuvatava nime „%2$s“ asemele uueks nimeks „%3$s“" + "Sina muutsid senise kuvatava nime „%1$s“ asemel uueks nimeks „%2$s“" + "%1$s eemaldas oma kuvatava nime (mis oli „%2$s“)" + "Sina eemaldasid oma kuvatava nime (mis oli „%1$s“)" + "%1$s määras oma kuvatavaks nimeks „%2$s“" + "Sina määrasid oma kuvatavaks nimeks „%1$s“" + "%1$s on nüüd peakasutaja rollis" + "%1$s on nüüd moderaatori rollis" + "%1$s muutis jututoa tunnuspilti" + "Sina muutsid jututoa tunnuspilti" + "%1$s eemaldas jututoa tunnuspildi" + "Sina eemaldasid jututoa tunnuspildi" + "%1$s keelas %2$s ligipääsu" + "Sina keelasid %1$s ligipääsu" + "Sina seadsid ligipääsukeelu kasutajale %1$s: %2$s" + "%1$s seadis ligipääsukeelu kasutajale %2$s: %3$s" + "%1$s lõi jututoa" + "Sina lõid jututoa" + "%1$s saatis kutse kasutajale %2$s" + "%1$s võttis kutse vastu" + "Sina võtsid kutse vastu" + "Sina saatsid kutse kasutajale %1$s" + "%1$s saatis sulle kutse" + "%1$s liitus jututoaga" + "Sina liitusid jututoaga" + "%1$s palus võimalust liituda" + "%1$s lubas kasutajal %2$s liituda" + "Sina lubasid kasutajal %1$s liituda!" + "Sina palusid liitumist" + "%1$s lükkas tagasi kasutaja %2$s liitumispalve" + "Sina lükkasid tagasi kasutaja %1$s liitumispalve" + "%1$s lükkas tagasi sinu liitumispalve" + "%1$s pole enam liitumisest huvitatud" + "Sina tühitasid oma liitumissoovi" + "%1$s lahkus jututoast" + "Sina lahkusid jututoast" + "%1$s muutis jututoa uueks nimeks „%2$s“" + "Sina muutsid jututoa uueks nimeks „%1$s“" + "%1$s eemaldas jututoa nime" + "Sina eemaldasid jututoa nime" + "%1$s ei teinud ühtegi muudatust" + "Sina ei teinud ühtegi muudatust" + "%1$s muutis esiletõstetud sõnumeid" + "Sina muutsid esiletõstetud sõnumeid" + "%1$s tõstis sõnumi esile" + "Sina tõstsid sõnumi esile" + "%1$s eemaldas esiletõstetud sõnumi" + "Sina eemaldasid esiletõstetud sõnumi" + "%1$s lükkas kutse tagasi" + "Sina lükkasid kutse tagasi" + "%1$s eemaldas jututoast kasutaja %2$s" + "Sina eemaldasid jututoast kasutaja %1$s" + "Sina eemaldasid kasutaja %1$s: %2$s" + "%1$s eemaldas kasutaja %2$s: %3$s" + "%1$s saatis jututoaga liitumiseks kutse kasutajale %2$s" + "Sina saatsid kasutajale %1$s kutse jututoaga liitumiseks" + "%1$s võttis tagasi jututoaga liitumise kutse kasutajalt %2$s" + "Sina võtsid tagasi jututoaga liitumise kutse kasutajalt %1$s" + "%1$s muutis uueks teemaks %2$s" + "Sina muutsid uueks teemaks %1$s" + "%1$s eemaldas jututoa teema" + "Sina eemaldasid jututoa teema" + "%1$s taastas %2$s ligipääsu" + "Sina taastasid %1$s ligipääsu" + "%1$s tegi oma liikmelisuses teadmata muutatuse" + diff --git a/libraries/eventformatter/impl/src/main/res/values-eu/translations.xml b/libraries/eventformatter/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..20c4c8e --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,67 @@ + + + "(abatarra ere aldatu da)" + "%1$s(e)k abatarra aldatu du" + "Abatarra aldatu duzu" + "%1$s kide mailara jaitsi da" + "%1$s moderatzaile mailara jaitsi da" + "%1$s(e)k pantaila-izena %2$s(e)tik %3$s(e)ra aldatu du" + "Pantaila-izena %1$s(e)tik %2$s(e)ra aldatu duzu" + "%1$s(e)k pantaila-izena kendu du (%2$s zen)" + "Pantaila-izena kendu duzu (%1$s zen)" + "%1$s(e)k pantaila-izena %2$s(r)a aldatu du" + "Pantaila-izena %1$s(e)ra aldatu duzu" + "%1$s administratzaile mailara igo da" + "%1$s moderatzaile mailara igo da" + "%1$s(e)k gelako abatarra aldatu du" + "Gelako abatarra aldatu duzu" + "%1$s(e)k gelaren abatarra kendu du" + "Gelaren abatarra kendu duzu" + "%1$s(e)k %2$s(r) debekua ezarri dio" + "%1$s(r) debekua ezarri diozu" + "%1$s(e)k gela sortu du" + "Gela sortu duzu" + "%1$s(e)k %2$s gonbidatu du" + "%1$s(e)k gonbidapena onartu du" + "Gonbidapena onartu duzu" + "%1$s gonbidatu duzu" + "%1$s(e)k gonbidatu zaitu" + "%1$s gelara batu da" + "Gelara batu zara" + "%1$s(e)k batzeko eskaera egin du" + "%1$s(e)k batzeko baimena eman dio %2$s(r)i" + "Batzeko baimena eman diozu %1$s(r)i" + "Batzeko eskaera egin duzu" + "%1$s(e)k %2$s(r)en bat egiteko eskaera baztertu du" + "%1$s(r)en bat egiteko eskaera baztertu duzu" + "%1$s(e)k bat egiteko eskaera baztertu dizu" + "%1$s(e)k dagoeneko ez du bat egiteko interesik" + "Bat egiteko eskaera bertan behera utzi duzu" + "%1$s gelatik atera da" + "Gelatik atera zara" + "%1$s(e)k gelaren izena honakora aldatu du: %2$s" + "Gelaren izena honakora aldatu duzu: %1$s" + "%1$s(e)k gelaren izena kendu du" + "Gelaren izena kendu duzu" + "%1$s(e)k ez du aldaketarik egin" + "Ez duzu aldaketarik egin" + "%1$s(e)k finkatutako mezuak aldatu ditu" + "Finkatutako mezuak aldatu dituzu" + "%1$s(e)k mezu bat finkatu du" + "Mezu bat finkatu duzu" + "%1$s(e)k mezu bat finkatzeari utzi dio" + "Mezu bat finkatzeari utzi diozu" + "%1$s(e)k gonbidapena baztertu du" + "Gonbidapena baztertu duzu" + "%1$s(e)k %2$s kendu du" + "%1$s kendu duzu" + "%1$s(e)k %2$s(r)i gonbidapena bidali dio gelara batu dadin" + "Gonbidapena bidali diozu %1$s(r)i gelara batu dadin" + "%1$s(e)k gaia honakora aldatu du: %2$s" + "Gaia honakora aldatu duzu: %1$s" + "%1$s(e)k gelaren gaia aldatu du" + "Gelaren gaia aldatu duzu" + "%1$s(e)k %2$s(r)en debekua bertan behera utzi du" + "%1$s(r)en debekua bertan behera utzi duzu" + "%1$s(e)k kidetzan aldaketa ezezagun bat egin du" + diff --git a/libraries/eventformatter/impl/src/main/res/values-fa/translations.xml b/libraries/eventformatter/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..f7fd635 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,69 @@ + + + "(چهرک نیز تغییر کرد)" + "%1$s چهرکش را تغییر داد" + "چهرکتان را تغییر دادید" + "%1$s به عضو تنزّل یافت" + "%1$s به ناظم تنزّل یافت" + "%1$s نام نمایشیش را از %2$s به%3$s تغییر داد" + "نام نمایشیتان را از %1$s به %2$s تغییر دادید" + "%1$s نام نمایشیش را برداشت (%2$s بود)" + "نام نمایشیتان را برداشتید (%1$s بود)" + "%1$s نام نمایشیش را به %2$s تغییر داد" + "نام نمایشیتان را به %1$s تغییر دادید" + "%1$s به مدیر ارتقا یافت" + "%1$s به ناظم ارتقا یافت" + "%1$s چهرک اتاق را تغییر داد" + "چهرک اتاق را تغییر دادید" + "%1$s چهرک اتاق را برداشت" + "چهرک اتاق را برداشتید" + "%2$s به دست %1$s مسدود کرد" + "%1$s را مسدود کردید" + "%1$s اتاق را ایجاد کرد" + "اتاق را ساختید" + "%1$s از %2$s دعوت کرد" + "%1$s دعوت را پذیرفت" + "دعوت را پذیرفتید" + "از %1$s دعوت کردید" + "%1$s دعوتتان کرد" + "%1$s به اتاق پیوست" + "به اتاق پیوستید" + "%1$s درخواست پیوستن دارد" + "%1$s به %2$s دسترسی داد" + "گذاشتید %1$s بپیوندد" + "درخواست پیوستن کردید" + "درخواست پیوستن %2$s به دست %1$s لغو شد" + "درخوسات پیوستن %1$s را رد کردید" + "درخواست پیوستنتان به دست %1$s رد شد" + "%1$s دیگر علاقه‌ای به پیوستن ندارد" + "درخواست پیوستنتان را لغو کردید" + "%1$s اتاق را ترک کرد" + "اتاق را ترک کردید" + "%1$s نام اتاق را تغییر داد: %2$s" + "نام اتاق را تغییر دادید: %1$s" + "%1$sنام اتاق را برداشت" + "نام اتاق را برداشتید" + "%1$s تغییری ایجاد نکرد" + "تغییری ایجاد نکردید" + "%1$s پیام‌های سنجاق شده را تغییر داد" + "پیام‌های سنجاق شده را تغییر دادید" + "%1$s پیامی را سنجاق کرد" + "پیامی را سنجاق کردید" + "%1$s سنجاق پیامی را برداشت" + "سنجاق پیامی را برداشتید" + "%1$s دعوت را رد کرد" + "دعوت را رد کردید" + "%2$s به دست %1$s برداشته شد" + "%1$s را برداشتید" + "%1$s دعوتی برای پیوستن %2$s به اتاق فرستاد" + "برای %1$s دعوت پیوستن به اتاق فرستادید" + "%1$s دعوت پیوستن به اتاق %2$s را باطل کرد" + "دعوت پیوستن %1$s به اتاق را پس گرفتید" + "%1$s موضوع را تغییر داد: %2$s" + "موضوع را تغییر دادید: %1$s" + "%1$sموضوع اتاق را برداشت" + "موضوع اتاق را برداشتید" + "%1$s انسداد %2$s را لغو کرد" + "انسداد%1$s را لغو کردید" + "%1$s تغییری نامعلوم در عضویتش داد" + diff --git a/libraries/eventformatter/impl/src/main/res/values-fi/translations.xml b/libraries/eventformatter/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..408935c --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,73 @@ + + + "(myös avatar vaihdettiin)" + "%1$s vaihtoi avatarinsa" + "Vaihdoit avatarisi" + "%1$s alennettiin jäseneksi" + "%1$s alennettiin valvojaksi" + "%1$s vaihtoi näyttönimekseen %3$s (se oli %2$s)" + "Vaihdoit näyttönimeksesi %2$s (se oli %1$s)" + "%1$s poisti näyttönimensä (se oli %2$s)" + "Poistit näyttönimesi (se oli %1$s)" + "%1$s asetti näyttönimekseen %2$s" + "Asetit näyttönimeksesi %1$s" + "%1$s ylennettiin ylläpitäjäksi" + "%1$s ylennettiin valvojaksi" + "%1$s vaihtoi huoneen avatarin" + "Vaihdoit huoneen avatarin" + "%1$s poisti huoneen avatarin" + "Poistit huoneen avatarin" + "%1$s antoi porttikiellon käyttäjälle %2$s" + "Annoit porttikiellon käyttäjälle %1$s" + "Annoit porttikiellon käyttäjälle %1$s: %2$s" + "%1$s antoi porttikiellon käyttäjälle %2$s: %3$s" + "%1$s loi huoneen" + "Loit huoneen" + "%1$s kutsui käyttäjän %2$s" + "%1$s hyväksyi kutsun" + "Hyväksyit kutsun" + "Kutsuit käyttäjän %1$s" + "%1$s kutsui sinut" + "%1$s liittyi huoneeseen" + "Liityit huoneeseen" + "%1$s pyytää liittymistä" + "%1$s myönsi pääsyn käyttäjälle %2$s" + "Sallit käyttäjän %1$s liittyä" + "Pyysit liittymistä" + "%1$s hylkäsi käyttäjän %2$s liittymispyynnön" + "Hylkäsit käyttäjän %1$s liittymispyynnön" + "%1$s hylkäsi liittymispyyntösi" + "%1$s ei halua enää liittyä" + "Peruutit liittymispyyntösi" + "%1$s poistui huoneesta" + "Poistuit huoneesta" + "%1$s vaihtoi huoneen nimeksi: %2$s" + "Vaihdoit huoneen nimeksi: %1$s" + "%1$s poisti huoneen nimen" + "Poistit huoneen nimen" + "%1$s ei tehnyt muutoksia" + "Et tehnyt muutoksia" + "%1$s muutti kiinnitettyjä viestejä" + "Muutit kiinnitettyjä viestejä" + "%1$s kiinnitti viestin" + "Kiinnitit viestin" + "%1$s poisti viestin kiinnityksen" + "Poistit viestin kiinnityksen" + "%1$s hylkäsi kutsun" + "Hylkäsit kutsun" + "%1$s poisti käyttäjän %2$s" + "Poistit käyttäjän %1$s" + "Poistit käyttäjän %1$s: %2$s" + "%1$s poisti käyttäjän %2$s: %3$s" + "%1$s kutsui käyttäjän %2$s huoneeseen" + "Kutsuit käyttäjän %1$s huoneeseen" + "%1$s peruutti käyttäjän %2$s kutsun huoneeseen" + "Peruutit käyttäjän %1$s kutsun huoneeseen" + "%1$s vaihtoi aiheeksi: %2$s" + "Vaihdoit aiheeksi: %1$s" + "%1$s poisti huoneen aiheen" + "Poistit huoneen aiheen" + "%1$s poisti käyttäjän %2$s porttikiellon" + "Poistit käyttäjän %1$s porttikiellon" + "%1$s teki tuntemattoman muutoksen jäsenyyteensä" + 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 0000000..f8189ea --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,73 @@ + + + "(l’avatar a aussi été modifié)" + "%1$s a changé son avatar" + "Vous avez changé d’avatar" + "%1$s a été rétrogradé vers simple utilisateur" + "%1$s a été rétrogradé vers modérateur" + "%1$s a changé son pseudonyme de %2$s à %3$s" + "Vous avez changé votre pseudonyme de %1$s à %2$s" + "%1$s a supprimé son pseudonyme (c’était %2$s)" + "Vous avez supprimé votre pseudonyme (c’était %1$s)" + "%1$s a défini son pseudonyme en tant que %2$s" + "Vous avez défini votre pseudonyme comme %1$s" + "%1$s a été promu administrateur" + "%1$s a été promu modérateur" + "%1$s a changé l’avatar du salon" + "Vous avez changé l’avatar du salon" + "%1$s a supprimé l’avatar du salon" + "Vous avez supprimé l’avatar du salon" + "%1$s a banni %2$s" + "Vous avez banni %1$s" + "Vous avez banni %1$s: %2$s" + "%1$s a banni %2$s: %3$s" + "%1$s a créé le salon" + "Vous avez créé le salon" + "%1$s a invité %2$s" + "%1$s a accepté l’invitation" + "Vous avez accepté l’invitation" + "Vous avez invité %1$s" + "%1$s vous a invité(e)" + "%1$s a rejoint le salon" + "Vous avez rejoint le salon" + "%1$s demande à rejoindre le salon" + "%1$s a autorisé %2$s à rejoindre" + "Vous avez autorisé %1$s à joindre le salon" + "Vous avez demandé à rejoindre" + "%1$s a rejeté la demande de %2$s pour rejoindre" + "Vous avez rejeté la demande de %1$s pour rejoindre" + "%1$s a rejeté votre demande pour rejoindre" + "%1$s n’est plus intéressé à rejoindre" + "Vous avez annulé votre demande d’adhésion" + "%1$s a quitté le salon" + "Vous avez quitté le salon" + "%1$s a changé le nom du salon en : %2$s" + "Vous avez changé le nom du salon en : %1$s" + "%1$s a supprimé le nom du salon" + "Vous avez supprimé le nom du salon" + "%1$s n‘a fait aucun changement visible" + "Vous n‘avez fait aucun changement visible" + "%1$s a modifié les messages épinglés" + "Vous avez modifié les messages épinglés" + "%1$s a épinglé un message" + "Vous avez épinglé un message" + "%1$s a désépinglé un message" + "Vous avez désépinglé un message" + "%1$s a rejeté l’invitation" + "Vous avez refusé l’invitation" + "%1$s a supprimé %2$s" + "Vous avez supprimé %1$s" + "Vous avez supprimé %1$s: %2$s" + "%1$s a supprimé %2$s: %3$s" + "%1$s a envoyé une invitation à %2$s à rejoindre le salon" + "Vous avez envoyé une invitation à %1$s pour rejoindre le salon" + "%1$s a révoqué l’invitation de %2$s à rejoindre le salon" + "Vous avez révoqué l’invitation de %1$s à rejoindre le salon" + "%1$s a changé le sujet pour : %2$s" + "Vous avez changé le sujet pour : %1$s" + "%1$s a supprimé le sujet du salon" + "Vous avez supprimé le sujet du salon" + "%1$s a débanni %2$s" + "Vous avez débanni %1$s" + "%1$s a effectué un changement inconnu à son adhésion" + diff --git a/libraries/eventformatter/impl/src/main/res/values-hu/translations.xml b/libraries/eventformatter/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..311bf88 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,73 @@ + + + "(a profilkép is megváltozott)" + "%1$s megváltoztatta a profilképét" + "Megváltoztatta a profilképét" + "%1$s le lett fokozva taggá" + "%1$s le lett fokozva moderátorrá" + "%1$s megváltoztatta a megjelenítendő nevét: %2$s → %3$s" + "Megváltoztatta a megjelenítendő nevét: %1$s → %2$s" + "%1$s eltávolította a megjelenítendő nevét (ez volt: %2$s)" + "Eltávolította a megjelenítendő nevét (ez volt: %1$s)" + "%1$s beállította a megjelenítendő nevét: %2$s" + "Beállította a megjelenítendő nevét: %1$s" + "%1$s elő lett léptetve adminisztrátorrá" + "%1$s elő lett léptetve moderátorrá" + "%1$s megváltoztatta a szoba profilképét" + "Megváltoztatta a szoba profilképét" + "%1$s eltávolította a szoba profilképét" + "Eltávolította a szoba profilképét" + "%1$s kitiltotta: %2$s" + "Kitiltotta: %1$s" + "Kitiltotta %1$s felhasználót: %2$s" + "%1$s kitiltotta %2$s felhasználót: %3$s" + "%1$s létrehozta a szobát" + "Létrehozta a szobát" + "%1$s meghívta: %2$s" + "%1$s elfogadta a meghívást" + "Elfogadta a meghívást" + "Meghívta: %1$s" + "%1$s meghívta" + "%1$s csatlakozott a szobához" + "Csatlakozott a szobához" + "%1$s kéri, hogy csatlakozhasson" + "%1$s hozzáférést kapott a következőhöz: %2$s" + "Engedélyezte, hogy %1$s csatlakozhasson" + "Kérte, hogy csatlakozhasson" + "%1$s elutasította %2$s kérését, hogy csatlakozhasson" + "Elutasította %1$s kérését, hogy csatlakozhasson" + "%1$s elutasította a kérését, hogy csatlakozhasson" + "%1$s már nem akar csatlakozni" + "Lemondta a csatlakozási kérését" + "%1$s elhagyta a szobát" + "Elhagyta a szobát" + "%1$s megváltoztatta a szoba nevét: %2$s" + "Megváltoztatta a szoba nevét: %1$s" + "%1$s eltávolította a szoba nevét" + "Eltávolította a szoba nevét" + "%1$s nem változtatott semmin" + "Nem változtatott semmin" + "%1$s megváltoztatta a kitűzött üzeneteket" + "Megváltoztatta a kitűzött üzeneteket" + "%1$s kitűzött egy üzenetet" + "Kitűzött egy üzenetet" + "%1$s feloldotta egy üzenet kitűzését" + "Feloldotta egy üzenet kitűzését" + "%1$s elutasította a meghívást" + "Elutasította a meghívást" + "%1$s eltávolította: %2$s" + "Eltávolította: %1$s" + "Eltávolította %1$s felhasználót: %2$s" + "%1$s eltávolította %2$s felhasználót: %3$s" + "%1$s meghívót küldött %2$s számára, hogy csatlakozzon a szobához" + "Meghívót küldött %1$s számára, hogy csatlakozzon a szobához" + "%1$s visszavonta %2$s meghívását, hogy csatlakozzon a szobához" + "Visszavonta %1$s meghívását, hogy csatlakozzon a szobához" + "%1$s megváltoztatta a témát: %2$s" + "Megváltoztatta a témát: %1$s" + "%1$s eltávolította a szoba témáját" + "Eltávolította a szoba témáját" + "%1$s visszavonta %2$s kitiltását" + "Visszavonta %1$s kitiltását" + "%1$s ismeretlen változást hajtott végre a tagságában" + diff --git a/libraries/eventformatter/impl/src/main/res/values-in/translations.xml b/libraries/eventformatter/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..89da513 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,73 @@ + + + "(avatar juga diubah)" + "%1$s mengubah avatarnya" + "Anda mengubah avatar sendiri" + "%1$s telah diturunkan menjadi anggota" + "%1$s telah diturunkan menjadi moderator" + "%1$s mengubah nama tampilannya dari %2$s menjadi %3$s" + "Anda mengubah nama tampilan sendiri dari %1$s menjadi %2$s" + "%1$s menghapus nama tampilannya (sebelumnya %2$s)" + "Anda menghapus nama tampilan sendiri (sebelumnya %1$s)" + "%1$s menetapkan nama tampilannya menjadi %2$s" + "Anda menetapkan nama tampilan sendiri menjadi %1$s" + "%1$s telah dipromosikan menjadi admin" + "%1$s telah dipromosikan menjadi moderator" + "%1$s mengubah avatar ruangan" + "Anda mengubah avatar ruangan" + "%1$s menghapus avatar ruangan" + "Anda menghapus avatar ruangan" + "%1$s memblokir %2$s" + "Anda memblokir %1$s" + "Anda mencekal %1$s: %2$s" + "%1$s mencekal %2$s: %3$s" + "%1$s membuat ruangan" + "Anda membuat ruangan" + "%1$s mengundang %2$s" + "%1$s menerima undangan" + "Anda menerima undangan" + "Anda mengundang %1$s" + "%1$s mengundang Anda" + "%1$s bergabung ke ruangan" + "Anda bergabung ke ruangan" + "%1$s meminta untuk bergabung" + "%1$s memberikan akses kepada %2$s" + "Anda memperbolehkan %1$s untuk bergabung" + "Anda meminta untuk bergabung" + "%1$s menolak permintaan %2$s untuk bergabung" + "Anda menolak permintaan %1$s untuk bergabung" + "%1$s menolak permintaan Anda untuk bergabung" + "%1$s tidak lagi tertarik untuk bergabung" + "Anda membatalkan permintaan sendiri untuk bergabung" + "%1$s meninggalkan ruangan" + "Anda keluar dari ruangan" + "%1$s mengubah nama ruangan menjadi: %2$s" + "Anda mengubah nama ruangan menjadi: %1$s" + "%1$s menghapus nama ruangan" + "Anda menghapus nama ruangan" + "%1$s tidak membuat perubahan" + "Anda tidak membuat perubahan" + "%1$s mengubah pesan yang disematkan" + "Anda mengubah pesan yang disematkan" + "%1$s menyematkan pesan" + "Anda menyematkan pesan" + "%1$s melepas sematan pesan" + "Anda melepas sematan pesan" + "%1$s menolak undangan" + "Anda menolak undangan" + "%1$s mengeluarkan %2$s" + "Anda mengeluarkan %1$s" + "Anda menghapus %1$s: %2$s" + "%1$s menghapus %2$s: %3$s" + "%1$s mengirimkan undangan kepada %2$s untuk bergabung ke ruangan" + "Anda mengirimkan undangan kepada %1$s untuk bergabung ke ruangan" + "%1$s menghapus undangan kepada %2$s untuk bergabung ke ruangan" + "Anda menghapus undangan kepada %1$s untuk bergabung ke ruangan" + "%1$s mengubah topik menjadi: %2$s" + "Anda mengubah topik menjadi: %1$s" + "%1$s menghapus topik ruangan" + "Anda menghapus topik ruangan" + "%1$s membatalkan pemblokiran %2$s" + "Anda membatalkan pemblokiran %1$s" + "%1$s membuat perubahan keanggotaan yang tidak diketahui" + 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 0000000..d4b96e5 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,73 @@ + + + "(anche l\'avatar è stato cambiato)" + "%1$s ha cambiato il proprio avatar" + "Hai cambiato il tuo avatar" + "%1$s è stato declassato a membro" + "%1$s è stato declassato a moderatore" + "%1$s ha cambiato il proprio nome visualizzato da %2$s a %3$s" + "Hai cambiato il tuo nome visualizzato da %1$s a %2$s" + "%1$s ha rimosso il proprio nome visualizzato (era %2$s)" + "Hai rimosso il tuo nome visualizzato (era %1$s)" + "%1$s ha impostato il proprio nome visualizzato su %2$s" + "Hai impostato il tuo nome visualizzato su %1$s" + "%1$s è stato promosso amministratore" + "%1$s è stato promosso a moderatore" + "%1$s ha cambiato l\'avatar della stanza" + "Hai cambiato l\'avatar della stanza" + "%1$s ha rimosso l\'avatar della stanza" + "Hai rimosso l\'avatar della stanza" + "%1$s ha escluso %2$s" + "Hai escluso %1$s" + "Hai bannato %1$s: %2$s" + "%1$s ha bannato %2$s: %3$s" + "%1$s ha creato la stanza" + "Hai creato la stanza" + "%1$s ha invitato %2$s" + "%1$s ha accettato l\'invito" + "Hai accettato l\'invito" + "Hai invitato %1$s" + "%1$s ti ha invitato" + "%1$s si è unito alla stanza" + "Ti sei unito alla stanza" + "%1$s ha richiesto di entrare" + "%1$s ha permesso a %2$s di entrare" + "Hai permesso a %1$s di partecipare" + "Hai richiesto di unirti" + "%1$s ha rifiutato la richiesta di unirsi di %2$s" + "Hai rifiutato la richiesta di unirsi di %1$s" + "%1$s ha rifiutato la tua richiesta di unirti" + "%1$s non è più interessato a partecipare" + "Hai annullato la tua richiesta di unirti" + "%1$s ha lasciato la stanza" + "Hai lasciato la stanza" + "%1$s ha cambiato il nome della stanza in: %2$s" + "Hai cambiato il nome della stanza in: %1$s" + "%1$s ha rimosso il nome della stanza" + "Hai rimosso il nome della stanza" + "%1$s non ha apportato modifiche" + "Non hai apportato modifiche" + "%1$s ha modificato i messaggi fissati" + "Hai modificato i messaggi fissati" + "%1$s ha fissato un messaggio" + "Hai fissato un messaggio" + "%1$s ha rimosso un messaggio dai fissati" + "Hai rimosso un messaggio dai fissati" + "%1$s ha rifiutato l\'invito" + "Hai rifiutato l\'invito" + "%1$s ha rimosso %2$s" + "Hai rimosso %1$s" + "Hai rimosso%1$s: %2$s" + "%1$s ha rimosso %2$s: %3$s" + "%1$s ha inviato un invito a %2$s per unirsi alla stanza" + "Hai inviato un invito a %1$s per unirsi alla stanza" + "%1$s ha revocato l\'invito di %2$s ad unirsi alla stanza." + "Hai revocato l\'invito a %1$s a unirsi alla stanza" + "%1$s ha cambiato l\'argomento in: %2$s" + "Hai cambiato l\'argomento in: %1$s" + "%1$s ha rimosso l\'oggetto della stanza" + "Hai rimosso l\'argomento della stanza" + "%1$s ha sbloccato %2$s" + "Hai sbloccato %1$s" + "%1$s ha apportato una modifica sconosciuta alla propria presenza nella stanza" + diff --git a/libraries/eventformatter/impl/src/main/res/values-ka/translations.xml b/libraries/eventformatter/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..f60914b --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,63 @@ + + + "(ფოტოც შეიცვალა)" + "%1$s პროფილის ფოტო შეცვალა" + "თქვენ შეცვალეთ პროფილის ფოტო" + "%1$s დაქვეითდა წევრამდე" + "%1$s დაქვეითდა მოდერატორამდე" + "%1$s თავისი ნაჩვენები სახელი შეცვალა %2$s დან %3$s ზე" + "თქვენ შეცვალეთ თქვენი ნაჩვენები სახელი %1$s -დან %2$s -ზე" + "%1$s წაშალა თავისი ნაჩვენები სახელი (იყო %2$s)" + "თქვენ წაშალეთ ნაჩვენები სახელი (იყო %1$s)" + "%1$s თავისი ნაჩვენები სახელი შეცვალა %2$s" + "თქვენი ახალი ნაჩვენები სახელი - %1$s" + "%1$s დაწინაურდა ადმინისტრატორამდე" + "%1$s დაწინაურდა მოდერატორამდე" + "%1$s ოთახის ფოტო შეცვალა" + "თქვენ შეცვალეთ ოთახის ფოტო" + "%1$s წაშალა ოთახის ფოტო" + "თქვენ წაშალეთ ოთახის ფოტო" + "%1$s დაბლოკა %2$s" + "თქვენ დაბლოკეთ %1$s" + "%1$s შექმნა ოთახი" + "თქვენ შექმენით ოთახი" + "%1$s მოიწვია %2$s" + "%1$s მიიღო მოწვევა" + "თქვენ მიიღეთ მოწვევა" + "თქვენ მოიწვიეთ %1$s" + "%1$s მოგიწვიათ" + "%1$s გაწევრიანდა ოთახში" + "თქვენ გაწევრიანდით ოთახში" + "%1$s გაწევრიანება მოითხოვა" + "%1$s გაწევრიანების უფლება მისცა %2$s" + "თქვენ %1$s გაწევრიანების უფლება მიეცით" + "თქვენ მოითხოვეთ გაწევრიანება" + "%1$s უარი თქვა %2$s-ს გაწევრიანების მოთხოვნაზე" + "თქვენ უარი თქვით %1$s გაწევრიანების თხოვნაზე" + "%1$s უარი თქვა თქვენს მოთხოვნაზე გაწევრიანების შესახებ" + "%1$s აღარ არის დაინტერესებული გაწევრიანებით" + "თქვენ გააუქმეთ გაწევრიანების მოთხოვნა" + "%1$s დატოვა ოთახი" + "თქვენ დატოვეთ ოთახი" + "%1$s შეცვალა ოთახის სახელი: %2$s" + "თქვენ შეცვალეთ ოთახის სახელი: %1$s" + "%1$s წაშალა ოთახის სახელი" + "თქვენ წაშალეთ ოთახის სახელი" + "%1$s ცვლილებები არ შეიტანა" + "თქვენ არაფერი არ შეგიცვლიათ" + "%1$s მოწვევაზე უარი თქვა" + "თქვენ უარი თქვით მოწვევაზე" + "%1$s გააგდო %2$s" + "თქვენ გააგდეთ %1$s" + "%1$s მოიწვია %2$s ოთახში" + "თქვენ მოიწვიეთ %1$s ოთახში" + "%1$s გააუქმო %2$s-ს ოთახში მოწვევა" + "თქვენ %1$s-ს ოთახში მოწვევა გააუქმეთ" + "%1$s შეცვალა თემა: %2$s" + "თქვენ შეცვალეთ თემა: %1$s" + "%1$s წაშალა ოთახის თემა" + "თქვენ წაშალეთ ოთახის თემა" + "%1$s განბლოკა %2$s" + "თქვენ განბლოკეთ %1$s" + "%1$s უცნობი ცვლილება შეიტანა თავის წევრობაში" + diff --git a/libraries/eventformatter/impl/src/main/res/values-ko/translations.xml b/libraries/eventformatter/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..7217449 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,73 @@ + + + "(프로필 사진도 변경됨)" + "%1$s님이 프로필 사진을 변경함" + "프로필 사진을 변경함" + "%1$s 회원으로 강등되었습니다" + "%1$s 중재자로 강등되었습니다" + "%1$s님이 표시 이름을 %2$s에서 %3$s(으)로 변경했습니다." + "표시 이름을 %1$s에서 %2$s(으)로 변경했습니다" + "%1$s님이 표시 이름을 제거했습니다 (이전 이름 %2$s)" + "표시 이름을 제거했습니다 (이전 이름 %1$s)" + "%1$s님이 표시되는 이름을 %2$s(으)로 변경함" + "%1$s(으)로 표시되는 이름을 변경함" + "%1$s 는 관리자로 승진되었습니다" + "%1$s 는 중재자로 승진되었습니다" + "%1$s님이 방 아바타를 변경함" + "방 아바타를 변경함" + "%1$s님이 방 아바타를 삭제함" + "방 아바타를 삭제함" + "%1$s님이 %2$s님을 차단함" + "%1$s님을 차단함" + "당신은 차단했습니다 %1$s: %2$s" + "%1$s 차단됨 %2$s: %3$s" + "%1$s님이 방을 생성함" + "방을 생성함" + "%1$s님이 %2$s님을 초대함" + "%1$s님이 초대를 수락함" + "초대를 수락함" + "%1$s님을 초대함" + "%1$s님으로부터 초대받음" + "%1$s님이 방에 참석함" + "방에 참석함" + "%1$s님이 참가를 요청함" + "%1$s님이 %2$s님의 참가를 승인함" + "%1$s님이 참가를 승인함" + "참가를 요청함" + "%1$s이 %2$s의 참가 요청을 거절함" + "%1$s님의 가입 요청을 거부했습니다." + "%1$s님의 가입 요청을 거부했습니다" + "%1$s이 참가 요청에 관심이 없음" + "참가 요청을 거부함" + "%1$s님이 방을 떠남" + "방을 떠남" + "%1$s님이 방 이름을 변경함: %2$s" + "방 이름을 변경함: %1$s" + "%1$s님이 방 이름을 삭제함" + "방 이름을 삭제함" + "%1$s 변경 사항 없음" + "변경 사항이 없습니다." + "%1$s 고정된 메시지가 변경되었습니다." + "고정된 메시지가 변경되었습니다" + "%1$s 메시지 고정" + "당신은 메시지를 고정했습니다." + "%1$s 메시지 고정 해제" + "당신은 메시지 고정 해제" + "%1$s님이 초대를 거부함" + "초대를 거부함" + "%1$s님이 %2$s님을 제거함" + "%1$s님을 제거함" + "제거했습니다 %1$s :%2$s" + "%1$s 제거됨 %2$s : %3$s" + "%1$s님이 %2$s에게 초대를 보냄" + "%1$s님에게 초대를 보냄" + "%1$s님이 %2$s의 초대를 회수함" + "%1$s의 초대를 회수함" + "%1$s님이 주제를 %2$s으로 변경했습니다." + "주제 변경함: %1$s" + "%1$s님이 방 주제를 삭제함" + "방 주제를 삭제함" + "%1$s님이 %2$s님의 차단을 해제함" + "%1$s님의 차단을 해제함" + "%1$s님이 멤버십에 알려지지 않은 변경 사항을 만들었습니다." + diff --git a/libraries/eventformatter/impl/src/main/res/values-lt/translations.xml b/libraries/eventformatter/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..9fb1486 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,57 @@ + + + "(taip pat buvo pakeistas ir avataras)" + "%1$s pakeitė savo avatarą" + "Jūs pakeitėte savo avatarą" + "%1$s pakeitė savo slapyvardį iš %2$s į %3$s" + "Jūs pakeitėte savo slapyvardį iš %1$s į %2$s" + "%1$s pašalino savo slapyvardį (jis buvo %2$s)" + "Jūs pašalinote savo slapyvardį (jis buvo %1$s)" + "%1$s pakeitė savo slapyvardį į %2$s" + "Jūs nustatėte savo slapyvardį į %1$s" + "%1$s pakeitė kambario avatarą" + "Jūs pakeitėte kambario avatarą" + "%1$s pašalino kambario avatarą" + "Jūs pašalinote kambario avatarą" + "%1$s uždraudė %2$s" + "Jūs uždraudėte %1$s" + "%1$s sukūrė kambarį" + "Jūs sukūrėte kambarį" + "%1$s pakvietė %2$s" + "%1$s priėmė kvietimą" + "Priėmėte kvietimą" + "Jūs pakvietėte %1$s" + "%1$s pakvietė Jus" + "%1$s prisijungė prie kambario" + "Jūs prisijungėte prie kambario" + "%1$s prašo prisijungti" + "%1$s suteikė prieigą %2$s" + "Jūs leidote %1$s prisijungti" + "Jūs paprašėte prisijungti" + "%1$s atmetė %2$s prisijungimo prašymą" + "Jūs atmetėte %1$s prisijungimo prašymą" + "%1$s atmetė Jūsų prisijungimo prašymą" + "%1$s nebenori prisijungti" + "Jūs atšaukėte savo prisijungimo prašymą" + "%1$s išėjo iš kambario" + "Jūs išėjote iš kambario" + "%1$s pakeitė kambario pavadinimą į: %2$s" + "Pakeitėte kambario pavadinimą į: %1$s" + "%1$s pašalino kambario pavadinimą" + "Jūs pašalinote kambario pavadinimą" + "%1$s atmetė kvietimą" + "Jūs atmetėte kvietimą" + "%1$s pašalino %2$s" + "Jūs pašalinote %1$s" + "%1$s išsiuntė kvietimą %2$s prisijungti prie kambario" + "Išsiuntėte kvietimą %1$s prisijungti prie kambario" + "%1$s atšaukė kvietimą %2$s prisijungti prie kambario" + "Jūs atšaukėte kvietimą %1$s prisijungti prie kambario" + "%1$s pakeitė temą į: %2$s" + "Pakeitėte temą į: %1$s" + "%1$s pašalino kambario temą" + "Jūs pašalinote kambario temą" + "%1$s panaikino uždraudimą %2$s" + "Jūs panaikinote uždraudimą %1$s" + "%1$s padarė nežinomą savo narystės pakeitimą" + diff --git a/libraries/eventformatter/impl/src/main/res/values-nb/translations.xml b/libraries/eventformatter/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..6faa614 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,73 @@ + + + "(avataren ble også endret)" + "%1$s endret avatarene sine" + "Du endret avataren din" + "%1$s ble degradert til medlem" + "%1$s ble degradert til moderator" + "%1$s endret visningsnavnet fra %2$s til %3$s" + "Du endret visningsnavnet ditt fra %1$s til %2$s" + "%1$s fjernet visningsnavnet sitt (det var %2$s)" + "Du har fjernet visningsnavnet ditt (det var %1$s)" + "%1$s satte deres visningsnavn til %2$s" + "Du satt visningsnavnet ditt til %1$s" + "%1$s ble forfremmet til administrator" + "%1$s ble forfremmet til moderator" + "%1$s endret romavataren" + "Du endret rommets avatar" + "%1$s fjernet romavataren" + "Du fjernet romavataren" + "%1$s utestengte %2$s" + "Du utestengte %1$s" + "Du utestengte %1$s: %2$s" + "%1$s utestengte %2$s: %3$s" + "%1$s opprettet rommet" + "Du opprettet rommet" + "%1$s inviterte %2$s" + "%1$s takket ja til invitasjonen" + "Du takket ja til invitasjonen" + "Du inviterte %1$s" + "%1$s inviterte deg" + "%1$s ble med i rommet" + "Du ble med i rommet" + "%1$s ber om å få bli med" + "%1$s ga tilgang til %2$s" + "Du tillot %1$s å bli med" + "Du har bedt om å bli med" + "%1$s avslo %2$s\'s forespørsel om å bli med" + "Du avviste%1$s sin forespørsel om å bli med" + "%1$s avviste forespørselen din om å bli med" + "%1$s er ikke lenger interessert i å bli med" + "Du kansellerte forespørselen din om å bli med" + "%1$s forlot rommet" + "Du forlot rommet" + "%1$s endret romnavnet til: %2$s" + "Du endret romnavnet til: %1$s" + "%1$s fjernet romnavnet" + "Du fjernet romnavnet" + "%1$s gjorde ingen endringer" + "Du har ikke gjort noen endringer" + "%1$s endret de festede meldingene" + "Du endret de festede meldingene" + "%1$s festet en melding" + "Du festet en melding" + "%1$s løsnet en melding" + "Du løsnet en melding" + "%1$s avviste invitasjonen" + "Du avviste invitasjonen" + "%1$s fjernet %2$s" + "Du fjernet %1$s" + "Du fjernet %1$s: %2$s" + "%1$s fjernet %2$s: %3$s" + "%1$s sendte en invitasjon til %2$s om å bli med i rommet" + "Du sendte en invitasjon til %1$s om å bli med i rommet" + "%1$s trakk tilbake invitasjonen til %2$s om å delta i rommet" + "Du trakk tilbake invitasjonen til %1$s til å bli med i rommet" + "%1$s endret emnet til: %2$s" + "Du endret emnet til: %1$s" + "%1$s fjernet rommets emne" + "Du fjernet rommets emne" + "%1$s opphevet utestengelse av %2$s" + "Du opphevet utestengelsen av %1$s" + "%1$s gjort en ukjent endring i medlemskapet" + diff --git a/libraries/eventformatter/impl/src/main/res/values-nl/translations.xml b/libraries/eventformatter/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..4cf9479 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,69 @@ + + + "(afbeelding is ook gewijzigd)" + "%1$s wijzigde van afbeelding" + "Je hebt je afbeelding gewijzigd" + "%1$s werd gedegradeerd tot lid" + "%1$s werd gedegradeerd tot moderator" + "%1$s heeft de weergavenaam aangepast van %2$s naar %3$s" + "Je hebt je weergavenaam aangepast van %1$s naar %2$s" + "%1$s heeft de weergavenaam verwijderd (dit was %2$s)" + "Je hebt je weergavenaam verwijderd (dit was %1$s)" + "%1$s heeft de weergavenaam %2$s aangenomen" + "Je hebt de weergavenaam %1$s aangenomen" + "%1$s werd bevorderd tot beheerder" + "%1$s werd bevorderd tot moderator" + "%1$s heeft de kamerafbeelding gewijzigd" + "Je hebt de kamerafbeelding gewijzigd" + "%1$s heeft de kamerafbeelding verwijderd" + "Je hebt de kamerafbeelding verwijderd" + "%1$s heeft %2$s verbannen" + "Je hebt %1$s verbannen" + "%1$s heeft de kamer gemaakt" + "Je hebt de kamer gemaakt" + "%1$s heeft %2$s uitgenodigd" + "%1$s heeft de uitnodiging geaccepteerd" + "Je hebt de uitnodiging geaccepteerd" + "Jij hebt %1$s uitgenodigd" + "%1$s heeft je uitgenodigd" + "%1$s is tot de kamer toegetreden" + "Je bent toegetreden tot de kamer" + "%1$s vraagt om toe te treden" + "%1$s heeft %2$s toegang verleend" + "Je hebt %1$s toegestaan toe te treden" + "Je hebt gevraagd om toe te treden" + "%1$s heeft %2$s\'s verzoek om toe te treden afgewezen" + "Je hebt %1$s\'s verzoek om toe te treden afgewezen" + "%1$s heeft je verzoek om toe te treden afgewezen" + "%1$s wil niet meer toetreden" + "Je hebt je verzoek om toe te treden geannuleerd" + "%1$s verliet de kamer" + "Je hebt de kamer verlaten" + "%1$s heeft de kamernaam gewijzigd naar: %2$s" + "Je hebt de kamernaam gewijzigd naar: %1$s" + "%1$s heeft de kamernaam verwijderd" + "Je hebt de kamernaam verwijderd" + "%1$s heeft geen wijzigingen aangebracht" + "Je hebt geen wijzigingen aangebracht" + "%1$s heeft de vastgezette berichten gewijzigd" + "Je hebt de vastgezette berichten gewijzigd" + "%1$s heeft een bericht vastgezet" + "Je hebt een bericht vastgezet" + "%1$s heeft een bericht losgemaakt" + "Je hebt een bericht losgemaakt" + "%1$s heeft de uitnodiging afgewezen" + "Je hebt de uitnodiging afgewezen" + "%1$s heeft %2$s verwijderd" + "Je hebt %1$s verwijderd" + "%1$s heeft %2$s in deze kamer uitgenodigd" + "Je hebt %1$s in deze kamer uitgenodigd" + "%1$s heeft de uitnodiging aan %2$s om toe te treden tot de kamer ingetrokken" + "Je hebt de uitnodiging aan %1$s om toe te treden tot de kamer ingetrokken" + "%1$s heeft het onderwerp gewijzigd naar: %2$s" + "Je hebt het onderwerp gewijzigd naar: %1$s" + "%1$s heeft het kameronderwerp verwijderd" + "Je hebt het kamerondewerp verwijderd" + "%1$s heeft %2$s ontbannen" + "Jij hebt %1$s ontbannen" + "%1$s heeft een onbekende lidmaatschapswijziging" + diff --git a/libraries/eventformatter/impl/src/main/res/values-pl/translations.xml b/libraries/eventformatter/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..e45227a --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,73 @@ + + + "(zdjęcie profilowe też zostało zmienione)" + "%1$s zmienił swoje zdjęcie profilowe" + "Zmieniłeś swoje zdjęcie profilowe" + "%1$s został zdegradowany do członka" + "%1$s został zdegradowany do moderatora" + "%1$s zmienił swoją wyświetlaną nazwę z %2$s na %3$s" + "Zmieniłeś swoją wyświetlaną nazwę z %1$s na %2$s" + "%1$s usunął swoją wyświetlaną nazwę (byo to %2$s)" + "Usunąłeś swoją wyświetlaną nazwę (było to %1$s)" + "%1$s ustawił swoją wyświetlaną nazwę na %2$s" + "Ustawiłeś swoją wyświetlaną nazwę na %1$s" + "%1$s został awansowany na administratora" + "%1$s został awansowany na moderatora" + "%1$s zmienił zdjęcie profilowe pokoju" + "Zmieniłeś zdjęcie profilowe pokoju" + "%1$s usunął zdjęcie profilowe pokoju" + "Usunąłeś zdjęcie profilowe pokoju" + "%1$s zbanował %2$s" + "Zbanowałeś %1$s" + "Zbanowałeś %1$s: %2$s" + "%1$s zbanował %2$s: %3$s" + "%1$s stworzył pokój" + "Stworzyłeś pokój" + "%1$s zaprosił %2$s" + "%1$s zaakceptował zaproszenie" + "Zaakceptowałeś zaproszenie" + "Zaprosiłeś %1$s" + "%1$s zaprosił Cię" + "%1$s dołączył do pokoju" + "Dołączyłeś(aś) do pokoju" + "%1$s prosi o możliwość dołączenia" + "%1$s zezwolił %2$s na dołączenie" + "Zezwoliłeś %1$s na dołączenie" + "Poprosiłeś o możliwość dołączenia" + "%1$s odrzucił prośbę %2$s o dołączenie" + "Odrzuciłeś prośbę %1$s o dołączenie" + "%1$s odrzucił Twoją prośbę o dołączenie" + "%1$s nie jest już zainteresowany dołączeniem" + "Anulowałeś prośbę o dołączenie" + "%1$s opuścił pokój" + "Opuściłeś pokój" + "%1$s zmienił nazwę pokoju na: %2$s" + "Zmieniłeś nazwę pokoju na: %1$s" + "%1$s usunął nazwę pokoju" + "Usunąłeś nazwę pokoju" + "%1$s nie wprowadził żadnych zmian" + "Nie wprowadzono żadnych zmian" + "%1$s zmienił przypięte wiadomości" + "Zmieniłeś przypięte wiadomości" + "%1$s przypiął wiadomość" + "Przypiąłeś wiadomość" + "%1$s odpiął wiadomość" + "Odpiąłeś wiadomość" + "%1$s odrzucił zaproszenie" + "Odrzuciłeś zaproszenie" + "%1$s usunął %2$s" + "Usunąłeś %1$s" + "Usunąłeś %1$s: %2$s" + "%1$s usunął %2$s: %3$s" + "%1$s wysłał zaproszenie do %2$s, aby dołączył do pokoju" + "Wysłano zaproszenie do %1$s, aby dołączył do pokoju" + "%1$s cofnął zaproszenie dla %2$s do tego pokoju" + "Odwołano zaproszenie %1$s, aby dołączył do pokoju" + "%1$s zmienił temat na: %2$s" + "Zmieniłeś temat na: %1$s" + "%1$s usunął temat pokoju" + "Usunąłeś temat pokoju" + "%1$s odbanował %2$s" + "Odbanowałeś %1$s" + "%1$s dokonał nieznanej zmiany w swoim członkostwie" + diff --git a/libraries/eventformatter/impl/src/main/res/values-pt-rBR/translations.xml b/libraries/eventformatter/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..c5b36c4 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,73 @@ + + + "(o avatar também foi alterado)" + "%1$s mudou seu avatar" + "Você mudou seu avatar" + "%1$s foi rebaixado a membro" + "%1$s foi rebaixado a moderador" + "%1$s mudou seu nome de exibição de %2$s para %3$s" + "Você alterou seu nome de exibição de %1$s para %2$s" + "%1$s removeu seu nome de exibição (era %2$s)" + "Você removeu seu nome de exibição (era %1$s)" + "%1$s definiu seu nome de exibição como %2$s" + "Você definiu seu nome de exibição como %1$s" + "%1$s foi promovido a administrador" + "%1$s foi promovido a moderador" + "%1$s mudou o avatar da sala" + "Você mudou o avatar da sala" + "%1$s removeu o avatar da sala" + "Você removeu o avatar da sala" + "%1$s baniu %2$s" + "Você baniu %1$s" + "Você baniu %1$s: %2$s" + "%1$s baniu %2$s: %3$s" + "%1$s criou a sala" + "Você criou a sala" + "%1$s convidou %2$s" + "%1$s aceitou o convite" + "Você aceitou o convite" + "Você convidou %1$s" + "%1$s convidou você" + "%1$s entrou na sala" + "Você entrou na sala" + "%1$s solicitou entrada" + "%1$s concedeu o acesso a %2$s" + "Você permitiu que o %1$s entrasse" + "Você pediu para entrar" + "%1$s rejeitou a solicitação de %2$s para entrar" + "Você rejeitou a solicitação de %1$s para entrar" + "%1$s rejeitou seu pedido para entrar" + "%1$s não está mais interessado em entrar" + "Você cancelou seu pedido para entrar" + "%1$s saiu da sala" + "Você saiu da sala" + "%1$s mudou o nome da sala para: %2$s" + "Você mudou o nome da sala para: %1$s" + "%1$s removeu o nome da sala" + "Você removeu o nome da sala" + "%1$s não fez alterações" + "Você não fez nenhuma alteração" + "%1$s alterou as mensagens fixadas" + "Você alterou as mensagens fixadas" + "%1$s fixou uma mensagem" + "Você fixou uma mensagem" + "%1$s desafixou uma mensagem" + "Você desafixou uma mensagem" + "%1$s rejeitou o convite" + "Você rejeitou o convite" + "%1$s removido %2$s" + "Você removeu %1$s" + "Você removeu %1$s: %2$s" + "%1$s removeu %2$s: %3$s" + "%1$s enviou um convite para %2$s para entrar na sala" + "Você enviou um convite para %1$s para entrar na sala" + "%1$s revogou o convite para %2$s para entrar na sala" + "Você revogou o convite para %1$s para entrar na sala" + "%1$s mudou o tópico para: %2$s" + "Você mudou o tópico para: %1$s" + "%1$s removeu o tópico da sala" + "Você removeu o tópico da sala" + "%1$s desbaniu %2$s" + "Você desbaniu %1$s" + "%1$s fez uma alteração desconhecida em sua associação" + diff --git a/libraries/eventformatter/impl/src/main/res/values-pt/translations.xml b/libraries/eventformatter/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..94f5904 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,73 @@ + + + "(avatar alterado também)" + "%1$s alterou o seu avatar" + "Alteraste o teu avatar" + "%1$s foi despromovido a participante" + "%1$s foi despromovido a moderador" + "%1$s alterou o seu pseudónimo de %2$s para %3$s" + "Alteraste o teu pseudónimo de %1$s para %2$s" + "%1$s removeu o seu pseudónimo (era %2$s)" + "Removeste o teu pseudónimo (era %1$s)" + "%1$s definiu o seu pseudónimo como %2$s" + "Definiste o teu pseudónimo como %1$s" + "%1$s foi promovido a administrador" + "%1$s foi promovido a moderador" + "%1$s alterou o ícone da sala" + "Alteraste o ícone da sala" + "%1$s removeu o ícone da sala" + "Removeste o ícone da sala" + "%1$s baniu %2$s" + "Baniste %1$s" + "Baniste %1$s: %2$s" + "%1$s baniu %2$s: %3$s" + "%1$s criou a sala" + "Criaste a sala" + "%1$s convidou %2$s" + "%1$s aceitou o convite" + "Aceitaste o convite" + "Convidaste %1$s" + "%1$s convidou-te" + "%1$s entrou na sala" + "Entraste na sala" + "%1$s está a pedir para entrar" + "%1$s permitiu %2$s entrar" + "Permitiste a entrada de %1$s" + "Pediste para entrar" + "%1$s rejeitou o pedido de entrada de %2$s" + "Rejeitaste o pedido de entrada e %1$s" + "%1$s rejeitou o teu pedido de entrada" + "%1$s deixou de querer entrar" + "Cancelaste o teu pedido de entrada" + "%1$s saiu da sala" + "Saíste da sala" + "%1$s alterou o nome da sala para: %2$s" + "Alteraste o nome da sala para:%1$s" + "%1$s removeu o nome da sala" + "Removeste o nome da sala" + "%1$s não fiz nenhuma alteração" + "Não fizeste nenhuma alteração" + "%1$s alterou as mensagens afixadas" + "Alteraste as mensagens afixadas" + "%1$s afixou uma mensagem" + "Afixaste uma mensagem" + "%1$s desafixou uma mensagem" + "Desafixaste uma mensagem" + "%1$s rejeitou o convite" + "Rejeitaste o convite" + "%1$s removeu %2$s" + "Removeste %1$s" + "Removeste %1$s: %2$s" + "%1$s removeu %2$s: %3$s" + "%1$s enviou um convite a %2$s" + "Enviaste um convite a %1$s" + "%1$s revogou o convite de %2$s" + "Revogaste o convite de %1$s" + "%1$s alterou a descrição para: %2$s" + "Alteraste a descrição para: %1$s" + "%1$s removeu a descrição da sala" + "Removeste a descrição da sala" + "%1$s desbaniu %2$s" + "Anulaste o banimento de %1$s" + "%1$s efetuou uma alteração desconhecida à sua participação na sala" + 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 0000000..6465d32 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,73 @@ + + + "(s-a schimbat si avatarul)" + "%1$s și-a schimbat avatarul" + "V-ați schimbat avatarul" + "%1$s a fost retrogradat la funcția de membru" + "%1$s a fost retrogradat la funcția de moderator" + "%1$s și-a schimbat numele din %2$s în %3$s" + "V-ați schimbat numele din %1$s în %2$s" + "%1$s și-a șters numele (era %2$s)" + "V-ați șters numele (era %1$s)" + "%1$s și-a schimbat numele %2$s" + "V-ați schimbat numele în %1$s" + "%1$s a fost promovat în funcția de administrator" + "%1$s a fost promovat la funcția de moderator" + "%1$s a schimbat avatarul camerei" + "Ați schimbat avatarul camerei" + "%1$s a șters avatarul camerei" + "Ați șters avatarul camerei" + "%1$s a adăugat o interdicție pentru %2$s" + "Ați adăugat o interdicție pentru %1$s" + "L-ați interzis pe %1$s: %2$s" + "%1$s a interzis pe %2$s: %3$s" + "%1$s a creat camera" + "Ați creat camera" + "%1$s l-a invitat pe %2$s" + "%1$s a acceptat invitația" + "Ați acceptat invitația" + "L-ați invitat pe %1$s" + "%1$s v-a invitat" + "%1$s a intrat în cameră" + "Ați intrat în cameră" + "%1$s a cerut să se alăture camerei" + "%1$s i-a permis accesul lui %2$s" + "I-ați permis lui %1$s să se alăture" + "Ați cerut să vă alăturați camerei" + "%1$s a respins cererea de alăturare a lui %2$s" + "Ați respins cererea de alăturare a lui %1$s" + "%1$s a respins cererea dumneavoastră de alăturare" + "%1$s nu mai este interesat să se alăture camerei" + "Ați anulat cererea de alăturare" + "%1$s a părăsit camera" + "Ați părăsit camera" + "%1$s a schimbat numele camerei în: %2$s" + "Ați schimbat numele camerei în: %1$s" + "%1$s a șters numele camerei" + "Ați șters numele camerei" + "%1$s nu a făcut nicio modificare" + "Nu ați făcut nicio modificare" + "%1$s a schimbat mesajele fixate" + "Ați schimbat mesajele fixate" + "%1$s a fixat un mesaj" + "Ați fixat un mesaj" + "%1$s a defixat un mesaj" + "Ați defixat un mesaj" + "%1$s a respins invitația" + "Ați respins invitația" + "%1$s l-a îndepărtat pe %2$s" + "L-ați îndepărtat pe %1$s" + "L-ați îndepărtat pe %1$s: %2$s" + "%1$s l-a îndepărtat pe %2$s: %3$s" + "%1$s a trimis o invitație către %2$s pentru a se alătura camerei" + "Ați trimis o invitație către %1$s pentru a se alătura camerei" + "%1$s a revocat invitația pentru %2$s de a se alătura camerei" + "Ați revocat invitația pentru %1$s de a se alătura camerei" + "%1$s a schimbat subiectul în: %2$s" + "Ați schimbat subiectul în: %1$s" + "%1$s a șters subiectul camerei" + "Ați șters subiectul camerei" + "%1$s a anulat interdicția pentru %2$s" + "Ați anulat interdicția pentru %1$s" + "%1$s a făcut o modificare necunoscută asupra calității sale de membru" + diff --git a/libraries/eventformatter/impl/src/main/res/values-ru/translations.xml b/libraries/eventformatter/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..b551539 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,73 @@ + + + "(изображение тоже было изменено)" + "%1$s сменил своё изображение" + "Вы сменили изображение профиля" + "%1$s был понижен до участника" + "%1$s был понижен до модератора" + "%1$s изменил свое отображаемое имя с %2$s на %3$s" + "Вы изменили свое отображаемое имя с %1$s на %2$s" + "%1$s удалил свое отображаемое имя (оно было %2$s)" + "Вы удалили свое отображаемое имя (оно было %1$s)" + "%1$s установили свое отображаемое имя на %2$s" + "Вы установили отображаемое имя на %1$s" + "%1$s был повышен до уровня администратора" + "%1$s был повышен до модератора" + "%1$s изменил изображение комнаты" + "Вы изменили изображение комнаты" + "%1$s удалил изображение комнаты" + "Вы удалили изображение комнаты" + "%1$s заблокировал %2$s" + "Вы заблокировали %1$s" + "Вы заблокировали %1$s: %2$s" + "%1$s заблокирован %2$s: %3$s" + "%1$s создал комнату" + "Вы создали комнату" + "%1$s пригласил %2$s" + "%1$s принял приглашение" + "Вы приняли приглашение" + "Вы пригласили %1$s" + "Пользователь %1$s пригласил вас" + "%1$s присоединился к комнате" + "Вы присоединились к комнате" + "%1$s хочет присоединиться" + "%1$s разрешил %2$s присоединиться" + "Вы разрешили %1$s присоединиться" + "Вы запросили присоединение" + "%1$s отклонил запрос %2$s на присоединение" + "Вы отклонили запрос %1$s на присоединение" + "%1$s отклонил ваш запрос на присоединение" + "%1$s больше не заинтересован в присоединении" + "Вы отменили запрос на присоединение" + "%1$s покинул комнату" + "Вы покинули комнату" + "%1$s изменил название комнаты на: %2$s" + "Вы изменили название комнаты на: %1$s" + "%1$s удалил название комнаты" + "Вы удалили название комнаты" + "%1$s ничего не изменил" + "Вы не внесли никаких изменений" + "%1$s изменил закрепленные сообщения" + "Вы изменили закрепленные сообщения" + "%1$s закрепил сообщение" + "Вы закрепили сообщение" + "%1$s открепил сообщение" + "Вы открепили сообщение" + "%1$s отклонил приглашение" + "Вы отклонили приглашение" + "%1$s удалил %2$s" + "Вы удалили %1$s" + "Вы удалили %1$s: %2$s" + "%1$s удален %2$s: %3$s" + "%1$s отправила приглашение %2$s присоединиться к комнате" + "Вы отправили приглашение присоединиться к комнате %1$s" + "%1$s отозвал приглашение %2$s присоединиться к комнате" + "Вы отозвали приглашение %1$s присоединиться к комнате" + "%1$s изменил тему на: %2$s" + "Вы изменили тему на: %1$s" + "%1$s удалил тему комнаты" + "Вы удалили тему комнаты" + "%1$s разблокировал %2$s" + "Вы разблокировали %1$s" + "%1$s внес неизвестное изменение для своих участников" + 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 0000000..c13ce85 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,73 @@ + + + "(obrázok bol tiež zmenený)" + "%1$s zmenili svoj obrázok" + "Zmenili ste svoj obrázok" + "Používateľovi %1$s bola znížená úroveň na člena" + "Používateľovi %1$s bola znížená úroveň na moderátora" + "%1$s zmenili svoje zobrazované meno z %2$s na %3$s" + "Zmenili ste si zobrazované meno z %1$s na %2$s" + "%1$s odstránili svoje zobrazované meno (predtým bolo %2$s)" + "Odstránili ste svoje zobrazované meno (predtým bolo %1$s)" + "%1$s nastavili svoje zobrazované meno na %2$s" + "Svoje zobrazované meno ste nastavili na %1$s" + "%1$s bol/a povýšený/á na správcu" + "%1$s bol/a povýšený/á na moderátora" + "%1$s zmenil/a obrázok miestnosti" + "Zmenili ste obrázok miestnosti" + "%1$s odstránil/a obrázok miestnosti" + "Odstránili ste obrázok miestnosti" + "%1$s zakázal/a používateľa %2$s" + "Zakázali ste používateľa %1$s" + "Zakázali ste %1$s: %2$s" + "%1$s zakázal/a %2$s: %3$s" + "%1$s vytvoril/a miestnosť" + "Vytvorili ste miestnosť" + "%1$s pozval/a používateľa %2$s" + "%1$s prijal/a pozvanie" + "Prijali ste pozvánku" + "Pozvali ste používateľa %1$s" + "%1$s vás pozval/a" + "%1$s sa pripojil/a do miestnosti" + "Vstúpili ste do miestnosti" + "%1$s žiada o vstup" + "%1$s umožnil/a vstup používateľovi %2$s" + "Povolili ste používateľovi %1$s, aby sa pripojil" + "Požiadali ste o pripojenie" + "%1$s odmietol/a žiadosť používateľa %2$s o vstup" + "Odmietli ste žiadosť používateľa %1$s o pripojenie" + "%1$s zamietol vašu žiadosť o pripojenie" + "%1$s už nemá záujem o vstup" + "Zrušili ste svoju žiadosť o pripojenie" + "%1$s opustil/a miestnosť" + "Opustili ste miestnosť" + "%1$s zmenil/a názov miestnosti na: %2$s" + "Zmenili ste názov miestnosti na: %1$s" + "%1$s odstránil/a názov miestnosti" + "Odstránili ste názov miestnosti" + "%1$s nevykonal/a žiadne zmeny" + "Nevykonali ste žiadne zmeny" + "%1$s zmenil/a pripnuté správy" + "Zmenili ste pripnuté správy" + "%1$s pripol/la správu" + "Pripli ste správu" + "%1$s zrušil/a pripnutie správy" + "Zrušili ste pripnutie správy" + "%1$s odmietol/a pozvánku" + "Odmietli ste pozvánku" + "%1$s odstránil/a %2$s" + "Odstránili ste %1$s" + "Odstránili ste %1$s: %2$s" + "%1$s odstránil/a %2$s: %3$s" + "%1$s poslal/a pozvánku používateľovi %2$s, aby sa pripojil k miestnosti" + "Poslali ste pozvánku používateľovi %1$s, aby sa pripojil do miestnosti" + "%1$s zrušil/a pozvánku pre používateľa %2$s na vstup do miestnosti" + "Zrušili ste pozvánku pre používateľa %1$s na vstup do miestnosti" + "%1$s zmenil/a tému na: %2$s" + "Zmenili ste tému na: %1$s" + "%1$s odstránil/a tému miestnosti" + "Odstránili ste tému miestnosti" + "%1$s zrušil/a zákaz pre %2$s" + "Zrušili ste zákaz pre %1$s" + "%1$s urobil/a neznámu zmenu svojho členstva" + diff --git a/libraries/eventformatter/impl/src/main/res/values-sv/translations.xml b/libraries/eventformatter/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..473c73c --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,73 @@ + + + "(avatar ändrades också)" + "%1$s bytte sin avatar" + "Du bytte din avatar" + "%1$s degraderades till medlem" + "%1$s degraderades till moderator" + "%1$s bytte sitt visningsnamn från %2$s till %3$s" + "Du bytte ditt visningsnamn från %1$s till %2$s" + "%1$s tog bort sitt visningsnamn (det var %2$s)" + "Du tog bort ditt visningsnamn (det var %1$s)" + "%1$s satte sitt visningsnamn till %2$s" + "Du satte ditt visningsnamn till %1$s" + "%1$s befordrades till admin" + "%1$s befordrades till moderator" + "%1$s bytte rummets avatar" + "Du bytte rummets avatar" + "%1$s tog bort rummets avatar" + "Du tog bort rummets avatar" + "%1$s bannade %2$s" + "Du bannade %1$s" + "Du bannade %1$s: %2$s" + "%1$s bannade %2$s: %3$s" + "%1$s skapade rummet" + "Du skapade rummet" + "%1$s bjöd in %2$s" + "%1$s accepterade inbjudan" + "Du accepterade inbjudan" + "Du bjöd in %1$s" + "%1$s bjöd in dig" + "%1$s gick med i rummet" + "Du gick med i rummet" + "%1$s begär att gå med" + "%1$s tillät %2$s att gå med" + "Du lät %1$s att gå med" + "Du begärde att gå med" + "%1$s avvisade begäran från %2$s om att gå med" + "Du avvisade begäran från %1$s om att gå med" + "%1$s avvisade din begäran om att gå med" + "%1$s är inte längre intresserad av att gå med" + "Du avbröt din begäran om att gå med" + "%1$s lämnade rummet" + "Du lämnade rummet" + "%1$s bytte rummets namn till: %2$s" + "Du bytte rummets namn till: %1$s" + "%1$s tog bort rummets namn" + "Du tog bort rummets namn" + "%1$s gjorde inga ändringar" + "Du gjorde inga ändringar" + "%1$s ändrade de fästa meddelandena" + "Du ändrade de fästa meddelandena" + "%1$s fäste ett meddelande" + "Du har fäste ett meddelande" + "%1$s lossade ett meddelande" + "Du har lossade ett meddelande" + "%1$s avvisade inbjudan" + "Du avvisade inbjudan" + "%1$s tog bort %2$s" + "Du tog bort %1$s" + "Du tog bort %1$s: %2$s" + "%1$s tog bort %2$s: %3$s" + "%1$s skickade en inbjudan till %2$s att gå med i rummet" + "Du skickade en inbjudan till %1$s att gå med i rummet" + "%1$s återkallade inbjudan för %2$s att gå med i rummet" + "Du återkallade inbjudan för %1$s att gå med i rummet" + "%1$s bytte ämnet till: %2$s" + "Du bytte ämnet till: %1$s" + "%1$s tog bort rummets ämne" + "Du tog bort rummets ämne" + "%1$s avbannade %2$s" + "Du avbannade %1$s" + "%1$s gjorde en okänd ändring till deras medlemsskap." + diff --git a/libraries/eventformatter/impl/src/main/res/values-tr/translations.xml b/libraries/eventformatter/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..5078048 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,69 @@ + + + "(Profil fotoğrafı değiştirildi)" + "%1$s profil fotoğrafını değiştirdi" + "Profil fotoğrafını değiştirdin" + "%1$s üyeliğe düşürüldü" + "%1$s moderatörlüğe düşürüldü" + "%1$s görünen adını bundan %2$s şuna %3$s değiştirdi" + "Görünen adınızı bundan %1$s şuna %2$s değiştirdiniz" + "%1$s görünen adını kaldırdı (önceden %2$s)" + "Görünen adınızı kaldırdınız (önceden %1$s)" + "%1$s görünen adlarını şuna ayarla %2$s" + "Görünen adınız %1$s" + "%1$s yöneticiliğe terfi etti" + "%1$s moderatörlüğe terfi etti" + "%1$s odanın fotoğrafını değiştirdi" + "Odanın fotoğrafını değiştirdiniz" + "%1$s oda fotoğrafını kaldırdı" + "Oda fotoğrafını kaldırdınız" + "%1$syasaklandı%2$s" + "%1$s yasakladınız" + "%1$sodayı yarattı" + "Odayı sen yarattın" + "%1$s davet edildi %2$s" + "%1$s daveti kabul etti" + "Daveti kabul ettiniz" + "%1$s davet ettiniz" + "%1$s sizi davet etti" + "%1$sodaya katıldı" + "Odaya katıldınız" + "%1$s katılmak istiyor" + "%1$s, %2$s\'e erişim izni verdi" + "%1$s \'ın katılmasına izin verdiniz" + "Katılmayı talep ettiniz" + "%1$s, %2$s\'ın katılma isteğini reddetti" + "%1$s kullanıcısının katılma isteğini reddettiniz" + "%1$s katılma isteğinizi reddetti" + "%1$s artık katılmakla ilgilenmiyor" + "Katılma talebinizi iptal ettiniz" + "%1$sodadan ayrıldı" + "Odadan ayrıldın." + "%1$s Oda adını değiştirdi: %2$s" + "Odanın adını değiştridiniz: %1$s" + "%1$s Oda adını kaldırdı" + "Oda adını kaldırdınız" + "%1$s hiçbir değişiklik yapmadı" + "Hiçbir değişiklik yapmadınız" + "%1$s sabitlenmiş iletileri değiştirdi" + "Sabitlenmiş mesajları değiştirdiniz" + "%1$s bir mesaj sabitledi" + "Bir mesaj sabitlediniz" + "%1$s bir mesajın sabitlemesini kaldırdı" + "Bir mesajın sabitlemesini kaldırdınız" + "%1$sdaveti reddetti" + "Daveti reddettiniz" + "%1$skaldırıldı%2$s" + "%1$s kaldırdınız" + "%1$s odaya katılması için %2$s\'a davet gönderdi" + "Odaya katılması için %1$s\'a davet gönderdin" + "%1$s, %2$s\'nin odaya katılma davetini iptal etti" + "%1$s\'ın odaya katılma davetini iptal ettiniz" + "%1$s konuyu değiştirdi: %2$s" + "Konuyu değiştirdiniz: %1$s" + "%1$s oda konusunu kaldırdı" + "Oda konusunu kaldırdınız" + "%1$syasaklanmamış%2$s" + "%1$s yasağını kaldırdınız" + "%1$s üyeliğinde bilinmeyen bir değişiklik yaptı" + diff --git a/libraries/eventformatter/impl/src/main/res/values-uk/translations.xml b/libraries/eventformatter/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..82c41a5 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,73 @@ + + + "(аватар теж було змінено)" + "%1$s змінює свій аватар" + "Ви змінили свій аватар" + "%1$s понижено до учасника" + "%1$s понижено до модератора" + "%1$s змінює своє імʼя з %2$s на %3$s" + "Ви змінили своє ім\'я з %1$s на %2$s" + "%1$s вилучає своє ім\'я (було %2$s)" + "Ви видалили своє ім\'я (було%1$s)" + "%1$s змінює своє ім\'я на %2$s" + "Ви змінили своє імʼя на %1$s" + "%1$s підвищено до адміністратора" + "%1$s підвищено до модератора" + "%1$s змінює аватар кімнати" + "Ви змінили аватар кімнати" + "%1$s видаляє аватар кімнати" + "Ви видалили аватар кімнати" + "%1$s блокує %2$s" + "Ви заблокували %1$s" + "Ви заблокували %1$s: %2$s" + "%1$s блокує %2$s: %3$s" + "%1$s створює кімнату" + "Ви створили кімнату" + "%1$s запрошує %2$s" + "%1$s приймає запрошення" + "Ви прийняли запрошення" + "Ви запросили %1$s" + "Вас запрошує %1$s" + "%1$s приєднується до кімнати" + "Ви приєдналися до кімнати" + "%1$s подав (-ла) запит на приєднання" + "%1$s дозволив (-ла) %2$s приєднатися" + "Ви дозволили %1$s приєднатися" + "Ви подали запит на приєднання" + "%1$s відхиляє запит %2$s на приєднання" + "Ви відхилили запит на приєднання від %1$s" + "%1$s відхиляє ваш запит на приєднання" + "%1$s більше не хоче приєднуватися" + "Ви відкликали свій запит на приєднання" + "%1$s виходить з кімнати" + "Ви вийшли з кімнати" + "%1$s змінює назву кімнати на: %2$s" + "Ви змінили назву кімнати на: %1$s" + "%1$s вилучає назву кімнати" + "Ви видалили назву кімнати" + "%1$s нічого не змінює" + "Ви не внесли жодних змін" + "%1$s змінює закріплені повідомлення" + "Ви змінили закріплені повідомлення" + "%1$s закріплює повідомлення" + "Ви закріпили повідомлення" + "%1$s відкріплює повідомлення" + "Ви відкріпили повідомлення" + "%1$s відхиляє запрошення" + "Ви відхилили запрошення" + "%1$s вилучає %2$s" + "Ви видалили %1$s" + "Ви вилучили %1$s: %2$s" + "%1$s вилучає %2$s: %3$s" + "%1$s запрошує %2$s приєднатися до кімнати" + "Ви запросили %1$s приєднатися до кімнати" + "%1$s відкликає запрошення приєднатися до кімнати для %2$s" + "Ви відкликали запрошення приєднатися до кімнати для %1$s" + "%1$s змінює тему на: %2$s" + "Ви змінили тему на: %1$s" + "%1$s вилучає тему кімнати" + "Ви вилучили тему кімнати" + "%1$s розблоковує %2$s" + "Ви розблокували %1$s" + "%1$s вносить невідомі зміни щодо свого членства" + diff --git a/libraries/eventformatter/impl/src/main/res/values-ur/translations.xml b/libraries/eventformatter/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..6dba74f --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,69 @@ + + + "(اوتار بھی تبدیل ہوا)" + "%1$s نے اپنا اوتار بدل دیا" + "آپنے اپنا اوتار بدل دیا" + "%1$s کو تا رکن تنزل کیا گیا" + "%1$s کو تا ناظم تنزل کیا گیا" + "%1$s نے اپنا نمائشی نام %2$s سے %3$s پر بدل دیا" + "آپنے اپنا نمائشی نام %1$s سے %2$s پر بدل دیا" + "%1$s نے اپنا نمائشی نام ہٹادیا (%2$s تھا)" + "آپنے اپنا نمائشی نام ہٹادیا (%1$s تھا)" + "%1$s نے اپنا نمائشی نام %2$s پر متعین کردیا" + "آپنے اپنا نمائشی نام %1$s پر متعین کردیا" + "%1$s کو تا منتظم فروغ دیا گیا" + "%1$s کو تا ناظم فروغ دیا گیا" + "%1$s نے کمرے کا اوتار بدل دیا" + "آپنے کمرے کا اوتار بدل دیا" + "%1$s نے کمرے کا اوتار ہٹادیا" + "آپنے کمرے کا اوتار ہٹادیا" + "%1$s نے %2$s پر پابندی لگادی" + "آپنے %1$s پر پابندی لگادی" + "%1$s نے کمرہ تخلیق کیا" + "آپ نے کمرہ تخلیق کیا" + "%1$s نے %2$s کو مدعو کیا" + "%1$s نے دعوت قبول کرلی" + "آپنے دعوت قبول کرلی" + "آپنے %1$s مدعو کیا" + "%1$s نے آپکو مدعو کیا" + "%1$s کمرے میں شامل ہوگیا" + "آپ کمرے میں شامل ہو گئے" + "%1$s شامل ہونے کی درخواست کر رہا ہے" + "%1$s نے %2$s کو اجازت فراہم کی" + "آپنے %1$s کو شامل ہونے کی اجازت دی" + "آپنے شامل ہونے کی دعوت کی" + "%1$s نے %2$s کی شامل ہونے کی درخواست مسترد کردی" + "آپنے %1$s کی شامل ہونے کی درخواست مسترد کردی" + "%1$s نے آپکی شامل ہونے کی درخواست مسترد کردی" + "%1$s کی شامل ہونے میں دلچسپی نہیں" + "آپنے اپنی شامل ہونے کی درخواست منسوخ کردی" + "%1$s کمرے سے رخصت ہوگیا" + "آپ کمرے سے رخصت ہوگئے" + "%1$s نے کمرے کا نام بدل دیا تا: %2$s" + "آپنے کمرے کا نام بدل دیا تا: %1$s" + "%1$s نے کمرے کا نام ہٹادیا" + "آپنے کمرے کا نام ہٹادیا" + "%1$s نے کوئی تبدیلیاں نہیں کیں" + "آپ نے کوئی تبدیلیاں نہیں کیں" + "%1$s نے مثبوتہ پیغامات بدل دیے" + "آپنے مثبوتہ پیغامات بدل دیے" + "%1$s نے پیغام تثبیت کردیا" + "آپنے پیغام تثبیت کردیا" + "%1$s نے پیغام غیر مثبوت کردیا" + "آپنے پیغام غیر مثبوت کردیا" + "%1$s نے دعوت مسترد کر دی" + "آپنے دعوت مسترد کر دی" + "%1$s نے %2$s کو ہٹادیا" + "آپنے %1$s کو ہٹادیا" + "%1$s نے %2$s کو کمرے میں شامل ہونی کی دعوت بھیج دی" + "آپنے %1$s کو کمرے میں شامل ہونے کی دعوت بھیج دی" + "%1$s نے %2$s کی کمرے میں شامل ہونے کی دعوت منسوخ کردی" + "آپنے %1$s کے کمرے میں شامل ہونے کی دعوت منسوخ کردی" + "%1$s نے موضوع کو بدلا تا: %2$s" + "آپنے موضوع کو بدلا تا: %1$s" + "%1$s نے کمرے کا موضوع ہٹادیا" + "آپنے کمرے کا موضوع ہٹادیا" + "%1$s کے %2$s کو غیر پابندی یافتہ کردیا" + "آپنے %1$s کو غیر پابندی یافتہ کردیا" + "%1$s نے اپنی رکنیت میں ایک نامعلوم تبدیلی کی" + diff --git a/libraries/eventformatter/impl/src/main/res/values-uz/translations.xml b/libraries/eventformatter/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..11835dc --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,73 @@ + + + "(avatar ham o\'zgartirildi)" + "%1$s avatarini o\'zgartirdi" + "Siz avataringizni o\'zgartirdingiz" + "%1$s oddiy a’zo lavozimiga tushirildi" + "%1$s moderator lavozimiga tushirildi" + "%1$s ko\'rsatiladigan nomini %2$sdan %3$sga o\'zgartirdi" + "Siz ko\'rsatiladigan nomingizni %1$s dan %2$s ga o\'zgartirdingiz" + "%1$s ko\'rinadigan nomini o\'chirib tashladi (avval %2$s bo\'lgan edi)" + "Siz ko\'rinadigan nomingizni o\'chirib tashladingiz (avval %1$s bo\'lgan edi)" + "%1$s ularning ko\'rsatiladigan nomini o\'rnating %2$s" + "Siz ko\'rsatiladigan nomingizni o\'rnating %1$s" + "%1$s admin lavozimiga koʻtarildi" + "%1$s moderatorlikka ko‘tarildi" + "%1$s xonani avatarini o\'zgartirdi" + "Siz xonani avatarini o\'zgartirdingiz" + "%1$s xonani avatarini o\'chirib tashladi" + "Siz xonani avatarini o\'chirib tashladingiz" + "%1$staqiqlangan%2$s" + "Siz taqiqlangansiz%1$s" + "Siz %1$s: %2$sni blokladingiz" + "%1$s %2$s:%3$sni blokladi" + "%1$sxonani yaratdi" + "Siz xonani yaratdingiz" + "%1$staklif qilingan%2$s" + "%1$staklifni qabul qildi" + "Siz taklifni qabul qildingiz" + "Siz taklif qildingiz%1$s" + "%1$ssizni taklif qildi" + "%1$sxonaga qo\'shildi" + "Siz xonaga qo\'shildingiz" + "%1$s qoʻshilishni soʻradi" + "%1$s %2$sga qo\'shilishga ruxsat berdi" + "Siz %1$sga qo\'shilishaga ruxsat berdingiz" + "Siz qoʻshilishni soʻragansiz" + "%1$s %2$sning qo\'shilish haqidagi iltimosini rad etdi" + "Siz %1$sning qo\'shiliz iltimosini rad etdingiz" + "%1$s sizni qo\'shilish iltimosingizni rad etdi" + "%1$s endi qo\'shilishdan manfaatdor emas" + "Siz qoʻshilish soʻrovingizni bekor qildingiz" + "%1$sxonani tark etdi" + "Siz xonani tark etdingiz" + "%1$s xonani nomini %2$s o\'zgartirdi" + "Siz xonani nomini %1$s ga o\'zgartirdingiz" + "%1$s xonani nomini o\'chirib tashladi" + "Siz xonani nomini o\'chirib tashladingiz" + "%1$shech qanday o'zgarishlar qilmadi" + "Hech qanday o‘zgartirish kiritilmadi" + "%1$s qadalgan xabarlarni tahrirladi" + "Qadalgan xabarlarni o‘zgartirdingiz" + "%1$s xabarni qadadi" + "Siz xabarni qadadingiz" + "%1$s xabarni uzdi" + "Siz xabarni uzdingiz" + "%1$staklifni rad etdi" + "Siz taklifni rad etdingiz" + "%1$s o\'chirildi %2$s" + "Siz o\'chirildingiz %1$s" + "Siz olib tashladingiz %1$s :%2$s" + "%1$s %2$s:%3$sni olib tashladi" + "%1$s taklifnoma yubordi %2$sga xonaga qo\'shilish uchun" + "Siz taklifnoma yubordingiz %1$s ga xonaga qo\'shilishi uchun" + "%1$s taklifni %2$s ga xonaga qo\'shilish uchun bekor qildi" + "Siz xonaga qo\'shilish taklifini %1$s ga bekor qildingiz" + "%1$s mavzuni %2$s o\'zgartirdi" + "Siz mavzuni %1$s ga o\'zgartirdingiz" + "%1$s xonani mavzusini o\'chirib tashladi" + "Siz xonani mavzusini o\'chirib tashladingiz" + "%1$staqiqlanmagan%2$s" + "Siz %1$s taqiqini bekor qildingiz" + "%1$s aʼzoligiga nomaʼlum oʻzgarishlar kiritdi" + diff --git a/libraries/eventformatter/impl/src/main/res/values-zh-rTW/translations.xml b/libraries/eventformatter/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..cd81377 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,73 @@ + + + "(大頭照也變更了)" + "%1$s 變更了他的大頭貼" + "您變更了自己的大頭貼" + "%1$s 已降級為成員" + "%1$s 已降級為版主" + "%1$s 將他的顯示名稱從 %2$s 變更為 %3$s" + "您將您的顯示名稱從 %1$s1 變更為 %2$s" + "%1$s 的顯示名稱已被本人移除(原為 %2$s)" + "您的顯示名稱已被您移除(原為 %1$s)" + "%1$s 將他的顯示名稱設為 %2$s" + "您將您的顯示名稱設為 %1$s" + "%1$s 已升級為管理員" + "%1$s 已升級為版主" + "%1$s 變更了聊天室大頭照" + "您變更了聊天室大頭照" + "%1$s 移除了聊天室大頭照" + "您移除了聊天室大頭照" + "%1$s 將 %2$s 加入黑名單" + "您將 %1$s 加入黑名單" + "您封鎖了 %1$s:%2$s" + "%1$s 封鎖了 %2$s:%3$s" + "%1$s 建立此聊天室" + "您建立此聊天室" + "%1$s 已邀請 %2$s" + "%1$s 接受了邀請" + "您接受了邀請" + "您已邀請 %1$s" + "%1$s 已邀請您" + "%1$s 加入聊天室" + "您加入聊天室" + "%1$s 請求加入" + "%1$s 授予 %2$s 存取權" + "您允許 %1$s 加入" + "您請求加入" + "%1$s 拒絕了 %2$s 的加入請求" + "您拒絕了 %1$s 的加入請求" + "%1$s 拒絕了您的加入請求" + "%1$s 不再有興趣加入" + "您取消了您的加入請求" + "%1$s 離開聊天室" + "您離開聊天室" + "%1$s 將聊天室名稱變更為 %2$s" + "您將聊天室名稱變更為 %1$s" + "聊天室名稱已被 %1$s 移除" + "聊天室名稱已被您移除" + "%1$s 並未做出任何變更" + "您並未做出任何變更" + "%1$s 變更了釘選訊息" + "您變更了釘選訊息" + "%1$s 釘選了訊息" + "您釘選了訊息" + "%1$s 取消釘選了訊息" + "您取消釘選了訊息" + "%1$s 拒絕了邀請" + "您拒絕了邀請" + "%2$s 已被 %1$s 移除" + "%1$s 已被您移除" + "您移除了 %1$s:%2$s" + "%1$s 移除了 %2$s:%3$s" + "%1$s 已邀請 %2$s 加入聊天室" + "您已邀請 %1$s 加入聊天室" + "%1$s 撤銷了對 %2$s 的聊天室邀請" + "您撤銷了對 %1$s 的聊天室邀請" + "%1$s 將主題變更為 %2$s" + "您將主題變更為 %1$s" + "聊天室主題已被 %1$s 移除" + "聊天室主題已被您移除" + "%1$s 將 %2$s 從黑名單中移除" + "您將 %1$s 從黑名單中移除" + "%1$s 對其會員資格做出了未知的變更" + diff --git a/libraries/eventformatter/impl/src/main/res/values-zh/translations.xml b/libraries/eventformatter/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..9776510 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,73 @@ + + + "(头像也更改了)" + "%1$s 更换了头像" + "你更换了头像" + "%1$s 降级为成员" + "%1$s 降级为协管员" + "%1$s 把显示名称从 %2$s 更改为 %3$s" + "你将显示名称从 %1$s 更改为 %2$s" + "%1$s 移除了其显示名称(原为 %2$s)" + "你移除了自己的显示名称(原为 %1$s)" + "%1$s 将其显示名称设置为 %2$s" + "你将显示名称设置为 %1$s" + "%1$s 晋升为管理员" + "%1$s 晋升为协管员" + "%1$s 更换了聊天室头像" + "你更换了聊天室头像" + "%1$s 移除了聊天室头像" + "你移除了聊天室头像" + "%1$s 封禁了 %2$s" + "你封禁了 %1$s" + "你封禁了%1$s:%2$s" + "%1$s封禁了%2$s:%3$s" + "%1$s 创建了聊天室" + "你创建了聊天室" + "%1$s 邀请了 %2$s" + "%1$s 接受了邀请" + "你接受了邀请" + "你邀请了 %1$s" + "%1$s 邀请了你" + "%1$s 加入了聊天室" + "你加入了聊天室" + "%1$s 请求加入" + "%1$s 允许 %2$s 加入" + "您已允许 %1$s 加入" + "你已请求加入" + "%1$s 拒绝了 %2$s 的加入请求" + "你拒绝了 %1$s 的加入请求" + "%1$s 拒绝了你的加入请求" + "%1$s 已不再想加入" + "你取消了加入申请" + "%1$s 离开了聊天室" + "你离开了聊天室" + "%1$s 将聊天室名称改为 %2$s" + "你把聊天室名称改为 %1$s" + "%1$s 移除了聊天室名称" + "你移除了聊天室名称" + "%1$s 没有任何更改" + "您未进行任何更改" + "%1$s 更改了置顶消息" + "您更改了置顶消息" + "%1$s 置顶了一条消息" + "您置顶了一条消息" + "%1$s 取消置顶了一条消息" + "您取消置顶了一条消息" + "%1$s 拒绝了邀请" + "你拒绝了邀请" + "%1$s 移除了 %2$s" + "你移除了 %1$s" + "您已删除%1$s :%2$s" + "%1$s已移除%2$s:%3$s" + "%1$s 向 %2$s 发送了加入聊天室的邀请" + "你邀请 %1$s 加入聊天室" + "%1$s 撤销了 %2$s 加入聊天室的邀请" + "你撤销了 %1$s 加入聊天室的邀请" + "%1$s 将主题改为:%2$s" + "你将主题改为:%1$s" + "%1$s 移除了聊天室主题" + "你移除了聊天室主题" + "%1$s 解禁了 %2$s" + "你解禁了 %1$s" + "%1$s 对其成员资格进行了未知更改" + 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 0000000..dfa8a3b --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values/localazy.xml @@ -0,0 +1,73 @@ + + + "(avatar was changed too)" + "%1$s changed their avatar" + "You changed your avatar" + "%1$s was demoted to member" + "%1$s was demoted to moderator" + "%1$s changed their display name from %2$s to %3$s" + "You changed your display name from %1$s to %2$s" + "%1$s removed their display name (it was %2$s)" + "You removed your display name (it was %1$s)" + "%1$s set their display name to %2$s" + "You set your display name to %1$s" + "%1$s was promoted to admin" + "%1$s was promoted to moderator" + "%1$s changed the room avatar" + "You changed the room avatar" + "%1$s removed the room avatar" + "You removed the room avatar" + "%1$s banned %2$s" + "You banned %1$s" + "You banned %1$s: %2$s" + "%1$s banned %2$s: %3$s" + "%1$s created the room" + "You created the room" + "%1$s invited %2$s" + "%1$s accepted the invite" + "You accepted the invite" + "You invited %1$s" + "%1$s invited you" + "%1$s joined the room" + "You joined the room" + "%1$s is requesting to join" + "%1$s granted access to %2$s" + "You allowed %1$s to join" + "You requested to join" + "%1$s rejected %2$s\'s request to join" + "You rejected %1$s\'s request to join" + "%1$s rejected your request to join" + "%1$s is no longer interested in joining" + "You cancelled your request to join" + "%1$s left the room" + "You left the room" + "%1$s changed the room name to: %2$s" + "You changed the room name to: %1$s" + "%1$s removed the room name" + "You removed the room name" + "%1$s made no changes" + "You made no changes" + "%1$s changed the pinned messages" + "You changed the pinned messages" + "%1$s pinned a message" + "You pinned a message" + "%1$s unpinned a message" + "You unpinned a message" + "%1$s rejected the invitation" + "You rejected the invitation" + "%1$s removed %2$s" + "You removed %1$s" + "You removed %1$s: %2$s" + "%1$s removed %2$s: %3$s" + "%1$s sent an invitation to %2$s to join the room" + "You sent an invitation to %1$s to join the room" + "%1$s revoked the invitation for %2$s to join the room" + "You revoked the invitation for %1$s to join the room" + "%1$s changed the topic to: %2$s" + "You changed the topic to: %1$s" + "%1$s removed the room topic" + "You removed the room topic" + "%1$s unbanned %2$s" + "You unbanned %1$s" + "%1$s made an unknown change to their membership" + diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt new file mode 100644 index 0000000..10c09ad --- /dev/null +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt @@ -0,0 +1,791 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.eventformatter.impl + +import android.content.Context +import androidx.compose.ui.text.AnnotatedString +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +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.OtherMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.OtherState +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +import io.element.android.libraries.matrix.api.timeline.item.event.StateContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType +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.VideoMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType +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.media.aMediaSource +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.test.timeline.aPollContent +import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent +import io.element.android.libraries.matrix.test.timeline.aProfileDetails +import io.element.android.libraries.matrix.test.timeline.aStickerContent +import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem +import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent +import io.element.android.libraries.ui.strings.CommonStrings +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 + +@Suppress("LargeClass") +@RunWith(RobolectricTestRunner::class) +class DefaultPinnedMessagesBannerFormatterTest { + private lateinit var context: Context + private lateinit var fakeMatrixClient: FakeMatrixClient + private lateinit var formatter: DefaultPinnedMessagesBannerFormatter + private lateinit var unsupportedEvent: String + + @Before + fun setup() { + context = RuntimeEnvironment.getApplication() as Context + fakeMatrixClient = FakeMatrixClient() + val stringProvider = AndroidStringProvider(context.resources) + formatter = DefaultPinnedMessagesBannerFormatter( + sp = stringProvider, + permalinkParser = FakePermalinkParser(), + ) + unsupportedEvent = stringProvider.getString(CommonStrings.common_unsupported_event) + } + + @Test + @Config(qualifiers = "en") + fun `Redacted content`() { + val expected = "Message removed" + val senderName = "Someone" + val message = createRoomEvent(false, senderName, RedactedContent) + val result = formatter.format(message) + assertThat(result).isEqualTo(expected) + } + + @Test + @Config(qualifiers = "en") + fun `Sticker content`() { + val body = "a sticker body" + val info = ImageInfo(null, null, null, null, null, null, null) + val message = createRoomEvent(false, null, aStickerContent(body, info, aMediaSource(url = "url"))) + val result = formatter.format(message) + val expectedBody = "Sticker: a sticker body" + assertThat(result.toString()).isEqualTo(expectedBody) + } + + @Test + @Config(qualifiers = "en") + fun `Unable to decrypt content`() { + val expected = "Waiting for this message" + val senderName = "Someone" + val message = createRoomEvent(false, senderName, UnableToDecryptContent(UnableToDecryptContent.Data.Unknown)) + val result = formatter.format(message) + assertThat(result).isEqualTo(expected) + } + + @Test + @Config(qualifiers = "en") + fun `FailedToParseMessageLike, FailedToParseState & Unknown content`() { + val senderName = "Someone" + sequenceOf( + FailedToParseMessageLikeContent("", ""), + FailedToParseStateContent("", "", ""), + UnknownContent, + ).forEach { type -> + val message = createRoomEvent(false, senderName, type) + val result = formatter.format(message) + assertWithMessage("$type was not properly handled").that(result).isEqualTo(unsupportedEvent) + } + } + + // region Message contents + + @Test + @Config(qualifiers = "en") + fun `Message contents`() { + val body = "Shared body" + fun createMessageContent(type: MessageType): MessageContent { + return MessageContent(body, null, false, null, type) + } + + val sharedContentMessagesTypes = arrayOf( + TextMessageType(body, null), + VideoMessageType(body, null, null, MediaSource("url"), null), + AudioMessageType(body, null, null, MediaSource("url"), null), + VoiceMessageType(body, null, null, MediaSource("url"), null, null), + ImageMessageType(body, null, null, MediaSource("url"), null), + StickerMessageType(body, null, null, MediaSource("url"), null), + FileMessageType(body, null, null, MediaSource("url"), null), + LocationMessageType(body, "geo:1,2", null), + NoticeMessageType(body, null), + EmoteMessageType(body, null), + OtherMessageType(msgType = "a_type", body = body), + ) + val results = mutableListOf>() + + sharedContentMessagesTypes.forEach { type -> + val content = createMessageContent(type) + val message = createRoomEvent(sentByYou = false, senderDisplayName = "Someone", content = content) + val result = formatter.format(message) + results.add(type to result) + } + + // Verify results type + for ((type, result) in results) { + val expectedResult = when (type) { + is VideoMessageType, + is AudioMessageType, + is ImageMessageType, + is StickerMessageType, + is FileMessageType, + is LocationMessageType -> AnnotatedString::class.java + is VoiceMessageType, + is EmoteMessageType, + is TextMessageType, + is NoticeMessageType, + is OtherMessageType -> String::class.java + } + assertThat(result).isInstanceOf(expectedResult) + } + // Verify results content + for ((type, result) in results) { + val expectedResult = when (type) { + is VideoMessageType -> "Video: Shared body" + is AudioMessageType -> "Audio: Shared body" + is VoiceMessageType -> "Voice message" + is ImageMessageType -> "Image: Shared body" + is StickerMessageType -> "Sticker: Shared body" + is FileMessageType -> "File: Shared body" + is LocationMessageType -> "Shared location: Shared body" + is EmoteMessageType -> "* Someone ${type.body}" + is TextMessageType, + is NoticeMessageType, + is OtherMessageType -> body + } + assertWithMessage("$type was not properly handled").that(result.toString()).isEqualTo(expectedResult) + } + } + + // endregion + + // region Membership change + + @Test + @Config(qualifiers = "en") + fun `Membership change - joined`() { + val otherName = "Other" + val youContent = aRoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.JOINED) + + val youJoinedRoomEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youJoinedRoom = formatter.format(youJoinedRoomEvent) + assertThat(youJoinedRoom).isEqualTo(unsupportedEvent) + + val someoneJoinedRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneJoinedRoom = formatter.format(someoneJoinedRoomEvent) + assertThat(someoneJoinedRoom).isEqualTo(unsupportedEvent) + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - left`() { + val otherName = "Other" + val youContent = aRoomMembershipContent(A_USER_ID, null, MembershipChange.LEFT) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.LEFT) + + val youLeftRoomEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youLeftRoom = formatter.format(youLeftRoomEvent) + assertThat(youLeftRoom).isEqualTo(unsupportedEvent) + + val someoneLeftRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneLeftRoom = formatter.format(someoneLeftRoomEvent) + assertThat(someoneLeftRoom).isEqualTo(unsupportedEvent) + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - banned`() { + val otherName = "Other" + val third = "Someone" + val youContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.BANNED) + val youKickedContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED_AND_BANNED) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.BANNED) + val someoneKickedContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED_AND_BANNED) + + val youBannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youBanned = formatter.format(youBannedEvent) + assertThat(youBanned).isEqualTo(unsupportedEvent) + + val youKickBannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youKickedContent) + val youKickedBanned = formatter.format(youKickBannedEvent) + assertThat(youKickedBanned).isEqualTo(unsupportedEvent) + + val someoneBannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneBanned = formatter.format(someoneBannedEvent) + assertThat(someoneBanned).isEqualTo(unsupportedEvent) + + val someoneKickBannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneKickedContent) + val someoneKickBanned = formatter.format(someoneKickBannedEvent) + assertThat(someoneKickBanned).isEqualTo(unsupportedEvent) + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - unban`() { + val otherName = "Other" + val third = "Someone" + val youContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.UNBANNED) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.UNBANNED) + + val youUnbannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youUnbanned = formatter.format(youUnbannedEvent) + assertThat(youUnbanned).isEqualTo(unsupportedEvent) + + val someoneUnbannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneUnbanned = formatter.format(someoneUnbannedEvent) + assertThat(someoneUnbanned).isEqualTo(unsupportedEvent) + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - kicked`() { + val otherName = "Other" + val third = "Someone" + val youContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED) + + val youKickedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youKicked = formatter.format(youKickedEvent) + assertThat(youKicked).isEqualTo(unsupportedEvent) + + val someoneKickedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneKicked = formatter.format(someoneKickedEvent) + assertThat(someoneKicked).isEqualTo(unsupportedEvent) + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - invited`() { + val otherName = "Other" + val third = "Someone" + val youContent = aRoomMembershipContent(A_USER_ID, null, MembershipChange.INVITED) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.INVITED) + + val youWereInvitedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = youContent) + val youWereInvited = formatter.format(youWereInvitedEvent) + assertThat(youWereInvited).isEqualTo(unsupportedEvent) + + val youInvitedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) + val youInvited = formatter.format(youInvitedEvent) + assertThat(youInvited).isEqualTo(unsupportedEvent) + + val someoneInvitedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneInvited = formatter.format(someoneInvitedEvent) + assertThat(someoneInvited).isEqualTo(unsupportedEvent) + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - invitation accepted`() { + val otherName = "Other" + val youContent = aRoomMembershipContent(A_USER_ID, null, MembershipChange.INVITATION_ACCEPTED) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.INVITATION_ACCEPTED) + + val youAcceptedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youAcceptedInvite = formatter.format(youAcceptedInviteEvent) + assertThat(youAcceptedInvite).isEqualTo(unsupportedEvent) + + val someoneAcceptedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneAcceptedInvite = formatter.format(someoneAcceptedInviteEvent) + assertThat(someoneAcceptedInvite).isEqualTo(unsupportedEvent) + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - invitation rejected`() { + val otherName = "Other" + val youContent = aRoomMembershipContent(A_USER_ID, null, MembershipChange.INVITATION_REJECTED) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.INVITATION_REJECTED) + + val youRejectedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youRejectedInvite = formatter.format(youRejectedInviteEvent) + assertThat(youRejectedInvite).isEqualTo(unsupportedEvent) + + val someoneRejectedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneRejectedInvite = formatter.format(someoneRejectedInviteEvent) + assertThat(someoneRejectedInvite).isEqualTo(unsupportedEvent) + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - invitation revoked`() { + val otherName = "Other" + val third = "Someone" + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.INVITATION_REVOKED) + + val youRevokedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) + val youRevokedInvite = formatter.format(youRevokedInviteEvent) + assertThat(youRevokedInvite).isEqualTo(unsupportedEvent) + + val someoneRevokedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneRevokedInvite = formatter.format(someoneRevokedInviteEvent) + assertThat(someoneRevokedInvite).isEqualTo(unsupportedEvent) + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - knocked`() { + val otherName = "Other" + val youContent = aRoomMembershipContent(A_USER_ID, null, MembershipChange.KNOCKED) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.KNOCKED) + + val youKnockedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youKnocked = formatter.format(youKnockedEvent) + assertThat(youKnocked).isEqualTo(unsupportedEvent) + + val someoneKnockedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneKnocked = formatter.format(someoneKnockedEvent) + assertThat(someoneKnocked).isEqualTo(unsupportedEvent) + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - knock accepted`() { + val otherName = "Other" + val third = "Someone" + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KNOCK_ACCEPTED) + + val youAcceptedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) + val youAcceptedKnock = formatter.format(youAcceptedKnockEvent) + assertThat(youAcceptedKnock).isEqualTo(unsupportedEvent) + + val someoneAcceptedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneAcceptedKnock = formatter.format(someoneAcceptedKnockEvent) + assertThat(someoneAcceptedKnock).isEqualTo(unsupportedEvent) + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - knock retracted`() { + val otherName = "Other" + val youContent = aRoomMembershipContent(A_USER_ID, null, MembershipChange.KNOCK_RETRACTED) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), null, MembershipChange.KNOCK_RETRACTED) + + val youRetractedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youRetractedKnock = formatter.format(youRetractedKnockEvent) + assertThat(youRetractedKnock).isEqualTo(unsupportedEvent) + + val someoneRetractedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneRetractedKnock = formatter.format(someoneRetractedKnockEvent) + assertThat(someoneRetractedKnock).isEqualTo(unsupportedEvent) + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - knock denied`() { + val otherName = "Other" + val third = "Someone" + val youContent = aRoomMembershipContent(A_USER_ID, third, MembershipChange.KNOCK_DENIED) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KNOCK_DENIED) + + val youDeniedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) + val youDeniedKnock = formatter.format(youDeniedKnockEvent) + assertThat(youDeniedKnock).isEqualTo(unsupportedEvent) + + val someoneDeniedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneDeniedKnock = formatter.format(someoneDeniedKnockEvent) + assertThat(someoneDeniedKnock).isEqualTo(unsupportedEvent) + + val someoneDeniedYourKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = youContent) + val someoneDeniedYourKnock = formatter.format(someoneDeniedYourKnockEvent) + assertThat(someoneDeniedYourKnock).isEqualTo(unsupportedEvent) + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - None`() { + val otherName = "Other" + val youContent = aRoomMembershipContent(A_USER_ID, null, MembershipChange.NONE) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.NONE) + + val youNoneRoomEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youNoneRoom = formatter.format(youNoneRoomEvent) + assertThat(youNoneRoom).isEqualTo(unsupportedEvent) + + val someoneNoneRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneNoneRoom = formatter.format(someoneNoneRoomEvent) + assertThat(someoneNoneRoom).isEqualTo(unsupportedEvent) + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - others`() { + val otherChanges = arrayOf(MembershipChange.ERROR, MembershipChange.NOT_IMPLEMENTED, null) + + val results = otherChanges.map { change -> + val content = aRoomMembershipContent(A_USER_ID, null, change) + val event = createRoomEvent(sentByYou = false, senderDisplayName = "Someone", content = content) + val result = formatter.format(event) + change to result + } + val expected = otherChanges.map { it to unsupportedEvent } + assertThat(results).isEqualTo(expected) + } + + // endregion + + // region Room State + + @Test + @Config(qualifiers = "en") + fun `Room state change - avatar`() { + val otherName = "Other" + 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) + assertThat(youChangedRoomAvatar).isEqualTo(unsupportedEvent) + + val someoneChangedRoomAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneChangedRoomAvatar = formatter.format(someoneChangedRoomAvatarEvent) + assertThat(someoneChangedRoomAvatar).isEqualTo(unsupportedEvent) + + val youRemovedRoomAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youRemovedRoomAvatar = formatter.format(youRemovedRoomAvatarEvent) + assertThat(youRemovedRoomAvatar).isEqualTo(unsupportedEvent) + + val someoneRemovedRoomAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneRemovedRoomAvatar = formatter.format(someoneRemovedRoomAvatarEvent) + assertThat(someoneRemovedRoomAvatar).isEqualTo(unsupportedEvent) + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - create`() { + val otherName = "Other" + val content = StateContent("", OtherState.RoomCreate) + + val youCreatedRoomMessage = createRoomEvent(sentByYou = true, senderDisplayName = null, content = content) + val youCreatedRoom = formatter.format(youCreatedRoomMessage) + assertThat(youCreatedRoom).isEqualTo(unsupportedEvent) + + val someoneCreatedRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = content) + val someoneCreatedRoom = formatter.format(someoneCreatedRoomEvent) + assertThat(someoneCreatedRoom).isEqualTo(unsupportedEvent) + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - encryption`() { + val otherName = "Other" + val content = StateContent("", OtherState.RoomEncryption) + + val youCreatedRoomMessage = createRoomEvent(sentByYou = true, senderDisplayName = null, content = content) + val youCreatedRoom = formatter.format(youCreatedRoomMessage) + assertThat(youCreatedRoom).isEqualTo(unsupportedEvent) + + val someoneCreatedRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = content) + val someoneCreatedRoom = formatter.format(someoneCreatedRoomEvent) + assertThat(someoneCreatedRoom).isEqualTo(unsupportedEvent) + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - room name`() { + val otherName = "Other" + 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) + assertThat(youChangedRoomName).isEqualTo(unsupportedEvent) + + val someoneChangedRoomNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneChangedRoomName = formatter.format(someoneChangedRoomNameEvent) + assertThat(someoneChangedRoomName).isEqualTo(unsupportedEvent) + + val youRemovedRoomNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youRemovedRoomName = formatter.format(youRemovedRoomNameEvent) + assertThat(youRemovedRoomName).isEqualTo(unsupportedEvent) + + val someoneRemovedRoomNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneRemovedRoomName = formatter.format(someoneRemovedRoomNameEvent) + assertThat(someoneRemovedRoomName).isEqualTo(unsupportedEvent) + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - third party invite`() { + val otherName = "Other" + 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) + assertThat(youInvitedSomeone).isEqualTo(unsupportedEvent) + + val someoneInvitedSomeoneEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneInvitedSomeone = formatter.format(someoneInvitedSomeoneEvent) + assertThat(someoneInvitedSomeone).isEqualTo(unsupportedEvent) + + val youInvitedNoOneEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youInvitedNoOne = formatter.format(youInvitedNoOneEvent) + assertThat(youInvitedNoOne).isEqualTo(unsupportedEvent) + + val someoneInvitedNoOneEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneInvitedNoOne = formatter.format(someoneInvitedNoOneEvent) + assertThat(someoneInvitedNoOne).isEqualTo(unsupportedEvent) + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - room topic`() { + val otherName = "Other" + 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) + assertThat(youChangedRoomTopic).isEqualTo(unsupportedEvent) + + val someoneChangedRoomTopicEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneChangedRoomTopic = formatter.format(someoneChangedRoomTopicEvent) + assertThat(someoneChangedRoomTopic).isEqualTo(unsupportedEvent) + + val youRemovedRoomTopicEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youRemovedRoomTopic = formatter.format(youRemovedRoomTopicEvent) + assertThat(youRemovedRoomTopic).isEqualTo(unsupportedEvent) + + val someoneRemovedRoomTopicEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneRemovedRoomTopic = formatter.format(someoneRemovedRoomTopicEvent) + assertThat(someoneRemovedRoomTopic).isEqualTo(unsupportedEvent) + } + + @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(null), + OtherState.RoomPinnedEvents(OtherState.RoomPinnedEvents.Change.CHANGED), + OtherState.RoomUserPowerLevels(emptyMap()), + 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) + state to result + } + val expected = otherStates.map { it to unsupportedEvent } + assertThat(results).isEqualTo(expected) + } + + // endregion + + // region Profile change + + @Test + @Config(qualifiers = "en") + fun `Profile change - avatar`() { + val otherName = "Other" + 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) + assertThat(youChangedAvatar).isEqualTo(unsupportedEvent) + + val someoneChangeAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneChangeAvatar = formatter.format(someoneChangeAvatarEvent) + assertThat(someoneChangeAvatar).isEqualTo(unsupportedEvent) + + val youSetAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = setContent) + val youSetAvatar = formatter.format(youSetAvatarEvent) + assertThat(youSetAvatar).isEqualTo(unsupportedEvent) + + val someoneSetAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = setContent) + val someoneSetAvatar = formatter.format(someoneSetAvatarEvent) + assertThat(someoneSetAvatar).isEqualTo(unsupportedEvent) + + val youRemovedAvatarEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youRemovedAvatar = formatter.format(youRemovedAvatarEvent) + assertThat(youRemovedAvatar).isEqualTo(unsupportedEvent) + + val someoneRemovedAvatarEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneRemovedAvatar = formatter.format(someoneRemovedAvatarEvent) + assertThat(someoneRemovedAvatar).isEqualTo(unsupportedEvent) + + val unchangedEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = sameContent) + val unchangedResult = formatter.format(unchangedEvent) + assertThat(unchangedResult).isEqualTo(unsupportedEvent) + + val invalidEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = invalidContent) + val invalidResult = formatter.format(invalidEvent) + assertThat(invalidResult).isEqualTo(unsupportedEvent) + } + + @Test + @Config(qualifiers = "en") + fun `Profile change - display name`() { + val newDisplayName = "New" + val oldDisplayName = "Old" + val otherName = "Other" + 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) + assertThat(youChangedDisplayName).isEqualTo(unsupportedEvent) + + val someoneChangedDisplayNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneChangedDisplayName = formatter.format(someoneChangedDisplayNameEvent) + assertThat(someoneChangedDisplayName).isEqualTo(unsupportedEvent) + + val youSetDisplayNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = setContent) + val youSetDisplayName = formatter.format(youSetDisplayNameEvent) + assertThat(youSetDisplayName).isEqualTo(unsupportedEvent) + + val someoneSetDisplayNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = setContent) + val someoneSetDisplayName = formatter.format(someoneSetDisplayNameEvent) + assertThat(someoneSetDisplayName).isEqualTo(unsupportedEvent) + + val youRemovedDisplayNameEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youRemovedDisplayName = formatter.format(youRemovedDisplayNameEvent) + assertThat(youRemovedDisplayName).isEqualTo(unsupportedEvent) + + val someoneRemovedDisplayNameEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneRemovedDisplayName = formatter.format(someoneRemovedDisplayNameEvent) + assertThat(someoneRemovedDisplayName).isEqualTo(unsupportedEvent) + + val unchangedEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = sameContent) + val unchangedResult = formatter.format(unchangedEvent) + assertThat(unchangedResult).isEqualTo(unsupportedEvent) + + val invalidEvent = createRoomEvent(sentByYou = true, senderDisplayName = otherName, content = invalidContent) + val invalidResult = formatter.format(invalidEvent) + assertThat(invalidResult).isEqualTo(unsupportedEvent) + } + + @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) + assertThat(youChangedBoth).isEqualTo(unsupportedEvent) + + val invalidContentEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = invalidContent) + val invalidMessage = formatter.format(invalidContentEvent) + assertThat(invalidMessage).isEqualTo(unsupportedEvent) + + val sameContentEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = sameContent) + val sameMessage = formatter.format(sameContentEvent) + assertThat(sameMessage).isEqualTo(unsupportedEvent) + } + + // endregion + + // region Polls + + @Test + @Config(qualifiers = "en") + fun `Computes last message for poll`() { + val pollContent = aPollContent() + + val mineContentEvent = createRoomEvent(sentByYou = true, senderDisplayName = "Alice", content = pollContent) + val result = formatter.format(mineContentEvent) + assertThat(result).isInstanceOf(AnnotatedString::class.java) + assertThat(result.toString()).isEqualTo("Poll: Do you like polls?") + + val contentEvent = createRoomEvent(sentByYou = false, senderDisplayName = "Bob", content = pollContent) + val result2 = formatter.format(contentEvent) + assertThat(result2).isInstanceOf(AnnotatedString::class.java) + assertThat(result2.toString()).isEqualTo("Poll: Do you like polls?") + } + + // endregion + + private fun createRoomEvent( + sentByYou: Boolean, + senderDisplayName: String?, + content: EventContent, + ): EventTimelineItem { + val sender = if (sentByYou) A_USER_ID else someoneElseId + val profile = aProfileDetails(senderDisplayName) + return anEventTimelineItem( + content = content, + senderProfile = profile, + sender = sender, + isOwn = sentByYou, + ) + } + + private val someoneElseId = UserId("@someone_else:domain") +} diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt new file mode 100644 index 0000000..c06b79e --- /dev/null +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt @@ -0,0 +1,944 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.eventformatter.impl + +import android.content.Context +import androidx.compose.ui.text.AnnotatedString +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +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.roomlist.LatestEventValue +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.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.OtherMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.OtherState +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +import io.element.android.libraries.matrix.api.timeline.item.event.StateContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType +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.VideoMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType +import io.element.android.libraries.matrix.test.A_REASON +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.media.aMediaSource +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.test.room.aRemoteLatestEvent +import io.element.android.libraries.matrix.test.timeline.aPollContent +import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent +import io.element.android.libraries.matrix.test.timeline.aProfileDetails +import io.element.android.libraries.matrix.test.timeline.aStickerContent +import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent +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 + +@Suppress("LargeClass") +@RunWith(RobolectricTestRunner::class) +class DefaultRoomLatestEventFormatterTest { + private lateinit var context: Context + private lateinit var fakeMatrixClient: FakeMatrixClient + private lateinit var formatter: DefaultRoomLatestEventFormatter + + @Before + fun setup() { + context = RuntimeEnvironment.getApplication() as Context + fakeMatrixClient = FakeMatrixClient() + val stringProvider = AndroidStringProvider(context.resources) + formatter = DefaultRoomLatestEventFormatter( + sp = AndroidStringProvider(context.resources), + roomMembershipContentFormatter = RoomMembershipContentFormatter(fakeMatrixClient, stringProvider), + profileChangeContentFormatter = ProfileChangeContentFormatter(stringProvider), + stateContentFormatter = StateContentFormatter(stringProvider), + permalinkParser = FakePermalinkParser(), + ) + } + + @Test + @Config(qualifiers = "en") + fun `Redacted content`() { + val expected = "Message removed" + val senderName = "Someone" + sequenceOf(false, true).forEach { isDm -> + val message = createLatestEvent(false, senderName, RedactedContent) + val result = formatter.format(message, isDm) + if (isDm) { + assertThat(result).isEqualTo(expected) + } else { + assertThat(result).isInstanceOf(AnnotatedString::class.java) + assertThat(result.toString()).isEqualTo("$senderName: $expected") + } + } + } + + @Test + @Config(qualifiers = "en") + fun `Sticker content`() { + val body = "a sticker body" + val info = ImageInfo(null, null, null, null, null, null, null) + val message = createLatestEvent(false, null, aStickerContent(body, info, aMediaSource(url = "url"))) + val result = formatter.format(message, false) + val expectedBody = someoneElseId.value + ": Sticker (a sticker body)" + assertThat(result.toString()).isEqualTo(expectedBody) + } + + @Test + @Config(qualifiers = "en") + fun `Unable to decrypt content`() { + val expected = "Waiting for this message" + val senderName = "Someone" + sequenceOf(false, true).forEach { isDm -> + val message = createLatestEvent(false, senderName, UnableToDecryptContent(UnableToDecryptContent.Data.Unknown)) + val result = formatter.format(message, isDm) + if (isDm) { + assertThat(result).isEqualTo(expected) + } else { + assertThat(result).isInstanceOf(AnnotatedString::class.java) + 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 = createLatestEvent(false, senderName, type) + val result = formatter.format(message, isDm) + if (isDm) { + assertWithMessage("$type was not properly handled").that(result).isEqualTo(expected) + } else { + assertWithMessage("$type does not create an AnnotatedString").that(result).isInstanceOf(AnnotatedString::class.java) + assertWithMessage("$type was not properly handled").that(result.toString()).isEqualTo("$senderName: $expected") + } + } + } + } + + // region Message contents + + @Test + @Config(qualifiers = "en") + fun `Message contents sent by other user`() { + testMessageContents( + sentByYou = false, + senderName = "Alice", + expectedPrefix = "Alice", + ) + } + + @Test + @Config(qualifiers = "en") + fun `Message contents sent by current user`() { + testMessageContents( + sentByYou = true, + senderName = "Bob", + expectedPrefix = "You", + ) + } + + private fun testMessageContents( + sentByYou: Boolean, + senderName: String, + expectedPrefix: String, + ) { + val body = "Shared body" + fun createMessageContent(type: MessageType): MessageContent { + return MessageContent(body, null, false, null, type) + } + + val sharedContentMessagesTypes = arrayOf( + TextMessageType(body, null), + VideoMessageType(body, null, null, MediaSource("url"), null), + AudioMessageType(body, null, null, MediaSource("url"), null), + VoiceMessageType(body, null, null, MediaSource("url"), null, null), + ImageMessageType(body, null, null, MediaSource("url"), null), + StickerMessageType(body, null, null, MediaSource("url"), null), + FileMessageType(body, null, null, MediaSource("url"), null), + LocationMessageType(body, "geo:1,2", null), + NoticeMessageType(body, null), + EmoteMessageType(body, null), + OtherMessageType(msgType = "a_type", body = body), + ) + val resultsInRoom = mutableListOf>() + val resultsInDm = mutableListOf>() + + // Create messages for all types in DM and Room mode + sequenceOf(false, true).forEach { isDm -> + sharedContentMessagesTypes.forEach { type -> + val content = createMessageContent(type) + val message = createLatestEvent(sentByYou = sentByYou, senderDisplayName = senderName, content = content) + val result = formatter.format(message, isDmRoom = isDm) + if (isDm) { + resultsInDm.add(type to result) + } else { + resultsInRoom.add(type to result) + } + } + } + + // Verify results of DM mode + for ((type, result) in resultsInDm) { + val string = result.toString() + val expectedResult = when (type) { + is VideoMessageType -> "Video: Shared body" + is AudioMessageType -> "Audio: Shared body" + is VoiceMessageType -> "Voice message" + is ImageMessageType -> "Image: Shared body" + is StickerMessageType -> "Sticker: Shared body" + is FileMessageType -> "File: Shared body" + is LocationMessageType -> "Shared location" + is EmoteMessageType -> "* $senderName ${type.body}" + is TextMessageType, + is NoticeMessageType, + is OtherMessageType -> body + } + val shouldCreateAnnotatedString = when (type) { + is VideoMessageType -> true + is AudioMessageType -> true + is VoiceMessageType -> false + is ImageMessageType -> true + is StickerMessageType -> true + is FileMessageType -> true + is LocationMessageType -> false + is EmoteMessageType -> false + is TextMessageType -> false + is NoticeMessageType -> false + is OtherMessageType -> false + } + if (shouldCreateAnnotatedString) { + assertWithMessage("$type doesn't produce an AnnotatedString") + .that(result) + .isInstanceOf(AnnotatedString::class.java) + } + assertWithMessage("$type was not properly handled for DM").that(string).isEqualTo(expectedResult) + } + + // Verify results of Room mode + for ((type, result) in resultsInRoom) { + val string = result.toString() + val expectedResult = when (type) { + is VideoMessageType -> "$expectedPrefix: Video: Shared body" + is AudioMessageType -> "$expectedPrefix: Audio: Shared body" + is VoiceMessageType -> "$expectedPrefix: Voice message" + is ImageMessageType -> "$expectedPrefix: Image: Shared body" + is StickerMessageType -> "$expectedPrefix: Sticker: Shared body" + is FileMessageType -> "$expectedPrefix: File: Shared body" + is LocationMessageType -> "$expectedPrefix: Shared location" + is TextMessageType, + is NoticeMessageType, + is OtherMessageType -> "$expectedPrefix: $body" + is EmoteMessageType -> "* $senderName ${type.body}" + } + val shouldCreateAnnotatedString = when (type) { + is VideoMessageType -> true + is AudioMessageType -> true + is VoiceMessageType -> true + is ImageMessageType -> true + is StickerMessageType -> true + is FileMessageType -> true + is LocationMessageType -> false + is EmoteMessageType -> false + is TextMessageType -> true + is NoticeMessageType -> true + is OtherMessageType -> true + } + if (shouldCreateAnnotatedString) { + assertWithMessage("$type doesn't produce an AnnotatedString") + .that(result) + .isInstanceOf(AnnotatedString::class.java) + } + assertWithMessage("$type was not properly handled for room").that(string).isEqualTo(expectedResult) + } + } + + // endregion + + // region Membership change + + @Test + @Config(qualifiers = "en") + fun `Membership change - joined`() { + val otherName = "Other" + val youContent = aRoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.JOINED) + + val youJoinedRoomEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youJoinedRoom = formatter.format(youJoinedRoomEvent, false) + assertThat(youJoinedRoom).isEqualTo("You joined the room") + + val someoneJoinedRoomEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneJoinedRoom = formatter.format(someoneJoinedRoomEvent, false) + assertThat(someoneJoinedRoom).isEqualTo("$otherName joined the room") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - left`() { + val otherName = "Other" + val youContent = aRoomMembershipContent(A_USER_ID, null, MembershipChange.LEFT) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.LEFT) + + val youLeftRoomEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youLeftRoom = formatter.format(youLeftRoomEvent, false) + assertThat(youLeftRoom).isEqualTo("You left the room") + + val someoneLeftRoomEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneLeftRoom = formatter.format(someoneLeftRoomEvent, false) + assertThat(someoneLeftRoom).isEqualTo("$otherName left the room") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - banned`() { + val otherName = "Other" + val third = "Someone" + val youContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.BANNED) + val youKickedContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED_AND_BANNED) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.BANNED) + val someoneKickedContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED_AND_BANNED) + + val youBannedEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youBanned = formatter.format(youBannedEvent, false) + assertThat(youBanned).isEqualTo("You banned $third") + + val youKickBannedEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = youKickedContent) + val youKickedBanned = formatter.format(youKickBannedEvent, false) + assertThat(youKickedBanned).isEqualTo("You banned $third") + + val someoneBannedEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneBanned = formatter.format(someoneBannedEvent, false) + assertThat(someoneBanned).isEqualTo("$otherName banned $third") + + val someoneKickBannedEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = someoneKickedContent) + val someoneKickBanned = formatter.format(someoneKickBannedEvent, false) + assertThat(someoneKickBanned).isEqualTo("$otherName banned $third") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - banned with reason`() { + val otherName = "Other" + val third = "Someone" + val youContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.BANNED, A_REASON) + val youKickedContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED_AND_BANNED, A_REASON) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.BANNED, A_REASON) + val someoneKickedContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED_AND_BANNED, A_REASON) + + val youBannedEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youBanned = formatter.format(youBannedEvent, false) + assertThat(youBanned).isEqualTo("You banned $third: $A_REASON") + + val youKickBannedEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = youKickedContent) + val youKickedBanned = formatter.format(youKickBannedEvent, false) + assertThat(youKickedBanned).isEqualTo("You banned $third: $A_REASON") + + val someoneBannedEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneBanned = formatter.format(someoneBannedEvent, false) + assertThat(someoneBanned).isEqualTo("$otherName banned $third: $A_REASON") + + val someoneKickBannedEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = someoneKickedContent) + val someoneKickBanned = formatter.format(someoneKickBannedEvent, false) + assertThat(someoneKickBanned).isEqualTo("$otherName banned $third: $A_REASON") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - unban`() { + val otherName = "Other" + val third = "Someone" + val youContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.UNBANNED) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.UNBANNED) + + val youUnbannedEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youUnbanned = formatter.format(youUnbannedEvent, false) + assertThat(youUnbanned).isEqualTo("You unbanned $third") + + val someoneUnbannedEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneUnbanned = formatter.format(someoneUnbannedEvent, false) + assertThat(someoneUnbanned).isEqualTo("$otherName unbanned $third") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - kicked`() { + val otherName = "Other" + val third = "Someone" + val youContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED) + + val youKickedEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youKicked = formatter.format(youKickedEvent, false) + assertThat(youKicked).isEqualTo("You removed $third") + + val someoneKickedEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneKicked = formatter.format(someoneKickedEvent, false) + assertThat(someoneKicked).isEqualTo("$otherName removed $third") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - kicked with reason`() { + val otherName = "Other" + val third = "Someone" + val youContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED, A_REASON) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED, A_REASON) + + val youKickedEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youKicked = formatter.format(youKickedEvent, false) + assertThat(youKicked).isEqualTo("You removed $third: $A_REASON") + + val someoneKickedEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneKicked = formatter.format(someoneKickedEvent, false) + assertThat(someoneKicked).isEqualTo("$otherName removed $third: $A_REASON") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - invited`() { + val otherName = "Other" + val third = "Someone" + val youContent = aRoomMembershipContent(A_USER_ID, null, MembershipChange.INVITED) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.INVITED) + + val youWereInvitedEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = youContent) + val youWereInvited = formatter.format(youWereInvitedEvent, false) + assertThat(youWereInvited).isEqualTo("$otherName invited you") + + val youInvitedEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) + val youInvited = formatter.format(youInvitedEvent, false) + assertThat(youInvited).isEqualTo("You invited $third") + + val someoneInvitedEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneInvited = formatter.format(someoneInvitedEvent, false) + assertThat(someoneInvited).isEqualTo("$otherName invited $third") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - invitation accepted`() { + val otherName = "Other" + val youContent = aRoomMembershipContent(A_USER_ID, null, MembershipChange.INVITATION_ACCEPTED) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.INVITATION_ACCEPTED) + + val youAcceptedInviteEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youAcceptedInvite = formatter.format(youAcceptedInviteEvent, false) + assertThat(youAcceptedInvite).isEqualTo("You accepted the invite") + + val someoneAcceptedInviteEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneAcceptedInvite = formatter.format(someoneAcceptedInviteEvent, false) + assertThat(someoneAcceptedInvite).isEqualTo("$otherName accepted the invite") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - invitation rejected`() { + val otherName = "Other" + val youContent = aRoomMembershipContent(A_USER_ID, null, MembershipChange.INVITATION_REJECTED) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.INVITATION_REJECTED) + + val youRejectedInviteEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youRejectedInvite = formatter.format(youRejectedInviteEvent, false) + assertThat(youRejectedInvite).isEqualTo("You rejected the invitation") + + val someoneRejectedInviteEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneRejectedInvite = formatter.format(someoneRejectedInviteEvent, false) + assertThat(someoneRejectedInvite).isEqualTo("$otherName rejected the invitation") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - invitation revoked`() { + val otherName = "Other" + val third = "Someone" + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.INVITATION_REVOKED) + + val youRevokedInviteEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) + val youRevokedInvite = formatter.format(youRevokedInviteEvent, false) + assertThat(youRevokedInvite).isEqualTo("You revoked the invitation for $third to join the room") + + val someoneRevokedInviteEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneRevokedInvite = formatter.format(someoneRevokedInviteEvent, false) + assertThat(someoneRevokedInvite).isEqualTo("$otherName revoked the invitation for $third to join the room") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - knocked`() { + val otherName = "Other" + val youContent = aRoomMembershipContent(A_USER_ID, null, MembershipChange.KNOCKED) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.KNOCKED) + + val youKnockedEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youKnocked = formatter.format(youKnockedEvent, false) + assertThat(youKnocked).isEqualTo("You requested to join") + + val someoneKnockedEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneKnocked = formatter.format(someoneKnockedEvent, false) + assertThat(someoneKnocked).isEqualTo("$otherName is requesting to join") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - knock accepted`() { + val otherName = "Other" + val third = "Someone" + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KNOCK_ACCEPTED) + + val youAcceptedKnockEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) + val youAcceptedKnock = formatter.format(youAcceptedKnockEvent, false) + assertThat(youAcceptedKnock).isEqualTo("You allowed $third to join") + + val someoneAcceptedKnockEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneAcceptedKnock = formatter.format(someoneAcceptedKnockEvent, false) + assertThat(someoneAcceptedKnock).isEqualTo("$otherName granted access to $third") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - knock retracted`() { + val otherName = "Other" + val youContent = aRoomMembershipContent(A_USER_ID, null, MembershipChange.KNOCK_RETRACTED) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), null, MembershipChange.KNOCK_RETRACTED) + + val youRetractedKnockEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youRetractedKnock = formatter.format(youRetractedKnockEvent, false) + assertThat(youRetractedKnock).isEqualTo("You cancelled your request to join") + + val someoneRetractedKnockEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneRetractedKnock = formatter.format(someoneRetractedKnockEvent, false) + assertThat(someoneRetractedKnock).isEqualTo("$otherName is no longer interested in joining") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - knock denied`() { + val otherName = "Other" + val third = "Someone" + val youContent = aRoomMembershipContent(A_USER_ID, third, MembershipChange.KNOCK_DENIED) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KNOCK_DENIED) + + val youDeniedKnockEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) + val youDeniedKnock = formatter.format(youDeniedKnockEvent, false) + assertThat(youDeniedKnock).isEqualTo("You rejected $third's request to join") + + val someoneDeniedKnockEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneDeniedKnock = formatter.format(someoneDeniedKnockEvent, false) + assertThat(someoneDeniedKnock).isEqualTo("$otherName rejected $third's request to join") + + val someoneDeniedYourKnockEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = youContent) + val someoneDeniedYourKnock = formatter.format(someoneDeniedYourKnockEvent, false) + assertThat(someoneDeniedYourKnock).isEqualTo("$otherName rejected your request to join") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - None`() { + val otherName = "Other" + val youContent = aRoomMembershipContent(A_USER_ID, null, MembershipChange.NONE) + val someoneContent = aRoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.NONE) + + val youNoneRoomEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = youContent) + val youNoneRoom = formatter.format(youNoneRoomEvent, false) + assertThat(youNoneRoom).isEqualTo("You made no changes") + + val someoneNoneRoomEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) + val someoneNoneRoom = formatter.format(someoneNoneRoomEvent, false) + assertThat(someoneNoneRoom).isEqualTo("$otherName made no changes") + } + + @Test + @Config(qualifiers = "en") + fun `Membership change - others`() { + val otherChanges = arrayOf(MembershipChange.ERROR, MembershipChange.NOT_IMPLEMENTED, null) + + val results = otherChanges.map { change -> + val content = aRoomMembershipContent(A_USER_ID, null, change) + val event = createLatestEvent(sentByYou = false, senderDisplayName = "Someone", content = content) + val result = formatter.format(event, false) + change to result + } + val expected = otherChanges.map { it to null } + assertThat(results).isEqualTo(expected) + } + + // endregion + + // region Room State + + @Test + @Config(qualifiers = "en") + fun `Room state change - avatar`() { + val otherName = "Other" + val changedContent = StateContent("", OtherState.RoomAvatar("new_avatar")) + val removedContent = StateContent("", OtherState.RoomAvatar(null)) + + val youChangedRoomAvatarEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youChangedRoomAvatar = formatter.format(youChangedRoomAvatarEvent, false) + assertThat(youChangedRoomAvatar).isEqualTo("You changed the room avatar") + + val someoneChangedRoomAvatarEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneChangedRoomAvatar = formatter.format(someoneChangedRoomAvatarEvent, false) + assertThat(someoneChangedRoomAvatar).isEqualTo("$otherName changed the room avatar") + + val youRemovedRoomAvatarEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youRemovedRoomAvatar = formatter.format(youRemovedRoomAvatarEvent, false) + assertThat(youRemovedRoomAvatar).isEqualTo("You removed the room avatar") + + val someoneRemovedRoomAvatarEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneRemovedRoomAvatar = formatter.format(someoneRemovedRoomAvatarEvent, false) + assertThat(someoneRemovedRoomAvatar).isEqualTo("$otherName removed the room avatar") + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - create`() { + val otherName = "Other" + val content = StateContent("", OtherState.RoomCreate) + + val youCreatedRoomMessage = createLatestEvent(sentByYou = true, senderDisplayName = null, content = content) + val youCreatedRoom = formatter.format(youCreatedRoomMessage, false) + assertThat(youCreatedRoom).isEqualTo("You created the room") + + val someoneCreatedRoomEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = content) + val someoneCreatedRoom = formatter.format(someoneCreatedRoomEvent, false) + assertThat(someoneCreatedRoom).isEqualTo("$otherName created the room") + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - encryption`() { + val otherName = "Other" + val content = StateContent("", OtherState.RoomEncryption) + + val youCreatedRoomMessage = createLatestEvent(sentByYou = true, senderDisplayName = null, content = content) + val youCreatedRoom = formatter.format(youCreatedRoomMessage, false) + assertThat(youCreatedRoom).isEqualTo("Encryption enabled") + + val someoneCreatedRoomEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = content) + val someoneCreatedRoom = formatter.format(someoneCreatedRoomEvent, false) + assertThat(someoneCreatedRoom).isEqualTo("Encryption enabled") + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - room name`() { + val otherName = "Other" + val newName = "New name" + val changedContent = StateContent("", OtherState.RoomName(newName)) + val removedContent = StateContent("", OtherState.RoomName(null)) + + val youChangedRoomNameEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youChangedRoomName = formatter.format(youChangedRoomNameEvent, false) + assertThat(youChangedRoomName).isEqualTo("You changed the room name to: $newName") + + val someoneChangedRoomNameEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneChangedRoomName = formatter.format(someoneChangedRoomNameEvent, false) + assertThat(someoneChangedRoomName).isEqualTo("$otherName changed the room name to: $newName") + + val youRemovedRoomNameEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youRemovedRoomName = formatter.format(youRemovedRoomNameEvent, false) + assertThat(youRemovedRoomName).isEqualTo("You removed the room name") + + val someoneRemovedRoomNameEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneRemovedRoomName = formatter.format(someoneRemovedRoomNameEvent, false) + assertThat(someoneRemovedRoomName).isEqualTo("$otherName removed the room name") + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - third party invite`() { + val otherName = "Other" + val inviteeName = "Alice" + val changedContent = StateContent("", OtherState.RoomThirdPartyInvite(inviteeName)) + val removedContent = StateContent("", OtherState.RoomThirdPartyInvite(null)) + + val youInvitedSomeoneEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youInvitedSomeone = formatter.format(youInvitedSomeoneEvent, false) + assertThat(youInvitedSomeone).isEqualTo("You sent an invitation to $inviteeName to join the room") + + val someoneInvitedSomeoneEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneInvitedSomeone = formatter.format(someoneInvitedSomeoneEvent, false) + assertThat(someoneInvitedSomeone).isEqualTo("$otherName sent an invitation to $inviteeName to join the room") + + val youInvitedNoOneEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youInvitedNoOne = formatter.format(youInvitedNoOneEvent, false) + assertThat(youInvitedNoOne).isNull() + + val someoneInvitedNoOneEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneInvitedNoOne = formatter.format(someoneInvitedNoOneEvent, false) + assertThat(someoneInvitedNoOne).isNull() + } + + @Test + @Config(qualifiers = "en") + fun `Room state change - room topic`() { + val otherName = "Other" + val roomTopic = "New topic" + val changedContent = StateContent("", OtherState.RoomTopic(roomTopic)) + val removedContent = StateContent("", OtherState.RoomTopic(null)) + val blankContent = StateContent("", OtherState.RoomTopic("")) + + val youChangedRoomTopicEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youChangedRoomTopic = formatter.format(youChangedRoomTopicEvent, false) + assertThat(youChangedRoomTopic).isEqualTo("You changed the topic to: $roomTopic") + + val someoneChangedRoomTopicEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneChangedRoomTopic = formatter.format(someoneChangedRoomTopicEvent, false) + assertThat(someoneChangedRoomTopic).isEqualTo("$otherName changed the topic to: $roomTopic") + + val youRemovedRoomTopicEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youRemovedRoomTopic = formatter.format(youRemovedRoomTopicEvent, false) + assertThat(youRemovedRoomTopic).isEqualTo("You removed the room topic") + + val someoneRemovedRoomTopicEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneRemovedRoomTopic = formatter.format(someoneRemovedRoomTopicEvent, false) + assertThat(someoneRemovedRoomTopic).isEqualTo("$otherName removed the room topic") + + val youSetBlankRoomTopicEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = blankContent) + val youSetBlankRoomTopic = formatter.format(youSetBlankRoomTopicEvent, false) + assertThat(youSetBlankRoomTopic).isEqualTo("You removed the room topic") + + val someoneSetBlankRoomTopicEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = blankContent) + val someoneSetBlankRoomTopic = formatter.format(someoneSetBlankRoomTopicEvent, false) + assertThat(someoneSetBlankRoomTopic).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(null), + OtherState.RoomPinnedEvents(OtherState.RoomPinnedEvents.Change.CHANGED), + OtherState.RoomUserPowerLevels(emptyMap()), + OtherState.RoomServerAcl, + OtherState.RoomTombstone, + OtherState.SpaceChild, + OtherState.SpaceParent, + OtherState.Custom("custom_event_type") + ) + + val results = otherStates.map { state -> + val content = StateContent("", state) + val event = createLatestEvent(sentByYou = false, senderDisplayName = "Someone", content = content) + val result = formatter.format(event, false) + state to result + } + val expected = otherStates.map { it to null } + assertThat(results).isEqualTo(expected) + } + + // endregion + + // region Profile change + + @Test + @Config(qualifiers = "en") + fun `Profile change - avatar`() { + val otherName = "Other" + 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 = createLatestEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youChangedAvatar = formatter.format(youChangedAvatarEvent, false) + assertThat(youChangedAvatar).isEqualTo("You changed your avatar") + + val someoneChangeAvatarEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneChangeAvatar = formatter.format(someoneChangeAvatarEvent, false) + assertThat(someoneChangeAvatar).isEqualTo("$otherName changed their avatar") + + val youSetAvatarEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = setContent) + val youSetAvatar = formatter.format(youSetAvatarEvent, false) + assertThat(youSetAvatar).isEqualTo("You changed your avatar") + + val someoneSetAvatarEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = setContent) + val someoneSetAvatar = formatter.format(someoneSetAvatarEvent, false) + assertThat(someoneSetAvatar).isEqualTo("$otherName changed their avatar") + + val youRemovedAvatarEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youRemovedAvatar = formatter.format(youRemovedAvatarEvent, false) + assertThat(youRemovedAvatar).isEqualTo("You changed your avatar") + + val someoneRemovedAvatarEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneRemovedAvatar = formatter.format(someoneRemovedAvatarEvent, false) + assertThat(someoneRemovedAvatar).isEqualTo("$otherName changed their avatar") + + val unchangedEvent = createLatestEvent(sentByYou = true, senderDisplayName = otherName, content = sameContent) + val unchangedResult = formatter.format(unchangedEvent, false) + assertThat(unchangedResult).isNull() + + val invalidEvent = createLatestEvent(sentByYou = true, senderDisplayName = otherName, content = invalidContent) + val invalidResult = formatter.format(invalidEvent, false) + assertThat(invalidResult).isNull() + } + + @Test + @Config(qualifiers = "en") + fun `Profile change - display name`() { + val newDisplayName = "New" + val oldDisplayName = "Old" + val otherName = "Other" + 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 = createLatestEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youChangedDisplayName = formatter.format(youChangedDisplayNameEvent, false) + assertThat(youChangedDisplayName).isEqualTo("You changed your display name from $oldDisplayName to $newDisplayName") + + val someoneChangedDisplayNameEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = changedContent) + val someoneChangedDisplayName = formatter.format(someoneChangedDisplayNameEvent, false) + assertThat(someoneChangedDisplayName).isEqualTo("$someoneElseId changed their display name from $oldDisplayName to $newDisplayName") + + val youSetDisplayNameEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = setContent) + val youSetDisplayName = formatter.format(youSetDisplayNameEvent, false) + assertThat(youSetDisplayName).isEqualTo("You set your display name to $newDisplayName") + + val someoneSetDisplayNameEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = setContent) + val someoneSetDisplayName = formatter.format(someoneSetDisplayNameEvent, false) + assertThat(someoneSetDisplayName).isEqualTo("$someoneElseId set their display name to $newDisplayName") + + val youRemovedDisplayNameEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = removedContent) + val youRemovedDisplayName = formatter.format(youRemovedDisplayNameEvent, false) + assertThat(youRemovedDisplayName).isEqualTo("You removed your display name (it was $oldDisplayName)") + + val someoneRemovedDisplayNameEvent = createLatestEvent(sentByYou = false, senderDisplayName = otherName, content = removedContent) + val someoneRemovedDisplayName = formatter.format(someoneRemovedDisplayNameEvent, false) + assertThat(someoneRemovedDisplayName).isEqualTo("$someoneElseId removed their display name (it was $oldDisplayName)") + + val unchangedEvent = createLatestEvent(sentByYou = true, senderDisplayName = otherName, content = sameContent) + val unchangedResult = formatter.format(unchangedEvent, false) + assertThat(unchangedResult).isNull() + + val invalidEvent = createLatestEvent(sentByYou = true, senderDisplayName = otherName, content = invalidContent) + val invalidResult = formatter.format(invalidEvent, false) + 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 = createLatestEvent(sentByYou = true, senderDisplayName = null, content = changedContent) + val youChangedBoth = formatter.format(youChangedBothEvent, false) + assertThat(youChangedBoth).isEqualTo("You changed your display name from $oldDisplayName to $newDisplayName\n(avatar was changed too)") + + val invalidContentEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = invalidContent) + val invalidMessage = formatter.format(invalidContentEvent, false) + assertThat(invalidMessage).isNull() + + val sameContentEvent = createLatestEvent(sentByYou = true, senderDisplayName = null, content = sameContent) + val sameMessage = formatter.format(sameContentEvent, false) + assertThat(sameMessage).isNull() + } + + // endregion + + // region Polls + + @Test + @Config(qualifiers = "en") + fun `Computes last message for poll in DM`() { + val pollContent = aPollContent() + + val mineContentEvent = createLatestEvent(sentByYou = true, senderDisplayName = "Alice", content = pollContent) + assertThat(formatter.format(mineContentEvent, true)).isEqualTo("Poll: Do you like polls?") + + val contentEvent = createLatestEvent(sentByYou = false, senderDisplayName = "Bob", content = pollContent) + assertThat(formatter.format(contentEvent, true)).isEqualTo("Poll: Do you like polls?") + } + + @Test + @Config(qualifiers = "en") + fun `Computes last message for poll in room`() { + val pollContent = aPollContent() + + val mineContentEvent = createLatestEvent(sentByYou = true, senderDisplayName = "Alice", content = pollContent) + assertThat(formatter.format(mineContentEvent, false).toString()).isEqualTo("You: Poll: Do you like polls?") + + val contentEvent = createLatestEvent(sentByYou = false, senderDisplayName = "Bob", content = pollContent) + assertThat(formatter.format(contentEvent, false).toString()).isEqualTo("Bob: Poll: Do you like polls?") + } + + // endregion + + private fun createLatestEvent( + sentByYou: Boolean, + senderDisplayName: String?, + content: EventContent, + ): LatestEventValue.Remote { + val sender = if (sentByYou) A_USER_ID else someoneElseId + val profile = aProfileDetails(senderDisplayName) + return aRemoteLatestEvent( + senderId = sender, + senderProfile = profile, + content = content, + isOwn = sentByYou, + ) + } + + private val someoneElseId = UserId("@someone_else:domain") +} diff --git a/libraries/eventformatter/test/build.gradle.kts b/libraries/eventformatter/test/build.gradle.kts new file mode 100644 index 0000000..7a72cf0 --- /dev/null +++ b/libraries/eventformatter/test/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/FakePinnedMessagesBannerFormatter.kt b/libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakePinnedMessagesBannerFormatter.kt new file mode 100644 index 0000000..19698a7 --- /dev/null +++ b/libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakePinnedMessagesBannerFormatter.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.eventformatter.test + +import io.element.android.libraries.eventformatter.api.PinnedMessagesBannerFormatter +import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem + +class FakePinnedMessagesBannerFormatter( + val formatLambda: (event: EventTimelineItem) -> CharSequence +) : PinnedMessagesBannerFormatter { + override fun format(event: EventTimelineItem): CharSequence { + return formatLambda(event) + } +} diff --git a/libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakeRoomLatestEventFormatter.kt b/libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakeRoomLatestEventFormatter.kt new file mode 100644 index 0000000..5e1a056 --- /dev/null +++ b/libraries/eventformatter/test/src/main/kotlin/io/element/android/libraries/eventformatter/test/FakeRoomLatestEventFormatter.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.eventformatter.test + +import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter +import io.element.android.libraries.matrix.api.roomlist.LatestEventValue + +class FakeRoomLatestEventFormatter : RoomLatestEventFormatter { + private var result: CharSequence? = null + + override fun format(latestEvent: LatestEventValue.Local, isDmRoom: Boolean): CharSequence? { + return result + } + + override fun format(latestEvent: LatestEventValue.Remote, 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 0000000..b34e082 --- /dev/null +++ b/libraries/featureflag/api/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.featureflag.api" +} + +dependencies { + implementation(projects.appconfig) + implementation(projects.libraries.core) + 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 0000000..395fd04 --- /dev/null +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/Feature.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.featureflag.api + +import io.element.android.libraries.core.meta.BuildMeta + +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? + + /** + * Calculate the default value of the feature (enabled or disabled) given a [BuildMeta]. + */ + val defaultValue: (BuildMeta) -> Boolean + + /** + * Whether the feature is finished or not. + * If false: the feature is still in development, it will appear in the developer options screen to be able to enable it and test it. + * If true: the feature is finished, it will not appear in the developer options screen. + */ + val isFinished: Boolean + + /** + * Whether the feature is only available in Labs (and not in developer options). + * Feature flags that set this to `true` can be enabled by any users, not only those that have enabled developer mode. + */ + val isInLabs: 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 0000000..6a8cb2f --- /dev/null +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlagService.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.featureflag.api + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first + +interface FeatureFlagService { + /** + * @param feature the feature to check for + * + * @return true if the feature is enabled + */ + suspend fun isFeatureEnabled(feature: Feature): Boolean = isFeatureEnabledFlow(feature).first() + + /** + * @param feature the feature to check for + * + * @return a flow of booleans, true if the feature is enabled, false if it is disabled. + */ + fun isFeatureEnabledFlow(feature: Feature): Flow + + /** + * @param feature the feature to enable or disable + * @param enabled true to enable the feature + * + * @return true if the method succeeds, ie if a [io.element.android.libraries.featureflag.impl.MutableFeatureFlagProvider] + * is registered + */ + suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean + + /** + * @return the list of available features that can be toggled. + * @param includeFinishedFeatures whether to include finished features, default is false + * @param isInLabs whether the user is in labs (to include lab features), default is false + */ + fun getAvailableFeatures( + includeFinishedFeatures: Boolean = false, + isInLabs: Boolean = false, + ): List +} 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 0000000..d809fe3 --- /dev/null +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.featureflag.api + +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType + +/** + * To enable or disable a FeatureFlags, change the `defaultValue` value. + */ +enum class FeatureFlags( + override val key: String, + override val title: String, + override val description: String? = null, + override val defaultValue: (BuildMeta) -> Boolean, + override val isFinished: Boolean, + override val isInLabs: Boolean = false, +) : Feature { + RoomDirectorySearch( + key = "feature.roomdirectorysearch", + title = "Room directory search", + description = "Allow user to search for public rooms in their homeserver", + defaultValue = { false }, + isFinished = false, + ), + ShowBlockedUsersDetails( + key = "feature.showBlockedUsersDetails", + title = "Show blocked users details", + description = "Show the name and avatar of blocked users in the blocked users list", + defaultValue = { false }, + isFinished = false, + ), + SyncOnPush( + key = "feature.syncOnPush", + title = "Sync on push", + description = "Subscribe to room sync when a push is received", + defaultValue = { true }, + isFinished = false, + ), + OnlySignedDeviceIsolationMode( + key = "feature.onlySignedDeviceIsolationMode", + title = "Exclude insecure devices when sending/receiving messages", + description = "This setting controls how end-to-end encryption (E2E) keys are shared." + + " Enabling it will prevent the inclusion of devices that have not been explicitly verified by their owners." + + " You'll have to stop and re-open the app manually for that setting to take effect.", + defaultValue = { false }, + isFinished = false, + ), + EnableKeyShareOnInvite( + key = "feature.enableKeyShareOnInvite", + title = "Share encrypted history with new members", + description = "When inviting a user to an encrypted room that has history visibility set to \"shared\"," + + " share encrypted history with that user, and accept encrypted history when you are invited to such a room." + + "\nRequires an app restart to take effect." + + "\n\nWARNING: this feature is EXPERIMENTAL and not all security precautions are implemented." + + " Do not enable on production accounts.", + defaultValue = { false }, + isFinished = false, + ), + Knock( + key = "feature.knock", + title = "Ask to join", + description = "Allow creating rooms which users can request access to.", + defaultValue = { false }, + isFinished = false, + ), + Space( + key = "feature.space", + title = "Spaces", + defaultValue = { true }, + isFinished = false, + ), + PrintLogsToLogcat( + key = "feature.print_logs_to_logcat", + title = "Print logs to logcat", + description = "Print logs to logcat in addition to log files. Requires an app restart to take effect." + + "\n\nWARNING: this will make the logs visible in the device logs and may affect performance. " + + "It's not intended for daily usage in release builds.", + defaultValue = { buildMeta -> buildMeta.buildType != BuildType.RELEASE }, + // False so it's displayed in the developer options screen + isFinished = false, + ), + SelectableMediaQuality( + key = "feature.selectable_media_quality", + title = "Select media quality per upload", + description = "You can select the media quality for each attachment you upload.", + defaultValue = { false }, + // False so it's displayed in the developer options screen + isFinished = false, + ), + Threads( + key = "feature.thread_timeline", + title = "Threads", + description = "Renders thread messages as a dedicated timeline. Restarting the app is required for this setting to fully take effect.", + defaultValue = { false }, + isFinished = false, + isInLabs = true, + ), + MultiAccount( + key = "feature.multi_account", + title = "Multi accounts", + description = "Allow the application to connect to multiple accounts at the same time." + + "\n\nWARNING: this feature is EXPERIMENTAL and UNSTABLE.", + defaultValue = { false }, + isFinished = false, + ), + SyncNotificationsWithWorkManager( + key = "feature.sync_notifications_with_workmanager", + title = "Sync notifications with WorkManager", + description = "Use WorkManager to schedule notification sync tasks when a push is received." + + " This should improve reliability and battery usage.", + defaultValue = { true }, + isFinished = false, + ), +} diff --git a/libraries/featureflag/impl/build.gradle.kts b/libraries/featureflag/impl/build.gradle.kts new file mode 100644 index 0000000..63d9684 --- /dev/null +++ b/libraries/featureflag/impl/build.gradle.kts @@ -0,0 +1,36 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.libraries.featureflag.impl" +} + +setupDependencyInjection() + +dependencies { + api(projects.libraries.featureflag.api) + implementation(libs.androidx.datastore.preferences) + implementation(projects.appconfig) + implementation(projects.libraries.di) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.preferences.api) + implementation(libs.coroutines.core) + + testCommonDependencies(libs) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.featureflag.test) +} 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 0000000..f7361b6 --- /dev/null +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagService.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.featureflag.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.featureflag.api.Feature +import io.element.android.libraries.featureflag.api.FeatureFlagService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class DefaultFeatureFlagService( + private val providers: Set<@JvmSuppressWildcards FeatureFlagProvider>, + private val buildMeta: BuildMeta, + private val featuresProvider: FeaturesProvider, +) : FeatureFlagService { + override fun isFeatureEnabledFlow(feature: Feature): Flow { + return providers.filter { it.hasFeature(feature) } + .maxByOrNull(FeatureFlagProvider::priority) + ?.isFeatureEnabledFlow(feature) + ?: flowOf(feature.defaultValue(buildMeta)) + } + + override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean { + return providers.filterIsInstance() + .maxByOrNull(FeatureFlagProvider::priority) + ?.setFeatureEnabled(feature, enabled) + ?.let { true } + ?: false + } + + override fun getAvailableFeatures( + includeFinishedFeatures: Boolean, + isInLabs: Boolean, + ): List { + return featuresProvider.provide().filter { flag -> + (includeFinishedFeatures || !flag.isFinished) && + flag.isInLabs == isInLabs + } + } +} 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 0000000..e3172fc --- /dev/null +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/FeatureFlagProvider.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.featureflag.impl + +import io.element.android.libraries.featureflag.api.Feature +import kotlinx.coroutines.flow.Flow + +interface FeatureFlagProvider { + val priority: Int + fun isFeatureEnabledFlow(feature: Feature): Flow + 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/FeaturesProvider.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/FeaturesProvider.kt new file mode 100644 index 0000000..e24ae66 --- /dev/null +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/FeaturesProvider.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.featureflag.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.featureflag.api.Feature +import io.element.android.libraries.featureflag.api.FeatureFlags + +fun interface FeaturesProvider { + fun provide(): List +} + +@ContributesBinding(AppScope::class) +class DefaultFeaturesProvider : FeaturesProvider { + override fun provide(): List = FeatureFlags.entries +} diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/MutableFeatureFlagProvider.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/MutableFeatureFlagProvider.kt new file mode 100644 index 0000000..48ed2c3 --- /dev/null +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/MutableFeatureFlagProvider.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.featureflag.impl + +import io.element.android.libraries.featureflag.api.Feature + +interface MutableFeatureFlagProvider : FeatureFlagProvider { + suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean) +} 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 0000000..679bd9c --- /dev/null +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/PreferencesFeatureFlagProvider.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.featureflag.impl + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import dev.zacsweers.metro.Inject +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.featureflag.api.Feature +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +/** + * Note: this will be used only in the nightly and in the debug build. + */ +@Inject +class PreferencesFeatureFlagProvider( + private val buildMeta: BuildMeta, + preferenceDataStoreFactory: PreferenceDataStoreFactory, +) : MutableFeatureFlagProvider { + private val store = preferenceDataStoreFactory.create("elementx_featureflag") + + override val priority = MEDIUM_PRIORITY + + override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean) { + store.edit { prefs -> + prefs[booleanPreferencesKey(feature.key)] = enabled + } + } + + override fun isFeatureEnabledFlow(feature: Feature): Flow { + return store.data.map { prefs -> + prefs[booleanPreferencesKey(feature.key)] ?: feature.defaultValue(buildMeta) + }.distinctUntilChanged() + } + + override fun hasFeature(feature: Feature): Boolean { + return true + } +} 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 0000000..de3f8aa --- /dev/null +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/di/FeatureFlagModule.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.featureflag.impl.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.ElementsIntoSet +import dev.zacsweers.metro.Provides +import io.element.android.libraries.featureflag.impl.FeatureFlagProvider +import io.element.android.libraries.featureflag.impl.PreferencesFeatureFlagProvider + +@BindingContainer +@ContributesTo(AppScope::class) +object FeatureFlagModule { + @JvmStatic + @Provides + @ElementsIntoSet + fun providesFeatureFlagProvider( + mutableFeatureFlagProvider: PreferencesFeatureFlagProvider, + ): Set { + return buildSet { + add(mutableFeatureFlagProvider) + } + } +} 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 0000000..00bc687 --- /dev/null +++ b/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagServiceTest.kt @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.featureflag.impl + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.featureflag.api.Feature +import io.element.android.libraries.featureflag.test.FakeFeature +import io.element.android.libraries.matrix.test.core.aBuildMeta +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultFeatureFlagServiceTest { + private val aFeature = FakeFeature( + key = "test_feature", + title = "Test Feature", + ) + + @Test + fun `given service without provider when feature is checked then it returns the default value`() = runTest { + val featureWithDefaultToFalse = FakeFeature( + key = "test_feature", + title = "Test Feature", + defaultValue = { false } + ) + val featureWithDefaultToTrue = FakeFeature( + key = "test_feature_2", + title = "Test Feature 2", + defaultValue = { true } + ) + val buildMeta = aBuildMeta() + val featureFlagService = createDefaultFeatureFlagService(buildMeta = buildMeta) + featureFlagService.isFeatureEnabledFlow(featureWithDefaultToFalse).test { + assertThat(awaitItem()).isFalse() + cancelAndIgnoreRemainingEvents() + } + featureFlagService.isFeatureEnabledFlow(featureWithDefaultToTrue).test { + assertThat(awaitItem()).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `given service without provider when set enabled feature is called then it returns false`() = runTest { + val featureFlagService = createDefaultFeatureFlagService() + val result = featureFlagService.setFeatureEnabled(aFeature, true) + assertThat(result).isFalse() + } + + @Test + fun `given service with a runtime provider when set enabled feature is called then it returns true`() = runTest { + val buildMeta = aBuildMeta() + val featureFlagProvider = FakeMutableFeatureFlagProvider(0, buildMeta) + val featureFlagService = createDefaultFeatureFlagService( + providers = setOf(featureFlagProvider), + buildMeta = buildMeta, + ) + val result = featureFlagService.setFeatureEnabled(aFeature, true) + assertThat(result).isTrue() + } + + @Test + fun `given service with a runtime provider and feature enabled when feature is checked then it returns the correct value`() = runTest { + val buildMeta = aBuildMeta() + val featureFlagProvider = FakeMutableFeatureFlagProvider(0, buildMeta) + val featureFlagService = createDefaultFeatureFlagService( + providers = setOf(featureFlagProvider), + buildMeta = buildMeta + ) + featureFlagService.setFeatureEnabled(aFeature, true) + featureFlagService.isFeatureEnabledFlow(aFeature).test { + assertThat(awaitItem()).isTrue() + featureFlagService.setFeatureEnabled(aFeature, false) + assertThat(awaitItem()).isFalse() + } + } + + @Test + fun `given service with 2 runtime providers when feature is checked then it uses the priority correctly`() = runTest { + val buildMeta = aBuildMeta() + val lowPriorityFeatureFlagProvider = FakeMutableFeatureFlagProvider(LOW_PRIORITY, buildMeta) + val highPriorityFeatureFlagProvider = FakeMutableFeatureFlagProvider(HIGH_PRIORITY, buildMeta) + val featureFlagService = createDefaultFeatureFlagService( + providers = setOf(lowPriorityFeatureFlagProvider, highPriorityFeatureFlagProvider), + buildMeta = buildMeta + ) + lowPriorityFeatureFlagProvider.setFeatureEnabled(aFeature, false) + highPriorityFeatureFlagProvider.setFeatureEnabled(aFeature, true) + featureFlagService.isFeatureEnabledFlow(aFeature).test { + assertThat(awaitItem()).isTrue() + } + } + + @Test + fun `getAvailableFeatures should return expected features`() { + val aFinishedLabFeature = FakeFeature( + key = "finished_lab_feature", + title = "Finished Lab Feature", + isFinished = true, + isInLabs = true, + ) + val aFinishedDevFeature = FakeFeature( + key = "finished_dev_feature", + title = "Finished Dev Feature", + isFinished = true, + isInLabs = false, + ) + val anUnfinishedLabFeature = FakeFeature( + key = "unfinished_lab_feature", + title = "Unfinished Lab Feature", + isFinished = false, + isInLabs = true, + ) + val anUnfinishedDevFeature = FakeFeature( + key = "unfinished_dev_feature", + title = "Unfinished Dev Feature", + isFinished = false, + isInLabs = false, + ) + val featureFlagService = createDefaultFeatureFlagService( + features = listOf( + aFinishedLabFeature, + aFinishedDevFeature, + anUnfinishedLabFeature, + anUnfinishedDevFeature, + ), + ) + assertThat( + featureFlagService.getAvailableFeatures( + includeFinishedFeatures = false, + isInLabs = true, + ) + ).containsExactly(anUnfinishedLabFeature) + assertThat( + featureFlagService.getAvailableFeatures( + includeFinishedFeatures = true, + isInLabs = true, + ) + ).containsExactly(aFinishedLabFeature, anUnfinishedLabFeature) + assertThat( + featureFlagService.getAvailableFeatures( + includeFinishedFeatures = false, + isInLabs = false, + ) + ).containsExactly(anUnfinishedDevFeature) + assertThat( + featureFlagService.getAvailableFeatures( + includeFinishedFeatures = true, + isInLabs = false, + ) + ).containsExactly(aFinishedDevFeature, anUnfinishedDevFeature) + } +} + +private fun createDefaultFeatureFlagService( + providers: Set = emptySet(), + buildMeta: BuildMeta = aBuildMeta(), + features: List = emptyList(), +) = DefaultFeatureFlagService( + providers = providers, + buildMeta = buildMeta, + featuresProvider = { features } +) diff --git a/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeaturesProviderTest.kt b/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeaturesProviderTest.kt new file mode 100644 index 0000000..66d7729 --- /dev/null +++ b/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeaturesProviderTest.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.featureflag.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.featureflag.api.FeatureFlags +import org.junit.Test + +class DefaultFeaturesProviderTest { + @Test + fun `provide should return all features`() { + val provider = DefaultFeaturesProvider() + val features = provider.provide() + assertThat(features.size).isEqualTo(FeatureFlags.entries.size) + FeatureFlags.entries.forEach { + assertThat(features.contains(it)).isTrue() + } + } +} diff --git a/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/FakeMutableFeatureFlagProvider.kt b/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/FakeMutableFeatureFlagProvider.kt new file mode 100644 index 0000000..13f1f30 --- /dev/null +++ b/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/FakeMutableFeatureFlagProvider.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.featureflag.impl + +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.featureflag.api.Feature +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeMutableFeatureFlagProvider( + override val priority: Int, + private val buildMeta: BuildMeta, +) : MutableFeatureFlagProvider { + private val enabledFeatures = mutableMapOf>() + + override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean) { + val flow = enabledFeatures.getOrPut(feature.key) { MutableStateFlow(enabled) } + flow.emit(enabled) + } + + override fun isFeatureEnabledFlow(feature: Feature): Flow { + return enabledFeatures.getOrPut(feature.key) { MutableStateFlow(feature.defaultValue(buildMeta)) } + } + + 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 0000000..2572131 --- /dev/null +++ b/libraries/featureflag/test/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.featureflag.test" +} + +dependencies { + api(projects.libraries.featureflag.api) + implementation(projects.libraries.core) + implementation(projects.libraries.matrix.test) + implementation(libs.coroutines.core) +} diff --git a/libraries/featureflag/test/src/main/java/io/element/android/libraries/featureflag/test/FakeFeature.kt b/libraries/featureflag/test/src/main/java/io/element/android/libraries/featureflag/test/FakeFeature.kt new file mode 100644 index 0000000..c8ba9f4 --- /dev/null +++ b/libraries/featureflag/test/src/main/java/io/element/android/libraries/featureflag/test/FakeFeature.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.featureflag.test + +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.featureflag.api.Feature + +data class FakeFeature( + override val key: String, + override val title: String, + override val description: String? = null, + override val defaultValue: (BuildMeta) -> Boolean = { false }, + override val isFinished: Boolean = false, + override val isInLabs: Boolean = false, +) : Feature 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 0000000..eee8ee4 --- /dev/null +++ b/libraries/featureflag/test/src/main/java/io/element/android/libraries/featureflag/test/FakeFeatureFlagService.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.featureflag.test + +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.featureflag.api.Feature +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.matrix.test.core.aBuildMeta +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeFeatureFlagService( + initialState: Map = emptyMap(), + private val buildMeta: BuildMeta = aBuildMeta(), + private val getAvailableFeaturesResult: (Boolean, Boolean) -> List = { _, _ -> emptyList() }, +) : FeatureFlagService { + private val enabledFeatures = initialState + .mapValues { MutableStateFlow(it.value) } + .toMutableMap() + + override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean { + val flow = enabledFeatures.getOrPut(feature.key) { MutableStateFlow(enabled) } + flow.emit(enabled) + return true + } + + override fun isFeatureEnabledFlow(feature: Feature): Flow { + return enabledFeatures.getOrPut(feature.key) { MutableStateFlow(feature.defaultValue(buildMeta)) } + } + + override fun getAvailableFeatures( + includeFinishedFeatures: Boolean, + isInLabs: Boolean, + ): List { + return getAvailableFeaturesResult(includeFinishedFeatures, isInLabs) + } +} diff --git a/libraries/featureflag/ui/build.gradle.kts b/libraries/featureflag/ui/build.gradle.kts new file mode 100644 index 0000000..085c619 --- /dev/null +++ b/libraries/featureflag/ui/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.libraries.featureflag.ui" +} + +dependencies { + implementation(projects.libraries.designsystem) +} 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 0000000..bd6d883 --- /dev/null +++ b/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/FeatureListView.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.featureflag.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.libraries.designsystem.components.preferences.PreferenceCheckbox +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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, + 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 +private fun FeaturePreferenceView( + feature: FeatureUiModel, + onCheckedChange: (Boolean) -> Unit, +) { + PreferenceCheckbox( + title = feature.title, + supportingText = feature.description, + isChecked = feature.isEnabled, + onCheckedChange = onCheckedChange + ) +} + +@PreviewsDayNight +@Composable +internal fun FeatureListViewPreview() = ElementPreview { + 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 0000000..d3ec315 --- /dev/null +++ b/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/model/FeatureUiModel.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.featureflag.ui.model + +import io.element.android.libraries.designsystem.theme.components.IconSource + +data class FeatureUiModel( + val key: String, + val title: String, + val description: String?, + val icon: IconSource?, + 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 0000000..63bc118 --- /dev/null +++ b/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/model/FeatureUiModelProvider.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.featureflag.ui.model + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +fun aFeatureUiModelList(): ImmutableList { + return persistentListOf( + FeatureUiModel(key = "key1", title = "Display State Events", description = "Show state events in the timeline", icon = null, isEnabled = true), + FeatureUiModel(key = "key2", title = "Display Room Events", description = null, icon = null, isEnabled = false), + ) +} diff --git a/libraries/fullscreenintent/api/build.gradle.kts b/libraries/fullscreenintent/api/build.gradle.kts new file mode 100644 index 0000000..8d6be45 --- /dev/null +++ b/libraries/fullscreenintent/api/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.fullscreenintent.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.permissions.api) + implementation(projects.libraries.preferences.api) +} diff --git a/libraries/fullscreenintent/api/src/main/kotlin/io/element/android/libraries/fullscreenintent/api/FullScreenIntentPermissionsEvents.kt b/libraries/fullscreenintent/api/src/main/kotlin/io/element/android/libraries/fullscreenintent/api/FullScreenIntentPermissionsEvents.kt new file mode 100644 index 0000000..1c5bc4b --- /dev/null +++ b/libraries/fullscreenintent/api/src/main/kotlin/io/element/android/libraries/fullscreenintent/api/FullScreenIntentPermissionsEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.fullscreenintent.api + +sealed interface FullScreenIntentPermissionsEvents { + data object Dismiss : FullScreenIntentPermissionsEvents + data object OpenSettings : FullScreenIntentPermissionsEvents +} diff --git a/libraries/fullscreenintent/api/src/main/kotlin/io/element/android/libraries/fullscreenintent/api/FullScreenIntentPermissionsState.kt b/libraries/fullscreenintent/api/src/main/kotlin/io/element/android/libraries/fullscreenintent/api/FullScreenIntentPermissionsState.kt new file mode 100644 index 0000000..5b1ddb9 --- /dev/null +++ b/libraries/fullscreenintent/api/src/main/kotlin/io/element/android/libraries/fullscreenintent/api/FullScreenIntentPermissionsState.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.fullscreenintent.api + +data class FullScreenIntentPermissionsState( + val permissionGranted: Boolean, + val shouldDisplayBanner: Boolean, + val eventSink: (FullScreenIntentPermissionsEvents) -> Unit, +) diff --git a/libraries/fullscreenintent/api/src/main/kotlin/io/element/android/libraries/fullscreenintent/api/FullScreenIntentPermissionsStateProvider.kt b/libraries/fullscreenintent/api/src/main/kotlin/io/element/android/libraries/fullscreenintent/api/FullScreenIntentPermissionsStateProvider.kt new file mode 100644 index 0000000..7248a24 --- /dev/null +++ b/libraries/fullscreenintent/api/src/main/kotlin/io/element/android/libraries/fullscreenintent/api/FullScreenIntentPermissionsStateProvider.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.fullscreenintent.api + +fun aFullScreenIntentPermissionsState( + permissionGranted: Boolean = true, + shouldDisplay: Boolean = false, + eventSink: (FullScreenIntentPermissionsEvents) -> Unit = {}, +) = FullScreenIntentPermissionsState( + permissionGranted = permissionGranted, + shouldDisplayBanner = shouldDisplay, + eventSink = eventSink, +) diff --git a/libraries/fullscreenintent/impl/build.gradle.kts b/libraries/fullscreenintent/impl/build.gradle.kts new file mode 100644 index 0000000..57c4f5b --- /dev/null +++ b/libraries/fullscreenintent/impl/build.gradle.kts @@ -0,0 +1,38 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.fullscreenintent.impl" +} + +setupDependencyInjection() + +dependencies { + api(projects.libraries.fullscreenintent.api) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.permissions.api) + implementation(projects.libraries.permissions.noop) + implementation(projects.libraries.preferences.api) + implementation(projects.services.toolbox.api) + implementation(libs.androidx.datastore.preferences) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.permissions.test) + testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.testtags) + testImplementation(projects.services.toolbox.test) +} diff --git a/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenter.kt b/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenter.kt new file mode 100644 index 0000000..ed406e7 --- /dev/null +++ b/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenter.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.fullscreenintent.impl + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Build +import android.provider.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.core.app.NotificationManagerCompat +import androidx.core.net.toUri +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents +import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +@SingleIn(AppScope::class) +@Inject +class FullScreenIntentPermissionsPresenter( + private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, + private val externalIntentLauncher: ExternalIntentLauncher, + private val buildMeta: BuildMeta, + private val notificationManagerCompat: NotificationManagerCompat, + preferencesDataStoreFactory: PreferenceDataStoreFactory, +) : Presenter { + companion object { + private const val PREF_KEY_FULL_SCREEN_INTENT_BANNER_DISMISSED = "PREF_KEY_FULL_SCREEN_INTENT_BANNER_DISMISSED" + } + + private val dataStore = preferencesDataStoreFactory.create("full_screen_intent_permissions") + + private val isFullScreenIntentBannerDismissed = dataStore.data.map { prefs -> + prefs[booleanPreferencesKey(PREF_KEY_FULL_SCREEN_INTENT_BANNER_DISMISSED)] ?: false + } + + private suspend fun dismissFullScreenIntentBanner() { + dataStore.edit { prefs -> + prefs[booleanPreferencesKey(PREF_KEY_FULL_SCREEN_INTENT_BANNER_DISMISSED)] = true + } + } + + @Composable + override fun present(): FullScreenIntentPermissionsState { + val coroutineScope = rememberCoroutineScope() + val isGranted = notificationManagerCompat.canUseFullScreenIntent() + val isBannerDismissed by isFullScreenIntentBannerDismissed.collectAsState(initial = true) + + fun handleEvent(event: FullScreenIntentPermissionsEvents) { + when (event) { + FullScreenIntentPermissionsEvents.Dismiss -> coroutineScope.launch { + dismissFullScreenIntentBanner() + } + FullScreenIntentPermissionsEvents.OpenSettings -> openFullScreenIntentSettings() + } + } + + return FullScreenIntentPermissionsState( + permissionGranted = isGranted, + shouldDisplayBanner = !isBannerDismissed && !isGranted, + eventSink = ::handleEvent, + ) + } + + private fun openFullScreenIntentSettings() { + if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) { + try { + val intent = Intent( + Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, + "package:${buildMeta.applicationId}".toUri() + ) + externalIntentLauncher.launch(intent) + } catch (e: ActivityNotFoundException) { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, buildMeta.applicationId) + externalIntentLauncher.launch(intent) + } + } + } +} diff --git a/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/di/FullScreenIntentModule.kt b/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/di/FullScreenIntentModule.kt new file mode 100644 index 0000000..249485b --- /dev/null +++ b/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/di/FullScreenIntentModule.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.fullscreenintent.impl.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState +import io.element.android.libraries.fullscreenintent.impl.FullScreenIntentPermissionsPresenter + +@ContributesTo(AppScope::class) +@BindingContainer +interface FullScreenIntentModule { + @Binds + fun bindFullScreenIntentPermissionsPresenter(presenter: FullScreenIntentPermissionsPresenter): Presenter +} diff --git a/libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/test/FullScreenIntentPermissionsPresenterTest.kt b/libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/test/FullScreenIntentPermissionsPresenterTest.kt new file mode 100644 index 0000000..44a5579 --- /dev/null +++ b/libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/test/FullScreenIntentPermissionsPresenterTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.fullscreenintent.test + +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationManagerCompat +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents +import io.element.android.libraries.fullscreenintent.impl.FullScreenIntentPermissionsPresenter +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory +import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher +import io.element.android.services.toolbox.test.intent.FakeExternalIntentLauncher +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class FullScreenIntentPermissionsPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `shouldDisplay - is true when permission is not granted and banner is not dismissed`() = runTest { + val presenter = createPresenter( + notificationManagerCompat = mockk { + every { canUseFullScreenIntent() } returns false + } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialItem = awaitItem() + assertThat(initialItem.shouldDisplayBanner).isTrue() + } + } + + @Test + fun `shouldDisplay - is false if permission is granted`() = runTest { + val presenter = createPresenter( + notificationManagerCompat = mockk { + every { canUseFullScreenIntent() } returns true + } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialItem = awaitItem() + assertThat(initialItem.shouldDisplayBanner).isFalse() + } + } + + @Test + fun `dismissFullScreenIntentBanner - makes shouldDisplay false`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val loadedItem = awaitItem() + loadedItem.eventSink(FullScreenIntentPermissionsEvents.Dismiss) + runCurrent() + assertThat(awaitItem().shouldDisplayBanner).isFalse() + } + } + + @Test + fun `openFullScreenIntentSettings - opens external screen using intent`() = runTest { + val launchLambda = lambdaRecorder { _ -> } + val externalIntentLauncher = FakeExternalIntentLauncher(launchLambda) + val presenter = createPresenter(externalIntentLauncher = externalIntentLauncher) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val loadedItem = awaitItem() + loadedItem.eventSink(FullScreenIntentPermissionsEvents.OpenSettings) + launchLambda.assertions().isCalledOnce() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `openFullScreenIntentSettings - does nothing in old APIs`() = runTest { + val launchLambda = lambdaRecorder { _ -> } + val externalIntentLauncher = FakeExternalIntentLauncher(launchLambda) + val presenter = createPresenter( + buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.Q), + externalIntentLauncher = externalIntentLauncher, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val loadedItem = awaitItem() + loadedItem.eventSink(FullScreenIntentPermissionsEvents.OpenSettings) + launchLambda.assertions().isNeverCalled() + cancelAndIgnoreRemainingEvents() + } + } + + private fun createPresenter( + buildVersionSdkIntProvider: FakeBuildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.UPSIDE_DOWN_CAKE), + dataStoreFactory: FakePreferenceDataStoreFactory = FakePreferenceDataStoreFactory(), + externalIntentLauncher: ExternalIntentLauncher = FakeExternalIntentLauncher(), + buildMeta: BuildMeta = aBuildMeta(), + notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true) + ) = FullScreenIntentPermissionsPresenter( + buildVersionSdkIntProvider = buildVersionSdkIntProvider, + externalIntentLauncher = externalIntentLauncher, + buildMeta = buildMeta, + preferencesDataStoreFactory = dataStoreFactory, + notificationManagerCompat = notificationManagerCompat, + ) +} diff --git a/libraries/indicator/api/build.gradle.kts b/libraries/indicator/api/build.gradle.kts new file mode 100644 index 0000000..c6f33ec --- /dev/null +++ b/libraries/indicator/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.indicator.api" +} + +dependencies { + implementation(libs.coroutines.core) +} diff --git a/libraries/indicator/api/src/main/kotlin/io/element/android/libraries/indicator/api/IndicatorService.kt b/libraries/indicator/api/src/main/kotlin/io/element/android/libraries/indicator/api/IndicatorService.kt new file mode 100644 index 0000000..0409a0f --- /dev/null +++ b/libraries/indicator/api/src/main/kotlin/io/element/android/libraries/indicator/api/IndicatorService.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.indicator.api + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State + +/** + * A set of State to observe to display or not the indicators in the UI. + */ +interface IndicatorService { + @Composable + fun showRoomListTopBarIndicator(): State + + @Composable + fun showSettingChatBackupIndicator(): State +} diff --git a/libraries/indicator/impl/build.gradle.kts b/libraries/indicator/impl/build.gradle.kts new file mode 100644 index 0000000..8f1e9ea --- /dev/null +++ b/libraries/indicator/impl/build.gradle.kts @@ -0,0 +1,34 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +setupDependencyInjection() + +android { + namespace = "io.element.android.libraries.indicator.impl" +} + +dependencies { + implementation(projects.libraries.di) + implementation(projects.libraries.featureflag.api) + implementation(projects.libraries.matrix.api) + + implementation(libs.coroutines.core) + + api(projects.libraries.indicator.api) + + testCommonDependencies(libs) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.libraries.matrix.test) +} diff --git a/libraries/indicator/impl/src/main/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorService.kt b/libraries/indicator/impl/src/main/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorService.kt new file mode 100644 index 0000000..0629799 --- /dev/null +++ b/libraries/indicator/impl/src/main/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorService.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.indicator.impl + +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 dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.indicator.api.IndicatorService +import io.element.android.libraries.matrix.api.encryption.BackupState +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.api.verification.SessionVerificationService + +@ContributesBinding(SessionScope::class) +class DefaultIndicatorService( + private val sessionVerificationService: SessionVerificationService, + private val encryptionService: EncryptionService, +) : IndicatorService { + @Composable + override fun showRoomListTopBarIndicator(): State { + val canVerifySession by sessionVerificationService.needsSessionVerification.collectAsState(initial = false) + val settingChatBackupIndicator = showSettingChatBackupIndicator() + + return remember { + derivedStateOf { + canVerifySession || settingChatBackupIndicator.value + } + } + } + + @Composable + override fun showSettingChatBackupIndicator(): State { + val backupState by encryptionService.backupStateStateFlow.collectAsState() + val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() + + return remember { + derivedStateOf { + val showForBackup = backupState in listOf( + BackupState.UNKNOWN, + ) + val showForRecovery = recoveryState in listOf( + RecoveryState.DISABLED, + RecoveryState.INCOMPLETE, + ) + showForBackup || showForRecovery + } + } + } +} diff --git a/libraries/indicator/impl/src/test/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorServiceTest.kt b/libraries/indicator/impl/src/test/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorServiceTest.kt new file mode 100644 index 0000000..cd861ef --- /dev/null +++ b/libraries/indicator/impl/src/test/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorServiceTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.indicator.impl + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.encryption.BackupState +import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultIndicatorServiceTest { + @Test + fun `test - showRoomListTopBarIndicator`() = runTest { + val encryptionService = FakeEncryptionService() + val sessionVerificationService = FakeSessionVerificationService() + val sut = DefaultIndicatorService( + sessionVerificationService = sessionVerificationService, + encryptionService = encryptionService, + ) + moleculeFlow(RecompositionMode.Immediate) { + sut.showRoomListTopBarIndicator().value + }.test { + assertThat(awaitItem()).isTrue() + sessionVerificationService.emitNeedsSessionVerification(false) + encryptionService.emitBackupState(BackupState.ENABLED) + encryptionService.emitRecoveryState(RecoveryState.ENABLED) + assertThat(awaitItem()).isFalse() + sessionVerificationService.emitNeedsSessionVerification(true) + assertThat(awaitItem()).isTrue() + } + } + + @Test + fun `test - showSettingChatBackupIndicator is true when BackupState is UNKNOWN`() = runTest { + val encryptionService = FakeEncryptionService() + val sessionVerificationService = FakeSessionVerificationService() + val sut = DefaultIndicatorService( + sessionVerificationService = sessionVerificationService, + encryptionService = encryptionService, + ) + moleculeFlow(RecompositionMode.Immediate) { + sut.showSettingChatBackupIndicator().value + }.test { + assertThat(awaitItem()).isTrue() + encryptionService.emitBackupState(BackupState.ENABLED) + encryptionService.emitRecoveryState(RecoveryState.ENABLED) + assertThat(awaitItem()).isFalse() + encryptionService.emitBackupState(BackupState.UNKNOWN) + assertThat(awaitItem()).isTrue() + } + } + + @Test + fun `test - showSettingChatBackupIndicator is true when recoveryState is DISABLED`() = runTest { + val encryptionService = FakeEncryptionService() + val sessionVerificationService = FakeSessionVerificationService() + val sut = DefaultIndicatorService( + sessionVerificationService = sessionVerificationService, + encryptionService = encryptionService, + ) + moleculeFlow(RecompositionMode.Immediate) { + sut.showSettingChatBackupIndicator().value + }.test { + assertThat(awaitItem()).isTrue() + encryptionService.emitBackupState(BackupState.ENABLED) + encryptionService.emitRecoveryState(RecoveryState.ENABLED) + assertThat(awaitItem()).isFalse() + encryptionService.emitRecoveryState(RecoveryState.DISABLED) + assertThat(awaitItem()).isTrue() + } + } + + @Test + fun `test - showSettingChatBackupIndicator is true when recoveryState is INCOMPLETE`() = runTest { + val encryptionService = FakeEncryptionService() + val sessionVerificationService = FakeSessionVerificationService() + val sut = DefaultIndicatorService( + sessionVerificationService = sessionVerificationService, + encryptionService = encryptionService, + ) + moleculeFlow(RecompositionMode.Immediate) { + sut.showSettingChatBackupIndicator().value + }.test { + assertThat(awaitItem()).isTrue() + encryptionService.emitBackupState(BackupState.ENABLED) + encryptionService.emitRecoveryState(RecoveryState.ENABLED) + assertThat(awaitItem()).isFalse() + encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE) + assertThat(awaitItem()).isTrue() + } + } +} diff --git a/libraries/indicator/test/build.gradle.kts b/libraries/indicator/test/build.gradle.kts new file mode 100644 index 0000000..a5ce0c5 --- /dev/null +++ b/libraries/indicator/test/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.indicator.test" +} + +dependencies { + implementation(projects.libraries.matrix.api) + api(projects.libraries.indicator.api) +} diff --git a/libraries/indicator/test/src/main/kotlin/io/element/android/libraries/indicator/test/FakeIndicatorService.kt b/libraries/indicator/test/src/main/kotlin/io/element/android/libraries/indicator/test/FakeIndicatorService.kt new file mode 100644 index 0000000..76cca70 --- /dev/null +++ b/libraries/indicator/test/src/main/kotlin/io/element/android/libraries/indicator/test/FakeIndicatorService.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.indicator.test + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import io.element.android.libraries.indicator.api.IndicatorService + +class FakeIndicatorService : IndicatorService { + private val showRoomListTopBarIndicatorResult: MutableState = mutableStateOf(false) + private val showSettingChatBackupIndicatorResult: MutableState = mutableStateOf(false) + + fun setShowRoomListTopBarIndicator(value: Boolean) { + showRoomListTopBarIndicatorResult.value = value + } + + fun setShowSettingChatBackupIndicator(value: Boolean) { + showSettingChatBackupIndicatorResult.value = value + } + + @Composable + override fun showRoomListTopBarIndicator(): State { + return showRoomListTopBarIndicatorResult + } + + @Composable + override fun showSettingChatBackupIndicator(): State { + return showSettingChatBackupIndicatorResult + } +} diff --git a/libraries/maplibre-compose/build.gradle.kts b/libraries/maplibre-compose/build.gradle.kts new file mode 100644 index 0000000..5552aae --- /dev/null +++ b/libraries/maplibre-compose/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.libraries.maplibre.compose" + + kotlin { + compilerOptions { + explicitApi() + } + } +} + +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 0000000..8ef02f5 --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMode.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * Copyright 2021 Google LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.maplibre.compose + +import androidx.compose.runtime.Immutable +import org.maplibre.android.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 0000000..2683de1 --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMoveStartedReason.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * Copyright 2021 Google LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.maplibre.compose + +import androidx.compose.runtime.Immutable +import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_ANIMATION +import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE +import org.maplibre.android.maps.MapLibreMap.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/org.maplibre.android.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 [org.maplibre.android.maps.MapLibreMap.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/org.maplibre.android.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 0000000..1999526 --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraPositionState.kt @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * Copyright 2021 Google LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 kotlinx.parcelize.Parcelize +import org.maplibre.android.camera.CameraPosition +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.Projection + +/** + * 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( + crossinline init: CameraPositionState.() -> Unit = {} +): CameraPositionState = rememberSaveable(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 [MapLibreMap] 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: MapLibreMap? 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: MapLibreMap?) { + synchronized(lock) { + if (this.map == null && map == null) return + if (this.map != null && map != null) { + error("CameraPositionState may only be associated with one MapLibreMap 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 = Saver( + save = { SaveableCameraPositionData(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 + @[MapLibreMapComposable ReadOnlyComposable Composable] + get() = LocalCameraPositionState.current + +@Parcelize +public data class SaveableCameraPositionData( + 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 0000000..e46dcdc --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/IconAnchor.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * Copyright 2021 Google LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.maplibre.compose + +import androidx.compose.runtime.Immutable +import org.maplibre.android.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 0000000..f8fd64c --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapApplier.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * Copyright 2021 Google LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.maplibre.compose + +import androidx.compose.runtime.AbstractApplier +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.Style +import org.maplibre.android.plugins.annotation.SymbolManager + +internal interface MapNode { + fun onAttached() {} + fun onRemoved() {} + fun onCleared() {} +} + +private object MapNodeRoot : MapNode + +internal class MapApplier( + val map: MapLibreMap, + val style: Style, + val symbolManager: SymbolManager, +) : AbstractApplier(MapNodeRoot) { + private val decorations = mutableListOf() + + 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/MapLibreMap.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMap.kt new file mode 100644 index 0000000..62c29fb --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMap.kt @@ -0,0 +1,247 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * Copyright 2021 Google LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.maplibre.compose + +import android.content.ComponentCallbacks2 +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.material3.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.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.awaitCancellation +import org.maplibre.android.MapLibre +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.MapView +import org.maplibre.android.maps.Style +import org.maplibre.android.plugins.annotation.SymbolManager +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 MapLibreMap + * @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 MapLibreMap( + styleUri: String, + modifier: Modifier = Modifier, + images: ImmutableMap = persistentMapOf(), + cameraPositionState: CameraPositionState = rememberCameraPositionState(), + uiSettings: MapUiSettings = DefaultMapUiSettings, + symbolManagerSettings: MapSymbolManagerSettings = DefaultMapSymbolManagerSettings, + locationSettings: MapLocationSettings = DefaultMapLocationSettings, + content: (@Composable @MapLibreMapComposable () -> 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 { + MapLibre.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, + uiSettings = currentUiSettings, + locationSettings = currentMapLocationSettings, + symbolManagerSettings = 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, + 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(): MapLibreMap = suspendCoroutine { continuation -> + getMapAsync { map -> + continuation.resume(map) + } +} + +private suspend inline fun MapLibreMap.awaitStyle( + context: Context, + styleUri: String, + images: ImmutableMap, +): 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): 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 MapLibreMap 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 + } + Lifecycle.Event.ON_ANY -> error("ON_ANY should never be used") + } + previousState.value = event + } + +private fun MapView.componentCallbacks(): ComponentCallbacks2 = + object : ComponentCallbacks2 { + override fun onConfigurationChanged(config: Configuration) = Unit + + @Suppress("OVERRIDE_DEPRECATION") + override fun onLowMemory() = Unit + + override fun onTrimMemory(level: Int) { + // We call the `MapView.onLowMemory` method for any memory trim level + this@componentCallbacks.onLowMemory() + } + } diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMapComposable.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMapComposable.kt new file mode 100644 index 0000000..c819dee --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMapComposable.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * Copyright 2021 Google LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 [MapLibreMapComposable]. + * + * This will produce build warnings when [MapLibreMapComposable] composable functions are used outside + * of a [MapLibreMapComposable] 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 MapLibreMapComposable 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 0000000..7fb777a --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLocationSettings.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * Copyright 2021 Google LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.maplibre.compose + +import androidx.compose.ui.graphics.Color + +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, + public val backgroundTintColor: Color = Color.Unspecified, + public val foregroundTintColor: Color = Color.Unspecified, + public val backgroundStaleTintColor: Color = Color.Unspecified, + public val foregroundStaleTintColor: Color = Color.Unspecified, + public val accuracyColor: Color = Color.Unspecified, + public val pulseEnabled: Boolean = false, + public val pulseColor: Color = Color.Unspecified +) 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 0000000..93c7b21 --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapSymbolManagerSettings.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * Copyright 2021 Google LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..edee9b4 --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUiSettings.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * Copyright 2021 Google LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..a07a596 --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUpdater.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * Copyright 2021 Google LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +@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 org.maplibre.android.location.LocationComponentActivationOptions +import org.maplibre.android.location.LocationComponentOptions +import org.maplibre.android.location.OnCameraTrackingChangedListener +import org.maplibre.android.location.engine.LocationEngineRequest +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.Style + +private const val LOCATION_REQUEST_INTERVAL = 750L + +internal class MapPropertiesNode( + val map: MapLibreMap, + style: Style, + context: Context, + cameraPositionState: CameraPositionState, + locationSettings: MapLocationSettings, +) : MapNode { + init { + map.locationComponent.activateLocationComponent( + LocationComponentActivationOptions.Builder(context, style) + .locationComponentOptions( + LocationComponentOptions.builder(context) + .backgroundTintColor(locationSettings.backgroundTintColor.toArgb()) + .foregroundTintColor(locationSettings.foregroundTintColor.toArgb()) + .backgroundStaleTintColor(locationSettings.backgroundStaleTintColor.toArgb()) + .foregroundStaleTintColor(locationSettings.foregroundStaleTintColor.toArgb()) + .accuracyColor(locationSettings.accuracyColor.toArgb()) + .pulseEnabled(locationSettings.pulseEnabled) + .pulseColor(locationSettings.pulseColor.toArgb()) + .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, + locationSettings: MapLocationSettings, + uiSettings: MapUiSettings, + symbolManagerSettings: MapSymbolManagerSettings, +) { + val mapApplier = currentComposer.applier as MapApplier + val map = mapApplier.map + val style = mapApplier.style + val symbolManager = mapApplier.symbolManager + val context = LocalContext.current + ComposeNode( + factory = { + MapPropertiesNode( + map = map, + style = style, + context = context, + cameraPositionState = cameraPositionState, + locationSettings = locationSettings, + ) + }, + update = { + set(locationSettings.locationEnabled) { map.locationComponent.isLocationComponentEnabled = it } + + set(uiSettings.compassEnabled) { map.uiSettings.isCompassEnabled = it } + set(uiSettings.rotationGesturesEnabled) { map.uiSettings.isRotateGesturesEnabled = it } + set(uiSettings.scrollGesturesEnabled) { map.uiSettings.isScrollGesturesEnabled = it } + set(uiSettings.tiltGesturesEnabled) { map.uiSettings.isTiltGesturesEnabled = it } + set(uiSettings.zoomGesturesEnabled) { map.uiSettings.isZoomGesturesEnabled = it } + set(uiSettings.logoGravity) { map.uiSettings.logoGravity = it } + set(uiSettings.attributionGravity) { map.uiSettings.attributionGravity = it } + set(uiSettings.attributionTintColor) { map.uiSettings.setAttributionTintColor(it.toArgb()) } + + set(symbolManagerSettings.iconAllowOverlap) { symbolManager.iconAllowOverlap = it } + + update(cameraPositionState) { this.cameraPositionState = it } + } + ) +} 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 0000000..e6a5c3f --- /dev/null +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/Symbol.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * Copyright 2021 Google LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 org.maplibre.android.geometry.LatLng +import org.maplibre.android.plugins.annotation.Symbol +import org.maplibre.android.plugins.annotation.SymbolManager +import org.maplibre.android.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 +) { + /** + * 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 = Saver( + save = { it.position }, + restore = { SymbolState(it) } + ) + } +} + +@Composable +public fun rememberSymbolState( + position: LatLng = LatLng(0.0, 0.0) +): SymbolState = rememberSaveable(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 +@MapLibreMapComposable +public fun Symbol( + iconId: String, + state: SymbolState = rememberSymbolState(), + iconAnchor: IconAnchor? = null, +) { + val mapApplier = currentComposer.applier as MapApplier + val symbolManager = mapApplier.symbolManager + ComposeNode( + 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 0000000..1c70006 --- /dev/null +++ b/libraries/matrix/api/build.gradle.kts @@ -0,0 +1,58 @@ +import config.BuildTimeConfig +import extension.buildConfigFieldStr +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "io.element.android.libraries.matrix.api" + + buildFeatures { + buildConfig = true + } + + defaultConfig { + buildConfigFieldStr( + name = "CLIENT_URI", + value = BuildTimeConfig.URL_WEBSITE ?: "https://element.io" + ) + buildConfigFieldStr( + name = "LOGO_URI", + value = BuildTimeConfig.URL_LOGO ?: "https://element.io/mobile-icon.png" + ) + buildConfigFieldStr( + name = "TOS_URI", + value = BuildTimeConfig.URL_ACCEPTABLE_USE ?: "https://element.io/acceptable-use-policy-terms" + ) + buildConfigFieldStr( + name = "POLICY_URI", + value = BuildTimeConfig.URL_POLICY ?: "https://element.io/privacy" + ) + } +} + +dependencies { + implementation(projects.libraries.di) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.services.analytics.api) + implementation(libs.serialization.json) + api(projects.libraries.sessionStorage.api) + implementation(libs.coroutines.core) + api(projects.libraries.architecture) + + testCommonDependencies(libs) + testImplementation(projects.libraries.matrix.test) +} diff --git a/libraries/matrix/api/src/main/AndroidManifest.xml b/libraries/matrix/api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..608f16c --- /dev/null +++ b/libraries/matrix/api/src/main/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + 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 0000000..1718f81 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api + +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.MatrixPatterns +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +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.encryption.EncryptionService +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.media.MediaPreviewService +import io.element.android.libraries.matrix.api.notification.NotificationService +import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService +import io.element.android.libraries.matrix.api.oidc.AccountManagementAction +import io.element.android.libraries.matrix.api.pusher.PushersService +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.NotJoinedRoom +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias +import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.spaces.SpaceService +import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion +import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.timeline.Timeline +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 kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import java.util.Optional + +interface MatrixClient { + val sessionId: SessionId + val deviceId: DeviceId + val userProfile: StateFlow + val roomListService: RoomListService + val spaceService: SpaceService + val syncService: SyncService + val sessionVerificationService: SessionVerificationService + val pushersService: PushersService + val notificationService: NotificationService + val notificationSettingsService: NotificationSettingsService + val encryptionService: EncryptionService + val roomDirectoryService: RoomDirectoryService + val mediaPreviewService: MediaPreviewService + val matrixMediaLoader: MatrixMediaLoader + val sessionCoroutineScope: CoroutineScope + val ignoredUsersFlow: StateFlow> + val roomMembershipObserver: RoomMembershipObserver + suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom? + suspend fun getRoom(roomId: RoomId): BaseRoom? + suspend fun findDM(userId: UserId): Result + suspend fun getJoinedRoomIds(): Result> + suspend fun ignoreUser(userId: UserId): Result + suspend fun unignoreUser(userId: UserId): Result + suspend fun createRoom(createRoomParams: CreateRoomParameters): Result + suspend fun createDM(userId: UserId): Result + suspend fun getProfile(userId: UserId): Result + suspend fun searchUsers(searchTerm: String, limit: Long): Result + suspend fun setDisplayName(displayName: String): Result + suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result + suspend fun removeAvatar(): Result + suspend fun joinRoom(roomId: RoomId): Result + suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result + suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List): Result + suspend fun getCacheSize(): Long + + /** + * Will close the client and delete the cache data. + */ + suspend fun clearCache() + + /** + * Logout the user. + * + * @param userInitiated if false, the logout came from the HS, no request will be made and the session entry will be kept in the store. + * @param ignoreSdkError if true, the SDK will ignore any error and delete the session data anyway. + */ + suspend fun logout(userInitiated: Boolean, ignoreSdkError: Boolean) + + /** + * Retrieve the user profile, will also eventually emit a new value to [userProfile]. + */ + suspend fun getUserProfile(): Result + suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result + suspend fun uploadMedia(mimeType: String, data: ByteArray): Result + + /** + * Get a room info flow for a given room ID. + * The flow will emit a new value whenever the room info is updated. + * The flow will emit Optional.empty item if the room is not found. + */ + fun getRoomInfoFlow(roomId: RoomId): Flow> + + fun isMe(userId: UserId?) = userId == sessionId + + suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result + suspend fun getRecentlyVisitedRooms(): Result> + + /** + * Resolves the given room alias to a roomID (and a list of servers), if possible. + * @param roomAlias the room alias to resolve + * @return the resolved room alias if any, an empty result if not found,or an error if the resolution failed. + * + */ + suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result> + + /** + * Enables or disables the sending queue, according to the given parameter. + * + * The sending queue automatically disables itself whenever sending an + * event with it failed (e.g. sending an event via the Timeline), + * so it's required to manually re-enable it as soon as + * connectivity is back on the device. + */ + suspend fun setAllSendQueuesEnabled(enabled: Boolean) + + /** + * Returns a flow of room IDs that have send queue being disabled. + * This flow will emit a new value whenever the send queue is disabled for a room. + */ + fun sendQueueDisabledFlow(): Flow + + /** + * Return the server name part of the current user ID, using the SDK, and if a failure occurs, + * compute it manually. + */ + fun userIdServerName(): String + + /** + * Execute generic GET requests through the SDKs internal HTTP client. + */ + suspend fun getUrl(url: String): Result + + /** + * Get a room preview for a given room ID or alias. This is especially useful for rooms that the user is not a member of, or hasn't joined yet. + */ + suspend fun getRoomPreview(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result + + /** + * Returns the currently used sliding sync version. + */ + suspend fun currentSlidingSyncVersion(): Result + + fun canDeactivateAccount(): Boolean + suspend fun deactivateAccount(password: String, eraseData: Boolean): Result + + /** + * Check if the user can report a room. + */ + suspend fun canReportRoom(): Boolean + + /** + * Return true if Livekit Rtc is supported, i.e. if Element Call is available. + */ + suspend fun isLivekitRtcSupported(): Boolean + + /** + * Returns the maximum file upload size allowed by the Matrix server. + */ + suspend fun getMaxFileUploadSize(): Result + + /** + * Returns the list of shared recent emoji reactions for this account. + */ + suspend fun getRecentEmojis(): Result> + + /** + * Adds an emoji to the list of recent emoji reactions for this account. + */ + suspend fun addRecentEmoji(emoji: String): Result + + /** + * Marks the room with the provided [roomId] as read, sending a fully read receipt for [eventId]. + * + * This method should be used with caution as providing the [eventId] ourselves can result in incorrect read receipts. + * Use [Timeline.markAsRead] instead when possible. + */ + suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result +} + +/** + * Returns a room alias from a room alias name, or null if the name is not valid. + * @param name the room alias name ie. the local part of the room alias. + */ +fun MatrixClient.roomAliasFromName(name: String): RoomAlias? { + return name.takeIf { it.isNotEmpty() } + ?.let { "#$it:${userIdServerName()}" } + ?.takeIf { MatrixPatterns.isRoomAlias(it) } + ?.let { tryOrNull { RoomAlias(it) } } +} 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 0000000..54f7084 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientProvider.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 + + /** + * Can be used to retrieve an existing [MatrixClient] with the given [SessionId]. + * @param sessionId the [SessionId] of the [MatrixClient] to retrieve. + * @return the [MatrixClient] if it exists. + */ + fun getOrNull(sessionId: SessionId): MatrixClient? +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SdkMetadata.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SdkMetadata.kt new file mode 100644 index 0000000..16f3480 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SdkMetadata.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api + +interface SdkMetadata { + val sdkGitSha: String +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/ViewRoomExt.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/ViewRoomExt.kt new file mode 100644 index 0000000..ac3b0c8 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/ViewRoomExt.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.analytics + +import im.vector.app.features.analytics.plan.ViewRoom +import io.element.android.libraries.matrix.api.room.BaseRoom + +fun BaseRoom.toAnalyticsViewRoom( + trigger: ViewRoom.Trigger? = null, + selectedSpace: BaseRoom? = null, + viaKeyboard: Boolean? = null, +): ViewRoom { + val activeSpace = selectedSpace?.toActiveSpace() ?: ViewRoom.ActiveSpace.Home + + return ViewRoom( + isDM = info().isDirect, + isSpace = info().isSpace, + trigger = trigger, + activeSpace = activeSpace, + viaKeyboard = viaKeyboard + ) +} + +private fun BaseRoom.toActiveSpace(): ViewRoom.ActiveSpace { + return if (info().isPublic == true) ViewRoom.ActiveSpace.Public else ViewRoom.ActiveSpace.Private +} 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 0000000..b6053cc --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/AuthErrorCode.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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() + .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 0000000..c50ec09 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/AuthenticationException.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.auth + +sealed class AuthenticationException(message: String?) : Exception(message) { + data class AccountAlreadyLoggedIn( + val userId: String, + ) : AuthenticationException(null) + + class InvalidServerName(message: String?) : AuthenticationException(message) + class SlidingSyncVersion(message: String?) : AuthenticationException(message) + class ServerUnreachable(message: String?) : AuthenticationException(message) + class Oidc(message: String?) : AuthenticationException(message) + class Generic(message: String?) : AuthenticationException(message) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/HomeServerLoginCompatibilityChecker.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/HomeServerLoginCompatibilityChecker.kt new file mode 100644 index 0000000..dcf43a8 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/HomeServerLoginCompatibilityChecker.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.auth + +/** + * Checks the homeserver's compatibility with Element X. + */ +interface HomeServerLoginCompatibilityChecker { + /** + * Performs the compatibility check given the homeserver's [url]. + * @return a `true` value if the homeserver is compatible, `false` if not, or a failure result if the check unexpectedly failed. + */ + suspend fun check(url: String): Result +} 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 0000000..1c574ad --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.auth + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.auth.external.ExternalSession +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import io.element.android.libraries.matrix.api.core.SessionId + +interface MatrixAuthenticationService { + /** + * Restore a session from a [sessionId]. + * Do not restore anything it the access token is not valid anymore. + * Generally this method should not be used directly, prefer using [MatrixClientProvider.getOrRestore] instead. + */ + suspend fun restoreSession(sessionId: SessionId): Result + + /** + * Set the homeserver to use for authentication, and return its details. + */ + suspend fun setHomeserver(homeserver: String): Result + + suspend fun login(username: String, password: String): Result + + /** + * Import a session that was created using another client, for instance Element Web. + */ + suspend fun importCreatedSession(externalSession: ExternalSession): Result + + /* + * OIDC part. + */ + + /** + * Get the Oidc url to display to the user. + */ + suspend fun getOidcUrl( + prompt: OidcPrompt, + loginHint: String?, + ): Result + + /** + * Cancel Oidc login sequence. + */ + suspend fun cancelOidcLogin(): Result + + /** + * Attempt to login using the [callbackUrl] provided by the Oidc page. + */ + suspend fun loginWithOidc(callbackUrl: String): Result + + suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit): Result + + /** Listen to new Matrix clients being created on authentication. */ + fun listenToNewMatrixClients(lambda: (MatrixClient) -> Unit) +} 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 0000000..aa5ed9a --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixHomeServerDetails.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.auth + +data class MatrixHomeServerDetails( + val url: String, + val supportsPasswordLogin: Boolean, + val supportsOidcLogin: Boolean, +) { + val isSupported = supportsPasswordLogin || supportsOidcLogin +} 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 0000000..ee8b7ec --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OidcConfig.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.auth + +import io.element.android.libraries.matrix.api.BuildConfig + +object OidcConfig { + const val CLIENT_URI = BuildConfig.CLIENT_URI + + // Note: host must match with the host of CLIENT_URI + const val LOGO_URI = BuildConfig.LOGO_URI + + // Note: host must match with the host of CLIENT_URI + const val TOS_URI = BuildConfig.TOS_URI + + // Note: host must match with the host of CLIENT_URI + const val POLICY_URI = BuildConfig.POLICY_URI + + // Some homeservers/auth issuers don't support dynamic client registration, and have to be registered manually + val STATIC_REGISTRATIONS = mapOf( + "https://id.thirdroom.io/realms/thirdroom" to "elementx", + ) +} 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 0000000..c4fb87e --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OidcDetails.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/auth/OidcPrompt.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OidcPrompt.kt new file mode 100644 index 0000000..8ddad9f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OidcPrompt.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.auth + +sealed interface OidcPrompt { + /** + * The Authorization Server should prompt the End-User for + * reauthentication. + */ + data object Login : OidcPrompt + + /** + * The Authorization Server should prompt the End-User to create a user + * account. + * + * Defined in [Initiating User Registration via OpenID Connect](https://openid.net/specs/openid-connect-prompt-create-1_0.html). + */ + data object Create : OidcPrompt + + /** + * An unknown value. + */ + data class Unknown(val value: String) : OidcPrompt +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OidcRedirectUrlProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OidcRedirectUrlProvider.kt new file mode 100644 index 0000000..ad4d862 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OidcRedirectUrlProvider.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.auth + +interface OidcRedirectUrlProvider { + fun provide(): String +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/external/ExternalSession.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/external/ExternalSession.kt new file mode 100644 index 0000000..1241f5f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/external/ExternalSession.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.auth.external + +/*** + * Represents a session data of a session created by another client. + */ +data class ExternalSession( + val userId: String, + val deviceId: String, + val accessToken: String, + val refreshToken: String?, + val homeserverUrl: String, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/MatrixQrCodeLoginData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/MatrixQrCodeLoginData.kt new file mode 100644 index 0000000..413f17a --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/MatrixQrCodeLoginData.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.auth.qrlogin + +interface MatrixQrCodeLoginData { + fun serverName(): String? +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/MatrixQrCodeLoginDataFactory.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/MatrixQrCodeLoginDataFactory.kt new file mode 100644 index 0000000..b0e3089 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/MatrixQrCodeLoginDataFactory.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.auth.qrlogin + +interface MatrixQrCodeLoginDataFactory { + fun parseQrCodeData(data: ByteArray): Result +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrCodeDecodeException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrCodeDecodeException.kt new file mode 100644 index 0000000..438ee4e --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrCodeDecodeException.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.auth.qrlogin + +sealed class QrCodeDecodeException(message: String) : Exception(message) { + class Crypto( + message: String, +// val reason: Reason + ) : QrCodeDecodeException(message) { + // We plan to restore it in the future when UniFFi can process them +// enum class Reason { +// NOT_ENOUGH_DATA, +// NOT_UTF8, +// URL_PARSE, +// INVALID_MODE, +// INVALID_VERSION, +// BASE64, +// INVALID_PREFIX +// } + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrCodeLoginStep.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrCodeLoginStep.kt new file mode 100644 index 0000000..56b3dc6 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrCodeLoginStep.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.auth.qrlogin + +sealed interface QrCodeLoginStep { + data object Uninitialized : QrCodeLoginStep + data class EstablishingSecureChannel(val checkCode: String) : QrCodeLoginStep + data object Starting : QrCodeLoginStep + data class WaitingForToken(val userCode: String) : QrCodeLoginStep + data object SyncingSecrets : QrCodeLoginStep + data class Failed(val error: QrLoginException) : QrCodeLoginStep + data object Finished : QrCodeLoginStep +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrLoginException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrLoginException.kt new file mode 100644 index 0000000..c81437e --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrLoginException.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.auth.qrlogin + +sealed class QrLoginException : Exception() { + data object Cancelled : QrLoginException() + data object ConnectionInsecure : QrLoginException() + data object Declined : QrLoginException() + data object Expired : QrLoginException() + data object LinkingNotSupported : QrLoginException() + data object OidcMetadataInvalid : QrLoginException() + data object SlidingSyncNotAvailable : QrLoginException() + data object OtherDeviceNotSignedIn : QrLoginException() + data object CheckCodeAlreadySent : QrLoginException() + data object CheckCodeCannotBeSent : QrLoginException() + data object Unknown : QrLoginException() + data object NotFound : QrLoginException() +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/DeviceId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/DeviceId.kt new file mode 100644 index 0000000..1fd8485 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/DeviceId.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.core + +import java.io.Serializable + +@JvmInline +value class DeviceId(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/EventId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/EventId.kt new file mode 100644 index 0000000..0a2fe6e --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/EventId.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.core + +import io.element.android.libraries.androidutils.metadata.isInDebug +import java.io.Serializable + +@JvmInline +value class EventId(val value: String) : Serializable { + init { + if (isInDebug && !MatrixPatterns.isEventId(value)) { + error("`$value` is not a valid event id.\nExample event id: `\$Rqnc-F-dvnEYJTyHq_iKxU2bZ1CI92-kuZq3a5lr5Zg`.") + } + } + + override fun toString(): String = value +} + +fun EventId.toThreadId(): ThreadId = ThreadId(value) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/FlowId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/FlowId.kt new file mode 100644 index 0000000..70564be --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/FlowId.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.core + +import java.io.Serializable + +@JvmInline +value class FlowId(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/MatrixPatterns.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt new file mode 100644 index 0000000..a933e50 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.core + +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser + +/** + * 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-Za-z0-9.-]+(:[0-9]{2,5})?" + + private const val BASE_64_ALPHABET = "[0-9A-Za-z/\\+=]+" + private const val BASE_64_URL_SAFE_ALPHABET = "[0-9A-Za-z/\\-_]+" + + // regex pattern to find matrix user ids in a string. + // See https://matrix.org/docs/spec/appendices#historical-user-ids + // Sadly, we need to relax the regex pattern a bit as there already exist some ids that don't match the spec. + // Note: local part can be empty + private const val MATRIX_USER_IDENTIFIER_REGEX = "^@\\S*?$DOMAIN_REGEX$" + private val PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER = MATRIX_USER_IDENTIFIER_REGEX.toRegex() + + // !localpart:domain" used in most room versions prior to MSC4291 + // Note: roomId can be arbitrary strings, including space and new line char + private const val MATRIX_ROOM_IDENTIFIER_REGEX = "^!.+$DOMAIN_REGEX$" + private val PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER = MATRIX_ROOM_IDENTIFIER_REGEX.toRegex(RegexOption.DOT_MATCHES_ALL) + + // "!event_id_base_64" used in room versions post MSC4291 + private const val MATRIX_ROOM_IDENTIFIER_DOMAINLESS_REGEX = "!$BASE_64_URL_SAFE_ALPHABET" + private val PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER_DOMAINLESS = MATRIX_ROOM_IDENTIFIER_DOMAINLESS_REGEX.toRegex() + + // regex pattern to match room aliases. + private const val MATRIX_ROOM_ALIAS_REGEX = "^#\\S+$DOMAIN_REGEX$" + private val PATTERN_CONTAIN_MATRIX_ALIAS = MATRIX_ROOM_ALIAS_REGEX.toRegex(RegexOption.IGNORE_CASE) + + // regex pattern to match event ids. + // Sadly, we need to relax the regex pattern a bit as there already exist some ids that don't match the spec. + // v1 and v2: arbitrary string + domain + private const val MATRIX_EVENT_IDENTIFIER_REGEX = "^\\$.+$DOMAIN_REGEX$" + private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER = MATRIX_EVENT_IDENTIFIER_REGEX.toRegex() + + // v3: base64 + private const val MATRIX_EVENT_IDENTIFIER_V3_REGEX = "\\$$BASE_64_ALPHABET" + private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V3 = MATRIX_EVENT_IDENTIFIER_V3_REGEX.toRegex() + + // v4: url-safe base64 + private const val MATRIX_EVENT_IDENTIFIER_V4_REGEX = "\\$$BASE_64_URL_SAFE_ALPHABET" + private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4 = MATRIX_EVENT_IDENTIFIER_V4_REGEX.toRegex() + + private const val MAX_IDENTIFIER_LENGTH = 255 + + /** + * 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.length <= MAX_IDENTIFIER_LENGTH && + str matches PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER + } + + /** + * 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.length <= MAX_IDENTIFIER_LENGTH && + (str matches PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER_DOMAINLESS || + 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.length <= MAX_IDENTIFIER_LENGTH && + 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.length <= MAX_IDENTIFIER_LENGTH && + (str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4 || + str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V3 || + str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER) + } + + /** + * 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) + + /** + * Finds existing ids or aliases in a [CharSequence]. + * Note not all cases are implemented. + */ + fun findPatterns(text: CharSequence, permalinkParser: PermalinkParser): List { + val rawTextMatches = "\\S+$DOMAIN_REGEX".toRegex(RegexOption.IGNORE_CASE).findAll(text) + val urlMatches = "\\[\\S+\\]\\((\\S+)\\)".toRegex(RegexOption.IGNORE_CASE).findAll(text) + val atRoomMatches = Regex("@room").findAll(text) + return buildList { + for (match in rawTextMatches) { + // Match existing id and alias patterns in the text + val type = when { + isUserId(match.value) -> MatrixPatternType.USER_ID + isRoomId(match.value) -> MatrixPatternType.ROOM_ID + isRoomAlias(match.value) -> MatrixPatternType.ROOM_ALIAS + isEventId(match.value) -> MatrixPatternType.EVENT_ID + else -> null + } + if (type != null) { + add(MatrixPatternResult(type, match.value, match.range.first, match.range.last + 1)) + } + } + for (match in urlMatches) { + // Extract the link and check if it's a valid permalink + val urlMatch = match.groupValues[1] + when (val permalink = permalinkParser.parse(urlMatch)) { + is PermalinkData.UserLink -> { + add(MatrixPatternResult(MatrixPatternType.USER_ID, permalink.userId.value, match.range.first, match.range.last + 1)) + } + is PermalinkData.RoomLink -> { + when (permalink.roomIdOrAlias) { + is RoomIdOrAlias.Alias -> MatrixPatternType.ROOM_ALIAS + is RoomIdOrAlias.Id -> if (permalink.eventId == null) MatrixPatternType.ROOM_ID else null + }?.let { type -> + add(MatrixPatternResult(type, permalink.roomIdOrAlias.identifier, match.range.first, match.range.last + 1)) + } + } + else -> Unit + } + } + for (match in atRoomMatches) { + // Special case for `@room` mentions + add(MatrixPatternResult(MatrixPatternType.AT_ROOM, match.value, match.range.first, match.range.last + 1)) + } + } + } +} + +enum class MatrixPatternType { + USER_ID, + ROOM_ID, + ROOM_ALIAS, + EVENT_ID, + AT_ROOM +} + +data class MatrixPatternResult(val type: MatrixPatternType, val value: String, val start: Int, val end: Int) 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 0000000..a7abc0f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ProgressCallback.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/RoomAlias.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomAlias.kt new file mode 100644 index 0000000..c4a5c40 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomAlias.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.core + +import io.element.android.libraries.androidutils.metadata.isInDebug +import java.io.Serializable + +@JvmInline +value class RoomAlias(val value: String) : Serializable { + init { + if (isInDebug && !MatrixPatterns.isRoomAlias(value)) { + error("`$value` is not a valid room alias.\n Example room alias: `#room_alias:domain`.") + } + } + + override fun toString(): String = value +} 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 0000000..1758563 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.core + +import io.element.android.libraries.androidutils.metadata.isInDebug +import java.io.Serializable + +@JvmInline +value class RoomId(val value: String) : Serializable { + init { + if (isInDebug && !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/RoomIdOrAlias.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomIdOrAlias.kt new file mode 100644 index 0000000..4ac480e --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomIdOrAlias.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.core + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import kotlinx.parcelize.Parcelize + +@Immutable +sealed interface RoomIdOrAlias : Parcelable { + @Parcelize + @JvmInline + value class Id(val roomId: RoomId) : RoomIdOrAlias + + @Parcelize + @JvmInline + value class Alias(val roomAlias: RoomAlias) : RoomIdOrAlias + + val identifier: String + get() = when (this) { + is Id -> roomId.value + is Alias -> roomAlias.value + } +} + +fun RoomId.toRoomIdOrAlias() = RoomIdOrAlias.Id(this) +fun RoomAlias.toRoomIdOrAlias() = RoomIdOrAlias.Alias(this) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SendHandle.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SendHandle.kt new file mode 100644 index 0000000..f3b8904 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SendHandle.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.core + +fun interface SendHandle { + suspend fun retry(): Result +} 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 0000000..7fcfcd9 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SessionId.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.core + +/** + * The [UserId] of the currently logged in user. + */ +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 0000000..6acf2e4 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SpaceId.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.core + +typealias SpaceId = RoomId + +/** + * 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 0000000..5b42213 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ThreadId.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.core + +import io.element.android.libraries.androidutils.metadata.isInDebug +import java.io.Serializable + +@JvmInline +value class ThreadId(val value: String) : Serializable { + init { + if (isInDebug && !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 +} + +fun ThreadId.asEventId(): EventId = EventId(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 0000000..b548dcc --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/TransactionId.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/UniqueId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/UniqueId.kt new file mode 100644 index 0000000..e2c84cc --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/UniqueId.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.core + +import java.io.Serializable + +@JvmInline +value class UniqueId(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 0000000..b00fdde --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/UserId.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.core + +import io.element.android.libraries.androidutils.metadata.isInDebug +import java.io.Serializable + +/** + * A [String] holding a valid Matrix user ID. + * + * https://spec.matrix.org/v1.8/appendices/#user-identifiers + */ +@JvmInline +value class UserId(val value: String) : Serializable { + init { + if (isInDebug && !MatrixPatterns.isUserId(value)) { + error("`$value` is not a valid user id.\nExample user id: `@name:domain`.") + } + } + + override fun toString(): String = value + + val extractedDisplayName: String + get() = value + .removePrefix("@") + .substringBefore(":") + + val domainName: String? + get() = value.substringAfter(":").takeIf { it.isNotEmpty() } +} 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 0000000..a0a0bde --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/CreateRoomParameters.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.createroom + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility +import java.util.Optional + +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? = null, + val avatar: String? = null, + val joinRuleOverride: JoinRule? = null, + val historyVisibilityOverride: RoomHistoryVisibility? = null, + val roomAliasName: Optional = Optional.empty(), +) 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 0000000..83fa25c --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/RoomPreset.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +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/encryption/BackupState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/BackupState.kt new file mode 100644 index 0000000..9c21ce0 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/BackupState.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.encryption + +enum class BackupState { + /** + * Special value, when the SDK is waiting for the first sync to be done. + */ + WAITING_FOR_SYNC, + + /** + * Values mapped from the SDK. + */ + UNKNOWN, + CREATING, + ENABLING, + RESUMING, + ENABLED, + DOWNLOADING, + DISABLING +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/BackupUploadState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/BackupUploadState.kt new file mode 100644 index 0000000..2a90cdb --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/BackupUploadState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.encryption + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface BackupUploadState { + data object Unknown : BackupUploadState + + data object Waiting : BackupUploadState + + data class Uploading( + val backedUpCount: Int, + val totalCount: Int, + ) : BackupUploadState + + data object Done : BackupUploadState + + data object Error : BackupUploadState + + data class SteadyException(val exception: SteadyStateException) : BackupUploadState +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EnableRecoveryProgress.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EnableRecoveryProgress.kt new file mode 100644 index 0000000..3fc2769 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EnableRecoveryProgress.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.encryption + +sealed interface EnableRecoveryProgress { + data object Starting : EnableRecoveryProgress + data object CreatingBackup : EnableRecoveryProgress + data object CreatingRecoveryKey : EnableRecoveryProgress + data class BackingUp(val backedUpCount: Int, val totalCount: Int) : EnableRecoveryProgress + data object RoomKeyUploadError : EnableRecoveryProgress + data class Done(val recoveryKey: String) : EnableRecoveryProgress +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt new file mode 100644 index 0000000..aefad51 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.encryption + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface EncryptionService { + val backupStateStateFlow: StateFlow + val recoveryStateStateFlow: StateFlow + val enableRecoveryProgressStateFlow: StateFlow + val isLastDevice: StateFlow + val hasDevicesToVerifyAgainst: StateFlow> + + suspend fun enableBackups(): Result + + /** + * Enable recovery. Observe enableProgressStateFlow to get progress and recovery key. + */ + suspend fun enableRecovery(waitForBackupsToUpload: Boolean): Result + + /** + * Change the recovery and return the new recovery key. + */ + suspend fun resetRecoveryKey(): Result + + suspend fun disableRecovery(): Result + + suspend fun doesBackupExistOnServer(): Result + + /** + * Note: accept both recoveryKey and passphrase. + */ + suspend fun recover(recoveryKey: String): Result + + /** + * Wait for backup upload steady state. + */ + fun waitForBackupUploadSteadyState(): Flow + + /** + * Get the public curve25519 key of our own device in base64. This is usually what is + * called the identity key of the device. + */ + suspend fun deviceCurve25519(): String? + + /** + * Get the public ed25519 key of our own device. This is usually what is + * called the fingerprint of the device. + */ + suspend fun deviceEd25519(): String? + + /** + * Starts the identity reset process. This will return a handle that can be used to reset the identity. + */ + suspend fun startIdentityReset(): Result + + /** + * Remember this identity, ensuring it does not result in a pin violation. + */ + suspend fun pinUserIdentity(userId: UserId): Result + + /** + * Withdraw the verification for that user (also pin the identity). + * + * Useful when a user that was verified is not anymore, but it is not + * possible to re-verify immediately. This allows to restore communication by reverting the + * user trust from verified to TOFU verified. + */ + suspend fun withdrawVerification(userId: UserId): Result + + /** + * Get the identity state of a user, if known. + * @param userId the user id to get the identity for. + * @param fallbackToServer whether to fallback to fetching the identity from the server if not known locally. Defaults to true. + */ + suspend fun getUserIdentity(userId: UserId, fallbackToServer: Boolean = true): Result +} + +/** + * A handle to reset the user's identity. + */ +sealed interface IdentityResetHandle { + /** + * Cancel the reset process and drops the existing handle in the SDK. + */ + suspend fun cancel() +} + +/** + * A handle to reset the user's identity with a password login type. + */ +interface IdentityPasswordResetHandle : IdentityResetHandle { + /** + * Reset the password of the user. + * + * This method will block the coroutine it's running on and keep polling indefinitely until either the coroutine is cancelled, the [cancel] method is + * called, or the identity is reset. + * + * @param password the current password, which will be validated before the process takes place. + */ + suspend fun resetPassword(password: String): Result +} + +/** + * A handle to reset the user's identity with an OIDC login type. + */ +interface IdentityOidcResetHandle : IdentityResetHandle { + /** + * The URL to open in a webview/custom tab to reset the identity. + */ + val url: String + + /** + * Reset the identity using the OIDC flow. + * + * This method will block the coroutine it's running on and keep polling indefinitely until either the coroutine is cancelled, the [cancel] method is + * called, or the identity is reset. + */ + suspend fun resetOidc(): Result +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/RecoveryException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/RecoveryException.kt new file mode 100644 index 0000000..4b580f2 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/RecoveryException.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.encryption + +import io.element.android.libraries.matrix.api.exception.ClientException + +sealed class RecoveryException(message: String) : Exception(message) { + class SecretStorage(message: String) : RecoveryException(message) + class Import(message: String) : RecoveryException(message) + data object BackupExistsOnServer : RecoveryException("BackupExistsOnServer") + data class Client(val exception: ClientException) : RecoveryException(exception.message ?: "Unknown error") +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/RecoveryState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/RecoveryState.kt new file mode 100644 index 0000000..eee547c --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/RecoveryState.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.encryption + +enum class RecoveryState { + /** + * Special value, when the SDK is waiting for the first sync to be done. + */ + WAITING_FOR_SYNC, + + /** + * Values mapped from the SDK. + */ + UNKNOWN, + ENABLED, + DISABLED, + INCOMPLETE, +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/SteadyStateException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/SteadyStateException.kt new file mode 100644 index 0000000..6410633 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/SteadyStateException.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.encryption + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface SteadyStateException { + /** + * The backup can be deleted. + */ + data class BackupDisabled(val message: String) : SteadyStateException + + /** + * The task waiting for notifications coming from the upload task can fall behind so much that it lost some notifications. + */ + data class Lagged(val message: String) : SteadyStateException + + /** + * The request(s) to upload the room keys failed. + */ + data class Connection(val message: String) : SteadyStateException +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/identity/IdentityState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/identity/IdentityState.kt new file mode 100644 index 0000000..f6f35d8 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/identity/IdentityState.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.encryption.identity + +enum class IdentityState { + /** The user is verified with us. */ + Verified, + + /** + * Either this is the first identity we have seen for this user, or the + * user has acknowledged a change of identity explicitly e.g. by + * clicking OK on a notification. + */ + Pinned, + + /** + * The user's identity has changed since it was pinned. The user should be + * notified about this and given the opportunity to acknowledge the + * change, which will make the new identity pinned. + */ + PinViolation, + + /** + * The user's identity has changed, and before that it was verified. This + * is a serious problem. The user can either verify again to make this + * identity verified, or withdraw verification to make it pinned. + */ + VerificationViolation, +} + +fun IdentityState.isAViolation() = this == IdentityState.PinViolation || this == IdentityState.VerificationViolation diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/identity/IdentityStateChange.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/identity/IdentityStateChange.kt new file mode 100644 index 0000000..f269e5f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/identity/IdentityStateChange.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.encryption.identity + +import io.element.android.libraries.matrix.api.core.UserId + +data class IdentityStateChange( + val userId: UserId, + val identityState: IdentityState, +) 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 0000000..35acec4 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.exception + +sealed class ClientException(message: String, val details: String?) : Exception(message) { + class Generic(message: String, details: String?) : ClientException(message, details) + class MatrixApi(val kind: ErrorKind, val code: String, message: String, details: String?) : ClientException(message, details) + class Other(message: String) : ClientException(message, null) +} + +fun ClientException.isNetworkError(): Boolean { + return this is ClientException.Generic && message?.contains("error sending request for url", ignoreCase = true) == true +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ErrorKind.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ErrorKind.kt new file mode 100644 index 0000000..9d37dde --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ErrorKind.kt @@ -0,0 +1,458 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.exception + +sealed interface ErrorKind { + /** + * M_BAD_ALIAS + * + * One or more room aliases within the m.room.canonical_alias event do + * not point to the room ID for which the state event is to be sent to. + * + * room aliases: https://spec.matrix.org/latest/client-server-api/#room-aliases + */ + data object BadAlias : ErrorKind + + /** + * M_BAD_JSON + * + * The request contained valid JSON, but it was malformed in some way, e.g. + * missing required keys, invalid values for keys. + */ + data object BadJson : ErrorKind + + /** + * M_BAD_STATE + * + * The state change requested cannot be performed, such as attempting to + * unban a user who is not banned. + */ + data object BadState : ErrorKind + + /** + * M_BAD_STATUS + * + * The application service returned a bad status. + */ + data class BadStatus( + /** + * The HTTP status code of the response. + */ + val status: Int?, + /** + * The body of the response. + */ + val body: String? + ) : ErrorKind + + /** + * M_CANNOT_LEAVE_SERVER_NOTICE_ROOM + * + * The user is unable to reject an invite to join the server notices + * room. + * + * server notices: https://spec.matrix.org/latest/client-server-api/#server-notices + */ + data object CannotLeaveServerNoticeRoom : ErrorKind + + /** + * M_CANNOT_OVERWRITE_MEDIA + * + * The create_content_async endpoint was called with a media ID that + * already has content. + * + */ + data object CannotOverwriteMedia : ErrorKind + + /** + * M_CAPTCHA_INVALID + * + * The Captcha provided did not match what was expected. + */ + data object CaptchaInvalid : ErrorKind + + /** + * M_CAPTCHA_NEEDED + * + * A Captcha is required to complete the request. + */ + data object CaptchaNeeded : ErrorKind + + /** + * M_CONNECTION_FAILED + * + * The connection to the application service failed. + */ + data object ConnectionFailed : ErrorKind + + /** + * M_CONNECTION_TIMEOUT + * + * The connection to the application service timed out. + */ + data object ConnectionTimeout : ErrorKind + + /** + * M_DUPLICATE_ANNOTATION + * + * The request is an attempt to send a duplicate annotation. + * + * duplicate annotation: https://spec.matrix.org/latest/client-server-api/#avoiding-duplicate-annotations + */ + data object DuplicateAnnotation : ErrorKind + + /** + * M_EXCLUSIVE + * + * The resource being requested is reserved by an application service, or + * the application service making the request has not created the + * resource. + */ + data object Exclusive : ErrorKind + + /** + * M_FORBIDDEN + * + * Forbidden access, e.g. joining a room without permission, failed login. + */ + data object Forbidden : ErrorKind + + /** + * M_GUEST_ACCESS_FORBIDDEN + * + * The room or resource does not permit guests to access it. + * + * guests: https://spec.matrix.org/latest/client-server-api/#guest-access + */ + data object GuestAccessForbidden : ErrorKind + + /** + * M_INCOMPATIBLE_ROOM_VERSION + * + * The client attempted to join a room that has a version the server does + * not support. + */ + data class IncompatibleRoomVersion( + /** + * The room's version. + */ + val roomVersion: String + ) : ErrorKind + + /** + * M_INVALID_PARAM + * + * A parameter that was specified has the wrong value. For example, the + * server expected an integer and instead received a string. + */ + data object InvalidParam : ErrorKind + + /** + * M_INVALID_ROOM_STATE + * + * The initial state implied by the parameters to the create_room + * request is invalid, e.g. the user's power_level is set below that + * necessary to set the room name. + * + */ + data object InvalidRoomState : ErrorKind + + /** + * M_INVALID_USERNAME + * + * The desired user name is not valid. + */ + data object InvalidUsername : ErrorKind + + /** + * M_LIMIT_EXCEEDED + * + * The request has been refused due to rate limiting: too many requests + * have been sent in a short period of time. + * + * rate limiting: https://spec.matrix.org/latest/client-server-api/#rate-limiting + */ + data class LimitExceeded( + /** + * How long a client should wait before they can try again. + */ + val retryAfterMs: Long? + ) : ErrorKind + + /** + * M_MISSING_PARAM + * + * A required parameter was missing from the request. + */ + data object MissingParam : ErrorKind + + /** + * M_MISSING_TOKEN + * + * No access token was specified for the request, but one is required. + * + * access token: https://spec.matrix.org/latest/client-server-api/#client-authentication + */ + data object MissingToken : ErrorKind + + /** + * M_NOT_FOUND + * + * No resource was found for this request. + */ + data object NotFound : ErrorKind + + /** + * M_NOT_JSON + * + * The request did not contain valid JSON. + */ + data object NotJson : ErrorKind + + /** + * M_NOT_YET_UPLOADED + * + * An mxc URI generated was used and the content is not yet available. + * + */ + data object NotYetUploaded : ErrorKind + + /** + * M_RESOURCE_LIMIT_EXCEEDED + * + * The request cannot be completed because the homeserver has reached a + * resource limit imposed on it. For example, a homeserver held in a + * shared hosting environment may reach a resource limit if it starts + * using too much memory or disk space. + */ + data class ResourceLimitExceeded( + /** + * A URI giving a contact method for the server administrator. + */ + val adminContact: String + ) : ErrorKind + + /** + * M_ROOM_IN_USE + * + * The room alias specified in the request is already taken. + * + * room alias: https://spec.matrix.org/latest/client-server-api/#room-aliases + */ + data object RoomInUse : ErrorKind + + /** + * M_SERVER_NOT_TRUSTED + * + * The client's request used a third-party server, e.g. identity server, + * that this server does not trust. + */ + data object ServerNotTrusted : ErrorKind + + /** + * M_THREEPID_AUTH_FAILED + * + * Authentication could not be performed on the third-party identifier. + * + * third-party identifier: https://spec.matrix.org/latest/client-server-api/#adding-account-administrative-contact-information + */ + data object ThreepidAuthFailed : ErrorKind + + /** + * M_THREEPID_DENIED + * + * The server does not permit this third-party identifier. This may + * happen if the server only permits, for example, email addresses from + * a particular domain. + * + * third-party identifier: https://spec.matrix.org/latest/client-server-api/#adding-account-administrative-contact-information + */ + data object ThreepidDenied : ErrorKind + + /** + * M_THREEPID_IN_USE + * + * The third-party identifier is already in use by another user. + * + * third-party identifier: https://spec.matrix.org/latest/client-server-api/#adding-account-administrative-contact-information + */ + data object ThreepidInUse : ErrorKind + + /** + * M_THREEPID_MEDIUM_NOT_SUPPORTED + * + * The homeserver does not support adding a third-party identifier of the + * given medium. + * + * third-party identifier: https://spec.matrix.org/latest/client-server-api/#adding-account-administrative-contact-information + */ + data object ThreepidMediumNotSupported : ErrorKind + + /** + * M_THREEPID_NOT_FOUND + * + * No account matching the given third-party identifier could be found. + * + * third-party identifier: https://spec.matrix.org/latest/client-server-api/#adding-account-administrative-contact-information + */ + data object ThreepidNotFound : ErrorKind + + /** + * M_TOO_LARGE + * + * The request or entity was too large. + */ + data object TooLarge : ErrorKind + + /** + * M_UNABLE_TO_AUTHORISE_JOIN + * + * The room is restricted and none of the conditions can be validated by + * the homeserver. This can happen if the homeserver does not know + * about any of the rooms listed as conditions, for example. + * + * restricted: https://spec.matrix.org/latest/client-server-api/#restricted-rooms + */ + data object UnableToAuthorizeJoin : ErrorKind + + /** + * M_UNABLE_TO_GRANT_JOIN + * + * A different server should be attempted for the join. This is typically + * because the resident server can see that the joining user satisfies + * one or more conditions, such as in the case of restricted rooms, + * but the resident server would be unable to meet the authorization + * rules. + * + * restricted rooms: https://spec.matrix.org/latest/client-server-api/#restricted-rooms + */ + data object UnableToGrantJoin : ErrorKind + + /** + * M_UNAUTHORIZED + * + * The request was not correctly authorized. Usually due to login failures. + */ + data object Unauthorized : ErrorKind + + /** + * M_UNKNOWN + * + * An unknown error has occurred. + */ + data object Unknown : ErrorKind + + /** + * M_UNKNOWN_TOKEN + * + * The access or refresh token specified was not recognized. + * + * access or refresh token: https://spec.matrix.org/latest/client-server-api/#client-authentication + */ + data class UnknownToken( + /** + * If this is true, the client is in a "soft logout" state, i.e. + * the server requires re-authentication but the session is not + * invalidated. The client can acquire a new access token by + * specifying the device ID it is already using to the login API. + * + * soft logout: https://spec.matrix.org/latest/client-server-api/#soft-logout + */ + val softLogout: Boolean + ) : ErrorKind + + /** + * M_UNRECOGNIZED + * + * The server did not understand the request. + * + * This is expected to be returned with a 404 HTTP status code if the + * endpoint is not implemented or a 405 HTTP status code if the + * endpoint is implemented, but the incorrect HTTP method is used. + */ + data object Unrecognized : ErrorKind + + /** + * M_UNSUPPORTED_ROOM_VERSION + * + * The request to create_room used a room version that the server does + * not support. + * + */ + data object UnsupportedRoomVersion : ErrorKind + + /** + * M_URL_NOT_SET + * + * The application service doesn't have a URL configured. + */ + data object UrlNotSet : ErrorKind + + /** + * M_USER_DEACTIVATED + * + * The user ID associated with the request has been deactivated. + */ + data object UserDeactivated : ErrorKind + + /** + * M_USER_IN_USE + * + * The desired user ID is already taken. + */ + data object UserInUse : ErrorKind + + /** + * M_USER_LOCKED + * + * The account has been locked and cannot be used at this time. + * + * locked: https://spec.matrix.org/latest/client-server-api/#account-locking + */ + data object UserLocked : ErrorKind + + /** + * M_USER_SUSPENDED + * + * The account has been suspended and can only be used for limited + * actions at this time. + * + * suspended: https://spec.matrix.org/latest/client-server-api/#account-suspension + */ + data object UserSuspended : ErrorKind + + /** + * M_WEAK_PASSWORD + * + * The password was rejected by the server for being too weak. + * + * rejected: https://spec.matrix.org/latest/client-server-api/#notes-on-password-management + */ + data object WeakPassword : ErrorKind + + /** + * M_WRONG_ROOM_KEYS_VERSION + * + * The version of the room keys backup provided in the request does not + * match the current backup version. + * + * room keys backup: https://spec.matrix.org/latest/client-server-api/#server-side-key-backups + */ + data class WrongRoomKeysVersion( + /** + * The currently active backup version. + */ + val currentVersion: String? + ) : ErrorKind + + /** + * A custom API error. + */ + data class Custom(val errcode: String) : ErrorKind +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/NotificationResolverException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/NotificationResolverException.kt new file mode 100644 index 0000000..fd3adf2 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/NotificationResolverException.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.exception + +/** + * Exceptions that can occur while resolving the events associated to push notifications. + */ +sealed class NotificationResolverException : Exception() { + /** + * The event was not found by the notification service. + */ + data object EventNotFound : NotificationResolverException() + + /** + * The event was found but it was filtered out by the notification service. + */ + data object EventFilteredOut : NotificationResolverException() + + /** + * An unexpected error occurred while trying to resolve the event. + */ + data class UnknownError(override val message: String) : NotificationResolverException() +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioDetails.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioDetails.kt new file mode 100644 index 0000000..b933868 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioDetails.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.media + +import kotlinx.collections.immutable.ImmutableList +import kotlin.time.Duration + +data class AudioDetails( + val duration: Duration, + val waveform: ImmutableList, +) 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 0000000..a91a3c0 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/AudioInfo.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.media + +import kotlin.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 0000000..794807c --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/FileInfo.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..946e523 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/ImageInfo.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..a36849b --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MatrixMediaLoader.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 + + /** + * @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 + + /** + * @param source to fetch the data for. + * @param mimeType: optional mime type. + * @param filename: optional String which will be used to name the file. + * @param useCache: if true, the rust sdk will cache the media in its store. + * @return a [Result] of [MediaFile] + */ + suspend fun downloadMediaFile( + source: MediaSource, + mimeType: String?, + filename: String?, + useCache: Boolean = true, + ): Result +} 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 0000000..b5cefe8 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaFile.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 unless [persist] has been used. + */ +interface MediaFile : Closeable { + fun path(): String + + /** + * Persists the temp file to the given path. The file will be moved to + * the given path and won't be deleted anymore when closing the handle. + */ + fun persist(path: String): Boolean +} + +fun MediaFile.toFile(): File { + return File(path()) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewConfig.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewConfig.kt new file mode 100644 index 0000000..c91fea7 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewConfig.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.media + +/** + * Configuration for media preview ie. invite avatars and timeline media. + */ +data class MediaPreviewConfig( + val mediaPreviewValue: MediaPreviewValue, + val hideInviteAvatar: Boolean, +) { + companion object { + /** + * The default config if unknown (no local nor server config). + */ + val DEFAULT = MediaPreviewConfig( + mediaPreviewValue = MediaPreviewValue.DEFAULT, + hideInviteAvatar = false + ) + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewService.kt new file mode 100644 index 0000000..39718dd --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewService.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.media + +import kotlinx.coroutines.flow.StateFlow + +interface MediaPreviewService { + /** + * Will fetch the media preview config from the server. + */ + suspend fun fetchMediaPreviewConfig(): Result + + /** + * Will emit the media preview config known by the client. + * This will emit a new value when received from sync. + */ + val mediaPreviewConfigFlow: StateFlow + + /** + * Set the media preview display policy. This will update the value on the server and update the local value when successful. + */ + suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result + + /** + * Set the invite avatars display policy. This will update the value on the server and update the local value when successful. + */ + suspend fun setHideInviteAvatars(hide: Boolean): Result +} + +fun MediaPreviewService.getMediaPreviewValue() = mediaPreviewConfigFlow.value.mediaPreviewValue diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewValue.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewValue.kt new file mode 100644 index 0000000..b77b01b --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewValue.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.media + +import io.element.android.libraries.matrix.api.media.MediaPreviewValue.Off +import io.element.android.libraries.matrix.api.media.MediaPreviewValue.On +import io.element.android.libraries.matrix.api.media.MediaPreviewValue.Private +import io.element.android.libraries.matrix.api.room.join.JoinRule + +/** + * Represents the values for media preview settings. + * - [On] means that media preview are enabled + * - [Off] means that media preview are disabled + * - [Private] means that media preview are enabled only for private chats. + */ +enum class MediaPreviewValue { + On, + Off, + Private; + + companion object { + /** + * The default value if unknown (no local nor server config). + */ + val DEFAULT = On + } +} + +fun MediaPreviewValue?.isPreviewEnabled(joinRule: JoinRule?): Boolean { + return when (this) { + null, On -> true + Off -> false + Private -> when (joinRule) { + is JoinRule.Private, + is JoinRule.Knock, + is JoinRule.Invite, + is JoinRule.Restricted, + is JoinRule.KnockRestricted -> true + else -> false + } + } +} 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 0000000..56e32ba --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaSource.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/MediaUploadHandler.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaUploadHandler.kt new file mode 100644 index 0000000..c079494 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaUploadHandler.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.media + +/** + * This is an abstraction over the Rust SDK's `SendAttachmentJoinHandle` which allows us to either [await] the upload process or [cancel] it. + */ +interface MediaUploadHandler { + /** Await the upload process to finish. */ + suspend fun await(): Result + + /** Cancel the upload process. */ + fun cancel() +} 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 0000000..368abd4 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/ThumbnailInfo.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..e5f3915 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/VideoInfo.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.media + +import kotlin.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/mxc/MxcTools.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt new file mode 100644 index 0000000..306ab83 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.mxc + +interface MxcTools { + /** + * Sanitizes an mxcUri to be used as a relative file path. + * + * @param mxcUri the Matrix Content (mxc://) URI of the file. + * @return the relative file path as "/" or null if the mxcUri is invalid. + */ + fun mxcUri2FilePath(mxcUri: String): 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 0000000..40ec310 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 +import io.element.android.libraries.matrix.api.core.ThreadId +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 sessionId: SessionId, + val eventId: EventId, + val threadId: ThreadId?, + val roomId: RoomId, + // mxc url + val senderAvatarUrl: String?, + // private, must use `getDisambiguatedDisplayName` + private val senderDisplayName: String?, + private val senderIsNameAmbiguous: Boolean, + val roomAvatarUrl: String?, + val roomDisplayName: String?, + val isDirect: Boolean, + val isDm: Boolean, + val isEncrypted: Boolean, + val isNoisy: Boolean, + val timestamp: Long, + val content: NotificationContent, + val hasMention: Boolean, +) { + fun getDisambiguatedDisplayName(userId: UserId): String = when { + senderDisplayName.isNullOrBlank() -> userId.value + senderIsNameAmbiguous -> "$senderDisplayName ($userId)" + else -> senderDisplayName + } +} + +sealed interface NotificationContent { + sealed interface MessageLike : NotificationContent { + data object CallAnswer : MessageLike + data class CallInvite( + val senderId: UserId, + ) : MessageLike + + data class RtcNotification( + val senderId: UserId, + val type: RtcNotificationType, + val expirationTimestampMillis: Long + ) : MessageLike + + data object CallHangup : MessageLike + data object CallCandidates : MessageLike + data object KeyVerificationReady : MessageLike + data object KeyVerificationStart : MessageLike + data object KeyVerificationCancel : MessageLike + data object KeyVerificationAccept : MessageLike + data object KeyVerificationKey : MessageLike + data object KeyVerificationMac : MessageLike + data object KeyVerificationDone : MessageLike + data class ReactionContent( + val relatedEventId: String + ) : MessageLike + + data object RoomEncrypted : MessageLike + data class RoomMessage( + val senderId: UserId, + val messageType: MessageType + ) : MessageLike + + data class RoomRedaction( + val redactedEventId: EventId?, + val reason: String?, + ) : MessageLike + + data object Sticker : MessageLike + data class Poll( + val senderId: UserId, + val question: String, + ) : MessageLike + } + + sealed interface StateEvent : NotificationContent { + data object PolicyRuleRoom : StateEvent + data object PolicyRuleServer : StateEvent + data object PolicyRuleUser : StateEvent + data object RoomAliases : StateEvent + data object RoomAvatar : StateEvent + data object RoomCanonicalAlias : StateEvent + data object RoomCreate : StateEvent + data object RoomEncryption : StateEvent + data object RoomGuestAccess : StateEvent + data object RoomHistoryVisibility : StateEvent + data object RoomJoinRules : StateEvent + data class RoomMemberContent( + val userId: UserId, + val membershipState: RoomMembershipState + ) : StateEvent + + data object RoomName : StateEvent + data object RoomPinnedEvents : StateEvent + data object RoomPowerLevels : StateEvent + data object RoomServerAcl : StateEvent + data object RoomThirdPartyInvite : StateEvent + data object RoomTombstone : StateEvent + data class RoomTopic(val topic: String) : StateEvent + data object SpaceChild : StateEvent + data object SpaceParent : StateEvent + } + + data class Invite( + val senderId: UserId, + ) : NotificationContent +} + +enum class RtcNotificationType { + RING, + NOTIFY +} 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 0000000..369587c --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 + +/** + * Represents the resolution state of an attempt to retrieve notification data for a set of event ids. + * The outer [Result] indicates the success or failure of the setup to retrieve notifications. + * The inner [Result] for each [EventId] in the map indicates whether the notification data was successfully retrieved or if there was an error. + */ +typealias GetNotificationDataResult = Result>> + +/** + * Service to retrieve notifications for a given set of event ids in specific rooms. + */ +interface NotificationService { + /** + * Fetch notifications for the specified event ids in the given rooms. + */ + suspend fun getNotifications(ids: Map>): GetNotificationDataResult +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notificationsettings/NotificationSettingsService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notificationsettings/NotificationSettingsService.kt new file mode 100644 index 0000000..4d8ce8a --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notificationsettings/NotificationSettingsService.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.notificationsettings + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.RoomNotificationSettings +import io.element.android.libraries.matrix.api.room.RoomNotificationSettingsState +import kotlinx.coroutines.flow.SharedFlow + +interface NotificationSettingsService { + /** + * State of the current room notification settings flow ([RoomNotificationSettingsState.Unknown] if not started). + */ + val notificationSettingsChangeFlow: SharedFlow + suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result + suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, isOneToOne: Boolean): Result + suspend fun setDefaultRoomNotificationMode(isEncrypted: Boolean, mode: RoomNotificationMode, isOneToOne: Boolean): Result + suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result + suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result + suspend fun muteRoom(roomId: RoomId): Result + suspend fun unmuteRoom(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result + suspend fun isRoomMentionEnabled(): Result + suspend fun setRoomMentionEnabled(enabled: Boolean): Result + suspend fun isCallEnabled(): Result + suspend fun setCallEnabled(enabled: Boolean): Result + suspend fun isInviteForMeEnabled(): Result + suspend fun setInviteForMeEnabled(enabled: Boolean): Result + suspend fun getRoomsWithUserDefinedRules(): Result> + suspend fun canHomeServerPushEncryptedEventsToDevice(): Result + suspend fun getRawPushRules(): Result +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/oidc/AccountManagementAction.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/oidc/AccountManagementAction.kt new file mode 100644 index 0000000..77e8854 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/oidc/AccountManagementAction.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.oidc + +import io.element.android.libraries.matrix.api.core.DeviceId + +sealed interface AccountManagementAction { + data object Profile : AccountManagementAction + data object SessionsList : AccountManagementAction + data class SessionView(val deviceId: DeviceId) : AccountManagementAction + data class SessionEnd(val deviceId: DeviceId) : AccountManagementAction +} 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 0000000..cc3e2b5 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.permalink + +import android.net.Uri + +/** + * Mapping of an input URI to a matrix.to compliant URI. + */ +interface MatrixToConverter { + /** + * Try to convert a URL from an element web instance or from a client permalink to a matrix.to url. + * 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? +} 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 0000000..878b0e7 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.permalink + +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.UserId + +interface PermalinkBuilder { + fun permalinkForUser(userId: UserId): Result + fun permalinkForRoomAlias(roomAlias: RoomAlias): Result +} + +sealed class PermalinkBuilderError : Throwable() { + data object InvalidData : 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 0000000..a554ce2 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkData.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.permalink + +import android.net.Uri +import android.os.Parcelable +import androidx.compose.runtime.Immutable +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.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.parcelize.Parcelize + +/** + * This sealed class represents all the permalink cases. + * You don't have to instantiate yourself but should use [PermalinkParser] instead. + */ +@Immutable +@Parcelize +sealed interface PermalinkData : Parcelable { + data class RoomLink( + val roomIdOrAlias: RoomIdOrAlias, + val eventId: EventId? = null, + val threadId: ThreadId? = null, + val viaParameters: ImmutableList = persistentListOf() + ) : PermalinkData + + /* + * &room_name=Team2 + * &room_avatar_url=mxc: + * &inviter_name=bob + */ + data class RoomEmailInviteLink( + val roomId: RoomId, + 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: UserId) : 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 0000000..5365b87 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.permalink + +/** + * 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. user/@chagai95:matrix.org) + * or matrix: permalinks (e.g. matrix:u/chagai95:matrix.org) + */ +interface PermalinkParser { + /** + * Turns a uri string to a [PermalinkData]. + * https://github.com/matrix-org/matrix-doc/blob/master/proposals/1704-matrix.to-permalinks.md + */ + fun parse(uriString: String): PermalinkData +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/platform/InitPlatformService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/platform/InitPlatformService.kt new file mode 100644 index 0000000..a08946b --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/platform/InitPlatformService.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.platform + +import io.element.android.libraries.matrix.api.tracing.TracingConfiguration + +/** + * This service is responsible for initializing the platform-related settings of the SDK. + */ +interface InitPlatformService { + /** + * Initialize the platform-related settings of the SDK. + * @param tracingConfiguration the tracing configuration to use for logging. + */ + fun init(tracingConfiguration: TracingConfiguration) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/poll/PollAnswer.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/poll/PollAnswer.kt new file mode 100644 index 0000000..043d288 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/poll/PollAnswer.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.poll + +data class PollAnswer( + val id: String, + val text: String +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/poll/PollKind.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/poll/PollKind.kt new file mode 100644 index 0000000..e79d716 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/poll/PollKind.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +package io.element.android.libraries.matrix.api.poll + +enum class PollKind { + /** Voters should see results as soon as they have voted. */ + Disclosed, + + /** Results should be only revealed when the poll is ended. */ + Undisclosed, +} + +val PollKind.isDisclosed: Boolean + get() = this == PollKind.Disclosed 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 0000000..6764598 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.pusher + +interface PushersService { + suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData): Result + suspend fun unsetHttpPusher(unsetHttpPusherData: UnsetHttpPusherData): Result +} 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 0000000..8a557f4 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/SetHttpPusherData.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/pusher/UnsetHttpPusherData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/UnsetHttpPusherData.kt new file mode 100644 index 0000000..dc9cf12 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/UnsetHttpPusherData.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.pusher + +data class UnsetHttpPusherData( + val pushKey: String, + val appId: String, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt new file mode 100644 index 0000000..41e6ce0 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.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.core.UserId +import io.element.android.libraries.matrix.api.room.draft.ComposerDraft +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues +import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom +import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility +import io.element.android.libraries.matrix.api.timeline.ReceiptType +import io.element.android.libraries.matrix.api.timeline.Timeline +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import java.io.Closeable + +/** + * This interface represents the common functionality for a local room, whether it's joined, invited, knocked, or left. + */ +interface BaseRoom : Closeable { + /** + * The session id of the current user. + */ + val sessionId: SessionId + + /** + * The id of the room. + */ + val roomId: RoomId + + /** + * The coroutine scope that will handle all jobs related to this room. + */ + val roomCoroutineScope: CoroutineScope + + /** + * The current loaded members as a StateFlow. + * Initial value is [RoomMembersState.Unknown]. + * To update them you should call [updateMembers]. + */ + val membersStateFlow: StateFlow + + /** + * A flow that emits the current [RoomInfo] state. + */ + val roomInfoFlow: StateFlow + + /** + * Get the latest room info we have received from the SDK stream. + */ + fun info(): RoomInfo = roomInfoFlow.value + + fun predecessorRoom(): PredecessorRoom? + + /** + * A one-to-one is a room with exactly 2 members. + * See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/#default-underride-rules). + */ + val isOneToOne: Boolean get() = info().activeMembersCount == 2L + + /** + * Try to load the room members and update the membersFlow. + */ + suspend fun updateMembers() + + /** + * Get the members of the room. Note: generally this should not be used, please use + * [membersStateFlow] and [updateMembers] instead. + */ + suspend fun getMembers(limit: Int = 5): Result> + + /** + * Will return an updated member or an error. + */ + suspend fun getUpdatedMember(userId: UserId): Result + + /** + * Adds the room to the sync subscription list. + */ + suspend fun subscribeToSync() + + /** + * Gets the power levels of the room. + */ + suspend fun powerLevels(): Result + + /** + * Gets the role of the user with the provided [userId] in the room. + */ + suspend fun userRole(userId: UserId): Result + + /** + * Gets the display name of the user with the provided [userId] in the room. + */ + suspend fun userDisplayName(userId: UserId): Result + + /** + * Gets the avatar of the user with the provided [userId] in the room. + */ + suspend fun userAvatarUrl(userId: UserId): Result + + /** + * Leaves and forgets the room. Only joined, invited or knocked rooms can be left. + */ + suspend fun leave(): Result + + /** + * Joins the room. Only invited rooms can be joined. + */ + suspend fun join(): Result + + /** + * Forgets about the room, removing it from the server and the local cache. Only left and banned rooms can be forgotten. + */ + suspend fun forget(): Result + + /** + * Returns `true` if the user with the provided [userId] can invite other users to the room. + */ + suspend fun canUserInvite(userId: UserId): Result + + /** + * Returns `true` if the user with the provided [userId] can kick other users from the room. + */ + suspend fun canUserKick(userId: UserId): Result + + /** + * Returns `true` if the user with the provided [userId] can ban other users from the room. + */ + suspend fun canUserBan(userId: UserId): Result + + /** + * Returns `true` if the user with the provided [userId] can redact their own messages. + */ + suspend fun canUserRedactOwn(userId: UserId): Result + + /** + * Returns `true` if the user with the provided [userId] can redact messages from other users. + */ + suspend fun canUserRedactOther(userId: UserId): Result + + /** + * Returns `true` if the user with the provided [userId] can send state events. + */ + suspend fun canUserSendState(userId: UserId, type: StateEventType): Result + + /** + * Returns `true` if the user with the provided [userId] can send messages. + */ + suspend fun canUserSendMessage(userId: UserId, type: MessageEventType): Result + + /** + * Returns `true` if the user with the provided [userId] can trigger an `@room` notification. + */ + suspend fun canUserTriggerRoomNotification(userId: UserId): Result + + /** + * Returns `true` if the user with the provided [userId] can pin or unpin messages. + */ + suspend fun canUserPinUnpin(userId: UserId): Result + + /** + * Returns `true` if the user with the provided [userId] can join or starts calls. + */ + suspend fun canUserJoinCall(userId: UserId): Result = + canUserSendState(userId, StateEventType.CALL_MEMBER) + + /** + * Sets the room as favorite or not, based on the [isFavorite] parameter. + */ + suspend fun setIsFavorite(isFavorite: Boolean): Result + + /** + * Mark the room as read by trying to attach an unthreaded read receipt to the latest room event. + * + * Note this will instantiate a new timeline, which is an expensive operation. + * Prefer using [Timeline.markAsRead] instead when possible. + * + * @param receiptType The type of receipt to send. + */ + suspend fun markAsRead(receiptType: ReceiptType): Result + + /** + * Sets a flag on the room to indicate that the user has explicitly marked it as unread, or reverts the flag. + * @param isUnread true to mark the room as unread, false to remove the flag. + */ + suspend fun setUnreadFlag(isUnread: Boolean): Result + + /** + * Clear the event cache storage for the current room. + */ + suspend fun clearEventCacheStorage(): Result + + /** + * Get the permalink for the room. + */ + suspend fun getPermalink(): Result + + /** + * Get the permalink for the provided [eventId]. + * @param eventId The event id to get the permalink for. + * @return The permalink, or a failure. + */ + suspend fun getPermalinkFor(eventId: EventId): Result + + /** + * Returns the visibility for this room in the room directory. + * If the room is not published, the result will be [RoomVisibility.Private]. + */ + suspend fun getRoomVisibility(): Result + + /** + * Returns the visibility for this room in the room directory, fetching it from the homeserver if needed. + */ + suspend fun getUpdatedIsEncrypted(): Result + + /** + * Store the given `ComposerDraft` in the state store of this room. + */ + suspend fun saveComposerDraft(composerDraft: ComposerDraft, threadRoot: ThreadId?): Result + + /** + * Retrieve the `ComposerDraft` stored in the state store for this room. + */ + suspend fun loadComposerDraft(threadRoot: ThreadId?): Result + + /** + * Clear the `ComposerDraft` stored in the state store for this room. + */ + suspend fun clearComposerDraft(threadRoot: ThreadId?): Result + + /** + * Reports a room as inappropriate to the server. + * The caller is not required to be joined to the room to report it. + * @param reason - The reason the room is being reported. + */ + suspend fun reportRoom(reason: String?): Result + + suspend fun declineCall(notificationEventId: EventId): Result + + suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow + + suspend fun threadRootIdForEvent(eventId: EventId): Result + + /** + * Destroy the room and release all resources associated to it. + */ + fun destroy() + + override fun close() = destroy() +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/CreateTimelineParams.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/CreateTimelineParams.kt new file mode 100644 index 0000000..b5374c8 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/CreateTimelineParams.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.ThreadId + +sealed interface CreateTimelineParams { + data class Focused(val focusedEventId: EventId) : CreateTimelineParams + data object MediaOnly : CreateTimelineParams + data class MediaOnlyFocused(val focusedEventId: EventId) : CreateTimelineParams + data object PinnedOnly : CreateTimelineParams + data class Threaded(val threadRootEventId: ThreadId) : CreateTimelineParams +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/CurrentUserMembership.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/CurrentUserMembership.kt new file mode 100644 index 0000000..ecf43e9 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/CurrentUserMembership.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room + +enum class CurrentUserMembership { + INVITED, + JOINED, + LEFT, + KNOCKED, + BANNED, +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/FilterRoomMembers.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/FilterRoomMembers.kt new file mode 100644 index 0000000..80cefc4 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/FilterRoomMembers.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room + +import io.element.android.libraries.core.bool.orFalse +import kotlinx.coroutines.withContext +import kotlin.coroutines.CoroutineContext + +/** + * Method to filter members by userId or displayName. + * It does filter through the already known members, it doesn't perform additional requests. + */ +suspend fun BaseRoom.filterMembers(query: String, coroutineContext: CoroutineContext): List = withContext(coroutineContext) { + val roomMembersState = membersStateFlow.value + val activeRoomMembers = roomMembersState.roomMembers() + ?.filter { it.membership.isActive() } + .orEmpty() + val filteredMembers = if (query.isBlank()) { + activeRoomMembers + } else { + activeRoomMembers.filter { member -> + member.userId.value.contains(query, ignoreCase = true) || + member.displayName?.contains(query, ignoreCase = true).orFalse() + } + } + filteredMembers +} 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 0000000..f73b756 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/ForwardEventException.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room + +import io.element.android.libraries.matrix.api.core.RoomId + +class ForwardEventException( + val roomIds: List +) : 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/IntentionalMention.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/IntentionalMention.kt new file mode 100644 index 0000000..25d5fa6 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/IntentionalMention.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room + +import io.element.android.libraries.matrix.api.core.UserId + +sealed interface IntentionalMention { + data class User(val userId: UserId) : IntentionalMention + data object Room : IntentionalMention +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt new file mode 100644 index 0000000..1cfcfc4 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room + +import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.SendHandle +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.knock.KnockRequest +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues +import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange +import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver +import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface JoinedRoom : BaseRoom { + val syncUpdateFlow: StateFlow + + val roomTypingMembersFlow: Flow> + val identityStateChangesFlow: Flow> + val roomNotificationSettingsStateFlow: StateFlow + + /** + * The current knock requests in the room as a Flow. + */ + val knockRequestsFlow: Flow> + + /** + * The live timeline of the room. Must be used to send Event to a room. + */ + val liveTimeline: Timeline + + /** + * Create a new timeline. + * @param createTimelineParams contains parameters about how to filter the timeline. Will also configure the date separators. + */ + suspend fun createTimeline( + createTimelineParams: CreateTimelineParams, + ): Result + + suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, intentionalMentions: List): Result + + /** + * Send a typing notification. + * @param isTyping True if the user is typing, false otherwise. + */ + suspend fun typingNotice(isTyping: Boolean): Result + + suspend fun inviteUserById(id: UserId): Result + + suspend fun updateAvatar(mimeType: String, data: ByteArray): Result + + suspend fun removeAvatar(): Result + + suspend fun updateRoomNotificationSettings(): Result + + /** + * Update the canonical alias of the room. + * + * Note that publishing the alias in the room directory is done separately. + */ + suspend fun updateCanonicalAlias( + canonicalAlias: RoomAlias?, + alternativeAliases: List + ): Result + + /** + * Update the room's visibility in the room directory. + */ + suspend fun updateRoomVisibility(roomVisibility: RoomVisibility): Result + + /** + * Update room history visibility for this room. + */ + suspend fun updateHistoryVisibility(historyVisibility: RoomHistoryVisibility): Result + + /** + * Publish a new room alias for this room in the room directory. + * + * Returns: + * - `true` if the room alias didn't exist and it's now published. + * - `false` if the room alias was already present so it couldn't be + * published. + */ + suspend fun publishRoomAliasInRoomDirectory(roomAlias: RoomAlias): Result + + /** + * Remove an existing room alias for this room in the room directory. + * + * Returns: + * - `true` if the room alias was present and it's now removed from the + * room directory. + * - `false` if the room alias didn't exist so it couldn't be removed. + */ + suspend fun removeRoomAliasFromRoomDirectory(roomAlias: RoomAlias): Result + + /** + * Enable End-to-end encryption in this room. + */ + suspend fun enableEncryption(): Result + + /** + * Update the join rule for this room. + */ + suspend fun updateJoinRule(joinRule: JoinRule): Result + + suspend fun updateUsersRoles(changes: List): Result + + suspend fun updatePowerLevels(roomPowerLevelsValues: RoomPowerLevelsValues): Result + + suspend fun resetPowerLevels(): Result + + suspend fun setName(name: String): Result + + suspend fun setTopic(topic: String): Result + + suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result + + suspend fun kickUser(userId: UserId, reason: String? = null): Result + + suspend fun banUser(userId: UserId, reason: String? = null): Result + + suspend fun unbanUser(userId: UserId, reason: String? = null): Result + + /** + * Generates a Widget url to display in a [android.webkit.WebView] given the provided parameters. + * @param widgetSettings The widget settings to use. + * @param clientId The client id to use. It should be unique per app install. + * @param languageTag The language tag to use. If null, the default language will be used. + * @param theme The theme to use. If null, the default theme will be used. + * @return The resulting url, or a failure. + */ + suspend fun generateWidgetWebViewUrl( + widgetSettings: MatrixWidgetSettings, + clientId: String, + languageTag: String?, + theme: String?, + ): Result + + /** + * Get a [MatrixWidgetDriver] for the provided [widgetSettings]. + * @param widgetSettings The widget settings to use. + * @return The resulting [MatrixWidgetDriver], or a failure. + */ + fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result + + suspend fun setSendQueueEnabled(enabled: Boolean) + + /** + * Ignore the local trust for the given devices and resend messages that failed to send because said devices are unverified. + * + * @param devices The map of users identifiers to device identifiers received in the error + * @param sendHandle The send queue handle of the local echo the send error applies to. It can be used to retry the upload. + * + */ + suspend fun ignoreDeviceTrustAndResend(devices: Map>, sendHandle: SendHandle): Result + + /** + * Remove verification requirements for the given users and + * resend messages that failed to send because their identities were no longer verified. + * + * @param userIds The list of users identifiers received in the error. + * @param sendHandle The send queue handle of the local echo the send error applies to. It can be used to retry the upload. + * + */ + suspend fun withdrawVerificationAndResend(userIds: List, sendHandle: SendHandle): Result +} 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 0000000..adf8ebf --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MessageEventType.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface MessageEventType { + data object CallAnswer : MessageEventType + data object CallInvite : MessageEventType + data object CallHangup : MessageEventType + data object CallCandidates : MessageEventType + data object RtcNotification : MessageEventType + data object KeyVerificationReady : MessageEventType + data object KeyVerificationStart : MessageEventType + data object KeyVerificationCancel : MessageEventType + data object KeyVerificationAccept : MessageEventType + data object KeyVerificationKey : MessageEventType + data object KeyVerificationMac : MessageEventType + data object KeyVerificationDone : MessageEventType + data object Reaction : MessageEventType + data object RoomEncrypted : MessageEventType + data object RoomMessage : MessageEventType + data object RoomRedaction : MessageEventType + data object Sticker : MessageEventType + data object PollEnd : MessageEventType + data object PollResponse : MessageEventType + data object PollStart : MessageEventType + data object UnstablePollEnd : MessageEventType + data object UnstablePollResponse : MessageEventType + data object UnstablePollStart : MessageEventType + data class Other(val type: String) : MessageEventType +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/NotJoinedRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/NotJoinedRoom.kt new file mode 100644 index 0000000..fd5e71f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/NotJoinedRoom.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room + +import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo + +/** A reference to a room either invited, knocked or banned. */ +interface NotJoinedRoom : AutoCloseable { + val previewInfo: RoomPreviewInfo + val localRoom: BaseRoom? + + /** + * Get the membership details of the user in the room, as well as from the user who sent the `m.room.member` event. + */ + suspend fun membershipDetails(): Result +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomInfo.kt new file mode 100644 index 0000000..943745d --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomInfo.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.RoomAlias +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.history.RoomHistoryVisibility +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels +import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList + +data class RoomInfo( + val id: RoomId, + /** The room's name from the room state event if received from sync, or one that's been computed otherwise. */ + val name: String?, + /** Room name as defined by the room state event only. */ + val rawName: String?, + val topic: String?, + val avatarUrl: String?, + val isPublic: Boolean?, + val isDirect: Boolean, + val isEncrypted: Boolean?, + val joinRule: JoinRule?, + val isSpace: Boolean, + val isFavorite: Boolean, + val canonicalAlias: RoomAlias?, + val alternativeAliases: ImmutableList, + val currentUserMembership: CurrentUserMembership, + /** + * Member who invited the current user to a room that's in the invited + * state. + * + * Can be missing if the room membership invite event is missing from the + * store. + */ + val inviter: RoomMember?, + val activeMembersCount: Long, + val invitedMembersCount: Long, + val joinedMembersCount: Long, + val roomPowerLevels: RoomPowerLevels?, + val highlightCount: Long, + val notificationCount: Long, + val userDefinedNotificationMode: RoomNotificationMode?, + val hasRoomCall: Boolean, + val activeRoomCallParticipants: ImmutableList, + val isMarkedUnread: Boolean, + /** + * "Interesting" messages received in that room, independently of the + * notification settings. + */ + val numUnreadMessages: Long, + /** + * Events that will notify the user, according to their + * notification settings. + */ + val numUnreadNotifications: Long, + /** + * Events causing mentions/highlights for the user, according to their + * notification settings. + */ + val numUnreadMentions: Long, + val heroes: ImmutableList, + val pinnedEventIds: ImmutableList, + val creators: ImmutableList, + val historyVisibility: RoomHistoryVisibility, + val successorRoom: SuccessorRoom?, + val roomVersion: String?, + val privilegedCreatorRole: Boolean, +) { + val aliases: List + get() = listOfNotNull(canonicalAlias) + alternativeAliases + + /** + * Returns the list of users with the given [role] in this room. + */ + fun usersWithRole(role: RoomMember.Role): List { + return if (role is RoomMember.Role.Owner && role.isCreator) { + this.creators + } else { + this.roomPowerLevels?.usersWithRole(role).orEmpty().toList() + } + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomIsDmCheck.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomIsDmCheck.kt new file mode 100644 index 0000000..f33319e --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomIsDmCheck.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room + +import kotlinx.coroutines.flow.first + +/** + * Returns whether the room with the provided info is a DM. + * A DM is a room with at most 2 active members (one of them may have left). + * + * @param isDirect true if the room is direct + * @param activeMembersCount the number of active members in the room (joined or invited) + */ +fun isDm(isDirect: Boolean, activeMembersCount: Int): Boolean { + return isDirect && activeMembersCount <= 2 +} + +/** + * Returns whether the [BaseRoom] is a DM, with an updated state from the latest [RoomInfo]. + */ +suspend fun BaseRoom.isDm() = roomInfoFlow.first().isDm + +/** + * Returns whether the [RoomInfo] is from a DM. + */ +val RoomInfo.isDm get() = isDm(isDirect, activeMembersCount.toInt()) 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 0000000..5a1f253 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser + +data class RoomMember( + val userId: UserId, + val displayName: String?, + val avatarUrl: String?, + val membership: RoomMembershipState, + val isNameAmbiguous: Boolean, + val powerLevel: Long, + val isIgnored: Boolean, + val role: Role, + val membershipChangeReason: String?, +) { + /** + * Role of the RoomMember, based on its [powerLevel]. + */ + @Immutable + sealed interface Role { + data class Owner(val isCreator: Boolean) : Role + data object Admin : Role + data object Moderator : Role + data object User : Role + + val powerLevel: Long + get() = when (this) { + is Owner -> if (isCreator) CREATOR_POWERLEVEL else SUPERADMIN_POWERLEVEL + Admin -> ADMIN_POWERLEVEL + Moderator -> MODERATOR_POWERLEVEL + User -> USER_POWERLEVEL + } + + companion object { + private const val CREATOR_POWERLEVEL = Long.MAX_VALUE + private const val SUPERADMIN_POWERLEVEL = 150L + private const val ADMIN_POWERLEVEL = 100L + private const val MODERATOR_POWERLEVEL = 50L + private const val USER_POWERLEVEL = 0L + + fun forPowerLevel(powerLevel: Long): Role { + return when { + powerLevel == CREATOR_POWERLEVEL -> Owner(isCreator = true) + powerLevel >= SUPERADMIN_POWERLEVEL -> Owner(isCreator = false) + powerLevel >= ADMIN_POWERLEVEL -> Admin + powerLevel >= MODERATOR_POWERLEVEL -> Moderator + else -> User + } + } + } + } + + /** + * Disambiguated display name for the RoomMember. + * If the display name is null, the user ID is returned. + * If the display name is ambiguous, the user ID is appended in parentheses. + * Otherwise, the display name is returned. + */ + val disambiguatedDisplayName: String = when { + displayName == null -> userId.value + isNameAmbiguous -> "$displayName ($userId)" + else -> displayName + } + + val displayNameOrDefault: String + get() = when { + displayName == null -> userId.extractedDisplayName + else -> displayName + } +} + +enum class RoomMembershipState { + BAN, + INVITE, + JOIN, + KNOCK, + LEAVE; + + fun isActive(): Boolean = this == JOIN || this == INVITE +} + +/** + * Returns the best name value to display for the RoomMember. + * If the [RoomMember.displayName] is present and not empty it'll be used, otherwise the [RoomMember.userId] will be used. + */ +fun RoomMember.getBestName(): String { + return displayName?.takeIf { it.isNotEmpty() } ?: userId.value +} + +fun RoomMember.toMatrixUser() = MatrixUser( + userId = userId, + displayName = displayName, + avatarUrl = avatarUrl, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembersState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembersState.kt new file mode 100644 index 0000000..1c35fab --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembersState.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.collections.immutable.ImmutableList + +@Immutable +sealed interface RoomMembersState { + data object Unknown : RoomMembersState + data class Pending(val prevRoomMembers: ImmutableList? = null) : RoomMembersState + data class Error(val failure: Throwable, val prevRoomMembers: ImmutableList? = null) : RoomMembersState + data class Ready(val roomMembers: ImmutableList) : RoomMembersState +} + +fun RoomMembersState.roomMembers(): List? { + return when (this) { + is RoomMembersState.Ready -> roomMembers + is RoomMembersState.Pending -> prevRoomMembers + is RoomMembersState.Error -> prevRoomMembers + else -> null + } +} + +fun RoomMembersState.joinedRoomMembers(): List { + return roomMembers().orEmpty().filter { it.membership == RoomMembershipState.JOIN } +} + +fun RoomMembersState.activeRoomMembers(): List { + return roomMembers().orEmpty().filter { it.membership.isActive() } +} + +fun RoomMembersState.getDirectRoomMember(roomInfo: RoomInfo, sessionId: SessionId): RoomMember? { + return roomMembers() + ?.takeIf { roomInfo.isDm } + ?.find { it.userId != sessionId && it.membership.isActive() } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipDetails.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipDetails.kt new file mode 100644 index 0000000..df99d1b --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipDetails.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room + +/** + * Room membership details for the current user and the sender of the membership event. + * + * It also includes the reason the current user's membership changed, if any. + */ +data class RoomMembershipDetails( + val currentUserMember: RoomMember, + val senderMember: RoomMember?, +) { + val membershipChangeReason: String? = currentUserMember.membershipChangeReason +} 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 0000000..852a997 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 isSpace: Boolean, + val isUserInRoom: Boolean, + val change: MembershipChange, + ) + + private val _updates = MutableSharedFlow(extraBufferCapacity = 10) + val updates = _updates.asSharedFlow() + + suspend fun notifyUserLeftRoom( + roomId: RoomId, + isSpace: Boolean, + membershipBeforeLeft: CurrentUserMembership, + ) { + val membershipChange = when (membershipBeforeLeft) { + CurrentUserMembership.INVITED -> MembershipChange.INVITATION_REJECTED + CurrentUserMembership.KNOCKED -> MembershipChange.KNOCK_RETRACTED + else -> MembershipChange.LEFT + } + _updates.emit( + RoomMembershipUpdate( + roomId = roomId, + isSpace = isSpace, + isUserInRoom = false, + change = membershipChange, + ) + ) + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomNotificationSettings.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomNotificationSettings.kt new file mode 100644 index 0000000..4505c99 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomNotificationSettings.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room + +data class RoomNotificationSettings( + val mode: RoomNotificationMode, + val isDefault: Boolean, +) + +enum class RoomNotificationMode { + ALL_MESSAGES, + MENTIONS_AND_KEYWORDS_ONLY, + MUTE +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomNotificationSettingsState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomNotificationSettingsState.kt new file mode 100644 index 0000000..243f6c0 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomNotificationSettingsState.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room + +sealed interface RoomNotificationSettingsState { + data object Unknown : RoomNotificationSettingsState + data class Pending(val prevRoomNotificationSettings: RoomNotificationSettings? = null) : RoomNotificationSettingsState + data class Error(val failure: Throwable, val prevRoomNotificationSettings: RoomNotificationSettings? = null) : RoomNotificationSettingsState + data class Ready(val roomNotificationSettings: RoomNotificationSettings) : RoomNotificationSettingsState +} + +fun RoomNotificationSettingsState.roomNotificationSettings(): RoomNotificationSettings? { + return when (this) { + is RoomNotificationSettingsState.Ready -> roomNotificationSettings + is RoomNotificationSettingsState.Pending -> prevRoomNotificationSettings + is RoomNotificationSettingsState.Error -> prevRoomNotificationSettings + else -> null + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomType.kt new file mode 100644 index 0000000..49cc345 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomType.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room + +sealed interface RoomType { + data object Space : RoomType + data object Room : RoomType + data class Other(val type: String) : RoomType +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StartDM.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StartDM.kt new file mode 100644 index 0000000..057c583 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StartDM.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId + +/** + * Try to find an existing DM with the given user, or create one if none exists and [createIfDmDoesNotExist] is true. + */ +suspend fun MatrixClient.startDM( + userId: UserId, + createIfDmDoesNotExist: Boolean, +): StartDMResult { + return findDM(userId) + .fold( + onSuccess = { existingDM -> + if (existingDM != null) { + StartDMResult.Success(existingDM, isNew = false) + } else if (createIfDmDoesNotExist) { + createDM(userId).fold( + { StartDMResult.Success(it, isNew = true) }, + { StartDMResult.Failure(it) } + ) + } else { + StartDMResult.DmDoesNotExist + } + }, + onFailure = { error -> + StartDMResult.Failure(error) + } + ) +} + +sealed interface StartDMResult { + data class Success(val roomId: RoomId, val isNew: Boolean) : StartDMResult + data object DmDoesNotExist : StartDMResult + data class Failure(val throwable: Throwable) : StartDMResult +} 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 0000000..452d934 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StateEventType.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room + +enum class StateEventType { + POLICY_RULE_ROOM, + POLICY_RULE_SERVER, + POLICY_RULE_USER, + CALL_MEMBER, + 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/alias/MatrixRoomAlias.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/alias/MatrixRoomAlias.kt new file mode 100644 index 0000000..1b26e8c --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/alias/MatrixRoomAlias.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.alias + +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.room.BaseRoom + +/** + * Return true if the given roomIdOrAlias is the same room as this room. + */ +fun BaseRoom.matches(roomIdOrAlias: RoomIdOrAlias): Boolean { + return when (roomIdOrAlias) { + is RoomIdOrAlias.Id -> { + roomIdOrAlias.roomId == roomId + } + is RoomIdOrAlias.Alias -> { + roomIdOrAlias.roomAlias == info().canonicalAlias || roomIdOrAlias.roomAlias in info().alternativeAliases + } + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/alias/ResolvedRoomAlias.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/alias/ResolvedRoomAlias.kt new file mode 100644 index 0000000..65a3f2a --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/alias/ResolvedRoomAlias.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.alias + +import io.element.android.libraries.matrix.api.core.RoomId + +/** + * Information about a room, that was resolved from a room alias. + */ +data class ResolvedRoomAlias( + /** + * The room ID that the alias resolved to. + */ + val roomId: RoomId, + /** + * A list of servers that can be used to find the room by its room ID. + */ + val servers: List +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/alias/RoomAliasHelper.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/alias/RoomAliasHelper.kt new file mode 100644 index 0000000..c232529 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/alias/RoomAliasHelper.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.alias + +import io.element.android.libraries.matrix.api.core.RoomAlias + +interface RoomAliasHelper { + fun roomAliasNameFromRoomDisplayName(name: String): String + fun isRoomAliasValid(roomAlias: RoomAlias): Boolean +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/draft/ComposerDraft.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/draft/ComposerDraft.kt new file mode 100644 index 0000000..bd1bdac --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/draft/ComposerDraft.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.draft + +/** + * A draft of a message composed by the user. + * @param plainText The draft content in plain text. + * @param htmlText If the message is formatted in HTML, the HTML representation of the message. + * @param draftType The type of draft. + */ +data class ComposerDraft( + val plainText: String, + val htmlText: String?, + val draftType: ComposerDraftType +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/draft/ComposerDraftType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/draft/ComposerDraftType.kt new file mode 100644 index 0000000..79e9bd2 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/draft/ComposerDraftType.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.draft + +import io.element.android.libraries.matrix.api.core.EventId + +sealed interface ComposerDraftType { + data object NewMessage : ComposerDraftType + data class Reply(val eventId: EventId) : ComposerDraftType + data class Edit(val eventId: EventId) : ComposerDraftType +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/errors/FocusEventException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/errors/FocusEventException.kt new file mode 100644 index 0000000..a966394 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/errors/FocusEventException.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.errors + +import io.element.android.libraries.matrix.api.core.EventId + +sealed class FocusEventException : Exception() { + data class InvalidEventId( + val eventId: String, + val err: String + ) : FocusEventException() + + data class EventNotFound( + val eventId: EventId + ) : FocusEventException() + + data class Other( + val msg: String + ) : FocusEventException() +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/history/RoomHistoryVisibility.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/history/RoomHistoryVisibility.kt new file mode 100644 index 0000000..9f3826f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/history/RoomHistoryVisibility.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.history + +sealed interface RoomHistoryVisibility { + /** + * Previous events are accessible to newly joined members from the point + * they were invited onwards. + * + * Events stop being accessible when the member's state changes to + * something other than *invite* or *join*. + */ + data object Invited : RoomHistoryVisibility + + /** + * Previous events are accessible to newly joined members from the point + * they joined the room onwards. + * Events stop being accessible when the member's state changes to + * something other than *join*. + */ + data object Joined : RoomHistoryVisibility + + /** + * Previous events are always accessible to newly joined members. + * + * All events in the room are accessible, even those sent when the member + * was not a part of the room. + */ + data object Shared : RoomHistoryVisibility + + /** + * All events while this is the `HistoryVisibility` value may be shared by + * any participating homeserver with anyone, regardless of whether they + * have ever joined the room. + */ + data object WorldReadable : RoomHistoryVisibility + + /** + * A custom visibility value. + */ + data class Custom(val value: String) : RoomHistoryVisibility +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/AllowRule.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/AllowRule.kt new file mode 100644 index 0000000..247fba8 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/AllowRule.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.join + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.core.RoomId + +@Immutable +sealed interface AllowRule { + data class RoomMembership(val roomId: RoomId) : AllowRule + data class Custom(val json: String) : AllowRule +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRoom.kt new file mode 100644 index 0000000..4220b68 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRoom.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.join + +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias + +interface JoinRoom { + suspend operator fun invoke( + roomIdOrAlias: RoomIdOrAlias, + serverNames: List, + trigger: JoinedRoom.Trigger, + ): Result + + sealed class Failures : Exception() { + data object UnauthorizedJoin : Failures() + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRule.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRule.kt new file mode 100644 index 0000000..dad2492 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRule.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.join + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +sealed interface JoinRule { + data object Public : JoinRule + data object Private : JoinRule + data object Knock : JoinRule + data object Invite : JoinRule + data class Restricted(val rules: ImmutableList) : JoinRule + data class KnockRestricted(val rules: ImmutableList) : JoinRule + data class Custom(val value: String) : JoinRule +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/knock/KnockRequest.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/knock/KnockRequest.kt new file mode 100644 index 0000000..3171577 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/knock/KnockRequest.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.knock + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId + +interface KnockRequest { + val eventId: EventId + val userId: UserId + val displayName: String? + val avatarUrl: String? + val reason: String? + val timestamp: Long? + val isSeen: Boolean + + suspend fun accept(): Result + + suspend fun decline(reason: String?): Result + + suspend fun declineAndBan(reason: String?): Result + + suspend fun markAsSeen(): Result +} 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 0000000..42d384f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/AssetType.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/powerlevels/MatrixRoomMembersWithRole.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomMembersWithRole.kt new file mode 100644 index 0000000..39e5daf --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomMembersWithRole.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.powerlevels + +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.activeRoomMembers +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart + +/** + * Return a flow of the list of active room members who have the given role. + */ +fun BaseRoom.usersWithRole(role: RoomMember.Role): Flow> { + // Ensure the room members flow is ready + val readyMembersFlow = membersStateFlow + .onStart { + if (membersStateFlow.value is RoomMembersState.Unknown) { + updateMembers() + } + } + .filter { it is RoomMembersState.Ready } + + return roomInfoFlow + .map { roomInfo -> roomInfo.usersWithRole(role) } + .combine(readyMembersFlow) { powerLevels, membersState -> + membersState.activeRoomMembers() + .filter { powerLevels.contains(it.userId) } + .toImmutableList() + } + .distinctUntilChanged() +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPowerLevels.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPowerLevels.kt new file mode 100644 index 0000000..19db749 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPowerLevels.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.powerlevels + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.RoomMember +import kotlinx.collections.immutable.ImmutableMap + +/** + * Represents the power levels in a Matrix room, containing both the levels needed to perform actions and the custom power levels for users. + * + * **WARNING**: this won't contain the power level of the room creators, as it is not stored in the power levels event. The `users` property is private to + * enforce this restriction and try to avoid using this property directly to check if a user has a certain role. + * Use the [usersWithRole] or [roleOf] methods instead, and never for creators, that logic should be handled separately. + */ +data class RoomPowerLevels( + /** + * The power levels required to perform various actions in the room. + */ + val values: RoomPowerLevelsValues, + private val users: ImmutableMap, +) { + /** + * Returns the power level of the user in the room. + * + * If the user is not found, returns 0. + */ + fun powerLevelOf(userId: UserId): Long { + return users[userId] ?: 0L + } + + /** + * Returns the set of [UserId]s that have the given role in the room. + * + * **WARNING**: This method must not be used with a creator role. It'll result in a runtime error. + */ + fun usersWithRole(role: RoomMember.Role): Set { + return if (role is RoomMember.Role.Owner && role.isCreator) { + error("RoomPowerLevels.usersWithRole should not be used with a creator role, use roomInfo.creators instead") + } else { + users.filterValues { RoomMember.Role.forPowerLevel(it) == role }.keys + } + } + + /** + * Returns the role of the user in the room based on their power level. + * If the user is not found, returns null. + * + * **WARNING**: This method must not be used with a creator role, as it won't return any results. + */ + fun roleOf(userId: UserId): RoomMember.Role? { + return users[userId]?.let(RoomMember.Role::forPowerLevel) + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPowerLevelsValues.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPowerLevelsValues.kt new file mode 100644 index 0000000..d20b614 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPowerLevelsValues.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.powerlevels + +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.room.StateEventType + +data class RoomPowerLevelsValues( + val ban: Long, + val invite: Long, + val kick: Long, + val sendEvents: Long, + val redactEvents: Long, + val roomName: Long, + val roomAvatar: Long, + val roomTopic: Long, + val spaceChild: Long, +) + +/** + * Shortcut for calling [BaseRoom.canUserInvite] with our own user. + */ +suspend fun BaseRoom.canInvite(): Result = canUserInvite(sessionId) + +/** + * Shortcut for calling [BaseRoom.canUserKick] with our own user. + */ +suspend fun BaseRoom.canKick(): Result = canUserKick(sessionId) + +/** + * Shortcut for calling [BaseRoom.canUserBan] with our own user. + */ +suspend fun BaseRoom.canBan(): Result = canUserBan(sessionId) + +/** + * Shortcut for calling [BaseRoom.canUserSendState] with our own user. + */ +suspend fun BaseRoom.canSendState(type: StateEventType): Result = canUserSendState(sessionId, type) + +/** + * Shortcut for calling [BaseRoom.canUserSendMessage] with our own user. + */ +suspend fun BaseRoom.canSendMessage(type: MessageEventType): Result = canUserSendMessage(sessionId, type) + +/** + * Shortcut for calling [BaseRoom.canUserRedactOwn] with our own user. + */ +suspend fun BaseRoom.canRedactOwn(): Result = canUserRedactOwn(sessionId) + +/** + * Shortcut for calling [BaseRoom.canRedactOther] with our own user. + */ +suspend fun BaseRoom.canRedactOther(): Result = canUserRedactOther(sessionId) + +/** + * Shortcut for checking if current user can handle knock requests. + */ +suspend fun BaseRoom.canHandleKnockRequests(): Result = runCatchingExceptions { + canInvite().getOrThrow() || canBan().getOrThrow() || canKick().getOrThrow() +} + +/** + * Shortcut for calling [BaseRoom.canUserPinUnpin] with our own user. + */ +suspend fun BaseRoom.canPinUnpin(): Result = canUserPinUnpin(sessionId) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/UserRoleChange.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/UserRoleChange.kt new file mode 100644 index 0000000..43d6b8a --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/UserRoleChange.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.powerlevels + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.RoomMember + +data class UserRoleChange( + val userId: UserId, + val role: RoomMember.Role, +) { + val powerLevel: Long = role.powerLevel +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/preview/RoomPreviewInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/preview/RoomPreviewInfo.kt new file mode 100644 index 0000000..ee422d6 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/preview/RoomPreviewInfo.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.preview + +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomType +import io.element.android.libraries.matrix.api.room.join.JoinRule + +data class RoomPreviewInfo( + /** The room id for this room. */ + val roomId: RoomId, + /** The canonical alias for the room. */ + val canonicalAlias: RoomAlias?, + /** The room's name, if set. */ + val name: String?, + /** The room's topic, if set. */ + val topic: String?, + /** The MXC URI to the room's avatar, if set. */ + val avatarUrl: String?, + /** The number of joined members. */ + val numberOfJoinedMembers: Long, + /** The room type (space, custom) or nothing, if it's a regular room. */ + val roomType: RoomType, + /** Is the history world-readable for this room? */ + val isHistoryWorldReadable: Boolean, + /** the membership of the current user. */ + val membership: CurrentUserMembership?, + /** The room's join rule. */ + val joinRule: JoinRule?, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/recent/RecentDirectRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/recent/RecentDirectRoom.kt new file mode 100644 index 0000000..8221674 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/recent/RecentDirectRoom.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.recent + +import io.element.android.libraries.matrix.api.MatrixClient +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.BaseRoom +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.room.toMatrixUser +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.coroutines.flow.first + +private const val MAX_RECENT_DIRECT_ROOMS_TO_RETURN = 5 + +data class RecentDirectRoom( + val roomId: RoomId, + val matrixUser: MatrixUser, +) + +suspend fun MatrixClient.getRecentDirectRooms( + maxNumberOfResults: Int = MAX_RECENT_DIRECT_ROOMS_TO_RETURN, +): List { + val result = mutableListOf() + val foundUserIds = mutableSetOf() + getRecentlyVisitedRooms().getOrNull()?.let { roomIds -> + roomIds + .mapNotNull { roomId -> getRoom(roomId) } + .filter { it.isDm() && it.isJoined() } + .map { room -> + val otherUser = room.getMembers().getOrNull() + ?.firstOrNull { it.userId != sessionId } + ?.takeIf { foundUserIds.add(it.userId) } + ?.toMatrixUser() + if (otherUser != null) { + result.add( + RecentDirectRoom(room.roomId, otherUser) + ) + // Return early to avoid useless computation + if (result.size >= maxNumberOfResults) { + return@map + } + } + } + } + return result +} + +suspend fun BaseRoom.isJoined(): Boolean { + return roomInfoFlow.first().currentUserMembership == CurrentUserMembership.JOINED +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/tombstone/PredecessorRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/tombstone/PredecessorRoom.kt new file mode 100644 index 0000000..d66d149 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/tombstone/PredecessorRoom.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.tombstone + +import io.element.android.libraries.matrix.api.core.RoomId + +/** + * + * When a room A is tombstoned, it is replaced by a room B. The room A is the + * predecessor of B, and B is the successor of A. This type holds information + * about the predecessor room. + * + * A room is tombstoned if it has received a m.room.tombstone state event. + */ +data class PredecessorRoom( + /** + * The ID of the replaced room. + */ + val roomId: RoomId, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/tombstone/SuccessorRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/tombstone/SuccessorRoom.kt new file mode 100644 index 0000000..c8179d8 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/tombstone/SuccessorRoom.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room.tombstone + +import io.element.android.libraries.matrix.api.core.RoomId + +/** + * + * When a room A is tombstoned, it is replaced by a room B. The room A is the + * predecessor of B, and B is the successor of A. This type holds information + * about the successor room. + * + * A room is tombstoned if it has received a m.room.tombstone state event. + * + */ +data class SuccessorRoom( + /** + * The ID of the replacement room. + */ + val roomId: RoomId, + /** + * The message explaining why the room has been tombstoned. + */ + val reason: String?, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDescription.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDescription.kt new file mode 100644 index 0000000..b628008 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDescription.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.roomdirectory + +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId + +data class RoomDescription( + val roomId: RoomId, + val name: String?, + val topic: String?, + val alias: RoomAlias?, + val avatarUrl: String?, + val joinRule: JoinRule, + val isWorldReadable: Boolean, + val numberOfMembers: Long +) { + enum class JoinRule { + PUBLIC, + KNOCK, + RESTRICTED, + KNOCK_RESTRICTED, + INVITE, + UNKNOWN + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryList.kt new file mode 100644 index 0000000..c48bf54 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryList.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.roomdirectory + +import kotlinx.coroutines.flow.Flow + +interface RoomDirectoryList { + /** + * Starts a filtered search for the server. + * If the filter is not provided it will search for all the rooms. You can specify a batch_size to control the number of rooms to fetch per request. + * If the via_server is not provided it will search in the current homeserver by default. + * This method will clear the current search results and start a new one + */ + suspend fun filter(filter: String?, batchSize: Int, viaServerName: String?): Result + + /** + * Load more rooms from the current search results. + */ + suspend fun loadMore(): Result + + /** + * The current search results as a state flow. + */ + val state: Flow + + data class SearchResult( + val hasMoreToLoad: Boolean, + val items: List, + ) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryService.kt new file mode 100644 index 0000000..8f71cfb --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryService.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.roomdirectory + +import kotlinx.coroutines.CoroutineScope + +interface RoomDirectoryService { + fun createRoomDirectoryList(scope: CoroutineScope): RoomDirectoryList +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomVisibility.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomVisibility.kt new file mode 100644 index 0000000..1d4cd18 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomVisibility.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.roomdirectory + +/** + * Enum class representing the visibility of a room in the room directory. + */ +sealed interface RoomVisibility { + /** + * Indicates that the room will be shown in the published room list. + */ + data object Public : RoomVisibility + + /** + * Indicates that the room will not be shown in the published room list. + */ + data object Private : RoomVisibility + + /** + * A custom value that's not present in the spec. + */ + data class Custom(val value: String) : RoomVisibility +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/DynamicRoomList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/DynamicRoomList.kt new file mode 100644 index 0000000..acba1e3 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/DynamicRoomList.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.roomlist + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * RoomList with dynamic filtering and loading. + * This is useful for large lists of rooms. + * It lets load rooms on demand and filter them. + */ +interface DynamicRoomList : RoomList { + val currentFilter: StateFlow + val loadedPages: StateFlow + val pageSize: Int + + val filteredSummaries: SharedFlow> + + /** + * Load more rooms into the list if possible. + */ + suspend fun loadMore() + + /** + * Reset the list to its initial size. + */ + suspend fun reset() + + /** + * Update the filter to apply to the list. + * @param filter the filter to apply. + */ + suspend fun updateFilter(filter: RoomListFilter) +} + +/** + * Offers a way to load all the rooms incrementally. + * It will load more room until all are loaded. + * If total number of rooms increase, it will load more pages if needed. + * The number of rooms is independent of the filter. + */ +fun DynamicRoomList.loadAllIncrementally(coroutineScope: CoroutineScope) { + combine( + loadedPages, + loadingState, + ) { loadedPages, loadingState -> + loadedPages to loadingState + } + .onEach { (loadedPages, loadingState) -> + when (loadingState) { + is RoomList.LoadingState.Loaded -> { + if (pageSize * loadedPages < loadingState.numberOfRooms) { + loadMore() + } + } + RoomList.LoadingState.NotLoaded -> Unit + } + } + .launchIn(coroutineScope) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/LatestEventValue.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/LatestEventValue.kt new file mode 100644 index 0000000..5482a67 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/LatestEventValue.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.roomlist + +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.ProfileDetails + +sealed interface LatestEventValue { + data object None : LatestEventValue + data class Remote( + val timestamp: Long, + val content: EventContent, + val senderId: UserId, + val senderProfile: ProfileDetails, + val isOwn: Boolean, + ) : LatestEventValue + + data class Local( + val timestamp: Long, + val content: EventContent, + val senderId: UserId, + val senderProfile: ProfileDetails, + val isSending: Boolean, + ) : LatestEventValue +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomList.kt new file mode 100644 index 0000000..d03a967 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomList.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.roomlist + +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withTimeout +import timber.log.Timber +import kotlin.time.Duration + +/** + * Holds some flows related to a specific set of rooms. + * Can be retrieved from [RoomListService] methods. + */ +interface RoomList { + /** + * The loading state of the room list. + */ + sealed interface LoadingState { + data object NotLoaded : LoadingState + data class Loaded(val numberOfRooms: Int) : LoadingState + } + + /** + * The source of the room list data. + * All: all rooms. + * + * To apply some dynamic filtering on top of that, use [DynamicRoomList]. + */ + enum class Source { + All + } + + /** + * The list of room summaries as a flow. + */ + val summaries: SharedFlow> + + /** + * The loading state of the room list as a flow. + * This is useful to know if a specific set of rooms is loaded or not. + */ + val loadingState: StateFlow + + /** + * Force a refresh of the room summaries. + * Might be useful for some situations where we are not notified of changes. + */ + suspend fun rebuildSummaries() +} + +suspend fun RoomList.awaitLoaded(timeout: Duration = Duration.INFINITE) { + try { + Timber.d("awaitAllRoomsAreLoaded: wait") + withTimeout(timeout) { + loadingState.firstOrNull { + it is RoomList.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/roomlist/RoomListFilter.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt new file mode 100644 index 0000000..33d233d --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.roomlist + +import io.element.android.libraries.core.extensions.withoutAccents + +sealed interface RoomListFilter { + companion object { + /** + * Create a filter that matches all the given filters. + * If no filters are provided, all the rooms will match. + */ + fun all(vararg filters: RoomListFilter): RoomListFilter { + return All(filters.toList()) + } + + /** + * Create a filter that matches any of the given filters. + */ + fun any(vararg filters: RoomListFilter): RoomListFilter { + return Any(filters.toList()) + } + } + + /** + * A filter that matches all the given filters. + * If [filters] is empty, all the room will match. + */ + data class All( + val filters: List + ) : RoomListFilter + + /** + * A filter that matches any of the given filters. + */ + data class Any( + val filters: List + ) : RoomListFilter + + /** + * A filter that matches rooms that are unread. + */ + data object Unread : RoomListFilter + + /** + * A filter that matches rooms that are marked as favorite. + */ + data object Favorite : RoomListFilter + + /** + * A filter that matches rooms with Invited membership. + */ + data object Invite : RoomListFilter + + /** + * A filter that matches either Group,People rooms or Space. + */ + sealed interface Category : RoomListFilter { + data object Group : Category + data object People : Category + data object Space : Category + } + + /** + * A filter that matches no room. + */ + data object None : RoomListFilter + + /** + * A filter that matches rooms with a name using a normalized match. + */ + data class NormalizedMatchRoomName( + val pattern: String + ) : RoomListFilter { + val normalizedPattern: String = pattern.withoutAccents() + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt new file mode 100644 index 0000000..4a53179 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.roomlist + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterIsInstance + +/** + * Entry point for the room list api. + * This service will provide different sets of rooms (all, invites, etc.). + * It requires the SyncService to be started to receive updates. + */ +interface RoomListService { + @Immutable + sealed interface State { + data object Idle : State + data object Running : State + data object Error : State + data object Terminated : State + } + + @Immutable + sealed interface SyncIndicator { + data object Show : SyncIndicator + data object Hide : SyncIndicator + } + + /** + * Creates a room list that can be used to load more rooms and filter them dynamically. + * @param pageSize the number of rooms to load at once. + * @param initialFilter the initial filter to apply to the rooms. + * @param source the source of the rooms, either all rooms or invites. + */ + fun createRoomList( + pageSize: Int, + initialFilter: RoomListFilter, + source: RoomList.Source, + ): DynamicRoomList + + /** + * Subscribes to sync requests for the visible rooms. + * @param roomIds the list of visible room ids to subscribe to. + */ + suspend fun subscribeToVisibleRooms(roomIds: List) + + /** + * Returns a [DynamicRoomList] object of all rooms we want to display. + * If you want to get a filtered room list, consider using [createRoomList]. + */ + val allRooms: DynamicRoomList + + /** + * The sync indicator as a flow. + */ + val syncIndicator: StateFlow + + /** + * The state of the service as a flow. + */ + val state: StateFlow +} + +fun RoomList.loadedStateFlow(): Flow { + return loadingState.filterIsInstance() +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt new file mode 100644 index 0000000..89a4acf --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.roomlist + +import io.element.android.libraries.matrix.api.room.RoomInfo + +data class RoomSummary( + val info: RoomInfo, + val latestEvent: LatestEventValue, +) { + val roomId = info.id + val latestEventTimestamp = when (latestEvent) { + is LatestEventValue.None -> null + is LatestEventValue.Local -> latestEvent.timestamp + is LatestEventValue.Remote -> latestEvent.timestamp + } + val isOneToOne get() = info.activeMembersCount == 2L +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/server/UserServerResolver.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/server/UserServerResolver.kt new file mode 100644 index 0000000..3529b17 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/server/UserServerResolver.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.server + +interface UserServerResolver { + fun resolve(): String +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceHandle.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceHandle.kt new file mode 100644 index 0000000..174ec20 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceHandle.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.spaces + +import io.element.android.libraries.matrix.api.core.RoomId + +interface LeaveSpaceHandle { + /** + * The id of the space to leave. + */ + val id: RoomId + + /** + * Get a list of rooms that can be left when leaving the space. + * It will include the current space and all the subspaces and rooms that the user has joined. + */ + suspend fun rooms(): Result> + + /** + * Leave the space and the given rooms. + * If [roomIds] is empty, only the space will be left. + */ + suspend fun leave(roomIds: List): Result + + /** + * Close the handle and free resources. + */ + fun close() +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceRoom.kt new file mode 100644 index 0000000..071d569 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceRoom.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.spaces + +data class LeaveSpaceRoom( + val spaceRoom: SpaceRoom, + val isLastAdmin: Boolean, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoom.kt new file mode 100644 index 0000000..d21c376 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoom.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.spaces + +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomType +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList + +data class SpaceRoom( + val rawName: String?, + val displayName: String, + val avatarUrl: String?, + val canonicalAlias: RoomAlias?, + val childrenCount: Int, + val guestCanJoin: Boolean, + val heroes: ImmutableList, + val joinRule: JoinRule?, + val numJoinedMembers: Int, + val roomId: RoomId, + val roomType: RoomType, + val state: CurrentUserMembership?, + val topic: String?, + val worldReadable: Boolean, + /** + * The via parameters of the room. + */ + val via: ImmutableList, + val isDirect: Boolean?, +) { + val isSpace = roomType == RoomType.Space + + val visibility = SpaceRoomVisibility.fromJoinRule(joinRule) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt new file mode 100644 index 0000000..e2528bf --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.spaces + +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import java.util.Optional + +interface SpaceRoomList { + sealed interface PaginationStatus { + data object Loading : PaginationStatus + data class Idle(val hasMoreToLoad: Boolean) : PaginationStatus + } + + val roomId: RoomId + + val currentSpaceFlow: StateFlow> + + val spaceRoomsFlow: Flow> + val paginationStatusFlow: StateFlow + suspend fun paginate(): Result + + fun destroy() +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomVisibility.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomVisibility.kt new file mode 100644 index 0000000..47a7446 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomVisibility.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.spaces + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.room.join.JoinRule +@Immutable +sealed interface SpaceRoomVisibility { + data object Private : SpaceRoomVisibility + data object Public : SpaceRoomVisibility + data object Restricted : SpaceRoomVisibility + + companion object { + fun fromJoinRule(joinRule: JoinRule?): SpaceRoomVisibility = when (joinRule) { + JoinRule.Public -> Public + is JoinRule.Restricted, is JoinRule.KnockRestricted -> Restricted + // Else fallback to Private + else -> Private + } + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt new file mode 100644 index 0000000..6f5ba67 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.spaces + +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.flow.SharedFlow + +interface SpaceService { + val spaceRoomsFlow: SharedFlow> + suspend fun joinedSpaces(): Result> + + fun spaceRoomList(id: RoomId): SpaceRoomList + + fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SlidingSyncVersion.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SlidingSyncVersion.kt new file mode 100644 index 0000000..8a60626 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SlidingSyncVersion.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.sync + +sealed interface SlidingSyncVersion { + data object None : SlidingSyncVersion + data object Proxy : SlidingSyncVersion + data object Native : SlidingSyncVersion +} 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 0000000..ce0019d --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 + + /** + * Tries to stop the sync. If service is not syncing it has no effect. + */ + suspend fun stopSync(): Result + + /** + * Flow of [SyncState]. Will be updated as soon as the current [SyncState] changes. + */ + val syncState: StateFlow + + val isOnline: StateFlow +} 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 0000000..e4210c8 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncState.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.sync + +enum class SyncState { + Idle, + Running, + Error, + Terminated, + Offline, +} 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 0000000..b9e9608 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MatrixTimelineItem.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.core.UniqueId +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: UniqueId, val event: EventTimelineItem) : MatrixTimelineItem { + val eventId: EventId? = event.eventId + val transactionId: TransactionId? = event.transactionId + } + + data class Virtual(val uniqueId: UniqueId, val virtual: VirtualTimelineItem) : MatrixTimelineItem + data object Other : MatrixTimelineItem +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/ReceiptType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/ReceiptType.kt new file mode 100644 index 0000000..f0c9bba --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/ReceiptType.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline + +enum class ReceiptType { + READ, + READ_PRIVATE, + FULLY_READ +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt new file mode 100644 index 0000000..500d9f3 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +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.ThreadId +import io.element.android.libraries.matrix.api.core.TransactionId +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.MediaUploadHandler +import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.matrix.api.room.IntentionalMention +import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.parcelize.Parcelize +import java.io.File + +interface Timeline : AutoCloseable { + data class PaginationStatus( + val isPaginating: Boolean, + val hasMoreToLoad: Boolean, + ) { + val canPaginate: Boolean = !isPaginating && hasMoreToLoad + } + + enum class PaginationDirection { + BACKWARDS, + FORWARDS + } + + @Parcelize + @Immutable + sealed interface Mode : Parcelable { + data object Live : Mode + data class FocusedOnEvent(val eventId: EventId) : Mode + data object PinnedEvents : Mode + data object Media : Mode + data class Thread(val threadRootId: ThreadId) : Mode + } + + val mode: Mode + val membershipChangeEventReceived: Flow + val onSyncedEventReceived: Flow + suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result + suspend fun markAsRead(receiptType: ReceiptType): Result + suspend fun paginate(direction: PaginationDirection): Result + + val backwardPaginationStatus: StateFlow + val forwardPaginationStatus: StateFlow + + val timelineItems: Flow> + + suspend fun sendMessage( + body: String, + htmlBody: String?, + intentionalMentions: List, + ): Result + + suspend fun editMessage( + eventOrTransactionId: EventOrTransactionId, + body: String, + htmlBody: String?, + intentionalMentions: List, + ): Result + + suspend fun editCaption( + eventOrTransactionId: EventOrTransactionId, + caption: String?, + formattedCaption: String?, + ): Result + + suspend fun replyMessage( + repliedToEventId: EventId, + body: String, + htmlBody: String?, + intentionalMentions: List, + fromNotification: Boolean = false, + ): Result + + suspend fun sendImage( + file: File, + thumbnailFile: File?, + imageInfo: ImageInfo, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result + + suspend fun sendVideo( + file: File, + thumbnailFile: File?, + videoInfo: VideoInfo, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result + + suspend fun sendAudio( + file: File, + audioInfo: AudioInfo, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result + + suspend fun sendFile( + file: File, + fileInfo: FileInfo, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result + + /** + * 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. + * @param inReplyToEventId Optional [EventId] for the event this message should reply to. + */ + suspend fun sendLocation( + body: String, + geoUri: String, + description: String? = null, + zoomLevel: Int? = null, + assetType: AssetType? = null, + inReplyToEventId: EventId?, + ): Result + + suspend fun sendVoiceMessage( + file: File, + audioInfo: AudioInfo, + waveform: List, + inReplyToEventId: EventId?, + ): Result + + suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result + + suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result + + suspend fun forwardEvent(eventId: EventId, roomIds: List): Result + + suspend fun cancelSend(transactionId: TransactionId): Result = + redactEvent(transactionId.toEventOrTransactionId(), reason = null) + + /** + * Create a poll in the room. + * + * @param question The question to ask. + * @param answers The list of answers. + * @param maxSelections The maximum number of answers that can be selected. + * @param pollKind The kind of poll to create. + */ + suspend fun createPoll( + question: String, + answers: List, + maxSelections: Int, + pollKind: PollKind, + ): Result + + /** + * Edit a poll in the room. + * + * @param pollStartId The event ID of the poll start event. + * @param question The question to ask. + * @param answers The list of answers. + * @param maxSelections The maximum number of answers that can be selected. + * @param pollKind The kind of poll to create. + */ + suspend fun editPoll( + pollStartId: EventId, + question: String, + answers: List, + maxSelections: Int, + pollKind: PollKind, + ): Result + + /** + * Send a response to a poll. + * + * @param pollStartId The event ID of the poll start event. + * @param answers The list of answer ids to send. + */ + suspend fun sendPollResponse(pollStartId: EventId, answers: List): Result + + /** + * Ends a poll in the room. + * + * @param pollStartId The event ID of the poll start event. + * @param text Fallback text of the poll end event. + */ + suspend fun endPoll(pollStartId: EventId, text: String): Result + + suspend fun loadReplyDetails(eventId: EventId): InReplyTo + + /** + * Adds a new pinned event by sending an updated `m.room.pinned_events` + * event containing the new event id. + * + * Returns `true` if we sent the request, `false` if the event was already + * pinned. + */ + suspend fun pinEvent(eventId: EventId): Result + + /** + * Adds a new pinned event by sending an updated `m.room.pinned_events` + * event without the event id we want to remove. + * + * Returns `true` if we sent the request, `false` if the event wasn't + * pinned + */ + suspend fun unpinEvent(eventId: EventId): Result + + /** + * Get the latest event id of the timeline. + */ + suspend fun getLatestEventId(): Result +} 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 0000000..0e05257 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineException.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline + +sealed class TimelineException : Exception() { + data object CannotPaginate : TimelineException() + data object EventNotFound : TimelineException() +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineProvider.kt new file mode 100644 index 0000000..28e487f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineProvider.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline + +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first + +/** + * This interface defines a way to get the active timeline. + * It could be the live timeline, a pinned timeline or a detached timeline. + * By default, the active timeline is the live timeline. + */ +fun interface TimelineProvider { + fun activeTimelineFlow(): StateFlow +} + +suspend fun TimelineProvider.getActiveTimeline(): Timeline = activeTimelineFlow().filterNotNull().first() diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/ThreadSummary.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/ThreadSummary.kt new file mode 100644 index 0000000..9792399 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/ThreadSummary.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline.item + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.ThreadId +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.EventOrTransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails + +sealed interface EventThreadInfo { + data class ThreadRoot(val summary: ThreadSummary) : EventThreadInfo + data class ThreadResponse(val threadRootId: ThreadId) : EventThreadInfo +} + +data class ThreadSummary( + val latestEvent: AsyncData, + val numberOfReplies: Long, +) + +data class EmbeddedEventInfo( + val eventOrTransactionId: EventOrTransactionId, + val content: EventContent, + val senderId: UserId, + val senderProfile: ProfileDetails, + val timestamp: Long, +) 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 0000000..f34e418 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/TimelineItemDebugInfo.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..c6272e8 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline.item.event + +import androidx.compose.runtime.Immutable +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.poll.PollAnswer +import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap + +@Immutable +sealed interface EventContent + +data class MessageContent( + val body: String, + val inReplyTo: InReplyTo?, + val isEdited: Boolean, + val threadInfo: EventThreadInfo?, + val type: MessageType +) : EventContent + +data object RedactedContent : EventContent + +data class StickerContent( + val filename: String, + val body: String?, + val info: ImageInfo, + val source: MediaSource, +) : EventContent { + val bestDescription: String + get() = body ?: filename +} + +data class PollContent( + val question: String, + val kind: PollKind, + val maxSelections: ULong, + val answers: ImmutableList, + val votes: ImmutableMap>, + val endTime: ULong?, + val isEdited: Boolean, +) : EventContent + +data class UnableToDecryptContent( + val data: Data +) : EventContent { + @Immutable + sealed interface Data { + data class OlmV1Curve25519AesSha2( + val senderKey: String + ) : Data + + data class MegolmV1AesSha2( + val sessionId: String, + val utdCause: UtdCause + ) : Data + + data object Unknown : Data + } +} + +data class RoomMembershipContent( + val userId: UserId, + val userDisplayName: String?, + val change: MembershipChange?, + val reason: String?, +) : 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 + +data object LegacyCallInviteContent : EventContent + +data object CallNotifyContent : EventContent + +data object UnknownContent : EventContent diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventOrTransactionId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventOrTransactionId.kt new file mode 100644 index 0000000..a900326 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventOrTransactionId.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline.item.event + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.TransactionId + +@Immutable +sealed interface EventOrTransactionId { + @JvmInline + value class Event(val id: EventId) : EventOrTransactionId + + @JvmInline + value class Transaction(val id: TransactionId) : EventOrTransactionId + + val eventId: EventId? + get() = (this as? Event)?.id + + companion object { + fun from(eventId: EventId?, transactionId: TransactionId?): EventOrTransactionId { + return when { + eventId != null -> Event(eventId) + transactionId != null -> Transaction(transactionId) + else -> throw IllegalArgumentException("EventId and TransactionId are both null") + } + } + } +} + +fun EventId.toEventOrTransactionId() = EventOrTransactionId.Event(this) +fun TransactionId.toEventOrTransactionId() = EventOrTransactionId.Transaction(this) 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 0000000..c9dd22e --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventReaction.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline.item.event + +import kotlinx.collections.immutable.ImmutableList + +data class EventReaction( + val key: String, + val senders: ImmutableList +) 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 0000000..8294b78 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.SendHandle +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.EventThreadInfo +import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import kotlinx.collections.immutable.ImmutableList + +data class EventTimelineItem( + val eventId: EventId?, + val transactionId: TransactionId?, + val isEditable: Boolean, + val canBeRepliedTo: Boolean, + val isOwn: Boolean, + val isRemote: Boolean, + val localSendState: LocalEventSendState?, + val reactions: ImmutableList, + val receipts: ImmutableList, + val sender: UserId, + val senderProfile: ProfileDetails, + val timestamp: Long, + val content: EventContent, + val origin: TimelineItemEventOrigin?, + val timelineItemDebugInfoProvider: TimelineItemDebugInfoProvider, + val messageShieldProvider: MessageShieldProvider, + val sendHandleProvider: SendHandleProvider, +) { + fun inReplyTo(): InReplyTo? { + return (content as? MessageContent)?.inReplyTo + } + + fun threadInfo(): EventThreadInfo? = (content as? MessageContent)?.threadInfo + + fun hasNotLoadedInReplyTo(): Boolean { + val details = inReplyTo() + return details is InReplyTo.NotLoaded + } +} + +fun interface TimelineItemDebugInfoProvider { + operator fun invoke(): TimelineItemDebugInfo +} + +fun interface MessageShieldProvider { + operator fun invoke(strict: Boolean): MessageShield? +} + +fun interface SendHandleProvider { + operator fun invoke(): SendHandle? +} 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 0000000..8978d28 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventType.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline.item.event + +/** + * Constants defining known event types from Matrix specifications. + */ +object EventType { + const val MESSAGE = "m.room.message" + + // Call Events + const val CALL_INVITE = "m.call.invite" + + const val RTC_NOTIFICATION = "org.matrix.msc4075.rtc.notification" +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/FormattedBody.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/FormattedBody.kt new file mode 100644 index 0000000..88dda6c --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/FormattedBody.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline.item.event + +data class FormattedBody( + val format: MessageFormat, + val body: String +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/InReplyTo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/InReplyTo.kt new file mode 100644 index 0000000..43a5460 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/InReplyTo.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline.item.event + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId + +@Immutable +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. */ + data class Pending(val eventId: EventId) : InReplyTo + + /** The event details are available. */ + data class Ready( + val eventId: EventId, + val content: EventContent, + val senderId: UserId, + val senderProfile: ProfileDetails, + ) : 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. + * */ + data class Error( + val eventId: EventId, + val message: String, + ) : InReplyTo +} 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 0000000..05a510b --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/LocalEventSendState.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline.item.event + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId + +@Immutable +sealed interface LocalEventSendState { + sealed interface Sending : LocalEventSendState { + data object Event : Sending + data class MediaWithProgress( + val index: Long, + val progress: Long, + val total: Long + ) : Sending + } + sealed interface Failed : LocalEventSendState { + data class Unknown(val error: String) : Failed + data object SendingFromUnverifiedDevice : Failed + + sealed interface VerifiedUser : Failed + data class VerifiedUserHasUnsignedDevice( + /** + * The unsigned devices belonging to verified users. A map from user ID + * to a list of device IDs. + */ + val devices: Map> + ) : VerifiedUser + + data class VerifiedUserChangedIdentity( + /** + * The users that were previously verified but are no longer. + */ + val users: List + ) : VerifiedUser + + data class InvalidMimeType(val mimeType: String) : Failed + + data object MissingMediaContent : Failed + } + + 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/MembershipChange.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MembershipChange.kt new file mode 100644 index 0000000..e8cd52e --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MembershipChange.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline.item.event + +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 +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageFormat.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageFormat.kt new file mode 100644 index 0000000..8ef6b1d --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageFormat.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline.item.event + +enum class MessageFormat { + HTML, + UNKNOWN +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageShield.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageShield.kt new file mode 100644 index 0000000..a422e0b --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageShield.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline.item.event + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface MessageShield { + /** Not enough information available to check the authenticity. */ + data class AuthenticityNotGuaranteed(val isCritical: Boolean) : MessageShield + + /** The sending device isn't yet known by the Client. */ + data class UnknownDevice(val isCritical: Boolean) : MessageShield + + /** The sending device hasn't been verified by the sender. */ + data class UnsignedDevice(val isCritical: Boolean) : MessageShield + + /** The sender hasn't been verified by the Client's user. */ + data class UnverifiedIdentity(val isCritical: Boolean) : MessageShield + + /** An unencrypted event in an encrypted room. */ + data class SentInClear(val isCritical: Boolean) : MessageShield + + /** The sender was previously verified but is not anymore. */ + data class VerificationViolation(val isCritical: Boolean) : MessageShield + + /** The sender of the event does not match the owner of the device that created the Megolm session. */ + data class MismatchedSender(val isCritical: Boolean) : MessageShield +} + +val MessageShield.isCritical: Boolean + get() = when (this) { + is MessageShield.AuthenticityNotGuaranteed -> isCritical + is MessageShield.UnknownDevice -> isCritical + is MessageShield.UnsignedDevice -> isCritical + is MessageShield.UnverifiedIdentity -> isCritical + is MessageShield.SentInClear -> isCritical + is MessageShield.VerificationViolation -> isCritical + is MessageShield.MismatchedSender -> isCritical + } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt new file mode 100644 index 0000000..6de2876 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline.item.event + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.media.AudioDetails +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 + +@Immutable +sealed interface MessageType + +@Immutable +sealed interface MessageTypeWithAttachment : MessageType { + val filename: String + val caption: String? + val formattedCaption: FormattedBody? + + val bestDescription: String + get() = caption ?: filename +} + +data class EmoteMessageType( + val body: String, + val formatted: FormattedBody? +) : MessageType + +data class ImageMessageType( + override val filename: String, + override val caption: String?, + override val formattedCaption: FormattedBody?, + val source: MediaSource, + val info: ImageInfo? +) : MessageTypeWithAttachment + +// FIXME This is never used in production code. +data class StickerMessageType( + override val filename: String, + override val caption: String?, + override val formattedCaption: FormattedBody?, + val source: MediaSource, + val info: ImageInfo? +) : MessageTypeWithAttachment + +data class LocationMessageType( + val body: String, + val geoUri: String, + val description: String?, +) : MessageType + +data class AudioMessageType( + override val filename: String, + override val caption: String?, + override val formattedCaption: FormattedBody?, + val source: MediaSource, + val info: AudioInfo?, +) : MessageTypeWithAttachment + +data class VoiceMessageType( + override val filename: String, + override val caption: String?, + override val formattedCaption: FormattedBody?, + val source: MediaSource, + val info: AudioInfo?, + val details: AudioDetails?, +) : MessageTypeWithAttachment + +data class VideoMessageType( + override val filename: String, + override val caption: String?, + override val formattedCaption: FormattedBody?, + val source: MediaSource, + val info: VideoInfo? +) : MessageTypeWithAttachment + +data class FileMessageType( + override val filename: String, + override val caption: String?, + override val formattedCaption: FormattedBody?, + val source: MediaSource, + val info: FileInfo? +) : MessageTypeWithAttachment + +data class NoticeMessageType( + val body: String, + val formatted: FormattedBody? +) : MessageType + +data class TextMessageType( + val body: String, + val formatted: FormattedBody? +) : MessageType + +data class OtherMessageType( + val msgType: String, + val body: String, +) : MessageType diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/OtherState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/OtherState.kt new file mode 100644 index 0000000..ed3f531 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/OtherState.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline.item.event + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.room.join.JoinRule + +@Immutable +sealed interface OtherState { + data object PolicyRuleRoom : OtherState + data object PolicyRuleServer : OtherState + data object PolicyRuleUser : OtherState + data object RoomAliases : OtherState + data class RoomAvatar(val url: String?) : OtherState + data object RoomCanonicalAlias : OtherState + data object RoomCreate : OtherState + data object RoomEncryption : OtherState + data object RoomGuestAccess : OtherState + data object RoomHistoryVisibility : OtherState + data class RoomJoinRules(val joinRule: JoinRule?) : OtherState + data class RoomName(val name: String?) : OtherState + data class RoomPinnedEvents(val change: Change) : OtherState { + enum class Change { + ADDED, + REMOVED, + CHANGED + } + } + + data class RoomUserPowerLevels(val users: Map) : OtherState + data object RoomServerAcl : OtherState + data class RoomThirdPartyInvite(val displayName: String?) : OtherState + data object RoomTombstone : OtherState + data class RoomTopic(val topic: String?) : OtherState + data object SpaceChild : OtherState + data 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/ProfileDetails.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ProfileDetails.kt new file mode 100644 index 0000000..6393a27 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ProfileDetails.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline.item.event + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.core.UserId + +@Immutable +sealed interface ProfileDetails { + data object Unavailable : ProfileDetails + + data object Pending : ProfileDetails + + data class Ready( + val displayName: String?, + val displayNameAmbiguous: Boolean, + val avatarUrl: String? + ) : ProfileDetails + + data class Error( + val message: String + ) : ProfileDetails +} + +/** + * Returns a disambiguated display name for the user. + * If the display name is null, or profile is not Ready, the user ID is returned. + * If the display name is ambiguous, the user ID is appended in parentheses. + * Otherwise, the display name is returned. + */ +fun ProfileDetails.getDisambiguatedDisplayName(userId: UserId): String { + return when (this) { + is ProfileDetails.Ready -> when { + displayName == null -> userId.value + displayNameAmbiguous -> "$displayName ($userId)" + else -> displayName + } + else -> userId.value + } +} + +fun ProfileDetails.getDisplayName(): String? { + return when (this) { + is ProfileDetails.Ready -> displayName + else -> null + } +} + +fun ProfileDetails.getAvatarUrl(): String? { + return when (this) { + is ProfileDetails.Ready -> avatarUrl + else -> null + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ReactionSender.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ReactionSender.kt new file mode 100644 index 0000000..92eb0b5 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ReactionSender.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline.item.event + +import io.element.android.libraries.matrix.api.core.UserId + +/** + * The sender of a reaction. + * + * @property senderId the ID of the user who sent the reaction + * @property timestamp the timestamp the reaction was received on the origin homeserver + */ +data class ReactionSender( + val senderId: UserId, + val timestamp: Long +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/Receipt.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/Receipt.kt new file mode 100644 index 0000000..9a371ba --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/Receipt.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline.item.event + +import io.element.android.libraries.matrix.api.core.UserId + +data class Receipt( + val userId: UserId, + val timestamp: Long, +) 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 0000000..f867a03 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/TimelineItemEventOrigin.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline.item.event + +enum class TimelineItemEventOrigin { + LOCAL, + SYNC, + PAGINATION, + CACHE, +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/UtdCause.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/UtdCause.kt new file mode 100644 index 0000000..6203d34 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/UtdCause.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline.item.event + +enum class UtdCause { + Unknown, + SentBeforeWeJoined, + VerificationViolation, + UnsignedDevice, + UnknownDevice, + + /** + * We are missing the keys for this event, but it is a "device-historical" message and + * there is no key storage backup on the server, presumably because the user has turned it off. + */ + HistoricalMessageAndBackupIsDisabled, + + /** + * We are missing the keys for this event, but it is a "device-historical" + * message, and even though a key storage backup does exist, we can't use + * it because our device is unverified. + */ + HistoricalMessageAndDeviceIsUnverified, + + /** + * The key was withheld on purpose because your device is insecure and/or the + * sender trust requirement settings are not met for your device. + */ + WithheldUnverifiedOrInsecureDevice, + + /** + * Key is withheld by sender. + */ + WithheldBySender, +} 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 0000000..8e32c2e --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline.item.virtual + +import io.element.android.libraries.matrix.api.timeline.Timeline + +sealed interface VirtualTimelineItem { + data class DayDivider( + val timestamp: Long + ) : VirtualTimelineItem + + data object ReadMarker : VirtualTimelineItem + + data object RoomBeginning : VirtualTimelineItem + + data object LastForwardIndicator : VirtualTimelineItem + + data class LoadingIndicator( + val direction: Timeline.PaginationDirection, + val timestamp: Long, + ) : VirtualTimelineItem + + data object TypingNotification : VirtualTimelineItem +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/LogLevel.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/LogLevel.kt new file mode 100644 index 0000000..9cd40f4 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/LogLevel.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.tracing + +/** + * Log levels for tracing in the SDK. + */ +enum class LogLevel { + ERROR, + WARN, + INFO, + DEBUG, + TRACE, +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TraceLogPack.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TraceLogPack.kt new file mode 100644 index 0000000..4f71f73 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TraceLogPack.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.tracing + +enum class TraceLogPack(val key: String) { + EVENT_CACHE("event_cache") { + override val title: String = "Event Cache" + }, + SEND_QUEUE("send_queue") { + override val title: String = "Send Queue" + }, + TIMELINE("timeline") { + override val title: String = "Timeline" + }, + NOTIFICATION_CLIENT("notification_client") { + override val title: String = "Notification Client" + }; + + abstract val title: String +} 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 0000000..45d6e7e --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingConfiguration.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.tracing + +data class TracingConfiguration( + val logLevel: LogLevel, + val extraTargets: List, + val traceLogPacks: Set, + val writesToLogcat: Boolean, + val writesToFilesConfiguration: WriteToFilesConfiguration, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingService.kt new file mode 100644 index 0000000..d4ef27f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingService.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.tracing + +import timber.log.Timber + +interface TracingService { + fun createTimberTree(target: String): Timber.Tree + + fun updateWriteToFilesConfiguration(config: WriteToFilesConfiguration) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/WriteToFilesConfiguration.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/WriteToFilesConfiguration.kt new file mode 100644 index 0000000..6482284 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/WriteToFilesConfiguration.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.tracing + +sealed interface WriteToFilesConfiguration { + data object Disabled : WriteToFilesConfiguration + data class Enabled( + val directory: String, + val filenamePrefix: String, + val numberOfFiles: Int?, + ) : WriteToFilesConfiguration { + // DO NOT CHANGE: suffix *MUST* be "log" for the rageshake server to not rename the file to something generic + val filenameSuffix = "log" + } +} 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 0000000..5f49ae6 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixSearchUserResults.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.user + +import kotlinx.collections.immutable.ImmutableList + +data class MatrixSearchUserResults( + val results: ImmutableList, + 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 0000000..53cefa3 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/SessionVerificationData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationData.kt new file mode 100644 index 0000000..e96681a --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationData.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.verification + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface SessionVerificationData { + data class Emojis( + // 7 emojis + val emojis: List, + ) : SessionVerificationData + + data class Decimals( + // 3 numbers + val decimals: List, + ) : SessionVerificationData +} + +// https://spec.matrix.org/unstable/client-server-api/#sas-method-emoji +data class VerificationEmoji( + val number: Int, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationRequestDetails.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationRequestDetails.kt new file mode 100644 index 0000000..175c9fb --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationRequestDetails.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.verification + +import android.os.Parcelable +import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.core.FlowId +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.parcelize.Parcelize + +@Parcelize +data class SessionVerificationRequestDetails( + val senderProfile: MatrixUser, + val flowId: FlowId, + val deviceId: DeviceId, + val deviceDisplayName: String?, + val firstSeenTimestamp: Long, +) : 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 0000000..4d2e786 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.verification + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface SessionVerificationService { + /** + * State of the current verification flow ([VerificationFlowState.Initial] if not started). + */ + val verificationFlowState: StateFlow + + /** + * Returns whether the current verification status is either: [SessionVerifiedStatus.Unknown], [SessionVerifiedStatus.NotVerified] + * or [SessionVerifiedStatus.Verified]. + */ + val sessionVerifiedStatus: StateFlow + + /** + * Returns whether the current session needs to be verified. + */ + val needsSessionVerification: Flow + + /** + * Request verification of the current session. + */ + suspend fun requestCurrentSessionVerification() + + /** + * Request verification of the user with the given [userId]. + */ + suspend fun requestUserVerification(userId: UserId) + + /** + * 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(cancelAnyPendingVerificationAttempt: Boolean) + + /** + * Register a listener to be notified of incoming session verification requests. + */ + fun setListener(listener: SessionVerificationServiceListener?) + + /** + * Set this particular request as the currently active one and register for + * events pertaining it. + */ + suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming) + + /** + * Accept the previously acknowledged verification request. + */ + suspend fun acceptVerificationRequest() +} + +interface SessionVerificationServiceListener { + fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) +} + +/** Verification status of the current session. */ +@Immutable +sealed interface SessionVerifiedStatus { + /** Unknown status, we couldn't read the actual value from the SDK. */ + data object Unknown : SessionVerifiedStatus + + /** Not verified session status. */ + data object NotVerified : SessionVerifiedStatus + + /** Verified session status. */ + data object Verified : SessionVerifiedStatus + + /** Returns whether the session is [Verified]. */ + fun isVerified(): Boolean = this is Verified +} + +/** States produced by the [SessionVerificationService]. */ +@Immutable +sealed interface VerificationFlowState { + /** Initial state. */ + data object Initial : VerificationFlowState + + /** Session verification request was accepted by another device. */ + data object DidAcceptVerificationRequest : VerificationFlowState + + /** Short Authentication String (SAS) verification started between the 2 devices. */ + data object DidStartSasVerification : VerificationFlowState + + /** Verification data for the SAS verification received. */ + data class DidReceiveVerificationData(val data: SessionVerificationData) : VerificationFlowState + + /** Verification completed successfully. */ + data object DidFinish : VerificationFlowState + + /** Verification was cancelled by either device. */ + data object DidCancel : VerificationFlowState + + /** Verification failed with an error. */ + data object DidFail : VerificationFlowState +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/VerificationRequest.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/VerificationRequest.kt new file mode 100644 index 0000000..303ab28 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/VerificationRequest.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.verification + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.parcelize.Parcelize + +@Immutable +sealed interface VerificationRequest : Parcelable { + @Immutable + sealed interface Outgoing : VerificationRequest { + @Parcelize + data object CurrentSession : Outgoing + + @Parcelize + data class User(val userId: UserId) : Outgoing + } + + sealed class Incoming(open val details: SessionVerificationRequestDetails) : VerificationRequest { + @Parcelize + data class OtherSession(override val details: SessionVerificationRequestDetails) : Incoming(details) + + @Parcelize + data class User(override val details: SessionVerificationRequestDetails) : Incoming(details) + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallAnalyticCredentialsProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallAnalyticCredentialsProvider.kt new file mode 100644 index 0000000..5b2a691 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallAnalyticCredentialsProvider.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.widget + +interface CallAnalyticCredentialsProvider { + val posthogUserId: String? + val posthogApiHost: String? + val posthogApiKey: String? + val rageshakeSubmitUrl: String? + val sentryDsn: String? +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallWidgetSettingsProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallWidgetSettingsProvider.kt new file mode 100644 index 0000000..6c91a90 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallWidgetSettingsProvider.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.widget + +import java.util.UUID + +interface CallWidgetSettingsProvider { + suspend fun provide( + baseUrl: String, + widgetId: String = UUID.randomUUID().toString(), + encrypted: Boolean, + direct: Boolean, + hasActiveCall: Boolean, + ): MatrixWidgetSettings +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetDriver.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetDriver.kt new file mode 100644 index 0000000..d8cca95 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetDriver.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.widget + +import kotlinx.coroutines.flow.Flow + +interface MatrixWidgetDriver : AutoCloseable { + val id: String + val incomingMessages: Flow + + suspend fun run() + suspend fun send(message: String) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetSettings.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetSettings.kt new file mode 100644 index 0000000..00b0eb6 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetSettings.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.widget + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class MatrixWidgetSettings( + val id: String, + val initAfterContentLoad: Boolean, + val rawUrl: String, +) : Parcelable { + companion object +} diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/auth/AuthErrorCodeTest.kt b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/auth/AuthErrorCodeTest.kt new file mode 100644 index 0000000..c8dfe2d --- /dev/null +++ b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/auth/AuthErrorCodeTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.auth + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class AuthErrorCodeTest { + @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/api/src/test/kotlin/io/element/android/libraries/matrix/api/auth/MatrixHomeServerDetailsTest.kt b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/auth/MatrixHomeServerDetailsTest.kt new file mode 100644 index 0000000..d4b360e --- /dev/null +++ b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/auth/MatrixHomeServerDetailsTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.auth + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails +import org.junit.Test + +class MatrixHomeServerDetailsTest { + @Test + fun `if homeserver supports oidc, then it is supported`() { + val sut = aMatrixHomeServerDetails( + supportsOidcLogin = true, + supportsPasswordLogin = false, + ) + assertThat(sut.isSupported).isTrue() + } + + @Test + fun `if homeserver supports password, then it is supported`() { + val sut = aMatrixHomeServerDetails( + supportsOidcLogin = false, + supportsPasswordLogin = true, + ) + assertThat(sut.isSupported).isTrue() + } + + @Test + fun `if homeserver supports both, then it is supported`() { + val sut = aMatrixHomeServerDetails( + supportsOidcLogin = true, + supportsPasswordLogin = true, + ) + assertThat(sut.isSupported).isTrue() + } + + @Test + fun `if homeserver supports none, then it is not supported`() { + val sut = aMatrixHomeServerDetails( + supportsOidcLogin = false, + supportsPasswordLogin = false, + ) + assertThat(sut.isSupported).isFalse() + } +} diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatternsTest.kt b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatternsTest.kt new file mode 100644 index 0000000..4d1f905 --- /dev/null +++ b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatternsTest.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.core + +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import org.junit.Test + +class MatrixPatternsTest { + private val longLocalPart = "a".repeat(255 - ":server.com".length - 1) + + @Test + fun `findPatterns - returns raw user ids`() { + val text = "A @user:server.com and @user2:server.com" + val patterns = MatrixPatterns.findPatterns(text, aPermalinkParser()) + assertThat(patterns).containsExactly( + MatrixPatternResult(MatrixPatternType.USER_ID, "@user:server.com", 2, 18), + MatrixPatternResult(MatrixPatternType.USER_ID, "@user2:server.com", 23, 40) + ) + } + + @Test + fun `findPatterns - returns raw room ids`() { + val text = "A !room:server.com and !room2:server.com" + val patterns = MatrixPatterns.findPatterns(text, aPermalinkParser()) + assertThat(patterns).containsExactly( + MatrixPatternResult(MatrixPatternType.ROOM_ID, "!room:server.com", 2, 18), + MatrixPatternResult(MatrixPatternType.ROOM_ID, "!room2:server.com", 23, 40) + ) + } + + @Test + fun `findPatterns - returns raw room aliases`() { + val text = "A #room:server.com and #room2:server.com" + val patterns = MatrixPatterns.findPatterns(text, aPermalinkParser()) + assertThat(patterns).containsExactly( + MatrixPatternResult(MatrixPatternType.ROOM_ALIAS, "#room:server.com", 2, 18), + MatrixPatternResult(MatrixPatternType.ROOM_ALIAS, "#room2:server.com", 23, 40) + ) + } + + @Test + fun `findPatterns - returns raw event ids`() { + val text = "A \$event:server.com and \$event2:server.com" + val patterns = MatrixPatterns.findPatterns(text, aPermalinkParser()) + assertThat(patterns).containsExactly( + MatrixPatternResult(MatrixPatternType.EVENT_ID, "\$event:server.com", 2, 19), + MatrixPatternResult(MatrixPatternType.EVENT_ID, "\$event2:server.com", 24, 42) + ) + } + + @Test + fun `findPatterns - returns @room mention`() { + val text = "A @room mention" + val patterns = MatrixPatterns.findPatterns(text, aPermalinkParser()) + assertThat(patterns).containsExactly(MatrixPatternResult(MatrixPatternType.AT_ROOM, "@room", 2, 7)) + } + + @Test + fun `findPatterns - returns user ids in permalinks`() { + val text = "A [User](https://matrix.to/#/@user:server.com)" + val permalinkParser = aPermalinkParser { _ -> + PermalinkData.UserLink(UserId("@user:server.com")) + } + val patterns = MatrixPatterns.findPatterns(text, permalinkParser) + assertThat(patterns).containsExactly(MatrixPatternResult(MatrixPatternType.USER_ID, "@user:server.com", 2, 46)) + } + + @Test + fun `findPatterns - returns room aliases in permalinks`() { + val text = "A [Room](https://matrix.to/#/#room:server.com)" + val permalinkParser = aPermalinkParser { _ -> + PermalinkData.RoomLink(RoomIdOrAlias.Alias(RoomAlias("#room:server.com"))) + } + val patterns = MatrixPatterns.findPatterns(text, permalinkParser) + assertThat(patterns).containsExactly(MatrixPatternResult(MatrixPatternType.ROOM_ALIAS, "#room:server.com", 2, 46)) + } + + @Test + fun `test isRoomId`() { + assertThat(MatrixPatterns.isRoomId(null)).isFalse() + assertThat(MatrixPatterns.isRoomId("")).isFalse() + assertThat(MatrixPatterns.isRoomId("not a room id")).isFalse() + assertThat(MatrixPatterns.isRoomId(" !room:server.com")).isFalse() + assertThat(MatrixPatterns.isRoomId("!room:server.com ")).isFalse() + assertThat(MatrixPatterns.isRoomId("@room:server.com")).isFalse() + assertThat(MatrixPatterns.isRoomId("#room:server.com")).isFalse() + assertThat(MatrixPatterns.isRoomId("\$room:server.com")).isFalse() + assertThat(MatrixPatterns.isRoomId("!${longLocalPart}a:server.com")).isFalse() + assertThat(MatrixPatterns.isRoomId("!9BozuV4TBw6rfRW@rMEgZ5v-jNk1D6FA8Hd1OsWqT9k")).isFalse() + + assertThat(MatrixPatterns.isRoomId("!9BozuV4TBw6rfRW3rMEgZ5v-jNk1D6FA8Hd1OsWqT9k")).isTrue() + assertThat(MatrixPatterns.isRoomId("!room:server.com")).isTrue() + assertThat(MatrixPatterns.isRoomId("!$longLocalPart:server.com")).isTrue() + assertThat(MatrixPatterns.isRoomId("!#test/room\nversion 11, with @🐈️:maunium.net")).isTrue() + } + + @Test + fun `test isRoomAlias`() { + assertThat(MatrixPatterns.isRoomAlias(null)).isFalse() + assertThat(MatrixPatterns.isRoomAlias("")).isFalse() + assertThat(MatrixPatterns.isRoomAlias("not a room alias")).isFalse() + assertThat(MatrixPatterns.isRoomAlias(" #room:server.com")).isFalse() + assertThat(MatrixPatterns.isRoomAlias("#room:server.com ")).isFalse() + assertThat(MatrixPatterns.isRoomAlias("@room:server.com")).isFalse() + assertThat(MatrixPatterns.isRoomAlias("!room:server.com")).isFalse() + assertThat(MatrixPatterns.isRoomAlias("\$room:server.com")).isFalse() + assertThat(MatrixPatterns.isRoomAlias("#${longLocalPart}a:server.com")).isFalse() + + assertThat(MatrixPatterns.isRoomAlias("#room:server.com")).isTrue() + assertThat(MatrixPatterns.isRoomAlias("#nico's-stickers:neko.dev")).isTrue() + assertThat(MatrixPatterns.isRoomAlias("#$longLocalPart:server.com")).isTrue() + } + + @Test + fun `test isEventId`() { + assertThat(MatrixPatterns.isEventId(null)).isFalse() + assertThat(MatrixPatterns.isEventId("")).isFalse() + assertThat(MatrixPatterns.isEventId("not an event id")).isFalse() + assertThat(MatrixPatterns.isEventId(" \$event:server.com")).isFalse() + assertThat(MatrixPatterns.isEventId("\$event:server.com ")).isFalse() + assertThat(MatrixPatterns.isEventId("@event:server.com")).isFalse() + assertThat(MatrixPatterns.isEventId("!event:server.com")).isFalse() + assertThat(MatrixPatterns.isEventId("#event:server.com")).isFalse() + assertThat(MatrixPatterns.isEventId("$${longLocalPart}a:server.com")).isFalse() + assertThat(MatrixPatterns.isEventId("\$" + "a".repeat(255))).isFalse() + + assertThat(MatrixPatterns.isEventId("\$event:server.com")).isTrue() + assertThat(MatrixPatterns.isEventId("$$longLocalPart:server.com")).isTrue() + assertThat(MatrixPatterns.isEventId("\$9BozuV4TBw6rfRW3rMEgZ5v-jNk1D6FA8Hd1OsWqT9k")).isTrue() + assertThat(MatrixPatterns.isEventId("\$" + "a".repeat(254))).isTrue() + } + + @Test + fun `test isUserId`() { + assertThat(MatrixPatterns.isUserId(null)).isFalse() + assertThat(MatrixPatterns.isUserId("")).isFalse() + assertThat(MatrixPatterns.isUserId("not a user id")).isFalse() + assertThat(MatrixPatterns.isUserId(" @user:server.com")).isFalse() + assertThat(MatrixPatterns.isUserId("@user:server.com ")).isFalse() + assertThat(MatrixPatterns.isUserId("!user:server.com")).isFalse() + assertThat(MatrixPatterns.isUserId("#user:server.com")).isFalse() + assertThat(MatrixPatterns.isUserId("\$user:server.com")).isFalse() + assertThat(MatrixPatterns.isUserId("@${longLocalPart}a:server.com")).isFalse() + + assertThat(MatrixPatterns.isUserId("@user:server.com")).isTrue() + assertThat(MatrixPatterns.isUserId("@:server.com")).isTrue() + assertThat(MatrixPatterns.isUserId("@$longLocalPart:server.com")).isTrue() + } + + private fun aPermalinkParser(block: (String) -> PermalinkData = { PermalinkData.FallbackLink(Uri.EMPTY) }) = object : PermalinkParser { + override fun parse(uriString: String): PermalinkData { + return block(uriString) + } + } +} diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/notification/NotificationDataTest.kt b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/notification/NotificationDataTest.kt new file mode 100644 index 0000000..26dc241 --- /dev/null +++ b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/notification/NotificationDataTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.notification + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.notification.aNotificationData +import org.junit.Test + +class NotificationDataTest { + @Test + fun `getSenderName should return user id if there is no sender name`() { + val sut = aNotificationData( + senderDisplayName = null, + senderIsNameAmbiguous = false, + ) + assertThat(sut.getDisambiguatedDisplayName(A_USER_ID)).isEqualTo("@alice:server.org") + } + + @Test + fun `getSenderName should return sender name if defined`() { + val sut = aNotificationData( + senderDisplayName = "Alice", + senderIsNameAmbiguous = false, + ) + assertThat(sut.getDisambiguatedDisplayName(A_USER_ID)).isEqualTo("Alice") + } + + @Test + fun `getSenderName should return sender name and user id in case of ambiguous display name`() { + val sut = aNotificationData( + senderDisplayName = "Alice", + senderIsNameAmbiguous = true, + ) + assertThat(sut.getDisambiguatedDisplayName(A_USER_ID)).isEqualTo("Alice (@alice:server.org)") + } +} diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/room/RoomIsDmCheckTest.kt b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/room/RoomIsDmCheckTest.kt new file mode 100644 index 0000000..1461d28 --- /dev/null +++ b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/room/RoomIsDmCheckTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.room + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class RoomIsDmCheckTest { + @Test + fun `a room is a DM only if it has at most 2 members and is direct`() { + val isDirect = true + val activeMembersCount = 2 + + val isDm = isDm(isDirect, activeMembersCount) + + assertThat(isDm).isTrue() + } + + @Test + fun `a room can be a DM if it has also a single active user`() { + val isDirect = true + val activeMembersCount = 1 + + val isDm = isDm(isDirect, activeMembersCount) + + assertThat(isDm).isTrue() + } + + @Test + fun `a room is not a DM if it's not direct`() { + val isDirect = false + val activeMembersCount = 2 + + val isDm = isDm(isDirect, activeMembersCount) + + assertThat(isDm).isFalse() + } + + @Test + fun `a room is not a DM if it has more than 2 active users`() { + val isDirect = true + val activeMembersCount = 3 + + val isDm = isDm(isDirect, activeMembersCount) + + assertThat(isDm).isFalse() + } +} diff --git a/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ProfileTimelineDetailsTest.kt b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ProfileTimelineDetailsTest.kt new file mode 100644 index 0000000..ffb59ba --- /dev/null +++ b/libraries/matrix/api/src/test/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ProfileTimelineDetailsTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.timeline.item.event + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.UserId +import org.junit.Test + +private const val A_USER_ID = "@foo:example.org" +private val aUserId = UserId(A_USER_ID) + +class ProfileTimelineDetailsTest { + @Test + fun `getDisambiguatedDisplayName of Unavailable should be equal to userId`() { + assertThat(ProfileDetails.Unavailable.getDisambiguatedDisplayName(aUserId)).isEqualTo(A_USER_ID) + } + + @Test + fun `getDisambiguatedDisplayName of Error should be equal to userId`() { + assertThat(ProfileDetails.Error("An error").getDisambiguatedDisplayName(aUserId)).isEqualTo(A_USER_ID) + } + + @Test + fun `getDisambiguatedDisplayName of Pending should be equal to userId`() { + assertThat(ProfileDetails.Pending.getDisambiguatedDisplayName(aUserId)).isEqualTo(A_USER_ID) + } + + @Test + fun `getDisambiguatedDisplayName of Ready without display name should be equal to userId`() { + assertThat( + ProfileDetails.Ready( + displayName = null, + displayNameAmbiguous = false, + avatarUrl = null, + ).getDisambiguatedDisplayName(aUserId) + ).isEqualTo(A_USER_ID) + } + + @Test + fun `getDisambiguatedDisplayName of Ready with display name should be equal to display name`() { + assertThat( + ProfileDetails.Ready( + displayName = "Alice", + displayNameAmbiguous = false, + avatarUrl = null, + ).getDisambiguatedDisplayName(aUserId) + ).isEqualTo("Alice") + } + + @Test + fun `getDisambiguatedDisplayName of Ready with display name and ambiguous should be equal to display name with user id`() { + assertThat( + ProfileDetails.Ready( + displayName = "Alice", + displayNameAmbiguous = true, + avatarUrl = null, + ).getDisambiguatedDisplayName(aUserId) + ).isEqualTo("Alice ($A_USER_ID)") + } +} diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts new file mode 100644 index 0000000..394cb23 --- /dev/null +++ b/libraries/matrix/impl/build.gradle.kts @@ -0,0 +1,54 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "io.element.android.libraries.matrix.impl" +} + +setupDependencyInjection() + +dependencies { + releaseImplementation(libs.matrix.sdk) + if (file("${rootDir.path}/libraries/rustsdk/matrix-rust-sdk.aar").exists()) { + println("\nNote: Using local binary of the Rust SDK.\n") + debugImplementation(projects.libraries.rustsdk) + } else { + debugImplementation(libs.matrix.sdk) + } + implementation(projects.appconfig) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.di) + implementation(projects.libraries.featureflag.api) + implementation(projects.libraries.network) + implementation(projects.libraries.preferences.api) + implementation(projects.services.analytics.api) + implementation(projects.services.toolbox.api) + api(projects.libraries.matrix.api) + implementation(projects.libraries.core) + implementation("net.java.dev.jna:jna:5.18.1@aar") + implementation(libs.androidx.datastore.preferences) + implementation(libs.serialization.json) + implementation(libs.kotlinx.collections.immutable) + + testCommonDependencies(libs) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.previewutils) + testImplementation(projects.libraries.sessionStorage.test) + testImplementation(projects.services.analytics.test) + testImplementation(projects.services.toolbox.test) +} diff --git a/libraries/matrix/impl/src/main/AndroidManifest.xml b/libraries/matrix/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..608f16c --- /dev/null +++ b/libraries/matrix/impl/src/main/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/ClientBuilderProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/ClientBuilderProvider.kt new file mode 100644 index 0000000..f3cf4ff --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/ClientBuilderProvider.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import org.matrix.rustcomponents.sdk.ClientBuilder + +interface ClientBuilderProvider { + fun provide(): ClientBuilder +} + +@ContributesBinding(AppScope::class) +class RustClientBuilderProvider : ClientBuilderProvider { + override fun provide(): ClientBuilder { + return ClientBuilder() + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt new file mode 100644 index 0000000..ee9f1d3 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl + +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.impl.mapper.toSessionData +import io.element.android.libraries.matrix.impl.paths.getSessionPaths +import io.element.android.libraries.matrix.impl.util.anonymizedTokens +import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.matrix.rustcomponents.sdk.ClientDelegate +import org.matrix.rustcomponents.sdk.ClientSessionDelegate +import org.matrix.rustcomponents.sdk.Session +import timber.log.Timber +import java.lang.ref.WeakReference +import java.util.concurrent.atomic.AtomicBoolean + +private val loggerTag = LoggerTag("RustClientSessionDelegate") + +/** + * This class is responsible for handling the session data for the Rust SDK. + * + * It implements both [ClientSessionDelegate] and [ClientDelegate] to react to session data updates and auth errors. + * + * IMPORTANT: you must set the [client] property as soon as possible so [didReceiveAuthError] can work properly. + */ +class RustClientSessionDelegate( + private val sessionStore: SessionStore, + private val appCoroutineScope: CoroutineScope, + coroutineDispatchers: CoroutineDispatchers, +) : ClientSessionDelegate, ClientDelegate { + // Used to ensure several calls to `didReceiveAuthError` don't trigger multiple logouts + private val isLoggingOut = AtomicBoolean(false) + + // To make sure only one coroutine affecting the token persistence can run at a time + private val updateTokensDispatcher = coroutineDispatchers.io.limitedParallelism(1) + + // This Client needs to be set up as soon as possible so `didReceiveAuthError` can work properly. + private var client: WeakReference = WeakReference(null) + + /** + * Sets the [ClientDelegate] for the [RustMatrixClient], and keeps a reference to the client so it can be used later. + */ + fun bindClient(client: RustMatrixClient) { + this.client = WeakReference(client) + } + + /** + * Clears the current client reference. + */ + fun clearCurrentClient() { + this.client.clear() + } + + override fun saveSessionInKeychain(session: Session) { + appCoroutineScope.launch(updateTokensDispatcher) { + val existingData = sessionStore.getSession(session.userId) ?: return@launch + val (anonymizedAccessToken, anonymizedRefreshToken) = session.anonymizedTokens() + Timber.tag(loggerTag.value).d( + "Saving new session data with token: access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'. " + + "Was token valid: ${existingData.isTokenValid}" + ) + val newData = session.toSessionData( + isTokenValid = true, + loginType = existingData.loginType, + passphrase = existingData.passphrase, + sessionPaths = existingData.getSessionPaths(), + ) + sessionStore.updateData(newData) + Timber.tag(loggerTag.value).d("Saved new session data with access token: '$anonymizedAccessToken'.") + }.invokeOnCompletion { + if (it != null) { + Timber.tag(loggerTag.value).e(it, "Failed to save new session data.") + } + } + } + + override fun didReceiveAuthError(isSoftLogout: Boolean) { + Timber.tag(loggerTag.value).w("didReceiveAuthError(isSoftLogout=$isSoftLogout)") + if (isLoggingOut.getAndSet(true).not()) { + Timber.tag(loggerTag.value).v("didReceiveAuthError -> do the cleanup") + // TODO handle isSoftLogout parameter. + appCoroutineScope.launch(updateTokensDispatcher) { + val currentClient = client.get() + if (currentClient == null) { + Timber.tag(loggerTag.value).w("didReceiveAuthError -> no client, exiting") + isLoggingOut.set(false) + return@launch + } + val existingData = sessionStore.getSession(currentClient.sessionId.value) + val (anonymizedAccessToken, anonymizedRefreshToken) = existingData.anonymizedTokens() + Timber.tag(loggerTag.value).d( + "Removing session data with access token '$anonymizedAccessToken' " + + "and refresh token '$anonymizedRefreshToken'." + ) + if (existingData != null) { + // Set isTokenValid to false + val newData = existingData.copy(isTokenValid = false) + sessionStore.updateData(newData) + Timber.tag(loggerTag.value).d("Invalidated session data with access token: '$anonymizedAccessToken'.") + } else { + Timber.tag(loggerTag.value).d("No session data found.") + } + currentClient.logout(userInitiated = false, ignoreSdkError = true) + }.invokeOnCompletion { + if (it != null) { + Timber.tag(loggerTag.value).e(it, "Failed to remove session data.") + } + } + } else { + Timber.tag(loggerTag.value).v("didReceiveAuthError -> already cleaning up") + } + } + + override fun retrieveSessionFromKeychain(userId: String): Session { + // This should never be called, as it's only used for multi-process setups + error("retrieveSessionFromKeychain should never be called for Android") + } +} 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 0000000..fca2aaa --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -0,0 +1,769 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl + +import io.element.android.libraries.androidutils.file.getSizeOfFiles +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.coroutine.childScope +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.core.extensions.mapFailure +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +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.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.oidc.AccountManagementAction +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.NotJoinedRoom +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.spaces.SpaceService +import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion +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.impl.encryption.RustEncryptionService +import io.element.android.libraries.matrix.impl.exception.mapClientException +import io.element.android.libraries.matrix.impl.mapper.map +import io.element.android.libraries.matrix.impl.media.RustMediaLoader +import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService +import io.element.android.libraries.matrix.impl.notification.RustNotificationService +import io.element.android.libraries.matrix.impl.notificationsettings.RustNotificationSettingsService +import io.element.android.libraries.matrix.impl.oidc.toRustAction +import io.element.android.libraries.matrix.impl.pushers.RustPushersService +import io.element.android.libraries.matrix.impl.room.GetRoomResult +import io.element.android.libraries.matrix.impl.room.NotJoinedRustRoom +import io.element.android.libraries.matrix.impl.room.RoomContentForwarder +import io.element.android.libraries.matrix.impl.room.RoomInfoMapper +import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber +import io.element.android.libraries.matrix.impl.room.RustRoomFactory +import io.element.android.libraries.matrix.impl.room.TimelineEventTypeFilterFactory +import io.element.android.libraries.matrix.impl.room.history.map +import io.element.android.libraries.matrix.impl.room.join.map +import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewInfoMapper +import io.element.android.libraries.matrix.impl.roomdirectory.RustRoomDirectoryService +import io.element.android.libraries.matrix.impl.roomdirectory.map +import io.element.android.libraries.matrix.impl.roomlist.RoomListFactory +import io.element.android.libraries.matrix.impl.roomlist.RustRoomListService +import io.element.android.libraries.matrix.impl.roomlist.roomOrNull +import io.element.android.libraries.matrix.impl.spaces.RustSpaceService +import io.element.android.libraries.matrix.impl.sync.RustSyncService +import io.element.android.libraries.matrix.impl.sync.map +import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper +import io.element.android.libraries.matrix.impl.util.SessionPathsProvider +import io.element.android.libraries.matrix.impl.util.cancelAndDestroy +import io.element.android.libraries.matrix.impl.util.mxCallbackFlow +import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.matrix.rustcomponents.sdk.AuthData +import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.ClientException +import org.matrix.rustcomponents.sdk.IgnoredUsersListener +import org.matrix.rustcomponents.sdk.Membership +import org.matrix.rustcomponents.sdk.NotificationProcessSetup +import org.matrix.rustcomponents.sdk.PowerLevels +import org.matrix.rustcomponents.sdk.RoomInfoListener +import org.matrix.rustcomponents.sdk.SendQueueRoomErrorListener +import org.matrix.rustcomponents.sdk.TaskHandle +import org.matrix.rustcomponents.sdk.use +import timber.log.Timber +import java.io.File +import java.util.Optional +import kotlin.jvm.optionals.getOrNull +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParameters +import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset +import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService + +class RustMatrixClient( + private val innerClient: Client, + private val sessionStore: SessionStore, + private val sessionDelegate: RustClientSessionDelegate, + private val innerSyncService: ClientSyncService, + appCoroutineScope: CoroutineScope, + dispatchers: CoroutineDispatchers, + baseCacheDirectory: File, + clock: SystemClock, + timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory, + private val featureFlagService: FeatureFlagService, + private val analyticsService: AnalyticsService, +) : MatrixClient { + override val sessionId: UserId = UserId(innerClient.userId()) + override val deviceId: DeviceId = DeviceId(innerClient.deviceId()) + override val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-$sessionId") + private val sessionDispatcher = dispatchers.io.limitedParallelism(64) + + private val innerRoomListService = innerSyncService.roomListService() + private val innerSpaceService = innerClient.spaceService() + + override val roomMembershipObserver = RoomMembershipObserver() + + override val syncService = RustSyncService( + inner = innerSyncService, + dispatcher = sessionDispatcher, + sessionCoroutineScope = sessionCoroutineScope + ) + override val pushersService = RustPushersService( + client = innerClient, + dispatchers = dispatchers, + ) + private val notificationProcessSetup = NotificationProcessSetup.SingleProcess(innerSyncService) + private val innerNotificationClient = runBlocking { innerClient.notificationClient(notificationProcessSetup) } + override val notificationService = RustNotificationService(sessionId, innerNotificationClient, dispatchers, clock) + override val notificationSettingsService = RustNotificationSettingsService(innerClient, sessionCoroutineScope, dispatchers) + override val encryptionService = RustEncryptionService( + client = innerClient, + syncService = syncService, + sessionCoroutineScope = sessionCoroutineScope, + dispatchers = dispatchers, + ) + + override val roomDirectoryService = RustRoomDirectoryService( + client = innerClient, + sessionDispatcher = sessionDispatcher, + ) + + private val sessionPathsProvider = SessionPathsProvider(sessionStore) + + private val roomSyncSubscriber: RoomSyncSubscriber = RoomSyncSubscriber(innerRoomListService, dispatchers) + + override val roomListService: RoomListService = RustRoomListService( + innerRoomListService = innerRoomListService, + sessionCoroutineScope = sessionCoroutineScope, + sessionDispatcher = sessionDispatcher, + roomListFactory = RoomListFactory( + innerRoomListService = innerRoomListService, + sessionCoroutineScope = sessionCoroutineScope, + analyticsService = analyticsService, + ), + roomSyncSubscriber = roomSyncSubscriber, + ) + + override val spaceService: SpaceService = RustSpaceService( + innerSpaceService = innerSpaceService, + roomMembershipObserver = roomMembershipObserver, + sessionCoroutineScope = sessionCoroutineScope, + sessionDispatcher = sessionDispatcher, + ) + + override val sessionVerificationService = RustSessionVerificationService( + client = innerClient, + isSyncServiceReady = syncService.syncState.map { it == SyncState.Running }, + sessionCoroutineScope = sessionCoroutineScope, + ) + + private val roomInfoMapper = RoomInfoMapper() + + private val roomFactory = RustRoomFactory( + roomListService = roomListService, + innerRoomListService = innerRoomListService, + sessionId = sessionId, + deviceId = deviceId, + notificationSettingsService = notificationSettingsService, + sessionCoroutineScope = sessionCoroutineScope, + dispatchers = dispatchers, + systemClock = clock, + roomContentForwarder = RoomContentForwarder(innerRoomListService), + roomSyncSubscriber = roomSyncSubscriber, + timelineEventTypeFilterFactory = timelineEventTypeFilterFactory, + roomMembershipObserver = roomMembershipObserver, + roomInfoMapper = roomInfoMapper, + featureFlagService = featureFlagService, + analyticsService = analyticsService, + ) + + override val matrixMediaLoader: MatrixMediaLoader = RustMediaLoader( + baseCacheDirectory = baseCacheDirectory, + dispatchers = dispatchers, + innerClient = innerClient, + ) + + override val mediaPreviewService = RustMediaPreviewService( + sessionCoroutineScope = sessionCoroutineScope, + innerClient = innerClient, + sessionDispatcher = sessionDispatcher, + ) + + private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate) + + private val _userProfile: MutableStateFlow = MutableStateFlow( + MatrixUser( + userId = sessionId, + displayName = null, + avatarUrl = null, + ) + ) + + override val userProfile: StateFlow = _userProfile + + override val ignoredUsersFlow = mxCallbackFlow> { + // Fetch the initial value manually, the SDK won't return it automatically + channel.trySend(innerClient.ignoredUsers().map(::UserId).toImmutableList()) + + innerClient.subscribeToIgnoredUsers(object : IgnoredUsersListener { + override fun call(ignoredUserIds: List) { + channel.trySend(ignoredUserIds.map(::UserId).toImmutableList()) + } + }) + } + .buffer(Channel.UNLIMITED) + .stateIn(sessionCoroutineScope, started = SharingStarted.Eagerly, initialValue = persistentListOf()) + + init { + // Make sure the session delegate has a reference to the client to be able to logout on auth error + sessionDelegate.bindClient(this) + + sessionCoroutineScope.launch { + // Start notification settings + notificationSettingsService.start() + + // Update the user profile in the session store if needed + sessionStore.getSession(sessionId.value)?.let { sessionData -> + _userProfile.emit( + MatrixUser( + userId = sessionId, + displayName = sessionData.userDisplayName, + avatarUrl = sessionData.userAvatarUrl, + ) + ) + } + // Force a refresh of the profile + getUserProfile() + } + } + + override fun userIdServerName(): String { + return runCatchingExceptions { + innerClient.userIdServerName() + } + .onFailure { + Timber.w(it, "Failed to get userIdServerName") + } + .getOrNull() + ?: sessionId.value.substringAfter(":") + } + + override suspend fun getUrl(url: String): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.getUrl(url) + }.mapFailure { it.mapClientException() } + } + + override suspend fun getRoom(roomId: RoomId): BaseRoom? = withContext(sessionDispatcher) { + roomFactory.getBaseRoom(roomId) + } + + override suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom? = withContext(sessionDispatcher) { + (roomFactory.getJoinedRoomOrPreview(roomId, emptyList()) as? GetRoomResult.Joined)?.joinedRoom + } + + /** + * Wait for the room to be available in the client with the correct membership for the current user. + * @param roomId the room id to wait for + * @param timeout the timeout to wait for the room to be available + * @param currentUserMembership the membership to wait for + * @throws TimeoutCancellationException if the room is not available after the timeout + */ + private suspend fun awaitRoom( + roomId: RoomId, + timeout: Duration, + currentUserMembership: CurrentUserMembership, + ): RoomInfo { + return withTimeout(timeout) { + getRoomInfoFlow(roomId) + .mapNotNull { roomInfo -> roomInfo.getOrNull() } + .first { info -> info.currentUserMembership == currentUserMembership } + // Ensure that the room is ready + .also { innerClient.awaitRoomRemoteEcho(roomId.value).destroy() } + } + } + + override suspend fun findDM(userId: UserId): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.getDmRoom(userId.value)?.use { RoomId(it.id()) } + } + } + + override suspend fun getJoinedRoomIds(): Result> = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.rooms() + .filter { it.membership() == Membership.JOINED } + .map { RoomId(it.id()) } + .toSet() + } + } + + override suspend fun ignoreUser(userId: UserId): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.ignoreUser(userId.value) + } + } + + override suspend fun unignoreUser(userId: UserId): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.unignoreUser(userId.value) + } + } + + override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + val rustParams = RustCreateRoomParameters( + name = createRoomParams.name, + topic = createRoomParams.topic, + isEncrypted = createRoomParams.isEncrypted, + isDirect = createRoomParams.isDirect, + visibility = createRoomParams.visibility.map(), + preset = when (createRoomParams.preset) { + RoomPreset.PRIVATE_CHAT -> RustRoomPreset.PRIVATE_CHAT + RoomPreset.TRUSTED_PRIVATE_CHAT -> RustRoomPreset.TRUSTED_PRIVATE_CHAT + RoomPreset.PUBLIC_CHAT -> RustRoomPreset.PUBLIC_CHAT + }, + invite = createRoomParams.invite?.map { it.value }, + avatar = createRoomParams.avatar, + powerLevelContentOverride = defaultRoomCreationPowerLevels.copy( + invite = if (createRoomParams.joinRuleOverride == JoinRule.Knock) { + // override the invite power level so it's the same as kick. + RoomMember.Role.Moderator.powerLevel.toInt() + } else { + null + } + ), + joinRuleOverride = createRoomParams.joinRuleOverride?.map(), + historyVisibilityOverride = createRoomParams.historyVisibilityOverride?.map(), + canonicalAlias = createRoomParams.roomAliasName.getOrNull(), + ) + val roomId = RoomId(innerClient.createRoom(rustParams)) + // Wait to receive the room back from the sync but do not returns failure if it fails. + try { + awaitRoom(roomId, 30.seconds, CurrentUserMembership.JOINED) + } catch (e: Exception) { + Timber.e(e, "Timeout waiting for the room to be available in the room list") + } + roomId + } + } + + override suspend fun createDM(userId: UserId): Result { + 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 = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.getProfile(userId.value).map() + } + } + + override suspend fun getUserProfile(): Result = getProfile(sessionId) + .onSuccess { matrixUser -> + _userProfile.emit(matrixUser) + // Also update our session storage + sessionStore.updateUserProfile( + sessionId = sessionId.value, + displayName = matrixUser.displayName, + avatarUrl = matrixUser.avatarUrl, + ) + } + + override suspend fun searchUsers(searchTerm: String, limit: Long): Result = + withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.searchUsers(searchTerm, limit.toULong()).let(UserSearchResultMapper::map) + } + } + + override suspend fun setDisplayName(displayName: String): Result = + withContext(sessionDispatcher) { + runCatchingExceptions { innerClient.setDisplayName(displayName) } + } + + override suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result = + withContext(sessionDispatcher) { + runCatchingExceptions { innerClient.uploadAvatar(mimeType, data) } + } + + override suspend fun removeAvatar(): Result = + withContext(sessionDispatcher) { + runCatchingExceptions { innerClient.removeAvatar() } + } + + override suspend fun joinRoom(roomId: RoomId): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.joinRoomById(roomId.value).destroy() + try { + awaitRoom(roomId, 10.seconds, CurrentUserMembership.JOINED) + } catch (e: Exception) { + Timber.e(e, "Timeout waiting for the room to be available in the room list") + null + } + } + }.mapFailure { it.mapClientException() } + + override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + val roomId = innerClient.joinRoomByIdOrAlias( + roomIdOrAlias = roomIdOrAlias.identifier, + serverNames = serverNames, + ).use { + RoomId(it.id()) + } + try { + awaitRoom(roomId, 10.seconds, CurrentUserMembership.JOINED) + } catch (e: Exception) { + Timber.e(e, "Timeout waiting for the room to be available in the room list") + null + } + }.mapFailure { it.mapClientException() } + } + + override suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List): Result = withContext( + sessionDispatcher + ) { + runCatchingExceptions { + val roomId = innerClient.knock(roomIdOrAlias.identifier, message, serverNames).use { + RoomId(it.id()) + } + try { + awaitRoom(roomId, 10.seconds, CurrentUserMembership.KNOCKED) + } catch (e: Exception) { + Timber.e(e, "Timeout waiting for the room to be available in the room list") + null + } + }.mapFailure { it.mapClientException() } + } + + override suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.trackRecentlyVisitedRoom(roomId.value) + } + } + + override suspend fun getRecentlyVisitedRooms(): Result> = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.getRecentlyVisitedRooms().map(::RoomId) + } + } + + override suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result> = withContext(sessionDispatcher) { + runCatchingExceptions { + val result = innerClient.resolveRoomAlias(roomAlias.value)?.let { + ResolvedRoomAlias( + roomId = RoomId(it.roomId), + servers = it.servers, + ) + } + Optional.ofNullable(result) + } + } + + override suspend fun getRoomPreview(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + when (roomIdOrAlias) { + is RoomIdOrAlias.Alias -> { + val roomId = innerClient.resolveRoomAlias(roomIdOrAlias.roomAlias.value)?.roomId?.let { RoomId(it) } + + var room = (roomId?.let { roomFactory.getJoinedRoomOrPreview(it, serverNames) } as? GetRoomResult.NotJoined)?.notJoinedRoom + if (room == null) { + val preview = innerClient.getRoomPreviewFromRoomAlias(roomIdOrAlias.roomAlias.value) + room = NotJoinedRustRoom(sessionId, null, RoomPreviewInfoMapper.map(preview.info())) + } + room + } + is RoomIdOrAlias.Id -> { + var room = (roomFactory.getJoinedRoomOrPreview(roomIdOrAlias.roomId, serverNames) as? GetRoomResult.NotJoined)?.notJoinedRoom + + if (room == null) { + val preview = innerClient.getRoomPreviewFromRoomId(roomIdOrAlias.roomId.value, serverNames) + room = NotJoinedRustRoom(sessionId, null, RoomPreviewInfoMapper.map(preview.info())) + } + room + } + } + }.mapFailure { it.mapClientException() } + } + + internal suspend fun destroy() { + innerNotificationClient.close() + + roomFactory.destroy() + syncService.destroy() + notificationSettingsService.destroy() + notificationProcessSetup.destroy() + + sessionCoroutineScope.cancel() + clientDelegateTaskHandle?.cancelAndDestroy() + sessionVerificationService.destroy() + + sessionDelegate.clearCurrentClient() + innerRoomListService.close() + innerSpaceService.close() + notificationService.close() + encryptionService.close() + innerClient.close() + } + + override suspend fun getCacheSize(): Long { + return getCacheSize(includeCryptoDb = false) + } + + override suspend fun clearCache() { + innerClient.clearCaches(innerSyncService) + destroy() + } + + override suspend fun logout(userInitiated: Boolean, ignoreSdkError: Boolean) { + sessionCoroutineScope.cancel() + // Remove current delegate so we don't receive an auth error + clientDelegateTaskHandle?.cancelAndDestroy() + clientDelegateTaskHandle = null + withContext(sessionDispatcher) { + if (userInitiated) { + try { + innerClient.logout() + } catch (failure: Throwable) { + if (ignoreSdkError) { + Timber.e(failure, "Fail to call logout on HS. Still delete local files.") + } else { + // If the logout failed we need to restore the delegate + clientDelegateTaskHandle = innerClient.setDelegate(sessionDelegate) + Timber.e(failure, "Fail to call logout on HS.") + throw failure + } + } + } + destroy() + + deleteSessionDirectory() + if (userInitiated) { + sessionStore.removeSession(sessionId.value) + } + } + } + + override fun canDeactivateAccount(): Boolean { + return runCatchingExceptions { + innerClient.canDeactivateAccount() + } + .getOrNull() + .orFalse() + } + + override suspend fun deactivateAccount(password: String, eraseData: Boolean): Result = withContext(sessionDispatcher) { + Timber.w("Deactivating account") + // Remove current delegate so we don't receive an auth error + clientDelegateTaskHandle?.cancelAndDestroy() + clientDelegateTaskHandle = null + runCatchingExceptions { + // First call without AuthData, should fail + val firstAttempt = runCatchingExceptions { + innerClient.deactivateAccount( + authData = null, + eraseData = eraseData, + ) + } + if (firstAttempt.isFailure) { + Timber.w(firstAttempt.exceptionOrNull(), "Expected failure, try again") + // This is expected, try again with the password + runCatchingExceptions { + innerClient.deactivateAccount( + authData = AuthData.Password( + passwordDetails = AuthDataPasswordDetails( + identifier = sessionId.value, + password = password, + ), + ), + eraseData = eraseData, + ) + }.onFailure { + Timber.e(it, "Failed to deactivate account") + // If the deactivation failed we need to restore the delegate + clientDelegateTaskHandle = innerClient.setDelegate(sessionDelegate) + throw it + } + } + destroy() + deleteSessionDirectory() + sessionStore.removeSession(sessionId.value) + }.onFailure { + Timber.e(it, "Failed to deactivate account") + } + } + + override suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result = withContext(sessionDispatcher) { + val rustAction = action?.toRustAction() + runCatchingExceptions { + innerClient.accountUrl(rustAction) + } + } + + override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.uploadMedia(mimeType, data, progressWatcher = null) + } + } + + override fun getRoomInfoFlow(roomId: RoomId): Flow> { + return mxCallbackFlow { + val roomNotFound = innerRoomListService.roomOrNull(roomId.value).use { it == null } + if (roomNotFound) { + channel.send(Optional.empty()) + } + innerClient.subscribeToRoomInfo(roomId.value, object : RoomInfoListener { + override fun call(roomInfo: org.matrix.rustcomponents.sdk.RoomInfo) { + val mappedRoomInfo = roomInfoMapper.map(roomInfo) + channel.trySend(Optional.of(mappedRoomInfo)) + } + }) + }.distinctUntilChanged() + } + + override suspend fun setAllSendQueuesEnabled(enabled: Boolean) { + withContext(sessionDispatcher) { + Timber.i("setAllSendQueuesEnabled($enabled)") + tryOrNull { + innerClient.enableAllSendQueues(enabled) + } + } + } + + override fun sendQueueDisabledFlow(): Flow = mxCallbackFlow { + innerClient.subscribeToSendQueueStatus(object : SendQueueRoomErrorListener { + override fun onError(roomId: String, error: ClientException) { + trySend(RoomId(roomId)) + } + }) + }.buffer(Channel.UNLIMITED) + + override suspend fun currentSlidingSyncVersion(): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.session().slidingSyncVersion.map() + } + } + + override suspend fun canReportRoom(): Boolean = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.isReportRoomApiSupported() + }.getOrDefault(false) + } + + override suspend fun isLivekitRtcSupported(): Boolean = withContext(sessionDispatcher) { + innerClient.isLivekitRtcSupported() + } + + override suspend fun getMaxFileUploadSize(): Result = withContext(sessionDispatcher) { + runCatchingExceptions { innerClient.getMaxMediaUploadSize().toLong() } + } + + override suspend fun addRecentEmoji(emoji: String): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.addRecentEmoji(emoji) + } + } + + override suspend fun getRecentEmojis(): Result> = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.getRecentEmojis().map { it.emoji } + } + } + + override suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + val room = innerClient.getRoom(roomId.value) ?: error("Could not fetch associated room") + room.markAsFullyReadUnchecked(eventId.value) + } + } + + private suspend fun getCacheSize( + includeCryptoDb: Boolean = false, + ): Long = withContext(sessionDispatcher) { + val sessionDirectory = sessionPathsProvider.provides(sessionId) ?: return@withContext 0L + val cacheSize = sessionDirectory.cacheDirectory.getSizeOfFiles() + if (includeCryptoDb) { + cacheSize + sessionDirectory.fileDirectory.getSizeOfFiles() + } else { + cacheSize + listOf( + "matrix-sdk-state.sqlite3", + "matrix-sdk-state.sqlite3-shm", + "matrix-sdk-state.sqlite3-wal", + ).map { fileName -> + File(sessionDirectory.fileDirectory, fileName) + }.sumOf { file -> + file.length() + } + } + } + + private suspend fun deleteSessionDirectory() = withContext(sessionDispatcher) { + // Delete all the files for this session + sessionPathsProvider.provides(sessionId)?.deleteRecursively() + } +} + +private val defaultRoomCreationPowerLevels = PowerLevels( + usersDefault = null, + eventsDefault = null, + stateDefault = null, + ban = null, + kick = null, + redact = null, + invite = null, + notifications = null, + users = mapOf(), + events = mapOf( + "m.call.member" to 0, + "org.matrix.msc3401.call.member" to 0, + ) +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt new file mode 100644 index 0000000..4e335ae --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.data.ByteUnit +import io.element.android.libraries.core.data.megaBytes +import io.element.android.libraries.di.CacheDirectory +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.impl.analytics.UtdTracker +import io.element.android.libraries.matrix.impl.certificates.UserCertificatesProvider +import io.element.android.libraries.matrix.impl.paths.SessionPaths +import io.element.android.libraries.matrix.impl.paths.getSessionPaths +import io.element.android.libraries.matrix.impl.proxy.ProxyProvider +import io.element.android.libraries.matrix.impl.room.TimelineEventTypeFilterFactory +import io.element.android.libraries.matrix.impl.util.anonymizedTokens +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.analytics.api.AnalyticsService +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.ClientBuilder +import org.matrix.rustcomponents.sdk.RequestConfig +import org.matrix.rustcomponents.sdk.Session +import org.matrix.rustcomponents.sdk.SlidingSyncVersion +import org.matrix.rustcomponents.sdk.SlidingSyncVersionBuilder +import org.matrix.rustcomponents.sdk.SqliteStoreBuilder +import org.matrix.rustcomponents.sdk.use +import timber.log.Timber +import uniffi.matrix_sdk_base.MediaRetentionPolicy +import uniffi.matrix_sdk_crypto.CollectStrategy +import uniffi.matrix_sdk_crypto.DecryptionSettings +import uniffi.matrix_sdk_crypto.TrustRequirement +import java.io.File +import kotlin.time.Duration.Companion.days +import kotlin.time.toJavaDuration + +@Inject +class RustMatrixClientFactory( + @CacheDirectory private val cacheDirectory: File, + @AppCoroutineScope + private val appCoroutineScope: CoroutineScope, + private val coroutineDispatchers: CoroutineDispatchers, + private val sessionStore: SessionStore, + private val userAgentProvider: UserAgentProvider, + private val userCertificatesProvider: UserCertificatesProvider, + private val proxyProvider: ProxyProvider, + private val clock: SystemClock, + private val analyticsService: AnalyticsService, + private val featureFlagService: FeatureFlagService, + private val timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory, + private val clientBuilderProvider: ClientBuilderProvider, +) { + private val sessionDelegate = RustClientSessionDelegate(sessionStore, appCoroutineScope, coroutineDispatchers) + + suspend fun create(sessionData: SessionData): RustMatrixClient = withContext(coroutineDispatchers.io) { + val client = getBaseClientBuilder( + sessionPaths = sessionData.getSessionPaths(), + passphrase = sessionData.passphrase, + slidingSyncType = ClientBuilderSlidingSync.Restored, + ) + .homeserverUrl(sessionData.homeserverUrl) + .username(sessionData.userId) + .use { it.build() } + + client.setMediaRetentionPolicy( + MediaRetentionPolicy( + // Make this 500MB instead of 400MB + maxCacheSize = 500.megaBytes.to(ByteUnit.BYTES).toULong(), + // This is the default value, but let's make it explicit + maxFileSize = 20.megaBytes.to(ByteUnit.BYTES).toULong(), + // Use 30 days instead of 60 + lastAccessExpiry = 30.days.toJavaDuration(), + // This is the default value, but let's make it explicit + cleanupFrequency = 1.days.toJavaDuration(), + ) + ) + + client.restoreSession(sessionData.toSession()) + + create(client) + } + + suspend fun create(client: Client): RustMatrixClient { + val (anonymizedAccessToken, anonymizedRefreshToken) = client.session().anonymizedTokens() + + client.setUtdDelegate(UtdTracker(analyticsService)) + + val syncService = client.syncService() + .withSharePos(true) + .withOfflineMode() + .finish() + + return RustMatrixClient( + innerClient = client, + sessionStore = sessionStore, + appCoroutineScope = appCoroutineScope, + sessionDelegate = sessionDelegate, + innerSyncService = syncService, + dispatchers = coroutineDispatchers, + baseCacheDirectory = cacheDirectory, + clock = clock, + timelineEventTypeFilterFactory = timelineEventTypeFilterFactory, + featureFlagService = featureFlagService, + analyticsService = analyticsService, + ).also { + Timber.tag(it.toString()).d("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'") + } + } + + internal suspend fun getBaseClientBuilder( + sessionPaths: SessionPaths, + passphrase: String?, + slidingSyncType: ClientBuilderSlidingSync, + ): ClientBuilder { + return clientBuilderProvider.provide() + .sqliteStore( + SqliteStoreBuilder( + dataPath = sessionPaths.fileDirectory.absolutePath, + cachePath = sessionPaths.cacheDirectory.absolutePath, + ).passphrase(passphrase) + ) + .setSessionDelegate(sessionDelegate) + .userAgent(userAgentProvider.provide()) + .addRootCertificates(userCertificatesProvider.provides()) + .autoEnableBackups(true) + .autoEnableCrossSigning(true) + .roomKeyRecipientStrategy( + strategy = if (featureFlagService.isFeatureEnabled(FeatureFlags.OnlySignedDeviceIsolationMode)) { + CollectStrategy.IDENTITY_BASED_STRATEGY + } else { + CollectStrategy.ERROR_ON_VERIFIED_USER_PROBLEM + } + ) + .decryptionSettings( + DecryptionSettings( + senderDeviceTrustRequirement = if (featureFlagService.isFeatureEnabled(FeatureFlags.OnlySignedDeviceIsolationMode)) { + TrustRequirement.CROSS_SIGNED_OR_LEGACY + } else { + TrustRequirement.UNTRUSTED + } + ) + ) + .enableShareHistoryOnInvite(featureFlagService.isFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite)) + .threadsEnabled(featureFlagService.isFeatureEnabled(FeatureFlags.Threads), threadSubscriptions = false) + .requestConfig( + RequestConfig( + timeout = 30_000uL, + retryLimit = 0u, + // Use default values for the rest + maxConcurrentRequests = null, + maxRetryTime = null, + ) + ) + .run { + // Apply sliding sync version settings + when (slidingSyncType) { + ClientBuilderSlidingSync.Restored -> this + ClientBuilderSlidingSync.Discovered -> slidingSyncVersionBuilder(SlidingSyncVersionBuilder.DISCOVER_NATIVE) + ClientBuilderSlidingSync.Native -> slidingSyncVersionBuilder(SlidingSyncVersionBuilder.NATIVE) + } + } + .run { + // Workaround for non-nullable proxy parameter in the SDK, since each call to the ClientBuilder returns a new reference we need to keep + proxyProvider.provides()?.let { proxy(it) } ?: this + } + } +} + +sealed interface ClientBuilderSlidingSync { + // The proxy will be supplied when restoring the Session. + data object Restored : ClientBuilderSlidingSync + + // A Native Sliding Sync instance must be discovered whilst building the session. + data object Discovered : ClientBuilderSlidingSync + + // Force using Native Sliding Sync. + data object Native : ClientBuilderSlidingSync +} + +private fun SessionData.toSession() = Session( + accessToken = accessToken, + refreshToken = refreshToken, + userId = userId, + deviceId = deviceId, + homeserverUrl = homeserverUrl, + slidingSyncVersion = SlidingSyncVersion.NATIVE, + oidcData = oidcData, +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustSdkMetadata.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustSdkMetadata.kt new file mode 100644 index 0000000..65bf81e --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustSdkMetadata.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.matrix.api.SdkMetadata +import org.matrix.rustcomponents.sdk.sdkGitSha + +@ContributesBinding(AppScope::class) +class RustSdkMetadata : SdkMetadata { + override val sdkGitSha: String + get() = sdkGitSha() +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/JoinedRoomExt.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/JoinedRoomExt.kt new file mode 100644 index 0000000..263ce15 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/JoinedRoomExt.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.analytics + +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.isDm +import kotlinx.coroutines.flow.first + +private fun Long.toAnalyticsRoomSize(): JoinedRoom.RoomSize { + return when (this) { + 0L, + 1L -> JoinedRoom.RoomSize.One + 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 + } +} + +suspend fun BaseRoom.toAnalyticsJoinedRoom(trigger: JoinedRoom.Trigger?): JoinedRoom { + val roomInfo = roomInfoFlow.first() + return roomInfo.toAnalyticsJoinedRoom(trigger) +} + +fun RoomInfo.toAnalyticsJoinedRoom(trigger: JoinedRoom.Trigger?): JoinedRoom { + return JoinedRoom( + isDM = isDm, + isSpace = isSpace, + roomSize = joinedMembersCount.toAnalyticsRoomSize(), + trigger = trigger + ) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/UtdTracker.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/UtdTracker.kt new file mode 100644 index 0000000..8d71ad3 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/UtdTracker.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.analytics + +import im.vector.app.features.analytics.plan.Error +import io.element.android.services.analytics.api.AnalyticsService +import org.matrix.rustcomponents.sdk.UnableToDecryptDelegate +import org.matrix.rustcomponents.sdk.UnableToDecryptInfo +import timber.log.Timber +import uniffi.matrix_sdk_crypto.UtdCause + +class UtdTracker( + private val analyticsService: AnalyticsService, +) : UnableToDecryptDelegate { + override fun onUtd(info: UnableToDecryptInfo) { + Timber.d("onUtd for event ${info.eventId}, timeToDecryptMs: ${info.timeToDecryptMs}") + val name = when (info.cause) { + UtdCause.UNKNOWN -> Error.Name.OlmKeysNotSentError + UtdCause.SENT_BEFORE_WE_JOINED -> Error.Name.ExpectedDueToMembership + UtdCause.VERIFICATION_VIOLATION -> Error.Name.ExpectedVerificationViolation + UtdCause.UNSIGNED_DEVICE, + UtdCause.UNKNOWN_DEVICE -> { + Error.Name.ExpectedSentByInsecureDevice + } + UtdCause.HISTORICAL_MESSAGE_AND_BACKUP_IS_DISABLED, + UtdCause.HISTORICAL_MESSAGE_AND_DEVICE_IS_UNVERIFIED, + -> Error.Name.HistoricalMessage + UtdCause.WITHHELD_FOR_UNVERIFIED_OR_INSECURE_DEVICE -> Error.Name.RoomKeysWithheldForUnverifiedDevice + UtdCause.WITHHELD_BY_SENDER -> Error.Name.OlmKeysNotSentError + } + val event = Error( + context = null, + // Keep cryptoModule for compatibility. + cryptoModule = Error.CryptoModule.Rust, + cryptoSDK = Error.CryptoSDK.Rust, + timeToDecryptMillis = info.timeToDecryptMs?.toInt() ?: -1, + domain = Error.Domain.E2EE, + name = name, + eventLocalAgeMillis = info.eventLocalAgeMillis.toInt(), + userTrustsOwnIdentity = info.userTrustsOwnIdentity, + isFederated = info.ownHomeserver != info.senderHomeserver, + isMatrixDotOrg = info.ownHomeserver == "matrix.org", + ) + analyticsService.capture(event) + } +} 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 0000000..36af411 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth + +import io.element.android.libraries.matrix.api.auth.AuthenticationException +import org.matrix.rustcomponents.sdk.ClientBuildException +import org.matrix.rustcomponents.sdk.OidcException + +fun Throwable.mapAuthenticationException(): AuthenticationException { + return when (this) { + is AuthenticationException -> this + is ClientBuildException -> when (this) { + is ClientBuildException.Generic -> AuthenticationException.Generic(message) + is ClientBuildException.InvalidServerName -> AuthenticationException.InvalidServerName(message) + is ClientBuildException.SlidingSyncVersion -> AuthenticationException.SlidingSyncVersion(message) + is ClientBuildException.Sdk -> AuthenticationException.Generic(message) + is ClientBuildException.ServerUnreachable -> AuthenticationException.ServerUnreachable(message) + is ClientBuildException.SlidingSync -> AuthenticationException.Generic(message) + is ClientBuildException.WellKnownDeserializationException -> AuthenticationException.Generic(message) + is ClientBuildException.WellKnownLookupFailed -> AuthenticationException.Generic(message) + is ClientBuildException.EventCache -> AuthenticationException.Generic(message) + } + is OidcException -> when (this) { + is OidcException.Generic -> AuthenticationException.Oidc(message) + is OidcException.CallbackUrlInvalid -> AuthenticationException.Oidc(message) + is OidcException.Cancelled -> AuthenticationException.Oidc(message) + is OidcException.MetadataInvalid -> AuthenticationException.Oidc(message) + is OidcException.NotSupported -> AuthenticationException.Oidc(message) + } + else -> AuthenticationException.Generic(message) + } +} 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 0000000..acf96d6 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/HomeserverDetails.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 = supportsOidcLogin(), + ) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProvider.kt new file mode 100644 index 0000000..6f9dd67 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProvider.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.auth.OidcConfig +import io.element.android.libraries.matrix.api.auth.OidcRedirectUrlProvider +import org.matrix.rustcomponents.sdk.OidcConfiguration + +@Inject +class OidcConfigurationProvider( + private val buildMeta: BuildMeta, + private val oidcRedirectUrlProvider: OidcRedirectUrlProvider, +) { + fun get(): OidcConfiguration = OidcConfiguration( + clientName = buildMeta.applicationName, + redirectUri = oidcRedirectUrlProvider.provide(), + clientUri = OidcConfig.CLIENT_URI, + logoUri = OidcConfig.LOGO_URI, + tosUri = OidcConfig.TOS_URI, + policyUri = OidcConfig.POLICY_URI, + staticRegistrations = OidcConfig.STATIC_REGISTRATIONS, + ) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcPrompt.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcPrompt.kt new file mode 100644 index 0000000..e21d8d9 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/OidcPrompt.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth + +import io.element.android.libraries.matrix.api.auth.OidcPrompt +import org.matrix.rustcomponents.sdk.OidcPrompt as RustOidcPrompt + +internal fun OidcPrompt.toRustPrompt(): RustOidcPrompt { + return when (this) { + OidcPrompt.Login -> RustOidcPrompt.Unknown("consent") + OidcPrompt.Create -> RustOidcPrompt.Create + is OidcPrompt.Unknown -> RustOidcPrompt.Unknown(value) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeServerLoginCompatibilityChecker.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeServerLoginCompatibilityChecker.kt new file mode 100644 index 0000000..bab803f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeServerLoginCompatibilityChecker.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker +import io.element.android.libraries.matrix.impl.ClientBuilderProvider +import io.element.android.libraries.matrix.impl.certificates.UserCertificatesProvider +import timber.log.Timber + +@ContributesBinding(AppScope::class) +class RustHomeServerLoginCompatibilityChecker( + private val clientBuilderProvider: ClientBuilderProvider, + private val userCertificatesProvider: UserCertificatesProvider, +) : HomeServerLoginCompatibilityChecker { + override suspend fun check(url: String): Result = runCatchingExceptions { + clientBuilderProvider.provide() + .inMemoryStore() + .serverNameOrHomeserverUrl(url) + .addRootCertificates(userCertificatesProvider.provides()) + .build() + .use { + it.homeserverLoginDetails() + } + .use { + Timber.d("Homeserver $url | OIDC: ${it.supportsOidcLogin()} | Password: ${it.supportsPasswordLogin()} | SSO: ${it.supportsSsoLogin()}") + it.supportsOidcLogin() || it.supportsPasswordLogin() + } + } +} 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 0000000..df43336 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -0,0 +1,358 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.mapFailure +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.auth.AuthenticationException +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.auth.OidcPrompt +import io.element.android.libraries.matrix.api.auth.external.ExternalSession +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync +import io.element.android.libraries.matrix.impl.RustMatrixClientFactory +import io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper +import io.element.android.libraries.matrix.impl.auth.qrlogin.SdkQrCodeLoginData +import io.element.android.libraries.matrix.impl.auth.qrlogin.toStep +import io.element.android.libraries.matrix.impl.exception.mapClientException +import io.element.android.libraries.matrix.impl.keys.PassphraseGenerator +import io.element.android.libraries.matrix.impl.mapper.toSessionData +import io.element.android.libraries.matrix.impl.paths.SessionPaths +import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory +import io.element.android.libraries.sessionstorage.api.LoginType +import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.ClientBuilder +import org.matrix.rustcomponents.sdk.HumanQrLoginException +import org.matrix.rustcomponents.sdk.QrCodeData +import org.matrix.rustcomponents.sdk.QrCodeDecodeException +import org.matrix.rustcomponents.sdk.QrLoginProgress +import org.matrix.rustcomponents.sdk.QrLoginProgressListener +import timber.log.Timber +import uniffi.matrix_sdk.OAuthAuthorizationData + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class RustMatrixAuthenticationService( + private val sessionPathsFactory: SessionPathsFactory, + private val coroutineDispatchers: CoroutineDispatchers, + private val sessionStore: SessionStore, + private val rustMatrixClientFactory: RustMatrixClientFactory, + private val passphraseGenerator: PassphraseGenerator, + private val oidcConfigurationProvider: OidcConfigurationProvider, +) : MatrixAuthenticationService { + // Passphrase which will be used for new sessions. Existing sessions will use the passphrase + // stored in the SessionData. + private val pendingPassphrase = getDatabasePassphrase() + + // Need to keep a copy of the current session path to eventually delete it. + // Ideally it would be possible to get the sessionPath from the Client to avoid doing this. + private var sessionPaths: SessionPaths? = null + private var currentClient: Client? = null + + private val newMatrixClientObservers = mutableListOf<(MatrixClient) -> Unit>() + override fun listenToNewMatrixClients(lambda: (MatrixClient) -> Unit) { + newMatrixClientObservers.add(lambda) + } + + private fun rotateSessionPath(): SessionPaths { + sessionPaths?.deleteRecursively() + return sessionPathsFactory.create() + .also { sessionPaths = it } + } + + override suspend fun restoreSession(sessionId: SessionId): Result = withContext(coroutineDispatchers.io) { + runCatchingExceptions { + val sessionData = sessionStore.getSession(sessionId.value) + if (sessionData != null) { + if (sessionData.isTokenValid) { + // Use the sessionData.passphrase, which can be null for a previously created session + if (sessionData.passphrase == null) { + Timber.w("Restoring a session without a passphrase") + } else { + Timber.w("Restoring a session with a passphrase") + } + rustMatrixClientFactory.create(sessionData) + } else { + error("Token is not valid") + } + } else { + error("No session to restore with id $sessionId") + } + }.mapFailure { failure -> + failure.mapClientException() + } + } + + private fun getDatabasePassphrase(): String? { + val passphrase = passphraseGenerator.generatePassphrase() + if (passphrase != null) { + Timber.w("New sessions will be encrypted with a passphrase") + } + return passphrase + } + + override suspend fun setHomeserver(homeserver: String): Result = + withContext(coroutineDispatchers.io) { + val emptySessionPath = rotateSessionPath() + runCatchingExceptions { + val client = makeClient(sessionPaths = emptySessionPath) { + serverNameOrHomeserverUrl(homeserver) + } + + currentClient = client + client.homeserverLoginDetails().map() + }.onFailure { + clear() + }.mapFailure { failure -> + Timber.e(failure, "Failed to set homeserver to $homeserver") + failure.mapAuthenticationException() + } + } + + override suspend fun login(username: String, password: String): Result = + withContext(coroutineDispatchers.io) { + runCatchingExceptions { + val client = currentClient ?: error("You need to call `setHomeserver()` first") + val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first") + client.login(username, password, "Element X Android", null) + // Ensure that the user is not already logged in with the same account + ensureNotAlreadyLoggedIn(client) + val sessionData = client.session() + .toSessionData( + isTokenValid = true, + loginType = LoginType.PASSWORD, + passphrase = pendingPassphrase, + sessionPaths = currentSessionPaths, + ) + val matrixClient = rustMatrixClientFactory.create(client) + newMatrixClientObservers.forEach { it.invoke(matrixClient) } + sessionStore.addSession(sessionData) + + // Clean up the strong reference held here since it's no longer necessary + currentClient = null + + SessionId(sessionData.userId) + }.mapFailure { failure -> + Timber.e(failure, "Failed to login") + failure.mapAuthenticationException() + } + } + + override suspend fun importCreatedSession(externalSession: ExternalSession): Result = + withContext(coroutineDispatchers.io) { + runCatchingExceptions { + currentClient ?: error("You need to call `setHomeserver()` first") + val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first") + val sessionData = externalSession.toSessionData( + isTokenValid = true, + loginType = LoginType.PASSWORD, + passphrase = pendingPassphrase, + sessionPaths = currentSessionPaths, + ) + clear() + sessionStore.addSession(sessionData) + SessionId(sessionData.userId) + } + } + + private var pendingOAuthAuthorizationData: OAuthAuthorizationData? = null + + override suspend fun getOidcUrl( + prompt: OidcPrompt, + loginHint: String?, + ): Result { + return withContext(coroutineDispatchers.io) { + runCatchingExceptions { + val client = currentClient ?: error("You need to call `setHomeserver()` first") + val oAuthAuthorizationData = client.urlForOidc( + oidcConfiguration = oidcConfigurationProvider.get(), + prompt = prompt.toRustPrompt(), + loginHint = loginHint, + // If we want to restore a previous session for which we have encryption keys, we can pass the deviceId here. At the moment, we don't + deviceId = null, + additionalScopes = emptyList(), + ) + val url = oAuthAuthorizationData.loginUrl() + pendingOAuthAuthorizationData = oAuthAuthorizationData + OidcDetails(url) + }.mapFailure { failure -> + Timber.e(failure, "Failed to get OIDC URL") + failure.mapAuthenticationException() + } + } + } + + override suspend fun cancelOidcLogin(): Result { + return withContext(coroutineDispatchers.io) { + runCatchingExceptions { + pendingOAuthAuthorizationData?.use { + currentClient?.abortOidcAuth(it) + } + pendingOAuthAuthorizationData = null + }.mapFailure { failure -> + Timber.e(failure, "Failed to cancel OIDC login") + failure.mapAuthenticationException() + } + } + } + + /** + * callbackUrl should be the uriRedirect from OidcClientMetadata (with all the parameters). + */ + override suspend fun loginWithOidc(callbackUrl: String): Result { + return withContext(coroutineDispatchers.io) { + runCatchingExceptions { + val client = currentClient ?: error("You need to call `setHomeserver()` first") + val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first") + client.loginWithOidcCallback(callbackUrl) + + // Free the pending data since we won't use it to abort the flow anymore + pendingOAuthAuthorizationData?.close() + pendingOAuthAuthorizationData = null + + // Ensure that the user is not already logged in with the same account + ensureNotAlreadyLoggedIn(client) + val sessionData = client.session().toSessionData( + isTokenValid = true, + loginType = LoginType.OIDC, + passphrase = pendingPassphrase, + sessionPaths = currentSessionPaths, + ) + val matrixClient = rustMatrixClientFactory.create(client) + newMatrixClientObservers.forEach { it.invoke(matrixClient) } + sessionStore.addSession(sessionData) + + // Clean up the strong reference held here since it's no longer necessary + currentClient = null + + SessionId(sessionData.userId) + }.mapFailure { failure -> + Timber.e(failure, "Failed to login with OIDC") + failure.mapAuthenticationException() + } + } + } + + @Throws(AuthenticationException.AccountAlreadyLoggedIn::class) + private suspend fun ensureNotAlreadyLoggedIn(client: Client) { + val newUserId = client.userId() + val accountAlreadyLoggedIn = sessionStore.getAllSessions().any { + it.userId == newUserId + } + if (accountAlreadyLoggedIn) { + // Sign out the client, ignoring any error + runCatchingExceptions { + client.logout() + } + throw AuthenticationException.AccountAlreadyLoggedIn(newUserId) + } + } + + override suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit) = + withContext(coroutineDispatchers.io) { + val sdkQrCodeLoginData = (qrCodeData as SdkQrCodeLoginData).rustQrCodeData + val emptySessionPaths = rotateSessionPath() + val oidcConfiguration = oidcConfigurationProvider.get() + val progressListener = object : QrLoginProgressListener { + override fun onUpdate(state: QrLoginProgress) { + Timber.d("QR Code login progress: $state") + progress(state.toStep()) + } + } + runCatchingExceptions { + val client = makeQrCodeLoginClient( + sessionPaths = emptySessionPaths, + qrCodeData = sdkQrCodeLoginData, + ) + client.newLoginWithQrCodeHandler( + oidcConfiguration = oidcConfiguration, + ).use { + it.scan( + qrCodeData = qrCodeData.rustQrCodeData, + progressListener = progressListener, + ) + } + // Ensure that the user is not already logged in with the same account + ensureNotAlreadyLoggedIn(client) + val sessionData = client.session() + .toSessionData( + isTokenValid = true, + loginType = LoginType.QR, + passphrase = pendingPassphrase, + sessionPaths = emptySessionPaths, + ) + val matrixClient = rustMatrixClientFactory.create(client) + newMatrixClientObservers.forEach { it.invoke(matrixClient) } + sessionStore.addSession(sessionData) + + // Clean up the strong reference held here since it's no longer necessary + currentClient = null + + SessionId(sessionData.userId) + }.mapFailure { + when (it) { + is QrCodeDecodeException -> QrErrorMapper.map(it) + is HumanQrLoginException -> QrErrorMapper.map(it) + else -> it + } + }.onFailure { throwable -> + if (throwable is CancellationException) { + throw throwable + } + Timber.e(throwable, "Failed to login with QR code") + } + } + + private suspend fun makeClient( + sessionPaths: SessionPaths, + config: suspend ClientBuilder.() -> ClientBuilder, + ): Client { + Timber.d("Creating client with simplified sliding sync") + return rustMatrixClientFactory + .getBaseClientBuilder( + sessionPaths = sessionPaths, + passphrase = pendingPassphrase, + slidingSyncType = ClientBuilderSlidingSync.Discovered, + ) + .config() + .build() + } + + private suspend fun makeQrCodeLoginClient( + sessionPaths: SessionPaths, + qrCodeData: QrCodeData, + ): Client { + Timber.d("Creating client for QR Code login with simplified sliding sync") + return rustMatrixClientFactory + .getBaseClientBuilder( + sessionPaths = sessionPaths, + passphrase = pendingPassphrase, + slidingSyncType = ClientBuilderSlidingSync.Discovered, + ) + .serverNameOrHomeserverUrl(qrCodeData.serverName()!!) + .build() + } + + private fun clear() { + currentClient?.close() + currentClient = null + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrErrorMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrErrorMapper.kt new file mode 100644 index 0000000..65e15b5 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrErrorMapper.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth.qrlogin + +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeDecodeException +import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException +import org.matrix.rustcomponents.sdk.HumanQrLoginException as RustHumanQrLoginException +import org.matrix.rustcomponents.sdk.QrCodeDecodeException as RustQrCodeDecodeException + +object QrErrorMapper { + fun map(qrCodeDecodeException: RustQrCodeDecodeException): QrCodeDecodeException = when (qrCodeDecodeException) { + is RustQrCodeDecodeException.Crypto -> { + // We plan to restore it in the future when UniFFi can process them +// val reason = when (qrCodeDecodeException.error) { +// LoginQrCodeDecodeError.NOT_ENOUGH_DATA -> QrCodeDecodeException.Crypto.Reason.NOT_ENOUGH_DATA +// LoginQrCodeDecodeError.NOT_UTF8 -> QrCodeDecodeException.Crypto.Reason.NOT_UTF8 +// LoginQrCodeDecodeError.URL_PARSE -> QrCodeDecodeException.Crypto.Reason.URL_PARSE +// LoginQrCodeDecodeError.INVALID_MODE -> QrCodeDecodeException.Crypto.Reason.INVALID_MODE +// LoginQrCodeDecodeError.INVALID_VERSION -> QrCodeDecodeException.Crypto.Reason.INVALID_VERSION +// LoginQrCodeDecodeError.BASE64 -> QrCodeDecodeException.Crypto.Reason.BASE64 +// LoginQrCodeDecodeError.INVALID_PREFIX -> QrCodeDecodeException.Crypto.Reason.INVALID_PREFIX +// } + QrCodeDecodeException.Crypto( + qrCodeDecodeException.message.orEmpty(), +// reason + ) + } + } + + fun map(humanQrLoginError: RustHumanQrLoginException): QrLoginException = when (humanQrLoginError) { + is RustHumanQrLoginException.Cancelled -> QrLoginException.Cancelled + is RustHumanQrLoginException.ConnectionInsecure -> QrLoginException.ConnectionInsecure + is RustHumanQrLoginException.Declined -> QrLoginException.Declined + is RustHumanQrLoginException.Expired -> QrLoginException.Expired + is RustHumanQrLoginException.OtherDeviceNotSignedIn -> QrLoginException.OtherDeviceNotSignedIn + is RustHumanQrLoginException.LinkingNotSupported -> QrLoginException.LinkingNotSupported + is RustHumanQrLoginException.Unknown -> QrLoginException.Unknown + is RustHumanQrLoginException.OidcMetadataInvalid -> QrLoginException.OidcMetadataInvalid + is RustHumanQrLoginException.SlidingSyncNotAvailable -> QrLoginException.SlidingSyncNotAvailable + is RustHumanQrLoginException.CheckCodeAlreadySent -> QrLoginException.CheckCodeAlreadySent + is RustHumanQrLoginException.CheckCodeCannotBeSent -> QrLoginException.CheckCodeCannotBeSent + is RustHumanQrLoginException.NotFound -> QrLoginException.NotFound + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrLoginProgressExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrLoginProgressExtensions.kt new file mode 100644 index 0000000..96ae276 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrLoginProgressExtensions.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth.qrlogin + +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import org.matrix.rustcomponents.sdk.QrLoginProgress + +fun QrLoginProgress.toStep(): QrCodeLoginStep { + return when (this) { + is QrLoginProgress.EstablishingSecureChannel -> QrCodeLoginStep.EstablishingSecureChannel(checkCodeString) + is QrLoginProgress.Starting -> QrCodeLoginStep.Starting + is QrLoginProgress.WaitingForToken -> QrCodeLoginStep.WaitingForToken(userCode) + is QrLoginProgress.SyncingSecrets -> QrCodeLoginStep.SyncingSecrets + is QrLoginProgress.Done -> QrCodeLoginStep.Finished + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/RustQrCodeLoginDataFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/RustQrCodeLoginDataFactory.kt new file mode 100644 index 0000000..0e2c206 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/RustQrCodeLoginDataFactory.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth.qrlogin + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginDataFactory +import org.matrix.rustcomponents.sdk.QrCodeData + +@ContributesBinding(AppScope::class) +class RustQrCodeLoginDataFactory : MatrixQrCodeLoginDataFactory { + override fun parseQrCodeData(data: ByteArray): Result { + return runCatchingExceptions { SdkQrCodeLoginData(QrCodeData.fromBytes(data)) } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/SdkQrCodeLoginData.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/SdkQrCodeLoginData.kt new file mode 100644 index 0000000..b269804 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/SdkQrCodeLoginData.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth.qrlogin + +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import org.matrix.rustcomponents.sdk.QrCodeData as RustQrCodeData + +class SdkQrCodeLoginData( + internal val rustQrCodeData: RustQrCodeData, +) : MatrixQrCodeLoginData { + override fun serverName(): String? { + return rustQrCodeData.serverName() + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/certificates/DefaultUserCertificatesProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/certificates/DefaultUserCertificatesProvider.kt new file mode 100644 index 0000000..902fe5b --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/certificates/DefaultUserCertificatesProvider.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.certificates + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import timber.log.Timber +import java.security.KeyStore +import java.security.KeyStoreException + +@ContributesBinding(AppScope::class) +class DefaultUserCertificatesProvider : UserCertificatesProvider { + /** + * Get additional user-installed certificates from the `AndroidCAStore` `Keystore`. + * + * The Rust HTTP client doesn't include user-installed certificates in its internal certificate + * store. This means that whatever the user installs will be ignored. + * + * While most users don't need user-installed certificates some special deployments or debugging + * setups using a proxy might want to use them. + * + * @return A list of byte arrays where each byte array is a single user-installed certificate + * in encoded form. + */ + override fun provides(): List { + // At least for API 34 the `AndroidCAStore` `Keystore` type contained user certificates as well. + // I have not found this to be documented anywhere. + val keyStore: KeyStore = try { + KeyStore.getInstance("AndroidCAStore") + } catch (e: KeyStoreException) { + Timber.w(e, "Failed to get AndroidCAStore keystore") + return emptyList() + } + val aliases = try { + keyStore.load(null) + keyStore.aliases() + } catch (e: Exception) { + Timber.w(e, "Failed to load and get aliases AndroidCAStore keystore") + return emptyList() + } + return aliases.toList() + .filter { alias -> + // The certificate alias always contains the prefix `system` or + // `user` and the MD5 subject hash separated by a colon. + // + // The subject hash can be calculated using openssl as such: + // openssl x509 -subject_hash_old -noout -in mycert.cer + // + // Again, I have not found this to be documented somewhere. + alias.startsWith("user") + } + .mapNotNull { alias -> + try { + keyStore.getEntry(alias, null) + } catch (e: Exception) { + Timber.w(e, "Failed to get entry for alias $alias") + null + } + } + .filterIsInstance() + .map { trustedCertificateEntry -> + trustedCertificateEntry.trustedCertificate.encoded + } + .also { + // Let's at least log the number of user-installed certificates we found, + // since the alias isn't particularly useful nor does the issuer seem to + // be easily available. + Timber.i("Found ${it.size} additional user-provided certificates.") + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/certificates/UserCertificatesProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/certificates/UserCertificatesProvider.kt new file mode 100644 index 0000000..90d1584 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/certificates/UserCertificatesProvider.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.certificates + +interface UserCertificatesProvider { + fun provides(): List +} 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 0000000..058243a --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/core/ProgressWatcherWrapper.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/core/RustSendHandle.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/core/RustSendHandle.kt new file mode 100644 index 0000000..bb472f7 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/core/RustSendHandle.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.core + +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.core.SendHandle + +class RustSendHandle( + val inner: org.matrix.rustcomponents.sdk.SendHandle, +) : SendHandle { + override suspend fun retry(): Result { + return runCatchingExceptions { + inner.tryResend() + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/RoomModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/RoomModule.kt new file mode 100644 index 0000000..af6f032 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/RoomModule.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.di + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.annotations.RoomCoroutineScope +import io.element.android.libraries.matrix.api.room.BaseRoom +import kotlinx.coroutines.CoroutineScope + +@BindingContainer +@ContributesTo(RoomScope::class) +object RoomModule { + @RoomCoroutineScope + @Provides + fun providesSessionCoroutineScope(room: BaseRoom): CoroutineScope { + return room.roomCoroutineScope + } +} 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 0000000..6ca7d27 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.di + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.media.MediaPreviewService +import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.spaces.SpaceService +import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import kotlinx.coroutines.CoroutineScope + +@BindingContainer +@ContributesTo(SessionScope::class) +object SessionMatrixModule { + @Provides + fun providesSessionId(matrixClient: MatrixClient): SessionId { + return matrixClient.sessionId + } + + @Provides + fun providesSessionVerificationService(matrixClient: MatrixClient): SessionVerificationService { + return matrixClient.sessionVerificationService + } + + @Provides + fun providesNotificationSettingsService(matrixClient: MatrixClient): NotificationSettingsService { + return matrixClient.notificationSettingsService + } + + @Provides + fun provideRoomMembershipObserver(matrixClient: MatrixClient): RoomMembershipObserver { + return matrixClient.roomMembershipObserver + } + + @Provides + fun providesRoomListService(matrixClient: MatrixClient): RoomListService { + return matrixClient.roomListService + } + + @Provides + fun providesSyncService(matrixClient: MatrixClient): SyncService { + return matrixClient.syncService + } + + @Provides + fun providesEncryptionService(matrixClient: MatrixClient): EncryptionService { + return matrixClient.encryptionService + } + + @Provides + fun providesMatrixMediaLoader(matrixClient: MatrixClient): MatrixMediaLoader { + return matrixClient.matrixMediaLoader + } + + @SessionCoroutineScope + @Provides + fun providesSessionCoroutineScope(matrixClient: MatrixClient): CoroutineScope { + return matrixClient.sessionCoroutineScope + } + + @Provides + fun providesRoomDirectoryService(matrixClient: MatrixClient): RoomDirectoryService { + return matrixClient.roomDirectoryService + } + + @Provides + fun providesMediaPreviewService(matrixClient: MatrixClient): MediaPreviewService { + return matrixClient.mediaPreviewService + } + + @Provides + fun providesSpaceService(matrixClient: MatrixClient): SpaceService { + return matrixClient.spaceService + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/BackupStateMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/BackupStateMapper.kt new file mode 100644 index 0000000..a3728bb --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/BackupStateMapper.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.encryption + +import io.element.android.libraries.matrix.api.encryption.BackupState +import org.matrix.rustcomponents.sdk.BackupState as RustBackupState + +class BackupStateMapper { + fun map(backupState: RustBackupState): BackupState { + return when (backupState) { + RustBackupState.UNKNOWN -> BackupState.UNKNOWN + RustBackupState.CREATING -> BackupState.CREATING + RustBackupState.ENABLING -> BackupState.ENABLING + RustBackupState.RESUMING -> BackupState.RESUMING + RustBackupState.ENABLED -> BackupState.ENABLED + RustBackupState.DOWNLOADING -> BackupState.DOWNLOADING + RustBackupState.DISABLING -> BackupState.DISABLING + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/BackupUploadStateMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/BackupUploadStateMapper.kt new file mode 100644 index 0000000..393813a --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/BackupUploadStateMapper.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.encryption + +import io.element.android.libraries.matrix.api.encryption.BackupUploadState +import org.matrix.rustcomponents.sdk.BackupUploadState as RustBackupUploadState + +class BackupUploadStateMapper { + fun map(rustEnableProgress: RustBackupUploadState): BackupUploadState { + return when (rustEnableProgress) { + RustBackupUploadState.Done -> + BackupUploadState.Done + is RustBackupUploadState.Uploading -> { + val backedUpCount = rustEnableProgress.backedUpCount.toInt() + val totalCount = rustEnableProgress.totalCount.toInt() + if (backedUpCount == totalCount) { + // Consider that the state is Done in this case, + // the SDK will not send a Done state + BackupUploadState.Done + } else { + BackupUploadState.Uploading( + backedUpCount = backedUpCount, + totalCount = totalCount, + ) + } + } + RustBackupUploadState.Waiting -> + BackupUploadState.Waiting + RustBackupUploadState.Error -> + BackupUploadState.Error + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/EnableRecoveryProgressMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/EnableRecoveryProgressMapper.kt new file mode 100644 index 0000000..4d0de46 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/EnableRecoveryProgressMapper.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.encryption + +import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress +import org.matrix.rustcomponents.sdk.EnableRecoveryProgress as RustEnableRecoveryProgress + +class EnableRecoveryProgressMapper { + fun map(rustEnableProgress: RustEnableRecoveryProgress): EnableRecoveryProgress { + return when (rustEnableProgress) { + is RustEnableRecoveryProgress.Starting -> EnableRecoveryProgress.Starting + is RustEnableRecoveryProgress.CreatingBackup -> EnableRecoveryProgress.CreatingBackup + is RustEnableRecoveryProgress.CreatingRecoveryKey -> EnableRecoveryProgress.CreatingRecoveryKey + is RustEnableRecoveryProgress.BackingUp -> EnableRecoveryProgress.BackingUp( + backedUpCount = rustEnableProgress.backedUpCount.toInt(), + totalCount = rustEnableProgress.totalCount.toInt(), + ) + is RustEnableRecoveryProgress.RoomKeyUploadError -> EnableRecoveryProgress.RoomKeyUploadError + is RustEnableRecoveryProgress.Done -> EnableRecoveryProgress.Done( + recoveryKey = rustEnableProgress.recoveryKey + ) + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/EncryptionExtension.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/EncryptionExtension.kt new file mode 100644 index 0000000..1fd670e --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/EncryptionExtension.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.encryption + +import io.element.android.libraries.matrix.api.encryption.BackupState +import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.impl.util.mxCallbackFlow +import kotlinx.coroutines.flow.Flow +import org.matrix.rustcomponents.sdk.BackupStateListener +import org.matrix.rustcomponents.sdk.EncryptionInterface +import org.matrix.rustcomponents.sdk.RecoveryStateListener +import org.matrix.rustcomponents.sdk.BackupState as RustBackupState +import org.matrix.rustcomponents.sdk.RecoveryState as RustRecoveryState + +internal fun EncryptionInterface.backupStateFlow(): Flow = mxCallbackFlow { + val backupStateMapper = BackupStateMapper() + trySend(backupStateMapper.map(backupState())) + val listener = object : BackupStateListener { + override fun onUpdate(status: RustBackupState) { + trySend(backupStateMapper.map(status)) + } + } + backupStateListener(listener) +} + +internal fun EncryptionInterface.recoveryStateFlow(): Flow = mxCallbackFlow { + val recoveryStateMapper = RecoveryStateMapper() + trySend(recoveryStateMapper.map(recoveryState())) + val listener = object : RecoveryStateListener { + override fun onUpdate(status: RustRecoveryState) { + trySend(recoveryStateMapper.map(status)) + } + } + recoveryStateListener(listener) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RecoveryExceptionMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RecoveryExceptionMapper.kt new file mode 100644 index 0000000..76a72cd --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RecoveryExceptionMapper.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.encryption + +import io.element.android.libraries.matrix.api.encryption.RecoveryException +import io.element.android.libraries.matrix.api.exception.ClientException +import io.element.android.libraries.matrix.impl.exception.mapClientException +import org.matrix.rustcomponents.sdk.RecoveryException as RustRecoveryException + +fun Throwable.mapRecoveryException(): RecoveryException { + return when (this) { + is RustRecoveryException -> { + when (this) { + is RustRecoveryException.SecretStorage -> RecoveryException.SecretStorage( + message = errorMessage + ) + is RustRecoveryException.BackupExistsOnServer -> RecoveryException.BackupExistsOnServer + is RustRecoveryException.Import -> RecoveryException.Import( + message = errorMessage + ) + is RustRecoveryException.Client -> RecoveryException.Client( + source.mapClientException() + ) + } + } + else -> RecoveryException.Client( + ClientException.Other("Unknown error") + ) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RecoveryStateMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RecoveryStateMapper.kt new file mode 100644 index 0000000..dbb2fd9 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RecoveryStateMapper.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.encryption + +import io.element.android.libraries.matrix.api.encryption.RecoveryState +import org.matrix.rustcomponents.sdk.RecoveryState as RustRecoveryState + +class RecoveryStateMapper { + fun map(state: RustRecoveryState): RecoveryState { + return when (state) { + RustRecoveryState.UNKNOWN -> RecoveryState.UNKNOWN + RustRecoveryState.ENABLED -> RecoveryState.ENABLED + RustRecoveryState.DISABLED -> RecoveryState.DISABLED + RustRecoveryState.INCOMPLETE -> RecoveryState.INCOMPLETE + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt new file mode 100644 index 0000000..e081d1c --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.encryption + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.flatMap +import io.element.android.libraries.core.extensions.mapFailure +import io.element.android.libraries.core.extensions.runCatchingExceptions +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.encryption.BackupState +import io.element.android.libraries.matrix.api.encryption.BackupUploadState +import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle +import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.libraries.matrix.impl.exception.mapClientException +import io.element.android.libraries.matrix.impl.sync.RustSyncService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.BackupSteadyStateListener +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.EnableRecoveryProgressListener +import org.matrix.rustcomponents.sdk.Encryption +import org.matrix.rustcomponents.sdk.UserIdentity +import timber.log.Timber +import org.matrix.rustcomponents.sdk.BackupUploadState as RustBackupUploadState +import org.matrix.rustcomponents.sdk.EnableRecoveryProgress as RustEnableRecoveryProgress +import org.matrix.rustcomponents.sdk.RecoveryException as RustRecoveryException +import org.matrix.rustcomponents.sdk.SteadyStateException as RustSteadyStateException + +class RustEncryptionService( + client: Client, + syncService: RustSyncService, + sessionCoroutineScope: CoroutineScope, + private val dispatchers: CoroutineDispatchers, +) : EncryptionService { + private val service: Encryption = client.encryption() + private val sessionId = SessionId(client.session().userId) + + private val enableRecoveryProgressMapper = EnableRecoveryProgressMapper() + private val backupUploadStateMapper = BackupUploadStateMapper() + private val steadyStateExceptionMapper = SteadyStateExceptionMapper() + + override val backupStateStateFlow = combine( + service.backupStateFlow(), + syncService.syncState, + ) { backupState, syncState -> + if (syncState == SyncState.Running) { + backupState + } else { + BackupState.WAITING_FOR_SYNC + } + }.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, BackupState.WAITING_FOR_SYNC) + + override val recoveryStateStateFlow = combine( + service.recoveryStateFlow(), + syncService.syncState, + ) { recoveryState, syncState -> + if (syncState == SyncState.Running) { + recoveryState + } else { + RecoveryState.WAITING_FOR_SYNC + } + }.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, RecoveryState.WAITING_FOR_SYNC) + + override val enableRecoveryProgressStateFlow: MutableStateFlow = MutableStateFlow(EnableRecoveryProgress.Starting) + + /** + * Check if the session is the last session every 5 seconds. + * TODO This is a temporary workaround, when we will have a way to observe + * the sessions, this code will have to be updated. + */ + override val isLastDevice: StateFlow = flow { + while (currentCoroutineContext().isActive) { + val result = isLastDevice().getOrDefault(false) + emit(result) + delay(5_000) + } + } + .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false) + + /** + * Check if the user has any devices available to verify against every 5 seconds. + * TODO This is a temporary workaround, when we will have a way to observe + * the sessions, this code will have to be updated. + */ + override val hasDevicesToVerifyAgainst: StateFlow> = flow { + while (currentCoroutineContext().isActive) { + val result = hasDevicesToVerifyAgainst() + result + .onSuccess { + emit(AsyncData.Success(it)) + } + .onFailure { + Timber.e(it, "Failed to get hasDevicesToVerifyAgainst, retrying in 5s...") + } + delay(5_000) + } + } + .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, AsyncData.Uninitialized) + + override suspend fun enableBackups(): Result = withContext(dispatchers.io) { + runCatchingExceptions { + service.enableBackups() + }.mapFailure { + it.mapRecoveryException() + } + } + + override suspend fun enableRecovery( + waitForBackupsToUpload: Boolean, + ): Result = withContext(dispatchers.io) { + runCatchingExceptions { + service.enableRecovery( + waitForBackupsToUpload = waitForBackupsToUpload, + progressListener = object : EnableRecoveryProgressListener { + override fun onUpdate(status: RustEnableRecoveryProgress) { + enableRecoveryProgressStateFlow.value = enableRecoveryProgressMapper.map(status) + } + }, + passphrase = null, + ) + // enableRecovery returns the encryption key, but we read it from the state flow + .let { } + }.mapFailure { + it.mapRecoveryException() + } + } + + override suspend fun doesBackupExistOnServer(): Result = withContext(dispatchers.io) { + runCatchingExceptions { + service.backupExistsOnServer() + } + } + + override fun waitForBackupUploadSteadyState(): Flow { + return callbackFlow { + runCatchingExceptions { + service.waitForBackupUploadSteadyState( + progressListener = object : BackupSteadyStateListener { + override fun onUpdate(status: RustBackupUploadState) { + trySend(backupUploadStateMapper.map(status)) + if (status == RustBackupUploadState.Done) { + close() + } + } + } + ) + }.onFailure { + if (it is RustSteadyStateException) { + trySend(BackupUploadState.SteadyException(steadyStateExceptionMapper.map(it))) + } else { + trySend(BackupUploadState.Error) + } + close() + } + awaitClose {} + } + } + + override suspend fun disableRecovery(): Result = withContext(dispatchers.io) { + runCatchingExceptions { + service.disableRecovery() + }.mapFailure { + it.mapRecoveryException() + } + } + + private suspend fun isLastDevice(): Result = withContext(dispatchers.io) { + runCatchingExceptions { + service.isLastDevice() + }.mapFailure { + it.mapRecoveryException() + } + } + + private suspend fun hasDevicesToVerifyAgainst(): Result = withContext(dispatchers.io) { + runCatchingExceptions { + service.hasDevicesToVerifyAgainst() + }.mapFailure { + it.mapClientException() + } + } + + override suspend fun resetRecoveryKey(): Result = withContext(dispatchers.io) { + runCatchingExceptions { + service.resetRecoveryKey() + }.mapFailure { + it.mapRecoveryException() + } + } + + override suspend fun recover(recoveryKey: String): Result = withContext(dispatchers.io) { + runCatchingExceptions { + service.recover(recoveryKey) + }.recoverCatching { + when (it) { + // We ignore import errors because the user will be notified about them via the "Key storage out of sync" detection. + is RustRecoveryException.Import -> Unit + else -> throw it.mapRecoveryException() + } + } + } + + override suspend fun deviceCurve25519(): String? { + return runCatchingExceptions { service.curve25519Key() }.getOrNull() + } + + override suspend fun deviceEd25519(): String? { + return runCatchingExceptions { service.ed25519Key() }.getOrNull() + } + + override suspend fun startIdentityReset(): Result { + return runCatchingExceptions { + service.resetIdentity() + }.flatMap { handle -> + RustIdentityResetHandleFactory.create(sessionId, handle) + } + } + + override suspend fun pinUserIdentity(userId: UserId): Result = runCatchingExceptions { + getUserIdentityInternal(userId).pin() + } + + override suspend fun withdrawVerification(userId: UserId): Result = runCatchingExceptions { + getUserIdentityInternal(userId).withdrawVerification() + } + + override suspend fun getUserIdentity(userId: UserId, fallbackToServer: Boolean): Result = runCatchingExceptions { + val identity = getUserIdentityInternal(userId, fallbackToServer) + val isVerified = identity.isVerified() + when { + identity.hasVerificationViolation() -> IdentityState.VerificationViolation + isVerified -> IdentityState.Verified + !isVerified -> IdentityState.Pinned + else -> null + } + } + + private suspend fun getUserIdentityInternal(userId: UserId, fallbackToServer: Boolean = true): UserIdentity { + return service.userIdentity( + userId = userId.value, + fallbackToServer = fallbackToServer, + ) ?: error("User identity not found") + } + + fun close() { + service.close() + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustIdentityResetHandle.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustIdentityResetHandle.kt new file mode 100644 index 0000000..4813ec1 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustIdentityResetHandle.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.encryption + +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.IdentityOidcResetHandle +import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetHandle +import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle +import org.matrix.rustcomponents.sdk.AuthData +import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails +import org.matrix.rustcomponents.sdk.CrossSigningResetAuthType + +object RustIdentityResetHandleFactory { + fun create( + userId: UserId, + identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle? + ): Result { + return runCatchingExceptions { + identityResetHandle?.let { + when (val authType = identityResetHandle.authType()) { + is CrossSigningResetAuthType.Oidc -> RustOidcIdentityResetHandle(identityResetHandle, authType.info.approvalUrl) + // User interactive authentication (user + password) + CrossSigningResetAuthType.Uiaa -> RustPasswordIdentityResetHandle(userId, identityResetHandle) + } + } + } + } +} + +class RustPasswordIdentityResetHandle( + private val userId: UserId, + private val identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle, +) : IdentityPasswordResetHandle { + override suspend fun resetPassword(password: String): Result { + return runCatchingExceptions { identityResetHandle.reset(AuthData.Password(AuthDataPasswordDetails(userId.value, password))) } + } + + override suspend fun cancel() { + identityResetHandle.cancelAndDestroy() + } +} + +class RustOidcIdentityResetHandle( + private val identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle, + override val url: String, +) : IdentityOidcResetHandle { + override suspend fun resetOidc(): Result { + return runCatchingExceptions { identityResetHandle.reset(null) } + } + + override suspend fun cancel() { + identityResetHandle.cancelAndDestroy() + } +} + +private suspend fun org.matrix.rustcomponents.sdk.IdentityResetHandle.cancelAndDestroy() { + cancel() + destroy() +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/SteadyStateExceptionMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/SteadyStateExceptionMapper.kt new file mode 100644 index 0000000..08b6b2a --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/SteadyStateExceptionMapper.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.encryption + +import io.element.android.libraries.matrix.api.encryption.SteadyStateException +import org.matrix.rustcomponents.sdk.SteadyStateException as RustSteadyStateException + +class SteadyStateExceptionMapper { + fun map(data: RustSteadyStateException): SteadyStateException { + return when (data) { + is RustSteadyStateException.BackupDisabled -> SteadyStateException.BackupDisabled( + message = data.message.orEmpty() + ) + is RustSteadyStateException.Connection -> SteadyStateException.Connection( + message = data.message.orEmpty() + ) + is RustSteadyStateException.Lagged -> SteadyStateException.Lagged( + message = data.message.orEmpty() + ) + } + } +} 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 0000000..668e55e --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ClientException.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 -> { + when (this) { + is RustClientException.Generic -> ClientException.Generic(msg, details) + is RustClientException.MatrixApi -> ClientException.MatrixApi( + kind = kind.map(), + code = code, + message = msg, + details = details, + ) + } + } + else -> ClientException.Other(message ?: "Unknown error") + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ErrorKind.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ErrorKind.kt new file mode 100644 index 0000000..cb82c92 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ErrorKind.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.exception +import io.element.android.libraries.matrix.api.exception.ErrorKind +import org.matrix.rustcomponents.sdk.ErrorKind as RustErrorKind + +fun RustErrorKind.map(): ErrorKind { + return when (this) { + RustErrorKind.BadAlias -> ErrorKind.BadAlias + RustErrorKind.BadJson -> ErrorKind.BadJson + RustErrorKind.BadState -> ErrorKind.BadState + is RustErrorKind.BadStatus -> ErrorKind.BadStatus(status?.toInt(), body) + RustErrorKind.CannotLeaveServerNoticeRoom -> ErrorKind.CannotLeaveServerNoticeRoom + RustErrorKind.CannotOverwriteMedia -> ErrorKind.CannotOverwriteMedia + RustErrorKind.CaptchaInvalid -> ErrorKind.CaptchaInvalid + RustErrorKind.CaptchaNeeded -> ErrorKind.CaptchaNeeded + RustErrorKind.ConnectionFailed -> ErrorKind.ConnectionFailed + RustErrorKind.ConnectionTimeout -> ErrorKind.ConnectionTimeout + is RustErrorKind.Custom -> ErrorKind.Custom(errcode) + RustErrorKind.DuplicateAnnotation -> ErrorKind.DuplicateAnnotation + RustErrorKind.Exclusive -> ErrorKind.Exclusive + RustErrorKind.Forbidden -> ErrorKind.Forbidden + RustErrorKind.GuestAccessForbidden -> ErrorKind.GuestAccessForbidden + is RustErrorKind.IncompatibleRoomVersion -> ErrorKind.IncompatibleRoomVersion(roomVersion) + RustErrorKind.InvalidParam -> ErrorKind.InvalidParam + RustErrorKind.InvalidRoomState -> ErrorKind.InvalidRoomState + RustErrorKind.InvalidUsername -> ErrorKind.InvalidUsername + is RustErrorKind.LimitExceeded -> ErrorKind.LimitExceeded(retryAfterMs?.toLong()) + RustErrorKind.MissingParam -> ErrorKind.MissingParam + RustErrorKind.MissingToken -> ErrorKind.MissingToken + RustErrorKind.NotFound -> ErrorKind.NotFound + RustErrorKind.NotJson -> ErrorKind.NotJson + RustErrorKind.NotYetUploaded -> ErrorKind.NotYetUploaded + is RustErrorKind.ResourceLimitExceeded -> ErrorKind.ResourceLimitExceeded(adminContact) + RustErrorKind.RoomInUse -> ErrorKind.RoomInUse + RustErrorKind.ServerNotTrusted -> ErrorKind.ServerNotTrusted + RustErrorKind.ThreepidAuthFailed -> ErrorKind.ThreepidAuthFailed + RustErrorKind.ThreepidDenied -> ErrorKind.ThreepidDenied + RustErrorKind.ThreepidInUse -> ErrorKind.ThreepidInUse + RustErrorKind.ThreepidMediumNotSupported -> ErrorKind.ThreepidMediumNotSupported + RustErrorKind.ThreepidNotFound -> ErrorKind.ThreepidNotFound + RustErrorKind.TooLarge -> ErrorKind.TooLarge + RustErrorKind.UnableToAuthorizeJoin -> ErrorKind.UnableToAuthorizeJoin + RustErrorKind.UnableToGrantJoin -> ErrorKind.UnableToGrantJoin + RustErrorKind.Unauthorized -> ErrorKind.Unauthorized + RustErrorKind.Unknown -> ErrorKind.Unknown + is RustErrorKind.UnknownToken -> ErrorKind.UnknownToken(softLogout) + RustErrorKind.Unrecognized -> ErrorKind.Unrecognized + RustErrorKind.UnsupportedRoomVersion -> ErrorKind.UnsupportedRoomVersion + RustErrorKind.UrlNotSet -> ErrorKind.UrlNotSet + RustErrorKind.UserDeactivated -> ErrorKind.UserDeactivated + RustErrorKind.UserInUse -> ErrorKind.UserInUse + RustErrorKind.UserLocked -> ErrorKind.UserLocked + RustErrorKind.UserSuspended -> ErrorKind.UserSuspended + RustErrorKind.WeakPassword -> ErrorKind.WeakPassword + is RustErrorKind.WrongRoomKeysVersion -> ErrorKind.WrongRoomKeysVersion(currentVersion) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/keys/DefaultPassphraseGenerator.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/keys/DefaultPassphraseGenerator.kt new file mode 100644 index 0000000..d13ff77 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/keys/DefaultPassphraseGenerator.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.keys + +import android.util.Base64 +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import java.security.SecureRandom + +private const val SECRET_SIZE = 256 + +@ContributesBinding(AppScope::class) +class DefaultPassphraseGenerator : PassphraseGenerator { + override fun generatePassphrase(): String? { + val key = ByteArray(size = SECRET_SIZE) + SecureRandom().nextBytes(key) + return Base64.encodeToString(key, Base64.NO_PADDING or Base64.NO_WRAP) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/keys/PassphraseGenerator.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/keys/PassphraseGenerator.kt new file mode 100644 index 0000000..e0f925f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/keys/PassphraseGenerator.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.keys + +interface PassphraseGenerator { + /** + * Generate a passphrase to encrypt the databases of a session. + * Return null to not encrypt the databases. + */ + fun generatePassphrase(): String? +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/IdentityState.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/IdentityState.kt new file mode 100644 index 0000000..5bb95fc --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/IdentityState.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.mapper + +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import uniffi.matrix_sdk_crypto.IdentityState as RustIdentityState + +fun RustIdentityState.map(): IdentityState = when (this) { + RustIdentityState.VERIFIED -> IdentityState.Verified + RustIdentityState.PINNED -> IdentityState.Pinned + RustIdentityState.PIN_VIOLATION -> IdentityState.PinViolation + RustIdentityState.VERIFICATION_VIOLATION -> IdentityState.VerificationViolation +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt new file mode 100644 index 0000000..3199ebf --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.mapper + +import io.element.android.libraries.matrix.api.auth.external.ExternalSession +import io.element.android.libraries.matrix.impl.paths.SessionPaths +import io.element.android.libraries.sessionstorage.api.LoginType +import io.element.android.libraries.sessionstorage.api.SessionData +import org.matrix.rustcomponents.sdk.Session +import java.util.Date + +internal fun Session.toSessionData( + isTokenValid: Boolean, + loginType: LoginType, + passphrase: String?, + sessionPaths: SessionPaths, + homeserverUrl: String? = null, +) = SessionData( + userId = userId, + deviceId = deviceId, + accessToken = accessToken, + refreshToken = refreshToken, + homeserverUrl = homeserverUrl ?: this.homeserverUrl, + oidcData = oidcData, + loginTimestamp = Date(), + isTokenValid = isTokenValid, + loginType = loginType, + passphrase = passphrase, + sessionPath = sessionPaths.fileDirectory.absolutePath, + cachePath = sessionPaths.cacheDirectory.absolutePath, + // Note: position and lastUsageIndex will be set by the SessionStore when adding the session + position = 0, + lastUsageIndex = 0, + userDisplayName = null, + userAvatarUrl = null, +) + +internal fun ExternalSession.toSessionData( + isTokenValid: Boolean, + loginType: LoginType, + passphrase: String?, + sessionPaths: SessionPaths, +) = SessionData( + userId = userId, + deviceId = deviceId, + accessToken = accessToken, + refreshToken = refreshToken, + homeserverUrl = homeserverUrl, + oidcData = null, + loginTimestamp = Date(), + isTokenValid = isTokenValid, + loginType = loginType, + passphrase = passphrase, + sessionPath = sessionPaths.fileDirectory.absolutePath, + cachePath = sessionPaths.cacheDirectory.absolutePath, + position = 0, + lastUsageIndex = 0, + userDisplayName = null, + userAvatarUrl = null, +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/UserProfileMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/UserProfileMapper.kt new file mode 100644 index 0000000..14c8135 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/UserProfileMapper.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.mapper + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import org.matrix.rustcomponents.sdk.UserProfile + +fun UserProfile.map() = MatrixUser( + userId = UserId(userId), + displayName = displayName, + avatarUrl = avatarUrl, +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioDetails.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioDetails.kt new file mode 100644 index 0000000..73eb408 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioDetails.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.matrix.api.media.AudioDetails +import kotlinx.collections.immutable.toImmutableList +import kotlin.time.toJavaDuration +import kotlin.time.toKotlinDuration +import org.matrix.rustcomponents.sdk.UnstableAudioDetailsContent as RustAudioDetails + +fun RustAudioDetails.map(): AudioDetails = AudioDetails( + duration = duration.toKotlinDuration(), + waveform = waveform.fromMSC3246range().toImmutableList(), +) + +fun AudioDetails.map(): RustAudioDetails = RustAudioDetails( + duration = duration.toJavaDuration(), + waveform = waveform.toMSC3246range() +) + +/** + * Resizes the given [0;1024] int list as per unstable MSC3246 spec + * to a [0;1] float list to be used for waveform rendering. + * + * https://github.com/matrix-org/matrix-spec-proposals/blob/travis/msc/audio-waveform/proposals/3246-audio-waveform.md + */ +internal fun List.fromMSC3246range(): List = map { it.toInt() / 1024f } + +/** + * Resizes the given [0;1] float list as per unstable MSC3246 spec + * to a [0;1024] int list to be used for waveform rendering. + * + * https://github.com/matrix-org/matrix-spec-proposals/blob/travis/msc/audio-waveform/proposals/3246-audio-waveform.md + */ +internal fun List.toMSC3246range(): List = map { (it * 1024).toInt().toUShort() } 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 0000000..9c20ad7 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/AudioInfo.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.matrix.api.media.AudioInfo +import kotlin.time.toJavaDuration +import kotlin.time.toKotlinDuration +import org.matrix.rustcomponents.sdk.AudioInfo as RustAudioInfo + +fun RustAudioInfo.map(): AudioInfo = AudioInfo( + duration = duration?.toKotlinDuration(), + size = size?.toLong(), + mimetype = mimetype +) + +fun AudioInfo.map(): RustAudioInfo = RustAudioInfo( + duration = duration?.toJavaDuration(), + 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 0000000..9059e79 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/FileInfo.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..83ab5eb --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ImageInfo.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.matrix.api.media.ImageInfo +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, + isAnimated = null +) 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 0000000..08bef24 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/MediaUploadHandlerImpl.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaUploadHandlerImpl.kt new file mode 100644 index 0000000..c3f5d97 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaUploadHandlerImpl.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.androidutils.file.safeDelete +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.media.MediaUploadHandler +import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle +import java.io.File + +class MediaUploadHandlerImpl( + private val filesToUpload: List, + private val sendAttachmentJoinHandle: SendAttachmentJoinHandle, +) : MediaUploadHandler { + override suspend fun await(): Result = + runCatchingExceptions { + sendAttachmentJoinHandle.join() + } + .also { cleanUpFiles() } + + override fun cancel() { + sendAttachmentJoinHandle.cancel() + cleanUpFiles() + } + + private fun cleanUpFiles() { + filesToUpload.forEach { file -> file.safeDelete() } + } +} 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 0000000..4ecb33d --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaFile.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 persist(path: String): Boolean { + return inner.persist(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 0000000..892c99f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.core.mimetype.MimeTypes +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.withContext +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.use +import java.io.File +import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource + +class RustMediaLoader( + private val baseCacheDirectory: File, + dispatchers: CoroutineDispatchers, + private val innerClient: Client, +) : MatrixMediaLoader { + private val mediaDispatcher = dispatchers.io.limitedParallelism(32) + private val cacheDirectory + get() = File(baseCacheDirectory, "temp/media").apply { + if (!exists()) mkdirs() // Must always ensure that this directory exists because "Clear cache" does not restart an app's process. + } + + override suspend fun loadMediaContent(source: MediaSource): Result = + withContext(mediaDispatcher) { + runCatchingExceptions { + source.toRustMediaSource().use { source -> + innerClient.getMediaContent(source) + } + } + } + + override suspend fun loadMediaThumbnail( + source: MediaSource, + width: Long, + height: Long + ): Result = + withContext(mediaDispatcher) { + runCatchingExceptions { + source.toRustMediaSource().use { mediaSource -> + innerClient.getMediaThumbnail( + mediaSource = mediaSource, + width = width.toULong(), + height = height.toULong() + ) + } + } + } + + override suspend fun downloadMediaFile( + source: MediaSource, + mimeType: String?, + filename: String?, + useCache: Boolean, + ): Result = + withContext(mediaDispatcher) { + runCatchingExceptions { + source.toRustMediaSource().use { mediaSource -> + val mediaFile = innerClient.getMediaFile( + mediaSource = mediaSource, + filename = filename, + mimeType = when { + mimeType == null -> MimeTypes.OctetStream + MimeTypes.hasSubtype(mimeType) -> mimeType + // Fallback to a default mime type based on the main type, so that the SDK can create a file with the correct extension. + mimeType == MimeTypes.Images -> MimeTypes.Jpeg + mimeType == MimeTypes.Videos -> MimeTypes.Mp4 + mimeType == MimeTypes.Audio -> MimeTypes.Mp3 + else -> MimeTypes.OctetStream + }, + useCache = useCache, + tempDir = cacheDirectory.path, + ) + RustMediaFile(mediaFile) + } + } + } + + private fun MediaSource.toRustMediaSource(): RustMediaSource { + val json = this.json + return if (json != null) { + RustMediaSource.fromJson(json) + } else { + RustMediaSource.fromUrl(url) + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaPreviewService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaPreviewService.kt new file mode 100644 index 0000000..454c600 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaPreviewService.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.media.MediaPreviewConfig +import io.element.android.libraries.matrix.api.media.MediaPreviewService +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.matrix.impl.util.mxCallbackFlow +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.InviteAvatars +import org.matrix.rustcomponents.sdk.MediaPreviewConfigListener +import org.matrix.rustcomponents.sdk.MediaPreviews +import org.matrix.rustcomponents.sdk.MediaPreviewConfig as RustMediaPreviewConfig + +class RustMediaPreviewService( + sessionCoroutineScope: CoroutineScope, + private val sessionDispatcher: CoroutineDispatcher, + private val innerClient: Client, +) : MediaPreviewService { + override val mediaPreviewConfigFlow: StateFlow = + innerClient + .getMediaPreviewConfigFlow() + .stateIn(sessionCoroutineScope, started = SharingStarted.Lazily, initialValue = MediaPreviewConfig.DEFAULT) + + override suspend fun fetchMediaPreviewConfig(): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.fetchMediaPreviewConfig()?.into() + } + } + + override suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.setMediaPreviewDisplayPolicy(mediaPreviewValue.into()) + } + } + + override suspend fun setHideInviteAvatars(hide: Boolean): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + val inviteAvatars = if (hide) InviteAvatars.OFF else InviteAvatars.ON + innerClient.setInviteAvatarsDisplayPolicy(inviteAvatars) + } + } +} + +private fun RustMediaPreviewConfig.into(): MediaPreviewConfig { + return MediaPreviewConfig( + mediaPreviewValue = mediaPreviews.into(), + hideInviteAvatar = inviteAvatars == InviteAvatars.OFF + ) +} + +private fun Client.getMediaPreviewConfigFlow() = mxCallbackFlow { + subscribeToMediaPreviewConfig(object : MediaPreviewConfigListener { + override fun onChange(mediaPreviewConfig: RustMediaPreviewConfig?) { + if (mediaPreviewConfig != null) { + trySend(mediaPreviewConfig.into()) + } + } + }) +} + +private fun MediaPreviewValue.into(): MediaPreviews { + return when (this) { + MediaPreviewValue.On -> MediaPreviews.ON + MediaPreviewValue.Off -> MediaPreviews.OFF + MediaPreviewValue.Private -> MediaPreviews.PRIVATE + } +} + +private fun MediaPreviews?.into(): MediaPreviewValue { + return when (this) { + null -> MediaPreviewValue.DEFAULT + MediaPreviews.ON -> MediaPreviewValue.On + MediaPreviews.OFF -> MediaPreviewValue.Off + MediaPreviews.PRIVATE -> MediaPreviewValue.Private + } +} 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 0000000..0303f87 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/ThumbnailInfo.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..33f622f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/VideoInfo.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.media + +import io.element.android.libraries.matrix.api.media.VideoInfo +import kotlin.time.toJavaDuration +import kotlin.time.toKotlinDuration +import org.matrix.rustcomponents.sdk.VideoInfo as RustVideoInfo + +fun RustVideoInfo.map(): VideoInfo = VideoInfo( + duration = duration?.toKotlinDuration(), + 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?.toJavaDuration(), + 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/mxc/DefaultMxcTools.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mxc/DefaultMxcTools.kt new file mode 100644 index 0000000..b9ff2f5 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mxc/DefaultMxcTools.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.mxc + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.matrix.api.mxc.MxcTools + +@ContributesBinding(AppScope::class) +class DefaultMxcTools : MxcTools { + /** + * Regex to match a Matrix Content (mxc://) URI. + * + * See: https://spec.matrix.org/v1.8/client-server-api/#matrix-content-mxc-uris + */ + private val mxcRegex = Regex("""^mxc://([^/]+)/([^/]+)$""") + + /** + * Sanitizes an mxcUri to be used as a relative file path. + * + * @param mxcUri the Matrix Content (mxc://) URI of the file. + * @return the relative file path as "/" or null if the mxcUri is invalid. + */ + override fun mxcUri2FilePath(mxcUri: String): String? = mxcRegex.matchEntire(mxcUri)?.let { match -> + buildString { + append(match.groupValues[1]) + append("/") + append(match.groupValues[2]) + } + } +} 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 0000000..8e33117 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.notification + +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.extensions.runCatchingExceptions +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.core.UserId +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.isDm +import io.element.android.services.toolbox.api.systemclock.SystemClock +import org.matrix.rustcomponents.sdk.NotificationEvent +import org.matrix.rustcomponents.sdk.NotificationItem +import org.matrix.rustcomponents.sdk.use + +class NotificationMapper( + private val clock: SystemClock, +) { + private val notificationContentMapper = NotificationContentMapper() + + fun map( + sessionId: SessionId, + eventId: EventId, + roomId: RoomId, + notificationItem: NotificationItem + ): Result { + return runCatchingExceptions { + notificationItem.use { item -> + val isDm = isDm( + isDirect = item.roomInfo.isDirect, + activeMembersCount = item.roomInfo.joinedMembersCount.toInt(), + ) + val timestamp = item.timestamp() ?: clock.epochMillis() + NotificationData( + sessionId = sessionId, + eventId = eventId, + threadId = item.threadId?.let(::ThreadId), + roomId = roomId, + senderAvatarUrl = item.senderInfo.avatarUrl, + senderDisplayName = item.senderInfo.displayName, + senderIsNameAmbiguous = item.senderInfo.isNameAmbiguous, + roomAvatarUrl = item.roomInfo.avatarUrl ?: item.senderInfo.avatarUrl.takeIf { isDm }, + roomDisplayName = item.roomInfo.displayName, + isDirect = item.roomInfo.isDirect, + isDm = isDm, + isEncrypted = item.roomInfo.isEncrypted.orFalse(), + isNoisy = item.isNoisy.orFalse(), + timestamp = timestamp, + content = notificationContentMapper.map(item.event).getOrThrow(), + hasMention = item.hasMention.orFalse(), + ) + } + } + } +} + +class NotificationContentMapper { + private val timelineEventToNotificationContentMapper = TimelineEventToNotificationContentMapper() + + fun map(notificationEvent: NotificationEvent): Result = + when (notificationEvent) { + is NotificationEvent.Timeline -> timelineEventToNotificationContentMapper.map(notificationEvent.event) + is NotificationEvent.Invite -> Result.success( + NotificationContent.Invite( + senderId = UserId(notificationEvent.sender), + ) + ) + } +} + +private fun NotificationItem.timestamp(): Long? { + return (this.event as? NotificationEvent.Timeline)?.event?.timestamp()?.toLong() +} 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 0000000..59d95af --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.notification + +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.runCatchingExceptions +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.exception.NotificationResolverException +import io.element.android.libraries.matrix.api.notification.GetNotificationDataResult +import io.element.android.libraries.matrix.api.notification.NotificationService +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.BatchNotificationResult +import org.matrix.rustcomponents.sdk.NotificationClient +import org.matrix.rustcomponents.sdk.NotificationItemsRequest +import org.matrix.rustcomponents.sdk.NotificationStatus +import org.matrix.rustcomponents.sdk.use +import timber.log.Timber + +class RustNotificationService( + private val sessionId: SessionId, + private val notificationClient: NotificationClient, + private val dispatchers: CoroutineDispatchers, + clock: SystemClock, +) : NotificationService { + private val notificationMapper: NotificationMapper = NotificationMapper(clock) + + override suspend fun getNotifications( + ids: Map> + ): GetNotificationDataResult = withContext(dispatchers.io) { + runCatchingExceptions { + val requests = ids.map { (roomId, eventIds) -> + NotificationItemsRequest( + roomId = roomId.value, + eventIds = eventIds.map { it.value } + ) + } + val items = notificationClient.getNotifications(requests) + buildMap { + val eventIds = requests.flatMap { it.eventIds }.distinct() + for (rawEventId in eventIds) { + val roomId = RoomId(requests.find { it.eventIds.contains(rawEventId) }?.roomId!!) + val eventId = EventId(rawEventId) + items[rawEventId].use { result -> + when (result) { + is BatchNotificationResult.Ok -> { + when (val status = result.status) { + is NotificationStatus.Event -> { + val result = notificationMapper.map(sessionId, eventId, roomId, status.item) + result.onFailure { Timber.e(it, "Could not map notification event $eventId") } + put(eventId, result) + } + is NotificationStatus.EventNotFound -> { + Timber.e("Could not retrieve event for notification with $eventId - event not found") + put(eventId, Result.failure(NotificationResolverException.EventNotFound)) + } + is NotificationStatus.EventFilteredOut -> { + Timber.d("Could not retrieve event for notification with $eventId - event filtered out") + put(eventId, Result.failure(NotificationResolverException.EventFilteredOut)) + } + } + } + is BatchNotificationResult.Error -> { + Timber.e("Error while retrieving notification with $rawEventId - ${result.message}") + put( + eventId, + Result.failure(NotificationResolverException.UnknownError(result.message)) + ) + } + null -> { + Timber.e("The notification data for $rawEventId was not in the retrieved results. This is unexpected.") + put( + eventId, + Result.failure(NotificationResolverException.UnknownError("Notification data not found")) + ) + } + } + } + } + } + } + } + + fun close() { + notificationClient.close() + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt new file mode 100644 index 0000000..b38ad71 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.notification + +import io.element.android.libraries.core.extensions.runCatchingExceptions +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.notification.NotificationContent +import io.element.android.libraries.matrix.api.notification.RtcNotificationType +import io.element.android.libraries.matrix.impl.room.member.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 org.matrix.rustcomponents.sdk.RtcNotificationType as SdkRtcNotificationType + +class TimelineEventToNotificationContentMapper { + fun map(timelineEvent: TimelineEvent): Result { + return runCatchingExceptions { + timelineEvent.use { + val senderId = UserId(timelineEvent.senderId()) + timelineEvent.eventType().use { eventType -> + eventType.toContent(senderId = senderId) + } + } + } + } +} + +private fun TimelineEventType.toContent(senderId: UserId): NotificationContent { + return when (this) { + is TimelineEventType.MessageLike -> content.toContent(senderId) + 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 = UserId(userId), + membershipState = 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 + is StateEventContent.RoomTopic -> NotificationContent.StateEvent.RoomTopic(topic) + StateEventContent.SpaceChild -> NotificationContent.StateEvent.SpaceChild + StateEventContent.SpaceParent -> NotificationContent.StateEvent.SpaceParent + } +} + +private fun MessageLikeEventContent.toContent(senderId: UserId): NotificationContent.MessageLike { + return use { + when (this) { + MessageLikeEventContent.CallAnswer -> NotificationContent.MessageLike.CallAnswer + MessageLikeEventContent.CallCandidates -> NotificationContent.MessageLike.CallCandidates + MessageLikeEventContent.CallHangup -> NotificationContent.MessageLike.CallHangup + MessageLikeEventContent.CallInvite -> NotificationContent.MessageLike.CallInvite(senderId) + is MessageLikeEventContent.RtcNotification -> NotificationContent.MessageLike.RtcNotification( + senderId = senderId, + type = notificationType.map(), + expirationTimestampMillis = expirationTs.toLong() + ) + 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(relatedEventId) + MessageLikeEventContent.RoomEncrypted -> NotificationContent.MessageLike.RoomEncrypted + is MessageLikeEventContent.RoomMessage -> { + NotificationContent.MessageLike.RoomMessage(senderId, EventMessageMapper().mapMessageType(messageType)) + } + is MessageLikeEventContent.RoomRedaction -> NotificationContent.MessageLike.RoomRedaction( + redactedEventId = redactedEventId?.let(::EventId), + reason = reason, + ) + MessageLikeEventContent.Sticker -> NotificationContent.MessageLike.Sticker + is MessageLikeEventContent.Poll -> NotificationContent.MessageLike.Poll(senderId, question) + } + } +} + +private fun SdkRtcNotificationType.map(): RtcNotificationType = when (this) { + SdkRtcNotificationType.NOTIFICATION -> RtcNotificationType.NOTIFY + SdkRtcNotificationType.RING -> RtcNotificationType.RING +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RoomNotificationSettingsMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RoomNotificationSettingsMapper.kt new file mode 100644 index 0000000..f453c93 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RoomNotificationSettingsMapper.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.notificationsettings + +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.RoomNotificationSettings +import org.matrix.rustcomponents.sdk.RoomNotificationMode as RustRoomNotificationMode +import org.matrix.rustcomponents.sdk.RoomNotificationSettings as RustRoomNotificationSettings + +object RoomNotificationSettingsMapper { + fun map(roomNotificationSettings: RustRoomNotificationSettings): RoomNotificationSettings = + RoomNotificationSettings( + mode = mapMode(roomNotificationSettings.mode), + isDefault = roomNotificationSettings.isDefault + ) + + fun mapMode(mode: RustRoomNotificationMode): RoomNotificationMode = + when (mode) { + RustRoomNotificationMode.ALL_MESSAGES -> RoomNotificationMode.ALL_MESSAGES + RustRoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + RustRoomNotificationMode.MUTE -> RoomNotificationMode.MUTE + } + + fun mapMode(mode: RoomNotificationMode): RustRoomNotificationMode = + when (mode) { + RoomNotificationMode.ALL_MESSAGES -> RustRoomNotificationMode.ALL_MESSAGES + RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> RustRoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + RoomNotificationMode.MUTE -> RustRoomNotificationMode.MUTE + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt new file mode 100644 index 0000000..7da0f14 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsService.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.notificationsettings + +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.coroutine.suspendLazy +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.RoomNotificationSettings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.NotificationSettingsDelegate +import org.matrix.rustcomponents.sdk.NotificationSettingsException +import timber.log.Timber + +class RustNotificationSettingsService( + client: Client, + sessionCoroutineScope: CoroutineScope, + private val dispatchers: CoroutineDispatchers, +) : NotificationSettingsService { + private val notificationSettings by suspendLazy(sessionCoroutineScope.coroutineContext + dispatchers.io) { client.getNotificationSettings() } + private val _notificationSettingsChangeFlow = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + override val notificationSettingsChangeFlow: SharedFlow = _notificationSettingsChangeFlow.asSharedFlow() + + private var notificationSettingsDelegate = object : NotificationSettingsDelegate { + override fun settingsDidChange() { + _notificationSettingsChangeFlow.tryEmit(Unit) + } + } + + suspend fun start() { + notificationSettings.await().setDelegate(notificationSettingsDelegate) + } + + suspend fun destroy() { + notificationSettings.await().setDelegate(null) + } + + override suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result = + runCatchingExceptions { + notificationSettings.await().getRoomNotificationSettings(roomId.value, isEncrypted, isOneToOne).let(RoomNotificationSettingsMapper::map) + } + + override suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, isOneToOne: Boolean): Result = + runCatchingExceptions { + notificationSettings.await().getDefaultRoomNotificationMode(isEncrypted, isOneToOne).let(RoomNotificationSettingsMapper::mapMode) + } + + override suspend fun setDefaultRoomNotificationMode( + isEncrypted: Boolean, + mode: RoomNotificationMode, + isOneToOne: Boolean + ): Result = withContext(dispatchers.io) { + runCatchingExceptions { + try { + notificationSettings.await().setDefaultRoomNotificationMode(isEncrypted, isOneToOne, mode.let(RoomNotificationSettingsMapper::mapMode)) + } catch (exception: NotificationSettingsException.RuleNotFound) { + // `setDefaultRoomNotificationMode` updates multiple rules including unstable rules (e.g. the polls push rules defined in the MSC3930) + // since production home servers may not have these rules yet, we drop the RuleNotFound error + Timber.w("Unable to find the rule: ${exception.ruleId}") + } + } + } + + override suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result = withContext(dispatchers.io) { + runCatchingExceptions { + notificationSettings.await().setRoomNotificationMode(roomId.value, mode.let(RoomNotificationSettingsMapper::mapMode)) + } + } + + override suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result = withContext(dispatchers.io) { + runCatchingExceptions { + notificationSettings.await().restoreDefaultRoomNotificationMode(roomId.value) + } + } + + override suspend fun muteRoom(roomId: RoomId): Result = setRoomNotificationMode(roomId, RoomNotificationMode.MUTE) + + override suspend fun unmuteRoom(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean) = withContext(dispatchers.io) { + runCatchingExceptions { + notificationSettings.await().unmuteRoom(roomId.value, isEncrypted, isOneToOne) + } + } + + override suspend fun isRoomMentionEnabled(): Result = withContext(dispatchers.io) { + runCatchingExceptions { + notificationSettings.await().isRoomMentionEnabled() + } + } + + override suspend fun setRoomMentionEnabled(enabled: Boolean): Result = withContext(dispatchers.io) { + runCatchingExceptions { + notificationSettings.await().setRoomMentionEnabled(enabled) + } + } + + override suspend fun isCallEnabled(): Result = withContext(dispatchers.io) { + runCatchingExceptions { + notificationSettings.await().isCallEnabled() + } + } + + override suspend fun setCallEnabled(enabled: Boolean): Result = withContext(dispatchers.io) { + runCatchingExceptions { + notificationSettings.await().setCallEnabled(enabled) + } + } + + override suspend fun isInviteForMeEnabled(): Result = withContext(dispatchers.io) { + runCatchingExceptions { + notificationSettings.await().isInviteForMeEnabled() + } + } + + override suspend fun setInviteForMeEnabled(enabled: Boolean): Result = withContext(dispatchers.io) { + runCatchingExceptions { + notificationSettings.await().setInviteForMeEnabled(enabled) + } + } + + override suspend fun getRoomsWithUserDefinedRules(): Result> = + runCatchingExceptions { + notificationSettings.await().getRoomsWithUserDefinedRules(enabled = true).map(::RoomId) + } + + override suspend fun canHomeServerPushEncryptedEventsToDevice(): Result = + runCatchingExceptions { + notificationSettings.await().canPushEncryptedEventToDevice() + } + + override suspend fun getRawPushRules(): Result = runCatchingExceptions { + notificationSettings.await().getRawPushRules() + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/oidc/AccountManagementAction.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/oidc/AccountManagementAction.kt new file mode 100644 index 0000000..f998126 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/oidc/AccountManagementAction.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.oidc + +import io.element.android.libraries.matrix.api.oidc.AccountManagementAction +import org.matrix.rustcomponents.sdk.AccountManagementAction as RustAccountManagementAction + +fun AccountManagementAction.toRustAction(): RustAccountManagementAction { + return when (this) { + AccountManagementAction.Profile -> RustAccountManagementAction.Profile + is AccountManagementAction.SessionEnd -> RustAccountManagementAction.SessionEnd(deviceId.value) + is AccountManagementAction.SessionView -> RustAccountManagementAction.SessionView(deviceId.value) + AccountManagementAction.SessionsList -> RustAccountManagementAction.SessionsList + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/paths/SessionPaths.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/paths/SessionPaths.kt new file mode 100644 index 0000000..1888cd0 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/paths/SessionPaths.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.paths + +import io.element.android.libraries.sessionstorage.api.SessionData +import java.io.File + +data class SessionPaths( + val fileDirectory: File, + val cacheDirectory: File, +) { + fun deleteRecursively() { + fileDirectory.deleteRecursively() + cacheDirectory.deleteRecursively() + } +} + +internal fun SessionData.getSessionPaths(): SessionPaths { + return SessionPaths( + fileDirectory = File(sessionPath), + cacheDirectory = File(cachePath), + ) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/paths/SessionPathsFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/paths/SessionPathsFactory.kt new file mode 100644 index 0000000..d2e519f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/paths/SessionPathsFactory.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.paths + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.di.BaseDirectory +import io.element.android.libraries.di.CacheDirectory +import java.io.File +import java.util.UUID + +@Inject +class SessionPathsFactory( + @BaseDirectory private val baseDirectory: File, + @CacheDirectory private val cacheDirectory: File, +) { + fun create(): SessionPaths { + val subPath = UUID.randomUUID().toString() + return SessionPaths( + fileDirectory = File(baseDirectory, subPath), + cacheDirectory = File(cacheDirectory, subPath), + ) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverter.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverter.kt new file mode 100644 index 0000000..efb106a --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverter.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.permalink + +import android.net.Uri +import androidx.core.net.toUri +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.appconfig.MatrixConfiguration +import io.element.android.libraries.core.extensions.replacePrefix +import io.element.android.libraries.matrix.api.permalink.MatrixToConverter + +/** + * Mapping of an input URI to a matrix.to compliant URI. + */ +@ContributesBinding(AppScope::class) +class DefaultMatrixToConverter : 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 + * Also convert links coming from the matrix.to website: + * - element://room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org + * - element://user/@alice:matrix.org -> https://matrix.to/#/@alice:matrix.org + */ + override fun convert(uri: Uri): Uri? { + val uriString = uri.toString() + // Handle links coming from the matrix.to website. + .replacePrefix(MATRIX_TO_CUSTOM_SCHEME_BASE_URL, "https://app.element.io/#/") + val baseUrl = MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL + + 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 } + (baseUrl + uriString.substringAfter(path)).toUri() + } + // URL is not supported + else -> null + } + } + + companion object { + private const val MATRIX_TO_CUSTOM_SCHEME_BASE_URL = "element://" + private val SUPPORTED_PATHS = listOf( + "/#/room/", + "/#/user/", + "/#/group/" + ) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt new file mode 100644 index 0000000..2b34138 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkBuilder.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.permalink + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.core.MatrixPatterns +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilderError +import org.matrix.rustcomponents.sdk.matrixToRoomAliasPermalink +import org.matrix.rustcomponents.sdk.matrixToUserPermalink + +@ContributesBinding(AppScope::class) +class DefaultPermalinkBuilder : PermalinkBuilder { + override fun permalinkForUser(userId: UserId): Result { + if (!MatrixPatterns.isUserId(userId.value)) { + return Result.failure(PermalinkBuilderError.InvalidData) + } + return runCatchingExceptions { + matrixToUserPermalink(userId.value) + } + } + + override fun permalinkForRoomAlias(roomAlias: RoomAlias): Result { + if (!MatrixPatterns.isRoomAlias(roomAlias.value)) { + return Result.failure(PermalinkBuilderError.InvalidData) + } + return runCatchingExceptions { + matrixToRoomAliasPermalink(roomAlias.value) + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParser.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParser.kt new file mode 100644 index 0000000..e59530e --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultPermalinkParser.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.permalink + +import androidx.core.net.toUri +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomAlias +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.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.permalink.MatrixToConverter +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import kotlinx.collections.immutable.toImmutableList +import org.matrix.rustcomponents.sdk.MatrixId +import org.matrix.rustcomponents.sdk.parseMatrixEntityFrom + +/** + * 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. user/@chagai95:matrix.org) + * or matrix: permalinks (e.g. matrix:u/chagai95:matrix.org) + */ +@ContributesBinding(AppScope::class) +class DefaultPermalinkParser( + private val matrixToConverter: MatrixToConverter +) : PermalinkParser { + /** + * Turns a uri string to a [PermalinkData]. + * https://github.com/matrix-org/matrix-doc/blob/master/proposals/1704-matrix.to-permalinks.md + */ + override fun parse(uriString: String): PermalinkData { + val uri = uriString.toUri() + val matrixToUri = if (uri.scheme == "matrix") { + // take matrix: URI as is to [parseMatrixEntityFrom] + uri + } else { + // 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 + matrixToConverter.convert(uri) ?: return PermalinkData.FallbackLink(uri) + } + + val result = runCatchingExceptions { + parseMatrixEntityFrom(matrixToUri.toString()) + }.getOrNull() + return if (result == null) { + PermalinkData.FallbackLink(uri) + } else { + val viaParameters = result.via.toImmutableList() + when (val id = result.id) { + is MatrixId.User -> PermalinkData.UserLink( + userId = UserId(id.id), + ) + is MatrixId.Room -> PermalinkData.RoomLink( + roomIdOrAlias = RoomId(id.id).toRoomIdOrAlias(), + viaParameters = viaParameters, + ) + is MatrixId.RoomAlias -> PermalinkData.RoomLink( + roomIdOrAlias = RoomAlias(id.alias).toRoomIdOrAlias(), + viaParameters = viaParameters, + ) + is MatrixId.EventOnRoomId -> PermalinkData.RoomLink( + roomIdOrAlias = RoomId(id.roomId).toRoomIdOrAlias(), + eventId = EventId(id.eventId), + viaParameters = viaParameters, + ) + is MatrixId.EventOnRoomAlias -> PermalinkData.RoomLink( + roomIdOrAlias = RoomAlias(id.alias).toRoomIdOrAlias(), + eventId = EventId(id.eventId), + viaParameters = viaParameters, + ) + } + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/platform/RustInitPlatformService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/platform/RustInitPlatformService.kt new file mode 100644 index 0000000..cb1fdc9 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/platform/RustInitPlatformService.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.platform + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.matrix.api.platform.InitPlatformService +import io.element.android.libraries.matrix.api.tracing.TracingConfiguration +import io.element.android.libraries.matrix.impl.tracing.map +import org.matrix.rustcomponents.sdk.initPlatform + +@ContributesBinding(AppScope::class) +class RustInitPlatformService : InitPlatformService { + override fun init(tracingConfiguration: TracingConfiguration) { + initPlatform( + config = tracingConfiguration.map(), + useLightweightTokioRuntime = false + ) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/poll/PollAnswer.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/poll/PollAnswer.kt new file mode 100644 index 0000000..df9eb14 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/poll/PollAnswer.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.poll + +import io.element.android.libraries.matrix.api.poll.PollAnswer +import org.matrix.rustcomponents.sdk.PollAnswer as RustPollAnswer + +fun RustPollAnswer.map(): PollAnswer = PollAnswer( + id = id, + text = text, +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/poll/PollKind.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/poll/PollKind.kt new file mode 100644 index 0000000..d1d7bdc --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/poll/PollKind.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.poll + +import io.element.android.libraries.matrix.api.poll.PollKind +import org.matrix.rustcomponents.sdk.PollKind as RustPollKind + +fun RustPollKind.map(): PollKind = when (this) { + RustPollKind.DISCLOSED -> PollKind.Disclosed + RustPollKind.UNDISCLOSED -> PollKind.Undisclosed +} + +fun PollKind.toInner(): RustPollKind = when (this) { + PollKind.Disclosed -> RustPollKind.DISCLOSED + PollKind.Undisclosed -> RustPollKind.UNDISCLOSED +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/proxy/DefaultProxyProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/proxy/DefaultProxyProvider.kt new file mode 100644 index 0000000..79dc7e6 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/proxy/DefaultProxyProvider.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.proxy + +import android.content.Context +import android.net.ConnectivityManager +import android.provider.Settings +import androidx.core.content.getSystemService +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.annotations.ApplicationContext +import timber.log.Timber + +/** + * Provides the proxy settings from the system. + * Note that you can configure the global proxy using adb like this: + * ``` + * adb shell settings put global http_proxy https://proxy.example.com:8080 + * ``` + * and to remove it: + * ``` + * adb shell settings delete global http_proxy + * ``` + */ +@ContributesBinding(AppScope::class) +class DefaultProxyProvider( + @ApplicationContext + private val context: Context +) : ProxyProvider { + override fun provides(): String? { + val defaultProxy = context.getSystemService()?.defaultProxy + if (defaultProxy == null) { + // Note: can be tested by running: + // adb shell settings put global http_proxy :0 + Timber.d("No default proxy") + return null + } + return Settings.Global.getString(context.contentResolver, Settings.Global.HTTP_PROXY) + ?.also { + Timber.d("Using global proxy") + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/proxy/ProxyProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/proxy/ProxyProvider.kt new file mode 100644 index 0000000..1428183 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/proxy/ProxyProvider.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.proxy + +interface ProxyProvider { + fun provides(): String? +} 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 0000000..860993d --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.pushers + +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.mapFailure +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.pusher.PushersService +import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData +import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData +import io.element.android.libraries.matrix.impl.exception.mapClientException +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 { + return withContext(dispatchers.io) { + runCatchingExceptions { + 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 + ) + } + .mapFailure { it.mapClientException() } + } + } + + override suspend fun unsetHttpPusher(unsetHttpPusherData: UnsetHttpPusherData): Result { + return withContext(dispatchers.io) { + runCatchingExceptions { + client.deletePusher( + identifiers = PusherIdentifiers( + pushkey = unsetHttpPusherData.pushKey, + appId = unsetHttpPusherData.appId + ), + ) + } + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/FocusEventException.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/FocusEventException.kt new file mode 100644 index 0000000..4199b28 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/FocusEventException.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.errors.FocusEventException +import org.matrix.rustcomponents.sdk.FocusEventException as RustFocusEventException + +fun Throwable.toFocusEventException(): Throwable { + return when (this) { + is RustFocusEventException -> { + when (this) { + is RustFocusEventException.InvalidEventId -> { + FocusEventException.InvalidEventId(eventId, err) + } + is RustFocusEventException.EventNotFound -> { + FocusEventException.EventNotFound(EventId(eventId)) + } + is RustFocusEventException.Other -> { + FocusEventException.Other(msg) + } + } + } + else -> { + this + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt new file mode 100644 index 0000000..943b5ee --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -0,0 +1,509 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.core.extensions.mapFailure +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.SendHandle +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange +import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.CreateTimelineParams +import io.element.android.libraries.matrix.api.room.IntentionalMention +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomNotificationSettingsState +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.knock.KnockRequest +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues +import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange +import io.element.android.libraries.matrix.api.room.roomNotificationSettings +import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver +import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings +import io.element.android.libraries.matrix.impl.core.RustSendHandle +import io.element.android.libraries.matrix.impl.mapper.map +import io.element.android.libraries.matrix.impl.room.history.map +import io.element.android.libraries.matrix.impl.room.join.map +import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest +import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher +import io.element.android.libraries.matrix.impl.roomdirectory.map +import io.element.android.libraries.matrix.impl.timeline.RustTimeline +import io.element.android.libraries.matrix.impl.util.MessageEventContent +import io.element.android.libraries.matrix.impl.util.mxCallbackFlow +import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver +import io.element.android.libraries.matrix.impl.widget.generateWidgetWebViewUrl +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.DateDividerMode +import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener +import org.matrix.rustcomponents.sdk.KnockRequestsListener +import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType +import org.matrix.rustcomponents.sdk.TimelineConfiguration +import org.matrix.rustcomponents.sdk.TimelineFilter +import org.matrix.rustcomponents.sdk.TimelineFocus +import org.matrix.rustcomponents.sdk.TypingNotificationsListener +import org.matrix.rustcomponents.sdk.UserPowerLevelUpdate +import org.matrix.rustcomponents.sdk.WidgetCapabilities +import org.matrix.rustcomponents.sdk.WidgetCapabilitiesProvider +import org.matrix.rustcomponents.sdk.getElementCallRequiredPermissions +import org.matrix.rustcomponents.sdk.use +import timber.log.Timber +import uniffi.matrix_sdk.RoomPowerLevelChanges +import uniffi.matrix_sdk_ui.TimelineReadReceiptTracking +import kotlin.coroutines.cancellation.CancellationException +import org.matrix.rustcomponents.sdk.IdentityStatusChange as RustIdentityStateChange +import org.matrix.rustcomponents.sdk.KnockRequest as InnerKnockRequest +import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline + +class JoinedRustRoom( + private val baseRoom: RustBaseRoom, + private val liveInnerTimeline: InnerTimeline, + private val notificationSettingsService: NotificationSettingsService, + private val coroutineDispatchers: CoroutineDispatchers, + private val systemClock: SystemClock, + private val roomContentForwarder: RoomContentForwarder, + private val featureFlagService: FeatureFlagService, +) : JoinedRoom, BaseRoom by baseRoom { + // Create a dispatcher for all room methods... + private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32) + private val innerRoom = baseRoom.innerRoom + + override val roomTypingMembersFlow: Flow> = mxCallbackFlow { + val initial = emptyList() + channel.trySend(initial) + innerRoom.subscribeToTypingNotifications(object : TypingNotificationsListener { + override fun call(typingUserIds: List) { + channel.trySend( + typingUserIds + .filter { it != sessionId.value } + .map(::UserId) + ) + } + }) + } + + override val identityStateChangesFlow: Flow> = mxCallbackFlow { + val initial = emptyList() + channel.trySend(initial) + innerRoom.subscribeToIdentityStatusChanges(object : IdentityStatusChangeListener { + override fun call(identityStatusChange: List) { + channel.trySend( + identityStatusChange.map { + IdentityStateChange( + userId = UserId(it.userId), + identityState = it.changedTo.map(), + ) + } + ) + } + }) + } + + override val knockRequestsFlow: Flow> = mxCallbackFlow { + innerRoom.subscribeToKnockRequests(object : KnockRequestsListener { + override fun call(joinRequests: List) { + val knockRequests = joinRequests.map { RustKnockRequest(it) } + channel.trySend(knockRequests) + } + }) + } + + override val roomNotificationSettingsStateFlow = MutableStateFlow(RoomNotificationSettingsState.Unknown) + + override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live) + + override val syncUpdateFlow = flow { + var counter = 0L + liveTimeline.onSyncedEventReceived.collect { + emit(++counter) + } + }.stateIn( + scope = roomCoroutineScope, + started = WhileSubscribed(), + initialValue = 0L, + ) + + init { + subscribeToRoomMembersChange() + } + + private fun subscribeToRoomMembersChange() { + val powerLevelChanges = roomInfoFlow.map { it.roomPowerLevels }.distinctUntilChanged() + val membershipChanges = liveTimeline.membershipChangeEventReceived.onStart { emit(Unit) } + combine(membershipChanges, powerLevelChanges) { _, _ -> } + // Skip initial one + .drop(1) + // The new events should already be in the SDK cache, no need to fetch them from the server + .onEach { baseRoom.roomMemberListFetcher.fetchRoomMembers(source = RoomMemberListFetcher.Source.CACHE) } + .launchIn(roomCoroutineScope) + .invokeOnCompletion { + Timber.d("Observing membership changes for room $roomId stopped, reason: $it") + } + } + + override suspend fun createTimeline( + createTimelineParams: CreateTimelineParams, + ): Result = withContext(roomDispatcher) { + val hideThreadedEvents = featureFlagService.isFeatureEnabled(FeatureFlags.Threads) + val focus = when (createTimelineParams) { + is CreateTimelineParams.PinnedOnly -> TimelineFocus.PinnedEvents( + maxEventsToLoad = 100u, + maxConcurrentRequests = 10u, + ) + is CreateTimelineParams.MediaOnly -> TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents) + is CreateTimelineParams.Focused -> TimelineFocus.Event( + eventId = createTimelineParams.focusedEventId.value, + numContextEvents = 50u, + hideThreadedEvents = hideThreadedEvents, + ) + is CreateTimelineParams.MediaOnlyFocused -> TimelineFocus.Event( + eventId = createTimelineParams.focusedEventId.value, + numContextEvents = 50u, + // Never hide threaded events in media focused timeline + hideThreadedEvents = false, + ) + is CreateTimelineParams.Threaded -> TimelineFocus.Thread( + rootEventId = createTimelineParams.threadRootEventId.value, + ) + } + + val filter = when (createTimelineParams) { + is CreateTimelineParams.MediaOnly, + is CreateTimelineParams.MediaOnlyFocused -> TimelineFilter.OnlyMessage( + types = listOf( + RoomMessageEventMessageType.FILE, + RoomMessageEventMessageType.IMAGE, + RoomMessageEventMessageType.VIDEO, + RoomMessageEventMessageType.AUDIO, + ) + ) + is CreateTimelineParams.Focused, + CreateTimelineParams.PinnedOnly, + is CreateTimelineParams.Threaded -> TimelineFilter.All + } + + val internalIdPrefix = when (createTimelineParams) { + is CreateTimelineParams.PinnedOnly -> "pinned_events" + is CreateTimelineParams.Focused -> "focus_${createTimelineParams.focusedEventId}" + is CreateTimelineParams.MediaOnly -> "MediaGallery_" + is CreateTimelineParams.MediaOnlyFocused -> "MediaGallery_${createTimelineParams.focusedEventId}" + is CreateTimelineParams.Threaded -> "Thread_${createTimelineParams.threadRootEventId}" + } + + // Note that for TimelineFilter.MediaOnlyFocused, the date separator will be filtered out, + // but there is no way to exclude data separator at the moment. + val dateDividerMode = when (createTimelineParams) { + is CreateTimelineParams.MediaOnly, + is CreateTimelineParams.MediaOnlyFocused -> DateDividerMode.MONTHLY + is CreateTimelineParams.Focused, + CreateTimelineParams.PinnedOnly, + is CreateTimelineParams.Threaded -> DateDividerMode.DAILY + } + + // Track read receipts only for focused timeline for performance optimization + val trackReadReceipts = createTimelineParams is CreateTimelineParams.Focused + + runCatchingExceptions { + innerRoom.timelineWithConfiguration( + configuration = TimelineConfiguration( + focus = focus, + filter = filter, + internalIdPrefix = internalIdPrefix, + dateDividerMode = dateDividerMode, + trackReadReceipts = if (trackReadReceipts) TimelineReadReceiptTracking.ALL_EVENTS else TimelineReadReceiptTracking.DISABLED, + reportUtds = true, + ) + ).let { innerTimeline -> + val mode = when (createTimelineParams) { + is CreateTimelineParams.Focused -> Timeline.Mode.FocusedOnEvent(createTimelineParams.focusedEventId) + is CreateTimelineParams.MediaOnly -> Timeline.Mode.Media + is CreateTimelineParams.MediaOnlyFocused -> Timeline.Mode.FocusedOnEvent(createTimelineParams.focusedEventId) + CreateTimelineParams.PinnedOnly -> Timeline.Mode.PinnedEvents + is CreateTimelineParams.Threaded -> Timeline.Mode.Thread(createTimelineParams.threadRootEventId) + } + innerTimeline.map(mode = mode) + } + }.mapFailure { + when (createTimelineParams) { + is CreateTimelineParams.Focused, + is CreateTimelineParams.MediaOnlyFocused, + is CreateTimelineParams.Threaded -> it.toFocusEventException() + CreateTimelineParams.MediaOnly, + CreateTimelineParams.PinnedOnly -> it + } + }.onFailure { + if (it is CancellationException) { + throw it + } + } + } + + override suspend fun editMessage( + eventId: EventId, + body: String, + htmlBody: String?, + intentionalMentions: List + ): Result = withContext(roomDispatcher) { + runCatchingExceptions { + MessageEventContent.from(body, htmlBody, intentionalMentions).use { newContent -> + innerRoom.edit(eventId.value, newContent) + } + } + } + + override suspend fun typingNotice(isTyping: Boolean) = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.typingNotice(isTyping) + } + } + + override suspend fun inviteUserById(id: UserId): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.inviteUserById(id.value) + } + } + + override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.uploadAvatar(mimeType, data, null) + } + } + + override suspend fun removeAvatar(): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.removeAvatar() + } + } + + override suspend fun setName(name: String): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.setName(name) + } + } + + override suspend fun setTopic(topic: String): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.setTopic(topic) + } + } + + override suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.reportContent(eventId = eventId.value, score = null, reason = reason) + if (blockUserId != null) { + innerRoom.ignoreUser(blockUserId.value) + } + } + } + + override suspend fun updateRoomNotificationSettings(): Result = withContext(roomDispatcher) { + val currentState = roomNotificationSettingsStateFlow.value + val currentRoomNotificationSettings = currentState.roomNotificationSettings() + roomNotificationSettingsStateFlow.value = RoomNotificationSettingsState.Pending(prevRoomNotificationSettings = currentRoomNotificationSettings) + runCatchingExceptions { + val isEncrypted = roomInfoFlow.value.isEncrypted ?: getUpdatedIsEncrypted().getOrThrow() + notificationSettingsService.getRoomNotificationSettings(roomId, isEncrypted, isOneToOne).getOrThrow() + }.map { + roomNotificationSettingsStateFlow.value = RoomNotificationSettingsState.Ready(it) + }.onFailure { + roomNotificationSettingsStateFlow.value = RoomNotificationSettingsState.Error( + prevRoomNotificationSettings = currentRoomNotificationSettings, + failure = it + ) + } + } + + override suspend fun updateCanonicalAlias(canonicalAlias: RoomAlias?, alternativeAliases: List): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.updateCanonicalAlias(canonicalAlias?.value, alternativeAliases.map { it.value }) + } + } + + override suspend fun publishRoomAliasInRoomDirectory(roomAlias: RoomAlias): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.publishRoomAliasInRoomDirectory(roomAlias.value) + } + } + + override suspend fun removeRoomAliasFromRoomDirectory(roomAlias: RoomAlias): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.removeRoomAliasFromRoomDirectory(roomAlias.value) + } + } + + override suspend fun updateRoomVisibility(roomVisibility: RoomVisibility): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.updateRoomVisibility(roomVisibility.map()) + } + } + + override suspend fun updateHistoryVisibility(historyVisibility: RoomHistoryVisibility): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.updateHistoryVisibility(historyVisibility.map()) + } + } + + override suspend fun enableEncryption(): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.enableEncryption() + } + } + + override suspend fun updateJoinRule(joinRule: JoinRule): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.updateJoinRules(joinRule.map()) + } + } + + override suspend fun updateUsersRoles(changes: List): Result { + return runCatchingExceptions { + val powerLevelChanges = changes.map { UserPowerLevelUpdate(it.userId.value, it.powerLevel) } + innerRoom.updatePowerLevelsForUsers(powerLevelChanges) + } + } + + override suspend fun updatePowerLevels(roomPowerLevelsValues: RoomPowerLevelsValues): Result = withContext(roomDispatcher) { + runCatchingExceptions { + val changes = RoomPowerLevelChanges( + ban = roomPowerLevelsValues.ban, + invite = roomPowerLevelsValues.invite, + kick = roomPowerLevelsValues.kick, + redact = roomPowerLevelsValues.redactEvents, + eventsDefault = roomPowerLevelsValues.sendEvents, + roomName = roomPowerLevelsValues.roomName, + roomAvatar = roomPowerLevelsValues.roomAvatar, + roomTopic = roomPowerLevelsValues.roomTopic, + ) + innerRoom.applyPowerLevelChanges(changes) + } + } + + override suspend fun resetPowerLevels(): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.resetPowerLevels().let {} + } + } + + override suspend fun kickUser(userId: UserId, reason: String?): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.kickUser(userId.value, reason) + } + } + + override suspend fun banUser(userId: UserId, reason: String?): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.banUser(userId.value, reason) + } + } + + override suspend fun unbanUser(userId: UserId, reason: String?): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.unbanUser(userId.value, reason) + } + } + + override suspend fun generateWidgetWebViewUrl( + widgetSettings: MatrixWidgetSettings, + clientId: String, + languageTag: String?, + theme: String?, + ) = withContext(roomDispatcher) { + runCatchingExceptions { + widgetSettings.generateWidgetWebViewUrl(innerRoom, clientId, languageTag, theme) + } + } + + override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result { + return runCatchingExceptions { + RustWidgetDriver( + widgetSettings = widgetSettings, + room = innerRoom, + widgetCapabilitiesProvider = object : WidgetCapabilitiesProvider { + override fun acquireCapabilities(capabilities: WidgetCapabilities): WidgetCapabilities { + return getElementCallRequiredPermissions(sessionId.value, baseRoom.deviceId.value) + } + }, + ) + } + } + + override suspend fun setSendQueueEnabled(enabled: Boolean) { + withContext(roomDispatcher) { + Timber.d("setSendQueuesEnabled: $enabled") + runCatchingExceptions { + innerRoom.enableSendQueue(enabled) + } + } + } + + override suspend fun ignoreDeviceTrustAndResend(devices: Map>, sendHandle: SendHandle) = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.ignoreDeviceTrustAndResend( + devices = devices.entries.associate { entry -> + entry.key.value to entry.value.map { it.value } + }, + sendHandle = (sendHandle as RustSendHandle).inner, + ) + } + } + + override suspend fun withdrawVerificationAndResend(userIds: List, sendHandle: SendHandle) = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.withdrawVerificationAndResend( + userIds = userIds.map { it.value }, + sendHandle = (sendHandle as RustSendHandle).inner, + ) + } + } + + override fun close() = destroy() + + override fun destroy() { + baseRoom.destroy() + liveInnerTimeline.destroy() + Timber.d("Room $roomId destroyed") + } + + private fun InnerTimeline.map( + mode: Timeline.Mode, + ): Timeline { + val timelineCoroutineScope = roomCoroutineScope.childScope(coroutineDispatchers.main, "TimelineScope-$roomId-$this") + return RustTimeline( + mode = mode, + joinedRoom = this@JoinedRustRoom, + inner = this@map, + systemClock = systemClock, + coroutineScope = timelineCoroutineScope, + dispatcher = roomDispatcher, + roomContentForwarder = roomContentForwarder, + ) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/Mention.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/Mention.kt new file mode 100644 index 0000000..22303ae --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/Mention.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room + +import io.element.android.libraries.matrix.api.room.IntentionalMention +import org.matrix.rustcomponents.sdk.Mentions + +fun List.map(): Mentions { + val hasRoom = any { it is IntentionalMention.Room } + val userIds = filterIsInstance().map { it.userId.value } + return Mentions(userIds, hasRoom) +} 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 0000000..dffa5c1 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.CallAnswer -> MessageLikeEventType.CallAnswer + MessageEventType.CallInvite -> MessageLikeEventType.CallInvite + MessageEventType.CallHangup -> MessageLikeEventType.CallHangup + MessageEventType.CallCandidates -> MessageLikeEventType.CallCandidates + MessageEventType.RtcNotification -> MessageLikeEventType.RtcNotification + MessageEventType.KeyVerificationReady -> MessageLikeEventType.KeyVerificationReady + MessageEventType.KeyVerificationStart -> MessageLikeEventType.KeyVerificationStart + MessageEventType.KeyVerificationCancel -> MessageLikeEventType.KeyVerificationCancel + MessageEventType.KeyVerificationAccept -> MessageLikeEventType.KeyVerificationAccept + MessageEventType.KeyVerificationKey -> MessageLikeEventType.KeyVerificationKey + MessageEventType.KeyVerificationMac -> MessageLikeEventType.KeyVerificationMac + MessageEventType.KeyVerificationDone -> MessageLikeEventType.KeyVerificationDone + MessageEventType.Reaction -> MessageLikeEventType.Reaction + MessageEventType.RoomEncrypted -> MessageLikeEventType.RoomEncrypted + MessageEventType.RoomMessage -> MessageLikeEventType.RoomMessage + MessageEventType.RoomRedaction -> MessageLikeEventType.RoomRedaction + MessageEventType.Sticker -> MessageLikeEventType.Sticker + MessageEventType.PollEnd -> MessageLikeEventType.PollEnd + MessageEventType.PollResponse -> MessageLikeEventType.PollResponse + MessageEventType.PollStart -> MessageLikeEventType.PollStart + MessageEventType.UnstablePollEnd -> MessageLikeEventType.UnstablePollEnd + MessageEventType.UnstablePollResponse -> MessageLikeEventType.UnstablePollResponse + MessageEventType.UnstablePollStart -> MessageLikeEventType.UnstablePollStart + is MessageEventType.Other -> MessageLikeEventType.Other(type) +} + +fun MessageLikeEventType.map(): MessageEventType = when (this) { + MessageLikeEventType.CallAnswer -> MessageEventType.CallAnswer + MessageLikeEventType.CallInvite -> MessageEventType.CallInvite + MessageLikeEventType.CallHangup -> MessageEventType.CallHangup + MessageLikeEventType.CallCandidates -> MessageEventType.CallCandidates + MessageLikeEventType.RtcNotification -> MessageEventType.RtcNotification + MessageLikeEventType.KeyVerificationReady -> MessageEventType.KeyVerificationReady + MessageLikeEventType.KeyVerificationStart -> MessageEventType.KeyVerificationStart + MessageLikeEventType.KeyVerificationCancel -> MessageEventType.KeyVerificationCancel + MessageLikeEventType.KeyVerificationAccept -> MessageEventType.KeyVerificationAccept + MessageLikeEventType.KeyVerificationKey -> MessageEventType.KeyVerificationKey + MessageLikeEventType.KeyVerificationMac -> MessageEventType.KeyVerificationMac + MessageLikeEventType.KeyVerificationDone -> MessageEventType.KeyVerificationDone + MessageLikeEventType.Reaction -> MessageEventType.Reaction + MessageLikeEventType.RoomEncrypted -> MessageEventType.RoomEncrypted + MessageLikeEventType.RoomMessage -> MessageEventType.RoomMessage + MessageLikeEventType.RoomRedaction -> MessageEventType.RoomRedaction + MessageLikeEventType.Sticker -> MessageEventType.Sticker + MessageLikeEventType.PollEnd -> MessageEventType.PollEnd + MessageLikeEventType.PollResponse -> MessageEventType.PollResponse + MessageLikeEventType.PollStart -> MessageEventType.PollStart + MessageLikeEventType.UnstablePollEnd -> MessageEventType.UnstablePollEnd + MessageLikeEventType.UnstablePollResponse -> MessageEventType.UnstablePollResponse + MessageLikeEventType.UnstablePollStart -> MessageEventType.UnstablePollStart + is MessageLikeEventType.Other -> MessageEventType.Other(v1) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/NotJoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/NotJoinedRustRoom.kt new file mode 100644 index 0000000..973a363 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/NotJoinedRustRoom.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room + +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.room.NotJoinedRoom +import io.element.android.libraries.matrix.api.room.RoomMembershipDetails +import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo +import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper + +class NotJoinedRustRoom( + private val sessionId: SessionId, + override val localRoom: RustBaseRoom?, + override val previewInfo: RoomPreviewInfo, +) : NotJoinedRoom { + override suspend fun membershipDetails(): Result = runCatchingExceptions { + val room = localRoom?.innerRoom ?: return@runCatchingExceptions null + val (ownMember, senderInfo) = room.memberWithSenderInfo(sessionId.value) + RoomMembershipDetails( + currentUserMember = RoomMemberMapper.map(ownMember), + senderMember = senderInfo?.let { RoomMemberMapper.map(it) }, + ) + } + + override fun close() { + localRoom?.close() + } +} 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 0000000..e000c2f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room + +import io.element.android.libraries.core.coroutine.parallelMap +import io.element.android.libraries.core.extensions.runCatchingExceptions +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 io.element.android.libraries.matrix.impl.roomlist.roomOrNull +import io.element.android.libraries.matrix.impl.timeline.runWithTimelineListenerRegistered +import kotlinx.coroutines.withTimeout +import org.matrix.rustcomponents.sdk.MsgLikeKind +import org.matrix.rustcomponents.sdk.RoomListService +import org.matrix.rustcomponents.sdk.Timeline +import org.matrix.rustcomponents.sdk.TimelineItemContent +import org.matrix.rustcomponents.sdk.contentWithoutRelationFromMessage +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 [fromTimeline] to the given [toRoomIds]. + * @param fromTimeline 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( + fromTimeline: Timeline, + eventId: EventId, + toRoomIds: List, + timeoutMs: Long = 5000L + ) { + val messageLikeContent = (fromTimeline.getEventTimelineItemByEventId(eventId.value).content as? TimelineItemContent.MsgLike)?.content + ?: throw ForwardEventException(toRoomIds) + + val content = (messageLikeContent.kind as? MsgLikeKind.Message)?.content + ?: throw ForwardEventException(toRoomIds) + + val targetRooms = toRoomIds.mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) } + val failedForwardingTo = mutableSetOf() + targetRooms.parallelMap { room -> + room.use { targetRoom -> + runCatchingExceptions { + // Sending a message requires a registered timeline listener + targetRoom.timeline().runWithTimelineListenerRegistered { + withTimeout(timeoutMs.milliseconds) { + targetRoom.timeline().send(contentWithoutRelationFromMessage(content)) + } + } + } + }.onFailure { + failedForwardingTo.add(RoomId(room.id())) + } + } + + if (failedForwardingTo.isNotEmpty()) { + throw ForwardEventException(failedForwardingTo.toList()) + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoExt.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoExt.kt new file mode 100644 index 0000000..668cfc4 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoExt.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room + +import io.element.android.libraries.matrix.api.user.MatrixUser +import org.matrix.rustcomponents.sdk.RoomInfo + +/** + * Extract the heroes from the room info. + * For now we only use heroes for direct rooms with 2 members. + * Also we keep the heroes only if there is one single hero. + */ +fun RoomInfo.elementHeroes(): List { + return heroes + .takeIf { isDirect && activeMembersCount.toLong() == 2L } + ?.takeIf { it.size == 1 } + ?.map { it.map() } + .orEmpty() +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapper.kt new file mode 100644 index 0000000..36da41f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapper.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomAlias +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.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.impl.room.history.map +import io.element.android.libraries.matrix.impl.room.join.map +import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper +import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsValuesMapper +import io.element.android.libraries.matrix.impl.room.tombstone.map +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap +import org.matrix.rustcomponents.sdk.Membership +import org.matrix.rustcomponents.sdk.RoomHero +import uniffi.matrix_sdk_base.EncryptionState +import org.matrix.rustcomponents.sdk.Membership as RustMembership +import org.matrix.rustcomponents.sdk.RoomInfo as RustRoomInfo +import org.matrix.rustcomponents.sdk.RoomNotificationMode as RustRoomNotificationMode +import org.matrix.rustcomponents.sdk.RoomPowerLevels as RustRoomPowerLevels + +class RoomInfoMapper { + fun map(rustRoomInfo: RustRoomInfo): RoomInfo = rustRoomInfo.let { + return RoomInfo( + id = RoomId(it.id), + creators = it.creators.orEmpty().map(::UserId).toImmutableList(), + name = it.displayName, + rawName = it.rawName, + topic = it.topic, + avatarUrl = it.avatarUrl, + isPublic = it.isPublic, + isDirect = it.isDirect, + isEncrypted = when (it.encryptionState) { + EncryptionState.ENCRYPTED -> true + EncryptionState.NOT_ENCRYPTED -> false + EncryptionState.UNKNOWN -> null + }, + joinRule = it.joinRule?.map(), + isSpace = it.isSpace, + isFavorite = it.isFavourite, + canonicalAlias = it.canonicalAlias?.let(::RoomAlias), + alternativeAliases = it.alternativeAliases.map(::RoomAlias).toImmutableList(), + currentUserMembership = it.membership.map(), + inviter = it.inviter?.let(RoomMemberMapper::map), + activeMembersCount = it.activeMembersCount.toLong(), + invitedMembersCount = it.invitedMembersCount.toLong(), + joinedMembersCount = it.joinedMembersCount.toLong(), + roomPowerLevels = it.powerLevels?.let(::mapPowerLevels), + highlightCount = it.highlightCount.toLong(), + notificationCount = it.notificationCount.toLong(), + userDefinedNotificationMode = it.cachedUserDefinedNotificationMode?.map(), + hasRoomCall = it.hasRoomCall, + activeRoomCallParticipants = it.activeRoomCallParticipants.map(::UserId).toImmutableList(), + heroes = it.elementHeroes().toImmutableList(), + pinnedEventIds = it.pinnedEventIds.map(::EventId).toImmutableList(), + isMarkedUnread = it.isMarkedUnread, + numUnreadMessages = it.numUnreadMessages.toLong(), + numUnreadMentions = it.numUnreadMentions.toLong(), + numUnreadNotifications = it.numUnreadNotifications.toLong(), + historyVisibility = it.historyVisibility.map(), + successorRoom = it.successorRoom?.map(), + roomVersion = it.roomVersion, + privilegedCreatorRole = it.privilegedCreatorsRole, + ) + } +} + +fun RustMembership.map(): CurrentUserMembership = when (this) { + RustMembership.INVITED -> CurrentUserMembership.INVITED + RustMembership.JOINED -> CurrentUserMembership.JOINED + RustMembership.LEFT -> CurrentUserMembership.LEFT + Membership.KNOCKED -> CurrentUserMembership.KNOCKED + RustMembership.BANNED -> CurrentUserMembership.BANNED +} + +fun RustRoomNotificationMode.map(): RoomNotificationMode = when (this) { + RustRoomNotificationMode.ALL_MESSAGES -> RoomNotificationMode.ALL_MESSAGES + RustRoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY + RustRoomNotificationMode.MUTE -> RoomNotificationMode.MUTE +} + +/** + * Map a RoomHero to a MatrixUser. There is not need to create a RoomHero type on the application side. + */ +fun RoomHero.map(): MatrixUser = MatrixUser( + userId = UserId(userId), + displayName = displayName, + avatarUrl = avatarUrl +) + +fun mapPowerLevels(roomPowerLevels: RustRoomPowerLevels): RoomPowerLevels { + return RoomPowerLevels( + values = RoomPowerLevelsValuesMapper.map(roomPowerLevels.values()), + users = roomPowerLevels.userPowerLevels().mapKeys { (key, _) -> UserId(key) }.toImmutableMap() + ) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSyncSubscriber.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSyncSubscriber.kt new file mode 100644 index 0000000..6a6b45b --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSyncSubscriber.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room + +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.RoomListService +import timber.log.Timber + +class RoomSyncSubscriber( + private val roomListService: RoomListService, + private val dispatchers: CoroutineDispatchers, +) { + private val subscribedRoomIds = mutableSetOf() + private val mutex = Mutex() + + suspend fun subscribe(roomId: RoomId) { + mutex.withLock { + withContext(dispatchers.io) { + try { + if (!isSubscribedTo(roomId)) { + Timber.d("Subscribing to room $roomId}") + roomListService.subscribeToRooms(listOf(roomId.value)) + } + subscribedRoomIds.add(roomId) + } catch (exception: Exception) { + Timber.e("Failed to subscribe to room $roomId") + } + } + } + } + + suspend fun batchSubscribe(roomIds: List) = mutex.withLock { + withContext(dispatchers.io) { + try { + val roomIdsToSubscribeTo = roomIds.filterNot { isSubscribedTo(it) } + if (roomIdsToSubscribeTo.isNotEmpty()) { + Timber.d("Subscribing to rooms: $roomIds") + roomListService.subscribeToRooms(roomIdsToSubscribeTo.map { it.value }) + subscribedRoomIds.addAll(roomIds) + } + } catch (cancellationException: CancellationException) { + throw cancellationException + } catch (exception: Exception) { + Timber.e(exception, "Failed to subscribe to rooms: $roomIds") + } + } + } + + fun isSubscribedTo(roomId: RoomId): Boolean { + return subscribedRoomIds.contains(roomId) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomType.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomType.kt new file mode 100644 index 0000000..a8681e1 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomType.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room + +import io.element.android.libraries.matrix.api.room.RoomType +import org.matrix.rustcomponents.sdk.RoomType as RustRoomType + +fun RustRoomType.map(): RoomType { + return when (this) { + RustRoomType.Room -> RoomType.Room + RustRoomType.Space -> RoomType.Space + is RustRoomType.Custom -> RoomType.Other(this.value) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt new file mode 100644 index 0000000..be7f624 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt @@ -0,0 +1,334 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.core.DeviceId +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.core.UserId +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.api.room.draft.ComposerDraft +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues +import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom +import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility +import io.element.android.libraries.matrix.api.timeline.ReceiptType +import io.element.android.libraries.matrix.impl.room.draft.into +import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher +import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper +import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsValuesMapper +import io.element.android.libraries.matrix.impl.room.tombstone.map +import io.element.android.libraries.matrix.impl.roomdirectory.map +import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType +import io.element.android.libraries.matrix.impl.util.mxCallbackFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.CallDeclineListener +import org.matrix.rustcomponents.sdk.RoomInfoListener +import org.matrix.rustcomponents.sdk.use +import timber.log.Timber +import uniffi.matrix_sdk_base.EncryptionState +import org.matrix.rustcomponents.sdk.Room as InnerRoom + +class RustBaseRoom( + override val sessionId: SessionId, + internal val deviceId: DeviceId, + internal val innerRoom: InnerRoom, + coroutineDispatchers: CoroutineDispatchers, + private val roomSyncSubscriber: RoomSyncSubscriber, + private val roomMembershipObserver: RoomMembershipObserver, + sessionCoroutineScope: CoroutineScope, + roomInfoMapper: RoomInfoMapper, + initialRoomInfo: RoomInfo, +) : BaseRoom { + 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) + + internal val roomMemberListFetcher = RoomMemberListFetcher(innerRoom, roomMembersDispatcher) + + override val membersStateFlow: StateFlow = roomMemberListFetcher.membersFlow + + override val roomCoroutineScope = sessionCoroutineScope.childScope(coroutineDispatchers.main, "RoomScope-$roomId") + + override val roomInfoFlow: StateFlow = mxCallbackFlow { + innerRoom.subscribeToRoomInfoUpdates(object : RoomInfoListener { + override fun call(roomInfo: org.matrix.rustcomponents.sdk.RoomInfo) { + channel.trySend(roomInfoMapper.map(roomInfo)) + } + }) + }.stateIn(roomCoroutineScope, started = SharingStarted.Lazily, initialValue = initialRoomInfo) + + override fun predecessorRoom(): PredecessorRoom? { + return innerRoom.predecessorRoom()?.map() + } + + override suspend fun subscribeToSync() = roomSyncSubscriber.subscribe(roomId) + + override suspend fun updateMembers() { + val useCache = membersStateFlow.value is RoomMembersState.Unknown + val source = if (useCache) { + RoomMemberListFetcher.Source.CACHE_AND_SERVER + } else { + RoomMemberListFetcher.Source.SERVER + } + roomMemberListFetcher.fetchRoomMembers(source = source) + } + + override suspend fun getMembers(limit: Int) = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.members().use { + it.nextChunk(limit.toUInt()).orEmpty().map { roomMember -> + RoomMemberMapper.map(roomMember) + } + } + } + } + + override suspend fun getUpdatedMember(userId: UserId): Result = withContext(roomDispatcher) { + runCatchingExceptions { + RoomMemberMapper.map(innerRoom.member(userId.value)) + } + } + + override fun close() = destroy() + + override fun destroy() { + innerRoom.destroy() + roomCoroutineScope.cancel() + } + + override suspend fun userDisplayName(userId: UserId): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.memberDisplayName(userId.value) + } + } + + override suspend fun userRole(userId: UserId): Result = withContext(roomDispatcher) { + runCatchingExceptions { + val powerLevel = roomInfoFlow.value.roomPowerLevels?.powerLevelOf(userId) ?: 0L + RoomMemberMapper.mapRole( + role = innerRoom.suggestedRoleForUser(userId.value), + powerLevel = powerLevel, + ) + } + } + + override suspend fun powerLevels(): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.getPowerLevels().use { + RoomPowerLevelsValuesMapper.map(it.values()) + } + } + } + + override suspend fun userAvatarUrl(userId: UserId): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.memberAvatarUrl(userId.value) + } + } + + override suspend fun leave(): Result = withContext(roomDispatcher) { + val membershipBeforeLeft = roomInfoFlow.value.currentUserMembership + runCatchingExceptions { + innerRoom.leave() + }.onSuccess { + roomMembershipObserver.notifyUserLeftRoom( + roomId = roomId, + isSpace = roomInfoFlow.value.isSpace, + membershipBeforeLeft = membershipBeforeLeft, + ) + } + } + + override suspend fun join(): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.join() + } + } + + override suspend fun forget(): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.forget() + } + } + + override suspend fun canUserInvite(userId: UserId): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.getPowerLevels().use { it.canUserInvite(userId.value) } + } + } + + override suspend fun canUserKick(userId: UserId): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.getPowerLevels().use { it.canUserKick(userId.value) } + } + } + + override suspend fun canUserBan(userId: UserId): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.getPowerLevels().use { it.canUserBan(userId.value) } + } + } + + override suspend fun canUserRedactOwn(userId: UserId): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.getPowerLevels().use { it.canUserRedactOwn(userId.value) } + } + } + + override suspend fun canUserRedactOther(userId: UserId): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.getPowerLevels().use { it.canUserRedactOther(userId.value) } + } + } + + override suspend fun canUserSendState(userId: UserId, type: StateEventType): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.getPowerLevels().use { it.canUserSendState(userId.value, type.map()) } + } + } + + override suspend fun canUserSendMessage(userId: UserId, type: MessageEventType): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.getPowerLevels().use { it.canUserSendMessage(userId.value, type.map()) } + } + } + + override suspend fun canUserTriggerRoomNotification(userId: UserId): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.getPowerLevels().use { it.canUserTriggerRoomNotification(userId.value) } + } + } + + override suspend fun canUserPinUnpin(userId: UserId): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.getPowerLevels().use { it.canUserPinUnpin(userId.value) } + } + } + + override suspend fun clearEventCacheStorage(): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.clearEventCacheStorage() + } + } + + override suspend fun setIsFavorite(isFavorite: Boolean): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.setIsFavourite(isFavorite, null) + } + } + + override suspend fun markAsRead(receiptType: ReceiptType): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.markAsRead(receiptType.toRustReceiptType()) + } + } + + override suspend fun setUnreadFlag(isUnread: Boolean): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.setUnreadFlag(isUnread) + } + } + + override suspend fun getPermalink(): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.matrixToPermalink() + } + } + + override suspend fun getPermalinkFor(eventId: EventId): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.matrixToEventPermalink(eventId.value) + } + } + + override suspend fun getRoomVisibility(): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.getRoomVisibility().map() + } + } + + override suspend fun getUpdatedIsEncrypted(): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.latestEncryptionState() == EncryptionState.ENCRYPTED + } + } + + override suspend fun saveComposerDraft(composerDraft: ComposerDraft, threadRoot: ThreadId?): Result = withContext(roomDispatcher) { + runCatchingExceptions { + Timber.d("saveComposerDraft: $composerDraft into $roomId for thread root: $threadRoot") + innerRoom.saveComposerDraft(composerDraft.into(), threadRoot = threadRoot?.value) + } + } + + override suspend fun loadComposerDraft(threadRoot: ThreadId?): Result = withContext(roomDispatcher) { + runCatchingExceptions { + Timber.d("loadComposerDraft for $roomId with thread root: $threadRoot") + innerRoom.loadComposerDraft(threadRoot?.value)?.into() + } + } + + override suspend fun clearComposerDraft(threadRoot: ThreadId?): Result = withContext(roomDispatcher) { + runCatchingExceptions { + Timber.d("clearComposerDraft for $roomId with thread root: $threadRoot") + innerRoom.clearComposerDraft(threadRoot = threadRoot?.value) + } + } + + override suspend fun reportRoom(reason: String?): Result = withContext(roomDispatcher) { + runCatchingExceptions { + Timber.d("reportRoom $roomId") + innerRoom.reportRoom(reason.orEmpty()) + } + } + + override suspend fun declineCall(notificationEventId: EventId): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.declineCall(notificationEventId.value) + } + } + + override suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow = withContext(roomDispatcher) { + mxCallbackFlow { + innerRoom.subscribeToCallDeclineEvents(notificationEventId.value, object : CallDeclineListener { + override fun call(declinerUserId: String) { + trySend(UserId(declinerUserId)) + } + }) + } + } + + override suspend fun threadRootIdForEvent(eventId: EventId): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.loadOrFetchEvent(eventId.value).use { + it.threadRootEventId()?.let(::ThreadId) + } + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt new file mode 100644 index 0000000..f184356 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room + +import io.element.android.appconfig.TimelineConfig +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.core.DeviceId +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.notificationsettings.NotificationSettingsService +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.roomlist.awaitLoaded +import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewInfoMapper +import io.element.android.libraries.matrix.impl.roomlist.roomOrNull +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.recordTransaction +import io.element.android.services.analyticsproviders.api.recordChildTransaction +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.DateDividerMode +import org.matrix.rustcomponents.sdk.Membership +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.RoomInfo +import org.matrix.rustcomponents.sdk.TimelineConfiguration +import org.matrix.rustcomponents.sdk.TimelineFilter +import org.matrix.rustcomponents.sdk.TimelineFocus +import timber.log.Timber +import uniffi.matrix_sdk_ui.TimelineReadReceiptTracking +import java.util.concurrent.atomic.AtomicBoolean +import org.matrix.rustcomponents.sdk.RoomListService as InnerRoomListService + +class RustRoomFactory( + private val sessionId: SessionId, + private val deviceId: DeviceId, + private val notificationSettingsService: NotificationSettingsService, + private val sessionCoroutineScope: CoroutineScope, + private val dispatchers: CoroutineDispatchers, + private val systemClock: SystemClock, + private val roomContentForwarder: RoomContentForwarder, + private val roomListService: RoomListService, + private val innerRoomListService: InnerRoomListService, + private val roomSyncSubscriber: RoomSyncSubscriber, + private val timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory, + private val featureFlagService: FeatureFlagService, + private val roomMembershipObserver: RoomMembershipObserver, + private val roomInfoMapper: RoomInfoMapper, + private val analyticsService: AnalyticsService, +) { + private val dispatcher = dispatchers.computation.limitedParallelism(1) + private val mutex = Mutex() + private val isDestroyed: AtomicBoolean = AtomicBoolean(false) + + private val eventFilters = TimelineConfig.excludedEvents + .takeIf { it.isNotEmpty() } + ?.let { listStateEventType -> + timelineEventTypeFilterFactory.create(listStateEventType) + } + + suspend fun destroy() { + withContext(NonCancellable + dispatcher) { + mutex.withLock { + Timber.d("Destroying room factory") + isDestroyed.set(true) + } + } + } + + suspend fun getBaseRoom(roomId: RoomId): RustBaseRoom? = withContext(dispatcher) { + mutex.withLock { + if (isDestroyed.get()) { + Timber.d("Room factory is destroyed, returning null for $roomId") + return@withContext null + } + val room = awaitRoomInRoomList(roomId) ?: return@withContext null + getBaseRoom(sdkRoom = room, roomInfo = room.roomInfo()) + } + } + + private fun getBaseRoom(sdkRoom: Room, roomInfo: RoomInfo) = RustBaseRoom( + sessionId = sessionId, + deviceId = deviceId, + innerRoom = sdkRoom, + coroutineDispatchers = dispatchers, + roomSyncSubscriber = roomSyncSubscriber, + roomMembershipObserver = roomMembershipObserver, + roomInfoMapper = roomInfoMapper, + initialRoomInfo = roomInfoMapper.map(roomInfo), + sessionCoroutineScope = sessionCoroutineScope, + ) + + suspend fun getJoinedRoomOrPreview(roomId: RoomId, serverNames: List): GetRoomResult? = withContext(dispatcher) { + mutex.withLock { + if (isDestroyed.get()) { + Timber.d("Room factory is destroyed, returning null for $roomId") + return@withContext null + } + + val sdkRoom = awaitRoomInRoomList(roomId) ?: return@withLock null + val roomInfo = sdkRoom.roomInfo() + + val parentTransaction = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.OpenRoom) + + if (roomInfo.membership == Membership.JOINED) { + analyticsService.recordTransaction( + name = "Get joined room", + operation = "RustRoomFactory.getJoinedRoomOrPreview", + parentTransaction = parentTransaction, + ) { transaction -> + val hideThreadedEvents = featureFlagService.isFeatureEnabled(FeatureFlags.Threads) + // Init the live timeline in the SDK from the Room + val timeline = transaction.recordChildTransaction( + operation = "sdkRoom.timelineWithConfiguration", + description = "Get timeline from the SDK", + ) { + sdkRoom.timelineWithConfiguration( + TimelineConfiguration( + focus = TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents), + filter = eventFilters?.let(TimelineFilter::EventTypeFilter) ?: TimelineFilter.All, + internalIdPrefix = "live", + dateDividerMode = DateDividerMode.DAILY, + trackReadReceipts = TimelineReadReceiptTracking.ALL_EVENTS, + reportUtds = true, + ) + ) + } + + GetRoomResult.Joined( + JoinedRustRoom( + baseRoom = getBaseRoom(sdkRoom, roomInfo), + notificationSettingsService = notificationSettingsService, + roomContentForwarder = roomContentForwarder, + liveInnerTimeline = timeline, + coroutineDispatchers = dispatchers, + systemClock = systemClock, + featureFlagService = featureFlagService, + ) + ) + } + } else { + analyticsService.recordTransaction( + name = "Get preview of room", + operation = "RustRoomFactory.getJoinedRoomOrPreview", + parentTransaction = parentTransaction, + ) { + val preview = try { + sdkRoom.previewRoom(via = serverNames) + } catch (e: Exception) { + Timber.e(e, "Failed to get room preview for $roomId") + return@recordTransaction null + } + + GetRoomResult.NotJoined( + NotJoinedRustRoom( + sessionId = sessionId, + localRoom = getBaseRoom(sdkRoom, roomInfo), + previewInfo = RoomPreviewInfoMapper.map(preview.info()), + ) + ) + } + } + } + } + + /** + * Get the Rust room for a room, retrying after the room list is loaded if necessary. + */ + private suspend fun awaitRoomInRoomList(roomId: RoomId): Room? { + var sdkRoom = innerRoomListService.roomOrNull(roomId.value) + if (sdkRoom == null) { + // ... otherwise, lets wait for the SS to load all rooms and check again. + roomListService.allRooms.awaitLoaded() + sdkRoom = innerRoomListService.roomOrNull(roomId.value) + } + + if (sdkRoom == null) { + Timber.d("Room not found for $roomId") + return null + } + + return sdkRoom + } +} + +sealed interface GetRoomResult { + data class Joined(val joinedRoom: JoinedRoom) : GetRoomResult + data class NotJoined(val notJoinedRoom: NotJoinedRustRoom) : GetRoomResult + + val room: BaseRoom? + get() = when (this) { + is Joined -> joinedRoom + is NotJoined -> notJoinedRoom.localRoom + } +} 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 0000000..c1ba8c9 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/StateEventType.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.CALL_MEMBER -> RustStateEventType.CALL_MEMBER + 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.CALL_MEMBER -> StateEventType.CALL_MEMBER + 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/TimelineEventTypeFilterFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/TimelineEventTypeFilterFactory.kt new file mode 100644 index 0000000..d25f595 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/TimelineEventTypeFilterFactory.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.matrix.api.room.StateEventType +import org.matrix.rustcomponents.sdk.FilterTimelineEventType +import org.matrix.rustcomponents.sdk.TimelineEventTypeFilter + +interface TimelineEventTypeFilterFactory { + fun create(listStateEventType: List): TimelineEventTypeFilter +} + +@ContributesBinding(AppScope::class) +class RustTimelineEventTypeFilterFactory : TimelineEventTypeFilterFactory { + override fun create(listStateEventType: List): TimelineEventTypeFilter { + return TimelineEventTypeFilter.exclude( + listStateEventType.map { stateEventType -> + FilterTimelineEventType.State(stateEventType.map()) + } + ) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/alias/DefaultRoomAliasHelper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/alias/DefaultRoomAliasHelper.kt new file mode 100644 index 0000000..3db3c12 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/alias/DefaultRoomAliasHelper.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.alias + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper + +@ContributesBinding(AppScope::class) +class DefaultRoomAliasHelper : RoomAliasHelper { + override fun roomAliasNameFromRoomDisplayName(name: String): String { + return org.matrix.rustcomponents.sdk.roomAliasNameFromRoomDisplayName(name) + } + + override fun isRoomAliasValid(roomAlias: RoomAlias): Boolean { + return org.matrix.rustcomponents.sdk.isRoomAliasFormatValid(roomAlias.value) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/draft/ComposerDraftMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/draft/ComposerDraftMapper.kt new file mode 100644 index 0000000..5e69b66 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/draft/ComposerDraftMapper.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.draft + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.draft.ComposerDraft +import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType +import org.matrix.rustcomponents.sdk.ComposerDraft as RustComposerDraft +import org.matrix.rustcomponents.sdk.ComposerDraftType as RustComposerDraftType + +internal fun ComposerDraft.into(): RustComposerDraft { + return RustComposerDraft( + plainText = plainText, + htmlText = htmlText, + draftType = draftType.into(), + // TODO add media attachments to the draft + attachments = emptyList(), + ) +} + +internal fun RustComposerDraft.into(): ComposerDraft { + return ComposerDraft( + plainText = plainText, + htmlText = htmlText, + draftType = draftType.into() + ) +} + +private fun RustComposerDraftType.into(): ComposerDraftType { + return when (this) { + RustComposerDraftType.NewMessage -> ComposerDraftType.NewMessage + is RustComposerDraftType.Reply -> ComposerDraftType.Reply(EventId(eventId)) + is RustComposerDraftType.Edit -> ComposerDraftType.Edit(EventId(eventId)) + } +} + +private fun ComposerDraftType.into(): RustComposerDraftType { + return when (this) { + ComposerDraftType.NewMessage -> RustComposerDraftType.NewMessage + is ComposerDraftType.Reply -> RustComposerDraftType.Reply(eventId.value) + is ComposerDraftType.Edit -> RustComposerDraftType.Edit(eventId.value) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/history/RoomHistoryVisibilityMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/history/RoomHistoryVisibilityMapper.kt new file mode 100644 index 0000000..30c8d7f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/history/RoomHistoryVisibilityMapper.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.history + +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility +import org.matrix.rustcomponents.sdk.RoomHistoryVisibility as RustRoomHistoryVisibility + +fun RoomHistoryVisibility.map(): RustRoomHistoryVisibility { + return when (this) { + RoomHistoryVisibility.WorldReadable -> RustRoomHistoryVisibility.WorldReadable + RoomHistoryVisibility.Invited -> RustRoomHistoryVisibility.Invited + RoomHistoryVisibility.Joined -> RustRoomHistoryVisibility.Joined + RoomHistoryVisibility.Shared -> RustRoomHistoryVisibility.Shared + is RoomHistoryVisibility.Custom -> RustRoomHistoryVisibility.Custom(value) + } +} + +fun RustRoomHistoryVisibility.map(): RoomHistoryVisibility { + return when (this) { + RustRoomHistoryVisibility.WorldReadable -> RoomHistoryVisibility.WorldReadable + RustRoomHistoryVisibility.Invited -> RoomHistoryVisibility.Invited + RustRoomHistoryVisibility.Joined -> RoomHistoryVisibility.Joined + RustRoomHistoryVisibility.Shared -> RoomHistoryVisibility.Shared + is RustRoomHistoryVisibility.Custom -> RoomHistoryVisibility.Custom(value) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/AllowRule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/AllowRule.kt new file mode 100644 index 0000000..2de5197 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/AllowRule.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.join + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.join.AllowRule +import org.matrix.rustcomponents.sdk.AllowRule as RustAllowRule + +fun RustAllowRule.map(): AllowRule { + return when (this) { + is RustAllowRule.RoomMembership -> AllowRule.RoomMembership(RoomId(roomId)) + is RustAllowRule.Custom -> AllowRule.Custom(json) + } +} + +fun AllowRule.map(): RustAllowRule { + return when (this) { + is AllowRule.RoomMembership -> RustAllowRule.RoomMembership(roomId.value) + is AllowRule.Custom -> RustAllowRule.Custom(json) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoom.kt new file mode 100644 index 0000000..66be149 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoom.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.join + +import dev.zacsweers.metro.ContributesBinding +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.libraries.core.extensions.mapFailure +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.exception.ClientException +import io.element.android.libraries.matrix.api.exception.ErrorKind +import io.element.android.libraries.matrix.api.room.join.JoinRoom +import io.element.android.libraries.matrix.impl.analytics.toAnalyticsJoinedRoom +import io.element.android.services.analytics.api.AnalyticsService + +@ContributesBinding(SessionScope::class) +class DefaultJoinRoom( + private val client: MatrixClient, + private val analyticsService: AnalyticsService, +) : JoinRoom { + override suspend fun invoke( + roomIdOrAlias: RoomIdOrAlias, + serverNames: List, + trigger: JoinedRoom.Trigger + ): Result { + return when (roomIdOrAlias) { + is RoomIdOrAlias.Id -> { + if (serverNames.isEmpty()) { + client.joinRoom(roomIdOrAlias.roomId) + } else { + client.joinRoomByIdOrAlias(roomIdOrAlias, serverNames) + } + } + is RoomIdOrAlias.Alias -> { + client.joinRoomByIdOrAlias(roomIdOrAlias, serverNames = emptyList()) + } + }.onSuccess { roomInfo -> + if (roomInfo != null) { + analyticsService.capture(roomInfo.toAnalyticsJoinedRoom(trigger)) + } + }.mapFailure { + if (it is ClientException.MatrixApi) { + when (it.kind) { + ErrorKind.Forbidden -> JoinRoom.Failures.UnauthorizedJoin + else -> it + } + } else { + it + } + }.map { } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/JoinRule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/JoinRule.kt new file mode 100644 index 0000000..aac5745 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/JoinRule.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.join + +import io.element.android.libraries.matrix.api.room.join.JoinRule +import kotlinx.collections.immutable.toImmutableList +import org.matrix.rustcomponents.sdk.JoinRule as RustJoinRule + +fun RustJoinRule.map(): JoinRule { + return when (this) { + RustJoinRule.Public -> JoinRule.Public + RustJoinRule.Private -> JoinRule.Private + RustJoinRule.Knock -> JoinRule.Knock + RustJoinRule.Invite -> JoinRule.Invite + is RustJoinRule.Restricted -> JoinRule.Restricted(rules.map { it.map() }.toImmutableList()) + is RustJoinRule.Custom -> JoinRule.Custom(repr) + is RustJoinRule.KnockRestricted -> JoinRule.KnockRestricted(rules.map { it.map() }.toImmutableList()) + } +} + +fun JoinRule.map(): RustJoinRule { + return when (this) { + JoinRule.Public -> RustJoinRule.Public + JoinRule.Private -> RustJoinRule.Private + JoinRule.Knock -> RustJoinRule.Knock + JoinRule.Invite -> RustJoinRule.Invite + is JoinRule.Restricted -> RustJoinRule.Restricted(rules.map { it.map() }) + is JoinRule.Custom -> RustJoinRule.Custom(value) + is JoinRule.KnockRestricted -> RustJoinRule.KnockRestricted(rules.map { it.map() }) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/knock/RustKnockRequest.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/knock/RustKnockRequest.kt new file mode 100644 index 0000000..9e27b7b --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/knock/RustKnockRequest.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.knock + +import io.element.android.libraries.core.extensions.runCatchingExceptions +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.knock.KnockRequest +import org.matrix.rustcomponents.sdk.KnockRequest as InnerKnockRequest + +class RustKnockRequest( + private val inner: InnerKnockRequest, +) : KnockRequest { + override val eventId: EventId = EventId(inner.eventId) + override val userId: UserId = UserId(inner.userId) + override val displayName: String? = inner.displayName + override val avatarUrl: String? = inner.avatarUrl + override val reason: String? = inner.reason + override val timestamp: Long? = inner.timestamp?.toLong() + override val isSeen: Boolean = inner.isSeen + + override suspend fun accept(): Result = runCatchingExceptions { + inner.actions.accept() + } + + override suspend fun decline(reason: String?): Result = runCatchingExceptions { + inner.actions.decline(reason) + } + + override suspend fun declineAndBan(reason: String?): Result = runCatchingExceptions { + inner.actions.declineAndBan(reason) + } + + override suspend fun markAsSeen(): Result = runCatchingExceptions { + inner.actions.markAsSeen() + } +} 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 0000000..c7c2c88 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetType.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/member/RoomMemberListFetcher.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberListFetcher.kt new file mode 100644 index 0000000..cfe36a2 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberListFetcher.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.member + +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.roomMembers +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.RoomInterface +import org.matrix.rustcomponents.sdk.RoomMembersIterator +import org.matrix.rustcomponents.sdk.use +import timber.log.Timber +import kotlin.coroutines.cancellation.CancellationException +import kotlin.coroutines.coroutineContext + +/** + * This class fetches the room members for a given room in a 'paginated' way, and taking into account previous cached values. + */ +internal class RoomMemberListFetcher( + private val room: RoomInterface, + private val dispatcher: CoroutineDispatcher, + private val pageSize: Int = 10_000, +) { + enum class Source { + CACHE, + CACHE_AND_SERVER, + SERVER, + } + private val updatedRoomMemberMutex = Mutex() + private val roomId = room.id() + + private val _membersFlow = MutableStateFlow(RoomMembersState.Unknown) + val membersFlow: StateFlow = _membersFlow + + /** + * Fetches the room members for the given room. + * It will emit the cached members first, and then the updated members in batches of [pageSize] items, through [membersFlow]. + * @param source Where we should load the members from. Defaults to [Source.CACHE_AND_SERVER]. + */ + suspend fun fetchRoomMembers(source: Source = Source.CACHE_AND_SERVER) { + if (updatedRoomMemberMutex.isLocked) { + Timber.i("Room members are already being updated for room $roomId") + return + } + updatedRoomMemberMutex.withLock { + withContext(dispatcher) { + _membersFlow.run { + when (source) { + Source.CACHE -> { + fetchCachedRoomMembers(asPendingState = false) + } + Source.CACHE_AND_SERVER -> { + fetchCachedRoomMembers(asPendingState = true) + fetchRemoteRoomMembers() + } + Source.SERVER -> { + fetchRemoteRoomMembers() + } + } + } + } + } + } + + private suspend fun MutableStateFlow.fetchCachedRoomMembers(asPendingState: Boolean = true) { + Timber.i("Loading cached members for room $roomId") + try { + // Send current member list with pending state to notify the UI that we are loading new members + value = pendingWithCurrentMembers() + val members = parseAndEmitMembers(room.membersNoSync()) + val newState = if (asPendingState) { + RoomMembersState.Pending(prevRoomMembers = members) + } else { + RoomMembersState.Ready(members) + } + value = newState + } catch (exception: CancellationException) { + Timber.d("Cancelled loading cached members for room $roomId") + throw exception + } catch (exception: Exception) { + Timber.e(exception, "Failed to load cached members for room $roomId") + value = RoomMembersState.Error(exception, _membersFlow.value.roomMembers()?.toImmutableList()) + } + } + + private suspend fun MutableStateFlow.fetchRemoteRoomMembers() { + try { + // Send current member list with pending state to notify the UI that we are loading new members + value = pendingWithCurrentMembers() + // Start loading new members + value = RoomMembersState.Ready(parseAndEmitMembers(room.members())) + } catch (exception: CancellationException) { + Timber.d("Cancelled loading updated members for room $roomId") + throw exception + } catch (exception: Exception) { + Timber.e(exception, "Failed to load updated members for room $roomId") + value = RoomMembersState.Error(exception, _membersFlow.value.roomMembers()?.toImmutableList()) + } + } + + private suspend fun parseAndEmitMembers(roomMembersIterator: RoomMembersIterator): ImmutableList { + return roomMembersIterator.use { iterator -> + val results = buildList(capacity = roomMembersIterator.len().toInt()) { + while (true) { + // Loading the whole membersIterator as a stop-gap measure. + // We should probably implement some sort of paging in the future. + coroutineContext.ensureActive() + val chunk = iterator.nextChunk(pageSize.toUInt()) + // Load next chunk. If null (no more items), exit the loop + val members = chunk?.map(RoomMemberMapper::map) ?: break + addAll(members) + Timber.i("Loaded first $size members for room $roomId") + } + } + results.toImmutableList() + } + } + + private fun pendingWithCurrentMembers() = RoomMembersState.Pending(_membersFlow.value.roomMembers().orEmpty().toImmutableList()) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberMapper.kt new file mode 100644 index 0000000..447fa42 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberMapper.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.member + +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 io.element.android.libraries.matrix.impl.room.powerlevels.into +import uniffi.matrix_sdk.RoomMemberRole +import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState +import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember + +object RoomMemberMapper { + fun map(roomMember: RustRoomMember): RoomMember { + val powerLevel = roomMember.powerLevel.into() + return RoomMember( + userId = UserId(roomMember.userId), + displayName = roomMember.displayName, + avatarUrl = roomMember.avatarUrl, + membership = mapMembership(roomMember.membership), + isNameAmbiguous = roomMember.isNameAmbiguous, + powerLevel = powerLevel, + isIgnored = roomMember.isIgnored, + role = mapRole(roomMember.suggestedRoleForPowerLevel, powerLevel), + membershipChangeReason = roomMember.membershipChangeReason + ) + } + + fun mapRole(role: RoomMemberRole, powerLevel: Long?): RoomMember.Role = + when (role) { + RoomMemberRole.CREATOR -> RoomMember.Role.Owner(isCreator = true) + RoomMemberRole.ADMINISTRATOR -> { + val superAdmin = RoomMember.Role.Owner(isCreator = false) + val powerLevelOrDefault = powerLevel ?: 0L + if (powerLevelOrDefault >= superAdmin.powerLevel) { + superAdmin + } else { + RoomMember.Role.Admin + } + } + RoomMemberRole.MODERATOR -> RoomMember.Role.Moderator + RoomMemberRole.USER -> RoomMember.Role.User + } + + 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 + is RustMembershipState.Custom -> TODO() + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/powerlevels/RoomPowerLevelsValuesMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/powerlevels/RoomPowerLevelsValuesMapper.kt new file mode 100644 index 0000000..081d084 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/powerlevels/RoomPowerLevelsValuesMapper.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.powerlevels + +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues +import org.matrix.rustcomponents.sdk.PowerLevel +import org.matrix.rustcomponents.sdk.RoomPowerLevelsValues as RustRoomPowerLevelsValues + +object RoomPowerLevelsValuesMapper { + fun map(values: RustRoomPowerLevelsValues): RoomPowerLevelsValues { + return RoomPowerLevelsValues( + ban = values.ban, + invite = values.invite, + kick = values.kick, + sendEvents = values.eventsDefault, + redactEvents = values.redact, + roomName = values.roomName, + roomAvatar = values.roomAvatar, + roomTopic = values.roomTopic, + spaceChild = values.spaceChild, + ) + } +} + +fun PowerLevel.into(): Long = when (this) { + PowerLevel.Infinite -> RoomMember.Role.Owner(isCreator = true).powerLevel + is PowerLevel.Value -> this.value +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/preview/RoomPreviewInfoMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/preview/RoomPreviewInfoMapper.kt new file mode 100644 index 0000000..5e2bdd2 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/preview/RoomPreviewInfoMapper.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.preview + +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo +import io.element.android.libraries.matrix.impl.room.join.map +import io.element.android.libraries.matrix.impl.room.map +import org.matrix.rustcomponents.sdk.RoomPreviewInfo as RustRoomPreviewInfo + +object RoomPreviewInfoMapper { + fun map(info: RustRoomPreviewInfo): RoomPreviewInfo { + return RoomPreviewInfo( + roomId = RoomId(info.roomId), + canonicalAlias = info.canonicalAlias?.let(::RoomAlias), + name = info.name, + topic = info.topic, + avatarUrl = info.avatarUrl, + numberOfJoinedMembers = info.numJoinedMembers.toLong(), + roomType = info.roomType.map(), + isHistoryWorldReadable = info.isHistoryWorldReadable.orFalse(), + membership = info.membership?.map(), + joinRule = info.joinRule?.map(), + ) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/tombstone/PredecessorRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/tombstone/PredecessorRoom.kt new file mode 100644 index 0000000..c9d493f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/tombstone/PredecessorRoom.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.tombstone + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom +import org.matrix.rustcomponents.sdk.PredecessorRoom as RustPredecessorRoom + +fun RustPredecessorRoom.map(): PredecessorRoom { + return PredecessorRoom(roomId = RoomId(roomId)) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/tombstone/SuccessorRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/tombstone/SuccessorRoom.kt new file mode 100644 index 0000000..f806e3f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/tombstone/SuccessorRoom.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.tombstone + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom +import org.matrix.rustcomponents.sdk.SuccessorRoom as RustSuccessorRoom + +fun RustSuccessorRoom.map(): SuccessorRoom { + return SuccessorRoom( + roomId = RoomId(roomId), + reason = reason + ) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDescriptionMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDescriptionMapper.kt new file mode 100644 index 0000000..dd17a24 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDescriptionMapper.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomdirectory + +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.roomdirectory.RoomDescription +import org.matrix.rustcomponents.sdk.PublicRoomJoinRule +import org.matrix.rustcomponents.sdk.RoomDescription as RustRoomDescription + +class RoomDescriptionMapper { + fun map(roomDescription: RustRoomDescription): RoomDescription { + return RoomDescription( + roomId = RoomId(roomDescription.roomId), + name = roomDescription.name, + topic = roomDescription.topic, + avatarUrl = roomDescription.avatarUrl, + alias = roomDescription.alias?.let(::RoomAlias), + joinRule = roomDescription.joinRule.map(), + isWorldReadable = roomDescription.isWorldReadable, + numberOfMembers = roomDescription.joinedMembers.toLong(), + ) + } +} + +internal fun PublicRoomJoinRule?.map(): RoomDescription.JoinRule { + return when (this) { + PublicRoomJoinRule.PUBLIC -> RoomDescription.JoinRule.PUBLIC + PublicRoomJoinRule.KNOCK -> RoomDescription.JoinRule.KNOCK + PublicRoomJoinRule.RESTRICTED -> RoomDescription.JoinRule.RESTRICTED + PublicRoomJoinRule.KNOCK_RESTRICTED -> RoomDescription.JoinRule.KNOCK_RESTRICTED + PublicRoomJoinRule.INVITE -> RoomDescription.JoinRule.INVITE + null -> RoomDescription.JoinRule.UNKNOWN + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchExtension.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchExtension.kt new file mode 100644 index 0000000..c560e3d --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchExtension.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomdirectory + +import io.element.android.libraries.matrix.impl.util.cancelAndDestroy +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch +import org.matrix.rustcomponents.sdk.RoomDirectorySearch +import org.matrix.rustcomponents.sdk.RoomDirectorySearchEntriesListener +import org.matrix.rustcomponents.sdk.RoomDirectorySearchEntryUpdate +import timber.log.Timber + +internal fun RoomDirectorySearch.resultsFlow(): Flow> = + callbackFlow { + val listener = object : RoomDirectorySearchEntriesListener { + override fun onUpdate(roomEntriesUpdate: List) { + trySendBlocking(roomEntriesUpdate) + } + } + val result = results(listener) + awaitClose { + result.cancelAndDestroy() + } + }.catch { + Timber.d(it, "timelineDiffFlow() failed") + }.buffer(Channel.UNLIMITED) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchProcessor.kt new file mode 100644 index 0000000..7c4f1e2 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchProcessor.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomdirectory + +import io.element.android.libraries.matrix.api.roomdirectory.RoomDescription +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.RoomDirectorySearchEntryUpdate +import timber.log.Timber +import kotlin.coroutines.CoroutineContext + +class RoomDirectorySearchProcessor( + private val coroutineContext: CoroutineContext, +) { + private val roomDescriptions: MutableSharedFlow> = MutableSharedFlow(replay = 1) + val roomDescriptionsFlow: Flow> = roomDescriptions + + private val roomDescriptionMapper: RoomDescriptionMapper = RoomDescriptionMapper() + private val mutex = Mutex() + + suspend fun postUpdates(updates: List) { + updateRoomDescriptions { + Timber.v("Update room descriptions from postUpdates (with ${updates.size} items) on ${Thread.currentThread()}") + updates.forEach { update -> + applyUpdate(update) + } + } + } + + private fun MutableList.applyUpdate(update: RoomDirectorySearchEntryUpdate) { + when (update) { + is RoomDirectorySearchEntryUpdate.Append -> { + val roomSummaries = update.values.map(roomDescriptionMapper::map) + addAll(roomSummaries) + } + is RoomDirectorySearchEntryUpdate.PushBack -> { + val roomDescription = roomDescriptionMapper.map(update.value) + add(roomDescription) + } + is RoomDirectorySearchEntryUpdate.PushFront -> { + val roomDescription = roomDescriptionMapper.map(update.value) + add(0, roomDescription) + } + is RoomDirectorySearchEntryUpdate.Set -> { + val roomDescription = roomDescriptionMapper.map(update.value) + this[update.index.toInt()] = roomDescription + } + is RoomDirectorySearchEntryUpdate.Insert -> { + val roomDescription = roomDescriptionMapper.map(update.value) + add(update.index.toInt(), roomDescription) + } + is RoomDirectorySearchEntryUpdate.Remove -> { + removeAt(update.index.toInt()) + } + is RoomDirectorySearchEntryUpdate.Reset -> { + clear() + addAll(update.values.map(roomDescriptionMapper::map)) + } + RoomDirectorySearchEntryUpdate.PopBack -> { + removeLastOrNull() + } + RoomDirectorySearchEntryUpdate.PopFront -> { + removeFirstOrNull() + } + RoomDirectorySearchEntryUpdate.Clear -> { + clear() + } + is RoomDirectorySearchEntryUpdate.Truncate -> { + subList(update.length.toInt(), size).clear() + } + } + } + + private suspend fun updateRoomDescriptions(block: suspend MutableList.() -> Unit) = withContext(coroutineContext) { + mutex.withLock { + val current = roomDescriptions.replayCache.lastOrNull() + val mutableRoomSummaries = current.orEmpty().toMutableList() + block(mutableRoomSummaries) + roomDescriptions.emit(mutableRoomSummaries) + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomVisibilityMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomVisibilityMapper.kt new file mode 100644 index 0000000..a66d0a3 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomVisibilityMapper.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomdirectory + +import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility +import org.matrix.rustcomponents.sdk.RoomVisibility as RustRoomVisibility + +fun RoomVisibility.map(): RustRoomVisibility { + return when (this) { + RoomVisibility.Public -> RustRoomVisibility.Public + RoomVisibility.Private -> RustRoomVisibility.Private + is RoomVisibility.Custom -> RustRoomVisibility.Custom(value) + } +} + +fun RustRoomVisibility.map(): RoomVisibility { + return when (this) { + RustRoomVisibility.Public -> RoomVisibility.Public + RustRoomVisibility.Private -> RoomVisibility.Private + is RustRoomVisibility.Custom -> RoomVisibility.Custom(value) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryList.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryList.kt new file mode 100644 index 0000000..699291f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryList.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomdirectory + +import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.matrix.rustcomponents.sdk.RoomDirectorySearch +import kotlin.coroutines.CoroutineContext + +class RustRoomDirectoryList( + private val inner: RoomDirectorySearch, + coroutineScope: CoroutineScope, + private val coroutineContext: CoroutineContext, +) : RoomDirectoryList { + private val hasMoreToLoad = MutableStateFlow(true) + private val processor = RoomDirectorySearchProcessor(coroutineContext) + + init { + launchIn(coroutineScope) + } + + private fun launchIn(coroutineScope: CoroutineScope) { + inner + .resultsFlow() + .onEach { updates -> + processor.postUpdates(updates) + } + .flowOn(coroutineContext) + .launchIn(coroutineScope) + } + + override suspend fun filter(filter: String?, batchSize: Int, viaServerName: String?): Result { + return execute { + inner.search(filter = filter, batchSize = batchSize.toUInt(), viaServerName = null) + } + } + + override suspend fun loadMore(): Result { + return execute { + inner.nextPage() + } + } + + private suspend fun execute(action: suspend () -> Unit): Result { + return try { + // We always assume there is more to load until we know there isn't. + // As accessing hasMoreToLoad is otherwise blocked by the current action. + hasMoreToLoad.value = true + action() + Result.success(Unit) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } finally { + hasMoreToLoad.value = hasMoreToLoad() + } + } + + private suspend fun hasMoreToLoad(): Boolean { + return !inner.isAtLastPage() + } + + override val state: Flow = + combine(hasMoreToLoad, processor.roomDescriptionsFlow) { hasMoreToLoad, items -> + RoomDirectoryList.SearchResult( + hasMoreToLoad = hasMoreToLoad, + items = items + ) + } + .flowOn(coroutineContext) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryService.kt new file mode 100644 index 0000000..216991f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustRoomDirectoryService.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomdirectory + +import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList +import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import org.matrix.rustcomponents.sdk.Client + +class RustRoomDirectoryService( + private val client: Client, + private val sessionDispatcher: CoroutineDispatcher, +) : RoomDirectoryService { + override fun createRoomDirectoryList(scope: CoroutineScope): RoomDirectoryList { + return RustRoomDirectoryList(client.roomDirectorySearch(), scope, sessionDispatcher) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListDynamicEvents.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListDynamicEvents.kt new file mode 100644 index 0000000..48d97cc --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListDynamicEvents.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomlist + +import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind + +internal sealed interface RoomListDynamicEvents { + data object Reset : RoomListDynamicEvents + data object LoadMore : RoomListDynamicEvents + data class SetFilter(val filter: RoomListEntriesDynamicFilterKind) : RoomListDynamicEvents +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListEntriesUpdateExt.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListEntriesUpdateExt.kt new file mode 100644 index 0000000..27b19eb --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListEntriesUpdateExt.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomlist + +import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage +import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate + +@Suppress("unused") +@ExcludeFromCoverage +internal fun RoomListEntriesUpdate.describe(): String { + return when (this) { + is RoomListEntriesUpdate.Set -> { + "Set #$index to '${value.displayName()}'" + } + is RoomListEntriesUpdate.Append -> { + "Append ${values.map { "'" + it.displayName() + "'" }}" + } + is RoomListEntriesUpdate.PushBack -> { + "PushBack '${value.displayName()}'" + } + is RoomListEntriesUpdate.PushFront -> { + "PushFront '${value.displayName()}'" + } + is RoomListEntriesUpdate.Insert -> { + "Insert at #$index: '${value.displayName()}'" + } + is RoomListEntriesUpdate.Remove -> { + "Remove #$index" + } + is RoomListEntriesUpdate.Reset -> { + "Reset all to ${values.map { "'" + it.displayName() + "'" }}" + } + RoomListEntriesUpdate.PopBack -> { + "PopBack" + } + RoomListEntriesUpdate.PopFront -> { + "PopFront" + } + RoomListEntriesUpdate.Clear -> { + "Clear" + } + is RoomListEntriesUpdate.Truncate -> { + "Truncate to $length items" + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt new file mode 100644 index 0000000..84e7590 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomlist + +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.matrix.impl.util.cancelAndDestroy +import io.element.android.libraries.matrix.impl.util.mxCallbackFlow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind +import org.matrix.rustcomponents.sdk.RoomListEntriesListener +import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate +import org.matrix.rustcomponents.sdk.RoomListInterface +import org.matrix.rustcomponents.sdk.RoomListLoadingState +import org.matrix.rustcomponents.sdk.RoomListLoadingStateListener +import org.matrix.rustcomponents.sdk.RoomListServiceInterface +import org.matrix.rustcomponents.sdk.RoomListServiceState +import org.matrix.rustcomponents.sdk.RoomListServiceStateListener +import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicator +import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicatorListener +import timber.log.Timber + +private const val SYNC_INDICATOR_DELAY_BEFORE_SHOWING = 1000u +private const val SYNC_INDICATOR_DELAY_BEFORE_HIDING = 0u + +fun RoomListInterface.loadingStateFlow(): Flow = + mxCallbackFlow { + val listener = object : RoomListLoadingStateListener { + override fun onUpdate(state: RoomListLoadingState) { + trySendBlocking(state) + } + } + val result = loadingState(listener) + try { + send(result.state) + } catch (exception: Exception) { + Timber.d("loadingStateFlow() initialState failed.") + } + result.stateStream + }.catch { + Timber.d(it, "loadingStateFlow() failed") + }.buffer(Channel.UNLIMITED) + +internal fun RoomListInterface.entriesFlow( + pageSize: Int, + roomListDynamicEvents: Flow, + initialFilterKind: RoomListEntriesDynamicFilterKind +): Flow> = + callbackFlow { + val listener = object : RoomListEntriesListener { + override fun onUpdate(roomEntriesUpdate: List) { + trySendBlocking(roomEntriesUpdate) + } + } + val result = entriesWithDynamicAdaptersWith( + pageSize = pageSize.toUInt(), + enableLatestEventSorter = true, + listener = listener, + ) + val controller = result.controller() + controller.setFilter(initialFilterKind) + roomListDynamicEvents.onEach { controllerEvents -> + when (controllerEvents) { + is RoomListDynamicEvents.SetFilter -> { + controller.setFilter(controllerEvents.filter) + } + is RoomListDynamicEvents.LoadMore -> { + controller.addOnePage() + } + is RoomListDynamicEvents.Reset -> { + controller.resetToOnePage() + } + } + }.launchIn(this) + awaitClose { + result.entriesStream().cancelAndDestroy() + controller.destroy() + result.destroy() + } + }.catch { + Timber.d(it, "entriesFlow() failed") + }.buffer(Channel.UNLIMITED) + +internal fun RoomListServiceInterface.stateFlow(): Flow = + mxCallbackFlow { + val listener = object : RoomListServiceStateListener { + override fun onUpdate(state: RoomListServiceState) { + trySendBlocking(state) + } + } + state(listener) + }.buffer(Channel.UNLIMITED) + +internal fun RoomListServiceInterface.syncIndicator(): Flow = + mxCallbackFlow { + val listener = object : RoomListServiceSyncIndicatorListener { + override fun onUpdate(syncIndicator: RoomListServiceSyncIndicator) { + trySendBlocking(syncIndicator) + } + } + syncIndicator( + SYNC_INDICATOR_DELAY_BEFORE_SHOWING, + SYNC_INDICATOR_DELAY_BEFORE_HIDING, + listener, + ) + }.buffer(Channel.UNLIMITED) + +internal fun RoomListServiceInterface.roomOrNull(roomId: String): Room? { + return tryOrNull( + onException = { Timber.e(it, "Failed finding room with id=$roomId.") } + ) { + room(roomId) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt new file mode 100644 index 0000000..b15411c --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomlist + +import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList +import io.element.android.libraries.matrix.api.roomlist.RoomList +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.finishLongRunningTransaction +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind +import org.matrix.rustcomponents.sdk.RoomListLoadingState +import org.matrix.rustcomponents.sdk.RoomListService +import kotlin.coroutines.CoroutineContext +import org.matrix.rustcomponents.sdk.RoomList as InnerRoomList + +private val ROOM_LIST_RUST_FILTERS = listOf( + RoomListEntriesDynamicFilterKind.NonLeft, + RoomListEntriesDynamicFilterKind.DeduplicateVersions +) + +internal class RoomListFactory( + private val innerRoomListService: RoomListService, + private val sessionCoroutineScope: CoroutineScope, + private val analyticsService: AnalyticsService, +) { + private val roomSummaryFactory: RoomSummaryFactory = RoomSummaryFactory() + + /** + * Creates a room list that can be used to load more rooms and filter them dynamically. + */ + fun createRoomList( + pageSize: Int, + coroutineContext: CoroutineContext, + coroutineScope: CoroutineScope = sessionCoroutineScope, + initialFilter: RoomListFilter = RoomListFilter.all(), + innerProvider: suspend () -> InnerRoomList + ): DynamicRoomList { + val loadingStateFlow: MutableStateFlow = MutableStateFlow(RoomList.LoadingState.NotLoaded) + val filteredSummariesFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1) + val summariesFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1) + val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryFactory) + // Makes sure we don't miss any events + val dynamicEvents = MutableSharedFlow(replay = 100) + val currentFilter = MutableStateFlow(initialFilter) + val loadedPages = MutableStateFlow(1) + var innerRoomList: InnerRoomList? = null + + val firstRoomsTransaction = analyticsService.startTransaction("Load first set of rooms", "innerRoomList.entriesFlow") + + coroutineScope.launch(coroutineContext) { + innerRoomList = innerProvider() + innerRoomList.let { innerRoomList -> + innerRoomList.entriesFlow( + pageSize = pageSize, + roomListDynamicEvents = dynamicEvents, + initialFilterKind = RoomListEntriesDynamicFilterKind.All(ROOM_LIST_RUST_FILTERS), + ).onEach { update -> + if (!firstRoomsTransaction.isFinished()) { + analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.FirstRoomsDisplayed) + firstRoomsTransaction.finish() + } + processor.postUpdate(update) + }.launchIn(this) + + innerRoomList.loadingStateFlow() + .map { it.toLoadingState() } + .onEach { + loadingStateFlow.value = it + } + .launchIn(this) + + combine( + currentFilter, + summariesFlow + ) { filter, summaries -> + summaries.filter(filter) + }.onEach { + filteredSummariesFlow.emit(it) + }.launchIn(this) + } + }.invokeOnCompletion { + innerRoomList?.destroy() + } + return RustDynamicRoomList( + summaries = summariesFlow, + filteredSummaries = filteredSummariesFlow, + loadingState = loadingStateFlow, + currentFilter = currentFilter, + loadedPages = loadedPages, + dynamicEvents = dynamicEvents, + processor = processor, + pageSize = pageSize, + ) + } +} + +private class RustDynamicRoomList( + override val summaries: MutableSharedFlow>, + override val filteredSummaries: SharedFlow>, + override val loadingState: MutableStateFlow, + override val currentFilter: MutableStateFlow, + override val loadedPages: MutableStateFlow, + private val dynamicEvents: MutableSharedFlow, + private val processor: RoomSummaryListProcessor, + override val pageSize: Int, +) : DynamicRoomList { + override suspend fun rebuildSummaries() { + processor.rebuildRoomSummaries() + } + + override suspend fun updateFilter(filter: RoomListFilter) { + currentFilter.emit(filter) + } + + override suspend fun loadMore() { + dynamicEvents.emit(RoomListDynamicEvents.LoadMore) + loadedPages.getAndUpdate { it + 1 } + } + + override suspend fun reset() { + dynamicEvents.emit(RoomListDynamicEvents.Reset) + loadedPages.emit(1) + } +} + +private fun RoomListLoadingState.toLoadingState(): RoomList.LoadingState { + return when (this) { + is RoomListLoadingState.Loaded -> RoomList.LoadingState.Loaded(maximumNumberOfRooms?.toInt() ?: 0) + RoomListLoadingState.NotLoaded -> RoomList.LoadingState.NotLoaded + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilter.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilter.kt new file mode 100644 index 0000000..ed4d573 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilter.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomlist + +import io.element.android.libraries.core.extensions.withoutAccents +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter +import io.element.android.libraries.matrix.api.roomlist.RoomSummary + +val RoomListFilter.predicate + get() = when (this) { + is RoomListFilter.All -> { roomSummary -> NonSpacePredicate(roomSummary) || IsInvitedPredicate(roomSummary) } + is RoomListFilter.Any -> { roomSummary -> NonSpacePredicate(roomSummary) || IsInvitedPredicate(roomSummary) } + RoomListFilter.None -> { _ -> false } + RoomListFilter.Category.Group -> { roomSummary: RoomSummary -> + !roomSummary.info.isDm && NonInvitedPredicate(roomSummary) && NonSpacePredicate(roomSummary) + } + RoomListFilter.Category.People -> { roomSummary: RoomSummary -> + roomSummary.info.isDm && NonInvitedPredicate(roomSummary) && NonSpacePredicate(roomSummary) + } + RoomListFilter.Category.Space -> IsSpacePredicate + RoomListFilter.Favorite -> { roomSummary: RoomSummary -> + roomSummary.info.isFavorite && NonInvitedPredicate(roomSummary) && NonSpacePredicate(roomSummary) + } + RoomListFilter.Unread -> { roomSummary: RoomSummary -> + NonInvitedPredicate(roomSummary) && + NonSpacePredicate(roomSummary) && + (roomSummary.info.numUnreadNotifications > 0 || roomSummary.info.isMarkedUnread) + } + is RoomListFilter.NormalizedMatchRoomName -> { roomSummary: RoomSummary -> + roomSummary.info.name?.withoutAccents().orEmpty().contains(normalizedPattern, ignoreCase = true) && + (NonSpacePredicate(roomSummary) || IsInvitedPredicate(roomSummary)) + } + RoomListFilter.Invite -> IsInvitedPredicate + } + +fun List.filter(filter: RoomListFilter): List { + return when (filter) { + is RoomListFilter.All -> { + val predicates = if (filter.filters.isNotEmpty()) { + filter.filters.map { it.predicate } + } else { + listOf(filter.predicate) + } + filter { roomSummary -> predicates.all { it(roomSummary) } } + } + is RoomListFilter.Any -> { + val predicates = if (filter.filters.isNotEmpty()) { + filter.filters.map { it.predicate } + } else { + listOf(filter.predicate) + } + filter { roomSummary -> predicates.any { it(roomSummary) } } + } + else -> filter(filter.predicate) + } +} + +private val IsSpacePredicate = { roomSummary: RoomSummary -> roomSummary.info.isSpace } + +private val NonSpacePredicate = { roomSummary: RoomSummary -> !IsSpacePredicate(roomSummary) } + +private val IsInvitedPredicate = { roomSummary: RoomSummary -> roomSummary.info.currentUserMembership == CurrentUserMembership.INVITED } + +private val NonInvitedPredicate = { roomSummary: RoomSummary -> !IsInvitedPredicate(roomSummary) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryFactory.kt new file mode 100644 index 0000000..3d5efed --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryFactory.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomlist + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.roomlist.LatestEventValue +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.libraries.matrix.impl.room.RoomInfoMapper +import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper +import io.element.android.libraries.matrix.impl.timeline.item.event.map +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.use +import org.matrix.rustcomponents.sdk.LatestEventValue as RustLatestEventValue + +class RoomSummaryFactory( + private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper(), + private val roomInfoMapper: RoomInfoMapper = RoomInfoMapper(), +) { + suspend fun create(room: Room): RoomSummary { + val roomInfo = room.roomInfo().let(roomInfoMapper::map) + val latestEvent = room.newLatestEvent().use { event -> + when (event) { + is RustLatestEventValue.None -> LatestEventValue.None + is RustLatestEventValue.Local -> LatestEventValue.Local( + timestamp = event.timestamp.toLong(), + content = contentMapper.map(event.content), + isSending = event.isSending, + senderId = UserId(event.sender), + senderProfile = event.profile.map(), + ) + is RustLatestEventValue.Remote -> LatestEventValue.Remote( + timestamp = event.timestamp.toLong(), + content = contentMapper.map(event.content), + senderId = UserId(event.sender), + senderProfile = event.profile.map(), + isOwn = event.isOwn, + ) + } + } + return RoomSummary( + info = roomInfo, + latestEvent = latestEvent, + ) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt new file mode 100644 index 0000000..3f5a919 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomlist + +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate +import org.matrix.rustcomponents.sdk.RoomListServiceInterface +import org.matrix.rustcomponents.sdk.use +import timber.log.Timber +import kotlin.coroutines.CoroutineContext + +class RoomSummaryListProcessor( + private val roomSummaries: MutableSharedFlow>, + private val roomListService: RoomListServiceInterface, + private val coroutineContext: CoroutineContext, + private val roomSummaryFactory: RoomSummaryFactory, +) { + private val mutex = Mutex() + + suspend fun postUpdate(updates: List) { + updateRoomSummaries { + Timber.v("Update rooms from postUpdates (with ${updates.size} items) on ${Thread.currentThread()}") + updates.forEach { update -> + applyUpdate(update) + } + + // TODO remove once https://github.com/element-hq/element-x-android/issues/5031 has been confirmed as fixed + val duplicates = groupingBy { it.roomId }.eachCount().filter { it.value > 1 } + if (duplicates.isNotEmpty()) { + Timber.e("Found duplicates in room summaries after a list update from the SDK: $duplicates. Updates: $updates") + } + } + } + + suspend fun rebuildRoomSummaries() { + updateRoomSummaries { + forEachIndexed { i, summary -> + val result = buildRoomSummaryForIdentifier(summary.roomId.value) + if (result != null) { + this[i] = result + } + } + } + } + + private suspend fun MutableList.applyUpdate(update: RoomListEntriesUpdate) { + // Remove this comment to debug changes in the room list + // Timber.d("Apply room list update: ${update.describe()}") + 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() + } + is RoomListEntriesUpdate.Truncate -> { + subList(update.length.toInt(), size).clear() + } + } + } + + private suspend fun buildSummaryForRoomListEntry(entry: Room): RoomSummary { + return entry.use { roomSummaryFactory.create(room = it) } + } + + private suspend fun buildRoomSummaryForIdentifier(identifier: String): RoomSummary? { + return roomListService.roomOrNull(identifier)?.let { room -> + buildSummaryForRoomListEntry(room) + } + } + + private suspend fun updateRoomSummaries(block: suspend MutableList.() -> Unit) = withContext(coroutineContext) { + mutex.withLock { + val current = roomSummaries.replayCache.lastOrNull() + val mutableRoomSummaries = current.orEmpty().toMutableList() + block(mutableRoomSummaries) + roomSummaries.emit(mutableRoomSummaries) + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt new file mode 100644 index 0000000..9c462dd --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomlist + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList +import io.element.android.libraries.matrix.api.roomlist.RoomList +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally +import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +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.RoomListServiceSyncIndicator +import timber.log.Timber +import org.matrix.rustcomponents.sdk.RoomListService as InnerRustRoomListService + +private const val DEFAULT_PAGE_SIZE = 20 + +internal class RustRoomListService( + private val innerRoomListService: InnerRustRoomListService, + private val sessionDispatcher: CoroutineDispatcher, + private val roomListFactory: RoomListFactory, + private val roomSyncSubscriber: RoomSyncSubscriber, + sessionCoroutineScope: CoroutineScope, +) : RoomListService { + override fun createRoomList( + pageSize: Int, + initialFilter: RoomListFilter, + source: RoomList.Source + ): DynamicRoomList { + return roomListFactory.createRoomList( + pageSize = pageSize, + initialFilter = initialFilter, + coroutineContext = sessionDispatcher, + ) { + when (source) { + RoomList.Source.All -> innerRoomListService.allRooms() + } + } + } + + override suspend fun subscribeToVisibleRooms(roomIds: List) { + roomSyncSubscriber.batchSubscribe(roomIds) + } + + override val allRooms: DynamicRoomList = roomListFactory.createRoomList( + pageSize = DEFAULT_PAGE_SIZE, + coroutineContext = sessionDispatcher, + ) { + innerRoomListService.allRooms() + } + + init { + allRooms.loadAllIncrementally(sessionCoroutineScope) + } + + override val syncIndicator: StateFlow = + innerRoomListService.syncIndicator() + .map { it.toSyncIndicator() } + .onEach { syncIndicator -> + Timber.d("SyncIndicator = $syncIndicator") + } + .distinctUntilChanged() + .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, RoomListService.SyncIndicator.Hide) + + override val state: StateFlow = + innerRoomListService.stateFlow() + .map { it.toRoomListState() } + .onEach { state -> + Timber.d("RoomList state=$state") + } + .distinctUntilChanged() + .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, RoomListService.State.Idle) +} + +private fun RoomListServiceState.toRoomListState(): RoomListService.State { + return when (this) { + RoomListServiceState.INITIAL, + RoomListServiceState.RECOVERING, + RoomListServiceState.SETTING_UP -> RoomListService.State.Idle + RoomListServiceState.RUNNING -> RoomListService.State.Running + RoomListServiceState.ERROR -> RoomListService.State.Error + RoomListServiceState.TERMINATED -> RoomListService.State.Terminated + } +} + +private fun RoomListServiceSyncIndicator.toSyncIndicator(): RoomListService.SyncIndicator { + return when (this) { + RoomListServiceSyncIndicator.SHOW -> RoomListService.SyncIndicator.Show + RoomListServiceSyncIndicator.HIDE -> RoomListService.SyncIndicator.Hide + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/server/DefaultUserServerResolver.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/server/DefaultUserServerResolver.kt new file mode 100644 index 0000000..da21c03 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/server/DefaultUserServerResolver.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.server + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.server.UserServerResolver + +@ContributesBinding(SessionScope::class) +class DefaultUserServerResolver( + private val matrixClient: MatrixClient, +) : UserServerResolver { + override fun resolve(): String { + return matrixClient.userIdServerName() + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustLeaveSpaceHandle.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustLeaveSpaceHandle.kt new file mode 100644 index 0000000..10e329e --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustLeaveSpaceHandle.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.spaces + +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle +import io.element.android.libraries.matrix.api.spaces.LeaveSpaceRoom +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import timber.log.Timber +import org.matrix.rustcomponents.sdk.LeaveSpaceHandle as RustLeaveSpaceHandle + +class RustLeaveSpaceHandle( + override val id: RoomId, + private val spaceRoomMapper: SpaceRoomMapper, + private val roomMembershipObserver: RoomMembershipObserver, + sessionCoroutineScope: CoroutineScope, + private val innerProvider: suspend () -> RustLeaveSpaceHandle, +) : LeaveSpaceHandle { + private val inner = CompletableDeferred() + + init { + sessionCoroutineScope.launch { + inner.complete(innerProvider()) + } + } + + override suspend fun rooms(): Result> = runCatchingExceptions { + inner.await().rooms().map { leaveSpaceRoom -> + LeaveSpaceRoom( + spaceRoom = spaceRoomMapper.map(leaveSpaceRoom.spaceRoom), + isLastAdmin = leaveSpaceRoom.isLastAdmin, + ) + } + } + + override suspend fun leave(roomIds: List): Result = runCatchingExceptions { + // Ensure the space is included and is the last room to be left + val roomToLeave = roomIds - id + id + inner.await().leave(roomToLeave.map { it.value }) + }.onSuccess { + roomMembershipObserver.notifyUserLeftRoom( + roomId = id, + isSpace = true, + membershipBeforeLeft = CurrentUserMembership.JOINED, + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun close() { + Timber.d("Destroying LeaveSpaceHandle $id") + try { + inner.getCompleted().destroy() + } catch (_: Exception) { + // Ignore, we just want to make sure it's completed + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt new file mode 100644 index 0000000..c6fe867 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.spaces + +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber +import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState +import java.util.Optional +import org.matrix.rustcomponents.sdk.SpaceRoomList as InnerSpaceRoomList + +class RustSpaceRoomList( + override val roomId: RoomId, + private val innerProvider: suspend () -> InnerSpaceRoomList, + private val coroutineScope: CoroutineScope, + spaceRoomMapper: SpaceRoomMapper, +) : SpaceRoomList { + private val innerCompletable = CompletableDeferred() + + override val currentSpaceFlow = MutableStateFlow>(Optional.empty()) + + override val spaceRoomsFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = Int.MAX_VALUE) + + override val paginationStatusFlow: MutableStateFlow = + MutableStateFlow(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false)) + private val spaceListUpdateProcessor = SpaceListUpdateProcessor( + spaceRoomsFlow = spaceRoomsFlow, + mapper = spaceRoomMapper + ) + + init { + coroutineScope.launch { + val inner = innerProvider() + innerCompletable.complete(inner) + + inner.paginationStateFlow() + .onEach { paginationStatus -> + paginationStatusFlow.emit(paginationStatus.into()) + } + .launchIn(this) + + inner.spaceListUpdateFlow() + .onEach { updates -> + spaceListUpdateProcessor.postUpdates(updates) + } + .launchIn(this) + + inner.spaceUpdateFlow() + .map { space -> space.map(spaceRoomMapper::map) } + .onEach { space -> + currentSpaceFlow.emit(space) + } + .launchIn(this) + } + } + + override suspend fun paginate(): Result { + return runCatchingExceptions { + innerCompletable.await().paginate() + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun destroy() { + Timber.d("Destroying SpaceRoomList $roomId") + coroutineScope.cancel() + try { + innerCompletable.getCompleted().destroy() + } catch (_: Exception) { + // Ignore, we just want to make sure it's completed + } + } + + private fun SpaceRoomListPaginationState.into(): SpaceRoomList.PaginationStatus { + return when (this) { + is SpaceRoomListPaginationState.Idle -> SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = !endReached) + SpaceRoomListPaginationState.Loading -> SpaceRoomList.PaginationStatus.Loading + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt new file mode 100644 index 0000000..84da9f1 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.spaces + +import io.element.android.libraries.core.coroutine.childScope +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import io.element.android.libraries.matrix.api.spaces.SpaceService +import io.element.android.libraries.matrix.impl.util.cancelAndDestroy +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.SpaceListUpdate +import org.matrix.rustcomponents.sdk.SpaceServiceInterface +import org.matrix.rustcomponents.sdk.SpaceServiceJoinedSpacesListener +import timber.log.Timber +import org.matrix.rustcomponents.sdk.SpaceService as ClientSpaceService + +class RustSpaceService( + private val innerSpaceService: ClientSpaceService, + private val sessionCoroutineScope: CoroutineScope, + private val sessionDispatcher: CoroutineDispatcher, + private val roomMembershipObserver: RoomMembershipObserver, +) : SpaceService { + private val spaceRoomMapper = SpaceRoomMapper() + override val spaceRoomsFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1) + private val spaceListUpdateProcessor = SpaceListUpdateProcessor( + spaceRoomsFlow = spaceRoomsFlow, + mapper = spaceRoomMapper + ) + + override suspend fun joinedSpaces(): Result> = withContext(sessionDispatcher) { + runCatchingExceptions { + innerSpaceService.joinedSpaces() + .map { + it.let(spaceRoomMapper::map) + } + } + } + + override fun spaceRoomList(id: RoomId): SpaceRoomList { + val childCoroutineScope = sessionCoroutineScope.childScope(sessionDispatcher, "SpaceRoomListScope-$this") + return RustSpaceRoomList( + roomId = id, + innerProvider = { innerSpaceService.spaceRoomList(id.value) }, + coroutineScope = childCoroutineScope, + spaceRoomMapper = spaceRoomMapper, + ) + } + + override fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle { + return RustLeaveSpaceHandle( + id = spaceId, + spaceRoomMapper = spaceRoomMapper, + roomMembershipObserver = roomMembershipObserver, + sessionCoroutineScope = sessionCoroutineScope, + ) { + innerSpaceService.leaveSpace(spaceId.value) + } + } + + init { + innerSpaceService + .spaceListUpdate() + .onEach { updates -> + spaceListUpdateProcessor.postUpdates(updates) + } + .launchIn(sessionCoroutineScope) + } +} + +internal fun SpaceServiceInterface.spaceListUpdate(): Flow> = + callbackFlow { + val listener = object : SpaceServiceJoinedSpacesListener { + override fun onUpdate(roomUpdates: List) { + trySendBlocking(roomUpdates) + } + } + Timber.d("Open spaceDiffFlow for SpaceServiceInterface ${this@spaceListUpdate}") + val taskHandle = subscribeToJoinedSpaces(listener) + awaitClose { + Timber.d("Close spaceDiffFlow for SpaceServiceInterface ${this@spaceListUpdate}") + taskHandle.cancelAndDestroy() + } + }.catch { + Timber.d(it, "spaceDiffFlow() failed") + }.buffer(Channel.UNLIMITED) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt new file mode 100644 index 0000000..41f2cc1 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.spaces + +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.matrix.rustcomponents.sdk.SpaceListUpdate +import timber.log.Timber + +internal class SpaceListUpdateProcessor( + private val spaceRoomsFlow: MutableSharedFlow>, + private val mapper: SpaceRoomMapper, +) { + private val mutex = Mutex() + + suspend fun postUpdates(updates: List) { + Timber.v("Update space rooms from postUpdates (with ${updates.size} items) on ${Thread.currentThread()}") + updateSpaceRooms { + updates.forEach { update -> applyUpdate(update) } + } + } + + private suspend fun updateSpaceRooms(block: MutableList.() -> Unit) = + mutex.withLock { + val spaceRooms = if (spaceRoomsFlow.replayCache.isNotEmpty()) { + spaceRoomsFlow.first().toMutableList() + } else { + mutableListOf() + } + block(spaceRooms) + spaceRoomsFlow.emit(spaceRooms) + } + + private fun MutableList.applyUpdate(update: SpaceListUpdate) { + when (update) { + is SpaceListUpdate.Append -> { + val newSpaces = update.values.map(mapper::map) + addAll(newSpaces) + } + SpaceListUpdate.Clear -> clear() + is SpaceListUpdate.Insert -> { + val newSpace = mapper.map(update.value) + add(update.index.toInt(), newSpace) + } + SpaceListUpdate.PopBack -> { + removeAt(lastIndex) + } + SpaceListUpdate.PopFront -> { + removeAt(0) + } + is SpaceListUpdate.PushBack -> { + val newSpace = mapper.map(update.value) + add(newSpace) + } + is SpaceListUpdate.PushFront -> { + val newSpace = mapper.map(update.value) + add(0, newSpace) + } + is SpaceListUpdate.Remove -> { + removeAt(update.index.toInt()) + } + is SpaceListUpdate.Reset -> { + clear() + val newSpaces = update.values.map(mapper::map) + addAll(newSpaces) + } + is SpaceListUpdate.Set -> { + val newSpace = mapper.map(update.value) + this[update.index.toInt()] = newSpace + } + is SpaceListUpdate.Truncate -> { + subList(update.length.toInt(), size).clear() + } + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomListExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomListExtensions.kt new file mode 100644 index 0000000..62b65a4 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomListExtensions.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.spaces + +import io.element.android.libraries.matrix.impl.util.cancelAndDestroy +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch +import org.matrix.rustcomponents.sdk.SpaceListUpdate +import org.matrix.rustcomponents.sdk.SpaceRoom +import org.matrix.rustcomponents.sdk.SpaceRoomListEntriesListener +import org.matrix.rustcomponents.sdk.SpaceRoomListInterface +import org.matrix.rustcomponents.sdk.SpaceRoomListPaginationStateListener +import org.matrix.rustcomponents.sdk.SpaceRoomListSpaceListener +import timber.log.Timber +import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState +import java.util.Optional + +internal fun SpaceRoomListInterface.paginationStateFlow(): Flow = callbackFlow { + val listener = object : SpaceRoomListPaginationStateListener { + override fun onUpdate(paginationState: SpaceRoomListPaginationState) { + trySend(paginationState) + } + } + // Send the initial value + trySend(paginationState()) + // Then subscribe to updates + val result = subscribeToPaginationStateUpdates(listener) + awaitClose { + result.cancelAndDestroy() + } +}.catch { + Timber.d(it, "paginationStateFlow() failed") +}.buffer(Channel.UNLIMITED) + +internal fun SpaceRoomListInterface.spaceListUpdateFlow(): Flow> = + callbackFlow { + val listener = object : SpaceRoomListEntriesListener { + override fun onUpdate(rooms: List) { + trySendBlocking(rooms) + } + } + Timber.d("Open spaceListUpdateFlow for SpaceRoomListInterface ${this@spaceListUpdateFlow}") + val taskHandle = subscribeToRoomUpdate(listener) + awaitClose { + Timber.d("Close spaceListUpdateFlow for SpaceRoomListInterface ${this@spaceListUpdateFlow}") + taskHandle.cancelAndDestroy() + } + }.catch { + Timber.d(it, "spaceListUpdateFlow() failed") + }.buffer(Channel.UNLIMITED) + +internal fun SpaceRoomListInterface.spaceUpdateFlow(): Flow> = + callbackFlow { + val listener = object : SpaceRoomListSpaceListener { + override fun onUpdate(space: SpaceRoom?) { + trySendBlocking(Optional.ofNullable(space)) + } + } + Timber.d("Open spaceUpdateFlow for SpaceRoomListInterface ${this@spaceUpdateFlow}") + trySendBlocking(Optional.ofNullable(space())) + val taskHandle = subscribeToSpaceUpdates(listener) + awaitClose { + Timber.d("Close spaceUpdateFlow for SpaceRoomListInterface ${this@spaceUpdateFlow}") + taskHandle.cancelAndDestroy() + } + }.catch { + Timber.d(it, "spaceUpdateFlow() failed") + }.buffer(Channel.UNLIMITED) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomMapper.kt new file mode 100644 index 0000000..f83cd64 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceRoomMapper.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.spaces + +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.impl.room.join.map +import io.element.android.libraries.matrix.impl.room.map +import kotlinx.collections.immutable.toImmutableList +import org.matrix.rustcomponents.sdk.SpaceRoom as RustSpaceRoom + +class SpaceRoomMapper { + fun map(spaceRoom: RustSpaceRoom): SpaceRoom { + return SpaceRoom( + avatarUrl = spaceRoom.avatarUrl, + canonicalAlias = spaceRoom.canonicalAlias?.let(::RoomAlias), + childrenCount = spaceRoom.childrenCount.toInt(), + guestCanJoin = spaceRoom.guestCanJoin, + heroes = spaceRoom.heroes.orEmpty().map { it.map() }.toImmutableList(), + joinRule = spaceRoom.joinRule?.map(), + rawName = spaceRoom.rawName, + displayName = spaceRoom.displayName, + numJoinedMembers = spaceRoom.numJoinedMembers.toInt(), + roomId = RoomId(spaceRoom.roomId), + roomType = spaceRoom.roomType.map(), + state = spaceRoom.state?.map(), + topic = spaceRoom.topic, + worldReadable = spaceRoom.worldReadable.orFalse(), + via = spaceRoom.via.toImmutableList(), + isDirect = spaceRoom.isDirect, + ) + } +} 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 0000000..53ffb62 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/AppStateMapper.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.sync + +import io.element.android.libraries.matrix.api.sync.SyncState +import org.matrix.rustcomponents.sdk.SyncServiceState + +internal fun SyncServiceState.toSyncState(): SyncState { + return when (this) { + SyncServiceState.IDLE -> SyncState.Idle + SyncServiceState.RUNNING -> SyncState.Running + SyncServiceState.TERMINATED -> SyncState.Terminated + SyncServiceState.ERROR -> SyncState.Error + SyncServiceState.OFFLINE -> SyncState.Offline + } +} 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 0000000..f7732cd --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.sync + +import io.element.android.libraries.core.coroutine.mapState +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.sync.SyncState +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable +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 kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.SyncServiceState +import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean +import org.matrix.rustcomponents.sdk.SyncService as InnerSyncService + +class RustSyncService( + private val inner: InnerSyncService, + private val dispatcher: CoroutineDispatcher, + sessionCoroutineScope: CoroutineScope, +) : SyncService { + private val isServiceReady = AtomicBoolean(true) + + override suspend fun startSync() = withContext(dispatcher) { + runCatchingExceptions { + if (!isServiceReady.get()) { + Timber.d("Can't start sync: service is not ready") + return@runCatchingExceptions + } + Timber.i("Start sync") + inner.start() + }.onFailure { + Timber.d("Start sync failed: $it") + } + } + + override suspend fun stopSync() = withContext(dispatcher) { + runCatchingExceptions { + if (!isServiceReady.get()) { + Timber.d("Can't stop sync: service is not ready") + return@runCatchingExceptions + } + Timber.i("Stop sync") + inner.stop() + }.onFailure { + Timber.d("Stop sync failed: $it") + } + } + + suspend fun destroy() = withContext(NonCancellable) { + // If the service was still running, stop it + stopSync() + Timber.d("Destroying sync service") + isServiceReady.set(false) + inner.destroy() + } + + override val syncState: StateFlow = + inner.stateFlow() + .map(SyncServiceState::toSyncState) + .distinctUntilChanged() + .onEach { state -> + Timber.i("Sync state=$state") + } + .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle) + + override val isOnline: StateFlow = syncState.mapState { it != SyncState.Offline } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SlidingSyncVersion.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SlidingSyncVersion.kt new file mode 100644 index 0000000..8a1ed28 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SlidingSyncVersion.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.sync + +import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion +import org.matrix.rustcomponents.sdk.SlidingSyncVersion as RustSlidingSyncVersion + +internal fun RustSlidingSyncVersion.map(): SlidingSyncVersion { + return when (this) { + RustSlidingSyncVersion.NONE -> SlidingSyncVersion.None + RustSlidingSyncVersion.NATIVE -> SlidingSyncVersion.Native + } +} 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 0000000..3433257 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SyncServiceExtension.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.SyncServiceInterface +import org.matrix.rustcomponents.sdk.SyncServiceState +import org.matrix.rustcomponents.sdk.SyncServiceStateObserver + +fun SyncServiceInterface.stateFlow(): Flow = + 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/EventOrTransactionId.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/EventOrTransactionId.kt new file mode 100644 index 0000000..6752806 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/EventOrTransactionId.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline + +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +import org.matrix.rustcomponents.sdk.EventOrTransactionId as RustEventOrTransactionId + +fun EventOrTransactionId.toRustEventOrTransactionId() = when (this) { + is EventOrTransactionId.Event -> RustEventOrTransactionId.EventId(id.value) + is EventOrTransactionId.Transaction -> RustEventOrTransactionId.TransactionId(id.value) +} 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 0000000..c522016 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessor.kt @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline + +import androidx.compose.ui.util.fastForEach +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent +import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.matrix.rustcomponents.sdk.TimelineDiff +import org.matrix.rustcomponents.sdk.TimelineItem +import timber.log.Timber + +internal class MatrixTimelineDiffProcessor( + private val timelineItems: MutableSharedFlow>, + private val membershipChangeEventReceivedFlow: MutableSharedFlow, + private val syncedEventReceivedFlow: MutableSharedFlow, + private val timelineItemMapper: MatrixTimelineItemMapper, +) { + private val mutex = Mutex() + + suspend fun postDiffs(diffs: List) { + mutex.withLock { + Timber.v("Update timeline items from postDiffs (with ${diffs.size} items) on ${Thread.currentThread()}") + val result = processDiffs(diffs) + timelineItems.emit(result.items()) + if (result.hasNewEventsFromSync) { + syncedEventReceivedFlow.emit(Unit) + } + if (result.hasMembershipChangeEventFromSync) { + membershipChangeEventReceivedFlow.emit(Unit) + } + } + } + + private suspend fun processDiffs(diffs: List): DiffingResult { + val timelineItems = if (timelineItems.replayCache.isNotEmpty()) { + timelineItems.first() + } else { + emptyList() + } + val result = DiffingResult(timelineItems) + diffs.forEach { diff -> + result.applyDiff(diff) + } + return result + } + + private fun DiffingResult.applyDiff(diff: TimelineDiff) { + when (diff) { + is TimelineDiff.Append -> { + diff.values.fastForEach { item -> + add(item.map()) + } + } + is TimelineDiff.PushBack -> { + val item = diff.value.map() + add(item) + } + is TimelineDiff.PushFront -> { + val item = diff.value.map() + add(0, item) + } + is TimelineDiff.Set -> { + val item = diff.value.map() + set(diff.index.toInt(), item) + } + is TimelineDiff.Insert -> { + val item = diff.value.map() + add(diff.index.toInt(), item) + } + is TimelineDiff.Remove -> { + removeAt(diff.index.toInt()) + } + is TimelineDiff.Reset -> { + clear() + diff.values.fastForEach { item -> + add(item.map()) + } + } + TimelineDiff.PopFront -> { + removeFirst() + } + TimelineDiff.PopBack -> { + removeLast() + } + TimelineDiff.Clear -> { + clear() + } + is TimelineDiff.Truncate -> { + truncate(diff.length.toInt()) + } + } + } + + private fun TimelineItem.map(): MatrixTimelineItem { + return timelineItemMapper.map(this) + } +} + +private class DiffingResult(initialItems: List) { + private val items = initialItems.toMutableList() + var hasNewEventsFromSync: Boolean = false + private set + var hasMembershipChangeEventFromSync: Boolean = false + private set + + fun items(): List = items + + fun add(item: MatrixTimelineItem) { + processItem(item) + items.add(item) + } + + fun add(index: Int, item: MatrixTimelineItem) { + processItem(item) + items.add(index, item) + } + + fun set(index: Int, item: MatrixTimelineItem) { + processItem(item) + items[index] = item + } + + fun removeAt(index: Int) { + items.removeAt(index) + } + + fun removeFirst() { + items.removeFirstOrNull() + } + + fun removeLast() { + items.removeLastOrNull() + } + + fun truncate(length: Int) { + items.subList(length, items.size).clear() + } + + fun clear() { + items.clear() + } + + private fun processItem(item: MatrixTimelineItem) { + if (skipProcessing()) return + when (item) { + is MatrixTimelineItem.Event -> { + if (item.event.origin == TimelineItemEventOrigin.SYNC) { + hasNewEventsFromSync = true + when (item.event.content) { + is RoomMembershipContent -> hasMembershipChangeEventFromSync = true + else -> Unit + } + } + } + else -> Unit + } + } + + private fun skipProcessing(): Boolean { + return hasNewEventsFromSync && hasMembershipChangeEventFromSync + } +} 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 0000000..affaf24 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UniqueId +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, + private val coroutineScope: CoroutineScope, + private val virtualTimelineItemMapper: VirtualTimelineItemMapper, + private val eventTimelineItemMapper: EventTimelineItemMapper, +) { + fun map(timelineItem: TimelineItem): MatrixTimelineItem = timelineItem.use { + val uniqueId = UniqueId(timelineItem.uniqueId().id) + 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) = coroutineScope.launch { + fetchDetailsForEvent(eventId) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/ReceiptTypeMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/ReceiptTypeMapper.kt new file mode 100644 index 0000000..36591c1 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/ReceiptTypeMapper.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline + +import io.element.android.libraries.matrix.api.timeline.ReceiptType +import org.matrix.rustcomponents.sdk.ReceiptType as RustReceiptType + +internal fun ReceiptType.toRustReceiptType(): RustReceiptType = when (this) { + ReceiptType.READ -> RustReceiptType.READ + ReceiptType.READ_PRIVATE -> RustReceiptType.READ_PRIVATE + ReceiptType.FULLY_READ -> RustReceiptType.FULLY_READ +} 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 0000000..bc0e5ee --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline + +import io.element.android.libraries.matrix.impl.util.cancelAndDestroy +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch +import org.matrix.rustcomponents.sdk.PaginationStatusListener +import org.matrix.rustcomponents.sdk.TimelineDiff +import org.matrix.rustcomponents.sdk.TimelineInterface +import org.matrix.rustcomponents.sdk.TimelineListener +import timber.log.Timber +import uniffi.matrix_sdk.RoomPaginationStatus + +internal fun TimelineInterface.liveBackPaginationStatus(): Flow = callbackFlow { + val listener = object : PaginationStatusListener { + override fun onUpdate(status: RoomPaginationStatus) { + trySend(status) + } + } + val result = subscribeToBackPaginationStatus(listener) + awaitClose { + result.cancelAndDestroy() + } +}.catch { + Timber.d(it, "liveBackPaginationStatus() failed") +}.buffer(Channel.UNLIMITED) + +internal fun TimelineInterface.timelineDiffFlow(): Flow> = + callbackFlow { + val listener = object : TimelineListener { + override fun onUpdate(diff: List) { + trySendBlocking(diff) + } + } + Timber.d("Open timelineDiffFlow for TimelineInterface ${this@timelineDiffFlow}") + val taskHandle = addListener(listener) + awaitClose { + Timber.d("Close timelineDiffFlow for TimelineInterface ${this@timelineDiffFlow}") + taskHandle.cancelAndDestroy() + } + }.catch { + Timber.d(it, "timelineDiffFlow() failed") + }.buffer(Channel.UNLIMITED) + +internal suspend fun TimelineInterface.runWithTimelineListenerRegistered(action: suspend () -> Unit) { + val result = addListener(NoOpTimelineListener) + try { + action() + } finally { + result.cancelAndDestroy() + } +} + +private object NoOpTimelineListener : TimelineListener { + override fun onUpdate(diff: List) = Unit +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt new file mode 100644 index 0000000..391349b --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -0,0 +1,618 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline + +import io.element.android.libraries.androidutils.hash.hash +import io.element.android.libraries.core.extensions.runCatchingExceptions +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.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.MediaUploadHandler +import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.matrix.api.room.IntentionalMention +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.ReceiptType +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.TimelineException +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo +import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl +import io.element.android.libraries.matrix.impl.media.map +import io.element.android.libraries.matrix.impl.poll.toInner +import io.element.android.libraries.matrix.impl.room.RoomContentForwarder +import io.element.android.libraries.matrix.impl.room.location.toInner +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 io.element.android.libraries.matrix.impl.timeline.postprocessor.LastForwardIndicatorsPostProcessor +import io.element.android.libraries.matrix.impl.timeline.postprocessor.LoadingIndicatorsPostProcessor +import io.element.android.libraries.matrix.impl.timeline.postprocessor.RoomBeginningPostProcessor +import io.element.android.libraries.matrix.impl.timeline.postprocessor.TypingNotificationPostProcessor +import io.element.android.libraries.matrix.impl.timeline.reply.InReplyToMapper +import io.element.android.libraries.matrix.impl.util.MessageEventContent +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.EditedContent +import org.matrix.rustcomponents.sdk.FormattedBody +import org.matrix.rustcomponents.sdk.MessageFormat +import org.matrix.rustcomponents.sdk.PollData +import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle +import org.matrix.rustcomponents.sdk.UploadParameters +import org.matrix.rustcomponents.sdk.UploadSource +import org.matrix.rustcomponents.sdk.use +import timber.log.Timber +import uniffi.matrix_sdk.RoomPaginationStatus +import java.io.File +import org.matrix.rustcomponents.sdk.EventOrTransactionId as RustEventOrTransactionId +import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline + +private const val PAGINATION_SIZE = 50 + +class RustTimeline( + private val inner: InnerTimeline, + override val mode: Timeline.Mode, + private val systemClock: SystemClock, + private val joinedRoom: JoinedRoom, + private val coroutineScope: CoroutineScope, + private val dispatcher: CoroutineDispatcher, + private val roomContentForwarder: RoomContentForwarder, +) : Timeline { + private val _timelineItems: MutableSharedFlow> = + MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE) + + private val _membershipChangeEventReceived = MutableSharedFlow(extraBufferCapacity = 1) + private val _onSyncedEventReceived: MutableSharedFlow = MutableSharedFlow(extraBufferCapacity = 1) + + private val timelineEventContentMapper = TimelineEventContentMapper() + private val inReplyToMapper = InReplyToMapper(timelineEventContentMapper) + private val timelineItemMapper = MatrixTimelineItemMapper( + fetchDetailsForEvent = this::fetchDetailsForEvent, + coroutineScope = coroutineScope, + virtualTimelineItemMapper = VirtualTimelineItemMapper(), + eventTimelineItemMapper = EventTimelineItemMapper( + contentMapper = timelineEventContentMapper + ), + ) + private val timelineDiffProcessor = MatrixTimelineDiffProcessor( + timelineItems = _timelineItems, + membershipChangeEventReceivedFlow = _membershipChangeEventReceived, + syncedEventReceivedFlow = _onSyncedEventReceived, + timelineItemMapper = timelineItemMapper, + ) + private val timelineItemsSubscriber = TimelineItemsSubscriber( + timeline = inner, + timelineCoroutineScope = coroutineScope, + timelineDiffProcessor = timelineDiffProcessor, + dispatcher = dispatcher, + ) + + private val roomBeginningPostProcessor = RoomBeginningPostProcessor(mode) + private val loadingIndicatorsPostProcessor = LoadingIndicatorsPostProcessor(systemClock) + private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(mode) + private val typingNotificationPostProcessor = TypingNotificationPostProcessor(mode) + + override val backwardPaginationStatus = MutableStateFlow( + Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode != Timeline.Mode.PinnedEvents) + ) + + override val forwardPaginationStatus = MutableStateFlow( + Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode is Timeline.Mode.FocusedOnEvent) + ) + + init { + when (mode) { + is Timeline.Mode.Live, is Timeline.Mode.FocusedOnEvent -> coroutineScope.fetchMembers() + else -> Unit + } + + if (mode == Timeline.Mode.Live) { + // When timeline is live, we need to listen to the back pagination status as + // sdk can automatically paginate backwards. + coroutineScope.registerBackPaginationStatusListener() + } + } + + private fun CoroutineScope.registerBackPaginationStatusListener() { + inner.liveBackPaginationStatus() + .onEach { backPaginationStatus -> + updatePaginationStatus(Timeline.PaginationDirection.BACKWARDS) { + when (backPaginationStatus) { + is RoomPaginationStatus.Idle -> it.copy(isPaginating = false, hasMoreToLoad = !backPaginationStatus.hitTimelineStart) + is RoomPaginationStatus.Paginating -> it.copy(isPaginating = true, hasMoreToLoad = true) + } + } + } + .launchIn(this) + } + + override val membershipChangeEventReceived: Flow = _membershipChangeEventReceived + .onStart { timelineItemsSubscriber.subscribeIfNeeded() } + .onCompletion { timelineItemsSubscriber.unsubscribeIfNeeded() } + + override val onSyncedEventReceived: Flow = _onSyncedEventReceived + .onStart { timelineItemsSubscriber.subscribeIfNeeded() } + .onCompletion { timelineItemsSubscriber.unsubscribeIfNeeded() } + + override suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result = withContext(dispatcher) { + runCatchingExceptions { + inner.sendReadReceipt(receiptType.toRustReceiptType(), eventId.value) + } + } + + override suspend fun markAsRead(receiptType: ReceiptType): Result = withContext(dispatcher) { + runCatchingExceptions { + inner.markAsRead(receiptType.toRustReceiptType()) + } + } + + private fun updatePaginationStatus(direction: Timeline.PaginationDirection, update: (Timeline.PaginationStatus) -> Timeline.PaginationStatus) { + when (direction) { + Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus.getAndUpdate(update) + Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.getAndUpdate(update) + } + } + + // Use NonCancellable to avoid breaking the timeline when the coroutine is cancelled. + override suspend fun paginate(direction: Timeline.PaginationDirection): Result = withContext(NonCancellable) { + withContext(dispatcher) { + runCatchingExceptions { + if (!canPaginate(direction)) throw TimelineException.CannotPaginate + updatePaginationStatus(direction) { it.copy(isPaginating = true) } + when (direction) { + Timeline.PaginationDirection.BACKWARDS -> inner.paginateBackwards(PAGINATION_SIZE.toUShort()) + Timeline.PaginationDirection.FORWARDS -> inner.paginateForwards(PAGINATION_SIZE.toUShort()) + } + }.onFailure { error -> + if (error is TimelineException.CannotPaginate) { + Timber.d("Can't paginate $direction on room ${joinedRoom.roomId} with paginationStatus: ${backwardPaginationStatus.value}") + } else { + updatePaginationStatus(direction) { it.copy(isPaginating = false) } + Timber.e(error, "Error paginating $direction on room ${joinedRoom.roomId}") + } + }.onSuccess { hasReachedEnd -> + updatePaginationStatus(direction) { it.copy(isPaginating = false, hasMoreToLoad = !hasReachedEnd) } + } + } + } + + private fun canPaginate(direction: Timeline.PaginationDirection): Boolean { + return when (direction) { + Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus.value.canPaginate + Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.value.canPaginate + } + } + + override val timelineItems: Flow> = combine( + _timelineItems, + backwardPaginationStatus, + forwardPaginationStatus, + joinedRoom.roomInfoFlow.map { it.creators to it.isDm }.distinctUntilChanged(), + ) { + timelineItems, + backwardPaginationStatus, + forwardPaginationStatus, + (roomCreators, isDm), + -> + withContext(dispatcher) { + timelineItems + .let { items -> + roomBeginningPostProcessor.process( + items = items, + isDm = isDm, + roomCreator = roomCreators.firstOrNull(), + hasMoreToLoadBackwards = backwardPaginationStatus.hasMoreToLoad, + ) + } + .let { items -> + loadingIndicatorsPostProcessor.process( + items = items, + hasMoreToLoadBackward = backwardPaginationStatus.hasMoreToLoad, + hasMoreToLoadForward = forwardPaginationStatus.hasMoreToLoad, + ) + } + .let { items -> + typingNotificationPostProcessor.process(items = items) + } + // Keep lastForwardIndicatorsPostProcessor last + .let { items -> + lastForwardIndicatorsPostProcessor.process(items = items) + } + } + }.onStart { + timelineItemsSubscriber.subscribeIfNeeded() + }.onCompletion { + timelineItemsSubscriber.unsubscribeIfNeeded() + } + + override fun close() { + coroutineScope.cancel() + inner.close() + } + + private fun CoroutineScope.fetchMembers() = launch(dispatcher) { + try { + inner.fetchMembers() + } catch (exception: Exception) { + Timber.e(exception, "Error fetching members for room ${joinedRoom.roomId}") + } + } + + override suspend fun sendMessage( + body: String, + htmlBody: String?, + intentionalMentions: List, + ): Result = withContext(dispatcher) { + MessageEventContent.from(body, htmlBody, intentionalMentions).use { content -> + runCatchingExceptions { + inner.send(content) + } + } + } + + override suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result = withContext(dispatcher) { + runCatchingExceptions { + inner.redactEvent( + eventOrTransactionId = eventOrTransactionId.toRustEventOrTransactionId(), + reason = reason, + ) + } + } + + override suspend fun editMessage( + eventOrTransactionId: EventOrTransactionId, + body: String, + htmlBody: String?, + intentionalMentions: List, + ): Result = withContext(dispatcher) { + runCatchingExceptions { + val editedContent = EditedContent.RoomMessage( + content = MessageEventContent.from( + body = body, + htmlBody = htmlBody, + intentionalMentions = intentionalMentions + ), + ) + inner.edit( + newContent = editedContent, + eventOrTransactionId = eventOrTransactionId.toRustEventOrTransactionId(), + ) + } + } + + override suspend fun editCaption( + eventOrTransactionId: EventOrTransactionId, + caption: String?, + formattedCaption: String?, + ): Result = withContext(dispatcher) { + runCatchingExceptions { + val editedContent = EditedContent.MediaCaption( + caption = caption, + formattedCaption = formattedCaption?.let { + FormattedBody(body = it, format = MessageFormat.Html) + }, + mentions = null, + ) + withContext(Dispatchers.IO) { + inner.edit( + newContent = editedContent, + eventOrTransactionId = eventOrTransactionId.toRustEventOrTransactionId(), + ) + } + } + } + + override suspend fun replyMessage( + repliedToEventId: EventId, + body: String, + htmlBody: String?, + intentionalMentions: List, + fromNotification: Boolean, + ): Result = withContext(dispatcher) { + runCatchingExceptions { + val msg = MessageEventContent.from(body, htmlBody, intentionalMentions) + inner.sendReply( + msg = msg, + eventId = repliedToEventId.value, + ) + } + } + + override suspend fun sendImage( + file: File, + thumbnailFile: File?, + imageInfo: ImageInfo, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result { + Timber.d("Sending image ${file.path.hash()}") + return sendAttachment(listOfNotNull(file, thumbnailFile)) { + inner.sendImage( + params = UploadParameters( + source = UploadSource.File(file.path), + caption = caption, + formattedCaption = formattedCaption?.let { + FormattedBody(body = it, format = MessageFormat.Html) + }, + mentions = null, + inReplyTo = inReplyToEventId?.value, + ), + thumbnailSource = thumbnailFile?.path?.let(UploadSource::File), + imageInfo = imageInfo.map(), + ) + } + } + + override suspend fun sendVideo( + file: File, + thumbnailFile: File?, + videoInfo: VideoInfo, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result { + Timber.d("Sending video ${file.path.hash()}") + return sendAttachment(listOfNotNull(file, thumbnailFile)) { + inner.sendVideo( + params = UploadParameters( + source = UploadSource.File(file.path), + caption = caption, + formattedCaption = formattedCaption?.let { + FormattedBody(body = it, format = MessageFormat.Html) + }, + mentions = null, + inReplyTo = inReplyToEventId?.value, + ), + thumbnailSource = thumbnailFile?.path?.let(UploadSource::File), + videoInfo = videoInfo.map(), + ) + } + } + + override suspend fun sendAudio( + file: File, + audioInfo: AudioInfo, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result { + Timber.d("Sending audio ${file.path.hash()}") + return sendAttachment(listOf(file)) { + inner.sendAudio( + params = UploadParameters( + source = UploadSource.File(file.path), + caption = caption, + formattedCaption = formattedCaption?.let { + FormattedBody(body = it, format = MessageFormat.Html) + }, + mentions = null, + inReplyTo = inReplyToEventId?.value, + ), + audioInfo = audioInfo.map(), + ) + } + } + + override suspend fun sendFile( + file: File, + fileInfo: FileInfo, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result { + Timber.d("Sending file ${file.path.hash()}") + return sendAttachment(listOf(file)) { + inner.sendFile( + params = UploadParameters( + source = UploadSource.File(file.path), + caption = caption, + formattedCaption = formattedCaption?.let { + FormattedBody(body = it, format = MessageFormat.Html) + }, + mentions = null, + inReplyTo = inReplyToEventId?.value, + ), + fileInfo = fileInfo.map(), + ) + } + } + + override suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result = withContext(dispatcher) { + runCatchingExceptions { + inner.toggleReaction( + key = emoji, + itemId = eventOrTransactionId.toRustEventOrTransactionId(), + ) + } + } + + override suspend fun forwardEvent(eventId: EventId, roomIds: List): Result = withContext(dispatcher) { + runCatchingExceptions { + roomContentForwarder.forward(fromTimeline = inner, eventId = eventId, toRoomIds = roomIds) + }.onFailure { + Timber.e(it) + } + } + + override suspend fun sendLocation( + body: String, + geoUri: String, + description: String?, + zoomLevel: Int?, + assetType: AssetType?, + inReplyToEventId: EventId?, + ): Result = withContext(dispatcher) { + runCatchingExceptions { + inner.sendLocation( + body = body, + geoUri = geoUri, + description = description, + zoomLevel = zoomLevel?.toUByte(), + assetType = assetType?.toInner(), + repliedToEventId = inReplyToEventId?.value, + ) + } + } + + override suspend fun sendVoiceMessage( + file: File, + audioInfo: AudioInfo, + waveform: List, + inReplyToEventId: EventId?, + ): Result { + return sendAttachment(listOf(file)) { + inner.sendVoiceMessage( + params = UploadParameters( + source = UploadSource.File(file.path), + // Maybe allow a caption in the future? + caption = null, + formattedCaption = null, + mentions = null, + inReplyTo = inReplyToEventId?.value, + ), + audioInfo = audioInfo.map(), + waveform = waveform, + ) + } + } + + override suspend fun createPoll( + question: String, + answers: List, + maxSelections: Int, + pollKind: PollKind, + ): Result = withContext(dispatcher) { + runCatchingExceptions { + inner.createPoll( + question = question, + answers = answers, + maxSelections = maxSelections.toUByte(), + pollKind = pollKind.toInner(), + ) + } + } + + override suspend fun editPoll( + pollStartId: EventId, + question: String, + answers: List, + maxSelections: Int, + pollKind: PollKind, + ): Result = withContext(dispatcher) { + runCatchingExceptions { + val editedContent = EditedContent.PollStart( + pollData = PollData( + question = question, + answers = answers, + maxSelections = maxSelections.toUByte(), + pollKind = pollKind.toInner(), + ), + ) + inner.edit( + newContent = editedContent, + eventOrTransactionId = RustEventOrTransactionId.EventId(pollStartId.value), + ) + } + } + + override suspend fun sendPollResponse( + pollStartId: EventId, + answers: List + ): Result = withContext(dispatcher) { + runCatchingExceptions { + inner.sendPollResponse( + pollStartEventId = pollStartId.value, + answers = answers, + ) + } + } + + override suspend fun endPoll( + pollStartId: EventId, + text: String + ): Result = withContext(dispatcher) { + runCatchingExceptions { + inner.endPoll( + pollStartEventId = pollStartId.value, + text = text, + ) + } + } + + private fun sendAttachment(files: List, handle: () -> SendAttachmentJoinHandle): Result { + return runCatchingExceptions { + MediaUploadHandlerImpl(files, handle()) + } + } + + override suspend fun loadReplyDetails(eventId: EventId): InReplyTo = withContext(dispatcher) { + val timelineItem = _timelineItems.first().firstOrNull { timelineItem -> + timelineItem is MatrixTimelineItem.Event && timelineItem.eventId == eventId + } as? MatrixTimelineItem.Event + + if (timelineItem != null) { + InReplyTo.Ready( + eventId = eventId, + content = timelineItem.event.content, + senderId = timelineItem.event.sender, + senderProfile = timelineItem.event.senderProfile, + ) + } else { + inner.loadReplyDetails(eventId.value).use(inReplyToMapper::map) + } + } + + override suspend fun pinEvent(eventId: EventId): Result = withContext(dispatcher) { + runCatchingExceptions { + inner.pinEvent(eventId = eventId.value) + } + } + + override suspend fun unpinEvent(eventId: EventId): Result = withContext(dispatcher) { + runCatchingExceptions { + inner.unpinEvent(eventId = eventId.value) + } + } + + override suspend fun getLatestEventId(): Result = withContext(dispatcher) { + runCatchingExceptions { + inner.latestEventId()?.let(::EventId) + } + } + + private suspend fun fetchDetailsForEvent(eventId: EventId): Result = withContext(dispatcher) { + runCatchingExceptions { + inner.fetchDetailsForEvent(eventId.value) + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriber.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriber.kt new file mode 100644 index 0000000..adf9102 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriber.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline + +import io.element.android.libraries.core.coroutine.childScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.matrix.rustcomponents.sdk.Timeline + +/** + * This class is responsible for subscribing to a timeline and post the items/diffs to the timelineDiffProcessor. + * It will also trigger a callback when a new synced event is received. + */ +internal class TimelineItemsSubscriber( + timelineCoroutineScope: CoroutineScope, + dispatcher: CoroutineDispatcher, + private val timeline: Timeline, + private val timelineDiffProcessor: MatrixTimelineDiffProcessor, +) { + private var subscriptionCount = 0 + private val mutex = Mutex() + + private val coroutineScope = timelineCoroutineScope.childScope(dispatcher, "TimelineItemsSubscriber") + + /** + * Add a subscription to the timeline and start posting items/diffs to the timelineDiffProcessor. + * It will also trigger a callback when a new synced event is received. + */ + suspend fun subscribeIfNeeded() = mutex.withLock { + if (subscriptionCount == 0) { + timeline.timelineDiffFlow() + .onEach { diffs -> + timelineDiffProcessor.postDiffs(diffs) + } + .launchIn(coroutineScope) + } + subscriptionCount++ + } + + /** + * Remove a subscription to the timeline and unsubscribe if needed. + * The timeline will be unsubscribed when the last subscription is removed. + * If the timelineCoroutineScope is cancelled, the timeline will be unsubscribed automatically. + */ + suspend fun unsubscribeIfNeeded() = mutex.withLock { + when (subscriptionCount) { + 0 -> return@withLock + 1 -> { + coroutineScope.coroutineContext.cancelChildren() + } + } + subscriptionCount-- + } +} 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 0000000..813bf0e --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline.item.event + +import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo +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.OtherMessageType +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.api.timeline.item.event.VoiceMessageType +import io.element.android.libraries.matrix.impl.media.map +import io.element.android.libraries.matrix.impl.timeline.reply.InReplyToMapper +import org.matrix.rustcomponents.sdk.InReplyToDetails +import org.matrix.rustcomponents.sdk.MessageType +import org.matrix.rustcomponents.sdk.MsgLikeKind +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 + +// https://github.com/Johennes/matrix-spec-proposals/blob/johannes/msgtype-galleries/proposals/4274-inline-media-galleries.md#unstable-prefix +private const val MSG_TYPE_GALLERY_UNSTABLE = "dm.filament.gallery" + +class EventMessageMapper { + private val inReplyToMapper by lazy { InReplyToMapper(TimelineEventContentMapper()) } + + fun map(message: MsgLikeKind.Message, inReplyTo: InReplyToDetails?, threadInfo: EventThreadInfo?): MessageContent = message.use { + val type = it.content.msgType.use(this::mapMessageType) + val inReplyToEvent: InReplyTo? = inReplyTo?.use(inReplyToMapper::map) + MessageContent( + body = it.content.body, + inReplyTo = inReplyToEvent, + isEdited = it.content.isEdited, + threadInfo = threadInfo, + type = type + ) + } + + fun mapMessageType(type: RustMessageType) = when (type) { + is RustMessageType.Audio -> { + when (type.content.voice) { + null -> { + AudioMessageType( + filename = type.content.filename, + caption = type.content.caption, + formattedCaption = type.content.formattedCaption?.map(), + source = type.content.source.map(), + info = type.content.info?.map(), + ) + } + else -> { + VoiceMessageType( + filename = type.content.filename, + caption = type.content.caption, + formattedCaption = type.content.formattedCaption?.map(), + source = type.content.source.map(), + info = type.content.info?.map(), + details = type.content.audio?.map(), + ) + } + } + } + is RustMessageType.File -> { + FileMessageType( + filename = type.content.filename, + caption = type.content.caption, + formattedCaption = type.content.formattedCaption?.map(), + source = type.content.source.map(), + info = type.content.info?.map(), + ) + } + is RustMessageType.Image -> { + ImageMessageType( + filename = type.content.filename, + caption = type.content.caption, + formattedCaption = type.content.formattedCaption?.map(), + source = type.content.source.map(), + info = 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( + filename = type.content.filename, + caption = type.content.caption, + formattedCaption = type.content.formattedCaption?.map(), + source = type.content.source.map(), + info = type.content.info?.map(), + ) + } + is RustMessageType.Location -> { + LocationMessageType(type.content.body, type.content.geoUri, type.content.description) + } + is MessageType.Other -> { + OtherMessageType(type.msgtype, type.body) + } + is MessageType.Gallery -> { + // TODO expose the GalleryType. + OtherMessageType(MSG_TYPE_GALLERY_UNSTABLE, type.content.body) + } + } +} + +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/EventOrTransactionIdExtension.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventOrTransactionIdExtension.kt new file mode 100644 index 0000000..211e2ce --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventOrTransactionIdExtension.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.timeline.item.event.EventOrTransactionId +import org.matrix.rustcomponents.sdk.EventOrTransactionId as RustEventOrTransactionId + +fun RustEventOrTransactionId.map(): EventOrTransactionId = when (this) { + is RustEventOrTransactionId.EventId -> EventOrTransactionId.Event(EventId(eventId)) + is RustEventOrTransactionId.TransactionId -> EventOrTransactionId.Transaction(TransactionId(transactionId)) +} 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 0000000..6715169 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline.item.event + +import io.element.android.libraries.matrix.api.core.DeviceId +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.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.MessageShield +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender +import io.element.android.libraries.matrix.api.timeline.item.event.Receipt +import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin +import io.element.android.libraries.matrix.impl.core.RustSendHandle +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import org.matrix.rustcomponents.sdk.EventOrTransactionId +import org.matrix.rustcomponents.sdk.QueueWedgeError +import org.matrix.rustcomponents.sdk.Reaction +import org.matrix.rustcomponents.sdk.ShieldState +import org.matrix.rustcomponents.sdk.TimelineItemContent +import uniffi.matrix_sdk_common.ShieldStateCode +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 +import org.matrix.rustcomponents.sdk.Receipt as RustReceipt +import uniffi.matrix_sdk_ui.EventItemOrigin as RustEventItemOrigin + +class EventTimelineItemMapper( + private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper(), +) { + fun map(eventTimelineItem: RustEventTimelineItem): EventTimelineItem = eventTimelineItem.run { + EventTimelineItem( + eventId = eventOrTransactionId.eventId(), + transactionId = eventOrTransactionId.transactionId(), + isEditable = isEditable, + canBeRepliedTo = canBeRepliedTo, + isOwn = isOwn, + isRemote = isRemote, + localSendState = localSendState?.map(), + reactions = (content as? TimelineItemContent.MsgLike)?.content?.reactions.map(), + receipts = readReceipts.map(), + sender = UserId(sender), + senderProfile = senderProfile.map(), + timestamp = timestamp.toLong(), + content = contentMapper.map(content), + origin = origin?.map(), + timelineItemDebugInfoProvider = { lazyProvider.debugInfo().map() }, + messageShieldProvider = { strict -> lazyProvider.getShields(strict)?.map() }, + sendHandleProvider = { lazyProvider.getSendHandle()?.let(::RustSendHandle) } + ) + } +} + +fun RustProfileDetails.map(): ProfileDetails { + return when (this) { + RustProfileDetails.Pending -> ProfileDetails.Pending + RustProfileDetails.Unavailable -> ProfileDetails.Unavailable + is RustProfileDetails.Error -> ProfileDetails.Error(message) + is RustProfileDetails.Ready -> ProfileDetails.Ready( + displayName = displayName, + displayNameAmbiguous = displayNameAmbiguous, + avatarUrl = avatarUrl + ) + } +} + +fun RustEventSendState?.map(): LocalEventSendState? { + return when (this) { + null -> null + is RustEventSendState.NotSentYet -> { + val mediaUploadProgress = this.progress + if (mediaUploadProgress != null) { + LocalEventSendState.Sending.MediaWithProgress( + index = mediaUploadProgress.index.toLong(), + progress = mediaUploadProgress.progress.current.toLong(), + total = mediaUploadProgress.progress.total.toLong(), + ) + } else { + LocalEventSendState.Sending.Event + } + } + is RustEventSendState.SendingFailed -> { + when (val queueWedgeError = error) { + QueueWedgeError.CrossVerificationRequired -> { + // The current device is not cross-signed (or cross signing is not setup) + LocalEventSendState.Failed.SendingFromUnverifiedDevice + } + is QueueWedgeError.IdentityViolations -> { + LocalEventSendState.Failed.VerifiedUserChangedIdentity(queueWedgeError.users.map { UserId(it) }) + } + is QueueWedgeError.InsecureDevices -> { + LocalEventSendState.Failed.VerifiedUserHasUnsignedDevice( + devices = queueWedgeError.userDeviceMap.entries.associate { entry -> + UserId(entry.key) to entry.value.map { DeviceId(it) } + } + ) + } + is QueueWedgeError.GenericApiError -> { + if (isRecoverable) { + LocalEventSendState.Sending.Event + } else { + LocalEventSendState.Failed.Unknown(queueWedgeError.msg) + } + } + is QueueWedgeError.InvalidMimeType -> { + LocalEventSendState.Failed.InvalidMimeType(queueWedgeError.mimeType) + } + is QueueWedgeError.MissingMediaContent -> { + LocalEventSendState.Failed.MissingMediaContent + } + } + } + is RustEventSendState.Sent -> LocalEventSendState.Sent(EventId(eventId)) + } +} + +private fun List?.map(): ImmutableList { + return this?.map { + EventReaction( + key = it.key, + senders = it.senders.map { sender -> + ReactionSender( + senderId = UserId(sender.senderId), + timestamp = sender.timestamp.toLong() + ) + }.toImmutableList() + ) + }?.toImmutableList() ?: persistentListOf() +} + +private fun Map.map(): ImmutableList { + return map { + Receipt( + userId = UserId(it.key), + timestamp = it.value.timestamp?.toLong() ?: 0 + ) + } + .sortedByDescending { it.timestamp } + .toImmutableList() +} + +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 + RustEventItemOrigin.CACHE -> TimelineItemEventOrigin.CACHE + } +} + +private fun ShieldState?.map(): MessageShield? { + this ?: return null + val shieldStateCode = when (this) { + is ShieldState.Grey -> code + is ShieldState.Red -> code + ShieldState.None -> null + } ?: return null + val isCritical = when (this) { + ShieldState.None, + is ShieldState.Grey -> false + is ShieldState.Red -> true + } + return when (shieldStateCode) { + ShieldStateCode.AUTHENTICITY_NOT_GUARANTEED -> MessageShield.AuthenticityNotGuaranteed(isCritical) + ShieldStateCode.UNKNOWN_DEVICE -> MessageShield.UnknownDevice(isCritical) + ShieldStateCode.UNSIGNED_DEVICE -> MessageShield.UnsignedDevice(isCritical) + ShieldStateCode.UNVERIFIED_IDENTITY -> MessageShield.UnverifiedIdentity(isCritical) + ShieldStateCode.SENT_IN_CLEAR -> MessageShield.SentInClear(isCritical) + ShieldStateCode.VERIFICATION_VIOLATION -> MessageShield.VerificationViolation(isCritical) + ShieldStateCode.MISMATCHED_SENDER -> MessageShield.MismatchedSender(isCritical) + } +} + +private fun EventOrTransactionId.eventId(): EventId? { + return (this as? EventOrTransactionId.EventId)?.let { EventId(it.eventId) } +} + +private fun EventOrTransactionId.transactionId(): TransactionId? { + return (this as? EventOrTransactionId.TransactionId)?.let { TransactionId(it.transactionId) } +} 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 0000000..d0545d3 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline.item.event + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.EmbeddedEventInfo +import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo +import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary +import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent +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.LegacyCallInviteContent +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.PollContent +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.api.timeline.item.event.UtdCause +import io.element.android.libraries.matrix.impl.media.map +import io.element.android.libraries.matrix.impl.poll.map +import io.element.android.libraries.matrix.impl.room.join.map +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap +import org.matrix.rustcomponents.sdk.EmbeddedEventDetails +import org.matrix.rustcomponents.sdk.MsgLikeKind +import org.matrix.rustcomponents.sdk.TimelineItemContent +import org.matrix.rustcomponents.sdk.use +import uniffi.matrix_sdk_ui.RoomPinnedEventsChange +import org.matrix.rustcomponents.sdk.EncryptedMessage as RustEncryptedMessage +import org.matrix.rustcomponents.sdk.MembershipChange as RustMembershipChange +import org.matrix.rustcomponents.sdk.OtherState as RustOtherState +import uniffi.matrix_sdk_crypto.UtdCause as RustUtdCause + +class TimelineEventContentMapper( + private val eventMessageMapper: EventMessageMapper = EventMessageMapper(), +) { + fun map(content: TimelineItemContent): EventContent { + return content.use { + when (it) { + is TimelineItemContent.FailedToParseMessageLike -> { + FailedToParseMessageLikeContent( + eventType = it.eventType, + error = it.error + ) + } + is TimelineItemContent.FailedToParseState -> { + FailedToParseStateContent( + eventType = it.eventType, + stateKey = it.stateKey, + error = it.error + ) + } + is TimelineItemContent.MsgLike -> { + when (val kind = it.content.kind) { + is MsgLikeKind.Message -> { + val inReplyTo = it.content.inReplyTo + val threadSummary = it.content.threadSummary?.use { summary -> + val numberOfReplies = summary.numReplies().toLong() + val latestEvent = summary.latestEvent() + val details = when (latestEvent) { + is EmbeddedEventDetails.Unavailable -> AsyncData.Uninitialized + is EmbeddedEventDetails.Pending -> AsyncData.Loading() + is EmbeddedEventDetails.Error -> AsyncData.Failure(IllegalStateException(latestEvent.message)) + is EmbeddedEventDetails.Ready -> { + AsyncData.Success( + EmbeddedEventInfo( + eventOrTransactionId = latestEvent.eventOrTransactionId.map(), + content = map(latestEvent.content), + senderId = UserId(latestEvent.sender), + senderProfile = latestEvent.senderProfile.map(), + timestamp = latestEvent.timestamp.toLong(), + ) + ) + } + } + ThreadSummary( + latestEvent = details, + numberOfReplies = numberOfReplies, + ) + } + val threadRootId = it.content.threadRoot?.let(::ThreadId) + val threadInfo = when { + threadSummary != null -> EventThreadInfo.ThreadRoot(threadSummary) + threadRootId != null -> EventThreadInfo.ThreadResponse(threadRootId) + else -> null + } + eventMessageMapper.map(kind, inReplyTo, threadInfo) + } + is MsgLikeKind.Redacted -> { + RedactedContent + } + is MsgLikeKind.Poll -> { + PollContent( + question = kind.question, + kind = kind.kind.map(), + maxSelections = kind.maxSelections, + answers = kind.answers.map { answer -> answer.map() }.toImmutableList(), + votes = kind.votes.mapValues { vote -> + vote.value.map { userId -> UserId(userId) }.toImmutableList() + }.toImmutableMap(), + endTime = kind.endTime, + isEdited = kind.hasBeenEdited, + ) + } + is MsgLikeKind.UnableToDecrypt -> { + UnableToDecryptContent( + data = kind.msg.map() + ) + } + is MsgLikeKind.Sticker -> { + StickerContent( + filename = kind.body, + body = null, + info = kind.info.map(), + source = kind.source.map(), + ) + } + is MsgLikeKind.Other -> UnknownContent + } + } + is TimelineItemContent.ProfileChange -> { + ProfileChangeContent( + displayName = it.displayName, + prevDisplayName = it.prevDisplayName, + avatarUrl = it.avatarUrl, + prevAvatarUrl = it.prevAvatarUrl + ) + } + is TimelineItemContent.RoomMembership -> { + RoomMembershipContent( + userId = UserId(it.userId), + userDisplayName = it.userDisplayName, + change = it.change?.map(), + reason = it.reason, + ) + } + is TimelineItemContent.State -> { + StateContent( + stateKey = it.stateKey, + content = it.content.map() + ) + } + is TimelineItemContent.CallInvite -> LegacyCallInviteContent + is TimelineItemContent.RtcNotification -> CallNotifyContent + } + } + } +} + +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 + } +} + +private fun RustUtdCause.map(): UtdCause { + return when (this) { + RustUtdCause.SENT_BEFORE_WE_JOINED -> UtdCause.SentBeforeWeJoined + RustUtdCause.UNKNOWN -> UtdCause.Unknown + RustUtdCause.VERIFICATION_VIOLATION -> UtdCause.VerificationViolation + RustUtdCause.UNSIGNED_DEVICE -> UtdCause.UnsignedDevice + RustUtdCause.UNKNOWN_DEVICE -> UtdCause.UnknownDevice + RustUtdCause.HISTORICAL_MESSAGE_AND_BACKUP_IS_DISABLED -> UtdCause.HistoricalMessageAndBackupIsDisabled + RustUtdCause.HISTORICAL_MESSAGE_AND_DEVICE_IS_UNVERIFIED -> UtdCause.HistoricalMessageAndDeviceIsUnverified + RustUtdCause.WITHHELD_FOR_UNVERIFIED_OR_INSECURE_DEVICE -> UtdCause.WithheldUnverifiedOrInsecureDevice + RustUtdCause.WITHHELD_BY_SENDER -> UtdCause.WithheldBySender + } +} + +// 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 + is RustOtherState.RoomJoinRules -> OtherState.RoomJoinRules(joinRule?.map()) + is RustOtherState.RoomName -> OtherState.RoomName(name) + is RustOtherState.RoomPinnedEvents -> OtherState.RoomPinnedEvents(change.map()) + is RustOtherState.RoomPowerLevels -> OtherState.RoomUserPowerLevels(users) + 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 + is RustOtherState.RoomCreate -> OtherState.RoomCreate + is RustOtherState.RoomHistoryVisibility -> OtherState.RoomHistoryVisibility + } +} + +private fun RoomPinnedEventsChange.map(): OtherState.RoomPinnedEvents.Change { + return when (this) { + RoomPinnedEventsChange.ADDED -> OtherState.RoomPinnedEvents.Change.ADDED + RoomPinnedEventsChange.REMOVED -> OtherState.RoomPinnedEvents.Change.REMOVED + RoomPinnedEventsChange.CHANGED -> OtherState.RoomPinnedEvents.Change.CHANGED + } +} + +private fun RustEncryptedMessage.map(): UnableToDecryptContent.Data { + return when (this) { + is RustEncryptedMessage.MegolmV1AesSha2 -> UnableToDecryptContent.Data.MegolmV1AesSha2(sessionId, cause.map()) + 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 0000000..770de4f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/virtual/VirtualTimelineItemMapper.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.DateDivider -> VirtualTimelineItem.DayDivider(virtualTimelineItem.ts.toLong()) + RustVirtualTimelineItem.ReadMarker -> VirtualTimelineItem.ReadMarker + RustVirtualTimelineItem.TimelineStart -> VirtualTimelineItem.RoomBeginning + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LastForwardIndicatorsPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LastForwardIndicatorsPostProcessor.kt new file mode 100644 index 0000000..68be5cb --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LastForwardIndicatorsPostProcessor.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline.postprocessor + +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem + +/** + * This post processor is responsible for adding virtual items to indicate all the previous last forward item. + */ +class LastForwardIndicatorsPostProcessor( + private val mode: Timeline.Mode, +) { + private val lastForwardIdentifiers = LinkedHashSet() + + fun process( + items: List, + ): List { + // We don't need to add the last forward indicator if we are not in the FOCUSED_ON_EVENT mode + if (mode !is Timeline.Mode.FocusedOnEvent) { + return items + } else { + return buildList { + val latestEventIdentifier = items.latestEventIdentifier() + // Remove if it always exists (this should happen only when no new events are added) + lastForwardIdentifiers.remove(latestEventIdentifier) + + items.forEach { item -> + add(item) + + if (item is MatrixTimelineItem.Event) { + if (lastForwardIdentifiers.contains(item.uniqueId)) { + add(createLastForwardIndicator(item.uniqueId)) + } + } + } + // This is important to always add this one at the end of the list so it's used to keep the scroll position. + add(createLastForwardIndicator(latestEventIdentifier)) + lastForwardIdentifiers.add(latestEventIdentifier) + } + } + } +} + +private fun createLastForwardIndicator(identifier: UniqueId): MatrixTimelineItem { + return MatrixTimelineItem.Virtual( + uniqueId = UniqueId("last_forward_indicator_$identifier"), + virtual = VirtualTimelineItem.LastForwardIndicator + ) +} + +private fun List.latestEventIdentifier(): UniqueId { + return findLast { + it is MatrixTimelineItem.Event + }?.let { + (it as MatrixTimelineItem.Event).uniqueId + } ?: UniqueId("fake_id") +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LoadingIndicatorsPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LoadingIndicatorsPostProcessor.kt new file mode 100644 index 0000000..6f20cfe --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LoadingIndicatorsPostProcessor.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline.postprocessor + +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem +import io.element.android.services.toolbox.api.systemclock.SystemClock + +class LoadingIndicatorsPostProcessor(private val systemClock: SystemClock) { + fun process( + items: List, + hasMoreToLoadBackward: Boolean, + hasMoreToLoadForward: Boolean, + ): List { + val shouldAddForwardLoadingIndicator = hasMoreToLoadForward && items.isNotEmpty() + val currentTimestamp = systemClock.epochMillis() + return buildList { + if (hasMoreToLoadBackward) { + val backwardLoadingIndicator = MatrixTimelineItem.Virtual( + uniqueId = UniqueId("BackwardLoadingIndicator"), + virtual = VirtualTimelineItem.LoadingIndicator( + direction = Timeline.PaginationDirection.BACKWARDS, + timestamp = currentTimestamp + ) + ) + add(backwardLoadingIndicator) + } + addAll(items) + if (shouldAddForwardLoadingIndicator) { + val forwardLoadingIndicator = MatrixTimelineItem.Virtual( + uniqueId = UniqueId("ForwardLoadingIndicator"), + virtual = VirtualTimelineItem.LoadingIndicator( + direction = Timeline.PaginationDirection.FORWARDS, + timestamp = currentTimestamp + ) + ) + add(forwardLoadingIndicator) + } + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt new file mode 100644 index 0000000..3972802 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline.postprocessor + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.Timeline +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.RoomMembershipContent +import io.element.android.libraries.matrix.api.timeline.item.event.StateContent + +/** + * This timeline post-processor removes the room creation event and the self-join event from the timeline for DMs + * or add the RoomBeginning item. + */ +class RoomBeginningPostProcessor(private val mode: Timeline.Mode) { + fun process( + items: List, + isDm: Boolean, + roomCreator: UserId?, + hasMoreToLoadBackwards: Boolean, + ): List { + return when { + items.isEmpty() -> items + mode == Timeline.Mode.PinnedEvents -> items + isDm -> processForDM(items, roomCreator) + hasMoreToLoadBackwards -> items + else -> processForRoom(items) + } + } + + private fun processForRoom(items: List): List { + // No changes needed, timeline start item is already added by the SDK + return items + } + + private fun processForDM(items: List, roomCreator: UserId?): List { + // Find room creation event. + // This is usually the first MatrixTimelineItem.Event (so index 1, index 0 is a date) + val roomCreationEventIndex = items.indexOfFirst { + val stateEventContent = (it as? MatrixTimelineItem.Event)?.event?.content as? StateContent + stateEventContent?.content is OtherState.RoomCreate + }.takeIf { it >= 0 } + + // If the parameter roomCreator is null, the creator is the sender of the RoomCreate Event. + val roomCreatorUserId = roomCreator ?: roomCreationEventIndex?.let { + (items.getOrNull(it) as? MatrixTimelineItem.Event)?.event?.sender + } + // Find self-join event for the room creator. + // This is usually the second MatrixTimelineItem.Event (so index 2) + val selfUserJoinedEventIndex = roomCreatorUserId?.let { creatorUserId -> + items.indexOfFirst { + val stateEventContent = (it as? MatrixTimelineItem.Event)?.event?.content as? RoomMembershipContent + stateEventContent?.change == MembershipChange.JOINED && stateEventContent.userId == creatorUserId + }.takeIf { it >= 0 } + } + + val indicesToRemove = listOfNotNull( + roomCreationEventIndex, + selfUserJoinedEventIndex, + ) + if (indicesToRemove.isEmpty()) { + // Nothing to do, return the list as is + return items + } + + // Remove items at the indices we found + val newItems = items.toMutableList() + indicesToRemove.sortedDescending().forEach { index -> + newItems.removeAt(index) + } + return newItems + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TypingNotificationPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TypingNotificationPostProcessor.kt new file mode 100644 index 0000000..d56d536 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TypingNotificationPostProcessor.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline.postprocessor + +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem + +/** + * This post processor is responsible for adding a typing notification item to the timeline items when the timeline is in live mode. + */ +class TypingNotificationPostProcessor(private val mode: Timeline.Mode) { + fun process(items: List): List { + return if (mode is Timeline.Mode.Live) { + buildList { + addAll(items) + add( + MatrixTimelineItem.Virtual( + uniqueId = UniqueId("TypingNotification"), + virtual = VirtualTimelineItem.TypingNotification + ) + ) + } + } else { + items + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/reply/InReplyToMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/reply/InReplyToMapper.kt new file mode 100644 index 0000000..93e12d0 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/reply/InReplyToMapper.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline.reply + +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.InReplyTo +import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper +import io.element.android.libraries.matrix.impl.timeline.item.event.map +import org.matrix.rustcomponents.sdk.EmbeddedEventDetails +import org.matrix.rustcomponents.sdk.InReplyToDetails + +class InReplyToMapper( + private val timelineEventContentMapper: TimelineEventContentMapper, +) { + fun map(inReplyToDetails: InReplyToDetails): InReplyTo { + val inReplyToId = EventId(inReplyToDetails.eventId()) + return when (val event = inReplyToDetails.event()) { + is EmbeddedEventDetails.Ready -> { + InReplyTo.Ready( + eventId = inReplyToId, + content = timelineEventContentMapper.map(event.content), + senderId = UserId(event.sender), + senderProfile = event.senderProfile.map(), + ) + } + is EmbeddedEventDetails.Error -> InReplyTo.Error( + eventId = inReplyToId, + message = event.message, + ) + EmbeddedEventDetails.Pending -> InReplyTo.Pending( + eventId = inReplyToId, + ) + is EmbeddedEventDetails.Unavailable -> InReplyTo.NotLoaded( + eventId = inReplyToId + ) + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/LogEventLocation.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/LogEventLocation.kt new file mode 100644 index 0000000..c84e042 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/LogEventLocation.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.tracing + +/** + * This class is used to provide file, line, column information to the Rust SDK [org.matrix.rustcomponents.sdk.logEvent] method. + * The data is extracted from a [StackTraceElement] instance. + */ +data class LogEventLocation( + val file: String, + val line: UInt?, +) { + companion object { + /** + * Create a [LogEventLocation] from a [StackTraceElement]. + */ + fun from(stackTraceElement: StackTraceElement): LogEventLocation { + return LogEventLocation( + file = stackTraceElement.fileName ?: "", + line = stackTraceElement.lineNumber.takeIf { it >= 0 }?.toUInt() + ) + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt new file mode 100644 index 0000000..204fece --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.tracing + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.tracing.LogLevel +import io.element.android.libraries.matrix.api.tracing.TracingConfiguration +import io.element.android.libraries.matrix.api.tracing.TracingService +import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration +import org.matrix.rustcomponents.sdk.TracingFileConfiguration +import org.matrix.rustcomponents.sdk.reloadTracingFileWriter +import timber.log.Timber + +@ContributesBinding(AppScope::class) +class RustTracingService(private val buildMeta: BuildMeta) : TracingService { + override fun createTimberTree(target: String): Timber.Tree { + return RustTracingTree(target = target, retrieveFromStackTrace = buildMeta.isDebuggable) + } + + override fun updateWriteToFilesConfiguration(config: WriteToFilesConfiguration) { + config.toTracingFileConfiguration()?.let { + reloadTracingFileWriter(it) + } + } +} + +private fun LogLevel.toRustLogLevel(): org.matrix.rustcomponents.sdk.LogLevel { + return when (this) { + LogLevel.ERROR -> org.matrix.rustcomponents.sdk.LogLevel.ERROR + LogLevel.WARN -> org.matrix.rustcomponents.sdk.LogLevel.WARN + LogLevel.INFO -> org.matrix.rustcomponents.sdk.LogLevel.INFO + LogLevel.DEBUG -> org.matrix.rustcomponents.sdk.LogLevel.DEBUG + LogLevel.TRACE -> org.matrix.rustcomponents.sdk.LogLevel.TRACE + } +} + +private fun WriteToFilesConfiguration.toTracingFileConfiguration(): TracingFileConfiguration? { + return when (this) { + is WriteToFilesConfiguration.Disabled -> null + is WriteToFilesConfiguration.Enabled -> TracingFileConfiguration( + path = directory, + filePrefix = filenamePrefix, + fileSuffix = filenameSuffix, + maxFiles = numberOfFiles?.toULong(), + ) + } +} + +fun TracingConfiguration.map(): org.matrix.rustcomponents.sdk.TracingConfiguration = org.matrix.rustcomponents.sdk.TracingConfiguration( + writeToStdoutOrSystem = writesToLogcat, + logLevel = logLevel.toRustLogLevel(), + extraTargets = extraTargets, + traceLogPacks = traceLogPacks.map(), + writeToFiles = writesToFilesConfiguration.toTracingFileConfiguration(), + sentryDsn = null, +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingTree.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingTree.kt new file mode 100644 index 0000000..47e6c8f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingTree.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.tracing + +import android.util.Log +import org.matrix.rustcomponents.sdk.LogLevel +import org.matrix.rustcomponents.sdk.logEvent +import timber.log.Timber + +/** + * List of fully qualified class names to ignore when looking for the first stack trace element. + */ +private val fqcnIgnore = listOf( + Timber::class.java.name, + Timber.Forest::class.java.name, + Timber.Tree::class.java.name, + RustTracingTree::class.java.name, +) + +/** + * A Timber tree that passes logs to the Rust SDK. + */ +internal class RustTracingTree( + private val target: String, + private val retrieveFromStackTrace: Boolean, +) : Timber.Tree() { + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + val location = if (retrieveFromStackTrace) { + getLogEventLocationFromStackTrace() + } else { + LogEventLocation("", null) + } + val logLevel = priority.toLogLevel() + logEvent( + file = location.file, + line = location.line, + level = logLevel, + target = target, + message = if (tag != null) "[$tag] $message" else message, + ) + } + + /** + * Extract the [LogEventLocation] from the stack trace. + */ + private fun getLogEventLocationFromStackTrace(): LogEventLocation { + return Throwable(null, null).stackTrace + .first { it.className !in fqcnIgnore } + .let(LogEventLocation::from) + } +} + +/** + * Convert a log priority to a Rust SDK log level. + */ +private fun Int.toLogLevel(): LogLevel { + return when (this) { + Log.VERBOSE -> LogLevel.TRACE + Log.DEBUG -> LogLevel.DEBUG + Log.INFO -> LogLevel.INFO + Log.WARN -> LogLevel.WARN + Log.ERROR -> LogLevel.ERROR + else -> LogLevel.DEBUG + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/TraceLogPacksMapping.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/TraceLogPacksMapping.kt new file mode 100644 index 0000000..0e26935 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/TraceLogPacksMapping.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.tracing + +import io.element.android.libraries.matrix.api.tracing.TraceLogPack +import org.matrix.rustcomponents.sdk.TraceLogPacks as RustTraceLogPack + +fun TraceLogPack.map(): RustTraceLogPack = when (this) { + TraceLogPack.SEND_QUEUE -> RustTraceLogPack.SEND_QUEUE + TraceLogPack.EVENT_CACHE -> RustTraceLogPack.EVENT_CACHE + TraceLogPack.TIMELINE -> RustTraceLogPack.TIMELINE + TraceLogPack.NOTIFICATION_CLIENT -> RustTraceLogPack.NOTIFICATION_CLIENT +} + +fun Collection.map(): List { + return map { it.map() } +} 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 0000000..5a6481b --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserSearchResultMapper.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.usersearch + +import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults +import io.element.android.libraries.matrix.impl.mapper.map +import kotlinx.collections.immutable.toImmutableList +import org.matrix.rustcomponents.sdk.SearchUsersResults + +object UserSearchResultMapper { + fun map(result: SearchUsersResults): MatrixSearchUserResults { + return MatrixSearchUserResults( + results = result.results + .map { userProfile -> userProfile.map() } + .toImmutableList(), + 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 0000000..c7219e5 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/CallbackFlow.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.util + +import io.element.android.libraries.core.data.tryOrNull +import kotlinx.coroutines.channels.ProducerScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import org.matrix.rustcomponents.sdk.TaskHandle + +internal fun mxCallbackFlow(block: suspend ProducerScope.() -> TaskHandle) = + callbackFlow { + val taskHandle: TaskHandle? = tryOrNull { + block(this) + } + awaitClose { + taskHandle?.cancelAndDestroy() + } + } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Disposables.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Disposables.kt new file mode 100644 index 0000000..0107d6c --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Disposables.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.util + +import org.matrix.rustcomponents.sdk.Disposable + +/** + * Call destroy on all elements of the iterable. + */ +internal fun Iterable.destroyAll() = forEach { it.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 0000000..0a1f45d --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Error.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/MessageEventContent.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt new file mode 100644 index 0000000..3e32011 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.util + +import io.element.android.libraries.matrix.api.room.IntentionalMention +import io.element.android.libraries.matrix.impl.room.map +import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation +import org.matrix.rustcomponents.sdk.messageEventContentFromHtml +import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown + +/** + * Creates a [RoomMessageEventContentWithoutRelation] from a body, an html body and a list of mentions. + */ +object MessageEventContent { + fun from(body: String, htmlBody: String?, intentionalMentions: List): RoomMessageEventContentWithoutRelation { + return if (htmlBody != null) { + messageEventContentFromHtml(body, htmlBody) + } else { + messageEventContentFromMarkdown(body) + }.withMentions(intentionalMentions.map()) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/SessionPathsProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/SessionPathsProvider.kt new file mode 100644 index 0000000..0030c6e --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/SessionPathsProvider.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.util + +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.impl.paths.SessionPaths +import io.element.android.libraries.matrix.impl.paths.getSessionPaths +import io.element.android.libraries.sessionstorage.api.SessionStore + +class SessionPathsProvider( + private val sessionStore: SessionStore, +) { + suspend fun provides(sessionId: SessionId): SessionPaths? { + val sessionData = sessionStore.getSession(sessionId.value) ?: return null + return sessionData.getSessionPaths() + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/TaskHandle.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/TaskHandle.kt new file mode 100644 index 0000000..96b075c --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/TaskHandle.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.util + +import org.matrix.rustcomponents.sdk.TaskHandle +import java.util.concurrent.CopyOnWriteArraySet + +fun TaskHandle.cancelAndDestroy() { + cancel() + destroy() +} + +class TaskHandleBag(private val taskHandles: MutableSet = CopyOnWriteArraySet()) : Set by taskHandles { + operator fun plusAssign(taskHandle: TaskHandle?) { + if (taskHandle == null) return + taskHandles += taskHandle + } + + fun dispose() { + taskHandles.forEach { + it.cancelAndDestroy() + } + taskHandles.clear() + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Token.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Token.kt new file mode 100644 index 0000000..815e134 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Token.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.util + +import io.element.android.libraries.sessionstorage.api.SessionData +import org.matrix.rustcomponents.sdk.Session +import java.security.MessageDigest + +private val sha256 by lazy { MessageDigest.getInstance("SHA-256") } + +@OptIn(ExperimentalStdlibApi::class) +private fun anonymizeToken(token: String): String { + return sha256.digest(token.toByteArray()).toHexString() +} + +fun SessionData?.anonymizedTokens(): Pair { + if (this == null) return null to null + return anonymizeToken(accessToken) to refreshToken?.let { anonymizeToken(it) } +} + +fun Session?.anonymizedTokens(): Pair { + if (this == null) return null to null + return anonymizeToken(accessToken) to refreshToken?.let { anonymizeToken(it) } +} 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 0000000..3014618 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt @@ -0,0 +1,290 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.verification + +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.verification.SessionVerificationData +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener +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 io.element.android.libraries.matrix.api.verification.VerificationRequest +import io.element.android.libraries.matrix.impl.util.cancelAndDestroy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.Encryption +import org.matrix.rustcomponents.sdk.RecoveryState +import org.matrix.rustcomponents.sdk.RecoveryStateListener +import org.matrix.rustcomponents.sdk.SessionVerificationController +import org.matrix.rustcomponents.sdk.SessionVerificationControllerDelegate +import org.matrix.rustcomponents.sdk.VerificationState +import org.matrix.rustcomponents.sdk.VerificationStateListener +import org.matrix.rustcomponents.sdk.use +import timber.log.Timber +import kotlin.time.Duration.Companion.seconds +import org.matrix.rustcomponents.sdk.SessionVerificationData as RustSessionVerificationData +import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails + +class RustSessionVerificationService( + private val client: Client, + isSyncServiceReady: Flow, + private val sessionCoroutineScope: CoroutineScope, +) : SessionVerificationService, SessionVerificationControllerDelegate { + private var currentVerificationRequest: VerificationRequest? = null + + private val encryptionService: Encryption = client.encryption() + private lateinit var verificationController: SessionVerificationController + + private val _verificationFlowState = MutableStateFlow(VerificationFlowState.Initial) + override val verificationFlowState = _verificationFlowState.asStateFlow() + + private val _sessionVerifiedStatus = MutableStateFlow(SessionVerifiedStatus.Unknown) + override val sessionVerifiedStatus: StateFlow = _sessionVerifiedStatus.asStateFlow() + + private val recoveryState = MutableStateFlow(RecoveryState.UNKNOWN) + + // Listen for changes in verification status and update accordingly + private val verificationStateListenerTaskHandle = encryptionService.verificationStateListener(object : VerificationStateListener { + override fun onUpdate(status: VerificationState) { + Timber.d("New verification state: $status") + _sessionVerifiedStatus.value = status.map() + } + }) + + // In case we enter the recovery key instead we check changes in the recovery state, since the listener above won't be triggered + private val recoveryStateListenerTaskHandle = encryptionService.recoveryStateListener(object : RecoveryStateListener { + override fun onUpdate(status: RecoveryState) { + Timber.d("New recovery state: $status") + // We could check the `RecoveryState`, but it's easier to just use the verification state directly + recoveryState.value = status + } + }) + + /** + * 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. + */ + private val isReady = isSyncServiceReady.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false) + + override val needsSessionVerification = sessionVerifiedStatus.map { verificationStatus -> + verificationStatus == SessionVerifiedStatus.NotVerified + } + + override fun didReceiveVerificationRequest(details: RustSessionVerificationRequestDetails) { + listener?.onIncomingSessionRequest(details.toVerificationRequest(UserId(client.userId()))) + } + + private var listener: SessionVerificationServiceListener? = null + + init { + // Instantiate the verification controller when possible, this is needed to get incoming verification requests + sessionCoroutineScope.launch { + tryOrNull { + encryptionService.waitForE2eeInitializationTasks() + initVerificationControllerIfNeeded() + } + } + } + + override fun setListener(listener: SessionVerificationServiceListener?) { + this.listener = listener + } + + override suspend fun requestCurrentSessionVerification() = tryOrFail { + initVerificationControllerIfNeeded() + verificationController.requestDeviceVerification() + currentVerificationRequest = VerificationRequest.Outgoing.CurrentSession + } + + override suspend fun requestUserVerification(userId: UserId) = tryOrFail { + initVerificationControllerIfNeeded() + verificationController.requestUserVerification(userId.value) + currentVerificationRequest = VerificationRequest.Outgoing.User(userId) + } + + override suspend fun cancelVerification() = tryOrFail { + verificationController.cancelVerification() + // We need to manually set the state to canceled, as the Rust SDK doesn't always call `didCancel` when it should + didCancel() + } + + override suspend fun approveVerification() = tryOrFail { verificationController.approveVerification() } + + override suspend fun declineVerification() = tryOrFail { verificationController.declineVerification() } + + override suspend fun startVerification() = tryOrFail { + verificationController.startSasVerification() + } + + override suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming) = tryOrFail { + initVerificationControllerIfNeeded() + verificationController.acknowledgeVerificationRequest( + senderId = verificationRequest.details.senderProfile.userId.value, + flowId = verificationRequest.details.flowId.value, + ) + } + + override suspend fun acceptVerificationRequest() = tryOrFail { + Timber.d("Accepting incoming verification request") + verificationController.acceptVerificationRequest() + } + + private suspend fun tryOrFail(block: suspend () -> Unit) { + runCatchingExceptions { + // Ensure the block cannot be cancelled, else if the Rust SDK emit a new state during the API execution, + // the state machine may cancel the api call. + withContext(NonCancellable) { + block() + } + }.onFailure { + Timber.e(it, "Failed to verify session") + didFail() + } + } + + // region Delegate implementation + + // When verification attempt is accepted by the other device + override fun didAcceptVerificationRequest() { + _verificationFlowState.value = VerificationFlowState.DidAcceptVerificationRequest + } + + override fun didCancel() { + _verificationFlowState.value = VerificationFlowState.DidCancel + } + + override fun didFail() { + Timber.e("Session verification failed with an unknown error") + _verificationFlowState.value = VerificationFlowState.DidFail + } + + override fun didFinish() { + sessionCoroutineScope.launch { + // Ideally this should be `verificationController?.isVerified().orFalse()` but for some reason it returns false if run immediately + // It also sometimes unexpectedly fails to report the session as verified, so we have to handle that possibility and fail if needed + runCatchingExceptions { + withTimeout(20.seconds) { + // Wait until the SDK reports the state as verified + sessionVerifiedStatus.first { it == SessionVerifiedStatus.Verified } + } + } + .onSuccess { + if (currentVerificationRequest is VerificationRequest.Outgoing.CurrentSession) { + // Try waiting for the final recovery state for better UX, but don't block the verification state on it + tryOrNull { + withTimeout(10.seconds) { + // Wait until the recovery state is either fully loaded or we check it's explicitly disabled + recoveryState.first { it == RecoveryState.ENABLED || it == RecoveryState.DISABLED } + } + } + } + + _verificationFlowState.value = VerificationFlowState.DidFinish + updateVerificationStatus() + } + .onFailure { + Timber.e(it, "Verification finished, but the Rust SDK still reports the session as unverified.") + didFail() + } + } + } + + override fun didReceiveVerificationData(data: RustSessionVerificationData) { + _verificationFlowState.value = VerificationFlowState.DidReceiveVerificationData(data.map()) + } + + // When the actual SAS verification starts + override fun didStartSasVerification() { + _verificationFlowState.value = VerificationFlowState.DidStartSasVerification + } + + // end-region + + override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) { + currentVerificationRequest = null + if (isReady.value && cancelAnyPendingVerificationAttempt) { + // Cancel any pending verification attempt + tryOrNull { verificationController.cancelVerification() } + } + _verificationFlowState.value = VerificationFlowState.Initial + } + + fun destroy() { + Timber.d("Destroying RustSessionVerificationService") + verificationStateListenerTaskHandle.cancelAndDestroy() + recoveryStateListenerTaskHandle.cancelAndDestroy() + if (this::verificationController.isInitialized) { + verificationController.setDelegate(null) + } + } + + private var initControllerMutex = Mutex() + + private suspend fun initVerificationControllerIfNeeded() = initControllerMutex.withLock { + if (!this::verificationController.isInitialized) { + tryOrFail { + verificationController = client.getSessionVerificationController() + verificationController.setDelegate(this) + } + } + } + + private fun updateVerificationStatus() { + runCatchingExceptions { + _sessionVerifiedStatus.value = encryptionService.verificationState().map() + Timber.d("New verification status: ${_sessionVerifiedStatus.value}") + } + } +} + +private fun VerificationState.map() = when (this) { + VerificationState.UNKNOWN -> SessionVerifiedStatus.Unknown + VerificationState.VERIFIED -> SessionVerifiedStatus.Verified + VerificationState.UNVERIFIED -> SessionVerifiedStatus.NotVerified +} + +private fun RustSessionVerificationData.map(): SessionVerificationData { + return use { sessionVerificationData -> + when (sessionVerificationData) { + is RustSessionVerificationData.Emojis -> { + SessionVerificationData.Emojis( + emojis = sessionVerificationData.emojis.mapIndexed { index, emoji -> + emoji.use { sessionVerificationEmoji -> + VerificationEmoji( + number = sessionVerificationData.indices[index].toInt(), + ) + } + }, + ) + } + is RustSessionVerificationData.Decimals -> { + SessionVerificationData.Decimals( + decimals = sessionVerificationData.values.map { it.toInt() }, + ) + } + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/SessionVerificationRequestDetails.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/SessionVerificationRequestDetails.kt new file mode 100644 index 0000000..d88f9fd --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/SessionVerificationRequestDetails.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.verification + +import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.core.FlowId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails +import io.element.android.libraries.matrix.api.verification.VerificationRequest +import io.element.android.libraries.matrix.impl.mapper.map +import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails + +fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails( + senderProfile = senderProfile.map(), + flowId = FlowId(flowId), + deviceId = DeviceId(deviceId), + deviceDisplayName = deviceDisplayName, + firstSeenTimestamp = firstSeenTimestamp.toLong(), +) + +fun RustSessionVerificationRequestDetails.toVerificationRequest(currentUserId: UserId): VerificationRequest.Incoming { + val details = map() + return if (currentUserId == details.senderProfile.userId) { + VerificationRequest.Incoming.OtherSession(details) + } else { + VerificationRequest.Incoming.User(details) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt new file mode 100644 index 0000000..60a0bea --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/DefaultCallWidgetSettingsProvider.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.widget + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.matrix.api.widget.CallAnalyticCredentialsProvider +import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider +import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.flow.first +import org.matrix.rustcomponents.sdk.newVirtualElementCallWidget +import timber.log.Timber +import uniffi.matrix_sdk.EncryptionSystem +import uniffi.matrix_sdk.VirtualElementCallWidgetConfig +import uniffi.matrix_sdk.VirtualElementCallWidgetProperties +import uniffi.matrix_sdk.Intent as CallIntent + +@ContributesBinding(AppScope::class) +class DefaultCallWidgetSettingsProvider( + private val buildMeta: BuildMeta, + private val callAnalyticsCredentialsProvider: CallAnalyticCredentialsProvider, + private val analyticsService: AnalyticsService, +) : CallWidgetSettingsProvider { + override suspend fun provide(baseUrl: String, widgetId: String, encrypted: Boolean, direct: Boolean, hasActiveCall: Boolean): MatrixWidgetSettings { + val isAnalyticsEnabled = analyticsService.userConsentFlow.first() + val properties = VirtualElementCallWidgetProperties( + elementCallUrl = baseUrl, + widgetId = widgetId, + fontScale = null, + font = null, + encryption = if (encrypted) EncryptionSystem.PerParticipantKeys else EncryptionSystem.Unencrypted, + posthogUserId = callAnalyticsCredentialsProvider.posthogUserId.takeIf { isAnalyticsEnabled }, + posthogApiHost = callAnalyticsCredentialsProvider.posthogApiHost.takeIf { isAnalyticsEnabled }, + posthogApiKey = callAnalyticsCredentialsProvider.posthogApiKey.takeIf { isAnalyticsEnabled }, + rageshakeSubmitUrl = callAnalyticsCredentialsProvider.rageshakeSubmitUrl, + sentryDsn = callAnalyticsCredentialsProvider.sentryDsn.takeIf { isAnalyticsEnabled }, + sentryEnvironment = if (buildMeta.buildType == BuildType.RELEASE) "RELEASE" else "DEBUG", + parentUrl = null, + ) + val config = VirtualElementCallWidgetConfig( + // TODO remove this once we have the next EC version + preload = false, + // TODO remove this once we have the next EC version + skipLobby = null, + intent = when { + direct && hasActiveCall -> CallIntent.JOIN_EXISTING_DM + hasActiveCall -> CallIntent.JOIN_EXISTING + direct -> CallIntent.START_CALL_DM + else -> CallIntent.START_CALL + }.also { + Timber.d("Starting/joining call with intent: $it") + } + ) + val rustWidgetSettings = newVirtualElementCallWidget( + props = properties, + config = config, + ) + return MatrixWidgetSettings.fromRustWidgetSettings(rustWidgetSettings) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/MatrixWidgetSettings.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/MatrixWidgetSettings.kt new file mode 100644 index 0000000..37a0b23 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/MatrixWidgetSettings.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.widget + +import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings +import org.matrix.rustcomponents.sdk.ClientProperties +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.WidgetSettings +import org.matrix.rustcomponents.sdk.generateWebviewUrl + +fun MatrixWidgetSettings.toRustWidgetSettings() = WidgetSettings( + widgetId = this.id, + initAfterContentLoad = this.initAfterContentLoad, + rawUrl = this.rawUrl, +) + +fun MatrixWidgetSettings.Companion.fromRustWidgetSettings(widgetSettings: WidgetSettings) = MatrixWidgetSettings( + id = widgetSettings.widgetId, + initAfterContentLoad = widgetSettings.initAfterContentLoad, + rawUrl = widgetSettings.rawUrl, +) + +suspend fun MatrixWidgetSettings.generateWidgetWebViewUrl( + room: Room, + clientId: String, + languageTag: String? = null, + theme: String? = null +) = generateWebviewUrl( + widgetSettings = this.toRustWidgetSettings(), + room = room, + props = ClientProperties( + clientId = clientId, + languageTag = languageTag, + theme = theme, + ) +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt new file mode 100644 index 0000000..1d1824d --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.widget + +import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver +import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.WidgetCapabilitiesProvider +import org.matrix.rustcomponents.sdk.makeWidgetDriver +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.coroutineContext + +class RustWidgetDriver( + widgetSettings: MatrixWidgetSettings, + private val room: Room, + private val widgetCapabilitiesProvider: WidgetCapabilitiesProvider, +) : MatrixWidgetDriver { + // It's important to have extra capacity here to make sure we don't drop any messages + override val incomingMessages = MutableSharedFlow(extraBufferCapacity = 10) + + private val driverAndHandle = makeWidgetDriver(widgetSettings.toRustWidgetSettings()) + private var receiveMessageJob: Job? = null + + private var isRunning = AtomicBoolean(false) + + override val id: String = widgetSettings.id + + override suspend fun run() { + // Don't run the driver if it's already running + if (!isRunning.compareAndSet(false, true)) { + return + } + + val coroutineScope = CoroutineScope(coroutineContext) + coroutineScope.launch { + // This call will suspend the coroutine while the driver is running, so it needs to be launched separately + driverAndHandle.driver.run(room, widgetCapabilitiesProvider) + } + receiveMessageJob = coroutineScope.launch(Dispatchers.IO) { + try { + while (isActive) { + driverAndHandle.handle.recv()?.let { incomingMessages.emit(it) } + } + } finally { + driverAndHandle.handle.close() + } + } + } + + override suspend fun send(message: String) { + try { + driverAndHandle.handle.send(message) + } catch (e: IllegalStateException) { + // The handle is closed, ignore + } + } + + override fun close() { + receiveMessageJob?.cancel() + driverAndHandle.driver.close() + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/FakeClientBuilderProvider.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/FakeClientBuilderProvider.kt new file mode 100644 index 0000000..97aedb9 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/FakeClientBuilderProvider.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl + +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClientBuilder +import org.matrix.rustcomponents.sdk.ClientBuilder + +class FakeClientBuilderProvider( + private val provideResult: () -> ClientBuilder = { FakeFfiClientBuilder() } +) : ClientBuilderProvider { + override fun provide(): ClientBuilder { + return provideResult() + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegateTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegateTest.kt new file mode 100644 index 0000000..7c63348 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegateTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSession +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RustClientSessionDelegateTest { + @Test + fun `saveSessionInKeychain should update the store`() = runTest { + val sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + accessToken = "anAccessToken", + refreshToken = "aRefreshToken", + ) + ) + ) + val sut = aRustClientSessionDelegate(sessionStore) + sut.saveSessionInKeychain( + aRustSession( + accessToken = "at", + refreshToken = "rt", + ) + ) + runCurrent() + val result = sessionStore.getLatestSession() + assertThat(result!!.accessToken).isEqualTo("at") + assertThat(result.refreshToken).isEqualTo("rt") + } +} + +fun TestScope.aRustClientSessionDelegate( + sessionStore: SessionStore = InMemorySessionStore(), +) = RustClientSessionDelegate( + sessionStore = sessionStore, + appCoroutineScope = this, + coroutineDispatchers = testCoroutineDispatchers(), +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt new file mode 100644 index 0000000..471efa3 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider +import io.element.android.libraries.matrix.impl.auth.FakeUserCertificatesProvider +import io.element.android.libraries.matrix.impl.room.FakeTimelineEventTypeFilterFactory +import io.element.android.libraries.network.useragent.SimpleUserAgentProvider +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test +import java.io.File + +@Ignore("JNA direct mapping has broken unit tests with FFI fakes") +class RustMatrixClientFactoryTest { + @Test + fun test() = runTest { + val sut = createRustMatrixClientFactory() + val result = sut.create(aSessionData()) + assertThat(result.sessionId).isEqualTo(SessionId("@alice:server.org")) + result.destroy() + } +} + +fun TestScope.createRustMatrixClientFactory( + cacheDirectory: File = File("/cache"), + sessionStore: SessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), + clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), +) = RustMatrixClientFactory( + cacheDirectory = cacheDirectory, + appCoroutineScope = backgroundScope, + coroutineDispatchers = testCoroutineDispatchers(), + sessionStore = sessionStore, + userAgentProvider = SimpleUserAgentProvider(), + userCertificatesProvider = FakeUserCertificatesProvider(), + proxyProvider = FakeProxyProvider(), + clock = FakeSystemClock(), + analyticsService = FakeAnalyticsService(), + featureFlagService = FakeFeatureFlagService(), + timelineEventTypeFilterFactory = FakeTimelineEventTypeFilterFactory(), + clientBuilderProvider = clientBuilderProvider, +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt new file mode 100644 index 0000000..06bd91e --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.matrix.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSyncService +import io.element.android.libraries.matrix.impl.room.FakeTimelineEventTypeFilterFactory +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.A_DEVICE_ID +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.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.UserProfile +import java.io.File + +@Ignore("JNA direct mapping has broken unit tests with FFI fakes") +class RustMatrixClientTest { + @Test + fun `ensure that sessionId and deviceId can be retrieved from the client`() = runTest { + createRustMatrixClient().run { + assertThat(sessionId).isEqualTo(A_USER_ID) + assertThat(deviceId).isEqualTo(A_DEVICE_ID) + destroy() + } + } + + @Test + fun `clear cache invokes the method clearCaches from the client and close it`() = runTest { + val clearCachesResult = lambdaRecorder { } + val closeResult = lambdaRecorder { } + val client = createRustMatrixClient( + client = FakeFfiClient( + clearCachesResult = clearCachesResult, + closeResult = closeResult, + ) + ) + client.clearCache() + clearCachesResult.assertions().isCalledOnce() + closeResult.assertions().isCalledOnce() + client.destroy() + } + + @Test + fun `retrieving the UserProfile updates the database`() = runTest { + val updateUserProfileResult = lambdaRecorder { _, _, _ -> } + val sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_USER_ID.value, + userDisplayName = null, + userAvatarUrl = null, + ) + ), + updateUserProfileResult = updateUserProfileResult, + ) + val client = createRustMatrixClient( + client = FakeFfiClient( + getProfileResult = { userId -> + UserProfile( + userId = userId, + displayName = A_USER_NAME, + avatarUrl = AN_AVATAR_URL, + ) + }, + ), + sessionStore = sessionStore, + ) + advanceUntilIdle() + updateUserProfileResult.assertions().isCalledOnce() + .with( + value(A_USER_ID.value), + value(A_USER_NAME), + value(AN_AVATAR_URL), + ) + client.destroy() + } + + private fun TestScope.createRustMatrixClient( + client: Client = FakeFfiClient(), + sessionStore: SessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), + ) = RustMatrixClient( + innerClient = client, + sessionStore = sessionStore, + appCoroutineScope = backgroundScope, + sessionDelegate = aRustClientSessionDelegate( + sessionStore = sessionStore, + ), + innerSyncService = FakeFfiSyncService(), + dispatchers = testCoroutineDispatchers(), + baseCacheDirectory = File(""), + clock = FakeSystemClock(), + timelineEventTypeFilterFactory = FakeTimelineEventTypeFilterFactory(), + featureFlagService = FakeFeatureFlagService(), + analyticsService = FakeAnalyticsService(), + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/analytics/JoinedExtKtTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/analytics/JoinedExtKtTest.kt new file mode 100644 index 0000000..68adfe0 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/analytics/JoinedExtKtTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.analytics + +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class JoinedExtKtTest { + @Test + fun `test room size mapping`() = runTest { + mapOf( + listOf(0L, 1L) to JoinedRoom.RoomSize.One, + listOf(2L, 2L) to JoinedRoom.RoomSize.Two, + listOf(3L, 10L) to JoinedRoom.RoomSize.ThreeToTen, + listOf(11L, 100L) to JoinedRoom.RoomSize.ElevenToOneHundred, + listOf(101L, 1000L) to JoinedRoom.RoomSize.OneHundredAndOneToAThousand, + listOf(1001L, 2000L) to JoinedRoom.RoomSize.MoreThanAThousand + ).forEach { (joinedMemberCounts, expectedRoomSize) -> + joinedMemberCounts.forEach { joinedMemberCount -> + assertThat(aRoom(joinedMemberCount = joinedMemberCount).toAnalyticsJoinedRoom(null)) + .isEqualTo( + JoinedRoom( + isDM = false, + isSpace = false, + roomSize = expectedRoomSize, + trigger = null + ) + ) + } + } + } + + @Test + fun `test isDirect parameter mapping`() = runTest { + assertThat(aRoom(isDirect = true).toAnalyticsJoinedRoom(null)) + .isEqualTo( + JoinedRoom( + isDM = true, + isSpace = false, + roomSize = JoinedRoom.RoomSize.One, + trigger = null + ) + ) + } + + @Test + fun `test isSpace parameter mapping`() = runTest { + assertThat(aRoom(isSpace = true).toAnalyticsJoinedRoom(null)) + .isEqualTo( + JoinedRoom( + isDM = false, + isSpace = true, + roomSize = JoinedRoom.RoomSize.One, + trigger = null + ) + ) + } + + @Test + fun `test trigger parameter mapping`() = runTest { + assertThat(aRoom(isDirect = false, isSpace = false, joinedMemberCount = 1).toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite)) + .isEqualTo( + JoinedRoom( + isDM = false, + isSpace = false, + roomSize = JoinedRoom.RoomSize.One, + trigger = JoinedRoom.Trigger.Invite + ) + ) + } + + private fun aRoom( + isDirect: Boolean = false, + isSpace: Boolean = false, + joinedMemberCount: Long = 0 + ): FakeBaseRoom { + return FakeBaseRoom().apply { + givenRoomInfo(aRoomInfo(isDirect = isDirect, isSpace = isSpace, joinedMembersCount = joinedMemberCount)) + } + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/analytics/UtdTrackerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/analytics/UtdTrackerTest.kt new file mode 100644 index 0000000..e59329b --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/analytics/UtdTrackerTest.kt @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.analytics + +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.Error +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUnableToDecryptInfo +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.services.analytics.test.FakeAnalyticsService +import org.junit.Test +import uniffi.matrix_sdk_crypto.UtdCause + +class UtdTrackerTest { + @Test + fun `when onUtd is called with null timeToDecryptMs, the expected analytics Event is sent`() { + val fakeAnalyticsService = FakeAnalyticsService() + val sut = UtdTracker(fakeAnalyticsService) + sut.onUtd( + aRustUnableToDecryptInfo( + eventId = AN_EVENT_ID.value, + timeToDecryptMs = null, + cause = UtdCause.UNKNOWN, + eventLocalAgeMillis = 100L, + ) + ) + assertThat(fakeAnalyticsService.capturedEvents).containsExactly( + Error( + context = null, + cryptoModule = Error.CryptoModule.Rust, + cryptoSDK = Error.CryptoSDK.Rust, + timeToDecryptMillis = -1, + domain = Error.Domain.E2EE, + name = Error.Name.OlmKeysNotSentError, + isFederated = false, + isMatrixDotOrg = false, + userTrustsOwnIdentity = false, + eventLocalAgeMillis = 100, + ) + ) + assertThat(fakeAnalyticsService.screenEvents).isEmpty() + assertThat(fakeAnalyticsService.trackedErrors).isEmpty() + } + + @Test + fun `when onUtd is called with timeToDecryptMs, the expected analytics Event is sent`() { + val fakeAnalyticsService = FakeAnalyticsService() + val sut = UtdTracker(fakeAnalyticsService) + sut.onUtd( + aRustUnableToDecryptInfo( + eventId = AN_EVENT_ID.value, + timeToDecryptMs = 123.toULong(), + cause = UtdCause.UNKNOWN, + ) + ) + assertThat(fakeAnalyticsService.capturedEvents).containsExactly( + Error( + context = null, + cryptoModule = Error.CryptoModule.Rust, + cryptoSDK = Error.CryptoSDK.Rust, + timeToDecryptMillis = 123, + domain = Error.Domain.E2EE, + name = Error.Name.OlmKeysNotSentError, + isFederated = false, + isMatrixDotOrg = false, + userTrustsOwnIdentity = false, + eventLocalAgeMillis = 0, + ) + ) + assertThat(fakeAnalyticsService.screenEvents).isEmpty() + assertThat(fakeAnalyticsService.trackedErrors).isEmpty() + } + + @Test + fun `when onUtd is called with membership cause, the expected analytics Event is sent`() { + val fakeAnalyticsService = FakeAnalyticsService() + val sut = UtdTracker(fakeAnalyticsService) + sut.onUtd( + aRustUnableToDecryptInfo( + eventId = AN_EVENT_ID.value, + timeToDecryptMs = 123.toULong(), + cause = UtdCause.SENT_BEFORE_WE_JOINED, + ) + ) + assertThat(fakeAnalyticsService.capturedEvents).containsExactly( + Error( + context = null, + cryptoModule = Error.CryptoModule.Rust, + cryptoSDK = Error.CryptoSDK.Rust, + timeToDecryptMillis = 123, + domain = Error.Domain.E2EE, + name = Error.Name.ExpectedDueToMembership, + isFederated = false, + isMatrixDotOrg = false, + userTrustsOwnIdentity = false, + eventLocalAgeMillis = 0, + ) + ) + assertThat(fakeAnalyticsService.screenEvents).isEmpty() + assertThat(fakeAnalyticsService.trackedErrors).isEmpty() + } + + @Test + fun `when onUtd is called with insecure cause, the expected analytics Event is sent`() { + val fakeAnalyticsService = FakeAnalyticsService() + val sut = UtdTracker(fakeAnalyticsService) + sut.onUtd( + aRustUnableToDecryptInfo( + eventId = AN_EVENT_ID.value, + timeToDecryptMs = 123.toULong(), + cause = UtdCause.UNSIGNED_DEVICE, + ) + ) + assertThat(fakeAnalyticsService.capturedEvents).containsExactly( + Error( + context = null, + cryptoModule = Error.CryptoModule.Rust, + cryptoSDK = Error.CryptoSDK.Rust, + timeToDecryptMillis = 123, + domain = Error.Domain.E2EE, + name = Error.Name.ExpectedSentByInsecureDevice, + isFederated = false, + isMatrixDotOrg = false, + userTrustsOwnIdentity = false, + eventLocalAgeMillis = 0, + ) + ) + } + + @Test + fun `when onUtd is called with verification violation cause, the expected analytics Event is sent`() { + val fakeAnalyticsService = FakeAnalyticsService() + val sut = UtdTracker(fakeAnalyticsService) + sut.onUtd( + aRustUnableToDecryptInfo( + eventId = AN_EVENT_ID.value, + timeToDecryptMs = 123.toULong(), + cause = UtdCause.VERIFICATION_VIOLATION, + ) + ) + assertThat(fakeAnalyticsService.capturedEvents).containsExactly( + Error( + context = null, + cryptoModule = Error.CryptoModule.Rust, + cryptoSDK = Error.CryptoSDK.Rust, + timeToDecryptMillis = 123, + domain = Error.Domain.E2EE, + name = Error.Name.ExpectedVerificationViolation, + isFederated = false, + isMatrixDotOrg = false, + userTrustsOwnIdentity = false, + eventLocalAgeMillis = 0, + ) + ) + } + + @Test + fun `when onUtd is called with different sender and receiver servers, the expected analytics Event is sent`() { + val fakeAnalyticsService = FakeAnalyticsService() + val sut = UtdTracker(fakeAnalyticsService) + sut.onUtd( + aRustUnableToDecryptInfo( + eventId = AN_EVENT_ID.value, + ownHomeserver = "example.com", + senderHomeserver = "matrix.org", + ) + ) + assertThat(fakeAnalyticsService.capturedEvents).containsExactly( + Error( + context = null, + cryptoModule = Error.CryptoModule.Rust, + cryptoSDK = Error.CryptoSDK.Rust, + timeToDecryptMillis = -1, + domain = Error.Domain.E2EE, + name = Error.Name.OlmKeysNotSentError, + isFederated = true, + isMatrixDotOrg = false, + userTrustsOwnIdentity = false, + eventLocalAgeMillis = 0, + ) + ) + } + + @Test + fun `when onUtd is called from a matrix-org user, the expected analytics Event is sent`() { + val fakeAnalyticsService = FakeAnalyticsService() + val sut = UtdTracker(fakeAnalyticsService) + sut.onUtd( + aRustUnableToDecryptInfo( + eventId = AN_EVENT_ID.value, + ownHomeserver = "matrix.org", + ) + ) + assertThat(fakeAnalyticsService.capturedEvents).containsExactly( + Error( + context = null, + cryptoModule = Error.CryptoModule.Rust, + cryptoSDK = Error.CryptoSDK.Rust, + timeToDecryptMillis = -1, + domain = Error.Domain.E2EE, + name = Error.Name.OlmKeysNotSentError, + isFederated = true, + isMatrixDotOrg = true, + userTrustsOwnIdentity = false, + eventLocalAgeMillis = 0, + ) + ) + } + + @Test + fun `when onUtd is called from a verified device, the expected analytics Event is sent`() { + val fakeAnalyticsService = FakeAnalyticsService() + val sut = UtdTracker(fakeAnalyticsService) + sut.onUtd( + aRustUnableToDecryptInfo( + eventId = AN_EVENT_ID.value, + userTrustsOwnIdentity = true, + ) + ) + assertThat(fakeAnalyticsService.capturedEvents).containsExactly( + Error( + context = null, + cryptoModule = Error.CryptoModule.Rust, + cryptoSDK = Error.CryptoSDK.Rust, + timeToDecryptMillis = -1, + domain = Error.Domain.E2EE, + name = Error.Name.OlmKeysNotSentError, + isFederated = false, + isMatrixDotOrg = false, + userTrustsOwnIdentity = true, + eventLocalAgeMillis = 0, + ) + ) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationExceptionMappingTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationExceptionMappingTest.kt new file mode 100644 index 0000000..a242552 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationExceptionMappingTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth + +import com.google.common.truth.ThrowableSubject +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.auth.AuthenticationException +import org.junit.Test +import org.matrix.rustcomponents.sdk.ClientBuildException +import org.matrix.rustcomponents.sdk.OidcException + +class AuthenticationExceptionMappingTest { + @Test + fun `mapping an exception with no message returns null message`() { + val exception = Exception() + val mappedException = exception.mapAuthenticationException() + assertThat(mappedException.message).isNull() + } + + @Test + fun `mapping a generic exception returns a Generic AuthenticationException`() { + val exception = Exception("Generic exception") + val mappedException = exception.mapAuthenticationException() + assertThat(mappedException).isException("Generic exception") + } + + @Test + fun `mapping specific exceptions map to their kotlin counterparts`() { + assertThat(ClientBuildException.Generic("Unknown error").mapAuthenticationException()) + .isException("Unknown error") + + assertThat(ClientBuildException.InvalidServerName("Invalid server name").mapAuthenticationException()) + .isException("Invalid server name") + + assertThat(ClientBuildException.SlidingSyncVersion("Sliding sync not available").mapAuthenticationException()) + .isException("Sliding sync not available") + } + + @Test + fun `mapping other exceptions map to the Generic Kotlin`() { + assertThat(ClientBuildException.Sdk("SDK issue").mapAuthenticationException()) + .isException("SDK issue") + assertThat(ClientBuildException.ServerUnreachable("Server unreachable").mapAuthenticationException()) + .isException("Server unreachable") + assertThat(ClientBuildException.SlidingSync("Sliding Sync").mapAuthenticationException()) + .isException("Sliding Sync") + assertThat(ClientBuildException.WellKnownDeserializationException("WellKnown Deserialization").mapAuthenticationException()) + .isException("WellKnown Deserialization") + assertThat(ClientBuildException.WellKnownLookupFailed("WellKnown Lookup Failed").mapAuthenticationException()) + .isException("WellKnown Lookup Failed") + assertThat(ClientBuildException.EventCache("EventCache error").mapAuthenticationException()) + .isException("EventCache error") + } + + @Test + fun `mapping Oidc exceptions map to the Oidc Kotlin`() { + assertThat(OidcException.Generic("Generic").mapAuthenticationException()) + .isException("Generic") + assertThat(OidcException.CallbackUrlInvalid("CallbackUrlInvalid").mapAuthenticationException()) + .isException("CallbackUrlInvalid") + assertThat(OidcException.Cancelled("Cancelled").mapAuthenticationException()) + .isException("Cancelled") + assertThat(OidcException.MetadataInvalid("MetadataInvalid").mapAuthenticationException()) + .isException("MetadataInvalid") + assertThat(OidcException.NotSupported("NotSupported").mapAuthenticationException()) + .isException("NotSupported") + } + + private inline fun ThrowableSubject.isException(message: String) { + isInstanceOf(T::class.java) + hasMessageThat().isEqualTo(message) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/FakePassphraseGenerator.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/FakePassphraseGenerator.kt new file mode 100644 index 0000000..a089920 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/FakePassphraseGenerator.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth + +import io.element.android.libraries.matrix.impl.keys.PassphraseGenerator +import io.element.android.libraries.matrix.test.A_PASSPHRASE + +class FakePassphraseGenerator( + private val passphrase: () -> String? = { A_PASSPHRASE } +) : PassphraseGenerator { + override fun generatePassphrase(): String? = passphrase() +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/FakeProxyProvider.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/FakeProxyProvider.kt new file mode 100644 index 0000000..4fa0c06 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/FakeProxyProvider.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth + +import io.element.android.libraries.matrix.impl.proxy.ProxyProvider + +class FakeProxyProvider : ProxyProvider { + override fun provides(): String? { + return null + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/FakeUserCertificatesProvider.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/FakeUserCertificatesProvider.kt new file mode 100644 index 0000000..bf4f697 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/FakeUserCertificatesProvider.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth + +import io.element.android.libraries.matrix.impl.certificates.UserCertificatesProvider + +class FakeUserCertificatesProvider : UserCertificatesProvider { + override fun provides(): List { + return emptyList() + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/HomeserverDetailsKtTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/HomeserverDetailsKtTest.kt new file mode 100644 index 0000000..82fa3df --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/HomeserverDetailsKtTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserverLoginDetails +import org.junit.Ignore +import org.junit.Test + +@Ignore("JNA direct mapping has broken unit tests with FFI fakes") +class HomeserverDetailsKtTest { + @Test + fun `map should be correct`() { + // Given + val homeserverLoginDetails = FakeFfiHomeserverLoginDetails( + url = "https://example.org", + supportsPasswordLogin = true, + supportsOidcLogin = false + ) + + // When + val result = homeserverLoginDetails.map() + + // Then + assertThat(result).isEqualTo( + MatrixHomeServerDetails( + url = "https://example.org", + supportsPasswordLogin = true, + supportsOidcLogin = false + ) + ) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProviderTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProviderTest.kt new file mode 100644 index 0000000..095cf54 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/OidcConfigurationProviderTest.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.auth.FAKE_REDIRECT_URL +import io.element.android.libraries.matrix.test.auth.FakeOidcRedirectUrlProvider +import io.element.android.libraries.matrix.test.core.aBuildMeta +import org.junit.Test + +class OidcConfigurationProviderTest { + @Test + fun get() { + val result = OidcConfigurationProvider( + buildMeta = aBuildMeta( + applicationName = "myName", + ), + oidcRedirectUrlProvider = FakeOidcRedirectUrlProvider(), + ).get() + assertThat(result.clientName).isEqualTo("myName") + assertThat(result.redirectUri).isEqualTo(FAKE_REDIRECT_URL) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeserverLoginCompatibilityCheckerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeserverLoginCompatibilityCheckerTest.kt new file mode 100644 index 0000000..c0122b2 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeserverLoginCompatibilityCheckerTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.impl.FakeClientBuilderProvider +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClientBuilder +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserverLoginDetails +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test + +@Ignore("JNA direct mapping has broken unit tests with FFI fakes") +class RustHomeserverLoginCompatibilityCheckerTest { + @Test + fun `check - is valid if it supports OIDC login`() = runTest { + val sut = createChecker { FakeFfiHomeserverLoginDetails(supportsOidcLogin = true) } + assertThat(sut.check("https://matrix.host.org").getOrNull()).isTrue() + } + + @Test + fun `check - is valid if it supports password login`() = runTest { + val sut = createChecker { FakeFfiHomeserverLoginDetails(supportsPasswordLogin = true) } + assertThat(sut.check("https://matrix.host.org").getOrNull()).isTrue() + } + + @Test + fun `check - is not valid if it only supports SSO login`() = runTest { + val sut = createChecker { FakeFfiHomeserverLoginDetails(supportsSsoLogin = true) } + assertThat(sut.check("https://matrix.host.org").getOrNull()).isFalse() + } + + @Test + fun `check - is not valid if fetching the data fails`() = runTest { + val sut = createChecker { error("Unexpected error!") } + assertThat(sut.check("https://matrix.host.org").isFailure).isTrue() + } + + private fun createChecker( + result: () -> FakeFfiHomeserverLoginDetails, + ) = RustHomeServerLoginCompatibilityChecker( + clientBuilderProvider = FakeClientBuilderProvider { + FakeFfiClientBuilder { + FakeFfiClient(homeserverLoginDetailsResult = result) + } + }, + userCertificatesProvider = FakeUserCertificatesProvider(), + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt new file mode 100644 index 0000000..f620e94 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.impl.ClientBuilderProvider +import io.element.android.libraries.matrix.impl.FakeClientBuilderProvider +import io.element.android.libraries.matrix.impl.createRustMatrixClientFactory +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClientBuilder +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserverLoginDetails +import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory +import io.element.android.libraries.matrix.test.auth.FakeOidcRedirectUrlProvider +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test +import java.io.File + +class RustMatrixAuthenticationServiceTest { + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `setHomeserver is successful`() = runTest { + val sut = createRustMatrixAuthenticationService( + clientBuilderProvider = FakeClientBuilderProvider( + provideResult = { + FakeFfiClientBuilder( + buildResult = { + FakeFfiClient( + homeserverLoginDetailsResult = { + FakeFfiHomeserverLoginDetails() + } + ) + } + ) + } + ), + ) + assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue() + } + + private fun TestScope.createRustMatrixAuthenticationService( + sessionStore: SessionStore = InMemorySessionStore(), + clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), + ): RustMatrixAuthenticationService { + val baseDirectory = File("/base") + val cacheDirectory = File("/cache") + val rustMatrixClientFactory = createRustMatrixClientFactory( + cacheDirectory = cacheDirectory, + sessionStore = sessionStore, + clientBuilderProvider = clientBuilderProvider, + ) + return RustMatrixAuthenticationService( + sessionPathsFactory = SessionPathsFactory(baseDirectory, cacheDirectory), + coroutineDispatchers = testCoroutineDispatchers(), + sessionStore = sessionStore, + rustMatrixClientFactory = rustMatrixClientFactory, + passphraseGenerator = FakePassphraseGenerator(), + oidcConfigurationProvider = OidcConfigurationProvider( + buildMeta = aBuildMeta(), + oidcRedirectUrlProvider = FakeOidcRedirectUrlProvider(), + ), + ) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrErrorMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrErrorMapperTest.kt new file mode 100644 index 0000000..0ef20c8 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrErrorMapperTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth.qrlogin + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeDecodeException +import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException +import org.junit.Test +import org.matrix.rustcomponents.sdk.HumanQrLoginException as RustHumanQrLoginException +import org.matrix.rustcomponents.sdk.QrCodeDecodeException as RustQrCodeDecodeException + +class QrErrorMapperTest { + @Test + fun `test map QrCodeDecodeException`() { + val result = QrErrorMapper.map(RustQrCodeDecodeException.Crypto("test")) + assertThat(result).isInstanceOf(QrCodeDecodeException.Crypto::class.java) + assertThat(result.message).isEqualTo("test") + } + + @Test + fun `test map HumanQrLoginException`() { + assertThat(QrErrorMapper.map(RustHumanQrLoginException.Cancelled())).isEqualTo(QrLoginException.Cancelled) + assertThat(QrErrorMapper.map(RustHumanQrLoginException.ConnectionInsecure())).isEqualTo(QrLoginException.ConnectionInsecure) + assertThat(QrErrorMapper.map(RustHumanQrLoginException.Declined())).isEqualTo(QrLoginException.Declined) + assertThat(QrErrorMapper.map(RustHumanQrLoginException.Expired())).isEqualTo(QrLoginException.Expired) + assertThat(QrErrorMapper.map(RustHumanQrLoginException.OtherDeviceNotSignedIn())).isEqualTo(QrLoginException.OtherDeviceNotSignedIn) + assertThat(QrErrorMapper.map(RustHumanQrLoginException.LinkingNotSupported())).isEqualTo(QrLoginException.LinkingNotSupported) + assertThat(QrErrorMapper.map(RustHumanQrLoginException.Unknown())).isEqualTo(QrLoginException.Unknown) + assertThat(QrErrorMapper.map(RustHumanQrLoginException.OidcMetadataInvalid())).isEqualTo(QrLoginException.OidcMetadataInvalid) + assertThat(QrErrorMapper.map(RustHumanQrLoginException.SlidingSyncNotAvailable())).isEqualTo(QrLoginException.SlidingSyncNotAvailable) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrLoginProgressExtensionsKtTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrLoginProgressExtensionsKtTest.kt new file mode 100644 index 0000000..94d1833 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrLoginProgressExtensionsKtTest.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth.qrlogin + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import org.junit.Test +import org.matrix.rustcomponents.sdk.QrLoginProgress + +class QrLoginProgressExtensionsKtTest { + @Test + fun `mapping QrLoginProgress should return expected result`() { + assertThat(QrLoginProgress.Starting.toStep()) + .isEqualTo(QrCodeLoginStep.Starting) + assertThat(QrLoginProgress.EstablishingSecureChannel(1u, "01").toStep()) + .isEqualTo(QrCodeLoginStep.EstablishingSecureChannel("01")) + assertThat(QrLoginProgress.WaitingForToken("userCode").toStep()) + .isEqualTo(QrCodeLoginStep.WaitingForToken("userCode")) + assertThat(QrLoginProgress.Done.toStep()) + .isEqualTo(QrCodeLoginStep.Finished) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/SdkQrCodeLoginDataTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/SdkQrCodeLoginDataTest.kt new file mode 100644 index 0000000..d4c71f2 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/SdkQrCodeLoginDataTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.auth.qrlogin + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiQrCodeData +import io.element.android.libraries.matrix.test.A_HOMESERVER_URL +import org.junit.Ignore +import org.junit.Test + +@Ignore("JNA direct mapping has broken unit tests with FFI fakes") +class SdkQrCodeLoginDataTest { + @Test + fun `getServer reads the value from the Rust side, null case`() { + val sut = SdkQrCodeLoginData( + rustQrCodeData = FakeFfiQrCodeData( + serverNameResult = { null }, + ), + ) + assertThat(sut.serverName()).isNull() + } + + @Test + fun `getServer reads the value from the Rust side`() { + val sut = SdkQrCodeLoginData( + rustQrCodeData = FakeFfiQrCodeData( + serverNameResult = { A_HOMESERVER_URL }, + ), + ) + assertThat(sut.serverName()).isEqualTo(A_HOMESERVER_URL) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/core/ProgressWatcherWrapperKtTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/core/ProgressWatcherWrapperKtTest.kt new file mode 100644 index 0000000..63b7c9a --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/core/ProgressWatcherWrapperKtTest.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.core + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.ProgressCallback +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.matrix.rustcomponents.sdk.TransmissionProgress +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +class ProgressWatcherWrapperKtTest { + @Test + fun testToProgressWatcher() = runTest { + suspendCoroutine { continuation -> + val callback = object : ProgressCallback { + override fun onProgress(current: Long, total: Long) { + assertThat(current).isEqualTo(1) + assertThat(total).isEqualTo(2) + continuation.resume(Unit) + } + } + val result = callback.toProgressWatcher() + result.transmissionProgress(TransmissionProgress(1.toULong(), 2.toULong())) + } + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/encryption/BackupStateMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/encryption/BackupStateMapperTest.kt new file mode 100644 index 0000000..66e71ae --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/encryption/BackupStateMapperTest.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.encryption + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.encryption.BackupState +import org.junit.Test +import org.matrix.rustcomponents.sdk.BackupState as RustBackupState + +class BackupStateMapperTest { + @Test + fun `Ensure that mapping is correct`() { + val sut = BackupStateMapper() + assertThat(sut.map(RustBackupState.UNKNOWN)).isEqualTo(BackupState.UNKNOWN) + assertThat(sut.map(RustBackupState.CREATING)).isEqualTo(BackupState.CREATING) + assertThat(sut.map(RustBackupState.ENABLING)).isEqualTo(BackupState.ENABLING) + assertThat(sut.map(RustBackupState.RESUMING)).isEqualTo(BackupState.RESUMING) + assertThat(sut.map(RustBackupState.ENABLED)).isEqualTo(BackupState.ENABLED) + assertThat(sut.map(RustBackupState.DOWNLOADING)).isEqualTo(BackupState.DOWNLOADING) + assertThat(sut.map(RustBackupState.DISABLING)).isEqualTo(BackupState.DISABLING) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/encryption/BackupUploadStateMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/encryption/BackupUploadStateMapperTest.kt new file mode 100644 index 0000000..8818607 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/encryption/BackupUploadStateMapperTest.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.encryption + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.encryption.BackupUploadState +import org.junit.Test +import org.matrix.rustcomponents.sdk.BackupUploadState as RustBackupUploadState + +class BackupUploadStateMapperTest { + @Test + fun `Ensure that mapping is correct`() { + val sut = BackupUploadStateMapper() + assertThat(sut.map(RustBackupUploadState.Waiting)) + .isEqualTo(BackupUploadState.Waiting) + assertThat(sut.map(RustBackupUploadState.Error)) + .isEqualTo(BackupUploadState.Error) + assertThat(sut.map(RustBackupUploadState.Done)) + .isEqualTo(BackupUploadState.Done) + assertThat(sut.map(RustBackupUploadState.Uploading(1.toUInt(), 2.toUInt()))) + .isEqualTo(BackupUploadState.Uploading(1, 2)) + } + + @Test + fun `Ensure that full uploading is mapper to Done`() { + val sut = BackupUploadStateMapper() + assertThat(sut.map(RustBackupUploadState.Uploading(2.toUInt(), 2.toUInt()))) + .isEqualTo(BackupUploadState.Done) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/encryption/EnableRecoveryProgressMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/encryption/EnableRecoveryProgressMapperTest.kt new file mode 100644 index 0000000..a0227c9 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/encryption/EnableRecoveryProgressMapperTest.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.encryption + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress +import org.junit.Test +import org.matrix.rustcomponents.sdk.EnableRecoveryProgress as RustEnableRecoveryProgress + +class EnableRecoveryProgressMapperTest { + @Test + fun `Ensure that mapping is correct`() { + val sut = EnableRecoveryProgressMapper() + assertThat(sut.map(RustEnableRecoveryProgress.CreatingRecoveryKey)) + .isEqualTo(EnableRecoveryProgress.CreatingRecoveryKey) + assertThat(sut.map(RustEnableRecoveryProgress.CreatingBackup)) + .isEqualTo(EnableRecoveryProgress.CreatingBackup) + assertThat(sut.map(RustEnableRecoveryProgress.Starting)) + .isEqualTo(EnableRecoveryProgress.Starting) + assertThat(sut.map(RustEnableRecoveryProgress.BackingUp(1.toUInt(), 2.toUInt()))) + .isEqualTo(EnableRecoveryProgress.BackingUp(1, 2)) + assertThat(sut.map(RustEnableRecoveryProgress.RoomKeyUploadError)) + .isEqualTo(EnableRecoveryProgress.RoomKeyUploadError) + assertThat(sut.map(RustEnableRecoveryProgress.Done("recoveryKey"))) + .isEqualTo(EnableRecoveryProgress.Done("recoveryKey")) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/encryption/RecoveryStateMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/encryption/RecoveryStateMapperTest.kt new file mode 100644 index 0000000..1083519 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/encryption/RecoveryStateMapperTest.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.encryption + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.encryption.RecoveryState +import org.junit.Test +import org.matrix.rustcomponents.sdk.RecoveryState as RustRecoveryState + +class RecoveryStateMapperTest { + @Test + fun `Ensure that mapping is correct`() { + val sut = RecoveryStateMapper() + assertThat(sut.map(RustRecoveryState.UNKNOWN)).isEqualTo(RecoveryState.UNKNOWN) + assertThat(sut.map(RustRecoveryState.ENABLED)).isEqualTo(RecoveryState.ENABLED) + assertThat(sut.map(RustRecoveryState.DISABLED)).isEqualTo(RecoveryState.DISABLED) + assertThat(sut.map(RustRecoveryState.INCOMPLETE)).isEqualTo(RecoveryState.INCOMPLETE) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt new file mode 100644 index 0000000..1e5692d --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.factories + +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiLazyTimelineItemProvider +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import org.matrix.rustcomponents.sdk.EventOrTransactionId +import org.matrix.rustcomponents.sdk.EventSendState +import org.matrix.rustcomponents.sdk.EventTimelineItem +import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo +import org.matrix.rustcomponents.sdk.ProfileDetails +import org.matrix.rustcomponents.sdk.Receipt +import org.matrix.rustcomponents.sdk.ShieldState +import org.matrix.rustcomponents.sdk.TimelineItemContent +import uniffi.matrix_sdk_ui.EventItemOrigin + +fun aRustEventTimelineItem( + isRemote: Boolean = true, + eventOrTransactionId: EventOrTransactionId = EventOrTransactionId.EventId(AN_EVENT_ID.value), + sender: String = A_USER_ID.value, + senderProfile: ProfileDetails = ProfileDetails.Unavailable, + isOwn: Boolean = true, + isEditable: Boolean = true, + content: TimelineItemContent = aRustTimelineItemMessageContent(), + timestamp: ULong = 0uL, + debugInfo: EventTimelineItemDebugInfo = anEventTimelineItemDebugInfo(), + localSendState: EventSendState? = null, + readReceipts: Map = emptyMap(), + origin: EventItemOrigin? = EventItemOrigin.SYNC, + canBeRepliedTo: Boolean = true, + shieldsState: ShieldState? = null, + localCreatedAt: ULong? = null, +) = EventTimelineItem( + isRemote = isRemote, + eventOrTransactionId = eventOrTransactionId, + sender = sender, + senderProfile = senderProfile, + timestamp = timestamp, + isOwn = isOwn, + isEditable = isEditable, + canBeRepliedTo = canBeRepliedTo, + content = content, + localSendState = localSendState, + readReceipts = readReceipts, + origin = origin, + localCreatedAt = localCreatedAt, + lazyProvider = FakeFfiLazyTimelineItemProvider( + debugInfo = debugInfo, + shieldsState = shieldsState, + ) +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItemContent.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItemContent.kt new file mode 100644 index 0000000..cf29697 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItemContent.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.factories + +import org.matrix.rustcomponents.sdk.MessageContent +import org.matrix.rustcomponents.sdk.MessageType +import org.matrix.rustcomponents.sdk.MsgLikeContent +import org.matrix.rustcomponents.sdk.MsgLikeKind +import org.matrix.rustcomponents.sdk.TextMessageContent +import org.matrix.rustcomponents.sdk.TimelineItemContent + +fun aRustTimelineItemMessageContent(body: String = "Hello") = TimelineItemContent.MsgLike( + content = MsgLikeContent( + kind = MsgLikeKind.Message( + content = MessageContent( + msgType = MessageType.Text(content = TextMessageContent(body = body, formatted = null)), + body = body, + isEdited = false, + mentions = null, + ) + ), + reactions = emptyList(), + threadRoot = null, + inReplyTo = null, + threadSummary = null, + ), +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItemDebugInfo.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItemDebugInfo.kt new file mode 100644 index 0000000..6995c49 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItemDebugInfo.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.factories + +import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo + +fun anEventTimelineItemDebugInfo( + model: String = "model", + originalJson: String? = null, + latestEditJson: String? = null, +) = EventTimelineItemDebugInfo( + model = model, + originalJson = originalJson, + latestEditJson = latestEditJson +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/NotificationItem.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/NotificationItem.kt new file mode 100644 index 0000000..e5b77b2 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/NotificationItem.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.factories + +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTimelineEvent +import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.libraries.matrix.test.A_USER_NAME +import org.matrix.rustcomponents.sdk.Action +import org.matrix.rustcomponents.sdk.JoinRule +import org.matrix.rustcomponents.sdk.NotificationEvent +import org.matrix.rustcomponents.sdk.NotificationItem +import org.matrix.rustcomponents.sdk.NotificationRoomInfo +import org.matrix.rustcomponents.sdk.NotificationSenderInfo +import org.matrix.rustcomponents.sdk.NotificationStatus +import org.matrix.rustcomponents.sdk.TimelineEvent + +fun aRustNotificationItem( + event: NotificationEvent = aRustNotificationEventTimeline(), + senderInfo: NotificationSenderInfo = aRustNotificationSenderInfo(), + roomInfo: NotificationRoomInfo = aRustNotificationRoomInfo(), + isNoisy: Boolean? = false, + hasMention: Boolean? = false, + threadId: ThreadId? = null, + actions: List? = null, +) = NotificationItem( + event = event, + senderInfo = senderInfo, + roomInfo = roomInfo, + isNoisy = isNoisy, + hasMention = hasMention, + threadId = threadId?.value, + actions = actions, +) + +fun aRustBatchNotificationResult( + notificationStatus: NotificationStatus = NotificationStatus.Event(aRustNotificationItem()), +) = org.matrix.rustcomponents.sdk.BatchNotificationResult.Ok( + status = notificationStatus, +) + +fun aRustNotificationSenderInfo( + displayName: String? = A_USER_NAME, + avatarUrl: String? = null, + isNameAmbiguous: Boolean = false, +) = NotificationSenderInfo( + displayName = displayName, + avatarUrl = avatarUrl, + isNameAmbiguous = isNameAmbiguous, +) + +fun aRustNotificationRoomInfo( + displayName: String = A_ROOM_NAME, + avatarUrl: String? = null, + canonicalAlias: String? = null, + topic: String? = null, + joinedMembersCount: ULong = 2u, + isEncrypted: Boolean? = true, + isDirect: Boolean = false, + joinRule: JoinRule? = null, + isSpace: Boolean = false, +) = NotificationRoomInfo( + displayName = displayName, + avatarUrl = avatarUrl, + canonicalAlias = canonicalAlias, + topic = topic, + joinedMembersCount = joinedMembersCount, + isEncrypted = isEncrypted, + isDirect = isDirect, + joinRule = joinRule, + isSpace = isSpace, +) + +fun aRustNotificationEventTimeline( + event: TimelineEvent = FakeFfiTimelineEvent(), +) = NotificationEvent.Timeline( + event = event, +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomDescription.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomDescription.kt new file mode 100644 index 0000000..9490937 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomDescription.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.factories + +import io.element.android.libraries.matrix.test.A_ROOM_ALIAS +import io.element.android.libraries.matrix.test.A_ROOM_ID +import org.matrix.rustcomponents.sdk.PublicRoomJoinRule +import org.matrix.rustcomponents.sdk.RoomDescription + +internal fun aRustRoomDescription( + roomId: String = A_ROOM_ID.value, + name: String? = "name", + topic: String? = "topic", + alias: String? = A_ROOM_ALIAS.value, + avatarUrl: String? = "avatarUrl", + joinRule: PublicRoomJoinRule = PublicRoomJoinRule.PUBLIC, + isWorldReadable: Boolean = true, + joinedMembers: ULong = 2u, +): RoomDescription { + return RoomDescription( + roomId = roomId, + name = name, + topic = topic, + alias = alias, + avatarUrl = avatarUrl, + joinRule = joinRule, + isWorldReadable = isWorldReadable, + joinedMembers = joinedMembers, + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomHero.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomHero.kt new file mode 100644 index 0000000..6725d80 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomHero.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.factories + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.test.A_USER_ID +import org.matrix.rustcomponents.sdk.RoomHero + +internal fun aRustRoomHero( + userId: UserId = A_USER_ID, +): RoomHero { + return RoomHero( + userId = userId.value, + displayName = "displayName", + avatarUrl = "avatarUrl", + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomInfo.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomInfo.kt new file mode 100644 index 0000000..b8454b2 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomInfo.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.factories + +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomPowerLevels +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_NAME +import org.matrix.rustcomponents.sdk.JoinRule +import org.matrix.rustcomponents.sdk.Membership +import org.matrix.rustcomponents.sdk.RoomHero +import org.matrix.rustcomponents.sdk.RoomHistoryVisibility +import org.matrix.rustcomponents.sdk.RoomInfo +import org.matrix.rustcomponents.sdk.RoomMember +import org.matrix.rustcomponents.sdk.RoomNotificationMode +import org.matrix.rustcomponents.sdk.RoomPowerLevels +import org.matrix.rustcomponents.sdk.SuccessorRoom +import uniffi.matrix_sdk_base.EncryptionState + +fun aRustRoomInfo( + id: String = A_ROOM_ID.value, + displayName: String? = A_ROOM_NAME, + rawName: String? = A_ROOM_NAME, + topic: String? = null, + avatarUrl: String? = null, + encryptionState: EncryptionState = EncryptionState.UNKNOWN, + isDirect: Boolean = false, + isPublic: Boolean = false, + isSpace: Boolean = false, + isFavourite: Boolean = false, + canonicalAlias: String? = null, + alternativeAliases: List = listOf(), + membership: Membership = Membership.JOINED, + inviter: RoomMember? = null, + heroes: List = listOf(), + activeMembersCount: ULong = 0uL, + invitedMembersCount: ULong = 0uL, + joinedMembersCount: ULong = 0uL, + roomPowerLevels: RoomPowerLevels = FakeFfiRoomPowerLevels(), + highlightCount: ULong = 0uL, + notificationCount: ULong = 0uL, + userDefinedNotificationMode: RoomNotificationMode? = null, + hasRoomCall: Boolean = false, + activeRoomCallParticipants: List = listOf(), + isMarkedUnread: Boolean = false, + numUnreadMessages: ULong = 0uL, + numUnreadNotifications: ULong = 0uL, + numUnreadMentions: ULong = 0uL, + pinnedEventIds: List = listOf(), + roomCreators: List? = emptyList(), + joinRule: JoinRule? = null, + historyVisibility: RoomHistoryVisibility = RoomHistoryVisibility.Joined, + successorRoom: SuccessorRoom? = null, + roomVersion: String? = "11", + privilegedCreatorsRole: Boolean = false, +) = RoomInfo( + id = id, + displayName = displayName, + rawName = rawName, + topic = topic, + avatarUrl = avatarUrl, + encryptionState = encryptionState, + isDirect = isDirect, + isPublic = isPublic, + isSpace = isSpace, + isFavourite = isFavourite, + canonicalAlias = canonicalAlias, + alternativeAliases = alternativeAliases, + membership = membership, + inviter = inviter, + heroes = heroes, + activeMembersCount = activeMembersCount, + invitedMembersCount = invitedMembersCount, + joinedMembersCount = joinedMembersCount, + powerLevels = roomPowerLevels, + highlightCount = highlightCount, + notificationCount = notificationCount, + cachedUserDefinedNotificationMode = userDefinedNotificationMode, + hasRoomCall = hasRoomCall, + activeRoomCallParticipants = activeRoomCallParticipants, + isMarkedUnread = isMarkedUnread, + numUnreadMessages = numUnreadMessages, + numUnreadNotifications = numUnreadNotifications, + numUnreadMentions = numUnreadMentions, + pinnedEventIds = pinnedEventIds, + creators = roomCreators, + joinRule = joinRule, + historyVisibility = historyVisibility, + successorRoom = successorRoom, + roomVersion = roomVersion, + privilegedCreatorsRole = privilegedCreatorsRole, +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomMember.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomMember.kt new file mode 100644 index 0000000..9004340 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomMember.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.factories + +import io.element.android.libraries.matrix.api.core.UserId +import org.matrix.rustcomponents.sdk.MembershipState +import org.matrix.rustcomponents.sdk.PowerLevel +import org.matrix.rustcomponents.sdk.RoomMember +import uniffi.matrix_sdk.RoomMemberRole + +fun aRustRoomMember( + userId: UserId, + displayName: String? = null, + avatarUrl: String? = null, + membership: MembershipState = MembershipState.Join, + isNameAmbiguous: Boolean = false, + powerLevel: PowerLevel = PowerLevel.Value(0L), + isIgnored: Boolean = false, + role: RoomMemberRole = RoomMemberRole.USER, + membershipChangeReason: String? = null, +) = RoomMember( + userId = userId.value, + displayName = displayName, + avatarUrl = avatarUrl, + membership = membership, + isNameAmbiguous = isNameAmbiguous, + powerLevel = powerLevel, + isIgnored = isIgnored, + suggestedRoleForPowerLevel = role, + membershipChangeReason = membershipChangeReason, +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomNotificationSettings.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomNotificationSettings.kt new file mode 100644 index 0000000..30b9ed4 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomNotificationSettings.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.factories + +import org.matrix.rustcomponents.sdk.RoomNotificationMode +import org.matrix.rustcomponents.sdk.RoomNotificationSettings + +fun aRustRoomNotificationSettings( + mode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES, + isDefault: Boolean = true, +) = RoomNotificationSettings( + mode = mode, + isDefault = isDefault +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomPowerLevels.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomPowerLevels.kt new file mode 100644 index 0000000..1c1bbb4 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomPowerLevels.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.factories + +import org.matrix.rustcomponents.sdk.RoomPowerLevelsValues + +internal fun aRustRoomPowerLevelsValues( + ban: Long, + invite: Long, + kick: Long, + redact: Long, + eventsDefault: Long, + stateDefault: Long, + usersDefault: Long, + roomName: Long, + roomAvatar: Long, + roomTopic: Long, + spaceChild: Long, +) = RoomPowerLevelsValues( + ban = ban, + invite = invite, + kick = kick, + redact = redact, + eventsDefault = eventsDefault, + stateDefault = stateDefault, + usersDefault = usersDefault, + roomName = roomName, + roomAvatar = roomAvatar, + roomTopic = roomTopic, + spaceChild = spaceChild +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomPreviewInfo.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomPreviewInfo.kt new file mode 100644 index 0000000..0ff92f2 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomPreviewInfo.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.factories + +import io.element.android.libraries.matrix.test.A_ROOM_ALIAS +import io.element.android.libraries.matrix.test.A_ROOM_ID +import org.matrix.rustcomponents.sdk.JoinRule +import org.matrix.rustcomponents.sdk.Membership +import org.matrix.rustcomponents.sdk.RoomPreviewInfo +import org.matrix.rustcomponents.sdk.RoomType + +internal fun aRustRoomPreviewInfo( + canonicalAlias: String? = A_ROOM_ALIAS.value, + membership: Membership? = Membership.JOINED, + joinRule: JoinRule = JoinRule.Public, +): RoomPreviewInfo { + return RoomPreviewInfo( + roomId = A_ROOM_ID.value, + canonicalAlias = canonicalAlias, + name = "name", + topic = "topic", + avatarUrl = "avatarUrl", + numJoinedMembers = 1u, + numActiveMembers = 1u, + isDirect = false, + roomType = RoomType.Room, + isHistoryWorldReadable = true, + membership = membership, + joinRule = joinRule, + heroes = null, + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/SearchUsersResults.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/SearchUsersResults.kt new file mode 100644 index 0000000..7782eb5 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/SearchUsersResults.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.factories + +import org.matrix.rustcomponents.sdk.SearchUsersResults +import org.matrix.rustcomponents.sdk.UserProfile + +internal fun aRustSearchUsersResults( + results: List, + limited: Boolean, +) = SearchUsersResults( + results = results, + limited = limited, +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/Session.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/Session.kt new file mode 100644 index 0000000..133e7ff --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/Session.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.factories + +import io.element.android.libraries.matrix.test.A_DEVICE_ID +import io.element.android.libraries.matrix.test.A_HOMESERVER_URL +import io.element.android.libraries.matrix.test.A_USER_ID +import org.matrix.rustcomponents.sdk.Session +import org.matrix.rustcomponents.sdk.SlidingSyncVersion + +internal fun aRustSession( + proxy: SlidingSyncVersion = SlidingSyncVersion.NONE, + accessToken: String = "accessToken", + refreshToken: String = "refreshToken", +): Session { + return Session( + accessToken = accessToken, + refreshToken = refreshToken, + userId = A_USER_ID.value, + deviceId = A_DEVICE_ID.value, + homeserverUrl = A_HOMESERVER_URL, + oidcData = null, + slidingSyncVersion = proxy, + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/SpaceRoom.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/SpaceRoom.kt new file mode 100644 index 0000000..7b82127 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/SpaceRoom.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.factories + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.test.A_ROOM_ID +import org.matrix.rustcomponents.sdk.JoinRule +import org.matrix.rustcomponents.sdk.Membership +import org.matrix.rustcomponents.sdk.RoomHero +import org.matrix.rustcomponents.sdk.RoomType +import org.matrix.rustcomponents.sdk.SpaceRoom + +fun aRustSpaceRoom( + roomId: RoomId = A_ROOM_ID, + isDirect: Boolean = false, + canonicalAlias: String? = null, + rawName: String? = null, + displayName: String = "", + topic: String? = null, + avatarUrl: String? = null, + roomType: RoomType = RoomType.Space, + numJoinedMembers: ULong = 0uL, + joinRule: JoinRule? = null, + worldReadable: Boolean? = null, + guestCanJoin: Boolean = false, + childrenCount: ULong = 0uL, + state: Membership? = null, + heroes: List = emptyList(), +) = SpaceRoom( + roomId = roomId.value, + isDirect = isDirect, + canonicalAlias = canonicalAlias, + rawName = rawName, + displayName = displayName, + topic = topic, + avatarUrl = avatarUrl, + roomType = roomType, + numJoinedMembers = numJoinedMembers, + joinRule = joinRule, + worldReadable = worldReadable, + guestCanJoin = guestCanJoin, + childrenCount = childrenCount, + state = state, + heroes = heroes, + via = emptyList() +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventType.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventType.kt new file mode 100644 index 0000000..395b2ae --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventType.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.factories + +import io.element.android.libraries.matrix.test.A_MESSAGE +import org.matrix.rustcomponents.sdk.FormattedBody +import org.matrix.rustcomponents.sdk.MessageLikeEventContent +import org.matrix.rustcomponents.sdk.MessageType +import org.matrix.rustcomponents.sdk.TextMessageContent +import org.matrix.rustcomponents.sdk.TimelineEventType + +fun aRustTimelineEventTypeMessageLike( + content: MessageLikeEventContent = aRustMessageLikeEventContentRoomMessage(), +): TimelineEventType.MessageLike { + return TimelineEventType.MessageLike( + content = content, + ) +} + +fun aRustMessageLikeEventContentRoomMessage( + messageType: MessageType = aRustMessageTypeText(), + inReplyToEventId: String? = null, +) = MessageLikeEventContent.RoomMessage( + messageType = messageType, + inReplyToEventId = inReplyToEventId, +) + +fun aRustMessageTypeText( + content: TextMessageContent = aRustTextMessageContent(), +) = MessageType.Text( + content = content, +) + +fun aRustTextMessageContent( + body: String = A_MESSAGE, + formatted: FormattedBody? = null, +) = TextMessageContent( + body = body, + formatted = formatted, +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UnableToDecryptInfo.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UnableToDecryptInfo.kt new file mode 100644 index 0000000..7a7acec --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UnableToDecryptInfo.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.factories + +import org.matrix.rustcomponents.sdk.UnableToDecryptInfo +import uniffi.matrix_sdk_crypto.UtdCause + +internal fun aRustUnableToDecryptInfo( + eventId: String, + timeToDecryptMs: ULong? = null, + cause: UtdCause = UtdCause.UNKNOWN, + eventLocalAgeMillis: Long = 0L, + userTrustsOwnIdentity: Boolean = false, + senderHomeserver: String = "", + ownHomeserver: String = "", +): UnableToDecryptInfo { + return UnableToDecryptInfo( + eventId = eventId, + timeToDecryptMs = timeToDecryptMs, + cause = cause, + eventLocalAgeMillis = eventLocalAgeMillis, + userTrustsOwnIdentity = userTrustsOwnIdentity, + senderHomeserver = senderHomeserver, + ownHomeserver = ownHomeserver, + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UserProfile.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UserProfile.kt new file mode 100644 index 0000000..c91327a --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UserProfile.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.factories + +import io.element.android.libraries.matrix.test.A_USER_ID +import org.matrix.rustcomponents.sdk.UserProfile + +fun aRustUserProfile( + userId: String = A_USER_ID.value, + displayName: String = "displayName", + avatarUrl: String = "avatarUrl", +) = UserProfile( + userId = userId, + displayName = displayName, + avatarUrl = avatarUrl, +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt new file mode 100644 index 0000000..a2227ab --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSession +import io.element.android.libraries.matrix.test.A_DEVICE_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.ClientDelegate +import org.matrix.rustcomponents.sdk.Encryption +import org.matrix.rustcomponents.sdk.HomeserverLoginDetails +import org.matrix.rustcomponents.sdk.IgnoredUsersListener +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.NotificationClient +import org.matrix.rustcomponents.sdk.NotificationProcessSetup +import org.matrix.rustcomponents.sdk.NotificationSettings +import org.matrix.rustcomponents.sdk.PusherIdentifiers +import org.matrix.rustcomponents.sdk.PusherKind +import org.matrix.rustcomponents.sdk.RoomDirectorySearch +import org.matrix.rustcomponents.sdk.Session +import org.matrix.rustcomponents.sdk.SessionVerificationController +import org.matrix.rustcomponents.sdk.SpaceService +import org.matrix.rustcomponents.sdk.SyncService +import org.matrix.rustcomponents.sdk.SyncServiceBuilder +import org.matrix.rustcomponents.sdk.TaskHandle +import org.matrix.rustcomponents.sdk.UnableToDecryptDelegate +import org.matrix.rustcomponents.sdk.UserProfile + +class FakeFfiClient( + private val userId: String = A_USER_ID.value, + private val deviceId: String = A_DEVICE_ID.value, + private val notificationClient: NotificationClient = FakeFfiNotificationClient(), + private val notificationSettings: NotificationSettings = FakeFfiNotificationSettings(), + private val encryption: Encryption = FakeFfiEncryption(), + private val session: Session = aRustSession(), + private val clearCachesResult: () -> Unit = { lambdaError() }, + private val withUtdHook: (UnableToDecryptDelegate) -> Unit = { lambdaError() }, + private val getProfileResult: (String) -> UserProfile = { UserProfile(userId = userId, displayName = null, avatarUrl = null) }, + private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() }, + private val closeResult: () -> Unit = {}, +) : Client(NoHandle) { + override fun userId(): String = userId + override fun deviceId(): String = deviceId + override suspend fun notificationClient(processSetup: NotificationProcessSetup) = notificationClient + override suspend fun getNotificationSettings(): NotificationSettings = notificationSettings + override fun encryption(): Encryption = encryption + override fun session(): Session = session + override fun setDelegate(delegate: ClientDelegate?): TaskHandle = FakeFfiTaskHandle() + override suspend fun cachedAvatarUrl(): String? = null + override suspend fun restoreSession(session: Session) = Unit + override fun syncService(): SyncServiceBuilder = FakeFfiSyncServiceBuilder() + override fun spaceService(): SpaceService = FakeFfiSpaceService() + override fun roomDirectorySearch(): RoomDirectorySearch = FakeFfiRoomDirectorySearch() + override suspend fun setPusher( + identifiers: PusherIdentifiers, + kind: PusherKind, + appDisplayName: String, + deviceDisplayName: String, + profileTag: String?, + lang: String, + ) = Unit + + override suspend fun deletePusher(identifiers: PusherIdentifiers) = Unit + override suspend fun clearCaches(syncService: SyncService?) = simulateLongTask { clearCachesResult() } + override suspend fun setUtdDelegate(utdDelegate: UnableToDecryptDelegate) = withUtdHook(utdDelegate) + override suspend fun getSessionVerificationController(): SessionVerificationController = FakeFfiSessionVerificationController() + override suspend fun ignoredUsers(): List { + return emptyList() + } + + override fun subscribeToIgnoredUsers(listener: IgnoredUsersListener): TaskHandle { + return FakeFfiTaskHandle() + } + + override suspend fun getProfile(userId: String): UserProfile { + return getProfileResult(userId) + } + + override suspend fun homeserverLoginDetails(): HomeserverLoginDetails { + return homeserverLoginDetailsResult() + } + + override fun close() = closeResult() +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClientBuilder.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClientBuilder.kt new file mode 100644 index 0000000..3ac1fef --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClientBuilder.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.ClientBuilder +import org.matrix.rustcomponents.sdk.ClientSessionDelegate +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.RequestConfig +import org.matrix.rustcomponents.sdk.SlidingSyncVersionBuilder +import org.matrix.rustcomponents.sdk.SqliteStoreBuilder +import uniffi.matrix_sdk.BackupDownloadStrategy +import uniffi.matrix_sdk_crypto.CollectStrategy +import uniffi.matrix_sdk_crypto.DecryptionSettings + +class FakeFfiClientBuilder( + val buildResult: () -> Client = { FakeFfiClient(withUtdHook = {}) } +) : ClientBuilder(NoHandle) { + override fun addRootCertificates(certificates: List) = this + override fun autoEnableBackups(autoEnableBackups: Boolean) = this + override fun autoEnableCrossSigning(autoEnableCrossSigning: Boolean) = this + override fun backupDownloadStrategy(backupDownloadStrategy: BackupDownloadStrategy) = this + override fun disableAutomaticTokenRefresh() = this + override fun disableBuiltInRootCertificates() = this + override fun decryptionSettings(decryptionSettings: DecryptionSettings): ClientBuilder = this + override fun disableSslVerification() = this + override fun homeserverUrl(url: String) = this + override fun proxy(url: String) = this + override fun requestConfig(config: RequestConfig) = this + override fun roomKeyRecipientStrategy(strategy: CollectStrategy) = this + override fun serverName(serverName: String) = this + override fun serverNameOrHomeserverUrl(serverNameOrUrl: String) = this + override fun sessionPaths(dataPath: String, cachePath: String) = this + override fun setSessionDelegate(sessionDelegate: ClientSessionDelegate) = this + override fun slidingSyncVersionBuilder(versionBuilder: SlidingSyncVersionBuilder) = this + override fun userAgent(userAgent: String) = this + override fun username(username: String) = this + override fun enableShareHistoryOnInvite(enableShareHistoryOnInvite: Boolean): ClientBuilder = this + override fun threadsEnabled(enabled: Boolean, threadSubscriptions: Boolean): ClientBuilder = this + override fun sqliteStore(config: SqliteStoreBuilder): ClientBuilder = this + override suspend fun build() = buildResult() +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiEncryption.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiEncryption.kt new file mode 100644 index 0000000..89cfd57 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiEncryption.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import io.element.android.tests.testutils.simulateLongTask +import org.matrix.rustcomponents.sdk.BackupState +import org.matrix.rustcomponents.sdk.BackupStateListener +import org.matrix.rustcomponents.sdk.Encryption +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.RecoveryState +import org.matrix.rustcomponents.sdk.RecoveryStateListener +import org.matrix.rustcomponents.sdk.TaskHandle +import org.matrix.rustcomponents.sdk.VerificationStateListener + +class FakeFfiEncryption : Encryption(NoHandle) { + override fun verificationStateListener(listener: VerificationStateListener): TaskHandle { + return FakeFfiTaskHandle() + } + + override fun recoveryStateListener(listener: RecoveryStateListener): TaskHandle { + return FakeFfiTaskHandle() + } + + override suspend fun waitForE2eeInitializationTasks() = simulateLongTask {} + + override suspend fun isLastDevice(): Boolean { + return false + } + + override suspend fun hasDevicesToVerifyAgainst(): Boolean { + return true + } + + override fun backupState(): BackupState { + return BackupState.ENABLED + } + + override fun recoveryState(): RecoveryState { + return RecoveryState.ENABLED + } + + override fun backupStateListener(listener: BackupStateListener): TaskHandle { + return FakeFfiTaskHandle() + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiHomeserverLoginDetails.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiHomeserverLoginDetails.kt new file mode 100644 index 0000000..ade3a23 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiHomeserverLoginDetails.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.HomeserverLoginDetails +import org.matrix.rustcomponents.sdk.NoHandle + +class FakeFfiHomeserverLoginDetails( + private val url: String = "https://example.org", + private val supportsPasswordLogin: Boolean = false, + private val supportsOidcLogin: Boolean = false, + private val supportsSsoLogin: Boolean = false, +) : HomeserverLoginDetails(NoHandle) { + override fun url(): String = url + override fun supportsOidcLogin(): Boolean = supportsOidcLogin + override fun supportsPasswordLogin(): Boolean = supportsPasswordLogin + override fun supportsSsoLogin(): Boolean = supportsSsoLogin +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLazyTimelineItemProvider.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLazyTimelineItemProvider.kt new file mode 100644 index 0000000..8ee167d --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLazyTimelineItemProvider.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import io.element.android.libraries.matrix.impl.fixtures.factories.anEventTimelineItemDebugInfo +import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo +import org.matrix.rustcomponents.sdk.LazyTimelineItemProvider +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.SendHandle +import org.matrix.rustcomponents.sdk.ShieldState + +class FakeFfiLazyTimelineItemProvider( + private val debugInfo: EventTimelineItemDebugInfo = anEventTimelineItemDebugInfo(), + private val shieldsState: ShieldState? = null, +) : LazyTimelineItemProvider(NoHandle) { + override fun getShields(strict: Boolean) = shieldsState + override fun debugInfo() = debugInfo + override fun getSendHandle(): SendHandle? = null +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiNotificationClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiNotificationClient.kt new file mode 100644 index 0000000..782cf0e --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiNotificationClient.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.BatchNotificationResult +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.NotificationClient +import org.matrix.rustcomponents.sdk.NotificationItemsRequest + +class FakeFfiNotificationClient( + var notificationItemResult: Map = emptyMap(), + val closeResult: () -> Unit = { } +) : NotificationClient(NoHandle) { + override suspend fun getNotifications(requests: List): Map { + return notificationItemResult + } + + override fun close() = closeResult() +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiNotificationSettings.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiNotificationSettings.kt new file mode 100644 index 0000000..d2ed0ce --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiNotificationSettings.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomNotificationSettings +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.NotificationSettings +import org.matrix.rustcomponents.sdk.NotificationSettingsDelegate +import org.matrix.rustcomponents.sdk.RoomNotificationSettings + +class FakeFfiNotificationSettings( + private val roomNotificationSettings: RoomNotificationSettings = aRustRoomNotificationSettings(), +) : NotificationSettings(NoHandle) { + private var delegate: NotificationSettingsDelegate? = null + + override fun setDelegate(delegate: NotificationSettingsDelegate?) { + this.delegate = delegate + } + + override suspend fun getRoomNotificationSettings( + roomId: String, + isEncrypted: Boolean, + isOneToOne: Boolean, + ): RoomNotificationSettings = roomNotificationSettings +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiQrCodeData.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiQrCodeData.kt new file mode 100644 index 0000000..d377643 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiQrCodeData.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import io.element.android.tests.testutils.lambda.lambdaError +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.QrCodeData + +class FakeFfiQrCodeData( + private val serverNameResult: () -> String? = { lambdaError() }, +) : QrCodeData(NoHandle) { + override fun serverName(): String? { + return serverNameResult() + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoom.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoom.kt new file mode 100644 index 0000000..86625c9 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoom.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomInfo +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.tests.testutils.lambda.lambdaError +import org.matrix.rustcomponents.sdk.EventTimelineItem +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.RoomInfo +import org.matrix.rustcomponents.sdk.RoomMembersIterator +import uniffi.matrix_sdk.RoomMemberRole + +class FakeFfiRoom( + private val roomId: RoomId = A_ROOM_ID, + private val getMembers: () -> RoomMembersIterator = { lambdaError() }, + private val getMembersNoSync: () -> RoomMembersIterator = { lambdaError() }, + private val leaveLambda: () -> Unit = { lambdaError() }, + private val latestEventLambda: () -> EventTimelineItem? = { lambdaError() }, + private val suggestedRoleForUserLambda: (String) -> RoomMemberRole = { lambdaError() }, + private val roomInfo: RoomInfo = aRustRoomInfo(id = roomId.value), +) : Room(NoHandle) { + override fun id(): String { + return roomId.value + } + + override suspend fun members(): RoomMembersIterator { + return getMembers() + } + + override suspend fun membersNoSync(): RoomMembersIterator { + return getMembersNoSync() + } + + override suspend fun leave() { + leaveLambda() + } + + override suspend fun roomInfo(): RoomInfo { + return roomInfo + } + + override suspend fun latestEvent(): EventTimelineItem? { + return latestEventLambda() + } + + override suspend fun suggestedRoleForUser(userId: String): RoomMemberRole { + return suggestedRoleForUserLambda(userId) + } + + override fun close() { + // No-op + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomDirectorySearch.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomDirectorySearch.kt new file mode 100644 index 0000000..f941b32 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomDirectorySearch.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import io.element.android.tests.testutils.simulateLongTask +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.RoomDirectorySearch +import org.matrix.rustcomponents.sdk.RoomDirectorySearchEntriesListener +import org.matrix.rustcomponents.sdk.RoomDirectorySearchEntryUpdate +import org.matrix.rustcomponents.sdk.TaskHandle + +class FakeFfiRoomDirectorySearch( + var isAtLastPage: Boolean = false, +) : RoomDirectorySearch(NoHandle) { + override suspend fun isAtLastPage(): Boolean { + return isAtLastPage + } + + override suspend fun search(filter: String?, batchSize: UInt, viaServerName: String?) = simulateLongTask { } + override suspend fun nextPage() = simulateLongTask { } + + private var listener: RoomDirectorySearchEntriesListener? = null + + override suspend fun results(listener: RoomDirectorySearchEntriesListener): TaskHandle { + this.listener = listener + return FakeFfiTaskHandle() + } + + fun emitResult(roomEntriesUpdate: List) { + listener?.onUpdate(roomEntriesUpdate) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomList.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomList.kt new file mode 100644 index 0000000..f3d7120 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomList.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.RoomList + +class FakeFfiRoomList : RoomList(NoHandle) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomListService.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomListService.kt new file mode 100644 index 0000000..5f65194 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomListService.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.RoomList +import org.matrix.rustcomponents.sdk.RoomListService +import org.matrix.rustcomponents.sdk.RoomListServiceStateListener +import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicator +import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicatorListener +import org.matrix.rustcomponents.sdk.TaskHandle + +class FakeFfiRoomListService : RoomListService(NoHandle) { + override suspend fun allRooms(): RoomList { + return FakeFfiRoomList() + } + + private var listener: RoomListServiceSyncIndicatorListener? = null + override fun syncIndicator( + delayBeforeShowingInMs: UInt, + delayBeforeHidingInMs: UInt, + listener: RoomListServiceSyncIndicatorListener, + ): TaskHandle { + this.listener = listener + return FakeFfiTaskHandle() + } + + fun emitRoomListServiceSyncIndicator(syncIndicator: RoomListServiceSyncIndicator) { + listener?.onUpdate(syncIndicator) + } + + override fun state(listener: RoomListServiceStateListener): TaskHandle { + return FakeFfiTaskHandle() + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomMembersIterator.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomMembersIterator.kt new file mode 100644 index 0000000..06dc793 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomMembersIterator.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.RoomMember +import org.matrix.rustcomponents.sdk.RoomMembersIterator + +class FakeFfiRoomMembersIterator( + private var members: List? = null +) : RoomMembersIterator(NoHandle) { + override fun len(): UInt { + return members?.size?.toUInt() ?: 0u + } + + override fun nextChunk(chunkSize: UInt): List? { + if (members?.isEmpty() == true) { + return null + } + return members?.let { + val result = it.take(chunkSize.toInt()) + members = it.subList(result.size, it.size) + result + } + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomPowerLevels.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomPowerLevels.kt new file mode 100644 index 0000000..7246482 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomPowerLevels.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.RoomPowerLevels +import org.matrix.rustcomponents.sdk.RoomPowerLevelsValues + +class FakeFfiRoomPowerLevels( + private val values: RoomPowerLevelsValues = defaultFfiRoomPowerLevelValues(), + private val users: Map = emptyMap(), +) : RoomPowerLevels(NoHandle) { + override fun values(): RoomPowerLevelsValues = values + override fun userPowerLevels(): Map = users +} + +fun defaultFfiRoomPowerLevelValues() = RoomPowerLevelsValues( + ban = 50, + invite = 0, + kick = 50, + eventsDefault = 0, + redact = 50, + roomName = 100, + roomAvatar = 100, + roomTopic = 100, + stateDefault = 0, + usersDefault = 0, + spaceChild = 100, +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSessionVerificationController.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSessionVerificationController.kt new file mode 100644 index 0000000..c6aa547 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSessionVerificationController.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.SessionVerificationController +import org.matrix.rustcomponents.sdk.SessionVerificationControllerDelegate + +class FakeFfiSessionVerificationController : SessionVerificationController(NoHandle) { + override fun setDelegate(delegate: SessionVerificationControllerDelegate?) {} +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSpaceRoomList.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSpaceRoomList.kt new file mode 100644 index 0000000..40c3627 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSpaceRoomList.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.SpaceListUpdate +import org.matrix.rustcomponents.sdk.SpaceRoom +import org.matrix.rustcomponents.sdk.SpaceRoomList +import org.matrix.rustcomponents.sdk.SpaceRoomListEntriesListener +import org.matrix.rustcomponents.sdk.SpaceRoomListPaginationStateListener +import org.matrix.rustcomponents.sdk.TaskHandle +import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState + +class FakeFfiSpaceRoomList( + private val paginateResult: () -> Unit = { lambdaError() }, + private val paginationStateResult: () -> SpaceRoomListPaginationState = { lambdaError() }, + private val roomsResult: () -> List = { lambdaError() }, +) : SpaceRoomList(NoHandle) { + private var spaceRoomListPaginationStateListener: SpaceRoomListPaginationStateListener? = null + private var spaceRoomListEntriesListener: SpaceRoomListEntriesListener? = null + + override suspend fun paginate() = simulateLongTask { + paginateResult() + } + + override fun paginationState(): SpaceRoomListPaginationState { + return paginationStateResult() + } + + override fun rooms(): List { + return roomsResult() + } + + override fun subscribeToPaginationStateUpdates(listener: SpaceRoomListPaginationStateListener): TaskHandle { + spaceRoomListPaginationStateListener = listener + return FakeFfiTaskHandle() + } + + fun triggerPaginationStateUpdate(state: SpaceRoomListPaginationState) { + spaceRoomListPaginationStateListener?.onUpdate(state) + } + + override fun subscribeToRoomUpdate(listener: SpaceRoomListEntriesListener): TaskHandle { + spaceRoomListEntriesListener = listener + return FakeFfiTaskHandle() + } + + fun triggerRoomListUpdate(rooms: List) { + spaceRoomListEntriesListener?.onUpdate(rooms) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSpaceService.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSpaceService.kt new file mode 100644 index 0000000..ca9ec75 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSpaceService.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.SpaceService + +class FakeFfiSpaceService : SpaceService(NoHandle) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSyncService.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSyncService.kt new file mode 100644 index 0000000..d4d87d0 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSyncService.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.RoomListService +import org.matrix.rustcomponents.sdk.SyncService +import org.matrix.rustcomponents.sdk.SyncServiceStateObserver +import org.matrix.rustcomponents.sdk.TaskHandle + +class FakeFfiSyncService( + private val roomListService: RoomListService = FakeFfiRoomListService(), +) : SyncService(NoHandle) { + override fun roomListService(): RoomListService = roomListService + override fun state(listener: SyncServiceStateObserver): TaskHandle { + return FakeFfiTaskHandle() + } + override suspend fun stop() {} +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSyncServiceBuilder.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSyncServiceBuilder.kt new file mode 100644 index 0000000..2cb67f2 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSyncServiceBuilder.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.SyncService +import org.matrix.rustcomponents.sdk.SyncServiceBuilder + +class FakeFfiSyncServiceBuilder : SyncServiceBuilder(NoHandle) { + override fun withOfflineMode(): SyncServiceBuilder = this + override fun withSharePos(enable: Boolean): SyncServiceBuilder = this + override suspend fun finish(): SyncService = FakeFfiSyncService() +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTaskHandle.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTaskHandle.kt new file mode 100644 index 0000000..24d7884 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTaskHandle.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.TaskHandle + +class FakeFfiTaskHandle : TaskHandle(NoHandle) { + override fun cancel() = Unit + override fun destroy() = Unit +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimeline.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimeline.kt new file mode 100644 index 0000000..d45330b --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimeline.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.PaginationStatusListener +import org.matrix.rustcomponents.sdk.TaskHandle +import org.matrix.rustcomponents.sdk.Timeline +import org.matrix.rustcomponents.sdk.TimelineDiff +import org.matrix.rustcomponents.sdk.TimelineListener +import uniffi.matrix_sdk.RoomPaginationStatus + +class FakeFfiTimeline : Timeline(NoHandle) { + private var listener: TimelineListener? = null + override suspend fun addListener(listener: TimelineListener): TaskHandle { + this.listener = listener + return FakeFfiTaskHandle() + } + + fun emitDiff(diff: List) { + listener!!.onUpdate(diff) + } + + private var paginationStatusListener: PaginationStatusListener? = null + override suspend fun subscribeToBackPaginationStatus(listener: PaginationStatusListener): TaskHandle { + this.paginationStatusListener = listener + return FakeFfiTaskHandle() + } + + fun emitPaginationStatus(status: RoomPaginationStatus) { + paginationStatusListener!!.onUpdate(status) + } + + override suspend fun paginateBackwards(numEvents: UShort): Boolean { + return true + } + + override suspend fun fetchMembers() = Unit +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimelineEvent.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimelineEvent.kt new file mode 100644 index 0000000..f364ca6 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimelineEvent.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustTimelineEventTypeMessageLike +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.TimelineEvent +import org.matrix.rustcomponents.sdk.TimelineEventType + +open class FakeFfiTimelineEvent( + val timestamp: ULong = A_FAKE_TIMESTAMP.toULong(), + val timelineEventType: TimelineEventType = aRustTimelineEventTypeMessageLike(), + val senderId: String = A_USER_ID_2.value, +) : TimelineEvent(NoHandle) { + override fun timestamp(): ULong = timestamp + override fun eventType(): TimelineEventType = timelineEventType + override fun senderId(): String = senderId +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimelineEventTypeFilter.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimelineEventTypeFilter.kt new file mode 100644 index 0000000..067567c --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimelineEventTypeFilter.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.TimelineEventTypeFilter + +class FakeFfiTimelineEventTypeFilter : TimelineEventTypeFilter(NoHandle) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimelineItem.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimelineItem.kt new file mode 100644 index 0000000..eb7f8a1 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimelineItem.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.EventTimelineItem +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.TimelineItem +import org.matrix.rustcomponents.sdk.TimelineUniqueId +import org.matrix.rustcomponents.sdk.VirtualTimelineItem + +class FakeFfiTimelineItem( + private val asEventResult: EventTimelineItem? = null, +) : TimelineItem(NoHandle) { + override fun asEvent(): EventTimelineItem? = asEventResult + override fun asVirtual(): VirtualTimelineItem? = null + override fun fmtDebug(): String = "fmtDebug" + override fun uniqueId(): TimelineUniqueId = TimelineUniqueId("uniqueId") +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/keys/DefaultPassphraseGeneratorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/keys/DefaultPassphraseGeneratorTest.kt new file mode 100644 index 0000000..465d920 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/keys/DefaultPassphraseGeneratorTest.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.keys + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultPassphraseGeneratorTest { + @Test + fun `check that generated passphrase has the expected length`() { + val passphraseGenerator = DefaultPassphraseGenerator() + val passphrase = passphraseGenerator.generatePassphrase() + assertThat(passphrase!!.length).isEqualTo(342) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mapper/SessionKtTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mapper/SessionKtTest.kt new file mode 100644 index 0000000..e5fc8b1 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mapper/SessionKtTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.mapper + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.auth.external.ExternalSession +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSession +import io.element.android.libraries.matrix.impl.paths.SessionPaths +import io.element.android.libraries.matrix.test.A_DEVICE_ID +import io.element.android.libraries.matrix.test.A_HOMESERVER_URL +import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2 +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.sessionstorage.api.LoginType +import org.junit.Test +import java.io.File + +class SessionKtTest { + @Test + fun `toSessionData compute the expected result`() { + val result = aRustSession().toSessionData( + isTokenValid = true, + loginType = LoginType.PASSWORD, + passphrase = A_SECRET, + sessionPaths = SessionPaths(File("/a/file"), File("/a/cache")), + ) + assertThat(result.userId).isEqualTo(A_USER_ID.value) + assertThat(result.deviceId).isEqualTo(A_DEVICE_ID.value) + assertThat(result.accessToken).isEqualTo("accessToken") + assertThat(result.refreshToken).isEqualTo("refreshToken") + assertThat(result.homeserverUrl).isEqualTo(A_HOMESERVER_URL) + assertThat(result.isTokenValid).isTrue() + assertThat(result.oidcData).isNull() + assertThat(result.loginType).isEqualTo(LoginType.PASSWORD) + assertThat(result.loginTimestamp).isNotNull() + assertThat(result.passphrase).isEqualTo(A_SECRET) + assertThat(result.sessionPath).isEqualTo("/a/file") + assertThat(result.cachePath).isEqualTo("/a/cache") + } + + @Test + fun `toSessionData can change the validity of the token`() { + val result = aRustSession().toSessionData( + isTokenValid = false, + loginType = LoginType.PASSWORD, + passphrase = A_SECRET, + sessionPaths = SessionPaths(File("/a/file"), File("/a/cache")), + homeserverUrl = null, + ) + assertThat(result.isTokenValid).isFalse() + } + + @Test + fun `toSessionData can override the value of the homeserver url`() { + val result = aRustSession().toSessionData( + isTokenValid = true, + loginType = LoginType.PASSWORD, + passphrase = A_SECRET, + sessionPaths = SessionPaths(File("/a/file"), File("/a/cache")), + homeserverUrl = A_HOMESERVER_URL_2, + ) + assertThat(result.homeserverUrl).isEqualTo(A_HOMESERVER_URL_2) + } + + @Test + fun `ExternalSession toSessionData compute the expected result`() { + val result = anExternalSession().toSessionData( + isTokenValid = true, + loginType = LoginType.PASSWORD, + passphrase = A_SECRET, + sessionPaths = SessionPaths(File("/a/file"), File("/a/cache")), + ) + assertThat(result.userId).isEqualTo(A_USER_ID.value) + assertThat(result.deviceId).isEqualTo(A_DEVICE_ID.value) + assertThat(result.accessToken).isEqualTo("accessToken") + assertThat(result.refreshToken).isNull() + assertThat(result.homeserverUrl).isEqualTo(A_HOMESERVER_URL) + assertThat(result.isTokenValid).isTrue() + assertThat(result.oidcData).isNull() + assertThat(result.loginType).isEqualTo(LoginType.PASSWORD) + assertThat(result.loginTimestamp).isNotNull() + assertThat(result.passphrase).isEqualTo(A_SECRET) + assertThat(result.sessionPath).isEqualTo("/a/file") + assertThat(result.cachePath).isEqualTo("/a/cache") + } + + @Test + fun `ExternalSession toSessionData can change the validity of the token`() { + val result = anExternalSession().toSessionData( + isTokenValid = false, + loginType = LoginType.PASSWORD, + passphrase = A_SECRET, + sessionPaths = SessionPaths(File("/a/file"), File("/a/cache")), + ) + assertThat(result.isTokenValid).isFalse() + } +} + +private fun anExternalSession( + userId: String = A_USER_ID.value, + deviceId: String = A_DEVICE_ID.value, + accessToken: String = "accessToken", + refreshToken: String? = null, + homeserverUrl: String = A_HOMESERVER_URL, +) = ExternalSession( + userId = userId, + deviceId = deviceId, + accessToken = accessToken, + refreshToken = refreshToken, + homeserverUrl = homeserverUrl, +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mapper/UserProfileMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mapper/UserProfileMapperTest.kt new file mode 100644 index 0000000..61e5886 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mapper/UserProfileMapperTest.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.mapper + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile +import io.element.android.libraries.matrix.test.A_USER_ID +import org.junit.Test + +class UserProfileMapperTest { + @Test + fun map() { + assertThat(aRustUserProfile(A_USER_ID.value, "displayName", "avatarUrl").map()) + .isEqualTo(MatrixUser(A_USER_ID, "displayName", "avatarUrl")) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mxc/DefaultMxcToolsTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mxc/DefaultMxcToolsTest.kt new file mode 100644 index 0000000..56863c5 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mxc/DefaultMxcToolsTest.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.mxc + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class DefaultMxcToolsTest { + @Test + fun `mxcUri2FilePath returns extracted path`() { + val mxcTools = DefaultMxcTools() + val mxcUri = "mxc://server.org/abc123" + val filePath = mxcTools.mxcUri2FilePath(mxcUri) + assertThat(filePath).isEqualTo("server.org/abc123") + } + + @Test + fun `mxcUri2FilePath returns null for invalid data`() { + val mxcTools = DefaultMxcTools() + assertThat(mxcTools.mxcUri2FilePath("")).isNull() + assertThat(mxcTools.mxcUri2FilePath("mxc://server.org")).isNull() + assertThat(mxcTools.mxcUri2FilePath("mxc://server.org/")).isNull() + assertThat(mxcTools.mxcUri2FilePath("m://server.org/abc123")).isNull() + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationServiceTest.kt new file mode 100644 index 0000000..e6fc802 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationServiceTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.notification + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.exception.NotificationResolverException +import io.element.android.libraries.matrix.api.notification.NotificationContent +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustBatchNotificationResult +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustNotificationEventTimeline +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustNotificationItem +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiNotificationClient +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTimelineEvent +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_MESSAGE +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_USER_ID_2 +import io.element.android.services.toolbox.api.systemclock.SystemClock +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test +import org.matrix.rustcomponents.sdk.NotificationClient +import org.matrix.rustcomponents.sdk.NotificationStatus +import org.matrix.rustcomponents.sdk.TimelineEventType + +class RustNotificationServiceTest { + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun test() = runTest { + val notificationClient = FakeFfiNotificationClient( + notificationItemResult = mapOf(AN_EVENT_ID.value to aRustBatchNotificationResult()), + ) + val sut = createRustNotificationService( + notificationClient = notificationClient, + ) + val result = sut.getNotifications(mapOf(A_ROOM_ID to listOf(AN_EVENT_ID))).getOrThrow()[AN_EVENT_ID]!!.getOrThrow() + assertThat(result.isEncrypted).isTrue() + assertThat(result.content).isEqualTo( + NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = TextMessageType( + body = A_MESSAGE, + formatted = null, + ) + ) + ) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `test mapping invalid item only drops that item`() = runTest { + val error = IllegalStateException("This event type is not supported") + val faultyEvent = object : FakeFfiTimelineEvent() { + override fun eventType(): TimelineEventType { + throw error + } + } + val notificationClient = FakeFfiNotificationClient( + notificationItemResult = mapOf( + AN_EVENT_ID.value to aRustBatchNotificationResult( + notificationStatus = NotificationStatus.Event(aRustNotificationItem(aRustNotificationEventTimeline(faultyEvent))) + ), + AN_EVENT_ID_2.value to aRustBatchNotificationResult() + ), + ) + val sut = createRustNotificationService( + notificationClient = notificationClient, + ) + val result = sut.getNotifications(mapOf(A_ROOM_ID to listOf(AN_EVENT_ID, AN_EVENT_ID_2))).getOrThrow() + val exception = result[AN_EVENT_ID]!!.exceptionOrNull() + assertThat(exception).isEqualTo(error) + + val successfulResult = result[AN_EVENT_ID_2] + assertThat(successfulResult?.isSuccess).isTrue() + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `test unable to resolve event`() = runTest { + val notificationClient = FakeFfiNotificationClient( + notificationItemResult = emptyMap(), + ) + val sut = createRustNotificationService( + notificationClient = notificationClient, + ) + val exception = sut.getNotifications(mapOf(A_ROOM_ID to listOf(AN_EVENT_ID))).getOrThrow()[AN_EVENT_ID]!!.exceptionOrNull() + assertThat(exception).isInstanceOf(NotificationResolverException::class.java) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `close should invoke the close method of the service`() = runTest { + val closeResult = lambdaRecorder { } + val notificationClient = FakeFfiNotificationClient( + closeResult = closeResult, + ) + val sut = createRustNotificationService( + notificationClient = notificationClient, + ) + sut.close() + closeResult.assertions().isCalledOnce() + } + + private fun TestScope.createRustNotificationService( + notificationClient: NotificationClient = FakeFfiNotificationClient(), + clock: SystemClock = FakeSystemClock(), + ) = + RustNotificationService( + sessionId = A_SESSION_ID, + notificationClient = notificationClient, + dispatchers = testCoroutineDispatchers(), + clock = clock, + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsServiceTest.kt new file mode 100644 index 0000000..81d0a33 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsServiceTest.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.notificationsettings + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiNotificationSettings +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test +import org.matrix.rustcomponents.sdk.NotificationSettings + +class RustNotificationSettingsServiceTest { + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun test() = runTest { + val sut = createRustNotificationSettingsService() + val result = sut.getRoomNotificationSettings( + roomId = A_ROOM_ID, + isEncrypted = true, + isOneToOne = true, + ).getOrNull()!! + assertThat(result.mode).isEqualTo(RoomNotificationMode.ALL_MESSAGES) + assertThat(result.isDefault).isTrue() + } + + private fun TestScope.createRustNotificationSettingsService( + notificationSettings: NotificationSettings = FakeFfiNotificationSettings(), + ) = RustNotificationSettingsService( + client = FakeFfiClient( + notificationSettings = notificationSettings, + ), + sessionCoroutineScope = this, + dispatchers = testCoroutineDispatchers(), + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/oidc/AccountManagementActionKtTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/oidc/AccountManagementActionKtTest.kt new file mode 100644 index 0000000..8115465 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/oidc/AccountManagementActionKtTest.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.oidc + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.oidc.AccountManagementAction +import io.element.android.libraries.matrix.test.A_DEVICE_ID +import org.junit.Test +import org.matrix.rustcomponents.sdk.AccountManagementAction as RustAccountManagementAction + +class AccountManagementActionKtTest { + @Test + fun `test AccountManagementAction to RustAccountManagementAction`() { + assertThat(AccountManagementAction.Profile.toRustAction()) + .isEqualTo(RustAccountManagementAction.Profile) + assertThat(AccountManagementAction.SessionEnd(A_DEVICE_ID).toRustAction()) + .isEqualTo(RustAccountManagementAction.SessionEnd(A_DEVICE_ID.value)) + assertThat(AccountManagementAction.SessionView(A_DEVICE_ID).toRustAction()) + .isEqualTo(RustAccountManagementAction.SessionView(A_DEVICE_ID.value)) + assertThat(AccountManagementAction.SessionsList.toRustAction()) + .isEqualTo(RustAccountManagementAction.SessionsList) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverterTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverterTest.kt new file mode 100644 index 0000000..dcd2c45 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverterTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.permalink + +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultMatrixToConverterTest { + @Test + fun `converting a matrix-to url does nothing`() { + val url = Uri.parse("https://matrix.to/#/#element-android:matrix.org") + assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(url) + } + + @Test + fun `converting a url with a supported room path returns a matrix-to url`() { + val url = Uri.parse("https://riot.im/develop/#/room/#element-android:matrix.org") + assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/#element-android:matrix.org")) + } + + @Test + fun `converting a url with a supported user path returns a matrix-to url`() { + val url = Uri.parse("https://riot.im/develop/#/user/@test:matrix.org") + assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/@test:matrix.org")) + } + + @Test + fun `converting a url with a supported group path returns a matrix-to url`() { + val url = Uri.parse("https://riot.im/develop/#/group/+group:matrix.org") + assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/+group:matrix.org")) + } + + @Test + fun `converting an unsupported url returns null`() { + val url = Uri.parse("https://element.io/") + assertThat(DefaultMatrixToConverter().convert(url)).isNull() + } + + @Test + fun `converting url coming from the matrix-to website returns a matrix-to url for room case`() { + val url = Uri.parse("element://room/#element-android:matrix.org") + assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/#element-android:matrix.org")) + } + + @Test + fun `converting url coming from the matrix-to website returns a matrix-to url for user case`() { + val url = Uri.parse("element://user/@alice:matrix.org") + assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/@alice:matrix.org")) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/poll/PollKindKtTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/poll/PollKindKtTest.kt new file mode 100644 index 0000000..ed475dc --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/poll/PollKindKtTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.poll + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.poll.PollKind +import org.junit.Test +import org.matrix.rustcomponents.sdk.PollKind as RustPollKind + +class PollKindKtTest { + @Test + fun `map should return Disclosed when RustPollKind is Disclosed`() { + val pollKind = RustPollKind.DISCLOSED.map() + assertThat(pollKind).isEqualTo(PollKind.Disclosed) + } + + @Test + fun `map should return Undisclosed when RustPollKind is Undisclosed`() { + val pollKind = RustPollKind.UNDISCLOSED.map() + assertThat(pollKind).isEqualTo(PollKind.Undisclosed) + } + + @Test + fun `toInner should return DISCLOSED when PollKind is Disclosed`() { + val rustPollKind = PollKind.Disclosed.toInner() + assertThat(rustPollKind).isEqualTo(RustPollKind.DISCLOSED) + } + + @Test + fun `toInner should return UNDISCLOSED when PollKind is Undisclosed`() { + val rustPollKind = PollKind.Undisclosed.toInner() + assertThat(rustPollKind).isEqualTo(RustPollKind.UNDISCLOSED) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersServiceTest.kt new file mode 100644 index 0000000..1843bf4 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersServiceTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.pushers + +import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData +import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test + +@Ignore("JNA direct mapping has broken unit tests with FFI fakes") +class RustPushersServiceTest { + @Test + fun `setPusher should invoke the client method`() = runTest { + val sut = RustPushersService( + client = FakeFfiClient(), + dispatchers = testCoroutineDispatchers() + ) + sut.setHttpPusher( + setHttpPusherData = aSetHttpPusherData() + ).getOrThrow() + } + + @Test + fun `unsetPusher should invoke the client method`() = runTest { + val sut = RustPushersService( + client = FakeFfiClient(), + dispatchers = testCoroutineDispatchers() + ) + sut.unsetHttpPusher( + unsetHttpPusherData = aUnsetHttpPusherData(), + ).getOrThrow() + } +} + +private fun aSetHttpPusherData( + pushKey: String = "pushKey", + appId: String = "appId", + url: String = "url", + defaultPayload: String = "defaultPayload", + appDisplayName: String = "appDisplayName", + deviceDisplayName: String = "deviceDisplayName", + profileTag: String = "profileTag", + lang: String = "lang", +) = SetHttpPusherData( + pushKey = pushKey, + appId = appId, + url = url, + defaultPayload = defaultPayload, + appDisplayName = appDisplayName, + deviceDisplayName = deviceDisplayName, + profileTag = profileTag, + lang = lang +) + +private fun aUnsetHttpPusherData( + pushKey: String = "pushKey", + appId: String = "appId", +) = UnsetHttpPusherData( + pushKey = pushKey, + appId = appId, +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/FakeTimelineEventTypeFilterFactory.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/FakeTimelineEventTypeFilterFactory.kt new file mode 100644 index 0000000..52da51a --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/FakeTimelineEventTypeFilterFactory.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room + +import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTimelineEventTypeFilter +import org.matrix.rustcomponents.sdk.TimelineEventTypeFilter + +class FakeTimelineEventTypeFilterFactory : TimelineEventTypeFilterFactory { + override fun create(listStateEventType: List): TimelineEventTypeFilter { + return FakeFfiTimelineEventTypeFilter() + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventTypeKtTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventTypeKtTest.kt new file mode 100644 index 0000000..654989a --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventTypeKtTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.MessageEventType +import org.junit.Test +import org.matrix.rustcomponents.sdk.MessageLikeEventType + +class MessageEventTypeKtTest { + @Test + fun `map Rust type should result to correct Kotlin type`() { + assertThat(MessageLikeEventType.CallAnswer.map()).isEqualTo(MessageEventType.CallAnswer) + assertThat(MessageLikeEventType.CallInvite.map()).isEqualTo(MessageEventType.CallInvite) + assertThat(MessageLikeEventType.CallHangup.map()).isEqualTo(MessageEventType.CallHangup) + assertThat(MessageLikeEventType.CallCandidates.map()).isEqualTo(MessageEventType.CallCandidates) + assertThat(MessageLikeEventType.RtcNotification.map()).isEqualTo(MessageEventType.RtcNotification) + assertThat(MessageLikeEventType.KeyVerificationReady.map()).isEqualTo(MessageEventType.KeyVerificationReady) + assertThat(MessageLikeEventType.KeyVerificationStart.map()).isEqualTo(MessageEventType.KeyVerificationStart) + assertThat(MessageLikeEventType.KeyVerificationCancel.map()).isEqualTo(MessageEventType.KeyVerificationCancel) + assertThat(MessageLikeEventType.KeyVerificationAccept.map()).isEqualTo(MessageEventType.KeyVerificationAccept) + assertThat(MessageLikeEventType.KeyVerificationKey.map()).isEqualTo(MessageEventType.KeyVerificationKey) + assertThat(MessageLikeEventType.KeyVerificationMac.map()).isEqualTo(MessageEventType.KeyVerificationMac) + assertThat(MessageLikeEventType.KeyVerificationDone.map()).isEqualTo(MessageEventType.KeyVerificationDone) + assertThat(MessageLikeEventType.Reaction.map()).isEqualTo(MessageEventType.Reaction) + assertThat(MessageLikeEventType.RoomEncrypted.map()).isEqualTo(MessageEventType.RoomEncrypted) + assertThat(MessageLikeEventType.RoomMessage.map()).isEqualTo(MessageEventType.RoomMessage) + assertThat(MessageLikeEventType.RoomRedaction.map()).isEqualTo(MessageEventType.RoomRedaction) + assertThat(MessageLikeEventType.Sticker.map()).isEqualTo(MessageEventType.Sticker) + assertThat(MessageLikeEventType.PollEnd.map()).isEqualTo(MessageEventType.PollEnd) + assertThat(MessageLikeEventType.PollResponse.map()).isEqualTo(MessageEventType.PollResponse) + assertThat(MessageLikeEventType.PollStart.map()).isEqualTo(MessageEventType.PollStart) + assertThat(MessageLikeEventType.UnstablePollEnd.map()).isEqualTo(MessageEventType.UnstablePollEnd) + assertThat(MessageLikeEventType.UnstablePollResponse.map()).isEqualTo(MessageEventType.UnstablePollResponse) + assertThat(MessageLikeEventType.UnstablePollStart.map()).isEqualTo(MessageEventType.UnstablePollStart) + } + + @Test + fun `map Kotlin type should result to correct Rust type`() { + assertThat(MessageEventType.CallAnswer.map()).isEqualTo(MessageLikeEventType.CallAnswer) + assertThat(MessageEventType.CallInvite.map()).isEqualTo(MessageLikeEventType.CallInvite) + assertThat(MessageEventType.CallHangup.map()).isEqualTo(MessageLikeEventType.CallHangup) + assertThat(MessageEventType.CallCandidates.map()).isEqualTo(MessageLikeEventType.CallCandidates) + assertThat(MessageEventType.RtcNotification.map()).isEqualTo(MessageLikeEventType.RtcNotification) + assertThat(MessageEventType.KeyVerificationReady.map()).isEqualTo(MessageLikeEventType.KeyVerificationReady) + assertThat(MessageEventType.KeyVerificationStart.map()).isEqualTo(MessageLikeEventType.KeyVerificationStart) + assertThat(MessageEventType.KeyVerificationCancel.map()).isEqualTo(MessageLikeEventType.KeyVerificationCancel) + assertThat(MessageEventType.KeyVerificationAccept.map()).isEqualTo(MessageLikeEventType.KeyVerificationAccept) + assertThat(MessageEventType.KeyVerificationKey.map()).isEqualTo(MessageLikeEventType.KeyVerificationKey) + assertThat(MessageEventType.KeyVerificationMac.map()).isEqualTo(MessageLikeEventType.KeyVerificationMac) + assertThat(MessageEventType.KeyVerificationDone.map()).isEqualTo(MessageLikeEventType.KeyVerificationDone) + assertThat(MessageEventType.Reaction.map()).isEqualTo(MessageLikeEventType.Reaction) + assertThat(MessageEventType.RoomEncrypted.map()).isEqualTo(MessageLikeEventType.RoomEncrypted) + assertThat(MessageEventType.RoomMessage.map()).isEqualTo(MessageLikeEventType.RoomMessage) + assertThat(MessageEventType.RoomRedaction.map()).isEqualTo(MessageLikeEventType.RoomRedaction) + assertThat(MessageEventType.Sticker.map()).isEqualTo(MessageLikeEventType.Sticker) + assertThat(MessageEventType.PollEnd.map()).isEqualTo(MessageLikeEventType.PollEnd) + assertThat(MessageEventType.PollResponse.map()).isEqualTo(MessageLikeEventType.PollResponse) + assertThat(MessageEventType.PollStart.map()).isEqualTo(MessageLikeEventType.PollStart) + assertThat(MessageEventType.UnstablePollEnd.map()).isEqualTo(MessageLikeEventType.UnstablePollEnd) + assertThat(MessageEventType.UnstablePollResponse.map()).isEqualTo(MessageLikeEventType.UnstablePollResponse) + assertThat(MessageEventType.UnstablePollStart.map()).isEqualTo(MessageLikeEventType.UnstablePollStart) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoExtTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoExtTest.kt new file mode 100644 index 0000000..dc4eba2 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoExtTest.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomHero +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomInfo +import io.element.android.libraries.matrix.test.A_USER_ID +import org.junit.Ignore +import org.junit.Test + +@Ignore("JNA direct mapping has broken unit tests with FFI fakes") +class RoomInfoExtTest { + @Test + fun `get non empty element Heroes`() { + val result = aRustRoomInfo( + isDirect = true, + activeMembersCount = 2uL, + heroes = listOf(aRustRoomHero()) + ).elementHeroes() + assertThat(result).isEqualTo( + listOf( + MatrixUser( + userId = UserId(A_USER_ID.value), + displayName = "displayName", + avatarUrl = "avatarUrl", + ) + ) + ) + } + + @Test + fun `too many heroes and element Heroes is empty`() { + val result = aRustRoomInfo( + isDirect = true, + activeMembersCount = 2uL, + heroes = listOf(aRustRoomHero(), aRustRoomHero()) + ).elementHeroes() + assertThat(result).isEmpty() + } + + @Test + fun `not direct and element Heroes is empty`() { + val result = aRustRoomInfo( + isDirect = false, + activeMembersCount = 2uL, + heroes = listOf(aRustRoomHero()) + ).elementHeroes() + assertThat(result).isEmpty() + } + + @Test + fun `too many members and element Heroes is empty`() { + val result = aRustRoomInfo( + isDirect = true, + activeMembersCount = 3uL, + heroes = listOf(aRustRoomHero()) + ).elementHeroes() + assertThat(result).isEmpty() + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapperTest.kt new file mode 100644 index 0000000..c964f02 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapperTest.kt @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomHero +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomInfo +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomMember +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomPowerLevels +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_ALIAS +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_ID_3 +import io.element.android.libraries.matrix.test.A_USER_ID_6 +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableList +import org.junit.Ignore +import org.junit.Test +import org.matrix.rustcomponents.sdk.Membership +import uniffi.matrix_sdk_base.EncryptionState +import org.matrix.rustcomponents.sdk.JoinRule as RustJoinRule +import org.matrix.rustcomponents.sdk.RoomHistoryVisibility as RustRoomHistoryVisibility +import org.matrix.rustcomponents.sdk.RoomNotificationMode as RustRoomNotificationMode + +@Ignore("JNA direct mapping has broken unit tests with FFI fakes") +class RoomInfoMapperTest { + @Test + fun `mapping of RustRoomInfo should map all the fields`() { + assertThat( + RoomInfoMapper().map( + aRustRoomInfo( + id = A_ROOM_ID.value, + displayName = "displayName", + rawName = "rawName", + topic = "topic", + avatarUrl = AN_AVATAR_URL, + encryptionState = EncryptionState.ENCRYPTED, + isDirect = true, + isPublic = false, + isSpace = false, + joinRule = RustJoinRule.Invite, + successorRoom = null, + isFavourite = false, + canonicalAlias = A_ROOM_ALIAS.value, + alternativeAliases = listOf(A_ROOM_ALIAS.value), + membership = Membership.JOINED, + inviter = aRustRoomMember(A_USER_ID), + heroes = listOf(aRustRoomHero()), + activeMembersCount = 2uL, + invitedMembersCount = 3uL, + joinedMembersCount = 4uL, + roomPowerLevels = FakeFfiRoomPowerLevels(users = mapOf(A_USER_ID_6.value to 50L)), + highlightCount = 10uL, + notificationCount = 11uL, + userDefinedNotificationMode = RustRoomNotificationMode.MUTE, + hasRoomCall = true, + activeRoomCallParticipants = listOf(A_USER_ID_3.value), + isMarkedUnread = false, + numUnreadMessages = 12uL, + numUnreadNotifications = 13uL, + numUnreadMentions = 14uL, + pinnedEventIds = listOf(AN_EVENT_ID.value), + roomCreators = listOf(A_USER_ID.value), + historyVisibility = RustRoomHistoryVisibility.Joined, + roomVersion = "12", + privilegedCreatorsRole = true, + ) + ) + ).isEqualTo( + RoomInfo( + id = A_ROOM_ID, + name = "displayName", + rawName = "rawName", + topic = "topic", + avatarUrl = AN_AVATAR_URL, + isPublic = false, + isDirect = true, + isEncrypted = true, + isSpace = false, + isFavorite = false, + joinRule = JoinRule.Invite, + canonicalAlias = A_ROOM_ALIAS, + alternativeAliases = listOf(A_ROOM_ALIAS).toImmutableList(), + currentUserMembership = CurrentUserMembership.JOINED, + inviter = aRoomMember(A_USER_ID), + activeMembersCount = 2L, + invitedMembersCount = 3L, + joinedMembersCount = 4L, + roomPowerLevels = RoomPowerLevels( + values = defaultRoomPowerLevelValues(), + users = persistentMapOf(A_USER_ID_6 to 50L) + ), + highlightCount = 10L, + notificationCount = 11L, + userDefinedNotificationMode = RoomNotificationMode.MUTE, + hasRoomCall = true, + activeRoomCallParticipants = persistentListOf(A_USER_ID_3), + heroes = persistentListOf( + MatrixUser( + userId = A_USER_ID, + displayName = "displayName", + avatarUrl = "avatarUrl", + ) + ), + pinnedEventIds = persistentListOf(AN_EVENT_ID), + creators = persistentListOf(A_USER_ID), + isMarkedUnread = false, + numUnreadMessages = 12L, + numUnreadNotifications = 13L, + numUnreadMentions = 14L, + historyVisibility = RoomHistoryVisibility.Joined, + successorRoom = null, + roomVersion = "12", + privilegedCreatorRole = true, + ) + ) + } + + @Test + fun `mapping of RustRoomInfo with null members should map all the fields`() { + assertThat( + RoomInfoMapper().map( + aRustRoomInfo( + id = A_ROOM_ID.value, + displayName = null, + rawName = null, + topic = null, + avatarUrl = null, + encryptionState = EncryptionState.UNKNOWN, + isDirect = false, + isPublic = true, + joinRule = null, + isSpace = false, + successorRoom = null, + isFavourite = true, + canonicalAlias = null, + alternativeAliases = emptyList(), + membership = Membership.INVITED, + inviter = null, + heroes = listOf(aRustRoomHero()), + activeMembersCount = 2uL, + invitedMembersCount = 3uL, + joinedMembersCount = 4uL, + roomPowerLevels = FakeFfiRoomPowerLevels(), + highlightCount = 10uL, + notificationCount = 11uL, + userDefinedNotificationMode = null, + hasRoomCall = false, + activeRoomCallParticipants = emptyList(), + isMarkedUnread = true, + numUnreadMessages = 12uL, + numUnreadNotifications = 13uL, + numUnreadMentions = 14uL, + pinnedEventIds = emptyList(), + roomCreators = null, + roomVersion = "12", + privilegedCreatorsRole = true, + ) + ) + ).isEqualTo( + RoomInfo( + id = A_ROOM_ID, + name = null, + rawName = null, + topic = null, + avatarUrl = null, + isEncrypted = null, + isPublic = true, + isDirect = false, + joinRule = null, + isSpace = false, + successorRoom = null, + isFavorite = true, + canonicalAlias = null, + alternativeAliases = persistentListOf(), + currentUserMembership = CurrentUserMembership.INVITED, + inviter = null, + activeMembersCount = 2L, + invitedMembersCount = 3L, + joinedMembersCount = 4L, + roomPowerLevels = RoomPowerLevels( + values = defaultRoomPowerLevelValues(), + users = persistentMapOf(), + ), + highlightCount = 10L, + notificationCount = 11L, + userDefinedNotificationMode = null, + hasRoomCall = false, + activeRoomCallParticipants = persistentListOf(), + heroes = persistentListOf(), + pinnedEventIds = persistentListOf(), + creators = persistentListOf(), + isMarkedUnread = true, + numUnreadMessages = 12L, + numUnreadNotifications = 13L, + numUnreadMentions = 14L, + historyVisibility = RoomHistoryVisibility.Joined, + roomVersion = "12", + privilegedCreatorRole = true, + ) + ) + } + + @Test + fun `mapping Membership`() { + assertThat(Membership.INVITED.map()).isEqualTo(CurrentUserMembership.INVITED) + assertThat(Membership.JOINED.map()).isEqualTo(CurrentUserMembership.JOINED) + assertThat(Membership.LEFT.map()).isEqualTo(CurrentUserMembership.LEFT) + } + + @Test + fun `mapping RoomNotificationMode`() { + assertThat(RustRoomNotificationMode.ALL_MESSAGES.map()).isEqualTo(RoomNotificationMode.ALL_MESSAGES) + assertThat(RustRoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY.map()).isEqualTo(RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) + assertThat(RustRoomNotificationMode.MUTE.map()).isEqualTo(RoomNotificationMode.MUTE) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomTypeKtTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomTypeKtTest.kt new file mode 100644 index 0000000..a6920b6 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomTypeKtTest.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +package io.element.android.libraries.matrix.impl.room + +import io.element.android.libraries.matrix.api.room.RoomType +import org.junit.Test +import org.matrix.rustcomponents.sdk.RoomType as RustRoomType + +class RoomTypeKtTest { + @Test + fun toRoomType() { + assert(RustRoomType.Room.map() == RoomType.Room) + assert(RustRoomType.Space.map() == RoomType.Space) + assert(RustRoomType.Custom("m.other").map() == RoomType.Other("m.other")) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoomTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoomTest.kt new file mode 100644 index 0000000..a98a222 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoomTest.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room + +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels +import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoom +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListService +import io.element.android.libraries.matrix.test.A_DEVICE_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.isActive +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test +import uniffi.matrix_sdk.RoomMemberRole + +@Ignore("JNA direct mapping has broken unit tests with FFI fakes") +class RustBaseRoomTest { + @Test + fun `RustBaseRoom should cancel the room coroutine scope when it is destroyed`() = runTest { + val rustBaseRoom = createRustBaseRoom() + assertThat(rustBaseRoom.roomCoroutineScope.isActive).isTrue() + rustBaseRoom.destroy() + assertThat(rustBaseRoom.roomCoroutineScope.isActive).isFalse() + } + + @Test + fun `when currentUserMembership=JOINED and user leave room succeed then roomMembershipObserver emits change as LEFT`() = runTest { + val roomMembershipObserver = RoomMembershipObserver() + val rustBaseRoom = createRustBaseRoom( + initialRoomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.JOINED), + innerRoom = FakeFfiRoom( + leaveLambda = { + // Simulate a successful leave + } + ), + roomMembershipObserver = roomMembershipObserver, + ) + leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) { + val membershipUpdate = awaitItem() + assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId) + assertThat(membershipUpdate.isSpace).isFalse() + assertThat(membershipUpdate.isUserInRoom).isFalse() + assertThat(membershipUpdate.change).isEqualTo(MembershipChange.LEFT) + } + } + + @Test + fun `when currentUserMembership=KNOCKED and user leave room succeed then roomMembershipObserver emits change as KNOCK_RETRACTED`() = runTest { + val roomMembershipObserver = RoomMembershipObserver() + val rustBaseRoom = createRustBaseRoom( + initialRoomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.KNOCKED), + innerRoom = FakeFfiRoom( + leaveLambda = { + // Simulate a successful leave + } + ), + roomMembershipObserver = roomMembershipObserver, + ) + leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) { + val membershipUpdate = awaitItem() + assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId) + assertThat(membershipUpdate.isSpace).isFalse() + assertThat(membershipUpdate.isUserInRoom).isFalse() + assertThat(membershipUpdate.change).isEqualTo(MembershipChange.KNOCK_RETRACTED) + } + } + + @Test + fun `when currentUserMembership=INVITED and user leave room succeed then roomMembershipObserver emits change as INVITATION_REJECTED`() = runTest { + val roomMembershipObserver = RoomMembershipObserver() + val rustBaseRoom = createRustBaseRoom( + initialRoomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.INVITED), + innerRoom = FakeFfiRoom( + leaveLambda = { + // Simulate a successful leave + } + ), + roomMembershipObserver = roomMembershipObserver, + ) + leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) { + val membershipUpdate = awaitItem() + assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId) + assertThat(membershipUpdate.isSpace).isFalse() + assertThat(membershipUpdate.isUserInRoom).isFalse() + assertThat(membershipUpdate.change).isEqualTo(MembershipChange.INVITATION_REJECTED) + } + } + + @Test + fun `when user leave room fails then roomMembershipObserver emits nothing`() = runTest { + val roomMembershipObserver = RoomMembershipObserver() + val rustBaseRoom = createRustBaseRoom( + initialRoomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.INVITED), + innerRoom = FakeFfiRoom( + leaveLambda = { error("Leave failed") } + ), + roomMembershipObserver = roomMembershipObserver, + ) + leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) { + // No emit + } + } + + @Test + fun `userRole loads and maps the role`() = runTest { + val rustBaseRoom = createRustBaseRoom( + initialRoomInfo = aRoomInfo( + roomPowerLevels = RoomPowerLevels( + values = defaultRoomPowerLevelValues(), + users = persistentMapOf(A_USER_ID to 100L) + ) + ), + innerRoom = FakeFfiRoom( + suggestedRoleForUserLambda = { userId -> + // Simulate the role suggestion based on power level + if (userId == A_USER_ID.value) RoomMemberRole.ADMINISTRATOR else RoomMemberRole.USER + } + ), + ) + val result = rustBaseRoom.userRole(A_USER_ID).getOrNull() + assertThat(result).isNotNull() + assertThat(result).isEqualTo(RoomMember.Role.Admin) + + rustBaseRoom.destroy() + } + + private suspend fun TestScope.leaveRoomAndObserveMembershipChange( + roomMembershipObserver: RoomMembershipObserver, + rustBaseRoom: RustBaseRoom, + validate: suspend TurbineTestContext.() -> Unit + ) { + val shared = roomMembershipObserver.updates.shareIn(scope = backgroundScope, started = SharingStarted.Eagerly, replay = 1) + rustBaseRoom.leave() + shared.test { + validate() + ensureAllEventsConsumed() + } + rustBaseRoom.destroy() + } + + private fun TestScope.createRustBaseRoom( + initialRoomInfo: RoomInfo = aRoomInfo(), + innerRoom: FakeFfiRoom = FakeFfiRoom(), + roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), + ): RustBaseRoom { + val dispatchers = testCoroutineDispatchers() + return RustBaseRoom( + sessionId = A_SESSION_ID, + deviceId = A_DEVICE_ID, + innerRoom = innerRoom, + coroutineDispatchers = dispatchers, + roomSyncSubscriber = RoomSyncSubscriber( + roomListService = FakeFfiRoomListService(), + dispatchers = dispatchers, + ), + roomMembershipObserver = roomMembershipObserver, + // Not using backgroundScope here, but the test scope + sessionCoroutineScope = this, + roomInfoMapper = RoomInfoMapper(), + initialRoomInfo = initialRoomInfo, + ) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/StateEventTypeTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/StateEventTypeTest.kt new file mode 100644 index 0000000..245a321 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/StateEventTypeTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.StateEventType +import org.junit.Test +import org.matrix.rustcomponents.sdk.StateEventType as RustStateEventType + +class StateEventTypeTest { + @Test + fun `mapping Rust type should work`() { + assertThat(RustStateEventType.CALL_MEMBER.map()).isEqualTo(StateEventType.CALL_MEMBER) + assertThat(RustStateEventType.POLICY_RULE_ROOM.map()).isEqualTo(StateEventType.POLICY_RULE_ROOM) + assertThat(RustStateEventType.POLICY_RULE_SERVER.map()).isEqualTo(StateEventType.POLICY_RULE_SERVER) + assertThat(RustStateEventType.POLICY_RULE_USER.map()).isEqualTo(StateEventType.POLICY_RULE_USER) + assertThat(RustStateEventType.ROOM_ALIASES.map()).isEqualTo(StateEventType.ROOM_ALIASES) + assertThat(RustStateEventType.ROOM_AVATAR.map()).isEqualTo(StateEventType.ROOM_AVATAR) + assertThat(RustStateEventType.ROOM_CANONICAL_ALIAS.map()).isEqualTo(StateEventType.ROOM_CANONICAL_ALIAS) + assertThat(RustStateEventType.ROOM_CREATE.map()).isEqualTo(StateEventType.ROOM_CREATE) + assertThat(RustStateEventType.ROOM_ENCRYPTION.map()).isEqualTo(StateEventType.ROOM_ENCRYPTION) + assertThat(RustStateEventType.ROOM_GUEST_ACCESS.map()).isEqualTo(StateEventType.ROOM_GUEST_ACCESS) + assertThat(RustStateEventType.ROOM_HISTORY_VISIBILITY.map()).isEqualTo(StateEventType.ROOM_HISTORY_VISIBILITY) + assertThat(RustStateEventType.ROOM_JOIN_RULES.map()).isEqualTo(StateEventType.ROOM_JOIN_RULES) + assertThat(RustStateEventType.ROOM_MEMBER_EVENT.map()).isEqualTo(StateEventType.ROOM_MEMBER_EVENT) + assertThat(RustStateEventType.ROOM_NAME.map()).isEqualTo(StateEventType.ROOM_NAME) + assertThat(RustStateEventType.ROOM_PINNED_EVENTS.map()).isEqualTo(StateEventType.ROOM_PINNED_EVENTS) + assertThat(RustStateEventType.ROOM_POWER_LEVELS.map()).isEqualTo(StateEventType.ROOM_POWER_LEVELS) + assertThat(RustStateEventType.ROOM_SERVER_ACL.map()).isEqualTo(StateEventType.ROOM_SERVER_ACL) + assertThat(RustStateEventType.ROOM_THIRD_PARTY_INVITE.map()).isEqualTo(StateEventType.ROOM_THIRD_PARTY_INVITE) + assertThat(RustStateEventType.ROOM_TOMBSTONE.map()).isEqualTo(StateEventType.ROOM_TOMBSTONE) + assertThat(RustStateEventType.ROOM_TOPIC.map()).isEqualTo(StateEventType.ROOM_TOPIC) + assertThat(RustStateEventType.SPACE_CHILD.map()).isEqualTo(StateEventType.SPACE_CHILD) + assertThat(RustStateEventType.SPACE_PARENT.map()).isEqualTo(StateEventType.SPACE_PARENT) + } + + @Test + fun `mapping Kotlin type should work`() { + assertThat(StateEventType.CALL_MEMBER.map()).isEqualTo(RustStateEventType.CALL_MEMBER) + assertThat(StateEventType.POLICY_RULE_ROOM.map()).isEqualTo(RustStateEventType.POLICY_RULE_ROOM) + assertThat(StateEventType.POLICY_RULE_SERVER.map()).isEqualTo(RustStateEventType.POLICY_RULE_SERVER) + assertThat(StateEventType.POLICY_RULE_USER.map()).isEqualTo(RustStateEventType.POLICY_RULE_USER) + assertThat(StateEventType.ROOM_ALIASES.map()).isEqualTo(RustStateEventType.ROOM_ALIASES) + assertThat(StateEventType.ROOM_AVATAR.map()).isEqualTo(RustStateEventType.ROOM_AVATAR) + assertThat(StateEventType.ROOM_CANONICAL_ALIAS.map()).isEqualTo(RustStateEventType.ROOM_CANONICAL_ALIAS) + assertThat(StateEventType.ROOM_CREATE.map()).isEqualTo(RustStateEventType.ROOM_CREATE) + assertThat(StateEventType.ROOM_ENCRYPTION.map()).isEqualTo(RustStateEventType.ROOM_ENCRYPTION) + assertThat(StateEventType.ROOM_GUEST_ACCESS.map()).isEqualTo(RustStateEventType.ROOM_GUEST_ACCESS) + assertThat(StateEventType.ROOM_HISTORY_VISIBILITY.map()).isEqualTo(RustStateEventType.ROOM_HISTORY_VISIBILITY) + assertThat(StateEventType.ROOM_JOIN_RULES.map()).isEqualTo(RustStateEventType.ROOM_JOIN_RULES) + assertThat(StateEventType.ROOM_MEMBER_EVENT.map()).isEqualTo(RustStateEventType.ROOM_MEMBER_EVENT) + assertThat(StateEventType.ROOM_NAME.map()).isEqualTo(RustStateEventType.ROOM_NAME) + assertThat(StateEventType.ROOM_PINNED_EVENTS.map()).isEqualTo(RustStateEventType.ROOM_PINNED_EVENTS) + assertThat(StateEventType.ROOM_POWER_LEVELS.map()).isEqualTo(RustStateEventType.ROOM_POWER_LEVELS) + assertThat(StateEventType.ROOM_SERVER_ACL.map()).isEqualTo(RustStateEventType.ROOM_SERVER_ACL) + assertThat(StateEventType.ROOM_THIRD_PARTY_INVITE.map()).isEqualTo(RustStateEventType.ROOM_THIRD_PARTY_INVITE) + assertThat(StateEventType.ROOM_TOMBSTONE.map()).isEqualTo(RustStateEventType.ROOM_TOMBSTONE) + assertThat(StateEventType.ROOM_TOPIC.map()).isEqualTo(RustStateEventType.ROOM_TOPIC) + assertThat(StateEventType.SPACE_CHILD.map()).isEqualTo(RustStateEventType.SPACE_CHILD) + assertThat(StateEventType.SPACE_PARENT.map()).isEqualTo(RustStateEventType.SPACE_PARENT) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoomTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoomTest.kt new file mode 100644 index 0000000..e4f0b02 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoomTest.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.join + +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.impl.analytics.toAnalyticsJoinedRoom +import io.element.android.libraries.matrix.test.A_ROOM_ALIAS +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SERVER_LIST +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test + +@Ignore("JNA direct mapping has broken unit tests with FFI fakes") +class DefaultJoinRoomTest { + @Test + fun `when using roomId and there is no server names, the classic join room API is used`() = runTest { + val roomInfo = aRoomInfo() + val joinRoomLambda = lambdaRecorder { _: RoomId -> Result.success(roomInfo) } + val joinRoomByIdOrAliasLambda = lambdaRecorder { _: RoomIdOrAlias, _: List -> Result.success(roomInfo) } + val roomResult = FakeBaseRoom().apply { + givenRoomInfo(aRoomInfo()) + } + val aTrigger = JoinedRoom.Trigger.MobilePermalink + val client: MatrixClient = FakeMatrixClient().also { + it.joinRoomLambda = joinRoomLambda + it.joinRoomByIdOrAliasLambda = joinRoomByIdOrAliasLambda + it.givenGetRoomResult( + roomId = A_ROOM_ID, + result = roomResult + ) + } + val analyticsService = FakeAnalyticsService() + val sut = DefaultJoinRoom( + client = client, + analyticsService = analyticsService, + ) + sut.invoke(A_ROOM_ID.toRoomIdOrAlias(), emptyList(), aTrigger) + joinRoomByIdOrAliasLambda + .assertions() + .isNeverCalled() + joinRoomLambda + .assertions() + .isCalledOnce() + .with( + value(A_ROOM_ID) + ) + assertThat(analyticsService.capturedEvents).containsExactly( + roomResult.toAnalyticsJoinedRoom(aTrigger) + ) + } + + @Test + fun `when using roomId and server names are available, joinRoomByIdOrAlias API is used`() = runTest { + val roomInfo = aRoomInfo() + val joinRoomLambda = lambdaRecorder { _: RoomId -> Result.success(roomInfo) } + val joinRoomByIdOrAliasLambda = lambdaRecorder { _: RoomIdOrAlias, _: List -> Result.success(roomInfo) } + val roomResult = FakeBaseRoom().apply { + givenRoomInfo(aRoomInfo()) + } + val aTrigger = JoinedRoom.Trigger.MobilePermalink + val client: MatrixClient = FakeMatrixClient().also { + it.joinRoomLambda = joinRoomLambda + it.joinRoomByIdOrAliasLambda = joinRoomByIdOrAliasLambda + it.givenGetRoomResult( + roomId = A_ROOM_ID, + result = roomResult + ) + } + val analyticsService = FakeAnalyticsService() + val sut = DefaultJoinRoom( + client = client, + analyticsService = analyticsService, + ) + sut.invoke(A_ROOM_ID.toRoomIdOrAlias(), A_SERVER_LIST, aTrigger) + joinRoomByIdOrAliasLambda + .assertions() + .isCalledOnce() + .with( + value(A_ROOM_ID.toRoomIdOrAlias()), + value(A_SERVER_LIST) + ) + joinRoomLambda + .assertions() + .isNeverCalled() + assertThat(analyticsService.capturedEvents).containsExactly( + roomResult.toAnalyticsJoinedRoom(aTrigger) + ) + } + + @Test + fun `when using roomAlias, joinRoomByIdOrAlias API is used`() = runTest { + val roomInfo = aRoomInfo() + val joinRoomLambda = lambdaRecorder { _: RoomId -> Result.success(roomInfo) } + val joinRoomByIdOrAliasLambda = lambdaRecorder { _: RoomIdOrAlias, _: List -> Result.success(roomInfo) } + val roomResult = FakeBaseRoom().apply { + givenRoomInfo(aRoomInfo()) + } + val aTrigger = JoinedRoom.Trigger.MobilePermalink + val client: MatrixClient = FakeMatrixClient().also { + it.joinRoomLambda = joinRoomLambda + it.joinRoomByIdOrAliasLambda = joinRoomByIdOrAliasLambda + it.givenGetRoomResult( + roomId = A_ROOM_ID, + result = roomResult + ) + } + val analyticsService = FakeAnalyticsService() + val sut = DefaultJoinRoom( + client = client, + analyticsService = analyticsService, + ) + sut.invoke(A_ROOM_ALIAS.toRoomIdOrAlias(), A_SERVER_LIST, aTrigger) + joinRoomByIdOrAliasLambda + .assertions() + .isCalledOnce() + .with( + value(A_ROOM_ALIAS.toRoomIdOrAlias()), + value(emptyList()) + ) + joinRoomLambda + .assertions() + .isNeverCalled() + assertThat(analyticsService.capturedEvents).containsExactly( + roomResult.toAnalyticsJoinedRoom(aTrigger) + ) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetTypeKtTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetTypeKtTest.kt new file mode 100644 index 0000000..9b12d12 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetTypeKtTest.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.location + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.location.AssetType +import org.junit.Test + +class AssetTypeKtTest { + @Test + fun toInner() { + assertThat(AssetType.SENDER.toInner()).isEqualTo(org.matrix.rustcomponents.sdk.AssetType.SENDER) + assertThat(AssetType.PIN.toInner()).isEqualTo(org.matrix.rustcomponents.sdk.AssetType.PIN) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberListFetcherTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberListFetcherTest.kt new file mode 100644 index 0000000..266a4ee --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberListFetcherTest.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.member + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.roomMembers +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomMember +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoom +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomMembersIterator +import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher.Source.CACHE +import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher.Source.CACHE_AND_SERVER +import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher.Source.SERVER +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_ID_3 +import io.element.android.libraries.matrix.test.A_USER_ID_4 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test + +@Ignore("JNA direct mapping has broken unit tests with FFI fakes") +class RoomMemberListFetcherTest { + @Test + fun `fetchRoomMembers with CACHE source - emits cached members, if any`() = runTest { + val room = FakeFfiRoom(getMembersNoSync = { + FakeFfiRoomMembersIterator( + listOf( + aRustRoomMember(A_USER_ID), + aRustRoomMember(A_USER_ID_2), + aRustRoomMember(A_USER_ID_3), + ) + ) + }) + + val fetcher = RoomMemberListFetcher(room, Dispatchers.Default) + fetcher.membersFlow.test { + assertThat(awaitItem()).isInstanceOf(RoomMembersState.Unknown::class.java) + + fetcher.fetchRoomMembers(source = CACHE) + + // Loading state + assertThat(awaitItem()).isInstanceOf(RoomMembersState.Pending::class.java) + + val cachedItemsState = awaitItem() + assertThat(cachedItemsState).isInstanceOf(RoomMembersState.Ready::class.java) + assertThat((cachedItemsState as? RoomMembersState.Ready)?.roomMembers).hasSize(3) + } + } + + @Test + fun `fetchRoomMembers with CACHE source - emits empty list, if no members exist`() = runTest { + val room = FakeFfiRoom(getMembersNoSync = { + FakeFfiRoomMembersIterator(emptyList()) + }) + + val fetcher = RoomMemberListFetcher(room, Dispatchers.Default) + fetcher.membersFlow.test { + fetcher.fetchRoomMembers(source = CACHE) + assertThat(awaitItem()).isInstanceOf(RoomMembersState.Unknown::class.java) + assertThat(awaitItem()).isInstanceOf(RoomMembersState.Pending::class.java) + assertThat((awaitItem() as? RoomMembersState.Ready)?.roomMembers).isEmpty() + } + } + + @Test + fun `fetchRoomMembers with CACHE source - emits Error on error found`() = runTest { + val room = FakeFfiRoom(getMembersNoSync = { + error("Some unexpected issue") + }) + + val fetcher = RoomMemberListFetcher(room, Dispatchers.Default) + fetcher.membersFlow.test { + fetcher.fetchRoomMembers(source = CACHE) + assertThat(awaitItem()).isInstanceOf(RoomMembersState.Unknown::class.java) + assertThat(awaitItem()).isInstanceOf(RoomMembersState.Pending::class.java) + assertThat(awaitItem()).isInstanceOf(RoomMembersState.Error::class.java) + } + } + + @Test + fun `fetchRoomMembers with CACHE source - emits all items at once`() = runTest { + val room = FakeFfiRoom(getMembersNoSync = { + FakeFfiRoomMembersIterator( + listOf( + aRustRoomMember(A_USER_ID), + aRustRoomMember(A_USER_ID_2), + aRustRoomMember(A_USER_ID_3), + ) + ) + }) + + val fetcher = RoomMemberListFetcher(room, Dispatchers.Default, pageSize = 2) + fetcher.membersFlow.test { + fetcher.fetchRoomMembers(source = CACHE) + + // Initial state + assertThat(awaitItem()).isInstanceOf(RoomMembersState.Unknown::class.java) + // Started loading cached members + assertThat(awaitItem()).isInstanceOf(RoomMembersState.Pending::class.java) + // Finished loading cached members + assertThat((awaitItem() as? RoomMembersState.Ready)?.roomMembers).hasSize(3) + + ensureAllEventsConsumed() + } + } + + @Test + fun `fetchRoomMembers with SERVER source - emits only new members, if any`() = runTest { + val room = FakeFfiRoom(getMembers = { + FakeFfiRoomMembersIterator( + listOf( + aRustRoomMember(A_USER_ID), + aRustRoomMember(A_USER_ID_2), + aRustRoomMember(A_USER_ID_3), + ) + ) + }) + + val fetcher = RoomMemberListFetcher(room, Dispatchers.Default) + fetcher.membersFlow.test { + fetcher.fetchRoomMembers(source = SERVER) + + assertThat(awaitItem()).isInstanceOf(RoomMembersState.Unknown::class.java) + assertThat(awaitItem()).isInstanceOf(RoomMembersState.Pending::class.java) + assertThat((awaitItem() as? RoomMembersState.Ready)?.roomMembers?.size).isEqualTo(3) + } + } + + @Test + fun `fetchRoomMembers with SERVER source - on error it emits an Error item`() = runTest { + val room = FakeFfiRoom(getMembers = { error("An unexpected error") }) + + val fetcher = RoomMemberListFetcher(room, Dispatchers.Default) + fetcher.membersFlow.test { + fetcher.fetchRoomMembers(source = SERVER) + + assertThat(awaitItem()).isInstanceOf(RoomMembersState.Unknown::class.java) + assertThat(awaitItem()).isInstanceOf(RoomMembersState.Pending::class.java) + assertThat(awaitItem()).isInstanceOf(RoomMembersState.Error::class.java) + } + } + + @Test + fun `fetchRoomMembers with CACHE_AND_SERVER source - returns cached items first, then new ones`() = runTest { + val room = FakeFfiRoom( + getMembersNoSync = { + FakeFfiRoomMembersIterator(listOf(aRustRoomMember(A_USER_ID_4))) + }, + getMembers = { + FakeFfiRoomMembersIterator( + listOf( + aRustRoomMember(A_USER_ID), + aRustRoomMember(A_USER_ID_2), + aRustRoomMember(A_USER_ID_3), + ) + ) + } + ) + + val fetcher = RoomMemberListFetcher(room, Dispatchers.Default) + fetcher.membersFlow.test { + fetcher.fetchRoomMembers(source = CACHE_AND_SERVER) + // Initial + assertThat(awaitItem()).isInstanceOf(RoomMembersState.Unknown::class.java) + // Loading cached + awaitItem().let { pending -> + assertThat(pending).isInstanceOf(RoomMembersState.Pending::class.java) + assertThat(pending.roomMembers()).isEmpty() + } + // Loaded cached + awaitItem().let { cached -> + assertThat(cached).isInstanceOf(RoomMembersState.Pending::class.java) + assertThat(cached.roomMembers()).hasSize(1) + } + // Start loading new + awaitItem().let { ready -> + assertThat(ready).isInstanceOf(RoomMembersState.Ready::class.java) + assertThat(ready.roomMembers()).hasSize(3) + } + } + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberMapperTest.kt new file mode 100644 index 0000000..09bfafd --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberMapperTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.member + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import org.junit.Test +import uniffi.matrix_sdk.RoomMemberRole +import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState + +class RoomMemberMapperTest { + @Test + fun mapRole() { + assertThat(RoomMemberMapper.mapRole(RoomMemberRole.USER, 0L)).isEqualTo(RoomMember.Role.User) + assertThat(RoomMemberMapper.mapRole(RoomMemberRole.MODERATOR, 50L)).isEqualTo(RoomMember.Role.Moderator) + assertThat(RoomMemberMapper.mapRole(RoomMemberRole.ADMINISTRATOR, 100L)).isEqualTo(RoomMember.Role.Admin) + assertThat(RoomMemberMapper.mapRole(RoomMemberRole.ADMINISTRATOR, 150L)).isEqualTo(RoomMember.Role.Owner(isCreator = false)) + assertThat(RoomMemberMapper.mapRole(RoomMemberRole.CREATOR, Long.MAX_VALUE)).isEqualTo(RoomMember.Role.Owner(isCreator = true)) + + // `null` power level defaults to USER role + assertThat(RoomMemberMapper.mapRole(RoomMemberRole.ADMINISTRATOR, null)).isEqualTo(RoomMember.Role.Admin) + + // Power level is only taken into account for ADMINISTRATOR role + assertThat(RoomMemberMapper.mapRole(RoomMemberRole.USER, 123L)).isEqualTo(RoomMember.Role.User) + assertThat(RoomMemberMapper.mapRole(RoomMemberRole.MODERATOR, 1L)).isEqualTo(RoomMember.Role.Moderator) + assertThat(RoomMemberMapper.mapRole(RoomMemberRole.CREATOR, 0L)).isEqualTo(RoomMember.Role.Owner(isCreator = true)) + } + + @Test + fun mapMembership() { + assertThat(RoomMemberMapper.mapMembership(RustMembershipState.Ban)).isEqualTo(RoomMembershipState.BAN) + assertThat(RoomMemberMapper.mapMembership(RustMembershipState.Invite)).isEqualTo(RoomMembershipState.INVITE) + assertThat(RoomMemberMapper.mapMembership(RustMembershipState.Join)).isEqualTo(RoomMembershipState.JOIN) + assertThat(RoomMemberMapper.mapMembership(RustMembershipState.Knock)).isEqualTo(RoomMembershipState.KNOCK) + assertThat(RoomMemberMapper.mapMembership(RustMembershipState.Leave)).isEqualTo(RoomMembershipState.LEAVE) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/powerlevels/RoomPowerLevelsValuesMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/powerlevels/RoomPowerLevelsValuesMapperTest.kt new file mode 100644 index 0000000..3c10028 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/powerlevels/RoomPowerLevelsValuesMapperTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.powerlevels + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomPowerLevelsValues +import org.junit.Test + +class RoomPowerLevelsValuesMapperTest { + @Test + fun `test that mapping of RoomPowerLevelsValues is correct`() { + assertThat( + RoomPowerLevelsValuesMapper.map( + aRustRoomPowerLevelsValues( + ban = 1, + invite = 2, + kick = 3, + redact = 4, + eventsDefault = 5, + stateDefault = 6, + usersDefault = 7, + roomName = 8, + roomAvatar = 9, + roomTopic = 10, + spaceChild = 11, + ) + ) + ).isEqualTo( + RoomPowerLevelsValues( + ban = 1, + invite = 2, + kick = 3, + sendEvents = 5, + redactEvents = 4, + roomName = 8, + roomAvatar = 9, + roomTopic = 10, + spaceChild = 11, + ) + ) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/preview/RoomPreviewInfoMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/preview/RoomPreviewInfoMapperTest.kt new file mode 100644 index 0000000..f32e018 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/preview/RoomPreviewInfoMapperTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.room.preview + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomType +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomPreviewInfo +import io.element.android.libraries.matrix.test.A_ROOM_ALIAS +import io.element.android.libraries.matrix.test.A_ROOM_ID +import org.junit.Test +import org.matrix.rustcomponents.sdk.Membership +import org.matrix.rustcomponents.sdk.JoinRule as RustJoinRule + +class RoomPreviewInfoMapperTest { + @Test + fun `map should map values 1`() { + assertThat( + RoomPreviewInfoMapper.map( + info = aRustRoomPreviewInfo( + membership = Membership.JOINED, + ) + ) + ).isEqualTo( + RoomPreviewInfo( + roomId = A_ROOM_ID, + canonicalAlias = A_ROOM_ALIAS, + name = "name", + topic = "topic", + avatarUrl = "avatarUrl", + numberOfJoinedMembers = 1L, + roomType = RoomType.Room, + isHistoryWorldReadable = true, + membership = CurrentUserMembership.JOINED, + joinRule = JoinRule.Public, + ) + ) + } + + @Test + fun `map should map values 2`() { + assertThat( + RoomPreviewInfoMapper.map( + info = aRustRoomPreviewInfo( + canonicalAlias = null, + membership = Membership.JOINED, + joinRule = RustJoinRule.Knock, + ) + ) + ).isEqualTo( + RoomPreviewInfo( + roomId = A_ROOM_ID, + canonicalAlias = null, + name = "name", + topic = "topic", + avatarUrl = "avatarUrl", + numberOfJoinedMembers = 1L, + roomType = RoomType.Room, + isHistoryWorldReadable = true, + membership = CurrentUserMembership.JOINED, + joinRule = JoinRule.Knock, + ) + ) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDescriptionMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDescriptionMapperTest.kt new file mode 100644 index 0000000..cf9223b --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDescriptionMapperTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomdirectory + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.roomdirectory.RoomDescription +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomDescription +import io.element.android.libraries.matrix.test.A_ROOM_ALIAS +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.roomdirectory.aRoomDescription +import org.junit.Test +import org.matrix.rustcomponents.sdk.PublicRoomJoinRule + +class RoomDescriptionMapperTest { + @Test + fun map() { + assertThat(RoomDescriptionMapper().map(aRustRoomDescription())).isEqualTo( + aRoomDescription( + roomId = A_ROOM_ID, + name = "name", + topic = "topic", + alias = A_ROOM_ALIAS, + avatarUrl = "avatarUrl", + joinRule = RoomDescription.JoinRule.PUBLIC, + isWorldReadable = true, + joinedMembers = 2L + ) + ) + } + + @Test + fun mapWithNullAlias() { + assertThat(RoomDescriptionMapper().map(aRustRoomDescription().copy(alias = null)).alias).isNull() + } + + @Test + fun `map join rule`() { + assertThat(PublicRoomJoinRule.PUBLIC.map()).isEqualTo(RoomDescription.JoinRule.PUBLIC) + assertThat(PublicRoomJoinRule.KNOCK.map()).isEqualTo(RoomDescription.JoinRule.KNOCK) + assertThat(null.map()).isEqualTo(RoomDescription.JoinRule.UNKNOWN) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchProcessorTest.kt new file mode 100644 index 0000000..c59d156 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RoomDirectorySearchProcessorTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomdirectory + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomDescription +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_ID_3 +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.matrix.rustcomponents.sdk.RoomDirectorySearchEntryUpdate + +class RoomDirectorySearchProcessorTest { + private val rustRoom1 = aRustRoomDescription(roomId = A_ROOM_ID.value) + private val rustRoom2 = aRustRoomDescription(roomId = A_ROOM_ID_2.value) + private val rustRoom3 = aRustRoomDescription(roomId = A_ROOM_ID_3.value) + private val mapper = RoomDescriptionMapper() + private val room1 = mapper.map(rustRoom1) + private val room2 = mapper.map(rustRoom2) + private val room3 = mapper.map(rustRoom3) + + @Test + fun test() = runTest { + val sut = RoomDirectorySearchProcessor( + coroutineContext = StandardTestDispatcher(testScheduler), + ) + sut.roomDescriptionsFlow.test { + sut.postUpdates(listOf(RoomDirectorySearchEntryUpdate.Reset(listOf(rustRoom1)))) + assertThat(awaitItem()).isEqualTo(listOf(room1)) + sut.postUpdates(listOf(RoomDirectorySearchEntryUpdate.Append(listOf(rustRoom2)))) + assertThat(awaitItem()).isEqualTo(listOf(room1, room2)) + sut.postUpdates(listOf(RoomDirectorySearchEntryUpdate.PushFront(rustRoom3))) + assertThat(awaitItem()).isEqualTo(listOf(room3, room1, room2)) + sut.postUpdates(listOf(RoomDirectorySearchEntryUpdate.PopFront)) + assertThat(awaitItem()).isEqualTo(listOf(room1, room2)) + sut.postUpdates(listOf(RoomDirectorySearchEntryUpdate.PushBack(rustRoom3))) + assertThat(awaitItem()).isEqualTo(listOf(room1, room2, room3)) + sut.postUpdates(listOf(RoomDirectorySearchEntryUpdate.PopBack)) + assertThat(awaitItem()).isEqualTo(listOf(room1, room2)) + sut.postUpdates(listOf(RoomDirectorySearchEntryUpdate.Insert(1u, rustRoom3))) + assertThat(awaitItem()).isEqualTo(listOf(room1, room3, room2)) + sut.postUpdates(listOf(RoomDirectorySearchEntryUpdate.Remove(1u))) + assertThat(awaitItem()).isEqualTo(listOf(room1, room2)) + + sut.postUpdates(listOf(RoomDirectorySearchEntryUpdate.Reset(listOf(rustRoom1, rustRoom2)))) + assertThat(awaitItem()).isEqualTo(listOf(room1, room2)) + sut.postUpdates(listOf(RoomDirectorySearchEntryUpdate.Set(1u, rustRoom3))) + assertThat(awaitItem()).isEqualTo(listOf(room1, room3)) + sut.postUpdates(listOf(RoomDirectorySearchEntryUpdate.Truncate(1u))) + assertThat(awaitItem()).isEqualTo(listOf(room1)) + + sut.postUpdates(listOf(RoomDirectorySearchEntryUpdate.Clear)) + assertThat(awaitItem()).isEmpty() + + // Check that all the actions are performed + sut.postUpdates( + listOf( + RoomDirectorySearchEntryUpdate.PushBack(rustRoom1), + RoomDirectorySearchEntryUpdate.PushBack(rustRoom2), + RoomDirectorySearchEntryUpdate.PushBack(rustRoom3), + ) + ) + assertThat(awaitItem()).isEqualTo(listOf(room1, room2, room3)) + } + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustBaseRoomDirectoryListTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustBaseRoomDirectoryListTest.kt new file mode 100644 index 0000000..cf3260b --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustBaseRoomDirectoryListTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomdirectory + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomDescription +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomDirectorySearch +import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test +import org.matrix.rustcomponents.sdk.RoomDirectorySearch +import org.matrix.rustcomponents.sdk.RoomDirectorySearchEntryUpdate + +@Ignore("JNA direct mapping has broken unit tests with FFI fakes") +@OptIn(ExperimentalCoroutinesApi::class) +class RustBaseRoomDirectoryListTest { + @Test + fun `check that the state emits the expected values`() = runTest { + val roomDirectorySearch = FakeFfiRoomDirectorySearch() + val mapper = RoomDescriptionMapper() + val sut = createRustRoomDirectoryList( + roomDirectorySearch = roomDirectorySearch, + ) + // Let the mxCallback be ready + runCurrent() + sut.state.test { + sut.filter(filter = "", batchSize = 20, viaServerName = null) + roomDirectorySearch.emitResult( + listOf( + RoomDirectorySearchEntryUpdate.Append(listOf(aRustRoomDescription())) + ) + ) + val initialItem = awaitItem() + assertThat(initialItem).isEqualTo( + RoomDirectoryList.SearchResult( + hasMoreToLoad = true, + items = listOf(mapper.map(aRustRoomDescription())) + ) + ) + assertThat(initialItem.hasMoreToLoad).isTrue() + roomDirectorySearch.isAtLastPage = true + sut.loadMore() + roomDirectorySearch.emitResult( + listOf( + RoomDirectorySearchEntryUpdate.Append(listOf(aRustRoomDescription(A_ROOM_ID_2.value))) + ) + ) + val nextItem = awaitItem() + assertThat(nextItem).isEqualTo( + RoomDirectoryList.SearchResult( + hasMoreToLoad = false, + items = listOf( + mapper.map(aRustRoomDescription()), + ) + ) + ) + val finalItem = awaitItem() + assertThat(finalItem).isEqualTo( + RoomDirectoryList.SearchResult( + hasMoreToLoad = false, + items = listOf( + mapper.map(aRustRoomDescription()), + mapper.map(aRustRoomDescription(A_ROOM_ID_2.value)), + ) + ) + ) + } + } + + private fun TestScope.createRustRoomDirectoryList( + roomDirectorySearch: RoomDirectorySearch = FakeFfiRoomDirectorySearch(), + ) = RustRoomDirectoryList( + inner = roomDirectorySearch, + coroutineScope = backgroundScope, + coroutineContext = StandardTestDispatcher(testScheduler), + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustBaseRoomDirectoryServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustBaseRoomDirectoryServiceTest.kt new file mode 100644 index 0000000..04e6a9a --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustBaseRoomDirectoryServiceTest.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomdirectory + +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test + +class RustBaseRoomDirectoryServiceTest { + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun test() = runTest { + val client = FakeFfiClient() + val sut = RustRoomDirectoryService( + client = client, + sessionDispatcher = StandardTestDispatcher(testScheduler), + ) + sut.createRoomDirectoryList(backgroundScope) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactoryTest.kt new file mode 100644 index 0000000..ec8053e --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactoryTest.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomlist + +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomList +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListService +import io.element.android.services.analytics.test.FakeAnalyticsService +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test +import kotlin.coroutines.EmptyCoroutineContext + +class RoomListFactoryTest { + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `createRoomList should work`() = runTest { + val sut = RoomListFactory( + innerRoomListService = FakeFfiRoomListService(), + sessionCoroutineScope = backgroundScope, + analyticsService = FakeAnalyticsService(), + ) + sut.createRoomList( + pageSize = 10, + coroutineContext = EmptyCoroutineContext, + ) { + FakeFfiRoomList() + } + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterTest.kt new file mode 100644 index 0000000..9568de3 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomlist + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter +import io.element.android.libraries.matrix.test.room.aRoomSummary +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RoomListFilterTest { + private val regularRoom = aRoomSummary( + isDirect = false, + ) + private val dmRoom = aRoomSummary( + isDirect = true, + activeMembersCount = 2 + ) + private val favoriteRoom = aRoomSummary( + isFavorite = true + ) + private val markedAsUnreadRoom = aRoomSummary( + isMarkedUnread = true + ) + private val unreadNotificationRoom = aRoomSummary( + numUnreadNotifications = 1 + ) + private val roomToSearch = aRoomSummary( + name = "Room to search" + ) + private val roomWithAccent = aRoomSummary( + name = "Frédéric" + ) + private val invitedRoom = aRoomSummary( + currentUserMembership = CurrentUserMembership.INVITED + ) + + private val space = aRoomSummary( + isSpace = true + ) + private val invitedSpace = aRoomSummary( + isSpace = true, + currentUserMembership = CurrentUserMembership.INVITED + ) + + private val roomSummaries = listOf( + regularRoom, + dmRoom, + favoriteRoom, + markedAsUnreadRoom, + unreadNotificationRoom, + roomToSearch, + roomWithAccent, + invitedRoom, + space, + invitedSpace, + ) + + @Test + fun `Room list filter all empty`() = runTest { + val filter = RoomListFilter.all() + assertThat(roomSummaries.filter(filter)).isEqualTo(roomSummaries - space) + } + + @Test + fun `Room list filter none`() = runTest { + val filter = RoomListFilter.None + assertThat(roomSummaries.filter(filter)).isEmpty() + } + + @Test + fun `Room list filter people`() = runTest { + val filter = RoomListFilter.Category.People + assertThat(roomSummaries.filter(filter)).containsExactly(dmRoom) + } + + @Test + fun `Room list filter group`() = runTest { + val filter = RoomListFilter.Category.Group + assertThat(roomSummaries.filter(filter)).containsExactly( + regularRoom, + favoriteRoom, + markedAsUnreadRoom, + unreadNotificationRoom, + roomToSearch, + roomWithAccent, + ) + } + + @Test + fun `Room list filter space`() = runTest { + val filter = RoomListFilter.Category.Space + assertThat(roomSummaries.filter(filter)).containsExactly(space, invitedSpace) + } + + @Test + fun `Room list filter favorite`() = runTest { + val filter = RoomListFilter.Favorite + assertThat(roomSummaries.filter(filter)).containsExactly(favoriteRoom) + } + + @Test + fun `Room list filter unread`() = runTest { + val filter = RoomListFilter.Unread + assertThat(roomSummaries.filter(filter)).containsExactly(markedAsUnreadRoom, unreadNotificationRoom) + } + + @Test + fun `Room list filter invites`() = runTest { + val filter = RoomListFilter.Invite + assertThat(roomSummaries.filter(filter)).containsExactly(invitedRoom, invitedSpace) + } + + @Test + fun `Room list filter normalized match room name`() = runTest { + val filter = RoomListFilter.NormalizedMatchRoomName("search") + assertThat(roomSummaries.filter(filter)).containsExactly(roomToSearch) + } + + @Test + fun `Room list filter normalized match room name with accent`() = runTest { + val filter = RoomListFilter.NormalizedMatchRoomName("Fred") + assertThat(roomSummaries.filter(filter)).containsExactly(roomWithAccent) + } + + @Test + fun `Room list filter normalized match room name with accent when searching with accent`() = runTest { + val filter = RoomListFilter.NormalizedMatchRoomName("Fréd") + assertThat(roomSummaries.filter(filter)).containsExactly(roomWithAccent) + } + + @Test + fun `Room list filter all with one match`() = runTest { + val filter = RoomListFilter.all( + RoomListFilter.Category.Group, + RoomListFilter.Favorite + ) + assertThat(roomSummaries.filter(filter)).containsExactly(favoriteRoom) + } + + @Test + fun `Room list filter all with no match`() = runTest { + val filter = RoomListFilter.all( + RoomListFilter.Category.People, + RoomListFilter.Favorite + ) + assertThat(roomSummaries.filter(filter)).isEmpty() + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt new file mode 100644 index 0000000..eaa62ab --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.roomlist + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoom +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListService +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_ID_3 +import io.element.android.libraries.matrix.test.room.aRoomSummary +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test +import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate + +class RoomSummaryListProcessorTest { + private val summaries = MutableStateFlow>(emptyList()) + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Append adds new entries at the end of the list`() = runTest { + summaries.value = listOf(aRoomSummary()) + val processor = createProcessor() + + val newEntry = aRustRoom(A_ROOM_ID_2) + processor.postUpdate(listOf(RoomListEntriesUpdate.Append(listOf(newEntry, newEntry, newEntry)))) + + assertThat(summaries.value.count()).isEqualTo(4) + assertThat(summaries.value.subList(1, 4).all { it.roomId == A_ROOM_ID_2 }).isTrue() + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `PushBack adds a new entry at the end of the list`() = runTest { + summaries.value = listOf(aRoomSummary()) + val processor = createProcessor() + processor.postUpdate(listOf(RoomListEntriesUpdate.PushBack(aRustRoom(A_ROOM_ID_2)))) + + assertThat(summaries.value.count()).isEqualTo(2) + assertThat(summaries.value.last().roomId).isEqualTo(A_ROOM_ID_2) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `PushFront inserts a new entry at the start of the list`() = runTest { + summaries.value = listOf(aRoomSummary()) + val processor = createProcessor() + processor.postUpdate(listOf(RoomListEntriesUpdate.PushFront(aRustRoom(A_ROOM_ID_2)))) + + assertThat(summaries.value.count()).isEqualTo(2) + assertThat(summaries.value.first().roomId).isEqualTo(A_ROOM_ID_2) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Set replaces an entry at some index`() = runTest { + summaries.value = listOf(aRoomSummary()) + val processor = createProcessor() + val index = 0 + + processor.postUpdate(listOf(RoomListEntriesUpdate.Set(index.toUInt(), aRustRoom(A_ROOM_ID_2)))) + + assertThat(summaries.value.count()).isEqualTo(1) + assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID_2) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Insert inserts a new entry at the provided index`() = runTest { + summaries.value = listOf(aRoomSummary()) + val processor = createProcessor() + val index = 0 + + processor.postUpdate(listOf(RoomListEntriesUpdate.Insert(index.toUInt(), aRustRoom(A_ROOM_ID_2)))) + + assertThat(summaries.value.count()).isEqualTo(2) + assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID_2) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Remove removes an entry at some index`() = runTest { + summaries.value = listOf( + aRoomSummary(roomId = A_ROOM_ID), + aRoomSummary(A_ROOM_ID_2) + ) + val processor = createProcessor() + val index = 0 + + processor.postUpdate(listOf(RoomListEntriesUpdate.Remove(index.toUInt()))) + + assertThat(summaries.value.count()).isEqualTo(1) + assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID_2) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `PopBack removes an entry at the end of the list`() = runTest { + summaries.value = listOf( + aRoomSummary(roomId = A_ROOM_ID), + aRoomSummary(A_ROOM_ID_2) + ) + val processor = createProcessor() + val index = 0 + + processor.postUpdate(listOf(RoomListEntriesUpdate.PopBack)) + + assertThat(summaries.value.count()).isEqualTo(1) + assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `PopFront removes an entry at the start of the list`() = runTest { + summaries.value = listOf( + aRoomSummary(roomId = A_ROOM_ID), + aRoomSummary(A_ROOM_ID_2) + ) + val processor = createProcessor() + val index = 0 + + processor.postUpdate(listOf(RoomListEntriesUpdate.PopFront)) + + assertThat(summaries.value.count()).isEqualTo(1) + assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID_2) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Clear removes all the entries`() = runTest { + summaries.value = listOf( + aRoomSummary(roomId = A_ROOM_ID), + aRoomSummary(A_ROOM_ID_2) + ) + val processor = createProcessor() + + processor.postUpdate(listOf(RoomListEntriesUpdate.Clear)) + + assertThat(summaries.value).isEmpty() + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Truncate removes all entries after the provided length`() = runTest { + summaries.value = listOf( + aRoomSummary(roomId = A_ROOM_ID), + aRoomSummary(A_ROOM_ID_2) + ) + val processor = createProcessor() + val index = 0 + + processor.postUpdate(listOf(RoomListEntriesUpdate.Truncate(1u))) + + assertThat(summaries.value.count()).isEqualTo(1) + assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Reset removes all entries and add the provided ones`() = runTest { + summaries.value = listOf( + aRoomSummary(roomId = A_ROOM_ID), + aRoomSummary(A_ROOM_ID_2) + ) + val processor = createProcessor() + val index = 0 + + processor.postUpdate(listOf(RoomListEntriesUpdate.Reset(listOf(aRustRoom(A_ROOM_ID_3))))) + + assertThat(summaries.value.count()).isEqualTo(1) + assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID_3) + } + + private fun aRustRoom(roomId: RoomId = A_ROOM_ID) = FakeFfiRoom( + roomId = roomId, + latestEventLambda = { null }, + ) + + private fun TestScope.createProcessor() = RoomSummaryListProcessor( + summaries, + FakeFfiRoomListService(), + coroutineContext = StandardTestDispatcher(testScheduler), + roomSummaryFactory = RoomSummaryFactory(), + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustBaseRoomListServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustBaseRoomListServiceTest.kt new file mode 100644 index 0000000..9c8c0dd --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustBaseRoomListServiceTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +package io.element.android.libraries.matrix.impl.roomlist + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListService +import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test +import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicator +import org.matrix.rustcomponents.sdk.RoomListService as RustRoomListService + +@Ignore("JNA direct mapping has broken unit tests with FFI fakes") +@OptIn(ExperimentalCoroutinesApi::class) +class RustBaseRoomListServiceTest { + @Test + fun `syncIndicator should emit the expected values`() = runTest { + val roomListService = FakeFfiRoomListService() + val sut = createRustRoomListService( + roomListService = roomListService, + ) + // Give time for mxCallback to setup + runCurrent() + sut.syncIndicator.test { + assertThat(awaitItem()).isEqualTo(RoomListService.SyncIndicator.Hide) + roomListService.emitRoomListServiceSyncIndicator(RoomListServiceSyncIndicator.SHOW) + assertThat(awaitItem()).isEqualTo(RoomListService.SyncIndicator.Show) + roomListService.emitRoomListServiceSyncIndicator(RoomListServiceSyncIndicator.HIDE) + assertThat(awaitItem()).isEqualTo(RoomListService.SyncIndicator.Hide) + } + } +} + +private fun TestScope.createRustRoomListService( + roomListService: RustRoomListService = FakeFfiRoomListService(), +) = RustRoomListService( + innerRoomListService = roomListService, + sessionDispatcher = StandardTestDispatcher(testScheduler), + roomListFactory = RoomListFactory( + innerRoomListService = roomListService, + sessionCoroutineScope = backgroundScope, + analyticsService = FakeAnalyticsService(), + ), + roomSyncSubscriber = RoomSyncSubscriber( + roomListService = roomListService, + dispatchers = testCoroutineDispatchers(), + ), + sessionCoroutineScope = backgroundScope, +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/server/DefaultUserServerResolverTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/server/DefaultUserServerResolverTest.kt new file mode 100644 index 0000000..152475a --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/server/DefaultUserServerResolverTest.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.server + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.FakeMatrixClient +import org.junit.Test + +class DefaultUserServerResolverTest { + @Test + fun resolve() { + // Given + val userServerResolver = DefaultUserServerResolver(FakeMatrixClient( + userIdServerNameLambda = { "dummy.org" } + )) + + // When + val result = userServerResolver.resolve() + + // Then + assertThat(result).isEqualTo("dummy.org") + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RoomSummaryListProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RoomSummaryListProcessorTest.kt new file mode 100644 index 0000000..116b98a --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RoomSummaryListProcessorTest.kt @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.spaces + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSpaceRoom +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_ID_3 +import io.element.android.libraries.previewutils.room.aSpaceRoom +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test +import org.matrix.rustcomponents.sdk.SpaceListUpdate + +class RoomSummaryListProcessorTest { + private val spaceRoomsFlow = MutableStateFlow>(emptyList()) + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Append adds new entries at the end of the list`() = runTest { + spaceRoomsFlow.value = listOf(aSpaceRoom()) + val processor = createProcessor() + + val newEntry = aRustSpaceRoom(roomId = A_ROOM_ID_2) + processor.postUpdates(listOf(SpaceListUpdate.Append(listOf(newEntry, newEntry, newEntry)))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(4) + assertThat(spaceRoomsFlow.value.subList(1, 4).all { it.roomId == A_ROOM_ID_2 }).isTrue() + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `PushBack adds a new entry at the end of the list`() = runTest { + spaceRoomsFlow.value = listOf(aSpaceRoom()) + val processor = createProcessor() + processor.postUpdates(listOf(SpaceListUpdate.PushBack(aRustSpaceRoom(roomId = A_ROOM_ID_2)))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(2) + assertThat(spaceRoomsFlow.value.last().roomId).isEqualTo(A_ROOM_ID_2) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `PushFront inserts a new entry at the start of the list`() = runTest { + spaceRoomsFlow.value = listOf(aSpaceRoom()) + val processor = createProcessor() + processor.postUpdates(listOf(SpaceListUpdate.PushFront(aRustSpaceRoom(roomId = A_ROOM_ID_2)))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(2) + assertThat(spaceRoomsFlow.value.first().roomId).isEqualTo(A_ROOM_ID_2) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Set replaces an entry at some index`() = runTest { + spaceRoomsFlow.value = listOf(aSpaceRoom()) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.Set(index.toUInt(), aRustSpaceRoom(roomId = A_ROOM_ID_2)))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(1) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID_2) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Insert inserts a new entry at the provided index`() = runTest { + spaceRoomsFlow.value = listOf(aSpaceRoom()) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.Insert(index.toUInt(), aRustSpaceRoom(roomId = A_ROOM_ID_2)))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(2) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID_2) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Remove removes an entry at some index`() = runTest { + spaceRoomsFlow.value = listOf( + aSpaceRoom(roomId = A_ROOM_ID), + aSpaceRoom(roomId = A_ROOM_ID_2) + ) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.Remove(index.toUInt()))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(1) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID_2) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `PopBack removes an entry at the end of the list`() = runTest { + spaceRoomsFlow.value = listOf( + aSpaceRoom(roomId = A_ROOM_ID), + aSpaceRoom(roomId = A_ROOM_ID_2) + ) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.PopBack)) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(1) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `PopFront removes an entry at the start of the list`() = runTest { + spaceRoomsFlow.value = listOf( + aSpaceRoom(roomId = A_ROOM_ID), + aSpaceRoom(roomId = A_ROOM_ID_2) + ) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.PopFront)) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(1) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID_2) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Clear removes all the entries`() = runTest { + spaceRoomsFlow.value = listOf( + aSpaceRoom(roomId = A_ROOM_ID), + aSpaceRoom(roomId = A_ROOM_ID_2) + ) + val processor = createProcessor() + + processor.postUpdates(listOf(SpaceListUpdate.Clear)) + + assertThat(spaceRoomsFlow.value).isEmpty() + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Truncate removes all entries after the provided length`() = runTest { + spaceRoomsFlow.value = listOf( + aSpaceRoom(roomId = A_ROOM_ID), + aSpaceRoom(roomId = A_ROOM_ID_2) + ) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.Truncate(1u))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(1) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Reset removes all entries and add the provided ones`() = runTest { + spaceRoomsFlow.value = listOf( + aSpaceRoom(roomId = A_ROOM_ID), + aSpaceRoom(roomId = A_ROOM_ID_2) + ) + val processor = createProcessor() + val index = 0 + + processor.postUpdates(listOf(SpaceListUpdate.Reset(listOf(aRustSpaceRoom(A_ROOM_ID_3))))) + + assertThat(spaceRoomsFlow.value.count()).isEqualTo(1) + assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID_3) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `When there is no replay cache SpaceListUpdateProcessor starts with an empty list`() = runTest { + val spaceRoomsSharedFlow = MutableSharedFlow>(replay = 1) + val processor = createProcessor( + spaceRoomsFlow = spaceRoomsSharedFlow, + ) + assertThat(spaceRoomsSharedFlow.replayCache).isEmpty() + val newEntry = aRustSpaceRoom(roomId = A_ROOM_ID) + processor.postUpdates(listOf(SpaceListUpdate.Append(listOf(newEntry)))) + assertThat(spaceRoomsSharedFlow.replayCache).hasSize(1) + assertThat(spaceRoomsSharedFlow.replayCache.first()).hasSize(1) + } + + private fun createProcessor( + spaceRoomsFlow: MutableSharedFlow> = this.spaceRoomsFlow, + ) = SpaceListUpdateProcessor( + spaceRoomsFlow = spaceRoomsFlow, + mapper = SpaceRoomMapper(), + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomListTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomListTest.kt new file mode 100644 index 0000000..74cc97d --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomListTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.matrix.impl.spaces + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSpaceRoom +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSpaceRoomList +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.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test +import org.matrix.rustcomponents.sdk.SpaceListUpdate +import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState +import org.matrix.rustcomponents.sdk.SpaceRoomList as InnerSpaceRoomList + +class RustSpaceRoomListTest { + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `paginationStatusFlow emits values`() = runTest { + val innerSpaceRoomList = FakeFfiSpaceRoomList( + paginationStateResult = { SpaceRoomListPaginationState.Idle(false) } + ) + val sut = createRustSpaceRoomList( + innerSpaceRoomList = innerSpaceRoomList, + ) + sut.paginationStatusFlow.test { + // First value is the initial one + assertThat(awaitItem()).isEqualTo(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false)) + // First value after the subscription occurs + assertThat(awaitItem()).isEqualTo(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = true)) + innerSpaceRoomList.triggerPaginationStateUpdate(SpaceRoomListPaginationState.Loading) + assertThat(awaitItem()).isEqualTo(SpaceRoomList.PaginationStatus.Loading) + innerSpaceRoomList.triggerPaginationStateUpdate(SpaceRoomListPaginationState.Idle(true)) + assertThat(awaitItem()).isEqualTo(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false)) + innerSpaceRoomList.triggerPaginationStateUpdate(SpaceRoomListPaginationState.Idle(false)) + assertThat(awaitItem()).isEqualTo(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = true)) + } + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `spaceRoomsFlow emits values`() = runTest { + val innerSpaceRoomList = FakeFfiSpaceRoomList( + paginationStateResult = { SpaceRoomListPaginationState.Idle(false) } + ) + val sut = createRustSpaceRoomList( + innerSpaceRoomList = innerSpaceRoomList, + ) + sut.spaceRoomsFlow.test { + // Give time for the subscription to be set + runCurrent() + innerSpaceRoomList.triggerRoomListUpdate( + listOf( + SpaceListUpdate.PushBack(aRustSpaceRoom(roomId = A_ROOM_ID_2)) + ) + ) + val rooms = awaitItem() + assertThat(rooms).hasSize(1) + assertThat(rooms[0].roomId).isEqualTo(A_ROOM_ID_2) + } + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `paginate invokes paginate on the inner class`() = runTest { + val paginateResult = lambdaRecorder { } + val innerSpaceRoomList = FakeFfiSpaceRoomList( + paginateResult = paginateResult, + ) + val sut = createRustSpaceRoomList( + innerSpaceRoomList = innerSpaceRoomList, + ) + sut.paginate() + paginateResult.assertions().isCalledOnce() + } + + private fun TestScope.createRustSpaceRoomList( + roomId: RoomId = A_ROOM_ID, + innerSpaceRoomList: InnerSpaceRoomList = FakeFfiSpaceRoomList(), + innerProvider: suspend () -> InnerSpaceRoomList = { innerSpaceRoomList }, + spaceRoomMapper: SpaceRoomMapper = SpaceRoomMapper(), + ): RustSpaceRoomList { + return RustSpaceRoomList( + roomId = roomId, + innerProvider = innerProvider, + coroutineScope = backgroundScope, + spaceRoomMapper = spaceRoomMapper, + ) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/sync/AppStateMapperKtTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/sync/AppStateMapperKtTest.kt new file mode 100644 index 0000000..38462ca --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/sync/AppStateMapperKtTest.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.sync + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.sync.SyncState +import org.junit.Test +import org.matrix.rustcomponents.sdk.SyncServiceState + +class AppStateMapperKtTest { + @Test + fun toSyncState() { + assertThat(SyncServiceState.IDLE.toSyncState()).isEqualTo(SyncState.Idle) + assertThat(SyncServiceState.RUNNING.toSyncState()).isEqualTo(SyncState.Running) + assertThat(SyncServiceState.TERMINATED.toSyncState()).isEqualTo(SyncState.Terminated) + assertThat(SyncServiceState.ERROR.toSyncState()).isEqualTo(SyncState.Error) + assertThat(SyncServiceState.OFFLINE.toSyncState()).isEqualTo(SyncState.Offline) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessorTest.kt new file mode 100644 index 0000000..ba7f640 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessorTest.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTimelineItem +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 io.element.android.libraries.matrix.test.A_UNIQUE_ID +import io.element.android.libraries.matrix.test.A_UNIQUE_ID_2 +import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test +import org.matrix.rustcomponents.sdk.TimelineDiff + +class MatrixTimelineDiffProcessorTest { + private val timelineItems = MutableStateFlow>(emptyList()) + + private val anEvent = MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem()) + private val anEvent2 = MatrixTimelineItem.Event(A_UNIQUE_ID_2, anEventTimelineItem()) + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Append adds new entries at the end of the list`() = runTest { + timelineItems.value = listOf(anEvent) + val processor = createMatrixTimelineDiffProcessor(timelineItems) + processor.postDiffs(listOf(TimelineDiff.Append(listOf(FakeFfiTimelineItem())))) + assertThat(timelineItems.value.count()).isEqualTo(2) + assertThat(timelineItems.value).containsExactly( + anEvent, + MatrixTimelineItem.Other, + ) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `PushBack adds a new entry at the end of the list`() = runTest { + timelineItems.value = listOf(anEvent) + val processor = createMatrixTimelineDiffProcessor(timelineItems) + processor.postDiffs(listOf(TimelineDiff.PushBack(FakeFfiTimelineItem()))) + assertThat(timelineItems.value.count()).isEqualTo(2) + assertThat(timelineItems.value).containsExactly( + anEvent, + MatrixTimelineItem.Other, + ) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `PushFront inserts a new entry at the start of the list`() = runTest { + timelineItems.value = listOf(anEvent) + val processor = createMatrixTimelineDiffProcessor(timelineItems) + processor.postDiffs(listOf(TimelineDiff.PushFront(FakeFfiTimelineItem()))) + assertThat(timelineItems.value.count()).isEqualTo(2) + assertThat(timelineItems.value).containsExactly( + MatrixTimelineItem.Other, + anEvent, + ) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Set replaces an entry at some index`() = runTest { + timelineItems.value = listOf(anEvent, anEvent2) + val processor = createMatrixTimelineDiffProcessor(timelineItems) + processor.postDiffs(listOf(TimelineDiff.Set(1u, FakeFfiTimelineItem()))) + assertThat(timelineItems.value.count()).isEqualTo(2) + assertThat(timelineItems.value).containsExactly( + anEvent, + MatrixTimelineItem.Other + ) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Insert inserts a new entry at the provided index`() = runTest { + timelineItems.value = listOf(anEvent, anEvent2) + val processor = createMatrixTimelineDiffProcessor(timelineItems) + processor.postDiffs(listOf(TimelineDiff.Insert(1u, FakeFfiTimelineItem()))) + assertThat(timelineItems.value.count()).isEqualTo(3) + assertThat(timelineItems.value).containsExactly( + anEvent, + MatrixTimelineItem.Other, + anEvent2, + ) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Remove removes an entry at some index`() = runTest { + timelineItems.value = listOf(anEvent, MatrixTimelineItem.Other, anEvent2) + val processor = createMatrixTimelineDiffProcessor(timelineItems) + processor.postDiffs(listOf(TimelineDiff.Remove(1u))) + assertThat(timelineItems.value.count()).isEqualTo(2) + assertThat(timelineItems.value).containsExactly( + anEvent, + anEvent2, + ) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `PopBack removes an entry at the end of the list`() = runTest { + timelineItems.value = listOf(anEvent, anEvent2) + val processor = createMatrixTimelineDiffProcessor(timelineItems) + processor.postDiffs(listOf(TimelineDiff.PopBack)) + assertThat(timelineItems.value.count()).isEqualTo(1) + assertThat(timelineItems.value).containsExactly( + anEvent, + ) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `PopFront removes an entry at the start of the list`() = runTest { + timelineItems.value = listOf(anEvent, anEvent2) + val processor = createMatrixTimelineDiffProcessor(timelineItems) + processor.postDiffs(listOf(TimelineDiff.PopFront)) + assertThat(timelineItems.value.count()).isEqualTo(1) + assertThat(timelineItems.value).containsExactly( + anEvent2, + ) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Clear removes all the entries`() = runTest { + timelineItems.value = listOf(anEvent, anEvent2) + val processor = createMatrixTimelineDiffProcessor(timelineItems) + processor.postDiffs(listOf(TimelineDiff.Clear)) + assertThat(timelineItems.value).isEmpty() + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Truncate removes all entries after the provided length`() = runTest { + timelineItems.value = listOf(anEvent, MatrixTimelineItem.Other, anEvent2) + val processor = createMatrixTimelineDiffProcessor(timelineItems) + processor.postDiffs(listOf(TimelineDiff.Truncate(1u))) + assertThat(timelineItems.value.count()).isEqualTo(1) + assertThat(timelineItems.value).containsExactly( + anEvent, + ) + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `Reset removes all entries and add the provided ones`() = runTest { + timelineItems.value = listOf(anEvent, MatrixTimelineItem.Other, anEvent2) + val processor = createMatrixTimelineDiffProcessor(timelineItems) + processor.postDiffs(listOf(TimelineDiff.Reset(listOf(FakeFfiTimelineItem())))) + assertThat(timelineItems.value.count()).isEqualTo(1) + assertThat(timelineItems.value).containsExactly( + MatrixTimelineItem.Other, + ) + } +} + +internal fun TestScope.createMatrixTimelineDiffProcessor( + timelineItems: MutableSharedFlow> = MutableSharedFlow(), + membershipChangeEventReceivedFlow: MutableSharedFlow = MutableSharedFlow(), + syncedEventReceivedFlow: MutableSharedFlow = MutableSharedFlow(), + ): MatrixTimelineDiffProcessor { + val timelineEventContentMapper = TimelineEventContentMapper() + val timelineItemFactory = MatrixTimelineItemMapper( + fetchDetailsForEvent = { _ -> Result.success(Unit) }, + coroutineScope = this, + virtualTimelineItemMapper = VirtualTimelineItemMapper(), + eventTimelineItemMapper = EventTimelineItemMapper( + contentMapper = timelineEventContentMapper + ) + ) + return MatrixTimelineDiffProcessor( + timelineItems = timelineItems, + membershipChangeEventReceivedFlow = membershipChangeEventReceivedFlow, + syncedEventReceivedFlow = syncedEventReceivedFlow, + timelineItemMapper = timelineItemFactory, + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/ReceiptTypeMapperKtTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/ReceiptTypeMapperKtTest.kt new file mode 100644 index 0000000..792a52b --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/ReceiptTypeMapperKtTest.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +package io.element.android.libraries.matrix.impl.timeline + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.timeline.ReceiptType +import org.junit.Test +import org.matrix.rustcomponents.sdk.ReceiptType as RustReceiptType + +class ReceiptTypeMapperKtTest { + @Test + fun toRustReceiptType() { + assertThat(ReceiptType.READ.toRustReceiptType()).isEqualTo(RustReceiptType.READ) + assertThat(ReceiptType.READ_PRIVATE.toRustReceiptType()).isEqualTo(RustReceiptType.READ_PRIVATE) + assertThat(ReceiptType.FULLY_READ.toRustReceiptType()).isEqualTo(RustReceiptType.FULLY_READ) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt new file mode 100644 index 0000000..1dde045 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.matrix.impl.timeline + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListService +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTimeline +import io.element.android.libraries.matrix.impl.room.RoomContentForwarder +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.services.toolbox.api.systemclock.SystemClock +import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test +import org.matrix.rustcomponents.sdk.TimelineDiff +import uniffi.matrix_sdk.RoomPaginationStatus +import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline + +@Ignore("JNA direct mapping has broken unit tests with FFI fakes") +class RustTimelineTest { + @Test + fun `ensure that the timeline emits new loading item when pagination does not bring new events`() = runTest { + val inner = FakeFfiTimeline() + val systemClock = FakeSystemClock() + val sut = createRustTimeline( + inner = inner, + systemClock = systemClock, + ) + sut.timelineItems.test { + // Give time for the listener to be set + runCurrent() + inner.emitDiff( + listOf( + TimelineDiff.Reset(emptyList()) + ) + ) + with(awaitItem()) { + assertThat(size).isEqualTo(2) + // The loading + assertThat((get(0) as MatrixTimelineItem.Virtual).virtual).isEqualTo( + VirtualTimelineItem.LoadingIndicator( + direction = Timeline.PaginationDirection.BACKWARDS, + timestamp = A_FAKE_TIMESTAMP, + ) + ) + // Typing notification + assertThat((get(1) as MatrixTimelineItem.Virtual).virtual).isEqualTo(VirtualTimelineItem.TypingNotification) + } + systemClock.epochMillisResult = A_FAKE_TIMESTAMP + 1 + // Start pagination + sut.paginate(Timeline.PaginationDirection.BACKWARDS) + // Simulate SDK starting pagination + inner.emitPaginationStatus(RoomPaginationStatus.Paginating) + // No new events received + // Simulate SDK stopping pagination, more event to load + inner.emitPaginationStatus(RoomPaginationStatus.Idle(hitTimelineStart = false)) + // expect an item to be emitted, with an updated timestamp + with(awaitItem()) { + assertThat(size).isEqualTo(2) + // The loading + assertThat((get(0) as MatrixTimelineItem.Virtual).virtual).isEqualTo( + VirtualTimelineItem.LoadingIndicator( + direction = Timeline.PaginationDirection.BACKWARDS, + timestamp = A_FAKE_TIMESTAMP + 1, + ) + ) + // Typing notification + assertThat((get(1) as MatrixTimelineItem.Virtual).virtual).isEqualTo(VirtualTimelineItem.TypingNotification) + } + } + } +} + +private fun TestScope.createRustTimeline( + inner: InnerTimeline, + mode: Timeline.Mode = Timeline.Mode.Live, + systemClock: SystemClock = FakeSystemClock(), + joinedRoom: JoinedRoom = FakeJoinedRoom().apply { givenRoomInfo(aRoomInfo()) }, + coroutineScope: CoroutineScope = backgroundScope, + dispatcher: CoroutineDispatcher = testCoroutineDispatchers().io, + roomContentForwarder: RoomContentForwarder = RoomContentForwarder(FakeFfiRoomListService()), +): RustTimeline { + return RustTimeline( + inner = inner, + mode = mode, + systemClock = systemClock, + joinedRoom = joinedRoom, + coroutineScope = coroutineScope, + dispatcher = dispatcher, + roomContentForwarder = roomContentForwarder, + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriberTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriberTest.kt new file mode 100644 index 0000000..4accf26 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriberTest.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustEventTimelineItem +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTimeline +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTimelineItem +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test +import org.matrix.rustcomponents.sdk.Timeline +import org.matrix.rustcomponents.sdk.TimelineDiff +import uniffi.matrix_sdk_ui.EventItemOrigin + +@OptIn(ExperimentalCoroutinesApi::class) +class TimelineItemsSubscriberTest { + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `when timeline emits an empty list of items, the flow must emits an empty list`() = runTest { + val timelineItems: MutableSharedFlow> = + MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE) + val timeline = FakeFfiTimeline() + val diffProcessor = createMatrixTimelineDiffProcessor( + timelineItems = timelineItems, + ) + val timelineItemsSubscriber = createTimelineItemsSubscriber( + timeline = timeline, + timelineDiffProcessor = diffProcessor, + ) + timelineItems.test { + timelineItemsSubscriber.subscribeIfNeeded() + // Wait for the listener to be set. + runCurrent() + timeline.emitDiff(listOf(TimelineDiff.Reset(emptyList()))) + val final = awaitItem() + assertThat(final).isEmpty() + timelineItemsSubscriber.unsubscribeIfNeeded() + } + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `when timeline emits a non empty list of items, the flow must emits a non empty list`() = runTest { + val timelineItems: MutableSharedFlow> = + MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE) + val timeline = FakeFfiTimeline() + val diffProcessor = createMatrixTimelineDiffProcessor( + timelineItems = timelineItems, + ) + val timelineItemsSubscriber = createTimelineItemsSubscriber( + timeline = timeline, + timelineDiffProcessor = diffProcessor, + ) + timelineItems.test { + timelineItemsSubscriber.subscribeIfNeeded() + // Wait for the listener to be set. + runCurrent() + timeline.emitDiff(listOf(TimelineDiff.Reset(listOf(FakeFfiTimelineItem())))) + val final = awaitItem() + assertThat(final).isNotEmpty() + timelineItemsSubscriber.unsubscribeIfNeeded() + } + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `when timeline emits an item with SYNC origin`() = runTest { + val timelineItems: MutableSharedFlow> = + MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE) + val timeline = FakeFfiTimeline() + val diffProcessor = createMatrixTimelineDiffProcessor( + timelineItems = timelineItems, + ) + val timelineItemsSubscriber = createTimelineItemsSubscriber( + timeline = timeline, + timelineDiffProcessor = diffProcessor, + ) + timelineItems.test { + timelineItemsSubscriber.subscribeIfNeeded() + // Wait for the listener to be set. + runCurrent() + timeline.emitDiff( + listOf( + TimelineDiff.Reset( + listOf(FakeFfiTimelineItem( + asEventResult = aRustEventTimelineItem(origin = EventItemOrigin.SYNC), + )) + ) + ) + ) + val final = awaitItem() + assertThat(final).isNotEmpty() + timelineItemsSubscriber.unsubscribeIfNeeded() + } + } + + @Ignore("JNA direct mapping has broken unit tests with FFI fakes") + @Test + fun `multiple subscriptions does not have side effect`() = runTest { + val timelineItemsSubscriber = createTimelineItemsSubscriber() + timelineItemsSubscriber.subscribeIfNeeded() + timelineItemsSubscriber.subscribeIfNeeded() + timelineItemsSubscriber.unsubscribeIfNeeded() + timelineItemsSubscriber.unsubscribeIfNeeded() + } +} + +private fun TestScope.createTimelineItemsSubscriber( + timeline: Timeline = FakeFfiTimeline(), + timelineDiffProcessor: MatrixTimelineDiffProcessor = createMatrixTimelineDiffProcessor(), +): TimelineItemsSubscriber { + return TimelineItemsSubscriber( + timelineCoroutineScope = backgroundScope, + dispatcher = StandardTestDispatcher(testScheduler), + timeline = timeline, + timelineDiffProcessor = timelineDiffProcessor, + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/Fixtures.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/Fixtures.kt new file mode 100644 index 0000000..50f8637 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/Fixtures.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline.postprocessor + +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +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.StateContent +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem +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.timeline.aMessageContent +import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem +import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent + +internal val timelineStartEvent = MatrixTimelineItem.Virtual( + uniqueId = UniqueId("timeline_start"), + virtual = VirtualTimelineItem.RoomBeginning, +) +internal val roomCreateEvent = MatrixTimelineItem.Event( + uniqueId = UniqueId("m.room.create"), + event = anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate)) +) +internal val roomCreatorJoinEvent = MatrixTimelineItem.Event( + uniqueId = UniqueId("m.room.member"), + event = anEventTimelineItem(content = aRoomMembershipContent(userId = A_USER_ID, change = MembershipChange.JOINED)) +) +internal val otherMemberJoinEvent = MatrixTimelineItem.Event( + uniqueId = UniqueId("m.room.member_other"), + event = anEventTimelineItem(content = aRoomMembershipContent(userId = A_USER_ID_2, change = MembershipChange.JOINED)) +) +internal val messageEvent = MatrixTimelineItem.Event( + uniqueId = UniqueId("m.room.message"), + event = anEventTimelineItem(content = aMessageContent("hi")) +) +internal val messageEvent2 = MatrixTimelineItem.Event( + uniqueId = UniqueId("m.room.message2"), + event = anEventTimelineItem(content = aMessageContent("hello")) +) +internal val dayEvent = MatrixTimelineItem.Virtual( + uniqueId = UniqueId("day"), + virtual = VirtualTimelineItem.DayDivider(0), +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LastForwardIndicatorsPostProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LastForwardIndicatorsPostProcessorTest.kt new file mode 100644 index 0000000..3d68210 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LastForwardIndicatorsPostProcessorTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline.postprocessor + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import org.junit.Test + +class LastForwardIndicatorsPostProcessorTest { + @Test + fun `LastForwardIndicatorsPostProcessor does not alter the items with mode not FOCUSED_ON_EVENT`() { + val sut = LastForwardIndicatorsPostProcessor(Timeline.Mode.Live) + val result = sut.process(listOf(messageEvent)) + assertThat(result).containsExactly(messageEvent) + } + + @Test + fun `LastForwardIndicatorsPostProcessor add virtual items`() { + val sut = LastForwardIndicatorsPostProcessor(Timeline.Mode.FocusedOnEvent(AN_EVENT_ID)) + val result = sut.process(listOf(messageEvent)) + assertThat(result).containsExactly( + messageEvent, + MatrixTimelineItem.Virtual( + uniqueId = UniqueId("last_forward_indicator_${messageEvent.uniqueId}"), + virtual = VirtualTimelineItem.LastForwardIndicator + ) + ) + } + + @Test + fun `LastForwardIndicatorsPostProcessor add virtual items on empty list`() { + val sut = LastForwardIndicatorsPostProcessor(Timeline.Mode.FocusedOnEvent(AN_EVENT_ID)) + val result = sut.process(listOf()) + assertThat(result).containsExactly( + MatrixTimelineItem.Virtual( + uniqueId = UniqueId("last_forward_indicator_fake_id"), + virtual = VirtualTimelineItem.LastForwardIndicator + ) + ) + } + + @Test + fun `LastForwardIndicatorsPostProcessor add virtual items but does not alter the list if called a second time`() { + val sut = LastForwardIndicatorsPostProcessor(Timeline.Mode.FocusedOnEvent(AN_EVENT_ID)) + // Process a first time + sut.process(listOf(messageEvent)) + // Process a second time with the same Event + val result = sut.process(listOf(messageEvent)) + assertThat(result).containsExactly( + messageEvent, + MatrixTimelineItem.Virtual( + uniqueId = UniqueId("last_forward_indicator_${messageEvent.uniqueId}"), + virtual = VirtualTimelineItem.LastForwardIndicator + ) + ) + } + + @Test + fun `LastForwardIndicatorsPostProcessor add virtual items each time it is called with new Events`() { + val sut = LastForwardIndicatorsPostProcessor(Timeline.Mode.FocusedOnEvent(AN_EVENT_ID)) + // Process a first time + sut.process(listOf(dayEvent, messageEvent)) + // Process a second time with the same Event + val result = sut.process(listOf(dayEvent, messageEvent, messageEvent2)) + assertThat(result).containsExactly( + dayEvent, + messageEvent, + MatrixTimelineItem.Virtual( + uniqueId = UniqueId("last_forward_indicator_${messageEvent.uniqueId}"), + virtual = VirtualTimelineItem.LastForwardIndicator + ), + messageEvent2, + MatrixTimelineItem.Virtual( + uniqueId = UniqueId("last_forward_indicator_${messageEvent2.uniqueId}"), + virtual = VirtualTimelineItem.LastForwardIndicator + ) + ) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LoadingIndicatorsPostProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LoadingIndicatorsPostProcessorTest.kt new file mode 100644 index 0000000..371d936 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/LoadingIndicatorsPostProcessorTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline.postprocessor + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import org.junit.Test + +class LoadingIndicatorsPostProcessorTest { + @Test + fun `LoadingIndicatorsPostProcessor adds Loading indicator at the top of the list if hasMoreToLoadBackward is true`() { + val clock = FakeSystemClock() + val sut = LoadingIndicatorsPostProcessor(clock) + val result = sut.process( + items = listOf(messageEvent, messageEvent2), + hasMoreToLoadBackward = true, + hasMoreToLoadForward = false, + ) + assertThat(result).containsExactly( + MatrixTimelineItem.Virtual( + uniqueId = UniqueId("BackwardLoadingIndicator"), + virtual = VirtualTimelineItem.LoadingIndicator( + direction = Timeline.PaginationDirection.BACKWARDS, + timestamp = clock.epochMillis() + ) + ), + messageEvent, + messageEvent2, + ) + } + + @Test + fun `LoadingIndicatorsPostProcessor adds Loading indicator at the bottom of the list if hasMoreToLoadForward is true`() { + val clock = FakeSystemClock() + val sut = LoadingIndicatorsPostProcessor(clock) + val result = sut.process( + items = listOf(messageEvent, messageEvent2), + hasMoreToLoadBackward = false, + hasMoreToLoadForward = true, + ) + assertThat(result).containsExactly( + messageEvent, + messageEvent2, + MatrixTimelineItem.Virtual( + uniqueId = UniqueId("ForwardLoadingIndicator"), + virtual = VirtualTimelineItem.LoadingIndicator( + direction = Timeline.PaginationDirection.FORWARDS, + timestamp = clock.epochMillis() + ) + ), + ) + } + + @Test + fun `LoadingIndicatorsPostProcessor adds Loading indicator at the bottom and at the top of the list`() { + val clock = FakeSystemClock() + val sut = LoadingIndicatorsPostProcessor(clock) + val result = sut.process( + items = listOf(messageEvent, messageEvent2), + hasMoreToLoadBackward = true, + hasMoreToLoadForward = true, + ) + assertThat(result).containsExactly( + MatrixTimelineItem.Virtual( + uniqueId = UniqueId("BackwardLoadingIndicator"), + virtual = VirtualTimelineItem.LoadingIndicator( + direction = Timeline.PaginationDirection.BACKWARDS, + timestamp = clock.epochMillis() + ) + ), + messageEvent, + messageEvent2, + MatrixTimelineItem.Virtual( + uniqueId = UniqueId("ForwardLoadingIndicator"), + virtual = VirtualTimelineItem.LoadingIndicator( + direction = Timeline.PaginationDirection.FORWARDS, + timestamp = clock.epochMillis() + ) + ), + ) + } + + @Test + fun `LoadingIndicatorsPostProcessor only adds 1 Loading indicator if there is no items in the list`() { + val clock = FakeSystemClock() + val sut = LoadingIndicatorsPostProcessor(clock) + val result = sut.process( + items = listOf(), + hasMoreToLoadBackward = true, + hasMoreToLoadForward = true, + ) + assertThat(result).containsExactly( + MatrixTimelineItem.Virtual( + uniqueId = UniqueId("BackwardLoadingIndicator"), + virtual = VirtualTimelineItem.LoadingIndicator( + direction = Timeline.PaginationDirection.BACKWARDS, + timestamp = clock.epochMillis() + ) + ), + ) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt new file mode 100644 index 0000000..dbeba39 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.timeline.postprocessor + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.test.A_USER_ID +import org.junit.Test + +class RoomBeginningPostProcessorTest { + @Test + fun `processor returns empty list when empty list is provided`() { + val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) + val processedItems = processor.process( + items = emptyList(), + isDm = true, + roomCreator = A_USER_ID, + hasMoreToLoadBackwards = false, + ) + assertThat(processedItems).isEmpty() + } + + @Test + fun `processor returns the provided list when it only contains a message`() { + val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) + val processedItems = processor.process( + items = listOf(messageEvent), + isDm = true, + roomCreator = A_USER_ID, + hasMoreToLoadBackwards = false, + ) + assertThat(processedItems).isEqualTo(listOf(messageEvent)) + } + + @Test + fun `processor returns the provided list when it only contains a message and the roomCreator is not provided`() { + val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) + val processedItems = processor.process( + items = listOf(messageEvent), + isDm = true, + roomCreator = null, + hasMoreToLoadBackwards = false, + ) + assertThat(processedItems).isEqualTo(listOf(messageEvent)) + } + + @Test + fun `processor removes room creation event and self-join event from DM timeline`() { + val timelineItems = listOf( + timelineStartEvent, + roomCreateEvent, + roomCreatorJoinEvent, + ) + val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) + val processedItems = processor.process( + items = timelineItems, + isDm = true, + roomCreator = A_USER_ID, + hasMoreToLoadBackwards = false, + ) + assertThat(processedItems).containsExactly(timelineStartEvent) + } + + @Test + fun `processor does not remove anything with PINNED_EVENTS mode`() { + val timelineItems = listOf( + roomCreateEvent, + roomCreatorJoinEvent, + ) + val processor = RoomBeginningPostProcessor(Timeline.Mode.PinnedEvents) + val processedItems = processor.process( + items = timelineItems, + isDm = true, + roomCreator = A_USER_ID, + hasMoreToLoadBackwards = false, + ) + assertThat(processedItems).isEqualTo(timelineItems) + } + + @Test + fun `processor removes room creation event and self-join event from DM timeline even if they're not the first items`() { + val timelineItems = listOf( + otherMemberJoinEvent, + roomCreateEvent, + messageEvent, + roomCreatorJoinEvent, + ) + val expected = listOf( + otherMemberJoinEvent, + messageEvent, + ) + val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) + val processedItems = processor.process(timelineItems, isDm = true, roomCreator = A_USER_ID, hasMoreToLoadBackwards = false) + assertThat(processedItems).isEqualTo(expected) + } + + @Test + fun `processor removes items event it's not at the start of the timeline`() { + val timelineItems = listOf( + roomCreateEvent, + roomCreatorJoinEvent, + ) + val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) + val processedItems = processor.process(timelineItems, isDm = true, roomCreator = A_USER_ID, hasMoreToLoadBackwards = true) + assertThat(processedItems).isEmpty() + } + + @Test + fun `processor removes the first member join event if it matches the roomCreator parameter`() { + val timelineItems = listOf( + roomCreatorJoinEvent, + ) + val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) + val processedItems = processor.process(timelineItems, isDm = true, roomCreator = A_USER_ID, hasMoreToLoadBackwards = true) + assertThat(processedItems).isEmpty() + } + + @Test + fun `processor won't remove the first member join event if it's not from the room creator`() { + val timelineItems = listOf( + roomCreateEvent, + otherMemberJoinEvent, + ) + val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) + val processedItems = processor.process(timelineItems, isDm = true, roomCreator = A_USER_ID, hasMoreToLoadBackwards = true) + assertThat(processedItems).isEqualTo(listOf(otherMemberJoinEvent)) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserSearchResultMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserSearchResultMapperTest.kt new file mode 100644 index 0000000..3926b93 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserSearchResultMapperTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.usersearch + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSearchUsersResults +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile +import io.element.android.libraries.matrix.test.A_USER_ID +import kotlinx.collections.immutable.toImmutableList +import org.junit.Test + +class UserSearchResultMapperTest { + @Test + fun `map limited list`() { + assertThat( + UserSearchResultMapper.map( + aRustSearchUsersResults( + results = listOf(aRustUserProfile(A_USER_ID.value, "displayName", "avatarUrl")), + limited = true, + ) + ) + ) + .isEqualTo( + MatrixSearchUserResults( + results = listOf(MatrixUser(A_USER_ID, "displayName", "avatarUrl")).toImmutableList(), + limited = true, + ) + ) + } + + @Test + fun `map not limited list`() { + assertThat( + UserSearchResultMapper.map( + aRustSearchUsersResults( + results = listOf(aRustUserProfile(A_USER_ID.value, "displayName", "avatarUrl")), + limited = false, + ) + ) + ) + .isEqualTo( + MatrixSearchUserResults( + results = listOf(MatrixUser(A_USER_ID, "displayName", "avatarUrl")).toImmutableList(), + limited = false, + ) + ) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/util/SessionPathsProviderTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/util/SessionPathsProviderTest.kt new file mode 100644 index 0000000..8edc95c --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/util/SessionPathsProviderTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.util + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SessionPathsProviderTest { + @Test + fun `if session is not found, provides returns null`() = runTest { + val sut = SessionPathsProvider(InMemorySessionStore()) + val result = sut.provides(A_SESSION_ID) + assertThat(result).isNull() + } + + @Test + fun `if session is found, provides returns the data`() = runTest { + val store = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionPath = "/a/path/to/a/session", + cachePath = "/a/path/to/a/cache", + ) + ) + ) + val sut = SessionPathsProvider(store) + val result = sut.provides(A_SESSION_ID)!! + assertThat(result.fileDirectory.absolutePath).isEqualTo("/a/path/to/a/session") + assertThat(result.cacheDirectory.absolutePath).isEqualTo("/a/path/to/a/cache") + } +} diff --git a/libraries/matrix/test/build.gradle.kts b/libraries/matrix/test/build.gradle.kts new file mode 100644 index 0000000..ccb1a37 --- /dev/null +++ b/libraries/matrix/test/build.gradle.kts @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.libraries.matrix.impl) + implementation(projects.services.analytics.api) + implementation(projects.tests.testutils) + implementation(libs.kotlinx.collections.immutable) +} diff --git a/libraries/matrix/test/src/main/AndroidManifest.xml b/libraries/matrix/test/src/main/AndroidManifest.xml new file mode 100644 index 0000000..608f16c --- /dev/null +++ b/libraries/matrix/test/src/main/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + 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 0000000..940cce6 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -0,0 +1,354 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +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.encryption.EncryptionService +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.media.MediaPreviewService +import io.element.android.libraries.matrix.api.notification.NotificationService +import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService +import io.element.android.libraries.matrix.api.oidc.AccountManagementAction +import io.element.android.libraries.matrix.api.pusher.PushersService +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.NotJoinedRoom +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias +import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.spaces.SpaceService +import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion +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 io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader +import io.element.android.libraries.matrix.test.media.FakeMediaPreviewService +import io.element.android.libraries.matrix.test.notification.FakeNotificationService +import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.matrix.test.pushers.FakePushersService +import io.element.android.libraries.matrix.test.roomdirectory.FakeRoomDirectoryService +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.libraries.matrix.test.spaces.FakeSpaceService +import io.element.android.libraries.matrix.test.sync.FakeSyncService +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import java.util.Optional + +class FakeMatrixClient( + override val sessionId: SessionId = A_SESSION_ID, + override val deviceId: DeviceId = A_DEVICE_ID, + override val sessionCoroutineScope: CoroutineScope = TestScope(), + private val userDisplayName: String? = A_USER_NAME, + private val userAvatarUrl: String? = AN_AVATAR_URL, + override val roomListService: RoomListService = FakeRoomListService(), + override val spaceService: SpaceService = FakeSpaceService(), + override val matrixMediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(), + override val sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(), + override val pushersService: PushersService = FakePushersService(), + override val notificationService: NotificationService = FakeNotificationService(), + override val notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(), + override val syncService: SyncService = FakeSyncService(), + override val encryptionService: EncryptionService = FakeEncryptionService(), + override val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(), + override val mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(), + override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), + private val accountManagementUrlResult: (AccountManagementAction?) -> Result = { lambdaError() }, + private val resolveRoomAliasResult: (RoomAlias) -> Result> = { + Result.success( + Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList())) + ) + }, + private val getNotJoinedRoomResult: (RoomIdOrAlias, List) -> Result = { _, _ -> lambdaError() }, + private val clearCacheLambda: () -> Unit = { lambdaError() }, + private val userIdServerNameLambda: () -> String = { lambdaError() }, + private val getUrlLambda: (String) -> Result = { lambdaError() }, + private val canDeactivateAccountResult: () -> Boolean = { lambdaError() }, + private val deactivateAccountResult: (String, Boolean) -> Result = { _, _ -> lambdaError() }, + private val currentSlidingSyncVersionLambda: () -> Result = { lambdaError() }, + private val ignoreUserResult: (UserId) -> Result = { lambdaError() }, + private var unIgnoreUserResult: (UserId) -> Result = { Result.success(Unit) }, + private val canReportRoomLambda: () -> Boolean = { false }, + private val isLivekitRtcSupportedLambda: () -> Boolean = { false }, + override val ignoredUsersFlow: StateFlow> = MutableStateFlow(persistentListOf()), + private val getMaxUploadSizeResult: () -> Result = { lambdaError() }, + private val getJoinedRoomIdsResult: () -> Result> = { Result.success(emptySet()) }, + private val getRecentEmojisLambda: () -> Result> = { Result.success(emptyList()) }, + private val addRecentEmojiLambda: (String) -> Result = { Result.success(Unit) }, + private val markRoomAsFullyReadResult: (RoomId, EventId) -> Result = { _, _ -> lambdaError() }, +) : MatrixClient { + var setDisplayNameCalled: Boolean = false + private set + var uploadAvatarCalled: Boolean = false + private set + var removeAvatarCalled: Boolean = false + private set + + private val _userProfile: MutableStateFlow = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl)) + override val userProfile: StateFlow = _userProfile + + private var createRoomResult: Result = Result.success(A_ROOM_ID) + private var createDmResult: Result = Result.success(A_ROOM_ID) + private var findDmResult: Result = Result.success(A_ROOM_ID) + private val getRoomResults = mutableMapOf() + private val searchUserResults = mutableMapOf>() + private val getProfileResults = mutableMapOf>() + private var uploadMediaResult: Result = Result.success(AN_AVATAR_URL) + private var setDisplayNameResult: Result = Result.success(Unit) + private var uploadAvatarResult: Result = Result.success(Unit) + private var removeAvatarResult: Result = Result.success(Unit) + var joinRoomLambda: (RoomId) -> Result = { + Result.success(null) + } + var joinRoomByIdOrAliasLambda: (RoomIdOrAlias, List) -> Result = { _, _ -> + Result.success(null) + } + var knockRoomLambda: (RoomIdOrAlias, String, List) -> Result = { _, _, _ -> + Result.success(null) + } + var getRoomInfoFlowLambda = { _: RoomId -> + flowOf>(Optional.empty()) + } + var logoutLambda: (Boolean, Boolean) -> Unit = { _, _ -> } + + override suspend fun getRoom(roomId: RoomId): BaseRoom? { + return getRoomResults[roomId] + } + + override suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom? { + return getRoomResults[roomId] as? JoinedRoom + } + + override suspend fun findDM(userId: UserId): Result { + return findDmResult + } + + override suspend fun getJoinedRoomIds(): Result> { + return getJoinedRoomIdsResult() + } + + override suspend fun ignoreUser(userId: UserId): Result = simulateLongTask { + return ignoreUserResult(userId) + } + + override suspend fun unignoreUser(userId: UserId): Result = simulateLongTask { + return unIgnoreUserResult(userId) + } + + override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result = simulateLongTask { + return createRoomResult + } + + override suspend fun createDM(userId: UserId): Result = simulateLongTask { + return createDmResult + } + + override suspend fun getProfile(userId: UserId): Result { + return getProfileResults[userId] ?: Result.failure(IllegalStateException("No profile found for $userId")) + } + + override suspend fun searchUsers(searchTerm: String, limit: Long): Result { + return searchUserResults[searchTerm] ?: Result.failure(IllegalStateException("No response defined for $searchTerm")) + } + + override suspend fun getCacheSize(): Long { + return 0 + } + + override suspend fun clearCache() = simulateLongTask { + clearCacheLambda() + } + + override suspend fun logout(userInitiated: Boolean, ignoreSdkError: Boolean) = simulateLongTask { + logoutLambda(ignoreSdkError, userInitiated) + } + + override fun canDeactivateAccount() = canDeactivateAccountResult() + + override suspend fun deactivateAccount(password: String, eraseData: Boolean): Result = simulateLongTask { + deactivateAccountResult(password, eraseData) + } + + override suspend fun getUserProfile(): Result = simulateLongTask { + val result = getProfileResults[sessionId]?.getOrNull() ?: MatrixUser(sessionId, userDisplayName, userAvatarUrl) + _userProfile.tryEmit(result) + return Result.success(result) + } + + override suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result = simulateLongTask { + accountManagementUrlResult(action) + } + + override suspend fun uploadMedia( + mimeType: String, + data: ByteArray, + ): Result { + return uploadMediaResult + } + + override suspend fun setDisplayName(displayName: String): Result = simulateLongTask { + setDisplayNameCalled = true + return setDisplayNameResult + } + + override suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result = simulateLongTask { + uploadAvatarCalled = true + return uploadAvatarResult + } + + override suspend fun removeAvatar(): Result = simulateLongTask { + removeAvatarCalled = true + return removeAvatarResult + } + + override suspend fun joinRoom(roomId: RoomId): Result = joinRoomLambda(roomId) + + override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result { + return joinRoomByIdOrAliasLambda(roomIdOrAlias, serverNames) + } + + override suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List): Result { + return knockRoomLambda(roomIdOrAlias, message, serverNames) + } + + // Mocks + + fun givenCreateRoomResult(result: Result) { + createRoomResult = result + } + + fun givenCreateDmResult(result: Result) { + createDmResult = result + } + + fun givenFindDmResult(result: Result) { + findDmResult = result + } + + fun givenGetRoomResult(roomId: RoomId, result: BaseRoom?) { + if (result == null) { + getRoomResults.remove(roomId) + } else { + getRoomResults[roomId] = result + } + } + + fun givenSearchUsersResult(searchTerm: String, result: Result) { + searchUserResults[searchTerm] = result + } + + fun givenGetProfileResult(userId: UserId, result: Result) { + getProfileResults[userId] = result + } + + fun givenUploadMediaResult(result: Result) { + uploadMediaResult = result + } + + fun givenSetDisplayNameResult(result: Result) { + setDisplayNameResult = result + } + + fun givenUploadAvatarResult(result: Result) { + uploadAvatarResult = result + } + + fun givenRemoveAvatarResult(result: Result) { + removeAvatarResult = result + } + + private val visitedRoomsId: MutableList = mutableListOf() + + override suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result { + visitedRoomsId.removeAll { it == roomId } + visitedRoomsId.add(0, roomId) + return Result.success(Unit) + } + + override suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result> = simulateLongTask { + resolveRoomAliasResult(roomAlias) + } + + override suspend fun getRoomPreview(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result = simulateLongTask { + getNotJoinedRoomResult(roomIdOrAlias, serverNames) + } + + override suspend fun getRecentlyVisitedRooms(): Result> { + return Result.success(visitedRoomsId) + } + + override fun getRoomInfoFlow(roomId: RoomId) = getRoomInfoFlowLambda(roomId) + + var setAllSendQueuesEnabledLambda = lambdaRecorder(ensureNeverCalled = true) { _: Boolean -> + // no-op + } + + override suspend fun setAllSendQueuesEnabled(enabled: Boolean) = setAllSendQueuesEnabledLambda(enabled) + + var sendQueueDisabledFlow = emptyFlow() + override fun sendQueueDisabledFlow(): Flow = sendQueueDisabledFlow + + override fun userIdServerName(): String { + return userIdServerNameLambda() + } + + override suspend fun getUrl(url: String): Result { + return getUrlLambda(url) + } + + override suspend fun currentSlidingSyncVersion(): Result { + return currentSlidingSyncVersionLambda() + } + + override suspend fun canReportRoom(): Boolean { + return canReportRoomLambda() + } + + override suspend fun isLivekitRtcSupported(): Boolean { + return isLivekitRtcSupportedLambda() + } + + override suspend fun getMaxFileUploadSize(): Result { + return getMaxUploadSizeResult() + } + + override suspend fun addRecentEmoji(emoji: String): Result { + return addRecentEmojiLambda(emoji) + } + + override suspend fun getRecentEmojis(): Result> { + return getRecentEmojisLambda() + } + + override suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result { + return markRoomAsFullyReadResult(roomId, eventId) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.kt new file mode 100644 index 0000000..228ae94 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId + +class FakeMatrixClientProvider( + var getClient: (SessionId) -> Result = { Result.success(FakeMatrixClient()) } +) : MatrixClientProvider { + override suspend fun getOrRestore(sessionId: SessionId): Result = getClient(sessionId) + + override fun getOrNull(sessionId: SessionId): MatrixClient? = getClient(sessionId).getOrNull() +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeSdkMetadata.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeSdkMetadata.kt new file mode 100644 index 0000000..d10e967 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeSdkMetadata.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test + +import io.element.android.libraries.matrix.api.SdkMetadata + +class FakeSdkMetadata(override val sdkGitSha: String) : SdkMetadata 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 0000000..dbbf2f2 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test + +import androidx.annotation.ColorInt +import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomAlias +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.UniqueId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.RoomNotificationMode + +const val A_USER_NAME = "alice" +const val A_USER_NAME_2 = "Bob" +const val A_PASSWORD = "password" +const val A_PASSPHRASE = "passphrase" +const val A_SECRET = "secret" +const val AN_APPLICATION_NAME = "AppName" +const val AN_APPLICATION_NAME_DESKTOP = "AppNameDesktop" + +val A_USER_ID = UserId("@alice:server.org") +val A_USER_ID_2 = UserId("@bob:server.org") +val A_USER_ID_3 = UserId("@carol:server.org") +val A_USER_ID_4 = UserId("@david:server.org") +val A_USER_ID_5 = UserId("@eve:server.org") +val A_USER_ID_6 = UserId("@justin:server.org") +val A_USER_ID_7 = UserId("@mallory:server.org") +val A_USER_ID_8 = UserId("@susie:server.org") +val A_USER_ID_9 = UserId("@victor:server.org") +val A_USER_ID_10 = UserId("@walter: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_SPACE_ID_2 = SpaceId("!aSpaceId2:domain") +val A_ROOM_ID = RoomId("!aRoomId:domain") +val A_ROOM_ID_2 = RoomId("!aRoomId2:domain") +val A_ROOM_ID_3 = RoomId("!aRoomId3:domain") +val A_THREAD_ID = ThreadId("\$aThreadId") +val A_THREAD_ID_2 = ThreadId("\$aThreadId2") +val AN_EVENT_ID = EventId("\$anEventId") +val AN_EVENT_ID_2 = EventId("\$anEventId2") +val AN_EVENT_ID_3 = EventId("\$anEventId3") +val A_ROOM_ALIAS = RoomAlias("#alias1:domain") +val A_TRANSACTION_ID = TransactionId("aTransactionId") +val A_DEVICE_ID = DeviceId("ILAKNDNASDLK") + +val A_UNIQUE_ID = UniqueId("aUniqueId") +val A_UNIQUE_ID_2 = UniqueId("aUniqueId2") + +const val A_ROOM_NAME = "A room name" +const val A_ROOM_TOPIC = "A room topic" +const val A_ROOM_RAW_NAME = "A room raw 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_CAPTION = "A media caption" +const val A_REASON = "A reason" + +const val A_SPACE_NAME = "A space name" + +const val A_REDACTION_REASON = "A redaction reason" + +const val A_HOMESERVER_URL = "matrix.org" +const val A_HOMESERVER_URL_2 = "matrix-client.org" + +const val AN_ACCOUNT_PROVIDER_URL = "https://account.provider.org" +const val AN_ACCOUNT_PROVIDER = "matrix.org" +const val AN_ACCOUNT_PROVIDER_2 = "element.io" +const val AN_ACCOUNT_PROVIDER_3 = "other.io" + +val A_ROOM_NOTIFICATION_MODE = RoomNotificationMode.MUTE + +const val AN_AVATAR_URL = "mxc://data" + +const val A_FAILURE_REASON = "There has been a failure" + +@Suppress("unused") +val A_THROWABLE = Throwable(A_FAILURE_REASON) +val AN_EXCEPTION = Exception(A_FAILURE_REASON) + +const val A_RECOVERY_KEY = "1234 5678" + +val A_SERVER_LIST = listOf("server1", "server2") + +const val A_TIMESTAMP = 567L +const val A_FORMATTED_DATE = "April 6, 1980 at 6:35 PM" + +const val A_LOGIN_HINT = "mxid:@alice:example.org" + +@ColorInt +const val A_COLOR_INT: Int = 0xFFFF0000.toInt() diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeHomeServerLoginCompatibilityChecker.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeHomeServerLoginCompatibilityChecker.kt new file mode 100644 index 0000000..83c83e3 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeHomeServerLoginCompatibilityChecker.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.auth + +import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker + +class FakeHomeServerLoginCompatibilityChecker( + private val checkResult: (String) -> Result, +) : HomeServerLoginCompatibilityChecker { + override suspend fun check(url: String): Result { + return checkResult(url) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt new file mode 100644 index 0000000..c4acccb --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.auth.OidcPrompt +import io.element.android.libraries.matrix.api.auth.external.ExternalSession +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.simulateLongTask + +val A_OIDC_DATA = OidcDetails(url = "a-url") + +class FakeMatrixAuthenticationService( + var matrixClientResult: ((SessionId) -> Result)? = null, + var loginWithQrCodeResult: (qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit) -> Result = + lambdaRecorder Unit, Result> { _, _ -> Result.success(A_SESSION_ID) }, + private val importCreatedSessionLambda: (ExternalSession) -> Result = { lambdaError() }, + private val setHomeserverResult: (String) -> Result = { lambdaError() }, +) : MatrixAuthenticationService { + private var oidcError: Throwable? = null + private var oidcCancelError: Throwable? = null + private var loginError: Throwable? = null + private var matrixClient: MatrixClient? = null + private var onAuthenticationListener: ((MatrixClient) -> Unit)? = null + + override suspend fun restoreSession(sessionId: SessionId): Result { + matrixClientResult?.let { + return it.invoke(sessionId) + } + return if (matrixClient != null) { + onAuthenticationListener?.invoke(matrixClient!!) + Result.success(matrixClient!!) + } else { + Result.failure(IllegalStateException()) + } + } + + override suspend fun setHomeserver(homeserver: String): Result = simulateLongTask { + setHomeserverResult(homeserver) + } + + override suspend fun login(username: String, password: String): Result = simulateLongTask { + loginError?.let { Result.failure(it) } ?: run { + onAuthenticationListener?.invoke(matrixClient ?: FakeMatrixClient()) + Result.success(A_USER_ID) + } + } + + override suspend fun importCreatedSession(externalSession: ExternalSession): Result = simulateLongTask { + return importCreatedSessionLambda(externalSession) + } + + override suspend fun getOidcUrl( + prompt: OidcPrompt, + loginHint: String?, + ): Result = simulateLongTask { + oidcError?.let { Result.failure(it) } ?: Result.success(A_OIDC_DATA) + } + + override suspend fun cancelOidcLogin(): Result { + return oidcCancelError?.let { Result.failure(it) } ?: Result.success(Unit) + } + + override suspend fun loginWithOidc(callbackUrl: String): Result = simulateLongTask { + loginError?.let { Result.failure(it) } ?: run { + onAuthenticationListener?.invoke(matrixClient ?: FakeMatrixClient()) + Result.success(A_USER_ID) + } + } + + override suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit): Result = simulateLongTask { + onAuthenticationListener?.invoke(matrixClient ?: FakeMatrixClient()) + loginWithQrCodeResult(qrCodeData, progress) + } + + override fun listenToNewMatrixClients(lambda: (MatrixClient) -> Unit) { + onAuthenticationListener = lambda + } + + fun givenOidcError(throwable: Throwable?) { + oidcError = throwable + } + + fun givenOidcCancelError(throwable: Throwable?) { + oidcCancelError = throwable + } + + fun givenLoginError(throwable: Throwable?) { + loginError = throwable + } + + fun givenMatrixClient(matrixClient: MatrixClient) { + this.matrixClient = matrixClient + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeOidcRedirectUrlProvider.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeOidcRedirectUrlProvider.kt new file mode 100644 index 0000000..47c9b09 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeOidcRedirectUrlProvider.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.auth + +import io.element.android.libraries.matrix.api.auth.OidcRedirectUrlProvider + +const val FAKE_REDIRECT_URL = "io.element.android:/" + +class FakeOidcRedirectUrlProvider( + private val provideResult: String = FAKE_REDIRECT_URL, +) : OidcRedirectUrlProvider { + override fun provide() = provideResult +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/MatrixHomeServerDetails.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/MatrixHomeServerDetails.kt new file mode 100644 index 0000000..3b9573b --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/MatrixHomeServerDetails.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.auth + +import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails +import io.element.android.libraries.matrix.test.A_HOMESERVER_URL + +fun aMatrixHomeServerDetails( + url: String = A_HOMESERVER_URL, + supportsPasswordLogin: Boolean = false, + supportsOidcLogin: Boolean = false, +) = MatrixHomeServerDetails( + url = url, + supportsPasswordLogin = supportsPasswordLogin, + supportsOidcLogin = supportsOidcLogin, +) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/qrlogin/FakeMatrixQrCodeLoginDataFactory.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/qrlogin/FakeMatrixQrCodeLoginDataFactory.kt new file mode 100644 index 0000000..c601337 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/qrlogin/FakeMatrixQrCodeLoginDataFactory.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.auth.qrlogin + +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginDataFactory +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder + +class FakeMatrixQrCodeLoginDataFactory( + var parseQrCodeLoginDataResult: () -> Result = + lambdaRecorder> { Result.success(FakeMatrixQrCodeLoginData()) }, +) : MatrixQrCodeLoginDataFactory { + override fun parseQrCodeData(data: ByteArray): Result { + return parseQrCodeLoginDataResult() + } +} + +class FakeMatrixQrCodeLoginData( + private val serverNameResult: () -> String? = { lambdaError() }, +) : MatrixQrCodeLoginData { + override fun serverName() = serverNameResult() +} 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 0000000..510e2bc --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/core/BuildMeta.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 = "", + productionApplicationName: String = applicationName, + desktopApplicationName: String = applicationName, + applicationId: String = "", + isEnterpriseBuild: Boolean = false, + lowPrivacyLoggingEnabled: Boolean = true, + versionName: String = "", + versionCode: Long = 0, + gitRevision: String = "", + gitBranchName: String = "", + flavorDescription: String = "", + flavorShortDescription: String = "", +) = BuildMeta( + buildType = buildType, + isDebuggable = isDebuggable, + applicationName = applicationName, + productionApplicationName = productionApplicationName, + desktopApplicationName = desktopApplicationName, + applicationId = applicationId, + isEnterpriseBuild = isEnterpriseBuild, + lowPrivacyLoggingEnabled = lowPrivacyLoggingEnabled, + versionName = versionName, + versionCode = versionCode, + gitRevision = gitRevision, + gitBranchName = gitBranchName, + flavorDescription = flavorDescription, + flavorShortDescription = flavorShortDescription, +) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/core/FakeSendHandle.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/core/FakeSendHandle.kt new file mode 100644 index 0000000..abbf5d3 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/core/FakeSendHandle.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.core + +import io.element.android.libraries.matrix.api.core.SendHandle +import io.element.android.tests.testutils.simulateLongTask + +class FakeSendHandle( + var retryLambda: () -> Result = { Result.success(Unit) } +) : SendHandle { + override suspend fun retry(): Result = simulateLongTask { + return retryLambda() + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt new file mode 100644 index 0000000..04e3779 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.encryption + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.BackupState +import io.element.android.libraries.matrix.api.encryption.BackupUploadState +import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle +import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf + +class FakeEncryptionService( + var startIdentityResetLambda: () -> Result = { lambdaError() }, + private val pinUserIdentityResult: (UserId) -> Result = { lambdaError() }, + private val withdrawVerificationResult: (UserId) -> Result = { lambdaError() }, + private val getUserIdentityResult: (UserId) -> Result = { lambdaError() }, + private val enableRecoveryLambda: (Boolean) -> Result = { lambdaError() }, +) : EncryptionService { + private var disableRecoveryFailure: Exception? = null + override val backupStateStateFlow: MutableStateFlow = MutableStateFlow(BackupState.UNKNOWN) + override val recoveryStateStateFlow: MutableStateFlow = MutableStateFlow(RecoveryState.UNKNOWN) + override val enableRecoveryProgressStateFlow: MutableStateFlow = MutableStateFlow(EnableRecoveryProgress.Starting) + override val isLastDevice: MutableStateFlow = MutableStateFlow(false) + override val hasDevicesToVerifyAgainst: MutableStateFlow> = MutableStateFlow(AsyncData.Uninitialized) + private var waitForBackupUploadSteadyStateFlow: Flow = flowOf() + + private var recoverFailure: Exception? = null + private var doesBackupExistOnServerResult: Result = Result.success(true) + + private var enableBackupsFailure: Exception? = null + + private var curve25519: String? = null + private var ed25519: String? = null + + fun givenEnableBackupsFailure(exception: Exception?) { + enableBackupsFailure = exception + } + + override suspend fun enableBackups(): Result = simulateLongTask { + enableBackupsFailure?.let { return Result.failure(it) } + return Result.success(Unit) + } + + fun givenDisableRecoveryFailure(exception: Exception) { + disableRecoveryFailure = exception + } + + fun givenRecoverFailure(exception: Exception?) { + recoverFailure = exception + } + + override suspend fun disableRecovery(): Result = simulateLongTask { + disableRecoveryFailure?.let { return Result.failure(it) } + return Result.success(Unit) + } + + fun givenDoesBackupExistOnServerResult(result: Result) { + doesBackupExistOnServerResult = result + } + + override suspend fun doesBackupExistOnServer(): Result = simulateLongTask { + return doesBackupExistOnServerResult + } + + override suspend fun recover(recoveryKey: String): Result = simulateLongTask { + recoverFailure?.let { return Result.failure(it) } + return Result.success(Unit) + } + + fun emitIsLastDevice(isLastDevice: Boolean) { + this.isLastDevice.value = isLastDevice + } + + fun emitHasDevicesToVerifyAgainst(hasDevicesToVerifyAgainst: AsyncData) { + this.hasDevicesToVerifyAgainst.value = hasDevicesToVerifyAgainst + } + + override suspend fun resetRecoveryKey(): Result = simulateLongTask { + return Result.success(FAKE_RECOVERY_KEY) + } + + override suspend fun enableRecovery(waitForBackupsToUpload: Boolean): Result = simulateLongTask { + return enableRecoveryLambda(waitForBackupsToUpload) + } + + fun givenWaitForBackupUploadSteadyStateFlow(flow: Flow) { + waitForBackupUploadSteadyStateFlow = flow + } + + override fun waitForBackupUploadSteadyState(): Flow { + return waitForBackupUploadSteadyStateFlow + } + + fun givenDeviceKeys(curve25519: String?, ed25519: String?) { + this.curve25519 = curve25519 + this.ed25519 = ed25519 + } + + override suspend fun deviceCurve25519(): String? = curve25519 + + override suspend fun deviceEd25519(): String? = ed25519 + + suspend fun emitBackupState(state: BackupState) { + backupStateStateFlow.emit(state) + } + + suspend fun emitRecoveryState(state: RecoveryState) { + recoveryStateStateFlow.emit(state) + } + + suspend fun emitEnableRecoveryProgress(state: EnableRecoveryProgress) { + enableRecoveryProgressStateFlow.emit(state) + } + + override suspend fun startIdentityReset(): Result { + return startIdentityResetLambda() + } + + override suspend fun pinUserIdentity(userId: UserId): Result { + return pinUserIdentityResult(userId) + } + + override suspend fun withdrawVerification(userId: UserId): Result { + return withdrawVerificationResult(userId) + } + + override suspend fun getUserIdentity(userId: UserId, fallbackToServer: Boolean): Result = simulateLongTask { + return getUserIdentityResult(userId) + } + + companion object { + const val FAKE_RECOVERY_KEY = "fake" + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeIdentityResetHandle.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeIdentityResetHandle.kt new file mode 100644 index 0000000..06ffeb5 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeIdentityResetHandle.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.encryption + +import io.element.android.libraries.matrix.api.encryption.IdentityOidcResetHandle +import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetHandle + +class FakeIdentityOidcResetHandle( + override val url: String = "", + var resetOidcLambda: () -> Result = { error("Not implemented") }, + var cancelLambda: () -> Unit = { error("Not implemented") }, +) : IdentityOidcResetHandle { + override suspend fun resetOidc(): Result { + return resetOidcLambda() + } + + override suspend fun cancel() { + cancelLambda() + } +} + +class FakeIdentityPasswordResetHandle( + var resetPasswordLambda: (String) -> Result = { _ -> error("Not implemented") }, + var cancelLambda: () -> Unit = { error("Not implemented") }, +) : IdentityPasswordResetHandle { + override suspend fun resetPassword(password: String): Result { + return resetPasswordLambda(password) + } + + override suspend fun cancel() { + cancelLambda() + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMatrixMediaLoader.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMatrixMediaLoader.kt new file mode 100644 index 0000000..3b2dc36 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMatrixMediaLoader.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 FakeMatrixMediaLoader : MatrixMediaLoader { + var shouldFail = false + var path: String = "" + + override suspend fun loadMediaContent(source: MediaSource): Result = simulateLongTask { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(ByteArray(0)) + } + } + + override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result = simulateLongTask { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(ByteArray(0)) + } + } + + override suspend fun downloadMediaFile( + source: MediaSource, + mimeType: String?, + filename: String?, + useCache: Boolean, + ): Result = simulateLongTask { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(FakeMediaFile(path)) + } + } +} 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 0000000..88572c8 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaFile.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.media + +import io.element.android.libraries.matrix.api.media.MediaFile +import java.io.File + +class FakeMediaFile(private val path: String) : MediaFile { + override fun path(): String { + return path + } + + override fun persist(path: String): Boolean { + return File(path()).renameTo(File(path)) + } + + override fun close() = Unit +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaPreviewService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaPreviewService.kt new file mode 100644 index 0000000..47ddb82 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaPreviewService.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.media + +import io.element.android.libraries.matrix.api.media.MediaPreviewConfig +import io.element.android.libraries.matrix.api.media.MediaPreviewService +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class FakeMediaPreviewService( + override val mediaPreviewConfigFlow: StateFlow = MutableStateFlow(MediaPreviewConfig.DEFAULT), + private val fetchMediaPreviewConfigResult: () -> Result = { lambdaError() }, + private val setMediaPreviewValueResult: (MediaPreviewValue) -> Result = { lambdaError() }, + private val setHideInviteAvatarsResult: (Boolean) -> Result = { lambdaError() }, +) : MediaPreviewService { + override suspend fun fetchMediaPreviewConfig(): Result = simulateLongTask { + fetchMediaPreviewConfigResult() + } + + override suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result = simulateLongTask { + setMediaPreviewValueResult(mediaPreviewValue) + } + + override suspend fun setHideInviteAvatars(hide: Boolean): Result = simulateLongTask { + setHideInviteAvatarsResult(hide) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaUploadHandler.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaUploadHandler.kt new file mode 100644 index 0000000..63b184a --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaUploadHandler.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.media + +import io.element.android.libraries.matrix.api.media.MediaUploadHandler +import io.element.android.tests.testutils.simulateLongTask +import kotlin.coroutines.cancellation.CancellationException + +class FakeMediaUploadHandler( + private var result: Result = Result.success(Unit), +) : MediaUploadHandler { + override suspend fun await(): Result = simulateLongTask { result } + + override fun cancel() { + result = Result.failure(CancellationException()) + } +} 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 0000000..501749e --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/MediaSource.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/mxc/FakeMxcTools.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/mxc/FakeMxcTools.kt new file mode 100644 index 0000000..c348cd3 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/mxc/FakeMxcTools.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.mxc + +import io.element.android.libraries.matrix.api.mxc.MxcTools +import io.element.android.libraries.matrix.impl.mxc.DefaultMxcTools + +class FakeMxcTools( + private val delegate: MxcTools = DefaultMxcTools() +) : MxcTools by delegate 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 0000000..e09d71b --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.notification.NotificationData +import io.element.android.libraries.matrix.api.notification.NotificationService + +class FakeNotificationService : NotificationService { + private var getNotificationsResult: Result>> = Result.success(emptyMap()) + + fun givenGetNotificationsResult(result: Result>>) { + getNotificationsResult = result + } + + override suspend fun getNotifications(ids: Map>): Result>> { + return getNotificationsResult + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt new file mode 100644 index 0000000..3eb0758 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.notification + +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.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_SESSION_ID +import io.element.android.libraries.matrix.test.A_TIMESTAMP +import io.element.android.libraries.matrix.test.A_USER_NAME_2 + +fun aNotificationData( + content: NotificationContent = NotificationContent.MessageLike.RoomEncrypted, + isDirect: Boolean = false, + hasMention: Boolean = false, + threadId: ThreadId? = null, + timestamp: Long = A_TIMESTAMP, + senderDisplayName: String? = A_USER_NAME_2, + senderIsNameAmbiguous: Boolean = false, + roomDisplayName: String? = A_ROOM_NAME +): NotificationData { + return NotificationData( + sessionId = A_SESSION_ID, + eventId = AN_EVENT_ID, + threadId = threadId, + roomId = A_ROOM_ID, + senderAvatarUrl = null, + senderDisplayName = senderDisplayName, + senderIsNameAmbiguous = senderIsNameAmbiguous, + roomAvatarUrl = null, + roomDisplayName = roomDisplayName, + isDirect = isDirect, + isDm = false, + isEncrypted = false, + isNoisy = false, + timestamp = timestamp, + content = content, + hasMention = hasMention, + ) +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notificationsettings/FakeNotificationSettingsService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notificationsettings/FakeNotificationSettingsService.kt new file mode 100644 index 0000000..564cd23 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notificationsettings/FakeNotificationSettingsService.kt @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.notificationsettings + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.RoomNotificationSettings +import io.element.android.libraries.matrix.test.A_ROOM_NOTIFICATION_MODE +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow + +class FakeNotificationSettingsService( + initialRoomMode: RoomNotificationMode = A_ROOM_NOTIFICATION_MODE, + initialRoomModeIsDefault: Boolean = true, + initialGroupDefaultMode: RoomNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, + initialEncryptedGroupDefaultMode: RoomNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, + initialOneToOneDefaultMode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES, + initialEncryptedOneToOneDefaultMode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES, + private val getRawPushRulesResult: () -> Result = { lambdaError() }, + private val getRoomsWithUserDefinedRulesResult: () -> Result> = { lambdaError() }, +) : NotificationSettingsService { + private val notificationSettingsStateFlow = MutableStateFlow(Unit) + private var defaultGroupRoomNotificationMode: RoomNotificationMode = initialGroupDefaultMode + private var defaultEncryptedGroupRoomNotificationMode: RoomNotificationMode = initialEncryptedGroupDefaultMode + private var defaultOneToOneRoomNotificationMode: RoomNotificationMode = initialOneToOneDefaultMode + private var defaultEncryptedOneToOneRoomNotificationMode: RoomNotificationMode = initialEncryptedOneToOneDefaultMode + private var roomNotificationMode: RoomNotificationMode = initialRoomMode + private var roomNotificationModeIsDefault: Boolean = initialRoomModeIsDefault + private var callNotificationsEnabled = false + private var inviteNotificationsEnabled = false + private var atRoomNotificationsEnabled = false + private var setNotificationModeError: Throwable? = null + private var restoreDefaultNotificationModeError: Throwable? = null + private var setDefaultNotificationModeError: Throwable? = null + private var setAtRoomError: Throwable? = null + private var canHomeServerPushEncryptedEventsToDeviceResult = Result.success(true) + override val notificationSettingsChangeFlow: SharedFlow + get() = notificationSettingsStateFlow + + override suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result { + return Result.success( + RoomNotificationSettings( + mode = if (roomNotificationModeIsDefault) defaultEncryptedGroupRoomNotificationMode else roomNotificationMode, + isDefault = roomNotificationModeIsDefault + ) + ) + } + + override suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, isOneToOne: Boolean): Result { + return if (isOneToOne) { + if (isEncrypted) { + Result.success(defaultEncryptedOneToOneRoomNotificationMode) + } else { + Result.success(defaultOneToOneRoomNotificationMode) + } + } else { + if (isEncrypted) { + Result.success(defaultEncryptedGroupRoomNotificationMode) + } else { + Result.success(defaultGroupRoomNotificationMode) + } + } + } + + override suspend fun setDefaultRoomNotificationMode(isEncrypted: Boolean, mode: RoomNotificationMode, isOneToOne: Boolean): Result { + val error = setDefaultNotificationModeError + if (error != null) { + return Result.failure(error) + } + if (isOneToOne) { + if (isEncrypted) { + defaultEncryptedOneToOneRoomNotificationMode = mode + } else { + defaultOneToOneRoomNotificationMode = mode + } + } else { + if (isEncrypted) { + defaultEncryptedGroupRoomNotificationMode = mode + } else { + defaultGroupRoomNotificationMode = mode + } + } + notificationSettingsStateFlow.emit(Unit) + return Result.success(Unit) + } + + override suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result { + val error = setNotificationModeError + return if (error != null) { + Result.failure(error) + } else { + roomNotificationModeIsDefault = false + roomNotificationMode = mode + notificationSettingsStateFlow.emit(Unit) + Result.success(Unit) + } + } + + override suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result { + val error = restoreDefaultNotificationModeError + if (error != null) { + return Result.failure(error) + } + roomNotificationModeIsDefault = true + roomNotificationMode = defaultEncryptedGroupRoomNotificationMode + notificationSettingsStateFlow.emit(Unit) + return Result.success(Unit) + } + + override suspend fun muteRoom(roomId: RoomId): Result { + return setRoomNotificationMode(roomId, RoomNotificationMode.MUTE) + } + + override suspend fun unmuteRoom(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result { + return restoreDefaultRoomNotificationMode(roomId) + } + + override suspend fun isRoomMentionEnabled(): Result { + return Result.success(atRoomNotificationsEnabled) + } + + override suspend fun setRoomMentionEnabled(enabled: Boolean): Result { + val error = setAtRoomError + if (error != null) { + return Result.failure(error) + } + atRoomNotificationsEnabled = enabled + return Result.success(Unit) + } + + override suspend fun isCallEnabled(): Result { + return Result.success(callNotificationsEnabled) + } + + override suspend fun setCallEnabled(enabled: Boolean): Result { + callNotificationsEnabled = enabled + return Result.success(Unit) + } + + override suspend fun isInviteForMeEnabled(): Result { + return Result.success(inviteNotificationsEnabled) + } + + override suspend fun setInviteForMeEnabled(enabled: Boolean): Result { + inviteNotificationsEnabled = enabled + return Result.success(Unit) + } + + override suspend fun getRoomsWithUserDefinedRules(): Result> { + return getRoomsWithUserDefinedRulesResult() + } + + override suspend fun canHomeServerPushEncryptedEventsToDevice(): Result { + return canHomeServerPushEncryptedEventsToDeviceResult + } + + fun givenSetNotificationModeError(throwable: Throwable?) { + setNotificationModeError = throwable + } + + fun givenRestoreDefaultNotificationModeError(throwable: Throwable?) { + restoreDefaultNotificationModeError = throwable + } + + fun givenSetAtRoomError(throwable: Throwable?) { + setAtRoomError = throwable + } + + fun givenSetDefaultNotificationModeError(throwable: Throwable?) { + setDefaultNotificationModeError = throwable + } + + fun givenCanHomeServerPushEncryptedEventsToDeviceResult(result: Result) { + canHomeServerPushEncryptedEventsToDeviceResult = result + } + + override suspend fun getRawPushRules(): Result { + return getRawPushRulesResult() + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt new file mode 100644 index 0000000..461d59c --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.permalink + +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePermalinkBuilder( + private val permalinkForUserLambda: (UserId) -> Result = { lambdaError() }, + private val permalinkForRoomAliasLambda: (RoomAlias) -> Result = { lambdaError() }, +) : PermalinkBuilder { + override fun permalinkForUser(userId: UserId): Result { + return permalinkForUserLambda(userId) + } + + override fun permalinkForRoomAlias(roomAlias: RoomAlias): Result { + return permalinkForRoomAliasLambda(roomAlias) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt new file mode 100644 index 0000000..65aa7f9 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.permalink + +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePermalinkParser( + private var result: (String) -> PermalinkData = { lambdaError() } +) : PermalinkParser { + fun givenResult(result: PermalinkData) { + this.result = { result } + } + + override fun parse(uriString: String): PermalinkData { + return result(uriString) + } +} 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 0000000..ec1efee --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 +import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePushersService( + private val setHttpPusherResult: (SetHttpPusherData) -> Result = { lambdaError() }, + private val unsetHttpPusherResult: (UnsetHttpPusherData) -> Result = { lambdaError() }, +) : PushersService { + override suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData) = setHttpPusherResult(setHttpPusherData) + override suspend fun unsetHttpPusher(unsetHttpPusherData: UnsetHttpPusherData): Result = unsetHttpPusherResult(unsetHttpPusherData) +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt new file mode 100644 index 0000000..56f880d --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.room + +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.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.api.room.draft.ComposerDraft +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues +import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom +import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility +import io.element.android.libraries.matrix.api.timeline.ReceiptType +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.TestScope + +class FakeBaseRoom( + override val sessionId: SessionId = A_SESSION_ID, + override val roomId: RoomId = A_ROOM_ID, + initialRoomInfo: RoomInfo = aRoomInfo(), + override val roomCoroutineScope: CoroutineScope = TestScope(), + private var roomPermalinkResult: () -> Result = { lambdaError() }, + private var eventPermalinkResult: (EventId) -> Result = { lambdaError() }, + private val userDisplayNameResult: (UserId) -> Result = { lambdaError() }, + private val userAvatarUrlResult: () -> Result = { lambdaError() }, + private val userRoleResult: () -> Result = { lambdaError() }, + private val getUpdatedMemberResult: (UserId) -> Result = { lambdaError() }, + private val joinRoomResult: () -> Result = { lambdaError() }, + private val canInviteResult: (UserId) -> Result = { lambdaError() }, + private val canKickResult: (UserId) -> Result = { lambdaError() }, + private val canBanResult: (UserId) -> Result = { lambdaError() }, + private val canRedactOwnResult: (UserId) -> Result = { lambdaError() }, + private val canRedactOtherResult: (UserId) -> Result = { lambdaError() }, + private val canSendStateResult: (UserId, StateEventType) -> Result = { _, _ -> lambdaError() }, + private val canUserSendMessageResult: (UserId, MessageEventType) -> Result = { _, _ -> lambdaError() }, + private val canUserTriggerRoomNotificationResult: (UserId) -> Result = { lambdaError() }, + private val canUserJoinCallResult: (UserId) -> Result = { lambdaError() }, + private val canUserPinUnpinResult: (UserId) -> Result = { lambdaError() }, + private val setIsFavoriteResult: (Boolean) -> Result = { lambdaError() }, + private val markAsReadResult: (ReceiptType) -> Result = { Result.success(Unit) }, + private val powerLevelsResult: () -> Result = { lambdaError() }, + private val leaveRoomLambda: () -> Result = { lambdaError() }, + private var updateMembersResult: () -> Unit = { lambdaError() }, + private val getMembersResult: (Int) -> Result> = { lambdaError() }, + private val saveComposerDraftLambda: (ComposerDraft) -> Result = { _: ComposerDraft -> Result.success(Unit) }, + private val loadComposerDraftLambda: () -> Result = { Result.success(null) }, + private val clearComposerDraftLambda: () -> Result = { Result.success(Unit) }, + private val subscribeToSyncLambda: () -> Unit = { lambdaError() }, + private val getRoomVisibilityResult: () -> Result = { lambdaError() }, + private val forgetResult: () -> Result = { lambdaError() }, + private val reportRoomResult: (String?) -> Result = { lambdaError() }, + private val predecessorRoomResult: () -> PredecessorRoom? = { null }, + private val threadRootIdForEventResult: (EventId) -> Result = { lambdaError() }, +) : BaseRoom { + private val _roomInfoFlow: MutableStateFlow = MutableStateFlow(initialRoomInfo) + override val roomInfoFlow: StateFlow = _roomInfoFlow + + fun givenRoomInfo(roomInfo: RoomInfo) { + _roomInfoFlow.tryEmit(roomInfo) + } + + private val declineCallFlowMap: MutableMap> = mutableMapOf() + + suspend fun givenDecliner(userId: UserId, forNotificationEventId: EventId) { + declineCallFlowMap[forNotificationEventId]?.emit(userId) + } + + override val membersStateFlow: MutableStateFlow = MutableStateFlow(RoomMembersState.Unknown) + + override suspend fun updateMembers() = updateMembersResult() + + override suspend fun getUpdatedMember(userId: UserId): Result { + return getUpdatedMemberResult(userId) + } + + override suspend fun getMembers(limit: Int): Result> { + return getMembersResult(limit) + } + + override suspend fun subscribeToSync() { + subscribeToSyncLambda() + } + + override suspend fun powerLevels(): Result { + return powerLevelsResult() + } + + private var isDestroyed = false + + override fun destroy() { + isDestroyed = true + } + + fun assertDestroyed() { + check(isDestroyed) { "Room should be destroyed" } + } + + override suspend fun userDisplayName(userId: UserId): Result = simulateLongTask { + userDisplayNameResult(userId) + } + + override suspend fun userAvatarUrl(userId: UserId): Result = simulateLongTask { + userAvatarUrlResult() + } + + override suspend fun userRole(userId: UserId): Result { + return userRoleResult() + } + + override suspend fun getPermalink(): Result { + return roomPermalinkResult() + } + + override suspend fun getPermalinkFor(eventId: EventId): Result { + return eventPermalinkResult(eventId) + } + + override suspend fun getRoomVisibility(): Result = simulateLongTask { + getRoomVisibilityResult() + } + + override suspend fun leave(): Result = simulateLongTask { + return leaveRoomLambda() + } + + override suspend fun join(): Result { + return joinRoomResult() + } + + override suspend fun forget(): Result { + return forgetResult() + } + + override suspend fun canUserBan(userId: UserId): Result { + return canBanResult(userId) + } + + override suspend fun canUserKick(userId: UserId): Result { + return canKickResult(userId) + } + + override suspend fun canUserInvite(userId: UserId): Result { + return canInviteResult(userId) + } + + override suspend fun canUserRedactOwn(userId: UserId): Result { + return canRedactOwnResult(userId) + } + + override suspend fun canUserRedactOther(userId: UserId): Result { + return canRedactOtherResult(userId) + } + + override suspend fun canUserSendState(userId: UserId, type: StateEventType): Result { + return canSendStateResult(userId, type) + } + + override suspend fun canUserSendMessage(userId: UserId, type: MessageEventType): Result { + return canUserSendMessageResult(userId, type) + } + + override suspend fun canUserTriggerRoomNotification(userId: UserId): Result { + return canUserTriggerRoomNotificationResult(userId) + } + + override suspend fun canUserJoinCall(userId: UserId): Result { + return canUserJoinCallResult(userId) + } + + override suspend fun canUserPinUnpin(userId: UserId): Result { + return canUserPinUnpinResult(userId) + } + + override suspend fun setIsFavorite(isFavorite: Boolean): Result { + return setIsFavoriteResult(isFavorite) + } + + override suspend fun markAsRead(receiptType: ReceiptType): Result { + return markAsReadResult(receiptType) + } + + var setUnreadFlagCalls = mutableListOf() + private set + + override suspend fun setUnreadFlag(isUnread: Boolean): Result { + setUnreadFlagCalls.add(isUnread) + return Result.success(Unit) + } + + override suspend fun saveComposerDraft( + composerDraft: ComposerDraft, + threadRoot: ThreadId? + ) = saveComposerDraftLambda(composerDraft) + + override suspend fun loadComposerDraft(threadRoot: ThreadId?) = loadComposerDraftLambda() + + override suspend fun clearComposerDraft(threadRoot: ThreadId?) = clearComposerDraftLambda() + + override suspend fun getUpdatedIsEncrypted(): Result = simulateLongTask { + Result.success(info().isEncrypted.orFalse()) + } + + fun givenRoomMembersState(state: RoomMembersState) { + membersStateFlow.value = state + } + + override suspend fun clearEventCacheStorage(): Result { + return Result.success(Unit) + } + + override suspend fun reportRoom(reason: String?) = reportRoomResult(reason) + + override suspend fun declineCall(notificationEventId: EventId): Result { + return Result.success(Unit) + } + + override suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow { + val flow = declineCallFlowMap.getOrPut(notificationEventId, { MutableSharedFlow() }) + return flow + } + + override fun predecessorRoom(): PredecessorRoom? = predecessorRoomResult() + + fun givenUpdateMembersResult(result: () -> Unit) { + updateMembersResult = result + } + + override suspend fun threadRootIdForEvent(eventId: EventId): Result { + return threadRootIdForEventResult(eventId) + } +} + +fun defaultRoomPowerLevelValues() = RoomPowerLevelsValues( + ban = 50, + invite = 0, + kick = 50, + sendEvents = 0, + redactEvents = 50, + roomName = 100, + roomAvatar = 100, + roomTopic = 100, + spaceChild = 100, +) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt new file mode 100644 index 0000000..2d9694c --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.room + +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.matrix.api.core.DeviceId +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.RoomAlias +import io.element.android.libraries.matrix.api.core.SendHandle +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.CreateTimelineParams +import io.element.android.libraries.matrix.api.room.IntentionalMention +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.RoomNotificationSettingsState +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.knock.KnockRequest +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues +import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange +import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver +import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings +import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.TestScope + +class FakeJoinedRoom( + val baseRoom: FakeBaseRoom = FakeBaseRoom(), + override val liveTimeline: Timeline = FakeTimeline(), + override val roomCoroutineScope: CoroutineScope = TestScope(), + override val syncUpdateFlow: StateFlow = MutableStateFlow(0), + override val roomTypingMembersFlow: Flow> = MutableStateFlow(emptyList()), + override val identityStateChangesFlow: Flow> = MutableStateFlow(emptyList()), + override val roomNotificationSettingsStateFlow: StateFlow = + MutableStateFlow(RoomNotificationSettingsState.Unknown), + override val knockRequestsFlow: Flow> = MutableStateFlow(emptyList()), + private val roomNotificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), + private var createTimelineResult: (CreateTimelineParams) -> Result = { lambdaError() }, + private val editMessageLambda: (EventId, String, String?, List) -> Result = { _, _, _, _ -> lambdaError() }, + private val progressCallbackValues: List> = emptyList(), + private val generateWidgetWebViewUrlResult: (MatrixWidgetSettings, String, String?, String?) -> Result = { _, _, _, _ -> lambdaError() }, + private val getWidgetDriverResult: (MatrixWidgetSettings) -> Result = { lambdaError() }, + private val typingNoticeResult: (Boolean) -> Result = { lambdaError() }, + private val inviteUserResult: (UserId) -> Result = { lambdaError() }, + private val setNameResult: (String) -> Result = { lambdaError() }, + private val setTopicResult: (String) -> Result = { lambdaError() }, + private val updateAvatarResult: (String, ByteArray) -> Result = { _, _ -> lambdaError() }, + private val removeAvatarResult: () -> Result = { lambdaError() }, + private val updateUserRoleResult: (List) -> Result = { lambdaError() }, + private val updatePowerLevelsResult: (RoomPowerLevelsValues) -> Result = { lambdaError() }, + private val resetPowerLevelsResult: () -> Result = { lambdaError() }, + private val reportContentResult: (EventId, String, UserId?) -> Result = { _, _, _ -> lambdaError() }, + private val kickUserResult: (UserId, String?) -> Result = { _, _ -> lambdaError() }, + private val banUserResult: (UserId, String?) -> Result = { _, _ -> lambdaError() }, + private val unBanUserResult: (UserId, String?) -> Result = { _, _ -> lambdaError() }, + private val ignoreDeviceTrustAndResendResult: (Map>, SendHandle) -> Result = { _, _ -> lambdaError() }, + private val withdrawVerificationAndResendResult: (List, SendHandle) -> Result = { _, _ -> lambdaError() }, + private val updateCanonicalAliasResult: (RoomAlias?, List) -> Result = { _, _ -> lambdaError() }, + private val updateRoomVisibilityResult: (RoomVisibility) -> Result = { lambdaError() }, + private val updateRoomHistoryVisibilityResult: (RoomHistoryVisibility) -> Result = { lambdaError() }, + private val publishRoomAliasInRoomDirectoryResult: (RoomAlias) -> Result = { lambdaError() }, + private val removeRoomAliasFromRoomDirectoryResult: (RoomAlias) -> Result = { lambdaError() }, + private val enableEncryptionResult: () -> Result = { lambdaError() }, + private val updateJoinRuleResult: (JoinRule) -> Result = { lambdaError() }, + private val setSendQueueEnabledResult: (Boolean) -> Unit = { _: Boolean -> }, +) : JoinedRoom, BaseRoom by baseRoom { + fun givenRoomMembersState(state: RoomMembersState) { + baseRoom.givenRoomMembersState(state) + } + + fun givenRoomInfo(roomInfo: RoomInfo) { + baseRoom.givenRoomInfo(roomInfo) + } + + override suspend fun createTimeline(createTimelineParams: CreateTimelineParams): Result = simulateLongTask { + createTimelineResult(createTimelineParams) + } + + override suspend fun editMessage( + eventId: EventId, + body: String, + htmlBody: String?, + intentionalMentions: List + ): Result = simulateLongTask { + editMessageLambda(eventId, body, htmlBody, intentionalMentions) + } + + override suspend fun typingNotice(isTyping: Boolean): Result = simulateLongTask { + typingNoticeResult(isTyping) + } + + override suspend fun inviteUserById(id: UserId): Result = simulateLongTask { + inviteUserResult(id) + } + + override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result = simulateLongTask { + simulateSendMediaProgress(null) + updateAvatarResult(mimeType, data) + } + + override suspend fun removeAvatar(): Result = simulateLongTask { + removeAvatarResult() + } + + override suspend fun updateRoomNotificationSettings(): Result = simulateLongTask { + val notificationSettings = roomNotificationSettingsService.getRoomNotificationSettings(roomId, info().isEncrypted.orFalse(), isOneToOne).getOrThrow() + (roomNotificationSettingsStateFlow as MutableStateFlow).value = RoomNotificationSettingsState.Ready(notificationSettings) + return Result.success(Unit) + } + + override suspend fun updateCanonicalAlias(canonicalAlias: RoomAlias?, alternativeAliases: List): Result = simulateLongTask { + updateCanonicalAliasResult(canonicalAlias, alternativeAliases) + } + + override suspend fun updateRoomVisibility(roomVisibility: RoomVisibility): Result = simulateLongTask { + updateRoomVisibilityResult(roomVisibility) + } + + override suspend fun updateHistoryVisibility(historyVisibility: RoomHistoryVisibility): Result = simulateLongTask { + updateRoomHistoryVisibilityResult(historyVisibility) + } + + override suspend fun publishRoomAliasInRoomDirectory(roomAlias: RoomAlias): Result = simulateLongTask { + publishRoomAliasInRoomDirectoryResult(roomAlias) + } + + override suspend fun removeRoomAliasFromRoomDirectory(roomAlias: RoomAlias): Result = simulateLongTask { + removeRoomAliasFromRoomDirectoryResult(roomAlias) + } + + override suspend fun enableEncryption(): Result = simulateLongTask { + enableEncryptionResult().onSuccess { + baseRoom.givenRoomInfo(info().copy(isEncrypted = true)) + emitSyncUpdate() + } + } + + override suspend fun updateJoinRule(joinRule: JoinRule): Result = simulateLongTask { + updateJoinRuleResult(joinRule) + } + + override suspend fun updateUsersRoles(changes: List): Result = simulateLongTask { + updateUserRoleResult(changes) + } + + override suspend fun updatePowerLevels(roomPowerLevelsValues: RoomPowerLevelsValues): Result = simulateLongTask { + updatePowerLevelsResult(roomPowerLevelsValues) + } + + override suspend fun resetPowerLevels(): Result = simulateLongTask { + resetPowerLevelsResult() + } + + override suspend fun setName(name: String): Result = simulateLongTask { + setNameResult(name) + } + + override suspend fun setTopic(topic: String): Result = simulateLongTask { + setTopicResult(topic) + } + + override suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result = simulateLongTask { + reportContentResult(eventId, reason, blockUserId) + } + + override suspend fun kickUser(userId: UserId, reason: String?): Result = simulateLongTask { + kickUserResult(userId, reason) + } + + override suspend fun banUser(userId: UserId, reason: String?): Result = simulateLongTask { + banUserResult(userId, reason) + } + + override suspend fun unbanUser(userId: UserId, reason: String?): Result = simulateLongTask { + unBanUserResult(userId, reason) + } + + override suspend fun generateWidgetWebViewUrl( + widgetSettings: MatrixWidgetSettings, + clientId: String, + languageTag: String?, + theme: String? + ): Result = simulateLongTask { + generateWidgetWebViewUrlResult(widgetSettings, clientId, languageTag, theme) + } + + override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result { + return getWidgetDriverResult(widgetSettings) + } + + override suspend fun setSendQueueEnabled(enabled: Boolean) = simulateLongTask { + setSendQueueEnabledResult(enabled) + } + + override suspend fun ignoreDeviceTrustAndResend(devices: Map>, sendHandle: SendHandle): Result = simulateLongTask { + ignoreDeviceTrustAndResendResult(devices, sendHandle) + } + + override suspend fun withdrawVerificationAndResend(userIds: List, sendHandle: SendHandle): Result = simulateLongTask { + withdrawVerificationAndResendResult(userIds, sendHandle) + } + + private suspend fun simulateSendMediaProgress(progressCallback: ProgressCallback?) { + progressCallbackValues.forEach { (current, total) -> + progressCallback?.onProgress(current, total) + delay(1) + } + } + + fun emitSyncUpdate() { + (syncUpdateFlow as MutableStateFlow).value = syncUpdateFlow.value + 1 + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeNotJoinedRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeNotJoinedRoom.kt new file mode 100644 index 0000000..6c1c7c2 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeNotJoinedRoom.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.room + +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.NotJoinedRoom +import io.element.android.libraries.matrix.api.room.RoomMembershipDetails +import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask + +class FakeNotJoinedRoom( + override val localRoom: BaseRoom? = null, + override val previewInfo: RoomPreviewInfo = aRoomPreviewInfo(), + private val roomMembershipDetails: () -> Result = { lambdaError() }, +) : NotJoinedRoom { + override suspend fun membershipDetails(): Result = simulateLongTask { + roomMembershipDetails() + } + + override fun close() = Unit +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/LatestEventValueFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/LatestEventValueFixture.kt new file mode 100644 index 0000000..32d6cc9 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/LatestEventValueFixture.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.room + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.roomlist.LatestEventValue +import io.element.android.libraries.matrix.api.timeline.item.event.EventContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.timeline.aMessageContent +import io.element.android.libraries.matrix.test.timeline.aProfileDetails + +fun aRemoteLatestEvent( + content: EventContent = aMessageContent(), + timestamp: Long = 0L, + isOwn: Boolean = false, + senderId: UserId = A_USER_ID, + senderProfile: ProfileDetails = aProfileDetails(), +): LatestEventValue.Remote { + return LatestEventValue.Remote( + timestamp = timestamp, + content = content, + senderId = senderId, + senderProfile = senderProfile, + isOwn = isOwn, + ) +} + +fun aLocalLatestEvent( + content: EventContent = aMessageContent(), + timestamp: Long = 0L, + isSending: Boolean = false, + senderId: UserId = A_USER_ID, + senderProfile: ProfileDetails = aProfileDetails(), +): LatestEventValue.Local { + return LatestEventValue.Local( + timestamp = timestamp, + content = content, + senderId = senderId, + senderProfile = senderProfile, + isSending = isSending, + ) +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt new file mode 100644 index 0000000..71ed8b8 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.RoomAlias +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.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels +import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom +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_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.libraries.matrix.test.A_ROOM_RAW_NAME +import io.element.android.libraries.matrix.test.A_ROOM_TOPIC +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableList + +fun aRoomInfo( + id: RoomId = A_ROOM_ID, + name: String? = A_ROOM_NAME, + rawName: String? = A_ROOM_RAW_NAME, + topic: String? = A_ROOM_TOPIC, + avatarUrl: String? = AN_AVATAR_URL, + isPublic: Boolean = true, + isDirect: Boolean = false, + isEncrypted: Boolean = false, + joinRule: JoinRule? = JoinRule.Public, + isSpace: Boolean = false, + successorRoom: SuccessorRoom? = null, + isFavorite: Boolean = false, + canonicalAlias: RoomAlias? = null, + alternativeAliases: List = emptyList(), + currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED, + inviter: RoomMember? = null, + activeMembersCount: Long = 2, + invitedMembersCount: Long = 1, + joinedMembersCount: Long = 1, + highlightCount: Long = 0, + notificationCount: Long = 0, + userDefinedNotificationMode: RoomNotificationMode? = null, + hasRoomCall: Boolean = false, + roomPowerLevels: RoomPowerLevels? = RoomPowerLevels( + values = defaultRoomPowerLevelValues(), + users = persistentMapOf(), + ), + activeRoomCallParticipants: List = emptyList(), + heroes: List = emptyList(), + pinnedEventIds: List = emptyList(), + roomCreators: List = emptyList(), + isMarkedUnread: Boolean = false, + numUnreadMessages: Long = 0, + numUnreadNotifications: Long = 0, + numUnreadMentions: Long = 0, + historyVisibility: RoomHistoryVisibility = RoomHistoryVisibility.Joined, + roomVersion: String? = "11", + privilegedCreatorRole: Boolean = false, +) = RoomInfo( + id = id, + name = name, + rawName = rawName, + topic = topic, + avatarUrl = avatarUrl, + isPublic = isPublic, + isDirect = isDirect, + isEncrypted = isEncrypted, + joinRule = joinRule, + isSpace = isSpace, + successorRoom = successorRoom, + isFavorite = isFavorite, + canonicalAlias = canonicalAlias, + alternativeAliases = alternativeAliases.toImmutableList(), + currentUserMembership = currentUserMembership, + inviter = inviter, + activeMembersCount = activeMembersCount, + invitedMembersCount = invitedMembersCount, + joinedMembersCount = joinedMembersCount, + highlightCount = highlightCount, + notificationCount = notificationCount, + userDefinedNotificationMode = userDefinedNotificationMode, + hasRoomCall = hasRoomCall, + roomPowerLevels = roomPowerLevels, + activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(), + heroes = heroes.toImmutableList(), + pinnedEventIds = pinnedEventIds.toImmutableList(), + creators = roomCreators.toImmutableList(), + isMarkedUnread = isMarkedUnread, + numUnreadMessages = numUnreadMessages, + numUnreadNotifications = numUnreadNotifications, + numUnreadMentions = numUnreadMentions, + historyVisibility = historyVisibility, + roomVersion = roomVersion, + privilegedCreatorRole = privilegedCreatorRole, +) 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 0000000..f6bc0c5 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomMemberFixture.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 +import kotlinx.collections.immutable.persistentListOf + +fun aRoomMember( + userId: UserId = UserId("@alice:server.org"), + displayName: String? = null, + avatarUrl: String? = null, + membership: RoomMembershipState = RoomMembershipState.JOIN, + isNameAmbiguous: Boolean = false, + powerLevel: Long = 0L, + isIgnored: Boolean = false, + role: RoomMember.Role = RoomMember.Role.User, + membershipChangeReason: String? = null, +) = RoomMember( + userId = userId, + displayName = displayName, + avatarUrl = avatarUrl, + membership = membership, + isNameAmbiguous = isNameAmbiguous, + powerLevel = powerLevel, + isIgnored = isIgnored, + role = role, + membershipChangeReason = membershipChangeReason, +) + +fun aRoomMemberList() = persistentListOf( + 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", role = RoomMember.Role.Admin) +fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob", role = RoomMember.Role.Moderator) + +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/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomPreviewInfoFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomPreviewInfoFixture.kt new file mode 100644 index 0000000..6d1f6da --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomPreviewInfoFixture.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.room + +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomMembershipDetails +import io.element.android.libraries.matrix.api.room.RoomType +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo +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_NAME +import io.element.android.libraries.matrix.test.A_ROOM_TOPIC +import io.element.android.tests.testutils.lambda.lambdaError + +fun aRoomPreview( + localRoom: FakeBaseRoom? = null, + info: RoomPreviewInfo = aRoomPreviewInfo(), + roomMembershipDetails: () -> Result = { lambdaError() }, +) = FakeNotJoinedRoom( + localRoom = localRoom, + previewInfo = info, + roomMembershipDetails = roomMembershipDetails, +) + +fun aRoomPreviewInfo( + roomId: RoomId = A_ROOM_ID, + name: String? = A_ROOM_NAME, + topic: String? = A_ROOM_TOPIC, + avatarUrl: String? = AN_AVATAR_URL, + joinRule: JoinRule = JoinRule.Public, + isSpace: Boolean = false, + canonicalAlias: RoomAlias? = null, + currentUserMembership: CurrentUserMembership? = null, + numberOfJoinedMembers: Long = 1, + isHistoryWorldReadable: Boolean = true, +) = RoomPreviewInfo( + roomId = roomId, + name = name, + topic = topic, + avatarUrl = avatarUrl, + joinRule = joinRule, + canonicalAlias = canonicalAlias, + numberOfJoinedMembers = numberOfJoinedMembers, + roomType = if (isSpace) RoomType.Space else RoomType.Room, + isHistoryWorldReadable = isHistoryWorldReadable, + membership = currentUserMembership, +) 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 0000000..8568e3c --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.RoomAlias +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.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels +import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom +import io.element.android.libraries.matrix.api.roomlist.LatestEventValue +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.libraries.matrix.api.user.MatrixUser +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_ROOM_RAW_NAME +import io.element.android.libraries.matrix.test.A_ROOM_TOPIC +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableList + +fun aRoomSummary( + info: RoomInfo = aRoomInfo(), + latestEventValue: LatestEventValue = aRemoteLatestEvent(), +) = RoomSummary( + info = info, + latestEvent = latestEventValue, +) + +fun aRoomSummary( + roomId: RoomId = A_ROOM_ID, + name: String? = A_ROOM_NAME, + rawName: String? = A_ROOM_RAW_NAME, + topic: String? = A_ROOM_TOPIC, + avatarUrl: String? = null, + isPublic: Boolean = true, + isDirect: Boolean = false, + isEncrypted: Boolean = false, + joinRule: JoinRule? = JoinRule.Public, + isSpace: Boolean = false, + successorRoom: SuccessorRoom? = null, + isFavorite: Boolean = false, + canonicalAlias: RoomAlias? = null, + alternativeAliases: List = emptyList(), + currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED, + inviter: RoomMember? = null, + activeMembersCount: Long = 1, + invitedMembersCount: Long = 0, + joinedMembersCount: Long = 1, + highlightCount: Long = 0, + notificationCount: Long = 0, + userDefinedNotificationMode: RoomNotificationMode? = null, + hasRoomCall: Boolean = false, + roomPowerLevels: RoomPowerLevels = RoomPowerLevels( + values = defaultRoomPowerLevelValues(), + users = persistentMapOf(), + ), + activeRoomCallParticipants: List = emptyList(), + heroes: List = emptyList(), + pinnedEventIds: List = emptyList(), + roomCreators: List = emptyList(), + isMarkedUnread: Boolean = false, + numUnreadMessages: Long = 0, + numUnreadNotifications: Long = 0, + numUnreadMentions: Long = 0, + historyVisibility: RoomHistoryVisibility = RoomHistoryVisibility.Joined, + latestEvent: LatestEventValue = aRemoteLatestEvent(), + roomVersion: String? = "11", + privilegedCreatorRole: Boolean = false, +) = RoomSummary( + info = RoomInfo( + id = roomId, + name = name, + rawName = rawName, + topic = topic, + avatarUrl = avatarUrl, + isPublic = isPublic, + isDirect = isDirect, + isEncrypted = isEncrypted, + joinRule = joinRule, + isSpace = isSpace, + successorRoom = successorRoom, + isFavorite = isFavorite, + canonicalAlias = canonicalAlias, + alternativeAliases = alternativeAliases.toImmutableList(), + currentUserMembership = currentUserMembership, + inviter = inviter, + activeMembersCount = activeMembersCount, + invitedMembersCount = invitedMembersCount, + joinedMembersCount = joinedMembersCount, + roomPowerLevels = roomPowerLevels, + highlightCount = highlightCount, + notificationCount = notificationCount, + userDefinedNotificationMode = userDefinedNotificationMode, + hasRoomCall = hasRoomCall, + activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(), + heroes = heroes.toImmutableList(), + pinnedEventIds = pinnedEventIds.toImmutableList(), + creators = roomCreators.toImmutableList(), + isMarkedUnread = isMarkedUnread, + numUnreadMessages = numUnreadMessages, + numUnreadNotifications = numUnreadNotifications, + numUnreadMentions = numUnreadMentions, + historyVisibility = historyVisibility, + roomVersion = roomVersion, + privilegedCreatorRole = privilegedCreatorRole, + ), + latestEvent = latestEvent, +) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/alias/FakeRoomAliasHelper.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/alias/FakeRoomAliasHelper.kt new file mode 100644 index 0000000..d2ba6af --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/alias/FakeRoomAliasHelper.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.room.alias + +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper + +class FakeRoomAliasHelper( + private val roomAliasNameFromRoomDisplayNameLambda: (String) -> String = { name -> + name.trimStart().trimEnd().replace(" ", "_") + }, + private val isRoomAliasValidLambda: (RoomAlias) -> Boolean = { true } +) : RoomAliasHelper { + override fun roomAliasNameFromRoomDisplayName(name: String): String { + return roomAliasNameFromRoomDisplayNameLambda(name) + } + + override fun isRoomAliasValid(roomAlias: RoomAlias): Boolean { + return isRoomAliasValidLambda(roomAlias) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/join/FakeJoinRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/join/FakeJoinRoom.kt new file mode 100644 index 0000000..e268930 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/join/FakeJoinRoom.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.room.join + +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.room.join.JoinRoom +import io.element.android.tests.testutils.simulateLongTask + +class FakeJoinRoom( + var lambda: (RoomIdOrAlias, List, JoinedRoom.Trigger) -> Result +) : JoinRoom { + override suspend fun invoke( + roomIdOrAlias: RoomIdOrAlias, + serverNames: List, + trigger: JoinedRoom.Trigger, + ): Result = simulateLongTask { + lambda(roomIdOrAlias, serverNames, trigger) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/knock/FakeKnockRequest.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/knock/FakeKnockRequest.kt new file mode 100644 index 0000000..9fd7706 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/knock/FakeKnockRequest.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.room.knock + +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.knock.KnockRequest +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_USER_ID +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask + +class FakeKnockRequest( + override val eventId: EventId = AN_EVENT_ID, + override val userId: UserId = A_USER_ID, + override val displayName: String? = A_USER_NAME, + override val avatarUrl: String? = AN_AVATAR_URL, + override val reason: String? = null, + override val timestamp: Long? = null, + override val isSeen: Boolean = false, + val acceptLambda: () -> Result = { lambdaError() }, + val declineLambda: (String?) -> Result = { lambdaError() }, + val declineAndBanLambda: (String?) -> Result = { lambdaError() }, + val markAsSeenLambda: () -> Result = { lambdaError() }, +) : KnockRequest { + override suspend fun accept(): Result = simulateLongTask { + acceptLambda() + } + + override suspend fun decline(reason: String?): Result = simulateLongTask { + declineLambda(reason) + } + + override suspend fun declineAndBan(reason: String?): Result = simulateLongTask { + declineAndBanLambda(reason) + } + + override suspend fun markAsSeen(): Result = simulateLongTask { + markAsSeenLambda() + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/FakeRoomDirectoryList.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/FakeRoomDirectoryList.kt new file mode 100644 index 0000000..78a6644 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/FakeRoomDirectoryList.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.roomdirectory + +import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +class FakeRoomDirectoryList( + override val state: Flow = emptyFlow(), + val filterLambda: (String?, Int, String?) -> Result = { _, _, _ -> Result.success(Unit) }, + val loadMoreLambda: () -> Result = { Result.success(Unit) } +) : RoomDirectoryList { + override suspend fun filter(filter: String?, batchSize: Int, viaServerName: String?): Result = filterLambda(filter, batchSize, viaServerName) + + override suspend fun loadMore(): Result = loadMoreLambda() +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/FakeRoomDirectoryService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/FakeRoomDirectoryService.kt new file mode 100644 index 0000000..17634a2 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/FakeRoomDirectoryService.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.roomdirectory + +import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList +import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService +import kotlinx.coroutines.CoroutineScope + +class FakeRoomDirectoryService( + private val createRoomDirectoryListFactory: (CoroutineScope) -> RoomDirectoryList = { throw AssertionError("Configure a proper factory.") } +) : RoomDirectoryService { + override fun createRoomDirectoryList(scope: CoroutineScope) = createRoomDirectoryListFactory(scope) +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/RoomDescriptionFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/RoomDescriptionFixture.kt new file mode 100644 index 0000000..df52be3 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomdirectory/RoomDescriptionFixture.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.roomdirectory + +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.roomdirectory.RoomDescription +import io.element.android.libraries.matrix.test.A_ROOM_ID + +fun aRoomDescription( + roomId: RoomId = A_ROOM_ID, + name: String? = null, + topic: String? = null, + alias: RoomAlias? = null, + avatarUrl: String? = null, + joinRule: RoomDescription.JoinRule = RoomDescription.JoinRule.UNKNOWN, + isWorldReadable: Boolean = true, + joinedMembers: Long = 2L +) = RoomDescription( + roomId = roomId, + name = name, + topic = topic, + alias = alias, + avatarUrl = avatarUrl, + joinRule = joinRule, + isWorldReadable = isWorldReadable, + numberOfMembers = joinedMembers +) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt new file mode 100644 index 0000000..b184438 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.roomlist + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList +import io.element.android.libraries.matrix.api.roomlist.RoomList +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class FakeRoomListService( + var subscribeToVisibleRoomsLambda: (List) -> Unit = {}, +) : RoomListService { + private val allRoomSummariesFlow = MutableStateFlow>(emptyList()) + private val allRoomsLoadingStateFlow = MutableStateFlow(RoomList.LoadingState.NotLoaded) + private val roomListStateFlow = MutableStateFlow(RoomListService.State.Idle) + private val syncIndicatorStateFlow = MutableStateFlow(RoomListService.SyncIndicator.Hide) + + suspend fun postAllRooms(roomSummaries: List) { + allRoomSummariesFlow.emit(roomSummaries) + } + + suspend fun postAllRoomsLoadingState(loadingState: RoomList.LoadingState) { + allRoomsLoadingStateFlow.emit(loadingState) + } + + suspend fun postState(state: RoomListService.State) { + roomListStateFlow.emit(state) + } + + suspend fun postSyncIndicator(value: RoomListService.SyncIndicator) { + syncIndicatorStateFlow.emit(value) + } + + override fun createRoomList( + pageSize: Int, + initialFilter: RoomListFilter, + source: RoomList.Source + ): DynamicRoomList { + return when (source) { + RoomList.Source.All -> allRooms + } + } + + override suspend fun subscribeToVisibleRooms(roomIds: List) { + subscribeToVisibleRoomsLambda(roomIds) + } + + override val allRooms = SimplePagedRoomList( + allRoomSummariesFlow, + allRoomsLoadingStateFlow, + MutableStateFlow(RoomListFilter.all()) + ) + + override val state: StateFlow = roomListStateFlow + + override val syncIndicator: StateFlow = syncIndicatorStateFlow +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/SimplePagedRoomList.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/SimplePagedRoomList.kt new file mode 100644 index 0000000..a63212c --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/SimplePagedRoomList.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.roomlist + +import io.element.android.libraries.matrix.api.roomlist.DynamicRoomList +import io.element.android.libraries.matrix.api.roomlist.RoomList +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.getAndUpdate + +data class SimplePagedRoomList( + override val summaries: MutableStateFlow>, + override val loadingState: StateFlow, + override val currentFilter: MutableStateFlow +) : DynamicRoomList { + override val pageSize: Int = Int.MAX_VALUE + override val loadedPages = MutableStateFlow(1) + + override val filteredSummaries: SharedFlow> = summaries + + override suspend fun loadMore() { + // No-op + loadedPages.getAndUpdate { it + 1 } + } + + override suspend fun reset() { + loadedPages.emit(1) + } + + override suspend fun updateFilter(filter: RoomListFilter) { + currentFilter.emit(filter) + } + + override suspend fun rebuildSummaries() { + // No-op + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeLeaveSpaceHandle.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeLeaveSpaceHandle.kt new file mode 100644 index 0000000..83017d9 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeLeaveSpaceHandle.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.spaces + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle +import io.element.android.libraries.matrix.api.spaces.LeaveSpaceRoom +import io.element.android.libraries.matrix.test.A_SPACE_ID +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask + +class FakeLeaveSpaceHandle( + override val id: RoomId = A_SPACE_ID, + private val roomsResult: () -> Result> = { lambdaError() }, + private val leaveResult: (List) -> Result = { lambdaError() }, + private val closeResult: () -> Unit = { lambdaError() }, +) : LeaveSpaceHandle { + override suspend fun rooms(): Result> = simulateLongTask { + roomsResult() + } + + override suspend fun leave(roomIds: List): Result = simulateLongTask { + leaveResult(roomIds) + } + + override fun close() { + return closeResult() + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceRoomList.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceRoomList.kt new file mode 100644 index 0000000..70d919d --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceRoomList.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.spaces + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.tests.testutils.lambda.lambdaError +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.asStateFlow +import java.util.Optional + +class FakeSpaceRoomList( + override val roomId: RoomId = A_ROOM_ID, + initialSpaceFlowValue: SpaceRoom? = null, + initialSpaceRoomsValue: List = emptyList(), + initialSpaceRoomList: SpaceRoomList.PaginationStatus = SpaceRoomList.PaginationStatus.Loading, + private val paginateResult: () -> Result = { lambdaError() }, +) : SpaceRoomList { + private val currentSpaceMutableStateFlow: MutableStateFlow> = MutableStateFlow(Optional.ofNullable(initialSpaceFlowValue)) + override val currentSpaceFlow: StateFlow> = currentSpaceMutableStateFlow.asStateFlow() + + fun emitCurrentSpace(value: SpaceRoom?) { + currentSpaceMutableStateFlow.value = Optional.ofNullable(value) + } + + private val _spaceRoomsFlow: MutableStateFlow> = MutableStateFlow(initialSpaceRoomsValue) + override val spaceRoomsFlow: Flow> = _spaceRoomsFlow.asStateFlow() + + fun emitSpaceRooms(value: List) { + _spaceRoomsFlow.value = value + } + + private val _paginationStatusFlow: MutableStateFlow = MutableStateFlow(initialSpaceRoomList) + override val paginationStatusFlow: StateFlow = _paginationStatusFlow.asStateFlow() + + fun emitPaginationStatus(value: SpaceRoomList.PaginationStatus) { + _paginationStatusFlow.value = value + } + + override suspend fun paginate(): Result = simulateLongTask { + paginateResult() + } + + override fun destroy() { + // No op + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt new file mode 100644 index 0000000..eaa36ee --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.spaces + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import io.element.android.libraries.matrix.api.spaces.SpaceService +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class FakeSpaceService( + private val joinedSpacesResult: () -> Result> = { lambdaError() }, + private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() }, + private val leaveSpaceHandleResult: (RoomId) -> LeaveSpaceHandle = { lambdaError() }, +) : SpaceService { + private val _spaceRoomsFlow = MutableSharedFlow>() + override val spaceRoomsFlow: SharedFlow> + get() = _spaceRoomsFlow.asSharedFlow() + + suspend fun emitSpaceRoomList(value: List) { + _spaceRoomsFlow.emit(value) + } + + override suspend fun joinedSpaces(): Result> = simulateLongTask { + return joinedSpacesResult() + } + + override fun spaceRoomList(id: RoomId): SpaceRoomList { + return spaceRoomListResult(id) + } + + override fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle { + return leaveSpaceHandleResult(spaceId) + } +} 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 0000000..5bb85fa --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.sync + +import io.element.android.libraries.core.coroutine.mapState +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( + initialSyncState: SyncState = SyncState.Idle, +) : SyncService { + private val syncStateFlow: MutableStateFlow = MutableStateFlow(initialSyncState) + + var startSyncLambda: () -> Result = { Result.success(Unit) } + override suspend fun startSync(): Result { + return startSyncLambda() + } + + var stopSyncLambda: () -> Result = { Result.success(Unit) } + override suspend fun stopSync(): Result { + return stopSyncLambda() + } + + override val syncState: StateFlow = syncStateFlow + + override val isOnline: StateFlow = syncState.mapState { it != SyncState.Offline } + + suspend fun emitSyncState(syncState: SyncState) { + syncStateFlow.emit(syncState) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt new file mode 100644 index 0000000..6115dd2 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt @@ -0,0 +1,453 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.timeline + +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.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.MediaUploadHandler +import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.matrix.api.room.IntentionalMention +import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.ReceiptType +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo +import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import java.io.File + +class FakeTimeline( + private val name: String = "FakeTimeline", + override val timelineItems: Flow> = MutableStateFlow(emptyList()), + override val backwardPaginationStatus: MutableStateFlow = MutableStateFlow( + Timeline.PaginationStatus( + isPaginating = false, + hasMoreToLoad = true + ) + ), + override val forwardPaginationStatus: MutableStateFlow = MutableStateFlow( + Timeline.PaginationStatus( + isPaginating = false, + hasMoreToLoad = false + ) + ), + override val membershipChangeEventReceived: Flow = MutableSharedFlow(), + override val onSyncedEventReceived: Flow = MutableSharedFlow(), + private val cancelSendResult: (TransactionId) -> Result = { lambdaError() }, + override val mode: Timeline.Mode = Timeline.Mode.Live, + private val markAsReadResult: (ReceiptType) -> Result = { lambdaError() }, + private val getLatestEventIdResult: () -> Result = { lambdaError() }, + var sendReadReceiptLambda: ( + eventId: EventId, + receiptType: ReceiptType, + ) -> Result = { _, _ -> + lambdaError() + } +) : Timeline { + var sendMessageLambda: ( + body: String, + htmlBody: String?, + intentionalMentions: List, + ) -> Result = { _, _, _ -> + lambdaError() + } + + override suspend fun cancelSend(transactionId: TransactionId): Result = simulateLongTask { + cancelSendResult(transactionId) + } + + override suspend fun sendMessage( + body: String, + htmlBody: String?, + intentionalMentions: List, + ): Result = simulateLongTask { + sendMessageLambda(body, htmlBody, intentionalMentions) + } + + var redactEventLambda: (eventOrTransactionId: EventOrTransactionId, reason: String?) -> Result = { _, _ -> + lambdaError() + } + + override suspend fun redactEvent( + eventOrTransactionId: EventOrTransactionId, + reason: String? + ): Result = redactEventLambda(eventOrTransactionId, reason) + + var editMessageLambda: ( + eventOrTransactionId: EventOrTransactionId, + body: String, + htmlBody: String?, + intentionalMentions: List, + ) -> Result = { _, _, _, _ -> + lambdaError() + } + + override suspend fun editMessage( + eventOrTransactionId: EventOrTransactionId, + body: String, + htmlBody: String?, + intentionalMentions: List, + ): Result = editMessageLambda( + eventOrTransactionId, + body, + htmlBody, + intentionalMentions + ) + + var editCaptionLambda: ( + eventOrTransactionId: EventOrTransactionId, + caption: String?, + formattedCaption: String?, + ) -> Result = { _, _, _ -> + lambdaError() + } + + override suspend fun editCaption( + eventOrTransactionId: EventOrTransactionId, + caption: String?, + formattedCaption: String?, + ): Result = editCaptionLambda( + eventOrTransactionId, + caption, + formattedCaption, + ) + + var replyMessageLambda: ( + inReplyToEventId: EventId?, + body: String, + htmlBody: String?, + intentionalMentions: List, + fromNotification: Boolean, + ) -> Result = { _, _, _, _, _ -> + lambdaError() + } + + override suspend fun replyMessage( + repliedToEventId: EventId, + body: String, + htmlBody: String?, + intentionalMentions: List, + fromNotification: Boolean, + ): Result = replyMessageLambda( + repliedToEventId, + body, + htmlBody, + intentionalMentions, + fromNotification, + ) + + var sendImageLambda: ( + file: File, + thumbnailFile: File?, + imageInfo: ImageInfo, + body: String?, + formattedBody: String?, + inReplyToEventId: EventId??, + ) -> Result = { _, _, _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + + override suspend fun sendImage( + file: File, + thumbnailFile: File?, + imageInfo: ImageInfo, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId??, + ): Result = simulateLongTask { + sendImageLambda( + file, + thumbnailFile, + imageInfo, + caption, + formattedCaption, + inReplyToEventId, + ) + } + + var sendVideoLambda: ( + file: File, + thumbnailFile: File?, + videoInfo: VideoInfo, + body: String?, + formattedBody: String?, + inReplyToEventId: EventId??, + ) -> Result = { _, _, _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + + override suspend fun sendVideo( + file: File, + thumbnailFile: File?, + videoInfo: VideoInfo, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId??, + ): Result = simulateLongTask { + sendVideoLambda( + file, + thumbnailFile, + videoInfo, + caption, + formattedCaption, + inReplyToEventId, + ) + } + + var sendAudioLambda: ( + file: File, + audioInfo: AudioInfo, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId??, + ) -> Result = { _, _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + + override suspend fun sendAudio( + file: File, + audioInfo: AudioInfo, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId??, + ): Result = simulateLongTask { + sendAudioLambda( + file, + audioInfo, + caption, + formattedCaption, + inReplyToEventId, + ) + } + + var sendFileLambda: ( + file: File, + fileInfo: FileInfo, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId??, + ) -> Result = { _, _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + + override suspend fun sendFile( + file: File, + fileInfo: FileInfo, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId??, + ): Result = simulateLongTask { + sendFileLambda( + file, + fileInfo, + caption, + formattedCaption, + inReplyToEventId, + ) + } + + var sendVoiceMessageLambda: ( + file: File, + audioInfo: AudioInfo, + waveform: List, + inReplyToEventId: EventId??, + ) -> Result = { _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + + override suspend fun sendVoiceMessage( + file: File, + audioInfo: AudioInfo, + waveform: List, + inReplyToEventId: EventId??, + ): Result = simulateLongTask { + sendVoiceMessageLambda( + file, + audioInfo, + waveform, + inReplyToEventId, + ) + } + + var sendLocationLambda: ( + body: String, + geoUri: String, + description: String?, + zoomLevel: Int?, + assetType: AssetType?, + inReplyToEventId: EventId??, + ) -> Result = { _, _, _, _, _, _ -> + lambdaError() + } + + override suspend fun sendLocation( + body: String, + geoUri: String, + description: String?, + zoomLevel: Int?, + assetType: AssetType?, + inReplyToEventId: EventId??, + ): Result = simulateLongTask { + sendLocationLambda( + body, + geoUri, + description, + zoomLevel, + assetType, + inReplyToEventId, + ) + } + + var toggleReactionLambda: (emoji: String, eventOrTransactionId: EventOrTransactionId) -> Result = { _, _ -> lambdaError() } + + override suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result = simulateLongTask { + toggleReactionLambda( + emoji, + eventOrTransactionId, + ) + } + + var forwardEventLambda: (eventId: EventId, roomIds: List) -> Result = { _, _ -> lambdaError() } + + override suspend fun forwardEvent(eventId: EventId, roomIds: List): Result = simulateLongTask { + forwardEventLambda(eventId, roomIds) + } + + var createPollLambda: ( + question: String, + answers: List, + maxSelections: Int, + pollKind: PollKind, + ) -> Result = { _, _, _, _ -> + lambdaError() + } + + override suspend fun createPoll(question: String, answers: List, maxSelections: Int, pollKind: PollKind): Result = simulateLongTask { + createPollLambda( + question, + answers, + maxSelections, + pollKind, + ) + } + + var editPollLambda: ( + pollStartId: EventId, + question: String, + answers: List, + maxSelections: Int, + pollKind: PollKind, + ) -> Result = { _, _, _, _, _ -> + lambdaError() + } + + override suspend fun editPoll( + pollStartId: EventId, + question: String, + answers: List, + maxSelections: Int, + pollKind: PollKind + ): Result = simulateLongTask { + editPollLambda( + pollStartId, + question, + answers, + maxSelections, + pollKind, + ) + } + + var sendPollResponseLambda: ( + pollStartId: EventId, + answers: List, + ) -> Result = { _, _ -> + lambdaError() + } + + override suspend fun sendPollResponse( + pollStartId: EventId, + answers: List, + ): Result = simulateLongTask { + sendPollResponseLambda( + pollStartId, + answers, + ) + } + + var endPollLambda: ( + pollStartId: EventId, + text: String, + ) -> Result = { _, _ -> + lambdaError() + } + + override suspend fun endPoll( + pollStartId: EventId, + text: String, + ): Result = simulateLongTask { + endPollLambda( + pollStartId, + text, + ) + } + + override suspend fun sendReadReceipt( + eventId: EventId, + receiptType: ReceiptType, + ): Result = sendReadReceiptLambda(eventId, receiptType) + + override suspend fun markAsRead(receiptType: ReceiptType): Result { + return markAsReadResult(receiptType) + } + + var paginateLambda: (direction: Timeline.PaginationDirection) -> Result = { + Result.success(false) + } + + override suspend fun paginate(direction: Timeline.PaginationDirection): Result = paginateLambda(direction) + + var loadReplyDetailsLambda: (eventId: EventId) -> InReplyTo = { + InReplyTo.NotLoaded(it) + } + + override suspend fun loadReplyDetails(eventId: EventId) = loadReplyDetailsLambda(eventId) + + var pinEventLambda: (eventId: EventId) -> Result = { lambdaError() } + override suspend fun pinEvent(eventId: EventId): Result { + return pinEventLambda(eventId) + } + + var unpinEventLambda: (eventId: EventId) -> Result = { lambdaError() } + override suspend fun unpinEvent(eventId: EventId): Result { + return unpinEventLambda(eventId) + } + + override suspend fun getLatestEventId(): Result { + return getLatestEventIdResult() + } + + var closeCounter = 0 + private set + + override fun close() { + closeCounter++ + } + + override fun toString() = "FakeTimeline: $name" +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimelineProvider.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimelineProvider.kt new file mode 100644 index 0000000..b163255 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimelineProvider.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.timeline + +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.TimelineProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeTimelineProvider( + initialTimeline: Timeline? = null, +) : TimelineProvider { + private val timelineFlow = MutableStateFlow(initialTimeline) + + override fun activeTimelineFlow(): StateFlow { + return timelineFlow.asStateFlow() + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/LiveTimelineProvider.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/LiveTimelineProvider.kt new file mode 100644 index 0000000..60cee84 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/LiveTimelineProvider.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.timeline + +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.TimelineProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class LiveTimelineProvider( + private val room: JoinedRoom, +) : TimelineProvider { + override fun activeTimelineFlow(): StateFlow = MutableStateFlow(room.liveTimeline) +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt new file mode 100644 index 0000000..c8d8ff6 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.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.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.poll.PollAnswer +import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo +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.MessageShieldProvider +import io.element.android.libraries.matrix.api.timeline.item.event.MessageType +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +import io.element.android.libraries.matrix.api.timeline.item.event.Receipt +import io.element.android.libraries.matrix.api.timeline.item.event.SendHandleProvider +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.TimelineItemDebugInfoProvider +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.A_USER_NAME +import io.element.android.libraries.matrix.test.core.FakeSendHandle +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf + +fun anEventTimelineItem( + eventId: EventId = AN_EVENT_ID, + transactionId: TransactionId? = null, + isEditable: Boolean = false, + canBeRepliedTo: Boolean = false, + isOwn: Boolean = false, + isRemote: Boolean = false, + localSendState: LocalEventSendState? = null, + reactions: ImmutableList = persistentListOf(), + receipts: ImmutableList = persistentListOf(), + sender: UserId = A_USER_ID, + senderProfile: ProfileDetails = aProfileDetails(), + timestamp: Long = 0L, + content: EventContent = aProfileChangeMessageContent(), + debugInfoProvider: TimelineItemDebugInfoProvider = TimelineItemDebugInfoProvider { aTimelineItemDebugInfo() }, + messageShieldProvider: MessageShieldProvider = MessageShieldProvider { null }, + sendHandleProvider: SendHandleProvider = SendHandleProvider { FakeSendHandle() } +) = EventTimelineItem( + eventId = eventId, + transactionId = transactionId, + isEditable = isEditable, + canBeRepliedTo = canBeRepliedTo, + isOwn = isOwn, + isRemote = isRemote, + localSendState = localSendState, + reactions = reactions, + receipts = receipts, + sender = sender, + senderProfile = senderProfile, + timestamp = timestamp, + content = content, + origin = null, + timelineItemDebugInfoProvider = debugInfoProvider, + messageShieldProvider = messageShieldProvider, + sendHandleProvider = sendHandleProvider, +) + +fun aProfileDetails( + displayName: String? = A_USER_NAME, + displayNameAmbiguous: Boolean = false, + avatarUrl: String? = null +): ProfileDetails = ProfileDetails.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, + threadInfo: EventThreadInfo? = null, + messageType: MessageType = TextMessageType( + body = body, + formatted = null + ) +) = MessageContent( + body = body, + inReplyTo = inReplyTo, + isEdited = isEdited, + threadInfo = threadInfo, + type = messageType +) + +fun aStickerContent( + filename: String = "filename", + info: ImageInfo, + mediaSource: MediaSource, + body: String? = null, +) = StickerContent( + filename = filename, + body = body, + info = info, + source = mediaSource, +) + +fun aTimelineItemDebugInfo( + model: String = "Rust(Model())", + originalJson: String? = null, + latestEditedJson: String? = null, +) = TimelineItemDebugInfo( + model, + originalJson, + latestEditedJson +) + +fun aPollContent( + question: String = "Do you like polls?", + answers: ImmutableList = persistentListOf(PollAnswer("1", "Yes"), PollAnswer("2", "No")), + kind: PollKind = PollKind.Disclosed, + maxSelections: ULong = 1u, + votes: ImmutableMap> = persistentMapOf(), + endTime: ULong? = null, + isEdited: Boolean = false, +) = PollContent( + question = question, + kind = kind, + maxSelections = maxSelections, + answers = answers, + votes = votes, + endTime = endTime, + isEdited = isEdited, +) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/item/event/Fixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/item/event/Fixture.kt new file mode 100644 index 0000000..5e659d3 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/item/event/Fixture.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.timeline.item.event + +import io.element.android.libraries.matrix.api.core.UserId +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.libraries.matrix.test.A_USER_ID + +fun aRoomMembershipContent( + userId: UserId = A_USER_ID, + userDisplayName: String? = null, + change: MembershipChange? = null, + reason: String? = null, +) = RoomMembershipContent( + userId = userId, + userDisplayName = userDisplayName, + change = change, + reason = reason, +) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/tracing/FakeTracingService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/tracing/FakeTracingService.kt new file mode 100644 index 0000000..0b57c0a --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/tracing/FakeTracingService.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.tracing + +import io.element.android.libraries.matrix.api.tracing.TracingService +import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration +import io.element.android.tests.testutils.lambda.lambdaError +import timber.log.Timber + +class FakeTracingService( + private val createTimberTreeResult: (String) -> Timber.Tree = { lambdaError() }, + private val updateWriteToFilesConfigurationResult: (WriteToFilesConfiguration) -> Unit = { lambdaError() } +) : TracingService { + override fun createTimberTree(target: String): Timber.Tree { + return createTimberTreeResult(target) + } + + override fun updateWriteToFilesConfiguration(config: WriteToFilesConfiguration) { + updateWriteToFilesConfigurationResult(config) + } +} 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 0000000..f4f12a4 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.verification + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus +import io.element.android.libraries.matrix.api.verification.VerificationFlowState +import io.element.android.libraries.matrix.api.verification.VerificationRequest +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class FakeSessionVerificationService( + initialSessionVerifiedStatus: SessionVerifiedStatus = SessionVerifiedStatus.Unknown, + private val requestCurrentSessionVerificationLambda: () -> Unit = { lambdaError() }, + private val requestUserVerificationLambda: (UserId) -> Unit = { lambdaError() }, + private val cancelVerificationLambda: () -> Unit = { lambdaError() }, + private val approveVerificationLambda: () -> Unit = { lambdaError() }, + private val declineVerificationLambda: () -> Unit = { lambdaError() }, + private val startVerificationLambda: () -> Unit = { lambdaError() }, + private val resetLambda: (Boolean) -> Unit = { lambdaError() }, + private val acknowledgeVerificationRequestLambda: (VerificationRequest.Incoming) -> Unit = { lambdaError() }, + private val acceptVerificationRequestLambda: () -> Unit = { lambdaError() }, +) : SessionVerificationService { + private val _sessionVerifiedStatus = MutableStateFlow(initialSessionVerifiedStatus) + private var _verificationFlowState = MutableStateFlow(VerificationFlowState.Initial) + private var _needsSessionVerification = MutableStateFlow(true) + + override val verificationFlowState: StateFlow = _verificationFlowState + override val sessionVerifiedStatus: StateFlow = _sessionVerifiedStatus + override val needsSessionVerification: Flow = _needsSessionVerification + + override suspend fun requestCurrentSessionVerification() { + requestCurrentSessionVerificationLambda() + } + + override suspend fun requestUserVerification(userId: UserId) { + requestUserVerificationLambda(userId) + } + + override suspend fun cancelVerification() { + cancelVerificationLambda() + } + + override suspend fun approveVerification() { + approveVerificationLambda() + } + + override suspend fun declineVerification() { + declineVerificationLambda() + } + + override suspend fun startVerification() { + startVerificationLambda() + } + + override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) { + resetLambda(cancelAnyPendingVerificationAttempt) + } + + var listener: SessionVerificationServiceListener? = null + private set + + override fun setListener(listener: SessionVerificationServiceListener?) { + this.listener = listener + } + + override suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming) { + acknowledgeVerificationRequestLambda(verificationRequest) + } + + override suspend fun acceptVerificationRequest() = simulateLongTask { + acceptVerificationRequestLambda() + } + + suspend fun emitVerificationFlowState(state: VerificationFlowState) { + _verificationFlowState.emit(state) + } + + suspend fun emitVerifiedStatus(status: SessionVerifiedStatus) { + _sessionVerifiedStatus.emit(status) + } + + suspend fun emitNeedsSessionVerification(needsVerification: Boolean) { + _needsSessionVerification.emit(needsVerification) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeCallWidgetSettingsProvider.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeCallWidgetSettingsProvider.kt new file mode 100644 index 0000000..a91868c --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeCallWidgetSettingsProvider.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.widget + +import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider +import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings + +class FakeCallWidgetSettingsProvider( + private val provideFn: (String, String, Boolean, Boolean, Boolean) -> MatrixWidgetSettings = { _, _, _, _, _ -> MatrixWidgetSettings("id", true, "url") } +) : CallWidgetSettingsProvider { + val providedBaseUrls = mutableListOf() + + override suspend fun provide( + baseUrl: String, + widgetId: String, + encrypted: Boolean, + direct: Boolean, + hasActiveCall: Boolean + ): MatrixWidgetSettings { + providedBaseUrls += baseUrl + return provideFn(baseUrl, widgetId, encrypted, direct, hasActiveCall) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeMatrixWidgetDriver.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeMatrixWidgetDriver.kt new file mode 100644 index 0000000..5540ba8 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeMatrixWidgetDriver.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.widget + +import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver +import kotlinx.coroutines.flow.MutableSharedFlow +import java.util.UUID + +class FakeMatrixWidgetDriver( + override val id: String = UUID.randomUUID().toString(), +) : MatrixWidgetDriver { + private val _sentMessages = mutableListOf() + val sentMessages: List = _sentMessages + + var runCalledCount = 0 + private set + var closeCalledCount = 0 + private set + + override val incomingMessages = MutableSharedFlow(extraBufferCapacity = 1) + + override suspend fun run() { + runCalledCount++ + } + + override suspend fun send(message: String) { + _sentMessages.add(message) + } + + override fun close() { + closeCalledCount++ + } + + fun givenIncomingMessage(message: String) { + incomingMessages.tryEmit(message) + } +} diff --git a/libraries/matrixmedia/api/build.gradle.kts b/libraries/matrixmedia/api/build.gradle.kts new file mode 100644 index 0000000..90c7ee6 --- /dev/null +++ b/libraries/matrixmedia/api/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.matrix.ui.media.api" +} + +dependencies { + implementation(projects.libraries.designsystem) + implementation(projects.libraries.matrix.api) + implementation(libs.coil.compose) +} diff --git a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/Avatar.kt b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/Avatar.kt new file mode 100644 index 0000000..4d4dd40 --- /dev/null +++ b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/Avatar.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media + +/** + * The size in pixel of the thumbnail to generate for the avatar. + * This is not the size of the avatar displayed in the UI but the size to get from the servers. + * Servers SHOULD produce thumbnails with the following dimensions and methods: + * + * 32x32, crop + * 96x96, crop + * 320x240, scale + * 640x480, scale + * 800x600, scale + * + * Let's always use the same size so coil caching works properly. + */ +const val AVATAR_THUMBNAIL_SIZE_IN_PIXEL = 240L diff --git a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderHolder.kt b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderHolder.kt new file mode 100644 index 0000000..62b1697 --- /dev/null +++ b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderHolder.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media + +import coil3.ImageLoader +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId + +interface ImageLoaderHolder { + fun get(): ImageLoader + fun get(client: MatrixClient): ImageLoader + fun remove(sessionId: SessionId) +} diff --git a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/InitialsAvatarBitmapGenerator.kt b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/InitialsAvatarBitmapGenerator.kt new file mode 100644 index 0000000..e6ff5d7 --- /dev/null +++ b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/InitialsAvatarBitmapGenerator.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media + +import android.graphics.Bitmap +import io.element.android.libraries.designsystem.components.avatar.AvatarData + +/** + * Generates a bitmap for an initials avatar based on the provided [io.element.android.libraries.designsystem.components.avatar.AvatarData]. + */ +interface InitialsAvatarBitmapGenerator { + fun generateBitmap( + size: Int, + avatarData: AvatarData, + useDarkTheme: Boolean, + fontSizePercentage: Float = 0.5f, + ): Bitmap? +} diff --git a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt new file mode 100644 index 0000000..47841a5 --- /dev/null +++ b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media + +import io.element.android.libraries.matrix.api.media.MediaSource + +/** + * Can be use with [coil3.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 { + data object Content : Kind + + data class File( + val fileName: String, + val mimeType: String, + ) : Kind + + data class Thumbnail(val width: Long, val height: Long) : Kind { + constructor(size: Long) : this(size, size) + } + } +} + +/** Max width a thumbnail can have according to [the spec](https://spec.matrix.org/v1.10/client-server-api/#thumbnails). */ +const val MAX_THUMBNAIL_WIDTH = 800L + +/** Max height a thumbnail can have according to [the spec](https://spec.matrix.org/v1.10/client-server-api/#thumbnails). */ +const val MAX_THUMBNAIL_HEIGHT = 600L diff --git a/libraries/matrixmedia/impl/build.gradle.kts b/libraries/matrixmedia/impl/build.gradle.kts new file mode 100644 index 0000000..82afc2f --- /dev/null +++ b/libraries/matrixmedia/impl/build.gradle.kts @@ -0,0 +1,33 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.matrix.ui.media.impl" +} + +setupDependencyInjection() + +dependencies { + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixmedia.api) + implementation(projects.libraries.designsystem) + implementation(libs.coil.compose) + implementation(libs.coil.gif) + implementation(libs.coil.network.okhttp) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.dateformatter.test) + testImplementation(projects.libraries.sessionStorage.test) +} diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataExt.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataExt.kt new file mode 100644 index 0000000..0b1a09b --- /dev/null +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataExt.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 + +internal fun AvatarData.toMediaRequestData(): MediaRequestData { + return MediaRequestData( + source = url?.let { MediaSource(it) }, + kind = MediaRequestData.Kind.Thumbnail(AVATAR_THUMBNAIL_SIZE_IN_PIXEL) + ) +} diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactory.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactory.kt new file mode 100644 index 0000000..b6093cd --- /dev/null +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactory.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media + +import coil3.ImageLoader +import coil3.fetch.Fetcher +import coil3.request.Options +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader + +internal class AvatarDataFetcherFactory( + private val matrixMediaLoader: MatrixMediaLoader +) : Fetcher.Factory { + override fun create( + data: AvatarData, + options: Options, + imageLoader: ImageLoader + ): Fetcher { + return CoilMediaFetcher( + mediaLoader = matrixMediaLoader, + mediaData = data.toMediaRequestData(), + ) + } +} diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt new file mode 100644 index 0000000..06321f6 --- /dev/null +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media + +import coil3.decode.DataSource +import coil3.decode.ImageSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.SourceFetchResult +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.FileSystem +import okio.Path.Companion.toOkioPath +import timber.log.Timber +import java.nio.ByteBuffer + +internal class CoilMediaFetcher( + private val mediaLoader: MatrixMediaLoader, + private val mediaData: MediaRequestData, +) : Fetcher { + override suspend fun fetch(): FetchResult? { + val source = mediaData.source + if (source == null) { + Timber.e("MediaData source is null") + return null + } + return when (val kind = mediaData.kind) { + is MediaRequestData.Kind.Content -> fetchContent(source) + is MediaRequestData.Kind.Thumbnail -> fetchThumbnail(source, kind) + is MediaRequestData.Kind.File -> fetchFile(source, 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.fileName) + .map { mediaFile -> + val file = mediaFile.toFile() + SourceFetchResult( + source = ImageSource( + file = file.toOkioPath(), + fileSystem = FileSystem.SYSTEM, + closeable = mediaFile, + ), + mimeType = null, + dataSource = DataSource.DISK + ) + } + .onFailure { + Timber.e(it) + } + .getOrNull() + } + + private suspend fun fetchContent(mediaSource: MediaSource): FetchResult? { + return mediaLoader.loadMediaContent( + source = mediaSource, + ).map { byteArray -> + byteArray.asSourceResult() + }.onFailure { + Timber.e(it) + }.getOrNull() + } + + private suspend fun fetchThumbnail(mediaSource: MediaSource, kind: MediaRequestData.Kind.Thumbnail): FetchResult? { + return mediaLoader.loadMediaThumbnail( + source = mediaSource, + width = kind.width, + height = kind.height, + ).map { byteArray -> + byteArray.asSourceResult() + }.onFailure { + Timber.e(it) + }.getOrNull() + } + + private fun ByteArray.asSourceResult(): SourceFetchResult { + val byteBuffer = ByteBuffer.wrap(this) + val bufferedSource = try { + Buffer().apply { write(byteBuffer) } + } finally { + byteBuffer.position(0) + } + return SourceFetchResult( + source = ImageSource( + source = bufferedSource, + fileSystem = FileSystem.SYSTEM, + ), + mimeType = null, + dataSource = DataSource.MEMORY + ) + } +} diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolder.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolder.kt new file mode 100644 index 0000000..a0957a3 --- /dev/null +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolder.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media + +import coil3.ImageLoader +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.sessionstorage.api.observer.SessionListener +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class DefaultImageLoaderHolder( + private val imageLoaderFactory: ImageLoaderFactory, + private val sessionObserver: SessionObserver, +) : ImageLoaderHolder { + private val map = mutableMapOf() + private val notLoggedInImageLoader by lazy { + imageLoaderFactory.newImageLoader() + } + + init { + observeSessions() + } + + private fun observeSessions() { + sessionObserver.addListener(object : SessionListener { + override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) { + remove(SessionId(userId)) + } + }) + } + + override fun get(): ImageLoader { + return notLoggedInImageLoader + } + + override fun get(client: MatrixClient): ImageLoader { + return synchronized(map) { + map.getOrPut(client.sessionId) { + imageLoaderFactory + .newImageLoader(client.matrixMediaLoader) + } + } + } + + override fun remove(sessionId: SessionId) { + synchronized(map) { + map.remove(sessionId) + } + } +} diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultInitialsAvatarBitmapGenerator.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultInitialsAvatarBitmapGenerator.kt new file mode 100644 index 0000000..71c894f --- /dev/null +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultInitialsAvatarBitmapGenerator.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.Typeface +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.core.graphics.createBitmap +import coil3.Bitmap +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.compound.theme.AvatarColors +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.SemanticColors +import io.element.android.compound.tokens.generated.compoundColorsDark +import io.element.android.compound.tokens.generated.compoundColorsLight +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text + +@ContributesBinding(AppScope::class) +class DefaultInitialsAvatarBitmapGenerator : InitialsAvatarBitmapGenerator { + // List of predefined avatar colors to use for initials avatars, in light mode + private val lightAvatarColors: List = compoundColorsLight.buildAvatarColors() + + // List of predefined avatar colors to use for initials avatars, in dark mode + private val darkAvatarColors: List = compoundColorsDark.buildAvatarColors() + + /** + * Generates a bitmap for an avatar with no URL, using the initials from the [AvatarData]. + * @param size The size of the bitmap to generate, in pixels. + * @param avatarData The [AvatarData] containing the initials and other information. + * @param useDarkTheme Whether the theme is dark. + * @param fontSizePercentage The percentage of the avatar size to use for the font size. + */ + override fun generateBitmap( + size: Int, + avatarData: AvatarData, + useDarkTheme: Boolean, + fontSizePercentage: Float, + ): Bitmap? { + if (avatarData.url != null) { + // This generator is only for initials avatars, not for avatars with URLs + return null + } + + // Get the color pair to use for the initials avatar + val colors = if (useDarkTheme) darkAvatarColors else lightAvatarColors + val avatarColors = colors[avatarData.id.sumOf { it.code } % colors.size] + + val bitmap = createBitmap(size, size) + Canvas(bitmap).run { + drawColor(avatarColors.background.toArgb()) + val letter = avatarData.initialLetter + + val textPaint = Paint().apply { + color = avatarColors.foreground.toArgb() + textSize = size * fontSizePercentage // Adjust text size relative to the avatar size + isAntiAlias = true + textAlign = Paint.Align.CENTER + typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + } + val bounds = Rect() + textPaint.getTextBounds(letter, 0, letter.length, bounds) + drawText( + letter, + size / 2f, + size.toFloat() / 2 - (textPaint.descent() + textPaint.ascent()) / 2, + textPaint + ) + } + + return bitmap + } +} + +private fun SemanticColors.buildAvatarColors(): List = listOf( + AvatarColors(background = bgDecorative1, foreground = textDecorative1), + AvatarColors(background = bgDecorative2, foreground = textDecorative2), + AvatarColors(background = bgDecorative3, foreground = textDecorative3), + AvatarColors(background = bgDecorative4, foreground = textDecorative4), + AvatarColors(background = bgDecorative5, foreground = textDecorative5), + AvatarColors(background = bgDecorative6, foreground = textDecorative6), +) + +@Composable +@PreviewsDayNight +internal fun InitialsAvatarBitmapGeneratorPreview() = ElementPreview { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + val generator = remember { DefaultInitialsAvatarBitmapGenerator() } + repeat(6) { index -> + val avatarData = remember { AvatarData(id = index.toString(), name = Char('0'.code + index).toString(), size = AvatarSize.IncomingCall) } + val isLightTheme = ElementTheme.isLightTheme + val bitmap = remember(isLightTheme) { + generator.generateBitmap( + size = 512, + avatarData = avatarData, + useDarkTheme = !isLightTheme, + )?.asImageBitmap() + } + bitmap?.let { + Image(bitmap = it, contentDescription = null, modifier = Modifier.size(48.dp)) + } ?: Text("No avatar generated") + } + } +} diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt new file mode 100644 index 0000000..e67e630 --- /dev/null +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media + +import android.content.Context +import android.os.Build +import coil3.ImageLoader +import coil3.gif.AnimatedImageDecoder +import coil3.gif.GifDecoder +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Provider +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import okhttp3.OkHttpClient + +interface ImageLoaderFactory { + fun newImageLoader(): ImageLoader + fun newImageLoader(matrixMediaLoader: MatrixMediaLoader): ImageLoader +} + +@ContributesBinding(AppScope::class) +class DefaultImageLoaderFactory( + @ApplicationContext private val context: Context, + private val okHttpClient: Provider, +) : ImageLoaderFactory { + private val okHttpNetworkFetcherFactory = OkHttpNetworkFetcherFactory( + callFactory = { + // Use newBuilder, see https://coil-kt.github.io/coil/network/#using-a-custom-okhttpclient + okHttpClient().newBuilder().build() + } + ) + + override fun newImageLoader(): ImageLoader { + return ImageLoader.Builder(context) + .components { + add(okHttpNetworkFetcherFactory) + } + .build() + } + + override fun newImageLoader(matrixMediaLoader: MatrixMediaLoader): ImageLoader { + return ImageLoader.Builder(context) + .components { + add(okHttpNetworkFetcherFactory) + // Add gif support + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + add(AnimatedImageDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + add(AvatarDataKeyer()) + add(MediaRequestDataKeyer()) + add(AvatarDataFetcherFactory(matrixMediaLoader)) + add(MediaRequestDataFetcherFactory(matrixMediaLoader)) + } + .build() + } +} diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataFetcherFactory.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataFetcherFactory.kt new file mode 100644 index 0000000..f0cfd20 --- /dev/null +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataFetcherFactory.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media + +import coil3.ImageLoader +import coil3.fetch.Fetcher +import coil3.request.Options +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader + +internal class MediaRequestDataFetcherFactory( + private val matrixMediaLoader: MatrixMediaLoader, +) : Fetcher.Factory { + override fun create( + data: MediaRequestData, + options: Options, + imageLoader: ImageLoader + ): Fetcher { + return CoilMediaFetcher( + mediaLoader = matrixMediaLoader, + mediaData = data, + ) + } +} diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt new file mode 100644 index 0000000..488803e --- /dev/null +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media + +import coil3.key.Keyer +import coil3.request.Options +import io.element.android.libraries.designsystem.components.avatar.AvatarData + +internal class AvatarDataKeyer : Keyer { + override fun key(data: AvatarData, options: Options): String? { + return data.toMediaRequestData().toKey() + } +} + +internal class MediaRequestDataKeyer : Keyer { + override fun key(data: MediaRequestData, options: Options): String? { + return data.toKey() + } +} + +private fun MediaRequestData.toKey(): String? { + return source?.let { "${it.url}_$kind" } +} diff --git a/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolderTest.kt b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolderTest.kt new file mode 100644 index 0000000..dd9127a --- /dev/null +++ b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolderTest.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media + +import androidx.test.platform.app.InstrumentationRegistry +import coil3.ImageLoader +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver +import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultImageLoaderHolderTest { + @Test + fun `get - returns the same ImageLoader for the same client`() { + val context = InstrumentationRegistry.getInstrumentation().context + val lambda = lambdaRecorder { ImageLoader.Builder(context).build() } + + val holder = createDefaultImageLoaderHolder( + imageLoaderFactory = FakeImageLoaderFactory( + newMatrixImageLoaderLambda = lambda, + ), + ) + val client = FakeMatrixClient() + val imageLoader1 = holder.get(client) + val imageLoader2 = holder.get(client) + assert(imageLoader1 === imageLoader2) + lambda.assertions() + .isCalledOnce() + .with(value(client.matrixMediaLoader)) + } + + @Test + fun `when session is deleted, the image loader is deleted`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val lambda = + lambdaRecorder { ImageLoader.Builder(context).build() } + val sessionObserver = FakeSessionObserver() + val holder = DefaultImageLoaderHolder( + imageLoaderFactory = FakeImageLoaderFactory( + newMatrixImageLoaderLambda = lambda, + ), + sessionObserver = sessionObserver, + ) + assertThat(sessionObserver.listeners.size).isEqualTo(1) + val client = FakeMatrixClient() + holder.get(client) + sessionObserver.onSessionDeleted(client.sessionId.value) + holder.get(client) + lambda.assertions() + .isCalledExactly(2) + } + + @Test + fun `when session is created, nothing happen`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val lambda = + lambdaRecorder { ImageLoader.Builder(context).build() } + val sessionObserver = FakeSessionObserver() + DefaultImageLoaderHolder( + imageLoaderFactory = FakeImageLoaderFactory( + newMatrixImageLoaderLambda = lambda, + ), + sessionObserver = sessionObserver, + ) + assertThat(sessionObserver.listeners.size).isEqualTo(1) + sessionObserver.onSessionCreated(A_SESSION_ID.value) + } +} + +private fun createDefaultImageLoaderHolder( + imageLoaderFactory: ImageLoaderFactory = FakeImageLoaderFactory(), + sessionObserver: SessionObserver = NoOpSessionObserver(), +) = DefaultImageLoaderHolder( + imageLoaderFactory = imageLoaderFactory, + sessionObserver = sessionObserver, +) diff --git a/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/FakeImageLoaderFactory.kt b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/FakeImageLoaderFactory.kt new file mode 100644 index 0000000..bcafad0 --- /dev/null +++ b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/FakeImageLoaderFactory.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media + +import coil3.ImageLoader +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeImageLoaderFactory( + private val newImageLoaderLambda: () -> ImageLoader = { lambdaError() }, + private val newMatrixImageLoaderLambda: (MatrixMediaLoader) -> ImageLoader = { lambdaError() }, +) : ImageLoaderFactory { + override fun newImageLoader(): ImageLoader { + return newImageLoaderLambda() + } + + override fun newImageLoader(matrixMediaLoader: MatrixMediaLoader): ImageLoader { + return newMatrixImageLoaderLambda(matrixMediaLoader) + } +} diff --git a/libraries/matrixmedia/test/build.gradle.kts b/libraries/matrixmedia/test/build.gradle.kts new file mode 100644 index 0000000..5b8e966 --- /dev/null +++ b/libraries/matrixmedia/test/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.matrix.ui.media.test" +} + +dependencies { + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixmedia.api) + implementation(projects.libraries.designsystem) + implementation(projects.tests.testutils) + implementation(libs.coil.compose) +} diff --git a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoader.kt b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoader.kt new file mode 100644 index 0000000..d3cd738 --- /dev/null +++ b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoader.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media.test + +import coil3.ComponentRegistry +import coil3.ImageLoader +import coil3.disk.DiskCache +import coil3.memory.MemoryCache +import coil3.request.Disposable +import coil3.request.ImageRequest +import coil3.request.ImageResult + +class FakeImageLoader : ImageLoader { + private val executedRequests = mutableListOf() + + override val defaults: ImageRequest.Defaults + get() = error("Not implemented") + override val components: ComponentRegistry + get() = error("Not implemented") + override val memoryCache: MemoryCache? + get() = error("Not implemented") + override val diskCache: DiskCache? + get() = error("Not implemented") + + override fun enqueue(request: ImageRequest): Disposable { + error("Not implemented") + } + + override suspend fun execute(request: ImageRequest): ImageResult { + executedRequests.add(request) + error("Not implemented") + } + + override fun shutdown() { + error("Not implemented") + } + + override fun newBuilder(): ImageLoader.Builder { + error("Not implemented") + } + + fun getExecutedRequestsData(): List { + return executedRequests.map { it.data } + } +} diff --git a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoaderHolder.kt b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoaderHolder.kt new file mode 100644 index 0000000..59b9efd --- /dev/null +++ b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoaderHolder.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media.test + +import coil3.ImageLoader +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder + +class FakeImageLoaderHolder( + val fakeImageLoader: ImageLoader = FakeImageLoader(), +) : ImageLoaderHolder { + override fun get(): ImageLoader { + return fakeImageLoader + } + + override fun get(client: MatrixClient): ImageLoader { + return fakeImageLoader + } + + override fun remove(sessionId: SessionId) { + // No-op + } +} diff --git a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeInitialsAvatarBitmapGenerator.kt b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeInitialsAvatarBitmapGenerator.kt new file mode 100644 index 0000000..2d0df75 --- /dev/null +++ b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeInitialsAvatarBitmapGenerator.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media.test + +import coil3.Bitmap +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.matrix.ui.media.InitialsAvatarBitmapGenerator +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeInitialsAvatarBitmapGenerator( + private val generateBitmapResult: (Int, AvatarData, Boolean, Float) -> Bitmap? = { _, _, _, _ -> lambdaError() } +) : InitialsAvatarBitmapGenerator { + override fun generateBitmap( + size: Int, + avatarData: AvatarData, + useDarkTheme: Boolean, + fontSizePercentage: Float, + ): Bitmap? { + return generateBitmapResult(size, avatarData, useDarkTheme, fontSizePercentage) + } +} diff --git a/libraries/matrixui/build.gradle.kts b/libraries/matrixui/build.gradle.kts new file mode 100644 index 0000000..94aa388 --- /dev/null +++ b/libraries/matrixui/build.gradle.kts @@ -0,0 +1,46 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.libraries.matrix.ui" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.libraries.di) + implementation(projects.libraries.architecture) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixmedia.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.core) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.testtags) + implementation(libs.coil.compose) + implementation(libs.jsoup) + implementation(projects.libraries.previewutils) + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.dateformatter.test) + testImplementation(projects.libraries.sessionStorage.test) +} 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 0000000..8ffdc1b --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +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.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.PinIcon +import io.element.android.libraries.designsystem.components.blurhash.BlurHashAsyncImage +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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 if (info.blurHash != null) { + BlurHashAsyncImage( + model = null, + blurHash = info.blurHash, + contentDescription = info.textContent, + contentScale = ContentScale.Crop, + modifier = modifier, + ) + } else { + Box( + modifier = modifier.background(backgroundColor), + contentAlignment = Alignment.Center + ) { + when (info.type) { + AttachmentThumbnailType.Image -> { + Icon( + imageVector = CompoundIcons.Image(), + contentDescription = info.textContent, + ) + } + AttachmentThumbnailType.Video -> { + Icon( + imageVector = CompoundIcons.VideoCall(), + contentDescription = info.textContent, + ) + } + AttachmentThumbnailType.Audio -> { + Icon( + imageVector = CompoundIcons.Audio(), + contentDescription = info.textContent, + ) + } + AttachmentThumbnailType.Voice -> { + Icon( + imageVector = CompoundIcons.MicOnSolid(), + contentDescription = info.textContent, + ) + } + AttachmentThumbnailType.File -> { + Icon( + imageVector = CompoundIcons.Attachment(), + contentDescription = info.textContent, + modifier = Modifier.rotate(-45f) + ) + } + AttachmentThumbnailType.Location -> { + PinIcon( + modifier = Modifier.fillMaxSize() + ) + /* + // For coherency across the app, we should us this instead. Waiting for design decision. + Icon( + resourceId = R.drawable.ic_september_location, + contentDescription = info.textContent, + ) + */ + } + AttachmentThumbnailType.Poll -> { + Icon( + imageVector = CompoundIcons.Polls(), + contentDescription = info.textContent, + ) + } + } + } + } +} + +@Parcelize +enum class AttachmentThumbnailType : Parcelable { + Image, + Video, + File, + Audio, + Location, + Voice, + Poll, +} + +@Parcelize +data class AttachmentThumbnailInfo( + val type: AttachmentThumbnailType, + val thumbnailSource: MediaSource? = null, + val textContent: String? = null, + val blurHash: String? = null, +) : Parcelable + +@PreviewsDayNight +@Composable +internal fun AttachmentThumbnailPreview(@PreviewParameter(AttachmentThumbnailInfoProvider::class) data: AttachmentThumbnailInfo) = ElementPreview { + AttachmentThumbnail( + info = data, + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(4.dp)) + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnailInfoProvider.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnailInfoProvider.kt new file mode 100644 index 0000000..ea38a97 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnailInfoProvider.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.media.MediaSource + +open class AttachmentThumbnailInfoProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anAttachmentThumbnailInfo(type = AttachmentThumbnailType.Image), + anAttachmentThumbnailInfo(type = AttachmentThumbnailType.Image, blurHash = A_BLUR_HASH), + anAttachmentThumbnailInfo(type = AttachmentThumbnailType.Video), + anAttachmentThumbnailInfo(type = AttachmentThumbnailType.Video, blurHash = A_BLUR_HASH), + anAttachmentThumbnailInfo(type = AttachmentThumbnailType.Audio), + anAttachmentThumbnailInfo(type = AttachmentThumbnailType.File), + anAttachmentThumbnailInfo(type = AttachmentThumbnailType.Location), + anAttachmentThumbnailInfo(type = AttachmentThumbnailType.Voice), + anAttachmentThumbnailInfo(type = AttachmentThumbnailType.Poll), + ) +} + +fun anAttachmentThumbnailInfo( + type: AttachmentThumbnailType, + thumbnailSource: MediaSource? = null, + textContent: String? = null, + blurHash: String? = null, +) = + AttachmentThumbnailInfo( + type = type, + thumbnailSource = thumbnailSource, + textContent = textContent, + blurHash = blurHash, + ) + +const val A_BLUR_HASH = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr" 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 0000000..4628833 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.libraries.matrix.ui.components + +import androidx.activity.compose.BackHandler +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.material3.ExperimentalMaterial3Api +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 io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.hide +import io.element.android.libraries.matrix.ui.media.AvatarAction +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AvatarActionBottomSheet( + actions: ImmutableList, + isVisible: Boolean, + onSelectAction: (action: AvatarAction) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val coroutineScope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + BackHandler(enabled = isVisible) { + sheetState.hide(coroutineScope, then = { onDismiss() }) + } + + fun onItemActionClick(itemAction: AvatarAction) { + onSelectAction(itemAction) + sheetState.hide(coroutineScope, then = { onDismiss() }) + } + + if (isVisible) { + ModalBottomSheet( + onDismissRequest = { + sheetState.hide(coroutineScope, then = { onDismiss() }) + }, + modifier = modifier, + sheetState = sheetState, + ) { + AvatarActionBottomSheetContent( + actions = actions, + onActionClick = ::onItemActionClick, + modifier = Modifier + .navigationBarsPadding() + .imePadding() + ) + } + } +} + +@Composable +private fun AvatarActionBottomSheetContent( + actions: ImmutableList, + modifier: Modifier = Modifier, + onActionClick: (AvatarAction) -> Unit = { }, +) { + LazyColumn( + modifier = modifier.fillMaxWidth() + ) { + items( + items = actions, + ) { action -> + ListItem( + modifier = Modifier.clickable { onActionClick(action) }, + headlineContent = { + Text( + text = stringResource(action.titleResId), + style = ElementTheme.typography.fontBodyLgRegular, + color = if (action.destructive) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textPrimary, + ) + }, + leadingContent = ListItemContent.Icon(IconSource.Resource(action.iconResourceId)), + style = when { + action.destructive -> ListItemStyle.Destructive + else -> ListItemStyle.Primary + } + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun AvatarActionBottomSheetPreview() = ElementPreview { + AvatarActionBottomSheet( + actions = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto, AvatarAction.Remove), + isVisible = true, + onSelectAction = { }, + onDismiss = { }, + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableUserRow.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableUserRow.kt new file mode 100644 index 0000000..fbaa827 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableUserRow.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.runtime.Immutable +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 io.element.android.libraries.designsystem.atomic.atoms.SelectedIndicatorAtom +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.HorizontalDivider +import io.element.android.libraries.matrix.ui.model.getAvatarData + +@Composable +fun CheckableUserRow( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + data: CheckableUserRowData, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(role = Role.Checkbox, enabled = enabled) { + onCheckedChange(!checked) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + val rowModifier = Modifier.weight(1f) + when (data) { + is CheckableUserRowData.Resolved -> { + UserRow( + modifier = rowModifier, + avatarData = data.avatarData, + name = data.name, + subtext = data.subtext, + enabled = enabled, + ) + } + is CheckableUserRowData.Unresolved -> { + UnresolvedUserRow( + modifier = rowModifier, + avatarData = data.avatarData, + id = data.id, + enabled = enabled, + ) + } + } + SelectedIndicatorAtom( + modifier = Modifier.padding(end = 24.dp), + checked = checked, + enabled = enabled, + ) + } +} + +@Immutable +sealed interface CheckableUserRowData { + data class Resolved( + val avatarData: AvatarData, + val name: String, + val subtext: String?, + ) : CheckableUserRowData + + data class Unresolved( + val avatarData: AvatarData, + val id: String, + ) : CheckableUserRowData +} + +@Preview +@Composable +internal fun CheckableResolvedUserRowPreview() = ElementThemedPreview { + val matrixUser = aMatrixUser() + val data = CheckableUserRowData.Resolved( + avatarData = matrixUser.getAvatarData(AvatarSize.UserListItem), + name = matrixUser.displayName.orEmpty(), + subtext = matrixUser.userId.value, + ) + Column { + CheckableUserRow( + checked = false, + onCheckedChange = { }, + data = data, + ) + HorizontalDivider() + CheckableUserRow( + checked = true, + onCheckedChange = { }, + data = data, + ) + HorizontalDivider() + CheckableUserRow( + checked = false, + onCheckedChange = { }, + data = data, + enabled = false, + ) + HorizontalDivider() + CheckableUserRow( + checked = true, + onCheckedChange = { }, + data = data, + enabled = false, + ) + } +} + +@Preview +@Composable +internal fun CheckableUnresolvedUserRowPreview() = ElementThemedPreview { + val matrixUser = aMatrixUser() + val data = CheckableUserRowData.Unresolved( + avatarData = matrixUser.getAvatarData(AvatarSize.UserListItem), + id = matrixUser.userId.value, + ) + Column { + CheckableUserRow( + checked = false, + onCheckedChange = { }, + data = data, + ) + HorizontalDivider() + CheckableUserRow( + checked = true, + onCheckedChange = { }, + data = data, + ) + HorizontalDivider() + CheckableUserRow( + checked = false, + onCheckedChange = { }, + data = data, + enabled = false, + ) + HorizontalDivider() + CheckableUserRow( + checked = true, + onCheckedChange = { }, + data = data, + enabled = false, + ) + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CreateDmConfirmationBottomSheet.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CreateDmConfirmationBottomSheet.kt new file mode 100644 index 0000000..9593594 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CreateDmConfirmationBottomSheet.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components + +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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +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.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.R +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.matrix.ui.model.getFullName +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * Figma: + * https://www.figma.com/design/dywzKQvHYxFD1Ncn4a5NkI/PSB-675%253A-Improve-invite-into-a-DM?node-id=12-36886 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateDmConfirmationBottomSheet( + matrixUser: MatrixUser, + onSendInvite: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismiss, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(24.dp)) + Avatar( + avatarData = matrixUser.getAvatarData(AvatarSize.DmCreationConfirmation), + avatarType = AvatarType.User, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.screen_bottom_sheet_create_dm_title), + style = ElementTheme.typography.fontHeadingMdBold, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.screen_bottom_sheet_create_dm_message, matrixUser.getFullName()), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(40.dp)) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = onSendInvite, + leadingIcon = IconSource.Vector(CompoundIcons.UserAdd()), + text = stringResource(R.string.screen_bottom_sheet_create_dm_confirmation_button_title), + ) + Spacer(modifier = Modifier.height(16.dp)) + TextButton( + modifier = Modifier.fillMaxWidth(), + onClick = onDismiss, + text = stringResource(CommonStrings.action_cancel), + ) + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@PreviewsDayNight +@Composable +internal fun CreateDmConfirmationBottomSheetPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreview { + CreateDmConfirmationBottomSheet( + matrixUser = matrixUser, + onSendInvite = {}, + onDismiss = {}, + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/EditableAvatarView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/EditableAvatarView.kt new file mode 100644 index 0000000..889c977 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/EditableAvatarView.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components + +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ripple +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.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +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.avatar.AvatarType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.utils.CommonDrawables +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun EditableAvatarView( + matrixId: String, + displayName: String?, + avatarUrl: String?, + avatarSize: AvatarSize, + avatarType: AvatarType, + onAvatarClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val a11yAvatar = stringResource(CommonStrings.a11y_avatar) + Box( + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + onClickLabel = stringResource(CommonStrings.a11y_edit_avatar), + onClick = onAvatarClick, + indication = ripple(bounded = false), + ) + .testTag(TestTags.editAvatar) + .clearAndSetSemantics { + contentDescription = a11yAvatar + }, + ) { + when { + avatarUrl == null || avatarUrl.startsWith("mxc://") -> { + Avatar( + avatarData = AvatarData( + id = matrixId, + name = displayName, + url = avatarUrl, + size = avatarSize, + ), + avatarType = avatarType, + ) + } + else -> { + UnsavedAvatar( + avatarUri = avatarUrl, + avatarSize = avatarSize, + avatarType = avatarType, + ) + } + } + + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .clip(CircleShape) + .background(ElementTheme.colors.iconPrimary) + .size(24.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = CompoundIcons.EditSolid(), + contentDescription = null, + tint = ElementTheme.colors.iconOnSolidPrimary, + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun EditableAvatarViewPreview( + @PreviewParameter(EditableAvatarViewUriProvider::class) uri: String? +) = ElementPreview( + drawableFallbackForImages = CommonDrawables.sample_avatar, +) { + EditableAvatarView( + matrixId = "id", + displayName = "A room", + avatarUrl = uri, + avatarSize = AvatarSize.EditRoomDetails, + avatarType = AvatarType.User, + onAvatarClick = {}, + ) +} + +open class EditableAvatarViewUriProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + null, + "mxc://matrix.org/123456", + "https://example.com/avatar.jpg", + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/EditableOrgAvatar.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/EditableOrgAvatar.kt new file mode 100644 index 0000000..81c3e05 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/EditableOrgAvatar.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +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.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +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.avatar.AvatarType +import io.element.android.libraries.designsystem.components.avatar.anAvatarData +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.toPx +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=3643-2678&m=dev + */ +@Composable +fun EditableOrgAvatar( + avatarData: AvatarData, + onEdit: () -> Unit, + modifier: Modifier = Modifier, +) { + val actionEdit = stringResource(id = CommonStrings.action_edit) + val description = stringResource(CommonStrings.a11y_avatar) + Box( + modifier = modifier + .width(avatarData.size.dp + 16.dp) + .clearAndSetSemantics { + contentDescription = description + // Note: this does not set the click effect to the whole Box + // when talkback is not enabled + onClick( + label = actionEdit, + action = { + onEdit() + true + } + ) + } + ) { + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + val editIconRadius = 17.dp.toPx() + val editIconXOffset = 7.dp.toPx() + val editIconYOffset = 15.dp.toPx() + Avatar( + avatarData = avatarData, + avatarType = AvatarType.Space(false), + modifier = Modifier + .align(Alignment.Center) + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + } + .drawWithContent { + drawContent() + val xOffset = if (isRtl) { + editIconXOffset + } else { + size.width - editIconXOffset + } + drawCircle( + color = Color.Black, + center = Offset( + x = xOffset, + y = size.height - editIconYOffset, + ), + radius = editIconRadius, + blendMode = BlendMode.Clear, + ) + }, + ) + Surface( + color = ElementTheme.colors.bgCanvasDefault, + shape = CircleShape, + border = BorderStroke(1.dp, color = ElementTheme.colors.borderInteractiveSecondary), + modifier = Modifier + .clip(CircleShape) + .size(30.dp) + .align(Alignment.BottomEnd) + .clickable( + indication = ripple(), + interactionSource = remember { MutableInteractionSource() }, + onClick = onEdit, + ), + ) { + Icon( + imageVector = CompoundIcons.Edit(), + // Note: keep the context description for the test + contentDescription = stringResource(id = CommonStrings.action_edit), + tint = ElementTheme.colors.iconPrimary, + modifier = Modifier.padding(6.dp) + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun EditableOrgAvatarPreview() = ElementPreview { + EditableOrgAvatar( + avatarData = anAvatarData( + url = "anUrl", + size = AvatarSize.OrganizationHeader, + ), + onEdit = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun EditableOrgAvatarRtlPreview() = CompositionLocalProvider( + LocalLayoutDirection provides LayoutDirection.Rtl, +) { + ElementPreview { + EditableOrgAvatar( + avatarData = anAvatarData( + url = "anUrl", + size = AvatarSize.OrganizationHeader, + ), + onEdit = {}, + ) + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/InviteSenderView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/InviteSenderView.kt new file mode 100644 index 0000000..13747a1 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/InviteSenderView.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components + +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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +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.avatar.AvatarType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.ui.model.InviteSender + +@Composable +fun InviteSenderView( + inviteSender: InviteSender, + modifier: Modifier = Modifier, + hideAvatarImage: Boolean = false, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Box(modifier = Modifier.padding(vertical = 2.dp)) { + Avatar( + avatarData = inviteSender.avatarData, + avatarType = AvatarType.User, + hideImage = hideAvatarImage, + ) + } + Text( + text = inviteSender.annotatedString(), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun InviteSenderViewPreview() = ElementPreview { + InviteSenderView( + inviteSender = InviteSender( + userId = UserId("@bob:example.com"), + displayName = "Bob", + avatarData = AvatarData( + id = "@bob:example.com", + name = "Bob", + url = null, + size = AvatarSize.InviteSender, + ), + membershipChangeReason = null, + ) + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/JoinButton.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/JoinButton.kt new file mode 100644 index 0000000..e162feb --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/JoinButton.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components + +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.ButtonSize +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun JoinButton( + showProgress: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textActionAccent) { + TextButton( + modifier = modifier, + text = stringResource(CommonStrings.action_join), + onClick = onClick, + size = ButtonSize.LargeLowPadding, + showProgress = showProgress, + ) + } +} 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 0000000..5b44b50 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeader.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.draw.clipToBounds +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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 + +@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), + avatarType = AvatarType.User, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier.weight(1f) + ) { + // Name + Text( + modifier = Modifier.clipToBounds(), + text = matrixUser.getBestName(), + maxLines = 1, + style = ElementTheme.typography.fontHeadingSmMedium, + overflow = TextOverflow.Ellipsis, + color = ElementTheme.colors.textPrimary, + ) + // Id + if (matrixUser.displayName.isNullOrEmpty().not()) { + Text( + text = matrixUser.userId.value, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun MatrixUserHeaderPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreview { + 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 0000000..08f8a37 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeaderPlaceholder.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.unit.dp +import io.element.android.compound.theme.ElementTheme +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.placeholderBackground + +@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) + } + } +} + +@PreviewsDayNight +@Composable +internal fun MatrixUserHeaderPlaceholderPreview() = ElementPreview { + 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 0000000..4d5a1cd --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 { + override val values: Sequence + get() = sequenceOf( + aMatrixUser(), + aMatrixUser(displayName = null), + ) +} + +open class MatrixUserWithNullProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aMatrixUser(), + aMatrixUser(displayName = null), + null, + ) +} + +open class MatrixUserWithAvatarProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aMatrixUser(displayName = "John Doe"), + aMatrixUser(displayName = "John Doe", avatarUrl = "anUrl"), + ) +} + +fun aMatrixUser( + id: String = "@id_of_alice:server.org", + displayName: String? = "Alice", + avatarUrl: String? = null, +) = MatrixUser( + userId = UserId(id), + displayName = displayName, + avatarUrl = avatarUrl, +) + +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"), +) 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 0000000..cf89074 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserRow.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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 MatrixUserRow( + matrixUser: MatrixUser, + modifier: Modifier = Modifier, + avatarSize: AvatarSize = AvatarSize.UserListItem, + trailingContent: @Composable (() -> Unit)? = null, +) = UserRow( + avatarData = matrixUser.getAvatarData(avatarSize), + name = matrixUser.getBestName(), + subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value, + modifier = modifier, + trailingContent = trailingContent, +) + +@PreviewsDayNight +@Composable +internal fun MatrixUserRowPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreview { + MatrixUserRow(matrixUser) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/OrganizationHeader.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/OrganizationHeader.kt new file mode 100644 index 0000000..374ebd7 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/OrganizationHeader.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components + +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +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.avatar.AvatarType +import io.element.android.libraries.designsystem.components.avatar.anAvatarData +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +/** + * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=3643-2048&m=dev + */ +@Composable +fun OrganizationHeader( + avatarData: AvatarData, + name: String, + numberOfSpaces: Int, + numberOfRooms: Int, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 24.dp, start = 16.dp, end = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Avatar( + avatarData = avatarData, + avatarType = AvatarType.Space(false), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = name, + style = ElementTheme.typography.fontHeadingLgBold, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(12.dp)) + SpaceInfoRow( + leftText = numberOfSpaces(numberOfSpaces), + rightText = numberOfRooms(numberOfRooms), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun OrganizationHeaderPreview() = ElementPreview { + OrganizationHeader( + avatarData = anAvatarData( + url = "anUrl", + size = AvatarSize.OrganizationHeader, + ), + name = "Space name", + numberOfSpaces = 9, + numberOfRooms = 88, + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectRoomInfoProvider.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectRoomInfoProvider.kt new file mode 100644 index 0000000..731d499 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectRoomInfoProvider.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.model.SelectRoomInfo +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +class SelectRoomInfoProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aSelectRoomInfo(roomId = RoomId("!room1:domain")), + aSelectRoomInfo(roomId = RoomId("!room2:domain"), name = "Room with a name"), + aSelectRoomInfo(roomId = RoomId("!room3:domain"), name = "Room with a name and avatar", avatarUrl = "anUrl"), + ) +} + +fun aSelectRoomInfo( + roomId: RoomId, + name: String? = null, + canonicalAlias: RoomAlias? = null, + avatarUrl: String? = null, + heroes: ImmutableList = persistentListOf(), + isTombstoned: Boolean = false, +) = SelectRoomInfo( + roomId = roomId, + name = name, + canonicalAlias = canonicalAlias, + avatarUrl = avatarUrl, + heroes = heroes, + isTombstoned = isTombstoned, +) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedItem.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedItem.kt new file mode 100644 index 0000000..29d2f46 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedItem.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.material3.MaterialTheme +import androidx.compose.material3.ripple +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.draw.clipToBounds +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +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.AvatarType +import io.element.android.libraries.designsystem.text.toPx +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.ui.strings.CommonStrings + +@Composable +fun SelectedItem( + avatarData: AvatarData, + avatarType: AvatarType, + text: String, + maxLines: Int, + a11yContentDescription: String, + canRemove: Boolean, + onRemoveClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val actionRemove = stringResource(id = CommonStrings.action_remove) + Box( + modifier = modifier + .width(avatarData.size.dp) + .clearAndSetSemantics { + contentDescription = a11yContentDescription + if (canRemove) { + // Note: this does not set the click effect to the whole Box + // when talkback is not enabled + onClick( + label = actionRemove, + action = { + onRemoveClick() + true + } + ) + } + } + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + val closeIconRadius = 12.dp.toPx() + val closeIconOffset = 10.dp.toPx() + Avatar( + avatarData = avatarData, + avatarType = avatarType, + modifier = Modifier + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + } + .drawWithContent { + drawContent() + if (canRemove) { + val xOffset = if (isRtl) { + closeIconOffset + } else { + size.width - closeIconOffset + } + drawCircle( + color = Color.Black, + center = Offset( + x = xOffset, + y = closeIconOffset, + ), + radius = closeIconRadius, + blendMode = BlendMode.Clear, + ) + } + }, + ) + Text( + modifier = Modifier.clipToBounds(), + text = text, + overflow = TextOverflow.Ellipsis, + maxLines = maxLines, + style = MaterialTheme.typography.bodyMedium, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + if (canRemove) { + Surface( + color = ElementTheme.colors.bgActionPrimaryRest, + modifier = Modifier + .clip(CircleShape) + .size(20.dp) + .align(Alignment.TopEnd) + .clickable( + indication = ripple(), + interactionSource = remember { MutableInteractionSource() }, + onClick = onRemoveClick, + ), + ) { + Icon( + imageVector = CompoundIcons.Close(), + // Note: keep the context description for the test + contentDescription = stringResource(id = CommonStrings.action_remove), + tint = ElementTheme.colors.iconOnSolidPrimary, + modifier = Modifier.padding(2.dp) + ) + } + } + } +} 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 0000000..b748490 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.LayoutDirection +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.ui.model.SelectRoomInfo +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun SelectedRoom( + roomInfo: SelectRoomInfo, + onRemoveRoom: (SelectRoomInfo) -> Unit, + modifier: Modifier = Modifier, +) { + SelectedItem( + avatarData = roomInfo.getAvatarData(AvatarSize.SelectedRoom), + avatarType = AvatarType.Room( + heroes = roomInfo.heroes.map { it.getAvatarData(AvatarSize.SelectedRoom) }.toImmutableList(), + isTombstoned = roomInfo.isTombstoned, + ), + // If name is null, we do not have space to render "No room name", so just use `#` here. + text = roomInfo.name ?: "#", + maxLines = 1, + a11yContentDescription = roomInfo.name + ?: roomInfo.canonicalAlias?.value + ?: stringResource(id = CommonStrings.common_room_name), + canRemove = true, + onRemoveClick = { onRemoveRoom(roomInfo) }, + modifier = modifier, + ) +} + +@PreviewsDayNight +@Composable +internal fun SelectedRoomPreview( + @PreviewParameter(SelectRoomInfoProvider::class) roomInfo: SelectRoomInfo +) = ElementPreview { + SelectedRoom( + roomInfo = roomInfo, + onRemoveRoom = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun SelectedRoomRtlPreview( + @PreviewParameter(SelectRoomInfoProvider::class) roomInfo: SelectRoomInfo +) = CompositionLocalProvider( + LocalLayoutDirection provides LayoutDirection.Rtl, +) { + ElementPreview { + SelectedRoom( + roomInfo = roomInfo, + onRemoveRoom = {}, + ) + } +} 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 0000000..b118dbc --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUser.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.LayoutDirection +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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 SelectedUser( + matrixUser: MatrixUser, + canRemove: Boolean, + onUserRemove: (MatrixUser) -> Unit, + modifier: Modifier = Modifier, +) { + SelectedItem( + avatarData = matrixUser.getAvatarData(size = AvatarSize.SelectedUser), + avatarType = AvatarType.User, + text = matrixUser.getBestName(), + maxLines = 2, + a11yContentDescription = matrixUser.getBestName(), + canRemove = canRemove, + onRemoveClick = { onUserRemove(matrixUser) }, + modifier = modifier, + ) +} + +@PreviewsDayNight +@Composable +internal fun SelectedUserPreview(@PreviewParameter(MatrixUserWithAvatarProvider::class) user: MatrixUser) = ElementPreview { + SelectedUser( + matrixUser = user, + canRemove = true, + onUserRemove = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun SelectedUserRtlPreview() = CompositionLocalProvider( + LocalLayoutDirection provides LayoutDirection.Rtl, +) { + ElementPreview { + SelectedUser( + matrixUser = aMatrixUser(displayName = "John Doe"), + canRemove = true, + onUserRemove = {}, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun SelectedUserCannotRemovePreview() = ElementPreview { + SelectedUser( + matrixUser = aMatrixUser(), + canRemove = false, + onUserRemove = {}, + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUsersRowList.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUsersRowList.kt new file mode 100644 index 0000000..ecb86a5 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUsersRowList.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.mutableIntStateOf +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.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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 SelectedUsersRowList( + selectedUsers: ImmutableList, + onUserRemove: (MatrixUser) -> Unit, + modifier: Modifier = Modifier, + autoScroll: Boolean = false, + canDeselect: (MatrixUser) -> Boolean = { true }, + contentPadding: PaddingValues = PaddingValues(0.dp), +) { + val lazyListState = rememberLazyListState() + if (autoScroll) { + var currentSize by rememberSaveable { mutableIntStateOf(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, selectedUser -> + Layout( + content = { + SelectedUser( + matrixUser = selectedUser, + canRemove = canDeselect(selectedUser), + onUserRemove = onUserRemove, + ) + }, + 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) + } + } + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun SelectedUsersRowListPreview() = ElementPreview { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Two users that will be visible with no scrolling + SelectedUsersRowList( + selectedUsers = aMatrixUserList().take(2).toImmutableList(), + onUserRemove = {}, + 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) { + SelectedUsersRowList( + selectedUsers = aMatrixUserList().take(6).toImmutableList(), + onUserRemove = {}, + 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/SpaceHeaderRootView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderRootView.kt new file mode 100644 index 0000000..f80cf2d --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderRootView.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.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.Text +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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=3643-2048 + */ +@Composable +fun SpaceHeaderRootView( + numberOfSpaces: Int, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(top = 32.dp, bottom = 24.dp, start = 16.dp, end = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + BigIcon( + style = BigIcon.Style.Default(CompoundIcons.WorkspaceSolid()) + ) + Text( + text = stringResource(CommonStrings.screen_space_list_title), + style = ElementTheme.typography.fontHeadingLgBold, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + SpaceInfoRow( + leftText = numberOfSpaces(numberOfSpaces), + rightText = null, + ) + Text( + text = stringResource(CommonStrings.screen_space_list_description), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun SpaceHeaderRootViewPreview() = ElementPreview { + SpaceHeaderRootView( + numberOfSpaces = 3, + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderView.kt new file mode 100644 index 0000000..ae185d2 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceHeaderView.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom +import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom +import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism +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.avatar.AvatarType +import io.element.android.libraries.designsystem.components.avatar.anAvatarData +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility +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 + +/** + * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=3643-2429&m=dev + */ +@Composable +fun SpaceHeaderView( + avatarData: AvatarData, + name: String?, + topic: String?, + visibility: SpaceRoomVisibility, + heroes: ImmutableList, + numberOfMembers: Int, + modifier: Modifier = Modifier, + topicMaxLines: Int = Int.MAX_VALUE, + onTopicClick: ((String) -> Unit)? = null, +) { + RoomPreviewOrganism( + modifier = modifier.padding(24.dp), + avatar = { + Avatar( + avatarData = avatarData, + avatarType = AvatarType.Space(), + ) + }, + title = { + if (name != null) { + RoomPreviewTitleAtom(title = name) + } else { + RoomPreviewTitleAtom( + title = stringResource(id = CommonStrings.common_no_space_name), + fontStyle = FontStyle.Italic + ) + } + }, + subtitle = { + SpaceInfoRow(visibility = visibility) + }, + description = if (topic.isNullOrBlank()) { + null + } else { + { + RoomPreviewDescriptionAtom( + description = topic, + maxLines = topicMaxLines, + modifier = Modifier.clickable( + enabled = onTopicClick != null, + onClick = { onTopicClick?.invoke(topic) } + ) + ) + } + }, + memberCount = { + SpaceMembersView( + heroes = heroes, + numberOfMembers = numberOfMembers, + modifier = Modifier.padding(horizontal = 32.dp), + ) + }, + ) +} + +@PreviewsDayNight +@Composable +internal fun SpaceHeaderViewPreview() = ElementPreview { + SpaceHeaderView( + avatarData = anAvatarData( + url = "anUrl", + size = AvatarSize.SpaceHeader, + ), + name = "Space name", + topic = "Space topic: " + LoremIpsum(40).values.first(), + topicMaxLines = 2, + visibility = SpaceRoomVisibility.Public, + heroes = persistentListOf( + aMatrixUser(id = "@1:d", displayName = "Alice", avatarUrl = "aUrl"), + aMatrixUser(id = "@2:d", displayName = "Bob"), + aMatrixUser(id = "@3:d", displayName = "Charlie", avatarUrl = "aUrl"), + aMatrixUser(id = "@4:d", displayName = "Dave"), + ), + numberOfMembers = 999, + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceInfoRow.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceInfoRow.kt new file mode 100644 index 0000000..9709e0e --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceInfoRow.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components + +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility +import io.element.android.libraries.matrix.ui.model.icon +import io.element.android.libraries.matrix.ui.model.label +import io.element.android.libraries.ui.strings.CommonPlurals +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun SpaceInfoRow( + leftText: String, + rightText: String?, + modifier: Modifier = Modifier, + iconVector: ImageVector? = null, +) { + Row( + modifier = modifier, + horizontalArrangement = spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (iconVector != null) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = iconVector, + contentDescription = null, + tint = ElementTheme.colors.iconTertiary, + ) + } + val text = if (rightText != null) { + stringResource(id = CommonStrings.screen_space_list_details, leftText, rightText) + } else { + leftText + } + Text( + text = text, + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +fun SpaceInfoRow( + visibility: SpaceRoomVisibility, + modifier: Modifier = Modifier, +) { + SpaceInfoRow( + leftText = visibility.label, + rightText = null, + modifier = modifier, + iconVector = visibility.icon, + ) +} + +@Composable +@ReadOnlyComposable +fun numberOfRooms(numberOfRooms: Int): String { + return pluralStringResource(CommonPlurals.common_rooms, numberOfRooms, numberOfRooms) +} + +@Composable +@ReadOnlyComposable +fun numberOfSpaces(numberOfSpaces: Int): String { + return pluralStringResource(CommonPlurals.common_spaces, numberOfSpaces, numberOfSpaces) +} + +@PreviewsDayNight +@Composable +internal fun SpaceInfoRowPreview() = ElementPreview { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SpaceInfoRow( + leftText = numberOfSpaces(5), + rightText = numberOfRooms(10), + ) + SpaceInfoRow( + leftText = "Element space", + rightText = numberOfRooms(16), + iconVector = CompoundIcons.Workspace(), + ) + SpaceInfoRow( + visibility = SpaceRoomVisibility.Private, + ) + SpaceInfoRow( + visibility = SpaceRoomVisibility.Public + ) + SpaceInfoRow( + visibility = SpaceRoomVisibility.Restricted + ) + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceMembersView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceMembersView.kt new file mode 100644 index 0000000..202ea79 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceMembersView.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.atomic.molecules.MembersCountMolecule +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarRow +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.CommonDrawables +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.model.getAvatarData +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +/** + * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=3729-605&m=dev + */ +@Composable +fun SpaceMembersView( + heroes: ImmutableList, + numberOfMembers: Int, + modifier: Modifier = Modifier, +) { + if (heroes.isEmpty()) { + MembersCountMolecule( + memberCount = numberOfMembers, + modifier = modifier, + ) + } else { + SpaceMembersWithAvatar( + heroes = heroes + .take(3) + .map { + it.getAvatarData(AvatarSize.SpaceMember) + } + .toImmutableList(), + numberOfMembers = numberOfMembers, + modifier = modifier, + ) + } +} + +@Composable +private fun SpaceMembersWithAvatar( + heroes: ImmutableList, + numberOfMembers: Int, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + AvatarRow( + avatarDataList = heroes, + avatarType = AvatarType.User, + lastOnTop = true, + ) + Text( + text = "$numberOfMembers", + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } +} + +@Composable +@PreviewsDayNight +internal fun SpaceMembersViewNoHeroesPreview() = ElementPreview { + SpaceMembersView( + heroes = persistentListOf(), + numberOfMembers = 123, + ) +} + +@Composable +@PreviewsDayNight +internal fun SpaceMembersViewPreview() = ElementPreview( + drawableFallbackForImages = CommonDrawables.sample_avatar, +) { + SpaceMembersView( + heroes = persistentListOf( + aMatrixUser(id = "@1:d", displayName = "Alice", avatarUrl = "aUrl"), + aMatrixUser(id = "@2:d", displayName = "Bob"), + aMatrixUser(id = "@3:d", displayName = "Charlie", avatarUrl = "aUrl"), + aMatrixUser(id = "@4:d", displayName = "Dave"), + ), + numberOfMembers = 123, + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt new file mode 100644 index 0000000..61cb3f9 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +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.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom +import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule +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.avatar.AvatarType +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.unreadIndicator +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.matrix.ui.model.icon +import io.element.android.libraries.matrix.ui.model.label +import io.element.android.libraries.ui.strings.CommonPlurals +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/** + * Figma reference: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=3643-2079&m=dev + */ +@Composable +fun SpaceRoomItemView( + spaceRoom: SpaceRoom, + showUnreadIndicator: Boolean, + hideAvatars: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + trailingAction: @Composable (() -> Unit)? = null, + bottomAction: @Composable (() -> Unit)? = null, +) { + val clickModifier = Modifier + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), + indication = ripple(), + interactionSource = remember { MutableInteractionSource() } + ) + .onKeyboardContextMenuAction { onLongClick } + Column( + modifier = modifier + .then(clickModifier) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + SpaceRoomItemScaffold( + avatarData = spaceRoom.getAvatarData(AvatarSize.SpaceListItem), + isSpace = spaceRoom.isSpace, + hideAvatars = hideAvatars, + heroes = spaceRoom.heroes + .map { hero -> hero.getAvatarData(AvatarSize.SpaceListItem) } + .toImmutableList(), + trailingAction = trailingAction, + ) { + NameAndIndicatorRow( + name = spaceRoom.displayName, + showIndicator = showUnreadIndicator + ) + Spacer(modifier = Modifier.height(1.dp)) + SubtitleRow( + visibilityIcon = spaceRoom.visibilityIcon(), + subtitle = spaceRoom.subtitle() + ) + Spacer(modifier = Modifier.height(1.dp)) + val info = spaceRoom.info() + if (info.isNotBlank()) { + Text( + modifier = Modifier.weight(1f), + style = ElementTheme.typography.fontBodyMdRegular, + text = info, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + if (bottomAction != null) { + Spacer(modifier = Modifier.height(12.dp)) + // Match the padding of the text content (avatar + spacer) + Box(modifier = Modifier.padding(start = AvatarSize.SpaceListItem.dp + 16.dp)) { + bottomAction() + } + Spacer(modifier = Modifier.height(4.dp)) + } + } +} + +@Composable +private fun SubtitleRow( + visibilityIcon: ImageVector?, + subtitle: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (visibilityIcon != null) { + Icon( + modifier = Modifier + .size(16.dp) + .padding(end = 4.dp), + imageVector = visibilityIcon, + contentDescription = null, + tint = ElementTheme.colors.iconTertiary, + ) + } + Text( + modifier = Modifier.weight(1f), + style = ElementTheme.typography.fontBodyMdRegular, + text = subtitle, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun NameAndIndicatorRow( + name: String, + showIndicator: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + style = ElementTheme.typography.fontBodyLgMedium, + text = name, + color = ElementTheme.colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (showIndicator) { + UnreadIndicatorAtom( + color = ElementTheme.colors.unreadIndicator + ) + } + } +} + +@Composable +private fun SpaceRoomItemScaffold( + avatarData: AvatarData, + isSpace: Boolean, + heroes: ImmutableList, + hideAvatars: Boolean, + modifier: Modifier = Modifier, + trailingAction: @Composable (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + avatarData = avatarData, + avatarType = if (isSpace) AvatarType.Space() else AvatarType.Room(heroes = heroes), + hideImage = hideAvatars, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier.weight(1f), + content = content, + ) + if (trailingAction != null) { + Spacer(modifier = Modifier.width(16.dp)) + trailingAction() + } + } +} + +@Composable +@ReadOnlyComposable +private fun SpaceRoom.subtitle(): String { + return if (isSpace) { + visibility.label + } else { + pluralStringResource(CommonPlurals.common_member_count, numJoinedMembers, numJoinedMembers) + } +} + +@Composable +@ReadOnlyComposable +private fun SpaceRoom.info(): String { + return if (isSpace) { + pluralStringResource(CommonPlurals.common_member_count, numJoinedMembers, numJoinedMembers) + } else { + topic.orEmpty() + } +} + +@Composable +private fun SpaceRoom.visibilityIcon(): ImageVector? { + // Don't show any icon for restricted rooms as it's the default and would add noise + return if (visibility == SpaceRoomVisibility.Restricted) { + null + } else { + visibility.icon + } +} + +@Composable +@PreviewsDayNight +internal fun SpaceRoomItemViewPreview(@PreviewParameter(SpaceRoomProvider::class) spaceRoom: SpaceRoom) = ElementPreview { + SpaceRoomItemView( + spaceRoom = spaceRoom, + showUnreadIndicator = spaceRoom.state == CurrentUserMembership.INVITED, + hideAvatars = false, + onClick = {}, + onLongClick = {}, + bottomAction = if (spaceRoom.state == CurrentUserMembership.INVITED) { + { InviteButtonsRowMolecule({}, {}) } + } else { + null + }, + trailingAction = when (spaceRoom.state) { + null, CurrentUserMembership.LEFT -> { + { + JoinButton( + showProgress = spaceRoom.state == CurrentUserMembership.LEFT, + onClick = { }, + ) + } + } + else -> null + } + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomProvider.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomProvider.kt new file mode 100644 index 0000000..db63bba --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomProvider.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomType +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.previewutils.room.aSpaceRoom + +class SpaceRoomProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + aSpaceRoom( + roomType = RoomType.Room, + displayName = "Room name with topic", + topic = "Room topic that is quite long and might be truncated" + ), + aSpaceRoom( + roomType = RoomType.Room, + displayName = "Room name no topic", + state = CurrentUserMembership.LEFT, + ), + aSpaceRoom( + displayName = "Alice", + roomType = RoomType.Room, + isDirect = true, + heroes = listOf(aMatrixUser(displayName = "Alice")), + state = CurrentUserMembership.JOINED, + numJoinedMembers = 2, + ), + aSpaceRoom( + roomType = RoomType.Room, + displayName = "Room name with topic", + topic = "Room topic that is quite long and might be truncated", + state = CurrentUserMembership.INVITED, + ), + aSpaceRoom( + roomType = RoomType.Room, + displayName = "Room name no topic", + state = CurrentUserMembership.INVITED, + ), + aSpaceRoom( + numJoinedMembers = 5, + childrenCount = 10, + worldReadable = true, + roomId = RoomId("!spaceId0:example.com"), + ), + aSpaceRoom( + numJoinedMembers = 5, + childrenCount = 10, + worldReadable = true, + avatarUrl = "anUrl", + roomId = RoomId("!spaceId1:example.com"), + state = CurrentUserMembership.LEFT, + ), + aSpaceRoom( + numJoinedMembers = 5, + childrenCount = 10, + worldReadable = true, + avatarUrl = "anUrl", + roomId = RoomId("!spaceId2:example.com"), + state = CurrentUserMembership.INVITED, + ), + aSpaceRoom( + displayName = "Alice", + roomType = RoomType.Space, + heroes = listOf(aMatrixUser(displayName = "Alice")), + state = CurrentUserMembership.JOINED, + numJoinedMembers = 2, + ), + ) +} 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 0000000..56e88bf --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnresolvedUserRow.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components + +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.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.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +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.avatar.AvatarType +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +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.ui.strings.CommonStrings + +@Composable +fun UnresolvedUserRow( + avatarData: AvatarData, + id: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + 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 = avatarData, + avatarType = AvatarType.User, + ) + Column( + modifier = Modifier + .padding(start = 12.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + // ID + Text( + text = id, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (enabled) ElementTheme.colors.textPrimary else ElementTheme.colors.textDisabled, + style = ElementTheme.typography.fontBodyLgMedium, + ) + + // Warning + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 3.dp) + ) { + Icon( + imageVector = CompoundIcons.ErrorSolid(), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .align(Alignment.Top) + .padding(2.dp), + tint = if (enabled) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconDisabled, + ) + Text( + text = stringResource(CommonStrings.common_invite_unknown_profile), + color = if (enabled) ElementTheme.colors.textSecondary else ElementTheme.colors.textDisabled, + style = ElementTheme.typography.fontBodySmRegular.copy(lineHeight = 16.sp), + ) + } + } + } +} + +@Preview +@Composable +internal fun UnresolvedUserRowPreview() = ElementThemedPreview { + val matrixUser = aMatrixUser() + Column { + UnresolvedUserRow(matrixUser.getAvatarData(size = AvatarSize.UserListItem), matrixUser.userId.value) + UnresolvedUserRow(matrixUser.getAvatarData(size = 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 0000000..104a418 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnsavedAvatar.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components + +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.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.unit.dp +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.avatar.avatarShape +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial + +/** + * 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: String?, + avatarSize: AvatarSize, + avatarType: AvatarType, + modifier: Modifier = Modifier, +) { + val commonModifier = modifier + .size(avatarSize.dp) + .clip(avatarType.avatarShape(avatarSize.dp)) + + if (avatarUri != null) { + val context = LocalContext.current + val model = ImageRequest.Builder(context) + .data(avatarUri) + .build() + AsyncImage( + modifier = commonModifier, + model = model, + placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + } else { + Box(modifier = commonModifier.background(ElementTheme.colors.temporaryColorBgSpecial)) { + Icon( + imageVector = Icons.Outlined.AddAPhoto, + contentDescription = null, + modifier = Modifier + .align(Alignment.Center) + .size(avatarSize.dp * 4 / 7), + tint = ElementTheme.colors.iconSecondary, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun UnsavedAvatarPreview() = ElementPreview { + Row( + modifier = Modifier.padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + UnsavedAvatar(null, AvatarSize.EditRoomDetails, AvatarType.User) + UnsavedAvatar("", AvatarSize.EditRoomDetails, AvatarType.User) + UnsavedAvatar(null, AvatarSize.EditRoomDetails, AvatarType.Space()) + UnsavedAvatar("", AvatarSize.EditRoomDetails, AvatarType.Space()) + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UserRow.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UserRow.kt new file mode 100644 index 0000000..932cab9 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UserRow.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +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.AvatarType +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +internal fun UserRow( + avatarData: AvatarData, + name: String, + subtext: String?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + trailingContent: @Composable (() -> Unit)? = null, +) { + 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 = avatarData, + avatarType = AvatarType.User, + ) + Column( + modifier = Modifier + .padding(start = 12.dp) + .weight(1f), + ) { + // Name + Text( + modifier = Modifier.clipToBounds(), + text = name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (enabled) ElementTheme.colors.textPrimary else ElementTheme.colors.textDisabled, + style = ElementTheme.typography.fontBodyLgRegular, + ) + // Id + subtext?.let { + Text( + text = subtext, + color = if (enabled) ElementTheme.colors.textSecondary else ElementTheme.colors.textDisabled, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodySmRegular, + ) + } + } + trailingContent?.invoke() + } +} 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 0000000..c9fad12 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarAction.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.media + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable +import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.ui.strings.CommonStrings + +@Immutable +sealed class AvatarAction( + @StringRes val titleResId: Int, + @DrawableRes val iconResourceId: Int, + val destructive: Boolean = false, +) { + data object TakePhoto : AvatarAction( + titleResId = CommonStrings.action_take_photo, + iconResourceId = CompoundDrawables.ic_compound_take_photo, + ) + + data object ChoosePhoto : AvatarAction( + titleResId = CommonStrings.action_choose_photo, + iconResourceId = CompoundDrawables.ic_compound_image, + ) + + data object Remove : AvatarAction( + titleResId = CommonStrings.action_remove, + iconResourceId = CompoundDrawables.ic_compound_delete, + destructive = true + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomMemberProfilesCache.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomMemberProfilesCache.kt new file mode 100644 index 0000000..4a9f4f9 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomMemberProfilesCache.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.messages + +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.RoomMember +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.runningFold + +@SingleIn(RoomScope::class) +@Inject +class RoomMemberProfilesCache { + private val cache = MutableStateFlow(mapOf()) + val updateFlow = cache.drop(1).runningFold(0) { acc, _ -> acc + 1 } + + suspend fun replace(items: List) = coroutineScope { + cache.value = items.associateBy { it.userId } + } + + fun getDisplayName(userId: UserId): String? { + return cache.value[userId]?.disambiguatedDisplayName + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomNamesCache.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomNamesCache.kt new file mode 100644 index 0000000..f8d3402 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/RoomNamesCache.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.messages + +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.runningFold + +@SingleIn(RoomScope::class) +@Inject +class RoomNamesCache { + private val cache = MutableStateFlow(mapOf()) + val updateFlow = cache.drop(1).runningFold(0) { acc, _ -> acc + 1 } + + suspend fun replace(items: List) = coroutineScope { + val roomNamesByRoomIdOrAlias = LinkedHashMap(items.size * 2) + items + .forEach { summary -> + roomNamesByRoomIdOrAlias[summary.info.id.toRoomIdOrAlias()] = summary.info.name + val canonicalAlias = summary.info.canonicalAlias + if (canonicalAlias != null) { + roomNamesByRoomIdOrAlias[canonicalAlias.toRoomIdOrAlias()] = summary.info.name + } + } + cache.value = roomNamesByRoomIdOrAlias + } + + fun getDisplayName(roomIdOrAlias: RoomIdOrAlias): String? { + return cache.value[roomIdOrAlias] + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt new file mode 100644 index 0000000..5a2297e --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.messages + +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +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 + +/** + * Converts the HTML string [FormattedBody.body] to a [Document] by parsing it. + * If the message is not formatted or the format is not [MessageFormat.HTML] we return `null`. + * + * This will also make sure mentions are prefixed with `@`. + * + * @param permalinkParser the parser to use to parse the mentions. + * @param prefix if not null, the prefix will be inserted at the beginning of the message. + */ +fun FormattedBody.toHtmlDocument( + permalinkParser: PermalinkParser, + prefix: String? = null, +): Document? { + return takeIf { it.format == MessageFormat.HTML }?.body + // Trim whitespace at the end to avoid having wrong rendering of the message. + // We don't trim the start in case it's used as indentation. + ?.trimEnd() + ?.let { formattedBody -> + val dom = if (prefix != null) { + Jsoup.parse("$prefix $formattedBody") + } else { + Jsoup.parse(formattedBody) + } + + // Prepend `@` to mentions + fixMentions(dom, permalinkParser) + + dom + } +} + +private fun fixMentions( + dom: Document, + permalinkParser: PermalinkParser, +) { + val links = dom.getElementsByTag("a") + links.forEach { + if (it.hasAttr("href")) { + val link = permalinkParser.parse(it.attr("href")) + if (link is PermalinkData.UserLink && !it.text().startsWith("@")) { + it.prependText("@") + } + } + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt new file mode 100644 index 0000000..2fc371c --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.messages + +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody +import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import org.jsoup.select.NodeVisitor + +/** + * Converts the HTML string in [TextMessageType.formatted] to a plain text representation by parsing it and removing all formatting. + * If the message is not formatted or the format is not [MessageFormat.HTML], the [TextMessageType.body] is returned instead. + */ +fun TextMessageType.toPlainText( + permalinkParser: PermalinkParser, +) = formatted?.toPlainText(permalinkParser) ?: body + +/** + * Converts the HTML string in [FormattedBody.body] to a plain text representation by parsing it and removing all formatting. + * If the message is not formatted or the format is not [MessageFormat.HTML] we return `null`. + * @param permalinkParser the parser to use to parse the mentions. + * @param prefix if not null, the prefix will be inserted at the beginning of the message. + */ +fun FormattedBody.toPlainText( + permalinkParser: PermalinkParser, + prefix: String? = null, +): String? { + return this.toHtmlDocument( + permalinkParser = permalinkParser, + prefix = prefix, + )?.toPlainText() +} + +/** + * Converts the HTML [Document] to a plain text representation by parsing it and removing all formatting. + */ +fun Document.toPlainText(): String { + val visitor = PlainTextNodeVisitor() + traverse(visitor) + return visitor.build() +} + +private class PlainTextNodeVisitor : NodeVisitor { + private val builder = StringBuilder() + + override fun head(node: Node, depth: Int) { + if (node is TextNode && node.text().isNotBlank()) { + builder.append(node.text()) + } else if (node is Element && node.tagName() == "li") { + val index = node.elementSiblingIndex() + 1 + val isOrdered = node.parent()?.nodeName()?.lowercase() == "ol" + if (isOrdered) { + val startIndex = node.parent()?.attr("start")?.toIntOrNull() + val actualIndex = if (startIndex != null) { + startIndex + index - 1 + } else { + index + } + builder.append("$actualIndex. ") + } else { + builder.append("• ") + } + } else if (node is Element && node.isBlock && builder.lastOrNull() != '\n') { + builder.append("\n") + } + } + + override fun tail(node: Node, depth: Int) { + fun nodeIsBlockButNotLastOne(node: Node) = node is Element && node.isBlock && node.lastElementSibling() !== node + fun nodeIsLineBreak(node: Node) = node.nodeName().lowercase() == "br" + if (nodeIsBlockButNotLastOne(node) || nodeIsLineBreak(node)) { + builder.append("\n") + } + } + + fun build(): String { + return builder.toString().trim() + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetails.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetails.kt new file mode 100644 index 0000000..3179c74 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetails.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.messages.reply + +import androidx.compose.runtime.Immutable +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.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.timeline.item.event.EventContent +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +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.ui.messages.toPlainText + +@Immutable +sealed interface InReplyToDetails { + data class Ready( + val eventId: EventId, + val senderId: UserId, + val senderProfile: ProfileDetails, + val eventContent: EventContent?, + val textContent: String?, + ) : InReplyToDetails + + data class Loading(val eventId: EventId) : InReplyToDetails + data class Error(val eventId: EventId, val message: String) : InReplyToDetails +} + +fun InReplyToDetails.eventId() = when (this) { + is InReplyToDetails.Ready -> eventId + is InReplyToDetails.Loading -> eventId + is InReplyToDetails.Error -> eventId +} + +fun InReplyTo.map( + permalinkParser: PermalinkParser, +) = when (this) { + is InReplyTo.Ready -> InReplyToDetails.Ready( + eventId = eventId, + senderId = senderId, + senderProfile = senderProfile, + eventContent = content, + textContent = when (content) { + is MessageContent -> { + val messageContent = content as MessageContent + (messageContent.type as? TextMessageType)?.toPlainText(permalinkParser = permalinkParser) ?: messageContent.body + } + is StickerContent -> { + val stickerContent = content as StickerContent + stickerContent.body + } + else -> null + } + ) + is InReplyTo.Error -> InReplyToDetails.Error(eventId, message) + is InReplyTo.NotLoaded -> InReplyToDetails.Loading(eventId) + is InReplyTo.Pending -> InReplyToDetails.Loading(eventId) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt new file mode 100644 index 0000000..ac545ed --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.messages.reply + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +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.MediaSource +import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo +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.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.PollContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType +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.VideoMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf + +open class InReplyToDetailsProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aMessageContent( + body = "Message which are being replied.", + type = TextMessageType("Message which are being replied.", null) + ), + aMessageContent( + body = "Message which are being replied, and which was long enough to be displayed on two lines (only!).", + type = TextMessageType("Message which are being replied, and which was long enough to be displayed on two lines (only!).", null) + ), + aMessageContent( + body = "Video", + type = VideoMessageType("Video", null, null, MediaSource("url"), null), + ), + aMessageContent( + body = "Audio", + type = AudioMessageType("Audio", null, null, MediaSource("url"), null), + ), + aMessageContent( + body = "Voice", + type = VoiceMessageType("Voice", null, null, MediaSource("url"), null, null), + ), + aMessageContent( + body = "Image", + type = ImageMessageType("Image", null, null, MediaSource("url"), null), + ), + aMessageContent( + body = "Sticker", + type = StickerMessageType("Image", null, null, MediaSource("url"), null), + ), + aMessageContent( + body = "File", + type = FileMessageType("File", null, null, MediaSource("url"), null), + ), + aMessageContent( + body = "Location", + type = LocationMessageType("Location", "geo:1,2", null), + ), + aMessageContent( + body = "Notice", + type = NoticeMessageType("Notice", null), + ), + aMessageContent( + body = "Emote", + type = EmoteMessageType("Emote", null), + ), + PollContent( + question = "Poll which are being replied.", + kind = PollKind.Disclosed, + maxSelections = 1u, + answers = persistentListOf(), + votes = persistentMapOf(), + endTime = null, + isEdited = false, + ), + ).map { + aInReplyToDetails( + eventContent = it, + ) + } +} + +class InReplyToDetailsDisambiguatedProvider : InReplyToDetailsProvider() { + override val values: Sequence + get() = sequenceOf( + aMessageContent( + body = "Message which are being replied.", + type = TextMessageType("Message which are being replied.", null) + ), + ).map { + aInReplyToDetails( + displayNameAmbiguous = true, + eventContent = it, + ) + } +} + +class InReplyToDetailsInformativeProvider : InReplyToDetailsProvider() { + override val values: Sequence + get() = sequenceOf( + RedactedContent, + UnableToDecryptContent(UnableToDecryptContent.Data.Unknown), + ).map { + aInReplyToDetails( + eventContent = it, + ) + } +} + +class InReplyToDetailsOtherProvider : InReplyToDetailsProvider() { + override val values: Sequence + get() = sequenceOf( + InReplyToDetails.Loading(eventId = EventId("\$anEventId")), + InReplyToDetails.Error(eventId = EventId("\$anEventId"), message = "An error message."), + ) +} + +private fun aMessageContent( + body: String, + type: MessageType, + threadInfo: EventThreadInfo? = null, +) = MessageContent( + body = body, + inReplyTo = null, + isEdited = false, + threadInfo = threadInfo, + type = type, +) + +private fun aInReplyToDetails( + eventContent: EventContent, + displayNameAmbiguous: Boolean = false, +) = InReplyToDetails.Ready( + eventId = EventId("\$event"), + eventContent = eventContent, + senderId = UserId("@Sender:domain"), + senderProfile = aProfileTimelineDetailsReady( + displayNameAmbiguous = displayNameAmbiguous, + ), + textContent = (eventContent as? MessageContent)?.body.orEmpty(), +) + +fun aProfileTimelineDetailsReady( + displayName: String? = "Sender", + displayNameAmbiguous: Boolean = false, + avatarUrl: String? = null, +) = ProfileDetails.Ready( + displayName = displayName, + displayNameAmbiguous = displayNameAmbiguous, + avatarUrl = avatarUrl, +) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadata.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadata.kt new file mode 100644 index 0000000..4d0a0f0 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadata.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.messages.reply + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent +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.LegacyCallInviteContent +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.PollContent +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.api.timeline.item.event.VideoMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.libraries.ui.strings.CommonStrings + +@Immutable +internal sealed interface InReplyToMetadata { + data class Thumbnail( + val attachmentThumbnailInfo: AttachmentThumbnailInfo + ) : InReplyToMetadata { + val text: String = attachmentThumbnailInfo.textContent.orEmpty() + } + + data class Text( + val text: String + ) : InReplyToMetadata + + sealed interface Informative : InReplyToMetadata + + data object Redacted : Informative + data object UnableToDecrypt : Informative +} + +/** + * Computes metadata for the in reply to message. + * + * Metadata can be either a thumbnail with a text OR just a text. + */ +@Composable +internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetadata? = when (eventContent) { + is MessageContent -> when (val type = eventContent.type) { + is ImageMessageType -> InReplyToMetadata.Thumbnail( + AttachmentThumbnailInfo( + thumbnailSource = (type.info?.thumbnailSource ?: type.source).takeUnless { hideImage }, + textContent = eventContent.body, + type = AttachmentThumbnailType.Image, + blurHash = type.info?.blurhash, + ) + ) + is VideoMessageType -> InReplyToMetadata.Thumbnail( + AttachmentThumbnailInfo( + thumbnailSource = type.info?.thumbnailSource?.takeUnless { hideImage }, + textContent = eventContent.body, + type = AttachmentThumbnailType.Video, + blurHash = type.info?.blurhash, + ) + ) + is FileMessageType -> InReplyToMetadata.Thumbnail( + AttachmentThumbnailInfo( + thumbnailSource = type.info?.thumbnailSource?.takeUnless { hideImage }, + textContent = eventContent.body, + type = AttachmentThumbnailType.File, + ) + ) + is LocationMessageType -> InReplyToMetadata.Thumbnail( + AttachmentThumbnailInfo( + textContent = stringResource(CommonStrings.common_shared_location), + type = AttachmentThumbnailType.Location, + ) + ) + is AudioMessageType -> InReplyToMetadata.Thumbnail( + AttachmentThumbnailInfo( + textContent = eventContent.body, + type = AttachmentThumbnailType.Audio, + ) + ) + is VoiceMessageType -> InReplyToMetadata.Thumbnail( + AttachmentThumbnailInfo( + textContent = stringResource(CommonStrings.common_voice_message), + type = AttachmentThumbnailType.Voice, + ) + ) + else -> InReplyToMetadata.Text(textContent ?: eventContent.body) + } + is StickerContent -> InReplyToMetadata.Thumbnail( + AttachmentThumbnailInfo( + thumbnailSource = eventContent.source.takeUnless { hideImage }, + textContent = eventContent.body, + type = AttachmentThumbnailType.Image, + blurHash = eventContent.info.blurhash, + ) + ) + is PollContent -> InReplyToMetadata.Thumbnail( + AttachmentThumbnailInfo( + textContent = eventContent.question, + type = AttachmentThumbnailType.Poll, + ) + ) + is RedactedContent -> InReplyToMetadata.Redacted + is UnableToDecryptContent -> InReplyToMetadata.UnableToDecrypt + is FailedToParseMessageLikeContent, + is FailedToParseStateContent, + is ProfileChangeContent, + is RoomMembershipContent, + is StateContent, + UnknownContent, + is LegacyCallInviteContent, + is CallNotifyContent, + null -> null +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt new file mode 100644 index 0000000..fc5d657 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.messages.reply + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +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.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.traversalIndex +import androidx.compose.ui.text.font.FontStyle +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.compound.theme.ElementTheme +import io.element.android.libraries.core.extensions.toSafeLength +import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom +import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail +import io.element.android.libraries.matrix.ui.messages.sender.SenderName +import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun InReplyToView( + inReplyTo: InReplyToDetails, + hideImage: Boolean, + modifier: Modifier = Modifier, +) { + when (inReplyTo) { + is InReplyToDetails.Ready -> { + ReplyToReadyContent( + senderId = inReplyTo.senderId, + senderProfile = inReplyTo.senderProfile, + metadata = inReplyTo.metadata(hideImage), + modifier = modifier, + ) + } + is InReplyToDetails.Error -> + ReplyToErrorContent(data = inReplyTo, modifier = modifier) + is InReplyToDetails.Loading -> + ReplyToLoadingContent(modifier = modifier) + } +} + +@Composable +private fun ReplyToReadyContent( + senderId: UserId, + senderProfile: ProfileDetails, + metadata: InReplyToMetadata?, + modifier: Modifier = Modifier, +) { + val paddings = if (metadata is InReplyToMetadata.Thumbnail) { + PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp) + } else { + PaddingValues(horizontal = 12.dp, vertical = 4.dp) + } + Row( + modifier + .background(MaterialTheme.colorScheme.surface) + .padding(paddings) + ) { + if (metadata is InReplyToMetadata.Thumbnail) { + AttachmentThumbnail( + info = metadata.attachmentThumbnailInfo, + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(4.dp)) + ) + Spacer(modifier = Modifier.width(8.dp)) + } + val a11InReplyToText = stringResource(CommonStrings.common_in_reply_to, senderProfile.getDisambiguatedDisplayName(senderId)) + Column( + modifier = Modifier.semantics(mergeDescendants = false) { isTraversalGroup = true }, + verticalArrangement = Arrangement.SpaceBetween + ) { + SenderName( + senderId = senderId, + senderProfile = senderProfile, + senderNameMode = SenderNameMode.Reply, + modifier = Modifier.semantics { + contentDescription = a11InReplyToText + isTraversalGroup = true + traversalIndex = 1f + }, + ) + ReplyToContentText(metadata) + } + } +} + +@Composable +private fun ReplyToLoadingContent( + modifier: Modifier = Modifier, +) { + val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp) + Row( + modifier + .background(MaterialTheme.colorScheme.surface) + .padding(paddings) + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + PlaceholderAtom(width = 80.dp, height = 12.dp) + PlaceholderAtom(width = 140.dp, height = 14.dp) + } + } +} + +@Composable +private fun ReplyToErrorContent( + data: InReplyToDetails.Error, + modifier: Modifier = Modifier, +) { + val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp) + Row( + modifier + .background(MaterialTheme.colorScheme.surface) + .padding(paddings) + ) { + Text( + text = data.message, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textCriticalPrimary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun ReplyToContentText(metadata: InReplyToMetadata?) { + val text = when (metadata) { + InReplyToMetadata.Redacted -> stringResource(id = CommonStrings.common_message_removed) + InReplyToMetadata.UnableToDecrypt -> stringResource(id = CommonStrings.common_waiting_for_decryption_key) + // Add a limit to the text length to avoid a crash in Compose + is InReplyToMetadata.Text -> metadata.text.toSafeLength() + // Add a limit to the text length to avoid a crash in Compose + is InReplyToMetadata.Thumbnail -> metadata.text.toSafeLength() + null -> "" + } + val iconResourceId = when (metadata) { + InReplyToMetadata.Redacted -> CompoundDrawables.ic_compound_delete + InReplyToMetadata.UnableToDecrypt -> CompoundDrawables.ic_compound_time + else -> null + } + val fontStyle = when (metadata) { + is InReplyToMetadata.Informative -> FontStyle.Italic + else -> FontStyle.Normal + } + Row( + modifier = Modifier.semantics(mergeDescendants = false) { + isTraversalGroup = true + traversalIndex = -1f + }, + verticalAlignment = Alignment.CenterVertically, + ) { + if (iconResourceId != null) { + Icon( + resourceId = iconResourceId, + tint = ElementTheme.colors.iconSecondary, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = text, + style = ElementTheme.typography.fontBodyMdRegular, + fontStyle = fontStyle, + textAlign = TextAlign.Start, + color = ElementTheme.colors.textSecondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun InReplyToViewPreview(@PreviewParameter(provider = InReplyToDetailsProvider::class) inReplyTo: InReplyToDetails) = ElementPreview { + InReplyToView( + inReplyTo = inReplyTo, + hideImage = false, + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/sender/SenderName.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/sender/SenderName.kt new file mode 100644 index 0000000..cdca093 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/sender/SenderName.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.messages.sender + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails + +// https://www.figma.com/file/Ni6Ii8YKtmXCKYNE90cC67/Timeline-(new)?type=design&node-id=917-80169&mode=design&t=A0CJCBbMqR8NOwUQ-0 +@Composable +fun SenderName( + senderId: UserId, + senderProfile: ProfileDetails, + senderNameMode: SenderNameMode, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + when (senderProfile) { + is ProfileDetails.Error, + ProfileDetails.Pending, + ProfileDetails.Unavailable -> { + MainText(text = senderId.value, mode = senderNameMode) + } + is ProfileDetails.Ready -> { + val displayName = senderProfile.displayName + if (displayName.isNullOrEmpty()) { + MainText(text = senderId.value, mode = senderNameMode) + } else { + MainText(text = displayName, mode = senderNameMode) + if (senderProfile.displayNameAmbiguous) { + SecondaryText(text = senderId.value, mode = senderNameMode) + } + } + } + } + } +} + +@Composable +private fun RowScope.MainText( + text: String, + mode: SenderNameMode, +) { + val style = when (mode) { + is SenderNameMode.Timeline -> ElementTheme.typography.fontBodyMdMedium + SenderNameMode.ActionList, + SenderNameMode.Reply -> ElementTheme.typography.fontBodySmMedium + } + val modifier = when (mode) { + is SenderNameMode.Timeline -> Modifier.alignByBaseline() + SenderNameMode.ActionList, + SenderNameMode.Reply -> Modifier + } + val color = when (mode) { + is SenderNameMode.Timeline -> mode.mainColor + SenderNameMode.ActionList, + SenderNameMode.Reply -> ElementTheme.colors.textPrimary + } + Text( + modifier = modifier.clipToBounds(), + text = text, + style = style, + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun RowScope.SecondaryText( + text: String, + mode: SenderNameMode, +) { + val style = when (mode) { + is SenderNameMode.Timeline -> ElementTheme.typography.fontBodySmRegular + SenderNameMode.ActionList, + SenderNameMode.Reply -> ElementTheme.typography.fontBodyXsRegular + } + val modifier = when (mode) { + is SenderNameMode.Timeline -> Modifier.alignByBaseline() + SenderNameMode.ActionList, + SenderNameMode.Reply -> Modifier + } + Text( + modifier = modifier.clipToBounds(), + text = text, + style = style, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@PreviewsDayNight +@Composable +internal fun SenderNamePreview( + @PreviewParameter(SenderNameDataProvider::class) senderNameData: SenderNameData, +) = ElementPreview { + SenderName( + senderId = senderNameData.userId, + senderProfile = senderNameData.profileDetails, + senderNameMode = senderNameData.senderNameMode, + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/sender/SenderNameDataProvider.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/sender/SenderNameDataProvider.kt new file mode 100644 index 0000000..96ad91b --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/sender/SenderNameDataProvider.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.messages.sender + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails + +data class SenderNameData( + val userId: UserId, + val profileDetails: ProfileDetails, + val senderNameMode: SenderNameMode, +) + +open class SenderNameDataProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + SenderNameMode.Timeline(mainColor = Color.Red), + SenderNameMode.Reply, + SenderNameMode.ActionList, + ) + .flatMap { senderNameMode -> + sequenceOf( + aSenderNameData( + senderNameMode = senderNameMode, + ), + aSenderNameData( + senderNameMode = senderNameMode, + displayNameAmbiguous = true, + ), + SenderNameData( + senderNameMode = senderNameMode, + userId = UserId("@alice:${senderNameMode.javaClass.simpleName.lowercase()}"), + profileDetails = ProfileDetails.Unavailable, + ), + ) + } +} + +private fun aSenderNameData( + senderNameMode: SenderNameMode, + displayNameAmbiguous: Boolean = false, +) = SenderNameData( + userId = UserId("@alice:${senderNameMode.javaClass.simpleName.lowercase()}"), + profileDetails = ProfileDetails.Ready( + displayName = "Alice ${senderNameMode.javaClass.simpleName}", + displayNameAmbiguous = displayNameAmbiguous, + avatarUrl = null + ), + senderNameMode = senderNameMode, +) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/sender/SenderNameMode.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/sender/SenderNameMode.kt new file mode 100644 index 0000000..503c152 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/sender/SenderNameMode.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.messages.sender + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +@Immutable +sealed interface SenderNameMode { + data class Timeline(val mainColor: Color) : SenderNameMode + data object Reply : SenderNameMode + data object ActionList : SenderNameMode +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt new file mode 100644 index 0000000..34e95f6 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.model + +import androidx.compose.runtime.Composable +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 io.element.android.compound.theme.ElementTheme +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.UserId +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.ui.R + +data class InviteSender( + val userId: UserId, + val displayName: String, + val avatarData: AvatarData, + val membershipChangeReason: String?, +) { + @Composable + fun annotatedString(): AnnotatedString { + return stringResource(R.string.screen_invites_invited_you, displayName, 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 = ElementTheme.colors.textPrimary + ), + start = senderNameStart, + end = senderNameStart + displayName.length + ) + ) + ) + } + } +} + +fun RoomMember.toInviteSender() = InviteSender( + userId = userId, + displayName = displayName ?: "", + avatarData = getAvatarData(size = AvatarSize.InviteSender), + membershipChangeReason = membershipChangeReason +) 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 0000000..83a7073 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.model + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +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 +import io.element.android.libraries.ui.strings.CommonStrings + +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 +} + +@Composable +fun MatrixUser.getFullName(): String { + return displayName.let { name -> + if (name.isNullOrBlank()) { + userId.value + } else { + stringResource(CommonStrings.common_name_and_id, name, userId.value) + } + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/RoomInfoExtension.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/RoomInfoExtension.kt new file mode 100644 index 0000000..f9a86c9 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/RoomInfoExtension.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.core.UserId +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.RoomMember + +fun RoomInfo.getAvatarData(size: AvatarSize) = AvatarData( + id = id.value, + name = name, + url = avatarUrl, + size = size, +) + +/** + * Returns the role of the user in the room. + * If the user is a creator and [RoomInfo.privilegedCreatorRole] is true, returns [RoomMember.Role.Owner]. + * Otherwise, checks the power levels and returns the corresponding role. + * If no specific power level is set for the user, defaults to [RoomMember.Role.User]. + */ +fun RoomInfo.roleOf(userId: UserId): RoomMember.Role { + return if (privilegedCreatorRole && creators.contains(userId)) { + RoomMember.Role.Owner(isCreator = true) + } else { + roomPowerLevels?.roleOf(userId) ?: RoomMember.Role.User + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/RoomMemberExtension.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/RoomMemberExtension.kt new file mode 100644 index 0000000..9ed27ac --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/RoomMemberExtension.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.room.RoomMember + +fun RoomMember.getAvatarData(size: AvatarSize) = AvatarData( + id = userId.value, + name = displayName, + url = avatarUrl, + size = size, +) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SelectRoomInfo.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SelectRoomInfo.kt new file mode 100644 index 0000000..49c8415 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SelectRoomInfo.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList + +data class SelectRoomInfo( + val roomId: RoomId, + val name: String?, + val canonicalAlias: RoomAlias?, + val avatarUrl: String?, + val heroes: ImmutableList, + val isTombstoned: Boolean, +) { + fun getAvatarData(size: AvatarSize) = AvatarData( + id = roomId.value, + name = name, + url = avatarUrl, + size = size, + ) +} + +fun RoomSummary.toSelectRoomInfo() = SelectRoomInfo( + roomId = roomId, + name = info.name, + avatarUrl = info.avatarUrl, + heroes = info.heroes, + canonicalAlias = info.canonicalAlias, + isTombstoned = info.successorRoom != null, +) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SpaceExtension.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SpaceExtension.kt new file mode 100644 index 0000000..f9d4063 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SpaceExtension.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.model + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import io.element.android.compound.tokens.generated.CompoundIcons +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.spaces.SpaceRoom +import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility +import io.element.android.libraries.ui.strings.CommonStrings + +fun SpaceRoom.getAvatarData(size: AvatarSize) = AvatarData( + id = roomId.value, + name = displayName, + url = avatarUrl, + size = size, +) + +val SpaceRoomVisibility.icon: ImageVector + @Composable + get() { + return when (this) { + SpaceRoomVisibility.Private -> CompoundIcons.LockSolid() + SpaceRoomVisibility.Public -> CompoundIcons.Public() + SpaceRoomVisibility.Restricted -> CompoundIcons.Workspace() + } + } + +val SpaceRoomVisibility.label: String + @Composable + @ReadOnlyComposable + get() { + return when (this) { + SpaceRoomVisibility.Private -> stringResource(CommonStrings.common_private_space) + SpaceRoomVisibility.Public -> stringResource(CommonStrings.common_public_space) + SpaceRoomVisibility.Restricted -> stringResource(CommonStrings.common_shared_space) + } + } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/LoadingRoomState.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/LoadingRoomState.kt new file mode 100644 index 0000000..cbbe79b --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/LoadingRoomState.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.room + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import dev.zacsweers.metro.Inject +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.JoinedRoom +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +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 + +@Immutable +sealed interface LoadingRoomState { + data object Loading : LoadingRoomState + data object Error : LoadingRoomState + data class Loaded(val room: JoinedRoom) : LoadingRoomState +} + +open class LoadingRoomStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + LoadingRoomState.Loading, + LoadingRoomState.Error + ) +} + +@Inject +class LoadingRoomStateFlowFactory(private val matrixClient: MatrixClient) { + fun create(lifecycleScope: CoroutineScope, roomId: RoomId, joinedRoom: JoinedRoom?): StateFlow { + return if (joinedRoom != null) { + MutableStateFlow(LoadingRoomState.Loaded(joinedRoom)) + } else { + getJoinedRoomFlow(roomId) + .map { room -> + if (room != null) { + LoadingRoomState.Loaded(room) + } else { + LoadingRoomState.Error + } + } + .stateIn(lifecycleScope, SharingStarted.Eagerly, LoadingRoomState.Loading) + } + } + + private fun getJoinedRoomFlow(roomId: RoomId): Flow = suspend { + matrixClient.getJoinedRoom(roomId = roomId) + } + .asFlow() +} 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 0000000..098117a --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomMembers.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.BaseRoom +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.getDirectRoomMember +import io.element.android.libraries.matrix.api.room.roomMembers + +@Composable +fun BaseRoom.getRoomMemberAsState(userId: UserId): State { + val roomMembersState by membersStateFlow.collectAsState() + return getRoomMemberAsState(roomMembersState = roomMembersState, userId = userId) +} + +@Composable +fun getRoomMemberAsState(roomMembersState: RoomMembersState, userId: UserId): State { + val roomMembers = roomMembersState.roomMembers() + return remember(roomMembers) { + derivedStateOf { + roomMembers?.find { + it.userId == userId + } + } + } +} + +@Composable +fun BaseRoom.getDirectRoomMember(roomMembersState: RoomMembersState): State { + val roomInfo by roomInfoFlow.collectAsState() + return remember { + derivedStateOf { + roomMembersState.getDirectRoomMember(roomInfo, sessionId) + } + } +} + +@Composable +fun BaseRoom.getCurrentRoomMember(roomMembersState: RoomMembersState): State { + return getRoomMemberAsState(roomMembersState = roomMembersState, userId = sessionId) +} 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 0000000..e9a3328 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.getValue +import androidx.compose.runtime.produceState +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.room.powerlevels.canBan +import io.element.android.libraries.matrix.api.room.powerlevels.canHandleKnockRequests +import io.element.android.libraries.matrix.api.room.powerlevels.canInvite +import io.element.android.libraries.matrix.api.room.powerlevels.canKick +import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther +import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn +import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage +import io.element.android.libraries.matrix.ui.model.roleOf + +@Composable +fun BaseRoom.canSendMessageAsState(type: MessageEventType, updateKey: Long): State { + return produceState(initialValue = true, key1 = updateKey) { + value = canSendMessage(type).getOrElse { true } + } +} + +@Composable +fun BaseRoom.canInviteAsState(updateKey: Long): State { + return produceState(initialValue = false, key1 = updateKey) { + value = canInvite().getOrElse { false } + } +} + +@Composable +fun BaseRoom.canRedactOwnAsState(updateKey: Long): State { + return produceState(initialValue = false, key1 = updateKey) { + value = canRedactOwn().getOrElse { false } + } +} + +@Composable +fun BaseRoom.canRedactOtherAsState(updateKey: Long): State { + return produceState(initialValue = false, key1 = updateKey) { + value = canRedactOther().getOrElse { false } + } +} + +@Composable +fun BaseRoom.canCall(updateKey: Long): State { + return produceState(initialValue = false, key1 = updateKey) { + value = canUserJoinCall(sessionId).getOrElse { false } + } +} + +@Composable +fun BaseRoom.canPinUnpin(updateKey: Long): State { + return produceState(initialValue = false, key1 = updateKey) { + value = canUserPinUnpin(sessionId).getOrElse { false } + } +} + +@Composable +fun BaseRoom.isDmAsState(): State { + return produceState(initialValue = false) { + roomInfoFlow.collect { value = it.isDm } + } +} + +@Composable +fun BaseRoom.canKickAsState(updateKey: Long): State { + return produceState(initialValue = false, key1 = updateKey) { + value = canKick().getOrElse { false } + } +} + +@Composable +fun BaseRoom.canBanAsState(updateKey: Long): State { + return produceState(initialValue = false, key1 = updateKey) { + value = canBan().getOrElse { false } + } +} + +@Composable +fun BaseRoom.canHandleKnockRequestsAsState(updateKey: Long): State { + return produceState(initialValue = false, key1 = updateKey) { + value = canHandleKnockRequests().getOrElse { false } + } +} + +@Composable +fun BaseRoom.userPowerLevelAsState(updateKey: Long): State { + return produceState(initialValue = 0, key1 = updateKey) { + value = userRole(sessionId) + .getOrDefault(RoomMember.Role.User) + .powerLevel + } +} + +@Composable +fun BaseRoom.isOwnUserAdmin(): Boolean { + val roomInfo by roomInfoFlow.collectAsState() + val role = roomInfo.roleOf(sessionId) + return role == RoomMember.Role.Admin || role is RoomMember.Role.Owner +} + +@Composable +fun BaseRoom.rawName(): String? { + val roomInfo by roomInfoFlow.collectAsState() + return roomInfo.rawName +} + +@Composable +fun BaseRoom.topic(): String? { + val roomInfo by roomInfoFlow.collectAsState() + return roomInfo.topic +} + +@Composable +fun BaseRoom.avatarUrl(): String? { + val roomInfo by roomInfoFlow.collectAsState() + return roomInfo.avatarUrl +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/ObserveRoomMemberIdentityStateChange.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/ObserveRoomMemberIdentityStateChange.kt new file mode 100644 index 0000000..980c31b --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/ObserveRoomMemberIdentityStateChange.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.room + +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.UserId +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.roomMembers +import io.element.android.libraries.matrix.ui.model.getAvatarData +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow + +@OptIn(ExperimentalCoroutinesApi::class) +fun JoinedRoom.roomMemberIdentityStateChange(waitForEncryption: Boolean): Flow> { + val encryptionChangeFlow = flow { + if (waitForEncryption) { + // Room cannot become unencrypted, so it's ok to use first here + roomInfoFlow.first { roomInfo -> roomInfo.isEncrypted == true } + } + emit(Unit) + } + return encryptionChangeFlow + .flatMapLatest { + combine(identityStateChangesFlow, membersStateFlow) { identityStateChanges, membersState -> + identityStateChanges.map { identityStateChange -> + val member = membersState.roomMembers() + ?.find { roomMember -> roomMember.userId == identityStateChange.userId } + ?.toIdentityRoomMember() + ?: createDefaultRoomMemberForIdentityChange(identityStateChange.userId) + RoomMemberIdentityStateChange( + identityRoomMember = member, + identityState = identityStateChange.identityState, + ) + }.toImmutableList() + }.distinctUntilChanged() + } +} + +private fun RoomMember.toIdentityRoomMember() = IdentityRoomMember( + userId = userId, + displayNameOrDefault = displayNameOrDefault, + avatarData = getAvatarData(AvatarSize.ComposerAlert), +) + +private fun createDefaultRoomMemberForIdentityChange(userId: UserId) = IdentityRoomMember( + userId = userId, + displayNameOrDefault = userId.extractedDisplayName, + avatarData = AvatarData( + id = userId.value, + name = null, + url = null, + size = AvatarSize.ComposerAlert, + ), +) + +data class RoomMemberIdentityStateChange( + val identityRoomMember: IdentityRoomMember, + val identityState: IdentityState, +) + +data class IdentityRoomMember( + val userId: UserId, + val displayNameOrDefault: String, + val avatarData: AvatarData, +) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/PowerLevelRoomMemberComparator.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/PowerLevelRoomMemberComparator.kt new file mode 100644 index 0000000..d5a7cfa --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/PowerLevelRoomMemberComparator.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.room + +import io.element.android.libraries.matrix.api.room.RoomMember +import java.text.Collator + +// Comparator used to sort room members by power level (descending) and then by name (ascending) +class PowerLevelRoomMemberComparator : Comparator { + // Used to simplify and compare unicode and ASCII chars (á == a) + private val collator = Collator.getInstance().apply { + decomposition = Collator.CANONICAL_DECOMPOSITION + } + override fun compare(o1: RoomMember, o2: RoomMember): Int { + return when { + o1.powerLevel > o2.powerLevel -> return -1 + o1.powerLevel < o2.powerLevel -> return 1 + else -> { + collator.compare(o1.sortingName(), o2.sortingName()) + } + } + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/RoomMemberExtensions.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/RoomMemberExtensions.kt new file mode 100644 index 0000000..9e5d70d --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/RoomMemberExtensions.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.room + +import io.element.android.libraries.matrix.api.room.RoomMember + +/** + * Returns the name value to use when sorting room members. + * + * If the display name is not null and not empty, it is returned. + * Otherwise, the user ID is returned without the initial "@". + */ +fun RoomMember.sortingName(): String { + return displayName?.takeIf { it.isNotEmpty() } ?: userId.value.drop(1) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/address/RoomAddressField.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/address/RoomAddressField.kt new file mode 100644 index 0000000..4166f37 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/address/RoomAddressField.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.room.address + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextField +import io.element.android.libraries.designsystem.theme.components.TextFieldValidity +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun RoomAddressField( + address: String, + homeserverName: String, + addressValidity: RoomAddressValidity, + onAddressChange: (String) -> Unit, + label: String, + supportingText: String, + modifier: Modifier = Modifier, +) { + TextField( + modifier = modifier.testTag(TestTags.roomAddressField), + value = address, + label = label, + leadingIcon = { + Text( + text = "#", + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textSecondary, + ) + }, + trailingIcon = { + Text( + text = homeserverName, + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textSecondary, + ) + }, + supportingText = when (addressValidity) { + RoomAddressValidity.InvalidSymbols -> { + stringResource(CommonStrings.error_room_address_invalid_symbols) + } + RoomAddressValidity.NotAvailable -> { + stringResource(CommonStrings.error_room_address_already_exists) + } + else -> supportingText + }, + validity = when (addressValidity) { + RoomAddressValidity.InvalidSymbols, RoomAddressValidity.NotAvailable -> TextFieldValidity.Invalid + else -> TextFieldValidity.None + }, + onValueChange = onAddressChange, + singleLine = true, + ) +} + +@PreviewsDayNight +@Composable +internal fun RoomAddressFieldPreview() = ElementPreview { + RoomAddressField( + address = "room", + homeserverName = "element.io", + addressValidity = RoomAddressValidity.Valid, + onAddressChange = {}, + label = "Room address", + supportingText = "This is the address that people will use to join your room", + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/address/RoomAddressValidity.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/address/RoomAddressValidity.kt new file mode 100644 index 0000000..005f5f8 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/address/RoomAddressValidity.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.room.address + +import androidx.compose.runtime.Immutable + +/** + * Represents the validity state of a room address. + * ie. whether it contains invalid characters, is already taken, or is valid. + */ +@Immutable +sealed interface RoomAddressValidity { + data object Unknown : RoomAddressValidity + data object InvalidSymbols : RoomAddressValidity + data object NotAvailable : RoomAddressValidity + data object Valid : RoomAddressValidity +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/address/RoomAddressValidityEffect.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/address/RoomAddressValidityEffect.kt new file mode 100644 index 0000000..d8b9b90 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/address/RoomAddressValidityEffect.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.room.address + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper +import io.element.android.libraries.matrix.api.roomAliasFromName +import kotlinx.coroutines.delay + +@Composable +fun RoomAddressValidityEffect( + client: MatrixClient, + roomAliasHelper: RoomAliasHelper, + newRoomAddress: String, + knownRoomAddress: String?, + onRoomAddressValidityChange: (RoomAddressValidity) -> Unit, +) { + val onChange by rememberUpdatedState(onRoomAddressValidityChange) + LaunchedEffect(newRoomAddress) { + if (newRoomAddress.isEmpty() || newRoomAddress == knownRoomAddress) { + onChange(RoomAddressValidity.Unknown) + return@LaunchedEffect + } + // debounce the room address validation + delay(300) + val roomAlias = client.roomAliasFromName(newRoomAddress) + if (roomAlias == null || !roomAliasHelper.isRoomAliasValid(roomAlias)) { + onChange(RoomAddressValidity.InvalidSymbols) + } else { + client.resolveRoomAlias(roomAlias) + .onSuccess { resolved -> + if (resolved.isPresent) { + onChange(RoomAddressValidity.NotAvailable) + } else { + onChange(RoomAddressValidity.Valid) + } + } + .onFailure { + onChange(RoomAddressValidity.Valid) + } + } + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/safety/Avatars.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/safety/Avatars.kt new file mode 100644 index 0000000..5d36b30 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/safety/Avatars.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.safety + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import io.element.android.libraries.core.coroutine.mapState +import io.element.android.libraries.matrix.api.MatrixClient + +@Composable +fun MatrixClient.rememberHideInvitesAvatar(): State { + return remember { + mediaPreviewService + .mediaPreviewConfigFlow + .mapState { config -> config.hideInviteAvatar } + }.collectAsState() +} diff --git a/libraries/matrixui/src/main/res/values-be/translations.xml b/libraries/matrixui/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..f2c4756 --- /dev/null +++ b/libraries/matrixui/src/main/res/values-be/translations.xml @@ -0,0 +1,4 @@ + + + "%1$s (%2$s) запрасіў(-ла) вас" + diff --git a/libraries/matrixui/src/main/res/values-bg/translations.xml b/libraries/matrixui/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..2085ebd --- /dev/null +++ b/libraries/matrixui/src/main/res/values-bg/translations.xml @@ -0,0 +1,4 @@ + + + "%1$s (%2$s) ви покани" + diff --git a/libraries/matrixui/src/main/res/values-cs/translations.xml b/libraries/matrixui/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..a8e82b2 --- /dev/null +++ b/libraries/matrixui/src/main/res/values-cs/translations.xml @@ -0,0 +1,7 @@ + + + "Poslat pozvánku" + "Chcete začít chatovat s %1$s?" + "Poslat pozvánku?" + "%1$s (%2$s) vás pozval(a)" + diff --git a/libraries/matrixui/src/main/res/values-cy/translations.xml b/libraries/matrixui/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..0131131 --- /dev/null +++ b/libraries/matrixui/src/main/res/values-cy/translations.xml @@ -0,0 +1,7 @@ + + + "Anfon gwahoddiad" + "Hoffech chi ddechrau sgwrs gyda %1$s?" + "Anfon gwahoddiad?" + "Mae %1$s (%2$s) wedi eich gwahodd" + diff --git a/libraries/matrixui/src/main/res/values-da/translations.xml b/libraries/matrixui/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..5b4970f --- /dev/null +++ b/libraries/matrixui/src/main/res/values-da/translations.xml @@ -0,0 +1,7 @@ + + + "Send invitation" + "Kunne du tænke dig at starte en samtale med %1$s?" + "Send invitation?" + "%1$s(%2$s ) inviterede dig" + diff --git a/libraries/matrixui/src/main/res/values-de/translations.xml b/libraries/matrixui/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..d779c15 --- /dev/null +++ b/libraries/matrixui/src/main/res/values-de/translations.xml @@ -0,0 +1,7 @@ + + + "Einladung senden" + "Möchtest du einen Chat mit %1$s starten?" + "Einladung senden?" + "%1$s (%2$s) hat dich eingeladen" + diff --git a/libraries/matrixui/src/main/res/values-el/translations.xml b/libraries/matrixui/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..9e9f195 --- /dev/null +++ b/libraries/matrixui/src/main/res/values-el/translations.xml @@ -0,0 +1,7 @@ + + + "Αποστολή πρόσκλησης" + "Θα θέλατε να ξεκινήσετε μια συνομιλία με τον χρήστη %1$s;" + "Αποστολή πρόσκλησης;" + "%1$s (%2$s) σέ προσκάλεσε" + diff --git a/libraries/matrixui/src/main/res/values-es/translations.xml b/libraries/matrixui/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..5d4ac4c --- /dev/null +++ b/libraries/matrixui/src/main/res/values-es/translations.xml @@ -0,0 +1,7 @@ + + + "Enviar invitación" + "¿Quieres iniciar un chat con %1$s?" + "¿Enviar invitación?" + "%1$s (%2$s) te invitó" + diff --git a/libraries/matrixui/src/main/res/values-et/translations.xml b/libraries/matrixui/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..bd98409 --- /dev/null +++ b/libraries/matrixui/src/main/res/values-et/translations.xml @@ -0,0 +1,7 @@ + + + "Saada kutse" + "Kas sa soovid alustada vestlust kasutajaga %1$s?" + "Kas saadame kutse?" + "%1$s (%2$s) saatis sulle kutse" + diff --git a/libraries/matrixui/src/main/res/values-eu/translations.xml b/libraries/matrixui/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..3bea664 --- /dev/null +++ b/libraries/matrixui/src/main/res/values-eu/translations.xml @@ -0,0 +1,6 @@ + + + "Bidali gonbidapena" + "Gonbidapena bidali?" + "%1$s(e)k (%2$s) gonbidatu zaitu" + diff --git a/libraries/matrixui/src/main/res/values-fa/translations.xml b/libraries/matrixui/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..3f5d32f --- /dev/null +++ b/libraries/matrixui/src/main/res/values-fa/translations.xml @@ -0,0 +1,7 @@ + + + "فرستادن دعوت" + "می‌خواهید گپی را با %1$s بیاغازید؟" + "فرستادن دعوت؟" + "%1$s (%2$s) دعوتتان کرد" + diff --git a/libraries/matrixui/src/main/res/values-fi/translations.xml b/libraries/matrixui/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..daba355 --- /dev/null +++ b/libraries/matrixui/src/main/res/values-fi/translations.xml @@ -0,0 +1,7 @@ + + + "Lähetä kutsu" + "Haluaisitko aloittaa keskustelun käyttäjän %1$s kanssa?" + "Lähetetäänkö kutsu?" + "%1$s (%2$s) kutsui sinut" + diff --git a/libraries/matrixui/src/main/res/values-fr/translations.xml b/libraries/matrixui/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..ca952f5 --- /dev/null +++ b/libraries/matrixui/src/main/res/values-fr/translations.xml @@ -0,0 +1,7 @@ + + + "Envoyer l’invitation" + "Voulez-vous entamer une discussion avec %1$s ?" + "Envoyer l’invitation ?" + "%1$s (%2$s) vous a invité(e)" + diff --git a/libraries/matrixui/src/main/res/values-hu/translations.xml b/libraries/matrixui/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..f22454c --- /dev/null +++ b/libraries/matrixui/src/main/res/values-hu/translations.xml @@ -0,0 +1,7 @@ + + + "Meghívó küldése" + "Csevegést kezd vele: %1$s?" + "Meghívó küldése?" + "%1$s (%2$s) meghívta" + diff --git a/libraries/matrixui/src/main/res/values-in/translations.xml b/libraries/matrixui/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..e7706af --- /dev/null +++ b/libraries/matrixui/src/main/res/values-in/translations.xml @@ -0,0 +1,7 @@ + + + "Kirim undangan" + "Apakah Anda ingin memulai obrolan dengan %1$s?" + "Kirim undangan?" + "%1$s (%2$s) mengundang Anda" + diff --git a/libraries/matrixui/src/main/res/values-it/translations.xml b/libraries/matrixui/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..439d613 --- /dev/null +++ b/libraries/matrixui/src/main/res/values-it/translations.xml @@ -0,0 +1,7 @@ + + + "Invia invito" + "Vuoi iniziare una conversazione con%1$s?" + "Inviare invito?" + "%1$s (%2$s) ti ha invitato" + diff --git a/libraries/matrixui/src/main/res/values-ka/translations.xml b/libraries/matrixui/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..07619e7 --- /dev/null +++ b/libraries/matrixui/src/main/res/values-ka/translations.xml @@ -0,0 +1,4 @@ + + + "%1$s (%2$s) მოგიწვიათ" + diff --git a/libraries/matrixui/src/main/res/values-ko/translations.xml b/libraries/matrixui/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..09d831e --- /dev/null +++ b/libraries/matrixui/src/main/res/values-ko/translations.xml @@ -0,0 +1,7 @@ + + + "초대장 보내기" + "%1$s 와 채팅을 시작하시겠습니까?" + "초대장을 보내시겠습니까?" + "%1$s (%2$s) 당신을 초대했습니다" + diff --git a/libraries/matrixui/src/main/res/values-lt/translations.xml b/libraries/matrixui/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..e80c6ad --- /dev/null +++ b/libraries/matrixui/src/main/res/values-lt/translations.xml @@ -0,0 +1,4 @@ + + + "%1$s(%2$s) pakvietė Jus" + diff --git a/libraries/matrixui/src/main/res/values-nb/translations.xml b/libraries/matrixui/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..01bea5c --- /dev/null +++ b/libraries/matrixui/src/main/res/values-nb/translations.xml @@ -0,0 +1,7 @@ + + + "Send invitasjon" + "Vil du starte en chat med %1$s?" + "Vil du sende invitasjon?" + "%1$s(%2$s) inviterte deg" + diff --git a/libraries/matrixui/src/main/res/values-nl/translations.xml b/libraries/matrixui/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..1886b64 --- /dev/null +++ b/libraries/matrixui/src/main/res/values-nl/translations.xml @@ -0,0 +1,4 @@ + + + "%1$s (%2$s) heeft je uitgenodigd" + diff --git a/libraries/matrixui/src/main/res/values-pl/translations.xml b/libraries/matrixui/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..caa2c0e --- /dev/null +++ b/libraries/matrixui/src/main/res/values-pl/translations.xml @@ -0,0 +1,7 @@ + + + "Wyślij zaproszenie" + "Czy chcesz rozpocząć czat z %1$s?" + "Wysłać zaproszenie?" + "%1$s (%2$s) zaprosił Cię" + diff --git a/libraries/matrixui/src/main/res/values-pt-rBR/translations.xml b/libraries/matrixui/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..ed6acf2 --- /dev/null +++ b/libraries/matrixui/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,7 @@ + + + "Enviar convite" + "Gostaria de iniciar uma conversa com %1$s?" + "Enviar convite?" + "%1$s(%2$s) convidou você" + diff --git a/libraries/matrixui/src/main/res/values-pt/translations.xml b/libraries/matrixui/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..c3cb0dc --- /dev/null +++ b/libraries/matrixui/src/main/res/values-pt/translations.xml @@ -0,0 +1,7 @@ + + + "Enviar convite" + "Gostarias de iniciar uma conversa com %1$s?" + "Enviar convite?" + "%1$s (%2$s) convidou-te" + diff --git a/libraries/matrixui/src/main/res/values-ro/translations.xml b/libraries/matrixui/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..5156d6b --- /dev/null +++ b/libraries/matrixui/src/main/res/values-ro/translations.xml @@ -0,0 +1,7 @@ + + + "Trimiteți invitația" + "Doriți să începeți o discuție cu %1$s?" + "Trimiteți invitația?" + "%1$s (%2$s) v-a invitat." + diff --git a/libraries/matrixui/src/main/res/values-ru/translations.xml b/libraries/matrixui/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..44c3053 --- /dev/null +++ b/libraries/matrixui/src/main/res/values-ru/translations.xml @@ -0,0 +1,7 @@ + + + "Отправить приглашение" + "Хотите начать чат с %1$s?" + "Отправить приглашение?" + "%1$s (%2$s) пригласил вас" + diff --git a/libraries/matrixui/src/main/res/values-sk/translations.xml b/libraries/matrixui/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..57b644f --- /dev/null +++ b/libraries/matrixui/src/main/res/values-sk/translations.xml @@ -0,0 +1,7 @@ + + + "Odoslať pozvánku" + "Chceli by ste začať rozhovor s používateľom %1$s?" + "Poslať pozvánku?" + "%1$s (%2$s) vás pozval/a" + diff --git a/libraries/matrixui/src/main/res/values-sv/translations.xml b/libraries/matrixui/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..7a446ff --- /dev/null +++ b/libraries/matrixui/src/main/res/values-sv/translations.xml @@ -0,0 +1,7 @@ + + + "Skicka inbjudan" + "Vill du starta en chatt med %1$s?" + "Skicka inbjudan?" + "%1$s (%2$s) bjöd in dig" + diff --git a/libraries/matrixui/src/main/res/values-tr/translations.xml b/libraries/matrixui/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..dc065e1 --- /dev/null +++ b/libraries/matrixui/src/main/res/values-tr/translations.xml @@ -0,0 +1,7 @@ + + + "Davet gönder" + "%1$s ile sohbet başlatmak ister misiniz?" + "Davet gönder?" + "%1$s (%2$s) sizi davet etti" + diff --git a/libraries/matrixui/src/main/res/values-uk/translations.xml b/libraries/matrixui/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..324bb96 --- /dev/null +++ b/libraries/matrixui/src/main/res/values-uk/translations.xml @@ -0,0 +1,7 @@ + + + "Надіслати запрошення" + "Хочете розпочати бесіду з %1$s?" + "Надіслати запрошення?" + "%1$s (%2$s) запрошує вас" + diff --git a/libraries/matrixui/src/main/res/values-ur/translations.xml b/libraries/matrixui/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..7a6751a --- /dev/null +++ b/libraries/matrixui/src/main/res/values-ur/translations.xml @@ -0,0 +1,4 @@ + + + "%1$s (%2$s) نے آپ کو مدعو کیا" + diff --git a/libraries/matrixui/src/main/res/values-uz/translations.xml b/libraries/matrixui/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..63add2d --- /dev/null +++ b/libraries/matrixui/src/main/res/values-uz/translations.xml @@ -0,0 +1,7 @@ + + + "Taklif yuborish" + "%1$s bilan chatni boshlashni xohlaysizmi?" + "Taklif yuborilsinmi?" + "%1$s(%2$s ) sizni taklif qildi" + diff --git a/libraries/matrixui/src/main/res/values-zh-rTW/translations.xml b/libraries/matrixui/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..f851e39 --- /dev/null +++ b/libraries/matrixui/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,7 @@ + + + "傳送邀請" + "您想要開始與 %1$s 聊天嗎?" + "傳送邀請?" + "%1$s(%2$s)邀請您" + diff --git a/libraries/matrixui/src/main/res/values-zh/translations.xml b/libraries/matrixui/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..aa8479f --- /dev/null +++ b/libraries/matrixui/src/main/res/values-zh/translations.xml @@ -0,0 +1,7 @@ + + + "发送邀请" + "您想与%1$s 开始聊天吗?" + "发送邀请?" + "%1$s (%2$s)邀请了你" + diff --git a/libraries/matrixui/src/main/res/values/localazy.xml b/libraries/matrixui/src/main/res/values/localazy.xml new file mode 100644 index 0000000..b27021e --- /dev/null +++ b/libraries/matrixui/src/main/res/values/localazy.xml @@ -0,0 +1,7 @@ + + + "Send invite" + "Would you like to start a chat with %1$s?" + "Send invite?" + "%1$s (%2$s) invited you" + diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocumentTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocumentTest.kt new file mode 100644 index 0000000..ad42799 --- /dev/null +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocumentTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.messages + +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody +import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ToHtmlDocumentTest { + @Test + fun `toHtmlDocument - returns null if format is not HTML`() { + val body = FormattedBody( + format = MessageFormat.UNKNOWN, + body = "Hello world" + ) + + val document = body.toHtmlDocument(permalinkParser = FakePermalinkParser()) + + assertThat(document).isNull() + } + + @Test + fun `toHtmlDocument - returns a Document if the format is HTML`() { + val body = FormattedBody( + format = MessageFormat.HTML, + body = "

    Hello world

    " + ) + + val document = body.toHtmlDocument(permalinkParser = FakePermalinkParser()) + assertThat(document).isNotNull() + assertThat(document?.text()).isEqualTo("Hello world") + } + + @Test + fun `toHtmlDocument - returns a Document with a prefix if provided`() { + val body = FormattedBody( + format = MessageFormat.HTML, + body = "

    Hello world

    " + ) + + val document = body.toHtmlDocument( + permalinkParser = FakePermalinkParser(), + prefix = "@Jorge:" + ) + assertThat(document).isNotNull() + assertThat(document?.text()).isEqualTo("@Jorge: Hello world") + } + + @Test + fun `toHtmlDocument - if a mention is found without an '@' prefix, it will be added`() { + val body = FormattedBody( + format = MessageFormat.HTML, + body = "Hey
    Alice!" + ) + + val document = body.toHtmlDocument(permalinkParser = object : PermalinkParser { + override fun parse(uriString: String): PermalinkData { + return PermalinkData.UserLink(UserId("@alice:matrix.org")) + } + }) + assertThat(document?.text()).isEqualTo("Hey @Alice!") + } + + @Test + fun `toHtmlDocument - if a mention is found with an '@' prefix, nothing will be done`() { + val body = FormattedBody( + format = MessageFormat.HTML, + body = "Hey @Alice!" + ) + + val document = body.toHtmlDocument(permalinkParser = object : PermalinkParser { + override fun parse(uriString: String): PermalinkData { + return PermalinkData.UserLink(UserId("@alice:matrix.org")) + } + }) + assertThat(document?.text()).isEqualTo("Hey @Alice!") + } + + @Test + fun `toHtmlDocument - if a link is not a mention, nothing will be done for it`() { + val body = FormattedBody( + format = MessageFormat.HTML, + body = "Hey Alice!" + ) + + val document = body.toHtmlDocument(permalinkParser = object : PermalinkParser { + override fun parse(uriString: String): PermalinkData { + return PermalinkData.FallbackLink(uri = Uri.parse("https://matrix.org")) + } + }) + assertThat(document?.text()).isEqualTo("Hey Alice!") + } +} diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainTextTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainTextTest.kt new file mode 100644 index 0000000..5345113 --- /dev/null +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainTextTest.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.messages + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody +import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat +import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import org.jsoup.Jsoup +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ToPlainTextTest { + @Test + fun `Document toPlainText - returns a plain text version of the document`() { + val document = Jsoup.parse( + """ + Hello world +
    • This is an unordered list.
    +
    1. This is an ordered list.
    +
    + """.trimIndent() + ) + + assertThat(document.toPlainText()).isEqualTo( + """ + Hello world + • This is an unordered list. + 1. This is an ordered list. + """.trimIndent() + ) + } + + @Test + fun `FormattedBody toPlainText - returns a plain text version of the HTML body`() { + val formattedBody = FormattedBody( + format = MessageFormat.HTML, + body = """ + Hello world +
    • This is an unordered list.
    +
    1. This is an ordered list.
    +
    + """.trimIndent() + ) + assertThat(formattedBody.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo( + """ + Hello world + • This is an unordered list. + 1. This is an ordered list. + """.trimIndent() + ) + } + + @Test + fun `FormattedBody toPlainText - returns null if the format is not HTML`() { + val formattedBody = FormattedBody( + format = MessageFormat.UNKNOWN, + body = """ + Hello world +
    • This is an unordered list.
    +
    1. This is an ordered list.
    +
    + """.trimIndent() + ) + assertThat(formattedBody.toPlainText(permalinkParser = FakePermalinkParser())).isNull() + } + + @Test + fun `TextMessageType toPlainText - returns a plain text version of the HTML body`() { + val messageType = TextMessageType( + body = "Hello world\n- This in an unordered list.\n1. This is an ordered list.\n", + formatted = FormattedBody( + format = MessageFormat.HTML, + body = """ + Hello world +
    • This is an unordered list.
    +
    1. This is an ordered list.
    +
    + """.trimIndent() + ) + ) + assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo( + """ + Hello world + • This is an unordered list. + 1. This is an ordered list. + """.trimIndent() + ) + } + + @Test + fun `TextMessageType toPlainText - respects the ol start attr if present`() { + val messageType = TextMessageType( + body = "1. First item\n2. Second item\n", + formatted = FormattedBody( + format = MessageFormat.HTML, + body = """ +
      +
    1. First item.
    2. +
    3. Second item.
    4. +
    +
    + """.trimIndent() + ) + ) + assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo( + """ + 11. First item. + 12. Second item. + """.trimIndent() + ) + } + + @Test + fun `TextMessageType toPlainText - returns the markdown body if the formatted one cannot be parsed`() { + val messageType = TextMessageType( + body = "This is the fallback text", + formatted = FormattedBody( + format = MessageFormat.UNKNOWN, + body = """ + Hello world +
    • This is an unordered list.
    +
    1. This is an ordered list.
    +
    + """.trimIndent() + ) + ) + assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo("This is the fallback text") + } +} diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailTest.kt new file mode 100644 index 0000000..9507a30 --- /dev/null +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.messages.reply + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody +import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo +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.MessageFormat +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_USER_ID +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.test.timeline.aProfileDetails +import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent +import org.junit.Test + +class InReplyToDetailTest { + @Test + fun `map - with a not ready InReplyTo return expected object`() { + assertThat( + InReplyTo.Pending(AN_EVENT_ID).map( + permalinkParser = FakePermalinkParser() + ) + ).isEqualTo(InReplyToDetails.Loading(AN_EVENT_ID)) + assertThat( + InReplyTo.NotLoaded(AN_EVENT_ID).map( + permalinkParser = FakePermalinkParser() + ) + ).isEqualTo(InReplyToDetails.Loading(AN_EVENT_ID)) + assertThat( + InReplyTo.Error(AN_EVENT_ID, "a message").map( + permalinkParser = FakePermalinkParser() + ) + ).isEqualTo(InReplyToDetails.Error(AN_EVENT_ID, "a message")) + } + + @Test + fun `map - with something other than a MessageContent has no textContent`() { + val inReplyTo = InReplyTo.Ready( + eventId = AN_EVENT_ID, + senderId = A_USER_ID, + senderProfile = aProfileDetails(), + content = aRoomMembershipContent( + userId = A_USER_ID, + change = MembershipChange.INVITED, + ) + ) + val inReplyToDetails = inReplyTo.map( + permalinkParser = FakePermalinkParser() + ) + assertThat(inReplyToDetails).isNotNull() + assertThat((inReplyToDetails as InReplyToDetails.Ready).textContent).isNull() + } + + @Test + fun `map - with a message content tries to use the formatted text if exists for its textContent`() { + val inReplyTo = InReplyTo.Ready( + eventId = AN_EVENT_ID, + senderId = A_USER_ID, + senderProfile = aProfileDetails(), + content = MessageContent( + body = "**Hello!**", + inReplyTo = null, + isEdited = false, + threadInfo = null, + type = TextMessageType( + body = "**Hello!**", + formatted = FormattedBody( + format = MessageFormat.HTML, + body = "

    Hello!

    " + ) + ) + ) + ) + assertThat( + (inReplyTo.map(permalinkParser = FakePermalinkParser()) as InReplyToDetails.Ready).textContent + ).isEqualTo("Hello!") + } + + @Test + fun `map - with a message content and no formatted body uses body as fallback for textContent`() { + val inReplyTo = InReplyTo.Ready( + eventId = AN_EVENT_ID, + senderId = A_USER_ID, + senderProfile = aProfileDetails(), + content = MessageContent( + body = "**Hello!**", + inReplyTo = null, + isEdited = false, + threadInfo = null, + type = TextMessageType( + body = "**Hello!**", + formatted = null, + ) + ) + ) + assertThat( + (inReplyTo.map(permalinkParser = FakePermalinkParser()) as InReplyToDetails.Ready).textContent + ).isEqualTo("**Hello!**") + } +} diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt new file mode 100644 index 0000000..e7cae11 --- /dev/null +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt @@ -0,0 +1,586 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.messages.reply + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.mimetype.MimeTypes +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.VideoInfo +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +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.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.OtherState +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +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.api.timeline.item.event.VideoMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType +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.media.aMediaSource +import io.element.android.libraries.matrix.test.timeline.aMessageContent +import io.element.android.libraries.matrix.test.timeline.aPollContent +import io.element.android.libraries.matrix.test.timeline.aProfileDetails +import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent +import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.tests.testutils.withConfigurationAndContext +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.time.Duration.Companion.minutes + +@RunWith(AndroidJUnit4::class) +class InReplyToMetadataKtTest { + @Test + fun `any message content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady(eventContent = aMessageContent()).metadata(hideImage = false) + }.test { + awaitItem().let { + assertThat(it).isEqualTo(InReplyToMetadata.Text("textContent")) + } + } + } + + @Test + fun `an image message content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = aMessageContent( + messageType = ImageMessageType( + filename = "filename", + caption = null, + formattedCaption = null, + source = aMediaSource(), + info = anImageInfo(), + ) + ) + ).metadata(hideImage = false) + }.test { + awaitItem().let { + assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = aMediaSource(), + textContent = "body", + type = AttachmentThumbnailType.Image, + blurHash = A_BLUR_HASH, + ) + ) + ) + } + } + } + + @Test + fun `an image message content, no thumbnail`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = aMessageContent( + messageType = ImageMessageType( + filename = "filename", + caption = "caption", + formattedCaption = null, + source = aMediaSource(), + info = anImageInfo(), + ) + ) + ).metadata(hideImage = true) + }.test { + awaitItem().let { + assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = null, + textContent = "body", + type = AttachmentThumbnailType.Image, + blurHash = A_BLUR_HASH, + ) + ) + ) + } + } + } + + @Test + fun `a sticker message content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = StickerContent( + filename = "filename", + body = "body", + info = anImageInfo(), + source = aMediaSource(url = "url") + ) + ).metadata(hideImage = false) + }.test { + awaitItem().let { + assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = aMediaSource(url = "url"), + textContent = "body", + type = AttachmentThumbnailType.Image, + blurHash = A_BLUR_HASH, + ) + ) + ) + } + } + } + + @Test + fun `a sticker message content, no thumbnail`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = StickerContent( + filename = "filename", + body = "body", + info = anImageInfo(), + source = aMediaSource(url = "url") + ) + ).metadata(hideImage = true) + }.test { + awaitItem().let { + assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = null, + textContent = "body", + type = AttachmentThumbnailType.Image, + blurHash = A_BLUR_HASH, + ) + ) + ) + } + } + } + + @Test + fun `a video message content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = aMessageContent( + messageType = VideoMessageType( + filename = "filename", + caption = null, + formattedCaption = null, + source = aMediaSource(), + info = aVideoInfo(), + ) + ) + ).metadata(hideImage = false) + }.test { + awaitItem().let { + assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = aMediaSource(), + textContent = "body", + type = AttachmentThumbnailType.Video, + blurHash = A_BLUR_HASH, + ) + ) + ) + } + } + } + + @Test + fun `a video message content, no thumbnail`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = aMessageContent( + messageType = VideoMessageType( + filename = "filename", + caption = "caption", + formattedCaption = null, + source = aMediaSource(), + info = aVideoInfo(), + ) + ) + ).metadata(hideImage = true) + }.test { + awaitItem().let { + assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = null, + textContent = "body", + type = AttachmentThumbnailType.Video, + blurHash = A_BLUR_HASH, + ) + ) + ) + } + } + } + + @Test + fun `a file message content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = aMessageContent( + messageType = FileMessageType( + filename = "filename", + caption = "caption", + formattedCaption = null, + source = aMediaSource(), + info = FileInfo( + mimetype = null, + size = null, + thumbnailInfo = null, + thumbnailSource = aMediaSource(), + ), + ) + ) + ).metadata(hideImage = false) + }.test { + awaitItem().let { + assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = aMediaSource(), + textContent = "body", + type = AttachmentThumbnailType.File, + blurHash = null, + ) + ) + ) + } + } + } + + @Test + fun `a file message content, no thumbnail`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = aMessageContent( + messageType = FileMessageType( + filename = "filename", + caption = "caption", + formattedCaption = null, + source = aMediaSource(), + info = FileInfo( + mimetype = null, + size = null, + thumbnailInfo = null, + thumbnailSource = aMediaSource(), + ), + ) + ) + ).metadata(hideImage = true) + }.test { + awaitItem().let { + assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = null, + textContent = "body", + type = AttachmentThumbnailType.File, + blurHash = null, + ) + ) + ) + } + } + } + + @Test + fun `a audio message content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = aMessageContent( + messageType = AudioMessageType( + filename = "filename", + caption = "caption", + formattedCaption = null, + source = aMediaSource(), + info = AudioInfo( + duration = null, + size = null, + mimetype = null + ), + ) + ) + ).metadata(hideImage = false) + }.test { + awaitItem().let { + assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + textContent = "body", + type = AttachmentThumbnailType.Audio, + blurHash = null, + ) + ) + ) + } + } + } + + @Test + fun `a location message content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + withConfigurationAndContext { + anInReplyToDetailsReady( + eventContent = aMessageContent( + messageType = LocationMessageType( + body = "body", + geoUri = "geo:3.0,4.0;u=5.0", + description = null, + ) + ) + ).metadata(hideImage = false) + } + }.test { + awaitItem().let { + assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = null, + textContent = "Shared location", + type = AttachmentThumbnailType.Location, + blurHash = null, + ) + ) + ) + } + } + } + + @Test + fun `a voice message content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + withConfigurationAndContext { + anInReplyToDetailsReady( + eventContent = aMessageContent( + messageType = VoiceMessageType( + filename = "filename", + caption = "caption", + formattedCaption = null, + source = aMediaSource(), + info = null, + details = null, + ) + ) + ).metadata(hideImage = false) + } + }.test { + awaitItem().let { + assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = null, + textContent = "Voice message", + type = AttachmentThumbnailType.Voice, + blurHash = null, + ) + ) + ) + } + } + } + + @Test + fun `a poll content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = aPollContent() + ).metadata(hideImage = false) + }.test { + awaitItem().let { + assertThat(it).isEqualTo( + InReplyToMetadata.Thumbnail( + attachmentThumbnailInfo = AttachmentThumbnailInfo( + thumbnailSource = null, + textContent = "Do you like polls?", + type = AttachmentThumbnailType.Poll, + blurHash = null, + ) + ) + ) + } + } + } + + @Test + fun `redacted content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = RedactedContent + ).metadata(hideImage = false) + }.test { + awaitItem().let { + assertThat(it).isEqualTo(InReplyToMetadata.Redacted) + } + } + } + + @Test + fun `unable to decrypt content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = UnableToDecryptContent(UnableToDecryptContent.Data.Unknown) + ).metadata(hideImage = false) + }.test { + awaitItem().let { + assertThat(it).isEqualTo(InReplyToMetadata.UnableToDecrypt) + } + } + } + + @Test + fun `failed to parse message content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = FailedToParseMessageLikeContent("", "") + ).metadata(hideImage = false) + }.test { + awaitItem().let { + assertThat(it).isNull() + } + } + } + + @Test + fun `failed to parse state content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = FailedToParseStateContent("", "", "") + ).metadata(hideImage = false) + }.test { + awaitItem().let { + assertThat(it).isNull() + } + } + } + + @Test + fun `profile change content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = ProfileChangeContent("", "", "", "") + ).metadata(hideImage = false) + }.test { + awaitItem().let { + assertThat(it).isNull() + } + } + } + + @Test + fun `room membership content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = aRoomMembershipContent(userId = A_USER_ID) + ).metadata(hideImage = false) + }.test { + awaitItem().let { + assertThat(it).isNull() + } + } + } + + @Test + fun `state content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = StateContent("", OtherState.RoomJoinRules(null)) + ).metadata(hideImage = false) + }.test { + awaitItem().let { + assertThat(it).isNull() + } + } + } + + @Test + fun `unknown content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = UnknownContent + ).metadata(hideImage = false) + }.test { + awaitItem().let { + assertThat(it).isNull() + } + } + } + + @Test + fun `null content`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + anInReplyToDetailsReady( + eventContent = null + ).metadata(hideImage = false) + }.test { + awaitItem().let { + assertThat(it).isNull() + } + } + } +} + +private fun anInReplyToDetailsReady( + eventId: EventId = AN_EVENT_ID, + senderId: UserId = A_USER_ID, + senderProfile: ProfileDetails = aProfileDetails(), + eventContent: EventContent? = aMessageContent(), + textContent: String? = "textContent", +) = InReplyToDetails.Ready( + eventId = eventId, + senderId = senderId, + senderProfile = senderProfile, + eventContent = eventContent, + textContent = textContent, +) + +fun aVideoInfo(): VideoInfo { + return VideoInfo( + duration = 1.minutes, + height = 100, + width = 100, + mimetype = "video/mp4", + size = 1000, + thumbnailInfo = null, + thumbnailSource = aMediaSource(), + blurhash = A_BLUR_HASH, + ) +} + +fun anImageInfo(): ImageInfo { + return ImageInfo( + height = 100, + width = 100, + mimetype = MimeTypes.Jpeg, + size = 1000, + thumbnailInfo = null, + thumbnailSource = aMediaSource(), + blurhash = A_BLUR_HASH, + ) +} diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt new file mode 100644 index 0000000..dd9d801 --- /dev/null +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.model + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +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 +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.withConfigurationAndContext +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MatrixUserExtensionsTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `getAvatarData should return the expected value`() { + val matrixUser = MatrixUser( + userId = A_USER_ID, + displayName = "displayName", + avatarUrl = "avatarUrl", + ) + val expected = AvatarData( + id = A_USER_ID.value, + name = "displayName", + url = "avatarUrl", + size = AvatarSize.UserHeader, + ) + assertThat(matrixUser.getAvatarData(AvatarSize.UserHeader)).isEqualTo(expected) + } + + @Test + fun `getBestName should return the display name is available`() { + val matrixUser = MatrixUser( + userId = A_USER_ID, + displayName = "displayName", + ) + assertThat(matrixUser.getBestName()).isEqualTo("displayName") + } + + @Test + fun `getBestName should return the id when name is not available`() { + val matrixUser = MatrixUser( + userId = A_USER_ID, + displayName = null, + ) + assertThat(matrixUser.getBestName()).isEqualTo(A_USER_ID.value) + } + + @Test + fun `getBestName should return the id when name is empty`() { + val matrixUser = MatrixUser( + userId = A_USER_ID, + displayName = "", + ) + assertThat(matrixUser.getBestName()).isEqualTo(A_USER_ID.value) + } + + @Test + fun `getFullName should return the display name is available and the userId`() = runTest { + val matrixUser = MatrixUser( + userId = A_USER_ID, + displayName = "displayName", + ) + moleculeFlow(RecompositionMode.Immediate) { + withConfigurationAndContext { + matrixUser.getFullName() + } + }.test { + assertThat(awaitItem()).isEqualTo("displayName (@alice:server.org)") + } + } + + @Test + fun `getBestName should return only the id when name is not available`() = runTest { + val matrixUser = MatrixUser( + userId = A_USER_ID, + displayName = null, + ) + moleculeFlow(RecompositionMode.Immediate) { + matrixUser.getFullName() + }.test { + assertThat(awaitItem()).isEqualTo(A_USER_ID.value) + } + } +} diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/RoomInfoExtensionTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/RoomInfoExtensionTest.kt new file mode 100644 index 0000000..c6dcc27 --- /dev/null +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/RoomInfoExtensionTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.model + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels +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_ID_3 +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues +import kotlinx.collections.immutable.toImmutableMap +import org.junit.Test + +class RoomInfoExtensionTest { + @Test + fun `roleOf returns Owner for creator with privilegedCreatorRole true`() { + val roomInfo = aRoomInfo( + privilegedCreatorRole = true, + roomCreators = listOf(A_USER_ID), + ) + assertThat(roomInfo.roleOf(A_USER_ID)).isEqualTo(RoomMember.Role.Owner(isCreator = true)) + } + + @Test + fun `roleOf returns User for not creator with privilegedCreatorRole true`() { + val roomInfo = aRoomInfo( + privilegedCreatorRole = true, + roomCreators = listOf(A_USER_ID), + ) + assertThat(roomInfo.roleOf(A_USER_ID_2)).isEqualTo(RoomMember.Role.User) + } + + @Test + fun `roleOf returns User for creator with privilegedCreatorRole false`() { + val roomInfo = aRoomInfo( + privilegedCreatorRole = false, + roomCreators = listOf(A_USER_ID), + ) + assertThat(roomInfo.roleOf(A_USER_ID)).isEqualTo(RoomMember.Role.User) + } + + @Test + fun `roleOf returns role from the power level`() { + val roomInfo = aRoomInfo( + privilegedCreatorRole = false, + roomPowerLevels = RoomPowerLevels( + values = defaultRoomPowerLevelValues(), + users = mapOf( + A_USER_ID to 100L, // Admin + A_USER_ID_2 to 50L, // Moderator + A_USER_ID_3 to 0L, // User + ).toImmutableMap(), + ), + roomCreators = listOf(A_USER_ID), + ) + assertThat(roomInfo.roleOf(A_USER_ID)).isEqualTo(RoomMember.Role.Admin) + assertThat(roomInfo.roleOf(A_USER_ID_2)).isEqualTo(RoomMember.Role.Moderator) + assertThat(roomInfo.roleOf(A_USER_ID_3)).isEqualTo(RoomMember.Role.User) + } + + @Test + fun `roleOf returns User when the power level is null`() { + val roomInfo = aRoomInfo( + privilegedCreatorRole = false, + roomPowerLevels = null, + roomCreators = listOf(A_USER_ID), + ) + assertThat(roomInfo.roleOf(A_USER_ID)).isEqualTo(RoomMember.Role.User) + assertThat(roomInfo.roleOf(A_USER_ID_2)).isEqualTo(RoomMember.Role.User) + assertThat(roomInfo.roleOf(A_USER_ID_3)).isEqualTo(RoomMember.Role.User) + } +} diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/room/ObserveRoomMemberIdentityStateChangeTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/room/ObserveRoomMemberIdentityStateChangeTest.kt new file mode 100644 index 0000000..d8ea157 --- /dev/null +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/room/ObserveRoomMemberIdentityStateChangeTest.kt @@ -0,0 +1,404 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.room + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange +import io.element.android.libraries.matrix.api.room.RoomMembersState +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_ID_3 +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.room.aRoomMember +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ObserveRoomMemberIdentityStateChangeTest { + private val aliceRoomMember = aRoomMember(A_USER_ID, displayName = "Alice") + private val bobRoomMember = aRoomMember(A_USER_ID_2, displayName = "Bob") + private val carolRoomMember = aRoomMember(A_USER_ID_3, displayName = "Carol") + + @Test + fun `roomMemberIdentityStateChange emits empty list for non-encrypted room with no identity changes`() = + runTest { + val identityStateChangesFlow = MutableStateFlow>(emptyList()) + + val joinedRoom = FakeJoinedRoom( + identityStateChangesFlow = identityStateChangesFlow, + baseRoom = FakeJoinedRoom().baseRoom.apply { + givenRoomInfo(aRoomInfo(isEncrypted = false)) + givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf( + aliceRoomMember, + bobRoomMember + ) + ) + ) + } + ) + + joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test { + val result = awaitItem() + assertThat(result).isEmpty() + } + } + + @Test + fun `roomMemberIdentityStateChange emits identity changes for non-encrypted room when waitForEncryption is false`() = + runTest { + val identityStateChangesFlow = MutableStateFlow( + listOf( + IdentityStateChange(bobRoomMember.userId, IdentityState.Verified), + IdentityStateChange(carolRoomMember.userId, IdentityState.PinViolation) + ) + ) + + val joinedRoom = FakeJoinedRoom( + identityStateChangesFlow = identityStateChangesFlow, + baseRoom = FakeJoinedRoom().baseRoom.apply { + givenRoomInfo(aRoomInfo(isEncrypted = false)) + givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf( + aliceRoomMember, + bobRoomMember, + carolRoomMember + ) + ) + ) + } + ) + + joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test { + val result = awaitItem() + assertThat(result).hasSize(2) + + val bobChange = result.find { it.identityRoomMember.userId == bobRoomMember.userId } + assertThat(bobChange).isNotNull() + assertThat(bobChange?.identityState).isEqualTo(IdentityState.Verified) + assertThat(bobChange?.identityRoomMember?.displayNameOrDefault).isEqualTo("Bob") + + val carolChange = result.find { it.identityRoomMember.userId == carolRoomMember.userId } + assertThat(carolChange).isNotNull() + assertThat(carolChange?.identityState).isEqualTo(IdentityState.PinViolation) + assertThat(carolChange?.identityRoomMember?.displayNameOrDefault).isEqualTo("Carol") + } + } + + @Test + fun `roomMemberIdentityStateChange emits identity changes for already encrypted room`() = + runTest { + val identityStateChangesFlow = MutableStateFlow( + listOf( + IdentityStateChange(bobRoomMember.userId, IdentityState.VerificationViolation) + ) + ) + + val joinedRoom = FakeJoinedRoom( + identityStateChangesFlow = identityStateChangesFlow, + baseRoom = FakeJoinedRoom().baseRoom.apply { + givenRoomInfo(aRoomInfo(isEncrypted = true)) + givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf( + aliceRoomMember, + bobRoomMember + ) + ) + ) + } + ) + + joinedRoom.roomMemberIdentityStateChange(waitForEncryption = true).test { + val result = awaitItem() + assertThat(result).hasSize(1) + + val bobChange = result.first() + assertThat(bobChange.identityRoomMember.userId).isEqualTo(bobRoomMember.userId) + assertThat(bobChange.identityState).isEqualTo(IdentityState.VerificationViolation) + assertThat(bobChange.identityRoomMember.displayNameOrDefault).isEqualTo("Bob") + } + } + + @Test + fun `roomMemberIdentityStateChange waits for encryption before emitting when waitForEncryption is true`() = + runTest { + val identityStateChangesFlow = MutableStateFlow( + listOf(IdentityStateChange(bobRoomMember.userId, IdentityState.Pinned)) + ) + val joinedRoom = FakeJoinedRoom( + identityStateChangesFlow = identityStateChangesFlow, + baseRoom = FakeJoinedRoom().baseRoom.apply { + givenRoomInfo(aRoomInfo(isEncrypted = false)) + givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf( + aliceRoomMember, + bobRoomMember + ) + ) + ) + } + ) + + joinedRoom.roomMemberIdentityStateChange(waitForEncryption = true).test { + // Should not emit anything yet since room is not encrypted + expectNoEvents() + + // Enable encryption + joinedRoom.baseRoom.givenRoomInfo(aRoomInfo(isEncrypted = true)) + + val result = awaitItem() + assertThat(result).hasSize(1) + assertThat(result.first().identityRoomMember.userId).isEqualTo(bobRoomMember.userId) + assertThat(result.first().identityState).isEqualTo(IdentityState.Pinned) + } + } + + @Test + fun `roomMemberIdentityStateChange creates default member when room member not found`() = + runTest { + val identityStateChangesFlow = MutableStateFlow( + listOf(IdentityStateChange(carolRoomMember.userId, IdentityState.PinViolation)) + ) + + val joinedRoom = FakeJoinedRoom( + identityStateChangesFlow = identityStateChangesFlow, + baseRoom = FakeJoinedRoom().baseRoom.apply { + givenRoomInfo(aRoomInfo(isEncrypted = true)) + // Only include aliceRoomMember and bobRoomMember, not carolRoomMember + givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf( + aliceRoomMember, + bobRoomMember + ) + ) + ) + } + ) + + joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test { + val result = awaitItem() + assertThat(result).hasSize(1) + + val carolChange = result.first() + assertThat(carolChange.identityRoomMember.userId).isEqualTo(carolRoomMember.userId) + assertThat(carolChange.identityState).isEqualTo(IdentityState.PinViolation) + // Should use extracted display name from user ID since member not found + assertThat(carolChange.identityRoomMember.displayNameOrDefault).isEqualTo( + carolRoomMember.userId.extractedDisplayName + ) + } + } + + @Test + fun `roomMemberIdentityStateChange updates when identity state changes`() = runTest { + val identityStateChangesFlow = MutableStateFlow( + listOf(IdentityStateChange(bobRoomMember.userId, IdentityState.Pinned)) + ) + + val joinedRoom = FakeJoinedRoom( + identityStateChangesFlow = identityStateChangesFlow, + baseRoom = FakeJoinedRoom().baseRoom.apply { + givenRoomInfo(aRoomInfo(isEncrypted = true)) + givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf( + aliceRoomMember, + bobRoomMember + ) + ) + ) + } + ) + + joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test { + val firstResult = awaitItem() + assertThat(firstResult).hasSize(1) + assertThat(firstResult.first().identityState).isEqualTo(IdentityState.Pinned) + + // Update identity state + identityStateChangesFlow.value = listOf( + IdentityStateChange(bobRoomMember.userId, IdentityState.VerificationViolation) + ) + + val secondResult = awaitItem() + assertThat(secondResult).hasSize(1) + assertThat(secondResult.first().identityState).isEqualTo(IdentityState.VerificationViolation) + } + } + + @Test + fun `roomMemberIdentityStateChange updates when members state changes`() = runTest { + val identityStateChangesFlow = MutableStateFlow( + listOf(IdentityStateChange(bobRoomMember.userId, IdentityState.Verified)) + ) + + val joinedRoom = FakeJoinedRoom( + identityStateChangesFlow = identityStateChangesFlow, + baseRoom = FakeJoinedRoom().baseRoom.apply { + givenRoomInfo(aRoomInfo(isEncrypted = true)) + givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf( + aliceRoomMember, + bobRoomMember + ) + ) + ) + } + ) + + joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test { + val firstResult = awaitItem() + assertThat(firstResult).hasSize(1) + assertThat(firstResult.first().identityRoomMember.displayNameOrDefault).isEqualTo("Bob") + + // Update room member with different display name + val updatedMember2 = bobRoomMember.copy(displayName = "Bobby") + joinedRoom.baseRoom.givenRoomMembersState( + RoomMembersState.Ready(persistentListOf(aliceRoomMember, updatedMember2)) + ) + + val secondResult = awaitItem() + assertThat(secondResult).hasSize(1) + assertThat(secondResult.first().identityRoomMember.displayNameOrDefault).isEqualTo("Bobby") + } + } + + @Test + fun `roomMemberIdentityStateChange handles multiple identity states`() = runTest { + val identityStateChangesFlow = MutableStateFlow( + listOf( + IdentityStateChange(aliceRoomMember.userId, IdentityState.Verified), + IdentityStateChange(bobRoomMember.userId, IdentityState.PinViolation), + IdentityStateChange(carolRoomMember.userId, IdentityState.VerificationViolation) + ) + ) + + val joinedRoom = FakeJoinedRoom( + identityStateChangesFlow = identityStateChangesFlow, + baseRoom = FakeJoinedRoom().baseRoom.apply { + givenRoomInfo(aRoomInfo(isEncrypted = true)) + givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf( + aliceRoomMember, + bobRoomMember, + carolRoomMember + ) + ) + ) + } + ) + + joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test { + val result = awaitItem() + assertThat(result).hasSize(3) + + val verifiedUser = result.find { it.identityState == IdentityState.Verified } + assertThat(verifiedUser?.identityRoomMember?.userId).isEqualTo(aliceRoomMember.userId) + + val pinViolationUser = result.find { it.identityState == IdentityState.PinViolation } + assertThat(pinViolationUser?.identityRoomMember?.userId).isEqualTo(bobRoomMember.userId) + + val verificationViolationUser = + result.find { it.identityState == IdentityState.VerificationViolation } + assertThat(verificationViolationUser?.identityRoomMember?.userId).isEqualTo(carolRoomMember.userId) + } + } + + @Test + fun `roomMemberIdentityStateChange handles room becoming encrypted scenario`() = runTest { + val identityStateChangesFlow = MutableStateFlow( + listOf(IdentityStateChange(bobRoomMember.userId, IdentityState.Pinned)) + ) + + val joinedRoom = FakeJoinedRoom( + identityStateChangesFlow = identityStateChangesFlow, + baseRoom = FakeJoinedRoom().baseRoom.apply { + givenRoomInfo(aRoomInfo(isEncrypted = false)) + givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf( + aliceRoomMember, + bobRoomMember + ) + ) + ) + } + ) + + joinedRoom.roomMemberIdentityStateChange(waitForEncryption = true).test { + // Should not emit anything initially as room is not encrypted + expectNoEvents() + + // Room becomes encrypted + joinedRoom.baseRoom.givenRoomInfo(aRoomInfo(isEncrypted = true)) + + val result = awaitItem() + assertThat(result).hasSize(1) + assertThat(result.first().identityRoomMember.userId).isEqualTo(bobRoomMember.userId) + assertThat(result.first().identityState).isEqualTo(IdentityState.Pinned) + + // Add more identity changes after encryption is enabled + identityStateChangesFlow.value = listOf( + IdentityStateChange(bobRoomMember.userId, IdentityState.Pinned), + IdentityStateChange(aliceRoomMember.userId, IdentityState.VerificationViolation) + ) + + val updatedResult = awaitItem() + assertThat(updatedResult).hasSize(2) + } + } + + @Test + fun `roomMemberIdentityStateChange does not emit duplicates for same state`() = runTest { + val identityStateChangesFlow = MutableSharedFlow>() + val identityStateChanges = listOf( + IdentityStateChange(bobRoomMember.userId, IdentityState.Verified) + ) + + val joinedRoom = FakeJoinedRoom( + identityStateChangesFlow = identityStateChangesFlow, + baseRoom = FakeJoinedRoom().baseRoom.apply { + givenRoomInfo(aRoomInfo(isEncrypted = true)) + givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf( + aliceRoomMember, + bobRoomMember + ) + ) + ) + } + ) + + joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test { + identityStateChangesFlow.emit(identityStateChanges) + + val firstResult = awaitItem() + assertThat(firstResult).hasSize(1) + + // Emit the same state again + identityStateChangesFlow.emit(identityStateChanges) + + // Should not emit a new item due to distinctUntilChanged + expectNoEvents() + } + } +} diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/room/PowerLevelRoomMemberComparatorTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/room/PowerLevelRoomMemberComparatorTest.kt new file mode 100644 index 0000000..d824071 --- /dev/null +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/room/PowerLevelRoomMemberComparatorTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.room + +import io.element.android.libraries.matrix.api.core.UserId +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_ID_3 +import io.element.android.libraries.matrix.test.A_USER_ID_4 +import io.element.android.libraries.matrix.test.A_USER_ID_5 +import io.element.android.libraries.matrix.test.room.aRoomMember +import org.junit.Test + +class PowerLevelRoomMemberComparatorTest { + @Test + fun `order is Admin, then Moderator, then User`() { + val memberList = listOf( + aRoomMember(userId = UserId("@admin:example.com"), powerLevel = 100), + aRoomMember(userId = UserId("@moderator:example.com"), powerLevel = 50), + aRoomMember(userId = UserId("@user:example.com"), powerLevel = 0), + ).shuffled() + + val ordered = memberList.sortedWith(PowerLevelRoomMemberComparator()) + assert(ordered[0].userId == UserId("@admin:example.com")) + assert(ordered[1].userId == UserId("@moderator:example.com")) + assert(ordered[2].userId == UserId("@user:example.com")) + } + + @Test + fun `with the same power level, alphabetical ascending order for name is used`() { + val memberList = listOf( + aRoomMember(userId = A_USER_ID, displayName = "First - admin", powerLevel = 100), + aRoomMember(userId = A_USER_ID_2, displayName = "Second - admin", powerLevel = 100), + aRoomMember(userId = A_USER_ID_3, displayName = "Third - admin", powerLevel = 100), + aRoomMember(userId = A_USER_ID_4, displayName = "First - user", powerLevel = 0), + aRoomMember(userId = A_USER_ID_5, displayName = "Second - user", powerLevel = 0), + ).shuffled() + + val ordered = memberList.sortedWith(PowerLevelRoomMemberComparator()) + assert(ordered[0].userId == A_USER_ID) + assert(ordered[1].userId == A_USER_ID_2) + assert(ordered[2].userId == A_USER_ID_3) + assert(ordered[3].userId == A_USER_ID_4) + assert(ordered[4].userId == A_USER_ID_5) + } + + @Test + fun `when no names are provided, alphabetical order uses user id`() { + val memberList = listOf( + aRoomMember(userId = A_USER_ID, displayName = "Z - LAST!", powerLevel = 100), + aRoomMember(userId = A_USER_ID_2, powerLevel = 100), + aRoomMember(userId = A_USER_ID_3, powerLevel = 100), + ).shuffled() + + val ordered = memberList.sortedWith(PowerLevelRoomMemberComparator()) + assert(ordered[0].userId == A_USER_ID_2) + assert(ordered[1].userId == A_USER_ID_3) + assert(ordered[2].userId == A_USER_ID) + } + + @Test + fun `unicode characters are simplified and compared, order ignores case`() { + val memberList = listOf( + aRoomMember(userId = A_USER_ID, displayName = "First", powerLevel = 100), + aRoomMember(userId = A_USER_ID_2, displayName = "Șecond", powerLevel = 100), + aRoomMember(userId = A_USER_ID_3, displayName = "third", powerLevel = 100), + ).shuffled() + + val ordered = memberList.sortedWith(PowerLevelRoomMemberComparator()) + assert(ordered[0].userId == A_USER_ID) + assert(ordered[1].userId == A_USER_ID_2) + assert(ordered[2].userId == A_USER_ID_3) + } +} diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/room/RoomMembersTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/room/RoomMembersTest.kt new file mode 100644 index 0000000..816ac09 --- /dev/null +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/room/RoomMembersTest.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.room + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.RoomMembersState +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.A_USER_ID_3 +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.room.aRoomMember +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RoomMembersTest { + private val roomMember1 = aRoomMember(A_USER_ID) + private val roomMember2 = aRoomMember(A_USER_ID_2) + private val roomMember3 = aRoomMember(A_USER_ID_3) + + @Test + fun `getDirectRoomMember emits other member for encrypted DM with 2 joined members`() = runTest { + val joinedRoom = FakeBaseRoom( + sessionId = A_USER_ID, + initialRoomInfo = aRoomInfo( + isDirect = true, + joinedMembersCount = 2, + ) + ) + moleculeFlow(RecompositionMode.Immediate) { + joinedRoom.getDirectRoomMember( + RoomMembersState.Ready(persistentListOf(roomMember1, roomMember2)) + ) + }.test { + assertThat(awaitItem().value).isEqualTo(roomMember2) + } + } + + @Test + fun `getDirectRoomMember emit null if the room is not a dm`() = runTest { + val joinedRoom = FakeBaseRoom( + sessionId = A_USER_ID, + initialRoomInfo = aRoomInfo(isDirect = false) + ) + moleculeFlow(RecompositionMode.Immediate) { + joinedRoom.getDirectRoomMember( + RoomMembersState.Ready(persistentListOf(roomMember1, roomMember2)) + ) + }.test { + assertThat(awaitItem().value).isNull() + } + } + + @Test + fun `getDirectRoomMember emits other member even if the room is not encrypted`() = runTest { + val joinedRoom = FakeBaseRoom( + sessionId = A_USER_ID, + initialRoomInfo = aRoomInfo( + isDirect = true, + activeMembersCount = 2, + ) + ) + moleculeFlow(RecompositionMode.Immediate) { + joinedRoom.getDirectRoomMember( + RoomMembersState.Ready(persistentListOf(roomMember1, roomMember2)) + ) + }.test { + assertThat(awaitItem().value).isEqualTo(roomMember2) + } + } + + @Test + fun `getDirectRoomMember emit null if the room has only 1 member`() = runTest { + val joinedRoom = FakeBaseRoom( + sessionId = A_USER_ID, + initialRoomInfo = aRoomInfo(isDirect = true) + ) + moleculeFlow(RecompositionMode.Immediate) { + joinedRoom.getDirectRoomMember( + RoomMembersState.Ready(persistentListOf(roomMember1)) + ) + }.test { + assertThat(awaitItem().value).isNull() + } + } + + @Test + fun `getDirectRoomMember emit null if the room has only 3 members`() = runTest { + val joinedRoom = FakeBaseRoom( + sessionId = A_USER_ID, + ).apply { + givenRoomInfo(aRoomInfo(isDirect = true, activeMembersCount = 3L)) + } + moleculeFlow(RecompositionMode.Immediate) { + joinedRoom.getDirectRoomMember( + RoomMembersState.Ready(persistentListOf(roomMember1, roomMember2, roomMember3)) + ) + }.test { + assertThat(awaitItem().value).isNull() + } + } + + @Test + fun `getDirectRoomMember emit null if the other member is not active`() = runTest { + val joinedRoom = FakeBaseRoom( + sessionId = A_USER_ID, + initialRoomInfo = aRoomInfo(isDirect = true), + ) + moleculeFlow(RecompositionMode.Immediate) { + joinedRoom.getDirectRoomMember( + RoomMembersState.Ready( + persistentListOf( + roomMember1, + roomMember2.copy(membership = RoomMembershipState.BAN), + ) + ) + ) + }.test { + assertThat(awaitItem().value).isNull() + } + } + + @Test + fun `getDirectRoomMember emit the other member if there are 2 active members`() = runTest { + val joinedRoom = FakeBaseRoom( + sessionId = A_USER_ID, + initialRoomInfo = aRoomInfo( + isDirect = true, + activeMembersCount = 2, + ) + ) + moleculeFlow(RecompositionMode.Immediate) { + joinedRoom.getDirectRoomMember( + RoomMembersState.Ready( + persistentListOf( + roomMember1, + roomMember2, + roomMember3.copy(membership = RoomMembershipState.BAN), + ) + ) + ) + }.test { + assertThat(awaitItem().value).isEqualTo(roomMember2) + } + } + + @Test + fun `getCurrentRoomMember returns the current user`() = runTest { + val joinedRoom = FakeBaseRoom(sessionId = A_USER_ID) + moleculeFlow(RecompositionMode.Immediate) { + joinedRoom.getCurrentRoomMember( + RoomMembersState.Ready( + persistentListOf( + roomMember1, + roomMember2, + roomMember3, + ) + ) + ) + }.test { + assertThat(awaitItem().value).isEqualTo(roomMember1) + } + } + + @Test + fun `getCurrentRoomMember returns null if the member is not found`() = runTest { + val joinedRoom = FakeBaseRoom(sessionId = A_USER_ID) + moleculeFlow(RecompositionMode.Immediate) { + joinedRoom.getCurrentRoomMember( + RoomMembersState.Ready( + persistentListOf( + roomMember2, + roomMember3, + ) + ) + ) + }.test { + assertThat(awaitItem().value).isNull() + } + } +} diff --git a/libraries/mediapickers/api/build.gradle.kts b/libraries/mediapickers/api/build.gradle.kts new file mode 100644 index 0000000..5e3d78d --- /dev/null +++ b/libraries/mediapickers/api/build.gradle.kts @@ -0,0 +1,25 @@ +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.mediapickers.api" +} + +dependencies { + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + + testCommonDependencies(libs) +} 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 0000000..7a9b393 --- /dev/null +++ b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerLauncher.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediapickers.api + +import android.content.ActivityNotFoundException +import androidx.activity.compose.ManagedActivityResultLauncher +import timber.log.Timber + +/** + * Wrapper around [ManagedActivityResultLauncher] to be used with media/file pickers. + */ +interface PickerLauncher { + /** 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( + private val managedLauncher: ManagedActivityResultLauncher, + private val defaultRequest: Input, +) : PickerLauncher { + override fun launch() { + try { + managedLauncher.launch(defaultRequest) + } catch (activityNotFoundException: ActivityNotFoundException) { + Timber.w(activityNotFoundException, "No activity found") + } + } + + override fun launch(customInput: Input) { + try { + managedLauncher.launch(customInput) + } catch (activityNotFoundException: ActivityNotFoundException) { + Timber.w(activityNotFoundException, "No activity found") + } + } +} + +/** Needed for screenshot tests. */ +class NoOpPickerLauncher( + private val onResult: () -> Unit, +) : PickerLauncher { + 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 0000000..7ae35e8 --- /dev/null +++ b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 + + @Composable + fun registerGalleryImagePicker( + onResult: (Uri?) -> Unit + ): PickerLauncher + + @Composable + fun registerFilePicker( + mimeType: String, + onResult: (uri: Uri?, mimeType: String?) -> Unit, + ): PickerLauncher + + @Composable + fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher + + @Composable + fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher +} 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 0000000..9c69e64 --- /dev/null +++ b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerType.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 androidx.compose.runtime.Immutable +import io.element.android.libraries.core.mimetype.MimeTypes + +@Immutable +sealed interface PickerType { + fun getContract(): ActivityResultContract + fun getDefaultRequest(): Input + + data object Image : PickerType { + override fun getContract() = ActivityResultContracts.PickVisualMedia() + override fun getDefaultRequest(): PickVisualMediaRequest { + return PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + } + } + + data object ImageAndVideo : PickerType { + override fun getContract() = ActivityResultContracts.PickVisualMedia() + override fun getDefaultRequest(): PickVisualMediaRequest { + return PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo) + } + } + + object Camera { + data class Photo(val destUri: Uri) : PickerType { + override fun getContract() = ActivityResultContracts.TakePicture() + override fun getDefaultRequest(): Uri { + return destUri + } + } + + data class Video(val destUri: Uri) : PickerType { + override fun getContract() = ActivityResultContracts.CaptureVideo() + override fun getDefaultRequest(): Uri { + return destUri + } + } + } + + data class File(val mimeType: String = MimeTypes.Any) : PickerType { + 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/PickerTypeTest.kt b/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTest.kt new file mode 100644 index 0000000..6c7dbb7 --- /dev/null +++ b/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 PickerTypeTest { + @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 0000000..6ed9cd9 --- /dev/null +++ b/libraries/mediapickers/impl/build.gradle.kts @@ -0,0 +1,25 @@ +import extension.setupDependencyInjection + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +setupDependencyInjection() + +android { + namespace = "io.element.android.libraries.mediapickers.impl" +} + +dependencies { + implementation(projects.libraries.core) + implementation(projects.libraries.di) + api(projects.libraries.mediapickers.api) +} diff --git a/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/DefaultPickerProvider.kt b/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/DefaultPickerProvider.kt new file mode 100644 index 0000000..1b3df9c --- /dev/null +++ b/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/DefaultPickerProvider.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.LocalInspectionMode +import androidx.core.content.FileProvider +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.annotations.ApplicationContext +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 + +@ContributesBinding(AppScope::class) +class DefaultPickerProvider( + @ApplicationContext private val context: Context, +) : PickerProvider { + /** + * Remembers and returns a [PickerLauncher] for a certain media/file [type]. + */ + @Composable + internal fun rememberPickerLauncher( + type: PickerType, + onResult: (Output) -> Unit, + ): PickerLauncher { + return if (LocalInspectionMode.current) { + 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 { + // Tests and UI preview can't handle Contexts, so we might as well disable the whole picker + return if (LocalInspectionMode.current) { + 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 { + // Tests and UI preview can't handle Contexts, so we might as well disable the whole picker + return if (LocalInspectionMode.current) { + NoOpPickerLauncher { onResult(null, null) } + } else { + 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: Uri?, mimeType: String?) -> Unit, + ): PickerLauncher { + // Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker + return if (LocalInspectionMode.current) { + NoOpPickerLauncher { onResult(null, null) } + } else { + rememberPickerLauncher(type = PickerType.File(mimeType)) { uri -> + val pickedMimeType = uri?.let { context.contentResolver.getType(it) } + onResult(uri, pickedMimeType) + } + } + } + + /** + * 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 { + // Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker + return if (LocalInspectionMode.current) { + NoOpPickerLauncher { onResult(null) } + } else { + val tmpFile = remember { getTemporaryFile("photo.jpg") } + val tmpFileUri = remember(tmpFile) { getTemporaryUri(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 { + // Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker + return if (LocalInspectionMode.current) { + NoOpPickerLauncher { onResult(null) } + } else { + val tmpFile = remember { getTemporaryFile("video.mp4") } + val tmpFileUri = remember(tmpFile) { getTemporaryUri(tmpFile) } + rememberPickerLauncher(type = PickerType.Camera.Video(tmpFileUri)) { success -> + // Execute callback + onResult(if (success) tmpFileUri else null) + } + } + } + + private fun getTemporaryFile( + filename: String, + ): File { + return File(context.cacheDir, filename) + } + + private fun getTemporaryUri( + 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 0000000..d7c2989 --- /dev/null +++ b/libraries/mediapickers/test/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.mediapickers.test" +} + +dependencies { + implementation(projects.libraries.core) + implementation(projects.libraries.di) + 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 0000000..98cbcec --- /dev/null +++ b/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 { + return NoOpPickerLauncher { onResult(result, mimeType) } + } + + @Composable + override fun registerGalleryImagePicker(onResult: (uri: Uri?) -> Unit): PickerLauncher { + return NoOpPickerLauncher { onResult(result) } + } + + @Composable + override fun registerFilePicker(mimeType: String, onResult: (Uri?, String?) -> Unit): PickerLauncher { + return NoOpPickerLauncher { onResult(result, this.mimeType) } + } + + @Composable + override fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher { + return NoOpPickerLauncher { onResult(result) } + } + + @Composable + override fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher { + return NoOpPickerLauncher { onResult(result) } + } + + fun givenResult(value: Uri?) { + this.result = value + } + + fun givenMimeType(mimeType: String) { + this.mimeType = mimeType + } +} diff --git a/libraries/mediaplayer/api/build.gradle.kts b/libraries/mediaplayer/api/build.gradle.kts new file mode 100644 index 0000000..eb4a6cf --- /dev/null +++ b/libraries/mediaplayer/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.mediaplayer.api" +} + +dependencies { + implementation(projects.libraries.matrix.api) + implementation(libs.coroutines.core) +} diff --git a/libraries/mediaplayer/api/src/main/kotlin/io/element/android/libraries/mediaplayer/api/MediaPlayer.kt b/libraries/mediaplayer/api/src/main/kotlin/io/element/android/libraries/mediaplayer/api/MediaPlayer.kt new file mode 100644 index 0000000..bfbd2bb --- /dev/null +++ b/libraries/mediaplayer/api/src/main/kotlin/io/element/android/libraries/mediaplayer/api/MediaPlayer.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaplayer.api + +import io.element.android.libraries.matrix.api.core.EventId +import kotlinx.coroutines.flow.StateFlow + +/** + * A media player for Element X. + */ +interface MediaPlayer : AutoCloseable { + /** + * The current state of the player. + */ + val state: StateFlow + + /** + * Initialises the player with a new media item, will suspend until the player is ready. + * + * @return the ready state of the player. + */ + suspend fun setMedia( + uri: String, + mediaId: String, + mimeType: String, + startPositionMs: Long = 0, + ): State + + /** + * Plays the current media. + */ + fun play() + + /** + * Pauses the current media. + */ + fun pause() + + /** + * Seeks the current media to the given position. + */ + fun seekTo(positionMs: Long) + + /** + * Releases any resources associated with this player. + */ + override fun close() + + data class State( + /** + * Whether the player is ready to play. + */ + val isReady: Boolean, + /** + * Whether the player is currently playing. + */ + val isPlaying: Boolean, + /** + * Whether the player has reached the end of the current media. + */ + val isEnded: Boolean, + /** + * The id of the media which is currently playing. + * + * NB: This is usually the string representation of the [EventId] of the event + * which contains the media. + */ + val mediaId: String?, + /** + * The current position of the player. + */ + val currentPosition: Long, + /** + * The duration of the current content, if available. + */ + val duration: Long?, + ) +} diff --git a/libraries/mediaplayer/impl/build.gradle.kts b/libraries/mediaplayer/impl/build.gradle.kts new file mode 100644 index 0000000..aa9140d --- /dev/null +++ b/libraries/mediaplayer/impl/build.gradle.kts @@ -0,0 +1,34 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.mediaplayer.impl" +} + +setupDependencyInjection() + +dependencies { + api(projects.libraries.mediaplayer.api) + implementation(libs.androidx.media3.exoplayer) + + implementation(projects.libraries.audio.api) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + + implementation(libs.coroutines.core) + + testCommonDependencies(libs) + testImplementation(projects.libraries.audio.test) + testImplementation(libs.coroutines.core) +} diff --git a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayer.kt b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayer.kt new file mode 100644 index 0000000..098e7e4 --- /dev/null +++ b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayer.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaplayer.impl + +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.audio.api.AudioFocus +import io.element.android.libraries.audio.api.AudioFocusRequester +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.mediaplayer.api.MediaPlayer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.timeout +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds + +/** + * Default implementation of [MediaPlayer] backed by a [SimplePlayer]. + */ +@ContributesBinding(RoomScope::class) +@SingleIn(RoomScope::class) +class DefaultMediaPlayer( + private val player: SimplePlayer, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, + private val audioFocus: AudioFocus, +) : MediaPlayer { + private val listener = object : SimplePlayer.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + _state.update { + it.copy( + currentPosition = player.currentPosition, + duration = duration, + isPlaying = isPlaying, + ) + } + if (isPlaying) { + job = sessionCoroutineScope.launch { updateCurrentPosition() } + } else { + audioFocus.releaseAudioFocus() + job?.cancel() + } + } + + override fun onMediaItemTransition(mediaItem: MediaItem?) { + _state.update { + it.copy( + currentPosition = player.currentPosition, + duration = duration, + mediaId = mediaItem?.mediaId, + ) + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + _state.update { + it.copy( + isReady = playbackState == Player.STATE_READY, + isEnded = playbackState == Player.STATE_ENDED, + currentPosition = player.currentPosition, + duration = duration, + ) + } + } + } + + init { + player.addListener(listener) + } + + private var job: Job? = null + + private val _state = MutableStateFlow( + MediaPlayer.State( + isReady = false, + isPlaying = false, + isEnded = false, + mediaId = null, + currentPosition = 0L, + duration = null, + ) + ) + + override val state: StateFlow = _state.asStateFlow() + + @OptIn(FlowPreview::class) + override suspend fun setMedia( + uri: String, + mediaId: String, + mimeType: String, + startPositionMs: Long, + ): MediaPlayer.State { + // Must pause here otherwise if the player was playing it would keep on playing the new media item. + player.pause() + player.clearMediaItems() + player.setMediaItem( + MediaItem.Builder() + .setUri(uri) + .setMediaId(mediaId) + .setMimeType(mimeType) + .build(), + startPositionMs, + ) + player.prepare() + // Will throw TimeoutCancellationException if the player is not ready after 1 second. + return state.timeout(1.seconds).first { it.isReady && it.mediaId == mediaId } + } + + override fun play() { + audioFocus.requestAudioFocus( + requester = AudioFocusRequester.VoiceMessage, + onFocusLost = { + if (player.isPlaying()) { + player.pause() + } + }, + ) + if (player.playbackState == Player.STATE_ENDED) { + // There's a bug with some ogg files that somehow report to + // have no duration. + // With such files, once playback has ended once, calling + // player.seekTo(0) and then player.play() results in the + // player starting and stopping playing immediately effectively + // playing no sound. + // This is a workaround which will reload the media file. + player.getCurrentMediaItem()?.let { + player.setMediaItem(it, 0) + player.prepare() + } + } + player.play() + } + + override fun pause() { + player.pause() + } + + override fun seekTo(positionMs: Long) { + player.seekTo(positionMs) + _state.update { + it.copy(currentPosition = player.currentPosition) + } + } + + override fun close() { + player.release() + } + + private suspend fun updateCurrentPosition() { + while (true) { + if (!_state.value.isPlaying) return + delay(100) + _state.update { + it.copy(currentPosition = player.currentPosition) + } + } + } + + private val duration: Long? + get() = player.duration.let { + if (it == C.TIME_UNSET) null else it + } +} diff --git a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt new file mode 100644 index 0000000..f0b8746 --- /dev/null +++ b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaplayer.impl + +import android.content.Context +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.annotations.ApplicationContext + +/** + * A subset of media3 [Player] that only exposes the few methods we need making it easier to mock. + */ +interface SimplePlayer { + fun addListener(listener: Listener) + val currentPosition: Long + val playbackState: Int + val duration: Long + fun clearMediaItems() + fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) + fun getCurrentMediaItem(): MediaItem? + fun prepare() + fun play() + fun isPlaying(): Boolean + fun pause() + fun seekTo(positionMs: Long) + fun release() + interface Listener { + fun onIsPlayingChanged(isPlaying: Boolean) + fun onMediaItemTransition(mediaItem: MediaItem?) + fun onPlaybackStateChanged(playbackState: Int) + } +} + +@ContributesTo(RoomScope::class) +@BindingContainer +object SimplePlayerModule { + @Provides + fun simplePlayerProvider( + @ApplicationContext context: Context, + ): SimplePlayer = DefaultSimplePlayer(ExoPlayer.Builder(context).build()) +} + +/** + * Default implementation of [SimplePlayer] backed by a media3 [Player]. + */ +class DefaultSimplePlayer( + private val p: Player +) : SimplePlayer { + override fun addListener(listener: SimplePlayer.Listener) { + p.addListener(object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) = listener.onIsPlayingChanged(isPlaying) + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) = listener.onMediaItemTransition(mediaItem) + override fun onPlaybackStateChanged(playbackState: Int) = listener.onPlaybackStateChanged(playbackState) + }) + } + + override val currentPosition: Long + get() = p.currentPosition + override val playbackState: Int + get() = p.playbackState + override val duration: Long + get() = p.duration + + override fun clearMediaItems() = p.clearMediaItems() + + override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) = p.setMediaItem(mediaItem, startPositionMs) + + override fun getCurrentMediaItem(): MediaItem? = p.currentMediaItem + + override fun prepare() = p.prepare() + + override fun play() = p.play() + + override fun isPlaying() = p.isPlaying + + override fun pause() = p.pause() + + override fun seekTo(positionMs: Long) = p.seekTo(positionMs) + + override fun release() = p.release() +} diff --git a/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt b/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt new file mode 100644 index 0000000..f7a748b --- /dev/null +++ b/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt @@ -0,0 +1,430 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaplayer.impl + +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.audio.api.AudioFocus +import io.element.android.libraries.audio.api.AudioFocusRequester +import io.element.android.libraries.mediaplayer.api.MediaPlayer +import io.element.android.libraries.mediaplayer.test.FakeAudioFocus +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertThrows +import org.junit.Test + +class DefaultMediaPlayerTest { + private val aMediaId = "mediaId" + private val aMediaItem = MediaItem.Builder().setMediaId(aMediaId).build() + + @Test + fun `initial state`() = runTest { + val sut = createDefaultMediaPlayer() + sut.state.test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo( + MediaPlayer.State( + isReady = false, + isPlaying = false, + isEnded = false, + mediaId = null, + currentPosition = 0, + duration = null, + ) + ) + } + } + + @Test + fun `start player will update the current position and pause it will stop`() = runTest { + val playLambda = lambdaRecorder { } + val pauseLambda = lambdaRecorder { } + val player = FakeSimplePlayer( + playLambda = playLambda, + pauseLambda = pauseLambda, + ) + val requestAudioFocusResult = lambdaRecorder Unit, Unit> { _, _ -> } + val releaseAudioFocusResult = lambdaRecorder {} + val audioFocus = FakeAudioFocus( + requestAudioFocusResult = requestAudioFocusResult, + releaseAudioFocusResult = releaseAudioFocusResult + ) + val sut = createDefaultMediaPlayer( + simplePlayer = player, + audioFocus = audioFocus, + ) + sut.state.test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo( + MediaPlayer.State( + isReady = false, + isPlaying = false, + isEnded = false, + mediaId = null, + currentPosition = 0, + duration = null, + ) + ) + sut.play() + playLambda.assertions().isCalledOnce() + requestAudioFocusResult.assertions().isCalledOnce() + player.durationResult = 123L + player.simulateIsPlayingChanged(true) + val playingState = awaitItem() + assertThat(playingState).isEqualTo( + MediaPlayer.State( + isReady = false, + isPlaying = true, + isEnded = false, + mediaId = null, + currentPosition = 0, + duration = 123, + ) + ) + player.currentPositionResult = 1L + assertThat(awaitItem()).isEqualTo( + MediaPlayer.State( + isReady = false, + isPlaying = true, + isEnded = false, + mediaId = null, + currentPosition = 1, + duration = 123, + ) + ) + player.currentPositionResult = 2L + assertThat(awaitItem()).isEqualTo( + MediaPlayer.State( + isReady = false, + isPlaying = true, + isEnded = false, + mediaId = null, + currentPosition = 2, + duration = 123, + ) + ) + player.pause() + pauseLambda.assertions().isCalledOnce() + player.simulateIsPlayingChanged(false) + releaseAudioFocusResult.assertions().isCalledOnce() + assertThat(awaitItem()).isEqualTo( + MediaPlayer.State( + isReady = false, + isPlaying = false, + isEnded = false, + mediaId = null, + currentPosition = 2, + duration = 123, + ) + ) + } + } + + @Test + fun `start player on ended playback will not invoke more methods if current media item is null`() = runTest { + val playLambda = lambdaRecorder { } + val getCurrentMediaItemLambda = lambdaRecorder { null } + val player = FakeSimplePlayer( + playLambda = playLambda, + getCurrentMediaItemLambda = getCurrentMediaItemLambda, + ) + val requestAudioFocusResult = lambdaRecorder Unit, Unit> { _, _ -> } + val audioFocus = FakeAudioFocus( + requestAudioFocusResult = requestAudioFocusResult, + ) + val sut = createDefaultMediaPlayer( + simplePlayer = player, + audioFocus = audioFocus, + ) + sut.state.test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo( + MediaPlayer.State( + isReady = false, + isPlaying = false, + isEnded = false, + mediaId = null, + currentPosition = 0, + duration = null, + ) + ) + player.playbackStateResult = Player.STATE_ENDED + sut.play() + playLambda.assertions().isCalledOnce() + requestAudioFocusResult.assertions().isCalledOnce() + } + } + + @Test + fun `start player on ended playback will invoke more methods if current media item is not null`() = runTest { + val playLambda = lambdaRecorder { } + val prepareLambda = lambdaRecorder { } + val getCurrentMediaItemLambda = lambdaRecorder { aMediaItem } + val setMediaItemLambda = lambdaRecorder { _, _ -> } + val requestAudioFocusResult = lambdaRecorder Unit, Unit> { _, _ -> } + val audioFocus = FakeAudioFocus( + requestAudioFocusResult = requestAudioFocusResult, + ) + val player = FakeSimplePlayer( + playLambda = playLambda, + prepareLambda = prepareLambda, + setMediaItemLambda = setMediaItemLambda, + getCurrentMediaItemLambda = getCurrentMediaItemLambda, + ) + val sut = createDefaultMediaPlayer( + simplePlayer = player, + audioFocus = audioFocus, + ) + sut.state.test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo( + MediaPlayer.State( + isReady = false, + isPlaying = false, + isEnded = false, + mediaId = null, + currentPosition = 0, + duration = null, + ) + ) + player.playbackStateResult = Player.STATE_ENDED + sut.play() + setMediaItemLambda.assertions().isCalledOnce().with( + value(aMediaItem), + value(0L), + ) + prepareLambda.assertions().isCalledOnce() + playLambda.assertions().isCalledOnce() + requestAudioFocusResult.assertions().isCalledOnce() + } + } + + @Test + fun `pause player invokes pause on the embedded player`() = runTest { + val pauseLambda = lambdaRecorder { } + val player = FakeSimplePlayer( + pauseLambda = pauseLambda, + ) + val sut = createDefaultMediaPlayer( + simplePlayer = player, + ) + sut.pause() + pauseLambda.assertions().isCalledOnce() + } + + @Test + fun `close player invokes release on the embedded player`() = runTest { + val releaseLambda = lambdaRecorder { } + val player = FakeSimplePlayer( + releaseLambda = releaseLambda, + ) + val sut = createDefaultMediaPlayer( + simplePlayer = player, + ) + sut.close() + releaseLambda.assertions().isCalledOnce() + } + + @Test + fun `seekTo invokes release on the embedded player`() = runTest { + val seekToLambda = lambdaRecorder { } + val player = FakeSimplePlayer( + seekToLambda = seekToLambda, + ) + val sut = createDefaultMediaPlayer( + simplePlayer = player, + ) + sut.state.test { + awaitItem() + player.currentPositionResult = 33L + sut.seekTo(33L) + seekToLambda.assertions().isCalledOnce().with(value(33L)) + val finalState = awaitItem() + assertThat(finalState).isEqualTo( + MediaPlayer.State( + isReady = false, + isPlaying = false, + isEnded = false, + mediaId = null, + currentPosition = 33L, + duration = null, + ) + ) + } + } + + @Test + fun `onPlaybackStateChanged update the state`() = runTest { + val player = FakeSimplePlayer() + val sut = createDefaultMediaPlayer( + simplePlayer = player, + ) + sut.state.test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo( + MediaPlayer.State( + isReady = false, + isPlaying = false, + isEnded = false, + mediaId = null, + currentPosition = 0, + duration = null, + ) + ) + player.currentPositionResult = 44 + player.durationResult = 123L + player.simulatePlaybackStateChanged(Player.STATE_READY) + val readyState = awaitItem() + assertThat(readyState).isEqualTo( + MediaPlayer.State( + isReady = true, + isPlaying = false, + isEnded = false, + mediaId = null, + currentPosition = 44, + duration = 123, + ) + ) + player.simulatePlaybackStateChanged(Player.STATE_ENDED) + val endedState = awaitItem() + assertThat(endedState).isEqualTo( + MediaPlayer.State( + isReady = false, + isPlaying = false, + isEnded = true, + mediaId = null, + currentPosition = 44, + duration = 123, + ) + ) + } + } + + @Test + fun `setMedia with timeout error`() = runTest { + val pauseLambda = lambdaRecorder { } + val clearMediaItemsLambda = lambdaRecorder { } + val setMediaItemLambda = lambdaRecorder { _, _ -> } + val prepareLambda = lambdaRecorder { } + val player = FakeSimplePlayer( + pauseLambda = pauseLambda, + clearMediaItemsLambda = clearMediaItemsLambda, + setMediaItemLambda = setMediaItemLambda, + prepareLambda = prepareLambda, + ) + val sut = createDefaultMediaPlayer( + simplePlayer = player, + ) + sut.state.test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo( + MediaPlayer.State( + isReady = false, + isPlaying = false, + isEnded = false, + mediaId = null, + currentPosition = 0, + duration = null, + ) + ) + @Suppress("RunCatchingNotAllowed") + val result = runCatching { + sut.setMedia("uri", "mediaId", "mimeType", 12) + } + pauseLambda.assertions().isCalledOnce() + clearMediaItemsLambda.assertions().isCalledOnce() + setMediaItemLambda.assertions().isCalledOnce().with( + value(MediaItem.Builder().setUri("uri").setMediaId("mediaId").setMimeType("mimeType").build()), + value(12L), + ) + prepareLambda.assertions().isCalledOnce() + assertThat(result.isFailure).isTrue() + assertThrows(TimeoutCancellationException::class.java) { + result.getOrThrow() + } + } + } + + @Test + fun `setMedia success`() = runTest { + var player: FakeSimplePlayer? = null + val pauseLambda = lambdaRecorder { } + val clearMediaItemsLambda = lambdaRecorder { } + val setMediaItemLambda = lambdaRecorder { _, _ -> } + val prepareLambda = lambdaRecorder { + player?.simulatePlaybackStateChanged(Player.STATE_READY) + player?.simulateMediaItemTransition(aMediaItem) + } + player = FakeSimplePlayer( + pauseLambda = pauseLambda, + clearMediaItemsLambda = clearMediaItemsLambda, + setMediaItemLambda = setMediaItemLambda, + prepareLambda = prepareLambda, + ) + val sut = createDefaultMediaPlayer( + simplePlayer = player, + ) + sut.state.test { + val initialState = awaitItem() + assertThat(initialState).isEqualTo( + MediaPlayer.State( + isReady = false, + isPlaying = false, + isEnded = false, + mediaId = null, + currentPosition = 0, + duration = null, + ) + ) + val state = sut.setMedia("uri", "mediaId", "mimeType", 12) + pauseLambda.assertions().isCalledOnce() + clearMediaItemsLambda.assertions().isCalledOnce() + setMediaItemLambda.assertions().isCalledOnce().with( + value(MediaItem.Builder().setUri("uri").setMediaId("mediaId").setMimeType("mimeType").build()), + value(12L), + ) + prepareLambda.assertions().isCalledOnce() + + val finalState = MediaPlayer.State( + isReady = true, + isPlaying = false, + isEnded = false, + mediaId = "mediaId", + currentPosition = 0, + duration = 0, + ) + assertThat(awaitItem()).isEqualTo( + MediaPlayer.State( + isReady = true, + isPlaying = false, + isEnded = false, + mediaId = null, + currentPosition = 0, + duration = 0, + ) + ) + assertThat(awaitItem()).isEqualTo(finalState) + assertThat(state).isEqualTo(finalState) + } + } + + private fun TestScope.createDefaultMediaPlayer( + simplePlayer: SimplePlayer = FakeSimplePlayer(), + audioFocus: AudioFocus = FakeAudioFocus(), + ): DefaultMediaPlayer = DefaultMediaPlayer( + player = simplePlayer, + sessionCoroutineScope = backgroundScope, + audioFocus = audioFocus, + ) +} diff --git a/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/FakeSimplePlayer.kt b/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/FakeSimplePlayer.kt new file mode 100644 index 0000000..609862c --- /dev/null +++ b/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/FakeSimplePlayer.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaplayer.impl + +import androidx.media3.common.MediaItem +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeSimplePlayer( + private val clearMediaItemsLambda: () -> Unit = { lambdaError() }, + private val setMediaItemLambda: (MediaItem, Long) -> Unit = { _, _ -> lambdaError() }, + private val getCurrentMediaItemLambda: () -> MediaItem? = { lambdaError() }, + private val prepareLambda: () -> Unit = { lambdaError() }, + private val playLambda: () -> Unit = { lambdaError() }, + private val isPlayingLambda: () -> Boolean = { lambdaError() }, + private val pauseLambda: () -> Unit = { lambdaError() }, + private val seekToLambda: (Long) -> Unit = { lambdaError() }, + private val releaseLambda: () -> Unit = { lambdaError() }, +) : SimplePlayer { + private val listeners = mutableListOf() + override fun addListener(listener: SimplePlayer.Listener) { + listeners.add(listener) + } + + var currentPositionResult: Long = 0 + override val currentPosition: Long get() = currentPositionResult + var playbackStateResult: Int = 0 + override val playbackState: Int get() = playbackStateResult + var durationResult: Long = 0 + override val duration: Long get() = durationResult + + override fun clearMediaItems() = clearMediaItemsLambda() + override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) { + setMediaItemLambda(mediaItem, startPositionMs) + } + + override fun getCurrentMediaItem(): MediaItem? = getCurrentMediaItemLambda() + override fun prepare() = prepareLambda() + override fun play() = playLambda() + override fun isPlaying() = isPlayingLambda() + override fun pause() = pauseLambda() + override fun seekTo(positionMs: Long) = seekToLambda(positionMs) + override fun release() = releaseLambda() + + fun simulateIsPlayingChanged(isPlaying: Boolean) { + listeners.forEach { it.onIsPlayingChanged(isPlaying) } + } + + fun simulateMediaItemTransition(mediaItem: MediaItem?) { + listeners.forEach { it.onMediaItemTransition(mediaItem) } + } + + fun simulatePlaybackStateChanged(playbackState: Int) { + listeners.forEach { it.onPlaybackStateChanged(playbackState) } + } +} diff --git a/libraries/mediaplayer/test/build.gradle.kts b/libraries/mediaplayer/test/build.gradle.kts new file mode 100644 index 0000000..e2f1c8a --- /dev/null +++ b/libraries/mediaplayer/test/build.gradle.kts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.mediaplayer.test" +} + +dependencies { + api(projects.libraries.mediaplayer.api) + implementation(projects.tests.testutils) + + implementation(libs.coroutines.test) + implementation(libs.test.truth) +} diff --git a/libraries/mediaplayer/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeMediaPlayer.kt b/libraries/mediaplayer/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeMediaPlayer.kt new file mode 100644 index 0000000..54fed2b --- /dev/null +++ b/libraries/mediaplayer/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeMediaPlayer.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaplayer.test + +import io.element.android.libraries.mediaplayer.api.MediaPlayer +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Fake implementation of [MediaPlayer] for testing purposes. + */ +class FakeMediaPlayer( + private val fakeTotalDurationMs: Long = 10_000L, + private val fakePlayedDurationMs: Long = 1000L, +) : MediaPlayer { + private val _state = MutableStateFlow( + MediaPlayer.State( + isReady = false, + isPlaying = false, + isEnded = false, + mediaId = null, + currentPosition = 0L, + duration = null + ) + ) + + override val state: StateFlow = _state.asStateFlow() + + override suspend fun setMedia( + uri: String, + mediaId: String, + mimeType: String, + startPositionMs: Long, + ): MediaPlayer.State { + _state.update { + it.copy( + isReady = false, + isPlaying = false, + isEnded = false, + mediaId = mediaId, + currentPosition = startPositionMs, + duration = null, + ) + } + delay(1) // fake delay to simulate prepare() call. + _state.update { + it.copy( + isReady = true, + duration = fakeTotalDurationMs, + ) + } + return _state.value + } + + override fun play() { + _state.update { + val newPosition = it.currentPosition + fakePlayedDurationMs + if (newPosition < fakeTotalDurationMs) { + it.copy( + isPlaying = true, + currentPosition = newPosition, + ) + } else { + it.copy( + isReady = false, + isPlaying = false, + isEnded = true, + currentPosition = fakeTotalDurationMs, + ) + } + } + } + + override fun pause() { + _state.update { + it.copy( + isPlaying = false, + ) + } + } + + override fun seekTo(positionMs: Long) { + _state.update { + it.copy( + currentPosition = positionMs, + ) + } + } + + override fun close() { + // no-op + } +} diff --git a/libraries/mediaupload/api/build.gradle.kts b/libraries/mediaupload/api/build.gradle.kts new file mode 100644 index 0000000..1f2d844 --- /dev/null +++ b/libraries/mediaupload/api/build.gradle.kts @@ -0,0 +1,27 @@ +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.mediaupload.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + api(projects.libraries.matrix.api) + api(projects.libraries.preferences.api) + implementation(libs.coroutines.core) +} diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MaxUploadSizeProvider.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MaxUploadSizeProvider.kt new file mode 100644 index 0000000..a5462a6 --- /dev/null +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MaxUploadSizeProvider.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.api + +/** + * Provides the maximum upload size allowed by the Matrix server. + */ +fun interface MaxUploadSizeProvider { + suspend fun getMaxUploadSize(): Result +} diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfig.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfig.kt new file mode 100644 index 0000000..561a5af --- /dev/null +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfig.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.api + +import io.element.android.libraries.androidutils.media.VideoCompressorHelper +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset + +data class MediaOptimizationConfig( + val compressImages: Boolean, + val videoCompressionPreset: VideoCompressionPreset, +) + +fun VideoCompressionPreset.compressorHelper(): VideoCompressorHelper = when (this) { + VideoCompressionPreset.STANDARD -> VideoCompressorHelper(1280) + VideoCompressionPreset.HIGH -> VideoCompressorHelper(1920) + VideoCompressionPreset.LOW -> VideoCompressorHelper(640) +} diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfigProvider.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfigProvider.kt new file mode 100644 index 0000000..976a705 --- /dev/null +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfigProvider.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.api + +fun interface MediaOptimizationConfigProvider { + suspend fun get(): MediaOptimizationConfig +} 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 0000000..c2bf1e5 --- /dev/null +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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, + mediaOptimizationConfig: MediaOptimizationConfig, + ): Result + + /** + * Clean up any temporary files or resources used during the media processing. + */ + fun cleanUp() + + data class Failure(override val cause: Throwable?) : Exception(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 0000000..628e760 --- /dev/null +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.api + +import android.net.Uri +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.Timeline + +fun interface MediaSenderFactory { + /** + * Create a [MediaSender] for the given [Timeline.Mode], in the Room Scope. + */ + fun create( + timelineMode: Timeline.Mode, + ): MediaSender +} + +fun interface MediaSenderRoomFactory { + /** + * Create a [MediaSender] for the given [JoinedRoom], with timeline mode Live. + */ + fun create( + room: JoinedRoom, + ): MediaSender +} + +interface MediaSender { + suspend fun preProcessMedia( + uri: Uri, + mimeType: String, + mediaOptimizationConfig: MediaOptimizationConfig, + ): Result + + suspend fun sendPreProcessedMedia( + mediaUploadInfo: MediaUploadInfo, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result + + suspend fun sendMedia( + uri: Uri, + mimeType: String, + caption: String? = null, + formattedCaption: String? = null, + inReplyToEventId: EventId? = null, + mediaOptimizationConfig: MediaOptimizationConfig, + ): Result + + suspend fun sendVoiceMessage( + uri: Uri, + mimeType: String, + waveForm: List, + inReplyToEventId: EventId? = null, + ): Result + + fun cleanUp() +} 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 0000000..f0082a4 --- /dev/null +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 VoiceMessage(override val file: File, val audioInfo: AudioInfo, val waveform: List) : MediaUploadInfo + data class AnyFile(override val file: File, val fileInfo: FileInfo) : MediaUploadInfo +} + +fun MediaUploadInfo.allFiles(): List { + return listOfNotNull( + file, + (this@allFiles as? MediaUploadInfo.Image)?.thumbnailFile, + (this@allFiles as? MediaUploadInfo.Video)?.thumbnailFile, + ) +} diff --git a/libraries/mediaupload/impl/build.gradle.kts b/libraries/mediaupload/impl/build.gradle.kts new file mode 100644 index 0000000..dd73164 --- /dev/null +++ b/libraries/mediaupload/impl/build.gradle.kts @@ -0,0 +1,48 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.mediaupload.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + 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(projects.services.toolbox.api) + implementation(libs.androidx.exifinterface) + implementation(libs.androidx.media3.transformer) + implementation(libs.androidx.media3.effect) + implementation(libs.androidx.media3.common) + implementation(libs.coroutines.core) + implementation(libs.vanniktech.blurhash) + + testCommonDependencies(libs) + testImplementation(projects.services.toolbox.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.mediaupload.test) +} diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt new file mode 100644 index 0000000..2b18836 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt @@ -0,0 +1,373 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.impl + +import android.content.Context +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import android.net.Uri +import androidx.exifinterface.media.ExifInterface +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.file.TemporaryUriDeleter +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.hash.hash +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.extensions.runCatchingExceptions +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.annotations.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.MediaOptimizationConfig +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File +import java.io.InputStream +import java.util.UUID +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +@ContributesBinding(AppScope::class) +class AndroidMediaPreProcessor( + @ApplicationContext private val context: Context, + private val thumbnailFactory: ThumbnailFactory, + private val imageCompressor: ImageCompressor, + private val videoCompressor: VideoCompressor, + private val coroutineDispatchers: CoroutineDispatchers, + private val temporaryUriDeleter: TemporaryUriDeleter, +) : 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 notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP, MimeTypes.Svg) + } + + private val contentResolver = context.contentResolver + + private val cacheDir = context.cacheDir + private val baseTmpFileDir = File(cacheDir, "uploads") + + override suspend fun process( + uri: Uri, + mimeType: String, + deleteOriginal: Boolean, + mediaOptimizationConfig: MediaOptimizationConfig, + ): Result = withContext(coroutineDispatchers.computation) { + runCatchingExceptions { + val result = when { + // Special case for SVG, since Android can't read its metadata or create a thumbnail, it must be sent as a file + mimeType == MimeTypes.Svg -> { + processFile(uri, mimeType) + } + mimeType.isMimeTypeImage() -> { + val shouldBeCompressed = mediaOptimizationConfig.compressImages && mimeType !in notCompressibleImageTypes + processImage(uri, mimeType, shouldBeCompressed) + } + mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType, mediaOptimizationConfig.videoCompressionPreset) + mimeType.isMimeTypeAudio() -> processAudio(uri, mimeType) + else -> processFile(uri, mimeType) + } + if (deleteOriginal) { + tryOrNull { + Timber.w("Deleting original uri $uri") + contentResolver.delete(uri, null, null) + } + } else { + temporaryUriDeleter.delete(uri) + } + result.postProcess(uri) + } + }.mapFailure { MediaPreProcessor.Failure(it) } + + override fun cleanUp() { + Timber.d("Cleaning up temporary media files") + + // Clear temporary files created in older versions of the app + cacheDir.listFiles()?.onEach { file -> + if (file.isFile) { + val nameWithoutExtension = file.nameWithoutExtension + // UUIDs are 36 characters long, so we check if we can take those 36 characters + val nameWithoutExtensionAndRandom = if (nameWithoutExtension.length > 36) { + nameWithoutExtension.substring(0, 36) + } else { + // Not a temp file + return@onEach + } + val isUUID = tryOrNull { UUID.fromString(nameWithoutExtensionAndRandom) } != null + if (isUUID && file.extension.isNotEmpty()) { + file.delete() + } + } + } + // Clear temporary files created by this pre-processor in the separate uploads directory + baseTmpFileDir.listFiles()?.onEach { it.delete() } + } + + private suspend fun processFile(uri: Uri, mimeType: String): MediaUploadInfo { + Timber.d("Processing file ${uri.path.orEmpty().hash()}") + 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 { + Timber.d("Finished processing, post-processing ${uri.path.orEmpty().hash()}") + 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) + is MediaUploadInfo.VoiceMessage -> copy(file = renamedFile) + } + } + + private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo { + Timber.d("Processing image ${uri.path.orEmpty().hash()}") + suspend fun processImageWithCompression(): MediaUploadInfo { + // Read the orientation metadata from its own stream. Trying to reuse this stream for compression will fail. + val orientation = contentResolver.openInputStream(uri).use { input -> + val exifInterface = input?.let { ExifInterface(it) } + exifInterface?.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED) + } ?: ExifInterface.ORIENTATION_UNDEFINED + + val compressionResult = imageCompressor.compressToTmpFile( + inputStreamProvider = { contentResolver.openInputStream(uri)!! }, + resizeMode = ResizeMode.Approximate(IMAGE_SCALE_REF_SIZE, IMAGE_SCALE_REF_SIZE), + mimeType = mimeType, + orientation = orientation, + ).getOrThrow() + val thumbnailResult = thumbnailFactory.createImageThumbnail( + file = compressionResult.file, + mimeType = mimeType, + ) + 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 = thumbnailFactory.createImageThumbnail( + file = file, + mimeType = mimeType, + ) + 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?, videoCompressionPreset: VideoCompressionPreset): MediaUploadInfo { + Timber.d("Processing video ${uri.path.orEmpty().hash()}") + val resultFile = runCatchingExceptions { + videoCompressor.compress(uri, videoCompressionPreset) + .onEach { + if (it is VideoTranscodingEvent.Progress) { + Timber.d("Video compression progress: ${it.value}%") + } else if (it is VideoTranscodingEvent.Completed) { + Timber.d("Video compression completed: ${it.file.path}") + } + } + .filterIsInstance() + .first() + .file + } + .onFailure { + Timber.e(it, "Failed to compress video: $uri") + } + .getOrNull() + + if (resultFile != null) { + val thumbnailInfo = thumbnailFactory.createVideoThumbnail(resultFile) + val videoInfo = extractVideoMetadata(resultFile, mimeType, thumbnailInfo) + return MediaUploadInfo.Video( + file = resultFile, + videoInfo = videoInfo, + thumbnailFile = thumbnailInfo?.file + ) + } else { + Timber.d("Could not transcode video ${uri.path.orEmpty().hash()}, sending original file as plain file") + // If the video could not be compressed, just use the original one, but send it as a file + return processFile(uri, MimeTypes.OctetStream) + } + } + + private suspend fun processAudio(uri: Uri, mimeType: String?): MediaUploadInfo { + Timber.d("Processing audio ${uri.path.orEmpty().hash()}") + 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 + ExifInterface(file).apply { + // See ExifInterface.TAG_GPS_INFO_IFD_POINTER + setAttribute("GPSInfoIFDPointer", null) + setAttribute(ExifInterface.TAG_USER_COMMENT, null) + setAttribute(ExifInterface.TAG_IMAGE_DESCRIPTION, null) + + setAttribute(ExifInterface.TAG_GPS_VERSION_ID, null) + setAttribute(ExifInterface.TAG_GPS_ALTITUDE_REF, null) + setAttribute(ExifInterface.TAG_GPS_ALTITUDE, null) + setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, null) + setAttribute(ExifInterface.TAG_GPS_DATESTAMP, null) + setAttribute(ExifInterface.TAG_GPS_SATELLITES, null) + setAttribute(ExifInterface.TAG_GPS_STATUS, null) + setAttribute(ExifInterface.TAG_GPS_MEASURE_MODE, null) + setAttribute(ExifInterface.TAG_GPS_DOP, null) + setAttribute(ExifInterface.TAG_GPS_SPEED_REF, null) + setAttribute(ExifInterface.TAG_GPS_SPEED, null) + setAttribute(ExifInterface.TAG_GPS_TRACK_REF, null) + setAttribute(ExifInterface.TAG_GPS_TRACK, null) + setAttribute(ExifInterface.TAG_GPS_IMG_DIRECTION_REF, null) + setAttribute(ExifInterface.TAG_GPS_IMG_DIRECTION, null) + setAttribute(ExifInterface.TAG_GPS_MAP_DATUM, null) + setAttribute(ExifInterface.TAG_GPS_DEST_BEARING_REF, null) + setAttribute(ExifInterface.TAG_GPS_DEST_BEARING, null) + setAttribute(ExifInterface.TAG_GPS_DEST_DISTANCE_REF, null) + setAttribute(ExifInterface.TAG_GPS_DEST_DISTANCE, null) + setAttribute(ExifInterface.TAG_GPS_PROCESSING_METHOD, null) + setAttribute(ExifInterface.TAG_GPS_AREA_INFORMATION, null) + setAttribute(ExifInterface.TAG_GPS_DIFFERENTIAL, null) + setAttribute(ExifInterface.TAG_GPS_H_POSITIONING_ERROR, null) + setAttribute(ExifInterface.TAG_GPS_LATITUDE, null) + setAttribute(ExifInterface.TAG_GPS_LATITUDE_REF, null) + setAttribute(ExifInterface.TAG_GPS_LONGITUDE, null) + setAttribute(ExifInterface.TAG_GPS_LONGITUDE_REF, null) + setAttribute(ExifInterface.TAG_GPS_DEST_LONGITUDE, null) + setAttribute(ExifInterface.TAG_GPS_DEST_LONGITUDE_REF, null) + tryOrNull { saveAttributes() } + } + } + + private suspend fun createTmpFileWithInput(inputStream: InputStream): File? { + return withContext(coroutineDispatchers.io) { + tryOrNull { + if (!baseTmpFileDir.exists()) { + baseTmpFileDir.mkdirs() + } + val tmpFile = context.createTmpFile(baseTmpFileDir) + tmpFile.outputStream().use { inputStream.copyTo(it) } + tmpFile + } + } + } + + private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailResult: ThumbnailResult?): VideoInfo = + MediaMetadataRetriever().runAndRelease { + setDataSource(context, Uri.fromFile(file)) + + val rotation = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toInt() ?: 0 + val rawWidth = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toLong() ?: 0L + val rawHeight = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L + + val (width, height) = if (rotation == 90 || rotation == 270) rawHeight to rawWidth else rawWidth to rawHeight + + VideoInfo( + duration = extractDuration(), + width = width, + height = height, + 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") + } +} + +private 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 durationInMs.milliseconds +} diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMaxUploadSizeProvider.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMaxUploadSizeProvider.kt new file mode 100644 index 0000000..0cd43bc --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMaxUploadSizeProvider.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.impl + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.mediaupload.api.MaxUploadSizeProvider + +/** + * Provides the maximum upload size allowed by the Matrix server. + */ +@ContributesBinding(SessionScope::class) +class DefaultMaxUploadSizeProvider( + private val matrixClient: MatrixClient, +) : MaxUploadSizeProvider { + override suspend fun getMaxUploadSize(): Result { + return matrixClient.getMaxFileUploadSize() + } +} diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProvider.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProvider.kt new file mode 100644 index 0000000..4b6d298 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProvider.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.impl + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import kotlinx.coroutines.flow.first + +@ContributesBinding(SessionScope::class) +class DefaultMediaOptimizationConfigProvider( + private val sessionPreferencesStore: SessionPreferencesStore, +) : MediaOptimizationConfigProvider { + override suspend fun get(): MediaOptimizationConfig = MediaOptimizationConfig( + compressImages = sessionPreferencesStore.doesOptimizeImages().first(), + videoCompressionPreset = sessionPreferencesStore.getVideoCompressionPreset().first(), + ) +} diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt new file mode 100644 index 0000000..ea2cac2 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.impl + +import android.net.Uri +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.hash.hash +import io.element.android.libraries.core.extensions.flatMap +import io.element.android.libraries.core.extensions.flatMapCatching +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaUploadHandler +import io.element.android.libraries.matrix.api.room.CreateTimelineParams +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.mediaupload.api.MediaSender +import io.element.android.libraries.mediaupload.api.MediaSenderFactory +import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import timber.log.Timber +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +@ContributesBinding(RoomScope::class) +class DefaultMediaSenderFactory( + private val preProcessor: MediaPreProcessor, + private val room: JoinedRoom, + private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, +) : MediaSenderFactory { + override fun create( + timelineMode: Timeline.Mode, + ): MediaSender { + return DefaultMediaSender( + preProcessor = preProcessor, + room = room, + timelineMode = timelineMode, + mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, + ) + } +} + +@ContributesBinding(SessionScope::class) +class DefaultMediaSenderRoomFactory( + private val preProcessor: MediaPreProcessor, + private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, +) : MediaSenderRoomFactory { + override fun create( + room: JoinedRoom, + ): MediaSender { + return DefaultMediaSender( + preProcessor = preProcessor, + room = room, + timelineMode = Timeline.Mode.Live, + mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, + ) + } +} + +class DefaultMediaSender( + private val preProcessor: MediaPreProcessor, + private val room: JoinedRoom, + private val timelineMode: Timeline.Mode, + private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, +) : MediaSender { + private val ongoingUploadJobs = ConcurrentHashMap() + val hasOngoingMediaUploads get() = ongoingUploadJobs.isNotEmpty() + + override suspend fun preProcessMedia( + uri: Uri, + mimeType: String, + mediaOptimizationConfig: MediaOptimizationConfig, + ): Result { + Timber.d("Pre-processing media | uri: ${mediaId(uri)} | mimeType: $mimeType") + return preProcessor + .process( + uri = uri, + mimeType = mimeType, + deleteOriginal = false, + mediaOptimizationConfig = mediaOptimizationConfig, + ) + } + + override suspend fun sendPreProcessedMedia( + mediaUploadInfo: MediaUploadInfo, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result { + val mediaLogId = mediaId(mediaUploadInfo.file) + return getTimeline().flatMap { + Timber.d("Started sending media $mediaLogId using timeline: ${it.mode}") + it.sendMedia( + uploadInfo = mediaUploadInfo, + caption = caption, + formattedCaption = formattedCaption, + inReplyToEventId = inReplyToEventId, + ) + } + .handleSendResult(mediaLogId) + } + + override suspend fun sendMedia( + uri: Uri, + mimeType: String, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + mediaOptimizationConfig: MediaOptimizationConfig, + ): Result { + return preProcessor + .process( + uri = uri, + mimeType = mimeType, + deleteOriginal = false, + mediaOptimizationConfig = mediaOptimizationConfig, + ) + .flatMapCatching { info -> + getTimeline().getOrThrow().sendMedia( + uploadInfo = info, + caption = caption, + formattedCaption = formattedCaption, + inReplyToEventId = inReplyToEventId, + ) + } + .handleSendResult(mediaId(uri)) + } + + override suspend fun sendVoiceMessage( + uri: Uri, + mimeType: String, + waveForm: List, + inReplyToEventId: EventId?, + ): Result { + return preProcessor + .process( + uri = uri, + mimeType = mimeType, + deleteOriginal = true, + mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), + ) + .flatMapCatching { info -> + val audioInfo = (info as MediaUploadInfo.Audio).audioInfo + val newInfo = MediaUploadInfo.VoiceMessage( + file = info.file, + audioInfo = audioInfo, + waveform = waveForm, + ) + getTimeline().getOrThrow().sendMedia( + uploadInfo = newInfo, + caption = null, + formattedCaption = null, + inReplyToEventId = inReplyToEventId, + ) + } + .handleSendResult(mediaId(uri)) + } + + private fun Result.handleSendResult(mediaId: String) = this + .onFailure { error -> + val job = ongoingUploadJobs.remove(Job) + Timber.e(error, "Sending media $mediaId failed. Removing ongoing upload job. Total: ${ongoingUploadJobs.size}") + if (error !is CancellationException) { + job?.cancel() + } + } + .onSuccess { + Timber.d("Sent media $mediaId successfully. Removing ongoing upload job. Total: ${ongoingUploadJobs.size}") + ongoingUploadJobs.remove(Job) + } + + private suspend fun Timeline.sendMedia( + uploadInfo: MediaUploadInfo, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result { + val handler = when (uploadInfo) { + is MediaUploadInfo.Image -> { + sendImage( + file = uploadInfo.file, + thumbnailFile = uploadInfo.thumbnailFile, + imageInfo = uploadInfo.imageInfo, + caption = caption, + formattedCaption = formattedCaption, + inReplyToEventId = inReplyToEventId, + ) + } + is MediaUploadInfo.Video -> { + sendVideo( + file = uploadInfo.file, + thumbnailFile = uploadInfo.thumbnailFile, + videoInfo = uploadInfo.videoInfo, + caption = caption, + formattedCaption = formattedCaption, + inReplyToEventId = inReplyToEventId, + ) + } + is MediaUploadInfo.Audio -> { + sendAudio( + file = uploadInfo.file, + audioInfo = uploadInfo.audioInfo, + caption = caption, + formattedCaption = formattedCaption, + inReplyToEventId = inReplyToEventId, + ) + } + is MediaUploadInfo.VoiceMessage -> { + sendVoiceMessage( + file = uploadInfo.file, + audioInfo = uploadInfo.audioInfo, + waveform = uploadInfo.waveform, + inReplyToEventId = inReplyToEventId, + ) + } + is MediaUploadInfo.AnyFile -> { + sendFile( + file = uploadInfo.file, + fileInfo = uploadInfo.fileInfo, + caption = caption, + formattedCaption = formattedCaption, + inReplyToEventId = inReplyToEventId, + ) + } + } + + // We handle the cancellations here manually, so we suppress the warning + @Suppress("RunCatchingNotAllowed") + return handler + .mapCatching { uploadHandler -> + Timber.d("Added ongoing upload job, total: ${ongoingUploadJobs.size + 1}") + ongoingUploadJobs[Job] = uploadHandler + uploadHandler.await() + } + } + + private suspend fun getTimeline(): Result { + return when (timelineMode) { + is Timeline.Mode.Thread -> { + room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = timelineMode.threadRootId)) + } + else -> Result.success(room.liveTimeline) + } + } + + /** + * Clean up any temporary files or resources used during the media processing. + */ + override fun cleanUp() = preProcessor.cleanUp() +} + +private fun mediaId(uri: Uri?): String = uri?.path.orEmpty().hash() +private fun mediaId(file: File): String = file.path.orEmpty().hash() diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/ImageCompressor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/ImageCompressor.kt new file mode 100644 index 0000000..ab32afc --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/ImageCompressor.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.impl + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.exifinterface.media.ExifInterface +import dev.zacsweers.metro.Inject +import io.element.android.libraries.androidutils.bitmap.calculateInSampleSize +import io.element.android.libraries.androidutils.bitmap.resizeToMax +import io.element.android.libraries.androidutils.bitmap.rotateToExifMetadataOrientation +import io.element.android.libraries.androidutils.file.createTmpFile +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.annotations.ApplicationContext +import kotlinx.coroutines.withContext +import java.io.File +import java.io.InputStream + +@Inject +class ImageCompressor( + @ApplicationContext private val context: Context, + private val dispatchers: CoroutineDispatchers, +) { + /** + * 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], [orientation] and [desiredQuality]. + * @return a [Result] containing the resulting [ImageCompressionResult] with the temporary [File] and some metadata. + */ + suspend fun compressToTmpFile( + inputStreamProvider: () -> InputStream, + resizeMode: ResizeMode, + mimeType: String, + orientation: Int = ExifInterface.ORIENTATION_UNDEFINED, + desiredQuality: Int = 78, + ): Result = withContext(dispatchers.io) { + runCatchingExceptions { + val format = mimeTypeToCompressFormat(mimeType) + val extension = mimeTypeToCompressFileExtension(mimeType) + val compressedBitmap = compressToBitmap(inputStreamProvider, resizeMode, orientation).getOrThrow() + // Encode bitmap to the destination temporary file + val tmpFile = context.createTmpFile(extension = extension) + tmpFile.outputStream().use { + compressedBitmap.compress(format, desiredQuality, it) + } + ImageCompressionResult( + file = tmpFile, + width = compressedBitmap.width, + height = compressedBitmap.height, + size = tmpFile.length() + ) + } + } + + /** + * Decodes the inputStream from [inputStreamProvider] into a [Bitmap] and applies the needed transformations (rotation, scale) + * based on [resizeMode] and [orientation]. + * @return a [Result] containing the resulting [Bitmap]. + */ + fun compressToBitmap( + inputStreamProvider: () -> InputStream, + resizeMode: ResizeMode, + orientation: Int, + ): Result = runCatchingExceptions { + val options = BitmapFactory.Options() + // Decode bounds + inputStreamProvider().use { input -> + calculateDecodingScale(input, resizeMode, options) + } + // Decode the actual bitmap + inputStreamProvider().use { input -> + // Now read the actual image and rotate it to match its metadata + options.inJustDecodeBounds = false + val decodedBitmap = BitmapFactory.decodeStream(input, null, options) + ?: error("Decoding Bitmap from InputStream failed") + val rotatedBitmap = decodedBitmap.rotateToExifMetadataOrientation(orientation) + if (resizeMode is ResizeMode.Strict) { + rotatedBitmap.resizeToMax(resizeMode.maxWidth, resizeMode.maxHeight) + } else { + rotatedBitmap + } + } + } + + private fun calculateDecodingScale( + inputStream: InputStream, + 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 + options.inJustDecodeBounds = true + BitmapFactory.decodeStream(inputStream, null, options) + // Set sample size based on the outWidth and outHeight + options.inSampleSize = options.calculateInSampleSize(width, height) + } +} + +data class ImageCompressionResult( + val file: File, + val width: Int, + val height: Int, + val size: Long, +) + +sealed interface ResizeMode { + data 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/impl/MimeTypeUtil.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/MimeTypeUtil.kt new file mode 100644 index 0000000..9d4c11c --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/MimeTypeUtil.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.impl + +import android.graphics.Bitmap +import io.element.android.libraries.core.mimetype.MimeTypes + +fun mimeTypeToCompressFormat(mimeType: String) = when (mimeType) { + MimeTypes.Png -> Bitmap.CompressFormat.PNG + else -> Bitmap.CompressFormat.JPEG +} + +fun mimeTypeToCompressFileExtension(mimeType: String) = when (mimeType) { + MimeTypes.Png -> "png" + else -> "jpeg" +} + +fun mimeTypeToThumbnailMimeType(mimeType: String) = when (mimeType) { + MimeTypes.Png -> MimeTypes.Png + else -> MimeTypes.Jpeg +} diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/ThumbnailFactory.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/ThumbnailFactory.kt new file mode 100644 index 0000000..6c6c711 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/ThumbnailFactory.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.impl + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.media.MediaMetadataRetriever +import android.media.MediaMetadataRetriever.OPTION_CLOSEST_SYNC +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 dev.zacsweers.metro.Inject +import io.element.android.libraries.androidutils.bitmap.resizeToMax +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.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.media.ThumbnailInfo +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import kotlinx.coroutines.suspendCancellableCoroutine +import timber.log.Timber +import java.io.File +import java.io.IOException +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 + +@Inject +class ThumbnailFactory( + @ApplicationContext private val context: Context, + private val sdkIntProvider: BuildVersionSdkIntProvider +) { + @SuppressLint("NewApi") + suspend fun createImageThumbnail( + file: File, + mimeType: String, + ): ThumbnailResult? { + return createThumbnail(mimeType = mimeType) { cancellationSignal -> + try { + // This API works correctly with GIF + if (sdkIntProvider.isAtLeast(Build.VERSION_CODES.Q)) { + try { + ThumbnailUtils.createImageThumbnail( + file, + Size(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), + cancellationSignal + ) + } catch (ioException: IOException) { + Timber.w(ioException, "Failed to create thumbnail for $file") + null + } + } else { + @Suppress("DEPRECATION") + ThumbnailUtils.createImageThumbnail( + file.path, + MediaStore.Images.Thumbnails.MINI_KIND, + ) + } + } catch (throwable: Throwable) { + Timber.w(throwable, "Failed to create thumbnail for $file") + null + } + } + } + + suspend fun createVideoThumbnail(file: File): ThumbnailResult? { + return createThumbnail(mimeType = MimeTypes.Jpeg) { + MediaMetadataRetriever().runAndRelease { + setDataSource(context, file.toUri()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + getScaledFrameAtTime(VIDEO_THUMB_FRAME, OPTION_CLOSEST_SYNC, THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT) + } else { + getFrameAtTime(VIDEO_THUMB_FRAME)?.resizeToMax(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT) + } + } + } + } + + private suspend fun createThumbnail( + mimeType: String, + bitmapFactory: (CancellationSignal) -> Bitmap?, + ): ThumbnailResult? = suspendCancellableCoroutine { continuation -> + val cancellationSignal = CancellationSignal() + continuation.invokeOnCancellation { + cancellationSignal.cancel() + } + val bitmapThumbnail: Bitmap? = bitmapFactory(cancellationSignal) + if (bitmapThumbnail == null) { + continuation.resume(null) + return@suspendCancellableCoroutine + } + val format = mimeTypeToCompressFormat(mimeType) + val extension = mimeTypeToCompressFileExtension(mimeType) + val thumbnailFile = context.createTmpFile(extension = extension) + thumbnailFile.outputStream().use { outputStream -> + bitmapThumbnail.compress(format, 78, outputStream) + } + val blurhash = BlurHash.encode(bitmapThumbnail, 3, 3) + val thumbnailResult = ThumbnailResult( + file = thumbnailFile, + info = ThumbnailInfo( + width = bitmapThumbnail.width.toLong(), + height = bitmapThumbnail.height.toLong(), + mimetype = mimeTypeToThumbnailMimeType(mimeType), + 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/impl/VideoCompressor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressor.kt new file mode 100644 index 0000000..11cb193 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressor.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.impl + +import android.content.Context +import android.media.MediaCodecInfo +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.util.Size +import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.media3.effect.Presentation +import androidx.media3.transformer.Composition +import androidx.media3.transformer.DefaultEncoderFactory +import androidx.media3.transformer.EditedMediaItem +import androidx.media3.transformer.Effects +import androidx.media3.transformer.ExportException +import androidx.media3.transformer.ExportResult +import androidx.media3.transformer.ProgressHolder +import androidx.media3.transformer.TransformationRequest +import androidx.media3.transformer.Transformer +import androidx.media3.transformer.VideoEncoderSettings +import dev.zacsweers.metro.Inject +import io.element.android.libraries.androidutils.file.createTmpFile +import io.element.android.libraries.androidutils.file.safeDelete +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File + +@Inject +class VideoCompressor( + @ApplicationContext private val context: Context, +) { + @OptIn(UnstableApi::class) + fun compress(uri: Uri, videoCompressionPreset: VideoCompressionPreset): Flow = callbackFlow { + val metadata = getVideoMetadata(uri) + + val videoCompressorConfig = VideoCompressorConfigFactory.create( + metadata = metadata, + preset = videoCompressionPreset, + ) + + val tmpFile = context.createTmpFile(extension = "mp4") + + val width = metadata?.width ?: Int.MAX_VALUE + val height = metadata?.height ?: Int.MAX_VALUE + + val videoResizeEffect = run { + val outputSize = videoCompressorConfig.videoCompressorHelper.getOutputSize(Size(width, height)) + if (metadata?.rotation == 90 || metadata?.rotation == 270) { + // If the video is rotated, we need to swap width and height + Presentation.createForWidthAndHeight( + outputSize.height, + outputSize.width, + Presentation.LAYOUT_SCALE_TO_FIT, + ) + } else { + // Otherwise, we can use the original width and height + Presentation.createForWidthAndHeight( + outputSize.width, + outputSize.height, + Presentation.LAYOUT_SCALE_TO_FIT, + ) + } + } + + // If we are resizing, we also want to reduce set frame rate to the default value (30fps) + val newFrameRate = videoCompressorConfig.newFrameRate + + // If we need to resize the video, we also want to recalculate the bitrate + val newBitrate = videoCompressorConfig.newBitRate + + val inputMediaItem = MediaItem.fromUri(uri) + val outputMediaItem = EditedMediaItem.Builder(inputMediaItem) + .setFrameRate(newFrameRate) + .setEffects(Effects(emptyList(), listOf(videoResizeEffect))) + .build() + + val encoderFactory = DefaultEncoderFactory.Builder(context) + .setRequestedVideoEncoderSettings( + VideoEncoderSettings.Builder() + // Use VBR which is generally better for quality and compatibility, although slightly worse for file size + .setBitrateMode(MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR) + .setBitrate(newBitrate) + .build() + ) + .build() + + val videoTransformer = Transformer.Builder(context) + .setVideoMimeType(MimeTypes.VIDEO_H264) + .setAudioMimeType(MimeTypes.AUDIO_AAC) + .setPortraitEncodingEnabled(false) + .setEncoderFactory(encoderFactory) + .addListener(object : Transformer.Listener { + override fun onCompleted(composition: Composition, exportResult: ExportResult) { + trySend(VideoTranscodingEvent.Completed(tmpFile)) + close() + } + + override fun onError(composition: Composition, exportResult: ExportResult, exportException: ExportException) { + Timber.e(exportException, "Video transcoding failed") + tmpFile.safeDelete() + close(exportException) + } + + override fun onFallbackApplied( + composition: Composition, + originalTransformationRequest: TransformationRequest, + fallbackTransformationRequest: TransformationRequest + ) = Unit + }) + .build() + + val progressJob = launch(Dispatchers.Main) { + val progressHolder = ProgressHolder() + while (isActive) { + val state = videoTransformer.getProgress(progressHolder) + if (state != Transformer.PROGRESS_STATE_NOT_STARTED) { + channel.send(VideoTranscodingEvent.Progress(progressHolder.progress.toFloat())) + } + delay(500) + } + } + + withContext(Dispatchers.Main) { + videoTransformer.start(outputMediaItem, tmpFile.path) + } + + awaitClose { + progressJob.cancel() + } + } + + private fun getVideoMetadata(uri: Uri): VideoFileMetadata? { + return runCatchingExceptions { + MediaMetadataRetriever().use { + it.setDataSource(context, uri) + + val width = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: -1 + val height = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: -1 + val bitrate = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)?.toLongOrNull() ?: -1 + val frameRate = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)?.toIntOrNull() ?: -1 + val rotation = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0 + + val (actualWidth, actualHeight) = if (width == -1 || height == -1) { + // Try getting the first frame instead + val bitmap = it.getFrameAtTime(0) ?: return null + bitmap.width to bitmap.height + } else { + width to height + } + + VideoFileMetadata( + width = actualWidth, + height = actualHeight, + bitrate = bitrate, + frameRate = frameRate, + rotation = rotation, + ) + } + }.onFailure { + Timber.e(it, "Failed to get video dimensions") + }.getOrNull() + } +} + +internal data class VideoFileMetadata( + val width: Int, + val height: Int, + val bitrate: Long, + val frameRate: Int, + val rotation: Int, +) + +sealed interface VideoTranscodingEvent { + data class Progress(val value: Float) : VideoTranscodingEvent + data class Completed(val file: File) : VideoTranscodingEvent +} diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfig.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfig.kt new file mode 100644 index 0000000..1526782 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfig.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.impl + +import android.util.Size +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import io.element.android.libraries.androidutils.media.VideoCompressorHelper +import io.element.android.libraries.mediaupload.api.compressorHelper +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import kotlin.math.min + +@OptIn(UnstableApi::class) +internal object VideoCompressorConfigFactory { + private const val DEFAULT_FRAME_RATE = 30 + + fun create( + metadata: VideoFileMetadata?, + preset: VideoCompressionPreset, + ): VideoCompressorConfig { + val width = metadata?.width?.takeIf { it >= 0 } ?: Int.MAX_VALUE + val height = metadata?.height?.takeIf { it >= 0 } ?: Int.MAX_VALUE + val originalFrameRate = metadata?.frameRate?.takeIf { it >= 0 } ?: DEFAULT_FRAME_RATE + + val resizer = preset.compressorHelper() + + // If we are resizing, we also want to reduce the frame rate to the default value (30fps) + val newFrameRate = min(originalFrameRate, DEFAULT_FRAME_RATE) + + // If we need to resize the video, we also want to recalculate the bitrate + val newBitrate = resizer.calculateOptimalBitrate(Size(width, height), newFrameRate) + + return VideoCompressorConfig( + videoCompressorHelper = resizer, + newBitRate = newBitrate.toInt(), + newFrameRate = newFrameRate, + ) + } +} + +@OptIn(UnstableApi::class) +internal data class VideoCompressorConfig( + val videoCompressorHelper: VideoCompressorHelper, + val newBitRate: Int, + val newFrameRate: Int, +) diff --git a/libraries/mediaupload/impl/src/test/assets/animated_gif.gif b/libraries/mediaupload/impl/src/test/assets/animated_gif.gif new file mode 100644 index 0000000..8fd2ce1 --- /dev/null +++ b/libraries/mediaupload/impl/src/test/assets/animated_gif.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6831610b21668c49e31732f9005177e959277233d3cab758910e061294f91d79 +size 687979 diff --git a/libraries/mediaupload/impl/src/test/assets/image.jpeg b/libraries/mediaupload/impl/src/test/assets/image.jpeg new file mode 100644 index 0000000..3d75513 --- /dev/null +++ b/libraries/mediaupload/impl/src/test/assets/image.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77276f9b174f8823eaf787ab0a659199ef5d30c0361ec8b9b4f0890adb1907a1 +size 9986336 diff --git a/libraries/mediaupload/impl/src/test/assets/image.png b/libraries/mediaupload/impl/src/test/assets/image.png new file mode 100644 index 0000000..b0946f7 --- /dev/null +++ b/libraries/mediaupload/impl/src/test/assets/image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a980f7b74cb9edc323919db8652798da4b3dcf865fc7b6a1eb1110096b7bfb4f +size 1856786 diff --git a/libraries/mediaupload/impl/src/test/assets/sample3s.mp3 b/libraries/mediaupload/impl/src/test/assets/sample3s.mp3 new file mode 100644 index 0000000..cb4db9c --- /dev/null +++ b/libraries/mediaupload/impl/src/test/assets/sample3s.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0244590f2b4bcb62352b574e78bea940e8d89cfa69823b5208ef4c43e0abcb44 +size 52079 diff --git a/libraries/mediaupload/impl/src/test/assets/text.txt b/libraries/mediaupload/impl/src/test/assets/text.txt new file mode 100644 index 0000000..d45ec43 --- /dev/null +++ b/libraries/mediaupload/impl/src/test/assets/text.txt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ba904eae8773b70c75333db4de2f3ac45a8ad4ddba1b242f0b3cfc199391dd8 +size 13 diff --git a/libraries/mediaupload/impl/src/test/assets/video.mp4 b/libraries/mediaupload/impl/src/test/assets/video.mp4 new file mode 100644 index 0000000..4d57318 --- /dev/null +++ b/libraries/mediaupload/impl/src/test/assets/video.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb58436524db95bd0c10b2c3023c2eb7b87404a2eab8987939f051647eb859d3 +size 1673712 diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt new file mode 100644 index 0000000..f4b4e7d --- /dev/null +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt @@ -0,0 +1,443 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.impl + +import android.content.Context +import android.net.Uri +import android.os.Build +import androidx.core.net.toUri +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.file.TemporaryUriDeleter +import io.element.android.libraries.core.mimetype.MimeTypes +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.ThumbnailInfo +import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider +import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import kotlin.time.Duration + +@RunWith(RobolectricTestRunner::class) +class AndroidMediaPreProcessorTest { + private suspend fun TestScope.process( + asset: Asset, + mediaOptimizationConfig: MediaOptimizationConfig, + sdkIntVersion: Int = Build.VERSION_CODES.P, + deleteOriginal: Boolean = false, + ): MediaUploadInfo { + val context = InstrumentationRegistry.getInstrumentation().context + val deleteCallback = lambdaRecorder {} + val sut = createAndroidMediaPreProcessor( + context = context, + sdkIntVersion = sdkIntVersion, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + ) + val file = getFileFromAssets(context, asset.filename) + val result = sut.process( + uri = file.toUri(), + mimeType = asset.mimeType, + deleteOriginal = deleteOriginal, + mediaOptimizationConfig = mediaOptimizationConfig, + ) + val data = result.getOrThrow() + assertThat(data.file.path).endsWith(asset.filename) + deleteCallback.assertions().isCalledExactly(if (deleteOriginal) 0 else 1) + return data + } + + @Test + @Ignore("Ignore now that min API for enterprise is 33") + fun `test processing png`() = runTest { + val mediaUploadInfo = process( + asset = assetImagePng, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.thumbnailFile).isNotNull() + assertThat(info.imageInfo).isEqualTo( + ImageInfo( + height = assetImagePng.height, + width = assetImagePng.width, + mimetype = assetImagePng.mimeType, + size = 2_026_433, + ThumbnailInfo(height = 25, width = 25, mimetype = MimeTypes.Png, size = 91), + thumbnailSource = null, + blurhash = "K00000fQfQfQfQfQfQfQfQ" + ) + ) + } + + @Test + fun `test processing png api Q`() = runTest { + val mediaUploadInfo = process( + asset = assetImagePng, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + sdkIntVersion = Build.VERSION_CODES.Q, + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.thumbnailFile).isNull() + assertThat(info.imageInfo).isEqualTo( + ImageInfo( + height = assetImagePng.height, + width = assetImagePng.width, + mimetype = assetImagePng.mimeType, + size = 2_026_433, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ) + ) + } + + @Test + @Ignore("Ignore now that min API for enterprise is 33") + fun `test processing png no compression`() = runTest { + val mediaUploadInfo = process( + asset = assetImagePng, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = false, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.thumbnailFile).isNotNull() + assertThat(info.imageInfo).isEqualTo( + ImageInfo( + height = assetImagePng.height, + width = assetImagePng.width, + mimetype = assetImagePng.mimeType, + size = assetImagePng.size, + thumbnailInfo = ThumbnailInfo(height = 25, width = 25, mimetype = MimeTypes.Png, size = 91), + thumbnailSource = null, + blurhash = "K00000fQfQfQfQfQfQfQfQ", + ) + ) + } + + @Test + @Ignore("Ignore now that min API for enterprise is 33") + fun `test processing png and delete`() = runTest { + val mediaUploadInfo = process( + asset = assetImagePng, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = false, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + deleteOriginal = true, + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.thumbnailFile).isNotNull() + assertThat(info.imageInfo).isEqualTo( + ImageInfo( + height = assetImagePng.height, + width = assetImagePng.width, + mimetype = assetImagePng.mimeType, + size = assetImagePng.size, + thumbnailInfo = ThumbnailInfo(height = 25, width = 25, mimetype = MimeTypes.Png, size = 91), + thumbnailSource = null, + blurhash = "K00000fQfQfQfQfQfQfQfQ", + ) + ) + // Does not work + // assertThat(file.exists()).isFalse() + } + + @Test + @Ignore("Ignore now that min API for enterprise is 33") + fun `test processing jpeg`() = runTest { + val mediaUploadInfo = process( + asset = assetImageJpeg, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.thumbnailFile).isNotNull() + assertThat(info.imageInfo).isEqualTo( + ImageInfo( + height = 979, + width = 3006, + mimetype = MimeTypes.Jpeg, + size = 84_845, + ThumbnailInfo(height = 244, width = 751, mimetype = MimeTypes.Jpeg, size = 7_178), + thumbnailSource = null, + blurhash = "K07gBzX=j_D4xZjoaSe,s:" + ) + ) + } + + @Test + fun `test processing jpeg api Q`() = runTest { + val mediaUploadInfo = process( + asset = assetImageJpeg, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + sdkIntVersion = Build.VERSION_CODES.Q, + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.thumbnailFile).isNull() + assertThat(info.imageInfo).isEqualTo( + ImageInfo( + height = 979, + width = 3_006, + mimetype = MimeTypes.Jpeg, + size = 84_845, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ) + ) + } + + @Test + @Ignore("Ignore now that min API for enterprise is 33") + fun `test processing jpeg no compression`() = runTest { + val mediaUploadInfo = process( + asset = assetImageJpeg, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = false, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.thumbnailFile).isNotNull() + assertThat(info.imageInfo).isEqualTo( + ImageInfo( + height = assetImageJpeg.height, + width = assetImageJpeg.width, + mimetype = assetImageJpeg.mimeType, + size = assetImageJpeg.size, + thumbnailInfo = ThumbnailInfo(height = 6, width = 6, mimetype = MimeTypes.Jpeg, size = 631), + thumbnailSource = null, + blurhash = "K00000fQfQfQfQfQfQfQfQ", + ) + ) + } + + @Test + @Ignore("Ignore now that min API for enterprise is 33") + fun `test processing jpeg and delete`() = runTest { + val mediaUploadInfo = process( + asset = assetImageJpeg, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = false, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + deleteOriginal = true, + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.thumbnailFile).isNotNull() + assertThat(info.imageInfo).isEqualTo( + ImageInfo( + height = assetImageJpeg.height, + width = assetImageJpeg.width, + mimetype = assetImageJpeg.mimeType, + size = assetImageJpeg.size, + thumbnailInfo = ThumbnailInfo(height = 6, width = 6, mimetype = MimeTypes.Jpeg, size = 631), + thumbnailSource = null, + blurhash = "K00000fQfQfQfQfQfQfQfQ", + ) + ) + // Does not work + // assertThat(file.exists()).isFalse() + } + + @Test + @Ignore("Ignore now that min API for enterprise is 33") + fun `test processing gif`() = runTest { + val mediaUploadInfo = process( + asset = assetAnimatedGif, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + ) + val info = mediaUploadInfo as MediaUploadInfo.Image + assertThat(info.thumbnailFile).isNotNull() + assertThat(info.imageInfo).isEqualTo( + ImageInfo( + height = assetAnimatedGif.height, + width = assetAnimatedGif.width, + mimetype = assetAnimatedGif.mimeType, + size = assetAnimatedGif.size, + thumbnailInfo = ThumbnailInfo(height = 50, width = 50, mimetype = MimeTypes.Jpeg, size = 691), + thumbnailSource = null, + blurhash = "K00000fQfQfQfQfQfQfQfQ", + ) + ) + } + + @Test + fun `test processing file`() = runTest { + val mediaUploadInfo = process( + asset = assetText, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + ) + val info = mediaUploadInfo as MediaUploadInfo.AnyFile + assertThat(info.fileInfo).isEqualTo( + FileInfo( + mimetype = assetText.mimeType, + size = assetText.size, + thumbnailInfo = null, + thumbnailSource = null, + ) + ) + } + + @Ignore("Compressing video is not working with Robolectric") + @Test + fun `test processing video`() = runTest { + val mediaUploadInfo = process( + asset = assetVideo, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + ) + val info = mediaUploadInfo as MediaUploadInfo.Video + assertThat(info.thumbnailFile).isNotNull() + assertThat(info.videoInfo).isEqualTo( + VideoInfo( + // Not available with Robolectric? + duration = Duration.ZERO, + height = 1_178, + width = 1_818, + mimetype = MimeTypes.Mp4, + size = 114_867, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ) + ) + } + + @Ignore("Compressing video is not working with Robolectric") + @Test + fun `test processing video no compression`() = runTest { + val mediaUploadInfo = process( + asset = assetVideo, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.HIGH, + ), + ) + val info = mediaUploadInfo as MediaUploadInfo.Video + // Computing thumbnailFile is failing with Robolectric + assertThat(info.thumbnailFile).isNull() + assertThat(info.videoInfo).isEqualTo( + VideoInfo( + // Not available with Robolectric? + duration = Duration.ZERO, + // Not available with Robolectric? + height = 0, + // Not available with Robolectric? + width = 0, + mimetype = MimeTypes.Mp4, + size = 1_673_712, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ) + ) + } + + @Test + fun `test processing audio`() = runTest { + val mediaUploadInfo = process( + asset = assetAudio, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + ) + val info = mediaUploadInfo as MediaUploadInfo.Audio + assertThat(info.audioInfo).isEqualTo( + AudioInfo( + // Not available with Robolectric? + duration = Duration.ZERO, + size = 52_079, + mimetype = MimeTypes.Mp3, + ) + ) + } + + @Test + fun `test file which does not exist`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val sut = createAndroidMediaPreProcessor(context) + val file = File(context.cacheDir, "not found.txt") + val result = sut.process( + uri = file.toUri(), + mimeType = MimeTypes.PlainText, + deleteOriginal = false, + mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ), + ) + assertThat(result.isFailure).isTrue() + val failure = result.exceptionOrNull() + assertThat(failure).isInstanceOf(MediaPreProcessor.Failure::class.java) + assertThat(failure?.cause).isInstanceOf(FileNotFoundException::class.java) + } + + private fun TestScope.createAndroidMediaPreProcessor( + context: Context, + sdkIntVersion: Int = Build.VERSION_CODES.P, + temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(), + ) = AndroidMediaPreProcessor( + context = context, + thumbnailFactory = ThumbnailFactory(context, FakeBuildVersionSdkIntProvider(sdkIntVersion)), + imageCompressor = ImageCompressor(context, testCoroutineDispatchers()), + videoCompressor = VideoCompressor(context), + coroutineDispatchers = testCoroutineDispatchers(), + temporaryUriDeleter = temporaryUriDeleter, + ) + + @Throws(IOException::class) + private fun getFileFromAssets(context: Context, fileName: String): File = File(context.cacheDir, fileName) + .also { + if (!it.exists()) { + it.outputStream().use { cache -> + context.assets.open(fileName).use { inputStream -> + inputStream.copyTo(cache) + } + } + } + } +} diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/Asset.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/Asset.kt new file mode 100644 index 0000000..4dad31a --- /dev/null +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/Asset.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.impl + +import io.element.android.libraries.core.mimetype.MimeTypes + +data class Asset( + val filename: String, + val mimeType: String, + val size: Long, + val width: Long?, + val height: Long?, +) + +/** + * "image.png" is a 1_818 x 1_178 PNG image with a size of 1_856_786 bytes. + */ +val assetImagePng = Asset( + filename = "image.png", + mimeType = MimeTypes.Png, + size = 1_856_786, + width = 1_818, + height = 1_178, +) + +/** + * "image.jpeg" is a 12_024 x 3_916, JPEG image with a size of 9_986_336 bytes. + */ +val assetImageJpeg = Asset( + filename = "image.jpeg", + mimeType = MimeTypes.Jpeg, + size = 9_986_336, + width = 12_024, + height = 3_916, +) + +/** + * "video.mp4" is a 1_280 x 720, MP4 video with a size of 1_673_712 bytes. + */ +val assetVideo = Asset( + filename = "video.mp4", + mimeType = MimeTypes.Mp4, + size = 1_673_712, + width = 1_280, + height = 720, +) + +/** + * "sample3s.mp3" is a 3 seconds MP3 audio file with a size of 52_079 bytes. + */ +val assetAudio = Asset( + filename = "sample3s.mp3", + mimeType = MimeTypes.Mp3, + size = 52_079, + width = null, + height = null, +) + +/** + * "text.txt" is a 13 bytes text file. + */ +val assetText = Asset( + filename = "text.txt", + mimeType = MimeTypes.PlainText, + size = 13, + width = null, + height = null, +) + +/** + * "animated_gif.gif" is a 800 x 600, GIF image with a size of 687_979 bytes. + */ +val assetAnimatedGif = Asset( + filename = "animated_gif.gif", + mimeType = MimeTypes.Gif, + size = 687_979, + width = 800, + height = 600, +) diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSenderTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSenderTest.kt new file mode 100644 index 0000000..1390428 --- /dev/null +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSenderTest.kt @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.impl + +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.core.EventId +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.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +@RunWith(RobolectricTestRunner::class) +class DefaultMediaSenderTest { + private val mediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ) + + @Test + fun `given an attachment when sending it the preprocessor always runs`() = runTest { + val preProcessor = FakeMediaPreProcessor() + val sender = createDefaultMediaSender( + preProcessor = preProcessor, + room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendFileLambda = lambdaRecorder< + File, + FileInfo, + String?, + String?, + EventId?, + Result, + > { _, _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + }, + ) + ) + + val uri = Uri.parse("content://image.jpg") + sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig) + + assertThat(preProcessor.processCallCount).isEqualTo(1) + } + + @Test + fun `given an attachment when sending it the Room will call sendMedia`() = runTest { + val sendImageResult = + lambdaRecorder { _: File, _: File?, _: ImageInfo, _: String?, _: String?, _: EventId? -> + Result.success(FakeMediaUploadHandler()) + } + val room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendImageLambda = sendImageResult + }, + ) + val sender = createDefaultMediaSender(room = room) + + val uri = Uri.parse("content://image.jpg") + sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig) + } + + @Test + fun `given a failure in the preprocessor when sending the whole process fails`() = runTest { + val preProcessor = FakeMediaPreProcessor().apply { + givenResult(Result.failure(Exception())) + } + val sender = createDefaultMediaSender(preProcessor) + + val uri = Uri.parse("content://image.jpg") + val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig) + + assertThat(result.exceptionOrNull()).isNotNull() + } + + @Test + fun `given a failure in the media upload when sending the whole process fails`() = runTest { + val preProcessor = FakeMediaPreProcessor().apply { + givenImageResult() + } + val sendImageResult = + lambdaRecorder { _: File, _: File?, _: ImageInfo, _: String?, _: String?, _: EventId? -> + Result.failure(Exception()) + } + val room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendImageLambda = sendImageResult + }, + ) + val sender = createDefaultMediaSender( + preProcessor = preProcessor, + room = room, + ) + + val uri = Uri.parse("content://image.jpg") + val result = sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig) + + assertThat(result.exceptionOrNull()).isNotNull() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `given a cancellation in the media upload when sending the job is cancelled`() = runTest(StandardTestDispatcher()) { + val sendFileResult = + lambdaRecorder> { _, _, _, _, _ -> + Result.success(FakeMediaUploadHandler()) + } + val room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendFileLambda = sendFileResult + }, + ) + val sender = createDefaultMediaSender(room = room) + val sendJob = launch { + val uri = Uri.parse("content://image.jpg") + sender.sendMedia(uri = uri, mimeType = MimeTypes.Jpeg, mediaOptimizationConfig = mediaOptimizationConfig) + } + // Wait until several internal tasks run and the file is being uploaded + advanceTimeBy(3L) + + // Assert the file is being uploaded + assertThat(sender.hasOngoingMediaUploads).isTrue() + + // Cancel the coroutine + sendJob.cancel() + + // Wait for the coroutine cleanup to happen + advanceTimeBy(1L) + + // Assert the file is not being uploaded anymore + assertThat(sender.hasOngoingMediaUploads).isFalse() + sendFileResult.assertions().isCalledOnce() + } + + private fun createDefaultMediaSender( + preProcessor: MediaPreProcessor = FakeMediaPreProcessor(), + room: JoinedRoom = FakeJoinedRoom(), + mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = MediaOptimizationConfigProvider { mediaOptimizationConfig }, + ) = DefaultMediaSender( + preProcessor = preProcessor, + room = room, + timelineMode = Timeline.Mode.Live, + mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, + ) +} diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfigFactoryTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfigFactoryTest.kt new file mode 100644 index 0000000..041d05a --- /dev/null +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfigFactoryTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.impl + +import androidx.media3.transformer.VideoEncoderSettings +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@Suppress("NOTHING_TO_INLINE") +@RunWith(RobolectricTestRunner::class) +class VideoCompressorConfigFactoryTest { + @Test + fun `if we don't have metadata the video will be resized`() { + // Given + val metadata = null + val preset = VideoCompressionPreset.STANDARD + + // When + val videoCompressorConfig = VideoCompressorConfigFactory.create( + metadata = metadata, + preset = preset, + ) + + // Then + assertThat(videoCompressorConfig.videoCompressorHelper).isNotNull() + assertThat(videoCompressorConfig.newFrameRate).isEqualTo(30) + assertThat(videoCompressorConfig.newBitRate).isNotEqualTo(VideoEncoderSettings.NO_VALUE) + } + + @Test + fun `if the video should be compressed and is larger than 720p it will be resized`() { + // Given + val metadata = VideoFileMetadata(width = 1920, height = 1080, bitrate = 1_000_000, frameRate = 50, rotation = 0) + val preset = VideoCompressionPreset.STANDARD + + // When + val videoCompressorConfig = VideoCompressorConfigFactory.create( + metadata = metadata, + preset = preset, + ) + + // Then + assertIsResized(videoCompressorConfig, metadata.width) + } + + @Test + fun `if the video should be compressed and is smaller or equal to 720p it will not be resized`() { + // Given + val metadata = VideoFileMetadata(width = 1280, height = 720, bitrate = 1_000_000, frameRate = 50, rotation = 0) + val preset = VideoCompressionPreset.STANDARD + + // When + val videoCompressorConfig = VideoCompressorConfigFactory.create( + metadata = metadata, + preset = preset, + ) + + // Then + assertIsNotResized(videoCompressorConfig, 1280) + } + + @Test + fun `if the video should not be compressed and is larger than 1080p it will be resized`() { + // Given + val metadata = VideoFileMetadata(width = 2560, height = 1440, bitrate = 1_000_000, frameRate = 50, rotation = 0) + val preset = VideoCompressionPreset.HIGH + + // When + val videoCompressorConfig = VideoCompressorConfigFactory.create( + metadata = metadata, + preset = preset, + ) + + // Then + assertIsResized(videoCompressorConfig, metadata.width) + } + + @Test + fun `if the video should not be compressed and is smaller or equal than 1080p it will not be resized`() { + // Given + val metadata = VideoFileMetadata(width = 1920, height = 1080, bitrate = 1_000_000, frameRate = 50, rotation = 0) + val preset = VideoCompressionPreset.HIGH + + // When + val videoCompressorConfig = VideoCompressorConfigFactory.create( + metadata = metadata, + preset = preset, + ) + + // Then + assertIsNotResized(videoCompressorConfig, 1920) + } + + private inline fun assertIsResized(videoCompressorConfig: VideoCompressorConfig, referenceSize: Int) { + assertThat(videoCompressorConfig.videoCompressorHelper.maxSize).isNotEqualTo(referenceSize) + } + + private inline fun assertIsNotResized(videoCompressorConfig: VideoCompressorConfig, referenceSize: Int) { + assertThat(videoCompressorConfig.videoCompressorHelper.maxSize).isEqualTo(referenceSize) + } +} diff --git a/libraries/mediaupload/test/build.gradle.kts b/libraries/mediaupload/test/build.gradle.kts new file mode 100644 index 0000000..7e72908 --- /dev/null +++ b/libraries/mediaupload/test/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.mediaupload.test" +} + +dependencies { + api(projects.libraries.mediaupload.api) + implementation(projects.libraries.core) + implementation(projects.tests.testutils) +} diff --git a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaOptimizationConfigProvider.kt b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaOptimizationConfigProvider.kt new file mode 100644 index 0000000..f22129e --- /dev/null +++ b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaOptimizationConfigProvider.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.test + +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset + +class FakeMediaOptimizationConfigProvider( + val config: MediaOptimizationConfig = MediaOptimizationConfig( + compressImages = true, + videoCompressionPreset = VideoCompressionPreset.STANDARD, + ) +) : MediaOptimizationConfigProvider { + override suspend fun get(): MediaOptimizationConfig = config +} 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 0000000..c07ebb6 --- /dev/null +++ b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.test + +import android.net.Uri +import io.element.android.libraries.core.mimetype.MimeTypes +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.MediaOptimizationConfig +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.CompletableDeferred +import java.io.File +import kotlin.time.Duration.Companion.seconds + +class FakeMediaPreProcessor( + private val processLatch: CompletableDeferred? = null, +) : MediaPreProcessor { + var processCallCount = 0 + private set + + var cleanUpCallCount = 0 + private set + + private var result: Result = Result.success( + MediaUploadInfo.AnyFile( + File("test"), + FileInfo( + mimetype = MimeTypes.Any, + size = 999L, + thumbnailInfo = null, + thumbnailSource = null, + ) + ) + ) + + override suspend fun process( + uri: Uri, + mimeType: String, + deleteOriginal: Boolean, + mediaOptimizationConfig: MediaOptimizationConfig, + ): Result = simulateLongTask { + processLatch?.await() + processCallCount++ + result + } + + fun givenResult(value: Result) { + this.result = value + } + + fun givenAudioResult() { + givenResult( + Result.success( + MediaUploadInfo.Audio( + file = File("audio.ogg"), + audioInfo = AudioInfo( + duration = 1000.seconds, + size = 1000, + mimetype = MimeTypes.Ogg, + ), + ) + ) + ) + } + + fun givenImageResult() { + givenResult( + Result.success( + MediaUploadInfo.Image( + file = File("image.jpg"), + imageInfo = ImageInfo( + height = 100, + width = 100, + mimetype = MimeTypes.Jpeg, + size = 1000, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ), + thumbnailFile = null, + ) + ) + ) + } + + fun givenVideoResult() { + givenResult( + Result.success( + MediaUploadInfo.Video( + file = File("image.jpg"), + videoInfo = VideoInfo( + duration = 1000.seconds, + height = 100, + width = 100, + mimetype = MimeTypes.Mp4, + size = 1000, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ), + thumbnailFile = null, + ) + ) + ) + } + + override fun cleanUp() { + cleanUpCallCount += 1 + } +} diff --git a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaSender.kt b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaSender.kt new file mode 100644 index 0000000..1713f7b --- /dev/null +++ b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaSender.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaupload.test + +import android.net.Uri +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig +import io.element.android.libraries.mediaupload.api.MediaSender +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeMediaSender( + private val preProcessMediaResult: () -> Result = { lambdaError() }, + private val sendPreProcessedMediaResult: () -> Result = { lambdaError() }, + private val sendMediaResult: () -> Result = { lambdaError() }, + private val sendVoiceMessageResult: () -> Result = { lambdaError() }, + private val cleanUpResult: () -> Unit = { lambdaError() }, +) : MediaSender { + override suspend fun preProcessMedia( + uri: Uri, + mimeType: String, + mediaOptimizationConfig: MediaOptimizationConfig, + ): Result { + return preProcessMediaResult() + } + + override suspend fun sendPreProcessedMedia( + mediaUploadInfo: MediaUploadInfo, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + ): Result { + return sendPreProcessedMediaResult() + } + + override suspend fun sendMedia( + uri: Uri, + mimeType: String, + caption: String?, + formattedCaption: String?, + inReplyToEventId: EventId?, + mediaOptimizationConfig: MediaOptimizationConfig, + ): Result { + return sendMediaResult() + } + + override suspend fun sendVoiceMessage( + uri: Uri, + mimeType: String, + waveForm: List, + inReplyToEventId: EventId?, + ): Result { + return sendVoiceMessageResult() + } + + override fun cleanUp() { + cleanUpResult() + } +} diff --git a/libraries/mediaviewer/api/build.gradle.kts b/libraries/mediaviewer/api/build.gradle.kts new file mode 100644 index 0000000..59760d2 --- /dev/null +++ b/libraries/mediaviewer/api/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.libraries.mediaviewer.api" +} + +dependencies { + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaGalleryEntryPoint.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaGalleryEntryPoint.kt new file mode 100644 index 0000000..906604f --- /dev/null +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaGalleryEntryPoint.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.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.EventId + +interface MediaGalleryEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: Callback, + ): Node + + interface Callback : Plugin { + fun onBackClick() + fun viewInTimeline(eventId: EventId) + fun forward(eventId: EventId, fromPinnedEvents: Boolean) + } +} diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt new file mode 100644 index 0000000..74b479d --- /dev/null +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.api + +import android.os.Parcelable +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.parcelize.Parcelize + +@Parcelize +data class MediaInfo( + val filename: String, + val caption: String?, + val mimeType: String, + val fileSize: Long?, + val formattedFileSize: String, + val fileExtension: String, + val senderId: UserId?, + val senderName: String?, + val senderAvatar: String?, + val dateSent: String?, + val dateSentFull: String?, + val waveform: List?, + val duration: String?, +) : Parcelable + +fun anImageMediaInfo( + senderId: UserId? = UserId("@alice:server.org"), + caption: String? = null, + senderName: String? = null, + dateSent: String? = null, + dateSentFull: String? = null, +): MediaInfo = MediaInfo( + filename = "an image file.jpg", + fileSize = 4 * 1024 * 1024, + caption = caption, + mimeType = MimeTypes.Jpeg, + formattedFileSize = "4MB", + fileExtension = "jpg", + senderId = senderId, + senderName = senderName, + senderAvatar = null, + dateSent = dateSent, + dateSentFull = dateSentFull, + waveform = null, + duration = null, +) + +fun aVideoMediaInfo( + caption: String? = null, + senderName: String? = null, + dateSent: String? = null, + dateSentFull: String? = null, + duration: String? = null, +): MediaInfo = MediaInfo( + filename = "a video file.mp4", + fileSize = 14 * 1024 * 1024, + caption = caption, + mimeType = MimeTypes.Mp4, + formattedFileSize = "14MB", + fileExtension = "mp4", + senderId = UserId("@alice:server.org"), + senderName = senderName, + senderAvatar = null, + dateSent = dateSent, + dateSentFull = dateSentFull, + waveform = null, + duration = duration, +) + +fun aPdfMediaInfo( + filename: String = "a pdf file.pdf", + caption: String? = null, + senderName: String? = null, + dateSent: String? = null, + dateSentFull: String? = null, +): MediaInfo = MediaInfo( + filename = filename, + fileSize = 23 * 1024 * 1024, + caption = caption, + mimeType = MimeTypes.Pdf, + formattedFileSize = "23MB", + fileExtension = "pdf", + senderId = UserId("@alice:server.org"), + senderName = senderName, + senderAvatar = null, + dateSent = dateSent, + dateSentFull = dateSentFull, + waveform = null, + duration = null, +) + +fun anApkMediaInfo( + senderId: UserId? = UserId("@alice:server.org"), + senderName: String? = null, + dateSent: String? = null, + dateSentFull: String? = null, +): MediaInfo = MediaInfo( + filename = "an apk file.apk", + fileSize = 50 * 1024 * 1024, + caption = null, + mimeType = MimeTypes.Apk, + formattedFileSize = "50MB", + fileExtension = "apk", + senderId = senderId, + senderName = senderName, + senderAvatar = null, + dateSent = dateSent, + dateSentFull = dateSentFull, + waveform = null, + duration = null, +) + +fun anAudioMediaInfo( + filename: String = "an audio file.mp3", + caption: String? = null, + senderName: String? = null, + dateSent: String? = null, + dateSentFull: String? = null, + waveForm: List? = null, + duration: String? = null, +): MediaInfo = MediaInfo( + filename = filename, + fileSize = 7 * 1024 * 1024, + caption = caption, + mimeType = MimeTypes.Mp3, + formattedFileSize = "7MB", + fileExtension = "mp3", + senderId = UserId("@alice:server.org"), + senderName = senderName, + senderAvatar = null, + dateSent = dateSent, + dateSentFull = dateSentFull, + waveform = waveForm, + duration = duration, +) + +fun aVoiceMediaInfo( + filename: String = "a voice file.ogg", + caption: String? = null, + senderName: String? = null, + dateSent: String? = null, + dateSentFull: String? = null, + waveForm: List? = null, + duration: String? = null, +): MediaInfo = MediaInfo( + filename = filename, + fileSize = 3 * 1024 * 1024, + caption = caption, + mimeType = MimeTypes.Ogg, + formattedFileSize = "3MB", + fileExtension = "ogg", + senderId = UserId("@alice:server.org"), + senderName = senderName, + senderAvatar = null, + dateSent = dateSent, + dateSentFull = dateSentFull, + waveform = waveForm, + duration = duration, +) + +fun aTxtMediaInfo( + filename: String = "a text file.txt", + caption: String? = null, + senderName: String? = null, + dateSent: String? = null, + dateSentFull: String? = null, +): MediaInfo = MediaInfo( + filename = filename, + fileSize = 2 * 1024, + caption = caption, + mimeType = MimeTypes.PlainText, + formattedFileSize = "2kB", + fileExtension = "txt", + senderId = UserId("@alice:server.org"), + senderName = senderName, + senderAvatar = null, + dateSent = dateSent, + dateSentFull = dateSentFull, + waveform = null, + duration = null, +) diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt new file mode 100644 index 0000000..536930b --- /dev/null +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.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.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.timeline.Timeline +import kotlinx.parcelize.Parcelize + +interface MediaViewerEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + callback: Callback, + ): Node + + fun createParamsForAvatar(filename: String, avatarUrl: String): Params + + interface Callback : Plugin { + fun onDone() + fun viewInTimeline(eventId: EventId) + fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) + } + + data class Params( + val mode: MediaViewerMode, + val eventId: EventId?, + val mediaInfo: MediaInfo, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + val canShowInfo: Boolean, + ) : NodeInputs + + sealed interface MediaViewerMode : Parcelable { + @Parcelize + data object SingleMedia : MediaViewerMode + + @Parcelize + data class TimelineImagesAndVideos(val timelineMode: Timeline.Mode) : MediaViewerMode + + @Parcelize + data class TimelineFilesAndAudios(val timelineMode: Timeline.Mode) : MediaViewerMode + } +} diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/helper/FileExtensionAndSizeFormatter.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/helper/FileExtensionAndSizeFormatter.kt new file mode 100644 index 0000000..fc40bca --- /dev/null +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/helper/FileExtensionAndSizeFormatter.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.api.helper + +fun formatFileExtensionAndSize(extension: String, size: String?): String { + return buildString { + append(extension.uppercase()) + if (size != null) { + append(' ') + append("($size)") + } + } +} diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMedia.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMedia.kt new file mode 100644 index 0000000..0041930 --- /dev/null +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMedia.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.api.local + +import android.net.Uri +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import io.element.android.libraries.mediaviewer.api.MediaInfo +import kotlinx.parcelize.Parcelize + +@Parcelize +@Immutable +data class LocalMedia( + val uri: Uri, + val info: MediaInfo, +) : Parcelable diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaFactory.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaFactory.kt new file mode 100644 index 0000000..383f985 --- /dev/null +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaFactory.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.api.local + +import android.net.Uri +import io.element.android.libraries.matrix.api.media.MediaFile +import io.element.android.libraries.mediaviewer.api.MediaInfo + +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/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaRenderer.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaRenderer.kt new file mode 100644 index 0000000..fb7f261 --- /dev/null +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaRenderer.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.api.local + +import androidx.compose.runtime.Composable + +interface LocalMediaRenderer { + @Composable + fun Render(localMedia: LocalMedia) +} diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/util/FileExtensionExtractor.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/util/FileExtensionExtractor.kt new file mode 100644 index 0000000..094b451 --- /dev/null +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/util/FileExtensionExtractor.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.api.util + +interface FileExtensionExtractor { + fun extractFromName(name: String): String +} diff --git a/libraries/mediaviewer/impl/build.gradle.kts b/libraries/mediaviewer/impl/build.gradle.kts new file mode 100644 index 0000000..9c2342e --- /dev/null +++ b/libraries/mediaviewer/impl/build.gradle.kts @@ -0,0 +1,68 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.libraries.mediaviewer.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(libs.coroutines.core) + implementation(libs.coil.compose) + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.ui) + implementation(libs.telephoto.zoomableimage) + implementation(libs.vanniktech.blurhash) + implementation(libs.telephoto.flick) + + implementation(projects.features.enterprise.api) + implementation(projects.features.viewfolder.api) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.audio.api) + implementation(projects.libraries.core) + implementation(projects.libraries.dateformatter.api) + implementation(projects.libraries.di) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.featureflag.api) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixmedia.api) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.voiceplayer.api) + implementation(projects.services.toolbox.api) + + api(projects.libraries.mediaviewer.api) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + implementation(projects.libraries.matrix.api) + + testCommonDependencies(libs, true) + testImplementation(projects.features.enterprise.test) + testImplementation(projects.libraries.audio.test) + testImplementation(projects.libraries.dateformatter.test) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.matrixui) + testImplementation(projects.libraries.mediaviewer.test) + testImplementation(projects.services.toolbox.test) + testImplementation(libs.coroutines.core) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaGalleryEntryPoint.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaGalleryEntryPoint.kt new file mode 100644 index 0000000..433a53a --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaGalleryEntryPoint.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint +import io.element.android.libraries.mediaviewer.impl.gallery.root.MediaGalleryFlowNode + +@ContributesBinding(AppScope::class) +class DefaultMediaGalleryEntryPoint : MediaGalleryEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: MediaGalleryEntryPoint.Callback, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf(callback), + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt new file mode 100644 index 0000000..e1e112e --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint +import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerNode + +@ContributesBinding(AppScope::class) +class DefaultMediaViewerEntryPoint : MediaViewerEntryPoint { + override fun createParamsForAvatar(filename: String, avatarUrl: String): MediaViewerEntryPoint.Params { + // We need to fake the MimeType here for the viewer to work. + val mimeType = MimeTypes.Images + return MediaViewerEntryPoint.Params( + mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia, + eventId = null, + mediaInfo = MediaInfo( + filename = filename, + fileSize = null, + caption = null, + mimeType = mimeType, + formattedFileSize = "", + fileExtension = "", + senderId = UserId("@dummy:server.org"), + senderName = null, + senderAvatar = null, + dateSent = null, + dateSentFull = null, + waveform = null, + duration = null, + ), + mediaSource = MediaSource(url = avatarUrl), + thumbnailSource = null, + canShowInfo = false, + ) + } + + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: MediaViewerEntryPoint.Params, + callback: MediaViewerEntryPoint.Callback, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf(params, callback), + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt new file mode 100644 index 0000000..edbf9dc --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.datasource + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode +import io.element.android.libraries.dateformatter.api.toHumanReadableDuration +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent +import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType +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.LegacyCallInviteContent +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.OtherMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +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.StickerMessageType +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.VideoMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl +import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import timber.log.Timber + +@Inject +class EventItemFactory( + private val fileSizeFormatter: FileSizeFormatter, + private val fileExtensionExtractor: FileExtensionExtractor, + private val dateFormatter: DateFormatter, +) { + fun create( + currentTimelineItem: MatrixTimelineItem.Event, + ): MediaItem.Event? { + val event = currentTimelineItem.event + val dateSent = dateFormatter.format( + currentTimelineItem.event.timestamp, + mode = DateFormatterMode.Day, + ) + val dateSentFull = dateFormatter.format( + timestamp = currentTimelineItem.event.timestamp, + mode = DateFormatterMode.Full, + ) + return when (val content = event.content) { + CallNotifyContent, + is FailedToParseMessageLikeContent, + is FailedToParseStateContent, + LegacyCallInviteContent, + is PollContent, + is ProfileChangeContent, + RedactedContent, + is RoomMembershipContent, + is StateContent, + is StickerContent, + is UnableToDecryptContent, + UnknownContent -> { + Timber.w("Should not happen: ${content.javaClass.simpleName}") + null + } + is MessageContent -> { + when (val type = content.type) { + is EmoteMessageType, + is NoticeMessageType, + is OtherMessageType, + is LocationMessageType, + is TextMessageType -> { + Timber.w("Should not happen: ${content.type}") + null + } + is AudioMessageType -> MediaItem.Audio( + id = currentTimelineItem.uniqueId, + eventId = currentTimelineItem.eventId, + mediaInfo = MediaInfo( + filename = type.filename, + fileSize = type.info?.size, + caption = type.caption, + mimeType = type.info?.mimetype.orEmpty(), + formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), + fileExtension = fileExtensionExtractor.extractFromName(type.filename), + senderId = event.sender, + senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender), + senderAvatar = event.senderProfile.getAvatarUrl(), + dateSent = dateSent, + dateSentFull = dateSentFull, + waveform = null, + duration = null, + ), + mediaSource = type.source, + ) + is FileMessageType -> MediaItem.File( + id = currentTimelineItem.uniqueId, + eventId = currentTimelineItem.eventId, + mediaInfo = MediaInfo( + filename = type.filename, + fileSize = type.info?.size, + caption = type.caption, + mimeType = type.info?.mimetype.orEmpty(), + formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), + fileExtension = fileExtensionExtractor.extractFromName(type.filename), + senderId = event.sender, + senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender), + senderAvatar = event.senderProfile.getAvatarUrl(), + dateSent = dateSent, + dateSentFull = dateSentFull, + waveform = null, + duration = null, + ), + mediaSource = type.source, + // TODO We may want to add a thumbnailSource and set it to type.info?.thumbnailSource + ) + is ImageMessageType -> MediaItem.Image( + id = currentTimelineItem.uniqueId, + eventId = currentTimelineItem.eventId, + mediaInfo = MediaInfo( + filename = type.filename, + fileSize = type.info?.size, + caption = type.caption, + mimeType = type.info?.mimetype.orEmpty(), + formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), + fileExtension = fileExtensionExtractor.extractFromName(type.filename), + senderId = event.sender, + senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender), + senderAvatar = event.senderProfile.getAvatarUrl(), + dateSent = dateSent, + dateSentFull = dateSentFull, + waveform = null, + duration = null, + ), + mediaSource = type.source, + thumbnailSource = type.info?.thumbnailSource, + ) + is StickerMessageType -> MediaItem.Image( + id = currentTimelineItem.uniqueId, + eventId = currentTimelineItem.eventId, + mediaInfo = MediaInfo( + filename = type.filename, + fileSize = type.info?.size, + caption = type.caption, + mimeType = type.info?.mimetype.orEmpty(), + formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), + fileExtension = fileExtensionExtractor.extractFromName(type.filename), + senderId = event.sender, + senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender), + senderAvatar = event.senderProfile.getAvatarUrl(), + dateSent = dateSent, + dateSentFull = dateSentFull, + waveform = null, + duration = null, + ), + mediaSource = type.source, + thumbnailSource = type.info?.thumbnailSource, + ) + is VideoMessageType -> MediaItem.Video( + id = currentTimelineItem.uniqueId, + eventId = currentTimelineItem.eventId, + mediaInfo = MediaInfo( + filename = type.filename, + fileSize = type.info?.size, + caption = type.caption, + mimeType = type.info?.mimetype.orEmpty(), + formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), + fileExtension = fileExtensionExtractor.extractFromName(type.filename), + senderId = event.sender, + senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender), + senderAvatar = event.senderProfile.getAvatarUrl(), + dateSent = dateSent, + dateSentFull = dateSentFull, + waveform = null, + duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(), + ), + mediaSource = type.source, + thumbnailSource = type.info?.thumbnailSource, + ) + is VoiceMessageType -> MediaItem.Voice( + id = currentTimelineItem.uniqueId, + eventId = currentTimelineItem.eventId, + mediaInfo = MediaInfo( + filename = type.filename, + fileSize = type.info?.size, + caption = type.caption, + mimeType = type.info?.mimetype.orEmpty(), + formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(), + fileExtension = fileExtensionExtractor.extractFromName(type.filename), + senderId = event.sender, + senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender), + senderAvatar = event.senderProfile.getAvatarUrl(), + dateSent = dateSent, + dateSentFull = dateSentFull, + waveform = type.details?.waveform.orEmpty(), + duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(), + ), + mediaSource = type.source, + ) + } + } + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/FocusedTimelineMediaGalleryDataSourceFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/FocusedTimelineMediaGalleryDataSourceFactory.kt new file mode 100644 index 0000000..5e127b6 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/FocusedTimelineMediaGalleryDataSourceFactory.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.datasource + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.mediaviewer.impl.model.MediaItem + +fun interface FocusedTimelineMediaGalleryDataSourceFactory { + fun createFor( + eventId: EventId, + mediaItem: MediaItem.Event, + onlyPinnedEvents: Boolean, + ): MediaGalleryDataSource +} + +@ContributesBinding(RoomScope::class) +class DefaultFocusedTimelineMediaGalleryDataSourceFactory( + private val room: JoinedRoom, + private val timelineMediaItemsFactory: TimelineMediaItemsFactory, + private val mediaItemsPostProcessor: MediaItemsPostProcessor, +) : FocusedTimelineMediaGalleryDataSourceFactory { + override fun createFor( + eventId: EventId, + mediaItem: MediaItem.Event, + onlyPinnedEvents: Boolean, + ): MediaGalleryDataSource { + return TimelineMediaGalleryDataSource( + room = room, + mediaTimeline = FocusedMediaTimeline( + room = room, + eventId = eventId, + initialMediaItem = mediaItem, + onlyPinnedEvents = onlyPinnedEvents, + ), + timelineMediaItemsFactory = timelineMediaItemsFactory, + mediaItemsPostProcessor = mediaItemsPostProcessor, + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaGalleryDataSource.kt new file mode 100644 index 0000000..722e14a --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaGalleryDataSource.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.datasource + +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import java.util.concurrent.atomic.AtomicBoolean + +interface MediaGalleryDataSource { + fun start() + fun groupedMediaItemsFlow(): Flow> + fun getLastData(): AsyncData + suspend fun loadMore(direction: Timeline.PaginationDirection) + suspend fun deleteItem(eventId: EventId) +} + +@SingleIn(RoomScope::class) +@ContributesBinding(RoomScope::class) +class TimelineMediaGalleryDataSource( + private val room: BaseRoom, + private val mediaTimeline: MediaTimeline, + private val timelineMediaItemsFactory: TimelineMediaItemsFactory, + private val mediaItemsPostProcessor: MediaItemsPostProcessor, +) : MediaGalleryDataSource { + private var timeline: Timeline? = null + + private val groupedMediaItemsFlow = MutableSharedFlow>(replay = 1) + + override fun groupedMediaItemsFlow(): Flow> = groupedMediaItemsFlow + + override fun getLastData(): AsyncData = groupedMediaItemsFlow.replayCache.firstOrNull() + ?: mediaTimeline.cache?.let { AsyncData.Success(it) } + ?: AsyncData.Uninitialized + + private val isStarted = AtomicBoolean(false) + + @OptIn(ExperimentalCoroutinesApi::class) + override fun start() { + if (!isStarted.compareAndSet(false, true)) { + return + } + flow { + val cache = mediaTimeline.cache + if (cache != null) { + groupedMediaItemsFlow.emit(AsyncData.Success(cache)) + } else { + groupedMediaItemsFlow.emit(AsyncData.Loading()) + } + mediaTimeline.getTimeline().fold( + { + timeline = it + emit(it) + }, + { + groupedMediaItemsFlow.emit(AsyncData.Failure(it)) + }, + ) + }.flatMapLatest { timeline -> + timeline.timelineItems.onEach { + timelineMediaItemsFactory.replaceWith( + timelineItems = it, + ) + } + }.flatMapLatest { + timelineMediaItemsFactory.timelineItems + } + .distinctUntilChanged() + .map { timelineItems -> + val groupedItems = mediaItemsPostProcessor.process(mediaItems = timelineItems) + mediaTimeline.orCache(groupedItems) + } + .onEach { groupedMediaItems -> + groupedMediaItemsFlow.emit(AsyncData.Success(groupedMediaItems)) + } + .onCompletion { + timeline?.close() + } + .launchIn(room.roomCoroutineScope) + } + + override suspend fun loadMore(direction: Timeline.PaginationDirection) { + timeline?.paginate(direction) + } + + override suspend fun deleteItem(eventId: EventId) { + timeline?.redactEvent( + eventOrTransactionId = eventId.toEventOrTransactionId(), + reason = null, + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaItemsPostProcessor.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaItemsPostProcessor.kt new file mode 100644 index 0000000..d95c3b4 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaItemsPostProcessor.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.datasource + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import kotlinx.collections.immutable.toImmutableList + +@Inject +class MediaItemsPostProcessor { + fun process( + mediaItems: List, + ): GroupedMediaItems { + val imageAndVideoItems = mutableListOf() + val fileItems = mutableListOf() + + val imageAndVideoItemsSubList = mutableListOf() + val fileItemsSublist = mutableListOf() + mediaItems.forEach { item -> + when (item) { + is MediaItem.DateSeparator -> { + if (imageAndVideoItemsSubList.isNotEmpty()) { + // Date separator first + imageAndVideoItems.add(item) + // Then events + imageAndVideoItems.addAll(imageAndVideoItemsSubList) + imageAndVideoItemsSubList.clear() + } + if (fileItemsSublist.isNotEmpty()) { + // Date separator first + fileItems.add(item) + // Then events + fileItems.addAll(fileItemsSublist) + fileItemsSublist.clear() + } + } + is MediaItem.Event -> { + when (item) { + is MediaItem.Image, + is MediaItem.Video -> { + imageAndVideoItemsSubList.add(item) + } + is MediaItem.Audio, + is MediaItem.Voice, + is MediaItem.File -> { + fileItemsSublist.add(item) + } + } + } + is MediaItem.LoadingIndicator -> { + imageAndVideoItems.add(item) + fileItems.add(item) + } + } + } + if (imageAndVideoItemsSubList.isNotEmpty()) { + // Should not happen, since the SDK is always adding a date separator + imageAndVideoItems.addAll(imageAndVideoItemsSubList) + } + if (fileItemsSublist.isNotEmpty()) { + // Should not happen, since the SDK is always adding a date separator + fileItems.addAll(fileItemsSublist) + } + return GroupedMediaItems( + imageAndVideoItems = imageAndVideoItems.toImmutableList(), + fileItems = fileItems.toImmutableList(), + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaTimeline.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaTimeline.kt new file mode 100644 index 0000000..d20c620 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaTimeline.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.datasource + +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.room.CreateTimelineParams +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.hasEvent +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +interface MediaTimeline { + suspend fun getTimeline(): Result + val cache: GroupedMediaItems? + fun orCache(data: GroupedMediaItems): GroupedMediaItems +} + +/** + * A timeline holder that can be used by the gallery and the media viewer. + * When opening the Media Viewer, if the held timeline knows the Event, it will + * be used, else a FocusedMediaTimeline will be used. + */ +@SingleIn(RoomScope::class) +@ContributesBinding(RoomScope::class) +class LiveMediaTimeline( + private val room: JoinedRoom, +) : MediaTimeline { + private var timeline: Timeline? = null + private val mutex = Mutex() + + override suspend fun getTimeline(): Result = mutex.withLock { + val currentTimeline = timeline + if (currentTimeline == null) { + room.createTimeline(CreateTimelineParams.MediaOnly) + .onSuccess { timeline = it } + } else { + Result.success(currentTimeline) + } + } + + // No cache for LiveMediaTimeline + override val cache = null + override fun orCache(data: GroupedMediaItems) = data +} + +/** + * A class that will provide a media timeline that is focused on a particular event. + * Optionally, the timeline will only contain the pinned events. + */ +class FocusedMediaTimeline( + private val room: JoinedRoom, + private val eventId: EventId, + private val onlyPinnedEvents: Boolean, + initialMediaItem: MediaItem.Event, +) : MediaTimeline { + override suspend fun getTimeline(): Result { + return room.createTimeline( + createTimelineParams = if (onlyPinnedEvents) { + CreateTimelineParams.PinnedOnly + } else { + CreateTimelineParams.MediaOnlyFocused(eventId) + }, + ) + } + + override val cache = persistentListOf( + MediaItem.LoadingIndicator( + id = UniqueId("loading_forwards"), + direction = Timeline.PaginationDirection.FORWARDS, + timestamp = 0L, + ), + initialMediaItem, + MediaItem.LoadingIndicator( + id = UniqueId("loading_backwards"), + direction = Timeline.PaginationDirection.BACKWARDS, + timestamp = 0L, + ), + ).let { + GroupedMediaItems( + fileItems = it, + imageAndVideoItems = it, + ) + } + + override fun orCache(data: GroupedMediaItems): GroupedMediaItems { + return if (data.hasEvent(eventId)) { + data + } else { + cache + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaItemsFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaItemsFactory.kt new file mode 100644 index 0000000..d2f78a7 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaItemsFactory.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.datasource + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.androidutils.diff.DefaultDiffCacheInvalidator +import io.element.android.libraries.androidutils.diff.DiffCacheUpdater +import io.element.android.libraries.androidutils.diff.MutableListDiffCache +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +@Inject +class TimelineMediaItemsFactory( + private val dispatchers: CoroutineDispatchers, + private val virtualItemFactory: VirtualItemFactory, + private val eventItemFactory: EventItemFactory, +) { + private val _timelineItems = MutableSharedFlow>(replay = 1) + private val lock = Mutex() + private val diffCache = MutableListDiffCache() + private val diffCacheUpdater = DiffCacheUpdater( + diffCache = diffCache, + detectMoves = false, + cacheInvalidator = DefaultDiffCacheInvalidator() + ) { old, new -> + if (old is MatrixTimelineItem.Event && new is MatrixTimelineItem.Event) { + old.uniqueId == new.uniqueId + } else { + false + } + } + + val timelineItems: Flow> = _timelineItems.distinctUntilChanged() + + suspend fun replaceWith( + timelineItems: List, + ) = withContext(dispatchers.computation) { + lock.withLock { + diffCacheUpdater.updateWith(timelineItems) + buildAndEmitTimelineItemStates(timelineItems) + } + } + + private suspend fun buildAndEmitTimelineItemStates( + timelineItems: List, + ) { + val newTimelineItemStates = ArrayList() + for (index in diffCache.indices().reversed()) { + val cacheItem = diffCache.get(index) + if (cacheItem == null) { + buildAndCacheItem(timelineItems, index)?.also { timelineItemState -> + newTimelineItemStates.add(timelineItemState) + } + } else { + newTimelineItemStates.add(cacheItem) + } + } + _timelineItems.emit(newTimelineItemStates.toImmutableList()) + } + + private fun buildAndCacheItem( + timelineItems: List, + index: Int, + ): MediaItem? { + val timelineItem = + when (val currentTimelineItem = timelineItems[index]) { + is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem) + is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem) + MatrixTimelineItem.Other -> null + } + diffCache[index] = timelineItem + return timelineItem + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/VirtualItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/VirtualItemFactory.kt new file mode 100644 index 0000000..1ffc12b --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/VirtualItemFactory.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.datasource + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode +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.mediaviewer.impl.model.MediaItem + +@Inject +class VirtualItemFactory( + private val dateFormatter: DateFormatter, +) { + fun create(timelineItem: MatrixTimelineItem.Virtual): MediaItem? { + return when (val virtual = timelineItem.virtual) { + is VirtualTimelineItem.DayDivider -> MediaItem.DateSeparator( + id = timelineItem.uniqueId, + formattedDate = dateFormatter.format( + timestamp = virtual.timestamp, + mode = DateFormatterMode.Month, + useRelative = true, + ) + ) + VirtualTimelineItem.LastForwardIndicator -> null + is VirtualTimelineItem.LoadingIndicator -> MediaItem.LoadingIndicator( + id = timelineItem.uniqueId, + direction = virtual.direction, + timestamp = virtual.timestamp + ) + VirtualTimelineItem.ReadMarker -> null + VirtualTimelineItem.RoomBeginning -> null + VirtualTimelineItem.TypingNotification -> null + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaBottomSheetState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaBottomSheetState.kt new file mode 100644 index 0000000..7cd4dee --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaBottomSheetState.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.details + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.mediaviewer.api.MediaInfo + +sealed interface MediaBottomSheetState { + data object Hidden : MediaBottomSheetState + + data class MediaDeleteConfirmationState( + val eventId: EventId, + val mediaInfo: MediaInfo, + val thumbnailSource: MediaSource?, + ) : MediaBottomSheetState + + data class MediaDetailsBottomSheetState( + val eventId: EventId?, + val canDelete: Boolean, + val mediaInfo: MediaInfo, + val thumbnailSource: MediaSource?, + ) : MediaBottomSheetState +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt new file mode 100644 index 0000000..3850f3a --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.details + +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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.mediaviewer.impl.R +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MediaDeleteConfirmationBottomSheet( + state: MediaBottomSheetState.MediaDeleteConfirmationState, + onDelete: (EventId) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismiss, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + IconTitleSubtitleMolecule( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp, horizontal = 8.dp), + title = stringResource(R.string.screen_media_browser_delete_confirmation_title), + iconStyle = BigIcon.Style.Default(CompoundIcons.Delete(), useCriticalTint = true), + subTitle = stringResource(R.string.screen_media_browser_delete_confirmation_subtitle), + ) + Spacer(modifier = Modifier.height(16.dp)) + MediaRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + state = state, + ) + Button( + modifier = Modifier + .fillMaxWidth() + .padding(top = 40.dp), + text = stringResource(CommonStrings.action_remove), + onClick = { + onDelete(state.eventId) + }, + destructive = true, + ) + TextButton( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + text = stringResource(CommonStrings.action_cancel), + onClick = { + onDismiss() + }, + ) + } + } +} + +@Composable +private fun MediaRow( + state: MediaBottomSheetState.MediaDeleteConfirmationState, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp), + ) { + if (state.thumbnailSource == null) { + BigIcon( + style = BigIcon.Style.Default(CompoundIcons.Attachment()), + ) + } else { + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .background(Color.White), + model = MediaRequestData(state.thumbnailSource, MediaRequestData.Kind.Thumbnail(100)), + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + contentDescription = null, + ) + } + } + Column( + modifier = Modifier + .padding(start = 12.dp) + .weight(1f), + ) { + // Name + Text( + modifier = Modifier.clipToBounds(), + text = state.mediaInfo.filename, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyLgRegular, + ) + // Info + Text( + text = state.mediaInfo.mimeType + " - " + state.mediaInfo.formattedFileSize, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodySmRegular, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun MediaDeleteConfirmationBottomSheetPreview() = ElementPreview { + MediaDeleteConfirmationBottomSheet( + state = aMediaDeleteConfirmationState(), + onDelete = {}, + onDismiss = {}, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt new file mode 100644 index 0000000..a6c3079 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.details + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.colors.AvatarColorsProvider +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.avatar.AvatarType +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListItemStyle +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.impl.R +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MediaDetailsBottomSheet( + state: MediaBottomSheetState.MediaDetailsBottomSheetState, + onViewInTimeline: (EventId) -> Unit, + onShare: (EventId) -> Unit, + onForward: (EventId) -> Unit, + onDownload: (EventId) -> Unit, + onDelete: (EventId) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismiss, + ) { + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + Section( + title = stringResource(R.string.screen_media_details_uploaded_by), + ) { + SenderRow( + mediaInfo = state.mediaInfo, + ) + } + SectionText( + title = stringResource(R.string.screen_media_details_uploaded_on), + text = state.mediaInfo.dateSentFull.orEmpty(), + ) + SectionText( + title = stringResource(R.string.screen_media_details_filename), + text = state.mediaInfo.filename, + ) + SectionText( + title = stringResource(R.string.screen_media_details_file_format), + text = state.mediaInfo.mimeType + " - " + state.mediaInfo.formattedFileSize, + ) + if (state.eventId != null) { + Column { + HorizontalDivider() + ListItem( + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.VisibilityOn())), + headlineContent = { Text(stringResource(CommonStrings.action_view_in_timeline)) }, + style = ListItemStyle.Primary, + onClick = { + onViewInTimeline(state.eventId) + } + ) + ListItem( + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ShareAndroid())), + headlineContent = { Text(stringResource(CommonStrings.action_share)) }, + style = ListItemStyle.Primary, + onClick = { + onShare(state.eventId) + } + ) + ListItem( + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Forward())), + headlineContent = { Text(stringResource(CommonStrings.action_forward)) }, + style = ListItemStyle.Primary, + onClick = { + onForward(state.eventId) + } + ) + ListItem( + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Download())), + headlineContent = { Text(stringResource(CommonStrings.action_save)) }, + style = ListItemStyle.Primary, + onClick = { + onDownload(state.eventId) + } + ) + if (state.canDelete) { + HorizontalDivider() + ListItem( + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Delete())), + headlineContent = { Text(stringResource(CommonStrings.action_remove)) }, + style = ListItemStyle.Destructive, + onClick = { + onDelete(state.eventId) + } + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + } + } + } +} + +@Composable +private fun SenderRow( + mediaInfo: MediaInfo, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + val id = mediaInfo.senderId?.value ?: "@Alice:domain" + Avatar( + avatarData = AvatarData( + id = id, + name = mediaInfo.senderName, + url = mediaInfo.senderAvatar, + size = AvatarSize.MediaSender, + ), + avatarType = AvatarType.User, + ) + Column( + modifier = Modifier + .padding(start = 8.dp) + .weight(1f), + ) { + // Name + val avatarColors = AvatarColorsProvider.provide(id) + Text( + modifier = Modifier.clipToBounds(), + text = mediaInfo.senderName.orEmpty(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = avatarColors.foreground, + style = ElementTheme.typography.fontBodyMdMedium, + ) + // Id + Text( + text = mediaInfo.senderId?.value.orEmpty(), + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } + } +} + +@Composable +private fun Section( + title: String, + content: @Composable () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = title.uppercase(), + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + content() + } +} + +@Composable +private fun SectionText( + title: String, + text: String, +) { + Section(title = title) { + Text( + text = text, + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textPrimary, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun MediaDetailsBottomSheetPreview() = ElementPreview { + MediaDetailsBottomSheet( + state = aMediaDetailsBottomSheetState(), + onViewInTimeline = {}, + onShare = {}, + onForward = {}, + onDownload = {}, + onDelete = {}, + onDismiss = {}, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/Preview.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/Preview.kt new file mode 100644 index 0000000..a152a32 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/Preview.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.details + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.mediaviewer.api.anImageMediaInfo + +fun aMediaDetailsBottomSheetState( + dateSentFull: String = "December 6, 2024 at 12:59", + canDelete: Boolean = true, +): MediaBottomSheetState.MediaDetailsBottomSheetState { + return MediaBottomSheetState.MediaDetailsBottomSheetState( + eventId = EventId("\$eventId"), + canDelete = canDelete, + mediaInfo = anImageMediaInfo( + senderName = "Alice", + dateSentFull = dateSentFull, + ), + thumbnailSource = null, + ) +} + +fun aMediaDeleteConfirmationState(): MediaBottomSheetState.MediaDeleteConfirmationState { + return MediaBottomSheetState.MediaDeleteConfirmationState( + eventId = EventId("\$eventId"), + mediaInfo = anImageMediaInfo( + senderName = "Alice", + ), + thumbnailSource = null, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryEvents.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryEvents.kt new file mode 100644 index 0000000..2bf4f6b --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryEvents.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.impl.model.MediaItem + +sealed interface MediaGalleryEvents { + data class ChangeMode(val mode: MediaGalleryMode) : MediaGalleryEvents + data class LoadMore(val direction: Timeline.PaginationDirection) : MediaGalleryEvents + data class Share(val eventId: EventId) : MediaGalleryEvents + data class Forward(val eventId: EventId) : MediaGalleryEvents + data class SaveOnDisk(val eventId: EventId) : MediaGalleryEvents + data class OpenInfo(val mediaItem: MediaItem.Event) : MediaGalleryEvents + data class ViewInTimeline(val eventId: EventId) : MediaGalleryEvents + + data class ConfirmDelete( + val eventId: EventId, + val mediaInfo: MediaInfo, + val thumbnailSource: MediaSource?, + ) : MediaGalleryEvents + + data object CloseBottomSheet : MediaGalleryEvents + data class Delete(val eventId: EventId) : MediaGalleryEvents +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNavigator.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNavigator.kt new file mode 100644 index 0000000..3e60b4e --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNavigator.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import io.element.android.libraries.matrix.api.core.EventId + +interface MediaGalleryNavigator { + fun onViewInTimelineClick(eventId: EventId) + fun onForwardClick(eventId: EventId) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt new file mode 100644 index 0000000..ca1fd62 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.mediaviewer.impl.gallery.di.LocalMediaItemPresenterFactories +import io.element.android.libraries.mediaviewer.impl.gallery.di.MediaItemPresenterFactories +import io.element.android.libraries.mediaviewer.impl.model.MediaItem + +@ContributesNode(RoomScope::class) +@AssistedInject +class MediaGalleryNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: MediaGalleryPresenter.Factory, + private val mediaItemPresenterFactories: MediaItemPresenterFactories, +) : Node(buildContext, plugins = plugins), + MediaGalleryNavigator { + private val presenter = presenterFactory.create( + navigator = this, + ) + + interface Callback : Plugin { + fun onBackClick() + fun showItem(item: MediaItem.Event) + fun viewInTimeline(eventId: EventId) + fun forward(eventId: EventId) + } + + private val callback: Callback = callback() + + override fun onViewInTimelineClick(eventId: EventId) { + callback.viewInTimeline(eventId) + } + + override fun onForwardClick(eventId: EventId) { + callback.forward(eventId) + } + + @Composable + override fun View(modifier: Modifier) { + CompositionLocalProvider( + LocalMediaItemPresenterFactories provides mediaItemPresenterFactories, + ) { + val state = presenter.present() + MediaGalleryView( + state = state, + onBackClick = callback::onBackClick, + onItemClick = callback::showItem, + modifier = modifier, + ) + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt new file mode 100644 index 0000000..aee9af8 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import android.content.ActivityNotFoundException +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.androidutils.R +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.extensions.mapCatchingExceptions +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther +import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory +import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState +import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions +import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.eventId +import io.element.android.libraries.mediaviewer.impl.model.mediaInfo +import io.element.android.libraries.mediaviewer.impl.model.mediaSource +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.launch + +@AssistedInject +class MediaGalleryPresenter( + @Assisted private val navigator: MediaGalleryNavigator, + private val room: BaseRoom, + private val mediaGalleryDataSource: MediaGalleryDataSource, + private val localMediaFactory: LocalMediaFactory, + private val mediaLoader: MatrixMediaLoader, + private val localMediaActions: LocalMediaActions, + private val snackbarDispatcher: SnackbarDispatcher, +) : Presenter { + @AssistedFactory + interface Factory { + fun create( + navigator: MediaGalleryNavigator, + ): MediaGalleryPresenter + } + + @Composable + override fun present(): MediaGalleryState { + val coroutineScope = rememberCoroutineScope() + var mode by remember { mutableStateOf(MediaGalleryMode.Images) } + + val roomInfo by room.roomInfoFlow.collectAsState() + + var mediaBottomSheetState by remember { mutableStateOf(MediaBottomSheetState.Hidden) } + + val groupedMediaItems by remember { + mediaGalleryDataSource.groupedMediaItemsFlow() + } + .collectAsState(AsyncData.Uninitialized) + + LaunchedEffect(Unit) { + mediaGalleryDataSource.start() + } + + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + localMediaActions.Configure() + + fun handleEvent(event: MediaGalleryEvents) { + when (event) { + is MediaGalleryEvents.ChangeMode -> { + mode = event.mode + } + is MediaGalleryEvents.LoadMore -> coroutineScope.launch { + mediaGalleryDataSource.loadMore(event.direction) + } + is MediaGalleryEvents.Delete -> coroutineScope.launch { + mediaGalleryDataSource.deleteItem(event.eventId) + } + is MediaGalleryEvents.SaveOnDisk -> coroutineScope.launch { + mediaBottomSheetState = MediaBottomSheetState.Hidden + groupedMediaItems.dataOrNull().find(event.eventId)?.let { + saveOnDisk(it) + } + } + is MediaGalleryEvents.Share -> coroutineScope.launch { + mediaBottomSheetState = MediaBottomSheetState.Hidden + groupedMediaItems.dataOrNull().find(event.eventId)?.let { + share(it) + } + } + is MediaGalleryEvents.Forward -> { + mediaBottomSheetState = MediaBottomSheetState.Hidden + navigator.onForwardClick(event.eventId) + } + is MediaGalleryEvents.ViewInTimeline -> { + mediaBottomSheetState = MediaBottomSheetState.Hidden + navigator.onViewInTimelineClick(event.eventId) + } + is MediaGalleryEvents.OpenInfo -> coroutineScope.launch { + mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState( + eventId = event.mediaItem.eventId(), + canDelete = when (event.mediaItem.mediaInfo().senderId) { + null -> false + room.sessionId -> room.canRedactOwn().getOrElse { false } && event.mediaItem.eventId() != null + else -> room.canRedactOther().getOrElse { false } && event.mediaItem.eventId() != null + }, + mediaInfo = event.mediaItem.mediaInfo(), + thumbnailSource = when (event.mediaItem) { + is MediaItem.Image -> event.mediaItem.thumbnailSource ?: event.mediaItem.mediaSource + is MediaItem.Video -> event.mediaItem.thumbnailSource ?: event.mediaItem.mediaSource + is MediaItem.Audio -> null + is MediaItem.File -> null + is MediaItem.Voice -> null + }, + ) + } + is MediaGalleryEvents.ConfirmDelete -> { + mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState( + eventId = event.eventId, + mediaInfo = event.mediaInfo, + thumbnailSource = event.thumbnailSource, + ) + } + MediaGalleryEvents.CloseBottomSheet -> { + mediaBottomSheetState = MediaBottomSheetState.Hidden + } + } + } + + return MediaGalleryState( + roomName = roomInfo.name.orEmpty(), + mode = mode, + groupedMediaItems = groupedMediaItems, + mediaBottomSheetState = mediaBottomSheetState, + snackbarMessage = snackbarMessage, + eventSink = ::handleEvent, + ) + } + + private suspend fun downloadMedia(mediaItem: MediaItem.Event): Result { + return mediaLoader.downloadMediaFile( + source = mediaItem.mediaSource(), + mimeType = mediaItem.mediaInfo().mimeType, + filename = mediaItem.mediaInfo().filename + ) + .mapCatchingExceptions { mediaFile -> + localMediaFactory.createFromMediaFile( + mediaFile = mediaFile, + mediaInfo = mediaItem.mediaInfo() + ) + } + } + + private suspend fun saveOnDisk(mediaItem: MediaItem.Event) { + downloadMedia(mediaItem) + .mapCatchingExceptions { localMedia -> + localMediaActions.saveOnDisk(localMedia) + } + .onSuccess { + val snackbarMessage = SnackbarMessage(CommonStrings.common_file_saved_on_disk_android) + snackbarDispatcher.post(snackbarMessage) + } + .onFailure { + val snackbarMessage = SnackbarMessage(mediaActionsError(it)) + snackbarDispatcher.post(snackbarMessage) + } + } + + private suspend fun share(mediaItem: MediaItem.Event) { + downloadMedia(mediaItem) + .mapCatchingExceptions { localMedia -> + localMediaActions.share(localMedia) + } + .onFailure { + val snackbarMessage = SnackbarMessage(mediaActionsError(it)) + snackbarDispatcher.post(snackbarMessage) + } + } + + private fun mediaActionsError(throwable: Throwable): Int { + return if (throwable is ActivityNotFoundException) { + R.string.error_no_compatible_app_found + } else { + CommonStrings.error_unknown + } + } +} + +private fun GroupedMediaItems?.find(eventId: EventId?): MediaItem.Event? { + if (this == null || eventId == null) { + return null + } + return (imageAndVideoItems + fileItems).filterIsInstance() + .firstOrNull { it.eventId() == eventId } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt new file mode 100644 index 0000000..897e5d1 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.mediaviewer.impl.R +import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState +import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems + +data class MediaGalleryState( + val roomName: String, + val mode: MediaGalleryMode, + val groupedMediaItems: AsyncData, + val mediaBottomSheetState: MediaBottomSheetState, + val snackbarMessage: SnackbarMessage?, + val eventSink: (MediaGalleryEvents) -> Unit, +) + +enum class MediaGalleryMode(val stringResource: Int) { + Images(R.string.screen_media_browser_list_mode_media), + Files(R.string.screen_media_browser_list_mode_files), +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt new file mode 100644 index 0000000..a19f810 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.media.WaveFormSamples +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState +import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState +import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemAudio +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemDateSeparator +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemFile +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemLoadingIndicator +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVideo +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice +import kotlinx.collections.immutable.toImmutableList + +open class MediaGalleryStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aMediaGalleryState( + roomName = "A long room name that will be truncated", + ), + aMediaGalleryState(groupedMediaItems = AsyncData.Loading()), + aMediaGalleryState(groupedMediaItems = AsyncData.Success(aGroupedMediaItems())), + aMediaGalleryState( + groupedMediaItems = AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf( + aMediaItemDateSeparator(id = UniqueId("0")), + aMediaItemImage(id = UniqueId("1")), + aMediaItemDateSeparator( + id = UniqueId("2"), + formattedDate = "September 2004", + ), + aMediaItemImage(id = UniqueId("3")), + aMediaItemVideo(id = UniqueId("4")), + aMediaItemImage(id = UniqueId("5")), + aMediaItemImage(id = UniqueId("6")), + aMediaItemImage(id = UniqueId("7")), + aMediaItemImage(id = UniqueId("8")), + aMediaItemImage(id = UniqueId("9")), + aMediaItemLoadingIndicator(), + ).toImmutableList() + ) + ), + ), + aMediaGalleryState(mode = MediaGalleryMode.Files), + aMediaGalleryState(mode = MediaGalleryMode.Files, groupedMediaItems = AsyncData.Loading()), + aMediaGalleryState(mode = MediaGalleryMode.Files, groupedMediaItems = AsyncData.Success(aGroupedMediaItems())), + aMediaGalleryState( + mode = MediaGalleryMode.Files, + groupedMediaItems = AsyncData.Success( + aGroupedMediaItems( + fileItems = listOf( + aMediaItemDateSeparator(id = UniqueId("0")), + aMediaItemFile(id = UniqueId("1")), + aMediaItemDateSeparator( + id = UniqueId("2"), + formattedDate = "September 2004", + ), + aMediaItemAudio(id = UniqueId("4")), + aMediaItemVoice( + id = UniqueId("5"), + waveform = WaveFormSamples.realisticWaveForm, + ), + aMediaItemLoadingIndicator(), + ).toImmutableList() + ) + ), + ), + aMediaGalleryState(mediaBottomSheetState = aMediaDetailsBottomSheetState()), + aMediaGalleryState( + groupedMediaItems = AsyncData.Failure(Exception("Failed to load media")), + ), + aMediaGalleryState( + mode = MediaGalleryMode.Files, + groupedMediaItems = AsyncData.Failure(Exception("Failed to load media")), + ), + // Timeline is loaded but does not have relevant content yet for images and videos + aMediaGalleryState( + groupedMediaItems = AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf( + aMediaItemLoadingIndicator(), + ), + ) + ) + ), + // Timeline is loaded but does not have relevant content yet for files + aMediaGalleryState( + mode = MediaGalleryMode.Files, + groupedMediaItems = AsyncData.Success( + aGroupedMediaItems( + fileItems = listOf( + aMediaItemLoadingIndicator(), + ), + ) + ) + ), + ) +} + +private fun aMediaGalleryState( + roomName: String = "Room name", + mode: MediaGalleryMode = MediaGalleryMode.Images, + groupedMediaItems: AsyncData = AsyncData.Uninitialized, + mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden, +) = MediaGalleryState( + roomName = roomName, + mode = mode, + groupedMediaItems = groupedMediaItems, + mediaBottomSheetState = mediaBottomSheetState, + snackbarMessage = null, + eventSink = {} +) + +fun aGroupedMediaItems( + imageAndVideoItems: List = emptyList(), + fileItems: List = emptyList(), +) = GroupedMediaItems( + imageAndVideoItems = imageAndVideoItems.toImmutableList(), + fileItems = fileItems.toImmutableList(), +) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt new file mode 100644 index 0000000..6f7a201 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt @@ -0,0 +1,534 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +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.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.background.OnboardingBackground +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.async.AsyncFailure +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.LinearProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.SegmentedButton +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.snackbar.SnackbarHost +import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.mediaviewer.impl.R +import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState +import io.element.android.libraries.mediaviewer.impl.details.MediaDeleteConfirmationBottomSheet +import io.element.android.libraries.mediaviewer.impl.details.MediaDetailsBottomSheet +import io.element.android.libraries.mediaviewer.impl.gallery.di.LocalMediaItemPresenterFactories +import io.element.android.libraries.mediaviewer.impl.gallery.di.aFakeMediaItemPresenterFactories +import io.element.android.libraries.mediaviewer.impl.gallery.di.rememberPresenter +import io.element.android.libraries.mediaviewer.impl.gallery.ui.AudioItemView +import io.element.android.libraries.mediaviewer.impl.gallery.ui.DateItemView +import io.element.android.libraries.mediaviewer.impl.gallery.ui.FileItemView +import io.element.android.libraries.mediaviewer.impl.gallery.ui.ImageItemView +import io.element.android.libraries.mediaviewer.impl.gallery.ui.VideoItemView +import io.element.android.libraries.mediaviewer.impl.gallery.ui.VoiceItemView +import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.id +import io.element.android.libraries.voiceplayer.api.VoiceMessageState +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MediaGalleryView( + state: MediaGalleryState, + onBackClick: () -> Unit, + onItemClick: (MediaItem.Event) -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) + BackHandler { onBackClick() } + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { + Text( + modifier = Modifier.semantics { + heading() + }, + text = state.roomName, + style = ElementTheme.typography.aliasScreenTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + BackButton( + onClick = onBackClick, + ) + }, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + SingleChoiceSegmentedButtonRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + MediaGalleryMode.entries.forEach { mode -> + SegmentedButton( + index = mode.ordinal, + count = MediaGalleryMode.entries.size, + selected = state.mode == mode, + onClick = { state.eventSink(MediaGalleryEvents.ChangeMode(mode)) }, + text = stringResource(mode.stringResource), + ) + } + } + val pagerState = rememberPagerState(0, 0f) { + MediaGalleryMode.entries.size + } + LaunchedEffect(state.mode) { + pagerState.scrollToPage(state.mode.ordinal) + } + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + ) { page -> + val mode = MediaGalleryMode.entries[page] + MediaGalleryPage( + mode = mode, + state = state, + onItemClick = onItemClick, + ) + } + } + } + when (val bottomSheetState = state.mediaBottomSheetState) { + MediaBottomSheetState.Hidden -> Unit + is MediaBottomSheetState.MediaDetailsBottomSheetState -> { + MediaDetailsBottomSheet( + state = bottomSheetState, + onViewInTimeline = { eventId -> + state.eventSink(MediaGalleryEvents.ViewInTimeline(eventId)) + }, + onShare = { eventId -> + state.eventSink(MediaGalleryEvents.Share(eventId)) + }, + onForward = { eventId -> + state.eventSink(MediaGalleryEvents.Forward(eventId)) + }, + onDownload = { eventId -> + state.eventSink(MediaGalleryEvents.SaveOnDisk(eventId)) + }, + onDelete = { eventId -> + state.eventSink( + MediaGalleryEvents.ConfirmDelete( + eventId = eventId, + mediaInfo = bottomSheetState.mediaInfo, + thumbnailSource = bottomSheetState.thumbnailSource, + ) + ) + }, + onDismiss = { + state.eventSink(MediaGalleryEvents.CloseBottomSheet) + }, + ) + } + is MediaBottomSheetState.MediaDeleteConfirmationState -> { + MediaDeleteConfirmationBottomSheet( + state = bottomSheetState, + onDelete = { + state.eventSink(MediaGalleryEvents.Delete(it)) + }, + onDismiss = { + state.eventSink(MediaGalleryEvents.CloseBottomSheet) + }, + ) + } + } +} + +@Composable +private fun MediaGalleryPage( + mode: MediaGalleryMode, + state: MediaGalleryState, + onItemClick: (MediaItem.Event) -> Unit, +) { + val groupedMediaItems = state.groupedMediaItems + if (groupedMediaItems.isLoadingItems(mode)) { + // Need to trigger a pagination now if there is only one LoadingIndicator. + val loadingItem = groupedMediaItems.dataOrNull()?.getItems(mode)?.singleOrNull() as? MediaItem.LoadingIndicator + if (loadingItem != null) { + LaunchedEffect(loadingItem.timestamp) { + state.eventSink(MediaGalleryEvents.LoadMore(loadingItem.direction)) + } + } + LoadingContent(mode) + } else { + when (groupedMediaItems) { + is AsyncData.Success -> { + when (mode) { + MediaGalleryMode.Images -> MediaGalleryImages( + imagesAndVideos = groupedMediaItems.data.imageAndVideoItems, + eventSink = state.eventSink, + onItemClick = onItemClick, + ) + MediaGalleryMode.Files -> MediaGalleryFiles( + files = groupedMediaItems.data.fileItems, + eventSink = state.eventSink, + onItemClick = onItemClick, + ) + } + } + is AsyncData.Failure -> { + ErrorContent( + error = groupedMediaItems.error, + ) + } + else -> Unit + } + } +} + +/** + * Return true when the timeline is not loaded or if it contains only a single loading item. + */ +private fun AsyncData.isLoadingItems(mode: MediaGalleryMode): Boolean { + return when (this) { + AsyncData.Uninitialized, + is AsyncData.Loading -> true + is AsyncData.Success -> data.getItems(mode).singleOrNull() is MediaItem.LoadingIndicator + is AsyncData.Failure -> false + } +} + +@Composable +private fun MediaGalleryImages( + imagesAndVideos: ImmutableList, + eventSink: (MediaGalleryEvents) -> Unit, + onItemClick: (MediaItem.Event) -> Unit, +) { + if (imagesAndVideos.isEmpty()) { + EmptyContent( + titleRes = R.string.screen_media_browser_media_empty_state_title, + subtitleRes = R.string.screen_media_browser_media_empty_state_subtitle, + icon = CompoundIcons.Image(), + ) + } else { + MediaGalleryImageGrid( + imagesAndVideos = imagesAndVideos, + eventSink = eventSink, + onItemClick = onItemClick, + ) + } +} + +@Composable +private fun MediaGalleryFiles( + files: ImmutableList, + eventSink: (MediaGalleryEvents) -> Unit, + onItemClick: (MediaItem.Event) -> Unit, +) { + if (files.isEmpty()) { + EmptyContent( + titleRes = R.string.screen_media_browser_files_empty_state_title, + subtitleRes = R.string.screen_media_browser_files_empty_state_subtitle, + icon = CompoundIcons.Files(), + ) + } else { + MediaGalleryFilesList( + files = files, + eventSink = eventSink, + onItemClick = onItemClick, + ) + } +} + +@Composable +private fun MediaGalleryFilesList( + files: ImmutableList, + eventSink: (MediaGalleryEvents) -> Unit, + onItemClick: (MediaItem.Event) -> Unit, +) { + val presenterFactories = LocalMediaItemPresenterFactories.current + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items( + items = files, + key = { it.id() }, + contentType = { it::class.java }, + ) { item -> + when (item) { + is MediaItem.File -> FileItemView( + modifier = Modifier.animateItem(), + file = item, + onClick = { onItemClick(item) }, + onLongClick = { + eventSink(MediaGalleryEvents.OpenInfo(item)) + }, + ) + is MediaItem.Audio -> AudioItemView( + modifier = Modifier.animateItem(), + audio = item, + onClick = { onItemClick(item) }, + onLongClick = { + eventSink(MediaGalleryEvents.OpenInfo(item)) + }, + ) + is MediaItem.Voice -> { + val presenter: Presenter = presenterFactories.rememberPresenter(item) + VoiceItemView( + modifier = Modifier.animateItem(), + state = presenter.present(), + voice = item, + onLongClick = { + eventSink(MediaGalleryEvents.OpenInfo(item)) + }, + ) + } + is MediaItem.DateSeparator -> DateItemView( + modifier = Modifier.animateItem(), + item = item + ) + is MediaItem.Image, + is MediaItem.Video -> { + // Should not happen + } + is MediaItem.LoadingIndicator -> LoadingMoreIndicator( + modifier = Modifier.animateItem(), + item = item, + eventSink = eventSink, + ) + } + } + } +} + +@Composable +private fun MediaGalleryImageGrid( + imagesAndVideos: ImmutableList, + eventSink: (MediaGalleryEvents) -> Unit, + onItemClick: (MediaItem.Event) -> Unit, +) { + LazyVerticalGrid( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + columns = GridCells.Adaptive(80.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items( + items = imagesAndVideos, + span = { item -> + when (item) { + is MediaItem.LoadingIndicator, + is MediaItem.DateSeparator -> GridItemSpan(maxLineSpan) + is MediaItem.Event -> GridItemSpan(1) + } + }, + key = { it.id() }, + contentType = { it::class.java }, + ) { item -> + when (item) { + is MediaItem.DateSeparator -> DateItemView( + modifier = Modifier.animateItem(), + item = item, + ) + is MediaItem.Audio -> { + // Should not happen + } + is MediaItem.Voice -> { + // Should not happen + } + is MediaItem.File -> { + // Should not happen + } + is MediaItem.Image -> ImageItemView( + modifier = Modifier.animateItem(), + image = item, + onClick = { onItemClick(item) }, + onLongClick = { + eventSink(MediaGalleryEvents.OpenInfo(item)) + }, + ) + is MediaItem.Video -> VideoItemView( + modifier = Modifier.animateItem(), + video = item, + onClick = { onItemClick(item) }, + onLongClick = { + eventSink(MediaGalleryEvents.OpenInfo(item)) + }, + ) + is MediaItem.LoadingIndicator -> LoadingMoreIndicator( + modifier = Modifier.animateItem(), + item = item, + eventSink = eventSink, + ) + } + } + } +} + +@Composable +private fun LoadingMoreIndicator( + item: MediaItem.LoadingIndicator, + eventSink: (MediaGalleryEvents) -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + when (item.direction) { + Timeline.PaginationDirection.FORWARDS -> { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp) + .height(1.dp) + ) + } + Timeline.PaginationDirection.BACKWARDS -> { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + } + val latestEventSink by rememberUpdatedState(eventSink) + LaunchedEffect(item.timestamp) { + latestEventSink(MediaGalleryEvents.LoadMore(item.direction)) + } + } +} + +@Composable +private fun ErrorContent(error: Throwable) { + AsyncFailure( + throwable = error, + onRetry = null, + modifier = Modifier.fillMaxSize(), + ) +} + +@Composable +private fun EmptyContent( + titleRes: Int, + subtitleRes: Int, + icon: ImageVector, +) { + Box( + modifier = Modifier.fillMaxSize(), + ) { + OnboardingBackground() + IconTitleSubtitleMolecule( + modifier = Modifier + .fillMaxWidth() + .padding(top = 44.dp) + .padding(24.dp), + title = stringResource(titleRes), + iconStyle = BigIcon.Style.Default(icon), + subTitle = stringResource(subtitleRes), + ) + } +} + +@Composable +private fun LoadingContent( + mode: MediaGalleryMode, +) { + Box( + modifier = Modifier.fillMaxSize(), + ) { + OnboardingBackground() + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 48.dp) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator() + val res = when (mode) { + MediaGalleryMode.Images -> R.string.screen_media_browser_list_loading_media + MediaGalleryMode.Files -> R.string.screen_media_browser_list_loading_files + } + Text( + text = stringResource(res), + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun MediaGalleryViewPreview( + @PreviewParameter(MediaGalleryStateProvider::class) state: MediaGalleryState +) = ElementPreview { + CompositionLocalProvider( + LocalMediaItemPresenterFactories provides aFakeMediaItemPresenterFactories(), + ) { + MediaGalleryView( + state = state, + onBackClick = {}, + onItemClick = {}, + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/FakeTimelineItemPresenterFactories.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/FakeTimelineItemPresenterFactories.kt new file mode 100644 index 0000000..f38262e --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/FakeTimelineItemPresenterFactories.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.di + +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.voiceplayer.api.VoiceMessageState +import io.element.android.libraries.voiceplayer.api.aVoiceMessageState + +/** + * A fake [MediaItemPresenterFactories] for screenshot tests. + */ +fun aFakeMediaItemPresenterFactories() = MediaItemPresenterFactories( + mapOf( + Pair( + MediaItem.Voice::class, + MediaItemPresenterFactory { Presenter { aVoiceMessageState() } }, + ), + ) +) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/LocalMediaItemPresenterFactories.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/LocalMediaItemPresenterFactories.kt new file mode 100644 index 0000000..04003d8 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/LocalMediaItemPresenterFactories.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.di + +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * Provides a [MediaItemPresenterFactories] to the composition. + */ +val LocalMediaItemPresenterFactories = staticCompositionLocalOf { + MediaItemPresenterFactories(emptyMap()) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemEventContentKey.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemEventContentKey.kt new file mode 100644 index 0000000..38ea708 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemEventContentKey.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.di + +import dev.zacsweers.metro.MapKey +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import kotlin.reflect.KClass + +/** + * Annotation to add a factory of type [MediaItemPresenterFactory] to a + * DI map multi binding keyed with a subclass of [MediaItem.Event]. + */ +@Retention(AnnotationRetention.RUNTIME) +@MapKey +annotation class MediaItemEventContentKey(val value: KClass) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactories.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactories.kt new file mode 100644 index 0000000..b74debc --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactories.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.di + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.Multibinds +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import kotlin.reflect.KClass + +/** + * Container that declares the [MediaItemPresenterFactory] map multi binding. + * + * Its sole purpose is to support the case of an empty map multibinding. + */ +@BindingContainer +@ContributesTo(RoomScope::class) +interface MediaItemPresenterFactoriesModule { + @Multibinds + fun multiBindMediaItemPresenterFactories(): @JvmSuppressWildcards Map, MediaItemPresenterFactory<*, *>> +} + +/** + * Room level caching layer for the [MediaItemPresenterFactory] instances. + * + * It will cache the presenter instances in the room scope, so that they can be + * reused across recompositions of the gallery items that happen whenever an item + * goes out of the [LazyColumn] viewport. + */ +@SingleIn(RoomScope::class) +@Inject +class MediaItemPresenterFactories( + private val factories: @JvmSuppressWildcards Map, MediaItemPresenterFactory<*, *>>, +) { + private val presenters: MutableMap> = mutableMapOf() + + /** + * Creates and caches a presenter for the given content. + * + * Will throw if the presenter is not found in the [MediaItemPresenterFactory] map multi binding. + * + * @param C The [MediaItem.Event] subtype handled by this TimelineItem presenter. + * @param S The state type produced by this timeline item presenter. + * @param content The [MediaItem.Event] instance to create a presenter for. + * @param contentClass The class of [content]. + * @return An instance of a TimelineItem presenter that will be cached in the room scope. + */ + @Composable + fun rememberPresenter( + content: C, + contentClass: KClass, + ): Presenter = remember(content) { + presenters[content]?.let { + @Suppress("UNCHECKED_CAST") + it as Presenter + } ?: factories.getValue(contentClass).let { + @Suppress("UNCHECKED_CAST") + (it as MediaItemPresenterFactory).create(content).apply { + presenters[content] = this + } + } + } +} + +/** + * Creates and caches a presenter for the given content. + * + * Will throw if the presenter is not found in the [MediaItemPresenterFactory] map multi binding. + * + * @param C The [MediaItem.Event] subtype handled by this TimelineItem presenter. + * @param S The state type produced by this timeline item presenter. + * @param content The [MediaItem.Event] instance to create a presenter for. + * @return An instance of a TimelineItem presenter that will be cached in the room scope. + */ +@Composable +inline fun MediaItemPresenterFactories.rememberPresenter( + content: C +): Presenter = rememberPresenter( + content = content, + contentClass = C::class +) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactory.kt new file mode 100644 index 0000000..6553a0a --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactory.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.di + +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.mediaviewer.impl.model.MediaItem + +/** + * A factory for a [Presenter] associated with a timeline item. + * + * Implementations should be annotated with [dev.zacsweers.metro.AssistedFactory] to be created. + * + * @param C The timeline item's [MediaItem.Event] subtype. + * @param S The [Presenter]'s state class. + * @return A [Presenter] that produces a state of type [S] for the given content of type [C]. + */ +fun interface MediaItemPresenterFactory { + fun create(content: C): Presenter +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryFlowNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryFlowNode.kt new file mode 100644 index 0000000..5d6a48c --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryFlowNode.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.root + +import android.os.Parcelable +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.navmodel.backstack.BackStack +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.BackstackWithOverlayBox +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.architecture.overlay.Overlay +import io.element.android.libraries.architecture.overlay.operation.hide +import io.element.android.libraries.architecture.overlay.operation.show +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint +import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryNode +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.eventId +import io.element.android.libraries.mediaviewer.impl.model.mediaInfo +import io.element.android.libraries.mediaviewer.impl.model.mediaSource +import io.element.android.libraries.mediaviewer.impl.model.thumbnailSource +import kotlinx.parcelize.Parcelize + +@ContributesNode(RoomScope::class) +@AssistedInject +class MediaGalleryFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val mediaViewerEntryPoint: MediaViewerEntryPoint, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + overlay = Overlay( + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + sealed interface NavTarget : Parcelable { + @Parcelize + data object Root : NavTarget + + @Parcelize + data class MediaViewer( + val mode: MediaViewerEntryPoint.MediaViewerMode, + val eventId: EventId?, + val mediaInfo: MediaInfo, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + ) : NavTarget + } + + private val callback: MediaGalleryEntryPoint.Callback = callback() + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Root -> { + val callback = object : MediaGalleryNode.Callback { + override fun onBackClick() { + callback.onBackClick() + } + + override fun viewInTimeline(eventId: EventId) { + callback.viewInTimeline(eventId) + } + + override fun forward(eventId: EventId) { + callback.forward(eventId, fromPinnedEvents = false) + } + + override fun showItem(item: MediaItem.Event) { + val mode = when (item) { + is MediaItem.Audio, + is MediaItem.Voice, + is MediaItem.File -> MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(Timeline.Mode.Media) + is MediaItem.Image, + is MediaItem.Video -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media) + } + overlay.show( + NavTarget.MediaViewer( + mode = mode, + eventId = item.eventId(), + mediaInfo = item.mediaInfo(), + mediaSource = item.mediaSource(), + thumbnailSource = item.thumbnailSource(), + ) + ) + } + } + createNode(buildContext = buildContext, plugins = listOf(callback)) + } + is NavTarget.MediaViewer -> { + val callback = object : MediaViewerEntryPoint.Callback { + override fun onDone() { + overlay.hide() + } + + override fun viewInTimeline(eventId: EventId) { + callback.viewInTimeline(eventId) + } + + override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) { + // Need to go to the parent because of the overlay + callback.forward(eventId, fromPinnedEvents) + } + } + mediaViewerEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = MediaViewerEntryPoint.Params( + mode = navTarget.mode, + eventId = navTarget.eventId, + mediaInfo = navTarget.mediaInfo, + mediaSource = navTarget.mediaSource, + thumbnailSource = navTarget.thumbnailSource, + canShowInfo = true, + ), + callback = callback, + ) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackWithOverlayBox(modifier) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/AudioItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/AudioItemView.kt new file mode 100644 index 0000000..ed4e59d --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/AudioItemView.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +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.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.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.core.extensions.withBrackets +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun AudioItemView( + audio: MediaItem.Audio, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Spacer(modifier = Modifier.height(20.dp)) + FilenameRow( + audio = audio, + onClick = onClick, + onLongClick = onLongClick, + ) + val caption = audio.mediaInfo.caption + if (caption != null) { + CaptionView(caption) + } else { + Spacer(modifier = Modifier.height(20.dp)) + } + HorizontalDivider() + } +} + +@Composable +private fun FilenameRow( + audio: MediaItem.Audio, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background( + color = ElementTheme.colors.bgSubtleSecondary, + shape = RoundedCornerShape(12.dp), + ) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), + ) + .onKeyboardContextMenuAction(onLongClick) + .fillMaxWidth() + .padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier + .background( + color = ElementTheme.colors.bgActionSecondaryRest, + shape = CircleShape, + ) + .size(32.dp) + .padding(6.dp), + imageVector = CompoundIcons.Audio(), + contentDescription = null, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = audio.mediaInfo.filename, + modifier = Modifier.weight(1f), + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val formattedSize = audio.mediaInfo.formattedFileSize + if (formattedSize.isNotEmpty()) { + Text( + text = formattedSize.withBrackets(), + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textPrimary, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun AudioItemViewPreview( + @PreviewParameter(MediaItemAudioProvider::class) audio: MediaItem.Audio, +) = ElementPreview { + AudioItemView( + audio = audio, + onClick = {}, + onLongClick = {}, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/CaptionView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/CaptionView.kt new file mode 100644 index 0000000..cb137cf --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/CaptionView.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun CaptionView( + caption: String, + modifier: Modifier = Modifier, +) { + Text( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + text = caption, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textPrimary, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/DateItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/DateItemView.kt new file mode 100644 index 0000000..b04b25a --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/DateItemView.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.mediaviewer.impl.model.MediaItem + +@Composable +fun DateItemView( + item: MediaItem.DateSeparator, + modifier: Modifier = Modifier, +) { + Text( + modifier = modifier + .fillMaxWidth() + .padding(12.dp) + .semantics { + heading() + }, + text = item.formattedDate, + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textPrimary, + ) +} + +@PreviewsDayNight +@Composable +internal fun DateItemViewPreview( + @PreviewParameter(MediaItemDateSeparatorProvider::class) date: MediaItem.DateSeparator, +) = ElementPreview { + DateItemView(date) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt new file mode 100644 index 0000000..7738e4c --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +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.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.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.core.extensions.withBrackets +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun FileItemView( + file: MediaItem.File, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Spacer(modifier = Modifier.height(20.dp)) + FilenameRow( + file = file, + onClick = onClick, + onLongClick = onLongClick, + ) + val caption = file.mediaInfo.caption + if (caption != null) { + CaptionView(caption) + } else { + Spacer(modifier = Modifier.height(20.dp)) + } + HorizontalDivider() + } +} + +@Composable +private fun FilenameRow( + file: MediaItem.File, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background( + color = ElementTheme.colors.bgSubtleSecondary, + shape = RoundedCornerShape(12.dp), + ) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), + ) + .onKeyboardContextMenuAction(onLongClick) + .fillMaxWidth() + .padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier + .background( + color = ElementTheme.colors.bgActionSecondaryRest, + shape = CircleShape, + ) + .size(32.dp) + .padding(6.dp), + imageVector = CompoundIcons.Attachment(), + contentDescription = null, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = file.mediaInfo.filename, + modifier = Modifier.weight(1f), + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val formattedSize = file.mediaInfo.formattedFileSize + if (formattedSize.isNotEmpty()) { + Text( + text = formattedSize.withBrackets(), + style = ElementTheme.typography.fontBodyLgRegular, + color = ElementTheme.colors.textPrimary, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun FileItemViewPreview( + @PreviewParameter(MediaItemFileProvider::class) file: MediaItem.File, +) = ElementPreview { + FileItemView( + file = file, + onClick = {}, + onLongClick = {}, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt new file mode 100644 index 0000000..65256d6 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +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.res.stringResource +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun ImageItemView( + image: MediaItem.Image, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .aspectRatio(1f) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), + ) + .onKeyboardContextMenuAction(onLongClick), + ) { + var isLoaded by remember { mutableStateOf(false) } + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .then(if (isLoaded) Modifier.background(Color.White) else Modifier), + model = image.thumbnailMediaRequestData, + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + contentDescription = null, + onState = { isLoaded = it is AsyncImagePainter.State.Success }, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun ImageItemViewPreview() = ElementPreview { + ImageItemView( + image = aMediaItemImage(), + onClick = {}, + onLongClick = {}, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemAudioProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemAudioProvider.kt new file mode 100644 index 0000000..25e8a88 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemAudioProvider.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.ui + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.core.preview.loremIpsum +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemAudio + +class MediaItemAudioProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aMediaItemAudio(), + aMediaItemAudio( + filename = "A long filename that should be truncated.mp3", + caption = "A caption", + ), + aMediaItemAudio( + caption = loremIpsum, + ), + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemDateSeparatorProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemDateSeparatorProvider.kt new file mode 100644 index 0000000..05705a9 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemDateSeparatorProvider.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.ui + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemDateSeparator + +class MediaItemDateSeparatorProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aMediaItemDateSeparator(), + aMediaItemDateSeparator(formattedDate = "A long date that should be truncated"), + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt new file mode 100644 index 0000000..7419ae3 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.ui + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.core.preview.loremIpsum +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemFile + +class MediaItemFileProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aMediaItemFile(), + aMediaItemFile( + filename = "A long filename that should be truncated.jpg", + caption = "A caption", + ), + aMediaItemFile( + caption = loremIpsum, + ), + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt new file mode 100644 index 0000000..c9c4666 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.ui + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVideo + +class MediaItemVideoProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aMediaItemVideo(), + aMediaItemVideo( + duration = null, + ), + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVoiceProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVoiceProvider.kt new file mode 100644 index 0000000..4b23d28 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVoiceProvider.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.ui + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.core.preview.loremIpsum +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice + +class MediaItemVoiceProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aMediaItemVoice(), + aMediaItemVoice( + filename = "A long filename that should be truncated.ogg", + caption = "A caption", + ), + aMediaItemVoice( + caption = loremIpsum, + ), + aMediaItemVoice( + waveform = emptyList(), + ), + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt new file mode 100644 index 0000000..7d9e634 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun VideoItemView( + video: MediaItem.Video, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .aspectRatio(1f) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), + ) + .onKeyboardContextMenuAction(onLongClick), + ) { + var isLoaded by remember { mutableStateOf(false) } + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .then(if (isLoaded) Modifier.background(Color.White) else Modifier), + model = video.thumbnailMediaRequestData, + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + contentDescription = null, + onState = { isLoaded = it is AsyncImagePainter.State.Success }, + ) + VideoInfoRow( + video = video, + modifier = Modifier.align(Alignment.BottomStart) + ) + } +} + +@Composable +private fun VideoInfoRow( + video: MediaItem.Video, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + colors = listOf( + ElementTheme.colors.bgCanvasDefault.copy(alpha = 0f), + ElementTheme.colors.bgCanvasDefault, + ) + ) + ) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.VideoCallSolid(), + contentDescription = null + ) + video.mediaInfo.duration?.let { duration -> + Spacer(Modifier.weight(1f)) + Text( + text = duration, + style = ElementTheme.typography.fontBodySmMedium, + color = ElementTheme.colors.textPrimary, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun VideoItemViewPreview( + @PreviewParameter(MediaItemVideoProvider::class) video: MediaItem.Video, +) = ElementPreview { + VideoItemView( + video = video, + onClick = {}, + onLongClick = {}, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt new file mode 100644 index 0000000..9e62451 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +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.foundation.shape.RoundedCornerShape +import androidx.compose.material3.IconButtonDefaults +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.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView +import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +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.Text +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents +import io.element.android.libraries.voiceplayer.api.VoiceMessageState +import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider +import io.element.android.libraries.voiceplayer.api.aVoiceMessageState +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay + +@Composable +fun VoiceItemView( + state: VoiceMessageState, + voice: MediaItem.Voice, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Spacer(modifier = Modifier.height(20.dp)) + VoiceInfoRow( + state = state, + voice = voice, + onLongClick = onLongClick, + ) + val caption = voice.mediaInfo.caption + if (caption != null) { + CaptionView(caption) + } else { + Spacer(modifier = Modifier.height(16.dp)) + } + HorizontalDivider() + } +} + +@Composable +private fun VoiceInfoRow( + state: VoiceMessageState, + voice: MediaItem.Voice, + onLongClick: () -> Unit, +) { + fun playPause() { + state.eventSink(VoiceMessageEvents.PlayPause) + } + + Row( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background( + color = ElementTheme.colors.bgSubtleSecondary, + shape = RoundedCornerShape(12.dp), + ) + .combinedClickable( + onClick = {}, + onLongClick = onLongClick, + onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), + ) + .onKeyboardContextMenuAction(onLongClick) + .fillMaxWidth() + .padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + when (state.button) { + VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause) + VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause) + VoiceMessageState.Button.Downloading -> ProgressButton() + VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause) + VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false) + } + Spacer(Modifier.width(8.dp)) + Text( + text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(8.dp)) + WaveformPlaybackView( + modifier = Modifier + .weight(1f) + .height(34.dp), + showCursor = state.showCursor, + playbackProgress = state.progress, + waveform = voice.mediaInfo.waveform.orEmpty().toImmutableList(), + onSeek = { + state.eventSink(VoiceMessageEvents.Seek(it)) + }, + seekEnabled = true, + ) + } +} + +/** + * Progress button is shown when the voice message is being downloaded. + * + * The progress indicator is optimistic and displays a pause button (which + * indicates the audio is playing) for 2 seconds before revealing the + * actual progress indicator. + */ +@Composable +private fun ProgressButton( + displayImmediately: Boolean = false, +) { + var canDisplay by remember { mutableStateOf(displayImmediately) } + LaunchedEffect(Unit) { + delay(2000L) + canDisplay = true + } + CustomIconButton( + onClick = {}, + enabled = false, + ) { + if (canDisplay) { + CircularProgressIndicator( + modifier = Modifier + .padding(2.dp) + .size(16.dp), + color = ElementTheme.colors.iconSecondary, + strokeWidth = 2.dp, + ) + } else { + ControlIcon( + imageVector = CompoundIcons.PauseSolid(), + contentDescription = stringResource(id = CommonStrings.a11y_pause), + ) + } + } +} + +@Composable +private fun PlayButton( + onClick: () -> Unit, + enabled: Boolean = true, +) { + CustomIconButton( + onClick = onClick, + enabled = enabled, + ) { + ControlIcon( + imageVector = CompoundIcons.PlaySolid(), + contentDescription = stringResource(id = CommonStrings.a11y_play), + ) + } +} + +@Composable +private fun PauseButton( + onClick: () -> Unit, +) { + CustomIconButton( + onClick = onClick, + ) { + ControlIcon( + imageVector = CompoundIcons.PauseSolid(), + contentDescription = stringResource(id = CommonStrings.a11y_pause), + ) + } +} + +@Composable +private fun RetryButton( + onClick: () -> Unit, +) { + CustomIconButton( + onClick = onClick, + ) { + ControlIcon( + imageVector = CompoundIcons.Restart(), + contentDescription = stringResource(id = CommonStrings.action_retry), + ) + } +} + +@Composable +private fun ControlIcon( + imageVector: ImageVector, + contentDescription: String?, +) { + Icon( + modifier = Modifier.padding(vertical = 10.dp), + imageVector = imageVector, + contentDescription = contentDescription, + ) +} + +@Composable +private fun CustomIconButton( + onClick: () -> Unit, + enabled: Boolean = true, + content: @Composable () -> Unit, +) { + IconButton( + onClick = onClick, + modifier = Modifier + .background(color = ElementTheme.colors.bgCanvasDefault, shape = CircleShape) + .border( + width = 1.dp, + color = ElementTheme.colors.borderInteractiveSecondary, + shape = CircleShape, + ) + .size(36.dp), + enabled = enabled, + colors = IconButtonDefaults.iconButtonColors( + contentColor = ElementTheme.colors.iconSecondary, + disabledContentColor = ElementTheme.colors.iconDisabled, + ), + content = content, + ) +} + +@PreviewsDayNight +@Composable +internal fun VoiceItemViewPreview( + @PreviewParameter(MediaItemVoiceProvider::class) voice: MediaItem.Voice, +) = ElementPreview { + VoiceItemView( + state = aVoiceMessageState(), + voice = voice, + onLongClick = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun VoiceItemViewPlayPreview( + @PreviewParameter(VoiceMessageStateProvider::class) state: VoiceMessageState, +) = ElementPreview { + VoiceItemView( + state = state, + voice = aMediaItemVoice(), + onLongClick = {}, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/voice/VoiceMessagePresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/voice/VoiceMessagePresenter.kt new file mode 100644 index 0000000..4eac5a5 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/voice/VoiceMessagePresenter.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery.voice + +import androidx.compose.runtime.Composable +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.IntoMap +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.mediaviewer.impl.gallery.di.MediaItemEventContentKey +import io.element.android.libraries.mediaviewer.impl.gallery.di.MediaItemPresenterFactory +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.voiceplayer.api.VoiceMessagePresenterFactory +import io.element.android.libraries.voiceplayer.api.VoiceMessageState +import kotlin.time.Duration + +@BindingContainer +@ContributesTo(RoomScope::class) +interface VoiceMessagePresenterModule { + @Binds + @IntoMap + @MediaItemEventContentKey(MediaItem.Voice::class) + fun bindVoiceMessagePresenterFactory(factory: VoiceMessagePresenter.Factory): MediaItemPresenterFactory<*, *> +} + +@AssistedInject +class VoiceMessagePresenter( + voiceMessagePresenterFactory: VoiceMessagePresenterFactory, + @Assisted private val item: MediaItem.Voice, +) : Presenter { + @AssistedFactory + fun interface Factory : MediaItemPresenterFactory { + override fun create(content: MediaItem.Voice): VoiceMessagePresenter + } + + private val presenter = voiceMessagePresenterFactory.createVoiceMessagePresenter( + eventId = item.eventId, + mediaSource = item.mediaSource, + mimeType = item.mediaInfo.mimeType, + filename = item.mediaInfo.filename, + // TODO Get the duration for the fallback? + duration = Duration.ZERO, + ) + + @Composable + override fun present(): VoiceMessageState { + return presenter.present() + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActions.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActions.kt new file mode 100644 index 0000000..c7a01ea --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActions.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local + +import android.Manifest +import android.app.Activity +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.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.FileProvider +import androidx.core.content.PermissionChecker +import androidx.core.net.toFile +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.system.startInstallFromSourceIntent +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream + +@ContributesBinding(AppScope::class) +class AndroidLocalMediaActions( + @ApplicationContext private val context: Context, + private val coroutineDispatchers: CoroutineDispatchers, + private val buildMeta: BuildMeta, +) : LocalMediaActions { + private var activityContext: Context? = null + private var apkInstallLauncher: ManagedActivityResultLauncher? = null + private var pendingMedia: LocalMedia? = null + + @Composable + override fun Configure() { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + apkInstallLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { activityResult -> + if (activityResult.resultCode == Activity.RESULT_OK) { + pendingMedia?.let { + coroutineScope.launch { + openFile(it) + } + } + } else { + // User cancelled + } + pendingMedia = null + } + return DisposableEffect(Unit) { + activityContext = context + onDispose { + activityContext = null + } + } + } + + override suspend fun saveOnDisk(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) + runCatchingExceptions { + 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 = withContext(coroutineDispatchers.io) { + require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) + runCatchingExceptions { + 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 = withContext(coroutineDispatchers.io) { + require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) + runCatchingExceptions { + when (localMedia.info.mimeType) { + MimeTypes.Apk -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (PermissionChecker.checkPermission( + context, + Manifest.permission.REQUEST_INSTALL_PACKAGES, + -1, + -1, + context.packageName + ) == PermissionChecker.PERMISSION_GRANTED && + activityContext?.packageManager?.canRequestPackageInstalls() == false) { + pendingMedia = localMedia + activityContext?.startInstallFromSourceIntent(apkInstallLauncher!!).let { } + } else { + openFile(localMedia) + } + } else { + openFile(localMedia) + } + } + else -> openFile(localMedia) + } + }.onSuccess { + Timber.v("Open media succeed") + }.onFailure { + Timber.e(it, "Open media failed") + } + } + + private suspend fun openFile(localMedia: LocalMedia) { + 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) + } + } + + 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.filename) + 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.filename + ) + 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/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt new file mode 100644 index 0000000..05cbe40 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +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.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.media.MediaFile +import io.element.android.libraries.matrix.api.media.toFile +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory +import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor + +@ContributesBinding(AppScope::class) +class AndroidLocalMediaFactory( + @ApplicationContext private val context: Context, + private val fileSizeFormatter: FileSizeFormatter, + private val fileExtensionExtractor: FileExtensionExtractor, +) : LocalMediaFactory { + override fun createFromMediaFile( + mediaFile: MediaFile, + mediaInfo: MediaInfo, + ): LocalMedia = createFromUri( + uri = mediaFile.toFile().toUri(), + mimeType = mediaInfo.mimeType, + name = mediaInfo.filename, + caption = mediaInfo.caption, + formattedFileSize = mediaInfo.formattedFileSize, + senderId = mediaInfo.senderId, + senderName = mediaInfo.senderName, + senderAvatar = mediaInfo.senderAvatar, + dateSent = mediaInfo.dateSent, + dateSentFull = mediaInfo.dateSentFull, + waveform = mediaInfo.waveform, + duration = mediaInfo.duration, + ) + + override fun createFromUri( + uri: Uri, + mimeType: String?, + name: String?, + formattedFileSize: String? + ): LocalMedia = createFromUri( + uri = uri, + mimeType = mimeType, + name = name, + caption = null, + formattedFileSize = formattedFileSize, + senderId = null, + senderName = null, + senderAvatar = null, + dateSent = null, + dateSentFull = null, + waveform = null, + duration = null, + ) + + private fun createFromUri( + uri: Uri, + mimeType: String?, + name: String?, + caption: String?, + formattedFileSize: String?, + senderId: UserId?, + senderName: String?, + senderAvatar: String?, + dateSent: String?, + dateSentFull: String?, + waveform: List?, + duration: String?, + ): LocalMedia { + val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream + val fileName = name ?: context.getFileName(uri) ?: "" + val fileSize = context.getFileSize(uri) + val calculatedFormattedFileSize = formattedFileSize ?: fileSizeFormatter.format(fileSize) + val fileExtension = fileExtensionExtractor.extractFromName(fileName) + return LocalMedia( + uri = uri, + info = MediaInfo( + mimeType = resolvedMimeType, + filename = fileName, + fileSize = fileSize, + caption = caption, + formattedFileSize = calculatedFormattedFileSize, + fileExtension = fileExtension, + senderId = senderId, + senderName = senderName, + senderAvatar = senderAvatar, + dateSent = dateSent, + dateSentFull = dateSentFull, + waveform = waveform, + duration = duration, + ) + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/DefaultLocalMediaRenderer.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/DefaultLocalMediaRenderer.kt new file mode 100644 index 0000000..96450d9 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/DefaultLocalMediaRenderer.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.viewfolder.api.TextFileViewer +import io.element.android.libraries.audio.api.AudioFocus +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer +import me.saket.telephoto.zoomable.OverzoomEffect +import me.saket.telephoto.zoomable.ZoomSpec +import me.saket.telephoto.zoomable.rememberZoomableState + +@ContributesBinding(AppScope::class) +class DefaultLocalMediaRenderer( + private val textFileViewer: TextFileViewer, + private val audioFocus: AudioFocus, +) : LocalMediaRenderer { + @Composable + override fun Render(localMedia: LocalMedia) { + val localMediaViewState = rememberLocalMediaViewState( + zoomableState = rememberZoomableState( + zoomSpec = ZoomSpec(maxZoomFactor = 4f, overzoomEffect = OverzoomEffect.NoLimits) + ) + ) + LocalMediaView( + modifier = Modifier.fillMaxSize(), + bottomPaddingInPixels = 0, + localMedia = localMedia, + localMediaViewState = localMediaViewState, + textFileViewer = textFileViewer, + audioFocus = audioFocus, + onClick = {}, + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaActions.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaActions.kt new file mode 100644 index 0000000..8aee16c --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaActions.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local + +import androidx.compose.runtime.Composable +import io.element.android.libraries.mediaviewer.api.local.LocalMedia + +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 + + /** + * 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 + + /** + * 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 +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt new file mode 100644 index 0000000..cc276b5 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.features.viewfolder.api.TextFileViewer +import io.element.android.libraries.audio.api.AudioFocus +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.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.impl.local.audio.MediaAudioView +import io.element.android.libraries.mediaviewer.impl.local.file.MediaFileView +import io.element.android.libraries.mediaviewer.impl.local.image.MediaImageView +import io.element.android.libraries.mediaviewer.impl.local.pdf.MediaPdfView +import io.element.android.libraries.mediaviewer.impl.local.txt.TextFileView +import io.element.android.libraries.mediaviewer.impl.local.video.MediaVideoView + +@Composable +fun LocalMediaView( + localMedia: LocalMedia?, + bottomPaddingInPixels: Int, + audioFocus: AudioFocus?, + onClick: () -> Unit, + textFileViewer: TextFileViewer, + modifier: Modifier = Modifier, + isDisplayed: Boolean = true, + isUserSelected: Boolean = false, + localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(), + mediaInfo: MediaInfo? = localMedia?.info, +) { + val mimeType = mediaInfo?.mimeType + when { + mimeType.isMimeTypeImage() -> MediaImageView( + localMediaViewState = localMediaViewState, + localMedia = localMedia, + modifier = modifier, + onClick = onClick, + ) + mimeType.isMimeTypeVideo() -> MediaVideoView( + isDisplayed = isDisplayed, + localMediaViewState = localMediaViewState, + bottomPaddingInPixels = bottomPaddingInPixels, + localMedia = localMedia, + autoplay = isUserSelected, + audioFocus = audioFocus, + modifier = modifier, + ) + mimeType == MimeTypes.PlainText -> TextFileView( + localMedia = localMedia, + textFileViewer = textFileViewer, + modifier = modifier, + ) + mimeType == MimeTypes.Pdf -> MediaPdfView( + localMediaViewState = localMediaViewState, + localMedia = localMedia, + modifier = modifier, + onClick = onClick, + ) + mimeType.isMimeTypeAudio() -> MediaAudioView( + isDisplayed = isDisplayed, + localMediaViewState = localMediaViewState, + bottomPaddingInPixels = bottomPaddingInPixels, + localMedia = localMedia, + info = mediaInfo, + audioFocus = audioFocus, + modifier = modifier, + ) + else -> MediaFileView( + localMediaViewState = localMediaViewState, + uri = localMedia?.uri, + info = mediaInfo, + modifier = modifier, + onClick = onClick, + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaViewState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaViewState.kt new file mode 100644 index 0000000..08b3ec5 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaViewState.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +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 +import me.saket.telephoto.zoomable.ZoomableState +import me.saket.telephoto.zoomable.rememberZoomableState + +@Stable +class LocalMediaViewState internal constructor( + val zoomableState: ZoomableState, +) { + var isReady: Boolean by mutableStateOf(false) + var playableState: PlayableState by mutableStateOf(PlayableState.NotPlayable) +} + +@Immutable +sealed interface PlayableState { + data object NotPlayable : PlayableState + data class Playable( + val isShowingControls: Boolean, + ) : PlayableState +} + +@Composable +fun rememberLocalMediaViewState(zoomableState: ZoomableState = rememberZoomableState()): LocalMediaViewState { + return remember(zoomableState) { + LocalMediaViewState(zoomableState) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt new file mode 100644 index 0000000..1bf952d --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt @@ -0,0 +1,381 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.audio + +import android.annotation.SuppressLint +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.FrameLayout +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.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +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.setValue +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.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +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 androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.audio.api.AudioFocus +import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.Text +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState +import io.element.android.libraries.mediaviewer.impl.local.PlayableState +import io.element.android.libraries.mediaviewer.impl.local.player.MediaPlayerControllerState +import io.element.android.libraries.mediaviewer.impl.local.player.MediaPlayerControllerView +import io.element.android.libraries.mediaviewer.impl.local.player.rememberExoPlayer +import io.element.android.libraries.mediaviewer.impl.local.player.seekToEnsurePlaying +import io.element.android.libraries.mediaviewer.impl.local.player.togglePlay +import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay + +@SuppressLint("UnsafeOptInUsageError") +@Composable +fun MediaAudioView( + localMediaViewState: LocalMediaViewState, + bottomPaddingInPixels: Int, + localMedia: LocalMedia?, + info: MediaInfo?, + audioFocus: AudioFocus?, + modifier: Modifier = Modifier, + isDisplayed: Boolean = true, +) { + val exoPlayer = rememberExoPlayer() + ExoPlayerMediaAudioView( + isDisplayed = isDisplayed, + localMediaViewState = localMediaViewState, + bottomPaddingInPixels = bottomPaddingInPixels, + exoPlayer = exoPlayer, + localMedia = localMedia, + info = info, + audioFocus = audioFocus, + modifier = modifier, + ) +} + +@SuppressLint("UnsafeOptInUsageError") +@Composable +private fun ExoPlayerMediaAudioView( + isDisplayed: Boolean, + localMediaViewState: LocalMediaViewState, + bottomPaddingInPixels: Int, + exoPlayer: ExoPlayer, + localMedia: LocalMedia?, + info: MediaInfo?, + audioFocus: AudioFocus?, + modifier: Modifier = Modifier, +) { + var mediaPlayerControllerState: MediaPlayerControllerState by remember { + mutableStateOf( + MediaPlayerControllerState( + isVisible = true, + isPlaying = false, + isReady = false, + progressInMillis = 0, + durationInMillis = 0, + canMute = false, + isMuted = false, + ) + ) + } + + var metadata: MediaMetadata? by remember { + mutableStateOf(null) + } + + val playableState: PlayableState.Playable by remember { + derivedStateOf { + PlayableState.Playable( + isShowingControls = mediaPlayerControllerState.isVisible, + ) + } + } + + localMediaViewState.playableState = playableState + + val playerListener = remember { + object : Player.Listener { + override fun onRenderedFirstFrame() { + localMediaViewState.isReady = true + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + mediaPlayerControllerState = mediaPlayerControllerState.copy( + isPlaying = isPlaying, + ) + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) { + exoPlayer.duration.takeIf { it >= 0 } + ?.let { + mediaPlayerControllerState = mediaPlayerControllerState.copy( + durationInMillis = it, + ) + } + } + } + + override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { + metadata = mediaMetadata + } + } + } + + LaunchedEffect(exoPlayer.isPlaying) { + if (exoPlayer.isPlaying) { + while (true) { + mediaPlayerControllerState = mediaPlayerControllerState.copy( + progressInMillis = exoPlayer.currentPosition, + ) + delay(200) + } + } else { + // Ensure we render the final state + mediaPlayerControllerState = mediaPlayerControllerState.copy( + progressInMillis = exoPlayer.currentPosition, + ) + } + } + LaunchedEffect(isDisplayed) { + // If not displayed, make sure to pause the audio + if (!isDisplayed) { + exoPlayer.pause() + } + } + if (localMedia?.uri != null) { + LaunchedEffect(localMedia.uri) { + val mediaItem = MediaItem.fromUri(localMedia.uri) + exoPlayer.setMediaItem(mediaItem) + } + } else { + exoPlayer.setMediaItems(emptyList()) + } + val context = LocalContext.current + val waveform = info?.waveform + Box( + modifier = modifier + .fillMaxSize() + .background(ElementTheme.colors.bgSubtlePrimary), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + if (LocalInspectionMode.current) { + Text( + modifier = Modifier + .padding(16.dp) + .width(240.dp), + text = "An audio Player may render an image here if the audio file contains some artwork.", + textAlign = TextAlign.Center, + color = ElementTheme.colors.textPrimary, + ) + } else { + AndroidView( + modifier = Modifier + .clip(shape = RoundedCornerShape(12.dp)) + .clipToBounds() + .width(240.dp), + factory = { + PlayerView(context).apply { + player = exoPlayer + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + useController = false + } + }, + update = { playerView -> + playerView.isVisible = metadata.hasArtwork() + }, + onRelease = { playerView -> + playerView.player = null + }, + ) + } + if (waveform != null) { + WaveformPlaybackView( + modifier = Modifier + .height(48.dp), + playbackProgress = mediaPlayerControllerState.progressAsFloat, + showCursor = true, + waveform = waveform.toImmutableList(), + onSeek = { + exoPlayer.seekToEnsurePlaying((it * exoPlayer.duration).toLong()) + }, + seekEnabled = true, + ) + } else { + if (!metadata.hasArtwork()) { + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background(ElementTheme.colors.iconPrimary), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = CompoundIcons.Audio(), + contentDescription = null, + tint = ElementTheme.colors.iconOnSolidPrimary, + modifier = Modifier + .size(32.dp), + ) + } + } + } + } + if (waveform == null) { + // Display the info below the player + AudioInfoView( + modifier = Modifier.padding(horizontal = 16.dp), + info = info, + metadata = metadata, + ) + } + } + MediaPlayerControllerView( + state = mediaPlayerControllerState, + onTogglePlay = { + exoPlayer.togglePlay() + }, + onSeekChange = { + exoPlayer.seekToEnsurePlaying(it.toLong()) + }, + onToggleMute = { + // Cannot happen for audio files + }, + audioFocus = audioFocus, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(bottom = bottomPaddingInPixels.toDp()), + ) + } + + OnLifecycleEvent { _, event -> + when (event) { + Lifecycle.Event.ON_CREATE -> exoPlayer.addListener(playerListener) + Lifecycle.Event.ON_RESUME -> exoPlayer.prepare() + Lifecycle.Event.ON_PAUSE -> exoPlayer.pause() + Lifecycle.Event.ON_DESTROY -> { + exoPlayer.release() + exoPlayer.removeListener(playerListener) + } + else -> Unit + } + } +} + +@Composable +private fun AudioInfoView( + info: MediaInfo?, + metadata: MediaMetadata?, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Render the info about the file and from the metadata + val metaDataInfo = metadata.buildInfo() + if (metaDataInfo.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = metaDataInfo, + style = ElementTheme.typography.fontBodyMdRegular, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = ElementTheme.colors.textPrimary + ) + } + if (info != null) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = info.filename, + maxLines = 2, + style = ElementTheme.typography.fontBodyLgRegular, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + color = ElementTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = formatFileExtensionAndSize(info.fileExtension, info.formattedFileSize), + style = ElementTheme.typography.fontBodyMdRegular, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = ElementTheme.colors.textPrimary + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun MediaAudioViewPreview( + @PreviewParameter(MediaInfoAudioProvider::class) info: MediaInfo +) = ElementPreview { + MediaAudioView( + modifier = Modifier.fillMaxSize(), + bottomPaddingInPixels = 0, + localMediaViewState = rememberLocalMediaViewState(), + info = info, + audioFocus = null, + localMedia = null, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaInfoAudioProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaInfoAudioProvider.kt new file mode 100644 index 0000000..02607e3 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaInfoAudioProvider.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.audio + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.designsystem.components.media.WaveFormSamples +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo + +open class MediaInfoAudioProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anAudioMediaInfo(), + anAudioMediaInfo( + waveForm = WaveFormSamples.realisticWaveForm, + ), + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaMetadata.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaMetadata.kt new file mode 100644 index 0000000..4c267a5 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaMetadata.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.audio + +import androidx.media3.common.MediaMetadata + +fun MediaMetadata?.hasArtwork(): Boolean { + return this?.artworkData != null || this?.artworkUri != null +} + +fun MediaMetadata?.buildInfo(): String { + this ?: return "" + return buildString { + if (artist != null) { + append(artist) + } + if (title != null) { + if (isNotEmpty()) { + append(" - ") + } + append(title) + } + if (recordingYear != null) { + if (isNotEmpty()) { + append(" - ") + } + append(recordingYear) + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaFileView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaFileView.kt new file mode 100644 index 0000000..912ea88 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaFileView.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.file + +import android.net.Uri +import androidx.compose.foundation.background +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.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.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.draw.rotate +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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize +import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState +import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState + +@Composable +fun MediaFileView( + localMediaViewState: LocalMediaViewState, + uri: Uri?, + info: MediaInfo?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val isAudio = info?.mimeType.isMimeTypeAudio().orFalse() + localMediaViewState.isReady = uri != null + + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = modifier + .padding(horizontal = 8.dp) + .clickable( + onClick = onClick, + interactionSource = interactionSource, + indication = null + ), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background(ElementTheme.colors.iconPrimary), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (isAudio) CompoundIcons.Audio() else CompoundIcons.Attachment(), + contentDescription = null, + tint = ElementTheme.colors.iconOnSolidPrimary, + modifier = Modifier + .size(32.dp) + .rotate(if (isAudio) 0f else -45f), + ) + } + if (info != null) { + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = info.filename, + maxLines = 2, + style = ElementTheme.typography.fontBodyLgRegular, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + color = ElementTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = formatFileExtensionAndSize(info.fileExtension, info.formattedFileSize), + style = ElementTheme.typography.fontBodyMdRegular, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = ElementTheme.colors.textPrimary + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun MediaFileViewPreview( + @PreviewParameter(MediaInfoFileProvider::class) info: MediaInfo +) = ElementPreview { + MediaFileView( + modifier = Modifier.fillMaxSize(), + localMediaViewState = rememberLocalMediaViewState(), + uri = null, + info = info, + onClick = {}, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaInfoFileProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaInfoFileProvider.kt new file mode 100644 index 0000000..8e41a48 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaInfoFileProvider.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.file + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo + +open class MediaInfoFileProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aPdfMediaInfo(), + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/image/MediaImageView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/image/MediaImageView.kt new file mode 100644 index 0000000..306a18b --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/image/MediaImageView.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.image + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.utils.CommonDrawables +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState +import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState +import io.element.android.libraries.ui.strings.CommonStrings +import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage +import me.saket.telephoto.zoomable.rememberZoomableImageState + +@Composable +fun MediaImageView( + localMediaViewState: LocalMediaViewState, + localMedia: LocalMedia?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + if (LocalInspectionMode.current) { + Image( + painter = painterResource(id = CommonDrawables.sample_background), + modifier = modifier, + contentDescription = null, + ) + } else { + val zoomableImageState = rememberZoomableImageState(localMediaViewState.zoomableState) + localMediaViewState.isReady = zoomableImageState.isImageDisplayed + ZoomableAsyncImage( + modifier = modifier, + state = zoomableImageState, + model = localMedia?.uri, + contentDescription = stringResource(id = CommonStrings.common_image), + contentScale = ContentScale.Fit, + onClick = { onClick() } + ) + } +} + +@PreviewsDayNight +@Composable +internal fun MediaImageViewPreview() = ElementPreview { + MediaImageView( + modifier = Modifier.fillMaxSize(), + localMediaViewState = rememberLocalMediaViewState(), + localMedia = null, + onClick = {}, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/MediaPdfView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/MediaPdfView.kt new file mode 100644 index 0000000..82024bd --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/MediaPdfView.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.pdf + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState + +@Composable +fun MediaPdfView( + localMediaViewState: LocalMediaViewState, + localMedia: LocalMedia?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val pdfViewerState = rememberPdfViewerState( + model = localMedia?.uri, + zoomableState = localMediaViewState.zoomableState, + ) + localMediaViewState.isReady = pdfViewerState.isLoaded + PdfViewer( + pdfViewerState = pdfViewerState, + onClick = onClick, + modifier = modifier, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/ParcelFileDescriptorFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/ParcelFileDescriptorFactory.kt new file mode 100644 index 0000000..67949c9 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/ParcelFileDescriptorFactory.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.pdf + +import android.content.Context +import android.net.Uri +import android.os.ParcelFileDescriptor +import io.element.android.libraries.core.extensions.runCatchingExceptions +import java.io.File + +class ParcelFileDescriptorFactory(private val context: Context) { + fun create(model: Any?) = runCatchingExceptions { + 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/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/PdfPage.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/PdfPage.kt new file mode 100644 index 0000000..7163ccb --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/PdfPage.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.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.Loading( + width = renderWidth, + height = renderHeight + ) + ) + val stateFlow: StateFlow = 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/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/PdfRendererManager.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/PdfRendererManager.kt new file mode 100644 index 0000000..3b2e169 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/PdfRendererManager.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.pdf + +import android.graphics.pdf.PdfRenderer +import android.os.ParcelFileDescriptor +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.extensions.runCatchingExceptions +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +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>>(AsyncData.Uninitialized) + val pdfPages: StateFlow>> = mutablePdfPages + + fun open() { + coroutineScope.launch { + mutex.withLock { + withContext(Dispatchers.IO) { + pdfRenderer = runCatchingExceptions { + PdfRenderer(parcelFileDescriptor) + }.fold( + onSuccess = { pdfRenderer -> + pdfRenderer.apply { + // Preload just 3 pages so we can render faster + val firstPages = loadPages(from = 0, to = 3) + mutablePdfPages.value = AsyncData.Success(firstPages.toImmutableList()) + val nextPages = loadPages(from = 3, to = pageCount) + mutablePdfPages.value = AsyncData.Success((firstPages + nextPages).toImmutableList()) + } + }, + onFailure = { + mutablePdfPages.value = AsyncData.Failure(it) + null + } + ) + } + } + } + } + + fun close() { + coroutineScope.launch { + mutex.withLock { + mutablePdfPages.value.dataOrNull()?.forEach { pdfPage -> + pdfPage.close() + } + pdfRenderer?.close() + parcelFileDescriptor.close() + } + } + } + + private fun PdfRenderer.loadPages(from: Int, to: Int): List { + return (from until minOf(to, pageCount)).map { pageIndex -> + PdfPage(width, pageIndex, mutex, this, coroutineScope) + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/PdfViewer.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/PdfViewer.kt new file mode 100644 index 0000000..395a442 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/PdfViewer.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.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.Spacer +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.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.roundToPx +import io.element.android.libraries.designsystem.text.toDp +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.mediaviewer.impl.viewer.topAppBarHeight +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import me.saket.telephoto.zoomable.zoomable +import java.io.IOException + +@Composable +fun PdfViewer( + pdfViewerState: PdfViewerState, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + BoxWithConstraints( + modifier = modifier + .zoomable( + state = pdfViewerState.zoomableState, + onClick = { onClick() } + ), + contentAlignment = Alignment.Center + ) { + val maxWidthInPx = maxWidth.roundToPx() + DisposableEffect(pdfViewerState) { + pdfViewerState.openForWidth(maxWidthInPx) + onDispose { + pdfViewerState.close() + } + } + val pdfPages = pdfViewerState.getPages() + PdfPagesView( + pdfPages = pdfPages, + lazyListState = pdfViewerState.lazyListState, + ) + } +} + +@Composable +private fun PdfPagesView( + pdfPages: AsyncData>, + lazyListState: LazyListState, + modifier: Modifier = Modifier, +) { + when (pdfPages) { + is AsyncData.Uninitialized, + is AsyncData.Loading -> Unit + is AsyncData.Failure -> PdfPagesErrorView( + pdfPages.error, + modifier, + ) + is AsyncData.Success -> PdfPagesContentView( + pdfPages = pdfPages.data, + lazyListState = lazyListState, + modifier = modifier + ) + } +} + +@Composable +private fun PdfPagesErrorView( + error: Throwable, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = buildString { + append(stringResource(id = CommonStrings.error_unknown)) + append("\n\n") + append(error.localizedMessage) + }, + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodyLgRegular, + ) + } +} + +@Composable +private fun PdfPagesContentView( + pdfPages: ImmutableList, + lazyListState: LazyListState, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically) + ) { + // Add a fake item to the top so that the first item is not at the top of the screen. + item { + Spacer(modifier = Modifier.height(topAppBarHeight)) + } + items(pdfPages.size) { index -> + val pdfPage = pdfPages[index] + PdfPageView(pdfPage) + } + } +} + +@Composable +private fun PdfPageView( + pdfPage: PdfPage, +) { + 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 = stringResource(id = CommonStrings.a11y_page_n, pdfPage.pageIndex), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth() + ) + } + is PdfPage.State.Loading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .height(state.height.toDp()) + .background(color = Color.White) + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun PdfPagesErrorViewPreview() = ElementPreview { + PdfPagesErrorView( + error = IOException("file not in PDF format or corrupted"), + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/PdfViewerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/PdfViewerState.kt new file mode 100644 index 0000000..83d111e --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/PdfViewerState.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.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 io.element.android.libraries.architecture.AsyncData +import kotlinx.collections.immutable.ImmutableList +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(null) + + @Composable + fun getPages(): AsyncData> { + return pdfRendererManager?.run { + pdfPages.collectAsState().value + } ?: AsyncData.Uninitialized + } + + 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/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerExtensions.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerExtensions.kt new file mode 100644 index 0000000..4e17b77 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerExtensions.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.player + +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer + +fun ExoPlayer.togglePlay() { + if (isPlaying) { + pause() + } else { + if (playbackState == Player.STATE_ENDED) { + seekTo(0) + } else { + play() + } + } +} + +fun ExoPlayer.seekToEnsurePlaying(positionMs: Long) { + if (isPlaying.not()) { + play() + } + seekTo(positionMs) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerFactory.kt new file mode 100644 index 0000000..d0043c6 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerFactory.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.player + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.media3.exoplayer.ExoPlayer + +@Composable +fun rememberExoPlayer(): ExoPlayer { + return if (LocalInspectionMode.current) { + remember { + ExoPlayerForPreview() + } + } else { + val context = LocalContext.current + remember { + ExoPlayer.Builder(context).build() + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt new file mode 100644 index 0000000..4cb05ee --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:Suppress( + "OVERRIDE_DEPRECATION", + "RedundantNullableReturnType", + "DEPRECATION", +) + +package io.element.android.libraries.mediaviewer.impl.local.player + +import android.annotation.SuppressLint +import android.media.AudioDeviceInfo +import android.os.Looper +import android.view.Surface +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.TextureView +import androidx.media3.common.AudioAttributes +import androidx.media3.common.AuxEffectInfo +import androidx.media3.common.DeviceInfo +import androidx.media3.common.Effect +import androidx.media3.common.Format +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.PriorityTaskManager +import androidx.media3.common.Timeline +import androidx.media3.common.TrackSelectionParameters +import androidx.media3.common.Tracks +import androidx.media3.common.VideoSize +import androidx.media3.common.text.CueGroup +import androidx.media3.common.util.Clock +import androidx.media3.common.util.Size +import androidx.media3.exoplayer.DecoderCounters +import androidx.media3.exoplayer.ExoPlaybackException +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.PlayerMessage +import androidx.media3.exoplayer.Renderer +import androidx.media3.exoplayer.ScrubbingModeParameters +import androidx.media3.exoplayer.SeekParameters +import androidx.media3.exoplayer.analytics.AnalyticsCollector +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.image.ImageOutput +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.ShuffleOrder +import androidx.media3.exoplayer.source.TrackGroupArray +import androidx.media3.exoplayer.trackselection.TrackSelectionArray +import androidx.media3.exoplayer.trackselection.TrackSelector +import androidx.media3.exoplayer.video.VideoFrameMetadataListener +import androidx.media3.exoplayer.video.spherical.CameraMotionListener +import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage + +@SuppressLint("UnsafeOptInUsageError") +@ExcludeFromCoverage +class ExoPlayerForPreview( + private val isPlaying: Boolean = false, +) : ExoPlayer { + override fun getApplicationLooper(): Looper = throw NotImplementedError() + override fun addListener(listener: Player.Listener) {} + override fun removeListener(listener: Player.Listener) {} + override fun setMediaItems(mediaItems: MutableList) {} + override fun setMediaItems(mediaItems: MutableList, resetPosition: Boolean) {} + override fun setMediaItems(mediaItems: MutableList, startIndex: Int, startPositionMs: Long) {} + override fun setMediaItem(mediaItem: MediaItem) {} + override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) {} + override fun setMediaItem(mediaItem: MediaItem, resetPosition: Boolean) {} + override fun addMediaItem(mediaItem: MediaItem) {} + override fun addMediaItem(index: Int, mediaItem: MediaItem) {} + override fun addMediaItems(mediaItems: MutableList) {} + override fun addMediaItems(index: Int, mediaItems: MutableList) {} + override fun moveMediaItem(currentIndex: Int, newIndex: Int) {} + override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) {} + override fun replaceMediaItem(index: Int, mediaItem: MediaItem) {} + override fun replaceMediaItems(fromIndex: Int, toIndex: Int, mediaItems: MutableList) {} + override fun removeMediaItem(index: Int) {} + override fun removeMediaItems(fromIndex: Int, toIndex: Int) {} + override fun clearMediaItems() {} + override fun isCommandAvailable(command: Int): Boolean = throw NotImplementedError() + override fun canAdvertiseSession(): Boolean = throw NotImplementedError() + override fun getAvailableCommands(): Player.Commands = throw NotImplementedError() + override fun prepare(mediaSource: MediaSource) {} + override fun prepare(mediaSource: MediaSource, resetPosition: Boolean, resetState: Boolean) {} + override fun prepare() {} + override fun getPlaybackState(): Int = throw NotImplementedError() + override fun getPlaybackSuppressionReason(): Int = throw NotImplementedError() + override fun isPlaying() = isPlaying + override fun getPlayerError(): ExoPlaybackException? = null + override fun play() {} + override fun pause() {} + override fun setPlayWhenReady(playWhenReady: Boolean) {} + override fun getPlayWhenReady(): Boolean = throw NotImplementedError() + override fun setRepeatMode(repeatMode: Int) {} + override fun getRepeatMode(): Int = throw NotImplementedError() + override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {} + override fun getShuffleModeEnabled(): Boolean = throw NotImplementedError() + override fun isLoading(): Boolean = throw NotImplementedError() + override fun seekToDefaultPosition() {} + override fun seekToDefaultPosition(mediaItemIndex: Int) {} + override fun seekTo(positionMs: Long) {} + override fun seekTo(mediaItemIndex: Int, positionMs: Long) {} + override fun getSeekBackIncrement(): Long = throw NotImplementedError() + override fun seekBack() {} + override fun getSeekForwardIncrement(): Long = throw NotImplementedError() + override fun seekForward() {} + override fun hasPreviousMediaItem(): Boolean = throw NotImplementedError() + override fun seekToPreviousMediaItem() {} + override fun getMaxSeekToPreviousPosition(): Long = throw NotImplementedError() + override fun seekToPrevious() {} + override fun hasNextMediaItem(): Boolean = throw NotImplementedError() + override fun seekToNextMediaItem() {} + override fun seekToNext() {} + override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {} + override fun setPlaybackSpeed(speed: Float) {} + override fun getPlaybackParameters(): PlaybackParameters = throw NotImplementedError() + override fun stop() {} + override fun release() {} + override fun getCurrentTracks(): Tracks = throw NotImplementedError() + override fun getTrackSelectionParameters(): TrackSelectionParameters = throw NotImplementedError() + override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) {} + override fun getMediaMetadata(): MediaMetadata = throw NotImplementedError() + override fun getPlaylistMetadata(): MediaMetadata = throw NotImplementedError() + override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) {} + override fun getCurrentManifest(): Any? = throw NotImplementedError() + override fun getCurrentTimeline(): Timeline = throw NotImplementedError() + override fun getCurrentPeriodIndex(): Int = throw NotImplementedError() + override fun getCurrentWindowIndex(): Int = throw NotImplementedError() + override fun getCurrentMediaItemIndex(): Int = throw NotImplementedError() + override fun getNextWindowIndex(): Int = throw NotImplementedError() + override fun getNextMediaItemIndex(): Int = throw NotImplementedError() + override fun getPreviousWindowIndex(): Int = throw NotImplementedError() + override fun getPreviousMediaItemIndex(): Int = throw NotImplementedError() + override fun getCurrentMediaItem(): MediaItem? = throw NotImplementedError() + override fun getMediaItemCount(): Int = throw NotImplementedError() + override fun getMediaItemAt(index: Int): MediaItem = throw NotImplementedError() + override fun getDuration(): Long = throw NotImplementedError() + override fun getCurrentPosition(): Long = throw NotImplementedError() + override fun getBufferedPosition(): Long = throw NotImplementedError() + override fun getBufferedPercentage(): Int = throw NotImplementedError() + override fun getTotalBufferedDuration(): Long = throw NotImplementedError() + override fun isCurrentWindowDynamic(): Boolean = throw NotImplementedError() + override fun isCurrentMediaItemDynamic(): Boolean = throw NotImplementedError() + override fun isCurrentWindowLive(): Boolean = throw NotImplementedError() + override fun isCurrentMediaItemLive(): Boolean = throw NotImplementedError() + override fun getCurrentLiveOffset(): Long = throw NotImplementedError() + override fun isCurrentWindowSeekable(): Boolean = throw NotImplementedError() + override fun isCurrentMediaItemSeekable(): Boolean = throw NotImplementedError() + override fun isPlayingAd(): Boolean = throw NotImplementedError() + override fun getCurrentAdGroupIndex(): Int = throw NotImplementedError() + override fun getCurrentAdIndexInAdGroup(): Int = throw NotImplementedError() + override fun getContentDuration(): Long = throw NotImplementedError() + override fun getContentPosition(): Long = throw NotImplementedError() + override fun getContentBufferedPosition(): Long = throw NotImplementedError() + override fun getAudioAttributes(): AudioAttributes = throw NotImplementedError() + override fun setVolume(volume: Float) = throw NotImplementedError() + override fun getVolume(): Float = throw NotImplementedError() + override fun clearVideoSurface() {} + override fun clearVideoSurface(surface: Surface?) {} + override fun setVideoSurface(surface: Surface?) {} + override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) {} + override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) {} + override fun setVideoSurfaceView(surfaceView: SurfaceView?) {} + override fun clearVideoSurfaceView(surfaceView: SurfaceView?) {} + override fun setVideoTextureView(textureView: TextureView?) {} + override fun clearVideoTextureView(textureView: TextureView?) {} + override fun getVideoSize(): VideoSize = throw NotImplementedError() + override fun getSurfaceSize(): Size = throw NotImplementedError() + override fun getCurrentCues(): CueGroup = throw NotImplementedError() + override fun getDeviceInfo(): DeviceInfo = throw NotImplementedError() + override fun getDeviceVolume(): Int = throw NotImplementedError() + override fun isDeviceMuted(): Boolean = throw NotImplementedError() + override fun setDeviceVolume(volume: Int) {} + override fun setDeviceVolume(volume: Int, flags: Int) {} + override fun increaseDeviceVolume() {} + override fun increaseDeviceVolume(flags: Int) {} + override fun decreaseDeviceVolume() {} + override fun decreaseDeviceVolume(flags: Int) {} + override fun setDeviceMuted(muted: Boolean) {} + override fun setDeviceMuted(muted: Boolean, flags: Int) {} + override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) {} + override fun addAudioOffloadListener(listener: ExoPlayer.AudioOffloadListener) {} + override fun removeAudioOffloadListener(listener: ExoPlayer.AudioOffloadListener) {} + override fun getAnalyticsCollector(): AnalyticsCollector = throw NotImplementedError() + override fun addAnalyticsListener(listener: AnalyticsListener) {} + override fun removeAnalyticsListener(listener: AnalyticsListener) {} + override fun getRendererCount(): Int = throw NotImplementedError() + override fun getRendererType(index: Int): Int = throw NotImplementedError() + override fun getRenderer(index: Int): Renderer = throw NotImplementedError() + override fun getTrackSelector(): TrackSelector? = throw NotImplementedError() + override fun getCurrentTrackGroups(): TrackGroupArray = throw NotImplementedError() + override fun getCurrentTrackSelections(): TrackSelectionArray = throw NotImplementedError() + override fun getPlaybackLooper(): Looper = throw NotImplementedError() + override fun getClock(): Clock = throw NotImplementedError() + override fun setMediaSources(mediaSources: MutableList) {} + override fun setMediaSources(mediaSources: MutableList, resetPosition: Boolean) {} + override fun setMediaSources(mediaSources: MutableList, startMediaItemIndex: Int, startPositionMs: Long) {} + override fun setMediaSource(mediaSource: MediaSource) {} + override fun setMediaSource(mediaSource: MediaSource, startPositionMs: Long) {} + override fun setMediaSource(mediaSource: MediaSource, resetPosition: Boolean) {} + override fun addMediaSource(mediaSource: MediaSource) {} + override fun addMediaSource(index: Int, mediaSource: MediaSource) {} + override fun addMediaSources(mediaSources: MutableList) {} + override fun addMediaSources(index: Int, mediaSources: MutableList) {} + override fun setShuffleOrder(shuffleOrder: ShuffleOrder) {} + override fun getShuffleOrder(): ShuffleOrder = ShuffleOrder.DefaultShuffleOrder(0) + override fun setPreloadConfiguration(preloadConfiguration: ExoPlayer.PreloadConfiguration) {} + override fun getPreloadConfiguration(): ExoPlayer.PreloadConfiguration = throw NotImplementedError() + override fun setAudioSessionId(audioSessionId: Int) {} + override fun getAudioSessionId(): Int = throw NotImplementedError() + override fun setAuxEffectInfo(auxEffectInfo: AuxEffectInfo) {} + override fun clearAuxEffectInfo() {} + override fun setPreferredAudioDevice(audioDeviceInfo: AudioDeviceInfo?) {} + override fun setSkipSilenceEnabled(skipSilenceEnabled: Boolean) {} + override fun getSkipSilenceEnabled(): Boolean = throw NotImplementedError() + override fun setScrubbingModeEnabled(scrubbingModeEnabled: Boolean) {} + override fun isScrubbingModeEnabled(): Boolean = false + override fun setScrubbingModeParameters(scrubbingModeParameters: ScrubbingModeParameters) {} + override fun getScrubbingModeParameters(): ScrubbingModeParameters = ScrubbingModeParameters.DEFAULT + override fun setVideoEffects(videoEffects: MutableList) {} + override fun setVideoScalingMode(videoScalingMode: Int) {} + override fun getVideoScalingMode(): Int = throw NotImplementedError() + override fun setVideoChangeFrameRateStrategy(videoChangeFrameRateStrategy: Int) {} + override fun getVideoChangeFrameRateStrategy(): Int = throw NotImplementedError() + override fun setVideoFrameMetadataListener(listener: VideoFrameMetadataListener) {} + override fun clearVideoFrameMetadataListener(listener: VideoFrameMetadataListener) {} + override fun setCameraMotionListener(listener: CameraMotionListener) {} + override fun clearCameraMotionListener(listener: CameraMotionListener) {} + override fun createMessage(target: PlayerMessage.Target): PlayerMessage = throw NotImplementedError() + override fun setSeekParameters(seekParameters: SeekParameters?) {} + override fun getSeekParameters(): SeekParameters = throw NotImplementedError() + override fun setForegroundMode(foregroundMode: Boolean) {} + override fun setPauseAtEndOfMediaItems(pauseAtEndOfMediaItems: Boolean) {} + override fun getPauseAtEndOfMediaItems(): Boolean = throw NotImplementedError() + override fun getAudioFormat(): Format? = throw NotImplementedError() + override fun getVideoFormat(): Format? = throw NotImplementedError() + override fun getAudioDecoderCounters(): DecoderCounters? = throw NotImplementedError() + override fun getVideoDecoderCounters(): DecoderCounters? = throw NotImplementedError() + override fun setHandleAudioBecomingNoisy(handleAudioBecomingNoisy: Boolean) {} + override fun setWakeMode(wakeMode: Int) {} + override fun setPriority(priority: Int) {} + override fun setPriorityTaskManager(priorityTaskManager: PriorityTaskManager?) {} + override fun isSleepingForOffload(): Boolean = throw NotImplementedError() + override fun isTunnelingEnabled(): Boolean = throw NotImplementedError() + override fun isReleased(): Boolean = throw NotImplementedError() + override fun setImageOutput(imageOutput: ImageOutput?) {} +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerState.kt new file mode 100644 index 0000000..b7fe22a --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerState.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.player + +import androidx.annotation.FloatRange + +data class MediaPlayerControllerState( + val isVisible: Boolean, + val isPlaying: Boolean, + val isReady: Boolean, + val progressInMillis: Long, + val durationInMillis: Long, + val canMute: Boolean, + val isMuted: Boolean, +) { + @FloatRange(from = 0.0, to = 1.0) + val progressAsFloat = (progressInMillis.toFloat() / durationInMillis.toFloat()).coerceIn(0f, 1f) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerStateProvider.kt new file mode 100644 index 0000000..bcdef47 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerStateProvider.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.player + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class MediaPlayerControllerStateProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + aMediaPlayerControllerState(), + aMediaPlayerControllerState( + isPlaying = true, + progressInMillis = 59_000, + durationInMillis = 83_000, + isMuted = true, + ), + aMediaPlayerControllerState( + canMute = false, + ), + ) +} + +private fun aMediaPlayerControllerState( + isVisible: Boolean = true, + isPlaying: Boolean = false, + isReady: Boolean = false, + progressInMillis: Long = 0, + // Default to 1 minute and 23 seconds + durationInMillis: Long = 83_000, + canMute: Boolean = true, + isMuted: Boolean = false, +) = MediaPlayerControllerState( + isVisible = isVisible, + isPlaying = isPlaying, + isReady = isReady, + progressInMillis = progressInMillis, + durationInMillis = durationInMillis, + canMute = canMute, + isMuted = isMuted, +) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerView.kt new file mode 100644 index 0000000..7c09c02 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerView.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +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.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.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.audio.api.AudioFocus +import io.element.android.libraries.audio.api.AudioFocusRequester +import io.element.android.libraries.dateformatter.api.toHumanReadableDuration +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.Slider +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency +import io.element.android.libraries.ui.strings.CommonStrings +import timber.log.Timber + +@Composable +fun MediaPlayerControllerView( + state: MediaPlayerControllerState, + onTogglePlay: () -> Unit, + onSeekChange: (Float) -> Unit, + onToggleMute: () -> Unit, + audioFocus: AudioFocus?, + modifier: Modifier = Modifier, +) { + if (audioFocus != null) { + val latestOnTogglePlay by rememberUpdatedState(onTogglePlay) + LaunchedEffect(state.isPlaying) { + if (state.isPlaying) { + audioFocus.requestAudioFocus( + requester = AudioFocusRequester.MediaViewer, + onFocusLost = { + Timber.w("Audio focus lost") + latestOnTogglePlay() + }, + ) + } else { + audioFocus.releaseAudioFocus() + } + } + } + + AnimatedVisibility( + visible = state.isVisible, + modifier = modifier, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = Modifier + .background(color = bgCanvasWithTransparency) + .padding(horizontal = 8.dp, vertical = 4.dp), + contentAlignment = Alignment.Center, + ) { + Row( + modifier = Modifier + .widthIn(max = 480.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val bgColor = if (state.isPlaying) { + ElementTheme.colors.bgCanvasDefault + } else { + ElementTheme.colors.textPrimary + } + Box( + modifier = Modifier + .size(36.dp) + .background( + color = bgColor, + shape = CircleShape, + ) + .clip(CircleShape) + .clickable { onTogglePlay() } + .padding(8.dp), + contentAlignment = Alignment.Center, + ) { + if (state.isPlaying) { + Icon( + imageVector = CompoundIcons.PauseSolid(), + tint = ElementTheme.colors.iconPrimary, + contentDescription = stringResource(CommonStrings.a11y_pause) + ) + } else { + Icon( + imageVector = CompoundIcons.PlaySolid(), + tint = ElementTheme.colors.iconOnSolidPrimary, + contentDescription = stringResource(CommonStrings.a11y_play) + ) + } + } + Text( + modifier = Modifier + .widthIn(min = 48.dp) + .padding(horizontal = 8.dp), + text = state.progressInMillis.toHumanReadableDuration(), + textAlign = TextAlign.Center, + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyXsMedium, + ) + var lastSelectedValue by remember { mutableFloatStateOf(-1f) } + Slider( + modifier = Modifier.weight(1f), + valueRange = 0f..state.durationInMillis.toFloat(), + value = lastSelectedValue.takeIf { it >= 0 } ?: state.progressInMillis.toFloat(), + onValueChange = { + lastSelectedValue = it + }, + onValueChangeFinish = { + onSeekChange(lastSelectedValue) + lastSelectedValue = -1f + }, + useCustomLayout = true, + ) + val formattedDuration = remember(state.durationInMillis) { + state.durationInMillis.toHumanReadableDuration() + } + Text( + modifier = Modifier + .widthIn(min = 48.dp) + .padding(horizontal = 8.dp), + text = formattedDuration, + textAlign = TextAlign.Center, + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyXsMedium, + ) + if (state.canMute) { + IconButton( + onClick = onToggleMute, + ) { + if (state.isMuted) { + Icon( + imageVector = CompoundIcons.VolumeOffSolid(), + tint = ElementTheme.colors.iconPrimary, + contentDescription = stringResource(CommonStrings.common_unmute) + ) + } else { + Icon( + imageVector = CompoundIcons.VolumeOnSolid(), + tint = ElementTheme.colors.iconPrimary, + contentDescription = stringResource(CommonStrings.common_mute) + ) + } + } + } + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun MediaPlayerControllerViewPreview( + @PreviewParameter(MediaPlayerControllerStateProvider::class) state: MediaPlayerControllerState +) = ElementPreview { + MediaPlayerControllerView( + state = state, + onTogglePlay = {}, + onSeekChange = {}, + onToggleMute = {}, + audioFocus = null, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/txt/TextFileContentProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/txt/TextFileContentProvider.kt new file mode 100644 index 0000000..d92e400 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/txt/TextFileContentProvider.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.txt + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncData +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +open class TextFileContentProvider : PreviewParameterProvider>> { + override val values: Sequence>> + get() = sequenceOf( + AsyncData.Uninitialized, + AsyncData.Loading(), + AsyncData.Success(persistentListOf("Hello, World!")), + AsyncData.Failure(Exception("Failed to load text")), + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/txt/TextFileView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/txt/TextFileView.kt new file mode 100644 index 0000000..f498447 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/txt/TextFileView.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.txt + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.tooling.preview.PreviewParameter +import io.element.android.features.viewfolder.api.TextFileViewer +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.impl.viewer.topAppBarHeight +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun TextFileView( + localMedia: LocalMedia?, + textFileViewer: TextFileViewer, + modifier: Modifier = Modifier, +) { + val data = remember { mutableStateOf>>(AsyncData.Uninitialized) } + val context = LocalContext.current + LaunchedEffect(localMedia?.uri) { + data.value = AsyncData.Loading() + if (localMedia?.uri != null) { + // Load the file content + val result = runCatchingExceptions { + context.contentResolver.openInputStream(localMedia.uri).use { + it?.bufferedReader()?.readLines()?.toList().orEmpty() + } + } + data.value = if (result.isSuccess) { + AsyncData.Success(result.getOrNull().orEmpty().toImmutableList()) + } else { + AsyncData.Failure(result.exceptionOrNull() ?: Exception("An error occurred")) + } + } + } + TextFileContentView( + data = data.value, + textFileViewer = textFileViewer, + modifier = modifier, + ) +} + +@Composable +private fun TextFileContentView( + data: AsyncData>, + textFileViewer: TextFileViewer, + modifier: Modifier = Modifier, +) { + when (data) { + AsyncData.Uninitialized, + is AsyncData.Loading -> Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + is AsyncData.Failure -> Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(text = data.error.message ?: stringResource(id = CommonStrings.error_unknown)) + } + is AsyncData.Success -> { + textFileViewer.Render( + lines = data.data, + modifier = modifier + .fillMaxSize() + .padding(top = topAppBarHeight), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TextFileContentViewPreview( + @PreviewParameter(TextFileContentProvider::class) text: AsyncData>, +) = ElementPreview { + TextFileContentView( + data = text, + textFileViewer = { lines, modifier -> + Text( + modifier = modifier, + text = lines.firstOrNull() ?: "File content" + ) + } + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt new file mode 100644 index 0000000..65148f3 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt @@ -0,0 +1,324 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.video + +import android.annotation.SuppressLint +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.widget.FrameLayout +import androidx.annotation.OptIn +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.Player.STATE_READY +import androidx.media3.common.Timeline +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.audio.api.AudioFocus +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.toDp +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.KeepScreenOn +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState +import io.element.android.libraries.mediaviewer.impl.local.PlayableState +import io.element.android.libraries.mediaviewer.impl.local.player.MediaPlayerControllerState +import io.element.android.libraries.mediaviewer.impl.local.player.MediaPlayerControllerView +import io.element.android.libraries.mediaviewer.impl.local.player.rememberExoPlayer +import io.element.android.libraries.mediaviewer.impl.local.player.seekToEnsurePlaying +import io.element.android.libraries.mediaviewer.impl.local.player.togglePlay +import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState +import kotlinx.coroutines.delay +import me.saket.telephoto.zoomable.zoomable +import timber.log.Timber +import kotlin.time.Duration.Companion.seconds + +@SuppressLint("UnsafeOptInUsageError") +@Composable +fun MediaVideoView( + isDisplayed: Boolean, + localMediaViewState: LocalMediaViewState, + bottomPaddingInPixels: Int, + localMedia: LocalMedia?, + autoplay: Boolean, + audioFocus: AudioFocus?, + modifier: Modifier = Modifier, +) { + val exoPlayer = rememberExoPlayer() + ExoPlayerMediaVideoView( + isDisplayed = isDisplayed, + localMediaViewState = localMediaViewState, + bottomPaddingInPixels = bottomPaddingInPixels, + exoPlayer = exoPlayer, + localMedia = localMedia, + autoplay = autoplay, + audioFocus = audioFocus, + modifier = modifier, + ) +} + +@SuppressLint("UnsafeOptInUsageError") +@Composable +private fun ExoPlayerMediaVideoView( + isDisplayed: Boolean, + localMediaViewState: LocalMediaViewState, + bottomPaddingInPixels: Int, + exoPlayer: ExoPlayer, + localMedia: LocalMedia?, + autoplay: Boolean, + audioFocus: AudioFocus?, + modifier: Modifier = Modifier, +) { + var mediaPlayerControllerState: MediaPlayerControllerState by remember { + mutableStateOf( + MediaPlayerControllerState( + isVisible = true, + isPlaying = false, + isReady = false, + progressInMillis = 0, + durationInMillis = 0, + canMute = true, + isMuted = false, + ) + ) + } + + val playableState: PlayableState.Playable by remember { + derivedStateOf { + PlayableState.Playable( + isShowingControls = mediaPlayerControllerState.isVisible, + ) + } + } + + localMediaViewState.playableState = playableState + + val playerListener = remember { + object : Player.Listener { + override fun onRenderedFirstFrame() { + localMediaViewState.isReady = true + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + mediaPlayerControllerState = mediaPlayerControllerState.copy( + isPlaying = isPlaying, + ) + } + + override fun onVolumeChanged(volume: Float) { + mediaPlayerControllerState = mediaPlayerControllerState.copy( + isMuted = volume == 0f, + ) + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) { + exoPlayer.duration.takeIf { it >= 0 } + ?.let { + mediaPlayerControllerState = mediaPlayerControllerState.copy( + durationInMillis = it, + ) + } + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + mediaPlayerControllerState = mediaPlayerControllerState.copy( + isReady = playbackState == STATE_READY, + ) + } + } + } + + var autoHideController by remember { mutableIntStateOf(0) } + + LaunchedEffect(autoHideController) { + delay(5.seconds) + if (exoPlayer.isPlaying) { + mediaPlayerControllerState = mediaPlayerControllerState.copy( + isVisible = false, + ) + } + } + + if (localMedia?.uri != null) { + LaunchedEffect(localMedia.uri) { + val mediaItem = MediaItem.fromUri(localMedia.uri) + exoPlayer.setMediaItem(mediaItem) + } + } else { + exoPlayer.setMediaItems(emptyList()) + } + KeepScreenOn(mediaPlayerControllerState.isPlaying) + Box( + modifier = modifier + .background(ElementTheme.colors.bgSubtlePrimary), + ) { + val context = LocalContext.current + if (LocalInspectionMode.current) { + Text( + modifier = Modifier + .background(ElementTheme.colors.bgSubtlePrimary) + .align(Alignment.Center), + text = "A Video Player will render here", + ) + } else { + AndroidView( + modifier = Modifier + .fillMaxSize() + .zoomable( + state = localMediaViewState.zoomableState, + onClick = { + autoHideController++ + mediaPlayerControllerState = mediaPlayerControllerState.copy( + isVisible = !mediaPlayerControllerState.isVisible, + ) + } + ), + factory = { + PlayerView(context).apply { + player = exoPlayer + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) + useController = false + } + }, + onRelease = { playerView -> + playerView.player = null + }, + ) + } + MediaPlayerControllerView( + state = mediaPlayerControllerState, + onTogglePlay = { + autoHideController++ + exoPlayer.togglePlay() + }, + onSeekChange = { + autoHideController++ + exoPlayer.seekToEnsurePlaying(it.toLong()) + }, + onToggleMute = { + autoHideController++ + exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f + }, + audioFocus = audioFocus, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(bottom = bottomPaddingInPixels.toDp()), + ) + } + + LaunchedEffect(exoPlayer.isPlaying) { + if (exoPlayer.isPlaying) { + while (true) { + mediaPlayerControllerState = mediaPlayerControllerState.copy( + progressInMillis = exoPlayer.currentPosition, + ) + delay(200) + } + } else { + // Ensure we render the final state + mediaPlayerControllerState = mediaPlayerControllerState.copy( + progressInMillis = exoPlayer.currentPosition, + ) + } + } + + ExoPlayerLifecycleHelper( + exoPlayer = exoPlayer, + autoplay = autoplay, + isDisplayed = isDisplayed, + playerListener = playerListener, + mediaPlayerControllerState = mediaPlayerControllerState, + ) +} + +@OptIn(UnstableApi::class) +@Composable +private fun ExoPlayerLifecycleHelper( + exoPlayer: ExoPlayer, + autoplay: Boolean, + isDisplayed: Boolean, + playerListener: Player.Listener, + mediaPlayerControllerState: MediaPlayerControllerState, +) { + // Prepare and release the exoPlayer with the composable lifecycle + DisposableEffect(Unit) { + Timber.d("ExoPlayerMediaVideoView DisposableEffect: initializing exoPlayer") + exoPlayer.addListener(playerListener) + exoPlayer.prepare() + + onDispose { + Timber.d("Disposing exoplayer") + if (!exoPlayer.isReleased) { + exoPlayer.removeListener(playerListener) + exoPlayer.release() + } + } + } + + var needsAutoPlay by remember { mutableStateOf(autoplay) } + LaunchedEffect(needsAutoPlay, isDisplayed, mediaPlayerControllerState.isReady) { + val isReadyAndNotPlaying = mediaPlayerControllerState.isReady && !mediaPlayerControllerState.isPlaying + if (needsAutoPlay && isDisplayed && isReadyAndNotPlaying) { + // When displayed, start autoplaying + exoPlayer.play() + needsAutoPlay = false + } else if (!isDisplayed && mediaPlayerControllerState.isPlaying) { + // If not displayed, make sure to pause the video + exoPlayer.pause() + } + } + + // Pause playback when lifecycle is paused + OnLifecycleEvent { _, event -> + if (event == Lifecycle.Event.ON_PAUSE && exoPlayer.isPlaying) { + exoPlayer.pause() + } + } +} + +@PreviewsDayNight +@Composable +internal fun MediaVideoViewPreview() = ElementPreview { + MediaVideoView( + isDisplayed = true, + modifier = Modifier.fillMaxSize(), + bottomPaddingInPixels = 0, + localMediaViewState = rememberLocalMediaViewState(), + localMedia = null, + audioFocus = null, + autoplay = false, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/GroupedMediaItems.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/GroupedMediaItems.kt new file mode 100644 index 0000000..1662afc --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/GroupedMediaItems.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.model + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode +import kotlinx.collections.immutable.ImmutableList + +data class GroupedMediaItems( + val imageAndVideoItems: ImmutableList, + val fileItems: ImmutableList, +) { + fun getItems(mode: MediaGalleryMode): ImmutableList { + return when (mode) { + MediaGalleryMode.Images -> imageAndVideoItems + MediaGalleryMode.Files -> fileItems + } + } +} + +fun GroupedMediaItems.hasEvent(eventId: EventId): Boolean { + return (fileItems + imageAndVideoItems) + .filterIsInstance() + .any { it.eventId() == eventId } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt new file mode 100644 index 0000000..90708db --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.model + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.mediaviewer.api.MediaInfo + +sealed interface MediaItem { + data class DateSeparator( + val id: UniqueId, + val formattedDate: String, + ) : MediaItem + + data class LoadingIndicator( + val id: UniqueId, + val direction: Timeline.PaginationDirection, + val timestamp: Long, + ) : MediaItem + + sealed interface Event : MediaItem + + data class Image( + val id: UniqueId, + val eventId: EventId?, + val mediaInfo: MediaInfo, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + ) : Event { + val thumbnailMediaRequestData: MediaRequestData + get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100)) + } + + data class Video( + val id: UniqueId, + val eventId: EventId?, + val mediaInfo: MediaInfo, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + ) : Event { + val thumbnailMediaRequestData: MediaRequestData + get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100)) + } + + data class Audio( + val id: UniqueId, + val eventId: EventId?, + val mediaInfo: MediaInfo, + val mediaSource: MediaSource, + ) : Event + + data class Voice( + val id: UniqueId, + val eventId: EventId?, + val mediaInfo: MediaInfo, + val mediaSource: MediaSource, + ) : Event + + data class File( + val id: UniqueId, + val eventId: EventId?, + val mediaInfo: MediaInfo, + val mediaSource: MediaSource, + ) : Event +} + +fun MediaItem.id(): UniqueId { + return when (this) { + is MediaItem.DateSeparator -> id + is MediaItem.LoadingIndicator -> id + is MediaItem.Image -> id + is MediaItem.Video -> id + is MediaItem.File -> id + is MediaItem.Audio -> id + is MediaItem.Voice -> id + } +} + +fun MediaItem.Event.eventId(): EventId? { + return when (this) { + is MediaItem.Image -> eventId + is MediaItem.Video -> eventId + is MediaItem.File -> eventId + is MediaItem.Audio -> eventId + is MediaItem.Voice -> eventId + } +} + +fun MediaItem.Event.mediaInfo(): MediaInfo { + return when (this) { + is MediaItem.Image -> mediaInfo + is MediaItem.Video -> mediaInfo + is MediaItem.File -> mediaInfo + is MediaItem.Audio -> mediaInfo + is MediaItem.Voice -> mediaInfo + } +} + +fun MediaItem.Event.mediaSource(): MediaSource { + return when (this) { + is MediaItem.Image -> mediaSource + is MediaItem.Video -> mediaSource + is MediaItem.File -> mediaSource + is MediaItem.Audio -> mediaSource + is MediaItem.Voice -> mediaSource + } +} + +fun MediaItem.Event.thumbnailSource(): MediaSource? { + return when (this) { + is MediaItem.Image -> thumbnailSource + is MediaItem.Video -> thumbnailSource + is MediaItem.File -> null + is MediaItem.Audio -> null + is MediaItem.Voice -> null + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItemFactories.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItemFactories.kt new file mode 100644 index 0000000..be73787 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItemFactories.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.model + +import io.element.android.libraries.designsystem.components.media.WaveFormSamples +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UniqueId +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.Timeline +import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo +import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo +import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo +import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo +import io.element.android.libraries.mediaviewer.api.anImageMediaInfo + +fun aMediaItemImage( + id: UniqueId = UniqueId("imageId"), + eventId: EventId? = null, + senderId: UserId? = null, + mediaSourceUrl: String = "", +): MediaItem.Image { + return MediaItem.Image( + id = id, + eventId = eventId, + mediaInfo = anImageMediaInfo( + senderId = senderId, + ), + mediaSource = MediaSource(mediaSourceUrl), + thumbnailSource = null, + ) +} + +fun aMediaItemVideo( + id: UniqueId = UniqueId("videoId"), + mediaSource: MediaSource = MediaSource(""), + duration: String? = "1:23", +): MediaItem.Video { + return MediaItem.Video( + id = id, + eventId = null, + mediaInfo = aVideoMediaInfo( + duration = duration + ), + mediaSource = mediaSource, + thumbnailSource = null, + ) +} + +fun aMediaItemFile( + id: UniqueId = UniqueId("fileId"), + eventId: EventId? = null, + filename: String = "filename", + caption: String? = null, +): MediaItem.File { + return MediaItem.File( + id = id, + eventId = eventId, + mediaInfo = aPdfMediaInfo( + filename = filename, + caption = caption, + ), + mediaSource = MediaSource(""), + ) +} + +fun aMediaItemAudio( + id: UniqueId = UniqueId("fileId"), + eventId: EventId? = null, + filename: String = "filename", + caption: String? = null, +): MediaItem.Audio { + return MediaItem.Audio( + id = id, + eventId = eventId, + mediaInfo = anAudioMediaInfo( + filename = filename, + caption = caption, + ), + mediaSource = MediaSource(""), + ) +} + +fun aMediaItemVoice( + id: UniqueId = UniqueId("fileId"), + filename: String = "filename.ogg", + caption: String? = null, + duration: String? = "1:23", + waveform: List = WaveFormSamples.realisticWaveForm, +): MediaItem.Voice { + return MediaItem.Voice( + id = id, + eventId = null, + mediaInfo = aVoiceMediaInfo( + filename = filename, + caption = caption, + duration = duration, + waveForm = waveform, + ), + mediaSource = MediaSource(""), + ) +} + +fun aMediaItemDateSeparator( + id: UniqueId = UniqueId("dateId"), + formattedDate: String = "October 2024", +): MediaItem.DateSeparator { + return MediaItem.DateSeparator( + id = id, + formattedDate = formattedDate, + ) +} + +fun aMediaItemLoadingIndicator( + id: UniqueId = UniqueId("loadingId"), + direction: Timeline.PaginationDirection = Timeline.PaginationDirection.BACKWARDS, +): MediaItem.LoadingIndicator { + return MediaItem.LoadingIndicator( + id = id, + direction = direction, + timestamp = 123, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/util/Colors.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/util/Colors.kt new file mode 100644 index 0000000..4d110b5 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/util/Colors.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.util + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import io.element.android.compound.theme.ElementTheme + +val bgCanvasWithTransparency: Color + @Composable + get() = ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.6f) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/util/FileExtensionExtractorWithValidation.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/util/FileExtensionExtractorWithValidation.kt new file mode 100644 index 0000000..cb390ad --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/util/FileExtensionExtractorWithValidation.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.util + +import android.webkit.MimeTypeMap +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor + +@ContributesBinding(AppScope::class) +class FileExtensionExtractorWithValidation : 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" + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt new file mode 100644 index 0000000..ae7b54c --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.extensions.mapCatchingExceptions +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.timeline.Timeline +import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint.MediaViewerMode +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory +import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.eventId +import io.element.android.libraries.mediaviewer.impl.model.mediaInfo +import io.element.android.libraries.mediaviewer.impl.model.mediaSource +import io.element.android.libraries.mediaviewer.impl.model.thumbnailSource +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import timber.log.Timber + +class MediaViewerDataSource( + mode: MediaViewerMode, + private val dispatcher: CoroutineDispatcher, + private val galleryDataSource: MediaGalleryDataSource, + private val mediaLoader: MatrixMediaLoader, + private val localMediaFactory: LocalMediaFactory, + private val systemClock: SystemClock, + private val pagerKeysHandler: PagerKeysHandler, +) { + // List of media files that are currently being loaded + private val mediaFiles: MutableList = mutableListOf() + + private val galleryMode = when (mode) { + MediaViewerMode.SingleMedia, + is MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images + is MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files + } + + // Map of sourceUrl to local media state + private val localMediaStates: MutableMap>> = + mutableMapOf() + + fun setup() { + galleryDataSource.start() + } + + fun dispose() { + mediaFiles.forEach { it.close() } + mediaFiles.clear() + localMediaStates.clear() + } + + @Composable + fun collectAsState(): State> { + return remember { dataFlow() }.collectAsState(initialData()) + } + + @VisibleForTesting + internal fun dataFlow(): Flow> { + return galleryDataSource.groupedMediaItemsFlow() + .map { groupedItems -> + when (groupedItems) { + AsyncData.Uninitialized, + is AsyncData.Loading -> { + persistentListOf( + MediaViewerPageData.Loading( + direction = Timeline.PaginationDirection.BACKWARDS, + timestamp = systemClock.epochMillis(), + pagerKey = Long.MIN_VALUE, + ) + ) + } + is AsyncData.Failure -> { + persistentListOf( + MediaViewerPageData.Failure(groupedItems.error), + ) + } + is AsyncData.Success -> { + withContext(dispatcher) { + val mediaItems = groupedItems.data.getItems(galleryMode) + buildMediaViewerPageList(mediaItems) + } + } + } + } + } + + private fun initialData(): ImmutableList { + val initialMediaItems = + galleryDataSource.getLastData().dataOrNull()?.getItems(galleryMode).orEmpty() + return buildMediaViewerPageList(initialMediaItems) + } + + /** + * Build a list of [MediaViewerPageData] from a list of [MediaItem]. + * In particular, create a mutable state of AsyncData for each media item, which + * will be used to render the downloaded media (see [loadMedia] which will update this value). + */ + private fun buildMediaViewerPageList(groupedItems: List) = buildList { + // Filter out DateSeparator items, we do not need them for the media viewer + val groupedItemsNoDateSeparator = groupedItems.filterNot { it is MediaItem.DateSeparator } + pagerKeysHandler.accept(groupedItemsNoDateSeparator) + groupedItemsNoDateSeparator.forEach { mediaItem -> + when (mediaItem) { + is MediaItem.DateSeparator -> Unit + is MediaItem.Event -> { + val sourceUrl = mediaItem.mediaSource().url + val localMedia = localMediaStates.getOrPut(sourceUrl) { + mutableStateOf(AsyncData.Uninitialized) + } + add( + MediaViewerPageData.MediaViewerData( + eventId = mediaItem.eventId(), + mediaInfo = mediaItem.mediaInfo(), + mediaSource = mediaItem.mediaSource(), + thumbnailSource = mediaItem.thumbnailSource(), + downloadedMedia = localMedia, + pagerKey = pagerKeysHandler.getKey(mediaItem), + ) + ) + } + is MediaItem.LoadingIndicator -> add( + MediaViewerPageData.Loading( + direction = mediaItem.direction, + timestamp = systemClock.epochMillis(), + pagerKey = pagerKeysHandler.getKey(mediaItem), + ) + ) + } + } + }.toImmutableList() + + fun clearLoadingError(data: MediaViewerPageData.MediaViewerData) { + localMediaStates[data.mediaSource.url]?.value = AsyncData.Uninitialized + } + + suspend fun loadMore(direction: Timeline.PaginationDirection) { + galleryDataSource.loadMore(direction) + } + + suspend fun loadMedia(data: MediaViewerPageData.MediaViewerData) { + Timber.d("loadMedia for ${data.eventId}") + val localMediaState = localMediaStates.getOrPut(data.mediaSource.url) { + mutableStateOf(AsyncData.Uninitialized) + } + localMediaState.value = AsyncData.Loading() + mediaLoader + .downloadMediaFile( + source = data.mediaSource, + mimeType = data.mediaInfo.mimeType, + filename = data.mediaInfo.filename + ) + .onSuccess { mediaFile -> + mediaFiles.add(mediaFile) + } + .mapCatchingExceptions { mediaFile -> + localMediaFactory.createFromMediaFile( + mediaFile = mediaFile, + mediaInfo = data.mediaInfo + ) + } + .onSuccess { + localMediaState.value = AsyncData.Success(it) + } + .onFailure { + localMediaState.value = AsyncData.Failure(it) + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt new file mode 100644 index 0000000..3f1436b --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline + +sealed interface MediaViewerEvents { + data class LoadMedia(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents + data class SaveOnDisk(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents + data class Share(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents + data class OpenWith(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents + data class ClearLoadingError(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents + data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents + data class Forward(val eventId: EventId) : MediaViewerEvents + data class OpenInfo(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents + data class ConfirmDelete( + val eventId: EventId, + val data: MediaViewerPageData.MediaViewerData, + ) : MediaViewerEvents + + data object CloseBottomSheet : MediaViewerEvents + data class Delete(val eventId: EventId) : MediaViewerEvents + data class OnNavigateTo(val index: Int) : MediaViewerEvents + data class LoadMore(val direction: Timeline.PaginationDirection) : MediaViewerEvents +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt new file mode 100644 index 0000000..b35b638 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.androidutils.system.areAnimationsEnabled +import kotlinx.coroutines.delay +import me.saket.telephoto.ExperimentalTelephotoApi +import me.saket.telephoto.flick.FlickToDismiss +import me.saket.telephoto.flick.FlickToDismissState +import me.saket.telephoto.flick.rememberFlickToDismissState +import kotlin.time.Duration + +@OptIn(ExperimentalTelephotoApi::class) +@Composable +fun MediaViewerFlickToDismiss( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + onDragging: () -> Unit = {}, + onResetting: () -> Unit = {}, + content: @Composable BoxScope.() -> Unit, +) { + val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false) + val context = LocalContext.current + DismissFlickEffects( + flickState = flickState, + onDismissing = { animationDuration -> + // Only add the delay if an animation should be played, otherwise `onDismiss` will never be called + if (context.areAnimationsEnabled()) { + delay(animationDuration / 3) + } + onDismiss() + }, + onDragging = onDragging, + onResetting = onResetting, + ) + FlickToDismiss( + state = flickState, + modifier = modifier.background(backgroundColorFor(flickState)), + content = content, + ) +} + +@Composable +private fun DismissFlickEffects( + flickState: FlickToDismissState, + onDismissing: suspend (Duration) -> Unit, + onDragging: suspend () -> Unit, + onResetting: suspend () -> Unit, +) { + val currentOnDismissing by rememberUpdatedState(onDismissing) + val currentOnDragging by rememberUpdatedState(onDragging) + val currentOnResetting by rememberUpdatedState(onResetting) + + when (val gestureState = flickState.gestureState) { + is FlickToDismissState.GestureState.Dismissing -> { + LaunchedEffect(Unit) { + currentOnDismissing(gestureState.animationDuration) + } + } + is FlickToDismissState.GestureState.Dragging -> { + LaunchedEffect(Unit) { + currentOnDragging() + } + } + is FlickToDismissState.GestureState.Resetting -> { + LaunchedEffect(Unit) { + currentOnResetting() + } + } + else -> Unit + } +} + +@Composable +private fun backgroundColorFor(flickState: FlickToDismissState): Color { + val animatedAlpha by animateFloatAsState( + targetValue = when (flickState.gestureState) { + is FlickToDismissState.GestureState.Dismissed, + is FlickToDismissState.GestureState.Dismissing -> 0f + is FlickToDismissState.GestureState.Dragging, + is FlickToDismissState.GestureState.Idle, + is FlickToDismissState.GestureState.Resetting -> 1f - flickState.offsetFraction + }, + label = "Background alpha", + ) + return ElementTheme.colors.bgCanvasDefault.copy(alpha = animatedAlpha) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNavigator.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNavigator.kt new file mode 100644 index 0000000..327505d --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNavigator.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import io.element.android.libraries.matrix.api.core.EventId + +interface MediaViewerNavigator { + fun onViewInTimelineClick(eventId: EventId) + fun onForwardClick(eventId: EventId, fromPinnedEvents: Boolean) + fun onItemDeleted() +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt new file mode 100644 index 0000000..d834534 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.compound.colors.SemanticColorsLightDark +import io.element.android.compound.theme.ForcedDarkElementTheme +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.viewfolder.api.TextFileViewer +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.audio.api.AudioFocus +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.RoomScope +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.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint +import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory +import io.element.android.libraries.mediaviewer.impl.datasource.FocusedTimelineMediaGalleryDataSourceFactory +import io.element.android.libraries.mediaviewer.impl.datasource.TimelineMediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.model.hasEvent +import io.element.android.services.toolbox.api.systemclock.SystemClock + +@ContributesNode(RoomScope::class) +@AssistedInject +class MediaViewerNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: MediaViewerPresenter.Factory, + timelineMediaGalleryDataSource: TimelineMediaGalleryDataSource, + focusedTimelineMediaGalleryDataSourceFactory: FocusedTimelineMediaGalleryDataSourceFactory, + mediaLoader: MatrixMediaLoader, + localMediaFactory: LocalMediaFactory, + coroutineDispatchers: CoroutineDispatchers, + systemClock: SystemClock, + pagerKeysHandler: PagerKeysHandler, + private val textFileViewer: TextFileViewer, + private val audioFocus: AudioFocus, + private val sessionId: SessionId, + private val enterpriseService: EnterpriseService, +) : Node(buildContext, plugins = plugins), + MediaViewerNavigator { + private val callback: MediaViewerEntryPoint.Callback = callback() + private val inputs = inputs() + + override fun onViewInTimelineClick(eventId: EventId) { + callback.viewInTimeline(eventId) + } + + override fun onForwardClick(eventId: EventId, fromPinnedEvents: Boolean) { + callback.forwardEvent(eventId, fromPinnedEvents) + } + + override fun onItemDeleted() { + callback.onDone() + } + + private val mediaGallerySource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) { + SingleMediaGalleryDataSource.createFrom(inputs) + } else { + val eventId = inputs.eventId + if (eventId == null) { + // Should not happen + timelineMediaGalleryDataSource + } else { + // Can we use a specific timeline? + val timelineMode = inputs.mode.getTimelineMode() + when (timelineMode) { + null -> timelineMediaGalleryDataSource + Timeline.Mode.Live, + is Timeline.Mode.FocusedOnEvent, + is Timeline.Mode.Thread -> { + // Does timelineMediaGalleryDataSource knows the eventId? + val lastData = timelineMediaGalleryDataSource.getLastData().dataOrNull() + val isEventKnown = lastData?.hasEvent(eventId) == true + if (isEventKnown) { + timelineMediaGalleryDataSource + } else { + focusedTimelineMediaGalleryDataSourceFactory.createFor( + eventId = eventId, + mediaItem = inputs.toMediaItem(), + onlyPinnedEvents = false, + ) + } + } + Timeline.Mode.PinnedEvents -> { + focusedTimelineMediaGalleryDataSourceFactory.createFor( + eventId = eventId, + mediaItem = inputs.toMediaItem(), + onlyPinnedEvents = true, + ) + } + Timeline.Mode.Media -> timelineMediaGalleryDataSource + } + } + } + + private val presenter = presenterFactory.create( + inputs = inputs, + navigator = this, + dataSource = MediaViewerDataSource( + mode = inputs.mode, + dispatcher = coroutineDispatchers.computation, + galleryDataSource = mediaGallerySource, + mediaLoader = mediaLoader, + localMediaFactory = localMediaFactory, + systemClock = systemClock, + pagerKeysHandler = pagerKeysHandler, + ) + ) + + @Composable + override fun View(modifier: Modifier) { + val colors by remember { + enterpriseService.semanticColorsFlow(sessionId = sessionId) + }.collectAsState(SemanticColorsLightDark.default) + ForcedDarkElementTheme( + colors = colors, + ) { + val state = presenter.present() + MediaViewerView( + state = state, + textFileViewer = textFileViewer, + modifier = modifier, + audioFocus = audioFocus, + onBackClick = callback::onDone, + ) + } + } +} + +internal fun MediaViewerEntryPoint.MediaViewerMode.getTimelineMode(): Timeline.Mode? { + return when (this) { + is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> timelineMode + is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> timelineMode + else -> null + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt new file mode 100644 index 0000000..4709a4e --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt @@ -0,0 +1,296 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import android.content.ActivityNotFoundException +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.IntState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther +import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.impl.R +import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState +import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import io.element.android.libraries.androidutils.R as UtilsR + +@AssistedInject +class MediaViewerPresenter( + @Assisted private val inputs: MediaViewerEntryPoint.Params, + @Assisted private val navigator: MediaViewerNavigator, + @Assisted private val dataSource: MediaViewerDataSource, + private val room: JoinedRoom, + private val localMediaActions: LocalMediaActions, +) : Presenter { + @AssistedFactory + fun interface Factory { + fun create( + inputs: MediaViewerEntryPoint.Params, + navigator: MediaViewerNavigator, + dataSource: MediaViewerDataSource, + ): MediaViewerPresenter + } + + // Use a local snackbarDispatcher because this presenter is used in an Overlay Node + private val snackbarDispatcher = SnackbarDispatcher() + + @Composable + override fun present(): MediaViewerState { + val coroutineScope = rememberCoroutineScope() + val data = dataSource.collectAsState() + val currentIndex = remember { mutableIntStateOf(searchIndex(data.value, inputs.eventId)) } + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + + NoMoreItemsBackwardSnackBarDisplayer(currentIndex, data) + NoMoreItemsForwardSnackBarDisplayer(currentIndex, data) + + var mediaBottomSheetState by remember { mutableStateOf(MediaBottomSheetState.Hidden) } + + DisposableEffect(Unit) { + dataSource.setup() + onDispose { + dataSource.dispose() + } + } + localMediaActions.Configure() + + fun handleEvent(event: MediaViewerEvents) { + when (event) { + is MediaViewerEvents.LoadMedia -> { + coroutineScope.downloadMedia(data = event.data) + } + is MediaViewerEvents.ClearLoadingError -> { + dataSource.clearLoadingError(event.data) + } + is MediaViewerEvents.SaveOnDisk -> { + mediaBottomSheetState = MediaBottomSheetState.Hidden + coroutineScope.saveOnDisk(event.data.downloadedMedia.value) + } + is MediaViewerEvents.Share -> { + mediaBottomSheetState = MediaBottomSheetState.Hidden + coroutineScope.share(event.data.downloadedMedia.value) + } + is MediaViewerEvents.OpenWith -> { + mediaBottomSheetState = MediaBottomSheetState.Hidden + coroutineScope.open(event.data.downloadedMedia.value) + } + is MediaViewerEvents.Delete -> { + mediaBottomSheetState = MediaBottomSheetState.Hidden + coroutineScope.delete(event.eventId) + } + is MediaViewerEvents.ViewInTimeline -> { + mediaBottomSheetState = MediaBottomSheetState.Hidden + navigator.onViewInTimelineClick(event.eventId) + } + is MediaViewerEvents.Forward -> { + mediaBottomSheetState = MediaBottomSheetState.Hidden + navigator.onForwardClick( + eventId = event.eventId, + fromPinnedEvents = inputs.mode.getTimelineMode() == Timeline.Mode.PinnedEvents, + ) + } + is MediaViewerEvents.OpenInfo -> coroutineScope.launch { + mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState( + eventId = event.data.eventId, + canDelete = when (event.data.mediaInfo.senderId) { + null -> false + room.sessionId -> room.canRedactOwn().getOrElse { false } && event.data.eventId != null + else -> room.canRedactOther().getOrElse { false } && event.data.eventId != null + }, + mediaInfo = event.data.mediaInfo, + thumbnailSource = event.data.thumbnailSource, + ) + } + is MediaViewerEvents.ConfirmDelete -> { + mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState( + eventId = event.eventId, + mediaInfo = event.data.mediaInfo, + thumbnailSource = event.data.thumbnailSource ?: event.data.mediaSource, + ) + } + MediaViewerEvents.CloseBottomSheet -> { + mediaBottomSheetState = MediaBottomSheetState.Hidden + } + is MediaViewerEvents.OnNavigateTo -> { + currentIndex.intValue = event.index + } + is MediaViewerEvents.LoadMore -> coroutineScope.launch { + dataSource.loadMore(event.direction) + } + } + } + + return MediaViewerState( + initiallySelectedEventId = inputs.eventId, + listData = data.value, + currentIndex = currentIndex.intValue, + snackbarMessage = snackbarMessage, + canShowInfo = inputs.canShowInfo, + mediaBottomSheetState = mediaBottomSheetState, + eventSink = ::handleEvent, + ) + } + + @Composable + private fun NoMoreItemsBackwardSnackBarDisplayer( + currentIndex: IntState, + data: State>, + ) { + val isRenderingLoadingBackward by remember { + derivedStateOf { + currentIndex.intValue == data.value.lastIndex && + data.value.size > 1 && + data.value.lastOrNull() is MediaViewerPageData.Loading + } + } + if (isRenderingLoadingBackward) { + LaunchedEffect(Unit) { + // Observe the loading data vanishing + snapshotFlow { data.value.lastOrNull() is MediaViewerPageData.Loading } + .distinctUntilChanged() + .filter { !it } + .onEach { showNoMoreItemsSnackbar() } + .launchIn(this) + } + } + } + + @Composable + private fun NoMoreItemsForwardSnackBarDisplayer( + currentIndex: IntState, + data: State>, + ) { + val isRenderingLoadingForward by remember { + derivedStateOf { + currentIndex.intValue == 0 && + data.value.size > 1 && + data.value.firstOrNull() is MediaViewerPageData.Loading + } + } + if (isRenderingLoadingForward) { + LaunchedEffect(Unit) { + // Observe the loading data vanishing + snapshotFlow { data.value.firstOrNull() is MediaViewerPageData.Loading } + .distinctUntilChanged() + .filter { !it } + .onEach { showNoMoreItemsSnackbar() } + .launchIn(this) + } + } + } + + private fun showNoMoreItemsSnackbar() { + val messageResId = when (inputs.mode) { + MediaViewerEntryPoint.MediaViewerMode.SingleMedia, + is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> R.string.screen_media_details_no_more_media_to_show + is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> R.string.screen_media_details_no_more_files_to_show + } + val message = SnackbarMessage(messageResId) + snackbarDispatcher.post(message) + } + + private fun CoroutineScope.downloadMedia( + data: MediaViewerPageData.MediaViewerData, + ) = launch { + dataSource.loadMedia(data) + } + + private fun CoroutineScope.saveOnDisk(localMedia: AsyncData) = launch { + if (localMedia is AsyncData.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) + } + } + } + + private fun CoroutineScope.delete(eventId: EventId) = launch { + room.liveTimeline.redactEvent(eventId.toEventOrTransactionId(), null) + .onFailure { + val snackbarMessage = SnackbarMessage(CommonStrings.error_unknown) + snackbarDispatcher.post(snackbarMessage) + } + .onSuccess { + navigator.onItemDeleted() + } + } + + private fun CoroutineScope.share(localMedia: AsyncData) = launch { + if (localMedia is AsyncData.Success) { + localMediaActions.share(localMedia.data) + .onFailure { + val snackbarMessage = SnackbarMessage(mediaActionsError(it)) + snackbarDispatcher.post(snackbarMessage) + } + } + } + + private fun CoroutineScope.open(localMedia: AsyncData) = launch { + if (localMedia is AsyncData.Success) { + localMediaActions.open(localMedia.data) + .onFailure { + val snackbarMessage = SnackbarMessage(mediaActionsError(it)) + snackbarDispatcher.post(snackbarMessage) + } + } + } + + private fun mediaActionsError(throwable: Throwable): Int { + return if (throwable is ActivityNotFoundException) { + UtilsR.string.error_no_compatible_app_found + } else { + CommonStrings.error_unknown + } + } + + private fun searchIndex(data: List, eventId: EventId?): Int { + if (eventId == null) { + return 0 + } + return data.indexOfFirst { + (it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId + }.coerceAtLeast(0) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt new file mode 100644 index 0000000..ae1a422 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import androidx.compose.runtime.State +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState +import kotlinx.collections.immutable.ImmutableList + +data class MediaViewerState( + val initiallySelectedEventId: EventId?, + val listData: ImmutableList, + val currentIndex: Int, + val snackbarMessage: SnackbarMessage?, + val canShowInfo: Boolean, + val mediaBottomSheetState: MediaBottomSheetState, + val eventSink: (MediaViewerEvents) -> Unit, +) + +sealed interface MediaViewerPageData { + val pagerKey: Long + + data class Failure( + val throwable: Throwable, + override val pagerKey: Long = 0, + ) : MediaViewerPageData + + data class Loading( + val direction: Timeline.PaginationDirection, + val timestamp: Long, + override val pagerKey: Long, + ) : MediaViewerPageData + + data class MediaViewerData( + val eventId: EventId?, + val mediaInfo: MediaInfo, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + val downloadedMedia: State>, + override val pagerKey: Long, + ) : MediaViewerPageData +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt new file mode 100644 index 0000000..85aecc4 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import android.net.Uri +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.media.WaveFormSamples +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo +import io.element.android.libraries.mediaviewer.api.aTxtMediaInfo +import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo +import io.element.android.libraries.mediaviewer.api.anApkMediaInfo +import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo +import io.element.android.libraries.mediaviewer.api.anImageMediaInfo +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState +import io.element.android.libraries.mediaviewer.impl.details.aMediaDeleteConfirmationState +import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState +import kotlinx.collections.immutable.toImmutableList + +open class MediaViewerStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aMediaViewerState(), + aMediaViewerState(listOf(aMediaViewerPageData(AsyncData.Loading()))), + aMediaViewerState(listOf(aMediaViewerPageData(AsyncData.Failure(IllegalStateException("error"))))), + anImageMediaInfo( + senderName = "Sally Sanderson", + dateSent = "21 NOV, 2024", + caption = "A caption", + ).let { + aMediaViewerState( + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + ) + ) + }, + aVideoMediaInfo( + senderName = "A very long name so that it will be truncated and will not be displayed on multiple lines", + dateSent = "A very very long date that will be truncated and will not be displayed on multiple lines", + caption = "A caption", + ).let { + aMediaViewerState( + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + ) + ) + }, + aPdfMediaInfo().let { + aMediaViewerState( + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + ) + ) + }, + aMediaViewerState( + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Loading(), + mediaInfo = anApkMediaInfo(), + ) + ) + ), + anApkMediaInfo().let { + aMediaViewerState( + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + ) + ) + }, + aMediaViewerState( + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Loading(), + mediaInfo = anAudioMediaInfo(), + ) + ) + ), + anAudioMediaInfo().let { + aMediaViewerState( + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + ) + ) + }, + anImageMediaInfo().let { + aMediaViewerState( + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + ), + canShowInfo = false, + ) + }, + aMediaViewerState( + mediaBottomSheetState = aMediaDetailsBottomSheetState(), + ), + aMediaViewerState( + mediaBottomSheetState = aMediaDeleteConfirmationState(), + ), + anAudioMediaInfo( + waveForm = WaveFormSamples.realisticWaveForm, + ).let { + aMediaViewerState( + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + ) + ) + }, + aMediaViewerState( + listOf( + aMediaViewerPageDataLoading() + ), + ), + aMediaViewerState( + listOf( + MediaViewerPageData.Failure(Exception("error")) + ), + ), + aMediaViewerState( + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Loading(), + mediaInfo = aTxtMediaInfo(), + ) + ) + ), + ) +} + +fun aMediaViewerPageDataLoading( + direction: Timeline.PaginationDirection = Timeline.PaginationDirection.BACKWARDS, + timestamp: Long = 0L, +): MediaViewerPageData { + return MediaViewerPageData.Loading( + direction = direction, + timestamp = timestamp, + pagerKey = 0L, + ) +} + +fun aMediaViewerPageData( + downloadedMedia: AsyncData = AsyncData.Uninitialized, + mediaInfo: MediaInfo = anImageMediaInfo(), + mediaSource: MediaSource = MediaSource(""), +): MediaViewerPageData.MediaViewerData = MediaViewerPageData.MediaViewerData( + eventId = null, + mediaInfo = mediaInfo, + mediaSource = mediaSource, + thumbnailSource = null, + downloadedMedia = mutableStateOf(downloadedMedia), + pagerKey = 0L, +) + +fun aMediaViewerState( + listData: List = listOf(aMediaViewerPageData()), + currentIndex: Int = 0, + canShowInfo: Boolean = true, + mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden, + eventSink: (MediaViewerEvents) -> Unit = {}, +) = MediaViewerState( + initiallySelectedEventId = EventId("\$a:b"), + listData = listData.toImmutableList(), + currentIndex = currentIndex, + snackbarMessage = null, + canShowInfo = canShowInfo, + mediaBottomSheetState = mediaBottomSheetState, + eventSink = eventSink, +) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt new file mode 100644 index 0000000..7439909 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -0,0 +1,606 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.libraries.mediaviewer.impl.viewer + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +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.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +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 coil3.compose.AsyncImage +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.viewfolder.api.TextFileViewer +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.audio.api.AudioFocus +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +import io.element.android.libraries.designsystem.components.async.AsyncFailure +import io.element.android.libraries.designsystem.components.async.AsyncLoading +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.HorizontalDivider +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.designsystem.utils.snackbar.SnackbarHost +import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.impl.R +import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState +import io.element.android.libraries.mediaviewer.impl.details.MediaDeleteConfirmationBottomSheet +import io.element.android.libraries.mediaviewer.impl.details.MediaDetailsBottomSheet +import io.element.android.libraries.mediaviewer.impl.local.LocalMediaView +import io.element.android.libraries.mediaviewer.impl.local.PlayableState +import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState +import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.delay +import me.saket.telephoto.zoomable.OverzoomEffect +import me.saket.telephoto.zoomable.ZoomSpec +import me.saket.telephoto.zoomable.rememberZoomableState + +val topAppBarHeight = 88.dp + +@Composable +fun MediaViewerView( + state: MediaViewerState, + textFileViewer: TextFileViewer, + onBackClick: () -> Unit, + audioFocus: AudioFocus?, + modifier: Modifier = Modifier, +) { + val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) + var showOverlay by remember { mutableStateOf(true) } + + val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0 + val currentData = state.listData.getOrNull(state.currentIndex) + BackHandler { onBackClick() } + Scaffold( + modifier, + containerColor = Color.Transparent, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { + val pagerState = rememberPagerState(state.currentIndex, 0f) { + state.listData.size + } + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage }.collect { page -> + state.eventSink(MediaViewerEvents.OnNavigateTo(page)) + } + } + HorizontalPager( + state = pagerState, + modifier = Modifier, + // Pre-load previous and next pages + beyondViewportPageCount = 1, + key = { index -> state.listData[index].pagerKey }, + ) { page -> + when (val dataForPage = state.listData[page]) { + is MediaViewerPageData.Failure -> { + MediaViewerErrorPage( + throwable = dataForPage.throwable, + onDismiss = onBackClick, + ) + } + is MediaViewerPageData.Loading -> { + LaunchedEffect(dataForPage.timestamp) { + state.eventSink(MediaViewerEvents.LoadMore(dataForPage.direction)) + } + MediaViewerLoadingPage( + onDismiss = onBackClick, + ) + } + is MediaViewerPageData.MediaViewerData -> { + var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) } + LaunchedEffect(Unit) { + state.eventSink(MediaViewerEvents.LoadMedia(dataForPage)) + } + Box( + modifier = Modifier.fillMaxSize() + ) { + val isDisplayed = remember(pagerState.settledPage) { + // This 'item provider' lambda will be called when the data source changes with an outdated `settlePage` value + // So we need to update this value only when the `settledPage` value changes. It seems like a bug that needs to be fixed in Compose. + page == pagerState.settledPage + } + MediaViewerPage( + isDisplayed = isDisplayed, + showOverlay = showOverlay, + bottomPaddingInPixels = bottomPaddingInPixels, + data = dataForPage, + textFileViewer = textFileViewer, + onDismiss = onBackClick, + onRetry = { + state.eventSink(MediaViewerEvents.LoadMedia(dataForPage)) + }, + onDismissError = { + state.eventSink(MediaViewerEvents.ClearLoadingError(dataForPage)) + }, + onShowOverlayChange = { + showOverlay = it + }, + audioFocus = audioFocus, + isUserSelected = (state.listData[page] as? MediaViewerPageData.MediaViewerData)?.eventId == state.initiallySelectedEventId, + ) + // Bottom bar + AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) { + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + ) { + MediaViewerBottomBar( + modifier = Modifier.align(Alignment.BottomCenter), + showDivider = dataForPage.mediaInfo.mimeType.isMimeTypeVideo(), + caption = dataForPage.mediaInfo.caption, + onHeightChange = { bottomPaddingInPixels = it }, + ) + } + } + } + } + } + } + // Top bar + AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) { + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + ) { + when (currentData) { + is MediaViewerPageData.MediaViewerData -> { + MediaViewerTopBar( + data = currentData, + canShowInfo = state.canShowInfo, + onBackClick = onBackClick, + onInfoClick = { + state.eventSink(MediaViewerEvents.OpenInfo(currentData)) + }, + eventSink = state.eventSink + ) + } + else -> { + TopAppBar( + title = { + if (currentData is MediaViewerPageData.Loading) { + Text( + modifier = Modifier.semantics { + heading() + }, + text = stringResource(id = CommonStrings.common_loading_more), + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textPrimary, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = bgCanvasWithTransparency, + ), + navigationIcon = { BackButton(onClick = onBackClick) }, + ) + } + } + } + } + } + + when (val bottomSheetState = state.mediaBottomSheetState) { + MediaBottomSheetState.Hidden -> Unit + is MediaBottomSheetState.MediaDetailsBottomSheetState -> { + MediaDetailsBottomSheet( + state = bottomSheetState, + onViewInTimeline = { + state.eventSink(MediaViewerEvents.ViewInTimeline(it)) + }, + onShare = { + (currentData as? MediaViewerPageData.MediaViewerData)?.let { + state.eventSink(MediaViewerEvents.Share(currentData)) + } + }, + onForward = { + state.eventSink(MediaViewerEvents.Forward(it)) + }, + onDownload = { + (currentData as? MediaViewerPageData.MediaViewerData)?.let { + state.eventSink(MediaViewerEvents.SaveOnDisk(currentData)) + } + }, + onDelete = { eventId -> + (currentData as? MediaViewerPageData.MediaViewerData)?.let { + state.eventSink( + MediaViewerEvents.ConfirmDelete( + eventId, + currentData, + ) + ) + } + }, + onDismiss = { + state.eventSink(MediaViewerEvents.CloseBottomSheet) + }, + ) + } + is MediaBottomSheetState.MediaDeleteConfirmationState -> { + MediaDeleteConfirmationBottomSheet( + state = bottomSheetState, + onDelete = { + state.eventSink(MediaViewerEvents.Delete(it)) + }, + onDismiss = { + state.eventSink(MediaViewerEvents.CloseBottomSheet) + }, + ) + } + } +} + +@Composable +private fun MediaViewerPage( + isDisplayed: Boolean, + showOverlay: Boolean, + bottomPaddingInPixels: Int, + data: MediaViewerPageData.MediaViewerData, + textFileViewer: TextFileViewer, + isUserSelected: Boolean, + onDismiss: () -> Unit, + onRetry: () -> Unit, + onDismissError: () -> Unit, + onShowOverlayChange: (Boolean) -> Unit, + audioFocus: AudioFocus?, + modifier: Modifier = Modifier, +) { + val currentShowOverlay by rememberUpdatedState(showOverlay) + val currentOnShowOverlayChange by rememberUpdatedState(onShowOverlayChange) + + MediaViewerFlickToDismiss( + onDismiss = onDismiss, + onDragging = { + currentOnShowOverlayChange(false) + }, + onResetting = { + currentOnShowOverlayChange(true) + }, + modifier = modifier, + ) { + val downloadedMedia by data.downloadedMedia + val showProgress = rememberShowProgress(downloadedMedia) + + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + ) { + Box(contentAlignment = Alignment.Center) { + val zoomableState = rememberZoomableState( + zoomSpec = ZoomSpec(maxZoomFactor = 4f, overzoomEffect = OverzoomEffect.NoLimits) + ) + val localMediaViewState = rememberLocalMediaViewState(zoomableState) + val showThumbnail = !localMediaViewState.isReady + val playableState = localMediaViewState.playableState + val showError = downloadedMedia.isFailure() + + LaunchedEffect(playableState) { + if (playableState is PlayableState.Playable) { + currentOnShowOverlayChange(playableState.isShowingControls) + } + } + + LocalMediaView( + modifier = Modifier.fillMaxSize(), + isDisplayed = isDisplayed, + bottomPaddingInPixels = bottomPaddingInPixels, + localMediaViewState = localMediaViewState, + localMedia = downloadedMedia.dataOrNull(), + mediaInfo = data.mediaInfo, + textFileViewer = textFileViewer, + onClick = { + if (playableState is PlayableState.NotPlayable) { + currentOnShowOverlayChange(!currentShowOverlay) + } + }, + isUserSelected = isUserSelected, + audioFocus = audioFocus, + ) + ThumbnailView( + mediaInfo = data.mediaInfo, + thumbnailSource = data.thumbnailSource, + isVisible = showThumbnail, + ) + if (showError) { + ErrorView( + errorMessage = stringResource(id = CommonStrings.error_unknown), + onRetry = onRetry, + onDismiss = onDismissError + ) + } + } + if (showProgress) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + ) + } + } + } +} + +@Composable +private fun MediaViewerLoadingPage( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + MediaViewerFlickToDismiss( + onDismiss = onDismiss, + modifier = modifier, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + contentAlignment = Alignment.Center + ) { + AsyncLoading() + } + } +} + +@Composable +private fun MediaViewerErrorPage( + throwable: Throwable, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + MediaViewerFlickToDismiss( + onDismiss = onDismiss, + modifier = modifier, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + contentAlignment = Alignment.Center + ) { + AsyncFailure( + throwable = throwable, + onRetry = null + ) + } + } +} + +@Composable +private fun rememberShowProgress(downloadedMedia: AsyncData): 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 +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MediaViewerTopBar( + data: MediaViewerPageData.MediaViewerData, + canShowInfo: Boolean, + onBackClick: () -> Unit, + onInfoClick: () -> Unit, + eventSink: (MediaViewerEvents) -> Unit, +) { + val downloadedMedia by data.downloadedMedia + val actionsEnabled = downloadedMedia.isSuccess() + val mimeType = data.mediaInfo.mimeType + val senderName = data.mediaInfo.senderName + val dateSent = data.mediaInfo.dateSent + TopAppBar( + title = { + if (senderName != null && dateSent != null) { + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Text( + modifier = Modifier.semantics { + heading() + }, + text = senderName, + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = dateSent, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = bgCanvasWithTransparency, + ), + navigationIcon = { BackButton(onClick = onBackClick) }, + actions = { + IconButton( + enabled = actionsEnabled, + onClick = { + eventSink(MediaViewerEvents.OpenWith(data)) + }, + ) { + when (mimeType) { + MimeTypes.Apk -> Icon( + resourceId = R.drawable.ic_apk_install, + contentDescription = stringResource(id = CommonStrings.common_install_apk_android) + ) + else -> Icon( + imageVector = CompoundIcons.PopOut(), + contentDescription = stringResource(id = CommonStrings.action_open_with) + ) + } + } + if (canShowInfo) { + IconButton( + onClick = onInfoClick, + enabled = actionsEnabled, + ) { + Icon( + imageVector = CompoundIcons.Info(), + contentDescription = stringResource(id = CommonStrings.a11y_view_details), + ) + } + } + } + ) +} + +@Composable +private fun MediaViewerBottomBar( + caption: String?, + showDivider: Boolean, + onHeightChange: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(bgCanvasWithTransparency) + .onSizeChanged { + onHeightChange(it.height) + }, + ) { + if (caption != null) { + if (showDivider) { + HorizontalDivider() + } + Text( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + text = caption, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyLgRegular, + ) + } + } +} + +@Composable +private fun ThumbnailView( + thumbnailSource: MediaSource?, + isVisible: Boolean, + mediaInfo: MediaInfo, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (isVisible) { + val mediaRequestData = MediaRequestData( + source = thumbnailSource, + kind = MediaRequestData.Kind.File(mediaInfo.filename, mediaInfo.mimeType) + ) + val alpha = if (LocalInspectionMode.current) 0.1f else 1f + AsyncImage( + modifier = Modifier + .fillMaxSize() + .alpha(alpha), + model = mediaRequestData, + contentScale = ContentScale.Fit, + contentDescription = null, + ) + } + } +} + +@Composable +private fun ErrorView( + errorMessage: String, + onRetry: () -> Unit, + onDismiss: () -> Unit, +) { + RetryDialog( + content = errorMessage, + onRetry = onRetry, + onDismiss = onDismiss + ) +} + +// Only preview in dark, dark theme is forced on the Node. +@Preview +@Composable +internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark { + MediaViewerView( + state = state, + audioFocus = null, + textFileViewer = { _, _ -> }, + onBackClick = {}, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandler.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandler.kt new file mode 100644 index 0000000..a223414 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandler.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.eventId + +/** + * x and y are loading items. + * Capital letters are media items. + * First list emitted + * x F G H y + * indexes will be + * 0 1 2 3 4 + * (keyOffset = 0) + * New items added to the end of the list + * x F G H I J K y + * indexes will be + * 0 1 2 3 4 5 6 7 + * (keyOffset = 0) + * New items added to the beginning of the list + * x D E F G H I J K y + * indexes will be + * -2 -1 0 1 2 3 4 5 6 7 + * (keyOffset = -2) + * loader item vanishes + * D E F G H I J K + * indexes will be + * -1 0 1 2 3 4 5 6 + * (keyOffset = -1) + */ +@Inject +class PagerKeysHandler { + private data class Data( + val mediaItems: List, + val keyOffset: Long, + ) + + // Will store the list of media items and the key offset of the first item in the list + private var cachedData: Data = Data(emptyList(), 0) + + fun accept(mediaItems: List) { + if (cachedData.mediaItems.isEmpty()) { + cachedData = Data(mediaItems, 0) + } else { + // Search a common item in both lists, i.e. an item with the same eventId + val itemInCacheIndex = cachedData.mediaItems.indexOfFirst { mediaItem -> + mediaItem is MediaItem.Event && mediaItems + .filterIsInstance() + .any { mediaItem.eventId() == it.eventId() } + } + cachedData = if (itemInCacheIndex == -1) { + // If the item is not found, start with a new cache + Data(mediaItems, 0) + } else { + val cachedItem = cachedData.mediaItems[itemInCacheIndex] + val eventId = (cachedItem as? MediaItem.Event)?.eventId() + if (eventId == null) { + // Should not happen, but in this case, start with a new cache + Data(mediaItems, 0) + } else { + // Search the index of the item in the new list + val itemIndex = mediaItems.indexOfFirst { mediaItem -> + mediaItem is MediaItem.Event && mediaItem.eventId() == eventId + } + if (itemIndex == -1) { + // If the item is not found, start with a new cache + Data(mediaItems, 0) + } else { + // Update the cache with the new list and the new offset + Data(mediaItems, cachedData.keyOffset + itemInCacheIndex - itemIndex.toLong()) + } + } + } + } + } + + fun getKey(mediaItem: MediaItem): Long { + return cachedData.mediaItems.indexOf(mediaItem) + cachedData.keyOffset + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt new file mode 100644 index 0000000..f243ac4 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import io.element.android.libraries.architecture.AsyncData +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.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint +import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.flowOf + +class SingleMediaGalleryDataSource( + private val data: GroupedMediaItems, +) : MediaGalleryDataSource { + override fun start() = Unit + override fun groupedMediaItemsFlow() = flowOf(AsyncData.Success(data)) + override fun getLastData(): AsyncData = AsyncData.Success(data) + + override suspend fun loadMore(direction: Timeline.PaginationDirection) = Unit + override suspend fun deleteItem(eventId: EventId) = Unit + + companion object { + fun createFrom(params: MediaViewerEntryPoint.Params) = SingleMediaGalleryDataSource( + data = GroupedMediaItems( + // Always use imageAndVideoItems, in Single mode, this is the data that will be used + imageAndVideoItems = persistentListOf(params.toMediaItem()), + fileItems = persistentListOf(), + ) + ) + } +} + +fun MediaViewerEntryPoint.Params.toMediaItem() = when { + mediaInfo.mimeType.isMimeTypeImage() -> { + MediaItem.Image( + id = UniqueId("dummy"), + eventId = eventId, + mediaInfo = mediaInfo, + mediaSource = mediaSource, + thumbnailSource = thumbnailSource, + ) + } + mediaInfo.mimeType.isMimeTypeVideo() -> { + MediaItem.Video( + id = UniqueId("dummy"), + eventId = eventId, + mediaInfo = mediaInfo, + mediaSource = mediaSource, + thumbnailSource = thumbnailSource, + ) + } + mediaInfo.mimeType.isMimeTypeAudio() -> { + if (mediaInfo.waveform == null) { + MediaItem.Audio( + id = UniqueId("dummy"), + eventId = eventId, + mediaInfo = mediaInfo, + mediaSource = mediaSource, + ) + } else { + MediaItem.Voice( + id = UniqueId("dummy"), + eventId = eventId, + mediaInfo = mediaInfo, + mediaSource = mediaSource, + ) + } + } + else -> { + MediaItem.File( + id = UniqueId("dummy"), + eventId = eventId, + mediaInfo = mediaInfo, + mediaSource = mediaSource, + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/res/drawable/ic_apk_install.xml b/libraries/mediaviewer/impl/src/main/res/drawable/ic_apk_install.xml new file mode 100644 index 0000000..b39fc4c --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/drawable/ic_apk_install.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/mediaviewer/impl/src/main/res/values-bg/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..8607121 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,7 @@ + + + "Зареждане на медийни файлове…" + "Файлове" + "Медия" + "Медия и файлове" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-cs/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..bcb7b64 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,21 @@ + + + "Tento soubor bude odstraněn z místnosti a členové k němu nebudou mít přístup." + "Smazat soubor?" + "Zkontrolujte připojení k internetu a zkuste to znovu." + "Zde se zobrazí dokumenty, zvukové soubory a hlasové zprávy nahrané do této místnosti." + "Zatím nebyly nahrány žádné soubory" + "Načítání souborů…" + "Načítání médií…" + "Soubory" + "Média" + "Obrázky a videa nahraná do této místnosti budou zobrazeny zde." + "Zatím nebyla nahrána žádná média" + "Média a soubory" + "Formát souboru" + "Název souboru" + "Žádné další soubory k zobrazení" + "Žádná další média k zobrazení" + "Nahrál(a)" + "Nahráno" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-cy/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..6e08baa --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,21 @@ + + + "Bydd y ffeil hon yn cael ei thynnu o\'r ystafell a bydd gan aelodau ddim mynediad iddi." + "Dileu ffeil?" + "Gwiriwch eich cysylltiad rhyngrwyd a rhowch gynnig arall arni." + "Bydd dogfennau, ffeiliau sain, a negeseuon llais llwythwyd i\'r ystafell hon yn cael eu dangos yma." + "Dim ffeiliau wedi\'u llwytho eto" + "Wrthi\'n llwytho ffeiliau…" + "Wrthi\'n llwytho cyfryngau…" + "Ffeiliau" + "Cyfryngau" + "Bydd delweddau a fideos llwythwyd i\'r ystafell hon yn cael eu dangos yma." + "Dim cyfrwng wedi\'i llwytho eto" + "Cyfryngau a ffeiliau" + "Fformat ffeil" + "Enw\'r ffeil" + "Dim mwy o ffeiliau i\'w dangos" + "Dim mwy o gyfryngau i\'w dangos" + "Llwythwyd gan" + "Llwythwyd i fyny ar" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-da/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..5d88458 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,21 @@ + + + "Denne fil vil blive fjernet fra rummet, og medlemmer vil ikke længere have adgang til den." + "Vil du slette filen?" + "Tjek din internetforbindelse, og prøv igen." + "Dokumenter, lydfiler og stemmemeddelelser uploadet til dette rum vises her." + "Ingen filer uploadet endnu" + "Indlæser filer…" + "Indlæser medier…" + "Filer" + "Medier" + "Billeder og videoer uploadet til dette rum vil blive vist her." + "Ingen medier uploadet endnu" + "Medier og filer" + "Filformat" + "Filnavn" + "Ikke flere filer at vise" + "Ikke flere medier at vise" + "Uploadet af" + "Uploadet på" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-de/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..ab3634e --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,21 @@ + + + "Diese Datei wird aus dem Chat entfernt und die Mitglieder werden keinen Zugriff mehr darauf haben." + "Datei löschen?" + "Überprüfe deine Internetverbindung und versuche es erneut." + "Dokumente, Audiodateien und Sprachnachrichten, die in diesen Chat hochgeladen wurden, werden hier angezeigt." + "Es wurden noch keine Dateien hochgeladen" + "Dateien werden geladen…" + "Medien werden geladen…" + "Dateien" + "Medien" + "In diesen Chat hochgeladene Bilder und Videos werden hier angezeigt." + "Noch keine Medien hochgeladen" + "Medien und Dateien" + "Dateiformat" + "Dateiname" + "Keine weiteren Dateien zum Anzeigen" + "Keine weiteren Medien mehr zum Anzeigen" + "Hochgeladen von" + "Hochgeladen am" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-el/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..c273012 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,21 @@ + + + "Αυτό το αρχείο θα αφαιρεθεί από την αίθουσα και τα μέλη δεν θα έχουν πρόσβαση σε αυτό." + "Διαγραφή αρχείου;" + "Ελέγξτε τη σύνδεσή σας στο διαδίκτυο και δοκιμάστε ξανά." + "Τα έγγραφα, τα αρχεία ήχου και τα φωνητικά μηνύματα που μεταφορτώνονται σε αυτή την αίθουσα θα εμφανίζονται εδώ." + "Δεν έχουν μεταφορτωθεί ακόμα αρχεία" + "Φόρτωση αρχείων…" + "Φόρτωση πολυμέσων…" + "Αρχεία" + "Πολυμέσα" + "Οι εικόνες και τα βίντεο που μεταφορτώνονται σε αυτή την αίθουσα θα εμφανίζονται εδώ." + "Δεν έχουν μεταφορτωθεί ακόμα πολυμέσα" + "Πολυμέσα και αρχεία" + "Μορφή αρχείου" + "Όνομα αρχείου" + "Δεν υπάρχουν άλλα αρχεία για εμφάνιση" + "Δεν υπάρχουν άλλα μέσα για εμφάνιση" + "Μεταφορτώθηκε από" + "Μεταφορτώθηκε στις" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-es/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..686dc60 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,21 @@ + + + "Este archivo se eliminará de la sala y los miembros no tendrán acceso a él." + "¿Borrar archivo?" + "Verifica tu conexión a Internet e inténtalo de nuevo." + "Los documentos, archivos de audio y mensajes de voz subidos a esta sala se mostrarán aquí." + "Aún no se ha subido ningún archivo" + "Cargando archivos…" + "Cargando medios…" + "Archivos" + "Medios" + "Las imágenes y vídeos subidos a esta sala se mostrarán aquí." + "Aún no se ha subido ningún medio" + "Medios y archivos" + "Formato de archivo" + "Nombre del archivo" + "No hay más archivos que mostrar" + "No hay más medios que mostrar" + "Subido por" + "Subido el" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-et/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..63329a7 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,21 @@ + + + "Järgnevaga eemaldame selle faili jututoast ka tema liikmed enam ei pääse failile ligi." + "Kas kustutame faili?" + "Kontrolli oma nutiseadme internetiühenduse toimimist ja proovi uuesti" + "Antud jututuppa üleslaaditud dokumendid, helifailid ja häälsõnumid saavad olema nähtaval siin." + "Ühtegi faili pole veel üleslaaditud" + "Laadime faile…" + "Laadime meediat…" + "Failid" + "Meedia" + "Antud jututuppa üleslaaditud pildid ja videod kuvatakse siin." + "Mitte keegi pole veel meediat üles laadinud" + "Meedia ja failid" + "Failivorming" + "Failinimi" + "Pole enam kuvatavaid faile" + "Pole enam kuvatavat meediat" + "Üleslaadija" + "Üleslaaditud" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-eu/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..b7f00b4 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,19 @@ + + + "Fitxategia gelatik kenduko da eta kideek ezingo dute atzitu." + "Fitxategia ezabatu?" + "Egiaztatu Interneteko konexioa eta saiatu berriro." + "Gela honetara igotako dokumentuak, audio-fitxategiak, eta ahots-mezuak hemen erakutsiko dira." + "Oraindik ez da fitxategirik igo" + "Fitxategiak kargatzen…" + "Multimedia kargatzen…" + "Fitxategiak" + "Multimedia" + "Gela honetara igotako irudiak eta bideoak hemen erakutsiko dira." + "Oraindik ez da multimedia fitxategirik igo" + "Multimedia eta fitxategiak" + "Fitxategiaren formatua" + "Fitxategiaren izena" + "Nork igota:" + "Noiz igota:" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-fa/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..81f8f4d --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,18 @@ + + + "حذف پرونده؟" + "بررسی اتّصال اینترنتیتان و تلاش دوباره." + "سندها، پرونده‌ها و پیام‌های صوتی بار گذاشته در این اتاق این‌جا نشان داده خواهند شد." + "هنوز هیچ پرونده‌ای بارگذاشته نشده" + "بار کردن پرونده‌ها…" + "بار کردن رسانه‌ها…" + "پرونده‌ها" + "رسانه" + "تصویرها و ویدیوهای بار گذاشته در این اتاق این‌جا نشان داده خواهند شد." + "هنوز هیچ رسانه‌ای بارگذاشته نشده" + "رسانه‌ها و پرونده‌ها" + "قالب پرونده" + "نام پرونده" + "بارگذاشته به دست" + "بارگذاشته در" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-fi/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..b3728cb --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,21 @@ + + + "Tämä tiedosto poistetaan huoneesta, eikä jäsenillä ole enää pääsyä siihen." + "Poistetaanko tiedosto?" + "Tarkista internet-yhteytesi ja yritä uudelleen." + "Tähän huoneeseen ladatut asiakirjat, äänitiedostot ja ääniviestit näkyvät täällä." + "Ei vielä ladattuja tiedostoja" + "Ladataan tiedostoja…" + "Ladataan mediaa…" + "Tiedostot" + "Media" + "Tähän huoneeseen lähetetyt kuvat ja videot näytetään täällä." + "Mediaa ei ole vielä lähetetty" + "Media ja tiedostot" + "Tiedostomuoto" + "Tiedostonimi" + "Ei enää näytettäviä tiedostoja" + "Ei enää näytettävää mediaa" + "Lähettäjä" + "Lähetetty" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-fr/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..b633409 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,21 @@ + + + "Ce fichier sera supprimé du salon et les membres n’y auront plus accès." + "Supprimer le fichier ?" + "Vérifiez votre connexion Internet et réessayez." + "Les documents, les fichiers audio et les messages vocaux envoyés dans ce salon seront affichés ici." + "Aucun fichier n’a encore été envoyé" + "Chargement des fichiers…" + "Chargement des médias…" + "Fichiers" + "Média" + "Les images et vidéos envoyées dans ce salon seront affichées ici." + "Aucun média n’a encore été envoyé dans ce salon" + "Médias et fichiers" + "Format du fichier" + "Nom du fichier" + "Il n’y a plus de fichiers à montrer" + "Il n’y a plus de médias à montrer" + "Envoyé par" + "Envoyé le" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-hu/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..14cc11f --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,21 @@ + + + "Ez a fájl el lesz távolítva a szobából, és a tagok nem férhetnek hozzá." + "Törli a fájlt?" + "Ellenőrizze az internetkapcsolatot, és próbálja újra." + "A szobába feltöltött dokumentumok, hangfájlok és hangüzenetek itt jelennek meg." + "Még nincsenek fájlok feltöltve" + "Fájlok betöltése…" + "Média betöltése…" + "Fájlok" + "Média" + "Az ebbe a szobába feltöltött képek és videók itt jelennek meg." + "Még nincs feltöltött média" + "Média és fájlok" + "Fájlformátum" + "Fájlnév" + "Nincs több megjeleníthető fájl" + "Nincs több megjeleníthető média" + "Feltöltötte:" + "Feltöltve:" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-in/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..b5c2bde --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,21 @@ + + + "Berkas ini akan dihapus dari ruangan dan anggota tidak akan memiliki akses ke sana." + "Hapus berkas?" + "Periksa koneksi internet Anda dan coba lagi." + "Dokumen, berkas audio, dan pesan suara yang diunggah ke ruangan ini akan ditampilkan di sini." + "Belum ada berkas yang diunggah" + "Memuat berkas…" + "Memuat media…" + "Berkas" + "Media" + "Gambar dan video yang diunggah ke ruangan ini akan ditampilkan di sini." + "Belum ada media yang diunggah" + "Media dan berkas" + "Format berkas" + "Nama berkas" + "Tidak ada lagi berkas untuk ditampilkan" + "Tidak ada lagi media untuk ditampilkan" + "Diunggah oleh" + "Diunggah pada" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-it/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..7270119 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,21 @@ + + + "Questo file verrà rimosso dalla stanza e i membri non ne avranno accesso." + "Eliminare il file?" + "Controlla la tua connessione Internet e riprova." + "I documenti, i file audio e i messaggi vocali caricati in questa stanza verranno visualizzati qui." + "Nessun file ancora caricato" + "Caricamento dei file…" + "Caricamento dei file multimediali…" + "File" + "Contenuti multimediali" + "Le immagini e i video caricati in questa stanza verranno mostrati qui." + "Nessun file multimediale ancora caricato" + "File e contenuti multimediali" + "Formato del file" + "Nome del file" + "Nessun altro file da mostrare" + "Non ci sono più contenuti multimediali da mostrare" + "Caricato da" + "Caricato il" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-ko/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..3362171 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,21 @@ + + + "이 파일은 방에서 삭제되며, 회원들은 더 이상 액세스할 수 없습니다." + "파일을 삭제하시겠습니까?" + "인터넷 연결을 확인하고 다시 시도해 주세요." + "이 방에 업로드된 문서, 오디오 파일 및 음성 메시지가 여기에 표시됩니다." + "아직 업로드된 파일이 없습니다." + "파일 로딩 중…" + "미디어 로딩 중…" + "파일" + "미디어" + "이 방에 업로드된 이미지와 동영상은 여기에 표시됩니다." + "아직 미디어가 업로드되지 않았습니다." + "미디어 및 파일" + "파일 형식" + "파일 명" + "더 이상 표시할 파일이 없습니다" + "더 이상 보여줄 미디어가 없습니다" + "에 의해 업로드됨" + "에 업로드됨" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-nb/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..8cc6eb3 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,21 @@ + + + "Denne filen vil bli fjernet fra rommet, og medlemmene vil ikke lenger ha tilgang til den." + "Vil du slette filen?" + "Sjekk internettforbindelsen din og prøv igjen." + "Dokumenter, lydfiler og talemeldinger som lastes opp til dette rommet, vises her." + "Ingen filer lastet opp ennå" + "Laster inn filer…" + "Laster inn medier…" + "Filer" + "Mediefiler" + "Bilder og videoer som lastes opp til dette rommet, vises her." + "Ingen mediefiler lastet opp ennå" + "Medier og filer" + "Filformat" + "Filnavn" + "Ingen flere filer å vise" + "Ikke flere mediefiler å vise" + "Lastet opp av" + "Lastet opp den" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-nl/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..ab7f922 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,4 @@ + + + "Bestand verwijderen?" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-pl/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..c398123 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,21 @@ + + + "Ten plik zostanie usunięty z pokoju, a członkowie nie będą mieli do niego dostępu." + "Usunąć plik?" + "Sprawdź połączenie internetowe i spróbuj ponownie." + "Dokumenty, pliki audio i wiadomości głosowe przesłane do tego pokoju będą wyświetlane tutaj." + "Jeszcze nie przesłano plików" + "Wczytywanie plików…" + "Wczytywanie mediów…" + "Pliki" + "Media" + "Obrazy i filmy przesyłane w tym pokoju wyświetlą się tutaj." + "Jeszcze nie przesłano żadnych mediów" + "Media i pliki" + "Format pliku" + "Nazwa pliku" + "Brak plików do pokazania" + "Brak mediów do pokazania" + "Przesłane przez" + "Przesłane w dniu" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-pt-rBR/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..284e7db --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,21 @@ + + + "Esse arquivo será removido da sala e os membros não terão acesso a ele." + "Excluir arquivo?" + "Verifique sua conexão à internet e tente novamente." + "Os documentos, arquivos de áudio e mensagens de voz enviados nesta sala serão exibidos aqui." + "Nenhum arquivo enviado ainda" + "Carregando arquivos…" + "Carregando mídia…" + "Arquivos" + "Mídia" + "As imagens e os vídeos enviados nesta sala serão exibidos aqui." + "Nenhuma mídia enviada ainda" + "Mídia e arquivos" + "Formato do arquivo" + "Nome do arquivo" + "Não há mais arquivos para mostrar" + "Não há mais mídia para mostrar" + "Enviado por" + "Enviado em" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-pt/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..453a009 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,21 @@ + + + "Este ficheiro será removido da sala e os membros deixarão de o poder aceder." + "Eliminar ficheiro?" + "Verifica a tua ligação à Internet e tenta novamente." + "Documentos, ficheiros de áudio e mensagens de voz carregados para esta sala serão mostrados aqui." + "Ainda sem qualquer ficheiro" + "A carregar ficheiros…" + "A carregar multimédia…" + "Ficheiros" + "Multimédia" + "Imagens e vídeos enviados nesta sala serão mostrados aqui." + "Ainda sem qualquer multimédia" + "Multimédia e ficheiros" + "Formato do ficheiro" + "Nome do ficheiro" + "Sem mais ficheiros para mostrar" + "Sem mais multimédia para mostrar" + "Enviado por" + "Enviado a" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-ro/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..bfd1d49 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,21 @@ + + + "Acest fișier va fi șters din cameră și membrii nu vor mai avea acces la el." + "Ştergeţi fişierul?" + "Verificați conexiunea la internet și încercați din nou." + "Documentele, fișierele audio și mesajele vocale încărcate în această cameră vor fi afișate aici." + "Nu există încă fișiere încărcate" + "Se încarcă fișierele…" + "Se încarcă media…" + "Fișiere" + "Media" + "Imaginile și videoclipurile încărcate în această cameră vor fi afișate aici." + "Nu a fost încărcat încă niciun fișier media" + "Media și fișiere" + "Format fişier" + "Nume fișier" + "Nu mai există fișiere de afișat" + "Nu mai există conținut media de afișat" + "Încărcat de" + "Încărcat la" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-ru/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..0598d68 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,21 @@ + + + "Этот файл будет удален из комнаты и участники не будут иметь к нему доступ." + "Удалить файл?" + "Проверьте соединение с Интернетом и повторите попытку." + "Здесь будут отображаться документы, аудиофайлы и голосовые сообщения, загруженные в комнату." + "Нет загруженных файлов." + "Загрузка файлов…" + "Загрузка медиа…" + "Файлы" + "Медиа" + "Здесь будут показаны изображения и видео, загруженные в данную комнату." + "Пока что нет загруженных медиафайлов" + "Медиа и файлы" + "Формат файла" + "Имя файла" + "Больше нет файлов для показа" + "Больше нет медиа для показа" + "Загружено" + "Загружено на" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-sk/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..4cfc16b --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,21 @@ + + + "Tento súbor bude odstránený z miestnosti a členovia k nemu nebudú mať prístup." + "Vymazať súbor?" + "Skontrolujte svoje pripojenie k internetu a skúste to znova." + "Tu sa zobrazia dokumenty, zvukové súbory a hlasové správy nahrané do tejto miestnosti." + "Zatiaľ nie sú nahrané žiadne súbory" + "Načítavajú sa súbory…" + "Načítava sa médium…" + "Súbory" + "Médiá" + "Tu sa zobrazia obrázky a videá nahrané do tejto miestnosti." + "Žiadne médiá zatiaľ neboli nahrané" + "Médiá a súbory" + "Formát súboru" + "Názov súboru" + "Žiadne ďalšie súbory na zobrazenie" + "Žiadne ďalšie médiá na zobrazenie" + "Nahrané používateľom" + "Nahrané dňa" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-sv/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..ac1040d --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,21 @@ + + + "Den här filen kommer att tas bort från rummet och medlemmar kommer inte att ha tillgång till den." + "Radera fil?" + "Kontrollera din internetanslutning och försök igen." + "Dokument, ljudfiler och röstmeddelanden som laddas upp till detta rum visas här." + "Inga filer uppladdade än" + "Laddar in filer…" + "Läser in media…" + "Filer" + "Media" + "Bilder och videor som laddas upp till detta rum kommer att visas här." + "Ingen media uppladdad ännu" + "Media och filer" + "Filformat" + "Filnamn" + "Inga fler filer att visa" + "Ingen mer media att visa" + "Uppladdad av" + "Uppladdad på" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-tr/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..30a1d13 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,21 @@ + + + "Bu dosya odadan kaldırılır ve üyeler bu dosyaya erişemez." + "Dosyayı sil?" + "İnternet bağlantınızı kontrol edin ve tekrar deneyin." + "Bu odaya yüklenen belgeler, ses dosyaları ve sesli mesajlar burada gösterilecektir." + "Henüz yüklenen dosya yok" + "Dosyalar yükleniyor…" + "Medya yükleniyor…" + "Dosyalar" + "Medya" + "Bu odaya yüklenen resimler ve videolar burada gösterilecektir." + "Henüz yüklenen medya yok" + "Medya ve dosyalar" + "Dosya biçimi" + "Dosya adı" + "Gösterilecek daha fazla dosya yok" + "Gösterilecek daha fazla medya yok" + "Yükleyen:" + "Yüklendi" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-uk/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..45e1e17 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,21 @@ + + + "Цей файл буде вилучено з кімнати, і учасники не матимуть доступу до нього." + "Видалити файл?" + "Перевірте з\'єднання з інтернетом і повторіть спробу." + "Тут будуть показані документи, аудіофайли та голосові повідомлення, вивантажені в цю кімнату." + "Ще не вивантажено жодного файлу" + "Завантаження файлів…" + "Завантаження медіа…" + "Файли" + "Медіа" + "Зображення та відео, вивантажені в цю кімнату, будуть показані тут." + "Ще не вивантажено жодного медіафайлу" + "Медіа та файли" + "Формат файлу" + "Назва файлу" + "Більше немає файлів для показу" + "Більше немає медіа для показу" + "Вивантажено користувачем" + "Вивантажено" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-uz/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..8f838c0 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,21 @@ + + + "Bu fayl xonadan olib tashlanadi va a’zolar unga kira olmaydilar." + "Fayl oʻchirilsinmi?" + "Internet aloqangizni tekshiring va qayta urining." + "Ushbu xonaga yuklangan hujjatlar, audio fayllar va ovozli xabarlar shu yerda ko‘rsatiladi." + "Hali hech qanday fayl yuklanmagan" + "Fayllar yuklanmoqda…" + "Media yuklanmoqda…" + "Fayllar" + "Media" + "Bu xonaga yuklangan rasm va videolar shu yerda chiqadi." + "Hali hech qanday media yuklanmagan" + "Media va fayllar" + "Fayl formati" + "Fayl nomi" + "Ko‘rsatish uchun boshqa fayllar yo‘q" + "Ko‘rsatish uchun boshqa media yo‘q" + "Tomonidan yuklangan" + "Yuklangan" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-zh-rTW/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..4cbe579 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,21 @@ + + + "此檔案將會從聊天室中移除,成員將無法存取該檔案。" + "刪除檔案?" + "檢查您的網際網路連線,然後再試一次。" + "上傳此聊天室的文件、音訊檔與語音訊息將會在此顯示。" + "尚未上傳檔案" + "正在載入檔案……" + "正在載入媒體……" + "檔案" + "媒體" + "上傳到此聊天室的圖片與影片將在此處顯示。" + "尚未上傳媒體" + "媒體與檔案" + "檔案格式" + "檔案名稱" + "無可顯示的檔案" + "無可顯示的媒體" + "上傳者:" + "上傳於" + diff --git a/libraries/mediaviewer/impl/src/main/res/values-zh/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..08b3399 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,21 @@ + + + "此文件将从房间中删除,并且成员将无法访问它。" + "删除文件?" + "检查你的互联网连接并重试。" + "上传到此房间的文档、音频文件和语音消息将在此处显示。" + "尚未上传任何文件" + "正在加载文件…" + "正在加载媒体…" + "文件" + "媒体" + "上传到此房间的图像和视频将在此处显示。" + "尚未上传任何媒体" + "媒体和文件" + "文件格式" + "文件名" + "没有更多文件可显示了" + "没有更多媒体可显示了" + "上传者:" + "上传于" + diff --git a/libraries/mediaviewer/impl/src/main/res/values/localazy.xml b/libraries/mediaviewer/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..2982f80 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values/localazy.xml @@ -0,0 +1,21 @@ + + + "This file will be removed from the room and members won’t have access to it." + "Delete file?" + "Check your internet connection and try again." + "Documents, audio files, and voice messages uploaded to this room will be shown here." + "No files uploaded yet" + "Loading files…" + "Loading media…" + "Files" + "Media" + "Images and videos uploaded to this room will be shown here." + "No media uploaded yet" + "Media and files" + "File format" + "File name" + "No more files to show" + "No more media to show" + "Uploaded by" + "Uploaded on" + diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaGalleryEntryPointTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaGalleryEntryPointTest.kt new file mode 100644 index 0000000..8b2f3b5 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaGalleryEntryPointTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint +import io.element.android.libraries.mediaviewer.impl.gallery.root.MediaGalleryFlowNode +import io.element.android.libraries.mediaviewer.test.FakeMediaViewerEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultMediaGalleryEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultMediaGalleryEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + MediaGalleryFlowNode( + buildContext = buildContext, + plugins = plugins, + mediaViewerEntryPoint = FakeMediaViewerEntryPoint(), + ) + } + val callback = object : MediaGalleryEntryPoint.Callback { + override fun onBackClick() = lambdaError() + override fun viewInTimeline(eventId: EventId) = lambdaError() + override fun forward(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError() + } + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + callback = callback, + ) + assertThat(result).isInstanceOf(MediaGalleryFlowNode::class.java) + assertThat(result.plugins).contains(callback) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt new file mode 100644 index 0000000..b848ea2 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl + +import android.net.Uri +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.libraries.core.mimetype.MimeTypes +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.MediaSource +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader +import io.element.android.libraries.mediaplayer.test.FakeAudioFocus +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint +import io.element.android.libraries.mediaviewer.impl.datasource.createTimelineMediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerNode +import io.element.android.libraries.mediaviewer.impl.viewer.PagerKeysHandler +import io.element.android.libraries.mediaviewer.impl.viewer.createMediaViewerEntryPointParams +import io.element.android.libraries.mediaviewer.impl.viewer.createMediaViewerPresenter +import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import io.element.android.tests.testutils.testCoroutineDispatchers +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DefaultMediaViewerEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `test node builder`() = runTest { + val entryPoint = DefaultMediaViewerEntryPoint() + val mockMediaUri: Uri = mockk("localMediaUri") + val localMediaFactory = FakeLocalMediaFactory(mockMediaUri) + val parentNode = TestParentNode.create { buildContext, plugins -> + MediaViewerNode( + buildContext = buildContext, + plugins = plugins, + presenterFactory = { _, _, _ -> + createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + ) + }, + timelineMediaGalleryDataSource = createTimelineMediaGalleryDataSource(), + focusedTimelineMediaGalleryDataSourceFactory = { _, _, _ -> + lambdaError() + }, + mediaLoader = FakeMatrixMediaLoader(), + localMediaFactory = FakeLocalMediaFactory(mockMediaUri), + coroutineDispatchers = testCoroutineDispatchers(), + systemClock = FakeSystemClock(), + pagerKeysHandler = PagerKeysHandler(), + textFileViewer = { _, _ -> lambdaError() }, + audioFocus = FakeAudioFocus(), + sessionId = A_SESSION_ID, + enterpriseService = FakeEnterpriseService(), + ) + } + val callback = object : MediaViewerEntryPoint.Callback { + override fun onDone() = lambdaError() + override fun viewInTimeline(eventId: EventId) = lambdaError() + override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError() + } + val params = createMediaViewerEntryPointParams() + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + params = params, + callback = callback, + ) + assertThat(result).isInstanceOf(MediaViewerNode::class.java) + assertThat(result.plugins).contains(params) + assertThat(result.plugins).contains(callback) + } + + @Test + fun `test node builder avatar`() = runTest { + val entryPoint = DefaultMediaViewerEntryPoint() + val mockMediaUri: Uri = mockk("localMediaUri") + val localMediaFactory = FakeLocalMediaFactory(mockMediaUri) + val parentNode = TestParentNode.create { buildContext, plugins -> + MediaViewerNode( + buildContext = buildContext, + plugins = plugins, + presenterFactory = { _, _, _ -> + createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + ) + }, + timelineMediaGalleryDataSource = createTimelineMediaGalleryDataSource(), + focusedTimelineMediaGalleryDataSourceFactory = { _, _, _ -> + lambdaError() + }, + mediaLoader = FakeMatrixMediaLoader(), + localMediaFactory = FakeLocalMediaFactory(mockMediaUri), + coroutineDispatchers = testCoroutineDispatchers(), + systemClock = FakeSystemClock(), + pagerKeysHandler = PagerKeysHandler(), + textFileViewer = { _, _ -> lambdaError() }, + audioFocus = FakeAudioFocus(), + sessionId = A_SESSION_ID, + enterpriseService = FakeEnterpriseService(), + ) + } + val callback = object : MediaViewerEntryPoint.Callback { + override fun onDone() = lambdaError() + override fun viewInTimeline(eventId: EventId) = lambdaError() + override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) = lambdaError() + } + val params = entryPoint.createParamsForAvatar( + filename = "fn", + avatarUrl = "avatarUrl", + ) + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + params = params, + callback = callback, + ) + assertThat(result).isInstanceOf(MediaViewerNode::class.java) + assertThat(result.plugins).contains( + MediaViewerEntryPoint.Params( + mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia, + eventId = null, + mediaInfo = MediaInfo( + filename = "fn", + fileSize = null, + caption = null, + mimeType = MimeTypes.Images, + formattedFileSize = "", + fileExtension = "", + senderId = UserId("@dummy:server.org"), + senderName = null, + senderAvatar = null, + dateSent = null, + dateSentFull = null, + waveform = null, + duration = null, + ), + mediaSource = MediaSource(url = "avatarUrl"), + thumbnailSource = null, + canShowInfo = false, + ) + ) + assertThat(result.plugins).contains(callback) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt new file mode 100644 index 0000000..68d6564 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt @@ -0,0 +1,436 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.datasource + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.dateformatter.test.FakeDateFormatter +import io.element.android.libraries.matrix.api.media.AudioDetails +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 +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent +import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType +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.LegacyCallInviteContent +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.OtherMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.OtherState +import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent +import io.element.android.libraries.matrix.api.timeline.item.event.StateContent +import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType +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.VideoMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_UNIQUE_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.timeline.aMessageContent +import io.element.android.libraries.matrix.test.timeline.aPollContent +import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent +import io.element.android.libraries.matrix.test.timeline.aStickerContent +import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem +import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +class DefaultEventItemFactoryTest { + @Test + fun `create check all null cases`() { + val factory = createEventItemFactory() + val contents = listOf( + CallNotifyContent, + FailedToParseMessageLikeContent("", ""), + FailedToParseStateContent("", "", ""), + LegacyCallInviteContent, + aPollContent(), + aProfileChangeMessageContent(), + RedactedContent, + aRoomMembershipContent( + userId = A_USER_ID, + ), + StateContent("", OtherState.RoomCreate), + aStickerContent( + info = ImageInfo( + width = null, + height = null, + mimetype = null, + size = null, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ), + mediaSource = MediaSource("") + ), + UnableToDecryptContent(UnableToDecryptContent.Data.Unknown), + UnknownContent, + ) + contents.forEach { + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = it + ) + ) + ) + assertThat(result).isNull() + } + } + + @Test + fun `create MessageContent check all null cases`() { + val factory = createEventItemFactory() + val messageTypes = listOf( + EmoteMessageType("", null), + NoticeMessageType("", null), + OtherMessageType("", ""), + LocationMessageType("", "", null), + TextMessageType("", null) + ) + messageTypes.forEach { + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = it + ) + ) + ) + ) + assertThat(result).isNull() + } + } + + @Test + fun `create for FileMessageType`() { + val factory = createEventItemFactory() + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = FileMessageType( + filename = "filename.apk", + caption = "caption", + formattedCaption = null, + source = MediaSource(""), + info = FileInfo( + mimetype = MimeTypes.Apk, + size = 123L, + thumbnailInfo = null, + thumbnailSource = null, + ) + ) + ) + ) + ) + ) + assertThat(result).isEqualTo( + MediaItem.File( + id = A_UNIQUE_ID, + eventId = AN_EVENT_ID, + mediaInfo = MediaInfo( + mimeType = MimeTypes.Apk, + filename = "filename.apk", + fileSize = 123L, + caption = "caption", + formattedFileSize = "123 Bytes", + fileExtension = "apk", + senderId = A_USER_ID, + senderName = "alice", + senderAvatar = null, + dateSent = "0 Day false", + dateSentFull = "0 Full false", + waveform = null, + duration = null, + ), + mediaSource = MediaSource(""), + ) + ) + } + + @Test + fun `create for ImageMessageType`() { + val factory = createEventItemFactory() + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = ImageMessageType( + filename = "filename.jpg", + caption = "caption", + formattedCaption = null, + source = MediaSource(""), + info = ImageInfo( + mimetype = MimeTypes.Jpeg, + size = 123L, + thumbnailInfo = null, + thumbnailSource = null, + height = 1L, + width = 2L, + blurhash = null, + ) + ) + ) + ) + ) + ) + assertThat(result).isEqualTo( + MediaItem.Image( + id = A_UNIQUE_ID, + eventId = AN_EVENT_ID, + mediaInfo = MediaInfo( + mimeType = MimeTypes.Jpeg, + filename = "filename.jpg", + fileSize = 123L, + caption = "caption", + formattedFileSize = "123 Bytes", + fileExtension = "jpg", + senderId = A_USER_ID, + senderName = "alice", + senderAvatar = null, + dateSent = "0 Day false", + dateSentFull = "0 Full false", + waveform = null, + duration = null, + ), + mediaSource = MediaSource(""), + thumbnailSource = null, + ) + ) + } + + @Test + fun `create for AudioMessageType`() { + val factory = createEventItemFactory() + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = AudioMessageType( + filename = "filename.mp3", + caption = "caption", + formattedCaption = null, + source = MediaSource(""), + info = AudioInfo( + mimetype = MimeTypes.Mp3, + size = 123L, + duration = 456.seconds, + ) + ) + ) + ) + ) + ) + assertThat(result).isEqualTo( + MediaItem.Audio( + id = A_UNIQUE_ID, + eventId = AN_EVENT_ID, + mediaInfo = MediaInfo( + mimeType = MimeTypes.Mp3, + filename = "filename.mp3", + fileSize = 123L, + caption = "caption", + formattedFileSize = "123 Bytes", + fileExtension = "mp3", + senderId = A_USER_ID, + senderName = "alice", + senderAvatar = null, + dateSent = "0 Day false", + dateSentFull = "0 Full false", + waveform = null, + duration = null, + ), + mediaSource = MediaSource(""), + ) + ) + } + + @Test + fun `create for VideoMessageType`() { + val factory = createEventItemFactory() + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = VideoMessageType( + filename = "filename.mp4", + caption = "caption", + formattedCaption = null, + source = MediaSource(""), + info = VideoInfo( + mimetype = MimeTypes.Mp4, + size = 123L, + thumbnailInfo = null, + duration = 123.seconds, + height = 1L, + width = 2L, + thumbnailSource = null, + blurhash = null + ) + ) + ) + ) + ) + ) + assertThat(result).isEqualTo( + MediaItem.Video( + id = A_UNIQUE_ID, + eventId = AN_EVENT_ID, + mediaInfo = MediaInfo( + mimeType = MimeTypes.Mp4, + filename = "filename.mp4", + fileSize = 123L, + caption = "caption", + formattedFileSize = "123 Bytes", + fileExtension = "mp4", + senderId = A_USER_ID, + senderName = "alice", + senderAvatar = null, + dateSent = "0 Day false", + dateSentFull = "0 Full false", + waveform = null, + duration = "2:03", + ), + mediaSource = MediaSource(""), + thumbnailSource = null, + ) + ) + } + + @Test + fun `create for VoiceMessageType`() { + val factory = createEventItemFactory() + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = VoiceMessageType( + filename = "filename.ogg", + caption = "caption", + formattedCaption = null, + source = MediaSource(""), + info = AudioInfo( + mimetype = MimeTypes.Ogg, + size = 123L, + duration = 456.seconds, + ), + details = AudioDetails( + duration = 456.seconds, + waveform = persistentListOf(1f, 2f), + ) + ) + ) + ) + ) + ) + assertThat(result).isEqualTo( + MediaItem.Voice( + id = A_UNIQUE_ID, + eventId = AN_EVENT_ID, + mediaInfo = MediaInfo( + mimeType = MimeTypes.Ogg, + filename = "filename.ogg", + fileSize = 123L, + caption = "caption", + formattedFileSize = "123 Bytes", + fileExtension = "ogg", + senderId = A_USER_ID, + senderName = "alice", + senderAvatar = null, + dateSent = "0 Day false", + dateSentFull = "0 Full false", + waveform = listOf(1f, 2f).toImmutableList(), + duration = "7:36", + ), + mediaSource = MediaSource(""), + ) + ) + } + + @Test + fun `create for StickerMessageType`() { + val factory = createEventItemFactory() + val result = factory.create( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = StickerMessageType( + filename = "filename.gif", + caption = "caption", + formattedCaption = null, + source = MediaSource(""), + info = ImageInfo( + mimetype = MimeTypes.Gif, + size = 123L, + thumbnailInfo = null, + thumbnailSource = null, + height = 1L, + width = 2L, + blurhash = null, + ) + ) + ) + ) + ) + ) + assertThat(result).isEqualTo( + MediaItem.Image( + id = A_UNIQUE_ID, + eventId = AN_EVENT_ID, + mediaInfo = MediaInfo( + mimeType = MimeTypes.Gif, + filename = "filename.gif", + fileSize = 123L, + caption = "caption", + formattedFileSize = "123 Bytes", + fileExtension = "gif", + senderId = A_USER_ID, + senderName = "alice", + senderAvatar = null, + dateSent = "0 Day false", + dateSentFull = "0 Full false", + waveform = null, + duration = null, + ), + mediaSource = MediaSource(""), + thumbnailSource = null, + ) + ) + } +} + +private fun createEventItemFactory() = EventItemFactory( + fileSizeFormatter = FakeFileSizeFormatter(), + fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), + dateFormatter = FakeDateFormatter(), +) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultFocusedTimelineMediaGalleryDataSourceFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultFocusedTimelineMediaGalleryDataSourceFactoryTest.kt new file mode 100644 index 0000000..a70c865 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultFocusedTimelineMediaGalleryDataSourceFactoryTest.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.datasource + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultFocusedTimelineMediaGalleryDataSourceFactoryTest { + @Test + fun `createFor should create a TimelineMediaGalleryDataSource`() = runTest { + val sut = DefaultFocusedTimelineMediaGalleryDataSourceFactory( + room = FakeJoinedRoom(), + timelineMediaItemsFactory = createTimelineMediaItemsFactory(), + mediaItemsPostProcessor = MediaItemsPostProcessor(), + ) + val result = sut.createFor( + eventId = AN_EVENT_ID, + mediaItem = aMediaItemImage(), + onlyPinnedEvents = false, + ) + assertThat(result).isInstanceOf(TimelineMediaGalleryDataSource::class.java) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/FakeMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/FakeMediaGalleryDataSource.kt new file mode 100644 index 0000000..c612bba --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/FakeMediaGalleryDataSource.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.datasource + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow + +class FakeMediaGalleryDataSource( + private val startLambda: () -> Unit = { lambdaError() }, + private val loadMoreLambda: (Timeline.PaginationDirection) -> Unit = { lambdaError() }, + private val deleteItemLambda: (EventId) -> Unit = { lambdaError() }, + ) : MediaGalleryDataSource { + override fun start() = startLambda() + + private val groupedMediaItemsFlow = MutableSharedFlow>( + replay = 1 + ) + + override fun groupedMediaItemsFlow(): Flow> { + return groupedMediaItemsFlow + } + + suspend fun emitGroupedMediaItems(groupedMediaItems: AsyncData) { + groupedMediaItemsFlow.emit(groupedMediaItems) + } + + override fun getLastData(): AsyncData { + return groupedMediaItemsFlow.replayCache.firstOrNull() ?: AsyncData.Uninitialized + } + + override suspend fun loadMore(direction: Timeline.PaginationDirection) { + loadMoreLambda(direction) + } + + override suspend fun deleteItem(eventId: EventId) { + deleteItemLambda(eventId) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/FocusedMediaTimelineTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/FocusedMediaTimelineTest.kt new file mode 100644 index 0000000..4e1a57e --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/FocusedMediaTimelineTest.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.datasource + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.CreateTimelineParams +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class FocusedMediaTimelineTest { + @Test + fun `check the returned cache data`() { + val media = aMediaItemImage() + val sut = createFocusedMediaTimeline( + initialMediaItem = media, + ) + val cache = sut.cache + assertThat(cache.imageAndVideoItems.size).isEqualTo(3) + assertThat(cache.fileItems.size).isEqualTo(3) + assertThat(cache.imageAndVideoItems[1]).isEqualTo(media) + assertThat(cache.fileItems[1]).isEqualTo(media) + } + + @Test + fun `when event is not found, the cache is returned`() { + val media = aMediaItemImage( + eventId = AN_EVENT_ID, + ) + val sut = createFocusedMediaTimeline( + initialMediaItem = media, + ) + val cache = sut.orCache( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(), + fileItems = persistentListOf(), + ) + ) + assertThat(cache.imageAndVideoItems.size).isEqualTo(3) + assertThat(cache.fileItems.size).isEqualTo(3) + } + + @Test + fun `when event is found, the data is returned`() { + val media = aMediaItemImage( + eventId = AN_EVENT_ID, + ) + val sut = createFocusedMediaTimeline( + initialMediaItem = media, + ) + val cache = sut.orCache( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(media), + fileItems = persistentListOf(), + ) + ) + assertThat(cache.imageAndVideoItems.size).isEqualTo(1) + assertThat(cache.fileItems).isEmpty() + } + + @Test + fun `getTimeline returns the timeline provided by the room`() = runTest { + val createTimelineResult = lambdaRecorder> { + Result.success(FakeTimeline()) + } + val room = FakeJoinedRoom( + createTimelineResult = createTimelineResult, + ) + val sut = createFocusedMediaTimeline( + room = room, + eventId = AN_EVENT_ID, + ) + val timeline = sut.getTimeline() + assertThat(timeline.isSuccess).isTrue() + createTimelineResult.assertions().isCalledOnce().with(value(CreateTimelineParams.MediaOnlyFocused(AN_EVENT_ID))) + } + + @Test + fun `getTimeline returns the timeline provided by the room for pinned Events`() = runTest { + val createTimelineResult = lambdaRecorder> { + Result.success(FakeTimeline()) + } + val room = FakeJoinedRoom( + createTimelineResult = createTimelineResult, + ) + val sut = createFocusedMediaTimeline( + room = room, + eventId = AN_EVENT_ID, + onlyPinnedEvent = true, + ) + val timeline = sut.getTimeline() + assertThat(timeline.isSuccess).isTrue() + createTimelineResult.assertions().isCalledOnce().with(value(CreateTimelineParams.PinnedOnly)) + } + + private fun createFocusedMediaTimeline( + room: JoinedRoom = FakeJoinedRoom(), + eventId: EventId = AN_EVENT_ID, + initialMediaItem: MediaItem.Event = aMediaItemImage(), + onlyPinnedEvent: Boolean = false, + ) = FocusedMediaTimeline( + room = room, + eventId = eventId, + initialMediaItem = initialMediaItem, + onlyPinnedEvents = onlyPinnedEvent, + ) +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/LiveMediaTimelineTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/LiveMediaTimelineTest.kt new file mode 100644 index 0000000..fae858d --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/LiveMediaTimelineTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.datasource + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.CreateTimelineParams +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LiveMediaTimelineTest { + @Test + fun `LiveMediaTimeline cache is always null`() = runTest { + val sut = createLiveMediaTimeline() + assertThat(sut.cache).isNull() + } + + @Test + fun `getTimeline returns the timeline provided by the room, then from cache`() = runTest { + val createTimelineResult = lambdaRecorder> { + Result.success(FakeTimeline()) + } + val room = FakeJoinedRoom( + createTimelineResult = createTimelineResult, + ) + val sut = createLiveMediaTimeline( + room = room, + ) + val timeline = sut.getTimeline() + assertThat(timeline.isSuccess).isTrue() + createTimelineResult.assertions().isCalledOnce().with(value(CreateTimelineParams.MediaOnly)) + val timeline2 = sut.getTimeline() + assertThat(timeline2.isSuccess).isTrue() + // No called another time + createTimelineResult.assertions().isCalledOnce() + } + + private fun createLiveMediaTimeline( + room: JoinedRoom = FakeJoinedRoom(), + ) = LiveMediaTimeline( + room = room, + ) +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaItemsPostProcessorTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaItemsPostProcessorTest.kt new file mode 100644 index 0000000..53cc25c --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaItemsPostProcessorTest.kt @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.datasource + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemAudio +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemDateSeparator +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemFile +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemLoadingIndicator +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVideo +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice +import kotlinx.collections.immutable.toImmutableList +import org.junit.Test + +class MediaItemsPostProcessorTest { + private val file1 = aMediaItemFile(id = UniqueId("1")) + private val file2 = aMediaItemFile(id = UniqueId("2")) + private val file3 = aMediaItemFile(id = UniqueId("3")) + private val audio1 = aMediaItemAudio(id = UniqueId("1")) + private val audio2 = aMediaItemAudio(id = UniqueId("2")) + private val audio3 = aMediaItemAudio(id = UniqueId("3")) + private val voice1 = aMediaItemVoice(id = UniqueId("1")) + private val voice2 = aMediaItemVoice(id = UniqueId("2")) + private val voice3 = aMediaItemVoice(id = UniqueId("3")) + private val image1 = aMediaItemImage(id = UniqueId("1")) + private val image2 = aMediaItemImage(id = UniqueId("2")) + private val image3 = aMediaItemImage(id = UniqueId("3")) + private val video1 = aMediaItemVideo(id = UniqueId("1")) + private val video2 = aMediaItemVideo(id = UniqueId("2")) + private val video3 = aMediaItemVideo(id = UniqueId("3")) + private val date1 = aMediaItemDateSeparator(id = UniqueId("1")) + private val date2 = aMediaItemDateSeparator(id = UniqueId("2")) + private val date3 = aMediaItemDateSeparator(id = UniqueId("3")) + private val loading1 = aMediaItemLoadingIndicator(id = UniqueId("1")) + + @Test + fun `process Empty`() { + test( + mediaItems = listOf(), + expectedImageAndVideoItems = emptyList(), + expectedFileItems = emptyList(), + ) + } + + @Test + fun `process will reorder files`() { + test( + mediaItems = listOf( + audio1, + file3, + file2, + file1, + date1, + ), + expectedImageAndVideoItems = emptyList(), + expectedFileItems = listOf( + date1, + audio1, + file3, + file2, + file1, + ), + ) + } + + @Test + fun `process will reorder images`() { + test( + mediaItems = listOf( + image3, + image2, + image1, + date1, + ), + expectedImageAndVideoItems = listOf( + date1, + image3, + image2, + image1, + ), + expectedFileItems = emptyList(), + ) + } + + @Test + fun `process will split images, videos and files`() { + test( + mediaItems = listOf( + audio1, + file1, + image1, + video1, + date1, + ), + expectedImageAndVideoItems = listOf( + date1, + image1, + video1, + ), + expectedFileItems = listOf( + date1, + audio1, + file1, + ), + ) + } + + @Test + fun `process will skip date if there is no items`() { + test( + mediaItems = listOf( + date1, + date2, + date3, + ), + expectedImageAndVideoItems = emptyList(), + expectedFileItems = emptyList(), + ) + } + + @Test + fun `process will add the loading indicator to both list`() { + test( + mediaItems = listOf( + loading1, + ), + expectedImageAndVideoItems = listOf( + loading1, + ), + expectedFileItems = listOf( + loading1, + ), + ) + } + + @Test + fun `process will handle complex case`() { + test( + mediaItems = listOf( + file3, + date3, + video3, + video2, + date2, + voice3, + voice2, + voice1, + audio3, + audio2, + audio1, + file1, + image1, + video1, + date1, + loading1, + ), + expectedImageAndVideoItems = listOf( + date2, + video3, + video2, + date1, + image1, + video1, + loading1, + ), + expectedFileItems = listOf( + date3, + file3, + date1, + voice3, + voice2, + voice1, + audio3, + audio2, + audio1, + file1, + loading1, + ), + ) + } + + private fun test( + mediaItems: List, + expectedImageAndVideoItems: List, + expectedFileItems: List, + ) { + val sut = MediaItemsPostProcessor() + val result = sut.process(mediaItems.toImmutableList()) + + // Compare the lists to have better failure info + assertThat(result.imageAndVideoItems.toList()).isEqualTo(expectedImageAndVideoItems) + assertThat(result.fileItems.toList()).isEqualTo(expectedFileItems) + + assertThat(result).isEqualTo( + GroupedMediaItems( + imageAndVideoItems = expectedImageAndVideoItems.toImmutableList(), + fileItems = expectedFileItems.toImmutableList(), + ) + ) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaGalleryDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaGalleryDataSourceTest.kt new file mode 100644 index 0000000..bb8419d --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaGalleryDataSourceTest.kt @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.datasource + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.dateformatter.test.FakeDateFormatter +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.ThumbnailInfo +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +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.MessageFormat +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_UNIQUE_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.matrix.test.timeline.aMessageContent +import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem +import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class TimelineMediaGalleryDataSourceTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `test - not started TimelineMediaGalleryDataSource emits no events`() = runTest { + val fakeTimeline = FakeTimeline() + val sut = createTimelineMediaGalleryDataSource( + room = FakeJoinedRoom( + createTimelineResult = { Result.success(fakeTimeline) }, + roomCoroutineScope = backgroundScope, + ) + ) + sut.groupedMediaItemsFlow().test { + // Also, loadMore and deleteItem should be no-op + sut.loadMore(Timeline.PaginationDirection.BACKWARDS) + sut.deleteItem(AN_EVENT_ID) + expectNoEvents() + } + } + + @Test + fun `test - getLastData should return the previous emitted data`() { + val fakeTimeline = FakeTimeline() + runTest { + val sut = createTimelineMediaGalleryDataSource( + room = FakeJoinedRoom( + createTimelineResult = { Result.success(fakeTimeline) }, + roomCoroutineScope = backgroundScope, + ) + ) + sut.start() + assertThat(sut.getLastData()).isEqualTo(AsyncData.Uninitialized) + sut.groupedMediaItemsFlow().test { + assertThat(awaitItem().isLoading()).isTrue() + assertThat(sut.getLastData().isLoading()).isTrue() + assertThat(awaitItem()).isEqualTo( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(), + fileItems = persistentListOf(), + ) + ) + ) + assertThat(sut.getLastData().isSuccess()).isTrue() + // Also test that starting again should have no effect + sut.start() + } + } + // Ensure that the timeline has been closed on flow completion + assertThat(fakeTimeline.closeCounter).isEqualTo(1) + } + + @Test + fun `test - load more should call the timeline paginate method`() = runTest { + val paginateLambdaRecorder = + lambdaRecorder> { _ -> + Result.success(true) + } + val fakeTimeline = FakeTimeline().apply { + paginateLambda = paginateLambdaRecorder + } + val sut = createTimelineMediaGalleryDataSource( + room = FakeJoinedRoom( + createTimelineResult = { Result.success(fakeTimeline) }, + roomCoroutineScope = backgroundScope, + ) + ) + sut.start() + sut.groupedMediaItemsFlow().test { + skipItems(2) + sut.loadMore(Timeline.PaginationDirection.BACKWARDS) + paginateLambdaRecorder.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS)) + } + } + + @Test + fun `test - delete item should call the timeline redact method`() = runTest { + val redactEventLambdaRecorder = + lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val fakeTimeline = FakeTimeline().apply { + redactEventLambda = redactEventLambdaRecorder + } + val sut = createTimelineMediaGalleryDataSource( + room = FakeJoinedRoom( + createTimelineResult = { Result.success(fakeTimeline) }, + roomCoroutineScope = backgroundScope, + ) + ) + sut.start() + sut.groupedMediaItemsFlow().test { + skipItems(2) + sut.deleteItem(AN_EVENT_ID) + redactEventLambdaRecorder.assertions().isCalledOnce().with( + value(AN_EVENT_ID.toEventOrTransactionId()), + value(null), + ) + } + } + + @Test + fun `test - failing to load timeline should emit an error`() = runTest { + val sut = createTimelineMediaGalleryDataSource( + room = FakeJoinedRoom( + createTimelineResult = { Result.failure(AN_EXCEPTION) }, + roomCoroutineScope = backgroundScope, + ) + ) + sut.start() + sut.groupedMediaItemsFlow().test { + assertThat(awaitItem().isLoading()).isTrue() + assertThat(sut.getLastData().isLoading()).isTrue() + assertThat(awaitItem()).isEqualTo( + AsyncData.Failure(AN_EXCEPTION) + ) + } + } + + @Test + fun `test - when timeline emits new data, the flow emits the data`() = runTest { + val timelineItems = MutableStateFlow>(emptyList()) + val fakeTimeline = FakeTimeline( + timelineItems = timelineItems, + ) + val sut = createTimelineMediaGalleryDataSource( + room = FakeJoinedRoom( + createTimelineResult = { Result.success(fakeTimeline) }, + roomCoroutineScope = backgroundScope, + ) + ) + sut.start() + sut.groupedMediaItemsFlow().test { + assertThat(awaitItem().isLoading()).isTrue() + assertThat(sut.getLastData().isLoading()).isTrue() + assertThat(awaitItem()).isEqualTo( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(), + fileItems = persistentListOf(), + ) + ) + ) + timelineItems.emit( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + content = aMessageContent( + messageType = ImageMessageType( + filename = "body.jpg", + caption = "body.jpg caption", + formattedCaption = FormattedBody(MessageFormat.HTML, "formatted"), + source = MediaSource("url"), + info = ImageInfo( + height = 10L, + width = 5L, + mimetype = MimeTypes.Jpeg, + size = 888L, + thumbnailInfo = ThumbnailInfo( + height = 10L, + width = 5L, + mimetype = MimeTypes.Jpeg, + size = 111L, + ), + thumbnailSource = MediaSource("url_thumbnail"), + blurhash = A_BLUR_HASH, + ) + ) + ) + ), + ) + ) + ) + assertThat(awaitItem()).isEqualTo( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf( + MediaItem.Image( + id = A_UNIQUE_ID, + eventId = AN_EVENT_ID, + mediaInfo = MediaInfo( + filename = "body.jpg", + fileSize = 888L, + caption = "body.jpg caption", + mimeType = MimeTypes.Jpeg, + formattedFileSize = "888 Bytes", + fileExtension = "jpg", + senderId = A_USER_ID, + senderName = "alice", + senderAvatar = null, + dateSent = "0 Day false", + dateSentFull = "0 Full false", + waveform = null, + duration = null + ), + mediaSource = MediaSource("url"), + thumbnailSource = MediaSource("url_thumbnail"), + ) + ), + fileItems = persistentListOf() + ) + ) + ) + } + } +} + +internal fun TestScope.createTimelineMediaGalleryDataSource( + room: JoinedRoom = FakeJoinedRoom( + liveTimeline = FakeTimeline(), + ), +): TimelineMediaGalleryDataSource { + return TimelineMediaGalleryDataSource( + room = room, + mediaTimeline = LiveMediaTimeline(room), + timelineMediaItemsFactory = createTimelineMediaItemsFactory(), + mediaItemsPostProcessor = MediaItemsPostProcessor(), + ) +} + +fun TestScope.createTimelineMediaItemsFactory() = TimelineMediaItemsFactory( + dispatchers = testCoroutineDispatchers(), + virtualItemFactory = VirtualItemFactory( + dateFormatter = FakeDateFormatter(), + ), + eventItemFactory = EventItemFactory( + fileSizeFormatter = FakeFileSizeFormatter(), + fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), + dateFormatter = FakeDateFormatter(), + ), +) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheetTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheetTest.kt new file mode 100644 index 0000000..4d8b81a --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheetTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.details + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.setSafeContent +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MediaDeleteConfirmationBottomSheetTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on Cancel invokes expected callback`() { + val state = aMediaDeleteConfirmationState() + ensureCalledOnce { callback -> + rule.setMediaDeleteConfirmationBottomSheet( + state = state, + onDismiss = callback, + ) + rule.clickOn(CommonStrings.action_cancel) + } + } + + @Test + fun `clicking on Remove invokes expected callback`() { + val state = aMediaDeleteConfirmationState() + ensureCalledOnceWithParam(state.eventId) { callback -> + rule.setMediaDeleteConfirmationBottomSheet( + state = state, + onDelete = callback, + ) + rule.onNodeWithText(rule.activity.getString(CommonStrings.action_remove)).assertExists() + rule.clickOn(CommonStrings.action_remove) + } + } +} + +private fun AndroidComposeTestRule.setMediaDeleteConfirmationBottomSheet( + state: MediaBottomSheetState.MediaDeleteConfirmationState, + onDelete: (EventId) -> Unit = EnsureNeverCalledWithParam(), + onDismiss: () -> Unit = EnsureNeverCalled(), +) { + setSafeContent { + MediaDeleteConfirmationBottomSheet( + state = state, + onDelete = onDelete, + onDismiss = onDismiss, + ) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheetTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheetTest.kt new file mode 100644 index 0000000..580cd89 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheetTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.details + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.setSafeContent +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class MediaDetailsBottomSheetTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + @Config(qualifiers = "h1024dp") + fun `clicking on View in timeline invokes expected callback`() { + val state = aMediaDetailsBottomSheetState() + ensureCalledOnceWithParam(state.eventId) { callback -> + rule.setMediaDetailsBottomSheet( + state = state, + onViewInTimeline = callback, + ) + rule.clickOn(CommonStrings.action_view_in_timeline) + } + } + + @Test + @Config(qualifiers = "h1024dp") + fun `clicking on Share invokes expected callback`() { + val state = aMediaDetailsBottomSheetState() + ensureCalledOnceWithParam(state.eventId) { callback -> + rule.setMediaDetailsBottomSheet( + state = state, + onShare = callback, + ) + rule.clickOn(CommonStrings.action_share) + } + } + + @Test + @Config(qualifiers = "h1024dp") + fun `clicking on Forward invokes expected callback`() { + val state = aMediaDetailsBottomSheetState() + ensureCalledOnceWithParam(state.eventId) { callback -> + rule.setMediaDetailsBottomSheet( + state = state, + onForward = callback, + ) + rule.clickOn(CommonStrings.action_forward) + } + } + + @Test + @Config(qualifiers = "h1024dp") + fun `clicking on Save invokes expected callback`() { + val state = aMediaDetailsBottomSheetState() + ensureCalledOnceWithParam(state.eventId) { callback -> + rule.setMediaDetailsBottomSheet( + state = state, + onDownload = callback, + ) + rule.clickOn(CommonStrings.action_save) + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on Remove invokes expected callback`() { + val state = aMediaDetailsBottomSheetState() + ensureCalledOnceWithParam(state.eventId) { callback -> + rule.setMediaDetailsBottomSheet( + state = state, + onDelete = callback, + ) + rule.onNodeWithText(rule.activity.getString(CommonStrings.action_remove)).assertExists() + rule.clickOn(CommonStrings.action_remove) + } + } + + @Config(qualifiers = "h1024dp") + @Test + fun `Remove is not present if canDelete is false`() { + val state = aMediaDetailsBottomSheetState( + canDelete = false, + ) + rule.setMediaDetailsBottomSheet( + state = state, + ) + rule.onNodeWithText(rule.activity.getString(CommonStrings.action_remove)).assertDoesNotExist() + } +} + +private fun AndroidComposeTestRule.setMediaDetailsBottomSheet( + state: MediaBottomSheetState.MediaDetailsBottomSheetState, + onViewInTimeline: (EventId) -> Unit = EnsureNeverCalledWithParam(), + onShare: (EventId) -> Unit = EnsureNeverCalledWithParam(), + onForward: (EventId) -> Unit = EnsureNeverCalledWithParam(), + onDownload: (EventId) -> Unit = EnsureNeverCalledWithParam(), + onDelete: (EventId) -> Unit = EnsureNeverCalledWithParam(), + onDismiss: () -> Unit = EnsureNeverCalled(), +) { + setSafeContent { + MediaDetailsBottomSheet( + state = state, + onViewInTimeline = onViewInTimeline, + onShare = onShare, + onForward = onForward, + onDownload = onDownload, + onDelete = onDelete, + onDismiss = onDismiss, + ) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryNavigator.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryNavigator.kt new file mode 100644 index 0000000..0d8ff6b --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryNavigator.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeMediaGalleryNavigator( + private val onViewInTimelineClickLambda: (EventId) -> Unit = { lambdaError() }, + private val onForwardClickLambda: (EventId) -> Unit = { lambdaError() }, +) : MediaGalleryNavigator { + override fun onViewInTimelineClick(eventId: EventId) { + onViewInTimelineClickLambda(eventId) + } + + override fun onForwardClick(eventId: EventId) { + onForwardClickLambda(eventId) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt new file mode 100644 index 0000000..e890b7f --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt @@ -0,0 +1,452 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import android.net.Uri +import app.cash.turbine.ReceiveTurbine +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.test.AN_EVENT_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_ID_2 +import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.mediaviewer.impl.datasource.FakeMediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage +import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions +import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class MediaGalleryPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private val mockMediaUri: Uri = mockk("localMediaUri") + private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri) + + @Test + fun `present - initial state`() = runTest { + val startLambda = lambdaRecorder { } + val presenter = createMediaGalleryPresenter( + mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = startLambda, + ), + room = FakeJoinedRoom( + baseRoom = FakeBaseRoom(initialRoomInfo = aRoomInfo(name = A_ROOM_NAME)), + createTimelineResult = { Result.success(FakeTimeline()) }, + ) + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images) + assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + assertThat(initialState.roomName).isEqualTo(A_ROOM_NAME) + assertThat(initialState.groupedMediaItems.isUninitialized()).isTrue() + assertThat(initialState.snackbarMessage).isNull() + } + startLambda.assertions().isCalledOnce() + } + + @Test + fun `present - change mode`() = runTest { + val presenter = createMediaGalleryPresenter( + room = FakeJoinedRoom( + baseRoom = FakeBaseRoom(initialRoomInfo = aRoomInfo(name = A_ROOM_NAME)), + createTimelineResult = { Result.success(FakeTimeline()) }, + ) + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images) + initialState.eventSink(MediaGalleryEvents.ChangeMode(MediaGalleryMode.Files)) + val state = awaitItem() + assertThat(state.mode).isEqualTo(MediaGalleryMode.Files) + state.eventSink(MediaGalleryEvents.ChangeMode(MediaGalleryMode.Images)) + val imageModeState = awaitItem() + assertThat(imageModeState.mode).isEqualTo(MediaGalleryMode.Images) + } + } + + @Test + fun `present - bottom sheet state - own message and can delete own`() = runTest { + `present - bottom sheet state - own message`(canDeleteOwn = true) + } + + @Test + fun `present - bottom sheet state - own message and cannot delete own`() = runTest { + `present - bottom sheet state - own message`(canDeleteOwn = false) + } + + private suspend fun `present - bottom sheet state - own message`(canDeleteOwn: Boolean) { + val presenter = createMediaGalleryPresenter( + room = FakeJoinedRoom( + createTimelineResult = { Result.success(FakeTimeline()) }, + baseRoom = FakeBaseRoom( + sessionId = A_USER_ID, + initialRoomInfo = aRoomInfo(name = A_ROOM_NAME), + canRedactOwnResult = { Result.success(canDeleteOwn) } + ), + ) + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + val item = aMediaItemImage( + eventId = AN_EVENT_ID, + senderId = A_USER_ID, + ) + initialState.eventSink(MediaGalleryEvents.OpenInfo(item)) + val state = awaitItem() + assertThat(state.mediaBottomSheetState).isEqualTo( + MediaBottomSheetState.MediaDetailsBottomSheetState( + eventId = AN_EVENT_ID, + canDelete = canDeleteOwn, + mediaInfo = item.mediaInfo, + thumbnailSource = item.mediaSource, + ) + ) + // Close the bottom sheet + state.eventSink(MediaGalleryEvents.CloseBottomSheet) + val closedState = awaitItem() + assertThat(closedState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + } + } + + @Test + fun `present - bottom sheet state - other message and can delete other`() = runTest { + `present - bottom sheet state - other message`(canDeleteOther = true) + } + + @Test + fun `present - bottom sheet state - other message and cannot delete other`() = runTest { + `present - bottom sheet state - other message`(canDeleteOther = false) + } + + private suspend fun `present - bottom sheet state - other message`(canDeleteOther: Boolean) { + val presenter = createMediaGalleryPresenter( + room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + sessionId = A_USER_ID, + initialRoomInfo = aRoomInfo(name = A_ROOM_NAME), + canRedactOtherResult = { Result.success(canDeleteOther) }, + ), + createTimelineResult = { Result.success(FakeTimeline()) } + ) + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + val item = aMediaItemImage( + eventId = AN_EVENT_ID, + senderId = A_USER_ID_2, + ) + initialState.eventSink(MediaGalleryEvents.OpenInfo(item)) + val state = awaitItem() + assertThat(state.mediaBottomSheetState).isEqualTo( + MediaBottomSheetState.MediaDetailsBottomSheetState( + eventId = AN_EVENT_ID, + canDelete = canDeleteOther, + mediaInfo = item.mediaInfo, + thumbnailSource = item.mediaSource, + ) + ) + // Close the bottom sheet + state.eventSink(MediaGalleryEvents.CloseBottomSheet) + val closedState = awaitItem() + assertThat(closedState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + } + } + + @Test + fun `present - delete bottom sheet`() = runTest { + val presenter = createMediaGalleryPresenter( + room = FakeJoinedRoom( + baseRoom = FakeBaseRoom(initialRoomInfo = aRoomInfo(name = A_ROOM_NAME)), + createTimelineResult = { Result.success(FakeTimeline()) }, + ) + ) + presenter.test { + val initialState = awaitFirstItem() + // Delete bottom sheet + val item = aMediaItemImage() + initialState.eventSink(MediaGalleryEvents.ConfirmDelete(AN_EVENT_ID, item.mediaInfo, item.thumbnailSource)) + val deleteState = awaitItem() + assertThat(deleteState.mediaBottomSheetState).isEqualTo( + MediaBottomSheetState.MediaDeleteConfirmationState( + eventId = AN_EVENT_ID, + mediaInfo = item.mediaInfo, + thumbnailSource = item.thumbnailSource, + ) + ) + // Close the bottom sheet + deleteState.eventSink(MediaGalleryEvents.CloseBottomSheet) + val deleteClosedState = awaitItem() + assertThat(deleteClosedState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + } + } + + @Test + fun `present - delete item`() = runTest { + val deleteItemLambda = lambdaRecorder { } + val presenter = createMediaGalleryPresenter( + mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + deleteItemLambda = deleteItemLambda, + ), + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(MediaGalleryEvents.Delete(AN_EVENT_ID)) + deleteItemLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) + } + } + + @Test + fun `present - share item - item not found`() = runTest { + val presenter = createMediaGalleryPresenter() + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(MediaGalleryEvents.Share(AN_EVENT_ID)) + } + } + + @Test + fun `present - share item - item found`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)), + fileItems = emptyList(), + ) + ) + ) + val presenter = createMediaGalleryPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(MediaGalleryEvents.Share(AN_EVENT_ID)) + val finalState = awaitItem() + assertThat(finalState.snackbarMessage).isNull() + } + } + + @Test + fun `present - share item - item found - download error`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)), + fileItems = emptyList(), + ) + ) + ) + val presenter = createMediaGalleryPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + matrixMediaLoader = FakeMatrixMediaLoader().apply { shouldFail = true }, + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(MediaGalleryEvents.Share(AN_EVENT_ID)) + skipItems(1) + val finalState = awaitItem() + assertThat(finalState.snackbarMessage).isInstanceOf(SnackbarMessage::class.java) + } + } + + @Test + fun `present - save on disk - item not found`() = runTest { + val presenter = createMediaGalleryPresenter() + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(MediaGalleryEvents.SaveOnDisk(AN_EVENT_ID)) + } + } + + @Test + fun `present - save on disk - item found`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)), + fileItems = emptyList(), + ) + ) + ) + val presenter = createMediaGalleryPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(MediaGalleryEvents.SaveOnDisk(AN_EVENT_ID)) + skipItems(1) + val finalState = awaitItem() + assertThat(finalState.snackbarMessage?.messageResId).isEqualTo(CommonStrings.common_file_saved_on_disk_android) + } + } + + @Test + fun `present - save on disk - item found - download error`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)), + fileItems = emptyList(), + ) + ) + ) + val presenter = createMediaGalleryPresenter( + mediaGalleryDataSource = mediaGalleryDataSource, + matrixMediaLoader = FakeMatrixMediaLoader().apply { shouldFail = true }, + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(MediaGalleryEvents.SaveOnDisk(AN_EVENT_ID)) + skipItems(1) + val finalState = awaitItem() + assertThat(finalState.snackbarMessage).isInstanceOf(SnackbarMessage::class.java) + } + } + + @Test + fun `present - view in timeline closes the bottom sheet and invokes the navigator`() = runTest { + val onViewInTimelineClickLambda = lambdaRecorder { } + val navigator = FakeMediaGalleryNavigator( + onViewInTimelineClickLambda = onViewInTimelineClickLambda, + ) + val presenter = createMediaGalleryPresenter( + room = FakeJoinedRoom( + createTimelineResult = { Result.success(FakeTimeline()) }, + baseRoom = FakeBaseRoom( + canRedactOwnResult = { Result.success(true) }, + ), + ), + navigator = navigator, + ) + presenter.test { + val initialState = awaitFirstItem() + val item = aMediaItemImage( + eventId = AN_EVENT_ID, + senderId = A_USER_ID, + ) + initialState.eventSink(MediaGalleryEvents.OpenInfo(item)) + val withBottomSheetState = awaitItem() + assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java) + withBottomSheetState.eventSink(MediaGalleryEvents.ViewInTimeline(AN_EVENT_ID)) + val finalState = awaitItem() + assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) + } + } + + @Test + fun `present - forward closes the bottom sheet and invokes the navigator`() = runTest { + val onForwardClickLambda = lambdaRecorder { } + val navigator = FakeMediaGalleryNavigator( + onForwardClickLambda = onForwardClickLambda, + ) + val presenter = createMediaGalleryPresenter( + room = FakeJoinedRoom( + createTimelineResult = { Result.success(FakeTimeline()) }, + baseRoom = FakeBaseRoom( + canRedactOwnResult = { Result.success(true) }, + ), + ), + navigator = navigator, + ) + presenter.test { + val initialState = awaitFirstItem() + val item = aMediaItemImage( + eventId = AN_EVENT_ID, + senderId = A_USER_ID, + ) + initialState.eventSink(MediaGalleryEvents.OpenInfo(item)) + val withBottomSheetState = awaitItem() + assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java) + withBottomSheetState.eventSink(MediaGalleryEvents.Forward(AN_EVENT_ID)) + val finalState = awaitItem() + assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + onForwardClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) + } + } + + @Test + fun `present - load more`() = runTest { + val loadMoreLambda = lambdaRecorder { } + val presenter = createMediaGalleryPresenter( + mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + loadMoreLambda = loadMoreLambda, + ), + ) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(MediaGalleryEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS)) + loadMoreLambda.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS)) + } + } + + private suspend fun ReceiveTurbine.awaitFirstItem(): T { + return awaitItem() + } + + private fun createMediaGalleryPresenter( + matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(), + mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ), + localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(), + snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), + navigator: MediaGalleryNavigator = FakeMediaGalleryNavigator(), + room: JoinedRoom = FakeJoinedRoom( + liveTimeline = FakeTimeline(), + ), + ): MediaGalleryPresenter { + return MediaGalleryPresenter( + navigator = navigator, + room = room, + mediaGalleryDataSource = mediaGalleryDataSource, + localMediaFactory = localMediaFactory, + mediaLoader = matrixMediaLoader, + localMediaActions = localMediaActions, + snackbarDispatcher = snackbarDispatcher, + ) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActionsTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActionsTest.kt new file mode 100644 index 0000000..cd7b700 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActionsTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local + +import android.net.Uri +import androidx.activity.compose.LocalActivityResultRegistryOwner +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalContext +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class AndroidLocalMediaActionsTest { + @Test + fun `present - AndroidLocalMediaAction configure`() = runTest { + val sut = createAndroidLocalMediaActions() + moleculeFlow(RecompositionMode.Immediate) { + CompositionLocalProvider( + LocalContext provides RuntimeEnvironment.getApplication(), + LocalActivityResultRegistryOwner provides NoOpActivityResultRegistryOwner() + ) { + sut.Configure() + } + }.test { + awaitItem() + } + } + + @Test + fun `test AndroidLocalMediaAction share`() = runTest { + val sut = createAndroidLocalMediaActions() + val result = sut.share(aLocalMedia(Uri.parse("file://afile"))) + assertThat(result.exceptionOrNull()).isNotNull() + } + + @Test + fun `test AndroidLocalMediaAction open`() = runTest { + val sut = createAndroidLocalMediaActions() + val result = sut.open(aLocalMedia(Uri.parse("file://afile"))) + assertThat(result.exceptionOrNull()).isNotNull() + } + + @Test + fun `test AndroidLocalMediaAction save on disk`() = runTest { + val sut = createAndroidLocalMediaActions() + val result = sut.saveOnDisk(aLocalMedia(Uri.parse("file://afile"))) + assertThat(result.exceptionOrNull()).isNotNull() + } + + private fun TestScope.createAndroidLocalMediaActions() = AndroidLocalMediaActions( + RuntimeEnvironment.getApplication(), + testCoroutineDispatchers(), + aBuildMeta() + ) +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt new file mode 100644 index 0000000..f01ac1d --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.media.MediaFile +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.media.FakeMediaFile +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.anImageMediaInfo +import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class AndroidLocalMediaFactoryTest { + @Test + fun `test AndroidLocalMediaFactory`() { + val sut = createAndroidLocalMediaFactory() + val result = sut.createFromMediaFile( + mediaFile = aMediaFile(), + mediaInfo = anImageMediaInfo( + senderId = A_USER_ID, + senderName = A_USER_NAME, + dateSent = "12:34", + dateSentFull = "full", + ) + ) + assertThat(result.uri.toString()).endsWith("aPath") + assertThat(result.info).isEqualTo( + MediaInfo( + filename = "an image file.jpg", + // MediaFile does not provide file size in this test + fileSize = 0L, + caption = null, + mimeType = MimeTypes.Jpeg, + formattedFileSize = "4MB", + fileExtension = "jpg", + senderId = A_USER_ID, + senderName = A_USER_NAME, + senderAvatar = null, + dateSent = "12:34", + dateSentFull = "full", + waveform = null, + duration = null, + ) + ) + } + + private fun aMediaFile(): MediaFile { + return FakeMediaFile("aPath") + } + + private fun createAndroidLocalMediaFactory(): AndroidLocalMediaFactory { + return AndroidLocalMediaFactory( + RuntimeEnvironment.getApplication(), + FakeFileSizeFormatter(), + FileExtensionExtractorWithoutValidation() + ) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/NoOpActivityResultRegistryOwner.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/NoOpActivityResultRegistryOwner.kt new file mode 100644 index 0000000..e3e2399 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/NoOpActivityResultRegistryOwner.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local + +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.ActivityResultRegistryOwner +import androidx.activity.result.contract.ActivityResultContract +import androidx.core.app.ActivityOptionsCompat + +class NoOpActivityResultRegistryOwner : ActivityResultRegistryOwner { + override val activityResultRegistry: ActivityResultRegistry + get() = NoOpActivityResultRegistry() +} + +class NoOpActivityResultRegistry : ActivityResultRegistry() { + override fun onLaunch( + requestCode: Int, + contract: ActivityResultContract, + input: I, + options: ActivityOptionsCompat?, + ) = Unit +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/model/GroupedMediaItemsTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/model/GroupedMediaItemsTest.kt new file mode 100644 index 0000000..dd36b40 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/model/GroupedMediaItemsTest.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.model + +import com.google.common.truth.Truth.assertThat +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.AN_EVENT_ID_3 +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test + +class GroupedMediaItemsTest { + @Test + fun `hasEvent returns the expected value`() { + val sut = GroupedMediaItems( + imageAndVideoItems = persistentListOf( + aMediaItemImage(eventId = AN_EVENT_ID), + ), + fileItems = persistentListOf( + aMediaItemAudio(eventId = AN_EVENT_ID_2), + ), + ) + assertThat(sut.hasEvent(AN_EVENT_ID)).isTrue() + assertThat(sut.hasEvent(AN_EVENT_ID_2)).isTrue() + assertThat(sut.hasEvent(AN_EVENT_ID_3)).isFalse() + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/util/FileExtensionExtractorWithValidationTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/util/FileExtensionExtractorWithValidationTest.kt new file mode 100644 index 0000000..3c5c915 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/util/FileExtensionExtractorWithValidationTest.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.util + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class FileExtensionExtractorWithValidationTest { + @Test + fun `test FileExtensionExtractor with validation OK`() { + val sut = FileExtensionExtractorWithValidation() + assertThat(sut.extractFromName("test.txt")).isEqualTo("txt") + } + + @Test + fun `test FileExtensionExtractor with validation ERROR`() { + val sut = FileExtensionExtractorWithValidation() + assertThat(sut.extractFromName("test.bla")).isEqualTo("bin") + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/FakeMediaViewerNavigator.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/FakeMediaViewerNavigator.kt new file mode 100644 index 0000000..a116faa --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/FakeMediaViewerNavigator.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeMediaViewerNavigator( + private val onViewInTimelineClickLambda: (EventId) -> Unit = { lambdaError() }, + private val onForwardClickLambda: (EventId, Boolean) -> Unit = { _, _ -> lambdaError() }, + private val onItemDeletedLambda: () -> Unit = { lambdaError() }, +) : MediaViewerNavigator { + override fun onViewInTimelineClick(eventId: EventId) { + onViewInTimelineClickLambda(eventId) + } + + override fun onForwardClick(eventId: EventId, fromPinnedEvents: Boolean) { + onForwardClickLambda(eventId, fromPinnedEvents) + } + + override fun onItemDeleted() { + onItemDeletedLambda() + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt new file mode 100644 index 0000000..44eed27 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt @@ -0,0 +1,282 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import android.net.Uri +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.timeline.Timeline +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.AN_EXCEPTION +import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader +import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint.MediaViewerMode +import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory +import io.element.android.libraries.mediaviewer.impl.datasource.FakeMediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.gallery.aGroupedMediaItems +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemDateSeparator +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemFile +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemLoadingIndicator +import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory +import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +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 MediaViewerDataSourceTest { + private val mockMediaUrl: Uri = mockk("localMediaUri") + + @Test + fun `setup should start the gallery data source`() = runTest { + val startLambda = lambdaRecorder { } + val galleryDataSource = FakeMediaGalleryDataSource( + startLambda = startLambda + ) + val sut = createMediaViewerDataSource( + galleryDataSource = galleryDataSource, + ) + sut.setup() + startLambda.assertions().isCalledOnce() + } + + @Test + fun `test dispose`() = runTest { + val sut = createMediaViewerDataSource() + sut.dispose() + } + + @Test + fun `test dataFlow uninitialized, loading and error`() = runTest { + val galleryDataSource = FakeMediaGalleryDataSource() + val sut = createMediaViewerDataSource( + galleryDataSource = galleryDataSource, + ) + sut.dataFlow().test { + galleryDataSource.emitGroupedMediaItems(AsyncData.Uninitialized) + assertThat(awaitItem().first()).isInstanceOf(MediaViewerPageData.Loading::class.java) + galleryDataSource.emitGroupedMediaItems(AsyncData.Loading()) + assertThat(awaitItem().first()).isInstanceOf(MediaViewerPageData.Loading::class.java) + galleryDataSource.emitGroupedMediaItems(AsyncData.Failure(AN_EXCEPTION)) + assertThat(awaitItem().first()).isEqualTo(MediaViewerPageData.Failure(AN_EXCEPTION)) + } + } + + @Test + fun `test dataFlow empty`() = runTest { + val galleryDataSource = FakeMediaGalleryDataSource() + val sut = createMediaViewerDataSource( + galleryDataSource = galleryDataSource, + ) + sut.dataFlow().test { + galleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf(), + fileItems = listOf(), + ) + ) + ) + val result = awaitItem() + assertThat(result).isEmpty() + } + } + + @Test + fun `test dataFlow loading items`() = runTest { + val galleryDataSource = FakeMediaGalleryDataSource() + val sut = createMediaViewerDataSource( + galleryDataSource = galleryDataSource, + ) + sut.dataFlow().test { + galleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf( + aMediaItemLoadingIndicator( + direction = Timeline.PaginationDirection.BACKWARDS, + ), + aMediaItemLoadingIndicator( + direction = Timeline.PaginationDirection.FORWARDS, + ), + ), + fileItems = listOf(), + ) + ) + ) + val result = awaitItem() + assertThat(result).containsExactly( + MediaViewerPageData.Loading( + direction = Timeline.PaginationDirection.BACKWARDS, + timestamp = A_FAKE_TIMESTAMP, + pagerKey = 0L, + ), + MediaViewerPageData.Loading( + direction = Timeline.PaginationDirection.FORWARDS, + timestamp = A_FAKE_TIMESTAMP, + pagerKey = 1L, + ), + ) + } + } + + @Test + fun `test dataFlow with data galleryMode image`() = runTest { + val galleryDataSource = FakeMediaGalleryDataSource() + val sut = createMediaViewerDataSource( + mode = MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.Media), + galleryDataSource = galleryDataSource, + ) + sut.dataFlow().test { + galleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)), + fileItems = listOf(aMediaItemFile(eventId = AN_EVENT_ID_2)), + ) + ) + ) + val result = awaitItem() + assertThat(result).hasSize(1) + assertThat((result.first() as MediaViewerPageData.MediaViewerData).eventId).isEqualTo(AN_EVENT_ID) + } + } + + @Test + fun `test dataFlow with data galleryMode files`() = runTest { + val galleryDataSource = FakeMediaGalleryDataSource() + val sut = createMediaViewerDataSource( + mode = MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.Media), + galleryDataSource = galleryDataSource, + ) + sut.dataFlow().test { + galleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)), + fileItems = listOf(aMediaItemFile(eventId = AN_EVENT_ID_2)), + ) + ) + ) + val result = awaitItem() + assertThat(result).hasSize(1) + assertThat((result.first() as MediaViewerPageData.MediaViewerData).eventId).isEqualTo(AN_EVENT_ID_2) + } + } + + @Test + fun `test dataFlow - date separator are filtered out`() = runTest { + val galleryDataSource = FakeMediaGalleryDataSource() + val sut = createMediaViewerDataSource( + galleryDataSource = galleryDataSource, + ) + sut.dataFlow().test { + galleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf(aMediaItemDateSeparator(), aMediaItemImage(), aMediaItemDateSeparator()), + fileItems = emptyList(), + ) + ) + ) + val result = awaitItem() + assertThat(result).hasSize(1) + } + } + + @Test + fun `loadMore invokes the gallery data source loadMore`() = runTest { + val loadMoreLambda = lambdaRecorder { } + val galleryDataSource = FakeMediaGalleryDataSource( + loadMoreLambda = loadMoreLambda + ) + val sut = createMediaViewerDataSource( + galleryDataSource = galleryDataSource, + ) + sut.loadMore(Timeline.PaginationDirection.BACKWARDS) + loadMoreLambda.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS)) + } + + @Test + fun `test dataFlow with data galleryMode image and load media`() = runTest { + val galleryDataSource = FakeMediaGalleryDataSource() + val sut = createMediaViewerDataSource( + galleryDataSource = galleryDataSource, + ) + sut.dataFlow().test { + galleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)), + ) + ) + ) + val result = awaitItem() + val mediaViewerData = result.first() as MediaViewerPageData.MediaViewerData + assertThat(mediaViewerData.downloadedMedia.value).isEqualTo(AsyncData.Uninitialized) + sut.loadMedia(mediaViewerData) + assertThat(mediaViewerData.downloadedMedia.value.isSuccess()).isTrue() + } + } + + @Test + fun `test dataFlow with data galleryMode image and load media with failure then success`() = runTest { + val galleryDataSource = FakeMediaGalleryDataSource() + val mediaLoader = FakeMatrixMediaLoader() + val sut = createMediaViewerDataSource( + galleryDataSource = galleryDataSource, + mediaLoader = mediaLoader, + ) + sut.dataFlow().test { + galleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + aGroupedMediaItems( + imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)), + ) + ) + ) + val result = awaitItem() + val mediaViewerData = result.first() as MediaViewerPageData.MediaViewerData + assertThat(mediaViewerData.downloadedMedia.value).isEqualTo(AsyncData.Uninitialized) + mediaLoader.shouldFail = true + sut.loadMedia(mediaViewerData) + assertThat(mediaViewerData.downloadedMedia.value.isFailure()).isTrue() + // clear the error + sut.clearLoadingError(mediaViewerData) + assertThat(mediaViewerData.downloadedMedia.value).isEqualTo(AsyncData.Uninitialized) + // load again with success + mediaLoader.shouldFail = false + sut.loadMedia(mediaViewerData) + assertThat(mediaViewerData.downloadedMedia.value.isSuccess()).isTrue() + } + } + + private fun TestScope.createMediaViewerDataSource( + mode: MediaViewerMode = MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.Media), + galleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(), + mediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(), + localMediaFactory: LocalMediaFactory = FakeLocalMediaFactory(mockMediaUrl), + ) = MediaViewerDataSource( + mode = mode, + dispatcher = testCoroutineDispatchers().computation, + galleryDataSource = galleryDataSource, + mediaLoader = mediaLoader, + localMediaFactory = localMediaFactory, + systemClock = FakeSystemClock(), + pagerKeysHandler = PagerKeysHandler(), + ) +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt new file mode 100644 index 0000000..d9769aa --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt @@ -0,0 +1,892 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.mediaviewer.impl.viewer + +import android.net.Uri +import app.cash.turbine.ReceiveTurbine +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader +import io.element.android.libraries.matrix.test.media.aMediaSource +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint +import io.element.android.libraries.mediaviewer.api.anApkMediaInfo +import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory +import io.element.android.libraries.mediaviewer.impl.R +import io.element.android.libraries.mediaviewer.impl.datasource.FakeMediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState +import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemLoadingIndicator +import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions +import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +private val TESTED_MEDIA_INFO = anApkMediaInfo( + senderId = A_USER_ID, +) + +@Suppress("LargeClass") +class MediaViewerPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + private val mockMediaUri: Uri = mockk("localMediaUri") + private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri) + private val aUrl = "aUrl" + + private val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + private val aBackwardLoadingIndicator = aMediaItemLoadingIndicator( + direction = Timeline.PaginationDirection.BACKWARDS + ) + private val aForwardLoadingIndicator = aMediaItemLoadingIndicator( + direction = Timeline.PaginationDirection.FORWARDS + ) + + @Test + fun `present - initial state null Event`() = runTest { + val presenter = createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canRedactOwnResult = { Result.success(true) }, + ) + ) + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.listData).isEmpty() + assertThat(initialState.currentIndex).isEqualTo(0) + assertThat(initialState.snackbarMessage).isNull() + assertThat(initialState.canShowInfo).isTrue() + assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + } + } + + @Test + fun `present - initial state cannot show info`() = runTest { + val presenter = createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + canShowInfo = false, + room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canRedactOwnResult = { Result.success(true) }, + ) + ) + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.listData).isEmpty() + assertThat(initialState.currentIndex).isEqualTo(0) + assertThat(initialState.snackbarMessage).isNull() + assertThat(initialState.canShowInfo).isFalse() + assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + } + } + + @Test + fun `present - initial state Event`() = runTest { + val presenter = createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + eventId = AN_EVENT_ID, + room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canRedactOwnResult = { Result.success(true) }, + ) + ) + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.listData).isEmpty() + assertThat(initialState.currentIndex).isEqualTo(0) + assertThat(initialState.snackbarMessage).isNull() + assertThat(initialState.canShowInfo).isTrue() + assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + } + } + + @Test + fun `present - initial state Event from other`() = runTest { + val presenter = createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + eventId = AN_EVENT_ID, + room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + sessionId = A_SESSION_ID_2, + canRedactOtherResult = { Result.success(false) }, + ) + ) + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.listData).isEmpty() + assertThat(initialState.currentIndex).isEqualTo(0) + assertThat(initialState.snackbarMessage).isNull() + assertThat(initialState.canShowInfo).isTrue() + assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + } + } + + @Test + fun `present - data source update`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage() + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.listData).isEmpty() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitFirstItem() + assertThat(updatedState.listData).hasSize(1) + val item = updatedState.listData.first() as MediaViewerPageData.MediaViewerData + assertThat(item.eventId).isNull() + assertThat(item.mediaInfo).isEqualTo(anImage.mediaInfo) + assertThat(item.mediaSource).isEqualTo(anImage.mediaSource) + assertThat(item.thumbnailSource).isEqualTo(anImage.thumbnailSource) + assertThat(item.downloadedMedia.value).isEqualTo(AsyncData.Uninitialized) + } + } + + @Test + fun `present - load media`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.LoadMedia( + aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + } + } + + @Test + fun `present - open info`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + mediaGalleryDataSource = mediaGalleryDataSource, + room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canRedactOwnResult = { Result.success(true) }, + ) + ) + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.OpenInfo( + aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + val withInfoState = awaitItem() + assertThat(withInfoState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java) + withInfoState.eventSink( + MediaViewerEvents.CloseBottomSheet + ) + val finalState = awaitItem() + assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + } + } + + @Test + fun `present - clear loading error`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.ClearLoadingError( + aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + } + } + + @Test + fun `present - share`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.Share( + aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + } + } + + @Test + fun `present - save on disk`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.SaveOnDisk( + aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + } + } + + @Test + fun `present - open with`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.OpenWith( + aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + } + } + + @Test + fun `present - delete and cancel`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.ConfirmDelete( + eventId = AN_EVENT_ID, + data = aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + val withBottomSheetState = awaitItem() + assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDeleteConfirmationState::class.java) + withBottomSheetState.eventSink( + MediaViewerEvents.CloseBottomSheet + ) + val finalState = awaitItem() + assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + } + } + + @Test + fun `present - delete`() = runTest { + val redactEventLambda = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val timeline = FakeTimeline().apply { + this.redactEventLambda = redactEventLambda + } + val onItemDeletedLambda = lambdaRecorder { } + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + room = FakeJoinedRoom( + liveTimeline = timeline, + baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }), + ), + mediaGalleryDataSource = mediaGalleryDataSource, + mediaViewerNavigator = FakeMediaViewerNavigator( + onItemDeletedLambda = onItemDeletedLambda + ) + ) + val anImage = aMediaItemImage( + eventId = AN_EVENT_ID, + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.ConfirmDelete( + eventId = AN_EVENT_ID, + data = aMediaViewerPageData( + mediaSource = MediaSource(aUrl) + ) + ) + ) + val withBottomSheetState = awaitItem() + assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDeleteConfirmationState::class.java) + updatedState.eventSink( + MediaViewerEvents.Delete( + eventId = AN_EVENT_ID, + ) + ) + val finalState = awaitItem() + assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + redactEventLambda.assertions() + .isCalledOnce() + .with( + value(AN_EVENT_ID.toEventOrTransactionId()), + value(null), + ) + onItemDeletedLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - on navigate to`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + val anImage2 = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage, anImage2), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.OnNavigateTo(1) + ) + val finalState = awaitItem() + assertThat(finalState.currentIndex).isEqualTo(1) + } + } + + @Test + fun `present - snackbar displayed when there is no more items forward images and videos`() { + `present - snackbar displayed when there is no more items forward`( + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.Media), + expectedSnackbarResId = R.string.screen_media_details_no_more_media_to_show, + ) + } + + @Test + fun `present - snackbar displayed when there is no more items forward files and audio`() { + `present - snackbar displayed when there is no more items forward`( + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.Media), + expectedSnackbarResId = R.string.screen_media_details_no_more_files_to_show, + ) + } + + private fun `present - snackbar displayed when there is no more items forward`( + mode: MediaViewerEntryPoint.MediaViewerMode, + expectedSnackbarResId: Int, + ) = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + mode = mode, + mediaGalleryDataSource = mediaGalleryDataSource, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) { + GroupedMediaItems( + imageAndVideoItems = persistentListOf(), + fileItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator), + ) + } else { + GroupedMediaItems( + imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator), + fileItems = persistentListOf(), + ) + } + ) + ) + val updatedState = awaitItem() + // User navigate to the first item (forward loading indicator) + updatedState.eventSink( + MediaViewerEvents.OnNavigateTo(0) + ) + // data source claims that there is no more items to load forward + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) { + GroupedMediaItems( + imageAndVideoItems = persistentListOf(), + fileItems = persistentListOf(anImage, aBackwardLoadingIndicator), + ) + } else { + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage, aBackwardLoadingIndicator), + fileItems = persistentListOf(), + ) + } + ) + ) + skipItems(1) + val stateWithSnackbar = awaitItem() + assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId) + } + } + + @Test + fun `present - snackbar displayed when there is no more items backward images and videos`() { + `present - snackbar displayed when there is no more items backward`( + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(timelineMode = Timeline.Mode.Media), + expectedSnackbarResId = R.string.screen_media_details_no_more_media_to_show, + ) + } + + @Test + fun `present - snackbar displayed when there is no more items backward files and audio`() { + `present - snackbar displayed when there is no more items backward`( + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.Media), + expectedSnackbarResId = R.string.screen_media_details_no_more_files_to_show, + ) + } + + private fun `present - snackbar displayed when there is no more items backward`( + mode: MediaViewerEntryPoint.MediaViewerMode, + expectedSnackbarResId: Int, + ) = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + mode = mode, + mediaGalleryDataSource = mediaGalleryDataSource, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) { + GroupedMediaItems( + imageAndVideoItems = persistentListOf(), + fileItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator), + ) + } else { + GroupedMediaItems( + imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator), + fileItems = persistentListOf(), + ) + } + ) + ) + val updatedState = awaitItem() + // User navigate to the last item (backward loading indicator) + updatedState.eventSink( + MediaViewerEvents.OnNavigateTo(2) + ) + skipItems(1) + // data source claims that there is no more items to load backward + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) { + GroupedMediaItems( + imageAndVideoItems = persistentListOf(), + fileItems = persistentListOf(aForwardLoadingIndicator, anImage), + ) + } else { + GroupedMediaItems( + imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage), + fileItems = persistentListOf(), + ) + } + ) + ) + skipItems(1) + val stateWithSnackbar = awaitItem() + assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId) + } + } + + @Test + fun `present - no snackbar displayed when there is no more items but not displaying a loading item`() = runTest { + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ) + val presenter = createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + mediaGalleryDataSource = mediaGalleryDataSource, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + // User navigate to the media + updatedState.eventSink( + MediaViewerEvents.OnNavigateTo(1) + ) + skipItems(1) + // data source claims that there is no more items to load at all + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val finalState = awaitItem() + assertThat(finalState.snackbarMessage).isNull() + } + } + + @Test + fun `present - load more`() = runTest { + val loadMoreLambda = lambdaRecorder { } + val mediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + loadMoreLambda = loadMoreLambda, + ) + val presenter = createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + mediaGalleryDataSource = mediaGalleryDataSource, + ) + val anImage = aMediaItemImage( + mediaSourceUrl = aUrl, + ) + presenter.test { + awaitFirstItem() + mediaGalleryDataSource.emitGroupedMediaItems( + AsyncData.Success( + GroupedMediaItems( + imageAndVideoItems = persistentListOf(anImage), + fileItems = persistentListOf(), + ) + ) + ) + val updatedState = awaitItem() + updatedState.eventSink( + MediaViewerEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS) + ) + loadMoreLambda.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS)) + } + } + + @Test + fun `present - view in timeline hides the bottom sheet and invokes the navigator`() = runTest { + val onViewInTimelineClickLambda = lambdaRecorder { } + val navigator = FakeMediaViewerNavigator( + onViewInTimelineClickLambda = onViewInTimelineClickLambda, + ) + val presenter = createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + mediaViewerNavigator = navigator, + room = FakeJoinedRoom( + baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }), + ) + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(MediaViewerEvents.OpenInfo(aMediaViewerPageData())) + val withBottomSheetState = awaitItem() + assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java) + initialState.eventSink(MediaViewerEvents.ViewInTimeline(AN_EVENT_ID)) + val finalState = awaitItem() + assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) + } + } + + @Test + fun `present - forward hides the bottom sheet and invokes the navigator`() = runTest { + val onForwardClickLambda = lambdaRecorder { _, _ -> } + val navigator = FakeMediaViewerNavigator( + onForwardClickLambda = onForwardClickLambda, + ) + val presenter = createMediaViewerPresenter( + localMediaFactory = localMediaFactory, + mediaViewerNavigator = navigator, + room = FakeJoinedRoom( + baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }), + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(MediaViewerEvents.OpenInfo(aMediaViewerPageData())) + val withBottomSheetState = awaitItem() + assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java) + initialState.eventSink(MediaViewerEvents.Forward(AN_EVENT_ID)) + val finalState = awaitItem() + assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + onForwardClickLambda.assertions().isCalledOnce() + .with(value(AN_EVENT_ID), value(false)) + } + } + + @Test + fun `present - forward from pinned events hides the bottom sheet and invokes the navigator`() = runTest { + val onForwardClickLambda = lambdaRecorder { _, _ -> } + val navigator = FakeMediaViewerNavigator( + onForwardClickLambda = onForwardClickLambda, + ) + val presenter = createMediaViewerPresenter( + mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(timelineMode = Timeline.Mode.PinnedEvents), + localMediaFactory = localMediaFactory, + mediaViewerNavigator = navigator, + room = FakeJoinedRoom( + baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }), + ), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(MediaViewerEvents.OpenInfo(aMediaViewerPageData())) + val withBottomSheetState = awaitItem() + assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java) + initialState.eventSink(MediaViewerEvents.Forward(AN_EVENT_ID)) + val finalState = awaitItem() + assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden) + onForwardClickLambda.assertions().isCalledOnce() + .with(value(AN_EVENT_ID), value(true)) + } + } + + private suspend fun ReceiveTurbine.awaitFirstItem(): T { + return awaitItem() + } +} + +internal fun TestScope.createMediaViewerPresenter( + localMediaFactory: LocalMediaFactory, + eventId: EventId? = null, + mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia, + matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(), + localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(), + mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource( + startLambda = { }, + ), + canShowInfo: Boolean = true, + mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(), + room: JoinedRoom = FakeJoinedRoom( + liveTimeline = FakeTimeline(), + ), +): MediaViewerPresenter { + return MediaViewerPresenter( + inputs = createMediaViewerEntryPointParams( + eventId = eventId, + mode = mode, + canShowInfo = canShowInfo, + ), + navigator = mediaViewerNavigator, + dataSource = MediaViewerDataSource( + mode = mode, + dispatcher = testCoroutineDispatchers().computation, + galleryDataSource = mediaGalleryDataSource, + mediaLoader = matrixMediaLoader, + localMediaFactory = localMediaFactory, + systemClock = FakeSystemClock(), + pagerKeysHandler = PagerKeysHandler(), + ), + room = room, + localMediaActions = localMediaActions, + ) +} + +internal fun createMediaViewerEntryPointParams( + eventId: EventId? = null, + mode: MediaViewerEntryPoint.MediaViewerMode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia, + canShowInfo: Boolean = true, +) = MediaViewerEntryPoint.Params( + mode = mode, + eventId = eventId, + mediaInfo = TESTED_MEDIA_INFO, + mediaSource = aMediaSource(), + thumbnailSource = null, + canShowInfo = canShowInfo, +) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt new file mode 100644 index 0000000..b111494 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState +import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.setSafeContent +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class MediaViewerViewTest { + @get:Rule val rule = createAndroidComposeRule() + + private val mockMediaUrl: Uri = mockk("localMediaUri") + + @Test + fun `clicking on back invokes expected callback`() { + val eventsRecorder = EventsRecorder() + val state = aMediaViewerState( + eventSink = eventsRecorder + ) + ensureCalledOnce { callback -> + rule.setMediaViewerView( + state = state, + onBackClick = callback, + ) + rule.pressBack() + } + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData), + ) + ) + } + + @Test + fun `clicking on open emit expected Event`() { + val data = aMediaViewerPageData( + downloadedMedia = AsyncData.Success(aLocalMedia(uri = mockMediaUrl)), + ) + testMenuAction( + data, + CommonStrings.action_open_with, + MediaViewerEvents.OpenWith(data), + ) + } + + @Test + fun `clicking on info emit expected Event`() { + val data = aMediaViewerPageData( + downloadedMedia = AsyncData.Success(aLocalMedia(uri = mockMediaUrl)), + ) + testMenuAction( + data, + CommonStrings.a11y_view_details, + MediaViewerEvents.OpenInfo(data), + ) + } + + private fun testMenuAction( + data: MediaViewerPageData.MediaViewerData, + contentDescriptionRes: Int, + expectedEvent: MediaViewerEvents, + ) { + val eventsRecorder = EventsRecorder() + rule.setMediaViewerView( + aMediaViewerState( + listData = listOf(data), + eventSink = eventsRecorder + ), + ) + val contentDescription = rule.activity.getString(contentDescriptionRes) + rule.onNodeWithContentDescription(contentDescription).performClick() + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(data), + expectedEvent, + ) + ) + } + + @Test + @Config(qualifiers = "h1024dp") + fun `clicking on save emit expected Event`() { + val data = aMediaViewerPageData() + testBottomSheetAction( + data, + CommonStrings.action_save, + MediaViewerEvents.SaveOnDisk(data), + ) + } + + @Test + @Config(qualifiers = "h1024dp") + fun `clicking on share emit expected Event`() { + val data = aMediaViewerPageData() + testBottomSheetAction( + data, + CommonStrings.action_share, + MediaViewerEvents.Share(data), + ) + } + + private fun testBottomSheetAction( + data: MediaViewerPageData.MediaViewerData, + contentDescriptionRes: Int, + expectedEvent: MediaViewerEvents, + ) { + val eventsRecorder = EventsRecorder() + rule.setMediaViewerView( + aMediaViewerState( + listData = listOf(data), + mediaBottomSheetState = aMediaDetailsBottomSheetState(), + eventSink = eventsRecorder + ), + ) + rule.clickOn(contentDescriptionRes) + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(data), + expectedEvent, + ) + ) + } + + @Test + fun `clicking on image hides the overlay`() { + val eventsRecorder = EventsRecorder() + val state = aMediaViewerState( + eventSink = eventsRecorder + ) + rule.setMediaViewerView( + state = state, + ) + // Ensure that the action are visible + val contentDescription = rule.activity.getString(CommonStrings.action_open_with) + rule.onNodeWithContentDescription(contentDescription) + .assertExists() + .assertHasClickAction() + val imageContentDescription = rule.activity.getString(CommonStrings.common_image) + rule.onNodeWithContentDescription(imageContentDescription).performClick() + // Give time for the animation (? since even by removing AnimatedVisibility it still fails) + rule.mainClock.advanceTimeBy(1_000) + rule.onNodeWithContentDescription(contentDescription) + .assertDoesNotExist() + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData), + ) + ) + } + + @Test + fun `clicking swipe on the image invokes the expected callback`() { + val eventsRecorder = EventsRecorder() + val state = aMediaViewerState( + eventSink = eventsRecorder + ) + ensureCalledOnce { callback -> + rule.setMediaViewerView( + state = state, + onBackClick = callback, + ) + val imageContentDescription = rule.activity.getString(CommonStrings.common_image) + rule.onNodeWithContentDescription(imageContentDescription).performTouchInput { swipeDown(startY = centerY) } + rule.mainClock.advanceTimeBy(1_000) + } + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData), + ) + ) + } + + @Test + fun `error case, click on retry emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val data = aMediaViewerPageData( + downloadedMedia = AsyncData.Failure(IllegalStateException("error")), + ) + rule.setMediaViewerView( + aMediaViewerState( + listData = listOf(data), + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.action_retry) + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(data), + MediaViewerEvents.LoadMedia(data), + ) + ) + } + + @Test + fun `error case, click on cancel emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val data = aMediaViewerPageData( + downloadedMedia = AsyncData.Failure(IllegalStateException("error")), + ) + rule.setMediaViewerView( + aMediaViewerState( + listData = listOf(data), + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertList( + listOf( + MediaViewerEvents.OnNavigateTo(0), + MediaViewerEvents.LoadMedia(data), + MediaViewerEvents.ClearLoadingError(data) + ) + ) + } +} + +private fun AndroidComposeTestRule.setMediaViewerView( + state: MediaViewerState, + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setSafeContent { + MediaViewerView( + state = state, + audioFocus = null, + textFileViewer = { _, _ -> }, + onBackClick = onBackClick, + ) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandlerTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandlerTest.kt new file mode 100644 index 0000000..8a334b3 --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandlerTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.timeline.Timeline +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.mediaviewer.impl.model.aMediaItemImage +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemLoadingIndicator +import org.junit.Test + +class PagerKeysHandlerTest { + private val image1 = aMediaItemImage( + eventId = AN_EVENT_ID, + ) + private val image2 = aMediaItemImage( + eventId = AN_EVENT_ID_2, + ) + private val aBackwardLoadingIndicator = aMediaItemLoadingIndicator( + direction = Timeline.PaginationDirection.BACKWARDS + ) + private val aForwardLoadingIndicator = aMediaItemLoadingIndicator( + direction = Timeline.PaginationDirection.FORWARDS + ) + + @Test + fun `when new items are inserted after existing items, keys are not shifted`() { + val sut = PagerKeysHandler() + sut.accept(listOf(aBackwardLoadingIndicator, image1, aForwardLoadingIndicator)) + assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(0) + assertThat(sut.getKey(image1)).isEqualTo(1) + assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2) + sut.accept(listOf(aBackwardLoadingIndicator, image1, image2, aForwardLoadingIndicator)) + assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(0) + assertThat(sut.getKey(image1)).isEqualTo(1) + assertThat(sut.getKey(image2)).isEqualTo(2) + assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(3) + } + + @Test + fun `when new items are inserted before existing items, keys are not shifted`() { + val sut = PagerKeysHandler() + sut.accept(listOf(aBackwardLoadingIndicator, image1, aForwardLoadingIndicator)) + assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(0) + assertThat(sut.getKey(image1)).isEqualTo(1) + assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2) + sut.accept(listOf(aBackwardLoadingIndicator, image2, image1, aForwardLoadingIndicator)) + assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(-1) + assertThat(sut.getKey(image2)).isEqualTo(0) + assertThat(sut.getKey(image1)).isEqualTo(1) + assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2) + // Accepting the same list should not change the keys + sut.accept(listOf(aBackwardLoadingIndicator, image2, image1, aForwardLoadingIndicator)) + assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(-1) + assertThat(sut.getKey(image2)).isEqualTo(0) + assertThat(sut.getKey(image1)).isEqualTo(1) + assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2) + } + + @Test + fun `when loaders are removed, keys are not shifted`() { + val sut = PagerKeysHandler() + sut.accept(listOf(aBackwardLoadingIndicator, image1, aForwardLoadingIndicator)) + assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(0) + assertThat(sut.getKey(image1)).isEqualTo(1) + assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2) + sut.accept(listOf(image1)) + assertThat(sut.getKey(image1)).isEqualTo(1) + } +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSourceTest.kt new file mode 100644 index 0000000..c6460cb --- /dev/null +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSourceTest.kt @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.viewer + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.components.media.WaveFormSamples +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.media.aMediaSource +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint +import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo +import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo +import io.element.android.libraries.mediaviewer.api.anApkMediaInfo +import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo +import io.element.android.libraries.mediaviewer.api.anImageMediaInfo +import io.element.android.libraries.mediaviewer.impl.gallery.aGroupedMediaItems +import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemFile +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class SingleMediaGalleryDataSourceTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `function start is no op`() { + val sut = SingleMediaGalleryDataSource(aGroupedMediaItems()) + sut.start() + } + + @Test + fun `function loadMore is no op`() = runTest { + val sut = SingleMediaGalleryDataSource(aGroupedMediaItems()) + sut.loadMore(Timeline.PaginationDirection.BACKWARDS) + sut.loadMore(Timeline.PaginationDirection.FORWARDS) + } + + @Test + fun `function deleteItem is no op`() = runTest { + val sut = SingleMediaGalleryDataSource(aGroupedMediaItems()) + sut.deleteItem(AN_EVENT_ID) + } + + @Test + fun `getLastData should return the data`() { + val data = aGroupedMediaItems( + imageAndVideoItems = listOf(aMediaItemImage()), + fileItems = listOf(aMediaItemFile()), + ) + val sut = SingleMediaGalleryDataSource(data) + assertThat(sut.getLastData()).isEqualTo(AsyncData.Success(data)) + } + + @Test + fun `groupedMediaItemsFlow emit a single item`() = runTest { + val data = aGroupedMediaItems( + imageAndVideoItems = listOf(aMediaItemImage()), + fileItems = listOf(aMediaItemFile()), + ) + val sut = SingleMediaGalleryDataSource(data) + sut.groupedMediaItemsFlow().test { + assertThat(awaitItem()).isEqualTo(AsyncData.Success(data)) + awaitComplete() + } + } + + @Test + fun `createFrom should create a SingleMediaGalleryDataSource with an image item`() { + testFactory( + mediaInfo = anImageMediaInfo(), + expectedResult = { params -> + MediaItem.Image( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + thumbnailSource = params.thumbnailSource, + ) + } + ) + } + + @Test + fun `createFrom should create a SingleMediaGalleryDataSource with a video item`() { + testFactory( + mediaInfo = aVideoMediaInfo(), + expectedResult = { params -> + MediaItem.Video( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + thumbnailSource = params.thumbnailSource, + ) + } + ) + } + + @Test + fun `createFrom should create a SingleMediaGalleryDataSource with an audio item`() { + testFactory( + mediaInfo = anAudioMediaInfo(), + expectedResult = { params -> + MediaItem.Audio( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + ) + } + ) + } + + @Test + fun `createFrom should create a SingleMediaGalleryDataSource with a voice item`() { + testFactory( + mediaInfo = aVoiceMediaInfo( + waveForm = WaveFormSamples.longRealisticWaveForm, + duration = "12:34", + ), + expectedResult = { params -> + MediaItem.Voice( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + ) + } + ) + } + + @Test + fun `createFrom should create a SingleMediaGalleryDataSource with a file item`() { + testFactory( + mediaInfo = anApkMediaInfo(), + expectedResult = { params -> + MediaItem.File( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + ) + } + ) + } + + private fun testFactory( + mediaInfo: MediaInfo, + expectedResult: (MediaViewerEntryPoint.Params) -> MediaItem, + ) { + val params = aMediaViewerEntryPointParams(mediaInfo) + val result = SingleMediaGalleryDataSource.createFrom(params) + val resultData = result.getLastData().dataOrNull() + assertThat(resultData!!.imageAndVideoItems.first()).isEqualTo(expectedResult(params)) + assertThat(resultData.fileItems).isEmpty() + } + + internal fun aMediaViewerEntryPointParams( + mediaInfo: MediaInfo, + ) = MediaViewerEntryPoint.Params( + mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia, + eventId = AN_EVENT_ID, + mediaInfo = mediaInfo, + mediaSource = aMediaSource(url = "aUrl"), + thumbnailSource = aMediaSource(url = "aThumbnailUrl"), + canShowInfo = true, + ) +} diff --git a/libraries/mediaviewer/test/build.gradle.kts b/libraries/mediaviewer/test/build.gradle.kts new file mode 100644 index 0000000..1918714 --- /dev/null +++ b/libraries/mediaviewer/test/build.gradle.kts @@ -0,0 +1,26 @@ +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.mediaviewer.test" +} + +dependencies { + api(projects.libraries.mediaviewer.impl) + implementation(projects.libraries.core) + implementation(projects.tests.testutils) + implementation(projects.libraries.matrix.api) + + testCommonDependencies(libs) +} diff --git a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaActions.kt b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaActions.kt new file mode 100644 index 0000000..875be94 --- /dev/null +++ b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaActions.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.test + +import androidx.compose.runtime.Composable +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.impl.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 = simulateLongTask { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(Unit) + } + } + + override suspend fun share(localMedia: LocalMedia): Result = simulateLongTask { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(Unit) + } + } + + override suspend fun open(localMedia: LocalMedia): Result = simulateLongTask { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(Unit) + } + } +} diff --git a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt new file mode 100644 index 0000000..faa27fd --- /dev/null +++ b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.test + +import android.net.Uri +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.media.MediaFile +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory +import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor +import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation +import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia + +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( + filename = safeName, + fileSize = null, + caption = null, + mimeType = mimeType ?: fallbackMimeType, + formattedFileSize = formattedFileSize ?: fallbackFileSize, + fileExtension = fileExtensionExtractor.extractFromName(safeName), + senderId = null, + senderName = null, + senderAvatar = null, + dateSent = null, + dateSentFull = null, + waveform = null, + duration = null, + ) + return aLocalMedia(uri, mediaInfo) + } +} diff --git a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeMediaGalleryEntryPoint.kt b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeMediaGalleryEntryPoint.kt new file mode 100644 index 0000000..be5d1b9 --- /dev/null +++ b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeMediaGalleryEntryPoint.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeMediaGalleryEntryPoint : MediaGalleryEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: MediaGalleryEntryPoint.Callback, + ): Node = lambdaError() +} diff --git a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeMediaViewerEntryPoint.kt b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeMediaViewerEntryPoint.kt new file mode 100644 index 0000000..8867f9c --- /dev/null +++ b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeMediaViewerEntryPoint.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeMediaViewerEntryPoint : MediaViewerEntryPoint { + override fun createParamsForAvatar(filename: String, avatarUrl: String) = lambdaError() + + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: MediaViewerEntryPoint.Params, + callback: MediaViewerEntryPoint.Callback, + ): Node = lambdaError() +} diff --git a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/util/FileExtensionExtractorWithoutValidation.kt b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/util/FileExtensionExtractorWithoutValidation.kt new file mode 100644 index 0000000..09a74f2 --- /dev/null +++ b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/util/FileExtensionExtractorWithoutValidation.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.test.util + +import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor + +class FileExtensionExtractorWithoutValidation : FileExtensionExtractor { + override fun extractFromName(name: String): String { + return name.substringAfterLast('.', "") + } +} diff --git a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/viewer/LocalMedia.kt b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/viewer/LocalMedia.kt new file mode 100644 index 0000000..a7bb30d --- /dev/null +++ b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/viewer/LocalMedia.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.test.viewer + +import android.net.Uri +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.anImageMediaInfo +import io.element.android.libraries.mediaviewer.api.local.LocalMedia + +fun aLocalMedia( + uri: Uri, + mediaInfo: MediaInfo = anImageMediaInfo(), +) = LocalMedia( + uri = uri, + info = mediaInfo +) diff --git a/libraries/mediaviewer/test/src/test/kotlin/io/element/android/libraries/mediaviewer/test/util/FileExtensionExtractorWithoutValidationTest.kt b/libraries/mediaviewer/test/src/test/kotlin/io/element/android/libraries/mediaviewer/test/util/FileExtensionExtractorWithoutValidationTest.kt new file mode 100644 index 0000000..fad7a1d --- /dev/null +++ b/libraries/mediaviewer/test/src/test/kotlin/io/element/android/libraries/mediaviewer/test/util/FileExtensionExtractorWithoutValidationTest.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.test.util + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class FileExtensionExtractorWithoutValidationTest { + @Test + fun `extension should always be extracted even is invalid`() { + val sut = FileExtensionExtractorWithoutValidation() + assertThat(sut.extractFromName("test.png")).isEqualTo("png") + assertThat(sut.extractFromName("test.bla")).isEqualTo("bla") + } +} diff --git a/libraries/network/build.gradle.kts b/libraries/network/build.gradle.kts new file mode 100644 index 0000000..72e7eb0 --- /dev/null +++ b/libraries/network/build.gradle.kts @@ -0,0 +1,39 @@ +import extension.setupDependencyInjection + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.network" + + buildTypes { + release { + consumerProguardFiles("consumer-rules.pro") + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.preferences.api) + implementation(platform(libs.network.okhttp.bom)) + implementation(libs.network.okhttp) + implementation(libs.network.okhttp.logging) + implementation(platform(libs.network.retrofit.bom)) + 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 0000000..1026dca --- /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 0000000..d8809e0 --- /dev/null +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/NetworkModule.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.network + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.network.interceptors.DynamicHttpLoggingInterceptor +import io.element.android.libraries.network.interceptors.FormattedJsonHttpLogger +import io.element.android.libraries.network.interceptors.UserAgentInterceptor +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import java.util.concurrent.TimeUnit + +@BindingContainer +@ContributesTo(AppScope::class) +object NetworkModule { + @Provides + @SingleIn(AppScope::class) + fun providesOkHttpClient( + userAgentInterceptor: UserAgentInterceptor, + dynamicHttpLoggingInterceptor: DynamicHttpLoggingInterceptor, + ): OkHttpClient = OkHttpClient.Builder().apply { + connectTimeout(30, TimeUnit.SECONDS) + readTimeout(60, TimeUnit.SECONDS) + writeTimeout(60, TimeUnit.SECONDS) + addInterceptor(userAgentInterceptor) + addInterceptor(dynamicHttpLoggingInterceptor) + }.build() + + @Provides + @SingleIn(AppScope::class) + fun providesHttpLoggingInterceptor(): HttpLoggingInterceptor { + val logger = FormattedJsonHttpLogger(HttpLoggingInterceptor.Level.BODY) + return HttpLoggingInterceptor(logger) + } +} 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 0000000..88240c3 --- /dev/null +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/RetrofitFactory.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.network + +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.Provider +import io.element.android.libraries.androidutils.json.JsonProvider +import io.element.android.libraries.core.uri.ensureTrailingSlash +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory + +@Inject +class RetrofitFactory( + private val okHttpClient: Provider, + private val json: Provider, +) { + fun create(baseUrl: String): Retrofit = Retrofit.Builder() + .baseUrl(baseUrl.ensureTrailingSlash()) + .addConverterFactory(json()().asConverterFactory("application/json".toMediaType())) + .callFactory { request -> okHttpClient().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 0000000..259e6b8 --- /dev/null +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/headers/HttpHeaders.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.network.headers + +@Suppress("ktlint:standard:property-naming") +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/DynamicHttpLoggingInterceptor.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/DynamicHttpLoggingInterceptor.kt new file mode 100644 index 0000000..daaf922 --- /dev/null +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/DynamicHttpLoggingInterceptor.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.network.interceptors + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.matrix.api.tracing.LogLevel +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Response +import okhttp3.logging.HttpLoggingInterceptor +import okhttp3.logging.HttpLoggingInterceptor.Level + +/** + * HTTP logging interceptor that decides whether to display the HTTP logs or not based on the current log level. + */ +@Inject +@SingleIn(AppScope::class) +class DynamicHttpLoggingInterceptor( + private val appPreferencesStore: AppPreferencesStore, + private val loggingInterceptor: HttpLoggingInterceptor, +) : Interceptor by loggingInterceptor { + override fun intercept(chain: Interceptor.Chain): Response { + // This is called in a separate thread, so calling `runBlocking` here should be fine, it should be also instant after the value is cached + val logLevel = runBlocking { appPreferencesStore.getTracingLogLevelFlow().first() } + loggingInterceptor.level = if (logLevel >= LogLevel.DEBUG) Level.BODY else Level.NONE + return loggingInterceptor.intercept(chain) + } +} 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 0000000..11aa0a5 --- /dev/null +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/FormattedJsonHttpLogger.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.network.interceptors + +import io.element.android.libraries.core.extensions.ellipsize +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.d(message.ellipsize(200_000)) + + // 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.length > 100_000) { + Timber.d("Content is too long (${message.length} chars) to be formatted as JSON") + 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 0000000..eb1d621 --- /dev/null +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/UserAgentInterceptor.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.network.interceptors + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.network.headers.HttpHeaders +import io.element.android.libraries.network.useragent.UserAgentProvider +import okhttp3.Interceptor +import okhttp3.Response + +@Inject +class UserAgentInterceptor( + 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 0000000..3ecd091 --- /dev/null +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/DefaultUserAgentProvider.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.network.useragent + +import android.os.Build +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.SdkMetadata + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultUserAgentProvider( + private val buildMeta: BuildMeta, + private val sdkMeta: SdkMetadata, +) : 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 c344b155c) + */ + 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 = sdkMeta.sdkGitSha + + 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 0000000..9d8e71a --- /dev/null +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/SimpleUserAgentProvider.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..dc552f4 --- /dev/null +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/useragent/UserAgentProvider.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.network.useragent + +interface UserAgentProvider { + fun provide(): String +} diff --git a/libraries/oidc/api/build.gradle.kts b/libraries/oidc/api/build.gradle.kts new file mode 100644 index 0000000..8cc0125 --- /dev/null +++ b/libraries/oidc/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.libraries.oidc.api" +} + +dependencies { + implementation(projects.libraries.architecture) +} diff --git a/libraries/oidc/api/src/main/kotlin/io/element/android/libraries/oidc/api/OidcAction.kt b/libraries/oidc/api/src/main/kotlin/io/element/android/libraries/oidc/api/OidcAction.kt new file mode 100644 index 0000000..d7c061a --- /dev/null +++ b/libraries/oidc/api/src/main/kotlin/io/element/android/libraries/oidc/api/OidcAction.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.oidc.api + +sealed interface OidcAction { + data class GoBack(val toUnblock: Boolean = false) : OidcAction + data class Success(val url: String) : OidcAction +} diff --git a/libraries/oidc/api/src/main/kotlin/io/element/android/libraries/oidc/api/OidcActionFlow.kt b/libraries/oidc/api/src/main/kotlin/io/element/android/libraries/oidc/api/OidcActionFlow.kt new file mode 100644 index 0000000..17340eb --- /dev/null +++ b/libraries/oidc/api/src/main/kotlin/io/element/android/libraries/oidc/api/OidcActionFlow.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.oidc.api + +import kotlinx.coroutines.flow.FlowCollector + +interface OidcActionFlow { + fun post(oidcAction: OidcAction) + suspend fun collect(collector: FlowCollector) + fun reset() +} diff --git a/libraries/oidc/api/src/main/kotlin/io/element/android/libraries/oidc/api/OidcIntentResolver.kt b/libraries/oidc/api/src/main/kotlin/io/element/android/libraries/oidc/api/OidcIntentResolver.kt new file mode 100644 index 0000000..97fa1ba --- /dev/null +++ b/libraries/oidc/api/src/main/kotlin/io/element/android/libraries/oidc/api/OidcIntentResolver.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.oidc.api + +import android.content.Intent + +interface OidcIntentResolver { + fun resolve(intent: Intent): OidcAction? +} diff --git a/libraries/oidc/impl/build.gradle.kts b/libraries/oidc/impl/build.gradle.kts new file mode 100644 index 0000000..e11ce11 --- /dev/null +++ b/libraries/oidc/impl/build.gradle.kts @@ -0,0 +1,47 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "io.element.android.libraries.oidc.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.appconfig) + implementation(projects.libraries.core) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(libs.androidx.browser) + implementation(platform(libs.network.retrofit.bom)) + implementation(libs.network.retrofit) + implementation(libs.serialization.json) + api(projects.libraries.oidc.api) + + testCommonDependencies(libs) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.permissions.test) +} diff --git a/libraries/oidc/impl/src/main/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcActionFlow.kt b/libraries/oidc/impl/src/main/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcActionFlow.kt new file mode 100644 index 0000000..6096ef7 --- /dev/null +++ b/libraries/oidc/impl/src/main/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcActionFlow.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.oidc.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.oidc.api.OidcAction +import io.element.android.libraries.oidc.api.OidcActionFlow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class DefaultOidcActionFlow : OidcActionFlow { + private val mutableStateFlow = MutableStateFlow(null) + + override fun post(oidcAction: OidcAction) { + mutableStateFlow.value = oidcAction + } + + override suspend fun collect(collector: FlowCollector) { + mutableStateFlow.collect(collector) + } + + override fun reset() { + mutableStateFlow.value = null + } +} diff --git a/libraries/oidc/impl/src/main/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcIntentResolver.kt b/libraries/oidc/impl/src/main/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcIntentResolver.kt new file mode 100644 index 0000000..2a16030 --- /dev/null +++ b/libraries/oidc/impl/src/main/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcIntentResolver.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.oidc.impl + +import android.content.Intent +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.oidc.api.OidcAction +import io.element.android.libraries.oidc.api.OidcIntentResolver + +@ContributesBinding(AppScope::class) +class DefaultOidcIntentResolver( + private val oidcUrlParser: OidcUrlParser, +) : OidcIntentResolver { + override fun resolve(intent: Intent): OidcAction? { + return oidcUrlParser.parse(intent.dataString.orEmpty()) + } +} diff --git a/libraries/oidc/impl/src/main/kotlin/io/element/android/libraries/oidc/impl/OidcUrlParser.kt b/libraries/oidc/impl/src/main/kotlin/io/element/android/libraries/oidc/impl/OidcUrlParser.kt new file mode 100644 index 0000000..8933873 --- /dev/null +++ b/libraries/oidc/impl/src/main/kotlin/io/element/android/libraries/oidc/impl/OidcUrlParser.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.oidc.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.matrix.api.auth.OidcRedirectUrlProvider +import io.element.android.libraries.oidc.api.OidcAction + +fun interface OidcUrlParser { + fun parse(url: String): OidcAction? +} + +/** + * Simple parser for oidc url interception. + * TODO Find documentation about the format. + */ +@ContributesBinding(AppScope::class) +class DefaultOidcUrlParser( + private val oidcRedirectUrlProvider: OidcRedirectUrlProvider, +) : OidcUrlParser { + /** + * Return a OidcAction, or null if the url is not a OidcUrl. + * Note: + * When user press button "Cancel", we get the url: + * `io.element.android:/?error=access_denied&state=IFF1UETGye2ZA8pO` + * On success, we get: + * `io.element.android:/?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB` + */ + override fun parse(url: String): OidcAction? { + if (url.startsWith(oidcRedirectUrlProvider.provide()).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/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcActionFlowTest.kt b/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcActionFlowTest.kt new file mode 100644 index 0000000..387b9ac --- /dev/null +++ b/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcActionFlowTest.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.oidc.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.oidc.api.OidcAction +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultOidcActionFlowTest { + @Test + fun `collect gets all the posted events`() = runTest { + val data = mutableListOf() + val sut = DefaultOidcActionFlow() + backgroundScope.launch { + sut.collect { action -> + data.add(action) + } + } + sut.post(OidcAction.GoBack()) + delay(1) + sut.reset() + delay(1) + assertThat(data).containsExactly(OidcAction.GoBack(), null) + } +} diff --git a/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcIntentResolverTest.kt b/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcIntentResolverTest.kt new file mode 100644 index 0000000..6406803 --- /dev/null +++ b/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcIntentResolverTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.oidc.impl + +import android.app.Activity +import android.content.Intent +import androidx.core.net.toUri +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.auth.FakeOidcRedirectUrlProvider +import io.element.android.libraries.oidc.api.OidcAction +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class DefaultOidcIntentResolverTest { + @Test + fun `test resolve oidc go back`() { + val sut = createDefaultOidcIntentResolver() + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_VIEW + data = "io.element.android:/?error=access_denied&state=IFF1UETGye2ZA8pO".toUri() + } + val result = sut.resolve(intent) + assertThat(result).isEqualTo(OidcAction.GoBack()) + } + + @Test + fun `test resolve oidc success`() { + val sut = createDefaultOidcIntentResolver() + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_VIEW + data = "io.element.android:/?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB".toUri() + } + val result = sut.resolve(intent) + assertThat(result).isEqualTo( + OidcAction.Success( + url = "io.element.android:/?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB" + ) + ) + } + + @Test + fun `test resolve oidc invalid`() { + val sut = createDefaultOidcIntentResolver() + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_VIEW + data = "io.element.android:/invalid".toUri() + } + assertThrows(IllegalStateException::class.java) { + sut.resolve(intent) + } + } + + private fun createDefaultOidcIntentResolver(): DefaultOidcIntentResolver { + return DefaultOidcIntentResolver( + oidcUrlParser = DefaultOidcUrlParser( + oidcRedirectUrlProvider = FakeOidcRedirectUrlProvider(), + ), + ) + } +} diff --git a/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcUrlParserTest.kt b/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcUrlParserTest.kt new file mode 100644 index 0000000..7f145c0 --- /dev/null +++ b/libraries/oidc/impl/src/test/kotlin/io/element/android/libraries/oidc/impl/DefaultOidcUrlParserTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.oidc.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.auth.FAKE_REDIRECT_URL +import io.element.android.libraries.matrix.test.auth.FakeOidcRedirectUrlProvider +import io.element.android.libraries.oidc.api.OidcAction +import org.junit.Assert +import org.junit.Test + +class DefaultOidcUrlParserTest { + @Test + fun `test empty url`() { + val sut = createDefaultOidcUrlParser() + assertThat(sut.parse("")).isNull() + } + + @Test + fun `test regular url`() { + val sut = createDefaultOidcUrlParser() + assertThat(sut.parse("https://matrix.org")).isNull() + } + + @Test + fun `test cancel url`() { + val sut = createDefaultOidcUrlParser() + val aCancelUrl = "$FAKE_REDIRECT_URL?error=access_denied&state=IFF1UETGye2ZA8pO" + assertThat(sut.parse(aCancelUrl)).isEqualTo(OidcAction.GoBack()) + } + + @Test + fun `test success url`() { + val sut = createDefaultOidcUrlParser() + val aSuccessUrl = "$FAKE_REDIRECT_URL?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB" + assertThat(sut.parse(aSuccessUrl)).isEqualTo(OidcAction.Success(aSuccessUrl)) + } + + @Test + fun `test unknown url`() { + val sut = createDefaultOidcUrlParser() + val anUnknownUrl = "$FAKE_REDIRECT_URL?state=IFF1UETGye2ZA8pO&goat=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB" + Assert.assertThrows(IllegalStateException::class.java) { + assertThat(sut.parse(anUnknownUrl)) + } + } + + private fun createDefaultOidcUrlParser(): DefaultOidcUrlParser { + return DefaultOidcUrlParser( + oidcRedirectUrlProvider = FakeOidcRedirectUrlProvider(), + ) + } +} diff --git a/libraries/oidc/test/build.gradle.kts b/libraries/oidc/test/build.gradle.kts new file mode 100644 index 0000000..efe32d4 --- /dev/null +++ b/libraries/oidc/test/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.oidc.test" +} + +dependencies { + implementation(libs.coroutines.core) + api(projects.libraries.oidc.api) + implementation(projects.tests.testutils) +} diff --git a/libraries/oidc/test/src/main/kotlin/io/element/android/libraries/oidc/test/FakeOidcIntentResolver.kt b/libraries/oidc/test/src/main/kotlin/io/element/android/libraries/oidc/test/FakeOidcIntentResolver.kt new file mode 100644 index 0000000..45b4008 --- /dev/null +++ b/libraries/oidc/test/src/main/kotlin/io/element/android/libraries/oidc/test/FakeOidcIntentResolver.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.oidc.test + +import android.content.Intent +import io.element.android.libraries.oidc.api.OidcAction +import io.element.android.libraries.oidc.api.OidcIntentResolver +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeOidcIntentResolver( + private val resolveResult: (Intent) -> OidcAction? = { lambdaError() } +) : OidcIntentResolver { + override fun resolve(intent: Intent): OidcAction? { + return resolveResult(intent) + } +} diff --git a/libraries/oidc/test/src/main/kotlin/io/element/android/libraries/oidc/test/customtab/FakeOidcActionFlow.kt b/libraries/oidc/test/src/main/kotlin/io/element/android/libraries/oidc/test/customtab/FakeOidcActionFlow.kt new file mode 100644 index 0000000..5362aef --- /dev/null +++ b/libraries/oidc/test/src/main/kotlin/io/element/android/libraries/oidc/test/customtab/FakeOidcActionFlow.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.oidc.test.customtab + +import io.element.android.libraries.oidc.api.OidcAction +import io.element.android.libraries.oidc.api.OidcActionFlow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * This is actually a copy of DefaultOidcActionFlow. + */ +class FakeOidcActionFlow : OidcActionFlow { + private val mutableStateFlow = MutableStateFlow(null) + + override fun post(oidcAction: OidcAction) { + mutableStateFlow.value = oidcAction + } + + override suspend fun collect(collector: FlowCollector) { + mutableStateFlow.collect(collector) + } + + override fun reset() { + mutableStateFlow.value = null + } +} diff --git a/libraries/permissions/api/build.gradle.kts b/libraries/permissions/api/build.gradle.kts new file mode 100644 index 0000000..0b7edd9 --- /dev/null +++ b/libraries/permissions/api/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.permissions.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) +} diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionStateProvider.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionStateProvider.kt new file mode 100644 index 0000000..beeef0b --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionStateProvider.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.api + +import kotlinx.coroutines.flow.Flow + +interface PermissionStateProvider { + fun isPermissionGranted(permission: String): Boolean + suspend fun setPermissionDenied(permission: String, value: Boolean) + fun isPermissionDenied(permission: String): Flow + + suspend fun setPermissionAsked(permission: String, value: Boolean) + fun isPermissionAsked(permission: String): Flow +} 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 0000000..2d3cb00 --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvents.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.api + +sealed interface PermissionsEvents { + data object RequestPermissions : PermissionsEvents + data object CloseDialog : PermissionsEvents + data object OpenSystemSettingAndCloseDialog : 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 0000000..f2ccb2b --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsPresenter.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.api + +import io.element.android.libraries.architecture.Presenter + +interface PermissionsPresenter : Presenter { + 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 0000000..28c34e6 --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsState.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/PermissionsStateProvider.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsStateProvider.kt new file mode 100644 index 0000000..a330333 --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsStateProvider.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.api + +import android.Manifest +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class PermissionsStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aPermissionsState(showDialog = true, permission = Manifest.permission.POST_NOTIFICATIONS), + aPermissionsState(showDialog = true, permission = Manifest.permission.CAMERA), + aPermissionsState(showDialog = true, permission = Manifest.permission.RECORD_AUDIO), + aPermissionsState(showDialog = true, permission = Manifest.permission.INTERNET), + ) +} + +fun aPermissionsState( + showDialog: Boolean, + permission: String = Manifest.permission.POST_NOTIFICATIONS, + permissionGranted: Boolean = false, +) = PermissionsState( + permission = permission, + permissionGranted = permissionGranted, + shouldShowRationale = false, + showDialog = showDialog, + permissionAlreadyAsked = false, + permissionAlreadyDenied = false, + eventSink = {} +) diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsStore.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsStore.kt new file mode 100644 index 0000000..3bb7d2c --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsStore.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.api + +import kotlinx.coroutines.flow.Flow + +interface PermissionsStore { + suspend fun setPermissionDenied(permission: String, value: Boolean) + fun isPermissionDenied(permission: String): Flow + + suspend fun setPermissionAsked(permission: String, value: Boolean) + fun isPermissionAsked(permission: String): Flow + + suspend fun resetPermission(permission: String) + + // To debug + suspend fun resetStore() +} 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 0000000..ef68614 --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.api + +import android.Manifest +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun PermissionsView( + state: PermissionsState, + modifier: Modifier = Modifier, + title: String = stringResource(id = CommonStrings.common_permission), + content: String? = null, + icon: @Composable (() -> Unit)? = null, +) { + if (state.showDialog.not()) return + + ConfirmationDialog( + modifier = modifier, + title = title, + content = content ?: state.permission.toDialogContent(), + submitText = stringResource(id = CommonStrings.action_open_settings), + onSubmitClick = { + state.eventSink.invoke(PermissionsEvents.OpenSystemSettingAndCloseDialog) + }, + onDismiss = { state.eventSink.invoke(PermissionsEvents.CloseDialog) }, + icon = icon, + ) +} + +@Composable +@ReadOnlyComposable +private fun String.toDialogContent(): String { + return when (this) { + Manifest.permission.POST_NOTIFICATIONS -> stringResource(id = R.string.dialog_permission_notification) + Manifest.permission.CAMERA -> stringResource(id = R.string.dialog_permission_camera) + Manifest.permission.RECORD_AUDIO -> stringResource(id = R.string.dialog_permission_microphone) + else -> stringResource(id = R.string.dialog_permission_generic) + } +} + +@PreviewsDayNight +@Composable +internal fun PermissionsViewPreview(@PreviewParameter(PermissionsStateProvider::class) state: PermissionsState) = ElementPreview { + PermissionsView( + state = state, + ) +} 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 0000000..5a0e9b2 --- /dev/null +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/Util.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/api/src/main/res/values-be/translations.xml b/libraries/permissions/api/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..5a489ba --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-be/translations.xml @@ -0,0 +1,7 @@ + + + "Каб дазволіць праграме выкарыстоўваць камеру, дайце дазвол у наладах сістэмы." + "Калі ласка, дайце дазвол у наладах сістэмы." + "Каб дазволіць праграме выкарыстоўваць мікрафон, дайце дазвол у наладах сістэмы." + "Каб дазволіць праграме паказваць апавяшчэнні, дайце дазвол у наладах сістэмы." + diff --git a/libraries/permissions/api/src/main/res/values-cs/translations.xml b/libraries/permissions/api/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..9679242 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-cs/translations.xml @@ -0,0 +1,7 @@ + + + "Aby mohla aplikace používat fotoaparát, udělte prosím oprávnění v nastavení systému." + "Udělte prosím oprávnění v nastavení systému." + "Aby aplikace mohla používat mikrofon, udělte prosím oprávnění v nastavení systému." + "Aby aplikace mohla zobrazovat upozornění, udělte prosím oprávnění v nastavení systému." + diff --git a/libraries/permissions/api/src/main/res/values-cy/translations.xml b/libraries/permissions/api/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..e72a9d0 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-cy/translations.xml @@ -0,0 +1,7 @@ + + + "Er mwyn gadael i\'r rhaglen ddefnyddio\'r camera, rhowch ganiatâd iddo yn y gosodiadau system." + "Rhowch ganiatâd yn y gosodiadau system." + "Er mwyn gadael i\'r cais ddefnyddio\'r meicroffon, rhowch ganiatâd yng ngosodiadau\'r system." + "Er mwyn gadael i\'r ap ddangos hysbysiadau, rhowch ganiatâd yn y gosodiadau system." + diff --git a/libraries/permissions/api/src/main/res/values-da/translations.xml b/libraries/permissions/api/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..8c823f7 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-da/translations.xml @@ -0,0 +1,7 @@ + + + "For at lade applikationen bruge kameraet, skal du give tilladelsen i systemindstillingerne." + "Giv venligst tilladelsen i systemindstillingerne." + "For at lade applikationen bruge mikrofonen, skal du give tilladelsen i systemindstillingerne." + "For at lade applikationen vise notifikationer, skal du give tilladelsen i systemindstillingerne." + diff --git a/libraries/permissions/api/src/main/res/values-de/translations.xml b/libraries/permissions/api/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..1d63c04 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-de/translations.xml @@ -0,0 +1,7 @@ + + + "Damit die Anwendung die Kamera verwenden kann, erteile bitte die Berechtigung in den Systemeinstellungen." + "Bitte erteile die Berechtigung in den Systemeinstellungen." + "Damit die App das Mikrofon nutzen kann, gib bitte die Berechtigung in den Systemeinstellungen frei." + "Damit die App Benachrichtigungen anzeigen kann, gib bitte die Berechtigung in den Systemeinstellungen frei." + diff --git a/libraries/permissions/api/src/main/res/values-el/translations.xml b/libraries/permissions/api/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..61f8eb6 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-el/translations.xml @@ -0,0 +1,7 @@ + + + "Για να επιτρέψεις στην εφαρμογή να χρησιμοποιεί την κάμερα, παραχώρησε την άδεια στις ρυθμίσεις συστήματος." + "Παρακαλώ παραχώρησε την άδεια στις ρυθμίσεις συστήματος." + "Για να επιτρέψεις στην εφαρμογή να χρησιμοποιεί το μικρόφωνο, παραχώρησε την άδεια στις ρυθμίσεις συστήματος." + "Για να επιτρέψεις στην εφαρμογή να εμφανίζει ειδοποιήσεις, παραχώρησε την άδεια στις ρυθμίσεις συστήματος." + diff --git a/libraries/permissions/api/src/main/res/values-es/translations.xml b/libraries/permissions/api/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..d530767 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-es/translations.xml @@ -0,0 +1,7 @@ + + + "Para permitir que la aplicación utilice la cámara, por favor concede el permiso en los ajustes del sistema." + "Por favor concede el permiso en los ajustes del sistema." + "Para permitir que la aplicación utilice el micrófono, por favor conceda el permiso en los ajustes del sistema." + "Para permitir que la aplicación muestre notificaciones, por favor concede el permiso en los ajustes del sistema." + diff --git a/libraries/permissions/api/src/main/res/values-et/translations.xml b/libraries/permissions/api/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..fe65b6f --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-et/translations.xml @@ -0,0 +1,7 @@ + + + "Selleks, et rakendus saaks kaamerat kasutada, palun luba see süsteemi seadistuses." + "Palun luba süsteemi seadistustest vajalikud õigused." + "Selleks, et rakendus saaks mikrofoni kasutada, palun luba see süsteemi seadistuses." + "Selleks, et rakendus saaks kuvada teavitusi, palun anna vajalikud õiguse süsteemi seadistustes." + diff --git a/libraries/permissions/api/src/main/res/values-eu/translations.xml b/libraries/permissions/api/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..00819b0 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-eu/translations.xml @@ -0,0 +1,7 @@ + + + "Aplikazioak kamera erabiltzeko, eman baimena sistemaren ezarpenetan." + "Eman baimena sistemaren ezarpenetan." + "Aplikazioak mikrofonoa erabiltzeko, eman baimena sistemaren ezarpenetan." + "Aplikazioak jakinarazpenak bistaratzeko, eman baimena sistemaren ezarpenetan." + diff --git a/libraries/permissions/api/src/main/res/values-fa/translations.xml b/libraries/permissions/api/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..1b01442 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-fa/translations.xml @@ -0,0 +1,7 @@ + + + "برای اینکه برنامه از دوربین استفاده کند، لطفا در تنظیمات سیستم مجوز بدهید." + "لطفاً در تنظیمات سامانه اجازه بدهید." + "برای اینکه برنامه از میکروفون استفاده کند، لطفا اجازه دهید در تنظیمات سیستم مجوز بدهید." + "برای اینکه برنامه اعلان ها را نمایش دهد، لطفا مجوز را در تنظیمات سیستم اعطا کنید." + diff --git a/libraries/permissions/api/src/main/res/values-fi/translations.xml b/libraries/permissions/api/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..5c9be59 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-fi/translations.xml @@ -0,0 +1,7 @@ + + + "Jotta sovellus voisi käyttää kameraa, anna lupa järjestelmän asetuksista." + "Anna lupa järjestelmän asetuksista." + "Jotta sovellus voisi käyttää mikrofonia, anna lupa järjestelmän asetuksista." + "Jotta sovellus voisi näyttää ilmoituksia, anna lupa järjestelmän asetuksista." + diff --git a/libraries/permissions/api/src/main/res/values-fr/translations.xml b/libraries/permissions/api/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..1ddcac9 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-fr/translations.xml @@ -0,0 +1,7 @@ + + + "Pour permettre à l’application d’utiliser l’appareil photo, veuillez accorder l’autorisation dans les paramètres du système." + "Veuillez accorder l’autorisation dans les paramètres du système." + "Pour permettre à l’application d’utiliser le microphone, veuillez accorder l’autorisation dans les paramètres du système." + "Pour permettre à l’application d’afficher les notifications, veuillez accorder l’autorisation dans les paramètres du système." + diff --git a/libraries/permissions/api/src/main/res/values-hu/translations.xml b/libraries/permissions/api/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..de3573f --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-hu/translations.xml @@ -0,0 +1,7 @@ + + + "Hogy az alkalmazás használhassa a kamerát, adja meg az engedélyt a rendszerbeállításokban." + "Adja meg az engedélyt a rendszerbeállításokban." + "Hogy az alkalmazás használhassa a mikrofont, adja meg az engedélyt a rendszerbeállításokban." + "Hogy az alkalmazás megjeleníthesse az értesítéseket, adja meg az engedélyt a rendszerbeállításokban." + diff --git a/libraries/permissions/api/src/main/res/values-in/translations.xml b/libraries/permissions/api/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..40fe4b0 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-in/translations.xml @@ -0,0 +1,7 @@ + + + "Supaya aplikasinya dapat menggunakan kamera, berikan izin dalam pengaturan sistem." + "Silakan memberikan izin dalam pengaturan sistem." + "Supaya aplikasinya dapat menggunakan mikrofon, berikan izin dalam pengaturan sistem." + "Supaya aplikasinya dapat menampilkan notifikasi, berikan izin dalam pengaturan sistem." + diff --git a/libraries/permissions/api/src/main/res/values-it/translations.xml b/libraries/permissions/api/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..2fe09c6 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-it/translations.xml @@ -0,0 +1,7 @@ + + + "Per permettere all\'applicazione di usare la fotocamera, concedi l\'autorizzazione nelle impostazioni di sistema." + "Concedi l\'autorizzazione nelle impostazioni di sistema." + "Per permettere all\'applicazione di usare il microfono, concedi l\'autorizzazione nelle impostazioni di sistema." + "Per permettere all\'applicazione di mostrare notifiche, concedi l\'autorizzazione nelle impostazioni di sistema." + diff --git a/libraries/permissions/api/src/main/res/values-ka/translations.xml b/libraries/permissions/api/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..ffeb3a4 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-ka/translations.xml @@ -0,0 +1,7 @@ + + + "იმისათვის, რომ აპლიკაციამ გამოიყენოს კამერა, გთხოვთ, მიანიჭოთ ნებართვა სისტემის პარამეტრებში." + "გთხოვთ, მიანიჭოთ ნებართვა სისტემის პარამეტრებში." + "იმისათვის, რომ აპლიკაციამ მიკროფონი გამოიყენოს, გთხოვთ, მიანიჭოთ ნებართვა სისტემის პარამეტრებში." + "იმისათვის, რომ აპლიკაციამ გამოაჩინოს შეტყობინებები, გთხოვთ, მიანიჭოთ ნებართვა სისტემის პარამეტრებში." + diff --git a/libraries/permissions/api/src/main/res/values-ko/translations.xml b/libraries/permissions/api/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..c1e3b42 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-ko/translations.xml @@ -0,0 +1,7 @@ + + + "애플리케이션이 카메라를 사용할 수 있도록 시스템 설정에서 권한을 허용해주세요." + "시스템 설정에서 권한을 허용해주세요." + "애플리케이션이 마이크를 사용할 수 있도록 시스템 설정에서 권한을 허용해주세요." + "애플리케이션이 알림을 표시할 수 있도록 시스템 설정에서 권한을 허용해주세요." + diff --git a/libraries/permissions/api/src/main/res/values-nb/translations.xml b/libraries/permissions/api/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..2736c66 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-nb/translations.xml @@ -0,0 +1,7 @@ + + + "For å la programmet bruke kameraet, vennligst gi tillatelse i systeminnstillingene." + "Vennligst gi tillatelse i systeminnstillingene." + "For å la applikasjonen bruke mikrofonen, må du gi tillatelse i systeminnstillingene." + "For å la applikasjonen vise varsler, må du gi tillatelse til dette i systeminnstillingene." + diff --git a/libraries/permissions/api/src/main/res/values-nl/translations.xml b/libraries/permissions/api/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..8f4eac6 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-nl/translations.xml @@ -0,0 +1,7 @@ + + + "Geef toestemming in de systeeminstellingen om de applicatie de camera te laten gebruiken." + "Geef hiervoor toestemming in de systeeminstellingen." + "Geef toestemming in de systeeminstellingen om de applicatie de microfoon te laten gebruiken." + "Geef toestemming in de systeeminstellingen om de applicatie meldingen te laten weergeven." + diff --git a/libraries/permissions/api/src/main/res/values-pl/translations.xml b/libraries/permissions/api/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..647eb9a --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-pl/translations.xml @@ -0,0 +1,7 @@ + + + "Aby umożliwić aplikacji korzystanie z aparatu, prosimy o udzielenie zezwolenia w ustawieniach systemowych." + "Proszę nadać uprawnienia w ustawieniach systemowych." + "Aby umożliwić aplikacji korzystanie z mikrofonu, prosimy o udzielenie zezwolenia w ustawieniach systemowych." + "Aby aplikacja mogła wyświetlać powiadomienia, udziel uprawnienia w ustawieniach systemowych." + diff --git a/libraries/permissions/api/src/main/res/values-pt-rBR/translations.xml b/libraries/permissions/api/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..ab2a74a --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,7 @@ + + + "Para permitir que o aplicativo use a câmera, conceda a permissão nas configurações do sistema." + "Por favor, conceda a permissão nas configurações do sistema." + "Para permitir que o aplicativo use o microfone, conceda a permissão nas configurações do sistema." + "Para permitir que o aplicativo exiba notificações, conceda a permissão nas configurações do sistema." + diff --git a/libraries/permissions/api/src/main/res/values-pt/translations.xml b/libraries/permissions/api/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..6a029c6 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-pt/translations.xml @@ -0,0 +1,7 @@ + + + "Para que a aplicação possa utilizar a câmara, concede a permissão nas configurações do sistema." + "Concede a permissão nas configurações do sistema." + "Para que a aplicação possa utilizar o microfone, concede essa permissão nas configurações do sistema." + "Para permitir que a aplicação apresente notificações, concede a permissão nas configurações do sistema." + diff --git a/libraries/permissions/api/src/main/res/values-ro/translations.xml b/libraries/permissions/api/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..30b6e4d --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-ro/translations.xml @@ -0,0 +1,7 @@ + + + "Pentru a permite aplicației să utilizeze camera, vă rugăm să acordați permisiunea în setările sistemului." + "Vă rugăm să acordați permisiunea în setările sistemului." + "Pentru a permite aplicației să utilizeze microfonul, vă rugăm să acordați permisiunea în setările sistemului." + "Pentru a permite aplicației să afișeze notificări, vă rugăm să acordați permisiunea în setările sistemului." + diff --git a/libraries/permissions/api/src/main/res/values-ru/translations.xml b/libraries/permissions/api/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..5c7a1b2 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-ru/translations.xml @@ -0,0 +1,7 @@ + + + "Чтобы приложение могло использовать камеру, предоставьте разрешение в системных настройках." + "Пожалуйста, предоставьте разрешение в системных настройках." + "Чтобы приложение могло использовать микрофон, предоставьте разрешение в системных настройках." + "Чтобы приложение отображало уведомления, предоставьте разрешение в системных настройках." + diff --git a/libraries/permissions/api/src/main/res/values-sk/translations.xml b/libraries/permissions/api/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..0670b59 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-sk/translations.xml @@ -0,0 +1,7 @@ + + + "Aby aplikácia mohla používať fotoaparát, udeľte povolenie v systémových nastaveniach." + "Udeľte prosím povolenie v systémových nastaveniach." + "Aby aplikácia mohla používať mikrofón, udeľte povolenie v systémových nastaveniach." + "Ak chcete, aby aplikácia zobrazovala oznámenia, udeľte povolenie v nastaveniach systému." + diff --git a/libraries/permissions/api/src/main/res/values-sv/translations.xml b/libraries/permissions/api/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..e57346a --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-sv/translations.xml @@ -0,0 +1,7 @@ + + + "För att låta programmet använda kameran, vänligen ge behörigheten i systeminställningarna." + "Vänligen ge behörigheten i systeminställningarna." + "För att låta programmet använda mikrofonen, vänligen ge behörigheten i systeminställningarna." + "För att låta applikationen visa aviseringar, vänligen bevilja behörighet i systeminställningarna." + diff --git a/libraries/permissions/api/src/main/res/values-tr/translations.xml b/libraries/permissions/api/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..20451d9 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-tr/translations.xml @@ -0,0 +1,7 @@ + + + "Uygulamanın kamerayı kullanmasına izin vermek için lütfen sistem ayarlarından izin verin." + "Lütfen sistem ayarlarından izin verin." + "Uygulamanın mikrofonu kullanmasına izin vermek için lütfen sistem ayarlarından izin verin." + "Uygulamanın bildirimleri görüntülemesine izin vermek için lütfen sistem ayarlarından izin verin." + diff --git a/libraries/permissions/api/src/main/res/values-uk/translations.xml b/libraries/permissions/api/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..6c69147 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-uk/translations.xml @@ -0,0 +1,7 @@ + + + "Щоб дозволити застосунку використовувати камеру, надайте дозвіл у системних налаштуваннях." + "Надайте дозвіл в системних налаштуваннях." + "Щоб дозволити застосунку використовувати мікрофон, надайте дозвіл у налаштуваннях системи." + "Щоб застосунок показував сповіщення, надайте дозвіл у налаштуваннях системи." + diff --git a/libraries/permissions/api/src/main/res/values-ur/translations.xml b/libraries/permissions/api/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..92925ae --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-ur/translations.xml @@ -0,0 +1,7 @@ + + + "اطلاقیے کو تصویرگر استعمال کرنے دینے کے لیے، برائے مہربانی نظام کی ترتیبات میں اجازت دیں۔" + "برائے مہربانی نظام کی ترتیبات میں اجازت دیں۔" + "اطلاقیے کو صوتگر استعمال کرنے دینے کے لیے، برائے مہربانی نظام کی ترتیبات میں اجازت دیں۔" + "اطلاقیے کو اطلاعات ظاہر کرنے دینے کے لیے، برائے مہربانی نظام کی ترتیبات میں اجازت دیں۔" + diff --git a/libraries/permissions/api/src/main/res/values-uz/translations.xml b/libraries/permissions/api/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..3106dae --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-uz/translations.xml @@ -0,0 +1,7 @@ + + + "Ilovaga kameradan foydalanishiga ruxsat berish uchun tizim sozlamalarida ruxsat bering." + "Iltimos, tizim sozlamalarida ruxsat bering." + "Ilovaga mikrofondan foydalanishiga ruxsat berish uchun tizim sozlamalarida ruxsat bering." + "Ilova bildirishnomalarni ko\'rsatishi uchun tizim sozlamalarida ruxsat bering." + diff --git a/libraries/permissions/api/src/main/res/values-zh-rTW/translations.xml b/libraries/permissions/api/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..cfd4762 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,7 @@ + + + "為了讓應用程式使用相機,請到系統設定中開啟權限。" + "請到系統設定中開啟權限。" + "為了讓應用程式使用麥克風,請到系統設定中開啟權限。" + "為了讓應用程式顯示通知,請到系統設定中開啟權限。" + diff --git a/libraries/permissions/api/src/main/res/values-zh/translations.xml b/libraries/permissions/api/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..eb09304 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-zh/translations.xml @@ -0,0 +1,7 @@ + + + "为了让应用程序使用相机,请在系统设置中授予权限。" + "请在系统设置中授予权限。" + "为了让应用程序使用麦克风,请在系统设置中授予权限。" + "为了让应用程序显示通知,请在系统设置中授予权限。" + diff --git a/libraries/permissions/api/src/main/res/values/localazy.xml b/libraries/permissions/api/src/main/res/values/localazy.xml new file mode 100644 index 0000000..b189d59 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values/localazy.xml @@ -0,0 +1,7 @@ + + + "In order to let the application use the camera, please grant the permission in the system settings." + "Please grant the permission in the system settings." + "In order to let the application use the microphone, please grant the permission in the system settings." + "In order to let the application display notifications, please grant the permission in the system settings." + diff --git a/libraries/permissions/impl/build.gradle.kts b/libraries/permissions/impl/build.gradle.kts new file mode 100644 index 0000000..70e27db --- /dev/null +++ b/libraries/permissions/impl/build.gradle.kts @@ -0,0 +1,49 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.permissions.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + 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.troubleshoot.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.preferences.api) + implementation(projects.services.toolbox.api) + api(projects.libraries.permissions.api) + + testCommonDependencies(libs) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.permissions.test) + testImplementation(projects.libraries.troubleshoot.test) + testImplementation(projects.services.toolbox.test) +} 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 0000000..be5ce1a --- /dev/null +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/AccompanistPermissionStateProvider.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding + +interface ComposablePermissionStateProvider { + @Composable + fun provide(permission: String, onPermissionResult: (Boolean) -> Unit): PermissionState +} + +@ContributesBinding(AppScope::class) +class AccompanistPermissionStateProvider : ComposablePermissionStateProvider { + @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/DefaultPermissionStateProvider.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionStateProvider.kt new file mode 100644 index 0000000..fadde40 --- /dev/null +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionStateProvider.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.impl + +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.permissions.api.PermissionStateProvider +import io.element.android.libraries.permissions.api.PermissionsStore +import kotlinx.coroutines.flow.Flow + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultPermissionStateProvider( + @ApplicationContext private val context: Context, + private val permissionsStore: PermissionsStore, +) : PermissionStateProvider { + override fun isPermissionGranted(permission: String): Boolean { + return ContextCompat.checkSelfPermission( + context, + permission, + ) == PackageManager.PERMISSION_GRANTED + } + + override suspend fun setPermissionDenied(permission: String, value: Boolean) = permissionsStore.setPermissionDenied(permission, value) + + override fun isPermissionDenied(permission: String): Flow = permissionsStore.isPermissionDenied(permission) + + override suspend fun setPermissionAsked(permission: String, value: Boolean) = permissionsStore.setPermissionAsked(permission, value) + + override fun isPermissionAsked(permission: String): Flow = permissionsStore.isPermissionAsked(permission) +} 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 0000000..76e2880 --- /dev/null +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.remember +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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.log.logger.LoggerTag +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 io.element.android.libraries.permissions.api.PermissionsStore +import io.element.android.libraries.permissions.impl.action.PermissionActions +import kotlinx.coroutines.launch +import timber.log.Timber + +private val loggerTag = LoggerTag("DefaultPermissionsPresenter") + +@AssistedInject +class DefaultPermissionsPresenter( + @Assisted val permission: String, + private val permissionsStore: PermissionsStore, + private val composablePermissionStateProvider: ComposablePermissionStateProvider, + private val permissionActions: PermissionActions, +) : 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 remember { + permissionsStore.isPermissionDenied(permission) + }.collectAsState(initial = false) + + val isAlreadyAsked: Boolean by remember { + 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 = composablePermissionStateProvider.provide( + permission = permission, + onPermissionResult = ::onPermissionResult + ) + + LaunchedEffect(this) { + if (permissionState.status.isGranted) { + // User may have granted permission from the settings, so reset the store regarding this permission + permissionsStore.resetPermission(permission) + } + } + + val showDialog = rememberSaveable { mutableStateOf(false) } + + fun handleEvent(event: PermissionsEvents) { + when (event) { + PermissionsEvents.CloseDialog -> { + showDialog.value = false + } + PermissionsEvents.RequestPermissions -> { + if (permissionState.status !is PermissionStatus.Granted && isAlreadyDenied) { + showDialog.value = true + } else { + permissionState.launchPermissionRequest() + } + } + PermissionsEvents.OpenSystemSettingAndCloseDialog -> { + permissionActions.openSettings() + showDialog.value = false + } + } + } + + return PermissionsState( + permission = permissionState.permission, + permissionGranted = permissionState.status.isGranted, + shouldShowRationale = permissionState.status.shouldShowRationale, + showDialog = showDialog.value, + permissionAlreadyAsked = isAlreadyAsked, + permissionAlreadyDenied = isAlreadyDenied, + eventSink = ::handleEvent, + ) + } + + /* + @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 0000000..facc633 --- /dev/null +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsStore.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.impl + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.permissions.api.PermissionsStore +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +@ContributesBinding(AppScope::class) +class DefaultPermissionsStore( + preferenceDataStoreFactory: PreferenceDataStoreFactory, +) : PermissionsStore { + private val store = preferenceDataStoreFactory.create("permissions_store") + + override suspend fun setPermissionDenied(permission: String, value: Boolean) { + store.edit { prefs -> + prefs[getDeniedPreferenceKey(permission)] = value + } + } + + override fun isPermissionDenied(permission: String): Flow { + 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 { + 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/action/AndroidPermissionActions.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/AndroidPermissionActions.kt new file mode 100644 index 0000000..59ebfc6 --- /dev/null +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/AndroidPermissionActions.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.impl.action + +import android.content.Context +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.system.startNotificationSettingsIntent +import io.element.android.libraries.di.annotations.ApplicationContext + +@ContributesBinding(AppScope::class) +class AndroidPermissionActions( + @ApplicationContext private val context: Context +) : PermissionActions { + override fun openSettings() { + context.startNotificationSettingsIntent() + } +} diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/PermissionActions.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/PermissionActions.kt new file mode 100644 index 0000000..5c496bf --- /dev/null +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/PermissionActions.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.impl.action + +interface PermissionActions { + fun openSettings() +} diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTest.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTest.kt new file mode 100644 index 0000000..f5ff79e --- /dev/null +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.impl.troubleshoot + +import android.Manifest +import android.os.Build +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.libraries.permissions.api.PermissionStateProvider +import io.element.android.libraries.permissions.impl.R +import io.element.android.libraries.permissions.impl.action.PermissionActions +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootNavigator +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestDelegate +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import io.element.android.services.toolbox.api.strings.StringProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +@ContributesIntoSet(AppScope::class) +@Inject +class NotificationTroubleshootCheckPermissionTest( + private val permissionStateProvider: PermissionStateProvider, + private val sdkVersionProvider: BuildVersionSdkIntProvider, + private val permissionActions: PermissionActions, + stringProvider: StringProvider, +) : NotificationTroubleshootTest { + override val order: Int = 0 + + private val delegate = NotificationTroubleshootTestDelegate( + defaultName = stringProvider.getString(R.string.troubleshoot_notifications_test_check_permission_title), + defaultDescription = stringProvider.getString(R.string.troubleshoot_notifications_test_check_permission_description), + hasQuickFix = true, + fakeDelay = NotificationTroubleshootTestDelegate.SHORT_DELAY, + ) + + override val state: StateFlow = delegate.state + + override suspend fun run(coroutineScope: CoroutineScope) { + delegate.start() + val result = if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { + permissionStateProvider.isPermissionGranted(Manifest.permission.POST_NOTIFICATIONS) + } else { + true + } + delegate.done(result) + } + + override suspend fun reset() = delegate.reset() + + override suspend fun quickFix( + coroutineScope: CoroutineScope, + navigator: NotificationTroubleshootNavigator, + ) { + // Do not bother about asking the permission inline, just lead the user to the settings + permissionActions.openSettings() + } +} diff --git a/libraries/permissions/impl/src/main/res/values-be/translations.xml b/libraries/permissions/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..44c9d83 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,5 @@ + + + "Пераканайцеся, што праграма можа паказваць апавяшчэнні." + "Праверце дазволы" + diff --git a/libraries/permissions/impl/src/main/res/values-bg/translations.xml b/libraries/permissions/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..7c8a783 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,5 @@ + + + "Проверка дали приложението може да показва известия." + "Проверка на разрешенията" + diff --git a/libraries/permissions/impl/src/main/res/values-cs/translations.xml b/libraries/permissions/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..038561d --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,5 @@ + + + "Ujistěte se, že aplikace může zobrazovat oznámení." + "Kontrola oprávnění" + diff --git a/libraries/permissions/impl/src/main/res/values-cy/translations.xml b/libraries/permissions/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..72a0373 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,5 @@ + + + "Gwiriwch y gall y rhaglen ddangos hysbysiadau." + "Gwirio caniatâd" + diff --git a/libraries/permissions/impl/src/main/res/values-da/translations.xml b/libraries/permissions/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..871610e --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,5 @@ + + + "Kontroller, at applikationen kan vise underretninger." + "Kontroller tilladelser" + diff --git a/libraries/permissions/impl/src/main/res/values-de/translations.xml b/libraries/permissions/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..180a05f --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,5 @@ + + + "Prüfe, dass die Anwendung Benachrichtigungen anzeigen kann." + "Berechtigungen überprüfen" + diff --git a/libraries/permissions/impl/src/main/res/values-el/translations.xml b/libraries/permissions/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..53ea950 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,5 @@ + + + "Έλεγξε ότι η εφαρμογή μπορεί να εμφανίζει ειδοποιήσεις." + "Έλεγχος δικαιωμάτων" + diff --git a/libraries/permissions/impl/src/main/res/values-es/translations.xml b/libraries/permissions/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..45d5eaf --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,5 @@ + + + "Verificar que la aplicación pueda mostrar notificaciones." + "Verificar permisos" + diff --git a/libraries/permissions/impl/src/main/res/values-et/translations.xml b/libraries/permissions/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..178b782 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,5 @@ + + + "Kontrolli, kas rakendus võib kuvada teavitusi." + "Täpsusta õigusi" + diff --git a/libraries/permissions/impl/src/main/res/values-eu/translations.xml b/libraries/permissions/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..9f5c8ed --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,5 @@ + + + "Egiaztatu aplikazioak jakinarazpenak erakutsi ditzakeela." + "Egiaztatu baimenak" + diff --git a/libraries/permissions/impl/src/main/res/values-fa/translations.xml b/libraries/permissions/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..8d566ef --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,4 @@ + + + "بررسی اجازه‌ها" + diff --git a/libraries/permissions/impl/src/main/res/values-fi/translations.xml b/libraries/permissions/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..f8253bc --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,5 @@ + + + "Tarkistaa, että sovellus voi näyttää ilmoituksia." + "Lupien tarkistus" + diff --git a/libraries/permissions/impl/src/main/res/values-fr/translations.xml b/libraries/permissions/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..b2cd018 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,5 @@ + + + "Vérifie que l’application peut afficher des notifications." + "Vérifier les autorisations" + diff --git a/libraries/permissions/impl/src/main/res/values-hu/translations.xml b/libraries/permissions/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..4afe7f1 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,5 @@ + + + "Ellenőrizze, hogy az alkalmazás képes-e értesítéseket megjeleníteni." + "Engedélyek ellenőrzése" + diff --git a/libraries/permissions/impl/src/main/res/values-in/translations.xml b/libraries/permissions/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..dff88d1 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,5 @@ + + + "Pastikan aplikasi dapat menampilkan notifikasi." + "Periksa izin" + diff --git a/libraries/permissions/impl/src/main/res/values-it/translations.xml b/libraries/permissions/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..2b2b4c4 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,5 @@ + + + "Verifica che l\'applicazione possa mostrare le notifiche." + "Controlla autorizzazioni" + diff --git a/libraries/permissions/impl/src/main/res/values-ka/translations.xml b/libraries/permissions/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..2dbb401 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,5 @@ + + + "შეამოწმეთ რომ აპლიკაციას შეტყობინებების ჩვენება შეუძლია." + "ნებართვების შემოწმება" + diff --git a/libraries/permissions/impl/src/main/res/values-ko/translations.xml b/libraries/permissions/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..06ed586 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,5 @@ + + + "애플리케이션에서 알림을 표시할 수 있는지 확인하세요." + "권한 확인" + diff --git a/libraries/permissions/impl/src/main/res/values-nb/translations.xml b/libraries/permissions/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..b53212a --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,5 @@ + + + "Kontroller at programmet kan vise varsler." + "Sjekk tillatelser" + diff --git a/libraries/permissions/impl/src/main/res/values-nl/translations.xml b/libraries/permissions/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..b23c075 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,5 @@ + + + "Controleren of de applicatie meldingen kan weergeven." + "Controleer machtigingen" + diff --git a/libraries/permissions/impl/src/main/res/values-pl/translations.xml b/libraries/permissions/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..c3f5197 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,5 @@ + + + "Sprawdź, czy aplikacja może wyświetlać powiadomienia." + "Sprawdź uprawnienia" + diff --git a/libraries/permissions/impl/src/main/res/values-pt-rBR/translations.xml b/libraries/permissions/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..e4e2226 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,5 @@ + + + "Verifique se o aplicativo pode mostrar notificações." + "Verifique as permissões" + diff --git a/libraries/permissions/impl/src/main/res/values-pt/translations.xml b/libraries/permissions/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..128711f --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,5 @@ + + + "Verificar se a aplicação consegue mostrar notificações." + "Verificar permissões" + diff --git a/libraries/permissions/impl/src/main/res/values-ro/translations.xml b/libraries/permissions/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..af6b9d2 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,5 @@ + + + "Verificați dacă aplicația poate afișa notificări." + "Verificați permisiunile" + diff --git a/libraries/permissions/impl/src/main/res/values-ru/translations.xml b/libraries/permissions/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..d1acff7 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,5 @@ + + + "Убедитесь, что приложение может показывать уведомления." + "Проверка разрешений" + diff --git a/libraries/permissions/impl/src/main/res/values-sk/translations.xml b/libraries/permissions/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..9957549 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,5 @@ + + + "Uistite sa, že aplikácia dokáže zobrazovať upozornenia." + "Skontrolovať povolenia" + diff --git a/libraries/permissions/impl/src/main/res/values-sv/translations.xml b/libraries/permissions/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..ab32646 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,5 @@ + + + "Kontrollera att applikationen kan visa aviseringar." + "Kontrollera behörigheter" + diff --git a/libraries/permissions/impl/src/main/res/values-tr/translations.xml b/libraries/permissions/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..84407bc --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,5 @@ + + + "Uygulamanın bildirimleri gösterebildiğini kontrol edin." + "İzinleri kontrol et" + diff --git a/libraries/permissions/impl/src/main/res/values-uk/translations.xml b/libraries/permissions/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..ef1daf4 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,5 @@ + + + "Перевірте, чи може застосунок показувати сповіщення." + "Перевірте дозволи" + diff --git a/libraries/permissions/impl/src/main/res/values-ur/translations.xml b/libraries/permissions/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..f714837 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,5 @@ + + + "پڑتال کریں کہ اطلاقیہ اطلاعات دکھا سکتا ہے" + "اجازتوں کی پڑتال کریں" + diff --git a/libraries/permissions/impl/src/main/res/values-uz/translations.xml b/libraries/permissions/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..a7c2782 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,5 @@ + + + "Ilova bildirishnomalarni ko‘rsata olishini tekshiring." + "Ruxsatlarni tekshiring" + diff --git a/libraries/permissions/impl/src/main/res/values-zh-rTW/translations.xml b/libraries/permissions/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..4308b61 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,5 @@ + + + "檢查應用程式是否可以顯示通知。" + "檢查權限" + diff --git a/libraries/permissions/impl/src/main/res/values-zh/translations.xml b/libraries/permissions/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..ac12346 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,5 @@ + + + "检查应用程序是否可以显示通知。" + "检查权限" + diff --git a/libraries/permissions/impl/src/main/res/values/localazy.xml b/libraries/permissions/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..948b026 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values/localazy.xml @@ -0,0 +1,5 @@ + + + "Check that the application can show notifications." + "Check permissions" + 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 0000000..069a632 --- /dev/null +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalPermissionsApi::class) + +package io.element.android.libraries.permissions.impl + +import app.cash.molecule.RecompositionMode +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 io.element.android.libraries.permissions.impl.action.FakePermissionActions +import io.element.android.libraries.permissions.test.InMemoryPermissionsStore +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +const val A_PERMISSION = "A_PERMISSION" + +class DefaultPermissionsPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val permissionsStore = InMemoryPermissionsStore() + val permissionState = FakePermissionState( + A_PERMISSION, + PermissionStatus.Granted + ) + val permissionStateProvider = + FakeComposablePermissionStateProvider( + permissionState + ) + val presenter = DefaultPermissionsPresenter( + A_PERMISSION, + permissionsStore, + permissionStateProvider, + FakePermissionActions(), + ) + moleculeFlow(RecompositionMode.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( + permissionDenied = true, + permissionAsked = true + ) + val permissionState = FakePermissionState( + A_PERMISSION, + PermissionStatus.Denied(shouldShowRationale = false) + ) + val permissionStateProvider = + FakeComposablePermissionStateProvider( + permissionState + ) + val presenter = DefaultPermissionsPresenter( + A_PERMISSION, + permissionsStore, + permissionStateProvider, + FakePermissionActions(), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink.invoke(PermissionsEvents.RequestPermissions) + val withDialogState = awaitItem() + assertThat(withDialogState.showDialog).isTrue() + withDialogState.eventSink.invoke(PermissionsEvents.CloseDialog) + assertThat(awaitItem().showDialog).isFalse() + } + } + + @Test + fun `present - user open settings`() = runTest { + val permissionsStore = InMemoryPermissionsStore( + permissionDenied = true, + permissionAsked = true + ) + val permissionState = FakePermissionState( + A_PERMISSION, + PermissionStatus.Denied(shouldShowRationale = false) + ) + val permissionStateProvider = + FakeComposablePermissionStateProvider( + permissionState + ) + val permissionActions = FakePermissionActions() + val presenter = DefaultPermissionsPresenter( + A_PERMISSION, + permissionsStore, + permissionStateProvider, + permissionActions, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink.invoke(PermissionsEvents.RequestPermissions) + val withDialogState = awaitItem() + assertThat(withDialogState.showDialog).isTrue() + assertThat(permissionActions.openSettingsCalled).isFalse() + withDialogState.eventSink.invoke(PermissionsEvents.OpenSystemSettingAndCloseDialog) + assertThat(awaitItem().showDialog).isFalse() + assertThat(permissionActions.openSettingsCalled).isTrue() + } + } + + @Test + fun `present - user does not grant permission`() = runTest { + val permissionsStore = InMemoryPermissionsStore() + val permissionState = FakePermissionState( + A_PERMISSION, + PermissionStatus.Denied(shouldShowRationale = false) + ) + val permissionStateProvider = + FakeComposablePermissionStateProvider( + permissionState + ) + val presenter = DefaultPermissionsPresenter( + A_PERMISSION, + permissionsStore, + permissionStateProvider, + FakePermissionActions(), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.showDialog).isFalse() + initialState.eventSink.invoke(PermissionsEvents.RequestPermissions) + assertThat(permissionState.launchPermissionRequestCalled).isTrue() + // 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 = + FakeComposablePermissionStateProvider( + permissionState + ) + val presenter = DefaultPermissionsPresenter( + A_PERMISSION, + permissionsStore, + permissionStateProvider, + FakePermissionActions(), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.showDialog).isFalse() + initialState.eventSink.invoke(PermissionsEvents.RequestPermissions) + assertThat(permissionState.launchPermissionRequestCalled).isTrue() + // 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 = + FakeComposablePermissionStateProvider( + permissionState + ) + val presenter = DefaultPermissionsPresenter( + A_PERMISSION, + permissionsStore, + permissionStateProvider, + FakePermissionActions(), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink.invoke(PermissionsEvents.RequestPermissions) + val withDialogState = awaitItem() + assertThat(withDialogState.showDialog).isTrue() + assertThat(withDialogState.permissionGranted).isFalse() + assertThat(withDialogState.permissionAlreadyDenied).isTrue() + assertThat(withDialogState.permissionAlreadyAsked).isTrue() + } + } + + @Test + fun `present - user grants permission`() = runTest { + val permissionsStore = InMemoryPermissionsStore() + val permissionState = FakePermissionState( + A_PERMISSION, + PermissionStatus.Denied(shouldShowRationale = false) + ) + val permissionStateProvider = + FakeComposablePermissionStateProvider( + permissionState + ) + val presenter = DefaultPermissionsPresenter( + A_PERMISSION, + permissionsStore, + permissionStateProvider, + FakePermissionActions(), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.showDialog).isFalse() + initialState.eventSink.invoke(PermissionsEvents.RequestPermissions) + assertThat(permissionState.launchPermissionRequestCalled).isTrue() + // 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/FakeComposablePermissionStateProvider.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/FakeComposablePermissionStateProvider.kt new file mode 100644 index 0000000..eca61c6 --- /dev/null +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/FakeComposablePermissionStateProvider.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@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 FakeComposablePermissionStateProvider( + private val permissionState: FakePermissionState +) : ComposablePermissionStateProvider { + 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/action/FakePermissionActions.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/action/FakePermissionActions.kt new file mode 100644 index 0000000..4299672 --- /dev/null +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/action/FakePermissionActions.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.impl.action + +class FakePermissionActions( + val openSettingsAction: () -> Unit = {} +) : PermissionActions { + var openSettingsCalled = false + private set + + override fun openSettings() { + openSettingsAction() + openSettingsCalled = true + } +} diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTestTest.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTestTest.kt new file mode 100644 index 0000000..03a9027 --- /dev/null +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTestTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.impl.troubleshoot + +import android.os.Build +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.permissions.impl.action.FakePermissionActions +import io.element.android.libraries.permissions.test.FakePermissionStateProvider +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleshootNavigator +import io.element.android.libraries.troubleshoot.test.runAndTestState +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class NotificationTroubleshootCheckPermissionTestTest { + @Test + fun `test NotificationTroubleshootCheckPermissionTest below TIRAMISU success`() = runTest { + val sut = NotificationTroubleshootCheckPermissionTest( + permissionStateProvider = FakePermissionStateProvider(), + sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkInt = Build.VERSION_CODES.TIRAMISU - 1), + permissionActions = FakePermissionActions(), + stringProvider = FakeStringProvider(), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + } + } + + @Test + fun `test NotificationTroubleshootCheckPermissionTest TIRAMISU success`() = runTest { + val sut = NotificationTroubleshootCheckPermissionTest( + permissionStateProvider = FakePermissionStateProvider(), + sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkInt = Build.VERSION_CODES.TIRAMISU), + permissionActions = FakePermissionActions(), + stringProvider = FakeStringProvider(), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + } + } + + @Test + fun `test NotificationTroubleshootCheckPermissionTest TIRAMISU error`() = runTest { + val permissionStateProvider = FakePermissionStateProvider( + permissionGranted = false + ) + val actions = FakePermissionActions( + openSettingsAction = { + permissionStateProvider.setPermissionGranted() + } + ) + val sut = NotificationTroubleshootCheckPermissionTest( + permissionStateProvider = permissionStateProvider, + sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkInt = Build.VERSION_CODES.TIRAMISU), + permissionActions = actions, + stringProvider = FakeStringProvider(), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(hasQuickFix = true)) + // Quick fix + backgroundScope.launch { + sut.quickFix(this, FakeNotificationTroubleshootNavigator()) + // Run the test again (IRL it will be done thanks to the resuming of the application) + sut.run(this) + } + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + } + } + + @Test + fun `test NotificationTroubleshootCheckPermissionTest error and reset`() = runTest { + val permissionStateProvider = FakePermissionStateProvider( + permissionGranted = false + ) + val actions = FakePermissionActions( + openSettingsAction = { + permissionStateProvider.setPermissionGranted() + } + ) + val sut = NotificationTroubleshootCheckPermissionTest( + permissionStateProvider = permissionStateProvider, + sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkInt = Build.VERSION_CODES.TIRAMISU), + permissionActions = actions, + stringProvider = FakeStringProvider(), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(hasQuickFix = true)) + sut.reset() + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + } + } +} diff --git a/libraries/permissions/noop/build.gradle.kts b/libraries/permissions/noop/build.gradle.kts new file mode 100644 index 0000000..d9aad63 --- /dev/null +++ b/libraries/permissions/noop/build.gradle.kts @@ -0,0 +1,24 @@ +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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) + + testCommonDependencies(libs) +} 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 0000000..fb18fa6 --- /dev/null +++ b/libraries/permissions/noop/src/main/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenter.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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( + private val isGranted: Boolean = false, +) : PermissionsPresenter { + @Composable + override fun present(): PermissionsState { + return PermissionsState( + permission = "", + permissionGranted = isGranted, + 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 0000000..aff3c87 --- /dev/null +++ b/libraries/permissions/noop/src/test/kotlin/io/element/android/libraries/permissions/noop/NoopPermissionsPresenterTest.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.noop + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class NoopPermissionsPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = NoopPermissionsPresenter() + moleculeFlow(RecompositionMode.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/permissions/test/build.gradle.kts b/libraries/permissions/test/build.gradle.kts new file mode 100644 index 0000000..1601143 --- /dev/null +++ b/libraries/permissions/test/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.permissions.test" +} + +dependencies { + implementation(projects.libraries.architecture) + api(projects.libraries.permissions.api) +} diff --git a/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionStateProvider.kt b/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionStateProvider.kt new file mode 100644 index 0000000..b7fa096 --- /dev/null +++ b/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionStateProvider.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.test + +import io.element.android.libraries.permissions.api.PermissionStateProvider +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FakePermissionStateProvider( + private var permissionGranted: Boolean = true, + permissionDenied: Boolean = false, + permissionAsked: Boolean = false, +) : PermissionStateProvider { + private val permissionDeniedFlow = MutableStateFlow(permissionDenied) + private val permissionAskedFlow = MutableStateFlow(permissionAsked) + + fun setPermissionGranted() { + permissionGranted = true + } + + override fun isPermissionGranted(permission: String): Boolean = permissionGranted + + override suspend fun setPermissionDenied(permission: String, value: Boolean) { + permissionDeniedFlow.value = value + } + + override fun isPermissionDenied(permission: String): Flow = permissionDeniedFlow + + override suspend fun setPermissionAsked(permission: String, value: Boolean) { + permissionAskedFlow.value = value + } + + override fun isPermissionAsked(permission: String): Flow = permissionAskedFlow +} diff --git a/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionsPresenter.kt b/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionsPresenter.kt new file mode 100644 index 0000000..b15f4db --- /dev/null +++ b/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionsPresenter.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.test + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +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 io.element.android.libraries.permissions.api.aPermissionsState + +class FakePermissionsPresenter( + private val initialState: PermissionsState = aPermissionsState(showDialog = false), +) : PermissionsPresenter { + private fun handleEvent(event: PermissionsEvents) { + when (event) { + PermissionsEvents.RequestPermissions -> state.value = state.value.copy(showDialog = true, permissionAlreadyAsked = true) + PermissionsEvents.CloseDialog -> state.value = state.value.copy(showDialog = false) + PermissionsEvents.OpenSystemSettingAndCloseDialog -> state.value = state.value.copy(showDialog = false) + } + } + + private val state = mutableStateOf(initialState.copy(eventSink = ::handleEvent)) + + fun setPermissionGranted() { + state.value = state.value.copy(permissionGranted = true) + } + + fun setPermissionDenied() { + state.value = state.value.copy(permissionGranted = false, permissionAlreadyDenied = true) + } + + @Composable + override fun present(): PermissionsState { + return state.value + } +} diff --git a/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionsPresenterFactory.kt b/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionsPresenterFactory.kt new file mode 100644 index 0000000..49c258a --- /dev/null +++ b/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionsPresenterFactory.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.test + +import io.element.android.libraries.permissions.api.PermissionsPresenter + +class FakePermissionsPresenterFactory( + private val permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(), +) : PermissionsPresenter.Factory { + override fun create(permission: String): PermissionsPresenter { + return permissionPresenter + } +} diff --git a/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/InMemoryPermissionsStore.kt b/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/InMemoryPermissionsStore.kt new file mode 100644 index 0000000..38aff2f --- /dev/null +++ b/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/InMemoryPermissionsStore.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.permissions.test + +import io.element.android.libraries.permissions.api.PermissionsStore +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 = permissionDeniedFlow + + override suspend fun setPermissionAsked(permission: String, value: Boolean) { + permissionAskedFlow.value = value + } + + override fun isPermissionAsked(permission: String): Flow = permissionAskedFlow + + override suspend fun resetPermission(permission: String) { + setPermissionAsked(permission, false) + setPermissionDenied(permission, false) + } + + override suspend fun resetStore() = Unit +} diff --git a/libraries/preferences/api/build.gradle.kts b/libraries/preferences/api/build.gradle.kts new file mode 100644 index 0000000..a441616 --- /dev/null +++ b/libraries/preferences/api/build.gradle.kts @@ -0,0 +1,26 @@ +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.preferences.api" +} + +dependencies { + implementation(libs.coroutines.core) + implementation(projects.libraries.matrix.api) + implementation(libs.androidx.datastore.preferences) + + testCommonDependencies(libs) + testImplementation(projects.libraries.preferences.test) +} diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt new file mode 100644 index 0000000..4766589 --- /dev/null +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.preferences.api.store + +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.matrix.api.tracing.LogLevel +import io.element.android.libraries.matrix.api.tracing.TraceLogPack +import kotlinx.coroutines.flow.Flow + +interface AppPreferencesStore { + suspend fun setDeveloperModeEnabled(enabled: Boolean) + fun isDeveloperModeEnabledFlow(): Flow + + suspend fun setCustomElementCallBaseUrl(string: String?) + fun getCustomElementCallBaseUrlFlow(): Flow + + suspend fun setTheme(theme: String) + fun getThemeFlow(): Flow + + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") + suspend fun setHideInviteAvatars(hide: Boolean?) + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") + fun getHideInviteAvatarsFlow(): Flow + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") + suspend fun setTimelineMediaPreviewValue(mediaPreviewValue: MediaPreviewValue?) + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") + fun getTimelineMediaPreviewValueFlow(): Flow + + suspend fun setTracingLogLevel(logLevel: LogLevel) + fun getTracingLogLevelFlow(): Flow + + suspend fun setTracingLogPacks(targets: Set) + fun getTracingLogPacksFlow(): Flow> + + suspend fun reset() +} diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/PreferenceDataStoreFactory.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/PreferenceDataStoreFactory.kt new file mode 100644 index 0000000..50f6eb2 --- /dev/null +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/PreferenceDataStoreFactory.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.preferences.api.store + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences + +/** + * Factory used to create a [DataStore] for preferences. + * + * It's a wrapper around AndroidX's `PreferenceDataStoreFactory` to make testing easier. + */ +interface PreferenceDataStoreFactory { + fun create(name: String): DataStore +} diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStore.kt new file mode 100644 index 0000000..d3f3fb4 --- /dev/null +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStore.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.preferences.api.store + +import kotlinx.coroutines.flow.Flow + +interface SessionPreferencesStore { + suspend fun setSharePresence(enabled: Boolean) + fun isSharePresenceEnabled(): Flow + + suspend fun setSendPublicReadReceipts(enabled: Boolean) + fun isSendPublicReadReceiptsEnabled(): Flow + + suspend fun setRenderReadReceipts(enabled: Boolean) + fun isRenderReadReceiptsEnabled(): Flow + + suspend fun setSendTypingNotifications(enabled: Boolean) + fun isSendTypingNotificationsEnabled(): Flow + + suspend fun setRenderTypingNotifications(enabled: Boolean) + fun isRenderTypingNotificationsEnabled(): Flow + + suspend fun setSkipSessionVerification(skip: Boolean) + fun isSessionVerificationSkipped(): Flow + + suspend fun setOptimizeImages(compress: Boolean) + fun doesOptimizeImages(): Flow + + suspend fun setVideoCompressionPreset(preset: VideoCompressionPreset) + fun getVideoCompressionPreset(): Flow + + suspend fun clear() +} diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStoreFactory.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStoreFactory.kt new file mode 100644 index 0000000..2c0038d --- /dev/null +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStoreFactory.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.preferences.api.store + +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.CoroutineScope + +interface SessionPreferencesStoreFactory { + fun get(sessionId: SessionId, sessionCoroutineScope: CoroutineScope): SessionPreferencesStore + fun remove(sessionId: SessionId) +} diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/VideoCompressionPreset.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/VideoCompressionPreset.kt new file mode 100644 index 0000000..22e0da1 --- /dev/null +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/VideoCompressionPreset.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.preferences.api.store + +/** + * Video compression presets to use when processing videos before uploading them. + */ +enum class VideoCompressionPreset { + /** High quality compression, suitable for high-resolution videos. */ + HIGH, + + /** Standard quality compression, suitable for most videos. */ + STANDARD, + + /** Low quality compression, suitable for low-resolution videos or when bandwidth is a concern. */ + LOW +} diff --git a/libraries/preferences/impl/build.gradle.kts b/libraries/preferences/impl/build.gradle.kts new file mode 100644 index 0000000..c567471 --- /dev/null +++ b/libraries/preferences/impl/build.gradle.kts @@ -0,0 +1,28 @@ +import extension.setupDependencyInjection + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.preferences.impl" +} + +setupDependencyInjection() + +dependencies { + api(projects.libraries.preferences.api) + implementation(libs.androidx.datastore.preferences) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.di) + implementation(projects.libraries.core) + implementation(projects.libraries.matrix.api) +} diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt new file mode 100644 index 0000000..6856f8b --- /dev/null +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.preferences.impl.store + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.matrix.api.tracing.LogLevel +import io.element.android.libraries.matrix.api.tracing.TraceLogPack +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val developerModeKey = booleanPreferencesKey("developerMode") +private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl") +private val themeKey = stringPreferencesKey("theme") +private val hideInviteAvatarsKey = booleanPreferencesKey("hideInviteAvatars") +private val timelineMediaPreviewValueKey = stringPreferencesKey("timelineMediaPreviewValue") +private val logLevelKey = stringPreferencesKey("logLevel") +private val traceLogPacksKey = stringPreferencesKey("traceLogPacks") + +@ContributesBinding(AppScope::class) +class DefaultAppPreferencesStore( + private val buildMeta: BuildMeta, + preferenceDataStoreFactory: PreferenceDataStoreFactory, +) : AppPreferencesStore { + private val store = preferenceDataStoreFactory.create("elementx_preferences") + + override suspend fun setDeveloperModeEnabled(enabled: Boolean) { + store.edit { prefs -> + prefs[developerModeKey] = enabled + } + } + + override fun isDeveloperModeEnabledFlow(): Flow { + return store.data.map { prefs -> + // disabled by default on release and nightly, enabled by default on debug + prefs[developerModeKey] ?: (buildMeta.buildType == BuildType.DEBUG) + } + } + + override suspend fun setCustomElementCallBaseUrl(string: String?) { + store.edit { prefs -> + if (string != null) { + prefs[customElementCallBaseUrlKey] = string + } else { + prefs.remove(customElementCallBaseUrlKey) + } + } + } + + override fun getCustomElementCallBaseUrlFlow(): Flow { + return store.data.map { prefs -> + prefs[customElementCallBaseUrlKey] + } + } + + override suspend fun setTheme(theme: String) { + store.edit { prefs -> + prefs[themeKey] = theme + } + } + + override fun getThemeFlow(): Flow { + return store.data.map { prefs -> + prefs[themeKey] + } + } + + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") + override fun getHideInviteAvatarsFlow(): Flow { + return store.data.map { prefs -> + prefs[hideInviteAvatarsKey] + } + } + + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") + override suspend fun setHideInviteAvatars(hide: Boolean?) { + store.edit { prefs -> + if (hide != null) { + prefs[hideInviteAvatarsKey] = hide + } else { + prefs.remove(hideInviteAvatarsKey) + } + } + } + + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") + override suspend fun setTimelineMediaPreviewValue(mediaPreviewValue: MediaPreviewValue?) { + store.edit { prefs -> + if (mediaPreviewValue != null) { + prefs[timelineMediaPreviewValueKey] = mediaPreviewValue.name + } else { + prefs.remove(timelineMediaPreviewValueKey) + } + } + } + + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") + override fun getTimelineMediaPreviewValueFlow(): Flow { + return store.data.map { prefs -> + prefs[timelineMediaPreviewValueKey]?.let { MediaPreviewValue.valueOf(it) } + } + } + + override suspend fun setTracingLogLevel(logLevel: LogLevel) { + store.edit { prefs -> + prefs[logLevelKey] = logLevel.name + } + } + + override fun getTracingLogLevelFlow(): Flow { + return store.data.map { prefs -> + prefs[logLevelKey]?.let { LogLevel.valueOf(it) } ?: buildMeta.defaultLogLevel() + } + } + + override suspend fun setTracingLogPacks(targets: Set) { + val value = targets.joinToString(",") { it.key } + store.edit { prefs -> + prefs[traceLogPacksKey] = value + } + } + + override fun getTracingLogPacksFlow(): Flow> { + return store.data.map { prefs -> + prefs[traceLogPacksKey] + ?.split(",") + ?.mapNotNull { value -> TraceLogPack.entries.find { it.key == value } } + ?.toSet() + ?: emptySet() + } + } + + override suspend fun reset() { + store.edit { it.clear() } + } +} + +private fun BuildMeta.defaultLogLevel(): LogLevel { + return when (buildType) { + BuildType.DEBUG -> LogLevel.TRACE + BuildType.NIGHTLY -> LogLevel.DEBUG + BuildType.RELEASE -> LogLevel.INFO + } +} diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultPreferencesDataStoreFactory.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultPreferencesDataStoreFactory.kt new file mode 100644 index 0000000..267961c --- /dev/null +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultPreferencesDataStoreFactory.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.preferences.impl.store + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.androidutils.preferences.DefaultPreferencesCorruptionHandlerFactory +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import java.util.concurrent.ConcurrentHashMap + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultPreferencesDataStoreFactory( + @ApplicationContext private val context: Context, +) : PreferenceDataStoreFactory { + private val dataStoreHolders = ConcurrentHashMap() + + private class DataStoreHolder(name: String) { + val Context.dataStore: DataStore by preferencesDataStore( + name = name, + corruptionHandler = DefaultPreferencesCorruptionHandlerFactory.replaceWithEmpty(), + ) + } + + override fun create(name: String): DataStore { + val holder = dataStoreHolders.getOrPut(name) { + DataStoreHolder(name) + } + return with(holder) { + context.dataStore + } + } +} diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultSessionPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultSessionPreferencesStore.kt new file mode 100644 index 0000000..907d454 --- /dev/null +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultSessionPreferencesStore.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.preferences.impl.store + +import android.content.Context +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +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.preferencesDataStoreFile +import io.element.android.libraries.androidutils.file.safeDelete +import io.element.android.libraries.androidutils.hash.hash +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.io.File + +class DefaultSessionPreferencesStore( + context: Context, + sessionId: SessionId, + @SessionCoroutineScope sessionCoroutineScope: CoroutineScope, +) : SessionPreferencesStore { + companion object { + fun storeFile(context: Context, sessionId: SessionId): File { + val hashedUserId = sessionId.value.hash().take(16) + return context.preferencesDataStoreFile("session_${hashedUserId}_preferences") + } + } + + private val sharePresenceKey = booleanPreferencesKey("sharePresence") + private val sendPublicReadReceiptsKey = booleanPreferencesKey("sendPublicReadReceipts") + private val renderReadReceiptsKey = booleanPreferencesKey("renderReadReceipts") + private val sendTypingNotificationsKey = booleanPreferencesKey("sendTypingNotifications") + private val renderTypingNotificationsKey = booleanPreferencesKey("renderTypingNotifications") + private val skipSessionVerification = booleanPreferencesKey("skipSessionVerification") + private val compressImages = booleanPreferencesKey("compressMedia") + private val compressMediaPreset = stringPreferencesKey("compressMediaPreset") + + private val dataStoreFile = storeFile(context, sessionId) + private val store = PreferenceDataStoreFactory.create( + scope = sessionCoroutineScope, + migrations = listOf( + SessionPreferencesStoreMigration( + sharePresenceKey, + sendPublicReadReceiptsKey, + ) + ), + ) { dataStoreFile } + + override suspend fun setSharePresence(enabled: Boolean) { + update(sharePresenceKey, enabled) + // Also update all the other settings + setSendPublicReadReceipts(enabled) + setRenderReadReceipts(enabled) + setSendTypingNotifications(enabled) + setRenderTypingNotifications(enabled) + } + + override fun isSharePresenceEnabled(): Flow { + return get(sharePresenceKey) { true } + } + + override suspend fun setSendPublicReadReceipts(enabled: Boolean) = update(sendPublicReadReceiptsKey, enabled) + override fun isSendPublicReadReceiptsEnabled(): Flow = get(sendPublicReadReceiptsKey) { true } + + override suspend fun setRenderReadReceipts(enabled: Boolean) = update(renderReadReceiptsKey, enabled) + override fun isRenderReadReceiptsEnabled(): Flow = get(renderReadReceiptsKey) { true } + + override suspend fun setSendTypingNotifications(enabled: Boolean) = update(sendTypingNotificationsKey, enabled) + override fun isSendTypingNotificationsEnabled(): Flow = get(sendTypingNotificationsKey) { true } + + override suspend fun setRenderTypingNotifications(enabled: Boolean) = update(renderTypingNotificationsKey, enabled) + override fun isRenderTypingNotificationsEnabled(): Flow = get(renderTypingNotificationsKey) { true } + + override suspend fun setSkipSessionVerification(skip: Boolean) = update(skipSessionVerification, skip) + override fun isSessionVerificationSkipped(): Flow = get(skipSessionVerification) { false } + + override suspend fun setOptimizeImages(compress: Boolean) = update(compressImages, compress) + override fun doesOptimizeImages(): Flow = get(compressImages) { true } + + override suspend fun setVideoCompressionPreset(preset: VideoCompressionPreset) = update(compressMediaPreset, preset.name) + override fun getVideoCompressionPreset(): Flow = get(compressMediaPreset) { VideoCompressionPreset.STANDARD.name } + .map { tryOrNull { VideoCompressionPreset.valueOf(it) } ?: VideoCompressionPreset.STANDARD } + + override suspend fun clear() { + dataStoreFile.safeDelete() + } + + private suspend fun update(key: Preferences.Key, value: T) { + store.edit { prefs -> prefs[key] = value } + } + + private fun get(key: Preferences.Key, default: () -> T): Flow { + return store.data.map { prefs -> prefs[key] ?: default() } + } +} diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultSessionPreferencesStoreFactory.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultSessionPreferencesStoreFactory.kt new file mode 100644 index 0000000..ce76bd9 --- /dev/null +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultSessionPreferencesStoreFactory.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.preferences.impl.store + +import android.content.Context +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory +import io.element.android.libraries.sessionstorage.api.observer.SessionListener +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import kotlinx.coroutines.CoroutineScope +import java.util.concurrent.ConcurrentHashMap + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultSessionPreferencesStoreFactory( + @ApplicationContext private val context: Context, + sessionObserver: SessionObserver, +) : SessionPreferencesStoreFactory { + private val cache = ConcurrentHashMap() + + init { + sessionObserver.addListener(object : SessionListener { + override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) { + val sessionPreferences = cache.remove(SessionId(userId)) + sessionPreferences?.clear() + } + }) + } + + override fun get(sessionId: SessionId, sessionCoroutineScope: CoroutineScope): SessionPreferencesStore = cache.getOrPut(sessionId) { + DefaultSessionPreferencesStore(context, sessionId, sessionCoroutineScope) + } + + override fun remove(sessionId: SessionId) { + cache.remove(sessionId) + } +} diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/SessionPreferencesModule.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/SessionPreferencesModule.kt new file mode 100644 index 0000000..43de0b5 --- /dev/null +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/SessionPreferencesModule.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.preferences.impl.store + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import kotlinx.coroutines.CoroutineScope + +@BindingContainer +@ContributesTo(SessionScope::class) +object SessionPreferencesModule { + @Provides + fun providesSessionPreferencesStore( + defaultSessionPreferencesStoreFactory: DefaultSessionPreferencesStoreFactory, + sessionId: SessionId, + @SessionCoroutineScope sessionCoroutineScope: CoroutineScope, + ): SessionPreferencesStore { + return defaultSessionPreferencesStoreFactory + .get(sessionId, sessionCoroutineScope) + } +} diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/SessionPreferencesStoreMigration.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/SessionPreferencesStoreMigration.kt new file mode 100644 index 0000000..a19107d --- /dev/null +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/SessionPreferencesStoreMigration.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.preferences.impl.store + +import androidx.datastore.core.DataMigration +import androidx.datastore.preferences.core.Preferences + +class SessionPreferencesStoreMigration( + private val sharePresenceKey: Preferences.Key, + private val sendPublicReadReceiptsKey: Preferences.Key, +) : DataMigration { + override suspend fun cleanUp() = Unit + + override suspend fun shouldMigrate(currentData: Preferences): Boolean { + return currentData[sharePresenceKey] == null + } + + override suspend fun migrate(currentData: Preferences): Preferences { + // If sendPublicReadReceiptsKey was false, consider that sharing presence is false. + val defaultValue = currentData[sendPublicReadReceiptsKey] ?: true + return currentData.toMutablePreferences().apply { + set(sharePresenceKey, defaultValue) + }.toPreferences() + } +} diff --git a/libraries/preferences/test/build.gradle.kts b/libraries/preferences/test/build.gradle.kts new file mode 100644 index 0000000..d433490 --- /dev/null +++ b/libraries/preferences/test/build.gradle.kts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.preferences.test" +} + +dependencies { + api(projects.libraries.preferences.api) + implementation(projects.libraries.matrix.api) + implementation(projects.tests.testutils) + implementation(libs.coroutines.core) + implementation(libs.androidx.datastore.preferences) +} diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakePreferenceDataStoreFactory.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakePreferenceDataStoreFactory.kt new file mode 100644 index 0000000..dc9bbc2 --- /dev/null +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakePreferenceDataStoreFactory.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.preferences.test + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import java.io.File +import androidx.datastore.preferences.core.PreferenceDataStoreFactory as AndroidPreferenceDataStoreFactory + +class FakePreferenceDataStoreFactory : PreferenceDataStoreFactory { + override fun create(name: String): DataStore { + return AndroidPreferenceDataStoreFactory.create { File.createTempFile("test", ".preferences_pb") } + } +} diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferencesStoreFactory.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferencesStoreFactory.kt new file mode 100644 index 0000000..2fc3d66 --- /dev/null +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferencesStoreFactory.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.preferences.test + +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory +import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder +import io.element.android.tests.testutils.lambda.LambdaTwoParamsRecorder +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.CoroutineScope + +class FakeSessionPreferencesStoreFactory( + val getLambda: LambdaTwoParamsRecorder = lambdaRecorder { _, _ -> lambdaError() }, + val removeLambda: LambdaOneParamRecorder = lambdaRecorder { _ -> lambdaError() }, +) : SessionPreferencesStoreFactory { + override fun get(sessionId: SessionId, sessionCoroutineScope: CoroutineScope): SessionPreferencesStore { + return getLambda(sessionId, sessionCoroutineScope) + } + + override fun remove(sessionId: SessionId) { + removeLambda(sessionId) + } +} diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt new file mode 100644 index 0000000..6e7d22a --- /dev/null +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.preferences.test + +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.matrix.api.tracing.LogLevel +import io.element.android.libraries.matrix.api.tracing.TraceLogPack +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class InMemoryAppPreferencesStore( + isDeveloperModeEnabled: Boolean = false, + customElementCallBaseUrl: String? = null, + hideInviteAvatars: Boolean? = null, + timelineMediaPreviewValue: MediaPreviewValue? = null, + theme: String? = null, + logLevel: LogLevel = LogLevel.INFO, + traceLockPacks: Set = emptySet(), +) : AppPreferencesStore { + private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled) + private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl) + private val theme = MutableStateFlow(theme) + private val logLevel = MutableStateFlow(logLevel) + private val tracingLogPacks = MutableStateFlow(traceLockPacks) + private val hideInviteAvatars = MutableStateFlow(hideInviteAvatars) + private val timelineMediaPreviewValue = MutableStateFlow(timelineMediaPreviewValue) + + override suspend fun setDeveloperModeEnabled(enabled: Boolean) { + isDeveloperModeEnabled.value = enabled + } + + override fun isDeveloperModeEnabledFlow(): Flow { + return isDeveloperModeEnabled + } + + override suspend fun setCustomElementCallBaseUrl(string: String?) { + customElementCallBaseUrl.tryEmit(string) + } + + override fun getCustomElementCallBaseUrlFlow(): Flow { + return customElementCallBaseUrl + } + + override suspend fun setTheme(theme: String) { + this.theme.value = theme + } + + override fun getThemeFlow(): Flow { + return theme + } + + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") + override fun getHideInviteAvatarsFlow(): Flow { + return hideInviteAvatars + } + + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") + override fun getTimelineMediaPreviewValueFlow(): Flow { + return timelineMediaPreviewValue + } + + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") + override suspend fun setHideInviteAvatars(hide: Boolean?) { + hideInviteAvatars.value = hide + } + + @Deprecated("Use MediaPreviewService instead. Kept only for migration.") + override suspend fun setTimelineMediaPreviewValue(mediaPreviewValue: MediaPreviewValue?) { + timelineMediaPreviewValue.value = mediaPreviewValue + } + + override suspend fun setTracingLogLevel(logLevel: LogLevel) { + this.logLevel.value = logLevel + } + + override fun getTracingLogLevelFlow(): Flow { + return logLevel + } + + override suspend fun setTracingLogPacks(targets: Set) { + tracingLogPacks.value = targets + } + + override fun getTracingLogPacksFlow(): Flow> { + return tracingLogPacks + } + + override suspend fun reset() { + // No op + } +} diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemorySessionPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemorySessionPreferencesStore.kt new file mode 100644 index 0000000..7e2027d --- /dev/null +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemorySessionPreferencesStore.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.preferences.test + +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class InMemorySessionPreferencesStore( + isSharePresenceEnabled: Boolean = true, + isSendPublicReadReceiptsEnabled: Boolean = true, + isRenderReadReceiptsEnabled: Boolean = true, + isSendTypingNotificationsEnabled: Boolean = true, + isRenderTypingNotificationsEnabled: Boolean = true, + isSessionVerificationSkipped: Boolean = false, + doesCompressMedia: Boolean = true, + videoCompressionPreset: VideoCompressionPreset = VideoCompressionPreset.STANDARD, +) : SessionPreferencesStore { + private val isSharePresenceEnabled = MutableStateFlow(isSharePresenceEnabled) + private val isSendPublicReadReceiptsEnabled = MutableStateFlow(isSendPublicReadReceiptsEnabled) + private val isRenderReadReceiptsEnabled = MutableStateFlow(isRenderReadReceiptsEnabled) + private val isSendTypingNotificationsEnabled = MutableStateFlow(isSendTypingNotificationsEnabled) + private val isRenderTypingNotificationsEnabled = MutableStateFlow(isRenderTypingNotificationsEnabled) + private val isSessionVerificationSkipped = MutableStateFlow(isSessionVerificationSkipped) + private val doesCompressMedia = MutableStateFlow(doesCompressMedia) + private val videoCompressionPreset = MutableStateFlow(videoCompressionPreset) + var clearCallCount = 0 + private set + + override suspend fun setSharePresence(enabled: Boolean) { + isSharePresenceEnabled.tryEmit(enabled) + } + + override fun isSharePresenceEnabled(): Flow = isSharePresenceEnabled + + override suspend fun setSendPublicReadReceipts(enabled: Boolean) { + isSendPublicReadReceiptsEnabled.tryEmit(enabled) + } + + override fun isSendPublicReadReceiptsEnabled(): Flow = isSendPublicReadReceiptsEnabled + + override suspend fun setRenderReadReceipts(enabled: Boolean) { + isRenderReadReceiptsEnabled.tryEmit(enabled) + } + + override fun isRenderReadReceiptsEnabled(): Flow = isRenderReadReceiptsEnabled + + override suspend fun setSendTypingNotifications(enabled: Boolean) { + isSendTypingNotificationsEnabled.tryEmit(enabled) + } + + override fun isSendTypingNotificationsEnabled(): Flow = isSendTypingNotificationsEnabled + + override suspend fun setRenderTypingNotifications(enabled: Boolean) { + isRenderTypingNotificationsEnabled.tryEmit(enabled) + } + + override fun isRenderTypingNotificationsEnabled(): Flow = isRenderTypingNotificationsEnabled + + override suspend fun setSkipSessionVerification(skip: Boolean) { + isSessionVerificationSkipped.tryEmit(skip) + } + + override fun isSessionVerificationSkipped(): Flow { + return isSessionVerificationSkipped + } + + override suspend fun setOptimizeImages(compress: Boolean) = doesCompressMedia.emit(compress) + + override fun doesOptimizeImages(): Flow = doesCompressMedia + + override suspend fun setVideoCompressionPreset(preset: VideoCompressionPreset) { + videoCompressionPreset.value = preset + } + + override fun getVideoCompressionPreset(): Flow { + return videoCompressionPreset + } + + override suspend fun clear() { + clearCallCount++ + isSendPublicReadReceiptsEnabled.tryEmit(true) + } +} diff --git a/libraries/previewutils/build.gradle.kts b/libraries/previewutils/build.gradle.kts new file mode 100644 index 0000000..3fca91a --- /dev/null +++ b/libraries/previewutils/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.previewutils" +} + +dependencies { + implementation(projects.libraries.designsystem) + implementation(projects.libraries.matrix.api) + + implementation(libs.kotlinx.collections.immutable) +} diff --git a/libraries/previewutils/src/main/kotlin/io/element/android/libraries/previewutils/room/RoomMemberFixture.kt b/libraries/previewutils/src/main/kotlin/io/element/android/libraries/previewutils/room/RoomMemberFixture.kt new file mode 100644 index 0000000..1b73cce --- /dev/null +++ b/libraries/previewutils/src/main/kotlin/io/element/android/libraries/previewutils/room/RoomMemberFixture.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.previewutils.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 kotlinx.collections.immutable.persistentListOf + +fun aRoomMember( + userId: UserId = UserId("@alice:server.org"), + displayName: String? = null, + avatarUrl: String? = null, + membership: RoomMembershipState = RoomMembershipState.JOIN, + isNameAmbiguous: Boolean = false, + powerLevel: Long = 0L, + isIgnored: Boolean = false, + role: RoomMember.Role = RoomMember.Role.User, + membershipChangeReason: String? = null, +) = RoomMember( + userId = userId, + displayName = displayName, + avatarUrl = avatarUrl, + membership = membership, + isNameAmbiguous = isNameAmbiguous, + powerLevel = powerLevel, + isIgnored = isIgnored, + role = role, + membershipChangeReason = membershipChangeReason, +) + +fun aRoomMemberList() = persistentListOf( + 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", role = RoomMember.Role.Admin) +fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob", role = RoomMember.Role.Moderator) + +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/libraries/previewutils/src/main/kotlin/io/element/android/libraries/previewutils/room/SpaceRoomFixture.kt b/libraries/previewutils/src/main/kotlin/io/element/android/libraries/previewutils/room/SpaceRoomFixture.kt new file mode 100644 index 0000000..4bf1d25 --- /dev/null +++ b/libraries/previewutils/src/main/kotlin/io/element/android/libraries/previewutils/room/SpaceRoomFixture.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.previewutils.room + +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomType +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.toImmutableList + +fun aSpaceRoom( + rawName: String? = null, + displayName: String = "Space name", + avatarUrl: String? = null, + canonicalAlias: RoomAlias? = null, + childrenCount: Int = 0, + guestCanJoin: Boolean = false, + heroes: List = emptyList(), + joinRule: JoinRule? = null, + numJoinedMembers: Int = 0, + roomId: RoomId = RoomId("!roomId:example.com"), + roomType: RoomType = RoomType.Space, + state: CurrentUserMembership? = null, + topic: String? = null, + worldReadable: Boolean = false, + isDirect: Boolean? = null, + via: List = emptyList(), +) = SpaceRoom( + rawName = rawName, + displayName = displayName, + avatarUrl = avatarUrl, + canonicalAlias = canonicalAlias, + childrenCount = childrenCount, + guestCanJoin = guestCanJoin, + heroes = heroes.toImmutableList(), + joinRule = joinRule, + numJoinedMembers = numJoinedMembers, + roomId = roomId, + roomType = roomType, + state = state, + topic = topic, + worldReadable = worldReadable, + via = via.toImmutableList(), + isDirect = isDirect +) diff --git a/libraries/push/api/build.gradle.kts b/libraries/push/api/build.gradle.kts new file mode 100644 index 0000000..df6ac61 --- /dev/null +++ b/libraries/push/api/build.gradle.kts @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.push.api" +} + +dependencies { + implementation(libs.androidx.corektx) + implementation(libs.coroutines.core) + implementation(libs.coil.compose) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixmedia.api) + implementation(projects.libraries.pushproviders.api) +} diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/GetCurrentPushProvider.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/GetCurrentPushProvider.kt new file mode 100644 index 0000000..a6fbb47 --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/GetCurrentPushProvider.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.api + +import io.element.android.libraries.matrix.api.core.SessionId + +interface GetCurrentPushProvider { + suspend fun getCurrentPushProvider(sessionId: SessionId): String? +} 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 0000000..a43ee36 --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.api + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.push.api.history.PushHistoryItem +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider +import kotlinx.coroutines.flow.Flow + +interface PushService { + /** + * Return the current push provider, or null if none. + */ + suspend fun getCurrentPushProvider(sessionId: SessionId): PushProvider? + + /** + * Return the list of push providers, available at compile time, sorted by index. + */ + fun getAvailablePushProviders(): List + + /** + * 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, + ): Result + + /** + * Ensure that the pusher with the current push provider and distributor is registered. + * If there is no current config, the default push provider with the default distributor will be used. + * Error can be [PusherRegistrationFailure]. + */ + suspend fun ensurePusherIsRegistered( + matrixClient: MatrixClient, + ): Result + + /** + * Store the given push provider as the current one, but do not register. + * To be used when there is no distributor available. + */ + suspend fun selectPushProvider( + sessionId: SessionId, + pushProvider: PushProvider, + ) + + fun ignoreRegistrationError(sessionId: SessionId): Flow + suspend fun setIgnoreRegistrationError(sessionId: SessionId, ignore: Boolean) + + /** + * Return false in case of early error. + */ + suspend fun testPush(sessionId: SessionId): Boolean + + /** + * Get a flow of total number of received Push. + */ + val pushCounter: Flow + + /** + * Get a flow of list of [PushHistoryItem]. + */ + fun getPushHistoryItemsFlow(): Flow> + + /** + * Reset the push history, including the push counter. + */ + suspend fun resetPushHistory() + + /** + * Reset the battery optimization state. + */ + suspend fun resetBatteryOptimizationState() + + /** + * Notify the user that the service is un-registered. + */ + suspend fun onServiceUnregistered(userId: UserId) +} diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PusherRegistrationFailure.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PusherRegistrationFailure.kt new file mode 100644 index 0000000..b8ae677 --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PusherRegistrationFailure.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.api + +import io.element.android.libraries.matrix.api.exception.ClientException + +sealed class PusherRegistrationFailure : Exception() { + class AccountNotVerified : PusherRegistrationFailure() + class NoProvidersAvailable : PusherRegistrationFailure() + class NoDistributorsAvailable : PusherRegistrationFailure() + + /** + * @param clientException the failure that occurred. + * @param isRegisteringAgain true if the server should already have a the same pusher registered. + */ + class RegistrationFailure( + val clientException: ClientException, + val isRegisteringAgain: Boolean, + ) : PusherRegistrationFailure() +} diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationEvents.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationEvents.kt new file mode 100644 index 0000000..b7a01cc --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.api.battery + +sealed interface BatteryOptimizationEvents { + data object Dismiss : BatteryOptimizationEvents + data object RequestDisableOptimizations : BatteryOptimizationEvents +} diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationState.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationState.kt new file mode 100644 index 0000000..6732f7c --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationState.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.api.battery + +data class BatteryOptimizationState( + val shouldDisplayBanner: Boolean, + val eventSink: (BatteryOptimizationEvents) -> Unit, +) diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationStateProvider.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationStateProvider.kt new file mode 100644 index 0000000..684185f --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/battery/BatteryOptimizationStateProvider.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.api.battery + +fun aBatteryOptimizationState( + shouldDisplayBanner: Boolean = false, + eventSink: (BatteryOptimizationEvents) -> Unit = {}, +) = BatteryOptimizationState( + shouldDisplayBanner = shouldDisplayBanner, + eventSink = eventSink, +) 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 0000000..975e10e --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/gateway/PushGatewayFailure.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.api.gateway + +sealed class PushGatewayFailure : Exception() { + class PusherRejected : PushGatewayFailure() +} diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/history/PushHistoryItem.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/history/PushHistoryItem.kt new file mode 100644 index 0000000..e3a8a49 --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/history/PushHistoryItem.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.api.history + +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 representing a push history item. + * @property pushDate Date (timestamp). + * @property formattedDate Formatted date. + * @property providerInfo Push provider name / info + * @property eventId EventId from the push, can be null if the received data are not correct. + * @property roomId RoomId from the push, can be null if the received data are not correct. + * @property sessionId The session Id, can be null if the session cannot be retrieved + * @property hasBeenResolved Result of resolving the event + * @property comment Comment. Can contains an error message if the event could not be resolved, or other any information. + */ +data class PushHistoryItem( + val pushDate: Long, + val formattedDate: String, + val providerInfo: String, + val eventId: EventId?, + val roomId: RoomId?, + val sessionId: SessionId?, + val hasBeenResolved: Boolean, + val comment: String?, +) diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationBitmapLoader.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationBitmapLoader.kt new file mode 100644 index 0000000..bded699 --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationBitmapLoader.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.api.notifications + +import android.graphics.Bitmap +import androidx.core.graphics.drawable.IconCompat +import coil3.ImageLoader +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL + +interface NotificationBitmapLoader { + /** + * Get icon of a room. + * @param avatarData the data related to the Avatar + * @param imageLoader Coil image loader + * @param targetSize The size we want the bitmap to be resized to + */ + suspend fun getRoomBitmap( + avatarData: AvatarData, + imageLoader: ImageLoader, + targetSize: Long = AVATAR_THUMBNAIL_SIZE_IN_PIXEL, + ): Bitmap? + + /** + * Get icon of a user. + * Before Android P, this does nothing because the icon won't be used + * @param avatarData the data related to the Avatar + * @param imageLoader Coil image loader + */ + suspend fun getUserIcon( + avatarData: AvatarData, + imageLoader: ImageLoader, + ): IconCompat? +} diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationCleaner.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationCleaner.kt new file mode 100644 index 0000000..0a4e35c --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationCleaner.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.api.notifications + +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 + +interface NotificationCleaner { + fun clearAllMessagesEvents(sessionId: SessionId) + fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) + fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) + fun clearEvent(sessionId: SessionId, eventId: EventId) + + 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/notifications/NotificationIdProvider.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationIdProvider.kt new file mode 100644 index 0000000..ff7119b --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationIdProvider.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.api.notifications + +import io.element.android.libraries.matrix.api.core.SessionId +import kotlin.math.abs + +object NotificationIdProvider { + 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 + } + + fun getForegroundServiceNotificationId(type: ForegroundServiceType): Int { + return type.ordinal * 10 + FOREGROUND_SERVICE_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 + } + + 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 + + private const val FOREGROUND_SERVICE_NOTIFICATION_ID = 4 +} + +enum class ForegroundServiceType { + INCOMING_CALL, + ONGOING_CALL, +} diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/OnMissedCallNotificationHandler.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/OnMissedCallNotificationHandler.kt new file mode 100644 index 0000000..2ba6192 --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/OnMissedCallNotificationHandler.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.api.notifications + +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 + +/** + * Handles missed calls by creating a new notification. + */ +interface OnMissedCallNotificationHandler { + /** + * Adds a missed call notification. + */ + suspend fun addMissedCallNotification( + sessionId: SessionId, + roomId: RoomId, + eventId: EventId, + ) +} diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/conversations/NotificationConversationService.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/conversations/NotificationConversationService.kt new file mode 100644 index 0000000..504adac --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/conversations/NotificationConversationService.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.api.notifications.conversations + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId + +/** + * Service to handle conversation-related notifications. + */ +interface NotificationConversationService { + /** + * Called when a new message is received in a room. + * It should create a new conversation shortcut for this room. + */ + suspend fun onSendMessage( + sessionId: SessionId, + roomId: RoomId, + roomName: String, + roomIsDirect: Boolean, + roomAvatarUrl: String?, + ) + + /** + * Called when a room is left. + * It should remove the conversation shortcut for this room. + */ + suspend fun onLeftRoom(sessionId: SessionId, roomId: RoomId) + + /** + * Called when the list of available rooms changes. + * It should update the conversation shortcuts accordingly, removing shortcuts for no longer joined rooms. + */ + suspend fun onAvailableRoomsChanged(sessionId: SessionId, roomIds: Set) +} diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/NotificationEventRequest.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/NotificationEventRequest.kt new file mode 100644 index 0000000..ff38c7a --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/NotificationEventRequest.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.api.push + +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 NotificationEventRequest( + val sessionId: SessionId, + val roomId: RoomId, + val eventId: EventId, + val providerInfo: String, +) diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/SyncOnNotifiableEvent.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/SyncOnNotifiableEvent.kt new file mode 100644 index 0000000..bc7bf44 --- /dev/null +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/SyncOnNotifiableEvent.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.api.push + +fun interface SyncOnNotifiableEvent { + suspend operator fun invoke(requests: List) +} diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts new file mode 100644 index 0000000..68d32db --- /dev/null +++ b/libraries/push/impl/build.gradle.kts @@ -0,0 +1,101 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.sqldelight) +} + +android { + namespace = "io.element.android.libraries.push.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(libs.androidx.corektx) + implementation(libs.androidx.datastore.preferences) + implementation(platform(libs.network.retrofit.bom)) + implementation(libs.network.retrofit) + implementation(libs.serialization.json) + implementation(libs.coil) + + implementation(libs.sqldelight.driver.android) + implementation(libs.sqlcipher) + implementation(libs.sqlite) + implementation(libs.sqldelight.coroutines) + implementation(projects.libraries.encryptedDb) + + implementation(projects.appconfig) + implementation(projects.libraries.architecture) + implementation(projects.libraries.core) + implementation(projects.libraries.dateformatter.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.di) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.network) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.matrixmedia.api) + implementation(projects.features.networkmonitor.api) + implementation(projects.libraries.preferences.api) + implementation(projects.libraries.sessionStorage.api) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.troubleshoot.api) + implementation(projects.libraries.workmanager.api) + implementation(projects.features.call.api) + implementation(projects.features.enterprise.api) + implementation(projects.features.lockscreen.api) + implementation(projects.libraries.featureflag.api) + 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) + + testCommonDependencies(libs) + testImplementation(libs.coil.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.matrixmedia.test) + testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.sessionStorage.test) + testImplementation(projects.libraries.push.test) + testImplementation(projects.libraries.pushproviders.test) + testImplementation(projects.libraries.pushstore.test) + testImplementation(projects.libraries.troubleshoot.test) + testImplementation(projects.libraries.workmanager.test) + testImplementation(projects.features.call.test) + testImplementation(projects.features.enterprise.test) + testImplementation(projects.features.lockscreen.test) + testImplementation(projects.features.networkmonitor.test) + testImplementation(projects.services.appnavstate.impl) + testImplementation(projects.services.appnavstate.test) + testImplementation(projects.services.toolbox.impl) + testImplementation(projects.services.toolbox.test) + testImplementation(projects.libraries.featureflag.test) + testImplementation(libs.kotlinx.collections.immutable) +} + +sqldelight { + databases { + create("PushDatabase") { + schemaOutputDirectory = File("src/main/sqldelight/databases") + } + } +} diff --git a/libraries/push/impl/src/debug/res/raw/message.mp3 b/libraries/push/impl/src/debug/res/raw/message.mp3 new file mode 100644 index 0000000..abc0567 Binary files /dev/null and b/libraries/push/impl/src/debug/res/raw/message.mp3 differ diff --git a/libraries/push/impl/src/main/AndroidManifest.xml b/libraries/push/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a15bb34 --- /dev/null +++ b/libraries/push/impl/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultGetCurrentPushProvider.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultGetCurrentPushProvider.kt new file mode 100644 index 0000000..a3e6cf2 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultGetCurrentPushProvider.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.api.GetCurrentPushProvider +import io.element.android.libraries.pushstore.api.UserPushStoreFactory + +@ContributesBinding(AppScope::class) +class DefaultGetCurrentPushProvider( + private val pushStoreFactory: UserPushStoreFactory, +) : GetCurrentPushProvider { + override suspend fun getCurrentPushProvider(sessionId: SessionId): String? { + return pushStoreFactory.getOrCreate(sessionId).getPushProviderName() + } +} 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 0000000..3f3a8d0 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import dev.zacsweers.metro.binding +import io.element.android.libraries.matrix.api.MatrixClient +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.verification.SessionVerifiedStatus +import io.element.android.libraries.push.api.GetCurrentPushProvider +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.api.PusherRegistrationFailure +import io.element.android.libraries.push.api.history.PushHistoryItem +import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore +import io.element.android.libraries.push.impl.store.PushDataStore +import io.element.android.libraries.push.impl.test.TestPush +import io.element.android.libraries.push.impl.unregistration.ServiceUnregisteredHandler +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.libraries.pushproviders.api.RegistrationFailure +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretStore +import io.element.android.libraries.sessionstorage.api.observer.SessionListener +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import timber.log.Timber + +@ContributesBinding(AppScope::class, binding = binding()) +@SingleIn(AppScope::class) +class DefaultPushService( + private val testPush: TestPush, + private val userPushStoreFactory: UserPushStoreFactory, + private val pushProviders: Set<@JvmSuppressWildcards PushProvider>, + private val getCurrentPushProvider: GetCurrentPushProvider, + private val sessionObserver: SessionObserver, + private val pushClientSecretStore: PushClientSecretStore, + private val pushDataStore: PushDataStore, + private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore, + private val serviceUnregisteredHandler: ServiceUnregisteredHandler, +) : PushService, SessionListener { + init { + observeSessions() + } + + override suspend fun getCurrentPushProvider(sessionId: SessionId): PushProvider? { + val currentPushProvider = getCurrentPushProvider.getCurrentPushProvider(sessionId) + return pushProviders.find { it.name == currentPushProvider } + } + + override fun getAvailablePushProviders(): List { + return pushProviders + .sortedBy { it.index } + } + + override suspend fun registerWith( + matrixClient: MatrixClient, + pushProvider: PushProvider, + distributor: Distributor, + ): Result { + Timber.d("Registering with ${pushProvider.name}/${distributor.name}") + val userPushStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId) + val currentPushProviderName = userPushStore.getPushProviderName() + val currentPushProvider = pushProviders.find { it.name == currentPushProviderName } + val currentDistributorValue = currentPushProvider?.getCurrentDistributor(matrixClient.sessionId)?.value + if (currentPushProviderName != pushProvider.name || currentDistributorValue != distributor.value) { + // Unregister previous one if any + currentPushProvider + ?.also { Timber.d("Unregistering previous push provider $currentPushProviderName/$currentDistributorValue") } + ?.unregister(matrixClient) + ?.onFailure { + Timber.w(it, "Failed to unregister previous push provider") + return Result.failure(it) + } + } + // Store new value + userPushStore.setPushProviderName(pushProvider.name) + // Then try to register + return pushProvider.registerWith(matrixClient, distributor) + } + + override suspend fun ensurePusherIsRegistered(matrixClient: MatrixClient): Result { + val verificationStatus = matrixClient.sessionVerificationService.sessionVerifiedStatus.first() + if (verificationStatus != SessionVerifiedStatus.Verified) { + return Result.failure(PusherRegistrationFailure.AccountNotVerified()) + .also { Timber.w("Account is not verified") } + } + Timber.d("Ensure pusher is registered") + val currentPushProvider = getCurrentPushProvider(matrixClient.sessionId) + val result = if (currentPushProvider == null) { + Timber.d("Register with the first available push provider with at least one distributor") + val pushProvider = getAvailablePushProviders() + .firstOrNull { it.getDistributors().isNotEmpty() } + // Else fallback to the first available push provider (the list should never be empty) + ?: getAvailablePushProviders().firstOrNull() + ?: return Result.failure(PusherRegistrationFailure.NoProvidersAvailable()) + .also { Timber.w("No push providers available") } + val distributor = pushProvider.getDistributors().firstOrNull() + ?: return Result.failure(PusherRegistrationFailure.NoDistributorsAvailable()) + .also { Timber.w("No distributors available") } + .also { + // In this case, consider the push provider is chosen. + selectPushProvider(matrixClient.sessionId, pushProvider) + } + registerWith(matrixClient, pushProvider, distributor) + } else { + val currentPushDistributor = currentPushProvider.getCurrentDistributor(matrixClient.sessionId) + if (currentPushDistributor == null) { + Timber.d("Register with the first available distributor") + val distributor = currentPushProvider.getDistributors().firstOrNull() + ?: return Result.failure(PusherRegistrationFailure.NoDistributorsAvailable()) + .also { Timber.w("No distributors available") } + registerWith(matrixClient, currentPushProvider, distributor) + } else { + Timber.d("Re-register with the current distributor") + registerWith(matrixClient, currentPushProvider, currentPushDistributor) + } + } + return result.fold( + onSuccess = { + Timber.d("Pusher registered") + Result.success(Unit) + }, + onFailure = { + Timber.e(it, "Failed to register pusher") + if (it is RegistrationFailure) { + Result.failure(PusherRegistrationFailure.RegistrationFailure(it.clientException, it.isRegisteringAgain)) + } else { + Result.failure(it) + } + } + ) + } + + override suspend fun selectPushProvider( + sessionId: SessionId, + pushProvider: PushProvider, + ) { + Timber.d("Select ${pushProvider.name}") + val userPushStore = userPushStoreFactory.getOrCreate(sessionId) + userPushStore.setPushProviderName(pushProvider.name) + } + + override fun ignoreRegistrationError(sessionId: SessionId): Flow { + return userPushStoreFactory.getOrCreate(sessionId).ignoreRegistrationError() + } + + override suspend fun setIgnoreRegistrationError(sessionId: SessionId, ignore: Boolean) { + userPushStoreFactory.getOrCreate(sessionId).setIgnoreRegistrationError(ignore) + } + + override suspend fun testPush(sessionId: SessionId): Boolean { + val pushProvider = getCurrentPushProvider(sessionId) ?: return false + val config = pushProvider.getPushConfig(sessionId) ?: return false + testPush.execute(config) + return true + } + + private fun observeSessions() { + sessionObserver.addListener(this) + } + + /** + * The session has been deleted. + * In this case, this is not necessary to unregister the pusher from the homeserver, + * but we need to do some cleanup locally. + * The current push provider may want to take action, and we need to + * cleanup the stores. + */ + override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) { + val sessionId = SessionId(userId) + val userPushStore = userPushStoreFactory.getOrCreate(sessionId) + val currentPushProviderName = userPushStore.getPushProviderName() + val currentPushProvider = pushProviders.find { it.name == currentPushProviderName } + // Cleanup the current push provider. They may need the client secret, so delete the secret after. + currentPushProvider?.onSessionDeleted(sessionId) + // Now we can safely reset the stores. + pushClientSecretStore.resetSecret(sessionId) + userPushStore.reset() + } + + override val pushCounter: Flow = pushDataStore.pushCounterFlow + + override fun getPushHistoryItemsFlow(): Flow> { + return pushDataStore.getPushHistoryItemsFlow() + } + + override suspend fun resetPushHistory() { + pushDataStore.reset() + } + + override suspend fun resetBatteryOptimizationState() { + mutableBatteryOptimizationStore.reset() + } + + override suspend fun onServiceUnregistered(userId: UserId) { + serviceUnregisteredHandler.handle(userId) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt new file mode 100644 index 0000000..39f9d3a --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.appconfig.PushConfig +import io.element.android.libraries.core.extensions.mapFailure +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.exception.ClientException +import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData +import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.libraries.pushproviders.api.RegistrationFailure +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import timber.log.Timber + +internal const val DEFAULT_PUSHER_FILE_TAG = "mobile" + +private val loggerTag = LoggerTag("DefaultPusherSubscriber", LoggerTag.PushLoggerTag) + +@ContributesBinding(AppScope::class) +class DefaultPusherSubscriber( + private val buildMeta: BuildMeta, + private val pushClientSecret: PushClientSecret, + private val userPushStoreFactory: UserPushStoreFactory, +) : PusherSubscriber { + /** + * Register a pusher to the server if not done yet. + */ + override suspend fun registerPusher( + matrixClient: MatrixClient, + pushKey: String, + gateway: String, + ): Result { + val userDataStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId) + val isRegisteringAgain = userDataStore.getCurrentRegisteredPushKey() == pushKey + if (isRegisteringAgain) { + Timber.tag(loggerTag.value) + .d("Unnecessary to register again the same pusher, but do it in case the pusher has been removed from the server") + } + return matrixClient.pushersService + .setHttpPusher( + createHttpPusher(pushKey, gateway, matrixClient.sessionId) + ) + .onSuccess { + userDataStore.setCurrentRegisteredPushKey(pushKey) + } + .mapFailure { throwable -> + Timber.tag(loggerTag.value).e(throwable, "Unable to register the pusher") + if (throwable is ClientException) { + // It should always be the case. + RegistrationFailure(throwable, isRegisteringAgain = isRegisteringAgain) + } else { + throwable + } + } + } + + private suspend fun createHttpPusher( + pushKey: String, + gateway: String, + userId: SessionId, + ): SetHttpPusherData = + SetHttpPusherData( + pushKey = pushKey, + appId = PushConfig.PUSHER_APP_ID, + // TODO + abs(activeSessionHolder.getActiveSession().myUserId.hashCode()) + profileTag = DEFAULT_PUSHER_FILE_TAG + "_", + // TODO localeProvider.current().language + lang = "en", + appDisplayName = buildMeta.applicationName, + // TODO getDeviceInfoUseCase.execute().displayName().orEmpty() + deviceDisplayName = "MyDevice", + url = gateway, + defaultPayload = createDefaultPayload(pushClientSecret.getSecretForUser(userId)) + ) + + /** + * Ex: {"cs":"sfvsdv"}. + */ + private fun createDefaultPayload(secretForUser: String): String { + return "{\"cs\":\"$secretForUser\"}" + } + + override suspend fun unregisterPusher( + matrixClient: MatrixClient, + pushKey: String, + gateway: String, + ): Result { + val userDataStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId) + return matrixClient.pushersService + .unsetHttpPusher( + unsetHttpPusherData = UnsetHttpPusherData( + pushKey = pushKey, + appId = PushConfig.PUSHER_APP_ID + ) + ) + .onSuccess { + userDataStore.setCurrentRegisteredPushKey(null) + } + .onFailure { throwable -> + Timber.tag(loggerTag.value).e(throwable, "Unable to unregister the pusher") + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimization.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimization.kt new file mode 100644 index 0000000..0912987 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimization.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.battery + +import android.annotation.SuppressLint +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.os.PowerManager +import android.provider.Settings +import androidx.core.content.getSystemService +import androidx.core.net.toUri +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher +import timber.log.Timber + +interface BatteryOptimization { + /** + * 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 isIgnoringBatteryOptimizations(): Boolean + + /** + * Request the user to disable battery optimizations for this app. + * This will open the system settings where the user can disable battery optimizations. + * See https://developer.android.com/training/monitoring-device-state/doze-standby#exemption-cases + * + * @return true if the intent was successfully started, false if the activity was not found + */ + fun requestDisablingBatteryOptimization(): Boolean +} + +@ContributesBinding(AppScope::class) +class AndroidBatteryOptimization( + @ApplicationContext + private val context: Context, + private val externalIntentLauncher: ExternalIntentLauncher, +) : BatteryOptimization { + override fun isIgnoringBatteryOptimizations(): Boolean { + return context.getSystemService() + ?.isIgnoringBatteryOptimizations(context.packageName) == true + } + + @SuppressLint("BatteryLife") + override fun requestDisablingBatteryOptimization(): Boolean { + val ignoreBatteryOptimizationsResult = launchAction(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, withData = true) + if (ignoreBatteryOptimizationsResult) { + return true + } + // Open settings as a fallback if the first attempt fails + return launchAction(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS, withData = false) + } + + private fun launchAction( + action: String, + withData: Boolean, + ): Boolean { + val intent = Intent() + intent.action = action + if (withData) { + intent.data = ("package:" + context.packageName).toUri() + } + return try { + externalIntentLauncher.launch(intent) + true + } catch (exception: ActivityNotFoundException) { + Timber.w(exception, "Cannot launch intent with action $action.") + false + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimizationPresenter.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimizationPresenter.kt new file mode 100644 index 0000000..e9af494 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimizationPresenter.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.battery + +import androidx.compose.runtime.Composable +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.lifecycle.compose.LifecycleResumeEffect +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.push.api.battery.BatteryOptimizationEvents +import io.element.android.libraries.push.api.battery.BatteryOptimizationState +import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore +import io.element.android.libraries.push.impl.store.PushDataStore +import kotlinx.coroutines.launch + +@Inject +class BatteryOptimizationPresenter( + private val pushDataStore: PushDataStore, + private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore, + private val batteryOptimization: BatteryOptimization, +) : Presenter { + @Composable + override fun present(): BatteryOptimizationState { + val coroutineScope = rememberCoroutineScope() + var isRequestSent by remember { mutableStateOf(false) } + var localShouldDisplayBanner by remember { mutableStateOf(true) } + val storeShouldDisplayBanner by pushDataStore.shouldDisplayBatteryOptimizationBannerFlow.collectAsState(initial = false) + var isSystemIgnoringBatteryOptimizations by remember { + mutableStateOf(batteryOptimization.isIgnoringBatteryOptimizations()) + } + + LifecycleResumeEffect(Unit) { + isSystemIgnoringBatteryOptimizations = batteryOptimization.isIgnoringBatteryOptimizations() + if (isRequestSent) { + localShouldDisplayBanner = false + } + onPauseOrDispose {} + } + + fun handleEvent(event: BatteryOptimizationEvents) { + when (event) { + BatteryOptimizationEvents.Dismiss -> coroutineScope.launch { + mutableBatteryOptimizationStore.onOptimizationBannerDismissed() + } + BatteryOptimizationEvents.RequestDisableOptimizations -> { + isRequestSent = true + if (batteryOptimization.requestDisablingBatteryOptimization().not()) { + // If not able to perform the request, ensure that we do not display the banner again + coroutineScope.launch { + mutableBatteryOptimizationStore.onOptimizationBannerDismissed() + } + } + } + } + } + + return BatteryOptimizationState( + shouldDisplayBanner = localShouldDisplayBanner && storeShouldDisplayBanner && !isSystemIgnoringBatteryOptimizations, + eventSink = ::handleEvent, + ) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushModule.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushModule.kt new file mode 100644 index 0000000..b7e8075 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushModule.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.di + +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.push.api.battery.BatteryOptimizationState +import io.element.android.libraries.push.impl.battery.BatteryOptimizationPresenter + +@BindingContainer +@ContributesTo(AppScope::class) +interface PushModule { + companion object { + @Provides + fun provideNotificationCompatManager(@ApplicationContext context: Context): NotificationManagerCompat { + return NotificationManagerCompat.from(context) + } + } + + @Binds + fun bindBatteryOptimizationPresenter(presenter: BatteryOptimizationPresenter): Presenter +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt new file mode 100644 index 0000000..2c8dc54 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.history + +import android.content.Context +import android.os.Build +import android.os.PowerManager +import androidx.core.content.getSystemService +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.annotations.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.push.impl.PushDatabase +import io.element.android.libraries.push.impl.db.PushHistory +import io.element.android.services.toolbox.api.systemclock.SystemClock + +@ContributesBinding(AppScope::class) +class DefaultPushHistoryService( + private val pushDatabase: PushDatabase, + private val systemClock: SystemClock, + @ApplicationContext context: Context, +) : PushHistoryService { + private val powerManager = context.getSystemService() + private val packageName = context.packageName + + override fun onPushReceived( + providerInfo: String, + eventId: EventId?, + roomId: RoomId?, + sessionId: SessionId?, + hasBeenResolved: Boolean, + includeDeviceState: Boolean, + comment: String?, + ) { + val finalComment = buildString { + append(comment.orEmpty()) + if (includeDeviceState && powerManager != null) { + // Add info about device state + append("\n") + append(" - Idle: ${powerManager.isDeviceIdleMode}\n") + append(" - Power Save Mode: ${powerManager.isPowerSaveMode}\n") + append(" - Ignoring Battery Optimizations: ${powerManager.isIgnoringBatteryOptimizations(packageName)}\n") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + append(" - Device Light Idle Mode: ${powerManager.isDeviceLightIdleMode}\n") + append(" - Low Power Standby Enabled: ${powerManager.isLowPowerStandbyEnabled}\n") + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + append(" - Exempt from Low Power Standby: ${powerManager.isExemptFromLowPowerStandby}\n") + } + } + }.takeIf { it.isNotEmpty() } + + pushDatabase.pushHistoryQueries.insertPushHistory( + PushHistory( + pushDate = systemClock.epochMillis(), + providerInfo = providerInfo, + eventId = eventId?.value, + roomId = roomId?.value, + sessionId = sessionId?.value, + hasBeenResolved = if (hasBeenResolved) 1 else 0, + comment = finalComment, + ) + ) + + // Keep only the last 1_000 events + pushDatabase.pushHistoryQueries.removeOldest(1_000) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt new file mode 100644 index 0000000..8096ad2 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.history + +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 PushHistoryService { + /** + * Create a new push history entry. + * Do not use directly, prefer using the extension functions. + */ + fun onPushReceived( + providerInfo: String, + eventId: EventId?, + roomId: RoomId?, + sessionId: SessionId?, + hasBeenResolved: Boolean, + includeDeviceState: Boolean, + comment: String?, + ) +} + +fun PushHistoryService.onInvalidPushReceived( + providerInfo: String, + data: String, +) = onPushReceived( + providerInfo = providerInfo, + eventId = null, + roomId = null, + sessionId = null, + hasBeenResolved = false, + includeDeviceState = false, + comment = "Invalid or ignored push data:\n$data", +) + +fun PushHistoryService.onUnableToRetrieveSession( + providerInfo: String, + eventId: EventId, + roomId: RoomId, + reason: String, +) = onPushReceived( + providerInfo = providerInfo, + eventId = eventId, + roomId = roomId, + sessionId = null, + hasBeenResolved = false, + includeDeviceState = true, + comment = "Unable to retrieve session: $reason", +) + +fun PushHistoryService.onUnableToResolveEvent( + providerInfo: String, + eventId: EventId, + roomId: RoomId, + sessionId: SessionId, + reason: String, +) = onPushReceived( + providerInfo = providerInfo, + eventId = eventId, + roomId = roomId, + sessionId = sessionId, + hasBeenResolved = false, + includeDeviceState = true, + comment = "Unable to resolve event: $reason", +) + +fun PushHistoryService.onSuccess( + providerInfo: String, + eventId: EventId, + roomId: RoomId, + sessionId: SessionId, + comment: String?, +) = onPushReceived( + providerInfo = providerInfo, + eventId = eventId, + roomId = roomId, + sessionId = sessionId, + hasBeenResolved = true, + includeDeviceState = false, + comment = buildString { + append("Success") + if (comment.isNullOrBlank().not()) { + append(" - $comment") + } + }, +) + +fun PushHistoryService.onDiagnosticPush( + providerInfo: String, +) = onPushReceived( + providerInfo = providerInfo, + eventId = null, + roomId = null, + sessionId = null, + hasBeenResolved = true, + includeDeviceState = false, + comment = "Diagnostic push", +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/di/PushHistoryModule.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/di/PushHistoryModule.kt new file mode 100644 index 0000000..6472883 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/di/PushHistoryModule.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.history.di + +import android.content.Context +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.push.impl.PushDatabase +import io.element.encrypteddb.SqlCipherDriverFactory +import io.element.encrypteddb.passphrase.RandomSecretPassphraseProvider + +@BindingContainer +@ContributesTo(AppScope::class) +object PushHistoryModule { + @Provides + @SingleIn(AppScope::class) + fun providePushDatabase( + @ApplicationContext context: Context, + ): PushDatabase { + val name = "push_database" + val secretFile = context.getDatabasePath("$name.key") + + // Make sure the parent directory of the key file exists, otherwise it will crash in older Android versions + val parentDir = secretFile.parentFile + if (parentDir != null && !parentDir.exists()) { + parentDir.mkdirs() + } + + val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile) + val driver = SqlCipherDriverFactory(passphraseProvider) + .create(PushDatabase.Schema, "$name.db", context) + return PushDatabase(driver) + } +} 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 0000000..82ee730 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.intent + +import android.content.Intent +import android.os.Bundle +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 + +interface IntentProvider { + /** + * Provide an intent to start the application on a room or thread. + */ + fun getViewRoomIntent( + sessionId: SessionId, + roomId: RoomId?, + threadId: ThreadId?, + eventId: EventId?, + extras: Bundle? = null, + ): Intent +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ActiveNotificationsProvider.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ActiveNotificationsProvider.kt new file mode 100644 index 0000000..4cc279b --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ActiveNotificationsProvider.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.service.notification.StatusBarNotification +import androidx.core.app.NotificationManagerCompat +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.runCatchingExceptions +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.api.notifications.NotificationIdProvider +import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +import timber.log.Timber + +interface ActiveNotificationsProvider { + /** + * Gets the displayed notifications for the combination of [sessionId], [roomId] and [threadId]. + */ + fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): List + + /** + * Gets all displayed notifications associated to [sessionId] and [roomId]. These will include all thread notifications as well. + */ + fun getAllMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List + fun getNotificationsForSession(sessionId: SessionId): List + fun getMembershipNotificationForSession(sessionId: SessionId): List + fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List + fun getSummaryNotification(sessionId: SessionId): StatusBarNotification? + fun count(sessionId: SessionId): Int +} + +@ContributesBinding(AppScope::class) +class DefaultActiveNotificationsProvider( + private val notificationManager: NotificationManagerCompat, +) : ActiveNotificationsProvider { + override fun getNotificationsForSession(sessionId: SessionId): List { + return runCatchingExceptions { notificationManager.activeNotifications } + .onFailure { + Timber.e(it, "Failed to get active notifications") + } + .getOrElse { emptyList() } + .filter { it.notification.group == sessionId.value } + } + + override fun getMembershipNotificationForSession(sessionId: SessionId): List { + val notificationId = NotificationIdProvider.getRoomInvitationNotificationId(sessionId) + return getNotificationsForSession(sessionId).filter { it.id == notificationId } + } + + override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): List { + val notificationId = NotificationIdProvider.getRoomMessagesNotificationId(sessionId) + val expectedTag = NotificationCreator.messageTag(roomId, threadId) + return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == expectedTag } + } + + override fun getAllMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List { + val notificationId = NotificationIdProvider.getRoomMessagesNotificationId(sessionId) + return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag.startsWith(roomId.value) } + } + + override fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List { + val notificationId = NotificationIdProvider.getRoomInvitationNotificationId(sessionId) + return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == roomId.value } + } + + override fun getSummaryNotification(sessionId: SessionId): StatusBarNotification? { + val summaryId = NotificationIdProvider.getSummaryNotificationId(sessionId) + return getNotificationsForSession(sessionId).find { it.id == summaryId } + } + + override fun count(sessionId: SessionId): Int { + return getNotificationsForSession(sessionId).size + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt new file mode 100644 index 0000000..444d0c7 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.exception.NotificationResolverException +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.notification.RtcNotificationType +import io.element.android.libraries.matrix.api.timeline.item.event.EventType +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent +import io.element.android.services.appnavstate.api.AppForegroundStateService +import io.element.android.services.toolbox.api.strings.StringProvider +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withTimeoutOrNull +import timber.log.Timber +import kotlin.time.Duration.Companion.seconds + +/** + * Helper to resolve a valid [NotifiableEvent] from a [NotificationData]. + */ +interface CallNotificationEventResolver { + /** + * Resolve a call notification event from a notification data depending on whether it should be a ringing one or not. + * @param sessionId the current session id + * @param notificationData the notification data + * @param forceNotify `true` to force the notification to be non-ringing, `false` to use the default behaviour. Default is `false`. + * @return a [NotifiableEvent] if the notification data is a call notification, null otherwise + */ + suspend fun resolveEvent( + sessionId: SessionId, + notificationData: NotificationData, + forceNotify: Boolean = false, + ): Result +} + +@ContributesBinding(AppScope::class) +class DefaultCallNotificationEventResolver( + private val stringProvider: StringProvider, + private val appForegroundStateService: AppForegroundStateService, + private val clientProvider: MatrixClientProvider, +) : CallNotificationEventResolver { + override suspend fun resolveEvent( + sessionId: SessionId, + notificationData: NotificationData, + forceNotify: Boolean + ): Result = runCatchingExceptions { + val content = notificationData.content as? NotificationContent.MessageLike.RtcNotification + ?: throw NotificationResolverException.UnknownError("content is not a call notify") + + val previousRingingCallStatus = appForegroundStateService.hasRingingCall.value + // We need the sync service working to get the updated room info + val isRoomCallActive = runCatchingExceptions { + if (content.type == RtcNotificationType.RING) { + appForegroundStateService.updateHasRingingCall(true) + + val client = clientProvider.getOrRestore( + sessionId + ).getOrNull() ?: throw NotificationResolverException.UnknownError("Session $sessionId not found") + val room = client.getRoom( + notificationData.roomId + ) ?: throw NotificationResolverException.UnknownError("Room ${notificationData.roomId} not found") + // Give a few seconds for the room info flow to catch up with the sync, if needed - this is usually instant + val isActive = withTimeoutOrNull(3.seconds) { room.roomInfoFlow.firstOrNull { it.hasRoomCall } }?.hasRoomCall ?: false + + // We no longer need the sync service to be active because of a call notification. + appForegroundStateService.updateHasRingingCall(previousRingingCallStatus) + + isActive + } else { + // If the call notification is not of ringing type, we don't need to check if the call is active + false + } + }.onFailure { + // Make sure to reset the hasRingingCall state in case of failure + appForegroundStateService.updateHasRingingCall(previousRingingCallStatus) + }.getOrDefault(false) + + notificationData.run { + if (content.type == RtcNotificationType.RING && isRoomCallActive && !forceNotify) { + NotifiableRingingCallEvent( + sessionId = sessionId, + roomId = roomId, + eventId = eventId, + roomName = roomDisplayName, + editedEventId = null, + canBeReplaced = true, + timestamp = this.timestamp, + isRedacted = false, + isUpdated = false, + description = stringProvider.getString(R.string.notification_incoming_call), + senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId), + roomAvatarUrl = roomAvatarUrl, + rtcNotificationType = content.type, + senderId = content.senderId, + senderAvatarUrl = senderAvatarUrl, + expirationTimestamp = content.expirationTimestampMillis, + ) + } else { + Timber.d("Event $eventId is call notify but should not ring: $isRoomCallActive, notify: ${content.type}") + // Create a simple message notification event + buildNotifiableMessageEvent( + sessionId = sessionId, + senderId = content.senderId, + roomId = roomId, + eventId = eventId, + noisy = true, + timestamp = this.timestamp, + senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId), + body = stringProvider.getString(R.string.notification_incoming_call), + roomName = roomDisplayName, + roomIsDm = isDm, + roomAvatarPath = roomAvatarUrl, + senderAvatarPath = senderAvatarUrl, + type = EventType.RTC_NOTIFICATION, + ) + } + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt new file mode 100644 index 0000000..3b39815 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt @@ -0,0 +1,451 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.content.Context +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.content.FileProvider +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.core.extensions.flatMap +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.MatrixClientProvider +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.core.UserId +import io.element.android.libraries.matrix.api.exception.NotificationResolverException +import io.element.android.libraries.matrix.api.media.MediaPreviewValue +import io.element.android.libraries.matrix.api.media.getMediaPreviewValue +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.permalink.PermalinkParser +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.EventType +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.OtherMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType +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.api.timeline.item.event.VoiceMessageType +import io.element.android.libraries.matrix.ui.messages.toPlainText +import io.element.android.libraries.push.api.push.NotificationEventRequest +import io.element.android.libraries.push.impl.R +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.ResolvedPushEvent +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.toolbox.api.strings.StringProvider +import timber.log.Timber + +private val loggerTag = LoggerTag("DefaultNotifiableEventResolver", LoggerTag.NotificationLoggerTag) + +/** + * Result of resolving a batch of push events. + * The outermost [Result] indicates whether the setup to resolve the events was successful. + * The results for each push notification will be a map of [NotificationEventRequest] to [Result] of [ResolvedPushEvent]. + * If the resolution of a specific event fails, the innermost [Result] will contain an exception. + */ +typealias ResolvePushEventsResult = Result>> + +/** + * 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. + */ +interface NotifiableEventResolver { + suspend fun resolveEvents( + sessionId: SessionId, + notificationEventRequests: List + ): ResolvePushEventsResult +} + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class DefaultNotifiableEventResolver( + private val stringProvider: StringProvider, + private val matrixClientProvider: MatrixClientProvider, + private val notificationMediaRepoFactory: NotificationMediaRepo.Factory, + @ApplicationContext private val context: Context, + private val permalinkParser: PermalinkParser, + private val callNotificationEventResolver: CallNotificationEventResolver, + private val fallbackNotificationFactory: FallbackNotificationFactory, + private val featureFlagService: FeatureFlagService, +) : NotifiableEventResolver { + override suspend fun resolveEvents( + sessionId: SessionId, + notificationEventRequests: List + ): ResolvePushEventsResult { + Timber.d("Queueing notifications: $notificationEventRequests") + val client = matrixClientProvider.getOrRestore(sessionId).getOrElse { + return Result.failure(IllegalStateException("Couldn't get or restore client for session $sessionId")) + } + val ids = notificationEventRequests.groupBy { it.roomId } + .mapValues { (_, requests) -> + requests.map { it.eventId } + } + + // TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event + val notificationsResult = client.notificationService.getNotifications(ids) + + if (notificationsResult.isFailure) { + val exception = notificationsResult.exceptionOrNull() + Timber.tag(loggerTag.value).e(exception, "Failed to get notifications for $ids") + return Result.failure(exception ?: NotificationResolverException.UnknownError("Unknown error while fetching notifications")) + } + + // The null check is done above + val notificationDataMap = notificationsResult.getOrNull()!!.mapValues { (_, notificationData) -> + notificationData.flatMap { data -> + data.asNotifiableEvent(client, sessionId) + } + } + + return Result.success( + notificationEventRequests.associate { request -> + val notificationDataResult = notificationDataMap[request.eventId] + if (notificationDataResult == null) { + request to Result.failure(NotificationResolverException.UnknownError("No notification data for ${request.roomId} - ${request.eventId}")) + } else { + request to notificationDataResult + } + } + ) + } + + private suspend fun NotificationData.asNotifiableEvent( + client: MatrixClient, + userId: SessionId, + ): Result = runCatchingExceptions { + when (val content = this.content) { + is NotificationContent.MessageLike.RoomMessage -> { + val showMediaPreview = client.mediaPreviewService.getMediaPreviewValue() == MediaPreviewValue.On + val senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId) + val imageMimeType = if (showMediaPreview) content.getImageMimetype() else null + val imageUriString = imageMimeType?.let { content.fetchImageIfPresent(client, imageMimeType)?.toString() } + val messageBody = descriptionFromMessageContent( + content = content, + senderDisambiguatedDisplayName = senderDisambiguatedDisplayName, + hasImageUri = imageUriString != null, + ) + val notifiableMessageEvent = buildNotifiableMessageEvent( + sessionId = userId, + senderId = content.senderId, + roomId = roomId, + eventId = eventId, + threadId = threadId.takeIf { featureFlagService.isFeatureEnabled(FeatureFlags.Threads) }, + noisy = isNoisy, + timestamp = this.timestamp, + senderDisambiguatedDisplayName = senderDisambiguatedDisplayName, + body = messageBody, + imageUriString = imageUriString, + imageMimeType = imageMimeType.takeIf { imageUriString != null }, + roomName = roomDisplayName, + roomIsDm = isDm, + roomAvatarPath = roomAvatarUrl, + senderAvatarPath = senderAvatarUrl, + hasMentionOrReply = hasMention, + ) + ResolvedPushEvent.Event(notifiableMessageEvent) + } + is NotificationContent.Invite -> { + val senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId) + val inviteNotifiableEvent = InviteNotifiableEvent( + sessionId = userId, + roomId = roomId, + eventId = eventId, + editedEventId = null, + canBeReplaced = true, + roomName = roomDisplayName, + noisy = isNoisy, + timestamp = this.timestamp, + soundName = null, + isRedacted = false, + isUpdated = false, + description = descriptionFromRoomMembershipInvite(senderDisambiguatedDisplayName, isDirect), + // TODO check if type is needed anymore + type = null, + // TODO check if title is needed anymore + title = null, + ) + ResolvedPushEvent.Event(inviteNotifiableEvent) + } + NotificationContent.MessageLike.CallAnswer, + NotificationContent.MessageLike.CallCandidates, + NotificationContent.MessageLike.CallHangup -> { + Timber.tag(loggerTag.value).d("Ignoring notification for call ${content.javaClass.simpleName}") + throw NotificationResolverException.EventFilteredOut + } + is NotificationContent.MessageLike.CallInvite -> { + val notifiableMessageEvent = buildNotifiableMessageEvent( + sessionId = userId, + senderId = content.senderId, + roomId = roomId, + eventId = eventId, + noisy = isNoisy, + timestamp = this.timestamp, + senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId), + body = stringProvider.getString(CommonStrings.common_unsupported_call), + roomName = roomDisplayName, + roomIsDm = isDm, + roomAvatarPath = roomAvatarUrl, + senderAvatarPath = senderAvatarUrl, + ) + ResolvedPushEvent.Event(notifiableMessageEvent) + } + is NotificationContent.MessageLike.RtcNotification -> { + val notifiableEvent = callNotificationEventResolver.resolveEvent(userId, this).getOrThrow() + ResolvedPushEvent.Event(notifiableEvent) + } + NotificationContent.MessageLike.KeyVerificationAccept, + NotificationContent.MessageLike.KeyVerificationCancel, + NotificationContent.MessageLike.KeyVerificationDone, + NotificationContent.MessageLike.KeyVerificationKey, + NotificationContent.MessageLike.KeyVerificationMac, + NotificationContent.MessageLike.KeyVerificationReady, + NotificationContent.MessageLike.KeyVerificationStart -> { + Timber.tag(loggerTag.value).d("Ignoring notification for verification ${content.javaClass.simpleName}") + throw NotificationResolverException.EventFilteredOut + } + is NotificationContent.MessageLike.Poll -> { + val notifiableEventMessage = buildNotifiableMessageEvent( + sessionId = userId, + senderId = content.senderId, + roomId = roomId, + eventId = eventId, + noisy = isNoisy, + timestamp = this.timestamp, + senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId), + body = stringProvider.getString(CommonStrings.common_poll_summary, content.question), + imageUriString = null, + roomName = roomDisplayName, + roomIsDm = isDm, + roomAvatarPath = roomAvatarUrl, + senderAvatarPath = senderAvatarUrl, + ) + ResolvedPushEvent.Event(notifiableEventMessage) + } + is NotificationContent.MessageLike.ReactionContent -> { + Timber.tag(loggerTag.value).d("Ignoring notification for reaction") + throw NotificationResolverException.EventFilteredOut + } + NotificationContent.MessageLike.RoomEncrypted -> { + Timber.tag(loggerTag.value).w("Notification with encrypted content -> fallback") + val fallbackNotifiableEvent = fallbackNotificationFactory.create( + sessionId = userId, + roomId = roomId, + eventId = eventId, + cause = "Unable to decrypt event content", + ) + ResolvedPushEvent.Event(fallbackNotifiableEvent) + } + is NotificationContent.MessageLike.RoomRedaction -> { + // Note: this case will be handled below + val redactedEventId = content.redactedEventId + if (redactedEventId == null) { + Timber.tag(loggerTag.value).d("redactedEventId is null.") + throw NotificationResolverException.UnknownError("redactedEventId is null") + } else { + ResolvedPushEvent.Redaction( + sessionId = userId, + roomId = roomId, + redactedEventId = redactedEventId, + reason = content.reason, + ) + } + } + NotificationContent.MessageLike.Sticker -> { + Timber.tag(loggerTag.value).d("Ignoring notification for sticker") + throw NotificationResolverException.EventFilteredOut + } + is NotificationContent.StateEvent.RoomMemberContent, + NotificationContent.StateEvent.PolicyRuleRoom, + NotificationContent.StateEvent.PolicyRuleServer, + NotificationContent.StateEvent.PolicyRuleUser, + NotificationContent.StateEvent.RoomAliases, + NotificationContent.StateEvent.RoomAvatar, + NotificationContent.StateEvent.RoomCanonicalAlias, + NotificationContent.StateEvent.RoomCreate, + NotificationContent.StateEvent.RoomEncryption, + NotificationContent.StateEvent.RoomGuestAccess, + NotificationContent.StateEvent.RoomHistoryVisibility, + NotificationContent.StateEvent.RoomJoinRules, + NotificationContent.StateEvent.RoomName, + NotificationContent.StateEvent.RoomPinnedEvents, + NotificationContent.StateEvent.RoomPowerLevels, + NotificationContent.StateEvent.RoomServerAcl, + NotificationContent.StateEvent.RoomThirdPartyInvite, + NotificationContent.StateEvent.RoomTombstone, + is NotificationContent.StateEvent.RoomTopic, + NotificationContent.StateEvent.SpaceChild, + NotificationContent.StateEvent.SpaceParent -> { + Timber.tag(loggerTag.value).d("Ignoring notification for state event ${content.javaClass.simpleName}") + throw NotificationResolverException.EventFilteredOut + } + } + } + + private fun descriptionFromMessageContent( + content: NotificationContent.MessageLike.RoomMessage, + senderDisambiguatedDisplayName: String, + hasImageUri: Boolean, + ): String? { + return when (val messageType = content.messageType) { + is AudioMessageType -> messageType.bestDescription + is VoiceMessageType -> stringProvider.getString(CommonStrings.common_voice_message) + is EmoteMessageType -> "* $senderDisambiguatedDisplayName ${messageType.body}" + is FileMessageType -> messageType.bestDescription + is ImageMessageType -> if (hasImageUri) { + messageType.caption + } else { + messageType.bestDescription + } + is StickerMessageType -> messageType.bestDescription + is NoticeMessageType -> messageType.body + is TextMessageType -> messageType.toPlainText(permalinkParser = permalinkParser) + is VideoMessageType -> messageType.bestDescription + is LocationMessageType -> messageType.body + is OtherMessageType -> messageType.body + } + } + + private fun descriptionFromRoomMembershipInvite( + senderDisambiguatedDisplayName: String, + isDirectRoom: Boolean + ): String { + return if (isDirectRoom) { + stringProvider.getString(R.string.notification_invite_body_with_sender, senderDisambiguatedDisplayName) + } else { + stringProvider.getString(R.string.notification_room_invite_body_with_sender, senderDisambiguatedDisplayName) + } + } + + /** + * Fetch the image for message type, only if the mime type is supported, as recommended + * per [NotificationCompat.MessagingStyle.Message.setData] documentation. + * Then convert to a [Uri] accessible to the Notification Service. + */ + private suspend fun NotificationContent.MessageLike.RoomMessage.fetchImageIfPresent( + client: MatrixClient, + mimeType: String, + ): Uri? { + val fileResult = when (val messageType = messageType) { + is ImageMessageType -> { + val isMimeTypeSupported = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ImageDecoder.isMimeTypeSupported(mimeType) + } else { + // Assume it's supported on old systems... + true + } + if (isMimeTypeSupported) { + notificationMediaRepoFactory.create(client).getMediaFile( + mediaSource = messageType.source, + mimeType = messageType.info?.mimetype, + filename = messageType.filename, + ) + } else { + Timber.tag(loggerTag.value).d("Mime type $mimeType not supported by the system") + null + } + } + is VideoMessageType -> null // Use the thumbnail here? + else -> null + } + ?: return null + + return fileResult + .onFailure { + Timber.tag(loggerTag.value).e(it, "Failed to download image for notification") + } + .map { mediaFile -> + val authority = "${context.packageName}.notifications.fileprovider" + FileProvider.getUriForFile(context, authority, mediaFile) + } + .getOrNull() + } + + private fun NotificationContent.MessageLike.RoomMessage.getImageMimetype(): String? { + return when (val messageType = messageType) { + is ImageMessageType -> messageType.info?.mimetype + is VideoMessageType -> null // Use the thumbnail here? + else -> null + } + } +} + +@Suppress("LongParameterList") +internal fun buildNotifiableMessageEvent( + sessionId: SessionId, + senderId: UserId, + roomId: RoomId, + eventId: EventId, + editedEventId: EventId? = null, + canBeReplaced: Boolean = false, + noisy: Boolean, + timestamp: Long, + senderDisambiguatedDisplayName: String?, + body: String?, + // We cannot use Uri? type here, as that could trigger a + // NotSerializableException when persisting this to storage + imageUriString: String? = null, + imageMimeType: String? = null, + threadId: ThreadId? = null, + roomName: String? = null, + roomIsDm: 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, + type: String = EventType.MESSAGE, + hasMentionOrReply: Boolean = false, +) = NotifiableMessageEvent( + sessionId = sessionId, + senderId = senderId, + roomId = roomId, + eventId = eventId, + editedEventId = editedEventId, + canBeReplaced = canBeReplaced, + noisy = noisy, + timestamp = timestamp, + senderDisambiguatedDisplayName = senderDisambiguatedDisplayName, + body = body, + imageUriString = imageUriString, + imageMimeType = imageMimeType, + threadId = threadId, + roomName = roomName, + roomIsDm = roomIsDm, + roomAvatarPath = roomAvatarPath, + senderAvatarPath = senderAvatarPath, + soundName = soundName, + outGoingMessage = outGoingMessage, + outGoingMessageFailed = outGoingMessageFailed, + isRedacted = isRedacted, + isUpdated = isUpdated, + type = type, + hasMentionOrReply = hasMentionOrReply, +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationBitmapLoader.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationBitmapLoader.kt new file mode 100644 index 0000000..086914e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationBitmapLoader.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.content.Context +import android.content.res.Configuration +import android.graphics.Bitmap +import android.os.Build +import androidx.core.graphics.drawable.IconCompat +import coil3.ImageLoader +import coil3.request.ImageRequest +import coil3.request.transformations +import coil3.toBitmap +import coil3.transform.CircleCropTransformation +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL +import io.element.android.libraries.matrix.ui.media.InitialsAvatarBitmapGenerator +import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import timber.log.Timber + +@ContributesBinding(AppScope::class) +class DefaultNotificationBitmapLoader( + @ApplicationContext private val context: Context, + private val sdkIntProvider: BuildVersionSdkIntProvider, + private val initialsAvatarBitmapGenerator: InitialsAvatarBitmapGenerator, +) : NotificationBitmapLoader { + override suspend fun getRoomBitmap( + avatarData: AvatarData, + imageLoader: ImageLoader, + targetSize: Long, + ): Bitmap? { + return try { + loadBitmap( + avatarData = avatarData, + imageLoader = imageLoader, + targetSize = targetSize, + ) + } catch (e: Throwable) { + Timber.e(e, "Unable to load room bitmap") + null + } + } + + override suspend fun getUserIcon( + avatarData: AvatarData, + imageLoader: ImageLoader, + ): IconCompat? { + if (sdkIntProvider.get() < Build.VERSION_CODES.P) { + return null + } + return try { + loadBitmap( + avatarData = avatarData, + imageLoader = imageLoader, + targetSize = AVATAR_THUMBNAIL_SIZE_IN_PIXEL, + ) + ?.let { IconCompat.createWithBitmap(it) } + } catch (e: Throwable) { + Timber.e(e, "Unable to load user bitmap") + null + } + } + + private fun isDarkTheme(): Boolean { + return context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES + } + + private suspend fun loadBitmap( + avatarData: AvatarData, + imageLoader: ImageLoader, + targetSize: Long + ): Bitmap? { + val path = avatarData.url + val data = if (path != null) { + MediaRequestData( + source = MediaSource(path), + kind = MediaRequestData.Kind.Thumbnail(targetSize), + ) + } else { + initialsAvatarBitmapGenerator.generateBitmap( + size = targetSize.toInt(), + avatarData = avatarData, + useDarkTheme = isDarkTheme(), + ) + } + val imageRequest = ImageRequest.Builder(context) + .data(data) + .transformations(CircleCropTransformation()) + .build() + return imageLoader.execute(imageRequest).image?.toBitmap() + } +} 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 0000000..ee72c34 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.matrix.api.MatrixClientProvider +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.ui.media.ImageLoaderHolder +import io.element.android.libraries.push.api.notifications.NotificationCleaner +import io.element.android.libraries.push.api.notifications.NotificationIdProvider +import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom +import io.element.android.services.appnavstate.api.AppNavigationStateService +import io.element.android.services.appnavstate.api.NavigationState +import io.element.android.services.appnavstate.api.currentSessionId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * This class receives notification events as they arrive from the PushHandler calling [onNotifiableEventReceived] 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) +@ContributesBinding(AppScope::class) +class DefaultNotificationDrawerManager( + private val notificationDisplayer: NotificationDisplayer, + private val notificationRenderer: NotificationRenderer, + private val appNavigationStateService: AppNavigationStateService, + @AppCoroutineScope + coroutineScope: CoroutineScope, + private val matrixClientProvider: MatrixClientProvider, + private val imageLoaderHolder: ImageLoaderHolder, + private val activeNotificationsProvider: ActiveNotificationsProvider, +) : NotificationCleaner { + // 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 var currentAppNavigationState: NavigationState? = null + + private fun onAppNavigationStateChange(navigationState: NavigationState) { + when (navigationState) { + NavigationState.Root -> { + currentAppNavigationState?.currentSessionId()?.let { sessionId -> + // User signed out, clear all notifications related to the session. + clearAllEvents(sessionId) + } + } + is NavigationState.Session -> {} + is NavigationState.Space -> {} + is NavigationState.Room -> { + // Cleanup notification for current room + clearMessagesForRoom( + sessionId = navigationState.parentSpace.parentSession.sessionId, + roomId = navigationState.roomId, + ) + } + is NavigationState.Thread -> { + clearMessagesForThread( + sessionId = navigationState.parentRoom.parentSpace.parentSession.sessionId, + roomId = navigationState.parentRoom.roomId, + threadId = navigationState.threadId, + ) + } + } + currentAppNavigationState = navigationState + } + + /** + * Should be called as soon as a new event is ready to be displayed, filtering out notifications that shouldn't be displayed. + * Events might be grouped and there might not be one notification per event! + */ + suspend fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { + if (notifiableEvent.shouldIgnoreEventInRoom(appNavigationStateService.appNavigationState.value)) { + return + } + renderEvents(listOf(notifiableEvent)) + } + + suspend fun onNotifiableEventsReceived(notifiableEvents: List) { + val eventsToNotify = notifiableEvents.filter { !it.shouldIgnoreEventInRoom(appNavigationStateService.appNavigationState.value) } + renderEvents(eventsToNotify) + } + + /** + * Clear all known message events for a [sessionId]. + */ + override fun clearAllMessagesEvents(sessionId: SessionId) { + notificationDisplayer.cancelNotification(null, NotificationIdProvider.getRoomMessagesNotificationId(sessionId)) + clearSummaryNotificationIfNeeded(sessionId) + } + + /** + * Clear all notifications related to the session. + */ + fun clearAllEvents(sessionId: SessionId) { + activeNotificationsProvider.getNotificationsForSession(sessionId) + .forEach { notificationDisplayer.cancelNotification(it.tag, it.id) } + } + + /** + * 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. + */ + override fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) { + notificationDisplayer.cancelNotification(roomId.value, NotificationIdProvider.getRoomMessagesNotificationId(sessionId)) + clearSummaryNotificationIfNeeded(sessionId) + } + + /** + * 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. + */ + override fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) { + val tag = NotificationCreator.messageTag(roomId, threadId) + notificationDisplayer.cancelNotification(tag, NotificationIdProvider.getRoomMessagesNotificationId(sessionId)) + clearSummaryNotificationIfNeeded(sessionId) + } + + override fun clearMembershipNotificationForSession(sessionId: SessionId) { + activeNotificationsProvider.getMembershipNotificationForSession(sessionId) + .forEach { notificationDisplayer.cancelNotification(it.tag, it.id) } + clearSummaryNotificationIfNeeded(sessionId) + } + + /** + * Clear invitation notification for the provided room. + */ + override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { + activeNotificationsProvider.getMembershipNotificationForRoom(sessionId, roomId) + .forEach { notificationDisplayer.cancelNotification(it.tag, it.id) } + clearSummaryNotificationIfNeeded(sessionId) + } + + /** + * Clear the notifications for a single event. + */ + override fun clearEvent(sessionId: SessionId, eventId: EventId) { + val id = NotificationIdProvider.getRoomEventNotificationId(sessionId) + notificationDisplayer.cancelNotification(eventId.value, id) + clearSummaryNotificationIfNeeded(sessionId) + } + + private fun clearSummaryNotificationIfNeeded(sessionId: SessionId) { + val summaryNotification = activeNotificationsProvider.getSummaryNotification(sessionId) + if (summaryNotification != null && activeNotificationsProvider.count(sessionId) == 1) { + notificationDisplayer.cancelNotification(null, summaryNotification.id) + } + } + + private suspend fun renderEvents(eventsToRender: List) { + // Group by sessionId + val eventsForSessions = eventsToRender.groupBy { + it.sessionId + } + + for ((sessionId, notifiableEvents) in eventsForSessions) { + val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow() + val imageLoader = imageLoaderHolder.get(client) + val userFromCache = client.userProfile.value + val currentUser = if (userFromCache.avatarUrl != null && userFromCache.displayName.isNullOrEmpty().not()) { + // We have an avatar and a display name, use it + userFromCache + } else { + client.getUserProfile().getOrNull() ?: MatrixUser(sessionId) + } + notificationRenderer.render(currentUser, useCompleteNotificationFormat, notifiableEvents, imageLoader) + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandler.kt new file mode 100644 index 0000000..413425d --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandler.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.matrix.api.MatrixClientProvider +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.push.api.notifications.OnMissedCallNotificationHandler + +@ContributesBinding(AppScope::class) +class DefaultOnMissedCallNotificationHandler( + private val matrixClientProvider: MatrixClientProvider, + private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager, + private val callNotificationEventResolver: CallNotificationEventResolver, +) : OnMissedCallNotificationHandler { + override suspend fun addMissedCallNotification( + sessionId: SessionId, + roomId: RoomId, + eventId: EventId, + ) { + // Resolve the event and add a notification for it, at this point it should no longer be a ringing one + val notificationData = matrixClientProvider.getOrRestore(sessionId).getOrNull() + ?.notificationService + ?.getNotifications(mapOf(roomId to listOf(eventId))) + ?.getOrNull() + ?.get(eventId) + ?.getOrNull() + ?: return + + val notifiableEvent = callNotificationEventResolver.resolveEvent( + sessionId = sessionId, + notificationData = notificationData, + // Make sure the notifiable event is not a ringing one + forceNotify = true, + ).getOrNull() + notifiableEvent?.let { defaultNotificationDrawerManager.onNotifiableEventReceived(it) } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FallbackNotificationFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FallbackNotificationFactory.kt new file mode 100644 index 0000000..7b06aae --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FallbackNotificationFactory.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import dev.zacsweers.metro.Inject +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.push.impl.R +import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.api.systemclock.SystemClock + +@Inject +class FallbackNotificationFactory( + private val clock: SystemClock, + private val stringProvider: StringProvider, +) { + fun create( + sessionId: SessionId, + roomId: RoomId, + eventId: EventId, + cause: String?, + ): FallbackNotifiableEvent = FallbackNotifiableEvent( + sessionId = sessionId, + roomId = roomId, + eventId = eventId, + editedEventId = null, + canBeReplaced = true, + isRedacted = false, + isUpdated = false, + timestamp = clock.epochMillis(), + description = stringProvider.getString(R.string.notification_fallback_content), + cause = cause, + ) +} 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 0000000..6b2eeaa --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationAction.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +data class NotificationAction( + val shouldNotify: Boolean, + val highlight: Boolean, + val soundName: String? +) + +/* +fun List.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 0000000..a030600 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.core.meta.BuildMeta + +/** + * Util class for creating notifications action Ids, using the application id. + */ +@Inject data class NotificationActionIds( + private val buildMeta: BuildMeta, +) { + val join = "${buildMeta.applicationId}.NotificationActions.JOIN_ACTION" + val reject = "${buildMeta.applicationId}.NotificationActions.REJECT_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" +} 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 0000000..7ead2f3 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.bindings + +/** + * Receives actions broadcast by notification (on click, on dismiss, inline replies, etc.). + */ +class NotificationBroadcastReceiver : BroadcastReceiver() { + @Inject lateinit var notificationBroadcastReceiverHandler: NotificationBroadcastReceiverHandler + + override fun onReceive(context: Context?, intent: Intent?) { + if (intent == null || context == null) return + context.bindings().inject(this) + notificationBroadcastReceiverHandler.onReceive(intent) + } + + 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 0000000..79dc616 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverBindings.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesTo + +@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/NotificationBroadcastReceiverHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt new file mode 100644 index 0000000..eb08a25 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.content.Intent +import dev.zacsweers.metro.Inject +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.matrix.api.MatrixClientProvider +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.room.CreateTimelineParams +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.timeline.ReceiptType +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory +import io.element.android.libraries.push.api.notifications.NotificationCleaner +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.push.OnNotifiableEventReceived +import io.element.android.services.appnavstate.api.ActiveRoomsHolder +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.UUID + +private val loggerTag = LoggerTag("NotificationBroadcastReceiverHandler", LoggerTag.NotificationLoggerTag) + +@Inject +class NotificationBroadcastReceiverHandler( + @AppCoroutineScope + private val appCoroutineScope: CoroutineScope, + private val matrixClientProvider: MatrixClientProvider, + private val sessionPreferencesStore: SessionPreferencesStoreFactory, + private val notificationCleaner: NotificationCleaner, + private val actionIds: NotificationActionIds, + private val systemClock: SystemClock, + private val onNotifiableEventReceived: OnNotifiableEventReceived, + private val stringProvider: StringProvider, + private val replyMessageExtractor: ReplyMessageExtractor, + private val activeRoomsHolder: ActiveRoomsHolder, +) { + fun onReceive(intent: Intent) { + val sessionId = intent.getStringExtra(NotificationBroadcastReceiver.KEY_SESSION_ID)?.let(::SessionId) ?: return + val roomId = intent.getStringExtra(NotificationBroadcastReceiver.KEY_ROOM_ID)?.let(::RoomId) + val threadId = intent.getStringExtra(NotificationBroadcastReceiver.KEY_THREAD_ID)?.let(::ThreadId) + val eventId = intent.getStringExtra(NotificationBroadcastReceiver.KEY_EVENT_ID)?.let(::EventId) + + Timber.tag(loggerTag.value).d("onReceive: ${intent.action} ${intent.data} for: ${roomId?.value}/${eventId?.value}") + when (intent.action) { + actionIds.smartReply -> if (roomId != null) { + handleSmartReply(sessionId, roomId, eventId, threadId, intent) + } + actionIds.dismissRoom -> if (roomId != null) { + notificationCleaner.clearMessagesForRoom(sessionId, roomId) + } + actionIds.dismissSummary -> + notificationCleaner.clearAllMessagesEvents(sessionId) + actionIds.dismissInvite -> if (roomId != null) { + notificationCleaner.clearMembershipNotificationForRoom(sessionId, roomId) + } + actionIds.dismissEvent -> if (eventId != null) { + notificationCleaner.clearEvent(sessionId, eventId) + } + actionIds.markRoomRead -> if (roomId != null) { + if (threadId == null) { + notificationCleaner.clearMessagesForRoom(sessionId, roomId) + } else { + notificationCleaner.clearMessagesForThread(sessionId, roomId, threadId) + } + handleMarkAsRead(sessionId, roomId, threadId) + } + actionIds.join -> if (roomId != null) { + notificationCleaner.clearMembershipNotificationForRoom(sessionId, roomId) + handleJoinRoom(sessionId, roomId) + } + actionIds.reject -> if (roomId != null) { + notificationCleaner.clearMembershipNotificationForRoom(sessionId, roomId) + handleRejectRoom(sessionId, roomId) + } + } + } + + private fun handleJoinRoom(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch { + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch + client.joinRoom(roomId) + } + + private fun handleRejectRoom(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch { + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch + client.getRoom(roomId)?.leave() + } + + @Suppress("unused") + private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?) = appCoroutineScope.launch { + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch + val isSendPublicReadReceiptsEnabled = sessionPreferencesStore.get(sessionId, this).isSendPublicReadReceiptsEnabled().first() + val receiptType = if (isSendPublicReadReceiptsEnabled) { + ReceiptType.READ + } else { + ReceiptType.READ_PRIVATE + } + val room = client.getJoinedRoom(roomId) ?: return@launch + val timeline = if (threadId != null) { + room.createTimeline(CreateTimelineParams.Threaded(threadId)).getOrNull() + } else { + room.liveTimeline + } + timeline?.markAsRead(receiptType) + ?.onSuccess { + if (threadId != null) { + Timber.d("Marked thread $threadId in room $roomId as read with receipt type $receiptType") + } else { + Timber.d("Marked room $roomId as read with receipt type $receiptType") + } + } + ?.onFailure { + Timber.e(it, "Fails to mark as read with receipt type $receiptType") + } + if (timeline?.mode != Timeline.Mode.Live) { + timeline?.close() + } + } + + private fun handleSmartReply( + sessionId: SessionId, + roomId: RoomId, + replyToEventId: EventId?, + threadId: ThreadId?, + intent: Intent, + ) = appCoroutineScope.launch { + val message = replyMessageExtractor.getReplyMessage(intent) + if (message.isNullOrBlank()) { + // ignore this event + // Can this happen? should we update notification? + return@launch + } + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch + val room = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId) ?: client.getJoinedRoom(roomId) + + room?.let { + sendMatrixEvent( + sessionId = sessionId, + roomId = roomId, + replyToEventId = replyToEventId, + threadId = threadId, + room = it, + message = message, + ) + } + } + + private suspend fun sendMatrixEvent( + sessionId: SessionId, + roomId: RoomId, + threadId: ThreadId?, + replyToEventId: EventId?, + room: JoinedRoom, + message: String, + ) { + // Create a new event to be displayed in the notification drawer, right now + val notifiableMessageEvent = NotifiableMessageEvent( + sessionId = sessionId, + roomId = roomId, + // Generate a Fake event id + eventId = EventId("\$" + UUID.randomUUID().toString()), + editedEventId = null, + canBeReplaced = false, + senderId = sessionId, + noisy = false, + timestamp = systemClock.epochMillis(), + senderDisambiguatedDisplayName = room.getUpdatedMember(sessionId).getOrNull() + ?.disambiguatedDisplayName + ?: stringProvider.getString(R.string.notification_sender_me), + body = message, + imageUriString = null, + imageMimeType = null, + threadId = threadId, + roomName = room.info().name, + roomIsDm = room.isDm(), + outGoingMessage = true, + ) + onNotifiableEventReceived.onNotifiableEventsReceived(listOf(notifiableMessageEvent)) + + if (threadId != null && replyToEventId != null) { + room.liveTimeline.replyMessage( + body = message, + htmlBody = null, + intentionalMentions = emptyList(), + fromNotification = true, + repliedToEventId = replyToEventId, + ) + } else { + room.liveTimeline.sendMessage( + body = message, + htmlBody = null, + intentionalMentions = emptyList() + ) + }.onFailure { + Timber.e(it, "Failed to send smart reply message") + onNotifiableEventReceived.onNotifiableEventsReceived( + listOf( + notifiableMessageEvent.copy( + outGoingMessageFailed = true + ) + ) + ) + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt new file mode 100644 index 0000000..5489fa0 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2021-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.app.Notification +import android.graphics.Typeface +import android.text.style.StyleSpan +import androidx.core.text.buildSpannedString +import androidx.core.text.inSpans +import coil3.ImageLoader +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +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.R +import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams +import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +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 io.element.android.services.toolbox.api.strings.StringProvider + +interface NotificationDataFactory { + suspend fun toNotifications( + messages: List, + imageLoader: ImageLoader, + notificationAccountParams: NotificationAccountParams, + ): List + + @JvmName("toNotificationInvites") + @Suppress("INAPPLICABLE_JVM_NAME") + fun toNotifications( + invites: List, + notificationAccountParams: NotificationAccountParams, + ): List + + @JvmName("toNotificationSimpleEvents") + @Suppress("INAPPLICABLE_JVM_NAME") + fun toNotifications( + simpleEvents: List, + notificationAccountParams: NotificationAccountParams, + ): List + + @JvmName("toNotificationFallbackEvents") + @Suppress("INAPPLICABLE_JVM_NAME") + fun toNotifications( + fallback: List, + notificationAccountParams: NotificationAccountParams, + ): List + + fun createSummaryNotification( + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + notificationAccountParams: NotificationAccountParams, + ): SummaryNotification +} + +@ContributesBinding(AppScope::class) +class DefaultNotificationDataFactory( + private val notificationCreator: NotificationCreator, + private val roomGroupMessageCreator: RoomGroupMessageCreator, + private val summaryGroupMessageCreator: SummaryGroupMessageCreator, + private val activeNotificationsProvider: ActiveNotificationsProvider, + private val stringProvider: StringProvider, +) : NotificationDataFactory { + override suspend fun toNotifications( + messages: List, + imageLoader: ImageLoader, + notificationAccountParams: NotificationAccountParams, + ): List { + val messagesToDisplay = messages.filterNot { it.canNotBeDisplayed() } + .groupBy { it.roomId } + return messagesToDisplay.flatMap { (roomId, events) -> + val roomName = events.lastOrNull()?.roomName ?: roomId.value + val isDm = events.lastOrNull()?.roomIsDm ?: false + val eventsByThreadId = events.groupBy { it.threadId } + + eventsByThreadId.map { (threadId, events) -> + val notification = roomGroupMessageCreator.createRoomMessage( + events = events, + roomId = roomId, + threadId = threadId, + imageLoader = imageLoader, + existingNotification = getExistingNotificationForMessages(notificationAccountParams.user.userId, roomId, threadId), + notificationAccountParams = notificationAccountParams, + ) + RoomNotification( + notification = notification, + roomId = roomId, + threadId = threadId, + summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, isDm), + messageCount = events.size, + latestTimestamp = events.maxOf { it.timestamp }, + shouldBing = events.any { it.noisy } + ) + } + } + } + + private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted + + private fun getExistingNotificationForMessages(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): Notification? { + return activeNotificationsProvider.getMessageNotificationsForRoom(sessionId, roomId, threadId).firstOrNull()?.notification + } + + @JvmName("toNotificationInvites") + @Suppress("INAPPLICABLE_JVM_NAME") + override fun toNotifications( + invites: List, + notificationAccountParams: NotificationAccountParams, + ): List { + return invites.map { event -> + OneShotNotification( + tag = event.roomId.value, + notification = notificationCreator.createRoomInvitationNotification(notificationAccountParams, event), + summaryLine = event.description, + isNoisy = event.noisy, + timestamp = event.timestamp + ) + } + } + + @JvmName("toNotificationSimpleEvents") + @Suppress("INAPPLICABLE_JVM_NAME") + override fun toNotifications( + simpleEvents: List, + notificationAccountParams: NotificationAccountParams, + ): List { + return simpleEvents.map { event -> + OneShotNotification( + tag = event.eventId.value, + notification = notificationCreator.createSimpleEventNotification(notificationAccountParams, event), + summaryLine = event.description, + isNoisy = event.noisy, + timestamp = event.timestamp + ) + } + } + + @JvmName("toNotificationFallbackEvents") + @Suppress("INAPPLICABLE_JVM_NAME") + override fun toNotifications( + fallback: List, + notificationAccountParams: NotificationAccountParams, + ): List { + return fallback.map { event -> + OneShotNotification( + tag = event.eventId.value, + notification = notificationCreator.createFallbackNotification(notificationAccountParams, event), + summaryLine = event.description.orEmpty(), + isNoisy = false, + timestamp = event.timestamp + ) + } + } + + override fun createSummaryNotification( + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + notificationAccountParams: NotificationAccountParams, + ): SummaryNotification { + return when { + roomNotifications.isEmpty() && invitationNotifications.isEmpty() && simpleNotifications.isEmpty() -> SummaryNotification.Removed + else -> SummaryNotification.Update( + summaryGroupMessageCreator.createSummaryNotification( + roomNotifications = roomNotifications, + invitationNotifications = invitationNotifications, + simpleNotifications = simpleNotifications, + fallbackNotifications = fallbackNotifications, + notificationAccountParams = notificationAccountParams, + ) + ) + } + } + + private fun createRoomMessagesGroupSummaryLine(events: List, roomName: String, roomIsDm: Boolean): CharSequence { + return when (events.size) { + 1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDm) + else -> { + stringProvider.getQuantityString( + R.plurals.notification_compat_summary_line_for_room, + events.size, + roomName, + events.size + ) + } + } + } + + private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDm: Boolean): CharSequence { + return if (roomIsDm) { + buildSpannedString { + event.senderDisambiguatedDisplayName?.let { + inSpans(StyleSpan(Typeface.BOLD)) { + append(it) + append(": ") + } + } + append(event.description) + } + } else { + buildSpannedString { + inSpans(StyleSpan(Typeface.BOLD)) { + append(roomName) + append(": ") + event.senderDisambiguatedDisplayName?.let { + append(it) + append(" ") + } + } + append(event.description) + } + } + } +} + +data class RoomNotification( + val notification: Notification, + val roomId: RoomId, + val threadId: ThreadId?, + val summaryLine: CharSequence, + val messageCount: Int, + val latestTimestamp: Long, + val shouldBing: Boolean, +) { + fun isDataEqualTo(other: RoomNotification): Boolean { + return notification == other.notification && + roomId == other.roomId && + threadId == other.threadId && + summaryLine.toString() == other.summaryLine.toString() && + messageCount == other.messageCount && + latestTimestamp == other.latestTimestamp && + shouldBing == other.shouldBing + } +} + +data class OneShotNotification( + val notification: Notification, + val tag: String, + val summaryLine: CharSequence, + val isNoisy: Boolean, + val timestamp: Long, +) + +sealed interface SummaryNotification { + data 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/NotificationDisplayer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt new file mode 100644 index 0000000..d46bffc --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2021-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.Manifest +import android.app.Notification +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationManagerCompat +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.annotations.ApplicationContext +import timber.log.Timber + +interface NotificationDisplayer { + fun showNotification(tag: String?, id: Int, notification: Notification): Boolean + fun cancelNotification(tag: String?, id: Int) + fun displayDiagnosticNotification(notification: Notification): Boolean + fun dismissDiagnosticNotification() + fun displayUnregistrationNotification(notification: Notification): Boolean +} + +@ContributesBinding(AppScope::class) +class DefaultNotificationDisplayer( + @ApplicationContext private val context: Context, + private val notificationManager: NotificationManagerCompat +) : NotificationDisplayer { + override fun showNotification(tag: String?, id: Int, notification: Notification): Boolean { + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + Timber.w("Not allowed to notify.") + return false + } + notificationManager.notify(tag, id, notification) + Timber.d("Notifying with tag: $tag, id: $id") + return true + } + + override fun cancelNotification(tag: String?, id: Int) { + notificationManager.cancel(tag, id) + } + + override fun displayDiagnosticNotification(notification: Notification): Boolean { + return showNotification( + tag = TAG_DIAGNOSTIC, + id = NOTIFICATION_ID_DIAGNOSTIC, + notification = notification + ) + } + + override fun dismissDiagnosticNotification() { + cancelNotification( + tag = TAG_DIAGNOSTIC, + id = NOTIFICATION_ID_DIAGNOSTIC + ) + } + + override fun displayUnregistrationNotification(notification: Notification): Boolean { + return showNotification( + tag = TAG_DIAGNOSTIC, + id = NOTIFICATION_ID_UNREGISTRATION, + notification = notification, + ) + } + + companion object { + private const val TAG_DIAGNOSTIC = "DIAGNOSTIC" + + /* ========================================================================================== + * IDs for notifications + * ========================================================================================== */ + private const val NOTIFICATION_ID_DIAGNOSTIC = 888 + private const val NOTIFICATION_ID_UNREGISTRATION = 889 + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationMediaRepo.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationMediaRepo.kt new file mode 100644 index 0000000..f309f75 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationMediaRepo.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.mapCatchingExceptions +import io.element.android.libraries.di.CacheDirectory +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.api.mxc.MxcTools +import java.io.File + +/** + * Fetches the media file for a notification. + * + * Media is downloaded from the rust sdk and stored in the application's cache directory. + * Media files are indexed by their Matrix Content (mxc://) URI and considered immutable. + * Whenever a given mxc is found in the cache, it is returned immediately. + */ +interface NotificationMediaRepo { + /** + * Factory for [NotificationMediaRepo]. + */ + fun interface Factory { + /** + * Creates a [NotificationMediaRepo]. + * + */ + fun create( + client: MatrixClient + ): NotificationMediaRepo + } + + /** + * Returns the file. + * + * In case of a cache hit the file is returned immediately. + * In case of a cache miss the file is downloaded and then returned. + * + * @param mediaSource the media source of the media. + * @param mimeType the mime type of the media. + * @param filename optional String which will be used to name the file. + * @return A [Result] holding either the media [File] from the cache directory or an [Exception]. + */ + suspend fun getMediaFile( + mediaSource: MediaSource, + mimeType: String?, + filename: String?, + ): Result +} + +@AssistedInject +class DefaultNotificationMediaRepo( + @CacheDirectory private val cacheDir: File, + private val mxcTools: MxcTools, + @Assisted private val client: MatrixClient, +) : NotificationMediaRepo { + @ContributesBinding(AppScope::class) + @AssistedFactory + fun interface Factory : NotificationMediaRepo.Factory { + override fun create( + client: MatrixClient, + ): DefaultNotificationMediaRepo + } + + private val matrixMediaLoader = client.matrixMediaLoader + + override suspend fun getMediaFile( + mediaSource: MediaSource, + mimeType: String?, + filename: String?, + ): Result { + val cachedFile = mediaSource.cachedFile() + return when { + cachedFile == null -> Result.failure(IllegalStateException("Invalid mxcUri.")) + cachedFile.exists() -> Result.success(cachedFile) + else -> matrixMediaLoader.downloadMediaFile( + source = mediaSource, + mimeType = mimeType, + filename = filename, + ).mapCatchingExceptions { + it.use { mediaFile -> + val dest = cachedFile.apply { parentFile?.mkdirs() } + if (mediaFile.persist(dest.path)) { + dest + } else { + error("Failed to move file to cache.") + } + } + } + } + } + + private fun MediaSource.cachedFile(): File? = mxcTools.mxcUri2FilePath(url)?.let { + File("${cacheDir.path}/$CACHE_NOTIFICATION_SUBDIR/$it") + } +} + +/** + * Subdirectory of the application's cache directory where file are stored. + */ +private const val CACHE_NOTIFICATION_SUBDIR = "temp/notif" 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 0000000..26769f0 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import androidx.compose.ui.graphics.toArgb +import coil3.ImageLoader +import dev.zacsweers.metro.Inject +import io.element.android.appconfig.NotificationConfig +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.push.api.notifications.NotificationIdProvider +import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams +import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +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.NotifiableRingingCallEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.coroutines.flow.first +import timber.log.Timber + +private val loggerTag = LoggerTag("NotificationRenderer", LoggerTag.NotificationLoggerTag) + +@Inject +class NotificationRenderer( + private val notificationDisplayer: NotificationDisplayer, + private val notificationDataFactory: NotificationDataFactory, + private val enterpriseService: EnterpriseService, + private val sessionStore: SessionStore, +) { + suspend fun render( + currentUser: MatrixUser, + useCompleteNotificationFormat: Boolean, + eventsToProcess: List, + imageLoader: ImageLoader, + ) { + val color = enterpriseService.brandColorsFlow(currentUser.userId).first()?.toArgb() + ?: NotificationConfig.NOTIFICATION_ACCENT_COLOR + val numberOfAccounts = sessionStore.numberOfSessions() + val notificationAccountParams = NotificationAccountParams( + user = currentUser, + color = color, + showSessionId = numberOfAccounts > 1, + ) + val groupedEvents = eventsToProcess.groupByType() + val roomNotifications = notificationDataFactory.toNotifications(groupedEvents.roomEvents, imageLoader, notificationAccountParams) + val invitationNotifications = notificationDataFactory.toNotifications(groupedEvents.invitationEvents, notificationAccountParams) + val simpleNotifications = notificationDataFactory.toNotifications(groupedEvents.simpleEvents, notificationAccountParams) + val fallbackNotifications = notificationDataFactory.toNotifications(groupedEvents.fallbackEvents, notificationAccountParams) + val summaryNotification = notificationDataFactory.createSummaryNotification( + roomNotifications = roomNotifications, + invitationNotifications = invitationNotifications, + simpleNotifications = simpleNotifications, + fallbackNotifications = fallbackNotifications, + notificationAccountParams = notificationAccountParams, + ) + + // Remove summary first to avoid briefly displaying it after dismissing the last notification + if (summaryNotification == SummaryNotification.Removed) { + Timber.tag(loggerTag.value).d("Removing summary notification") + notificationDisplayer.cancelNotification( + tag = null, + id = NotificationIdProvider.getSummaryNotificationId(currentUser.userId) + ) + } + + roomNotifications.forEach { notificationData -> + val tag = NotificationCreator.messageTag( + roomId = notificationData.roomId, + threadId = notificationData.threadId + ) + notificationDisplayer.showNotification( + tag = tag, + id = NotificationIdProvider.getRoomMessagesNotificationId(currentUser.userId), + notification = notificationData.notification + ) + } + + invitationNotifications.forEach { notificationData -> + if (useCompleteNotificationFormat) { + Timber.tag(loggerTag.value).d("Updating invitation notification ${notificationData.tag}") + notificationDisplayer.showNotification( + tag = notificationData.tag, + id = NotificationIdProvider.getRoomInvitationNotificationId(currentUser.userId), + notification = notificationData.notification + ) + } + } + + simpleNotifications.forEach { notificationData -> + if (useCompleteNotificationFormat) { + Timber.tag(loggerTag.value).d("Updating simple notification ${notificationData.tag}") + notificationDisplayer.showNotification( + tag = notificationData.tag, + id = NotificationIdProvider.getRoomEventNotificationId(currentUser.userId), + notification = notificationData.notification + ) + } + } + + // Show only the first fallback notification + if (fallbackNotifications.isNotEmpty()) { + Timber.tag(loggerTag.value).d("Showing fallback notification") + notificationDisplayer.showNotification( + tag = "FALLBACK", + id = NotificationIdProvider.getFallbackNotificationId(currentUser.userId), + notification = fallbackNotifications.first().notification + ) + } + + // Update summary last to avoid briefly displaying it before other notifications + if (summaryNotification is SummaryNotification.Update) { + Timber.tag(loggerTag.value).d("Updating summary notification") + notificationDisplayer.showNotification( + tag = null, + id = NotificationIdProvider.getSummaryNotificationId(currentUser.userId), + notification = summaryNotification.notification + ) + } + } +} + +private fun List.groupByType(): GroupedNotificationEvents { + val roomEvents: MutableList = mutableListOf() + val simpleEvents: MutableList = mutableListOf() + val invitationEvents: MutableList = mutableListOf() + val fallbackEvents: MutableList = mutableListOf() + forEach { event -> + when (event) { + is InviteNotifiableEvent -> invitationEvents.add(event.castedToEventType()) + is NotifiableMessageEvent -> roomEvents.add(event.castedToEventType()) + is SimpleNotifiableEvent -> simpleEvents.add(event.castedToEventType()) + is FallbackNotifiableEvent -> fallbackEvents.add(event.castedToEventType()) + // Nothing should be done for ringing call events as they're not handled here + is NotifiableRingingCallEvent -> {} + } + } + return GroupedNotificationEvents(roomEvents, simpleEvents, invitationEvents, fallbackEvents) +} + +@Suppress("UNCHECKED_CAST") +private fun NotifiableEvent.castedToEventType(): T = this as T + +data class GroupedNotificationEvents( + val roomEvents: List, + val simpleEvents: List, + val invitationEvents: List, + val fallbackEvents: List, +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt new file mode 100644 index 0000000..0d1478c --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.push.api.push.NotificationEventRequest +import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.libraries.push.impl.workmanager.SyncNotificationWorkManagerRequest +import io.element.android.libraries.push.impl.workmanager.WorkerDataConverter +import io.element.android.libraries.workmanager.api.WorkManagerScheduler +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import kotlin.time.Duration.Companion.milliseconds + +interface NotificationResolverQueue { + val results: SharedFlow, Map>>> + suspend fun enqueue(request: NotificationEventRequest) +} + +/** + * This class is responsible for periodically batching notification requests and resolving them in a single call, + * so that we can avoid having to resolve each notification individually in the SDK. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultNotificationResolverQueue( + private val notifiableEventResolver: NotifiableEventResolver, + @AppCoroutineScope + private val appCoroutineScope: CoroutineScope, + private val workManagerScheduler: WorkManagerScheduler, + private val featureFlagService: FeatureFlagService, + private val workerDataConverter: WorkerDataConverter, + private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, +) : NotificationResolverQueue { + companion object { + private const val BATCH_WINDOW_MS = 250L + } + + private val requestQueue = Channel(capacity = 100) + + private var currentProcessingJob: Job? = null + + /** + * A flow that emits pairs of a list of notification event requests and a map of the resolved events. + * The map contains the original request as the key and the resolved event as the value. + */ + override val results = MutableSharedFlow, Map>>>() + + /** + * Enqueues a notification event request to be resolved. + * The request will be processed in batches, so it may not be resolved immediately. + * + * @param request The notification event request to enqueue. + */ + override suspend fun enqueue(request: NotificationEventRequest) { + // Cancel previous processing job if it exists, acting as a debounce operation + Timber.d("Cancelling job: $currentProcessingJob") + currentProcessingJob?.cancel() + + // Enqueue the request and start a delayed processing job + requestQueue.send(request) + currentProcessingJob = processQueue() + Timber.d("Starting processing job for request: $request") + } + + private fun processQueue() = appCoroutineScope.launch(SupervisorJob()) { + delay(BATCH_WINDOW_MS.milliseconds) + + // If this job is still active (so this is the latest job), we launch a separate one that won't be cancelled when enqueueing new items + // to process the existing queued items. + appCoroutineScope.launch { + val groupedRequestsById = buildList { + while (!requestQueue.isEmpty) { + requestQueue.receiveCatching().getOrNull()?.let(::add) + } + }.groupBy { it.sessionId } + + if (featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) { + for ((sessionId, requests) in groupedRequestsById) { + workManagerScheduler.submit( + SyncNotificationWorkManagerRequest( + sessionId = sessionId, + notificationEventRequests = requests, + workerDataConverter = workerDataConverter, + buildVersionSdkIntProvider = buildVersionSdkIntProvider, + ) + ) + } + } else { + val sessionIds = groupedRequestsById.keys + for (sessionId in sessionIds) { + val requests = groupedRequestsById[sessionId].orEmpty() + Timber.d("Fetching notifications for $sessionId: $requests. Pending requests: ${!requestQueue.isEmpty}") + // Resolving the events in parallel should improve performance since each session id will query a different Client + launch { + // No need for a Mutex since the SDK already has one internally + val notifications = notifiableEventResolver.resolveEvents(sessionId, requests).getOrNull().orEmpty() + results.emit(requests to notifications) + } + } + } + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationsFileProvider.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationsFileProvider.kt new file mode 100644 index 0000000..5a2bd36 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationsFileProvider.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import androidx.core.content.FileProvider + +/** + * We have to declare our own file provider to avoid collision with other modules + * having their own. + */ +class NotificationsFileProvider : FileProvider() diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ReplyMessageExtractor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ReplyMessageExtractor.kt new file mode 100644 index 0000000..360f376 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ReplyMessageExtractor.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.content.Intent +import androidx.core.app.RemoteInput +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding + +interface ReplyMessageExtractor { + fun getReplyMessage(intent: Intent): String? +} + +@ContributesBinding(AppScope::class) +class AndroidReplyMessageExtractor : ReplyMessageExtractor { + override fun getReplyMessage(intent: Intent): String? { + return RemoteInput.getResultsFromIntent(intent) + ?.getCharSequence(NotificationBroadcastReceiver.KEY_TEXT_REPLY) + ?.toString() + } +} 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 0000000..8723420 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 isDm: Boolean = false, + // An event in the list has not yet been display + val hasNewEvent: Boolean = false, + // true if at least one on the not yet displayed event is noisy + val shouldBing: Boolean = false, + val customSound: String? = null, + val hasSmartReplyError: Boolean = false, + val 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 0000000..8683b58 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2021-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.app.Notification +import android.graphics.Bitmap +import coil3.ImageLoader +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +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.ThreadId +import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams +import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +import io.element.android.libraries.push.impl.notifications.factories.isSmartReplyError +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.services.toolbox.api.strings.StringProvider + +interface RoomGroupMessageCreator { + suspend fun createRoomMessage( + notificationAccountParams: NotificationAccountParams, + events: List, + roomId: RoomId, + threadId: ThreadId?, + imageLoader: ImageLoader, + existingNotification: Notification?, + ): Notification +} + +@ContributesBinding(AppScope::class) +class DefaultRoomGroupMessageCreator( + private val bitmapLoader: NotificationBitmapLoader, + private val stringProvider: StringProvider, + private val notificationCreator: NotificationCreator, +) : RoomGroupMessageCreator { + override suspend fun createRoomMessage( + notificationAccountParams: NotificationAccountParams, + events: List, + roomId: RoomId, + threadId: ThreadId?, + imageLoader: ImageLoader, + existingNotification: Notification?, + ): Notification { + val lastKnownRoomEvent = events.last() + val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderDisambiguatedDisplayName ?: "Room name (${roomId.value.take(8)}…)" + val roomIsGroup = !lastKnownRoomEvent.roomIsDm + + val tickerText = if (roomIsGroup) { + stringProvider.getString(R.string.notification_ticker_text_group, roomName, events.last().senderDisambiguatedDisplayName, events.last().description) + } else { + stringProvider.getString(R.string.notification_ticker_text_dm, events.last().senderDisambiguatedDisplayName, events.last().description) + } + + val largeBitmap = getRoomBitmap(events, imageLoader) + + val lastMessageTimestamp = events.last().timestamp + val smartReplyErrors = events.filter { it.isSmartReplyError() } + val roomIsDm = !roomIsGroup + return notificationCreator.createMessagesListNotification( + notificationAccountParams = notificationAccountParams, + RoomEventGroupInfo( + sessionId = notificationAccountParams.user.userId, + roomId = roomId, + roomDisplayName = roomName, + isDm = roomIsDm, + hasSmartReplyError = smartReplyErrors.isNotEmpty(), + shouldBing = events.any { it.noisy }, + customSound = events.last().soundName, + isUpdated = events.last().isUpdated, + ), + threadId = threadId, + largeIcon = largeBitmap, + lastMessageTimestamp = lastMessageTimestamp, + tickerText = tickerText, + existingNotification = existingNotification, + imageLoader = imageLoader, + events = events, + ) + } + + private suspend fun getRoomBitmap( + events: List, + imageLoader: ImageLoader, + ): Bitmap? { + // Use the last event (most recent?) + val event = events.reversed().firstOrNull { it.roomAvatarPath != null } + ?: events.reversed().firstOrNull() + return event?.let { event -> + bitmapLoader.getRoomBitmap( + avatarData = AvatarData( + id = event.roomId.value, + name = event.roomName, + url = event.roomAvatarPath, + size = AvatarSize.RoomDetailsHeader, + ), + imageLoader = imageLoader, + ) + } + } +} 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 0000000..f7f0c05 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2021-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.app.Notification +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams +import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +import io.element.android.services.toolbox.api.strings.StringProvider + +interface SummaryGroupMessageCreator { + fun createSummaryNotification( + notificationAccountParams: NotificationAccountParams, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + ): Notification +} + +/** + * ======== 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. + * 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 + */ +@ContributesBinding(AppScope::class) +class DefaultSummaryGroupMessageCreator( + private val stringProvider: StringProvider, + private val notificationCreator: NotificationCreator, +) : SummaryGroupMessageCreator { + override fun createSummaryNotification( + notificationAccountParams: NotificationAccountParams, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + ): Notification { + val summaryIsNoisy = roomNotifications.any { it.shouldBing } || + invitationNotifications.any { it.isNoisy } || + simpleNotifications.any { it.isNoisy } + val lastMessageTimestamp = roomNotifications.lastOrNull()?.latestTimestamp + ?: invitationNotifications.lastOrNull()?.timestamp + ?: simpleNotifications.last().timestamp + val nbEvents = roomNotifications.size + invitationNotifications.size + simpleNotifications.size + val sumTitle = stringProvider.getQuantityString(R.plurals.notification_compat_summary_title, nbEvents, nbEvents) + return notificationCreator.createSummaryListNotification( + notificationAccountParams = notificationAccountParams, + sumTitle, + 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 0000000..5312dc6 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiver.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.push.impl.troubleshoot.NotificationClickHandler + +class TestNotificationReceiver : BroadcastReceiver() { + @Inject lateinit var notificationClickHandler: NotificationClickHandler + + override fun onReceive(context: Context, intent: Intent) { + context.bindings().inject(this) + notificationClickHandler.handleNotificationClick() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiverBinding.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiverBinding.kt new file mode 100644 index 0000000..331f4f4 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/TestNotificationReceiverBinding.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesTo + +@ContributesTo(AppScope::class) +interface TestNotificationReceiverBinding { + fun inject(service: TestNotificationReceiver) +} 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 0000000..9d1452f --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.channels + +import android.content.ContentResolver +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioAttributes.USAGE_NOTIFICATION +import android.media.AudioManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationManagerCompat +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.appconfig.NotificationConfig +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.push.impl.R +import io.element.android.services.toolbox.api.strings.StringProvider + +/* ========================================================================================== + * IDs for channels + * ========================================================================================== */ +internal const val SILENT_NOTIFICATION_CHANNEL_ID = "DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID_V2" +internal const val NOISY_NOTIFICATION_CHANNEL_ID = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID_V2" +internal const val CALL_NOTIFICATION_CHANNEL_ID = "CALL_NOTIFICATION_CHANNEL_ID_V3" +internal const val RINGING_CALL_NOTIFICATION_CHANNEL_ID = "RINGING_CALL_NOTIFICATION_CHANNEL_ID" + +/** + * on devices >= android O, we need to define a channel for each notifications. + */ +interface NotificationChannels { + /** + * Get the channel for incoming call. + * @param ring true if the device should ring when receiving the call. + */ + fun getChannelForIncomingCall(ring: Boolean): String + + /** + * Get the channel for messages. + * @param noisy true if the notification should have sound and vibration. + */ + fun getChannelIdForMessage(noisy: Boolean): String + + /** + * Get the channel for test notifications. + */ + fun getChannelIdForTest(): String +} + +@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O) +private fun supportNotificationChannels() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultNotificationChannels( + private val notificationManager: NotificationManagerCompat, + private val stringProvider: StringProvider, + @ApplicationContext + private val context: Context, +) : NotificationChannels { + init { + createNotificationChannels() + } + + /** + * Create notification channels. + */ + private fun createNotificationChannels() { + if (!supportNotificationChannels()) { + return + } + + val accentColor = NotificationConfig.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", + "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID", + "CALL_NOTIFICATION_CHANNEL_ID", + "CALL_NOTIFICATION_CHANNEL_ID_V2", + "LISTEN_FOR_EVENTS_NOTIFICATION_CHANNEL_ID", + )) { + notificationManager.getNotificationChannel(channelId)?.let { + notificationManager.deleteNotificationChannel(channelId) + } + } + + // Default notification importance: shows everywhere, makes noise, but does not visually intrude. + notificationManager.createNotificationChannel( + NotificationChannelCompat.Builder( + NOISY_NOTIFICATION_CHANNEL_ID, + NotificationManagerCompat.IMPORTANCE_DEFAULT + ) + .setSound( + Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + // Strangely wwe have to provide a "//" before the package name + .path("//" + context.packageName + "/" + R.raw.message) + .build(), + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(USAGE_NOTIFICATION) + .build(), + ) + .setName(stringProvider.getString(R.string.notification_channel_noisy).ifEmpty { "Noisy notifications" }) + .setDescription(stringProvider.getString(R.string.notification_channel_noisy)) + .setVibrationEnabled(true) + .setLightsEnabled(true) + .setLightColor(accentColor) + .build() + ) + + // Low notification importance: shows everywhere, but is not intrusive. + notificationManager.createNotificationChannel( + NotificationChannelCompat.Builder( + SILENT_NOTIFICATION_CHANNEL_ID, + NotificationManagerCompat.IMPORTANCE_LOW + ) + .setName(stringProvider.getString(R.string.notification_channel_silent).ifEmpty { "Silent notifications" }) + .setDescription(stringProvider.getString(R.string.notification_channel_silent)) + .setSound(null, null) + .setLightsEnabled(true) + .setLightColor(accentColor) + .build() + ) + + // Register a channel for incoming and in progress call notifications with no ringing + notificationManager.createNotificationChannel( + NotificationChannelCompat.Builder( + CALL_NOTIFICATION_CHANNEL_ID, + NotificationManagerCompat.IMPORTANCE_HIGH + ) + .setName(stringProvider.getString(R.string.notification_channel_call).ifEmpty { "Call" }) + .setDescription(stringProvider.getString(R.string.notification_channel_call)) + .setVibrationEnabled(true) + .setLightsEnabled(true) + .setLightColor(accentColor) + .build() + ) + + // Register a channel for incoming call notifications which will ring the device when received + notificationManager.createNotificationChannel( + NotificationChannelCompat.Builder( + RINGING_CALL_NOTIFICATION_CHANNEL_ID, + NotificationManagerCompat.IMPORTANCE_MAX, + ) + .setName(stringProvider.getString(R.string.notification_channel_ringing_calls).ifEmpty { "Ringing calls" }) + .setVibrationEnabled(true) + .setSound( + Settings.System.DEFAULT_RINGTONE_URI, + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setLegacyStreamType(AudioManager.STREAM_RING) + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .build() + ) + .setDescription(stringProvider.getString(R.string.notification_channel_ringing_calls)) + .setLightsEnabled(true) + .setLightColor(accentColor) + .build() + ) + } + + override fun getChannelForIncomingCall(ring: Boolean): String { + return if (ring) RINGING_CALL_NOTIFICATION_CHANNEL_ID else CALL_NOTIFICATION_CHANNEL_ID + } + + override fun getChannelIdForMessage(noisy: Boolean): String { + return if (noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID + } + + override fun getChannelIdForTest(): String = NOISY_NOTIFICATION_CHANNEL_ID +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt new file mode 100644 index 0000000..ce20234 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.conversations + +import android.content.Context +import android.content.pm.ShortcutInfo +import android.os.Build +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.features.lockscreen.api.LockScreenService +import io.element.android.libraries.core.coroutine.withPreviousValue +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader +import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService +import io.element.android.libraries.push.impl.intent.IntentProvider +import io.element.android.libraries.push.impl.notifications.shortcut.createShortcutId +import io.element.android.libraries.push.impl.notifications.shortcut.filterBySession +import io.element.android.libraries.sessionstorage.api.observer.SessionListener +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import timber.log.Timber + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultNotificationConversationService( + @ApplicationContext private val context: Context, + private val intentProvider: IntentProvider, + private val bitmapLoader: NotificationBitmapLoader, + private val matrixClientProvider: MatrixClientProvider, + private val imageLoaderHolder: ImageLoaderHolder, + private val lockScreenService: LockScreenService, + sessionObserver: SessionObserver, + @AppCoroutineScope private val coroutineScope: CoroutineScope, +) : NotificationConversationService { + private val isRequestPinShortcutSupported = ShortcutManagerCompat.isRequestPinShortcutSupported(context) + + init { + sessionObserver.addListener(object : SessionListener { + override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) { + onSessionLogOut(SessionId(userId)) + } + }) + + lockScreenService.isPinSetup() + .withPreviousValue() + .onEach { (hadPinCode, hasPinCode) -> + if (hadPinCode == false && hasPinCode) { + clearShortcuts() + } + } + .launchIn(coroutineScope) + } + + override suspend fun onSendMessage( + sessionId: SessionId, + roomId: RoomId, + roomName: String, + roomIsDirect: Boolean, + roomAvatarUrl: String?, + ) { + if (lockScreenService.isPinSetup().first()) { + // We don't create shortcuts when a pin code is set for privacy reasons + return + } + + val categories = setOfNotNull( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION else null + ) + + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return + val imageLoader = imageLoaderHolder.get(client) + + val defaultShortcutIconSize = ShortcutManagerCompat.getIconMaxWidth(context) + val icon = bitmapLoader.getRoomBitmap( + avatarData = AvatarData( + id = roomId.value, + name = roomName, + url = roomAvatarUrl, + size = AvatarSize.RoomDetailsHeader, + ), + imageLoader = imageLoader, + targetSize = defaultShortcutIconSize.toLong() + )?.let(IconCompat::createWithBitmap) + + val shortcutInfo = ShortcutInfoCompat.Builder(context, createShortcutId(sessionId, roomId)) + .setShortLabel(roomName) + .setIcon(icon) + .setIntent(intentProvider.getViewRoomIntent(sessionId, roomId, threadId = null, eventId = null)) + .setCategories(categories) + .setLongLived(true) + .let { + when (roomIsDirect) { + true -> it.addCapabilityBinding("actions.intent.SEND_MESSAGE") + false -> it.addCapabilityBinding("actions.intent.SEND_MESSAGE", "message.recipient.@type", listOf("Audience")) + } + } + .build() + + runCatchingExceptions { ShortcutManagerCompat.pushDynamicShortcut(context, shortcutInfo) } + .onFailure { + Timber.e(it, "Failed to create shortcut for room $roomId in session $sessionId") + } + } + + override suspend fun onLeftRoom(sessionId: SessionId, roomId: RoomId) { + val shortcutsToRemove = listOf(createShortcutId(sessionId, roomId)) + runCatchingExceptions { + ShortcutManagerCompat.removeDynamicShortcuts(context, shortcutsToRemove) + if (isRequestPinShortcutSupported) { + ShortcutManagerCompat.disableShortcuts( + context, + shortcutsToRemove, + context.getString(CommonStrings.common_android_shortcuts_remove_reason_left_room) + ) + } + }.onFailure { + Timber.e(it, "Failed to remove shortcut for room $roomId in session $sessionId") + } + } + + override suspend fun onAvailableRoomsChanged(sessionId: SessionId, roomIds: Set) { + runCatchingExceptions { + val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context) + + val shortcutsToRemove = mutableListOf() + shortcuts.filter { it.id.startsWith(sessionId.value) } + .forEach { shortcut -> + val roomId = RoomId(shortcut.id.removePrefix("$sessionId-")) + if (!roomIds.contains(roomId)) { + shortcutsToRemove.add(shortcut.id) + } + } + + if (shortcutsToRemove.isNotEmpty()) { + ShortcutManagerCompat.removeDynamicShortcuts(context, shortcutsToRemove) + if (isRequestPinShortcutSupported) { + ShortcutManagerCompat.disableShortcuts( + context, + shortcutsToRemove, + context.getString(CommonStrings.common_android_shortcuts_remove_reason_left_room) + ) + } + } + }.onFailure { + Timber.e(it, "Failed to remove shortcuts for session $sessionId") + } + } + + private fun clearShortcuts() { + runCatchingExceptions { + ShortcutManagerCompat.removeAllDynamicShortcuts(context) + }.onFailure { + Timber.e(it, "Failed to clear all shortcuts") + } + } + + private fun onSessionLogOut(sessionId: SessionId) { + runCatchingExceptions { + val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context) + val shortcutIdsToRemove = shortcuts.filterBySession(sessionId).map { it.id } + ShortcutManagerCompat.removeDynamicShortcuts(context, shortcutIdsToRemove) + + if (isRequestPinShortcutSupported) { + ShortcutManagerCompat.disableShortcuts( + context, + shortcutIdsToRemove, + context.getString(CommonStrings.common_android_shortcuts_remove_reason_session_logged_out) + ) + } + }.onFailure { + Timber.e(it, "Failed to remove shortcuts for session $sessionId after logout") + } + } +} 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 0000000..7afe1b9 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/debug/DebugNotification.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/NotificationAccountParams.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationAccountParams.kt new file mode 100644 index 0000000..858f102 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationAccountParams.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.factories + +import androidx.annotation.ColorInt +import io.element.android.libraries.matrix.api.user.MatrixUser + +data class NotificationAccountParams( + val user: MatrixUser, + @ColorInt val color: Int, + val showSessionId: Boolean, +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt new file mode 100755 index 0000000..9533f6b --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt @@ -0,0 +1,518 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.factories + +import android.app.Notification +import android.content.Context +import android.graphics.Bitmap +import androidx.annotation.ColorInt +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.MessagingStyle +import androidx.core.app.Person +import androidx.core.os.bundleOf +import coil3.ImageLoader +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.meta.BuildMeta +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.CommonDrawables +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.timeline.item.event.EventType +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.push.api.notifications.NotificationBitmapLoader +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.AcceptInvitationActionFactory +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.factories.action.RejectInvitationActionFactory +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 io.element.android.libraries.push.impl.notifications.shortcut.createShortcutId +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.appnavstate.api.ROOM_OPENED_FROM_NOTIFICATION +import io.element.android.services.toolbox.api.strings.StringProvider + +interface NotificationCreator { + /** + * Create a notification for a Room. + */ + suspend fun createMessagesListNotification( + notificationAccountParams: NotificationAccountParams, + roomInfo: RoomEventGroupInfo, + threadId: ThreadId?, + largeIcon: Bitmap?, + lastMessageTimestamp: Long, + tickerText: String, + existingNotification: Notification?, + imageLoader: ImageLoader, + events: List, + ): Notification + + fun createRoomInvitationNotification( + notificationAccountParams: NotificationAccountParams, + inviteNotifiableEvent: InviteNotifiableEvent, + ): Notification + + fun createSimpleEventNotification( + notificationAccountParams: NotificationAccountParams, + simpleNotifiableEvent: SimpleNotifiableEvent, + ): Notification + + fun createFallbackNotification( + notificationAccountParams: NotificationAccountParams, + fallbackNotifiableEvent: FallbackNotifiableEvent, + ): Notification + + /** + * Create the summary notification. + */ + fun createSummaryListNotification( + notificationAccountParams: NotificationAccountParams, + compatSummary: String, + noisy: Boolean, + lastMessageTimestamp: Long, + ): Notification + + fun createDiagnosticNotification( + @ColorInt color: Int, + ): Notification + + fun createUnregistrationNotification( + notificationAccountParams: NotificationAccountParams, + ): Notification + + companion object { + /** + * Creates a tag for a message notification given its [roomId] and optional [threadId]. + */ + fun messageTag(roomId: RoomId, threadId: ThreadId?): String = if (threadId != null) { + "$roomId|$threadId" + } else { + roomId.value + } + } +} + +@ContributesBinding(AppScope::class) +class DefaultNotificationCreator( + @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, + private val bitmapLoader: NotificationBitmapLoader, + private val acceptInvitationActionFactory: AcceptInvitationActionFactory, + private val rejectInvitationActionFactory: RejectInvitationActionFactory, +) : NotificationCreator { + /** + * Create a notification for a Room. + */ + override suspend fun createMessagesListNotification( + notificationAccountParams: NotificationAccountParams, + roomInfo: RoomEventGroupInfo, + threadId: ThreadId?, + largeIcon: Bitmap?, + lastMessageTimestamp: Long, + tickerText: String, + existingNotification: Notification?, + imageLoader: ImageLoader, + events: List, + ): Notification { + // Build the pending intent for when the notification is clicked + val eventId = events.firstOrNull()?.eventId + val openIntent = when { + threadId != null -> pendingIntentFactory.createOpenThreadPendingIntent(roomInfo.sessionId, roomInfo.roomId, eventId, threadId) + else -> pendingIntentFactory.createOpenRoomPendingIntent( + sessionId = roomInfo.sessionId, + roomId = roomInfo.roomId, + eventId = eventId, + extras = bundleOf(ROOM_OPENED_FROM_NOTIFICATION to true), + ) + } + val containsMissedCall = events.any { it.type == EventType.RTC_NOTIFICATION } + val channelId = if (containsMissedCall) { + notificationChannels.getChannelForIncomingCall(false) + } else { + notificationChannels.getChannelIdForMessage(noisy = roomInfo.shouldBing) + } + // 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) + // If any of the events are of rtc notification type it means a missed call, set the category to the right value + val category = if (containsMissedCall) { + NotificationCompat.CATEGORY_MISSED_CALL + } else { + NotificationCompat.CATEGORY_MESSAGE + } + val builder = if (existingNotification != null) { + NotificationCompat.Builder(context, existingNotification) + // Clear existing actions + .clearActions() + } else { + NotificationCompat.Builder(context, channelId) + // ID of the corresponding shortcut, for conversation features under API 30+ + // Must match those created in the ShortcutInfoCompat.Builder() + // for the notification to appear as a "Conversation": + // https://developer.android.com/develop/ui/views/notifications/conversations + .apply { + if (threadId == null) { + setShortcutId(createShortcutId(roomInfo.sessionId, roomInfo.roomId)) + } + } + .setGroupSummary(false) + // In order to avoid notification making sound twice (due to the summary notification) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) + // Remove notification after opening it or using an action + .setAutoCancel(true) + } + val messagingStyle = existingNotification?.let { + MessagingStyle.extractMessagingStyleFromNotification(it) + } ?: createMessagingStyleFromCurrentUser( + user = notificationAccountParams.user, + imageLoader = imageLoader, + roomName = roomInfo.roomDisplayName, + isThread = threadId != null, + roomIsGroup = !roomInfo.isDm, + ) + messagingStyle.addMessagesFromEvents(events, imageLoader) + return builder + .setCategory(category) + .setNumber(events.size) + .setOnlyAlertOnce(roomInfo.isUpdated) + .setWhen(lastMessageTimestamp) + // MESSAGING_STYLE sets title and content for API 16 and above devices. + .setStyle(messagingStyle) + .configureWith(notificationAccountParams) + // Mark room/thread as read + .addAction(markAsReadActionFactory.create(roomInfo, threadId)) + .setContentIntent(openIntent) + .setLargeIcon(largeIcon) + .setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId)) + .apply { + // 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. + if (roomInfo.shouldBing) { + priority = NotificationCompat.PRIORITY_DEFAULT + setLights(notificationAccountParams.color, 500, 500) + } else { + priority = NotificationCompat.PRIORITY_LOW + } + // Quick reply + if (!roomInfo.hasSmartReplyError) { + val latestEventId = events.lastOrNull()?.eventId + addAction(quickReplyActionFactory.create(roomInfo, latestEventId, threadId)) + } + } + .setTicker(tickerText) + .build() + } + + override fun createRoomInvitationNotification( + notificationAccountParams: NotificationAccountParams, + inviteNotifiableEvent: InviteNotifiableEvent, + ): 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)) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) + .configureWith(notificationAccountParams) + .addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent)) + .addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent)) + // Build the pending intent for when the notification is clicked + .setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent( + sessionId = inviteNotifiableEvent.sessionId, + roomId = inviteNotifiableEvent.roomId, + eventId = null, + )) + .apply { + if (inviteNotifiableEvent.noisy) { + // Compat + priority = NotificationCompat.PRIORITY_DEFAULT + setLights(notificationAccountParams.color, 500, 500) + } else { + priority = NotificationCompat.PRIORITY_LOW + } + } + .setDeleteIntent( + pendingIntentFactory.createDismissInvitePendingIntent( + inviteNotifiableEvent.sessionId, + inviteNotifiableEvent.roomId, + ) + ) + .setAutoCancel(true) + .build() + } + + override fun createSimpleEventNotification( + notificationAccountParams: NotificationAccountParams, + simpleNotifiableEvent: SimpleNotifiableEvent, + ): Notification { + val channelId = notificationChannels.getChannelIdForMessage(simpleNotifiableEvent.noisy) + return NotificationCompat.Builder(context, channelId) + .setOnlyAlertOnce(true) + .setContentTitle(buildMeta.applicationName.annotateForDebug(7)) + .setContentText(simpleNotifiableEvent.description.annotateForDebug(8)) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) + .configureWith(notificationAccountParams) + .setAutoCancel(true) + .setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent( + sessionId = simpleNotifiableEvent.sessionId, + roomId = simpleNotifiableEvent.roomId, + eventId = null, + extras = bundleOf(ROOM_OPENED_FROM_NOTIFICATION to true), + )) + .apply { + if (simpleNotifiableEvent.noisy) { + // Compat + priority = NotificationCompat.PRIORITY_DEFAULT + setLights(notificationAccountParams.color, 500, 500) + } else { + priority = NotificationCompat.PRIORITY_LOW + } + } + .build() + } + + override fun createFallbackNotification( + notificationAccountParams: NotificationAccountParams, + fallbackNotifiableEvent: FallbackNotifiableEvent, + ): Notification { + val channelId = notificationChannels.getChannelIdForMessage(false) + return NotificationCompat.Builder(context, channelId) + .setOnlyAlertOnce(true) + .setContentTitle(buildMeta.applicationName.annotateForDebug(7)) + .setContentText(fallbackNotifiableEvent.description.orEmpty().annotateForDebug(8)) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) + .configureWith(notificationAccountParams) + .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 + ) + ) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + + /** + * Create the summary notification. + */ + override fun createSummaryListNotification( + notificationAccountParams: NotificationAccountParams, + compatSummary: String, + noisy: Boolean, + lastMessageTimestamp: Long, + ): Notification { + val channelId = notificationChannels.getChannelIdForMessage(noisy) + val userId = notificationAccountParams.user.userId + return NotificationCompat.Builder(context, channelId) + .setOnlyAlertOnce(true) + // used in compat < N, after summary is built based on child notifications + .setWhen(lastMessageTimestamp) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + // set this notification as the summary for the group + .setGroupSummary(true) + .configureWith(notificationAccountParams) + .apply { + if (noisy) { + // Compat + priority = NotificationCompat.PRIORITY_DEFAULT + setLights(notificationAccountParams.color, 500, 500) + } else { + // compat + priority = NotificationCompat.PRIORITY_LOW + } + } + .setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(userId)) + .setDeleteIntent(pendingIntentFactory.createDismissSummaryPendingIntent(userId)) + .build() + } + + override fun createDiagnosticNotification( + @ColorInt color: Int, + ): Notification { + val intent = pendingIntentFactory.createTestPendingIntent() + return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest()) + .setContentTitle(buildMeta.applicationName) + .setContentText(stringProvider.getString(R.string.notification_test_push_notification_content)) + .setSmallIcon(CommonDrawables.ic_notification) + .setColor(color) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setAutoCancel(true) + .setContentIntent(intent) + .setDeleteIntent(intent) + .build() + } + + override fun createUnregistrationNotification( + notificationAccountParams: NotificationAccountParams, + ): Notification { + val userId = notificationAccountParams.user.userId + val text = stringProvider.getString(R.string.notification_error_unified_push_unregistered_android) + return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest()) + .setSubText(userId.value) + // The text is long and can be truncated so use BigTextStyle. + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setContentTitle(stringProvider.getString(CommonStrings.dialog_title_warning)) + .setContentText(text) + .configureWith(notificationAccountParams) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setAutoCancel(true) + .setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(userId)) + .build() + } + + private suspend fun MessagingStyle.addMessagesFromEvents( + events: List, + imageLoader: ImageLoader, + ) { + events.forEach { event -> + val senderPerson = if (event.outGoingMessage) { + null + } else { + val senderName = event.senderDisambiguatedDisplayName.orEmpty() + // If the notification is for a mention or reply, we create a fake `Person` with a custom name and key + val displayName = if (event.hasMentionOrReply) { + stringProvider.getString(R.string.notification_sender_mention_reply, senderName) + } else { + senderName + } + val key = if (event.hasMentionOrReply) { + "mention-or-reply:${event.eventId.value}" + } else { + event.senderId.value + } + Person.Builder() + .setName(displayName.annotateForDebug(70)) + .setIcon( + bitmapLoader.getUserIcon( + avatarData = AvatarData( + id = event.senderId.value, + name = senderName, + url = event.senderAvatarPath, + size = AvatarSize.UserHeader, + ), + imageLoader = imageLoader, + ) + ) + .setKey(key) + .build() + } + when { + event.isSmartReplyError() -> addMessage( + stringProvider.getString(R.string.notification_inline_reply_failed), + event.timestamp, + senderPerson + ) + else -> { + if (event.imageMimeType != null && event.imageUri != null) { + // Image case + val message = MessagingStyle.Message( + // This text will not be rendered, but some systems does not render the image + // if the text is null + stringProvider.getString(CommonStrings.common_image), + event.timestamp, + senderPerson, + ) + .setData(event.imageMimeType, event.imageUri) + message.extras.putString(MESSAGE_EVENT_ID, event.eventId.value) + addMessage(message) + // Add additional message for captions + if (event.body != null) { + addMessage( + MessagingStyle.Message( + event.body.annotateForDebug(72), + event.timestamp, + senderPerson, + ) + ) + } + } else { + // Text case + val message = MessagingStyle.Message( + event.body?.annotateForDebug(71), + event.timestamp, + senderPerson + ) + message.extras.putString(MESSAGE_EVENT_ID, event.eventId.value) + addMessage(message) + } + } + } + } + } + + private suspend fun createMessagingStyleFromCurrentUser( + user: MatrixUser, + imageLoader: ImageLoader, + roomName: String, + isThread: Boolean, + roomIsGroup: Boolean + ): MessagingStyle { + return MessagingStyle( + Person.Builder() + // Note: name cannot be empty else NotificationCompat.MessagingStyle() will crash + .setName(user.getBestName().annotateForDebug(50)) + .setIcon( + bitmapLoader.getUserIcon( + avatarData = user.getAvatarData(AvatarSize.UserHeader), + imageLoader = imageLoader, + ) + ) + .setKey(user.userId.value) + .build() + ).also { + it.conversationTitle = if (isThread) { + stringProvider.getString(R.string.notification_thread_in_room, roomName) + } else { + roomName + } + // So the avatar is displayed even if they're part of a conversation + it.isGroupConversation = roomIsGroup || isThread + } + } + + companion object { + const val MESSAGE_EVENT_ID = "message_event_id" + } +} + +private fun NotificationCompat.Builder.configureWith(notificationAccountParams: NotificationAccountParams) = apply { + setSmallIcon(CommonDrawables.ic_notification) + setColor(notificationAccountParams.color) + setGroup(notificationAccountParams.user.userId.value) + if (notificationAccountParams.showSessionId) { + setSubText(notificationAccountParams.user.userId.value) + } +} + +fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed 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 0000000..07fa70a --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/PendingIntentFactory.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.factories + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Bundle +import dev.zacsweers.metro.Inject +import io.element.android.libraries.androidutils.uri.createIgnoredUri +import io.element.android.libraries.di.annotations.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.TestNotificationReceiver +import io.element.android.services.toolbox.api.systemclock.SystemClock + +@Inject +class PendingIntentFactory( + @ApplicationContext private val context: Context, + private val intentProvider: IntentProvider, + private val clock: SystemClock, + private val actionIds: NotificationActionIds, +) { + fun createOpenSessionPendingIntent(sessionId: SessionId, extras: Bundle? = null): PendingIntent? { + return createRoomPendingIntent(sessionId = sessionId, roomId = null, eventId = null, threadId = null, extras = extras) + } + + fun createOpenRoomPendingIntent(sessionId: SessionId, roomId: RoomId, eventId: EventId?, extras: Bundle? = null): PendingIntent? { + return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, eventId = eventId, threadId = null, extras = extras) + } + + fun createOpenThreadPendingIntent(sessionId: SessionId, roomId: RoomId, eventId: EventId?, threadId: ThreadId, extras: Bundle? = null): PendingIntent? { + return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, eventId = eventId, threadId = threadId, extras = extras) + } + + private fun createRoomPendingIntent(sessionId: SessionId, roomId: RoomId?, eventId: EventId?, threadId: ThreadId?, extras: Bundle? = null): PendingIntent? { + val intent = intentProvider.getViewRoomIntent(sessionId = sessionId, roomId = roomId, eventId = eventId, threadId = threadId, extras = extras) + 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 + ) + } +} 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 0000000..c951bbd --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/AcceptInvitationActionFactory.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Inject +import io.element.android.appconfig.NotificationConfig +import io.element.android.libraries.androidutils.uri.createIgnoredUri +import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.di.annotations.ApplicationContext +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.libraries.ui.strings.CommonStrings +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.api.systemclock.SystemClock + +@Inject +class AcceptInvitationActionFactory( + @ApplicationContext private val context: Context, + private val actionIds: NotificationActionIds, + private val stringProvider: StringProvider, + private val clock: SystemClock, +) { + fun create(inviteNotifiableEvent: InviteNotifiableEvent): NotificationCompat.Action? { + if (!NotificationConfig.SHOW_ACCEPT_AND_DECLINE_INVITE_ACTIONS) return null + 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( + CompoundDrawables.ic_compound_check, + stringProvider.getString(CommonStrings.action_accept), + 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 0000000..599fcb5 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/MarkAsReadActionFactory.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Inject +import io.element.android.appconfig.NotificationConfig +import io.element.android.libraries.androidutils.uri.createIgnoredUri +import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.core.ThreadId +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 + +@Inject +class MarkAsReadActionFactory( + @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.SHOW_MARK_AS_READ_ACTION) 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" + threadId?.let { "/$it" }.orEmpty()) + intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId) + intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId) + threadId?.let { intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, threadId.value) } + val pendingIntent = PendingIntent.getBroadcast( + context, + clock.epochMillis().toInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Action.Builder( + CompoundDrawables.ic_compound_mark_as_read, + 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 0000000..c037adf --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Inject +import io.element.android.appconfig.NotificationConfig +import io.element.android.libraries.androidutils.uri.createIgnoredUri +import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.di.annotations.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.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 + +@Inject +class QuickReplyActionFactory( + @ApplicationContext private val context: Context, + private val actionIds: NotificationActionIds, + private val stringProvider: StringProvider, + private val clock: SystemClock, +) { + fun create(roomInfo: RoomEventGroupInfo, eventId: EventId?, threadId: ThreadId?): NotificationCompat.Action? { + if (!NotificationConfig.SHOW_QUICK_REPLY_ACTION) return null + val sessionId = roomInfo.sessionId + val roomId = roomInfo.roomId + val replyPendingIntent = buildQuickReplyIntent(sessionId, roomId, eventId, threadId) + val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY) + .setLabel(stringProvider.getString(R.string.notification_room_action_quick_reply)) + .build() + + return NotificationCompat.Action.Builder( + CompoundDrawables.ic_compound_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, + eventId: EventId?, + threadId: ThreadId?, + ): PendingIntent { + 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) + eventId?.let { intent.putExtra(NotificationBroadcastReceiver.KEY_EVENT_ID, it.value) } + threadId?.let { intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it.value) } + + return 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 + } + ) + } +} 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 0000000..94ff565 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/RejectInvitationActionFactory.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 dev.zacsweers.metro.Inject +import io.element.android.appconfig.NotificationConfig +import io.element.android.libraries.androidutils.uri.createIgnoredUri +import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.di.annotations.ApplicationContext +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.libraries.ui.strings.CommonStrings +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.api.systemclock.SystemClock + +@Inject +class RejectInvitationActionFactory( + @ApplicationContext private val context: Context, + private val actionIds: NotificationActionIds, + private val stringProvider: StringProvider, + private val clock: SystemClock, +) { + fun create(inviteNotifiableEvent: InviteNotifiableEvent): NotificationCompat.Action? { + if (!NotificationConfig.SHOW_ACCEPT_AND_DECLINE_INVITE_ACTIONS) return null + 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( + CompoundDrawables.ic_compound_close, + stringProvider.getString(CommonStrings.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 0000000..97f66fa --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/FallbackNotifiableEvent.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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, + val cause: String?, +) : 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 0000000..91d5230 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/InviteNotifiableEvent.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +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 0000000..4a26b9f --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 + +/** + * Parent interface for all events which can be displayed as a Notification. + */ +sealed interface NotifiableEvent { + 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 0000000..07f7f8b --- /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) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +package io.element.android.libraries.push.impl.notifications.model + +import android.net.Uri +import androidx.core.net.toUri +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.core.UserId +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 senderId: UserId, + val noisy: Boolean, + val timestamp: Long, + val senderDisambiguatedDisplayName: String?, + val body: String?, + // We cannot use Uri? type here, as that could trigger a + // NotSerializableException when persisting this to storage + private val imageUriString: String?, + val imageMimeType: String?, + val threadId: ThreadId?, + val roomName: String?, + val roomIsDm: 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, + val type: String = EventType.MESSAGE, + val hasMentionOrReply: Boolean = false, +) : NotifiableEvent { + override val description: String = body ?: "" + + // Example of value: + // content://io.element.android.x.debug.notifications.fileprovider/downloads/temp/notif/matrix.org/XGItzSDOnSyXjYtOPfiKexDJ + val imageUri: Uri? + get() = imageUriString?.toUri() +} + +/** + * 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 -> { + // Never ignore ringing call notifications + if (this is NotifiableRingingCallEvent) { + 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/NotifiableRingingCallEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableRingingCallEvent.kt new file mode 100644 index 0000000..35432e9 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableRingingCallEvent.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.notification.RtcNotificationType + +data class NotifiableRingingCallEvent( + 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 roomName: String?, + val senderId: UserId, + val senderDisambiguatedDisplayName: String?, + val senderAvatarUrl: String?, + val roomAvatarUrl: String? = null, + val rtcNotificationType: RtcNotificationType, + val timestamp: Long, + val expirationTimestamp: Long, +) : NotifiableEvent diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/ResolvedPushEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/ResolvedPushEvent.kt new file mode 100644 index 0000000..3c79896 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/ResolvedPushEvent.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 + +sealed interface ResolvedPushEvent { + val sessionId: SessionId + val roomId: RoomId + val eventId: EventId + + data class Event(val notifiableEvent: NotifiableEvent) : ResolvedPushEvent { + override val sessionId: SessionId = notifiableEvent.sessionId + override val roomId: RoomId = notifiableEvent.roomId + override val eventId: EventId = notifiableEvent.eventId + } + + data class Redaction( + override val sessionId: SessionId, + override val roomId: RoomId, + val redactedEventId: EventId, + val reason: String?, + ) : ResolvedPushEvent { + override val eventId: EventId = redactedEventId + } +} 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 0000000..c1c2ce9 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +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 val 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/notifications/shortcut/Utils.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/shortcut/Utils.kt new file mode 100644 index 0000000..4a09c28 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/shortcut/Utils.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.shortcut + +import androidx.core.content.pm.ShortcutInfoCompat +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId + +internal fun createShortcutId(sessionId: SessionId, roomId: RoomId) = "$sessionId-$roomId" + +internal fun Iterable.filterBySession(sessionId: SessionId): Iterable { + val prefix = "$sessionId-" + return filter { it.id.startsWith(prefix) } +} 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 0000000..7aec719 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt @@ -0,0 +1,293 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.push + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.features.call.api.CallType +import io.element.android.features.call.api.ElementCallEntryPoint +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.exception.NotificationResolverException +import io.element.android.libraries.push.api.push.NotificationEventRequest +import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent +import io.element.android.libraries.push.impl.history.PushHistoryService +import io.element.android.libraries.push.impl.history.onDiagnosticPush +import io.element.android.libraries.push.impl.history.onInvalidPushReceived +import io.element.android.libraries.push.impl.history.onSuccess +import io.element.android.libraries.push.impl.history.onUnableToResolveEvent +import io.element.android.libraries.push.impl.history.onUnableToRetrieveSession +import io.element.android.libraries.push.impl.notifications.FallbackNotificationFactory +import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue +import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels +import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent +import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.libraries.push.impl.test.DefaultTestPush +import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler +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.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import timber.log.Timber + +private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag) + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultPushHandler( + private val onNotifiableEventReceived: OnNotifiableEventReceived, + private val onRedactedEventReceived: OnRedactedEventReceived, + private val incrementPushDataStore: IncrementPushDataStore, + private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore, + private val userPushStoreFactory: UserPushStoreFactory, + private val pushClientSecret: PushClientSecret, + private val buildMeta: BuildMeta, + private val diagnosticPushHandler: DiagnosticPushHandler, + private val elementCallEntryPoint: ElementCallEntryPoint, + private val notificationChannels: NotificationChannels, + private val pushHistoryService: PushHistoryService, + private val resolverQueue: NotificationResolverQueue, + @AppCoroutineScope + private val appCoroutineScope: CoroutineScope, + private val fallbackNotificationFactory: FallbackNotificationFactory, + private val syncOnNotifiableEvent: SyncOnNotifiableEvent, + private val featureFlagService: FeatureFlagService, +) : PushHandler { + init { + processPushEventResults() + } + + /** + * Process the push notification event results emitted by the [resolverQueue]. + */ + private fun processPushEventResults() { + resolverQueue.results + .map { (requests, resolvedEvents) -> + for (request in requests) { + // Log the result of the push notification event + val result = resolvedEvents[request] + if (result == null) { + pushHistoryService.onUnableToResolveEvent( + providerInfo = request.providerInfo, + eventId = request.eventId, + roomId = request.roomId, + sessionId = request.sessionId, + reason = "Push not handled: no result found for request", + ) + } else { + result.fold( + onSuccess = { + if (it is ResolvedPushEvent.Event && it.notifiableEvent is FallbackNotifiableEvent) { + pushHistoryService.onUnableToResolveEvent( + providerInfo = request.providerInfo, + eventId = request.eventId, + roomId = request.roomId, + sessionId = request.sessionId, + reason = it.notifiableEvent.cause.orEmpty(), + ) + } else { + pushHistoryService.onSuccess( + providerInfo = request.providerInfo, + eventId = request.eventId, + roomId = request.roomId, + sessionId = request.sessionId, + comment = "Push handled successfully", + ) + } + }, + onFailure = { exception -> + if (exception is NotificationResolverException.EventFilteredOut) { + pushHistoryService.onSuccess( + providerInfo = request.providerInfo, + eventId = request.eventId, + roomId = request.roomId, + sessionId = request.sessionId, + comment = "Push handled successfully but notification was filtered out", + ) + } else { + val reason = when (exception) { + is NotificationResolverException.EventNotFound -> "Event not found" + else -> "Unknown error: ${exception.message}" + } + pushHistoryService.onUnableToResolveEvent( + providerInfo = request.providerInfo, + eventId = request.eventId, + roomId = request.roomId, + sessionId = request.sessionId, + reason = "$reason - Showing fallback notification", + ) + mutableBatteryOptimizationStore.showBatteryOptimizationBanner() + } + } + ) + } + } + + val events = mutableListOf() + val redactions = mutableListOf() + + @Suppress("LoopWithTooManyJumpStatements") + for ((request, result) in resolvedEvents) { + val event = result.recover { exception -> + // If the event could not be resolved, we create a fallback notification + when (exception) { + is NotificationResolverException.EventFilteredOut -> { + // Do nothing, we don't want to show a notification for filtered out events + null + } + else -> { + Timber.tag(loggerTag.value).e(exception, "Failed to resolve push event") + ResolvedPushEvent.Event( + fallbackNotificationFactory.create( + sessionId = request.sessionId, + roomId = request.roomId, + eventId = request.eventId, + cause = exception.message, + ) + ) + } + } + }.getOrNull() ?: continue + + val userPushStore = userPushStoreFactory.getOrCreate(event.sessionId) + val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first() + // If notifications are disabled for this session and device, we don't want to show the notification + // But if it's a ringing call, we want to show it anyway + val isRingingCall = (event as? ResolvedPushEvent.Event)?.notifiableEvent is NotifiableRingingCallEvent + if (!areNotificationsEnabled && !isRingingCall) continue + + // We categorise each result into either a NotifiableEvent or a Redaction + when (event) { + is ResolvedPushEvent.Event -> { + events.add(event.notifiableEvent) + } + is ResolvedPushEvent.Redaction -> { + redactions.add(event) + } + } + } + + // Process redactions of messages in background to not block operations with higher priority + if (redactions.isNotEmpty()) { + appCoroutineScope.launch { onRedactedEventReceived.onRedactedEventsReceived(redactions) } + } + + // Find and process ringing call notifications separately + val (ringingCallEvents, nonRingingCallEvents) = events.partition { it is NotifiableRingingCallEvent } + for (ringingCallEvent in ringingCallEvents) { + Timber.tag(loggerTag.value).d("Ringing call event: $ringingCallEvent") + handleRingingCallEvent(ringingCallEvent as NotifiableRingingCallEvent) + } + + // Finally, process other notifications (messages, invites, generic notifications, etc.) + if (nonRingingCallEvents.isNotEmpty()) { + onNotifiableEventReceived.onNotifiableEventsReceived(nonRingingCallEvents) + } + + if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) { + syncOnNotifiableEvent(requests) + } + } + .launchIn(appCoroutineScope) + } + + /** + * Called when message is received. + * + * @param pushData the data received in the push. + * @param providerInfo the provider info. + */ + override suspend fun handle(pushData: PushData, providerInfo: String) { + Timber.tag(loggerTag.value).d("## handling pushData: ${pushData.roomId}/${pushData.eventId}") + if (buildMeta.lowPrivacyLoggingEnabled) { + Timber.tag(loggerTag.value).d("## pushData: $pushData") + } + incrementPushDataStore.incrementPushCounter() + // Diagnostic Push + if (pushData.eventId == DefaultTestPush.TEST_EVENT_ID) { + pushHistoryService.onDiagnosticPush(providerInfo) + diagnosticPushHandler.handlePush() + } else { + handleInternal(pushData, providerInfo) + } + } + + override suspend fun handleInvalid(providerInfo: String, data: String) { + incrementPushDataStore.incrementPushCounter() + pushHistoryService.onInvalidPushReceived(providerInfo, data) + } + + /** + * Internal receive method. + * + * @param pushData Object containing message data. + * @param providerInfo the provider info. + */ + private suspend fun handleInternal(pushData: PushData, providerInfo: String) { + try { + if (buildMeta.lowPrivacyLoggingEnabled) { + Timber.tag(loggerTag.value).d("## handleInternal() : $pushData") + } else { + Timber.tag(loggerTag.value).d("## handleInternal()") + } + // Get userId from client secret + val userId = pushClientSecret.getUserIdFromSecret(pushData.clientSecret) + if (userId == null) { + Timber.w("Unable to get userId from client secret") + pushHistoryService.onUnableToRetrieveSession( + providerInfo = providerInfo, + eventId = pushData.eventId, + roomId = pushData.roomId, + reason = "Unable to get userId from client secret", + ) + return + } + + appCoroutineScope.launch { + val notificationEventRequest = NotificationEventRequest( + sessionId = userId, + roomId = pushData.roomId, + eventId = pushData.eventId, + providerInfo = providerInfo, + ) + Timber.d("Queueing notification: $notificationEventRequest") + resolverQueue.enqueue(notificationEventRequest) + } + } catch (e: Exception) { + Timber.tag(loggerTag.value).e(e, "## handleInternal() failed") + } + } + + private suspend fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) { + Timber.i("## handleInternal() : Incoming call.") + elementCallEntryPoint.handleIncomingCall( + callType = CallType.RoomCall(notifiableEvent.sessionId, notifiableEvent.roomId), + eventId = notifiableEvent.eventId, + senderId = notifiableEvent.senderId, + roomName = notifiableEvent.roomName, + senderName = notifiableEvent.senderDisambiguatedDisplayName, + avatarUrl = notifiableEvent.roomAvatarUrl, + timestamp = notifiableEvent.timestamp, + expirationTimestamp = notifiableEvent.expirationTimestamp, + notificationChannelId = notificationChannels.getChannelForIncomingCall(ring = true), + textContent = notifiableEvent.description, + ) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultSyncOnNotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultSyncOnNotifiableEvent.kt new file mode 100644 index 0000000..8b8e671 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultSyncOnNotifiableEvent.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.push + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.push.api.push.NotificationEventRequest +import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent +import io.element.android.services.appnavstate.api.AppForegroundStateService +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import timber.log.Timber +import kotlin.time.Duration.Companion.seconds + +@ContributesBinding(AppScope::class) +class DefaultSyncOnNotifiableEvent( + private val matrixClientProvider: MatrixClientProvider, + private val featureFlagService: FeatureFlagService, + private val appForegroundStateService: AppForegroundStateService, + private val dispatchers: CoroutineDispatchers, +) : SyncOnNotifiableEvent { + override suspend operator fun invoke(requests: List) = withContext(dispatchers.io) { + if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncOnPush)) { + return@withContext + } + + try { + val eventsBySession = requests.groupBy { it.sessionId } + + appForegroundStateService.updateIsSyncingNotificationEvent(true) + Timber.d("Starting opportunistic room list sync | In foreground: ${appForegroundStateService.isInForeground.value}") + + for ((sessionId, events) in eventsBySession) { + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: continue + val roomIds = events.map { it.roomId }.distinct() + + client.roomListService.subscribeToVisibleRooms(roomIds) + + if (!appForegroundStateService.isInForeground.value) { + // Give the sync some time to complete in background + delay(10.seconds) + } + } + } finally { + Timber.d("Finished opportunistic room list sync") + appForegroundStateService.updateIsSyncingNotificationEvent(false) + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/IncrementPushDataStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/IncrementPushDataStore.kt new file mode 100644 index 0000000..0a6467f --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/IncrementPushDataStore.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.push + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.push.impl.store.DefaultPushDataStore + +interface IncrementPushDataStore { + suspend fun incrementPushCounter() +} + +@ContributesBinding(AppScope::class) +class DefaultIncrementPushDataStore( + private val defaultPushDataStore: DefaultPushDataStore +) : IncrementPushDataStore { + override suspend fun incrementPushCounter() { + defaultPushDataStore.incrementPushCounter() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/MutableBatteryOptimizationStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/MutableBatteryOptimizationStore.kt new file mode 100644 index 0000000..0cb0d94 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/MutableBatteryOptimizationStore.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.push + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.push.impl.store.DefaultPushDataStore + +interface MutableBatteryOptimizationStore { + suspend fun showBatteryOptimizationBanner() + suspend fun onOptimizationBannerDismissed() + suspend fun reset() +} + +@ContributesBinding(AppScope::class) +class DefaultMutableBatteryOptimizationStore( + private val defaultPushDataStore: DefaultPushDataStore, +) : MutableBatteryOptimizationStore { + override suspend fun showBatteryOptimizationBanner() { + defaultPushDataStore.setBatteryOptimizationBannerState(DefaultPushDataStore.BATTERY_OPTIMIZATION_BANNER_STATE_SHOW) + } + + override suspend fun onOptimizationBannerDismissed() { + defaultPushDataStore.setBatteryOptimizationBannerState(DefaultPushDataStore.BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED) + } + + override suspend fun reset() { + defaultPushDataStore.setBatteryOptimizationBannerState(DefaultPushDataStore.BATTERY_OPTIMIZATION_BANNER_STATE_INIT) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt new file mode 100644 index 0000000..2c4339d --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.push + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +interface OnNotifiableEventReceived { + fun onNotifiableEventsReceived(notifiableEvents: List) +} + +@ContributesBinding(AppScope::class) +class DefaultOnNotifiableEventReceived( + private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager, + @AppCoroutineScope + private val coroutineScope: CoroutineScope, +) : OnNotifiableEventReceived { + override fun onNotifiableEventsReceived(notifiableEvents: List) { + coroutineScope.launch { + defaultNotificationDrawerManager.onNotifiableEventsReceived(notifiableEvents.filter { it !is NotifiableRingingCallEvent }) + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnRedactedEventReceived.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnRedactedEventReceived.kt new file mode 100644 index 0000000..18388ba --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnRedactedEventReceived.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.push + +import android.content.Context +import android.graphics.Typeface +import android.text.style.StyleSpan +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.MessagingStyle +import androidx.core.text.buildSpannedString +import androidx.core.text.inSpans +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.push.impl.notifications.ActiveNotificationsProvider +import io.element.android.libraries.push.impl.notifications.NotificationDisplayer +import io.element.android.libraries.push.impl.notifications.factories.DefaultNotificationCreator +import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.toolbox.api.strings.StringProvider +import timber.log.Timber + +interface OnRedactedEventReceived { + suspend fun onRedactedEventsReceived(redactions: List) +} + +@ContributesBinding(AppScope::class) +class DefaultOnRedactedEventReceived( + private val activeNotificationsProvider: ActiveNotificationsProvider, + private val notificationDisplayer: NotificationDisplayer, + @ApplicationContext private val context: Context, + private val stringProvider: StringProvider, +) : OnRedactedEventReceived { + override suspend fun onRedactedEventsReceived(redactions: List) { + val redactionsBySessionIdAndRoom = redactions.groupBy { redaction -> + redaction.sessionId to redaction.roomId + } + for ((keys, roomRedactions) in redactionsBySessionIdAndRoom) { + val (sessionId, roomId) = keys + // Get all notifications for the room, including those for threads + val notifications = activeNotificationsProvider.getAllMessageNotificationsForRoom(sessionId, roomId) + if (notifications.isEmpty()) { + Timber.d("No notifications found for redacted event") + } + notifications.forEach { statusBarNotification -> + val notification = statusBarNotification.notification + val messagingStyle = MessagingStyle.extractMessagingStyleFromNotification(notification) + if (messagingStyle == null) { + Timber.w("Unable to retrieve messaging style from notification") + return@forEach + } + val messageToRedactIndex = messagingStyle.messages.indexOfFirst { message -> + roomRedactions.any { it.redactedEventId.value == message.extras.getString(DefaultNotificationCreator.MESSAGE_EVENT_ID) } + } + if (messageToRedactIndex == -1) { + Timber.d("Unable to find the message to remove from notification") + return@forEach + } + val oldMessage = messagingStyle.messages[messageToRedactIndex] + val content = buildSpannedString { + inSpans(StyleSpan(Typeface.ITALIC)) { + append(stringProvider.getString(CommonStrings.common_message_removed)) + } + } + val newMessage = MessagingStyle.Message( + content, + oldMessage.timestamp, + oldMessage.person + ) + messagingStyle.messages[messageToRedactIndex] = newMessage + notificationDisplayer.showNotification( + statusBarNotification.tag, + statusBarNotification.id, + NotificationCompat.Builder(context, notification) + .setStyle(messagingStyle) + .build() + ) + } + } + } +} 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 0000000..3458687 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +package io.element.android.libraries.push.impl.pushgateway + +import retrofit2.http.Body +import retrofit2.http.POST + +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/PushGatewayApiFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayApiFactory.kt new file mode 100644 index 0000000..9e1d370 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayApiFactory.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.pushgateway + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.network.RetrofitFactory + +interface PushGatewayApiFactory { + fun create(baseUrl: String): PushGatewayAPI +} + +@ContributesBinding(AppScope::class) +class DefaultPushGatewayApiFactory( + private val retrofitFactory: RetrofitFactory, +) : PushGatewayApiFactory { + override fun create(baseUrl: String): PushGatewayAPI { + return retrofitFactory.create(baseUrl) + .create(PushGatewayAPI::class.java) + } +} 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 0000000..8cdf0e6 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayConfig.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..20a9e3e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.pushgateway + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +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, + /** Optional. Additional pusher data. */ + @SerialName("data") + val data: PusherData? = null, +) + +@Serializable +data class PusherData( + @SerialName("default_payload") + val defaultPayload: Map, +) 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 0000000..a7e4438 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.pushgateway + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PushGatewayNotification( + @SerialName("event_id") + val eventId: String, + @SerialName("room_id") + val roomId: String, + /** + * Required. This is an array of devices that the notification should be sent to. + */ + @SerialName("devices") + val devices: List, +) 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 0000000..6848ca3 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.pushgateway + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +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 0000000..6315285 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +package io.element.android.libraries.push.impl.pushgateway + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.push.api.gateway.PushGatewayFailure + +interface PushGatewayNotifyRequest { + data class Params( + val url: String, + val appId: String, + val pushKey: String, + val eventId: EventId, + val roomId: RoomId, + ) + + suspend fun execute(params: Params) +} + +@ContributesBinding(AppScope::class) +class DefaultPushGatewayNotifyRequest( + private val pushGatewayApiFactory: PushGatewayApiFactory, +) : PushGatewayNotifyRequest { + override suspend fun execute(params: PushGatewayNotifyRequest.Params) { + val pushGatewayApi = pushGatewayApiFactory.create( + params.url.substringBefore(PushGatewayConfig.URI_PUSH_GATEWAY_PREFIX_PATH) + ) + val response = pushGatewayApi.notify( + PushGatewayNotifyBody( + PushGatewayNotification( + eventId = params.eventId.value, + roomId = params.roomId.value, + devices = listOf( + PushGatewayDevice( + params.appId, + params.pushKey, + PusherData(mapOf( + "cs" to "A_FAKE_SECRET", + )) + ) + ), + ) + ) + ) + + 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 0000000..9c2f682 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.pushgateway + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PushGatewayNotifyResponse( + @SerialName("rejected") + val rejectedPushKeys: List +) 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 0000000..170b8e8 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.store + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.dateformatter.api.DateFormatter +import io.element.android.libraries.dateformatter.api.DateFormatterMode +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.preferences.api.store.PreferenceDataStoreFactory +import io.element.android.libraries.push.api.history.PushHistoryItem +import io.element.android.libraries.push.impl.PushDatabase +import io.element.android.libraries.push.impl.store.DefaultPushDataStore.Companion.BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED +import io.element.android.libraries.push.impl.store.DefaultPushDataStore.Companion.BATTERY_OPTIMIZATION_BANNER_STATE_INIT +import io.element.android.libraries.push.impl.store.DefaultPushDataStore.Companion.BATTERY_OPTIMIZATION_BANNER_STATE_SHOW +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +@ContributesBinding(AppScope::class) +class DefaultPushDataStore( + private val pushDatabase: PushDatabase, + private val dateFormatter: DateFormatter, + private val dispatchers: CoroutineDispatchers, + preferencesFactory: PreferenceDataStoreFactory, +) : PushDataStore { + private val pushCounter = intPreferencesKey("push_counter") + + private val dataStore = preferencesFactory.create("push_store") + + /** + * Integer preference to track the state of the battery optimization banner. + * Possible values: + * [BATTERY_OPTIMIZATION_BANNER_STATE_INIT]: Should not show the banner + * [BATTERY_OPTIMIZATION_BANNER_STATE_SHOW]: Should show the banner + * [BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED]: Banner has been shown and user has dismissed it + */ + private val batteryOptimizationBannerState = intPreferencesKey("battery_optimization_banner_state") + + override val pushCounterFlow: Flow = dataStore.data.map { preferences -> + preferences[pushCounter] ?: 0 + } + + @Suppress("UnnecessaryParentheses") + override val shouldDisplayBatteryOptimizationBannerFlow: Flow = dataStore.data.map { preferences -> + (preferences[batteryOptimizationBannerState] ?: BATTERY_OPTIMIZATION_BANNER_STATE_INIT) == BATTERY_OPTIMIZATION_BANNER_STATE_SHOW + } + + suspend fun incrementPushCounter() { + dataStore.edit { settings -> + val currentCounterValue = settings[pushCounter] ?: 0 + settings[pushCounter] = currentCounterValue + 1 + } + } + + suspend fun setBatteryOptimizationBannerState(newState: Int) { + dataStore.edit { settings -> + val currentValue = settings[batteryOptimizationBannerState] ?: BATTERY_OPTIMIZATION_BANNER_STATE_INIT + settings[batteryOptimizationBannerState] = when (currentValue) { + BATTERY_OPTIMIZATION_BANNER_STATE_INIT, + BATTERY_OPTIMIZATION_BANNER_STATE_SHOW -> newState + BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED -> currentValue + else -> error("Invalid value for showBatteryOptimizationBanner: $currentValue") + } + } + } + + override fun getPushHistoryItemsFlow(): Flow> { + return pushDatabase.pushHistoryQueries.selectAll() + .asFlow() + .mapToList(dispatchers.io) + .map { items -> + items.map { pushHistory -> + PushHistoryItem( + pushDate = pushHistory.pushDate, + formattedDate = dateFormatter.format( + timestamp = pushHistory.pushDate, + mode = DateFormatterMode.Full, + useRelative = false, + ), + providerInfo = pushHistory.providerInfo, + eventId = pushHistory.eventId?.let { EventId(it) }, + roomId = pushHistory.roomId?.let { RoomId(it) }, + sessionId = pushHistory.sessionId?.let { SessionId(it) }, + hasBeenResolved = pushHistory.hasBeenResolved == 1L, + comment = pushHistory.comment, + ) + } + } + } + + override suspend fun reset() { + pushDatabase.pushHistoryQueries.removeAll() + dataStore.edit { + it.clear() + } + } + + companion object { + const val BATTERY_OPTIMIZATION_BANNER_STATE_INIT = 0 + const val BATTERY_OPTIMIZATION_BANNER_STATE_SHOW = 1 + const val BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED = 2 + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/PushDataStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/PushDataStore.kt new file mode 100644 index 0000000..a8ea6cc --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/PushDataStore.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.store + +import io.element.android.libraries.push.api.history.PushHistoryItem +import kotlinx.coroutines.flow.Flow + +interface PushDataStore { + val shouldDisplayBatteryOptimizationBannerFlow: Flow + val pushCounterFlow: Flow + + /** + * Get a flow of list of [PushHistoryItem]. + */ + fun getPushHistoryItemsFlow(): Flow> + + /** + * Reset the push counter to 0, and clear the database. + */ + suspend fun reset() +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt new file mode 100644 index 0000000..4ed8ca8 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.test + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.appconfig.PushConfig +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest +import io.element.android.libraries.pushproviders.api.Config + +interface TestPush { + suspend fun execute(config: Config) +} + +@ContributesBinding(AppScope::class) +class DefaultTestPush( + private val pushGatewayNotifyRequest: PushGatewayNotifyRequest, +) : TestPush { + override suspend fun execute(config: Config) { + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = config.url, + appId = PushConfig.PUSHER_APP_ID, + pushKey = config.pushKey, + eventId = TEST_EVENT_ID, + roomId = TEST_ROOM_ID, + ) + ) + } + + companion object { + val TEST_EVENT_ID = EventId("\$THIS_IS_A_FAKE_EVENT_ID") + val TEST_ROOM_ID = RoomId("!room:domain") + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/CurrentPushProviderTest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/CurrentPushProviderTest.kt new file mode 100644 index 0000000..347ee3b --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/CurrentPushProviderTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestDelegate +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.services.toolbox.api.strings.StringProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +@ContributesIntoSet(SessionScope::class) +@Inject +class CurrentPushProviderTest( + private val pushService: PushService, + private val sessionId: SessionId, + private val stringProvider: StringProvider, +) : NotificationTroubleshootTest { + override val order = 110 + private val delegate = NotificationTroubleshootTestDelegate( + defaultName = stringProvider.getString(R.string.troubleshoot_notifications_test_current_push_provider_title), + defaultDescription = stringProvider.getString(R.string.troubleshoot_notifications_test_current_push_provider_description), + fakeDelay = NotificationTroubleshootTestDelegate.SHORT_DELAY, + ) + override val state: StateFlow = delegate.state + + override suspend fun run(coroutineScope: CoroutineScope) { + delegate.start() + val pushProvider = pushService.getCurrentPushProvider(sessionId) + if (pushProvider == null) { + delegate.updateState( + description = stringProvider.getString(R.string.troubleshoot_notifications_test_current_push_provider_failure), + status = NotificationTroubleshootTestState.Status.Failure() + ) + } else if (pushProvider.supportMultipleDistributors.not()) { + delegate.updateState( + description = stringProvider.getString( + R.string.troubleshoot_notifications_test_current_push_provider_success, + pushProvider.name + ), + status = NotificationTroubleshootTestState.Status.Success + ) + } else { + val distributorValue = pushProvider.getCurrentDistributorValue(sessionId) + if (distributorValue == null) { + // No distributors configured + delegate.updateState( + description = stringProvider.getString( + R.string.troubleshoot_notifications_test_current_push_provider_failure_no_distributor, + pushProvider.name + ), + status = NotificationTroubleshootTestState.Status.Failure(false) + ) + } else { + val distributor = pushProvider.getDistributors().find { it.value == distributorValue } + if (distributor == null) { + // Distributor has been uninstalled? + delegate.updateState( + description = stringProvider.getString( + R.string.troubleshoot_notifications_test_current_push_provider_failure_distributor_not_found, + pushProvider.name, + distributorValue, + distributorValue, + ), + status = NotificationTroubleshootTestState.Status.Failure(false) + ) + } else { + delegate.updateState( + description = stringProvider.getString( + R.string.troubleshoot_notifications_test_current_push_provider_success_with_distributor, + pushProvider.name, + distributorValue, + ), + status = NotificationTroubleshootTestState.Status.Success + ) + } + } + } + } + + override suspend fun reset() = delegate.reset() +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/DiagnosticPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/DiagnosticPushHandler.kt new file mode 100644 index 0000000..72153fa --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/DiagnosticPushHandler.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow + +@SingleIn(AppScope::class) +@Inject +class DiagnosticPushHandler { + private val _state = MutableSharedFlow() + val state: SharedFlow = _state + + suspend fun handlePush() { + _state.emit(Unit) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/IgnoredUsersTest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/IgnoredUsersTest.kt new file mode 100644 index 0000000..59ba1eb --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/IgnoredUsersTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootNavigator +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestDelegate +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.services.toolbox.api.strings.StringProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +@ContributesIntoSet(SessionScope::class) +@Inject +class IgnoredUsersTest( + private val matrixClient: MatrixClient, + private val stringProvider: StringProvider, +) : NotificationTroubleshootTest { + override val order = 80 + private val delegate = NotificationTroubleshootTestDelegate( + defaultName = stringProvider.getString(R.string.troubleshoot_notifications_test_blocked_users_title), + defaultDescription = stringProvider.getString(R.string.troubleshoot_notifications_test_blocked_users_description), + fakeDelay = NotificationTroubleshootTestDelegate.SHORT_DELAY, + ) + override val state: StateFlow = delegate.state + + override suspend fun run(coroutineScope: CoroutineScope) { + delegate.start() + val ignorerUsers = matrixClient.ignoredUsersFlow.value + if (ignorerUsers.isEmpty()) { + delegate.updateState( + description = stringProvider.getString(R.string.troubleshoot_notifications_test_blocked_users_result_none), + status = NotificationTroubleshootTestState.Status.Success, + ) + } else { + delegate.updateState( + description = stringProvider.getQuantityString( + R.plurals.troubleshoot_notifications_test_blocked_users_result_some, + ignorerUsers.size, + ignorerUsers.size + ), + status = NotificationTroubleshootTestState.Status.Failure( + hasQuickFix = true, + isCritical = false, + quickFixButtonString = stringProvider.getString(R.string.troubleshoot_notifications_test_blocked_users_quick_fix), + ), + ) + } + } + + override suspend fun quickFix( + coroutineScope: CoroutineScope, + navigator: NotificationTroubleshootNavigator, + ) { + navigator.navigateToBlockedUsers() + } + + override suspend fun reset() = delegate.reset() +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationClickHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationClickHandler.kt new file mode 100644 index 0000000..c370c04 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationClickHandler.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow + +@SingleIn(AppScope::class) +@Inject +class NotificationClickHandler { + private val _state = MutableSharedFlow(extraBufferCapacity = 1) + val state: SharedFlow = _state + + fun handleNotificationClick() { + _state.tryEmit(Unit) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTest.kt new file mode 100644 index 0000000..96860bf --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import androidx.compose.ui.graphics.toArgb +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.appconfig.NotificationConfig +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.NotificationDisplayer +import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestDelegate +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.services.toolbox.api.strings.StringProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import timber.log.Timber +import kotlin.time.Duration.Companion.seconds + +@ContributesIntoSet(SessionScope::class) +@Inject +class NotificationTest( + private val sessionId: SessionId, + private val notificationCreator: NotificationCreator, + private val notificationDisplayer: NotificationDisplayer, + private val notificationClickHandler: NotificationClickHandler, + private val stringProvider: StringProvider, + private val enterpriseService: EnterpriseService, +) : NotificationTroubleshootTest { + override val order = 50 + private val delegate = NotificationTroubleshootTestDelegate( + defaultName = stringProvider.getString(R.string.troubleshoot_notifications_test_display_notification_title), + defaultDescription = stringProvider.getString(R.string.troubleshoot_notifications_test_display_notification_description), + fakeDelay = NotificationTroubleshootTestDelegate.SHORT_DELAY, + ) + override val state: StateFlow = delegate.state + + override suspend fun run(coroutineScope: CoroutineScope) { + delegate.start() + val color = enterpriseService.brandColorsFlow(sessionId).first()?.toArgb() + ?: NotificationConfig.NOTIFICATION_ACCENT_COLOR + val notification = notificationCreator.createDiagnosticNotification(color) + val result = notificationDisplayer.displayDiagnosticNotification(notification) + if (result) { + coroutineScope.listenToNotificationClick() + delegate.updateState( + description = stringProvider.getString(R.string.troubleshoot_notifications_test_display_notification_waiting), + status = NotificationTroubleshootTestState.Status.WaitingForUser + ) + } else { + delegate.updateState( + description = stringProvider.getString(R.string.troubleshoot_notifications_test_display_notification_permission_failure), + status = NotificationTroubleshootTestState.Status.Failure() + ) + } + } + + private fun CoroutineScope.listenToNotificationClick() = launch { + val job = launch { + notificationClickHandler.state.first() + Timber.d("Notification clicked!") + } + @Suppress("RunCatchingNotAllowed") + runCatching { + withTimeout(30.seconds) { + job.join() + } + }.fold( + onSuccess = { + delegate.updateState( + description = stringProvider.getString(R.string.troubleshoot_notifications_test_display_notification_success), + status = NotificationTroubleshootTestState.Status.Success + ) + }, + onFailure = { + job.cancel() + notificationDisplayer.dismissDiagnosticNotification() + delegate.updateState( + description = stringProvider.getString(R.string.troubleshoot_notifications_test_display_notification_failure), + status = NotificationTroubleshootTestState.Status.Failure() + ) + } + ) + }.invokeOnCompletion { + // Ensure that the notification is cancelled when the screen is left + notificationDisplayer.dismissDiagnosticNotification() + } + + override suspend fun reset() = delegate.reset() +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt new file mode 100644 index 0000000..64b8f98 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.api.gateway.PushGatewayFailure +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootNavigator +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestDelegate +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import timber.log.Timber +import kotlin.time.Duration.Companion.seconds + +@ContributesIntoSet(SessionScope::class) +@Inject +class PushLoopbackTest( + private val sessionId: SessionId, + private val pushService: PushService, + private val diagnosticPushHandler: DiagnosticPushHandler, + private val clock: SystemClock, + private val stringProvider: StringProvider, +) : NotificationTroubleshootTest { + override val order = 500 + private val delegate = NotificationTroubleshootTestDelegate( + defaultName = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_title), + defaultDescription = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_description), + ) + override val state: StateFlow = delegate.state + + override suspend fun run(coroutineScope: CoroutineScope) { + delegate.start() + val startTime = clock.epochMillis() + val completable = CompletableDeferred() + val job = coroutineScope.launch { + diagnosticPushHandler.state.first() + completable.complete(clock.epochMillis() - startTime) + } + val testPushResult = try { + pushService.testPush(sessionId) + } catch (pusherRejected: PushGatewayFailure.PusherRejected) { + val hasQuickFix = pushService.getCurrentPushProvider(sessionId)?.canRotateToken() == true + delegate.updateState( + description = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_failure_1), + status = NotificationTroubleshootTestState.Status.Failure(hasQuickFix = hasQuickFix) + ) + job.cancel() + return + } catch (e: Exception) { + Timber.e(e, "Failed to test push") + delegate.updateState( + description = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_failure_2, e.message), + status = NotificationTroubleshootTestState.Status.Failure() + ) + job.cancel() + return + } + if (!testPushResult) { + delegate.updateState( + description = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_failure_3), + status = NotificationTroubleshootTestState.Status.Failure() + ) + job.cancel() + return + } + @Suppress("RunCatchingNotAllowed") + runCatching { + withTimeout(10.seconds) { + completable.await() + } + }.fold( + onSuccess = { duration -> + delegate.updateState( + description = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_success, duration), + status = NotificationTroubleshootTestState.Status.Success + ) + }, + onFailure = { + job.cancel() + delegate.updateState( + description = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_failure_4), + status = NotificationTroubleshootTestState.Status.Failure() + ) + } + ) + } + + override suspend fun quickFix( + coroutineScope: CoroutineScope, + navigator: NotificationTroubleshootNavigator, + ) { + delegate.start() + pushService.getCurrentPushProvider(sessionId)?.rotateToken() + run(coroutineScope) + } + + override suspend fun reset() = delegate.reset() +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt new file mode 100644 index 0000000..1a6368b --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTest.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestDelegate +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.services.toolbox.api.strings.StringProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +@ContributesIntoSet(AppScope::class) +@Inject +class PushProvidersTest( + pushProviders: Set<@JvmSuppressWildcards PushProvider>, + private val stringProvider: StringProvider, +) : NotificationTroubleshootTest { + private val sortedPushProvider = pushProviders.sortedBy { it.index } + override val order = 100 + private val delegate = NotificationTroubleshootTestDelegate( + defaultName = stringProvider.getString(R.string.troubleshoot_notifications_test_detect_push_provider_title), + defaultDescription = stringProvider.getString(R.string.troubleshoot_notifications_test_detect_push_provider_description), + fakeDelay = NotificationTroubleshootTestDelegate.SHORT_DELAY, + ) + override val state: StateFlow = delegate.state + + override suspend fun run(coroutineScope: CoroutineScope) { + delegate.start() + val result = sortedPushProvider.isNotEmpty() + if (result) { + delegate.updateState( + description = stringProvider.getString( + resId = R.string.troubleshoot_notifications_test_detect_push_provider_success_2, + sortedPushProvider.joinToString { it.name } + ), + status = NotificationTroubleshootTestState.Status.Success + ) + } else { + delegate.updateState( + description = stringProvider.getString(R.string.troubleshoot_notifications_test_detect_push_provider_failure), + status = NotificationTroubleshootTestState.Status.Failure() + ) + } + } + + override suspend fun reset() = delegate.reset() +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unregistration/ServiceUnregisteredHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unregistration/ServiceUnregisteredHandler.kt new file mode 100644 index 0000000..29052b4 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unregistration/ServiceUnregisteredHandler.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.unregistration + +import androidx.compose.ui.graphics.toArgb +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.appconfig.NotificationConfig +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.push.impl.notifications.NotificationDisplayer +import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams +import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.coroutines.flow.first + +interface ServiceUnregisteredHandler { + suspend fun handle(userId: UserId) +} + +@ContributesBinding(AppScope::class) +class DefaultServiceUnregisteredHandler( + private val enterpriseService: EnterpriseService, + private val notificationCreator: NotificationCreator, + private val notificationDisplayer: NotificationDisplayer, + private val sessionStore: SessionStore, +) : ServiceUnregisteredHandler { + override suspend fun handle(userId: UserId) { + val color = enterpriseService.brandColorsFlow(userId).first()?.toArgb() + ?: NotificationConfig.NOTIFICATION_ACCENT_COLOR + val hasMultipleAccounts = sessionStore.numberOfSessions() > 1 + val notification = notificationCreator.createUnregistrationNotification( + NotificationAccountParams( + user = MatrixUser(userId), + color = color, + showSessionId = hasMultipleAccounts, + ) + ) + notificationDisplayer.displayUnregistrationNotification(notification) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/DataForWorkManagerIsTooBig.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/DataForWorkManagerIsTooBig.kt new file mode 100644 index 0000000..e700a58 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/DataForWorkManagerIsTooBig.kt @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.workmanager + +class DataForWorkManagerIsTooBig : Exception() diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt new file mode 100644 index 0000000..be8db1a --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.workmanager + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.binding +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.api.push.NotificationEventRequest +import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent +import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver +import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue +import io.element.android.libraries.workmanager.api.WorkManagerScheduler +import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory +import io.element.android.libraries.workmanager.api.di.WorkerKey +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import timber.log.Timber +import kotlin.time.Duration.Companion.seconds + +@AssistedInject +class FetchNotificationsWorker( + @Assisted workerParams: WorkerParameters, + @ApplicationContext private val context: Context, + private val networkMonitor: NetworkMonitor, + private val eventResolver: NotifiableEventResolver, + private val queue: NotificationResolverQueue, + private val workManagerScheduler: WorkManagerScheduler, + private val syncOnNotifiableEvent: SyncOnNotifiableEvent, + private val coroutineDispatchers: CoroutineDispatchers, + private val workerDataConverter: WorkerDataConverter, + private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, +) : CoroutineWorker(context, workerParams) { + override suspend fun doWork(): Result = withContext(coroutineDispatchers.io) { + Timber.d("FetchNotificationsWorker started") + val requests = workerDataConverter.deserialize(inputData) ?: return@withContext Result.failure() + // Wait for network to be available, but not more than 10 seconds + val hasNetwork = withTimeoutOrNull(10.seconds) { + networkMonitor.connectivity.first { it == NetworkStatus.Connected } + } != null + if (!hasNetwork) { + Timber.w("No network, retrying later") + return@withContext Result.retry() + } + + val failedSyncForSessions = mutableSetOf() + + val groupedRequests = requests.groupBy { it.sessionId } + for ((sessionId, notificationRequests) in groupedRequests) { + Timber.d("Processing notification requests for session $sessionId") + eventResolver.resolveEvents(sessionId, notificationRequests) + .fold( + onSuccess = { result -> + // Update the resolved results in the queue + (queue.results as MutableSharedFlow).emit(requests to result) + }, + onFailure = { + failedSyncForSessions += sessionId + Timber.e(it, "Failed to resolve notification events for session $sessionId") + } + ) + } + + // If there were failures for whole sessions, we retry all their requests + if (failedSyncForSessions.isNotEmpty()) { + for (failedSessionId in failedSyncForSessions) { + val requestsToRetry = groupedRequests[failedSessionId] ?: continue + Timber.d("Re-scheduling ${requestsToRetry.size} failed notification requests for session $failedSessionId") + workManagerScheduler.submit( + SyncNotificationWorkManagerRequest( + sessionId = failedSessionId, + notificationEventRequests = requestsToRetry, + workerDataConverter = workerDataConverter, + buildVersionSdkIntProvider = buildVersionSdkIntProvider, + ) + ) + } + } + + Timber.d("Notifications processed successfully") + + performOpportunisticSyncIfNeeded(groupedRequests) + + Result.success() + } + + private suspend fun performOpportunisticSyncIfNeeded( + groupedRequests: Map>, + ) { + for ((sessionId, notificationRequests) in groupedRequests) { + runCatchingExceptions { + syncOnNotifiableEvent(notificationRequests) + }.onFailure { + Timber.e(it, "Failed to sync on notifiable events for session $sessionId") + } + } + } + + @ContributesIntoMap(AppScope::class, binding = binding>()) + @WorkerKey(FetchNotificationsWorker::class) + @AssistedFactory + interface Factory : MetroWorkerFactory.WorkerInstanceFactory +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequest.kt new file mode 100644 index 0000000..b11b83d --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequest.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.workmanager + +import android.os.Build +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkRequest +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.api.push.NotificationEventRequest +import io.element.android.libraries.workmanager.api.WorkManagerRequest +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.workManagerTag +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import timber.log.Timber +import java.security.InvalidParameterException + +class SyncNotificationWorkManagerRequest( + private val sessionId: SessionId, + private val notificationEventRequests: List, + private val workerDataConverter: WorkerDataConverter, + private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, +) : WorkManagerRequest { + override fun build(): Result> { + if (notificationEventRequests.isEmpty()) { + return Result.failure(InvalidParameterException("notificationEventRequests cannot be empty")) + } + Timber.d("Scheduling ${notificationEventRequests.size} notification requests with WorkManager for $sessionId") + return workerDataConverter.serialize(notificationEventRequests).map { dataList -> + dataList.map { data -> + OneTimeWorkRequestBuilder() + .setInputData(data) + .apply { + // Expedited workers aren't needed on Android 12 or lower: + // They force displaying a foreground sync notification for no good reason, since they sync almost immediately anyway + // See https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#backwards-compat + if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { + setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + } + } + .setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC)) + // TODO investigate using this instead of the resolver queue + // .setInputMerger() + .build() + } + } + } + + @Serializable + data class Data( + @SerialName("session_id") + val sessionId: String, + @SerialName("room_id") + val roomId: String, + @SerialName("event_id") + val eventId: String, + @SerialName("provider_info") + val providerInfo: String, + ) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/WorkerDataConverter.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/WorkerDataConverter.kt new file mode 100644 index 0000000..23e6639 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/WorkerDataConverter.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.workmanager + +import androidx.work.Data +import androidx.work.workDataOf +import dev.zacsweers.metro.Inject +import io.element.android.libraries.androidutils.json.JsonProvider +import io.element.android.libraries.core.extensions.mapCatchingExceptions +import io.element.android.libraries.core.extensions.runCatchingExceptions +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.push.api.push.NotificationEventRequest +import timber.log.Timber + +@Inject +class WorkerDataConverter( + private val json: JsonProvider, +) { + fun serialize(notificationEventRequests: List): Result> { + // First try to serialize all requests at once. In the vast majority of cases this will work. + return serializeRequests(notificationEventRequests) + .map { listOf(it) } + .recoverCatching { t -> + if (t is DataForWorkManagerIsTooBig) { + // Perform serialization on sublists, workDataOf have failed because of size limit + Timber.w(t, "Failed to serialize ${notificationEventRequests.size} notification requests, split the requests per room.") + // Group the requests per rooms + val requestsSortedPerRoom = notificationEventRequests.groupBy { it.roomId }.values + // Build a list of sublist with size at most CHUNK_SIZE, and with all rooms kept together + buildList { + val currentChunk = mutableListOf() + for (requests in requestsSortedPerRoom) { + if (currentChunk.size + requests.size <= CHUNK_SIZE) { + // Can add the whole room requests to the current chunk + currentChunk.addAll(requests) + } else { + // Add the current chunk + add(currentChunk.toList()) + // Start a new chunk with the current room requests + currentChunk.clear() + // If a room has more requests than CHUNK_SIZE, we need to split them + requests.chunked(CHUNK_SIZE) { chunk -> + if (chunk.size == CHUNK_SIZE) { + add(chunk.toList()) + } else { + currentChunk.addAll(chunk) + } + } + } + } + // Add any remaining requests + add(currentChunk.toList()) + } + .filter { it.isNotEmpty() } + .also { + Timber.d("Split notification requests into ${it.size} chunks for WorkManager serialization") + it.forEach { requests -> + Timber.d(" - Chunk with ${requests.size} requests") + } + } + .mapNotNull { serializeRequests(it).getOrNull() } + } else { + throw t + } + } + } + + private fun serializeRequests(notificationEventRequests: List): Result { + return runCatchingExceptions { json().encodeToString(notificationEventRequests.map { it.toData() }) } + .onFailure { + Timber.e(it, "Failed to serialize notification requests") + } + .mapCatchingExceptions { str -> + // Note: workDataOf can fail if the data is too large + try { + workDataOf(REQUESTS_KEY to str) + } catch (_: IllegalStateException) { + throw DataForWorkManagerIsTooBig() + } + } + } + + fun deserialize(data: Data): List? { + val rawRequestsJson = data.getString(REQUESTS_KEY) ?: return null + return runCatchingExceptions { + json().decodeFromString>(rawRequestsJson).map { it.toRequest() } + }.fold( + onSuccess = { + Timber.d("Deserialized ${it.size} requests") + it + }, + onFailure = { + Timber.e(it, "Failed to deserialize notification requests") + null + } + ) + } + + companion object { + private const val REQUESTS_KEY = "requests" + internal const val CHUNK_SIZE = 20 + } +} + +private fun NotificationEventRequest.toData(): SyncNotificationWorkManagerRequest.Data { + return SyncNotificationWorkManagerRequest.Data( + sessionId = sessionId.value, + roomId = roomId.value, + eventId = eventId.value, + providerInfo = providerInfo, + ) +} + +private fun SyncNotificationWorkManagerRequest.Data.toRequest(): NotificationEventRequest { + return NotificationEventRequest( + sessionId = SessionId(sessionId), + roomId = RoomId(roomId), + eventId = EventId(eventId), + providerInfo = providerInfo, + ) +} diff --git a/libraries/push/impl/src/main/res/raw/message.mp3 b/libraries/push/impl/src/main/res/raw/message.mp3 new file mode 100644 index 0000000..abc0567 Binary files /dev/null and b/libraries/push/impl/src/main/res/raw/message.mp3 differ diff --git a/libraries/push/impl/src/main/res/values-be/translations.xml b/libraries/push/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..7a30be6 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,87 @@ + + + "Пазваніць" + "Праслухоўванне падзей" + "Шумныя апавяшчэнні" + "Званкі" + "Ціхія апавяшчэнні" + + "%1$s: %2$d паведамленне" + "%1$s: %2$d паведамленні" + "%1$s: %2$d паведамленняў" + + + "%d апавяшчэнне" + "%d апавяшчэнні" + "%d апавяшчэнняў" + + "📹 Уваходны выклік" + "** Не атрымалася даслаць - калі ласка, адкрыйце пакой" + "Далучыцца" + "Адхіліць" + + "%d запрашэнне" + "%d запрашэнні" + "%d запрашэнняў" + + "Запрасіў(-ла) вас у чат" + "%1$s запрасіў(-ла) вас у чат" + "Згадаў(-ла) вас: %1$s" + "Новыя паведамленні" + + "%d новае паведамленне" + "%d новыя паведамленні" + "%d новых паведамленняў" + + "Адрэагаваў(-ла) на %1$s" + "Пазначыць як прачытанае" + "Хуткі адказ" + "Запрасіў(-ла) вас далучыцца да пакоя" + "%1$s запрасіў(-ла) вас далучыцца да пакоя" + "Я" + "%1$s згадаў ці адказаў" + "Вы праглядаеце апавяшчэнне! Націсніце мяне!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d непрачытанае апавяшчэнне" + "%d непрачытаныя апавяшчэнні" + "%d непрачытаных апавяшчэнняў" + + "%1$s і %2$s" + "%1$s у %2$s" + "%1$s у %2$s і %3$s" + + "%d пакой" + "%d пакоі" + "%d пакояў" + + "Фонавая сінхранізацыя" + "Сэрвісы Google" + "Службы Google Play не знойдзены. Апавяшчэнні могуць не працаваць належным чынам." + "Атрымаць назву бягучага пастаўшчыка." + "Пастаўшчыкі push-апавяшчэнняў не выбраны." + "Бягучы пастаўшчык push-апавяшчэнняў: %1$s." + "Бягучы пастаўшчык push-апавяшчэнняў" + "Пераканайцеся, што ў праграме ёсць хаця б адзін пастаўшчык push-апавяшчэнняў." + "Пастаўшчыкі push-апавяшчэнняў не знойдзены." + + "Знайшлі %1$d пастаўшчыка push-апавяшчэнняў: %2$s" + "Знайшлі %1$d пастаўшчыкоў push-апавяшчэнняў: %2$s" + "Знайшлі %1$d пастаўшчыкоў push-апавяшчэнняў: %2$s" + + "Выяўленне пастаўшчыкоў push-паслуг" + "Праверце, ці можа праграма паказваць апавяшчэнні." + "Апавяшчэнне не было націснута." + "Немагчыма паказаць апавяшчэнне." + "Апавяшчэнне было націснута!" + "Паказаць апавяшчэнне" + "Націсніце на апавяшчэнне, каб працягнуць тэст." + "Пераканайцеся, што праграма атрымлівае push-апавяшчэнні." + "Памылка: pusher адхіліў запыт." + "Памылка: %1$s." + "Памылка, немагчыма праверыць push-апавяшчэнне." + "Памылка, тайм-аўт у чаканні push-апавяшчэння." + "Зварот цыклу назад заняў %1$d мс." + "Тэст Націсніце кнопку вярнуцца" + diff --git a/libraries/push/impl/src/main/res/values-bg/translations.xml b/libraries/push/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..3de0fd4 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,64 @@ + + + "Обаждане" + "Слушане за събития" + "Шумни известия" + "Безшумни известия" + + "%1$s: %2$d съобщение" + "%1$s: %2$d съобщения" + + + "%d известие" + "%d известия" + + "Имате нови съобщения." + "** Неуспешно изпращане - моля, отворете стаята" + "Присъединяване" + + "%d покана" + "%d покани" + + "Поканиха ви за чат" + "Ви спомена: %1$s" + "Нови съобщения" + + "%d ново съобщение" + "%d нови съобщения" + + "Реагира с %1$s" + "Отбелязване като прочетено" + "Бърз отговор" + "Ви покани да се присъедините към стаята" + "Аз" + "Преглеждате известието! Кликнете върху мен!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d непрочетено известено съобщение" + "%d непрочетени известени съобщения" + + "%1$s и %2$s" + "%1$s в %2$s" + "%1$s в %2$s и %3$s" + + "%d стая" + "%d стаи" + + "Синхронизация на заден план" + "Услуги на Google" + "Не са намерени валидни услуги на Google Play. Известията може да не работят правилно." + "Проверка на блокирани потребители" + "Преглед на блокираните потребители" + "Няма блокирани потребители." + "Блокирани потребители" + "Получаване на името на текущия доставчик." + "Приложението е изградено с поддръжка за: %1$s" + "Проверка дали приложението може да показва известия." + "Известието не е било кликнато." + "Не може да се покаже известието." + "Известието беше натиснато!" + "Показване на известие" + "Моля, натиснете известието, за да продължите теста." + "Грешка: %1$s" + 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 0000000..667f181 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,103 @@ + + + "Hovor" + "Naslouchání událostem" + "Hlasitá oznámení" + "Vyzvánění hovorů" + "Tichá oznámení" + + "%1$s: %2$d zpráva" + "%1$s: %2$d zprávy" + "%1$s: %2$d zpráv" + + + "%d oznámení" + "%d oznámení" + "%d oznámení" + + "Distributor oznámení UnifiedPush se nepodařilo zaregistrovat, takže již nebudete dostávat oznámení. Zkontrolujte nastavení oznámení v aplikaci a stav distributora push oznámění." + "Oznámení" + "📹 Příchozí hovor" + "** Nepodařilo se odeslat - otevřete prosím místnost" + "Vstoupit" + "Odmítnout" + + "%d pozvánka" + "%d pozvánky" + "%d pozvánek" + + "Vás pozval(a) do chatu" + "%1$s vás pozval(a) do chatu" + "Zmínili vás: %1$s" + "Nové zprávy" + + "%d nová zpráva" + "%d nové zprávy" + "%d nových zpráv" + + "Reagoval(a) s %1$s" + "Označit jako přečtené" + "Rychlá odpověď" + "Vás pozval(a) do místnosti" + "%1$s vás pozval(a) do místnosti" + "Já" + "%1$s zmínil(a) nebo odpověděl(a)" + "Prohlížíte si oznámení! Klikněte na mě!" + "Vlákno v %1$s" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d nepřečtená oznámená zpráva" + "%d nepřečtené oznámené zprávy" + "%d nepřečtených oznámených zpráv" + + "%1$s a %2$s" + "%1$s in %2$s" + "%1$s v %2$s a %3$s" + + "%d místnost" + "%d místnosti" + "%d místností" + + "Synchronizace na pozadí" + "Služby Google" + "Nebyly nalezeny žádné funkční služby Google Play. Oznámení nemusí fungovat správně." + "Kontrola blokovaných uživatelů" + "Zobrazit blokované uživatele" + "Žádní uživatelé nejsou blokováni." + + "Zablokovali jste %1$d uživatele. Nebudete dostávat oznámení od tohoto uživatele." + "Zablokovali jste %1$d uživatele. Nebudete dostávat oznámení od těchto uživatelů." + "Zablokovali jste %1$d uživatelů. Nebudete dostávat oznámení od těchto uživatelů." + + "Blokovaní uživatelé" + "Získat název aktuálního poskytovatele." + "Nebyli vybráni žádní push poskytovatelé." + "Aktuální poskytovatel push oznámení: %1$s a současný distributor: %2$s. Ale distributor %3$s nebyl nalezen. Možná byla aplikace odinstalována?" + "Aktuální poskytovatel push oznámení: %1$s , ale nebyli nakonfigurováni žádní distributoři." + "Aktuální push poskytovatel: %1$s." + "Aktuální poskytovatel push oznámení: %1$s (%2$s)" + "Aktuální push poskytovatel" + "Ujistěte se, že aplikace má alespoň jednoho push poskytovatele." + "Nebyli nalezeni žádní push poskytovatelé." + + "Nalezen %1$d push poskytovatel: %2$s" + "Nalezeni %1$d push poskytovatelé: %2$s" + "Nalezeno %1$d push poskytovatelů: %2$s" + + "Aplikace byla vytvořena s podporou: %1$s" + "Zjistit push poskytovatele" + "Zkontrolujte, zda aplikace může zobrazit oznámení." + "Na oznámení nebylo kliknuto." + "Oznámení nelze zobrazit." + "Na oznámení bylo kliknuto!" + "Zobrazit oznámení" + "Kliknutím na oznámení pokračujte v testu." + "Ujistěte se, že aplikace přijímá push." + "Chyba: pusher odmítl požadavek." + "Chyba: %1$s." + "Chyba, nelze otestovat push." + "Chyba, časový limit čekání na push." + "Push zpětná smyčka trvala %1$d ms." + "Otestovat push zpětnou smyčku" + diff --git a/libraries/push/impl/src/main/res/values-cy/translations.xml b/libraries/push/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..6ef6c0c --- /dev/null +++ b/libraries/push/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,122 @@ + + + "Galw" + "Gwrando am ddigwyddiadau" + "Hysbysiadau swnllyd" + "Galwadau\'n canu" + "Hysbysiadau tawel" + + "%1$s: %2$d negeseuon" + "%1$s: %2$d neges" + "%1$s: %2$d neges" + "%1$s: %2$d neges" + "%1$s: %2$d neges" + "%1$s: %2$d neges" + + + "%d hysbysiadau" + "%d hysbysiad" + "%d hysbysiad" + "%d hysbysiad" + "%d hysbysiad" + "%d hysbysiad" + + "Mae gennych chi negeseuon newydd." + "📹 Galwad i mewn" + "** Wedi methu anfon - agorwch yr ystafell" + "Ymuno" + "Gwrthod" + + "%d gwahoddiadau" + "%d gwahoddiadau" + "%d gwahoddiadau" + "%d gwahoddiadau" + "%d gwahoddiadau" + "%d gwahoddiadau" + + "Wedi eich gwahodd i sgwrsio" + "Mae %1$s wedi eich gwahodd i sgwrsio" + "Wedi eich crybwyll: %1$s" + "Negeseuon Newydd" + + "%d negeseuon newydd" + "%d neges newydd" + "%d neges newydd" + "%d neges newydd" + "%d neges newydd" + "%d neges newydd" + + "Wedi ymateb gyda %1$s" + "Marcio fel wedi\'i ddarllen" + "Ymateb cyflym" + "Wedi\'ch gwahodd i ymuno â\'r ystafell" + "Mae %1$s wedi eich gwahodd i ymuno â\'r ystafell" + "Fi" + "Crybwyllodd neu atebodd %1$s" + "Rydych chi\'n edrych ar yr hysbysiad! Cliciwch fi!" + "%1$s: %2$s" + "%1$s : %2$s %3$s" + + "%d negeseuon hysbyswyd heb eu darllen" + "%d neges hysbyswyd heb ei ddarllen" + "%d neges hysbyswyd heb eu darllen" + "%d neges hysbyswyd heb eu darllen" + "%d neges hysbyswyd heb eu darllen" + "%d neges hysbyswyd heb eu darllen" + + "%1$s a %2$s" + "%1$s yn %2$s" + "%1$s yn %2$s a %3$s" + + "%d ystafelloedd" + "%d ystafell" + "%d ystafell" + "%d ystafell" + "%d ystafell" + "%d ystafell" + + "Cydweddu\'n y cefndir" + "Gwasanaethau Google" + "Heb ganfod Google Play Services dilys. Efallai fydd hysbysiadau ddim yn gweithio\'n iawn." + "Gwirio defnyddwyr sydd wedi\'u rhwystro" + "Gweld defnyddwyr wedi\'u rhwystro" + "Does dim defnyddwyr wedi\'u rhwystro." + + "Rydych wedi rhwystro %1$d defnyddiwr. Fyddwch chi ddim yn derbyn hysbysiadau ar gyfer y defnyddiwr hwn." + "Rydych wedi rhwystro %1$d defnyddiwr. Fyddwch chi ddim yn derbyn hysbysiadau ar gyfer y defnyddiwr hwn." + "Rydych wedi rhwystro %1$d defnyddiwr. Fyddwch chi ddim yn derbyn hysbysiadau ar gyfer y defnyddiwr hwn." + "Rydych wedi rhwystro %1$d defnyddiwr. Fyddwch chi ddim yn derbyn hysbysiadau ar gyfer y defnyddiwr hwn." + "Rydych wedi rhwystro %1$d defnyddiwr. Fyddwch chi ddim yn derbyn hysbysiadau ar gyfer y defnyddiwr hwn." + "Rydych wedi rhwystro %1$d defnyddiwr. Fyddwch chi ddim yn derbyn hysbysiadau ar gyfer y defnyddiwr hwn." + + "Defnyddwyr wedi\'u rhwystro" + "Cael enw\'r darparwr presennol." + "Dim darparwyr gwthio wedi\'u dewis." + "Darparwr gwthio presennol: %1$s." + "Darparwr gwthio presennol" + "Gwnewch yn siŵr fod y cais yn cefnogi o leiaf un darparwr gwthio." + "Heb ganfod cefnogaeth darparwr gwthio." + + "Wedi canfod %1$d darparwyr gwthio: %2$s" + "Wedi canfod %1$d darparwr gwthio: %2$s" + "Wedi canfod %1$d darparwr gwthio: %2$s" + "Wedi canfod %1$d darparwr gwthio: %2$s" + "Wedi canfod %1$d darparwr gwthio: %2$s" + "Wedi canfod %1$d darparwr gwthio: %2$s" + + "Adeiladwyd y rhaglen gyda chefnogaeth ar gyfer: %1$s" + "Cefnogaeth darparwr gwthio" + "Gwiriwch y gall y cais ddangos hysbysiad." + "Nid yw\'r hysbysiad wedi\'i glicio." + "Methu dangos yr hysbysiad." + "Mae\'r hysbysiad wedi\'i glicio!" + "Hysbysiad dangos" + "Cliciwch ar yr hysbysiad i barhau â\'r prawf." + "Gwnewch yn siŵr fod y cais yn cael ei wthio." + "Gwall: mae\'r gwthiwr wedi gwrthod y cais." + "Gwall: %1$s." + "Gwall, methu profi\'r gwthio." + "Gwall, terfyn amser wrth aros am y gwthio." + "Cymerodd dolen gwthio nôl %1$d ms." + "Prawf dolen gwthio\'n ôl" + diff --git a/libraries/push/impl/src/main/res/values-da/translations.xml b/libraries/push/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..a5b23fc --- /dev/null +++ b/libraries/push/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,94 @@ + + + "Opkald" + "Lytter efter begivenheder" + "Lyd på notifikationer" + "Ringende opkald" + "Lydløse notifikationer" + + "%1$s: %2$d besked" + "%1$s: %2$d beskeder" + + + "%d notifikation" + "%d notifikationer" + + "Du har nye beskeder." + "📹 Indgående opkald" + "** Kunne ikke sende - åbn venligst rummet" + "Deltag" + "Afvis" + + "%d invitation" + "%d invitationer" + + "Inviterede dig til at samtale" + "%1$s inviterede dig til at samtale" + "Nævnte dig: %1$s" + "Nye beskeder" + + "%d ny besked" + "%d nye beskeder" + + "Reagerede med%1$s" + "Marker som læst" + "Hurtigt svar" + "Inviterede dig til at deltage i rummet" + "%1$s inviterede dig til at deltage i rummet" + "Mig" + "%1$s nævnt eller besvaret" + "Du ser notifikationen! Klik på mig!" + "Tråd i %1$s" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d ulæst besked" + "%d ulæste beskeder" + + "%1$s og %2$s" + "%1$s i %2$s" + "%1$s i %2$s og %3$s" + + "%d rum" + "%d rum" + + "Synkronisering i baggrunden" + "Google-tjenester" + "Der blev ikke fundet nogen gyldige Google Play-tjenester. Notifikationer fungerer muligvis ikke korrekt." + "Kontrollerer blokerede brugere" + "Se blokerede brugere" + "Ingen brugere er blokeret." + + "Du har blokeret %1$d bruger. Du vil ikke modtage meddelelser fra denne bruger." + "Du har blokeret %1$d brugere. Du vil ikke modtage meddelelser fra disse brugere." + + "Blokerede brugere" + "Få navnet på den aktuelle udbyder." + "Ingen push-udbydere valgt." + "Nuværende push-udbyder: %1$s og nuværende distributør: %2$s. Men distributøren %3$s kan ikke findes. Måske er applikationen blevet afinstalleret?" + "Nuværende push-udbyder: %1$s, men der er ikke konfigureret nogen distributører." + "Nuværende push-udbyder: %1$s." + "Nuværende push-udbyder: %1$s (%2$s)" + "Nuværende push-udbyder" + "Sørg for, at programmet understøtter mindst én push-udbyder." + "Ingen push-udbyder understøttelse fundet." + + "Fandt %1$d push-udbyder: %2$s" + "Fandt %1$d push-udbydere: %2$s" + + "Applikationen blev bygget med støtte til: %1$s" + "Understøttelse af push-udbydere" + "Kontrollér, at appen kan vise notifikationer." + "Der er ikke blevet klikket på meddelelsen." + "Kan ikke vise notifikationen." + "Der er blevet klikket på notifikationen!" + "Vis notifikation" + "Klik venligst på notifikationen for at fortsætte testen." + "Sørg for, at applikationen modtager push." + "Fejl: pusher har afvist anmodningen." + "Fejl: %1$s." + "Fejl, kan ikke teste push." + "Fejl, timeout venter på push." + "Push loop back tog %1$d ms." + "Afprøv Push loop back" + 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 0000000..1a9e2f2 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,94 @@ + + + "Anruf" + "Auf Ereignisse achten" + "Laute Benachrichtigungen" + "Klingelnde Anrufe" + "Stumme Benachrichtigungen" + + "%1$s: %2$d Nachricht" + "%1$s: %2$d Nachrichten" + + + "%d Mitteilung" + "%d Mitteilungen" + + "Du hast neue Nachrichten." + "Eingehender Anruf" + "** Fehler beim Senden - bitte Chat öffnen" + "Beitreten" + "Ablehnen" + + "%d Einladung" + "%d Einladungen" + + "Du wurdest zu einem Chat eingeladen" + "%1$s hat dich zum Chatten eingeladen" + "Hat Dich erwähnt: %1$s" + "Neue Nachrichten" + + "%d neue Nachricht" + "%d neue Nachrichten" + + "Reagiert mit %1$s" + "Als gelesen markieren" + "Schnelle Antwort" + "Du wurdest eingeladen, den Chat zu betreten" + "%1$s hat dich eingeladen, dem Chat beizutreten" + "Ich" + "%1$s hat Dich erwähnt oder geantwortet" + "Du siehst dir die Benachrichtigung an! Klicke hier!" + "Thread in %1$s" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d ungelesene gemeldete Nachricht" + "%d ungelesene gemeldete Nachrichten" + + "%1$s und %2$s" + "%1$s in %2$s" + "%1$s in %2$s und %3$s" + + "%d Chat" + "%d Chats" + + "Hintergrundsynchronisation" + "Google-Dienste" + "Keine gültigen Google Play-Dienste gefunden. Benachrichtigungen funktionieren möglicherweise nicht richtig." + "Überprüfen von gesperrten Nutzern" + "Gesperrte Nutzer ansehen" + "Keine Nutzer sind gesperrt." + + "Du hast %1$d Nutzer gesperrt. Du wirst für diesen Nutzer keine Benachrichtigungen erhalten." + "Du hast %1$d Nutzer gesperrt. Du wirst für diese Nutzer keine Benachrichtigungen erhalten." + + "Gesperrte Nutzer" + "Ermittele den Namen des aktuellen Anbieters." + "Kein Dienst für Push-Benachrichtigungen ausgewählt." + "Aktueller Push-Dienst: %1$s und aktueller UnifiedPush-Distributor: %2$s. Aber der Distributor %3$s kann nicht gefunden werden. Vielleicht wurde die App deinstalliert?" + "Aktueller Push-Dienst: %1$s, aber kein UnifiedPush-Distributor konfiguriert." + "Aktueller Push-Dienst: %1$s." + "Aktueller Push-Dienst: %1$s (%2$s)" + "Aktueller Push-Dienst" + "Stelle sicher, dass die Anwendung mindestens einen Push-Dienst hat." + "Keine Unterstützung für Push-Dienst gefunden." + + "%1$d Push-Dienst gefunden: %2$s" + "%1$d Push-Dienst gefunden: %2$s" + + "Die Anwendung bietet Unterstützung für: %1$s" + "Unterstützung für Push-Dienst" + "Prüfe, ob die Anwendung Benachrichtigungen anzeigen kann." + "Die Benachrichtigung wurde nicht angeklickt." + "Die Benachrichtigung kann nicht angezeigt werden." + "Die Benachrichtigung wurde angeklickt!" + "Benachrichtigung anzeigen" + "Bitte klicke auf die Benachrichtigung, um den Test fortzusetzen." + "Stelle sicher, dass die App Push-Benachrichtigungen empfängt." + "Fehler: Der Pusher hat die Anfrage abgelehnt." + "Fehler:%1$s." + "Fehler: Push kann nicht getestet werden." + "Fehler: Timeout beim Warten auf Push." + "Push-Loop-Back Dauer: %1$d ms." + "Teste Push-Loop-Back" + diff --git a/libraries/push/impl/src/main/res/values-el/translations.xml b/libraries/push/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..da21ccf --- /dev/null +++ b/libraries/push/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,82 @@ + + + "Κλήση" + "Ακρόαση για εκδηλώσεις" + "Θορυβώδεις ειδοποιήσεις" + "Κουδούνισμα κλήσεων" + "Αθόρυβες ειδοποιήσεις" + + "%1$s: %2$d μήνυμα" + "%1$s: %2$d μηνύματα" + + + "%d ειδοποίηση" + "%d ειδοποιήσεις" + + "Έχεις νέο(α) μήνυμα(τα)." + "📹 Εισερχόμενη κλήση" + "** Αποτυχία αποστολής - παρακαλώ ανοίξτε την αίθουσα" + "Συμμετοχή" + "Απόρριψη" + + "%d πρόσκληση" + "%d προσκλήσεις" + + "Σε προσκάλεσε να συνομιλήσετε" + "Ο χρήστης %1$s σε προσκάλεσε σε συνομιλία" + "Σέ ανέφερε: %1$s" + "Νέα Μηνύματα" + + "%d νέο μήνυμα" + "%d νέα μηνύματα" + + "Αντέδρασε με %1$s" + "Επισήμανση ως αναγνωσμένου" + "Γρήγορη απάντηση" + "Σας προσκάλεσε να ενταχθείτε στην αίθουσα" + "%1$s σας προσκάλεσε να συμμετάσχετε στην αίθουσα" + "Εγώ" + "Ο χρήστης %1$s αναφέρθηκε ή απάντησε" + "Βλέπεις την ειδοποίηση! Κάνε μου κλικ!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d μη αναγνωσμένο ειδοποιημένο μήνυμα" + "%d μη αναγνωσμένα ειδοποημένα μηνύματα" + + "%1$s και %2$s" + "%1$s σε %2$s" + "%1$s σε %2$s και %3$s" + + "%d αίθουσα" + "%d αίθουσες" + + "Συγχρονισμός στο παρασκήνιο" + "Υπηρεσίες Google" + "Δεν βρέθηκαν έγκυρες υπηρεσίες Google Play. Οι ειδοποιήσεις ενδέχεται να μην λειτουργούν σωστά." + "Λάβε το όνομα του τρέχοντος παρόχου." + "Δεν έχουν επιλεγεί πάροχοι push." + "Τρέχων πάροχος push: %1$s." + "Τρέχων πάροχος push" + "Βεβαιώσου ότι η εφαρμογή διαθέτει τουλάχιστον έναν πάροχο push." + "Δεν βρέθηκαν πάροχοι push." + + "Βρέθηκε %1$d πάροχος push: %2$s" + "Βρέθηκαν %1$d πάροχοι push: %2$s" + + "Η εφαρμογή δημιουργήθηκε με υποστήριξη για: %1$s" + "Εντοπισμός παρόχων push" + "Έλεγξε ότι η εφαρμογή μπορεί να εμφανίσει ειδοποίηση." + "Δεν έχει γίνει κλικ στην ειδοποίηση." + "Δεν είναι δυνατή η εμφάνιση της ειδοποίησης." + "Έγινε κλικ στην ειδοποίηση!" + "Εμφάνιση ειδοποίησης" + "Κάνε κλικ στην ειδοποίηση για να συνεχίσεις τη δοκιμή." + "Βεβαιώσου ότι η εφαρμογή λαμβάνει push." + "Σφάλμα: ο pusher απέρριψε το αίτημα." + "Σφάλμα: %1$s." + "Σφάλμα, δεν είναι δυνατή η δοκιμή push." + "Σφάλμα, λήξη χρονικού ορίου αναμένοντας για push." + "Το push loop back χρειάστηκε %1$d ms." + "Δοκιμή push loopback" + 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 0000000..4200cb9 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,81 @@ + + + "Llamada" + "Esperando eventos" + "Notificaciones ruidosas" + "Llamadas entrantes" + "Notificaciones silenciosas" + + "%1$s: %2$d mensaje" + "%1$s: %2$d mensajes" + + + "%d notificación" + "%d notificaciones" + + "📹 Llamada entrante" + "** No se ha podido enviar - por favor, abre la sala" + "Unirse" + "Rechazar" + + "%d invitación" + "%d invitaciones" + + "Te invitó a chatear" + "%1$s te invitó a chatear" + "Te mencionó: %1$s" + "Mensajes nuevos" + + "%d mensaje nuevo" + "%d mensajes nuevos" + + "Reaccionó con %1$s" + "Marcar como leído" + "Respuesta rápida" + "Te invitó a unirte a la sala" + "%1$s te invitó a unirte a la sala" + "Yo" + "%1$s mencionó o respondió" + "¡Estás viendo la notificación! ¡Haz clic en mí!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d mensaje notificado no leído" + "%d mensajes notificados no leídos" + + "%1$s y %2$s" + "%1$s en %2$s" + "%1$s en %2$s y %3$s" + + "%d sala" + "%d salas" + + "Sincronización en segundo plano" + "Servicios de Google" + "No se han encontrado Servicios de Google Play válidos. Es posible que las notificaciones no funcionen correctamente." + "Obtener el nombre del proveedor actual." + "No se ha seleccionado ningún proveedor de push." + "Proveedor de push actual: %1$s." + "Proveedor de push actual" + "Asegurarse de que la aplicación tiene al menos un proveedor de push." + "No se encontró ningún proveedor de push." + + "Se encontró %1$d proveedor de push: %2$s" + "Se encontraron %1$d proveedores de push: %2$s" + + "La aplicación se compiló con compatibilidad para: %1$s" + "Detectar proveedores de push" + "Verificar que la aplicación pueda mostrar notificaciones." + "No se ha hecho clic en la notificación." + "No se puede mostrar la notificación." + "¡Se ha hecho clic en la notificación!" + "Mostrar notificación" + "Haz clic en la notificación para continuar la prueba." + "Asegurarse de que la aplicación esté recibiendo notificaciones push." + "Error: el servicio de push ha rechazado la solicitud." + "Error: %1$s." + "Error, no se puede probar el push." + "Error, tiempo de espera agotado para push." + "Envío y recepción de notificación push tomó %1$d ms." + "Probar envío y recepción Push" + diff --git a/libraries/push/impl/src/main/res/values-et/translations.xml b/libraries/push/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..24a355e --- /dev/null +++ b/libraries/push/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,95 @@ + + + "Kõne" + "Kontrollime, kas on uusi sündmusi" + "Lärmakad teavitused" + "Kõnehelinad" + "Vaiksed teavitused" + + "%1$s: %2$d sõnum" + "%1$s: %2$d sõnumit" + + + "%d teavitus" + "%d teavitust" + + "UnifiedPushi levitajas registreerimine ei õnnestunud ja seega sa ei saa enam teavitusi. Palun kontrolli selle rakenduse teavituste seadistusi ja tõuketeenuste levitaja olekut." + "Sulle on uusi sõnumeid." + "📹 Sissetulev kõne" + "** Saatmine ei õnnestunud - palun ava jututoa täisvaade" + "Liitu" + "Keeldu" + + "%d kutse" + "%d kutset" + + "Kutse osalema vestluses" + "%1$s saatus sulle vestluskutse" + "Mainis sind: %1$s" + "Uued sõnumid" + + "%d uus sõnum" + "%d uut sõnumit" + + "Reageeris nii: %1$s" + "Märgi loetuks" + "Kiirvastus" + "Saatis sulle kutse jututuppa" + "%1$s saatis sulle kutse jututoaga liitumiseks" + "Mina" + "%1$s mainis või vastas" + "See ongi teavitus! Klõpsi mind!" + "Jutulõng „%1$s“ jututoas" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d lugemata sõnum, millele on teavitus saadetud" + "%d lugemata sõnumit, millele on teavitus saadetud" + + "%1$s ja %2$s" + "%1$s jututoas %2$s" + "%1$s jututoas %2$s ning kutse jututuppa %3$s" + + "%d jututuba" + "%d jututuba" + + "Sünkroniseerimine taustal" + "Google\'i teenused" + "Google Play Services taustateenust ei leidu. Teavitused ei pruugi toimida korrektselt." + "Kontrollin blokeeritud kasutajaid" + "Vaata blokeeritud kasutajaid" + "Sa pole ühtegi kasutajat blokeerinud." + + "Sa oled blokeerinud %1$d kasutaja. Sa ei saa tema kohta teavitusi" + "Sa oled blokeerinud %1$d kasutajat. Sa ei saa tema kohta teavitusi" + + "Blokeeritud kasutajad" + "Vali hetkel kasutatava tõuketeenuste pakkuja nimi." + "Tõuketeenuste pakkujaid pole valitud." + "Praegune tõuketeenuste pakkuja on %1$s ja praegune levitaja on %2$s. Aga levitajat %3$s ei leidu - kas võib olla, et rakendus on eemaldatud?" + "Praegune tõuketeenuste pakkuja on %1$s, aga ühtegi levitajat pole seadistatud." + "Hetkel kasutatav tõuketeenuste pakkuja: %1$s." + "Praegune tõuketeenuste pakkuja: %1$s (%2$s)" + "Hetkel kasutatav tõuketeenuste pakkuja" + "Palun taga selle rakenduse jaoks on seadistatud vähemalt üks tõuketeenuste pakkuja." + "Ühtegi tõuketeenuste pakkujat ei leidu." + + "Leidsime %1$d tõuketeenuse pakkuja: %2$s" + "Leidsime %1$d tõuketeenuse pakkujat: %2$s" + + "Rakendus on kompileeritud kaasneva toega teenusele: %1$s" + "Tuvasta tõuketeenuste pakkujad" + "Palun kontrolli, et rakendus saaks kuvada teavitusi." + "Sa pole teavitust klõpsinud." + "Teavituse kuvamine ei õnnestu." + "Sa oled teavitust klõpsinud!" + "Kuva teavitust" + "Testiga jätkamiseks palun klõpsi teavitust." + "Palun veendu, et rakendus saab tõuketeavitusi." + "Viga: tõuketeenuse teostaja on päringust keeldunud." + "Viga: %1$s." + "Viga: katselist tõuketeavitust pole võimalik teha." + "Viga: tõuketeavituse ootamisel tekkis aegumine." + "Kogu tõuketeavituse ringi tegemiseks kulus %1$d ms." + "Tee tõuketeenuse silmustest" + diff --git a/libraries/push/impl/src/main/res/values-eu/translations.xml b/libraries/push/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..c72badb --- /dev/null +++ b/libraries/push/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,75 @@ + + + "Deia" + "Gertaerei adi" + "Jakinarazpen zaratatsuak" + "Jakinarazpen isilak" + + "%1$s: mezu %2$d" + "%1$s: %2$d mezu" + + + "jakinarazpen %d" + "%d jakinarazpen" + + "Mezu berriak dituzu." + "Deia jasotzen" + "** Huts egin du bidalketak - ireki gela" + "Elkartu" + "Baztertu" + + "Gonbidapen %d" + "%d gonbidapen" + + "Txatetzera gonbidatu zaitu" + "%1$s(e)k txatera gonbidatu zaitu" + "Aipatu zaitu: %1$s" + "Mezu berriak" + + "Mezu berri %d" + "%d mezu berri" + + "%1$s (r)ekin erreakzionatu du" + "Markatu irakurritzat" + "Erantzun azkarra" + "Gelan sartzera gonbidatu zaitu" + "%1$s(e)k gelan sartzera gonbidatu zaitu" + "Neu" + "%1$s(e)k aipatu zaitu edo erantzun dizu" + "Jakinarazpena ikusten ari zara! Klikatu!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "Irakurri gabeko mezu %den jakinarazpena" + "Irakurri gabeko %d mezuren jakinarazpena" + + "%1$s eta %2$s" + "%1$s %2$s gelan" + "%1$s %2$s gelan eta %3$s" + + "Gela %d" + "%d gela" + + "Atzeko planoko sinkronizazioa" + "Google Services" + "Ez da baliozko Google Play Servicerik aurkitu. Litekeena da jakinarazpenak behar bezala ez ibiltzea." + "Lortu uneko hornitzailearen izena." + "Ez da push hornitzailerik hautatu." + "Uneko push hornitzailea: %1$s." + "Uneko push hornitzailea" + "Ez da push hornitzailerik aurkitu." + + "Push hornitzaile %1$d aurkitu da: %2$s" + "%1$d push hornitzaile aurkitu dira: %2$s" + + "Detektatu push hornitzaileak" + "Egiaztatu aplikazioak jakinarazpena bistaratu dezakeela." + "Ez da klikik egin jakinarazpenean." + "Ezin da jakinarazpena bistaratu." + "Klik egin da jakinarazpenean!" + "Bistaratu jakinarazpena" + "Klikatu jakinarazpenean probarekin jarraitzeko." + "Errorea: %1$s." + "Errorea, ezin da push-a probatu." + "Errorea, denbora agortu da push-aren zain." + diff --git a/libraries/push/impl/src/main/res/values-fa/translations.xml b/libraries/push/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..ef24f09 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,63 @@ + + + "تماس" + "در حال گوش دادن به رویدادها" + "اعلان‌های پرصدا" + "زنگ خوردن تماس" + "اعلان‌های صامت" + + "%1$s:%2$d پیام" + "%1$s:%2$d پیام‌" + + + "%dاعلان" + "%dاعلان‌" + + "پیام‌های جدیدی دارید." + "📹 تماس ورودی" + "**‌شکست در فرستادن - لطفاً اتاق را بگشایید" + "پیوستن" + "رد کردن" + + "%d دعوت" + "%d دعوت" + + "به گپ دعوتتان کرد" + "%1$s به گپ دعوتتان کرد" + "به شما اشاره کرد: %1$s" + "پیام جدید" + + "%d پیام جدید" + "%d پیام جدید" + + "با %1$s واکنش داد" + "علامت‌گذاری به عنوان خوانده شده" + "پاسخ سریع" + "دعوت کرد به اتاق بپیوندید" + "%1$s دعوت کرد به اتاق بپیوندید" + "خودم" + "%1$s اشاره کرد یا پاسخ داد" + "دارید آگاهی را مشاهده می‌کنید! کلیک کنید!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d پیام اعلان نشده" + "%d پیام اعلان نشده" + + "%1$s و %2$s" + "%1$s در %2$s" + "%1$s در %2$s و %3$s" + + "%d اتاق" + "%d اتاق" + + "همگام سازی پس‌زمینه" + "خدمات گوگل" + "خدمت پلی گوگل معتبری پیدا نشد. ممکن است آگاهی‌ها به درستی کار نکنند." + "کاربران مسدود" + "روی آگاهی کلیک شد!" + "خطا: فرستنده درخواست را رد کرد." + "خطا: %1$s." + "خطا. نتوانست فرستادن را بیازماید." + "خطا. مهلت زمانی انتظار برای فرستادن سر رسید." + diff --git a/libraries/push/impl/src/main/res/values-fi/translations.xml b/libraries/push/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..1af69c2 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,94 @@ + + + "Puhelu" + "Tapahtumien kuuntelu" + "Äänekkäät ilmoitukset" + "Soivat puhelut" + "Hiljaiset ilmoitukset" + + "%1$s: %2$d viesti" + "%1$s: %2$d viestiä" + + + "%d ilmoitus" + "%d ilmoitusta" + + "UnifiedPush ilmoitusten jakelijaa ei voitu rekisteröidä, joten et enää vastaanota ilmoituksia. Tarkista sovelluksen ilmoitusasetukset ja push-jakelijan tila." + "Sinulle on uusia viestejä." + "📹 Saapuva puhelu" + "** Lähetys epäonnistui - avaa huone" + "Liity" + "Hylkää" + + "%d kutsu" + "%d kutsua" + + "Kutsui sinut keskustelemaan" + "%1$s kutsui sinut keskustelemaan" + "Mainitsi sinut: %1$s" + "Uusia viestejä" + + "%d uusi viesti" + "%d uutta viestiä" + + "Reaktio: %1$s" + "Merkitse luetuksi" + "Pikavastaus" + "Kutsui sinut liittymään huoneeseen" + "%1$s kutsui sinut liittymään huoneeseen" + "Minä" + "%1$s mainitsi tai vastasi" + "Katselet ilmoitusta! Klikkaa minua!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d lukematon viesti" + "%d lukematonta viestiä" + + "%1$s ja %2$s" + "%1$s %2$s" + "%1$s %2$s ja %3$s" + + "%d huoneessa" + "%d huoneessa" + + "Taustasynkronointi" + "Googlen palvelut" + "Kelvollisia Google Play -palveluita ei löytynyt. Ilmoitukset eivät ehkä toimi oikein." + "Estettyjen käyttäjien tarkistus" + "Näytä estetyt käyttäjät" + "Yhtään käyttäjää ei ole estetty." + + "Estit %1$d käyttäjän. Et saa ilmoituksia tältä käyttäjältä." + "Estit %1$d käyttäjää. Et saa ilmoituksia näiltä käyttäjiltä." + + "Estetyt käyttäjät" + "Hakee nykyisen palveluntarjoajan nimen." + "Push-palveluntarjoajia ei ole valittu." + "Nykyinen push-palveluntarjoaja: %1$s ja nykyinen jakelija: %2$s. Mutta jakelijaa %3$s ei löydy. Ehkä sovellus on poistettu?" + "Nykyinen push-palveluntarjoaja: %1$s, mutta jakelijoita ei ole määritetty." + "Nykyinen push-palveluntarjoaja: %1$s." + "Nykyinen push-palveluntarjoaja: %1$s (%2$s)" + "Nykyinen push-palveluntarjoaja" + "Varmistaa, että sovelluksella on vähintään yksi push-palveluntarjoaja." + "Push-palveluntarjoajia ei löytynyt." + + "Löytyi %1$d push-palveluntarjoaja: %2$s" + "Löytyi %1$d push-palveluntarjoajaa: %2$s" + + "Sovellus on rakennettu tukemaan: %1$s" + "Push-palveluntarjoajien havaitseminen" + "Tarkistaa, että sovellus voi näyttää ilmoituksen." + "Ilmoitusta ei ole klikattu." + "Ilmoitusta ei voida näyttää." + "Ilmoitusta on klikattu!" + "Ilmoituksen näyttäminen" + "Klikkaa ilmoitusta jatkaaksesi testiä." + "Varmistaa, että sovellus vastaanottaa push-ilmoituksen." + "Virhe: pusher on hylännyt pyynnön." + "Virhe: %1$s." + "Virhe, push-ilmoitusta ei voi testata." + "Virhe, aikakatkaisu push-ilmoitusta odotellessa." + "Push-ilmoituksella kesti %1$d ms palata takaisin." + "Testaa push-ilmoituksen paluu" + 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 0000000..8927f9e --- /dev/null +++ b/libraries/push/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,95 @@ + + + "Appel" + "À l’écoute des événements" + "Notifications bruyantes" + "Appels entrants" + "Notifications silencieuses" + + "%1$s : %2$d message" + "%1$s : %2$d messages" + + + "%d notification" + "%d notifications" + + "Le distributeur de notifications UnifiedPush n’a pas pu être enregistré, vous ne recevrez donc plus de notifications. Veuillez vérifier les paramètres de notification de l’application et l’état du distributeur." + "Vous avez de nouveau(x) message(s)." + "📹 Appel entrant" + "** Échec de l’envoi - veuillez ouvrir le salon" + "Rejoindre" + "Rejeter" + + "%d invitation" + "%d invitations" + + "Vous a invité(e) à discuter" + "%1$s vous a invité à discuter" + "Mentionné(e) : %1$s" + "Nouveaux messages" + + "%d nouveau message" + "%d nouveaux messages" + + "A réagi avec %1$s" + "Marquer comme lu" + "Réponse rapide" + "Vous a invité(e) à rejoindre le salon" + "%1$s vous a invité à rejoindre le salon" + "Moi" + "%1$s mentionné ou en réponse" + "Vous êtes en train de voir la notification ! Cliquez-moi !" + "Discussion dans %1$s" + "%1$s : %2$s" + "%1$s : %2$s %3$s" + + "%d message notifié non lu" + "%d messages notifiés non lus" + + "%1$s et %2$s" + "%1$s dans %2$s" + "%1$s dans %2$s et %3$s" + + "%d salon" + "%d salons" + + "Synchronisation en arrière-plan" + "Services Google" + "Aucun service Google Play valide n’a été trouvé. Les notifications peuvent ne pas fonctionner correctement." + "Vérification des utilisateurs bloqués" + "Voir les utilisateurs bloqués" + "Aucun utilisateur n’est bloqué." + + "Vous avez bloqué %1$d utilisateur. Vous ne recevrez pas de notifications pour cet utilisateur." + "Vous avez bloqué %1$d utilisateurs. Vous ne recevrez pas de notifications pour ces utilisateurs." + + "Utilisateurs bloqués" + "Obtenir le nom du fournisseur de Push actuel." + "Aucun fournisseur de Push n’est sélectionné." + "Fournisseur de Push actuel: %1$s et distributeur actuel: %2$s. Mais le distributeur %3$s est introuvable. L’application a peut-être été désinstallée?" + "Fournisseur de Push actuel: %1$s, mais aucun distributeur n’a été configuré." + "Fournisseur de Push actuel : %1$s." + "Fournisseur de Push actuel: %1$s (%2$s)" + "Fournisseur de Push actuel" + "Vérifier que l’application possède au moins un fournisseur de Push." + "Aucun fournisseur de Push n’a été trouvé." + + "%1$d fournisseur de Push détecté : %2$s" + "%1$d fournisseurs de Push détectés : %2$s" + + "L’application a été compilée avec la prise en charge de : %1$s" + "Détecter les fournisseurs de Push" + "Vérifier que l’application peut afficher des notifications." + "Vous n’avez pas cliqué sur la notification." + "Impossible d’afficher la notification." + "Vous avez cliqué sur la notification !" + "Affichage des notifications" + "Veuillez cliquer sur la notification pour continuer le test." + "Vérifier que l’application reçoit les Push." + "Erreur : le Pusher a rejeté la demande." + "Erreur :%1$s." + "Erreur, impossible de tester les Push." + "Erreur, le délai d’attente du Push est dépassé." + "La demande d’envoi de Push et sa réception ont pris %1$d ms." + "Tester la réception des Push" + diff --git a/libraries/push/impl/src/main/res/values-hu/translations.xml b/libraries/push/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..69d4082 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,95 @@ + + + "Hívás" + "Események figyelése" + "Zajos értesítések" + "Csörgő hívások" + "Csendes értesítések" + + "%1$s: %2$d üzenet" + "%1$s: %2$d üzenet" + + + "%d értesítés" + "%d értesítés" + + "A UnifiedPush leküldéses értesítési terjesztő nem regisztrálható, ezért többé nem fog értesítéseket kapni. Ellenőrizze az alkalmazás értesítési beállításait és a leküldés értesítési terjesztő állapotát." + "Értesítés" + "📹 Bejövő hívás" + "** Nem sikerült elküldeni – nyissa meg a szobát" + "Csatlakozás" + "Elutasítás" + + "%d meghívó" + "%d meghívó" + + "Meghívta, hogy csevegjen" + "%1$s meghívta egy csevegésre" + "Megemlítette Önt: %1$s" + "Új üzenetek" + + "%d új üzenet" + "%d új üzenet" + + "Ezzel reagált: %1$s" + "Megjelölés olvasottként" + "Gyors válasz" + "Meghívta, hogy csatlakozzon a szobához" + "%1$s meghívta, hogy csatlakozzon a szobához" + "Én" + "%1$s megemlítette vagy válaszolt" + "Az értesítést nézi! Kattintson ide!" + "Üzenetszál itt: %1$s" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d olvasatlan értesített üzenet" + "%d olvasatlan értesített üzenet" + + "%1$s és %2$s" + "%1$s itt: %2$s" + "%1$s itt: %2$s és %3$s" + + "%d szoba" + "%d szoba" + + "Háttérszinkronizálás" + "Google szolgáltatások" + "A Google Play szolgáltatások nem találhatók. Előfordulhat, hogy az értesítések nem működnek megfelelően." + "Letiltott felhasználók ellenőrzése" + "Letiltott felhasználók megtekintése" + "Nincs felhasználó letiltva." + + "Letiltotta %1$d felhasználót. Nem fog értesítéseket kapni erről a felhasználóról." + "Letiltott %1$d felhasználót. Nem fog értesítéseket kapni ezekről a felhasználókról." + + "Letiltott felhasználók" + "A jelenlegi szolgáltató nevének lekérdezése." + "Nincs kiválasztva leküldéses értesítési szolgáltató." + "Jelenlegi leküldéses értesítések szolgáltatója: %1$s és jelenlegi terjesztő: %2$s. De a terjesztő %3$s nem található. Lehet, hogy az alkalmazást eltávolították?" + "Jelenlegi leküldéses értesítések szolgáltatója: %1$s, de még nem konfiguráltak forgalmazókat." + "Jelenlegi leküldéses értesítési szolgáltató: %1$s." + "Jelenlegi leküldéses értesítések szolgáltatója: %1$s (%2$s)" + "Jelenlegi leküldéses értesítési szolgáltató" + "Győződjön meg arról, hogy az alkalmazás legalább egy leküldéses értesítési szolgáltatóval rendelkezik." + "Nem található leküldéses értesítési szolgáltató." + + "%1$d leküldéses értesítési szolgáltató találva: %2$s" + "%1$d leküldéses értesítési szolgáltató találva: %2$s" + + "Az alkalmazás úgy lett összeállítva, hogy támogatja a következőket: %1$s" + "Leküldéses értesítési szolgáltatók észlelése" + "Ellenőrizze, hogy az alkalmazás képes-e megjeleníteni az értesítést." + "Az értesítésre nem kattintottak rá." + "Az értesítés nem jeleníthető meg." + "Az értesítésre rákattintottak!" + "Értesítés megjelenítése" + "A teszt folytatásához kattintson az értesítésre." + "Győződjön meg arról, hogy az alkalmazás megkapja-e a leküldéses értesítést." + "Hiba: a leküldő elutasította a kérést." + "Hiba: %1$s." + "Hiba, nem lehet tesztelni a leküldéses értesítést." + "Hiba, időtúllépés a leküldéses értesítésre való várakozás során." + "A leküldéses értesítés folyamata %1$d ezredmásodpercig tartott." + "Leküldéses értesítés folyamatának tesztelése" + diff --git a/libraries/push/impl/src/main/res/values-in/translations.xml b/libraries/push/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..760d507 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,75 @@ + + + "Panggil" + "Mendengarkan peristiwa" + "Pemberitahuan berisik" + "Panggilan berdering" + "Pemberitahuan diam" + + "%1$s: %2$d pesan" + + + "%d pemberitahuan" + + "Anda memiliki pesan baru." + "📹 Panggilan masuk" + "** Gagal mengirim — silakan buka ruangan" + "Gabung" + "Tolak" + + "%d undangan" + + "Mengundang Anda untuk mengobrol" + "%1$s mengundang Anda untuk mengobrol" + "Menyebutkan Anda: %1$s" + "Pesan Baru" + + "%d pesan baru" + + "Menghapus dengan %1$s" + "Tandai sebagai dibaca" + "Balas cepat" + "Mengundang Anda untuk bergabung ke ruangan" + "%1$s mengundang Anda untuk bergabung dengan ruangan" + "Saya" + "%1$s disebut atau dibalas" + "Anda sedang melihat pemberitahuan ini! Klik saya!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d pesan pemberitahuan yang belum dibaca" + + "%1$s dan %2$s" + "%1$s di %2$s" + "%1$s di %2$s dan %3$s" + + "%d ruangan" + + "Sinkronisasi latar belakang" + "Layanan Google" + "Tidak ditemukan Layanan Google Play yang valid. Pemberitahuan mungkin tidak berfungsi dengan baik." + "Dapatkan nama penyedia saat ini." + "Tidak ada penyedia notifikasi dorongan yang dipilih." + "Penyedia notifikasi dorongan saat ini: %1$s." + "Penyedia notifikasi dorongan saat ini" + "Pastikan aplikasi memiliki setidaknya satu penyedia notifikasi dorongan." + "Tidak ada penyedia notifikasi dorongan yang ditemukan." + + "Ditemukan %1$d penyedia notifikasi dorongan: %2$s" + + "Aplikasi ini dibangun dengan dukungan untuk: %1$s" + "Deteksi penyedia notifikasi dorongan" + "Periksa apakah aplikasi dapat menampilkan notifikasi." + "Notifikasi belum diklik." + "Tidak dapat menampilkan notifikasi." + "Notifikasi telah diklik!" + "Tampilan notifikasi" + "Silakan klik pada notifikasi untuk melanjutkan tes." + "Pastikan aplikasi menerima notifikasi dorongan." + "Kesalahan: pendorong telah menolak permintaan." + "Kesalahan: %1$s." + "Terjadi kesalahan, tidak dapat menguji notifikasi dorongan." + "Terjadi kesalahan, melebihi batas waktu menunggu notifikasi dorongan." + "Ulangan notifikasi dorongan membutuhkan %1$d ms." + "Uji ulangan notifikasi dorongan lagi" + 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 0000000..9e44c2d --- /dev/null +++ b/libraries/push/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,95 @@ + + + "Chiamata" + "Eventi in ascolto" + "Notifiche con suono" + "Squillo delle chiamate" + "Notifiche silenziose" + + "%1$s: %2$d messaggio" + "%1$s: %2$d messaggi" + + + "%d notifica" + "%d notifiche" + + "Non è stato possibile registrare il distributore di notifiche UnifiedPush, quindi non riceverai più notifiche. Controlla le impostazioni delle notifiche dell\'app e lo stato del distributore push." + "Hai nuovi messaggi." + "📹 Chiamata in arrivo" + "** Invio fallito - si prega di aprire la stanza" + "Entra" + "Rifiuta" + + "%d invito" + "%d inviti" + + "Ti ha invitato ad una conversazione" + "%1$s ti ha invitato ad una conversazione" + "Ti ha menzionato: %1$s" + "Nuovi messaggi" + + "%d nuovo messaggio" + "%d nuovi messaggi" + + "Ha reagito con %1$s" + "Segna come letto" + "Risposta rapida" + "Ti ha invitato ad entrare nella stanza" + "%1$s ti ha invitato a unirti alla stanza" + "Io" + "%1$s ti ha menzionato o risposto" + "Stai visualizzando la notifica! Cliccami!" + "Discussione in %1$s" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d messaggio notificato non letto" + "%d messaggi notificati non letti" + + "%1$s e %2$s" + "%1$s in %2$s" + "%1$s in %2$s e %3$s" + + "%d stanza" + "%d stanze" + + "Sincronizzazione in background" + "Servizi Google" + "Google Play Services non trovato. Le notifiche non funzioneranno bene." + "Controllo degli utenti bloccati" + "Visualizza gli utenti bloccati" + "Nessun utente è bloccato." + + "Hai bloccato%1$d utente. Non riceverai notifiche da questo utente." + "Hai bloccato%1$d utenti. Non riceverai notifiche da questi utenti." + + "Utenti bloccati" + "Ottieni il nome del fornitore attuale." + "Nessun provider push selezionato." + "Fornitore push attuale: %1$s e attuale distributore: %2$s Ma il distributore %3$s non viene trovato. Forse l\'applicazione è stata disinstallata?" + "Fornitore push attuale:%1$s , ma non è stato configurato alcun distributore." + "Provider push attuale: %1$s." + "Fornitore push attuale: %1$s (%2$s )" + "Provider push attuale" + "Assicurati che l\'applicazione abbia almeno un fornitore push." + "Nessun provider push trovato." + + "Provider %1$d push trovato: %2$s" + "Provider %1$d push trovati: %2$s" + + "L\'applicazione è stata creata con il supporto per: %1$s" + "Rileva i provider di servizi push" + "Verifica che l\'applicazione possa mostrare una notifica." + "La notifica non è stata cliccata." + "Impossibile visualizzare la notifica." + "La notifica è stata cliccata!" + "Mostra notifica" + "Clicca sulla notifica per continuare il test." + "Assicurati che l\'applicazione riceva le notifiche push" + "Errore: il servizio push ha rifiutato la richiesta." + "Errore: %1$s." + "Errore, impossibile testare le notifiche push." + "Errore, tempo scaduto in attesa della notifica push." + "Il test push loop back ha impiegato %1$d ms." + "Prova invio notifica push loop back" + diff --git a/libraries/push/impl/src/main/res/values-ka/translations.xml b/libraries/push/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..3a0d626 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,74 @@ + + + "ზარი" + "მოვლენებისთვის მოსმენა" + "ხმაურიანი შეტყობინებები" + "ჩუმი შეტყობინებები" + + "%1$s: %2$d შეტყობინება" + "%1$s: %2$d შეტყობინება" + + + "%d შეტყობინება" + "%d შეტყობინება" + + "** გაგზავნა ვერ მოხერხდა - გთხოვთ, გახსნათ ოთახი" + "გაწევრიანება" + + "%d მოწვევა" + "%d მოწვევები" + + "მოგიწვიათ ჩატში" + "მოგახსენათ: %1$s" + "ახალი შეტყობინებები" + + "%d ახალი მესიჯი" + "%d ახალი მესიჯი" + + "რეაგირება მოხდა: %1$s" + "წაკითხულად მონიშვნა" + "Სწრაფი პასუხი" + "მოგიწვიათ ოთახში" + "მე" + "თქვენ ხედავთ შეტყობინებას! დამაწკაპუნეთ!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d წაუკითხავი შეტყობინება" + "%d წაუკითხავი შეტყობინება" + + "%1$s და %2$s" + "%1$s %2$s-ში" + "%1$s %2$s-ში და %3$s" + + "%d ოთახი" + "%d ოთახი" + + "ფონის სინქრონიზაცია" + "Google სერვისები" + "მოქმედი Google Play სერვისები ვერ მოიძებნა. შეტყობინებები შეიძლება ვერ იმუშაოს სწორად." + "მიმდინარე პროვაიდერის სახელის გაგება" + "push პროვაიდერები არაა არჩეული." + "მიმდინარე push პროვაიდერი: %1$s." + "მიმდინარე push პროვაიდერი" + "დარწმუნდით რომ აპლიკაციას მინიმუმ ერთი push პროვაიდერი." + "push პროვაიდერები ვერ მოიძებნა." + + "მოიძებნა %1$d push პროვაიდერი: %2$s" + "მოიძებნა %1$d push პროვაიდერი: %2$s" + + "push პროვაიდერების აღმოჩენა" + "შეამოწმეთ აპლიკაციას თუ შეუძლია შეტყობინებების ჩვენება" + "შეტყობინება არ იქნა დაჭერილი." + "შეტყობინების ჩვენება შეუძლებელია." + "შეტყობინება იყო დაჭერილი!" + "შეტყობინებების ჩვენება" + "გთხოვთ დააჭიროთ შეტყობინებაზე ტესტის გასაგრძელებლად." + "დარწმუნდით რომ აპლიკაცია იღებს push-შეტყობინებას." + "შეცდომა: გამგზავნმა უარყო მოთხოვნა." + "შეცდომა: %1$s." + "შეცდომა, push-ის ტესტირება შეუძლებელია." + "შეცდომა, push-ისთვის ლოდინის დრო გავიდა." + "Push-შეტყობინების ციკლმა დაიკავა %1$d ms." + "push-შეტყობინების ციკლის ტესტირება" + diff --git a/libraries/push/impl/src/main/res/values-ko/translations.xml b/libraries/push/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..9c2aa8c --- /dev/null +++ b/libraries/push/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,75 @@ + + + "통화" + "이벤트 수신" + "소리 알림" + "전화벨이 울린다" + "무음 알림" + + "%1$s: %2$d 메세지" + + + "%d 알림" + + "알림" + "📹 수신 전화" + "** 전송 실패 - 방을 열여주세요" + "참가하기" + "거부" + + "%d 초대" + + "채팅에 초대됨" + "%1$s 가 채팅에 초대했습니다" + "당신을 언급했습니다: %1$s" + "새 메시지" + + "%d 새 메시지" + + "%1$s로 반응함" + "읽음으로 표시" + "빠른 답장" + "방에 초대받음" + "%1$s 가 당신을 이 방에 초대했습니다" + "나" + "%1$s 언급하거나 답변함" + "알림을 보고 있습니다! 클릭해주세요!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d 읽지 않은 메시지 알림" + + "%1$s 및 %2$s" + "%1$s 내 %2$s" + "%1$s 내 %2$s 및 %3$s" + + "%d 방" + + "백그라운드 동기화" + "Google 서비스" + "유효한 Google Play 서비스를 찾지 못했습니다. 알림이 정상적으로 동작하지 않을 수 있습니다." + "현재 제공자의 이름을 가져옵니다." + "푸시 제공자가 선택되지 않았습니다." + "현재 푸시 제공자: %1$s." + "현재 푸시 제공자" + "애플리케이션이 적어도 하나의 푸시 제공자를 지원하는지 확인하십시오." + "푸시 제공자 지원이 발견되지 않았습니다." + + "%1$d 푸시 제공자를 찾았습니다: %2$s" + + "이 애플리케이션은 다음을 지원하도록 구축되었습니다: %1$s" + "푸시 제공자 지원" + "애플리케이션에서 알림을 표시할 수 있는지 확인하세요." + "알림이 클릭되지 않았습니다." + "알림을 표시할 수 없습니다." + "알림이 클릭되었습니다!" + "알림 표시" + "알림을 클릭하여 테스트를 계속하세요." + "애플리케이션이 푸시를 수신하는지 확인하세요." + "오류: 푸셔가 요청을 거부했습니다." + "오류: %1$s." + "오류, 푸시 테스트가 불가능합니다." + "오류, 푸시 대기 중 시간 초과." + "푸시 루프백이 %1$d ms 소요되었습니다." + "테스트 푸시 루프백" + diff --git a/libraries/push/impl/src/main/res/values-lt/translations.xml b/libraries/push/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..b894a9c --- /dev/null +++ b/libraries/push/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,51 @@ + + + "Skambinti" + "Klausomasi įvykių" + "Triukšmingi pranešimai" + "Tylūs pranešimai" + + "%1$s: %2$d žinutė" + "%1$s: %2$d žinutės" + "%1$s: %2$d žinučių" + + + "%d pranešimas" + "%d pranešimai" + "%d pranešimų" + + "** Nepavyko išsiųsti - prašome atidaryti kambarį" + + "%d kvietimas" + "%d kvietimai" + "%d kvietimų" + + "Pakvietė jus bendrauti" + "Naujos žinutės" + + "%d nauja žinutė" + "%d naujos žinutės" + "%d naujų žinučių" + + "Sparčiai atsakyti" + "Aš" + "Jūs žiūrite pranešimą! Spustelėkite mane!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d neperskaityta žinutė" + "%d neperskaitytos žinutės" + "%d neperskaitytų žinučių" + + "%1$s ir %2$s" + "%1$s (%2$s)" + "%1$s (%2$s) ir %3$s" + + "%d kambaryje" + "%d kambariuose" + "%d kambarių" + + "Sinchronizavimas fone" + "Google Services" + "Nerasta veikiančių \"Google Play Services\". Pranešimai gali veikti netinkamai." + diff --git a/libraries/push/impl/src/main/res/values-nb/translations.xml b/libraries/push/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..c711455 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,93 @@ + + + "Anrop" + "Lytter etter hendelser" + "Støyende varsler" + "Ringende anrop" + "Stille varsler" + + "%1$s: %2$d melding" + "%1$s: %2$d meldinger" + + + "%d varsel" + "%d varsler" + + "Du har nye meldinger." + "📹 Innkommende anrop" + "** Kunne ikke sende - vennligst åpne rommet" + "Bli med" + "Avvis" + + "%d invitasjon" + "%d invitasjoner" + + "Inviterte deg til å chatte" + "%1$s inviterte deg til å chatte" + "Nevnte deg: %1$s" + "Nye meldinger" + + "%d ny melding" + "%d nye meldinger" + + "Reagerte med %1$s" + "Marker som lest" + "Raskt svar" + "Invitert deg til å bli med i rommet" + "%1$s inviterte deg til å bli med i rommet" + "Meg" + "%1$s nevnt eller besvart" + "Du ser på varselet! Klikk på meg!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d ulest varslet melding" + "%d uleste varslede meldinger" + + "%1$s og %2$s" + "%1$s i %2$s" + "%1$s i %2$s og %3$s" + + "%d rom" + "%d rom" + + "Bakgrunnssynkronisering" + "Google Services" + "Ingen gyldige Google Play-tjenester funnet. Det kan hende at varsler ikke fungerer som de skal." + "Sjekker blokkerte brukere" + "Vis blokkerte brukere" + "Ingen brukere er blokkert." + + "Du blokkerte%1$d bruker. Du vil ikke motta varsler for denne brukeren." + "Du blokkerte%1$d brukere. Du vil ikke motta varsler for disse brukerne." + + "Blokkerte brukere" + "Få navnet på den nåværende tilbyderen." + "Ingen push-leverandører er valgt." + "Nåværende push-leverandør: %1$s og nåværende distributør: %2$s. Men distributøren %3$s blir ikke funnet. Kanskje er applikasjonen avinstallert?" + "Nåværende push-leverandør: %1$s, men ingen distributører er konfigurert." + "Gjeldende push-leverandør: %1$s." + "Nåværende push-leverandør: %1$s (%2$s)" + "Nåværende push-leverandør" + "Påse at applikasjonen har minst én push-leverandør." + "Ingen push-leverandører funnet." + + "Fant %1$d push-leverandør: %2$s" + "Fant %1$d push-leverandører: %2$s" + + "Applikasjonen ble bygget med støtte for: %1$s" + "Oppdag push-leverandører" + "Kontroller at programmet kan vise varsler." + "Det er ikke klikket på varselet." + "Kan ikke vise varselet." + "Varselet har blitt klikket på!" + "Vis varsel" + "Klikk på varselet for å fortsette testen." + "Kontroller at applikasjonen mottar push." + "Feil: pusheren har avvist forespørselen." + "Feil: %1$s." + "Feil, kan ikke teste push." + "Feil, tidsavbrudd i påvente av push." + "Push loop back tok %1$d ms." + "Test Push loop back" + diff --git a/libraries/push/impl/src/main/res/values-nl/translations.xml b/libraries/push/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..833c6e7 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,80 @@ + + + "Bellen" + "Wachten op gebeurtenissen" + "Luide meldingen" + "Overgaande oproepen" + "Stille meldingen" + + "%1$s: %2$d bericht" + "%1$s: %2$d berichten" + + + "%d melding" + "%d meldingen" + + "📹 Inkomende oproep" + "** Verzenden is mislukt - open de kamer" + "Deelnemen" + "Weiger" + + "%d uitnodiging" + "%d uitnodigingen" + + "Nodigde je uit om te chatten" + "%1$s nodigde je uit om te chatten" + "Heeft je genoemd: %1$s" + "Nieuwe berichten" + + "%d nieuw bericht" + "%d nieuwe berichten" + + "Reageerde met %1$s" + "Markeren als gelezen" + "Snel antwoord" + "Nodigde je uit om tot de kamer toe te treden" + "%1$s nodigde je uit om tot de kamer toe te treden" + "Mij" + "%1$s heeft vermeld of beantwoord" + "Je bekijkt de melding! Klik hier!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d ongelezen bericht met melding" + "%d ongelezen berichten met melding" + + "%1$s en %2$s" + "%1$s in %2$s" + "%1$s in %2$s en %3$s" + + "%d kamer" + "%d kamers" + + "Achtergrondsynchronisatie" + "Google-services" + "Geen geldige Google Play-services gevonden. Meldingen werken mogelijk niet goed." + "Naam van de huidige provider aan het ophalen." + "Er zijn geen push-providers geselecteerd." + "Huidige push-provider: %1$s." + "Huidige push-provider" + "Zorg ervoor dat de applicatie minimaal één push-provider heeft." + "Geen push-providers gevonden." + + "%1$d push-provider gevonden: %2$s" + "%1$d push-providers gevonden: %2$s" + + "Push-providers detecteren" + "Controleer of de applicatie een melding kan weergeven." + "Er is niet op de melding geklikt." + "Kan de melding niet weergeven." + "Er is op de melding geklikt!" + "Melding weergeven" + "Klik op de melding om verder te gaan met de test." + "Ervoor zorgen dat de applicatie pushberichten ontvangt." + "Fout: pusher heeft het verzoek afgewezen." + "Fout: %1$s." + "Fout, kan push niet testen." + "Fout, time-out tijdens het wachten op push." + "Push terugkoppeling duurde %1$d ms." + "Test Push terugkoppeling" + diff --git a/libraries/push/impl/src/main/res/values-pl/translations.xml b/libraries/push/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..b9ae289 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,101 @@ + + + "Zadzwoń" + "Nasłuchiwanie wydarzeń" + "Głośne powiadomienia" + "Dzwoniące połączenia" + "Ciche powiadomienia" + + "%1$s: %2$d wiadomość" + "%1$s: %2$d wiadomości" + "%1$s: %2$d wiadomości" + + + "%d powiadomienie" + "%d powiadomienia" + "%d powiadomień" + + "Masz nowe wiadomości." + "📹 Połączenie przychodzące" + "** Nie udało się wysłać - proszę otworzyć pokój" + "Dołącz" + "Odrzuć" + + "%d zaproszenie" + "%d zaproszenia" + "%d zaproszeń" + + "Zaprosił Cię do czatu" + "%1$s zaprosił Cię do czatu" + "Wspomniano o Tobie: %1$s" + "Nowe wiadomości" + + "%d nowa wiadomość" + "%d nowe wiadomości" + "%d nowych wiadomości" + + "Zareagował z %1$s" + "Oznacz jako przeczytane" + "Szybka odpowiedź" + "Zaprosił Cię do dołączenia do pokoju" + "%1$s zaprosił Cię do pokoju" + "Ja" + "%1$s wspomniał lub odpowiedział" + "Wyświetlasz powiadomienie! Kliknij mnie!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d nieprzeczytana wiadomość" + "%d nieprzeczytane wiadomość" + "%d nieprzeczytanych wiadomości" + + "%1$s i %2$s" + "%1$s w %2$s" + "%1$s w %2$s i %3$s" + + "%d pokój" + "%d pokoje" + "%d pokoi" + + "Synchronizacja w tle" + "Usługi Google" + "Nie znaleziono usług Google Play. Powiadomienia mogą nie działać prawidłowo." + "Sprawdzam zablokowanych użytkowników" + "Wyświetl zablokowanych użytkowników" + "Żaden użytkownik nie jest zablokowany." + + "Zablokowano %1$d użytkownika. Nie otrzymasz od niego żadnych powiadomień." + "Zablokowano %1$d użytkowników. Nie otrzymasz od nich żadnych powiadomień." + "Zablokowano %1$d użytkowników. Nie otrzymasz od nich żadnych powiadomień." + + "Zablokowani użytkownicy" + "Uzyskaj nazwę bieżącego dostawcy." + "Nie wybrano dostawców push." + "Aktualny dostawca usług push: %1$s, dystrybutor: %2$s. Nie znaleziono dystrybutora %3$s. Sprawdź czy aplikacja nie została usunięta." + "Aktualny dostawca usługi push: %1$s, nie skonfigurowano żadnych dystrybutorów." + "Bieżący dostawca push: %1$s." + "Aktualny dostawca usług push: %1$s (%2$s)" + "Bieżący dostawca push" + "Upewnij się, że aplikacja ma co najmniej jednego dostawcę push." + "Nie znaleziono dostawców push." + + "Znaleziono %1$d dostawcę push: %2$s" + "Znaleziono %1$d dostawców push: %2$s" + "Znaleziono %1$d dostawców push: %2$s" + + "Aplikacja została zbudowana ze wsparciem dla: %1$s" + "Wykryj dostawców powiadomień push" + "Sprawdź, czy aplikacja może wyświetlać powiadomienie." + "Powiadomienie nie zostało kliknięte." + "Nie można wyświetlić powiadomienia." + "Powiadomienie zostało kliknięte!" + "Wyświetl powiadomienie" + "Kliknij powiadomienie, aby kontynuować test." + "Upewnij się, że aplikacja otrzymuje powiadomienie push." + "Błąd: pusher odrzucił żądanie." + "Błąd: %1$s." + "Błąd, nie można przetestować push." + "Błąd, upłynął limit czasu powiadomienia push." + "Pętla powrotna push zajęła %1$d ms." + "Przetestuj pętlę Push back" + diff --git a/libraries/push/impl/src/main/res/values-pt-rBR/translations.xml b/libraries/push/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..178823e --- /dev/null +++ b/libraries/push/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,95 @@ + + + "Chamada" + "Ouvindo eventos" + "Notificações barulhentas" + "Chamadas tocando" + "Notificações silenciosas" + + "%1$s: %2$d mensagem" + "%1$s: %2$d mensagens" + + + "%d notificação" + "%d notificações" + + "O distribuidor de notificações do UnifiedPush não pôde ser cadastrado, então você não receberá mais notificações. Confira as configurações do app e o estado do distribuidor de push." + "Você tem mensagens novas." + "📹 Chamada recebida" + "** Falha ao enviar - por favor, abra a sala" + "Entrar" + "Recusar" + + "%d convite" + "%d convites" + + "Convidou você para conversar" + "%1$s te convidou para conversar" + "Mencionou você: %1$s" + "Novas mensagens" + + "%d nova mensagem" + "%d novas mensagens" + + "Reagiu com %1$s" + "Marcar como lida" + "Resposta rápida" + "Convidou você para entrar na sala" + "%1$s te convidou para entrar na sala" + "Eu" + "%1$s mencionado ou respondido" + "Você está visualizando a notificação! Clique em mim!" + "Tópico em %1$s" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d mensagem notificada não lida" + "%d mensagens notificadas não lidas" + + "%1$s e %2$s" + "%1$s em %2$s" + "%1$s em %2$s e %3$s" + + "%d sala" + "%d salas" + + "Sincronização em segundo plano" + "Serviços do Google" + "O Google Play Services não foi encontrado. As notificações podem não funcionar corretamente." + "Verificando usuários bloqueados" + "Ver usuários bloqueados" + "Nenhum usuário está bloqueado." + + "Você bloqueou %1$d usuário. Você não receberá notificações deste usuário." + "Você bloqueou %1$d usuários. Você não receberá notificações deles." + + "Usuários bloqueados" + "Obtenha o nome do provedor atual." + "Nenhum provedor de push foi selecionado." + "Provedor de push atual: %1$s e distribuidor atual: %2$s. Mas o distribuidor %3$s não foi encontrado. Talvez o aplicativo foi desinstalado?" + "Provedor de push atual: %1$s, mas nenhum distribuidor foi configurado." + "Provedor de push atual: %1$s." + "Provedor de push atual: %1$s (%2$s)" + "Provedor de push atual" + "Certifique-se de que o aplicativo tenha suporte a pelo menos um provedor de push." + "Nenhum provedor de push com suporte foi encontrado." + + "Foi encontrado %1$d provedor de push: %2$s" + "Foram encontrados %1$d provedores de push: %2$s" + + "O aplicativo foi compilado com suporte para: %1$s" + "Suporte a provedores de push" + "Verifique se o aplicativo pode exibir a notificação." + "A notificação não foi clicada." + "Não é possível exibir a notificação." + "A notificação foi clicada!" + "Exibir notificação" + "Clique na notificação para continuar o teste." + "Certifique-se de que o aplicativo está recebendo push." + "Erro: o pusher rejeitou a solicitação." + "Erro: %1$s." + "Erro, não é possível testar o push." + "Erro, tempo limite de espera pelo push." + "O loopback do push levou %1$d ms." + "Teste o loopback do push" + diff --git a/libraries/push/impl/src/main/res/values-pt/translations.xml b/libraries/push/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..1d0184e --- /dev/null +++ b/libraries/push/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,90 @@ + + + "Chamada" + "À escuta de eventos" + "Notificações barulhentas" + "Chamadas a tocar" + "Notificações silenciosas" + + "%1$s: %2$d mensagem" + "%1$s: %2$d mensagens" + + + "%d notificação" + "%d notificações" + + "Tens novas mensagens." + "📹 A receber chamada" + "** Falha no envio - por favor abre a sala" + "Entrar" + "Rejeitar" + + "%d convite" + "%d convites" + + "Convidou-te para conversar" + "%1$s convidou-te para conversar" + "Mencionou-te: %1$s" + "Mensagens novas" + + "%d mensagem nova" + "%d mensagens novas" + + "Reagiu com %1$s" + "Marcar como lida" + "Resposta rápida" + "Convidou-te a entrar na sala" + "%1$s convidou-te a entrares na sala" + "Eu" + "%1$s mencionou ou respondeu" + "Estás a ver a notificação! Clica em mim!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d mensagem notificada não lida" + "%d mensagens notificadas não lidas" + + "%1$s e %2$s" + "%1$s em %2$s" + "%1$s em %2$s e %3$s" + + "%d sala" + "%d salas" + + "Sincronização em segundo plano" + "Serviços do Google Play" + "Nenhuns Serviços do Google Play válidos encontrados. As notificações poderão não funcionar devidamente." + "A verificar utilizadores bloqueados" + "Ver utilizadores bloqueados" + "Sem utilizadores bloqueados." + + "Bloqueaste %1$d utilizador. Não receberás notificações dele." + "Bloqueaste %1$d utilizadores. Não receberás notificações deles." + + "Utilizadores bloqueados" + "Obtém o nome do fornecedor atual." + "Nenhum fornecedor de envio selecionado." + "Fornecedor de envio atual: %1$s." + "Fornecedor de envio atual" + "Certifica que a aplicação tem pelo menos um fornecedor de envio." + "Nenhum fornecedor de envio encontrado." + + "%1$d fornecedor de envio encontrado: %2$s" + "%1$d fornecedores de envio encontrados: %2$s" + + "A aplicação suporta: %1$s" + "Detetar fornecedores de envio" + "Verificar se a aplicação consegue mostrar notificações." + "Não clicaste na notificação." + "Não foi possível mostrar a notificação." + "Clicaste na notificação!" + "Mostrar notificação" + "Por favor, carrega na notificação para continuar o teste." + "Certifica que a aplicação está a receber notificações instantâneas." + "Erro: fornecedor de envio rejeitou o pedido" + "Erro: %1$s." + "Erro: não foi possível testar envio" + "Erro: envio demorou demasiado" + "O ciclo de envio demorou %1$d ms." + "Testar ciclo de envio" + 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 0000000..7a5bcf2 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,95 @@ + + + "Apel" + "Ascultare evenimente" + "Notificări zgomotoase" + "Apeluri care sună" + "Notificări silențioase" + + "%1$s: %2$d mesaj" + "%1$s: %2$d mesaje" + + + "%d notificare" + "%d notificări" + + "Aveți mesaje noi" + "Apel primit" + "** Trimiterea eșuată - vă rugăm să deschideți camera" + "Alăturați-vă" + "Respinge" + + "%d invitație" + "%d invitații" + + "V-a invitat la o discuție" + "%1$s v-a invitat să discutați" + "%1$s v-a menționat" + "Mesaje noi" + + "%d mesaj nou" + "%d mesaje noi" + + "A reacționat cu %1$s" + "Marcați ca citită" + "Raspuns rapid" + "V-a invitat să vă alăturați camerei" + "%1$s v-a invitat să vă alăturați camerei" + "Eu" + "%1$s v-a menționat sau răspuns" + "Vizualizați o notificare! Faceți clic pe mine!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d mesaj notificat necitit" + "%d mesaje notificate necitite" + + "%1$s și %2$s" + "%1$s în %2$s" + "%1$s în %2$s și %3$s" + + "%d cameră" + "%d camere" + + "Sincronizare în fundal" + "Servicii Google" + "Nu au fost găsite servicii Google Play valide. Este posibil ca notificările să nu funcționeze corect." + "Se verifică utilizatorii blocați" + "Vizualizați utilizatorii blocați" + "Niciun utilizator nu este blocat." + + "Ai blocat%1$d utilizator. Nu veți primi notificări pentru acest utilizator." + "Ai blocat%1$d utilizatori. Nu veți primi notificări pentru acești utilizatori." + "Ai blocat%1$d utilizatori. Nu veți primi notificări pentru acești utilizatori." + + "Utilizatori blocați" + "Obțineți numele furnizorului curent." + "Niciun furnizor push selectat." + "Furnizorul actual de push: %1$s și distribuitorul actual: %2$s. Dar distribuitorul %3$s nu este găsit. Poate că aplicația a fost dezinstalată?" + "Furnizor push actual: %1$s, dar nu au fost configurați distribuitori." + "Furnizor de push actual: %1$s." + "Furnizor actual de push: %1$s (%2$s)" + "Furnizor de push curent" + "Asigurați-vă că aplicația are cel puțin un furnizor push." + "Nu s-au găsit furnizori push." + + "S-a găsit %1$d furnizor push: %2$s" + "S-au găsit %1$d furnizori push: %2$s" + "S-au găsit %1$d furnizori push: %2$s" + + "Aplicația a fost creată cu suport pentru: %1$s" + "Detectați furnizorii push" + "Verificați dacă aplicația poate afișa notificări." + "Notificarea nu a fost apăsată." + "Nu s-a putut afișa notificarea." + "Notificarea a fost apăsată!" + "Afișați notificarea" + "Vă rugăm să faceți clic pe notificare pentru a continua testul." + "Asigurați-vă că aplicația primește push-uri." + "Eroare: pusher-ul a respins cererea." + "Eroare: %1$s." + "Eroare, nu se poate testa push-ul." + "Eroare, timeout în așteptare pentru push." + "Push-ul înapoi a durat %1$d ms." + "Testați că push-ul se întoarce" + diff --git a/libraries/push/impl/src/main/res/values-ru/translations.xml b/libraries/push/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..b2a79de --- /dev/null +++ b/libraries/push/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,103 @@ + + + "Позвонить" + "Прослушивание событий" + "Шумные уведомления" + "Звонки" + "Бесшумные уведомления" + + "%1$s: %2$d сообщение" + "%1$s: %2$d сообщения" + "%1$s: %2$d сообщений" + + + "%d уведомление" + "%d уведомления" + "%d уведомлений" + + "Не удалось зарегистрировать дистрибьютора уведомлений UnifiedPush, поэтому вы больше не будете получать уведомления. Проверьте настройки уведомлений в приложении и статус дистрибьютора push-уведомлений." + "У вас есть новые сообщения." + "📹 Входящий вызов" + "** Не удалось отправить - пожалуйста, откройте комнату" + "Присоединиться" + "Отклонить" + + "%d приглашение" + "%d приглашения" + "%d приглашений" + + "Пригласил вас в чат" + "%1$s пригласил вас в чат" + "Упомянул вас: %1$s" + "Новые сообщения" + + "%d новое сообщение" + "%d новых сообщения" + "%d новых сообщений" + + "Отреагировал на %1$s" + "Пометить как прочитанное" + "Быстрый ответ" + "Пригласил вас в комнату" + "%1$s пригласил вас присоединиться к комнате" + "Я" + "%1$s упомянул или ответил" + "Вы просматриваете уведомление! Нажмите на меня!" + "Ветка обсуждения в %1$s" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d непрочитанное уведомление" + "%d непрочитанных уведомления" + "%d непрочитанных уведомлений" + + "%1$s и %2$s" + "%1$s в %2$s" + "%1$s в %2$s и %3$s" + + "%d комната" + "%d комнаты" + "%d комнат" + + "Фоновая синхронизация" + "Сервисы Google" + "Не найдены действующие службы Google Play. Уведомления могут работать некорректно." + "Проверка заблокированных пользователей" + "Просмотреть заблокированных пользователей" + "Ни один пользователь не заблокирован." + + "Вы заблокировали пользователя %1$d. Вы не будете получать уведомления от этого пользователей." + "Вы заблокировали пользователей %1$d. Вы не будете получать уведомления от этих пользователей." + "Вы заблокировали пользователей %1$d. Вы не будете получать уведомления от этих пользователей." + + "Заблокированные пользователи" + "Получение имени текущего поставщика." + "Поставщики push-уведомлений не выбраны." + "Текущий поставщик push-уведомлений: %1$s и текущий дистрибьютор: %2$s . Но дистрибьютор %3$s не найдено. Возможно, приложение было удалено?" + "Текущий поставщик push-уведомлений: %1$s , но ни один дистрибьютор не был настроен." + "Текущий поставщик push-уведомлений: %1$s." + "Текущий поставщик push-уведомлений: %1$s (%2$s)" + "Текущий поставщик push-уведомлений" + "Убедитесь, что у приложения есть хотя бы один поставщик push-сообщений." + "Поставщики push-уведомлений не найдены." + + "Найден %1$d push-провайдер: %2$s" + "Найдено %1$d push-провайдеров: %2$s" + "Найдено %1$d push-провайдеров: %2$s" + + "Приложение было создано с поддержкой: %1$s" + "Обнаружение поставщиков push-уведомлений" + "Убедитесь, что приложение может отображать уведомление." + "Уведомление не было нажато." + "Невозможно отобразить уведомление." + "Уведомление было нажато!" + "Отобразить уведомление" + "Нажмите на уведомление, чтобы продолжить тест." + "Убедитесь, что приложение получает push-сообщение." + "Ошибка: pusher отклонил запрос." + "Ошибка: %1$s." + "Ошибка, невозможно протестировать отправку." + "Ошибка, тайм-аут ожидания push-уведомления." + "Обратная отправка push-уведомления, заняла %1$d мс." + "Тест обратной отправки push-уведомления" + 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 0000000..a4deefb --- /dev/null +++ b/libraries/push/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,102 @@ + + + "Zavolať" + "Počúvanie udalostí" + "Hlasité oznámenia" + "Vyzváňanie hovorov" + "Tiché oznámenia" + + "%1$s: %2$d správa" + "%1$s: %2$d správy" + "%1$s: %2$d správ" + + + "%d oznámenie" + "%d oznámenia" + "%d oznámení" + + "Máte nové správy." + "📹 Prichádzajúci hovor" + "** Nepodarilo sa odoslať - prosím otvorte miestnosť" + "Pripojiť sa" + "Odmietnuť" + + "%d pozvánka" + "%d pozvánky" + "%d pozvánok" + + "Vás pozval/a na konverzáciu" + "%1$s vás pozval/a na rozhovor" + "Spomenul/a vás: %1$s" + "Nové správy" + + "%d nová správa" + "%d nové správy" + "%d nových správ" + + "Reagoval/a s %1$s" + "Označiť ako prečítané" + "Rýchla odpoveď" + "Vás pozval do miestnosti" + "%1$s vás pozval/a, aby ste sa pripojili k miestnosti" + "Ja" + "%1$s spomenul/a alebo odpovedal/a" + "Prezeráte si oznámenie! Kliknite na mňa!" + "Vlákno v %1$s" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d neprečítaná oznámená správa" + "%d neprečítané oznámené správy" + "%d neprečítaných oznámených správ" + + "%1$s a %2$s" + "%1$s v %2$s" + "%1$s v %2$s a %3$s" + + "%d miestnosť" + "%d miestnosti" + "%d miestností" + + "Synchronizácia na pozadí" + "Služby Google" + "Nenašli sa žiadne platné služby Google Play. Oznámenia nemusia fungovať správne." + "Kontrola blokovaných používateľov" + "Zobraziť blokovaných používateľov" + "Žiadni používatelia nie sú blokovaní." + + "Zablokovali ste %1$d používateľa. Nebudete dostávať oznámenia od tohto používateľa." + "Zablokovali ste %1$d používateľov. Nebudete dostávať oznámenia od týchto používateľov." + "Zablokovali ste %1$d používateľov. Nebudete dostávať oznámenia od týchto používateľov." + + "Blokovaní používatelia" + "Získaťe názov aktuálneho poskytovateľa." + "Nie sú vybraní žiadni poskytovatelia push." + "Aktuálny poskytovateľ push oznámení: %1$s a súčasný distribútor: %2$s. Ale distribútor %3$s sa nenašiel. Možno bola aplikácia odinštalovaná?" + "Aktuálny poskytovateľ push oznámení: %1$s, ale neboli nakonfigurovaní žiadni distribútori." + "Aktuálny poskytovateľ push: %1$s." + "Aktuálny poskytovateľ push oznámení: %1$s (%2$s)" + "Aktuálny poskytovateľ push" + "Uistite sa, že aplikácia má aspoň jedného poskytovateľa push." + "Nenašli sa žiadni poskytovatelia push." + + "Nájdený %1$d poskytovateľ služby push: %2$s" + "Nájdení %1$d poskytovatelia služby push: %2$s" + "Nájdených %1$d poskytovateľov služby push: %2$s" + + "Aplikácia bola vytvorená s podporou pre: %1$s" + "Zistiť poskytovateľov push" + "Skontrolujte, či aplikácia dokáže zobraziť upozornenie." + "Na oznámenie nebolo kliknuté." + "Nie je možné zobraziť upozornenie." + "Na oznámenie bolo kliknuté!" + "Zobraziť upozornenie" + "Kliknite na upozornenie a pokračujte v teste." + "Uistite sa, že aplikácia prijíma push oznámenia." + "Chyba: pusher odmietol požiadavku." + "Chyba: %1$s." + "Chyba, nie je možné testovať push." + "Chyba, časový limit na push vypršal." + "Push loop back trvalo %1$d ms." + "Testovať Push loop back" + diff --git a/libraries/push/impl/src/main/res/values-sv/translations.xml b/libraries/push/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..aff078b --- /dev/null +++ b/libraries/push/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,82 @@ + + + "Samtal" + "Lyssnar efter händelser" + "Högljudda aviseringar" + "Ringande samtal" + "Tysta aviseringar" + + "%1$s: %2$d meddelande" + "%1$s: %2$d meddelanden" + + + "%d avisering" + "%d aviseringar" + + "Du har nya meddelanden." + "📹 Inkommande samtal" + "** Misslyckades att skicka - vänligen öppna rummet" + "Gå med" + "Avvisa" + + "%d inbjudan" + "%d inbjudningar" + + "Bjöd in dig att chatta" + "%1$s bjöd in dig att chatta" + "Nämnde dig: %1$s" + "Nya meddelanden" + + "%d nytt meddelande" + "%d nya meddelanden" + + "Reagerade med %1$s" + "Markera som läst" + "Snabbsvar" + "Bjöd in dig att gå med i rummet" + "%1$s bjöd in dig att gå med i rummet" + "Jag" + "%1$s nämnde eller svarade" + "Du tittar på aviseringen! Klicka på mig!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d oläst aviserat meddelande" + "%d olästa aviserade meddelanden" + + "%1$s och %2$s" + "%1$s i %2$s" + "%1$s i %2$s och %3$s" + + "%d rum" + "%d rum" + + "Bakgrundssynkronisering" + "Google-tjänster" + "Inga giltiga Google Play-tjänster hittades. Aviseringar kanske inte fungerar korrekt." + "Hämta namnet på den nuvarande leverantören." + "Inga push-leverantörer valda." + "Nuvarande push-leverantör: %1$s." + "Nuvarande push-leverantör" + "Se till att applikationen har minst en push-leverantör." + "Inga push-leverantörer hittades." + + "Hittade %1$d push-leverantör: %2$s" + "Hittade %1$d push-leverantörer: %2$s" + + "Applikationen byggdes med stöd för: %1$s" + "Upptäck push-leverantörer" + "Kontrollera att applikationen kan visa avisering." + "Aviseringen har inte klickats på." + "Kan inte visa aviseringen." + "Aviseringen har klickats på!" + "Visa avisering" + "Vänligen klicka på aviseringen för att fortsätta testet." + "Kontrollera att applikationen tar emot push." + "Fel: pusher har avvisat begäran." + "Fel: %1$s." + "Fel, kan inte testa push." + "Fel, tidsgräns överskriden vid väntan på push." + "Returnering av push tog %1$d ms." + "Testa returnering av push" + diff --git a/libraries/push/impl/src/main/res/values-tr/translations.xml b/libraries/push/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..a3c415a --- /dev/null +++ b/libraries/push/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,81 @@ + + + "Çağrı" + "Etkinlikleri dinlemek" + "Gürültülü bildirimler" + "Çalan aramalar" + "Sessiz bildirimler" + + "%1$s: %2$d mesaj" + "%1$s: %2$d mesaj" + + + "%d bildirim" + "%d bildirim" + + "Yeni mesajlarınız var" + "📹 Gelen çağrı" + "** Gönderilemedi - lütfen odayı açın" + "Katıl" + "Reddet" + + "%d davet" + "%d davet" + + "Sizi sohbete davet etti" + "%1$s sizi sohbete davet etti" + "Senden bahsettim: %1$s" + "Yeni Mesajlar" + + "%d yeni mesaj" + "%d yeni mesaj" + + "%1$s ile tepki verildi" + "Okundu olarak işaretle" + "Hızlı cevap" + "Sizi odaya katılmaya davet etti" + "%1$s sizi odaya katılmaya davet etti" + "Ben" + "%1$s belirtildi veya yanıtlandı" + "Bildirimi görüntülüyorsunuz! Beni tıklayın!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d okunmamış mesaj bildirimi" + "%d okunmamış mesaj bildirimi" + + "%1$s ve %2$s" + "%1$s içinde %2$s" + "%1$s içinde %2$s ve %3$s" + + "%d oda" + "%d oda" + + "Arkaplan senkronizasyonu" + "Google Hizmetleri" + "Geçerli bir Google Play Hizmeti bulunamadı. Bildirimler düzgün çalışmayabilir." + "Geçerli sağlayıcının adını al." + "Hiçbir gönderme sağlayıcısı seçilmedi." + "Geçerli gönderme sağlayıcısı: %1$s." + "Geçerli gönderme sağlayıcısı" + "Uygulamanın en az bir gönderme sağlayıcısına sahip olduğundan emin olun." + "Gönderim sağlayıcısı bulunamadı." + + "%1$d gönderme sağlayıcısı bulundu: %2$s" + "%1$d gönderme sağlayıcısı bulundu: %2$s" + + "Gönderme sağlayıcılarını tespit et" + "Uygulamanın bildirim görüntüleyebildiğini kontrol edin." + "Bildirime tıklanmadı." + "Bildirim görüntülenemiyor." + "Bildirime tıklandı!" + "Bildirimi görüntüle" + "Teste devam etmek için lütfen bildirime tıklayın." + "Uygulamanın push aldığından emin olun." + "Hata: itici isteği reddetti." + "Hata: %1$s." + "Hata, itme test edilemiyor." + "Hata, push beklenirken zaman aşımı oluştu." + "Geri itme döngüsü %1$d ms sürdü." + "Test Geri İtme döngüsü" + diff --git a/libraries/push/impl/src/main/res/values-uk/translations.xml b/libraries/push/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..1aa535b --- /dev/null +++ b/libraries/push/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,89 @@ + + + "Виклик" + "Прослуховування подій" + "Гучні сповіщення" + "Виклики" + "Тихі сповіщення" + + "%1$s: %2$d повідомлення" + "%1$s: %2$d повідомлення" + "%1$s: %2$d повідомлень" + + + "%d сповіщення" + "%d сповіщення" + "%d сповіщень" + + "У вас є нові повідомлення." + "📹 Вхідний виклик" + "** Не вдалося надіслати - відкрийте кімнату" + "Доєднатися" + "Відхилити" + + "%d запрошення" + "%d запрошення" + "%d запрошень" + + "Запрошує вас до бесіди" + "%1$s запросив вас до чату" + "Вас згадує: %1$s" + "Нові повідомлення" + + "%d нове повідомлення" + "%d нові повідомлення" + "%d нових повідомлень" + + "Реагує з %1$s" + "Позначити прочитаним" + "Швидка відповідь" + "Запрошує вас приєднатися до кімнати" + "%1$s запросив вас приєднатися до кімнати" + "Я" + "%1$s згадували або відповідали" + "Ви переглядаєте сповіщення! Натисніть тут!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d непрочитане сповіщення" + "%d непрочитані сповіщення" + "%d непрочитаних сповіщень" + + "%1$s та %2$s" + "%1$s у %2$s" + "%1$s у %2$s та %3$s" + + "%d кімната" + "%d кімнати" + "%d кімнат" + + "Фонова синхронізація" + "Сервіси Google" + "Не знайдено дійсних сервісів Google Play. Сповіщення можуть не працювати належним чином." + "Отримує назву поточного постачальника." + "Постачальників push-сповіщень не вибрано." + "Поточний постачальник: %1$s." + "Поточний постачальник push-сповіщень" + "Переконайтеся, що застосунок має принаймні одного постачальника push-сповіщень." + "Не знайдено постачальників push-сповіщень." + + "Виявлено %1$d постачальника: %2$s" + "Виявлено %1$d постачальники: %2$s" + "Виявлено %1$d постачальників: %2$s" + + "Застосунок створено за підтримки: %1$s" + "Виявлення постачальників push-сповіщень" + "Перевірте, чи може застосунок показувати сповіщення." + "Ви не натиснули на сповіщення." + "Не вдається показати сповіщення." + "Ви натиснули на сповіщення!" + "Показ сповіщення" + "Натисніть на сповіщення, щоб продовжити тест." + "Переконується, що застосунок отримує push-сповіщення." + "Помилка: постачальник push-сповіщень відхилив запит." + "Помилка: %1$s." + "Помилка, неможливо перевірити push." + "Помилка, час очікування вийшов на push-повідомлення." + "Зворотнє відправлення push-повідомлення, зайняло %1$d мс." + "Перевірка зворотного надсилання" + diff --git a/libraries/push/impl/src/main/res/values-ur/translations.xml b/libraries/push/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..40a54ab --- /dev/null +++ b/libraries/push/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,81 @@ + + + "مکالمہ" + "واقعات کیلئے سن رہا ہے" + "صاخب اطلاعات" + "بجنے والے مکالمات" + "خاموش اطلاعات" + + "%1$s: %2$d پیغام" + "%1$s: %2$d پیغامات" + + + "%d اطلاع" + "%d اطلاعات" + + "آپ کے لیے نئے پیغامات ہیں۔" + "📹 آنے والا مکالمہ" + "** بھیجنے میں ناکام - براہ کرم کمرہ کھولیں" + "شامل ہوں" + "مسترد کریں" + + "%d دعوت نامہ" + "%d دعوت نامے" + + "آپ کو گفتگو کیلئے مدعو کیا" + "%1$s نے آپ کو چیٹ کے لیے مدعو کیا" + "آپ کا تذکرہ کیا: %1$s" + "نئے پیغامات" + + "%d نیا پیغام" + "%d نئے پیغامات" + + "%1$s کے ساتھ ردعمل کیا" + "بطور مقروءہ نشانزد کریں" + "فوری جواب" + "آپ کو کمرے میں شامل ہونے کی دعوت دی" + "%1$s نے آپ کو روم میں شامل ہونے کی دعوت دی" + "میں" + "%1$s نے ذکر کیا یا جواب دیا" + "آپ اطلاع دیکھ رہے ہیں! مجھے دبائیں!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d غیر مقروءہ مطلع پیغام" + "%d غیر مقروءہ مطلع پیغامات" + + "%1$s اور %2$s" + "%2$s میں %1$s" + "%2$s اور %3$s میں %1$s" + + "%d کمرہ" + "%d کمرے" + + "پس منظر مطابقت پذیری" + "گوگل سروسز" + "کوئی درست گوگل پلے سروسز نہیں ملی۔ ہو سکتا ہے اطلاعات ٹھیک سے کام نہ کریں۔" + "موجودہ فراہم کنندہ کا نام حاصل کریں۔" + "کوئی دھکا فراہم کنندہ منتخب نہیں کیا گیا" + "موجودہ دھکا فراہم کنندہ: %1$s۔" + "موجودہ دھکا فراہم کنندہ" + "یقینی بنائیں کہ اطلاقیہ کم از کم ایک دھکا فراہم کنندہ کی حمایت کرتا ہے۔" + "کوئی دھکا فراہم کنندہ حمایت نہیں ملی۔" + + "پایا %1$d دھکا فراہم کنندہ: %2$s" + "پائے %1$d دھکا فراہم کنندگان: %2$s" + + "دھکا فراہم کنندہ حمایت" + "پڑتال کریں کہ اطلاقیہ اطلاع دکھا سکتا ہے" + "یہ اطلاع نہیں دبائی گئی" + "اطلاع ظاہر نہیں کرسکتا" + "یہ اطلاع دبا دی گئی ہے!" + "اطلاع دکھائیں" + "برائے مہربانی جانچ جاری رکھنے کیلئے اطلاع پر دبائیں" + "اس بات کو یقینی بنائیں کہ اطلاقیے کو دھکا موصول ہو رہا ہے۔" + "نقص: دھکا کنندہ نے درخواست مسترد کردی ہے۔" + "نقص: %1$s۔" + "نقص، دھکا جانچ نہیں سکتا" + "نقص، دھکے کے انتظار میں نفذ الوقت" + "دھکئی حلقۂ رجوعیہ نے %1$d مث لیا" + "دھکئی حلقۂ رجوعیہ جانچیں" + diff --git a/libraries/push/impl/src/main/res/values-uz/translations.xml b/libraries/push/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..7ba9e63 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,81 @@ + + + "Qo\'ng\'iroq" + "Voqealarni tinglash" + "Shovqinli bildirishnomalar" + "Jiringlayotgan qoʻngʻiroqlar" + "Ovozsiz bildirishnomalar" + + "%1$s:%2$d xabar" + "%1$s:%2$d xabarlar" + + + "%dbildirishnoma" + "%dbildirishnomalar" + + "Sizda yangi xabarlar bor." + "📹 Kiruvchi qoʻngʻiroq" + "** Yuborilmadi - iltimos, xonani oching" + "Qo\'shilish" + "Rad etish" + + "%dtaklifnoma" + "%dtaklifnomalar" + + "Sizni suhbatga taklif qildi" + "%1$s sizni suhbatga taklif qildi" + "%1$s sizni eslatib oʻtdi" + "Yangi xabarlar" + + "%dyangi xabar" + "%dyangi xabarlar" + + "%1$sbilan munosabat bildiring" + "Oʻqilgan deb belgilash" + "Tez javob" + "Sizni xonaga kirishga taklif qildi" + "%1$s sizni xonaga kirishga taklif qildi" + "Men" + "%1$s eslatib o‘tdi yoki javob qaytardi" + "Siz bildirishnomani ko\'ryapsiz! Meni bosing!" + "%1$s:%2$s" + "%1$s:%2$s%3$s" + + "%do\'qilmagan xabarnoma" + "%do\'qilmagan xabarlar" + + "%1$sva%2$s" + "%1$sichida%2$s" + "%1$sichida%2$s va%3$s" + + "%dxona" + "%dxonalar" + + "Orqa Fon sinxronizatsiyasi" + "Google xizmatlari" + "Yaroqli Google Play xizmatlari topilmadi. Bildirishnomalar to\'g\'ri ishlamasligi mumkin." + "Joriy provayder nomini oling." + "Hech qanday push-provayder tanlanmagan." + "Joriy push provider: %1$s." + "Joriy push provider" + "Ilova kamida bitta push-provayderni qo‘llab-quvvatlashini tekshiring." + "Hech qanday push-provayder xizmati topilmadi." + + "Topildi %1$d push provider: %2$s" + "Topildi %1$d push provayderlar: %2$s" + + "Ilova quyidagilar uchun yaratilgan: %1$s" + "Provayderni qoʻllab-quvvatlash" + "Ilova bildirishnomani koʻrsata olishini tekshiring." + "Bildirishnoma bosilmagan." + "Bildirishnomani ko‘rsatib boʻlmaydi." + "Bildirishnoma bosildi!" + "Bildirishnomani koʻrsatish" + "Sinovni davom ettirish uchun bildirishnoma ustiga bosing." + "Ilovaning push-bildirishnomalarni qabul qilayotganiga ishonch hosil qiling." + "Xato: pusher so‘rovni rad etdi." + "Xato: %1$s." + "Xatolik, push qilishni sinab bo‘lmadi." + "Xatolik, taym aut pushni kutmoqda." + "Test Push loop back" + diff --git a/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml b/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..f0c1f10 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,87 @@ + + + "通話" + "監聽事件" + "吵雜通知" + "來電鈴聲" + "無聲通知" + + "%1$s:%2$d 則訊息" + + + "%d 個通知" + + "Unified Push 通知散佈程式註冊失敗,因此您無法再收到通知。請檢查應用程式的通知設定與推播散佈程式的狀態。" + "您有新訊息。" + "📹 來電" + "** 無法傳送,請開啟聊天室" + "加入" + "拒絕" + + "%d 個邀請" + + "邀請您聊天" + "%1$s 邀請您加入聊天" + "提及您:%1$s" + "新訊息" + + "%d 則新訊息" + + "回應 %1$s" + "標為已讀" + "快速回覆" + "邀請您加入聊天室" + "%1$s 邀請您加入聊天室" + "我" + "%1$s 提及或回覆" + "您正在查看通知!點我!" + "在 %1$s 的討論串" + "%1$s:%2$s" + "%1$s:%2$s %3$s" + + "%d 則未讀的已通知訊息" + + "%1$s 與 %2$s" + "%1$s 在 %2$s 中" + "%1$s 在 %2$s 與 %3$s 中" + + "%d 個聊天室" + + "背景同步" + "Google 服務" + "找不到有效的 Google Play 服務。通知可能無法正常運作。" + "檢查被封鎖的使用者" + "檢視被封鎖的使用者" + "無被封鎖的使用者。" + + "您已封鎖 %1$d 個使用者。您將不會收到來自這些使用者的通知。" + + "已封鎖使用者" + "取得目前提供者的名稱。" + "未選取推播提供者。" + "目前的推播提供者 %1$s 與目前的散佈者 %2$s。但找不到散佈者 %3$s。可能已解除安裝應用程式?" + "目前的推播提供者:%1$s,但尚未設定散佈者。" + "目前的推播提供者:%1$s。" + "目前的推播提供者:%1$s (%2$s)" + "目前的推播提供者" + "確保應用程式至少有一個推播提供者。" + "找不到推播提供者。" + + "找到 %1$d 個推播提供者:%2$s" + + "該應用程式支援以下功能:%1$s" + "偵測推播提供者" + "檢查應用程式是否可以顯示通知。" + "尚未點選通知。" + "無法顯示通知。" + "已點選通知!" + "顯示通知" + "請點選通知以繼續測試。" + "確保應用程式正在接收推播。" + "錯誤:推播程式拒絕請求。" + "錯誤:%1$s。" + "錯誤,無法測試推播。" + "錯誤,等待推播逾時。" + "推播返回需要 %1$d 毫秒。" + "測試推播返回" + diff --git a/libraries/push/impl/src/main/res/values-zh/translations.xml b/libraries/push/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..3f2bd7e --- /dev/null +++ b/libraries/push/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,86 @@ + + + "通话" + "监听事件" + "嘈杂通知" + "来电振铃" + "静默通知" + + "%1$s:%2$d 条消息" + + + "%d 条通知" + + "您有新消息。" + "📹 来电" + "** 无法发送——请打开聊天室" + "加入" + "拒绝" + + "%d 个邀请" + + "邀请您聊天" + "%1$s 邀您聊天" + "提到了你:%1$s" + "新消息" + + "%d 条新消息" + + "使用 %1$s 回应" + "标记为已读" + "快速回复" + "邀请你加入聊天室" + "%1$s 邀请您加入房间" + "我" + "%1$s提及或回复" + "您正在查看通知!点击我!" + "线程 %1$s" + "%1$s:%2$s" + "%1$s: %2$s %3$s" + + "%d 条未读消息" + + "%1$s 和 %2$s" + "%2$s 中的 %1$s" + "在 %2$s 和 %3$s 中的 %1$s" + + "%d 个聊天室" + + "后台同步" + "谷歌服务" + "找不到有效的 Google Play 服务。通知可能无法正常工作。" + "检查被阻止的用户" + "查看被屏蔽的用户" + "没有用户被阻止。" + + "您已屏蔽 %1$d 位用户。您将不再收到这些用户的推送通知。" + + "已屏蔽用户" + "获取当前推送提供者的名称。" + "未选择任何推送提供者。" + "当前推送提供商:%1$s和当前分销商:%2$s . 但经销商%3$s未找到。应用程序可能已被卸载?" + "当前推送提供商:%1$s ,但尚未配置分销商。" + "当前推送提供者:%1$s。" + "当前推送提供商:%1$s (%2$s )" + "当前推送提供者" + "确保应用程序至少有一个推送提供者。" + "未找到推送提供者。" + + "找到了 %1$d 个推送提供者:%2$s" + + "该应用程序支持:%1$s" + "检测推送提供者" + "检查应用程序是否可以显示通知。" + "通知未被点击。" + "无法显示通知。" + "通知已被点击!" + "显示通知" + "请点击通知继续测试。" + "确保应用程序正在接收推送。" + "错误:推送者拒绝了该请求。" + "错误:%1$s。" + "错误,无法测试推送。" + "错误,等待推送超时。" + "推送回路耗时%1$d 毫秒。" + "测试推送回路" + 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 0000000..0764851 --- /dev/null +++ b/libraries/push/impl/src/main/res/values/localazy.xml @@ -0,0 +1,95 @@ + + + "Call" + "Listening for events" + "Noisy notifications" + "Ringing calls" + "Silent notifications" + + "%1$s: %2$d message" + "%1$s: %2$d messages" + + + "%d notification" + "%d notifications" + + "The UnifiedPush notification distributor couldn\'t be registered, so you will not receive notifications anymore. Please check the notifications settings of the app and the status of the push distributor." + "You have new messages." + "📹 Incoming call" + "** Failed to send - please open room" + "Join" + "Reject" + + "%d invitation" + "%d invitations" + + "Invited you to chat" + "%1$s invited you to chat" + "Mentioned you: %1$s" + "New Messages" + + "%d new message" + "%d new messages" + + "Reacted with %1$s" + "Mark as read" + "Quick reply" + "Invited you to join the room" + "%1$s invited you to join the room" + "Me" + "%1$s mentioned or replied" + "You are viewing the notification! Click me!" + "Thread in %1$s" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d unread notified message" + "%d unread notified messages" + + "%1$s and %2$s" + "%1$s in %2$s" + "%1$s in %2$s and %3$s" + + "%d room" + "%d rooms" + + "Background synchronization" + "Google Services" + "No valid Google Play Services found. Notifications may not work properly." + "Checking blocked users" + "View blocked users" + "No users are blocked." + + "You blocked %1$d user. You will not receive notifications for this user." + "You blocked %1$d users. You will not receive notifications for these users." + + "Blocked users" + "Get the name of the current provider." + "No push providers selected." + "Current push provider: %1$s and current distributor: %2$s. But the distributor %3$s is not found. Maybe the application has been uninstalled?" + "Current push provider: %1$s, but no distributors have been configured." + "Current push provider: %1$s." + "Current push provider: %1$s (%2$s)" + "Current push provider" + "Ensure that the application supports at least one push provider." + "No push provider support found." + + "Found %1$d push provider: %2$s" + "Found %1$d push providers: %2$s" + + "The application was built with support for: %1$s" + "Push provider support" + "Check that the application can display notification." + "The notification has not been clicked." + "Cannot display the notification." + "The notification has been clicked!" + "Display notification" + "Please click on the notification to continue the test." + "Ensure that the application is receiving push." + "Error: pusher has rejected the request." + "Error: %1$s." + "Error, cannot test push." + "Error, timeout waiting for push." + "Push loop back took %1$d ms." + "Test Push loop back" + diff --git a/libraries/push/impl/src/main/res/xml/notifications_provider_paths.xml b/libraries/push/impl/src/main/res/xml/notifications_provider_paths.xml new file mode 100644 index 0000000..7c15e41 --- /dev/null +++ b/libraries/push/impl/src/main/res/xml/notifications_provider_paths.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/libraries/push/impl/src/main/sqldelight/io/element/android/libraries/push/impl/db/PushHistory.sq b/libraries/push/impl/src/main/sqldelight/io/element/android/libraries/push/impl/db/PushHistory.sq new file mode 100644 index 0000000..7a355ba --- /dev/null +++ b/libraries/push/impl/src/main/sqldelight/io/element/android/libraries/push/impl/db/PushHistory.sq @@ -0,0 +1,22 @@ +CREATE TABLE PushHistory ( + pushDate INTEGER NOT NULL, + providerInfo TEXT NOT NULL, + eventId TEXT, + roomId TEXT, + sessionId TEXT, + hasBeenResolved INTEGER NOT NULL, + comment TEXT +); + +selectAll: +SELECT * FROM PushHistory ORDER BY pushDate DESC; + +insertPushHistory: +INSERT INTO PushHistory VALUES ?; + +removeAll: +DELETE FROM PushHistory; + +-- add query to keep only the last x entries +removeOldest: +DELETE FROM PushHistory WHERE rowid NOT IN (SELECT rowid FROM PushHistory ORDER BY pushDate DESC LIMIT ?); diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPushServiceTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPushServiceTest.kt new file mode 100644 index 0000000..f48b7ee --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPushServiceTest.kt @@ -0,0 +1,644 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl + +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.api.core.SessionId +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus +import io.element.android.libraries.matrix.test.AN_EVENT_ID +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_SESSION_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.libraries.push.api.GetCurrentPushProvider +import io.element.android.libraries.push.api.PusherRegistrationFailure +import io.element.android.libraries.push.api.history.PushHistoryItem +import io.element.android.libraries.push.impl.push.FakeMutableBatteryOptimizationStore +import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore +import io.element.android.libraries.push.impl.store.InMemoryPushDataStore +import io.element.android.libraries.push.impl.store.PushDataStore +import io.element.android.libraries.push.impl.test.FakeTestPush +import io.element.android.libraries.push.impl.test.TestPush +import io.element.android.libraries.push.impl.unregistration.FakeServiceUnregisteredHandler +import io.element.android.libraries.push.impl.unregistration.ServiceUnregisteredHandler +import io.element.android.libraries.push.test.FakeGetCurrentPushProvider +import io.element.android.libraries.pushproviders.api.Config +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.libraries.pushproviders.test.FakePushProvider +import io.element.android.libraries.pushproviders.test.aSessionPushConfig +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.InMemoryPushClientSecretStore +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultPushServiceTest { + @Test + fun `test push no push provider`() = runTest { + val defaultPushService = createDefaultPushService() + assertThat(defaultPushService.testPush(A_SESSION_ID)).isFalse() + } + + @Test + fun `test push no config`() = runTest { + val aPushProvider = FakePushProvider() + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aPushProvider.name), + ) + assertThat(defaultPushService.testPush(A_SESSION_ID)).isFalse() + } + + @Test + fun `test push ok`() = runTest { + val aConfig = aSessionPushConfig() + val testPushResult = lambdaRecorder { } + val aPushProvider = FakePushProvider( + config = aConfig + ) + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aPushProvider.name), + testPush = FakeTestPush(executeResult = testPushResult), + ) + assertThat(defaultPushService.testPush(A_SESSION_ID)).isTrue() + testPushResult.assertions() + .isCalledOnce() + .with(value(aConfig)) + } + + @Test + fun `getCurrentPushProvider null`() = runTest { + val defaultPushService = createDefaultPushService() + val result = defaultPushService.getCurrentPushProvider(A_SESSION_ID) + assertThat(result).isNull() + } + + @Test + fun `getCurrentPushProvider ok`() = runTest { + val aPushProvider = FakePushProvider() + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aPushProvider.name), + ) + val result = defaultPushService.getCurrentPushProvider(A_SESSION_ID) + assertThat(result).isEqualTo(aPushProvider) + } + + @Test + fun `getAvailablePushProviders empty`() = runTest { + val defaultPushService = createDefaultPushService() + val result = defaultPushService.getAvailablePushProviders() + assertThat(result).isEmpty() + } + + @Test + fun `registerWith ok`() = runTest { + val client = FakeMatrixClient() + val aPushProvider = FakePushProvider( + registerWithResult = { _, _ -> Result.success(Unit) }, + ) + val aDistributor = Distributor("aValue", "aName") + val defaultPushService = createDefaultPushService() + val result = defaultPushService.registerWith(client, aPushProvider, aDistributor) + assertThat(result).isEqualTo(Result.success(Unit)) + } + + @Test + fun `registerWith fail to register`() = runTest { + val client = FakeMatrixClient() + val aPushProvider = FakePushProvider( + registerWithResult = { _, _ -> Result.failure(AN_EXCEPTION) }, + ) + val aDistributor = Distributor("aValue", "aName") + val defaultPushService = createDefaultPushService() + val result = defaultPushService.registerWith(client, aPushProvider, aDistributor) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `registerWith fail to unregister previous push provider`() = runTest { + val client = FakeMatrixClient() + val aCurrentPushProvider = FakePushProvider( + unregisterWithResult = { Result.failure(AN_EXCEPTION) }, + name = "aCurrentPushProvider", + ) + val aPushProvider = FakePushProvider( + name = "aPushProvider", + ) + val userPushStore = FakeUserPushStore().apply { + setPushProviderName(aCurrentPushProvider.name) + } + val aDistributor = Distributor("aValue", "aName") + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aCurrentPushProvider, aPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aCurrentPushProvider.name), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { userPushStore }, + ), + ) + val result = defaultPushService.registerWith(client, aPushProvider, aDistributor) + assertThat(result.isFailure).isTrue() + assertThat(userPushStore.getPushProviderName()).isEqualTo(aCurrentPushProvider.name) + } + + @Test + fun `registerWith unregister previous push provider and register new OK`() = runTest { + val client = FakeMatrixClient() + val unregisterLambda = lambdaRecorder> { Result.success(Unit) } + val registerLambda = lambdaRecorder> { _, _ -> Result.success(Unit) } + val aCurrentPushProvider = FakePushProvider( + unregisterWithResult = unregisterLambda, + name = "aCurrentPushProvider", + ) + val aPushProvider = FakePushProvider( + registerWithResult = registerLambda, + name = "aPushProvider", + ) + val userPushStore = FakeUserPushStore().apply { + setPushProviderName(aCurrentPushProvider.name) + } + val aDistributor = Distributor("aValue", "aName") + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aCurrentPushProvider, aPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aCurrentPushProvider.name), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { userPushStore }, + ), + ) + val result = defaultPushService.registerWith(client, aPushProvider, aDistributor) + assertThat(result.isSuccess).isTrue() + assertThat(userPushStore.getPushProviderName()).isEqualTo(aPushProvider.name) + unregisterLambda.assertions() + .isCalledOnce() + .with(value(client)) + registerLambda.assertions() + .isCalledOnce() + .with(value(client), value(aDistributor)) + } + + @Test + fun `getAvailablePushProviders sorted`() = runTest { + val aPushProvider1 = FakePushProvider( + index = 1, + name = "aPushProvider1", + ) + val aPushProvider2 = FakePushProvider( + index = 2, + name = "aPushProvider2", + ) + val aPushProvider3 = FakePushProvider( + index = 3, + name = "aPushProvider3", + ) + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aPushProvider1, aPushProvider3, aPushProvider2), + ) + val result = defaultPushService.getAvailablePushProviders() + assertThat(result).containsExactly(aPushProvider1, aPushProvider2, aPushProvider3).inOrder() + } + + @Test + fun `test setIgnoreRegistrationError is sent to the store`() = runTest { + val userPushStore = FakeUserPushStore().apply { + } + val defaultPushService = createDefaultPushService( + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { userPushStore }, + ), + ) + assertThat(defaultPushService.ignoreRegistrationError(A_SESSION_ID).first()).isFalse() + defaultPushService.setIgnoreRegistrationError(A_SESSION_ID, true) + assertThat(defaultPushService.ignoreRegistrationError(A_SESSION_ID).first()).isTrue() + } + + @Test + fun `onSessionCreated is noop`() = runTest { + val defaultPushService = createDefaultPushService() + defaultPushService.onSessionCreated(A_SESSION_ID.value) + } + + @Test + fun `onSessionDeleted should transmit the info to the current push provider and cleanup the stores`() = runTest { + val onSessionDeletedLambda = lambdaRecorder { } + val aCurrentPushProvider = FakePushProvider( + name = "aCurrentPushProvider", + onSessionDeletedLambda = onSessionDeletedLambda, + ) + val userPushStore = FakeUserPushStore( + pushProviderName = aCurrentPushProvider.name, + ) + val pushClientSecretStore = InMemoryPushClientSecretStore() + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aCurrentPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aCurrentPushProvider.name), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { userPushStore }, + ), + pushClientSecretStore = pushClientSecretStore, + ) + defaultPushService.onSessionDeleted(A_SESSION_ID.value, false) + assertThat(userPushStore.getPushProviderName()).isNull() + assertThat(pushClientSecretStore.getSecret(A_SESSION_ID)).isNull() + onSessionDeletedLambda.assertions().isCalledOnce().with(value(A_SESSION_ID)) + } + + @Test + fun `onSessionDeleted when there is no push provider should just cleanup the stores`() = runTest { + val userPushStore = FakeUserPushStore( + pushProviderName = null, + ) + val pushClientSecretStore = InMemoryPushClientSecretStore() + val defaultPushService = createDefaultPushService( + pushProviders = emptySet(), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = null), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { userPushStore }, + ), + pushClientSecretStore = pushClientSecretStore, + ) + defaultPushService.onSessionDeleted(A_SESSION_ID.value, false) + assertThat(userPushStore.getPushProviderName()).isNull() + assertThat(pushClientSecretStore.getSecret(A_SESSION_ID)).isNull() + } + + @Test + fun `selectPushProvider should store the data in the store`() = runTest { + val userPushStore = FakeUserPushStore() + val defaultPushService = createDefaultPushService( + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { userPushStore }, + ), + ) + val aPushProvider = FakePushProvider( + name = "aCurrentPushProvider", + ) + assertThat(userPushStore.getPushProviderName()).isNull() + defaultPushService.selectPushProvider(A_SESSION_ID, aPushProvider) + assertThat(userPushStore.getPushProviderName()).isEqualTo(aPushProvider.name) + } + + @Test + fun `resetBatteryOptimizationState invokes the store method`() = runTest { + val resetResult = lambdaRecorder { } + val defaultPushService = createDefaultPushService( + mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore( + resetResult = resetResult, + ), + ) + defaultPushService.resetBatteryOptimizationState() + resetResult.assertions().isCalledOnce() + } + + @Test + fun `resetPushHistory invokes the store method`() = runTest { + val resetResult = lambdaRecorder { } + val defaultPushService = createDefaultPushService( + pushDataStore = InMemoryPushDataStore( + resetResult = resetResult + ), + ) + defaultPushService.resetPushHistory() + resetResult.assertions().isCalledOnce() + } + + @Test + fun `getPushHistoryItemsFlow invokes the store method`() = runTest { + val store = InMemoryPushDataStore() + val aPushHistoryItem = PushHistoryItem( + pushDate = 0L, + formattedDate = "formattedDate", + providerInfo = "providerInfo", + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + sessionId = A_SESSION_ID, + hasBeenResolved = false, + comment = null, + ) + val defaultPushService = createDefaultPushService( + pushDataStore = store, + ) + defaultPushService.getPushHistoryItemsFlow().test { + assertThat(awaitItem().isEmpty()).isTrue() + store.emitPushHistoryItems(listOf(aPushHistoryItem)) + assertThat(awaitItem().first()).isEqualTo(aPushHistoryItem) + } + } + + @Test + fun `ensurePusher - error when account is not verified`() = runTest { + val sessionVerificationService = FakeSessionVerificationService( + initialSessionVerifiedStatus = SessionVerifiedStatus.NotVerified + ) + val pushService = createDefaultPushService() + val result = pushService.ensurePusherIsRegistered( + FakeMatrixClient( + sessionVerificationService = sessionVerificationService, + ) + ) + assertThat(result.exceptionOrNull()!!).isInstanceOf(PusherRegistrationFailure.AccountNotVerified::class.java) + } + + @Test + fun `ensurePusher - case two push providers but first one does not have distributor - second one will be used`() = runTest { + val lambda = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val sessionVerificationService = FakeSessionVerificationService( + initialSessionVerifiedStatus = SessionVerifiedStatus.Verified + ) + val pushProvider0 = FakePushProvider( + index = 0, + name = "aFakePushProvider0", + distributors = emptyList(), + ) + val distributor = Distributor("aDistributorValue1", "aDistributorName1") + val pushProvider1 = FakePushProvider( + index = 1, + name = "aFakePushProvider1", + distributors = listOf(distributor), + registerWithResult = lambda, + ) + val pushService = createDefaultPushService( + pushProviders = setOf( + pushProvider0, + pushProvider1, + ), + ) + val result = pushService.ensurePusherIsRegistered( + FakeMatrixClient( + sessionVerificationService = sessionVerificationService, + ) + ) + assertThat(result.isSuccess).isTrue() + lambda.assertions().isCalledOnce() + .with( + // MatrixClient + any(), + // First distributor of second push provider + value(distributor), + ) + } + + @Test + fun `ensurePusher - case one push provider but no distributor available`() = runTest { + val lambda = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val sessionVerificationService = FakeSessionVerificationService( + initialSessionVerifiedStatus = SessionVerifiedStatus.Verified + ) + val pushProvider = FakePushProvider( + index = 0, + name = "aFakePushProvider", + distributors = emptyList(), + registerWithResult = lambda, + ) + val pushService = createDefaultPushService( + pushProviders = setOf(pushProvider), + ) + val result = pushService.ensurePusherIsRegistered( + FakeMatrixClient( + sessionVerificationService = sessionVerificationService, + ) + ) + assertThat(result.exceptionOrNull()).isInstanceOf(PusherRegistrationFailure.NoDistributorsAvailable::class.java) + lambda.assertions().isNeverCalled() + } + + @Test + fun `ensurePusher - ensure default pusher is registered with default provider`() = runTest { + val lambda = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val sessionVerificationService = FakeSessionVerificationService( + initialSessionVerifiedStatus = SessionVerifiedStatus.Verified + ) + val pushService = createDefaultPushService( + pushProviders = setOf( + FakePushProvider( + index = 0, + name = "aFakePushProvider", + distributors = listOf(Distributor("aDistributorValue0", "aDistributorName0")), + registerWithResult = lambda, + ) + ), + ) + val result = pushService.ensurePusherIsRegistered( + FakeMatrixClient( + sessionVerificationService = sessionVerificationService, + ) + ) + assertThat(result.isSuccess).isTrue() + lambda.assertions() + .isCalledOnce() + .with( + // MatrixClient + any(), + // First distributor + value(pushService.getAvailablePushProviders()[0].getDistributors()[0]), + ) + } + + @Test + fun `ensurePusher - ensure default pusher is registered with default provider - fail to register`() = runTest { + val lambda = lambdaRecorder> { _, _ -> + Result.failure(AN_EXCEPTION) + } + val sessionVerificationService = FakeSessionVerificationService( + initialSessionVerifiedStatus = SessionVerifiedStatus.Verified + ) + val pushService = createDefaultPushService( + pushProviders = setOf( + FakePushProvider( + index = 0, + name = "aFakePushProvider", + distributors = listOf(Distributor("aDistributorValue0", "aDistributorName0")), + registerWithResult = lambda, + ) + ), + ) + val result = pushService.ensurePusherIsRegistered( + FakeMatrixClient( + sessionVerificationService = sessionVerificationService, + ) + ) + assertThat(result.isFailure).isTrue() + lambda.assertions() + .isCalledOnce() + .with( + // MatrixClient + any(), + // First distributor + value(pushService.getAvailablePushProviders()[0].getDistributors()[0]), + ) + } + + @Test + fun `ensurePusher - if current push provider does not have distributors, nothing happen`() = runTest { + val lambda = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val sessionVerificationService = FakeSessionVerificationService( + initialSessionVerifiedStatus = SessionVerifiedStatus.Verified + ) + val pushProvider = FakePushProvider( + index = 0, + name = "aFakePushProvider0", + distributors = emptyList(), + registerWithResult = lambda, + ) + val pushService = createDefaultPushService( + pushProviders = setOf(pushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = pushProvider.name), + ) + val result = pushService.ensurePusherIsRegistered( + FakeMatrixClient( + sessionVerificationService = sessionVerificationService, + ) + ) + assertThat(result.exceptionOrNull()) + .isInstanceOf(PusherRegistrationFailure.NoDistributorsAvailable::class.java) + lambda.assertions() + .isNeverCalled() + } + + @Test + fun `ensurePusher - ensure current provider is registered with current distributor`() = runTest { + val lambda = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val sessionVerificationService = FakeSessionVerificationService( + initialSessionVerifiedStatus = SessionVerifiedStatus.Verified + ) + val distributor = Distributor("aDistributorValue1", "aDistributorName1") + val pushProvider = FakePushProvider( + index = 0, + name = "aFakePushProvider0", + distributors = listOf( + Distributor("aDistributorValue0", "aDistributorName0"), + distributor, + ), + currentDistributor = { distributor }, + registerWithResult = lambda, + ) + val pushService = createDefaultPushService( + pushProviders = setOf(pushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = pushProvider.name), + ) + val result = pushService.ensurePusherIsRegistered( + FakeMatrixClient( + sessionVerificationService = sessionVerificationService, + ) + ) + assertThat(result.isSuccess).isTrue() + lambda.assertions() + .isCalledOnce() + .with( + // MatrixClient + any(), + // Current distributor + value(distributor), + ) + } + + @Test + fun `ensurePusher - case no push provider available provider`() = runTest { + val lambda = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val sessionVerificationService = FakeSessionVerificationService(SessionVerifiedStatus.Verified) + val pushService = createDefaultPushService( + pushProviders = emptySet(), + ) + val result = pushService.ensurePusherIsRegistered( + FakeMatrixClient( + sessionVerificationService = sessionVerificationService, + ) + ) + assertThat(result.exceptionOrNull()) + .isInstanceOf(PusherRegistrationFailure.NoProvidersAvailable::class.java) + lambda.assertions() + .isNeverCalled() + } + + @Test + fun `ensurePusher - if current push provider does not have current distributor, the first one is used`() = runTest { + val lambda = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val sessionVerificationService = FakeSessionVerificationService( + initialSessionVerifiedStatus = SessionVerifiedStatus.Verified + ) + val pushProvider = FakePushProvider( + index = 0, + name = "aFakePushProvider0", + distributors = listOf( + Distributor("aDistributorValue0", "aDistributorName0"), + Distributor("aDistributorValue1", "aDistributorName1"), + ), + currentDistributor = { null }, + registerWithResult = lambda, + ) + val pushService = createDefaultPushService( + pushProviders = setOf(pushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = pushProvider.name), + ) + val result = pushService.ensurePusherIsRegistered( + FakeMatrixClient( + sessionVerificationService = sessionVerificationService, + ) + ) + assertThat(result.isSuccess).isTrue() + lambda.assertions() + .isCalledOnce() + .with( + // MatrixClient + any(), + // First distributor + value(pushService.getAvailablePushProviders()[0].getDistributors()[0]), + ) + } + + private fun createDefaultPushService( + testPush: TestPush = FakeTestPush(), + userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(), + pushProviders: Set<@JvmSuppressWildcards PushProvider> = emptySet(), + getCurrentPushProvider: GetCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = null), + sessionObserver: SessionObserver = NoOpSessionObserver(), + pushClientSecretStore: PushClientSecretStore = InMemoryPushClientSecretStore(), + pushDataStore: PushDataStore = InMemoryPushDataStore(), + mutableBatteryOptimizationStore: MutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(), + serviceUnregisteredHandler: ServiceUnregisteredHandler = FakeServiceUnregisteredHandler(), + ): DefaultPushService { + return DefaultPushService( + testPush = testPush, + userPushStoreFactory = userPushStoreFactory, + pushProviders = pushProviders, + getCurrentPushProvider = getCurrentPushProvider, + sessionObserver = sessionObserver, + pushClientSecretStore = pushClientSecretStore, + pushDataStore = pushDataStore, + mutableBatteryOptimizationStore = mutableBatteryOptimizationStore, + serviceUnregisteredHandler = serviceUnregisteredHandler, + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt new file mode 100644 index 0000000..dd2cb24 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.appconfig.PushConfig +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData +import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.pushers.FakePushersService +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultPusherSubscriberTest { + @Test + fun `test register pusher OK`() = runTest { + testRegisterPusher( + currentPushKey = null, + registerResult = Result.success(Unit), + ) + } + + @Test + fun `test re-register pusher OK`() = runTest { + testRegisterPusher( + currentPushKey = "aPushKey", + registerResult = Result.success(Unit), + ) + } + + @Test + fun `test register pusher error`() = runTest { + testRegisterPusher( + currentPushKey = null, + registerResult = Result.failure(AN_EXCEPTION), + ) + } + + @Test + fun `test re-register pusher error`() = runTest { + testRegisterPusher( + currentPushKey = "aPushKey", + registerResult = Result.failure(AN_EXCEPTION), + ) + } + + private suspend fun testRegisterPusher( + currentPushKey: String?, + registerResult: Result, + ) { + val setHttpPusherResult = lambdaRecorder> { registerResult } + val userPushStore = FakeUserPushStore().apply { + setCurrentRegisteredPushKey(currentPushKey) + } + assertThat(userPushStore.getCurrentRegisteredPushKey()).isEqualTo(currentPushKey) + + val matrixClient = FakeMatrixClient( + pushersService = FakePushersService( + setHttpPusherResult = setHttpPusherResult, + ), + ) + val defaultPusherSubscriber = createDefaultPusherSubscriber( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET }, + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { userPushStore }, + ), + ) + val result = defaultPusherSubscriber.registerPusher( + matrixClient = matrixClient, + pushKey = "aPushKey", + gateway = "aGateway", + ) + assertThat(result).isEqualTo(registerResult) + setHttpPusherResult.assertions() + .isCalledOnce() + .with( + value( + SetHttpPusherData( + pushKey = "aPushKey", + appId = PushConfig.PUSHER_APP_ID, + url = "aGateway", + appDisplayName = "MyApp", + deviceDisplayName = "MyDevice", + profileTag = DEFAULT_PUSHER_FILE_TAG + "_", + lang = "en", + defaultPayload = "{\"cs\":\"$A_SECRET\"}", + ), + ) + ) + assertThat(userPushStore.getCurrentRegisteredPushKey()).isEqualTo( + if (registerResult.isSuccess) "aPushKey" else currentPushKey + ) + } + + @Test + fun `test unregister pusher OK`() = runTest { + testUnregisterPusher( + currentPushKey = "aPushKey", + unregisterResult = Result.success(Unit), + ) + } + + @Test + fun `test unregister pusher error`() = runTest { + testUnregisterPusher( + currentPushKey = "aPushKey", + unregisterResult = Result.failure(AN_EXCEPTION), + ) + } + + private suspend fun testUnregisterPusher( + currentPushKey: String?, + unregisterResult: Result, + ) { + val unsetHttpPusherResult = lambdaRecorder> { unregisterResult } + val userPushStore = FakeUserPushStore().apply { + setCurrentRegisteredPushKey(currentPushKey) + } + assertThat(userPushStore.getCurrentRegisteredPushKey()).isEqualTo(currentPushKey) + + val matrixClient = FakeMatrixClient( + pushersService = FakePushersService( + unsetHttpPusherResult = unsetHttpPusherResult, + ), + ) + val defaultPusherSubscriber = createDefaultPusherSubscriber( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET }, + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { userPushStore }, + ), + ) + val result = defaultPusherSubscriber.unregisterPusher( + matrixClient = matrixClient, + pushKey = "aPushKey", + gateway = "aGateway", + ) + assertThat(result).isEqualTo(unregisterResult) + unsetHttpPusherResult.assertions() + .isCalledOnce() + .with( + value( + UnsetHttpPusherData( + pushKey = "aPushKey", + appId = PushConfig.PUSHER_APP_ID, + ), + ) + ) + assertThat(userPushStore.getCurrentRegisteredPushKey()).isEqualTo( + if (unregisterResult.isSuccess) null else currentPushKey + ) + } + + private fun createDefaultPusherSubscriber( + buildMeta: BuildMeta = aBuildMeta(applicationName = "MyApp"), + userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(), + pushClientSecret: PushClientSecret = FakePushClientSecret(), + ): DefaultPusherSubscriber { + return DefaultPusherSubscriber( + buildMeta = buildMeta, + pushClientSecret = pushClientSecret, + userPushStoreFactory = userPushStoreFactory, + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/AndroidBatteryOptimizationTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/AndroidBatteryOptimizationTest.kt new file mode 100644 index 0000000..dd1dd7b --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/AndroidBatteryOptimizationTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.battery + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.provider.Settings +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher +import io.element.android.services.toolbox.test.intent.FakeExternalIntentLauncher +import io.element.android.tests.testutils.lambda.lambdaRecorder +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AndroidBatteryOptimizationTest { + @Test + fun `isIgnoringBatteryOptimizations should return false`() { + val sut = createAndroidBatteryOptimization() + assertThat(sut.isIgnoringBatteryOptimizations()).isFalse() + } + + @Test + fun `requestDisablingBatteryOptimization is called once with expected intent`() { + val launchLambda = lambdaRecorder { intent -> + assertThat(intent.action).isEqualTo(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + assertThat(intent.data.toString()).isEqualTo("package:${InstrumentationRegistry.getInstrumentation().context.packageName}") + } + val externalIntentLauncher = FakeExternalIntentLauncher(launchLambda) + val sut = createAndroidBatteryOptimization( + externalIntentLauncher = externalIntentLauncher, + ) + val result = sut.requestDisablingBatteryOptimization() + launchLambda.assertions().isCalledOnce() + assertThat(result).isTrue() + } + + @Test + fun `in case of 1 error, requestDisablingBatteryOptimization returns true`() { + var callNumber = 0 + val launchLambda = lambdaRecorder { intent -> + callNumber++ + when (callNumber) { + 1 -> { + assertThat(intent.action).isEqualTo(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + assertThat(intent.data.toString()).isEqualTo("package:${InstrumentationRegistry.getInstrumentation().context.packageName}") + throw ActivityNotFoundException("Test exception") + } + 2 -> { + assertThat(intent.action).isEqualTo(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) + assertThat(intent.data).isNull() + // No error + } + else -> { + throw AssertionError("Unexpected call number: $callNumber") + } + } + } + val externalIntentLauncher = FakeExternalIntentLauncher(launchLambda) + val sut = createAndroidBatteryOptimization( + externalIntentLauncher = externalIntentLauncher, + ) + val result = sut.requestDisablingBatteryOptimization() + launchLambda.assertions().isCalledExactly(2) + assertThat(result).isTrue() + } + + @Test + fun `in case of 2 errors, requestDisablingBatteryOptimization returns false`() { + var callNumber = 0 + val launchLambda = lambdaRecorder { intent -> + callNumber++ + when (callNumber) { + 1 -> { + assertThat(intent.action).isEqualTo(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + assertThat(intent.data.toString()).isEqualTo("package:${InstrumentationRegistry.getInstrumentation().context.packageName}") + throw ActivityNotFoundException("Test exception") + } + 2 -> { + assertThat(intent.action).isEqualTo(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) + assertThat(intent.data).isNull() + throw ActivityNotFoundException("Test exception") + } + else -> { + throw AssertionError("Unexpected call number: $callNumber") + } + } + } + val externalIntentLauncher = FakeExternalIntentLauncher(launchLambda) + val sut = createAndroidBatteryOptimization( + externalIntentLauncher = externalIntentLauncher, + ) + val result = sut.requestDisablingBatteryOptimization() + launchLambda.assertions().isCalledExactly(2) + assertThat(result).isFalse() + } + + private fun createAndroidBatteryOptimization( + externalIntentLauncher: ExternalIntentLauncher = FakeExternalIntentLauncher(), + ): AndroidBatteryOptimization { + return AndroidBatteryOptimization( + context = InstrumentationRegistry.getInstrumentation().context, + externalIntentLauncher = externalIntentLauncher, + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimizationPresenterTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimizationPresenterTest.kt new file mode 100644 index 0000000..d3b2c8d --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/BatteryOptimizationPresenterTest.kt @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.battery + +import androidx.lifecycle.Lifecycle +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.push.api.battery.BatteryOptimizationEvents +import io.element.android.libraries.push.impl.push.FakeMutableBatteryOptimizationStore +import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore +import io.element.android.libraries.push.impl.store.InMemoryPushDataStore +import io.element.android.libraries.push.impl.store.PushDataStore +import io.element.android.tests.testutils.FakeLifecycleOwner +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.testWithLifecycleOwner +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class BatteryOptimizationPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter( + pushDataStore = InMemoryPushDataStore( + initialShouldDisplayBatteryOptimizationBanner = false, + ), + batteryOptimization = FakeBatteryOptimization( + isIgnoringBatteryOptimizationsResult = false, + ), + ) + val lifeCycleOwner = FakeLifecycleOwner() + presenter.testWithLifecycleOwner(lifeCycleOwner) { + val initialState = awaitItem() + assertThat(initialState.shouldDisplayBanner).isFalse() + lifeCycleOwner.givenState(Lifecycle.State.RESUMED) + } + } + + @Test + fun `present - should display banner`() = runTest { + val presenter = createPresenter( + pushDataStore = InMemoryPushDataStore( + initialShouldDisplayBatteryOptimizationBanner = true, + ), + batteryOptimization = FakeBatteryOptimization( + isIgnoringBatteryOptimizationsResult = false, + ), + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + assertThat(initialState.shouldDisplayBanner).isFalse() + assertThat(awaitItem().shouldDisplayBanner).isTrue() + } + } + + @Test + fun `present - should display banner, but setting already performed`() = runTest { + val presenter = createPresenter( + pushDataStore = InMemoryPushDataStore( + initialShouldDisplayBatteryOptimizationBanner = true, + ), + batteryOptimization = FakeBatteryOptimization( + isIgnoringBatteryOptimizationsResult = true, + ), + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + assertThat(initialState.shouldDisplayBanner).isFalse() + assertThat(awaitItem().shouldDisplayBanner).isFalse() + } + } + + @Test + fun `present - should display banner, user dismisses`() = runTest { + val onOptimizationBannerDismissedResult = lambdaRecorder { } + val presenter = createPresenter( + pushDataStore = InMemoryPushDataStore( + initialShouldDisplayBatteryOptimizationBanner = true, + ), + batteryOptimization = FakeBatteryOptimization( + isIgnoringBatteryOptimizationsResult = false, + ), + mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore( + onOptimizationBannerDismissedResult = onOptimizationBannerDismissedResult, + ), + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + assertThat(initialState.shouldDisplayBanner).isFalse() + val displayedItem = awaitItem() + assertThat(displayedItem.shouldDisplayBanner).isTrue() + displayedItem.eventSink(BatteryOptimizationEvents.Dismiss) + onOptimizationBannerDismissedResult.assertions().isCalledOnce() + } + } + + @Test + fun `present - should display banner, user continue, error case`() = runTest { + val onOptimizationBannerDismissedResult = lambdaRecorder { } + val requestDisablingBatteryOptimizationResult = lambdaRecorder { false } + val presenter = createPresenter( + pushDataStore = InMemoryPushDataStore( + initialShouldDisplayBatteryOptimizationBanner = true, + ), + batteryOptimization = FakeBatteryOptimization( + isIgnoringBatteryOptimizationsResult = false, + requestDisablingBatteryOptimizationResult = requestDisablingBatteryOptimizationResult + ), + mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore( + onOptimizationBannerDismissedResult = onOptimizationBannerDismissedResult, + ), + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + assertThat(initialState.shouldDisplayBanner).isFalse() + val displayedItem = awaitItem() + assertThat(displayedItem.shouldDisplayBanner).isTrue() + displayedItem.eventSink(BatteryOptimizationEvents.RequestDisableOptimizations) + requestDisablingBatteryOptimizationResult.assertions().isCalledOnce() + onOptimizationBannerDismissedResult.assertions().isCalledOnce() + } + } + + @Test + fun `present - should display banner, user continue, nominal case`() = runTest { + val requestDisablingBatteryOptimizationResult = lambdaRecorder { true } + val batteryOptimization = FakeBatteryOptimization( + isIgnoringBatteryOptimizationsResult = false, + requestDisablingBatteryOptimizationResult = requestDisablingBatteryOptimizationResult + ) + val presenter = createPresenter( + pushDataStore = InMemoryPushDataStore( + initialShouldDisplayBatteryOptimizationBanner = true, + ), + batteryOptimization = batteryOptimization, + mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(), + ) + val lifeCycleOwner = FakeLifecycleOwner() + presenter.testWithLifecycleOwner(lifeCycleOwner) { + val initialState = awaitItem() + assertThat(initialState.shouldDisplayBanner).isFalse() + val displayedItem = awaitItem() + assertThat(displayedItem.shouldDisplayBanner).isTrue() + displayedItem.eventSink(BatteryOptimizationEvents.RequestDisableOptimizations) + requestDisablingBatteryOptimizationResult.assertions().isCalledOnce() + batteryOptimization.isIgnoringBatteryOptimizationsResult = true + lifeCycleOwner.givenState(Lifecycle.State.RESUMED) + assertThat(awaitItem().shouldDisplayBanner).isFalse() + assertThat(awaitItem().shouldDisplayBanner).isFalse() + } + } + + private fun createPresenter( + pushDataStore: PushDataStore = InMemoryPushDataStore(), + mutableBatteryOptimizationStore: MutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(), + batteryOptimization: BatteryOptimization = FakeBatteryOptimization(), + ) = BatteryOptimizationPresenter( + pushDataStore = pushDataStore, + mutableBatteryOptimizationStore = mutableBatteryOptimizationStore, + batteryOptimization = batteryOptimization + ) +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/FakeBatteryOptimization.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/FakeBatteryOptimization.kt new file mode 100644 index 0000000..ae3bfed --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/battery/FakeBatteryOptimization.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.battery + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeBatteryOptimization( + var isIgnoringBatteryOptimizationsResult: Boolean = false, + private val requestDisablingBatteryOptimizationResult: () -> Boolean = { lambdaError() } +) : BatteryOptimization { + override fun isIgnoringBatteryOptimizations(): Boolean { + return isIgnoringBatteryOptimizationsResult + } + + override fun requestDisablingBatteryOptimization(): Boolean { + return requestDisablingBatteryOptimizationResult() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt new file mode 100644 index 0000000..aac8f8f --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.history + +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.tests.testutils.lambda.lambdaError + +class FakePushHistoryService( + private val onPushReceivedResult: ( + String, + EventId?, + RoomId?, + SessionId?, + Boolean, + Boolean, + String? + ) -> Unit = { _, _, _, _, _, _, _ -> lambdaError() } +) : PushHistoryService { + override fun onPushReceived( + providerInfo: String, + eventId: EventId?, + roomId: RoomId?, + sessionId: SessionId?, + hasBeenResolved: Boolean, + includeDeviceState: Boolean, + comment: String?, + ) { + onPushReceivedResult( + providerInfo, + eventId, + roomId, + sessionId, + hasBeenResolved, + includeDeviceState, + comment + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultActiveNotificationsProviderTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultActiveNotificationsProviderTest.kt new file mode 100644 index 0000000..573ec37 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultActiveNotificationsProviderTest.kt @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.app.Notification +import android.service.notification.StatusBarNotification +import androidx.core.app.NotificationManagerCompat +import androidx.test.platform.app.InstrumentationRegistry +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_ROOM_ID_2 +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.A_THREAD_ID +import io.element.android.libraries.push.api.notifications.NotificationIdProvider +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultActiveNotificationsProviderTest { + private val notificationIdProvider = NotificationIdProvider + + @Test + fun `getNotificationsForSession returns only notifications for that session id`() { + val activeNotifications = listOf( + aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value), + aStatusBarNotification(id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value), + ) + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications) + + assertThat(activeNotificationsProvider.getNotificationsForSession(A_SESSION_ID)).hasSize(1) + assertThat(activeNotificationsProvider.getNotificationsForSession(A_SESSION_ID_2)).hasSize(2) + } + + @Test + fun `getMembershipNotificationsForSession returns only membership notifications for that session id`() { + val activeNotifications = listOf( + aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value), + aStatusBarNotification( + id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), + groupId = A_SESSION_ID_2.value, + tag = A_ROOM_ID.value, + ), + ) + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications) + + assertThat(activeNotificationsProvider.getMembershipNotificationForSession(A_SESSION_ID)).isEmpty() + assertThat(activeNotificationsProvider.getMembershipNotificationForSession(A_SESSION_ID_2)).hasSize(1) + } + + @Test + fun `getMessageNotificationsForRoom returns only message notifications for those session and room ids`() { + val activeNotifications = listOf( + aStatusBarNotification( + id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), + groupId = A_SESSION_ID.value, + tag = A_ROOM_ID.value + ), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value, tag = A_ROOM_ID.value), + aStatusBarNotification( + id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID_2), + groupId = A_SESSION_ID_2.value, + tag = A_ROOM_ID.value + ), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value, tag = A_ROOM_ID.value), + aStatusBarNotification( + id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), + groupId = A_SESSION_ID_2.value, + tag = A_ROOM_ID.value + ), + ) + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications) + + assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID, A_ROOM_ID, null)).hasSize(1) + assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID_2, A_ROOM_ID_2, null)).isEmpty() + } + + @Test + fun `getMessageNotificationsForRoom with thread id returns only message notifications for a thread using those session and room ids`() { + val activeNotifications = listOf( + aStatusBarNotification( + id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), + groupId = A_SESSION_ID.value, + tag = "$A_ROOM_ID|$A_THREAD_ID", + ), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value, tag = A_ROOM_ID.value), + aStatusBarNotification( + id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID_2), + groupId = A_SESSION_ID_2.value, + tag = "$A_ROOM_ID|$A_THREAD_ID", + ), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value, tag = A_ROOM_ID.value), + aStatusBarNotification( + id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), + groupId = A_SESSION_ID_2.value, + tag = "$A_ROOM_ID|$A_THREAD_ID", + ), + ) + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications) + + assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID)).hasSize(1) + assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID_2, A_ROOM_ID_2, A_THREAD_ID)).isEmpty() + } + + @Test + fun `getMembershipNotificationsForRoom returns only membership notifications for those session and room ids`() { + val activeNotifications = listOf( + aStatusBarNotification( + id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), + groupId = A_SESSION_ID.value, + tag = A_ROOM_ID.value + ), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value, tag = A_ROOM_ID.value), + aStatusBarNotification( + id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), + groupId = A_SESSION_ID_2.value, + tag = A_ROOM_ID_2.value + ), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value, tag = A_ROOM_ID.value), + aStatusBarNotification( + id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), + groupId = A_SESSION_ID_2.value, + tag = A_ROOM_ID_2.value + ), + ) + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications) + + assertThat(activeNotificationsProvider.getMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID)).isEmpty() + assertThat(activeNotificationsProvider.getMembershipNotificationForRoom(A_SESSION_ID_2, A_ROOM_ID_2)).hasSize(2) + } + + @Test + fun `getSummaryNotification returns only the summary notification for that session id if it exists`() { + val activeNotifications = listOf( + aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value), + aStatusBarNotification(id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value), + ) + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications) + + assertThat(activeNotificationsProvider.getSummaryNotification(A_SESSION_ID)).isNotNull() + assertThat(activeNotificationsProvider.getSummaryNotification(A_SESSION_ID_2)).isNull() + } + + private fun aStatusBarNotification(id: Int, groupId: String, tag: String? = null) = mockk { + every { this@mockk.id } returns id + every { this@mockk.tag } returns tag + @Suppress("DEPRECATION") + every { this@mockk.notification } returns Notification.Builder(InstrumentationRegistry.getInstrumentation().targetContext).setGroup(groupId).build() + } + + private fun createActiveNotificationsProvider( + activeNotifications: List = emptyList(), + ): DefaultActiveNotificationsProvider { + val notificationManager = mockk { + every { this@mockk.activeNotifications } returns activeNotifications + } + return DefaultActiveNotificationsProvider( + notificationManager = notificationManager, + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultBaseRoomGroupMessageCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultBaseRoomGroupMessageCreatorTest.kt new file mode 100644 index 0000000..5426082 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultBaseRoomGroupMessageCreatorTest.kt @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import com.google.common.truth.Truth.assertThat +import io.element.android.appconfig.NotificationConfig +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_TIMESTAMP +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL +import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrix.ui.media.test.FakeImageLoader +import io.element.android.libraries.matrix.ui.media.test.FakeInitialsAvatarBitmapGenerator +import io.element.android.libraries.push.impl.notifications.factories.MARK_AS_READ_ACTION_TITLE +import io.element.android.libraries.push.impl.notifications.factories.QUICK_REPLY_ACTION_TITLE +import io.element.android.libraries.push.impl.notifications.factories.aNotificationAccountParams +import io.element.android.libraries.push.impl.notifications.factories.createNotificationCreator +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import io.element.android.services.toolbox.impl.strings.AndroidStringProvider +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +private const val A_ROOM_AVATAR = "mxc://roomAvatar" +private const val A_USER_AVATAR_1 = "mxc://userAvatar1" +private const val A_USER_AVATAR_2 = "mxc://userAvatar2" + +@RunWith(RobolectricTestRunner::class) +class DefaultBaseRoomGroupMessageCreatorTest { + @Test + fun `test createRoomMessage with one Event`() = runTest { + val sut = createRoomGroupMessageCreator() + val fakeImageLoader = FakeImageLoader() + val result = sut.createRoomMessage( + notificationAccountParams = aNotificationAccountParams(), + events = listOf( + aNotifiableMessageEvent(timestamp = A_TIMESTAMP).copy( + imageUriString = "aUri", + ) + ), + roomId = A_ROOM_ID, + imageLoader = fakeImageLoader, + existingNotification = null, + threadId = null, + ) + assertThat(result.number).isEqualTo(1) + @Suppress("DEPRECATION") + assertThat(result.priority).isEqualTo(NotificationCompat.PRIORITY_LOW) + assertThat(result.`when`).isEqualTo(A_TIMESTAMP) + assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty() + } + + @Test + fun `test createRoomMessage with one noisy Event`() = runTest { + val sut = createRoomGroupMessageCreator() + val fakeImageLoader = FakeImageLoader() + val result = sut.createRoomMessage( + notificationAccountParams = aNotificationAccountParams(), + events = listOf( + aNotifiableMessageEvent(timestamp = A_TIMESTAMP).copy( + noisy = true, + ) + ), + roomId = A_ROOM_ID, + imageLoader = fakeImageLoader, + existingNotification = null, + threadId = null, + ) + @Suppress("DEPRECATION") + assertThat(result.priority).isEqualTo(NotificationCompat.PRIORITY_DEFAULT) + assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty() + } + + @Test + fun `test createRoomMessage with room avatar and sender avatar android O`() { + `test createRoomMessage with room avatar and sender avatar`( + api = Build.VERSION_CODES.O, + // Only the Room avatar is loaded + expectedCoilRequests = listOf( + MediaRequestData( + source = MediaSource(url = A_ROOM_AVATAR), + kind = MediaRequestData.Kind.Thumbnail(AVATAR_THUMBNAIL_SIZE_IN_PIXEL) + ) + ) + ) + } + + @Test + fun `test createRoomMessage with room avatar and sender avatar android P`() = runTest { + `test createRoomMessage with room avatar and sender avatar`( + api = Build.VERSION_CODES.P, + // Room and user avatar are loaded + expectedCoilRequests = listOf( + MediaRequestData( + source = MediaSource(url = A_USER_AVATAR_1), + kind = MediaRequestData.Kind.Thumbnail(AVATAR_THUMBNAIL_SIZE_IN_PIXEL) + ), + MediaRequestData( + source = MediaSource(url = A_USER_AVATAR_2), + kind = MediaRequestData.Kind.Thumbnail(AVATAR_THUMBNAIL_SIZE_IN_PIXEL) + ), + MediaRequestData( + source = MediaSource(url = A_ROOM_AVATAR), + kind = MediaRequestData.Kind.Thumbnail(AVATAR_THUMBNAIL_SIZE_IN_PIXEL) + ), + ) + ) + } + + private fun `test createRoomMessage with room avatar and sender avatar`( + api: Int, + expectedCoilRequests: List, + ) = runTest { + val fakeImageLoader = FakeImageLoader() + val sut = createRoomGroupMessageCreator( + sdkIntProvider = FakeBuildVersionSdkIntProvider(api) + ) + val result = sut.createRoomMessage( + notificationAccountParams = aNotificationAccountParams( + user = aMatrixUser( + // Some user avatar + avatarUrl = A_USER_AVATAR_1, + ) + ), + events = listOf( + aNotifiableMessageEvent(timestamp = A_TIMESTAMP).copy( + roomAvatarPath = A_ROOM_AVATAR, + senderAvatarPath = A_USER_AVATAR_2, + ) + ), + roomId = A_ROOM_ID, + imageLoader = fakeImageLoader, + existingNotification = null, + threadId = null, + ) + assertThat(result.number).isEqualTo(1) + assertThat(fakeImageLoader.getExecutedRequestsData()).containsExactlyElementsIn(expectedCoilRequests) + } + + @Test + fun `test createRoomMessage with two Events`() = runTest { + val sut = createRoomGroupMessageCreator() + val fakeImageLoader = FakeImageLoader() + val result = sut.createRoomMessage( + notificationAccountParams = aNotificationAccountParams(), + events = listOf( + aNotifiableMessageEvent(timestamp = A_TIMESTAMP), + aNotifiableMessageEvent(timestamp = A_TIMESTAMP + 10), + ), + roomId = A_ROOM_ID, + imageLoader = fakeImageLoader, + existingNotification = null, + threadId = null, + ) + assertThat(result.number).isEqualTo(2) + assertThat(result.`when`).isEqualTo(A_TIMESTAMP + 10) + val actionTitles = result.actions?.map { it.title } + assertThat(actionTitles).isEqualTo( + listOfNotNull( + MARK_AS_READ_ACTION_TITLE.takeIf { NotificationConfig.SHOW_MARK_AS_READ_ACTION }, + QUICK_REPLY_ACTION_TITLE.takeIf { NotificationConfig.SHOW_QUICK_REPLY_ACTION }, + ) + ) + assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty() + } + + @Test + fun `test createRoomMessage with smart reply error`() = runTest { + val sut = createRoomGroupMessageCreator() + val fakeImageLoader = FakeImageLoader() + val result = sut.createRoomMessage( + notificationAccountParams = aNotificationAccountParams(), + events = listOf( + aNotifiableMessageEvent(timestamp = A_TIMESTAMP).copy( + outGoingMessage = true, + outGoingMessageFailed = true, + ), + ), + roomId = A_ROOM_ID, + imageLoader = fakeImageLoader, + existingNotification = null, + threadId = null, + ) + val actionTitles = result.actions?.map { it.title } + assertThat(actionTitles).isEqualTo( + listOfNotNull( + MARK_AS_READ_ACTION_TITLE.takeIf { NotificationConfig.SHOW_MARK_AS_READ_ACTION } + ) + ) + assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty() + } + + @Test + fun `test createRoomMessage for DM`() = runTest { + val sut = createRoomGroupMessageCreator() + val fakeImageLoader = FakeImageLoader() + val result = sut.createRoomMessage( + notificationAccountParams = aNotificationAccountParams(), + events = listOf( + aNotifiableMessageEvent(timestamp = A_TIMESTAMP).copy( + roomIsDm = true, + ), + ), + roomId = A_ROOM_ID, + imageLoader = fakeImageLoader, + existingNotification = null, + threadId = null, + ) + assertThat(result.number).isEqualTo(1) + assertThat(result.`when`).isEqualTo(A_TIMESTAMP) + assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty() + } +} + +fun createRoomGroupMessageCreator( + sdkIntProvider: BuildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.O), +): RoomGroupMessageCreator { + val context = RuntimeEnvironment.getApplication() as Context + val bitmapLoader = DefaultNotificationBitmapLoader( + context = RuntimeEnvironment.getApplication(), + sdkIntProvider = sdkIntProvider, + initialsAvatarBitmapGenerator = FakeInitialsAvatarBitmapGenerator(), + ) + return DefaultRoomGroupMessageCreator( + notificationCreator = createNotificationCreator(bitmapLoader = bitmapLoader), + bitmapLoader = bitmapLoader, + stringProvider = AndroidStringProvider(context.resources) + ) +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultCallNotificationEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultCallNotificationEventResolverTest.kt new file mode 100644 index 0000000..f406bcc --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultCallNotificationEventResolverTest.kt @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.notification.NotificationContent +import io.element.android.libraries.matrix.api.notification.RtcNotificationType +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_SESSION_ID +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.A_USER_NAME_2 +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.notification.aNotificationData +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent +import io.element.android.services.appnavstate.test.FakeAppForegroundStateService +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultCallNotificationEventResolverTest { + @Test + fun `resolve CallNotify - RING when call is still ongoing`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + // The call is still ongoing + initialRoomInfo = aRoomInfo(hasRoomCall = true), + ) + ) + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + + val resolver = createDefaultNotifiableEventResolver( + clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }), + ) + val expectedResult = NotifiableRingingCallEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + senderId = A_USER_ID_2, + roomName = A_ROOM_NAME, + editedEventId = null, + description = "📹 Incoming call", + timestamp = 567L, + canBeReplaced = true, + isRedacted = false, + isUpdated = false, + senderDisambiguatedDisplayName = A_USER_NAME_2, + senderAvatarUrl = null, + expirationTimestamp = 1567L, + rtcNotificationType = RtcNotificationType.RING, + ) + + val notificationData = aNotificationData( + content = NotificationContent.MessageLike.RtcNotification(A_USER_ID_2, RtcNotificationType.RING, 1567) + ) + val result = resolver.resolveEvent(A_SESSION_ID, notificationData) + assertThat(result.getOrNull()).isEqualTo(expectedResult) + } + + @Test + fun `resolve CallNotify - NOTIFY`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + // The call already ended + initialRoomInfo = aRoomInfo(hasRoomCall = true), + ) + ) + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + + val resolver = createDefaultNotifiableEventResolver( + clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }), + ) + val expectedResult = NotifiableMessageEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + senderId = A_USER_ID_2, + roomName = A_ROOM_NAME, + editedEventId = null, + body = "📹 Incoming call", + timestamp = 567L, + canBeReplaced = false, + isRedacted = false, + isUpdated = false, + senderDisambiguatedDisplayName = A_USER_NAME_2, + noisy = true, + imageUriString = null, + imageMimeType = null, + threadId = null, + type = "org.matrix.msc4075.rtc.notification", + ) + + val notificationData = aNotificationData( + content = NotificationContent.MessageLike.RtcNotification(A_USER_ID_2, RtcNotificationType.NOTIFY, 0) + ) + val result = resolver.resolveEvent(A_SESSION_ID, notificationData) + assertThat(result.getOrNull()).isEqualTo(expectedResult) + } + + @Test + fun `resolve CallNotify - RING but timed out displays the same as NOTIFY`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + // The call already ended + initialRoomInfo = aRoomInfo(hasRoomCall = false), + ) + ) + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + + val resolver = createDefaultNotifiableEventResolver( + clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }), + ) + val expectedResult = NotifiableMessageEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + senderId = A_USER_ID_2, + roomName = A_ROOM_NAME, + editedEventId = null, + body = "📹 Incoming call", + timestamp = 567L, + canBeReplaced = false, + isRedacted = false, + isUpdated = false, + senderDisambiguatedDisplayName = A_USER_NAME_2, + noisy = true, + imageUriString = null, + imageMimeType = null, + threadId = null, + type = "org.matrix.msc4075.rtc.notification", + ) + + val notificationData = aNotificationData( + content = NotificationContent.MessageLike.RtcNotification(A_USER_ID_2, RtcNotificationType.RING, 0) + ) + val result = resolver.resolveEvent(A_SESSION_ID, notificationData) + assertThat(result.getOrNull()).isEqualTo(expectedResult) + } + + private fun createDefaultNotifiableEventResolver( + stringProvider: FakeStringProvider = FakeStringProvider(defaultResult = "\uD83D\uDCF9 Incoming call"), + appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(), + clientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), + ) = DefaultCallNotificationEventResolver( + stringProvider = stringProvider, + appForegroundStateService = appForegroundStateService, + clientProvider = clientProvider, + ) +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt new file mode 100644 index 0000000..d625c78 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt @@ -0,0 +1,863 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.content.Context +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.exception.NotificationResolverException +import io.element.android.libraries.matrix.api.media.MediaSource +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.notification.RtcNotificationType +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.EventType +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.LocationMessageType +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.StickerMessageType +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.api.timeline.item.event.VoiceMessageType +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.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_REDACTION_REASON +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_TIMESTAMP +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.A_USER_NAME_2 +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.notification.FakeNotificationService +import io.element.android.libraries.matrix.test.notification.aNotificationData +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.push.api.push.NotificationEventRequest +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationMediaRepo +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +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.ResolvedPushEvent +import io.element.android.libraries.push.test.notifications.FakeCallNotificationEventResolver +import io.element.android.services.toolbox.impl.strings.AndroidStringProvider +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@Suppress("LargeClass") +@RunWith(RobolectricTestRunner::class) +class DefaultNotifiableEventResolverTest { + @Test + fun `resolve event no session`() = runTest { + val sut = createDefaultNotifiableEventResolver(notificationService = null) + val result = sut.resolveEvents(A_SESSION_ID, listOf(NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase"))) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `resolve fetching failure`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.failure(AN_EXCEPTION) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `resolve event failure`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success(mapOf(AN_EVENT_ID to Result.failure(AN_EXCEPTION))) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)?.isFailure).isTrue() + } + + @Test + fun `resolve event message text`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = TextMessageType(body = "Hello world", formatted = null) + ), + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + aNotifiableMessageEvent(body = "Hello world") + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + @Config(qualifiers = "en") + fun `resolve event message with mention`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = TextMessageType(body = "Hello world", formatted = null) + ), + hasMention = true, + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + aNotifiableMessageEvent(body = "Hello world", hasMentionOrReply = true) + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve HTML formatted event message text takes plain text version`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = TextMessageType( + body = "Hello world!", + formatted = FormattedBody( + body = "Hello world", + format = MessageFormat.HTML, + ) + ) + ), + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + aNotifiableMessageEvent(body = "Hello world") + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve incorrectly formatted event message text uses fallback`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = TextMessageType( + body = "Hello world", + formatted = FormattedBody( + body = "???Hello world!???", + format = MessageFormat.UNKNOWN, + ) + ) + ), + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + aNotifiableMessageEvent(body = "Hello world") + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve event message audio`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = AudioMessageType("Audio", null, null, MediaSource("url"), null) + ), + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + aNotifiableMessageEvent(body = "Audio") + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve event message video`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = VideoMessageType("Video", null, null, MediaSource("url"), null) + ), + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + aNotifiableMessageEvent(body = "Video") + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve event message voice`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = VoiceMessageType("Voice", null, null, MediaSource("url"), null, null) + ), + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + aNotifiableMessageEvent(body = "Voice message") + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve event message image`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = ImageMessageType("Image", null, null, MediaSource("url"), null), + ), + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + aNotifiableMessageEvent(body = "Image") + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve event message sticker`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = StickerMessageType("Sticker", null, null, MediaSource("url"), null), + ), + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + aNotifiableMessageEvent(body = "Sticker") + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve event message file`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = FileMessageType("File", null, null, MediaSource("url"), null), + ), + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + aNotifiableMessageEvent(body = "File") + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve event message location`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = LocationMessageType("Location", "geo:1,2", null), + ), + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + aNotifiableMessageEvent(body = "Location") + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve event message notice`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = NoticeMessageType("Notice", null), + ), + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + aNotifiableMessageEvent(body = "Notice") + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve event message emote`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_USER_ID_2, + messageType = EmoteMessageType("is happy", null), + ), + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + aNotifiableMessageEvent(body = "* Bob is happy") + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve poll`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.MessageLike.Poll( + senderId = A_USER_ID_2, + question = "A question" + ), + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + aNotifiableMessageEvent(body = "Poll: A question") + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve RoomMemberContent invite room`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.StateEvent.RoomMemberContent( + userId = A_USER_ID_2, + membershipState = RoomMembershipState.INVITE + ), + isDirect = false, + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)?.getOrNull()).isNull() + } + + @Test + fun `resolve invite room`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.Invite( + senderId = A_USER_ID_2, + ), + isDirect = false, + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + InviteNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + canBeReplaced = true, + roomName = A_ROOM_NAME, + noisy = false, + title = null, + description = "Bob invited you to join the room", + type = null, + timestamp = A_TIMESTAMP, + soundName = null, + isRedacted = false, + isUpdated = false, + ) + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve invite direct`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.Invite( + senderId = A_USER_ID_2, + ), + isDirect = true, + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + InviteNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + canBeReplaced = true, + roomName = A_ROOM_NAME, + noisy = false, + title = null, + description = "Bob invited you to chat", + type = null, + timestamp = A_TIMESTAMP, + soundName = null, + isRedacted = false, + isUpdated = false, + ) + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve invite direct, no display name`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.Invite( + senderId = A_USER_ID_2, + ), + isDirect = true, + senderDisplayName = null, + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + InviteNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + canBeReplaced = true, + roomName = A_ROOM_NAME, + noisy = false, + title = null, + description = "@bob:server.org invited you to chat", + type = null, + timestamp = A_TIMESTAMP, + soundName = null, + isRedacted = false, + isUpdated = false, + ) + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve invite direct, ambiguous display name`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success( + aNotificationData( + content = NotificationContent.Invite( + senderId = A_USER_ID_2, + ), + isDirect = false, + senderIsNameAmbiguous = true, + ) + ) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + InviteNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + canBeReplaced = true, + roomName = A_ROOM_NAME, + noisy = false, + title = null, + description = "Bob (@bob:server.org) invited you to join the room", + type = null, + timestamp = A_TIMESTAMP, + soundName = null, + isRedacted = false, + isUpdated = false, + ) + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve RoomMemberContent other`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.StateEvent.RoomMemberContent( + userId = A_USER_ID_2, + membershipState = RoomMembershipState.JOIN + ) + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)?.getOrNull()).isNull() + } + + @Test + fun `resolve RoomEncrypted`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf(AN_EVENT_ID to Result.success(aNotificationData(content = NotificationContent.MessageLike.RoomEncrypted))) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + FallbackNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + description = "You have new messages.", + canBeReplaced = true, + isRedacted = false, + isUpdated = false, + timestamp = A_FAKE_TIMESTAMP, + cause = "Unable to decrypt event content", + ) + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve UnableToResolve`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf(AN_EVENT_ID to Result.failure(NotificationResolverException.EventNotFound)) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)).isEqualTo(Result.failure(NotificationResolverException.EventNotFound)) + } + + @Test + fun `resolve CallInvite`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success( + aNotificationData( + content = NotificationContent.MessageLike.CallInvite(A_USER_ID_2), + ) + ) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + NotifiableMessageEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + canBeReplaced = false, + senderId = A_USER_ID_2, + noisy = false, + timestamp = A_TIMESTAMP, + senderDisambiguatedDisplayName = A_USER_NAME_2, + body = "Unsupported call", + imageUriString = null, + imageMimeType = null, + threadId = null, + roomName = A_ROOM_NAME, + roomAvatarPath = null, + senderAvatarPath = null, + soundName = null, + outGoingMessage = false, + outGoingMessageFailed = false, + isRedacted = false, + isUpdated = false + ) + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve CallNotify - goes through CallNotificationEventResolver`() = runTest { + val callNotificationEventResolver = FakeCallNotificationEventResolver() + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.MessageLike.RtcNotification( + A_USER_ID_2, + RtcNotificationType.NOTIFY, + 0 + ), + )) + ) + ), + callNotificationEventResolver = callNotificationEventResolver, + ) + val expectedResult = ResolvedPushEvent.Event( + NotifiableMessageEvent( + sessionId = A_SESSION_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + noisy = true, + timestamp = A_TIMESTAMP, + senderDisambiguatedDisplayName = A_USER_NAME_2, + senderId = A_USER_ID_2, + body = "📹 Incoming call", + roomId = A_ROOM_ID, + threadId = null, + roomName = A_ROOM_NAME, + canBeReplaced = false, + isRedacted = false, + imageUriString = null, + imageMimeType = null, + type = EventType.RTC_NOTIFICATION, + ) + ) + callNotificationEventResolver.resolveEventLambda = { _, _, _ -> Result.success(expectedResult.notifiableEvent) } + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve RoomRedaction`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.MessageLike.RoomRedaction( + AN_EVENT_ID_2, + A_REDACTION_REASON, + ) + )) + ) + ) + ) + val expectedResult = ResolvedPushEvent.Redaction( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + redactedEventId = AN_EVENT_ID_2, + reason = A_REDACTION_REASON, + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + + @Test + fun `resolve RoomRedaction with null redactedEventId should return null`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.MessageLike.RoomRedaction( + null, + A_REDACTION_REASON, + ) + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)?.getOrNull()).isNull() + } + + @Test + fun `resolve null cases`() { + testNoResults(NotificationContent.MessageLike.CallAnswer) + testNoResults(NotificationContent.MessageLike.CallHangup) + testNoResults(NotificationContent.MessageLike.CallCandidates) + testNoResults(NotificationContent.MessageLike.KeyVerificationReady) + testNoResults(NotificationContent.MessageLike.KeyVerificationStart) + testNoResults(NotificationContent.MessageLike.KeyVerificationCancel) + testNoResults(NotificationContent.MessageLike.KeyVerificationAccept) + testNoResults(NotificationContent.MessageLike.KeyVerificationKey) + testNoResults(NotificationContent.MessageLike.KeyVerificationMac) + testNoResults(NotificationContent.MessageLike.KeyVerificationDone) + testNoResults(NotificationContent.MessageLike.ReactionContent(relatedEventId = AN_EVENT_ID_2.value)) + testNoResults(NotificationContent.MessageLike.Sticker) + testNoResults(NotificationContent.StateEvent.PolicyRuleRoom) + testNoResults(NotificationContent.StateEvent.PolicyRuleServer) + testNoResults(NotificationContent.StateEvent.PolicyRuleUser) + testNoResults(NotificationContent.StateEvent.RoomAliases) + testNoResults(NotificationContent.StateEvent.RoomAvatar) + testNoResults(NotificationContent.StateEvent.RoomCanonicalAlias) + testNoResults(NotificationContent.StateEvent.RoomCreate) + testNoResults(NotificationContent.StateEvent.RoomEncryption) + testNoResults(NotificationContent.StateEvent.RoomGuestAccess) + testNoResults(NotificationContent.StateEvent.RoomHistoryVisibility) + testNoResults(NotificationContent.StateEvent.RoomJoinRules) + testNoResults(NotificationContent.StateEvent.RoomName) + testNoResults(NotificationContent.StateEvent.RoomPinnedEvents) + testNoResults(NotificationContent.StateEvent.RoomPowerLevels) + testNoResults(NotificationContent.StateEvent.RoomServerAcl) + testNoResults(NotificationContent.StateEvent.RoomThirdPartyInvite) + testNoResults(NotificationContent.StateEvent.RoomTombstone) + testNoResults(NotificationContent.StateEvent.RoomTopic("")) + testNoResults(NotificationContent.StateEvent.SpaceChild) + testNoResults(NotificationContent.StateEvent.SpaceParent) + } + + private fun testNoResults(content: NotificationContent) = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf(AN_EVENT_ID to Result.success(aNotificationData(content = content))) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)?.getOrNull()).isNull() + } + + private fun Result>>.getEvent( + request: NotificationEventRequest + ): Result? { + return getOrNull()?.get(request) + } + + private fun createDefaultNotifiableEventResolver( + notificationService: FakeNotificationService? = FakeNotificationService(), + notificationResult: Result>> = Result.success(emptyMap()), + callNotificationEventResolver: FakeCallNotificationEventResolver = FakeCallNotificationEventResolver(), + ): DefaultNotifiableEventResolver { + val context = RuntimeEnvironment.getApplication() as Context + notificationService?.givenGetNotificationsResult(notificationResult) + val matrixClientProvider = FakeMatrixClientProvider(getClient = { + if (notificationService == null) { + Result.failure(IllegalStateException("Client not found")) + } else { + Result.success(FakeMatrixClient(notificationService = notificationService)) + } + }) + val notificationMediaRepoFactory = NotificationMediaRepo.Factory { + FakeNotificationMediaRepo() + } + return DefaultNotifiableEventResolver( + stringProvider = AndroidStringProvider(context.resources), + matrixClientProvider = matrixClientProvider, + notificationMediaRepoFactory = notificationMediaRepoFactory, + context = context, + permalinkParser = FakePermalinkParser(), + callNotificationEventResolver = callNotificationEventResolver, + fallbackNotificationFactory = FallbackNotificationFactory( + clock = FakeSystemClock(), + stringProvider = FakeStringProvider(defaultResult = "You have new messages.") + ), + featureFlagService = FakeFeatureFlagService(), + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt new file mode 100644 index 0000000..58bb86e --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.app.Notification +import androidx.compose.ui.graphics.Color +import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.test.FakeEnterpriseService +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_SPACE_ID +import io.element.android.libraries.matrix.test.A_THREAD_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.push.api.notifications.NotificationIdProvider +import io.element.android.libraries.push.impl.notifications.factories.aNotificationAccountParams +import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer +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.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.services.appnavstate.api.AppNavigationState +import io.element.android.services.appnavstate.api.AppNavigationStateService +import io.element.android.services.appnavstate.api.NavigationState +import io.element.android.services.appnavstate.test.FakeAppNavigationStateService +import io.element.android.services.appnavstate.test.aNavigationState +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultNotificationDrawerManagerTest { + @Test + fun `clearAllEvents should have no effect when queue is empty`() = runTest { + val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager() + defaultNotificationDrawerManager.clearAllEvents(A_SESSION_ID) + } + + @Test + fun `cover all APIs`() = runTest { + // For now just call all the API. Later, add more valuable tests. + val matrixUser = aMatrixUser(id = A_SESSION_ID.value, displayName = "alice", avatarUrl = "mxc://data") + val mockRoomGroupMessageCreator = FakeRoomGroupMessageCreator( + createRoomMessageResult = lambdaRecorder { notificationAccountParams, _, roomId, _, _, existingNotification -> + assertThat(notificationAccountParams.user).isEqualTo(matrixUser) + assertThat(roomId).isEqualTo(A_ROOM_ID) + assertThat(existingNotification).isNull() + Notification() + } + ) + val summaryGroupMessageCreator = FakeSummaryGroupMessageCreator() + val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager( + roomGroupMessageCreator = mockRoomGroupMessageCreator, + summaryGroupMessageCreator = summaryGroupMessageCreator, + ) + defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID) + defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID) + defaultNotificationDrawerManager.clearEvent(A_SESSION_ID, AN_EVENT_ID) + defaultNotificationDrawerManager.clearEvent(A_SESSION_ID, AN_EVENT_ID) + defaultNotificationDrawerManager.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID) + defaultNotificationDrawerManager.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID) + defaultNotificationDrawerManager.clearMembershipNotificationForSession(A_SESSION_ID) + defaultNotificationDrawerManager.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID) + defaultNotificationDrawerManager.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID) + defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) + // Add the same Event again (will be ignored) + defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) + } + + @Test + fun `react to applicationStateChange`() = runTest { + // For now just call all the API. Later, add more valuable tests. + val appNavigationStateFlow: MutableStateFlow = MutableStateFlow( + AppNavigationState( + navigationState = NavigationState.Root, + isInForeground = true, + ) + ) + val appNavigationStateService = FakeAppNavigationStateService(appNavigationState = appNavigationStateFlow) + createDefaultNotificationDrawerManager( + appNavigationStateService = appNavigationStateService + ) + appNavigationStateFlow.emit(AppNavigationState(aNavigationState(), isInForeground = true)) + runCurrent() + appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID), isInForeground = true)) + runCurrent() + appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID, A_SPACE_ID), isInForeground = true)) + runCurrent() + appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID), isInForeground = true)) + runCurrent() + appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID, A_THREAD_ID), isInForeground = true)) + runCurrent() + // Like a user sign out + appNavigationStateFlow.emit(AppNavigationState(aNavigationState(), isInForeground = true)) + runCurrent() + } + + @Test + fun `when MatrixClient has no cached user name and avatar, the profile is loaded to render the notification`() = runTest { + val matrixClient = FakeMatrixClient( + userDisplayName = null, + userAvatarUrl = null, + ) + val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + val messageCreator = FakeRoomGroupMessageCreator() + val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager( + matrixClientProvider = matrixClientProvider, + roomGroupMessageCreator = messageCreator, + enterpriseService = FakeEnterpriseService( + initialBrandColor = Color.Red, + ) + ) + // Gets a display name from MatrixClient.getUserProfile + matrixClient.givenGetProfileResult(A_SESSION_ID, Result.success(aMatrixUser(id = A_SESSION_ID.value, displayName = "alice"))) + defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) + + // Uses the user id as a fallback value since display name is blank + matrixClient.givenGetProfileResult(A_SESSION_ID, Result.success(aMatrixUser(id = A_SESSION_ID.value, displayName = ""))) + defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) + + // Uses the user id as a fallback value since the result fails + matrixClient.givenGetProfileResult(A_SESSION_ID, Result.failure(IllegalStateException("Failed to get profile"))) + defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) + + messageCreator.createRoomMessageResult.assertions() + .isCalledExactly(3) + .withSequence( + listOf( + value(aNotificationAccountParams(user = aMatrixUser(id = A_SESSION_ID.value, displayName = "alice"))), + any(), + any(), + any(), + any(), + any(), + ), + listOf( + value(aNotificationAccountParams(user = aMatrixUser(id = A_SESSION_ID.value, displayName = ""))), + any(), + any(), + any(), + any(), + any(), + ), + listOf( + value(aNotificationAccountParams(user = aMatrixUser(id = A_SESSION_ID.value, displayName = null, avatarUrl = null))), + any(), + any(), + any(), + any(), + any(), + ), + ) + } + + @Test + fun `clearSummaryNotificationIfNeeded will run after clearing all other notifications`() = runTest { + val cancelNotificationResult = lambdaRecorder { _, _ -> } + val notificationDisplayer = FakeNotificationDisplayer( + cancelNotificationResult = cancelNotificationResult, + ) + val summaryId = NotificationIdProvider.getSummaryNotificationId(A_SESSION_ID) + val roomMessageId = NotificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID) + val activeNotificationsProvider = FakeActiveNotificationsProvider( + getSummaryNotificationResult = { + mockk { + every { id } returns summaryId + } + }, + countResult = { 1 }, + ) + val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager( + notificationDisplayer = notificationDisplayer, + activeNotificationsProvider = activeNotificationsProvider, + ) + + // Ask to clear all existing message notifications. Since only the summary notification is left, it should be cleared + defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID) + + // Verify we asked to cancel the notification with summaryId + cancelNotificationResult.assertions().isCalledExactly(2).withSequence( + listOf(value(null), value(roomMessageId)), + listOf(value(null), value(summaryId)), + ) + } + + private fun TestScope.createDefaultNotificationDrawerManager( + notificationDisplayer: NotificationDisplayer = FakeNotificationDisplayer(), + appNavigationStateService: AppNavigationStateService = FakeAppNavigationStateService(), + roomGroupMessageCreator: RoomGroupMessageCreator = FakeRoomGroupMessageCreator(), + summaryGroupMessageCreator: SummaryGroupMessageCreator = FakeSummaryGroupMessageCreator(), + activeNotificationsProvider: FakeActiveNotificationsProvider = FakeActiveNotificationsProvider(), + matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), + sessionStore: SessionStore = InMemorySessionStore(), + enterpriseService: EnterpriseService = FakeEnterpriseService(), + ): DefaultNotificationDrawerManager { + return DefaultNotificationDrawerManager( + notificationDisplayer = notificationDisplayer, + notificationRenderer = NotificationRenderer( + notificationDisplayer = FakeNotificationDisplayer(), + notificationDataFactory = DefaultNotificationDataFactory( + notificationCreator = FakeNotificationCreator(), + roomGroupMessageCreator = roomGroupMessageCreator, + summaryGroupMessageCreator = summaryGroupMessageCreator, + activeNotificationsProvider = activeNotificationsProvider, + stringProvider = FakeStringProvider(), + ), + enterpriseService = enterpriseService, + sessionStore = sessionStore, + ), + appNavigationStateService = appNavigationStateService, + coroutineScope = backgroundScope, + matrixClientProvider = matrixClientProvider, + imageLoaderHolder = FakeImageLoaderHolder(), + activeNotificationsProvider = activeNotificationsProvider, + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt new file mode 100644 index 0000000..1a47200 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +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_USER_NAME +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.notification.FakeNotificationService +import io.element.android.libraries.matrix.test.notification.aNotificationData +import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDataFactory +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.libraries.push.test.notifications.FakeCallNotificationEventResolver +import io.element.android.services.appnavstate.test.FakeAppNavigationStateService +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultOnMissedCallNotificationHandlerTest { + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `addMissedCallNotification - should add missed call notification`() = runTest { + val dataFactory = FakeNotificationDataFactory( + messageEventToNotificationsResult = lambdaRecorder { _, _, _ -> emptyList() } + ) + // Create a fake matrix client provider that returns a fake matrix client with a fake notification service that returns a valid notification data + val matrixClientProvider = FakeMatrixClientProvider(getClient = { + val notificationService = FakeNotificationService().apply { + givenGetNotificationsResult( + Result.success(mapOf(AN_EVENT_ID to Result.success(aNotificationData(senderDisplayName = A_USER_NAME, senderIsNameAmbiguous = false)))) + ) + } + Result.success(FakeMatrixClient(notificationService = notificationService)) + }) + val defaultOnMissedCallNotificationHandler = DefaultOnMissedCallNotificationHandler( + matrixClientProvider = matrixClientProvider, + defaultNotificationDrawerManager = DefaultNotificationDrawerManager( + notificationDisplayer = FakeNotificationDisplayer(), + notificationRenderer = createNotificationRenderer( + notificationDataFactory = dataFactory, + ), + appNavigationStateService = FakeAppNavigationStateService(), + coroutineScope = backgroundScope, + matrixClientProvider = FakeMatrixClientProvider(), + imageLoaderHolder = FakeImageLoaderHolder(), + activeNotificationsProvider = FakeActiveNotificationsProvider(), + ), + callNotificationEventResolver = FakeCallNotificationEventResolver(resolveEventLambda = { _, _, _ -> + Result.success(aNotifiableMessageEvent()) + }), + ) + + defaultOnMissedCallNotificationHandler.addMissedCallNotification( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + ) + + runCurrent() + + dataFactory.messageEventToNotificationsResult.assertions().isCalledOnce() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt new file mode 100644 index 0000000..ba17fd0 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.app.Notification +import androidx.core.app.NotificationCompat +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.push.impl.notifications.factories.aNotificationAccountParams +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.nonNull +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultSummaryGroupMessageCreatorTest { + @Test + fun `process notifications`() = runTest { + val notificationCreator = FakeNotificationCreator() + val summaryCreator = DefaultSummaryGroupMessageCreator( + stringProvider = FakeStringProvider(), + notificationCreator = notificationCreator, + ) + + val result = summaryCreator.createSummaryNotification( + notificationAccountParams = aNotificationAccountParams(), + roomNotifications = listOf( + RoomNotification( + notification = Notification(), + roomId = A_ROOM_ID, + summaryLine = "", + messageCount = 1, + latestTimestamp = A_FAKE_TIMESTAMP + 10, + shouldBing = true, + threadId = null, + ) + ), + invitationNotifications = emptyList(), + simpleNotifications = emptyList(), + fallbackNotifications = emptyList(), + ) + + notificationCreator.createSummaryListNotificationResult.assertions() + .isCalledOnce() + .with(any(), any(), nonNull(), any(), any()) + + // Set from the events included + @Suppress("DEPRECATION") + assertThat(result.priority).isEqualTo(NotificationCompat.PRIORITY_DEFAULT) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt new file mode 100644 index 0000000..17ba744 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.api.push.NotificationEventRequest +import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeNotifiableEventResolver( + private val resolveEventsResult: (SessionId, List) -> Result>> = + { _, _ -> lambdaError() } +) : NotifiableEventResolver { + override suspend fun resolveEvents( + sessionId: SessionId, + notificationEventRequests: List + ): Result>> { + return resolveEventsResult(sessionId, notificationEventRequests) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeReplyMessageExtractor.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeReplyMessageExtractor.kt new file mode 100644 index 0000000..c0098a3 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeReplyMessageExtractor.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.content.Intent + +class FakeReplyMessageExtractor( + private val result: String? = null, +) : ReplyMessageExtractor { + override fun getReplyMessage(intent: Intent): String? { + return result + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt new file mode 100644 index 0000000..a52eb16 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt @@ -0,0 +1,507 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.content.Intent +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.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.room.IntentionalMention +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.timeline.ReceiptType +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_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.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory +import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory +import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.libraries.push.api.notifications.NotificationCleaner +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.push.FakeOnNotifiableEventReceived +import io.element.android.libraries.push.impl.push.OnNotifiableEventReceived +import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner +import io.element.android.services.appnavstate.api.ActiveRoomsHolder +import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.api.systemclock.SystemClock +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@ExperimentalCoroutinesApi +@RunWith(RobolectricTestRunner::class) +class NotificationBroadcastReceiverHandlerTest { + private val actionIds = NotificationActionIds(aBuildMeta()) + + @Test + fun `When no sessionId, nothing happen`() = runTest { + val sut = createNotificationBroadcastReceiverHandler() + sut.onReceive( + createIntent( + action = actionIds.join, + sessionId = null + ), + ) + } + + @Test + fun `Test dismiss room without a roomId, nothing happen`() = runTest { + val sut = createNotificationBroadcastReceiverHandler() + sut.onReceive( + createIntent( + action = actionIds.dismissRoom, + ), + ) + } + + @Test + fun `Test dismiss room`() = runTest { + val clearMessagesForRoomLambda = lambdaRecorder { _, _ -> } + val fakeNotificationCleaner = FakeNotificationCleaner( + clearMessagesForRoomLambda = clearMessagesForRoomLambda, + ) + val sut = createNotificationBroadcastReceiverHandler( + notificationCleaner = fakeNotificationCleaner + ) + sut.onReceive( + createIntent( + action = actionIds.dismissRoom, + roomId = A_ROOM_ID, + ), + ) + runCurrent() + clearMessagesForRoomLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID), value(A_ROOM_ID)) + } + + @Test + fun `Test dismiss summary`() = runTest { + val clearAllMessagesEventsLambda = lambdaRecorder { _ -> } + val fakeNotificationCleaner = FakeNotificationCleaner( + clearAllMessagesEventsLambda = clearAllMessagesEventsLambda, + ) + val sut = createNotificationBroadcastReceiverHandler( + notificationCleaner = fakeNotificationCleaner + ) + sut.onReceive( + createIntent( + action = actionIds.dismissSummary, + ), + ) + clearAllMessagesEventsLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID)) + } + + @Test + fun `Test dismiss Invite without room`() = runTest { + val sut = createNotificationBroadcastReceiverHandler() + sut.onReceive( + createIntent( + action = actionIds.dismissInvite, + ), + ) + } + + @Test + fun `Test dismiss Invite`() = runTest { + val clearMembershipNotificationForRoomLambda = lambdaRecorder { _, _ -> } + val fakeNotificationCleaner = FakeNotificationCleaner( + clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda, + ) + val sut = createNotificationBroadcastReceiverHandler( + notificationCleaner = fakeNotificationCleaner + ) + sut.onReceive( + createIntent( + action = actionIds.dismissInvite, + roomId = A_ROOM_ID, + ), + ) + clearMembershipNotificationForRoomLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID), value(A_ROOM_ID)) + } + + @Test + fun `Test dismiss Event without event`() = runTest { + val sut = createNotificationBroadcastReceiverHandler() + sut.onReceive( + createIntent( + action = actionIds.dismissEvent, + ), + ) + } + + @Test + fun `Test dismiss Event`() = runTest { + val clearEventLambda = lambdaRecorder { _, _ -> } + val fakeNotificationCleaner = FakeNotificationCleaner( + clearEventLambda = clearEventLambda, + ) + val sut = createNotificationBroadcastReceiverHandler( + notificationCleaner = fakeNotificationCleaner + ) + sut.onReceive( + createIntent( + action = actionIds.dismissEvent, + eventId = AN_EVENT_ID, + ), + ) + clearEventLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID), value(AN_EVENT_ID)) + } + + @Test + fun `Test mark room as read without room`() = runTest { + val sut = createNotificationBroadcastReceiverHandler() + sut.onReceive( + createIntent( + action = actionIds.markRoomRead, + ), + ) + } + + @Test + fun `Test mark room as read, send public RR`() { + testMarkRoomAsRead( + isSendPublicReadReceiptsEnabled = true, + expectedReceiptType = ReceiptType.READ + ) + } + + @Test + fun `Test mark room as read, send private RR`() { + testMarkRoomAsRead( + isSendPublicReadReceiptsEnabled = false, + expectedReceiptType = ReceiptType.READ_PRIVATE + ) + } + + private fun testMarkRoomAsRead( + isSendPublicReadReceiptsEnabled: Boolean, + expectedReceiptType: ReceiptType, + ) = runTest { + val getLambda = lambdaRecorder { _, _ -> + InMemorySessionPreferencesStore( + isSendPublicReadReceiptsEnabled = isSendPublicReadReceiptsEnabled + ) + } + val sessionPreferencesStore = FakeSessionPreferencesStoreFactory( + getLambda = getLambda + ) + val clearMessagesForRoomLambda = lambdaRecorder { _, _ -> } + val markAsReadResult = lambdaRecorder> { Result.success(Unit) } + val timeline = FakeTimeline(markAsReadResult = markAsReadResult) + val joinedRoom = FakeJoinedRoom( + baseRoom = FakeBaseRoom(), + liveTimeline = timeline, + createTimelineResult = { Result.success(timeline) }, + ) + val fakeNotificationCleaner = FakeNotificationCleaner( + clearMessagesForRoomLambda = clearMessagesForRoomLambda, + ) + val sut = createNotificationBroadcastReceiverHandler( + sessionPreferencesStore = sessionPreferencesStore, + joinedRoom = joinedRoom, + notificationCleaner = fakeNotificationCleaner + ) + sut.onReceive( + createIntent( + action = actionIds.markRoomRead, + roomId = A_ROOM_ID, + ), + ) + runCurrent() + clearMessagesForRoomLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID), value(A_ROOM_ID)) + markAsReadResult.assertions().isCalledOnce().with(value(expectedReceiptType)) + } + + @Test + fun `Test join room without room`() = runTest { + val sut = createNotificationBroadcastReceiverHandler() + sut.onReceive( + createIntent( + action = actionIds.join, + ), + ) + } + + @Test + fun `Test join room`() = runTest { + val joinRoom = lambdaRecorder> { _ -> Result.success(null) } + val clearMembershipNotificationForRoomLambda = lambdaRecorder { _, _ -> } + val fakeNotificationCleaner = FakeNotificationCleaner( + clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda, + ) + val sut = createNotificationBroadcastReceiverHandler( + joinRoom = joinRoom, + notificationCleaner = fakeNotificationCleaner, + ) + sut.onReceive( + createIntent( + action = actionIds.join, + roomId = A_ROOM_ID, + ), + ) + runCurrent() + joinRoom.assertions() + .isCalledOnce() + .with(value(A_ROOM_ID)) + clearMembershipNotificationForRoomLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID), value(A_ROOM_ID)) + } + + @Test + fun `Test reject room without room`() = runTest { + val sut = createNotificationBroadcastReceiverHandler() + sut.onReceive( + createIntent( + action = actionIds.reject, + ), + ) + } + + @Test + fun `Test reject room`() = runTest { + val leaveRoom = lambdaRecorder> { Result.success(Unit) } + val joinedRoom = FakeJoinedRoom( + baseRoom = FakeBaseRoom(leaveRoomLambda = leaveRoom), + ) + val clearMembershipNotificationForRoomLambda = lambdaRecorder { _, _ -> } + val fakeNotificationCleaner = FakeNotificationCleaner( + clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda, + ) + val sut = createNotificationBroadcastReceiverHandler( + joinedRoom = joinedRoom, + notificationCleaner = fakeNotificationCleaner + ) + sut.onReceive( + createIntent( + action = actionIds.reject, + roomId = A_ROOM_ID, + ), + ) + runCurrent() + clearMembershipNotificationForRoomLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID), value(A_ROOM_ID)) + + advanceUntilIdle() + + leaveRoom.assertions() + .isCalledOnce() + .with() + } + + @Test + fun `Test send reply without room`() = runTest { + val sut = createNotificationBroadcastReceiverHandler() + sut.onReceive( + createIntent( + action = actionIds.smartReply, + ), + ) + } + + @Test + fun `Test send reply`() = runTest { + val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } + val replyMessage = + lambdaRecorder, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) } + val liveTimeline = FakeTimeline().apply { + sendMessageLambda = sendMessage + replyMessageLambda = replyMessage + } + val joinedRoom = FakeJoinedRoom( + liveTimeline = liveTimeline, + baseRoom = FakeBaseRoom(getUpdatedMemberResult = { Result.success(aRoomMember()) }), + ).apply { + givenRoomInfo( + aRoomInfo( + isDirect = true, + activeMembersCount = 2, + ) + ) + } + val onNotifiableEventsReceivedResult = lambdaRecorder, Unit> { _ -> } + val onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventsReceivedResult = onNotifiableEventsReceivedResult) + val sut = createNotificationBroadcastReceiverHandler( + joinedRoom = joinedRoom, + onNotifiableEventReceived = onNotifiableEventReceived, + replyMessageExtractor = FakeReplyMessageExtractor(A_MESSAGE) + ) + sut.onReceive( + createIntent( + action = actionIds.smartReply, + roomId = A_ROOM_ID, + ), + ) + advanceUntilIdle() + sendMessage.assertions() + .isCalledOnce() + .with(value(A_MESSAGE), value(null), value(emptyList())) + onNotifiableEventsReceivedResult.assertions() + .isCalledOnce() + replyMessage.assertions() + .isNeverCalled() + } + + @Test + fun `Test send reply blank message`() = runTest { + val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } + val liveTimeline = FakeTimeline().apply { + sendMessageLambda = sendMessage + } + val joinedRoom = FakeJoinedRoom( + liveTimeline = liveTimeline + ) + val sut = createNotificationBroadcastReceiverHandler( + joinedRoom = joinedRoom, + replyMessageExtractor = FakeReplyMessageExtractor(" "), + ) + sut.onReceive( + createIntent( + action = actionIds.smartReply, + roomId = A_ROOM_ID, + ), + ) + runCurrent() + sendMessage.assertions() + .isNeverCalled() + } + + @Test + fun `Test send reply to thread`() = runTest { + val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } + val replyMessage = + lambdaRecorder, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) } + val liveTimeline = FakeTimeline().apply { + sendMessageLambda = sendMessage + replyMessageLambda = replyMessage + } + val joinedRoom = FakeJoinedRoom( + liveTimeline = liveTimeline, + baseRoom = FakeBaseRoom(getUpdatedMemberResult = { Result.success(aRoomMember()) }), + ).apply { + givenRoomInfo( + aRoomInfo( + isDirect = true, + activeMembersCount = 2, + ) + ) + } + val onNotifiableEventsReceivedResult = lambdaRecorder, Unit> { _ -> } + val onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventsReceivedResult = onNotifiableEventsReceivedResult) + val sut = createNotificationBroadcastReceiverHandler( + joinedRoom = joinedRoom, + onNotifiableEventReceived = onNotifiableEventReceived, + replyMessageExtractor = FakeReplyMessageExtractor(A_MESSAGE) + ) + sut.onReceive( + createIntent( + action = actionIds.smartReply, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + threadId = A_THREAD_ID, + ), + ) + runCurrent() + sendMessage.assertions() + .isNeverCalled() + onNotifiableEventsReceivedResult.assertions() + .isCalledOnce() + replyMessage.assertions() + .isCalledOnce() + .with( + value(AN_EVENT_ID), + value(A_MESSAGE), + value(null), + value(emptyList()), + value(true) + ) + } + + private fun createIntent( + action: String, + sessionId: SessionId? = A_SESSION_ID, + roomId: RoomId? = null, + eventId: EventId? = null, + threadId: ThreadId? = null, + ) = Intent(action).apply { + putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId?.value) + putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId?.value) + putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, threadId?.value) + putExtra(NotificationBroadcastReceiver.KEY_EVENT_ID, eventId?.value) + } + + private fun TestScope.createNotificationBroadcastReceiverHandler( + joinedRoom: FakeJoinedRoom? = FakeJoinedRoom(), + joinRoom: (RoomId) -> Result = { lambdaError() }, + matrixClient: MatrixClient? = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, joinedRoom) + joinRoomLambda = joinRoom + }, + sessionPreferencesStore: SessionPreferencesStoreFactory = FakeSessionPreferencesStoreFactory(), + notificationCleaner: NotificationCleaner = FakeNotificationCleaner(), + systemClock: SystemClock = FakeSystemClock(), + onNotifiableEventReceived: OnNotifiableEventReceived = FakeOnNotifiableEventReceived(), + stringProvider: StringProvider = FakeStringProvider(), + replyMessageExtractor: ReplyMessageExtractor = FakeReplyMessageExtractor(), + activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(), + ): NotificationBroadcastReceiverHandler { + return NotificationBroadcastReceiverHandler( + appCoroutineScope = this, + matrixClientProvider = FakeMatrixClientProvider { + if (matrixClient == null) { + Result.failure(Exception("No matrix client")) + } else { + Result.success(matrixClient) + } + }, + sessionPreferencesStore = sessionPreferencesStore, + notificationCleaner = notificationCleaner, + actionIds = actionIds, + systemClock = systemClock, + onNotifiableEventReceived = onNotifiableEventReceived, + stringProvider = stringProvider, + replyMessageExtractor = replyMessageExtractor, + activeRoomsHolder = activeRoomsHolder, + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt new file mode 100644 index 0000000..7d9d1e6 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2021-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.matrix.ui.media.test.FakeImageLoader +import io.element.android.libraries.push.impl.notifications.factories.aNotificationAccountParams +import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator +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 io.element.android.services.toolbox.test.strings.FakeStringProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +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) + +@RunWith(RobolectricTestRunner::class) +class NotificationDataFactoryTest { + private val notificationCreator = FakeNotificationCreator() + private val fakeRoomGroupMessageCreator = FakeRoomGroupMessageCreator() + private val fakeSummaryGroupMessageCreator = FakeSummaryGroupMessageCreator() + private val activeNotificationsProvider = FakeActiveNotificationsProvider() + + private val notificationDataFactory = DefaultNotificationDataFactory( + notificationCreator = notificationCreator, + roomGroupMessageCreator = fakeRoomGroupMessageCreator, + summaryGroupMessageCreator = fakeSummaryGroupMessageCreator, + activeNotificationsProvider = activeNotificationsProvider, + stringProvider = FakeStringProvider(), + ) + + @Test + fun `given a room invitation when mapping to notification then it's added`() = testWith(notificationDataFactory) { + val expectedNotification = notificationCreator.createRoomInvitationNotificationResult( + aNotificationAccountParams(), + AN_INVITATION_EVENT, + ) + val roomInvitation = listOf(AN_INVITATION_EVENT) + val result = toNotifications(roomInvitation, aNotificationAccountParams()) + + assertThat(result).isEqualTo( + listOf( + OneShotNotification( + notification = expectedNotification, + tag = A_ROOM_ID.value, + summaryLine = AN_INVITATION_EVENT.description, + isNoisy = AN_INVITATION_EVENT.noisy, + timestamp = AN_INVITATION_EVENT.timestamp + ) + ) + ) + } + + @Test + fun `given a simple event when mapping to notification then it's added`() = testWith(notificationDataFactory) { + val expectedNotification = notificationCreator.createRoomInvitationNotificationResult( + aNotificationAccountParams(), + AN_INVITATION_EVENT, + ) + val result = toNotifications(listOf(A_SIMPLE_EVENT), aNotificationAccountParams()) + assertThat(result).containsExactly( + OneShotNotification( + notification = expectedNotification, + tag = AN_EVENT_ID.value, + summaryLine = A_SIMPLE_EVENT.description, + isNoisy = A_SIMPLE_EVENT.noisy, + timestamp = AN_INVITATION_EVENT.timestamp + ) + ) + } + + @Test + fun `given room with message when mapping to notification then delegates to room group message creator`() = testWith(notificationDataFactory) { + val events = listOf(A_MESSAGE_EVENT) + val expectedNotification = RoomNotification( + notification = fakeRoomGroupMessageCreator.createRoomMessage( + notificationAccountParams = aNotificationAccountParams( + user = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + ), + events = events, + roomId = A_ROOM_ID, + threadId = null, + imageLoader = FakeImageLoader(), + existingNotification = null, + ), + roomId = A_ROOM_ID, + summaryLine = "A room name: Bob Hello world!", + messageCount = events.size, + latestTimestamp = events.maxOf { it.timestamp }, + shouldBing = events.any { it.noisy }, + threadId = null, + ) + val fakeImageLoader = FakeImageLoader() + val result = toNotifications( + messages = listOf(A_MESSAGE_EVENT), + notificationAccountParams = aNotificationAccountParams( + user = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + ), + imageLoader = fakeImageLoader, + ) + + assertThat(result.size).isEqualTo(1) + assertThat(result.first().isDataEqualTo(expectedNotification)).isTrue() + assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty() + } + + @Test + fun `given a room with only redacted events when mapping to notification then is Empty`() = testWith(notificationDataFactory) { + val redactedRoom = A_MESSAGE_EVENT.copy(isRedacted = true) + val fakeImageLoader = FakeImageLoader() + val result = toNotifications( + messages = listOf(redactedRoom), + notificationAccountParams = aNotificationAccountParams( + user = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + ), + imageLoader = fakeImageLoader, + ) + assertThat(result).isEmpty() + assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty() + } + + @Test + fun `given a room with redacted and non redacted message events when mapping to notification then redacted events are removed`() = testWith( + notificationDataFactory + ) { + val roomWithRedactedMessage = listOf( + A_MESSAGE_EVENT.copy(isRedacted = true), + A_MESSAGE_EVENT.copy(eventId = EventId("\$not-redacted")), + ) + val withRedactedRemoved = listOf(A_MESSAGE_EVENT.copy(eventId = EventId("\$not-redacted"))) + val expectedNotification = RoomNotification( + notification = fakeRoomGroupMessageCreator.createRoomMessage( + notificationAccountParams = aNotificationAccountParams( + user = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + ), + events = withRedactedRemoved, + roomId = A_ROOM_ID, + threadId = null, + imageLoader = FakeImageLoader(), + existingNotification = null, + ), + roomId = A_ROOM_ID, + summaryLine = "A room name: Bob Hello world!", + messageCount = withRedactedRemoved.size, + latestTimestamp = withRedactedRemoved.maxOf { it.timestamp }, + shouldBing = withRedactedRemoved.any { it.noisy }, + threadId = null, + ) + + val fakeImageLoader = FakeImageLoader() + val result = toNotifications( + messages = roomWithRedactedMessage, + notificationAccountParams = aNotificationAccountParams( + user = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + ), + imageLoader = fakeImageLoader, + ) + + assertThat(result.size).isEqualTo(1) + assertThat(result.first().isDataEqualTo(expectedNotification)).isTrue() + assertThat(fakeImageLoader.getExecutedRequestsData()).isEmpty() + } +} + +fun 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 0000000..bf9b06f --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProviderTest.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 io.element.android.libraries.push.api.notifications.NotificationIdProvider +import org.junit.Test + +class NotificationIdProviderTest { + @Test + fun `test notification id provider`() { + val sut = NotificationIdProvider + val offsetForASessionId = 305_410 + 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 0000000..51d491f --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2021-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.test.FakeEnterpriseService +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.matrix.ui.media.test.FakeImageLoader +import io.element.android.libraries.push.api.notifications.NotificationIdProvider +import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDataFactory +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer +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.A_NOTIFICATION +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.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +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 A_SUMMARY_NOTIFICATION = SummaryNotification.Update(A_NOTIFICATION) +private val ONE_SHOT_NOTIFICATION = + OneShotNotification(notification = A_NOTIFICATION, tag = "ignored", summaryLine = "ignored", isNoisy = false, timestamp = -1) + +@RunWith(RobolectricTestRunner::class) +class NotificationRendererTest { + private val notificationDisplayer = FakeNotificationDisplayer() + + private val notificationCreator = FakeNotificationCreator() + private val roomGroupMessageCreator = FakeRoomGroupMessageCreator() + private val summaryGroupMessageCreator = FakeSummaryGroupMessageCreator() + private val notificationDataFactory = DefaultNotificationDataFactory( + notificationCreator = notificationCreator, + roomGroupMessageCreator = roomGroupMessageCreator, + summaryGroupMessageCreator = summaryGroupMessageCreator, + activeNotificationsProvider = FakeActiveNotificationsProvider(), + stringProvider = FakeStringProvider(), + ) + private val notificationIdProvider = NotificationIdProvider + + private val notificationRenderer = createNotificationRenderer( + notificationDisplayer = notificationDisplayer, + notificationDataFactory = notificationDataFactory, + ) + + @Test + fun `given no notifications when rendering then cancels summary notification`() = runTest { + renderEventsAsNotifications(emptyList()) + + notificationDisplayer.verifySummaryCancelled() + } + + @Test + fun `given a room message group notification is added when rendering then show the message notification and update summary`() = runTest { + roomGroupMessageCreator.createRoomMessageResult = lambdaRecorder { _, _, _, _, _, _ -> A_NOTIFICATION } + + renderEventsAsNotifications(listOf(aNotifiableMessageEvent())) + + notificationDisplayer.showNotificationResult.assertions().isCalledExactly(2).withSequence( + listOf(value(A_ROOM_ID.value), value(notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID)), value(A_NOTIFICATION)), + listOf(value(null), value(notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)), value(A_SUMMARY_NOTIFICATION.notification)) + ) + } + + @Test + fun `given a simple notification is added when rendering then show the simple notification and update summary`() = runTest { + notificationCreator.createSimpleNotificationResult = lambdaRecorder { _, _ -> ONE_SHOT_NOTIFICATION.copy(tag = AN_EVENT_ID.value).notification } + + renderEventsAsNotifications(listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID))) + + notificationDisplayer.showNotificationResult.assertions().isCalledExactly(2).withSequence( + listOf(value(AN_EVENT_ID.value), value(notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID)), value(A_NOTIFICATION)), + listOf(value(null), value(notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)), value(A_SUMMARY_NOTIFICATION.notification)) + ) + } + + @Test + fun `given an invitation notification is added when rendering then show the invitation notification and update summary`() = runTest { + notificationCreator.createRoomInvitationNotificationResult = lambdaRecorder { _, _ -> ONE_SHOT_NOTIFICATION.copy(tag = AN_EVENT_ID.value).notification } + + renderEventsAsNotifications(listOf(anInviteNotifiableEvent())) + + notificationDisplayer.showNotificationResult.assertions().isCalledExactly(2).withSequence( + listOf(value(A_ROOM_ID.value), value(notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID)), value(A_NOTIFICATION)), + listOf(value(null), value(notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)), value(A_SUMMARY_NOTIFICATION.notification)) + ) + } + + private suspend fun renderEventsAsNotifications(events: List) { + notificationRenderer.render( + MatrixUser(A_SESSION_ID, MY_USER_DISPLAY_NAME, MY_USER_AVATAR_URL), + useCompleteNotificationFormat = USE_COMPLETE_NOTIFICATION_FORMAT, + eventsToProcess = events, + imageLoader = FakeImageLoader(), + ) + } +} + +fun createNotificationRenderer( + notificationDisplayer: NotificationDisplayer = FakeNotificationDisplayer(), + notificationDataFactory: NotificationDataFactory = FakeNotificationDataFactory(), + enterpriseService: EnterpriseService = FakeEnterpriseService(), + sessionStore: SessionStore = InMemorySessionStore(), +) = NotificationRenderer( + notificationDisplayer = notificationDisplayer, + notificationDataFactory = notificationDataFactory, + enterpriseService = enterpriseService, + sessionStore = sessionStore, +) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/FakeNotificationChannels.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/FakeNotificationChannels.kt new file mode 100644 index 0000000..6caf02b --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/FakeNotificationChannels.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.channels + +class FakeNotificationChannels( + var channelForIncomingCall: (ring: Boolean) -> String = { _ -> "" }, + var channelIdForMessage: (noisy: Boolean) -> String = { _ -> "" }, + var channelIdForTest: () -> String = { "" } +) : NotificationChannels { + override fun getChannelForIncomingCall(ring: Boolean): String { + return channelForIncomingCall(ring) + } + + override fun getChannelIdForMessage(noisy: Boolean): String { + return channelIdForMessage(noisy) + } + + override fun getChannelIdForTest(): String { + return channelIdForTest() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannelsTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannelsTest.kt new file mode 100644 index 0000000..e6b2883 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannelsTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.channels + +import android.os.Build +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationManagerCompat +import com.google.common.truth.Truth.assertThat +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +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 NotificationChannelsTest { + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `init - creates notification channels and migrates old ones`() { + val notificationManager = mockk(relaxed = true) { + every { notificationChannels } returns emptyList() + } + + createNotificationChannels(notificationManager = notificationManager) + + verify { notificationManager.createNotificationChannel(any()) } + verify { notificationManager.deleteNotificationChannel(any()) } + } + + @Test + fun `getChannelForIncomingCall - returns the right channel`() { + val notificationChannels = createNotificationChannels() + + val ringingChannel = notificationChannels.getChannelForIncomingCall(ring = true) + assertThat(ringingChannel).isEqualTo(RINGING_CALL_NOTIFICATION_CHANNEL_ID) + + val normalChannel = notificationChannels.getChannelForIncomingCall(ring = false) + assertThat(normalChannel).isEqualTo(CALL_NOTIFICATION_CHANNEL_ID) + } + + @Test + fun `getChannelIdForMessage - returns the right channel`() { + val notificationChannels = createNotificationChannels() + + assertThat(notificationChannels.getChannelIdForMessage(noisy = true)).isEqualTo(NOISY_NOTIFICATION_CHANNEL_ID) + assertThat(notificationChannels.getChannelIdForMessage(noisy = false)).isEqualTo(SILENT_NOTIFICATION_CHANNEL_ID) + } + + @Test + fun `getChannelIdForTest - returns the right channel`() { + val notificationChannels = createNotificationChannels() + + assertThat(notificationChannels.getChannelIdForTest()).isEqualTo(NOISY_NOTIFICATION_CHANNEL_ID) + } + + private fun createNotificationChannels( + notificationManager: NotificationManagerCompat = mockk(relaxed = true), + ) = DefaultNotificationChannels( + notificationManager = notificationManager, + stringProvider = FakeStringProvider(), + context = RuntimeEnvironment.getApplication(), + ) +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt new file mode 100644 index 0000000..d4d7713 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.conversations + +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.features.lockscreen.test.FakeLockScreenService +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_SESSION_ID_2 +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.push.impl.notifications.factories.FakeIntentProvider +import io.element.android.libraries.push.impl.notifications.shortcut.createShortcutId +import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader +import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.TIRAMISU]) +class DefaultNotificationConversationServiceTest { + @Test + fun `onSendMessage adds a shortcut`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val service = createService(context) + + service.onSendMessage( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + roomName = "Room title", + roomIsDirect = false, + roomAvatarUrl = null, + ) + + val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context) + assertThat(shortcuts).isNotEmpty() + } + + @Test + fun `onLeftRoom removes a shortcut`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val service = createService(context) + + val shortcutId = createShortcutId(A_SESSION_ID, A_ROOM_ID) + val shortcutInfo = ShortcutInfoCompat.Builder(context, shortcutId) + .setShortLabel("Room title") + .setIntent(Intent(Intent.ACTION_VIEW)) + .build() + + // First we add the shortcut + ShortcutManagerCompat.pushDynamicShortcut(context, shortcutInfo) + + assertThat(ShortcutManagerCompat.getDynamicShortcuts(context).firstOrNull()?.id).isEqualTo(shortcutId) + + service.onLeftRoom( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + ) + + // Then we check it's removed + val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context) + assertThat(shortcuts).isEmpty() + } + + @Test + fun `onAvailableRoomsChanged keeps only the available rooms as shortcuts`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val service = createService(context) + + // We add a couple of shortcuts + val shortcutInfoA = ShortcutInfoCompat.Builder(context, "$A_SESSION_ID-$A_ROOM_ID") + .setShortLabel("Room title") + .setIntent(Intent(Intent.ACTION_VIEW)) + .build() + val shortcutInfoB = ShortcutInfoCompat.Builder(context, "$A_SESSION_ID-$A_ROOM_ID_2") + .setShortLabel("Room title") + .setIntent(Intent(Intent.ACTION_VIEW)) + .build() + ShortcutManagerCompat.setDynamicShortcuts(context, listOf(shortcutInfoA, shortcutInfoB)) + + assertThat(ShortcutManagerCompat.getDynamicShortcuts(context)).hasSize(2) + + service.onAvailableRoomsChanged( + sessionId = A_SESSION_ID, + roomIds = setOf(A_ROOM_ID), + ) + + // Then we check only the shortcuts for the matching rooms remain + val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context) + assertThat(shortcuts).hasSize(1) + assertThat(shortcuts.first().id).isEqualTo("$A_SESSION_ID-$A_ROOM_ID") + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `on pin code enabled, all shortcuts are cleared`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val lockScreenService = FakeLockScreenService() + createService(context, lockScreenService = lockScreenService) + + // Make sure the pin is disabled + lockScreenService.setIsPinSetup(false) + // Give the test some time to save the pin setup value + runCurrent() + + // We add a couple of shortcuts from different sessions + val shortcutInfoA = ShortcutInfoCompat.Builder(context, "$A_SESSION_ID-$A_ROOM_ID") + .setShortLabel("Room title") + .setIntent(Intent(Intent.ACTION_VIEW)) + .build() + val shortcutInfoB = ShortcutInfoCompat.Builder(context, "$A_SESSION_ID_2-$A_ROOM_ID_2") + .setShortLabel("Room title") + .setIntent(Intent(Intent.ACTION_VIEW)) + .build() + ShortcutManagerCompat.setDynamicShortcuts(context, listOf(shortcutInfoA, shortcutInfoB)) + assertThat(ShortcutManagerCompat.getDynamicShortcuts(context)).hasSize(2) + + // Enable the pin code + lockScreenService.setIsPinSetup(true) + // Give the test some time to save the new pin setup value + runCurrent() + + // Then we check there are no shortcuts left from any session + val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context) + assertThat(shortcuts).isEmpty() + } + + @Test + fun `on session logged out, all shortcuts for the session are cleared`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val sessionObserver = FakeSessionObserver() + createService(context, sessionObserver = sessionObserver) + + // Set the initial session state + sessionObserver.onSessionCreated(A_SESSION_ID.value) + sessionObserver.onSessionCreated(A_SESSION_ID_2.value) + + // We add a couple of shortcuts from different sessions + val shortcutInfoA = ShortcutInfoCompat.Builder(context, "$A_SESSION_ID-$A_ROOM_ID") + .setShortLabel("Room title") + .setIntent(Intent(Intent.ACTION_VIEW)) + .build() + val shortcutInfoB = ShortcutInfoCompat.Builder(context, "$A_SESSION_ID_2-$A_ROOM_ID_2") + .setShortLabel("Room title") + .setIntent(Intent(Intent.ACTION_VIEW)) + .build() + ShortcutManagerCompat.setDynamicShortcuts(context, listOf(shortcutInfoA, shortcutInfoB)) + assertThat(ShortcutManagerCompat.getDynamicShortcuts(context)).hasSize(2) + + // A session is logged out + sessionObserver.onSessionDeleted(A_SESSION_ID.value) + + // Then we check the shortcuts for the logged out session are removed, but the rest remain + val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(context) + assertThat(shortcuts).hasSize(1) + assertThat(shortcuts.first().id).startsWith(A_SESSION_ID_2.value) + } + + private fun TestScope.createService( + context: Context = InstrumentationRegistry.getInstrumentation().context, + sessionObserver: FakeSessionObserver = FakeSessionObserver(), + lockScreenService: FakeLockScreenService = FakeLockScreenService(), + ) = DefaultNotificationConversationService( + context = context, + intentProvider = FakeIntentProvider(), + bitmapLoader = FakeNotificationBitmapLoader(), + matrixClientProvider = FakeMatrixClientProvider(), + imageLoaderHolder = FakeImageLoaderHolder(), + sessionObserver = sessionObserver, + lockScreenService = lockScreenService, + coroutineScope = backgroundScope, + ) +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt new file mode 100644 index 0000000..0504ae4 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt @@ -0,0 +1,368 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.factories + +import android.app.Notification +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.google.common.truth.Truth.assertThat +import io.element.android.appconfig.NotificationConfig +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_COLOR_INT +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.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.media.test.FakeImageLoader +import io.element.android.libraries.matrix.ui.media.test.FakeInitialsAvatarBitmapGenerator +import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader +import io.element.android.libraries.push.impl.notifications.DefaultNotificationBitmapLoader +import io.element.android.libraries.push.impl.notifications.NotificationActionIds +import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo +import io.element.android.libraries.push.impl.notifications.channels.DefaultNotificationChannels +import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels +import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory +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.factories.action.RejectInvitationActionFactory +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +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.test.sdk.FakeBuildVersionSdkIntProvider +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class DefaultNotificationCreatorTest { + @Test + fun `test createDiagnosticNotification`() { + val sut = createNotificationCreator() + val result = sut.createDiagnosticNotification( + color = A_COLOR_INT, + ) + result.commonAssertions( + expectedGroup = null, + expectedCategory = NotificationCompat.CATEGORY_STATUS, + ) + } + + @Test + fun `test createUnregistrationNotification`() { + val sut = createNotificationCreator() + val matrixUser = aMatrixUser() + val result = sut.createUnregistrationNotification( + notificationAccountParams = aNotificationAccountParams( + user = matrixUser, + ), + ) + result.commonAssertions( + expectedGroup = matrixUser.userId.value, + expectedCategory = NotificationCompat.CATEGORY_ERROR, + ) + } + + @Test + fun `test createFallbackNotification`() { + val sut = createNotificationCreator() + val result = sut.createFallbackNotification( + notificationAccountParams = aNotificationAccountParams(), + FallbackNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + description = "description", + canBeReplaced = false, + isRedacted = false, + isUpdated = false, + timestamp = A_FAKE_TIMESTAMP, + cause = null, + ), + ) + result.commonAssertions( + expectedCategory = null, + ) + } + + @Test + fun `test createSimpleEventNotification`() { + val sut = createNotificationCreator() + val result = sut.createSimpleEventNotification( + notificationAccountParams = aNotificationAccountParams(), + SimpleNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + noisy = false, + title = "title", + description = "description", + type = null, + timestamp = A_FAKE_TIMESTAMP, + soundName = null, + canBeReplaced = false, + isRedacted = false, + isUpdated = false, + ), + ) + result.commonAssertions( + expectedCategory = null, + ) + } + + @Test + fun `test createSimpleEventNotification noisy`() { + val sut = createNotificationCreator() + val result = sut.createSimpleEventNotification( + notificationAccountParams = aNotificationAccountParams(), + SimpleNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + noisy = true, + title = "title", + description = "description", + type = null, + timestamp = A_FAKE_TIMESTAMP, + soundName = null, + canBeReplaced = false, + isRedacted = false, + isUpdated = false, + ), + ) + result.commonAssertions( + expectedCategory = null, + ) + } + + @Test + fun `test createRoomInvitationNotification`() { + val sut = createNotificationCreator() + val result = sut.createRoomInvitationNotification( + notificationAccountParams = aNotificationAccountParams(), + InviteNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + noisy = false, + title = "title", + description = "description", + type = null, + timestamp = A_FAKE_TIMESTAMP, + soundName = null, + canBeReplaced = false, + isRedacted = false, + isUpdated = false, + roomName = "roomName", + ), + ) + result.commonAssertions( + expectedCategory = null, + ) + val actionTitles = result.actions?.map { it.title } + assertThat(actionTitles).isEqualTo( + listOfNotNull( + REJECT_INVITATION_ACTION_TITLE.takeIf { NotificationConfig.SHOW_ACCEPT_AND_DECLINE_INVITE_ACTIONS }, + ACCEPT_INVITATION_ACTION_TITLE.takeIf { NotificationConfig.SHOW_ACCEPT_AND_DECLINE_INVITE_ACTIONS }, + ) + ) + } + + @Test + fun `test createRoomInvitationNotification noisy`() { + val sut = createNotificationCreator() + val result = sut.createRoomInvitationNotification( + notificationAccountParams = aNotificationAccountParams(), + InviteNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + noisy = true, + title = "title", + description = "description", + type = null, + timestamp = A_FAKE_TIMESTAMP, + soundName = null, + canBeReplaced = false, + isRedacted = false, + isUpdated = false, + roomName = "roomName", + ), + ) + result.commonAssertions( + expectedCategory = null, + ) + } + + @Test + fun `test createSummaryListNotification`() { + val sut = createNotificationCreator() + val matrixUser = aMatrixUser() + val result = sut.createSummaryListNotification( + notificationAccountParams = aNotificationAccountParams(user = matrixUser), + compatSummary = "compatSummary", + noisy = false, + lastMessageTimestamp = 123_456L, + ) + result.commonAssertions( + expectedGroup = matrixUser.userId.value, + ) + } + + @Test + fun `test createSummaryListNotification noisy`() { + val sut = createNotificationCreator() + val matrixUser = aMatrixUser() + val result = sut.createSummaryListNotification( + notificationAccountParams = aNotificationAccountParams(user = matrixUser), + compatSummary = "compatSummary", + noisy = true, + lastMessageTimestamp = 123_456L, + ) + result.commonAssertions( + expectedGroup = matrixUser.userId.value, + ) + } + + @Test + fun `test createMessagesListNotification`() = runTest { + val sut = createNotificationCreator() + val result = sut.createMessagesListNotification( + notificationAccountParams = aNotificationAccountParams(), + roomInfo = RoomEventGroupInfo( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + roomDisplayName = "roomDisplayName", + hasSmartReplyError = false, + shouldBing = false, + customSound = null, + isUpdated = false, + ), + threadId = null, + largeIcon = null, + lastMessageTimestamp = 123_456L, + tickerText = "tickerText", + existingNotification = null, + imageLoader = FakeImageLoader(), + events = listOf(aNotifiableMessageEvent()), + ) + result.commonAssertions() + } + + @Test + fun `test createMessagesListNotification should bing and thread`() = runTest { + val sut = createNotificationCreator() + val result = sut.createMessagesListNotification( + notificationAccountParams = aNotificationAccountParams(), + roomInfo = RoomEventGroupInfo( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + roomDisplayName = "roomDisplayName", + hasSmartReplyError = false, + shouldBing = true, + customSound = null, + isUpdated = false, + ), + threadId = A_THREAD_ID, + largeIcon = null, + lastMessageTimestamp = 123_456L, + tickerText = "tickerText", + existingNotification = null, + imageLoader = FakeImageLoader(), + events = listOf(aNotifiableMessageEvent()), + ) + result.commonAssertions() + } + + private fun Notification.commonAssertions( + expectedGroup: String? = aMatrixUser().userId.value, + expectedCategory: String? = NotificationCompat.CATEGORY_MESSAGE, + ) { + assertThat(contentIntent).isNotNull() + assertThat(group).isEqualTo(expectedGroup) + assertThat(category).isEqualTo(expectedCategory) + } +} + +const val MARK_AS_READ_ACTION_TITLE = "MarkAsReadAction" +const val QUICK_REPLY_ACTION_TITLE = "QuickReplyAction" +const val ACCEPT_INVITATION_ACTION_TITLE = "AcceptInvitationAction" +const val REJECT_INVITATION_ACTION_TITLE = "RejectInvitationAction" + +fun createNotificationCreator( + context: Context = RuntimeEnvironment.getApplication(), + buildMeta: BuildMeta = aBuildMeta(), + notificationChannels: NotificationChannels = createNotificationChannels(), + bitmapLoader: NotificationBitmapLoader = DefaultNotificationBitmapLoader( + context = context, + sdkIntProvider = FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.R), + initialsAvatarBitmapGenerator = FakeInitialsAvatarBitmapGenerator(), + ), +): NotificationCreator { + return DefaultNotificationCreator( + context = context, + notificationChannels = notificationChannels, + stringProvider = FakeStringProvider("test"), + buildMeta = buildMeta, + pendingIntentFactory = PendingIntentFactory( + context, + FakeIntentProvider(), + FakeSystemClock(), + NotificationActionIds(buildMeta), + ), + markAsReadActionFactory = MarkAsReadActionFactory( + context = context, + actionIds = NotificationActionIds(buildMeta), + stringProvider = FakeStringProvider(MARK_AS_READ_ACTION_TITLE), + clock = FakeSystemClock(), + ), + quickReplyActionFactory = QuickReplyActionFactory( + context = context, + actionIds = NotificationActionIds(buildMeta), + stringProvider = FakeStringProvider(QUICK_REPLY_ACTION_TITLE), + clock = FakeSystemClock(), + ), + bitmapLoader = bitmapLoader, + acceptInvitationActionFactory = AcceptInvitationActionFactory( + context = context, + actionIds = NotificationActionIds(buildMeta), + stringProvider = FakeStringProvider(ACCEPT_INVITATION_ACTION_TITLE), + clock = FakeSystemClock(), + ), + rejectInvitationActionFactory = RejectInvitationActionFactory( + context = context, + actionIds = NotificationActionIds(buildMeta), + stringProvider = FakeStringProvider(REJECT_INVITATION_ACTION_TITLE), + clock = FakeSystemClock(), + ), + ) +} + +fun createNotificationChannels(): NotificationChannels { + val context = RuntimeEnvironment.getApplication() + return DefaultNotificationChannels( + notificationManager = NotificationManagerCompat.from(context), + stringProvider = FakeStringProvider(""), + context = context, + ) +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/FakeIntentProvider.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/FakeIntentProvider.kt new file mode 100644 index 0000000..62ab850 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/FakeIntentProvider.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.factories + +import android.content.Intent +import android.os.Bundle +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 + +class FakeIntentProvider : IntentProvider { + override fun getViewRoomIntent( + sessionId: SessionId, + roomId: RoomId?, + threadId: ThreadId?, + eventId: EventId?, + extras: Bundle?, + ) = Intent(Intent.ACTION_VIEW) +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationAccountParams.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationAccountParams.kt new file mode 100644 index 0000000..d0c724d --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationAccountParams.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.factories + +import androidx.annotation.ColorInt +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.A_COLOR_INT +import io.element.android.libraries.matrix.ui.components.aMatrixUser + +fun aNotificationAccountParams( + user: MatrixUser = aMatrixUser(), + @ColorInt color: Int = A_COLOR_INT, + showSessionId: Boolean = false, +) = NotificationAccountParams( + user = user, + color = color, + showSessionId = showSessionId, +) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeActiveNotificationsProvider.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeActiveNotificationsProvider.kt new file mode 100644 index 0000000..ae3edce --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeActiveNotificationsProvider.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.fake + +import android.service.notification.StatusBarNotification +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.ActiveNotificationsProvider + +class FakeActiveNotificationsProvider( + private val getMessageNotificationsForRoomResult: (SessionId, RoomId, ThreadId?) -> List = { _, _, _ -> emptyList() }, + private val getAllMessageNotificationsForRoomResult: (SessionId, RoomId) -> List = { _, _ -> emptyList() }, + private val getNotificationsForSessionResult: (SessionId) -> List = { emptyList() }, + private val getMembershipNotificationForSessionResult: (SessionId) -> List = { emptyList() }, + private val getMembershipNotificationForRoomResult: (SessionId, RoomId) -> List = { _, _ -> emptyList() }, + private val getSummaryNotificationResult: (SessionId) -> StatusBarNotification? = { null }, + private val countResult: (SessionId) -> Int = { 0 }, +) : ActiveNotificationsProvider { + override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): List { + return getMessageNotificationsForRoomResult(sessionId, roomId, threadId) + } + + override fun getAllMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List { + return getAllMessageNotificationsForRoomResult(sessionId, roomId) + } + + override fun getNotificationsForSession(sessionId: SessionId): List { + return getNotificationsForSessionResult(sessionId) + } + + override fun getMembershipNotificationForSession(sessionId: SessionId): List { + return getMembershipNotificationForSessionResult(sessionId) + } + + override fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List { + return getMembershipNotificationForRoomResult(sessionId, roomId) + } + + override fun getSummaryNotification(sessionId: SessionId): StatusBarNotification? { + return getSummaryNotificationResult(sessionId) + } + + override fun count(sessionId: SessionId): Int { + return countResult(sessionId) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt new file mode 100644 index 0000000..d5e4ad9 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.fake + +import android.app.Notification +import android.graphics.Bitmap +import androidx.annotation.ColorInt +import coil3.ImageLoader +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo +import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams +import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION +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 io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder +import io.element.android.tests.testutils.lambda.LambdaListAnyParamsRecorder +import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder +import io.element.android.tests.testutils.lambda.LambdaTwoParamsRecorder +import io.element.android.tests.testutils.lambda.lambdaAnyRecorder +import io.element.android.tests.testutils.lambda.lambdaRecorder + +class FakeNotificationCreator( + var createMessagesListNotificationResult: LambdaListAnyParamsRecorder = lambdaAnyRecorder { A_NOTIFICATION }, + var createRoomInvitationNotificationResult: LambdaTwoParamsRecorder = + lambdaRecorder { _, _ -> A_NOTIFICATION }, + var createSimpleNotificationResult: LambdaTwoParamsRecorder = + lambdaRecorder { _, _ -> A_NOTIFICATION }, + var createFallbackNotificationResult: LambdaTwoParamsRecorder = + lambdaRecorder { _, _ -> A_NOTIFICATION }, + var createSummaryListNotificationResult: LambdaFiveParamsRecorder< + NotificationAccountParams, String, Boolean, Long, NotificationAccountParams, Notification + > = lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION }, + var createDiagnosticNotificationResult: LambdaOneParamRecorder = + lambdaRecorder { _ -> A_NOTIFICATION }, + val createUnregistrationNotificationResult: LambdaOneParamRecorder = + lambdaRecorder { _ -> A_NOTIFICATION }, +) : NotificationCreator { + override suspend fun createMessagesListNotification( + notificationAccountParams: NotificationAccountParams, + roomInfo: RoomEventGroupInfo, + threadId: ThreadId?, + largeIcon: Bitmap?, + lastMessageTimestamp: Long, + tickerText: String, + existingNotification: Notification?, + imageLoader: ImageLoader, + events: List, + ): Notification { + return createMessagesListNotificationResult( + listOf(notificationAccountParams, roomInfo, threadId, largeIcon, lastMessageTimestamp, tickerText, existingNotification, imageLoader, events) + ) + } + + override fun createRoomInvitationNotification( + notificationAccountParams: NotificationAccountParams, + inviteNotifiableEvent: InviteNotifiableEvent, + ): Notification { + return createRoomInvitationNotificationResult(notificationAccountParams, inviteNotifiableEvent) + } + + override fun createSimpleEventNotification( + notificationAccountParams: NotificationAccountParams, + simpleNotifiableEvent: SimpleNotifiableEvent, + ): Notification { + return createSimpleNotificationResult(notificationAccountParams, simpleNotifiableEvent) + } + + override fun createFallbackNotification( + notificationAccountParams: NotificationAccountParams, + fallbackNotifiableEvent: FallbackNotifiableEvent, + ): Notification { + return createFallbackNotificationResult(notificationAccountParams, fallbackNotifiableEvent) + } + + override fun createSummaryListNotification( + notificationAccountParams: NotificationAccountParams, + compatSummary: String, + noisy: Boolean, + lastMessageTimestamp: Long, + ): Notification { + return createSummaryListNotificationResult(notificationAccountParams, compatSummary, noisy, lastMessageTimestamp, notificationAccountParams) + } + + override fun createDiagnosticNotification( + @ColorInt color: Int, + ): Notification { + return createDiagnosticNotificationResult(color) + } + + override fun createUnregistrationNotification(notificationAccountParams: NotificationAccountParams): Notification { + return createUnregistrationNotificationResult(notificationAccountParams) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDataFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDataFactory.kt new file mode 100644 index 0000000..009513b --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDataFactory.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2021-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.fake + +import coil3.ImageLoader +import io.element.android.libraries.push.impl.notifications.NotificationDataFactory +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.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams +import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION +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 io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder +import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder +import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder +import io.element.android.tests.testutils.lambda.lambdaRecorder + +class FakeNotificationDataFactory( + var messageEventToNotificationsResult: LambdaThreeParamsRecorder< + List, ImageLoader, NotificationAccountParams, List + > = lambdaRecorder { _, _, _ -> emptyList() }, + var summaryToNotificationsResult: LambdaFiveParamsRecorder< + List, + List, + List, + List, + NotificationAccountParams, + SummaryNotification + > = lambdaRecorder { _, _, _, _, _ -> SummaryNotification.Update(A_NOTIFICATION) }, + var inviteToNotificationsResult: LambdaOneParamRecorder, List> = lambdaRecorder { _ -> emptyList() }, + var simpleEventToNotificationsResult: LambdaOneParamRecorder, List> = lambdaRecorder { _ -> emptyList() }, + var fallbackEventToNotificationsResult: LambdaOneParamRecorder, List> = + lambdaRecorder { _ -> emptyList() }, +) : NotificationDataFactory { + override suspend fun toNotifications( + messages: List, + imageLoader: ImageLoader, + notificationAccountParams: NotificationAccountParams, + ): List { + return messageEventToNotificationsResult(messages, imageLoader, notificationAccountParams) + } + + @JvmName("toNotificationInvites") + @Suppress("INAPPLICABLE_JVM_NAME") + override fun toNotifications( + invites: List, + notificationAccountParams: NotificationAccountParams, + ): List { + return inviteToNotificationsResult(invites) + } + + @JvmName("toNotificationSimpleEvents") + @Suppress("INAPPLICABLE_JVM_NAME") + override fun toNotifications( + simpleEvents: List, + notificationAccountParams: NotificationAccountParams, + ): List { + return simpleEventToNotificationsResult(simpleEvents) + } + + @JvmName("toNotificationFallbackEvents") + @Suppress("INAPPLICABLE_JVM_NAME") + override fun toNotifications( + fallback: List, + notificationAccountParams: NotificationAccountParams, + ): List { + return fallbackEventToNotificationsResult(fallback) + } + + override fun createSummaryNotification( + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + notificationAccountParams: NotificationAccountParams, + ): SummaryNotification { + return summaryToNotificationsResult( + roomNotifications, + invitationNotifications, + simpleNotifications, + fallbackNotifications, + notificationAccountParams, + ) + } +} 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 0000000..fd4af70 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2021-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.fake + +import android.app.Notification +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.api.notifications.NotificationIdProvider +import io.element.android.libraries.push.impl.notifications.NotificationDisplayer +import io.element.android.tests.testutils.lambda.LambdaNoParamRecorder +import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder +import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder +import io.element.android.tests.testutils.lambda.LambdaTwoParamsRecorder +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value + +class FakeNotificationDisplayer( + var showNotificationResult: LambdaThreeParamsRecorder = lambdaRecorder { _, _, _ -> true }, + var cancelNotificationResult: LambdaTwoParamsRecorder = lambdaRecorder { _, _ -> }, + var displayDiagnosticNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> true }, + var dismissDiagnosticNotificationResult: LambdaNoParamRecorder = lambdaRecorder { -> }, + var displayUnregistrationNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> true }, +) : NotificationDisplayer { + override fun showNotification(tag: String?, id: Int, notification: Notification): Boolean { + return showNotificationResult(tag, id, notification) + } + + override fun cancelNotification(tag: String?, id: Int) { + return cancelNotificationResult(tag, id) + } + + override fun displayDiagnosticNotification(notification: Notification): Boolean { + return displayDiagnosticNotificationResult(notification) + } + + override fun dismissDiagnosticNotification() { + return dismissDiagnosticNotificationResult() + } + + override fun displayUnregistrationNotification(notification: Notification): Boolean { + return displayUnregistrationNotificationResult(notification) + } + + fun verifySummaryCancelled(times: Int = 1) { + cancelNotificationResult.assertions().isCalledExactly(times).withSequence( + listOf(value(null), value(NotificationIdProvider.getSummaryNotificationId(A_SESSION_ID))) + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationMediaRepo.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationMediaRepo.kt new file mode 100644 index 0000000..ecf4a15 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationMediaRepo.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.fake + +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.push.impl.notifications.NotificationMediaRepo +import java.io.File + +class FakeNotificationMediaRepo : NotificationMediaRepo { + override suspend fun getMediaFile( + mediaSource: MediaSource, + mimeType: String?, + filename: String?, + ): Result { + return Result.failure(IllegalStateException("Fake class")) + } +} 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 0000000..c4b9513 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2021-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.fake + +import android.app.Notification +import coil3.ImageLoader +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.push.impl.notifications.RoomGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams +import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.tests.testutils.lambda.LambdaSixParamsRecorder +import io.element.android.tests.testutils.lambda.lambdaRecorder + +// We just can't make the param types fit +@Suppress("MaxLineLength", "ktlint:standard:max-line-length", "ktlint:standard:parameter-wrapping") +class FakeRoomGroupMessageCreator( + var createRoomMessageResult: LambdaSixParamsRecorder< + NotificationAccountParams, List, RoomId, ThreadId?, ImageLoader, Notification?, Notification + > = lambdaRecorder { _, _, _, _, _, _ -> A_NOTIFICATION } +) : RoomGroupMessageCreator { + override suspend fun createRoomMessage( + notificationAccountParams: NotificationAccountParams, + events: List, + roomId: RoomId, + threadId: ThreadId?, + imageLoader: ImageLoader, + existingNotification: Notification?, + ): Notification { + return createRoomMessageResult(notificationAccountParams, events, roomId, threadId, imageLoader, existingNotification) + } +} 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 0000000..9db6853 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2021-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.fake + +import android.app.Notification +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.SummaryGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams +import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION +import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder +import io.element.android.tests.testutils.lambda.lambdaRecorder + +class FakeSummaryGroupMessageCreator( + var createSummaryNotificationResult: LambdaFiveParamsRecorder< + NotificationAccountParams, List, List, List, List, Notification> = + lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION } +) : SummaryGroupMessageCreator { + override fun createSummaryNotification( + notificationAccountParams: NotificationAccountParams, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + ): Notification { + return createSummaryNotificationResult( + notificationAccountParams, + roomNotifications, + invitationNotifications, + simpleNotifications, + fallbackNotifications, + ) + } +} 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 0000000..edd0c2b --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2021-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.api.core.UserId +import io.element.android.libraries.matrix.api.notification.RtcNotificationType +import io.element.android.libraries.matrix.api.timeline.item.event.EventType +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_MESSAGE +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_TIMESTAMP +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.A_USER_NAME_2 +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.NotifiableRingingCallEvent +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( + body: String = A_MESSAGE, + sessionId: SessionId = A_SESSION_ID, + roomId: RoomId = A_ROOM_ID, + eventId: EventId = AN_EVENT_ID, + threadId: ThreadId? = null, + isRedacted: Boolean = false, + hasMentionOrReply: Boolean = false, + timestamp: Long = A_TIMESTAMP, + type: String = EventType.MESSAGE, + senderId: UserId = A_USER_ID_2, + senderDisambiguatedDisplayName: String = A_USER_NAME_2, + roomName: String? = A_ROOM_NAME, +) = NotifiableMessageEvent( + sessionId = sessionId, + eventId = eventId, + editedEventId = null, + noisy = false, + timestamp = timestamp, + senderDisambiguatedDisplayName = senderDisambiguatedDisplayName, + senderId = senderId, + body = body, + roomId = roomId, + threadId = threadId, + roomName = roomName, + canBeReplaced = false, + isRedacted = isRedacted, + imageUriString = null, + imageMimeType = null, + roomAvatarPath = null, + senderAvatarPath = null, + soundName = null, + outGoingMessage = false, + outGoingMessageFailed = false, + isUpdated = false, + hasMentionOrReply = hasMentionOrReply, + type = type, +) + +fun aNotifiableCallEvent( + sessionId: SessionId = A_SESSION_ID, + roomId: RoomId = A_ROOM_ID, + eventId: EventId = AN_EVENT_ID, + senderId: UserId = A_USER_ID_2, + senderName: String? = null, + roomAvatarUrl: String? = AN_AVATAR_URL, + senderAvatarUrl: String? = AN_AVATAR_URL, + rtcNotificationType: RtcNotificationType = RtcNotificationType.NOTIFY, + timestamp: Long = 0L, + expirationTimestamp: Long = 0L, +) = NotifiableRingingCallEvent( + sessionId = sessionId, + eventId = eventId, + roomId = roomId, + roomName = A_ROOM_NAME, + editedEventId = null, + description = "description", + timestamp = timestamp, + expirationTimestamp = expirationTimestamp, + canBeReplaced = false, + isRedacted = false, + isUpdated = false, + senderDisambiguatedDisplayName = senderName, + senderId = senderId, + roomAvatarUrl = roomAvatarUrl, + senderAvatarUrl = senderAvatarUrl, + rtcNotificationType = rtcNotificationType, +) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationEventRequestFixture.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationEventRequestFixture.kt new file mode 100644 index 0000000..c450287 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationEventRequestFixture.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.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.api.push.NotificationEventRequest + +fun aNotificationEventRequest( + sessionId: SessionId = A_SESSION_ID, + roomId: RoomId = A_ROOM_ID, + eventId: EventId = AN_EVENT_ID, + providerInfo: String = "providerInfo", +) = NotificationEventRequest( + sessionId = sessionId, + roomId = roomId, + eventId = eventId, + providerInfo = providerInfo, +) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationFixture.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationFixture.kt new file mode 100644 index 0000000..5ae1156 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationFixture.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications.fixtures + +import android.app.Notification + +val A_NOTIFICATION = Notification() diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultOnRedactedEventReceivedTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultOnRedactedEventReceivedTest.kt new file mode 100644 index 0000000..2bab4c1 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultOnRedactedEventReceivedTest.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.push + +import android.app.Notification +import android.service.notification.StatusBarNotification +import androidx.core.app.NotificationCompat +import androidx.core.app.Person +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +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_SESSION_ID +import io.element.android.libraries.matrix.test.A_THREAD_ID +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.push.impl.notifications.factories.DefaultNotificationCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer +import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultOnRedactedEventReceivedTest { + private val fakePerson = Person.Builder().setName(A_USER_NAME).setKey(A_USER_ID.value).build() + private val fakeMessage = NotificationCompat.MessagingStyle.Message("A message", 0L, fakePerson).also { + it.extras.putString(DefaultNotificationCreator.MESSAGE_EVENT_ID, AN_EVENT_ID.value) + } + private val fakeNotification = NotificationCompat.Builder(InstrumentationRegistry.getInstrumentation().targetContext, "aChannel") + .setStyle( + NotificationCompat.MessagingStyle(fakePerson) + .addMessage(fakeMessage) + ) + .setGroup(A_SESSION_ID.value) + .build() + + private val fakeIncorrectMessage = NotificationCompat.MessagingStyle.Message("The wrong message", 0L, fakePerson).also { + it.extras.putString(DefaultNotificationCreator.MESSAGE_EVENT_ID, AN_EVENT_ID_2.value) + } + private val fakeIncorrectNotification = NotificationCompat.Builder(InstrumentationRegistry.getInstrumentation().targetContext, "aChannel") + .setGroup(A_SESSION_ID.value) + .setStyle( + NotificationCompat.MessagingStyle(fakePerson) + .addMessage(fakeIncorrectMessage) + ) + .build() + + @Test + fun `when no notifications are found, nothing happen`() = runTest { + val showNotificationLambda = lambdaRecorder { _, _, _ -> true } + val sut = createDefaultOnRedactedEventReceived( + getAllMessageNotificationsForRoomResult = { _, _ -> emptyList() }, + displayer = FakeNotificationDisplayer(showNotificationLambda), + ) + sut.onRedactedEventsReceived(listOf(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null))) + showNotificationLambda.assertions().isNeverCalled() + } + + @Test + fun `when a notification is found, try to retrieve the message`() = runTest { + val showNotificationLambda = lambdaRecorder { tag, id, _ -> + assertThat(tag).isEqualTo(A_ROOM_ID.value) + assertThat(id).isEqualTo(1) + true + } + val sut = createDefaultOnRedactedEventReceived( + getAllMessageNotificationsForRoomResult = { _, _ -> + listOf( + mockk { + every { id } returns 1 + every { notification } returns fakeNotification + every { tag } returns A_ROOM_ID.value + }, + mockk { + every { id } returns 2 + every { notification } returns fakeIncorrectNotification + every { tag } returns A_ROOM_ID.value + } + ) + }, + displayer = FakeNotificationDisplayer(showNotificationLambda), + ) + sut.onRedactedEventsReceived(listOf(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null))) + showNotificationLambda.assertions().isCalledOnce() + } + + @Test + fun `when thread notifications are found, try to retrieve the message`() = runTest { + val showNotificationLambda = lambdaRecorder { tag, id, _ -> + assertThat(tag).isEqualTo("$A_ROOM_ID|$A_THREAD_ID") + assertThat(id).isEqualTo(1) + true + } + val sut = createDefaultOnRedactedEventReceived( + getAllMessageNotificationsForRoomResult = { _, _ -> + listOf( + mockk { + every { id } returns 1 + every { notification } returns fakeNotification + every { tag } returns "$A_ROOM_ID|$A_THREAD_ID" + }, + mockk { + every { id } returns 2 + every { notification } returns fakeIncorrectNotification + every { tag } returns A_ROOM_ID.value + } + ) + }, + displayer = FakeNotificationDisplayer(showNotificationResult = showNotificationLambda), + ) + sut.onRedactedEventsReceived(listOf(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null))) + + showNotificationLambda.assertions().isCalledOnce() + } + + private fun createDefaultOnRedactedEventReceived( + getAllMessageNotificationsForRoomResult: (SessionId, RoomId) -> List = { _, _ -> lambdaError() }, + displayer: FakeNotificationDisplayer = FakeNotificationDisplayer(), + ): DefaultOnRedactedEventReceived { + val context = InstrumentationRegistry.getInstrumentation().context + return DefaultOnRedactedEventReceived( + activeNotificationsProvider = FakeActiveNotificationsProvider( + getMessageNotificationsForRoomResult = { _, _, _ -> lambdaError() }, + getAllMessageNotificationsForRoomResult = getAllMessageNotificationsForRoomResult, + getNotificationsForSessionResult = { lambdaError() }, + getMembershipNotificationForSessionResult = { lambdaError() }, + getMembershipNotificationForRoomResult = { _, _ -> lambdaError() }, + getSummaryNotificationResult = { lambdaError() }, + countResult = { lambdaError() }, + ), + notificationDisplayer = displayer, + context = context, + stringProvider = FakeStringProvider(), + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt new file mode 100644 index 0000000..89b8c0d --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt @@ -0,0 +1,733 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.push.impl.push + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.call.api.CallType +import io.element.android.features.call.test.FakeElementCallEntryPoint +import io.element.android.libraries.androidutils.json.DefaultJsonProvider +import io.element.android.libraries.core.meta.BuildMeta +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.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.exception.NotificationResolverException +import io.element.android.libraries.matrix.api.notification.RtcNotificationType +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_SECRET +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.push.api.push.NotificationEventRequest +import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent +import io.element.android.libraries.push.impl.history.FakePushHistoryService +import io.element.android.libraries.push.impl.history.PushHistoryService +import io.element.android.libraries.push.impl.notifications.DefaultNotificationResolverQueue +import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver +import io.element.android.libraries.push.impl.notifications.FallbackNotificationFactory +import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.libraries.push.impl.test.DefaultTestPush +import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler +import io.element.android.libraries.push.impl.workmanager.WorkerDataConverter +import io.element.android.libraries.pushproviders.api.PushData +import io.element.android.libraries.pushstore.api.UserPushStore +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret +import io.element.android.libraries.workmanager.api.WorkManagerRequest +import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.matching +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.time.Instant +import kotlin.time.Duration.Companion.milliseconds + +private const val A_PUSHER_INFO = "info" + +@Suppress("LargeClass") +class DefaultPushHandlerTest { + @Test + fun `check handleInvalid behavior`() = runTest { + val incrementPushCounterResult = lambdaRecorder {} + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val defaultPushHandler = createDefaultPushHandler( + incrementPushCounterResult = incrementPushCounterResult, + pushHistoryService = pushHistoryService, + ) + defaultPushHandler.handleInvalid(A_PUSHER_INFO, "data") + incrementPushCounterResult.assertions() + .isCalledOnce() + onPushReceivedResult.assertions() + .isCalledOnce() + .with(value(A_PUSHER_INFO), value(null), value(null), value(null), value(false), value(false), value("Invalid or ignored push data:\ndata")) + } + + @Test + fun `when classical PushData is received, the notification drawer is informed`() = runTest { + val aNotifiableMessageEvent = aNotifiableMessageEvent() + val notifiableEventResult = + lambdaRecorder, Result>>> { _, _ -> + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) + Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)))) + } + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + val incrementPushCounterResult = lambdaRecorder {} + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventsReceived = onNotifiableEventsReceived, + notifiableEventsResult = notifiableEventResult, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + incrementPushCounterResult = incrementPushCounterResult, + pushHistoryService = pushHistoryService, + ) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + + advanceTimeBy(300.milliseconds) + + incrementPushCounterResult.assertions() + .isCalledOnce() + notifiableEventResult.assertions() + .isCalledOnce() + .with(value(A_USER_ID), any()) + onNotifiableEventsReceived.assertions() + .isCalledOnce() + .with(value(listOf(aNotifiableMessageEvent))) + onPushReceivedResult.assertions() + .isCalledOnce() + } + + @Test + fun `when classical PushData is received and the workmanager flag is enabled, the work is scheduled`() = runTest { + val aNotifiableMessageEvent = aNotifiableMessageEvent() + val notifiableEventResult = + lambdaRecorder, Result>>> { _, _ -> + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) + Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)))) + } + val incrementPushCounterResult = lambdaRecorder {} + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + + val featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SyncNotificationsWithWorkManager.key to true)) + val submitWorkLambda = lambdaRecorder {} + val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = submitWorkLambda) + + val defaultPushHandler = createDefaultPushHandler( + notifiableEventsResult = notifiableEventResult, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + incrementPushCounterResult = incrementPushCounterResult, + featureFlagService = featureFlagService, + workManagerScheduler = workManagerScheduler, + ) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + + advanceTimeBy(300.milliseconds) + + submitWorkLambda.assertions().isCalledOnce() + + incrementPushCounterResult.assertions() + .isCalledOnce() + } + + @Test + fun `when classical PushData is received, but notifications are disabled, nothing happen`() = + runTest { + val aNotifiableMessageEvent = aNotifiableMessageEvent() + val notifiableEventResult = + lambdaRecorder, Result>>> { _, _ -> + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) + Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)))) + } + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + val incrementPushCounterResult = lambdaRecorder {} + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventsReceived = onNotifiableEventsReceived, + notifiableEventsResult = notifiableEventResult, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + userPushStore = FakeUserPushStore().apply { + setNotificationEnabledForDevice(false) + }, + incrementPushCounterResult = incrementPushCounterResult, + pushHistoryService = pushHistoryService, + ) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + + advanceTimeBy(300.milliseconds) + + incrementPushCounterResult.assertions() + .isCalledOnce() + notifiableEventResult.assertions() + .isCalledOnce() + onNotifiableEventsReceived.assertions() + .isNeverCalled() + onPushReceivedResult.assertions() + .isCalledOnce() + } + + @Test + fun `when PushData is received, but client secret is not known, nothing happen`() = + runTest { + val aNotifiableMessageEvent = aNotifiableMessageEvent() + val notifiableEventResult = + lambdaRecorder, Result>>> { _, _ -> + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) + Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)))) + } + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + val incrementPushCounterResult = lambdaRecorder {} + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventsReceived = onNotifiableEventsReceived, + notifiableEventsResult = notifiableEventResult, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { null } + ), + incrementPushCounterResult = incrementPushCounterResult, + pushHistoryService = pushHistoryService, + ) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + incrementPushCounterResult.assertions() + .isCalledOnce() + notifiableEventResult.assertions() + .isNeverCalled() + onNotifiableEventsReceived.assertions() + .isNeverCalled() + onPushReceivedResult.assertions() + .isCalledOnce() + } + + @Test + fun `when classical PushData is received, but a failure occurs (session not found), nothing happen`() { + `test notification resolver failure`( + notificationResolveResult = { _ -> + Result.failure(NotificationResolverException.UnknownError("Unable to restore session")) + }, + shouldSetOptimizationBatteryBanner = false, + ) + } + + @Test + fun `when classical PushData is received, but not able to resolve the event, the banner to disable battery optimization will be displayed`() { + `test notification resolver failure`( + notificationResolveResult = { requests: List -> + Result.success( + requests.associateWith { Result.failure(NotificationResolverException.UnknownError("Unable to resolve event")) } + ) + }, + shouldSetOptimizationBatteryBanner = true, + ) + } + + private fun `test notification resolver failure`( + notificationResolveResult: (List) -> Result>>, + shouldSetOptimizationBatteryBanner: Boolean, + ) { + runTest { + val notifiableEventResult = + lambdaRecorder, Result>>> { _, requests -> + notificationResolveResult(requests) + } + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + val incrementPushCounterResult = lambdaRecorder {} + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val showBatteryOptimizationBannerResult = lambdaRecorder {} + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventsReceived = onNotifiableEventsReceived, + notifiableEventsResult = notifiableEventResult, + buildMeta = aBuildMeta( + // Also test `lowPrivacyLoggingEnabled = false` here + lowPrivacyLoggingEnabled = false + ), + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + incrementPushCounterResult = incrementPushCounterResult, + mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore( + showBatteryOptimizationBannerResult = showBatteryOptimizationBannerResult, + ), + pushHistoryService = pushHistoryService, + ) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + + advanceTimeBy(300.milliseconds) + + incrementPushCounterResult.assertions() + .isCalledOnce() + notifiableEventResult.assertions() + .isCalledOnce() + .with(value(A_USER_ID), any()) + onPushReceivedResult.assertions() + .isCalledOnce() + .with(any(), value(AN_EVENT_ID), value(A_ROOM_ID), value(A_USER_ID), value(false), value(true), any()) + showBatteryOptimizationBannerResult.assertions().let { + if (shouldSetOptimizationBatteryBanner) { + it.isCalledOnce() + } else { + it.isNeverCalled() + } + } + } + } + + @Test + fun `when ringing call PushData is received, the incoming call will be handled`() = runTest { + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val handleIncomingCallLambda = lambdaRecorder< + CallType.RoomCall, + EventId, + UserId, + String?, + String?, + String?, + String, + String?, + Unit, + > { _, _, _, _, _, _, _, _ -> } + val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda) + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val defaultPushHandler = createDefaultPushHandler( + elementCallEntryPoint = elementCallEntryPoint, + notifiableEventsResult = { _, _ -> + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) + Result.success( + mapOf( + request to Result.success( + ResolvedPushEvent.Event( + aNotifiableCallEvent(rtcNotificationType = RtcNotificationType.RING, timestamp = Instant.now().toEpochMilli()) + ) + ) + ) + ) + }, + incrementPushCounterResult = {}, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + onNotifiableEventsReceived = onNotifiableEventsReceived, + pushHistoryService = pushHistoryService, + ) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + + advanceTimeBy(300.milliseconds) + + handleIncomingCallLambda.assertions().isCalledOnce() + onNotifiableEventsReceived.assertions().isNeverCalled() + onPushReceivedResult.assertions().isCalledOnce() + } + + @Test + fun `when notify call PushData is received, the incoming call will be treated as a normal notification`() = runTest { + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + val handleIncomingCallLambda = lambdaRecorder< + CallType.RoomCall, + EventId, + UserId, + String?, + String?, + String?, + String, + String?, + Unit, + > { _, _, _, _, _, _, _, _ -> } + val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda) + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val defaultPushHandler = createDefaultPushHandler( + elementCallEntryPoint = elementCallEntryPoint, + onNotifiableEventsReceived = onNotifiableEventsReceived, + notifiableEventsResult = { _, _ -> + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) + Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent(type = EventType.RTC_NOTIFICATION))))) + }, + incrementPushCounterResult = {}, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + pushHistoryService = pushHistoryService, + ) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + + advanceTimeBy(300.milliseconds) + + handleIncomingCallLambda.assertions().isNeverCalled() + onNotifiableEventsReceived.assertions().isCalledOnce() + onPushReceivedResult.assertions().isCalledOnce() + } + + @Test + fun `when notify call PushData is received, the incoming call will be treated as a normal notification even if notification are disabled`() = runTest { + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + val handleIncomingCallLambda = lambdaRecorder< + CallType.RoomCall, + EventId, + UserId, + String?, + String?, + String?, + String, + String?, + Unit, + > { _, _, _, _, _, _, _, _ -> } + val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda) + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val defaultPushHandler = createDefaultPushHandler( + elementCallEntryPoint = elementCallEntryPoint, + onNotifiableEventsReceived = onNotifiableEventsReceived, + notifiableEventsResult = { _, _ -> + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) + Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableCallEvent())))) + }, + incrementPushCounterResult = {}, + userPushStore = FakeUserPushStore().apply { + setNotificationEnabledForDevice(false) + }, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + pushHistoryService = pushHistoryService, + ) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + + advanceTimeBy(300.milliseconds) + + handleIncomingCallLambda.assertions().isCalledOnce() + onNotifiableEventsReceived.assertions().isNeverCalled() + onPushReceivedResult.assertions().isCalledOnce() + } + + @Test + fun `when a redaction is received, the onRedactedEventReceived is informed`() = runTest { + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val aRedaction = ResolvedPushEvent.Redaction( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + redactedEventId = AN_EVENT_ID_2, + reason = null + ) + val onRedactedEventReceived = lambdaRecorder, Unit> { } + val incrementPushCounterResult = lambdaRecorder {} + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val defaultPushHandler = createDefaultPushHandler( + onRedactedEventsReceived = onRedactedEventReceived, + incrementPushCounterResult = incrementPushCounterResult, + notifiableEventsResult = { _, _ -> + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) + Result.success(mapOf(request to Result.success(aRedaction))) + }, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + pushHistoryService = pushHistoryService, + ) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + + advanceTimeBy(300.milliseconds) + + incrementPushCounterResult.assertions() + .isCalledOnce() + onRedactedEventReceived.assertions().isCalledOnce() + .with(value(listOf(aRedaction))) + onPushReceivedResult.assertions() + .isCalledOnce() + } + + @Test + fun `when diagnostic PushData is received, the diagnostic push handler is informed`() = + runTest { + val aPushData = PushData( + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val diagnosticPushHandler = DiagnosticPushHandler() + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val defaultPushHandler = createDefaultPushHandler( + diagnosticPushHandler = diagnosticPushHandler, + incrementPushCounterResult = { }, + pushHistoryService = pushHistoryService, + ) + diagnosticPushHandler.state.test { + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + awaitItem() + } + onPushReceivedResult.assertions() + .isCalledOnce() + } + + @Test + fun `when receiving several push notifications at the same time, those are batched before being processed`() = runTest { + val aNotifiableMessageEvent = aNotifiableMessageEvent() + val notifiableEventResult = + lambdaRecorder, Result>>> { _, _ -> + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) + Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent)))) + } + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + val incrementPushCounterResult = lambdaRecorder {} + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val anotherPushData = PushData( + eventId = AN_EVENT_ID_2, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventsReceived = onNotifiableEventsReceived, + notifiableEventsResult = notifiableEventResult, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + incrementPushCounterResult = incrementPushCounterResult, + pushHistoryService = pushHistoryService, + ) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + defaultPushHandler.handle(anotherPushData, A_PUSHER_INFO) + + advanceTimeBy(300.milliseconds) + + incrementPushCounterResult.assertions() + .isCalledExactly(2) + notifiableEventResult.assertions() + .isCalledOnce() + .with(value(A_USER_ID), matching> { requests -> + requests.size == 2 && requests.first().eventId == AN_EVENT_ID && requests.last().eventId == AN_EVENT_ID_2 + }) + onNotifiableEventsReceived.assertions() + .isCalledOnce() + onPushReceivedResult.assertions() + .isCalledExactly(2) + } + + @Test + fun `when receiving a fallback event, we notify the push history service about it not being resolved`() = runTest { + val aNotifiableFallbackEvent = FallbackNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + description = "A fallback notification", + canBeReplaced = false, + isRedacted = false, + isUpdated = false, + timestamp = 0L, + cause = "Unable to decrypt event", + ) + val notifiableEventResult = + lambdaRecorder, Result>>> { _, _ -> + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) + Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableFallbackEvent)))) + } + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + val incrementPushCounterResult = lambdaRecorder {} + var receivedFallbackEvent = false + val onPushReceivedResult = + lambdaRecorder { _, _, _, _, isResolved, _, comment -> + receivedFallbackEvent = !isResolved && comment == "Unable to resolve event: ${aNotifiableFallbackEvent.cause}" + } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventsReceived = onNotifiableEventsReceived, + notifiableEventsResult = notifiableEventResult, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + incrementPushCounterResult = incrementPushCounterResult, + pushHistoryService = pushHistoryService, + ) + defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + + advanceTimeBy(300.milliseconds) + + onNotifiableEventsReceived.assertions().isCalledOnce() + + assertThat(receivedFallbackEvent).isTrue() + } + + private fun TestScope.createDefaultPushHandler( + onNotifiableEventsReceived: (List) -> Unit = { lambdaError() }, + onRedactedEventsReceived: (List) -> Unit = { lambdaError() }, + notifiableEventsResult: (SessionId, List) -> Result>> = + { _, _ -> lambdaError() }, + incrementPushCounterResult: () -> Unit = { lambdaError() }, + mutableBatteryOptimizationStore: MutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(), + userPushStore: UserPushStore = FakeUserPushStore(), + pushClientSecret: PushClientSecret = FakePushClientSecret(), + buildMeta: BuildMeta = aBuildMeta(), + diagnosticPushHandler: DiagnosticPushHandler = DiagnosticPushHandler(), + elementCallEntryPoint: FakeElementCallEntryPoint = FakeElementCallEntryPoint(), + notificationChannels: FakeNotificationChannels = FakeNotificationChannels(), + pushHistoryService: PushHistoryService = FakePushHistoryService(), + syncOnNotifiableEvent: SyncOnNotifiableEvent = SyncOnNotifiableEvent {}, + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.SyncNotificationsWithWorkManager.key to false)), + workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), + ): DefaultPushHandler { + return DefaultPushHandler( + onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventsReceived), + onRedactedEventReceived = FakeOnRedactedEventReceived(onRedactedEventsReceived), + incrementPushDataStore = object : IncrementPushDataStore { + override suspend fun incrementPushCounter() { + incrementPushCounterResult() + } + }, + mutableBatteryOptimizationStore = mutableBatteryOptimizationStore, + userPushStoreFactory = FakeUserPushStoreFactory { userPushStore }, + pushClientSecret = pushClientSecret, + buildMeta = buildMeta, + diagnosticPushHandler = diagnosticPushHandler, + elementCallEntryPoint = elementCallEntryPoint, + notificationChannels = notificationChannels, + pushHistoryService = pushHistoryService, + // We don't use a fake here so we can perform tests that are a bit more end to end + resolverQueue = DefaultNotificationResolverQueue( + notifiableEventResolver = FakeNotifiableEventResolver(notifiableEventsResult), + appCoroutineScope = backgroundScope, + workManagerScheduler = workManagerScheduler, + featureFlagService = featureFlagService, + workerDataConverter = WorkerDataConverter(DefaultJsonProvider()), + buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33), + ), + appCoroutineScope = backgroundScope, + fallbackNotificationFactory = FallbackNotificationFactory( + clock = FakeSystemClock(), + stringProvider = FakeStringProvider(), + ), + syncOnNotifiableEvent = syncOnNotifiableEvent, + featureFlagService = featureFlagService, + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeMutableBatteryOptimizationStore.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeMutableBatteryOptimizationStore.kt new file mode 100644 index 0000000..f5de845 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeMutableBatteryOptimizationStore.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.push + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeMutableBatteryOptimizationStore( + private val showBatteryOptimizationBannerResult: () -> Unit = { lambdaError() }, + private val onOptimizationBannerDismissedResult: () -> Unit = { lambdaError() }, + private val resetResult: () -> Unit = { lambdaError() }, +) : MutableBatteryOptimizationStore { + override suspend fun showBatteryOptimizationBanner() { + showBatteryOptimizationBannerResult() + } + + override suspend fun onOptimizationBannerDismissed() { + onOptimizationBannerDismissedResult() + } + + override suspend fun reset() { + resetResult() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnNotifiableEventReceived.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnNotifiableEventReceived.kt new file mode 100644 index 0000000..6686577 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnNotifiableEventReceived.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.push + +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeOnNotifiableEventReceived( + private val onNotifiableEventsReceivedResult: (List) -> Unit = { lambdaError() }, +) : OnNotifiableEventReceived { + override fun onNotifiableEventsReceived(notifiableEvents: List) { + onNotifiableEventsReceivedResult(notifiableEvents) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnRedactedEventReceived.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnRedactedEventReceived.kt new file mode 100644 index 0000000..e295298 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnRedactedEventReceived.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.push + +import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeOnRedactedEventReceived( + private val onRedactedEventsReceivedResult: (List) -> Unit = { lambdaError() }, +) : OnRedactedEventReceived { + override suspend fun onRedactedEventsReceived(redactions: List) { + onRedactedEventsReceivedResult(redactions) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEventTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEventTest.kt new file mode 100644 index 0000000..6c88e7b --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEventTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.push + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.sync.SyncState +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.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.sync.FakeSyncService +import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent +import io.element.android.libraries.push.impl.notifications.fixtures.aNotificationEventRequest +import io.element.android.services.appnavstate.test.FakeAppForegroundStateService +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SyncOnNotifiableEventTest { + private val startSyncLambda = lambdaRecorder> { Result.success(Unit) } + private val stopSyncLambda = lambdaRecorder> { Result.success(Unit) } + private val subscribeToSyncLambda = lambdaRecorder { } + + private val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomId = A_ROOM_ID, + subscribeToSyncLambda = subscribeToSyncLambda, + ), + ) + private val syncService = FakeSyncService(SyncState.Idle).also { + it.startSyncLambda = startSyncLambda + it.stopSyncLambda = stopSyncLambda + } + + private val client = FakeMatrixClient( + syncService = syncService, + ).apply { + givenGetRoomResult(A_ROOM_ID, room) + } + + private val notificationRequest = aNotificationEventRequest() + + @Test + fun `when feature flag is disabled, nothing happens`() = runTest { + val sut = createSyncOnNotifiableEvent(client = client, isSyncOnPushEnabled = false) + + sut(listOf(notificationRequest)) + + assert(startSyncLambda).isNeverCalled() + assert(stopSyncLambda).isNeverCalled() + assert(subscribeToSyncLambda).isNeverCalled() + } + + @Test + fun `when feature flag is enabled and app is in background, sync is started and stopped`() = runTest { + val appForegroundStateService = FakeAppForegroundStateService( + initialForegroundValue = false, + ) + val sut = createSyncOnNotifiableEvent(client = client, appForegroundStateService = appForegroundStateService, isSyncOnPushEnabled = true) + + appForegroundStateService.isSyncingNotificationEvent.test { + syncService.emitSyncState(SyncState.Running) + sut(listOf(notificationRequest)) + + // It's initially false + assertThat(awaitItem()).isFalse() + // Then it becomes true when we receive the push + assertThat(awaitItem()).isTrue() + // It becomes false once when the push is processed + assertThat(awaitItem()).isFalse() + + ensureAllEventsConsumed() + } + } + + @Test + fun `when feature flag is enabled and app is in background, running multiple times only call once`() = runTest { + val appForegroundStateService = FakeAppForegroundStateService( + initialForegroundValue = false, + ) + val sut = createSyncOnNotifiableEvent(client = client, appForegroundStateService = appForegroundStateService, isSyncOnPushEnabled = true) + + appForegroundStateService.isSyncingNotificationEvent.test { + launch { sut(listOf(notificationRequest)) } + launch { sut(listOf(notificationRequest)) } + + // It's initially false + assertThat(awaitItem()).isFalse() + // Then it becomes true once, for the first received push + assertThat(awaitItem()).isTrue() + // It becomes false once all pushes are processed + assertThat(awaitItem()).isFalse() + + ensureAllEventsConsumed() + } + } + + private fun TestScope.createSyncOnNotifiableEvent( + client: MatrixClient = FakeMatrixClient(), + isSyncOnPushEnabled: Boolean = true, + appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService( + initialForegroundValue = true, + ), + ): SyncOnNotifiableEvent { + val featureFlagService = FakeFeatureFlagService( + initialState = mapOf( + FeatureFlags.SyncOnPush.key to isSyncOnPushEnabled + ) + ) + val matrixClientProvider = FakeMatrixClientProvider { Result.success(client) } + return DefaultSyncOnNotifiableEvent( + matrixClientProvider = matrixClientProvider, + featureFlagService = featureFlagService, + appForegroundStateService = appForegroundStateService, + dispatchers = testCoroutineDispatchers(), + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/DefaultPushGatewayNotifyRequestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/DefaultPushGatewayNotifyRequestTest.kt new file mode 100644 index 0000000..47dc027 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/DefaultPushGatewayNotifyRequestTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.pushgateway + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.push.api.gateway.PushGatewayFailure +import io.element.android.libraries.push.impl.test.DefaultTestPush +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertThrows +import org.junit.Test + +class DefaultPushGatewayNotifyRequestTest { + @Test + fun `notify success`() = runTest { + val factory = FakePushGatewayApiFactory( + notifyResponse = { + PushGatewayNotifyResponse( + rejectedPushKeys = emptyList() + ) + } + ) + val pushGatewayNotifyRequest = DefaultPushGatewayNotifyRequest( + pushGatewayApiFactory = factory, + ) + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = "aUrl", + appId = "anAppId", + pushKey = "aPushKey", + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = DefaultTestPush.TEST_ROOM_ID, + ) + ) + assertThat(factory.baseUrlParameter).isEqualTo("aUrl") + } + + @Test + fun `notify success, url is stripped`() = runTest { + val factory = FakePushGatewayApiFactory( + notifyResponse = { + PushGatewayNotifyResponse( + rejectedPushKeys = emptyList() + ) + } + ) + val pushGatewayNotifyRequest = DefaultPushGatewayNotifyRequest( + pushGatewayApiFactory = factory, + ) + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = "aUrl" + PushGatewayConfig.URI_PUSH_GATEWAY_PREFIX_PATH, + appId = "anAppId", + pushKey = "aPushKey", + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = DefaultTestPush.TEST_ROOM_ID, + ) + ) + assertThat(factory.baseUrlParameter).isEqualTo("aUrl") + } + + @Test + fun `notify with rejected push key should throw expected Exception`() { + val factory = FakePushGatewayApiFactory( + notifyResponse = { + PushGatewayNotifyResponse( + rejectedPushKeys = listOf("aPushKey") + ) + } + ) + val pushGatewayNotifyRequest = DefaultPushGatewayNotifyRequest( + pushGatewayApiFactory = factory, + ) + assertThrows(PushGatewayFailure.PusherRejected::class.java) { + runTest { + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = "aUrl", + appId = "anAppId", + pushKey = "aPushKey", + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = DefaultTestPush.TEST_ROOM_ID, + ) + ) + } + } + assertThat(factory.baseUrlParameter).isEqualTo("aUrl") + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/FakePushGatewayApiFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/FakePushGatewayApiFactory.kt new file mode 100644 index 0000000..1b1958a --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/FakePushGatewayApiFactory.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.pushgateway + +class FakePushGatewayApiFactory( + private val notifyResponse: () -> PushGatewayNotifyResponse +) : PushGatewayApiFactory { + var baseUrlParameter: String? = null + private set + + override fun create(baseUrl: String): PushGatewayAPI { + baseUrlParameter = baseUrl + return FakePushGatewayAPI(notifyResponse) + } +} + +class FakePushGatewayAPI( + private val notifyResponse: () -> PushGatewayNotifyResponse +) : PushGatewayAPI { + override suspend fun notify(body: PushGatewayNotifyBody): PushGatewayNotifyResponse { + return notifyResponse() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/store/InMemoryPushDataStore.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/store/InMemoryPushDataStore.kt new file mode 100644 index 0000000..591e533 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/store/InMemoryPushDataStore.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.store + +import io.element.android.libraries.push.api.history.PushHistoryItem +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class InMemoryPushDataStore( + initialPushCounter: Int = 0, + initialShouldDisplayBatteryOptimizationBanner: Boolean = false, + initialPushHistoryItems: List = emptyList(), + private val resetResult: () -> Unit = { lambdaError() } +) : PushDataStore { + private val mutablePushCounterFlow = MutableStateFlow(initialPushCounter) + override val pushCounterFlow: Flow = mutablePushCounterFlow.asStateFlow() + + private val mutableShouldDisplayBatteryOptimizationBannerFlow = MutableStateFlow(initialShouldDisplayBatteryOptimizationBanner) + override val shouldDisplayBatteryOptimizationBannerFlow: Flow = mutableShouldDisplayBatteryOptimizationBannerFlow.asStateFlow() + + private val mutablePushHistoryItemsFlow = MutableStateFlow(initialPushHistoryItems) + + override fun getPushHistoryItemsFlow(): Flow> { + return mutablePushHistoryItemsFlow.asStateFlow() + } + + suspend fun emitPushHistoryItems(items: List) { + mutablePushHistoryItemsFlow.emit(items) + } + + override suspend fun reset() { + resetResult() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt new file mode 100644 index 0000000..9ab0f16 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.test + +import io.element.android.appconfig.PushConfig +import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest +import io.element.android.libraries.pushproviders.test.aSessionPushConfig +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultTestPushTest { + @Test + fun `test DefaultTestPush`() = runTest { + val executeResult = lambdaRecorder { } + val defaultTestPush = DefaultTestPush( + pushGatewayNotifyRequest = FakePushGatewayNotifyRequest( + executeResult = executeResult, + ) + ) + val aConfig = aSessionPushConfig() + defaultTestPush.execute(aConfig) + executeResult.assertions() + .isCalledOnce() + .with( + value( + PushGatewayNotifyRequest.Params( + url = aConfig.url, + appId = PushConfig.PUSHER_APP_ID, + pushKey = aConfig.pushKey, + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = DefaultTestPush.TEST_ROOM_ID, + ) + ) + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakePushGatewayNotifyRequest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakePushGatewayNotifyRequest.kt new file mode 100644 index 0000000..173b05e --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakePushGatewayNotifyRequest.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.test + +import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePushGatewayNotifyRequest( + private val executeResult: (PushGatewayNotifyRequest.Params) -> Unit = { lambdaError() } +) : PushGatewayNotifyRequest { + override suspend fun execute(params: PushGatewayNotifyRequest.Params) { + executeResult(params) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakeTestPush.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakeTestPush.kt new file mode 100644 index 0000000..9050601 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakeTestPush.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.test + +import io.element.android.libraries.pushproviders.api.Config +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeTestPush( + private val executeResult: (Config) -> Unit = { lambdaError() } +) : TestPush { + override suspend fun execute(config: Config) { + executeResult(config) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/CurrentPushProviderTestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/CurrentPushProviderTestTest.kt new file mode 100644 index 0000000..c49bff6 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/CurrentPushProviderTestTest.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.test.FakePushService +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.test.FakePushProvider +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.test.runAndTestState +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class CurrentPushProviderTestTest { + @Test + fun `test CurrentPushProviderTest with a push provider and a distributor`() = runTest { + val sut = CurrentPushProviderTest( + pushService = FakePushService( + currentPushProvider = { + FakePushProvider( + name = "foo", + currentDistributorValue = { "aDistributor" }, + ) + } + ), + stringProvider = FakeStringProvider(), + sessionId = A_SESSION_ID, + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + assertThat(lastItem.description).contains("foo") + } + } + + @Test + fun `test CurrentPushProviderTest with a push provider supporting multiple distributors, distributor found`() = runTest { + val sut = CurrentPushProviderTest( + pushService = FakePushService( + currentPushProvider = { + FakePushProvider( + name = "foo", + currentDistributorValue = { "aDistributor" }, + supportMultipleDistributors = true, + distributors = listOf(Distributor("aDistributor", "aDistributor")) + ) + }, + ), + stringProvider = FakeStringProvider(), + sessionId = A_SESSION_ID, + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + assertThat(lastItem.description).contains("foo") + } + } + + @Test + fun `test CurrentPushProviderTest with a push provider supporting multiple distributors, no distributor`() = runTest { + val sut = CurrentPushProviderTest( + pushService = FakePushService( + currentPushProvider = { + FakePushProvider( + name = "foo", + currentDistributorValue = { null }, + supportMultipleDistributors = true, + ) + }, + ), + stringProvider = FakeStringProvider(), + sessionId = A_SESSION_ID, + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure()) + } + } + + @Test + fun `test CurrentPushProviderTest with a push provider supporting multiple distributors, distributor not found`() = runTest { + val sut = CurrentPushProviderTest( + pushService = FakePushService( + currentPushProvider = { + FakePushProvider( + name = "foo", + currentDistributorValue = { "aDistributor" }, + supportMultipleDistributors = true, + distributors = emptyList() + ) + }, + ), + stringProvider = FakeStringProvider(), + sessionId = A_SESSION_ID, + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure()) + } + } + + @Test + fun `test CurrentPushProviderTest without push provider`() = runTest { + val sut = CurrentPushProviderTest( + pushService = FakePushService( + currentPushProvider = { null }, + ), + stringProvider = FakeStringProvider(), + sessionId = A_SESSION_ID, + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure()) + sut.reset() + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + } + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/IgnoredUsersTestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/IgnoredUsersTestTest.kt new file mode 100644 index 0000000..c662361 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/IgnoredUsersTestTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import com.google.common.truth.Truth.assertThat +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.FakeMatrixClient +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootNavigator +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.test.runAndTestState +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class IgnoredUsersTestTest { + @Test + fun `test IgnoredUsersTest order`() = runTest { + val sut = IgnoredUsersTest( + matrixClient = FakeMatrixClient(), + stringProvider = FakeStringProvider(), + ) + assertThat(sut.order).isEqualTo(80) + } + + @Test + fun `test IgnoredUsersTest quick fix`() = runTest { + val sut = IgnoredUsersTest( + matrixClient = FakeMatrixClient(), + stringProvider = FakeStringProvider(), + ) + val openIgnoredUsersResult = lambdaRecorder {} + val navigator = object : NotificationTroubleshootNavigator { + override fun navigateToBlockedUsers() = openIgnoredUsersResult() + } + sut.quickFix( + coroutineScope = backgroundScope, + navigator = navigator, + ) + openIgnoredUsersResult.assertions().isCalledOnce() + } + + @Test + fun `test IgnoredUsersTest with no blocked users`() = runTest { + val sut = IgnoredUsersTest( + matrixClient = FakeMatrixClient( + ignoredUsersFlow = MutableStateFlow(persistentListOf()) + ), + stringProvider = FakeStringProvider(), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + } + } + + @Test + fun `test IgnoredUsersTest with blocked users`() = runTest { + val sut = IgnoredUsersTest( + matrixClient = FakeMatrixClient( + ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID, A_USER_ID_2)) + ), + stringProvider = FakeStringProvider(), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + val lastStatus = lastItem.status as NotificationTroubleshootTestState.Status.Failure + assertThat(lastStatus.hasQuickFix).isTrue() + assertThat(lastStatus.isCritical).isFalse() + assertThat(lastStatus.quickFixButtonString).isNotNull() + assertThat(lastItem.description).contains("2") + } + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTestTest.kt new file mode 100644 index 0000000..06dbe38 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTestTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.test.runAndTestState +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class NotificationTestTest { + private val notificationCreator = FakeNotificationCreator() + private val fakeNotificationDisplayer = FakeNotificationDisplayer( + displayDiagnosticNotificationResult = lambdaRecorder { _ -> true }, + dismissDiagnosticNotificationResult = lambdaRecorder { -> } + ) + + private val notificationClickHandler = NotificationClickHandler() + + @Test + fun `test NotificationTest notification cannot be displayed`() = runTest { + fakeNotificationDisplayer.displayDiagnosticNotificationResult = lambdaRecorder { _ -> false } + val sut = createNotificationTest() + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + assertThat(awaitItem().status).isInstanceOf(NotificationTroubleshootTestState.Status.Failure::class.java) + } + } + + @Test + fun `test NotificationTest user does not click on notification`() = runTest { + val sut = createNotificationTest() + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.WaitingForUser) + assertThat(awaitItem().status).isInstanceOf(NotificationTroubleshootTestState.Status.Failure::class.java) + sut.reset() + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + } + } + + @Test + fun `test NotificationTest user clicks on notification`() = runTest { + val sut = createNotificationTest() + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.WaitingForUser) + notificationClickHandler.handleNotificationClick() + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + } + } + + private fun createNotificationTest(): NotificationTest { + return NotificationTest( + sessionId = A_SESSION_ID, + notificationCreator = notificationCreator, + notificationDisplayer = fakeNotificationDisplayer, + notificationClickHandler = notificationClickHandler, + stringProvider = FakeStringProvider(), + enterpriseService = FakeEnterpriseService(), + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTestTest.kt new file mode 100644 index 0000000..f83fb66 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTestTest.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_FAILURE_REASON +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.api.gateway.PushGatewayFailure +import io.element.android.libraries.push.test.FakePushService +import io.element.android.libraries.pushproviders.test.FakePushProvider +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleshootNavigator +import io.element.android.libraries.troubleshoot.test.runAndTestState +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.api.systemclock.SystemClock +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PushLoopbackTestTest { + @Test + fun `test PushLoopbackTest timeout - push is not received`() = runTest { + val sut = createPushLoopbackTest() + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure()) + } + } + + @Test + fun `test PushLoopbackTest PusherRejected error`() = runTest { + val sut = createPushLoopbackTest( + pushService = FakePushService( + testPushBlock = { + throw PushGatewayFailure.PusherRejected() + } + ), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure()) + sut.reset() + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + } + } + + @Test + fun `test PushLoopbackTest PusherRejected error with quick fix`() = runTest { + val rotateTokenLambda = lambdaRecorder> { Result.success(Unit) } + val sut = createPushLoopbackTest( + pushService = FakePushService( + testPushBlock = { + throw PushGatewayFailure.PusherRejected() + }, + currentPushProvider = { + FakePushProvider( + canRotateTokenResult = { true }, + rotateTokenLambda = rotateTokenLambda, + ) + } + ), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(hasQuickFix = true)) + sut.quickFix(this, FakeNotificationTroubleshootNavigator()) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(hasQuickFix = true)) + rotateTokenLambda.assertions().isCalledOnce() + } + } + + @Test + fun `test PushLoopbackTest setup error`() = runTest { + val sut = createPushLoopbackTest( + pushService = FakePushService( + testPushBlock = { false } + ), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure()) + } + } + + @Test + fun `test PushLoopbackTest other error`() = runTest { + val sut = createPushLoopbackTest( + pushService = FakePushService( + testPushBlock = { + throw AN_EXCEPTION + } + ), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure()) + assertThat(lastItem.description).contains(A_FAILURE_REASON) + } + } + + @Test + fun `test PushLoopbackTest push is received`() = runTest { + val diagnosticPushHandler = DiagnosticPushHandler() + val sut = createPushLoopbackTest( + pushService = FakePushService(testPushBlock = { + diagnosticPushHandler.handlePush() + true + }), + diagnosticPushHandler = diagnosticPushHandler, + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + } + } +} + +private fun createPushLoopbackTest( + sessionId: SessionId = A_SESSION_ID, + pushService: PushService = FakePushService(), + diagnosticPushHandler: DiagnosticPushHandler = DiagnosticPushHandler(), + clock: SystemClock = FakeSystemClock(), + stringProvider: StringProvider = FakeStringProvider(), +) = PushLoopbackTest( + sessionId = sessionId, + pushService = pushService, + diagnosticPushHandler = diagnosticPushHandler, + clock = clock, + stringProvider = stringProvider +) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTestTest.kt new file mode 100644 index 0000000..ce94d38 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushProvidersTestTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.troubleshoot + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.pushproviders.test.FakePushProvider +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.test.runAndTestState +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PushProvidersTestTest { + @Test + fun `test PushProvidersTest with empty list`() = runTest { + val sut = PushProvidersTest( + pushProviders = emptySet(), + stringProvider = FakeStringProvider(), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure()) + sut.reset() + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + } + } + + @Test + fun `test PushProvidersTest with 2 push providers`() = runTest { + val sut = PushProvidersTest( + pushProviders = setOf( + FakePushProvider(name = "foo"), + FakePushProvider(name = "bar"), + ), + stringProvider = FakeStringProvider(), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(true)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + assertThat(lastItem.description).contains("foo") + assertThat(lastItem.description).contains("bar") + } + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/unregistration/DefaultServiceUnregisteredHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/unregistration/DefaultServiceUnregisteredHandlerTest.kt new file mode 100644 index 0000000..bda82a3 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/unregistration/DefaultServiceUnregisteredHandlerTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.unregistration + +import android.app.Notification +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import io.element.android.appconfig.NotificationConfig +import io.element.android.features.enterprise.api.EnterpriseService +import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.libraries.matrix.api.user.MatrixUser +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.push.impl.notifications.NotificationDisplayer +import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams +import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer +import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultServiceUnregisteredHandlerTest { + @Test + fun `handle will create a notification and render it`() = runTest { + val notification = A_NOTIFICATION + val createUnregistrationNotificationResult = lambdaRecorder { notification } + val displayUnregistrationNotificationResult = lambdaRecorder { true } + val sut = createDefaultServiceUnregisteredHandler( + notificationCreator = FakeNotificationCreator( + createUnregistrationNotificationResult = createUnregistrationNotificationResult, + ), + notificationDisplayer = FakeNotificationDisplayer( + displayUnregistrationNotificationResult = displayUnregistrationNotificationResult, + ) + ) + sut.handle(A_SESSION_ID) + createUnregistrationNotificationResult.assertions().isCalledOnce().with( + value( + NotificationAccountParams( + MatrixUser( + userId = A_SESSION_ID, + displayName = null, + avatarUrl = null, + ), + color = NotificationConfig.NOTIFICATION_ACCENT_COLOR, + showSessionId = false, + ) + ) + ) + displayUnregistrationNotificationResult.assertions().isCalledOnce().with( + value(notification) + ) + } + + @Test + fun `handle will create a notification and render it - custom color and multi accounts`() = runTest { + val notification = A_NOTIFICATION + val createUnregistrationNotificationResult = lambdaRecorder { notification } + val displayUnregistrationNotificationResult = lambdaRecorder { true } + val sut = createDefaultServiceUnregisteredHandler( + enterpriseService = FakeEnterpriseService( + initialBrandColor = Color.Red, + ), + notificationCreator = FakeNotificationCreator( + createUnregistrationNotificationResult = createUnregistrationNotificationResult, + ), + notificationDisplayer = FakeNotificationDisplayer( + displayUnregistrationNotificationResult = displayUnregistrationNotificationResult, + ), + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_SESSION_ID.value), + aSessionData(sessionId = A_SESSION_ID_2.value), + ) + ) + ) + sut.handle(A_SESSION_ID) + createUnregistrationNotificationResult.assertions().isCalledOnce().with( + value( + NotificationAccountParams( + MatrixUser( + userId = A_SESSION_ID, + displayName = null, + avatarUrl = null, + ), + color = Color.Red.toArgb(), + showSessionId = true, + ) + ) + ) + displayUnregistrationNotificationResult.assertions().isCalledOnce().with( + value(notification) + ) + } + + private fun createDefaultServiceUnregisteredHandler( + enterpriseService: EnterpriseService = FakeEnterpriseService(), + notificationCreator: NotificationCreator = FakeNotificationCreator(), + notificationDisplayer: NotificationDisplayer = FakeNotificationDisplayer(), + sessionStore: SessionStore = InMemorySessionStore(), + ) = DefaultServiceUnregisteredHandler( + enterpriseService = enterpriseService, + notificationCreator = notificationCreator, + notificationDisplayer = notificationDisplayer, + sessionStore = sessionStore, + ) +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/unregistration/FakeServiceUnregisteredHandler.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/unregistration/FakeServiceUnregisteredHandler.kt new file mode 100644 index 0000000..0ae4190 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/unregistration/FakeServiceUnregisteredHandler.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.unregistration + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeServiceUnregisteredHandler( + private val handleResult: (UserId) -> Unit = { lambdaError() }, +) : ServiceUnregisteredHandler { + override suspend fun handle(userId: UserId) { + handleResult(userId) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationWorkerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationWorkerTest.kt new file mode 100644 index 0000000..23a38db --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationWorkerTest.kt @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.workmanager + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.Data +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import androidx.work.impl.utils.taskexecutor.WorkManagerTaskExecutor +import androidx.work.workDataOf +import com.google.common.truth.Truth.assertThat +import com.google.common.util.concurrent.ListenableFuture +import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.features.networkmonitor.test.FakeNetworkMonitor +import io.element.android.libraries.androidutils.json.DefaultJsonProvider +import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent +import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver +import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.libraries.push.test.notifications.FakeNotificationResolverQueue +import io.element.android.libraries.workmanager.api.WorkManagerRequest +import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory +import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import java.util.UUID +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class FetchNotificationWorkerTest { + @Test + fun `test - success`() = runTest { + var synced = false + val syncOnNotifiableEventLambda = SyncOnNotifiableEvent { synced = true } + + val queue = FakeNotificationResolverQueue( + processingLambda = { Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent())) } + ) + val worker = createWorker( + input = """ + [ + { + "session_id": "@alice:matrix.org", + "room_id": "!roomid:matrix.org", + "event_id": "$1436ebk:matrix.org", + "provider_info": "some_info" + } + ] + """.trimIndent(), + queue = queue, + syncOnNotifiableEvent = syncOnNotifiableEventLambda, + ) + + val result = worker.doWork() + + // The process finished successfully + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + + // A result was emitted + assertThat(queue.results.replayCache).isNotEmpty() + + // An opportunistic sync was triggered + assertThat(synced).isTrue() + } + + @Test + fun `test - invalid input fails the work`() = runTest { + val worker = createWorker( + input = """ + [ + { + "session_id": "!alice:matrix.org", + "room_id": "!roomid:matrix.org", + "event_id": "$1436ebk:matrix.org", + "provider_info": "some_info" + } + ] + """.trimIndent(), + ) + + val result = worker.doWork() + + // The process failed + assertThat(result).isEqualTo(ListenableWorker.Result.failure()) + } + + @Test + fun `test - no network connectivity fails the work`() = runTest { + val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected) + val worker = createWorker( + input = """ + [ + { + "session_id": "@alice:matrix.org", + "room_id": "!roomid:matrix.org", + "event_id": "$1436ebk:matrix.org", + "provider_info": "some_info" + } + ] + """.trimIndent(), + networkMonitor = networkMonitor, + ) + + val result = worker.doWork() + + advanceTimeBy(10.seconds) + + // The process failed due to a timeout in getting the network connectivity, a retry is scheduled + assertThat(result).isEqualTo(ListenableWorker.Result.retry()) + } + + @Test + fun `test - failing to resolve events re-schedules the work`() = runTest { + val submitWorkerLambda = lambdaRecorder {} + val scheduler = FakeWorkManagerScheduler(submitLambda = submitWorkerLambda) + + val resolver = FakeNotifiableEventResolver( + resolveEventsResult = { _, _ -> Result.failure(Exception("Failed to resolve events")) } + ) + + val worker = createWorker( + input = """ + [ + { + "session_id": "@alice:matrix.org", + "room_id": "!roomid:matrix.org", + "event_id": "$1436ebk:matrix.org", + "provider_info": "some_info" + } + ] + """.trimIndent(), + eventResolver = resolver, + workManagerScheduler = scheduler, + ) + + val result = worker.doWork() + + // The process was considered successful, but a retry was scheduled due to the failure to resolve events + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + submitWorkerLambda.assertions().isCalledOnce() + } + + private fun TestScope.createWorker( + input: String, + networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), + eventResolver: FakeNotifiableEventResolver = FakeNotifiableEventResolver(resolveEventsResult = { _, _ -> Result.success(emptyMap()) }), + queue: NotificationResolverQueue = FakeNotificationResolverQueue( + processingLambda = { Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent())) } + ), + workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), + syncOnNotifiableEvent: SyncOnNotifiableEvent = SyncOnNotifiableEvent {}, + ) = FetchNotificationsWorker( + workerParams = createWorkerParams(workDataOf("requests" to input)), + context = InstrumentationRegistry.getInstrumentation().context, + networkMonitor = networkMonitor, + eventResolver = eventResolver, + queue = queue, + workManagerScheduler = workManagerScheduler, + syncOnNotifiableEvent = syncOnNotifiableEvent, + coroutineDispatchers = testCoroutineDispatchers(), + workerDataConverter = WorkerDataConverter(DefaultJsonProvider()), + buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33), + ) + + private fun TestScope.createWorkerParams( + inputData: Data = Data.EMPTY, + ): WorkerParameters = WorkerParameters( + UUID.randomUUID(), + inputData, + emptySet(), + WorkerParameters.RuntimeExtras(), + 0, + 0, + Executors.newSingleThreadExecutor(), + backgroundScope.coroutineContext, + WorkManagerTaskExecutor(Executors.newSingleThreadExecutor()), + MetroWorkerFactory(emptyMap()), + { context, id, data -> FakeListenableFuture() }, + { context, id, foregroundInfo -> FakeListenableFuture() }, + ) +} + +class FakeListenableFuture : ListenableFuture { + override fun addListener(listener: Runnable, executor: Executor) = Unit + override fun cancel(mayInterruptIfRunning: Boolean): Boolean = true + override fun get(): T? = null + override fun get(timeout: Long, unit: TimeUnit?): T? = null + override fun isCancelled(): Boolean = false + override fun isDone(): Boolean = false +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequestTest.kt new file mode 100644 index 0000000..9dae435 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequestTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.workmanager + +import androidx.work.OneTimeWorkRequest +import androidx.work.hasKeyWithValueOfType +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.json.DefaultJsonProvider +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.api.push.NotificationEventRequest +import io.element.android.libraries.push.impl.notifications.fixtures.aNotificationEventRequest +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.workManagerTag +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.collections.first + +class SyncNotificationWorkManagerRequestTest { + @Test + fun `build - success API 33`() = runTest { + val request = createSyncNotificationWorkManagerRequest( + sessionId = A_SESSION_ID, + notificationEventRequests = listOf(aNotificationEventRequest()), + sdkVersion = 33, + ) + + val result = request.build() + assertThat(result.isSuccess).isTrue() + result.getOrNull()!!.first().run { + assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java) + assertThat(workSpec.input.hasKeyWithValueOfType("requests")).isTrue() + // True in API 33+ + assertThat(workSpec.expedited).isTrue() + assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC)) + } + } + + @Test + fun `build - success API 32 and lower`() = runTest { + val request = createSyncNotificationWorkManagerRequest( + sessionId = A_SESSION_ID, + notificationEventRequests = listOf(aNotificationEventRequest()), + sdkVersion = 32, + ) + + val result = request.build() + assertThat(result.isSuccess).isTrue() + result.getOrNull()!!.first().run { + assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java) + assertThat(workSpec.input.hasKeyWithValueOfType("requests")).isTrue() + // False before API 33 + assertThat(workSpec.expedited).isFalse() + assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC)) + } + } + + @Test + fun `build - empty list of requests fails`() = runTest { + val request = createSyncNotificationWorkManagerRequest( + sessionId = A_SESSION_ID, + notificationEventRequests = emptyList() + ) + + val result = request.build() + assertThat(result.isFailure).isTrue() + } + + @Test + fun `build - invalid serialization`() = runTest { + val request = createSyncNotificationWorkManagerRequest( + sessionId = A_SESSION_ID, + notificationEventRequests = listOf(aNotificationEventRequest()), + workerDataConverter = WorkerDataConverter({ error("error during serialization") }) + ) + val result = request.build() + assertThat(result.isFailure).isTrue() + } +} + +private fun createSyncNotificationWorkManagerRequest( + sessionId: SessionId, + notificationEventRequests: List, + workerDataConverter: WorkerDataConverter = WorkerDataConverter(DefaultJsonProvider()), + sdkVersion: Int = 33, +) = SyncNotificationWorkManagerRequest( + sessionId = sessionId, + notificationEventRequests = notificationEventRequests, + workerDataConverter = workerDataConverter, + buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkVersion), +) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/WorkerDataConverterTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/WorkerDataConverterTest.kt new file mode 100644 index 0000000..6c6998c --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/WorkerDataConverterTest.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.workmanager + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.json.DefaultJsonProvider +import io.element.android.libraries.matrix.api.core.EventId +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_ROOM_ID_3 +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.push.api.push.NotificationEventRequest +import org.junit.Test + +class WorkerDataConverterTest { + @Test + fun `ensure identity when serializing - deserializing an empty list`() { + testIdentity(emptyList()) + } + + @Test + fun `ensure identity when serializing - deserializing a list`() { + testIdentity( + listOf( + NotificationEventRequest( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + providerInfo = "info1", + ), + NotificationEventRequest( + sessionId = A_SESSION_ID_2, + roomId = A_ROOM_ID_2, + eventId = AN_EVENT_ID_2, + providerInfo = "info2", + ), + ) + ) + } + + @Test + fun `serializing lots of data leads to several work data generated - one room - 100 events should be split in 5 chunks`() { + val data = List(100) { + NotificationEventRequest( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = EventId(AN_EVENT_ID.value + it), + providerInfo = "info$it", + ) + } + val sut = WorkerDataConverter(DefaultJsonProvider()) + val serialized = sut.serialize(data) + assertThat(serialized.getOrNull()?.size).isGreaterThan(1) + assertThat(serialized.getOrNull()?.size).isEqualTo(100 / WorkerDataConverter.CHUNK_SIZE) + // All the items are present + val deserialized = serialized.getOrNull()?.flatMap { sut.deserialize(it)!! } + assertThat(deserialized).containsExactlyElementsIn(data) + } + + @Test + fun `serializing lots of data leads to several work data generated - one room - 101 events should be split in 6 chunks`() { + val data = List(101) { + NotificationEventRequest( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = EventId(AN_EVENT_ID.value + it), + providerInfo = "info$it", + ) + } + val sut = WorkerDataConverter(DefaultJsonProvider()) + val serialized = sut.serialize(data) + assertThat(serialized.getOrNull()?.size).isGreaterThan(1) + assertThat(serialized.getOrNull()?.size).isEqualTo(100 / WorkerDataConverter.CHUNK_SIZE + 1) + // All the items are present + val deserialized = serialized.getOrNull()?.flatMap { sut.deserialize(it)!! } + assertThat(deserialized).containsExactlyElementsIn(data) + } + + @Test + fun `serializing lots of data leads to several work data generated - 3 rooms - 25 events should be split in 2 chunks and room not mixed`() { + val data1 = List(15) { + NotificationEventRequest( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = EventId(AN_EVENT_ID.value + it), + providerInfo = "info".repeat(100) + it, + ) + } + val data2 = List(3) { + NotificationEventRequest( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID_2, + eventId = EventId(AN_EVENT_ID.value + it), + providerInfo = "info".repeat(100) + it, + ) + } + val data3 = List(7) { + NotificationEventRequest( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID_3, + eventId = EventId(AN_EVENT_ID.value + it), + providerInfo = "info".repeat(100) + it, + ) + } + val data = (data1 + data2 + data3).shuffled() + val sut = WorkerDataConverter(DefaultJsonProvider()) + val serialized = sut.serialize(data) + assertThat(serialized.getOrNull()?.size).isEqualTo(2) + // All the items are present + val deserialized = serialized.getOrNull()?.flatMap { sut.deserialize(it)!! } + assertThat(deserialized).containsExactlyElementsIn(data) + // Rooms are not mixed between the chunks + val setsOfRooms = serialized.getOrNull()!! + .map { workData -> sut.deserialize(workData)!! } + .map { + it.map { request -> request.roomId }.toSet() + } + // Ensure that all sets are distinct + assertThat(setsOfRooms.size).isEqualTo(2) + // 3 roomId are present + assertThat(setsOfRooms.flatten().toSet()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3) + // No intersection between sets + assertThat(setsOfRooms[0].intersect(setsOfRooms[1])).isEmpty() + } + + private fun testIdentity(data: List) { + val sut = WorkerDataConverter(DefaultJsonProvider()) + val serialized = sut.serialize(data).getOrThrow() + val result = sut.deserialize(serialized.first()) + assertThat(result).isEqualTo(data) + } +} diff --git a/libraries/push/test/build.gradle.kts b/libraries/push/test/build.gradle.kts new file mode 100644 index 0000000..3a0b553 --- /dev/null +++ b/libraries/push/test/build.gradle.kts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.push.test" +} + +dependencies { + api(projects.libraries.push.api) + api(projects.libraries.pushproviders.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.push.impl) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.tests.testutils) + implementation(libs.androidx.core) + implementation(libs.coil.compose) + implementation(libs.coil.test) + implementation(libs.test.robolectric) +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakeGetCurrentPushProvider.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakeGetCurrentPushProvider.kt new file mode 100644 index 0000000..f9cae82 --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakeGetCurrentPushProvider.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.test + +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.api.GetCurrentPushProvider + +class FakeGetCurrentPushProvider( + private val currentPushProvider: String? +) : GetCurrentPushProvider { + override suspend fun getCurrentPushProvider(sessionId: SessionId): String? = currentPushProvider +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt new file mode 100644 index 0000000..604ff88 --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.test + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.api.history.PushHistoryItem +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FakePushService( + private val testPushBlock: suspend (SessionId) -> Boolean = { true }, + private val availablePushProviders: List = emptyList(), + private val registerWithLambda: (MatrixClient, PushProvider, Distributor) -> Result = { _, _, _ -> + Result.success(Unit) + }, + private val currentPushProvider: (SessionId) -> PushProvider? = { availablePushProviders.firstOrNull() }, + private val selectPushProviderLambda: suspend (SessionId, PushProvider) -> Unit = { _, _ -> lambdaError() }, + private val setIgnoreRegistrationErrorLambda: (SessionId, Boolean) -> Unit = { _, _ -> lambdaError() }, + private val resetPushHistoryResult: () -> Unit = { lambdaError() }, + private val resetBatteryOptimizationStateResult: () -> Unit = { lambdaError() }, + private val onServiceUnregisteredResult: (UserId) -> Unit = { lambdaError() }, + private val ensurePusherIsRegisteredResult: () -> Result = { lambdaError() }, +) : PushService { + override suspend fun getCurrentPushProvider(sessionId: SessionId): PushProvider? { + return registeredPushProvider ?: currentPushProvider(sessionId) + } + + override fun getAvailablePushProviders(): List { + return availablePushProviders + } + + private var registeredPushProvider: PushProvider? = null + + override suspend fun registerWith( + matrixClient: MatrixClient, + pushProvider: PushProvider, + distributor: Distributor, + ): Result = simulateLongTask { + return registerWithLambda(matrixClient, pushProvider, distributor) + .also { + if (it.isSuccess) { + registeredPushProvider = pushProvider + } + } + } + + override suspend fun ensurePusherIsRegistered(matrixClient: MatrixClient): Result { + return ensurePusherIsRegisteredResult() + } + + override suspend fun selectPushProvider(sessionId: SessionId, pushProvider: PushProvider) { + selectPushProviderLambda(sessionId, pushProvider) + } + + private val ignoreRegistrationError = MutableStateFlow(false) + + override fun ignoreRegistrationError(sessionId: SessionId): Flow { + return ignoreRegistrationError + } + + override suspend fun setIgnoreRegistrationError(sessionId: SessionId, ignore: Boolean) { + ignoreRegistrationError.value = ignore + setIgnoreRegistrationErrorLambda(sessionId, ignore) + } + + override suspend fun testPush(sessionId: SessionId): Boolean = simulateLongTask { + testPushBlock(sessionId) + } + + private val pushHistoryItemsFlow = MutableStateFlow>(emptyList()) + + override fun getPushHistoryItemsFlow(): Flow> { + return pushHistoryItemsFlow + } + + fun emitPushHistoryItems(items: List) { + pushHistoryItemsFlow.value = items + } + + private val pushCounterFlow = MutableStateFlow(0) + + override val pushCounter: Flow = pushCounterFlow + + fun emitPushCounter(counter: Int) { + pushCounterFlow.value = counter + } + + override suspend fun resetPushHistory() = simulateLongTask { + resetPushHistoryResult() + } + + override suspend fun resetBatteryOptimizationState() { + resetBatteryOptimizationStateResult() + } + + override suspend fun onServiceUnregistered(userId: UserId) { + onServiceUnregisteredResult(userId) + } +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePusherSubscriber.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePusherSubscriber.kt new file mode 100644 index 0000000..50ed062 --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePusherSubscriber.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.test + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePusherSubscriber( + private val registerPusherResult: (MatrixClient, String, String) -> Result = { _, _, _ -> lambdaError() }, + private val unregisterPusherResult: (MatrixClient, String, String) -> Result = { _, _, _ -> lambdaError() }, +) : PusherSubscriber { + override suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result { + return registerPusherResult(matrixClient, pushKey, gateway) + } + + override suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result { + return unregisterPusherResult(matrixClient, pushKey, gateway) + } +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeCallNotificationEventResolver.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeCallNotificationEventResolver.kt new file mode 100644 index 0000000..f923d0c --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeCallNotificationEventResolver.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.test.notifications + +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.notification.NotificationData +import io.element.android.libraries.push.impl.notifications.CallNotificationEventResolver +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeCallNotificationEventResolver( + var resolveEventLambda: (sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean) -> Result = { _, _, _ -> + lambdaError() + }, +) : CallNotificationEventResolver { + override suspend fun resolveEvent(sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean): Result { + return resolveEventLambda(sessionId, notificationData, forceNotify) + } +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationCleaner.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationCleaner.kt new file mode 100644 index 0000000..28a249d --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationCleaner.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.test.notifications + +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.api.notifications.NotificationCleaner +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeNotificationCleaner( + private val clearAllMessagesEventsLambda: (SessionId) -> Unit = { lambdaError() }, + private val clearMessagesForRoomLambda: (SessionId, RoomId) -> Unit = { _, _ -> lambdaError() }, + private val clearMessagesForThreadLambda: (SessionId, RoomId, ThreadId) -> Unit = { _, _, _ -> lambdaError() }, + private val clearEventLambda: (SessionId, EventId) -> Unit = { _, _ -> lambdaError() }, + private val clearMembershipNotificationForSessionLambda: (SessionId) -> Unit = { lambdaError() }, + private val clearMembershipNotificationForRoomLambda: (SessionId, RoomId) -> Unit = { _, _ -> lambdaError() } +) : NotificationCleaner { + override fun clearAllMessagesEvents(sessionId: SessionId) { + clearAllMessagesEventsLambda(sessionId) + } + + override fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) { + clearMessagesForRoomLambda(sessionId, roomId) + } + + override fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) { + clearMessagesForThreadLambda(sessionId, roomId, threadId) + } + + override fun clearEvent(sessionId: SessionId, eventId: EventId) { + clearEventLambda(sessionId, eventId) + } + + override fun clearMembershipNotificationForSession(sessionId: SessionId) { + clearMembershipNotificationForSessionLambda(sessionId) + } + + override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { + clearMembershipNotificationForRoomLambda(sessionId, roomId) + } +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationResolverQueue.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationResolverQueue.kt new file mode 100644 index 0000000..d4279ab --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationResolverQueue.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.test.notifications + +import io.element.android.libraries.push.api.push.NotificationEventRequest +import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue +import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import kotlinx.coroutines.flow.MutableSharedFlow + +class FakeNotificationResolverQueue( + private val processingLambda: suspend (NotificationEventRequest) -> Result, +) : NotificationResolverQueue { + override val results = MutableSharedFlow, Map>>>(replay = 1) + + override suspend fun enqueue(request: NotificationEventRequest) { + results.emit(listOf(request) to mapOf(request to processingLambda(request))) + } +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeOnMissedCallNotificationHandler.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeOnMissedCallNotificationHandler.kt new file mode 100644 index 0000000..5e8063d --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeOnMissedCallNotificationHandler.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.test.notifications + +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.push.api.notifications.OnMissedCallNotificationHandler + +class FakeOnMissedCallNotificationHandler( + var addMissedCallNotificationLambda: (SessionId, RoomId, EventId) -> Unit = { _, _, _ -> } +) : OnMissedCallNotificationHandler { + override suspend fun addMissedCallNotification( + sessionId: SessionId, + roomId: RoomId, + eventId: EventId, + ) { + addMissedCallNotificationLambda(sessionId, roomId, eventId) + } +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/conversations/FakeNotificationConversationService.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/conversations/FakeNotificationConversationService.kt new file mode 100644 index 0000000..0c8d870 --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/conversations/FakeNotificationConversationService.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.test.notifications.conversations + +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.conversations.NotificationConversationService + +class FakeNotificationConversationService : NotificationConversationService { + override suspend fun onSendMessage( + sessionId: SessionId, + roomId: RoomId, + roomName: String, + roomIsDirect: Boolean, + roomAvatarUrl: String?, + ) = Unit + + override suspend fun onLeftRoom(sessionId: SessionId, roomId: RoomId) = Unit + + override suspend fun onAvailableRoomsChanged(sessionId: SessionId, roomIds: Set) = Unit +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/push/FakeNotificationBitmapLoader.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/push/FakeNotificationBitmapLoader.kt new file mode 100644 index 0000000..78af0e5 --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/push/FakeNotificationBitmapLoader.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.test.notifications.push + +import android.graphics.Bitmap +import androidx.core.graphics.drawable.IconCompat +import coil3.ImageLoader +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader + +class FakeNotificationBitmapLoader( + var getRoomBitmapResult: (AvatarData, ImageLoader, Long) -> Bitmap? = { _, _, _ -> null }, + var getUserIconResult: (AvatarData, ImageLoader) -> IconCompat? = { _, _ -> null }, +) : NotificationBitmapLoader { + override suspend fun getRoomBitmap(avatarData: AvatarData, imageLoader: ImageLoader, targetSize: Long): Bitmap? { + return getRoomBitmapResult(avatarData, imageLoader, targetSize) + } + + override suspend fun getUserIcon(avatarData: AvatarData, imageLoader: ImageLoader): IconCompat? { + return getUserIconResult(avatarData, imageLoader) + } +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/test/FakePushHandler.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/test/FakePushHandler.kt new file mode 100644 index 0000000..a7e476b --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/test/FakePushHandler.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.test.test + +import io.element.android.libraries.pushproviders.api.PushData +import io.element.android.libraries.pushproviders.api.PushHandler +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePushHandler( + private val handleResult: (PushData, String) -> Unit = { _, _ -> lambdaError() }, + private val handleInvalidResult: (String, String) -> Unit = { _, _ -> lambdaError() }, +) : PushHandler { + override suspend fun handle(pushData: PushData, providerInfo: String) { + handleResult(pushData, providerInfo) + } + + override suspend fun handleInvalid(providerInfo: String, data: String) { + handleInvalidResult(providerInfo, data) + } +} diff --git a/libraries/pushproviders/api/build.gradle.kts b/libraries/pushproviders/api/build.gradle.kts new file mode 100644 index 0000000..3581a43 --- /dev/null +++ b/libraries/pushproviders/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-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/Config.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/Config.kt new file mode 100644 index 0000000..fec7906 --- /dev/null +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/Config.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.api + +data class Config( + val url: String, + val pushKey: String, +) 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 0000000..e7082f8 --- /dev/null +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/Distributor.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.api + +/** + * Firebase does not have the concept of distributor. So for Firebase, there will be one distributor: + * Distributor("Firebase", "Firebase"). + * + * For UnifiedPush, for instance, the Distributor can be: + * Distributor("io.heckel.ntfy", "ntfy"). + * But other values are possible. + */ +data class Distributor( + val value: String, + val name: String, +) { + val fullName = "$name ($value)" +} 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 0000000..b8fb537 --- /dev/null +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushData.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..d310511 --- /dev/null +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushHandler.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.api + +interface PushHandler { + suspend fun handle( + pushData: PushData, + providerInfo: String, + ) + + suspend fun handleInvalid( + providerInfo: String, + data: String, + ) +} 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 0000000..405a83b --- /dev/null +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.api + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId + +/** + * 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 + + /** + * true if the Push provider supports multiple distributors. + */ + val supportMultipleDistributors: Boolean + + /** + * Return the list of available distributors. + */ + fun getDistributors(): List + + /** + * Register the pusher to the homeserver. + */ + suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result + + /** + * Return the current distributor, or null if none. + */ + suspend fun getCurrentDistributorValue(sessionId: SessionId): String? + + /** + * Return the current distributor, or null if none. + */ + suspend fun getCurrentDistributor(sessionId: SessionId): Distributor? + + /** + * Unregister the pusher. + */ + suspend fun unregister(matrixClient: MatrixClient): Result + + /** + * To invoke when the session is deleted. + */ + suspend fun onSessionDeleted(sessionId: SessionId) + + suspend fun getPushConfig(sessionId: SessionId): Config? + + fun canRotateToken(): Boolean + + suspend fun rotateToken(): Result { + error("rotateToken() not implemented, you need to override this method in your implementation") + } +} 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 0000000..989170c --- /dev/null +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PusherSubscriber.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.api + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.exception.ClientException + +interface PusherSubscriber { + /** + * Register a pusher. Note that failure will be a [RegistrationFailure]. + */ + suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result + + /** + * Unregister a pusher. + */ + suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result +} + +class RegistrationFailure( + val clientException: ClientException, + val isRegisteringAgain: Boolean +) : Exception(clientException) diff --git a/libraries/pushproviders/firebase/README.md b/libraries/pushproviders/firebase/README.md new file mode 100644 index 0000000..a2a7ad9 --- /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 set to `google_app_id` in the file `build.gradle.kts` of 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//values/values.xml` to import the generated values into the `build.gradle.kts` files. diff --git a/libraries/pushproviders/firebase/build.gradle.kts b/libraries/pushproviders/firebase/build.gradle.kts new file mode 100644 index 0000000..ee5bd94 --- /dev/null +++ b/libraries/pushproviders/firebase/build.gradle.kts @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:Suppress("UnstableApiUsage") + +import config.BuildTimeConfig +import extension.setupDependencyInjection +import extension.testCommonDependencies + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.pushproviders.firebase" + + buildTypes { + getByName("release") { + consumerProguardFiles("consumer-proguard-rules.pro") + resValue( + type = "string", + name = "google_app_id", + value = BuildTimeConfig.GOOGLE_APP_ID_RELEASE, + ) + } + getByName("debug") { + resValue( + type = "string", + name = "google_app_id", + value = BuildTimeConfig.GOOGLE_APP_ID_DEBUG, + ) + } + register("nightly") { + consumerProguardFiles("consumer-proguard-rules.pro") + matchingFallbacks += listOf("release") + resValue( + type = "string", + name = "google_app_id", + value = BuildTimeConfig.GOOGLE_APP_ID_NIGHTLY, + ) + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(libs.androidx.corektx) + implementation(projects.features.enterprise.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.troubleshoot.api) + implementation(projects.services.toolbox.api) + + implementation(projects.libraries.pushstore.api) + implementation(projects.libraries.pushproviders.api) + + api(platform(libs.google.firebase.bom)) + api("com.google.firebase:firebase-messaging") { + exclude(group = "com.google.firebase", module = "firebase-core") + exclude(group = "com.google.firebase", module = "firebase-analytics") + exclude(group = "com.google.firebase", module = "firebase-measurement-connector") + } + + testCommonDependencies(libs) + testImplementation(libs.kotlinx.collections.immutable) + testImplementation(projects.features.enterprise.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.push.test) + testImplementation(projects.libraries.pushstore.test) + testImplementation(projects.libraries.sessionStorage.test) + testImplementation(projects.libraries.troubleshoot.test) + testImplementation(projects.services.toolbox.test) +} diff --git a/libraries/pushproviders/firebase/consumer-proguard-rules.pro b/libraries/pushproviders/firebase/consumer-proguard-rules.pro new file mode 100644 index 0000000..0bc7b60 --- /dev/null +++ b/libraries/pushproviders/firebase/consumer-proguard-rules.pro @@ -0,0 +1,4 @@ +# Fix this error: +# ERROR: Missing classes detected while running R8. Please add the missing classes or apply additional keep rules that are generated in /Users/bmarty/workspaces/element-x-android/app/build/outputs/mapping/nightly/missing_rules.txt. +# ERROR: R8: Missing class com.google.firebase.analytics.connector.AnalyticsConnector (referenced from: void com.google.firebase.messaging.MessagingAnalytics.logToScion(java.lang.String, android.os.Bundle) and 1 other context) +-dontwarn com.google.firebase.analytics.connector.AnalyticsConnector diff --git a/libraries/pushproviders/firebase/src/main/AndroidManifest.xml b/libraries/pushproviders/firebase/src/main/AndroidManifest.xml new file mode 100644 index 0000000..24bad30 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + 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 0000000..10891d5 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseConfig.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/FirebaseGatewayProvider.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseGatewayProvider.kt new file mode 100644 index 0000000..1ae27d5 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseGatewayProvider.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.enterprise.api.EnterpriseService + +interface FirebaseGatewayProvider { + fun getFirebaseGateway(): String +} + +@ContributesBinding(AppScope::class) +class DefaultFirebaseGatewayProvider( + private val enterpriseService: EnterpriseService, +) : FirebaseGatewayProvider { + override fun getFirebaseGateway(): String { + return enterpriseService.firebasePushGateway() ?: FirebaseConfig.PUSHER_HTTP_URL + } +} 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 0000000..72977af --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseNewTokenHandler.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.flatMap +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.api.MatrixClientProvider +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 + +private val loggerTag = LoggerTag("FirebaseNewTokenHandler", LoggerTag.PushLoggerTag) + +/** + * Handle new token receive from Firebase. Will update all the sessions which are using Firebase as a push provider. + */ +interface FirebaseNewTokenHandler { + suspend fun handle(firebaseToken: String) +} + +@ContributesBinding(AppScope::class) +class DefaultFirebaseNewTokenHandler( + private val pusherSubscriber: PusherSubscriber, + private val sessionStore: SessionStore, + private val userPushStoreFactory: UserPushStoreFactory, + private val matrixClientProvider: MatrixClientProvider, + private val firebaseStore: FirebaseStore, + private val firebaseGatewayProvider: FirebaseGatewayProvider, +) : FirebaseNewTokenHandler { + override suspend fun handle(firebaseToken: String) { + firebaseStore.storeFcmToken(firebaseToken) + // Register the pusher for all the sessions + sessionStore.getAllSessions().toUserList() + .map { SessionId(it) } + .forEach { sessionId -> + val userDataStore = userPushStoreFactory.getOrCreate(sessionId) + if (userDataStore.getPushProviderName() == FirebaseConfig.NAME) { + matrixClientProvider + .getOrRestore(sessionId) + .onFailure { + Timber.tag(loggerTag.value).e(it, "Failed to restore session $sessionId") + } + .flatMap { client -> + pusherSubscriber + .registerPusher( + matrixClient = client, + pushKey = firebaseToken, + gateway = firebaseGatewayProvider.getFirebaseGateway(), + ) + .onFailure { + Timber.tag(loggerTag.value).e(it, "Failed to register pusher for session $sessionId") + } + } + } 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 0000000..a5b9bbc --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushParser.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.pushproviders.api.PushData + +@Inject +class FirebasePushParser { + fun parse(message: Map): 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 0000000..0aa3457 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushproviders.api.Config +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 + +private val loggerTag = LoggerTag("FirebasePushProvider", LoggerTag.PushLoggerTag) + +@ContributesIntoSet(AppScope::class) +@Inject +class FirebasePushProvider( + private val firebaseStore: FirebaseStore, + private val pusherSubscriber: PusherSubscriber, + private val isPlayServiceAvailable: IsPlayServiceAvailable, + private val firebaseTokenRotator: FirebaseTokenRotator, + private val firebaseGatewayProvider: FirebaseGatewayProvider, +) : PushProvider { + override val index = FirebaseConfig.INDEX + override val name = FirebaseConfig.NAME + override val supportMultipleDistributors = false + + override fun getDistributors(): List { + return listOfNotNull( + firebaseDistributor.takeIf { isPlayServiceAvailable.isAvailable() } + ) + } + + override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result { + val pushKey = firebaseStore.getFcmToken() ?: return Result.failure( + IllegalStateException( + "Unable to register pusher, Firebase token is not known." + ) + ).also { + Timber.tag(loggerTag.value).w("Unable to register pusher, Firebase token is not known.") + } + return pusherSubscriber.registerPusher( + matrixClient = matrixClient, + pushKey = pushKey, + gateway = firebaseGatewayProvider.getFirebaseGateway(), + ) + } + + override suspend fun getCurrentDistributorValue(sessionId: SessionId): String = firebaseDistributor.value + + override suspend fun getCurrentDistributor(sessionId: SessionId) = firebaseDistributor + + override suspend fun unregister(matrixClient: MatrixClient): Result { + val pushKey = firebaseStore.getFcmToken() + return if (pushKey == null) { + Timber.tag(loggerTag.value).w("Unable to unregister pusher, Firebase token is not known.") + Result.success(Unit) + } else { + pusherSubscriber.unregisterPusher(matrixClient, pushKey, firebaseGatewayProvider.getFirebaseGateway()) + } + } + + /** + * Nothing to clean up here. + */ + override suspend fun onSessionDeleted(sessionId: SessionId) = Unit + + override suspend fun getPushConfig(sessionId: SessionId): Config? { + return firebaseStore.getFcmToken()?.let { fcmToken -> + Config( + url = firebaseGatewayProvider.getFirebaseGateway(), + pushKey = fcmToken + ) + } + } + + override fun canRotateToken(): Boolean = true + + override suspend fun rotateToken(): Result { + return firebaseTokenRotator.rotate() + } + + companion object { + private val firebaseDistributor = Distributor("Firebase", "Firebase") + } +} 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 0000000..2d3f9dc --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseStore.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase + +import android.content.SharedPreferences +import androidx.core.content.edit +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart + +/** + * This class store the Firebase token in SharedPrefs. + */ +interface FirebaseStore { + fun getFcmToken(): String? + fun fcmTokenFlow(): Flow + fun storeFcmToken(token: String?) +} + +@ContributesBinding(AppScope::class) +class SharedPreferencesFirebaseStore( + private val sharedPreferences: SharedPreferences, +) : FirebaseStore { + override fun getFcmToken(): String? { + return sharedPreferences.getString(PREFS_KEY_FCM_TOKEN, null) + } + + override fun fcmTokenFlow(): Flow { + val flow = MutableStateFlow(getFcmToken()) + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, k -> + if (k == PREFS_KEY_FCM_TOKEN) { + try { + flow.value = getFcmToken() + } catch (e: Exception) { + flow.value = null + } + } + } + return flow + .onStart { sharedPreferences.registerOnSharedPreferenceChangeListener(listener) } + .onCompletion { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) } + } + + override fun storeFcmToken(token: String?) { + sharedPreferences.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/FirebaseTokenDeleter.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTokenDeleter.kt new file mode 100644 index 0000000..21fa189 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTokenDeleter.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase + +import com.google.firebase.messaging.FirebaseMessaging +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import timber.log.Timber +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +interface FirebaseTokenDeleter { + /** + * Deletes the current Firebase token. + */ + suspend fun delete() +} + +@ContributesBinding(AppScope::class) +class DefaultFirebaseTokenDeleter( + private val isPlayServiceAvailable: IsPlayServiceAvailable, +) : FirebaseTokenDeleter { + override suspend fun delete() { + // 'app should always check the device for a compatible Google Play services APK before accessing Google Play services features' + isPlayServiceAvailable.checkAvailableOrThrow() + suspendCoroutine { continuation -> + try { + FirebaseMessaging.getInstance().deleteToken() + .addOnSuccessListener { + continuation.resume(Unit) + } + .addOnFailureListener { e -> + Timber.e(e, "## deleteFirebaseToken() : failed") + continuation.resumeWithException(e) + } + } catch (e: Throwable) { + Timber.e(e, "## deleteFirebaseToken() : failed") + continuation.resumeWithException(e) + } + } + } +} diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTokenGetter.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTokenGetter.kt new file mode 100644 index 0000000..80fd972 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTokenGetter.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase + +import com.google.firebase.messaging.FirebaseMessaging +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import timber.log.Timber +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +interface FirebaseTokenGetter { + /** + * Read the current Firebase token from FirebaseMessaging. + * If the token does not exist, it will be generated. + */ + suspend fun get(): String +} + +@ContributesBinding(AppScope::class) +class DefaultFirebaseTokenGetter( + private val isPlayServiceAvailable: IsPlayServiceAvailable, +) : FirebaseTokenGetter { + override suspend fun get(): String { + // 'app should always check the device for a compatible Google Play services APK before accessing Google Play services features' + isPlayServiceAvailable.checkAvailableOrThrow() + return suspendCoroutine { continuation -> + 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) + } + } + } +} diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTokenRotator.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTokenRotator.kt new file mode 100644 index 0000000..78b33cb --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTokenRotator.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.runCatchingExceptions + +interface FirebaseTokenRotator { + suspend fun rotate(): Result +} + +/** + * This class delete the Firebase token and generate a new one. + */ +@ContributesBinding(AppScope::class) +class DefaultFirebaseTokenRotator( + private val firebaseTokenDeleter: FirebaseTokenDeleter, + private val firebaseTokenGetter: FirebaseTokenGetter, +) : FirebaseTokenRotator { + override suspend fun rotate(): Result { + return runCatchingExceptions { + firebaseTokenDeleter.delete() + firebaseTokenGetter.get() + } + } +} 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 0000000..78fce03 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseTroubleshooter.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.runCatchingExceptions + +interface FirebaseTroubleshooter { + suspend fun troubleshoot(): Result +} + +/** + * This class force retrieving and storage of the Firebase token. + */ +@ContributesBinding(AppScope::class) +class DefaultFirebaseTroubleshooter( + private val newTokenHandler: FirebaseNewTokenHandler, + private val firebaseTokenGetter: FirebaseTokenGetter, +) : FirebaseTroubleshooter { + override suspend fun troubleshoot(): Result { + return runCatchingExceptions { + val token = firebaseTokenGetter.get() + newTokenHandler.handle(token) + } + } +} diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/IsPlayServiceAvailable.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/IsPlayServiceAvailable.kt new file mode 100644 index 0000000..828b7bb --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/IsPlayServiceAvailable.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase + +import android.content.Context +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailabilityLight +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.annotations.ApplicationContext +import timber.log.Timber + +interface IsPlayServiceAvailable { + fun isAvailable(): Boolean +} + +fun IsPlayServiceAvailable.checkAvailableOrThrow() { + if (!isAvailable()) { + throw Exception("No valid Google Play Services found. Cannot use FCM.").also(Timber::e) + } +} + +@ContributesBinding(AppScope::class) +class DefaultIsPlayServiceAvailable( + @ApplicationContext private val context: Context, +) : IsPlayServiceAvailable { + override fun isAvailable(): Boolean { + val apiAvailability = GoogleApiAvailabilityLight.getInstance() + val resultCode = apiAvailability.isGooglePlayServicesAvailable(context) + return if (resultCode == ConnectionResult.SUCCESS) { + Timber.d("Google Play Services is available") + true + } else { + Timber.w("Google Play Services is not available") + false + } + } +} 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 0000000..04bdb12 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/PushDataFirebase.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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: + *
    + * {
    + *     "event_id":"$anEventId",
    + *     "room_id":"!aRoomId",
    + *     "unread":"1",
    + *     "prio":"high",
    + *     "cs":""
    + * }
    + * 
    + * . + */ +data class PushDataFirebase( + val eventId: String?, + val roomId: String?, + val unread: Int?, + val clientSecret: String? +) + +fun PushDataFirebase.toPushData(): PushData? { + val safeEventId = eventId?.let(::EventId) ?: return null + val safeRoomId = roomId?.let(::RoomId) ?: return null + val safeClientSecret = clientSecret ?: return null + return PushData( + eventId = safeEventId, + roomId = safeRoomId, + unread = unread, + clientSecret = safeClientSecret, + ) +} 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 0000000..532ee8a --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase + +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.pushproviders.api.PushHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +private val loggerTag = LoggerTag("VectorFirebaseMessagingService", LoggerTag.PushLoggerTag) + +class VectorFirebaseMessagingService : FirebaseMessagingService() { + @Inject lateinit var firebaseNewTokenHandler: FirebaseNewTokenHandler + @Inject lateinit var pushParser: FirebasePushParser + @Inject lateinit var pushHandler: PushHandler + @AppCoroutineScope + @Inject lateinit var coroutineScope: CoroutineScope + + override fun onCreate() { + super.onCreate() + bindings().inject(this) + } + + override fun onNewToken(token: String) { + Timber.tag(loggerTag.value).w("New Firebase token") + coroutineScope.launch { + firebaseNewTokenHandler.handle(token) + } + } + + override fun onMessageReceived(message: RemoteMessage) { + Timber.tag(loggerTag.value).w("New Firebase message. Priority: ${message.priority}/${message.originalPriority}") + coroutineScope.launch { + val pushData = pushParser.parse(message.data) + if (pushData == null) { + Timber.tag(loggerTag.value).w("Invalid data received from Firebase") + pushHandler.handleInvalid( + providerInfo = FirebaseConfig.NAME, + data = message.data.keys.joinToString("\n") { + "$it: ${message.data[it]}" + }, + ) + } else { + pushHandler.handle( + pushData = pushData, + providerInfo = FirebaseConfig.NAME, + ) + } + } + } +} 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 0000000..6de938c --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceBindings.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesTo + +@ContributesTo(AppScope::class) +interface VectorFirebaseMessagingServiceBindings { + fun inject(service: VectorFirebaseMessagingService) +} diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTest.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTest.kt new file mode 100644 index 0000000..69ffd50 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase.troubleshoot + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.libraries.pushproviders.firebase.FirebaseConfig +import io.element.android.libraries.pushproviders.firebase.IsPlayServiceAvailable +import io.element.android.libraries.pushproviders.firebase.R +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestDelegate +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.api.test.TestFilterData +import io.element.android.services.toolbox.api.strings.StringProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +@ContributesIntoSet(AppScope::class) +@Inject +class FirebaseAvailabilityTest( + private val isPlayServiceAvailable: IsPlayServiceAvailable, + private val stringProvider: StringProvider, +) : NotificationTroubleshootTest { + override val order = 300 + private val delegate = NotificationTroubleshootTestDelegate( + defaultName = stringProvider.getString(R.string.troubleshoot_notifications_test_firebase_availability_title), + defaultDescription = stringProvider.getString(R.string.troubleshoot_notifications_test_firebase_availability_description), + visibleWhenIdle = false, + fakeDelay = NotificationTroubleshootTestDelegate.LONG_DELAY, + ) + override val state: StateFlow = delegate.state + + override fun isRelevant(data: TestFilterData): Boolean { + return data.currentPushProviderName == FirebaseConfig.NAME + } + + override suspend fun run(coroutineScope: CoroutineScope) { + delegate.start() + val result = isPlayServiceAvailable.isAvailable() + if (result) { + delegate.updateState( + description = stringProvider.getString(R.string.troubleshoot_notifications_test_firebase_availability_success), + status = NotificationTroubleshootTestState.Status.Success + ) + } else { + delegate.updateState( + description = stringProvider.getString(R.string.troubleshoot_notifications_test_firebase_availability_failure), + status = NotificationTroubleshootTestState.Status.Failure() + ) + } + } + + override suspend fun reset() = delegate.reset() +} diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTest.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTest.kt new file mode 100644 index 0000000..8bcd449 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase.troubleshoot + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.libraries.pushproviders.firebase.FirebaseConfig +import io.element.android.libraries.pushproviders.firebase.FirebaseStore +import io.element.android.libraries.pushproviders.firebase.FirebaseTroubleshooter +import io.element.android.libraries.pushproviders.firebase.R +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootNavigator +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestDelegate +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.api.test.TestFilterData +import io.element.android.services.toolbox.api.strings.StringProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@ContributesIntoSet(AppScope::class) +@Inject +class FirebaseTokenTest( + private val firebaseStore: FirebaseStore, + private val firebaseTroubleshooter: FirebaseTroubleshooter, + private val stringProvider: StringProvider, +) : NotificationTroubleshootTest { + override val order = 310 + private val delegate = NotificationTroubleshootTestDelegate( + defaultName = stringProvider.getString(R.string.troubleshoot_notifications_test_firebase_token_title), + defaultDescription = stringProvider.getString(R.string.troubleshoot_notifications_test_firebase_token_description), + visibleWhenIdle = false, + fakeDelay = NotificationTroubleshootTestDelegate.LONG_DELAY, + ) + override val state: StateFlow = delegate.state + + override fun isRelevant(data: TestFilterData): Boolean { + return data.currentPushProviderName == FirebaseConfig.NAME + } + + private var currentJob: Job? = null + override suspend fun run(coroutineScope: CoroutineScope) { + currentJob?.cancel() + delegate.start() + currentJob = firebaseStore.fcmTokenFlow() + .onEach { token -> + if (token != null) { + delegate.updateState( + description = stringProvider.getString( + R.string.troubleshoot_notifications_test_firebase_token_success, + "*****${token.takeLast(8)}" + ), + status = NotificationTroubleshootTestState.Status.Success + ) + } else { + delegate.updateState( + description = stringProvider.getString(R.string.troubleshoot_notifications_test_firebase_token_failure), + status = NotificationTroubleshootTestState.Status.Failure(hasQuickFix = true) + ) + } + } + .launchIn(coroutineScope) + } + + override suspend fun reset() = delegate.reset() + + override suspend fun quickFix( + coroutineScope: CoroutineScope, + navigator: NotificationTroubleshootNavigator, + ) { + delegate.start() + firebaseTroubleshooter.troubleshoot() + run(coroutineScope) + } +} diff --git a/libraries/pushproviders/firebase/src/main/res/values-be/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..cd9082e --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-be/translations.xml @@ -0,0 +1,11 @@ + + + "Пераканайцеся, што Firebase даступны." + "Firebase недаступны." + "Firebase даступны." + "Праверыць Firebase" + "Пераканайцеся, што маркер Firebase даступны." + "Маркер Firebase невядомы." + "Маркер Firebase: %1$s." + "Праверце маркер Firebase" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-bg/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..7efaff4 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-bg/translations.xml @@ -0,0 +1,7 @@ + + + "Уверете се, че Firebase е наличен." + "Firebase не е наличен." + "Firebase е наличен." + "Проверка на Firebase" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-cs/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..e0b7eff --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-cs/translations.xml @@ -0,0 +1,11 @@ + + + "Ujistěte se, že je k dispozici Firebase." + "Firebase není k dispozici." + "Firebase je k dispozici." + "Zkontrolovat Firebase" + "Ujistěte se, že je k dispozici Firebase token." + "Firebase token není znám." + "Firebase token: %1$s." + "Zkontrolovat Firebase token" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-cy/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..337a477 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-cy/translations.xml @@ -0,0 +1,11 @@ + + + "Gwnewch yn siwr fod Firebase ar gael." + "Nid yw Firebase ar gael." + "Mae Firebase ar gael." + "Gwiriwch Firebase" + "Gwnewch yn siŵr fod tocyn Firebase ar gael." + "Nid yw tocyn Firebase yn hysbys." + "Tocyn Firebase: %1$s." + "Gwiriwch tocyn Firebase" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-da/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..ea9afeb --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-da/translations.xml @@ -0,0 +1,11 @@ + + + "Sørg for, at Firebase er tilgængelig." + "Firebase er ikke tilgængelig." + "Firebase er tilgængelig." + "Tjek Firebase" + "Sørg for, at Firebase-tokenet er tilgængeligt." + "Firebase-tokenet er ikke kendt." + "Firebase-token: %1$s." + "Tjek Firebase-token" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-de/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..5df74b7 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-de/translations.xml @@ -0,0 +1,11 @@ + + + "Stelle sicher, dass Firebase verfügbar ist." + "Firebase ist nicht verfügbar." + "Firebase ist verfügbar." + "Überprüfe Firebase" + "Stelle sicher, dass der Firebase Token verfügbar ist." + "Firebase Token ist nicht bekannt." + "Firebase Token: %1$s." + "Prüfe Firebase Token" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-el/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..9503880 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-el/translations.xml @@ -0,0 +1,11 @@ + + + "Βεβαιώσου ότι το Firebase είναι διαθέσιμο." + "Το Firebase δεν είναι διαθέσιμο." + "Το Firebase είναι διαθέσιμο." + "Έλεγχος Firebase" + "Βεβαιώσου ότι το διακριτικό του Firebase είναι διαθέσιμο." + "Το διακριτικό Firebase δεν είναι γνωστό." + "Διακριτικό Firebase: %1$s." + "Έλεγξε το διακριτικό του Firebase" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-es/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..d6a3de2 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-es/translations.xml @@ -0,0 +1,11 @@ + + + "Asegurarse de que Firebase esté disponible." + "Firebase no está disponible." + "Firebase está disponible." + "Verificar Firebase" + "Asegurarse de que el token de Firebase esté disponible." + "Se desconoce el token de Firebase." + "Token de Firebase: %1$s." + "Verificar token de Firebase" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-et/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..1717018 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-et/translations.xml @@ -0,0 +1,11 @@ + + + "Palun veendu, et Firebase oleks saadaval." + "Firebase pole saadaval." + "Firebase on saadaval." + "Kontrolli Firebase\'i" + "Palun veendu, et Firebase\'i pääsuluba oleks saadaval." + "Firebase\'i pääsuluba pole teada." + "Firebase\'i pääsuluba: %1$s." + "Kontrolli Firebase\'i pääsuluba" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-eu/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..e67e55e --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-eu/translations.xml @@ -0,0 +1,11 @@ + + + "Ziurtatu Firebase erabilgarri dagoela." + "Firebase ez dago erabilgarri." + "Firebase eskuragarri dago." + "Egiaztatu Firebase" + "Ziurtatu Firebaseren tokena erabilgarri dagoela." + "Ez da Firebaseren tokena ezagutzen." + "Firebaseren tokena: %1$s." + "Egiaztatu Firebaseren tokena" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-fi/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..6ba1964 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-fi/translations.xml @@ -0,0 +1,11 @@ + + + "Varmistaa, että Firebase on käytettävissä." + "Firebase ei ole saatavilla." + "Firebase on saatavilla." + "Firebasen tarkistus" + "Varmistaa, että Firebase token on käytettävissä." + "Firebase token ei ole tiedossa." + "Firebase token: %1$s." + "Firebase tokenin tarkistus" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-fr/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..7525602 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-fr/translations.xml @@ -0,0 +1,11 @@ + + + "Vérification que Firebase est disponible." + "Firebase n’est pas disponible." + "Firebase est disponible." + "Vérification de Firebase" + "Vérifier que le jeton Firebase est disponible." + "Le jeton Firebase n’est pas connu." + "Jeton Firebase :%1$s." + "Vérifier le jeton Firebase" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-hu/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..10fa194 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-hu/translations.xml @@ -0,0 +1,11 @@ + + + "Győződjön meg arról, hogy a Firebase elérhető-e." + "A Firebase nem érhető el." + "A Firebase elérhető." + "Ellenőrizze a Firebase-t" + "Győződjön meg arról, hogy a Firebase-token elérhető." + "A Firebase-token nem ismert." + "Firebase-token: %1$s." + "Ellenőrizze a Firebase-tokent" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-in/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..0c1a98a --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-in/translations.xml @@ -0,0 +1,11 @@ + + + "Pastikan bahwa Firebase tersedia." + "Firebase tidak tersedia." + "Firebase tersedia." + "Periksa Firebase" + "Pastikan token Firebase tersedia." + "Token Firebase tidak diketahui." + "Token Firebase: %1$s." + "Periksa token Firebase" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-it/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..1238fc4 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-it/translations.xml @@ -0,0 +1,11 @@ + + + "Assicurati che Firebase sia disponibile." + "Firebase non è disponibile." + "Firebase è disponibile." + "Controlla Firebase" + "Assicurati che il token di Firebase sia disponibile." + "Il token di Firebase non è noto." + "Token Firebase: %1$s." + "Verifica il token di Firebase" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-ka/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..eb3645b --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-ka/translations.xml @@ -0,0 +1,11 @@ + + + "დარწმუნდით რომ Firebase ხელმისაწვდომია." + "Firebase არაა ხელმისაწვდომი." + "Firebase ხელმისაწვდომია." + "Firebase-ის შემოწმება" + "დარწმუნდით რომ Firebase ტოკენი ხელმისაწვდომია." + "Firebase ტოკენი უცნობია." + "Firebase ტოკენი: %1$s." + "შეამოწმეთ Firebase ტოკენი" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-ko/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..3521f4f --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-ko/translations.xml @@ -0,0 +1,11 @@ + + + "Firebase를 사용할 수 있는지 확인하세요." + "Firebase는 사용할 수 없습니다." + "Firebase를 사용할 수 있습니다." + "Firebase 확인" + "Firebase 토큰을 사용할 수 있는지 확인하세요." + "Firebase 토큰이 인식되지 않았습니다." + "Firebase 토큰: %1$s." + "Firebase 토큰 확인" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-nb/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..9bbff0e --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-nb/translations.xml @@ -0,0 +1,11 @@ + + + "Sørg for at Firebase er tilgjengelig." + "Firebase er ikke tilgjengelig." + "Firebase er tilgjengelig." + "Sjekk Firebase" + "Sørg for at Firebase-token er tilgjengelig." + "Firebase-token er ikke kjent." + "Firebase-token: %1$s." + "Sjekk Firebase-token" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-nl/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..d847c39 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-nl/translations.xml @@ -0,0 +1,11 @@ + + + "Zorg ervoor dat Firebase beschikbaar is." + "Firebase is niet beschikbaar." + "Firebase is beschikbaar." + "Firebase controleren" + "Zorg ervoor dat de Firebase-token beschikbaar is." + "Firebase-token is niet bekend." + "Firebase-token: %1$s." + "Firebase-token controleren" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-pl/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..7a3e164 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-pl/translations.xml @@ -0,0 +1,11 @@ + + + "Upewnij się, że Firebase jest dostępny." + "Baza Firebase jest niedostępna." + "Baza Firebase jest dostępna." + "Sprawdź Firebase" + "Upewnij się, że token Firebase jest dostępny." + "Token Firebase nie jest znany." + "Token Firebase: %1$s." + "Sprawdź token Firebase" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-pt-rBR/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..51b5091 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,11 @@ + + + "Certifique-se de que o Firebase esteja disponível." + "O Firebase não está disponível." + "O Firebase está disponível." + "Verificar o Firebase" + "Certifique-se de que o token do Firebase esteja disponível." + "O token do Firebase não é conhecido." + "Token do Firebase: %1$s." + "Verificar o token do Firebase" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-pt/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..2c9344d --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-pt/translations.xml @@ -0,0 +1,11 @@ + + + "Certifica que a Firebase está disponível" + "Firebase indisponível." + "Firebase disponível." + "Verificar a Firebase" + "Certifica que o \"token\" da Firebase está disponível." + "\"Token\" da Firebase desconhecido." + "\"Token\" da Firebase: %1$s." + "Verificar \"token\" da Firebase" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-ro/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..72e1390 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-ro/translations.xml @@ -0,0 +1,11 @@ + + + "Asigurați-vă că Firebase este disponibil." + "Firebase nu este disponibil." + "Firebase este disponibil." + "Verificați Firebase" + "Asigurați-vă că tokenul Firebase este disponibil." + "Tokenul Firebase nu este cunoscut." + "Token Firebase: %1$s." + "Verificați token-ul Firebase" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-ru/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..4167dd0 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-ru/translations.xml @@ -0,0 +1,11 @@ + + + "Убедитесь, что Firebase доступен." + "Firebase недоступен." + "Firebase доступен." + "Проверить Firebase" + "Убедитесь, что токен Firebase доступен." + "Токен Firebase неизвестен." + "Токен Firebase: %1$s." + "Проверить токен Firebase" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-sk/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..312f751 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-sk/translations.xml @@ -0,0 +1,11 @@ + + + "Uistite sa, že Firebase je k dispozícii." + "Firebase nie je k dispozícii." + "Firebase je k dispozícii." + "Skontrolovať Firebase" + "Uistite sa, že je k dispozícii token Firebase." + "Token Firebase nie je známy." + "Token Firebase: %1$s." + "Skontrolovať token Firebase" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-sv/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..3e338ac --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-sv/translations.xml @@ -0,0 +1,11 @@ + + + "Se till att Firebase är tillgängligt." + "Firebase är inte tillgängligt." + "Firebase är tillgänglig." + "Kontrollera Firebase" + "Se till att Firebase-token är tillgänglig." + "Firebase-token är inte känd." + "Firebase-token: %1$s." + "Kontrollera Firebase-token" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-tr/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..af9b7c2 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-tr/translations.xml @@ -0,0 +1,11 @@ + + + "Firebase\'in kullanılabilir olduğundan emin olun." + "Firebase kullanılamıyor." + "Firebase kullanılabilir." + "Firebase\'i kontrol et" + "Firebase belirtecinin mevcut olduğundan emin olun." + "Firebase belirteci bilinmiyor." + "Firebase belirteci: %1$s." + "Firebase jetonunu kontrol edin" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-uk/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..8024070 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-uk/translations.xml @@ -0,0 +1,11 @@ + + + "Переконується, що Firebase доступний." + "Firebase недоступний." + "Firebase доступний." + "Перевірка Firebase" + "Переконується, що токен Firebase доступний." + "Токен Firebase невідомий." + "Токен Firebase: %1$s." + "Перевірка токена Firebase" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-ur/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..22605af --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-ur/translations.xml @@ -0,0 +1,11 @@ + + + "یقینی بنائیں کہ Firebase دستیاب ہے۔" + "Firebase دستیاب نہیں ہے۔" + "Firebase دستیاب ہے۔" + "Firebase کی پڑتال کریں" + "یقینی بنائیں کہ Firebase رمزِ ممیز دستیاب ہے۔" + "Firebase رمزِ ممیز معلوم نہیں ہے۔" + "Firebase رمزِ ممیز:%1$s۔" + "Firebase رمزِ ممیز کی پڑتال کریں" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-uz/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..0a2a1cf --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-uz/translations.xml @@ -0,0 +1,11 @@ + + + "Firebase mavjudligiga ishonch hosil qiling." + "Firebase mavjud emas." + "Firebase mavjud." + "Firebase-ni tekshiring" + "Firebase tokeni mavjudligiga ishonch hosil qiling." + "Firebase mavjudligiga ishonch hosil qiling." + "Firebase tokeni: %1$s ." + "Firebase tokenini tekshiring" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-zh-rTW/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..7329682 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,11 @@ + + + "確保 Firebase 可用。" + "Firebase 不可用。" + "Firebase 可用。" + "檢查 Firebase" + "確保 Firebase 權杖可用。" + "Firebase 權杖未知。" + "Firebase 權杖:%1$s。" + "檢查 Firebase 權杖" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-zh/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..c3b2098 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-zh/translations.xml @@ -0,0 +1,11 @@ + + + "确保 Firebase 可用。" + "Firebase 不可用。" + "Firebase 可用。" + "检查 Firebase" + "确保 Firebase 令牌可用。" + "Firebase 令牌未知。" + "Firebase 令牌:%1$s 。" + "检查 Firebase 令牌" + 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 0000000..b73238c --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values/firebase.xml @@ -0,0 +1,10 @@ + + + 912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com + https://vector-alpha.firebaseio.com + 912726360885 + AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c + AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c + vector-alpha.appspot.com + vector-alpha + diff --git a/libraries/pushproviders/firebase/src/main/res/values/localazy.xml b/libraries/pushproviders/firebase/src/main/res/values/localazy.xml new file mode 100644 index 0000000..654ba04 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values/localazy.xml @@ -0,0 +1,11 @@ + + + "Ensure that Firebase is available." + "Firebase is not available." + "Firebase is available." + "Check Firebase" + "Ensure that Firebase token is available." + "Firebase token is not known." + "Firebase token: %1$s." + "Check Firebase token" + diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/DefaultFirebaseNewTokenHandlerTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/DefaultFirebaseNewTokenHandlerTest.kt new file mode 100644 index 0000000..4db9ea6 --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/DefaultFirebaseNewTokenHandlerTest.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.test.AN_EXCEPTION +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_ID_3 +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.push.test.FakePusherSubscriber +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultFirebaseNewTokenHandlerTest { + @Test + fun `when a new token is received it is stored in the firebase store`() = runTest { + val firebaseStore = InMemoryFirebaseStore() + assertThat(firebaseStore.getFcmToken()).isNull() + val firebaseNewTokenHandler = createDefaultFirebaseNewTokenHandler( + firebaseStore = firebaseStore, + ) + firebaseNewTokenHandler.handle("aToken") + assertThat(firebaseStore.getFcmToken()).isEqualTo("aToken") + } + + @Test + fun `when a new token is received, the handler registers the pusher again to all sessions using Firebase`() = runTest { + val aMatrixClient1 = FakeMatrixClient(A_USER_ID) + val aMatrixClient2 = FakeMatrixClient(A_USER_ID_2) + val aMatrixClient3 = FakeMatrixClient(A_USER_ID_3) + val registerPusherResult = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val pusherSubscriber = FakePusherSubscriber(registerPusherResult = registerPusherResult) + val firebaseNewTokenHandler = createDefaultFirebaseNewTokenHandler( + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(A_USER_ID.value), + aSessionData(A_USER_ID_2.value), + aSessionData(A_USER_ID_3.value), + ) + ), + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + when (sessionId) { + A_USER_ID -> Result.success(aMatrixClient1) + A_USER_ID_2 -> Result.success(aMatrixClient2) + A_USER_ID_3 -> Result.success(aMatrixClient3) + else -> Result.failure(IllegalStateException()) + } + }, + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { sessionId -> + when (sessionId) { + A_USER_ID -> FakeUserPushStore(pushProviderName = FirebaseConfig.NAME) + A_USER_ID_2 -> FakeUserPushStore(pushProviderName = "Other") + A_USER_ID_3 -> FakeUserPushStore(pushProviderName = FirebaseConfig.NAME) + else -> error("Unexpected sessionId: $sessionId") + } + } + ), + pusherSubscriber = pusherSubscriber, + ) + firebaseNewTokenHandler.handle("aToken") + registerPusherResult.assertions() + .isCalledExactly(2) + .withSequence( + listOf(value(aMatrixClient1), value("aToken"), value(A_FIREBASE_GATEWAY)), + listOf(value(aMatrixClient3), value("aToken"), value(A_FIREBASE_GATEWAY)), + ) + } + + @Test + fun `when a new token is received, if the session cannot be restore, nothing happen`() = runTest { + val registerPusherResult = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val pusherSubscriber = FakePusherSubscriber(registerPusherResult = registerPusherResult) + val firebaseNewTokenHandler = createDefaultFirebaseNewTokenHandler( + sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData(A_USER_ID.value)) + ), + matrixClientProvider = FakeMatrixClientProvider { + Result.failure(IllegalStateException()) + }, + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { _ -> + FakeUserPushStore(pushProviderName = FirebaseConfig.NAME) + } + ), + pusherSubscriber = pusherSubscriber, + ) + firebaseNewTokenHandler.handle("aToken") + registerPusherResult.assertions() + .isNeverCalled() + } + + @Test + fun `when a new token is received, error when registering the pusher is ignored`() = runTest { + val aMatrixClient1 = FakeMatrixClient(A_USER_ID) + val registerPusherResult = lambdaRecorder> { _, _, _ -> Result.failure(AN_EXCEPTION) } + val pusherSubscriber = FakePusherSubscriber(registerPusherResult = registerPusherResult) + val firebaseNewTokenHandler = createDefaultFirebaseNewTokenHandler( + sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData(A_USER_ID.value)) + ), + matrixClientProvider = FakeMatrixClientProvider { + Result.success(aMatrixClient1) + }, + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { _ -> + FakeUserPushStore(pushProviderName = FirebaseConfig.NAME) + } + ), + pusherSubscriber = pusherSubscriber, + ) + firebaseNewTokenHandler.handle("aToken") + registerPusherResult.assertions() + registerPusherResult.assertions() + .isCalledOnce() + .with(value(aMatrixClient1), value("aToken"), value(A_FIREBASE_GATEWAY)) + } + + private fun createDefaultFirebaseNewTokenHandler( + pusherSubscriber: PusherSubscriber = FakePusherSubscriber(), + sessionStore: SessionStore = InMemorySessionStore(), + userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(), + matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(), + firebaseStore: FirebaseStore = InMemoryFirebaseStore(), + firebaseGatewayProvider: FirebaseGatewayProvider = FakeFirebaseGatewayProvider(), + ): FirebaseNewTokenHandler { + return DefaultFirebaseNewTokenHandler( + pusherSubscriber = pusherSubscriber, + sessionStore = sessionStore, + userPushStoreFactory = userPushStoreFactory, + matrixClientProvider = matrixClientProvider, + firebaseStore = firebaseStore, + firebaseGatewayProvider = firebaseGatewayProvider, + ) + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseGatewayProvider.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseGatewayProvider.kt new file mode 100644 index 0000000..cdd50e9 --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseGatewayProvider.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase + +const val A_FIREBASE_GATEWAY = "aGateway" + +class FakeFirebaseGatewayProvider( + private val firebaseGatewayResult: () -> String = { A_FIREBASE_GATEWAY } +) : FirebaseGatewayProvider { + override fun getFirebaseGateway() = firebaseGatewayResult() +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseNewTokenHandler.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseNewTokenHandler.kt new file mode 100644 index 0000000..2fa9508 --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseNewTokenHandler.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeFirebaseNewTokenHandler( + private val handleResult: (String) -> Unit = { lambdaError() } +) : FirebaseNewTokenHandler { + override suspend fun handle(firebaseToken: String) { + handleResult(firebaseToken) + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseTokenRotator.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseTokenRotator.kt new file mode 100644 index 0000000..d504aa3 --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseTokenRotator.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeFirebaseTokenRotator( + private val rotateWithResult: () -> Result = { lambdaError() } +) : FirebaseTokenRotator { + override suspend fun rotate(): Result { + return rotateWithResult() + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseTroubleshooter.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseTroubleshooter.kt new file mode 100644 index 0000000..6563c7d --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseTroubleshooter.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase + +import io.element.android.tests.testutils.simulateLongTask + +class FakeFirebaseTroubleshooter( + private val troubleShootResult: () -> Result = { Result.success(Unit) } +) : FirebaseTroubleshooter { + override suspend fun troubleshoot(): Result = simulateLongTask { + troubleShootResult() + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeIsPlayServiceAvailable.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeIsPlayServiceAvailable.kt new file mode 100644 index 0000000..226e57a --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeIsPlayServiceAvailable.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase + +class FakeIsPlayServiceAvailable( + private val isAvailable: Boolean, +) : IsPlayServiceAvailable { + override fun isAvailable() = isAvailable +} 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 0000000..d4a8ef6 --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushParserTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 empty client secret`() { + val pushParser = FirebasePushParser() + assertThat(pushParser.parse(FIREBASE_PUSH_DATA.mutate("cs", null))).isNull() + } + + @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.mutate(key: String, value: String?): Map { + return toMutableMap().apply { put(key, value) } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProviderTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProviderTest.kt new file mode 100644 index 0000000..312689d --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProviderTest.kt @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.push.test.FakePusherSubscriber +import io.element.android.libraries.pushproviders.api.Config +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class FirebasePushProviderTest { + @Test + fun `test index and name`() { + val firebasePushProvider = createFirebasePushProvider() + assertThat(firebasePushProvider.name).isEqualTo(FirebaseConfig.NAME) + assertThat(firebasePushProvider.index).isEqualTo(FirebaseConfig.INDEX) + } + + @Test + fun `getDistributors return the unique distributor if available`() { + val firebasePushProvider = createFirebasePushProvider( + isPlayServiceAvailable = FakeIsPlayServiceAvailable(isAvailable = true) + ) + val result = firebasePushProvider.getDistributors() + assertThat(result).containsExactly(Distributor("Firebase", "Firebase")) + } + + @Test + fun `getDistributors return empty list if service is not available`() { + val firebasePushProvider = createFirebasePushProvider( + isPlayServiceAvailable = FakeIsPlayServiceAvailable(isAvailable = false) + ) + val result = firebasePushProvider.getDistributors() + assertThat(result).isEmpty() + } + + @Test + fun `getCurrentDistributor always returns the unique distributor`() = runTest { + val firebasePushProvider = createFirebasePushProvider() + val result = firebasePushProvider.getCurrentDistributor(A_SESSION_ID) + assertThat(result).isEqualTo(Distributor("Firebase", "Firebase")) + } + + @Test + fun `register ok`() = runTest { + val matrixClient = FakeMatrixClient() + val registerPusherResultLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = "aToken" + ), + pusherSubscriber = FakePusherSubscriber( + registerPusherResult = registerPusherResultLambda + ) + ) + val result = firebasePushProvider.registerWith(matrixClient, Distributor("value", "Name")) + assertThat(result).isEqualTo(Result.success(Unit)) + registerPusherResultLambda.assertions() + .isCalledOnce() + .with(value(matrixClient), value("aToken"), value(A_FIREBASE_GATEWAY)) + } + + @Test + fun `register ko no token`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = null + ), + pusherSubscriber = FakePusherSubscriber( + registerPusherResult = { _, _, _ -> Result.success(Unit) } + ) + ) + val result = firebasePushProvider.registerWith(FakeMatrixClient(), Distributor("value", "Name")) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `register ko error`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = "aToken" + ), + pusherSubscriber = FakePusherSubscriber( + registerPusherResult = { _, _, _ -> Result.failure(AN_EXCEPTION) } + ) + ) + val result = firebasePushProvider.registerWith(FakeMatrixClient(), Distributor("value", "Name")) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `unregister ok`() = runTest { + val matrixClient = FakeMatrixClient() + val unregisterPusherResultLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = "aToken" + ), + pusherSubscriber = FakePusherSubscriber( + unregisterPusherResult = unregisterPusherResultLambda + ) + ) + val result = firebasePushProvider.unregister(matrixClient) + assertThat(result).isEqualTo(Result.success(Unit)) + unregisterPusherResultLambda.assertions() + .isCalledOnce() + .with(value(matrixClient), value("aToken"), value(A_FIREBASE_GATEWAY)) + } + + @Test + fun `unregister no token - in this case, the error is ignored`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = null + ), + ) + val result = firebasePushProvider.unregister(FakeMatrixClient()) + assertThat(result.isSuccess).isTrue() + } + + @Test + fun `unregister ko error`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = "aToken" + ), + pusherSubscriber = FakePusherSubscriber( + unregisterPusherResult = { _, _, _ -> Result.failure(AN_EXCEPTION) } + ) + ) + val result = firebasePushProvider.unregister(FakeMatrixClient()) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `getCurrentUserPushConfig no push ket`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = null + ) + ) + val result = firebasePushProvider.getPushConfig(A_SESSION_ID) + assertThat(result).isNull() + } + + @Test + fun `getCurrentUserPushConfig ok`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = "aToken" + ), + ) + val result = firebasePushProvider.getPushConfig(A_SESSION_ID) + assertThat(result).isEqualTo(Config(A_FIREBASE_GATEWAY, "aToken")) + } + + @Test + fun `rotateToken invokes the FirebaseTokenRotator`() = runTest { + val lambda = lambdaRecorder> { Result.success(Unit) } + val firebasePushProvider = createFirebasePushProvider( + firebaseTokenRotator = FakeFirebaseTokenRotator(lambda), + ) + firebasePushProvider.rotateToken() + lambda.assertions().isCalledOnce() + } + + @Test + fun `canRotateToken should return true`() = runTest { + val firebasePushProvider = createFirebasePushProvider() + assertThat(firebasePushProvider.canRotateToken()).isTrue() + } + + @Test + fun `onSessionDeleted should be noop`() = runTest { + val firebasePushProvider = createFirebasePushProvider() + firebasePushProvider.onSessionDeleted(A_SESSION_ID) + } + + private fun createFirebasePushProvider( + firebaseStore: FirebaseStore = InMemoryFirebaseStore(), + pusherSubscriber: PusherSubscriber = FakePusherSubscriber(), + isPlayServiceAvailable: IsPlayServiceAvailable = FakeIsPlayServiceAvailable(false), + firebaseTokenRotator: FirebaseTokenRotator = FakeFirebaseTokenRotator(), + firebaseGatewayProvider: FirebaseGatewayProvider = FakeFirebaseGatewayProvider() + ): FirebasePushProvider { + return FirebasePushProvider( + firebaseStore = firebaseStore, + pusherSubscriber = pusherSubscriber, + isPlayServiceAvailable = isPlayServiceAvailable, + firebaseTokenRotator = firebaseTokenRotator, + firebaseGatewayProvider = firebaseGatewayProvider, + ) + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/InMemoryFirebaseStore.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/InMemoryFirebaseStore.kt new file mode 100644 index 0000000..3e6292f --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/InMemoryFirebaseStore.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class InMemoryFirebaseStore( + private var token: String? = null +) : FirebaseStore { + override fun getFcmToken(): String? = token + + override fun fcmTokenFlow(): Flow = flowOf(token) + + override fun storeFcmToken(token: String?) { + this.token = token + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt new file mode 100644 index 0000000..1140d6f --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.pushproviders.firebase + +import android.os.Bundle +import com.google.firebase.messaging.RemoteMessage +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_SECRET +import io.element.android.libraries.push.test.test.FakePushHandler +import io.element.android.libraries.pushproviders.api.PushData +import io.element.android.libraries.pushproviders.api.PushHandler +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class VectorFirebaseMessagingServiceTest { + @Test + fun `test receiving invalid data`() = runTest { + val lambda = lambdaRecorder { _, _ -> } + val vectorFirebaseMessagingService = createVectorFirebaseMessagingService( + pushHandler = FakePushHandler(handleInvalidResult = lambda) + ) + vectorFirebaseMessagingService.onMessageReceived( + message = RemoteMessage( + Bundle().apply { + putString("a", "A") + putString("b", "B") + } + ) + ) + runCurrent() + lambda.assertions().isCalledOnce() + .with( + value(FirebaseConfig.NAME), + value("a: A\nb: B"), + ) + } + + @Test + fun `test receiving valid data`() = runTest { + val lambda = lambdaRecorder { _, _ -> } + val vectorFirebaseMessagingService = createVectorFirebaseMessagingService( + pushHandler = FakePushHandler(handleResult = lambda) + ) + vectorFirebaseMessagingService.onMessageReceived( + message = RemoteMessage( + Bundle().apply { + putString("event_id", AN_EVENT_ID.value) + putString("room_id", A_ROOM_ID.value) + putString("cs", A_SECRET) + }, + ) + ) + advanceUntilIdle() + lambda.assertions() + .isCalledOnce() + .with( + value(PushData(AN_EVENT_ID, A_ROOM_ID, null, A_SECRET)), + value(FirebaseConfig.NAME) + ) + } + + @Test + fun `test new token is forwarded to the handler`() = runTest { + val lambda = lambdaRecorder { } + val vectorFirebaseMessagingService = createVectorFirebaseMessagingService( + firebaseNewTokenHandler = FakeFirebaseNewTokenHandler(handleResult = lambda) + ) + vectorFirebaseMessagingService.onNewToken("aToken") + advanceUntilIdle() + lambda.assertions() + .isCalledOnce() + .with(value("aToken")) + } + + private fun TestScope.createVectorFirebaseMessagingService( + firebaseNewTokenHandler: FirebaseNewTokenHandler = FakeFirebaseNewTokenHandler(), + pushHandler: PushHandler = FakePushHandler(), + ): VectorFirebaseMessagingService { + return VectorFirebaseMessagingService().apply { + this.firebaseNewTokenHandler = firebaseNewTokenHandler + this.pushParser = FirebasePushParser() + this.pushHandler = pushHandler + this.coroutineScope = this@createVectorFirebaseMessagingService + } + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTestTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTestTest.kt new file mode 100644 index 0000000..22bc762 --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTestTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase.troubleshoot + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.pushproviders.firebase.FakeIsPlayServiceAvailable +import io.element.android.libraries.pushproviders.firebase.FirebaseConfig +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.api.test.TestFilterData +import io.element.android.libraries.troubleshoot.test.runAndTestState +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class FirebaseAvailabilityTestTest { + @Test + fun `test FirebaseAvailabilityTest success`() = runTest { + val sut = FirebaseAvailabilityTest( + isPlayServiceAvailable = FakeIsPlayServiceAvailable(true), + stringProvider = FakeStringProvider(), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + } + } + + @Test + fun `test FirebaseAvailabilityTest failure`() = runTest { + val sut = FirebaseAvailabilityTest( + isPlayServiceAvailable = FakeIsPlayServiceAvailable(false), + stringProvider = FakeStringProvider(), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure()) + sut.reset() + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + } + } + + @Test + fun `test FirebaseAvailabilityTest isRelevant`() { + val sut = FirebaseAvailabilityTest( + isPlayServiceAvailable = FakeIsPlayServiceAvailable(false), + stringProvider = FakeStringProvider(), + ) + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = "unknown"))).isFalse() + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = FirebaseConfig.NAME))).isTrue() + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt new file mode 100644 index 0000000..8f9721a --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.firebase.troubleshoot + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.pushproviders.firebase.FakeFirebaseTroubleshooter +import io.element.android.libraries.pushproviders.firebase.FirebaseConfig +import io.element.android.libraries.pushproviders.firebase.InMemoryFirebaseStore +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.api.test.TestFilterData +import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleshootNavigator +import io.element.android.libraries.troubleshoot.test.runAndTestState +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class FirebaseTokenTestTest { + @Test + fun `test FirebaseTokenTest success`() = runTest { + val sut = FirebaseTokenTest( + firebaseStore = InMemoryFirebaseStore(FAKE_TOKEN), + firebaseTroubleshooter = FakeFirebaseTroubleshooter(), + stringProvider = FakeStringProvider(), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + assertThat(lastItem.description).contains(FAKE_TOKEN.takeLast(8)) + assertThat(lastItem.description).doesNotContain(FAKE_TOKEN) + } + } + + @Test + fun `test FirebaseTokenTest error`() = runTest { + val firebaseStore = InMemoryFirebaseStore(null) + val sut = FirebaseTokenTest( + firebaseStore = firebaseStore, + firebaseTroubleshooter = FakeFirebaseTroubleshooter( + troubleShootResult = { + firebaseStore.storeFcmToken(FAKE_TOKEN) + Result.success(Unit) + } + ), + stringProvider = FakeStringProvider(), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(hasQuickFix = true)) + // Quick fix + sut.quickFix(this, FakeNotificationTroubleshootNavigator()) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + } + } + + @Test + fun `test FirebaseTokenTest error and reset`() = runTest { + val firebaseStore = InMemoryFirebaseStore(null) + val sut = FirebaseTokenTest( + firebaseStore = firebaseStore, + firebaseTroubleshooter = FakeFirebaseTroubleshooter( + troubleShootResult = { + firebaseStore.storeFcmToken(FAKE_TOKEN) + Result.success(Unit) + } + ), + stringProvider = FakeStringProvider(), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(hasQuickFix = true)) + sut.reset() + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + } + } + + @Test + fun `test FirebaseTokenTest isRelevant`() { + val sut = FirebaseTokenTest( + firebaseStore = InMemoryFirebaseStore(null), + firebaseTroubleshooter = FakeFirebaseTroubleshooter(), + stringProvider = FakeStringProvider(), + ) + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = "unknown"))).isFalse() + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = FirebaseConfig.NAME))).isTrue() + } + + companion object { + private const val FAKE_TOKEN = "abcdefghijk" + } +} diff --git a/libraries/pushproviders/test/build.gradle.kts b/libraries/pushproviders/test/build.gradle.kts new file mode 100644 index 0000000..61143e7 --- /dev/null +++ b/libraries/pushproviders/test/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.pushproviders.test" +} + +dependencies { + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.pushproviders.api) + implementation(projects.tests.testutils) +} diff --git a/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt b/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt new file mode 100644 index 0000000..86cb4a4 --- /dev/null +++ b/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.test + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushproviders.api.Config +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePushProvider( + override val index: Int = 0, + override val name: String = "aFakePushProvider", + override val supportMultipleDistributors: Boolean = false, + private val distributors: List = listOf(Distributor("aDistributorValue", "aDistributorName")), + private val currentDistributorValue: () -> String? = { lambdaError() }, + private val currentDistributor: () -> Distributor? = { distributors.firstOrNull() }, + private val config: Config? = null, + private val registerWithResult: (MatrixClient, Distributor) -> Result = { _, _ -> lambdaError() }, + private val unregisterWithResult: (MatrixClient) -> Result = { lambdaError() }, + private val onSessionDeletedLambda: (SessionId) -> Unit = { lambdaError() }, + private val canRotateTokenResult: () -> Boolean = { lambdaError() }, + private val rotateTokenLambda: () -> Result = { lambdaError() }, +) : PushProvider { + override fun getDistributors(): List = distributors + + override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result { + return registerWithResult(matrixClient, distributor) + } + + override suspend fun getCurrentDistributorValue(sessionId: SessionId): String? { + return currentDistributorValue() + } + + override suspend fun getCurrentDistributor(sessionId: SessionId): Distributor? { + return currentDistributor() + } + + override suspend fun unregister(matrixClient: MatrixClient): Result { + return unregisterWithResult(matrixClient) + } + + override suspend fun onSessionDeleted(sessionId: SessionId) { + onSessionDeletedLambda(sessionId) + } + + override suspend fun getPushConfig(sessionId: SessionId): Config? { + return config + } + + override fun canRotateToken(): Boolean { + return canRotateTokenResult() + } + + override suspend fun rotateToken(): Result { + return rotateTokenLambda() + } +} diff --git a/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/Fixtures.kt b/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/Fixtures.kt new file mode 100644 index 0000000..0e8dcb9 --- /dev/null +++ b/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/Fixtures.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.test + +import io.element.android.libraries.pushproviders.api.Config + +fun aSessionPushConfig( + url: String = "aUrl", + pushKey: String = "aPushKey", +) = Config( + url = url, + pushKey = pushKey, +) diff --git a/libraries/pushproviders/unifiedpush/build.gradle.kts b/libraries/pushproviders/unifiedpush/build.gradle.kts new file mode 100644 index 0000000..1b24bc4 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/build.gradle.kts @@ -0,0 +1,57 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "io.element.android.libraries.pushproviders.unifiedpush" +} + +setupDependencyInjection() + +dependencies { + implementation(projects.features.enterprise.api) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.push.api) + implementation(projects.libraries.uiStrings) + api(projects.libraries.troubleshoot.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(libs.network.okhttp.okhttp) + implementation(platform(libs.network.retrofit.bom)) + implementation(libs.network.retrofit) + + implementation(libs.serialization.json) + + // UnifiedPush library + api(libs.unifiedpush) + + testCommonDependencies(libs) + testImplementation(libs.kotlinx.collections.immutable) + testImplementation(projects.features.enterprise.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.push.test) + testImplementation(projects.libraries.pushproviders.test) + testImplementation(projects.libraries.pushstore.test) + testImplementation(projects.libraries.troubleshoot.test) + testImplementation(projects.services.toolbox.test) +} diff --git a/libraries/pushproviders/unifiedpush/src/main/AndroidManifest.xml b/libraries/pushproviders/unifiedpush/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5845883 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultPushGatewayHttpUrlProvider.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultPushGatewayHttpUrlProvider.kt new file mode 100644 index 0000000..ae3b2a9 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultPushGatewayHttpUrlProvider.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.enterprise.api.EnterpriseService + +interface DefaultPushGatewayHttpUrlProvider { + fun provide(): String +} + +@ContributesBinding(AppScope::class) +class DefaultDefaultPushGatewayHttpUrlProvider( + private val enterpriseService: EnterpriseService, +) : DefaultPushGatewayHttpUrlProvider { + override fun provide(): String { + return enterpriseService.unifiedPushDefaultPushGateway() ?: UnifiedPushConfig.DEFAULT_PUSH_GATEWAY_HTTP_URL + } +} 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 0000000..4a8eefe --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/GuardServiceStarter.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding + +interface GuardServiceStarter { + fun start() {} + fun stop() {} +} + +@ContributesBinding(AppScope::class) +class NoopGuardServiceStarter : 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 0000000..5530699 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/KeepInternalDistributor.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..cda7897 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/PushDataUnifiedPush.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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: + *
    + * {
    + *     "notification":{
    + *         "event_id":"$anEventId",
    + *         "room_id":"!aRoomId",
    + *         "counts":{
    + *             "unread":1
    + *         },
    + *         "prio":"high"
    + *     }
    + * }
    + * 
    + * . + */ +@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") val counts: PushDataUnifiedPushCounts? = null, + @SerialName("prio") val prio: String? = 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 0000000..00e5649 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import android.content.Context +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeout +import org.unifiedpush.android.connector.UnifiedPush +import kotlin.time.Duration.Companion.seconds + +interface RegisterUnifiedPushUseCase { + suspend fun execute(distributor: Distributor, clientSecret: String): Result +} + +@ContributesBinding(AppScope::class) +class DefaultRegisterUnifiedPushUseCase( + @ApplicationContext private val context: Context, + private val endpointRegistrationHandler: EndpointRegistrationHandler, +) : RegisterUnifiedPushUseCase { + override suspend fun execute(distributor: Distributor, clientSecret: String): Result { + UnifiedPush.saveDistributor(context, distributor.value) + // This will trigger the callback + // VectorUnifiedPushMessagingReceiver.onNewEndpoint + UnifiedPush.register(context = context, instance = clientSecret) + // Wait for VectorUnifiedPushMessagingReceiver.onNewEndpoint to proceed + @Suppress("RunCatchingNotAllowed") + return runCatching { + withTimeout(30.seconds) { + val result = endpointRegistrationHandler.state + .filter { it.clientSecret == clientSecret } + .first() + .result + result.getOrThrow() + } + } + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushApiFactory.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushApiFactory.kt new file mode 100644 index 0000000..5ff74b3 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushApiFactory.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.network.RetrofitFactory +import io.element.android.libraries.pushproviders.unifiedpush.network.UnifiedPushApi + +interface UnifiedPushApiFactory { + fun create(baseUrl: String): UnifiedPushApi +} + +@ContributesBinding(AppScope::class) +class DefaultUnifiedPushApiFactory( + private val retrofitFactory: RetrofitFactory, +) : UnifiedPushApiFactory { + override fun create(baseUrl: String): UnifiedPushApi { + return retrofitFactory.create(baseUrl) + .create(UnifiedPushApi::class.java) + } +} 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 0000000..9a92711 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushConfig.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 UNIFIED_PUSH_DISTRIBUTORS_URL = "https://unifiedpush.org/users/distributors/" + + const val INDEX = 1 + const val NAME = "UnifiedPush" +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushDistributorProvider.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushDistributorProvider.kt new file mode 100644 index 0000000..ac9dc74 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushDistributorProvider.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import android.content.Context +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.system.getApplicationLabel +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.pushproviders.api.Distributor +import org.unifiedpush.android.connector.UnifiedPush + +interface UnifiedPushDistributorProvider { + fun getDistributors(): List +} + +@ContributesBinding(AppScope::class) +class DefaultUnifiedPushDistributorProvider( + @ApplicationContext private val context: Context, +) : UnifiedPushDistributorProvider { + override fun getDistributors(): List { + val distributors = UnifiedPush.getDistributors(context) + return distributors.mapNotNull { + if (it == context.packageName) { + // Exclude self + null + } else { + Distributor(it, context.getApplicationLabel(it)) + } + } + } +} 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 0000000..8aa67b1 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushGatewayResolver.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.core.log.logger.LoggerTag +import kotlinx.coroutines.withContext +import retrofit2.HttpException +import timber.log.Timber +import java.net.HttpURLConnection +import java.net.URL + +sealed interface UnifiedPushGatewayResolverResult { + data class Success(val gateway: String) : UnifiedPushGatewayResolverResult + data class Error(val gateway: String) : UnifiedPushGatewayResolverResult + data object NoMatrixGateway : UnifiedPushGatewayResolverResult + data object ErrorInvalidUrl : UnifiedPushGatewayResolverResult +} + +interface UnifiedPushGatewayResolver { + suspend fun getGateway(endpoint: String): UnifiedPushGatewayResolverResult +} + +private val loggerTag = LoggerTag("DefaultUnifiedPushGatewayResolver") + +@ContributesBinding(AppScope::class) +class DefaultUnifiedPushGatewayResolver( + private val unifiedPushApiFactory: UnifiedPushApiFactory, + private val coroutineDispatchers: CoroutineDispatchers, +) : UnifiedPushGatewayResolver { + override suspend fun getGateway(endpoint: String): UnifiedPushGatewayResolverResult { + val url = tryOrNull( + onException = { Timber.tag(loggerTag.value).d(it, "Cannot parse endpoint as an URL") } + ) { + URL(endpoint) + } + return if (url == null) { + Timber.tag(loggerTag.value).d("ErrorInvalidUrl") + UnifiedPushGatewayResolverResult.ErrorInvalidUrl + } else { + val port = if (url.port != -1) ":${url.port}" else "" + val customBase = "${url.protocol}://${url.host}$port" + val customUrl = "$customBase/_matrix/push/v1/notify" + Timber.tag(loggerTag.value).i("Testing $customUrl") + return withContext(coroutineDispatchers.io) { + val api = unifiedPushApiFactory.create(customBase) + try { + val discoveryResponse = api.discover() + if (discoveryResponse.unifiedpush.gateway == "matrix") { + Timber.tag(loggerTag.value).d("The endpoint seems to be a valid UnifiedPush gateway") + UnifiedPushGatewayResolverResult.Success(customUrl) + } else { + // The endpoint returned a 200 OK but didn't promote an actual matrix gateway, which means it doesn't have any + Timber.tag(loggerTag.value).w("The endpoint does not seem to be a valid UnifiedPush gateway, using fallback") + UnifiedPushGatewayResolverResult.NoMatrixGateway + } + } catch (throwable: Throwable) { + val code = (throwable as? HttpException)?.code() + if (code in NoMatrixGatewayResp) { + Timber.tag(loggerTag.value).i("Checking for UnifiedPush endpoint yielded $code, using fallback") + UnifiedPushGatewayResolverResult.NoMatrixGateway + } else { + Timber.tag(loggerTag.value).e(throwable, "Error checking for UnifiedPush endpoint") + UnifiedPushGatewayResolverResult.Error(customUrl) + } + } + } + } + } + + companion object { + private val NoMatrixGatewayResp = listOf( + HttpURLConnection.HTTP_UNAUTHORIZED, + HttpURLConnection.HTTP_FORBIDDEN, + HttpURLConnection.HTTP_NOT_FOUND, + HttpURLConnection.HTTP_BAD_METHOD, + HttpURLConnection.HTTP_NOT_ACCEPTABLE + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushGatewayUrlResolver.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushGatewayUrlResolver.kt new file mode 100644 index 0000000..78a569a --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushGatewayUrlResolver.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding + +interface UnifiedPushGatewayUrlResolver { + fun resolve( + gatewayResult: UnifiedPushGatewayResolverResult, + instance: String, + ): String +} + +@ContributesBinding(AppScope::class) +class DefaultUnifiedPushGatewayUrlResolver( + private val unifiedPushStore: UnifiedPushStore, + private val defaultPushGatewayHttpUrlProvider: DefaultPushGatewayHttpUrlProvider, +) : UnifiedPushGatewayUrlResolver { + override fun resolve( + gatewayResult: UnifiedPushGatewayResolverResult, + instance: String, + ): String { + return when (gatewayResult) { + is UnifiedPushGatewayResolverResult.Error -> { + // Use previous gateway if any, or the provided one + unifiedPushStore.getPushGateway(instance) ?: gatewayResult.gateway + } + UnifiedPushGatewayResolverResult.ErrorInvalidUrl, + UnifiedPushGatewayResolverResult.NoMatrixGateway -> { + defaultPushGatewayHttpUrlProvider.provide() + } + is UnifiedPushGatewayResolverResult.Success -> { + gatewayResult.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 0000000..d7de923 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.flatMap +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.api.MatrixClientProvider +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 + +private val loggerTag = LoggerTag("DefaultUnifiedPushNewGatewayHandler", LoggerTag.PushLoggerTag) + +/** + * Handle new endpoint received from UnifiedPush. Will update the session matching the client secret. + */ +interface UnifiedPushNewGatewayHandler { + suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String): Result +} + +@ContributesBinding(AppScope::class) +class DefaultUnifiedPushNewGatewayHandler( + private val pusherSubscriber: PusherSubscriber, + private val userPushStoreFactory: UserPushStoreFactory, + private val pushClientSecret: PushClientSecret, + private val matrixClientProvider: MatrixClientProvider, +) : UnifiedPushNewGatewayHandler { + override suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String): Result { + // Register the pusher for the session with this client secret, if is it using UnifiedPush. + val userId = pushClientSecret.getUserIdFromSecret(clientSecret) ?: return Result.failure( + IllegalStateException("Unable to retrieve session") + ).also { + Timber.tag(loggerTag.value).w("Unable to retrieve session") + } + val userDataStore = userPushStoreFactory.getOrCreate(userId) + return if (userDataStore.getPushProviderName() == UnifiedPushConfig.NAME) { + matrixClientProvider + .getOrRestore(userId) + .flatMap { client -> + pusherSubscriber.registerPusher(client, endpoint, pushGateway) + } + .onFailure { + Timber.tag(loggerTag.value).w(it, "Unable to register pusher") + } + } else { + Timber.tag(loggerTag.value).d("This session is not using UnifiedPush pusher") + Result.failure( + IllegalStateException("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 0000000..0cc9560 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParser.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.androidutils.json.JsonProvider +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.pushproviders.api.PushData + +@Inject +class UnifiedPushParser( + private val json: JsonProvider, +) { + fun parse(message: ByteArray, clientSecret: String): PushData? { + return tryOrNull { json().decodeFromString(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 0000000..7c65e8e --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushproviders.api.Config +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 + +@ContributesIntoSet(AppScope::class) +@Inject +class UnifiedPushProvider( + private val unifiedPushDistributorProvider: UnifiedPushDistributorProvider, + private val registerUnifiedPushUseCase: RegisterUnifiedPushUseCase, + private val unRegisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase, + private val pushClientSecret: PushClientSecret, + private val unifiedPushStore: UnifiedPushStore, + private val unifiedPushSessionPushConfigProvider: UnifiedPushSessionPushConfigProvider, +) : PushProvider { + override val index = UnifiedPushConfig.INDEX + override val name = UnifiedPushConfig.NAME + override val supportMultipleDistributors = true + + override fun getDistributors(): List { + return unifiedPushDistributorProvider.getDistributors() + } + + override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result { + val clientSecret = pushClientSecret.getSecretForUser(matrixClient.sessionId) + return registerUnifiedPushUseCase.execute(distributor, clientSecret) + .onSuccess { + unifiedPushStore.setDistributorValue(matrixClient.sessionId, distributor.value) + } + } + + override suspend fun getCurrentDistributorValue(sessionId: SessionId): String? { + return unifiedPushStore.getDistributorValue(sessionId) + } + + override suspend fun getCurrentDistributor(sessionId: SessionId): Distributor? { + val distributorValue = unifiedPushStore.getDistributorValue(sessionId) + return getDistributors().find { it.value == distributorValue } + } + + override suspend fun unregister(matrixClient: MatrixClient): Result { + val clientSecret = pushClientSecret.getSecretForUser(matrixClient.sessionId) + return unRegisterUnifiedPushUseCase.unregister(matrixClient, clientSecret) + } + + override suspend fun onSessionDeleted(sessionId: SessionId) { + val clientSecret = pushClientSecret.getSecretForUser(sessionId) + unRegisterUnifiedPushUseCase.cleanup(clientSecret) + } + + override suspend fun getPushConfig(sessionId: SessionId): Config? { + return unifiedPushSessionPushConfigProvider.provide(sessionId) + } + + override fun canRotateToken(): Boolean = false +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushRemovedGatewayHandler.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushRemovedGatewayHandler.kt new file mode 100644 index 0000000..7baaaab --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushRemovedGatewayHandler.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.androidutils.throttler.FirstThrottler +import io.element.android.libraries.core.extensions.flatMap +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import kotlinx.coroutines.CoroutineScope +import timber.log.Timber + +private val loggerTag = LoggerTag("UnifiedPushRemovedGatewayHandler", LoggerTag.PushLoggerTag) + +/** + * Handle endpoint removal received from UnifiedPush. Will try to register again. + */ +fun interface UnifiedPushRemovedGatewayHandler { + suspend fun handle(clientSecret: String): Result +} + +@Inject +@SingleIn(AppScope::class) +class UnifiedPushRemovedGatewayThrottler( + @AppCoroutineScope + private val appCoroutineScope: CoroutineScope, +) { + private val firstThrottler = FirstThrottler( + minimumInterval = 60_000, + coroutineScope = appCoroutineScope, + ) + + fun canRegisterAgain(): Boolean { + return firstThrottler.canHandle() + } +} + +@ContributesBinding(AppScope::class) +class DefaultUnifiedPushRemovedGatewayHandler( + private val unregisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase, + private val pushClientSecret: PushClientSecret, + private val matrixClientProvider: MatrixClientProvider, + private val pushService: PushService, + private val unifiedPushRemovedGatewayThrottler: UnifiedPushRemovedGatewayThrottler, +) : UnifiedPushRemovedGatewayHandler { + /** + * The application has been informed by the UnifiedPush distributor that the topic has been deleted. + * So this code aim to unregister the pusher from the homeserver, register a new topic on the + * UnifiedPush application then register a new pusher to the homeserver. + * No registration will happen if the topic deletion has already occurred in the last minute. + */ + override suspend fun handle(clientSecret: String): Result { + val sessionId = pushClientSecret.getUserIdFromSecret(clientSecret) ?: return Result.failure( + IllegalStateException("Unable to retrieve session") + ).also { + Timber.tag(loggerTag.value).w("Unable to retrieve session") + } + return matrixClientProvider + .getOrRestore(sessionId) + .onFailure { + // Silently ignore this error (do not invoke onServiceUnregistered) + Timber.tag(loggerTag.value).w(it, "Fails to restore client") + } + .flatMap { client -> + client.rotateRegistration(clientSecret = clientSecret) + .onFailure { + Timber.tag(loggerTag.value).w(it, "Issue during pusher unregistration / re registration") + // Let the user know + pushService.onServiceUnregistered(sessionId) + } + } + } + + /** + * Unregister the pusher for the session. Then register again if possible. + */ + private suspend fun MatrixClient.rotateRegistration(clientSecret: String): Result { + val unregisterResult = unregisterUnifiedPushUseCase.unregister( + matrixClient = this, + clientSecret = clientSecret, + unregisterUnifiedPush = false, + ).onFailure { + Timber.tag(loggerTag.value).w(it, "Unable to unregister pusher") + } + return unregisterResult.flatMap { + registerAgain() + } + } + + /** + * Attempt to register again, if possible i.e. the current configuration is known and the + * deletion of data in the UnifiedPush application has not already occurred in the last minute. + */ + private suspend fun MatrixClient.registerAgain(): Result { + return if (unifiedPushRemovedGatewayThrottler.canRegisterAgain()) { + val pushProvider = pushService.getCurrentPushProvider(sessionId) + val distributor = pushProvider?.getCurrentDistributor(sessionId) + if (pushProvider != null && distributor != null) { + pushService.registerWith( + matrixClient = this, + pushProvider = pushProvider, + distributor = distributor, + ).onFailure { + Timber.tag(loggerTag.value).w(it, "Unable to register with current data") + } + } else { + Result.failure(IllegalStateException("Unable to register again")) + } + } else { + Timber.tag(loggerTag.value).w("Second removal in less than 1 minute, do not register again") + Result.failure(IllegalStateException("Too many requests to register again")) + } + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushSessionPushConfigProvider.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushSessionPushConfigProvider.kt new file mode 100644 index 0000000..20ac923 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushSessionPushConfigProvider.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushproviders.api.Config +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret + +interface UnifiedPushSessionPushConfigProvider { + suspend fun provide(sessionId: SessionId): Config? +} + +@ContributesBinding(AppScope::class) +class DefaultUnifiedPushPushConfigProvider( + private val pushClientSecret: PushClientSecret, + private val unifiedPushStore: UnifiedPushStore, +) : UnifiedPushSessionPushConfigProvider { + override suspend fun provide(sessionId: SessionId): Config? { + val clientSecret = pushClientSecret.getSecretForUser(sessionId) + val url = unifiedPushStore.getPushGateway(clientSecret) ?: return null + val pushKey = unifiedPushStore.getEndpoint(clientSecret) ?: return null + return Config( + url = url, + pushKey = pushKey, + ) + } +} 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 0000000..009c863 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushStore.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.core.UserId + +interface UnifiedPushStore { + fun getEndpoint(clientSecret: String): String? + fun storeUpEndpoint(clientSecret: String, endpoint: String?) + fun getPushGateway(clientSecret: String): String? + fun storePushGateway(clientSecret: String, gateway: String?) + fun getDistributorValue(userId: UserId): String? + fun setDistributorValue(userId: UserId, value: String) +} + +@ContributesBinding(AppScope::class) +class SharedPreferencesUnifiedPushStore( + @ApplicationContext val context: Context, + private val sharedPreferences: SharedPreferences, +) : UnifiedPushStore { + /** + * Retrieves the UnifiedPush Endpoint. + * + * @param clientSecret the client secret, to identify the session + * @return the UnifiedPush Endpoint or null if not received + */ + override fun getEndpoint(clientSecret: String): String? { + return sharedPreferences.getString(PREFS_ENDPOINT_OR_TOKEN + clientSecret, null) + } + + /** + * Store UnifiedPush Endpoint to the SharedPrefs. + * + * @param clientSecret the client secret, to identify the session + * @param endpoint the endpoint to store + */ + override fun storeUpEndpoint(clientSecret: String, endpoint: String?) { + sharedPreferences.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 + */ + override fun getPushGateway(clientSecret: String): String? { + return sharedPreferences.getString(PREFS_PUSH_GATEWAY + clientSecret, null) + } + + /** + * Store Push Gateway to the SharedPrefs. + * + * @param clientSecret the client secret, to identify the session + * @param gateway the push gateway to store + */ + override fun storePushGateway(clientSecret: String, gateway: String?) { + sharedPreferences.edit { + putString(PREFS_PUSH_GATEWAY + clientSecret, gateway) + } + } + + override fun getDistributorValue(userId: UserId): String? { + return sharedPreferences.getString(PREFS_DISTRIBUTOR + userId, null) + } + + override fun setDistributorValue(userId: UserId, value: String) { + sharedPreferences.edit { + putString(PREFS_DISTRIBUTOR + userId, value) + } + } + + companion object { + private const val PREFS_ENDPOINT_OR_TOKEN = "UP_ENDPOINT_OR_TOKEN" + private const val PREFS_PUSH_GATEWAY = "PUSH_GATEWAY" + private const val PREFS_DISTRIBUTOR = "DISTRIBUTOR" + } +} 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 0000000..2e0d6ea --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import android.content.Context +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import org.unifiedpush.android.connector.UnifiedPush +import timber.log.Timber + +interface UnregisterUnifiedPushUseCase { + /** + * Unregister the app from the homeserver, then from UnifiedPush if [unregisterUnifiedPush] is true. + */ + suspend fun unregister( + matrixClient: MatrixClient, + clientSecret: String, + unregisterUnifiedPush: Boolean = true, + ): Result + + /** + * Cleanup any remaining data for the given client secret and unregister the app from UnifiedPush. + */ + fun cleanup( + clientSecret: String, + unregisterUnifiedPush: Boolean = true, + ) +} + +@ContributesBinding(AppScope::class) +class DefaultUnregisterUnifiedPushUseCase( + @ApplicationContext private val context: Context, + private val unifiedPushStore: UnifiedPushStore, + private val pusherSubscriber: PusherSubscriber, +) : UnregisterUnifiedPushUseCase { + override suspend fun unregister( + matrixClient: MatrixClient, + clientSecret: String, + unregisterUnifiedPush: Boolean, + ): Result { + val endpoint = unifiedPushStore.getEndpoint(clientSecret) + val gateway = unifiedPushStore.getPushGateway(clientSecret) + if (endpoint == null || gateway == null) { + Timber.w("No endpoint or gateway found for client secret") + // Ensure we don't have any remaining data, but ignore this error + cleanup(clientSecret) + return Result.success(Unit) + } + return pusherSubscriber.unregisterPusher(matrixClient, endpoint, gateway) + .onSuccess { + cleanup(clientSecret, unregisterUnifiedPush) + } + } + + override fun cleanup(clientSecret: String, unregisterUnifiedPush: Boolean) { + unifiedPushStore.storeUpEndpoint(clientSecret, null) + unifiedPushStore.storePushGateway(clientSecret, null) + if (unregisterUnifiedPush) { + UnifiedPush.unregister(context, clientSecret) + } + } +} 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 0000000..05f6969 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import android.content.Context +import android.content.Intent +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.pushproviders.api.PushHandler +import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler +import io.element.android.libraries.pushproviders.unifiedpush.registration.RegistrationResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.MessagingReceiver +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage +import timber.log.Timber + +private val loggerTag = LoggerTag("VectorUnifiedPushMessagingReceiver", LoggerTag.PushLoggerTag) + +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 unifiedPushGatewayUrlResolver: UnifiedPushGatewayUrlResolver + @Inject lateinit var newGatewayHandler: UnifiedPushNewGatewayHandler + @Inject lateinit var removedGatewayHandler: UnifiedPushRemovedGatewayHandler + @Inject lateinit var endpointRegistrationHandler: EndpointRegistrationHandler + + @AppCoroutineScope + @Inject lateinit var coroutineScope: CoroutineScope + + override fun onReceive(context: Context, intent: Intent) { + context.bindings().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: PushMessage, instance: String) { + Timber.tag(loggerTag.value).d("New message, decrypted: ${message.decrypted}") + coroutineScope.launch { + val pushData = pushParser.parse(message.content, instance) + if (pushData == null) { + Timber.tag(loggerTag.value).w("Invalid data received from UnifiedPush") + pushHandler.handleInvalid( + providerInfo = "${UnifiedPushConfig.NAME} - $instance", + data = String(message.content), + ) + } else { + pushHandler.handle( + pushData = pushData, + providerInfo = "${UnifiedPushConfig.NAME} - $instance", + ) + } + } + } + + /** + * 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: PushEndpoint, instance: String) { + Timber.tag(loggerTag.value).w("onNewEndpoint: $endpoint") + coroutineScope.launch { + val gateway = unifiedPushGatewayResolver.getGateway(endpoint.url) + .let { gatewayResult -> + unifiedPushGatewayUrlResolver.resolve(gatewayResult, instance) + } + unifiedPushStore.storePushGateway(instance, gateway) + val result = newGatewayHandler.handle(endpoint.url, gateway, instance) + .onFailure { + Timber.tag(loggerTag.value).e(it, "Failed to handle new gateway") + } + .onSuccess { + unifiedPushStore.storeUpEndpoint(instance, endpoint.url) + } + endpointRegistrationHandler.registrationDone( + RegistrationResult( + clientSecret = instance, + result = result, + ) + ) + } + guardServiceStarter.stop() + } + + /** + * Called when the registration is not possible, eg. no network. + */ + override fun onRegistrationFailed(context: Context, reason: FailedReason, instance: String) { + Timber.tag(loggerTag.value).e("onRegistrationFailed for $instance, reason: $reason") + coroutineScope.launch { + endpointRegistrationHandler.registrationDone( + RegistrationResult( + clientSecret = instance, + result = Result.failure(Exception("Registration failed. Reason: $reason")), + ) + ) + } + } + + /** + * Called when this application is unregistered from receiving push messages. + */ + override fun onUnregistered(context: Context, instance: String) { + Timber.tag(loggerTag.value).w("onUnregistered $instance") + coroutineScope.launch { + removedGatewayHandler.handle(instance) + } + } +} 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 0000000..da13aa9 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo +import org.unifiedpush.android.connector.MessagingReceiver + +@ContributesTo(AppScope::class) +interface VectorUnifiedPushMessagingReceiverBindings { + fun inject(receiver: VectorUnifiedPushMessagingReceiver) + + @Binds + fun bindsMessagingReceiver(vectorUnifiedPushMessagingReceiver: VectorUnifiedPushMessagingReceiver): MessagingReceiver +} 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 0000000..ecdfad0 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/network/DiscoveryResponse.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..023dd99 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/network/DiscoveryUnifiedPush.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..ca99f47 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/network/UnifiedPushApi.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/registration/EndpointRegistrationHandler.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/registration/EndpointRegistrationHandler.kt new file mode 100644 index 0000000..b114849 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/registration/EndpointRegistrationHandler.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush.registration + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow + +data class RegistrationResult( + val clientSecret: String, + val result: Result, +) + +@SingleIn(AppScope::class) +@Inject +class EndpointRegistrationHandler { + private val _state = MutableSharedFlow() + val state: SharedFlow = _state + + suspend fun registrationDone(result: RegistrationResult) { + _state.emit(result) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/OpenDistributorWebPageAction.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/OpenDistributorWebPageAction.kt new file mode 100644 index 0000000..7f805ad --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/OpenDistributorWebPageAction.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush.troubleshoot + +import android.content.Context +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.system.openUrlInExternalApp +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.pushproviders.unifiedpush.UnifiedPushConfig + +interface OpenDistributorWebPageAction { + fun execute() +} + +@ContributesBinding(AppScope::class) +class DefaultOpenDistributorWebPageAction( + @ApplicationContext private val context: Context, +) : OpenDistributorWebPageAction { + override fun execute() { + // Open the distributor download page + context.openUrlInExternalApp( + url = UnifiedPushConfig.UNIFIED_PUSH_DISTRIBUTORS_URL, + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushMatrixGatewayTest.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushMatrixGatewayTest.kt new file mode 100644 index 0000000..7a50077 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushMatrixGatewayTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush.troubleshoot + +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushproviders.unifiedpush.UnifiedPushApiFactory +import io.element.android.libraries.pushproviders.unifiedpush.UnifiedPushConfig +import io.element.android.libraries.pushproviders.unifiedpush.UnifiedPushSessionPushConfigProvider +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestDelegate +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.api.test.TestFilterData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +@ContributesIntoSet(SessionScope::class) +@Inject +class UnifiedPushMatrixGatewayTest( + private val sessionId: SessionId, + private val unifiedPushApiFactory: UnifiedPushApiFactory, + private val coroutineDispatchers: CoroutineDispatchers, + private val unifiedPushSessionPushConfigProvider: UnifiedPushSessionPushConfigProvider, +) : NotificationTroubleshootTest { + override val order = 450 + private val delegate = NotificationTroubleshootTestDelegate( + defaultName = "Test push gateway", + defaultDescription = "Ensure that the push gateway is valid.", + visibleWhenIdle = false, + fakeDelay = NotificationTroubleshootTestDelegate.SHORT_DELAY, + ) + override val state: StateFlow = delegate.state + + override fun isRelevant(data: TestFilterData): Boolean { + return data.currentPushProviderName == UnifiedPushConfig.NAME + } + + override suspend fun run(coroutineScope: CoroutineScope) { + delegate.start() + val config = unifiedPushSessionPushConfigProvider.provide(sessionId) + if (config == null) { + delegate.updateState( + description = "No current push provider", + status = NotificationTroubleshootTestState.Status.Failure() + ) + } else { + val gatewayBaseUrl = config.url.removeSuffix("/_matrix/push/v1/notify") + // Checking if the gateway is a Matrix gateway + coroutineScope.launch(coroutineDispatchers.io) { + val api = unifiedPushApiFactory.create(gatewayBaseUrl) + try { + val discoveryResponse = api.discover() + if (discoveryResponse.unifiedpush.gateway == "matrix") { + delegate.updateState( + description = "${config.url} is a Matrix gateway.", + status = NotificationTroubleshootTestState.Status.Success + ) + } else { + delegate.updateState( + description = "${config.url} is not a Matrix gateway.", + status = NotificationTroubleshootTestState.Status.Failure() + ) + } + } catch (throwable: Throwable) { + delegate.updateState( + description = "Fail to check the gateway ${config.url}: ${throwable.localizedMessage}", + status = NotificationTroubleshootTestState.Status.Failure() + ) + } + } + } + } + + override suspend fun reset() = delegate.reset() +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTest.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTest.kt new file mode 100644 index 0000000..6d6d3b6 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush.troubleshoot + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.libraries.pushproviders.unifiedpush.R +import io.element.android.libraries.pushproviders.unifiedpush.UnifiedPushConfig +import io.element.android.libraries.pushproviders.unifiedpush.UnifiedPushDistributorProvider +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootNavigator +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestDelegate +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.api.test.TestFilterData +import io.element.android.services.toolbox.api.strings.StringProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +@ContributesIntoSet(AppScope::class) +@Inject +class UnifiedPushTest( + private val unifiedPushDistributorProvider: UnifiedPushDistributorProvider, + private val openDistributorWebPageAction: OpenDistributorWebPageAction, + private val stringProvider: StringProvider, +) : NotificationTroubleshootTest { + override val order = 400 + private val delegate = NotificationTroubleshootTestDelegate( + defaultName = stringProvider.getString(R.string.troubleshoot_notifications_test_unified_push_title), + defaultDescription = stringProvider.getString(R.string.troubleshoot_notifications_test_unified_push_description), + visibleWhenIdle = false, + fakeDelay = NotificationTroubleshootTestDelegate.SHORT_DELAY, + ) + override val state: StateFlow = delegate.state + + override fun isRelevant(data: TestFilterData): Boolean { + return data.currentPushProviderName == UnifiedPushConfig.NAME + } + + override suspend fun run(coroutineScope: CoroutineScope) { + delegate.start() + val distributors = unifiedPushDistributorProvider.getDistributors() + if (distributors.isNotEmpty()) { + delegate.updateState( + description = stringProvider.getQuantityString( + resId = R.plurals.troubleshoot_notifications_test_unified_push_success, + quantity = distributors.size, + distributors.size, + distributors.joinToString { it.name } + ), + status = NotificationTroubleshootTestState.Status.Success + ) + } else { + delegate.updateState( + description = stringProvider.getString(R.string.troubleshoot_notifications_test_unified_push_failure), + status = NotificationTroubleshootTestState.Status.Failure(hasQuickFix = true) + ) + } + } + + override suspend fun reset() = delegate.reset() + + override suspend fun quickFix( + coroutineScope: CoroutineScope, + navigator: NotificationTroubleshootNavigator, + ) { + openDistributorWebPageAction.execute() + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-be/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..4fd2fef --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-be/translations.xml @@ -0,0 +1,11 @@ + + + "Пераканайцеся, што размеркавальнікі UnifiedPush даступныя." + "Размеркавальнікі не знойдзены." + + "%1$d знойдзены размеркавальнік: %2$s." + "%1$d знойдзены размеркавальнікі: %2$s." + "%1$d знойдзена размеркавальнікаў: %2$s." + + "Праверыць UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-cs/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..d4eeb3e --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-cs/translations.xml @@ -0,0 +1,11 @@ + + + "Ujistěte se, že jsou k dispozici distributoři UnifiedPush." + "Nebyli nalezeni žádní push distributoři." + + "Nalezen %1$d distributor: %2$s." + "Nalezeni %1$d distributoři: %2$s." + "Nalezeno %1$d distributorů: %2$s." + + "Zkontrolovat UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-cy/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..1a46d85 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-cy/translations.xml @@ -0,0 +1,14 @@ + + + "Sicrhewch fod dosbarthwyr UnifiedPush ar gael." + "Heb ganfod dosbarthwyr gwthio." + + "Wedi canfod %1$d dosbarthwyr: %2$s" + "Wedi canfod %1$d dosbarthwr: %2$s" + "Wedi canfod %1$d dosbarthwr: %2$s" + "Wedi canfod %1$d dosbarthwr: %2$s" + "Wedi canfod %1$d dosbarthwr: %2$s" + "Wedi canfod %1$d dosbarthwr: %2$s" + + "Gwiriwch UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-da/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..b6d549b --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-da/translations.xml @@ -0,0 +1,10 @@ + + + "Sørg for, at UnifiedPush-distributører er tilgængelige." + "Ingen push-distributører fundet." + + "%1$d distributør fundet:%2$s." + "%1$d distributører fundet:%2$s." + + "Afprøv UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-de/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..92a632a --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-de/translations.xml @@ -0,0 +1,10 @@ + + + "Stelle sicher, dass UnifiedPush-Verteiler verfügbar sind." + "Keine Push-Verteiler gefunden." + + "%1$d Verteiler gefunden: %2$s." + "%1$d Verteiler gefunden: %2$s." + + "UnifiedPush prüfen" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-el/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..106a69e --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-el/translations.xml @@ -0,0 +1,10 @@ + + + "Βεβαιώσου ότι οι διανομείς UnifiedPush είναι διαθέσιμοι." + "Δεν βρέθηκαν διανομείς push." + + "%Βρέθηκε %1$d διανομέας: %2$s." + "Βρέθηκαν %1$d διανομείς: %2$s." + + "Έλεγχος UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-es/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..dca4f01 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-es/translations.xml @@ -0,0 +1,10 @@ + + + "Asegurarse de que los distribuidores de UnifiedPush están disponibles." + "No se ha encontrado ningún distribuidor push." + + "%1$d distribuidor encontrado: %2$s." + "%1$d distribuidores encontrados: %2$s." + + "Verificar UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-et/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..ce3ca58 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-et/translations.xml @@ -0,0 +1,10 @@ + + + "Palun veendu, et UnifiedPushi levitajad on saadaval." + "Tõuketeenuse levitajaid ei leidu." + + "Leidus %1$d tõuketeenuse levitaja: %2$s." + "Leidus %1$d tõuketeenuse levitajat: %2$s." + + "Kontrolli UnifiedPushi" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-eu/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..7952c7d --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-eu/translations.xml @@ -0,0 +1,9 @@ + + + "Ez da push banatzailerik aurkitu." + + "Banatzaile %1$d aurkitu da: %2$s." + "%1$d banatzailea aurkitu dira: %2$s." + + "Egiaztatu UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-fi/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..f997d02 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-fi/translations.xml @@ -0,0 +1,10 @@ + + + "Varmistas, että UnifiedPush-jakelijat ovat käytettävissä." + "Push-jakelijoita ei löytynyt." + + "%1$d jakelija löytyi: %2$s." + "%1$d jakelijaa löytyi: %2$s." + + "UnifiedPushin tarkistus" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-fr/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..fe7769d --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-fr/translations.xml @@ -0,0 +1,10 @@ + + + "Vérifier qu’au moins un distributeur UnifiedPush est disponible." + "Aucun distributeur UnifiedPush n’a été trouvé." + + "%1$d distributeur détecté :%2$s." + "%1$d distributeurs détectés :%2$s." + + "Vérifier UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-hu/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..7c92f52 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-hu/translations.xml @@ -0,0 +1,10 @@ + + + "Győződjön meg arról, hogy a UnifiedPush forgalmazói elérhetők." + "Nem található forgalmazó a leküldéses értesítésekhez." + + "%1$d forgalmazó található: %2$s." + "%1$d forgalmazó található: %2$s." + + "Ellenőrizze a UnifiedPush szolgáltatást" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-in/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..8190062 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-in/translations.xml @@ -0,0 +1,9 @@ + + + "Pastikan distributor UnifiedPush tersedia." + "Tidak ada distributor notifikasi dorongan yang ditemukan." + + "%1$d distributor ditemukan: %2$s." + + "Periksa UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-it/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..a92663f --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-it/translations.xml @@ -0,0 +1,10 @@ + + + "Assicurati che i distributori UnifiedPush siano disponibili." + "Nessun distributore di notifiche push trovato." + + "%1$d distributore trovato: %2$s." + "%1$d distributori trovati: %2$s." + + "Controlla UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-ka/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..b68a007 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-ka/translations.xml @@ -0,0 +1,10 @@ + + + "დარწმუნდით რომ UnifiedPush დისტრიბუტორები ხელმისაწვდომია." + "Push დისტრიბუტორები არ მოიძებნა." + + "%1$d დისტრიბუტორი მოიძებნა: %2$s" + "%1$d დისტრიბუტორი მოიძებნა: %2$s" + + "შეამოწმეთ UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-ko/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..ca789ff --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-ko/translations.xml @@ -0,0 +1,9 @@ + + + "UnifiedPush 배포자가 사용할 수 있는지 확인하세요." + "푸시 배포자가 발견되지 않았습니다." + + "%1$d 배포자 목록: %2$s." + + "UnifiedPush 확인하기" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-nb/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..67f6895 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-nb/translations.xml @@ -0,0 +1,10 @@ + + + "Påse at UnifiedPush-distributører er tilgjengelige." + "Ingen push-distributører funnet." + + "%1$d distributør funnet: %2$s." + "%1$d distributører funnet: %2$s." + + "Sjekk UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-nl/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..afe222b --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-nl/translations.xml @@ -0,0 +1,10 @@ + + + "Ervoor zorgen dat UnifiedPush verdelers beschikbaar zijn." + "Geen push-verdelers gevonden." + + "%1$d verdeler gevonden: %2$s." + "%1$d verdelers gevonden: %2$s." + + "UnifiedPush controleren" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-pl/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..d12f819 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-pl/translations.xml @@ -0,0 +1,11 @@ + + + "Upewnij się, że dystrybutorzy UnifiedPush są dostępni." + "Nie znaleziono dystrybutorów push." + + "Znaleziono %1$d dystrybutora: %2$s." + "Znaleziono %1$d dystrybutorów: %2$s." + "Znaleziono %1$d dystrybutorów: %2$s." + + "Sprawdź UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-pt-rBR/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..beaaf54 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,10 @@ + + + "Certifique-se de que os distribuidores do UnifiedPush estejam disponíveis." + "Nenhum distribuidor push encontrado." + + "%1$d distribuidor encontrado: %2$s." + "%1$d distribuidores encontrados: %2$s." + + "Verificar o UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-pt/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..8fec0ef --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-pt/translations.xml @@ -0,0 +1,10 @@ + + + "Certifica que os distribuidores UnifiedPush estão disponíveis." + "Nenhum distribuidor encontrado." + + "%1$d distribuidor encontrado: %2$s." + "%1$d distribuidores encontrados: %2$s." + + "Verificar UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-ro/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..fa96a07 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-ro/translations.xml @@ -0,0 +1,11 @@ + + + "Asigurați-vă că distribuitorii UnifiedPush sunt disponibili." + "Nu au fost găsiți distribuitori push." + + "%1$d distribuitor găsit: %2$s." + "%1$d distribuitori găsiți: %2$s." + "%1$d distribuitori găsiți: %2$s." + + "Verificați UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-ru/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..91a4dbc --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-ru/translations.xml @@ -0,0 +1,11 @@ + + + "Убедитесь, что дистрибьюторы UnifiedPush доступны." + "Поставщиков push-уведомлений не найдено." + + "%1$d провайдер найден: %2$s." + "%1$d провайдеров найдено: %2$s." + "%1$d провайдеров найдено: %2$s." + + "Проверка UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-sk/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..3dc876c --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-sk/translations.xml @@ -0,0 +1,11 @@ + + + "Uistite sa, že sú dostupní distribútori UnifiedPush." + "Nenašli sa žiadni distribútori push." + + "%1$d nájdený distribútor: %2$s." + "%1$d nájdení distribútori: %2$s." + "%1$d nájdených distribútorov: %2$s." + + "Skontrolovať UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-sv/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..502e05f --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-sv/translations.xml @@ -0,0 +1,10 @@ + + + "Se till att UnifiedPush-distributörer är tillgängliga." + "Inga push-distributörer hittades." + + "%1$d distributör hittades:%2$s." + "%1$d distributörer hittade:%2$s." + + "Kontrollera UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-tr/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..5fee0a6 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-tr/translations.xml @@ -0,0 +1,10 @@ + + + "UnifiedPush distribütörlerinin mevcut olduğundan emin olun." + "İtme dağıtıcı bulunamadı." + + "%1$d dağıtıcı bulundu: %2$s." + "%1$d dağıtıcı bulundu: %2$s." + + "UnifiedPush\'u kontrol edin" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-uk/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..5b33d80 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-uk/translations.xml @@ -0,0 +1,11 @@ + + + "Переконується, що дистриб\'ютори UnifiedPush доступні." + "Дистриб\'юторів не знайдено." + + "Знайдений %1$d дистриб\'ютор: %2$s." + "Знайдено %1$d дистриб\'ютори: %2$s." + "Знайдено %1$d дистриб\'юторів: %2$s." + + "Перевірка UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-ur/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..4e74d70 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-ur/translations.xml @@ -0,0 +1,10 @@ + + + "یقینی بنائیں کہ UnifiedPush تقسیم کاران دستیاب ہیں۔" + "کوئی دھکا تقسیم کاران نہیں ملے۔" + + "%1$d تقسیم کار ملا: %2$s۔" + "%1$d تقسیم کاران ملے: %2$s۔" + + "UnifiedPush کی پڑتال کریں" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-uz/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..a1d978c --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-uz/translations.xml @@ -0,0 +1,10 @@ + + + "UnifiedPush distribyutorlari mavjudligiga ishonch hosil qiling." + "Push distribyutorlari topilmadi." + + "%1$d ta distribyutor topildi: %2$s." + "%1$d ta distribyutor topildi: %2$s." + + "UnifiedPush tekshiruvi" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-zh-rTW/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..214dfe1 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,9 @@ + + + "確保 UnifiedPush 散佈者可用。" + "找不到散佈者。" + + "找到 %1$d 個散佈者:%2$s。" + + "檢查 UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-zh/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..2fac432 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-zh/translations.xml @@ -0,0 +1,9 @@ + + + "确保 UnifiedPush distributor 可用。" + "未找到推送 distributor。" + + "找到 %1$d 个 distributors:%2$s" + + "检查 UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values/localazy.xml b/libraries/pushproviders/unifiedpush/src/main/res/values/localazy.xml new file mode 100644 index 0000000..0e16af1 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values/localazy.xml @@ -0,0 +1,10 @@ + + + "Ensure that UnifiedPush distributors are available." + "No push distributors found." + + "%1$d distributor found: %2$s." + "%1$d distributors found: %2$s." + + "Check UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultRegisterUnifiedPushUseCaseTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultRegisterUnifiedPushUseCaseTest.kt new file mode 100644 index 0000000..a2304bf --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultRegisterUnifiedPushUseCaseTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler +import io.element.android.libraries.pushproviders.unifiedpush.registration.RegistrationResult +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultRegisterUnifiedPushUseCaseTest { + @Test + fun `test registration successful`() = runTest { + val endpointRegistrationHandler = EndpointRegistrationHandler() + val useCase = createDefaultRegisterUnifiedPushUseCase( + endpointRegistrationHandler = endpointRegistrationHandler + ) + val aDistributor = Distributor("aValue", "aName") + launch { + delay(100) + endpointRegistrationHandler.registrationDone(RegistrationResult(A_SECRET, Result.success(Unit))) + } + val result = useCase.execute(aDistributor, A_SECRET) + assertThat(result.isSuccess).isTrue() + } + + @Test + fun `test registration error`() = runTest { + val endpointRegistrationHandler = EndpointRegistrationHandler() + val useCase = createDefaultRegisterUnifiedPushUseCase( + endpointRegistrationHandler = endpointRegistrationHandler + ) + val aDistributor = Distributor("aValue", "aName") + launch { + delay(100) + endpointRegistrationHandler.registrationDone(RegistrationResult(A_SECRET, Result.failure(AN_EXCEPTION))) + } + val result = useCase.execute(aDistributor, A_SECRET) + assertThat(result.isSuccess).isFalse() + } + + @Test + fun `test registration timeout`() = runTest { + val endpointRegistrationHandler = EndpointRegistrationHandler() + val useCase = createDefaultRegisterUnifiedPushUseCase( + endpointRegistrationHandler = endpointRegistrationHandler + ) + val aDistributor = Distributor("aValue", "aName") + val result = useCase.execute(aDistributor, A_SECRET) + assertThat(result.isSuccess).isFalse() + } + + private fun TestScope.createDefaultRegisterUnifiedPushUseCase( + endpointRegistrationHandler: EndpointRegistrationHandler + ): DefaultRegisterUnifiedPushUseCase { + val context = InstrumentationRegistry.getInstrumentation().context + return DefaultRegisterUnifiedPushUseCase( + context = context, + endpointRegistrationHandler = endpointRegistrationHandler, + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushCurrentUserPushConfigProviderTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushCurrentUserPushConfigProviderTest.kt new file mode 100644 index 0000000..7a8ec16 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushCurrentUserPushConfigProviderTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.pushproviders.api.Config +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultUnifiedPushCurrentUserPushConfigProviderTest { + @Test + fun `getCurrentUserPushConfig no push gateway`() = runTest { + val sut = createDefaultUnifiedPushCurrentUserPushConfigProvider( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET } + ), + unifiedPushStore = FakeUnifiedPushStore( + getPushGatewayResult = { null } + ), + ) + val result = sut.provide(A_SESSION_ID) + assertThat(result).isNull() + } + + @Test + fun `getCurrentUserPushConfig no push key`() = runTest { + val sut = createDefaultUnifiedPushCurrentUserPushConfigProvider( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET } + ), + unifiedPushStore = FakeUnifiedPushStore( + getPushGatewayResult = { "aPushGateway" }, + getEndpointResult = { null } + ), + ) + val result = sut.provide(A_SESSION_ID) + assertThat(result).isNull() + } + + @Test + fun `getCurrentUserPushConfig ok`() = runTest { + val sut = createDefaultUnifiedPushCurrentUserPushConfigProvider( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET } + ), + unifiedPushStore = FakeUnifiedPushStore( + getPushGatewayResult = { "aPushGateway" }, + getEndpointResult = { "aEndpoint" } + ), + ) + val result = sut.provide(A_SESSION_ID) + assertThat(result).isEqualTo(Config("aPushGateway", "aEndpoint")) + } + + private fun createDefaultUnifiedPushCurrentUserPushConfigProvider( + pushClientSecret: PushClientSecret = FakePushClientSecret(), + unifiedPushStore: UnifiedPushStore = FakeUnifiedPushStore(), + ): DefaultUnifiedPushPushConfigProvider { + return DefaultUnifiedPushPushConfigProvider( + pushClientSecret = pushClientSecret, + unifiedPushStore = unifiedPushStore, + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushGatewayResolverTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushGatewayResolverTest.kt new file mode 100644 index 0000000..642f144 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushGatewayResolverTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.pushproviders.unifiedpush.network.DiscoveryResponse +import io.element.android.libraries.pushproviders.unifiedpush.network.DiscoveryUnifiedPush +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Test +import retrofit2.HttpException +import retrofit2.Response +import java.net.HttpURLConnection + +internal val matrixDiscoveryResponse = { + DiscoveryResponse( + unifiedpush = DiscoveryUnifiedPush( + gateway = "matrix" + ) + ) +} + +internal val invalidDiscoveryResponse = { + DiscoveryResponse( + unifiedpush = DiscoveryUnifiedPush( + gateway = "" + ) + ) +} + +class DefaultUnifiedPushGatewayResolverTest { + @Test + fun `when a custom url provide a correct matrix gateway, the custom url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = matrixDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("https://custom.url") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("https://custom.url") + assertThat(result).isEqualTo(UnifiedPushGatewayResolverResult.Success("https://custom.url/_matrix/push/v1/notify")) + } + + @Test + fun `when a custom url with port provides a correct matrix gateway, the custom url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = matrixDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("https://custom.url:123") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("https://custom.url:123") + assertThat(result).isEqualTo(UnifiedPushGatewayResolverResult.Success("https://custom.url:123/_matrix/push/v1/notify")) + } + + @Test + fun `when a custom url with port and path provides a correct matrix gateway, the custom url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = matrixDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("https://custom.url:123/some/path") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("https://custom.url:123") + assertThat(result).isEqualTo(UnifiedPushGatewayResolverResult.Success("https://custom.url:123/_matrix/push/v1/notify")) + } + + @Test + fun `when a custom url with http scheme provides a correct matrix gateway, the custom url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = matrixDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("http://custom.url:123/some/path") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("http://custom.url:123") + assertThat(result).isEqualTo(UnifiedPushGatewayResolverResult.Success("http://custom.url:123/_matrix/push/v1/notify")) + } + + @Test + fun `when a custom url is not reachable, the custom url is still returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = { throw AN_EXCEPTION } + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("http://custom.url") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("http://custom.url") + assertThat(result).isEqualTo(UnifiedPushGatewayResolverResult.Error("http://custom.url/_matrix/push/v1/notify")) + } + + @Test + fun `when a custom url is not found (404), NoMatrixGateway is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = { + throw HttpException(Response.error(HttpURLConnection.HTTP_NOT_FOUND, "".toResponseBody())) + } + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("http://custom.url") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("http://custom.url") + assertThat(result).isEqualTo(UnifiedPushGatewayResolverResult.NoMatrixGateway) + } + + @Test + fun `when a custom url is forbidden (403), NoMatrixGateway is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = { + throw HttpException(Response.error(HttpURLConnection.HTTP_FORBIDDEN, "".toResponseBody())) + } + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("http://custom.url") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("http://custom.url") + assertThat(result).isEqualTo(UnifiedPushGatewayResolverResult.NoMatrixGateway) + } + + @Test + fun `when a custom url is not acceptable (406), NoMatrixGateway is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = { + throw HttpException(Response.error(HttpURLConnection.HTTP_NOT_ACCEPTABLE, "".toResponseBody())) + } + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("http://custom.url") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("http://custom.url") + assertThat(result).isEqualTo(UnifiedPushGatewayResolverResult.NoMatrixGateway) + } + + @Test + fun `when a custom url is internal error (500), Error is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = { + throw HttpException(Response.error(HttpURLConnection.HTTP_INTERNAL_ERROR, "".toResponseBody())) + } + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("http://custom.url") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("http://custom.url") + assertThat(result).isEqualTo(UnifiedPushGatewayResolverResult.Error("http://custom.url/_matrix/push/v1/notify")) + } + + @Test + fun `when a custom url is invalid, ErrorInvalidUrl is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = matrixDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("invalid") + assertThat(unifiedPushApiFactory.baseUrlParameter).isNull() + assertThat(result).isEqualTo(UnifiedPushGatewayResolverResult.ErrorInvalidUrl) + } + + @Test + fun `when a custom url provides a invalid matrix gateway, NoMatrixGateway is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = invalidDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("https://custom.url") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("https://custom.url") + assertThat(result).isEqualTo(UnifiedPushGatewayResolverResult.NoMatrixGateway) + } + + private fun TestScope.createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory: UnifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = { DiscoveryResponse() } + ) + ) = DefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory, + coroutineDispatchers = testCoroutineDispatchers() + ) +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushGatewayUrlResolverTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushGatewayUrlResolverTest.kt new file mode 100644 index 0000000..3f437bd --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushGatewayUrlResolverTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class DefaultUnifiedPushGatewayUrlResolverTest { + @Test + fun `resolve ErrorInvalidUrl returns the default gateway`() { + val sut = createDefaultUnifiedPushGatewayUrlResolver() + val result = sut.resolve( + gatewayResult = UnifiedPushGatewayResolverResult.ErrorInvalidUrl, + instance = "", + ) + assertThat(result).isEqualTo(A_UNIFIED_PUSH_GATEWAY) + } + + @Test + fun `resolve NoMatrixGateway returns the default gateway`() { + val sut = createDefaultUnifiedPushGatewayUrlResolver() + val result = sut.resolve( + gatewayResult = UnifiedPushGatewayResolverResult.NoMatrixGateway, + instance = "", + ) + assertThat(result).isEqualTo(A_UNIFIED_PUSH_GATEWAY) + } + + @Test + fun `resolve Success returns the url`() { + val sut = createDefaultUnifiedPushGatewayUrlResolver() + val result = sut.resolve( + gatewayResult = UnifiedPushGatewayResolverResult.Success("aUrl"), + instance = "", + ) + assertThat(result).isEqualTo("aUrl") + } + + @Test + fun `resolve Error returns the current url when available`() { + val sut = createDefaultUnifiedPushGatewayUrlResolver( + unifiedPushStore = FakeUnifiedPushStore( + getPushGatewayResult = { instance -> + assertThat(instance).isEqualTo("instance") + "aCurrentUrl" + }, + ) + ) + val result = sut.resolve( + gatewayResult = UnifiedPushGatewayResolverResult.Error("aUrl"), + instance = "instance", + ) + assertThat(result).isEqualTo("aCurrentUrl") + } + + @Test + fun `resolve Error returns the url if no current url is available`() { + val sut = createDefaultUnifiedPushGatewayUrlResolver( + unifiedPushStore = FakeUnifiedPushStore( + getPushGatewayResult = { instance -> + assertThat(instance).isEqualTo("instance") + null + }, + ) + ) + val result = sut.resolve( + gatewayResult = UnifiedPushGatewayResolverResult.Error("aUrl"), + instance = "instance", + ) + assertThat(result).isEqualTo("aUrl") + } + + private fun createDefaultUnifiedPushGatewayUrlResolver( + unifiedPushStore: UnifiedPushStore = FakeUnifiedPushStore(), + defaultPushGatewayHttpUrlProvider: DefaultPushGatewayHttpUrlProvider = FakeDefaultPushGatewayHttpUrlProvider(), + ) = DefaultUnifiedPushGatewayUrlResolver( + unifiedPushStore = unifiedPushStore, + defaultPushGatewayHttpUrlProvider = defaultPushGatewayHttpUrlProvider, + ) +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushNewGatewayHandlerTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushNewGatewayHandlerTest.kt new file mode 100644 index 0000000..d5f2aa8 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushNewGatewayHandlerTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.test.A_SECRET +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.FakeMatrixClientProvider +import io.element.android.libraries.push.test.FakePusherSubscriber +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.libraries.pushstore.test.userpushstore.FakeUserPushStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultUnifiedPushNewGatewayHandlerTest { + @Test + fun `error when fail to retrieve the session`() = runTest { + val defaultUnifiedPushNewGatewayHandler = createDefaultUnifiedPushNewGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { null } + ) + ) + val result = defaultUnifiedPushNewGatewayHandler.handle( + endpoint = "aEndpoint", + pushGateway = "aPushGateway", + clientSecret = A_SECRET, + ) + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + assertThat(result.exceptionOrNull()?.message).isEqualTo("Unable to retrieve session") + } + + @Test + fun `error when the session is not using UnifiedPush`() = runTest { + val defaultUnifiedPushNewGatewayHandler = createDefaultUnifiedPushNewGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { FakeUserPushStore(pushProviderName = "other") } + ) + ) + val result = defaultUnifiedPushNewGatewayHandler.handle( + endpoint = "aEndpoint", + pushGateway = "aPushGateway", + clientSecret = A_SECRET, + ) + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + assertThat(result.exceptionOrNull()?.message).isEqualTo("This session is not using UnifiedPush pusher") + } + + @Test + fun `error when the registration fails`() = runTest { + val aMatrixClient = FakeMatrixClient() + val defaultUnifiedPushNewGatewayHandler = createDefaultUnifiedPushNewGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { FakeUserPushStore(pushProviderName = UnifiedPushConfig.NAME) } + ), + pusherSubscriber = FakePusherSubscriber( + registerPusherResult = { _, _, _ -> Result.failure(IllegalStateException("an error")) } + ), + matrixClientProvider = FakeMatrixClientProvider { Result.success(aMatrixClient) }, + ) + val result = defaultUnifiedPushNewGatewayHandler.handle( + endpoint = "aEndpoint", + pushGateway = "aPushGateway", + clientSecret = A_SECRET, + ) + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + assertThat(result.exceptionOrNull()?.message).isEqualTo("an error") + } + + @Test + fun `happy path`() = runTest { + val aMatrixClient = FakeMatrixClient() + val lambda = lambdaRecorder { _: MatrixClient, _: String, _: String -> + Result.success(Unit) + } + val defaultUnifiedPushNewGatewayHandler = createDefaultUnifiedPushNewGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { FakeUserPushStore(pushProviderName = UnifiedPushConfig.NAME) } + ), + pusherSubscriber = FakePusherSubscriber( + registerPusherResult = lambda + ), + matrixClientProvider = FakeMatrixClientProvider { Result.success(aMatrixClient) }, + ) + val result = defaultUnifiedPushNewGatewayHandler.handle( + endpoint = "aEndpoint", + pushGateway = "aPushGateway", + clientSecret = A_SECRET, + ) + assertThat(result).isEqualTo(Result.success(Unit)) + lambda.assertions() + .isCalledOnce() + .with(value(aMatrixClient), value("aEndpoint"), value("aPushGateway")) + } + + private fun createDefaultUnifiedPushNewGatewayHandler( + pusherSubscriber: PusherSubscriber = FakePusherSubscriber(), + userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(), + pushClientSecret: PushClientSecret = FakePushClientSecret(), + matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider() + ): DefaultUnifiedPushNewGatewayHandler { + return DefaultUnifiedPushNewGatewayHandler( + pusherSubscriber = pusherSubscriber, + userPushStoreFactory = userPushStoreFactory, + pushClientSecret = pushClientSecret, + matrixClientProvider = matrixClientProvider, + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushRemovedGatewayHandlerTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushRemovedGatewayHandlerTest.kt new file mode 100644 index 0000000..2fcaa39 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushRemovedGatewayHandlerTest.kt @@ -0,0 +1,298 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_SECRET +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.FakeMatrixClientProvider +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.test.FakePushService +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.libraries.pushproviders.test.FakePushProvider +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +class DefaultUnifiedPushRemovedGatewayHandlerTest { + @Test + fun `handle returns error if the secret is unknown`() = runTest { + val sut = createDefaultUnifiedPushRemovedGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { null }, + ), + ) + val result = sut.handle(A_SECRET) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `handle returns error if cannot restore the client`() = runTest { + val sut = createDefaultUnifiedPushRemovedGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_SESSION_ID }, + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { Result.failure(AN_EXCEPTION) }, + ), + ) + val result = sut.handle(A_SECRET) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `handle returns error if cannot unregister the pusher, and user is notified`() = runTest { + val onServiceUnregisteredResult = lambdaRecorder { } + val sut = createDefaultUnifiedPushRemovedGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_SESSION_ID }, + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { Result.success(FakeMatrixClient()) }, + ), + unregisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + unregisterLambda = { _, _, _ -> Result.failure(AN_EXCEPTION) }, + ), + pushService = FakePushService( + onServiceUnregisteredResult = onServiceUnregisteredResult, + ), + ) + val result = sut.handle(A_SECRET) + assertThat(result.isFailure).isTrue() + onServiceUnregisteredResult.assertions().isCalledOnce().with(value(A_SESSION_ID)) + } + + @Test + fun `handle returns error if cannot get current push provider, and user is notified`() = runTest { + val onServiceUnregisteredResult = lambdaRecorder { } + val sut = createDefaultUnifiedPushRemovedGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_SESSION_ID }, + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { Result.success(FakeMatrixClient()) }, + ), + unregisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + unregisterLambda = { _, _, _ -> Result.success(Unit) }, + ), + pushService = FakePushService( + currentPushProvider = { null }, + onServiceUnregisteredResult = onServiceUnregisteredResult, + ), + ) + val result = sut.handle(A_SECRET) + assertThat(result.isFailure).isTrue() + onServiceUnregisteredResult.assertions().isCalledOnce().with(value(A_SESSION_ID)) + } + + @Test + fun `handle returns error if cannot get current distributor, and user is notified`() = runTest { + val onServiceUnregisteredResult = lambdaRecorder { } + val sut = createDefaultUnifiedPushRemovedGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_SESSION_ID }, + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { Result.success(FakeMatrixClient()) }, + ), + unregisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + unregisterLambda = { _, _, _ -> Result.success(Unit) }, + ), + pushService = FakePushService( + currentPushProvider = { + FakePushProvider( + currentDistributor = { null }, + ) + }, + onServiceUnregisteredResult = onServiceUnregisteredResult, + ), + ) + val result = sut.handle(A_SECRET) + assertThat(result.isFailure).isTrue() + onServiceUnregisteredResult.assertions().isCalledOnce().with(value(A_SESSION_ID)) + } + + @Test + fun `handle returns error if cannot register again, and user is notified`() = runTest { + val onServiceUnregisteredResult = lambdaRecorder { } + val sut = createDefaultUnifiedPushRemovedGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_SESSION_ID }, + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { Result.success(FakeMatrixClient()) }, + ), + unregisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + unregisterLambda = { _, _, _ -> Result.success(Unit) }, + ), + pushService = FakePushService( + currentPushProvider = { + FakePushProvider( + currentDistributor = { Distributor("aValue", "aName") }, + ) + }, + registerWithLambda = { _, _, _ -> Result.failure(AN_EXCEPTION) }, + onServiceUnregisteredResult = onServiceUnregisteredResult, + ), + ) + val result = sut.handle(A_SECRET) + assertThat(result.isFailure).isTrue() + onServiceUnregisteredResult.assertions().isCalledOnce().with(value(A_SESSION_ID)) + } + + @Test + fun `handle returns success if can register again, and user is not notified`() = runTest { + val onServiceUnregisteredResult = lambdaRecorder { } + val unregisterLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val sut = createDefaultUnifiedPushRemovedGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_SESSION_ID }, + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { Result.success(FakeMatrixClient()) }, + ), + unregisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + unregisterLambda = unregisterLambda, + ), + pushService = FakePushService( + currentPushProvider = { + FakePushProvider( + currentDistributor = { Distributor("aValue", "aName") }, + ) + }, + registerWithLambda = { _, _, _ -> Result.success(Unit) }, + onServiceUnregisteredResult = onServiceUnregisteredResult, + ), + ) + val result = sut.handle(A_SECRET) + assertThat(result.isSuccess).isTrue() + unregisterLambda.assertions().isCalledOnce().with( + any(), + value(A_SECRET), + value(false), + ) + onServiceUnregisteredResult.assertions().isNeverCalled() + } + + @Test + fun `handle returns success if can register again, but after 2 removals user is notified`() = runTest { + val onServiceUnregisteredResult = lambdaRecorder { } + val unregisterLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val registerWithLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val sut = createDefaultUnifiedPushRemovedGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_SESSION_ID }, + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { Result.success(FakeMatrixClient()) }, + ), + unregisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + unregisterLambda = unregisterLambda, + ), + pushService = FakePushService( + currentPushProvider = { + FakePushProvider( + currentDistributor = { Distributor("aValue", "aName") }, + ) + }, + registerWithLambda = registerWithLambda, + onServiceUnregisteredResult = onServiceUnregisteredResult, + ), + ) + val result = sut.handle(A_SECRET) + assertThat(result.isSuccess).isTrue() + unregisterLambda.assertions().isCalledOnce().with( + any(), + value(A_SECRET), + value(false), + ) + registerWithLambda.assertions().isCalledOnce() + onServiceUnregisteredResult.assertions().isNeverCalled() + // Second attempt in less than 1 minute + val result2 = sut.handle(A_SECRET) + assertThat(result2.isFailure).isTrue() + unregisterLambda.assertions().isCalledExactly(2) + // Registration is not called twice + registerWithLambda.assertions().isCalledOnce() + onServiceUnregisteredResult.assertions().isCalledOnce().with(value(A_SESSION_ID)) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `handle returns success if can register again, but after 2 distant removals user is not notified`() = runTest { + val onServiceUnregisteredResult = lambdaRecorder { } + val unregisterLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val registerWithLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val sut = createDefaultUnifiedPushRemovedGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_SESSION_ID }, + ), + matrixClientProvider = FakeMatrixClientProvider( + getClient = { Result.success(FakeMatrixClient()) }, + ), + unregisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + unregisterLambda = unregisterLambda, + ), + pushService = FakePushService( + currentPushProvider = { + FakePushProvider( + currentDistributor = { Distributor("aValue", "aName") }, + ) + }, + registerWithLambda = registerWithLambda, + onServiceUnregisteredResult = onServiceUnregisteredResult, + ), + ) + val result = sut.handle(A_SECRET) + assertThat(result.isSuccess).isTrue() + unregisterLambda.assertions().isCalledOnce().with( + any(), + value(A_SECRET), + value(false), + ) + registerWithLambda.assertions().isCalledOnce() + onServiceUnregisteredResult.assertions().isNeverCalled() + // Second attempt in more than 1 minute + advanceTimeBy(61.seconds) + val result2 = sut.handle(A_SECRET) + assertThat(result2.isSuccess).isTrue() + unregisterLambda.assertions().isCalledExactly(2) + // Registration is not called twice + registerWithLambda.assertions().isCalledExactly(2) + onServiceUnregisteredResult.assertions().isNeverCalled() + } + + private fun TestScope.createDefaultUnifiedPushRemovedGatewayHandler( + unregisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase(), + pushClientSecret: PushClientSecret = FakePushClientSecret(), + matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(), + pushService: PushService = FakePushService(), + ) = DefaultUnifiedPushRemovedGatewayHandler( + unregisterUnifiedPushUseCase = unregisterUnifiedPushUseCase, + pushClientSecret = pushClientSecret, + matrixClientProvider = matrixClientProvider, + pushService = pushService, + unifiedPushRemovedGatewayThrottler = UnifiedPushRemovedGatewayThrottler( + appCoroutineScope = backgroundScope, + ), + ) +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnregisterUnifiedPushUseCaseTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnregisterUnifiedPushUseCaseTest.kt new file mode 100644 index 0000000..ed2337c --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnregisterUnifiedPushUseCaseTest.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.push.test.FakePusherSubscriber +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultUnregisterUnifiedPushUseCaseTest { + @Test + fun `test un registration successful`() = runTest { + val lambda = lambdaRecorder { _: MatrixClient, _: String, _: String -> Result.success(Unit) } + val storeUpEndpointResult = lambdaRecorder { _: String, _: String? -> } + val storePushGatewayResult = lambdaRecorder { _: String, _: String? -> } + val matrixClient = FakeMatrixClient() + val useCase = createDefaultUnregisterUnifiedPushUseCase( + unifiedPushStore = FakeUnifiedPushStore( + getEndpointResult = { "aEndpoint" }, + getPushGatewayResult = { "aGateway" }, + storeUpEndpointResult = storeUpEndpointResult, + storePushGatewayResult = storePushGatewayResult, + ), + pusherSubscriber = FakePusherSubscriber( + unregisterPusherResult = lambda + ) + ) + val result = useCase.unregister(matrixClient, A_SECRET) + assertThat(result.isSuccess).isTrue() + lambda.assertions() + .isCalledOnce() + .with(value(matrixClient), value("aEndpoint"), value("aGateway")) + storeUpEndpointResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + storePushGatewayResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + } + + @Test + fun `test un registration error - no endpoint - will not unregister but return success`() = runTest { + val matrixClient = FakeMatrixClient() + val storeUpEndpointResult = lambdaRecorder { _: String, _: String? -> } + val storePushGatewayResult = lambdaRecorder { _: String, _: String? -> } + val useCase = createDefaultUnregisterUnifiedPushUseCase( + unifiedPushStore = FakeUnifiedPushStore( + getEndpointResult = { null }, + getPushGatewayResult = { "aGateway" }, + storeUpEndpointResult = storeUpEndpointResult, + storePushGatewayResult = storePushGatewayResult, + ), + ) + val result = useCase.unregister(matrixClient, A_SECRET) + assertThat(result.isSuccess).isTrue() + storeUpEndpointResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + storePushGatewayResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + } + + @Test + fun `test un registration error - no gateway - will not unregister but return success`() = runTest { + val matrixClient = FakeMatrixClient() + val storeUpEndpointResult = lambdaRecorder { _: String, _: String? -> } + val storePushGatewayResult = lambdaRecorder { _: String, _: String? -> } + val useCase = createDefaultUnregisterUnifiedPushUseCase( + unifiedPushStore = FakeUnifiedPushStore( + getEndpointResult = { "aEndpoint" }, + getPushGatewayResult = { null }, + storeUpEndpointResult = storeUpEndpointResult, + storePushGatewayResult = storePushGatewayResult, + ), + ) + val result = useCase.unregister(matrixClient, A_SECRET) + assertThat(result.isSuccess).isTrue() + storeUpEndpointResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + storePushGatewayResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + } + + @Test + fun `test un registration error`() = runTest { + val matrixClient = FakeMatrixClient() + val useCase = createDefaultUnregisterUnifiedPushUseCase( + unifiedPushStore = FakeUnifiedPushStore( + getEndpointResult = { "aEndpoint" }, + getPushGatewayResult = { "aGateway" }, + ), + pusherSubscriber = FakePusherSubscriber( + unregisterPusherResult = { _, _, _ -> Result.failure(AN_EXCEPTION) } + ) + ) + val result = useCase.unregister(matrixClient, A_SECRET) + assertThat(result.isFailure).isTrue() + } + + private fun createDefaultUnregisterUnifiedPushUseCase( + unifiedPushStore: UnifiedPushStore = FakeUnifiedPushStore(), + pusherSubscriber: PusherSubscriber = FakePusherSubscriber() + ): DefaultUnregisterUnifiedPushUseCase { + val context = InstrumentationRegistry.getInstrumentation().context + return DefaultUnregisterUnifiedPushUseCase( + context = context, + unifiedPushStore = unifiedPushStore, + pusherSubscriber = pusherSubscriber + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeDefaultPushGatewayHttpUrlProvider.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeDefaultPushGatewayHttpUrlProvider.kt new file mode 100644 index 0000000..288fd30 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeDefaultPushGatewayHttpUrlProvider.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +const val A_UNIFIED_PUSH_GATEWAY = "aGateway" + +class FakeDefaultPushGatewayHttpUrlProvider( + private val provideResult: () -> String = { A_UNIFIED_PUSH_GATEWAY } +) : DefaultPushGatewayHttpUrlProvider { + override fun provide(): String { + return provideResult() + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeRegisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeRegisterUnifiedPushUseCase.kt new file mode 100644 index 0000000..3055dad --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeRegisterUnifiedPushUseCase.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeRegisterUnifiedPushUseCase( + private val result: (Distributor, String) -> Result = { _, _ -> lambdaError() } +) : RegisterUnifiedPushUseCase { + override suspend fun execute(distributor: Distributor, clientSecret: String): Result { + return result(distributor, clientSecret) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushApiFactory.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushApiFactory.kt new file mode 100644 index 0000000..5bd9dfc --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushApiFactory.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import io.element.android.libraries.pushproviders.unifiedpush.network.DiscoveryResponse +import io.element.android.libraries.pushproviders.unifiedpush.network.UnifiedPushApi + +class FakeUnifiedPushApiFactory( + private val discoveryResponse: () -> DiscoveryResponse +) : UnifiedPushApiFactory { + var baseUrlParameter: String? = null + private set + + override fun create(baseUrl: String): UnifiedPushApi { + baseUrlParameter = baseUrl + return FakeUnifiedPushApi(discoveryResponse) + } +} + +class FakeUnifiedPushApi( + private val discoveryResponse: () -> DiscoveryResponse +) : UnifiedPushApi { + override suspend fun discover(): DiscoveryResponse { + return discoveryResponse() + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushGatewayResolver.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushGatewayResolver.kt new file mode 100644 index 0000000..f4578c0 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushGatewayResolver.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeUnifiedPushGatewayResolver( + private val getGatewayResult: (String) -> UnifiedPushGatewayResolverResult = { lambdaError() }, +) : UnifiedPushGatewayResolver { + override suspend fun getGateway(endpoint: String): UnifiedPushGatewayResolverResult { + return getGatewayResult(endpoint) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushGatewayUrlResolver.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushGatewayUrlResolver.kt new file mode 100644 index 0000000..b02e0c3 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushGatewayUrlResolver.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeUnifiedPushGatewayUrlResolver( + private val resolveResult: (UnifiedPushGatewayResolverResult, String) -> String = { _, _ -> lambdaError() }, +) : UnifiedPushGatewayUrlResolver { + override fun resolve(gatewayResult: UnifiedPushGatewayResolverResult, instance: String): String { + return resolveResult(gatewayResult, instance) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushNewGatewayHandler.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushNewGatewayHandler.kt new file mode 100644 index 0000000..9bfef75 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushNewGatewayHandler.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeUnifiedPushNewGatewayHandler( + private val handleResult: (String, String, String) -> Result = { _, _, _ -> lambdaError() }, +) : UnifiedPushNewGatewayHandler { + override suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String): Result { + return handleResult(endpoint, pushGateway, clientSecret) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushStore.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushStore.kt new file mode 100644 index 0000000..ff1c3ef --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushStore.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeUnifiedPushStore( + private val getEndpointResult: (String) -> String? = { lambdaError() }, + private val storeUpEndpointResult: (String, String?) -> Unit = { _, _ -> lambdaError() }, + private val getPushGatewayResult: (String) -> String? = { lambdaError() }, + private val storePushGatewayResult: (String, String?) -> Unit = { _, _ -> lambdaError() }, + private val getDistributorValueResult: (UserId) -> String? = { lambdaError() }, + private val setDistributorValueResult: (UserId, String) -> Unit = { _, _ -> lambdaError() }, +) : UnifiedPushStore { + override fun getEndpoint(clientSecret: String): String? { + return getEndpointResult(clientSecret) + } + + override fun storeUpEndpoint(clientSecret: String, endpoint: String?) { + storeUpEndpointResult(clientSecret, endpoint) + } + + override fun getPushGateway(clientSecret: String): String? { + return getPushGatewayResult(clientSecret) + } + + override fun storePushGateway(clientSecret: String, gateway: String?) { + storePushGatewayResult(clientSecret, gateway) + } + + override fun getDistributorValue(userId: UserId): String? { + return getDistributorValueResult(userId) + } + + override fun setDistributorValue(userId: UserId, value: String) { + setDistributorValueResult(userId, value) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnregisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnregisterUnifiedPushUseCase.kt new file mode 100644 index 0000000..182bb5f --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnregisterUnifiedPushUseCase.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeUnregisterUnifiedPushUseCase( + private val unregisterLambda: (MatrixClient, String, Boolean) -> Result = { _, _, _ -> lambdaError() }, + private val cleanupLambda: (String, Boolean) -> Unit = { _, _ -> lambdaError() }, +) : UnregisterUnifiedPushUseCase { + override suspend fun unregister( + matrixClient: MatrixClient, + clientSecret: String, + unregisterUnifiedPush: Boolean, + ): Result { + return unregisterLambda(matrixClient, clientSecret, unregisterUnifiedPush) + } + + override fun cleanup( + clientSecret: String, + unregisterUnifiedPush: Boolean, + ) { + cleanupLambda(clientSecret, unregisterUnifiedPush) + } +} 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 0000000..4b057e9 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParserTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.json.DefaultJsonProvider +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 = createUnifiedPushParser() + // 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 = createUnifiedPushParser() + assertThat(pushParser.parse(UNIFIED_PUSH_DATA.toByteArray(), aClientSecret)).isEqualTo(validData) + } + + @Test + fun `test empty roomId`() { + val pushParser = createUnifiedPushParser() + assertThrowsInDebug { + pushParser.parse(UNIFIED_PUSH_DATA.replace(A_ROOM_ID.value, "").toByteArray(), aClientSecret) + } + } + + @Test + fun `test invalid roomId`() { + val pushParser = createUnifiedPushParser() + assertThrowsInDebug { + pushParser.parse(UNIFIED_PUSH_DATA.mutate(A_ROOM_ID.value, "aRoomId:domain"), aClientSecret) + } + } + + @Test + fun `test empty eventId`() { + val pushParser = createUnifiedPushParser() + assertThrowsInDebug { + pushParser.parse(UNIFIED_PUSH_DATA.mutate(AN_EVENT_ID.value, ""), aClientSecret) + } + } + + @Test + fun `test invalid eventId`() { + val pushParser = createUnifiedPushParser() + assertThrowsInDebug { + pushParser.parse(UNIFIED_PUSH_DATA.mutate(AN_EVENT_ID.value, "anEventId"), aClientSecret) + } + } + + companion object { + val UNIFIED_PUSH_DATA = + "{\"notification\":{\"event_id\":\"$AN_EVENT_ID\",\"room_id\":\"$A_ROOM_ID\",\"counts\":{\"unread\":1},\"prio\":\"high\"}}" + } +} + +private fun String.mutate(oldValue: String, newValue: String): ByteArray { + return replace(oldValue, newValue).toByteArray() +} + +fun createUnifiedPushParser() = UnifiedPushParser( + json = DefaultJsonProvider(), +) diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProviderTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProviderTest.kt new file mode 100644 index 0000000..e22c1c3 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProviderTest.kt @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.test.aSessionPushConfig +import io.element.android.libraries.pushproviders.unifiedpush.troubleshoot.FakeUnifiedPushDistributorProvider +import io.element.android.libraries.pushproviders.unifiedpush.troubleshoot.FakeUnifiedPushSessionPushConfigProvider +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class UnifiedPushProviderTest { + @Test + fun `test index and name`() { + val unifiedPushProvider = createUnifiedPushProvider() + assertThat(unifiedPushProvider.name).isEqualTo(UnifiedPushConfig.NAME) + assertThat(unifiedPushProvider.index).isEqualTo(UnifiedPushConfig.INDEX) + } + + @Test + fun `getDistributors return the available distributors`() { + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = listOf( + Distributor("value", "Name"), + ) + ) + ) + val result = unifiedPushProvider.getDistributors() + assertThat(result).containsExactly(Distributor("value", "Name")) + } + + @Test + fun `getDistributors return empty`() { + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = emptyList() + ) + ) + val result = unifiedPushProvider.getDistributors() + assertThat(result).isEmpty() + } + + @Test + fun `register ok`() = runTest { + val getSecretForUserResultLambda = lambdaRecorder { A_SECRET } + val executeLambda = lambdaRecorder> { _, _ -> Result.success(Unit) } + val setDistributorValueResultLambda = lambdaRecorder { _, _ -> } + val unifiedPushProvider = createUnifiedPushProvider( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = getSecretForUserResultLambda, + ), + registerUnifiedPushUseCase = FakeRegisterUnifiedPushUseCase( + result = executeLambda, + ), + unifiedPushStore = FakeUnifiedPushStore( + setDistributorValueResult = setDistributorValueResultLambda, + ), + ) + val result = unifiedPushProvider.registerWith(FakeMatrixClient(), Distributor("value", "Name")) + assertThat(result).isEqualTo(Result.success(Unit)) + getSecretForUserResultLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID)) + executeLambda.assertions() + .isCalledOnce() + .with(value(Distributor("value", "Name")), value(A_SECRET)) + setDistributorValueResultLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID), value("value")) + } + + @Test + fun `register ko`() = runTest { + val getSecretForUserResultLambda = lambdaRecorder { A_SECRET } + val executeLambda = lambdaRecorder> { _, _ -> Result.failure(AN_EXCEPTION) } + val setDistributorValueResultLambda = lambdaRecorder(ensureNeverCalled = true) { _, _ -> } + val unifiedPushProvider = createUnifiedPushProvider( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = getSecretForUserResultLambda, + ), + registerUnifiedPushUseCase = FakeRegisterUnifiedPushUseCase( + result = executeLambda, + ), + unifiedPushStore = FakeUnifiedPushStore( + setDistributorValueResult = setDistributorValueResultLambda, + ), + ) + val result = unifiedPushProvider.registerWith(FakeMatrixClient(), Distributor("value", "Name")) + assertThat(result).isEqualTo(Result.failure(AN_EXCEPTION)) + getSecretForUserResultLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID)) + executeLambda.assertions() + .isCalledOnce() + .with(value(Distributor("value", "Name")), value(A_SECRET)) + } + + @Test + fun `unregister ok`() = runTest { + val matrixClient = FakeMatrixClient() + val getSecretForUserResultLambda = lambdaRecorder { A_SECRET } + val unregisterLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val unifiedPushProvider = createUnifiedPushProvider( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = getSecretForUserResultLambda, + ), + unRegisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + unregisterLambda = unregisterLambda, + ), + ) + val result = unifiedPushProvider.unregister(matrixClient) + assertThat(result).isEqualTo(Result.success(Unit)) + getSecretForUserResultLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID)) + unregisterLambda.assertions() + .isCalledOnce() + .with(value(matrixClient), value(A_SECRET), value(true)) + } + + @Test + fun `unregister ko`() = runTest { + val matrixClient = FakeMatrixClient() + val getSecretForUserResultLambda = lambdaRecorder { A_SECRET } + val unregisterLambda = lambdaRecorder> { _, _, _ -> Result.failure(AN_EXCEPTION) } + val unifiedPushProvider = createUnifiedPushProvider( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = getSecretForUserResultLambda, + ), + unRegisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + unregisterLambda = unregisterLambda, + ), + ) + val result = unifiedPushProvider.unregister(matrixClient) + assertThat(result).isEqualTo(Result.failure(AN_EXCEPTION)) + getSecretForUserResultLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID)) + unregisterLambda.assertions() + .isCalledOnce() + .with(value(matrixClient), value(A_SECRET), value(true)) + } + + @Test + fun `getCurrentDistributor ok`() = runTest { + val distributor = Distributor("value", "Name") + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushStore = FakeUnifiedPushStore( + getDistributorValueResult = { distributor.value } + ), + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = listOf( + Distributor("value2", "Name2"), + distributor, + ) + ) + ) + val result = unifiedPushProvider.getCurrentDistributor(A_SESSION_ID) + assertThat(result).isEqualTo(distributor) + } + + @Test + fun `getCurrentDistributor not know`() = runTest { + val distributor = Distributor("value", "Name") + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushStore = FakeUnifiedPushStore( + getDistributorValueResult = { "unknown" } + ), + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = listOf( + distributor, + ) + ) + ) + val result = unifiedPushProvider.getCurrentDistributor(A_SESSION_ID) + assertThat(result).isNull() + } + + @Test + fun `getCurrentDistributor not found`() = runTest { + val distributor = Distributor("value", "Name") + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushStore = FakeUnifiedPushStore( + getDistributorValueResult = { distributor.value } + ), + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = emptyList() + ) + ) + val result = unifiedPushProvider.getCurrentDistributor(A_SESSION_ID) + assertThat(result).isNull() + } + + @Test + fun `getCurrentUserPushConfig invokes the provider methods`() = runTest { + val currentUserPushConfig = aSessionPushConfig() + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushSessionPushConfigProvider = FakeUnifiedPushSessionPushConfigProvider( + config = { currentUserPushConfig } + ) + ) + val result = unifiedPushProvider.getPushConfig(A_SESSION_ID) + assertThat(result).isEqualTo(currentUserPushConfig) + } + + @Test + fun `canRotateToken should return false`() = runTest { + val unifiedPushProvider = createUnifiedPushProvider() + assertThat(unifiedPushProvider.canRotateToken()).isFalse() + } + + @Test + fun `onSessionDeleted should do the cleanup`() = runTest { + val cleanupLambda = lambdaRecorder { _, _ -> } + val unifiedPushProvider = createUnifiedPushProvider( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET } + ), + unRegisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + cleanupLambda = cleanupLambda, + ), + ) + unifiedPushProvider.onSessionDeleted(A_SESSION_ID) + cleanupLambda.assertions().isCalledOnce().with(value(A_SECRET), value(true)) + } + + private fun createUnifiedPushProvider( + unifiedPushDistributorProvider: UnifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider(), + registerUnifiedPushUseCase: RegisterUnifiedPushUseCase = FakeRegisterUnifiedPushUseCase(), + unRegisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase(), + pushClientSecret: PushClientSecret = FakePushClientSecret(), + unifiedPushStore: UnifiedPushStore = FakeUnifiedPushStore(), + unifiedPushSessionPushConfigProvider: UnifiedPushSessionPushConfigProvider = FakeUnifiedPushSessionPushConfigProvider(), + ): UnifiedPushProvider { + return UnifiedPushProvider( + unifiedPushDistributorProvider = unifiedPushDistributorProvider, + registerUnifiedPushUseCase = registerUnifiedPushUseCase, + unRegisterUnifiedPushUseCase = unRegisterUnifiedPushUseCase, + pushClientSecret = pushClientSecret, + unifiedPushStore = unifiedPushStore, + unifiedPushSessionPushConfigProvider = unifiedPushSessionPushConfigProvider, + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt new file mode 100644 index 0000000..f10f643 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.pushproviders.unifiedpush + +import android.content.Intent +import androidx.test.platform.app.InstrumentationRegistry +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_EVENT_ID +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_SECRET +import io.element.android.libraries.push.test.test.FakePushHandler +import io.element.android.libraries.pushproviders.api.PushData +import io.element.android.libraries.pushproviders.api.PushHandler +import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler +import io.element.android.libraries.pushproviders.unifiedpush.registration.RegistrationResult +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.data.PublicKeySet +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage + +@RunWith(RobolectricTestRunner::class) +class VectorUnifiedPushMessagingReceiverTest { + @Test + fun `onReceive does the binding`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver() + // The binding is not found in the test env. + assertThrows(IllegalStateException::class.java) { + vectorUnifiedPushMessagingReceiver.onReceive(context, Intent()) + } + } + + @Test + fun `onUnregistered invokes the removedGatewayHandler`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val handleResult = lambdaRecorder> { + Result.success(Unit) + } + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver( + removedGatewayHandler = UnifiedPushRemovedGatewayHandler { handleResult(it) }, + ) + vectorUnifiedPushMessagingReceiver.onUnregistered(context, A_SECRET) + advanceUntilIdle() + handleResult.assertions().isCalledOnce().with(value(A_SECRET)) + } + + @Test + fun `onRegistrationFailed does nothing`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver() + vectorUnifiedPushMessagingReceiver.onRegistrationFailed(context, FailedReason.NETWORK, A_SECRET) + } + + @Test + fun `onMessage valid invokes the push handler`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val pushHandlerResult = lambdaRecorder { _, _ -> } + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver( + pushHandler = FakePushHandler( + handleResult = pushHandlerResult + ), + ) + vectorUnifiedPushMessagingReceiver.onMessage(context, aPushMessage(), A_SECRET) + advanceUntilIdle() + pushHandlerResult.assertions() + .isCalledOnce() + .with( + value( + PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 1, + clientSecret = A_SECRET + ) + ), + value( + UnifiedPushConfig.NAME + " - " + A_SECRET + ) + ) + } + + @Test + fun `onMessage invalid invokes the push handler invalid method`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val handleInvalidResult = lambdaRecorder { _, _ -> } + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver( + pushHandler = FakePushHandler( + handleInvalidResult = handleInvalidResult, + ), + ) + vectorUnifiedPushMessagingReceiver.onMessage(context, aPushMessage(""), A_SECRET) + advanceUntilIdle() + handleInvalidResult.assertions().isCalledOnce() + } + + @Test + fun `onNewEndpoint run the expected tasks`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val storePushGatewayResult = lambdaRecorder { _, _ -> } + val storeUpEndpointResult = lambdaRecorder { _, _ -> } + val unifiedPushStore = FakeUnifiedPushStore( + storePushGatewayResult = storePushGatewayResult, + storeUpEndpointResult = storeUpEndpointResult, + ) + val endpointRegistrationHandler = EndpointRegistrationHandler() + val handleResult = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val unifiedPushNewGatewayHandler = FakeUnifiedPushNewGatewayHandler( + handleResult = handleResult + ) + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver( + unifiedPushStore = unifiedPushStore, + unifiedPushGatewayResolver = FakeUnifiedPushGatewayResolver( + getGatewayResult = { UnifiedPushGatewayResolverResult.Success("aGateway") } + ), + unifiedPushGatewayUrlResolver = FakeUnifiedPushGatewayUrlResolver( + resolveResult = { _, _ -> "aGatewayUrl" } + ), + endpointRegistrationHandler = endpointRegistrationHandler, + unifiedPushNewGatewayHandler = unifiedPushNewGatewayHandler, + ) + endpointRegistrationHandler.state.test { + vectorUnifiedPushMessagingReceiver.onNewEndpoint(context, aPushEndpoint("anEndpoint"), A_SECRET) + advanceUntilIdle() + assertThat(awaitItem()).isEqualTo( + RegistrationResult( + clientSecret = A_SECRET, + result = Result.success(Unit) + ) + ) + } + storePushGatewayResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value("aGatewayUrl")) + storeUpEndpointResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value("anEndpoint")) + } + + @Test + fun `onNewEndpoint, if registration fails, the endpoint should not be stored`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val storePushGatewayResult = lambdaRecorder { _, _ -> } + val storeUpEndpointResult = lambdaRecorder { _, _ -> } + val unifiedPushStore = FakeUnifiedPushStore( + storePushGatewayResult = storePushGatewayResult, + storeUpEndpointResult = storeUpEndpointResult, + ) + val endpointRegistrationHandler = EndpointRegistrationHandler() + val handleResult = lambdaRecorder> { _, _, _ -> Result.failure(AN_EXCEPTION) } + val unifiedPushNewGatewayHandler = FakeUnifiedPushNewGatewayHandler( + handleResult = handleResult + ) + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver( + unifiedPushStore = unifiedPushStore, + unifiedPushGatewayResolver = FakeUnifiedPushGatewayResolver( + getGatewayResult = { UnifiedPushGatewayResolverResult.Success("aGateway") } + ), + unifiedPushGatewayUrlResolver = FakeUnifiedPushGatewayUrlResolver( + resolveResult = { _, _ -> "aGatewayUrl" } + ), + endpointRegistrationHandler = endpointRegistrationHandler, + unifiedPushNewGatewayHandler = unifiedPushNewGatewayHandler, + ) + endpointRegistrationHandler.state.test { + vectorUnifiedPushMessagingReceiver.onNewEndpoint(context, aPushEndpoint(), A_SECRET) + advanceUntilIdle() + assertThat(awaitItem()).isEqualTo( + RegistrationResult( + clientSecret = A_SECRET, + result = Result.failure(AN_EXCEPTION) + ) + ) + } + storePushGatewayResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value("aGatewayUrl")) + storeUpEndpointResult.assertions() + .isNeverCalled() + } + + private fun TestScope.createVectorUnifiedPushMessagingReceiver( + unifiedPushParser: UnifiedPushParser = createUnifiedPushParser(), + pushHandler: PushHandler = FakePushHandler(), + unifiedPushStore: UnifiedPushStore = FakeUnifiedPushStore(), + unifiedPushGatewayResolver: UnifiedPushGatewayResolver = FakeUnifiedPushGatewayResolver(), + unifiedPushGatewayUrlResolver: UnifiedPushGatewayUrlResolver = FakeUnifiedPushGatewayUrlResolver(), + unifiedPushNewGatewayHandler: UnifiedPushNewGatewayHandler = FakeUnifiedPushNewGatewayHandler(), + endpointRegistrationHandler: EndpointRegistrationHandler = EndpointRegistrationHandler(), + removedGatewayHandler: UnifiedPushRemovedGatewayHandler = UnifiedPushRemovedGatewayHandler { lambdaError() }, + ): VectorUnifiedPushMessagingReceiver { + return VectorUnifiedPushMessagingReceiver().apply { + this.pushParser = unifiedPushParser + this.pushHandler = pushHandler + this.guardServiceStarter = NoopGuardServiceStarter() + this.unifiedPushStore = unifiedPushStore + this.unifiedPushGatewayResolver = unifiedPushGatewayResolver + this.unifiedPushGatewayUrlResolver = unifiedPushGatewayUrlResolver + this.newGatewayHandler = unifiedPushNewGatewayHandler + this.removedGatewayHandler = removedGatewayHandler + this.endpointRegistrationHandler = endpointRegistrationHandler + this.coroutineScope = this@createVectorUnifiedPushMessagingReceiver + } + } +} + +private fun aPushMessage( + data: String = UnifiedPushParserTest.UNIFIED_PUSH_DATA, + decrypted: Boolean = true, +) = PushMessage( + content = data.toByteArray(), + decrypted = decrypted, +) + +private fun aPushEndpoint( + url: String = "anEndpoint", + pubKeySet: PublicKeySet? = null, + temporary: Boolean = false, +) = PushEndpoint( + url = url, + pubKeySet = pubKeySet, + temporary = temporary, +) diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/FakeOpenDistributorWebPageAction.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/FakeOpenDistributorWebPageAction.kt new file mode 100644 index 0000000..7ef5dbe --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/FakeOpenDistributorWebPageAction.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush.troubleshoot + +class FakeOpenDistributorWebPageAction( + private val executeAction: () -> Unit = {} +) : OpenDistributorWebPageAction { + override fun execute() = executeAction() +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/FakeUnifiedPushDistributorProvider.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/FakeUnifiedPushDistributorProvider.kt new file mode 100644 index 0000000..8ee80c7 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/FakeUnifiedPushDistributorProvider.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush.troubleshoot + +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.unifiedpush.UnifiedPushDistributorProvider + +class FakeUnifiedPushDistributorProvider( + private var getDistributorsResult: List = emptyList() +) : UnifiedPushDistributorProvider { + override fun getDistributors(): List { + return getDistributorsResult + } + + fun setDistributorsResult(list: List) { + getDistributorsResult = list + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/FakeUnifiedPushSessionPushConfigProvider.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/FakeUnifiedPushSessionPushConfigProvider.kt new file mode 100644 index 0000000..8d5d58e --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/FakeUnifiedPushSessionPushConfigProvider.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush.troubleshoot + +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushproviders.api.Config +import io.element.android.libraries.pushproviders.unifiedpush.UnifiedPushSessionPushConfigProvider +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeUnifiedPushSessionPushConfigProvider( + private val config: (SessionId) -> Config? = { lambdaError() }, +) : UnifiedPushSessionPushConfigProvider { + override suspend fun provide(sessionId: SessionId): Config? { + return config(sessionId) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushMatrixGatewayTestTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushMatrixGatewayTestTest.kt new file mode 100644 index 0000000..f10715f --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushMatrixGatewayTestTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush.troubleshoot + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.pushproviders.api.Config +import io.element.android.libraries.pushproviders.test.aSessionPushConfig +import io.element.android.libraries.pushproviders.unifiedpush.FakeUnifiedPushApiFactory +import io.element.android.libraries.pushproviders.unifiedpush.UnifiedPushConfig +import io.element.android.libraries.pushproviders.unifiedpush.invalidDiscoveryResponse +import io.element.android.libraries.pushproviders.unifiedpush.matrixDiscoveryResponse +import io.element.android.libraries.pushproviders.unifiedpush.network.DiscoveryResponse +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.api.test.TestFilterData +import io.element.android.libraries.troubleshoot.test.runAndTestState +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class UnifiedPushMatrixGatewayTestTest { + @Test + fun `test UnifiedPushMatrixGatewayTest success`() = runTest { + val sut = createUnifiedPushMatrixGatewayTest( + config = aSessionPushConfig(), + discoveryResponse = matrixDiscoveryResponse, + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + } + } + + @Test + fun `test UnifiedPushMatrixGatewayTest no config found`() = runTest { + val sut = createUnifiedPushMatrixGatewayTest( + config = null, + discoveryResponse = matrixDiscoveryResponse, + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure()) + } + } + + @Test + fun `test UnifiedPushMatrixGatewayTest not valid gateway`() = runTest { + val sut = createUnifiedPushMatrixGatewayTest( + config = aSessionPushConfig(), + discoveryResponse = invalidDiscoveryResponse, + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure()) + // Reset the error + sut.reset() + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + } + } + + @Test + fun `test UnifiedPushMatrixGatewayTest network error`() = runTest { + val sut = createUnifiedPushMatrixGatewayTest( + config = aSessionPushConfig(), + discoveryResponse = { error("Network error") }, + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure()) + } + } + + @Test + fun `test isRelevant`() = runTest { + val sut = createUnifiedPushMatrixGatewayTest() + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = UnifiedPushConfig.NAME))).isTrue() + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = "other"))).isFalse() + } + + private fun TestScope.createUnifiedPushMatrixGatewayTest( + sessionId: SessionId = A_SESSION_ID, + config: Config? = null, + discoveryResponse: () -> DiscoveryResponse = matrixDiscoveryResponse, + ): UnifiedPushMatrixGatewayTest { + return UnifiedPushMatrixGatewayTest( + sessionId = sessionId, + unifiedPushApiFactory = FakeUnifiedPushApiFactory(discoveryResponse), + coroutineDispatchers = testCoroutineDispatchers(), + unifiedPushSessionPushConfigProvider = FakeUnifiedPushSessionPushConfigProvider( + config = { config } + ), + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTestTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTestTest.kt new file mode 100644 index 0000000..2804f53 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTestTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushproviders.unifiedpush.troubleshoot + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.unifiedpush.UnifiedPushConfig +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.api.test.TestFilterData +import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleshootNavigator +import io.element.android.libraries.troubleshoot.test.runAndTestState +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class UnifiedPushTestTest { + @Test + fun `test UnifiedPushTest success`() = runTest { + val sut = UnifiedPushTest( + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = listOf( + Distributor("value", "Name"), + ) + ), + openDistributorWebPageAction = FakeOpenDistributorWebPageAction(), + stringProvider = FakeStringProvider(), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + } + } + + @Test + fun `test UnifiedPushTest error`() = runTest { + val providers = FakeUnifiedPushDistributorProvider() + val sut = UnifiedPushTest( + unifiedPushDistributorProvider = providers, + openDistributorWebPageAction = FakeOpenDistributorWebPageAction( + executeAction = { + providers.setDistributorsResult( + listOf( + Distributor("value", "Name"), + ) + ) + } + ), + stringProvider = FakeStringProvider(), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(hasQuickFix = true)) + // Quick fix + backgroundScope.launch { + sut.quickFix(this, FakeNotificationTroubleshootNavigator()) + sut.run(this) + } + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Success) + } + } + + @Test + fun `test UnifiedPushTest error and reset`() = runTest { + val providers = FakeUnifiedPushDistributorProvider() + val sut = UnifiedPushTest( + unifiedPushDistributorProvider = providers, + openDistributorWebPageAction = FakeOpenDistributorWebPageAction( + executeAction = { + providers.setDistributorsResult( + listOf( + Distributor("value", "Name"), + ) + ) + } + ), + stringProvider = FakeStringProvider(), + ) + sut.runAndTestState { + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.InProgress) + val lastItem = awaitItem() + assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(hasQuickFix = true)) + sut.reset() + assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Idle(false)) + } + } + + @Test + fun `test isRelevant`() { + val sut = UnifiedPushTest( + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider(), + openDistributorWebPageAction = FakeOpenDistributorWebPageAction(), + stringProvider = FakeStringProvider(), + ) + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = UnifiedPushConfig.NAME))).isTrue() + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = "other"))).isFalse() + } +} diff --git a/libraries/pushstore/api/build.gradle.kts b/libraries/pushstore/api/build.gradle.kts new file mode 100644 index 0000000..a495a4e --- /dev/null +++ b/libraries/pushstore/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.pushstore.api" +} + +dependencies { + implementation(libs.coroutines.core) + 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 0000000..8a1b2f9 --- /dev/null +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushstore.api +import kotlinx.coroutines.flow.Flow + +/** + * 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?) + + fun getNotificationEnabledForDevice(): Flow + suspend fun setNotificationEnabledForDevice(enabled: Boolean) + + fun ignoreRegistrationError(): Flow + suspend fun setIgnoreRegistrationError(ignore: 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 0000000..78571d8 --- /dev/null +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStoreFactory.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 getOrCreate(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 0000000..da3ea94 --- /dev/null +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecret.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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? +} 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 0000000..d7ea7d5 --- /dev/null +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretFactory.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..ecb87b2 --- /dev/null +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretStore.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..4e643fd --- /dev/null +++ b/libraries/pushstore/impl/build.gradle.kts @@ -0,0 +1,46 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.push.pushstore.impl" + + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["clearPackageData"] = "true" + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.preferences.api) + implementation(projects.libraries.pushstore.api) + implementation(libs.androidx.corektx) + implementation(libs.androidx.datastore.preferences) + + testCommonDependencies(libs) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.pushstore.test) + + androidTestImplementation(libs.coroutines.test) + androidTestImplementation(libs.test.core) + androidTestImplementation(libs.test.junit) + androidTestImplementation(libs.test.truth) + androidTestImplementation(libs.test.runner) +} diff --git a/libraries/pushstore/impl/src/androidTest/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactoryTest.kt b/libraries/pushstore/impl/src/androidTest/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactoryTest.kt new file mode 100644 index 0000000..a3b31ac --- /dev/null +++ b/libraries/pushstore/impl/src/androidTest/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactoryTest.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushstore.impl + +import androidx.test.platform.app.InstrumentationRegistry +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushstore.api.UserPushStore +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Test +import kotlin.concurrent.thread + +/** + * Note: to clear the emulator, invoke: + * adb uninstall io.element.android.libraries.push.pushstore.impl.test + */ +class DefaultUserPushStoreFactoryTest { + /** + * Ensure that creating UserPushStore is thread safe. + */ + @Test + fun testParallelCreation() { + val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + val sessionId = SessionId("@alice:server.org") + val userPushStoreFactory = DefaultUserPushStoreFactory(context) + var userPushStore1: UserPushStore? = null + val thread1 = thread { + userPushStore1 = userPushStoreFactory.getOrCreate(sessionId) + } + var userPushStore2: UserPushStore? = null + val thread2 = thread { + userPushStore2 = userPushStoreFactory.getOrCreate(sessionId) + } + thread1.join() + thread2.join() + runBlocking { + userPushStore1!!.getNotificationEnabledForDevice().first() + userPushStore2!!.getNotificationEnabledForDevice().first() + } + } +} 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 0000000..0690fbc --- /dev/null +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactory.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushstore.impl + +import android.content.Context +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import io.element.android.libraries.pushstore.api.UserPushStore +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import java.util.concurrent.ConcurrentHashMap + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultUserPushStoreFactory( + @ApplicationContext private val context: Context, + private val preferenceDataStoreFactory: PreferenceDataStoreFactory, +) : UserPushStoreFactory { + // We can have only one class accessing a single data store, so keep a cache of them. + private val cache = ConcurrentHashMap() + override fun getOrCreate(userId: SessionId): UserPushStore { + return cache.getOrPut(userId) { + UserPushStoreDataStore( + context = context, + userId = userId, + factory = preferenceDataStoreFactory, + ) + } + } +} 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 0000000..8ad2d62 --- /dev/null +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStore.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.preferencesDataStoreFile +import io.element.android.libraries.androidutils.hash.hash +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.bool.orTrue +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import io.element.android.libraries.pushstore.api.UserPushStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import timber.log.Timber + +/** + * Store data related to push about a user. + */ +class UserPushStoreDataStore( + private val context: Context, + userId: SessionId, + factory: PreferenceDataStoreFactory, +) : UserPushStore { + // Hash the sessionId to get rid of exotic chars and take only the first 16 chars. + // The risk of collision is not high. + private val preferenceName = "push_store_${userId.value.hash().take(16)}" + + init { + // Migrate legacy data. Previous file can be too long if the userId is too long. The userId can be up to 255 chars. + // Example of long file path, with `averylonguserid` replacing a very longer name + // /data/user/0/io.element.android.x.debug/files/datastore/push_store_@averylonguserid:example.org.preferences_pb + val legacyFile = context.preferencesDataStoreFile("push_store_$userId") + if (legacyFile.exists()) { + Timber.d("Migrating legacy push data store for $userId") + if (!legacyFile.renameTo(context.preferencesDataStoreFile(preferenceName))) { + Timber.w("Failed to migrate legacy push data store for $userId") + } + } + } + + private val store: DataStore = factory.create(preferenceName) + private val pushProviderName = stringPreferencesKey("pushProviderName") + private val currentPushKey = stringPreferencesKey("currentPushKey") + private val notificationEnabled = booleanPreferencesKey("notificationEnabled") + private val ignoreRegistrationError = booleanPreferencesKey("ignoreRegistrationError") + + override suspend fun getPushProviderName(): String? { + return store.data.first()[pushProviderName] + } + + override suspend fun setPushProviderName(value: String) { + store.edit { + it[pushProviderName] = value + } + } + + override suspend fun getCurrentRegisteredPushKey(): String? { + return store.data.first()[currentPushKey] + } + + override suspend fun setCurrentRegisteredPushKey(value: String?) { + store.edit { + if (value == null) { + it.remove(currentPushKey) + } else { + it[currentPushKey] = value + } + } + } + + override fun getNotificationEnabledForDevice(): Flow { + return store.data.map { it[notificationEnabled].orTrue() } + } + + override suspend fun setNotificationEnabledForDevice(enabled: Boolean) { + store.edit { + it[notificationEnabled] = enabled + } + } + + override fun useCompleteNotificationFormat(): Boolean { + return true + } + + override fun ignoreRegistrationError(): Flow { + return store.data.map { it[ignoreRegistrationError].orFalse() } + } + + override suspend fun setIgnoreRegistrationError(ignore: Boolean) { + store.edit { + it[ignoreRegistrationError] = ignore + } + } + + override suspend fun reset() { + store.edit { + it.clear() + } + // Also delete the file + context.preferencesDataStoreFile(preferenceName).delete() + } +} diff --git a/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DataStorePushClientSecretStore.kt b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DataStorePushClientSecretStore.kt new file mode 100644 index 0000000..827f4ef --- /dev/null +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DataStorePushClientSecretStore.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushstore.impl.clientsecret + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretStore +import kotlinx.coroutines.flow.first + +@ContributesBinding(AppScope::class) +class DataStorePushClientSecretStore( + preferenceDataStoreFactory: PreferenceDataStoreFactory, +) : PushClientSecretStore { + private val dataStore = preferenceDataStoreFactory.create("push_client_secret_store") + + override suspend fun storeSecret(userId: SessionId, clientSecret: String) { + dataStore.edit { settings -> + settings[getPreferenceKeyForUser(userId)] = clientSecret + } + } + + override suspend fun getSecret(userId: SessionId): String? { + return dataStore.data.first()[getPreferenceKeyForUser(userId)] + } + + override suspend fun resetSecret(userId: SessionId) { + dataStore.edit { settings -> + settings.remove(getPreferenceKeyForUser(userId)) + } + } + + override suspend fun getUserIdFromSecret(clientSecret: String): SessionId? { + val keyValues = 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/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DefaultPushClientSecret.kt b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DefaultPushClientSecret.kt new file mode 100644 index 0000000..8db0031 --- /dev/null +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DefaultPushClientSecret.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushstore.impl.clientsecret + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +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 + +@ContributesBinding(AppScope::class) +class DefaultPushClientSecret( + 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) + } +} diff --git a/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DefaultPushClientSecretFactory.kt b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DefaultPushClientSecretFactory.kt new file mode 100644 index 0000000..1ac1777 --- /dev/null +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DefaultPushClientSecretFactory.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushstore.impl.clientsecret + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretFactory +import java.util.UUID + +@ContributesBinding(AppScope::class) +class DefaultPushClientSecretFactory : PushClientSecretFactory { + override fun create(): String { + return UUID.randomUUID().toString() + } +} diff --git a/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStoreTest.kt b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStoreTest.kt new file mode 100644 index 0000000..9191ea4 --- /dev/null +++ b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStoreTest.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushstore.impl + +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.SessionId +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.preferences.test.FakePreferenceDataStoreFactory +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UserPushStoreDataStoreTest { + @Test + fun `test getPushProviderName`() = runTest { + val sut = createUserPushStoreDataStore() + assertThat(sut.getPushProviderName()).isNull() + sut.setPushProviderName("name") + assertThat(sut.getPushProviderName()).isEqualTo("name") + } + + @Test + fun `test getCurrentRegisteredPushKey`() = runTest { + val sut = createUserPushStoreDataStore() + assertThat(sut.getCurrentRegisteredPushKey()).isNull() + sut.setCurrentRegisteredPushKey("aKey") + assertThat(sut.getCurrentRegisteredPushKey()).isEqualTo("aKey") + sut.setCurrentRegisteredPushKey(null) + assertThat(sut.getCurrentRegisteredPushKey()).isNull() + } + + @Test + fun `test getNotificationEnabledForDevice`() = runTest { + val sut = createUserPushStoreDataStore() + assertThat(sut.getNotificationEnabledForDevice().first()).isTrue() + sut.setNotificationEnabledForDevice(false) + assertThat(sut.getNotificationEnabledForDevice().first()).isFalse() + sut.setNotificationEnabledForDevice(true) + assertThat(sut.getNotificationEnabledForDevice().first()).isTrue() + } + + @Test + fun `test useCompleteNotificationFormat`() = runTest { + val sut = createUserPushStoreDataStore() + assertThat(sut.useCompleteNotificationFormat()).isTrue() + } + + @Test + fun `test ignoreRegistrationError`() = runTest { + val sut = createUserPushStoreDataStore() + assertThat(sut.ignoreRegistrationError().first()).isFalse() + sut.setIgnoreRegistrationError(true) + assertThat(sut.ignoreRegistrationError().first()).isTrue() + sut.setIgnoreRegistrationError(false) + assertThat(sut.ignoreRegistrationError().first()).isFalse() + } + + @Test + fun `test reset`() = runTest { + val sut = createUserPushStoreDataStore() + sut.setPushProviderName("name") + sut.setCurrentRegisteredPushKey("aKey") + sut.setNotificationEnabledForDevice(false) + sut.setIgnoreRegistrationError(true) + sut.reset() + assertThat(sut.getPushProviderName()).isNull() + assertThat(sut.getCurrentRegisteredPushKey()).isNull() + assertThat(sut.getNotificationEnabledForDevice().first()).isTrue() + assertThat(sut.ignoreRegistrationError().first()).isFalse() + } + + @Test + fun `ensure a store is created per session`() = runTest { + val sut1 = createUserPushStoreDataStore() + sut1.setPushProviderName("name") + val sut2 = createUserPushStoreDataStore(A_SESSION_ID_2) + assertThat(sut1.getPushProviderName()).isEqualTo("name") + assertThat(sut2.getPushProviderName()).isNull() + } + + private fun createUserPushStoreDataStore( + sessionId: SessionId = A_SESSION_ID, + ) = UserPushStoreDataStore( + context = InstrumentationRegistry.getInstrumentation().context, + userId = sessionId, + factory = FakePreferenceDataStoreFactory(), + ) +} diff --git a/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DefaultPushClientSecretTest.kt b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DefaultPushClientSecretTest.kt new file mode 100644 index 0000000..2f7d16f --- /dev/null +++ b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DefaultPushClientSecretTest.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 io.element.android.libraries.pushstore.test.userpushstore.clientsecret.InMemoryPushClientSecretStore +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 DefaultPushClientSecretTest { + @Test + fun test() = runTest { + val factory = FakePushClientSecretFactory() + val store = InMemoryPushClientSecretStore() + val sut = DefaultPushClientSecret(factory, store) + + val secret0 = factory.getSecretForUser(0) + val secret1 = factory.getSecretForUser(1) + + 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() + + // Check the store content + assertThat(store.getSecrets()).isEqualTo( + mapOf( + A_USER_ID_0 to secret0, + A_USER_ID_1 to secret1, + ) + ) + } +} 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 0000000..325095a --- /dev/null +++ b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/FakePushClientSecretFactory.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/test/build.gradle.kts b/libraries/pushstore/test/build.gradle.kts new file mode 100644 index 0000000..ecc451d --- /dev/null +++ b/libraries/pushstore/test/build.gradle.kts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.pushstore.test" +} + +dependencies { + api(projects.libraries.matrix.api) + api(libs.coroutines.core) + implementation(libs.coroutines.test) + implementation(projects.tests.testutils) + implementation(projects.libraries.pushstore.api) +} diff --git a/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt new file mode 100644 index 0000000..e095a49 --- /dev/null +++ b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushstore.test.userpushstore + +import io.element.android.libraries.pushstore.api.UserPushStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeUserPushStore( + private var pushProviderName: String? = null +) : UserPushStore { + private var currentRegisteredPushKey: String? = null + private val notificationEnabledForDevice = MutableStateFlow(true) + private val ignoreRegistrationError = MutableStateFlow(false) + override suspend fun getPushProviderName(): String? { + return pushProviderName + } + + override suspend fun setPushProviderName(value: String) { + pushProviderName = value + } + + override suspend fun getCurrentRegisteredPushKey(): String? { + return currentRegisteredPushKey + } + + override suspend fun setCurrentRegisteredPushKey(value: String?) { + currentRegisteredPushKey = value + } + + override fun getNotificationEnabledForDevice(): Flow { + return notificationEnabledForDevice + } + + override suspend fun setNotificationEnabledForDevice(enabled: Boolean) { + notificationEnabledForDevice.value = enabled + } + + override fun useCompleteNotificationFormat(): Boolean { + return true + } + + override fun ignoreRegistrationError(): Flow { + return ignoreRegistrationError + } + + override suspend fun setIgnoreRegistrationError(ignore: Boolean) { + ignoreRegistrationError.value = ignore + } + + override suspend fun reset() { + pushProviderName = null + } +} diff --git a/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt new file mode 100644 index 0000000..e2bb6ed --- /dev/null +++ b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushstore.test.userpushstore + +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 + +class FakeUserPushStoreFactory( + val userPushStore: (SessionId) -> UserPushStore = { FakeUserPushStore() } +) : UserPushStoreFactory { + override fun getOrCreate(userId: SessionId): UserPushStore { + return userPushStore(userId) + } +} diff --git a/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/FakePushClientSecret.kt b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/FakePushClientSecret.kt new file mode 100644 index 0000000..2e7ccca --- /dev/null +++ b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/FakePushClientSecret.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushstore.test.userpushstore.clientsecret + +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePushClientSecret( + private val getSecretForUserResult: (SessionId) -> String = { lambdaError() }, + private val getUserIdFromSecretResult: (String) -> SessionId? = { lambdaError() } +) : PushClientSecret { + override suspend fun getSecretForUser(userId: SessionId): String { + return getSecretForUserResult(userId) + } + + override suspend fun getUserIdFromSecret(clientSecret: String): SessionId? { + return getUserIdFromSecretResult(clientSecret) + } +} diff --git a/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/InMemoryPushClientSecretStore.kt b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/InMemoryPushClientSecretStore.kt new file mode 100644 index 0000000..b82cf13 --- /dev/null +++ b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/InMemoryPushClientSecretStore.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.pushstore.test.userpushstore.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() + + fun getSecrets(): Map = 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/qrcode/build.gradle.kts b/libraries/qrcode/build.gradle.kts new file mode 100644 index 0000000..cbf4c2d --- /dev/null +++ b/libraries/qrcode/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.qrcode" +} + +dependencies { + implementation(projects.libraries.designsystem) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + implementation(libs.androidx.camera.camera2) + implementation(libs.zxing.cpp) +} diff --git a/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QRCodeAnalyzer.kt b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QRCodeAnalyzer.kt new file mode 100644 index 0000000..ab2ee2c --- /dev/null +++ b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QRCodeAnalyzer.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.qrcode + +import android.graphics.ImageFormat +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import timber.log.Timber +import zxingcpp.BarcodeReader + +internal class QRCodeAnalyzer( + private val onScanQrCode: (result: ByteArray?) -> Unit +) : ImageAnalysis.Analyzer { + private val reader by lazy { BarcodeReader() } + + override fun analyze(image: ImageProxy) { + if (image.format in SUPPORTED_IMAGE_FORMATS) { + try { + val bytes = reader.read(image).firstNotNullOfOrNull { it.bytes } + bytes?.let { onScanQrCode(it) } + } catch (e: Exception) { + Timber.w(e, "Error decoding QR code") + } finally { + image.close() + } + } + } + + companion object { + private val SUPPORTED_IMAGE_FORMATS = listOf( + ImageFormat.YUV_420_888, + ImageFormat.YUV_422_888, + ImageFormat.YUV_444_888, + ) + } +} diff --git a/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeCameraView.kt b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeCameraView.kt new file mode 100644 index 0000000..a0d6613 --- /dev/null +++ b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeCameraView.kt @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.qrcode + +import android.content.Context +import android.graphics.Bitmap +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.Text +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import timber.log.Timber +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +@Composable +fun QrCodeCameraView( + onScanQrCode: (ByteArray) -> Unit, + renderPreview: Boolean, + modifier: Modifier = Modifier, +) { + if (LocalInspectionMode.current) { + Box( + modifier = modifier + .background(color = ElementTheme.colors.bgSubtlePrimary), + contentAlignment = Alignment.Center, + ) { + Text("CameraView") + } + } else { + val coroutineScope = rememberCoroutineScope() + val localContext = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var cameraProvider by remember { mutableStateOf(null) } + val previewUseCase = remember { Preview.Builder().build() } + var lastFrame by remember { mutableStateOf(null) } + val imageAnalysis = remember { + ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + } + + LaunchedEffect(Unit) { + cameraProvider = localContext.getCameraProvider() + } + + suspend fun startQRCodeAnalysis(cameraProvider: ProcessCameraProvider, previewView: PreviewView, attempt: Int = 1) { + lastFrame = null + val cameraSelector = CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_BACK) + .build() + imageAnalysis.setAnalyzer( + ContextCompat.getMainExecutor(previewView.context), + QRCodeAnalyzer { result -> + result?.let { + Timber.d("QR code scanned!") + onScanQrCode(it) + } + } + ) + try { + // Make sure we unbind all use cases before binding them again + cameraProvider.unbindAll() + + cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + previewUseCase, + imageAnalysis + ) + lastFrame = null + } catch (e: Exception) { + val maxAttempts = 3 + if (attempt > maxAttempts) { + Timber.e(e, "Use case binding failed after $maxAttempts attempts. Giving up.") + } else { + Timber.e(e, "Use case binding failed (attempt #$attempt). Retrying after a delay...") + delay(100) + startQRCodeAnalysis(cameraProvider, previewView, attempt + 1) + } + } + } + + fun stopQRCodeAnalysis(previewView: PreviewView) { + // Stop analyzer + imageAnalysis.clearAnalyzer() + + // Save last frame to display it as the 'frozen' preview + if (lastFrame == null) { + lastFrame = previewView.bitmap + Timber.d("Saving last frame for frozen preview.") + } + + // Unbind preview use case + cameraProvider?.unbindAll() + } + + Box(modifier.clipToBounds()) { + AndroidView( + factory = { context -> + val previewView = PreviewView(context) + previewUseCase.setSurfaceProvider(previewView.surfaceProvider) + previewView.previewStreamState.observe(lifecycleOwner) { state -> + previewView.alpha = if (state == PreviewView.StreamState.STREAMING) 1f else 0f + } + previewView + }, + update = { previewView -> + if (renderPreview) { + cameraProvider?.let { provider -> + coroutineScope.launch { startQRCodeAnalysis(provider, previewView) } + } + } else { + stopQRCodeAnalysis(previewView) + } + }, + onRelease = { + cameraProvider?.unbindAll() + cameraProvider = null + }, + ) + lastFrame?.let { + Image(bitmap = it.asImageBitmap(), contentDescription = null) + } + } + } +} + +@Suppress("BlockingMethodInNonBlockingContext") +private suspend fun Context.getCameraProvider(): ProcessCameraProvider = + suspendCoroutine { continuation -> + ProcessCameraProvider.getInstance(this).also { cameraProvider -> + cameraProvider.addListener({ + continuation.resume(cameraProvider.get()) + }, ContextCompat.getMainExecutor(this)) + } + } diff --git a/libraries/recentemojis/api/build.gradle.kts b/libraries/recentemojis/api/build.gradle.kts new file mode 100644 index 0000000..2fc74c0 --- /dev/null +++ b/libraries/recentemojis/api/build.gradle.kts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.recentemojis.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + + implementation(libs.kotlinx.collections.immutable) + implementation(libs.matrix.emojibase.bindings) +} diff --git a/libraries/recentemojis/api/src/main/kotlin/io/element/android/libraries/recentemojis/api/AddRecentEmoji.kt b/libraries/recentemojis/api/src/main/kotlin/io/element/android/libraries/recentemojis/api/AddRecentEmoji.kt new file mode 100644 index 0000000..5b50823 --- /dev/null +++ b/libraries/recentemojis/api/src/main/kotlin/io/element/android/libraries/recentemojis/api/AddRecentEmoji.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.recentemojis.api + +fun interface AddRecentEmoji { + suspend operator fun invoke(emoji: String): Result +} diff --git a/libraries/recentemojis/api/src/main/kotlin/io/element/android/libraries/recentemojis/api/EmojibaseProvider.kt b/libraries/recentemojis/api/src/main/kotlin/io/element/android/libraries/recentemojis/api/EmojibaseProvider.kt new file mode 100644 index 0000000..9d6e41e --- /dev/null +++ b/libraries/recentemojis/api/src/main/kotlin/io/element/android/libraries/recentemojis/api/EmojibaseProvider.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.recentemojis.api + +import io.element.android.emojibasebindings.EmojibaseStore + +interface EmojibaseProvider { + val emojibaseStore: EmojibaseStore +} diff --git a/libraries/recentemojis/api/src/main/kotlin/io/element/android/libraries/recentemojis/api/GetRecentEmojis.kt b/libraries/recentemojis/api/src/main/kotlin/io/element/android/libraries/recentemojis/api/GetRecentEmojis.kt new file mode 100644 index 0000000..6517512 --- /dev/null +++ b/libraries/recentemojis/api/src/main/kotlin/io/element/android/libraries/recentemojis/api/GetRecentEmojis.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.recentemojis.api + +import kotlinx.collections.immutable.ImmutableList + +/** + * Returns the list of recently used emojis for reactions. + */ +fun interface GetRecentEmojis { + suspend operator fun invoke(): Result> +} diff --git a/libraries/recentemojis/impl/build.gradle.kts b/libraries/recentemojis/impl/build.gradle.kts new file mode 100644 index 0000000..a1a72c8 --- /dev/null +++ b/libraries/recentemojis/impl/build.gradle.kts @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +import extension.setupDependencyInjection + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.libraries.recentemojis.impl" +} + +setupDependencyInjection() + +dependencies { + api(projects.libraries.recentemojis.api) + implementation(projects.libraries.matrix.api) + implementation(libs.kotlinx.collections.immutable) + implementation(libs.matrix.emojibase.bindings) + + testImplementation(projects.libraries.recentemojis.test) + 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) +} diff --git a/libraries/recentemojis/impl/src/main/kotlin/io/element/android/libraries/recentemojis/impl/DefaultAddRecentEmoji.kt b/libraries/recentemojis/impl/src/main/kotlin/io/element/android/libraries/recentemojis/impl/DefaultAddRecentEmoji.kt new file mode 100644 index 0000000..4444d7e --- /dev/null +++ b/libraries/recentemojis/impl/src/main/kotlin/io/element/android/libraries/recentemojis/impl/DefaultAddRecentEmoji.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.recentemojis.impl + +import dev.zacsweers.metro.ContributesBinding +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.recentemojis.api.AddRecentEmoji +import kotlinx.coroutines.withContext + +@ContributesBinding(SessionScope::class) +class DefaultAddRecentEmoji( + private val client: MatrixClient, + private val dispatchers: CoroutineDispatchers, +) : AddRecentEmoji { + override suspend operator fun invoke(emoji: String): Result = withContext(dispatchers.io) { + client.addRecentEmoji(emoji) + } +} diff --git a/libraries/recentemojis/impl/src/main/kotlin/io/element/android/libraries/recentemojis/impl/DefaultEmojibaseProvider.kt b/libraries/recentemojis/impl/src/main/kotlin/io/element/android/libraries/recentemojis/impl/DefaultEmojibaseProvider.kt new file mode 100644 index 0000000..161dd0c --- /dev/null +++ b/libraries/recentemojis/impl/src/main/kotlin/io/element/android/libraries/recentemojis/impl/DefaultEmojibaseProvider.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.recentemojis.impl + +import android.content.Context +import io.element.android.emojibasebindings.EmojibaseDatasource +import io.element.android.emojibasebindings.EmojibaseStore +import io.element.android.libraries.recentemojis.api.EmojibaseProvider + +class DefaultEmojibaseProvider(val context: Context) : EmojibaseProvider { + override val emojibaseStore: EmojibaseStore by lazy { + EmojibaseDatasource().load(context) + } +} diff --git a/libraries/recentemojis/impl/src/main/kotlin/io/element/android/libraries/recentemojis/impl/DefaultGetRecentEmojis.kt b/libraries/recentemojis/impl/src/main/kotlin/io/element/android/libraries/recentemojis/impl/DefaultGetRecentEmojis.kt new file mode 100644 index 0000000..9c5ed17 --- /dev/null +++ b/libraries/recentemojis/impl/src/main/kotlin/io/element/android/libraries/recentemojis/impl/DefaultGetRecentEmojis.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.recentemojis.impl + +import dev.zacsweers.metro.ContributesBinding +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.recentemojis.api.EmojibaseProvider +import io.element.android.libraries.recentemojis.api.GetRecentEmojis +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.withContext + +@ContributesBinding(SessionScope::class) +class DefaultGetRecentEmojis( + private val client: MatrixClient, + private val dispatchers: CoroutineDispatchers, + private val emojibaseProvider: EmojibaseProvider, +) : GetRecentEmojis { + override suspend operator fun invoke(): Result> = withContext(dispatchers.io) { + val allEmojis = emojibaseProvider.emojibaseStore.allEmojis + client.getRecentEmojis() + .map { emojis -> + // Remove any possible duplicates + emojis.distinct() + // Return only those emojis that are valid + .filter { recent -> allEmojis.any { recent == it.unicode } } + .toImmutableList() + } + } +} diff --git a/libraries/recentemojis/impl/src/test/kotlin/io/element/android/libraries/recentemojis/impl/DefaultGetRecentEmojisTest.kt b/libraries/recentemojis/impl/src/test/kotlin/io/element/android/libraries/recentemojis/impl/DefaultGetRecentEmojisTest.kt new file mode 100644 index 0000000..722aaee --- /dev/null +++ b/libraries/recentemojis/impl/src/test/kotlin/io/element/android/libraries/recentemojis/impl/DefaultGetRecentEmojisTest.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.recentemojis.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.emojibasebindings.Emoji +import io.element.android.emojibasebindings.EmojibaseCategory +import io.element.android.emojibasebindings.EmojibaseCategory.People +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.recentemojis.test.FakeEmojibaseProvider +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultGetRecentEmojisTest { + @Test + fun `invoke - deduplicates results`() = runTest { + val recentEmojiResult = persistentListOf(":)", ":D", ":)") + val getRecentEmojis = createDefaultGetRecentEmojis( + recentEmojis = { Result.success(recentEmojiResult) }, + emojibaseContents = persistentMapOf(People to recentEmojiResult.map { emoji(it) }.toImmutableList()) + ) + + assertThat(getRecentEmojis()).isEqualTo(Result.success(persistentListOf(":)", ":D"))) + } + + @Test + fun `invoke - removes non-standard emojis`() = runTest { + val recentEmojiResult = persistentListOf(":)", ":D", "Custom reaction") + val getRecentEmojis = createDefaultGetRecentEmojis( + recentEmojis = { Result.success(recentEmojiResult) }, + emojibaseContents = persistentMapOf( + People to persistentListOf(emoji(":)"), emoji(":D")) + ) + ) + + assertThat(getRecentEmojis()).isEqualTo(Result.success(persistentListOf(":)", ":D"))) + } + + private fun emoji(unicode: String) = Emoji( + hexcode = "", + label = "", + tags = null, + shortcodes = persistentListOf(), + unicode = unicode, + skins = null, + ) + + private fun TestScope.createDefaultGetRecentEmojis( + recentEmojis: () -> Result> = { Result.success(emptyList()) }, + emojibaseContents: ImmutableMap> = persistentMapOf(People to persistentListOf(emoji(":)"))), + ) = DefaultGetRecentEmojis( + client = FakeMatrixClient(getRecentEmojisLambda = recentEmojis), + dispatchers = testCoroutineDispatchers(), + emojibaseProvider = FakeEmojibaseProvider(emojibaseContents), + ) +} diff --git a/libraries/recentemojis/test/build.gradle.kts b/libraries/recentemojis/test/build.gradle.kts new file mode 100644 index 0000000..7c32d16 --- /dev/null +++ b/libraries/recentemojis/test/build.gradle.kts @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.recentemojis.test" +} + +dependencies { + api(projects.libraries.matrix.api) + api(libs.coroutines.core) + + implementation(libs.kotlinx.collections.immutable) + implementation(libs.coroutines.test) + implementation(projects.tests.testutils) + implementation(projects.libraries.recentemojis.api) + implementation(libs.matrix.emojibase.bindings) +} diff --git a/libraries/recentemojis/test/src/main/kotlin/io/element/android/libraries/recentemojis/test/FakeEmojibaseProvider.kt b/libraries/recentemojis/test/src/main/kotlin/io/element/android/libraries/recentemojis/test/FakeEmojibaseProvider.kt new file mode 100644 index 0000000..4774144 --- /dev/null +++ b/libraries/recentemojis/test/src/main/kotlin/io/element/android/libraries/recentemojis/test/FakeEmojibaseProvider.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.recentemojis.test + +import io.element.android.emojibasebindings.Emoji +import io.element.android.emojibasebindings.EmojibaseCategory +import io.element.android.emojibasebindings.EmojibaseStore +import io.element.android.libraries.recentemojis.api.EmojibaseProvider +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentMap + +class FakeEmojibaseProvider( + val emojis: Map> = mapOf(), +) : EmojibaseProvider { + override val emojibaseStore: EmojibaseStore + get() = EmojibaseStore(emojis.toPersistentMap()) +} diff --git a/libraries/roomselect/api/build.gradle.kts b/libraries/roomselect/api/build.gradle.kts new file mode 100644 index 0000000..7bd15bb --- /dev/null +++ b/libraries/roomselect/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.roomselect.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt new file mode 100644 index 0000000..ba5ca79 --- /dev/null +++ b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.roomselect.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 RoomSelectEntryPoint : FeatureEntryPoint { + data class Params( + val mode: RoomSelectMode, + ) + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: Params, + callback: Callback, + ): Node + + interface Callback : Plugin { + fun onRoomSelected(roomIds: List) + fun onCancel() + } +} diff --git a/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectMode.kt b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectMode.kt new file mode 100644 index 0000000..fad4ffa --- /dev/null +++ b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectMode.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.roomselect.api + +enum class RoomSelectMode { + Forward, + Share, +} diff --git a/libraries/roomselect/impl/build.gradle.kts b/libraries/roomselect/impl/build.gradle.kts new file mode 100644 index 0000000..1a5ae81 --- /dev/null +++ b/libraries/roomselect/impl/build.gradle.kts @@ -0,0 +1,35 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2022-2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.libraries.roomselect.impl" +} + +setupDependencyInjection() + +dependencies { + 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.roomselect.api) + + testCommonDependencies(libs) + testImplementation(projects.libraries.matrix.test) +} diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPoint.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPoint.kt new file mode 100644 index 0000000..4ca9266 --- /dev/null +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPoint.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.roomselect.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint + +@ContributesBinding(SessionScope::class) +class DefaultRoomSelectEntryPoint : RoomSelectEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: RoomSelectEntryPoint.Params, + callback: RoomSelectEntryPoint.Callback, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf( + RoomSelectNode.Inputs(mode = params.mode), + callback, + ) + ) + } +} diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvents.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvents.kt new file mode 100644 index 0000000..b5ea0e0 --- /dev/null +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvents.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.roomselect.impl + +import io.element.android.libraries.matrix.ui.model.SelectRoomInfo + +sealed interface RoomSelectEvents { + data class SetSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvents + + // TODO remove to restore multi-selection + data object RemoveSelectedRoom : RoomSelectEvents + data object ToggleSearchActive : RoomSelectEvents + data class UpdateQuery(val query: String) : RoomSelectEvents +} diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt new file mode 100644 index 0000000..93294c9 --- /dev/null +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.roomselect.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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint +import io.element.android.libraries.roomselect.api.RoomSelectMode + +@ContributesNode(SessionScope::class) +@AssistedInject +class RoomSelectNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: RoomSelectPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + data class Inputs( + val mode: RoomSelectMode, + ) : NodeInputs + + private val inputs: Inputs = inputs() + private val presenter = presenterFactory.create(inputs.mode) + private val callback: RoomSelectEntryPoint.Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + RoomSelectView( + state = state, + onDismiss = callback::onCancel, + onSubmit = callback::onRoomSelected, + modifier = modifier + ) + } +} diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt new file mode 100644 index 0000000..be66db7 --- /dev/null +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.roomselect.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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.ui.model.SelectRoomInfo +import io.element.android.libraries.roomselect.api.RoomSelectMode +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@AssistedInject +class RoomSelectPresenter( + @Assisted private val mode: RoomSelectMode, + private val dataSource: RoomSelectSearchDataSource, +) : Presenter { + @AssistedFactory + fun interface Factory { + fun create(mode: RoomSelectMode): RoomSelectPresenter + } + + @Composable + override fun present(): RoomSelectState { + var selectedRooms by remember { mutableStateOf(persistentListOf()) } + var searchQuery by remember { mutableStateOf("") } + var isSearchActive by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + dataSource.load() + } + + LaunchedEffect(searchQuery) { + dataSource.setSearchQuery(searchQuery) + } + + val roomSummaryDetailsList by dataSource.roomInfoList.collectAsState(initial = persistentListOf()) + + val searchResults by remember>>> { + derivedStateOf { + when { + roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList()) + isSearchActive -> SearchBarResultState.NoResultsFound() + else -> SearchBarResultState.Initial() + } + } + } + + fun handleEvent(event: RoomSelectEvents) { + when (event) { + is RoomSelectEvents.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) +// } + } + RoomSelectEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf() + is RoomSelectEvents.UpdateQuery -> searchQuery = event.query + RoomSelectEvents.ToggleSearchActive -> isSearchActive = !isSearchActive + } + } + + return RoomSelectState( + mode = mode, + resultState = searchResults, + query = searchQuery, + isSearchActive = isSearchActive, + selectedRooms = selectedRooms, + eventSink = ::handleEvent, + ) + } +} diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt new file mode 100644 index 0000000..15148eb --- /dev/null +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.roomselect.impl + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.roomlist.RoomList +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally +import io.element.android.libraries.matrix.ui.model.SelectRoomInfo +import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +private const val PAGE_SIZE = 30 + +/** + * DataSource for RoomSummaryDetails that can be filtered by a search query, + * and which only includes rooms the user has joined. + */ +@Inject +class RoomSelectSearchDataSource( + roomListService: RoomListService, + coroutineDispatchers: CoroutineDispatchers, +) { + private val roomList = roomListService.createRoomList( + pageSize = PAGE_SIZE, + initialFilter = RoomListFilter.all(), + source = RoomList.Source.All, + ) + + val roomInfoList: Flow> = roomList.filteredSummaries + .map { roomSummaries -> + roomSummaries + .filter { it.info.currentUserMembership == CurrentUserMembership.JOINED } + .distinctBy { it.roomId } // This should be removed once we're sure no duplicate Rooms can be received + .map { roomSummary -> roomSummary.toSelectRoomInfo() } + .toImmutableList() + } + .flowOn(coroutineDispatchers.computation) + + suspend fun load() = coroutineScope { + roomList.loadAllIncrementally(this) + } + + suspend fun setSearchQuery(searchQuery: String) = coroutineScope { + val filter = if (searchQuery.isBlank()) { + RoomListFilter.all() + } else { + RoomListFilter.NormalizedMatchRoomName(searchQuery) + } + roomList.updateFilter(filter) + } +} diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt new file mode 100644 index 0000000..c1ccb00 --- /dev/null +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.roomselect.impl + +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.ui.model.SelectRoomInfo +import io.element.android.libraries.roomselect.api.RoomSelectMode +import kotlinx.collections.immutable.ImmutableList + +data class RoomSelectState( + val mode: RoomSelectMode, + val resultState: SearchBarResultState>, + val query: String, + val isSearchActive: Boolean, + val selectedRooms: ImmutableList, + val eventSink: (RoomSelectEvents) -> Unit +) diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt new file mode 100644 index 0000000..5b63d6d --- /dev/null +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.roomselect.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo +import io.element.android.libraries.matrix.ui.model.SelectRoomInfo +import io.element.android.libraries.roomselect.api.RoomSelectMode +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +open class RoomSelectStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aRoomSelectState(), + aRoomSelectState(query = "Test", isSearchActive = true), + aRoomSelectState(resultState = SearchBarResultState.Results(aRoomSelectRoomList())), + aRoomSelectState( + resultState = SearchBarResultState.Results(aRoomSelectRoomList()), + query = "Test", + isSearchActive = true, + ), + aRoomSelectState( + resultState = SearchBarResultState.Results(aRoomSelectRoomList()), + query = "Test", + isSearchActive = true, + selectedRooms = aRoomSelectRoomList().subList(0, 1), + ), + aRoomSelectState( + mode = RoomSelectMode.Share, + resultState = SearchBarResultState.Results(aRoomSelectRoomList()), + ), + ) +} + +private fun aRoomSelectState( + mode: RoomSelectMode = RoomSelectMode.Forward, + resultState: SearchBarResultState> = SearchBarResultState.Initial(), + query: String = "", + isSearchActive: Boolean = false, + selectedRooms: ImmutableList = persistentListOf(), +) = RoomSelectState( + mode = mode, + resultState = resultState, + query = query, + isSearchActive = isSearchActive, + selectedRooms = selectedRooms, + eventSink = {} +) + +private fun aRoomSelectRoomList() = persistentListOf( + aSelectRoomInfo( + roomId = RoomId("!room1:domain"), + name = "Room with name", + ), + aSelectRoomInfo( + roomId = RoomId("!room2:domain"), + name = "Room with alias", + canonicalAlias = RoomAlias("#alias:example.org"), + ), + aSelectRoomInfo( + roomId = RoomId("!room3:domain"), + ), +) diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt new file mode 100644 index 0000000..4d02e1b --- /dev/null +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.roomselect.impl + +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.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.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.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +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.matrix.api.core.RoomId +import io.element.android.libraries.matrix.ui.components.SelectedRoom +import io.element.android.libraries.matrix.ui.model.SelectRoomInfo +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.roomselect.api.RoomSelectMode +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Suppress("MultipleEmitters") // False positive +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RoomSelectView( + state: RoomSelectState, + onDismiss: () -> Unit, + onSubmit: (List) -> Unit, + modifier: Modifier = Modifier, +) { + @Suppress("UNUSED_PARAMETER") + fun onRoomRemoved(roomInfo: SelectRoomInfo) { + // TODO toggle selection when multi-selection is enabled + state.eventSink(RoomSelectEvents.RemoveSelectedRoom) + } + + @Composable + fun SelectedRoomsHelper(isForwarding: Boolean, selectedRooms: ImmutableList) { + if (isForwarding) return + SelectedRooms( + selectedRooms = selectedRooms, + onRemoveRoom = ::onRoomRemoved, + modifier = Modifier.padding(vertical = 16.dp) + ) + } + + var canHandleBack by remember { mutableStateOf(true) } + fun onBackButton(state: RoomSelectState) { + if (state.isSearchActive) { + state.eventSink(RoomSelectEvents.ToggleSearchActive) + } else if (canHandleBack) { + canHandleBack = false + onDismiss() + } + } + + BackHandler( + enabled = canHandleBack, + onBack = { onBackButton(state) } + ) + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + titleStr = when (state.mode) { + RoomSelectMode.Forward -> stringResource(CommonStrings.common_forward_message) + RoomSelectMode.Share -> stringResource(CommonStrings.common_send_to) + }, + navigationIcon = { + BackButton( + enabled = canHandleBack, + onClick = { onBackButton(state) } + ) + }, + actions = { + TextButton( + text = stringResource(CommonStrings.action_send), + enabled = state.selectedRooms.isNotEmpty(), + onClick = { onSubmit(state.selectedRooms.map { it.roomId }) } + ) + } + ) + } + ) { paddingValues -> + Column( + Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + ) { + SearchBar( + modifier = Modifier.fillMaxWidth(), + placeHolderTitle = stringResource(CommonStrings.action_search), + query = state.query, + onQueryChange = { state.eventSink(RoomSelectEvents.UpdateQuery(it)) }, + active = state.isSearchActive, + onActiveChange = { state.eventSink(RoomSelectEvents.ToggleSearchActive) }, + resultState = state.resultState, + showBackButton = false, + ) { summaries -> + LazyColumn { + item { + SelectedRoomsHelper( + // TODO state.isForwarding + isForwarding = false, + 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(RoomSelectEvents.SetSelectedRoom(roomSummary)) + } + ) + HorizontalDivider(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(RoomSelectEvents.SetSelectedRoom(roomSummary)) + } + ) + HorizontalDivider(modifier = Modifier.fillMaxWidth()) + } + } + } + } + } + } + } +} + +@Composable +private fun SelectedRooms( + selectedRooms: ImmutableList, + onRemoveRoom: (SelectRoomInfo) -> Unit, + modifier: Modifier = Modifier, +) { + LazyRow( + modifier, + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(32.dp) + ) { + items(selectedRooms, key = { it.roomId.value }) { selectRoomInfo -> + SelectedRoom(roomInfo = selectRoomInfo, onRemoveRoom = onRemoveRoom) + } + } +} + +@Composable +private fun RoomSummaryView( + roomInfo: SelectRoomInfo, + isSelected: Boolean, + onSelection: (SelectRoomInfo) -> Unit, +) { + Row( + modifier = Modifier + .clickable { onSelection(roomInfo) } + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp) + .heightIn(56.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Avatar( + avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem), + avatarType = AvatarType.Room( + heroes = roomInfo.heroes.map { user -> + user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem) + }.toImmutableList(), + isTombstoned = roomInfo.isTombstoned, + ), + ) + 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 = roomInfo.name ?: stringResource(id = CommonStrings.common_no_room_name), + fontStyle = FontStyle.Italic.takeIf { roomInfo.name == null }, + color = ElementTheme.colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + // Alias + roomInfo.canonicalAlias?.let { alias -> + Text( + text = alias.value, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmRegular, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + RadioButton(selected = isSelected, onClick = { onSelection(roomInfo) }) + } +} + +@PreviewsDayNight +@Composable +internal fun RoomSelectViewPreview(@PreviewParameter(RoomSelectStateProvider::class) state: RoomSelectState) = ElementPreview { + RoomSelectView( + state = state, + onDismiss = {}, + onSubmit = {}, + ) +} diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPointTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPointTest.kt new file mode 100644 index 0000000..0d80590 --- /dev/null +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPointTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.roomselect.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint +import io.element.android.libraries.roomselect.api.RoomSelectMode +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DefaultRoomSelectEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `test node builder`() = runTest { + val entryPoint = DefaultRoomSelectEntryPoint() + val testMode = RoomSelectMode.Share + val parentNode = TestParentNode.create { buildContext, plugins -> + RoomSelectNode( + buildContext = buildContext, + plugins = plugins, + presenterFactory = { mode -> + assertThat(mode).isEqualTo(testMode) + createRoomSelectPresenter(mode) + }, + ) + } + val callback = object : RoomSelectEntryPoint.Callback { + override fun onRoomSelected(roomIds: List) = lambdaError() + override fun onCancel() = lambdaError() + } + val params = RoomSelectEntryPoint.Params(testMode) + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + params = params, + callback = callback, + ) + assertThat(result).isInstanceOf(RoomSelectNode::class.java) + assertThat(result.plugins).contains(RoomSelectNode.Inputs(params.mode)) + assertThat(result.plugins).contains(callback) + } +} diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt new file mode 100644 index 0000000..f6c7ef5 --- /dev/null +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.roomselect.impl + +import app.cash.molecule.RecompositionMode +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.api.roomlist.RoomListFilter +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo +import io.element.android.libraries.roomselect.api.RoomSelectMode +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class RoomSelectPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createRoomSelectPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.selectedRooms).isEmpty() + assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java) + assertThat(initialState.isSearchActive).isFalse() + } + } + + @Test + fun `present - toggle search active`() = runTest { + val presenter = createRoomSelectPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(RoomSelectEvents.ToggleSearchActive) + assertThat(awaitItem().isSearchActive).isTrue() + initialState.eventSink(RoomSelectEvents.ToggleSearchActive) + assertThat(awaitItem().isSearchActive).isFalse() + } + } + + @Test + fun `present - update query`() = runTest { + val roomSummary = aRoomSummary() + val roomListService = FakeRoomListService().apply { + postAllRooms(listOf(roomSummary)) + } + val presenter = createRoomSelectPresenter( + roomListService = roomListService + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val expectedRoomInfo = roomSummary.toSelectRoomInfo() + // Do not compare the lambda because they will be different. So copy the lambda from expectedRoomSummary to result + val result = (awaitItem().resultState as SearchBarResultState.Results).results + assertThat(result).isEqualTo(listOf(expectedRoomInfo)) + initialState.eventSink(RoomSelectEvents.ToggleSearchActive) + skipItems(1) + initialState.eventSink(RoomSelectEvents.UpdateQuery("string not contained")) + assertThat( + roomListService.allRooms.currentFilter.value + ).isEqualTo( + RoomListFilter.NormalizedMatchRoomName("string not contained") + ) + assertThat(awaitItem().query).isEqualTo("string not contained") + roomListService.postAllRooms( + emptyList() + ) + assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResultsFound::class.java) + } + } + + @Test + fun `present - select and remove a room`() = runTest { + val roomSummary = aRoomSummary() + val roomListService = FakeRoomListService().apply { + postAllRooms(listOf(roomSummary)) + } + val presenter = createRoomSelectPresenter( + roomListService = roomListService, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val roomInfo = roomSummary.toSelectRoomInfo() + initialState.eventSink(RoomSelectEvents.SetSelectedRoom(roomInfo)) + assertThat(awaitItem().selectedRooms).isEqualTo(persistentListOf(roomInfo)) + initialState.eventSink(RoomSelectEvents.RemoveSelectedRoom) + assertThat(awaitItem().selectedRooms).isEmpty() + cancel() + } + } +} + +internal fun TestScope.createRoomSelectPresenter( + mode: RoomSelectMode = RoomSelectMode.Forward, + roomListService: RoomListService = FakeRoomListService(), +) = RoomSelectPresenter( + mode = mode, + dataSource = RoomSelectSearchDataSource( + roomListService = roomListService, + coroutineDispatchers = testCoroutineDispatchers(), + ), +) diff --git a/libraries/roomselect/test/build.gradle.kts b/libraries/roomselect/test/build.gradle.kts new file mode 100644 index 0000000..8a3d063 --- /dev/null +++ b/libraries/roomselect/test/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.roomselect.test" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.roomselect.api) + implementation(projects.tests.testutils) +} diff --git a/libraries/roomselect/test/src/main/kotlin/io/element/android/libraries/roomselect/test/FakeRoomSelectEntryPoint.kt b/libraries/roomselect/test/src/main/kotlin/io/element/android/libraries/roomselect/test/FakeRoomSelectEntryPoint.kt new file mode 100644 index 0000000..6514c2c --- /dev/null +++ b/libraries/roomselect/test/src/main/kotlin/io/element/android/libraries/roomselect/test/FakeRoomSelectEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.roomselect.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeRoomSelectEntryPoint : RoomSelectEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: RoomSelectEntryPoint.Params, + callback: RoomSelectEntryPoint.Callback, + ): Node = lambdaError() +} diff --git a/libraries/rustsdk/.gitignore b/libraries/rustsdk/.gitignore new file mode 100644 index 0000000..67f29a6 --- /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 0000000..56fd094 --- /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 0000000..74dfa91 --- /dev/null +++ b/libraries/session-storage/api/build.gradle.kts @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-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/LoggedInState.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/LoggedInState.kt new file mode 100644 index 0000000..f9267ee --- /dev/null +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/LoggedInState.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.sessionstorage.api + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface LoggedInState { + data object NotLoggedIn : LoggedInState + data class LoggedIn( + val sessionId: String, + val isTokenValid: Boolean, + ) : LoggedInState +} diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/LoginType.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/LoginType.kt new file mode 100644 index 0000000..3e80dbd --- /dev/null +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/LoginType.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.sessionstorage.api + +// Imported from Element Android, to be able to migrate from EA to EXA. +enum class LoginType { + PASSWORD, + OIDC, + SSO, + UNSUPPORTED, + CUSTOM, + DIRECT, + UNKNOWN, + QR; + + companion object { + fun fromName(name: String) = when (name) { + PASSWORD.name -> PASSWORD + OIDC.name -> OIDC + SSO.name -> SSO + UNSUPPORTED.name -> UNSUPPORTED + CUSTOM.name -> CUSTOM + DIRECT.name -> DIRECT + QR.name -> QR + else -> UNKNOWN + } + } +} 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 0000000..568dbe7 --- /dev/null +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.sessionstorage.api + +import java.util.Date + +/** + * Data class representing the session data to store locally. + */ +data class SessionData( + /** The user ID of the logged in user. */ + val userId: String, + /** The device ID of the session. */ + val deviceId: String, + /** The current access token of the session. */ + val accessToken: String, + /** The optional current refresh token of the session. */ + val refreshToken: String?, + /** The homeserver URL of the session. */ + val homeserverUrl: String, + /** The Open ID Connect info for this session, if any. */ + val oidcData: String?, + /** The timestamp of the last login. May be `null` in very old sessions. */ + val loginTimestamp: Date?, + /** Whether the [accessToken] is valid or not. */ + val isTokenValid: Boolean, + /** The login type used to authenticate the session. */ + val loginType: LoginType, + /** The optional passphrase used to encrypt data in the SDK local store. */ + val passphrase: String?, + /** The paths to the session data stored in the filesystem. */ + val sessionPath: String, + /** The path to the cache data stored for the session in the filesystem. */ + val cachePath: String, + /** The position, to be able to order account. */ + val position: Long, + /** The index of the last date of session usage. */ + val lastUsageIndex: Long, + /** The optional display name of the user. */ + val userDisplayName: String?, + /** The optional avatar URL of the user. */ + val userAvatarUrl: String?, +) 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 0000000..fb60311 --- /dev/null +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.sessionstorage.api + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface SessionStore { + /** + * A flow emitting the current logged in state. + * If there is at least one session, the state is [LoggedInState.LoggedIn] with the latest used session. + * If there is no session, the state is [LoggedInState.NotLoggedIn]. + */ + fun loggedInStateFlow(): Flow + + /** + * Return a flow of all sessions ordered by last usage descending. + */ + fun sessionsFlow(): Flow> + + /** + * Add a new session. If other sessions exist, the new one will be set as the latest used one, and + * the added session position will be set to a value higher than the other session positions. + */ + suspend fun addSession(sessionData: SessionData) + + /** + * Will update the session data matching the userId, except the value of loginTimestamp. + * No op if userId is not found in DB. + */ + suspend fun updateData(sessionData: SessionData) + + /** + * Update the user profile info of the session matching the userId. + */ + suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) + + /** + * Get the session data matching the userId, or null if not found. + */ + suspend fun getSession(sessionId: String): SessionData? + + /** + * Get all sessions ordered by last usage descending. + */ + suspend fun getAllSessions(): List + + /** + * Get the number of sessions. + */ + suspend fun numberOfSessions(): Int + + /** + * Get the latest session, or null if no session exists. + */ + suspend fun getLatestSession(): SessionData? + + /** + * Set the session with [sessionId] as the latest used one. + */ + suspend fun setLatestSession(sessionId: String) + + /** + * Remove the session matching the sessionId. + */ + suspend fun removeSession(sessionId: String) +} + +fun List.toUserList(): List { + return map { it.userId } +} + +fun Flow>.toUserListFlow(): Flow> { + return map { it.toUserList() } +} + +/** + * @return a flow emitting the sessionId of the latest session if logged in, null otherwise. + */ +fun SessionStore.sessionIdFlow(): Flow { + return loggedInStateFlow().map { + when (it) { + is LoggedInState.LoggedIn -> it.sessionId + is LoggedInState.NotLoggedIn -> null + } + } +} 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 0000000..8dadcaf --- /dev/null +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionListener.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.sessionstorage.api.observer + +interface SessionListener { + suspend fun onSessionCreated(userId: String) {} + suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) {} +} 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 0000000..fb120ac --- /dev/null +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionObserver.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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/build.gradle.kts b/libraries/session-storage/impl/build.gradle.kts new file mode 100644 index 0000000..8b7d9d6 --- /dev/null +++ b/libraries/session-storage/impl/build.gradle.kts @@ -0,0 +1,49 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") + alias(libs.plugins.sqldelight) +} + +android { + namespace = "io.element.android.libraries.sessionstorage.impl" +} + +setupDependencyInjection() + +dependencies { + 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(projects.libraries.di) + implementation(libs.sqldelight.coroutines) + + testCommonDependencies(libs) + testImplementation(libs.sqldelight.driver.jvm) +} + +sqldelight { + databases { + create("SessionDatabase") { + // https://sqldelight.github.io/sqldelight/2.1.0/android_sqlite/migrations/ + // To generate a .db file from your latest schema, run this task + // ./gradlew generateDebugSessionDatabaseSchema + // Test migration by running + // ./gradlew verifySqlDelightMigration + schemaOutputDirectory = File("src/main/sqldelight/databases") + 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 0000000..9be59e4 --- /dev/null +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.sessionstorage.impl + +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList +import app.cash.sqldelight.coroutines.mapToOneOrNull +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.sessionstorage.api.LoggedInState +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.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DatabaseSessionStore( + private val database: SessionDatabase, + private val dispatchers: CoroutineDispatchers, +) : SessionStore { + private val sessionDataMutex = Mutex() + + override fun loggedInStateFlow(): Flow { + return database.sessionDataQueries.selectLatest() + .asFlow() + .mapToOneOrNull(dispatchers.io) + .map { + if (it == null) { + LoggedInState.NotLoggedIn + } else { + LoggedInState.LoggedIn( + sessionId = it.userId, + isTokenValid = it.isTokenValid == 1L + ) + } + } + .distinctUntilChanged() + } + + override suspend fun addSession(sessionData: SessionData) { + sessionDataMutex.withLock { + val lastUsageIndex = getLastUsageIndex() + database.sessionDataQueries.insertSessionData( + sessionData + .copy( + // position value does not really matter, so just use lastUsageIndex + 1 to ensure that + // the value is always greater than value of any existing account + position = lastUsageIndex + 1, + lastUsageIndex = lastUsageIndex + 1, + ) + .toDbModel() + ) + } + } + + override suspend fun updateData(sessionData: SessionData) { + sessionDataMutex.withLock { + val result = database.sessionDataQueries.selectByUserId(sessionData.userId) + .executeAsOneOrNull() + ?.toApiModel() + + if (result == null) { + Timber.e("User ${sessionData.userId} not found in session database") + return + } + // Copy new data from SDK, but keep application data + database.sessionDataQueries.updateSession( + sessionData.copy( + loginTimestamp = result.loginTimestamp, + position = result.position, + lastUsageIndex = result.lastUsageIndex, + userDisplayName = result.userDisplayName, + userAvatarUrl = result.userAvatarUrl, + ).toDbModel() + ) + } + } + + override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) { + sessionDataMutex.withLock { + val result = database.sessionDataQueries.selectByUserId(sessionId) + .executeAsOneOrNull() + ?.toApiModel() + if (result == null) { + Timber.e("User $sessionId not found in session database") + return + } + database.sessionDataQueries.updateSession( + result.copy( + userDisplayName = displayName, + userAvatarUrl = avatarUrl, + ).toDbModel() + ) + } + } + + override suspend fun setLatestSession(sessionId: String) { + val latestSession = getLatestSession() + if (latestSession?.userId == sessionId) { + // Already the latest session + return + } + val lastUsageIndex = latestSession?.lastUsageIndex ?: 0 + val result = database.sessionDataQueries.selectByUserId(sessionId) + .executeAsOneOrNull() + ?.toApiModel() + if (result == null) { + Timber.e("User $sessionId not found in session database") + return + } + sessionDataMutex.withLock { + // Update lastUsageIndex of the session + database.sessionDataQueries.updateSession( + result.copy( + lastUsageIndex = lastUsageIndex + 1, + ).toDbModel() + ) + } + } + + private fun getLastUsageIndex(): Long { + return database.sessionDataQueries.selectLatest() + .executeAsOneOrNull() + ?.lastUsageIndex + ?: -1L + } + + override suspend fun getLatestSession(): SessionData? { + return sessionDataMutex.withLock { + database.sessionDataQueries.selectLatest() + .executeAsOneOrNull() + ?.toApiModel() + } + } + + override suspend fun getSession(sessionId: String): SessionData? { + return sessionDataMutex.withLock { + database.sessionDataQueries.selectByUserId(sessionId) + .executeAsOneOrNull() + ?.toApiModel() + } + } + + override suspend fun getAllSessions(): List { + return sessionDataMutex.withLock { + database.sessionDataQueries.selectAll() + .executeAsList() + .map { it.toApiModel() } + } + } + + override suspend fun numberOfSessions(): Int { + return sessionDataMutex.withLock { + database.sessionDataQueries.count() + .executeAsOneOrNull() + ?.toInt() + ?: 0 + } + } + + override fun sessionsFlow(): Flow> { + return database.sessionDataQueries.selectAll() + .asFlow() + .mapToList(dispatchers.io) + .map { it.map { sessionData -> sessionData.toApiModel() } } + } + + override suspend fun removeSession(sessionId: String) { + sessionDataMutex.withLock { + 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 0000000..ea69709 --- /dev/null +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.sessionstorage.impl + +import io.element.android.libraries.sessionstorage.api.LoginType +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, + oidcData = oidcData, + loginTimestamp = loginTimestamp?.time, + isTokenValid = if (isTokenValid) 1L else 0L, + loginType = loginType.name, + passphrase = passphrase, + sessionPath = sessionPath, + cachePath = cachePath, + position = position, + lastUsageIndex = lastUsageIndex, + userDisplayName = userDisplayName, + userAvatarUrl = userAvatarUrl, + ) +} + +internal fun DbSessionData.toApiModel(): SessionData { + return SessionData( + userId = userId, + deviceId = deviceId, + accessToken = accessToken, + refreshToken = refreshToken, + homeserverUrl = homeserverUrl, + oidcData = oidcData, + loginTimestamp = loginTimestamp?.let { Date(it) }, + isTokenValid = isTokenValid == 1L, + loginType = LoginType.fromName(loginType ?: LoginType.UNKNOWN.name), + passphrase = passphrase, + sessionPath = sessionPath, + cachePath = cachePath, + position = position, + lastUsageIndex = lastUsageIndex, + userDisplayName = userDisplayName, + userAvatarUrl = userAvatarUrl, + ) +} 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 0000000..fb2c9a2 --- /dev/null +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/di/SessionStorageModule.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.sessionstorage.impl.di + +import android.content.Context +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.sessionstorage.impl.SessionDatabase +import io.element.encrypteddb.SqlCipherDriverFactory +import io.element.encrypteddb.passphrase.RandomSecretPassphraseProvider + +@BindingContainer +@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") + + // Make sure the parent directory of the key file exists, otherwise it will crash in older Android versions + val parentDir = secretFile.parentFile + if (parentDir != null && !parentDir.exists()) { + parentDir.mkdirs() + } + + 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 0000000..3c78f5e --- /dev/null +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserver.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.sessionstorage.impl.observer + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.annotations.AppCoroutineScope +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 + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultSessionObserver( + private val sessionStore: SessionStore, + @AppCoroutineScope + private val coroutineScope: CoroutineScope, + private val dispatchers: CoroutineDispatchers, +) : SessionObserver { + // Keep only the userId + private var currentUsers: Set? = null + + init { + observeDatabase() + } + + private val listeners = CopyOnWriteArraySet() + 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 + val wasLastSession = newUserSet.isEmpty() + removedUsers.forEach { removedUser -> + listeners.onEach { listener -> + listener.onSessionDeleted(removedUser, wasLastSession) + } + } + // 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/databases/1.db b/libraries/session-storage/impl/src/main/sqldelight/databases/1.db new file mode 100644 index 0000000..24a9b98 Binary files /dev/null and b/libraries/session-storage/impl/src/main/sqldelight/databases/1.db differ diff --git a/libraries/session-storage/impl/src/main/sqldelight/databases/10.db b/libraries/session-storage/impl/src/main/sqldelight/databases/10.db new file mode 100644 index 0000000..fe31cc0 Binary files /dev/null and b/libraries/session-storage/impl/src/main/sqldelight/databases/10.db differ diff --git a/libraries/session-storage/impl/src/main/sqldelight/databases/11.db b/libraries/session-storage/impl/src/main/sqldelight/databases/11.db new file mode 100644 index 0000000..92222fe Binary files /dev/null and b/libraries/session-storage/impl/src/main/sqldelight/databases/11.db differ diff --git a/libraries/session-storage/impl/src/main/sqldelight/databases/2.db b/libraries/session-storage/impl/src/main/sqldelight/databases/2.db new file mode 100644 index 0000000..8d5a418 Binary files /dev/null and b/libraries/session-storage/impl/src/main/sqldelight/databases/2.db differ diff --git a/libraries/session-storage/impl/src/main/sqldelight/databases/3.db b/libraries/session-storage/impl/src/main/sqldelight/databases/3.db new file mode 100644 index 0000000..58c8e8f Binary files /dev/null and b/libraries/session-storage/impl/src/main/sqldelight/databases/3.db differ diff --git a/libraries/session-storage/impl/src/main/sqldelight/databases/4.db b/libraries/session-storage/impl/src/main/sqldelight/databases/4.db new file mode 100644 index 0000000..f51605f Binary files /dev/null and b/libraries/session-storage/impl/src/main/sqldelight/databases/4.db differ diff --git a/libraries/session-storage/impl/src/main/sqldelight/databases/5.db b/libraries/session-storage/impl/src/main/sqldelight/databases/5.db new file mode 100644 index 0000000..c6cf5eb Binary files /dev/null and b/libraries/session-storage/impl/src/main/sqldelight/databases/5.db differ diff --git a/libraries/session-storage/impl/src/main/sqldelight/databases/6.db b/libraries/session-storage/impl/src/main/sqldelight/databases/6.db new file mode 100644 index 0000000..8bf785b Binary files /dev/null and b/libraries/session-storage/impl/src/main/sqldelight/databases/6.db differ diff --git a/libraries/session-storage/impl/src/main/sqldelight/databases/7.db b/libraries/session-storage/impl/src/main/sqldelight/databases/7.db new file mode 100644 index 0000000..61805e1 Binary files /dev/null and b/libraries/session-storage/impl/src/main/sqldelight/databases/7.db differ diff --git a/libraries/session-storage/impl/src/main/sqldelight/databases/8.db b/libraries/session-storage/impl/src/main/sqldelight/databases/8.db new file mode 100644 index 0000000..f2870ba Binary files /dev/null and b/libraries/session-storage/impl/src/main/sqldelight/databases/8.db differ diff --git a/libraries/session-storage/impl/src/main/sqldelight/databases/9.db b/libraries/session-storage/impl/src/main/sqldelight/databases/9.db new file mode 100644 index 0000000..906384d Binary files /dev/null and b/libraries/session-storage/impl/src/main/sqldelight/databases/9.db differ 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 0000000..8e5fb7a --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq @@ -0,0 +1,62 @@ +-------------------------------------------------------------------- +-- Current version of the DB is the highest value of filename +-- in the folder `sqldelight/databases`. +-- +-- When upgrading the schema, you have to create a file .sqm in the +-- `sqldelight/databases` folder and run the following task to +-- generate a .db file using the latest schema +-- > ./gradlew generateDebugSessionDatabaseSchema +-------------------------------------------------------------------- + +CREATE TABLE SessionData ( + userId TEXT NOT NULL PRIMARY KEY, + deviceId TEXT NOT NULL, + accessToken TEXT NOT NULL, + refreshToken TEXT, + homeserverUrl TEXT NOT NULL, + -- added in version 2 + loginTimestamp INTEGER, + -- added in version 3 + oidcData TEXT, + -- added in version 4 + isTokenValid INTEGER NOT NULL DEFAULT 1, + loginType TEXT, + -- added in version 5 + passphrase TEXT, + -- added in version 6 + sessionPath TEXT NOT NULL DEFAULT "", + -- added in version 9 + cachePath TEXT NOT NULL DEFAULT "", + -- added in version 10 + -- position, to be able to sort account by session creation date + position INTEGER NOT NULL DEFAULT 0, + -- index of the last usage session. Each time the current session change, the index of the current + -- session is incremented to the max value + 1 so it becomes the current session + lastUsageIndex INTEGER NOT NULL DEFAULT 0, + -- user display name + userDisplayName TEXT, + -- user avatar url + userAvatarUrl TEXT +); + + +selectLatest: +SELECT * FROM SessionData ORDER BY lastUsageIndex DESC LIMIT 1; + +selectAll: +SELECT * FROM SessionData ORDER BY lastUsageIndex DESC; + +count: +SELECT count(*) FROM SessionData; + +selectByUserId: +SELECT * FROM SessionData WHERE userId = ?; + +insertSessionData: +INSERT INTO SessionData VALUES ?; + +removeSession: +DELETE FROM SessionData WHERE userId = ?; + +updateSession: +REPLACE INTO SessionData VALUES ?; 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 0000000..4577105 --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/0.sqm @@ -0,0 +1,11 @@ +-- This file is not striclty necessary, since the first +-- version of the DB is 1, so we will never migrate from 0 + +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 0000000..845fabc --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/1.sqm @@ -0,0 +1,3 @@ +-- Migrate DB from version 1 + +ALTER TABLE SessionData ADD COLUMN loginTimestamp INTEGER; diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/10.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/10.sqm new file mode 100644 index 0000000..3d96933 --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/10.sqm @@ -0,0 +1,43 @@ +-- Migrate DB from version 10 +-- Remove field slidingSyncProxy + +-- Equivalent to (DROP not supported by sqldelight): +-- ALTER TABLE SessionData DROP slidingSyncProxy; + +CREATE TABLE SessionData_bak ( + userId TEXT NOT NULL PRIMARY KEY, + deviceId TEXT NOT NULL, + accessToken TEXT NOT NULL, + refreshToken TEXT, + homeserverUrl TEXT NOT NULL, + loginTimestamp INTEGER, + oidcData TEXT, + isTokenValid INTEGER NOT NULL DEFAULT 1, + loginType TEXT, + passphrase TEXT, + sessionPath TEXT NOT NULL DEFAULT "", + cachePath TEXT NOT NULL DEFAULT "", + position INTEGER NOT NULL DEFAULT 0, + lastUsageIndex INTEGER NOT NULL DEFAULT 0, + userDisplayName TEXT, + userAvatarUrl TEXT +); +INSERT INTO SessionData_bak SELECT + userId, + deviceId, + accessToken, + refreshToken, + homeserverUrl, + loginTimestamp, + oidcData, + isTokenValid, + loginType, + passphrase, + sessionPath, + cachePath, + position, + lastUsageIndex, + userDisplayName, + userAvatarUrl FROM SessionData; +DROP TABLE SessionData; +ALTER TABLE SessionData_bak RENAME TO SessionData; diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/2.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/2.sqm new file mode 100644 index 0000000..0af4cf8 --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/2.sqm @@ -0,0 +1,3 @@ +-- Migrate DB from version 2 + +ALTER TABLE SessionData ADD COLUMN oidcData TEXT; diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/3.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/3.sqm new file mode 100644 index 0000000..eef6eb5 --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/3.sqm @@ -0,0 +1,4 @@ +-- Migrate DB from version 3 + +ALTER TABLE SessionData ADD COLUMN isTokenValid INTEGER NOT NULL DEFAULT 1; +ALTER TABLE SessionData ADD COLUMN loginType TEXT; diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/4.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/4.sqm new file mode 100644 index 0000000..144d569 --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/4.sqm @@ -0,0 +1,3 @@ +-- Migrate DB from version 4 + +ALTER TABLE SessionData ADD COLUMN passphrase TEXT; diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/5.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/5.sqm new file mode 100644 index 0000000..22797f1 --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/5.sqm @@ -0,0 +1,4 @@ +-- Migrate DB from version 5 +-- For users migrating previously logged in sessions, we force them to verify them too + +ALTER TABLE SessionData ADD COLUMN needsVerification INTEGER NOT NULL DEFAULT 1; diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/6.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/6.sqm new file mode 100644 index 0000000..e4eccfd --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/6.sqm @@ -0,0 +1,20 @@ +-- Migrate DB from version 6 +-- Remove DB value for verified status, we're back to using the Rust SDK as a source of truth + +CREATE TABLE SessionData_bak ( + userId TEXT NOT NULL PRIMARY KEY, + deviceId TEXT NOT NULL, + accessToken TEXT NOT NULL, + refreshToken TEXT, + homeserverUrl TEXT NOT NULL, + slidingSyncProxy TEXT, + loginTimestamp INTEGER, + oidcData TEXT, + isTokenValid INTEGER NOT NULL DEFAULT 1, + loginType TEXT, + passphrase TEXT +); + +INSERT INTO SessionData_bak SELECT userId, deviceId, accessToken, refreshToken, homeserverUrl, slidingSyncProxy, loginTimestamp, oidcData, isTokenValid, loginType, passphrase FROM SessionData; +DROP TABLE SessionData; +ALTER TABLE SessionData_bak RENAME TO SessionData; diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/7.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/7.sqm new file mode 100644 index 0000000..5814d23 --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/7.sqm @@ -0,0 +1,4 @@ +-- Migrate DB from version 7 +-- Add sessionPath so we can track the anonymized path for the session files dir + +ALTER TABLE SessionData ADD COLUMN sessionPath TEXT NOT NULL DEFAULT ""; diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/8.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/8.sqm new file mode 100644 index 0000000..02a8d43 --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/8.sqm @@ -0,0 +1,4 @@ +-- Migrate DB from version 8 +-- Add cachePath so we can track the anonymized path for the session cache dir + +ALTER TABLE SessionData ADD COLUMN cachePath TEXT NOT NULL DEFAULT ""; diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/9.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/9.sqm new file mode 100644 index 0000000..51c1366 --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/9.sqm @@ -0,0 +1,9 @@ +-- Migrate DB from version 9 +-- Add position to be able to sort account by session creation date +-- Add lastUsageIndex so we can restore the last session and switch to another one +-- Add display name and avatar url of the user so that we can display a list of accounts. + +ALTER TABLE SessionData ADD COLUMN position INTEGER NOT NULL DEFAULT 0; +ALTER TABLE SessionData ADD COLUMN lastUsageIndex INTEGER NOT NULL DEFAULT 0; +ALTER TABLE SessionData ADD COLUMN userDisplayName TEXT; +ALTER TABLE SessionData ADD COLUMN userAvatarUrl TEXT; diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt new file mode 100644 index 0000000..e400009 --- /dev/null +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.sessionstorage.impl + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.session.SessionData +import io.element.android.libraries.sessionstorage.api.LoggedInState +import io.element.android.libraries.sessionstorage.api.LoginType +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class DatabaseSessionStoreTest { + private lateinit var database: SessionDatabase + private lateinit var databaseSessionStore: DatabaseSessionStore + + private val aSessionData = aDbSessionData() + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + // Initialise in memory SQLite driver + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + SessionDatabase.Schema.create(driver) + + database = SessionDatabase(driver) + databaseSessionStore = DatabaseSessionStore( + database = database, + dispatchers = CoroutineDispatchers( + io = UnconfinedTestDispatcher(), + computation = UnconfinedTestDispatcher(), + main = UnconfinedTestDispatcher(), + ) + ) + } + + @Test + fun `addSession persists the SessionData into the DB`() = runTest { + assertThat(database.sessionDataQueries.selectLatest().executeAsOneOrNull()).isNull() + + databaseSessionStore.addSession(aSessionData.toApiModel()) + + assertThat(database.sessionDataQueries.selectLatest().executeAsOneOrNull()).isEqualTo(aSessionData) + assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(1) + assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(1) + } + + @Test + fun `loggedInStateFlow emits LoggedIn while there are sessions in the DB`() = runTest { + databaseSessionStore.loggedInStateFlow().test { + assertThat(awaitItem()).isEqualTo(LoggedInState.NotLoggedIn) + databaseSessionStore.addSession(aSessionData.toApiModel()) + assertThat(awaitItem()).isEqualTo(LoggedInState.LoggedIn(sessionId = aSessionData.userId, isTokenValid = true)) + // Add a second session + databaseSessionStore.addSession(aSessionData.copy(userId = "otherUserId").toApiModel()) + assertThat(awaitItem()).isEqualTo(LoggedInState.LoggedIn(sessionId = "otherUserId", isTokenValid = true)) + // Remove the second session + databaseSessionStore.removeSession("otherUserId") + assertThat(awaitItem()).isEqualTo(LoggedInState.LoggedIn(sessionId = aSessionData.userId, isTokenValid = true)) + // Remove the first session + databaseSessionStore.removeSession(aSessionData.userId) + assertThat(awaitItem()).isEqualTo(LoggedInState.NotLoggedIn) + } + } + + @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 `getAllSessions should return all the sessions`() = runTest { + val noSessions = databaseSessionStore.getAllSessions() + assertThat(noSessions).isEmpty() + database.sessionDataQueries.insertSessionData(aSessionData) + val otherSessionData = aSessionData.copy(userId = "otherUserId") + database.sessionDataQueries.insertSessionData(otherSessionData) + val allSessions = databaseSessionStore.getAllSessions().map { + it.toDbModel() + } + assertThat(allSessions).isEqualTo( + listOf( + aSessionData, + otherSessionData, + ) + ) + } + + @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) + assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).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() + } + + @Test + fun `updateUserProfile does nothing if the session is not found`() = runTest { + databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl") + assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOneOrNull()).isNull() + } + + @Test + fun `updateUserProfile update the data`() = runTest { + database.sessionDataQueries.insertSessionData(aSessionData) + databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl") + val updatedSession = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne() + assertThat(updatedSession.userDisplayName).isEqualTo("userDisplayName") + assertThat(updatedSession.userAvatarUrl).isEqualTo("userAvatarUrl") + } + + @Test + fun `setLatestSession is no op when the session is already the latest session`() = runTest { + database.sessionDataQueries.insertSessionData(aSessionData) + val session = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne() + assertThat(session.lastUsageIndex).isEqualTo(0) + assertThat(session.position).isEqualTo(0) + databaseSessionStore.setLatestSession(aSessionData.userId) + assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne().lastUsageIndex).isEqualTo(0) + } + + @Test + fun `setLatestSession is no op when the session is not found`() = runTest { + databaseSessionStore.setLatestSession(aSessionData.userId) + } + + @Test + fun `multi session test`() = runTest { + databaseSessionStore.addSession(aSessionData.toApiModel()) + val session = databaseSessionStore.getSession(aSessionData.userId)!! + assertThat(session.lastUsageIndex).isEqualTo(0) + assertThat(session.position).isEqualTo(0) + val secondSessionData = aSessionData.copy( + userId = "otherUserId", + position = 1, + lastUsageIndex = 1, + ) + databaseSessionStore.addSession(secondSessionData.toApiModel()) + val secondSession = database.sessionDataQueries.selectByUserId(secondSessionData.userId).executeAsOne() + assertThat(secondSession.lastUsageIndex).isEqualTo(1) + assertThat(secondSession.position).isEqualTo(1) + // Set the first session as the latest + databaseSessionStore.setLatestSession(aSessionData.userId) + val firstSession = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne() + assertThat(firstSession.lastUsageIndex).isEqualTo(2) + assertThat(firstSession.position).isEqualTo(0) + // Check that the second session has not been altered + val secondSession2 = database.sessionDataQueries.selectByUserId(secondSessionData.userId).executeAsOne() + assertThat(secondSession2.lastUsageIndex).isEqualTo(1) + assertThat(secondSession2.position).isEqualTo(1) + } + + @Test + fun `test sessionsFlow()`() = runTest { + databaseSessionStore.sessionsFlow().test { + assertThat(awaitItem()).isEmpty() + databaseSessionStore.addSession(aSessionData.toApiModel()) + assertThat(awaitItem().size).isEqualTo(1) + val secondSessionData = aSessionData.copy( + userId = "otherUserId", + position = 1, + lastUsageIndex = 1, + ) + assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(1) + databaseSessionStore.addSession(secondSessionData.toApiModel()) + assertThat(awaitItem().size).isEqualTo(2) + assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(2) + databaseSessionStore.removeSession(aSessionData.userId) + assertThat(awaitItem().size).isEqualTo(1) + assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(1) + databaseSessionStore.removeSession(secondSessionData.userId) + assertThat(awaitItem()).isEmpty() + assertThat(database.sessionDataQueries.count().executeAsOneOrNull()).isEqualTo(0) + } + } + + @Test + fun `update session update all fields except info used by the application`() = runTest { + val firstSessionData = SessionData( + userId = "userId", + deviceId = "deviceId", + accessToken = "accessToken", + refreshToken = "refreshToken", + homeserverUrl = "homeserverUrl", + loginTimestamp = 1, + oidcData = "aOidcData", + isTokenValid = 1, + loginType = null, + passphrase = "aPassphrase", + sessionPath = "sessionPath", + cachePath = "cachePath", + position = 0, + lastUsageIndex = 0, + userDisplayName = "userDisplayName", + userAvatarUrl = "userAvatarUrl", + ) + val secondSessionData = SessionData( + userId = "userId", + deviceId = "deviceIdAltered", + accessToken = "accessTokenAltered", + refreshToken = "refreshTokenAltered", + homeserverUrl = "homeserverUrlAltered", + loginTimestamp = 2, + oidcData = "aOidcDataAltered", + isTokenValid = 1, + loginType = null, + passphrase = "aPassphraseAltered", + sessionPath = "sessionPathAltered", + cachePath = "cachePathAltered", + position = 1, + lastUsageIndex = 1, + userDisplayName = "userDisplayNameAltered", + userAvatarUrl = "userAvatarUrlAltered", + ) + assertThat(firstSessionData.userId).isEqualTo(secondSessionData.userId) + assertThat(firstSessionData.loginTimestamp).isNotEqualTo(secondSessionData.loginTimestamp) + + database.sessionDataQueries.insertSessionData(firstSessionData) + databaseSessionStore.updateData(secondSessionData.toApiModel()) + + // Get the altered session + val alteredSession = databaseSessionStore.getSession(firstSessionData.userId)!!.toDbModel() + + assertThat(alteredSession.userId).isEqualTo(secondSessionData.userId) + assertThat(alteredSession.deviceId).isEqualTo(secondSessionData.deviceId) + assertThat(alteredSession.accessToken).isEqualTo(secondSessionData.accessToken) + assertThat(alteredSession.refreshToken).isEqualTo(secondSessionData.refreshToken) + assertThat(alteredSession.homeserverUrl).isEqualTo(secondSessionData.homeserverUrl) + // Check that alteredSession.loginTimestamp is not altered, so equal to firstSessionData.loginTimestamp + assertThat(alteredSession.loginTimestamp).isEqualTo(firstSessionData.loginTimestamp) + assertThat(alteredSession.oidcData).isEqualTo(secondSessionData.oidcData) + assertThat(alteredSession.passphrase).isEqualTo(secondSessionData.passphrase) + // Check that application data have not been altered + assertThat(alteredSession.position).isEqualTo(firstSessionData.position) + assertThat(alteredSession.lastUsageIndex).isEqualTo(firstSessionData.lastUsageIndex) + assertThat(alteredSession.userDisplayName).isEqualTo(firstSessionData.userDisplayName) + assertThat(alteredSession.userAvatarUrl).isEqualTo(firstSessionData.userAvatarUrl) + } + + @Test + fun `update data, session not found`() = runTest { + val firstSessionData = SessionData( + userId = "userId", + deviceId = "deviceId", + accessToken = "accessToken", + refreshToken = "refreshToken", + homeserverUrl = "homeserverUrl", + loginTimestamp = 1, + oidcData = "aOidcData", + isTokenValid = 1, + loginType = LoginType.PASSWORD.name, + passphrase = "aPassphrase", + sessionPath = "sessionPath", + cachePath = "cachePath", + position = 0, + lastUsageIndex = 0, + userDisplayName = "userDisplayName", + userAvatarUrl = "userAvatarUrl", + ) + val secondSessionData = SessionData( + userId = "userIdUnknown", + deviceId = "deviceIdAltered", + accessToken = "accessTokenAltered", + refreshToken = "refreshTokenAltered", + homeserverUrl = "homeserverUrlAltered", + loginTimestamp = 2, + oidcData = "aOidcDataAltered", + isTokenValid = 1, + loginType = LoginType.PASSWORD.name, + passphrase = "aPassphraseAltered", + sessionPath = "sessionPathAltered", + cachePath = "cachePathAltered", + position = 1, + lastUsageIndex = 1, + userDisplayName = "userDisplayNameAltered", + userAvatarUrl = "userAvatarUrlAltered", + ) + assertThat(firstSessionData.userId).isNotEqualTo(secondSessionData.userId) + + database.sessionDataQueries.insertSessionData(firstSessionData) + databaseSessionStore.updateData(secondSessionData.toApiModel()) + + // Get the session and check that it has not been altered + val notAlteredSession = databaseSessionStore.getSession(firstSessionData.userId)!!.toDbModel() + + assertThat(notAlteredSession).isEqualTo(firstSessionData) + } +} diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt new file mode 100644 index 0000000..4a48858 --- /dev/null +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.sessionstorage.impl + +import io.element.android.libraries.matrix.session.SessionData +import io.element.android.libraries.sessionstorage.api.LoginType + +internal fun aDbSessionData( + userId: String = "userId", +) = SessionData( + userId = userId, + deviceId = "deviceId", + accessToken = "accessToken", + refreshToken = "refreshToken", + homeserverUrl = "homeserverUrl", + loginTimestamp = null, + oidcData = "aOidcData", + isTokenValid = 1, + loginType = LoginType.UNKNOWN.name, + passphrase = null, + sessionPath = "sessionPath", + cachePath = "cachePath", + position = 0, + lastUsageIndex = 0, + userDisplayName = null, + userAvatarUrl = null, +) diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserverTest.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserverTest.kt new file mode 100644 index 0000000..54b41a2 --- /dev/null +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/observer/DefaultSessionObserverTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.sessionstorage.impl.observer + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.sessionstorage.impl.DatabaseSessionStore +import io.element.android.libraries.sessionstorage.impl.SessionDatabase +import io.element.android.libraries.sessionstorage.impl.aDbSessionData +import io.element.android.libraries.sessionstorage.impl.toApiModel +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultSessionObserverTest { + private lateinit var database: SessionDatabase + private lateinit var databaseSessionStore: DatabaseSessionStore + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + // Initialise in memory SQLite driver + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + SessionDatabase.Schema.create(driver) + database = SessionDatabase(driver) + databaseSessionStore = DatabaseSessionStore( + database = database, + dispatchers = CoroutineDispatchers( + io = UnconfinedTestDispatcher(), + computation = UnconfinedTestDispatcher(), + main = UnconfinedTestDispatcher(), + ) + ) + } + + @Test + fun `adding data invokes onSessionCreated`() = runTest { + val sessionData = aDbSessionData() + val sut = createDefaultSessionObserver() + runCurrent() + val listener = TestSessionListener() + sut.addListener(listener) + databaseSessionStore.addSession(sessionData.toApiModel()) + listener.assertEvents(TestSessionListener.Event.Created(sessionData.userId)) + sut.removeListener(listener) + } + + @Test + fun `adding and deleting data invokes onSessionCreated and onSessionDeleted`() = runTest { + val sessionData = aDbSessionData() + val sut = createDefaultSessionObserver() + runCurrent() + val listener = TestSessionListener() + sut.addListener(listener) + databaseSessionStore.addSession(sessionData.toApiModel()) + listener.assertEvents(TestSessionListener.Event.Created(sessionData.userId)) + databaseSessionStore.removeSession(sessionData.userId) + listener.assertEvents( + TestSessionListener.Event.Created(sessionData.userId), + TestSessionListener.Event.Deleted(sessionData.userId, true), + ) + } + + @Test + fun `adding and deleting data twice invokes onSessionCreated and onSessionDeleted`() = runTest { + val sessionData1 = aDbSessionData(userId = "user1") + val sessionData2 = aDbSessionData(userId = "user2") + val sut = createDefaultSessionObserver() + runCurrent() + val listener = TestSessionListener() + sut.addListener(listener) + databaseSessionStore.addSession(sessionData1.toApiModel()) + databaseSessionStore.addSession(sessionData2.toApiModel()) + databaseSessionStore.removeSession(sessionData2.userId) + databaseSessionStore.removeSession(sessionData1.userId) + listener.assertEvents( + TestSessionListener.Event.Created(sessionData1.userId), + TestSessionListener.Event.Created(sessionData2.userId), + TestSessionListener.Event.Deleted(sessionData2.userId, wasLastSession = false), + TestSessionListener.Event.Deleted(sessionData1.userId, wasLastSession = true), + ) + } + + private fun TestScope.createDefaultSessionObserver(): DefaultSessionObserver { + return DefaultSessionObserver( + sessionStore = databaseSessionStore, + coroutineScope = backgroundScope, + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + ) + } +} diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/observer/TestSessionListener.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/observer/TestSessionListener.kt new file mode 100644 index 0000000..f104039 --- /dev/null +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/observer/TestSessionListener.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.sessionstorage.impl.observer + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.sessionstorage.api.observer.SessionListener + +class TestSessionListener : SessionListener { + sealed interface Event { + data class Created(val userId: String) : Event + data class Deleted(val userId: String, val wasLastSession: Boolean) : Event + } + + private val trackRecord: MutableList = mutableListOf() + + override suspend fun onSessionCreated(userId: String) { + trackRecord.add(Event.Created(userId)) + } + + override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) { + trackRecord.add(Event.Deleted(userId, wasLastSession)) + } + + fun assertEvents(vararg events: Event) { + assertThat(trackRecord).containsExactly(*events) + } +} diff --git a/libraries/session-storage/test/build.gradle.kts b/libraries/session-storage/test/build.gradle.kts new file mode 100644 index 0000000..cfdc301 --- /dev/null +++ b/libraries/session-storage/test/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.sessionstorage.test" +} + +dependencies { + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.sessionStorage.api) +} diff --git a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt new file mode 100644 index 0000000..6cab993 --- /dev/null +++ b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.sessionstorage.test + +import io.element.android.libraries.sessionstorage.api.LoggedInState +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.asStateFlow +import kotlinx.coroutines.flow.map + +class InMemorySessionStore( + initialList: List = emptyList(), + private val updateUserProfileResult: (String, String?, String?) -> Unit = { _, _, _ -> error("Not implemented") }, + private val setLatestSessionResult: (String) -> Unit = { error("Not implemented") }, +) : SessionStore { + private val sessionDataListFlow = MutableStateFlow(initialList) + + override fun loggedInStateFlow(): Flow { + return sessionDataListFlow.map { + if (it.isEmpty()) { + LoggedInState.NotLoggedIn + } else { + it.first().let { sessionData -> + LoggedInState.LoggedIn( + sessionId = sessionData.userId, + isTokenValid = sessionData.isTokenValid, + ) + } + } + } + } + + override fun sessionsFlow(): Flow> = sessionDataListFlow.asStateFlow() + + override suspend fun addSession(sessionData: SessionData) { + val currentList = sessionDataListFlow.value.toMutableList() + currentList.removeAll { it.userId == sessionData.userId } + currentList.add(sessionData) + sessionDataListFlow.value = currentList + } + + override suspend fun updateData(sessionData: SessionData) { + val currentList = sessionDataListFlow.value.toMutableList() + val index = currentList.indexOfFirst { it.userId == sessionData.userId } + if (index != -1) { + currentList[index] = sessionData + sessionDataListFlow.value = currentList + } + } + + override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) { + updateUserProfileResult(sessionId, displayName, avatarUrl) + } + + override suspend fun getSession(sessionId: String): SessionData? { + return sessionDataListFlow.value.firstOrNull { it.userId == sessionId } + } + + override suspend fun getAllSessions(): List { + return sessionDataListFlow.value + } + + override suspend fun numberOfSessions(): Int { + return sessionDataListFlow.value.size + } + + override suspend fun getLatestSession(): SessionData? { + return sessionDataListFlow.value.firstOrNull() + } + + override suspend fun setLatestSession(sessionId: String) { + setLatestSessionResult(sessionId) + } + + override suspend fun removeSession(sessionId: String) { + val currentList = sessionDataListFlow.value.toMutableList() + currentList.removeAll { it.userId == sessionId } + sessionDataListFlow.value = currentList + } +} diff --git a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/SessionData.kt b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/SessionData.kt new file mode 100644 index 0000000..c791a20 --- /dev/null +++ b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/SessionData.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.sessionstorage.test + +import io.element.android.libraries.sessionstorage.api.LoginType +import io.element.android.libraries.sessionstorage.api.SessionData + +fun aSessionData( + sessionId: String = "@alice:server.org", + deviceId: String = "aDeviceId", + isTokenValid: Boolean = false, + sessionPath: String = "/a/path/to/a/session", + cachePath: String = "/a/path/to/a/cache", + accessToken: String = "anAccessToken", + refreshToken: String? = "aRefreshToken", + position: Long = 0, + lastUsageIndex: Long = 0, + userDisplayName: String? = null, + userAvatarUrl: String? = null, +): SessionData { + return SessionData( + userId = sessionId, + deviceId = deviceId, + accessToken = accessToken, + refreshToken = refreshToken, + homeserverUrl = "aHomeserverUrl", + oidcData = null, + loginTimestamp = null, + isTokenValid = isTokenValid, + loginType = LoginType.UNKNOWN, + passphrase = null, + sessionPath = sessionPath, + cachePath = cachePath, + position = position, + lastUsageIndex = lastUsageIndex, + userDisplayName = userDisplayName, + userAvatarUrl = userAvatarUrl, + ) +} diff --git a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/observer/FakeSessionObserver.kt b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/observer/FakeSessionObserver.kt new file mode 100644 index 0000000..fdf5cc5 --- /dev/null +++ b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/observer/FakeSessionObserver.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.sessionstorage.test.observer + +import io.element.android.libraries.sessionstorage.api.observer.SessionListener +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver + +class FakeSessionObserver : SessionObserver { + private val _listeners = mutableListOf() + + val listeners: List + get() = _listeners + + override fun addListener(listener: SessionListener) { + _listeners.add(listener) + } + + override fun removeListener(listener: SessionListener) { + _listeners.remove(listener) + } + + suspend fun onSessionCreated(userId: String) { + listeners.forEach { it.onSessionCreated(userId) } + } + + suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean = true) { + listeners.forEach { it.onSessionDeleted(userId, wasLastSession = wasLastSession) } + } +} diff --git a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/observer/NoOpSessionObserver.kt b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/observer/NoOpSessionObserver.kt new file mode 100644 index 0000000..ff1010a --- /dev/null +++ b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/observer/NoOpSessionObserver.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.sessionstorage.test.observer + +import io.element.android.libraries.sessionstorage.api.observer.SessionListener +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver + +class NoOpSessionObserver : SessionObserver { + override fun addListener(listener: SessionListener) = Unit + override fun removeListener(listener: SessionListener) = Unit +} diff --git a/libraries/testtags/build.gradle.kts b/libraries/testtags/build.gradle.kts new file mode 100644 index 0000000..68f5a34 --- /dev/null +++ b/libraries/testtags/build.gradle.kts @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.testtags" +} 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 0000000..21ce053 --- /dev/null +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/Compose.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.testtags + +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. + */ +fun Modifier.testTag(id: TestTag) = 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 0000000..e564dda --- /dev/null +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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") + + /** + * Verification screen. + */ + val recoveryKey = TestTag("verification-recovery_key") + + /** + * Sign out screen. + */ + val signOut = TestTag("sign-out-submit") + + /** + * Change server screen. + */ + val changeServerServer = TestTag("change_server-server") + + /** + * Room list / Home screen. + */ + val homeScreenSettings = TestTag("home_screen-settings") + val homeScreenClearFilters = TestTag("home_screen-clear_filters") + + /** + * Room detail screen. + */ + val roomDetailAvatar = TestTag("room_detail-avatar") + + /** + * Room member screen. + */ + val memberDetailAvatar = TestTag("member_detail-avatar") + + /** + * Edit avatar. + */ + val editAvatar = TestTag("edit-avatar") + + /** + * Welcome screen. + */ + val welcomeScreenTitle = TestTag("welcome_screen-title") + + /** + * TextEditor. + */ + val textEditor = TestTag("text_editor") + + /** + * EditText inside the MarkdownTextInput. + */ + val plainTextEditor = TestTag("plain_text_editor") + + /** + * Message bubble. + */ + val messageBubble = TestTag("message_bubble") + + /** + * Message Read Receipts. + */ + val messageReadReceipts = TestTag("message_read_receipts") + + /** + * Dialogs. + */ + val dialogPositive = TestTag("dialog-positive") + val dialogNegative = TestTag("dialog-negative") + val dialogNeutral = TestTag("dialog-neutral") + + /** + * Floating Action Button. + */ + val floatingActionButton = TestTag("floating-action-button") + + /** + * Timeline. + */ + val timeline = TestTag("timeline") + + /** + * Timeline item. + */ + val timelineItemSenderAvatar = TestTag("timeline_item-sender_avatar") + val timelineItemSenderName = TestTag("timeline_item-sender_name") + + /** + * Search field. + */ + val searchTextField = TestTag("search_text_field") + + /** + * Generic call to action. + */ + val callToAction = TestTag("call_to_action") + + /** + * Room address field. + * + */ + val roomAddressField = TestTag("room_address_field") +} diff --git a/libraries/textcomposer/impl/build.gradle.kts b/libraries/textcomposer/impl/build.gradle.kts new file mode 100644 index 0000000..a339890 --- /dev/null +++ b/libraries/textcomposer/impl/build.gradle.kts @@ -0,0 +1,49 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.libraries.textcomposer" + testOptions { + unitTests.isIncludeAndroidResources = true + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.libraries.architecture) + 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) + implementation(projects.libraries.testtags) + implementation(projects.libraries.uiUtils) + + releaseApi(libs.matrix.richtexteditor) + releaseApi(libs.matrix.richtexteditor.compose) + if (file("${rootDir.path}/libraries/textcomposer/lib/library-compose.aar").exists()) { + println("\nNote: Using local binaries of the Rich Text Editor.\n") + debugApi(projects.libraries.textcomposer.lib) + } else { + debugApi(libs.matrix.richtexteditor) + debugApi(libs.matrix.richtexteditor.compose) + } + + testCommonDependencies(libs, true) + testImplementation(projects.libraries.matrix.test) +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/CaptionWarningBottomSheet.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/CaptionWarningBottomSheet.kt new file mode 100644 index 0000000..23b4fae --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/CaptionWarningBottomSheet.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer + +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.ExperimentalMaterial3Api +import androidx.compose.material3.Text +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.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CaptionWarningBottomSheet( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismiss, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + BigIcon( + style = BigIcon.Style.AlertSolid, + ) + Text( + text = stringResource(R.string.screen_media_upload_preview_caption_warning), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + OutlinedButton( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + onClick = onDismiss, + text = stringResource(CommonStrings.action_ok), + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun CaptionWarningBottomSheetPreview() = ElementPreview { + CaptionWarningBottomSheet( + onDismiss = {}, + ) +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt new file mode 100644 index 0000000..ac64245 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer + +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ripple +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.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.messages.reply.InReplyToDetails +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToView +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun ComposerModeView( + composerMode: MessageComposerMode.Special, + onResetComposerMode: () -> Unit, + modifier: Modifier = Modifier, +) { + when (composerMode) { + is MessageComposerMode.Edit -> { + EditingModeView( + text = stringResource(CommonStrings.common_editing), + modifier = modifier, + onResetComposerMode = onResetComposerMode, + ) + } + is MessageComposerMode.EditCaption -> { + EditingModeView( + text = stringResource( + if (composerMode.content.isEmpty()) CommonStrings.common_adding_caption else CommonStrings.common_editing_caption + ), + modifier = modifier, + onResetComposerMode = onResetComposerMode, + ) + } + is MessageComposerMode.Reply -> { + ReplyToModeView( + modifier = modifier.padding(8.dp), + replyToDetails = composerMode.replyToDetails, + hideImage = composerMode.hideImage, + onResetComposerMode = onResetComposerMode, + ) + } + } +} + +@Composable +private fun EditingModeView( + onResetComposerMode: () -> Unit, + text: String, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .padding(start = 12.dp) + ) { + Icon( + imageVector = CompoundIcons.Edit(), + contentDescription = null, + tint = ElementTheme.colors.iconSecondary, + modifier = Modifier + .padding(vertical = 8.dp) + .size(16.dp), + ) + Text( + text = text, + style = ElementTheme.typography.fontBodySmRegular, + textAlign = TextAlign.Start, + color = ElementTheme.colors.textSecondary, + modifier = Modifier + .padding(vertical = 8.dp) + .weight(1f) + ) + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_close), + tint = ElementTheme.colors.iconSecondary, + modifier = Modifier + .padding(top = 8.dp, bottom = 8.dp, start = 16.dp, end = 12.dp) + .size(16.dp) + .clickable( + enabled = true, + onClick = onResetComposerMode, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = false) + ), + ) + } +} + +@Composable +private fun ReplyToModeView( + replyToDetails: InReplyToDetails, + hideImage: Boolean, + onResetComposerMode: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier + .clip(RoundedCornerShape(13.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(4.dp) + ) { + InReplyToView( + inReplyTo = replyToDetails, + hideImage = hideImage, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_close), + tint = ElementTheme.colors.iconSecondary, + modifier = Modifier + .padding(end = 4.dp, top = 4.dp, start = 8.dp, bottom = 16.dp) + .size(16.dp) + .clickable( + enabled = true, + onClick = onResetComposerMode, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = false) + ), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun ComposerModeViewPreview( + @PreviewParameter(MessageComposerModeSpecialProvider::class) mode: MessageComposerMode.Special +) = ElementPreview { + ComposerModeView( + composerMode = mode, + onResetComposerMode = {}, + modifier = Modifier.background(ElementTheme.colors.bgSubtleSecondary) + ) +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ElementRichTextEditorStyle.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ElementRichTextEditorStyle.kt new file mode 100644 index 0000000..57f0abe --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ElementRichTextEditorStyle.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer + +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.bgSubtleTertiary +import io.element.android.wysiwyg.compose.RichTextEditorDefaults +import io.element.android.wysiwyg.compose.RichTextEditorStyle + +object ElementRichTextEditorStyle { + @Composable + fun composerStyle( + hasFocus: Boolean, + ): RichTextEditorStyle { + val baseStyle = common() + return baseStyle.copy( + text = baseStyle.text.copy( + color = if (hasFocus) { + ElementTheme.colors.textPrimary + } else { + ElementTheme.colors.textSecondary + }, + placeholderColor = ElementTheme.colors.textSecondary, + lineHeight = TextUnit.Unspecified, + includeFontPadding = true, + ) + ) + } + + @Composable + fun textStyle(): RichTextEditorStyle { + return common() + } + + @Composable + private fun common(): RichTextEditorStyle { + val colors = ElementTheme.colors + val codeCornerRadius = 4.dp + val codeBorderWidth = 1.dp + return RichTextEditorDefaults.style( + text = RichTextEditorDefaults.textStyle( + color = LocalTextStyle.current.color.takeIf { it.isSpecified } ?: LocalContentColor.current, + fontStyle = LocalTextStyle.current.fontStyle, + lineHeight = LocalTextStyle.current.lineHeight, + includeFontPadding = false, + ), + cursor = RichTextEditorDefaults.cursorStyle( + color = colors.iconAccentTertiary, + ), + link = RichTextEditorDefaults.linkStyle( + color = colors.textLinkExternal, + ), + codeBlock = RichTextEditorDefaults.codeBlockStyle( + leadingMargin = 8.dp, + background = RichTextEditorDefaults.codeBlockBackgroundStyle( + color = colors.bgSubtleTertiary, + borderColor = colors.borderInteractiveSecondary, + cornerRadius = codeCornerRadius, + borderWidth = codeBorderWidth, + ) + ), + inlineCode = RichTextEditorDefaults.inlineCodeStyle( + verticalPadding = 0.dp, + background = RichTextEditorDefaults.inlineCodeBackgroundStyle( + color = colors.bgSubtleTertiary, + borderColor = colors.borderInteractiveSecondary, + cornerRadius = codeCornerRadius, + borderWidth = codeBorderWidth, + ) + ), + ) + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerModeSpecialProvider.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerModeSpecialProvider.kt new file mode 100644 index 0000000..60850d2 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/MessageComposerModeSpecialProvider.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider +import io.element.android.libraries.textcomposer.model.MessageComposerMode + +class MessageComposerModeSpecialProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + aMessageComposerModeEdit() + ) + + // Keep only 3 values from InReplyToDetailsProvider + InReplyToDetailsProvider().values.take(3).map { + aMessageComposerModeReply( + replyToDetails = it + ) + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/SoftKeyboardEffect.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/SoftKeyboardEffect.kt new file mode 100644 index 0000000..55a985e --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/SoftKeyboardEffect.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.viewinterop.AndroidView +import io.element.android.libraries.androidutils.ui.awaitWindowFocus +import io.element.android.libraries.androidutils.ui.isKeyboardVisible +import io.element.android.libraries.androidutils.ui.showKeyboard + +/** + * Shows the soft keyboard when a given key changes to meet the required condition. + * + * Uses [showKeyboard] to show the keyboard for compatibility with [AndroidView]. + * + * @param T + * @param key The key to watch for changes. + * @param onRequestFocus A callback to request focus to the view that will receive the keyboard input. + * @param predicate The predicate that [key] must meet before showing the keyboard. + */ +@Composable +internal fun SoftKeyboardEffect( + key: T, + onRequestFocus: () -> Unit, + predicate: (T) -> Boolean, +) { + val view = LocalView.current + val latestOnRequestFocus by rememberUpdatedState(onRequestFocus) + val latestPredicate by rememberUpdatedState(predicate) + LaunchedEffect(key) { + if (latestPredicate(key)) { + // Await window focus in case returning from a dialog + view.awaitWindowFocus() + + if (!view.isKeyboardVisible()) { + // Show the keyboard, temporarily using the root view for focus + view.showKeyboard(andRequestFocus = true) + + // Refocus to the correct view + latestOnRequestFocus() + } + } + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt new file mode 100644 index 0000000..f2070c9 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -0,0 +1,965 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer + +import android.content.res.Configuration +import android.net.Uri +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.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +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.requiredHeightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +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.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.androidutils.ui.showKeyboard +import io.element.android.libraries.designsystem.components.media.WaveFormSamples +import io.element.android.libraries.designsystem.preview.DAY_MODE_NAME +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.NIGHT_MODE_NAME +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconColorButton +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.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.textcomposer.components.SendButton +import io.element.android.libraries.textcomposer.components.TextFormatting +import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteButton +import io.element.android.libraries.textcomposer.components.VoiceMessagePreview +import io.element.android.libraries.textcomposer.components.VoiceMessageRecorderButton +import io.element.android.libraries.textcomposer.components.VoiceMessageRecording +import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput +import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.TextEditorState +import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent +import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent +import io.element.android.libraries.textcomposer.model.VoiceMessageState +import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown +import io.element.android.libraries.textcomposer.model.aTextEditorStateRich +import io.element.android.libraries.textcomposer.model.showCaptionCompatibilityWarning +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.wysiwyg.compose.RichTextEditor +import io.element.android.wysiwyg.display.TextDisplay +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.launch +import uniffi.wysiwyg_composer.MenuAction +import kotlin.time.Duration.Companion.seconds + +@Composable +fun TextComposer( + state: TextEditorState, + voiceMessageState: VoiceMessageState, + composerMode: MessageComposerMode, + onRequestFocus: () -> Unit, + onSendMessage: () -> Unit, + onResetComposerMode: () -> Unit, + onAddAttachment: () -> Unit, + onDismissTextFormatting: () -> Unit, + onVoiceRecorderEvent: (VoiceMessageRecorderEvent) -> Unit, + onVoicePlayerEvent: (VoiceMessagePlayerEvent) -> Unit, + onSendVoiceMessage: () -> Unit, + onDeleteVoiceMessage: () -> Unit, + onError: (Throwable) -> Unit, + onTyping: (Boolean) -> Unit, + onReceiveSuggestion: (Suggestion?) -> Unit, + onSelectRichContent: ((Uri) -> Unit)?, + resolveMentionDisplay: (text: String, url: String) -> TextDisplay, + resolveAtRoomMentionDisplay: () -> TextDisplay, + modifier: Modifier = Modifier, + showTextFormatting: Boolean = false, +) { + val markdown = when (state) { + is TextEditorState.Markdown -> state.state.text.value() + is TextEditorState.Rich -> state.richTextEditorState.messageMarkdown + } + val onSendClick = { + onSendMessage() + } + + val onPlayVoiceMessageClick = { + onVoicePlayerEvent(VoiceMessagePlayerEvent.Play) + } + + val onPauseVoiceMessageClick = { + onVoicePlayerEvent(VoiceMessagePlayerEvent.Pause) + } + + val onSeekVoiceMessage = { position: Float -> + onVoicePlayerEvent(VoiceMessagePlayerEvent.Seek(position)) + } + + val layoutModifier = modifier + .fillMaxSize() + .height(IntrinsicSize.Min) + + val composerOptionsButton: @Composable () -> Unit = remember(composerMode) { + @Composable { + when (composerMode) { + is MessageComposerMode.Attachment -> { + Spacer(modifier = Modifier.width(9.dp)) + } + is MessageComposerMode.EditCaption -> { + Spacer(modifier = Modifier.width(16.dp)) + } + else -> { + IconColorButton( + onClick = onAddAttachment, + imageVector = CompoundIcons.Plus(), + contentDescription = stringResource(R.string.rich_text_editor_a11y_add_attachment), + ) + } + } + } + } + + val placeholder = if (composerMode.inThread) { + stringResource(id = CommonStrings.action_reply_in_thread) + } else if (composerMode is MessageComposerMode.Attachment || composerMode is MessageComposerMode.EditCaption) { + stringResource(id = R.string.rich_text_editor_composer_caption_placeholder) + } else { + stringResource(id = R.string.rich_text_editor_composer_placeholder) + } + val textInput: @Composable () -> Unit = when (state) { + is TextEditorState.Rich -> { + val coroutineScope = rememberCoroutineScope() + val view = LocalView.current + remember(state.richTextEditorState, composerMode, onResetComposerMode, onError) { + @Composable { + TextInputBox( + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + coroutineScope.launch { + state.requestFocus() + view.showKeyboard() + } + } + .semantics { + hideFromAccessibility() + }, + composerMode = composerMode, + onResetComposerMode = onResetComposerMode, + isTextEmpty = state.richTextEditorState.messageHtml.isEmpty(), + ) { + RichTextEditor( + state = state.richTextEditorState, + placeholder = placeholder, + registerStateUpdates = true, + modifier = Modifier + .padding(top = 6.dp, bottom = 6.dp) + .fillMaxWidth(), + style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.richTextEditorState.hasFocus), + resolveMentionDisplay = resolveMentionDisplay, + resolveRoomMentionDisplay = resolveAtRoomMentionDisplay, + onError = onError, + onRichContentSelected = onSelectRichContent, + onTyping = onTyping, + ) + } + } + } + } + is TextEditorState.Markdown -> { + @Composable { + val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus()) + TextInputBox( + composerMode = composerMode, + onResetComposerMode = onResetComposerMode, + isTextEmpty = state.state.text.value().isEmpty(), + ) { + MarkdownTextInput( + state = state.state, + placeholder = placeholder, + placeholderColor = ElementTheme.colors.textSecondary, + onTyping = onTyping, + onReceiveSuggestion = onReceiveSuggestion, + richTextEditorStyle = style, + onSelectRichContent = onSelectRichContent, + ) + } + } + } + } + + val canSendMessage = markdown.isNotBlank() || composerMode is MessageComposerMode.Attachment + val sendButton = @Composable { + SendButton( + canSendMessage = canSendMessage, + onClick = onSendClick, + composerMode = composerMode, + ) + } + val recordVoiceButton = @Composable { + VoiceMessageRecorderButton( + isRecording = voiceMessageState is VoiceMessageState.Recording, + onEvent = onVoiceRecorderEvent, + ) + } + val sendVoiceButton = @Composable { + SendButton( + canSendMessage = voiceMessageState is VoiceMessageState.Preview, + onClick = onSendVoiceMessage, + composerMode = composerMode, + ) + } + val uploadVoiceProgress = @Composable { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + ) + } + + val textFormattingOptions: @Composable (() -> Unit)? = (state as? TextEditorState.Rich)?.let { + @Composable { TextFormatting(state = it.richTextEditorState) } + } + + val sendOrRecordButton = when { + !canSendMessage -> + when (voiceMessageState) { + VoiceMessageState.Idle, + is VoiceMessageState.Recording -> recordVoiceButton + is VoiceMessageState.Preview -> when (voiceMessageState.isSending) { + true -> uploadVoiceProgress + false -> sendVoiceButton + } + } + else -> sendButton + } + + val endButtonA11y = endButtonA11y( + composerMode = composerMode, + voiceMessageState = voiceMessageState, + canSendMessage = canSendMessage, + ) + + val voiceRecording = @Composable { + when (voiceMessageState) { + is VoiceMessageState.Preview -> + VoiceMessagePreview( + isInteractive = !voiceMessageState.isSending, + isPlaying = voiceMessageState.isPlaying, + showCursor = voiceMessageState.showCursor, + waveform = voiceMessageState.waveform, + playbackProgress = voiceMessageState.playbackProgress, + time = voiceMessageState.time, + onPlayClick = onPlayVoiceMessageClick, + onPauseClick = onPauseVoiceMessageClick, + onSeek = onSeekVoiceMessage, + ) + is VoiceMessageState.Recording -> + VoiceMessageRecording( + levels = voiceMessageState.levels, + duration = voiceMessageState.duration, + ) + VoiceMessageState.Idle -> {} + } + } + + val voiceDeleteButton = @Composable { + when (voiceMessageState) { + is VoiceMessageState.Preview -> + VoiceMessageDeleteButton(enabled = !voiceMessageState.isSending, onClick = onDeleteVoiceMessage) + is VoiceMessageState.Recording -> + VoiceMessageDeleteButton(enabled = true, onClick = { onVoiceRecorderEvent(VoiceMessageRecorderEvent.Cancel) }) + else -> {} + } + } + + if (showTextFormatting && textFormattingOptions != null) { + TextFormattingLayout( + modifier = layoutModifier, + isRoomEncrypted = state.isRoomEncrypted, + textInput = textInput, + dismissTextFormattingButton = { + IconColorButton( + onClick = onDismissTextFormatting, + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(R.string.rich_text_editor_close_formatting_options), + ) + }, + textFormatting = textFormattingOptions, + endButtonA11y = endButtonA11y, + sendButton = sendButton, + ) + } else { + StandardLayout( + voiceMessageState = voiceMessageState, + isRoomEncrypted = state.isRoomEncrypted, + modifier = layoutModifier, + composerOptionsButton = composerOptionsButton, + textInput = textInput, + endButton = sendOrRecordButton, + endButtonA11y = endButtonA11y, + voiceRecording = voiceRecording, + voiceDeleteButton = voiceDeleteButton, + ) + } + + SoftKeyboardEffect(composerMode, onRequestFocus) { + it is MessageComposerMode.Special + } + + SoftKeyboardEffect(showTextFormatting, onRequestFocus) { it } + + val latestOnReceiveSuggestion by rememberUpdatedState(onReceiveSuggestion) + if (state is TextEditorState.Rich) { + val menuAction = state.richTextEditorState.menuAction + LaunchedEffect(menuAction) { + if (menuAction is MenuAction.Suggestion) { + val suggestion = Suggestion(menuAction.suggestionPattern) + latestOnReceiveSuggestion(suggestion) + } else { + latestOnReceiveSuggestion(null) + } + } + } +} + +@ReadOnlyComposable +@Composable +private fun endButtonA11y( + composerMode: MessageComposerMode, + voiceMessageState: VoiceMessageState, + canSendMessage: Boolean, +): (SemanticsPropertyReceiver) -> Unit { + val a11ySendButtonDescription = stringResource( + id = when { + !canSendMessage -> + when (voiceMessageState) { + VoiceMessageState.Idle, + is VoiceMessageState.Recording -> if (voiceMessageState is VoiceMessageState.Recording) { + CommonStrings.a11y_voice_message_stop_recording + } else { + CommonStrings.a11y_voice_message_record + } + is VoiceMessageState.Preview -> when (voiceMessageState.isSending) { + true -> CommonStrings.common_sending + false -> CommonStrings.action_send_voice_message + } + } + composerMode.isEditing -> CommonStrings.action_send_edited_message + else -> CommonStrings.action_send_message + } + ) + val endButtonA11y: (SemanticsPropertyReceiver.() -> Unit) = { + contentDescription = a11ySendButtonDescription + onClick(null, null) + } + return endButtonA11y +} + +@Composable +private fun StandardLayout( + voiceMessageState: VoiceMessageState, + isRoomEncrypted: Boolean?, + textInput: @Composable () -> Unit, + composerOptionsButton: @Composable () -> Unit, + voiceRecording: @Composable () -> Unit, + voiceDeleteButton: @Composable () -> Unit, + endButton: @Composable () -> Unit, + endButtonA11y: (SemanticsPropertyReceiver.() -> Unit), + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + if (isRoomEncrypted == false) { + Spacer(Modifier.height(16.dp)) + NotEncryptedBadge() + Spacer(Modifier.height(4.dp)) + } + Row(verticalAlignment = Alignment.Bottom) { + if (voiceMessageState !is VoiceMessageState.Idle) { + if (voiceMessageState is VoiceMessageState.Preview || voiceMessageState is VoiceMessageState.Recording) { + Box( + modifier = Modifier + .padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp) + .size(48.dp), + contentAlignment = Alignment.Center, + ) { + voiceDeleteButton() + } + } else { + Spacer(modifier = Modifier.width(16.dp)) + } + Box( + modifier = Modifier + .padding(bottom = 8.dp, top = 8.dp) + .weight(1f) + ) { + voiceRecording() + } + } else { + Box( + Modifier + .padding(bottom = 5.dp, top = 5.dp, start = 3.dp) + ) { + composerOptionsButton() + } + Box( + modifier = Modifier + .padding(bottom = 8.dp, top = 8.dp) + .weight(1f) + ) { + textInput() + } + } + Box( + Modifier + .padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp) + .size(48.dp) + .clearAndSetSemantics(endButtonA11y), + contentAlignment = Alignment.Center, + ) { + endButton() + } + } + } +} + +@Composable +private fun NotEncryptedBadge() { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = CompoundIcons.LockOff(), + contentDescription = null, + tint = ElementTheme.colors.iconInfoPrimary, + ) + Spacer(Modifier.width(4.dp)) + Text( + text = stringResource(CommonStrings.common_not_encrypted), + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } +} + +@Composable +private fun TextFormattingLayout( + isRoomEncrypted: Boolean?, + textInput: @Composable () -> Unit, + dismissTextFormattingButton: @Composable () -> Unit, + textFormatting: @Composable () -> Unit, + sendButton: @Composable () -> Unit, + endButtonA11y: (SemanticsPropertyReceiver.() -> Unit), + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.padding(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (isRoomEncrypted == false) { + NotEncryptedBadge() + Spacer(Modifier.height(8.dp)) + } + Box( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp) + ) { + textInput() + } + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Box( + modifier = Modifier.padding(start = 3.dp) + ) { + dismissTextFormattingButton() + } + Box(modifier = Modifier.weight(1f)) { + textFormatting() + } + Box( + modifier = Modifier + .padding( + start = 14.dp, + end = 6.dp, + ) + .clearAndSetSemantics(endButtonA11y) + ) { + sendButton() + } + } + } +} + +@Composable +private fun TextInputBox( + composerMode: MessageComposerMode, + onResetComposerMode: () -> Unit, + isTextEmpty: Boolean, + modifier: Modifier = Modifier, + textInput: @Composable () -> Unit, +) { + val bgColor = ElementTheme.colors.bgSubtleSecondary + val borderColor = ElementTheme.colors.borderDisabled + val roundedCorners = textInputRoundedCornerShape(composerMode = composerMode) + + Column( + modifier = Modifier + .clip(roundedCorners) + .border(0.5.dp, borderColor, roundedCorners) + .background(color = bgColor) + .requiredHeightIn(min = 42.dp) + .fillMaxSize() + .then(modifier), + ) { + if (composerMode is MessageComposerMode.Special) { + ComposerModeView( + composerMode = composerMode, + onResetComposerMode = onResetComposerMode, + ) + } + Box( + modifier = Modifier + .padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 12.dp) + .then(Modifier.testTag(TestTags.textEditor)), + contentAlignment = Alignment.CenterStart, + ) { + textInput() + if (isTextEmpty && composerMode.showCaptionCompatibilityWarning()) { + var showBottomSheet by remember { mutableStateOf(false) } + Icon( + modifier = Modifier + .clickable { showBottomSheet = true } + .padding(horizontal = 8.dp, vertical = 4.dp) + .align(Alignment.CenterEnd), + imageVector = CompoundIcons.InfoSolid(), + tint = ElementTheme.colors.iconCriticalPrimary, + contentDescription = null, + ) + if (showBottomSheet) { + CaptionWarningBottomSheet( + onDismiss = { showBottomSheet = false }, + ) + } + } + } + } +} + +private fun aTextEditorStateMarkdownList(isRoomEncrypted: Boolean? = null) = persistentListOf( + aTextEditorStateMarkdown(initialText = "", initialFocus = true, isRoomEncrypted = isRoomEncrypted), + aTextEditorStateMarkdown(initialText = "A message", initialFocus = true, isRoomEncrypted = isRoomEncrypted), + aTextEditorStateMarkdown( + initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", + initialFocus = true, + isRoomEncrypted = isRoomEncrypted, + ), + aTextEditorStateMarkdown(initialText = "A message without focus", initialFocus = false, isRoomEncrypted = isRoomEncrypted), +) + +private fun aTextEditorStateRichList(isRoomEncrypted: Boolean? = null) = persistentListOf( + aTextEditorStateRich(initialFocus = true, isRoomEncrypted = isRoomEncrypted), + aTextEditorStateRich(initialText = "A message", initialFocus = true, isRoomEncrypted = isRoomEncrypted), + aTextEditorStateRich( + initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", + initialFocus = true, + isRoomEncrypted = isRoomEncrypted, + ), + aTextEditorStateRich(initialText = "A message without focus", initialFocus = false, isRoomEncrypted = isRoomEncrypted), +) + +@PreviewsDayNight +@Composable +internal fun TextComposerSimplePreview() = ElementPreview { + PreviewColumn( + items = aTextEditorStateMarkdownList() + ) { textEditorState -> + ATextComposer( + state = textEditorState, + voiceMessageState = VoiceMessageState.Idle, + composerMode = MessageComposerMode.Normal, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TextComposerSimpleNotEncryptedPreview() = ElementPreview { + PreviewColumn( + items = aTextEditorStateMarkdownList(isRoomEncrypted = false), + ) { textEditorState -> + ATextComposer( + state = textEditorState, + voiceMessageState = VoiceMessageState.Idle, + composerMode = MessageComposerMode.Normal, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TextComposerFormattingPreview() = ElementPreview { + PreviewColumn( + items = aTextEditorStateRichList() + ) { textEditorState -> + ATextComposer( + state = textEditorState, + voiceMessageState = VoiceMessageState.Idle, + showTextFormatting = true, + composerMode = MessageComposerMode.Normal, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TextComposerFormattingNotEncryptedPreview() = ElementPreview { + PreviewColumn( + items = aTextEditorStateRichList(isRoomEncrypted = false) + ) { textEditorState -> + ATextComposer( + state = textEditorState, + voiceMessageState = VoiceMessageState.Idle, + showTextFormatting = true, + composerMode = MessageComposerMode.Normal, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TextComposerEditPreview() = ElementPreview { + PreviewColumn( + items = aTextEditorStateRichList() + ) { textEditorState -> + ATextComposer( + state = textEditorState, + voiceMessageState = VoiceMessageState.Idle, + composerMode = aMessageComposerModeEdit(), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TextComposerEditNotEncryptedPreview() = ElementPreview { + PreviewColumn( + items = aTextEditorStateRichList(isRoomEncrypted = false) + ) { textEditorState -> + ATextComposer( + state = textEditorState, + voiceMessageState = VoiceMessageState.Idle, + composerMode = aMessageComposerModeEdit(), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TextComposerEditCaptionPreview() = ElementPreview { + PreviewColumn( + items = aTextEditorStateRichList() + ) { textEditorState -> + ATextComposer( + state = textEditorState, + voiceMessageState = VoiceMessageState.Idle, + composerMode = aMessageComposerModeEditCaption( + // Set an existing caption so that the UI will be in edit caption mode + content = "An existing caption", + ), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TextComposerAddCaptionPreview() = ElementPreview { + PreviewColumn( + items = aTextEditorStateRichList() + ) { textEditorState -> + ATextComposer( + state = textEditorState, + voiceMessageState = VoiceMessageState.Idle, + composerMode = aMessageComposerModeEditCaption( + // No caption so that the UI will be in add caption mode + content = "", + ), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun MarkdownTextComposerEditPreview() = ElementPreview { + PreviewColumn( + items = aTextEditorStateMarkdownList() + ) { textEditorState -> + ATextComposer( + state = textEditorState, + voiceMessageState = VoiceMessageState.Idle, + composerMode = aMessageComposerModeEdit(), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TextComposerReplyPreview(@PreviewParameter(InReplyToDetailsProvider::class) inReplyToDetails: InReplyToDetails) = ElementPreview { + PreviewColumn( + items = aTextEditorStateRichList() + ) { textEditorState -> + ATextComposer( + state = textEditorState, + voiceMessageState = VoiceMessageState.Idle, + composerMode = aMessageComposerModeReply( + replyToDetails = inReplyToDetails, + ), + ) + } +} + +@Preview( + name = DAY_MODE_NAME, + heightDp = 800, +) +@Preview( + name = NIGHT_MODE_NAME, + uiMode = Configuration.UI_MODE_NIGHT_YES, + heightDp = 800, +) +@Composable +internal fun TextComposerReplyNotEncryptedPreview(@PreviewParameter(InReplyToDetailsProvider::class) inReplyToDetails: InReplyToDetails) = ElementPreview { + PreviewColumn( + items = aTextEditorStateRichList(isRoomEncrypted = false) + ) { textEditorState -> + ATextComposer( + state = textEditorState, + voiceMessageState = VoiceMessageState.Idle, + composerMode = aMessageComposerModeReply( + replyToDetails = inReplyToDetails, + ), + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TextComposerCaptionPreview() = ElementPreview { + val list = aTextEditorStateMarkdownList() + PreviewColumn( + items = list, + ) { textEditorState -> + ATextComposer( + state = textEditorState, + voiceMessageState = VoiceMessageState.Idle, + composerMode = MessageComposerMode.Attachment, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TextComposerVoicePreview() = ElementPreview { + PreviewColumn( + items = persistentListOf( + VoiceMessageState.Recording( + duration = 61.seconds, + levels = WaveFormSamples.realisticWaveForm, + ), + VoiceMessageState.Preview( + isSending = false, + isPlaying = false, + showCursor = false, + waveform = WaveFormSamples.realisticWaveForm, + time = 0.seconds, + playbackProgress = 0.0f, + ), + VoiceMessageState.Preview( + isSending = false, + isPlaying = true, + showCursor = true, + waveform = WaveFormSamples.realisticWaveForm, + time = 3.seconds, + playbackProgress = 0.2f, + ), + VoiceMessageState.Preview( + isSending = true, + isPlaying = false, + showCursor = false, + waveform = WaveFormSamples.realisticWaveForm, + time = 61.seconds, + playbackProgress = 0.0f, + ), + ) + ) { voiceMessageState -> + ATextComposer( + state = aTextEditorStateRich(initialFocus = true), + voiceMessageState = voiceMessageState, + composerMode = MessageComposerMode.Normal, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TextComposerVoiceNotEncryptedPreview() = ElementPreview { + PreviewColumn( + items = persistentListOf( + VoiceMessageState.Recording( + duration = 61.seconds, + levels = WaveFormSamples.realisticWaveForm, + ), + VoiceMessageState.Preview( + isSending = false, + isPlaying = false, + showCursor = false, + waveform = WaveFormSamples.realisticWaveForm, + time = 0.seconds, + playbackProgress = 0.0f + ), + VoiceMessageState.Preview( + isSending = false, + isPlaying = true, + showCursor = true, + waveform = WaveFormSamples.realisticWaveForm, + time = 3.seconds, + playbackProgress = 0.2f + ), + VoiceMessageState.Preview( + isSending = true, + isPlaying = false, + showCursor = false, + waveform = WaveFormSamples.realisticWaveForm, + time = 61.seconds, + playbackProgress = 0.0f + ), + ) + ) { voiceMessageState -> + ATextComposer( + state = aTextEditorStateRich(initialFocus = true, isRoomEncrypted = false), + voiceMessageState = voiceMessageState, + composerMode = MessageComposerMode.Normal, + ) + } +} + +@Composable +private fun PreviewColumn( + items: ImmutableList, + view: @Composable (T) -> Unit, +) { + Column { + items.forEach { item -> + HorizontalDivider() + Box( + modifier = Modifier.height(IntrinsicSize.Min) + ) { + view(item) + } + } + } +} + +@Composable +private fun ATextComposer( + state: TextEditorState, + voiceMessageState: VoiceMessageState, + composerMode: MessageComposerMode, + showTextFormatting: Boolean = false, +) { + TextComposer( + state = state, + showTextFormatting = showTextFormatting, + voiceMessageState = voiceMessageState, + composerMode = composerMode, + onRequestFocus = {}, + onSendMessage = {}, + onResetComposerMode = {}, + onAddAttachment = {}, + onDismissTextFormatting = {}, + onVoiceRecorderEvent = {}, + onVoicePlayerEvent = {}, + onSendVoiceMessage = {}, + onDeleteVoiceMessage = {}, + onError = {}, + onTyping = {}, + onReceiveSuggestion = {}, + resolveMentionDisplay = { _, _ -> TextDisplay.Plain }, + resolveAtRoomMentionDisplay = { TextDisplay.Plain }, + onSelectRichContent = null, + ) +} + +fun aMessageComposerModeEdit( + eventOrTransactionId: EventOrTransactionId = EventId("$1234").toEventOrTransactionId(), + content: String = "Some text", +) = MessageComposerMode.Edit( + eventOrTransactionId = eventOrTransactionId, + content = content +) + +fun aMessageComposerModeEditCaption( + eventOrTransactionId: EventOrTransactionId = EventId("$1234").toEventOrTransactionId(), + content: String, +) = MessageComposerMode.EditCaption( + eventOrTransactionId = eventOrTransactionId, + content = content, +) + +fun aMessageComposerModeReply( + replyToDetails: InReplyToDetails, + hideImage: Boolean = false, +) = MessageComposerMode.Reply( + replyToDetails = replyToDetails, + hideImage = hideImage, +) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposerLinkDialog.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposerLinkDialog.kt new file mode 100644 index 0000000..3ee191d --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposerLinkDialog.kt @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer + +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.res.stringResource +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.components.dialogs.ListDialog +import io.element.android.libraries.designsystem.components.list.TextFieldListItem +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.wysiwyg.view.models.LinkAction + +@Composable +fun TextComposerLinkDialog( + onDismissRequest: () -> Unit, + linkAction: LinkAction, + onSaveLinkRequest: (url: String) -> Unit, + onCreateLinkRequest: (url: String, text: String) -> Unit, + onRemoveLinkRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + val urlToEdit by remember(linkAction) { + derivedStateOf { + (linkAction as? LinkAction.SetLink)?.currentUrl + } + } + + urlToEdit.let { url -> + when { + url != null -> { + EditLinkDialog( + currentUrl = url, + onDismissRequest = onDismissRequest, + onSaveLinkRequest = onSaveLinkRequest, + onRemoveLinkRequest = onRemoveLinkRequest, + modifier = modifier, + ) + } + linkAction is LinkAction.InsertLink -> { + CreateLinkWithTextDialog( + onDismissRequest = onDismissRequest, + onCreateLinkRequest = onCreateLinkRequest, + modifier = modifier, + ) + } + linkAction is LinkAction.SetLink -> { + CreateLinkWithoutTextDialog( + onDismissRequest = onDismissRequest, + onSaveLinkRequest = onSaveLinkRequest, + modifier = modifier, + ) + } + } + } +} + +@Composable +private fun CreateLinkWithTextDialog( + onDismissRequest: () -> Unit, + onCreateLinkRequest: (url: String, text: String) -> Unit, + modifier: Modifier = Modifier, +) { + var linkText by remember { mutableStateOf("") } + var linkUrl by remember { mutableStateOf("") } + + val titleText = stringResource(R.string.rich_text_editor_create_link) + + fun onSubmit() { + onCreateLinkRequest(linkUrl, linkText) + onDismissRequest() + } + + ListDialog( + onDismissRequest = onDismissRequest, + onSubmit = ::onSubmit, + title = titleText, + modifier = modifier + ) { + item { + TextFieldListItem( + placeholder = stringResource(id = CommonStrings.common_text), + text = linkText, + onTextChange = { linkText = it }, + ) + } + item { + TextFieldListItem( + placeholder = stringResource(id = R.string.rich_text_editor_url_placeholder), + text = linkUrl, + onTextChange = { linkUrl = it }, + ) + } + } +} + +@Composable +private fun CreateLinkWithoutTextDialog( + onDismissRequest: () -> Unit, + onSaveLinkRequest: (url: String) -> Unit, + modifier: Modifier = Modifier, +) { + var linkUrl by remember { mutableStateOf("") } + + val titleText = stringResource(R.string.rich_text_editor_create_link) + + fun onSubmit() { + onSaveLinkRequest(linkUrl) + onDismissRequest() + } + + ListDialog( + onDismissRequest = onDismissRequest, + onSubmit = ::onSubmit, + title = titleText, + modifier = modifier + ) { + item { + TextFieldListItem( + placeholder = stringResource(id = R.string.rich_text_editor_url_placeholder), + text = linkUrl, + onTextChange = { linkUrl = it }, + ) + } + } +} + +// The edit link dialog does not yet support displaying or editing the text of a link +// https://github.com/matrix-org/matrix-rich-text-editor/issues/617 +@Composable +private fun EditLinkDialog( + currentUrl: String, + onDismissRequest: () -> Unit, + onSaveLinkRequest: (url: String) -> Unit, + onRemoveLinkRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + var linkUrl by remember { mutableStateOf(currentUrl) } + + val titleText = stringResource(R.string.rich_text_editor_edit_link) + + fun onSubmit() { + onSaveLinkRequest(linkUrl) + onDismissRequest() + } + + fun onRemoveClick() { + onRemoveLinkRequest() + onDismissRequest() + } + + ListDialog( + onDismissRequest = onDismissRequest, + onSubmit = ::onSubmit, + title = titleText, + modifier = modifier + ) { + item { + TextFieldListItem( + placeholder = stringResource(id = R.string.rich_text_editor_url_placeholder), + text = linkUrl, + onTextChange = { linkUrl = it }, + ) + } + item { + ListItem( + headlineContent = { + Text( + text = stringResource(R.string.rich_text_editor_remove_link), + color = ElementTheme.colors.textCriticalPrimary + ) + }, + onClick = ::onRemoveClick, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun TextComposerLinkDialogCreateLinkPreview() = ElementPreview { + TextComposerLinkDialog( + onDismissRequest = {}, + linkAction = LinkAction.InsertLink, + onSaveLinkRequest = {}, + onCreateLinkRequest = { _, _ -> }, + onRemoveLinkRequest = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TextComposerLinkDialogCreateLinkWithoutTextPreview() = ElementPreview { + TextComposerLinkDialog( + onDismissRequest = {}, + linkAction = LinkAction.SetLink(null), + onSaveLinkRequest = {}, + onCreateLinkRequest = { _, _ -> }, + onRemoveLinkRequest = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun TextComposerLinkDialogEditLinkPreview() = ElementPreview { + TextComposerLinkDialog( + onDismissRequest = {}, + linkAction = LinkAction.SetLink("https://element.io"), + onSaveLinkRequest = {}, + onCreateLinkRequest = { _, _ -> }, + onRemoveLinkRequest = {}, + ) +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/FormattingOption.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/FormattingOption.kt new file mode 100644 index 0000000..db133a9 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/FormattingOption.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ripple +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.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon + +@Composable +internal fun FormattingOption( + state: FormattingOptionState, + toggleable: Boolean, + onClick: () -> Unit, + imageVector: ImageVector, + contentDescription: String, + modifier: Modifier = Modifier, +) { + val backgroundColor = when (state) { + FormattingOptionState.Selected -> ElementTheme.colors.bgAccentSelected + FormattingOptionState.Default, + FormattingOptionState.Disabled -> Color.Transparent + } + + val foregroundColor = when (state) { + FormattingOptionState.Selected -> ElementTheme.colors.iconAccentPrimary + FormattingOptionState.Default -> ElementTheme.colors.iconSecondary + FormattingOptionState.Disabled -> ElementTheme.colors.iconDisabled + } + Box( + modifier = modifier + .clickable( + onClick = onClick, + enabled = state != FormattingOptionState.Disabled, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple( + bounded = false, + radius = 20.dp, + ), + ) + .size(48.dp) + .then( + if (toggleable) { + Modifier.toggleable( + value = state == FormattingOptionState.Selected, + enabled = state != FormattingOptionState.Disabled, + onValueChange = { onClick() }, + ) + } else { + Modifier + } + ) + .clearAndSetSemantics { + this.contentDescription = contentDescription + } + ) { + Box( + modifier = Modifier + .size(36.dp) + .align(Alignment.Center) + .background(backgroundColor, shape = RoundedCornerShape(8.dp)) + ) { + Icon( + modifier = Modifier + .align(Alignment.Center) + .size(20.dp), + imageVector = imageVector, + contentDescription = contentDescription, + tint = foregroundColor, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun FormattingOptionPreview() = ElementPreview { + Row { + FormattingOption( + state = FormattingOptionState.Default, + toggleable = false, + onClick = { }, + imageVector = CompoundIcons.Bold(), + contentDescription = "", + ) + FormattingOption( + state = FormattingOptionState.Selected, + toggleable = true, + onClick = { }, + imageVector = CompoundIcons.Italic(), + contentDescription = "", + ) + FormattingOption( + state = FormattingOptionState.Disabled, + toggleable = false, + onClick = { }, + imageVector = CompoundIcons.Underline(), + contentDescription = "", + ) + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/FormattingOptionState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/FormattingOptionState.kt new file mode 100644 index 0000000..8c41a3d --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/FormattingOptionState.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.components + +internal enum class FormattingOptionState { + Default, + Selected, + Disabled +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/LiveWaveformView.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/LiveWaveformView.kt new file mode 100644 index 0000000..d18b85c --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/LiveWaveformView.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.components.media.drawWaveform +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import java.lang.Float.min + +private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F +private val waveFormHeight = 26.dp + +@Composable +fun LiveWaveformView( + levels: ImmutableList, + modifier: Modifier = Modifier, + brush: Brush = SolidColor(ElementTheme.colors.iconQuaternary), + lineWidth: Dp = 2.dp, + linePadding: Dp = 2.dp, +) { + var canvasSize by remember { mutableStateOf(DpSize(0.dp, 0.dp)) } + var parentWidth by remember { mutableIntStateOf(0) } + val waveformWidth = remember(levels.size, lineWidth, linePadding) { + levels.size * (lineWidth.value + linePadding.value) + } + + Box( + contentAlignment = Alignment.CenterEnd, + modifier = modifier + .fillMaxWidth() + .height(waveFormHeight) + .onSizeChanged { parentWidth = it.width } + ) { + Canvas( + modifier = Modifier + .width(Dp(waveformWidth)) + .graphicsLayer(alpha = DEFAULT_GRAPHICS_LAYER_ALPHA) + .then(modifier) + ) { + val width = min(waveformWidth, parentWidth.toFloat()) + canvasSize = DpSize(width.dp, size.height.toDp()) + val countThatFitsWidth = (parentWidth.toFloat() / (lineWidth.toPx() + linePadding.toPx())).toInt() + drawWaveform( + waveformData = levels.takeLast(countThatFitsWidth).toImmutableList(), + canvasSizePx = Size(canvasSize.width.toPx(), size.height), + brush = brush, + lineWidth = lineWidth, + linePadding = linePadding, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun LiveWaveformViewPreview() = ElementPreview { + Column { + LiveWaveformView( + levels = List(100) { it.toFloat() / 100 }.toImmutableList(), + modifier = Modifier.height(34.dp), + ) + LiveWaveformView( + levels = List(40) { it.toFloat() / 40 }.toImmutableList(), + modifier = Modifier.height(34.dp), + ) + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/SendButton.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/SendButton.kt new file mode 100644 index 0000000..a136f63 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/SendButton.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.components + +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.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.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.LinearGradientShader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.colors.gradientActionColors +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId +import io.element.android.libraries.textcomposer.model.MessageComposerMode + +/** + * Send button for the message composer. + * Figma: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=1956-37575&node-type=frame&m=dev + * Temporary Figma : https://www.figma.com/design/Ni6Ii8YKtmXCKYNE90cC67/Timeline-(new)?node-id=2274-39944&m=dev + */ +@Composable +internal fun SendButton( + canSendMessage: Boolean, + onClick: () -> Unit, + composerMode: MessageComposerMode, + modifier: Modifier = Modifier, +) { + IconButton( + modifier = modifier + .size(48.dp), + onClick = onClick, + enabled = canSendMessage, + ) { + val iconVector = when { + composerMode.isEditing -> CompoundIcons.Check() + else -> CompoundIcons.SendSolid() + } + val iconStartPadding = when { + composerMode.isEditing -> 0.dp + else -> 2.dp + } + Box( + modifier = Modifier + .clip(CircleShape) + .size(36.dp) + .buttonBackgroundModifier(canSendMessage) + ) { + Icon( + modifier = Modifier + .padding(start = iconStartPadding) + .align(Alignment.Center), + imageVector = iconVector, + // Note: accessibility is managed in TextComposer. + contentDescription = null, + tint = if (canSendMessage) { + if (ElementTheme.colors.isLight) { + ElementTheme.colors.iconOnSolidPrimary + } else { + ElementTheme.colors.iconPrimary + } + } else { + ElementTheme.colors.iconQuaternary + } + ) + } + } +} + +@Composable +private fun Modifier.buttonBackgroundModifier( + canSendMessage: Boolean, +) = then( + if (canSendMessage) { + val colors = gradientActionColors() + Modifier.drawWithCache { + val verticalGradientBrush = ShaderBrush( + LinearGradientShader( + from = Offset(0f, 0f), + to = Offset(0f, size.height), + colors = colors, + ) + ) + onDrawBehind { + drawRect( + brush = verticalGradientBrush, + ) + } + } + } else { + Modifier + } +) + +@PreviewsDayNight +@Composable +internal fun SendButtonPreview() = ElementPreview { + val normalMode = MessageComposerMode.Normal + val editMode = MessageComposerMode.Edit(EventId("\$id").toEventOrTransactionId(), "") + Row { + SendButton(canSendMessage = true, onClick = {}, composerMode = normalMode) + SendButton(canSendMessage = false, onClick = {}, composerMode = normalMode) + SendButton(canSendMessage = true, onClick = {}, composerMode = editMode) + SendButton(canSendMessage = false, onClick = {}, composerMode = editMode) + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/TextFormatting.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/TextFormatting.kt new file mode 100644 index 0000000..64e289a --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/TextFormatting.kt @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.components + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.rememberScrollState +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 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.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.textcomposer.R +import io.element.android.libraries.textcomposer.TextComposerLinkDialog +import io.element.android.libraries.textcomposer.model.aRichTextEditorState +import io.element.android.wysiwyg.compose.RichTextEditorState +import io.element.android.wysiwyg.view.models.InlineFormat +import io.element.android.wysiwyg.view.models.LinkAction +import kotlinx.coroutines.launch +import uniffi.wysiwyg_composer.ActionState +import uniffi.wysiwyg_composer.ComposerAction + +@Composable +internal fun TextFormatting( + state: RichTextEditorState, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + val coroutineScope = rememberCoroutineScope() + + fun onInlineFormatClick(inlineFormat: InlineFormat) { + coroutineScope.launch { + state.toggleInlineFormat(inlineFormat) + } + } + + fun onToggleListClick(ordered: Boolean) { + coroutineScope.launch { + state.toggleList(ordered) + } + } + + fun onIndentClick() { + coroutineScope.launch { + state.indent() + } + } + + fun onUnindentClick() { + coroutineScope.launch { + state.unindent() + } + } + + fun onCodeBlockClick() { + coroutineScope.launch { + state.toggleCodeBlock() + } + } + + fun onQuoteClick() { + coroutineScope.launch { + state.toggleQuote() + } + } + + fun onCreateLinkRequest(url: String, text: String) { + coroutineScope.launch { + state.insertLink(url, text) + } + } + + fun onSaveLinkRequest(url: String) { + coroutineScope.launch { + state.setLink(url) + } + } + + fun onRemoveLinkRequest() { + coroutineScope.launch { + state.removeLink() + } + } + + Row( + modifier = modifier + .horizontalScroll(scrollState), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + FormattingOption( + state = state.actions[ComposerAction.BOLD].toButtonState(), + toggleable = true, + onClick = { onInlineFormatClick(InlineFormat.Bold) }, + imageVector = CompoundIcons.Bold(), + contentDescription = stringResource(R.string.rich_text_editor_format_bold) + ) + FormattingOption( + state = state.actions[ComposerAction.ITALIC].toButtonState(), + toggleable = true, + onClick = { onInlineFormatClick(InlineFormat.Italic) }, + imageVector = CompoundIcons.Italic(), + contentDescription = stringResource(R.string.rich_text_editor_format_italic) + ) + FormattingOption( + state = state.actions[ComposerAction.UNDERLINE].toButtonState(), + toggleable = true, + onClick = { onInlineFormatClick(InlineFormat.Underline) }, + imageVector = CompoundIcons.Underline(), + contentDescription = stringResource(R.string.rich_text_editor_format_underline) + ) + FormattingOption( + state = state.actions[ComposerAction.STRIKE_THROUGH].toButtonState(), + toggleable = true, + onClick = { onInlineFormatClick(InlineFormat.StrikeThrough) }, + imageVector = CompoundIcons.Strikethrough(), + contentDescription = stringResource(R.string.rich_text_editor_format_strikethrough) + ) + + var linkDialogAction by remember { mutableStateOf(null) } + + linkDialogAction?.let { + TextComposerLinkDialog( + onDismissRequest = { linkDialogAction = null }, + onCreateLinkRequest = ::onCreateLinkRequest, + onSaveLinkRequest = ::onSaveLinkRequest, + onRemoveLinkRequest = ::onRemoveLinkRequest, + linkAction = it, + ) + } + + FormattingOption( + state = state.actions[ComposerAction.LINK].toButtonState(), + toggleable = true, + onClick = { linkDialogAction = state.linkAction }, + imageVector = CompoundIcons.Link(), + contentDescription = stringResource(R.string.rich_text_editor_link) + ) + + FormattingOption( + state = state.actions[ComposerAction.UNORDERED_LIST].toButtonState(), + toggleable = true, + onClick = { onToggleListClick(ordered = false) }, + imageVector = CompoundIcons.ListBulleted(), + contentDescription = stringResource(R.string.rich_text_editor_bullet_list) + ) + FormattingOption( + state = state.actions[ComposerAction.ORDERED_LIST].toButtonState(), + toggleable = true, + onClick = { onToggleListClick(ordered = true) }, + imageVector = CompoundIcons.ListNumbered(), + contentDescription = stringResource(R.string.rich_text_editor_numbered_list) + ) + FormattingOption( + state = state.actions[ComposerAction.INDENT].toButtonState(), + toggleable = false, + onClick = { onIndentClick() }, + imageVector = CompoundIcons.IndentIncrease(), + contentDescription = stringResource(R.string.rich_text_editor_indent) + ) + FormattingOption( + state = state.actions[ComposerAction.UNINDENT].toButtonState(), + toggleable = false, + onClick = { onUnindentClick() }, + imageVector = CompoundIcons.IndentDecrease(), + contentDescription = stringResource(R.string.rich_text_editor_unindent) + ) + FormattingOption( + state = state.actions[ComposerAction.INLINE_CODE].toButtonState(), + toggleable = true, + onClick = { onInlineFormatClick(InlineFormat.InlineCode) }, + imageVector = CompoundIcons.InlineCode(), + contentDescription = stringResource(R.string.rich_text_editor_inline_code) + ) + FormattingOption( + state = state.actions[ComposerAction.CODE_BLOCK].toButtonState(), + toggleable = true, + onClick = { onCodeBlockClick() }, + imageVector = CompoundIcons.Code(), + contentDescription = stringResource(R.string.rich_text_editor_code_block) + ) + FormattingOption( + state = state.actions[ComposerAction.QUOTE].toButtonState(), + toggleable = true, + onClick = { onQuoteClick() }, + imageVector = CompoundIcons.Quote(), + contentDescription = stringResource(R.string.rich_text_editor_quote) + ) + } +} + +private fun ActionState?.toButtonState(): FormattingOptionState = + when (this) { + ActionState.ENABLED -> FormattingOptionState.Default + ActionState.REVERSED -> FormattingOptionState.Selected + ActionState.DISABLED, null -> FormattingOptionState.Disabled + } + +@PreviewsDayNight +@Composable +internal fun TextFormattingPreview() = ElementPreview { + TextFormatting(state = aRichTextEditorState()) +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/TextInputRoundedCornerShape.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/TextInputRoundedCornerShape.kt new file mode 100644 index 0000000..c6519d5 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/TextInputRoundedCornerShape.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.components + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import io.element.android.libraries.textcomposer.model.MessageComposerMode + +@Composable +internal fun textInputRoundedCornerShape( + composerMode: MessageComposerMode, +): RoundedCornerShape { + val roundCornerSmall = 20.dp + val roundCornerLarge = 21.dp + + val roundedCornerSize = if (composerMode is MessageComposerMode.Special) { + roundCornerSmall + } else { + roundCornerLarge + } + + val roundedCornerSizeState = animateDpAsState( + targetValue = roundedCornerSize, + animationSpec = tween( + durationMillis = 100, + ), + label = "roundedCornerSizeAnimation" + ) + return RoundedCornerShape(roundedCornerSizeState.value) +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageDeleteButton.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageDeleteButton.kt new file mode 100644 index 0000000..af5f443 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageDeleteButton.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.components + +import androidx.compose.foundation.layout.Row +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.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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 VoiceMessageDeleteButton( + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + IconButton( + modifier = modifier + .size(48.dp), + enabled = enabled, + onClick = onClick, + ) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = CompoundIcons.Delete(), + contentDescription = stringResource(CommonStrings.a11y_delete), + tint = if (enabled) { + ElementTheme.colors.iconCriticalPrimary + } else { + ElementTheme.colors.iconDisabled + }, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun VoiceMessageDeleteButtonPreview() = ElementPreview { + Row { + VoiceMessageDeleteButton( + enabled = true, + onClick = {}, + ) + VoiceMessageDeleteButton( + enabled = false, + onClick = {}, + ) + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt new file mode 100644 index 0000000..d893979 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.components + +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.height +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.material3.IconButtonDefaults +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.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.media.WaveFormSamples +import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.Text +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.libraries.ui.utils.time.formatShort +import kotlinx.collections.immutable.ImmutableList +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@Composable +internal fun VoiceMessagePreview( + isInteractive: Boolean, + isPlaying: Boolean, + showCursor: Boolean, + waveform: ImmutableList, + time: Duration, + onPlayClick: () -> Unit, + onPauseClick: () -> Unit, + onSeek: (Float) -> Unit, + modifier: Modifier = Modifier, + playbackProgress: Float = 0f, +) { + Row( + modifier = modifier + .fillMaxWidth() + .background( + color = ElementTheme.colors.bgSubtleSecondary, + shape = MaterialTheme.shapes.medium, + ) + .padding(start = 8.dp, end = 20.dp, top = 6.dp, bottom = 6.dp) + .heightIn(26.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isPlaying) { + PlayerButton( + type = PlayerButtonType.Pause, + onClick = onPauseClick, + enabled = isInteractive, + ) + } else { + PlayerButton( + type = PlayerButtonType.Play, + onClick = onPlayClick, + enabled = isInteractive + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = time.formatShort(), + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.width(12.dp)) + + WaveformPlaybackView( + modifier = Modifier + .weight(1f) + .height(26.dp), + playbackProgress = playbackProgress, + showCursor = showCursor, + waveform = waveform, + seekEnabled = true, + onSeek = onSeek, + ) + } +} + +private enum class PlayerButtonType { + Play, + Pause +} + +@Composable +private fun PlayerButton( + type: PlayerButtonType, + enabled: Boolean, + onClick: () -> Unit, +) { + IconButton( + onClick = onClick, + modifier = Modifier + .background(color = ElementTheme.colors.bgCanvasDefault, shape = CircleShape) + .size(30.dp), + enabled = enabled, + colors = IconButtonDefaults.iconButtonColors( + contentColor = ElementTheme.colors.iconSecondary, + disabledContentColor = ElementTheme.colors.iconDisabled, + ), + ) { + when (type) { + PlayerButtonType.Play -> PlayIcon() + PlayerButtonType.Pause -> PauseIcon() + } + } +} + +@Composable +private fun PauseIcon() = Icon( + imageVector = CompoundIcons.PauseSolid(), + contentDescription = stringResource(id = CommonStrings.a11y_pause), + modifier = Modifier + .size(20.dp) + .padding(2.dp), +) + +@Composable +private fun PlayIcon() = Icon( + imageVector = CompoundIcons.PlaySolid(), + contentDescription = stringResource(id = CommonStrings.a11y_play), + modifier = Modifier + .size(20.dp) + .padding(2.dp), +) + +@PreviewsDayNight +@Composable +internal fun VoiceMessagePreviewPreview() = ElementPreview { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + AVoiceMessagePreview( + isInteractive = true, + isPlaying = true, + time = 2.seconds, + playbackProgress = 0.2f, + showCursor = true, + waveform = WaveFormSamples.longRealisticWaveForm, + ) + AVoiceMessagePreview( + isInteractive = true, + isPlaying = false, + time = 0.seconds, + playbackProgress = 0.0f, + showCursor = true, + waveform = WaveFormSamples.longRealisticWaveForm, + ) + AVoiceMessagePreview( + isInteractive = false, + isPlaying = false, + time = 789.seconds, + playbackProgress = 0.0f, + showCursor = false, + waveform = WaveFormSamples.longRealisticWaveForm, + ) + } +} + +@Composable +private fun AVoiceMessagePreview( + isInteractive: Boolean, + isPlaying: Boolean, + time: Duration, + playbackProgress: Float, + showCursor: Boolean, + waveform: ImmutableList, +) { + VoiceMessagePreview( + isInteractive = isInteractive, + isPlaying = isPlaying, + time = time, + playbackProgress = playbackProgress, + showCursor = showCursor, + waveform = waveform, + onPlayClick = {}, + onPauseClick = {}, + onSeek = {}, + ) +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButton.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButton.kt new file mode 100644 index 0000000..32fe284 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButton.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.components + +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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.utils.CommonDrawables +import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent + +@Composable +internal fun VoiceMessageRecorderButton( + isRecording: Boolean, + onEvent: (VoiceMessageRecorderEvent) -> Unit, + modifier: Modifier = Modifier, +) { + val hapticFeedback = LocalHapticFeedback.current + + val performHapticFeedback = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + } + + if (isRecording) { + StopButton( + modifier = modifier, + onClick = { + performHapticFeedback() + onEvent(VoiceMessageRecorderEvent.Stop) + } + ) + } else { + StartButton( + modifier = modifier, + onClick = { + performHapticFeedback() + onEvent(VoiceMessageRecorderEvent.Start) + } + ) + } +} + +@Composable +private fun StartButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) = IconButton( + modifier = modifier.size(48.dp), + onClick = onClick, +) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = CompoundIcons.MicOn(), + // Note: accessibility is managed in TextComposer. + contentDescription = null, + tint = ElementTheme.colors.iconSecondary, + ) +} + +@Composable +private fun StopButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) = IconButton( + modifier = modifier + .size(48.dp), + onClick = onClick, +) { + Box( + Modifier + .size(36.dp) + .background( + color = ElementTheme.colors.bgActionPrimaryRest, + shape = CircleShape, + ) + ) + Icon( + modifier = Modifier.size(24.dp), + resourceId = CommonDrawables.ic_stop, + // Note: accessibility is managed in TextComposer. + contentDescription = null, + tint = ElementTheme.colors.iconOnSolidPrimary, + ) +} + +@PreviewsDayNight +@Composable +internal fun VoiceMessageRecorderButtonPreview() = ElementPreview { + Row { + VoiceMessageRecorderButton( + isRecording = false, + onEvent = {}, + ) + VoiceMessageRecorderButton( + isRecording = true, + onEvent = {}, + ) + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecording.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecording.kt new file mode 100644 index 0000000..e742372 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecording.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.components + +import androidx.compose.animation.core.InfiniteRepeatableSpec +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.TweenSpec +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.foundation.background +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.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.utils.time.formatShort +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@Composable +internal fun VoiceMessageRecording( + levels: ImmutableList, + duration: Duration, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .background( + color = ElementTheme.colors.bgSubtleSecondary, + shape = MaterialTheme.shapes.medium, + ) + .padding(start = 12.dp, end = 20.dp, top = 8.dp, bottom = 8.dp) + .heightIn(26.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RedRecordingDot() + + Spacer(Modifier.size(8.dp)) + + // Timer + Text( + text = duration.formatShort(), + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmMedium + ) + + Spacer(Modifier.size(20.dp)) + + LiveWaveformView( + modifier = Modifier + .height(26.dp) + .weight(1f), + levels = levels, + ) + } +} + +@Composable +private fun RedRecordingDot() { + val infiniteTransition = rememberInfiniteTransition("RedRecordingDot") + val alpha by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0f, + animationSpec = InfiniteRepeatableSpec( + animation = TweenSpec(durationMillis = 1_000), + repeatMode = RepeatMode.Reverse, + ), + label = "RedRecordingDotAlpha", + ) + Box( + modifier = Modifier + .size(8.dp) + .alpha(alpha) + .background(color = ElementTheme.colors.textCriticalPrimary, shape = CircleShape) + ) +} + +@PreviewsDayNight +@Composable +internal fun VoiceMessageRecordingPreview() = ElementPreview { + VoiceMessageRecording(List(100) { it.toFloat() / 100 }.toImmutableList(), 0.seconds) +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt new file mode 100644 index 0000000..bd808c9 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.components.markdown + +import android.content.Context +import android.view.View +import androidx.appcompat.widget.AppCompatEditText + +internal class MarkdownEditText( + context: Context, +) : AppCompatEditText(context) { + var onSelectionChangeListener: ((Int, Int) -> Unit)? = null + + private var isModifyingText = false + + fun updateEditableText(charSequence: CharSequence) { + isModifyingText = true + editableText.clear() + editableText.append(charSequence) + isModifyingText = false + } + + override fun setText(text: CharSequence?, type: BufferType?) { + isModifyingText = true + super.setText(text, type) + isModifyingText = false + } + + override fun onSelectionChanged(selStart: Int, selEnd: Int) { + super.onSelectionChanged(selStart, selEnd) + if (!isModifyingText) { + onSelectionChangeListener?.invoke(selStart, selEnd) + } + } + + // When using the EditText within a Compose layout, we need to override focusSearch to prevent the default behavior + // Otherwise it can try searching for focusable nodes in the Compose hierarchy while they're being laid out, which will crash + override fun focusSearch(direction: Int): View? { + return null + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt new file mode 100644 index 0000000..725abe3 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.components.markdown + +import android.content.ClipData +import android.content.res.ColorStateList +import android.graphics.Color +import android.net.Uri +import android.text.Editable +import android.text.InputType +import android.text.Selection +import android.view.View +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.text.getSpans +import androidx.core.view.ContentInfoCompat +import androidx.core.view.OnReceiveContentListener +import androidx.core.view.ViewCompat +import androidx.core.view.setPadding +import androidx.core.widget.addTextChangedListener +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle +import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater +import io.element.android.libraries.textcomposer.mentions.MentionSpan +import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState +import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.SuggestionType +import io.element.android.libraries.textcomposer.model.aMarkdownTextEditorState +import io.element.android.wysiwyg.compose.RichTextEditorStyle +import io.element.android.wysiwyg.compose.internal.applyStyleInCompose + +@Suppress("ModifierMissing") +@Composable +fun MarkdownTextInput( + state: MarkdownTextEditorState, + placeholder: String, + placeholderColor: androidx.compose.ui.graphics.Color, + onTyping: (Boolean) -> Unit, + onReceiveSuggestion: (Suggestion?) -> Unit, + richTextEditorStyle: RichTextEditorStyle, + onSelectRichContent: ((Uri) -> Unit)?, +) { + // Copied from io.element.android.wysiwyg.internal.utils.UriContentListener + class ReceiveUriContentListener( + private val onContent: (uri: Uri) -> Unit, + ) : OnReceiveContentListener { + override fun onReceiveContent(view: View, payload: ContentInfoCompat): ContentInfoCompat? { + val split = payload.partition { item -> item.uri != null } + val uriContent = split.first + val remaining = split.second + + if (uriContent != null) { + val clip: ClipData = uriContent.clip + for (i in 0 until clip.itemCount) { + val uri = clip.getItemAt(i).uri + // ... app-specific logic to handle the URI ... + onContent(uri) + } + } + // Return anything that we didn't handle ourselves. This preserves the default platform + // behavior for text and anything else for which we are not implementing custom handling. + return remaining + } + } + + val mentionSpanUpdater = LocalMentionSpanUpdater.current + + AndroidView( + modifier = Modifier + .padding(top = 6.dp, bottom = 6.dp) + .fillMaxWidth(), + factory = { context -> + MarkdownEditText(context).apply { + tag = TestTags.plainTextEditor.value // Needed for UI tests + setPadding(0) + setBackgroundColor(Color.TRANSPARENT) + val text = state.text.value() + setText(text) + setHint(placeholder) + setHintTextColor(ColorStateList.valueOf(placeholderColor.toArgb())) + inputType = InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or + InputType.TYPE_TEXT_FLAG_MULTI_LINE or + InputType.TYPE_TEXT_FLAG_AUTO_CORRECT + val textRange = 0..text.length + setSelection(state.selection.first.coerceIn(textRange), state.selection.last.coerceIn(textRange)) + setOnFocusChangeListener { _, hasFocus -> + state.hasFocus = hasFocus + } + addTextChangedListener { editable -> + onTyping(!editable.isNullOrEmpty()) + state.text.update(editable, false) + state.lineCount = lineCount + + state.currentSuggestion = editable?.checkSuggestionNeeded() + onReceiveSuggestion(state.currentSuggestion) + } + onSelectionChangeListener = { selStart, selEnd -> + state.selection = selStart..selEnd + state.currentSuggestion = editableText.checkSuggestionNeeded() + onReceiveSuggestion(state.currentSuggestion) + } + if (onSelectRichContent != null) { + ViewCompat.setOnReceiveContentListener( + this, + arrayOf("image/*"), + ReceiveUriContentListener { onSelectRichContent(it) } + ) + } + state.requestFocusAction = { this.requestFocus() } + } + }, + update = { editText -> + editText.applyStyleInCompose(richTextEditorStyle) + val text = state.text.value() + mentionSpanUpdater.updateMentionSpans(text) + if (state.text.needsDisplaying()) { + editText.updateEditableText(text) + state.text.update(editText.editableText, false) + } + val newSelectionStart = state.selection.first + val newSelectionEnd = state.selection.last + val currentTextRange = 0..editText.editableText.length + val didSelectionChange = { editText.selectionStart != newSelectionStart || editText.selectionEnd != newSelectionEnd } + val isNewSelectionValid = { newSelectionStart in currentTextRange && newSelectionEnd in currentTextRange } + if (didSelectionChange() && isNewSelectionValid()) { + editText.setSelection(state.selection.first, state.selection.last) + } + } + ) +} + +private fun Editable.checkSuggestionNeeded(): Suggestion? { + if (this.isEmpty()) return null + val start = Selection.getSelectionStart(this) + val end = Selection.getSelectionEnd(this) + var startOfWord = start + while ((startOfWord > 0 || startOfWord == length) && !this[startOfWord - 1].isWhitespace()) { + startOfWord-- + } + if (startOfWord !in indices) return null + val firstChar = this[startOfWord] + + // If a mention span already exists we don't need suggestions + if (getSpans(startOfWord, startOfWord + 1).isNotEmpty()) return null + + return if (firstChar in listOf('@', '#', '/')) { + var endOfWord = end + while (endOfWord < this.length && !this[endOfWord].isWhitespace()) { + endOfWord++ + } + val text = this.subSequence(startOfWord + 1, endOfWord).toString() + val suggestionType = when (firstChar) { + '@' -> SuggestionType.Mention + '#' -> SuggestionType.Room + '/' -> SuggestionType.Command + ':' -> SuggestionType.Emoji + else -> error("Unknown suggestion type. This should never happen.") + } + Suggestion(startOfWord, endOfWord, suggestionType, text) + } else { + null + } +} + +@PreviewsDayNight +@Composable +internal fun MarkdownTextInputPreview() { + ElementPreview { + val style = ElementRichTextEditorStyle.composerStyle(hasFocus = true) + MarkdownTextInput( + state = aMarkdownTextEditorState(initialText = "Hello, World!"), + placeholder = "Placeholder", + placeholderColor = ElementTheme.colors.textSecondary, + onTyping = {}, + onReceiveSuggestion = {}, + richTextEditorStyle = style, + onSelectRichContent = {}, + ) + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/StableCharSequence.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/StableCharSequence.kt new file mode 100644 index 0000000..2b2f42a --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/StableCharSequence.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.components.markdown + +import android.text.SpannableString +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.element.android.libraries.core.extensions.orEmpty + +@Stable +class StableCharSequence(initialText: CharSequence = "") { + private var value by mutableStateOf(SpannableString.valueOf(initialText)) + private var needsDisplaying by mutableStateOf(false) + + fun update(newText: CharSequence?, needsDisplaying: Boolean) { + value = SpannableString.valueOf(newText.orEmpty()) + this.needsDisplaying = needsDisplaying + } + + fun value(): CharSequence = value + fun needsDisplaying(): Boolean = needsDisplaying + + override fun toString(): String { + return "StableCharSequence(value='$value', needsDisplaying=$needsDisplaying)" + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt new file mode 100644 index 0000000..2531a60 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.mentions + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import android.text.TextPaint +import android.text.TextUtils +import android.text.style.ReplacementSpan +import androidx.core.text.getSpans +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.wysiwyg.view.spans.CustomMentionSpan +import kotlin.math.roundToInt + +/** + * A span that represents a mention (user, room, etc.) in text. + * @param type The type of mention this span represents. + */ +class MentionSpan( + val type: MentionType, +) : ReplacementSpan() { + private val backgroundPaint = Paint() + private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG) + + private var backgroundColor: Int = 0 + private var textColor: Int = 0 + private var startPadding: Int = 0 + private var endPadding: Int = 0 + private var typeface: Typeface = Typeface.DEFAULT + + private var measuredTextWidth = 0 + + // The formatted display text, will be set by the formatter + var displayText: CharSequence = "" + private set + + /** + * Updates the visual properties of this span. + */ + fun updateTheme(mentionSpanTheme: MentionSpanTheme) { + val isCurrentUser = when (type) { + is MentionType.User -> type.userId == mentionSpanTheme.currentUserId + else -> false + } + + backgroundColor = when (type) { + is MentionType.User -> if (isCurrentUser) mentionSpanTheme.currentUserBackgroundColor else mentionSpanTheme.otherBackgroundColor + is MentionType.Everyone -> mentionSpanTheme.currentUserBackgroundColor + is MentionType.Room -> mentionSpanTheme.otherBackgroundColor + is MentionType.Message -> mentionSpanTheme.otherBackgroundColor + } + + textColor = when (type) { + is MentionType.User -> if (isCurrentUser) mentionSpanTheme.currentUserTextColor else mentionSpanTheme.otherTextColor + is MentionType.Everyone -> mentionSpanTheme.currentUserTextColor + is MentionType.Room -> mentionSpanTheme.otherTextColor + is MentionType.Message -> mentionSpanTheme.otherTextColor + } + + val (startPaddingPx, endPaddingPx) = mentionSpanTheme.paddingValuesPx.value + startPadding = startPaddingPx + endPadding = endPaddingPx + typeface = mentionSpanTheme.typeface.value + } + + /** + * Updates the display text using a formatter. + */ + fun updateDisplayText(formatter: MentionSpanFormatter) { + displayText = formatter.formatDisplayText(type) + } + + override fun getSize( + paint: Paint, + text: CharSequence?, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int { + textPaint.set(paint) + textPaint.typeface = typeface + // Measure the full text width without truncation + measuredTextWidth = textPaint.measureText(displayText, 0, displayText.length).roundToInt() + return measuredTextWidth + startPadding + endPadding + } + + override fun draw( + canvas: Canvas, + text: CharSequence?, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + // Extra vertical space to add below the baseline (y). This helps us center the span vertically + val extraVerticalSpace = y + paint.ascent() + paint.descent() - top + + val availableWidth = (canvas.width - x).coerceAtLeast(0f) + val measuredWidth = measuredTextWidth + startPadding + endPadding + val pillWidth = minOf(availableWidth, measuredWidth.toFloat()) + + backgroundPaint.color = backgroundColor + val rect = RectF(x, top.toFloat(), x + pillWidth, y.toFloat() + extraVerticalSpace) + val radius = rect.height() / 2 + canvas.drawRoundRect(rect, radius, radius, backgroundPaint) + + textPaint.set(paint) + textPaint.color = textColor + textPaint.typeface = typeface + + val availableWidthForText = availableWidth - startPadding - endPadding + val textToDraw = if (measuredTextWidth > availableWidthForText) { + TextUtils.ellipsize( + displayText, + textPaint, + availableWidthForText, + TextUtils.TruncateAt.END + ) + } else { + displayText + } + canvas.drawText(textToDraw, 0, textToDraw.length, x + startPadding, y.toFloat(), textPaint) + } +} + +/** + * Sealed interface representing different types of mentions. + */ +sealed interface MentionType { + data class User(val userId: UserId) : MentionType + data class Room(val roomIdOrAlias: RoomIdOrAlias) : MentionType + data class Message(val roomIdOrAlias: RoomIdOrAlias, val eventId: EventId) : MentionType + data object Everyone : MentionType +} + +/** + * Extension function to get all MentionSpans from a CharSequence. + */ +fun CharSequence.getMentionSpans(start: Int = 0, end: Int = length): List { + return if (this is android.text.Spanned) { + // If we have custom mention spans created by the RTE, we need to extract the provided spans and filter them + val customMentionSpans = getSpans(start, end) + .map { it.providedSpan } + .filterIsInstance() + // Collect all direct mention spans + val directMentionSpans = getSpans(start, end) + // Return the union of both + customMentionSpans + directMentionSpans + } else { + emptyList() + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanFormatter.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanFormatter.kt new file mode 100644 index 0000000..96a3dc6 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanFormatter.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.mentions + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache +import io.element.android.libraries.matrix.ui.messages.RoomNamesCache + +private const val EVERYONE_DISPLAY_TEXT = "@room" +private const val BUBBLE_ICON = "\uD83D\uDCAC" // 💬 + +interface MentionSpanFormatter { + fun formatDisplayText(mentionType: MentionType): CharSequence +} + +/** + * Formatter for MentionSpan display text. + * This class is responsible for formatting the display text of a MentionSpan + * based on its MentionType and context. + */ +@ContributesBinding(RoomScope::class) +class DefaultMentionSpanFormatter( + private val roomMemberProfilesCache: RoomMemberProfilesCache, + private val roomNamesCache: RoomNamesCache, +) : MentionSpanFormatter { + /** + * Format the display text for a mention span. + * + * @param mentionType The type of mention + * @return The formatted display text + */ + override fun formatDisplayText(mentionType: MentionType): CharSequence { + return when (mentionType) { + is MentionType.User -> formatUserMention(mentionType.userId) + is MentionType.Room -> formatRoomMention(mentionType.roomIdOrAlias) + is MentionType.Message -> formatMessageMention(mentionType.roomIdOrAlias) + is MentionType.Everyone -> EVERYONE_DISPLAY_TEXT + } + } + + private fun formatUserMention(userId: UserId): String { + // Try to get the display name from cache, fallback to userId + val displayName = roomMemberProfilesCache.getDisplayName(userId) + return if (displayName != null) { + "@$displayName" + } else { + userId.value + } + } + + private fun formatRoomMention(roomIdOrAlias: RoomIdOrAlias): String { + val displayName = roomNamesCache.getDisplayName(roomIdOrAlias) + return if (displayName != null) { + "#$displayName" + } else { + roomIdOrAlias.identifier + } + } + + private fun formatMessageMention( + roomIdOrAlias: RoomIdOrAlias, + ): String { + val roomMention = formatRoomMention(roomIdOrAlias) + return "$BUBBLE_ICON > $roomMention" + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt new file mode 100644 index 0000000..5942ca9 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.mentions + +import dev.zacsweers.metro.Inject +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser + +private const val EVERYONE_MENTION_TEXT = "@room" + +/** + * Provider for [MentionSpan]s. + */ +@Inject open class MentionSpanProvider( + private val permalinkParser: PermalinkParser, + private val mentionSpanFormatter: MentionSpanFormatter, + private val mentionSpanTheme: MentionSpanTheme, +) { + /** + * Creates a mention span from a text and URL. + * + * @param text The text associated with the mention + * @param url The URL associated with the mention + * @return A mention span if the URL can be parsed as a permalink, null otherwise + */ + fun getMentionSpanFor(text: String, url: String): MentionSpan? { + val permalinkData = permalinkParser.parse(url) + return getMentionSpanFor(text, permalinkData) + } + + /** + * Creates a mention span from a text and permalink data. + * + * @param text The text associated with the mention + * @param permalinkData The permalink data associated with the mention + * @return A mention span based on the permalink data, null if the permalink data is not supported + */ + private fun getMentionSpanFor(text: String, permalinkData: PermalinkData): MentionSpan? { + return when (permalinkData) { + is PermalinkData.UserLink -> { + createUserMentionSpan(permalinkData.userId) + } + is PermalinkData.RoomLink -> { + val eventId = permalinkData.eventId + if (eventId != null) { + createMessageMentionSpan(permalinkData.roomIdOrAlias, eventId) + } else { + createRoomMentionSpan(permalinkData.roomIdOrAlias) + } + } + is PermalinkData.FallbackLink -> { + if (text == EVERYONE_MENTION_TEXT) { + createEveryoneMentionSpan() + } else { + null + } + } + else -> null + } + } + + /** + * Create a mention span for a user mention. + * + * @param userId The user ID + * @return A mention span for the user + */ + fun createUserMentionSpan(userId: UserId): MentionSpan { + return MentionSpan(type = MentionType.User(userId = userId)).apply { + updateDisplayText(mentionSpanFormatter) + updateTheme(mentionSpanTheme) + } + } + + /** + * Create a mention span for a room mention. + * + * @param roomIdOrAlias The room ID or alias + * @return A mention span for the room + */ + fun createRoomMentionSpan(roomIdOrAlias: RoomIdOrAlias): MentionSpan { + return MentionSpan(MentionType.Room(roomIdOrAlias)).apply { + updateDisplayText(mentionSpanFormatter) + updateTheme(mentionSpanTheme) + } + } + + /** + * Create a mention span for a message mention. + * + * @param roomIdOrAlias The room ID or alias where the message is located + * @param eventId The event ID of the message + * @return A mention span for the message + */ + fun createMessageMentionSpan( + roomIdOrAlias: RoomIdOrAlias, + eventId: EventId, + ): MentionSpan { + return MentionSpan(type = MentionType.Message(roomIdOrAlias, eventId)).apply { + updateTheme(mentionSpanTheme) + updateDisplayText(mentionSpanFormatter) + } + } + + /** + * Create a mention span for @room (everyone). + * + * @return A mention span for @room + */ + fun createEveryoneMentionSpan(): MentionSpan { + return MentionSpan(type = MentionType.Everyone).apply { + updateTheme(mentionSpanTheme) + updateDisplayText(mentionSpanFormatter) + } + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanTheme.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanTheme.kt new file mode 100644 index 0000000..f64c811 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanTheme.kt @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.mentions + +import android.graphics.Color +import android.graphics.Typeface +import android.net.Uri +import android.text.Spanned +import android.view.ViewGroup +import android.widget.TextView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.text.buildSpannedString +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.rememberTypeface +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.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import kotlinx.collections.immutable.persistentListOf + +/** + * Theme used for mention spans. + * To make this work, you need to: + * 1. Call [MentionSpanTheme.updateStyles] so the colors and sizes are computed. + * 2. Use either [MentionSpanTheme.updateMentionStyles] or [MentionSpan.updateTheme] to update the styles of the mention spans. + */ +@Stable +@SingleIn(SessionScope::class) +class MentionSpanTheme(val currentUserId: UserId) { + @Inject + constructor(matrixClient: MatrixClient) : this(matrixClient.sessionId) + + internal var currentUserTextColor: Int = 0 + internal var currentUserBackgroundColor: Int = Color.WHITE + internal var otherTextColor: Int = 0 + internal var otherBackgroundColor: Int = Color.WHITE + + private val paddingValues = PaddingValues(start = 4.dp, end = 6.dp) + internal val paddingValuesPx = mutableStateOf(0 to 0) + internal val typeface = mutableStateOf(Typeface.DEFAULT) + + /** + * Updates the styles of the mention spans based on the [ElementTheme] and [currentUserId]. + */ + @Suppress("ComposableNaming") + @Composable + fun updateStyles() { + currentUserTextColor = ElementTheme.colors.textBadgeAccent.toArgb() + currentUserBackgroundColor = ElementTheme.colors.bgBadgeAccent.toArgb() + otherTextColor = ElementTheme.colors.textPrimary.toArgb() + otherBackgroundColor = ElementTheme.colors.bgBadgeDefault.toArgb() + + typeface.value = ElementTheme.typography.fontBodyLgMedium.rememberTypeface().value + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + paddingValuesPx.value = remember(paddingValues, density, layoutDirection) { + with(density) { + val leftPadding = paddingValues.calculateLeftPadding(layoutDirection).roundToPx() + val rightPadding = paddingValues.calculateRightPadding(layoutDirection).roundToPx() + leftPadding to rightPadding + } + } + } +} + +/** + * Updates the styles of the mention spans in the given [CharSequence]. + */ +fun MentionSpanTheme.updateMentionStyles(charSequence: CharSequence) { + val spanned = charSequence as? Spanned ?: return + val mentionSpans = spanned.getMentionSpans() + for (span in mentionSpans) { + span.updateTheme(this) + } +} + +@PreviewsDayNight +@Composable +internal fun MentionSpanThemePreview() { + ElementPreview { + val mentionSpanTheme = remember { MentionSpanTheme(UserId("@me:matrix.org")) } + val provider = remember { + MentionSpanProvider( + mentionSpanTheme = mentionSpanTheme, + mentionSpanFormatter = object : MentionSpanFormatter { + override fun formatDisplayText(mentionType: MentionType): CharSequence { + return when (mentionType) { + is MentionType.User -> mentionType.userId.value + is MentionType.Room -> mentionType.roomIdOrAlias.identifier + is MentionType.Message -> "\uD83D\uDCAC️ > ${mentionType.roomIdOrAlias.identifier}" + is MentionType.Everyone -> "@room" + } + } + }, + permalinkParser = object : PermalinkParser { + override fun parse(uriString: String): PermalinkData { + return when (uriString) { + "https://matrix.to/#/@me:matrix.org" -> PermalinkData.UserLink(UserId("@me:matrix.org")) + "https://matrix.to/#/@other:matrix.org" -> PermalinkData.UserLink(UserId("@other:matrix.org")) + "https://matrix.to/#/#room:matrix.org" -> PermalinkData.RoomLink( + roomIdOrAlias = RoomAlias("#room:matrix.org").toRoomIdOrAlias(), + eventId = null, + viaParameters = persistentListOf(), + ) + "@room" -> PermalinkData.FallbackLink(Uri.EMPTY, false) + else -> throw AssertionError("Unexpected value $uriString") + } + } + }, + ) + } + + val textColor = ElementTheme.colors.textPrimary.toArgb() + fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org") + fun mentionSpanOther() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@other:matrix.org") + fun mentionSpanRoom() = provider.getMentionSpanFor("room:matrix.org", "https://matrix.to/#/#room:matrix.org") + fun mentionSpanEveryone() = provider.createEveryoneMentionSpan() + mentionSpanTheme.updateStyles() + + AndroidView(factory = { context -> + TextView(context).apply { + includeFontPadding = false + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + text = buildSpannedString { + append("This is a ") + append("@mention", mentionSpanMe(), 0) + append(" to the current user and this is a ") + append("@mention", mentionSpanOther(), 0) + append(" to other user. This is for everyone in the ") + append("@room", mentionSpanEveryone(), 0) + append(". This one is for a link to another room: ") + append("#room:matrix.org", mentionSpanRoom(), 0) + append("\n\n") + append("This ") + append("mention", mentionSpanMe(), 0) + append(" didn't have an '@' and it was automatically added, same as this ") + append("room:matrix.org", mentionSpanRoom(), 0) + append(" one, which had no leading '#'.") + } + setTextColor(textColor) + } + }) + } +} + +@Composable +private fun MentionSpanThemeInTimelineContent( + bgColor: Int, + modifier: Modifier = Modifier, +) { + val mentionSpanTheme = remember { MentionSpanTheme(UserId("@me:matrix.org")) } + val provider = remember { + MentionSpanProvider( + mentionSpanTheme = mentionSpanTheme, + mentionSpanFormatter = object : MentionSpanFormatter { + override fun formatDisplayText(mentionType: MentionType): CharSequence { + return when (mentionType) { + is MentionType.User -> mentionType.userId.value + else -> throw AssertionError("Unexpected value $mentionType") + } + } + }, + permalinkParser = object : PermalinkParser { + override fun parse(uriString: String): PermalinkData { + return when (uriString) { + "https://matrix.to/#/@me:matrix.org" -> PermalinkData.UserLink(UserId("@me:matrix.org")) + "https://matrix.to/#/@other:matrix.org" -> PermalinkData.UserLink(UserId("@other:matrix.org")) + else -> throw AssertionError("Unexpected value $uriString") + } + } + }, + ) + } + + val textColor = ElementTheme.colors.textPrimary.toArgb() + fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org") + fun mentionSpanOther() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@other:matrix.org") + mentionSpanTheme.updateStyles() + + AndroidView( + modifier = modifier, + factory = { context -> + TextView(context).apply { + includeFontPadding = false + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + text = buildSpannedString { + append("Hello ") + append("@mention", mentionSpanMe(), 0) + append(" ") + append("@mention", mentionSpanOther(), 0) + } + setTextColor(textColor) + setBackgroundColor(bgColor) + } + } + ) +} + +@PreviewsDayNight +@Composable +internal fun MentionSpanThemeInTimelinePreview() = ElementPreview { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Message from me + Text( + text = "Message from me", + style = ElementTheme.typography.fontBodySmMedium, + ) + ElementTheme.colors.messageFromMeBackground.let { color -> + MentionSpanThemeInTimelineContent( + modifier = Modifier + .padding(start = 60.dp, end = 8.dp) + .background( + color = color, + shape = RoundedCornerShape(12.dp), + ) + .padding(8.dp), + bgColor = color.toArgb() + ) + } + // Message from other + ElementTheme.colors.messageFromOtherBackground.let { color -> + Text( + text = "Message from other", + style = ElementTheme.typography.fontBodySmMedium, + ) + MentionSpanThemeInTimelineContent( + modifier = Modifier + .padding(start = 8.dp, end = 60.dp) + .padding(4.dp) + .background( + color = color, + shape = RoundedCornerShape(12.dp) + ) + .padding(8.dp), + bgColor = color.toArgb() + ) + } + // Composer + ElementTheme.colors.bgSubtleSecondary.let { color -> + Text( + text = "Composer", + style = ElementTheme.typography.fontBodySmMedium, + ) + MentionSpanThemeInTimelineContent( + modifier = Modifier + .padding(start = 4.dp, end = 4.dp) + .background(color) + .padding(8.dp), + bgColor = color.toArgb() + ) + } + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanUpdater.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanUpdater.kt new file mode 100644 index 0000000..a530d64 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanUpdater.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.mentions + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import dev.zacsweers.metro.ContributesBinding +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache +import io.element.android.libraries.matrix.ui.messages.RoomNamesCache + +interface MentionSpanUpdater { + fun updateMentionSpans(text: CharSequence): CharSequence + + @Composable + fun rememberMentionSpans(text: CharSequence): CharSequence +} + +@ContributesBinding(RoomScope::class) +class DefaultMentionSpanUpdater( + private val formatter: MentionSpanFormatter, + private val theme: MentionSpanTheme, + private val roomMemberProfilesCache: RoomMemberProfilesCache, + private val roomNamesCache: RoomNamesCache, +) : MentionSpanUpdater { + @Composable + override fun rememberMentionSpans(text: CharSequence): CharSequence { + val isLightTheme = ElementTheme.isLightTheme + val roomInfoCacheUpdate by roomNamesCache.updateFlow.collectAsState(0) + val roomMemberProfilesCacheUpdate by roomMemberProfilesCache.updateFlow.collectAsState(0) + return remember(text, roomInfoCacheUpdate, roomMemberProfilesCacheUpdate, isLightTheme) { + updateMentionSpans(text) + text + } + } + + override fun updateMentionSpans(text: CharSequence): CharSequence { + for (mentionSpan in text.getMentionSpans()) { + mentionSpan.updateTheme(theme) + mentionSpan.updateDisplayText(formatter) + } + return text + } +} + +private object NoOpMentionSpanUpdater : MentionSpanUpdater { + override fun updateMentionSpans(text: CharSequence): CharSequence { + return text + } + + @Composable + override fun rememberMentionSpans(text: CharSequence): CharSequence { + return text + } +} + +val LocalMentionSpanUpdater = staticCompositionLocalOf { NoOpMentionSpanUpdater } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedSuggestion.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedSuggestion.kt new file mode 100644 index 0000000..d91735f --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedSuggestion.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.mentions + +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.RoomAlias +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomMember + +@Immutable +sealed interface ResolvedSuggestion { + data object AtRoom : ResolvedSuggestion + data class Member(val roomMember: RoomMember) : ResolvedSuggestion + data class Alias( + val roomAlias: RoomAlias, + val roomId: RoomId, + val roomName: String?, + val roomAvatarUrl: String?, + ) : ResolvedSuggestion { + fun getAvatarData(size: AvatarSize) = AvatarData( + id = roomId.value, + name = roomName, + url = roomAvatarUrl, + size = size, + ) + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/Fixtures.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/Fixtures.kt new file mode 100644 index 0000000..1d76494 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/Fixtures.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.model + +import io.element.android.wysiwyg.compose.RichTextEditorState + +fun aTextEditorStateMarkdown( + initialText: String? = "", + initialFocus: Boolean = false, + isRoomEncrypted: Boolean? = null, +): TextEditorState { + return TextEditorState.Markdown( + aMarkdownTextEditorState( + initialText = initialText, + initialFocus = initialFocus, + ), + isRoomEncrypted = isRoomEncrypted, + ) +} + +fun aMarkdownTextEditorState( + initialText: String? = "", + initialFocus: Boolean = false, +): MarkdownTextEditorState { + return MarkdownTextEditorState( + initialText = initialText, + initialFocus = initialFocus, + ) +} + +fun aTextEditorStateRich( + initialText: String = "", + initialHtml: String = initialText, + initialMarkdown: String = initialText, + initialFocus: Boolean = false, + isRoomEncrypted: Boolean? = null, +): TextEditorState { + return TextEditorState.Rich( + aRichTextEditorState( + initialText = initialText, + initialHtml = initialHtml, + initialMarkdown = initialMarkdown, + initialFocus = initialFocus, + ), + isRoomEncrypted = isRoomEncrypted, + ) +} + +fun aRichTextEditorState( + initialText: String = "", + initialHtml: String = initialText, + initialMarkdown: String = initialText, + initialFocus: Boolean = false, +): RichTextEditorState { + return RichTextEditorState( + initialHtml = initialHtml, + initialMarkdown = initialMarkdown, + initialFocus = initialFocus, + ) +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt new file mode 100644 index 0000000..ba7e3c5 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.model + +import android.os.Parcelable +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.Spanned +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.libraries.matrix.api.room.IntentionalMention +import io.element.android.libraries.textcomposer.components.markdown.StableCharSequence +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.MentionType +import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion +import io.element.android.libraries.textcomposer.mentions.getMentionSpans +import kotlinx.parcelize.Parcelize + +@Stable +class MarkdownTextEditorState( + initialText: String?, + initialFocus: Boolean, +) { + var text by mutableStateOf(StableCharSequence(initialText ?: "")) + var selection by mutableStateOf(0..0) + var hasFocus by mutableStateOf(initialFocus) + var requestFocusAction by mutableStateOf({}) + var lineCount by mutableIntStateOf(1) + var currentSuggestion by mutableStateOf(null) + + fun insertSuggestion( + resolvedSuggestion: ResolvedSuggestion, + mentionSpanProvider: MentionSpanProvider, + ) { + val suggestion = currentSuggestion ?: return + when (resolvedSuggestion) { + is ResolvedSuggestion.AtRoom -> { + val currentText = SpannableStringBuilder(text.value()) + val mentionSpan = mentionSpanProvider.createEveryoneMentionSpan() + currentText.replace(suggestion.start, suggestion.end, "@ ") + val end = suggestion.start + 1 + currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + text.update(currentText, true) + selection = IntRange(end + 1, end + 1) + } + is ResolvedSuggestion.Member -> { + val currentText = SpannableStringBuilder(text.value()) + val mentionSpan = mentionSpanProvider.createUserMentionSpan(resolvedSuggestion.roomMember.userId) + currentText.replace(suggestion.start, suggestion.end, "@ ") + val end = suggestion.start + 1 + currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + this.text.update(currentText, true) + this.selection = IntRange(end + 1, end + 1) + } + is ResolvedSuggestion.Alias -> { + val currentText = SpannableStringBuilder(text.value()) + val mentionSpan = mentionSpanProvider.createRoomMentionSpan(resolvedSuggestion.roomAlias.toRoomIdOrAlias()) + currentText.replace(suggestion.start, suggestion.end, "# ") + val end = suggestion.start + 1 + currentText.setSpan(mentionSpan, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + this.text.update(currentText, true) + this.selection = IntRange(end + 1, end + 1) + } + } + } + + fun getMessageMarkdown(permalinkBuilder: PermalinkBuilder): String { + val charSequence = text.value() + return if (charSequence is Spanned) { + val mentions = charSequence.getMentionSpans() + buildString { + append(charSequence.toString()) + if (mentions.isNotEmpty()) { + for (mention in mentions.sortedByDescending { charSequence.getSpanEnd(it) }) { + val start = charSequence.getSpanStart(mention) + val end = charSequence.getSpanEnd(mention) + when (mention.type) { + is MentionType.User -> { + permalinkBuilder.permalinkForUser(mention.type.userId).getOrNull()?.let { link -> + replace(start, end, "[${mention.type.userId}]($link)") + } + } + is MentionType.Everyone -> { + replace(start, end, "@room") + } + is MentionType.Room -> { + val roomIdOrAlias = mention.type.roomIdOrAlias + if (roomIdOrAlias is RoomIdOrAlias.Alias) { + permalinkBuilder.permalinkForRoomAlias(roomIdOrAlias.roomAlias).getOrNull()?.let { link -> + replace(start, end, "[${roomIdOrAlias.roomAlias}]($link)") + } + } + } + else -> Unit + } + } + } + } + } else { + charSequence.toString() + } + } + + fun getMentions(): List { + val mentionSpans = text.value().getMentionSpans() + return mentionSpans.mapNotNull { mentionSpan -> + when (mentionSpan.type) { + is MentionType.User -> IntentionalMention.User(mentionSpan.type.userId) + is MentionType.Everyone -> IntentionalMention.Room + else -> null + } + } + } + + @Parcelize + data class SavedValue( + val text: CharSequence, + val selectionStart: Int, + val selectionEnd: Int, + ) : Parcelable +} + +object MarkdownTextEditorStateSaver : Saver { + override fun restore(value: MarkdownTextEditorState.SavedValue): MarkdownTextEditorState { + return MarkdownTextEditorState( + initialText = "", + initialFocus = false, + ).apply { + text.update(value.text, true) + selection = value.selectionStart..value.selectionEnd + } + } + + override fun SaverScope.save(value: MarkdownTextEditorState): MarkdownTextEditorState.SavedValue { + return MarkdownTextEditorState.SavedValue( + text = value.text.value(), + selectionStart = value.selection.first, + selectionEnd = value.selection.last, + ) + } +} + +@Composable +fun rememberMarkdownTextEditorState( + initialText: String? = null, + initialFocus: Boolean = false, +): MarkdownTextEditorState { + return rememberSaveable(saver = MarkdownTextEditorStateSaver) { MarkdownTextEditorState(initialText, initialFocus) } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/Message.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/Message.kt new file mode 100644 index 0000000..26e4248 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/Message.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.model + +import io.element.android.libraries.matrix.api.room.IntentionalMention + +data class Message( + val html: String?, + val markdown: String, + val intentionalMentions: List, +) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt new file mode 100644 index 0000000..83e9f93 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MessageComposerMode.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.model + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId +import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails +import io.element.android.libraries.matrix.ui.messages.reply.eventId + +@Immutable +sealed interface MessageComposerMode { + data object Normal : MessageComposerMode + + data object Attachment : MessageComposerMode + + sealed interface Special : MessageComposerMode + + data class Edit( + val eventOrTransactionId: EventOrTransactionId, + val content: String + ) : Special + + data class EditCaption( + val eventOrTransactionId: EventOrTransactionId, + val content: String, + ) : Special + + data class Reply( + val replyToDetails: InReplyToDetails, + val hideImage: Boolean, + ) : Special { + val eventId: EventId = replyToDetails.eventId() + } + + val isEditing: Boolean + get() = this is Edit || this is EditCaption + + val isReply: Boolean + get() = this is Reply + + val inThread: Boolean + get() = this is Reply && + replyToDetails is InReplyToDetails.Ready && + replyToDetails.eventContent is MessageContent && + (replyToDetails.eventContent as MessageContent).threadInfo is EventThreadInfo.ThreadResponse +} + +fun MessageComposerMode.showCaptionCompatibilityWarning(): Boolean { + return when (this) { + is MessageComposerMode.Attachment -> true + is MessageComposerMode.EditCaption -> content.isEmpty() + else -> false + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/Suggestion.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/Suggestion.kt new file mode 100644 index 0000000..094fe4d --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/Suggestion.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.model + +import uniffi.wysiwyg_composer.PatternKey +import uniffi.wysiwyg_composer.SuggestionPattern + +data class Suggestion( + val start: Int, + val end: Int, + val type: SuggestionType, + val text: String, +) { + constructor(suggestion: SuggestionPattern) : this( + suggestion.start.toInt(), + suggestion.end.toInt(), + SuggestionType.fromPatternKey(suggestion.key), + suggestion.text, + ) +} + +sealed interface SuggestionType { + data object Mention : SuggestionType + data object Command : SuggestionType + data object Room : SuggestionType + data object Emoji : SuggestionType + data class Custom(val pattern: String) : SuggestionType + + companion object { + fun fromPatternKey(key: PatternKey): SuggestionType { + return when (key) { + PatternKey.At -> Mention + PatternKey.Slash -> Command + PatternKey.Hash -> Room + PatternKey.Colon -> Emoji + is PatternKey.Custom -> Custom(key.v1) + } + } + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt new file mode 100644 index 0000000..7161268 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.model + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.wysiwyg.compose.RichTextEditorState + +@Immutable +sealed interface TextEditorState { + val isRoomEncrypted: Boolean? + + data class Markdown( + val state: MarkdownTextEditorState, + override val isRoomEncrypted: Boolean?, + ) : TextEditorState + + data class Rich( + val richTextEditorState: RichTextEditorState, + override val isRoomEncrypted: Boolean?, + ) : TextEditorState + + fun messageHtml(): String? = when (this) { + is Markdown -> null + is Rich -> richTextEditorState.messageHtml + } + + fun messageMarkdown(permalinkBuilder: PermalinkBuilder): String = when (this) { + is Markdown -> state.getMessageMarkdown(permalinkBuilder) + is Rich -> richTextEditorState.messageMarkdown + } + + fun hasFocus(): Boolean = when (this) { + is Markdown -> state.hasFocus + is Rich -> richTextEditorState.hasFocus + } + + // Note: for test only + suspend fun setHtml(html: String) { + when (this) { + is Markdown -> Unit + is Rich -> richTextEditorState.setHtml(html) + } + } + + // Note: for test only + suspend fun setMarkdown(text: String) { + when (this) { + is Markdown -> state.text.update(text, true) + is Rich -> richTextEditorState.setMarkdown(text) + } + } + + suspend fun reset() { + when (this) { + is Markdown -> { + state.selection = IntRange.EMPTY + state.text.update("", true) + } + is Rich -> richTextEditorState.setHtml("") + } + } + + suspend fun requestFocus() { + when (this) { + is Markdown -> state.requestFocusAction() + is Rich -> richTextEditorState.requestFocus() + } + } + + val lineCount: Int get() = when (this) { + is Markdown -> state.lineCount + is Rich -> richTextEditorState.lineCount + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessagePlayerEvent.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessagePlayerEvent.kt new file mode 100644 index 0000000..f806dc4 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessagePlayerEvent.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.model + +sealed interface VoiceMessagePlayerEvent { + data object Play : VoiceMessagePlayerEvent + data object Pause : VoiceMessagePlayerEvent + + data class Seek( + val position: Float + ) : VoiceMessagePlayerEvent +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageRecorderEvent.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageRecorderEvent.kt new file mode 100644 index 0000000..ae78501 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageRecorderEvent.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.model + +sealed interface VoiceMessageRecorderEvent { + data object Start : VoiceMessageRecorderEvent + data object Stop : VoiceMessageRecorderEvent + data object Cancel : VoiceMessageRecorderEvent +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt new file mode 100644 index 0000000..54fa2bc --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.model + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlin.time.Duration + +@Immutable +sealed interface VoiceMessageState { + data object Idle : VoiceMessageState + + data class Preview( + val isSending: Boolean, + val isPlaying: Boolean, + val showCursor: Boolean, + val playbackProgress: Float, + val time: Duration, + // Values are between 0 and 1 + val waveform: ImmutableList, + ) : VoiceMessageState + + data class Recording( + val duration: Duration, + // Values are between 0 and 1 + val levels: ImmutableList, + ) : VoiceMessageState +} diff --git a/libraries/textcomposer/impl/src/main/res/values-be/translations.xml b/libraries/textcomposer/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..7f5693b --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,25 @@ + + + "Дадаць далучэнне" + "Пераключыць маркіраваны спіс" + "Закрыць параметры фарматавання" + "Пераключыць блок кода" + "Паведамленне…" + "Стварыць спасылку" + "Рэдагаваць спасылку" + "Ужыць тоўсты шрыфт" + "Ужыць курсіўны фармат" + "Ужыць фармат закрэслівання" + "Ужыць фармат падкрэслення" + "Пераключэнне поўнаэкраннага рэжыму" + "Водступ" + "Ужыць убудаваны фармат кода" + "Усталяваць спасылку" + "Пераключыць нумараваны спіс" + "Адкрыйце параметры кампазіцыі" + "Пераключыць цытату" + "Выдаліць спасылку" + "Без водступу" + "Спасылка" + "Утрымлівайце для запісу" + diff --git a/libraries/textcomposer/impl/src/main/res/values-bg/translations.xml b/libraries/textcomposer/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..b0dfde1 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,24 @@ + + + "Прикачване на файл" + "Отказ и затваряне на форматирането на текст" + "Превключване на кодов блок" + "Съобщение…" + "Създаване на връзка" + "Редактиране на връзката" + "Прилагане на удебелен формат" + "Прилагане на курсив формат" + "Прилагане на зачеркнат формат" + "Прилагане на формат за подчертаване" + "Превключване на режим на цял екран" + "Отстъп навътре" + "Прилагане на формат на вграден код" + "Задаване на връзка" + "Превключване на номериран списък" + "Отваряне на опциите за съставяне" + "Превключване на цитат" + "Премахване на връзката" + "Отстъп навън" + "Връзка" + "Задръжте, за записване" + diff --git a/libraries/textcomposer/impl/src/main/res/values-cs/translations.xml b/libraries/textcomposer/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..bdb92ed --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,33 @@ + + + "Přidat přílohu" + "Přepnout seznam s odrážkami" + "Zrušit a zavřít formátování textu" + "Přepnout blok kódu" + "Volitelný titulek…" + "Šifrovaná zpráva…" + "Zpráva…" + "Nešifrovaná zpráva…" + "Vytvořit odkaz" + "Upravit odkaz" + "%1$s, stav: %2$s" + "Použít tučný text" + "Použít kurzívu" + "zakázáno" + "VYP" + "ZAP" + "Použít přeškrtnutí" + "Použít podtržení" + "Přepnout režim celé obrazovky" + "Odsazení" + "Použít formát inline kódu" + "Nastavit odkaz" + "Přepnout číslovaný seznam" + "Otevřít možnosti psaní" + "Přepnout citaci" + "Odstranit odkaz" + "Zrušit odsazení" + "Odkaz" + "Titulky nemusí být viditelné pro lidi, kteří používají starší aplikace." + "Držte pro nahrávání" + diff --git a/libraries/textcomposer/impl/src/main/res/values-cy/translations.xml b/libraries/textcomposer/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..abc847a --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,33 @@ + + + "Ychwanegu atodiad" + "Toglo\'r rhestr fwledi" + "Cau\'r dewisiadau fformatio" + "Toglo\'r bloc cod" + "Ychwanegu capsiwn" + "Neges wedi\'i hamgryptio…" + "Neges…" + "Neges heb ei hamgryptio…" + "Creu dolen" + "Golygu dolen" + "%1$s, cyflwr: %2$s" + "Gosod fformat trwm" + "Gosod fformat italig" + "analluogwyd" + "i ffwrdd" + "ymlaen" + "Gosod fformat llinell trwodd" + "Gosod fformat tanlinellu" + "Toglo\'r modd sgrin lawn" + "Mewnoliad" + "Gosod fformat cod mewnlin" + "Gosod dolen" + "Toglo\'r rhestr wedi\'i rhifo" + "Agor y dewisiadau cyfansoddi" + "Toglo\'r dyfyniad" + "Dileu dolen" + "Dadmewnoli" + "Dolen" + "Efallai na fydd capsiynau yn weladwy i bobl sy\'n defnyddio apiau hŷn." + "Daliwch i recordio" + diff --git a/libraries/textcomposer/impl/src/main/res/values-da/translations.xml b/libraries/textcomposer/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..b9e7923 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,33 @@ + + + "Tilføj vedhæftet fil" + "Slå punktopstilling til/fra" + "Annullér og luk tekstformatering" + "Slå kodeblok til/fra" + "Tilføj en billedtekst" + "Krypteret besked…" + "Besked…" + "Ukrypteret besked…" + "Opret et link" + "Rediger link" + "%1$s, tilstand: %2$s" + "Anvend fed skrift" + "Anvend kursiv" + "deaktiveret" + "slukket" + "aktiv" + "Anvend gennemstregning" + "Anvend understregning" + "Slå fuldskærmsvisning til/fra" + "Indrykning" + "Anvend inline kodeformat" + "Indstil link" + "Slå nummereret liste til/fra" + "Åbn skriveindstillinger" + "Slå citation til/fra" + "Fjern link" + "Fjern indrykning" + "Link" + "Billedtekster er muligvis ikke synlige for personer, der bruger ældre apps." + "Hold nede for at optage" + diff --git a/libraries/textcomposer/impl/src/main/res/values-de/translations.xml b/libraries/textcomposer/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..1520699 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,33 @@ + + + "Anhang hinzufügen" + "Aufzählungsliste umschalten" + "Textformatierung abbrechen und schließen" + "Codeblock umschalten" + "Bildunterschrift hinzufügen" + "Verschlüsselte Nachricht…" + "Nachricht…" + "Unverschlüsselte Nachricht" + "Einen Link erstellen" + "Link bearbeiten" + "%1$s, Zustand: %2$s" + "Fettes Format anwenden" + "Kursives Format anwenden" + "deaktiviert" + "aus" + "ein" + "Text durchstreichen" + "Unterstreichungsformat anwenden" + "Vollbildmodus umschalten" + "Einrückung" + "Inline-Codeformat anwenden" + "Link setzen" + "Nummerierte Liste umschalten" + "Optionen zum Verfassen öffnen" + "Vorschlag umschalten" + "Link entfernen" + "Ohne Einrückung" + "Link" + "Bildunterschriften sind für Nutzer älterer Apps möglicherweise nicht sichtbar." + "Zum Aufnehmen gedrückt halten" + diff --git a/libraries/textcomposer/impl/src/main/res/values-el/translations.xml b/libraries/textcomposer/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..a054182 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,33 @@ + + + "Προσθήκη συνημμένου" + "Διακόπτης λίστας κουκκίδων" + "Κλείσε τις επιλογές μορφοποίησης" + "Διακόπτης μπλοκ κώδικα" + "Προαιρετική λεζάντα…" + "Κρυπτογραφημένο μήνυμα…" + "Μήνυμα…" + "Μη κρυπτογραφημένο μήνυμα…" + "Δημιούργησε έναν σύνδεσμο" + "Επεξεργασία συνδέσμου" + "%1$s, κατάσταση: %2$s" + "Εφαρμογή έντονης μορφής" + "Εφαρμογή πλάγιας μορφής" + "ανενεργό" + "κλειστό" + "ενεργό" + "Εφαρμογή μορφής διαγραφής" + "Εφαρμογή μορφής υπογράμμισης" + "Εναλλαγή λειτουργίας πλήρους οθόνης" + "Εσοχή" + "Εφαρμογή ενσωματωμένης μορφής κώδικα" + "Ορισμός συνδέσμου" + "Διακόπτης αριθμημένης λίστας" + "Άνοιξε τις επιλογές σύνθεσης" + "Διακόπτης παράθεσης" + "Κατάργηση συνδέσμου" + "Χωρίς εσοχή" + "Σύνδεσμος" + "Οι λεζάντες ενδέχεται να μην είναι ορατές σε άτομα που χρησιμοποιούν παλαιότερες εφαρμογές." + "Κράτα για εγγραφή" + diff --git a/libraries/textcomposer/impl/src/main/res/values-es/translations.xml b/libraries/textcomposer/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..2c7031a --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,29 @@ + + + "Adjuntar archivo" + "Lista de puntos" + "Cerrar opciones de formato" + "Bloque de código" + "Agregar una leyenda" + "Mensaje cifrado…" + "Mensaje…" + "Mensaje no cifrado…" + "Crear un enlace" + "Editar enlace" + "Aplicar formato negrita" + "Aplicar formato cursiva" + "Aplicar formato tachado" + "Aplicar formato de subrayado" + "Pantalla completa" + "Añadir sangría" + "Código" + "Enlazar" + "Lista numérica" + "Abrir opciones de formato" + "Cita" + "Eliminar enlace" + "Quitar sangría" + "Enlace" + "Es posible que las leyendas no sean visibles para las personas que usan aplicaciones más antiguas." + "Mantén pulsado para grabar" + diff --git a/libraries/textcomposer/impl/src/main/res/values-et/translations.xml b/libraries/textcomposer/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..035ac43 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,33 @@ + + + "Lisa manus" + "Lülita mummudega loend sisse/välja" + "Katkesta ja sulge tekstivorminduse valikud" + "Lülita lähtekoodi lõik sisse/välja" + "Selgitus või nimi, kui soovid lisada…" + "Krüptitud sõnum…" + "Sõnum…" + "Krüptimata sõnum…" + "Lisa link" + "Muuda linki" + "%1$s, olek: %2$s" + "Kasuta paksu kirja" + "Kasuta kaldkirja" + "pole kasutusel" + "väljas" + "sees" + "Kasuta läbikriipsutatud kirja" + "Kasuta allajoonitud kirja" + "Lülita täisekraanivaade sisse/välja" + "Lisa taandrida" + "Kuva lähtekoodi lõiguna" + "Lisa link" + "Lülita nummerdatud loend sisse/välja" + "Ava vorminduse valikud" + "Lülita tsiteerimine sisse/välja" + "Eemalda link" + "Eemalda taandrida" + "Link" + "Selgitused ja alapealkirjad ei pruugi olla nähtavad vanemate rakenduste kasutajatele." + "Salvestamiseks hoia nuppu all" + diff --git a/libraries/textcomposer/impl/src/main/res/values-eu/translations.xml b/libraries/textcomposer/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..597f2fc --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,30 @@ + + + "Gehitu eranskina" + "Buleten zerrenda bai/ez" + "Baztertu eta itxi formatu aukerak" + "Kode-blokea bai/ez" + "Gehitu testua" + "Zifratutako mezua…" + "Mezua…" + "Zifratu gabeko mezua…" + "Sortu esteka" + "Editatu esteka" + "Aplikatu formatu lodia" + "Aplikatu formatu etzana" + "desgaituta" + "itzalita" + "piztuta" + "Aplikatu ezabaketa formatua" + "Aplikatu azpimarra formatua" + "Pantaila osoa bai/ez" + "Koska" + "Ezarri esteka" + "Zenbakidu zerrenda bai/ez" + "Ireki idazketa aukerak" + "Aipua bai/ez" + "Kendu esteka" + "Koskarik gabe" + "Esteka" + "Mantendu sakatuta grabatzeko" + diff --git a/libraries/textcomposer/impl/src/main/res/values-fa/translations.xml b/libraries/textcomposer/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..fd55fdf --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,32 @@ + + + "افزودن پیوست" + "تغییر وضعیت سیاههٔ گلوله‌ای" + "لغو و بستن قالب‌بندی متن" + "تغییر حالت بلوک کد" + "افزودن عنوان" + "پیام رمزنگاری شده…" + "پیام…" + "پیام رمزنگاری نشده…" + "ایجاد پیوند" + "ویرایش پیوند" + "‏%1$s. وضعیت: %2$s" + "اعمال قالب توپر" + "اعمال قالب کج" + "از کار افتاده" + "خاموش" + "روشن" + "اعمال قالب خط‌خورده" + "اعمال قالب زیرخط‌دار" + "تغییر حالت تمام‌صفحه" + "تورفتگی" + "اعمال قالب کد درون‌خط" + "تنظیم پیوند" + "تغییر وضعیت سیاههٔ شماره‌دار" + "گشودن گزینه‌های نوشتن" + "تغییر حالت نقل قول" + "برداشتن پیوند" + "تونرفتگی" + "پیوند" + "نگه داشتن برای ضبط" + diff --git a/libraries/textcomposer/impl/src/main/res/values-fi/translations.xml b/libraries/textcomposer/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..fd030dc --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,33 @@ + + + "Lisää liite" + "Numeroimaton luettelo päälle/pois" + "Peruuta ja sulje muotoiluasetukset" + "Koodilohko päälle/pois" + "Lisää kuvateksti" + "Salattu viesti…" + "Viesti…" + "Salaamaton viesti…" + "Luo linkki" + "Muokkaa linkkiä" + "%1$s, tila: %2$s" + "Käytä lihavoitua muotoa" + "Käytä kursiivimuotoa" + "poissa käytöstä" + "pois päältä" + "päällä" + "Käytä yliviivausmuotoa" + "Käytä alleviivausmuotoa" + "Koko näytön tila päälle/pois" + "Sisennä" + "Käytä rivinsisäistä koodimuotoa" + "Aseta linkki" + "Numeroitu luettelo päälle/pois" + "Avaa kirjoitusvaihtoehdot" + "Lainaus päälle/pois" + "Poista linkki" + "Poista sisennys" + "Linkki" + "Kuvatekstit eivät välttämättä näy ihmisille, jotka käyttävät vanhempia sovelluksia." + "Pidä pohjassa nauhoittaaksesi" + diff --git a/libraries/textcomposer/impl/src/main/res/values-fr/translations.xml b/libraries/textcomposer/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..1f2c356 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,33 @@ + + + "Ajouter une pièce jointe" + "Afficher une liste à puces" + "Annuler et fermer les options de formatage" + "Afficher le bloc de code" + "Légende facultative…" + "Message chiffré…" + "Message…" + "Message non chiffré…" + "Créer un lien" + "Modifier le lien" + "%1$s, état : %2$s" + "Appliquer le format gras" + "Appliquer le format italique" + "désactivé" + "désactivé" + "activé" + "Appliquer le format barré" + "Appliquer le format souligné" + "Activer/désactiver le mode plein écran" + "Décaler vers la droite" + "Appliquer le formatage de code en ligne" + "Définir un lien" + "Afficher une liste numérotée" + "Ouvrir les options de rédaction" + "Afficher/masquer la citation" + "Supprimer le lien" + "Décaler vers la gauche" + "Lien" + "Les légendes peuvent ne pas être visibles pour les utilisateurs d’anciennes applications." + "Maintenir pour enregistrer" + diff --git a/libraries/textcomposer/impl/src/main/res/values-hu/translations.xml b/libraries/textcomposer/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..4b7c09e --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,33 @@ + + + "Melléklet hozzáadása" + "Felsorolás be/ki" + "Mégse, és a formázási beállítások bezárása" + "Kódblokk be/ki" + "Felirat hozzáadása…" + "Titkosított üzenet…" + "Üzenet…" + "Titkosítatlan üzenet…" + "Hivatkozás létrehozása" + "Hivatkozás szerkesztése" + "%1$s, állapot: %2$s" + "Félkövér formátum alkalmazása" + "Dőlt formátum alkalmazása" + "letiltva" + "ki" + "be" + "Áthúzott formátum alkalmazása" + "Aláhúzott formátum alkalmazása" + "Teljes képernyős mód be/ki" + "Behúzás" + "Soron belüli kód formátum alkalmazása" + "Hivatkozás beállítása" + "Számozott lista be/ki" + "Írási beállítások megnyitása" + "Idézet be/ki" + "Hivatkozás eltávolítása" + "Behúzás nélkül" + "Hivatkozás" + "Előfordulhat, hogy a feliratok nem láthatók a régebbi alkalmazásokat használók számára." + "Tartsa a rögzítéshez" + diff --git a/libraries/textcomposer/impl/src/main/res/values-in/translations.xml b/libraries/textcomposer/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..64f032c --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,33 @@ + + + "Tambahkan lampiran" + "Alihkan daftar poin" + "Batalkan dan tutup pemformatan teks" + "Alihkan blok kode" + "Keterangan opsional…" + "Pesan terenkripsi…" + "Kirim pesan…" + "Pesan tidak terenkripsi…" + "Buat tautan" + "Sunting tautan" + "%1$s, keadaan: %2$s" + "Terapkan format tebal" + "Terapkan format miring" + "dinonaktifkan" + "mati" + "nyala" + "Terapkan format coret" + "Terapkan format garis bawah" + "Alihkan mode layar penuh" + "Beri indentasi" + "Terapkan format kode dalam baris" + "Tetapkan tautan" + "Alihkan daftar bernomor" + "Buka opsi penulisan" + "Alihkan kutipan" + "Hapus tautan" + "Hapus indentasi" + "Tautan" + "Keterangan mungkin tidak terlihat oleh orang yang menggunakan aplikasi lama." + "Tahan untuk merekam" + diff --git a/libraries/textcomposer/impl/src/main/res/values-it/translations.xml b/libraries/textcomposer/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..92c0883 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,33 @@ + + + "Aggiungi allegato" + "Attiva/disattiva l\'elenco puntato" + "Annullare e chiudere la formattazione del testo" + "Attiva/disattiva il blocco di codice" + "Aggiungi una didascalia" + "Messaggio cifrato…" + "Messaggio…" + "Messaggio non cifrato…" + "Crea un collegamento" + "Modifica collegamento" + "%1$s stato: %2$s" + "Applica il formato grassetto" + "Applicare il formato corsivo" + "disabilitato" + "Disattivato" + "Attivo" + "Applica il formato barrato" + "Applicare il formato di sottolineatura" + "Attiva/disattiva la modalità a schermo intero" + "Rientro a destra" + "Applicare il formato codice inline" + "Imposta collegamento" + "Attiva/disattiva elenco numerato" + "Apri le opzioni di composizione" + "Attiva/disattiva citazione" + "Rimuovi collegamento" + "Rientro a sinistra" + "Collegamento" + "Le didascalie potrebbero non essere visibili agli utenti di app meno recenti." + "Tieni premuto per registrare" + diff --git a/libraries/textcomposer/impl/src/main/res/values-ka/translations.xml b/libraries/textcomposer/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..f79c0eb --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,25 @@ + + + "დაამატეთ დანართი" + "პუნქტების სიის ჩართვა" + "ფორმატირების პარამეტრები დახურვა" + "კოდის ბლოკის ჩართვა" + "შეტყობინება…" + "ბმულის შექმნა" + "ბმულის რედაქტირება" + "თამამი შრიფტის გამოყენება" + "კურსიული შრიფტის გამოყენება" + "გადახაზული ფორმატის გამოყენება" + "ხაზგასმული ფორმატის გამოყენება" + "სრული ეკრანის რეჟიმის ჩართვა" + "აბზაცი" + "კოდის შიდა ფორმატის გამოყენება" + "ბმულის დაყენება" + "დანომრილი სიის ჩართვა" + "გახსენით შედგენის ვარიანტები" + "ციტატის ჩართვა" + "ბმულის წაშლა" + "აბზაცის გარეშე" + "Ბმული" + "ჩასაწერად დააჭირეთ" + diff --git a/libraries/textcomposer/impl/src/main/res/values-ko/translations.xml b/libraries/textcomposer/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..40f030f --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,33 @@ + + + "첨부파일 추가" + "글머리 기호 목록 전환" + "텍스트 서식 취소 및 닫기" + "코드 블록 전환" + "캡션을 추가하세요" + "암호화된 메세지…" + "메시지…" + "비암호화된 메시지…" + "링크 생성" + "링크 수정" + "%1$s, 상태: %2$s" + "굵음 적용" + "기울임 적용" + "비활성화됨" + "끄기" + "켜기" + "취소선 적용" + "밑줄 적용" + "전체화면 모드 전환" + "들여쓰기" + "인라인 코드 형식 적용" + "링크 설정" + "숫자 목록 전환" + "작성 옵션 열기" + "인용 전환" + "링크 제거" + "들여쓰기 취소" + "링크" + "캡션은 오래된 앱을 사용하는 사용자에게 표시되지 않을 수 있습니다." + "녹음하려면 길게 누르세요." + diff --git a/libraries/textcomposer/impl/src/main/res/values-lt/translations.xml b/libraries/textcomposer/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..b23358c --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,17 @@ + + + "Perjungti punktų sąrašą" + "Kodo blokas" + "Žinutė…" + "Taikyti paryškintą formatą" + "Taikyti pasvirusį formatą" + "Taikyti perbrauktą formatą" + "Taikyti pabrauktą formatą" + "Perjungti viso ekrano režimą" + "Atitraukti" + "Taikyti įterpto kodo formatą" + "Nustatyti nuorodą" + "Perjungti sunumeruotą sąrašą" + "Cituoti" + "Panaikinti atitraukimą" + diff --git a/libraries/textcomposer/impl/src/main/res/values-nb/translations.xml b/libraries/textcomposer/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..bd424cb --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,33 @@ + + + "Legg til vedlegg" + "Aktiver/deaktiver punktliste" + "Avbryt og lukk formateringsvalg" + "Aktiver kodeblokk" + "Legg til en tekstbeskrivelse" + "Kryptert melding…" + "Melding…" + "Ukryptert melding…" + "Opprett en lenke" + "Rediger lenke" + "%1$s, tilstand: %2$s" + "Bruk fet skrift" + "Bruk kursivformat" + "deaktivert" + "av" + "på" + "Bruke gjennomstrekingsformat" + "Bruke understrekingsformat" + "Veksle fullskjermmodus" + "Innrykk" + "Bruk inline-kodeformat" + "Angi lenke" + "Aktiver/deaktiver nummerert liste" + "Åpne skrivealternativer" + "Slå på sitat" + "Fjern lenke" + "Fjern innrykk" + "Lenke" + "Teksting er kanskje ikke synlig for personer som bruker eldre apper." + "Hold for å ta opp" + diff --git a/libraries/textcomposer/impl/src/main/res/values-nl/translations.xml b/libraries/textcomposer/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..7db5ebc --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,26 @@ + + + "Bijlage toevoegen" + "Lijst met opsommingstekens in-/uitschakelen" + "Annuleren en opmaakopties sluiten" + "Codeblok in-/uitschakelen" + "Bijschrift toevoegen" + "Bericht…" + "Maak een link" + "Link bewerken" + "Vetgedrukte opmaak toepassen" + "Cursieve opmaak toepassen" + "Doorgestreepte opmaak toepassen" + "Onderstreepte opmaak toepassen" + "Modus volledig scherm in-/uitschakelen" + "Inspringen" + "Inline code-opmaak toepassen" + "Link instellen" + "Genummerde lijst in-/uitschakelen" + "Open opstelopties" + "Citaat in-/uitschakelen" + "Link verwijderen" + "Inspringing ongedaan maken" + "Link" + "Vasthouden om op te nemen" + diff --git a/libraries/textcomposer/impl/src/main/res/values-pl/translations.xml b/libraries/textcomposer/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..2e08649 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,33 @@ + + + "Dodaj załącznik" + "Przełącz listę punktową" + "Anuluj i zamknij formatowanie tekstu" + "Przełącz blok kodu" + "Dodaj opis" + "Wiadomość szyfrowana…" + "Wiadomość…" + "Niezaszyfrowana wiadomość…" + "Utwórz link" + "Edytuj link" + "%1$s, stan: %2$s" + "Zastosuj pogrubienie" + "Zastosuj kursywę" + "wyłączony" + "wyłączony" + "włączony" + "Zastosuj przekreślenie" + "Zastosuj podkreślenie" + "Przełącz pełny ekran" + "Wcięcie" + "Zastosuj formatowanie kodu w wierszu" + "Wstaw link" + "Przełącz listę numerowaną" + "Otwórz opcje tworzenia" + "Przełącz cytat" + "Usuń link" + "Bez wcięcia" + "Link" + "Opis może być niedostępny dla osób korzystających ze starszej wersji aplikacji." + "Przytrzymaj, aby nagrywać" + diff --git a/libraries/textcomposer/impl/src/main/res/values-pt-rBR/translations.xml b/libraries/textcomposer/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..76b9cae --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,33 @@ + + + "Adicionar anexo" + "Habilitar lista de objetivos" + "Cancelar e fechar opções de formatação" + "Habilitar bloco de código" + "Adicionar uma legenda" + "Mensagem criptografada…" + "Mensagem…" + "Mensagem não criptografada…" + "Criar um link" + "Editar link" + "%1$s, estado: %2$s" + "Aplicar formato em negrito" + "Aplicar itálico" + "desativado" + "desligado" + "ligado" + "Aplicar risco" + "Aplicar sublinhado" + "Habilitar o modo de tela cheia" + "Identar" + "Aplicar código na mesma linha" + "Definir link" + "Habilitar lista numerada" + "Abrir opções de composição" + "Habilitar citação" + "Remover link" + "Desidentar" + "Link" + "As legendas podem não ser visíveis para pessoas que usam apps mais antigos." + "Segure para gravar" + diff --git a/libraries/textcomposer/impl/src/main/res/values-pt/translations.xml b/libraries/textcomposer/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..d11cbfe --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,33 @@ + + + "Adicionar anexo" + "Ativar/desativar lista de pontos" + "Cancelar e fechar opções de formatação" + "Ativar/desativar bloco de código" + "Legenda opcional…" + "Mensagem encriptada…" + "Mensagem…" + "Mensagem não encriptada…" + "Criar uma ligação" + "Editar ligação" + "%1$s, estado: %2$s" + "Aplicar negrito" + "Aplicar itálico" + "desativado" + "desligado" + "ligado" + "Aplicar rasura" + "Aplicar sublinhado" + "Entrar/sair do modo de ecrã inteiro" + "Indentar" + "Aplicar código em linha" + "Definir ligação" + "Alternar lista numerada" + "Abrir opções de escrita" + "Pôr/tirar aspas" + "Remover ligação" + "Desindentar" + "Ligação" + "As legendas poderão não ser visíveis em versões mais antigas da aplicação." + "Segurar para gravar" + diff --git a/libraries/textcomposer/impl/src/main/res/values-ro/translations.xml b/libraries/textcomposer/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..cdfaabd --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,33 @@ + + + "Adăugați un atașament" + "Comutați lista cu puncte" + "Închideți opțiunile de formatare" + "Comutați blocul de cod" + "Adăugați o descriere" + "Mesaj criptat…" + "Mesaj…" + "Mesaj necriptat…" + "Creați un link" + "Editați link-ul" + "%1$s, stare: %2$s" + "Aplicați formatul aldin" + "Aplicați formatul italic" + "dezactivat" + "dezactivat" + "activat" + "Aplicați formatul barat" + "Aplică formatul de subliniere" + "Comutați modul ecran complet" + "Indentare" + "Aplicați formatul de cod inline" + "Setați linkul" + "Comutați lista numerotată" + "Deschideți opțiunile de compunere" + "Aplicați citatul" + "Ștergeți linkul" + "Dez-identare" + "Link" + "Este posibil ca descrierile să nu fie vizibile pentru persoanele care folosesc aplicații mai vechi." + "Țineți apăsat pentru a înregistra" + diff --git a/libraries/textcomposer/impl/src/main/res/values-ru/translations.xml b/libraries/textcomposer/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..589a1f9 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,33 @@ + + + "Прикрепить файл" + "Переключить список маркеров" + "Отменить и закрыть параметры форматирования" + "Переключить блок кода" + "Необязательный заголовок…" + "Зашифрованное сообщение…" + "Сообщение…" + "Незашифрованное сообщение…" + "Создать ссылку" + "Редактировать ссылку" + "%1$s, состояние: %2$s" + "Применить жирный шрифт" + "Применить курсивный формат" + "отключено" + "ОТКЛ." + "ВКЛ" + "Применить формат зачеркивания" + "Применить формат подчеркивания" + "Переключение полноэкранного режима" + "Отступ" + "Применить встроенный формат кода" + "Установить ссылку" + "Переключить нумерованный список" + "Открыть параметры компоновки" + "Переключить цитату" + "Удалить ссылку" + "Без отступа" + "Ссылка" + "Подпись может быть не видна пользователям старых приложений." + "Удерживайте для записи" + diff --git a/libraries/textcomposer/impl/src/main/res/values-sk/translations.xml b/libraries/textcomposer/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..428f65b --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,33 @@ + + + "Pridať prílohu" + "Prepnúť zoznam odrážok" + "Zrušiť a zatvoriť formátovanie textu" + "Prepnúť blok kódu" + "Voliteľný titulok…" + "Šifrovaná správa…" + "Správa…" + "Nešifrovaná správa…" + "Vytvoriť odkaz" + "Upraviť odkaz" + "%1$s, stav: %2$s" + "Použiť tučný formát" + "Použiť formát kurzívy" + "zakázané" + "vypnuté" + "zapnuté" + "Použiť formát prečiarknutia" + "Použiť formát podčiarknutia" + "Prepnúť režim celej obrazovky" + "Odsadenie" + "Použiť formát riadkového kódu" + "Nastaviť odkaz" + "Prepnúť číslovaný zoznam" + "Otvoriť možnosti písania" + "Prepnúť citáciu" + "Odstrániť odkaz" + "Zrušiť odsadenie" + "Odkaz" + "Titulky nemusia byť viditeľné pre ľudí používajúcich staršie aplikácie." + "Podržaním nahrajte" + diff --git a/libraries/textcomposer/impl/src/main/res/values-sv/translations.xml b/libraries/textcomposer/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..bc5e09d --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,33 @@ + + + "Lägg till bilaga" + "Växla punktlista" + "Avbryt och stäng textformatering" + "Växla kodblock" + "Lägg till en bildtext" + "Krypterat meddelande …" + "Meddelande …" + "Okrypterat meddelande …" + "Skapa en länk" + "Redigera länk" + "%1$s, läge: %2$s" + "Använd fetstil" + "Använd kursiv stil" + "inaktiverad" + "av" + "på" + "Använda genomstryken stil" + "Använd understruken stil" + "Växla helskärmsläge" + "Gör indrag" + "Använd kodformat i löptext" + "Ange länk" + "Växla numrerad lista" + "Öppna skrivalternativ" + "Växla citat" + "Ta bort länk" + "Ta bort indrag" + "Länk" + "Bildtexter kanske inte är synliga för personer som använder äldre appar." + "Håll för att spela in" + diff --git a/libraries/textcomposer/impl/src/main/res/values-tr/translations.xml b/libraries/textcomposer/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..e617fdf --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,29 @@ + + + "Ek ekle" + "Madde işaretli listeyi aç/kapat" + "İptal et ve biçimlendirme seçeneklerini kapat" + "Kod Bloğunu Aç/Kapat" + "Açıklama ekle" + "Şifrelenmiş mesaj…" + "Mesaj…" + "Şifrelenmemiş mesaj…" + "Bir bağlantı oluştur" + "Bağlantıyı Düzenle" + "Kalın biçimi uygula" + "İtalik biçimi uygula" + "Üstü çizili biçimi uygula" + "Altı çizili biçimi uygula" + "Tam ekran modunu aç/kapat" + "Girinti" + "Satır içi kod biçimini uygula" + "Bağlantıyı ayarla" + "Numaralı listeyi aç/kapat" + "Oluşturma seçeneklerini aç" + "Alıntıyı Aç/Kapat" + "Bağlantıyı kaldır" + "Girintiyi kaldır" + "Bağlantı" + "Açıklamalar, eski uygulamaları kullanan kişiler tarafından görülemeyebilir." + "Kaydetmek için basılı tut" + diff --git a/libraries/textcomposer/impl/src/main/res/values-uk/translations.xml b/libraries/textcomposer/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..87e83ad --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,33 @@ + + + "Додати вкладення" + "Перемкнути маркований список" + "Скасувати та закрити форматування тексту" + "Перемкнути блок коду" + "Необов\'язковий підпис…" + "Зашифроване повідомлення…" + "Повідомлення…" + "Незашифроване повідомлення…" + "Створити посилання" + "Редагувати посилання" + "%1$s, стан: %2$s" + "Жирний формат" + "Курсивний формат" + "вимкнено" + "вимкнено" + "увімкнено" + "Застосувати формат закреслення" + "Застосувати формат підкреслення" + "Перемкнути повноекранний режим" + "Відступ" + "Застосувати вбудований формат коду" + "Установити посилання" + "Перемкнути нумерований список" + "Відкрити параметри складання" + "Перемкнути цитату" + "Видалити посилання" + "Без відступу" + "Посилання" + "Користувачі старих застосунків можуть не бачити підписи." + "Затисніть, щоб записати" + diff --git a/libraries/textcomposer/impl/src/main/res/values-ur/translations.xml b/libraries/textcomposer/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..74d43f1 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,25 @@ + + + "منسلکہ شامل کریں" + "گولئی فہرست تبدیل کریں" + "ٹیکسٹ فارمیٹنگ کو منسوخ اور بند کریں۔" + "رمز بلاک تبدیل کریں" + "پیغام…" + "ایک ربط بنائیں" + "ربط میں ترمیم کریں" + "جلی وضع لاگو کریں" + "ترچھی وضع لاگو کریں" + "مشطوب وضع لاگو کریں" + "مسطر وضع لاگو کریں" + "مکمل نمایشگر وضع تبدیل کریں" + "حاشیہ" + "درخط رمز وضع تبدیل کریں" + "ربط متعین کریں" + "عددی فہرست تبدیل کریں" + "تحریر کے اختیارات کھولیں" + "حوالہ تبدیل کریں" + "ربط ہٹائیں" + "غیر حاشیہ" + "ربط" + "ثبت کرنے کیلئے دبا کر رکھیں" + diff --git a/libraries/textcomposer/impl/src/main/res/values-uz/translations.xml b/libraries/textcomposer/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..d5a7002 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,33 @@ + + + "Biriktirma qo\'shing" + "Belgilar roʻyxatini almashtirish" + "Formatlash parametrlarini yoping" + "Kod blokini almashtirish" + "Taglavha kiritish" + "Shifrlangan xabar…" + "Xabar…" + "Shifrlanmagan xabar…" + "Havola yarating" + "Havolani tahrirlash" + "%1$s, holat: %2$s" + "Qalin formatni qo\'llang" + "Kursiv formatini qo\'llang" + "oʻchirilgan" + "o\'chiq" + "yoniq" + "Chizilgan formatni qo\'llash" + "Pastki chiziq formatini qo\'llang" + "Toʻliq ekran rejimiga oʻtish" + "Paragraf" + "Koq formatini mos ravishda qo\'shing" + "Havolani o\'rnatish" + "Raqamlangan roʻyxatni almashtirish" + "Yozish parametrlarini oching" + "Iqtibosni almashtirish" + "Havolani olib tashlang" + "Paragrafni bekor qilish" + "Havola" + "Taglavhalar eski ilovalardan foydalanuvchilarga ko‘rinmasligi mumkin." + "Yozib olish uchun bosib turing" + diff --git a/libraries/textcomposer/impl/src/main/res/values-zh-rTW/translations.xml b/libraries/textcomposer/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..d27e86a --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,33 @@ + + + "新增附件" + "切換項目編號" + "取消並關閉關閉文字格式化" + "切換程式碼區塊" + "新增標題" + "已加密的訊息……" + "訊息" + "未加密的訊息……" + "建立連結" + "編輯連結" + "%1$s,狀態:%2$s" + "套用粗體" + "套用斜體" + "已停用" + "關閉" + "開啟" + "套用刪除線" + "套用底線" + "切換全螢幕模式" + "增加縮排" + "套用行內程式碼" + "設定連結" + "切換數字編號" + "開啟撰寫選項" + "切換引用" + "移除連結" + "減少縮排" + "連結" + "使用舊應用程式的使用者可能看不到標題。" + "按住錄音" + diff --git a/libraries/textcomposer/impl/src/main/res/values-zh/translations.xml b/libraries/textcomposer/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..8db2b9c --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,33 @@ + + + "添加附件" + "切换符号列表" + "取消并关闭文本格式" + "切换代码块" + "可选的标题……" + "加密信息…" + "消息…" + "未加密的消息…" + "创建链接" + "编辑链接" + "%1$s,状态:%2$s" + "应用粗体格式" + "应用斜体格式" + "已禁用" + "关" + "开" + "应用删除线格式" + "应用下划线格式" + "切换全屏模式" + "缩进" + "应用行内代码格式" + "设置链接" + "切换编号列表" + "打开撰写选项" + "切换引用" + "删除链接" + "取消缩进" + "链接" + "使用旧版应用程序的用户可能无法看到字幕。" + "按住录制" + diff --git a/libraries/textcomposer/impl/src/main/res/values/localazy.xml b/libraries/textcomposer/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..a9e0ca3 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values/localazy.xml @@ -0,0 +1,33 @@ + + + "Add attachment" + "Toggle bullet list" + "Cancel and close text formatting" + "Toggle code block" + "Add a caption" + "Encrypted message…" + "Message…" + "Unencrypted message…" + "Create a link" + "Edit link" + "%1$s, state: %2$s" + "Apply bold format" + "Apply italic format" + "disabled" + "off" + "on" + "Apply strikethrough format" + "Apply underline format" + "Toggle full screen mode" + "Indent" + "Apply inline code format" + "Set link" + "Toggle numbered list" + "Open compose options" + "Toggle quote" + "Remove link" + "Unindent" + "Link" + "Captions might not be visible to people using older apps." + "Hold to record" + diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt new file mode 100644 index 0000000..9a65ca0 --- /dev/null +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.impl.components.markdown + +import android.widget.EditText +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.core.text.getSpans +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle +import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput +import io.element.android.libraries.textcomposer.impl.mentions.aMentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.MentionSpan +import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion +import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState +import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.SuggestionType +import io.element.android.libraries.textcomposer.model.aMarkdownTextEditorState +import io.element.android.tests.testutils.EnsureCalledOnceWithParam +import io.element.android.tests.testutils.EventsRecorder +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MarkdownTextInputTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `when user types onTyping is triggered with value 'true'`() = runTest { + val state = aMarkdownTextEditorState(initialFocus = true) + val onTyping = EnsureCalledOnceWithParam(expectedParam = true, result = Unit) + rule.setMarkdownTextInput(state = state, onTyping = onTyping) + rule.activityRule.scenario.onActivity { + it.findEditor().setText("Test") + } + rule.awaitIdle() + onTyping.assertSuccess() + } + + @Test + fun `when user removes text onTyping is triggered with value 'false'`() = runTest { + val state = aMarkdownTextEditorState(initialFocus = true) + val onTyping = EventsRecorder() + rule.setMarkdownTextInput(state = state, onTyping = onTyping) + rule.activityRule.scenario.onActivity { + val editText = it.findEditor() + editText.setText("Test") + editText.setText("") + editText.setText(null) + } + rule.awaitIdle() + onTyping.assertList(listOf(true, false, false)) + } + + @Test + fun `when user types something that's not a mention onSuggestionReceived is triggered with 'null'`() = runTest { + val state = aMarkdownTextEditorState(initialFocus = true) + val onSuggestionReceived = EventsRecorder() + rule.setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived) + rule.activityRule.scenario.onActivity { + it.findEditor().setText("Test") + } + rule.awaitIdle() + onSuggestionReceived.assertSingle(null) + } + + @Test + fun `when user types something that's a mention onSuggestionReceived is triggered a real value`() = runTest { + val state = aMarkdownTextEditorState(initialFocus = true) + val onSuggestionReceived = EventsRecorder() + rule.setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived) + rule.activityRule.scenario.onActivity { + it.findEditor().setText("@") + it.findEditor().setText("#") + it.findEditor().setText("/") + } + rule.awaitIdle() + onSuggestionReceived.assertList( + listOf( + // User mention suggestion + Suggestion(0, 1, SuggestionType.Mention, ""), + // Room suggestion + Suggestion(0, 1, SuggestionType.Room, ""), + // Slash command suggestion + Suggestion(0, 1, SuggestionType.Command, ""), + ) + ) + } + + @Test + fun `when the selection changes in the UI the state is updated`() = runTest { + val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = true) + rule.setMarkdownTextInput(state = state) + rule.activityRule.scenario.onActivity { + val editor = it.findEditor() + editor.setSelection(2) + } + rule.awaitIdle() + // Selection is updated + assertThat(state.selection).isEqualTo(2..2) + } + + @Test + fun `when the selection state changes in the view is updated`() = runTest { + val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = true) + rule.setMarkdownTextInput(state = state) + var editor: EditText? = null + rule.activityRule.scenario.onActivity { + editor = it.findEditor() + state.selection = 2..2 + } + rule.awaitIdle() + // Selection state is updated + assertThat(editor?.selectionStart).isEqualTo(2) + assertThat(editor?.selectionEnd).isEqualTo(2) + } + + @Test + fun `when the view focus changes the state is updated`() = runTest { + val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = false) + rule.setMarkdownTextInput(state = state) + rule.activityRule.scenario.onActivity { + val editor = it.findEditor() + editor.requestFocus() + } + // Focus state is updated + assertThat(state.hasFocus).isTrue() + } + + @Test + fun `inserting a mention replaces the existing text with a span`() = runTest { + val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(A_SESSION_ID) }) + val state = aMarkdownTextEditorState(initialText = "@", initialFocus = true) + state.currentSuggestion = Suggestion(0, 1, SuggestionType.Mention, "") + rule.setMarkdownTextInput(state = state) + var editor: EditText? = null + rule.activityRule.scenario.onActivity { + editor = it.findEditor() + state.insertSuggestion( + ResolvedSuggestion.Member(roomMember = aRoomMember()), + aMentionSpanProvider(permalinkParser), + ) + } + rule.awaitIdle() + + // Text is replaced with a placeholder + assertThat(editor?.editableText.toString()).isEqualTo("@ ") + // The placeholder contains a MentionSpan + val mentionSpans = editor?.editableText?.getSpans(0, 2).orEmpty() + assertThat(mentionSpans).isNotEmpty() + } + + private fun AndroidComposeTestRule.setMarkdownTextInput( + state: MarkdownTextEditorState = aMarkdownTextEditorState(), + onTyping: (Boolean) -> Unit = {}, + onSuggestionReceived: (Suggestion?) -> Unit = {}, + ) { + setContent { + val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus) + MarkdownTextInput( + state = state, + placeholder = "Placeholder", + placeholderColor = ElementTheme.colors.textSecondary, + onTyping = onTyping, + onReceiveSuggestion = onSuggestionReceived, + richTextEditorStyle = style, + onSelectRichContent = null, + ) + } + } + + private fun ComponentActivity.findEditor(): EditText { + return window.decorView.findViewWithTag(TestTags.plainTextEditor.value) + } +} diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/IntentionalMentionSpanProviderTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/IntentionalMentionSpanProviderTest.kt new file mode 100644 index 0000000..7bac75c --- /dev/null +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/IntentionalMentionSpanProviderTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.impl.mentions + +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.textcomposer.mentions.MentionType +import io.element.android.tests.testutils.WarmUpRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class IntentionalMentionSpanProviderTest { + @JvmField @Rule + val warmUpRule = WarmUpRule() + + private val permalinkParser = FakePermalinkParser() + private val mentionSpanProvider = aMentionSpanProvider(permalinkParser) + + @Test + fun `getting mention span for a user returns a MentionSpan of type USER`() { + permalinkParser.givenResult(PermalinkData.UserLink(A_USER_ID)) + val mentionSpan = mentionSpanProvider.getMentionSpanFor("@me:matrix.org", "https://matrix.to/#/${A_USER_ID.value}") + assertThat(mentionSpan?.type).isInstanceOf(MentionType.User::class.java) + val userType = mentionSpan?.type as MentionType.User + assertThat(userType.userId).isEqualTo(A_USER_ID) + } + + @Test + fun `getting mention span for everyone in the room returns a MentionSpan of type EVERYONE`() { + permalinkParser.givenResult(PermalinkData.FallbackLink(uri = Uri.EMPTY)) + val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "#") + assertThat(mentionSpan?.type).isEqualTo(MentionType.Everyone) + } + + @Test + fun `getting mention span for a room returns a MentionSpan of type ROOM`() { + permalinkParser.givenResult( + PermalinkData.RoomLink( + roomIdOrAlias = RoomAlias("#room:matrix.org").toRoomIdOrAlias(), + ) + ) + val mentionSpan = mentionSpanProvider.getMentionSpanFor("#room:matrix.org", "https://matrix.to/#/#room:matrix.org") + assertThat(mentionSpan?.type).isInstanceOf(MentionType.Room::class.java) + val roomType = mentionSpan?.type as MentionType.Room + assertThat(roomType.roomIdOrAlias).isEqualTo(RoomAlias("#room:matrix.org").toRoomIdOrAlias()) + } +} diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanFormatterTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanFormatterTest.kt new file mode 100644 index 0000000..522912b --- /dev/null +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanFormatterTest.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.impl.mentions + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ALIAS +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache +import io.element.android.libraries.matrix.ui.messages.RoomNamesCache +import io.element.android.libraries.textcomposer.mentions.DefaultMentionSpanFormatter +import io.element.android.libraries.textcomposer.mentions.MentionType +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class MentionSpanFormatterTest { + private val roomMemberProfilesCache = RoomMemberProfilesCache() + private val roomNamesCache = RoomNamesCache() + private val formatter = DefaultMentionSpanFormatter( + roomMemberProfilesCache = roomMemberProfilesCache, + roomNamesCache = roomNamesCache, + ) + + @Test + fun `formatDisplayText - formats user mention with empty cache`() = runTest { + val userId = A_USER_ID + val mentionType = MentionType.User(userId) + val result = formatter.formatDisplayText(mentionType) + assertThat(result.toString()).isEqualTo(userId.value) + } + + @Test + fun `formatDisplayText - formats user mention with filled cache`() = runTest { + val userId = A_USER_ID + val roomMember = aRoomMember(userId, displayName = "alice") + roomMemberProfilesCache.replace(listOf(roomMember)) + val mentionType = MentionType.User(userId) + val result = formatter.formatDisplayText(mentionType) + assertThat(result.toString()).isEqualTo("@alice") + } + + @Test + fun `formatDisplayText - formats room mention with empty cache`() = runTest { + val roomAlias = A_ROOM_ALIAS + val mentionType = MentionType.Room(roomAlias.toRoomIdOrAlias()) + + val result = formatter.formatDisplayText(mentionType) + + assertThat(result.toString()).isEqualTo(roomAlias.value) + } + + @Test + fun `formatDisplayText - formats room mention with filled cache`() = runTest { + val roomAlias = A_ROOM_ALIAS + val roomSummary = aRoomSummary( + canonicalAlias = roomAlias, + name = "my room" + ) + roomNamesCache.replace(listOf(roomSummary)) + val mentionType = MentionType.Room(roomAlias.toRoomIdOrAlias()) + + val result = formatter.formatDisplayText(mentionType) + + assertThat(result.toString()).isEqualTo("#my room") + } + + @Test + fun `formatDisplayText - formats room mention with room id and empty cache`() = runTest { + val roomId = A_ROOM_ID + val mentionType = MentionType.Room(roomId.toRoomIdOrAlias()) + + val result = formatter.formatDisplayText(mentionType) + + assertThat(result.toString()).isEqualTo(roomId.value) + } + + @Test + fun `formatDisplayText - formats room mention with room id and filled cache`() = runTest { + val roomId = A_ROOM_ID + val roomSummary = aRoomSummary( + roomId = roomId, + name = "my room" + ) + roomNamesCache.replace(listOf(roomSummary)) + + val mentionType = MentionType.Room(roomId.toRoomIdOrAlias()) + val result = formatter.formatDisplayText(mentionType) + + assertThat(result.toString()).isEqualTo("#my room") + } + + @Test + fun `formatDisplayText - formats message mention with empty cache`() = runTest { + val roomId = A_ROOM_ID + val mentionType = MentionType.Message(roomId.toRoomIdOrAlias(), eventId = AN_EVENT_ID) + + val result = formatter.formatDisplayText(mentionType) + + assertThat(result.toString()).isEqualTo("💬 > ${roomId.value}") + } + + @Test + fun `formatDisplayText - formats message mention with filled cache`() = runTest { + val roomId = A_ROOM_ID + val roomSummary = aRoomSummary( + roomId = roomId, + name = "my room" + ) + roomNamesCache.replace(listOf(roomSummary)) + + val mentionType = MentionType.Message(roomId.toRoomIdOrAlias(), eventId = AN_EVENT_ID) + + val result = formatter.formatDisplayText(mentionType) + + assertThat(result.toString()).isEqualTo("💬 > #my room") + } + + @Test + fun `formatDisplayText - formats everyone mention`() = runTest { + val mentionType = MentionType.Everyone + + val result = formatter.formatDisplayText(mentionType) + + assertThat(result.toString()).isEqualTo("@room") + } +} diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderFixture.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderFixture.kt new file mode 100644 index 0000000..2537615 --- /dev/null +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderFixture.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.impl.mentions + +import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.textcomposer.mentions.MentionSpanFormatter +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme +import io.element.android.libraries.textcomposer.mentions.MentionType + +fun aMentionSpanProvider( + permalinkParser: PermalinkParser = FakePermalinkParser(), + mentionSpanFormatter: MentionSpanFormatter = object : MentionSpanFormatter { + override fun formatDisplayText(mentionType: MentionType): CharSequence { + return mentionType.toString() + } + }, + mentionSpanTheme: MentionSpanTheme = MentionSpanTheme(A_USER_ID), +): MentionSpanProvider { + return MentionSpanProvider( + permalinkParser = permalinkParser, + mentionSpanFormatter = mentionSpanFormatter, + mentionSpanTheme = mentionSpanTheme, + ) +} diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt new file mode 100644 index 0000000..04b7009 --- /dev/null +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.textcomposer.impl.model + +import android.net.Uri +import androidx.core.text.buildSpannedString +import androidx.core.text.inSpans +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.room.IntentionalMention +import io.element.android.libraries.matrix.test.A_ROOM_ALIAS +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.textcomposer.impl.mentions.aMentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.MentionSpan +import io.element.android.libraries.textcomposer.mentions.MentionType +import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion +import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.SuggestionType +import io.element.android.libraries.textcomposer.model.aMarkdownTextEditorState +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MarkdownTextEditorStateTest { + @Test + fun `insertMention - room alias - getMentions return empty list`() { + val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true) + val suggestion = aRoomAliasSuggestion() + val mentionSpanProvider = aMentionSpanProvider() + state.insertSuggestion(suggestion, mentionSpanProvider) + assertThat(state.getMentions()).isEmpty() + } + + @Test + fun `insertSuggestion - room alias - with member but failed PermalinkBuilder result`() { + val state = aMarkdownTextEditorState(initialText = "Hello #", initialFocus = true).apply { + currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Room, text = "") + } + val suggestion = aRoomAliasSuggestion() + val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) }) + val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) + state.insertSuggestion(suggestion, mentionSpanProvider) + } + + @Test + fun `insertSuggestion - room alias`() { + val state = aMarkdownTextEditorState(initialText = "Hello #", initialFocus = true).apply { + currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Room, text = "") + } + val suggestion = aRoomAliasSuggestion() + val permalinkParser = FakePermalinkParser(result = { PermalinkData.RoomLink(A_ROOM_ALIAS.toRoomIdOrAlias()) }) + val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) + state.insertSuggestion(suggestion, mentionSpanProvider) + } + + @Test + fun `insertSuggestion - with no currentMentionSuggestion does nothing`() { + val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true) + val member = aRoomMember() + val mention = ResolvedSuggestion.Member(member) + val mentionSpanProvider = aMentionSpanProvider() + state.insertSuggestion(mention, mentionSpanProvider) + assertThat(state.getMentions()).isEmpty() + } + + @Test + fun `insertSuggestion - with member`() { + val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply { + currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") + } + val member = aRoomMember() + val mention = ResolvedSuggestion.Member(member) + val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) }) + val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) + + state.insertSuggestion(mention, mentionSpanProvider) + + val mentions = state.getMentions() + assertThat(mentions).isNotEmpty() + assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId).isEqualTo(member.userId) + } + + @Test + fun `insertSuggestion - with @room`() { + val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply { + currentSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") + } + val mention = ResolvedSuggestion.AtRoom + val permalinkParser = FakePermalinkParser(result = { PermalinkData.FallbackLink(Uri.EMPTY, false) }) + val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) + + state.insertSuggestion(mention, mentionSpanProvider) + + val mentions = state.getMentions() + assertThat(mentions).isNotEmpty() + assertThat(mentions.firstOrNull()).isInstanceOf(IntentionalMention.Room::class.java) + } + + @Test + fun `getMessageMarkdown - when there are no MentionSpans returns the same text`() { + val text = "No mentions here" + val state = aMarkdownTextEditorState(initialText = text, initialFocus = true) + + val markdown = state.getMessageMarkdown(FakePermalinkBuilder()) + + assertThat(markdown).isEqualTo(text) + } + + @Test + fun `getMessageMarkdown - when there are MentionSpans returns the same text with links to the mentions`() { + val text = "No mentions here" + val permalinkBuilder = FakePermalinkBuilder( + permalinkForUserLambda = { Result.success("https://matrix.to/#/$it") }, + permalinkForRoomAliasLambda = { Result.success("https://matrix.to/#/$it") }, + ) + val state = aMarkdownTextEditorState(initialText = text, initialFocus = true) + state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false) + + val markdown = state.getMessageMarkdown(permalinkBuilder = permalinkBuilder) + + assertThat(markdown).isEqualTo( + "Hello [@alice:matrix.org](https://matrix.to/#/@alice:matrix.org) and everyone in @room" + + " and a room [#room:domain.org](https://matrix.to/#/#room:domain.org)" + ) + } + + @Test + fun `getMentions - when there are no MentionSpans returns empty list of mentions`() { + val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true) + + assertThat(state.getMentions()).isEmpty() + } + + @Test + fun `getMentions - when there are MentionSpans returns a list of mentions`() { + val state = aMarkdownTextEditorState(initialText = "Hello @", initialFocus = true) + state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false) + + val mentions = state.getMentions() + + assertThat(mentions).isNotEmpty() + assertThat((mentions.firstOrNull() as? IntentionalMention.User)?.userId?.value).isEqualTo("@alice:matrix.org") + assertThat(mentions.lastOrNull()).isInstanceOf(IntentionalMention.Room::class.java) + } + + private fun aMarkdownTextWithMentions(): CharSequence { + val userMentionSpan = MentionSpan(MentionType.User(UserId("@alice:matrix.org"))) + val atRoomMentionSpan = MentionSpan(MentionType.Everyone) + val roomMentionSpan = MentionSpan(MentionType.Room(RoomAlias("#room:domain.org").toRoomIdOrAlias())) + return buildSpannedString { + append("Hello ") + inSpans(userMentionSpan) { + append("@") + } + append(" and everyone in ") + inSpans(atRoomMentionSpan) { + append("@") + } + append(" and a room ") + inSpans(roomMentionSpan) { + append("#room:domain.org") + } + } + } + + private fun aRoomAliasSuggestion(): ResolvedSuggestion.Alias { + return ResolvedSuggestion.Alias( + roomAlias = A_ROOM_ALIAS, + roomId = A_ROOM_ID, + roomName = null, + roomAvatarUrl = null + ) + } +} diff --git a/libraries/textcomposer/lib/.gitignore b/libraries/textcomposer/lib/.gitignore new file mode 100644 index 0000000..67f29a6 --- /dev/null +++ b/libraries/textcomposer/lib/.gitignore @@ -0,0 +1,2 @@ +# Built application files +*.aar diff --git a/libraries/textcomposer/lib/build.gradle.kts b/libraries/textcomposer/lib/build.gradle.kts new file mode 100644 index 0000000..3fbff24 --- /dev/null +++ b/libraries/textcomposer/lib/build.gradle.kts @@ -0,0 +1,3 @@ +configurations.maybeCreate("default") +artifacts.add("default", file("library.aar")) +artifacts.add("default", file("library-compose.aar")) diff --git a/libraries/troubleshoot/api/build.gradle.kts b/libraries/troubleshoot/api/build.gradle.kts new file mode 100644 index 0000000..177b2a5 --- /dev/null +++ b/libraries/troubleshoot/api/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.troubleshoot.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(libs.androidx.corektx) + implementation(libs.coroutines.core) +} diff --git a/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/NotificationTroubleShootEntryPoint.kt b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/NotificationTroubleShootEntryPoint.kt new file mode 100644 index 0000000..4000fd1 --- /dev/null +++ b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/NotificationTroubleShootEntryPoint.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.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 NotificationTroubleShootEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: Callback, + ): Node + + interface Callback : Plugin { + fun onDone() + fun navigateToBlockedUsers() + } +} diff --git a/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/PushHistoryEntryPoint.kt b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/PushHistoryEntryPoint.kt new file mode 100644 index 0000000..e5a3c9c --- /dev/null +++ b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/PushHistoryEntryPoint.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.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.EventId +import io.element.android.libraries.matrix.api.core.RoomId + +interface PushHistoryEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: Callback, + ): Node + + interface Callback : Plugin { + fun onDone() + fun navigateToEvent(roomId: RoomId, eventId: EventId) + } +} diff --git a/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootNavigator.kt b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootNavigator.kt new file mode 100644 index 0000000..a4be22e --- /dev/null +++ b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootNavigator.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.api.test + +interface NotificationTroubleshootNavigator { + fun navigateToBlockedUsers() +} diff --git a/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootTest.kt b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootTest.kt new file mode 100644 index 0000000..2314464 --- /dev/null +++ b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootTest.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.api.test + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +interface NotificationTroubleshootTest { + val order: Int + val state: StateFlow + fun isRelevant(data: TestFilterData): Boolean = true + suspend fun run(coroutineScope: CoroutineScope) + suspend fun reset() + suspend fun quickFix( + coroutineScope: CoroutineScope, + navigator: NotificationTroubleshootNavigator, + ) { + error("Quick fix not implemented, you need to override this method in your test") + } +} diff --git a/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootTestDelegate.kt b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootTestDelegate.kt new file mode 100644 index 0000000..6dbf1f1 --- /dev/null +++ b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootTestDelegate.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.api.test + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * A NotificationTroubleshootTest delegate, with common pattern for running and resetting. + */ +class NotificationTroubleshootTestDelegate( + private val defaultName: String, + private val defaultDescription: String, + private val visibleWhenIdle: Boolean = true, + private val hasQuickFix: Boolean = false, + private val fakeDelay: Long = 0L, +) { + private val _state: MutableStateFlow = MutableStateFlow( + NotificationTroubleshootTestState( + name = defaultName, + description = defaultDescription, + status = NotificationTroubleshootTestState.Status.Idle(visibleWhenIdle), + ) + ) + + val state: StateFlow = _state.asStateFlow() + + suspend fun updateState( + status: NotificationTroubleshootTestState.Status, + name: String = defaultName, + description: String = defaultDescription, + ) { + _state.emit( + NotificationTroubleshootTestState( + name = name, + description = description, + status = status, + ) + ) + } + + suspend fun reset() { + updateState(NotificationTroubleshootTestState.Status.Idle(visibleWhenIdle)) + } + + suspend fun start() { + updateState(NotificationTroubleshootTestState.Status.InProgress) + delay(fakeDelay) + } + + suspend fun done(isSuccess: Boolean = true) { + updateState( + if (isSuccess) { + NotificationTroubleshootTestState.Status.Success + } else { + NotificationTroubleshootTestState.Status.Failure(hasQuickFix = hasQuickFix) + } + ) + } + + companion object { + const val SHORT_DELAY = 300L + const val LONG_DELAY = 500L + } +} diff --git a/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootTestState.kt b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootTestState.kt new file mode 100644 index 0000000..f022651 --- /dev/null +++ b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootTestState.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.api.test + +import androidx.compose.runtime.Immutable + +data class NotificationTroubleshootTestState( + val name: String, + val description: String, + val status: Status, +) { + @Immutable + sealed interface Status { + data class Idle(val visible: Boolean) : Status + data object InProgress : Status + data object WaitingForUser : Status + data object Success : Status + data class Failure( + val hasQuickFix: Boolean = false, + val isCritical: Boolean = true, + val quickFixButtonString: String? = null, + ) : Status + } +} diff --git a/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/TestFilterData.kt b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/TestFilterData.kt new file mode 100644 index 0000000..960d78e --- /dev/null +++ b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/TestFilterData.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.api.test + +data class TestFilterData( + val currentPushProviderName: String?, +) diff --git a/libraries/troubleshoot/impl/build.gradle.kts b/libraries/troubleshoot/impl/build.gradle.kts new file mode 100644 index 0000000..e2efdb3 --- /dev/null +++ b/libraries/troubleshoot/impl/build.gradle.kts @@ -0,0 +1,42 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "io.element.android.libraries.troubleshoot.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.di) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.uiStrings) + api(projects.libraries.troubleshoot.api) + api(projects.libraries.push.api) + implementation(projects.services.analytics.api) + + testCommonDependencies(libs, true) + testImplementation(projects.services.analytics.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.push.test) +} diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/DefaultNotificationTroubleShootEntryPoint.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/DefaultNotificationTroubleShootEntryPoint.kt new file mode 100644 index 0000000..117b352 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/DefaultNotificationTroubleShootEntryPoint.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint + +@ContributesBinding(AppScope::class) +class DefaultNotificationTroubleShootEntryPoint : NotificationTroubleShootEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: NotificationTroubleShootEntryPoint.Callback, + ): Node { + return parentNode.createNode(buildContext, listOf(callback)) + } +} diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsEvents.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsEvents.kt new file mode 100644 index 0000000..9433090 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsEvents.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl + +sealed interface TroubleshootNotificationsEvents { + data object StartTests : TroubleshootNotificationsEvents + data object RetryFailedTests : TroubleshootNotificationsEvents + data class QuickFix(val testIndex: Int) : TroubleshootNotificationsEvents +} diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsNode.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsNode.kt new file mode 100644 index 0000000..ce24c72 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsNode.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootNavigator +import io.element.android.services.analytics.api.ScreenTracker + +@ContributesNode(SessionScope::class) +@AssistedInject +class TroubleshootNotificationsNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val screenTracker: ScreenTracker, + factory: TroubleshootNotificationsPresenter.Factory, +) : Node(buildContext, plugins = plugins), + NotificationTroubleshootNavigator { + private val callback: NotificationTroubleShootEntryPoint.Callback = callback() + private val presenter = factory.create( + navigator = this, + ) + + override fun navigateToBlockedUsers() { + callback.navigateToBlockedUsers() + } + + @Composable + override fun View(modifier: Modifier) { + screenTracker.TrackScreen(MobileScreen.ScreenName.NotificationTroubleshoot) + val state = presenter.present() + TroubleshootNotificationsView( + state = state, + onBackClick = callback::onDone, + modifier = modifier, + ) + } +} diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsPresenter.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsPresenter.kt new file mode 100644 index 0000000..00958b1 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsPresenter.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootNavigator +import kotlinx.coroutines.launch + +@AssistedInject +class TroubleshootNotificationsPresenter( + @Assisted private val navigator: NotificationTroubleshootNavigator, + private val troubleshootTestSuite: TroubleshootTestSuite, +) : Presenter { + @AssistedFactory + fun interface Factory { + fun create(navigator: NotificationTroubleshootNavigator): TroubleshootNotificationsPresenter + } + + @Composable + override fun present(): TroubleshootNotificationsState { + val coroutineScope = rememberCoroutineScope() + LaunchedEffect(Unit) { + troubleshootTestSuite.start(this) + } + + val testSuiteState by troubleshootTestSuite.state.collectAsState() + fun handleEvent(event: TroubleshootNotificationsEvents) { + when (event) { + TroubleshootNotificationsEvents.StartTests -> coroutineScope.launch { + troubleshootTestSuite.runTestSuite(this) + } + is TroubleshootNotificationsEvents.QuickFix -> coroutineScope.launch { + troubleshootTestSuite.quickFix( + testIndex = event.testIndex, + coroutineScope = this, + navigator = navigator, + ) + } + TroubleshootNotificationsEvents.RetryFailedTests -> coroutineScope.launch { + troubleshootTestSuite.retryFailedTest(this) + } + } + } + + return TroubleshootNotificationsState( + testSuiteState = testSuiteState, + eventSink = ::handleEvent, + ) + } +} diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsState.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsState.kt new file mode 100644 index 0000000..92f4d66 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsState.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl + +data class TroubleshootNotificationsState( + val testSuiteState: TroubleshootTestSuiteState, + val eventSink: (TroubleshootNotificationsEvents) -> Unit, +) { + val hasFailedTests: Boolean = testSuiteState.mainState.isFailure() +} diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsStateProvider.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsStateProvider.kt new file mode 100644 index 0000000..5cc5655 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsStateProvider.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import kotlinx.collections.immutable.toImmutableList + +open class TroubleshootNotificationsStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aTroubleshootNotificationsState( + listOf( + aTroubleshootTestStateIdle(), + aTroubleshootTestStateIdle(), + aTroubleshootTestStateIdle(visible = false), + ) + ), + aTroubleshootNotificationsState( + listOf( + aTroubleshootTestStateInProgress(), + aTroubleshootTestStateIdle(), + aTroubleshootTestStateIdle(), + ) + ), + aTroubleshootNotificationsState( + listOf( + aTroubleshootTestStateSuccess(), + aTroubleshootTestStateFailure( + isCritical = false, + hasQuickFix = true, + quickFixButtonString = "Custom quick fix", + ), + aTroubleshootTestStateInProgress(), + ) + ), + aTroubleshootNotificationsState( + listOf( + aTroubleshootTestStateSuccess(), + aTroubleshootTestStateWaitingForUser(), + aTroubleshootTestStateIdle(), + ) + ), + aTroubleshootNotificationsState( + listOf( + aTroubleshootTestStateSuccess(), + aTroubleshootTestStateFailure(hasQuickFix = true), + aTroubleshootTestStateInProgress(), + ) + ), + aTroubleshootNotificationsState( + listOf( + aTroubleshootTestStateSuccess(), + aTroubleshootTestStateFailure(hasQuickFix = true), + aTroubleshootTestStateFailure(hasQuickFix = false), + ) + ), + aTroubleshootNotificationsState( + listOf( + aTroubleshootTestStateSuccess(), + aTroubleshootTestStateSuccess(), + aTroubleshootTestStateSuccess(), + ) + ), + aTroubleshootNotificationsState( + listOf( + aTroubleshootTestStateWaitingForUser(), + ) + ), + ) +} + +fun aTroubleshootNotificationsState( + tests: List = emptyList(), + eventSink: (TroubleshootNotificationsEvents) -> Unit = {}, +) = TroubleshootNotificationsState( + eventSink = eventSink, + testSuiteState = TroubleshootTestSuiteState( + mainState = tests.computeMainState(), + tests = tests.toImmutableList(), + ), +) + +fun aTroubleshootTestState( + status: NotificationTroubleshootTestState.Status, + name: String = "Test", + description: String = "Description", +): NotificationTroubleshootTestState { + return NotificationTroubleshootTestState( + name = name, + description = description, + status = status, + ) +} + +fun aTroubleshootTestStateIdle(visible: Boolean = true) = + aTroubleshootTestState(status = NotificationTroubleshootTestState.Status.Idle(visible = visible)) + +fun aTroubleshootTestStateInProgress() = + aTroubleshootTestState(status = NotificationTroubleshootTestState.Status.InProgress) + +fun aTroubleshootTestStateWaitingForUser() = + aTroubleshootTestState(status = NotificationTroubleshootTestState.Status.WaitingForUser) + +fun aTroubleshootTestStateSuccess() = + aTroubleshootTestState(status = NotificationTroubleshootTestState.Status.Success) + +fun aTroubleshootTestStateFailure( + hasQuickFix: Boolean = false, + isCritical: Boolean = true, + quickFixButtonString: String? = null, +) = aTroubleshootTestState( + status = NotificationTroubleshootTestState.Status.Failure( + hasQuickFix = hasQuickFix, + isCritical = isCritical, + quickFixButtonString = quickFixButtonString, + ) +) diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsView.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsView.kt new file mode 100644 index 0000000..fa5e707 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsView.kt @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.runtime.Composable +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 androidx.lifecycle.Lifecycle +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.Icon +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState.Status + +@Composable +fun TroubleshootNotificationsView( + state: TroubleshootNotificationsState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + OnLifecycleEvent { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> { + if (state.hasFailedTests) { + state.eventSink(TroubleshootNotificationsEvents.RetryFailedTests) + } + } + else -> Unit + } + } + + PreferencePage( + modifier = modifier, + onBackClick = onBackClick, + title = stringResource(id = R.string.troubleshoot_notifications_screen_title), + ) { + TroubleshootNotificationsContent(state) + } +} + +@Composable +private fun ColumnScope.TroubleshootTestView( + testState: NotificationTroubleshootTestState, + onQuickFixClick: () -> Unit, +) { + val status = testState.status + if ((status as? Status.Idle)?.visible == false) return + ListItem( + headlineContent = { Text(text = testState.name) }, + supportingContent = { Text(text = testState.description) }, + trailingContent = when (status) { + is Status.Idle -> null + Status.InProgress -> ListItemContent.Custom { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(20.dp), + strokeWidth = 2.dp + ) + } + Status.WaitingForUser -> ListItemContent.Custom { + Icon( + contentDescription = null, + modifier = Modifier.size(24.dp), + imageVector = CompoundIcons.Info(), + tint = ElementTheme.colors.iconAccentTertiary + ) + } + Status.Success -> ListItemContent.Custom { + Icon( + contentDescription = null, + modifier = Modifier.size(24.dp), + imageVector = CompoundIcons.Check(), + tint = ElementTheme.colors.iconAccentTertiary + ) + } + is Status.Failure -> ListItemContent.Custom { + Icon( + contentDescription = null, + modifier = Modifier.size(24.dp), + imageVector = if (status.isCritical) CompoundIcons.ErrorSolid() else CompoundIcons.Warning(), + tint = ElementTheme.colors.iconCriticalPrimary, + ) + } + } + ) + if (status is Status.Failure && status.hasQuickFix) { + ListItem( + headlineContent = { }, + trailingContent = ListItemContent.Custom { + Button( + text = status.quickFixButtonString ?: stringResource(id = R.string.troubleshoot_notifications_screen_quick_fix_action), + onClick = onQuickFixClick, + ) + } + ) + } +} + +@Composable +private fun ColumnScope.TroubleshootNotificationsContent(state: TroubleshootNotificationsState) { + when (state.testSuiteState.mainState) { + AsyncAction.Loading, + is AsyncAction.Confirming, + is AsyncAction.Success, + is AsyncAction.Failure -> { + TestSuiteView( + testSuiteState = state.testSuiteState, + onQuickFixClick = { + state.eventSink(TroubleshootNotificationsEvents.QuickFix(it)) + } + ) + } + AsyncAction.Uninitialized -> Unit + } + when (state.testSuiteState.mainState) { + AsyncAction.Uninitialized -> { + ListItem(headlineContent = { + Text( + text = stringResource(id = R.string.troubleshoot_notifications_screen_notice) + ) + }) + RunTestButton(state = state) + } + AsyncAction.Loading -> Unit + is AsyncAction.Failure -> { + ListItem(headlineContent = { + Text(text = stringResource(id = R.string.troubleshoot_notifications_screen_failure)) + }) + RunTestButton(state = state) + } + is AsyncAction.Confirming -> { + ListItem(headlineContent = { + Text( + text = stringResource(id = R.string.troubleshoot_notifications_screen_waiting) + ) + }) + } + is AsyncAction.Success -> { + ListItem(headlineContent = { + Text( + text = stringResource(id = R.string.troubleshoot_notifications_screen_success) + ) + }) + } + } +} + +@Composable +private fun RunTestButton(state: TroubleshootNotificationsState) { + ListItem( + headlineContent = { + Button( + text = stringResource( + id = if (state.testSuiteState.mainState is AsyncAction.Failure) { + R.string.troubleshoot_notifications_screen_action_again + } else { + R.string.troubleshoot_notifications_screen_action + } + ), + onClick = { + state.eventSink(TroubleshootNotificationsEvents.StartTests) + }, + modifier = Modifier.fillMaxWidth(), + ) + } + ) +} + +@Composable +private fun ColumnScope.TestSuiteView( + testSuiteState: TroubleshootTestSuiteState, + onQuickFixClick: (Int) -> Unit, +) { + testSuiteState.tests.forEachIndexed { index, testState -> + TroubleshootTestView( + testState = testState, + onQuickFixClick = { + onQuickFixClick(index) + }, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun TroubleshootNotificationsViewPreview( + @PreviewParameter(TroubleshootNotificationsStateProvider::class) state: TroubleshootNotificationsState, +) = ElementPreview { + TroubleshootNotificationsView( + state = state, + onBackClick = {}, + ) +} diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootTestSuite.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootTestSuite.kt new file mode 100644 index 0000000..52f0d89 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootTestSuite.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl + +import dev.zacsweers.metro.Inject +import im.vector.app.features.analytics.plan.NotificationTroubleshoot +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.api.GetCurrentPushProvider +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootNavigator +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.api.test.TestFilterData +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@Inject +class TroubleshootTestSuite( + private val sessionId: SessionId, + private val notificationTroubleshootTests: Set<@JvmSuppressWildcards NotificationTroubleshootTest>, + private val getCurrentPushProvider: GetCurrentPushProvider, + private val analyticsService: AnalyticsService, +) { + lateinit var tests: List + + private val _state: MutableStateFlow = MutableStateFlow( + TroubleshootTestSuiteState( + mainState = AsyncAction.Uninitialized, + tests = emptyList().toImmutableList() + ) + ) + val state: StateFlow = _state + + suspend fun start(coroutineScope: CoroutineScope) { + val testFilterData = TestFilterData( + currentPushProviderName = getCurrentPushProvider.getCurrentPushProvider(sessionId) + ) + tests = notificationTroubleshootTests + .filter { it.isRelevant(testFilterData) } + .sortedBy { it.order } + tests.forEach { + // Observe the state of the tests + it.state.onEach { + emitState() + }.launchIn(coroutineScope) + } + } + + suspend fun runTestSuite(coroutineScope: CoroutineScope) { + tests.forEach { + it.reset() + } + tests.forEach { + it.run(coroutineScope) + } + } + + suspend fun retryFailedTest(coroutineScope: CoroutineScope) { + tests + .filter { it.state.value.status is NotificationTroubleshootTestState.Status.Failure } + .forEach { + it.run(coroutineScope) + } + } + + private suspend fun emitState() { + val states = tests.map { it.state.value } + val mainState = states.computeMainState() + when (mainState) { + is AsyncAction.Success -> { + analyticsService.capture(NotificationTroubleshoot(hasError = false)) + } + is AsyncAction.Failure -> { + analyticsService.capture(NotificationTroubleshoot(hasError = true)) + } + else -> Unit + } + _state.emit( + TroubleshootTestSuiteState( + mainState = states.computeMainState(), + tests = states.toImmutableList() + ) + ) + } + + suspend fun quickFix( + testIndex: Int, + coroutineScope: CoroutineScope, + navigator: NotificationTroubleshootNavigator, + ) { + tests[testIndex].quickFix(coroutineScope, navigator) + } +} + +fun List.computeMainState(): AsyncAction { + val isIdle = all { it.status is NotificationTroubleshootTestState.Status.Idle } + val isRunning = any { it.status is NotificationTroubleshootTestState.Status.InProgress } + return when { + isIdle -> AsyncAction.Uninitialized + isRunning -> AsyncAction.Loading + else -> { + if (any { it.status is NotificationTroubleshootTestState.Status.WaitingForUser }) { + AsyncAction.ConfirmingNoParams + } else if (any { it.status.let { status -> status is NotificationTroubleshootTestState.Status.Failure && status.isCritical } }) { + AsyncAction.Failure(Exception("Some tests failed")) + } else { + AsyncAction.Success(Unit) + } + } + } +} diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootTestSuiteState.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootTestSuiteState.kt new file mode 100644 index 0000000..c51af92 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootTestSuiteState.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import kotlinx.collections.immutable.ImmutableList + +data class TroubleshootTestSuiteState( + val mainState: AsyncAction, + val tests: ImmutableList, +) diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/DefaultPushHistoryEntryPoint.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/DefaultPushHistoryEntryPoint.kt new file mode 100644 index 0000000..d19dcb6 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/DefaultPushHistoryEntryPoint.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl.history + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint + +@ContributesBinding(AppScope::class) +class DefaultPushHistoryEntryPoint : PushHistoryEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: PushHistoryEntryPoint.Callback, + ): Node { + return parentNode.createNode(buildContext, listOf(callback)) + } +} diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryEvents.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryEvents.kt new file mode 100644 index 0000000..5116e0e --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryEvents.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl.history + +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 + +sealed interface PushHistoryEvents { + data class SetShowOnlyErrors(val showOnlyErrors: Boolean) : PushHistoryEvents + data class Reset(val requiresConfirmation: Boolean) : PushHistoryEvents + data class NavigateTo(val sessionId: SessionId, val roomId: RoomId, val eventId: EventId) : PushHistoryEvents + data object ClearDialog : PushHistoryEvents +} diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryNode.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryNode.kt new file mode 100644 index 0000000..ed5eaad --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryNode.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl.history + +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint +import io.element.android.services.analytics.api.ScreenTracker + +@ContributesNode(SessionScope::class) +@AssistedInject +class PushHistoryNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: PushHistoryPresenter.Factory, + private val screenTracker: ScreenTracker, +) : Node(buildContext, plugins = plugins), PushHistoryNavigator { + private val callback: PushHistoryEntryPoint.Callback = callback() + + override fun navigateTo(roomId: RoomId, eventId: EventId) { + callback.navigateToEvent(roomId, eventId) + } + + private val presenter = presenterFactory.create(this) + + @Composable + override fun View(modifier: Modifier) { + screenTracker.TrackScreen(MobileScreen.ScreenName.NotificationTroubleshoot) + val state = presenter.present() + PushHistoryView( + state = state, + onBackClick = callback::onDone, + modifier = modifier, + ) + } +} diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt new file mode 100644 index 0000000..ad18780 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenter.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl.history + +import androidx.compose.runtime.Composable +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 dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +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.push.api.PushService +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +fun interface PushHistoryNavigator { + fun navigateTo(roomId: RoomId, eventId: EventId) +} + +@AssistedInject +class PushHistoryPresenter( + @Assisted private val pushHistoryNavigator: PushHistoryNavigator, + private val pushService: PushService, + matrixClient: MatrixClient, +) : Presenter { + @AssistedFactory + fun interface Factory { + fun create(pushHistoryNavigator: PushHistoryNavigator): PushHistoryPresenter + } + + private val sessionId = matrixClient.sessionId + + @Composable + override fun present(): PushHistoryState { + val coroutineScope = rememberCoroutineScope() + val pushCounter by pushService.pushCounter.collectAsState(0) + var showOnlyErrors: Boolean by remember { mutableStateOf(false) } + val pushHistory by remember(showOnlyErrors) { + pushService.getPushHistoryItemsFlow().map { + if (showOnlyErrors) { + it.filter { item -> item.hasBeenResolved.not() } + } else { + it + } + } + }.collectAsState(emptyList()) + var resetAction: AsyncAction by remember { mutableStateOf(AsyncAction.Uninitialized) } + var showNotSameAccountError by remember { mutableStateOf(false) } + + fun handleEvent(event: PushHistoryEvents) { + when (event) { + is PushHistoryEvents.SetShowOnlyErrors -> { + showOnlyErrors = event.showOnlyErrors + } + is PushHistoryEvents.Reset -> { + if (event.requiresConfirmation) { + resetAction = AsyncAction.ConfirmingNoParams + } else { + resetAction = AsyncAction.Loading + coroutineScope.launch { + pushService.resetPushHistory() + resetAction = AsyncAction.Uninitialized + } + } + } + PushHistoryEvents.ClearDialog -> { + resetAction = AsyncAction.Uninitialized + showNotSameAccountError = false + } + is PushHistoryEvents.NavigateTo -> { + if (event.sessionId != sessionId) { + showNotSameAccountError = true + } else { + pushHistoryNavigator.navigateTo(event.roomId, event.eventId) + } + } + } + } + + return PushHistoryState( + pushCounter = pushCounter, + pushHistoryItems = pushHistory.toImmutableList(), + showOnlyErrors = showOnlyErrors, + resetAction = resetAction, + showNotSameAccountError = showNotSameAccountError, + eventSink = ::handleEvent, + ) + } +} diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryState.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryState.kt new file mode 100644 index 0000000..dbc98bd --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryState.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl.history + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.push.api.history.PushHistoryItem +import kotlinx.collections.immutable.ImmutableList + +data class PushHistoryState( + val pushCounter: Int, + val pushHistoryItems: ImmutableList, + val showOnlyErrors: Boolean, + val resetAction: AsyncAction, + val showNotSameAccountError: Boolean, + val eventSink: (PushHistoryEvents) -> Unit, +) diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryStateProvider.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryStateProvider.kt new file mode 100644 index 0000000..e738404 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryStateProvider.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl.history + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +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.push.api.history.PushHistoryItem +import kotlinx.collections.immutable.toImmutableList + +open class PushHistoryStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aPushHistoryState(), + aPushHistoryState( + pushCounter = 123, + pushHistoryItems = listOf( + aPushHistoryItem( + hasBeenResolved = false, + comment = "An error description" + ), + aPushHistoryItem( + pushDate = 1, + providerInfo = "providerInfo2", + eventId = EventId("\$anEventId"), + roomId = RoomId("!roomId:domain"), + sessionId = SessionId("@alice:server.org"), + hasBeenResolved = true, + comment = "A comment" + ) + ) + ), + aPushHistoryState( + resetAction = AsyncAction.ConfirmingNoParams, + ), + aPushHistoryState( + showNotSameAccountError = true, + ), + ) +} + +fun aPushHistoryState( + pushCounter: Int = 0, + pushHistoryItems: List = emptyList(), + showOnlyErrors: Boolean = false, + resetAction: AsyncAction = AsyncAction.Uninitialized, + showNotSameAccountError: Boolean = false, + eventSink: (PushHistoryEvents) -> Unit = {}, +) = PushHistoryState( + pushCounter = pushCounter, + pushHistoryItems = pushHistoryItems.toImmutableList(), + showOnlyErrors = showOnlyErrors, + resetAction = resetAction, + showNotSameAccountError = showNotSameAccountError, + eventSink = eventSink, +) + +fun aPushHistoryItem( + pushDate: Long = 0, + formattedDate: String = "formattedDate", + providerInfo: String = "providerInfo", + eventId: EventId? = null, + roomId: RoomId? = null, + sessionId: SessionId? = null, + hasBeenResolved: Boolean = false, + comment: String? = null, +): PushHistoryItem { + return PushHistoryItem( + pushDate = pushDate, + formattedDate = formattedDate, + providerInfo = providerInfo, + eventId = eventId, + roomId = roomId, + sessionId = sessionId, + hasBeenResolved = hasBeenResolved, + comment = comment + ) +} diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryView.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryView.kt new file mode 100644 index 0000000..9ae748d --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryView.kt @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl.history + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +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.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.components.async.AsyncActionView +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.components.list.ListItemContent +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.ListItem +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.push.api.history.PushHistoryItem +import io.element.android.libraries.troubleshoot.impl.R +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PushHistoryView( + state: PushHistoryState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var showMenu by remember { mutableStateOf(false) } + + Scaffold( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + .imePadding(), + contentWindowInsets = WindowInsets.statusBars, + topBar = { + TopAppBar( + navigationIcon = { + BackButton(onClick = onBackClick) + }, + titleStr = stringResource(R.string.screen_push_history_title), + actions = { + IconButton(onClick = { showMenu = !showMenu }) { + Icon( + imageVector = CompoundIcons.OverflowVertical(), + contentDescription = stringResource(id = CommonStrings.a11y_user_menu), + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Show only errors") }, + trailingIcon = if (state.showOnlyErrors) { + { + Icon( + imageVector = CompoundIcons.CheckCircleSolid(), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } + } else { + null + }, + onClick = { + showMenu = false + state.eventSink(PushHistoryEvents.SetShowOnlyErrors(state.showOnlyErrors.not())) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(id = CommonStrings.action_reset)) }, + onClick = { + showMenu = false + state.eventSink(PushHistoryEvents.Reset(requiresConfirmation = true)) + }, + ) + } + } + ) + }, + ) { padding -> + PushHistoryContent( + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding), + state = state, + ) + } + + AsyncActionView( + async = state.resetAction, + onSuccess = {}, + confirmationDialog = { + ConfirmationDialog( + content = "", + title = stringResource(CommonStrings.dialog_title_confirmation), + submitText = stringResource(CommonStrings.action_reset), + cancelText = stringResource(CommonStrings.action_cancel), + onSubmitClick = { state.eventSink(PushHistoryEvents.Reset(requiresConfirmation = false)) }, + onDismiss = { state.eventSink(PushHistoryEvents.ClearDialog) }, + ) + }, + onErrorDismiss = {}, + ) + + if (state.showNotSameAccountError) { + ErrorDialog( + content = "Please switch account first to navigate to the event.", + onSubmit = { state.eventSink(PushHistoryEvents.ClearDialog) } + ) + } +} + +@Composable +private fun PushHistoryContent( + state: PushHistoryState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth() + ) { + ListItem( + headlineContent = { Text("Total number of received push") }, + trailingContent = ListItemContent.Text(state.pushCounter.toString()), + ) + LazyColumn( + modifier = Modifier.fillMaxWidth() + ) { + items( + items = state.pushHistoryItems, + key = { + it.pushDate.toString() + it.sessionId + it.roomId + it.eventId + }, + ) { pushHistory -> + PushHistoryItem( + pushHistory, + onClick = { + val sessionId = pushHistory.sessionId + val roomId = pushHistory.roomId + val eventId = pushHistory.eventId + if (sessionId != null && roomId != null && eventId != null) { + state.eventSink(PushHistoryEvents.NavigateTo(sessionId, roomId, eventId)) + } + } + ) + } + } + } +} + +@Composable +private fun PushHistoryItem( + pushHistoryItem: PushHistoryItem, + onClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { + onClick() + }, + ) { + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + ) { + Text( + text = pushHistoryItem.formattedDate, + color = ElementTheme.colors.textPrimary, + ) + Text( + text = pushHistoryItem.providerInfo, + color = ElementTheme.colors.textPrimary, + ) + Text( + modifier = Modifier.padding(start = 8.dp, top = 8.dp), + text = pushHistoryItem.sessionId?.value ?: "No sessionId", + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyMdRegular, + ) + Text( + modifier = Modifier.padding(start = 8.dp), + text = pushHistoryItem.roomId?.value ?: "No roomId", + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyMdRegular, + ) + Text( + modifier = Modifier.padding(start = 8.dp), + text = pushHistoryItem.eventId?.value ?: "No eventId", + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyMdRegular, + ) + pushHistoryItem.comment?.let { + Text( + modifier = Modifier.padding(top = 8.dp), + text = it, + color = if (pushHistoryItem.hasBeenResolved) { + ElementTheme.colors.textSecondary + } else { + ElementTheme.colors.textCriticalPrimary + }, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } + } + if (pushHistoryItem.hasBeenResolved) { + Icon( + imageVector = CompoundIcons.CheckCircleSolid(), + modifier = Modifier.size(24.dp), + tint = ElementTheme.colors.iconSuccessPrimary, + contentDescription = null, + ) + } else { + Icon( + imageVector = CompoundIcons.Error(), + modifier = Modifier.size(24.dp), + tint = ElementTheme.colors.iconCriticalPrimary, + contentDescription = null, + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun PushHistoryViewPreview( + @PreviewParameter(PushHistoryStateProvider::class) state: PushHistoryState, +) = ElementPreview { + PushHistoryView( + state = state, + onBackClick = {}, + ) +} diff --git a/libraries/troubleshoot/impl/src/main/res/values-be/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..905b9e8 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,11 @@ + + + "Запусціць тэсты" + "Запусціце тэсты яшчэ раз" + "Некаторыя тэсты не ўдаліся. Калі ласка, праглядзіце дэталі." + "Запусціце тэсты, каб выявіць праблемы ў вашай канфігурацыі, з-за якіх апавяшчэння могуць паводзіць сябе не так, як чакалася." + "Спроба выпраўлення" + "Усе тэсты паспяхова пройдзены." + "Выпраўленне непаладак з апавяшчэннямі" + "Некаторыя тэсты патрабуюць вашай увагі. Калі ласка, праглядзіце дэталі." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-bg/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..3af1921 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,7 @@ + + + "Изпълняване на тестове" + "Изпълняване на тестовете отново" + "Всички тестове преминаха успешно." + "Отстраняване на неизправности с известията" + diff --git a/libraries/troubleshoot/impl/src/main/res/values-cs/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 0000000..5e4a4f1 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,12 @@ + + + "Historie push oznámení" + "Spustit testy" + "Spustit testy znovu" + "Některé testy selhaly. Zkontrolujte prosím podrobnosti." + "Spusťte testy, abyste zjistili jakýkoli problém ve vaší konfiguraci, který může způsobit, že se oznámení nebudou chovat podle očekávání." + "Pokus o opravu" + "Všechny testy proběhly úspěšně." + "Odstraňování problémů s upozorněními" + "Některé testy vyžadují vaši pozornost. Zkontrolujte prosím podrobnosti." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-cy/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..8ea2bd1 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,12 @@ + + + "Hanes gwthio" + "Rhedeg profion" + "Rhedeg profion eto" + "Methodd rhai profion. Gwiriwch y manylion." + "Rhedwch y profion i ganfod unrhyw broblem yn eich ffurfweddiad a allai wneud i hysbysiadau beidio ag ymddwyn yn ôl y disgwyl." + "Ceisio trwsio" + "Pasiwyd pob prawf yn llwyddiannus." + "Hysbysiadau datrys problemau" + "Mae rhai profion angen eich sylw. Gwiriwch y manylion." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-da/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..c6c913d --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,12 @@ + + + "Push-historik" + "Kør test" + "Kør test igen" + "Nogle tests mislykkedes. Tjek venligst detaljerne." + "Kør testene for at registrere de problemer i din konfiguration, der kan medføre, at meddelelser ikke opfører sig som forventet." + "Forsøg på at reparere" + "Alle tests bestået med succes." + "Fejlfinding af meddelelser" + "Nogle tests kræver din opmærksomhed. Tjek venligst detaljerne." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-de/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-de/translations.xml new file mode 100644 index 0000000..60a11ba --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,12 @@ + + + "Verlauf pushen" + "Tests durchführen" + "Tests erneut durchführen" + "Einige Tests sind fehlgeschlagen. Bitte überprüfe die Details." + "Führe die Tests durch, um Probleme zu erkennen, die dazu führen können, dass sich die Benachrichtigungen nicht wie erwartet verhalten." + "Problem beheben" + "Alle Tests wurden erfolgreich bestanden." + "Fehlerbehebung für Benachrichtigungen" + "Einige Tests erfordern deine Aufmerksamkeit. Bitte überprüfe die Details." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-el/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..fcac4c0 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,12 @@ + + + "Ιστορικό push" + "Εκτέλεση δοκιμών" + "Επανεκτέλεση δοκιμών" + "Ορισμένες δοκιμές απέτυχαν. Παρακαλώ έλεγξε τις λεπτομέρειες." + "Εκτέλεσε τις δοκιμές για να εντοπίσεις οποιοδήποτε πρόβλημα στη διαμόρφωσή σου που ενδέχεται να κάνει τις ειδοποιήσεις να μην συμπεριφέρονται όπως αναμένεται." + "Προσπάθεια διόρθωσης" + "Όλες οι δοκιμές έγιναν με επιτυχία." + "Αντιμετώπιση προβλημάτων ειδοποιήσεων" + "Ορισμένες δοκιμές απαιτούν την προσοχή σου. Έλεγξε τις λεπτομέρειες." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-es/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-es/translations.xml new file mode 100644 index 0000000..9a2e946 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,12 @@ + + + "Historial de notificaciones push" + "Ejecutar pruebas" + "Volver a ejecutar pruebas" + "Algunas pruebas fallaron. Por favor, verifica los detalles." + "Ejecuta las pruebas para detectar cualquier problema en tu configuración que pueda hacer que las notificaciones no se comporten como es debido." + "Intentar arreglar" + "Todas las pruebas se han superado con éxito." + "Solucionar problemas con las notificaciones" + "Algunas pruebas requieren tu atención. Por favor, verifica los detalles." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-et/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..e2983c6 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,12 @@ + + + "Tõuketeadete ajalugu" + "Käivita testid" + "Käivita testid uuesti" + "Mõned testid tuvastasid vigu. Palun vaata üksikasjalikku teavet." + "Tuvastamaks kas teavituste toimimiseks on vigu, palun käivita mõned asjakohased testid." + "Proovi seda lahendada" + "Testid ei tuvastanud vigu." + "Teavituste veaotsing" + "Palun pööra tähelepanu mõnede testide tulemustele. Palun vaata üksikasjalikku teavet." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-eu/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..f609e42 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,9 @@ + + + "Exekutatu testak" + "Egin probak berriro" + "Proba batzuek huts egin dute. Aztertu xehetasunak." + "Saiatu konpontzen" + "Proba guztiak arrakastaz gainditu dira." + "Proba batzuek zure arreta eskatzen dute. Aztertu xehetasunak." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-fa/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..59f9ca6 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,10 @@ + + + "تاریخچهٔ آگاهی‌های ارسالی" + "اجرای آزمون‌ها" + "اجرای دوبارهٔ آزمون‌ها" + "برخی آزمون‌ها شکست خوردند. بررسی جزییات." + "تلاش برای تعمیر" + "همهٔ آزمون‌ها با موفّقیت گذرانده شدند." + "رفع‌اشکال آگاهی‌ها" + diff --git a/libraries/troubleshoot/impl/src/main/res/values-fi/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..9241534 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,12 @@ + + + "Push-historia" + "Suorita testit" + "Suorita testit uudelleen" + "Osa testeistä epäonnistui. Tarkista tiedot." + "Suorita testit havaitaksesi konfiguraatiossasi olevat ongelmat, joiden vuoksi ilmoitukset eivät ehkä toimi odotetulla tavalla." + "Yritä korjata" + "Kaikki testit läpäistiin onnistuneesti." + "Ilmoitusten vianmääritys" + "Jotkin testit vaativat huomiotasi. Tarkista tiedot." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-fr/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000..c920d3f --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,12 @@ + + + "Historique des Push" + "Exécuter les tests" + "Relancer les tests" + "Certains tests ont échoué. Veuillez vérifier les détails." + "Exécuter les tests pour détecter tout problème dans votre configuration susceptible de provoquer un dysfonctionnement des notifications." + "Tenter de corriger" + "Tous les tests ont réussi." + "Dépanner les notifications" + "Certains tests nécessitent votre attention. Veuillez vérifier les détails." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-hu/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..ff76ef3 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,12 @@ + + + "Leküldéses értesítések előzmények" + "Tesztek futtatása" + "Tesztek újbóli futtatása" + "Egyes tesztek sikertelenek voltak. Ellenőrizze a részleteket." + "A tesztek futtatása, hogy észlelje a konfigurációban felmerülő olyan problémákat, amelyek miatt az értesítések nem az elvárt módon viselkednek." + "Kísérlet a javításra" + "Minden teszt sikeresen lezajlott." + "Értesítések hibaelhárítása" + "Egyes tesztek a figyelmét igénylik. Ellenőrizze a részleteket." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-in/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..a2a6039 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,12 @@ + + + "Riwayat dorongan" + "Jalankan tes" + "Jalankan tes lagi" + "Beberapa tes gagal. Silakan periksa detailnya." + "Jalankan pengujian untuk mendeteksi masalah apa pun dalam konfigurasi Anda yang mungkin membuat notifikasi tidak berperilaku seperti yang diharapkan." + "Mencoba untuk memperbaiki" + "Semua tes berhasil dilalui." + "Pecahkan masalah notifikasi" + "Beberapa tes membutuhkan perhatian Anda. Silakan periksa detailnya." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-it/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000..946915f --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,12 @@ + + + "Cronologia push" + "Esegui i test" + "Esegui nuovamente i test" + "Alcuni test sono falliti. Si prega di controllare i dettagli." + "Esegui i test per individuare eventuali problemi nella tua configurazione che potrebbero far sì che le notifiche non si comportino come previsto." + "Prova a risolvere" + "Tutti i test sono stati superati con successo." + "Risoluzione di problemi delle notifiche" + "Alcuni test richiedono la tua attenzione. Si prega di controllare i dettagli." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-ka/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..347301a --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,11 @@ + + + "ტესტების გაშვება" + "ტესტების ისევ გაშვება" + "ზოგიერთი ტესტი წარუმატებელია. გთხოვთ შეამოწმოთ დეტალები." + "ჩაატარეთ ტესტები თქვენს კონფიგურაციაში იმ პრობლემების აღმოსაჩენად, რომლებიც გამოიწვევენ შეტყობინებების არასწორ ქცევას." + "შეკეთების მცდელობა" + "ყველა ტესტი წარმატებით დასრულდა." + "პრობლემების გადაჭრის შეტყობინებები" + "ზოგიერთი ტესტი ითხოვს თქვენს ყურადღებას. გთხოვთ შეამოწმოთ დეტალები." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-ko/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..9713cae --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,12 @@ + + + "푸시 기록" + "테스트 실행" + "테스트를 다시 실행하세요" + "일부 테스트가 실패했습니다. 자세한 내용을 확인해 주세요." + "구성에서 알림이 예상대로 작동하지 않게 하는 문제가 있는지 감지하기 위해 테스트를 실행하세요." + "수정을 시도하다" + "모든 테스트를 성공적으로 통과했습니다." + "문제 해결 알림" + "일부 테스트는 귀하의 주의가 필요합니다. 자세한 내용을 확인해 주시기 바랍니다." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-nb/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..92b31f7 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,12 @@ + + + "Push-historikk" + "Kjør tester" + "Kjør tester igjen" + "Noen tester mislyktes. Vennligst sjekk detaljene." + "Kjør testene for å avdekke eventuelle problemer i konfigurasjonen som kan føre til at varslene ikke oppfører seg som forventet." + "Forsøk å fikse" + "Alle testene ble bestått." + "Feilsøk varsler" + "Noen tester krever din oppmerksomhet. Vennligst sjekk detaljene." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-nl/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..5ce2bd8 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,11 @@ + + + "Tests uitvoeren" + "Tests opnieuw uitvoeren" + "Sommige tests zijn mislukt. Controleer de details." + "Voer de tests uit om problemen in je configuratie op te sporen waardoor meldingen mogelijk niet naar verwachting werken." + "Probeer het op te lossen" + "Alle tests zijn geslaagd." + "Problemen met meldingen oplossen" + "Sommige tests vereisen je aandacht. Controleer de details." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-pl/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..0a02363 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,12 @@ + + + "Historia powiadomień Push" + "Uruchom testy" + "Uruchom testy ponownie" + "Niektóre testy się nie powiodły. Sprawdź szczegóły." + "Uruchom testy, aby wykryć potencjalne problemy z konfiguracją, jeśli powiadomienia nie działają prawidłowo." + "Spróbuj naprawić" + "Wszystkie testy przebiegły pomyślnie." + "Rozwiązywanie problemów powiadomień" + "Niektóre testy wymagają Twojej uwagi. Sprawdź szczegóły." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-pt-rBR/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..5b1e7a7 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,12 @@ + + + "Histórico de push" + "Executar testes" + "Executar os testes novamente" + "Alguns testes falharam. Verifique os detalhes." + "Execute os testes para detectar qualquer problema na sua configuração que possa fazer com que as notificações não se comportem como esperado." + "Tentar consertar" + "Todos os testes foram aprovados com sucesso." + "Solucionar problemas de notificações" + "Alguns testes exigem sua atenção. Verifique os detalhes." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-pt/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..807e29e --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,12 @@ + + + "Histórico de push" + "Correr testes" + "Correr testes novamente" + "Alguns testes falharam. Por favor, verifica os detalhes." + "Corre os testes para detetar problemas com a tua configuração que possam levar a comportamentos inesperados das notificações." + "Tentar corrigir" + "Todos os testes realizados sem problemas." + "Corrigir notificações" + "Alguns testes necessitam a tua atenção. Por favor, verifica os detalhes." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-ro/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000..c28887c --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,12 @@ + + + "Istoricul notificărilor" + "Rulați testele" + "Rulați din nou testele" + "Unele teste au eșuat. Vă rugăm să verificați detaliile." + "Rulați testele pentru a detecta probleme în configurația dumneavoastră care poat face ca notificările să nu se funcționeze corect." + "Încercați să remediați" + "Toate testele au trecut cu succes." + "Depanați notificările" + "Unele teste necesită atenția dumneavoastră. Vă rugăm să verificați detaliile." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-ru/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..a37b3c8 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,12 @@ + + + "История уведомлений" + "Выполнение тестов" + "Повторное выполнение тестов" + "Некоторые тесты провалились. Пожалуйста, проверьте детали." + "Выполните тесты, чтобы обнаружить любую проблему в конфигурации, из-за которой уведомления могут работать не так, как ожидалось." + "Попытка исправить" + "Все тесты прошли успешно." + "Уведомления об устранении неполадок" + "Некоторые тесты требуют вашего внимания. Пожалуйста, проверьте детали." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-sk/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000..e661660 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,12 @@ + + + "História push oznámení" + "Spustiť testy" + "Spustiť testy znova" + "Niektoré testy zlyhali. Skontrolujte prosím podrobnosti." + "Spustite testy, aby ste zistili akýkoľvek problém vo vašej konfigurácii, ktorý môže spôsobiť, že sa upozornenia nebudú správať podľa očakávania." + "Pokus o opravu" + "Všetky testy prebehli úspešne." + "Oznámenia riešení problémov" + "Niektoré testy si vyžadujú vašu pozornosť. Prosím skontrolujte podrobnosti." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-sv/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..28ff461 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,12 @@ + + + "Push-historik" + "Kör tester" + "Kör tester igen" + "Vissa tester misslyckades. Kontrollera detaljerna." + "Kör testerna för att upptäcka eventuella problem i din konfiguration som kan göra att aviseringar inte fungerar som förväntat." + "Försök att fixa" + "Alla tester godkändes." + "Felsök aviseringar" + "Vissa tester kräver din uppmärksamhet. Kontrollera detaljerna." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-tr/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..24dba50 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,11 @@ + + + "Testleri çalıştır" + "Testleri yeniden çalıştır" + "Bazı testler başarısız oldu. Lütfen ayrıntıları kontrol edin." + "Yapılandırmanızda bildirimlerin beklendiği gibi davranmamasına neden olabilecek herhangi bir sorunu algılamak için testleri çalıştırın." + "Düzeltmeye çalışın" + "Tüm testler başarıyla geçti." + "Sorun Giderme Bildirimleri" + "Bazı testlere bakmanız gerekiyor. Lütfen ayrıntıları kontrol edin." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-uk/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..879ce09 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,12 @@ + + + "Історія push-сповіщень" + "Запустити тести" + "Запустити тести знову" + "Деякі тести не пройдено. Перегляньте подробиці." + "Запустіть тести, щоб виявити будь-яку проблему у вашій конфігурації, через яку сповіщення можуть не працювати належним чином." + "Спробувати виправити" + "Всі тести успішно пройдено." + "Усунення неполадок сповіщень" + "Деякі тести вимагають вашої уваги. Перегляньте подробиці." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-ur/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..509d332 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,11 @@ + + + "جانچہا چلائیں" + "جانچہا دوبارہ چلائیں" + "کچھ جانچیں ناکام۔ برائے مہربانی تفصیلات پڑتال کریں۔" + "اپنی تکوین میں کسی بھی مسئلے کا کھوج لگانے کے لیے جانچ ہا چلائیں جس سے اطلاعات توقع کے مطابق برتاؤ نہ کریں۔" + "ٹھیک کرنے کی کوشش کریں" + "تمام جانچیں کامیابی سے گزاریں ہیں۔" + "اطلاعات کا ازالہ کریں" + "کچھ جانچوں کیلئے آپکی توجہ درکار ہے۔ برائے مہربانی تفصیلات پڑتال کریں۔" + diff --git a/libraries/troubleshoot/impl/src/main/res/values-uz/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..d7fa720 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,12 @@ + + + "Bildirishnoma tarixi" + "Testlarni ishga tushirish" + "Testlarni qayta ishga tushirish" + "Ba’zi testlar muvaffaqiyatsiz tugadi. Iltimos, tafsilotlarni tekshirib chiqing." + "Sozlamalaringizdagi bildirishnomalarning kutilganidek ishlamasligi mumkin bo‘lgan har qanday muammoni aniqlash uchun testlarni o‘tkazing." + "Tuzatishga urinish" + "Barcha testlar muvaffaqiyatli yakunlandi." + "Bildirishnomalar bilan bog‘liq muammolarni bartaraf etish" + "Baʼzi testlar sizning eʼtiboringizni talab etadi. Iltimos, tafsilotlarni tekshirib chiqing." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-zh-rTW/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..4249f8a --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,12 @@ + + + "推播通知歷史紀錄" + "執行測試" + "再次執行測試" + "部份測試失敗。請檢查詳細資訊。" + "執行測試以偵測組態中可能導致通知無法如預期般執行的任何問題。" + "嘗試修復" + "所有測試皆成功通過。" + "疑難排解通知" + "部份測試需要您的注意。請檢查詳細資訊。" + diff --git a/libraries/troubleshoot/impl/src/main/res/values-zh/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..2375580 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,12 @@ + + + "推送历史记录" + "运行测试" + "再次运行测试" + "一些测试失败了。请查看详情。" + "运行测试以检测您的配置中可能导致通知无法按预期运行的问题。" + "尝试修复" + "所有测试均成功通过。" + "排查通知问题" + "有些测试需要注意。请查看详情。" + diff --git a/libraries/troubleshoot/impl/src/main/res/values/localazy.xml b/libraries/troubleshoot/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000..0427392 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values/localazy.xml @@ -0,0 +1,12 @@ + + + "Push history" + "Run tests" + "Run tests again" + "Some tests failed. Please check the details." + "Run the tests to detect any issue in your configuration that may make notifications not behave as expected." + "Attempt to fix" + "All tests passed successfully." + "Troubleshoot notifications" + "Some tests require your attention. Please check the details." + diff --git a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/DefaultNotificationTroubleShootEntryPointTest.kt b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/DefaultNotificationTroubleShootEntryPointTest.kt new file mode 100644 index 0000000..4bef725 --- /dev/null +++ b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/DefaultNotificationTroubleShootEntryPointTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint +import io.element.android.services.analytics.test.FakeScreenTracker +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultNotificationTroubleShootEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultNotificationTroubleShootEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + TroubleshootNotificationsNode( + buildContext = buildContext, + plugins = plugins, + factory = { createTroubleshootNotificationsPresenter() }, + screenTracker = FakeScreenTracker(), + ) + } + val callback = object : NotificationTroubleShootEntryPoint.Callback { + override fun onDone() = lambdaError() + override fun navigateToBlockedUsers() = lambdaError() + } + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + callback = callback, + ) + assertThat(result).isInstanceOf(TroubleshootNotificationsNode::class.java) + assertThat(result.plugins).contains(callback) + } +} diff --git a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/FakeNotificationTroubleshootTest.kt b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/FakeNotificationTroubleshootTest.kt new file mode 100644 index 0000000..597e45a --- /dev/null +++ b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/FakeNotificationTroubleshootTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl + +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootNavigator +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeNotificationTroubleshootTest( + override val order: Int = 0, + private val defaultName: String = "test name", + private val defaultDescription: String = "test description", + private val firstStatus: NotificationTroubleshootTestState.Status = NotificationTroubleshootTestState.Status.Idle(visible = true), + private val runAction: () -> NotificationTroubleshootTestState? = { null }, + private val resetAction: () -> NotificationTroubleshootTestState? = { null }, + private val quickFixAction: () -> NotificationTroubleshootTestState? = { null }, +) : NotificationTroubleshootTest { + private val _state = MutableStateFlow( + NotificationTroubleshootTestState( + name = defaultName, + description = defaultDescription, + status = firstStatus + ) + ) + override val state: StateFlow = _state.asStateFlow() + + override suspend fun run(coroutineScope: CoroutineScope) { + updateState(NotificationTroubleshootTestState.Status.InProgress) + runAction()?.let { + _state.tryEmit(it) + } + } + + override suspend fun reset() { + updateState( + name = defaultName, + description = defaultDescription, + status = firstStatus, + ) + resetAction()?.let { + _state.emit(it) + } + } + + override suspend fun quickFix( + coroutineScope: CoroutineScope, + navigator: NotificationTroubleshootNavigator, + ) { + updateState(NotificationTroubleshootTestState.Status.InProgress) + quickFixAction()?.let { + _state.emit(it) + } + } + + suspend fun updateState( + status: NotificationTroubleshootTestState.Status, + name: String = defaultName, + description: String = defaultDescription, + ) { + _state.emit( + NotificationTroubleshootTestState( + name = name, + description = description, + status = status, + ) + ) + } +} diff --git a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsPresenterTest.kt b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsPresenterTest.kt new file mode 100644 index 0000000..c8f8382 --- /dev/null +++ b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsPresenterTest.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.test.FakeGetCurrentPushProvider +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootNavigator +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class TroubleshootNotificationsPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createTroubleshootNotificationsPresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.testSuiteState.tests).isEmpty() + assertThat(initialState.testSuiteState.mainState).isEqualTo(AsyncAction.Uninitialized) + } + } + + @Test + fun `present - start test`() = runTest { + val troubleshootTestSuite = createTroubleshootTestSuite( + tests = setOf(FakeNotificationTroubleshootTest()) + ) + val presenter = createTroubleshootNotificationsPresenter( + troubleshootTestSuite = troubleshootTestSuite, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(TroubleshootNotificationsEvents.StartTests) + skipItems(1) + val stateAfterStart = awaitItem() + assertThat(stateAfterStart.testSuiteState.mainState).isEqualTo(AsyncAction.Loading) + } + } + + @Test + fun `present - start failed test`() = runTest { + val troubleshootTestSuite = createTroubleshootTestSuite( + tests = setOf( + FakeNotificationTroubleshootTest( + firstStatus = NotificationTroubleshootTestState.Status.Failure(hasQuickFix = false) + ) + ) + ) + val presenter = createTroubleshootNotificationsPresenter( + troubleshootTestSuite = troubleshootTestSuite, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(TroubleshootNotificationsEvents.RetryFailedTests) + skipItems(1) + val stateAfterStart = awaitItem() + assertThat(stateAfterStart.testSuiteState.mainState).isEqualTo(AsyncAction.Loading) + } + } + + @Test + fun `present - critical failed test`() { + `present - check main state`( + tests = setOf( + FakeNotificationTroubleshootTest( + firstStatus = NotificationTroubleshootTestState.Status.Failure(isCritical = true) + ) + ), + expectedIsCritical = true, + expectedMainState = AsyncAction.Failure::class.java, + ) + } + + @Test + fun `present - success and critical failed test`() { + `present - check main state`( + tests = setOf( + FakeNotificationTroubleshootTest( + firstStatus = NotificationTroubleshootTestState.Status.Success + ), + FakeNotificationTroubleshootTest( + firstStatus = NotificationTroubleshootTestState.Status.Failure(isCritical = true) + ), + ), + expectedIsCritical = true, + expectedMainState = AsyncAction.Failure::class.java, + ) + } + + @Test + fun `present - non critical failed test`() { + `present - check main state`( + tests = setOf( + FakeNotificationTroubleshootTest( + firstStatus = NotificationTroubleshootTestState.Status.Failure(isCritical = false) + ) + ), + expectedIsCritical = false, + expectedMainState = AsyncAction.Success::class.java, + ) + } + + @Test + fun `present - waiting for user`() { + `present - check main state`( + tests = setOf( + FakeNotificationTroubleshootTest( + firstStatus = NotificationTroubleshootTestState.Status.WaitingForUser + ) + ), + expectedIsCritical = false, + expectedMainState = AsyncAction.ConfirmingNoParams::class.java, + ) + } + + private fun `present - check main state`( + tests: Set, + expectedIsCritical: Boolean, + expectedMainState: Class>, + ) = runTest { + val troubleshootTestSuite = createTroubleshootTestSuite( + tests = tests + ) + val presenter = createTroubleshootNotificationsPresenter( + troubleshootTestSuite = troubleshootTestSuite, + ) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.hasFailedTests).isEqualTo(expectedIsCritical) + assertThat(initialState.testSuiteState.mainState).isInstanceOf(expectedMainState) + } + } + + @Test + fun `present - quick fix test`() = runTest { + val troubleshootTestSuite = createTroubleshootTestSuite( + tests = setOf( + FakeNotificationTroubleshootTest( + firstStatus = NotificationTroubleshootTestState.Status.Failure(hasQuickFix = false) + ) + ) + ) + val presenter = createTroubleshootNotificationsPresenter( + troubleshootTestSuite = troubleshootTestSuite, + ) + presenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.testSuiteState.mainState).isInstanceOf(AsyncAction.Failure::class.java) + initialState.eventSink(TroubleshootNotificationsEvents.QuickFix(0)) + val stateAfterStart = awaitItem() + assertThat(stateAfterStart.testSuiteState.mainState).isEqualTo(AsyncAction.Loading) + } + } +} + +private fun createTroubleshootTestSuite( + tests: Set = emptySet(), + currentPushProvider: String? = null, +): TroubleshootTestSuite { + return TroubleshootTestSuite( + sessionId = A_SESSION_ID, + notificationTroubleshootTests = tests, + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider), + analyticsService = FakeAnalyticsService(), + ) +} + +internal fun createTroubleshootNotificationsPresenter( + navigator: NotificationTroubleshootNavigator = object : NotificationTroubleshootNavigator { + override fun navigateToBlockedUsers() = lambdaError() + }, + troubleshootTestSuite: TroubleshootTestSuite = createTroubleshootTestSuite(), +): TroubleshootNotificationsPresenter { + return TroubleshootNotificationsPresenter( + navigator = navigator, + troubleshootTestSuite = troubleshootTestSuite, + ) +} diff --git a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsViewTest.kt b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsViewTest.kt new file mode 100644 index 0000000..0ba6c22 --- /dev/null +++ b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsViewTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class TroubleshootNotificationsViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `press menu back invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setTroubleshootNotificationsView( + state = aTroubleshootNotificationsState( + eventSink = eventsRecorder + ), + onBackClick = it, + ) + rule.pressBack() + } + } + + @Test + fun `clicking on run test emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setTroubleshootNotificationsView( + aTroubleshootNotificationsState( + eventSink = eventsRecorder + ), + ) + rule.onNodeWithText("Run tests").performClick() + eventsRecorder.assertSingle(TroubleshootNotificationsEvents.StartTests) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on run test again emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setTroubleshootNotificationsView( + aTroubleshootNotificationsState( + tests = listOf( + aTroubleshootTestStateFailure( + hasQuickFix = false + ) + ), + eventSink = eventsRecorder + ), + ) + rule.onNodeWithText("Run tests again").performClick() + eventsRecorder.assertList( + listOf( + TroubleshootNotificationsEvents.RetryFailedTests, + TroubleshootNotificationsEvents.StartTests, + ) + ) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on quick fix emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setTroubleshootNotificationsView( + aTroubleshootNotificationsState( + tests = listOf( + aTroubleshootTestStateFailure( + hasQuickFix = true + ) + ), + eventSink = eventsRecorder + ), + ) + rule.onNodeWithText("Attempt to fix").performClick() + eventsRecorder.assertList( + listOf( + TroubleshootNotificationsEvents.RetryFailedTests, + TroubleshootNotificationsEvents.QuickFix(0), + ) + ) + } +} + +private fun AndroidComposeTestRule.setTroubleshootNotificationsView( + state: TroubleshootNotificationsState, + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + TroubleshootNotificationsView( + state = state, + onBackClick = onBackClick, + ) + } +} diff --git a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/DefaultPushHistoryEntryPointTest.kt b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/DefaultPushHistoryEntryPointTest.kt new file mode 100644 index 0000000..1aa98fc --- /dev/null +++ b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/DefaultPushHistoryEntryPointTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl.history + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.push.test.FakePushService +import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint +import io.element.android.services.analytics.test.FakeScreenTracker +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import org.junit.Rule +import org.junit.Test + +class DefaultPushHistoryEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun `test node builder`() { + val entryPoint = DefaultPushHistoryEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + PushHistoryNode( + buildContext = buildContext, + plugins = plugins, + presenterFactory = { + PushHistoryPresenter( + pushHistoryNavigator = object : PushHistoryNavigator { + override fun navigateTo(roomId: RoomId, eventId: EventId) = lambdaError() + }, + pushService = FakePushService(), + matrixClient = FakeMatrixClient(), + ) + }, + screenTracker = FakeScreenTracker(), + ) + } + val callback = object : PushHistoryEntryPoint.Callback { + override fun onDone() = lambdaError() + override fun navigateToEvent(roomId: RoomId, eventId: EventId) = lambdaError() + } + val result = entryPoint.createNode( + parentNode = parentNode, + buildContext = BuildContext.root(null), + callback = callback, + ) + assertThat(result).isInstanceOf(PushHistoryNode::class.java) + assertThat(result.plugins).contains(callback) + } +} diff --git a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenterTest.kt b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenterTest.kt new file mode 100644 index 0000000..15fb6b5 --- /dev/null +++ b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenterTest.kt @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.troubleshoot.impl.history + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +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.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.FakeMatrixClient +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.test.FakePushService +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PushHistoryPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createPushHistoryPresenter() + presenter.test { + val initialState = awaitItem() + assertThat(initialState.pushCounter).isEqualTo(0) + assertThat(initialState.pushHistoryItems).isEmpty() + assertThat(initialState.showOnlyErrors).isFalse() + assertThat(initialState.resetAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(initialState.showNotSameAccountError).isFalse() + } + } + + @Test + fun `present - updating state`() = runTest { + val pushService = FakePushService() + val presenter = createPushHistoryPresenter( + pushService = pushService, + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.pushCounter).isEqualTo(0) + assertThat(initialState.pushHistoryItems).isEmpty() + pushService.emitPushCounter(1) + assertThat(awaitItem().pushCounter).isEqualTo(1) + val item = aPushHistoryItem() + pushService.emitPushHistoryItems(listOf(item)) + assertThat(awaitItem().pushHistoryItems).containsExactly(item) + } + } + + @Test + fun `present - reset and cancel`() = runTest { + val resetPushHistoryResult = lambdaRecorder { } + val pushService = FakePushService( + resetPushHistoryResult = resetPushHistoryResult, + ) + val presenter = createPushHistoryPresenter( + pushService = pushService, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(PushHistoryEvents.Reset(requiresConfirmation = true)) + assertThat(awaitItem().resetAction).isEqualTo(AsyncAction.ConfirmingNoParams) + initialState.eventSink(PushHistoryEvents.ClearDialog) + assertThat(awaitItem().resetAction).isEqualTo(AsyncAction.Uninitialized) + resetPushHistoryResult.assertions().isNeverCalled() + } + } + + @Test + fun `present - reset and confirm`() = runTest { + val resetPushHistoryResult = lambdaRecorder { } + val pushService = FakePushService( + resetPushHistoryResult = resetPushHistoryResult, + ) + val presenter = createPushHistoryPresenter( + pushService = pushService, + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(PushHistoryEvents.Reset(requiresConfirmation = true)) + assertThat(awaitItem().resetAction).isEqualTo(AsyncAction.ConfirmingNoParams) + initialState.eventSink(PushHistoryEvents.Reset(requiresConfirmation = false)) + assertThat(awaitItem().resetAction).isEqualTo(AsyncAction.Loading) + assertThat(awaitItem().resetAction).isEqualTo(AsyncAction.Uninitialized) + resetPushHistoryResult.assertions().isCalledOnce() + } + } + + @Test + fun `present - set show only errors`() = runTest { + val pushService = FakePushService() + val presenter = createPushHistoryPresenter( + pushService = pushService, + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.showOnlyErrors).isFalse() + val item = aPushHistoryItem(hasBeenResolved = true) + val itemError = aPushHistoryItem(hasBeenResolved = false) + pushService.emitPushHistoryItems(listOf(item, itemError)) + awaitItem().let { state -> + assertThat(state.pushHistoryItems).containsExactly(item, itemError) + state.eventSink(PushHistoryEvents.SetShowOnlyErrors(showOnlyErrors = true)) + } + skipItems(1) + awaitItem().let { state -> + assertThat(state.showOnlyErrors).isTrue() + assertThat(state.pushHistoryItems).containsExactly(itemError) + state.eventSink(PushHistoryEvents.SetShowOnlyErrors(showOnlyErrors = false)) + } + skipItems(1) + awaitItem().let { state -> + assertThat(state.showOnlyErrors).isFalse() + assertThat(state.pushHistoryItems).containsExactly(item, itemError) + } + } + } + + @Test + fun `present - item click current account`() = runTest { + val pushHistoryNavigatorResult = lambdaRecorder { _, _ -> } + val presenter = createPushHistoryPresenter( + pushHistoryNavigator = { roomId, eventId -> + pushHistoryNavigatorResult(roomId, eventId) + } + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink( + PushHistoryEvents.NavigateTo( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + ) + ) + pushHistoryNavigatorResult.assertions() + .isCalledOnce() + .with(value(A_ROOM_ID), value(AN_EVENT_ID)) + } + } + + @Test + fun `present - item click not current account`() = runTest { + val presenter = createPushHistoryPresenter() + presenter.test { + val initialState = awaitItem() + initialState.eventSink( + PushHistoryEvents.NavigateTo( + sessionId = A_SESSION_ID_2, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + ) + ) + assertThat(awaitItem().showNotSameAccountError).isTrue() + // Reset error + initialState.eventSink(PushHistoryEvents.ClearDialog) + assertThat(awaitItem().showNotSameAccountError).isFalse() + } + } + + private fun createPushHistoryPresenter( + pushHistoryNavigator: PushHistoryNavigator = PushHistoryNavigator { _, _ -> lambdaError() }, + pushService: PushService = FakePushService(), + matrixClient: MatrixClient = FakeMatrixClient(), + ): PushHistoryPresenter { + return PushHistoryPresenter( + pushHistoryNavigator = pushHistoryNavigator, + pushService = pushService, + matrixClient = matrixClient, + ) + } +} diff --git a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryViewTest.kt b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryViewTest.kt new file mode 100644 index 0000000..fa4e65a --- /dev/null +++ b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryViewTest.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.impl.history + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_FORMATTED_DATE +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.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PushHistoryViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on Reset sends a PushHistoryEvents`() { + val eventsRecorder = EventsRecorder() + rule.setPushHistoryView( + aPushHistoryState( + pushCounter = 123, + eventSink = eventsRecorder, + ), + ) + val menuContentDescription = rule.activity.getString(CommonStrings.a11y_user_menu) + rule.onNodeWithContentDescription(menuContentDescription).performClick() + rule.clickOn(CommonStrings.action_reset) + eventsRecorder.assertSingle(PushHistoryEvents.Reset(requiresConfirmation = true)) + // Also check that the push counter is rendered + rule.onNodeWithText("123").assertExists() + } + + @Test + fun `clicking on show only errors sends a PushHistoryEvents(true)`() { + val eventsRecorder = EventsRecorder() + rule.setPushHistoryView( + aPushHistoryState( + showOnlyErrors = false, + eventSink = eventsRecorder, + ), + ) + val menuContentDescription = rule.activity.getString(CommonStrings.a11y_user_menu) + rule.onNodeWithContentDescription(menuContentDescription).performClick() + rule.onNodeWithText("Show only errors").performClick() + eventsRecorder.assertSingle(PushHistoryEvents.SetShowOnlyErrors(showOnlyErrors = true)) + } + + @Test + fun `clicking on show only errors sends a PushHistoryEvents(false)`() { + val eventsRecorder = EventsRecorder() + rule.setPushHistoryView( + aPushHistoryState( + showOnlyErrors = true, + eventSink = eventsRecorder, + ), + ) + val menuContentDescription = rule.activity.getString(CommonStrings.a11y_user_menu) + rule.onNodeWithContentDescription(menuContentDescription).performClick() + rule.onNodeWithText("Show only errors").performClick() + eventsRecorder.assertSingle(PushHistoryEvents.SetShowOnlyErrors(showOnlyErrors = false)) + } + + @Test + fun `clicking on an invalid event has no effect`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setPushHistoryView( + aPushHistoryState( + pushHistoryItems = listOf( + aPushHistoryItem( + formattedDate = A_FORMATTED_DATE, + ) + ), + eventSink = eventsRecorder, + ), + ) + rule.onNodeWithText(A_FORMATTED_DATE).performClick() + // No callback invoked + } + + @Test + fun `clicking on a valid event emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setPushHistoryView( + aPushHistoryState( + pushHistoryItems = listOf( + aPushHistoryItem( + formattedDate = A_FORMATTED_DATE, + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + sessionId = A_SESSION_ID, + ) + ), + eventSink = eventsRecorder, + ), + ) + rule.onNodeWithText(A_FORMATTED_DATE).performClick() + eventsRecorder.assertSingle( + PushHistoryEvents.NavigateTo( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + ) + ) + } +} + +private fun AndroidComposeTestRule.setPushHistoryView( + state: PushHistoryState, + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + PushHistoryView( + state = state, + onBackClick = onBackClick, + ) + } +} diff --git a/libraries/troubleshoot/test/build.gradle.kts b/libraries/troubleshoot/test/build.gradle.kts new file mode 100644 index 0000000..ff0743c --- /dev/null +++ b/libraries/troubleshoot/test/build.gradle.kts @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.troubleshoot.test" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.troubleshoot.api) + implementation(projects.tests.testutils) + implementation(libs.coroutines.test) + implementation(libs.test.core) + implementation(libs.test.turbine) +} diff --git a/libraries/troubleshoot/test/src/main/kotlin/io/element/android/libraries/troubleshoot/test/FakeNotificationTroubleShootEntryPoint.kt b/libraries/troubleshoot/test/src/main/kotlin/io/element/android/libraries/troubleshoot/test/FakeNotificationTroubleShootEntryPoint.kt new file mode 100644 index 0000000..85a30e4 --- /dev/null +++ b/libraries/troubleshoot/test/src/main/kotlin/io/element/android/libraries/troubleshoot/test/FakeNotificationTroubleShootEntryPoint.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeNotificationTroubleShootEntryPoint : NotificationTroubleShootEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: NotificationTroubleShootEntryPoint.Callback, + ): Node = lambdaError() +} diff --git a/libraries/troubleshoot/test/src/main/kotlin/io/element/android/libraries/troubleshoot/test/FakeNotificationTroubleshootNavigator.kt b/libraries/troubleshoot/test/src/main/kotlin/io/element/android/libraries/troubleshoot/test/FakeNotificationTroubleshootNavigator.kt new file mode 100644 index 0000000..f983c8b --- /dev/null +++ b/libraries/troubleshoot/test/src/main/kotlin/io/element/android/libraries/troubleshoot/test/FakeNotificationTroubleshootNavigator.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.test + +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootNavigator +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeNotificationTroubleshootNavigator( + private val openIgnoredUsersResult: () -> Unit = { lambdaError() }, +) : NotificationTroubleshootNavigator { + override fun navigateToBlockedUsers() = openIgnoredUsersResult() +} diff --git a/libraries/troubleshoot/test/src/main/kotlin/io/element/android/libraries/troubleshoot/test/FakePushHistoryEntryPoint.kt b/libraries/troubleshoot/test/src/main/kotlin/io/element/android/libraries/troubleshoot/test/FakePushHistoryEntryPoint.kt new file mode 100644 index 0000000..28643f4 --- /dev/null +++ b/libraries/troubleshoot/test/src/main/kotlin/io/element/android/libraries/troubleshoot/test/FakePushHistoryEntryPoint.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.troubleshoot.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePushHistoryEntryPoint : PushHistoryEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: PushHistoryEntryPoint.Callback, + ): Node = lambdaError() +} diff --git a/libraries/troubleshoot/test/src/main/kotlin/io/element/android/libraries/troubleshoot/test/Utils.kt b/libraries/troubleshoot/test/src/main/kotlin/io/element/android/libraries/troubleshoot/test/Utils.kt new file mode 100644 index 0000000..5636195 --- /dev/null +++ b/libraries/troubleshoot/test/src/main/kotlin/io/element/android/libraries/troubleshoot/test/Utils.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:Suppress("UnusedImports") + +package io.element.android.libraries.troubleshoot.test + +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTest +import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope + +context(testScope: TestScope) +suspend fun NotificationTroubleshootTest.runAndTestState( + validate: suspend TurbineTestContext.() -> Unit, +) { + testScope.backgroundScope.launch { + run(this) + } + state.test(validate = validate) +} diff --git a/libraries/ui-common/build.gradle.kts b/libraries/ui-common/build.gradle.kts new file mode 100644 index 0000000..76c772f --- /dev/null +++ b/libraries/ui-common/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.ui.common" +} + +dependencies { + implementation(libs.appyx.core) + implementation(projects.libraries.designsystem) +} diff --git a/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt new file mode 100644 index 0000000..53cc8c8 --- /dev/null +++ b/libraries/ui-common/src/main/kotlin/io/element/android/libraries/ui/common/nodes/EmptyNode.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.ui.common.nodes + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +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.node.node +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +/** + * Ref: https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1518-85323 + */ +fun emptyNode( + buildContext: BuildContext, +): Node = node(buildContext) { modifier -> + EmptyView(modifier) +} + +@Composable +private fun EmptyView( + modifier: Modifier = Modifier, +) = Box( + modifier = modifier + .fillMaxSize() + .background(ElementTheme.colors.bgCanvasDefault), +) + +@PreviewsDayNight +@Composable +internal fun EmptyViewPreview() = ElementPreview { + EmptyView(Modifier) +} diff --git a/libraries/ui-strings/README.md b/libraries/ui-strings/README.md new file mode 100644 index 0000000..9cc2e85 --- /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 0000000..06c23bd --- /dev/null +++ b/libraries/ui-strings/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.ui.strings" + + lint { + disable += "Typos" + } +} 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 0000000..36fb0d2 --- /dev/null +++ b/libraries/ui-strings/src/main/kotlin/io/element/android/libraries/ui/strings/CommonPlurals.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..bf3c2c4 --- /dev/null +++ b/libraries/ui-strings/src/main/kotlin/io/element/android/libraries/ui/strings/CommonStrings.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.ui.strings + +typealias CommonStrings = R.string diff --git a/libraries/ui-strings/src/main/res/values-be/translations.xml b/libraries/ui-strings/src/main/res/values-be/translations.xml new file mode 100644 index 0000000..247dc9f --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-be/translations.xml @@ -0,0 +1,330 @@ + + + "Аватар" + "Выдаліць" + + "Уведзеная лічба %1$d" + "Уведзена %1$d лічбы" + "Уведзена %1$d лічб" + + "Схаваць пароль" + "Далучыцца да выкліку" + "Перайсці ўніз" + "Толькі згадкі" + "Гук адключаны" + "Старонка %1$d" + "Паўза" + "Поле PIN-кода" + "Прайграць" + "Апытанне" + "Апытанне скончана" + "Рэагаваць з %1$s" + "Рэагаваць з іншымі эмодзі" + "Прачытана %1$s і %2$s" + + "Прачытана %1$s і %2$d іншым" + "Прачытана %1$s і %2$d іншымі" + "Прачытана %1$s і %2$d іншымі" + + "Прачытана %1$s" + "Націсніце, каб паказаць усе" + "Выдаліць рэакцыю з %1$s" + "Адправіць файлы" + "Паказаць пароль" + "Пазваніць" + "Меню карыстальніка" + "Паглядзець падрабязнасці" + "Запісаць галасавое паведамленне." + "Спыніць запіс" + "Прыняць" + "Дадаць у хроніку" + "Назад" + "Званок" + "Скасаваць" + "Адмяніць пакуль" + "Выбраць фота" + "Ачысціць" + "Закрыць" + "Праверка завершана" + "Пацвердзіць" + "Пацвердзіць пароль" + "Працягнуць" + "Капіраваць" + "Скапіраваць спасылку" + "Скапіраваць спасылку на паведамленне" + "Стварыць" + "Стварыце пакой" + "Дэактываваць" + "Дэактываваць уліковы запіс" + "Адхіліць" + "Выдаліць апытанне" + "Адключыць" + "Адмяніць" + "Aдхіліць" + "Гатова" + "Рэдагаваць" + "Рэдагаваць апытанне" + "Уключыць" + "Скончыць апытанне" + "Увядзіце PIN-код" + "Забылі пароль?" + "Пераслаць" + "Вярнуцца" + "Запрасіць" + "Запрасіць карыстальнікаў" + "Запрасіць карыстальнікаў у %1$s" + "Запрасіць карыстальнікаў у %1$s" + "Запрашэнні" + "Далучыцца" + "Падрабязней" + "Пакінуць" + "Пакінуць размову" + "Пакінуць пакой" + "Загрузіць больш" + "Кіраванне ўліковым запісам" + "Кіраванне прыладамі" + "Паведамленне" + "Далей" + "Не" + "Не зараз" + "Добра" + "Налады" + "Адкрыць з дапамогай" + "Замацаваць" + "Хуткі адказ" + "Цытата" + "Рэакцыя" + "Адхіліць" + "Выдаліць" + "Адказаць" + "Адказаць у гутаркі" + "Паведаміць пра памылку" + "Паскардзіцца на змест" + "Скінуць" + "Скінуць ідэнтыфікацыйныя дадзеныя" + "Паўтарыць" + "Паўтарыць расшыфроўку" + "Захаваць" + "Пошук" + "Адправіць" + "Адправіць паведамленне" + "Падзяліцца" + "Абагуліць спасылку" + "Паказаць" + "Увайдзіце яшчэ раз" + "Выйсці" + "Усё роўна выйсці" + "Прапусціць" + "Пачаць" + "Пачаць чат" + "Пачаць праверку" + "Націсніце, каб загрузіць карту" + "Зрабіць фота" + "Дакраніцеся, каб убачыць параметры" + "Паўтарыць спробу" + "Адмацаваць" + "Прагляд у хроніцы" + "Прагляд зыходнага кода" + "Так" + "Ваш сервер зараз падтрымлівае новы, хутчэйшы пратакол. Выйдзіце з сістэмы і зноў увайдзіце, каб абнавіць яе. Гэта дапаможа вам пазбегнуць прымусовага выхаду з сістэмы, калі стары пратакол будзе пазней выдалены." + "Даступна абнаўленне" + "Аб праграме" + "Палітыка дапушчальнага выкарыстання" + "Пашыраныя налады" + "Аналітыка" + "Знешні выгляд" + "Аўдыя" + "Заблакіраваныя карыстальнікі" + "Бурбалкі" + "Званок пачаўся" + "Рэзервовае капіраванне чатаў" + "Скапіравана ў буфер абмену" + "Аўтарскае права" + "Стварэнне пакоя…" + "Выйшаў з пакоя" + "Цёмная" + "Памылка расшыфроўкі" + "Параметры распрацоўшчыка" + "Прамы чат" + "Не паказваць гэта зноў" + "(Адрэдагавана)" + "Рэдагаванне" + "* %1$s %2$s" + "Шыфраванне ўключана" + "Увядзіце свой PIN-код" + "Памылка" + "Адбылася памылка, вы можаце не атрымліваць апавяшчэнні аб новых паведамленнях. Ліквідуйце непаладкі з апавяшчэннямі ў наладах. + +Прычына: %1$s." + "Усе" + "Памылка" + "Абраць" + "Абранае" + "Файл" + "Файл захаваны ў папку Спампоўкі" + "Перасылка паведамлення" + "GIF" + "Відарыс" + "У адказ на %1$s" + "Усталяваць APK" + "Гэты Matrix ID не знойдзены, таму запрашэнне можа быць не атрымана." + "Пакінуць пакой" + "Светлая" + "Спасылка скапіравана ў буфер абмену" + "Загрузка…" + + "%d іншы" + "%d іншыя" + "%d іншых" + + + "%1$d удзельнік" + "%1$d удзельнікі" + "%1$d удзельнікаў" + + "Паведамленне" + "Дзеянні з паведамленням" + "Выгляд паведамлення" + "Паведамленне выдалена" + "Сучасны" + "Адкл. гук" + "Вынікаў няма" + "Няма назвы пакоя" + "Па-за сеткай" + "Ліцэнзіі з адкрытым зыходным кодам" + "або" + "Пароль" + "Людзі" + "Пастаянная спасылка" + "Дазвол" + "Замацаваны" + "Калі ласка, пачакайце…" + "Вы ўпэўнены, што хочаце скончыць гэтае апытанне?" + "Апытанне: %1$s" + "Усяго галасоў: %1$s" + "Вынікі будуць паказаны пасля завяршэння апытання" + + "%d голас" + "%d галасы" + "%d галасоў" + + "Палітыка прыватнасці" + "Прыватны пакой" + "Публічны пакой" + "Рэакцыя" + "Рэакцыі" + "Ключ аднаўлення" + "Абнаўленне…" + "Адказвае на %1$s" + "Паведаміць пра памылку" + "Паведаміць аб праблеме" + "Скарга прынята" + "Рэдактар фарматаванага тэксту" + "Пакой" + "Назва пакоя" + "напрыклад, назва вашага праекта" + "Захаваныя змены" + "Захаванне" + "Блакіроўка экрана" + "Шукаць кагосьці" + "Вынікі пошуку" + "Бяспека" + "Прагледжана" + "Адправіць" + "Адпраўка…" + "Памылка адпраўкі" + "Адпраўлена" + "Сервер не падтрымліваецца" + "URL-адрас сервера" + "Налады" + "Абагулена месцазнаходжанне" + "Выхад" + "Нешта пайшло не так" + "Пачатак чата…" + "Стыкер" + "Поспех" + "Прапановы" + "Сінхранізацыя" + "Сістэмная" + "Тэкст" + "Паведамленні трэціх асоб" + "Гутарка" + "Тэма" + "Пра што гэты пакой?" + "Немагчыма расшыфраваць" + "У вас няма доступу да гэтага паведамлення" + "Не ўдалося адправіць запрашэнні аднаму або некалькім карыстальнікам." + "Немагчыма адправіць запрашэнне(я)" + "Разблакіраваць" + "Укл. гук" + "Падзея не падтрымліваецца" + "Імя карыстальніка" + "Праверка адменена" + "Праверка завершана" + "Праверце прыладу" + "Відэа" + "Галасавое паведамленне" + "Чакаем…" + "Чакаю гэта паведамленне" + "Вы" + "(%1$s)" + "Пацвярджэнне" + "Памылка" + "Поспех" + "Папярэджанне" + "Вашы змены не былі захаваны. Вы ўпэўнены, што хочаце вярнуцца?" + "Захаваць змены?" + "Ваш хатні сервер неабходна абнавіць для падтрымкі Matrix Authentication Service і стварэння ўліковага запісу." + "Не атрымалася стварыць пастаянную спасылку" + "%1$s не атрымалася загрузіць карту. Калі ласка паспрабуйце зноў пазней." + "Не ўдалося загрузіць паведамленні" + "%1$s не магчыма атрымаць доступ да вашага месцазнаходжання. Калі ласка паспрабуйце зноў пазней." + "Не ўдалося загрузіць ваша галасавое паведамленне." + "Паведамленне не знойдзена" + "У %1$s няма дазволу на доступ да вашага месцазнаходжання. Вы можаце даць доступ у Наладах." + "У %1$s няма дазволу на доступ да вашага месцазнаходжання. Дазвольце доступ ніжэй." + "%1$s не мае дазволу на доступ да вашага мікрафона. Дазвольце доступ да запісу галасавога паведамлення." + "Некаторыя паведамленні не былі адпраўлены" + "Выбачце, адбылася памылка" + "Сапраўднасць гэтага зашыфраванага паведамлення не можа быць гарантаваная на гэтай прыладзе." + "Зашыфравана раней правераным карыстальнікам." + "Не зашыфраваны." + "Зашыфравана невядомай ці выдаленай прыладай." + "Зашыфравана прыладай, не пацверджанай яе ўладальнікам." + "Зашыфравана неправераным карыстальнікам." + "🔐️ Далучайцеся да мяне %1$s" + "Гэй, пагавары са мной у %1$s: %2$s" + "%1$s Android" + "Паведаміць аб памылцы з дапамогай Rageshake" + "Не ўдалося выбраць носьбіт, паўтарыце спробу." + "Націсніце на паведамленне і абярыце «%1$s », каб уключыць сюды." + "Замацуеце важныя паведамленні, каб іх можна было лёгка знайсці" + + "%1$d Замацаванае паведамленне" + "%1$d Замацаваныя паведамленні" + "%1$d Замацаваных паведамленняў" + + "Замацаваныя паведамленні" + "Адклікаць праверку і адправіць" + "Усё роўна адправіць паведамленне" + "%1$sвыкарыстоўвае адну або некалькі неправераных прылад. Вы можаце адправіць паведамленне ў любым выпадку, або вы можаце адмяніць зараз і паўтарыць спробу пазней, калі %2$s праверыць усе свае прылады." + "Ваша паведамленне не было адпраўлена, таму што%1$s не праверыў усе прылады" + "Не атрымалася апрацаваць медыяфайл для загрузкі, паспрабуйце яшчэ раз." + "Не ўдалося атрымаць інфармацыю пра карыстальніка" + "%1$s з %2$s" + "%1$s Замацаваныя паведамленні" + "Загрузка паведамлення…" + "Паглядзець усе" + "Чат" + "Падзяліцца месцазнаходжаннем" + "Падзяліцца маім месцазнаходжаннем" + "Адкрыць у Apple Maps" + "Адкрыць у Google Maps" + "Адкрыць у OpenStreetMap" + "Падзяліцеся гэтым месцазнаходжаннем" + "Паведамленне не адпраўлена таму што%1$s не праверыў усе прылады." + "Месцазнаходжанне" + "Версія: %1$s (%2$s)" + "be" + "У вас няма доступу да гэтага паведамлення" + diff --git a/libraries/ui-strings/src/main/res/values-bg/translations.xml b/libraries/ui-strings/src/main/res/values-bg/translations.xml new file mode 100644 index 0000000..5265110 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-bg/translations.xml @@ -0,0 +1,343 @@ + + + "Добавяне на реакция: %1$s" + "Изтриване" + + "%1$d въведена цифра" + "%1$d въведени цифри" + + "Пълният адрес ще бъде %1$s" + "Подробности за шифроването" + "Скриване на паролата" + "Присъединяване към обаждане" + "Скок към най-долу" + "Само споменавания" + "Заглушено" + "Нови споменавания" + "Нови съобщения" + "Текущо обаждане" + "Страница %1$d" + "Пауза" + "PIN поле" + "Пускане" + "Анкета" + "Приключила анкета" + "Реакция с %1$s" + "Реакция с други емоджита" + "Прочетено от %1$s и %2$s" + + "Прочетено от %1$s и %2$d друг" + "Прочетено от %1$s и %2$d други" + + "Прочетено от %1$s" + "Премахване на реакция с %1$s" + "Изпращане на файлове" + "Показване на паролата" + "Започнете обаждане" + "Потребителско меню" + "Вижте подробности" + "Приемане" + "Добавяне към хронологията" + "Назад" + "Обаждане" + "Отказ" + "Избор на снимка" + "Изчистване" + "Затваряне" + "Завършване на потвърждаването" + "Потвърждаване" + "Потвърдете паролата" + "Продължаване" + "Копиране" + "Копиране на връзката" + "Копиране на връзката към съобщението" + "Копиране на текста" + "Създаване" + "Създаване на стая" + "Деактивиране" + "Деактивиране на акаунта" + "Отхвърляне" + "Отхвърляне и блокиране" + "Изтриване на анкетата" + "Деактивиране" + "Готово" + "Редактиране" + "Редактиране на анкетата" + "Активиране" + "Приключване на анкетата" + "Въведете PIN" + "Забравена парола?" + "Препращане" + "Игнориране" + "Поканване" + "Поканване на хора" + "Поканване на хора в %1$s" + "Поканване на хора в %1$s" + "Покани" + "Присъединяване" + "Научете повече" + "Напускане" + "Напускане на разговора" + "Напускане на стаята" + "Напускане на пространството" + "Зареждане на още" + "Управление на профила" + "Управление на устройствата" + "Съобщение" + "Напред" + "Не" + "Не сега" + "Добре" + "Настройки" + "Отваряне с" + "Закачане" + "Бърз отговор" + "Цитат" + "Реакция" + "Премахване" + "Премахване на съобщението" + "Отговор" + "Отговор в нишка" + "Докладване" + "Докладване на грешка" + "Докладване на съдържанието" + "Докладване на стаята" + "Нулиране" + "Повторен опит" + "Повторен опит за разшифроване" + "Запазване" + "Търсене" + "Изпращане" + "Изпращане на съобщение" + "Споделяне" + "Споделяне на връзката" + "Показване" + "Влизане отново" + "Изход" + "Излизане въпреки това" + "Пропускане" + "Започване" + "Започване на чат" + "Започване на потвърждаването" + "Докоснете за зареждане на карта" + "Снимка" + "Докоснете за опции" + "Повторен опит" + "Откачване" + "Преглед на източника" + "Да" + "Да, опитай отново" + "Относно" + "Политика за приемлива употреба" + "Добавяне на акаунт" + "Добавяне на друг акаунт" + "Разширени настройки" + "изображение" + "Статистика" + "Напуснахте стаята" + "Облик" + "Аудио" + "Блокирани потребители" + "Мехурчета" + "Започнато обаждане" + "Резервно копие на чатовете" + "Авторски права" + "Създаване на стая…" + "Стаята е напусната" + "Пространството е напуснато" + "Тъмен" + "Грешка при разшифроване" + "Описание" + "Опции за разработчици" + "Директен чат" + "Не показвай това отново" + "Неуспешно изтегляне" + "Изтегля се" + "(редактирано)" + "Редактиране" + "* %1$s %2$s" + "Празен файл" + "Шифроване" + "Шифроването е включено" + "Въведете своя PIN" + "Грешка" + "Всеки" + "Фаворизиране" + "Фаворизирано" + "Файл" + "Файлът е изтрит" + "Файлът е запазен" + "Файлът е запазен в Изтеглени" + "Препращане на съобщението" + "GIF" + "Изображение" + "В отговор на %1$s" + "Инсталиране на APK" + "Този Matrix ID не може да бъде намерен, така че поканата може да не бъде получена." + "Стаята се напуска" + "Пространството се напуска" + "Светъл" + "Връзката е копирана в клипборда" + "Зарежда се…" + "Зарежда се още…" + + "%d друг" + "%d други" + + + "%1$d член" + "%1$d членове" + + "Съобщение" + "Оформление на съобщенията" + "Съобщението е премахнато" + "Модерно" + "Заглушаване" + "Няма резултати" + "Няма име на стая" + "Няма име на пространство" + "Без шифроване" + "Офлайн" + "Лицензи за отворен код" + "или" + "Парола" + "Хора" + "Постоянна връзка" + "Разрешение" + "Закачено" + "Моля, проверете вашата интернет връзка" + "Моля, изчакайте…" + "Сигурни ли сте, че искате да приключите тази анкета?" + "Анкета: %1$s" + "Общо гласове: %1$s" + "Резултатите ще се покажат след приключване на анкетата" + + "%d глас" + "%d гласа" + + "Подготвя се…" + "Политика за поверителност" + "Частна стая" + "Частно пространство" + "Общодостъпна стая" + "Общодостъпно пространство" + "Реакция" + "Реакции" + "Причина" + "Ключ за възстановяване" + "Опреснява се…" + + "%1$d отговор" + "%1$d отговора" + + "В отговор на %1$s" + "Съобщаване за грешка" + "Съобщаване за проблем" + "Докладът е изпратен" + "Редактор на богат текст" + "Стая" + "Име на стаята" + "напр. името на вашия проект" + + "%1$d стая" + "%1$d стаи" + + "Запазва се" + "Заключване на екрана" + "Търсене на някого" + "Резултати от търсенето" + "Защита" + "Видяно от" + "Избиране на акаунт" + "Изпраща се…" + "Изпращането е неуспешно" + "Изпратено" + "Сървърът не се поддържа" + "URL адрес на сървъра" + "Настройки" + "Споделяне на пространството" + "Споделено местоположение" + "Излизате" + "Нещо се обърка" + "Възникна проблем. Моля, опитайте отново." + "Пространство" + + "%1$d пространство" + "%1$d пространства" + + "Започване на чат…" + "Стикер" + "Успешно" + "Предложения" + "Синхронизиране" + "Система" + "Текст" + "Уведомления от трети страни" + "Нишка" + "Тема" + "За какво се отнася тази стая?" + "Не може да се разшифрова" + "Нямате достъп до това съобщение" + "Поканите не можаха да бъдат изпратени до един или повече потребители." + "Не може да се изпрати покана(и)" + "Отключване" + "Раззаглушаване" + "Неподдържано събитие" + "Потребителско име" + "Потвърждаването е отменено" + "Потвърждаването е завършено" + "Неуспешно потвърждаване" + "Потвърден" + "Потвърждаване на устройството" + "Потвърждаване на самоличността" + "Потвърждаване на потребителя" + "Видео" + "Високо качество" + "Ниско качество" + "Стандартно качество" + "Гласово съобщение" + "Изчаква се…" + "В очакване на това съобщение" + "Вие" + "Потвърждение" + "Грешка" + "Успешно" + "Внимание" + "Неуспешно създаване на постоянна връзка" + "%1$s не успя да зареди картата. Моля, опитайте отново по-късно." + "Неуспешно зареждане на съобщения" + "%1$s няма достъп до вашето местоположение. Моля, опитайте отново по-късно." + "%1$s няма разрешение за достъп до вашето местоположение. Можете да активирате достъпа в Настройки." + "%1$s няма разрешение за достъп до вашето местоположение. Активирайте достъпа по-долу." + "Някои съобщения не са изпратени" + "Съжаляваме, възникна грешка" + "Без шифроване" + "🔐️ Присъединете се към мен в %1$s" + "Хей, говорете с мен в %1$s: %2$s" + "%1$s Android" + "Неуспешен избор на мултимедия, моля, опитайте отново." + "Натиснете върху съобщение и изберете „%1$s“, за да го включите тук." + "Закачете важни съобщения, за да могат лесно да бъдат намерени" + + "%1$d закачено съобщение" + "%1$d закачени съобщения" + + "Закачени съобщения" + "Неуспешна обработка на мултимедия за качване, моля, опитайте отново." + "Не могат да бъдат извлечени потребителските данни" + "Споделяне на местоположение" + "Споделяне на моето местоположение" + "Отваряне в Apple Maps" + "Отваряне в Google Maps" + "Отваряне в OpenStreetMap" + "Споделяне на това местоположение" + "%1$s пространство" + "Пространства" + "Преглед на членовете" + "Местоположение" + "Версия: %1$s (%2$s)" + "bg" + "Трябва да потвърдите това устройство за да достъпите исторически съобщения" + "Нямате достъп до това съобщение" + "Съобщението не може да се разшифрова" + 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 0000000..d4f8550 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml @@ -0,0 +1,493 @@ + + + "Přidat reakci: %1$s" + "Profilový obrázek" + "Minimalizovat textové pole zprávy" + "Smazat" + + "zadána %1$d číslice" + "zadány %1$d číslice" + "zadáno %1$d číslic" + + "Upravit avatar" + "Úplná adresa bude %1$s" + "Podrobnosti o šifrování" + "Rozbalit textové pole zprávy" + "Skrýt heslo" + "Připojit se k hovoru" + "Přejít dolů" + "Přesunout mapu na mou polohu" + "Pouze zmínky" + "Ztišeno" + "Nové zmínky" + "Nové zprávy" + "Probíhající hovor" + "Avatar jiného uživatele" + "Strana %1$d" + "Pozastavit" + "Hlasová zpráva, délka: %1$s, aktuální pozice: %2$s" + "Pole pro PIN" + "Přehrát" + "Hlasování" + "Hlasování ukončeno" + "Reagovat s %1$s" + "Reagovat s dalšími emoji" + "%1$s a %2$s přečetli" + + "Přečetl(a) %1$s a %2$d další" + "Přečetl(a) %1$s a %2$d další" + "Přečetl(a) %1$s a %2$d dalších" + + "%1$s přečetl(a)" + "Klepnutím zobrazíte vše" + "Odstraňit reakci s %1$s" + "Odstranit reakci pomocí %1$s" + "Avatar místnosti" + "Odeslat soubory" + "Vyžaduje se časově omezená akce, na ověření máte jednu minutu" + "Zobrazit heslo" + "Zahájit hovor" + "Místnost s náhrobkem" + "Avatar uživatele" + "Uživatelské menu" + "Zobrazit avatar" + "Zobrazit podrobnosti" + "Hlasová zpráva, délka: %1$s" + "Nahrajte hlasovou zprávu." + "Zastavit nahrávání" + "Váš avatar" + "Přijmout" + "Přidat titulek" + "Přidat na časovou osu" + "Zpět" + "Hovor" + "Zrušit" + "Prozatím zrušit" + "Vybrat fotku" + "Vymazat" + "Zavřít" + "Dokončit ověření" + "Potvrdit" + "Potvrdit heslo" + "Pokračovat" + "Kopírovat" + "Kopírovat titulek" + "Kopírovat odkaz" + "Kopírovat odkaz na zprávu" + "Kopírovat text" + "Vytvořit" + "Vytvořit místnost" + "Deaktivovat" + "Deaktivovat účet" + "Odmítnout" + "Odmítnout a zablokovat" + "Odstranit hlasování" + "Odznačit vše" + "Zakázat" + "Vyřadit" + "Zavřít" + "Hotovo" + "Upravit" + "Upravit titulek" + "Upravit hlasování" + "Povolit" + "Ukončit hlasování" + "Zadejte PIN" + "Dokončit" + "Zapomněli jste heslo?" + "Přeposlat" + "Přejít zpět" + "Přejít na role a oprávnění" + "Přejít do nastavení" + "Ignorovat" + "Pozvat" + "Pozvat přátele" + "Pozvat přátele do %1$s" + "Pozvat lidi na %1$s" + "Pozvánky" + "Vstoupit" + "Zjistit více" + "Odejít" + "Opustit konverzaci" + "Opustit místnost" + "Opustit prostor" + "Načíst více" + "Spravovat účet" + "Spravovat zařízení" + "Zpráva" + "Minimalizovat" + "Další" + "Ne" + "Teď ne" + "OK" + "Otevřít kontextovou nabídku" + "Otevřít nastavení" + "Otevřít v aplikaci" + "Pin" + "Rychlá odpověď" + "Citovat" + "Reagovat" + "Odmítnout" + "Odstranit" + "Odstranit titulek" + "Odstranit zprávu" + "Odpovědět" + "Odpovědět ve vlákně" + "Nahlásit" + "Nahlásit chybu" + "Nahlásit obsah" + "Nahlásit konverzaci" + "Nahlásit místnost" + "Obnovit" + "Obnovit identitu" + "Zkusit znovu" + "Opakovat dešifrování" + "Uložit" + "Hledat" + "Vybrat vše" + "Odeslat" + "Odeslat upravenou zprávu" + "Odeslat zprávu" + "Odeslat hlasovou zprávu" + "Sdílet" + "Sdílet odkaz" + "Zobrazit" + "Přihlásit se znovu" + "Odhlásit se" + "Přesto se odhlásit" + "Přeskočit" + "Začít" + "Zahájit chat" + "Zahájit ověření" + "Klepnutím načtete mapu" + "Vyfotit" + "Klepnutím zobrazíte možnosti" + "Zkusit znovu" + "Odepnout" + "Zobrazit" + "Zobrazit na časové ose" + "Zobrazit zdroj" + "Ano" + "Ano, zkusit znovu" + "Váš server nyní podporuje nový, rychlejší protokol. Chcete-li upgradovat, odhlaste se a znovu se přihlaste. Pokud to uděláte nyní, pomůže vám vyhnout se nucenému odhlášení, když bude starý protokol později odstraněn." + "Upgrade k dispozici" + "O aplikaci" + "Zásady používání" + "Přidat účet" + "Přidat další účet" + "Přidání titulku" + "Pokročilá nastavení" + "obrázek" + "Analytika" + "Opustili jste místnost" + "Byli jste odhlášeni z relace" + "Vzhled" + "Zvuk" + "Beta" + "Blokovaní uživatelé" + "Bubliny" + "Hovor zahájen" + "Záloha chatu" + "Zkopírováno do schránky" + "Autorská práva" + "Vytváření místnosti…" + "Žádost zrušena" + "Místnost opuštěna" + "Opustit prostor" + "Pozvánka odmítnuta" + "Tmavé" + "Chyba dešifrování" + "Popis" + "Možnosti pro vývojáře" + "ID zařízení" + "Přímý chat" + "Znovu nezobrazovat" + "Stahování se nezdařilo" + "Stahování" + "(upraveno)" + "Úpravy" + "Úprava titulku" + "* %1$s %2$s" + "Prázdný soubor" + "Šifrování" + "Šifrování povoleno" + "Zadejte svůj PIN" + "Chyba" + "Došlo k chybě, nemusíte dostávat oznámení o nových zprávách. Vyřešte prosím problémy s oznámeními z nastavení. + +Důvod: %1$s." + "Všichni" + "Selhalo" + "Oblíbené" + "Oblíbené" + "Soubor" + "Soubor smazán" + "Soubor uložen" + "Soubor byl uložen do složky Stažené soubory" + "Přeposlat zprávu" + "Často používané" + "GIF" + "Obrázek" + "V odpovědi na %1$s" + "Nainstalovat APK" + "Tento Matrix identifikátor nelze najít, takže pozvánka nemusí být přijata." + "Opuštění místnosti" + "Opuštění prostoru" + "Světlý" + "Řádek zkopírován do schránky" + "Odkaz zkopírován do schránky" + "Načítání…" + "Načítání dalších…" + + "%d další" + "%d další" + "%d dalších" + + + "%1$d člen" + "%1$d členové" + "%1$d členů" + + "Zpráva" + "Akce zprávy" + "Zobrazení zpráv" + "Zpráva byla odstraněna" + "Moderní" + "Ztlumit" + "%1$s (%2$s)" + "Žádné výsledky" + "Žádný název místnosti" + "Žádný název prostoru" + "Nešifrováno" + "Offline" + "Licence s otevřeným zdrojovým kódem" + "nebo" + "Heslo" + "Lidé" + "Trvalý odkaz" + "Oprávnění" + "Připnuto" + "Zkontrolujte prosím své připojení k internetu" + "Počkejte prosím…" + "Opravdu chcete ukončit toto hlasování?" + "Hlasování: %1$s" + "Celkový počet hlasů: %1$s" + "Výsledky se zobrazí po skončení hlasování" + + "%d hlas" + "%d hlasy" + "%d hlasů" + + "Příprava…" + "Zásady ochrany osobních údajů" + "Soukromá místnost" + "Soukromý prostor" + "Veřejná místnost" + "Veřejný prostor" + "Reakce" + "Reakce" + "Důvod" + "Klíč pro obnovení" + "Obnovování…" + + "%1$d odpovědí" + + "Odpověď na %1$s" + "Nahlásit chybu" + "Nahlásit problém" + "Zpráva odeslána" + "Editor formátovaného textu" + "Místnost" + "Název místnosti" + "např. název vašeho projektu" + + "%1$d místnost" + "%1$d místnosti" + "%1$d místností" + + "Uložené změny" + "Ukládání" + "Zámek obrazovky" + "Hledat někoho" + "Výsledky hledání" + "Zabezpečení" + "Viděno" + "Vybrat účet" + "Odeslat do" + "Odesílání…" + "Odeslání se nezdařilo" + "Odesláno" + ". " + "Server není podporován" + "Server je nedostupný" + "URL serveru" + "Nastavení" + "Sdílet prostor" + "Sdílená poloha" + "Sdílený prostor" + "Odhlašování" + "Něco se nepovedlo" + "Narazili jsme na problém. Zkuste to prosím znovu." + "Prostor" + + "%1$d prostor" + "%1$d prostory" + "%1$d prostorů" + + "Zahajování chatu…" + "Nálepka" + "Úspěch" + "Návrhy" + "Synchronizace" + "Systém" + "Text" + "Oznámení třetích stran" + "Vlákno" + "Téma" + "O čem je tato místnost?" + "Nelze dešifrovat" + "Šifrováno nezabezpečeným zařízením" + "Nemáte přístup k této zprávě" + "Ověřená identita odesílatele se změnila" + "Pozvánky nebylo možné odeslat jednomu nebo více uživatelům." + "Nelze odeslat pozvánky" + "Odemknout" + "Zrušit ztlumení" + "Nepodporované volání" + "Nepodporovaná událost" + "Uživatelské jméno" + "Ověření zrušeno" + "Ověření dokončeno" + "Ověření se nezdařilo" + "Ověřeno" + "Ověřit zařízení" + "Ověření identity" + "Ověřit uživatele" + "Video" + "Vysoká kvalita" + "Nejlepší kvalita, ale větší velikost souboru" + "Nízká kvalita" + "Nejrychlejší rychlost nahrávání a nejmenší velikost souboru" + "Standardní kvalita" + "Rovnováha mezi kvalitou a rychlostí nahrávání" + "Hlasová zpráva" + "Čekání…" + "Čekání na dešifrovací klíč" + "Vy" + "Identita uživatele %1$s se změnila. %2$s" + "Identita uživatele %1$s %2$s se změnila. %3$s" + "(%1$s)" + "Identita uživatele %1$s se změnila." + "Identita uživatele %1$s %2$s se změnila. %3$s" + "Zrušit ověření" + "Odkaz %1$s vás přesměruje na jinou stránku %2$s + +Opravdu chcete pokračovat?" + "Zkontrolujte tento odkaz" + "Vyberte výchozí kvalitu nahrávaných videí." + "Kvalita nahrávání videa" + "Maximální povolená velikost souboru je: %1$s" + "Soubor je pro nahrání příliš velký." + "Místnost nahlášena" + "Nahlášen a opustil místnost" + "Potvrzení" + "Chyba" + "Úspěch" + "Upozornění" + "Vaše změny nebyly uloženy. Opravdu se chcete vrátit?" + "Uložit změny?" + "Maximální povolená velikost souboru je: %1$s" + "Vyberte kvalitu videa, které chcete nahrát." + "Vyberte kvalitu nahrávání videa" + "Hledat emotikony" + "Na tomto zařízení jste již přihlášeni jako %1$s." + "Váš domovský server je třeba upgradovat, aby podporoval službu Matrix Authentication Service a vytváření účtu." + "Vytvoření trvalého odkazu se nezdařilo" + "%1$s nemohl načíst mapu. Zkuste to prosím později." + "Načítání zpráv se nezdařilo" + "%1$s nemá přístup k vaší poloze. Zkuste to prosím později." + "Nepodařilo se nahrát hlasovou zprávu." + "Místnost již neexistuje nebo pozvánka již není platná." + "Zpráva nebyla nalezena" + "%1$s nemá oprávnění k přístupu k vaší poloze. Přístup můžete povolit v Nastavení." + "%1$s nemá oprávnění k přístupu k vaší poloze. Povolit přístup níže." + "%1$s nemá oprávnění k přístupu k mikrofonu. Povolte přístup k nahrávání hlasové zprávy." + "Může to být způsobeno problémy se sítí nebo serverem." + "Tato adresa místnosti již existuje, zkuste prosím upravit pole adresy místnosti nebo změnit název místnosti" + "Některé znaky nejsou povoleny. Podporovány jsou pouze písmena, číslice a následující symboly ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" + "Některé zprávy nebyly odeslány" + "Omlouváme se, došlo k chybě" + "Odesílatel události se neshoduje s vlastníkem zařízení, které ji odeslalo." + "Autenticitu této zašifrované zprávy nelze na tomto zařízení zaručit." + "Zašifrováno dříve ověřeným uživatelem." + "Není zašifrováno." + "Šifrováno neznámým nebo smazaným zařízením." + "Šifrováno zařízením, které nebylo ověřeno jeho vlastníkem." + "Šifrováno neověřeným uživatelem." + "🔐️ Připojte se ke mně na %1$s" + "Ahoj, ozvi se mi na %1$s: %2$s" + "%1$s Android" + "Zatřeste zařízením pro nahlášení chyby" + "Snímek obrazovky" + "%1$s: %2$s" + "Možnosti" + "Odstranit %1$s" + "Nastavení" + "Výběr média se nezdařil, zkuste to prosím znovu." + "Přidržte zprávu a vyberte „%1$s“, kterou chcete zahrnout sem." + "Připněte důležité zprávy, aby je bylo možné snadno najít" + + "%1$d Připnutá zpráva" + "%1$d Připnuté zprávy" + "%1$d Připnutých zpráv" + + "Připnuté zprávy" + "Chystáte se přejít na svůj %1$s účet a obnovit svou identitu. Poté budete přesměrováni zpět do aplikace." + "Nemůžete to potvrdit? Přejděte na svůj účet a resetujte svou identitu." + "Zrušit ověření a odeslat" + "Ověření můžete zrušit a přesto odeslat tuto zprávu, nebo můžete prozatím zrušit a zkusit to znovu později po opětovném ověření %1$s." + "Vaše zpráva nebyla odeslána, protože ověřená identita uživatele %1$s se změnila" + "Přesto odeslat zprávu" + "%1$s používá jedno nebo více neověřených zařízení. Zprávu můžete přesto odeslat, nebo můžete prozatím zrušit a zkusit to znovu později poté, co %2$s ověří všechna svá zařízení." + "Vaše zpráva nebyla odeslána, protože%1$s neověřil(a) všechna zařízení" + "Jedno nebo více vašich zařízení není ověřeno. Zprávu můžete přesto odeslat, nebo ji můžete prozatím zrušit a zkusit to znovu později, až ověříte všechna svá zařízení." + "Vaše zpráva nebyla odeslána, protože jste neověřili jedno nebo více zařízení" + "Změnit nastavení" + "Správa prostoru" + "Spravovat místnosti" + "Oprávnění" + "Upravit správce nebo vlastníky" + "Nahrání média se nezdařilo, zkuste to prosím znovu." + "Nepodařilo se načíst údaje o uživateli" + "Zpráva v %1$s" + "Rozbalit" + "Zmenšit" + "Již si prohlížíte tuto místnost!" + "%1$s z %2$s" + "%1$s Připnuté zprávy" + "Načítání zprávy…" + "Zobrazit vše" + "Chat" + "Sdílet polohu" + "Sdílet moji polohu" + "Otevřít v Mapách Apple" + "Otevřít v Mapách Google" + "Otevřít v OpenStreetMap" + "Sdílet tuto polohu" + "Prostory, které jste vytvořili nebo se k nim připojili." + "%1$s • %2$s" + "%1$s prostor" + "Prostory" + "Zobrazit členy" + "Zpráva nebyla odeslána, protože ověřená identita uživatele %1$s se změnila." + "Zpráva nebyla odeslána, protože%1$s neověřil(a) všechna zařízení." + "Zpráva nebyla odeslána, protože jste neověřili jedno nebo více zařízení." + "Poloha" + "Verze: %1$s (%2$s)" + "en" + "Historické zprávy nejsou na tomto zařízení k dispozici" + "Pro přístup k historickým zprávám musíte toto zařízení ověřit" + "Nemáte přístup k této zprávě" + "Nelze dešifrovat zprávu" + "Tato zpráva byla zablokována buď proto, že jste neověřili své zařízení, nebo proto, že odesílatel potřebuje ověřit vaši identitu." + diff --git a/libraries/ui-strings/src/main/res/values-cy/translations.xml b/libraries/ui-strings/src/main/res/values-cy/translations.xml new file mode 100644 index 0000000..11b46cb --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-cy/translations.xml @@ -0,0 +1,512 @@ + + + "Ychwanegu adwaith: %1$s" + "Afatar" + "Lleihau maes testun neges" + "Dileu" + + "%1$d nodau wedi eu cynnig" + "%1$d nod wedi ei gynnig" + "%1$d nod wedi eu cynnig" + "%1$d nod wedi eu cynnig" + "%1$d nod wedi eu cynnig" + "%1$d nod wedi eu cynnig" + + "Golygu afatar" + "Bydd y cyfeiriad llawn yn%1$s" + "Manylion amgryptio" + "Ehangu maes testun neges" + "Cuddio cyfrinair" + "Ymuno â galwad" + "Symud i\'r gwaelod" + "Symud y map i\'m lleoliad" + "Crybwylliadau\'n unig" + "Wedi\'i Dewi" + "Crybwylliadau newydd" + "Negeseuon newydd" + "Galwad cyfredol" + "Afatar defnyddiwr arall" + "Tudalen %1$d" + "Oedi" + "Neges llais, hyd: %1$s, safle presennol: %2$s" + "Maes PIN" + "Chwarae" + "Pôl" + "Mae\'r bleidlais wedi gorffen" + "Ymateb gyda %1$s" + "Ymateb gydag emojis eraill" + "Wedi\'i ddarllen gan %1$s a %2$s" + + "Wedi\'i ddarllen gan %1$s a %2$d arall" + "Wedi\'i ddarllen gan %1$s a %2$d arall" + "Wedi\'i ddarllen gan %1$s a %2$d arall" + "Wedi\'i ddarllen gan %1$s a %2$d arall" + "Wedi\'i ddarllen gan %1$s a %2$d arall" + "Wedi\'i ddarllen gan %1$s a %2$d arall" + + "Wedi\'i ddarllen gan %1$s" + "Tapio i ddangos y cyfan" + "Wedi dileu adwaith gyda %1$s" + "Wedi dileu adwaith gyda %1$s" + "Afatar ystafell" + "Anfon ffeiliau" + "Mae angen gweithredu o fewn amser cyfyngedig, mae gennych un funud i wirio" + "Dangos y cyfrinair" + "Cychwyn galwad" + "Ystafell Tombstoned" + "Afatar defnyddiwr" + "Dewislen defnyddiwr" + "Gweld afatar" + "Manylion" + "Neges llais, hyd: %1$s" + "Recordio neges llais." + "Stopio recordio" + "Eich afatar" + "Derbyn" + "Ychwanegu capsiwn" + "Ychwanegu at y llinell amser" + "Nôl" + "Galw" + "Nôl" + "Diddymu am y tro" + "Dewis llun" + "Clirio" + "Cau" + "Cwblhau\'r gwirio" + "Cadarnhau" + "Cadarnhau cyfrinair" + "Parhau" + "Copïo" + "Copïo capsiwn" + "Copïo dolen" + "Copïo dolen i\'r neges" + "Copïo testun" + "Creu" + "Creu ystafell" + "Dadweithredu" + "Cau cyfrif" + "Gwrthod" + "Gwrthod a rhwystro" + "Dileu Pleidlais" + "Dad ddewis y cyfan" + "Analluogi" + "Dileu" + "Cau" + "Gorffen" + "Golygu" + "Golygu capsiwn" + "Golygu pleidlais" + "Galluogi" + "Gorffen pleidlais" + "Rhoi\'r PIN" + "Gorffen" + "Wedi anghofio\'ch cyfrinair?" + "Ymlaen" + "Mynd nôl" + "Anwybyddu" + "Gwahodd" + "Gwahodd pobl" + "Gwahodd pobl i %1$s" + "Gwahodd pobl i %1$s" + "Gwahoddiadau" + "Ymuno" + "Dysgu rhagor" + "Gadael" + "Gadael y sgwrs" + "Gadael yr ystafell" + "Gadael y gofod" + "Llwytho rhagor" + "Rheoli cyfrif" + "Rheoli dyfeisiau" + "Neges" + "Nesaf" + "Na" + "Nid nawr" + "Iawn" + "Agor y ddewislen gyd-destun" + "Gosodiadau" + "Agor gyda" + "Pinio" + "Ymateb cyflym" + "Dyfyniad" + "Ymateb" + "Gwrthod" + "Tynnu" + "Tynnu capsiwn" + "Tynnu neges" + "Ateb" + "Ateb mewn edefyn" + "Adroddiadau" + "Adrodd ar wall" + "Adrodd ar gynnwys" + "Adrodd ar sgwrs" + "Adrodd ar ystafell" + "Ailosod" + "Ailosod hunaniaeth" + "Ceisio eto" + "Cynnig arall ar ddadgryptio" + "Cadw" + "Chwilio" + "Dewis y cyfan" + "Anfon" + "Anfon neges wedi\'i golygu" + "Anfonwch neges" + "Anfon neges llais" + "Rhannu" + "Rhannwch ddolen" + "Dangos" + "Mewngofnodi eto" + "Allgofnodi" + "Allgofnodi beth bynnag" + "Hepgor" + "Cychwyn" + "Dechrau sgwrs" + "Dechrau dilysu" + "Tapio i lwytho map" + "Cymryd llun" + "Tapio am ddewisiadau" + "Ceisiwch eto" + "Dad-binio" + "Golwg" + "Gweld yn yr amserlen" + "Gweld ffynhonnell" + "Iawn" + "Iawn, ceisiwch eto" + "Mae eich gweinydd nawr yn cefnogi protocol newydd, cynt. Allgofnodwch a mewngofnodi\'n ôl i uwchraddio nawr. Bydd gwneud hyn nawr yn eich helpu i osgoi allgofnodi gorfodol pan fydd yr hen brotocol yn cael ei ddileu cyn hir." + "Uwchraddiad ar gael" + "Ynghylch" + "Polisi defnydd derbyniol" + "Ychwanegu cyfrif" + "Ychwanegu cyfrif arall" + "Ychwanegu capsiwn" + "Gosodiadau uwch" + "delwedd" + "Dadansoddeg" + "Rydych wedi gadael yr ystafell" + "Rydych wedi\'ch allgofnodi o\'r sesiwn" + "Gwedd" + "Sain" + "Defnyddwyr wedi\'u rhwystro" + "Swigod" + "Galwad wedi dechrau" + "Sgwrs wrth gefn" + "Wedi\'i gopïo i\'r clipfwrdd" + "Hawlfraint" + "Wrthi\'n creu ystafell…" + "Cais wedi\'i ddiddymu" + "Wedi gadael yr ystafell" + "Gofod chwith" + "Wedi gwrthod y gwahoddiad" + "Tywyll" + "Gwall dadgryptio" + "Disgrifiad" + "Dewisiadau datblygwr" + "ID dyfais" + "Sgwrs uniongyrchol" + "Peidio â dangos hyn eto" + "Methodd y llwytho i lawr" + "Yn llwytho i lawr" + "(golygwyd)" + "Golygu" + "Wrthi\'n golygu capsiwn" + "* %1$s %2$s" + "Ffeil wag" + "Amgryptiad" + "Amgryptio wedi\'i alluogi" + "Rhowch eich PIN" + "Gwall" + "Digwyddodd gwall, efallai fyddwch chi ddim yn derbyn hysbysiadau ar gyfer negeseuon newydd. Ceisiwch ddatrys y problemau hysbysiadau o\'r gosodiadau. + +Rheswm: %1$s." + "Pawb" + "Wedi methu" + "Ffefryn" + "Ffafriwyd" + "Ffeil" + "Ffeil wedi\'i dileu" + "Ffeil wedi\'i chadw" + "Ffeil wedi\'i chadw i\'r Llwythi" + "Anfonwyd neges ymlaen" + "Defnydd cyffredin" + "GIF" + "Delwedd" + "Mewn ymateb i %1$s" + "Gosod APK" + "Nid oes modd dod o hyd i\'r ID Matrics hwn, felly mae\'n bosibl na fydd y gwahoddiad yn cael ei dderbyn." + "Gadael ystafell" + "Golau" + "Llinell wedi\'i chopïo i\'r clipfwrdd" + "Dolen wedi\'i chopïo i\'r clipfwrdd" + "Yn Llwytho…" + "Wrthi\'n llwytho mwy…" + + "%d arall" + "%d arall" + "%d arall" + "%d arall" + "%d arall" + "%d arall" + + + "%1$d Aelodau" + "%1$d Aelod" + "%1$d Aelod" + "%1$d Aelod" + "%1$d Aelod" + "%1$d Aelod" + + "Neges" + "Gweithredoedd neges" + "Cynllun y neges" + "Neges wedi\'i thynnu" + "Cyfoes" + "Tewi" + "%1$s (%2$s)" + "Dim canlyniadau" + "Dim enw i\'r ystafell" + "Dim enw gofod" + "Heb ei amgryptio" + "All-lein" + "Trwyddedau cod agored" + "neu" + "Cyfrinair" + "Pobl" + "Dolen Barhaol" + "Caniatâd" + "Piniwyd" + "Gwiriwch eich cysylltiad rhyngrwyd" + "Arhoswch…" + "Ydych chi\'n siŵr eich bod am ddod â\'r pôl hwn i ben?" + "Pleidlais: %1$s" + "Cyfanswm y pleidleisiau: %1$s" + "Bydd y canlyniadau\'n dangos ar ôl i\'r bleidlais ddod i ben" + + "%d pleidleisiau" + "%d pleidlais" + "%d pleidlais" + "%d pleidlais" + "%d pleidlais" + "%d pleidlais" + + "Yn paratoi…" + "Polisi preifatrwydd" + "Ystafell breifat" + "Gofod preifat" + "Ystafell gyhoeddus" + "Gofod cyhoeddus" + "Adwaith" + "Adweithiau" + "Rheswm" + "Allwedd adfer" + "Wrthi\'n adnewyddu…" + + "%1$d atebion" + "%1$d ateb" + "%1$d ateb" + "%1$d ateb" + "%1$d ateb" + "%1$d ateb" + + "Yn ymateb i %1$s" + "Adrodd ar wall" + "Adrodd am broblem" + "Adroddiad wedi ei gyflwyno" + "Golygydd testun cyfoethog" + "Ystafell" + "Enw\'r ystafell" + "e.e. enw eich project" + + "%1$d Ystafelloedd" + "%1$d Ystafell" + "%1$d Ystafell" + "%1$d Ystafell" + "%1$d Ystafell" + "%1$d Ystafell" + + "Newidiadau wedi\'u cadw" + "Cadw" + "Clo sgrin" + "Chwilio am rywun" + "Canlyniadau chwilio" + "Diogelwch" + "Wedi\'i weld gan" + "Dewis cyfrif" + "Anfon at" + "Yn anfon…" + "Methodd anfon" + "Anfonwyd" + ". " + "Nid yw\'r gweinydd yn cael ei gynnal" + "Gweinydd yn anghyraeddadwy" + "URL gweinydd" + "Gosodiadau" + "Rhannu gofod" + "Lleoliad yn cael ei rannu" + "Allgofnodi" + "Aeth rhywbeth o\'i le" + "Wedi canfod mater. Ceisiwch eto." + "Gofod" + + "%1$d Gofodau" + "%1$d Gofod" + "%1$d Ofod" + "%1$d Gofod" + "%1$d Gofod" + "%1$d Gofod" + + "Dechrau sgwrs…" + "Sticer" + "Llwyddiant" + "Awgrymiadau" + "Cydweddu" + "System" + "Testun" + "Hysbysiadau trydydd parti" + "Edefyn" + "Pwnc" + "Am beth mae\'r ystafell hon?" + "Methu dadgryptio" + "Wedi\'i anfon o ddyfais anniogel" + "Does gennych chi ddim mynediad i\'r neges hon" + "Cafodd hunaniaeth yr anfonwr ei hailosod" + "Doedd dim modd anfon gwahoddiadau at un neu fwy o ddefnyddwyr." + "Methu anfon gwahoddiad(au)" + "Datgloi" + "Dad-dewi" + "Galwad heb ei chefnogi" + "Digwyddiad heb ei gefnogi" + "Enw defnyddiwr" + "Dilysiad wedi\'i ddiddymu" + "Gwiriad wedi\'i gwblhau" + "Methodd y gwirio" + "Wedi ei wirio" + "Gwirio dyfais" + "Gwirio hunaniaeth" + "Gwirio defnyddiwr" + "Fideo" + "Ansawdd uchel" + "Yr ansawdd gorau ond maint ffeil mwy" + "Ansawdd isel" + "Y cyflymder llwytho cyflymaf a\'r maint ffeil lleiaf" + "Ansawdd safonol" + "Cydbwysedd ansawdd a chyflymder llwytho" + "Neges llais" + "Yn aros…" + "Yn aros am y neges hon" + "Chi" + "Cafodd hunaniaeth %1$s ei ailosod. %2$s" + "Cafodd hunaniaeth %2$s %1$s ei ailosod. %3$s" + "(%1$s)" + "Cafodd hunaniaeth %1$s ei ailosod." + "Cafodd hunaniaeth %2$s %1$s ei ailosod. %3$s" + "Tynnu\'r gwiriad yn ôl" + "Mae\'r ddolen %1$s yn mynd â chi i wefan arall %2$s + +Ydych chi\'n siŵr eich bod am barhau?" + "Gwnewch yn siŵr fod y ddolen hon yn iawn" + "Dewiswch ansawdd rhagosodedig y fideos rydych chi\'n eu llwytho." + "Ansawdd lwytho fideo" + "Y maint ffeil mwyaf sy\'n cael ei ganiatáu yw: %1$s" + "Mae maint y ffeil yn rhy fawr i\'w llwytho" + "Adroddwyd am yr ystafell" + "Adroddwyd a gadael yr ystafell" + "Cadarnhad" + "Gwall" + "Llwyddiant" + "Rhybudd" + "Dyw eich newidiadau heb gael eu cadw. Ydych chi\'n siŵr eich bod am fynd nôl?" + "Cadw\'r newidiadau?" + "Y maint ffeil mwyaf sy\'n cael ei ganiatáu yw: %1$s" + "Dewiswch ansawdd y fideo rydych chi am ei llwytho." + "Dewiswch ansawdd llwytho fideo" + "Chwilio emojis" + "Rydych chi eisoes wedi mewngofnodi ar y ddyfais hon fel %1$s ." + "Mae angen uwchraddio eich gweinydd cartref i gefnogi Gwasanaeth Dilysu Matrix a chreu cyfrif." + "Wedi methu creu\'r ddolen barhaol" + "Methodd %1$s â llwytho\'r map. Ceisiwch eto yn nes ymlaen." + "Wedi methu llwytho negeseuon" + "Doedd dim modd i %1$s gael mynediad i\'ch lleoliad. Ceisiwch eto yn nes ymlaen." + "Wedi methu llwytho eich neges llais." + "Nid yw\'r ystafell yn bodoli mwyach neu nid yw\'r gwahoddiad yn ddilys mwyach." + "Neges heb ei chanfod" + "Does gan %1$s ddim caniatâd i gael mynediad i\'ch lleoliad. Gallwch alluogi mynediad yn y Gosodiadau." + "Does gan %1$s ddim caniatâd i gael mynediad i\'ch lleoliad. Galluogwch fynediad isod." + "Does gan %1$s ddim caniatâd i gael mynediad i\'ch meicroffon. Galluogwch fynediad i recordio neges llais." + "Gall hyn fod oherwydd problemau rhwydwaith neu weinydd." + "Mae\'r cyfeiriad ystafell hwn yn bodoli eisoes. Ceisiwch olygu\'r maes cyfeiriad ystafell neu newid enw\'r ystafell" + "Dyw rhai nodau ddim yn cael eu caniatáu. Dim ond llythrennau, digidau a\'r symbolau canlynol sy\'n cael eu cefnogi ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" + "Nid yw rhai negeseuon wedi\'u hanfon" + "Ymddiheuriadau, mae gwall wedi digwydd" + "Nid yw anfonwr y digwyddiad yn cyfateb i berchennog y ddyfais a\'i hanfonodd." + "Nid oes modd gwarantu dilysrwydd y neges hon sydd wedi\'i hamgryptio ar y ddyfais hon." + "Wedi\'i amgryptio gan ddefnyddiwr a wiriwyd gynt." + "Heb ei amgryptio." + "Wedi\'i amgryptio gan ddyfais anhysbys neu wedi\'i dileu." + "Wedi\'i amgryptio gan ddyfais nad yw wedi\'i wirio gan ei pherchennog." + "Wedi\'i amgryptio gan ddefnyddiwr heb ei wirio." + "🔐️ Ymunwch â mi ar %1$s" + "Hei, siaradwch â mi ar %1$s: %2$s" + "Android %1$s" + "Rageshake i adrodd gwall" + "Llun sgrin" + "%1$s: %2$s" + "Dewisiadau" + "Tynnu %1$s" + "Gosodiadau" + "Wedi methu dewis cyfrwng, ceisiwch eto." + "Pwyswch ar neges a dewis “%1$s” i\'w cynnwys yma." + "Pinio negeseuon pwysig fel y mae modd eu darganfod yn hawdd" + + "%1$d Negeseuon wedi\'u pinio" + "%1$d Neges wedi\'i phinio" + "%1$d Neges wedi\'i phinio" + "%1$d Neges wedi\'i phinio" + "%1$d Neges wedi\'i phinio" + "%1$d Neges wedi\'i phinio" + + "Negeseuon wedi\'u pinio" + "Rydych chi ar fin mynd i\'ch cyfrif %1$s i ailosod eich hunaniaeth. Wedi hynny byddwch yn cael eich tywys yn ôl i\'r ap." + "Methu cadarnhau? Ewch i\'ch cyfrif i ailosod eich hunaniaeth." + "Tynnu\'r dilysiad yn ôl a\'i anfon" + "Gallwch dynnu\'ch dilysiad yn ôl ac anfon y neges hon beth bynnag, neu gallwch ei diddymu am y tro a rhoi cynnig arall arni yn nes ymlaen ar ôl dilysu %1$s." + "Nid yw eich neges wedi\'i hanfon oherwydd ailosodwyd hunaniaeth ddilys %1$s" + "Anfonwch neges beth bynnag" + "Mae %1$s yn defnyddio un neu fwy o ddyfeisiau heb eu gwirio. Gallwch anfon y neges beth bynnag, neu gallwch ei diddymu am y tro a cheisio eto yn nes ymlaen ar ôl i %2$s ddilysu ei holl ddyfeisiau." + "Dyw eich neges heb ei hanfon oherwydd nid yw %1$s wedi gwirio pob dyfais" + "Mae un neu fwy o\'ch dyfeisiau heb eu gwirio. Gallwch anfon y neges beth bynnag, neu gallwch ei diddymu am y tro a cheisio eto yn nes ymlaen ar ôl i chi ddilysu eich holl ddyfeisiau." + "Nid yw eich neges wedi\'i hanfon oherwydd nad ydych wedi gwirio un neu fwy o\'ch dyfeisiau" + "Golygu Gweinyddwyr neu Berchnogion" + "Wedi methu â phrosesu cyfryngau i\'w llwytho, ceisiwch eto." + "Methu â nôl manylion defnyddiwr" + "Neges yn %1$s" + "Dangos" + "Lleihau" + "Eisoes yn gweld yr ystafell hon!" + "%1$s o %2$s" + "%1$s Neges wedi\'u pinio" + "Wrthi\'n llwytho neges…" + "Dangos y Cyfan" + "Sgwrs" + "Rhannu lleoliad" + "Rhannu fy lleoliad" + "Agor yn Apple Maps" + "Agor yn Google Maps" + "Agor yn OpenStreetMap" + "Rhannu\'r lleoliad hwn" + "Gofodau rydych wedi\'u creu neu wedi ymuno â nhw." + "%1$s • %2$s" + "Gofod %1$s" + "Gofodau" + "Heb anfon y neges oherwydd bod hunaniaeth wedi \'i ddilysu %1$s wedi\'i ailosod." + "Heb anfon y neges oherwydd nid yw %1$s wedi gwirio pob dyfais." + "Heb anfon y neges oherwydd nad ydych wedi gwirio un neu fwy o\'ch dyfeisiau." + "Lleoliad" + "Fersiwn: %1$s (%2$s)" + "cy" + "cy" + "Nid yw negeseuon hanesyddol ar gael ar y ddyfais hon" + "Mae angen i chi wirio\'r ddyfais hon i gael mynediad at negeseuon hanesyddol" + "Does gennych chi ddim mynediad i\'r neges hon" + "Methu dadgryptio\'r neges" + "Cafodd y neges hon ei rhwystro naill ai oherwydd nad ydych wedi ddilysu\'ch dyfais neu oherwydd bod angen i\'r anfonwr ddilysu\'ch hunaniaeth chi." + diff --git a/libraries/ui-strings/src/main/res/values-da/translations.xml b/libraries/ui-strings/src/main/res/values-da/translations.xml new file mode 100644 index 0000000..f8491d6 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-da/translations.xml @@ -0,0 +1,481 @@ + + + "Tilføj reaktion: %1$s" + "Avatar" + "Minimér tekstfeltet for beskeder" + "Slet" + + "%1$d ciffer indtastet" + "%1$d cifre indtastet" + + "Redigér avatar" + "Den fulde adresse vil være %1$s" + "Krypteringsoplysninger" + "Udvid tekstfeltet for beskeder" + "Skjul adgangskode" + "Deltag i opkald" + "Hop til bunden" + "Flyt kortet til min lokation" + "Kun omtaler" + "Lyd slået fra" + "Nye omtaler" + "Nye beskeder" + "Igangværende opkald" + "Anden brugers avatar" + "Side %1$d" + "Pausér" + "Talebesked, varighed: %1$s, aktuel position: %2$s" + "PIN-felt" + "Afspil" + "Afstemning" + "Afsluttet afstemning" + "Reager med%1$s" + "Reager med andre emojis" + "Læs af %1$s og %2$s" + + "Læst af %1$s og %2$d andre" + "Læst af %1$s og %2$d andre" + + "Læst af%1$s" + "Tryk for at vise alle" + "Fjern reaktion med%1$s" + "Fjern reaktion med %1$s" + "Avatar for rummet" + "Send filer" + "Tidsbegrænset handling påkrævet, du har et minut til at bekræfte" + "Vis adgangskode" + "Start et opkald" + "Deaktiveret rum" + "Avatar for bruger" + "Brugermenu" + "Se avatar" + "Se detaljer" + "Talebesked, varighed: %1$s" + "Optag talebesked." + "Stop optagelsen" + "Din avatar" + "Accepter" + "Tilføj billedtekst" + "Føj til tidslinje" + "Tilbage" + "Opkald" + "Anullér" + "Spring over ind til videre" + "Vælg billede" + "Ryd" + "Luk" + "Fuldfør verifikation" + "Bekræft" + "Bekræft adgangskode" + "Fortsæt" + "Kopiér" + "Kopiér billedtekst" + "Kopiér link" + "Kopiér link til besked" + "Kopiér tekst" + "Opret" + "Opret et rum" + "Deaktiver" + "Deaktiver konto" + "Afvis" + "Afvis og blokér" + "Slet afstemning" + "Fravælg alle" + "Deaktiver" + "Kassér" + "Afvis" + "Færdig" + "Redigér" + "Rediger billedtekst" + "Redigér afstemning" + "Slå til" + "Afslut afstemning" + "Indtast PIN-kode" + "Afslut" + "Har du glemt din adgangskode?" + "Videresend" + "Gå tilbage" + "Gå til roller og rettigheder" + "Gå til indstillinger" + "Ignorér" + "Invitér" + "Invitér andre" + "Invitér andre til %1$s" + "Invitér andre til %1$s" + "Invitationer" + "Deltag" + "Få mere at vide" + "Forlad" + "Forlad samtalen" + "Forlad rum" + "Forlad gruppe" + "Indlæs mere" + "Administrer konto" + "Administrer enheder" + "Besked" + "Minimér" + "Næste" + "Nej" + "Ikke nu" + "OK" + "Åbn kontekstmenu" + "Indstillinger" + "Åbn med" + "Fastgør" + "Hurtigt svar" + "Citér" + "Reagér" + "Afvis" + "Fjern" + "Fjern billedtekst" + "Fjern besked" + "Svar" + "Svar i tråd" + "Anmeld" + "Anmeld fejl" + "Anmeld indhold" + "Anmeld samtale" + "Anmeld rummet" + "Nulstil" + "Nulstil identitet" + "Prøv igen" + "Prøv at dekryptere igen" + "Gem" + "Søg" + "Vælg alle" + "Send" + "Send redigeret besked" + "Send besked" + "Send talebesked" + "Del" + "Del link" + "Vis" + "Log ind igen" + "Log ud" + "Log ud alligevel" + "Spring over" + "Start" + "Start samtale" + "Begynd verifikation" + "Tryk for at indlæse kort" + "Tag billede" + "Tryk for indstillinger" + "Prøv igen" + "Frigør" + "Vis" + "Se i tidslinjen" + "Se kilde" + "Ja" + "Ja, prøv igen" + "Din server understøtter nu en ny, hurtigere protokol. Log ud og log ind igen for at opgradere nu. Hvis du gør dette nu, vil du undgå en tvungen logout, når den gamle protokol bliver fjerne senere." + "Opgradering tilgængelig" + "Om" + "Politik for acceptabel brug" + "Tilføj en konto" + "Tilføj en anden konto" + "Tilføjelse af billedtekst" + "Avancerede indstillinger" + "et billede" + "Analyse-værktøj" + "Du forlod rummet" + "Du blev logget ud af sessionen" + "Udseende" + "Lyd" + "Beta" + "Blokerede brugere" + "Bobler" + "Opkald startet" + "Backup af samtale" + "Kopieret til udklipsholder" + "Ophavsret" + "Opretter rum…" + "Anmodning annulleret" + "Forlod rummet" + "Forlod gruppe" + "Invitationen blev afvist" + "Mørkt tema" + "Fejl under dekryptering" + "Beskrivelse" + "Indstillinger for udviklere" + "Enheds-ID" + "Direkte samtale" + "Vis ikke dette igen" + "Download mislykkedes" + "Downloader" + "(redigeret)" + "Redigering" + "Redigering af billedtekst" + "* %1$s %2$s" + "Tom fil" + "Kryptering" + "Kryptering aktiveret" + "Indtast din PIN-kode" + "Fejl" + "Der opstod en fejl, du modtager muligvis ikke meddelelser om nye meddelelser. Fejlfinding af meddelelser fra indstillingerne. + +Årsag: %1$s." + "Alle" + "Mislykkedes" + "Favorit" + "Favoritmarkeret" + "Fil" + "Fil slettet" + "Fil gemt" + "Fil gemt i Downloads" + "Videresend besked" + "Ofte brugt" + "GIF" + "Billede" + "Som svar på %1$s" + "Installer APK" + "Dette Matrix-ID kan ikke findes, så invitationen modtages muligvis ikke." + "Forlader rummet" + "Forlader gruppe" + "Lyst tema" + "Linje kopieret til udklipsholder" + "Linket er kopieret til udklipsholderen" + "Indlæser…" + "Indlæser flere…" + + "%d anden" + "%d andre" + + + "%1$d medlem" + "%1$d medlemmer" + + "Besked" + "Beskedhandlinger" + "Layout på beskeder" + "Beskeden er fjernet" + "Moderne" + "Lydløs" + "%1$s (%2$s)" + "Ingen resultater" + "Intet rumnavn" + "Intet gruppenavn" + "Ikke krypteret" + "Offline" + "Open Source-licenser" + "eller" + "Adgangskode" + "Brugere" + "Permalink" + "Tilladelse" + "Fastgjort" + "Tjek venligst din internetforbindelse" + "Vent venligst…" + "Er du sikker på, at du vil afslutte denne afstemning?" + "Afstemning: %1$s" + "Stemmer i alt: %1$s" + "Resultaterne vil blive vist, når afstemningen er afsluttet" + + "%d stemme" + "%d stemmer" + + "Forbereder…" + "Privatlivspolitik" + "Privat rum" + "Privat gruppe" + "Offentligt rum" + "Offentlig gruppe" + "Reaktion" + "Reaktioner" + "Årsag" + "Gendannelsesnøgle" + "Opdaterer…" + + "%1$d svar" + + "Svarer til %1$s" + "Rapportér en fejl" + "Anmeld et problem" + "Anmeldelsen er indsendt" + "Rich text editor" + "Rum" + "Navn på rum" + "f.eks. navnet på dit projekt" + + "%1$d Rum" + "%1$d Rum" + + "Gemte ændringer" + "Gemmer" + "Skærmlås" + "Søg efter nogen" + "Søgeresultater" + "Sikkerhed" + "Set af" + "Vælg en konto" + "Send til" + "Sender…" + "Afsendelse mislykkedes" + "Sendt" + ". " + "Serveren er ikke understøttet" + "Serveren er ikke tilgængelig" + "Server URL" + "Indstillinger" + "Del gruppe" + "Delt placering" + "Delt gruppe" + "Logger ud" + "Noget gik galt" + "Vi stødte på et problem. Prøv venligst igen." + "Gruppe" + + "%1$d Gruppe" + "%1$d Grupper" + + "Starter samtale…" + "Klistermærke" + "Succes" + "Forslag" + "Synkroniserer" + "System" + "Tekst" + "Tredjepartsmeddelelser" + "Tråd" + "Emne" + "Hvad handler det her rum om?" + "Ude af stand til at dekryptere" + "Sendt fra en usikker enhed" + "Du har ikke adgang til denne meddelelse" + "Afsenderens verificerede identitet blev nulstillet" + "Invitationer kunne ikke sendes til en eller flere brugere." + "Kan ikke sende invitation(er)" + "Lås op" + "Slå lyden til" + "Ikke-understøttet opkald" + "Ikke-understøttet begivenhed" + "Brugernavn" + "Bekræftelse annulleret" + "Bekræftelse fuldført" + "Verifikation mislykkedes" + "Verificeret" + "Verificér enhed" + "Verificér identitet" + "Verificér bruger" + "Video" + "Høj kvalitet" + "Bedste kvalitet, men større filstørrelse" + "Lav kvalitet" + "Hurtigste uploadhastighed og mindste filstørrelse" + "Standardkvalitet" + "Balance mellem kvalitet og uploadhastighed" + "Talebesked" + "Venter…" + "Venter på denne besked" + "Dig" + "%1$ss identitet blev nulstillet. %2$s" + "%1$ss %2$s identitet blev nulstillet. %3$s" + "(%1$s)" + "%1$ss identitet blev nulstillet." + "%1$ss %2$s identitet blev nulstillet. %3$s" + "Tilbagetræk verifikation" + "Linket %1$s fører dig til et andet websted %2$s + +Er du sikker på, at du vil fortsætte?" + "Dobbelttjek dette link" + "Vælg standardkvaliteten for de videoer, du uploader." + "Kvalitet på overførte videoer" + "Den maksimalt tilladte filstørrelse er: %1$s" + "Filen er for stor til at kunne uploades." + "Rummet er anmeldt" + "Anmeldte og forlod rummet" + "Bekræftelse" + "Fejl" + "Succes" + "Advarsel" + "Dine ændringer er ikke blevet gemt. Er du sikker på, at du vil gå tilbage?" + "Gem ændringer?" + "Den maksimalt tilladte filstørrelse er: %1$s" + "Vælg den kvalitet, du ønsker at uploade videoen i." + "Vælg kvalitet for video-overførsel" + "Søg emojis" + "Du er allerede logget ind på denne enhed som %1$s." + "Din hjemmeserver skal opgraderes for at understøtte Matrix Authentication Service og kontooprettelse." + "Oprettelse af permalink mislykkedes" + "%1$s kunne ikke indlæse kortet. Prøv igen senere." + "Fejl under indlæsning af beskeder" + "%1$s kunne ikke få adgang til din placering. Prøv igen senere." + "Kunne ikke uploade din talebesked." + "Rummet findes ikke længere, eller invitationen er ikke længere gyldig." + "Meddelelsen blev ikke fundet" + "%1$s har ikke tilladelse til at få adgang til din placering. Du kan aktivere adgang i Indstillinger." + "%1$s har ikke tilladelse til at se din placering. Aktivér adgang nedenfor." + "%1$s har ikke tilladelse til at få adgang til din mikrofon. Aktivér adgang for at optage en stemmemeddelelse." + "Dette kan skyldes netværks- eller serverproblemer." + "Denne rumadresse er allerede taget. Prøv at redigere rummets adressefelt eller at skifte rummets navn" + "Nogle tegn er ikke tilladt. Kun bogstaver, cifre og følgende symboler understøttes! $ & \'() * +/; =? @ [] - . _" + "Nogle beskeder er ikke blevet sendt" + "Beklager, der opstod en fejl" + "Afsenderen af begivenheden matcher ikke ejeren af den enhed, der sendte den." + "Ægtheden af denne krypterede besked kan ikke garanteres på denne enhed." + "Krypteret af en tidligere verificeret bruger." + "Ikke krypteret." + "Krypteret af en ukendt eller slettet enhed." + "Krypteret af en enhed, der ikke er verificeret af sin ejer." + "Krypteret af en ikke-verificeret bruger." + "🔐️ Kom med mig til %1$s" + "Hej, lad os snakkes på %1$s: %2$s" + "%1$s Android" + "Ryst enheden i frustration for at anmelde en fejl" + "Skærmbillede" + "%1$s: %2$s" + "Valgmuligheder" + "Fjern %1$s" + "Indstillinger" + "Det lykkedes ikke at vælge medie. Prøv igen." + "Tryk på en besked og vælg \"%1$s\" for at inkludere den her." + "Fastgør vigtige beskeder, så de let kan opdages" + + "%1$d Fastgjort besked" + "%1$d Fastgjorte beskeder" + + "Fastgjorte beskeder" + "Du er ved at gå til din %1$s konto for at nulstille din identitet. Derefter vil du blive ført tilbage til appen." + "Kan du ikke bekræfte? Gå til din konto for at nulstille din identitet." + "Træk verifikationen tilbage og send" + "Du kan trække din verifikation tilbage og sende denne meddelelse alligevel, eller du kan annullere for nu og prøve igen senere efter at have gen-verificeret. %1$s" + "Din besked blev ikke sendt, fordi %1$s\'s verificerede identitet er blevet nulstillet" + "Send besked alligevel" + "%1$s bruger en eller flere uverificerede enheder. Du kan sende beskeden alligevel, eller du kan annullere for nu og prøve igen senere, når %2$s har bekræftet alle deres enheder." + "Din besked blev ikke sendt, fordi %1$s ikke har bekræftet alle enheder" + "En eller flere af dine enheder er ikke verificeret. Du kan sende beskeden alligevel, eller du kan annullere for nu og prøve igen senere, når du har verificeret alle dine enheder." + "Din besked blev ikke sendt, fordi du ikke har verificeret en eller flere af dine enheder" + "Rediger administratorer eller ejere" + "Det lykkedes ikke at behandle medier til upload. Prøv venligst igen." + "Kunne ikke hente brugeroplysninger" + "Besked i %1$s" + "Udvid" + "Reducér" + "Du ser allerede dette rum!" + "%1$s af %2$s" + "%1$s Fastgjorte beskeder" + "Indlæser besked…" + "Se alle" + "Samtale" + "Del lokation" + "Del min lokation" + "Åbn i Apple Maps" + "Åbn i Google Maps" + "Åbn i OpenStreetMap" + "Del denne lokation" + "Grupper, du har oprettet eller deltager i" + "%1$s•%2$s" + "%1$s gruppe" + "Grupper" + "Vis medlemmer" + "Beskeden blev ikke sendt fordi %1$s s bekræftede identitet blev nulstillet." + "Meddelelsen er ikke sendt, fordi %1$s ikke har bekræftet alle enheder." + "Beskeden er ikke sendt, fordi du ikke har verificeret en eller flere af dine enheder." + "Lokation" + "Version: %1$s (%2$s)" + "da" + "Historiske beskeder er ikke tilgængelige på denne enhed" + "Du skal verificere denne enhed for at få adgang til historiske beskeder" + "Du har ikke adgang til denne meddelelse" + "Kan ikke dekryptere beskeden" + "Denne besked blev blokeret, enten fordi du ikke verificerede din enhed, eller fordi afsenderen skal have verificeret din identitet." + 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 0000000..f912fc4 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -0,0 +1,481 @@ + + + "Reaktion hinzufügen: %1$s" + "Avatar" + "Nachrichtentextfeld minimieren" + "Löschen" + + "%1$d eingegebene Ziffer" + "%1$d eingegebene Ziffern" + + "Avatar bearbeiten" + "Die vollständige Adresse lautet %1$s" + "Details zur Verschlüsselung" + "Nachrichtentextfeld vergrößern" + "Passwort ausblenden" + "Anruf beitreten" + "Nach unten springen" + "Verschiebe die Karte zu meinem Standort." + "Nur Erwähnungen" + "Stummgeschaltet" + "Neue Erwähnungen" + "Neue Nachrichten" + "Laufender Anruf" + "Avatar des anderen Nutzers" + "Seite %1$d" + "Pausieren" + "Sprachnachricht, Dauer:%1$s, aktuelle Position: %2$s" + "PIN-Feld" + "Abspielen" + "Umfrage" + "Umfrage beendet" + "Reagiere mit %1$s" + "Mit anderen Emojis reagieren" + "Gelesen von %1$s und %2$s" + + "Gelesen von %1$s und %2$d weiteren" + "Gelesen von %1$s und %2$d weiteren" + + "Gelesen von %1$s" + "Tippe, um alle anzuzeigen" + "Reaktion mit %1$s entfernen" + "Entferne Reaktionen mit %1$s" + "Avatar" + "Dateien senden" + "Zeitlich begrenzte Handlung erforderlich, du hast eine Minute Zeit zur Verifizierung" + "Passwort anzeigen" + "Anruf starten" + "Stillgelegter Chat" + "Nutzer-Avatar" + "Nutzer-Menü" + "Avatar ansehen" + "Details anzeigen" + "Sprachnachricht, Dauer: %1$s" + "Sprachnachricht aufnehmen." + "Aufnahme beenden" + "Dein Avatar" + "Akzeptieren" + "Bildunterschrift hinzufügen" + "Zum Nachrichtenverlauf hinzufügen" + "Zurück" + "Anruf" + "Abbrechen" + "Vorerst abbrechen" + "Foto auswählen" + "Löschen" + "Schließen" + "Verifizierung abschließen" + "Bestätigen" + "Passwort bestätigen" + "Weiter" + "Kopieren" + "Bildunterschrift kopieren" + "Link kopieren" + "Link zur Nachricht kopieren" + "Text kopieren" + "Erstellen" + "Chat erstellen" + "Deaktivieren" + "Nutzerkonto deaktivieren" + "Ablehnen" + "Ablehnen und blockieren" + "Umfrage löschen" + "Auswahl aufheben" + "Deaktivieren" + "Verwerfen" + "Schließen" + "Erledigt" + "Bearbeiten" + "Bildunterschrift bearbeiten" + "Umfrage bearbeiten" + "Aktivieren" + "Umfrage beenden" + "PIN eingeben" + "Fertigstellen" + "Passwort vergessen?" + "Weiterleiten" + "Zurück" + "Zu den Einstellungen" + "Ignorieren" + "Einladen" + "Nutzer einladen" + "Lade Personen in %1$s ein" + "Lade Personen in %1$s ein" + "Einladungen" + "Beitreten" + "Mehr erfahren" + "Verlassen" + "Unterhaltung verlassen" + "Verlassen" + "Space verlassen" + "Mehr laden…" + "Konto verwalten" + "Geräte verwalten" + "Nachricht" + "Minimieren" + "Weiter" + "Nein" + "Später" + "Ok" + "Kontextmenü öffnen" + "Einstellungen öffnen" + "Öffnen mit" + "Fixieren" + "Schnelle Antwort" + "Zitat" + "Reagieren" + "Ablehnen" + "Entfernen" + "Bildunterschrift entfernen" + "Nachricht entfernen" + "Antworten" + "Im Thread antworten" + "Bericht" + "Fehler melden" + "Inhalt melden" + "Konversation melden" + "Chat melden" + "Zurücksetzen" + "Identität zurücksetzen" + "Erneut versuchen" + "Entschlüsselung wiederholen" + "Speichern" + "Suchen" + "Alles auswählen" + "Senden" + "Bearbeitete Nachricht senden" + "Nachricht senden" + "Sprachnachricht senden" + "Teilen" + "Link teilen" + "Zeige" + "Erneut anmelden" + "Abmelden" + "Trotzdem abmelden" + "Überspringen" + "Start" + "Chat starten" + "Verifizierung starten" + "Tippe, um die Karte zu laden" + "Foto aufnehmen" + "Für Optionen tippen" + "Erneut versuchen" + "Lösen" + "Ansicht" + "Im Nachrichtenverlauf anzeigen" + "Quellcode anzeigen" + "Ja" + "Ja, versuche es noch einmal" + "Dein Homeserver unterstützt jetzt ein neues, schnelleres Protokoll. Melde dich ab und wieder an, um zu aktualisieren. Wenn du das jetzt tust, vermeidest du eine erzwungene Abmeldung, wenn das alte Protokoll später entfernt wird." + "Aktualisierung verfügbar" + "Über" + "Nutzungsrichtlinie" + "Konto hinzufügen" + "Weiteres Konto hinzufügen" + "Hinzufügen einer Bildunterschrift" + "Erweiterte Einstellungen" + "ein Bild" + "Analysedaten" + "Du hast den Chat verlassen" + "Du wurdest aus der Sitzung abgemeldet." + "Erscheinungsbild" + "Audio" + "Beta" + "Blockierte Nutzer" + "Sprechblasen" + "Anruf gestartet" + "Chat-Backup" + "In die Zwischenablage kopiert" + "Copyright" + "Chat wird erstellt…" + "Anfrage abgebrochen" + "Hat den Chat verlassen" + "Space verlassen" + "Einladung abgelehnt" + "Dunkel" + "Dekodierungsfehler" + "Beschreibung" + "Entwickleroptionen" + "Geräte-ID" + "Direktnachricht" + "Nicht mehr anzeigen" + "Download fehlgeschlagen" + "Herunterladen" + "(bearbeitet)" + "Bearbeitung" + "Bildunterschrift bearbeiten" + "* %1$s %2$s" + "Leere Datei" + "Verschlüsselung" + "Verschlüsselung aktiviert" + "PIN eingeben" + "Fehler" + "Es ist ein Fehler aufgetreten. Du erhältst eventuell keine Benachrichtigungen für neue Nachrichten. Bitte behebe den Fehler in den Einstellungen. + +Grund: %1$s." + "Alle" + "Fehlgeschlagen" + "Favorit" + "Favorisiert" + "Datei" + "Datei wurde gelöscht" + "Datei gespeichert" + "Datei wurde unter Downloads gespeichert" + "Nachricht weiterleiten" + "Häufig verwendet" + "GIF" + "Bild" + "Als Antwort auf %1$s" + "APK installieren" + "Diese Matrix Kennung wurde nicht gefunden, daher wird die Einladung möglicherweise nicht empfangen." + "Chat verlassen" + "Space wird verlassen" + "Hell" + "Zeile in die Zwischenablage kopiert" + "Link in die Zwischenablage kopiert" + "Laden…" + "Mehr wird geladen…" + + "%d weitere" + "%d weitere" + + + "%1$d Mitglied" + "%1$d Mitglieder" + + "Nachricht" + "Nachrichtenaktionen" + "Nachrichtenlayout" + "Nachricht entfernt" + "Modern" + "Stumm" + "%1$s(%2$s)" + "Keine Ergebnisse" + "Kein Chat-Name" + "Kein Space Name" + "Nicht verschlüsselt" + "Offline" + "Open-Source-Lizenzen" + "oder" + "Passwort" + "Personen" + "Permalink" + "Berechtigung" + "Fixiert" + "Bitte überprüfe deine Internetverbindung" + "Bitte warten…" + "Bist du sicher, dass du diese Umfrage beenden möchtest?" + "Umfrage: %1$s" + "Stimmen insgesamt: %1$s" + "Ergebnisse werden nach Ende der Umfrage angezeigt" + + "%d Stimme" + "%d Stimmen" + + "Vorbereitung läuft …" + "Datenschutz­erklärung" + "Privater Chat" + "Privater Space" + "Öffentlicher Chat" + "Öffentlicher Space" + "Reaktion" + "Reaktionen" + "Grund" + "Wiederherstellungsschlüssel" + "Wird erneuert…" + + "%1$d Antwort" + "%1$d Antworten" + + "%1$s antworten" + "Einen Fehler melden" + "Ein Problem melden" + "Bericht eingereicht" + "Rich-Text-Editor" + "Chat" + "Chat-Name" + "z.B. dein Projektname" + + "%1$d Chat" + "%1$d Chats" + + "Gespeicherte Änderungen" + "Speichern" + "Bildschirmsperre" + "Nach jemandem suchen" + "Suchergebnisse" + "Sicherheit" + "Gesehen von" + "Konto auswählen" + "Senden an" + "Wird gesendet…" + "Senden fehlgeschlagen" + "Gesendet" + ". " + "Server wird nicht unterstützt" + "Server nicht erreichbar" + "Server-URL" + "Einstellungen" + "Space teilen" + "Geteilter Standort" + "Gemeinsamer Space" + "Abmelden" + "Es ist ein Fehler aufgetreten." + "Wir haben ein Problem festgestellt. Bitte versuch es erneut." + "Space" + + "%1$d Space" + "%1$d Spaces" + + "Chat wird gestartet…" + "Sticker" + "Erfolg" + "Vorschläge" + "Synchronisieren" + "System" + "Text" + "Hinweise von Drittanbietern" + "Thread" + "Thema" + "Worum geht es in diesem Chat?" + "Entschlüsselung nicht möglich" + "Von einem ungesicherten Gerät gesendet" + "Du hast keinen Zugriff auf diese Nachricht." + "Die verifizierte Identität des Senders hat sich geändert" + "Einladungen konnten nicht an einen oder mehrere Nutzer gesendet werden." + "Einladung(en) konnte(n) nicht gesendet werden" + "Entsperren" + "Stummschaltung aufheben" + "Anruf nicht unterstützt" + "Nicht unterstütztes Ereignis" + "Nutzername" + "Verifizierung abgebrochen" + "Verifizierung abgeschlossen" + "Verifizierung fehlgeschlagen" + "Verifiziert" + "Gerät verifizieren" + "Identität verifizieren" + "Nutzer verifizieren" + "Video" + "Hohe Qualität" + "Beste Qualität, aber größere Dateigröße" + "Niedrige Qualität" + "Schnellste Upload-Geschwindigkeit und kleinste Dateigröße" + "Standardqualität" + "Balance zwischen Qualität und Upload-Geschwindigkeit" + "Sprachnachricht" + "Warten…" + "Warte auf diese Nachricht" + "Du" + "%1$s\'s Identität has sich geändert. %2$s" + "%1$s\'s %2$s Identität hat sich geändert. %3$s" + "(%1$s)" + "Die Identität von %1$s hat sich geändert." + "Die Identität von %1$s\'s %2$s hat sich geändert. %3$s" + "Verifizierung zurückziehen" + "Der Link %1$s führt dich zu einer anderen Seite %2$s. + +Möchtest du wirklich fortfahren?" + "Überprüfe diesen Link noch einmal" + "Wähle die Standardqualität für Videos, die du hochlädst." + "Video-Upload-Qualität" + "Die maximal erlaubte Dateigröße ist: %1$s" + "Die Datei ist zu groß zum Hochladen." + "Chat gemeldet" + "Gemeldet und Chat verlassen" + "Bestätigung" + "Fehler" + "Erfolg" + "Warnung" + "Deine Änderungen wurden nicht gespeichert. Bist du sicher, dass du zurückgehen willst?" + "Änderungen speichern?" + "Die maximal erlaubte Dateigröße ist: %1$s" + "Wähle die Qualität des Videos, das du hochladen möchtest." + "Wähle die Video-Upload-Qualität" + "Emojis suchen" + "Du bist auf diesem Gerät bereits als %1$s angemeldet." + "Dein Homeserver muss aktualisiert werden, um den Matrix Authentication Services und die Erstellung von Konten zu unterstützen." + "Fehler beim Erstellen des Permalinks" + "%1$s konnte die Karte nicht laden. Bitte versuche es später erneut." + "Fehler beim Laden der Nachrichten" + "%1$s konnte nicht auf deinen Standort zugreifen. Bitte versuche es später erneut." + "Fehler beim Hochladen der Sprachnachricht." + "Der Chat existiert nicht mehr oder die Einladung ist nicht mehr gültig." + "Nachricht nicht gefunden" + "%1$s hat keine Berechtigung, auf deinen Standort zuzugreifen. Du kannst den Zugriff in den Einstellungen aktivieren." + "%1$s hat keine Berechtigung, auf deinen Standort zuzugreifen. Erlaube unten den Zugriff." + "%1$s hat nicht die Berechtigung auf dein Mikrofon zuzugreifen. Erlaube den Zugriff, um eine Sprachnachricht aufzunehmen." + "Dies kann auf Netzwerk- oder Serverprobleme zurückzuführen sein." + "Diese Chat-Adresse existiert bereits. Bitte bearbeite das Adressfeld des Chats oder ändere den Namen des Chats" + "Einige Zeichen sind nicht erlaubt. Es werden nur Buchstaben, Ziffern und die folgenden Symbole unterstützt: ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" + "Einige Nachrichten wurden nicht gesendet" + "Entschuldigung, es ist ein Fehler aufgetreten" + "Der Absender des Ereignisses stimmt nicht mit dem Besitzer des Gerätes überein, das es gesendet hat." + "Die Authentizität dieser verschlüsselten Nachricht kann auf diesem Gerät nicht garantiert werden." + "Verschlüsselt von einem zuvor verifizierten Nutzer." + "Unverschlüsselt." + "Verschlüsselt von einem unbekannten oder gelöschten Gerät." + "Verschlüsselt durch ein Gerät, das nicht von seinem Besitzer verifiziert wurde." + "Verschlüsselt durch einen nicht verifizierten Nutzer." + "🔐️ Begleite mich auf %1$s" + "Hey, sprich mit mir auf %1$s: %2$s" + "%1$s Android" + "Heftiges Schütteln um Fehler zu melden" + "Bildschirmfoto" + "%1$s: %2$s" + "Optionen" + "Entferne %1$s" + "Einstellungen" + "Medienauswahl fehlgeschlagen, bitte versuche es erneut." + "Halte eine Nachricht gedrückt und wähle “%1$s”, um sie hier einzufügen." + "Fixiere wichtige Nachrichten, so dass sie leicht gefunden werden können" + + "%1$d fixierte Nachricht" + "%1$d fixierte Nachrichten" + + "Fixierte Nachrichten" + "Du wirst jetzt zu deinem %1$s Konto geleitet, um deine Identität zurückzusetzen. Danach wirst du zur App zurückgebracht." + "Kannst du das nicht bestätigen? Gehe zu deinem Konto, um deine Identität zurückzusetzen." + "Verifizierung zurückziehen und senden" + "Du kannst deine Verifizierung zurückziehen und diese Nachricht trotzdem senden, oder du kannst vorerst abbrechen und es später noch einmal versuchen, nachdem du %1$s erneut verifiziert hast." + "Deine Nachricht wurde nicht gesendet, da die verifizierte Identität von %1$s zurückgesetzt wurde" + "Nachricht trotzdem senden" + "%1$s verwendet wenigstens ein nicht verifiziertes Gerät. Du kannst die Nachricht trotzdem verschicken, oder vorerst abbrechen und später erneut versuchen, nachdem %2$s alle Geräte verifiziert hat." + "Deine Nachricht wurde nicht gesendet, weil %1$s nicht alle Geräte verifiziert hat" + "Mindestens eines deiner Geräte ist nicht verifiziert. Du kannst die Nachricht trotzdem senden, oder den Vorgang zunächst abbrechen und es später erneut versuchen, nachdem du alle deine Geräte verifiziert hast." + "Deine Nachricht wurde nicht gesendet, da du eines oder mehrere deiner Geräte nicht verifiziert hast." + "Admins oder Eigentümer bearbeiten" + "Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut." + "Nutzerdetails konnten nicht abgerufen werden" + "Nachricht in %1$s" + "Erweitern" + "Verkleinern" + "Du siehst diesen Chat bereits!" + "%1$s von %2$s" + "%1$s fixierte Nachrichten" + "Nachricht wird geladen…" + "Alle anzeigen" + "Chat" + "Standort teilen" + "Meinen Standort teilen" + "In Apple Maps öffnen" + "In Google Maps öffnen" + "In OpenStreetMap öffnen" + "Diesen Standort teilen" + "Von dir erstellte oder beigetretene Spaces." + "%1$s • %2$s" + "%1$s Space" + "Spaces" + "Mitglieder anzeigen" + "Nachricht nicht gesendet, weil sich die verifizierte Identität von %1$s geändert hat." + "Die Nachricht wurde nicht gesendet, weil %1$s nicht alle Geräte verifiziert hat." + "Die Nachricht wurde nicht gesendet, weil du eines oder mehrere deiner Geräte nicht verifiziert hast." + "Standort" + "Version: %1$s (%2$s)" + "en" + "Der Nachrichtenverlauf ist auf diesem Gerät nicht verfügbar" + "Für den Zugriff auf den Nachrichtenverlauf musst du dieses Gerät verifizieren" + "Du hast keinen Zugriff auf diese Nachricht." + "Nachricht kann nicht entschlüsselt werden" + "Diese Nachricht wurde entweder blockiert, weil du dein Gerät nicht verifiziert hast oder weil der Absender deine Identität verifizieren muss." + diff --git a/libraries/ui-strings/src/main/res/values-el/translations.xml b/libraries/ui-strings/src/main/res/values-el/translations.xml new file mode 100644 index 0000000..5085f40 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-el/translations.xml @@ -0,0 +1,408 @@ + + + "Προσθήκη αντίδρασης: %1$s" + "Εικόνα Προφίλ" + "Διαγραφή" + + "%1$d ψηφίο εισήχθη" + "%1$d ψηφία εισήχθησαν" + + "Απόκρυψη κωδικού πρόσβασης" + "Συμμετοχή στην κλήση" + "Άλμα προς τα κάτω" + "Αναφορές μόνο" + "Σε σίγαση" + "Σελίδα %1$d" + "Παύση" + "Φωνητικό μήνυμα, διάρκεια:%1$s, τρέχουσα θέση: %2$s" + "Πεδίο PIN" + "Αναπαραγωγή" + "Δημοσκόπηση" + "Ολοκληρωμένη δημοσκόπηση" + "Αντέδρασε με %1$s" + "Αντέδρασε με άλλα emoji" + "Διαβάστηκε από%1$s και %2$s" + + "Διαβάστηκε από %1$s και %2$d ακόμη" + "Διαβάστηκε από %1$s και %2$d ακόμη" + + "Διαβάστηκε από %1$s" + "Πάτα για εμφάνιση όλων" + "Αφαίρεση αντίδρασης με %1$s" + "Αφαιρέστε την αντίδραση με %1$s" + "Αποστολή αρχείων" + "Εμφάνιση κωδικού πρόσβασης" + "Ξεκίνησε μια κλήση" + "Μενού χρήστη" + "Προβολή λεπτομερειών" + "Φωνητικό μήνυμα, διάρκεια: %1$s" + "Εγγραφή φωνητικού μηνύματος." + "Διακοπή καταγραφής" + "Αποδοχή" + "Προσθήκη λεζάντας" + "Προσθήκη στο χρονοδιάγραμμα" + "Πίσω" + "Κάλεσε" + "Άκυρο" + "Ακύρωση προς το παρόν" + "Επιλογή φωτογραφίας" + "Εκκαθάριση" + "Κλείσιμο" + "Ολοκλήρωση επαλήθευσης" + "Επιβεβαίωση" + "Επιβεβαίωση κωδικού πρόσβασης" + "Συνέχεια" + "Αντιγραφή" + "Αντιγραφή λεζάντας" + "Αντιγραφή συνδέσμου" + "Αντιγραφή συνδέσμου στο μήνυμα" + "Αντιγραφή κειμένου" + "Δημιουργία" + "Δημιουργία αίθουσας" + "Απενεργοποίηση" + "Απενεργοποίηση λογαριασμού" + "Απόρριψη" + "Απόρριψη και αποκλεισμός" + "Διαγραφή Δημοσκόπησης" + "Απενεργοποίηση" + "Απόρριψη" + "Παράβλεψη" + "Έγινε" + "Επεξεργασία" + "Επεξεργασία λεζάντας" + "Επεξεργασία δημοσκόπησης" + "Ενεργοποίηση" + "Λήξη δημοσκόπησης" + "Εισαγωγή PIN" + "Ξέχασες τον κωδικό πρόσβασης;" + "Προώθηση" + "Πήγαινε πίσω" + "Παράβλεψη" + "Πρόσκληση" + "Πρόσκληση ατόμων" + "Πρόσκληση ατόμων στο %1$s" + "Πρόσκληση ατόμων στο %1$s" + "Προσκλήσεις" + "Συμμετοχή" + "Μάθε περισσότερα" + "Αποχώρηση" + "Αποχώρηση από τη συζήτηση" + "Αποχώρηση από την αίθουσα" + "Φόρτωσε περισσότερα" + "Διαχείριση λογαριασμού" + "Διαχείριση συσκευών" + "Στείλε" + "Επόμενο" + "Όχι" + "Όχι τώρα" + "OK" + "Ρυθμίσεις" + "Άνοιγμα με" + "Καρφίτσωμα" + "Γρήγορη απάντηση" + "Παράθεση" + "Αντέδρασε" + "Απόρριψη" + "Αφαίρεση" + "Αφαίρεση λεζάντας" + "Αφαίρεση μηνύματος" + "Απάντηση" + "Απάντηση στο θέμα" + "Αναφορά" + "Αναφορά σφάλματος" + "Αναφορά περιεχομένου" + "Αναφορά συνομιλίας" + "Αναφορά αίθουσας" + "Επαναφορά" + "Επαναφορά ταυτότητας" + "Επανάληψη" + "Επανάληψη αποκρυπτογράφησης" + "Αποθήκευση" + "Αναζήτηση" + "Αποστολή" + "Αποστολή μηνύματος" + "Κοινή χρήση" + "Κοινή χρήση συνδέσμου" + "Εμφάνιση" + "Συνδέσου ξανά" + "Αποσύνδεση" + "Αποσύνδεση ούτως ή άλλως" + "Παράλειψη" + "Εκκίνηση" + "Έναρξη συνομιλίας" + "Έναρξη επαλήθευσης" + "Πάτα για φόρτωση χάρτη" + "Τράβηξε φωτογραφία" + "Πάτα για επιλογές" + "Προσπάθησε ξανά" + "Ξεκαρφίτσωμα" + "Προβολή στο χρονοδιάγραμμα" + "Προβολή πηγής" + "Ναι" + "Ναι, δοκιμή ξανά" + "Ο διακομιστής σου υποστηρίζει τώρα ένα νέο, ταχύτερο πρωτόκολλο. Αποσυνδέσου και συνδέσου ξανά για αναβάθμιση τώρα. Κάνοντας αυτό τώρα θα σε βοηθήσει να αποφύγεις μια αναγκαστική αποσύνδεση όταν το παλιό πρωτόκολλο καταργηθεί αργότερα." + "Διαθέσιμη αναβάθμιση" + "Σχετικά" + "Πολιτική αποδεκτής χρήσης" + "Η λεζάντα προστίθεται" + "Ρυθμίσεις για προχωρημένους" + "μια εικόνα" + "Στατιστικά στοιχεία" + "Εμφάνιση" + "Ήχος" + "Αποκλεισμένοι χρήστες" + "Φυσαλίδες" + "Η κλήση ξεκίνησε" + "Αντίγραφο ασφαλείας συνομιλίας" + "Αντιγράφηκε στο πρόχειρο" + "Πνευματικά δικαιώματα" + "Δημιουργία αίθουσας…" + "Το αίτημα ακυρώθηκε" + "Αποχώρησε από την αίθουσα" + "Η πρόσκληση απορρίφθηκε" + "Σκοτεινό" + "Σφάλμα αποκρυπτογράφησης" + "Επιλογές προγραμματιστή" + "ID συσκευής" + "Άμεση συνομιλία" + "Να μην εμφανιστεί ξανά" + "Η λήψη απέτυχε" + "Γίνεται λήψη" + "(επεξεργάστηκε)" + "Επεξεργάζεται" + "Η λεζάντα επεξεργάζεται" + "* %1$s %2$s" + "Κενό αρχείο" + "Κρυπτογράφηση" + "Η κρυπτογράφηση ενεργοποιήθηκε" + "Εισήγαγε το PIN σου" + "Σφάλμα" + "Παρουσιάστηκε σφάλμα, ενδέχεται να μην λαμβάνεις ειδοποιήσεις για νέα μηνύματα. Αντιμετώπισε το πρόβλημα με τις ειδοποιήσεις από τις ρυθμίσεις. + +Αιτία:%1$s." + "Όλοι" + "Απέτυχε" + "Αγαπημένο" + "Είναι αγαπημένο" + "Αρχείο" + "Το αρχείο διαγράφηκε" + "Το αρχείο αποθηκεύτηκε" + "Αρχείο αποθηκευμένο σε Λήψεις" + "Προώθηση μηνύματος" + "Χρησιμοποιείται συχνά" + "GIF" + "Εικόνα" + "Σε απάντηση στον χρήστη %1$s" + "Εγκατάσταση APK" + "Αυτό το Matrix ID δεν μπορεί να βρεθεί, επομένως η πρόσκληση ενδέχεται να μην ληφθεί." + "Αποχώρηση από την αίθουσα" + "Φωτεινό" + "Η γραμμή αντιγράφηκε στο πρόχειρο" + "Ο σύνδεσμος αντιγράφηκε στο πρόχειρο" + "Φόρτωση…" + "Φόρτωση περισσότερων…" + + "%d ακόμη" + "%d ακόμη" + + + "%1$d μέλος" + "%1$d μέλη" + + "Μήνυμα" + "Ενέργειες μηνυμάτων" + "Διάταξη μηνύματος" + "Το μήνυμα αφαιρέθηκε" + "Σύγχρονη" + "Σίγαση" + "%1$s (%2$s)" + "Κανένα αποτέλεσμα" + "Δεν υπάρχει όνομα αίθουσας" + "Χωρίς κρυπτογράφηση" + "Εκτός σύνδεσης" + "Άδειες ανοιχτού κώδικα" + "ή" + "Κωδικός πρόσβασης" + "Άτομα" + "Μόνιμος σύνδεσμος" + "Αδεια" + "Καρφιτσωμένο" + "Παρακαλώ ελέγξτε τη σύνδεσή σας στο διαδίκτυο" + "Παρακαλώ περίμενε…" + "Θες σίγουρα να τερματίσεις αυτή τη δημοσκόπηση;" + "Δημοσκόπηση: %1$s" + "Σύνολο ψήφων: %1$s" + "Τα αποτελέσματα θα εμφανιστούν μετά τη λήξη της ψηφοφορίας" + + "%d ψήφος" + "%d ψήφοι" + + "Πολιτική απορρήτου" + "Ιδιωτική αίθουσα" + "Δημόσια αίθουσα" + "Αντίδραση" + "Αντιδράσεις" + "Αιτιολογία" + "Κλειδί ανάκτησης" + "Ανανέωση…" + + "%1$d απάντηση" + "%1$d απαντήσεις" + + "Απάντηση σε %1$s" + "Αναφορά σφάλματος" + "Αναφορά προβλήματος" + "Η αναφορά υποβλήθηκε" + "Επεξεργαστής εμπλουτισμένου κειμένου" + "Αίθουσα" + "Όνομα αίθουσας" + "πχ. το όνομα του έργου σου" + "Αποθηκευμένες αλλαγές" + "Αποθηκεύεται" + "Κλείδωμα οθόνης" + "Αναζήτησε κάποιον" + "Αποτελέσματα αναζήτησης" + "Ασφάλεια" + "Προβλήθηκε από" + "Αποστολή σε" + "Αποστολή…" + "Αποτυχία αποστολής" + "Εστάλη" + ". " + "Ο διακομιστής δεν υποστηρίζεται" + "URL διακομιστή" + "Ρυθμίσεις" + "Κοινόχρηστη τοποθεσία" + "Αποσύνδεση" + "Κάτι πήγε στραβά" + "Αντιμετωπίσαμε ένα πρόβλημα. Παρακαλώ προσπαθήστε ξανά." + "Έναρξη συνομιλίας…" + "Αυτοκόλλητο" + "Επιτυχία" + "Προτάσεις" + "Συγχρονισμός" + "Σύστημα" + "Κείμενο" + "Ειδοποιήσεις τρίτων" + "Νήμα" + "Θέμα" + "Τι αφορά αυτή η αίθουσα;" + "Δεν είναι δυνατή η αποκρυπτογράφηση" + "Στάλθηκε από μια μη ασφαλής συσκευή" + "Δεν έχεις πρόσβαση σε αυτό το μήνυμα" + "Η επαληθευμένη ταυτότητα του αποστολέα έχει επαναφερθεί" + "Δεν ήταν δυνατή η αποστολή προσκλήσεων σε έναν ή περισσότερους χρήστες." + "Δεν είναι δυνατή η αποστολή προσκλήσεων" + "Ξεκλείδωμα" + "Άρση σίγασης" + "Μη υποστηριζόμενη κλήση" + "Μη υποστηριζόμενο συμβάν" + "Όνομα χρήστη" + "Η επαλήθευση ακυρώθηκε" + "Η επαλήθευση ολοκληρώθηκε" + "Αποτυχία επαλήθευσης" + "Επαληθεύτηκε" + "Επαλήθευση συσκευής" + "Επαλήθευση ταυτότητας" + "Επαλήθευση χρήστη" + "Βίντεο" + "Φωνητικό μήνυμα" + "Αναμονή…" + "Αναμονή για αυτό το μήνυμα" + "Εσύ" + "Η ταυτότητα του χρήστη %1$s επαναφέρθηκε. %2$s" + "Η ταυτότητα του %1$s %2$s επαναφέρθηκε. %3$s" + "(%1$s)" + "Η ταυτότητα του χρήστη %1$s επαναφέρθηκε." + "Η ταυτότητα του χρήστη %1$s %2$s επαναφέρθηκε. %3$s" + "Ανάκληση επαλήθευσης" + "Ο σύνδεσμος %1$s σας μεταφέρει σε άλλο ιστότοπο %2$s + +Είστε βέβαιοι ότι θέλετε να συνεχίσετε;" + "Ελέγξτε ξανά αυτόν τον σύνδεσμο" + "Η αίθουσα αναφέρθηκε" + "Αναφέρθηκε και αποχωρήσατε από την αίθουσα" + "Επιβεβαίωση" + "Σφάλμα" + "Επιτυχία" + "Προειδοποίηση" + "Οι αλλαγές σου δεν έχουν αποθηκευτεί. Σίγουρα θες να πας πίσω;" + "Αποθήκευση αλλαγών;" + "Ο οικιακός διακομιστής σου πρέπει να αναβαθμιστεί για να υποστηρίζει το Matrix Authentication Service και τη δημιουργία λογαριασμού." + "Αποτυχία δημιουργίας του μόνιμου συνδέσμου" + "%1$s δεν ήταν δυνατή η φόρτωση του χάρτη. Παρακαλώ δοκίμασε ξανά αργότερα." + "Αποτυχία φόρτωσης μηνυμάτων" + "Το %1$s δεν μπόρεσε να αποκτήσει πρόσβαση στην τοποθεσία σου. Προσπάθησε ξανά αργότερα." + "Αποτυχία μεταφόρτωσης του φωνητικού σου μηνύματος." + "Η αίθουσα δεν υπάρχει πλέον ή η πρόσκληση δεν ισχύει πλέον." + "Το μήνυμα δεν βρέθηκε" + "Το %1$s δεν έχει άδεια πρόσβασης στην τοποθεσία σου. Μπορείς να ενεργοποιήσεις την πρόσβαση στις Ρυθμίσεις." + "Ο χρήστης %1$s δεν έχει άδεια πρόσβασης στην τοποθεσία σου. Ενεργοποίησε την πρόσβαση παρακάτω." + "Το %1$s δεν έχει άδεια πρόσβασης στο μικρόφωνό σου. Ενεργοποίησε την πρόσβαση για εγγραφή φωνητικού μηνύματος." + "Αυτό μπορεί να οφείλεται σε προβλήματα δικτύου ή διακομιστή." + "Αυτή η διεύθυνση αίθουσας υπάρχει ήδη. Παρακαλώ δοκιμάστε να επεξεργαστείτε το πεδίο διεύθυνσης αίθουσας ή αλλάξτε το όνομα της αίθουσας" + "Ορισμένοι χαρακτήρες δεν επιτρέπονται. Υποστηρίζονται μόνο γράμματα, ψηφία και τα ακόλουθα σύμβολα ! $ & \'() * +/; = ? @ [] - . _" + "Ορισμένα μηνύματα δεν έχουν σταλεί" + "Λυπούμαστε, παρουσιάστηκε σφάλμα" + "Ο αποστολέας του συμβάντος δεν ταιριάζει με τον κάτοχο της συσκευής που το έστειλε." + "Η αυθεντικότητα αυτού του κρυπτογραφημένου μηνύματος δεν είναι εγγυημένη σε αυτήν τη συσκευή." + "Κρυπτογραφημένο από έναν προηγουμένως επαληθευμένο χρήστη." + "Μη κρυπτογραφημένο." + "Κρυπτογραφημένο από άγνωστη ή διαγεγραμμένη συσκευή." + "Κρυπτογραφημένο από μια συσκευή που δεν έχει επαληθευτεί από τον ιδιοκτήτη της." + "Κρυπτογραφημένο από μη επαληθευμένο χρήστη." + "🔐️ Έλα μαζί μου στο %1$s" + "Γεια, μίλα μου στην εφαρμογή %1$s :%2$s" + "%1$s Android" + "Κούνησε δυνατά τη συσκευή σου για να αναφέρεις κάποιο σφάλμα" + "%1$s: %2$s" + "Επιλογές" + "Αφαίρεση %1$s" + "Ρυθμίσεις" + "Αποτυχία επιλογής πολυμέσου, δοκίμασε ξανά." + "Πάτα σε ένα μήνυμα και επέλεξε «%1$s» για να συμπεριληφθεί εδώ." + "Καρφίτσωσε σημαντικά μηνύματα, ώστε να μπορούν να εντοπιστούν εύκολα" + + "%1$d Καρφιτσωμένο μήνυμα" + "%1$d Καρφιτσωμένα μηνύματα" + + "Καρφιτσωμένα μηνύματα" + "Πρόκειται να μεταβείς στον λογαριασμό σου %1$s για να επαναφέρεις την ταυτότητά σου. Στη συνέχεια, θα επιστρέψεις στην εφαρμογή." + "Δεν μπορείς να επιβεβαιώσεις; Πήγαινε στον λογαριασμό σου για να επαναφέρεις την ταυτότητά σου." + "Ανάκληση επαλήθευσης και αποστολή" + "Μπορείτε να ανακαλέσεις την επαλήθευσή σου και να στείλεις αυτό το μήνυμα όπως και να \'χει ή μπορείς να το ακυρώσεις προς το παρόν και να προσπαθήσεις ξανά αργότερα μετά την επαλήθευση του χρήστη %1$s." + "Το μήνυμά σου δεν στάλθηκε επειδή η επαληθευμένη ταυτότητα του χρήστη %1$s έχει επαναφερθεί" + "Αποστολή μηνύματος ούτως ή άλλως" + "Ο χρήστης %1$s χρησιμοποιεί τουλάχιστον μία μη επαληθευμένη συσκευή. Μπορείς να στείλεις το μήνυμα όπως και να \'χει ή μπορείς να το ακυρώσεις προς το παρόν και να δοκιμάσεις ξανά αργότερα αφού ο χρήστης %2$s επαληθεύσει όλες τις συσκευές του." + "Το μήνυμά σου δεν στάλθηκε επειδή ο χρήστης %1$s δεν έχει επαληθεύσει όλες τις συσκευές" + "Μία ή περισσότερες από τις συσκευές σου δεν έχουν επαληθευτεί. Μπορείς να στείλεις το μήνυμα ούτως ή άλλως, ή μπορείς να το ακυρώσεις προς το παρόν και να προσπαθήσεις ξανά αργότερα αφού επαληθεύσεις όλες τις συσκευές σου." + "Το μήνυμά σου δεν στάλθηκε επειδή δεν έχεις επαληθεύσει τουλάχιστον μία από τις συσκευές σου" + "Αποτυχία μεταφόρτωσης μέσου, δοκίμασε ξανά." + "Δεν ήταν δυνατή η ανάκτηση στοιχείων χρήστη" + "Μήνυμα στο %1$s" + "Επέκταση" + "Μείωση" + "Βλέπετε ήδη αυτήν την αίθουσα!" + "%1$s από %2$s" + "%1$s Καρφιτσωμένα μηνύματα" + "Φόρτωση μηνύματος…" + "Προβολή Όλων" + "Συνομιλία" + "Κοινή χρήση τοποθεσίας" + "Κοινή χρήση της τοποθεσίας μου" + "Άνοιγμα στο Apple Maps" + "Άνοιγμα στο Google Maps" + "Άνοιγμα στο OpenStreetMap" + "Κοινή χρήση αυτής της τοποθεσίας" + "Το μήνυμα δεν στάλθηκε γιατί έγινε επαναφορά της επαληθευμένης ταυτότητας του χρήστη %1$s." + "Το μήνυμα δεν στάλθηκε επειδή ο χρήστης %1$s δεν έχει επαληθεύσει όλες τις συσκευές." + "Το μήνυμα δεν στάλθηκε επειδή δεν έχεις επαληθεύσει τουλάχιστον μία από τις συσκευές σου." + "Τοποθεσία" + "Έκδοση: %1$s (%2$s)" + "el" + "Τα ιστορικά μηνύματα δεν είναι διαθέσιμα σε αυτήν τη συσκευή" + "Πρέπει να επαληθεύσετε αυτήν τη συσκευή για πρόσβαση σε μηνύματα ιστορικού" + "Δεν έχεις πρόσβαση σε αυτό το μήνυμα" + "Δεν είναι δυνατή η αποκρυπτογράφηση μηνύματος" + "Αυτό το μήνυμα αποκλείστηκε είτε επειδή δεν επαλήθευσες τη συσκευή σου είτε επειδή ο αποστολέας πρέπει να επαληθεύσει την ταυτότητά σου." + diff --git a/libraries/ui-strings/src/main/res/values-en-rUS/translations.xml b/libraries/ui-strings/src/main/res/values-en-rUS/translations.xml new file mode 100644 index 0000000..00d2674 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-en-rUS/translations.xml @@ -0,0 +1,6 @@ + + + "Minimize" + "Favorite" + "Favorited" + 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 0000000..4428e0b --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-es/translations.xml @@ -0,0 +1,391 @@ + + + "Avatar" + "Borrar" + + "%1$d dígito introducido" + "%1$d dígitos introducidos" + + "Ocultar contraseña" + "Unirse a la llamada" + "Ir al final" + "Sólo menciones" + "Silenciado" + "Página %1$d" + "Pausar" + "Mensaje de voz, duración: %1$s, posición actual: %2$s" + "Campo PIN" + "Reproducir" + "Encuesta" + "Encuesta finalizada" + "Reacciona con %1$s" + "Reacciona con otros emojis" + "Leído por %1$s y %2$s" + + "Leído por %1$s y %2$d otro" + "Leído por %1$s y %2$d otros" + + "Leído por %1$s" + "Pulsa para mostrar todo" + "Elimina la reacción con %1$s" + "Enviar archivos" + "Mostrar contraseña" + "Iniciar llamada" + "Menú de usuario" + "Mostrar detalles" + "Mensaje de voz, duración: %1$s" + "Grabar mensaje de voz" + "Detener grabación" + "Aceptar" + "Agregar leyenda" + "Añadir a la cronología" + "Atrás" + "Llamar" + "Cancelar" + "Cancelar por ahora" + "Elegir foto" + "Borrar" + "Cerrar" + "Completar verificación" + "Confirmar" + "Confirmar contraseña" + "Continuar" + "Copiar" + "Copiar leyenda" + "Copiar enlace" + "Copiar enlace al mensaje" + "Copiar texto" + "Crear" + "Crear una sala" + "Desactivar" + "Desactivar cuenta" + "Rechazar" + "Rechazar y bloquear" + "Eliminar encuesta" + "Desactivar" + "Descartar" + "Descartar" + "Hecho" + "Editar" + "Editar leyenda" + "Editar encuesta" + "Activar" + "Finalizar encuesta" + "Introducir PIN" + "¿Olvidaste tu contraseña?" + "Reenviar" + "Volver atrás" + "Ignorar" + "Invitar" + "Invitar personas" + "Invita a alguien a %1$s" + "Invita a alguien a %1$s" + "Invitaciones" + "Unirse" + "Más información" + "Salir" + "Salir de la conversación" + "Salir de la sala" + "Cargar más" + "Gestionar cuenta" + "Administrar dispositivos" + "Enviar mensaje" + "Siguiente" + "No" + "Ahora no" + "OK" + "Ajustes" + "Abrir con" + "Fijar" + "Respuesta rápida" + "Citar" + "Reaccionar" + "Rechazar" + "Eliminar" + "Eliminar leyenda" + "Eliminar mensaje" + "Responder" + "Responder en el hilo" + "Denunciar" + "Informar de un error" + "Denunciar contenido" + "Denunciar conversación" + "Denunciar sala" + "Restablecer" + "Restablecer identidad" + "Reintentar" + "Reintentar descifrado" + "Guardar" + "Buscar" + "Enviar" + "Enviar mensaje" + "Compartir" + "Compartir enlace" + "Mostrar" + "Inicia sesión de nuevo" + "Cerrar sesión" + "Cerrar sesión de todos modos" + "Saltar" + "Comenzar" + "Iniciar chat" + "Iniciar la verificación" + "Pulsa para cargar el mapa" + "Hacer foto" + "Toca para ver opciones" + "Intentar de nuevo" + "Desprender" + "Ver en la cronología" + "Ver fuente" + "Sí" + "Sí, intentar de nuevo" + "Tu servidor es ahora compatible con un protocolo nuevo y más rápido. Cierra la sesión y vuelve a iniciarla para actualizar ahora. Haciéndolo ahora, evitarás tener que cerrar la sesión cuando se elimine el protocolo antiguo." + "Actualización disponible" + "Acerca de" + "Política de uso aceptable" + "Añadiendo leyenda" + "Ajustes avanzados" + "Estadísticas" + "Apariencia" + "Sonido" + "Usuarios bloqueados" + "Burbujas" + "Llamada iniciada" + "Copia de seguridad del chat" + "Copiado al portapapeles" + "Derechos de autor" + "Creando sala…" + "Solicitud cancelada" + "Saliste de la sala" + "Invitación rechazada" + "Oscuro" + "Error de descifrado" + "Opciones de desarrollador" + "ID de dispositivo" + "Chat directo" + "No mostrar de nuevo" + "Descarga fallida" + "Descargando" + "(editado)" + "Edición" + "Editando leyenda" + "* %1$s %2$s" + "Archivo vacío" + "Cifrado" + "Cifrado activado" + "Introduce tu PIN" + "Error" + "Se ha producido un error, es posible que no recibas notificaciones de mensajes nuevos. Soluciona los problemas con las notificaciones desde los ajustes. + +Motivo: %1$s." + "Todos" + "Falló" + "Favorito" + "Marcado como favorito" + "Archivo" + "Archivo eliminado" + "Archivo guardado" + "Archivo guardado en Descargas" + "Reenviar mensaje" + "Usado frecuentemente" + "GIF" + "Imagen" + "En respuesta a %1$s" + "Instalar APK" + "No se encontró este ID de Matrix, por lo que es posible que no se reciba la invitación." + "Saliendo de la sala" + "Claro" + "Línea copiada al portapapeles" + "Enlace copiado al portapapeles" + "Cargando…" + "Cargando más…" + + "%d otro" + "%d otros" + + + "%1$d miembro" + "%1$d miembros" + + "Mensaje" + "Acciones del mensaje" + "Diseño de los mensajes" + "Mensaje eliminado" + "Moderno" + "Silenciar" + "%1$s (%2$s)" + "No hay resultados" + "Sala sin nombre" + "No cifrado" + "Sin conexión" + "Licencias de código abierto" + "o" + "Contraseña" + "Personas" + "Enlace permanente" + "Permiso" + "Fijado" + "Comprueba tu conexión a Internet" + "Espera, por favor…" + "¿Estás seguro de que quieres finalizar esta encuesta?" + "Encuesta: %1$s" + "Total de votos: %1$s" + "Los resultados se mostrarán una vez finalizada la encuesta" + + "%d voto" + "%d votos" + + "Política de privacidad" + "Sala privada" + "Sala pública" + "Reacción" + "Reacciones" + "Motivo" + "Clave de recuperación" + "Recargando…" + "Respondiendo a %1$s" + "Informar de un error" + "Informar de un problema" + "Informe enviado" + "Editor de texto enriquecido" + "Sala" + "Nombre de la sala" + "p. ej., el nombre de tu proyecto" + "Cambios guardados" + "Guardando" + "Bloqueo de pantalla" + "Buscar a alguien" + "Buscar resultados" + "Seguridad" + "Visto por" + "Enviar a" + "Enviando…" + "Fallo al enviar" + "Enviado" + "Servidor no compatible" + "Dirección del servidor" + "Ajustes" + "Ubicación compartida" + "Cerrando sesión" + "Algo salió mal" + "Hemos encontrado un problema. Inténtalo de nuevo." + "Iniciando chat…" + "Sticker" + "Terminado" + "Sugerencias" + "Sincronizando" + "Sistema" + "Texto" + "Avisos de terceros" + "Hilo" + "Tema" + "¿De qué trata esta sala?" + "No se puede descifrar" + "Enviado desde un dispositivo no seguro" + "No tienes acceso a este mensaje" + "Se restableció la identidad verificada del remitente" + "Las invitaciones no se pudieron enviar a uno o más usuarios." + "No se pudo enviar la(s) invitación(es)" + "Desbloquear" + "Dejar de silenciar" + "Llamada no compatible" + "Evento no compatible" + "Usuario" + "Verificación cancelada" + "Verificación completada" + "Verificación fallida" + "Verificado" + "Verificar dispositivo" + "Verificar identidad" + "Verificar usuario" + "Vídeo" + "Mensaje de voz" + "Esperando…" + "Esperando este mensaje" + "Tú" + "Se restableció la identidad de %1$s. %2$s" + "Se restableció la identidad de %1$s %2$s. %3$s" + "(%1$s)" + "Se restableció la identidad de %1$s." + "Se restableció la identidad de %1$s %2$s. %3$s" + "Retirar la verificación" + "El enlace %1$s te está llevando a otro sitio %2$s + +¿Estás seguro de que quieres continuar?" + "Revisa este enlace" + "Sala denunciada" + "Denunciaste y saliste de la sala" + "Confirmar" + "Error" + "Terminado" + "Atención" + "Tus cambios no se han guardado. ¿Estás seguro de que quieres volver atrás?" + "¿Guardar cambios?" + "Tu servidor base debe actualizarse para admitir Matrix Authentication Service y la creación de cuentas." + "No se pudo crear el enlace permanente" + "%1$s no pudo cargar el mapa. Por favor vuelve a intentarlo más tarde." + "Error al cargar mensajes" + "%1$s no ha podido acceder a tu ubicación. Por favor vuelve a intentarlo más tarde." + "No se pudo cargar tu mensaje de voz." + "Mensaje no encontrado" + "%1$s no tiene permiso para acceder a tu ubicación. Puedes habilitar el acceso en Ajustes." + "%1$s no tiene permiso para acceder a tu ubicación. Habilita el acceso a continuación." + "%1$s no tiene permiso para acceder al micrófono. Habilita el acceso para grabar un mensaje de voz." + "Esto puede deberse a problemas de red o del servidor." + "Esta dirección de sala ya existe. Intenta editar el campo de dirección de sala o cambia el nombre de la sala" + "No se permiten algunos caracteres. Solo se admiten letras, dígitos y los siguientes símbolos ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" + "Algunos mensajes no se han enviado" + "Lo siento, se ha producido un error" + "La autenticidad de este mensaje cifrado no puede ser garantizada en este dispositivo." + "Cifrado por un usuario verificado anteriormente." + "No cifrado." + "Cifrado por un dispositivo desconocido o eliminado." + "Cifrado por un dispositivo no verificado por su propietario." + "Cifrado por un usuario no verificado." + "🔐️ Únete a mí en %1$s" + "Hola, puedes hablar conmigo en %1$s: %2$s" + "%1$s Android" + "Agitar con fuerza para informar de un error" + "Error al seleccionar archivos multimedia, por favor inténtalo de nuevo." + "Presiona sobre un mensaje y selecciona «%1$s» para incluirlo aquí." + "Fija los mensajes importantes para que se puedan descubrir fácilmente" + + "%1$d mensaje fijado" + "%1$d mensajes fijados" + + "Mensajes fijados" + "Estás a punto de ir a tu cuenta de %1$s para restablecer tu identidad. Posteriormente volverás a la aplicación." + "¿No puedes confirmar? Ve a tu cuenta para restablecer tu identidad." + "Retirar la verificación y enviar" + "Puedes retirar tu verificación y enviar este mensaje de todos modos, o puedes cancelarlo por ahora e intentarlo de nuevo más tarde después de volver a verificar a %1$s." + "Tu mensaje no se envió porque la identidad verificada de %1$s fue restablecida" + "Enviar mensaje de todos modos" + "%1$s utiliza uno o más dispositivos no verificados. Puedes enviar el mensaje de todos modos, o puedes cancelarlo por ahora y volver a intentarlo más tarde, una vez %2$s haya verificado todos sus dispositivos." + "Tu mensaje no se envió porque %1$s no ha verificado todos los dispositivos" + "Uno o más de tus dispositivos no están verificados. Puedes enviar el mensaje de todos modos, o puedes cancelarlo por ahora e intentarlo de nuevo más tarde, una vez hayas verificado todos tus dispositivos." + "Tu mensaje no se envió porque no has verificado uno o más de tus dispositivos" + "Error al procesar el contenido multimedia, por favor inténtalo de nuevo." + "No se pudieron recuperar los detalles del usuario" + "Mensaje en %1$s" + "%1$s de %2$s" + "%1$s mensajes fijados" + "Cargando mensaje…" + "Ver todos" + "Chat" + "Compartir ubicación" + "Compartir mi ubicación" + "Abrir en Apple Maps" + "Abrir en Google Maps" + "Abrir en OpenStreetMap" + "Compartir esta ubicación" + "Mensaje no enviado porque la identidad verificada de %1$s fue restablecida." + "Mensaje no enviado porque %1$s no ha verificado todos los dispositivos." + "Mensaje no enviado porque no has verificado uno o más de tus dispositivos." + "Ubicación" + "Versión: %1$s (%2$s)" + "es" + "Los mensajes históricos no están disponibles en este dispositivo" + "Debes verificar este dispositivo para acceder a los mensajes históricos" + "No tienes acceso a este mensaje" + "No se ha podido descifrar el mensaje" + "Este mensaje fue bloqueado bien sea porque no verificaste tu dispositivo o porque el remitente necesita verificar tu identidad." + diff --git a/libraries/ui-strings/src/main/res/values-et/translations.xml b/libraries/ui-strings/src/main/res/values-et/translations.xml new file mode 100644 index 0000000..4b8c38b --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-et/translations.xml @@ -0,0 +1,486 @@ + + + "Reageeri: %1$s" + "Tunnuspilt" + "Vähenda tekstivälja" + "Kustuta" + + "%1$d number sisestatud" + "%1$d numbrit sisestatud" + + "Muuda tunnuspilti" + "Täisaadress saab olema %1$s" + "Krüptimise üksikasjad" + "Laienda tekstivälja" + "Peida salasõna" + "Liitu kõnega" + "Mine lõppu" + "Nihuta kaart minu asukohta" + "Ainult mainimised" + "Summutatud" + "Uued mainimised" + "Uued sõnumid" + "Kõne on pooleli" + "Teise kasutaja tunnuspilt" + "%1$d. lehekülg" + "Peata" + "Häälsõnum, kestus:%1$s, praegune asukoht: %2$s" + "PIN-koodi väli" + "Esita" + "Küsitlus" + "Lõppenud küsitlus" + "Reageeri emotikoniga %1$s" + "Reageeri mõne muu emotikoniga" + "Seda lugesid %1$s ja %2$s" + + "Seda lugesid %1$s ja veel %2$d kasutaja" + "Seda lugesid %1$s ja veel %2$d kasutajat" + + "Seda luges %1$s" + "Vaata kõiki" + "Eemalda reageerimine %1$s emotikoniga" + "Eemalda reageerimine: %1$s" + "Jututoa tunnuspilt" + "Saada faile" + "Palun tee see ajapiiranguga toiming, sul on aega üks minut" + "Näita salasõna" + "Helista" + "Lõpetatuks märgitud jututuba" + "Kasutaja tunnuspilt" + "Kasutajamenüü" + "Vaata tunnuspilti" + "Vaata üksikasju" + "Häälsõnum, kestus:%1$s" + "Salvesta häälsõnum." + "Lõpeta salvestamine" + "Sinu tunnuspilt" + "Nõustu" + "Lisa selgitus" + "Lisa ajajoonele" + "Tagasi" + "Helista" + "Loobu" + "Hetkel jäta tegemata" + "Vali foto" + "Selge" + "Sulge" + "Tee verifitseerimine lõpuni" + "Kinnita" + "Kinnita otsust oma salasõnaga" + "Jätka" + "Kopeeri" + "Kopeeri selgitus" + "Kopeeri link" + "Kopeeri sõnumi link" + "Kopeeri tekst" + "Loo" + "Loo jututuba" + "Eemalda konto" + "Eemalda konto kasutusest" + "Keeldu" + "Keeldu ja blokeeri" + "Kustuta küsitlus" + "Eemalda kõik valikud" + "Lülita välja" + "Loobu" + "Lõpeta" + "Valmis" + "Muuda" + "Muuda selgitust" + "Muuda küsitlust" + "Võta kasutusele" + "Lõpeta küsitlus" + "Sisesta PIN-kood" + "Lõpeta" + "Kas unustasid salasõna?" + "Edasta" + "Tagasi eelmisesse vaatesse" + "Ava „Rollid ja õigused“" + "Ava seadistused" + "Eira" + "Kutsu" + "Kutsu osalejaid" + "Kutsu huvilisi kasutama rakendust %1$s" + "Kutsu huvilisi kasutama rakendust %1$s" + "Kutsed" + "Liitu" + "Lisateave" + "Lahku" + "Lahku vestlusest" + "Lahku jututoast" + "Lahku kogukonnast" + "Näita veel" + "Halda kasutajakontot" + "Halda seadmeid" + "Saada sõnum" + "Minimeeri" + "Edasi" + "Ei" + "Mitte praegu" + "OK" + "Ava kontekstimenüü" + "Seadistused" + "Ava rakendusega" + "Tõsta esile" + "Kiirvastus" + "Tsiteeri" + "Reageeri" + "Keeldu" + "Eemalda" + "Eemalda selgitus" + "Eemalda sõnum" + "Vasta" + "Vasta jutulõngas" + "Teata" + "Teata veast" + "Teata sisust haldurile" + "Teata vestlusest" + "Teata jututoast" + "Lähtesta" + "Lähtesta oma identiteet" + "Proovi uuesti" + "Proovi dekrüptimist uuesti" + "Salvesta" + "Otsi" + "Vali kõik" + "Saada" + "Saada muudetud sõnum" + "Saada sõnum" + "Saada häälsõnum" + "Jaga" + "Jaga linki" + "Näita" + "Logi uuesti sisse" + "Logi välja" + "Ikkagi logi välja" + "Jäta vahele" + "Alusta" + "Alusta vestlust" + "Alusta verifitseerimist" + "Kaardi laadimiseks klõpsa" + "Tee pilt" + "Valikuteks klõpsa" + "Proovi uuesti" + "Eemalda esiletõstmine" + "Vaata" + "Vaata ajajoonel" + "Vaata lähtekoodi" + "Jah" + "Jah, proovi uuesti" + "Sinu koduserver toetab uut ja kiiremat protokolli. Uuendamiseks logi korraks rakendusest välja ja siis tagasi. Mingil hetkel tulevikus vana protokoll eemaldatakse kasutusest ja tehes uuenduse nüüd, väldid hilisemat sundkorras uuendust." + "Saadaval on uuendus" + "Rakenduse teave" + "Vastuvõetava kasutamise põhimõtted" + "Lisa kasutajakonto" + "Lisa veel üks kasutajakonto" + "Lisame selgitust" + "Täiendavad seadistused" + "pilt" + "Analüütika" + "Sa oled jututoast lahkunud" + "Sa olid sessioonist väljaloginud" + "Välimus" + "Heli" + "Beetaversioon" + "Blokeeritud kasutajad" + "Mullid" + "Kõne algas" + "Vestluse varukoopia" + "Kopeeritud lõikelauale" + "Autoriõigused" + "Loome jututoa…" + "Päring on tühistatud" + "Lahkus jututoast" + "Lahkus kogukonnast" + "Keeldusid kutsest" + "Tume" + "Dekrüptimisviga" + "Kirjeldus" + "Arendaja valikud" + "Seadme tunnus" + "Otsevestlus" + "Ära enam näita seda uuesti" + "Allaladimine ei õnnestunud" + "Laadime alla" + "(muudetud)" + "Muutmine" + "Muudame selgitust" + "* %1$s %2$s" + "Tühi fail" + "Krüptimine" + "Krüptimine on kasutusel" + "Sisesta oma PIN-kood" + "Viga" + "Tekkis viga ja sa ei pruugi enam saada uute sõnumite kohta teavitusi. Palun kontrolli teavituste seadistusi ja proovi viga tuvastada. + +Põhjus: %1$s." + "Kõik" + "Ei õnnestunud" + "Lemmik" + "Lemmikuks määratud" + "Fail" + "Fail on kustutatud" + "Fail on salvestatud" + "Fail on salvestatud kausta Allalaadimised" + "Edasta sõnum" + "Sagedasti kasutatud" + "GIF" + "Pilt" + "Vastuseks kasutajale %1$s" + "Paigalda APK-failist" + "Sellist Matrix\'i kasutajatunnust ei õnnestu leida, seega sõnumit ilmselt keegi kätte ei saa." + "Oled lahkumas jututoast" + "Oled lahkumas kogukonnast" + "Hele" + "Rida on kopeeritud lõikelauale" + "Link on kopeeritud lõikelauale" + "Laadime…" + "Laadime veel…" + + "%d muu" + "%d muud" + + + "%1$d liige" + "%1$d liiget" + + "Sõnum" + "Tegevused sõnumiga" + "Sõnumi paigutus" + "Sõnum on eemaldatud" + "Kaasaegne" + "Summutatud" + "%1$s (%2$s)" + "Otsingul pole tulemusi" + "Jututoal puudub nimi" + "Kogukonnal pole nime" + "Krüptimata" + "Võrgust väljas" + "Avatud lähtekoodiga litsentsid" + "või" + "Salasõna" + "Inimesed" + "Püsilink" + "Õigus" + "Esiletõstetud" + "Palun kontrolli oma nutiseadme internetiühendust" + "Palun oota…" + "Kas oled kindel, et soovid selle küsitluse lõpetada?" + "Küsitlus: %1$s" + "Hääli kokku: %1$s" + "Tulemused on näha peale küsitluse lõppemist" + + "%d hääl" + "%d häält" + + "Ettevalmistamisel…" + "Privaatsuspoliitika" + "Privaatne jututuba" + "Privaatne kogukond" + "Avalik jututuba" + "Avalik kogukond" + "Reaktsioon" + "Reaktsioonid" + "Põhjus" + "Taastevõti" + "Värskendame andmeid…" + + "%1$d vastus" + "%1$d vastust" + + "Vastates kasutajale %1$s" + "Teata veast" + "Teata veast" + "Veateade on saadetud" + "Vormindatud teksti toimeti" + "Jututuba" + "Jututoa nimi" + "näiteks sinu projekti või seltsingu nimi" + + "%1$d jututuba" + "%1$d jututuba" + + "Muudatused on salvestatud" + "Salvestame" + "Ekraanilukk" + "Otsi kedagi" + "Otsingutulemused" + "Turvalisus" + "Seda nägi(d)" + "Vali kasutajakonto" + "Saada kasutajale" + "Saadame…" + "Saatmine ei õnnestunud" + "Saadetud" + ". " + "Server pole toetatud" + "Server pole leitav" + "Serveri URL" + "Seadistused" + "Jaga kogukonda" + "Jagatud asukoht" + "Jagatud kogukond" + "Logime välja" + "Midagi läks valesti" + "Tekkis viga. Palun proovi uuesti." + "Kogukond" + + "%1$d kogukond" + "%1$d kogukonda" + + "Alustame vestlust…" + "Kleeps" + "Õnnestus" + "Soovitused" + "Sünkroniseerime" + "Süsteem" + "Tekst" + "Kolmandate osapoolte teatised" + "Jutulõng" + "Teema" + "Mis on selle jututoa mõte?" + "Dekrüptimine ei olnud võimalik" + "Saadetud ebaturvalisest seadmest" + "Sul pole ligipääsu antud sõnumile" + "Saatja verifitseeritud identiteet on lähtestatud" + "Kutset polnud võimalik saata ühele või enamale kasutajale." + "Kutse(te) saatmine ei õnnestunud" + "Eemalda lukustus" + "Lõpeta summutamine" + "See kõne pole toetatud" + "Toetamata sündmus" + "Kasutajanimi" + "Verifitseerimine on katkestatud" + "Verifitseerimine on tehtud" + "Verifitseerimine ei õnnestunud" + "Verifitseeritud" + "Verifitseeri seade" + "Verifitseeri võrguidentiteet" + "Verifitseeri kasutaja" + "Video" + "Kõrge kvaliteet" + "Parim kvaliteet, aga suuremad failid" + "Madal kvaliteet" + "Parim üleslaadimiskiirus ja väikseim failisuurus" + "Tavakvaliteet" + "Kvaliteedi ja üleslaadimise kiiruse optimaalne proportsioon" + "Häälsõnum" + "Ootame…" + "Ootame selle sõnumi dekrüptimisvõtit" + "Sina" + "Kasutaja %1$s võrguidentiteet on lähtestatud. %2$s" + "Kasutaja %1$s %2$s võrguidentiteet on lähtestatud. %3$s" + "(%1$s)" + "%1$s kasutaja verifitseeritud identiteet on lähtestatud." + "%1$s kasutaja (%2$s kasutajanimi) verifitseeritud identiteet on lähtestatud. %3$s" + "Võta verifitseerimine tagasi" + "%1$s link viib sind teise veebisaiti %2$s + +Kas sa oled kindel, et soovid jätkata?" + "Palun kontrolli seda linki mõttega" + "Vali üleslaaditavate videote kvaliteeditase" + "Üleslaaditavate videote kvaliteet" + "Suurim lubatud failisuurus on: %1$s" + "See fail on üleslaadimiseks liiga suur" + "Teatasid jututoast" + "Teatasid jututoast ja lahkusid sealt" + "Kinnitus" + "Viga" + "Õnnestus" + "Hoiatus" + "Sinu tehtud muudatused pole veel salvestatud. Kas sa oled kindel, et soovid minna tagasi?" + "Kas salvestame muudatused?" + "Suurim lubatud failisuurus on: %1$s" + "Vali üleslaaditava video kvaliteet." + "Vali video kvaliteet." + "Otsi emojisid" + "Sa juba oled sellesse seadmesse sisseloginud kasutajana %1$s." + "Selleks et koos kasutajakonto loomisega toimiks Matrix Authentication Service\'i tugi, vajab sinu koduserver uuendamist." + "Püsilingi loomine ei õnnestumud" + "%1$s kaardi laadimine ei õnnestunud. Palun proovi hiljem uuesti." + "Sõnumite laadimine ei õnnestunud" + "Rakendus %1$s ei suutnud tuvastada sinu asukohta. Palun proovi hiljem uuesti." + "Sinu häälsõnumi üleslaadimine ei õnnestunud." + "Seda jututuba pole enam olemas või pole see kutse enam kehtiv." + "Sõnumit ei leidu" + "Rakendusel %1$s puudub õigus sinu asukohta tuvastada. Sa saad seda lubada süsteemi seadistustest." + "Rakendusel %1$s puudub õigus sinu asukohta tuvastada. Järgnevalt anna vastavad õigused." + "Rakendusel %1$s puudub õigus sinu nutiseadme mikrofoni kasutada. Järgnevalt anna õigused heli salvestamiseks." + "See võib olla põhjustatud võrgu- või serverivigadest." + "Selline jututoa aadress on juba olemas. Palun proovi muuta kas aadressi või jututoa nime" + "Mõned tähemärgid pole lubatud. Kasuta vaid tähti, numbreid ja neid kirjavahemärke ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" + "Mõned sõnumid on saatmata" + "Vabandust, ilmnes viga" + "Sündmuse saatja ja seadme omanik pole vastavuses." + "Selle krüptitud sõnumi tõepärasus pole selles seadmes tagatud." + "Krüptitud varem verifitseeritud kasutaja poolt" + "Pole krüptitud." + "Krüptitud tundmatu või kustutatud seadme poolt." + "Krüptitud seadme poolt, mida tema omanik pole verifitseerinud." + "Krüptitud verifitseerimata kasutaja poolt." + "🔐️ Liitu minuga rakenduses %1$s" + "Hei, suhtle minuga %1$s võrgus: %2$s" + "%1$s Android" + "Veast teatamiseks raputa nutiseadet ägedalt" + "Ekraanitõmmis" + "%1$s: %2$s" + "Valikud" + "Kustuta: %1$s" + "Seadistused" + "Meediafaili valimine ei õnnestunud. Palun proovi uuesti." + "Siia lisamiseks vajuta sõnumil ja vali „%1$s“." + "Et olulisi sõnumeid oleks lihtsam leida, tõsta nad esile" + + "%1$d esiletõstetud sõnum" + "%1$d esiletõstetud sõnumit" + + "Esiletõstetud sõnumid" + "Oma võrguidentiteedi lähtestamiseks suuname sind %1$s kasutajakonto halduse lehele. Hiljem suunatakse sind tagasi sama rakenduse juurde." + "Sa ei saa seda kinnitada? Ava oma kasutajakonto haldus ja lähtesta oma võrguidentiteet." + "Unusta verifitseerimine ja saada ikkagi" + "Sa võid jätta verifitseerimisvea tähelepanuta ja sõnumi ikkagi saata või katkestad saatmise ja peale kasutaja %1$s verifitseerimist proovid seda uuesti." + "Sinu sõnum on saatmata, kuna kasutaja %1$s verifitseeritud identiteet on lähtestatud." + "Saada sõnum ikkagi" + "%1$s kasutab ühte või enamat verifitseerimata seadet. Sa võid sõnumi ikkagi saata või katkestad selle ning ootad kuni %2$s on kõik oma seadmed verifitseerinud ning proovid seejärel uuesti." + "Sinu sõnum on saatmata, kuna %1$s pole verifitseerinud kõiki oma seadmeid" + "Üks või enam sinu seadet on verifitseerimata. Sa võid sõnumi ikkagi ära saata või katkestad saatmise ning proovid uuesti, kui oled kõik oma seadmed verifitseerinud." + "Kuna sul on üks või enam verifitseerimata seadet, siis sinu sõnum jäi saatmata" + "Muuda seadistusi" + "Halda kogukonda" + "Halda jututuba" + "Õigused" + "Muuda peakasutajaid või omanikke" + "Meediafaili töötlemine enne üleslaadimist ei õnnestunud. Palun proovi uuesti." + "Kasutaja andmete laadimine ei õnnestunud" + "Sõnum jututoas %1$s" + "Näita rohkem" + "Näita vähem" + "Sa juba vaatad seda jututuba!" + "%1$s / %2$s" + "%1$s esiletõstetud sõnumit" + "Laadin sõnumit…" + "Näita kõiki" + "Vestlus" + "Jaga asukohta" + "Jaga minu asukohta" + "Ava Apple Mapsis" + "Ava Google Mapsis" + "Ava OpenStreetMapis" + "Jaga seda asukohta" + "Sinu loodud kogukonnad ning need, millega oled liitunud." + "%1$s • %2$s" + "Kogukond: %1$s" + "Kogukonnad" + "Vaata liikmeid" + "Sõnum on saatmata, kuna kasutaja %1$s verifitseeritud identiteet on lähtestatud." + "Sõnum on saatmata, kuna %1$s pole verifitseerinud kõiki oma seadmeid." + "Kuna sa pole üks või enamgi oma seadet verifitseerinud, siis sinu sõnum on saatmata." + "Asukoht" + "Versioon: %1$s (%2$s)" + "et" + "Vanu sõnumeid ei saa selles seadmes näha" + "Ligipääsuks vanadele sõnumitele pead selle seadme verifitseerima" + "Sul pole ligipääsu antud sõnumile" + "Sõnumi dekrüptimine ei õnnestu" + "Kuna seade on verifitseerimata või saatja pole sind verifitseerinud, siis sõnumi näitamine on blokeeritud." + diff --git a/libraries/ui-strings/src/main/res/values-eu/translations.xml b/libraries/ui-strings/src/main/res/values-eu/translations.xml new file mode 100644 index 0000000..15bfa21 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-eu/translations.xml @@ -0,0 +1,383 @@ + + + "Abatarra" + "Ezabatu" + "Editatu abatarra" + "Zifraketaren xehetasunak" + "Ezkutatu pasahitza" + "Batu deira" + "Joan behera" + "Mugitu mapa nire kokapenera" + "Aipamenak soilik" + "Mutututa" + "Aipamen berriak" + "Mezu berriak" + "Beste erabiltzailearen abatarra" + "%1$d. orria" + "Eten" + "Ahots-mezua, iraupena: %1$s, uneko posizioa: %2$s" + "PINaren eremua" + "Erreproduzitu" + "Inkesta" + "Amaitutako inkesta" + "Erreakzionatu %1$s(e)kin" + "Erreakzionatu beste emoji batzuekin" + "%1$s(e)k eta %2$s(e)k irakurri dute" + + "%1$s(e)k eta beste %2$dek irakurri dute" + "%1$s(e)k eta beste %2$d(e)k irakurri dute" + + "%1$s(e)k irakurri du" + "Egin tap guztiak ikusteko" + "Kendu %1$s erreakzioa" + "Gelaren abatarra" + "Bidali fitxategiak" + "Erakutsi pasahitza" + "Hasi dei bat" + "Erabiltzailearen abatarra" + "Erabiltzailea-menua" + "Ikusi abatarra" + "Ikusi xehetasunak" + "Ahots-mezua, iraupena: %1$s" + "Grabatu ahots-mezua." + "Utzi grabatzeari" + "Zure abatarra" + "Onartu" + "Gehitu testua" + "Gehitu denbora-lerrora" + "Atzera" + "Deia" + "Utzi" + "Utzi oraingoz" + "Aukeratu argazkia" + "Garbitu" + "Itxi" + "Burutu egiaztapena" + "Berretsi" + "Berretsi pasahitza" + "Jarraitu" + "Kopiatu" + "Kopiatu testua" + "Kopiatu esteka" + "Kopiatu esteka mezura" + "Kopiatu testua" + "Sortu" + "Sortu gela" + "Desaktibatu" + "Desaktibatu kontua" + "Ukatu" + "Baztertu eta blokeatu" + "Ezabatu inkesta" + "Desgaitu" + "Baztertu" + "Baztertu" + "Eginda" + "Editatu" + "Editatu testua" + "Editatu inkesta" + "Gaitu" + "Amaitu inkesta" + "Sartu PINa" + "Amaitu" + "Pasahitza ahaztu duzu?" + "Birbidali" + "Joan atzera" + "Ezikusi" + "Gonbidatu" + "Gonbidatu jendea" + "Gonbidatu jendea %1$s(e)ra" + "Gonbidatu jendea %1$s(e)ra" + "Gonbidapenak" + "Elkartu" + "Informazio gehiago" + "Atera" + "Utzi elkarrizketa" + "Atera gelatik" + "Kargatu gehiago" + "Kudeatu kontua" + "Kudeatu gailuak" + "Bidali mezua" + "Hurrengoa" + "Ez" + "Orain ez" + "Ados" + "Ezarpenak" + "Ireki honekin…" + "Finkatu" + "Erantzun azkarra" + "Aipatu" + "Erreakzioa" + "Baztertu" + "Kendu" + "Kendu testua" + "Kendu mezua" + "Erantzun" + "Erantzun harian" + "Salatu" + "Eman errore baten berri" + "Salatu edukia" + "Salatu elkarrizketa" + "Salatu gela" + "Berrezarri" + "Berrezarri identitatea" + "Saiatu berriro" + "Saiatu berriro deszifratzen" + "Gorde" + "Bilatu" + "Bidali" + "Bidali editatutako mezua" + "Bidali mezua" + "Bidali ahots-mezua" + "Partekatu" + "Partekatu esteka" + "Erakutsi" + "Hasi saioa berriro" + "Amaitu saioa" + "Amaitu saioa edonola ere" + "Saltatu" + "Hasi" + "Hasi txata" + "Hasi egiaztapena" + "Sakatu mapa kargatzeko" + "Egin argazkia" + "Sakatu aukerak ikusteko" + "Saiatu berriro" + "Utzi finkatzeari" + "Ikusi" + "Ikusi denbora-lerroan" + "Ikusi iturburua" + "Bai" + "Bai, saiatu berriro" + "Orain zure zerbitzaria protokolo berri eta azkarrago batekin da bateragarria. Amaitu saioa eta hasi berriro protokolo berria erabiltzeko. Orain eginez gero, etorkizunean protokolo zaharra kentzen denean ez zaizu behartutako saioa ixtera." + "Bertsio-berritzea eskuragarri" + "Honi buruz" + "Testua gehitzen" + "Ezarpen aurreratuak" + "irudia" + "Estatistikak" + "Itxura" + "Audioa" + "Blokeatutako erabiltzaileak" + "Bunbuiloak" + "Deia hasi da" + "Txataren babeskopia" + "Arbelean kopiatu da" + "Copyrighta" + "Gela sortzen…" + "Eskaera bertan behera utzi da" + "Gelatik atera da" + "Gonbidapenari uko egin zaio" + "Iluna" + "Deszifratze-errorea" + "Garapen aukerak" + "Gailuaren IDa" + "Txata zuzena" + "Ez erakutsi berriro" + "Deskargak huts egin du" + "Deskargatzen" + "(Editatua)" + "Editatzen" + "Testua editatzen" + "Fitxategia hutsik dago" + "Zifratzea" + "Zifratzea gaituta" + "Sartu zure PINa" + "Errorea" + "Errore bat gertatu da, litekeena da mezu berrien jakinarazpenik ez jasotzea. Aztertu jakinarazpenen ezarpenak. + +Arrazoia: %1$s." + "Guztiak" + "Huts egin du" + "Gogokoa" + "Gogoko eginda" + "Fitxategia" + "Fitxategia ezabatu da" + "Fitxategia gorde da" + "Fitxategia Deskargak atalean gorde da" + "Birbidali mezua" + "Maiz erabilitakoa" + "GIFa" + "Irudia" + "%1$s(r)i erantzunez" + "Instalatu APK" + "Matrix IDa ezin da topatu eta, beraz, litekeena da gonbidapena ez jasotzea." + "Gelatik ateratzen" + "Argia" + "Lerroa arbelean kopiatu da" + "Esteka arbelean kopiatu da" + "Kargatzen…" + "Gehiago kargatzen…" + + "beste %d" + "beste %d" + + + "Kide %1$d" + "%1$d kide" + + "Mezua" + "Mezuen ekintzak" + "Mezuen antolaketa" + "Mezua kendu da" + "Modernoa" + "Mututu" + "Emaitzarik ez" + "Gelak ez du izenik" + "Zifratu gabe" + "Deskonektatuta" + "Kode irekiko lizentziak" + "edo" + "Pasahitza" + "Jendea" + "Esteka iraunkorra" + "Baimena" + "Finkatuta" + "Egiaztatu Interneteko konexioa" + "Itxaron…" + "Ziur inkesta hau amaitu nahi duzula?" + "Inkesta:%1$s" + "Boto guztira: %1$s" + "Emaitzak inkesta amaitu ondoren erakutsiko dira" + + "Boto %d" + "%d boto" + + "Prestatzen…" + "Pribatutasun-politika" + "Gela pribatua" + "Gune pribatua" + "Gela publikoa" + "Gune publikoa" + "Erreakzioa" + "Erreakzioak" + "Arrazoia" + "Berreskuratze-gakoa" + "Freskatzen…" + "%1$s(r)i erantzuten" + "Eman akats baten berri" + "Eman arazo baten berri" + "Salaketa bidali da" + "Testu aberatsaren editorea" + "Gela" + "Gelaren izena" + "adibidez, zure proiektuaren izena" + "Gordetako aldaketak" + "Gordetzen" + "Blokeo-pantaila" + "Bilatu norbait" + "Bilaketaren emaitzak" + "Segurtasuna" + "Ikusi du" + "Bidali" + "Bidaltzen…" + "Bidalketak huts egin du" + "Bidalita" + ". " + "Zerbitzaria ez da bateragarria" + "Zerbitzariaren URLa" + "Ezarpenak" + "Partekatutako kokapena" + "Saioa amaitzen" + "Arazoren bat egon da" + "Arazo bat topatu dugu. Saiatu berriro." + "Gunea" + "Txata hasten…" + "Pegatina" + "Arrakasta" + "Iradokizunak" + "Sinkronizatzen" + "Sistema" + "Testua" + "Hirugarrenei buruzko oharrak" + "Haria" + "Gaia" + "Zeri buruzko gela da?" + "Ezin da desenkriptatu" + "Egiaztatu gabeko gailu batetik bidalia" + "Igorlearen egiaztatutako identitatea berrezarri da" + "Ezin izan dira gonbidapenak erabiltzaile bati edo gehiagori bidali." + "Ezin d(ir)a gonbidapena(k) bidali" + "Desblokeatu" + "Aktibatu audioa" + "Deia ez da bateragarria" + "Gertaera ez da bateragarria" + "Erabiltzaile-izena" + "Egiaztaketa ezeztatuta" + "Egiaztaketa burututa" + "Egiaztapenak huts egin du" + "Egiaztatuta" + "Egiaztatu gailua" + "Egiaztatu identitatea" + "Egiaztatu erabiltzailea" + "Bideoa" + "Kalitate handia" + "Kalitate handiena baina fitxategi-tamaina handiagoa" + "Kalitate txikia" + "Igoera abiadura azkarrena eta fitxategi-tamaina txikiena" + "Erdiko kalitatea" + "Kalitate eta igoera-abiaduraren arteko oreka" + "Ahots-mezua" + "Zain…" + "Mezu honen zain" + "Zeu" + "%1$s(r)en identitatea berrezarri da. %2$s" + "%1$s(r)en %2$s identitatea berrezarri da. %3$s" + "%1$s(r)en identitatea berrezarri da." + "%1$s(r)en %2$s identitatea berrezarri da. %3$s" + "%1$s estekak %2$s gunera zaramatza. + +Ziur jarraitu nahi duzula?" + "Egiaztatu honako esteka" + "Bideoen igoera-kalitatea" + "Fitxategiaren tamaina handiegia da igotzeko" + "Gela salatu da" + "Gela salatu eta utzi da" + "Baieztapena" + "Errorea" + "Arrakasta" + "Abisua" + "Zure aldaketak ez dira gorde. Ziur itzuli nahi duzula?" + "Aldaketak gorde?" + "Hautatu bideoaren igoera-kalitatea" + "Huts egin du esteka iraunkorra sortzeak" + "%1$s ezin izan da mapa kargatu. Saiatu berriro geroago." + "Huts egin du mezuak kargatzeak" + "%1$s ezin izan da zure kokapena atzitu. Saiatu berriro geroago." + "Ahots-mezua igotzeak huts egin du" + "Ez da mezua aurkitu" + "%1$sek ez du zure kokapena atzitzeko baimenik. Sarbidea Ezarpenetan gaitu dezakezu." + "%1$sek ez du zure kokapena atzitzeko baimenik. Gaitu sarbidea behean." + "%1$s(e)k ez du mikrofonoa atzitzeko baimenik. Gaitu sarbidea ahots-mezua grabatzeko." + "Sarearen edo zerbitzariaren arazoren bategatik izan daiteke." + "Gela badago lehendik ere. Saiatu gelaren helbidearen eremua editatzen edo aldatu gelaren izena" + "Karaktere batzuk ez daude baimenduta. Hizkiak, zifrak, eta ondorengo ikurrak onartzen dira bakarrik: ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" + "Mezu batzuk ez dira bidali" + "Barka, errore bat gertatu da" + "Ez dago zifratuta." + "🔐️ Zatoz nirekin %1$s(e)ra" + "%1$s Android" + "Astindu erroreen berri emateko" + "Aukerak" + "Huts egin du multimedia aukeratzeak, saiatu berriro." + + "Finkatutako mezu %1$d" + "Finkatutako %1$d mezu" + + "Finkatutako mezuak" + "Bidali mezua hala ere" + "%2$s(e)tik %1$s" + "Mezua kargatzen…" + "Ikusi guztia" + "Txata" + "Partekatu kokapena" + "Partekatu nire kokapena" + "Ireki Apple Maps-en" + "Ireki Google Maps-en" + "Ireki OpenStreetMap-en" + "Partekatu kokapen hau" + "Kokapena" + "Bertsioa: %1$s (%2$s)" + "eu" + "Iraganeko mezuak ez daude gailu honetan eskuragarri" + "Ezin da mezua deszifratu" + diff --git a/libraries/ui-strings/src/main/res/values-fa/translations.xml b/libraries/ui-strings/src/main/res/values-fa/translations.xml new file mode 100644 index 0000000..1656bc3 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-fa/translations.xml @@ -0,0 +1,407 @@ + + + "افزودن واکنش: %1$s" + "چهرک" + "حذف" + + "%1$d رقم وارد شده" + "%1$d رقم وارد شده" + + "ویرایش چهرک" + "نشانی کامل %1$s خواهد بود" + "جزییات رمزنگاری" + "نهفتن گذرواژه" + "پیوستن به تماس" + "به پایین بروید" + "جابه‌جایی نقشه به مکانم" + "فقط اشاره‌ها" + "خموش" + "اشاره‌های جدید" + "پیام‌های جدید" + "تماس خروجی" + "چهرک دیگر کاربران" + "صفحهٔ %1$d" + "مکث" + "پیام صوتی. طول %1$s. موقعیت کنونی: %2$s" + "زمینهٔ پین" + "پخش" + "نظرسنجی" + "نظرسنجی پایان یافته" + "واکنش با %1$s" + "واکنش با شکلک‌های دیگر" + "خوانده به دست %1$s و %2$s" + + "خوانده شده توسط%1$s و%2$d نفر دیگر" + "خوانده شده توسط%1$s و%2$d نفر دیگر" + + "خوانده به دست %1$s" + "زدن برای نمایش همه" + "برداشتن واکنش با %1$s" + "برداشتن واکنش با %1$s" + "چهرک اتاق" + "ارسال پرونده‌ها" + "نیازمند کنش محدود به زمان" + "نمایش گذرواژه" + "آغاز یک تماس" + "چهرک کاربر" + "فهرست کاربر" + "دیدن چهرک" + "دیدن جزییات" + "پیام صوتی. طول %1$s" + "ضبط پیام صوتی." + "توقّف ضبط" + "چهرکتان" + "پذیرش" + "افزودن عنوان" + "افزودن به خط زمانی" + "بازگشت" + "تماس" + "لغو" + "لغو برای امنون" + "گزینش عکس" + "پاک سازی" + "بستن" + "تکمیل تأیید" + "تأیید" + "تأیید گذرواژه" + "ادامه" + "رونوشت" + "رونوشت از عنوان7" + "رونوشت از پیوند" + "رونوشت از پیوند پیام" + "رونوشت از متن" + "ایجاد" + "ایجاد اتاق" + "غیرفعّال" + "غیرفعّال‌سازی حساب" + "رد" + "رد و انسداد" + "حذف نظرسنجی" + "ناگزینش همه" + "از کار انداختن" + "دور ریختن" + "دورانداختن" + "انجام شد" + "ویرایش" + "ویرایش عنوان" + "ویرایش نظرسنجی" + "به کار انداختن" + "پایان نظرسنجی" + "ورود پین" + "پایان" + "گذرواژه را فراموش کردید؟" + "پیشروی" + "پس‌روی" + "نادیده‌گرفتن" + "دعوت" + "دعوت افراد" + "دعوت به %1$s" + "دعوت افراد به %1$s" + "دعوت‌ها" + "پیوستن" + "بیش‌تر دانستن" + "ترک" + "ترک گفت‌وگو" + "ترک اتاق" + "ترک فضا" + "بار کردن بیش‌تر" + "مدیریت حساب" + "مدیریت افزاره‌ها" + "پیام" + "بعدی" + "نه" + "الآن نه" + "قبول" + "گشودن فهرست بافتاری" + "تنظیمات" + "گشودن با" + "سنجاق" + "پاسخ سریع" + "نقل قول" + "واکنش" + "رد کردن" + "حذف" + "برداشتن عنوان" + "برداشتن پیام‌ها" + "پاسخ" + "پاسخ در رشته" + "گزارش" + "گزارش اشکال" + "گزارش محتوا" + "گزارش گفت‌وگو" + "گزارش اتاق" + "بازنشانی" + "بازنشانی هویت" + "تلاش دوباره" + "تلاش دوباره برای رمزگشایی" + "ذخیره" + "جست‌وجو" + "گزینش همه" + "فرستادن" + "فرستادن پیام ویراسته" + "فرستادن پیام" + "فرستادن پیام صوتی" + "هم‌رسانی" + "هم‌رسانی پیوند" + "نمایش" + "ورود دوباره" + "خروج" + "خروج به هر صورت" + "پرش" + "آغاز" + "آغاز گپ" + "آغاز تأیید" + "زدن برای بار کردن نقشه" + "عکس گرفتن" + "زدن برای گزینه‌ها" + "تلاش دوباره" + "سنجاق نکردن" + "نما" + "دیدن در خط زمانی" + "دیدن منبع" + "بله" + "بله. تلاش دوباره" + "ارتقا موجود است" + "درباره" + "سیاست استفادهٔ پذیرفتنی" + "افزودن یک حساب" + "افزودن حسابی دیگر" + "افزودن عنوان" + "تنظیمات پیش‌رفته" + "تجزیه و تحلیل" + "اتاق را ترک کردید" + "ظاهر" + "صدا" + "آزمایشی" + "کاربران مسدود" + "حباب‌ها" + "تماس آغاز شد" + "پشتیبان گپ" + "در تخته‌گیره رونوشت شد" + "حق رونوشت" + "ایجاد کردن اتاق…" + "درخواست لغو شد" + "اتاق را ترک کرد" + "فضا را ترک کرد" + "دعوت لغو شد" + "تیره" + "خطای رمزگشایی" + "توضیح" + "گزینه‌های توسعه دهنده" + "شناسه دستگاه" + "گپ مستقیم" + "این مورد را دوباره نشان نده" + "بارگیری شکست خورد" + "بار گرفتن" + "(ویراسته)" + "ویرایش" + "ویراستن عنوان" + "* %1$s %2$s" + "پروندهٔ خالی" + "رمزنگاری" + "رمزنگاری به کار افتاده" + "پینتان را وارد کنید" + "خطا" + "هرکسی" + "شکست خورد" + "برگزیده" + "برگزیده" + "پرونده" + "پرونده حذف شد" + "پرونده ذخیره شد" + "پرونده در بارگیری‌ها ذخیره شد" + "هدایت پیام" + "پراستفاده" + "جیف" + "تصویر" + "در پاسخ به %1$s" + "نصب APK" + "شناسهٔ ماتریکس نتوانست پیدا شود. ممکن است دعوت نرسیده باشد." + "ترک کردن اتاق" + "ترک کردن فضا" + "روشن" + "خط در تخته‌گیره رونوشت شد" + "پیوند در تخته‌گیره رونوشت شد" + "بار کردن…" + "بار کردن بیش‌تر…" + + "%1$d عضو" + "%1$d عضو" + + "پیام" + "کنش‌های پیام" + "جینش پیام" + "پیام برداشته شد" + "نوین" + "بی‌صدا" + "%1$s ‏(%2$s)" + "بدون نتیجه" + "بدون نام اتاق" + "بدون نام فضا" + "رمزنگاری نشده" + "برون‌خط" + "پروانه‌های نرم‌افزاری آزاد" + "یا" + "گذرواژه" + "افراد" + "پایاپیوند" + "اجازه" + "سنجاق شده" + "لطفاً اتّصال اینترنتیتان را بررسی کنید" + "لطفا صبر کنید…" + "مطئنید که می‌خواهید این نظرسنجی را پایان دهید؟" + "نظرسنجی: %1$s" + "مجموع آرا: %1$s" + "نتیجه‌ها پس از پایان نظرسنجی نشان داده خواهند شد" + + "%d رأی" + "%d رأی" + + "آماده سازی…" + "سیاست محرمانگی" + "اتاق خصوصی" + "فضای خصوصی" + "اتاق عمومی" + "فضای عمومی" + "واکنش" + "واکنش‌ها" + "دلیل" + "کلید بازیابی" + "تازه سازی…" + "پاسخ دادن به %1$s" + "گزارش یک اشکال" + "گزارش مشکل" + "گزارش ثبت شد" + "ویرایشگر متن غنی" + "اتاق" + "نام اتاق" + "برای نمونه نام پروژه‌تان" + "تغییرات ذخیره شده" + "ذخیره کردن" + "قفل صفحه" + "جست‌وجوی افراد" + "نتایج جست‌وجو" + "امنیت" + "دیده شده به دست" + "گزینش حساب" + "فرستادن به" + "فرستادن…" + "فرستادن شکست خورد" + "فرستاده" + ". " + "کارساز پشتیبانی نمی‌شود" + "کارساز ناپاسخگو" + "نشانی کارساز" + "تنظیمات" + "هم‌رسانی فضا" + "مکان هم‌رسانده" + "فضای اشتراکی" + "خارج شدن" + "چیزی اشتباه پیش رفت" + "فاصله" + "آغازیدن گپ…" + "عکس برگردان" + "موفّقیت" + "پیشنهادها" + "هم‌گام ساختن" + "سامانه" + "متن" + "تذکّرهای سوم‌شخص" + "رشته" + "موضوع" + "این اتاق دربارهٔ چیست؟" + "ناتوان در رمزگشایی" + "به این پیام دسترسی ندارید" + "دعوت‌ها نتوانستند به کاربرانی برسند." + "ناتوان در فرستادن دعوت(ها)" + "قفل‌گشایی" + "باصدا" + "تماس پشتیبانی نشده" + "رویداد پشتیبانی نشده" + "نام کاربری" + "تأیید لغو شد" + "تأیید کامل شد" + "صحت‌سنجی شکست خورد" + "تأیید‌شده" + "تأیید افزاره" + "تأیید هویت" + "تأیید کاربر" + "ویدیو" + "کیفیت بالا" + "کیفیت پایین" + "کیفیت استاندارد" + "پیام صوتی" + "در انتظار…" + "در انتظار این پیام" + "شما" + "هویت %1$s بازنشانی شد. %2$s" + "(%1$s)" + "انصراف از تأیید" + "این پیوند را دوباره بررسی کنید" + "کیفیت بارگذاری ویدیو" + "اتاق گزارش شد" + "گزارش و از اتاق خارج شد" + "تایید" + "خطا" + "موفّقیت" + "هشدار" + "تغییراتتان ذخیره نشده‌اند. مطمئنید که می‌خواهید برگردید؟" + "ذخیرهٔ تغییرات؟" + "حست‌وجوی شکلک‌ها" + "شکست در ایجاد پایاپیوند" + "%1$s نتوانست نقشه را بارگیری کند. لطفا بعدا دوباره امتحان کنید." + "شکست در بار کردن پیام‌ها" + "‏%1$s نمی تواند به مکانتان دسنترسی داشته باشد. لطفا بعدا دوباره امتحان کنید." + "شکست در بارگذاری پیام صوتیتان." + "پیام پیدا نشد" + "‏%1$s اجازهٔ دسترسی به مکانتان را ندارد. می توانید دسترسی را در تنظیمات به کار بیندازید." + "%1$s اجازهٔ دسترسی به مکانتان را ندارد. دسترسی را در زیر به کار بیندازید." + "%1$s اجازه دسترسی به میکروفون شما را ندارد. دسترسی را برای ضبط پیام صوتی فعال کنید." + "برخی پیام‌ها ارسال نشده‌اند" + "متأسفیم ، خطایی رخ داد" + "اعتبار این پیام رمز شده نمی‌تواند روی این افزاره تأیید شود." + "رمز شده به دست کاربری از پیش تأیید شده." + "رمز نشده." + "رمز شده به دست افزاره‌ای ناشناخته یا حذف شده." + "رمز شده به دست افزاره‌ای که از سوی مالکش تأیید نشده." + "رمز شده به دست کاربری تأیید نشده." + "🔐️ پییوستن به من روی %1$s" + "درود. با من روی %1$s صحبت کن: %2$s" + "%1$s اندروید" + "Rageshake برای گزارش اشکال" + "نماگرفت" + "%1$s: %2$s" + "گزینه‌ها" + "تنظیمات" + "گزینش رسانه شکست خورد. لطفاً دوباره تلاش کنید." + "پیام‌های سنجاق شده" + "داردید برای بازنشانی هویتتان به حساب %1$s می‌روید. پس از آن به کاره برگردانده خواهید شد." + "فرستادن پیام به هر روی" + "ویرایش مدیران یا مالکان" + "پردازش رسانه برای بارگذاری شکست خورد. لطفاً دوباره تلاش کنید." + "نمی توان جزئیات کاربر را بازیابی کرد" + "پیام در %1$s" + "گسترش" + "کاهش" + "%1$s از %2$s" + "%1$s پیام‌های سنجاق شده" + "بار کردن پیام‌ها…" + "نمایش همه" + "گپ" + "هم‌رسانی مکان" + "هم‌رسانی مکانم" + "گشودن در نقشه‌های اپل" + "گشودن در نقشه‌های گوگل" + "گشودن در اوپن‌استریت‌مپ" + "هم‌رسانی این مکان" + "فضاهایی که ساخته یا پیوسته‌اید." + "%1$s • %2$s" + "‏%1$s فضا" + "فضاها" + "دیدن اعضا" + "مکان" + "نگارش : %1$s (%2$s)" + "fa" + "en" + "به این پیام دسترسی ندارید" + diff --git a/libraries/ui-strings/src/main/res/values-fi/translations.xml b/libraries/ui-strings/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000..bc23e78 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-fi/translations.xml @@ -0,0 +1,486 @@ + + + "Lisää reaktio: %1$s" + "Avatar" + "Pienennä viestin tekstikenttä" + "Poista" + + "%1$d numero syötetty" + "%1$d numeroa syötetty" + + "Muokkaa avataria" + "Täysi osoite tulee olemaan %1$s" + "Salauksen tiedot" + "Laajenna viestin tekstikenttä" + "Piilota salasana" + "Liity puheluun" + "Siirry loppuun" + "Siirrä kartta sijaintiini" + "Vain maininnat" + "Mykistetty" + "Uusia mainintoja" + "Uusia viestejä" + "Käynnissä oleva puhelu" + "Toisen käyttäjän avatar" + "Sivu %1$d" + "Keskeytä" + "Ääniviesti, kesto: %1$s, nykyinen sijainti: %2$s" + "PIN-kenttä" + "Toista" + "Kysely" + "Päättynyt kysely" + "Lisää reaktio: %1$s" + "Reagoi muilla emojeilla" + "%1$s ja %2$s on lukenut viestin" + + "%1$s ja %2$d muu on lukenut viestin" + "%1$s ja %2$d muuta on lukenut viestin" + + "%1$s on lukenut viestin" + "Näytä kaikki napauttamalla" + "Poista reaktio: %1$s" + "Poista reaktio: %1$s" + "Huoneen avatar" + "Lähetä tiedostoja" + "Aikarajoitettu toimenpide vaaditaan, sinulla on yksi minuutti aikaa vahvistaa" + "Näytä salasana" + "Aloita puhelu" + "Haudattu huone" + "Käyttäjän avatar" + "Käyttäjävalikko" + "Näytä avatar" + "Näytä tiedot" + "Ääniviesti, kesto: %1$s" + "Nauhoita ääniviesti." + "Lopeta nauhoittaminen" + "Avatarisi" + "Hyväksy" + "Lisää kuvateksti" + "Lisää aikajanalle" + "Takaisin" + "Soita" + "Peruuta" + "Peruuta toistaiseksi" + "Valitse kuva" + "Tyhjennä" + "Sulje" + "Viimeistele vahvistus" + "Vahvista" + "Vahvista salasana" + "Jatka" + "Kopioi" + "Kopioi kuvateksti" + "Kopioi linkki" + "Kopioi linkki viestiin" + "Kopioi teksti" + "Luo" + "Luo huone" + "Deaktivoi" + "Deaktivoi tili" + "Hylkää" + "Hylkää ja estä" + "Poista kysely" + "Poista kaikki valinnat" + "Poista käytöstä" + "Hylkää" + "Sulje" + "Valmis" + "Muokkaa" + "Muokkaa kuvatekstiä" + "Muokkaa kyselyä" + "Ota käyttöön" + "Lopeta kysely" + "Syötä PIN-koodi" + "Valmis" + "Unohditko salasanan?" + "Välitä" + "Takaisin" + "Siirry rooleihin ja oikeuksiin" + "Siirry asetuksiin" + "Ohita" + "Kutsu" + "Kutsu henkilöitä" + "Kutsu ihmisiä %1$s -sovellukseen" + "Kutsu ihmisiä %1$s -sovellukseen" + "Kutsut" + "Liity" + "Lue lisää" + "Poistu" + "Poistu keskustelusta" + "Poistu huoneesta" + "Poistu tilasta" + "Lataa lisää" + "Hallitse tiliä" + "Hallitse laitteita" + "Lähetä viesti" + "Pienennä" + "Seuraava" + "Ei" + "Ei nyt" + "OK" + "Avaa kontekstivalikko" + "Asetukset" + "Avaa sovelluksessa" + "Kiinnitä" + "Pikavastaus" + "Lainaa" + "Reagoi" + "Hylkää" + "Poista" + "Poista kuvateksti" + "Poista viesti" + "Vastaa" + "Vastaa ketjuun" + "Ilmoita" + "Ilmoita virheestä" + "Ilmoita sisällöstä" + "Ilmoita keskustelusta" + "Ilmoita huoneesta" + "Nollaa" + "Nollaa identiteetti" + "Yritä uudelleen" + "Yritä salauksen purkamista uudelleen" + "Tallenna" + "Hae" + "Valitse kaikki" + "Lähetä" + "Lähetä muokattu viesti" + "Lähetä viesti" + "Lähetä ääniviesti" + "Jaa" + "Jaa linkki" + "Näytä" + "Kirjaudu uudelleen" + "Kirjaudu ulos" + "Kirjaudu ulos silti" + "Ohita" + "Aloita" + "Aloita keskustelu" + "Aloita vahvistus" + "Lataa kartta napauttamalla" + "Ota kuva" + "Näytä vaihtoehdot napauttamalla" + "Yritä uudelleen" + "Poista kiinnitys" + "Näytä" + "Näytä aikajanalla" + "Näytä lähde" + "Kyllä" + "Kyllä, yritä uudelleen" + "Palvelimesi tukee nyt uutta, nopeampaa protokollaa. Kirjaudu ulos ja takaisin sisään päivittääksesi nyt. Jos teet tämän nyt, voit välttää pakotetun uloskirjautumisen, kun vanha protokolla poistetaan myöhemmin." + "Päivitys saatavilla" + "Tietoa" + "Hyväksyttävän käytön käytäntö" + "Lisää tili" + "Lisää toinen tili" + "Lisätään kuvatekstiä" + "Edistyneet asetukset" + "kuva" + "Analytiikka" + "Poistuit huoneesta" + "Sinut kirjattiin ulos istunnosta" + "Ulkoasu" + "Ääni" + "Beeta" + "Estetyt käyttäjät" + "Kuplat" + "Puhelu alkoi" + "Keskustelujen varmuuskopiointi" + "Kopioitu leikepöydälle" + "Tekijänoikeudet" + "Luodaan huonetta…" + "Pyyntö peruutettu" + "Poistuit huoneesta" + "Poistuit tilasta" + "Kutsu hylätty" + "Tumma" + "Salauksen purkuvirhe" + "Kuvaus" + "Kehittäjän asetukset" + "Laitteen tunnus" + "Yksityinen keskustelu" + "Älä näytä tätä uudelleen" + "Lataus epäonnistui" + "Ladataan" + "(muokattu)" + "Muokataan viestiä" + "Muokataan kuvatekstiä" + "* %1$s %2$s" + "Tyhjä tiedosto" + "Salaus" + "Salaus käytössä" + "Syötä PIN-koodisi" + "Virhe" + "Tapahtui virhe. Et välttämättä saa ilmoituksia uusista viesteistä. Tee ilmoitusten vianmääritys asetuksista. + +Syy: %1$s." + "Kaikki" + "Epäonnistui" + "Lisää suosikkeihin" + "Lisätty suosikkeihin" + "Tiedosto" + "Tiedosto poistettu" + "Tiedosto tallennettu" + "Tiedosto tallennettu Lataukset-kansioon" + "Välitä viesti" + "Usein käytetyt" + "GIF" + "Kuva" + "Vastauksena käyttäjälle %1$s" + "Asenna APK" + "Tätä Matrix-tunnusta ei löytynyt, joten kutsu ei välttämättä mene perille." + "Poistutaan huoneesta" + "Poistutaan tilasta" + "Vaalea" + "Rivi kopioitu leikepöydälle" + "Linkki kopioitu leikepöydälle" + "Ladataan…" + "Ladataan lisää…" + + "%d muu" + "%d muuta" + + + "%1$d Jäsen" + "%1$d Jäsentä" + + "Viesti" + "Viestitoiminnot" + "Viestien asettelu" + "Viesti poistettu" + "Moderni" + "Mykistä" + "%1$s (%2$s)" + "Ei tuloksia" + "Nimetön huone" + "Nimetön tila" + "Ei salattu" + "Ei yhteyttä" + "Avoimen lähdekoodin lisenssit" + "tai" + "Salasana" + "Ihmiset" + "Pysyvä linkki" + "Lupa" + "Kiinnitetty" + "Tarkista internet-yhteytesi" + "Odota hetki…" + "Haluatko varmasti lopettaa tämän kyselyn?" + "Kysely: %1$s" + "Ääniä yhteensä: %1$s" + "Tulokset näkyvät kyselyn päätyttyä" + + "%d ääni" + "%d ääntä" + + "Valmistellaan…" + "Tietosuojakäytäntö" + "Yksityinen huone" + "Yksityinen tila" + "Julkinen huone" + "Julkinen tila" + "Reaktio" + "Reaktiot" + "Syy" + "Palautusavain" + "Päivitetään…" + + "%1$d vastaus" + "%1$d vastausta" + + "Vastataan käyttäjälle %1$s" + "Ilmoita virheestä" + "Ilmoita ongelmasta" + "Ilmoitus lähetetty" + "Rikastettu tekstieditori" + "Huone" + "Huoneen nimi" + "esim. projektisi nimi" + + "%1$d Huone" + "%1$d Huonetta" + + "Muutokset tallennettu" + "Tallennetaan" + "Näyttölukko" + "Etsi jotakuta" + "Hakutulokset" + "Turvallisuus" + "Nähneet henkilöt" + "Valitse tili" + "Jaa" + "Lähetetään…" + "Lähetys epäonnistui" + "Lähetetty" + ". " + "Palvelin ei ole tuettu" + "Palvelimeen ei saada yhteyttä" + "Palvelimen osoite" + "Asetukset" + "Jaa tila" + "Jaettu sijainti" + "Jaettu tila" + "Kirjaudutaan ulos" + "Jokin meni pieleen" + "Kohtasimme ongelman. Yritä uudelleen." + "Tila" + + "%1$d Tila" + "%1$d Tilaa" + + "Aloitetaan keskustelua…" + "Tarra" + "Onnistui" + "Ehdotukset" + "Synkronoidaan" + "Järjestelmän oletus" + "Teksti" + "Kolmannen osapuolen ilmoitukset" + "Viestiketju" + "Aihe" + "Mistä tässä huoneessa on kyse?" + "Salauksen purkaminen ei onnistunut" + "Lähetetty suojaamattomasta laitteesta" + "Sinulla ei ole oikeutta lukea tätä viestiä" + "Lähettäjän vahvistettu identiteetti nollattiin" + "Kutsujen ei voitu lähettää yhdelle tai useammalle käyttäjälle." + "Kutsujen lähettäminen ei onnistunut" + "Avaa" + "Poista mykistys" + "Puhelu, jota ei tueta" + "Tapahtumaa ei tueta" + "Käyttäjänimi" + "Vahvistus peruttu" + "Vahvistus suoritettu" + "Vahvistus epäonnistui" + "Vahvistettu" + "Vahvista laite" + "Vahvista identiteetti" + "Vahvista käyttäjä" + "Video" + "Korkea laatu" + "Paras laatu, mutta suurempi tiedostokoko" + "Heikko laatu" + "Nopein lähetysnopeus ja pienin tiedostokoko" + "Normaali laatu" + "Tasapainotettu laatu ja lähetysnopeus" + "Ääniviesti" + "Odotetaan…" + "Odotetaan viestiä" + "Sinä" + "Käyttäjän %1$s identiteetti nollattiin. %2$s" + "Käyttäjän %1$s %2$s identiteetti nollattiin. %3$s" + "(%1$s)" + "Käyttäjän %1$s identiteetti nollattiin." + "Käyttäjän %1$s %2$s identiteetti nollattiin. %3$s" + "Peruuta vahvistus" + "Linkki %1$s on viemässä sinua toiselle sivustolle %2$s + +Haluatko varmasti jatkaa?" + "Tarkista tämä linkki" + "Valitse lähettämäsi videoiden oletuslaatu." + "Videon lähetyslaatu" + "Suurin sallittu tiedostokoko on: %1$s" + "Tiedostokoko on liian suuri lähetettäväksi" + "Huone ilmoitettu" + "Ilmoitettu ja poistuttu huoneesta" + "Vahvistus" + "Virhe" + "Onnistui" + "Varoitus" + "Muutoksiasi ei ole tallennettu. Haluatko varmasti palata takaisin?" + "Tallennetaanko muutokset?" + "Suurin sallittu tiedostokoko on: %1$s" + "Valitse lähetettävän videon laatu." + "Valitse videon lähetyslaatu" + "Etsi emojeja" + "Olet jo kirjautuneena tälle laitteelle käyttäjällä %1$s." + "Kotipalvelimesi on päivitettävä tukemaan Matrix Authentication Serviceä ja tilin luomista." + "Pysyvän linkin luominen epäonnistui" + "%1$s ei pystynyt lataamaan karttaa. Yritä myöhemmin uudelleen." + "Viestien lataaminen epäonnistui" + "%1$s ei päässyt käsiksi sijaintiisi. Yritä myöhemmin uudelleen." + "Ääniviestin lähettäminen epäonnistui." + "Huone ei ole enää olemassa tai kutsu ei ole enää voimassa." + "Viestiä ei löytynyt" + "%1$s -sovelluksella ei ole lupaa sijaintiisi. Voit sallia sen asetuksista." + "%1$s -sovelluksella ei ole lupaa sijaintiisi. Voit sallia sen painamalla alla olevaa nappia." + "%1$s -sovelluksella ei ole lupaa käyttää mikrofoniasi. Anna lupa, jotta voit nauhoittaa ääniviestejä." + "Tämä voi johtua verkko- tai palvelinongelmista." + "Tämä huoneen osoite on jo käytössä, yritä muokata huoneen osoitekenttää tai muuta huoneen nimeä" + "Jotkin merkit eivät ole sallittuja. Vain kirjaimet, numerot ja seuraavat symbolit ovat tuettuja ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" + "Joitakin viestejä ei ole lähetetty" + "Anteeksi, tapahtui virhe" + "Tapahtuman lähettäjä ei vastaa sitä lähettäneen laitteen omistajaa." + "Tämän salatun viestin aitoutta ei voida taata tällä laitteella." + "Aiemmin vahvistetun käyttäjän salaama." + "Ei salattu." + "Tuntemattoman tai poistetun laitteen salaama." + "Salattu laitteella, jota sen omistaja ei ole vahvistanut." + "Vahvistamattoman käyttäjän salaama." + "🔐️ Liity seuraani %1$s -sovelluksessa" + "Hei, keskustele kanssani %1$s -sovelluksessa: %2$s" + "%1$s Android" + "Raivostunut ravistaminen ilmoittaa virheestä" + "Näyttökuva" + "%1$s: %2$s" + "Vaihtoehdot" + "Poista %1$s" + "Asetukset" + "Median valinta epäonnistui, yritä uudelleen." + "Paina viestiä ja valitse “%1$s” lisätäksesi sen tänne." + "Kiinnitä tärkeät viestit, jotta ne löytyvät helposti." + + "%1$d kiinnitetty viesti" + "%1$d kiinnitettyä viestiä" + + "Kiinnitetyt viestit" + "Olet siirtymässä %1$s -tilillesi nollaamaan identiteettisi. Tämän jälkeen sinut ohjataan takaisin sovellukseen." + "Etkö voi vahvistaa? Siirry tilillesi ja nollaa identiteettisi." + "Peruuta vahvistus ja lähetä" + "Voit peruuttaa vahvistuksen ja lähettää tämän viestin silti, tai voit peruuttaa viestin lähettämisen toistaiseksi ja yrittää uudelleen myöhemmin, kun olet vahvistanut käyttäjän %1$s uudelleen." + "Viestiäsi ei lähetetty, koska käyttäjän %1$s vahvistettu identiteetti nollattiin" + "Lähetä viesti silti" + "%1$s käyttää yhtä tai useampaa vahvistamatonta laitetta. Voit lähettää viestin silti tai voit peruuttaa sen toistaiseksi ja yrittää myöhemmin uudelleen, kun %2$s on vahvistanut kaikki laitteensa." + "Viestiäsi ei lähetetty, koska %1$s ei ole vahvistanut kaikkia laitteitaan." + "Yksi tai useampi laitteistasi on vahvistamaton. Voit lähettää viestin silti tai peruuttaa sen toistaiseksi ja yrittää uudelleen myöhemmin, kun olet vahvistanut kaikki laitteesi." + "Viestiäsi ei lähetetty, koska et ole vahvistanut yhtä tai useampaa laitettasi." + "Asetusten muuttaminen" + "Tilan hallitseminen" + "Huoneiden hallitseminen" + "Oikeudet" + "Muokkaa ylläpitäjiä tai omistajia" + "Median käsittely epäonnistui, yritä uudelleen." + "Käyttäjän tietojen hakeminen epäonnistui" + "Viesti huoneessa %1$s" + "Laajenna" + "Pienennä" + "Katselet jo tätä huonetta!" + "%1$s / %2$s" + "Kiinnitetty viesti %1$s" + "Viestiä ladataan…" + "Näytä kaikki" + "Keskustelu" + "Jaa sijainti" + "Jaa sijaintini" + "Avaa Apple Mapsissa" + "Avaa Google Mapsissa" + "Avaa OpenStreetMapissa" + "Jaa tämä sijainti" + "Luomasi tai liittymäsi tilat." + "%1$s • %2$s" + "%1$s tila" + "Tilat" + "Näytä jäsenet" + "Viestiä ei lähetetty, koska käyttäjän %1$s vahvistettu identiteetti nollattiin." + "Viestiä ei lähetetty, koska %1$s ei ole vahvistanut kaikkia laitteitaan." + "Viestiä ei lähetetty, koska et ole vahvistanut yhtä tai useampaa laitettasi." + "Sijainti" + "Versio: %1$s (%2$s)" + "fi" + "Viestihistoria ei ole saatavilla tällä laitteella" + "Sinun on vahvistettava tämä laite, jotta pääset käsiksi viestihistoriaan." + "Sinulla ei ole oikeutta lukea tätä viestiä" + "Viestin salauksen purkaminen ei onnistu" + "Tämä viesti estettiin, koska laitettasi ei ole vahvistettu tai koska lähettäjän on vahvistettava identiteettisi." + 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 0000000..11834a4 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -0,0 +1,486 @@ + + + "Ajouter une réaction: %1$s" + "Avatar" + "Réduire la taille du composeur" + "Supprimer" + + "%1$d chiffre saisi" + "%1$d chiffres saisis" + + "Modifier l’avatar" + "L’adresse complète sera %1$s" + "Détails du chiffrement" + "Augmenter la taille du composeur" + "Masquer le mot de passe" + "Rejoindre l’appel" + "Retourner à la fin de la conversation" + "Déplacer la carte vers ma position" + "Mentions uniquement" + "En sourdine" + "Nouvelles mentions" + "Nouveaux messages" + "Appel en cours" + "Avatar de l’autre utilisateur" + "Page %1$d" + "Pause" + "Message vocal, durée: %1$s, position actuelle: %2$s" + "Code PIN" + "Lecture" + "Sondage" + "Sondage terminé" + "Réagir avec %1$s" + "Réagir avec d’autres émojis" + "Lu par %1$s et %2$s" + + "Lu par %1$s et %2$d autre" + "Lu par %1$s et %2$d autres" + + "Lu par %1$s" + "Taper pour voir toute la liste" + "Supprimer la réaction avec %1$s" + "Supprimer la réaction avec %1$s" + "Avatar du salon" + "Envoyer des fichiers" + "Action limitée dans le temps requise, vous avez une minute pour effectuer la vérification" + "Afficher le mot de passe" + "Démarrer un appel" + "Salon clôturé" + "Avatar de l’utilisateur" + "Menu utilisateur" + "Voir l’avatar" + "Afficher les détails" + "Message vocal, durée: %1$s" + "Enregistrer un message vocal." + "Arrêter l’enregistrement" + "Votre avatar" + "Accepter" + "Ajouter une légende" + "Ajouter à la discussion" + "Retour" + "Appel" + "Annuler" + "Annuler pour l’instant" + "Choisir une photo" + "Effacer" + "Fermer" + "Terminer la vérification" + "Confirmer" + "Confirmez le mot de passe" + "Continuer" + "Copier" + "Copier la légende" + "Copier le lien" + "Copier le lien vers le message" + "Copier le texte" + "Créer" + "Créer un salon" + "Désactiver" + "Désactiver le compte" + "Refuser" + "Refuser et bloquer" + "Supprimer le sondage" + "Tout désélectionner" + "Désactiver" + "Annuler" + "Ignorer" + "Terminé" + "Modifier" + "Modifier la légende" + "Modifier le sondage" + "Activer" + "Terminer le sondage" + "Saisir le code PIN" + "Terminer" + "Mot de passe oublié ?" + "Transférer" + "Retour" + "Accédez à l’écran « Rôles et autorisations »." + "Ouvrir les paramètres" + "Ignorer" + "Inviter" + "Inviter des amis" + "Inviter des amis à %1$s" + "Invitez des personnes à %1$s" + "Invitations" + "Rejoindre" + "En savoir plus" + "Quitter" + "Quitter la discussion" + "Quitter le salon" + "Quitter l’espace" + "Voir plus" + "Gérer le compte" + "Gérez les sessions" + "Message" + "Minimiser" + "Suivant" + "Non" + "Pas maintenant" + "OK" + "Ouvrir le menu contextuel" + "Ouvrir les paramètres" + "Ouvrir avec" + "Épingler" + "Réponse rapide" + "Citer" + "Réagissez" + "Rejeter" + "Supprimer" + "Supprimer la légende" + "Supprimer le message" + "Répondre" + "Répondre dans le fil de discussion" + "Signaler" + "Signaler un problème" + "Signaler le contenu" + "Signaler la discussion" + "Signaler le salon" + "Réinitialiser" + "Réinitialiser l’identité" + "Réessayer" + "Réessayer le déchiffrement" + "Enregistrer" + "Rechercher" + "Tout sélectionner" + "Envoyer" + "Envoyer les modifications" + "Envoyer un message" + "Envoyer un message vocal" + "Partager" + "Partager le lien" + "Afficher" + "Se connecter à nouveau" + "Se déconnecter" + "Se déconnecter quand même" + "Passer" + "Démarrer" + "Démarrer une discussion" + "Commencer la vérification" + "Cliquez pour charger la carte" + "Prendre une photo" + "Appuyez pour afficher les options" + "Essayer à nouveau" + "Désépingler" + "Voir" + "Voir dans la discussion" + "Afficher la source" + "Oui" + "Oui, réessayez" + "Votre serveur prend désormais en charge un nouveau protocole plus rapide. Déconnectez-vous, puis reconnectez-vous pour effectuer la mise à niveau dès maintenant. En le faisant tout de suite, vous éviterez une déconnexion forcée lorsque l’ancien protocole sera supprimé." + "Mise à niveau disponible" + "À propos" + "Politique d’utilisation acceptable" + "Ajouter un compte" + "Ajouter un autre compte" + "Ajout d’une légende" + "Paramètres avancés" + "une image" + "Statistiques d’utilisation" + "Vous avez quitter le salon" + "Vous avez été déconnecté de la session" + "Apparence" + "Audio" + "Bêta" + "Utilisateurs bloqués" + "Bulles" + "Appel démarré" + "Sauvegarde des discussions" + "Copié dans le presse-papiers" + "Droits d’auteur" + "Création du salon…" + "Demande annulée" + "Vous avez quitté le salon" + "Vous avez quitté l’espace" + "Invitation refusée" + "Sombre" + "Erreur de déchiffrement" + "Description" + "Options pour les développeurs" + "Identifiant de session" + "Discussion à deux" + "Ne plus afficher" + "Échec du téléchargement" + "En cours de téléchargement" + "(modifié)" + "Édition" + "Modification de la légende" + "* %1$s %2$s" + "Fichier vide" + "Chiffrement" + "Chiffrement activé" + "Saisissez votre code PIN" + "Erreur" + "Une erreur s’est produite, il est possible que vous ne receviez pas de notifications pour les nouveaux messages. Veuillez résoudre les problèmes liés aux notifications depuis les paramètres. + +Raison : %1$s." + "Tout le monde" + "Échec" + "Favori" + "Favorisé" + "Fichier" + "Fichier supprimé" + "Fichier enregistré" + "Fichier enregistré dans Téléchargements" + "Transférer le message" + "Fréquemment utilisé" + "GIF" + "Image" + "En réponse à %1$s" + "Installer l’APK" + "Cet identifiant Matrix est introuvable, il est donc possible que l’invitation ne soit pas reçue." + "Quitter le salon…" + "En train de quitter l’espace" + "Clair" + "Ligne copiée dans le presse-papiers" + "Lien copié dans le presse-papiers" + "Chargement…" + "Chargement…" + + "%d autre" + "%d autres" + + + "%1$d Membre" + "%1$d Membres" + + "Message" + "Actions sur le message" + "Mode d’affichage des messages" + "Message supprimé" + "Moderne" + "Mettre en sourdine" + "%1$s (%2$s)" + "Aucun résultat" + "Salon sans nom" + "Espace sans nom" + "Non chiffré" + "Hors ligne" + "Licences open source" + "ou" + "Mot de passe" + "Personnes" + "Permalien" + "Autorisation" + "Épinglé" + "Veuillez vérifier votre connexion internet" + "Veuillez patienter…" + "Êtes-vous sûr de vouloir mettre fin à ce sondage ?" + "Sondage : %1$s" + "Nombre total de votes : %1$s" + "Les résultats s’afficheront une fois le sondage terminé" + + "%d vote" + "%d votes" + + "Préparation…" + "Politique de confidentialité" + "Salon privé" + "Espace privé" + "Salon public" + "Espace public" + "Réaction" + "Réactions" + "Raison" + "Clé de récupération" + "Actualisation…" + + "%1$d réponse" + "%1$d réponses" + + "En réponse à %1$s" + "Signaler un problème" + "Remonter un problème" + "Rapport soumis" + "Éditeur de texte enrichi" + "Salon" + "Nom du salon" + "par exemple, le nom de votre projet" + + "%1$d Salon" + "%1$d Salons" + + "Modifications enregistrées" + "Enregistrement" + "Verrouillage d’écran" + "Rechercher quelqu’un" + "Résultats de la recherche" + "Sécurité" + "Vu par" + "Choisir un compte" + "Envoyer vers" + "Envoi en cours…" + "Échec de l’envoi" + "Envoyé" + ". " + "Serveur non pris en charge" + "Serveur inaccessible" + "URL du serveur" + "Paramètres" + "Partager l’espace" + "Position partagée" + "Espace partagé" + "Déconnexion" + "Une erreur s’est produite" + "Nous avons rencontré un problème. Veuillez réessayer." + "Espace" + + "%1$d Espace" + "%1$d Espaces" + + "Création de la discussion…" + "Autocollant" + "Succès" + "Suggestions" + "Synchronisation" + "Système" + "Texte" + "Avis de tiers" + "Fil de discussion" + "Sujet" + "De quoi s’agit-il dans ce salon ?" + "Échec de déchiffrement" + "Envoyé depuis un appareil non sécurisé" + "Vous ne pouvez pas voir ce message" + "L’identité vérifiée de l’expéditeur a été réinitialisée" + "Les invitations n’ont pas pu être envoyées à un ou plusieurs utilisateurs." + "Impossible d’envoyer une ou plusieurs invitations" + "Déverrouillage" + "Retirer la sourdine" + "Appel non pris en charge" + "Événement non pris en charge" + "Nom d’utilisateur" + "Vérification annulée" + "Vérification terminée" + "Échec de la vérification" + "Vérifié(e)" + "Vérifier la session" + "Vérifier l’identité" + "Vérifier l’utilisateur" + "Vidéo" + "Haute qualité" + "Meilleure qualité mais plus gros fichier" + "Basse qualité" + "Fichier plus petit et vitesse de téléchargement plus rapide" + "Qualité standard" + "Équilibre entre qualité et vitesse de téléchargement" + "Message vocal" + "En attente…" + "En attente de la clé de déchiffrement" + "Vous" + "L’identité de %1$s a été réinitialisée. %2$s" + "L’identité de %1$s %2$s a été réinitialisée. %3$s" + "(%1$s)" + "L’identité de %1$s a été réinitialisée." + "L’identité de %1$s %2$s a été réinitialisée. %3$s" + "Révoquer la vérification" + "Le lien \"%1$s\" vous redirige vers un autre site \"%2$s\". + +Êtes-vous sûr de vouloir continuer ?" + "Veuillez vérifier ce lien" + "Sélectionnez la qualité par défaut des vidéos que vous envoyez." + "Qualité des vidéos envoyées" + "La taille maximale de fichier autorisée est: %1$s" + "Le fichier est trop gros pour être envoyé" + "Salon signalé" + "Salon quitté et signalé" + "Confirmation" + "Erreur" + "Succès" + "Attention" + "Vos modifications n’ont pas été enregistrées. Êtes-vous certain de vouloir quitter ?" + "Enregistrer les changements ?" + "La taille maximale de fichier autorisée est: %1$s" + "Sélectionnez la qualité des vidéos que vous souhaitez envoyer." + "Sélectionnez la qualité d’envoi des vidéos" + "Chercher des emojis" + "Vous êtes déjà connecté sur cet appareil en tant que %1$s." + "Votre serveur d’accueil doit être mis à jour pour prendre en charge le protocole MAS (Matrix Authentication Service) et la création de compte." + "Échec de la création du permalien" + "%1$s n’a pas pu charger la carte. Veuillez réessayer ultérieurement." + "Échec du chargement des messages" + "%1$s n’a pas pu accéder à votre position. Veuillez réessayer ultérieurement." + "Échec lors de l’envoi du message vocal." + "Ce salon n’existe plus ou l’invitation n’est plus valable." + "Message introuvable" + "%1$s n’est pas autorisé à accéder à votre position. Vous pouvez activer l’accès dans les Paramètres." + "%1$s n’est pas autorisé à accéder à votre position. Activez l’accès ci-dessous." + "%1$s n’a pas l’autorisation d’utiliser le microphone. Autorisez l’utilisation pour enregistrer un message vocal." + "Cela peut être dû à des problèmes de réseau ou de serveur." + "Cette adresse de salon existe déjà, veuillez essayer de modifier le champ d’adresse de salon ou de modifier le nom du salon" + "Certains caractères ne sont pas autorisés. Seuls les lettres, les chiffres et les symboles suivants sont utilisables ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" + "Certains messages n’ont pas été envoyés" + "Désolé, une erreur s’est produite" + "L’expéditeur de ce message ne correspond pas au propriétaire de l’appareil qui l’a envoyé." + "L’authenticité de ce message chiffré ne peut être garantie sur cet appareil." + "Chiffré par un utilisateur précédemment vérifié." + "Non chiffré." + "Chiffré par un appareil inconnu ou supprimé." + "Chiffré par un appareil non vérifié par son propriétaire." + "Chiffré par un utilisateur non vérifié." + "🔐️ Rejoignez-moi sur %1$s" + "Salut, parle-moi sur %1$s : %2$s" + "%1$s Android" + "Rageshake pour signaler un problème" + "Capture d’écran" + "%1$s: %2$s" + "Options" + "Supprimer %1$s" + "Paramètres" + "Échec de la sélection du média, veuillez réessayer." + "Cliquez (clic long) sur un message et choisissez « %1$s » pour qu‘il apparaisse ici." + "Épinglez les messages importants pour leur donner plus de visibilité" + + "%1$d message épinglé" + "%1$d messages épinglés" + + "Messages épinglés" + "Vous êtes sur le point d’accéder à votre compte %1$s pour réinitialiser votre identité. Vous serez ensuite redirigé vers l’application." + "Vous ne pouvez pas confirmer ? Accédez à votre compte pour réinitialiser votre identité." + "Révoquer la verification et envoyer" + "Vous pouvez révoquer la verification et envoyer ce message, ou vous pouvez annuler pour l’instant et réessayer plus tard après avoir vérifié à nouveau %1$s." + "Votre message n’a pas été envoyé car l’identité vérifiée de %1$s a été réinitialisée" + "Envoyer le message quand même" + "%1$s utilise un ou plusieurs appareils non vérifiés. Vous pouvez quand même envoyer le message, ou vous pouvez annuler pour l’instant et réessayer plus tard après que %2$s vérifie tous ses appareils." + "Votre message n’a pas été envoyé car %1$s n’a pas vérifié tous ses appareils" + "Un ou plusieurs de vos appareils ne sont pas vérifiés. Vous pouvez quand même envoyer le message, ou vous pouvez annuler et réessayer plus tard après avoir vérifié tous vos appareils." + "Votre message n’a pas été envoyé car vous n’avez pas vérifié tous vos appareils" + "Changer les paramètres" + "Gérer l’espace" + "Gérer les salons" + "Autorisations" + "Modifier les administrateurs ou les propriétaires" + "Échec du traitement des médias à télécharger, veuillez réessayer." + "Impossible de récupérer les détails de l’utilisateur" + "Message dans %1$s" + "Développer" + "Réduire" + "Vous êtes déjà dans ce salon!" + "%1$s sur %2$s" + "%1$s Messages épinglés" + "Chargement du message…" + "Voir tout" + "Discussion" + "Partage de position" + "Partager ma position" + "Ouvrir dans Apple Maps" + "Ouvrir dans Google Maps" + "Ouvrir dans OpenStreetMap" + "Partager cette position" + "Espaces que vous avez créés ou rejoints." + "%1$s • %2$s" + "Espace %1$s" + "Espaces" + "Voir les membres" + "Le message n’a pas été envoyé car l’identité vérifiée de %1$s a été réinitialisée." + "Le message n’a pas été envoyé car %1$s n’a pas vérifié tous ses appareils." + "Message non envoyé car vous n’avez pas vérifié tous vos appareils." + "Position" + "Version : %1$s ( %2$s )" + "fr" + "Les anciens messages ne sont pas disponibles sur cet appareil" + "Vous devez vérifier cet appareil pour accéder à l’historique des messages" + "Vous ne pouvez pas voir ce message" + "Impossible de déchiffrer le message" + "Ce message a été bloqué soit parce que vous n’avez pas vérifié votre session, soit parce que l’expéditeur doit vérifier votre identité." + diff --git a/libraries/ui-strings/src/main/res/values-hu/translations.xml b/libraries/ui-strings/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000..4539d06 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-hu/translations.xml @@ -0,0 +1,485 @@ + + + "Reakció hozzáadása: %1$s" + "Profilkép" + "Üzenet szövegmezőjének minimalizálása" + "Törlés" + + "%1$d megadott számjegy" + "%1$d megadott számjegy" + + "Profilkép szerkesztése" + "A teljes cím ez lesz: %1$s" + "Titkosítás részletei" + "Üzenet szövegmezőjének kibontása" + "Jelszó elrejtése" + "Csatlakozás a híváshoz" + "Ugrás az aljára" + "Térkép áthelyezése a jelenlegi helyre" + "Csak megemlítések" + "Némítva" + "Új említések" + "Új üzenetek" + "Folyamatban lévő hívás" + "Más felhasználó profilképe" + "%1$d. oldal" + "Szüneteltetés" + "Hangüzenet, időtartam:%1$s, jelenlegi pozíció:%2$s" + "PIN-mező" + "Lejátszás" + "Szavazás" + "Befejezett szavazás" + "Reagálás a következővel: %1$s" + "Reagálás más emodzsikkal" + "Olvasta: %1$s és %2$s" + + "Olvasta: %1$s és még %2$d felhasználó" + "Olvasta: %1$s és még %2$d felhasználó" + + "Olvasta: %1$s" + "Koppintson az összes megjelenítéséhez" + "Reakció eltávolítása: %1$s" + "Reakció eltávolítása: %1$s" + "Szoba profilképe" + "Fájlküldés" + "Időkorlátos művelet szükséges, egy perce van az ellenőrzésre" + "Jelszó megjelenítése" + "Hanghívás indítása" + "Elévült szoba" + "Felhasználói profilkép" + "Felhasználói menü" + "Profilkép megtekintése" + "Részletek megtekintése" + "Hangüzenet, időtartam: %1$s" + "Hangüzenet felvétele." + "Rögzítés leállítása" + "Saját profilkép" + "Elfogadás" + "Felirat hozzáadása" + "Hozzáadás az idővonalhoz" + "Vissza" + "Hívás" + "Mégse" + "Egyelőre nem" + "Fénykép kiválasztása" + "Törlés" + "Bezárás" + "Ellenőrzés befejezése" + "Megerősítés" + "Jelszó megerősítése" + "Folytatás" + "Másolás" + "Felirat másolása" + "Hivatkozás másolása" + "Üzenethivatkozás másolása" + "Szöveg másolása" + "Létrehozás" + "Szoba létrehozása" + "Deaktiválás" + "Fiók deaktiválása" + "Elutasítás" + "Elutasítás és letiltás" + "Szavazás törlése" + "Kijelölés megszüntetése" + "Letiltás" + "Elvetés" + "Eltüntetés" + "Kész" + "Szerkesztés" + "Felirat szerkesztése" + "Szavazás szerkesztése" + "Engedélyezés" + "Szavazás lezárása" + "Adja meg a PIN-kódot" + "Befejezés" + "Elfelejtette a jelszót?" + "Tovább" + "Visszalépés" + "Ugrás a szerepkörökre és jogosultságokra" + "Ugrás a beállításokhoz" + "Mellőzés" + "Meghívás" + "Ismerősök meghívása" + "Ismerősök meghívása ide: %1$s" + "Emberek meghívása ide: %1$s" + "Meghívások" + "Csatlakozás" + "További tudnivalók" + "Elhagyás" + "Beszélgetés elhagyása" + "Szoba elhagyása" + "Tér elhagyása" + "Továbbiak betöltése" + "Fiók kezelése" + "Eszközök kezelése" + "Üzenet" + "Minimalizálás" + "Következő" + "Nem" + "Most nem" + "Rendben" + "Helyi menü megnyitása" + "Beállítások megnyitása" + "Megnyitás a következővel" + "Kitűzés" + "Gyors válasz" + "Idézet" + "Reakció" + "Elutasítás" + "Eltávolítás" + "Felirat eltávolítása" + "Üzenet eltávolítása" + "Válasz" + "Válasz az üzenetszálban" + "Jelentés" + "Hiba jelentése" + "Tartalom jelentése" + "Beszélgetés jelentése" + "Szoba jelentése" + "Visszaállítás" + "Személyazonosság visszaállítása" + "Újra" + "Visszafejtés újbóli megpróbálása" + "Mentés" + "Keresés" + "Összes kijelölése" + "Küldés" + "Szerkesztett üzenet küldése" + "Üzenet küldése" + "Hangüzenet küldése" + "Megosztás" + "Hivatkozás megosztása" + "Megjelenítés" + "Jelentkezzen be újra" + "Kijelentkezés" + "Kijelentkezés mindenképp" + "Kihagyás" + "Indítás" + "Csevegés indítása" + "Ellenőrzés elindítása" + "Koppintson a térkép betöltéséhez" + "Fénykép készítése" + "Koppintson a beállításokért" + "Próbálja újra" + "Kitűzés feloldása" + "Megtekintés" + "Megtekintés az idővonalon" + "Forrás megtekintése" + "Igen" + "Igen, újrapróbálkozás" + "A kiszolgálója mostantól egy új, gyorsabb protokollt támogat. A frissítéshez jelentkezzen ki, majd jelentkezzen be újra. Ha ezt most megteszi, elkerülheti a kényszerített kijelentkeztetést a régi protokollt eltávolításakor." + "Frissítés érhető el" + "Névjegy" + "Elfogadható használatra vonatkozó szabályzat" + "Fiók hozzáadása" + "Másik fiók hozzáadása" + "Felirat hozzáadása" + "Speciális beállítások" + "egy kép" + "Elemzések" + "Elhagyta a szobát" + "Ki lett jelentkeztetve a munkamenetből" + "Megjelenítés" + "Hang" + "Béta" + "Letiltott felhasználók" + "Buborékok" + "A hívás elindult" + "Csevegés biztonsági mentése" + "A vágólapra másolva" + "Szerzői jogok" + "Szoba létrehozása…" + "Kérés megszakítva" + "Elhagyta a szobát" + "Tér elhagyva" + "Meghívás elutasítva" + "Sötét" + "Visszafejtési hiba" + "Leírás" + "Fejlesztői beállítások" + "Eszközazonosító" + "Közvetlen csevegés" + "Ne jelenjen meg többé" + "Letöltés sikertelen" + "Letöltés" + "(szerkesztve)" + "Szerkesztés" + "Felirat szerkesztése" + "* %1$s %2$s" + "Üres fájl" + "Titkosítás" + "Titkosítás engedélyezve" + "Adja meg a PIN-kódját" + "Hiba" + "Hiba történt, előfordulhat, hogy nem kap értesítést az új üzenetekről. Az értesítések hibaelhárítása a beállításokban található. + +Ok: %1$s." + "Mindenki" + "Sikertelen" + "Kedvenc" + "Kedvencnek jelölve" + "Fájl" + "Fájl törölve" + "A fájl mentve" + "A fájl a Letöltések mappába mentve" + "Üzenet továbbítása" + "Gyakran használt" + "GIF" + "Kép" + "Válasz erre: %1$s" + "APK telepítése" + "Ez a Matrix-azonosító nem található, ezért előfordulhat, hogy a meghívó nem érkezik meg." + "Szoba elhagyása" + "Tér elhagyása" + "Világos" + "A sor a vágólapra másolva" + "Hivatkozás a vágólapra másolva" + "Betöltés…" + "Továbbiak betöltése…" + + "%d további felhasználó" + "%d további felhasználó" + + + "%1$d tag" + "%1$d tag" + + "Üzenet" + "Üzenetműveletek" + "Üzenet elrendezése" + "Üzenet eltávolítva" + "Modern" + "Némítás" + "%1$s (%2$s)" + "Nincs találat" + "Nincs szobanév" + "Nincs térnév" + "Nincs titkosítva" + "Kapcsolat nélkül" + "Nyílt forráskódú licencek" + "vagy" + "Jelszó" + "Emberek" + "Állandó hivatkozás" + "Engedély" + "Kitűzve" + "Ellenőrizze az internetkapcsolatát" + "Kis türelmet…" + "Biztos, hogy befejezi ezt a szavazást?" + "Szavazás: %1$s" + "Összes szavazat: %1$s" + "Az eredmények a szavazás befejezése után jelennek meg" + + "%d szavazat" + "%d szavazat" + + "Előkészítés…" + "Adatvédelmi nyilatkozat" + "Privát szoba" + "Privát tér" + "Nyilvános szoba" + "Nyilvános tér" + "Reakció" + "Reakciók" + "Ok" + "Helyreállítási kulcs" + "Frissítés…" + + "%1$d válasz" + + "Válasz %1$s számára" + "Hiba jelentése" + "Probléma jelentése" + "A jelentés elküldve" + "Formázott szöveges szerkesztő" + "Szoba" + "Szoba neve" + "például a projektje neve" + + "%1$d szoba" + "%1$d szoba" + + "Mentett módosítások" + "Mentés" + "Képernyőzár" + "Személy keresése" + "Keresési találatok" + "Biztonság" + "Látta" + "Fiók kiválasztása" + "Címzett" + "Küldés…" + "A küldés sikertelen" + "Elküldve" + ". " + "A kiszolgáló nem támogatott" + "A kiszolgáló nem érhető el" + "Kiszolgáló webcíme" + "Beállítások" + "Tér megosztása" + "Megosztott tartózkodási hely" + "Megosztott tér" + "Kijelentkezés" + "Valamilyen hiba történt" + "Problémába ütköztünk. Próbálja újra." + "Tér" + + "%1$d tér" + "%1$d tér" + + "Csevegés megkezdése…" + "Matrica" + "Sikeres" + "Javaslatok" + "Szinkronizálás" + "Rendszer" + "Szöveg" + "Harmadik felek nyilatkozatai" + "Üzenetszál" + "Téma" + "Miről szól ez a szoba?" + "Nem lehet visszafejteni" + "Nem biztonságos eszközről küldve" + "Nincs hozzáférése ehhez az üzenethez" + "A feladó ellenőrzött személyazonossága megváltozott" + "Nem sikerült meghívót küldeni egy vagy több felhasználónak." + "Nem sikerült elküldeni a meghívót (meghívókat)" + "Feloldás" + "Némítás feloldása" + "Nem támogatott hívás" + "Nem támogatott esemény" + "Felhasználónév" + "Az ellenőrzés megszakítva" + "Az ellenőrzés befejeződött" + "Az ellenőrzés sikertelen" + "Ellenőrizve" + "Eszköz ellenőrzése" + "Személyazonosság ellenőrzése" + "Felhasználó ellenőrzése" + "Videó" + "Magas minőség" + "Legjobb minőség, de nagyobb fájlméret" + "Alacsony minőség" + "Leggyorsabb feltöltési sebesség és legkisebb fájlméret" + "Szokásos minőség" + "A minőség és a feltöltési sebesség egyensúlya." + "Hangüzenet" + "Várakozás…" + "Várakozás a visszafejtési kulcsra" + "Ön" + "%1$s személyazonossága megváltozott. %2$s" + "%1$s (%2$s) személyazonossága megváltozott. %3$s" + "(%1$s)" + "%1$s személyazonossága megváltozott." + "%1$s (%2$s) ellenőrzött személyazonossága megváltozott. %3$s" + "Ellenőrzés visszavonása" + "A(z) %1$s hivatkozás átviszi egy másik webhelyre: %2$s + +Biztos, hogy folytatja?" + "Ellenőrizze újra ezt a hivatkozást" + "Válaszd ki a feltöltött videók alapértelmezett minőségét." + "Feltöltött videó minősége" + "A legnagyobb megengedett fájlméret: %1$s" + "A feltöltendő fájl túl nagy" + "Szoba jelentve" + "Jelentve, és a szoba elhagyva" + "Megerősítés" + "Hiba" + "Sikeres" + "Figyelmeztetés" + "A módosítások nem lettek mentve. Biztos, hogy visszalép?" + "Menti a módosításokat?" + "A legnagyobb megengedett fájlméret: %1$s" + "Válassza ki a feltöltendő videó minőségét." + "Feltöltött videó minőségének kiválasztása" + "Emodzsik keresése" + "Már bejelentkezett erre az eszközre, mint %1$s." + "A Matrix-kiszolgálót frissíteni kell a Matrix Authentication Service és a fióklétrehozás támogatásához." + "Nem sikerült létrehozni az állandó hivatkozást" + "Az %1$s nem tudta betölteni a térképet. Próbálja meg újra később." + "Nem sikerült betölteni az üzeneteket" + "Az %1$s nem tudta elérni a tartózkodási helyét. Próbálja újra később." + "Nem sikerült feltölteni a hangüzenetét." + "A szoba már nem létezik, vagy a meghívó már nem érvényes." + "Az üzenet nem található" + "Az %1$snek nincs engedélye, hogy hozzáférjen a tartózkodási helyéhez. Ezt a beállításokban engedélyezheti." + "Az %1$snek nincs engedélye, hogy hozzáférjen a tartózkodási helyéhez. Engedélyezze alább az elérését." + "Az %1$snek nincs engedélye, hogy hozzáférjen a mikrofonhoz. Engedélyezze, hogy tudjon hangüzenetet felvenni." + "Ennek oka hálózati vagy kiszolgálóprobléma lehet." + "Ez a szobacím már létezik. Próbálja meg szerkeszteni a szobacím mezőt, vagy módosítsa a szoba nevét." + "Egyes karakterek nem engedélyezettek. Csak a betűk, a számjegyek és a következő szimbólumok támogatottak: $ & \'() * +/; =? @ [] - . _" + "Néhány üzenet nem került elküldésre" + "Elnézést, hiba történt" + "Az esemény feladója nem egyezik az eseményt küldő eszköz tulajdonosával." + "A titkosított üzenetek valódiságát ezen az eszközön nem lehet garantálni." + "Egy korábban ellenőrzött felhasználó által titkosítva." + "Nincs titkosítva." + "Ismeretlen vagy törölt eszköz által titkosítva." + "A tulajdonos által nem ellenőrzött eszköz által titkosítva." + "Nem ellenőrzött felhasználó által titkosítva." + "🔐️ Csatlakozz hozzám itt: %1$s" + "Beszélgessünk itt: %1$s, %2$s" + "%1$s Android" + "Az eszköz rázása a hibajelentéshez" + "Képernyőkép" + "%1$s: %2$s" + "Lehetőségek" + "Eltávolítás: %1$s" + "Beállítások" + "Nem sikerült kiválasztani a médiát, próbálja újra." + "Nyomjon hosszan az üzenetre, és válassza a „%1$s” lehetőséget, hogy itt szerepeljen." + "Tűzze ki a fontos üzeneteket, hogy könnyen felfedezhetők legyenek" + + "%1$d kitűzött üzenet" + "%1$d kitűzött üzenet" + + "Kitűzött üzenetek" + "Arra készül, hogy belépjen a(z) %1$s fiókjába, hogy visszaállítsa a személyazonosságát. Ezután vissza fog térni az alkalmazásba." + "Nem tudja megerősíteni? Ugorjon a fiókjához, és állítsa vissza a személyazonosságát." + "Ellenőrzés visszavonása és elküldés" + "Visszavonhatja az ellenőrzést, és ennek ellenére elküldheti ezt az üzenetet, vagy egyelőre törölheti, és %1$s újbóli ellenőrzése után újra megpróbálhatja." + "Az üzenete nem lett elküldve, mert %1$s személyazonossága megváltozott." + "Üzenet elküldése mindenképp" + "%1$s egy vagy több ellenőrizetlen eszközt használ. Így is elküldheti az üzenetet, vagy megszakíthatja most, és megpróbálhatja újra, miután %2$s ellenőrizte az összes eszközét." + "Az üzenet nem lett elküldve, mert %1$s nem ellenőrizte az összes eszközét" + "Egy vagy több eszköze nincs ellenőrizve. Így is elküldheti az üzenetet, vagy egyelőre megszakíthatja, és később, az összes eszköz ellenőrzése után újrapróbálkozhat." + "Az üzenet nem lett elküldve, mert egy vagy több eszközét nem ellenőrizte" + "Beállítások módosítása" + "Tér kezelése" + "Szobák kezelése" + "Jogosultságok" + "Adminisztrátorok vagy tulajdonosok szerkesztése" + "Nem sikerült feldolgozni a feltöltendő médiát, próbálja újra." + "Nem sikerült letölteni a felhasználói adatokat" + "Üzenet a következőben: %1$s" + "Kibontás" + "Csökkentés" + "Már ezt a szobát nézi!" + "%1$s. / %2$s" + "%1$s kitűzött üzenet" + "Üzenet betöltése…" + "Összes megtekintése" + "Csevegés" + "Hely megosztása" + "Saját hely megosztása" + "Megnyitás az Apple Mapsben" + "Megnyitás a Google Mapsben" + "Megnyitás az OpenStreetMapen" + "E hely megosztása" + "Létrehozott vagy olyan terek, melyekhez csatlakozott." + "%1$s • %2$s" + "%1$s tér" + "Terek" + "Tagok megtekintése" + "Az üzenet nem lett elküldve, mert %1$s ellenőrzött személyazonossága megváltozott." + "Az üzenet nem lett elküldve, mert %1$s nem ellenőrizte az összes eszközét." + "Az üzenet nem lett elküldve, mert egy vagy több eszközét nem ellenőrizte." + "Hely" + "Verzió: %1$s (%2$s)" + "hu" + "A korábbi üzenetek nem érhetők el ezen az eszközön" + "Ellenőriznie kell ezt az eszközt a korábbi üzenetekhez való hozzáféréshez" + "Nincs hozzáférése ehhez az üzenethez" + "Nem sikerült visszafejteni az üzenetet" + "Ez az üzenet azért lett blokkolva, mert vagy nem ellenőrizte az eszközt, vagy a feladónak ellenőriznie kell az Ön személyazonosságát." + diff --git a/libraries/ui-strings/src/main/res/values-in/translations.xml b/libraries/ui-strings/src/main/res/values-in/translations.xml new file mode 100644 index 0000000..bd7739d --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-in/translations.xml @@ -0,0 +1,413 @@ + + + "Tambahkan reaksi: %1$s" + "Avatar" + "Hapus" + + "%1$d digit dimasukkan" + + "Sembunyikan kata sandi" + "Bergabung dalam panggilan" + "Lompat ke bawah" + "Hanya sebutan" + "Dibisukan" + "Sebutan baru" + "Pesan baru" + "Panggilan berlangsung" + "Avatar pengguna lain" + "Halaman %1$d" + "Jeda" + "Pesan suara, durasi: %1$s, posisi saat ini: %2$s" + "Kolom PIN" + "Putar" + "Pemungutan suara" + "Pemungutan suara berakhir" + "Bereaksi dengan %1$s" + "Reaksi dengan emoji lain" + "Dibaca oleh %1$s dan %2$s" + + "Dibaca oleh %1$s dan %2$d lainnya" + + "Dibaca oleh %1$s" + "Ketuk untuk melihat semua" + "Hapus reaksi dengan %1$s" + "Hapus reaksi dengan %1$s" + "Avatar ruangan" + "Kirim berkas" + "Tampilkan kata sandi" + "Mulai panggilan" + "Avatar pengguna" + "Menu pengguna" + "Lihat avatar" + "Lihat detail" + "Pesan suara, durasi: %1$s" + "Rekam pesan suara." + "Berhenti merekam" + "Avatar Anda" + "Terima" + "Tambahkan keterangan" + "Tambahkan ke lini masa" + "Kembali" + "Panggil" + "Batal" + "Batalkan untuk saat ini" + "Pilih foto" + "Hapus" + "Tutup" + "Selesaikan verifikasi" + "Konfirmasi" + "Konfirmasi kata sandi" + "Lanjutkan" + "Salin" + "Salin keterangan" + "Salin tautan" + "Salin tautan ke pesan" + "Salin teks" + "Buat" + "Buat ruangan" + "Nonaktifkan" + "Nonaktifkan akun" + "Tolak" + "Tolak dan blokir" + "Hapus pemungutan suara" + "Nonaktifkan" + "Abaikan" + "Abaikan" + "Selesai" + "Sunting" + "Sunting keterangan" + "Sunting pemungutan suara" + "Aktifkan" + "Akhiri pemungutan suara" + "Masukkan PIN" + "Lupa kata sandi?" + "Teruskan" + "Kembali" + "Abaikan" + "Undang" + "Undang orang-orang" + "Undang orang-orang ke %1$s" + "Undang orang-orang ke %1$s" + "Undangan" + "Gabung" + "Pelajari lebih lanjut" + "Tinggalkan" + "Tinggalkan percakapan" + "Tinggalkan ruangan" + "Muat lainnya" + "Kelola akun" + "Kelola perangkat" + "Kirim pesan" + "Berikutnya" + "Tidak" + "Jangan sekarang" + "Oke" + "Buka menu konteks" + "Pengaturan" + "Buka dengan" + "Sematkan" + "Balas cepat" + "Kutip" + "Bereaksi" + "Tolak" + "Hilangkan" + "Hilangkan keterangan" + "Hilangkan pesan" + "Balas" + "Balas dalam utas" + "Laporkan" + "Laporkan kutu" + "Laporkan Konten" + "Laporkan percakapan" + "Laporkan ruangan" + "Atur ulang" + "Atur ulang identitas" + "Coba lagi" + "Coba dekripsi ulang" + "Simpan" + "Cari" + "Kirim" + "Kirim pesan tersunting" + "Kirim pesan" + "Kirim pesan suara" + "Bagikan" + "Bagikan tautan" + "Tampilkan" + "Masuk lagi" + "Keluar dari akun" + "Keluar saja" + "Lewati" + "Mulai" + "Mulai obrolan" + "Mulai verifikasi" + "Ketuk untuk memuat peta" + "Ambil foto" + "Ketuk untuk opsi" + "Coba lagi" + "Lepaskan sematan" + "Lihat" + "Lihat di lini masa" + "Tampilkan sumber" + "Ya" + "Ya, coba lagi" + "Server Anda kini mendukung protokol baru yang lebih cepat. Keluar dan masuk lagi untuk memperbarui sekarang. Melakukan hal ini sekarang akan membantu Anda menghindari keluar paksa saat protokol lama dihapus nantinya." + "Peningkatan tersedia" + "Tentang" + "Kebijakan penggunaan wajar" + "Menambahkan keterangan" + "Pengaturan tingkat lanjut" + "sebuah gambar" + "Analitik" + "Penampilan" + "Audio" + "Pengguna yang diblokir" + "Gelembung" + "Panggilan dimulai" + "Pencadangan percakapan" + "Disalin ke papan klip" + "Hak cipta" + "Membuat ruangan…" + "Permintaan dibatalkan" + "Keluar dari ruangan" + "Undangan ditolak" + "Gelap" + "Kesalahan dekripsi" + "Opsi pengembang" + "ID Perangkat" + "Obrolan langsung" + "Jangan tampilkan ini lagi" + "Pengunduhan gagal" + "Mengunduh" + "(disunting)" + "Penyuntingan" + "Menyunting keterangan" + "* %1$s %2$s" + "Berkas kosong" + "Enkripsi" + "Enkripsi diaktifkan" + "Masukkan PIN Anda" + "Eror" + "Terjadi kesalahan, Anda mungkin tidak menerima notifikasi pesan baru. Silakan memecahkan masalah notifikasi dari pengaturan. + +Alasan: %1$s." + "Semua orang" + "Gagal" + "Favorit" + "Difavoritkan" + "Berkas" + "Berkas dihapus" + "Berkas disimpan" + "Berkas disimpan ke Unduhan" + "Teruskan pesan" + "Sering digunakan" + "GIF" + "Gambar" + "Membalas kepada %1$s" + "Pasang APK" + "ID Matrix ini tidak dapat ditemukan, sehingga undangan mungkin tidak diterima." + "Meninggalkan ruangan" + "Terang" + "Baris disalin ke papan klip" + "Tautan disalin ke papan klip" + "Memuat…" + "Memuat lebih banyak…" + + "%d lainnya" + + + "%1$d Anggota" + + "Pesan" + "Tindakan pesan" + "Tata letak pesan" + "Pesan dihapus" + "Modern" + "Bisukan" + "%1$s (%2$s)" + "Tidak ada hasil" + "Tidak ada nama ruangan" + "Tidak terenkripsi" + "Luring" + "Lisensi sumber terbuka" + "atau" + "Kata sandi" + "Orang" + "Tautan Permanen" + "Perizinan" + "Disematkan" + "Silakan periksa koneksi internet Anda" + "Mohon tunggu…" + "Apakah Anda yakin ingin mengakhiri pemungutan suara ini?" + "Pemungutan suara: %1$s" + "Total suara: %1$s" + "Hasil akan terlihat setelah pemungutan suara berakhir" + + "%d suara" + + "Kebijakan privasi" + "Ruangan pribadi" + "Ruangan publik" + "Reaksi" + "Reaksi" + "Alasan" + "Kunci pemulihan" + "Menyegarkan…" + + "%1$d balasan" + + "Membalas %1$s" + "Laporkan kutu" + "Laporkan masalah" + "Laporan terkirim" + "Penyunting teks kaya" + "Ruangan" + "Nama ruangan" + "misalnya, nama proyek Anda" + "Perubahan disimpan" + "Menyimpan" + "Layar kunci" + "Cari seseorang" + "Hasil pencarian" + "Keamanan" + "Dilihat oleh" + "Kirim ke" + "Mengirim…" + "Pengiriman gagal" + "Terkirim" + ". " + "Server tidak didukung" + "URL Server" + "Pengaturan" + "Lokasi terbagi" + "Mengeluarkan dari akun" + "Ada yang salah" + "Kami mengalami masalah. Silakan coba lagi." + "Memulai obrolan…" + "Stiker" + "Berhasil" + "Saran" + "Menyinkronkan" + "Sistem" + "Teks" + "Pemberitahuan pihak ketiga" + "Utas" + "Topik" + "Tentang apa ruangan ini?" + "Tidak dapat mendekripsi" + "Dikirim dari perangkat yang tidak aman" + "Anda tidak memiliki akses ke pesan ini" + "Identitas terverifikasi pengirim diatur ulang" + "Undangan tidak dapat dikirim ke satu atau beberapa pengguna." + "Tidak dapat mengirim undangan" + "Buka kunci" + "Bunyikan" + "Panggilan tidak didukung" + "Peristiwa tidak didukung" + "Nama pengguna" + "Verifikasi dibatalkan" + "Verifikasi selesai" + "Verifikasi gagal" + "Terverifikasi" + "Verifikasi perangkat" + "Verifikasi identitas" + "Verifikasi pengguna" + "Video" + "Pesan suara" + "Menunggu…" + "Menunggu pesan ini" + "Anda" + "Identitas %1$s telah diatur ulang. %2$s" + "Identitas %2$s %1$s telah diatur ulang. %3$s" + "(%1$s)" + "Identitasnya %1$s sudah diatur ulang." + "Identitas %2$s %1$s telah diatur ulang. %3$s" + "Tolak verifikasi" + "Tautan %1$s membawa Anda ke situs lain %2$s + +Apakah Anda yakin ingin melanjutkan?" + "Periksa kembali tautan ini" + "Ruangan dilaporkan" + "Dilaporkan dan ruangan ditinggalkan" + "Konfirmasi" + "Eror" + "Berhasil" + "Peringatan" + "Perubahan Anda belum disimpan. Apakah Anda yakin ingin kembali?" + "Simpan perubahan?" + "Homeserver Anda perlu ditingkatkan untuk mendukung Matrix Authentication Service dan pembuatan akun." + "Gagal membuat tautan permanen" + "%1$s tidak dapat memuat peta. Silakan coba lagi nanti." + "Gagal memuat pesan" + "%1$s tidak dapat mengakses lokasi Anda. Silakan coba lagi nanti." + "Gagal mengunggah pesan suara Anda." + "Ruangan tidak ada lagi atau undangan tidak lagi valid." + "Pesan tidak ditemukan" + "%1$s tidak memiliki izin untuk mengakses lokasi Anda. Anda dapat mengaktifkan akses di Pengaturan." + "%1$s tidak memiliki izin untuk mengakses lokasi Anda. Aktifkan akses di bawah ini." + "%1$s tidak memiliki izin untuk mengakses mikrofon. Aktifkan akses untuk merekam pesan suara." + "Hal ini mungkin disebabkan oleh masalah jaringan atau server." + "Alamat ruangan sudah ada, silakan coba sunting kolom alamat ruangan atau ubah nama ruangan" + "Beberapa karakter tidak diperbolehkan. Hanya huruf, angka, dan simbol berikut didukung ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" + "Beberapa pesan belum terkirim" + "Maaf, terjadi kesalahan" + "Pengirim peristiwa tidak cocok dengan pemilik perangkat yang mengirimnya." + "Keaslian pesan terenkripsi ini tidak dapat dijamin pada perangkat ini." + "Dienkripsi oleh pengguna yang telah diverifikasi sebelumnya." + "Tidak dienkripsi." + "Dienkripsi oleh perangkat yang tidak dikenal atau dihapus." + "Dienkripsi oleh perangkat yang tidak diverifikasi oleh pemiliknya." + "Dienkripsi oleh pengguna yang tidak terverifikasi." + "🔐️ Bergabunglah dengan saya di %1$s" + "Hai, bicaralah dengan saya di %1$s: %2$s" + "%1$s Android" + "Rageshake untuk melaporkan kutu" + "%1$s: %2$s" + "Opsi" + "Hapus %1$s" + "Pengaturan" + "Gagal memilih media, silakan coba lagi." + "Tekan pesan dan pilih “%1$s” untuk disertakan di sini." + "Sematkan pesan penting agar mudah ditemukan" + + "%1$d Pesan yang disematkan" + + "Pesan yang disematkan" + "Anda akan pergi ke akun %1$s Anda untuk mengatur ulang identitas Anda. Setelah itu Anda akan dibawa kembali ke aplikasi." + "Tidak dapat mengonfirmasi? Buka akun Anda untuk mengatur ulang identitas Anda." + "Tarik verifikasi dan kirim" + "Anda dapat menarik verifikasi dan tetap mengirim pesan ini, atau Anda dapat membatalkan untuk saat ini dan mencoba lagi nanti setelah memverifikasi ulang %1$s." + "Pesan Anda tidak terkirim karena identitas terverifikasi %1$s telah diatur ulang" + "Kirim pesan saja" + "%1$s menggunakan satu atau beberapa perangkat yang belum diverifikasi. Anda tetap dapat mengirim pesan, atau Anda dapat membatalkan untuk saat ini dan mencoba lagi nanti setelah %2$s telah memverifikasi semua perangkat mereka." + "Pesan Anda tidak terkirim karena %1$s belum memverifikasi semua perangkat" + "Satu atau beberapa perangkat Anda tidak terverifikasi. Anda tetap dapat mengirim pesan, atau Anda dapat membatalkannya dan mencoba lagi nanti setelah Anda memverifikasi semua perangkat." + "Pesan Anda tidak terkirim karena Anda belum memverifikasi satu atau beberapa perangkat Anda" + "Gagal memproses media untuk diunggah, silakan coba lagi." + "Tidak dapat mengambil detail pengguna" + "Pesan dalam %1$s" + "Buka" + "Kurangi" + "Sudah melihat ruangan ini!" + "%1$s dari %2$s" + "%1$s Pesan yang disematkan" + "Memuat pesan…" + "Lihat Semua" + "Obrolan" + "Bagikan lokasi" + "Bagikan lokasi saya" + "Buka di Apple Maps" + "Buka di Google Maps" + "Buka di OpenStreetMap" + "Bagikan lokasi ini" + "Pesan tidak terkirim karena identitas terverifikasi %1$s telah diatur ulang." + "Pesan tidak terkirim karena %1$s belum memverifikasi semua perangkat." + "Pesan tidak terkirim karena Anda belum memverifikasi satu atau beberapa perangkat Anda." + "Lokasi" + "Versi: %1$s (%2$s)" + "id" + "Riwayat pesan tidak tersedia di perangkat ini" + "Anda harus memverifikasi perangkat ini untuk mengakses riwayat pesan" + "Anda tidak memiliki akses ke pesan ini" + "Tidak dapat mendekripsi pesan" + "Pesan ini diblokir karena Anda tidak memverifikasi perangkat Anda atau karena pengirim perlu memverifikasi identitas Anda." + 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 0000000..793b115 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-it/translations.xml @@ -0,0 +1,486 @@ + + + "Aggiungi reazione: %1$s" + "Avatar" + "Riduci al minimo il campo di testo del messaggio" + "Elimina" + + "%1$d cifra inserita" + "%1$d cifre inserite" + + "Modifica avatar" + "L\'indirizzo completo sarà %1$s" + "Dettagli sulla crittografia" + "Allarga il campo di testo del messaggio" + "Nascondi password" + "Entra in chiamata" + "Vai alla fine" + "Sposta la mappa sulla mia posizione" + "Solo menzioni" + "Silenziato" + "Nuove menzioni" + "Nuovi messaggi" + "Chiamata in corso" + "Avatar dell\'altro utente" + "Pagina %1$d" + "Pausa" + "Messaggio vocale, durata: %1$s, posizione attuale: %2$s" + "Campo del PIN" + "Riproduci" + "Sondaggio" + "Sondaggio terminato" + "Reagisci con %1$s" + "Reagisci con altri emoji" + "Visualizzato da %1$s e %2$s" + + "Visualizzato da %1$s e %2$d altro" + "Visualizzato da %1$s e altri %2$d" + + "Visualizzato da %1$s" + "Tocca per mostrare tutti" + "Rimuovi la reazione con %1$s" + "Rimuovere la reazione con %1$s" + "Avatar della stanza" + "Invia file" + "Azione richiesta a tempo limitato, hai un minuto per la verifica" + "Mostra password" + "Avvia una chiamata" + "Stanza obsoleta" + "Avatar utente" + "Menu utente" + "Visualizza avatar" + "Visualizza dettagli" + "Messaggio vocale, durata: %1$s" + "Registra un messaggio vocale." + "Ferma la registrazione" + "Il tuo avatar" + "Accetta" + "Aggiungi didascalia" + "Aggiungi alla conversazione" + "Indietro" + "Chiama" + "Annulla" + "Annulla per ora" + "Scegli foto" + "Cancella" + "Chiudi" + "Completa verifica" + "Conferma" + "Conferma password" + "Continua" + "Copia" + "Copia didascalia" + "Copia collegamento" + "Copia collegamento al messaggio" + "Copia testo" + "Crea" + "Crea una stanza" + "Disattiva" + "Disattiva account" + "Rifiuta" + "Rifiuta e blocca" + "Elimina sondaggio" + "Deseleziona tutti" + "Disabilita" + "Annulla" + "Chiudi" + "Fine" + "Modifica" + "Modifica didascalia" + "Modifica sondaggio" + "Attiva" + "Termina sondaggio" + "Inserisci PIN" + "Fine" + "Password dimenticata?" + "Inoltra" + "Indietro" + "Vai a ruoli & autorizzazioni" + "Vai alle impostazioni" + "Ignora" + "Invita" + "Invita persone" + "Invita persone su %1$s" + "Invita persone su %1$s" + "Inviti" + "Entra" + "Ulteriori informazioni" + "Esci" + "Abbandona la conversazione" + "Esci dalla stanza" + "Esci dallo spazio" + "Carica altro" + "Gestisci account" + "Gestisci dispositivi" + "Invia messaggio" + "Riduci" + "Avanti" + "No" + "Non ora" + "OK" + "Apri il menu contestuale" + "Impostazioni" + "Apri con" + "Fissa" + "Risposta rapida" + "Citazione" + "Reagisci" + "Rifiuta" + "Rimuovi" + "Rimuovi didascalia" + "Rimuovi messaggio" + "Rispondi" + "Rispondi nella discussione" + "Segnala" + "Segnala un problema" + "Segnala contenuto" + "Segnala una conversazione" + "Segnala stanza" + "Reimposta" + "Reimposta identità" + "Riprova" + "Riprova la decrittazione" + "Salva" + "Ricerca" + "Seleziona tutti" + "Invia" + "Invia messaggio modificato" + "Invia messaggio" + "Invia messaggio vocale" + "Condividi" + "Condividi collegamento" + "Mostra" + "Accedi di nuovo" + "Disconnetti" + "Disconnetti comunque" + "Salta" + "Inizia" + "Avvia conversazione" + "Avvia la verifica" + "Tocca per caricare la mappa" + "Scatta foto" + "Tocca per le opzioni" + "Riprova" + "Rimuovi dai fissati" + "Visualizza" + "Visualizza nella conversazione" + "Vedi codice sorgente" + "Sì" + "Sì, riprova" + "Il tuo server ora supporta un nuovo protocollo più veloce. Esci e rientra per effettuare l\'aggiornamento. Se lo fai ora, eviterai una disconnessione forzata quando il vecchio protocollo verrà rimosso in seguito." + "Aggiornamento disponibile" + "Informazioni" + "Regole sull\'utilizzo consentito" + "Aggiungi un account" + "Aggiungi un altro account" + "Aggiunta didascalia" + "Impostazioni avanzate" + "un\'immagine" + "Statistiche di utilizzo" + "Hai lasciato la stanza" + "Sei stato disconnesso dalla sessione" + "Aspetto" + "Audio" + "Beta" + "Utenti bloccati" + "Fumetti" + "Chiamata avviata" + "Backup della chat" + "Copiato negli appunti" + "Copyright" + "Creazione stanza…" + "Richiesta annullata" + "Hai lasciato la stanza" + "Hai lasciato lo spazio" + "Invito rifiutato" + "Scuro" + "Errore di decrittazione" + "Descrizione" + "Opzioni sviluppatore" + "ID dispositivo" + "Conversazione diretta" + "Non mostrarlo più" + "Download non riuscito" + "Scaricamento" + "(modificato)" + "Modifica in corso" + "Modifica didascalia" + "* %1$s %2$s" + "File vuoto" + "Crittografia" + "Crittografia abilitata" + "Inserisci il PIN" + "Errore" + "Si è verificato un errore, potresti non ricevere notifiche per nuovi messaggi. Risolvi i problemi relativi alle notifiche dalle impostazioni. + +Motivo:. %1$s" + "Tutti" + "Fallito" + "Preferiti" + "Preferita" + "File" + "File eliminato" + "File salvato" + "File salvato" + "Inoltra messaggio" + "Usati di frequente" + "GIF" + "Immagine" + "In risposta a %1$s" + "Installa APK" + "Questo ID Matrix non può essere trovato, quindi l\'invito potrebbe non essere ricevuto." + "Lascio la stanza" + "Uscendo dallo spazio" + "Chiaro" + "Riga copiata negli appunti" + "Collegamento copiato negli appunti" + "Caricamento…" + "Caricamento in corso…" + + "%d altro" + "altri %d" + + + "%1$d Membro" + "%1$d Membri" + + "Messaggio" + "Azioni messaggio" + "Impaginazione del messaggio" + "Messaggio rimosso" + "Moderno" + "Silenzia" + "%1$s (%2$s)" + "Nessun risultato" + "Nessun nome della stanza" + "Spazio senza nome" + "Non cifrato" + "Non in linea" + "Licenze open source" + "o" + "Password" + "Persone" + "Collegamento permanente" + "Autorizzazione" + "Fissato" + "Per favore controlla la tua connessione Internet" + "Attendere prego…" + "Vuoi davvero terminare questo sondaggio?" + "Sondaggio: %1$s" + "Voti totali: %1$s" + "I risultati verranno mostrati al termine del sondaggio" + + "%d voto" + "%d voti" + + "Preparazione…" + "Informativa sulla privacy" + "Stanza privata" + "Spazio privato" + "Stanza pubblica" + "Spazio pubblico" + "Reazione" + "Reazioni" + "Motivo" + "Chiave di recupero" + "Aggiornamento…" + + "%1$d risposta" + "%1$d risposte" + + "Risposta a %1$s" + "Segnala un problema" + "Segnala un problema" + "Segnalazione inviata" + "Editor di testo avanzato" + "Stanza" + "Nome stanza" + "ad es. il nome del tuo progetto" + + "%1$d Stanza" + "%1$d Stanze" + + "Modifiche salvate" + "Salvataggio" + "Blocco schermo" + "Cerca qualcuno" + "Risultati di ricerca" + "Sicurezza" + "Visto da" + "Seleziona un account" + "Invia a" + "Invio in corso…" + "Invio fallito" + "Inviato" + "." + "Server non supportato" + "Server non raggiungibile" + "URL del server" + "Impostazioni" + "Condividi lo spazio" + "Posizione condivisa" + "Spazio condiviso" + "Disconnessione" + "Qualcosa è andato storto" + "Abbiamo riscontrato un problema. Per favore riprova." + "Spazio" + + "%1$d Spazio" + "%1$d Spazi" + + "Avvio della conversazione…" + "Adesivo" + "Operazione riuscita" + "Suggerimenti" + "Sincronizzazione" + "Sistema" + "Testo" + "Comunicazioni di terze parti" + "Discussione" + "Argomento" + "Di cosa parla questa stanza?" + "Impossibile decrittografare" + "Inviato da un dispositivo non sicuro" + "Non hai accesso a questo messaggio" + "L\'identità verificata del mittente è stata reimpostata" + "Non è stato possibile spedire inviti a uno o più utenti." + "Impossibile inviare inviti" + "Sblocca" + "Annulla silenzioso" + "Chiamata non supportata" + "Evento non supportato" + "Nome utente" + "Verifica annullata" + "Verifica completata" + "Verifica fallita" + "Verificato" + "Verifica dispositivo" + "Verifica l\'identità" + "Verifica utente" + "Video" + "Qualità alta" + "Migliore qualità ma dimensioni del file maggiori" + "Qualità bassa" + "Velocità di caricamento più elevata e dimensioni file più piccole" + "Qualità standard" + "Equilibrio tra qualità e velocità di caricamento" + "Messaggio vocale" + "In attesa…" + "In attesa del messaggio" + "Tu" + "L\'identità di %1$s è stata reimpostata. %2$s" + "L\'identità %2$s di %1$s sembra essere cambiata. %3$s" + "(%1$s)" + "L\'identità di %1$s è stata reimpostata." + "L\'identità %2$s di %1$s è stata reimpostata. %3$s" + "Ritira verifica" + "Il link %1$s ti porta ad un altro sito %2$s + +Sei sicuro di voler continuare?" + "Ricontrolla questo link" + "Seleziona la qualità predefinita dei video che carichi." + "Qualità del caricamento video" + "La dimensione massima consentita per il file è: %1$s" + "La dimensione del file è troppo grande per essere caricata" + "Stanza segnalata" + "Stanza segnalata ed abbandonata" + "Conferma" + "Errore" + "Operazione riuscita" + "Attenzione" + "Le modifiche non sono state salvate. Vuoi davvero tornare indietro?" + "Salvare le modifiche?" + "La dimensione massima consentita per il file è: %1$s" + "Seleziona la qualità del video che vuoi caricare." + "Seleziona la qualità di caricamento del video" + "Cerca emoji" + "Hai già effettuato l\'accesso su questo dispositivo come %1$s ." + "Il tuo homeserver deve essere aggiornato per supportare il Matrix Authentication Service e la creazione di account." + "Impossibile creare il collegamento permanente" + "%1$s non è riuscito a caricare la mappa. Riprova più tardi." + "Caricamento dei messaggi non riuscito" + "%1$s non è riuscito ad accedere alla tua posizione. Riprova più tardi." + "Invio del messaggio vocale fallito." + "La stanza non esiste più o l\'invito non è più valido." + "Messaggio non trovato" + "%1$s non ha l\'autorizzazione di accedere alla tua posizione. Puoi attivare l\'accesso nelle impostazioni." + "%1$s non ha l\'autorizzazione per accedere alla tua posizione. Attiva l\'accesso di seguito." + "%1$s non ha l\'autorizzazione di accedere al microfono. Attiva l\'accesso per registrare un messaggio vocale." + "Ciò può essere dovuto a problemi di rete o server." + "L\'indirizzo di questa stanza esiste già. Prova a modificare il campo dell\'indirizzo o a cambiare il nome della stanza" + "Alcuni caratteri non sono consentiti. Sono supportate solo lettere, cifre e i seguenti simboli ! $ & \'() * +/; =? @ [] - . _" + "Alcuni messaggi non sono stati inviati" + "Siamo spiacenti, si è verificato un errore" + "Il mittente dell\'evento non corrisponde al proprietario del dispositivo che lo ha inviato." + "L\'autenticità di questo messaggio cifrato non può essere garantita su questo dispositivo." + "Cifrato da un utente precedentemente verificato." + "Non cifrato." + "Cifrato da un dispositivo sconosciuto o eliminato." + "Cifrato da un dispositivo non verificato dal proprietario." + "Cifrato da un utente non verificato." + "🔐️ Unisciti a me su %1$s" + "Ehi, parliamo su %1$s: %2$s" + "%1$s Android" + "Scuoti per segnalare un problema" + "Istantanea schermo" + "%1$s: %2$s" + "Risposte" + "Rimuovi %1$s" + "Impostazioni" + "Selezione del file multimediale fallita, riprova." + "Premi su un messaggio e scegli “%1$s” per includerlo qui." + "Fissa i messaggi importanti così che possano essere trovati facilmente" + + "%1$d Messaggio fissato" + "%1$d Messaggi fissati" + + "Messaggi fissati" + "Stai per accedere al tuo account di %1$s per ripristinare la tua identità. Dopodiché verrai riportato all\'app." + "Non riesci a confermare? Vai al tuo account per ripristinare la tua identità." + "Ritira la verifica e invia" + "Puoi ritirare la tua verifica e inviare comunque questo messaggio, oppure annullarlo per ora e riprovare più tardi dopo aver riverificato %1$s." + "Il tuo messaggio non è stato inviato perché l\'identità verificata di %1$s è stata reimpostata." + "Invia comunque il messaggio" + "%1$s sta usando uno o più dispositivi non verificati. Puoi inviare il messaggio in ogni caso, oppure annullarlo e riprovare più tardi quando %2$s avrà verificato tutti i suoi dispositivi." + "Il tuo messaggio non è stato inviato perché %1$s non ha verificato tutti i dispositivi." + "Uno o più dispositivi non sono verificati. Puoi inviare il messaggio comunque, oppure annullarlo e riprovare più tardi dopo aver verificato tutti i tuoi dispositivi." + "Il tuo messaggio non è stato inviato perché non hai verificato uno o più dispositivi." + "Modifica impostazioni" + "Gestire lo spazio" + "Gestisci le stanze" + "Autorizzazioni" + "Modifica amministratori o proprietari" + "Elaborazione del file multimediale da caricare fallita, riprova." + "Impossibile recuperare i dettagli dell\'utente" + "Messaggio in %1$s" + "Espandi" + "Riduci" + "Stai già visualizzando questa stanza!" + "%1$s di %2$s" + "%1$s Messaggi fissati" + "Caricamento messaggio…" + "Mostra tutti" + "Conversazione" + "Condividi posizione" + "Condividi la mia posizione" + "Apri in Apple Maps" + "Apri in Google Maps" + "Apri in OpenStreetMap" + "Condividi questa posizione" + "Spazi che hai creato o a cui hai aderito." + "%1$s • %2$s" + "%1$s spazio" + "Spazi" + "Visualizza membri" + "Messaggio non inviato perché l\'identità verificata di %1$s è stata reimpostata." + "Messaggio non inviato perché %1$s non ha verificato tutti i dispositivi." + "Messaggio non inviato perché non hai verificato uno o più dispositivi." + "Posizione" + "Versione: %1$s (%2$s)" + "it" + "La cronologia messaggi non è disponibile su questo dispositivo" + "È necessario verificare questo dispositivo per accedere alla cronologia messaggi" + "Non hai accesso a questo messaggio" + "Impossibile decifrare il messaggio" + "Questo messaggio è stato bloccato perché il dispositivo non è verificato o perché il mittente deve verificare la tua identità." + diff --git a/libraries/ui-strings/src/main/res/values-ka/translations.xml b/libraries/ui-strings/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000..dd67ca0 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-ka/translations.xml @@ -0,0 +1,267 @@ + + + "წაშლა" + + "%1$d ციფრი ჩაიწერა" + "%1$d ციფრი ჩაიწერა" + + "პაროლის დამალვა" + "ბოლოში გადასვლა" + "მხოლოდ მოხსენიებები" + "დადუმებულია" + "გვერდი %1$d" + "პაუზა" + "PIN ველი" + "დაკვრა" + "გამოკითხვა" + "დასრულდა გამოკითხვა" + "რეაგირება %1$s-ით" + "რეაგირება სხვა ემოჯით" + "ნანახია %1$s-სა და %2$s-ს მიერ" + + "წაკითხულია %1$s და %2$d-ს ადამიანის მიერ" + "წაკითხულია %1$s და %2$d-ს ადამიანის მიერ" + + "წაიკითხეს: %s" + "შეეხეთ ყველაფრის საჩვენებლად" + "%1$s რეაქციის წაშლა" + "ფაილების გაგზავნა" + "პაროლის ჩვენება" + "დარეკვა" + "მომხმარებლის მენიუ" + "დეტალების ნახვა" + "ხმოვანი შეტყობინების ჩაწერა." + "ჩაწერის შეწყვეტა" + "მიღება" + "დამატება ქრონოლოგიაში" + "უკან" + "გაუქმება" + "აირჩიეთ ფოტო" + "გასუფთავება" + "დახურვა" + "დადასტურების დასრულება" + "დადასტურება" + "გაგრძელება" + "კოპირება" + "ბმულის კოპირება" + "დააკოპირეთ შეტყობინების ბმული" + "შექმნა" + "ოთახის შექმნა" + "უარყოფა" + "გამოკითხვის წაშლა" + "გამორთვა" + "გაუქმება" + "მზადაა" + "რედაქტირება" + "გამოკითხვის რედაქტირება" + "ჩართვა" + "გამოკითხვის დასრულება" + "შეიყვანეთ PIN" + "დაგავიწყდათ პაროლი?" + "გადაგზავნა" + "დაბრუნება" + "მოწვევა" + "ხალხის მოწვევა" + "ადამიანების დამატება %1$s" + "%1$s-ში ხალხის მოწვევა" + "მოწვევები" + "გაწევრიანება" + "შეიტყვეთ მეტი" + "დატოვება" + "საუბრის დატოვება" + "ოთახის დატოვება" + "მეტის ჩატვირთვა" + "ანგარიშის მართვა" + "მოწყობილობების მართვა" + "შემდეგი" + "არა" + "ახლა არა" + "OK" + "პარამეტრები" + "გახსნა პროგრამით:" + "Სწრაფი პასუხი" + "ციტირება" + "რეაგირება" + "წაშლა" + "პასუხი" + "პასუხი თემაში" + "ხარვეზის შეტყობინება" + "კონტენტის რეპორტი" + "განულება" + "ხელახლა ცდა" + "გაშიფვრის ხელახლა ცდა" + "შენახვა" + "ძიება" + "გაგზავნა" + "შეტყობინების გაგზავნა" + "გაზიარება" + "ბმულის გაზიარება" + "ხელახლა შედით" + "გამოსვლა" + "მაინც გასვლა" + "გამოტოვება" + "დაწყება" + "ჩატის დაწყება" + "დადასტურების დაწყება" + "დააწკაპუნეთ რუკის ჩასატვირთად" + "ფოტოს გადაღება" + "შეეხეთ ვარიანტების სანახავად" + "ხელახლა ცდა" + "წყაროს ნახვა" + "დიახ" + "შესახებ" + "მისაღები გამოყენების პოლიტიკა" + "გაფართოებული პარამეტრები" + "ანალიტიკა" + "გარეგნობა" + "აუდიო" + "დაბლოკილი მომხმარებლები" + "ბუშტები" + "ჩატის სარეზერვო ასლი" + "საავტორო უფლება" + "ოთახის შექმნა…" + "დატოვა ოთახი" + "მუქი" + "გაშიფვრის შეცდომა" + "დეველოპერის პარამეტრები" + "პირდაპირი ჩატი" + "(რედაქტირებულია)" + "რედაქტირება" + "* %1$s %2$s" + "დაშიფვრა ჩართულია" + "შეიყვანეთ თქვენი PIN" + "შეცდომა" + "ყველა" + "ვერ შესრულდა" + "რჩეული" + "რჩეულები" + "ფაილი" + "ფაილი შენახულია ჩამოტვირთვებში" + "შეტყობინების გადაგზავნა" + "GIF" + "სურათი" + "%1$s-ს პასუხად" + "დააინსტალირეთ APK" + "ეს Matrix ID ვერ მოიძებნა, ამიტომ მოწვევა შეიძლება არ იყოს მიღებული." + "ოთახის დატოვება" + "ღია" + "ბმული კოპირებულია გაცვლის ბუფერში" + "იტვირთება…" + + "%d სხვა" + "%d სხვა" + + + "%1$d წევრი" + "%1$d წევრები" + + "შეტყობინება" + "შეტყობინებაზე მოქმედებები" + "შეტყობინებების ფორმა" + "მესიჯი წაშლილია" + "თანამედროვე" + "დადუმება" + "შედეგი არ არის" + "ხაზგარეშე" + "ან" + "პაროლი" + "ხალხი" + "მუდმივი ბმული" + "ნებართვა" + "დარწმუნებული ხართ, რომ გსურთ ამ გამოკითხვის დასრულება?" + "გამოკითხვა: %1$s" + "სულ ხმები: %1$s" + "შედეგები გამოკითხვის დასრულების შემდეგ გამოჩნდება" + + "%d ხმა" + "%d ხმა" + + "კონფიდენციალურობის პოლიტიკა" + "კერძო ოთახი" + "რეაქცია" + "რეაქციები" + + "აღდგენის გასაღები" + + "განახლება…" + "პასუხი %1$s-ს" + "ხარვეზის შეტყობინება" + "შეტყობინება პრობლემაზე" + "რეპორტი გაგზავნილია" + "მდიდარი ტექსტის რედაქტორი" + "ოთახი" + "ოთახის სახელი" + "მაგ. თქვენი პროექტის სახელი" + "შენახული ცვლილებები" + "შენახვა" + "ეკრანის დაბლოკვა" + "ვიღაცის ძებნა" + "ძიების შედეგები" + "უსაფრთხოება" + "Ნანახი" + "იგზავნება…" + "გაგზავნა ვერ მოხერხდა" + "გაგზავნილი" + "სერვერი არ არის მხარდაჭერილი" + "სერვერის ვებ-მისამართი" + "პარამეტრები" + "გაზიარებული მდებარეობა" + "გასვლა…" + "ჩატის დაწყება…" + "სტიკერი" + "წარმატება" + "შეთავაზებები" + "სინქრონიზაცია" + "სისტემა" + "ტექსტი" + "მესამე პირის შენიშვნები" + "თემა" + "თემა" + "რა თემებს ეხება ეს ოთახი?" + "გაშიფვრა ვერ მოხერხდა" + "მოსაწვევები ვერ გაეგზავნა ერთ ან მეტ მომხმარებელს." + "მოწვევის (ების) გაგზავნა შეუძლებელია" + "განბლოკვა" + "დადუმების გაუქმება" + "მხარდაუჭერელი მოვლენა" + "მომხმარებლის სახელი" + "დადასტურება გაუქმდა" + "დადასტურება დასრულებულია" + "დაადასტურეთ მოწყობილობა" + "ვიდეო" + "ხმოვანი შეტყობინება" + "მოცდა…" + "ლოდინი ამ შეტყობინებისათვის" + "დადასტურება" + "შეცდომა" + "წარმატება" + "გაფრთხილება" + "თქვენი ცვლილებები არაა შენახული. დარწმუნებული ხართ დაბრუნებაში?" + "შენახვა?" + "მუდმივი ბმულის შექმნა ვერ მოხერხდა" + "ვერ გამოვიდა რუკის %1$s ჩატვირთვა. გთხოვთ, მოგვიანებით სცადოთ." + "შეტყობინებების ჩატვირთვა ვერ მოხერხდა" + "%1$s ვერ მოახერხა თქვენი ადგილმდებარეობაზე წვდომა. გთხოვთ, მოგვიანებით სცადოთ." + "თქვენი ხმოვანი შეტყობინების ატვირთვა ვერ მოხერხდა." + "%1$s არ აქვს თქვენს ადგილმდებარეობაზე წვდომის ნებართვა. შეგიძლიათ ჩართოთ წვდომა პარამეტრებში." + "%1$s არ აქვს თქვენს ადგილმდებარეობაზე წვდომის ნებართვა. ჩართეთ წვდომა ქვემოთ" + "%1$s არ აქვს თქვენს მიკროფონზე წვდომის ნებართვა. ჩართეთ წვდომა ხმოვანი შეტყობინების ჩასაწერად." + "ზოგიერთი შეტყობინება არ გაიგზავნა" + "ბოდიშით, შეცდომა მოხდა" + "🔐️ შემომიერთდით %1$s" + "გაგიმარჯოს! მესაუბრე %1$s-ზე: %2$s" + "%1$s Android" + "შეცდომის შესატყობინებლად ტელეფონის შენჯღრევა" + "მედიის შერჩევა ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა." + "მედიის ატვირთვა ვერ მოხერხდა. გთხოვთ, სცადოთ ხელახლა." + "მომხმარებლის მონაცემების მოძიება ვერ მოხერხდა" + "მდებარეობის გაზიარება" + "ჩემი მდებარეობის გაზიარება" + "Apple Maps-ში გახსნა" + "Google Maps-ში გახსნა" + "OpenStreetMap-ში გახსნა" + "ამ ადგილის გაზიარება" + "ადგილმდებარეობა" + "ვერსია: %1$s (%2$s)" + "ka" + diff --git a/libraries/ui-strings/src/main/res/values-ko/translations.xml b/libraries/ui-strings/src/main/res/values-ko/translations.xml new file mode 100644 index 0000000..256e561 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-ko/translations.xml @@ -0,0 +1,454 @@ + + + "반응 추가: %1$s" + "아바타" + "메시지 텍스트 필드 최소화" + "삭제" + + "%1$d자리 입력됨" + + "아바타 편집" + "전체 주소는 다음과 같습니다 %1$s" + "암호화 세부 정보" + "메시지 텍스트 필드 확장" + "비밀번호 숨기기" + "통화 참가" + "맨 아래로 이동" + "지도를 내 위치로 이동" + "멘션만" + "음소거함" + "새로운 언급" + "새 메시지" + "진행 중인 통화" + "다른 사용자의 아바타" + "페이지 %1$d" + "일시중지" + "음성 메시지, 지속 시간: %1$s, 현재 위치: %2$s" + "PIN 필드" + "재생" + "투표" + "종료된 투표" + "%1$s에 반응하세요" + "다른 이모지로 반응하세요" + "읽은 사람 %1$s 그리고 %2$s" + + "읽은 사람 %1$s 그리고 %2$d 다른 사람들" + + "%1$s 님이 읽음" + "모두 표시하려면 탭하세요" + "반응 제거: %1$s" + "%1$s 반응을 제거하세요" + "방 아바타" + "파일 보내기" + "시간 제한 조치가 필요합니다" + "비밀번호 표시" + "통화 시작" + "묘비 방" + "사용자 아바타" + "사용자 메뉴" + "아바타 보기" + "세부 정보 보기" + "음성 메시지, 재생 시간: %1$s" + "음성 메시지를 녹음합니다." + "녹화 중지" + "당신의 아바타" + "수락" + "캡션 추가" + "타임라인에 추가" + "이전" + "통화" + "취소" + "현재 취소" + "사진 선택" + "지우기" + "닫기" + "인증 완료" + "확인" + "비밀번호 확인" + "계속" + "복사" + "캡션 복사" + "링크 복사" + "메시지에 링크 복사" + "텍스트 복사" + "만들기" + "방 만들기" + "비활성화" + "계정 비활성화" + "거절" + "거부 및 차단" + "투표 삭제" + "비활성화" + "취소" + "닫기" + "완료" + "편집" + "캡션 편집" + "투표 수정" + "활성화" + "투표 종료" + "PIN을 입력하세요" + "완료" + "비밀번호를 잊으셨나요?" + "전달" + "뒤로 가기" + "무시하다" + "초대" + "사람 초대하기" + "%1$s에 친구 초대" + "%1$s에 사람 초대" + "초대" + "참가하기" + "더 알아보기" + "떠나기" + "대화에서 나가기" + "방 떠나기" + "더 불러오기" + "계정 관리" + "기기 관리" + "메시지" + "다음" + "아니오" + "나중에" + "확인" + "컨텍스트 메뉴 열기" + "다음" + "다음으로 열기" + "고정" + "빠른 답장" + "인용" + "반응" + "거부" + "제거" + "캡션 제거" + "메시지 삭제" + "답변" + "스레드에서 답장" + "신고" + "버그 보고" + "컨텐츠 신고" + "대화 신고" + "방 신고" + "초기화" + "신원 재설정" + "재시도" + "복호화 재시도" + "저장" + "검색" + "보내기" + "편집한 메시지 보내기" + "메시지 보내기" + "음성 메세지 보내기" + "공유" + "링크 공유" + "표시" + "다시 로그인" + "로그아웃" + "무시하고 로그아웃" + "건너뛰기" + "시작" + "채팅 시작" + "인증 시작" + "탭해서 지도 불러오기" + "사진 찍기" + "옵션을 보려면 탭하세요" + "다시 시도하기" + "고정 해제" + "보기" + "타임라인에서 보기" + "소스 보기" + "예" + "네, 다시 시도하세요" + "이제 서버가 새롭고 더 빠른 프로토콜을 지원합니다. 지금 로그아웃한 다음 다시 로그인하여 업그레이드하세요. 지금 업그레이드하면 나중에 이전 프로토콜이 제거될 때 강제 로그아웃되는 것을 방지할 수 있습니다." + "업그레이드 가능" + "정보" + "이용 목적 제한 방침" + "캡션 추가" + "고급 설정" + "이미지" + "통계" + "방에서 나갔습니다" + "세션에서 로그아웃되었습니다." + "외관" + "소리" + "차단한 사용자" + "버블" + "통화 시작" + "채팅 백업" + "클립보드에 복사됨" + "저작권" + "방 만드는 중…" + "요청이 취소되었습니다" + "방 떠남" + "초대 거부됨" + "다크" + "복호화 오류" + "개발자 설정" + "기기 ID" + "다이렉트 채팅" + "이 메시지를 다시 표시하지 마세요" + "다운로드 실패" + "다운로드 중" + "(수정됨)" + "수정 중" + "캡션 편집" + "* %1$s %2$s" + "빈 파일" + "암호화" + "암호화 활성화됨" + "PIN을 입력하세요" + "오류" + "오류가 발생했습니다, 새 메시지 알림을 받지 못할 수 있습니다. 설정에서 알림 문제를 해결하세요. + +이유: %1$s." + "모두" + "실패" + "즐겨찾기" + "즐겨찾기 됨" + "파일" + "파일 삭제됨" + "파일 저장됨" + "파일이 다운로드에 저장됨" + "메시지 전달" + "자주 사용되는" + "GIF" + "이미지" + "%1$s에게 답장" + "APK 설치" + "Matrix ID를 찾을 수 없기 때문에 초대가 수신되지 않을 수도 있습니다." + "방을 떠나는 중" + "라이트" + "줄이 클립보드에 복사되었습니다." + "링크가 클립보드에 복사됨" + "로딩 중…" + "더 많은 내용이 로딩 중…" + + "%d 기타" + + + "%1$d 회원" + + "메시지" + "메시지 작업" + "메시지 레이아웃" + "메시지 제거됨" + "모던" + "음소거" + "%1$s (%2$s)" + "결과 없음" + "방 이름 없음" + "암호화되지 않음" + "오프라인" + "오픈 소스 라이선스" + "또는" + "비밀번호" + "사람" + "퍼머링크" + "권한" + "고정됨" + "인터넷 연결을 확인해 주세요" + "기다려 주세요…" + "정말로 이 투표를 종료하시겠어요?" + "투표: %1$s" + "총 투표수: %1$s" + "결과는 투표가 끝난 이후에 공개됨" + + "%d 에 투표" + + "준비 중…" + "개인정보 처리방침" + "비공개 방" + "비공개 스페이스" + "공개 방" + "공개 스페이스" + "반응" + "반응" + "이유" + "복구 키" + "새로고침 중…" + + "%1$d 답변" + + "%1$s님에게 답장하는 중" + "버그 보고" + "문제 보고" + "보고 제출됨" + "리치 텍스트 편집기" + "방" + "방 이름" + "예: 프로젝트명" + + "%1$d 방" + + "저장된 변경 사항" + "저장" + "화면 잠금" + "사람 검색하기" + "검색 결과" + "보안" + "본 사람" + "보내기" + "전송 중…" + "전송 실패" + "보냄" + ". " + "지원되지 않는 서버" + "서버 URL" + "설정" + "공유된 위치" + "로그아웃" + "뭔가 잘못됐어요" + "문제가 발생했습니다. 다시 시도해 주세요." + "스페이스" + + "%1$d 스페이스" + + "채팅 시작 중…" + "스티커" + "성공" + "제안" + "동기화 중" + "시스템" + "글자" + "제3자 고지" + "스레드" + "주제" + "여기는 무슨 방인가요?" + "해독 불가" + "보안되지 않은 장치에서 전송됨" + "이 메시지에 액세스할 수 없습니다" + "발신자의 검증된 신원이 재설정되었습니다." + "한 명 이상의 사용자에게 초대를 보낼 수 없습니다." + "초대를 보낼 수 없음" + "잠금 해제" + "음소거 해제" + "지원되지 않는 통화" + "지원되지 않는 이벤트" + "아이디" + "인증 취소됨" + "인증 완료" + "검증 실패" + "검증됨" + "기기 인증" + "신원 확인" + "사용자 검증" + "동영상" + "고품질" + "최고의 품질이지만 파일 크기가 더 큽니다." + "저품질" + "가장 빠른 업로드 속도와 가장 작은 파일 크기" + "표준 품질" + "품질과 업로드 속도의 균형" + "음성 메시지" + "대기 중…" + "이 메시지를 기다리고 있습니다" + "당신" + "%1$s 의 신원이 재설정되었습니다. %2$s" + "%1$s의 %2$s 신원이 재설정되었습니다. %3$s" + "(%1$s)" + "%1$s의 신원이 재설정되었습니다." + "%1$s의 %2$s 신원이 재설정되었습니다. %3$s" + "확인 취소" + "%1$s 링크는 다른 사이트로 이동합니다 %2$s + +정말 계속 진행하시겠습니까?" + "이 링크를 다시 확인하세요." + "업로드하는 비디오의 기본 품질을 선택하세요." + "비디오 업로드 품질" + "허용되는 최대 파일 크기: %1$s +" + "파일 크기가 너무 커서 업로드할 수 없습니다." + "방 신고됨" + "신고 후 방 나가기" + "확인" + "오류" + "성공" + "경고" + "변경 내용이 저장되지 않았습니다. 정말로 돌아가시겠습니까?" + "변경 사항을 저장하시겠습니까?" + "허용되는 최대 파일 크기: %1$s +" + "업로드할 비디오의 품질을 선택하세요." + "비디오 업로드 품질 선택" + "Matrix Authentication Service 및 계정 생성을 지원하려면 홈서버를 업그레이드해야 합니다." + "퍼머링크 생성 실패" + "%1$s에서 맵을 로딩할 수 없습니다. 다시 시도해주세요." + "메시지 로딩 실패" + "%1$s가 위치에 접근할 수 없습니다. 나중에 다시 시도해 주세요." + "음성 메시지 업로드에 실패했습니다." + "해당 방이 더 이상 존재하지 않거나 초대장이 더 이상 유효하지 않습니다." + "메시지를 찾을 수 없습니다" + "%1$s에서 위치에 접근할 수 있는 권한이 없습니다. 설정에서 활성화가 가능합니다." + "%1$s에서 위치에 접근할 수 있는 권한이 없습니다. 아래에서 허용해주세요." + "%1$s 는 마이크에 액세스할 수 있는 권한이 없습니다. 음성 메시지를 녹음할 수 있도록 액세스를 허용하세요." + "이것은 네트워크 또는 서버 문제로 인해 발생할 수 있습니다." + "이 방 주소는 이미 존재합니다. 방 주소 필드를 편집하거나 방 이름을 변경해 보세요." + "일부 문자는 허용되지 않습니다. 로마자, 숫자 및 다음 기호만 지원됩니다! $ &amp; ' ( ) * + / ; = ? @ [ ] - . _" + "일부 메시지가 전송되지 않았습니다" + "이런, 오류가 발생했어요" + "이벤트의 발신자가 이벤트를 보낸 장치의 소유자와 일치하지 않습니다." + "이 장치에서는 이 암호화된 메시지의 진위 여부를 보장할 수 없습니다." + "이전에 검증된 사용자에 의해 암호화되었습니다." + "암호화되지 않음." + "알 수 없거나 삭제된 장치에 의해 암호화됩니다." + "소유자가 확인하지 않은 장치에 의해 암호화되었습니다." + "검증되지 않은 사용자에 의해 암호화되었습니다." + "🔐️ %1$s에 참여하기" + "%1$s에서 대화해요: %2$s" + "%1$s Android" + "강하게 흔들어서 오류 보고하기" + "스크린샷" + "%1$s: %2$s" + "옵션" + "%1$s 제거" + "설정" + "미디어 선택에 실패했습니다. 다시 시도해 주세요." + "메시지를 누르고 \"%1$s\" 를 선택하여 여기에 포함합니다." + "중요한 메시지를 고정하여 쉽게 찾을 수 있도록 합니다" + + "%1$d 고정된 메시지" + + "고정된 메세지" + "%1$s 계정으로 이동하여 신원을 재설정하시게 됩니다. 이후 앱으로 돌아가게 됩니다." + "확인할 수 없으신가요? 계정으로 이동하여 신원을 재설정하세요." + "인증 철회 및 전송" + "확인 절차를 철회하고 이 메시지를 보내거나, 지금 취소하고 나중에 %1$s 을 확인한 후 다시 시도할 수 있습니다." + "%1$s의 인증된 신원이 재설정되어 귀하의 메시지가 전송되지 않았습니다." + "아무튼 메시지 보내기" + "%1$s 는 하나 이상의 확인되지 않은 장치를 사용하고 있습니다. 메시지를 보내거나, %2$s 이 모든 장치를 확인한 후에 다시 시도할 수 있습니다." + "%1$s 이(가) 모든 기기를 확인하지 않았기 때문에 귀하의 메시지가 전송되지 않았습니다." + "하나 이상의 기기가 확인되지 않았습니다. 메시지를 보내거나, 모든 기기를 확인한 후 나중에 다시 시도할 수 있습니다." + "하나 이상의 기기를 확인하지 않았기 때문에 메시지가 전송되지 않았습니다" + "관리자 또는 소유자 편집" + "미디어 업로드 처리가 실패했습니다. 다시 시도해 주세요." + "사용자 세부 정보를 가져올 수 없습니다." + "메시지 %1$s" + "펼치기" + "줄이다" + "이 방을 이미 보고 있습니다!" + "%2$s 의 %1$s" + "%1$s 고정된 메시지" + "메시지 로딩 중…" + "모두 보기" + "채팅" + "위치 공유" + "내 위치 공유" + "Apple Maps에서 열기" + "Google Maps에서 열기" + "OpenStreetMap에서 열기" + "이 위치 공유" + "당신이 스페이스를 만들거나 가입했습니다." + "%1$s•%2$s" + "스페이스" + "%1$s의 인증된 신원이 재설정되어 메시지가 전송되지 않았습니다." + "%1$s 이 모든 장치를 확인하지 않았기 때문에 메시지가 전송되지 않았습니다." + "하나 이상의 기기를 확인하지 않았기 때문에 메시지가 전송되지 않았습니다." + "위치" + "버전: %1$s (%2$s)" + "ko" + "이 장치에서는 과거 메시지를 사용할 수 없습니다." + "이전 메시지에 액세스하려면 이 장치를 확인해야 합니다." + "이 메시지에 액세스할 수 없습니다" + "메시지를 해독할 수 없습니다." + "이 메시지는 귀하가 기기를 확인하지 않았거나 발신자가 귀하의 신원을 확인해야 하기 때문에 차단되었습니다." + diff --git a/libraries/ui-strings/src/main/res/values-lt/translations.xml b/libraries/ui-strings/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000..535e57f --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-lt/translations.xml @@ -0,0 +1,149 @@ + + + "Pridėti reakciją: %1$s" + "Pseudoportretas" + "Slėpti slaptažodį" + "Siųsti failus" + "Rodyti slaptažodį" + "Vartotojo meniu" + "Priimti" + "Atgal" + "Atšaukti" + "Pasirinkti nuotrauką" + "Išvalyti" + "Uždaryti" + "Užbaigti patvirtinimą" + "Patvirtinti" + "Tęsti" + "Kopijuoti" + "Kopijuoti nuorodą" + "Sukurti" + "Kurti kambarį" + "Atmesti" + "Išjungti" + "Atlikta" + "Taisyti" + "Įjungti" + "Pamiršote slaptažodį?" + "Kviesti" + "Pakviesti žmonių" + "Kviesti žmones į %1$s" + "Kvietimai" + "Sužinoti daugiau" + "Palikti" + "Palikti pokalbį" + "Palikti kambarį" + "Įkelti daugiau" + "Toliau" + "Ne" + "Ne dabar" + "Gerai" + "Sparčiai atsakyti" + "Cituoti" + "Šalinti" + "Atsakyti" + "Pranešti apie riktą" + "Pranešti apie turinį" + "Bandyti dar kartą" + "Pakartoti iššifravimą" + "Išsaugoti" + "Ieškoti" + "Siųsti" + "Siųsti žinutę" + "Dalintis" + "Bendrinti nuorodą" + "Prisijungti vėl" + "Atsijungti" + "Atsijungti vis tiek" + "Praleisti" + "Pradėti" + "Pradėti pokalbį" + "Pradėti patvirtinimą" + "Fotografuoti" + "Peržiūrėti šaltinį" + "Taip" + "Apie" + "Analitika" + "Garsas" + "Burbulai" + "Pokalbio atsarginė kopija" + "Kuriamas kambarys…" + "Išėjo iš kambario" + "Iššifravimo klaida" + "Kūrėjo nustatymai" + "Asmeninis pokalbis" + "(taisyta)" + "Taisymas" + "* %1$s %2$s" + "Šifravimas įjungtas" + "Klaida" + "Failas" + "Failas išsaugotas aplanke Atsisiuntimai" + "GIF" + "Paveikslėlis" + "Šio Matrix ID nepavyksta rasti, todėl kvietimas gali būti negautas." + "Paliekamas kambarys" + "Nuoroda nukopijuota į iškarpinę" + "Įkeliama…" + + "%1$d narys" + "%1$d nariai" + "%1$d narių" + + "Žinutė" + "Žinutės išdėstymas" + "Žinutė pašalinta" + "Modernus" + "Nėra rezultatų" + "Neprisijungta" + "Slaptažodis" + "Žmonės" + "Nuolatinė nuoroda" + "Privatus kambarys" + "Reakcijos" + "Atsakant %1$s" + "Pranešti apie klaidą" + "Skundas pateiktas" + "Kambario pavadinimas" + "pvz., Jūsų projekto pavadinimas" + "Ieškoti ko nors" + "Paieškos rezultatai" + "Saugumas" + "Siunčiama…" + "Serveris nepalaikomas" + "Serverio URL" + "Nustatymai" + "Atsijungiama" + "Pradedamas pokalbis…" + "Lipdukas" + "Pavyko" + "Pasiūlymai" + "Tema" + "Apie ką šis kambarys?" + "Nepavyko iššifruoti" + "Kvietimų nepavyko išsiųsti vienam ar keliems vartotojams." + "Nepavyko išsiųsti kvietimo (-ų)" + "Nepalaikomas įvykis" + "Vartotojo vardas" + "Patvirtinimas atšauktas" + "Patvirtinimas baigtas" + "Vaizdo įrašas" + "Laukiama…" + "Patvirtinimas" + "Klaida" + "Pavyko" + "Įspėjimas" + "Nepavyko sukurti nuolatinės nuorodos" + "Nepavyko įkelti žinučių" + "Kai kurios žinutės nebuvo išsiųstos" + "Atsiprašome, įvyko klaida" + "🔐️ Prisijunkite prie manęs %1$s" + "Ei, pasikalbėkime naudojant %1$s: %2$s" + "%1$s Android" + "Papurtykite, kad praneštumėte apie klaidą" + "Nepavyko pasirinkti laikmenos, pabandykite dar kartą." + "Nepavyko apdoroti įkeliamos laikmenos, bandykite dar kartą." + "Nepavyko gauti naudotojo išsamios informacijos." + "Versija: %1$s (%2$s)" + "lt" + diff --git a/libraries/ui-strings/src/main/res/values-nb/translations.xml b/libraries/ui-strings/src/main/res/values-nb/translations.xml new file mode 100644 index 0000000..e64a005 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-nb/translations.xml @@ -0,0 +1,479 @@ + + + "Legg til reaksjon: %1$s" + "Profilbilde" + "Minimer meldingstekstfeltet" + "Slett" + + "%1$d siffer angitt" + "%1$d sifre angitt" + + "Rediger avatar" + "Den fullstendige adressen vil være %1$s" + "Krypteringsdetaljer" + "Utvid meldingstekstfeltet" + "Skjul passord" + "Bli med i samtale" + "Hopp til bunnen" + "Flytt kartet til min lokasjon" + "Bare omtaler" + "Dempet" + "Nye omtaler" + "Nye meldinger" + "Pågående samtale" + "En annen brukers avatar" + "Side %1$d" + "Setter på pause" + "Talemelding, lengde: %1$s, nåværende posisjon: %2$s" + "PIN-felt" + "Spill av" + "Avstemning" + "Avsluttet avstemning" + "Reager med %1$s" + "Reager med andre emojier" + "Lest av %1$s og %2$s" + + "Lest av %1$s og %2$d andre" + "Lest av %1$s og %2$d andre" + + "Lest av %1$s" + "Trykk for å vise alle" + "Fjern reaksjonen med %1$s" + "Fjern reaksjonen med %1$s" + "Romavatar" + "Sende filer" + "Tidsbegrenset handling kreves, du har ett minutt på deg til å verifisere" + "Vis passord" + "Start en samtale" + "Deaktivert rom" + "Brukeravatar" + "Brukermeny" + "Vis avatar" + "Vis detaljer" + "Talemelding, lengde: %1$s" + "Ta opp talemelding." + "Stopp opptaket" + "Din avatar" + "Godta" + "Legg til bildetekst" + "Legg til i tidslinjen" + "Tilbake" + "Ring" + "Avbryt" + "Avbryt for nå" + "Velg bilde" + "Tøm" + "Lukk" + "Fullfør verifisering" + "Bekreft" + "Bekreft passord" + "Fortsett" + "Kopier" + "Kopier bildetekst" + "Kopier lenke" + "Kopier lenke til melding" + "Kopier tekst" + "Opprett" + "Opprett et rom" + "Deaktiver" + "Deaktiver kontoen" + "Avslå" + "Avslå og blokker" + "Slett avstemning" + "Velg bort alle" + "Deaktiver" + "Forkast" + "Avvis" + "Ferdig" + "Rediger" + "Rediger bildetekst" + "Rediger avstemning" + "Aktiver" + "Avslutt avstemning" + "Skriv inn PIN-koden" + "Fullfør" + "Glemt passordet?" + "Videresend" + "Gå tilbake" + "Gå til innstillinger" + "Ignorer" + "Inviter" + "Inviter folk" + "Inviter folk til%1$s" + "Inviter folk til %1$s" + "Invitasjoner" + "Bli med" + "Finn ut mer" + "Forlat" + "Forlat samtalen" + "Forlat rommet" + "Forlat område" + "Last inn mer" + "Administrer konto" + "Administrer enheter" + "Melding" + "Minimer" + "Neste" + "Nei" + "Ikke nå" + "OK" + "Åpne kontekstmenyen" + "Innstillinger" + "Åpne med" + "Fest" + "Raskt svar" + "Siter" + "Reagere" + "Avvis" + "Fjern" + "Fjern bildetekst" + "Fjern melding" + "Svar" + "Svar i tråden" + "Rapporter" + "Rapporter feil" + "Rapporter innhold" + "Rapporter samtale" + "Rapporter rommet" + "Tilbakestill" + "Tilbakestill identitet" + "Prøv på nytt" + "Prøv dekryptering på nytt" + "Lagre" + "Søke" + "Velg alle" + "Sende" + "Send redigert melding" + "Send melding" + "Send talemelding" + "Dele" + "Dele lenke" + "Vis" + "Logg på igjen" + "Logg ut" + "Logg ut likevel" + "Hopp over" + "Start" + "Start chat" + "Start verifisering" + "Trykk for å laste inn kart" + "Ta bilde" + "Trykk for alternativer" + "Prøv igjen" + "Løsne" + "Vis" + "Vis i tidslinjen" + "Vis kilde" + "Ja" + "Ja, prøv igjen" + "Serveren din støtter nå en ny, raskere protokoll. Logg ut og logg inn igjen for å oppgradere nå. Ved å gjøre dette nå, unngår du å bli tvunget til å logge ut når den gamle protokollen fjernes senere." + "Oppgradering tilgjengelig" + "Om" + "Retningslinjer for akseptabel bruk" + "Legg til en konto" + "Legg til en annen konto" + "Legger til bildetekst" + "Avanserte innstillinger" + "et bilde" + "Analyse" + "Du forlot rommet" + "Du ble logget ut av økten" + "Utseende" + "Lyd" + "Beta" + "Blokkerte brukere" + "Bobler" + "Samtale startet" + "Chat backup" + "Kopiert til utklippstavlen" + "Opphavsrett" + "Oppretter rom …" + "Forespørsel kansellert" + "Forlot rommet" + "Forlot område" + "Invitasjon avslått" + "Mørk" + "Dekrypteringsfeil" + "Beskrivelse" + "Alternativer for utviklere" + "Enhets-ID" + "Direkte chat" + "Ikke vis dette igjen" + "Nedlastingen mislyktes" + "Laster ned" + "(redigert)" + "Redigering" + "Redigerer bildetekst" + "* %1$s %2$s" + "Tom fil" + "Kryptering" + "Kryptering aktivert" + "Skriv inn PIN-koden din" + "Feil" + "Det har oppstått en feil, og vil kanskje ikke motta varsler om nye meldinger. Vennligst feilsøk varslinger fra innstillingene. + +Årsak: %1$s." + "Alle" + "Mislyktes" + "Favoritt" + "Favoritt" + "Fil" + "Fil slettet" + "Fil lagret" + "Fil lagret i Nedlastinger" + "Videresend melding" + "Ofte brukt" + "GIF" + "Bilde" + "Som svar på %1$s" + "Installer APK" + "Finner ikke denne Matrix-IDen, så invitasjonen blir kanskje ikke mottatt." + "Forlater rommet" + "Forlater området" + "Lys" + "Linje kopiert til utklippstavlen" + "Lenke kopiert til utklippstavlen" + "Laster inn…" + "Laster inn mer…" + + "%d andre" + "%d andre" + + + "%1$d medlem" + "%1$d medlemmer" + + "Melding" + "Meldingshandlinger" + "Meldingsoppsett" + "Melding fjernet" + "Moderne" + "Demp" + "%1$s (%2$s)" + "Ingen resultater" + "Ingen romnavn" + "Ikke noe områdenavn" + "Ikke kryptert" + "Frakoblet" + "Åpen kildekode-lisenser" + "eller" + "Passord" + "Personer" + "Permalenke" + "Tillatelse" + "Festet" + "Vennligst sjekk internettforbindelsen din" + "Vennligst vent…" + "Er du sikker på at du vil avslutte denne avstemningen?" + "Avstemning: %1$s" + "Totalt antall stemmer: %1$s" + "Resultatene vises etter at avstemningen er avsluttet" + + "%d stemme" + "%d stemmer" + + "Forbereder…" + "Retningslinjer for personvern" + "Privat rom" + "Privat område" + "Offentlig rom" + "Offentlig område" + "Reaksjon" + "Reaksjoner" + "Årsak" + "Gjenopprettingsnøkkel" + "Oppdaterer…" + + "%1$d svar" + + "Svar til %1$s" + "Rapporter en feil" + "Rapporter et problem" + "Rapport sendt inn" + "Redigeringsprogram for rik tekst" + "Rom" + "Romnavn" + "f.eks. prosjektnavnet ditt" + + "%1$d Rom" + "%1$d Rom" + + "Lagrede endringer" + "Lagrer" + "Skjermlås" + "Søk etter noen" + "Søkeresultater" + "Sikkerhet" + "Sett av" + "Velg en konto" + "Sendt til" + "Sender…" + "Kunne ikke sende" + "Sendt" + "Server støttes ikke" + "Serveren er ikke tilgjengelig" + "URL-adresse til server" + "Innstillinger" + "Del område" + "Delt posisjon" + "Delt område" + "Logger av" + "Noe gikk galt" + "Vi har støtt på et problem. Vennligst prøv igjen." + "Område" + + "%1$d Område" + "%1$d Områder" + + "Starter chat…" + "Klistremerke" + "Suksess" + "Forslag" + "Synkroniserer" + "System" + "Tekst" + "Varsler fra tredjeparter" + "Tråd" + "Emne" + "Hva er dette rommet for?" + "Kan ikke dekryptere" + "Sendt fra en usikker enhet" + "Du har ikke tilgang til denne meldingen" + "Avsenderens verifiserte identitet ble tilbakestilt" + "Invitasjoner kunne ikke sendes til en eller flere brukere." + "Kunne ikke sende invitasjon(er)" + "Lås opp" + "Slå på lyden" + "Ikke støttet anrop" + "Hendelsen er ikke støttet" + "Brukernavn" + "Verifisering kansellert" + "Verifisering fullført" + "Verifisering mislyktes" + "Verifisert" + "Verifiser enhet" + "Verifiser identiteten" + "Verifiser bruker" + "Video" + "Høy kvalitet" + "Beste kvalitet, men større filstørrelse" + "Lav kvalitet" + "Raskeste opplastingshastighet og minste filstørrelse" + "Standard kvalitet" + "Balansen mellom kvalitet og opplastingshastighet" + "Talemelding" + "Venter…" + "Venter på denne meldingen" + "Du" + "%1$s\'s identitet ble tilbakestilt. %2$s" + "%1$ss %2$s-identitet ble tilbakestilt. %3$s" + "(%1$s)" + "%1$ss identitet ble tilbakestilt." + "%1$ss %2$s identitet ble tilbakestilt. %3$s" + "Trekk tilbake verifisering" + "Lenken %1$s tar deg til et annet nettsted %2$s + +Er du sikker på at du vil fortsette?" + "Dobbeltsjekk denne lenken" + "Velg standardkvaliteten på videoene du laster opp." + "Kvalitet på videoopplasting" + "Maksimal tillatt filstørrelse er: %1$s" + "Filstørrelsen er for stor til å kunne lastes opp" + "Rommet er rapportert" + "Rapportert og forlatt rommet" + "Bekreftelse" + "Feil" + "Suksess" + "Advarsel" + "Endringene dine er ikke lagret. Er du sikker på at du vil gå tilbake?" + "Lagre endringer?" + "Maksimal tillatt filstørrelse er: %1$s" + "Velg kvaliteten på videoen du vil laste opp." + "Velg kvalitet for videoopplasting" + "Søk etter emojier" + "Du er allerede logget inn på denne enheten som %1$s." + "Hjemmeserveren din må oppgraderes for å støtte Matrix Authentication Service og kontooppretting." + "Opprettelse av permalenken mislyktes" + "%1$s kunne ikke laste inn kartet. Prøv igjen senere." + "Kunne ikke laste inn meldinger" + "%1$s fikk ikke tilgang til lokasjonen din. Vennligst prøv igjen senere." + "Kunne ikke laste opp talemeldingen din." + "Rommet eksisterer ikke lenger, eller invitasjonen er ikke lenger gyldig." + "Melding ikke funnet" + "%1$s har ikke tilgang til lokasjonen din. Du kan aktivere tilgang i Innstillinger." + "%1$s har ikke tilgang til lokasjonen din. Aktiver tilgang nedenfor." + "%1$s har ikke tilgang til mikrofonen din. Aktiver tilgang til å ta opp en talemelding." + "Dette kan skyldes nettverks- eller serverproblemer." + "Denne romadressen finnes allerede. Prøv å redigere romadressefeltet eller endre romnavnet" + "Noen tegn er ikke tillatt. Bare bokstaver, sifre og følgende symboler støttes! $ & \'() * +/; =? @ [] - . _" + "Noen meldinger er ikke sendt" + "Beklager, det oppstod en feil" + "Avsenderen av hendelsen samsvarer ikke med eieren av enheten som sendte den." + "Ektheten til denne krypterte meldingen kan ikke garanteres på denne enheten." + "Kryptert av en tidligere verifisert bruker." + "Ikke kryptert." + "Kryptert av en ukjent eller slettet enhet." + "Kryptert med en enhet som ikke er verifisert av eieren." + "Kryptert av en ubekreftet bruker." + "🔐️ Bli med meg på %1$s" + "Hei, snakk med meg på %1$s: %2$s" + "%1$s Android" + "Rageshake for å rapportere feil" + "Skjermbilde" + "%1$s: %2$s" + "Alternativer" + "Fjern %1$s" + "Innstillinger" + "Kunne ikke velge medium, prøv igjen." + "Trykk på en melding og velg “%1$s” for å inkludere her." + "Fest viktige meldinger slik at de lett kan ses" + + "%1$d Festet melding" + "%1$d Festede meldinger" + + "Festede meldinger" + "Du er i ferd med å gå til %1$s kontoen din for å tilbakestille identiteten din. Etterpå blir du tatt tilbake til appen." + "Kan du ikke bekrefte? Gå til kontoen din for å tilbakestille identiteten din." + "Trekk tilbake verifikasjon og send" + "Du kan trekke tilbake verifiseringen og sende denne meldingen likevel, eller du kan avbryte for nå og prøve igjen senere etter at du har reverifisert %1$s." + "Meldingen din ble ikke sendt fordi %1$ss verifiserte identitet er tilbakestilt" + "Send melding uansett" + "%1$s bruker én eller flere uverifiserte enheter. Du kan sende meldingen uansett, eller du kan avbryte foreløpig og prøve igjen senere etter at %2$s har verifisert alle enhetene sine." + "Meldingen din ble ikke sendt fordi %1$s ikke har bekreftet alle enhetene" + "En eller flere av enhetene dine er ikke verifisert. Du kan sende meldingen likevel, eller du kan avbryte og prøve igjen senere etter at du har verifisert alle enhetene dine." + "Meldingen din ble ikke sendt fordi du ikke har verifisert en eller flere av enhetene dine" + "Rediger administratorer eller eiere" + "Kunne ikke behandle medier for opplasting, vennligst prøv igjen." + "Kunne ikke hente brukerdetaljer" + "Melding i %1$s" + "Utvid" + "Reduser" + "Du ser allerede på dette rommet!" + "%1$s av %2$s" + "%1$s Festede meldinger" + "Laster inn melding…" + "Vis alle" + "Chat" + "Del lokasjon" + "Del min lokasjon" + "Åpne i Apple Maps" + "Åpne i Google Maps" + "Åpne i OpenStreetMap" + "Del denne lokasjonen" + "Områder du har opprettet eller blitt med i." + "%1$s • %2$s" + "%1$s område" + "Områder" + "Vis medlemmer" + "Meldingen ble ikke sendt fordi %1$ss verifiserte identitet er tilbakestilt." + "Meldingen ble ikke sendt fordi %1$s ikke har verifisert alle enheter." + "Meldingen ble ikke sendt fordi du ikke har verifisert en eller flere av enhetene dine." + "Lokasjon" + "Versjon: %1$s (%2$s)" + "en" + "Historiske meldinger er ikke tilgjengelige på denne enheten" + "Du må verifisere denne enheten for å få tilgang til historiske meldinger" + "Du har ikke tilgang til denne meldingen" + "Kan ikke dekryptere meldingen" + "Denne meldingen ble blokkert enten fordi du ikke har bekreftet enheten din, eller fordi avsenderen må bekrefte identiteten din." + diff --git a/libraries/ui-strings/src/main/res/values-nl/translations.xml b/libraries/ui-strings/src/main/res/values-nl/translations.xml new file mode 100644 index 0000000..7c68223 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-nl/translations.xml @@ -0,0 +1,365 @@ + + + "Reactie toevoegen: %1$s" + "Afbeelding" + "Verwijderen" + + "%1$d cijfer ingevoerd" + "%1$d cijfers ingevoerd" + + "Afbeelding bewerken" + "Het volledige adres wordt %1$s" + "Wachtwoord verbergen" + "Deelnemen aan gesprek" + "Spring naar einde" + "Ga naar mijn locatie op de kaart" + "Alleen vermeldingen" + "Gedempt" + "Nieuwe vermeldingen" + "Nieuwe berichten" + "Actieve oproep" + "Afbeelding van andere gebruiker" + "Pagina %1$d" + "Pauzeren" + "Spraakbericht, duur: %1$s, huidige positie: %2$s" + "PIN-veld" + "Afspelen" + "Peiling" + "Beeïndigde peiling" + "Reageer met %1$s" + "Reageer met andere emoji\'s" + "Gelezen door %1$s en %2$s" + + "Gelezen door %1$s en %2$d andere" + "Gelezen door %1$s en %2$d anderen" + + "Gelezen door %1$s" + "Tik om alles weer te geven" + "Verwijder reactie met %1$s" + "Reactie met %1$s verwijderen" + "Kamerafbeelding" + "Bestanden verzenden" + "Wachtwoord weergeven" + "Begin een oproep" + "Gebruikersafbeelding" + "Gebruikersmenu" + "Bekijk afbeelding" + "Details bekijken" + "Spraakbericht, duur: %1$s" + "Spraakbericht opnemen." + "Opnemen stoppen" + "Jouw afbeelding" + "Accepteren" + "Beschrijving toevoegen" + "Toevoegen aan tijdlijn" + "Terug" + "Bellen" + "Annuleren" + "Voor nu annuleren" + "Kies foto" + "Wissen" + "Sluiten" + "Verificatie voltooien" + "Bevestigen" + "Bevestig wachtwoord" + "Voortzetten" + "Kopiëren" + "Bijschrift kopiëren" + "Kopieer link" + "Kopieer link naar bericht" + "Tekst kopiëren" + "Aanmaken" + "Creëer een kamer" + "Sluiten" + "Account sluiten" + "Weigeren" + "Weigeren en blokkeren" + "Peiling verwijderen" + "Uitschakelen" + "Verwerpen" + "Sluiten" + "Gereed" + "Bewerken" + "Bijschrift bewerken" + "Peiling wijzigen" + "Activeren" + "Peiling beëindigen" + "Voer pincode in" + "Wachtwoord vergeten?" + "Doorsturen" + "Terug" + "Negeren" + "Uitnodigen" + "Mensen uitnodigen" + "Nodig mensen uit voor %1$s" + "Nodig mensen uit voor %1$s" + "Uitnodigingen" + "Deelnemen" + "Meer informatie" + "Verlaten" + "Gesprek verlaten" + "Kamer verlaten" + "Meer laden" + "Account beheren" + "Apparaten beheren" + "Bericht" + "Volgende" + "Nee" + "Niet nu" + "OK" + "Instellingen" + "Openen met" + "Vastmaken" + "Snel antwoord" + "Citeren" + "Reageren" + "Weiger" + "Verwijderen" + "Bijschrift verwijderen" + "Bericht verwijderen" + "Antwoorden" + "Antwoord in subchat" + "Melden" + "Probleem melden" + "Inhoud melden" + "Kamer melden" + "Opnieuw instellen" + "Identiteit opnieuw instellen" + "Opnieuw proberen" + "Decryptie opnieuw proberen" + "Opslaan" + "Zoeken" + "Verzenden" + "Bericht verzenden" + "Delen" + "Link delen" + "Toon" + "Log opnieuw in" + "Uitloggen" + "Toch uitloggen" + "Overslaan" + "Starten" + "Chat starten" + "Verificatie starten" + "Tik om kaart te laden" + "Foto maken" + "Tik voor opties" + "Probeer het opnieuw" + "Losmaken" + "Bekijken" + "Bekijk in tijdlijn" + "Bron weergeven" + "Ja" + "Ja, probeer het opnieuw" + "Je server ondersteunt nu een nieuw, sneller protocol. Log uit en log opnieuw in om nu te upgraden. Als je dit nu doet, voorkom je dat je geforceerd uitlogt wordt wanneer het oude protocol later wordt verwijderd." + "Upgrade beschikbaar" + "Over" + "Beleid inzake redelijk gebruik" + "Bijschrift toevoegen" + "Geavanceerde instellingen" + "Gebruiksgegevens" + "Weergave" + "Geluid" + "Geblokkeerde gebruikers" + "Bubbels" + "Oproep gestart" + "Chat back-up" + "Gekopieerd naar klembord" + "Copyright" + "Kamer maken…" + "Heeft de kamer verlaten" + "Uitnodiging geweigerd" + "Donker" + "Decryptie fout" + "Ontwikkelaarsopties" + "Apparaat-ID" + "Directe chat" + "Dit niet meer weergeven" + "(bewerkt)" + "Bewerken" + "* %1$s %2$s" + "Encryptie ingeschakeld" + "Voer je pincode in" + "Fout" + "Er is een fout opgetreden, je ontvangt mogelijk geen meldingen voor nieuwe berichten. Los problemen met meldingen op in de instellingen. + +Reden: %1$s." + "Iedereen" + "Mislukt" + "Favoriet" + "Favoriet gemarkeerd" + "Bestand" + "Bestand opgeslagen in Downloads" + "Bericht doorsturen" + "GIF" + "Afbeelding" + "Als antwoord op %1$s" + "APK installeren" + "Deze Matrix-ID kan niet worden gevonden, dus de uitnodiging is mogelijk niet ontvangen." + "De kamer verlaten" + "Licht" + "Link gekopieerd naar klembord" + "Laden…" + + "%d andere" + "%d anderen" + + + "%1$d lid" + "%1$d leden" + + "Bericht" + "Berichtacties" + "Berichtindeling" + "Bericht verwijderd" + "Modern" + "Dempen" + "Geen resultaten" + "Geen kamernaam" + "Offline" + "Open-sourcelicenties" + "of" + "Wachtwoord" + "Personen" + "Permalink" + "Toestemming" + "Vastgezet" + "Even geduld…" + "Weet je zeker dat je deze peiling wilt beëindigen?" + "Peiling: %1$s" + "Totaal aantal stemmen: %1$s" + "Resultaten worden getoond nadat de peiling is afgelopen" + + "%d stem" + "%d stemmen" + + "Privacybeleid" + "Privé kamer" + "Openbare kamer" + "Reactie" + "Reacties" + "Herstelsleutel" + "Verversen…" + "Reageren op %1$s" + "Een fout melden" + "Meld een probleem" + "Melding ingediend" + "Uitgebreide tekstverwerker" + "Kamer" + "Naam van de kamer" + "bijv. de naam van je project" + "Wijzigingen opgeslagen" + "Opslaan" + "Schermvergrendeling" + "Iemand zoeken" + "Zoekresultaten" + "Beveiliging" + "Gezien door" + "Sturen naar" + "Verzenden…" + "Verzenden mislukt" + "Verzonden" + "Server niet ondersteund" + "Server-URL" + "Instellingen" + "Gedeelde locatie" + "Uitloggen" + "Er is iets misgegaan" + "Chat starten…" + "Sticker" + "Geslaagd" + "Suggesties" + "Synchroniseren" + "Systeem" + "Tekst" + "Kennisgevingen van derden" + "Subchat" + "Onderwerp" + "Waar gaat deze kamer over?" + "Kan niet ontsleutelen" + "Je hebt geen toegang tot dit bericht" + "Uitnodigingen konden niet naar een of meerdere gebruikers worden verzonden." + "Kan uitnodiging(en) niet verzenden" + "Ontgrendelen" + "Dempen opheffen" + "Niet-ondersteunde gebeurtenis" + "Gebruikersnaam" + "Verificatie geannuleerd" + "Verificatie voltooid" + "Geverifieerd" + "Apparaat verifiëren" + "Video" + "Spraakbericht" + "Wachten…" + "Wachten op dit bericht" + "Jij" + "%1$s\'s identiteit lijkt te zijn gewijzigd. %2$s" + "%1$s\'s %2$s identiteit lijkt te zijn gewijzigd. %3$s" + "(%1$s)" + "Bevestiging" + "Fout" + "Geslaagd" + "Waarschuwing" + "Je wijzigingen zijn niet opgeslagen. Weet je zeker dat je terug wilt gaan?" + "Wijzigingen opslaan?" + "Je homeserver moet worden geüpgraded om de Matrix Authentication Service en het aanmaken van accounts te ondersteunen." + "Het aanmaken van de permanente link is mislukt" + "%1$s kon de kaart niet laden. Probeer het later opnieuw." + "Het laden van berichten is mislukt" + "%1$s had geen toegang tot je locatie. Probeer het later opnieuw." + "Het uploaden van je spraakbericht is mislukt." + "Bericht niet gevonden" + "%1$s heeft geen toegang tot je locatie. Je kunt dit inschakelen bij Instellingen." + "%1$s heeft geen toegang tot je locatie. Schakel toegang hieronder in." + "%1$s heeft geen toegang tot je microfoon. Schakel toegang in om een spraakbericht op te nemen." + "Sommige berichten zijn niet verzonden" + "Sorry, er is een fout opgetreden" + "De echtheid van dit versleutelde bericht kan op dit apparaat niet worden gegarandeerd." + "Versleuteld door een eerder geverifieerde gebruiker." + "Niet versleuteld." + "Versleuteld door een onbekend of verwijderd apparaat." + "Versleuteld door een apparaat dat niet is geverifieerd door de eigenaar." + "Versleuteld door een niet-geverifieerde gebruiker." + "🔐️ Sluit je bij mij aan op %1$s" + "Hé, praat met me op %1$s: %2$s" + "%1$s Android" + "Schudden om een bug te melden" + "Het selecteren van media is mislukt. Probeer het opnieuw." + "Druk op een bericht en kies „%1$s” om het hier toe te voegen." + "Zet belangrijke berichten vast zodat ze gemakkelijk te vinden zijn" + + "%1$d Vastgezet bericht" + "%1$d Vastgezette berichten" + + "Vastgezette berichten" + "Je staat op het punt naar je %1$s account te gaan om je identiteit opnieuw in te stellen. Daarna kom je terug naar de app." + "Kun je dit niet bevestigen? Ga naar je account om je identiteit opnieuw in te stellen." + "Verificatie intrekken en verzenden" + "Je kunt je verificatie intrekken en dit bericht toch verzenden, of je kunt het voorlopig annuleren en het later opnieuw proberen nadat je %1$s opnieuw hebt geverifieerd." + "Je bericht is niet verzonden omdat %1$s\'s geverifieerde identiteit is gewijzigd" + "Bericht toch versturen" + "%1$s gebruikt een of meer niet-geverifieerde apparaten. Je kunt het bericht toch verzenden, of je kunt het voorlopig annuleren en het later opnieuw proberen nadat %2$s alle apparaten heeft geverifieerd." + "Je bericht is niet verzonden omdat %1$s niet alle apparaten heeft geverifieerd" + "Een of meer van je apparaten zijn niet geverifieerd. Je kunt het bericht toch verzenden, of je kunt het voorlopig annuleren en het later opnieuw proberen nadat je al je apparaten hebt geverifieerd." + "Je bericht is niet verzonden omdat je een of meerdere apparaten niet geverifieerd hebt" + "Het verwerken van media voor uploaden is mislukt. Probeer het opnieuw." + "Kon gebruikersgegevens niet ophalen" + "%1$s van %2$s" + "%1$s Vastgezette berichten" + "Bericht laden…" + "Bekijk alles" + "Chat" + "Locatie delen" + "Deel mijn locatie" + "Openen in Apple Maps" + "Openen in Google Maps" + "Openen in OpenStreetMap" + "Deel deze locatie" + "Bericht niet verzonden omdat %1$s\'s geverifieerde identiteit is gewijzigd." + "Bericht niet verzonden omdat %1$s niet alle apparaten heeft geverifieerd." + "Bericht is niet verzonden omdat je een of meerdere apparaten niet geverifieerd hebt" + "Locatie" + "Versie: %1$s (%2$s)" + "en" + "Je hebt geen toegang tot dit bericht" + diff --git a/libraries/ui-strings/src/main/res/values-pl/translations.xml b/libraries/ui-strings/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000..e4186fd --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-pl/translations.xml @@ -0,0 +1,489 @@ + + + "Dodaj reakcję: %1$s" + "Awatar" + "Zmniejsz pole tekstowe wiadomości" + "Usuń" + + "Wprowadzono %1$d cyfrę" + "Wprowadzono %1$d cyfry" + "Wprowadzono %1$d cyfr" + + "Edytuj awatar" + "Podgląd pełnego adresu %1$s" + "Szczegóły szyfrowania" + "Powiększ pole tekstowe wiadomości" + "Ukryj hasło" + "Dołącz do połączenia" + "Przejdź na dół" + "Przesuń mapę do mojej lokalizacji" + "Tylko wzmianki" + "Wyciszone" + "Nowe wzmianki" + "Nowe wiadomości" + "Rozmowa w toku" + "Awatar innego użytkownika" + "Strona %1$d" + "Wstrzymaj" + "Wiadomość głosowa, czas trwania: %1$s, aktualna pozycja: %2$s" + "Pole PIN" + "Odtwórz" + "Ankieta" + "Zakończona ankieta" + "Zareaguj z %1$s" + "Zareaguj innym emoji" + "Odczytane przez %1$s i %2$s" + + "Odczytano przez %1$s i %2$d inną" + "Odczytano przez %1$s i %2$d innych" + "Odczytano przez %1$s i %2$d innych" + + "Odczytane przez %1$s" + "Stuknij, aby pokazać wszystkich" + "Usuń reakcję %1$s" + "Usuń reakcję z %1$s" + "Awatar pokoju" + "Wyślij pliki" + "Wymagane jest działanie ograniczone czasowo, została jedna minuta" + "Pokaż hasło" + "Rozpocznij rozmowę" + "Pokój nagrobkowy" + "Awatar użytkownika" + "Menu użytkownika" + "Wyświetl awatar" + "Wyświetl szczegóły" + "Wiadomość głosowa, czas trwania: %1$s" + "Nagraj wiadomość głosową." + "Zatrzymaj nagrywanie" + "Twój awatar" + "Akceptuj" + "Dodaj opis" + "Dodaj do osi czasu" + "Wróć" + "Zadzwoń" + "Anuluj" + "Anuluj na razie" + "Wybierz zdjęcie" + "Wyczyść" + "Zamknij" + "Dokończ weryfikację" + "Potwierdź" + "Potwierdź hasło" + "Kontynuuj" + "Kopiuj" + "Kopiuj opis" + "Kopiuj link" + "Kopiuj link do wiadomości" + "Kopiuj tekst" + "Utwórz" + "Utwórz pokój" + "Dezaktywuj" + "Dezaktywuj konto" + "Odrzuć" + "Odrzuć i zablokuj" + "Usuń ankietę" + "Odznacz wszystko" + "Wyłącz" + "Odrzuć" + "Zamknij" + "Gotowe" + "Edytuj" + "Edytuj opis" + "Edytuj ankietę" + "Włącz" + "Zakończ ankietę" + "Wprowadź PIN" + "Zakończ" + "Nie pamiętasz hasła?" + "Przekaż dalej" + "Wróć" + "Przejdź do ustawień" + "Ignoruj" + "Zaproś" + "Zaproś znajomych" + "Zaproś znajomych do %1$s" + "Zaproś ludzi do %1$s" + "Zaproszenia" + "Dołącz" + "Dowiedz się więcej" + "Opuść" + "Opuść rozmowę" + "Opuść pokój" + "Opuść przestrzeń" + "Załaduj więcej" + "Zarządzaj kontem" + "Zarządzaj urządzeniami" + "Wiadomość" + "Minimalizuj" + "Dalej" + "Nie" + "Nie teraz" + "Ok" + "Otwórz menu kontekstowe" + "Ustawienia" + "Otwórz za pomocą" + "Przypnij" + "Szybka odpowiedź" + "Cytuj" + "Dodaj reakcję" + "Odrzuć" + "Usuń" + "Usuń opis" + "Usuń wiadomość" + "Odpowiedz" + "Odpowiedz w wątku" + "Zgłoś" + "Zgłoś błąd" + "Zgłoś treść" + "Zgłoś rozmowę" + "Zgłoś pokój" + "Resetuj" + "Zresetuj tożsamość" + "Spróbuj ponownie" + "Ponów próbę odszyfrowania" + "Zapisz" + "Szukaj" + "Zaznacz wszystko" + "Wyślij" + "Wyślij edytowaną wiadomość" + "Wyślij wiadomość" + "Wyślij wiadomość głosową" + "Udostępnij" + "Udostępnij link" + "Pokaż" + "Zaloguj się ponownie" + "Wyloguj" + "Wyloguj mimo to" + "Pomiń" + "Rozpocznij" + "Rozpocznij chat" + "Rozpocznij weryfikację" + "Stuknij, aby załadować mapę" + "Zrób zdjęcie" + "Stuknij, by wyświetlić opcje" + "Spróbuj ponownie" + "Odepnij" + "Wyświetl" + "Wyświetl na osi czasu" + "Wyświetl źródło" + "Tak" + "Tak, spróbuj ponownie" + "Twój serwer wspiera teraz nowy, szybszy protokół. Zaloguj się ponownie, aby zaktualizować. Dzięki temu unikniesz wymuszonego wylogowania, gdy stary protokół zostanie usunięty." + "Dostępna aktualizacja" + "O programie" + "Polityka użytkowania" + "Dodaj konto" + "Dodaj kolejne konto" + "Dodawanie opisu" + "Ustawienia zaawansowane" + "obraz" + "Dane analityczne" + "Opuściłeś pokój" + "Zostałeś wylogowany z sesji" + "Wygląd" + "Dźwięk" + "Beta" + "Zablokowani użytkownicy" + "Bąbelki" + "Rozpoczęto rozmowę" + "Backup czatu" + "Skopiowano do schowka" + "Prawa autorskie" + "Tworzenie pokoju…" + "Anulowano żądanie" + "Opuszczono pokój" + "Opuścił przestrzeń" + "Odrzucono zaproszenie" + "Ciemny" + "Błąd deszyfrowania" + "Opis" + "Opcje programisty" + "ID urządzenia" + "Czat prywatny" + "Nie pokazuj ponownie" + "Błąd pobierania" + "Pobieram" + "(edytowane)" + "Edytowanie" + "Edytowanie opisu" + "* %1$s %2$s" + "Pusty plik" + "Szyfrowanie" + "Szyfrowanie włączone" + "Wprowadź kod PIN" + "Błąd" + "Wystąpił błąd, możesz nie otrzymać powiadomień nowych wiadomości. Spróbuj naprawić powiadomienia w ustawieniach. + +Powód: %1$s." + "Wszyscy" + "Niepowodzenie" + "Ulubione" + "Ulubione" + "Plik" + "Plik usunięty" + "Plik zapisany" + "Plik zapisany do folderu Pobrane" + "Przekaż wiadomość" + "Często używane" + "GIF" + "Zdjęcie" + "W odpowiedzi do %1$s" + "Zainstaluj APK" + "Nie można znaleźć identyfikatora Matrix ID, zaproszenie mogło nie dotrzeć." + "Opuszczanie pokoju" + "Opuszczam przestrzeń" + "Jasny" + "Wiersz skopiowany do schowka" + "Link został skopiowany do schowka" + "Ładowanie…" + "Ładuję więcej…" + + "%d inny" + "%d innych" + "%d innych" + + + "%1$d członek" + "%1$d członków" + "%1$d członków" + + "Wiadomość" + "Akcje wiadomości" + "Układ wiadomości" + "Wiadomość usunięta" + "Nowoczesny" + "Wycisz" + "%1$s (%2$s)" + "Brak wyników" + "Brak nazwy pokoju" + "Brak nazwy przestrzeni" + "Nieszyfrowane" + "Offline" + "Licencje open-source" + "lub" + "Hasło" + "Osoby" + "Link bezpośredni" + "Uprawnienie" + "Przypięte" + "Sprawdź swoje połączenie internetowe" + "Proszę czekać…" + "Jesteś pewien, że chcesz zakończyć tę ankietę?" + "Ankieta: %1$s" + "Łączna liczba głosów: %1$s" + "Wyniki zostaną wyświetlone po zakończeniu ankiety" + + "%d głos" + "%d głosy" + "%d głosów" + + "Przygotowuję…" + "Polityka prywatności" + "Pokój prywatny" + "Prywatna przestrzeń" + "Pokój publiczny" + "Przestrzeń publiczna" + "Reakcja" + "Reakcje" + "Powód" + "Klucz przywracania" + "Odświeżanie…" + + "%1$d odpowiedź" + "%1$d odpowiedzi" + "%1$d odpowiedzi" + + "Odpowiadanie do %1$s" + "Zgłoś błąd" + "Zgłoś problem" + "Zgłoszenie wysłane" + "Bogaty edytor tekstu" + "Pokój" + "Nazwa pokoju" + "np. nazwa projektu" + + "%1$d Pokój" + "%1$d Pokoje" + "%1$d Pokoi" + + "Zapisano zmiany" + "Zapisywanie" + "Blokada ekranu" + "Szukaj osób" + "Wyniki wyszukiwania" + "Bezpieczeństwo" + "Wyświetlone przez" + "Wybierz konto" + "Wyślij do" + "Wysyłanie…" + "Błąd wysyłania" + "Wysłano" + ". " + "Serwer nie jest obsługiwany" + "Serwer niedostępny" + "Adres URL serwera" + "Ustawienia" + "Udostępnij przestrzeń" + "Udostępniona lokalizacja" + "Udostępniona przestrzeń" + "Wylogowywanie" + "Coś poszło nie tak" + "Napotkaliśmy problem. Spróbuj ponownie." + "Przestrzeń" + + "%1$d Przestrzeń" + "%1$d Przestrzenie" + "%1$d Przestrzeni" + + "Rozpoczynanie czatu…" + "Naklejka" + "Sukces" + "Sugestie" + "Synchronizuję" + "System" + "Tekst" + "Informacje stron trzecich" + "Wątek" + "Temat" + "O czym jest ten pokój?" + "Nie można odszyfrować" + "Wysłane z niebezpiecznego urządzenia" + "Nie masz uprawnień do tej wiadomości" + "Zweryfikowana tożsamość nadawcy została zresetowana" + "Nie udało się wysłać zaproszenia do jednego lub więcej użytkowników." + "Nie można wysłać zaproszeń" + "Odblokuj" + "Wyłącz wyciszenie" + "Nieobsługiwane połączenie" + "Nieobsługiwane zdarzenie" + "Nazwa użytkownika" + "Weryfikacja anulowana" + "Weryfikacja zakończona" + "Weryfikacja nie powiodła się" + "Zweryfikowano" + "Zweryfikuj urządzenie" + "Zweryfikuj tożsamość" + "Zweryfikuj użytkownika" + "Film" + "Wysoka jakość" + "Najlepsza jakość, większy rozmiar pliku" + "Niska jakość" + "Największa prędkość przesyłania i najmniejszy rozmiar pliku" + "Jakość standardowa" + "Balans między jakością a szybkością przesyłania" + "Wiadomość głosowa" + "Oczekiwanie…" + "Oczekiwanie na tę wiadomość" + "Ty" + "Tożsamość %1$s została zresetowana. %2$s" + "Tożsamość %1$s %2$s została zresetowana. %3$s" + "(%1$s)" + "Tożsamość %1$s została zresetowana" + "Tożsamość %1$s %2$s została zresetowana. %3$s" + "Wycofaj weryfikację" + "Link %1$s prowadzi Cię do innej witryny %2$s + +Czy na pewno chcesz kontynuować?" + "Sprawdź dwukrotnie ten link" + "Wybierz domyślną jakość przesyłanych filmów." + "Jakość przesyłania wideo" + "Maksymalny dozwolony rozmiar pliku to: %1$s" + "Rozmiar pliku jest za duży, aby go przesłać" + "Pokój zgłoszony" + "Opuszczono i zgłoszono pokój" + "Potwierdzenie" + "Błąd" + "Sukces" + "Ostrzeżenie" + "Zmiany nie zostały zapisane. Czy na pewno chcesz wrócić?" + "Zapisać zmiany?" + "Maksymalny dozwolony rozmiar pliku to: %1$s" + "Wybierz jakość filmu, który chcesz przesłać." + "Wybierz jakość przesyłania wideo" + "Wyszukaj emoji" + "Jesteś już zalogowany na tym urządzeniu jako %1$s." + "Twój serwer domowy wymaga aktualizacji, aby uzyskać wsparcie usługi Matrix Authentication Service i tworzenia kont." + "Nie udało się utworzyć linku bezpośredniego" + "%1$s nie mogło wczytać mapy. Spróbuj ponownie później." + "Nie udało się załadować wiadomości" + "%1$s nie mógł uzyskać dostępu do Twojej lokalizacji. Spróbuj ponownie później." + "Nie udało się przesłać Twojej wiadomości głosowej." + "Pokój już nie istnieje lub zaproszenie nie jest już ważne." + "Nie znaleziono wiadomości" + "%1$s nie uzyskało uprawnienia do dostępu do twojej lokalizacji. Możesz włączyć dostęp w Ustawieniach." + "%1$s nie ma uprawnień dostępu do Twojej lokalizacji. Włącz dostęp poniżej." + "%1$s nie ma uprawnień dostępu do Twojego mikrofonu. Włącz dostęp, aby nagrać wiadomość głosową." + "Może to być spowodowane problemami z siecią lub serwerem." + "Ten adres pokoju już istnieje. Spróbuj zmienić adres lub nazwę pokoju" + "Niektóre znaki są niedozwolone. Obsługiwane są tylko litery, cyfry i następujące symbole ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" + "Niektóre wiadomości nie zostały wysłane" + "Przepraszamy, wystąpił błąd" + "Nadawca zdarzenia nie pasuje do właściciela urządzenia, które je wysłał." + "Autentyczność tej wiadomości szyfrowanej nie jest gwarantowana na tym urządzeniu." + "Zaszyfrowane przez wcześniej zweryfikowanego użytkownika." + "Nieszyfrowany." + "Zaszyfrowana przez nieznane lub usunięte urządzenie." + "Zaszyfrowana przez urządzenie niezweryfikowane przez jego właściciela." + "Zaszyfrowana przez niezweryfikowanego użytkownika." + "🔐️ Dołącz do mnie na %1$s" + "Hej, porozmawiajmy na %1$s: %2$s" + "%1$s Android" + "Wstrząśnij gniewnie, aby zgłosić błąd" + "Zrzut ekranu" + "%1$s: %2$s" + "Opcje" + "Usuń %1$s" + "Ustawienia" + "Nie udało się wybrać multimediów. Spróbuj ponownie." + "Naciśnij wiadomość i wybierz “%1$s”, aby dołączyć tutaj." + "Przypinaj ważne wiadomości, aby można było je łatwo znaleźć" + + "%1$d przypięta wiadomość" + "%1$d przypięte wiadomości" + "%1$d przypiętych wiadomości" + + "Przypięte wiadomości" + "Zostaniesz przeniesiony na swoje konto %1$s, aby zresetować tożsamość. Wrócisz do aplikacji po zakończeniu." + "Nie możesz potwierdzić? Przejdź do swojego konta i zresetuj swoją tożsamość." + "Wycofaj weryfikację i wyślij" + "Możesz wycofać weryfikację i wysłać wiadomość mimo wszystko lub anulować i spróbować ponownie po ponownej weryfikacji %1$s." + "Twoja wiadomość nie została wysłana, ponieważ tożsamość %1$s została zresetowana." + "Wyślij wiadomość mimo to" + "%1$s korzysta z jednego lub więcej niezweryfikowanych urządzeń. Wyślij wiadomość mimo to lub anuluj i spróbuj ponownie, gdy %2$s zweryfikuje wszystkie swoje urządzenia." + "Twoja wiadomość nie została wysłana, ponieważ %1$s nie zweryfikował swoich wszystkich urządzeń" + "Jedno lub więcej z Twoich urządzeń jest niezweryfikowanych. Wyślij wiadomość mimo to lub anuluj i spróbuj ponownie po zweryfikowaniu wszystkich swoich urządzeń." + "Twoja wiadomość nie została wysłana, ponieważ nie zweryfikowałeś jednego lub więcej swoich urządzeń." + "Edytuj administratorów lub właścicieli" + "Przetwarzanie multimediów do przesłania nie powiodło się, spróbuj ponownie." + "Nie można pobrać danych użytkownika" + "Wiadomość w %1$s" + "Rozwiń" + "Zmniejsz" + "Już oglądasz ten pokój!" + "%1$s z %2$s" + "%1$s przypiętych wiadomości" + "Wczytywanie wiadomości…" + "Wyświetl wszystkie" + "Czat" + "Udostępnij lokalizację" + "Udostępnij moją lokalizację" + "Otwórz w Apple Maps" + "Otwórz w Google Maps" + "Otwórz w OpenStreetMap" + "Udostępnij tę lokalizację" + "Przestrzenie, które stworzyłeś lub do których dołączyłeś." + "%1$s • %2$s" + "Przestrzeń %1$s" + "Przestrzenie" + "Wiadomość nie została wysłana, ponieważ tożsamość %1$s została zresetowana." + "Wiadomość nie została wysłana, ponieważ %1$s nie zweryfikował wszystkich urządzeń." + "Wiadomość nie została wysłana, ponieważ nie zweryfikowałeś jednego lub więcej swoich urządzeń." + "Lokalizacja" + "Wersja: %1$s (%2$s)" + "pl" + "Historia wiadomości nie jest dostępna na tym urządzeniu" + "Musisz zweryfikować to urządzenie, aby uzyskać dostęp do historii wiadomości" + "Nie masz uprawnień do tej wiadomości" + "Nie można odszyfrować wiadomości" + "Wiadomość została zablokowana, ponieważ urządzenie nie zostało zweryfikowane lub nadawca musi zweryfikować Twoją tożsamość." + diff --git a/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml b/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000..2f9b4a1 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,486 @@ + + + "Adicionar reação: %1$s" + "Avatar" + "Minimizar campo de texto de mensagem" + "Apagar" + + "%1$d dígito digitado" + "%1$d dígitos digitados" + + "Editar avatar" + "O endereço completo será %1$s" + "Detalhes de criptografia" + "Expandir campo de texto de mensagem" + "Ocultar senha" + "Entrar à chamada" + "Ir para o final" + "Mover o mapa para a minha localização" + "Apenas menções" + "Silenciado" + "Novas menções" + "Novas mensagens" + "Chamada em andamento" + "Avatar de outro usuário" + "%1$dª página" + "Pausar" + "Mensagem de voz, duração: %1$s, posição atual: %2$s" + "Campo de PIN" + "Reproduzir" + "Enquete" + "Enquete encerrada" + "Reagir com %1$s" + "Reagir com outros emojis" + "Lido por %1$s e %2$s" + + "Lido por %1$s e %2$d outro" + "Lido por %1$s e outros %2$d" + + "Lido por %1$s" + "Toque para mostrar tudo" + "Remover reação com %1$s" + "Remover reação com %1$s" + "Avatar da sala" + "Enviar arquivos" + "Ação de tempo limitado necessária, você tem um minuto para verificar" + "Mostrar senha" + "Iniciar uma chamada" + "Sala morta" + "Avatar do usuário" + "Menu do usuário" + "Ver avatar" + "Visualizar detalhes" + "Mensagem de voz, duração: %1$s" + "Gravar mensagem de voz." + "Parar gravação" + "Seu avatar" + "Aceitar" + "Adicionar legenda" + "Adicionar à linha do tempo" + "Voltar" + "Chamar" + "Cancelar" + "Cancelar por enquanto" + "Escolher foto" + "Limpar" + "Fechar" + "Concluir a verificação" + "Confirmar" + "Confirmar senha" + "Continuar" + "Copiar" + "Copiar legenda" + "Copiar link" + "Copiar link para a mensagem" + "Copiar texto" + "Criar" + "Criar uma sala" + "Desativar" + "Desativar conta" + "Recusar" + "Recusar e bloquear" + "Excluir enquete" + "Desselecionar tudo" + "Desativar" + "Descartar" + "Dispensar" + "Pronto" + "Editar" + "Editar legenda" + "Editar enquete" + "Ativar" + "Encerrar enquete" + "Digitar PIN" + "Concluir" + "Esqueceu a senha?" + "Encaminhar" + "Voltar" + "Ir aos cargos e permissões" + "Ir às configurações" + "Ignorar" + "Convidar" + "Convidar pessoas" + "Convidar pessoas para %1$s" + "Convide pessoas para %1$s" + "Convites" + "Entrar" + "Saber mais" + "Sair" + "Sair da conversa" + "Sair da sala" + "Sair do espaço" + "Carregar mais" + "Gerenciar conta" + "Gerenciar dispositivos" + "Mensagem" + "Minimizar" + "Avançar" + "Não" + "Agora não" + "OK" + "Abrir menu de contexto" + "Configurações" + "Abrir com" + "Fixar" + "Resposta rápida" + "Citar" + "Reagir" + "Recusar" + "Remover" + "Remover legenda" + "Remover mensagem" + "Responder" + "Responder no tópico" + "Denunciar" + "Reportar bug" + "Denunciar conteúdo" + "Denunciar conversa" + "Denunciar sala" + "Redefinir" + "Redefinir identidade" + "Tentar novamente" + "Tentar descriptografar novamente" + "Salvar" + "Pesquisar" + "Selecionar tudo" + "Enviar" + "Enviar mensagem editada" + "Enviar mensagem" + "Enviar mensagem de voz" + "Compartilhar" + "Compartilhar link" + "Mostrar" + "Entrar novamente" + "Sair" + "Sair mesmo assim" + "Pular" + "Iniciar" + "Iniciar conversa" + "Iniciar verificação" + "Toque para carregar o mapa" + "Tirar foto" + "Toque para opções" + "Tente novamente" + "Desafixar" + "Visualizar" + "Visualizar na linha do tempo" + "Ver fonte" + "Sim" + "Sim, tentar novamente" + "Seu servidor agora é compatível com um protocolo novo e mais rápido. Saia da sua conta e entre novamente para fazer a atualização. Fazendo isso agora, você evitará uma saída forçada quando o protocolo antigo for removido." + "Atualização disponível" + "Sobre" + "Política de uso aceitável" + "Adicionar uma conta" + "Adicionar outra conta" + "Adicionando legenda" + "Configurações avançadas" + "uma imagem" + "Telemetria" + "Você saiu da sala" + "Você foi desconectado da sessão" + "Aparência" + "Áudio" + "Beta" + "Usuários bloqueados" + "Balões" + "Chamada iniciada" + "Backup de conversas" + "Copiado para a área de transferência" + "Direitos autorais" + "Criando sala…" + "Solicitação cancelada" + "Saiu da sala" + "Saiu do espaço" + "Convite recusado" + "Escuro" + "Erro de descriptografia" + "Descrição" + "Opções de desenvolvedor" + "ID do dispositivo" + "Conversa direta" + "Não mostrar isto novamente" + "O download falhou" + "Baixando" + "(editado)" + "Editando" + "Editando legenda" + "* %1$s %2$s" + "Arquivo vazio" + "Criptografia" + "Criptografia ativada" + "Digite o seu PIN" + "Erro" + "Ocorreu​ um ​erro, você ​pode ​não ​receber ​notificações ​de ​novas ​mensagens. ​Você ​pode ​solucionar ​o ​problema ​das ​notificações ​nas ​configurações.↵ +↵ +Motivo:​ %1$s." + "Todos" + "Falhou" + "Favorito" + "Favoritado" + "Arquivo" + "Arquivo excluído" + "Arquivo salvo" + "Arquivo salvo nos Downloads" + "Encaminhar mensagem" + "Usado frequentemente" + "GIF" + "Imagem" + "Em resposta a %1$s" + "Instalar APK" + "Este ID Matrix não foi encontrado, então o convite pode não ser recebido" + "Saindo da sala" + "Saindo do espaço" + "Claro" + "Linha copiada para a área de transferência" + "Link copiado para área de transferência" + "Carregando…" + "Carregando mais…" + + "%d outro" + "%d outros" + + + "%1$d membro" + "%1$d membros" + + "Mensagem" + "Ações de mensagem" + "Layout da mensagem" + "Mensagem removida" + "Moderno" + "Mudo" + "%1$s (%2$s)" + "Não há resultados" + "Não há um nome para a sala" + "Sem nome de espaço" + "Sem criptografia" + "Off-line" + "Licenças de código aberto" + "ou" + "Senha" + "Pessoas" + "Link permanente" + "Permissão" + "Fixado" + "Verifique a sua conexão à internet" + "Por favor, aguarde…" + "Tem certeza de que deseja encerrar esta enquete?" + "Enquete: %1$s" + "Total de votos: %1$s" + "Os resultados serão exibidos após o término da enquete" + + "%d voto" + "%d votos" + + "Preparando…" + "Política de privacidade" + "Sala privada" + "Espaço privado" + "Sala pública" + "Espaço público" + "Reação" + "Reações" + "Motivo" + "Chave de recuperação" + "Recarregando…" + + "%1$d resposta" + "%1$d respostas" + + "Respondendo a %1$s" + "Denunciar um bug" + "Relatar um problema" + "Relatório enviado" + "Editor de rich text" + "Sala" + "Nome da sala" + "por exemplo, o nome do seu projeto" + + "%1$d sala" + "%1$d salas" + + "Alterações salvas" + "Salvando" + "Bloqueio de tela" + "Procurar por alguém" + "Resultados da pesquisa" + "Segurança" + "Visto por" + "Selecionar uma conta" + "Enviar para" + "Enviando…" + "Envio falhou" + "Enviado" + ". " + "Servidor não suportado" + "Servidor inacessível" + "URL do servidor" + "Configurações" + "Compartilhar espaço" + "Localização compartilhada" + "Espaço compartilhado" + "Saindo" + "Algo deu errado" + "Encontramos um problema. Tente novamente." + "Espaço" + + "%1$d espaço" + "%1$d espaços" + + "Iniciando a conversa…" + "Figurinha" + "Sucesso" + "Sugestões" + "Sincronizando" + "Sistema" + "Texto" + "Comunicados de terceiros" + "Tópico" + "Tópico" + "Sobre o que é essa sala?" + "Não foi possível descriptografar" + "Enviado de um dispositivo inseguro" + "Você não tem acesso à esta mensagem" + "A identidade verificada do remetente foi redefinida" + "Não foi possível enviar convites para um ou mais usuários." + "Não foi possível enviar o(s) convite(s)" + "Desbloquear" + "Parar de silenciar" + "Chamada não suportada" + "Evento não suportado" + "Nome do usuário" + "Verificação cancelada" + "Verificação concluída" + "A verificação falhou" + "Verificado" + "Verificar dispositivo" + "Verificar identidade" + "Verificar usuário" + "Vídeo" + "Alta qualidade" + "Melhor qualidade, mas tamanho de arquivo maior" + "Baixa qualidade" + "Maior velocidade de envio e menor tamanho de arquivo" + "Qualidade normal" + "Equilíbrio entre qualidade e velocidade de envio" + "Mensagem de voz" + "Aguardando…" + "Aguardando por esta mensagem" + "Você" + "A identidade de %1$s foi redefinida. %2$s" + "A identidade de %1$s %2$s foi redefinida. %3$s" + "(%1$s)" + "A identidade de %1$s foi redefinida." + "A identidade de %1$s %2$s foi redefinida. %3$s" + "Anular verificação" + "O link %1$s está levando você para outro site %2$s + +Você tem certeza de que deseja continuar?" + "Verifique este link duas vezes" + "Selecione a qualidade padrão dos videos que você envia." + "Qualidade de envio de vídeos" + "O tamanho máximo permitido de arquivos é: %1$s" + "O tamanho do arquivo é muito grande para ser enviado" + "Sala denunciada" + "Denunciou e deixou a sala" + "Confirmação" + "Erro" + "Sucesso" + "Alerta" + "Suas alterações não foram salvas. Tem certeza de que você quer voltar?" + "Salvar alterações?" + "O tamanho máximo permitido de arquivos é: %1$s" + "Selecione a qualidade do video que quer enviar." + "Selecione a qualidade de envio dos vídeos" + "Pesquisar emojis" + "Você já está conectado neste dispositivo como %1$s ." + "Seu servidor-casa precisa ser atualizado para oferecer suporte ao Matrix Authentication Service e à criação de contas." + "Falha ao criar o link permanente" + "%1$s não conseguiu carregar o mapa. Por favor, tente novamente mais tarde." + "Falha ao carregar mensagens" + "%1$s não conseguiu acessar sua localização. Por favor, tente novamente mais tarde." + "Falha ao enviar sua mensagem de voz." + "A sala não existe mais ou o convite não é mais válido." + "Mensagem não encontrada" + "%1$s não tem permissão para acessar sua localização. Você pode ativar o acesso nas Configurações." + "%1$s não tem permissão para acessar sua localização. Habilite o acesso abaixo." + "%1$s não tem permissão para acessar seu microfone. Permita o acesso para gravar uma mensagem de voz." + "Isso pode ocorrer devido a problemas na rede ou no servidor." + "Este endereço de sala já existe. Tente editar o campo de endereço da sala ou alterar o nome da sala" + "Alguns caracteres não são permitidos. Somente letras, dígitos e os seguintes símbolos são aceitos ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" + "Algumas mensagens não foram enviadas" + "Desculpe, ocorreu um erro" + "O remetente do evento não corresponde com o proprietário do dispositivo que o enviou." + "A autenticidade desta mensagem criptografada não pode ser garantida neste aparelho." + "Criptografado por um usuário verificado previamente." + "Não criptografado." + "Criptografado por um dispositivo desconhecido ou apagado." + "Criptografado por um dispositivo que não foi verificado pelo seu dono." + "Criptografado por um usuário não verificado." + "🔐️ Junte-se a mim no %1$s" + "Ei, fale comigo em %1$s: %2$s" + "%1$s (Android)" + "Agitar agressivamente para relatar um bug" + "Captura de tela" + "%1$s: %2$s" + "Opções" + "Remover %1$s" + "Configurações" + "Falha ao selecionar a mídia, tente novamente." + "Pressione em uma mensagem e escolha \"%1$s\" para incluir aqui." + "Fixe mensagens importantes para que elas possam ser facilmente descobertas" + + "%1$d mensagem fixada" + "%1$d mensagens fixadas" + + "Mensagens fixadas" + "Você está prestes a acessar sua conta %1$s para redefinir sua identidade. Depois disso, você será levado de volta ao app." + "Não consegue confirmar? Acesse sua conta para redefinir sua identidade." + "Retirar verificação e enviar" + "Você pode retirar sua verificação e enviar esta mensagem mesmo assim, ou pode cancelar por enquanto e tentar novamente mais tarde depois de reverificar %1$s." + "Sua mensagem não foi enviada porque a identidade verificada de %1$s foi redefinida" + "Enviar mensagem mesmo assim" + "%1$s está usando um ou mais dispositivos não verificados. Você pode enviar a mensagem de qualquer maneira, ou você pode cancelar por enquanto e tentar novamente mais tarde depois que %2$s tiver verificado todos os seus dispositivos." + "Sua mensagem não foi enviada porque %1$s não verificou todos os dispositivos" + "Um ou mais de seus dispositivos não foram verificados. Você pode enviar a mensagem mesmo assim ou pode cancelar por enquanto e tentar novamente mais tarde, depois de ter verificado todos os seus dispositivos." + "Sua mensagem não foi enviada porque você não verificou um ou mais de seus dispositivos" + "Alterar configurações" + "Gerenciar espaço" + "Gerenciar salas" + "Permissões" + "Editar administradores ou proprietários" + "Falha ao processar a mídia para o envio. Tente novamente." + "Não foi possível buscar os detalhes do usuário" + "Mensagem em %1$s" + "Expandir" + "Reduzir" + "Já está visualizando esta sala!" + "%1$s de %2$s" + "%1$s Mensagens fixadas" + "Carregando mensagem…" + "Ver tudo" + "Conversa" + "Compartilhar localização" + "Compartilhar minha localização" + "Abrir no Apple Maps" + "Abrir no Google Maps" + "Abrir no OpenStreetMap" + "Compartilhar esta localização" + "Os espaços que você criou ou entrou." + "%1$s • %2$s" + "Espaço %1$s" + "Espaços" + "Ver membros" + "Mensagem não enviada porque a identidade verificada de %1$s foi redefinida." + "A mensagem não foi enviada porque %1$s não verificou todos os dispositivos." + "Mensagem não enviada porque você não verificou um ou mais dos seus dispositivos." + "Localização" + "Versão: %1$s (%2$s)" + "pt-br" + "As mensagens históricas não estão disponíveis neste dispositivo" + "Você precisa verificar este dispositivo para ter acesso à mensagens históricas" + "Você não tem acesso à esta mensagem" + "Não foi possível descriptografar a mensagem" + "Esta mensagem foi bloqueada porque você não verificou seu dispositivo ou porque o remetente precisa verificar sua identidade." + diff --git a/libraries/ui-strings/src/main/res/values-pt/translations.xml b/libraries/ui-strings/src/main/res/values-pt/translations.xml new file mode 100644 index 0000000..5e55f3a --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-pt/translations.xml @@ -0,0 +1,475 @@ + + + "Adicionar reação: %1$s" + "Avatar" + "Minimizar campo de texto da mensagem" + "Eliminar" + + "%1$d dígito inserido" + "%1$d dígitos inseridos" + + "Editar avatar" + "O endereço completo será %1$s" + "Detalhes de cifragem" + "Expandir campo de texto da mensagem" + "Ocultar palavra-passe" + "Juntar-se à chamada" + "Saltar para o fundo" + "Mover o mapa para a minha localização" + "Apenas menções" + "Silenciado" + "Novas menções" + "Novas mensagens" + "Chamada em curso" + "O avatar do outro utilizador" + "Página %1$d" + "Pausar" + "Mensagem de voz, duração: %1$s, posição atual: %2$s" + "Campo para PIN" + "Reproduzir" + "Sondagem" + "Sondagem concluída" + "Reagir com %1$s" + "Reagir com outros emojis" + "Lida por %1$s e %2$s" + + "Lida por %1$s e %2$d outro" + "Lida por %1$s e %2$d outros" + + "Lida por %1$s" + "Toca para mostrar tudo" + "Remover reação com %1$s" + "Remover reação com %1$s" + "Ícone da sala" + "Enviar ficheiros" + "Necessária ação em tempo limitado, tens um minuto para verificares" + "Mostrar palavra-passe" + "Iniciar chamada" + "Sala antiga" + "Avatar do utilizador" + "Menu de utilizador" + "Ver avatar" + "Ver detalhes" + "Mensagem de voz, duração: %1$s" + "Gravar mensagem de voz." + "Parar gravação" + "O teu avatar" + "Aceitar" + "Adicionar legenda" + "Adicionar à cronologia" + "Voltar" + "Chamar" + "Cancelar" + "Cancelar por enquanto" + "Escolher foto" + "Limpar" + "Fechar" + "Concluir verificação" + "Confirmar" + "Confirmar palavra-passe" + "Continuar" + "Copiar" + "Copiar legenda" + "Copiar ligação" + "Copiar ligação da mensagem" + "Copiar texto" + "Criar" + "Criar uma sala" + "Desativar" + "Desativar conta" + "Recusar" + "Recusar e bloquear" + "Eliminar sondagem" + "Desselecionar tudo" + "Desativar" + "Descartar" + "Dispensar" + "Feito" + "Editar" + "Editar legenda" + "Editar sondagem" + "Ativar" + "Fim da sondagem" + "Inserir PIN" + "Concluir" + "Esqueceu-se da palavra-passe?" + "Reencaminhar" + "Voltar" + "Ignorar" + "Convidar" + "Convidar pessoas" + "Convidar amigos para %1$s" + "Convida pessoas para a %1$s" + "Convites" + "Entrar" + "Saber mais" + "Sair" + "Sair da conversa" + "Sair da sala" + "Sair do espaço" + "Carregar mais" + "Gerir conta" + "Gerir dispositivos" + "Enviar mensagem" + "Próximo" + "Não" + "Agora não" + "OK" + "Abrir menu de contexto" + "Configurações" + "Abrir com" + "Afixar" + "Resposta rápida" + "Citação" + "Reagir" + "Rejeitar" + "Remover" + "Remover legenda" + "Remover mensagem" + "Responder" + "Responder ao tópico" + "Reportar" + "Comunicar problema" + "Denunciar conteúdo" + "Denunciar conversa" + "Denunciar sala" + "Repor" + "Repor identidade" + "Tentar novamente" + "Tentar decifragem novamente" + "Guardar" + "Pesquisar" + "Selecionar tudo" + "Enviar" + "Enviar mensagem editada" + "Enviar mensagem" + "Enviar mensagem de voz" + "Partilhar" + "Partilhar ligação" + "Mostrar" + "Iniciar sessão novamente" + "Terminar sessão" + "Terminar mesmo assim" + "Saltar" + "Iniciar" + "Iniciar conversa" + "Iniciar verificação" + "Toca para carregar o mapa" + "Tirar foto" + "Toca para ver as opções" + "Tentar novamente" + "Desafixar" + "Ver" + "Ver na cronologia" + "Ver fonte" + "Sim" + "Sim, tentar novamente" + "O teu servidor suporta agora um protocolo novo e mais rápido. Termina a sessão e volta a iniciar sessão para atualizar agora. Se o fizeres agora, evitarás um fim de sessão forçado quando o protocolo antigo for removido mais tarde." + "Atualização disponível" + "Sobre" + "Política de utilização aceitável" + "Adicionar conta" + "Adicionar outra conta" + "A adicionar legenda" + "Configurações avançadas" + "uma imagem" + "Recolha e análise de dados" + "Saíste da sala" + "A tua sessão foi terminada" + "Aparência" + "Áudio" + "Utilizadores bloqueados" + "Bolhas" + "Chamada iniciada" + "Cópia de segurança das conversas" + "Copiado para a área de transferência" + "Direitos de autor" + "A criar sala…" + "Pedido cancelado" + "Saíste da sala" + "Saíste do espaço" + "Convite rejeitado" + "Escuro" + "Erro de decifragem" + "Descrição" + "Opções de programador" + "ID do dispositivo" + "Conversa direta" + "Não mostrar novamente" + "Descarga falhada" + "A descarregar" + "(editada)" + "A editar" + "A editar legenda" + "* %1$s %2$s" + "Ficheiro vazio" + "Cifragem" + "Cifragem ativada" + "Introduz o teu PIN" + "Erro" + "Ocorreu um erro, podes não estar a receber notificações de novas mensagens. Resolve o problema nas configurações. + +Razão: %1$s." + "Toda a gente" + "Falha" + "Marcar como favorita" + "Favoritas" + "Ficheiro" + "Ficheiro eliminado" + "Ficheiro guardado" + "Ficheiro guardado nas Transferências" + "Reencaminhar mensagem" + "Frequentemente utilizado" + "GIF" + "Imagem" + "Em resposta a %1$s" + "Instalar APK" + "Não foi possível encontrar este ID Matrix, portanto o convite pode não ser recebido." + "A sair da sala" + "Claro" + "Linha copiada para a área de transferência" + "Ligação copiada para a área de transferência" + "A carregar…" + "A carregar mais…" + + "%d outro" + "%d outros" + + + "%1$d membro" + "%1$d membros" + + "Mensagem" + "Ações de mensagem" + "Disposição das mensagens" + "Mensagem removida" + "Moderno" + "Silenciar" + "%1$s (%2$s)" + "Sem resultados" + "Sala sem nome" + "Espaço sem nome" + "Não encriptado" + "Desligado" + "Licenças de código aberto" + "ou" + "Senha" + "Pessoas" + "Ligação permanente" + "Permissão" + "Afixado" + "Por favor, verifica a tua ligação à internet" + "Por favor, aguarda…" + "Tens a certeza que queres concluir esta sondagem?" + "Sondagem: %1$s" + "Total de votos: %1$s" + "Os resultados serão apresentados após o fim da sondagem" + + "%d voto" + "%d votos" + + "A preparar…" + "Política de privacidade" + "Sala privada" + "Espaço privado" + "Sala pública" + "Espaço público" + "Reação" + "Reações" + "Motivo" + "Chave de recuperação" + "A atualizar…" + + "%1$d resposta" + "%1$d respostas" + + "Em resposta a %1$s" + "Comunicar falha" + "Comunicar um problema" + "Denúncia submetida" + "Editor de texto rico" + "Sala" + "Nome da sala" + "p.ex. o nome do teu projeto" + + "%1$d sala" + "%1$d salas" + + "Alterações guardadas" + "A guardar" + "Bloqueio do ecrã" + "Pesquisar por alguém" + "Resultados da pesquisa" + "Segurança" + "Vista por" + "Selecionar conta" + "Enviar para" + "A enviar…" + "Falha no envio" + "Enviada" + ". " + "Servidor não suportado" + "Servidor indisponível" + "URL do servidor" + "Configurações" + "Partilhar espaço" + "Localização partilhada" + "A terminar sessão" + "Algo correu mal" + "Encontramos um erro. Por favor, tenta novamente." + "Espaço" + + "%1$d espaço" + "%1$d espaços" + + "A iniciar conversa…" + "Autocolante" + "Sucesso" + "Sugestões" + "A sincronizar…" + "Sistema" + "Texto" + "Avisos de terceiros" + "Tópico" + "Descrição" + "Sobre o que é esta sala?" + "Impossível decifrar" + "Enviado de um dispositivo inseguro" + "Não tens acesso a esta mensagem" + "A identidade verificada do remetente foi reposta" + "Não foi possível enviar convites a um ou mais utilizadores." + "Não foi possível enviar convite(s)" + "Desbloquear" + "Dessilenciar" + "Chamada não suportada" + "Evento não suportado" + "Nome de utilizador" + "Verificação cancelada" + "Verificação concluída" + "A verificação falhou" + "Verificado" + "Verificar o dispositivo" + "Verifica a identidade" + "Verificar utilizador" + "Vídeo" + "Alta qualidade" + "Melhor qualidade, mas maior tamanho de ficheiro" + "Baixa qualidade" + "A velocidade de carregamento mais rápida e o tamanho de ficheiro mais pequeno" + "Qualidade padrão" + "Equilíbrio entre qualidade e velocidade de carregamento" + "Mensagem de voz" + "A aguardar…" + "À espera desta mensagem" + "Tu" + "A identidade de %1$s foi reposta. %2$s" + "A identidade de %1$s (%2$s) foi reposta. %3$s" + "(%1$s)" + "A identidade de %1$s foi reposta." + "A identidade de %1$s (%2$s) foi reposta. %3$s" + "Retirar verificação" + "O link %1$s está a levar-te para outro site %2$s + +Tens a certeza de que queres continuar?" + "Verifica novamente esta ligação" + "Seleciona a qualidade predefinida dos vídeos que carregas." + "Qualidade de carregamento do vídeo" + "O tamanho máximo de ficheiro permitido é: %1$s" + "O tamanho do ficheiro é demasiado grande para ser carregado" + "Sala denunciada" + "Reportaste e saíste da sala" + "Confirmação" + "Erro" + "Sucesso" + "Aviso" + "As tuas alterações não foram guardadas. Tens a certeza que queres voltar atrás?" + "Guardar alterações?" + "O tamanho máximo de ficheiro permitido é: %1$s" + "Seleciona a qualidade do vídeo que pretendes carregar." + "Seleciona a qualidade de carregamento do vídeo" + "Pesquisar emojis" + "Já tens sessão iniciada como %1$s neste dispositivo." + "Seu homeserver precisa ser atualizado para suportar o Matrix Authentication Service e a criação de conta." + "Falha ao criar ligação permanente" + "%1$s não foi possível carregar o mapa. Por favor, tente novamente mais tarde." + "Falha ao carregar mensagens" + "A %1$s não conseguiu aceder à tua localização. Por favor, tenta novamente mais tarde." + "Falha ao carregar mensagem de voz." + "A sala já não existe ou o convite já não é válido." + "Mensagem não encontrada" + "A %1$s não tem permissão para aceder à tua localização. Podes ativar o acesso nas Definições." + "A %1$s não tem permissão para aceder à tua localização. Continua para ativares o acesso." + "A %1$s não tem permissão para aceder ao teu microfone. Permite o acesso para gravar uma mensagem de voz." + "Isto pode dever-se a problemas na rede ou no servidor." + "Este endereço de sala já existe, tente editar o campo de endereço da sala ou altere o nome da sala" + "Alguns caracteres não são permitidos. Apenas letras, dígitos e os seguintes símbolos são suportados! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" + "Algumas mensagens não foram enviadas" + "Pedimos desculpa, ocorreu um erro desconhecido" + "O remetente deste evento não é o dono do dispositivo que o enviou." + "A autenticidade desta mensagem cifrada não pode ser garantida neste dispositivo." + "Criptografado por um usuário verificado anteriormente." + "Não cifrado." + "Cifragem com origem num dispositivo eliminado ou desconhecido." + "Cifragem com origem num dispositivo não verificado pelo seu dono." + "Cifragem com origem num utilizador não verificado." + "🔐️ Junta-te a mim na %1$s" + "Alô! Fala comigo na %1$s: %2$s" + "%1$s Android" + "Agita o dispositivo em fúria para comunicar um problema" + "Captura de ecrã" + "%1$s: %2$s" + "Opções" + "Remover %1$s" + "Configurações" + "Falha ao selecionar multimédia, por favor tente novamente." + "Pressione uma mensagem e escolha \"%1$s\" para incluir aqui." + "Fixa mensagens importantes para que possam ser facilmente descobertas" + + "%1$d Mensagem afixada" + "%1$d Mensagens afixadas" + + "Mensagens afixadas" + "Estás prestes a aceder à tua conta %1$s para redefinir a tua identidade. Depois disso, serás levado de volta à aplicação." + "Não consegue confirmar? Aceda à sua conta para repor a sua identidade." + "Retirar verificação e enviar" + "Podes retirar a tua verificação e enviar esta mensagem na mesma, ou podes cancelar por agora e tentar novamente mais tarde depois de reverificares %1$s." + "A tua mensagem não foi enviada porque a identidade verificada de %1$s foi reposta" + "Enviar mensagem mesmo assim" + "%1$s está a utilizar um ou mais dispositivos não verificados. Podes enviar a mensagem na mesma, ou podes cancelar por agora e tentar novamente mais tarde, depois de %2$s ter verificado todos os seus dispositivos." + "A sua mensagem não foi enviada porque %1$s não verificou todos os dispositivos" + "Um ou mais dos teus dispositivos não foram verificados. Podes enviar a mensagem na mesma, ou podes cancelar por agora e tentar novamente mais tarde, depois de teres verificado todos os teus dispositivos." + "A sua mensagem não foi enviada porque não verificou um ou mais dos seus dispositivos" + "Editar administradores ou proprietários" + "Falha ao processar multimédia para carregamento, por favor tente novamente." + "Não foi possível obter os detalhes de utilizador." + "Mensagem em %1$s" + "Expandir" + "Reduzir" + "Já estás a ver esta sala!" + "%1$s de %2$s" + "%1$s mensagens afixadas" + "A carregar mensagem…" + "Ver todas" + "Conversa" + "Partilhar localização" + "Partilhar a minha localização" + "Abrir no Apple Maps" + "Abrir no Google Maps" + "Abrir no OpenStreetMap" + "Partilhar este local" + "Espaços que criaste ou nos quais entraste." + "%1$s • %2$s" + "Espaço %1$s" + "Espaços" + "Mensagem não enviada porque a identidade verificada de %1$s foi reposta." + "Mensagem não enviada porque %1$s não verificou todos os dispositivos." + "Mensagem não enviada porque não verificou um ou mais dos seus dispositivos." + "Localização" + "Versão: %1$s (%2$s)" + "pt" + "O histórico de mensagens não está disponível neste dispositivo" + "É necessário verificares este dispositivos para acederes a mensagens antigas" + "Não tens acesso a esta mensagem" + "Impossível decifrar mensagem" + "Esta mensagem foi bloqueada ou porque ainda não verificaste este dispositivo ou porque o remetente necessita de verificar a tua identidade." + 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 0000000..0cf1acf --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml @@ -0,0 +1,489 @@ + + + "Adăugați o reacție: %1$s" + "Imagine de profil" + "Micșorați câmpul mesajului" + "Ștergere" + + "%1$d cifră introdusă" + "%1$d cifre introduse" + "%1$d cifre introduse" + + "Editați avatarul" + "Adresa completă va fi %1$s" + "Detalii privind criptarea" + "Extindeți câmpul mesajului" + "Ascundeți parola" + "Alăturați-vă apelului" + "Mergeți în jos" + "Mutați harta la locația mea" + "Doar mențiuni" + "Notificări dezactivate" + "Mențiuni noi" + "Mesaje noi" + "Apel în desfășurare" + "Avatarul celuilalt utilizator" + "Pagina %1$d" + "Pauză" + "Mesaj vocal, durată:%1$s, poziție curentă: %2$s" + "Câmp PIN" + "Redați" + "Sondaj" + "Sondaj încheiat" + "Reacționați cu %1$s" + "Reacționați cu alte emoji-uri" + "Citit de %1$s și %2$s" + + "Citit de %1$s și incă %2$d" + "Citit de %1$s și incă %2$d" + "Citit de %1$s și incă %2$d" + + "Citit de %1$s" + "Atingeți pentru a le afișa pe toate" + "Îndepărtați reacția cu %1$s" + "Îndepărtați reacția %1$s" + "Avatarul camerei" + "Trimiteți fișiere" + "Acțiune limitată în timp necesară, aveți un minut pentru a verifica" + "Afișați parola" + "Începeți un apel" + "Cameră terminată" + "Avatar utilizator" + "Meniu utilizator" + "Vizualizați avatarul" + "Vizualizați detalii" + "Mesaj vocal, durată: %1$s" + "Înregistrați un mesaj vocal" + "Opriți înregistrarea" + "Avatarul dumneavoastră" + "Acceptați" + "Adăugați o descriere" + "Adăugați listei de mesaje" + "Înapoi" + "Apel" + "Anulați" + "Anulați pentru moment" + "Alegeți o fotografie" + "Ștergeți" + "Închideți" + "Verificare completă" + "Confirmați" + "Confirmați parola" + "Continuați" + "Copiați" + "Copiați descrierea" + "Copiați linkul" + "Copiați linkul către mesaj" + "Copiați textul" + "Creați" + "Creați o cameră" + "Dezactivați" + "Dezactivați contul" + "Refuzați" + "Refuzați și blocați" + "Ștergeți sondajul" + "Deselectați tot" + "Dezactivați" + "Renunţare" + "Renunțați" + "Efectuat" + "Editați" + "Editați descrierea" + "Editați sondajul" + "Activați" + "Închideți sondajul" + "Introduceți PIN-ul" + "Finalizați" + "Ați uitat parola?" + "Redirecționați" + "Înapoi" + "Mergeți la setări" + "Ignorați" + "Invitați" + "Invitați prieteni" + "Invitați prieteni în %1$s" + "Invitați persoane în %1$s" + "Invitații" + "Alăturați-vă" + "Aflați mai multe" + "Părăsiți" + "Părăsiți conversația" + "Părăsiți camera" + "Părăsiți spațiul" + "Încărcați mai mult" + "Administrare cont" + "Gestionare dispozitive" + "Mesaj" + "Minimizați" + "Următorul" + "Nu" + "Nu acum" + "OK" + "Deschideți meniul contextual" + "Setări" + "Deschideți cu" + "Fixează" + "Raspuns rapid" + "Citat" + "Reacționați" + "Respinge" + "Indepărtați" + "Ștergeți descrierea" + "Ștergeți mesajul" + "Răspundeți" + "Răspundeți în fir" + "Raportați" + "Raportați o eroare" + "Raportați conținutul" + "Raportați conversația" + "Raportați camera" + "Resetare" + "Resetați identitatea" + "Reîncercați" + "Reîncercați decriptarea" + "Salvați" + "Căutați" + "Selectați tot" + "Trimiteți" + "Trimiteți mesajul editat" + "Trimiteți mesajul" + "Trimiteți un mesaj vocal" + "Partajați" + "Partajați linkul" + "Afișare" + "Autentificați-vă din nou" + "Deconectați-vă" + "Deconectați-vă oricum" + "Omiteți" + "Începeți" + "Începeți discuția" + "Începeți verificarea" + "Atingeți pentru a încărca harta" + "Faceți o fotografie" + "Atingeți pentru opțiuni" + "Încercați din nou" + "Defixeaza" + "Vizualizați" + "Vedeți în lista de mesaje" + "Vedeți sursă" + "Da" + "Da, încercați din nou" + "Serverul dvs. acceptă acum un protocol nou, mai rapid. Deconectați-vă și conectați-vă din nou pentru a face upgrade acum. Dacă faceți acest lucru acum, vă va ajuta să evitați o deconectare forțată atunci când vechiul protocol este eliminat ulterior." + "Upgrade disponibil" + "Despre" + "Politică de utilizare rezonabilă" + "Adăugați un cont" + "Adăugați un alt cont" + "Adăugare descriere" + "Setări avansate" + "o imagine" + "Analitice" + "Ați părăsit camera" + "Ați fost deconectat din sesiune." + "Aspect" + "Audio" + "Beta" + "Utilizatori blocați" + "Baloane" + "A început un apel" + "Backup conversații" + "Copiat în clipboard" + "Drepturi de autor" + "Se creează camera…" + "Cerere anulată" + "Ați parăsit camera" + "S-a părăsit spațiul" + "Invitația a fost refuzată" + "Întunecat" + "Eroare de decriptare" + "Descriere" + "Opțiuni programator" + "ID-ul dispozitivului" + "Chat direct" + "Nu mai afișa acest mesaj" + "Descărcarea a eșuat" + "Se descarcă" + "(editat)" + "Editare" + "Editare descriere" + "* %1$s %2$s" + "Fișier gol" + "Criptare" + "Criptare activată" + "Introduceți codul PIN" + "Eroare" + "A apărut o eroare, este posibil să nu primiți notificări pentru mesaje noi. Vă rugăm să depanați notificările din setări. + +Motiv:%1$s." + "Toți" + "Eșuat" + "Favorite" + "Favorită" + "Fişier" + "Fișier șters" + "Fișier salvat" + "Fișier salvat în Descărcări" + "Redirecționați mesajul" + "Utilizate frecvent" + "GIF" + "Imagine" + "Ca răspuns la %1$s" + "Instalați APK" + "Nu am putut valida ID-ul Matrix al acestui utilizator. Este posibil ca invitația să nu fi fost trimisă." + "Se părăsește conversația" + "Se părăsește spațiul" + "Deschis" + "Linie copiată în clipboard" + "Linkul a fost copiat în clipboard" + "Se încarcă…" + "Se încarcă…" + + "%d altul" + "%d alții" + "%d alții" + + + "%1$d Membru" + "%1$d Membri" + "%1$d Membri" + + "Mesaj" + "Acțiuni mesaj" + "Aspectul mesajelor" + "Mesaj șters" + "Modern" + "Dezactivați sunetul" + "%1$s (%2$s)" + "Niciun rezultat" + "Fără nume de cameră" + "Fără nume de spațiu" + "Necriptat" + "Deconectat" + "Licențe open source" + "sau" + "Parola" + "Persoane" + "Permalink" + "Permisiune" + "Fixat" + "Vă rugăm să verificați conexiunea la internet" + "Va rugam asteptati…" + "Sunteți sigur că doriți să încheiați acest sondaj?" + "Sondajul %1$s" + "Total voturi: %1$s" + "Rezultatele vor fi afișate după încheierea sondajului" + + "%d vot" + "%d voturi" + "%d voturi" + + "Se pregăteşte…" + "Politica de confidențialitate" + "Cameră privată" + "Spațiu privat" + "Cameră publică" + "Spațiu public" + "Reacţie" + "Reacții" + "Motiv" + "Cheie de recuperare" + "Se actualizează" + + "%1$d răspuns" + "%1$d răspunsuri" + "%1$d răspunsuri" + + "Răspuns pentru %1$s" + "Raportați o eroare" + "Raportați o problemă" + "Raport trimis" + "Editor text avansat" + "Cameră" + "Numele camerei" + "de exemplu, numele proiectului dvs." + + "%1$d Camera" + "%1$d Camere" + "%1$d Camere" + + "Modificări salvate" + "Se salvează…" + "Blocare ecran" + "Căutați pe cineva" + "Rezultatele căutării" + "Securitate" + "Văzut de" + "Selectați un cont" + "Trimiteți către" + "Se trimite…" + "Trimiterea a eșuat" + "Trimis" + ". " + "Serverul nu este compatibil" + "Serverul nu poate fi accesat" + "Adresa URL a serverului" + "Setări" + "Partajați spațiul" + "Locație partajată" + "Spațiu comun" + "Deconectare în curs" + "Ceva nu a mers bine" + "Am întâmpinat o problemă. Vă rugăm să încercați din nou." + "Spațiu" + + "%1$d Spațiu" + "%1$d Spații" + "%1$d Spații" + + "Se începe conversația…" + "Autocolant" + "Succes" + "Sugestii" + "Se sincronizează…" + "Sistem" + "Text" + "Notificări despre software de la terți" + "Fir" + "Subiect" + "Despre ce este vorba în această cameră?" + "Nu s-a putut decripta" + "Trimis de pe un dispozitiv nesigur" + "Nu aveți acces la acest mesaj" + "Identitatea verificată a expeditorului a fost resetată" + "Nu am putut trimite invitații unuia sau mai multor utilizatori." + "Nu s-a putut trimite invitația (invitațiile)" + "Deblocare" + "Activați sunetul" + "Apel nesuportat" + "Eveniment neacceptat" + "Utilizator" + "Verificare anulată" + "Verificare completă" + "Verificarea a eșuat" + "Verificat" + "Verificați dispozitivul" + "Verificați identitatea" + "Verificați utilizatorul" + "Video" + "Calitate înaltă" + "Cea mai bună calitate, dar dimensiuni mai mari ale fișierelor" + "Calitate redusă" + "Cea mai rapidă viteză de încărcare și cea mai mică dimensiune a fișierelor" + "Calitate standard" + "Echilibru între calitate și viteza de încărcare" + "Mesaj vocal" + "Se aşteaptă…" + "Mesaj în așteptare" + "Dumneavoastră" + "Identitatea lui %1$s a fost resetată. %2$s" + "Identitatea %2$s a lui %1$s a fost resetată. %3$s" + "(%1$s)" + "Identitatea lui %1$s a fost resetată." + "Identitatea %2$s a lui %1$s a fost resetată. %3$s" + "Retrageți verificarea" + "Linkul %1$s vă redirecționează către un alt site %2$s + +Sunteți sigur că doriți să continuați?" + "Verificați din nou acest link" + "Selectați calitatea implicită a videoclipurilor pe care le încărcați." + "Calitatea încărcării videoclipurilor" + "Dimensiunea maximă permisă pentru fișiere este: %1$s" + "Dimensiunea fișierului este prea mare pentru a fi încărcat." + "Cameră raportată" + "Camera a fost raportată si parasită" + "Confirmare" + "Eroare" + "Succes" + "Avertisment" + "Modificările dumneavoastră nu au fost salvate. Sunteți sigur că doriți să vă întoarceți?" + "Salvați modificările?" + "Dimensiunea maximă permisă pentru fișiere este: %1$s" + "Selectați calitatea videoclipului pe care doriți să îl încărcați." + "Selectați calitatea de încărcare a videoclipurilor" + "Căutați emoticoane" + "Sunteți deja conectat pe acest dispozitiv ca %1$s." + "Serverul dumneavoastră trebuie actualizat pentru a suporta serviciul de autentificare Matrix și crearea de conturi." + "Crearea permalink-ului a eșuat" + "%1$s nu a putut încărca harta. Vă rugăm să încercați din nou mai târziu." + "Încărcarea mesajelor a eșuat" + "%1$s nu a putut accesa locația dumneavoastră. Vă rugăm să încercați din nou mai târziu." + "Trimiterea mesajului vocal nu a reușit." + "Camera nu mai există sau invitația nu mai este valabilă." + "Mesajul nu a fost găsit" + "%1$s nu are permisiuni pentru a accesa locația dumneavoastră. Puteți permite accesul în Setări." + "%1$s nu are permisiuni pentru a accesa locația dumneavoastră. Permiteți accesul mai jos." + "%1$s nu are permisiunea de a vă accesa microfonul. Permiteți accesul pentru a înregistra un mesaj vocal." + "Acest lucru se poate datora unor probleme de rețea sau de server." + "Această adresă de cameră există deja. Încercați să editați câmpul adresei camerei sau să schimbați numele camerei." + "Unele caractere nu sunt permise. Sunt acceptate doar literele, cifrele și următoarele simboluri ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" + "Unele mesaje nu au fost trimise" + "Ne pare rău, a apărut o eroare" + "Expeditorul evenimentului nu corespunde proprietarului dispozitivului care l-a trimis." + "Autenticitatea acestui mesaj criptat nu poate fi garantată pe acest dispozitiv." + "Criptat de un utilizator verificat anterior." + "Necriptat" + "Criptat de un dispozitiv necunoscut sau șters." + "Criptat de un dispozitiv care nu este verificat de proprietarul său." + "Criptat de un utilizator neverificat." + "🔐️ Alăturați-vă mie în %1$s" + "Hei, vorbește cu mine pe %1$s: %2$s" + "%1$s Android" + "Rageshake pentru a raporta erori" + "Captură de ecran" + "%1$s: %2$s" + "Opțiuni" + "Ștergeți %1$s" + "Setări" + "Selectarea fișierelor media a eșuat, încercați din nou." + "Apăsați pe un mesaj și alegeți \"%1$s\" pentru a-l include aici." + "Fixați mesajele importante, astfel încât să poată fi descoperite cu ușurință" + + "%1$d Mesaj fixat" + "%1$d Mesaje fixate" + "%1$d Mesaje fixate" + + "Mesaje fixate" + "Urmează să accesați contul dvs. %1$s pentru a vă reseta identitatea. După aceea, veți fi redirecționat către aplicație." + "Nu puteți confirma? Accesați contul dvs. pentru a vă reseta identitatea." + "Retrageți verificarea și trimiteți" + "Puteți să vă retrageți verificarea și să trimiteți acest mesaj oricum, sau puteți anula pentru moment și să încercați din nou mai târziu după reverificarea lui %1$s." + "Mesajul dumneavoastră nu a fost trimis deoarece identitatea verificată a lui %1$s s-a schimbat" + "Trimiteți mesajul oricum" + "%1$s utilizează unul sau mai multe dispozitive neverificate. Puteți trimite mesajul oricum sau puteți anula pentru moment și puteți încerca din nou mai târziu, după ce %2$s își va verifica toate dispozitivele." + "Mesajul dvs. nu a fost trimis deoarece %1$s nu si-a verificat toate dispozitivele" + "Unul sau mai multe dispozitive nu sunt verificate. Puteți trimite mesajul oricum sau puteți anula deocamdată și încercați din nou mai târziu după ce ați verificat toate dispozitivele." + "Mesajul dumneavoastră nu a fost trimis deoarece nu ați verificat unul sau mai multe dispozitive" + "Editați administratorii sau proprietarii" + "Procesarea datelor media a eșuat, vă rugăm să încercați din nou." + "Nu am putut găsi detaliile utilizatorului" + "Mesaj în %1$s" + "Extindeți" + "Reduceți" + "Deja vizualizați această cameră!" + "%1$s din %2$s" + "%1$s Mesaje fixate" + "Se încarcă mesajul…" + "Vedeți toate" + "Chat" + "Partajați locația" + "Distribuiți locația mea" + "Deschideți în Apple Maps" + "Deschideți în Google Maps" + "Deschideți în OpenStreetMap" + "Distribuiți această locație" + "Spații pe care le-ați creat sau la care v-ați alăturat." + "%1$s • %2$s" + "Spațiu %1$s" + "Spații" + "Mesajul nu a fost trimis deoarece identitatea verificată a lui %1$s s-a schimbat." + "Mesajul nu a fost trimis deoarece %1$s nu a verificat toate dispozitivele." + "Mesajul nu a fost trimis deoarece nu ați verificat unul sau mai multe dispozitive." + "Locație" + "Versiunea: %1$s (%2$s)" + "ro" + "Messaje anterioare nu sunt disponibile pe acest dispozitiv." + "Trebuie să verificați acest dispozitiv pentru a avea acces la mesajele anterioare." + "Nu aveți acces la acest mesaj" + "Nu s-a putut decripta mesajul" + "Acest mesaj a fost blocat fie pentru că nu ați verificat dispozitivul, fie pentru că expeditorul trebuie să vă verifice identitatea." + diff --git a/libraries/ui-strings/src/main/res/values-ru/translations.xml b/libraries/ui-strings/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000..1290cb3 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml @@ -0,0 +1,495 @@ + + + "Добавить реакцию: %1$s" + "Аватар" + "Свернуть поле текста сообщения" + "Удалить" + + "Введена %1$d цифра" + "Ведено %1$d цифры" + "Введено много цифр" + + "Изменить аватар" + "Полный адрес %1$s" + "Сведения о шифровании" + "Развернуть поле текста сообщения" + "Скрыть пароль" + "Присоединиться к звонку" + "Перейти вниз" + "Переместить карту на мое местоположение" + "Только упоминания" + "Звук отключен" + "Новые упоминания" + "Новые сообшения" + "Текущий вызов" + "Аватар другого пользователя" + "Страница %1$d" + "Приостановить" + "Голосовое сообщение, длительность: %1$s, текущая позиция: %2$s" + "Поле PIN-кода" + "Воспроизвести" + "Опрос" + "Завершённый опрос" + "Реагировать вместе с %1$s" + "Реакция с помощью эмодзи" + "Прочитано %1$s и %2$s" + + "Прочитано %1$s и %2$d другим" + "Прочитано %1$s и %2$d другими" + "Прочитано %1$s и %2$d другими" + + "Прочитано %1$s" + "Нажмите, чтобы показать все" + "Удалить реакцию с %1$s" + "Удалить реакцию %1$s" + "Аватар комнаты" + "Отправить файлы" + "Требуется действие, на которое есть ограничение по времени, у вас есть одна минута для проверки" + "Показать пароль" + "Начать звонок" + "Брошенная комната" + "Аватар пользователя" + "Меню пользователя" + "Просмотреть аватар" + "Подробнее" + "Голосовое сообщение, продолжительность: %1$s" + "Записать голосовое сообщение." + "Остановить запись" + "Ваш аватар" + "Разрешить" + "Добавить подпись" + "Добавить в хронологию" + "Назад" + "Позвонить" + "Отмена" + "Отмените сейчас" + "Выбрать фото" + "Очистить" + "Закрыть" + "Завершите подтверждение" + "Подтвердить" + "Подтвердите пароль" + "Продолжить" + "Копировать" + "Скопировать подпись" + "Скопировать ссылку" + "Скопировать ссылку в сообщение" + "Копировать текст" + "Создать" + "Создать комнату" + "Отключить" + "Отключить учётную запись" + "Отклонить" + "Отклонить и заблокировать" + "Удалить опрос" + "Отменить выбор" + "Отключить" + "Отменить" + "Закрыть" + "Готово" + "Редактировать" + "Изменить подпись" + "Редактировать опрос" + "Включить" + "Завершить опрос" + "Введите PIN-код" + "Завершить" + "Забыли пароль?" + "Переслать" + "Вернуться" + "Перейти к ролям и разрешениям" + "Перейти к настройкам" + "Игнорировать" + "Пригласить" + "Пригласить в комнату" + "Пригласить в %1$s" + "Пригласите пользователей в %1$s" + "Приглашения" + "Присоединиться" + "Подробнее" + "Покинуть" + "Покинуть беседу" + "Покинуть комнату" + "Покинуть пространство" + "Загрузить еще" + "Настройки учетной записи" + "Управление устройствами" + "Сообщение" + "Свернуть" + "Далее" + "Нет" + "Не сейчас" + "Ок" + "Открыть контекстное меню" + "Открыть настройки" + "Открыть с помощью" + "Закрепить" + "Быстрый ответ" + "Цитата" + "Реакция" + "Отклонить" + "Удалить" + "Удалить подпись" + "Удалить сообщение" + "Ответить" + "Ответить в теме" + "Отчет" + "Сообщить об ошибке" + "Пожаловаться на содержание" + "Пожаловаться на беседу" + "Комната отчетов" + "Сбросить" + "Сбросить идентификацию" + "Повторить" + "Повторите расшифровку" + "Сохранить" + "Поиск" + "Выбрать все" + "Отправить" + "Отправить изменённое сообщение" + "Отправить сообщение" + "Отправить голосовое сообщение" + "Поделиться" + "Поделиться ссылкой" + "Показать" + "Повторите вход" + "Выйти" + "Все равно выйти" + "Пропустить" + "Начать" + "Начать чат" + "Начать подтверждение" + "Нажмите, чтобы загрузить карту" + "Сделать фото" + "Нажмите для просмотра вариантов" + "Повторить попытку" + "Открепить" + "Просмотр" + "Просмотр в хронологии" + "Показать источник" + "Да" + "Да, попробуйте еще раз" + "Теперь ваш сервер поддерживает новый, более быстрый протокол. Чтобы обновить его прямо сейчас, выйдите и войдите в свою учётную запись снова. Сделав это сейчас, вы сможете избежать принудительного выхода из системы при последующем удалении старого протокола." + "Доступно обновление" + "О приложении" + "Политика допустимого использования" + "Добавить аккаунт" + "Добавить другой аккаунт" + "Добавление подписи" + "Дополнительные настройки" + "изображение" + "Аналитика" + "Вы покинули комнату" + "Вы вышли из сеанса" + "Внешний вид" + "Аудио" + "Бета-версия" + "Заблокированные пользователи" + "Пузыри" + "Звонок начат" + "Резервная копия чатов" + "Скопировано в буфер обмена" + "Авторское право" + "Создание комнаты…" + "Запрос отменен" + "Покинул комнату" + "Покинуть пространство" + "Приглашение отклонено" + "Темная" + "Ошибка расшифровки" + "Описание" + "Для разработчика" + "Идентификатор устройства" + "Личный чат" + "Не показывать больше" + "Ошибка скачивания" + "Загрузка" + "(изменено)" + "Редактирование" + "Редактирование подписи" + "%1$s%2$s" + "Пустой файл" + "Шифрование" + "Шифрование включено" + "Введите свой PIN-код" + "Ошибка" + "Произошла ошибка. Вы можете не получать уведомления о новых сообщениях. Устраните неполадки с уведомлениями в настройках. + +Причина: %1$s." + "Все" + "Ошибка" + "Избранное" + "Избранное" + "Файл" + "Файл удалён" + "Файл сохранен" + "Файл сохранен в «Загрузки»" + "Переслать сообщение" + "Часто используемые" + "GIF" + "Изображения" + "В ответ на %1$s" + "Установить APK" + "Идентификатор Matrix ID не найден, приглашение может быть не получено." + "Покидаем комнату" + "Покинуть пространство" + "Светлое" + "Строка скопирована в буфер обмена" + "Ссылка скопирована в буфер обмена" + "Загрузка…" + "Загрузить больше…" + + "%d" + "%d другие" + "%d другие" + + + "%1$d участник" + "%1$d участников" + "%1$d участников" + + "Сообщение" + "Действия с сообщением" + "Оформление сообщения" + "Сообщение удалено" + "Современный" + "Выкл. звук" + "%1$s (%2$s)" + "Ничего не найдено" + "Название комнаты отсутствует" + "Нет имени пространства" + "Не зашифровано" + "Не в сети" + "Лицензии с открытым исходным кодом" + "или" + "Пароль" + "Пользователи" + "Постоянная ссылка" + "Разрешение" + "Закрепленный" + "Пожалуйста, проверьте подключение к Интернету" + "Подождите…" + "Вы действительно хотите завершить данный опрос?" + "Опрос: %1$s" + "Всего голосов: %1$s" + "Результаты будут показаны после завершения опроса" + + "%d голос" + "%d голоса" + "%d голосов" + + "Подготовка…" + "Политика конфиденциальности" + "Частная комната" + "Приватное пространство" + "Общедоступная комната" + "Публичное пространство" + "Реакция" + "Реакции" + "Причина" + "Ключ восстановления" + "Обновление…" + + "%1$d ответ" + "%1$d ответа" + "%1$d ответов" + + "Отвечает на %1$s" + "Сообщить об ошибке" + "Сообщить о проблеме" + "Отчет отправлен" + "Редактор форматированного текста" + "Комната" + "Название комнаты" + "например, название вашего проекта" + + "%1$d Комната" + "%1$d Комнат" + "%1$d Комнат" + + "Изменения сохранены" + "Сохранение" + "Блокировка приложения" + "Найти кого-нибудь" + "Результаты поиска" + "Безопасность" + "Просмотрено" + "Выберите учетную запись" + "Отправить" + "Отправка…" + "Сбой отправки" + "Отправлено" + ". " + "Сервер не поддерживается" + "Сервер недоступен" + "Адрес сервера" + "Настройки" + "Поделиться пространством" + "Поделился местоположением" + "Общее пространство" + "Выход…" + "Что-то пошло не так" + "Мы столкнулись с проблемой. Пожалуйста, попробуйте еще раз." + "Пространство" + + "%1$d Пространство" + "%1$d Пространств" + "%1$d Пространств" + + "Чат запускается…" + "Стикер" + "Успешно" + "Предложения" + "Синхронизация" + "Системное" + "Текст" + "Уведомление о третьей стороне" + "Обсуждение" + "Тема" + "О чем эта комната?" + "Невозможно расшифровать" + "Отправлено с незащищенного устройства" + "Вы не имеете доступа к этому сообщению" + "Подтвержденная личность отправителя была сброшена" + "Не удалось отправить приглашения одному или нескольким пользователям." + "Не удалось отправить приглашение(я)" + "Разблокировать" + "Вкл. звук" + "Неподдерживаемый вызов" + "Неподдерживаемое событие" + "Имя пользователя" + "Подтверждение отменено" + "Подтверждение завершено" + "Сбой проверки" + "Проверено" + "Подтверждение устройства" + "Подтвердить личность" + "Подтвердить пользователя" + "Видео" + "Высокое качество" + "Лучшее качество, но больший размер файла" + "Низкое качество" + "Быстрая скорость загрузки и меньший размер файла" + "Стандартное качество" + "Сочетание качества и скорости загрузки" + "Голосовое сообщение" + "Ожидание…" + "Ожидание ключа расшифровки" + "Вы" + "Идентификатор %1$s изменился. %2$s" + "Пользователь %1$s сменил имя на %2$s. %3$s" + "(%1$s)" + "%1$s была сброшена." + "Пользователь %1$s сменил имя на %2$s. %3$s" + "Вывод верификации" + "Ссылка %1$s ведет вас на другой сайт %2$s + +Вы действительно хотите продолжить?" + "Перепроверьте эту ссылку" + "Выберите качество загружаемых видео по умолчанию." + "Качество загружаемого видео" + "Максимально допустимый размер файла: %1$s" + "Размер файла слишком большой для загрузки." + "Сообщение о комнате" + "Пожаловался и покинул комнату" + "Подтверждение" + "Ошибка" + "Успешно" + "Предупреждение" + "Изменения не сохранены. Вы действительно хотите вернуться?" + "Сохранить изменения?" + "Максимально допустимый размер файла: %1$s" + "Выберите качество видео, которое вы хотите загрузить." + "Выберите качество загружаемого видео" + "Поиск эмодзи" + "Вы уже вошли на данном устройстве как %1$s." + "Ваш домашний сервер необходимо обновить, чтобы он поддерживал Matrix Authentication Service и создание учётных записей." + "Не удалось создать постоянную ссылку" + "Не удалось загрузить карту %1$s. Пожалуйста, повторите попытку позже." + "Не удалось загрузить сообщения" + "%1$s не удалось получить доступ к вашему местоположению. Пожалуйста, повторите попытку позже." + "Не удалось загрузить голосовое сообщение." + "Комната больше не существует или приглашение не действительно." + "Сообщение не найдено" + "У %1$s нет разрешения на доступ к вашему местоположению. Вы можете разрешить доступ в Настройках." + "У %1$s нет разрешения на доступ к вашему местоположению. Разрешите доступ ниже." + "%1$s не имеет разрешения на доступ к вашему микрофону. Разрешите доступ к записи голосового сообщения." + "Это может быть связано с проблемами сети или сервера." + "Такой адрес комнаты уже существует, попробуйте отредактировать поле адреса комнаты или изменить название комнаты" + "Некоторые символы не допускаются. Поддерживаются только буквы, цифры и следующие символы! $ & \'() * +/; =? @ [] - . _" + "Некоторые сообщения не были отправлены" + "Извините, произошла ошибка" + "Отправитель события и владелец устройства не совпадают." + "Подлинность этого зашифрованного сообщения не может быть гарантирована на этом устройстве." + "Зашифровано ранее проверенным пользователем." + "Не зашифровано." + "Зашифровано неизвестным или удаленным устройством." + "Зашифровано устройством, не проверенным его владельцем." + "Зашифровано непроверенным пользователем." + "🔐️ Присоединяйтесь ко мне в %1$s" + "Привет, поговори со мной по %1$s: %2$s" + "%1$s Android" + "Встряхните устройство, чтобы сообщить об ошибке" + "Скриншот" + "%1$s: %2$s" + "Параметры" + "Удалить %1$s" + "Настройки" + "Не удалось выбрать носитель, попробуйте еще раз." + "Нажмите на сообщение и выберите “%1$s”, чтобы добавить его сюда." + "Закрепите важные сообщения, чтобы их можно было легко найти" + + "%1$d закреплённое сообщение" + "%1$d закреплённых сообщения" + "%1$d закреплённых сообщений" + + "Закрепленные сообщения" + "Вы собираетесь перейти в свою учетную запись %1$s, чтобы сбросить идентификацию. После этого вы вернетесь в приложение." + "Не можете подтвердить? Перейдите в свою учетную запись, чтобы сбросить свою идентификацию." + "Отозвать статус и отправить" + "Вы можете либо отозвать свой статус подтверждения и всё равно отправить это сообщение, либо отменить его сейчас и повторить попытку после повторного подтверждения %1$s." + "Ваше сообщение не было отправлено, потому что подтвержденная личность %1$s была сброшена" + "Отправь сообщение в любом случае" + "%1$s использует одно или несколько непроверенных устройств. Вы все равно можете отправить сообщение или отменить его пока и повторить попытку позже %2$s, проверив все устройства пользователя." + "Ваше сообщение не было отправлено, потому что %1$s не проверил одно или несколько устройств" + "Одно или несколько ваших устройств не проверены. Вы можете отправить сообщение в любом случае или отменить его пока и повторить попытку позже, проверив все свои устройства." + "Ваше сообщение не было отправлено, поскольку вы не подтвердили одно или несколько своих устройств." + "Изменить настройки" + "Управление пространством" + "Управление комнатами" + "Разрешения" + "Редактировать роль владельца и администратора" + "Не удалось обработать медиафайл для загрузки, попробуйте еще раз." + "Не удалось получить данные о пользователе" + "Сообщение в %1$s" + "Развернуть" + "Уменьшить" + "Эта комната уже просматривается!" + "%1$s из %2$s" + "%1$s Закрепленные сообщения" + "Загрузка сообщения…" + "Посмотреть все" + "Чат" + "Поделиться местоположением" + "Поделиться моим местоположением" + "Открыть в Apple Maps" + "Открыть в Google Maps" + "Открыть в OpenStreetMap" + "Поделиться этим местоположением" + "Пространства, которые вы создали или к которым присоединились." + "%1$s • %2$s" + "%1$s пространство" + "Пространства" + "Просмотреть участников" + "Сообщение не отправлено, потому что подтвержденная личность %1$s была сброшена." + "Сообщение не отправлено, потому что %1$s не проверил одно или несколько устройств." + "Сообщение не отправлено, поскольку вы не подтвердили одно или несколько своих устройств." + "Местоположение" + "Версия: %1$s (%2$s)" + "ru" + "На этом устройстве недоступна история сообщений" + "Вам необходимо проверить это устройство для доступа к истории сообщений" + "Вы не имеете доступа к этому сообщению" + "Не удалось расшифровать сообщение" + "Это сообщение было заблокировано по причине того, что вы не подтвердили свое устройство, либо отправителю необходимо подтвердить вашу личность." + 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 0000000..a94c565 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-sk/translations.xml @@ -0,0 +1,491 @@ + + + "Pridať reakciu: %1$s" + "Obrázok" + "Minimalizovať textové pole správy" + "Vymazať" + + "%1$d zadaná číslica" + "%1$d zadané číslice" + "%1$d zadaných číslic" + + "Upraviť obrázok" + "Celá adresa bude %1$s" + "Podrobnosti o šifrovaní" + "Rozbaliť textové pole správy" + "Skryť heslo" + "Pripojiť sa k hovoru" + "Prejsť na spodok" + "Presunúť mapu na moju polohu" + "Iba zmienky" + "Stlmené" + "Nové zmienky" + "Nové správy" + "Prebiehajúci hovor" + "Obrázok iného používateľa" + "Strana %1$d" + "Pozastaviť" + "Hlasová správa, dĺžka:%1$s, aktuálna pozícia: %2$s" + "Pole PIN" + "Prehrať" + "Anketa" + "Ukončená anketa" + "Reagovať s %1$s" + "Reagovať s inými emotikonmi" + "Prečítal/a %1$s a %2$s" + + "Prečítal/a %1$s a %2$d ďalší" + "Prečítal/a %1$s a %2$d ďalší" + "Prečítal/a %1$s a %2$d ďalších" + + "Prečítal/a %1$s" + "Ťuknutím zobrazíte všetko" + "Odstrániť reakciu s %1$s" + "Odstrániť reakciu s %1$s" + "Obrázok miestnosti" + "Odoslať súbory" + "Vyžaduje sa časovo obmedzená akcia, na overenie máte jednu minútu" + "Zobraziť heslo" + "Začať hovor" + "Opustená miestnosť" + "Profilový obrázok" + "Používateľské menu" + "Zobraziť profilový obrázok" + "Zobraziť podrobnosti" + "Hlasová správa, dĺžka: %1$s" + "Nahrať hlasovú správu." + "Zastaviť nahrávanie" + "Váš profilový obrázok" + "Prijať" + "Pridať titulok" + "Pridať na časovú os" + "Späť" + "Zavolať" + "Zrušiť" + "Zatiaľ zrušiť" + "Vybrať fotku" + "Vyčistiť" + "Zavrieť" + "Dokončiť overenie" + "Potvrdiť" + "Potvrdiť heslo" + "Pokračovať" + "Kopírovať" + "Kopírovať titulok" + "Kopírovať odkaz" + "Kopírovať odkaz do správy" + "Kopírovať text" + "Vytvoriť" + "Vytvoriť miestnosť" + "Deaktivovať" + "Deaktivovať účet" + "Odmietnuť" + "Odmietnuť a zablokovať" + "Odstrániť anketu" + "Zrušiť výber všetkých" + "Vypnúť" + "Zahodiť" + "Zamietnuť" + "Hotovo" + "Upraviť" + "Upraviť titulok" + "Upraviť anketu" + "Povoliť" + "Ukončiť anketu" + "Zadajte PIN" + "Dokončiť" + "Zabudnuté heslo?" + "Preposlať" + "Ísť späť" + "Prejsť na roly a oprávnenia" + "Prejsť na nastavenia" + "Ignorovať" + "Pozvať" + "Pozvať ľudí" + "Pozvať ľudí do %1$s" + "Pozvať ľudí do %1$s" + "Pozvánky" + "Pripojiť sa" + "Zistiť viac" + "Opustiť" + "Opustiť konverzáciu" + "Opustiť miestnosť" + "Opustiť priestor" + "Načítať viac" + "Spravovať účet" + "Spravovať zariadenia" + "Poslať správu" + "Minimalizovať" + "Ďalej" + "Nie" + "Teraz nie" + "OK" + "Otvoriť kontextovú ponuku" + "Otvoriť nastavenia" + "Otvoriť pomocou" + "Pripnúť" + "Rýchla odpoveď" + "Citovať" + "Reagovať" + "Odmietnuť" + "Odstrániť" + "Odstrániť titulok" + "Odstrániť správu" + "Odpovedať" + "Odpovedať vo vlákne" + "Nahlásiť" + "Nahlásiť chybu" + "Nahlásiť obsah" + "Nahlásiť konverzáciu" + "Nahlásiť miestnosť" + "Obnoviť" + "Obnoviť identitu" + "Skúsiť znova" + "Opakovať dešifrovanie" + "Uložiť" + "Hľadať" + "Vybrať všetko" + "Odoslať" + "Odoslať upravenú správa" + "Odoslať správu" + "Odoslať hlasovú správu" + "Zdieľať" + "Zdieľať odkaz" + "Zobraziť" + "Prihláste sa znova" + "Odhlásiť sa" + "Napriek tomu sa odhlásiť" + "Preskočiť" + "Spustiť" + "Začať konverzáciu" + "Spustiť overovanie" + "Ťuknutím načítate mapu" + "Urobiť fotku" + "Klepnutím získate možnosti" + "Skúste to znova" + "Odopnúť" + "Zobraziť" + "Zobraziť na časovej osi" + "Zobraziť zdroj" + "Áno" + "Áno, skúsiť to znova" + "Váš server teraz podporuje nový, rýchlejší protokol. Odhláste sa a prihláste sa znova, aby ste mohli aktualizovať. Ak to urobíte teraz, pomôže vám vyhnúť sa nútenému odhláseniu, keď sa starý protokol neskôr odstráni." + "Aktualizácia je k dispozícii" + "O aplikácii" + "Zásady prijateľného používania" + "Pridať účet" + "Pridať ďalší účet" + "Pridáva sa titulok" + "Pokročilé nastavenia" + "obrázok" + "Analytika" + "Opustili ste miestnosť" + "Boli ste odhlásení zo relácie." + "Vzhľad" + "Zvuk" + "Beta" + "Blokovaní používatelia" + "Bubliny" + "Hovor sa začal" + "Záloha konverzácie" + "Skopírované do schránky" + "Autorské práva" + "Vytváranie miestnosti…" + "Žiadosť bola zrušená" + "Opustil/a miestnosť" + "Opustil priestor" + "Pozvánka bola odmietnutá" + "Tmavý" + "Chyba dešifrovania" + "Popis" + "Možnosti pre vývojárov" + "ID zariadenia" + "Priama konverzácia" + "Nezobrazovať toto znova" + "Sťahovanie zlyhalo" + "Sťahovanie" + "(upravené)" + "Upravuje sa" + "Úprava titulku" + "* %1$s %2$s" + "Prázdny súbor" + "Šifrovanie" + "Šifrovanie zapnuté" + "Zadajte svoj PIN" + "Chyba" + "Vyskytla sa chyba, nemusíte dostávať upozornenia na nové správy. Prosím, vyriešte problémy s upozorneniami v nastaveniach. + +Dôvod: %1$s." + "Všetci" + "Zlyhalo" + "Obľúbené" + "Obľúbené" + "Súbor" + "Súbor bol vymazaný" + "Súbor bol uložený" + "Súbor bol uložený do priečinka Stiahnuté súbory" + "Preposlať správu" + "Často používané" + "GIF" + "Obrázok" + "V odpovedi na %1$s" + "Inštalovať APK" + "Toto Matrix ID sa nedá nájsť, takže pozvánka nemusí byť prijatá." + "Opustenie miestnosti" + "Opúšťanie priestoru" + "Svetlý" + "Riadok skopírovaný do schránky" + "Odkaz bol skopírovaný do schránky" + "Načítava sa…" + "Načítava sa viac…" + + "%d ďalší" + "%d ďalší" + "%d ďalších" + + + "%1$d člen" + "%1$d členovia" + "%1$d členov" + + "Správa" + "Akcie správy" + "Štýl správ" + "Správa odstránená" + "Moderné" + "Stlmiť" + "%1$s (%2$s)" + "Žiadne výsledky" + "Žiadny názov miestnosti" + "Žiadny názov priestoru" + "Nešifrované" + "Offline" + "Licencie s otvoreným zdrojom" + "alebo" + "Heslo" + "Ľudia" + "Trvalý odkaz" + "Povolenie" + "Pripnuté" + "Skontrolujte si prosím vaše pripojenie k internetu" + "Prosím, počkajte…" + "Ste si istí, že chcete ukončiť túto anketu?" + "Anketa: %1$s" + "Celkový počet hlasov: %1$s" + "Výsledky sa zobrazia po ukončení ankety" + + "%d hlas" + "%d hlasy" + "%d hlasov" + + "Pripravuje sa…" + "Zásady ochrany osobných údajov" + "Súkromná miestnosť" + "Súkromný priestor" + "Verejná miestnosť" + "Verejný priestor" + "Reakcia" + "Reakcie" + "Dôvod" + "Kľúč na obnovenie" + "Obnovuje sa…" + + "%1$d odpoveď" + "%1$d odpovede" + "%1$d odpovedí" + + "Odpoveď na %1$s" + "Nahlásiť chybu" + "Nahlásiť problém" + "Nahlásenie bolo odoslané" + "Rozšírený textový editor" + "Miestnosť" + "Názov miestnosti" + "napr. názov vášho projektu" + + "%1$d miestnosť" + "%1$d miestnosti" + "%1$d miestností" + + "Uložené zmeny" + "Ukladá sa" + "Zámok obrazovky" + "Vyhľadať niekoho" + "Výsledky hľadania" + "Bezpečnosť" + "Videné" + "Vyberte účet" + "Odoslať" + "Odosiela sa…" + "Odoslanie zlyhalo" + "Odoslané" + ". " + "Server nie je podporovaný" + "Server je nedostupný" + "URL adresa servera" + "Nastavenia" + "Zdieľať priestor" + "Zdieľaná poloha" + "Zdieľaný priestor" + "Odhlasovanie" + "Niečo sa pokazilo" + "Vyskytol sa problém. Skúste to prosím znova." + "Priestor" + + "%1$d priestor" + "%1$d priestory" + "%1$d priestorov" + + "Spustenie konverzácie…" + "Nálepka" + "Úspech" + "Návrhy" + "Synchronizuje sa" + "Systém" + "Text" + "Oznámenia tretích strán" + "Vlákno" + "Téma" + "O čom je táto miestnosť?" + "Nie je možné dešifrovať" + "Odoslané z nezabezpečeného zariadenia" + "Nemáte prístup k tejto správe" + "Overená totožnosť odosielateľa bola obnovená" + "Pozvánky nebolo možné odoslať jednému alebo viacerým používateľom." + "Nie je možné odoslať pozvánku/ky" + "Odomknúť" + "Zrušiť stlmenie zvuku" + "Nepodporovaný hovor" + "Nepodporovaná udalosť" + "Používateľské meno" + "Overovanie zrušené" + "Overovanie je dokončené" + "Overenie zlyhalo" + "Overené" + "Overiť zariadenie" + "Overiť totožnosť" + "Overiť používateľa" + "Video" + "Vysoká kvalita" + "Najlepšia kvalita, ale väčšia veľkosť súboru" + "Nízka kvalita" + "Najrýchlejšia rýchlosť nahrávania a najmenšia veľkosť súboru" + "Štandardná kvalita" + "Vyvážená kvalita a rýchlosť nahrávania" + "Hlasová správa" + "Čaká sa…" + "Čaká sa na dešifrovací kľúč" + "Vy" + "Totožnosť používateľa %1$s sa obnovila.%2$s" + "Totožnosť používateľa %1$s %2$s bola obnovená. %3$s" + "(%1$s)" + "Totožnosť používateľa %1$s bola obnovená." + "Totožnosť používateľa %1$s %2$s bola obnovená. %3$s" + "Zrušiť overenie" + "Odkaz %1$s vás presmeruje na inú stránku %2$s + +Naozaj chcete pokračovať?" + "Dôkladne skontrolujte tento odkaz" + "Vyberte predvolenú kvalitu nahrávaných videí." + "Kvalita nahrávania videa" + "Maximálna povolená veľkosť súboru je: %1$s" + "Súbor je príliš veľký na nahratie" + "Miestnosť nahlásená" + "Nahlásili ste a opustili ste miestnosť" + "Potvrdenie" + "Chyba" + "Úspech" + "Upozornenie" + "Vaše zmeny neboli uložené. Naozaj sa chcete vrátiť?" + "Uložiť zmeny?" + "Maximálna povolená veľkosť súboru je: %1$s" + "Vyberte kvalitu videa, ktoré chcete nahrať." + "Vyberte kvalitu nahrávania videa" + "Hľadať emotikony" + "Už ste prihlásený/á na tomto zariadení ako %1$s ." + "Váš domovský server musí byť aktualizovaný tak, aby podporoval Matrix Authentication Service a vytvorenie účtu." + "Nepodarilo sa vytvoriť trvalý odkaz" + "%1$s nedokázal načítať mapu. Skúste to prosím neskôr." + "Načítanie správ zlyhalo" + "%1$s nemohol získať prístup k vašej polohe. Skúste to prosím neskôr." + "Nepodarilo sa nahrať hlasovú správu." + "Miestnosť už neexistuje alebo pozvánka už nie je platná." + "Správa sa nenašla" + "%1$s nemá povolenie na prístup k vašej polohe. Prístup môžete zapnúť v Nastaveniach." + "%1$s nemá povolenie na prístup k vašej polohe. Povoľte prístup nižšie." + "%1$s nemá povolenie na prístup k vášmu mikrofónu. Povoľte prístup na nahrávanie hlasovej správy." + "Môže to byť spôsobené problémami so sieťou alebo serverom." + "Táto adresa miestnosti už existuje, skúste upraviť pole adresy miestnosti alebo zmeňte názov miestnosti" + "Niektoré znaky nie sú povolené. Podporované sú iba písmená, číslice a nasledujúce symboly ! $ & \'() * +/; =? @ [] - . _" + "Niektoré správy neboli odoslané" + "Prepáčte, vyskytla sa chyba" + "Odosielateľ udalosti sa nezhoduje s vlastníkom zariadenia, ktoré ju odoslalo." + "Pravosť tejto šifrovanej správy nie je možné zaručiť na tomto zariadení." + "Šifrované predtým overeným používateľom." + "Nie je šifrované." + "Zašifrované neznámym alebo odstráneným zariadením." + "Šifrované zariadením, ktoré nie je overené jeho majiteľom." + "Šifrované neovereným používateľom." + "🔐️ Pripojte sa ku mne na %1$s" + "Ahoj, porozprávajte sa so mnou na %1$s: %2$s" + "%1$s Android" + "Zúrivo potriasť pre nahlásenie chyby" + "Snímka obrazovky" + "%1$s: %2$s" + "Možnosti" + "Odstrániť %1$s" + "Nastavenia" + "Nepodarilo sa vybrať médium, skúste to prosím znova." + "Stlačte správu a vyberte možnosť „%1$s“, ktorú chcete zahrnúť sem." + "Pripnite dôležité správy, aby sa dali ľahko nájsť" + + "%1$d pripnutá správa" + "%1$d pripnuté správy" + "%1$d pripnutých správ" + + "Pripnuté správy" + "Chystáte sa prejsť na svoj %1$s účet, aby ste obnovili svoju identitu. Potom budete vrátení späť do aplikácie." + "Neviete potvrdiť? Prejdite do svojho účtu a obnovte svoju identitu." + "Odvolať overenie a odoslať" + "Svoje overenie môžete odvolať a odoslať túto správu aj tak, alebo ju môžete zatiaľ zrušiť a po opätovnom overení to skúsiť znova %1$s." + "Vaša správa nebola odoslaná, pretože sa zmenila overená totožnosť používateľa %1$s." + "Odoslať správu aj tak" + "%1$s používa jedno alebo viac neoverených zariadení. Správu môžete odoslať aj tak, alebo ju môžete zatiaľ zrušiť a skúsiť to znova neskôr po %2$s overení všetkých zariadení." + "Vaša správa nebola odoslaná, pretože %1$s neoveril/a všetky zariadenia." + "Jedno alebo viac vašich zariadení nie je overených. Správu môžete odoslať aj tak, alebo môžete zatiaľ zrušiť a skúsiť to znova neskôr po overení všetkých svojich zariadení." + "Vaša správa nebola odoslaná, pretože ste neoverili jedno alebo viac svojich zariadení" + "Upraviť správcov alebo vlastníkov" + "Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova." + "Nepodarilo sa získať údaje o používateľovi" + "Správa v %1$s" + "Rozbaliť" + "Zmenšiť" + "Už si prezeráte túto miestnosť!" + "%1$s z %2$s" + "%1$s Pripnutých správ" + "Načítava sa správa…" + "Zobraziť všetko" + "Konverzácia" + "Zdieľať polohu" + "Zdieľať moju polohu" + "Otvoriť v Apple Maps" + "Otvoriť v Mapách Google" + "Otvoriť v OpenStreetMap" + "Zdieľajte túto polohu" + "Priestory, ktoré ste vytvorili alebo ku ktorým ste sa pripojili." + "%1$s • %2$s" + "%1$s priestor" + "Priestory" + "Zobraziť členov" + "Správa nebola odoslaná, pretože sa zmenila overená totožnosť používateľa %1$s." + "Správa nebola odoslaná, pretože %1$s neoveril/a všetky zariadenia." + "Správa nebola odoslaná, pretože ste neoverili jedno alebo viac svojich zariadení." + "Poloha" + "Verzia: %1$s (%2$s)" + "sk" + "Historické správy nie sú na tomto zariadení k dispozícii" + "Ak chcete získať prístup k historickým správam, musíte toto zariadenie overiť" + "Nemáte prístup k tejto správe" + "Správu sa nepodarilo dešifrovať" + "Táto správa bola zablokovaná buď preto, že ste neoverili svoje zariadenie, alebo preto, že odosielateľ potrebuje overiť vašu totožnosť." + diff --git a/libraries/ui-strings/src/main/res/values-sv/translations.xml b/libraries/ui-strings/src/main/res/values-sv/translations.xml new file mode 100644 index 0000000..e3cf261 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-sv/translations.xml @@ -0,0 +1,459 @@ + + + "Lägg till reaktion: %1$s" + "Avatar" + "Radera" + + "%1$d siffra angiven" + "%1$d siffror angivna" + + "Redigera avatar" + "Den fullständiga adressen kommer att vara %1$s" + "Krypteringsdetaljer" + "Dölj lösenord" + "Anslut till samtal" + "Hoppa till botten" + "Flytta kartan till min plats" + "Endast omnämningar" + "Tystad" + "Nya omnämnanden" + "Nya meddelanden" + "Pågående samtal" + "Annan användares avatar" + "Sida %1$d" + "Pausa" + "Röstmeddelande, varaktighet:%1$s, nuvarande position: %2$s" + "PIN-fält" + "Spela upp" + "Omröstning" + "Avslutade omröstning" + "Reagera med %1$s" + "Reagera med andra emojier" + "Läst av %1$s och %2$s" + + "Läst av %1$s och %2$d annan" + "Läst av %1$s och %2$d andra" + + "Läst av %1$s" + "Tryck för att visa alla" + "Ta bort reaktionen med %1$s" + "Ta bort reaktion med %1$s" + "Rumsavatar" + "Skicka filer" + "Tidsbegränsad åtgärd krävs" + "Visa lösenord" + "Starta ett samtal" + "Gravstensmärkt rum" + "Användaravatar" + "Användarmeny" + "Visa avatar" + "Visa detaljer" + "Röstmeddelande, varaktighet: %1$s" + "Spela in röstmeddelande." + "Stoppa inspelning" + "Din avatar" + "Godkänn" + "Lägg till bildtext" + "Lägg till i tidslinjen" + "Tillbaka" + "Ring" + "Avbryt" + "Avbryt för tillfället" + "Välj bild" + "Rensa" + "Stäng" + "Slutför verifiering" + "Bekräfta" + "Bekräfta lösenord" + "Fortsätt" + "Kopiera" + "Kopiera bildtext" + "Kopiera länk" + "Kopiera länk till meddelande" + "Kopiera text" + "Skapa" + "Skapa ett rum" + "Inaktivera" + "Inaktivera konto" + "Neka" + "Avvisa och blockera" + "Radera omröstning" + "Inaktivera" + "Kassera" + "Avfärda" + "Klar" + "Redigera" + "Redigera bildtext" + "Redigera omröstning" + "Aktivera" + "Avsluta omröstning" + "Ange PIN-kod" + "Slutför" + "Glömt lösenordet?" + "Vidarebefordra" + "Gå tillbaka" + "Ignorera" + "Bjud in" + "Bjud in personer" + "Bjud in personer till %1$s" + "Bjud in personer till %1$s" + "Inbjudningar" + "Gå med" + "Läs mer" + "Lämna" + "Lämna konversation" + "Lämna rum" + "Ladda mer" + "Hantera konto" + "Hantera enheter" + "Meddela" + "Nästa" + "Nej" + "Inte nu" + "OK" + "Öppna kontextmenyn" + "Inställningar" + "Öppna med" + "Fäst" + "Snabbsvar" + "Citera" + "Reagera" + "Avvisa" + "Ta bort" + "Ta bort bildtext" + "Ta bort meddelande" + "Svara" + "Svara i tråd" + "Anmäl" + "Rapportera bugg" + "Rapportera innehåll" + "Anmäl konversation" + "Anmäl rum" + "Återställ" + "Återställ identitet" + "Försök igen" + "Försök att avkryptera igen" + "Spara" + "Sök" + "Skicka" + "Skicka redigerat message" + "Skicka meddelande" + "Skicka röstmeddelande" + "Dela" + "Dela länk" + "Visa" + "Logga in igen" + "Logga ut" + "Logga ut ändå" + "Hoppa över" + "Starta" + "Starta chat" + "Starta verifiering" + "Tryck för att ladda kartan" + "Ta ett foto" + "Tryck för alternativ" + "Försök igen" + "Frigör" + "Visa" + "Visa i tidslinjen" + "Visa källkod" + "Ja" + "Ja, försök igen" + "Din server stöder nu ett nytt, snabbare protokoll. Logga ut och logga in igen för att uppgradera nu. Om du gör detta nu hjälper du dig att undvika en tvingad utloggning när det gamla protokollet tas bort senare." + "Uppgradering tillgänglig" + "Om" + "Policy för godtagbar användning" + "Lägga till bildtext" + "Avancerade inställningar" + "en bild" + "Analysdata" + "Du lämnade rummet" + "Du loggades ut ur sessionen" + "Utseende" + "Ljud" + "Blockerade användare" + "Bubblor" + "Samtal startat" + "Chattsäkerhetskopia" + "Kopierad till klippbordet" + "Upphovsrätt" + "Skapar rum …" + "Begäran avbruten" + "Lämnade rummet" + "Inbjudan avvisad" + "Mörkt" + "Avkrypteringsfel" + "Utvecklaralternativ" + "Enhets-ID" + "Direktchatt" + "Visa inte detta igen" + "Nedladdningen misslyckades" + "Laddar ner" + "(redigerad)" + "Redigerar" + "Redigera bildtext" + "* %1$s %2$s" + "Tom fil" + "Kryptering" + "Kryptering aktiverad" + "Ange din PIN-kod" + "Fel" + "Ett fel inträffade, du kanske inte får aviseringar för nya meddelanden. Felsök aviseringar från inställningarna. + +Anledning:%1$s." + "Alla" + "Misslyckades" + "Favorit" + "Favoriter" + "Fil" + "Fil borttagen" + "Fil sparad" + "Fil sparad i Download" + "Vidarebefordra meddelande" + "Används ofta" + "GIF" + "Bild" + "Som svar på %1$s" + "Installera APK" + "Det här Matrix-ID:t kan inte hittas, så inbjudan kanske inte tas emot." + "Lämnar rummet" + "Ljust" + "Rad kopierad till klippbordet" + "Länk kopierad till klippbordet" + "Laddar …" + "Laddar mer …" + + "%d annan" + "%d andra" + + + "%1$d medlem" + "%1$d medlemmar" + + "Meddelande" + "Meddelandeåtgärder" + "Meddelandearrangemang" + "Meddelande borttaget" + "Modernt" + "Tysta" + "%1$s (%2$s)" + "Inga resultat" + "Inget rumsnamn" + "Inte krypterad" + "Frånkopplad" + "Licenser för öppen källkod" + "eller" + "Lösenord" + "Personer" + "Permalänk" + "Behörighet" + "Fäst" + "Vänligen kontrollera din internetanslutning" + "Vänligen vänta …" + "Är du säker på att du vill avsluta den här omröstningen?" + "Omröstning: %1$s" + "Totalt antal röster: %1$s" + "Resultaten visas efter att omröstningen har avslutats" + + "%d röst" + "%d röster" + + "Förbereder …" + "Integritetspolicy" + "Privat rum" + "Privat utrymme" + "Offentligt rum" + "Offentligt utrymme" + "Reaktion" + "Reaktioner" + "Orsak" + "Återställningsnyckel" + "Uppdaterar …" + + "%1$d svar" + "%1$d svar" + + "Svarar till %1$s" + "Rapportera en bugg" + "Rapportera ett problem" + "Rapport inskickad" + "Riktextredigerare" + "Rum" + "Rumsnamn" + "t.ex. ditt projektnamn" + + "%1$d Rum" + "%1$d Rum" + + "Sparade ändringar" + "Sparar" + "Skärmlås" + "Sök efter någon" + "Sökresultat" + "Säkerhet" + "Sett av" + "Skicka till" + "Skickar …" + "Misslyckades att skicka" + "Skickat" + ". " + "Servern stöds inte" + "Server-URL" + "Inställningar" + "Delade plats" + "Loggar ut" + "Något gick fel" + "Vi stötte på ett problem. Vänligen försök igen." + "Utrymme" + + "%1$d Utrymme" + "%1$d Utrymmen" + + "Startar chatt …" + "Dekal" + "Lyckades" + "Förslag" + "Synkar" + "System" + "Text" + "Meddelanden från tredje part" + "Tråd" + "Ämne" + "Vad handlar det här rummet om?" + "Kan inte avkryptera" + "Skickad från en osäker enhet" + "Du har inte tillgång till det här meddelandet" + "Avsändarens verifierade identitet återställdes" + "Inbjudan kunde inte skickas till en eller flera användare." + "Kunde inte skicka inbjudningar" + "Lås upp" + "Avtysta" + "Samtal som inte stöds" + "Händelse som inte stöds" + "Användarnamn" + "Verifiering avbruten" + "Verifieringen slutförd" + "Verifiering misslyckades" + "Verifierad" + "Verifiera enheten" + "Verifiera identitet" + "Verifiera användare" + "Video" + "Hög kvalitet" + "Bästa kvalitet men större filstorlek" + "Låg kvalitet" + "Snabbast uppladdningshastighet och minsta filstorlek" + "Standardkvalitet" + "Balans mellan kvalitet och uppladdningshastighet" + "Röstmeddelande" + "Väntar …" + "Väntar på detta meddelande" + "Du" + "Identitet för %1$s återställdes. %2$s" + "Identitet för %1$s %2$s återställdes. %3$s" + "(%1$s)" + "Verifierad identitet för %1$s återställdes." + "Verifierad identitet för %1$s %2$s återställdes.%3$s" + "Återkalla verifiering" + "Länken %1$s tar dig till en annan webbplats %2$s + +Är du säker på att du vill fortsätta?" + "Dubbelkolla den här länken" + "Välj standardkvalitet för videor du laddar upp." + "Videouppladdningskvalitet" + "Den maximala tillåtna filstorleken är: %1$s" + "Filen är för stor för att laddas upp." + "Rum anmält" + "Anmälde och lämnade rummet" + "Bekräftelse" + "Fel" + "Lyckades" + "Varning" + "Dina ändringar har inte sparats. Är du säker på att du vill gå tillbaka?" + "Spara ändringar?" + "Den maximala tillåtna filstorleken är: %1$s" + "Välj kvaliteten på videon du vill ladda upp." + "Välj videouppladdningskvalitet" + "Din hemserver måste uppgraderas för att stödja Matrix Authentication Service och skapande av konto." + "Misslyckades att skapa permalänken" + "%1$s kunde inte ladda kartan. Vänligen försök igen senare." + "Misslyckades att ladda meddelanden" + "%1$s kunde inte komma åt din plats. Vänligen försök igen senare." + "Misslyckades med att ladda upp ditt röstmeddelande." + "Rummet finns inte längre eller så är inbjudan inte längre giltig." + "Meddelandet hittades inte" + "%1$s är inte behörig att komma åt din plats. Du kan aktivera åtkomst i Inställningar." + "%1$s är inte behörig att komma åt din plats. Aktivera åtkomst nedan." + "%1$s är inte behörig att komma åt din mikrofon. Aktivera åtkomst för att spela in ett röstmeddelande." + "Detta kan bero på nätverks- eller serverproblem." + "Den här rumsadressen finns redan. Försök att redigera adressfältet för rummet eller ändra rummets namn" + "Vissa tecken är inte tillåtna. Endast bokstäver, siffror och följande symboler stöds ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" + "Vissa meddelanden har inte skickats" + "Tyvärr, ett fel uppstod" + "Avsändaren av händelsen matchar inte ägaren av den enhet som skickade den." + "Detta krypterade meddelandes äkthet kan inte garanteras på den här enheten." + "Krypterat av en tidigare verifierad användare." + "Inte krypterad." + "Krypterad av en okänd eller raderad enhet." + "Krypterad av en enhet som inte verifierats av ägaren." + "Krypterad av en overifierad användare." + "🔐️ Häng med mig på %1$s" + "Hallå, prata med mig på %1$s: %2$s" + "%1$s Android" + "Raseriskaka för att rapportera bugg" + "Skärmdump" + "%1$s: %2$s" + "Alternativ" + "Ta bort %1$s" + "Inställningar" + "Misslyckades att välja media, vänligen pröva igen." + "Tryck på ett meddelande och välj ”%1$s” för att inkludera det här." + "Fäst viktiga meddelanden så att de lätt kan upptäckas" + + "%1$d Fäst meddelande" + "%1$d Fästa meddelanden" + + "Fästa meddelanden" + "Du är på väg att gå till ditt %1$s-konto för att återställa din identitet. Därefter kommer du att tas tillbaka till appen." + "Kan du inte bekräfta? Gå till ditt konto för att återställa din identitet." + "Dra tillbaka verifieringen och skicka" + "Du kan dra tillbaka din verifiering och skicka det här meddelandet ändå, eller så kan du avbryta för tillfället och försöka igen senare efter att ha verifierat %1$s igen." + "Ditt meddelande skickades inte eftersom verifierad identitet för %1$s återställdes" + "Skicka meddelandet ändå" + "%1$s använder en eller flera overifierade enheter. Du kan skicka meddelandet ändå, eller så kan du avbryta för tillfället och försöka igen senare efter att %2$s har verifierat alla sina enheter." + "Ditt meddelande skickades inte eftersom %1$s inte har verifierat alla enheter" + "En eller flera av dina enheter är overifierade. Du kan skicka meddelandet ändå, eller så kan du avbryta nu och försöka igen senare efter att du har verifierat alla dina enheter." + "Ditt meddelande skickades inte eftersom du inte har verifierat en eller flera av dina enheter" + "Redigera administratörer eller ägare" + "Misslyckades att bearbeta media för uppladdning, vänligen pröva igen." + "Kunde inte hämta användarinformation" + "Meddelande i %1$s" + "Expandera" + "Reducera" + "Visar redan det här rummet!" + "%1$s av %2$s" + "%1$s Fästa meddelanden" + "Laddar meddelande …" + "Visa alla" + "Chatt" + "Dela plats" + "Dela min plats" + "Öppna i Apple Maps" + "Öppna i Google Maps" + "Öppna i OpenStreetMap" + "Dela den här platsen" + "Utrymmen som du har skapat eller gått med i." + "%1$s • %2$s" + "Utrymmen" + "Meddelandet skickades inte eftersom verifierad identitet för %1$s återställdes." + "Meddelandet skickades inte eftersom %1$s inte har verifierat alla enheter." + "Meddelandet skickades inte eftersom du inte har verifierat en eller flera av dina enheter." + "Plats" + "Version: %1$s (%2$s)" + "sv" + "Historiska meddelanden är inte tillgängliga på den här enheten" + "Du måste verifiera den här enheten för åtkomst till historiska meddelanden" + "Du har inte tillgång till det här meddelandet" + "Det gick inte att dekryptera meddelandet" + "Det här meddelandet blockerades antingen för att du inte verifierade din enhet eller för att avsändaren måste verifiera din identitet." + diff --git a/libraries/ui-strings/src/main/res/values-tr/translations.xml b/libraries/ui-strings/src/main/res/values-tr/translations.xml new file mode 100644 index 0000000..c72f5a6 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-tr/translations.xml @@ -0,0 +1,379 @@ + + + "Profil resmi" + "Sil" + + "%1$d basamak girildi" + "%1$d basamak girildi" + + "Şifreyi gizle" + "Aramaya katıl" + "Aşağıya atla" + "Yalnızca bahsetmeler" + "Sessiz" + "Sayfa %1$d" + "Duraklat" + "PIN alanı" + "Oynat" + "Anket" + "Anket sona erdi" + "%1$s ile tepki verin" + "Diğer emojilerle tepki verin" + "Okuyan %1$s ve %2$s" + + "%1$s ve %2$d diğer kişi tarafından okundu" + "%1$s ve %2$d diğer kişi tarafından okundu" + + "Okuyan %1$s" + "Tümünü göstermek için dokunun" + "Tepkimeyi kaldır %1$s" + "Dosyaları gönder" + "Şifreyi göster" + "Bir arama başlatın" + "Kullanıcı menüsü" + "Ayrıntıları görüntüle" + "Sesli mesajı kaydedin." + "Kaydı durdur" + "Kabul et" + "Açıklama ekle" + "Zaman çizelgesine ekle" + "Geri" + "Çağrı" + "İptal" + "Şimdilik iptal et" + "Fotoğraf seç" + "Temizle" + "Kapat" + "Doğrulamayı tamamlayın" + "Onayla" + "Şifreyi onayla" + "Devam et" + "Kopyala" + "Açıklamayı kopyala" + "Bağlantıyı kopyala" + "Bağlantıyı mesaja kopyala" + "Metni kopyala" + "Oluştur" + "Bir oda oluştur" + "Devre dışı bırak" + "Hesabı devre dışı bırak" + "Reddet" + "Anketi Sil" + "Devre dışı" + "Vazgeç" + "Kapat" + "Bitti" + "Düzenle" + "Açıklamayı düzenle" + "Anketi düzenle" + "Etkinleştir" + "Anketi sonlandır" + "PIN girin" + "Parolanızı mı unuttunuz?" + "İleri" + "Geri dön" + "Yoksay" + "Davet et" + "İnsanları davet et" + "İnsanları davet et %1$s" + "İnsanları davet et %1$s" + "Davetiyeler" + "Katıl" + "Daha fazla bilgi" + "Ayrıl" + "Sohbeti bırak" + "Odadan ayrıl" + "Daha fazla yükle" + "Hesabı yönet" + "Cihazları yönet" + "Mesaj" + "Sonraki" + "Hayır" + "Şimdi değil" + "TAMAM" + "Ayarlar" + "İle aç" + "Pin" + "Hızlı cevap" + "Alıntı" + "Tepki" + "Reddet" + "Kaldır" + "Açıklamayı kaldır" + "Mesajı kaldır" + "Yanıtla" + "Konuya cevap ver" + "Hata bildir" + "İçeriği bildir" + "Sıfırla" + "Kimliği sıfırla" + "Yeniden dene" + "Şifre çözmeyi tekrar deneyin" + "Kaydet" + "Ara" + "Gönder" + "Mesaj gönder" + "Paylaş" + "Linki paylaş" + "Göster" + "Tekrar oturum açın" + "Oturumu kapat" + "Yine de çıkış yap" + "Atla" + "Başlat" + "Sohbeti başlat" + "Doğrulamayı başlat" + "Haritayı yüklemek için dokunun" + "Fotoğraf çek" + "Seçenekler için dokunun" + "Tekrar deneyin" + "Sabitlemeyi kaldır" + "Zaman çizelgesinde görüntüle" + "Kaynağı görüntüle" + "Evet" + "Evet, tekrar dene" + "Sunucunuz artık yeni, daha hızlı bir protokolü destekliyor. Şimdi oturumu kapatıp tekrar oturum açarak yükseltme yapın. Bunu şimdi yapmak, eski protokol daha sonra kaldırıldığında zorunlu oturum kapatmayı önlemenize yardımcı olacaktır." + "Yükseltme mevcut" + "Hakkında" + "Kabul edilebilir kullanım politikası" + "Açıklama ekleme" + "Gelişmiş Ayarlar" + "Analizler" + "Görünüm" + "Ses" + "Engellenen kullanıcılar" + "Kabarcıklar" + "Çağrı başladı" + "Sohbet yedekleme" + "Panoya kopyalandı" + "Telif Hakkı" + "Oda yaratmak…" + "İstek iptal edildi" + "Sol oda" + "Davet reddedildi" + "Koyu" + "Şifre çözme hatası" + "Geliştirici seçenekleri" + "Cihaz Kimliği" + "Doğrudan sohbet" + "Bunu bir daha gösterme" + "İndirme başarısız oldu" + "İndiriliyor" + "(düzenlendi)" + "Düzenleme" + "Açıklamayı düzenleme" + "* %1$s %2$s" + "Boş dosya" + "Şifreleme" + "Şifreleme etkin" + "PIN\'inizi girin" + "Hata" + "Bir hata oluştu, yeni mesajlar için bildirim alamayabilirsiniz. Lütfen ayarlardan bildirimlerle ilgili sorunları giderin. + +Neden: %1$s." + "Herkes" + "Başarısız" + "Favori" + "Favorilere eklendi" + "Dosya" + "Dosya silindi" + "Dosya kaydedildi" + "Dosya İndirilenler\'e kaydedildi" + "Mesajı ilet" + "Sık kullanılanlar" + "GIF" + "Resim" + "Cevap olarak %1$s" + "APK\'yı yükleyin" + "Bu Matrix Kimliği bulunamıyor, bu nedenle davet alınmayabilir." + "Odadan ayrılma" + "Aydınlık" + "Metin panoya kopyalandı" + "Bağlantı panoya kopyalandı" + "Yükleniyor…" + "Daha fazla yükleniyor…" + + "%d diğer" + "%d diğer" + + + "%1$d üye" + "%1$d üyeleri" + + "Mesaj" + "Mesaj eylemleri" + "Mesaj düzeni" + "Mesaj kaldırıldı" + "Modern" + "Sessiz" + "%1$s (%2$s)" + "Sonuç yok" + "Oda adı yok" + "Şifrelenmemiş" + "Çevrimdışı" + "Açık kaynak lisansları" + "veya" + "Şifre" + "Kişiler" + "Kalıcı bağlantı" + "İzin" + "Sabitlendi" + "Lütfen bekleyin…" + "Bu anketi sonlandırmak istediğinizden emin misiniz?" + "Anket: %1$s" + "Toplam oy: %1$s" + "Sonuçlar anket sona erdikten sonra gösterilecektir" + + "%d oy" + "%d oy" + + "Gizlilik Politikası" + "Özel oda" + "Herkese açık oda" + "Tepki" + "Tepkiler" + "Kurtarma anahtarı" + "Yenileniyor…" + "Cevaplamak için %1$s" + "Hata bildir" + "Sorun bildir" + "Rapor gönderildi" + "Zengin metin editörü" + "Oda" + "Oda adı" + "örn. proje adınız" + "Kaydedilen değişiklikler" + "Kaydediliyor" + "Ekran kilidi" + "Birini arayın" + "Arama sonuçları" + "Güvenlik" + "Tarafından görüldü" + "Şuraya gönder" + "Gönderiliyor…" + "Gönderme başarısız oldu" + "Gönderildi" + "Sunucu desteklenmiyor" + "Sunucu URL\'si" + "Ayarlar" + "Paylaşılan konum" + "Oturumu kapatma" + "Bir şeyler ters gitti" + "Sohbet başlatılıyor…" + "Çıkartma" + "Başarılı" + "Öneriler" + "Senkronizasyon" + "Sistem" + "Metin" + "Üçüncü taraf bildirimleri" + "Konu" + "Konu" + "Bu oda ne hakkında?" + "Şifre çözülemiyor" + "Güvenli olmayan bir cihazdan gönderildi" + "Bu mesaja erişiminiz yok" + "Gönderenin doğrulanmış kimliği değişti" + "Davetler bir veya daha fazla kullanıcıya gönderilemedi." + "Davetiye(ler) gönderilemedi" + "Kilidi aç" + "Sesi aç" + "Desteklenmeyen çağrı" + "Desteklenmeyen etkinlik" + "Kullanıcı adı" + "Doğrulama iptal edildi" + "Doğrulama tamamlandı" + "Doğrulama başarısız" + "Doğrulandı" + "Cihazı doğrula" + "Kimliği doğrula" + "Kullanıcıyı Doğrula" + "Video" + "Sesli Mesaj" + "Bekleniyor…" + "Bu mesajı bekliyorum" + "Sen" + "%1$s kişinin kimliği değişti. %2$s" + "%1$s\'ın %2$s kimliği değişti. %3$s" + "(%1$s)" + "%1$s kullanıcısının doğrulanmış kimliği değişti." + "%1$s kullanıcısının %2$s doğrulanmış kimliği değişti. %3$s" + "Doğrulamayı iptal et" + "%1$s bağlantısı seni başka bir siteye yönlendiriyor %2$s + +Devam etmek istediğinizden emin misiniz?" + "Bu bağlantıyı tekrardan kontrol edin" + "Onaylama" + "Hata" + "Başarılı" + "Uyarı" + "Değişiklikleriniz kaydedilmedi. Geri dönmek istediğinden emin misin?" + "Değişiklikleri Kaydet?" + "Ana sunucunuzun Matrix Authentication Service ve hesap oluşturmayı destekleyecek şekilde güncellenmesi gerekiyor." + "Kalıcı bağlantı oluşturulamadı" + "%1$s harita yüklenemedi. Lütfen daha sonra tekrar deneyin." + "Mesajlar yüklenemedi" + "%1$s konumunuza erişemedi. Lütfen daha sonra tekrar deneyin." + "Sesli mesajınız yüklenemedi." + "Mesaj bulunamadı" + "%1$s konumunuza erişim iznine sahip değil. Erişimi Ayarlar\'dan etkinleştirebilirsiniz." + "%1$s konumunuza erişim iznine sahip değil. Aşağıdan erişimi etkinleştirin." + "%1$s mikrofonunuza erişim izni yok. Sesli mesaj kaydetmek için erişimi etkinleştirin." + "Bunun nedeni ağ veya sunucu sorunları olabilir." + "Bu oda adresi zaten mevcut. Lütfen oda adres alanını düzenlemeyi deneyin veya oda adını değiştirin" + "Bazı karakterlere izin verilmez. Yalnızca harfler, rakamlar ve aşağıdaki semboller desteklenir ! $ &amp; \' ( ) * + / ; = ? @ [ ] - . _" + "Bazı mesajlar gönderilmedi" + "Üzgünüz, bir hata oluştu" + "Bu şifrelenmiş mesajın doğruluğu bu cihazda garanti edilemez." + "Daha önce doğrulanmış bir kullanıcı tarafından şifrelenmiştir." + "Şifrelenmemiş." + "Bilinmeyen veya silinmiş bir cihaz tarafından şifrelenmiştir." + "Sahibi tarafından doğrulanmamış bir cihaz tarafından şifrelenmiştir." + "Doğrulanmamış bir kullanıcı tarafından şifrelenmiştir." + "🔐️ Bana katılın %1$s" + "Hey, benimle konuş %1$s: %2$s" + "%1$s Android" + "Hata bildirmek için Rageshake" + "Medya seçilemedi, lütfen tekrar deneyin." + "Bir mesaja basın ve buraya eklemek için “%1$s” yi seçin." + "Önemli mesajları kolayca keşfedilebilmeleri için sabitleyin" + + "%1$d Sabitlenmiş mesaj" + "%1$d Sabitlenmiş mesajlar" + + "Sabitlenmiş mesajlar" + "Kimliğinizi sıfırlamak için %1$s hesabınıza gitmek üzeresiniz. Daha sonra uygulamaya geri döneceksiniz." + "Onaylayamıyor musunuz? Kimliğinizi sıfırlamak için hesabınıza gidin." + "Doğrulamayı geri çek ve gönder" + "Doğrulamanızı geri çekebilir ve yine de bu mesajı gönderebilir veya şimdilik iptal edebilir ve %1$s yeniden doğruladıktan sonra daha sonra tekrar deneyebilirsiniz." + "%1$s kullanıcısının doğrulanmış kimliği değiştiği için mesajınız gönderilmedi" + "Yine de mesaj gönder" + "%1$s bir veya daha fazla doğrulanmamış cihaz kullanıyor. Mesajı yine de gönderebilir veya şimdilik iptal edebilir ve %2$s tüm cihazlarını doğruladıktan sonra tekrar deneyebilirsiniz." + "%1$s tüm cihazları doğrulamadığı için mesajınız gönderilmedi" + "Bir veya daha fazla cihazınız doğrulanmamış. Mesajı yine de gönderebilir veya şimdilik iptal edip tüm cihazlarınızı doğruladıktan sonra tekrar deneyebilirsiniz." + "Bir veya daha fazla cihazınızı doğrulamadığınız için mesajınız gönderilmedi" + "Medya yüklenemedi, lütfen tekrar deneyin." + "Kullanıcı ayrıntıları alınamadı" + "%1$s / %2$s" + "%1$s Sabitlenmiş mesajlar" + "Mesaj yükleniyor…" + "Tümünü görüntüle" + "Sohbet" + "Konum paylaş" + "Konumumu paylaş" + "Apple Maps\'de aç" + "Google Maps\'te aç" + "OpenStreetMap\'te aç" + "Bu konumu paylaş" + "%1$s kullanıcısının doğrulanmış kimliği değiştiği için ileti gönderilmedi." + "%1$s tüm cihazları doğrulamadığı için mesaj gönderilmedi." + "Bir veya daha fazla cihazınızı doğrulamadığınız için mesaj gönderilmedi." + "Konum" + "Sürüm: %1$s (%2$s)" + "tr" + "Geçmiş mesajlar bu cihazda kullanılamıyor" + "Geçmiş mesajlara erişim için bu cihazı doğrulamanız gerekir" + "Bu mesaja erişiminiz yok" + "Mesaj şifresi çözülemedi" + "Bu mesaj, cihazınızı doğrulamadığınız veya gönderenin kimliğinizi doğrulaması gerektiği için engellendi." + diff --git a/libraries/ui-strings/src/main/res/values-uk/translations.xml b/libraries/ui-strings/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000..a4c171a --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-uk/translations.xml @@ -0,0 +1,475 @@ + + + "Додати реакцію: %1$s" + "Аватар" + "Згорнути поле тексту повідомлення" + "Видалити" + + "Введена %1$d цифра" + "Введено %1$d цифри" + "Введено %1$d цифр" + + "Редагувати аватар" + "Повна адреса буде %1$s" + "Подробиці шифрування" + "Розгорнути текстове поле повідомлення" + "Cховати пароль" + "Приєднатися до виклику" + "Перейти вниз" + "Перемістити карту до мого місця перебування" + "Тільки згадки" + "Звук вимкнено" + "Нові згадки" + "Нові повідомлення" + "Поточний виклик" + "Аватар іншого користувача" + "Сторінка %1$d" + "Пауза" + "Голосове повідомлення, тривалість: %1$s, поточна позиція: %2$s" + "Поле PIN-коду" + "Відтворити" + "Опитування" + "Опитування завершено" + "Реагувати з%1$s" + "Відреагувати іншими смайликами" + "Прочитано %1$s та %2$s" + + "Прочитано %1$s та %2$d іншим" + "Прочитано %1$s та %2$d іншими" + "Прочитано %1$s та %2$d іншими" + + "Прочитано %1$s" + "Натисніть, щоб показати все" + "Видалити реакцію з %1$s" + "Прибрати реакцію %1$s" + "Аватар кімнати" + "Надіслати файли" + "Необхідно виконати дію, обмежену в часі, у вас є одна хвилина для верифікації" + "Показати пароль" + "Розпочати виклик" + "Кімната більше не використовується" + "Аватар користувача" + "Меню користувача" + "Переглянути аватар" + "Переглянути подробиці" + "Голосове повідомлення, тривалість: %1$s" + "Записати голосове повідомлення." + "Припинити запис" + "Ваш аватар" + "Прийняти" + "Додати підпис" + "Додати до стрічки" + "Назад" + "Зателефонувати" + "Скасувати" + "Скасувати наразі" + "Вибрати фото" + "Очистити" + "Закрити" + "Верифікація завершена" + "Підтвердити" + "Підтвердіть пароль" + "Продовжити" + "Скопіювати" + "Копіювати підпис" + "Скопіювати посилання" + "Скопіювати посилання на повідомлення" + "Скопіювати текст" + "Створити" + "Створити кімнату" + "Деактивувати" + "Деактивувати обліковий запис" + "Відхилити" + "Відхилити та заблокувати" + "Видалити опитування" + "Вимкнути" + "Відкинути" + "Відхилити" + "Готово" + "Редагувати" + "Редагувати підпис" + "Редагувати опитування" + "Увімкнути" + "Завершити опитування" + "Введіть PIN-код" + "Завершити" + "Забули пароль?" + "Переслати" + "Повернутися" + "Ігнорувати" + "Запросити" + "Запросити людей" + "Запросити людей до %1$s" + "Запросити людей в %1$s" + "Запрошення" + "Доєднатися" + "Докладніше" + "Вийти" + "Залишити розмову" + "Вийти з кімнати" + "Завантажити ще" + "Керування обліковим записом" + "Керування пристроями" + "Написати" + "Далі" + "Ні" + "Не зараз" + "Гаразд" + "Відкрити контекстне меню" + "Налаштування" + "Відкрити за допомогою" + "Закріпити" + "Швидка відповідь" + "Цитувати" + "Реакція" + "Відхилити" + "Вилучити" + "Вилучити підпис" + "Вилучити повідомлення" + "Відповісти" + "Відповісти в гілці" + "Поскаржитися" + "Повідомити про помилку" + "Повідомити про вміст" + "Поскаржитися на розмову" + "Поскаржитися на кімнату" + "Скинути" + "Скинути ідентичність" + "Спробувати ще раз" + "Повторити спробу розшифрування" + "Зберегти" + "Шукати" + "Надіслати" + "Надіслати змінене повідомлення" + "Надіслати повідомлення" + "Надіслати голосове повідомлення" + "Поділитися" + "Поділитися посиланням" + "Показати" + "Увійдіть знову" + "Вийти" + "Все одно вийти" + "Пропустити" + "Розпочати" + "Розпочати бесіду" + "Почати верифікацію" + "Натисніть, щоб завантажити мапу" + "Зробити фото" + "Торкніться, щоб переглянути параметри" + "Спробуйте ще раз" + "Відкріпити" + "Переглянути" + "Переглянути на шкалі часу" + "Переглянути джерело" + "Так" + "Так, повторити спробу" + "Ваш сервер тепер підтримує новий, швидший протокол. Вийдіть із системи та увійдіть знову, щоб оновити систему зараз. Якщо ви зробите це зараз, це допоможе вам уникнути примусового виходу з системи, коли старий протокол буде видалено пізніше." + "Доступне оновлення" + "Відомості" + "Політика прийнятного використання" + "Додати обліковий запис" + "Додати ще один обліковий запис" + "Додавання підпису" + "Додаткові налаштування" + "зображення" + "Аналітика" + "Ви вийшли з кімнати" + "Ви вийшли з сеансу" + "Тема" + "Аудіо" + "Заблоковані користувачі" + "Бульбашки" + "Виклик розпочато" + "Резервне копіювання бесіди" + "Скопійовано до буферу обміну" + "Авторське право" + "Створення кімнати…" + "Запит скасовано" + "Виходить з кімнати" + "Запрошення відхилено" + "Темна" + "Помилка розшифрування" + "Налаштування розробника" + "Ідентифікатор пристрою" + "Особиста бесіда" + "Не показувати це знову" + "Не вдалося завантажити" + "Завантаження" + "(відредаговано)" + "Редагування" + "Редагування підпису" + "* %1$s %2$s" + "Порожній файл" + "Шифрування" + "Шифрування ввімкнено" + "Введіть свій PIN-код" + "Помилка" + "Сталася помилка, ви можете не отримувати сповіщення про нові повідомлення. Усуньте неполадки зі сповіщеннями в налаштуваннях. + +Причина: %1$s." + "Усі" + "Помилка" + "Обране" + "Обране" + "Файл" + "Файл видалено" + "Файл збережено" + "Файл збережений у Завантаження" + "Переслати повідомлення" + "Частовживані" + "GIF" + "Зображення" + "У відповідь на %1$s" + "Встановити APK" + "Цей Matrix-ID не знайдено, тому запрошення може не бути отримано." + "Вихід з кімнати" + "Світла" + "Рядок скопійовано до буфера обміну" + "Посилання скопійовано в буфер обміну" + "Завантаження" + "Завантаження наступних…" + + "%d інший" + "%d інші" + "%d інші" + + + "%1$d учасник" + "%1$d учасники" + "%1$d учасників" + + "Повідомлення" + "Дії з повідомленнями" + "Макет повідомлень" + "Повідомлення вилучено" + "Модерн" + "Вимкнути звук" + "%1$s (%2$s)" + "Немає результатів" + "Немає назви кімнати" + "Не зашифровано" + "Не в мережі" + "Ліцензії відкритого коду" + "або" + "Пароль" + "Люди" + "Постійне посилання" + "Дозвіл" + "Закріплено" + "Перевірте з\'єднання з інтернетом" + "Будь ласка, зачекайте…" + "Ви впевнені, що хочете закінчити це опитування?" + "Опитування: %1$s" + "Всього голосів: %1$s" + "Результати будуть показані після завершення опитування" + + "%d голос" + "%d голоси" + "%d голосів" + + "Приготування…" + "Політика конфіденційності" + "Приватна кімната (тільки за запрошенням)" + "Приватний простір" + "Загальнодоступна кімната" + "Загальнодоступний простір" + "Реакція" + "Реакції" + "Причина" + "Ключ відновлення" + "Оновлення…" + + "%1$d відповідь" + "%1$d відповіді" + "%1$d відповідей" + + "Відповідь %1$s" + "Повідомити про ваду" + "Повідомити про проблему" + "Звіт подано" + "Багатоформатний текстовий редактор" + "Кімната" + "Назва кімнати" + "напр., назва вашого проєкту" + + "%1$d кімната" + "%1$d кімнати" + "%1$d кімнат" + + "Збережені зміни" + "Збереження" + "Блокування екрану" + "Шукати когось" + "Результати пошуку" + "Безпека" + "Переглянули" + "Вибрати обліковий запис" + "Надіслати до" + "Надсилання…" + "Не вдалося надіслати" + "Надіслано" + ". " + "Сервер не підтримується" + "URL-адреса сервера" + "Налаштування" + "Поширене розташування" + "Вихід" + "Щось пішло не так" + "Ми зіткнулися з проблемою. Будь ласка, повторіть спробу." + "Простір" + + "%1$d простір" + "%1$d простори" + "%1$d просторів" + + "Початок бесіди…" + "Наліпка" + "Успіх" + "Пропозиції" + "Синхронізація" + "Системна" + "Текст" + "Повідомлення третіх сторін" + "Гілка" + "Тема" + "Про що ця кімната?" + "Неможливо розшифрувати" + "Надіслано з незахищеного пристрою" + "Ви не маєте доступу до цього повідомлення" + "Ідентичність відправника скинуто" + "Не вдалося надіслати запрошення одному чи кільком користувачам." + "Не вдалося надіслати запрошення" + "Розблокувати" + "Увімкнути звук" + "Непідтриманий виклик" + "Непідтримувана подія" + "Ім\'я користувача" + "Верифікацію скасовано" + "Верифікацію завершено" + "Перевірка не вдалася" + "Перевірено" + "Верифікувати пристрій" + "Підтвердити особу" + "Верифікувати користувача" + "Відео" + "Висока якість" + "Найкраща якість, але більший розмір файлу" + "Низька якість" + "Найшвидша швидкість вивантаження та найменший розмір файлу" + "Стандартна якість" + "Баланс якості та швидкості вивантаження" + "Голосове повідомлення" + "Очікування…" + "Чекаємо на це повідомлення" + "Ви" + "Ідентичність %1$s скинуто. %2$s" + "Ідентичність %1$s %2$s скинуто. %3$s" + "(%1$s)" + "Ідентичність %1$s скинуто." + "Ідентичність %1$s %2$s скинуто. %3$s" + "Відкликати верифікацію" + "Посилання %1$s спрямовує вас на інший сайт %2$s + +Ви впевнені, що хочете продовжити?" + "Уважно перевірте це посилання" + "Вибір усталеної якості вивантажуваних відео." + "Якість вивантаження відео" + "Максимально дозволений розмір файлу: %1$s" + "Розмір файлу завеликий для вивантаження" + "Скаргу на кімнату надіслано" + "Поскаржитися та вийти з кімнати" + "Підтвердження" + "Помилка" + "Успіх" + "Попередження" + "Внесені зміни не збережено. Ви впевнені, що хочете повернутися?" + "Зберегти зміни?" + "Максимально дозволений розмір файлу: %1$s" + "Виберіть якість відео, яке ви хочете вивантажити." + "Виберіть якість вивантажуваного відео" + "Пошук емодзі" + "Ви вже ввійшли на цьому пристрої як %1$s." + "Ваш домашній сервер потрібно оновити, щоб він підтримував службу автентифікації Matrix і створення облікових записів." + "Не вдалося створити постійне посилання" + "%1$s не може завантажити мапу. Повторіть спробу пізніше." + "Не вдалося завантажити повідомлення" + "%1$s не вдалося отримати доступ до вашого розташування. Повторіть спробу пізніше." + "Не вдалося завантажити голосове повідомлення." + "Кімната більше не існує або запрошення не чинне." + "Повідомлення не знайдено" + "%1$s не має дозволу на доступ до вашого розташування. Увімкнути доступ можна в Налаштуваннях." + "%1$s не має дозволу на доступ до вашого розташування. Увімкніть доступ нижче." + "%1$s не має доступу до вашого мікрофона. Надайте доступ, щоб записати голосове повідомлення." + "Це може бути пов\'язано з проблемами мережі або сервера." + "Ця адреса кімнати вже існує, будь ласка, спробуйте відредагувати поле адреси кімнати або змінити назву кімнати" + "Деякі символи не допускаються. Підтримуються тільки букви, цифри і наступні символи! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" + "Деякі повідомлення не були надіслані" + "Вибачте, сталася помилка" + "Відправник події не збігається з власником пристрою, який його надіслав." + "Автентичність цього зашифрованого повідомлення не може бути гарантована на цьому пристрої." + "Зашифровано попередньо перевіреним користувачем." + "Не зашифровано." + "Зашифровано невідомим або видаленим пристроєм." + "Зашифровано пристроєм, який не верифіковано його власником." + "Зашифровано неверифікованим користувачем." + "🔐️ Приєднуйтеся до мене в %1$s" + "Вітаю, поспілкуйтеся зі мною в %1$s: %2$s" + "%1$s Android" + "Повідомити про ваду за допомогою Rageshake" + "Знімок екрана" + "%1$s: %2$s" + "Варіанти" + "Вилучити %1$s" + "Налаштування" + "Не вдалося вибрати медіафайл, спробуйте ще раз." + "Натисніть на повідомлення і виберіть \"%1$s\", щоб додати його сюди." + "Закріпіть важливі повідомлення, щоб їх можна було легко знайти" + + "%1$d Закріплене повідомлення" + "%1$d Закріплених повідомлення" + "%1$d Закріплених повідомлення" + + "Закріплені повідомлення" + "Ви збираєтеся перейти до свого облікового запису %1$s, щоб скинути свій обліковий запис. Після цього ви повернетесь до програми." + "Не можете підтвердити? Перейдіть до свого облікового запису, щоб скинути облікові дані." + "Відкликати верифікацію та відправити" + "Ви все одно можете відкликати підтвердження та надіслати це повідомлення, або ви можете скасувати підписку на даний момент і спробувати пізніше після повторної перевірки %1$s." + "Ваше повідомлення не надіслано, оскільки підтверджену особистість %1$s скинуто" + "Надіслати повідомлення в будь-якому випадку" + "%1$s використовує один або кілька неперевірених пристроїв. Ви можете відправити повідомлення в будь-якому випадку, або ж скасувати відправку і спробувати пізніше, коли %2$s перевірить всі пристрої." + "Ваше повідомлення не було надіслано, тому що %1$s не перевірив усі пристрої" + "Один або кілька ваших пристроїв не підтверджено. Ви можете відправити повідомлення в будь-якому випадку, або ж скасувати відправку і спробувати пізніше, коли перевірите всі свої пристрої." + "Ваше повідомлення не було надіслано, оскільки ви не підтвердили один або декілька своїх пристроїв" + "Змінити адміністраторів або власників" + "Не вдалося обробити медіафайл для завантаження, спробуйте ще раз." + "Не вдалося отримати дані користувача" + "Повідомлення в %1$s" + "Розгорнути" + "Згорнути" + "Уже переглядаєте цю кімнату!" + "%1$s із %2$s" + "%1$s закріплених повідомлень" + "Завантаження повідомлення…" + "Переглянути всі" + "Бесіда" + "Поділитися розташуванням" + "Поділитися моїм розташуванням" + "Відкрити в Apple Maps" + "Відкрити в Картах Google" + "Відкрити в OpenStreetMap" + "Поділитися цим місцем перебування" + "Простори, які ви створили або до яких приєдналися." + "%1$s • %2$s" + "Простори" + "Повідомлення не надіслано, оскільки підтверджену особистість %1$s скинуто." + "Повідомлення не надіслано, оскільки %1$s перевірив не всі пристрої." + "Повідомлення не надіслано, оскільки ви не підтвердили один або кілька своїх пристроїв." + "Розташування" + "Версія: %1$s (%2$s)" + "uk" + "Історичні повідомлення недоступні на цьому пристрої" + "Щоб отримати доступ до історичних повідомлень, потрібно верифікувати цей пристрій" + "Ви не маєте доступу до цього повідомлення" + "Неможливо розшифрувати повідомлення" + "Це повідомлення заблоковано оскільки ви не верифікували свій пристрій, або тому, що відправнику потрібно верифікувати вашу особистість." + diff --git a/libraries/ui-strings/src/main/res/values-ur/translations.xml b/libraries/ui-strings/src/main/res/values-ur/translations.xml new file mode 100644 index 0000000..5abd2e0 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-ur/translations.xml @@ -0,0 +1,330 @@ + + + "ایک رد عمل کا اضافہ کریں: %1$s" + "اوتار" + "حذف کریں" + + "%1$d ہندسہ درج" + "%1$d ہندسے درج" + + "اووتار میں ترمیم کریں" + "مکمل پتہ ہو گا %1$s" + "لفظ عبور چھپائیں" + "نیچے چھلانگ لگائیں" + "صرف تذکرے" + "خاموش" + "صفحہ %1$d" + "متوقف" + "PIN میدان" + "چلائیں" + "رائے شماری" + "رائے شماری ختم کردی" + "%1$s کے ساتھ رد عمل کریں" + "دیگر رموز تعبیری سے رد عمل کریں" + "%1$s اور %2$s نے پڑھا" + + "%1$s اور %2$d دیگر نے پڑھا" + "%1$s اور %2$d دیگر نے لڑھا" + + "%1$s نے پڑھا" + "سب دکھانے کے لیے تھپتھپائیں" + "%1$s کے ساتھ رد عمل ہٹائیں" + "مسلیں بھیجیں" + "لفظ عبور دکھائیں" + "مکالمہ شروع کریں" + "لائحۂ صارف" + "صوتی پیغام ثبت کریں۔" + "ثبت کاری بند کریں" + "قبول کریں" + "جدول زمانی میں شامل کریں" + "واپس" + "مکالمہ کریں" + "منسوخ کریں" + "ابھی کے لیے منسوخ کریں" + "تصویر چنیں" + "صاف کریں" + "بند کریں" + "توثیق مکمل کریں" + "تصدیق کریں" + "پاس ورڈ کی تصدیق" + "جاری رکھیں" + "نقل" + "ربط نقل کریں" + "پیغام کا ربط نقل کریں" + "تخلیق کریں" + "ایک کمرہ بنائیں" + "غیر فعال کریں" + "اکاؤنٹ کو غیر فعال کریں" + "مسترد کریں" + "رائے شماری حذف کریں" + "غیر فعال کریں" + "رد کریں" + "ہوگیا" + "تدوین کریں" + "رائے شماری میں ترمیم کریں" + "فعال کریں" + "رائے شماری ختم کریں" + "‏PIN درج کریں" + "لفظِ عبور بھول گئے؟" + "آگے کریں" + "واپس جائیں" + "مدعو کریں" + "لوگوں کو مدعو کریں" + "لوگوں کو %1$s پر مدعو کریں" + "لوگوں کو %1$s پر مدعو کریں" + "دعوت نامے" + "شامل ہوں" + "مزید جانئے" + "چھوڑ دیں" + "گفتگو چھوڑیں" + "کمرہ چھوڑ دیں" + "مزید لادیں" + "کھاتہ کا انتظام کریں" + "آلات کا نظم کریں" + "ارسال" + "اگلا" + "نہیں" + "ابھی نہیں" + "ٹھیک" + "ترتیبات" + "کھولیں مع" + "تثبیت کریں" + "فوری جواب" + "حوالہ دیں" + "رد عمل" + "مسترد کریں" + "ہٹائیں" + "جواب دیں" + "دھاگے میں جواب دیں" + "خطاء کی اطلاع دیں" + "مواد کی اطلاع دیں" + "بحال کریں" + "شناخت بحال کر دیں" + "پھر کوشش کریں" + "رمزکشائی کی پھر کوشش کریں" + "محفوظ کریں" + "تلاش کریں" + "بھیجیں" + "پیغام بھیجیں" + "اشتراک کریں" + "ربط کا اشتراک کریں" + "دوبارہ داخل ہوں" + "خارج ہوں" + "بہرحال خارج ہوں" + "چھوڑیں" + "شروع کریں" + "گفتگو شروع کریں" + "توثیق شروع کریں" + "نقشہ لادنے کیلئے تھپتھپائیں" + "تصویر لیں" + "اختیارات کے لیے تھپتھپائیں" + "دوبارہ کوشش کریں" + "غیر تثبیت کریں" + "ٹائم لائن میں دیکھیں" + "ماخذ دیکھیں" + "ہاں" + "آپ کا سرور اب ایک نئے، تیز تر پروٹوکول کو سپورٹ کرتا ہے۔ لاگ آؤٹ کریں اور ابھی اپ گریڈ کے لیے دوبارہ لاگ ان کریں۔ ابھی ایسا کرنے سے آپ کو زبردستی لاگ آؤٹ سے بچنے میں مدد ملے گی جب پرانا پروٹوکول بعد میں ہٹا دیا جائے گا۔" + "اپ گریڈ دستیاب ہے۔" + "بمتعلق" + "قابل قبول استعمال کی سیاست" + "اعلیٰ ترتیبات" + "تجزیات" + "ظہور" + "صوت" + "مسدود صارفین" + "بلبلے" + "مکالمہ شروع" + "گفتگو کا پشتارہ" + "کلپ بورڈ میں کاپى کیا گیا" + "حقوقِ طبع و نشر" + "کمرہ تخلیق کررہاہے…" + "کمرہ چھوڑ لیا" + "اندھیرا" + "رمزکشائی کی خرابی" + "مطور اختیارات" + "براہ راست گفتگو" + "اسے دوبارہ نہ دکھائیں" + "(تدوین شدہ)" + "تدوین" + "* %1$s %2$s" + "مرموزکاری فعال ہے" + "اپنا PIN درج کریں" + "خرابی" + "کوئی نقص واقع ہوا ہے، ہوسکتا ہے کہ آپ کو نئے پیغامات کی اطلاعات موصول نہ ہوں۔ براہ کرم ترتیبات سے اطلاعات کا ازالہ کریں۔ + +وجہ: %1$s۔" + "ہر کوئی" + "ناکام" + "پسندیدہ" + "پسندیدہ" + "مسل" + "مسل تنزیلات میں محفوظ کر دی" + "پیغام آگے بھیجیں" + "GIF" + "تصویر" + "%1$s کے جواب میں" + "APK تنصیب کریں" + "یہ میٹرکس شناخت نہیں مل سکتی، تو ہو سکتا ہے کہ دعوت نامہ موصول نہ ہو۔" + "کمرہ چھوڑنا" + "روشنی" + "ربط تختہ تراشہ پر نقل کردا گیا" + "لاد رہا ہے…" + + "%d دیگر" + "%d دیگر" + + + "%1$d رکن" + "%1$d اراکین" + + "پیغام" + "پیغام کے اعمال" + "ترتیب پیغام" + "پیغام ہٹا دیا گیا" + "جدید" + "خاموش کریں" + "کوئی نتائج نہیں" + "کمرے کا کوئی نام نہیں" + "پرے خط" + "کھلا ماخذ رخصات" + "یا" + "لفظ عبور" + "لوگ" + "مستقل ربط" + "اجازت" + "پن کردہ" + "براہ کرم انتظار کریں…" + "کیا آپ واقعی اس رائے شماری کو ختم کرنا چاہتے ہیں؟" + "رائے شماری: %1$s" + "ک آراء: %1$s" + "رائے شماری ختم ہونے کے بعد نتائج ظاہر ہوں گے" + + "%d رائے" + "%d آراء" + + "سیاستِ نجیت" + "نجی کمرہ" + "عوامی کمرہ" + "ردعمل" + "ردود عمل" + "بازیابی کی کلید" + "تاکہ کر رہا ہے…" + "%1$s کا جواب دے رہے ہیں" + "ایک خطاء کی اطلاع دیں" + "کسی مسئلے کی اطلاع دیں" + "گزارش جمع ہوگئی" + "امیر مدیرِ متن" + "کمرہ" + "کمرے کا نام" + "e.g. your project name" + "محفوظ شدہ تبدیلیاں" + "محفوظ کر رہا ہے" + "صفحۂ نمائش قفل" + "کسی کیلئے تلاش کریں" + "تلاش کے نتائج" + "حفاظت" + "دیکھا گیا بہ" + "بھیجیں تا" + "بھیج رہا ہے…" + "بھیجنا ناکام" + "ارسال کردہ" + "خادم تعاون یافتہ نہیں" + "خادم عنوان" + "ترتیبات" + "مشترکہ مقام" + "خارج ہو رہا ہے" + "کچھ غلط ہو گیا" + "گفتگو شروع کر رہا ہے۔۔۔" + "برچسب" + "کامیابی" + "تجاویز" + "ہمسات سازی" + "نظام" + "متن" + "فریق ثالث کے اشعارات" + "دھاگہ" + "موضوع" + "یہ کمرہ کس کے بارے میں ہے؟" + "رمزکشائی کرنے سے قاصر" + "آپ کو اس پیغام تک رسائی حاصل نہیں" + "دعوت نامے ایک یا زیادہ صارفین کو نہیں بھیجے جا سکے۔" + "دعوت نامہ بھیجنے سے قاصر" + "غیر مقفل کریں" + "غیر خاموش کریں" + "غیر تعاون یافتہ واقعہ" + "صارف نام" + "توثیق منسوخ کردہ" + "توثیق مکمل" + "آلہ کی توثیق کریں" + "ویڈیو" + "صوتی پیغام" + "منتظر…" + "اس پیغام کا منتظر" + "آپ" + "تصدیق" + "خرابی" + "کامیابی" + "انتباہ" + "آپ کی تبدیلیاں محفوظ نہیں کی گئیں۔ کیا آپ کو یقین ہے کہ آپ واپس جانا چاہتے ہیں؟" + "تبدیلیاں محفوظ کریں؟" + "‏Matrix Authentication Service اور اکاؤنٹ بنانے میں معاونت کے لیے آپ کے ہوم سرور کو اپ گریڈ کرنے کی ضرورت ہے۔" + "مستقل ربط تخلیق کرنا ناکام" + "%1$s نقشہ لاد نہیں سکا۔ برائے مہربانی بعد میں دوبارہ کوشش کریں۔" + "پیغامات لادنا ناکام" + "%1$s آپ کے مقام تک رسائی حاصل نہیں کر سکا۔ برائے مہربانی بعد میں دوبارہ کوشش کریں۔" + "آپکا صوتی پیغام رفع کرنے میں ناکام۔" + "پیغام نہیں ملا" + "%1$sآپ کے مقام تک رسائی کی اجازت نہیں ہے۔ آپ ترتیبات میں رسائی کو فعال کر سکتے ہیں۔" + "%1$s کو آپ کے مقام تک رسائی حاصل کرنے کی اجازت نہیں ہے۔ نیچے رسائی کو فعال کریں۔" + "%1$s کو آپ کے صوتگر تک رسائی حاصل کرنے کی اجازت نہیں ہے۔ صوتی پیغام ثبت کرنے کیلئے رسائی فعال کریں۔" + "کچھ پیغامات ارسال نہیں ہوئے" + "معذرت، ایک خرابی واقع ہوگئی" + "اس آلہ پر اس مرموز کردہ پیغام کی صداقت کی ضمانت نہیں دی جا سکتی۔" + "پہلے سے تصدیق شدہ صارف کے ذریعہ خفیہ کردہ۔" + "مرموز کردہ نہیں۔" + "کسی نامعلوم یا حذف شدہ آلے کے ذریعے مرموز کردہ۔" + "کسی ایسے آلے کے ذریعے مرموز کردہ جس کی توثیق اسکے مالک سے نہیں ہوئی۔" + "ایک غیر توثیق شدہ صارف کے ذریعے مرموز کردہ۔" + "🔐️ %1$s پر میرے ساتھ شامل ہوں" + "ارے، مجھ سے %1$s پر بات کریں: %2$s" + "%1$s Android" + "خطاء کی اطلاع دینے کیلئے غصے سے ہلائیں" + "وسائط منتخب کرنا ناکام، برائے مہربانی دوبارہ کوشش کریں۔" + "ایک پیغام پر دبائیں اور یہاں شامل کرنے کے لیے %1$s منتخب کریں۔" + "اہم پیغامات کو پن کریں تاکہ انہیں آسانی سے دریافت کیا جا سکے۔" + + "%1$d مثبوتہ پیغام" + "%1$d مثبوتہ پیغامات" + + "مثبوتہ پیغامات" + "آپ اپنی شناخت کو دوبارہ ترتیب دینے کے لیے اپنی %1$s اکاؤنٹ میں جانے والے ہیں۔ اس عمل کے بعد آپ کو ایپ پر واپس لے جایا جائے گا۔" + "تصدیق نہیں کر سکتے؟ اپنی شناخت کو دوبارہ ترتیب دینے کے لیے اپنے اکاؤنٹ پر جائیں۔" + "تصدیق واپس لیں اور پیغام بھیجیں" + "آپ اپنی ویریفیکیشن ختم کر سکتے ہیں اور یہ پیغام بہرحال بھیج سکتے ہیں، یا آپ ابھی کے لیے منسوخ کر سکتے ہیں اور %1$s کے دوبارہ ویریفیکیشن کے بعد پھر سے کوشش کر سکتے ہیں." + "آپ کا پیغام نہیں بھیجا گیا کیوں کہ %1$s کی تصدیق شدہ شناخت کو دوبارہ ترتیب دے دیا گیا ہے" + "بہرحال پیغام بھیجیں" + "%1$s ایک یا زائد غیر تصدیق شدہ آلات استعمال کر رہے ہیں۔ آپ پھر بھی پیغام بھیج سکتے ہیں، یا آپ ابھی کے لیے منسوخ کر سکتے ہیں اور بعد میں دوبارہ کوشش کر سکتے ہیں، جب %2$s اپنے تمام آلات کی تصدیق کر چکے ہوں۔" + "آپ کا پیغام نہیں بھیجا گیا کیونکہ%1$s نے تمام آلات کی تصدیق نہیں کی ہے" + "آپ کے ایک یا زائد آلات غیر تصدیق شدہ ہیں۔ آپ بہر حال پیغام بھیج سکتے ہیں، یا آپ ابھی کے لیے منسوخ کر سکتے ہیں اور اپنے تمام آلات کی تصدیق کرنے کے بعد دوبارہ کوشش کر سکتے ہیں۔" + "آپ کا پیغام نہیں بھیجا گیا کیوں کہ آپ نے اپنے ایک یا زیادہ آلات کی تصدیق نہیں کی ہے۔" + "وسائط کا معالجہ برائے ترفیع ناکام، برائے مہربانی دوبارہ کوشش کریں۔" + "صارف کی تفصیلات بازیافت نہیں ہوسکیں" + "%1$s از %2$s" + "%1$s مثبتہ پیغامات" + "پیغام لاد رہا ہے…" + "تمام ملاحظہ کریں" + "گفتگو" + "مقام کا اشتراک کریں" + "میرے مقام کا اشتراک کریں" + "ایپل میپس میں کھولیں" + "گوگل میپس میں کھولیں۔ل" + "اوپن اسٹریٹ میپ میں کھولیں" + "اس مقام کا اشتراک کریں" + "پیغام نہیں بھیجا گیا کیونکہ %1$s کی تصدیق شدہ شناخت کو دوبارہ ترتیب دے دیا گیا ہے۔" + "پیغام نہیں بھیجا گیا کیونکہ%1$s نے تمام آلات کی تصدیق نہیں کی ہے۔" + "پیغام نہیں بھیجا گیا کیونکہ آپ نے اپنے ایک یا زیادہ آلات کی تصدیق نہیں کی ہے۔" + "مقام" + "نسخہ %1$s (%2$s)" + "ur" + "آپ کو اس پیغام تک رسائی حاصل نہیں" + diff --git a/libraries/ui-strings/src/main/res/values-uz/translations.xml b/libraries/ui-strings/src/main/res/values-uz/translations.xml new file mode 100644 index 0000000..8f23ee0 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-uz/translations.xml @@ -0,0 +1,468 @@ + + + "Reaksiya qoʻyish: %1$s" + "Avatar" + "Xabar matni maydonini kichraytirish" + "Oʻchirish" + + "%1$d ta raqam kiritildi" + "%1$d ta raqam kiritildi" + + "Avatarni tahrirlash" + "To\'liq manzil %1$s bo\'ladi" + "Shifrlash tafsilotlari" + "Xabar matni maydonini kengaytirish" + "Parolni yashirish" + "Qoʻngʻiroqga qoʻshilish" + "Pastga o\'tish" + "Xaritani mening joylashuvimga o\'tkazish" + "Faqat eslatmalar" + "Ovozsiz" + "Yangi eslatmalar" + "Yangi xabarlar" + "Davom etayotgan qo\'ng\'iroq" + "Boshqa foydalanuvchining avatari" + "Sahifa %1$d" + "Pauza" + "Ovoz xabar, davomiyligi: %1$s, joriy holati: %2$s" + "PIN-kod maydoni" + "O\'ynang" + "So\'ro\'vnoma" + "So‘rovnoma yakunlandi" + "%1$s bilan munosabat bildiring" + "Boshqa hisbelgilar bilan munosabat bildiring" + "%1$s va %2$s bilan oʻqish" + + "%1$s va %2$d boshqa kishilar tomonidan oʻqildi" + "%1$s va %2$d boshqa kishilar tomonidan oʻqildi" + + "Muallif: %1$s bilan oʻqish" + "Hammasini ko\'rsatish uchun bosing" + "Reaktsiyani olib tashlang: %1$s" + "%1$s bilan reaktsiyani olib tashlang" + "Xona avatari" + "Fayllarni yuborish" + "Amal bajarish vaqti cheklangan, tasdiqlash uchun bir daqiqa vaqtingiz bor" + "Parolni ko\'rsatish" + "Qoʻngʻiroqni boshlash" + "Arxivlangan xona" + "Foydalanuvchi avatari" + "Foydalanuvchi menyusi" + "Avatarni koʻrish" + "Tafsilotlarni ko\'rish" + "Ovozli xabar, davomiyligi: %1$s" + "Ovoz yozishni amalga oshiring" + "Yozishni to\'xtatish" + "Sizning avataringiz" + "Qabul qiling" + "Sarlavha qo\'shing" + "Vaqt jadvaliga qo\'shing" + "Orqaga" + "Qoʻngʻiroq" + "Bekor qilish" + "Hozircha bekor qilish" + "Fotosuratni tanlang" + "Tozalash" + "Yopish" + "To\'liq tekshirish" + "Tasdiqlash" + "Parolni tasdiqlang" + "Davom etish" + "Nusxa" + "Sarlavhani nusxalash" + "Havolani nusxalash" + "Havolani xabaraga nusxalash" + "Matnni nusxalash" + "Yaratmoq" + "Xonani yaratish" + "Faolsizlantirish" + "Hisobni faolsizlantirish" + "Rad etish" + "Rad etish va bloklash" + "So‘rovnomani o‘chirish" + "Oʻchirish" + "Bekor qilish" + "Bekor qilish" + "Bajarildi" + "Tahrirlash" + "Sarlavhani tahrirlash" + "So‘rovnomani tahrirlash" + "Yoqish" + "So‘rovnomani tugatish" + "PIN kodni kiriting" + "Tugatish" + "Parolni unutdingizmi?" + "Oldinga" + "Ortga qaytish" + "E’tiborsiz qoldirish" + "Taklif qilish" + "Odamlarni taklif qiling" + "Odamlarni taklif qilish%1$s" + "Odamlarni taklif qiling%1$s" + "Takliflar" + "Qo\'shilish" + "Batafsil malumot" + "Tark etish" + "Suhbatni tark etish" + "Xonani tark etish" + "Ko\'proq yuklash" + "Hisobni boshqarish" + "Qurilmalarni boshqarish" + "Xabar" + "Keyingisi" + "Yo\'q" + "Hozir emas" + "Ok" + "Kontekst menyusini oching" + "Sozlamalar" + "Bilan oching" + "Qadash" + "Tez javob" + "Iqtibos" + "Reaksiya qilish" + "Rad etish" + "Olib tashlash" + "Sarlavhani olib tashlash" + "Xabarni olib tashlash" + "Javob berish" + "Mavzuda javob berish" + "Shikoyat qilish" + "Xato haqida xabar berish" + "Tarkib haqida xabar berish" + "Suhbat haqida shikoyat bering" + "Xona ustidan shikoyat qilish" + "Boshlangʻich holatiga qaytarish" + "Shaxsiyatni tiklash" + "Qayta urinish" + "Shifrni ochishni qayta urinish" + "Saqlash" + "Qidirmoq" + "Yuborish" + "Tahrirlangan xabarni yuboring" + "Xabar yuborish" + "Ovozli xabar yuborish" + "Ulashish" + "Havolani ulashing" + "Koʻrsatish" + "Qaytadan kiring" + "Tizimdan chiqish" + "Baribir tizimdan chiqing" + "Oʻtkazib yuborish" + "Boshlash" + "Suhbatni boshlash" + "Tasdiqlashni boshlang" + "Xaritani yuklash uchun bosing" + "Rasmga olmoq" + "Variantlar uchun bosing" + "Qayta urinib ko\'ring" + "Olib tashlash" + "Ko\'rish" + "Vaqt jadvalida koʻrish" + "Manbani korish" + "Ha" + "Ha, qayta urinish" + "Sizning serveringiz endi yangi, tezroq protokolni qoʻllab-quvvatlaydi. Hozir yangilash uchun tizimdan chiqing va qayta kiring. Buni hozir qilish eski protokol bilan kirib, keyin olib tashlanganda majburiy chiqib-kirishni oldini olishga yordam beradi." + "Yangilash mavjud" + "Haqida" + "Qabul qilinadigan foydalanish siyosati" + "Hisob qo‘shish" + "Boshqa hisob qo‘shish" + "Sarlavha qoʻshish" + "Kengaytirilgan sozlamalar" + "rasm" + "Analitika" + "Siz xonani tark etdingiz" + "Siz sessiyadan chiqdingiz" + "Ko\'rinish" + "Audio" + "Bloklangan foydalanuvchilar" + "Pufakchalar" + "Qoʻngʻiroq boshlandi" + "Chatning zaxira nusxasi" + "Buferga nusxa koʻchirildi" + "Mualliflik huquqi" + "Xona yaratilmoqda…" + "So\'rov bekor qilindi" + "Xonani tark etdi" + "Taklif rad etildi" + "Tungi" + "Shifrni ochish xatosi" + "Tavsif" + "Dasturchi variantlari" + "Qurilma ID" + "Shaxsiy suhbat" + "Bu boshqa ko\'rsatilmasin" + "Yuklab olish amalga oshmadi" + "Yuklab olinmoqda" + "(tahrirlangan)" + "Tahrirlash" + "Sarlavhani tahrirlash" + "*%1$s%2$s" + "Bo\'sh fayl" + "Shifrlash" + "Shifrlash yoqilgan" + "PIN kodini kiriting" + "Xato" + "Xato yuz berdi, siz yangi xabarlar uchun bildirishnomalarni olmasligingiz mumkin. Iltimos, sozlamalardan bildirishnomalarni bartaraf eting. + +Sababi:%1$s." + "Har kim" + "Xatolikka uchradi" + "Sevimli" + "Sevimli" + "Fayl" + "Fayl o\'chirildi" + "Fayl saqlandi" + "Fayl “Yuklashlar”ga saqlandi" + "Xabarni yo\'naltirish" + "Tez-tez ishlatiladigan" + "GIF" + "Surat" + "%1$sga Javob bering" + "APK-ni o\'rnating" + "Ushbu Matrix identifikatori topilmadi, shuning uchun taklif qabul qilinmasligi mumkin." + "Xonadan chiqish" + "Nur" + "Satr vaqtinchalik xotiraga nusxalandi" + "Havola vaqtinchalik xotiraga nusxalandi" + "Yuklanmoqda…" + "Batafsil yuklanmoqda…" + + "%d boshqalar" + "%d boshqalar" + + + "%1$d a\'zo" + "%1$d ishtirokchilar" + + "Xabar" + "Xabar harakatlari" + "Xabar tartibi" + "Xabar ochirib tashlandi" + "Zamonaviy" + "Ovozsiz qilish" + "%1$s(%2$s )" + "Natijalar yoʻq" + "Xona nomi yoʻq" + "Shifrlanmagan" + "Oflayn" + "Ochiq kodli litsenziyalar" + "yoki" + "Parol" + "Odamlar" + "Doimiy havola" + "Ruxsat" + "Qadalgan" + "Internet ulanishingizni tekshiring" + "Iltimos kuting…" + "Haqiqatan ham bu soʻrovnomani tugatmoqchimisiz?" + "So‘rov:%1$s" + "Jami ovozlar:%1$s" + "Natijalar soʻrovnoma tugagandan soʻng koʻrsatiladi" + + "%dovoz berish" + "%dovozlar" + + "Tayyorlanmoqda…" + "Maxfiylik siyosati" + "Shaxsiy xona" + "Shaxsiy guruh" + "Jamoat xonasi" + "Jamoat guruhi" + "Reaktsiya" + "reaksiyalar" + "Sabab" + "Qayta tiklash kaliti" + "Yangilanmoqda…" + + "%1$d ta javob" + "%1$d ta javob" + + "%1$sga Javob berilmoqda" + "Xato haqida xabar bering" + "Muammo haqida xabar bering" + "Hisobot topshirildi" + "Boy matn muharriri" + "Xona" + "Xona nomi" + "masalan, loyihangiz nomi" + + "%1$d Xona" + "%1$d Xonalar" + + "Saqlangan oʻzgarishlar" + "Saqlash" + "Ekran qulfi" + "Kimnidir qidiring" + "Qidiruv natijalari" + "Xavfsizlik" + "Tomonidan koʻrilgan" + "Hisobni tanlang" + "Yubirish" + "Yuborilmoqda…" + "Yuborilmadi" + "Yuborilgan" + ". " + "Server qo\'llab-quvvatlanmaydi" + "Serverga kirish imkonsiz" + "Server URL manzili" + "Sozlamalar" + "Joylashuvi ulashildi" + "Chiqish" + "Nimadir xato ketdi" + "Muammoga duch keldik. Iltimos, qayta urinib koʻring." + "Bo‘shliq" + + "%1$d Guruh" + "%1$d Guruhlar" + + "Chat boshlanmoqda…" + "Stiker" + "Muvaffaqiyat" + "Tavsiyalar" + "Sinxronlash" + "Tizim" + "Matn" + "Uchinchi tomon bildirishnomalari" + "Ip" + "Mavzu" + "Bu xona nima haqida?" + "Shifrni ochish imkonsiz" + "Xavfsiz boʻlmagan qurilmadan yuborilgan" + "Sizni ushbu xabarga ruxsatingiz yoʻq" + "Yuboruvchining tasdiqlangan shaxsi qayta tiklandi" + "Takliflarni bir yoki bir nechta foydalanuvchiga yuborib bo‘lmadi." + "Taklif(lar)ni yuborib bo‘lmadi" + "Qulfni ochish" + "Ovozni yoqish" + "Qo‘llab-quvvatlanmaydigan chaqiruv" + "Qo\'llab-quvvatlanmagan hodisa" + "Foydalanuvchi nomi" + "Tasdiqlash bekor qilindi" + "Tasdiqlash yakunlandi" + "Tasdiqlanmadi" + "Tasdiqlangan" + "Qurilmani tasdiqlash" + "Shaxsni tasdiqlash" + "Foydalanuvchini tasdiqlang" + "Video" + "Yuqori sifatli" + "Eng yaxshi sifat, lekin kattaroq fayl hajmi" + "Past sifat" + "Eng tez yuklash tezligi va eng kichik fayl hajmi" + "Standart sifat" + "Sifat va yuklash tezligi balansi" + "Ovozli xabar" + "Kutilmoqda…" + "Ushbu xabarni kutilmoqda" + "Siz" + "%1$sning shaxsi qayta tiklandi.%2$s" + "%1$sʼning %2$s shaxsiy ma’lumotlari qayta tiklandi.%3$s" + "(%1$s )" + "%1$sning shaxsi qayta tiklandi." + "%1$sʼning %2$s shaxsiy ma’lumotlari qayta o‘rnatildi.%3$s" + "Tasdiqlashni bekor qilish" + "%1$s havolasi sizni boshqa %2$s saytiga olib boradi + +Davom etasizmi?" + "Ushbu havolani ikki marta tekshiring" + "Yuklagan videolaringizning standart sifatini tanlang." + "Video yuklash sifati" + "Ruxsat etilgan maksimal fayl hajmi: %1$s" + "Fayl hajmi yuklash uchun juda katta" + "Xona haqida xabar" + "Xabar berildi va xona tark etildi" + "Tasdiqlash" + "Xato" + "Muvaffaqiyat" + "Ogohlantirish" + "Oʻzgarishlar saqlanmadi. Haqiqatan ham orqaga qaytmoqchimisiz?" + "O‘zgartirishlarni saqlaysizmi?" + "Ruxsat etilgan maksimal fayl hajmi: %1$s" + "Yuklanadigan video sifatini tanlang." + "Video yuklash sifatini tanlang" + "Emojilarni qidiring" + "Bu qurilmada allaqachon %1$s hisobiga kirgansiz." + "Matrix autentifikatsiya xizmati va hisob yaratish imkoniyatini qo‘llab-quvvatlash uchun uy serveringizni yangilash talab etiladi." + "Doimiy havola yaratilmadi" + "%1$sxaritani yuklay olmadi. Iltimos keyinroq qayta urinib ko\'ring." + "Xabarlar yuklanmadi" + "%1$sjoylashuvingizga kira olmadi. Iltimos keyinroq qayta urinib ko\'ring." + "Ovozli xabaringizni yuklashda xatolik roʻy berdi." + "Xona endi mavjud emas yoki taklif yaroqsiz." + "Xabar topilmadi" + "%1$sjoylashuvingizga kirishga ruxsati yo\'q. Sozlamalar orqali kirishni yoqishingiz mumkin." + "%1$sjoylashuvingizga kirishga ruxsati yo\'q. Quyida kirishni yoqing." + "%1$smikrofoningizga kirish ruxsatiga ega emas. Ovozli xabar yozish uchun ruxsatni yoqing." + "Bu tarmoq yoki server muammolari bilan bog‘liq bo‘lishi mumkin." + "Bu xona manzili allaqachon mavjud. Xona manzili maydonini tahrirlang yoki xona nomini o‘zgartiring" + "Ayrim belgilarga ruxsat berilmagan. Faqat harf, raqam va quyidagi belgilar ishlaydi! $ &’() * + /; =? @ [ ] -. _" + "Bazi xabarlar yuborilmagan" + "Kechirasiz, xatolik yuz berdi" + "Hodisani yuborgan shaxs uni yuborgan qurilmaning egasi bilan mos kelmaydi." + "Bu qurilmada shifrlangan xabarning haqiqiyligini kafolatlash imkonsiz." + "Avval tasdiqlangan foydalanuvchi tomonidan shifrlangan." + "Shifrlanmagan" + "Nomaʼlum yoki oʻchirib tashlangan qurilma tomonidan shifrlangan." + "Egasi tasdiqlamagan qurilma tomonidan shifrlangan." + "Tasdiqlanmagan foydalanuvchi tomonidan shifrlangan." + "🔐️ Menga qo\'shiling%1$s" + "Hey, men bilan gaplash%1$s :%2$s" + "%1$sAndroid" + "Xato haqida xabar berish uchun G\'azablanish" + "Skrinshot" + "%1$s:%2$s" + "Parametrlar" + "%1$sni olib tashlash" + "Sozlamalar" + "Media tanlash jarayonida xatolik yuz berdi, qayta urinib ko\'ring" + "Xabarni bosib, bu yerga kiritish uchun \"%1$s\"-ni tanlang." + "Muhim xabarlarni osongina topish uchun qadang" + + "%1$d ta qadalgan xabar" + "%1$d ta qadalgan xabar" + + "Qadalgan xabarlar" + "Shaxsingizni qayta o‘rnatish uchun %1$s hisobingizga kirishingiz kerak. Shundan so‘ng, avtomatik ravishda ilovaga qaytarilasiz." + "Tasdiqlanmadimi? Shaxsingizni tiklash uchun hisobingizga kiring." + "Tasdiqlashni olib tashlang va yuboring" + "Siz tasdiqlashni bekor qilib, bu xabarni baribir yuborishingiz yoki hozircha to‘xtatib, %1$sʼni qayta tasdiqlagandan so‘ng keyinroq yana urinib ko‘rishingiz mumkin." + "%1$sning tasdiqlangan shaxsiy ma’lumotlari qayta o‘rnatilganligi tufayli xabaringiz jo‘natilmadi" + "Baribir xabar yuborilsin" + "%1$s tasdiqlanmagan bir yoki bir nechta qurilmadan foydalanmoqda. Siz xabarni baribir yuborishingiz mumkin yoki hozircha bekor qilib, %2$s barcha qurilmalarini tasdiqlagunga qadar kutib, keyinroq qayta urinishingiz mumkin." + "%1$s barcha qurilmalarni tasdiqlamagani uchun xabaringiz yuborilmadi" + "Bir yoki bir nechta qurilmangiz tasdiqlanmagan. Xabarni istalgancha yuborishingiz yoki hozircha bekor qilishingiz va barcha qurilmalaringizni tasdiqlaganingizdan keyin qayta urinishingiz mumkin." + "Xabaringiz yuborilmadi, chunki bir yoki bir nechta qurilmangizni tasdiqlamagansiz" + "Administratorlar yoki egalarni tahrirlash" + "Mediani yuklab bo‘lmadi, qayta urinib ko‘ring." + "Foydalanuvchi tafsilotlarini olinmadi" + "Xabar %1$sda" + "Kengaytirish" + "Kamaytirish" + "Bu xona allaqachon ko‘rilmoqda!" + "%1$sʼdan %2$s" + "%1$s ta qadalgan xabar" + "Xabar yuklanmoqda…" + "Barchasini koʻrish" + "Chat" + "Joylashuvni ulashish" + "Joylashuvimni ulashing" + "Apple Mapsda oching" + "Google Mapsda oching" + "OpenStreetMapda oching" + "Bu joylashuvni ulashing" + "Siz yaratgan yoki qo‘shilgan guruhlar." + "%1$s•%2$s" + "Bo‘shliqlar" + "Xabar yuborilmadi, chunki %1$sʼning tasdiqlangan identifikatori asliga qaytarildi." + "Xabar yuborilmadi, chunki %1$s barcha qurilmalarni tasdiqlamagan." + "Xabaringiz yuborilmadi, chunki siz bir yoki bir nechta qurilmangizni tasdiqlamagan ekansiz." + "Joylashuv" + "Versiya:%1$s (%2$s )" + "en" + "Bu qurilmada tarixiy xabarlar mavjud emas" + "Tarixiy xabarlarga kirish uchun bu qurilmani tasdiqlashingiz kerak" + "Sizni ushbu xabarga ruxsatingiz yoʻq" + "Xabarni shifrini ochib bo‘lmadi" + "Bu xabar bloklandi, chunki siz qurilmangizni tasdiqlamadingiz yoki yuboruvchi shaxsingizni tasdiqlashi kerak bo‘lgani sababli bloklandi" + diff --git a/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml b/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000..1c88ac7 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,477 @@ + + + "新增反應:%1$s" + "大頭貼" + "最小化訊息文字欄位" + "刪除" + + "已輸入 %1$d 個位數" + + "編輯大頭照" + "完整地址為 %1$s" + "加密詳細資訊" + "展開訊息文字欄位" + "隱藏密碼" + "加入通話" + "跳至底部" + "將地圖移動到我的位置" + "僅限提及" + "已關閉通知" + "新提及" + "新訊息" + "進行中的通話" + "其他使用者的大頭照" + "第 %1$d 頁" + "暫停" + "語音訊息,時長:%1$s,目前位置:%2$s" + "PIN 碼欄位" + "播放" + "投票" + "投票已結束" + "使用 %1$s 回應" + "用其他表情符號回應" + "%1$s 和 %2$s 已讀" + + "%1$s 與其他 %2$d 個人已讀" + + "%1$s 已讀" + "點擊以顯示全部" + "移除 %1$s 的反應" + "移除反應 %1$s" + "聊天室大頭照" + "傳送檔案" + "需要限時動作,您有一分鐘可以驗證" + "顯示密碼" + "開始通話" + "墓碑聊天室" + "使用者大頭照" + "使用者選單" + "檢視大頭照" + "檢視詳細資訊" + "語音訊息,時長:%1$s" + "錄製語音訊息。" + "停止錄音" + "您的大頭照" + "接受" + "新增標題" + "新增至時間軸" + "返回" + "通話" + "取消" + "暫時取消" + "選擇照片" + "清除" + "關閉" + "完成驗證" + "確認" + "確認密碼" + "繼續" + "複製" + "複製標題" + "複製連結" + "複製訊息連結" + "複製文字" + "建立" + "建立聊天室" + "停用" + "停用帳號" + "拒絕" + "拒絕並封鎖" + "刪除投票" + "取消全選" + "停用" + "捨棄" + "關閉" + "完成" + "編輯" + "編輯標題" + "編輯投票" + "啟用" + "結束投票" + "輸入 PIN 碼" + "結束" + "忘記密碼?" + "轉寄" + "返回" + "前往角色與權限" + "前往設定" + "忽略" + "邀請" + "邀請夥伴" + "邀請朋友使用 %1$s" + "邀請夥伴使用 %1$s" + "邀請" + "加入" + "了解更多" + "離開" + "離開對話" + "離開聊天室" + "離開空間" + "載入更多" + "管理帳號" + "管理裝置" + "聊天" + "最小化" + "下一步" + "否" + "以後再說" + "OK" + "開啟情境選單" + "開啟設定" + "用其他方式開啟" + "釘選" + "快速回覆" + "引用" + "回應" + "拒絕" + "移除" + "移除標題" + "移除訊息" + "回覆" + "在討論串中回覆" + "回報" + "回報程式錯誤" + "檢舉內容" + "回報對話" + "回報聊天室" + "重設" + "重設身份" + "再試一次" + "再次嘗試解密" + "儲存" + "搜尋" + "選取全部" + "傳送" + "傳送已編輯的訊息" + "傳送訊息" + "傳送語音訊息" + "分享" + "分享連結" + "顯示" + "再登入一次" + "登出" + "直接登出" + "略過" + "開始" + "開始聊天" + "開始驗證" + "點擊以載入地圖" + "拍照" + "點擊以查看選項" + "再試一次" + "取消釘選" + "檢視" + "在時間軸中檢視" + "檢視來源" + "是" + "是的,再試一次" + "您的伺服器現在支援新的、更快的協定。立即登出並重新登入以進行升級。現在這樣做將協助您避免在稍後移除舊協定時被強制登出。" + "可升級" + "關於" + "可接受使用政策" + "新增帳號" + "新增其他帳號" + "新增標題" + "進階設定" + "影像" + "分析" + "您離開了聊天室" + "您已登出工作階段" + "外觀" + "音訊" + "測試版" + "封鎖的使用者" + "泡泡" + "開始通話" + "聊天室備份" + "已複製到剪貼簿" + "著作權" + "正在建立聊天室…" + "請求已取消" + "已離開聊天室" + "離開空間" + "邀請被拒絕" + "深色" + "解密錯誤" + "描述" + "開發者選項" + "裝置 ID" + "私訊" + "不再顯示" + "下載失敗" + "正在下載" + "(已編輯)" + "編輯中" + "編輯標題" + "* %1$s %2$s" + "空檔案" + "加密" + "已啟用加密" + "輸入您的 PIN 碼" + "錯誤" + "發生錯誤,您可能無法收到新訊息的通知。請從設定中進行通知疑難排解。 + +理由:%1$s。" + "所有人" + "失敗" + "我的最愛" + "我的最愛" + "檔案" + "檔案已刪除" + "檔案已儲存" + "檔案已儲存至 Downloads" + "轉寄訊息" + "經常使用" + "GIF" + "圖片" + "回覆 %1$s" + "安裝 APK" + "找不到此 Matrix ID,因此可能沒有人會收到邀請。" + "正在離開聊天室" + "離開空間" + "淺色" + "行已複製到剪貼簿" + "連結已複製到剪貼簿" + "載入中…" + "載入更多……" + + "其他 %d 個人" + + + "%1$d 位成員" + + "訊息" + "訊息動作" + "訊息佈局" + "訊息已移除" + "現代" + "關閉通知" + "%1$s (%2$s)" + "查無結果" + "無聊天室名稱" + "沒有空間名稱" + "未加密" + "離線" + "開放原始碼授權條款" + "或" + "密碼" + "夥伴" + "永久連結" + "權限" + "已釘選" + "請檢查您的網際網路連線" + "請稍等…" + "您確定要結束這項投票嗎?" + "投票:%1$s" + "總票數:%1$s" + "結果將在投票結束後公佈" + + "%d 票" + + "正在準備……" + "隱私權政策" + "私密聊天室" + "私人空間" + "公開的聊天室" + "公開空間" + "回應" + "回應" + "理由" + "復原金鑰" + "重新整理中…" + + "%1$d 個回覆" + + "正在回覆%1$s" + "回報程式錯誤" + "回報問題" + "已遞交報告" + "格式化文字編輯器" + "聊天室" + "聊天室名稱" + "範例:您的計畫名稱" + + "%1$d 個聊天室" + + "變更已儲存" + "儲存中" + "螢幕鎖定" + "搜尋使用者" + "搜尋結果" + "安全性" + "已讀" + "選取帳號" + "傳送給" + "傳送中…" + "傳送失敗" + "已傳送" + ". " + "伺服器不支援" + "無法連線至伺服器" + "伺服器 URL" + "設定" + "分享空間" + "位置分享" + "共享空間" + "正在登出" + "有錯誤發生" + "我們了遇到了問題。請再試一次。" + "空間" + + "%1$d 個空間" + + "開始聊天…" + "貼圖" + "成功" + "建議" + "同步中" + "系統" + "文字" + "第三方通知" + "討論串" + "主題" + "這個聊天室是做什麼用的?" + "無法解密" + "從不安全的裝置傳送" + "您無法存取此則訊息" + "傳送者的驗證身份已重設" + "無法發送邀請給一或多個使用者。" + "無法發送邀請" + "解鎖" + "開啟通知" + "不支援的通話" + "不支援的事件" + "使用者名稱" + "驗證已取消" + "驗證完成" + "驗證失敗。" + "已驗證" + "驗證裝置" + "驗證身份" + "驗證使用者" + "影片" + "高品質" + "品質最佳但檔案較大" + "低品質" + "最快的上傳速度且檔案最小" + "標準品質" + "品質與上傳速度的平衡" + "語音訊息" + "等待中…" + "等待此則訊息" + "您" + "%1$s 的身份似乎已重設。%2$s" + "%1$s 的 %2$s 身份似乎已重設。%3$s" + "(%1$s)" + "%1$s 的已驗證身份被重設。" + "%1$s 的 %2$s 驗證身份已重設。 %3$s" + "撤回驗證" + "連結 %1$s 會將您帶往其他網站 %2$s + +您確定您想要繼續嗎?" + "仔細檢查此連結" + "選取您上傳的視訊預設品質。" + "視訊上傳品質" + "最大允許的檔案大小為:%1$s" + "檔案太大,無法上傳" + "聊天室已回報" + "回報並離開聊天室" + "確認" + "錯誤" + "成功" + "警告" + "變更尚未儲存,您確定要返回嗎?" + "是否儲存變更?" + "最大允許的檔案大小為:%1$s" + "選取您要上傳的視訊的品質。" + "選取視訊上傳品質" + "搜尋表情符號" + "您已在此裝置上以 %1$s 的身份登入。" + "您的家伺服器需要升級才能支援 Matrix Authentication Service 與帳號建立。" + "無法建立永久連結" + "%1$s無法載入地圖。請稍後再試。" + "無法載入訊息" + "%1$s 無法取得您的位置。請稍後再試。" + "無法上傳語音訊息。" + "此聊天室不再存在或邀請不再有效。" + "找不到訊息" + "%1$s 沒有權限存取您的位置。您可以到設定中開啟權限。" + "%1$s 沒有權限存取您的位置。請在下方開啟權限。" + "%1$s 沒有權限存取您的麥克風。您需要開啟權限才能錄製語音訊息。" + "這可能是因為網路或伺服器的問題所致。" + "此聊天室地址已存在。請嘗試編輯聊天室地址欄位或變更聊天室名稱" + "不允許使用部份字元。僅支援字母、數字與以下符號 ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" + "有些訊息尚未傳送" + "抱歉,發生錯誤" + "事件的傳送者與傳送該事件的裝置擁有者不相符。" + "無法在此裝置上保證此加密訊息的真實性。" + "由先前驗證的使用者加密。" + "未加密。" + "由未知或已刪除的裝置加密。" + "由未經其擁有者驗證的裝置加密。" + "由未經驗證的使用者加密。" + "🔐️ 在 %1$s 上加入我" + "嘿,來 %1$s 和我聊天:%2$s" + "%1$s Android" + "憤怒搖晃以回報臭蟲" + "螢幕截圖" + "%1$s:%2$s" + "選項" + "移除 %1$s" + "設定" + "選取媒體失敗,請再試一次。" + "按一下訊息,然後選擇「%1$s」以加入至此。" + "釘選重要訊息,如此才能輕鬆發現" + + "%1$d 則釘選的訊息" + + "釘選訊息" + "您將要前往您的 %1$s 帳號重設身份。然後您將會被帶回應用程式。" + "無法確認?前往您的帳號以重設您的身份。" + "撤回驗證並傳送" + "您可以撤回您的驗證並仍傳送此訊息,或者您也可以立刻取消並在重新驗證 %1$s 後再試一次。" + "因為 %1$s 的驗證身份已重設,因此未傳送您的訊息。" + "仍要傳送訊息" + "%1$s 正在使用一個或多個未經驗證的裝置。您仍然可以傳送訊息,也可以立刻取消並在 %2$s 驗證其所有裝置後再試一次。" + "未傳送您的訊息,因為 %1$s 尚未驗證所有裝置。" + "您的一個或多個裝置未經驗證。您仍可傳送訊息,也可以取消並在您驗證您的所有裝置後再試一次。" + "因為您尚未驗證一個或多個裝置,因為未傳送您的訊息" + "變更設定" + "管理空間" + "管理聊天室" + "權限" + "編輯管理員或擁有者" + "無法處理要上傳的媒體,請再試一次。" + "無法擷取使用者詳細資訊" + "%1$s 中的訊息" + "展開" + "減少" + "已檢視此聊天室!" + "第 %1$s 個,共 %2$s 個" + "%1$s 個釘選訊息" + "正在載入訊息……" + "檢視全部" + "聊天" + "分享位置" + "分享我的位置" + "在 Apple Maps 中開啟" + "在 Google Maps 中開啟" + "在開放街圖(OpenStreetMap) 中開啟" + "分享這個位置" + "您建立或加入的空間" + "%1$s • %2$s" + "%1$s 空間" + "空間" + "檢視成員" + "因為 %1$s 的驗證身份已重設,因此未傳送訊息。" + "訊息未傳送,因為 %1$s 尚未驗證所有裝置。" + "因為您尚未驗證一個或多個裝置,因此未傳送訊息" + "位置" + "版本:%1$s(%2$s)" + "zh-tw" + "歷史訊息在此裝置上無法讀取" + "您必須驗證此裝置才能存取歷史訊息" + "您無法存取此則訊息" + "無法解密訊息" + "此訊息被封鎖是因為您沒有驗證您的裝置,或是因為傳送者需要驗證您的身份而被封鎖。" + diff --git a/libraries/ui-strings/src/main/res/values-zh/translations.xml b/libraries/ui-strings/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000..a743a57 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-zh/translations.xml @@ -0,0 +1,473 @@ + + + "添加表情符号:%1$s" + "头像" + "最小化消息文本框" + "删除" + + "已输入 %1$d 个数字" + + "编辑头像" + "完整地址为%1$s" + "加密详情" + "展开消息文本框" + "隐藏密码" + "加入通话" + "跳转到底部" + "将地图移动到我的位置" + "仅提及" + "通知已关闭" + "新提及" + "新消息" + "正在进行的通话" + "其他用户的头像" + "第 %1$d 页" + "暂停" + "语音消息,时长:%1$s,当前位置:%2$s" + "PIN 字段" + "播放" + "投票" + "投票已结束" + "使用 %1$s 回应" + "使用其他表情符号回应" + "%1$s 和 %2$s 已读" + + "%1$s 及其他 %2$d 人已读" + + "%1$s 已读" + "点击以显示全部" + "撤回反应 %1$s" + "移除表情符号%1$s" + "房间头像" + "发送文件" + "限时操作,您有一分钟的时间来验证" + "显示密码" + "开始通话" + "墓碑聊天室" + "用户头像" + "用户菜单" + "查看头像" + "查看详情" + "语音消息,时长:%1$s" + "录制语音消息" + "停止录制" + "您的头像" + "接受" + "添加标题" + "添加到时间线" + "返回" + "呼叫" + "取消" + "暂时取消" + "选择照片" + "清除" + "关闭" + "完成验证" + "确认" + "确认密码" + "继续" + "复制" + "复制标题" + "复制链接" + "复制消息链接" + "复制文本" + "创建" + "创建聊天室" + "停用" + "停用账户" + "拒绝" + "拒绝并屏蔽" + "删除投票" + "取消全选" + "禁用" + "丢弃" + "关闭" + "完成" + "编辑" + "编辑标题" + "编辑投票" + "启用" + "结束投票" + "输入 PIN" + "完成" + "忘记密码?" + "转发" + "返回" + "前往角色与权限" + "前往设置" + "忽略" + "邀请" + "邀请朋友" + "邀请别人加入 %1$s" + "邀请别人加入 %1$s" + "邀请" + "加入" + "了解更多" + "离开" + "离开聊天" + "离开聊天室" + "离开空间" + "载入更多" + "管理账户" + "管理设备" + "发送消息给" + "最小化" + "下一步" + "否" + "以后再说" + "好" + "打开上下文菜单" + "打开设置" + "用其他方式打开" + "置顶" + "快速回复" + "引用" + "回应" + "拒绝" + "移除" + "删除标题" + "移除消息" + "回复" + "在消息列中回复" + "举报" + "报告错误" + "举报内容" + "举报对话" + "举报房间" + "重置" + "重置身份" + "重试" + "重试解密" + "保存" + "搜索" + "全选" + "发送" + "发送编辑后的消息" + "发送消息" + "发送语音消息" + "分享" + "分享链接" + "显示" + "再次登录" + "登出" + "仍然登出" + "跳过" + "开始" + "开始聊天" + "开始验证" + "点击以加载地图" + "拍摄照片" + "点按查看选项" + "再试一次" + "取消置顶" + "查看" + "在时间轴中查看" + "查看源码" + "是" + "是的,再试一次" + "您的服务器现在支持更快的新协议。现在登出并重新登录以进行升级。现在这样做可以帮助您避免在以后删除旧协议时被强制登出。" + "有可用升级" + "关于" + "可接受的使用政策" + "添加账户" + "添加另一个账户" + "添加标题" + "高级设置" + "一张图片" + "分析" + "你离开了聊天室" + "您已被注销当前会话" + "外观" + "音频" + "测试版" + "已屏蔽用户" + "气泡" + "通话已开始" + "聊天记录备份" + "已复制到剪贴板" + "版权" + "正在创建聊天室…" + "请求已取消" + "离开聊天室" + "离开空间" + "邀请已拒绝" + "深色" + "解密错误" + "描述" + "开发者选项" + "设备 ID" + "私聊" + "不再显示此内容" + "下载失败" + "正在下载" + "(已编辑)" + "编辑中" + "编辑标题" + "* %1$s %2$s" + "空文件" + "加密" + "已启用加密" + "输入 PIN 码" + "错误" + "发生错误,可能无法收到新消息通知。请在设置中对通知进行故障排除。 + +原因:%1$s。" + "所有人" + "失败" + "收藏" + "已收藏" + "文件" + "文件已删除" + "文件已保存" + "文件已保存到“下载”" + "转发消息" + "常用" + "GIF" + "图片" + "回复 %1$s" + "安装 APK" + "找不到此 Matrix ID,因此可能无法收到邀请。" + "正在离开聊天室" + "正在离开空间" + "浅色" + "链接已复制到剪贴板" + "链接已复制到剪贴板" + "正在加载…" + "正在加载更多……" + + "其他 %d 人" + + + "%1$d个成员" + + "消息" + "消息操作" + "消息布局" + "消息已移除" + "现代" + "静音" + "%1$s (%2$s)" + "没有结果" + "无聊天室名" + "未命名空间" + "未加密" + "离线" + "开源许可证" + "或" + "密码" + "用户" + "固定链接" + "权限" + "已置顶" + "请检查 Internet 连接" + "请稍候……" + "确定要结束这个投票吗?" + "投票:%1$s" + "总票数: %1$s" + "结果将在投票结束后显示" + + "%d 票" + + "正在准备…" + "隐私政策" + "私有聊天室" + "私有空间" + "公共聊天室" + "公开空间" + "回应" + "回应" + "理由" + "恢复密钥" + "正在刷新…" + + "%1$d 个回复" + + "正在回复 %1$s" + "报告错误" + "报告问题" + "报告已提交" + "富文本编辑器" + "聊天室" + "聊天室名称" + "例如:您的项目名称" + + "%1$d 房间" + + "保存的更改" + "正在保存" + "屏幕锁定" + "搜索某人" + "搜索结果" + "安全" + "已读" + "选择账户" + "发送至" + "正在发送…" + "发送失败" + "已发送" + "。 " + "服务器不支持" + "无法访问服务器" + "服务器 URL" + "设置" + "共享空间" + "共享位置" + "共享空间" + "正在登出" + "发生了一些错误" + "我们遇到了一个问题。请重试。" + "空间" + + "%1$d 空间" + + "开始聊天…" + "贴纸" + "成功" + "建议" + "正在同步" + "系统" + "文本" + "第三方通知" + "消息列" + "主题" + "这个聊天室是关于什么的?" + "无法解密" + "从不安全的设备发送" + "无权访问此消息" + "发送者的已验证身份已重置" + "无法向部分用户发送邀请。" + "无法发送邀请" + "解锁" + "解除静音" + "不支持的呼叫" + "不支持的事件" + "用户名" + "验证已取消" + "验证完成" + "验证失败" + "已验证" + "验证设备" + "验证身份" + "验证用户" + "视频" + "高质量" + "质量最好但文件较大" + "低质量" + "最快的上传速度和最小的文件大小" + "标准质量" + "质量与上传速度的平衡" + "语音消息" + "等待…" + "正在等待解密密钥" + "您" + "%1$s的身份已重置。%2$s" + "%1$s %2$s 的身份已重置。%3$s" + "(%1$s)" + "%1$s 的身份已重置。" + "%1$s %2$s 的身份已重置。%3$s" + "撤回验证" + "链接 %1$s 将跳转至外部网站 %2$s + +确定要继续吗?" + "请再次确认链接" + "选择您上传的视频的默认质量。" + "视频上传质量" + "允许的最大文件大小为:%1$s" + "文件太大,无法上传" + "已举报房间" + "举报并离开房间" + "确认" + "错误" + "成功" + "警告" + "更改尚未保存,确定要返回吗?" + "保存更改?" + "允许的最大文件大小为:%1$s" + "选择您要上传的视频的质量。" + "选择视频上传质量" + "搜索表情符号" + "您已在此设备以%1$s 身份登录。" + "您的服务器需要升级,以支持 Matrix 鉴权服务和账户创建。" + "创建固定链接失败" + "%1$s 无法加载地图,请稍后再试。" + "加载消息失败" + "%1$s 无法访问您的位置,请稍后再试。" + "无法上传语音消息。" + "该房间已不存在或邀请已失效。" + "找不到消息" + "%1$s 没有权限访问您的位置。您可以在设置中启用位置权限。" + "%1$s 没有权限访问您的位置。在下方启用位置权限。" + "%1$s 没有权限访问您的麦克风。启用录制语音消息的权限。" + "这可能是由于网络或服务器问题导致" + "此房间地址已存在。请尝试编辑房间地址字段或更改房间名称" + "不允许使用某些字符。仅支持字母、数字和以下符号 $ & ‘ ( ) * + / ; = ? @ [ ] - . _" + "某些信息尚未发送" + "抱歉,发生了错误" + "事件发送者与发送设备的所有者不匹配。" + "此加密消息的真实性无法在此设备上保证。" + "由先前验证过的用户加密。" + "未加密。" + "由未知或已删除的设备加密。" + "由未经其所有者验证的设备加密。" + "由未经验证的用户加密。" + "🔐️ 加入我 %1$s" + "嗨!请通过 %1$s 与我联系:%2$s" + "%1$s Android" + "摇一摇以报错" + "屏幕截图" + "%1$s:%2$s" + "选项" + "移除%1$s" + "设置" + "选择媒体失败,请重试。" + "按下消息并选择 “%1$s” 将其包含在此处。" + "固定重要消息,以便轻松发现它们" + + "%1$d 置顶消息" + + "置顶消息" + "您将要转到您的%1$s帐户来重置您的身份信息。之后,您将被带回该应用。" + "无法确认?请前往您的帐户重置您的身份。" + "撤回验证并发送" + "您可以撤回验证并仍然发送此消息;也可以暂时取消验证,在重新验证 %1$s 后重试。" + "您的消息未发送,因为%1$s的已验证身份已被重置" + "仍然发送消息" + "%1$s 正在使用一个或多个未经验证的设备。您还是可以继续发送信息;也可以暂时取消,等 %2$s 验证了所有设备后重试。" + "您的消息未发送,因为%1$s尚未验证所有设备" + "您有未验证的设备。您仍然可以发送消息;也可以暂时取消,并在验证所有设备后稍后重试。" + "您的消息未发送,因为您有尚未验证的设备。" + "编辑管理员或所有者" + "处理要上传的媒体失败,请重试。" + "无法获取用户信息" + "%1$s 中的消息" + "展开" + "折叠" + "已经在此房间了!" + "%1$s / %2$s" + "置顶消息 %1$s" + "正在加载消息…" + "查看全部" + "聊天" + "分享位置" + "分享我的位置" + "在 Apple Maps 中打开" + "在 Google Maps 中打开" + "在 OpenStreetMap 中打开" + "分享这个位置" + "您创建或加入的空间。" + "%1$s • %2$s" + "%1$s空间" + "空间" + "查看成员" + "消息未发送,因为%1$s的已验证身份已被重置。" + "消息未发送,因为%1$s尚未验证所有设备。" + "消息未发送,因为您有尚未验证的设备。" + "位置" + "版本:%1$s (%2$s)" + "zh-Hans" + "历史消息在此设备上不可用" + "您需要验证此设备才能访问历史消息" + "无权访问此消息" + "无法解密消息" + "此消息已被阻止,因为您未验证您的设备,或者发件人需要验证您的身份。" + 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 0000000..dcd3cdd --- /dev/null +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -0,0 +1,494 @@ + + + "Add reaction: %1$s" + "Avatar" + "Minimise message text field" + "Delete" + + "%1$d digit entered" + "%1$d digits entered" + + "Edit avatar" + "The full address will be %1$s" + "Encryption details" + "Expand message text field" + "Hide password" + "Join call" + "Jump to bottom" + "Move the map to my location" + "Mentions only" + "Muted" + "New mentions" + "New messages" + "Ongoing call" + "Other user\'s avatar" + "Page %1$d" + "Pause" + "Voice message, duration: %1$s, current position: %2$s" + "PIN field" + "Play" + "Poll" + "Ended poll" + "React with %1$s" + "React with other emojis" + "Read by %1$s and %2$s" + + "Read by %1$s and %2$d other" + "Read by %1$s and %2$d others" + + "Read by %1$s" + "Tap to show all" + "Remove reaction: %1$s" + "Remove reaction with %1$s" + "Room avatar" + "Send files" + "Time limited action required, you have one minute to verify" + "Show password" + "Start a call" + "Tombstoned room" + "User avatar" + "User menu" + "View avatar" + "View details" + "Voice message, duration: %1$s" + "Record voice message." + "Stop recording" + "Your avatar" + "Accept" + "Add caption" + "Add to timeline" + "Back" + "Call" + "Cancel" + "Cancel for now" + "Choose photo" + "Clear" + "Close" + "Complete verification" + "Confirm" + "Confirm password" + "Continue" + "Copy" + "Copy caption" + "Copy link" + "Copy link to message" + "Copy text" + "Create" + "Create a room" + "Deactivate" + "Deactivate account" + "Decline" + "Decline and block" + "Delete Poll" + "Deselect all" + "Disable" + "Discard" + "Dismiss" + "Done" + "Edit" + "Edit caption" + "Edit poll" + "Enable" + "End poll" + "Enter PIN" + "Finish" + "Forgot password?" + "Forward" + "Go back" + "Go to roles & permissions" + "Go to settings" + "Ignore" + "Invite" + "Invite people" + "Invite people to %1$s" + "Invite people to %1$s" + "Invites" + "Join" + "Learn more" + "Leave" + "Leave conversation" + "Leave room" + "Leave space" + "Load more" + "Manage account" + "Manage devices" + "Message" + "Minimise" + "Next" + "No" + "Not now" + "OK" + "Open context menu" + "Settings" + "Open with" + "Pin" + "Quick reply" + "Quote" + "React" + "Reject" + "Remove" + "Remove caption" + "Remove message" + "Reply" + "Reply in thread" + "Report" + "Report bug" + "Report content" + "Report conversation" + "Report room" + "Reset" + "Reset identity" + "Retry" + "Retry decryption" + "Save" + "Search" + "Select all" + "Send" + "Send edited message" + "Send message" + "Send voice message" + "Share" + "Share link" + "Show" + "Sign in again" + "Sign out" + "Sign out anyway" + "Skip" + "Start" + "Start chat" + "Start verification" + "Tap to load map" + "Take photo" + "Tap for options" + "Try again" + "Unpin" + "View" + "View in timeline" + "View source" + "Yes" + "Yes, try again" + "Your server now supports a new, faster protocol. Log out and log back in to upgrade now. Doing this now will help you avoid a forced logout when the old protocol is removed later." + "Upgrade available" + "About" + "Acceptable use policy" + "Add an account" + "Add another account" + "Adding caption" + "Advanced settings" + "an image" + "Analytics" + "You left the room" + "You were logged out of the session" + "Appearance" + "Audio" + "Beta" + "Blocked users" + "Bubbles" + "Call started" + "Chat backup" + "Copied to clipboard" + "Copyright" + "Creating room…" + "Request canceled" + "Left room" + "Left space" + "Invite declined" + "Dark" + "Decryption error" + "Description" + "Developer options" + "Device ID" + "Direct chat" + "Do not show this again" + "Download failed" + "Downloading" + "(edited)" + "Editing" + "Editing caption" + "* %1$s %2$s" + "Empty file" + "Encryption" + "Encryption enabled" + "Enter your PIN" + "Error" + "An error occurred, you may not receive notifications for new messages. Please troubleshoot notifications from the settings. + +Reason: %1$s." + "Everyone" + "Failed" + "Favourite" + "Favourited" + "File" + "File deleted" + "File saved" + "File saved to Downloads" + "Forward message" + "Frequently used" + "GIF" + "Image" + "In reply to %1$s" + "Install APK" + "This Matrix ID can\'t be found, so the invite might not be received." + "Leaving room" + "Leaving space" + "Light" + "Line copied to clipboard" + "Link copied to clipboard" + "Loading…" + "Loading more…" + + "%d other" + "%d others" + + + "%1$d Member" + "%1$d Members" + + "Message" + "Message actions" + "Message failed to send" + "Message layout" + "Message removed" + "Modern" + "Mute" + "%1$s (%2$s)" + "No results" + "No room name" + "No space name" + "Not encrypted" + "Offline" + "Open source licenses" + "or" + "Password" + "People" + "Permalink" + "Permission" + "Pinned" + "Please check your internet connection" + "Please wait…" + "Are you sure you want to end this poll?" + "Poll: %1$s" + "Total votes: %1$s" + "Results will show after the poll has ended" + + "%d vote" + "%d votes" + + "Preparing…" + "Privacy policy" + "Private room" + "Private space" + "Public room" + "Public space" + "Reaction" + "Reactions" + "Reason" + "Recovery key" + "Refreshing…" + + "%1$d reply" + "%1$d replies" + + "Replying to %1$s" + "Report a bug" + "Report a problem" + "Report submitted" + "Rich text editor" + "Room" + "Room name" + "e.g. your project name" + + "%1$d Room" + "%1$d Rooms" + + "Saved changes" + "Saving" + "Screen lock" + "Search for someone" + "Search results" + "Security" + "Seen by" + "Select an account" + "Send to" + "Sending…" + "Sending failed" + "Sent" + ". " + "Server not supported" + "Server unreachable" + "Server URL" + "Settings" + "Share space" + "Shared location" + "Shared space" + "Signing out" + "Something went wrong" + "We encountered an issue. Please try again." + "Space" + + "%1$d Space" + "%1$d Spaces" + + "Starting chat…" + "Sticker" + "Success" + "Suggestions" + "Syncing" + "System" + "Text" + "Third-party notices" + "Thread" + "Topic" + "What is this room about?" + "Unable to decrypt" + "Sent from an insecure device" + "You don\'t have access to this message" + "Sender\'s verified identity was reset" + "Invites couldn\'t be sent to one or more users." + "Unable to send invite(s)" + "Unlock" + "Unmute" + "Unsupported call" + "Unsupported event" + "Username" + "Verification cancelled" + "Verification complete" + "Verification failed" + "Verified" + "Verify device" + "Verify identity" + "Verify user" + "Video" + "High quality" + "Best quality but larger file size" + "Low quality" + "Fastest upload speed and smallest file size" + "Standard quality" + "Balance of quality and upload speed" + "Voice message" + "Waiting…" + "Waiting for this message" + "You" + "Messages you send will be shared with new members invited to this room. %1$s" + "%1$s\'s identity was reset. %2$s" + "%1$s’s %2$s identity was reset. %3$s" + "(%1$s)" + "%1$s’s identity was reset." + "%1$s’s %2$s identity was reset. %3$s" + "Withdraw verification" + "The link %1$s is taking you to another site %2$s + +Are you sure you want to continue?" + "Double-check this link" + "Select the default quality of videos you upload." + "Video upload quality" + "The max file size allowed is: %1$s" + "The file size is too large to upload" + "Room reported" + "Reported and left room" + "Confirmation" + "Error" + "Success" + "Warning" + "Your changes have not been saved. Are you sure you want to go back?" + "Save changes?" + "The max file size allowed is: %1$s" + "Select the quality of the video you want to upload." + "Select video upload quality" + "Search emojis" + "You\'re already logged in on this device as %1$s." + "Your homeserver needs to be upgraded to support Matrix Authentication Service and account creation." + "Failed creating the permalink" + "%1$s could not load the map. Please try again later." + "Failed loading messages" + "%1$s could not access your location. Please try again later." + "Failed to upload your voice message." + "The room no longer exists or the invite is no longer valid." + "Message not found" + "%1$s does not have permission to access your location. You can enable access in Settings." + "%1$s does not have permission to access your location. Enable access below." + "%1$s does not have permission to access your microphone. Enable access to record a voice message." + "This may be due to network or server issues." + "This room address already exists. Please try editing the room address field or change the room name" + "Some characters are not allowed. Only letters, digits and the following symbols are supported ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" + "Some messages have not been sent" + "Sorry, an error occurred" + "The sender of the event does not match the owner of the device that sent it." + "The authenticity of this encrypted message can\'t be guaranteed on this device." + "Encrypted by a previously-verified user." + "Not encrypted." + "Encrypted by an unknown or deleted device." + "Encrypted by a device not verified by its owner." + "Encrypted by an unverified user." + "🔐️ Join me on %1$s" + "Hey, talk to me on %1$s: %2$s" + "%1$s Android" + "Rageshake to report bug" + "Screenshot" + "%1$s: %2$s" + "Options" + "Remove %1$s" + "Settings" + "Spaces where members can join the room without an invitation." + "Manage spaces" + "(Unknown space)" + "Other spaces you’re not a member of" + "Your spaces" + "Failed selecting media, please try again." + "Press on a message and choose “%1$s” to include here." + "Pin important messages so that they can be easily discovered" + + "%1$d Pinned message" + "%1$d Pinned messages" + + "Pinned messages" + "You\'re about to go to your %1$s account to reset your identity. Afterwards you\'ll be taken back to the app." + "Can\'t confirm? Go to your account to reset your identity." + "Withdraw verification and send" + "You can withdraw your verification and send this message anyway, or you can cancel for now and try again later after reverifying %1$s." + "Your message was not sent because %1$s’s verified identity was reset" + "Send message anyway" + "%1$s is using one or more unverified devices. You can send the message anyway, or you can cancel for now and try again later after %2$s has verified all their devices." + "Your message was not sent because %1$s has not verified all devices" + "One or more of your devices are unverified. You can send the message anyway, or you can cancel for now and try again later after you have verified all of your devices." + "Your message was not sent because you have not verified one or more of your devices" + "Change settings" + "Manage space" + "Manage rooms" + "Permissions" + "Edit Admins or Owners" + "Failed processing media to upload, please try again." + "Could not retrieve user details" + "Message in %1$s" + "Expand" + "Reduce" + "Already viewing this room!" + "%1$s of %2$s" + "%1$s Pinned messages" + "Loading message…" + "View All" + "Chat" + "Share location" + "Share my location" + "Open in Apple Maps" + "Open in Google Maps" + "Open in OpenStreetMap" + "Share this location" + "Spaces you have created or joined." + "%1$s • %2$s" + "%1$s space" + "Spaces" + "View members" + "Message not sent because %1$s’s verified identity was reset." + "Message not sent because %1$s has not verified all devices." + "Message not sent because you have not verified one or more of your devices." + "Location" + "Version: %1$s (%2$s)" + "en" + "en" + "Historical messages are not available on this device" + "You need to verify this device for access to historical messages" + "You don\'t have access to this message" + "Unable to decrypt message" + "This message was blocked either because you did not verify your device or because the sender needs to verify your identity." + diff --git a/libraries/ui-utils/build.gradle.kts b/libraries/ui-utils/build.gradle.kts new file mode 100644 index 0000000..a0144fb --- /dev/null +++ b/libraries/ui-utils/build.gradle.kts @@ -0,0 +1,24 @@ +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.ui.utils" +} + +dependencies { + implementation(projects.libraries.androidutils) + implementation(projects.services.toolbox.impl) + + testCommonDependencies(libs) +} diff --git a/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/MultipleTapToUnlock.kt b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/MultipleTapToUnlock.kt new file mode 100644 index 0000000..dee1319 --- /dev/null +++ b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/MultipleTapToUnlock.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.ui.utils + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds + +/** + * Returns true if the user has tapped [numberOfTapToUnlock] times in a short amount of time. + * The counter is reset after 2 seconds of inactivity. + * + * @param numberOfTapToUnlock The number of taps required to unlock. + */ +class MultipleTapToUnlock( + private val numberOfTapToUnlock: Int = 7, +) { + private var counter = numberOfTapToUnlock + private var currentJob: Job? = null + + fun unlock(scope: CoroutineScope): Boolean { + counter-- + currentJob?.cancel() + return if (counter > 0) { + currentJob = scope.launch { + delay(2.seconds) + // Reset counter if user is not fast enough + counter = numberOfTapToUnlock + } + false + } else { + true + } + } +} diff --git a/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/formatter/FIleSizeFormatter.kt b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/formatter/FIleSizeFormatter.kt new file mode 100644 index 0000000..da67e48 --- /dev/null +++ b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/formatter/FIleSizeFormatter.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.ui.utils.formatter + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import io.element.android.libraries.androidutils.filesize.AndroidFileSizeFormatter +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.ui.utils.version.LocalSdkIntVersionProvider + +@Composable +fun rememberFileSizeFormatter(): FileSizeFormatter { + val context = LocalContext.current + val sdkIntProvider = LocalSdkIntVersionProvider.current + return remember { + AndroidFileSizeFormatter(context, sdkIntProvider) + } +} diff --git a/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/time/DurationExt.kt b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/time/DurationExt.kt new file mode 100644 index 0000000..0ccbace --- /dev/null +++ b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/time/DurationExt.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.ui.utils.time + +import kotlin.time.Duration + +/** + * Format a duration as minutes:seconds. + * + * For example, + * - 0 seconds will be formatted as "0:00". + * - 65 seconds will be formatted as "1:05". + * - 2 hours will be formatted as "120:00". + * - negative 10 seconds will be formatted as "-0:10". + * + * @return the formatted duration. + */ +fun Duration.formatShort(): String { + // Format as minutes:seconds + val seconds = (absoluteValue.inWholeSeconds % 60) + .toString() + .padStart(2, '0') + + val sign = isNegative().let { if (it) "-" else "" } + + return "$sign${absoluteValue.inWholeMinutes}:$seconds" +} diff --git a/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/time/IsTalkbackEnabled.kt b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/time/IsTalkbackEnabled.kt new file mode 100644 index 0000000..60ac188 --- /dev/null +++ b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/time/IsTalkbackEnabled.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.ui.utils.time + +import android.view.accessibility.AccessibilityManager +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.setValue +import androidx.compose.ui.platform.LocalContext + +@Composable +fun isTalkbackActive(): Boolean { + val context = LocalContext.current + val accessibilityManager = remember { context.getSystemService(AccessibilityManager::class.java) } + var isTouchExplorationEnabled by remember { mutableStateOf(accessibilityManager.isTouchExplorationEnabled) } + DisposableEffect(Unit) { + val listener = AccessibilityManager.TouchExplorationStateChangeListener { enabled -> + isTouchExplorationEnabled = enabled + } + accessibilityManager.addTouchExplorationStateChangeListener(listener) + onDispose { + accessibilityManager.removeTouchExplorationStateChangeListener(listener) + } + } + return isTouchExplorationEnabled +} diff --git a/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/time/KeyEventExt.kt b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/time/KeyEventExt.kt new file mode 100644 index 0000000..ba69dad --- /dev/null +++ b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/time/KeyEventExt.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.ui.utils.time + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.key + +/** + * Extension property to get the digit character from a KeyEvent. + * This handles both regular digit keys and numpad keys. + */ +val KeyEvent.digit: Char? get() { + val char = nativeKeyEvent.unicodeChar.toChar() + return when { + Character.isDigit(char) -> char + key == Key.NumPad0 -> '0' + key == Key.NumPad1 -> '1' + key == Key.NumPad2 -> '2' + key == Key.NumPad3 -> '3' + key == Key.NumPad4 -> '4' + key == Key.NumPad5 -> '5' + key == Key.NumPad6 -> '6' + key == Key.NumPad7 -> '7' + key == Key.NumPad8 -> '8' + key == Key.NumPad9 -> '9' + else -> null + } +} diff --git a/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/version/LocalSdkIntVersionProvider.kt b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/version/LocalSdkIntVersionProvider.kt new file mode 100644 index 0000000..88e17c9 --- /dev/null +++ b/libraries/ui-utils/src/main/kotlin/io/element/android/libraries/ui/utils/version/LocalSdkIntVersionProvider.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.ui.utils.version + +import androidx.compose.runtime.staticCompositionLocalOf +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import io.element.android.services.toolbox.impl.sdk.DefaultBuildVersionSdkIntProvider + +val LocalSdkIntVersionProvider = staticCompositionLocalOf { DefaultBuildVersionSdkIntProvider() } diff --git a/libraries/ui-utils/src/test/kotlin/io/element/android/libraries/ui/utils/MultipleTapToUnlockTest.kt b/libraries/ui-utils/src/test/kotlin/io/element/android/libraries/ui/utils/MultipleTapToUnlockTest.kt new file mode 100644 index 0000000..73b91a8 --- /dev/null +++ b/libraries/ui-utils/src/test/kotlin/io/element/android/libraries/ui/utils/MultipleTapToUnlockTest.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.ui.utils + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +class MultipleTapToUnlockTest { + @Test + fun `test multiple tap should unlock`() = runTest { + val sut = MultipleTapToUnlock(3) + assertThat(sut.unlock(backgroundScope)).isFalse() + assertThat(sut.unlock(backgroundScope)).isFalse() + assertThat(sut.unlock(backgroundScope)).isTrue() + assertThat(sut.unlock(backgroundScope)).isTrue() + // All next call returns true + advanceTimeBy(3.seconds) + assertThat(sut.unlock(backgroundScope)).isTrue() + } + @Test + fun `test waiting should reset counter`() = runTest { + val sut = MultipleTapToUnlock(3) + assertThat(sut.unlock(backgroundScope)).isFalse() + assertThat(sut.unlock(backgroundScope)).isFalse() + advanceTimeBy(3.seconds) + assertThat(sut.unlock(backgroundScope)).isFalse() + assertThat(sut.unlock(backgroundScope)).isFalse() + assertThat(sut.unlock(backgroundScope)).isTrue() + } +} diff --git a/libraries/ui-utils/src/test/kotlin/io/element/android/libraries/ui/utils/time/DurationFormatTest.kt b/libraries/ui-utils/src/test/kotlin/io/element/android/libraries/ui/utils/time/DurationFormatTest.kt new file mode 100644 index 0000000..d4c007d --- /dev/null +++ b/libraries/ui-utils/src/test/kotlin/io/element/android/libraries/ui/utils/time/DurationFormatTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.ui.utils.time + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import kotlin.time.Duration.Companion.seconds + +@RunWith(value = Parameterized::class) +class DurationFormatTest( + private val seconds: Double, + private val output: String, +) { + companion object { + @Parameterized.Parameters(name = "{index}: format({0})={1}") + @JvmStatic + fun data(): Iterable> { + return arrayListOf( + arrayOf(0, "0:00"), + arrayOf(1, "0:01"), + arrayOf(10, "0:10"), + arrayOf(59.9, "0:59"), + arrayOf(60, "1:00"), + arrayOf(61, "1:01"), + arrayOf(60 * 60, "60:00"), + arrayOf(-60, "-1:00"), + arrayOf(-1, "-0:01"), + ).toList() + } + } + + @Test + fun formatShort() { + assertEquals(output, seconds.seconds.formatShort()) + } +} diff --git a/libraries/usersearch/api/build.gradle.kts b/libraries/usersearch/api/build.gradle.kts new file mode 100644 index 0000000..978a5c0 --- /dev/null +++ b/libraries/usersearch/api/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..0f7e02c --- /dev/null +++ b/libraries/usersearch/api/src/main/kotlin/io/element/android/libraries/usersearch/api/UserListDataSource.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 + 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 0000000..f7e5367 --- /dev/null +++ b/libraries/usersearch/api/src/main/kotlin/io/element/android/libraries/usersearch/api/UserRepository.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.usersearch.api + +import kotlinx.coroutines.flow.Flow + +interface UserRepository { + fun search(query: String): Flow +} 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 0000000..30da8cf --- /dev/null +++ b/libraries/usersearch/api/src/main/kotlin/io/element/android/libraries/usersearch/api/UserSearchResult.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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, +) + +data class UserSearchResultState( + val results: List, + val isSearching: Boolean, +) diff --git a/libraries/usersearch/impl/build.gradle.kts b/libraries/usersearch/impl/build.gradle.kts new file mode 100644 index 0000000..bd8eb47 --- /dev/null +++ b/libraries/usersearch/impl/build.gradle.kts @@ -0,0 +1,34 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.usersearch.impl" +} + +setupDependencyInjection() + +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) + implementation(libs.kotlinx.collections.immutable) + + testCommonDependencies(libs) + 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 0000000..3d43318 --- /dev/null +++ b/libraries/usersearch/impl/src/main/kotlin/io/element/android/libraries/usersearch/impl/MatrixUserListDataSource.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.usersearch.impl + +import dev.zacsweers.metro.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 + +@ContributesBinding(SessionScope::class) +class MatrixUserListDataSource( + private val client: MatrixClient +) : UserListDataSource { + override suspend fun search(query: String, count: Long): List { + 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 0000000..2b6456b --- /dev/null +++ b/libraries/usersearch/impl/src/main/kotlin/io/element/android/libraries/usersearch/impl/MatrixUserRepository.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.usersearch.impl + +import dev.zacsweers.metro.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 io.element.android.libraries.usersearch.api.UserSearchResultState +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +@ContributesBinding(SessionScope::class) +class MatrixUserRepository( + private val client: MatrixClient, + private val dataSource: UserListDataSource +) : UserRepository { + override fun search(query: String): Flow = flow { + val shouldQueryProfile = MatrixPatterns.isUserId(query) && !client.isMe(UserId(query)) + val shouldFetchSearchResults = query.length >= MINIMUM_SEARCH_LENGTH + // 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 fakeSearchResult = if (shouldQueryProfile) { + UserSearchResult(MatrixUser(UserId(query))) + } else { + null + } + if (shouldQueryProfile || shouldFetchSearchResults) { + emit(UserSearchResultState(isSearching = shouldFetchSearchResults, results = listOfNotNull(fakeSearchResult))) + } + if (shouldFetchSearchResults) { + val results = fetchSearchResults(query, shouldQueryProfile) + emit(results) + } + } + + private suspend fun fetchSearchResults(query: String, shouldQueryProfile: Boolean): UserSearchResultState { + // 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) + ) + } + + return UserSearchResultState(results = results, isSearching = false) + } + + 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 0000000..149f7d9 --- /dev/null +++ b/libraries/usersearch/impl/src/test/kotlin/io/element/android/libraries/usersearch/impl/MatrixUserListDataSourceTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.usersearch.impl + +import com.google.common.truth.Truth.assertThat +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.collections.immutable.persistentListOf +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 = persistentListOf( + aMatrixUserProfile(), + aMatrixUserProfile(userId = A_USER_ID_2) + ), + limited = false + ) + ) + ) + val dataSource = MatrixUserListDataSource(matrixClient) + + val results = dataSource.search("test", 2) + 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(RuntimeException("Ruhroh")) + ) + val dataSource = MatrixUserListDataSource(matrixClient) + + val results = dataSource.search("test", 2) + 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) + 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(RuntimeException("Ruhroh")) + ) + val dataSource = MatrixUserListDataSource(matrixClient) + + val result = dataSource.getProfile(A_USER_ID) + 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 0000000..29ee1cb --- /dev/null +++ b/libraries/usersearch/impl/src/test/kotlin/io/element/android/libraries/usersearch/impl/MatrixUserRepositoryTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 { + awaitItem().also { + assertThat(it.isSearching).isTrue() + assertThat(it.results).isEmpty() + } + awaitItem().also { + assertThat(it.isSearching).isFalse() + assertThat(it.results).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 { + awaitItem().also { + assertThat(it.isSearching).isTrue() + assertThat(it.results).isEmpty() + } + awaitItem().also { + assertThat(it.isSearching).isFalse() + assertThat(it.results).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 { + awaitItem().also { + assertThat(it.isSearching).isTrue() + assertThat(it.results).isEqualTo(listOf(placeholderResult())) + } + cancelAndConsumeRemainingEvents() + } + } + + @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 { + awaitItem().also { + assertThat(it.isSearching).isTrue() + assertThat(it.results).isEmpty() + } + cancelAndConsumeRemainingEvents() + } + } + + @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 { + skipItems(1) + assertThat(awaitItem().results).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().results).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().results).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 { + skipItems(1) + assertThat(awaitItem().results).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().results).isEqualTo(listOf(placeholderResult(isUnresolved = true)) + searchResults.toUserSearchResults()) + awaitComplete() + } + } + + private fun aMatrixUserListWithoutUserId(userId: UserId) = aMatrixUserList().filterNot { it.userId == userId } + + private fun List.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 0000000..21cc140 --- /dev/null +++ b/libraries/usersearch/test/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 0000000..8838bfc --- /dev/null +++ b/libraries/usersearch/test/src/main/kotlin/io/element/android/libraries/usersearch/test/FakeUserListDataSource.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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 = emptyList() + private var profile: MatrixUser? = null + + override suspend fun search(query: String, count: Long): List = searchResult.take(count.toInt()) + + override suspend fun getProfile(userId: UserId): MatrixUser? = profile + + fun givenSearchResult(users: List) { + 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 0000000..a767e4d --- /dev/null +++ b/libraries/usersearch/test/src/main/kotlin/io/element/android/libraries/usersearch/test/FakeUserRepository.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.usersearch.test + +import io.element.android.libraries.usersearch.api.UserRepository +import io.element.android.libraries.usersearch.api.UserSearchResultState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow + +class FakeUserRepository : UserRepository { + var providedQuery: String? = null + private set + + private val flow = MutableSharedFlow() + + override fun search(query: String): Flow { + providedQuery = query + return flow + } + + suspend fun emitState(state: UserSearchResultState) { + flow.emit(state) + } +} diff --git a/libraries/voiceplayer/api/build.gradle.kts b/libraries/voiceplayer/api/build.gradle.kts new file mode 100644 index 0000000..f37c263 --- /dev/null +++ b/libraries/voiceplayer/api/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.voiceplayer.api" +} + +dependencies { + implementation(libs.androidx.annotationjvm) + implementation(libs.coroutines.core) + implementation(projects.libraries.matrix.api) +} diff --git a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageEvents.kt b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageEvents.kt new file mode 100644 index 0000000..4adc1cf --- /dev/null +++ b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageEvents.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voiceplayer.api + +sealed interface VoiceMessageEvents { + data object PlayPause : VoiceMessageEvents + data class Seek(val percentage: Float) : VoiceMessageEvents +} diff --git a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageException.kt b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageException.kt new file mode 100644 index 0000000..b3ee8e8 --- /dev/null +++ b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageException.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voiceplayer.api + +sealed class VoiceMessageException : Exception() { + data class FileException( + override val message: String?, + override val cause: Throwable? = null + ) : VoiceMessageException() + + data class PermissionMissing( + override val message: String?, + override val cause: Throwable? + ) : VoiceMessageException() + + data class PlayMessageError( + override val message: String?, + override val cause: Throwable? + ) : VoiceMessageException() +} diff --git a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessagePresenterFactory.kt b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessagePresenterFactory.kt new file mode 100644 index 0000000..0229ed9 --- /dev/null +++ b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessagePresenterFactory.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voiceplayer.api + +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import kotlin.time.Duration + +interface VoiceMessagePresenterFactory { + fun createVoiceMessagePresenter( + eventId: EventId?, + mediaSource: MediaSource, + mimeType: String?, + filename: String?, + duration: Duration, + ): Presenter +} diff --git a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageState.kt b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageState.kt new file mode 100644 index 0000000..e13d97b --- /dev/null +++ b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageState.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voiceplayer.api + +data class VoiceMessageState( + val button: Button, + val progress: Float, + val time: String, + val showCursor: Boolean, + val eventSink: (event: VoiceMessageEvents) -> Unit, +) { + enum class Button { + Play, + Pause, + Downloading, + Retry, + Disabled, + } +} diff --git a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStateProvider.kt b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStateProvider.kt new file mode 100644 index 0000000..7a98c52 --- /dev/null +++ b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStateProvider.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voiceplayer.api + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class VoiceMessageStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aVoiceMessageState( + VoiceMessageState.Button.Downloading, + progress = 0f, + time = "0:00", + ), + aVoiceMessageState( + VoiceMessageState.Button.Retry, + progress = 0.5f, + time = "0:01", + ), + aVoiceMessageState( + VoiceMessageState.Button.Play, + progress = 1f, + time = "1:00", + showCursor = true, + ), + aVoiceMessageState( + VoiceMessageState.Button.Pause, + progress = 0.2f, + time = "10:00", + showCursor = true, + ), + aVoiceMessageState( + VoiceMessageState.Button.Disabled, + progress = 0.2f, + time = "30:00", + ), + ) +} + +fun aVoiceMessageState( + button: VoiceMessageState.Button = VoiceMessageState.Button.Play, + progress: Float = 0f, + time: String = "1:00", + showCursor: Boolean = false, +) = VoiceMessageState( + button = button, + progress = progress, + time = time, + showCursor = showCursor, + eventSink = {}, +) diff --git a/libraries/voiceplayer/impl/build.gradle.kts b/libraries/voiceplayer/impl/build.gradle.kts new file mode 100644 index 0000000..679e820 --- /dev/null +++ b/libraries/voiceplayer/impl/build.gradle.kts @@ -0,0 +1,40 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.voiceplayer.impl" +} + +setupDependencyInjection() + +dependencies { + api(projects.libraries.voiceplayer.api) + + implementation(projects.libraries.audio.api) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.mediaplayer.api) + implementation(projects.libraries.uiUtils) + implementation(projects.services.analytics.api) + + implementation(libs.androidx.annotationjvm) + implementation(libs.coroutines.core) + + testCommonDependencies(libs) + testImplementation(libs.coroutines.core) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.mediaplayer.test) + testImplementation(projects.services.analytics.test) +} diff --git a/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessagePresenterFactory.kt b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessagePresenterFactory.kt new file mode 100644 index 0000000..b2518ea --- /dev/null +++ b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessagePresenterFactory.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voiceplayer.impl + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.voiceplayer.api.VoiceMessagePresenterFactory +import io.element.android.libraries.voiceplayer.api.VoiceMessageState +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.CoroutineScope +import kotlin.time.Duration + +@ContributesBinding(RoomScope::class) +class DefaultVoiceMessagePresenterFactory( + private val analyticsService: AnalyticsService, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, + private val voiceMessagePlayerFactory: VoiceMessagePlayer.Factory, +) : VoiceMessagePresenterFactory { + override fun createVoiceMessagePresenter( + eventId: EventId?, + mediaSource: MediaSource, + mimeType: String?, + filename: String?, + duration: Duration, + ): Presenter { + val player = voiceMessagePlayerFactory.create( + eventId = eventId, + mediaSource = mediaSource, + mimeType = mimeType, + filename = filename, + ) + + return VoiceMessagePresenter( + analyticsService = analyticsService, + sessionCoroutineScope = sessionCoroutineScope, + player = player, + eventId = eventId, + duration = duration, + ) + } +} diff --git a/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessageMediaRepo.kt b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessageMediaRepo.kt new file mode 100644 index 0000000..310120c --- /dev/null +++ b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessageMediaRepo.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voiceplayer.impl + +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.mapCatchingExceptions +import io.element.android.libraries.di.CacheDirectory +import io.element.android.libraries.di.RoomScope +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.mxc.MxcTools +import java.io.File + +/** + * Fetches the media file for a voice message. + * + * Media is downloaded from the rust sdk and stored in the application's cache directory. + * Media files are indexed by their Matrix Content (mxc://) URI and considered immutable. + * Whenever a given mxc is found in the cache, it is returned immediately. + */ +interface VoiceMessageMediaRepo { + /** + * Factory for [VoiceMessageMediaRepo]. + */ + fun interface Factory { + /** + * Creates a [VoiceMessageMediaRepo]. + * + * @param mediaSource the media source of the voice message. + * @param mimeType the mime type of the voice message. + * @param filename the filename of the voice message. + */ + fun create( + mediaSource: MediaSource, + mimeType: String?, + filename: String?, + ): VoiceMessageMediaRepo + } + + /** + * Returns the voice message media file. + * + * In case of a cache hit the file is returned immediately. + * In case of a cache miss the file is downloaded and then returned. + * + * @return A [Result] holding either the media [File] from the cache directory or an [Exception]. + */ + suspend fun getMediaFile(): Result +} + +@AssistedInject +class DefaultVoiceMessageMediaRepo( + @CacheDirectory private val cacheDir: File, + mxcTools: MxcTools, + private val matrixMediaLoader: MatrixMediaLoader, + @Assisted private val mediaSource: MediaSource, + @Assisted("mimeType") private val mimeType: String?, + @Assisted("filename") private val filename: String?, +) : VoiceMessageMediaRepo { + @ContributesBinding(RoomScope::class) + @AssistedFactory + fun interface Factory : VoiceMessageMediaRepo.Factory { + override fun create( + mediaSource: MediaSource, + @Assisted("mimeType") mimeType: String?, + @Assisted("filename") filename: String?, + ): DefaultVoiceMessageMediaRepo + } + + override suspend fun getMediaFile(): Result = when { + cachedFile == null -> Result.failure(IllegalStateException("Invalid mxcUri.")) + cachedFile.exists() -> Result.success(cachedFile) + else -> matrixMediaLoader.downloadMediaFile( + source = mediaSource, + mimeType = mimeType, + filename = filename, + ).mapCatchingExceptions { + it.use { mediaFile -> + val dest = cachedFile.apply { parentFile?.mkdirs() } + if (mediaFile.persist(dest.path)) { + dest + } else { + error("Failed to move file to cache.") + } + } + } + } + + private val cachedFile: File? = mxcTools.mxcUri2FilePath(mediaSource.url)?.let { + File("${cacheDir.path}/$CACHE_VOICE_SUBDIR/$it") + } +} + +/** + * Subdirectory of the application's cache directory where voice messages are stored. + */ +private const val CACHE_VOICE_SUBDIR = "temp/voice" diff --git a/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePlayer.kt b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePlayer.kt new file mode 100644 index 0000000..e45ae1c --- /dev/null +++ b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePlayer.kt @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voiceplayer.impl + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.mapCatchingExceptions +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.mediaplayer.api.MediaPlayer +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.update +import java.io.File + +/** + * A media player specialized in playing a single voice message. + */ +interface VoiceMessagePlayer { + fun interface Factory { + /** + * Creates a [VoiceMessagePlayer]. + * + * NB: Different voice messages can use the same content uri (e.g. in case of + * a forward of a voice message), + * therefore the mxc:// uri in [mediaSource] is not enough to uniquely identify + * a voice message. This is why we must provide the eventId as well. + * + * @param eventId The eventId of the voice message event. + * @param mediaSource The media source of the voice message. + * @param mimeType The mime type of the voice message. + * @param filename The filename of the voice message. + */ + fun create( + eventId: EventId?, + mediaSource: MediaSource, + mimeType: String?, + filename: String?, + ): VoiceMessagePlayer + } + + /** + * The current state of this player. + */ + val state: Flow + + /** + * Acquires control of the underlying [MediaPlayer] and prepares it + * to play the media file. + * + * Will suspend whilst the media file is being downloaded and/or + * the underlying [MediaPlayer] is loading the media file. + */ + suspend fun prepare(): Result + + /** + * Play the media. + */ + fun play() + + /** + * Pause playback. + */ + fun pause() + + /** + * Seek to a specific position. + * + * @param positionMs The position in milliseconds. + */ + fun seekTo(positionMs: Long) + + data class State( + /** + * Whether the player is ready to play. + */ + val isReady: Boolean, + /** + * Whether this player is currently playing. + */ + val isPlaying: Boolean, + /** + * Whether the player has reached the end of the media. + */ + val isEnded: Boolean, + /** + * The elapsed time of this player in milliseconds. + */ + val currentPosition: Long, + /** + * The duration of the current content, if available. + */ + val duration: Long?, + ) +} + +/** + * An implementation of [VoiceMessagePlayer] which is backed by a + * [VoiceMessageMediaRepo] to fetch and cache the media file and + * which uses a global [MediaPlayer] instance to play the media. + */ +class DefaultVoiceMessagePlayer( + private val mediaPlayer: MediaPlayer, + voiceMessageMediaRepoFactory: VoiceMessageMediaRepo.Factory, + private val eventId: EventId?, + mediaSource: MediaSource, + mimeType: String?, + filename: String?, +) : VoiceMessagePlayer { + @ContributesBinding(RoomScope::class) // Scoped types can't use @Inject. + class Factory( + private val mediaPlayer: MediaPlayer, + private val voiceMessageMediaRepoFactory: VoiceMessageMediaRepo.Factory, + ) : VoiceMessagePlayer.Factory { + override fun create( + eventId: EventId?, + mediaSource: MediaSource, + mimeType: String?, + filename: String?, + ): DefaultVoiceMessagePlayer = DefaultVoiceMessagePlayer( + mediaPlayer = mediaPlayer, + voiceMessageMediaRepoFactory = voiceMessageMediaRepoFactory, + eventId = eventId, + mediaSource = mediaSource, + mimeType = mimeType, + filename = filename, + ) + } + + private val repo = voiceMessageMediaRepoFactory.create( + mediaSource = mediaSource, + mimeType = mimeType, + filename = filename, + ) + + private var internalState = MutableStateFlow( + VoiceMessagePlayer.State( + isReady = false, + isPlaying = false, + isEnded = false, + currentPosition = 0L, + duration = null + ) + ) + + override val state: Flow = combine(mediaPlayer.state, internalState) { mediaPlayerState, internalState -> + if (mediaPlayerState.isMyTrack) { + this.internalState.update { + it.copy( + isReady = mediaPlayerState.isReady, + isPlaying = mediaPlayerState.isPlaying, + isEnded = mediaPlayerState.isEnded, + currentPosition = mediaPlayerState.currentPosition, + duration = mediaPlayerState.duration, + ) + } + } else { + this.internalState.update { + it.copy( + isReady = false, + isPlaying = false, + ) + } + } + VoiceMessagePlayer.State( + isReady = internalState.isReady, + isPlaying = internalState.isPlaying, + isEnded = internalState.isEnded, + currentPosition = internalState.currentPosition, + duration = internalState.duration, + ) + }.distinctUntilChanged() + + override suspend fun prepare(): Result = if (eventId != null) { + repo.getMediaFile().mapCatchingExceptions { mediaFile -> + val state = internalState.value + mediaPlayer.setMedia( + uri = mediaFile.path, + mediaId = eventId.value, + // Files in the voice cache have no extension so we need to set the mime type manually. + mimeType = MimeTypes.Ogg, + startPositionMs = if (state.isEnded) 0L else state.currentPosition, + ) + } + } else { + Result.failure(IllegalStateException("Cannot acquireControl on a voice message with no eventId")) + } + + override fun play() { + if (inControl()) { + mediaPlayer.play() + } + } + + override fun pause() { + if (inControl()) { + mediaPlayer.pause() + } + } + + override fun seekTo(positionMs: Long) { + if (inControl()) { + mediaPlayer.seekTo(positionMs) + } else { + internalState.update { + it.copy(currentPosition = positionMs) + } + } + } + + private val MediaPlayer.State.isMyTrack: Boolean + get() = if (eventId == null) false else this.mediaId == eventId.value + + private fun inControl(): Boolean = mediaPlayer.state.value.let { + it.isMyTrack && (it.isReady || it.isEnded) + } +} diff --git a/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenter.kt b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenter.kt new file mode 100644 index 0000000..5dddc18 --- /dev/null +++ b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenter.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voiceplayer.impl + +import androidx.compose.runtime.Composable +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 io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.core.extensions.flatMap +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.ui.utils.time.formatShort +import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents +import io.element.android.libraries.voiceplayer.api.VoiceMessageException +import io.element.android.libraries.voiceplayer.api.VoiceMessageState +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +class VoiceMessagePresenter( + private val analyticsService: AnalyticsService, + private val sessionCoroutineScope: CoroutineScope, + private val player: VoiceMessagePlayer, + private val eventId: EventId?, + private val duration: Duration, +) : Presenter { + private val play = mutableStateOf>(AsyncData.Uninitialized) + + @Composable + override fun present(): VoiceMessageState { + val playerState by player.state.collectAsState( + VoiceMessagePlayer.State( + isReady = false, + isPlaying = false, + isEnded = false, + currentPosition = 0L, + duration = null + ) + ) + + val button by remember { + derivedStateOf { + when { + eventId == null -> VoiceMessageState.Button.Disabled + playerState.isPlaying -> VoiceMessageState.Button.Pause + play.value is AsyncData.Loading -> VoiceMessageState.Button.Downloading + play.value is AsyncData.Failure -> VoiceMessageState.Button.Retry + else -> VoiceMessageState.Button.Play + } + } + } + val duration by remember { + derivedStateOf { playerState.duration ?: duration.inWholeMilliseconds } + } + val progress by remember { + derivedStateOf { + playerState.currentPosition / duration.toFloat() + } + } + val time by remember { + derivedStateOf { + when { + playerState.isReady && !playerState.isEnded -> playerState.currentPosition + playerState.currentPosition > 0 -> playerState.currentPosition + else -> duration + }.milliseconds.formatShort() + } + } + val showCursor by remember { + derivedStateOf { + !play.value.isUninitialized() && !playerState.isEnded + } + } + + fun handleEvent(event: VoiceMessageEvents) { + when (event) { + is VoiceMessageEvents.PlayPause -> { + if (playerState.isPlaying) { + player.pause() + } else if (playerState.isReady) { + player.play() + } else { + sessionCoroutineScope.launch { + play.runUpdatingState( + errorTransform = { + analyticsService.trackError( + VoiceMessageException.PlayMessageError("Error while trying to play voice message", it) + ) + it + }, + ) { + player.prepare().flatMap { + runCatchingExceptions { player.play() } + } + } + } + } + } + is VoiceMessageEvents.Seek -> { + player.seekTo((event.percentage * duration).toLong()) + } + } + } + + return VoiceMessageState( + button = button, + progress = progress, + time = time, + showCursor = showCursor, + eventSink = ::handleEvent, + ) + } +} diff --git a/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessageMediaRepoTest.kt b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessageMediaRepoTest.kt new file mode 100644 index 0000000..5df259f --- /dev/null +++ b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessageMediaRepoTest.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voiceplayer.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader +import io.element.android.libraries.matrix.test.mxc.FakeMxcTools +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class DefaultVoiceMessageMediaRepoTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun `cache miss - downloads and returns cached file successfully`() = runTest { + val matrixMediaLoader = FakeMatrixMediaLoader().apply { + path = temporaryFolder.createRustMediaFile().path + } + val repo = createDefaultVoiceMessageMediaRepo( + temporaryFolder = temporaryFolder, + matrixMediaLoader = matrixMediaLoader, + ) + + repo.getMediaFile().let { result -> + assertThat(result.isSuccess).isTrue() + result.getOrThrow().let { file -> + assertThat(file.path).isEqualTo(temporaryFolder.cachedFilePath) + assertThat(file.exists()).isTrue() + } + } + } + + @Test + fun `cache miss - download fails`() = runTest { + val matrixMediaLoader = FakeMatrixMediaLoader().apply { + shouldFail = true + } + val repo = createDefaultVoiceMessageMediaRepo( + temporaryFolder = temporaryFolder, + matrixMediaLoader = matrixMediaLoader, + ) + + repo.getMediaFile().let { result -> + assertThat(result.isFailure).isTrue() + result.exceptionOrNull()!!.let { exception -> + assertThat(exception).isInstanceOf(RuntimeException::class.java) + } + } + } + + @Test + fun `cache miss - download succeeds but file move fails`() = runTest { + val matrixMediaLoader = FakeMatrixMediaLoader().apply { + path = temporaryFolder.createRustMediaFile().path + } + File(temporaryFolder.cachedFilePath).apply { + parentFile?.mkdirs() + // Deny access to parent folder so move to cache will fail. + parentFile?.setReadable(false) + parentFile?.setWritable(false) + parentFile?.setExecutable(false) + } + val repo = createDefaultVoiceMessageMediaRepo( + temporaryFolder = temporaryFolder, + matrixMediaLoader = matrixMediaLoader, + ) + + repo.getMediaFile().let { result -> + assertThat(result.isFailure).isTrue() + result.exceptionOrNull()?.let { exception -> + assertThat(exception).apply { + isInstanceOf(IllegalStateException::class.java) + hasMessageThat().isEqualTo("Failed to move file to cache.") + } + } + } + } + + @Test + fun `cache hit - returns cached file successfully`() = runTest { + temporaryFolder.createCachedFile() + val matrixMediaLoader = FakeMatrixMediaLoader().apply { + shouldFail = true // so that if we hit the media loader it will crash + } + val repo = createDefaultVoiceMessageMediaRepo( + temporaryFolder = temporaryFolder, + matrixMediaLoader = matrixMediaLoader, + ) + + repo.getMediaFile().let { result -> + assertThat(result.isSuccess).isTrue() + result.getOrThrow().let { file -> + assertThat(file.path).isEqualTo(temporaryFolder.cachedFilePath) + assertThat(file.exists()).isTrue() + } + } + } + + @Test + fun `invalid mxc uri returns a failure`() = runTest { + val repo = createDefaultVoiceMessageMediaRepo( + temporaryFolder = temporaryFolder, + mxcUri = INVALID_MXC_URI, + ) + repo.getMediaFile().let { result -> + assertThat(result.isFailure).isTrue() + result.exceptionOrNull()!!.let { exception -> + assertThat(exception).isInstanceOf(RuntimeException::class.java) + assertThat(exception).hasMessageThat().isEqualTo("Invalid mxcUri.") + } + } + } +} + +private fun createDefaultVoiceMessageMediaRepo( + temporaryFolder: TemporaryFolder, + matrixMediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(), + mxcUri: String = MXC_URI, +) = DefaultVoiceMessageMediaRepo( + cacheDir = temporaryFolder.root, + mxcTools = FakeMxcTools(), + matrixMediaLoader = matrixMediaLoader, + mediaSource = MediaSource( + url = mxcUri, + json = null + ), + mimeType = MimeTypes.Ogg, + filename = "someBody.ogg" +) + +private const val MXC_URI = "mxc://matrix.org/1234567890abcdefg" +private const val INVALID_MXC_URI = "notAnMxcUri" +private val TemporaryFolder.cachedFilePath get() = "${this.root.path}/temp/voice/matrix.org/1234567890abcdefg" +private fun TemporaryFolder.createCachedFile() = File(cachedFilePath).apply { + parentFile?.mkdirs() + createNewFile() +} + +private fun TemporaryFolder.createRustMediaFile() = File(this.root, "rustMediaFile.ogg").apply { createNewFile() } diff --git a/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessagePlayerTest.kt b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessagePlayerTest.kt new file mode 100644 index 0000000..d400506 --- /dev/null +++ b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessagePlayerTest.kt @@ -0,0 +1,318 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voiceplayer.impl + +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.mediaplayer.api.MediaPlayer +import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultVoiceMessagePlayerTest { + @Test + fun `initial state`() = runTest { + createDefaultVoiceMessagePlayer().state.test { + matchInitialState() + } + } + + @Test + fun `prepare succeeds`() = runTest { + val player = createDefaultVoiceMessagePlayer() + player.state.test { + matchInitialState() + assertThat(player.prepare().isSuccess).isTrue() + matchReadyState() + } + } + + @Test + fun `prepare fails when repo fails`() = runTest { + val player = createDefaultVoiceMessagePlayer( + voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { + shouldFail = true + }, + ) + player.state.test { + matchInitialState() + assertThat(player.prepare().isFailure).isTrue() + } + } + + @Test + fun `prepare fails with no eventId`() = runTest { + val player = createDefaultVoiceMessagePlayer( + eventId = null + ) + player.state.test { + matchInitialState() + assertThat(player.prepare().isFailure).isTrue() + } + } + + @Test + fun `play after prepare works`() = runTest { + val player = createDefaultVoiceMessagePlayer() + player.state.test { + matchInitialState() + assertThat(player.prepare().isSuccess).isTrue() + matchReadyState() + player.play() + awaitItem().let { + assertThat(it.isPlaying).isTrue() + assertThat(it.currentPosition).isEqualTo(1000) + } + } + } + + @Test + fun `play reaches end of media`() = runTest { + val player = createDefaultVoiceMessagePlayer( + mediaPlayer = FakeMediaPlayer( + fakeTotalDurationMs = 1000, + fakePlayedDurationMs = 1000 + ) + ) + player.state.test { + matchInitialState() + assertThat(player.prepare().isSuccess).isTrue() + matchReadyState(fakeTotalDurationMs = 1000) + player.play() + awaitItem().let { + assertThat(it.isReady).isFalse() + assertThat(it.isPlaying).isFalse() + assertThat(it.isEnded).isTrue() + assertThat(it.currentPosition).isEqualTo(1000) + assertThat(it.duration).isEqualTo(1000) + } + } + } + + @Test + fun `player1 plays again after both player1 and player2 are finished`() = runTest { + val mediaPlayer = FakeMediaPlayer( + fakeTotalDurationMs = 1_000L, + fakePlayedDurationMs = 1_000L, + ) + val player1 = createDefaultVoiceMessagePlayer(mediaPlayer = mediaPlayer) + val player2 = createDefaultVoiceMessagePlayer(mediaPlayer = mediaPlayer) + + // Play player1 until the end. + player1.state.test { + matchInitialState() + assertThat(player1.prepare().isSuccess).isTrue() + matchReadyState(1_000L) + player1.play() + awaitItem().let { + // it plays until the end. + assertThat(it.isReady).isFalse() + assertThat(it.isPlaying).isFalse() + assertThat(it.isEnded).isTrue() + assertThat(it.currentPosition).isEqualTo(1000) + assertThat(it.duration).isEqualTo(1000) + } + } + + // Play player2 until the end. + player2.state.test { + matchInitialState() + assertThat(player2.prepare().isSuccess).isTrue() + awaitItem().let { + // Additional spurious state due to MediaPlayer owner change. + assertThat(it.isReady).isFalse() + assertThat(it.isPlaying).isFalse() + assertThat(it.isEnded).isTrue() + assertThat(it.currentPosition).isEqualTo(1000) + assertThat(it.duration).isEqualTo(1000) + } + awaitItem().let { + // Additional spurious state due to MediaPlayer owner change. + assertThat(it.isReady).isFalse() + assertThat(it.isPlaying).isFalse() + assertThat(it.isEnded).isFalse() + assertThat(it.currentPosition).isEqualTo(0) + assertThat(it.duration).isNull() + } + matchReadyState(1_000L) + player2.play() + awaitItem().let { + // it plays until the end. + assertThat(it.isReady).isFalse() + assertThat(it.isPlaying).isFalse() + assertThat(it.isEnded).isTrue() + assertThat(it.currentPosition).isEqualTo(1000) + assertThat(it.duration).isEqualTo(1000) + } + } + + // Play player1 again. + player1.state.test { + awaitItem().let { + // Last previous state/ + assertThat(it.isReady).isFalse() + assertThat(it.isPlaying).isFalse() + assertThat(it.isEnded).isTrue() + assertThat(it.currentPosition).isEqualTo(1000) + assertThat(it.duration).isEqualTo(1000) + } + assertThat(player1.prepare().isSuccess).isTrue() + awaitItem().let { + // Additional spurious state due to MediaPlayer owner change. + assertThat(it.isReady).isFalse() + assertThat(it.isPlaying).isFalse() + assertThat(it.isEnded).isFalse() + assertThat(it.currentPosition).isEqualTo(0) + assertThat(it.duration).isNull() + } + matchReadyState(1_000L) + player1.play() + awaitItem().let { + // it played again until the end. + assertThat(it.isReady).isFalse() + assertThat(it.isPlaying).isFalse() + assertThat(it.isEnded).isTrue() + assertThat(it.currentPosition).isEqualTo(1000) + assertThat(it.duration).isEqualTo(1000) + } + } + } + + @Test + fun `pause after play pauses`() = runTest { + val player = createDefaultVoiceMessagePlayer() + player.state.test { + matchInitialState() + assertThat(player.prepare().isSuccess).isTrue() + matchReadyState() + player.play() + // skip play state + skipItems(1) + player.pause() + awaitItem().let { + assertThat(it.isPlaying).isFalse() + assertThat(it.currentPosition).isEqualTo(1000) + } + } + } + + @Test + fun `play after pause works`() = runTest { + val player = createDefaultVoiceMessagePlayer() + player.state.test { + matchInitialState() + assertThat(player.prepare().isSuccess).isTrue() + matchReadyState() + player.play() + // skip play state + skipItems(1) + player.pause() + // skip pause state + skipItems(1) + player.play() + awaitItem().let { + assertThat(it.isPlaying).isTrue() + assertThat(it.currentPosition).isEqualTo(2000) + } + } + } + + @Test + fun `seek before prepare works`() = runTest { + val player = createDefaultVoiceMessagePlayer() + player.state.test { + matchInitialState() + player.seekTo(2000) + awaitItem().let { + assertThat(it.isReady).isFalse() + assertThat(it.isPlaying).isFalse() + assertThat(it.isEnded).isFalse() + assertThat(it.currentPosition).isEqualTo(2000) + assertThat(it.duration).isNull() + } + assertThat(player.prepare().isSuccess).isTrue() + awaitItem().let { + assertThat(it.isReady).isTrue() + assertThat(it.isPlaying).isFalse() + assertThat(it.isEnded).isFalse() + assertThat(it.currentPosition).isEqualTo(2000) + assertThat(it.duration).isEqualTo(FAKE_TOTAL_DURATION_MS) + } + } + } + + @Test + fun `seek after prepare works`() = runTest { + val player = createDefaultVoiceMessagePlayer() + player.state.test { + matchInitialState() + assertThat(player.prepare().isSuccess).isTrue() + matchReadyState() + player.seekTo(2000) + awaitItem().let { + assertThat(it.isReady).isTrue() + assertThat(it.isPlaying).isFalse() + assertThat(it.isEnded).isFalse() + assertThat(it.currentPosition).isEqualTo(2000) + assertThat(it.duration).isEqualTo(FAKE_TOTAL_DURATION_MS) + } + } + } +} + +private const val FAKE_TOTAL_DURATION_MS = 10_000L +private const val FAKE_PLAYED_DURATION_MS = 1000L + +private fun createDefaultVoiceMessagePlayer( + mediaPlayer: MediaPlayer = FakeMediaPlayer( + fakeTotalDurationMs = FAKE_TOTAL_DURATION_MS, + fakePlayedDurationMs = FAKE_PLAYED_DURATION_MS + ), + voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(), + eventId: EventId? = AN_EVENT_ID, +) = DefaultVoiceMessagePlayer( + mediaPlayer = mediaPlayer, + voiceMessageMediaRepoFactory = { _, _, _ -> voiceMessageMediaRepo }, + eventId = eventId, + mediaSource = MediaSource( + url = MXC_URI, + json = null + ), + mimeType = MimeTypes.Ogg, + filename = "someBody.ogg" +) + +private const val MXC_URI = "mxc://matrix.org/1234567890abcdefg" + +private suspend fun TurbineTestContext.matchInitialState() { + awaitItem().let { + assertThat(it.isReady).isFalse() + assertThat(it.isPlaying).isFalse() + assertThat(it.isEnded).isFalse() + assertThat(it.currentPosition).isEqualTo(0) + assertThat(it.duration).isNull() + } +} + +private suspend fun TurbineTestContext.matchReadyState( + fakeTotalDurationMs: Long = FAKE_TOTAL_DURATION_MS, +) { + awaitItem().let { + assertThat(it.isReady).isTrue() + assertThat(it.isPlaying).isFalse() + assertThat(it.isEnded).isFalse() + assertThat(it.currentPosition).isEqualTo(0) + assertThat(it.duration).isEqualTo(fakeTotalDurationMs) + } +} diff --git a/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/FakeVoiceMessageMediaRepo.kt b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/FakeVoiceMessageMediaRepo.kt new file mode 100644 index 0000000..e94f475 --- /dev/null +++ b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/FakeVoiceMessageMediaRepo.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voiceplayer.impl + +import io.element.android.tests.testutils.simulateLongTask +import java.io.File + +/** + * A fake implementation of [VoiceMessageMediaRepo] for testing purposes. + */ +class FakeVoiceMessageMediaRepo : VoiceMessageMediaRepo { + var shouldFail = false + + override suspend fun getMediaFile(): Result = simulateLongTask { + if (shouldFail) { + Result.failure(IllegalStateException("Failed to get media file")) + } else { + Result.success(File("")) + } + } +} diff --git a/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenterTest.kt b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenterTest.kt new file mode 100644 index 0000000..642a55d --- /dev/null +++ b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenterTest.kt @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voiceplayer.impl + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer +import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents +import io.element.android.libraries.voiceplayer.api.VoiceMessageException +import io.element.android.libraries.voiceplayer.api.VoiceMessageState +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.test.FakeAnalyticsService +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +class VoiceMessagePresenterTest { + @Test + fun `initial state has proper default values`() = runTest { + val presenter = createVoiceMessagePresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().let { + assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + assertThat(it.progress).isEqualTo(0f) + assertThat(it.time).isEqualTo("1:01") + } + } + } + + @Test + fun `pressing play downloads and plays`() = runTest { + val presenter = createVoiceMessagePresenter( + mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000), + duration = 2_000.milliseconds, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem().also { + assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + assertThat(it.progress).isEqualTo(0f) + assertThat(it.time).isEqualTo("0:02") + } + + initialState.eventSink(VoiceMessageEvents.PlayPause) + + awaitItem().also { + assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading) + assertThat(it.progress).isEqualTo(0f) + assertThat(it.time).isEqualTo("0:02") + } + awaitItem().also { + assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading) + assertThat(it.progress).isEqualTo(0f) + assertThat(it.time).isEqualTo("0:00") + } + awaitItem().also { + assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) + assertThat(it.progress).isEqualTo(0.5f) + assertThat(it.time).isEqualTo("0:01") + } + } + } + + @Test + fun `pressing play downloads and fails`() = runTest { + val analyticsService = FakeAnalyticsService() + val presenter = createVoiceMessagePresenter( + mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000), + voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { shouldFail = true }, + analyticsService = analyticsService, + duration = 2_000.milliseconds, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem().also { + assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + assertThat(it.progress).isEqualTo(0f) + assertThat(it.time).isEqualTo("0:02") + } + + initialState.eventSink(VoiceMessageEvents.PlayPause) + + awaitItem().also { + assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading) + assertThat(it.progress).isEqualTo(0f) + assertThat(it.time).isEqualTo("0:02") + } + awaitItem().also { + assertThat(it.button).isEqualTo(VoiceMessageState.Button.Retry) + assertThat(it.progress).isEqualTo(0f) + assertThat(it.time).isEqualTo("0:02") + } + analyticsService.trackedErrors.first().also { + assertThat(it).apply { + isInstanceOf(VoiceMessageException.PlayMessageError::class.java) + hasMessageThat().isEqualTo("Error while trying to play voice message") + } + } + } + } + + @Test + fun `pressing pause while playing pauses`() = runTest { + val presenter = createVoiceMessagePresenter( + mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000), + duration = 2_000.milliseconds, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem().also { + assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + assertThat(it.progress).isEqualTo(0f) + assertThat(it.time).isEqualTo("0:02") + } + + initialState.eventSink(VoiceMessageEvents.PlayPause) + skipItems(2) // skip downloading states + + val playingState = awaitItem().also { + assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) + assertThat(it.progress).isEqualTo(0.5f) + assertThat(it.time).isEqualTo("0:01") + } + + playingState.eventSink(VoiceMessageEvents.PlayPause) + awaitItem().also { + assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + assertThat(it.progress).isEqualTo(0.5f) + assertThat(it.time).isEqualTo("0:01") + } + } + } + + @Test + fun `content with null eventId shows disabled button`() = runTest { + val presenter = createVoiceMessagePresenter( + eventId = null, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().also { + assertThat(it.button).isEqualTo(VoiceMessageState.Button.Disabled) + assertThat(it.progress).isEqualTo(0f) + assertThat(it.time).isEqualTo("1:01") + } + } + } + + @Test + fun `seeking before play`() = runTest { + val presenter = createVoiceMessagePresenter( + mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000), + duration = 10_000.milliseconds, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem().also { + assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + assertThat(it.progress).isEqualTo(0f) + assertThat(it.time).isEqualTo("0:10") + } + + initialState.eventSink(VoiceMessageEvents.Seek(0.5f)) + + awaitItem().also { + assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + assertThat(it.progress).isEqualTo(0.5f) + assertThat(it.time).isEqualTo("0:05") + } + } + } + + @Test + fun `seeking after play`() = runTest { + val presenter = createVoiceMessagePresenter( + duration = 10_000.milliseconds, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem().also { + assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + assertThat(it.progress).isEqualTo(0f) + assertThat(it.time).isEqualTo("0:10") + } + + initialState.eventSink(VoiceMessageEvents.PlayPause) + + skipItems(2) // skip downloading states + + awaitItem().also { + assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) + assertThat(it.progress).isEqualTo(0.1f) + assertThat(it.time).isEqualTo("0:01") + } + + initialState.eventSink(VoiceMessageEvents.Seek(0.5f)) + + awaitItem().also { + assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) + assertThat(it.progress).isEqualTo(0.5f) + assertThat(it.time).isEqualTo("0:05") + } + } + } +} + +fun TestScope.createVoiceMessagePresenter( + mediaPlayer: FakeMediaPlayer = FakeMediaPlayer(), + voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(), + analyticsService: AnalyticsService = FakeAnalyticsService(), + eventId: EventId? = EventId("\$anEventId"), + filename: String = "filename doesn't really matter for a voice message", + duration: Duration = 61_000.milliseconds, + contentUri: String = "mxc://matrix.org/1234567890abcdefg", + mimeType: String = MimeTypes.Ogg, + mediaSource: MediaSource = MediaSource(contentUri), +) = VoiceMessagePresenter( + analyticsService = analyticsService, + sessionCoroutineScope = this, + player = DefaultVoiceMessagePlayer( + mediaPlayer = mediaPlayer, + voiceMessageMediaRepoFactory = { _, _, _ -> voiceMessageMediaRepo }, + eventId = eventId, + mediaSource = mediaSource, + mimeType = mimeType, + filename = filename + ), + eventId = eventId, + duration = duration, +) diff --git a/libraries/voicerecorder/api/build.gradle.kts b/libraries/voicerecorder/api/build.gradle.kts new file mode 100644 index 0000000..6490762 --- /dev/null +++ b/libraries/voicerecorder/api/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.voicerecorder.api" +} + +dependencies { + implementation(libs.androidx.annotationjvm) + implementation(libs.coroutines.core) +} diff --git a/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorder.kt b/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorder.kt new file mode 100644 index 0000000..3427f69 --- /dev/null +++ b/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorder.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.api + +import android.Manifest +import androidx.annotation.RequiresPermission +import kotlinx.coroutines.flow.StateFlow + +/** + * Audio recorder which records audio to opus/ogg files. + */ +interface VoiceRecorder { + /** + * Start a recording. + * + * Call [stopRecord] to stop the recording and release resources. + */ + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + suspend fun startRecord() + + /** + * Stop the current recording. + * + * Call [deleteRecording] to delete any recorded audio. + * + * @param cancelled If true, the recording is deleted. + */ + suspend fun stopRecord( + cancelled: Boolean = false + ) + + /** + * Stop the current recording and delete the output file. + */ + suspend fun deleteRecording() + + /** + * The current state of the recorder. + */ + val state: StateFlow +} diff --git a/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorderState.kt b/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorderState.kt new file mode 100644 index 0000000..0a68406 --- /dev/null +++ b/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorderState.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.api + +import androidx.compose.runtime.Immutable +import java.io.File +import kotlin.time.Duration + +@Immutable +sealed interface VoiceRecorderState { + /** + * The recorder is idle and not recording. + */ + data object Idle : VoiceRecorderState + + /** + * The recorder is currently recording. + * + * @property elapsedTime The elapsed time since the recording started. + * @property levels The current audio levels of the recording as a fraction of 1. All values are between 0 and 1. + */ + data class Recording( + val elapsedTime: Duration, + val levels: List, + ) : VoiceRecorderState + + /** + * The recorder has finished recording. + * + * @property file The recorded file. + * @property mimeType The mime type of the file. + * @property waveform The waveform of the recording. All values are between 0 and 1. + * @property duration The total time spent recording. + */ + data class Finished( + val file: File, + val mimeType: String, + val waveform: List, + val duration: Duration, + ) : VoiceRecorderState +} diff --git a/libraries/voicerecorder/impl/build.gradle.kts b/libraries/voicerecorder/impl/build.gradle.kts new file mode 100644 index 0000000..f737ca1 --- /dev/null +++ b/libraries/voicerecorder/impl/build.gradle.kts @@ -0,0 +1,35 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.voicerecorder.impl" +} + +setupDependencyInjection() + +dependencies { + api(projects.libraries.voicerecorder.api) + api(libs.opusencoder) + + implementation(projects.appconfig) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + + implementation(libs.androidx.annotationjvm) + implementation(libs.coroutines.core) + + testCommonDependencies(libs) + testImplementation(libs.coroutines.core) +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorder.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorder.kt new file mode 100644 index 0000000..5e4c9ae --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorder.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.impl + +import android.Manifest +import androidx.annotation.RequiresPermission +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.appconfig.VoiceMessageConfig +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.coroutine.childScope +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.voicerecorder.api.VoiceRecorder +import io.element.android.libraries.voicerecorder.api.VoiceRecorderState +import io.element.android.libraries.voicerecorder.impl.audio.Audio +import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig +import io.element.android.libraries.voicerecorder.impl.audio.AudioLevelCalculator +import io.element.android.libraries.voicerecorder.impl.audio.AudioReader +import io.element.android.libraries.voicerecorder.impl.audio.Encoder +import io.element.android.libraries.voicerecorder.impl.audio.resample +import io.element.android.libraries.voicerecorder.impl.file.VoiceFileConfig +import io.element.android.libraries.voicerecorder.impl.file.VoiceFileManager +import kotlinx.coroutines.CoroutineScope +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.yield +import timber.log.Timber +import java.io.File +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeSource + +@SingleIn(RoomScope::class) +@ContributesBinding(RoomScope::class) +class DefaultVoiceRecorder( + private val dispatchers: CoroutineDispatchers, + private val timeSource: TimeSource, + private val audioReaderFactory: AudioReader.Factory, + private val encoder: Encoder, + private val fileManager: VoiceFileManager, + private val config: AudioConfig, + private val fileConfig: VoiceFileConfig, + private val audioLevelCalculator: AudioLevelCalculator, + @SessionCoroutineScope + sessionCoroutineScope: CoroutineScope, +) : VoiceRecorder { + private val voiceCoroutineScope by lazy { + sessionCoroutineScope.childScope(dispatchers.io, "VoiceRecorder-${UUID.randomUUID()}") + } + + private var outputFile: File? = null + private var audioReader: AudioReader? = null + private var recordingJob: Job? = null + + // List of Float between 0 and 1 representing the audio levels + private val levels: MutableList = mutableListOf() + private val lock = Mutex() + + private val _state = MutableStateFlow(VoiceRecorderState.Idle) + override val state: StateFlow = _state + + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + override suspend fun startRecord() { + Timber.i("Voice recorder started recording") + outputFile = fileManager.createFile() + .also(encoder::init) + + lock.withLock { + levels.clear() + } + + val audioRecorder = audioReaderFactory.create(config, dispatchers).also { audioReader = it } + + recordingJob = voiceCoroutineScope.launch { + val startedAt = timeSource.markNow() + audioRecorder.record { audio -> + yield() + + val elapsedTime = startedAt.elapsedNow() + + if (elapsedTime > VoiceMessageConfig.maxVoiceMessageDuration) { + Timber.w("Voice message time limit reached") + stopRecord(false) + return@record + } + + when (audio) { + is Audio.Data -> { + val audioLevel = audioLevelCalculator.calculateAudioLevel(audio.buffer) + + lock.withLock { + levels.add(audioLevel) + _state.emit(VoiceRecorderState.Recording(elapsedTime, levels.toList())) + } + encoder.encode(audio.buffer, audio.readSize) + } + is Audio.Error -> { + Timber.e("Voice message error: code=${audio.audioRecordErrorCode}") + _state.emit(VoiceRecorderState.Recording(elapsedTime, listOf())) + } + } + } + } + } + + /** + * Stop the current recording. + * + * Call [deleteRecording] to delete any recorded audio. + */ + override suspend fun stopRecord( + cancelled: Boolean + ) { + recordingJob?.cancel()?.also { + Timber.i("Voice recorder stopped recording") + } + recordingJob = null + + audioReader?.stop() + audioReader = null + encoder.release() + + lock.withLock { + if (cancelled) { + deleteRecording() + levels.clear() + } + + _state.emit( + when (val file = outputFile) { + null -> VoiceRecorderState.Idle + else -> { + val duration = (state.value as? VoiceRecorderState.Recording)?.elapsedTime + VoiceRecorderState.Finished( + file = file, + mimeType = fileConfig.mimeType, + waveform = levels.resample(100), + duration = duration ?: 0.milliseconds + ) + } + } + ) + } + } + + /** + * Stop the current recording and delete the output file. + */ + override suspend fun deleteRecording() { + outputFile?.let(fileManager::deleteFile)?.also { + Timber.i("Voice recorder deleted recording") + } + outputFile = null + _state.emit(VoiceRecorderState.Idle) + } +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AndroidAudioReader.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AndroidAudioReader.kt new file mode 100644 index 0000000..c4686dc --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AndroidAudioReader.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.impl.audio + +import android.Manifest +import android.media.AudioRecord +import android.media.audiofx.AutomaticGainControl +import android.media.audiofx.NoiseSuppressor +import androidx.annotation.RequiresPermission +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.di.RoomScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext + +class AndroidAudioReader +@RequiresPermission(Manifest.permission.RECORD_AUDIO) +private constructor( + private val config: AudioConfig, + private val dispatchers: CoroutineDispatchers, +) : AudioReader { + private val audioRecord: AudioRecord + private var noiseSuppressor: NoiseSuppressor? = null + private var automaticGainControl: AutomaticGainControl? = null + private val outputBuffer: ShortArray + + init { + outputBuffer = createOutputBuffer(config.sampleRate) + audioRecord = AudioRecord.Builder().setAudioSource(config.source).setAudioFormat(config.format).setBufferSizeInBytes(outputBuffer.sizeInBytes()).build() + noiseSuppressor = requestNoiseSuppressor(audioRecord) + automaticGainControl = requestAutomaticGainControl(audioRecord) + } + + /** + * Record audio data continuously. + * + * @param onAudio callback when audio is read. + */ + override suspend fun record( + onAudio: suspend (Audio) -> Unit, + ) { + audioRecord.startRecording() + withContext(dispatchers.io) { + while (isActive) { + if (audioRecord.recordingState != AudioRecord.RECORDSTATE_RECORDING) { + break + } + onAudio(read()) + } + } + } + + private fun read(): Audio { + val result = audioRecord.read(outputBuffer, 0, outputBuffer.size) + + if (isAudioRecordErrorResult(result)) { + return Audio.Error(result) + } + + return Audio.Data( + result, + outputBuffer, + ) + } + + override fun stop() { + if (audioRecord.state == AudioRecord.STATE_INITIALIZED) { + audioRecord.stop() + } + audioRecord.release() + + noiseSuppressor?.release() + noiseSuppressor = null + + automaticGainControl?.release() + automaticGainControl = null + } + + private fun createOutputBuffer(sampleRate: SampleRate): ShortArray { + val bufferSizeInShorts = AudioRecord.getMinBufferSize( + sampleRate.HZ, + config.format.channelMask, + config.format.encoding + ) + return ShortArray(bufferSizeInShorts) + } + + private fun requestNoiseSuppressor(audioRecord: AudioRecord): NoiseSuppressor? { + if (!NoiseSuppressor.isAvailable()) { + return null + } + + return tryOrNull { + NoiseSuppressor.create(audioRecord.audioSessionId).apply { + enabled = true + } + } + } + + private fun requestAutomaticGainControl(audioRecord: AudioRecord): AutomaticGainControl? { + if (!AutomaticGainControl.isAvailable()) { + return null + } + + return tryOrNull { + AutomaticGainControl.create(audioRecord.audioSessionId).apply { + enabled = true + } + } + } + + @ContributesBinding(RoomScope::class) + companion object Factory : AudioReader.Factory { + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + override fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AndroidAudioReader { + return AndroidAudioReader(config, dispatchers) + } + } +} + +private fun isAudioRecordErrorResult(result: Int): Boolean { + return result < 0 +} + +private fun ShortArray.sizeInBytes(): Int = size * Short.SIZE_BYTES diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Audio.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Audio.kt new file mode 100644 index 0000000..522fe9f --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Audio.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.impl.audio + +sealed interface Audio { + data class Data( + val readSize: Int, + val buffer: ShortArray, + ) : Audio { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Data + + if (readSize != other.readSize) return false + if (!buffer.contentEquals(other.buffer)) return false + + return true + } + + override fun hashCode(): Int { + var result = readSize + result = 31 * result + buffer.contentHashCode() + return result + } + } + + data class Error( + val audioRecordErrorCode: Int + ) : Audio +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioConfig.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioConfig.kt new file mode 100644 index 0000000..350e3f9 --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioConfig.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.impl.audio + +import android.media.AudioFormat +import android.media.MediaRecorder.AudioSource + +/** + * Audio configuration for voice recording. + * + * @property source the audio source to use, see constants in [AudioSource] + * @property format the audio format to use, see [AudioFormat] + * @property sampleRate the sample rate to use. Ensure this matches the value set in [format]. + * @property bitRate the bitrate in bps + */ +data class AudioConfig( + val source: Int, + val format: AudioFormat, + val sampleRate: SampleRate, + val bitRate: Int, +) diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioLevelCalculator.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioLevelCalculator.kt new file mode 100644 index 0000000..2db448a --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioLevelCalculator.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.impl.audio + +import androidx.annotation.FloatRange + +interface AudioLevelCalculator { + /** + * Calculate the audio level of the audio buffer. + * + * @param buffer The audio buffer containing 16bit PCM audio data. + * @return A float value between 0 and 1 proportional to the audio level. + */ + @FloatRange(from = 0.0, to = 1.0) + fun calculateAudioLevel(buffer: ShortArray): Float +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioReader.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioReader.kt new file mode 100644 index 0000000..9f33699 --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/AudioReader.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.impl.audio + +import io.element.android.libraries.core.coroutine.CoroutineDispatchers + +interface AudioReader { + /** + * Record audio data continuously. + * + * @param onAudio callback when audio is read. + */ + suspend fun record( + onAudio: suspend (Audio) -> Unit, + ) + + fun stop() + + interface Factory { + fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AudioReader + } +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DBovAudioLevelCalculator.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DBovAudioLevelCalculator.kt new file mode 100644 index 0000000..12cd42c --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DBovAudioLevelCalculator.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.impl.audio + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.RoomScope +import kotlin.math.log10 +import kotlin.math.sqrt + +/** + * Default implementation of [AudioLevelCalculator]. + * + * It computes the normalized [0;1] dBov value of the given PCM16 encoded [ShortArray]. + * See: https://en.wikipedia.org/wiki/DBFS + */ +@ContributesBinding(RoomScope::class) +class DBovAudioLevelCalculator : AudioLevelCalculator { + override fun calculateAudioLevel(buffer: ShortArray): Float { + return buffer.rms().dBov().normalize().coerceIn(0f, 1f) + } +} + +/** + * Computes the normalized (range 0.0 to 1.0) root mean square + * value of the given PCM16 encoded [ShortArray]. + */ +private fun ShortArray.rms(): Float { + val floats = FloatArray(this.size) { i -> this[i] / Short.MAX_VALUE.toFloat() } + val squared = FloatArray(this.size) { i -> floats[i] * floats[i] } + val sum = squared.fold(0.0f) { acc, f -> acc + f } + val average = sum / this.size + return sqrt(average) +} + +/** + * Converts the given RMS value to decibels relative to overload (dBov). + * It has range [-96.0, 0.0] where 0.0 is the value of a full scale square wave. + */ +private fun Float.dBov(): Float = 20 * log10(this) + +/** + * Normalizes the given dBov value to the range [0.0, 1.0]. + */ +private fun Float.normalize(): Float = (this + DYNAMIC_RANGE_PCM16) / DYNAMIC_RANGE_PCM16 + +private const val DYNAMIC_RANGE_PCM16: Float = 96.0f diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DefaultEncoder.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DefaultEncoder.kt new file mode 100644 index 0000000..9d471f7 --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DefaultEncoder.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.impl.audio + +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Provider +import io.element.android.libraries.di.RoomScope +import io.element.android.opusencoder.OggOpusEncoder +import timber.log.Timber +import java.io.File + +/** + * Safe wrapper for OggOpusEncoder. + */ +@ContributesBinding(RoomScope::class) +class DefaultEncoder( + private val encoderProvider: Provider, + config: AudioConfig, +) : Encoder { + private val bitRate = config.bitRate + private val sampleRate = config.sampleRate.asEncoderModel() + + private var encoder: OggOpusEncoder? = null + override fun init( + file: File, + ) { + encoder?.release() + encoder = encoderProvider().apply { + init(file.absolutePath, sampleRate) + setBitrate(bitRate) + // TODO check encoder application: 2048 (voice, default is typically 2049 as audio) + } + } + + override fun encode( + buffer: ShortArray, + readSize: Int, + ) { + encoder?.encode(buffer, readSize) + ?: Timber.w("Can't encode when encoder not initialized") + } + + override fun release() { + encoder?.release() + encoder = null + } +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Encoder.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Encoder.kt new file mode 100644 index 0000000..0ceb042 --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Encoder.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.impl.audio + +import java.io.File + +interface Encoder { + fun init(file: File) + + fun encode(buffer: ShortArray, readSize: Int) + + fun release() +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Resample.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Resample.kt new file mode 100644 index 0000000..a8bc81e --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/Resample.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.impl.audio + +/** + * Resamples [this] list to [size] using linear interpolation. + */ +fun List.resample(size: Int): List { + require(size > 0) + val input = this + if (input.isEmpty()) return List(size) { 0f } // fast path. + if (input.size == 1) return List(size) { input[0] } // fast path. + if (input.size == size) return this // fast path. + val step: Float = input.size.toFloat() / size.toFloat() + return buildList(size) { + for (i in 0 until size) { + val x0 = (i * step).toInt() + val x1 = (x0 + 1).coerceAtMost(input.size - 1) + val x = i * step - x0 + val y = input[x0] * (1 - x) + input[x1] * x + add(i, y) + } + } +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/SampleRate.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/SampleRate.kt new file mode 100644 index 0000000..c3bfd7c --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/audio/SampleRate.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.impl.audio + +import io.element.android.opusencoder.configuration.SampleRate as LibOpusOggSampleRate + +data object SampleRate { + const val HZ = 48_000 + fun asEncoderModel() = LibOpusOggSampleRate.Rate48kHz +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/di/VoiceRecorderModule.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/di/VoiceRecorderModule.kt new file mode 100644 index 0000000..ee9f7bf --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/di/VoiceRecorderModule.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.impl.di + +import android.media.AudioFormat +import android.media.MediaRecorder +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig +import io.element.android.libraries.voicerecorder.impl.audio.SampleRate +import io.element.android.libraries.voicerecorder.impl.file.VoiceFileConfig +import io.element.android.opusencoder.OggOpusEncoder + +@BindingContainer +@ContributesTo(RoomScope::class) +object VoiceRecorderModule { + @Provides + fun provideAudioConfig(): AudioConfig { + val sampleRate = SampleRate + return AudioConfig( + format = AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setSampleRate(sampleRate.HZ) + .setChannelMask(AudioFormat.CHANNEL_IN_MONO) + .build(), + // 24 kbps + bitRate = 24_000, + sampleRate = sampleRate, + source = MediaRecorder.AudioSource.MIC, + ) + } + + @Provides + public fun provideVoiceFileConfig(): VoiceFileConfig = + VoiceFileConfig( + cacheSubdir = "voice_recordings", + fileExt = "ogg", + mimeType = MimeTypes.Ogg, + ) + + @Provides + fun provideOggOpusEncoder(): OggOpusEncoder = OggOpusEncoder.create() +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/DefaultVoiceFileManager.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/DefaultVoiceFileManager.kt new file mode 100644 index 0000000..316d003 --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/DefaultVoiceFileManager.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.impl.file + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.hash.md5 +import io.element.android.libraries.di.CacheDirectory +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.BaseRoom +import java.io.File +import java.util.UUID + +@ContributesBinding(RoomScope::class) +class DefaultVoiceFileManager( + @CacheDirectory private val cacheDir: File, + private val config: VoiceFileConfig, + room: BaseRoom, +) : VoiceFileManager { + private val roomId: RoomId = room.roomId + + override fun createFile(): File { + val fileName = "${UUID.randomUUID()}.${config.fileExt}" + val outputDirectory = File(cacheDir, config.cacheSubdir) + val roomDir = File(outputDirectory, roomId.value.md5()) + .apply(File::mkdirs) + return File(roomDir, fileName) + } + + override fun deleteFile(file: File) { + file.delete() + } +} diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/VoiceFileConfig.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/VoiceFileConfig.kt new file mode 100644 index 0000000..8b5d778 --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/VoiceFileConfig.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.impl.file + +/** + * File configuration for voice recording. + * + * @property cacheSubdir the subdirectory in the cache dir to use. + * @property fileExt the file extension for audio files. + * @property mimeType the mime type of audio files. + */ +data class VoiceFileConfig( + val cacheSubdir: String, + val fileExt: String, + val mimeType: String, +) diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/VoiceFileManager.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/VoiceFileManager.kt new file mode 100644 index 0000000..81897e0 --- /dev/null +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/file/VoiceFileManager.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.impl.file + +import java.io.File + +interface VoiceFileManager { + fun createFile(): File + + fun deleteFile(file: File) +} diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt new file mode 100644 index 0000000..e3ff6f9 --- /dev/null +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.impl + +import android.media.AudioFormat +import android.media.MediaRecorder +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.appconfig.VoiceMessageConfig +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.voicerecorder.api.VoiceRecorderState +import io.element.android.libraries.voicerecorder.impl.audio.Audio +import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig +import io.element.android.libraries.voicerecorder.impl.audio.SampleRate +import io.element.android.libraries.voicerecorder.impl.di.VoiceRecorderModule +import io.element.android.libraries.voicerecorder.test.FakeAudioLevelCalculator +import io.element.android.libraries.voicerecorder.test.FakeAudioReaderFactory +import io.element.android.libraries.voicerecorder.test.FakeEncoder +import io.element.android.libraries.voicerecorder.test.FakeFileSystem +import io.element.android.libraries.voicerecorder.test.FakeVoiceFileManager +import io.element.android.tests.testutils.testCoroutineDispatchers +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.BeforeClass +import org.junit.Test +import java.io.File +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TestTimeSource + +class DefaultVoiceRecorderTest { + private val fakeFileSystem = FakeFileSystem() + private val timeSource = TestTimeSource() + + @Test + fun `it emits the initial state`() = runTest { + val voiceRecorder = createDefaultVoiceRecorder() + voiceRecorder.state.test { + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle) + } + } + + @Test + fun `when recording, it emits the recording state`() = runTest { + val voiceRecorder = createDefaultVoiceRecorder() + voiceRecorder.state.test { + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle) + + voiceRecorder.startRecord() + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(0.seconds, listOf(1.0f))) + timeSource += 1.seconds + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(1.seconds, listOf())) + timeSource += 1.seconds + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(2.seconds, listOf(1.0f, 1.0f))) + } + } + + @Test + fun `when elapsed time reaches 30 minutes, it stops recording`() = runTest { + val voiceRecorder = createDefaultVoiceRecorder() + voiceRecorder.state.test { + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle) + + voiceRecorder.startRecord() + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(0.minutes, listOf(1.0f))) + timeSource += VoiceMessageConfig.maxVoiceMessageDuration + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(VoiceMessageConfig.maxVoiceMessageDuration, listOf())) + timeSource += 1.milliseconds + + assertThat(awaitItem()).isEqualTo( + VoiceRecorderState.Finished( + file = File(FILE_PATH), + mimeType = MimeTypes.Ogg, + waveform = List(100) { 1f }, + duration = VoiceMessageConfig.maxVoiceMessageDuration, + ) + ) + } + } + + @Test + fun `when stopped, it provides a file and duration`() = runTest { + val voiceRecorder = createDefaultVoiceRecorder() + voiceRecorder.state.test { + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle) + + voiceRecorder.startRecord() + skipItems(1) + timeSource += 5.seconds + skipItems(2) + voiceRecorder.stopRecord() + assertThat(awaitItem()).isEqualTo( + VoiceRecorderState.Finished( + file = File(FILE_PATH), + mimeType = MimeTypes.Ogg, + waveform = List(100) { 1f }, + duration = 5.seconds, + ) + ) + assertThat(fakeFileSystem.files[File(FILE_PATH)]).isEqualTo(ENCODED_DATA) + } + } + + @Test + fun `when cancelled, it deletes the file`() = runTest { + val voiceRecorder = createDefaultVoiceRecorder() + voiceRecorder.state.test { + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle) + + voiceRecorder.startRecord() + skipItems(3) + voiceRecorder.stopRecord(cancelled = true) + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle) + assertThat(fakeFileSystem.files[File(FILE_PATH)]).isNull() + } + } + + private fun TestScope.createDefaultVoiceRecorder(): DefaultVoiceRecorder { + val fileConfig = VoiceRecorderModule.provideVoiceFileConfig() + return DefaultVoiceRecorder( + dispatchers = testCoroutineDispatchers(), + timeSource = timeSource, + audioReaderFactory = FakeAudioReaderFactory( + audio = AUDIO, + ), + encoder = FakeEncoder(fakeFileSystem), + config = AudioConfig( + format = audioFormat, + // 24 kbps + bitRate = 24_000, + sampleRate = SampleRate, + source = MediaRecorder.AudioSource.MIC, + ), + fileConfig = fileConfig, + fileManager = FakeVoiceFileManager(fakeFileSystem, fileConfig, FILE_ID), + audioLevelCalculator = FakeAudioLevelCalculator(), + sessionCoroutineScope = backgroundScope, + ) + } + + companion object { + const val FILE_ID: String = "recording" + const val FILE_PATH = "voice_recordings/$FILE_ID.ogg" + private lateinit var audioFormat: AudioFormat + + // FakeEncoder doesn't actually encode, it just writes the data to the file + private const val ENCODED_DATA = "[32767, 32767, 32767][32767, 32767, 32767]" + private const val MAX_AMP = Short.MAX_VALUE + private val AUDIO = listOf( + Audio.Data(3, shortArrayOf(MAX_AMP, MAX_AMP, MAX_AMP)), + Audio.Error(-1), + Audio.Data(3, shortArrayOf(MAX_AMP, MAX_AMP, MAX_AMP)), + ) + + @BeforeClass + @JvmStatic + fun initAudioFormat() { + audioFormat = mockk() + } + } +} diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DBovAudioLevelCalculatorTest.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DBovAudioLevelCalculatorTest.kt new file mode 100644 index 0000000..7fd8c6e --- /dev/null +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/DBovAudioLevelCalculatorTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.impl.audio + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class DBovAudioLevelCalculatorTest { + @Test + fun `given max values, it returns 1`() { + val calculator = DBovAudioLevelCalculator() + val buffer = ShortArray(100) { Short.MAX_VALUE } + val level = calculator.calculateAudioLevel(buffer) + assertThat(level).isEqualTo(1.0f) + } + + @Test + fun `given mixed values, it returns values within range`() { + val calculator = DBovAudioLevelCalculator() + val buffer = shortArrayOf(100, -200, 300, -400, 500, -600, 700, -800, 900, -1000) + val level = calculator.calculateAudioLevel(buffer) + assertThat(level).apply { + isGreaterThan(0f) + isLessThan(1f) + } + } + + @Test + fun `given min values, it returns 0`() { + val calculator = DBovAudioLevelCalculator() + val buffer = ShortArray(100) { 0 } + val level = calculator.calculateAudioLevel(buffer) + assertThat(level).isEqualTo(0.0f) + } +} diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/ResampleTest.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/ResampleTest.kt new file mode 100644 index 0000000..9178080 --- /dev/null +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/ResampleTest.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.impl.audio + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ResampleTest { + @Test + fun `resample works`() { + listOf(0.0f).resample(10).let { + assertThat(it).isEqualTo(listOf(0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f)) + } + listOf(1.0f).resample(10).let { + assertThat(it).isEqualTo(listOf(1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f)) + } + listOf(0.0f, 1.0f).resample(10).let { + assertThat(it).isEqualTo(listOf(0.0f, 0.2f, 0.4f, 0.6f, 0.8f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f)) + } + listOf(0.0f, 0.5f, 1.0f).resample(10).let { + assertThat(it).isEqualTo(listOf(0.0f, 0.15f, 0.3f, 0.45000002f, 0.6f, 0.75f, 0.90000004f, 1.0f, 1.0f, 1.0f)) + } + List(100) { it.toFloat() }.resample(10).let { + assertThat(it).isEqualTo(listOf(0.0f, 10.0f, 20.0f, 30.0f, 40.0f, 50.0f, 60.0f, 70.0f, 80.0f, 90.0f)) + } + } +} diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioLevelCalculator.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioLevelCalculator.kt new file mode 100644 index 0000000..48e12c3 --- /dev/null +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioLevelCalculator.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.test + +import io.element.android.libraries.voicerecorder.impl.audio.AudioLevelCalculator +import kotlin.math.abs + +class FakeAudioLevelCalculator : AudioLevelCalculator { + override fun calculateAudioLevel(buffer: ShortArray): Float { + return buffer.map { abs(it.toFloat()) }.average().toFloat() / Short.MAX_VALUE + } +} diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReader.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReader.kt new file mode 100644 index 0000000..b14bb40 --- /dev/null +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReader.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.voicerecorder.test + +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.voicerecorder.impl.audio.Audio +import io.element.android.libraries.voicerecorder.impl.audio.AudioReader +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield + +class FakeAudioReader( + private val dispatchers: CoroutineDispatchers, + private val audio: List